From bd1d3b25c60a75179514e1e53e9225d36d1b0675 Mon Sep 17 00:00:00 2001 From: Marton Date: Thu, 9 Dec 2021 14:16:11 -0600 Subject: [PATCH] eth/client: AuditContract AuditContract decodes the init txns call data, matches the swap specified in the contract data bytes, extracts the recipient address, lock time, and value, and optionally rebroadcasts the transaction. --- client/asset/eth/eth.go | 71 ++++++- client/asset/eth/eth_test.go | 214 ++++++++++++++++++++ client/asset/eth/nodeclient.go | 10 + client/asset/eth/nodeclient_harness_test.go | 72 +++++++ 4 files changed, 364 insertions(+), 3 deletions(-) diff --git a/client/asset/eth/eth.go b/client/asset/eth/eth.go index 6787ba9725..5b9aebea2d 100644 --- a/client/asset/eth/eth.go +++ b/client/asset/eth/eth.go @@ -24,6 +24,7 @@ import ( "decred.org/dcrdex/dex/encode" "decred.org/dcrdex/dex/keygen" dexeth "decred.org/dcrdex/dex/networks/eth" + swapv0 "decred.org/dcrdex/dex/networks/eth/contracts/v0" "github.com/decred/dcrd/dcrutil/v4" "github.com/decred/dcrd/hdkeychain/v3" "github.com/ethereum/go-ethereum" @@ -162,6 +163,7 @@ type ethFetcher interface { signData(addr common.Address, data []byte) ([]byte, error) sendToAddr(ctx context.Context, addr common.Address, val uint64) (*types.Transaction, error) transactionConfirmations(context.Context, common.Hash) (uint32, error) + sendSignedTransaction(ctx context.Context, tx *types.Transaction) error } // Check that ExchangeWallet satisfies the asset.Wallet interface. @@ -772,9 +774,72 @@ func (eth *ExchangeWallet) SignMessage(coin asset.Coin, msg dex.Bytes) (pubkeys, // AuditContract retrieves information about a swap contract on the // blockchain. This would be used to verify the counter-party's contract -// during a swap. -func (*ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, rebroadcast bool) (*asset.AuditInfo, error) { - return nil, asset.ErrNotImplemented +// during a swap. coinID is expected to be the transaction id, and must +// be the same as the hash of txData. contract is expected to be +// (contractVersion|secretHash) where the secretHash uniquely keys the swap. +func (eth *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, rebroadcast bool) (*asset.AuditInfo, error) { + tx := new(types.Transaction) + err := tx.UnmarshalBinary(txData) + if err != nil { + return nil, fmt.Errorf("AuditContract: failed to unmarshal transaction: %w", err) + } + + txHash := tx.Hash() + if !bytes.Equal(coinID, txHash[:]) { + return nil, fmt.Errorf("AuditContract: coin id != txHash - coin id: %x, txHash: %x", coinID, tx.Hash()) + } + + initiations, err := dexeth.ParseInitiateData(tx.Data()) + if err != nil { + return nil, fmt.Errorf("AuditContract: failed to parse initiate data: %w", err) + } + + _, secretHash, err := dexeth.DecodeContractData(contract) + if err != nil { + return nil, fmt.Errorf("AuditContract: failed to decode versioned bytes: %w", err) + } + + var initiation *swapv0.ETHSwapInitiation + for _, init := range initiations { + if init.SecretHash == secretHash { + initiation = &init + break + } + } + if initiation == nil { + return nil, errors.New("AuditContract: tx does not initiate secret hash") + } + + expiration := time.Unix(initiation.RefundTimestamp.Int64(), 0) + recipient := initiation.Participant.Hex() + gweiVal, err := dexeth.ToGwei(initiation.Value) + if err != nil { + return nil, fmt.Errorf("AuditContract: failed to convert value to gwei: %w", err) + } + + coin := &coin{ + id: coinID, + value: gweiVal, + } + + // The counter-party should have broadcasted the contract tx but rebroadcast + // just in case to ensure that the tx is sent to the network. Do not block + // because this is not required and does not affect the audit result. + if rebroadcast { + go func() { + if err := eth.node.sendSignedTransaction(eth.ctx, tx); err != nil { + eth.log.Debugf("Rebroadcasting counterparty contract %v (THIS MAY BE NORMAL): %v", txHash, err) + } + }() + } + + return &asset.AuditInfo{ + Recipient: recipient, + Expiration: expiration, + Coin: coin, + Contract: contract, + SecretHash: secretHash[:], + }, nil } // LocktimeExpired returns true if the specified contract's locktime has diff --git a/client/asset/eth/eth_test.go b/client/asset/eth/eth_test.go index 75fa0dd272..ed89cd5611 100644 --- a/client/asset/eth/eth_test.go +++ b/client/asset/eth/eth_test.go @@ -24,6 +24,7 @@ import ( "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/encode" dexeth "decred.org/dcrdex/dex/networks/eth" + swapv0 "decred.org/dcrdex/dex/networks/eth/contracts/v0" "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -162,6 +163,19 @@ func (n *testNode) signData(addr common.Address, data []byte) ([]byte, error) { return crypto.Sign(crypto.Keccak256(data), n.privKeyForSigning) } +func (n *testNode) sendSignedTransaction(ctx context.Context, tx *types.Transaction) error { + return nil +} + +func tTx(gasFeeCap, gasTipCap, value uint64, to *common.Address, data []byte) *types.Transaction { + return types.NewTx(&types.DynamicFeeTx{ + GasFeeCap: dexeth.GweiToWei(gasFeeCap), + GasTipCap: dexeth.GweiToWei(gasTipCap), + To: to, + Value: dexeth.GweiToWei(value), + Data: data, + }) +} func (n *testNode) sendToAddr(ctx context.Context, addr common.Address, val uint64) (*types.Transaction, error) { return nil, nil @@ -1510,6 +1524,206 @@ func TestMaxOrder(t *testing.T) { } } +func overMaxWei() *big.Int { + maxInt := ^uint64(0) + maxWei := new(big.Int).SetUint64(maxInt) + gweiFactorBig := big.NewInt(dexeth.GweiFactor) + maxWei.Mul(maxWei, gweiFactorBig) + overMaxWei := new(big.Int).Set(maxWei) + return overMaxWei.Add(overMaxWei, gweiFactorBig) +} + +func TestAuditContract(t *testing.T) { + node := &testNode{} + eth := &ExchangeWallet{ + node: node, + log: tLogger, + } + + numSecretHashes := 3 + secretHashes := make([][32]byte, 0, numSecretHashes) + for i := 0; i < numSecretHashes; i++ { + var secretHash [32]byte + copy(secretHash[:], encode.RandomBytes(32)) + secretHashes = append(secretHashes, secretHash) + } + + now := time.Now().Unix() / 1000 + laterThanNow := now + 1000 + + tests := []struct { + name string + contract dex.Bytes + initiations []swapv0.ETHSwapInitiation + differentHash bool + badTxData bool + badTxBinary bool + wantErr bool + wantRecipient string + wantExpiration time.Time + }{ + { + name: "ok", + contract: dexeth.EncodeContractData(0, secretHashes[1]), + initiations: []swapv0.ETHSwapInitiation{ + swapv0.ETHSwapInitiation{ + RefundTimestamp: big.NewInt(now), + SecretHash: secretHashes[0], + Participant: testAddressA, + Value: big.NewInt(1), + }, + swapv0.ETHSwapInitiation{ + RefundTimestamp: big.NewInt(laterThanNow), + SecretHash: secretHashes[1], + Participant: testAddressB, + Value: big.NewInt(1), + }, + }, + wantRecipient: testAddressB.Hex(), + wantExpiration: time.Unix(laterThanNow, 0), + }, + { + name: "coin id different than tx hash", + contract: dexeth.EncodeContractData(0, secretHashes[0]), + initiations: []swapv0.ETHSwapInitiation{ + swapv0.ETHSwapInitiation{ + RefundTimestamp: big.NewInt(now), + SecretHash: secretHashes[0], + Participant: testAddressA, + Value: big.NewInt(1), + }, + }, + differentHash: true, + wantErr: true, + }, + { + name: "contract is invalid versioned bytes", + contract: []byte{}, + wantErr: true, + }, + { + name: "contract not part of transaction", + contract: dexeth.EncodeContractData(0, secretHashes[2]), + initiations: []swapv0.ETHSwapInitiation{ + swapv0.ETHSwapInitiation{ + RefundTimestamp: big.NewInt(now), + SecretHash: secretHashes[0], + Participant: testAddressA, + Value: big.NewInt(1), + }, + swapv0.ETHSwapInitiation{ + RefundTimestamp: big.NewInt(laterThanNow), + SecretHash: secretHashes[1], + Participant: testAddressB, + Value: big.NewInt(1), + }, + }, + wantErr: true, + }, + { + name: "cannot parse tx data", + contract: dexeth.EncodeContractData(0, secretHashes[2]), + badTxData: true, + wantErr: true, + }, + { + name: "cannot unmarshal tx binary", + contract: dexeth.EncodeContractData(0, secretHashes[1]), + initiations: []swapv0.ETHSwapInitiation{ + swapv0.ETHSwapInitiation{ + RefundTimestamp: big.NewInt(now), + SecretHash: secretHashes[0], + Participant: testAddressA, + Value: big.NewInt(1), + }, + swapv0.ETHSwapInitiation{ + RefundTimestamp: big.NewInt(laterThanNow), + SecretHash: secretHashes[1], + Participant: testAddressB, + Value: big.NewInt(1), + }, + }, + badTxBinary: true, + wantErr: true, + }, + { + name: "value over max gwei", + contract: dexeth.EncodeContractData(0, secretHashes[1]), + initiations: []swapv0.ETHSwapInitiation{ + swapv0.ETHSwapInitiation{ + RefundTimestamp: big.NewInt(now), + SecretHash: secretHashes[0], + Participant: testAddressA, + Value: big.NewInt(1), + }, + swapv0.ETHSwapInitiation{ + RefundTimestamp: big.NewInt(laterThanNow), + SecretHash: secretHashes[1], + Participant: testAddressB, + Value: overMaxWei(), + }, + }, + wantErr: true, + }, + } + + for _, test := range tests { + txData, err := dexeth.PackInitiateData(test.initiations) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if test.badTxData { + txData = []byte{0} + } + + tx := tTx(2, 300, uint64(len(test.initiations)), &testAddressC, txData) + txBinary, err := tx.MarshalBinary() + if err != nil { + t.Fatalf(`"%v": failed to marshal binary: %v`, test.name, err) + } + if test.badTxBinary { + txBinary = []byte{0} + } + + txHash := tx.Hash() + if test.differentHash { + copy(txHash[:], encode.RandomBytes(32)) + } + + auditInfo, err := eth.AuditContract(txHash[:], test.contract, txBinary, true) + if test.wantErr { + if err == nil { + t.Fatalf(`"%v": expected error but did not get`, test.name) + } + continue + } + if err != nil { + t.Fatalf(`"%v": unexpected error: %v`, test.name, err) + } + + if test.wantRecipient != auditInfo.Recipient { + t.Fatalf(`"%v": expected recipient %v != actual %v`, test.name, test.wantRecipient, auditInfo.Recipient) + } + if test.wantExpiration.Unix() != auditInfo.Expiration.Unix() { + t.Fatalf(`"%v": expected expiration %v != actual %v`, test.name, test.wantExpiration, auditInfo.Expiration) + } + if !bytes.Equal(txHash[:], auditInfo.Coin.ID()) { + t.Fatalf(`"%v": tx hash %x != coin id %x`, test.name, txHash, auditInfo.Coin.ID()) + } + if !bytes.Equal(test.contract, auditInfo.Contract) { + t.Fatalf(`"%v": expected contract %x != actual %x`, test.name, test.contract, auditInfo.Contract) + } + + _, expectedSecretHash, err := dexeth.DecodeContractData(test.contract) + if err != nil { + t.Fatalf(`"%v": failed to decode versioned bytes: %v`, test.name, err) + } + if !bytes.Equal(expectedSecretHash[:], auditInfo.SecretHash) { + t.Fatalf(`"%v": expected secret hash %x != actual %x`, test.name, expectedSecretHash, auditInfo.SecretHash) + } + } +} + func TestOwnsAddress(t *testing.T) { address := "0b84C791b79Ee37De042AD2ffF1A253c3ce9bc27" // no "0x" prefix if !common.IsHexAddress(address) { diff --git a/client/asset/eth/nodeclient.go b/client/asset/eth/nodeclient.go index f7deff0e33..222b8de784 100644 --- a/client/asset/eth/nodeclient.go +++ b/client/asset/eth/nodeclient.go @@ -359,6 +359,11 @@ func (n *nodeClient) addSignerToOpts(txOpts *bind.TransactOpts) error { return nil } +// signTransaction signs a transaction. +func (n *nodeClient) signTransaction(addr common.Address, tx *types.Transaction) (*types.Transaction, error) { + return n.creds.ks.SignTx(accounts.Account{Address: addr}, tx, n.chainID) +} + // initiate initiates multiple swaps in the same transaction. func (n *nodeClient) initiate(ctx context.Context, contracts []*asset.Contract, maxFeeRate uint64, contractVer uint32) (tx *types.Transaction, err error) { gas := dexeth.InitGas(len(contracts), contractVer) @@ -511,6 +516,11 @@ func (n *nodeClient) transactionConfirmations(ctx context.Context, txHash common return 0, asset.CoinNotFoundError } +// sendSignedTransaction injects a signed transaction into the pending pool for execution. +func (n *nodeClient) sendSignedTransaction(ctx context.Context, tx *types.Transaction) error { + return n.leth.ApiBackend.SendTx(ctx, tx) +} + // newTxOpts is a constructor for a TransactOpts. func newTxOpts(ctx context.Context, from common.Address, val, maxGas uint64, maxFeeRate, gasTipCap *big.Int) *bind.TransactOpts { if gasTipCap.Cmp(maxFeeRate) > 0 { diff --git a/client/asset/eth/nodeclient_harness_test.go b/client/asset/eth/nodeclient_harness_test.go index b60500ced1..8b201a89ab 100644 --- a/client/asset/eth/nodeclient_harness_test.go +++ b/client/asset/eth/nodeclient_harness_test.go @@ -47,6 +47,7 @@ import ( "github.com/davecgh/go-spew/spew" "github.com/ethereum/go-ethereum/accounts" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/crypto/secp256k1" "github.com/ethereum/go-ethereum/node" @@ -264,6 +265,7 @@ func TestAccount(t *testing.T) { t.Run("testUnlock", testUnlock) t.Run("testLock", testLock) t.Run("testSendTransaction", testSendTransaction) + t.Run("testSendSignedTransaction", testSendSignedTransaction) t.Run("testTransactionReceipt", testTransactionReceipt) t.Run("testSignMessage", testSignMessage) } @@ -392,6 +394,76 @@ func testSendTransaction(t *testing.T) { } } +func testSendSignedTransaction(t *testing.T) { + err := ethClient.unlock(pw) + if err != nil { + t.Fatal(err) + } + + // Checking confirmations for a random hash should result in not found error. + var txHash common.Hash + copy(txHash[:], encode.RandomBytes(32)) + _, err = ethClient.transactionConfirmations(ctx, txHash) + if !errors.Is(err, asset.CoinNotFoundError) { + t.Fatalf("no CoinNotFoundError") + } + + ethClient.nonceSendMtx.Lock() + defer ethClient.nonceSendMtx.Unlock() + nonce, err := ethClient.leth.ApiBackend.GetPoolNonce(ctx, ethClient.creds.addr) + if err != nil { + t.Fatalf("error getting nonce: %v", err) + } + tx := types.NewTx(&types.DynamicFeeTx{ + To: &simnetAddr, + ChainID: ethClient.chainID, + Nonce: nonce, + Gas: 21000, + GasFeeCap: dexeth.GweiToWei(300), + GasTipCap: dexeth.GweiToWei(2), + Value: dexeth.GweiToWei(1), + Data: []byte{}, + }) + tx, err = ethClient.signTransaction(simnetAddr, tx) + + err = ethClient.sendSignedTransaction(ctx, tx) + if err != nil { + t.Fatal(err) + } + + txHash = tx.Hash() + + confs, err := ethClient.transactionConfirmations(ctx, txHash) + if err != nil { + t.Fatalf("transactionConfirmations error: %v", err) + } + if confs != 0 { + t.Fatalf("%d confs reported for unmined transaction", confs) + } + + bal, _ := ethClient.balance(ctx) + if bal.PendingIn.Cmp(dexeth.GweiToWei(1)) != 0 { // We sent it to ourselves. + t.Fatalf("pending in not showing") + } + + if bal.PendingOut.Cmp(dexeth.GweiToWei(1)) != 0 { + t.Fatalf("pending out not showing") + } + + spew.Dump(tx) + if err := waitForMined(t, time.Second*10, false); err != nil { + t.Fatal(err) + } + + confs, err = ethClient.transactionConfirmations(ctx, txHash) + if err != nil { + t.Fatalf("transactionConfirmations error after mining: %v", err) + } + if confs == 0 { + t.Fatalf("zero confs after mining") + } +} + func testTransactionReceipt(t *testing.T) { err := ethClient.unlock(pw) if err != nil {