From ee847d09c8e13970abcf2024396a37278051affe Mon Sep 17 00:00:00 2001 From: martonp Date: Wed, 3 Nov 2021 17:49:29 +0100 Subject: [PATCH] eth/client: Swap --- client/asset/eth/eth.go | 246 ++++++++++++++--- client/asset/eth/eth_test.go | 491 ++++++++++++++++++++++++++++++++-- client/asset/eth/rpcclient.go | 14 + server/asset/eth/common.go | 11 + 4 files changed, 703 insertions(+), 59 deletions(-) diff --git a/client/asset/eth/eth.go b/client/asset/eth/eth.go index 26b970c7cf..74c03bd15d 100644 --- a/client/asset/eth/eth.go +++ b/client/asset/eth/eth.go @@ -51,6 +51,8 @@ const ( defaultGasFeeLimit = 200 // gwei RedeemGas = 63000 // gas + + GasTipCap = 10 // gwei ) var ( @@ -187,6 +189,8 @@ type ExchangeWallet struct { log dex.Logger tipChange func(error) + networkID int64 + internalNode *node.Node tipMtx sync.RWMutex @@ -275,12 +279,18 @@ func NewWallet(assetCFG *asset.WalletConfig, logger dex.Logger, network dex.Netw len(accounts)) } + networkID, err := getNetworkID(network) + if err != nil { + return nil, fmt.Errorf("NewWallet: unable to get netowrk ID: %w", err) + } + return &ExchangeWallet{ log: logger, tipChange: assetCFG.TipChange, internalNode: node, lockedFunds: make(map[string]uint64), acct: &accounts[0], + networkID: networkID, initGasCache: make(map[int]uint64), }, nil } @@ -508,7 +518,11 @@ func (*ExchangeWallet) PreRedeem(req *asset.PreRedeemForm) (*asset.PreRedeem, er // coin implements the asset.Coin interface for ETH type coin struct { - id srveth.AmountCoinID + id srveth.CoinID + // the value can be determined from the coin id, but for some + // coin ids a lookup would be required from the blockchain to + // determine its value, so this field is used as a cache. + value uint64 } // ID is the ETH coins ID. It includes the address the coins came from (20 bytes) @@ -525,14 +539,14 @@ func (c *coin) String() string { // Value returns the value in gwei of the coin. func (c *coin) Value() uint64 { - return c.id.Amount + return c.value } var _ asset.Coin = (*coin)(nil) -// decodeCoinID decodes a coin id into a coin object. The coin id +// decodeAmountCoinID decodes a coin id into a coin object. The coin id // must contain an AmountCoinID. -func decodeCoinID(coinID []byte) (*coin, error) { +func decodeAmountCoinID(coinID []byte) (*coin, error) { id, err := srveth.DecodeCoinID(coinID) if err != nil { return nil, err @@ -545,10 +559,19 @@ func decodeCoinID(coinID []byte) (*coin, error) { } return &coin{ - id: *amountCoinID, + id: amountCoinID, + value: amountCoinID.Amount, }, nil } +func (eth *ExchangeWallet) createAmountCoin(amount uint64) *coin { + id := srveth.CreateAmountCoinID(eth.acct.Address, amount) + return &coin{ + id: id, + value: amount, + } +} + // FundOrder selects coins for use in an order. The coins will be locked, and // will not be returned in subsequent calls to FundOrder or calculated in calls // to Available, unless they are unlocked with ReturnCoins. @@ -558,21 +581,10 @@ func decodeCoinID(coinID []byte) (*coin, error) { func (eth *ExchangeWallet) FundOrder(ord *asset.Order) (asset.Coins, []dex.Bytes, error) { maxFees := ord.DEXConfig.MaxFeeRate * ord.DEXConfig.SwapSize * ord.MaxSwapCount fundsNeeded := ord.Value + maxFees - - var nonce [8]byte - copy(nonce[:], encode.RandomBytes(8)) - var address [20]byte - copy(address[:], eth.acct.Address.Bytes()) - coin := coin{ - id: srveth.AmountCoinID{ - Address: address, - Amount: fundsNeeded, - Nonce: nonce, - }, - } - coins := asset.Coins{&coin} - + coins := asset.Coins{eth.createAmountCoin(fundsNeeded)} + eth.lockedFundsMtx.Lock() err := eth.lockFunds(coins) + eth.lockedFundsMtx.Unlock() if err != nil { return nil, nil, err } @@ -583,6 +595,8 @@ func (eth *ExchangeWallet) FundOrder(ord *asset.Order) (asset.Coins, []dex.Bytes // ReturnCoins unlocks coins. This would be necessary in the case of a // canceled order. func (eth *ExchangeWallet) ReturnCoins(unspents asset.Coins) error { + eth.lockedFundsMtx.Lock() + defer eth.lockedFundsMtx.Unlock() return eth.unlockFunds(unspents) } @@ -591,19 +605,28 @@ func (eth *ExchangeWallet) ReturnCoins(unspents asset.Coins) error { func (eth *ExchangeWallet) FundingCoins(ids []dex.Bytes) (asset.Coins, error) { coins := make([]asset.Coin, 0, len(ids)) for _, id := range ids { - coin, err := decodeCoinID(id) + coin, err := decodeAmountCoinID(id) if err != nil { return nil, err } - if !bytes.Equal(coin.id.Address.Bytes(), eth.acct.Address.Bytes()) { + + amountCoinID, ok := coin.id.(*srveth.AmountCoinID) + if !ok { + // this should never happen if decodeAmountCoinID is implemented properly + return nil, errors.New("FundingCoins: coin id must be amount coin id") + } + + if !bytes.Equal(amountCoinID.Address.Bytes(), eth.acct.Address.Bytes()) { return nil, fmt.Errorf("FundingCoins: coin address %v != wallet address %v", - coin.id.Address, eth.acct.Address) + amountCoinID.Address, eth.acct.Address) } coins = append(coins, coin) } + eth.lockedFundsMtx.Lock() err := eth.lockFunds(coins) + eth.lockedFundsMtx.Unlock() if err != nil { return nil, err } @@ -612,10 +635,9 @@ func (eth *ExchangeWallet) FundingCoins(ids []dex.Bytes) (asset.Coins, error) { } // lockFunds adds coins to the map of locked funds. +// +// lockedFundsMtx MUST be held when calling this function. func (eth *ExchangeWallet) lockFunds(coins asset.Coins) error { - eth.lockedFundsMtx.Lock() - defer eth.lockedFundsMtx.Unlock() - currentlyLocking := make(map[string]bool) var amountToLock uint64 for _, coin := range coins { @@ -646,11 +668,10 @@ func (eth *ExchangeWallet) lockFunds(coins asset.Coins) error { return nil } -// lockFunds removes coins from the map of locked funds. +// unlockFunds removes coins from the map of locked funds. +// +// lockedFundsMtx MUST be held when calling this function. func (eth *ExchangeWallet) unlockFunds(coins asset.Coins) error { - eth.lockedFundsMtx.Lock() - defer eth.lockedFundsMtx.Unlock() - currentlyUnlocking := make(map[string]bool) for _, coin := range coins { hexID := hex.EncodeToString(coin.ID()) @@ -670,12 +691,155 @@ func (eth *ExchangeWallet) unlockFunds(coins asset.Coins) error { return nil } -// Swap sends the swaps in a single transaction. The Receipts returned can be -// used to refund a failed transaction. The Input coins are manually unlocked -// because they're not auto-unlocked by the wallet and therefore inaccurately -// included as part of the locked balance despite being spent. -func (*ExchangeWallet) Swap(swaps *asset.Swaps) ([]asset.Receipt, asset.Coin, uint64, error) { - return nil, nil, 0, asset.ErrNotImplemented +// swapReceipt implements the asset.Receipt interface for ETH. +type swapReceipt struct { + txCoinID srveth.TxCoinID + swapCoinID srveth.SwapCoinID + // expiration and value can be determined using the coin ids with + // a blockchain lookup, but we cache these values to avoid this. + expiration time.Time + value uint64 +} + +// Expiration returns the time after which the contract can be +// refunded. +func (r *swapReceipt) Expiration() time.Time { + return r.expiration +} + +// Coin returns the coin used to fund the swap. +func (r *swapReceipt) Coin() asset.Coin { + return &coin{ + value: r.value, + id: &r.txCoinID, + } +} + +// Contract returns an encoded srveth.SwapCoinID that can be used to identify a +// swap contract. +func (r *swapReceipt) Contract() dex.Bytes { + return dex.Bytes(r.swapCoinID.Encode()) +} + +// String returns a string representation of the swapReceipt. +func (r *swapReceipt) String() string { + return fmt.Sprintf("Contract address: %v, Secret hash: %x", + r.swapCoinID.ContractAddress, r.swapCoinID.SecretHash) +} + +// SignedRefund returns an empty byte array. ETH does not support a pre-signed +// redeem script becuase the nonce needed in the transaction can not be previously +// determined. We will need to come up with a different way to support manual +// refunds in ETH. +func (*swapReceipt) SignedRefund() dex.Bytes { + return dex.Bytes{} +} + +var _ asset.Receipt = (*swapReceipt)(nil) + +// Swap sends the swaps in a single transaction. The Receipts returned can +// be used to refund a failed transaction. The fees used returned are the max +// fees that will possibly be used, since in ethereum with EIP-1559 we cannot +// know exactly how much fees will be used. +func (eth *ExchangeWallet) Swap(swaps *asset.Swaps) ([]asset.Receipt, asset.Coin, uint64, error) { + initiations := make([]dexeth.ETHSwapInitiation, 0, len(swaps.Contracts)) + receipts := make([]asset.Receipt, 0, len(swaps.Contracts)) + + eth.lockedFundsMtx.Lock() + defer eth.lockedFundsMtx.Unlock() + + var totalInputValue uint64 + for _, input := range swaps.Inputs { + if _, ok := eth.lockedFunds[input.ID().String()]; !ok { + return nil, nil, 0, + fmt.Errorf("Swap: attempting to use coin that was not locked") + } + totalInputValue += input.Value() + } + + var totalContractValue uint64 + secretHashMap := make(map[string]bool) + for _, contract := range swaps.Contracts { + totalContractValue += contract.Value + + if len(contract.SecretHash) != 32 { + return nil, nil, 0, + fmt.Errorf("Swap: expected secret hash of length 32, but got %v", len(contract.SecretHash)) + } + encSecretHash := hex.EncodeToString(contract.SecretHash) + if _, ok := secretHashMap[encSecretHash]; ok { + return nil, nil, 0, fmt.Errorf("Swap: secret hashes must be unique") + } + secretHashMap[encSecretHash] = true + var secretHash [32]byte + copy(secretHash[:], contract.SecretHash) + + if !common.IsHexAddress(contract.Address) { + return nil, nil, 0, fmt.Errorf("Swap: address in contract is not valid %v", contract.Address) + } + address := common.HexToAddress(contract.Address) + + initiations = append(initiations, + dexeth.ETHSwapInitiation{ + RefundTimestamp: big.NewInt(0).SetUint64(contract.LockTime), + SecretHash: secretHash, + Participant: address, + Value: big.NewInt(0).SetUint64(contract.Value), + }) + } + + initGas, err := eth.getInitGas(len(initiations)) + if err != nil { + return nil, nil, 0, err + } + + totalUsedValue := totalContractValue + (initGas * swaps.FeeRate) + if totalInputValue < totalUsedValue { + return nil, nil, 0, fmt.Errorf("Swap: coin inputs value %d < required %d", totalInputValue, totalUsedValue) + } + + opts := bind.TransactOpts{ + From: eth.acct.Address, + Value: big.NewInt(0).SetUint64(totalContractValue), + GasFeeCap: big.NewInt(0).SetUint64(swaps.FeeRate), + GasTipCap: big.NewInt(GasTipCap), + Context: eth.ctx} + + tx, err := eth.node.initiate(&opts, eth.networkID, initiations) + if err != nil { + return nil, nil, 0, fmt.Errorf("Swap: initiate error: %w", err) + } + + var txID [32]byte + copy(txID[:], tx.Hash().Bytes()) + for i, initiation := range initiations { + txCoinID := &srveth.TxCoinID{ + TxID: txID, + Index: uint32(i)} + swapCoinID := srveth.SwapCoinID{ + ContractAddress: mainnetContractAddr, + SecretHash: initiation.SecretHash} + receipts = append(receipts, + &swapReceipt{ + expiration: time.Unix(initiation.RefundTimestamp.Int64(), 0), + value: initiation.Value.Uint64(), + txCoinID: *txCoinID, + swapCoinID: swapCoinID, + }) + } + + eth.unlockFunds(swaps.Inputs) + var change asset.Coin + changeAmount := totalInputValue - totalUsedValue + if changeAmount > 0 { + change = eth.createAmountCoin(changeAmount) + } + if swaps.LockChange && change != nil { + eth.lockFunds(asset.Coins{change}) + } + + feesUsed := swaps.FeeRate * initGas + return receipts, change, feesUsed, nil } // Redeem sends the redemption transaction, which may contain more than one @@ -688,14 +852,20 @@ func (*ExchangeWallet) Redeem(form *asset.RedeemForm) ([]dex.Bytes, asset.Coin, // specified funding Coin. Only a coin that came from the address this wallet // is initialized with can be used to sign. func (e *ExchangeWallet) SignMessage(coin asset.Coin, msg dex.Bytes) (pubkeys, sigs []dex.Bytes, err error) { - ethCoin, err := decodeCoinID(coin.ID()) + ethCoin, err := decodeAmountCoinID(coin.ID()) if err != nil { return nil, nil, err } - if !bytes.Equal(ethCoin.id.Address.Bytes(), e.acct.Address.Bytes()) { + amountCoinID, ok := ethCoin.id.(*srveth.AmountCoinID) + if !ok { + // this should never happen if decodeAmountCoinID is implemented properly + return nil, nil, errors.New("FundingCoins: coin id must be amount coin id") + } + + if !bytes.Equal(amountCoinID.Address.Bytes(), e.acct.Address.Bytes()) { return nil, nil, fmt.Errorf("SignMessage: coin address: %v != wallet address: %v", - ethCoin.id.Address, e.acct.Address) + amountCoinID.Address, e.acct.Address) } sig, err := e.node.signData(e.acct.Address, msg) diff --git a/client/asset/eth/eth_test.go b/client/asset/eth/eth_test.go index e73a5fa5ea..4d5d6acb06 100644 --- a/client/asset/eth/eth_test.go +++ b/client/asset/eth/eth_test.go @@ -6,8 +6,10 @@ package eth import ( + "bytes" "context" "crypto/ecdsa" + "crypto/sha256" "encoding/hex" "errors" "math/big" @@ -17,8 +19,8 @@ import ( "decred.org/dcrdex/client/asset" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/encode" - swap "decred.org/dcrdex/dex/networks/eth" - dexeth "decred.org/dcrdex/server/asset/eth" + dexeth "decred.org/dcrdex/dex/networks/eth" + srveth "decred.org/dcrdex/server/asset/eth" "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -35,6 +37,11 @@ var ( tLogger = dex.StdOutLogger("ETHTEST", dex.LevelTrace) ) +type initTx struct { + hash common.Hash + initiations []dexeth.ETHSwapInitiation +} + type testNode struct { connectErr error bestHdr *types.Header @@ -55,6 +62,9 @@ type testNode struct { initGasErr error signDataErr error privKeyForSigning *ecdsa.PrivateKey + initErr error + nonce uint64 + lastInitiation initTx } func (n *testNode) connect(ctx context.Context, node *node.Node, addr *common.Address) error { @@ -108,16 +118,35 @@ func (n *testNode) syncProgress(ctx context.Context) (*ethereum.SyncProgress, er func (n *testNode) pendingTransactions(ctx context.Context) ([]*types.Transaction, error) { return nil, nil } -func (n *testNode) initiate(opts *bind.TransactOpts, netID int64, initiations []swap.ETHSwapInitiation) (*types.Transaction, error) { - return nil, nil +func (n *testNode) initiate(opts *bind.TransactOpts, netID int64, initiations []dexeth.ETHSwapInitiation) (*types.Transaction, error) { + if n.initErr != nil { + return nil, n.initErr + } + baseTx := &types.DynamicFeeTx{ + Nonce: n.nonce, + GasFeeCap: opts.GasFeeCap, + GasTipCap: opts.GasTipCap, + Gas: opts.GasLimit, + Value: opts.Value, + Data: []byte{}, + } + tx := types.NewTx(baseTx) + n.nonce++ + + n.lastInitiation = initTx{ + initiations: initiations, + hash: tx.Hash(), + } + return tx, nil } + func (n *testNode) redeem(opts *bind.TransactOpts, netID int64, secret, secretHash [32]byte) (*types.Transaction, error) { return nil, nil } func (n *testNode) refund(opts *bind.TransactOpts, netID int64, secretHash [32]byte) (*types.Transaction, error) { return nil, nil } -func (n *testNode) swap(ctx context.Context, from *accounts.Account, secretHash [32]byte) (*swap.ETHSwapSwap, error) { +func (n *testNode) swap(ctx context.Context, from *accounts.Account, secretHash [32]byte) (*dexeth.ETHSwapSwap, error) { return nil, nil } func (n *testNode) transactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) { @@ -263,7 +292,7 @@ func TestSyncStatus(t *testing.T) { wantRatio: 0.25, }, { name: "ok header too old", - subSecs: dexeth.MaxBlockInterval, + subSecs: srveth.MaxBlockInterval, }, { name: "best header error", bestHdrErr: errors.New(""), @@ -311,7 +340,7 @@ func TestSyncStatus(t *testing.T) { func TestBalance(t *testing.T) { maxInt := ^uint64(0) maxWei := new(big.Int).SetUint64(maxInt) - gweiFactorBig := big.NewInt(dexeth.GweiFactor) + gweiFactorBig := big.NewInt(srveth.GweiFactor) maxWei.Mul(maxWei, gweiFactorBig) overMaxWei := new(big.Int).Set(maxWei) overMaxWei.Add(overMaxWei, gweiFactorBig) @@ -327,11 +356,11 @@ func TestBalance(t *testing.T) { wantBal: 0, }, { name: "ok rounded down", - bal: big.NewInt(dexeth.GweiFactor - 1), + bal: big.NewInt(srveth.GweiFactor - 1), wantBal: 0, }, { name: "ok one", - bal: big.NewInt(dexeth.GweiFactor), + bal: big.NewInt(srveth.GweiFactor), wantBal: 1, }, { name: "ok max int", @@ -395,8 +424,8 @@ func TestFundOrderReturnCoinsFundingCoins(t *testing.T) { defer cancel() node := &testNode{} - walletBalanceGwei := uint64(dexeth.GweiFactor) - node.bal = big.NewInt(int64(walletBalanceGwei) * dexeth.GweiFactor) + walletBalanceGwei := uint64(srveth.GweiFactor) + node.bal = big.NewInt(int64(walletBalanceGwei) * srveth.GweiFactor) address := "0xB6De8BB5ed28E6bE6d671975cad20C03931bE981" account := accounts.Account{ Address: common.HexToAddress(address), @@ -444,12 +473,16 @@ func TestFundOrderReturnCoinsFundingCoins(t *testing.T) { if len(redeemScripts) != 1 { t.Fatalf("%v: expected 1 redeem script but got %v", test.testName, len(redeemScripts)) } - coin, err := decodeCoinID(coins[0].ID()) + coin, err := decodeAmountCoinID(coins[0].ID()) if err != nil { t.Fatalf("%v: unexpected error: %v", test.testName, err) } - if coin.id.Address.String() != test.coinAddress { - t.Fatalf("%v: coin address expected to be %v, but got %v", test.testName, test.coinAddress, coin.id.Address.String()) + amountCoinID, ok := coin.id.(*srveth.AmountCoinID) + if !ok { + t.Fatalf("%v: coin id must be amount coin id", test.testName) + } + if amountCoinID.Address.String() != test.coinAddress { + t.Fatalf("%v: coin address expected to be %v, but got %v", test.testName, test.coinAddress, amountCoinID.Address.String()) } if coins[0].Value() != test.coinValue { t.Fatalf("%v: expected %v but got %v", test.testName, test.coinValue, coins[0].Value()) @@ -581,7 +614,7 @@ func TestFundOrderReturnCoinsFundingCoins(t *testing.T) { var nonce [8]byte copy(nonce[:], encode.RandomBytes(8)) differentAddressCoin := coin{ - id: dexeth.AmountCoinID{ + id: &srveth.AmountCoinID{ Address: differentAddress, Amount: 100000, Nonce: nonce, @@ -701,13 +734,429 @@ func TestGetInitGas(t *testing.T) { cancel() } +func TestSwap(t *testing.T) { + ethToGwei := func(eth uint64) uint64 { + return eth * srveth.GweiFactor + } + ethToWei := func(eth int64) *big.Int { + return big.NewInt(0).Mul(big.NewInt(eth*srveth.GweiFactor), big.NewInt(srveth.GweiFactor)) + } + + node := &testNode{} + node.initGas = 150000 + additionalSwapGas := uint64(120000) + address := "0xB6De8BB5ed28E6bE6d671975cad20C03931bE981" + receivingAddress := "0x2b84C791b79Ee37De042AD2ffF1A253c3ce9bc27" + account := accounts.Account{ + Address: common.HexToAddress(address), + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + eth := &ExchangeWallet{ + node: node, + ctx: ctx, + log: tLogger, + acct: &account, + lockedFunds: make(map[string]uint64), + initGasCache: make(map[int]uint64), + } + + coinIDsForAmounts := func(coinAmounts []uint64) []dex.Bytes { + coinIDs := make([]dex.Bytes, 0, len(coinAmounts)) + for _, amt := range coinAmounts { + amountCoinID := srveth.CreateAmountCoinID(eth.acct.Address, amt) + coinIDs = append(coinIDs, amountCoinID.Encode()) + } + return coinIDs + } + + coinIDsToCoins := func(coinIDs []dex.Bytes) []asset.Coin { + coins := make([]asset.Coin, 0, len(coinIDs)) + for _, id := range coinIDs { + coin, _ := decodeAmountCoinID(id) + coins = append(coins, coin) + } + return coins + } + + refreshWalletAndFundCoins := func(ethBalance uint64, coinAmounts []uint64) asset.Coins { + node.bal = ethToWei(int64(ethBalance)) + eth.lockedFunds = make(map[string]uint64) + coins, err := eth.FundingCoins(coinIDsForAmounts(coinAmounts)) + if err != nil { + t.Fatalf("FundingCoins error: %v", err) + } + return coins + } + + gasNeededForSwaps := func(numSwaps int) uint64 { + return node.initGas + (additionalSwapGas * uint64(numSwaps-1)) + } + + testSwap := func(testName string, swaps asset.Swaps, expectError bool) { + originalBalance, err := eth.Balance() + if err != nil { + t.Fatalf("%v: error getting balance: %v", testName, err) + } + + receipts, changeCoin, feeSpent, err := eth.Swap(&swaps) + if expectError { + if err == nil { + t.Fatalf("%v: expected error but did not get", testName) + } + return + } + if err != nil { + t.Fatalf("%v: unexpected error doing Swap: %v", testName, err) + } + + if len(receipts) != len(swaps.Contracts) { + t.Fatalf("%v: num receipts %d != num contracts %d", + testName, len(receipts), len(swaps.Contracts)) + } + + var totalCoinValue uint64 + for i, contract := range swaps.Contracts { + receipt := receipts[i] + + expiration := int64(contract.LockTime) + if receipt.Expiration().Unix() != expiration { + t.Fatalf("%v: expected expiration %v != expiration %v", + testName, time.Unix(expiration, 0), receipts[0].Expiration()) + } + + if receipt.Coin().Value() != contract.Value { + t.Fatalf("%v: receipt coin value: %v != expected: %v", + testName, receipt.Coin().Value(), contract.Value) + } + + decodedCoinID, err := srveth.DecodeCoinID(receipt.Coin().ID()) + if err != nil { + t.Fatalf("%v: error decoding coin id: %v", testName, err) + } + txCoinID, isTxCoinID := decodedCoinID.(*srveth.TxCoinID) + if !isTxCoinID { + t.Fatalf("%v: coin returned from Swap must have a tx coin id", + testName) + } + + if !bytes.Equal(node.lastInitiation.hash.Bytes(), txCoinID.TxID[:]) { + t.Fatalf("%v: tx hash: %x != tx hash in coin id: %x", + testName, node.lastInitiation.hash, txCoinID.TxID[:]) + } + + decodedContract, err := srveth.DecodeCoinID(receipt.Contract()) + if err != nil { + t.Fatalf("%v: error decoding contract: %v", testName, err) + } + swapCoinID, isSwapCoinID := decodedContract.(*srveth.SwapCoinID) + if !isSwapCoinID { + t.Fatalf("%v: contract in receipt must be a swap coin id", + testName) + } + + if !bytes.Equal(swapCoinID.SecretHash[:], contract.SecretHash[:]) { + t.Fatalf("%v, secret hash in output: %x != secret hash in input: %x", + testName, swapCoinID.SecretHash, contract.SecretHash) + } + + initiation := node.lastInitiation.initiations[i] + if !bytes.Equal(initiation.Participant.Bytes(), common.HexToAddress(contract.Address).Bytes()) { + t.Fatalf("%v, address in contract: %v != participant address used to init swap: %v", + testName, common.HexToAddress(contract.Address), initiation.Participant) + } + + if !bytes.Equal(initiation.SecretHash[:], contract.SecretHash) { + t.Fatalf("%v: secretHash in contract: %x != secret hash used to init swap: %x", + testName, initiation.SecretHash, contract.SecretHash) + } + + if initiation.RefundTimestamp.Uint64() != contract.LockTime { + t.Fatalf("%v: lock time in contract %v != refundTimestamp used to init swap: %v", + testName, contract.LockTime, initiation.RefundTimestamp) + } + + totalCoinValue += receipt.Coin().Value() + } + + postSwapBalance, err := eth.Balance() + if err != nil { + t.Fatalf("%v: error getting balance: %v", testName, err) + } + + var totalInputValue uint64 + for _, coin := range swaps.Inputs { + totalInputValue += coin.Value() + } + + var expectedLocked uint64 + if swaps.LockChange { + expectedLocked = originalBalance.Locked - + totalCoinValue - + gasNeededForSwaps(len(swaps.Contracts))*swaps.FeeRate + } else { + expectedLocked = originalBalance.Locked - totalInputValue + } + if expectedLocked != postSwapBalance.Locked { + t.Fatalf("%v: funds locked after swap expected: %v != actual: %v", + testName, expectedLocked, postSwapBalance.Locked) + } + + expectedChangeValue := totalInputValue - + totalCoinValue - + gasNeededForSwaps(len(swaps.Contracts))*swaps.FeeRate + if expectedChangeValue == 0 && changeCoin != nil { + t.Fatalf("%v: change coin should be nil if change is 0", testName) + } else if expectedChangeValue > 0 && changeCoin == nil { + t.Fatalf("%v: change coin should not be nil if there is expected change", testName) + } else if changeCoin != nil && changeCoin.Value() != expectedChangeValue { + t.Fatalf("%v: expected change value %v != change coin value: %v", + testName, expectedChangeValue, changeCoin.Value()) + } + + expectedFees := gasNeededForSwaps(len(swaps.Contracts)) * swaps.FeeRate + if feeSpent != expectedFees { + t.Fatalf("%v: expected fees: %v != actual fees %v", testName, expectedFees, feeSpent) + } + } + + secret := encode.RandomBytes(32) + secretHash := sha256.Sum256(secret) + secret2 := encode.RandomBytes(32) + secretHash2 := sha256.Sum256(secret2) + expiration := time.Now().Add(time.Hour * 8).Unix() + + // Ensure error when gas estimation errors + swaps := asset.Swaps{ + Inputs: refreshWalletAndFundCoins(5, []uint64{ethToGwei(3)}), + Contracts: []*asset.Contract{ + { + Address: receivingAddress, + Value: ethToGwei(1), + SecretHash: secretHash[:], + LockTime: uint64(expiration), + }, + }, + FeeRate: 200, + LockChange: false, + } + node.initGasErr = errors.New("") + testSwap("estimate init gas error", swaps, true) + node.initGasErr = nil + + // Ensure error with invalid secret hash + contracts := []*asset.Contract{ + { + Address: receivingAddress, + Value: ethToGwei(1), + SecretHash: encode.RandomBytes(31), + LockTime: uint64(expiration), + }, + } + inputs := refreshWalletAndFundCoins(5, []uint64{ethToGwei(2)}) + swaps = asset.Swaps{ + Inputs: inputs, + Contracts: contracts, + FeeRate: 200, + LockChange: false, + } + testSwap("incorrect length secret hash", swaps, true) + + // Ensure error with invalid receiving address + contracts = []*asset.Contract{ + { + Address: hex.EncodeToString(encode.RandomBytes(21)), + Value: ethToGwei(1), + SecretHash: secretHash[:], + LockTime: uint64(expiration), + }, + } + inputs = refreshWalletAndFundCoins(5, []uint64{ethToGwei(2)}) + swaps = asset.Swaps{ + Inputs: inputs, + Contracts: contracts, + FeeRate: 200, + LockChange: false, + } + testSwap("invalid receiving address", swaps, true) + + // Ensure error when initializing swap errors + node.initErr = errors.New("") + contracts = []*asset.Contract{ + { + Address: receivingAddress, + Value: ethToGwei(1), + SecretHash: secretHash[:], + LockTime: uint64(expiration), + }, + } + inputs = refreshWalletAndFundCoins(5, []uint64{ethToGwei(2)}) + swaps = asset.Swaps{ + Inputs: inputs, + Contracts: contracts, + FeeRate: 200, + LockChange: false, + } + testSwap("error initialize but no send", swaps, true) + node.initErr = nil + + // Ensure error initializing two contracts with same secret hash + contracts = []*asset.Contract{ + { + Address: receivingAddress, + Value: ethToGwei(1), + SecretHash: secretHash[:], + LockTime: uint64(expiration), + }, + { + Address: receivingAddress, + Value: ethToGwei(1), + SecretHash: secretHash[:], + LockTime: uint64(expiration), + }, + } + inputs = refreshWalletAndFundCoins(5, []uint64{ethToGwei(3)}) + swaps = asset.Swaps{ + Inputs: inputs, + Contracts: contracts, + FeeRate: 200, + LockChange: false, + } + testSwap("two contracts same hash error", swaps, true) + + // Tests one contract without locking change + contracts = []*asset.Contract{ + { + Address: receivingAddress, + Value: ethToGwei(1), + SecretHash: secretHash[:], + LockTime: uint64(expiration), + }, + } + inputs = refreshWalletAndFundCoins(5, []uint64{ethToGwei(2)}) + swaps = asset.Swaps{ + Inputs: inputs, + Contracts: contracts, + FeeRate: 200, + LockChange: false, + } + testSwap("one contract, don't lock change", swaps, false) + + // Test one contract with locking change + inputs = refreshWalletAndFundCoins(5, []uint64{ethToGwei(2)}) + swaps = asset.Swaps{ + Inputs: inputs, + Contracts: contracts, + FeeRate: 200, + LockChange: true, + } + testSwap("one contract, lock change", swaps, false) + + // Test two contracts + contracts = []*asset.Contract{ + { + Address: receivingAddress, + Value: ethToGwei(1), + SecretHash: secretHash[:], + LockTime: uint64(expiration), + }, + { + Address: receivingAddress, + Value: ethToGwei(1), + SecretHash: secretHash2[:], + LockTime: uint64(expiration), + }, + } + inputs = refreshWalletAndFundCoins(5, []uint64{ethToGwei(3)}) + swaps = asset.Swaps{ + Inputs: inputs, + Contracts: contracts, + FeeRate: 200, + LockChange: false, + } + testSwap("two contracts", swaps, false) + + // Test error when funding coins are not enough to cover swaps + contracts = []*asset.Contract{ + { + Address: receivingAddress, + Value: ethToGwei(1), + SecretHash: secretHash[:], + LockTime: uint64(expiration), + }, + { + Address: receivingAddress, + Value: ethToGwei(1), + SecretHash: secretHash2[:], + LockTime: uint64(expiration), + }, + } + inputs = refreshWalletAndFundCoins(5, []uint64{ethToGwei(1)}) + swaps = asset.Swaps{ + Inputs: inputs, + Contracts: contracts, + FeeRate: 200, + LockChange: false, + } + testSwap("funding coins not enough balance", swaps, true) + + // Ensure error when inputs were not locked by wallet + contracts = []*asset.Contract{ + { + Address: receivingAddress, + Value: ethToGwei(1), + SecretHash: secretHash[:], + LockTime: uint64(expiration), + }, + { + Address: receivingAddress, + Value: ethToGwei(1), + SecretHash: secretHash2[:], + LockTime: uint64(expiration), + }, + } + _ = refreshWalletAndFundCoins(5, []uint64{ethToGwei(3)}) + inputs = coinIDsToCoins(coinIDsForAmounts([]uint64{ethToGwei(3)})) + swaps = asset.Swaps{ + Inputs: inputs, + Contracts: contracts, + FeeRate: 200, + LockChange: false, + } + testSwap("funding coins that were not locked", swaps, true) + + // Ensure when funds are exactly the same as required works properly + contracts = []*asset.Contract{ + { + Address: receivingAddress, + Value: ethToGwei(1), + SecretHash: secretHash[:], + LockTime: uint64(expiration), + }, + { + Address: receivingAddress, + Value: ethToGwei(1), + SecretHash: secretHash2[:], + LockTime: uint64(expiration), + }, + } + inputs = refreshWalletAndFundCoins(5, []uint64{ethToGwei(2) + (2 * 200 * node.initGas)}) + swaps = asset.Swaps{ + Inputs: inputs, + Contracts: contracts, + FeeRate: 200, + LockChange: false, + } + testSwap("exact change", swaps, false) +} + func TestPreSwap(t *testing.T) { ethToGwei := func(eth uint64) uint64 { - return eth * dexeth.GweiFactor + return eth * srveth.GweiFactor } ethToWei := func(eth int64) *big.Int { - return big.NewInt(0).Mul(big.NewInt(eth*dexeth.GweiFactor), big.NewInt(dexeth.GweiFactor)) + return big.NewInt(0).Mul(big.NewInt(eth*srveth.GweiFactor), big.NewInt(srveth.GweiFactor)) } estimatedInitGas := uint64(180000) @@ -909,11 +1358,11 @@ func TestPreRedeem(t *testing.T) { func TestMaxOrder(t *testing.T) { ethToGwei := func(eth uint64) uint64 { - return eth * dexeth.GweiFactor + return eth * srveth.GweiFactor } ethToWei := func(eth int64) *big.Int { - return big.NewInt(0).Mul(big.NewInt(eth*dexeth.GweiFactor), big.NewInt(dexeth.GweiFactor)) + return big.NewInt(0).Mul(big.NewInt(eth*srveth.GweiFactor), big.NewInt(srveth.GweiFactor)) } estimatedInitGas := uint64(180000) @@ -1087,7 +1536,7 @@ func TestSignMessage(t *testing.T) { differentAddress := common.HexToAddress("8d83B207674bfd53B418a6E47DA148F5bFeCc652") nonce := [8]byte{} coinDifferentAddress := coin{ - id: dexeth.AmountCoinID{ + id: &srveth.AmountCoinID{ Address: differentAddress, Amount: 100, Nonce: nonce, @@ -1099,7 +1548,7 @@ func TestSignMessage(t *testing.T) { } coin := coin{ - id: dexeth.AmountCoinID{ + id: &srveth.AmountCoinID{ Address: account.Address, Amount: 100, Nonce: nonce, diff --git a/client/asset/eth/rpcclient.go b/client/asset/eth/rpcclient.go index dc5cb74252..ac6c9941d6 100644 --- a/client/asset/eth/rpcclient.go +++ b/client/asset/eth/rpcclient.go @@ -11,6 +11,7 @@ import ( "fmt" "math/big" + "decred.org/dcrdex/dex" swap "decred.org/dcrdex/dex/networks/eth" "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts" @@ -38,6 +39,19 @@ type rpcclient struct { es *swap.ETHSwap } +func getNetworkID(network dex.Network) (int64, error) { + switch network { + case dex.Simnet: + return 42, nil + case dex.Testnet: + return 6284, nil // network ID for goerli testnet + case dex.Mainnet: + return 1, nil + default: + return 0, fmt.Errorf("unknown network ID: %d", uint8(network)) + } +} + // connect connects to a node. It then wraps ethclient's client and // bundles commands in a form we can easily use. func (c *rpcclient) connect(ctx context.Context, node *node.Node, contractAddr *common.Address) error { diff --git a/server/asset/eth/common.go b/server/asset/eth/common.go index 1ea461fbdc..36452d00d8 100644 --- a/server/asset/eth/common.go +++ b/server/asset/eth/common.go @@ -11,6 +11,7 @@ import ( "fmt" "math/big" + "decred.org/dcrdex/dex/encode" "github.com/ethereum/go-ethereum/common" ) @@ -221,6 +222,16 @@ func decodeAmountCoinID(coinID []byte) (*AmountCoinID, error) { }, nil } +func CreateAmountCoinID(address common.Address, amount uint64) *AmountCoinID { + var nonce [8]byte + copy(nonce[:], encode.RandomBytes(8)) + return &AmountCoinID{ + Address: address, + Amount: amount, + Nonce: nonce, + } +} + // DecodeCoinID decodes the coin id byte slice into an object implementing the // CoinID interface. func DecodeCoinID(coinID []byte) (CoinID, error) {