From 9afb95451e6af51a89450e26646a142cc008520b Mon Sep 17 00:00:00 2001 From: Danny Browning Date: Wed, 17 May 2023 09:19:33 -0600 Subject: [PATCH] feat: replace ethers with viem --- package-lock.json | 352 +++++++------ package.json | 5 +- src/models/transaction.ts | 6 +- .../transaction-state-machine.test.ts.snap | 46 ++ .../__tests__/eth-bc-service.test.ts | 492 +++--------------- .../transaction-state-machine.test.ts | 127 +++++ .../ethereum/ethereum-blockchain-service.ts | 438 ++-------------- .../blockchain/ethereum/ethereum-client.ts | 87 ++++ .../blockchain/ethereum/ethereum-wallet.ts | 37 ++ .../ethereum/transaction-state-machine.ts | 186 +++++++ 10 files changed, 817 insertions(+), 959 deletions(-) create mode 100644 src/services/blockchain/__tests__/__snapshots__/transaction-state-machine.test.ts.snap create mode 100644 src/services/blockchain/__tests__/transaction-state-machine.test.ts create mode 100644 src/services/blockchain/ethereum/ethereum-client.ts create mode 100644 src/services/blockchain/ethereum/ethereum-wallet.ts create mode 100644 src/services/blockchain/ethereum/transaction-state-machine.ts diff --git a/package-lock.json b/package-lock.json index 0edd49da0..4043b5595 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "cors": "^2.8.5", "dag-jose": "^4.0.0", "dotenv": "^16.0.3", - "ethers": "~5.7.2", + "exponential-backoff": "^3.1.1", "express": "^4.18.1", "http-status-codes": "^2.2.0", "ipfs-http-client": "^60.0.0", @@ -47,7 +47,8 @@ "tsm": "^2.2.2", "typed-inject": "^4.0.0", "uint8arrays": "^4.0.3", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "viem": "^0.3.49" }, "devDependencies": { "@babel/core": "^7.21.0", @@ -173,6 +174,11 @@ "npm": ">=7.0.0" } }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.9.0.tgz", + "integrity": "sha512-iowxq3U30sghZotgl4s/oJRci6WPBfNO5YYgk2cIOMCHr3LeGPcsZjCEr+33Q4N+oV3OABDAtA+pyvWjbvBifQ==" + }, "node_modules/@ampproject/remapping": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", @@ -4349,29 +4355,6 @@ "hash.js": "1.1.7" } }, - "node_modules/@ethersproject/solidity": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/solidity/-/solidity-5.7.0.tgz", - "integrity": "sha512-HmabMd2Dt/raavyaGukF4XxizWKhKQ24DoLtdNbBmNKUOPqwjsKQSdV9GQtj9CBEea9DlzETlVER1gYeXXBGaA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, "node_modules/@ethersproject/strings": { "version": "5.7.0", "resolved": "https://registry.npmjs.org/@ethersproject/strings/-/strings-5.7.0.tgz", @@ -4418,26 +4401,6 @@ "@ethersproject/signing-key": "^5.7.0" } }, - "node_modules/@ethersproject/units": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/units/-/units-5.7.0.tgz", - "integrity": "sha512-pD3xLMy3SJu9kG5xDGI7+xhTEmGXlEqXU4OfNapmfnxLVY4EMSSRp7j1k7eezutBPH7RBN/7QPnwR7hzNlEFeg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, "node_modules/@ethersproject/wallet": { "version": "5.7.0", "resolved": "https://registry.npmjs.org/@ethersproject/wallet/-/wallet-5.7.0.tgz", @@ -8577,6 +8540,22 @@ } ] }, + "node_modules/@scure/bip32": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.0.tgz", + "integrity": "sha512-bcKpo1oj54hGholplGLpqPHRbIsnbixFtc06nwuNM5/dwSXOq/AAYoIBRsBmnZJSdfeNW5rnff7NTAz3ZCqR9Q==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "@noble/curves": "~1.0.0", + "@noble/hashes": "~1.3.0", + "@scure/base": "~1.1.0" + } + }, "node_modules/@scure/bip39": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.1.0.tgz", @@ -9717,6 +9696,29 @@ "integrity": "sha512-MVEJ4vWAPNbrGLjz7ITnHYg+YXZ6ijAqtH5/cHwSoCpbvuJ98aLXwFfPKAUfZpJMQR5uXB58UJajbY130IRF/w==", "dev": true }, + "node_modules/@wagmi/chains": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@wagmi/chains/-/chains-1.0.0.tgz", + "integrity": "sha512-eNbqRWyHbivcMNq5tbXJks4NaOzVLHnNQauHPeE/EDT9AlpqzcrMc+v2T1/2Iw8zN4zgqB86NCsxeJHJs7+xng==", + "funding": [ + { + "type": "gitcoin", + "url": "https://wagmi.sh/gitcoin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/wagmi-dev" + } + ], + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@zondax/filecoin-signing-tools": { "version": "0.18.6", "resolved": "https://registry.npmjs.org/@zondax/filecoin-signing-tools/-/filecoin-signing-tools-0.18.6.tgz", @@ -9783,6 +9785,20 @@ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" }, + "node_modules/abitype": { + "version": "0.8.7", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-0.8.7.tgz", + "integrity": "sha512-wQ7hV8Yg/yKmGyFpqrNZufCxbszDe5es4AZGYPBitocfSqXtjrTG9JMWFcc4N30ukl2ve48aBTwt7NJxVQdU3w==", + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3 >=3.19.1" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -15031,53 +15047,6 @@ "node": ">= 0.6" } }, - "node_modules/ethers": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.7.2.tgz", - "integrity": "sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/abi": "5.7.0", - "@ethersproject/abstract-provider": "5.7.0", - "@ethersproject/abstract-signer": "5.7.0", - "@ethersproject/address": "5.7.0", - "@ethersproject/base64": "5.7.0", - "@ethersproject/basex": "5.7.0", - "@ethersproject/bignumber": "5.7.0", - "@ethersproject/bytes": "5.7.0", - "@ethersproject/constants": "5.7.0", - "@ethersproject/contracts": "5.7.0", - "@ethersproject/hash": "5.7.0", - "@ethersproject/hdnode": "5.7.0", - "@ethersproject/json-wallets": "5.7.0", - "@ethersproject/keccak256": "5.7.0", - "@ethersproject/logger": "5.7.0", - "@ethersproject/networks": "5.7.1", - "@ethersproject/pbkdf2": "5.7.0", - "@ethersproject/properties": "5.7.0", - "@ethersproject/providers": "5.7.2", - "@ethersproject/random": "5.7.0", - "@ethersproject/rlp": "5.7.0", - "@ethersproject/sha2": "5.7.0", - "@ethersproject/signing-key": "5.7.0", - "@ethersproject/solidity": "5.7.0", - "@ethersproject/strings": "5.7.0", - "@ethersproject/transactions": "5.7.0", - "@ethersproject/units": "5.7.0", - "@ethersproject/wallet": "5.7.0", - "@ethersproject/web": "5.7.1", - "@ethersproject/wordlists": "5.7.0" - } - }, "node_modules/ethr-did-registry": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/ethr-did-registry/-/ethr-did-registry-0.0.3.tgz", @@ -15276,6 +15245,11 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/exponential-backoff": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz", + "integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==" + }, "node_modules/express": { "version": "4.18.2", "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", @@ -19446,6 +19420,14 @@ "node": ">=0.10.0" } }, + "node_modules/isomorphic-ws": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", + "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", + "peerDependencies": { + "ws": "*" + } + }, "node_modules/issue-parser": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-6.0.0.tgz", @@ -29772,7 +29754,6 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", - "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -30213,6 +30194,57 @@ "node": ">= 0.8" } }, + "node_modules/viem": { + "version": "0.3.49", + "resolved": "https://registry.npmjs.org/viem/-/viem-0.3.49.tgz", + "integrity": "sha512-YW8bMtuaBm5aaYJUVM4aCOjUmmjaOpg2UZOze3A4gDgmYoz0De1Q5I9GE48waPPKVZig9iCgwLFdq8/mSvuGNg==", + "dependencies": { + "@adraffy/ens-normalize": "1.9.0", + "@noble/curves": "1.0.0", + "@noble/hashes": "1.3.0", + "@scure/bip32": "1.3.0", + "@scure/bip39": "1.2.0", + "@wagmi/chains": "1.0.0", + "abitype": "0.8.7", + "isomorphic-ws": "5.0.0", + "ws": "8.12.0" + } + }, + "node_modules/viem/node_modules/@scure/bip39": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.0.tgz", + "integrity": "sha512-SX/uKq52cuxm4YFXWFaVByaSHJh2w3BnokVSeUJVCv6K7WulT9u2BuNRBhuFl8vAuYnzx9bEu9WgpcNYTrYieg==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "@noble/hashes": "~1.3.0", + "@scure/base": "~1.1.0" + } + }, + "node_modules/viem/node_modules/ws": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.12.0.tgz", + "integrity": "sha512-kU62emKIdKVeEIOIKVegvqpXMSTAMLJozpHZaJNDYqBjzlSYXQGviYwN1osDLJ9av68qHd4a2oSjd7yD4pacig==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/vm2": { "version": "3.9.17", "resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.17.tgz", @@ -30857,6 +30889,11 @@ "xml2js": "^0.5.0" } }, + "@adraffy/ens-normalize": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.9.0.tgz", + "integrity": "sha512-iowxq3U30sghZotgl4s/oJRci6WPBfNO5YYgk2cIOMCHr3LeGPcsZjCEr+33Q4N+oV3OABDAtA+pyvWjbvBifQ==" + }, "@ampproject/remapping": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", @@ -34014,19 +34051,6 @@ "hash.js": "1.1.7" } }, - "@ethersproject/solidity": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/solidity/-/solidity-5.7.0.tgz", - "integrity": "sha512-HmabMd2Dt/raavyaGukF4XxizWKhKQ24DoLtdNbBmNKUOPqwjsKQSdV9GQtj9CBEea9DlzETlVER1gYeXXBGaA==", - "requires": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, "@ethersproject/strings": { "version": "5.7.0", "resolved": "https://registry.npmjs.org/@ethersproject/strings/-/strings-5.7.0.tgz", @@ -34053,16 +34077,6 @@ "@ethersproject/signing-key": "^5.7.0" } }, - "@ethersproject/units": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/units/-/units-5.7.0.tgz", - "integrity": "sha512-pD3xLMy3SJu9kG5xDGI7+xhTEmGXlEqXU4OfNapmfnxLVY4EMSSRp7j1k7eezutBPH7RBN/7QPnwR7hzNlEFeg==", - "requires": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, "@ethersproject/wallet": { "version": "5.7.0", "resolved": "https://registry.npmjs.org/@ethersproject/wallet/-/wallet-5.7.0.tgz", @@ -37294,6 +37308,16 @@ "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz", "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==" }, + "@scure/bip32": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.0.tgz", + "integrity": "sha512-bcKpo1oj54hGholplGLpqPHRbIsnbixFtc06nwuNM5/dwSXOq/AAYoIBRsBmnZJSdfeNW5rnff7NTAz3ZCqR9Q==", + "requires": { + "@noble/curves": "~1.0.0", + "@noble/hashes": "~1.3.0", + "@scure/base": "~1.1.0" + } + }, "@scure/bip39": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.1.0.tgz", @@ -38284,6 +38308,12 @@ "integrity": "sha512-MVEJ4vWAPNbrGLjz7ITnHYg+YXZ6ijAqtH5/cHwSoCpbvuJ98aLXwFfPKAUfZpJMQR5uXB58UJajbY130IRF/w==", "dev": true }, + "@wagmi/chains": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@wagmi/chains/-/chains-1.0.0.tgz", + "integrity": "sha512-eNbqRWyHbivcMNq5tbXJks4NaOzVLHnNQauHPeE/EDT9AlpqzcrMc+v2T1/2Iw8zN4zgqB86NCsxeJHJs7+xng==", + "requires": {} + }, "@zondax/filecoin-signing-tools": { "version": "0.18.6", "resolved": "https://registry.npmjs.org/@zondax/filecoin-signing-tools/-/filecoin-signing-tools-0.18.6.tgz", @@ -38345,6 +38375,12 @@ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" }, + "abitype": { + "version": "0.8.7", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-0.8.7.tgz", + "integrity": "sha512-wQ7hV8Yg/yKmGyFpqrNZufCxbszDe5es4AZGYPBitocfSqXtjrTG9JMWFcc4N30ukl2ve48aBTwt7NJxVQdU3w==", + "requires": {} + }, "abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -42270,43 +42306,6 @@ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" }, - "ethers": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.7.2.tgz", - "integrity": "sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==", - "requires": { - "@ethersproject/abi": "5.7.0", - "@ethersproject/abstract-provider": "5.7.0", - "@ethersproject/abstract-signer": "5.7.0", - "@ethersproject/address": "5.7.0", - "@ethersproject/base64": "5.7.0", - "@ethersproject/basex": "5.7.0", - "@ethersproject/bignumber": "5.7.0", - "@ethersproject/bytes": "5.7.0", - "@ethersproject/constants": "5.7.0", - "@ethersproject/contracts": "5.7.0", - "@ethersproject/hash": "5.7.0", - "@ethersproject/hdnode": "5.7.0", - "@ethersproject/json-wallets": "5.7.0", - "@ethersproject/keccak256": "5.7.0", - "@ethersproject/logger": "5.7.0", - "@ethersproject/networks": "5.7.1", - "@ethersproject/pbkdf2": "5.7.0", - "@ethersproject/properties": "5.7.0", - "@ethersproject/providers": "5.7.2", - "@ethersproject/random": "5.7.0", - "@ethersproject/rlp": "5.7.0", - "@ethersproject/sha2": "5.7.0", - "@ethersproject/signing-key": "5.7.0", - "@ethersproject/solidity": "5.7.0", - "@ethersproject/strings": "5.7.0", - "@ethersproject/transactions": "5.7.0", - "@ethersproject/units": "5.7.0", - "@ethersproject/wallet": "5.7.0", - "@ethersproject/web": "5.7.1", - "@ethersproject/wordlists": "5.7.0" - } - }, "ethr-did-registry": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/ethr-did-registry/-/ethr-did-registry-0.0.3.tgz", @@ -42461,6 +42460,11 @@ "jest-util": "^29.5.0" } }, + "exponential-backoff": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz", + "integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==" + }, "express": { "version": "4.18.2", "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", @@ -45653,6 +45657,12 @@ "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", "dev": true }, + "isomorphic-ws": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", + "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", + "requires": {} + }, "issue-parser": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-6.0.0.tgz", @@ -53347,8 +53357,7 @@ "typescript": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", - "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", - "dev": true + "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==" }, "u3": { "version": "0.1.1", @@ -53682,6 +53691,39 @@ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, + "viem": { + "version": "0.3.49", + "resolved": "https://registry.npmjs.org/viem/-/viem-0.3.49.tgz", + "integrity": "sha512-YW8bMtuaBm5aaYJUVM4aCOjUmmjaOpg2UZOze3A4gDgmYoz0De1Q5I9GE48waPPKVZig9iCgwLFdq8/mSvuGNg==", + "requires": { + "@adraffy/ens-normalize": "1.9.0", + "@noble/curves": "1.0.0", + "@noble/hashes": "1.3.0", + "@scure/bip32": "1.3.0", + "@scure/bip39": "1.2.0", + "@wagmi/chains": "1.0.0", + "abitype": "0.8.7", + "isomorphic-ws": "5.0.0", + "ws": "8.12.0" + }, + "dependencies": { + "@scure/bip39": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.0.tgz", + "integrity": "sha512-SX/uKq52cuxm4YFXWFaVByaSHJh2w3BnokVSeUJVCv6K7WulT9u2BuNRBhuFl8vAuYnzx9bEu9WgpcNYTrYieg==", + "requires": { + "@noble/hashes": "~1.3.0", + "@scure/base": "~1.1.0" + } + }, + "ws": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.12.0.tgz", + "integrity": "sha512-kU62emKIdKVeEIOIKVegvqpXMSTAMLJozpHZaJNDYqBjzlSYXQGviYwN1osDLJ9av68qHd4a2oSjd7yD4pacig==", + "requires": {} + } + } + }, "vm2": { "version": "3.9.17", "resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.17.tgz", diff --git a/package.json b/package.json index 0d09dd743..236fde373 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "cors": "^2.8.5", "dag-jose": "^4.0.0", "dotenv": "^16.0.3", - "ethers": "~5.7.2", + "exponential-backoff": "^3.1.1", "express": "^4.18.1", "http-status-codes": "^2.2.0", "ipfs-http-client": "^60.0.0", @@ -84,7 +84,8 @@ "tsm": "^2.2.2", "typed-inject": "^4.0.0", "uint8arrays": "^4.0.3", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "viem": "^0.3.49" }, "devDependencies": { "@babel/core": "^7.21.0", diff --git a/src/models/transaction.ts b/src/models/transaction.ts index 9c1f517dd..79c36f0d1 100644 --- a/src/models/transaction.ts +++ b/src/models/transaction.ts @@ -1,10 +1,10 @@ export class Transaction { chain: string txHash: string - blockNumber: number - blockTimestamp: number + blockNumber: bigint + blockTimestamp: bigint - constructor(chain: string, txHash: string, blockNumber: number, blockTimestamp: number) { + constructor(chain: string, txHash: string, blockNumber: bigint, blockTimestamp: bigint) { this.chain = chain this.txHash = txHash this.blockNumber = blockNumber diff --git a/src/services/blockchain/__tests__/__snapshots__/transaction-state-machine.test.ts.snap b/src/services/blockchain/__tests__/__snapshots__/transaction-state-machine.test.ts.snap new file mode 100644 index 000000000..bad0f9492 --- /dev/null +++ b/src/services/blockchain/__tests__/__snapshots__/transaction-state-machine.test.ts.snap @@ -0,0 +1,46 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TransactionStateMachine with mocks get block fails due to timeout 1`] = ` +Transaction { + "blockNumber": 1n, + "blockTimestamp": 0n, + "chain": "test", + "txHash": "0xdeadbeef", +} +`; + +exports[`TransactionStateMachine with mocks no errors encountered 1`] = ` +Transaction { + "blockNumber": 1n, + "blockTimestamp": 0n, + "chain": "test", + "txHash": "0xdeadbeef", +} +`; + +exports[`TransactionStateMachine with mocks simulate fails due to timeout 1`] = ` +Transaction { + "blockNumber": 1n, + "blockTimestamp": 0n, + "chain": "test", + "txHash": "0xdeadbeef", +} +`; + +exports[`TransactionStateMachine with mocks transaction receipt fails due to timeout 1`] = ` +Transaction { + "blockNumber": 1n, + "blockTimestamp": 0n, + "chain": "test", + "txHash": "0xdeadbeef", +} +`; + +exports[`TransactionStateMachine with mocks write fails due to timeout 1`] = ` +Transaction { + "blockNumber": 1n, + "blockTimestamp": 0n, + "chain": "test", + "txHash": "0xdeadbeef", +} +`; diff --git a/src/services/blockchain/__tests__/eth-bc-service.test.ts b/src/services/blockchain/__tests__/eth-bc-service.test.ts index 91542fb2b..183dc980e 100644 --- a/src/services/blockchain/__tests__/eth-bc-service.test.ts +++ b/src/services/blockchain/__tests__/eth-bc-service.test.ts @@ -3,23 +3,69 @@ import { afterAll, beforeAll, beforeEach, describe, expect, jest, test } from '@ import { CID } from 'multiformats/cid' import { config, Config } from 'node-config-ts' import { logger } from '../../../logger/index.js' -import { BigNumber, ethers } from 'ethers' import { BlockchainService } from '../blockchain-service.js' import { EthereumBlockchainService, MAX_RETRIES } from '../ethereum/ethereum-blockchain-service.js' -import { ErrorCode } from '@ethersproject/logger' import { readFile } from 'node:fs/promises' import cloneDeep from 'lodash.clonedeep' import { createInjector } from 'typed-inject' import type { GanacheServer } from '../../../__tests__/make-ganache.util.js' import { makeGanache } from '../../../__tests__/make-ganache.util.js' +import { + http, + Address, + Chain, + createPublicClient, + createWalletClient, + PublicClient, + WalletClient, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; + +class Scaffold { + readonly provider: PublicClient + readonly wallet: WalletClient + readonly contractAddress: Address + + constructor(provider: PublicClient, wallet: WalletClient, contractAddress: Address) { + this.provider = provider + this.wallet = wallet + this.contractAddress = contractAddress + } +} -const deployContract = async ( - provider: ethers.providers.JsonRpcProvider -): Promise => { - const wallet = new ethers.Wallet( - config.blockchain.connectors.ethereum.account.privateKey, - provider - ) +export const ganache = { + id: 1_337, + name: 'Foundry', + network: 'foundry', + nativeCurrency: { + decimals: 18, + name: 'Ether', + symbol: 'ETH', + }, + rpcUrls: { + default: { + http: ['http://127.0.0.1:8545'], + webSocket: ['ws://127.0.0.1:8545'], + }, + public: { + http: ['http://127.0.0.1:8545'], + webSocket: ['ws://127.0.0.1:8545'], + }, + }, +} as const satisfies Chain + +const scaffolding = async (url: string): Promise => { + const account = privateKeyToAccount(config.blockchain.connectors.ethereum.account.privateKey) + const transport = http(url) + const provider = createPublicClient({ + chain: ganache, + transport, + }) + const wallet = createWalletClient({ + transport, + account, + chain: ganache, + }) const artifactFilename = new URL( '../../../../contracts/out/CeramicAnchorServiceV2.sol/CeramicAnchorServiceV2.json', @@ -27,11 +73,12 @@ const deployContract = async ( ) const contractData = await readFile(artifactFilename, 'utf-8').then(JSON.parse) - const factory = new ethers.ContractFactory(contractData.abi, contractData.bytecode.object, wallet) - const contract = await factory.deploy() - await contract.deployed() - - return contract + const contractHash = await wallet.deployContract({ + bytecode: contractData.bytecode.object, + account, + }) + const tx = await provider.getTransaction({ hash: contractHash} ) + return new Scaffold(provider, wallet, tx.to) } describe('ETH service connected to ganache', () => { @@ -39,23 +86,21 @@ describe('ETH service connected to ganache', () => { let ganacheServer: GanacheServer let ethBc: BlockchainService let testConfig: Config - let providerForGanache: ethers.providers.JsonRpcProvider - let contract: ethers.Contract + let scaffold: Scaffold beforeAll(async () => { ganacheServer = await makeGanache() - providerForGanache = new ethers.providers.JsonRpcProvider(ganacheServer.url.href) - contract = await deployContract(providerForGanache) + scaffold = await scaffolding(ganacheServer.url.toString()) testConfig = cloneDeep(config) - testConfig.blockchain.connectors.ethereum.rpc.port = ganacheServer.port.toString() - testConfig.blockchain.connectors.ethereum.contractAddress = contract.address + testConfig.blockchain.connectors.ethereum.rpc.url = ganacheServer.url.toString() + testConfig.blockchain.connectors.ethereum.contractAddress = scaffold.contractAddress.address testConfig.useSmartContractAnchors = false const injector = createInjector() .provideValue('config', testConfig) .provideFactory('blockchainService', EthereumBlockchainService.make) - ethBc = injector.resolve('blockchainService') + ethBc = await injector.resolve('blockchainService') await ethBc.connect() }) @@ -64,399 +109,32 @@ describe('ETH service connected to ganache', () => { await ganacheServer.close() }) - describe('v0', () => { - test('should send CID to local ganache server', async () => { - const block = await providerForGanache.getBlock(await providerForGanache.getBlockNumber()) - const startTimestamp = block.timestamp - const startBlockNumber = block.number - - const cid = CID.parse('bafyreic5p7grucmzx363ayxgoywb6d4qf5zjxgbqjixpkokbf5jtmdj5ni') - const tx = await ethBc.sendTransaction(cid) - expect(tx).toBeDefined() - - // checking the timestamp + block number against the snapshot is too brittle since if the test runs slowly it - // can be off slightly. So we test it manually here instead. - const blockTimestamp = tx.blockTimestamp - delete tx.blockTimestamp - const blockNumber = tx.blockNumber - delete tx.blockNumber - expect(blockTimestamp).toBeGreaterThan(startTimestamp) - expect(blockNumber).toBeGreaterThan(startBlockNumber) - - expect(tx).toMatchSnapshot() - }) - - test('can fetch chainId properly', async () => { - const chainId = ethBc.chainId - expect(chainId).toEqual('eip155:1337') - }) - - test('gas price increase math', () => { - const gasEstimate = { - maxFeePerGas: BigNumber.from(2000), - maxPriorityFeePerGas: BigNumber.from(1000), - gasPrice: BigNumber.from(0), - } - const firstRetry = BigNumber.from(1100) - // Note that this is not 1200. It needs to be 10% over the previous attempt's gas, - // not 20% over the gas estimate - const secondRetry = BigNumber.from(1210) - expect( - EthereumBlockchainService.increaseGasPricePerAttempt( - gasEstimate.maxPriorityFeePerGas, - 0, - undefined - ).toNumber() - ).toEqual(gasEstimate.maxPriorityFeePerGas.toNumber()) - expect( - EthereumBlockchainService.increaseGasPricePerAttempt( - gasEstimate.maxPriorityFeePerGas, - 1, - gasEstimate.maxPriorityFeePerGas - ).toNumber() - ).toEqual(firstRetry.toNumber()) - expect( - EthereumBlockchainService.increaseGasPricePerAttempt( - gasEstimate.maxPriorityFeePerGas, - 2, - firstRetry - ).toNumber() - ).toEqual(secondRetry.toNumber()) - }) - }) - - describe('v1', () => { - beforeAll(() => { - testConfig.useSmartContractAnchors = true - }) + test('should anchor to contract', async () => { + const block = await scaffold.provider.getBlock() + const startTimestamp = block.timestamp + const startBlockNumber = block.number - afterAll(() => { - testConfig.useSmartContractAnchors = false + const filter = await scaffold.provider.createEventFilter({ + address: scaffold.contractAddress, }) - test('should anchor to contract', async () => { - const block = await providerForGanache.getBlock('latest') - const startTimestamp = block.timestamp - const startBlockNumber = block.number - - const cid = CID.parse('bafyreic5p7grucmzx363ayxgoywb6d4qf5zjxgbqjixpkokbf5jtmdj5ni') - const ethBc = EthereumBlockchainService.make(testConfig) - await ethBc.connect() - const tx = await ethBc.sendTransaction(cid) - expect(tx).toBeDefined() - const txReceipt = await providerForGanache.getTransactionReceipt(tx.txHash) - const contractEvents = txReceipt.logs.map((log) => contract.interface.parseLog(log)) - - expect(contractEvents.length).toEqual(1) - const didAnchorEvent = contractEvents[0] - expect(didAnchorEvent.name).toEqual('DidAnchor') - expect(didAnchorEvent.args['_root']).toEqual( - '0x5d7fcd1a0999befdb062e6762c1f0f902f729b98304a2ef539412f53360d3d6a' - ) - - // checking the values against the snapshot is too brittle since ganache is time based so we test manually - expect(tx.blockTimestamp).toBeGreaterThan(startTimestamp) - expect(tx.blockNumber).toBeGreaterThan(startBlockNumber) - }) - }) -}) - -describe('setGasPrice', () => { - const cid = CID.parse('bafyreic5p7grucmzx363ayxgoywb6d4qf5zjxgbqjixpkokbf5jtmdj5ni') - const feeData = { - maxFeePerGas: BigNumber.from(2000), - maxPriorityFeePerGas: BigNumber.from(1000), - gasPrice: BigNumber.from(1000), - } - const gasLimit = BigNumber.from(10) - const provider = { - estimateGas: jest.fn(() => gasLimit), - getNetwork: jest.fn(() => ({ chainId: '1337' })), - getTransactionCount: jest.fn(), - getFeeData: jest.fn(() => feeData), - } - - const buildBlockchainService = async (provider: any): Promise => { - const wallet = { - address: 'abcd1234', - provider: provider, - sendTransaction: jest.fn(), - } - const ethBc = new EthereumBlockchainService(config, wallet as any) - await ethBc.connect() - return ethBc - } - - test('legacy transaction', async () => { - const legacyProvider = Object.assign({}, provider, { - getFeeData: jest.fn(() => ({ gasPrice: feeData.gasPrice })), - }) - const ethBc = await buildBlockchainService(legacyProvider) - const txData = await ethBc._buildTransactionRequest(cid) - for (const attempt of [0, 1, 2]) { - await ethBc.setGasPrice(txData, attempt) - expect(txData).toMatchSnapshot() - } - }) - - test('EIP1559 transaction', async () => { - const ethBc = await buildBlockchainService(provider) - const txData = await ethBc._buildTransactionRequest(cid) - for (const attempt of [0, 1, 2]) { - await ethBc.setGasPrice(txData, attempt) - expect(txData).toMatchSnapshot() - } - }) -}) - -describe('ETH service with mock wallet', () => { - let ethBc: EthereumBlockchainService - const provider = { - estimateGas: jest.fn(), - getBalance: jest.fn(), - getBlock: jest.fn(), - getGasPrice: jest.fn(), - getNetwork: jest.fn(), - getTransactionCount: jest.fn(), - waitForTransaction: jest.fn(), - getFeeData: jest.fn(), - } - const wallet = { - address: 'abcd1234', - provider: provider, - sendTransaction: jest.fn(), - } - - beforeEach(async () => { - ethBc = new EthereumBlockchainService(config, wallet as any) - - provider.getNetwork.mockReturnValue({ chainId: '1337' }) - await ethBc.connect() - }) - - test('build transaction request', async () => { - const nonce = 5 - provider.getTransactionCount.mockReturnValue(nonce) - - const cid = CID.parse('bafyreic5p7grucmzx363ayxgoywb6d4qf5zjxgbqjixpkokbf5jtmdj5ni') - const txData = await ethBc._buildTransactionRequest(cid) - expect(txData).toMatchSnapshot() - }) - - test('single transaction attempt', async () => { - const nonce = 5 - const gasPrice = BigNumber.from(1000) - const gasEstimate = BigNumber.from(10 * 1000) - const txnResponse = { - hash: '0x12345abcde', - confirmations: 3, - from: 'me', - chainId: '1337', - } - const txReceipt = { - byzantium: true, - status: 1, - blockHash: '0x54321', - blockNumber: 54321, - transactionHash: txnResponse.hash, - } - const block = { - timestamp: 54321000, - } - - provider.getGasPrice.mockReturnValue(gasPrice) - provider.estimateGas.mockReturnValue(gasEstimate) - wallet.sendTransaction.mockReturnValue(txnResponse) - provider.waitForTransaction.mockReturnValue(txReceipt) - provider.getBlock.mockReturnValue(block) - - const txRequest = { - to: wallet.address, - data: '0x987654321', - nonce, - gasPrice: gasPrice, - gasLimit: gasEstimate, - } - const txResponse = await ethBc._trySendTransaction(txRequest) - expect(txResponse).toMatchSnapshot() - const tx = await ethBc._confirmTransactionSuccess(txResponse) - expect(tx).toMatchSnapshot() - - const txData = wallet.sendTransaction.mock.calls[0][0] - expect(txData).toMatchSnapshot() - }) - - test('successful mocked transaction', async () => { - const balance = BigNumber.from(10 * 1000 * 1000) - const nonce = 5 - const feeData = { - maxFeePerGas: BigNumber.from(2000), - maxPriorityFeePerGas: BigNumber.from(1000), - gasPrice: BigNumber.from(1000), - } - const gasEstimate = BigNumber.from(10 * 1000) - const txResponse = { foo: 'bar' } - const finalTransactionResult = { txHash: '0x12345' } - - provider.getBalance.mockReturnValue(balance) - provider.getTransactionCount.mockReturnValue(nonce) - provider.getGasPrice.mockReturnValue(feeData.gasPrice) - provider.estimateGas.mockReturnValue(gasEstimate) - provider.getFeeData.mockReturnValue(feeData) - - const mockTrySendTransaction = jest.fn() - const mockConfirmTransactionSuccess = jest.fn() - ethBc._trySendTransaction = mockTrySendTransaction as jest.Mocked< - typeof ethBc._trySendTransaction - > - ethBc._confirmTransactionSuccess = mockConfirmTransactionSuccess as jest.Mocked< - typeof ethBc._confirmTransactionSuccess - > - mockTrySendTransaction.mockReturnValue(txResponse) - mockConfirmTransactionSuccess.mockReturnValue(finalTransactionResult) const cid = CID.parse('bafyreic5p7grucmzx363ayxgoywb6d4qf5zjxgbqjixpkokbf5jtmdj5ni') - await expect(ethBc.sendTransaction(cid)).resolves.toEqual(finalTransactionResult) - - expect(mockTrySendTransaction).toHaveBeenCalledTimes(1) - const [txData] = mockTrySendTransaction.mock.calls[0] - expect(txData).toMatchSnapshot() - - expect(mockConfirmTransactionSuccess).toHaveBeenCalledTimes(1) - const [txResponseReceived] = mockConfirmTransactionSuccess.mock.calls[0] - expect(txResponseReceived).toEqual(txResponse) - }) - - test('insufficient funds error', async () => { - const balance = BigNumber.from(10 * 1000 * 1000) - const nonce = 5 - const gasPrice = BigNumber.from(1000) - const gasEstimate = BigNumber.from(10 * 1000) - const feeData = { - maxFeePerGas: BigNumber.from(2000), - maxPriorityFeePerGas: BigNumber.from(1000), - gasPrice: BigNumber.from(1000), - } - - provider.getBalance.mockReturnValue(balance) - provider.getTransactionCount.mockReturnValue(nonce) - provider.getGasPrice.mockReturnValue(gasPrice) - provider.estimateGas.mockReturnValue(gasEstimate) - provider.getFeeData.mockReturnValue(feeData) + const ethBc = await EthereumBlockchainService.make(testConfig) + await ethBc.connect() + const tx = await ethBc.sendTransaction(cid) + expect(tx).toBeDefined() - const mockTrySendTransaction = jest.fn() - ethBc._trySendTransaction = mockTrySendTransaction as jest.Mocked< - typeof ethBc._trySendTransaction - > - mockTrySendTransaction - .mockRejectedValueOnce({ code: ErrorCode.TIMEOUT }) - .mockRejectedValueOnce({ code: ErrorCode.INSUFFICIENT_FUNDS }) + const contractEvents = await scaffold.provider.getFilterLogs({ filter }) - const cid = CID.parse('bafyreic5p7grucmzx363ayxgoywb6d4qf5zjxgbqjixpkokbf5jtmdj5ni') - await expect(ethBc.sendTransaction(cid)).rejects.toThrow( - /Transaction cost is greater than our current balance/ + expect(contractEvents.length).toEqual(1) + const didAnchorEvent = contractEvents[0] + expect(didAnchorEvent.name).toEqual('DidAnchor') + expect(didAnchorEvent.args['_root']).toEqual( + '0x5d7fcd1a0999befdb062e6762c1f0f902f729b98304a2ef539412f53360d3d6a' ) - // In the first attempt we have exactly enough balance in our wallet to cover the cost, but the - // transaction times out. On retry, the gas cost is increased and goes over the wallet balance, - // causing the attempt to be aborted. - expect(mockTrySendTransaction).toHaveBeenCalledTimes(2) - - const [txData0] = mockTrySendTransaction.mock.calls[0] - expect(txData0).toMatchSnapshot() - - const [txData1] = mockTrySendTransaction.mock.calls[1] - expect(txData1).toMatchSnapshot() - }) - - test('timeout error', async () => { - const balance = BigNumber.from(10 * 1000 * 1000) - const nonce = 5 - const gasPrice = BigNumber.from(1000) - const gasEstimate = BigNumber.from(10 * 1000) - const txResponse = { foo: 'bar' } - const feeData = { - maxFeePerGas: BigNumber.from(2000), - maxPriorityFeePerGas: BigNumber.from(1000), - gasPrice: BigNumber.from(1000), - } - - provider.getBalance.mockReturnValue(balance) - provider.getTransactionCount.mockReturnValue(nonce) - provider.getGasPrice.mockReturnValue(gasPrice) - provider.estimateGas.mockReturnValue(gasEstimate) - provider.getFeeData.mockReturnValue(feeData) - - const mockTrySendTransaction = jest.fn() - const mockConfirmTransactionSuccess = jest.fn() - ethBc._trySendTransaction = mockTrySendTransaction as jest.Mocked< - typeof ethBc._trySendTransaction - > - ethBc._confirmTransactionSuccess = mockConfirmTransactionSuccess as jest.Mocked< - typeof ethBc._confirmTransactionSuccess - > - mockTrySendTransaction.mockReturnValue(txResponse) - mockConfirmTransactionSuccess.mockRejectedValue({ code: ErrorCode.TIMEOUT }) - - const cid = CID.parse('bafyreic5p7grucmzx363ayxgoywb6d4qf5zjxgbqjixpkokbf5jtmdj5ni') - await expect(ethBc.sendTransaction(cid)).rejects.toThrow('Failed to send transaction') - - expect(mockTrySendTransaction).toHaveBeenCalledTimes(MAX_RETRIES) - expect(mockConfirmTransactionSuccess).toHaveBeenCalledTimes(MAX_RETRIES) - }) - - test('nonce expired error', async () => { - // test what happens if a transaction is submitted, waiting for it to be mined times out, but - // then before the retry the original txn gets mined, causing a NONCE_EXPIRED error on the retry - const balance = BigNumber.from(10 * 1000 * 1000) - const nonce = 5 - const gasPrice = BigNumber.from(1000) - const gasEstimate = BigNumber.from(10 * 1000) - const txResponses = [{ attempt: 1 }, { attempt: 2 }] - const finalTransactionResult = { txHash: '0x12345' } - const feeData = { - maxFeePerGas: BigNumber.from(2000), - maxPriorityFeePerGas: BigNumber.from(1000), - gasPrice: BigNumber.from(1000), - } - - provider.getBalance.mockReturnValue(balance) - provider.getTransactionCount.mockReturnValue(nonce) - provider.getGasPrice.mockReturnValue(gasPrice) - provider.estimateGas.mockReturnValue(gasEstimate) - provider.getFeeData.mockReturnValue(feeData) - - const mockTrySendTransaction = jest.fn() - const mockConfirmTransactionSuccess = jest.fn() - ethBc._trySendTransaction = mockTrySendTransaction as jest.Mocked< - typeof ethBc._trySendTransaction - > - ethBc._confirmTransactionSuccess = mockConfirmTransactionSuccess as jest.Mocked< - typeof ethBc._confirmTransactionSuccess - > - // Successfully submit transaction - mockTrySendTransaction.mockReturnValueOnce(txResponses[0]) - // Get timeout waiting for it to be mined - mockConfirmTransactionSuccess.mockRejectedValueOnce({ code: ErrorCode.TIMEOUT }) - // Retry the transaction, submit it successfully - mockTrySendTransaction.mockReturnValueOnce(txResponses[1]) - // Get timeout waiting for the second attempt as well - mockConfirmTransactionSuccess.mockRejectedValueOnce({ code: ErrorCode.TIMEOUT }) - // On third attempt we get a NONCE_EXPIRED error because the first attempt was actually mined correctly - mockTrySendTransaction.mockRejectedValueOnce({ code: ErrorCode.NONCE_EXPIRED }) - // Try to confirm the second attempt, get NONCE_EXPIRED because it was the first attempt that - // was mined - mockConfirmTransactionSuccess.mockRejectedValueOnce({ code: ErrorCode.NONCE_EXPIRED }) - // Try to confirm the original attempt, succeed - mockConfirmTransactionSuccess.mockReturnValueOnce(finalTransactionResult) - - const cid = CID.parse('bafyreic5p7grucmzx363ayxgoywb6d4qf5zjxgbqjixpkokbf5jtmdj5ni') - await expect(ethBc.sendTransaction(cid)).resolves.toEqual(finalTransactionResult) - - expect(mockTrySendTransaction).toHaveBeenCalledTimes(3) - expect(mockConfirmTransactionSuccess).toHaveBeenCalledTimes(4) - expect(mockConfirmTransactionSuccess.mock.calls[0][0]).toEqual(txResponses[0]) - expect(mockConfirmTransactionSuccess.mock.calls[1][0]).toEqual(txResponses[1]) - expect(mockConfirmTransactionSuccess.mock.calls[2][0]).toEqual(txResponses[1]) - expect(mockConfirmTransactionSuccess.mock.calls[3][0]).toEqual(txResponses[0]) + // checking the values against the snapshot is too brittle since ganache is time based so we test manually + expect(tx.blockTimestamp).toBeGreaterThan(startTimestamp) + expect(tx.blockNumber).toBeGreaterThan(startBlockNumber) }) }) diff --git a/src/services/blockchain/__tests__/transaction-state-machine.test.ts b/src/services/blockchain/__tests__/transaction-state-machine.test.ts new file mode 100644 index 000000000..2b3f298ca --- /dev/null +++ b/src/services/blockchain/__tests__/transaction-state-machine.test.ts @@ -0,0 +1,127 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, jest, test } from '@jest/globals' +import {Block, ContractParameters, EthereumClient, FeeHistory, TransactionReceipt} from "../ethereum/ethereum-client.js" +import {Address} from "viem" +import {TransactionHash} from "../ethereum/ethereum-wallet.js" +import {TransactionStateMachine} from "../ethereum/transaction-state-machine.js"; +import {CID} from "multiformats/cid"; + +const FROM = '0x1234' +const WALLET = '0x1234abcd' +const CONTRACT = '0xfeed' + +class MockEthereumClient implements EthereumClient { + async getChainId(): Promise { + return 1337 + } + + async simulateContract(opts: ContractParameters): Promise { + return + } + + async waitForTransactionReceipt(hash: Address): Promise { + return { + blockHash: hash, + from: FROM, + successful: true, + } + } + + async getBlock(hash: Address): Promise { + return { + blockNumber: 1n, + blockHash: hash, + timestamp: 0n, + } + } + + async getFeeHistory(): Promise { + return { + baseFeePerGas: 100n, + } + } +} + +class MockEthereumWallet { + readonly address = WALLET + async writeContract(req: ContractParameters): Promise { + return '0xdeadbeef' + } +} + +describe('TransactionStateMachine with mocks', () => { + test('no errors encountered', async () => { + const provider = new MockEthereumClient() + const wallet = new MockEthereumWallet() + const cid = CID.parse('bafyreic5p7grucmzx363ayxgoywb6d4qf5zjxgbqjixpkokbf5jtmdj5ni') + const tsm = new TransactionStateMachine('test', provider, wallet, CONTRACT, cid) + const res = await tsm.run() + expect(res).toMatchSnapshot() + }) + + test('simulate fails due to timeout', async () => { + const provider = new MockEthereumClient() + const wallet = new MockEthereumWallet() + const simulateSpy = jest.spyOn(provider, 'simulateContract') + .mockImplementationOnce(() => { + throw new Error('Timeout') + }) + const writeSpy = jest.spyOn(wallet, 'writeContract') + const cid = CID.parse('bafyreic5p7grucmzx363ayxgoywb6d4qf5zjxgbqjixpkokbf5jtmdj5ni') + const tsm = new TransactionStateMachine('test', provider, wallet, CONTRACT, cid) + const res = await tsm.run() + expect(res).toMatchSnapshot() + expect(simulateSpy.mock.calls.length).toEqual(2) + expect(writeSpy.mock.calls.length).toEqual(1) + }) + + test('write fails due to timeout', async () => { + const provider = new MockEthereumClient() + const wallet = new MockEthereumWallet() + const writeSpy = jest.spyOn(wallet, 'writeContract') + .mockImplementationOnce(() => { + throw new Error('Timeout') + }) + const transactionSpy = jest.spyOn(provider, 'waitForTransactionReceipt') + const cid = CID.parse('bafyreic5p7grucmzx363ayxgoywb6d4qf5zjxgbqjixpkokbf5jtmdj5ni') + const tsm = new TransactionStateMachine('test', provider, wallet, CONTRACT, cid) + const res = await tsm.run() + expect(res).toMatchSnapshot() + expect(writeSpy.mock.calls.length).toEqual(2) + expect(transactionSpy.mock.calls.length).toEqual(1) + }) + + test('transaction receipt fails due to timeout', async () => { + const provider = new MockEthereumClient() + const wallet = new MockEthereumWallet() + const writeSpy = jest.spyOn(wallet, 'writeContract') + const transactionSpy = jest.spyOn(provider, 'waitForTransactionReceipt') + .mockImplementationOnce(() => { + throw new Error('Timeout') + }) + const blockSpy = jest.spyOn(provider, 'getBlock') + const cid = CID.parse('bafyreic5p7grucmzx363ayxgoywb6d4qf5zjxgbqjixpkokbf5jtmdj5ni') + const tsm = new TransactionStateMachine('test', provider, wallet, CONTRACT, cid) + const res = await tsm.run() + expect(res).toMatchSnapshot() + expect(writeSpy.mock.calls.length).toEqual(1) + expect(transactionSpy.mock.calls.length).toEqual(2) + expect(blockSpy.mock.calls.length).toEqual(1) + }) + + test('get block fails due to timeout', async () => { + const provider = new MockEthereumClient() + const wallet = new MockEthereumWallet() + const transactionSpy = jest.spyOn(provider, 'waitForTransactionReceipt') + const blockSpy = jest.spyOn(provider, 'getBlock') + .mockImplementationOnce(() => { + throw new Error('Timeout') + }) + const cid = CID.parse('bafyreic5p7grucmzx363ayxgoywb6d4qf5zjxgbqjixpkokbf5jtmdj5ni') + const tsm = new TransactionStateMachine('test', provider, wallet, CONTRACT, cid) + const res = await tsm.run() + expect(res).toMatchSnapshot() + expect(transactionSpy.mock.calls.length).toEqual(1) + expect(blockSpy.mock.calls.length).toEqual(2) + }) + +}) diff --git a/src/services/blockchain/ethereum/ethereum-blockchain-service.ts b/src/services/blockchain/ethereum/ethereum-blockchain-service.ts index 41463d456..75a55b0f7 100644 --- a/src/services/blockchain/ethereum/ethereum-blockchain-service.ts +++ b/src/services/blockchain/ethereum/ethereum-blockchain-service.ts @@ -1,84 +1,25 @@ import type { CID } from 'multiformats/cid' -import { base16 } from 'multiformats/bases/base16' -import { ErrorCode } from '@ethersproject/logger' -import { BigNumber, BigNumberish, Contract, ethers } from 'ethers' import { Config } from 'node-config-ts' -import * as uint8arrays from 'uint8arrays' -import { logger, logEvent, logMetric } from '../../../logger/index.js' +import { logger } from '../../../logger/index.js' import { Transaction } from '../../../models/transaction.js' import { BlockchainService } from '../blockchain-service.js' import { - TransactionRequest, - TransactionResponse, - TransactionReceipt, -} from '@ethersproject/abstract-provider' -import { Utils } from '../../../utils.js' + Address, + createPublicClient, + createWalletClient, + http, +} from 'viem' +import * as chains from 'viem/chains' +import {privateKeyToAccount} from "viem/accounts"; +import {EthereumClient, ViemEthereumClient} from "./ethereum-client.js"; +import {EthereumWallet, ViemEthereumWallet} from "./ethereum-wallet.js"; +import {TransactionStateMachine} from "./transaction-state-machine.js"; const BASE_CHAIN_ID = 'eip155' -const TX_FAILURE = 0 -const TX_SUCCESS = 1 -const NUM_BLOCKS_TO_WAIT = 4 export const MAX_RETRIES = 3 const POLLING_INTERVAL = 15 * 1000 // every 15 seconds -const ABI = ['function anchorDagCbor(bytes32)'] - -class WrongChainIdError extends Error { - constructor(expected: number, actual: number) { - super( - `Chain ID of connected blockchain changed from ${caipChainId(expected)} to ${caipChainId( - actual - )}` - ) - } -} - -/** - * Do up to +max+ attempts of an +operation+. Expect the +operation+ to return a defined value. - * If no defined value is returned, iterate at most +max+ times. - * - * @param max - Maximum number of attempts. - * @param operation - Operation to run. - */ -async function attempt( - max: number, - operation: (attempt: number) => Promise -): Promise { - let attempt = 0 - while (attempt < max) { - const result = await operation(attempt) - if (result) { - return result - } - attempt++ - logger.warn(`Failed to send transaction; ${max - attempt} retries remain`) - await Utils.delay(5000) - } - // All attempts spent - throw new Error('Failed to send transaction') -} - -/** - * Throw if a transaction requires more funds than available. - * - * @param txData - Transaction to write. - * @param walletBalance - Available funds. - */ -function handleInsufficientFundsError(txData: TransactionRequest, walletBalance: BigNumber): void { - const txCost = (txData.gasLimit as BigNumber).mul(txData.maxFeePerGas!) - if (txCost.gt(walletBalance)) { - logEvent.ethereum({ - type: 'insufficientFunds', - txCost: txCost, - balance: ethers.utils.formatUnits(walletBalance, 'gwei'), - }) - - const errMsg = `Transaction cost is greater than our current balance. [txCost: ${txCost.toHexString()}, balance: ${walletBalance.toHexString()}]` - logger.err(errMsg) - throw new Error(errMsg) - } -} /** * Represent chainId in CAIP format. @@ -88,49 +29,34 @@ function caipChainId(chainId: number) { return `${BASE_CHAIN_ID}:${chainId}` } -/** - * Throw if +actual+ and +expected+ chain ids are not equal. - * - * @param actual - Chain id we received. - * @param expected - Chain id we expect. - */ -function assertSameChainId(actual: number, expected: number) { - if (actual != expected) { - // TODO: This should be process-fatal - throw new WrongChainIdError(expected, actual) - } -} - -/** - * Just log a timeout error. - */ -function handleTimeoutError(transactionTimeoutSecs: number): void { - logEvent.ethereum({ - type: 'transactionTimeout', - transactionTimeoutSecs: transactionTimeoutSecs, - }) - logger.err(`Transaction timed out after ${transactionTimeoutSecs} seconds without being mined`) -} - -function make(config: Config): EthereumBlockchainService { +async function make(config: Config): Promise { const ethereum = config.blockchain.connectors.ethereum const { host, port, url } = ethereum.rpc - let provider + const chain = chains.find(ch => ch.network == ethereum.network) + let transport if (url) { logger.imp(`Connecting ethereum provider to url: ${url}`) - provider = new ethers.providers.StaticJsonRpcProvider(url) + transport = http(url) } else if (host && port) { logger.imp(`Connecting ethereum provider to host: ${host} and port ${port}`) - provider = new ethers.providers.StaticJsonRpcProvider(`${host}:${port}`) + transport = http(`http://${host}:${port}`) } else { logger.imp(`Connecting ethereum to default provider for network ${ethereum.network}`) - provider = ethers.getDefaultProvider(ethereum.network) - } - - provider.pollingInterval = POLLING_INTERVAL - const wallet = new ethers.Wallet(ethereum.account.privateKey, provider) - return new EthereumBlockchainService(config, wallet) + transport = http() + } + const provider = new ViemEthereumClient(createPublicClient({ + chain, + transport, + pollingInterval: POLLING_INTERVAL, + })) + + const wallet = await ViemEthereumWallet.create(createWalletClient({ + chain, + transport, + account: privateKeyToAccount(ethereum.account.privateKey) + })) + return new EthereumBlockchainService(config, provider, wallet) } make.inject = ['config'] as const @@ -140,22 +66,16 @@ make.inject = ['config'] as const export class EthereumBlockchainService implements BlockchainService { private _chainId: number | undefined private readonly network: string - private readonly transactionTimeoutSecs: number - private readonly contract: Contract - private readonly overrideGasConfig: boolean - private readonly gasLimit: number - private readonly useSmartContractAnchors: boolean - private readonly contractAddress: string + private readonly contractAddress: Address + private readonly provider: EthereumClient + private readonly wallet: EthereumWallet - constructor(config: Config, private readonly wallet: ethers.Wallet) { - this.useSmartContractAnchors = config.useSmartContractAnchors + constructor(config: Config, provider: EthereumClient, wallet: EthereumWallet) { const ethereumConfig = config.blockchain.connectors.ethereum this.network = ethereumConfig.network - this.transactionTimeoutSecs = ethereumConfig.transactionTimeoutSecs - this.contract = new ethers.Contract(ethereumConfig.contractAddress, ABI) - this.overrideGasConfig = ethereumConfig.overrideGasConfig - this.gasLimit = ethereumConfig.gasLimit - this.contractAddress = ethereumConfig.contractAddress + this.contractAddress = ethereumConfig.contractAddress as Address + this.provider = provider + this.wallet = wallet } static make = make @@ -174,109 +94,7 @@ export class EthereumBlockchainService implements BlockchainService { * connected blockchain to ask for it. */ private async _loadChainId(): Promise { - const network = await this.wallet.provider.getNetwork() - this._chainId = network.chainId - } - - /** - * Sets the gas price for the transaction request. - * For pre-1559 transaction we increase vanilla gasPrice by 10% each time. For a 1559 transaction, we increase maxPriorityFeePerGas, - * again by 10% each time. - * - * For 1559 there are two parameters that can be set on a transaction: maxPriorityFeePerGas and maxFeePerGas. - * maxFeePerGas should equal to `maxPriorityFeePerGas` (our tip to a miner) plus `baseFee` (ETH burned according to current network conditions). - * To estimate the current parameters, we use `getFeeData` function, which returns two of our parameters. - * Here we _can_ calculate `baseFee`, but also we can avoid doing that. Remember, we increase just `maxPriorityFeePerGas`. - * Here we calculate a difference between previously sent `maxPriorityFeePerGas` and the increased one. It is our voluntary increase in gas price we agree to pay to mine our transaction. - * We just add the difference to a currently estimated `maxFeePerGas` so that we conform to the equality `maxFeePerGas = baseFee + maxPriorityFeePerGas`. - * - * NB. EIP1559 now uses two components of gas cost: `baseFee` and `maxPriorityFeePerGas`. `maxPriorityFeePerGas` is a tip to a miner to include a transaction into a block. `baseFee` is a slowly changing amount of gas or ether that is going to be burned. `baseFee` is set by _network_. Since we do not know what `baseFee` will be, EIP1559 introduces `maxFeePerGas` which is an absolute maximum you are willing to pay for a transaction. `maxFeePerGas` must be `>= maxPriorityFeePerGas + baseFee`. The inequality here is to accommodate for changes in `baseFee`. If `maxFeePerGas` appears to be less than the sum, the transaction is underpriced. If it is greater than the sum (`maxFeePerGas = maxPriorityFeePerGas + baseFee + δ`): - * - if `baseFee` changes up to `δ`, the transaction can be mined still; `δ` is like a safety buffer; - * - transaction fee that is deducted from your wallet still equals `maxPriorityFeePerGas + baseFee`, no matter what `maxFeePerGas` you have set. - * - * To price a 1559 transaction, we use an estimate from `provider.getFeeData`. It returns `maxFeePerGas` and `maxPriorityFeePerGas`. It is worth noting here that `maxFeePerGas` returned from ethers uses a [widely recommended](https://www.blocknative.com/blog/eip-1559-fees) formula: `(baseFee of the latest block)*2 + (constant 2.5Gwei)`. If we only increase `maxPriorityFeePerGas` per attempt, we effectively deduct from our baseFee safety buffer `δ` which reduces transaction's chances. Our intent though is to increase a transaction's "mineability". So, when increasing `maxPriorityFeePerGas` we also increase `maxFeePerGas` by the same amount. Now the safety buffer reflects current network conditions, and we actually increase our transaction's "mineability". - * - * @param txData - transaction request data - * @param attempt - what number attempt this is at submitting the transaction. We increase - * the gas price we set by a 10% multiple with each subsequent attempt - * @private - */ - async setGasPrice(txData: TransactionRequest, attempt: number): Promise { - if (this.overrideGasConfig) { - txData.gasLimit = BigNumber.from(this.gasLimit) - logger.debug('Overriding Gas limit: ' + txData.gasLimit.toString()) - return - } - - const feeData = await this.wallet.provider.getFeeData() - // Add extra to gas price for each subsequent attempt - const maxFeePerGas = feeData.maxFeePerGas - const maxPriorityFeePerGas = feeData.maxPriorityFeePerGas - // Is EIP-1559 - if (maxPriorityFeePerGas && maxFeePerGas) { - // When attempt 0, use currently estimated maxPriorityFeePerGas; otherwise use previous transaction maxPriorityFeePerGas - const prevPriorityFee = BigNumber.from( - txData.maxPriorityFeePerGas || feeData.maxPriorityFeePerGas - ) - const nextPriorityFee = EthereumBlockchainService.increaseGasPricePerAttempt( - maxPriorityFeePerGas, - attempt, - prevPriorityFee - ) - txData.maxPriorityFeePerGas = nextPriorityFee - const baseFee = maxFeePerGas.sub(maxPriorityFeePerGas) - txData.maxFeePerGas = baseFee.add(nextPriorityFee) - logger.debug( - `Estimated maxPriorityFeePerGas: ${nextPriorityFee.toString()} wei; maxFeePerGas: ${txData.maxFeePerGas.toString()} wei` - ) - } else { - const feeDataGasPrice = feeData.gasPrice - if (!feeDataGasPrice) throw new Error(`Unavailable gas price for pre-EIP-1559 transaction`) - // When attempt 0, use currently estimated gasPrice; otherwise use previous transaction gasPrice - const prevGasPrice = BigNumber.from(txData.gasPrice || feeData.gasPrice) - txData.gasPrice = EthereumBlockchainService.increaseGasPricePerAttempt( - feeDataGasPrice, - attempt, - prevGasPrice - ) - logger.debug(`Estimated gasPrice: ${txData.gasPrice.toString()} wei`) - } - - txData.gasLimit = await this.wallet.provider.estimateGas(txData) - logger.debug('Estimated Gas limit: ' + txData.gasLimit.toString()) - } - - /** - * Take current gas price (or maxPriorityFeePerGas for 1559 transaction), and attempt number, - * and return new gas price (or maxPriorityFeePerGas for 1559 transaction) with 10% increase per attempt. - * If this isn't the first attempt, also ensures that the new gas is at least 10% greater than the - * previous attempt's gas, even if the gas price on chain has gone down since then. This is because - * retries of the same transaction (using the same nonce) need to have a gas price at least 10% higher - * than any previous attempts or the transaction will fail. - * - * @param estimate - Currently estimated gas price - * @param attempt - Index of a current attempt, starts with 0. - * @param previousGas - Either gasPrice for pre-1559 tx or maxPriorityFeePerGas for 1559 tx. - */ - static increaseGasPricePerAttempt( - estimate: BigNumberish, - attempt: number, - previousGas: BigNumberish | undefined - ): BigNumber { - // Try to increase an estimated gas price first - const estimateBN = BigNumber.from(estimate) - const increase = estimateBN.div(10).mul(attempt) // 10% increase per attempt - const increaseEstimate = estimateBN.add(increase) - - if (attempt == 0 || previousGas == undefined) { - return increaseEstimate - } - // Then try to increase a current transaction gas price - const previousGasBN = BigNumber.from(previousGas) - const increaseTransaction = previousGasBN.add(previousGasBN.div(10)) // +10% - - // Choose the bigger increase, either from current transaction or from increment - return increaseEstimate.gt(increaseTransaction) ? increaseEstimate : increaseTransaction + this._chainId = await this.provider.getChainId() } /** @@ -288,181 +106,17 @@ export class EthereumBlockchainService implements BlockchainService { return caipChainId(this._chainId) } - async _buildTransactionRequest(rootCid: CID): Promise { - logger.debug('Preparing ethereum transaction') - const baseNonce = await this.wallet.provider.getTransactionCount(this.wallet.address) - - if (!this.useSmartContractAnchors) { - const rootStrHex = rootCid.toString(base16) - const hexEncoded = '0x' + (rootStrHex.length % 2 == 0 ? rootStrHex : '0' + rootStrHex) - logger.debug(`Hex encoded root CID ${hexEncoded}`) - - return { - to: this.wallet.address, - data: hexEncoded, - nonce: baseNonce, - from: this.wallet.address, - } - } - - const hexEncoded = '0x' + uint8arrays.toString(rootCid.bytes.slice(4), 'base16') - // @ts-ignore `anchorDagCbor` is a Solidity function - const transactionRequest = await this.contract.populateTransaction.anchorDagCbor(hexEncoded) - return { - to: this.contractAddress, - data: transactionRequest.data, - nonce: baseNonce, - from: this.wallet.address, - } - } - - /** - * One attempt at submitting the prepared TransactionRequest to the ethereum blockchain. - * @param txData - */ - async _trySendTransaction(txData: TransactionRequest): Promise { - logger.imp('Transaction data:' + JSON.stringify(txData)) - - logEvent.ethereum({ - type: 'txRequest', - tx: txData, - }) - logger.imp(`Sending transaction to Ethereum ${this.network} network...`) - const txResponse: TransactionResponse = await this.wallet.sendTransaction(txData) - logEvent.ethereum({ - type: 'txResponse', - hash: txResponse.hash, - blockNumber: txResponse.blockNumber, - blockHash: txResponse.blockHash, - timestamp: txResponse.timestamp, - confirmations: txResponse.confirmations, - from: txResponse.from, - raw: txResponse.raw, - }) - - if (!this._chainId) throw new Error(`No chainId available`) - assertSameChainId(txResponse.chainId, this._chainId) - return txResponse - } - - /** - * Queries the blockchain to see if the submitted transaction was successfully mined, and returns - * the transaction info if so. - * @param txResponse - response from when the transaction was submitted to the mempool - */ - async _confirmTransactionSuccess(txResponse: TransactionResponse): Promise { - logger.imp(`Waiting to confirm transaction with hash ${txResponse.hash}`) - const txReceipt: TransactionReceipt = await this.wallet.provider.waitForTransaction( - txResponse.hash, - NUM_BLOCKS_TO_WAIT, - this.transactionTimeoutSecs * 1000 - ) - logEvent.ethereum({ - type: 'txReceipt', - tx: txReceipt, - }) - const block = await this.wallet.provider.getBlock(txReceipt.blockHash) - - const status = txReceipt.byzantium ? txReceipt.status : -1 - let statusMessage = status == TX_SUCCESS ? 'success' : 'failure' - if (!txReceipt.byzantium) { - statusMessage = 'unknown' - } - logger.imp( - `Transaction completed on Ethereum ${this.network} network. Transaction hash: ${txReceipt.transactionHash}. Status: ${statusMessage}.` - ) - if (status == TX_FAILURE) { - throw new Error('Transaction completed with a failure status') - } - - return new Transaction( - this.chainId, - txReceipt.transactionHash, - txReceipt.blockNumber, - block.timestamp - ) - } - - /** - * Queries the blockchain to see if any of the previously submitted transactions that had timed - * out went on to be successfully mined, and returns the transaction info if so. - * @param txResponses - responses from previous transaction submissions. - */ - async _checkForPreviousTransactionSuccess( - txResponses: Array - ): Promise { - for (let i = txResponses.length - 1; i >= 0; i--) { - const txResponse = txResponses[i] - if (!txResponse) continue - try { - return await this._confirmTransactionSuccess(txResponse) - } catch (err: any) { - logger.err(err) - } - } - throw new Error('Failed to confirm any previous transaction attempts') - } - /** * Sends transaction with root CID as data */ async sendTransaction(rootCid: CID): Promise { - const txData = await this._buildTransactionRequest(rootCid) - const txResponses: Array = [] - - return this.withWalletBalance((walletBalance) => { - return attempt(MAX_RETRIES, async (attemptNum) => { - try { - await this.setGasPrice(txData, attemptNum) - const txResponse = await this._trySendTransaction(txData) - txResponses.push(txResponse) - return await this._confirmTransactionSuccess(txResponse) - } catch (err: any) { - logger.err(err) - const { code } = err - switch (code) { - case ErrorCode.INSUFFICIENT_FUNDS: - return handleInsufficientFundsError(txData, walletBalance) - case ErrorCode.TIMEOUT: - return handleTimeoutError(this.transactionTimeoutSecs) - case ErrorCode.NONCE_EXPIRED: - // If this happens it most likely means that one of our previous attempts timed out, but - // then actually wound up being successfully mined - logEvent.ethereum({ - type: 'nonceExpired', - nonce: txData.nonce, - }) - if (attemptNum == 0 || txResponses.length == 0) { - throw err - } - return this._checkForPreviousTransactionSuccess(txResponses) - default: - return undefined - } - } - }) - }) - } - - /** - * Report wallet balance before and after +operation+. - * @param operation - */ - private async withWalletBalance(operation: (balance: BigNumber) => Promise): Promise { - const startingWalletBalance = await this.wallet.provider.getBalance(this.wallet.address) - logMetric.ethereum({ - type: 'walletBalance', - balance: ethers.utils.formatUnits(startingWalletBalance, 'gwei'), - }) - logger.debug(`Current wallet balance is ` + startingWalletBalance) - - const result = await operation(startingWalletBalance) - - const endingWalletBalance = await this.wallet.provider.getBalance(this.wallet.address) - logMetric.ethereum({ - type: 'walletBalance', - balance: ethers.utils.formatUnits(endingWalletBalance, 'gwei'), - }) - return result + const stateMachine = new TransactionStateMachine( + this.chainId, + this.provider, + this.wallet, + this.contractAddress, + rootCid + ) + return await stateMachine.run() } } diff --git a/src/services/blockchain/ethereum/ethereum-client.ts b/src/services/blockchain/ethereum/ethereum-client.ts new file mode 100644 index 000000000..80e0e625e --- /dev/null +++ b/src/services/blockchain/ethereum/ethereum-client.ts @@ -0,0 +1,87 @@ +import { + Address, + ParseAbi, PublicClient, +} from "viem"; + +export interface ContractParameters { + abi: ParseAbi, + functionName: string, + account: Address, + address: Address, + args: any[], + maxFeePerGas: bigint, + maxPriorityFeePerGas: bigint, +} + +export type BlockHash = Address + +export type TransactionReceipt = { + blockHash: BlockHash + from: Address + successful: boolean +} + +export type Block = { + blockHash: BlockHash | null + blockNumber: bigint | null + timestamp: bigint +} + +export type FeeHistory = { + baseFeePerGas: bigint +} + +export interface EthereumClient { + getChainId(): Promise + simulateContract(opts: ContractParameters): Promise + waitForTransactionReceipt(hash: Address): Promise + getBlock(hash: Address): Promise + getFeeHistory(): Promise +} + +export class ViemEthereumClient implements EthereumClient { + private readonly inner: PublicClient + + constructor(inner: PublicClient) { + this.inner = inner + } + + async getChainId(): Promise { + return await this.inner.getChainId() + } + + async simulateContract(opts: ContractParameters): Promise { + await this.inner.simulateContract(opts) + } + + async waitForTransactionReceipt(hash: Address): Promise { + const res = await this.inner.waitForTransactionReceipt(({ hash: hash})) + return { + blockHash: res.blockHash, + from: res.from, + successful: res.status == 'success' + } + } + + async getBlock(hash: Address): Promise { + const res = await this.inner.getBlock({blockHash: hash}) + return { + blockHash: res.hash, + blockNumber: res.number, + timestamp: res.timestamp + } + } + + async getFeeHistory(): Promise { + const res = await this.inner.getFeeHistory({ + blockCount: 4, + rewardPercentiles: [25, 75] + }) + if (res.baseFeePerGas.length == 0 || !res.baseFeePerGas[0]) { + throw new Error('Unable to get baseFeePerGas') + } + return { + baseFeePerGas: res.baseFeePerGas[0] + } + } +} diff --git a/src/services/blockchain/ethereum/ethereum-wallet.ts b/src/services/blockchain/ethereum/ethereum-wallet.ts new file mode 100644 index 000000000..019214b95 --- /dev/null +++ b/src/services/blockchain/ethereum/ethereum-wallet.ts @@ -0,0 +1,37 @@ +import {Address, WalletClient} from "viem"; +import {ContractParameters} from "./ethereum-client.js"; + +export type TransactionHash = Address + +export interface EthereumWallet { + readonly address: Address + writeContract(req: ContractParameters): Promise; +} + +export class ViemEthereumWallet implements EthereumWallet { + private readonly inner: WalletClient + readonly address: Address + + constructor(inner: WalletClient, address: Address) { + this.inner = inner + this.address = address + } + + public static async create(inner: WalletClient): Promise { + const walletAddress = (await inner.getAddresses()).at(0) + if(!walletAddress) { + throw new Error('No wallet addresses found') + } + return new ViemEthereumWallet(inner, walletAddress) + } + + async writeContract(req: ContractParameters): Promise { + return await this.inner.writeContract({ + abi: req.abi, + functionName: req.functionName, + address: req.address, + account: req.account, + chain: this.inner.chain, + }) + } +} diff --git a/src/services/blockchain/ethereum/transaction-state-machine.ts b/src/services/blockchain/ethereum/transaction-state-machine.ts new file mode 100644 index 000000000..430ad9e6e --- /dev/null +++ b/src/services/blockchain/ethereum/transaction-state-machine.ts @@ -0,0 +1,186 @@ +import {ContractParameters, EthereumClient, TransactionReceipt} from "./ethereum-client.js"; +import {Address, parseAbi, toHex} from "viem"; +import {CID} from "multiformats/cid"; +import {EthereumWallet} from "./ethereum-wallet.js"; +import {backOff, BackoffOptions} from "exponential-backoff" +import { logger, logEvent } from '../../../logger/index.js' +import {Transaction} from "../../../models/transaction.js"; + +const FUNCTION_NAME = "anchorDagCbor" as const +const ABI = parseAbi([`function ${FUNCTION_NAME}(bytes32)`]) + +class PreviousAttempt { + attemptNum: number + maxFeePerGas: bigint + maxPriorityFeePerGas: bigint + + constructor(baseFeePerGas: bigint) { + this.attemptNum = 0 + + this.maxPriorityFeePerGas = 1_500_000_000n // 1.5 gwei + this.maxFeePerGas = this.calculateMaxFeePerGas(baseFeePerGas) + } + + calculateMaxFeePerGas(baseFeePerGas: bigint): bigint { + return (baseFeePerGas * 120n) / 100n + this.maxPriorityFeePerGas + } + + increment(baseFeePerGas: bigint) { + this.attemptNum += 1 + const increment = (100n + 10n * BigInt(this.attemptNum)) / 100n + this.maxPriorityFeePerGas = this.maxPriorityFeePerGas * increment + this.maxFeePerGas = this.calculateMaxFeePerGas(baseFeePerGas) + } +} + +interface GetFeeHistory { + tag: 'fee_history' +} + +interface SimulateContract { + tag: 'simulate' + opts: ContractParameters +} + +interface WriteContract { + tag: 'write' + opts: ContractParameters +} + +interface GetTransactionReceipt { + tag: 'get_transaction' + transaction: Address +} + +interface GetBlock { + tag: 'get_block' + transaction: TransactionReceipt +} + +const MAX_ATTEMPTS = 3 + +const BACKOFF_OPTIONS: BackoffOptions = { + numOfAttempts: MAX_ATTEMPTS, + delayFirstAttempt: false, + startingDelay: 1_000, //delay in ms + retry: (e, attemptNumber) => { + logger.warn(`Failed to send transaction, attempt ${attemptNumber} of ${MAX_ATTEMPTS}: ${e}`) + return true + } +} + +type TransactionState = GetFeeHistory | SimulateContract | WriteContract | GetTransactionReceipt | GetBlock +export class TransactionStateMachine { + private readonly rootCid: CID + private previousAttempt?: PreviousAttempt + private state: TransactionState + private provider: EthereumClient + private wallet: EthereumWallet + private readonly contractAddress: Address + private readonly chainId: string + + constructor(chainId: string, provider: EthereumClient, wallet: EthereumWallet, contractAddress: Address, rootCid: CID) { + this.chainId = chainId + this.provider = provider + this.wallet = wallet + this.contractAddress = contractAddress + this.rootCid = rootCid + this.state = { + tag: 'fee_history' + } + } + + async getFeeHistory(): Promise { + const fees = await backOff(() => this.provider.getFeeHistory(), BACKOFF_OPTIONS) + const baseFeePerGas = fees.baseFeePerGas + let previousAttempt = this.previousAttempt + if (!previousAttempt) { + previousAttempt = new PreviousAttempt(baseFeePerGas) + } else { + previousAttempt.increment(baseFeePerGas) + } + const data = toHex(this.rootCid.bytes.slice(4)) + const opts = { + abi: ABI, + functionName: FUNCTION_NAME, + account: this.wallet.address, + address: this.contractAddress, + args: [data], + maxFeePerGas: previousAttempt.maxFeePerGas, + maxPriorityFeePerGas: previousAttempt.maxPriorityFeePerGas, + } + this.state = { + tag: 'simulate' as const, + opts + } + } + + async simulateContract(): Promise { + const state = this.state as SimulateContract + await backOff(() => this.provider.simulateContract(state.opts), BACKOFF_OPTIONS) + this.state = { + tag: 'write', + opts: state.opts + } + } + + async writeContract(): Promise { + const state = this.state as WriteContract + const hash = await backOff(() => this.wallet.writeContract(state.opts), BACKOFF_OPTIONS) + this.state = { + tag: 'get_transaction', + transaction: hash + } + } + + async getTransaction(): Promise { + const state = this.state as GetTransactionReceipt + const tx = await backOff(() => this.provider.waitForTransactionReceipt(state.transaction), BACKOFF_OPTIONS) + if (tx.successful) { + this.state = { + tag: 'get_block', + transaction: tx, + } + } else { + if (this.previousAttempt && this.previousAttempt.attemptNum < MAX_ATTEMPTS) { + logger.warn(`Transaction failed, retrying (${this.previousAttempt.attemptNum} / ${MAX_ATTEMPTS})`) + } + } + } + + async getBlock(): Promise { + const state = this.state as GetBlock + const block = await backOff(() => this.provider.getBlock(state.transaction.blockHash), BACKOFF_OPTIONS) + if (!block.blockNumber) { + throw new Error('Block did not have a block number') + } + + logEvent.ethereum({ + type: 'txResponse', + hash: state.transaction.blockHash, + blockTimestamp: block.timestamp, + blockNumber: block.blockNumber, + blockHash: block.blockHash, + from: state.transaction.from, + }) + + return new Transaction(this.chainId, state.transaction.blockHash, block.blockNumber, block.timestamp) + + } + + public async run(): Promise { + for(;;) { + if (this.state.tag == 'fee_history') { + await this.getFeeHistory() + } else if (this.state.tag == 'simulate') { + await this.simulateContract() + } else if (this.state.tag == 'write') { + await this.writeContract() + } else if (this.state.tag == 'get_transaction') { + await this.getTransaction() + } else if (this.state.tag == 'get_block') { + return await this.getBlock() + } + } + } +}