diff --git a/client/asset/eth/rpcclient.go b/client/asset/eth/rpcclient.go index 80ec02e24c..f405c9258c 100644 --- a/client/asset/eth/rpcclient.go +++ b/client/asset/eth/rpcclient.go @@ -36,6 +36,7 @@ type rpcclient struct { // ec wraps the client with some useful calls. ec *ethclient.Client n *node.Node + // es is a wrapper for contract calls. es *swap.ETHSwap } diff --git a/server/asset/eth/coiner.go b/server/asset/eth/coiner.go new file mode 100644 index 0000000000..266d9509eb --- /dev/null +++ b/server/asset/eth/coiner.go @@ -0,0 +1,257 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +//go:build lgpl +// +build lgpl + +package eth + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "math/big" + + dexeth "decred.org/dcrdex/dex/networks/eth" + "decred.org/dcrdex/server/asset" + "github.com/ethereum/go-ethereum/common" +) + +type swapCoinType uint8 + +const ( + sctInit swapCoinType = iota + sctRedeem +) + +var _ asset.Coin = (*swapCoin)(nil) + +type swapCoin struct { + backend *Backend + contractAddr, counterParty common.Address + secret, secretHash [32]byte + value uint64 + gasPrice uint64 + txid string + locktime int64 + sct swapCoinType +} + +// newSwapCoin creates a new swapCoin that stores and retrieves info about a +// swap. It requires a coinID that is a txid type of the initial transaction +// initializing or redeeming the swap. A txid type and not a swap type is +// required because the contract will give us no useful information about the +// swap before it is mined. Having the initial transaction allows us to track +// it in the mempool. It also tells us all the data we need to confirm a tx +// will do what we expect if mined and satisfies contract constraints. These +// fields are verified the first time the Confirmations method is called, and +// an error is returned then if something is different than expected. As such, +// the swapCoin expects Confirmations to be called with confirmations +// available at least once before the swap be trusted for swap initializations. +func newSwapCoin(backend *Backend, coinID []byte, sct swapCoinType) (*swapCoin, error) { + switch sct { + case sctInit, sctRedeem: + default: + return nil, fmt.Errorf("unknown swapCoin type: %d", sct) + } + + cID, err := DecodeCoinID(coinID) + if err != nil { + return nil, err + } + txCoinID, ok := cID.(*TxCoinID) + if !ok { + return nil, errors.New("coin ID not a txid") + } + txid := txCoinID.TxID + tx, _, err := backend.node.transaction(backend.rpcCtx, txid) + if err != nil { + return nil, fmt.Errorf("unable to fetch transaction: %v", err) + } + + txdata := tx.Data() + // Transactions that call contract functions must have extra data to do + // so. + if len(txdata) == 0 { + return nil, errors.New("tx calling contract function has no extra data") + } + + var ( + counterParty = new(common.Address) + secret, secretHash [32]byte + locktime int64 + ) + + switch sct { + case sctInit: + counterParty, secretHash, locktime, err = dexeth.ParseInitiateData(txdata) + case sctRedeem: + secret, secretHash, err = dexeth.ParseRedeemData(txdata) + } + if err != nil { + return nil, fmt.Errorf("unable to parse call data: %v", err) + } + + contractAddr := tx.To() + if *contractAddr != backend.contractAddr { + return nil, errors.New("contract address is not supported") + } + + // Gas price is not stored in the swap, and is used to determine if the + // initialization transaction could take a long time to be mined. A + // transaction with a very low gas price may need to be resent with a + // higher price. + gasPrice, err := ToGwei(tx.GasPrice()) + if err != nil { + return nil, fmt.Errorf("unable to convert gas price: %v", err) + } + + // Value is stored in the swap with the initialization transaction. + value, err := ToGwei(tx.Value()) + if err != nil { + return nil, fmt.Errorf("unable to convert value: %v", err) + } + + // For redemptions, the transaction should move no value. + if sct == sctRedeem && value != 0 { + return nil, fmt.Errorf("expected swapCoin value of zero for redeem but got: %d", value) + } + + return &swapCoin{ + backend: backend, + contractAddr: *contractAddr, + secret: secret, + secretHash: secretHash, + value: value, + gasPrice: gasPrice, + txid: hex.EncodeToString(txid[:]), + counterParty: *counterParty, + locktime: locktime, + sct: sct, + }, nil +} + +// Confirmations returns the number of confirmations for a Coin's +// transaction. +// +// In the case of ethereum it is extra important to check confirmations before +// confirming a swap. Even though we check the initial transaction's data, if +// that transaction were in mempool at the time, it could be swapped out with +// any other values if a user sent another transaction with a higher gas fee +// and the same account and nonce, effectivly voiding the transaction we +// expected to be mined. +func (c *swapCoin) Confirmations(_ context.Context) (int64, error) { + swap, err := c.backend.node.swap(c.backend.rpcCtx, c.secretHash) + if err != nil { + return -1, err + } + + switch c.sct { + case sctRedeem: + // There should be no need to check the counter party, or value + // as a swap with a specific secret hash that has been redeemed + // wouldn't have been redeemed without ensuring the initiator + // is the expected address and value was also as expected. Also + // not validating the locktime, as the swap is redeemed and + // locktime no longer relevant. + ss := SwapState(swap.State) + if ss == SSRedeemed { + // While not completely accurate, we know that if the + // swap is redeemed the redemption has at least one + // confirmation. + return 1, nil + } + // If swap is in the Initiated state, the transaction may be + // unmined. + if ss == SSInitiated { + // Assume the tx still has a chance of being mined. + return 0, nil + } + // If swap is in None state, then the redemption can't possibly + // succeed as the swap must already be in the Initialized state + // to redeem. If the swap is in the Refunded state, then the + // redemption either failed or never happened. + return -1, fmt.Errorf("redemption in failed state with swap at %s state", ss) + + case sctInit: + // Uninitiated state is zero confs. It could still be in mempool. + // It is important to only trust confirmations according to the + // swap contract. Until there are confirmations we cannot be sure + // that initiation happened successfuly. + if SwapState(swap.State) == SSNone { + // Assume the tx still has a chance of being mined. + return 0, nil + } + // Any other swap state is ok. We are sure that initialization + // happened. + + // The swap initiation transaction has some number of + // confirmations, and we are sure the secret hash belongs to + // this swap. Assert that the value, reciever, and locktime are + // as expected. + value, err := ToGwei(big.NewInt(0).Set(swap.Value)) + if err != nil { + return -1, fmt.Errorf("unable to convert value: %v", err) + } + if value != c.value { + return -1, fmt.Errorf("expected swap val (%dgwei) does not match expected (%dgwei)", + c.value, value) + } + if swap.Participant != c.counterParty { + return -1, fmt.Errorf("expected swap participant %q does not match expected %q", + c.counterParty, swap.Participant) + } + if !swap.RefundBlockTimestamp.IsInt64() { + return -1, errors.New("swap locktime is larger than expected") + } + locktime := swap.RefundBlockTimestamp.Int64() + if locktime != c.locktime { + return -1, fmt.Errorf("expected swap locktime (%d) does not match expected (%d)", + c.locktime, locktime) + } + + bn, err := c.backend.node.blockNumber(c.backend.rpcCtx) + if err != nil { + return 0, fmt.Errorf("unable to fetch block number: %v", err) + } + return int64(bn - swap.InitBlockNumber.Uint64()), nil + } + + return -1, fmt.Errorf("unsupported swap type for confirmations: %d", c.sct) +} + +// ID is the swap's coin ID. +func (c *swapCoin) ID() []byte { + sc := &SwapCoinID{ + ContractAddress: c.contractAddr, + SecretHash: c.secretHash, + } + return sc.Encode() +} + +// TxID is the original init transaction txid. +func (c *swapCoin) TxID() string { + return c.txid +} + +// String is a human readable representation of the swap coin. +func (c *swapCoin) String() string { + sc := &SwapCoinID{ + ContractAddress: c.contractAddr, + SecretHash: c.secretHash, + } + return sc.String() +} + +// Value is the amount paid to the swap, set in initialization. Always zero for +// redemptions. +func (c *swapCoin) Value() uint64 { + return c.value +} + +// FeeRate returns the gas rate, in gwei/gas. It is set in initialization of +// the swapCoin. +func (c *swapCoin) FeeRate() uint64 { + return c.gasPrice +} diff --git a/server/asset/eth/coiner_test.go b/server/asset/eth/coiner_test.go new file mode 100644 index 0000000000..0aef8581b8 --- /dev/null +++ b/server/asset/eth/coiner_test.go @@ -0,0 +1,331 @@ +//go:build !harness && lgpl +// +build !harness,lgpl + +// These tests will not be run if the harness build tag is set. + +package eth + +import ( + "encoding/hex" + "errors" + "math/big" + "testing" + + "decred.org/dcrdex/dex/encode" + dexeth "decred.org/dcrdex/dex/networks/eth" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +func overMaxWei() *big.Int { + maxInt := ^uint64(0) + maxWei := new(big.Int).SetUint64(maxInt) + gweiFactorBig := big.NewInt(GweiFactor) + maxWei.Mul(maxWei, gweiFactorBig) + overMaxWei := new(big.Int).Set(maxWei) + return overMaxWei.Add(overMaxWei, gweiFactorBig) +} + +func TestNewSwapCoin(t *testing.T) { + receiverAddr, contractAddr, randomAddr := new(common.Address), new(common.Address), new(common.Address) + copy(receiverAddr[:], encode.RandomBytes(20)) + copy(contractAddr[:], encode.RandomBytes(20)) + copy(randomAddr[:], encode.RandomBytes(20)) + secret, secretHash, txHash := [32]byte{}, [32]byte{}, [32]byte{} + copy(txHash[:], encode.RandomBytes(32)) + copy(secret[:], secretSlice) + copy(secretHash[:], secretHashSlice) + tc := TxCoinID{ + TxID: txHash, + } + txCoinIDBytes := tc.Encode() + sc := SwapCoinID{} + swapCoinIDBytes := sc.Encode() + gasPrice := big.NewInt(3e10) + value := big.NewInt(5e18) + wantGas, err := ToGwei(big.NewInt(3e10)) + if err != nil { + t.Fatal(err) + } + wantVal, err := ToGwei(big.NewInt(5e18)) + if err != nil { + t.Fatal(err) + } + tests := []struct { + name string + coinID []byte + tx *types.Transaction + ct swapCoinType + swpErr, txErr error + wantErr bool + }{{ + name: "ok init", + tx: tTx(gasPrice, value, contractAddr, initCalldata), + coinID: txCoinIDBytes, + ct: sctInit, + }, { + name: "ok redeem", + tx: tTx(gasPrice, big.NewInt(0), contractAddr, redeemCalldata), + coinID: txCoinIDBytes, + ct: sctRedeem, + }, { + name: "unknown coin type", + tx: tTx(gasPrice, value, contractAddr, initCalldata), + coinID: txCoinIDBytes, + ct: swapCoinType(^uint8(0)), + wantErr: true, + }, { + name: "tx has no data", + tx: tTx(gasPrice, value, contractAddr, nil), + coinID: txCoinIDBytes, + ct: sctInit, + wantErr: true, + }, { + name: "non zero value with redeem", + tx: tTx(gasPrice, value, contractAddr, redeemCalldata), + coinID: txCoinIDBytes, + ct: sctRedeem, + wantErr: true, + }, { + name: "unable to decode init data, must be init for init coin type", + tx: tTx(gasPrice, value, contractAddr, redeemCalldata), + coinID: txCoinIDBytes, + ct: sctInit, + wantErr: true, + }, { + name: "unable to decode redeem data, must be redeem for redeem coin type", + tx: tTx(gasPrice, big.NewInt(0), contractAddr, initCalldata), + coinID: txCoinIDBytes, + ct: sctRedeem, + wantErr: true, + }, { + name: "unable to decode CoinID", + tx: tTx(gasPrice, value, contractAddr, initCalldata), + ct: sctInit, + wantErr: true, + }, { + name: "wrong type of coinID", + tx: tTx(gasPrice, value, contractAddr, initCalldata), + coinID: swapCoinIDBytes, + ct: sctInit, + wantErr: true, + }, { + name: "transaction error", + tx: tTx(gasPrice, value, contractAddr, initCalldata), + coinID: txCoinIDBytes, + txErr: errors.New(""), + ct: sctInit, + wantErr: true, + }, { + name: "wrong contract", + tx: tTx(gasPrice, value, randomAddr, initCalldata), + coinID: txCoinIDBytes, + ct: sctInit, + wantErr: true, + }, { + name: "value too big", + tx: tTx(gasPrice, overMaxWei(), contractAddr, initCalldata), + coinID: txCoinIDBytes, + ct: sctInit, + wantErr: true, + }, { + name: "gas too big", + tx: tTx(overMaxWei(), value, contractAddr, initCalldata), + coinID: txCoinIDBytes, + ct: sctInit, + wantErr: true, + }} + for _, test := range tests { + node := &testNode{ + tx: test.tx, + txErr: test.txErr, + } + eth := &Backend{ + node: node, + log: tLogger, + contractAddr: *contractAddr, + } + sc, err := newSwapCoin(eth, test.coinID, test.ct) + if test.wantErr { + if err == nil { + t.Fatalf("expected error for test %q", test.name) + } + continue + } + if err != nil { + t.Fatalf("unexpected error for test %q: %v", test.name, err) + } + + wv := wantVal + var ws [32]byte + lt := initLocktime + sct := sctInit + cp := initParticipantAddr + if test.ct == sctRedeem { + wv = 0 + ws = secret + lt = 0 + sct = sctRedeem + cp = common.Address{} + } + if sc.contractAddr != *contractAddr || + sc.counterParty != cp || + sc.secretHash != secretHash || + sc.secret != ws || + sc.value != wv || + sc.gasPrice != wantGas || + sc.locktime != lt || + sc.sct != sct || + sc.txid != hex.EncodeToString(txHash[:]) { + t.Fatalf("returns do not match expected for test %q", test.name) + } + } +} + +func TestConfirmations(t *testing.T) { + contractAddr, nullAddr := new(common.Address), new(common.Address) + copy(contractAddr[:], encode.RandomBytes(20)) + secretHash, txHash := [32]byte{}, [32]byte{} + copy(txHash[:], encode.RandomBytes(32)) + copy(secretHash[:], secretHashSlice) + tc := TxCoinID{ + TxID: txHash, + } + txCoinIDBytes := tc.Encode() + gasPrice := big.NewInt(3e10) + value := big.NewInt(5e18) + locktime := big.NewInt(initLocktime) + bigO := big.NewInt(0) + oneGweiMore := big.NewInt(1e9) + oneGweiMore.Add(oneGweiMore, value) + tests := []struct { + name string + swap *dexeth.ETHSwapSwap + bn uint64 + value *big.Int + ct swapCoinType + wantConfs int64 + swapErr, bnErr error + wantErr, setCT bool + }{{ + name: "ok has confs value not verified", + bn: 100, + swap: tSwap(97, locktime, value, SSInitiated, &initParticipantAddr), + value: value, + ct: sctInit, + wantConfs: 3, + }, { + name: "ok no confs", + swap: tSwap(0, bigO, bigO, SSNone, nullAddr), + value: value, + ct: sctInit, + }, { + name: "ok redeem swap status redeemed", + swap: tSwap(97, locktime, value, SSRedeemed, &initParticipantAddr), + value: bigO, + ct: sctRedeem, + wantConfs: 1, + }, { + name: "ok redeem swap status initiated", + swap: tSwap(97, locktime, value, SSInitiated, &initParticipantAddr), + value: bigO, + ct: sctRedeem, + }, { + name: "unknown coin type", + ct: sctInit, + setCT: true, + wantErr: true, + }, { + name: "redeem bad swap state None", + swap: tSwap(0, bigO, bigO, SSNone, nullAddr), + value: bigO, + ct: sctRedeem, + wantErr: true, + }, { + name: "error getting swap", + swapErr: errors.New(""), + value: value, + ct: sctInit, + wantErr: true, + }, { + name: "swap value causes ToGwei error", + swap: tSwap(99, locktime, overMaxWei(), SSInitiated, &initParticipantAddr), + value: value, + ct: sctInit, + wantErr: true, + }, { + name: "value differs from initial transaction", + swap: tSwap(99, locktime, oneGweiMore, SSInitiated, &initParticipantAddr), + value: value, + ct: sctInit, + wantErr: true, + }, { + name: "participant differs from initial transaction", + swap: tSwap(99, locktime, value, SSInitiated, nullAddr), + value: value, + ct: sctInit, + wantErr: true, + }, { + name: "locktime not an int64", + swap: tSwap(99, new(big.Int).SetUint64(^uint64(0)), value, SSInitiated, &initParticipantAddr), + value: value, + ct: sctInit, + wantErr: true, + }, { + name: "locktime differs from initial transaction", + swap: tSwap(99, bigO, value, SSInitiated, &initParticipantAddr), + value: value, + ct: sctInit, + wantErr: true, + }, { + name: "block number error", + swap: tSwap(97, locktime, value, SSInitiated, &initParticipantAddr), + value: value, + ct: sctInit, + bnErr: errors.New(""), + wantErr: true, + }} + for _, test := range tests { + txdata := initCalldata + if test.ct == sctRedeem { + txdata = redeemCalldata + } + node := &testNode{ + tx: tTx(gasPrice, test.value, contractAddr, txdata), + swp: test.swap, + swpErr: test.swapErr, + blkNum: test.bn, + blkNumErr: test.bnErr, + } + eth := &Backend{ + node: node, + log: tLogger, + contractAddr: *contractAddr, + } + + sc, err := newSwapCoin(eth, txCoinIDBytes, test.ct) + if err != nil { + t.Fatalf("unexpected error for test %q: %v", test.name, err) + } + + _ = sc.String() // unrelated panic test + + if test.setCT { + sc.sct = swapCoinType(^uint8(0)) + } + + confs, err := sc.Confirmations(nil) + if test.wantErr { + if err == nil { + t.Fatalf("expected error for test %q", test.name) + } + continue + } + if err != nil { + t.Fatalf("unexpected error for test %q: %v", test.name, err) + } + if confs != test.wantConfs { + t.Fatalf("want %d but got %d confs for test: %v", test.wantConfs, confs, test.name) + } + } +} diff --git a/server/asset/eth/common.go b/server/asset/eth/common.go index 1cd7592abb..36d1f8c58f 100644 --- a/server/asset/eth/common.go +++ b/server/asset/eth/common.go @@ -258,7 +258,8 @@ const ( ) // ToGwei converts a *big.Int in wei (1e18 unit) to gwei (1e9 unit) as a uint64. -// Errors if the amount of gwei is too big to fit fully into a uint64. +// Errors if the amount of gwei is too big to fit fully into a uint64. The +// passed wei parameter value is changed and is no longer useable. func ToGwei(wei *big.Int) (uint64, error) { gweiFactorBig := big.NewInt(GweiFactor) wei.Div(wei, gweiFactorBig) diff --git a/server/asset/eth/config.go b/server/asset/eth/config.go index 7dc4a69bb6..41fa7acb0d 100644 --- a/server/asset/eth/config.go +++ b/server/asset/eth/config.go @@ -20,14 +20,16 @@ var ( ) type config struct { - // IPC is the location of the inner process communication socket. - IPC string `long:"ipc" description:"Location of the geth ipc socket."` + // ipc is the location of the inner process communication socket. + ipc string + // network is the network the dex is meant to be running on. + network dex.Network } // load checks the network and sets the ipc location if not supplied. // // TODO: Test this with windows. -func load(IPC string, network dex.Network) (*config, error) { +func load(ipc string, network dex.Network) (*config, error) { switch network { case dex.Simnet: case dex.Testnet: @@ -39,11 +41,12 @@ func load(IPC string, network dex.Network) (*config, error) { } cfg := &config{ - IPC: IPC, + ipc: ipc, + network: network, } - if cfg.IPC == "" { - cfg.IPC = defaultIPC + if cfg.ipc == "" { + cfg.ipc = defaultIPC } return cfg, nil diff --git a/server/asset/eth/eth.go b/server/asset/eth/eth.go index e4d4d1b2d7..c2ced80bf6 100644 --- a/server/asset/eth/eth.go +++ b/server/asset/eth/eth.go @@ -15,6 +15,8 @@ import ( "time" "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/encode" + swap "decred.org/dcrdex/dex/networks/eth" "decred.org/dcrdex/server/asset" "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" @@ -32,6 +34,12 @@ const ( // The blockPollInterval is the delay between calls to bestBlockHash to // check for new blocks. blockPollInterval = time.Second + // TODO: Fill in with an addresses. Also consider upgrades where one + // contract will be good for current active swaps, but a new one is + // required for new swaps. + mainnetContractAddr = "" + testnetContractAddr = "" + simnetContractAddr = "" ) var ( @@ -64,16 +72,23 @@ func (d *Driver) DecodeCoinID(coinID []byte) (string, error) { // ethFetcher represents a blockchain information fetcher. In practice, it is // satisfied by rpcclient. For testing, it can be satisfied by a stub. +// +// TODO: At some point multiple contracts will need to be used, at least for +// transitory periods when updating the contract, and possibly a random +// contract setup, and so contract addresses may need to be an argument in some +// of these methods. type ethFetcher interface { bestBlockHash(ctx context.Context) (common.Hash, error) bestHeader(ctx context.Context) (*types.Header, error) block(ctx context.Context, hash common.Hash) (*types.Block, error) - connect(ctx context.Context, IPC string) error + connect(ctx context.Context, ipc string, contractAddr *common.Address) error shutdown() suggestGasPrice(ctx context.Context) (*big.Int, error) syncProgress(ctx context.Context) (*ethereum.SyncProgress, error) blockNumber(ctx context.Context) (uint64, error) peers(ctx context.Context) ([]*p2p.PeerInfo, error) + swap(ctx context.Context, secretHash [32]byte) (*swap.ETHSwapSwap, error) + transaction(ctx context.Context, hash common.Hash) (tx *types.Transaction, isMempool bool, err error) } // Backend is an asset backend for Ethereum. It has methods for fetching output @@ -97,6 +112,12 @@ type Backend struct { // A logger will be provided by the DEX. All logging should use the provided // logger. log dex.Logger + // contractAddr is the address of the swap contract used for swaps. + // + // TODO: Allow supporting multiple addresses/contracts. Needed in the + // case of updating where two contracts may be valid for some time, + // possibly disallowing initialization for the deprecated one only. + contractAddr common.Address } // Check that Backend satisfies the Backend interface. @@ -106,13 +127,27 @@ var _ asset.Backend = (*Backend)(nil) // before use. func unconnectedETH(logger dex.Logger, cfg *config) *Backend { ctx, cancel := context.WithCancel(context.Background()) + // TODO: At some point multiple contracts will need to be used, at + // least for transitory periods when updating the contract, and + // possibly a random contract setup, and so this section will need to + // change to support multiple contracts. + var contractAddr common.Address + switch cfg.network { + case dex.Simnet: + contractAddr = common.HexToAddress(simnetContractAddr) + case dex.Testnet: + contractAddr = common.HexToAddress(testnetContractAddr) + case dex.Mainnet: + contractAddr = common.HexToAddress(mainnetContractAddr) + } return &Backend{ - rpcCtx: ctx, - cancelRPCs: cancel, - cfg: cfg, - blockCache: newBlockCache(logger), - log: logger, - blockChans: make(map[chan *asset.BlockUpdate]struct{}), + rpcCtx: ctx, + cancelRPCs: cancel, + cfg: cfg, + blockCache: newBlockCache(logger), + log: logger, + blockChans: make(map[chan *asset.BlockUpdate]struct{}), + contractAddr: contractAddr, } } @@ -133,7 +168,7 @@ func (eth *Backend) shutdown() { // Connect connects to the node RPC server and initializes some variables. func (eth *Backend) Connect(ctx context.Context) (*sync.WaitGroup, error) { c := rpcclient{} - if err := c.connect(ctx, eth.cfg.IPC); err != nil { + if err := c.connect(ctx, eth.cfg.ipc, ð.contractAddr); err != nil { return nil, err } eth.node = &c @@ -206,8 +241,23 @@ func (eth *Backend) BlockChannel(size int) <-chan *asset.BlockUpdate { } // Contract is part of the asset.Backend interface. -func (eth *Backend) Contract(coinID []byte, redeemScript []byte) (*asset.Contract, error) { - return nil, notImplementedErr +func (eth *Backend) Contract(coinID, _ []byte) (*asset.Contract, error) { + sc, err := newSwapCoin(eth, coinID, sctInit) + if err != nil { + return nil, fmt.Errorf("unable to create coiner: %v", err) + } + // Confirmations performs some extra swap status checks if the the tx + // is mined. + _, err = sc.Confirmations(eth.rpcCtx) + if err != nil { + return nil, fmt.Errorf("unable to get confirmations: %v", err) + } + return &asset.Contract{ + Coin: sc, + SwapAddress: sc.counterParty.String(), + RedeemScript: sc.secretHash[:], + LockTime: encode.UnixTimeMilli(sc.locktime), + }, nil } // ValidateSecret checks that the secret satisfies the contract. diff --git a/server/asset/eth/eth_test.go b/server/asset/eth/eth_test.go index d0d26ca441..e2db58b35a 100644 --- a/server/asset/eth/eth_test.go +++ b/server/asset/eth/eth_test.go @@ -9,6 +9,7 @@ import ( "bytes" "context" "encoding/binary" + "encoding/hex" "errors" "math/big" "testing" @@ -17,6 +18,8 @@ import ( "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/calc" "decred.org/dcrdex/dex/encode" + dexeth "decred.org/dcrdex/dex/networks/eth" + swap "decred.org/dcrdex/dex/networks/eth" "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" @@ -24,10 +27,29 @@ import ( ) var ( - _ ethFetcher = (*testNode)(nil) - tLogger = dex.StdOutLogger("ETHTEST", dex.LevelTrace) + _ ethFetcher = (*testNode)(nil) + tLogger = dex.StdOutLogger("ETHTEST", dex.LevelTrace) + initCalldata = mustParseHex("ae0521470000000000000000000000000000000000" + + "000000000000000000000061481114ebdc4c31b88d0c8f4d644591a8e00" + + "e92b607f920ad8050deb7c7469767d9c561000000000000000000000000" + + "345853e21b1d475582e71cc269124ed5e2dd3422") + redeemCalldata = mustParseHex("b31597ad87eac09638c0c38b4e735b79f053" + + "cb869167ee770640ac5df5c4ab030813122aebdc4c31b88d0c8f4d64459" + + "1a8e00e92b607f920ad8050deb7c7469767d9c561") + initParticipantAddr = common.HexToAddress("345853e21b1d475582E71cC269124eD5e2dD3422") + initLocktime = int64(1632112916) + secretHashSlice = mustParseHex("ebdc4c31b88d0c8f4d644591a8e00e92b607f920ad8050deb7c7469767d9c561") + secretSlice = mustParseHex("87eac09638c0c38b4e735b79f053cb869167ee770640ac5df5c4ab030813122a") ) +func mustParseHex(s string) []byte { + b, err := hex.DecodeString(s) + if err != nil { + panic(err) + } + return b +} + type testNode struct { connectErr error bestBlkHash common.Hash @@ -44,9 +66,14 @@ type testNode struct { sugGasPriceErr error peerInfo []*p2p.PeerInfo peersErr error + swp *swap.ETHSwapSwap + swpErr error + tx *types.Transaction + txIsMempool bool + txErr error } -func (n *testNode) connect(ctx context.Context, IPC string) error { +func (n *testNode) connect(ctx context.Context, ipc string, contractAddr *common.Address) error { return n.connectErr } @@ -80,31 +107,49 @@ func (n *testNode) suggestGasPrice(ctx context.Context) (*big.Int, error) { return n.sugGasPrice, n.sugGasPriceErr } +func (n *testNode) swap(ctx context.Context, secretHash [32]byte) (*swap.ETHSwapSwap, error) { + return n.swp, n.swpErr +} + +func (n *testNode) transaction(ctx context.Context, hash common.Hash) (tx *types.Transaction, isMempool bool, err error) { + return n.tx, n.txIsMempool, n.txErr +} + +func tSwap(bn int64, locktime, value *big.Int, state SwapState, participantAddr *common.Address) *swap.ETHSwapSwap { + return &swap.ETHSwapSwap{ + InitBlockNumber: big.NewInt(bn), + RefundBlockTimestamp: locktime, + Participant: *participantAddr, + State: uint8(state), + Value: value, + } +} + func TestLoad(t *testing.T) { tests := []struct { - name, IPC, wantIPC string + name, ipc, wantIPC string network dex.Network wantErr bool }{{ name: "ok ipc supplied", - IPC: "/home/john/bleh.ipc", + ipc: "/home/john/bleh.ipc", wantIPC: "/home/john/bleh.ipc", network: dex.Simnet, }, { name: "ok ipc not supplied", - IPC: "", + ipc: "", wantIPC: defaultIPC, network: dex.Simnet, }, { name: "mainnet not allowed", - IPC: "", + ipc: "", wantIPC: defaultIPC, network: dex.Mainnet, wantErr: true, }} for _, test := range tests { - cfg, err := load(test.IPC, test.network) + cfg, err := load(test.ipc, test.network) if test.wantErr { if err == nil { t.Fatalf("expected error for test %v", test.name) @@ -114,8 +159,8 @@ func TestLoad(t *testing.T) { if err != nil { t.Fatalf("unexpected error for test %v: %v", test.name, err) } - if cfg.IPC != test.wantIPC { - t.Fatalf("want ipc value of %v but got %v for test %v", test.wantIPC, cfg.IPC, test.name) + if cfg.ipc != test.wantIPC { + t.Fatalf("want ipc value of %v but got %v for test %v", test.wantIPC, cfg.ipc, test.name) } } } @@ -237,7 +282,8 @@ func TestRun(t *testing.T) { bestBlkHash: blockHash1, blk: block1, } - backend := unconnectedETH(tLogger, nil) + + backend := unconnectedETH(tLogger, new(config)) ch := backend.BlockChannel(1) backend.node = node go func() { @@ -403,3 +449,81 @@ func TestRequiredOrderFunds(t *testing.T) { t.Fatalf("want %v got %v for fees", want, got) } } + +func tTx(gasPrice, value *big.Int, to *common.Address, data []byte) *types.Transaction { + return types.NewTx(&types.LegacyTx{ + GasPrice: gasPrice, + To: to, + Value: value, + Data: data, + }) +} + +func TestContract(t *testing.T) { + receiverAddr, contractAddr := new(common.Address), new(common.Address) + copy(receiverAddr[:], encode.RandomBytes(20)) + copy(contractAddr[:], encode.RandomBytes(20)) + var txHash [32]byte + copy(txHash[:], encode.RandomBytes(32)) + gasPrice := big.NewInt(3e10) + value := big.NewInt(5e18) + tc := TxCoinID{ + TxID: txHash, + } + txCoinIDBytes := tc.Encode() + sc := SwapCoinID{} + swapCoinIDBytes := sc.Encode() + locktime := big.NewInt(initLocktime) + tests := []struct { + name string + coinID []byte + tx *types.Transaction + swap *dexeth.ETHSwapSwap + swapErr, txErr error + wantErr bool + }{{ + name: "ok", + tx: tTx(gasPrice, value, contractAddr, initCalldata), + swap: tSwap(97, locktime, value, SSInitiated, &initParticipantAddr), + coinID: txCoinIDBytes, + }, { + name: "new coiner error, wrong tx type", + tx: tTx(gasPrice, value, contractAddr, initCalldata), + swap: tSwap(97, locktime, value, SSInitiated, &initParticipantAddr), + coinID: swapCoinIDBytes, + wantErr: true, + }, { + name: "confirmations error, swap error", + tx: tTx(gasPrice, value, contractAddr, initCalldata), + coinID: txCoinIDBytes, + swapErr: errors.New(""), + wantErr: true, + }} + for _, test := range tests { + node := &testNode{ + tx: test.tx, + txErr: test.txErr, + swp: test.swap, + swpErr: test.swapErr, + } + eth := &Backend{ + node: node, + log: tLogger, + contractAddr: *contractAddr, + } + contract, err := eth.Contract(test.coinID, nil) + if test.wantErr { + if err == nil { + t.Fatalf("expected error for test %q", test.name) + } + continue + } + if err != nil { + t.Fatalf("unexpected error for test %q: %v", test.name, err) + } + if contract.SwapAddress != initParticipantAddr.String() || + contract.LockTime.Unix() != initLocktime/1000 { + t.Fatalf("returns do not match expected for test %q", test.name) + } + } +} diff --git a/server/asset/eth/rpcclient.go b/server/asset/eth/rpcclient.go index 0cb13aad8e..a829570e3d 100644 --- a/server/asset/eth/rpcclient.go +++ b/server/asset/eth/rpcclient.go @@ -11,7 +11,9 @@ import ( "fmt" "math/big" + swap "decred.org/dcrdex/dex/networks/eth" "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" @@ -27,16 +29,22 @@ type rpcclient struct { ec *ethclient.Client // c is a direct client for raw calls. c *rpc.Client + // es is a wrapper for contract calls. + es *swap.ETHSwap } // connect connects to an ipc socket. It then wraps ethclient's client and // bundles commands in a form we can easil use. -func (c *rpcclient) connect(ctx context.Context, IPC string) error { +func (c *rpcclient) connect(ctx context.Context, IPC string, contractAddr *common.Address) error { client, err := rpc.DialIPC(ctx, IPC) if err != nil { return fmt.Errorf("unable to dial rpc: %v", err) } c.ec = ethclient.NewClient(client) + c.es, err = swap.NewETHSwap(*contractAddr, c.ec) + if err != nil { + return fmt.Errorf("unable to find swap contract: %v", err) + } c.c = client return nil } @@ -83,7 +91,7 @@ func (c *rpcclient) syncProgress(ctx context.Context) (*ethereum.SyncProgress, e return c.ec.SyncProgress(ctx) } -// blockNumber returns the current block number. +// blockNumber gets the chain length at the time of calling. func (c *rpcclient) blockNumber(ctx context.Context) (uint64, error) { return c.ec.BlockNumber(ctx) } @@ -93,3 +101,22 @@ func (c *rpcclient) peers(ctx context.Context) ([]*p2p.PeerInfo, error) { var peers []*p2p.PeerInfo return peers, c.c.CallContext(ctx, &peers, "admin_peers") } + +// swap gets a swap keyed by secretHash in the contract. +func (c *rpcclient) swap(ctx context.Context, secretHash [32]byte) (*swap.ETHSwapSwap, error) { + callOpts := &bind.CallOpts{ + Pending: true, + Context: ctx, + } + swap, err := c.es.Swap(callOpts, secretHash) + if err != nil { + return nil, err + } + return &swap, nil +} + +// transaction gets the transaction that hashes to hash from the chain or +// mempool. Errors if tx does not exist. +func (c *rpcclient) transaction(ctx context.Context, hash common.Hash) (tx *types.Transaction, isMempool bool, err error) { + return c.ec.TransactionByHash(ctx, hash) +} diff --git a/server/asset/eth/rpcclient_harness_test.go b/server/asset/eth/rpcclient_harness_test.go index e18ac75b30..6092ea8d21 100644 --- a/server/asset/eth/rpcclient_harness_test.go +++ b/server/asset/eth/rpcclient_harness_test.go @@ -13,13 +13,17 @@ import ( "context" "testing" + + "decred.org/dcrdex/dex/encode" + "github.com/ethereum/go-ethereum/common" ) var ( - homeDir = os.Getenv("HOME") - ipc = filepath.Join(homeDir, "dextest/eth/alpha/node/geth.ipc") - ethClient = new(rpcclient) - ctx context.Context + homeDir = os.Getenv("HOME") + ipc = filepath.Join(homeDir, "dextest/eth/alpha/node/geth.ipc") + contractAddrFile = filepath.Join(homeDir, "dextest", "eth", "contract_addr.txt") + ethClient = new(rpcclient) + ctx context.Context ) func TestMain(m *testing.M) { @@ -31,8 +35,24 @@ func TestMain(m *testing.M) { cancel() ethClient.shutdown() }() - if err := ethClient.connect(ctx, ipc); err != nil { - return 1, fmt.Errorf("Connect error: %v\n", err) + ctx, cancel = context.WithCancel(context.Background()) + defer func() { + cancel() + ethClient.shutdown() + }() + addrBytes, err := os.ReadFile(contractAddrFile) + if err != nil { + return 1, fmt.Errorf("error reading contract address: %v", err) + } + addrLen := len(addrBytes) + if addrLen == 0 { + return 1, fmt.Errorf("no contract address found at %v", contractAddrFile) + } + addrStr := string(addrBytes[:addrLen-1]) + contractAddr := common.HexToAddress(addrStr) + fmt.Printf("Contract address is %v\n", addrStr) + if err := ethClient.connect(ctx, ipc, &contractAddr); err != nil { + return 1, fmt.Errorf("Connect error: %v", err) } return m.Run(), nil } @@ -95,3 +115,22 @@ func TestSuggestGasPrice(t *testing.T) { t.Fatal(err) } } + +func TestSwap(t *testing.T) { + var secretHash [32]byte + copy(secretHash[:], encode.RandomBytes(32)) + _, err := ethClient.swap(ctx, secretHash) + if err != nil { + t.Fatal(err) + } +} + +func TestTransaction(t *testing.T) { + var hash [32]byte + copy(hash[:], encode.RandomBytes(32)) + _, _, err := ethClient.transaction(ctx, hash) + // TODO: Test positive route. + if err.Error() != "not found" { + t.Fatal(err) + } +}