diff --git a/contracts/clients/README.md b/contracts/clients/README.md index 0dacd084e..4983eb187 100644 --- a/contracts/clients/README.md +++ b/contracts/clients/README.md @@ -80,6 +80,21 @@ const verificationGateway = VerificationGateway__factory.connect( await verificationGateway.processBundle(bundle); ``` +You can get the results of the operations in a bundle using `getOperationResults`. + +```ts +import { getOperationResults } from 'bls-wallet-clients'; +... +const txn = await verificationGateway.processBundle(bundle); +const txnReceipt = txn.wait(); +const opResults = getOperationResults(txnReceipt); +// Includes data from WalletOperationProcessed event, +// as well as parsed errors with action index +const { error } = opResults[0]; +console.log(error?.actionIndex); // ex. 0 (as BigNumber) +console.log(error?.message); // ex. "some require failure message" +``` + ## Signer Utilities for signing, aggregating and verifying transaction bundles using the diff --git a/contracts/clients/package.json b/contracts/clients/package.json index 4aaeeab42..e23d04309 100644 --- a/contracts/clients/package.json +++ b/contracts/clients/package.json @@ -1,6 +1,6 @@ { "name": "bls-wallet-clients", - "version": "0.7.3", + "version": "0.7.4", "description": "Client libraries for interacting with BLS Wallet components", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", @@ -33,6 +33,7 @@ "mocha": "^10.0.0", "source-map-support": "^0.5.21", "ts-node": "^10.9.1", + "typemoq": "^2.1.0", "typescript": "^4.8.2" } } diff --git a/contracts/clients/src/OperationResults.ts b/contracts/clients/src/OperationResults.ts new file mode 100644 index 000000000..e91a42e12 --- /dev/null +++ b/contracts/clients/src/OperationResults.ts @@ -0,0 +1,111 @@ + +import { BigNumber, ContractReceipt, utils } from "ethers"; +import { ActionData } from "./signer"; + +type OperationResultError = { + actionIndex: BigNumber; + message: string; +}; + +export type OperationResult = { + walletAddress: string; + nonce: BigNumber; + actions: ActionData[]; + success: Boolean; + results: string[]; + error?: OperationResultError; +}; + +const getError = ( + success: boolean, + results: string[], +): OperationResultError | undefined => { + if (success) { + return undefined; + } + + // Single event "WalletOperationProcessed(address indexed wallet, uint256 nonce, bool success, bytes[] results)" + // Get the first (only) result from "results" argument. + const [errorResult] = results; + // remove methodId (4bytes after 0x) + const errorArgBytesString = `0x${errorResult.substring(10)}`; + const errorString = utils.defaultAbiCoder.decode( + ["string"], + errorArgBytesString, + )[0]; // decoded bytes is a string of the action index that errored. + + const splitErrorString = errorString.split(" - "); + if (splitErrorString.length !== 2) { + throw new Error("unexpected error message format"); + } + + return { + actionIndex: BigNumber.from(splitErrorString[0]), + message: splitErrorString[1], + }; +}; + +/** + * Gets the results of operations (and actions) run through VerificationGateway.processBundle. + * Decodes unsuccessful operations into an error message and the index of the action that failed. + * + * @param transactionReceipt Transaction receipt from a VerificationGateway.processBundle transaction + * @returns An array of decoded operation results + */ +export const getOperationResults = ( + transactionReceipt: ContractReceipt, +): OperationResult[] => { + if (!transactionReceipt.events || !transactionReceipt.events.length) { + throw new Error( + `no events found in transaction ${transactionReceipt.transactionHash}`, + ); + } + + const walletOpProcessedEvents = transactionReceipt.events.filter( + (e) => e.event === "WalletOperationProcessed", + ); + if (!walletOpProcessedEvents.length) { + throw new Error( + `no WalletOperationProcessed events found in transaction ${transactionReceipt.transactionHash}`, + ); + } + + return walletOpProcessedEvents.reduce( + (opResults, { args }) => { + if (!args) { + throw new Error("WalletOperationProcessed event missing args"); + } + const { wallet, nonce, actions: rawActions, success, results } = args; + + const actions = rawActions.map( + ({ + ethValue, + contractAddress, + encodedFunction, + }: { + ethValue: BigNumber; + contractAddress: string; + encodedFunction: string; + }) => ({ + ethValue, + contractAddress, + encodedFunction, + }), + ); + const error = getError(success, results); + + return [ + ...opResults, + { + walletAddress: wallet, + nonce, + actions, + success, + results, + error, + }, + ]; + }, + [], + ); +}; diff --git a/contracts/clients/src/index.ts b/contracts/clients/src/index.ts index 65ecf7832..6463d20d5 100644 --- a/contracts/clients/src/index.ts +++ b/contracts/clients/src/index.ts @@ -24,6 +24,8 @@ import { validateMultiConfig, } from "./MultiNetworkConfig"; +import { OperationResult, getOperationResults } from "./OperationResults"; + export * from "./signer"; export { @@ -35,6 +37,8 @@ export { MultiNetworkConfig, getMultiConfig, validateMultiConfig, + OperationResult, + getOperationResults, // eslint-disable-next-line camelcase VerificationGateway__factory, VerificationGateway, diff --git a/contracts/clients/test/OpertionResults.test.ts b/contracts/clients/test/OpertionResults.test.ts new file mode 100644 index 000000000..2768eb63e --- /dev/null +++ b/contracts/clients/test/OpertionResults.test.ts @@ -0,0 +1,165 @@ +import { expect } from "chai"; +import { BigNumber, ContractReceipt, Event, utils } from "ethers"; +import * as TypeMoq from "typemoq"; +import { getOperationResults } from "../src"; + +class MockResult extends Array { + [key: string]: unknown; + + constructor(obj: Record) { + super(); + for (const k in obj) { + this[k] = obj[k]; + } + } +} + +const getErrorResult = (actionIndex: number, message: string): string => { + const fullErrorMessage = `${actionIndex} - ${message}`; + const encodedErrorMessage = utils.defaultAbiCoder.encode( + ["string"], + [fullErrorMessage], + ); + // Add empty methodId (4 bytes, 8 chars), remove leading 0 + return `0x${"0".repeat(8)}${encodedErrorMessage.substring(2)}`; +}; + +describe("OperationResults", () => { + describe("getOperationResults", () => { + it("fails if no events are in transaction", () => { + const hash = "0xabc123"; + const txnReceiptMock = TypeMoq.Mock.ofType(); + txnReceiptMock.setup((r) => r.transactionHash).returns(() => hash); + txnReceiptMock.setup((r) => r.events).returns(() => undefined); + + expect(() => getOperationResults(txnReceiptMock.object)).to.throw( + `no events found in transaction ${hash}`, + ); + }); + + it("fails if events are empty in transaction", () => { + const hash = "0xdef456"; + const txnReceiptMock = TypeMoq.Mock.ofType(); + txnReceiptMock.setup((r) => r.transactionHash).returns(() => hash); + txnReceiptMock.setup((r) => r.events).returns(() => []); + + expect(() => getOperationResults(txnReceiptMock.object)).to.throw( + `no events found in transaction ${hash}`, + ); + }); + + it("fails if no WalletOperationProcessed events are in transaction", () => { + const eventMock = TypeMoq.Mock.ofType(); + eventMock.setup((e) => e.event).returns(() => "Other"); + + const hash = "0x123456"; + const txnReceiptMock = TypeMoq.Mock.ofType(); + txnReceiptMock.setup((r) => r.transactionHash).returns(() => hash); + txnReceiptMock.setup((r) => r.events).returns(() => [eventMock.object]); + + expect(() => getOperationResults(txnReceiptMock.object)).to.throw( + `no WalletOperationProcessed events found in transaction ${hash}`, + ); + }); + + it("fails when WalletOperationProcessed event is missing args", () => { + const eventMock = TypeMoq.Mock.ofType(); + eventMock.setup((e) => e.event).returns(() => "WalletOperationProcessed"); + eventMock.setup((e) => e.args).returns(() => undefined); + + const txnReceiptMock = TypeMoq.Mock.ofType(); + txnReceiptMock.setup((r) => r.events).returns(() => [eventMock.object]); + + expect(() => getOperationResults(txnReceiptMock.object)).to.throw( + "WalletOperationProcessed event missing args", + ); + }); + + it("decodes WalletOperationProcessed events", () => { + const otherEventMock = TypeMoq.Mock.ofType(); + otherEventMock.setup((e) => e.event).returns(() => "Other"); + + const errorActionIndex = 0; + const errorMessage = "halt and catch fire"; + const errorResult = getErrorResult(errorActionIndex, errorMessage); + const failedEventArgs = new MockResult({ + wallet: "0x01", + nonce: BigNumber.from(0), + actions: [ + { + ethValue: BigNumber.from(0), + contractAddress: "0xaabbcc", + encodedFunction: "0xddeeff", + }, + ], + success: false, + results: [errorResult], + }); + const failedOperationEventMock = TypeMoq.Mock.ofType(); + failedOperationEventMock + .setup((e) => e.event) + .returns(() => "WalletOperationProcessed"); + failedOperationEventMock + .setup((e) => e.args) + .returns(() => failedEventArgs); + + const successfulEventArgs = new MockResult({ + wallet: "0x02", + nonce: BigNumber.from(1), + actions: [ + { + ethValue: BigNumber.from(0), + contractAddress: "0xabcabc", + encodedFunction: "0xdefdef", + }, + { + ethValue: BigNumber.from(42), + contractAddress: "0x123123", + encodedFunction: "0x456456", + }, + ], + success: true, + results: ["0x2A", "0x539"], + }); + const successfulOperationEventMock = TypeMoq.Mock.ofType(); + successfulOperationEventMock + .setup((e) => e.event) + .returns(() => "WalletOperationProcessed"); + successfulOperationEventMock + .setup((e) => e.args) + .returns(() => successfulEventArgs); + + const txnReceiptMock = TypeMoq.Mock.ofType(); + txnReceiptMock + .setup((r) => r.events) + .returns(() => [ + otherEventMock.object, + failedOperationEventMock.object, + successfulOperationEventMock.object, + ]); + + const opResults = getOperationResults(txnReceiptMock.object); + expect(opResults).to.have.lengthOf(2); + + const [r1, r2] = opResults; + expect(r1.walletAddress).to.eql(failedEventArgs.wallet); + expect(r1.nonce.toNumber()).to.eql( + BigNumber.from(failedEventArgs.nonce).toNumber(), + ); + expect(r1.actions).to.deep.equal(failedEventArgs.actions); + expect(r1.success).to.eql(failedEventArgs.success); + expect(r1.results).to.deep.equal(failedEventArgs.results); + expect(r1.error?.actionIndex.toNumber()).to.eql(errorActionIndex); + expect(r1.error?.message).to.eql(errorMessage); + + expect(r2.walletAddress).to.eql(successfulEventArgs.wallet); + expect(r2.nonce.toNumber()).to.eql( + BigNumber.from(successfulEventArgs.nonce).toNumber(), + ); + expect(r2.actions).to.deep.equal(successfulEventArgs.actions); + expect(r2.success).to.eql(successfulEventArgs.success); + expect(r2.results).to.deep.equal(successfulEventArgs.results); + expect(r2.error).to.be.undefined; + }); + }); +}); diff --git a/contracts/clients/yarn.lock b/contracts/clients/yarn.lock index 25f520fa8..7854cd0d8 100644 --- a/contracts/clients/yarn.lock +++ b/contracts/clients/yarn.lock @@ -948,6 +948,11 @@ chokidar@3.5.3: optionalDependencies: fsevents "~2.3.2" +circular-json@^0.3.1: + version "0.3.3" + resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.3.3.tgz#815c99ea84f6809529d2f45791bdf82711352d66" + integrity sha512-UZK3NBx2Mca+b5LsG7bY183pHWt5Y1xts4P3Pz7ENTwGVnJOUWbRb3ocjvX7hx9tq/yTAdclXm9sZ38gNuem4A== + cliui@^7.0.2: version "7.0.4" resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" @@ -1265,6 +1270,11 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash@^4.17.4: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + log-symbols@4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" @@ -1403,6 +1413,11 @@ picomatch@^2.0.4, picomatch@^2.2.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972" integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw== +postinstall-build@^5.0.1: + version "5.0.3" + resolved "https://registry.yarnpkg.com/postinstall-build/-/postinstall-build-5.0.3.tgz#238692f712a481d8f5bc8960e94786036241efc7" + integrity sha512-vPvPe8TKgp4FLgY3+DfxCE5PIfoXBK2lyLfNCxsRbDsV6vS4oU5RG/IWxrblMn6heagbnMED3MemUQllQ2bQUg== + randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -1518,6 +1533,15 @@ type-detect@^4.0.0, type-detect@^4.0.5: resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== +typemoq@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/typemoq/-/typemoq-2.1.0.tgz#4452ce360d92cf2a1a180f0c29de2803f87af1e8" + integrity sha512-DtRNLb7x8yCTv/KHlwes+NI+aGb4Vl1iPC63Hhtcvk1DpxSAZzKWQv0RQFY0jX2Uqj0SDBNl8Na4e6MV6TNDgw== + dependencies: + circular-json "^0.3.1" + lodash "^4.17.4" + postinstall-build "^5.0.1" + typescript@^4.8.2: version "4.8.2" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.2.tgz#e3b33d5ccfb5914e4eeab6699cf208adee3fd790" diff --git a/contracts/test/walletAction-test.ts b/contracts/test/walletAction-test.ts index 231cfc36e..345d3014e 100644 --- a/contracts/test/walletAction-test.ts +++ b/contracts/test/walletAction-test.ts @@ -10,6 +10,7 @@ import { parseEther, solidityPack } from "ethers/lib/utils"; import deployAndRunPrecompileCostEstimator from "../shared/helpers/deployAndRunPrecompileCostEstimator"; // import splitHex256 from "../shared/helpers/splitHex256"; import { defaultDeployerAddress } from "../shared/helpers/deployDeployer"; +import { getOperationResults } from "../clients/src"; describe("WalletActions", async function () { if (`${process.env.DEPLOYER_DEPLOYMENT}` === "true") { @@ -322,15 +323,12 @@ describe("WalletActions", async function () { ) ).wait(); - // Single event "WalletOperationProcessed(address indexed wallet, uint256 nonce, bool success, bytes[] results)" - // Get the first (only) result from "results" argument. - const result = r.events[0].args.results[0]; // For errors this is "Error(string)" - const errorArgBytesString: string = "0x" + result.substring(10); // remove methodId (4bytes after 0x) - const errorString = ethers.utils.defaultAbiCoder.decode( - ["string"], - errorArgBytesString, - )[0]; // decoded bytes is a string of the action index that errored. - expect(errorString).to.equal("1 - ERC20: transfer from the zero address"); + const opResults = getOperationResults(r); + expect(opResults).to.have.lengthOf(1); + expect(opResults[0].error.actionIndex.toNumber()).to.eql(1); + expect(opResults[0].error.message).to.eql( + "ERC20: transfer from the zero address", + ); const recipientBalance = await th.testToken.balanceOf(recipient.address);