From 893536940ce607347fa221f270e426de05b0f10d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Jakub=20Nani=C5=A1ta?= Date: Thu, 30 May 2024 12:32:06 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=97=9E=EF=B8=8F=20Batched=20transaction?= =?UTF-8?q?=20sending=20for=20Gnosis=20Safe=20(#621)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/brave-days-vanish.md | 7 + EXPERIMENTAL.md | 14 ++ packages/devtools-evm/src/signer/sdk.ts | 82 +++---- packages/devtools-evm/test/signer/sdk.test.ts | 206 +++++++++++++++--- packages/devtools/src/transactions/signer.ts | 56 ++++- packages/devtools/src/transactions/types.ts | 10 + .../devtools/test/transactions/signer.test.ts | 128 +++++++++++ 7 files changed, 438 insertions(+), 65 deletions(-) create mode 100644 .changeset/brave-days-vanish.md diff --git a/.changeset/brave-days-vanish.md b/.changeset/brave-days-vanish.md new file mode 100644 index 000000000..d47ec40a9 --- /dev/null +++ b/.changeset/brave-days-vanish.md @@ -0,0 +1,7 @@ +--- +"@layerzerolabs/devtools-evm": patch +"@layerzerolabs/devtools": patch +"@layerzerolabs/toolbox-hardhat": patch +--- + +Add experimental support for batched sending diff --git a/EXPERIMENTAL.md b/EXPERIMENTAL.md index a2e0f0560..6c1b4e4af 100644 --- a/EXPERIMENTAL.md +++ b/EXPERIMENTAL.md @@ -46,6 +46,20 @@ By default, the RPC calls that check the current state of your contracts are exe `LZ_ENABLE_EXPERIMENTAL_RETRY=` +## Batched transaction sending + +Some signers might support batched transaction sending (e.g. Gnosis Safe signer). If turned on, this feature flag will make use of the batched sending. If this feature flag is on and batched sending is not available, regular sending will be used instead. + +If the signer used does not support batch sending, batched awaiting feature flag will be used to determine which signing strategy to use. + +### To enable + +`LZ_ENABLE_EXPERIMENTAL_BATCHED_SEND=1` + +### To disable + +`LZ_ENABLE_EXPERIMENTAL_BATCHED_SEND=` + ## Batched transaction awaiting By default, the transactions are submitted and awaited one by one. This means a transaction will only be submitted once the previous transaction has been mined (which results in transactions being mined in consecutive blocks, one transaction per block). diff --git a/packages/devtools-evm/src/signer/sdk.ts b/packages/devtools-evm/src/signer/sdk.ts index 34b2d8a8a..26f50d604 100644 --- a/packages/devtools-evm/src/signer/sdk.ts +++ b/packages/devtools-evm/src/signer/sdk.ts @@ -2,7 +2,7 @@ import type { TransactionReceipt, TransactionRequest } from '@ethersproject/abst import type { Signer } from '@ethersproject/abstract-signer' import Safe, { ConnectSafeConfig, EthersAdapter } from '@safe-global/protocol-kit' import SafeApiKit from '@safe-global/api-kit' -import { MetaTransactionData, OperationType } from '@safe-global/safe-core-sdk-types' +import { MetaTransactionData, OperationType, SafeTransaction } from '@safe-global/safe-core-sdk-types' import type { EndpointId } from '@layerzerolabs/lz-definitions' import { formatEid, @@ -67,8 +67,8 @@ export class OmniSignerEVM extends OmniSignerEVMBase { data: transaction.data, // optional - ...(transaction.gasLimit && { gasLimit: transaction.gasLimit }), - ...(transaction.value && { value: transaction.value }), + ...(transaction.gasLimit != null && { gasLimit: transaction.gasLimit }), + ...(transaction.value != null && { value: transaction.value }), } } } @@ -77,43 +77,56 @@ export class OmniSignerEVM extends OmniSignerEVMBase { * Implements an OmniSigner interface for EVM-compatible chains using Gnosis Safe. */ export class GnosisOmniSignerEVM extends OmniSignerEVMBase { - protected safeSdk: Safe | undefined - protected apiKit: SafeApiKit | undefined - constructor( eid: EndpointId, signer: Signer, protected readonly safeUrl: string, - protected readonly safeConfig: TSafeConfig + protected readonly safeConfig: TSafeConfig, + protected readonly ethAdapter = new EthersAdapter({ + ethers, + signerOrProvider: signer, + }), + protected readonly apiKit = new SafeApiKit({ txServiceUrl: safeUrl, ethAdapter }), + protected readonly safeSdkPromise: Safe | Promise = Safe.create({ + ethAdapter, + safeAddress: safeConfig.safeAddress!, + contractNetworks: safeConfig.contractNetworks, + }) ) { super(eid, signer) } - async sign(_transaction: OmniTransaction): Promise { - throw new Error('Method not implemented.') + async sign(_: OmniTransaction): Promise { + throw new Error(`Signing transactions with safe is currently not supported, use signAndSend instead`) } async signAndSend(transaction: OmniTransaction): Promise { - this.assertTransaction(transaction) - const { safeSdk, apiKit } = await this.#initSafe() + return this.signAndSendBatch([transaction]) + } + + async signAndSendBatch(transactions: OmniTransaction[]): Promise { + assert(transactions.length > 0, `signAndSendBatch received 0 transactions`) + + const safeTransaction = await this.#createSafeTransaction(transactions) + + return this.#proposeSafeTransaction(safeTransaction) + } + + async #proposeSafeTransaction(safeTransaction: SafeTransaction): Promise { + const safeSdk = await this.safeSdkPromise const safeAddress = await safeSdk.getAddress() - const safeTransaction = await safeSdk.createTransaction({ - safeTransactionData: [this.#serializeTransaction(transaction)], - options: { - nonce: await apiKit.getNextNonce(safeAddress), - }, - }) const safeTxHash = await safeSdk.getTransactionHash(safeTransaction) const senderSignature = await safeSdk.signTransactionHash(safeTxHash) const senderAddress = await this.signer.getAddress() - await apiKit.proposeTransaction({ + await this.apiKit.proposeTransaction({ senderSignature: senderSignature.data, safeAddress, safeTransactionData: safeTransaction.data, safeTxHash, senderAddress, }) + return { transactionHash: safeTxHash, wait: async (_confirmations?: number) => { @@ -124,30 +137,25 @@ export class GnosisOmniSignerEVM extends } } + async #createSafeTransaction(transactions: OmniTransaction[]): Promise { + transactions.forEach((transaction) => this.assertTransaction(transaction)) + + const safeSdk = await this.safeSdkPromise + const safeAddress = await safeSdk.getAddress() + const nonce = await this.apiKit.getNextNonce(safeAddress) + + return safeSdk.createTransaction({ + safeTransactionData: transactions.map((transaction) => this.#serializeTransaction(transaction)), + options: { nonce }, + }) + } + #serializeTransaction(transaction: OmniTransaction): MetaTransactionData { return { to: transaction.point.address, data: transaction.data, - value: '0', + value: String(transaction.value ?? 0), operation: OperationType.Call, } } - - async #initSafe() { - if (!this.safeSdk || !this.apiKit) { - const ethAdapter = new EthersAdapter({ - ethers, - signerOrProvider: this.signer, - }) - this.apiKit = new SafeApiKit({ txServiceUrl: this.safeUrl, ethAdapter }) - - this.safeSdk = await Safe.create({ - ethAdapter, - safeAddress: this.safeConfig.safeAddress!, - contractNetworks: this.safeConfig.contractNetworks, - }) - } - - return { safeSdk: this.safeSdk, apiKit: this.apiKit } - } } diff --git a/packages/devtools-evm/test/signer/sdk.test.ts b/packages/devtools-evm/test/signer/sdk.test.ts index 66a023dae..f949dbe84 100644 --- a/packages/devtools-evm/test/signer/sdk.test.ts +++ b/packages/devtools-evm/test/signer/sdk.test.ts @@ -1,9 +1,10 @@ import fc from 'fast-check' -import { endpointArbitrary, evmAddressArbitrary, pointArbitrary } from '@layerzerolabs/test-devtools' +import { endpointArbitrary, evmAddressArbitrary, optionalArbitrary, pointArbitrary } from '@layerzerolabs/test-devtools' import { Signer } from '@ethersproject/abstract-signer' import { GnosisOmniSignerEVM, OmniSignerEVM } from '@/signer' -import Safe, { SafeConfig } from '@safe-global/protocol-kit' +import Safe from '@safe-global/protocol-kit' import SafeApiKit from '@safe-global/api-kit' +import { OperationType } from '@safe-global/safe-core-sdk-types' describe('signer/ethers', () => { const transactionHashArbitrary = fc.hexaString() @@ -11,6 +12,7 @@ describe('signer/ethers', () => { const transactionArbitrary = fc.record({ point: pointArbitrary, data: fc.hexaString(), + value: optionalArbitrary(fc.integer({ min: 0 })), }) describe('OmniSignerEVM', () => { @@ -42,6 +44,7 @@ describe('signer/ethers', () => { expect(signTransaction).toHaveBeenCalledWith({ to: transaction.point.address, data: transaction.data, + value: transaction.value, }) } ) @@ -74,6 +77,7 @@ describe('signer/ethers', () => { expect(sendTransaction).toHaveBeenCalledWith({ to: transaction.point.address, data: transaction.data, + value: transaction.value, }) }) ) @@ -82,29 +86,62 @@ describe('signer/ethers', () => { }) describe('GnosisOmniSignerEVM', () => { describe('sign', () => { - it('should throw', async () => { + it('should not be supported', async () => { await fc.assert( - fc.asyncProperty(endpointArbitrary, transactionArbitrary, async (eid, transaction) => { - const signer = {} as Signer - const omniSigner = new GnosisOmniSignerEVM(eid, signer, '', {} as SafeConfig) - await expect(() => omniSigner.sign(transaction)).rejects.toThrow(/Method not implemented/) - }) + fc.asyncProperty( + evmAddressArbitrary, + endpointArbitrary, + transactionArbitrary, + async (safeAddress, eid, transaction) => { + const signer = {} as Signer + + const apiKit = { + getNextNonce: jest.fn(), + } as unknown as SafeApiKit + + const safe = { + createTransaction: jest.fn().mockResolvedValue({ data: 'transaction' }), + getAddress: jest.fn().mockResolvedValue(safeAddress), + signTransaction: jest.fn().mockResolvedValue({ data: { data: '0xsigned' } }), + } as unknown as Safe + + const omniSigner = new GnosisOmniSignerEVM(eid, signer, '', {}, undefined, apiKit, safe) + + await expect(omniSigner.sign(transaction)).rejects.toThrow( + /Signing transactions with safe is currently not supported, use signAndSend instead/ + ) + } + ) ) }) }) + describe('signAndSend', () => { it('should reject if the eid of the transaction does not match the eid of the signer', async () => { await fc.assert( - fc.asyncProperty(endpointArbitrary, transactionArbitrary, async (eid, transaction) => { - fc.pre(eid !== transaction.point.eid) + fc.asyncProperty( + evmAddressArbitrary, + endpointArbitrary, + transactionArbitrary, + async (safeAddress, eid, transaction) => { + fc.pre(eid !== transaction.point.eid) - const signer = {} as Signer - const omniSigner = new GnosisOmniSignerEVM(eid, signer, '', {} as SafeConfig) + const signer = {} as Signer + const safe = { + createTransaction: jest.fn().mockResolvedValue({ data: 'transaction' }), + getAddress: jest.fn().mockResolvedValue(safeAddress), + signTransactionHash: jest.fn().mockResolvedValue({ data: 'signature' }), + } as unknown as Safe + const omniSigner = new GnosisOmniSignerEVM(eid, signer, '', {}, undefined, undefined, safe) - await expect(() => omniSigner.signAndSend(transaction)).rejects.toThrow(/Could not use signer/) - }) + await expect(() => omniSigner.signAndSend(transaction)).rejects.toThrow( + /Could not use signer/ + ) + } + ) ) }) + it('should send the transaction using the signer if the eids match', async () => { await fc.assert( fc.asyncProperty( @@ -112,29 +149,144 @@ describe('signer/ethers', () => { transactionArbitrary, transactionHashArbitrary, async (safeAddress, transaction, transactionHash) => { - const sendTransaction = jest.fn() - const getAddress = jest.fn() - const signer = { getAddress, sendTransaction } as unknown as Signer - const omniSigner = new GnosisOmniSignerEVM(transaction.point.eid, signer, '', { + const signer = { getAddress: jest.fn(), sendTransaction: jest.fn() } as unknown as Signer + const apiKit = { + proposeTransaction: jest.fn(), + getNextNonce: jest.fn(), + } as unknown as SafeApiKit + const safe = { + createTransaction: jest.fn().mockResolvedValue({ data: 'transaction' }), + getTransactionHash: jest.fn().mockResolvedValue(transactionHash), + getAddress: jest.fn().mockResolvedValue(safeAddress), + signTransactionHash: jest.fn().mockResolvedValue({ data: 'signature' }), + } as unknown as Safe + + const omniSigner = new GnosisOmniSignerEVM( + transaction.point.eid, + signer, + '', + { + safeAddress, + }, + undefined, + apiKit, + safe + ) + + const result = await omniSigner.signAndSend(transaction) + expect(result.transactionHash).toEqual(transactionHash) + + expect(await result.wait()).toEqual({ transactionHash }) + + expect(apiKit.getNextNonce).toHaveBeenCalledWith(safeAddress) + expect(apiKit.proposeTransaction).toHaveBeenCalledWith({ safeAddress, + safeTransactionData: 'transaction', + safeTxHash: transactionHash, + senderAddress: undefined, + senderSignature: 'signature', }) - // TODO These should be mocked using jest.mock - omniSigner['safeSdk'] = { + } + ) + ) + }) + }) + + describe('signAndSendBatch', () => { + it('should reject with no transactions', async () => { + await fc.assert( + fc.asyncProperty(evmAddressArbitrary, endpointArbitrary, async (safeAddress, eid) => { + const signer = {} as Signer + const safe = {} as unknown as Safe + const omniSigner = new GnosisOmniSignerEVM(eid, signer, '', {}, undefined, undefined, safe) + + await expect(() => omniSigner.signAndSendBatch([])).rejects.toThrow( + /signAndSendBatch received 0 transactions/ + ) + }) + ) + }) + + it('should reject if at least one of the transaction eids do not match the signer eid', async () => { + await fc.assert( + fc.asyncProperty( + evmAddressArbitrary, + endpointArbitrary, + fc.array(transactionArbitrary, { minLength: 1 }), + async (safeAddress, eid, transactions) => { + fc.pre(transactions.some((transaction) => eid !== transaction.point.eid)) + + const signer = {} as Signer + const safe = { createTransaction: jest.fn().mockResolvedValue({ data: 'transaction' }), - getTransactionHash: jest.fn().mockResolvedValue(transactionHash), getAddress: jest.fn().mockResolvedValue(safeAddress), signTransactionHash: jest.fn().mockResolvedValue({ data: 'signature' }), } as unknown as Safe - const safeService = (omniSigner['apiKit'] = { + const omniSigner = new GnosisOmniSignerEVM(eid, signer, '', {}, undefined, undefined, safe) + + await expect(() => omniSigner.signAndSendBatch(transactions)).rejects.toThrow( + /Could not use signer/ + ) + } + ) + ) + }) + + it('should send the transaction using the signer if the eids match', async () => { + await fc.assert( + fc.asyncProperty( + evmAddressArbitrary, + endpointArbitrary, + fc.array(transactionArbitrary, { minLength: 1 }), + transactionHashArbitrary, + async (safeAddress, eid, transactions, transactionHash) => { + const nonce = 17 + const signer = { getAddress: jest.fn(), sendTransaction: jest.fn() } as unknown as Signer + const apiKit = { proposeTransaction: jest.fn(), - getNextNonce: jest.fn(), - } as unknown as SafeApiKit) + getNextNonce: jest.fn().mockResolvedValue(nonce), + } as unknown as SafeApiKit + const safe = { + createTransaction: jest.fn().mockResolvedValue({ data: 'transaction' }), + getTransactionHash: jest.fn().mockResolvedValue(transactionHash), + getAddress: jest.fn().mockResolvedValue(safeAddress), + signTransactionHash: jest.fn().mockResolvedValue({ data: 'signature' }), + } as unknown as Safe - const result = await omniSigner.signAndSend(transaction) + const omniSigner = new GnosisOmniSignerEVM( + eid, + signer, + '', + { + safeAddress, + }, + undefined, + apiKit, + safe + ) + + const transactionsWithMatchingEids = transactions.map((t) => ({ + ...t, + point: { ...t.point, eid }, + })) + + const result = await omniSigner.signAndSendBatch(transactionsWithMatchingEids) expect(result.transactionHash).toEqual(transactionHash) + expect(await result.wait()).toEqual({ transactionHash }) - expect(safeService.getNextNonce).toHaveBeenCalledWith(safeAddress) - expect(safeService.proposeTransaction).toHaveBeenCalledWith({ + + expect(safe.createTransaction).toHaveBeenCalledWith({ + safeTransactionData: transactions.map((t) => ({ + to: t.point.address, + data: t.data, + value: String(t.value ?? 0), + operation: OperationType.Call, + })), + options: { nonce }, + }) + + expect(apiKit.getNextNonce).toHaveBeenCalledWith(safeAddress) + expect(apiKit.proposeTransaction).toHaveBeenCalledWith({ safeAddress, safeTransactionData: 'transaction', safeTxHash: transactionHash, diff --git a/packages/devtools/src/transactions/signer.ts b/packages/devtools/src/transactions/signer.ts index 6801b8c1c..19a16ad38 100644 --- a/packages/devtools/src/transactions/signer.ts +++ b/packages/devtools/src/transactions/signer.ts @@ -78,7 +78,18 @@ export const createSignAndSend = logger.warn(`You are using experimental batched transaction waiting`) } - const signerLogic: TransactionSignerLogic = useBatchedWait ? waitAfterSendingAll : waitBeforeSubmittingNext + const useBatchedSend = !!process.env.LZ_ENABLE_EXPERIMENTAL_BATCHED_SEND + if (useBatchedSend) { + logger.warn(`You are using experimental batched transaction sending`) + } + + // First we create a signer logic based on the batched wait feature flag + const fallbackSignerLogic: TransactionSignerLogic = useBatchedWait + ? waitAfterSendingAll + : waitBeforeSubmittingNext + // Then we create the final signer logic based on the batched send feature flag + // The batched send logic will fall back on the default logic if batched send is not available + const signerLogic = useBatchedSend ? sendBatchedIfAvailable(fallbackSignerLogic) : fallbackSignerLogic await Promise.allSettled( transactionGroups.map(async ([eid, eidTransactions]): Promise => { @@ -121,6 +132,49 @@ type TransactionSignerLogic = ( onError: (error: OmniTransactionWithError) => void ) => Promise +const sendBatchedIfAvailable = + (fallbackLogic: TransactionSignerLogic): TransactionSignerLogic => + async (eid, logger, signer, transactions, onSuccess, onError) => { + const eidName = formatEid(eid) + + // First we check that we can send batched transactions + // + // If we can't we fall back on the fallback logic + if (signer.signAndSendBatch == null) { + logger.warn(`Batched transaction sending is not available for ${eidName}, falling back on regular sending`) + + return await fallbackLogic(eid, logger, signer, transactions, onSuccess, onError) + } + + // For brevity we'll create a variable that holds a string with pluralized label for the transactions + // e.g. "0 transactions" or "1 transaction" + const transactionsName = pluralizeNoun( + transactions.length, + `1 transaction`, + `${transactions.length} transactions` + ) + + try { + logger.debug(`Signing a batch of ${transactionsName} for ${eidName}`) + const response = await signer.signAndSendBatch(transactions) + + logger.debug(`Signed a batch of ${transactionsName} for ${eidName}, got hash ${response.transactionHash}`) + const receipt = await response.wait() + + logger.debug(`Finished a batch of ${transactionsName} for ${eidName}`) + + for (const transaction of transactions) { + onSuccess({ transaction, receipt }) + } + } catch (error) { + logger.debug(`Failed to process a batch of ${transactionsName} for ${eidName}: ${error}`) + + for (const transaction of transactions) { + onError({ transaction, error }) + } + } + } + /** * This transaction submitting logic will wait for every single transaction * before submitting the next one. This is the default logic, it results in transactions diff --git a/packages/devtools/src/transactions/types.ts b/packages/devtools/src/transactions/types.ts index b311ad947..259a181b2 100644 --- a/packages/devtools/src/transactions/types.ts +++ b/packages/devtools/src/transactions/types.ts @@ -36,6 +36,16 @@ export interface OmniTransactionReceipt { export interface OmniSigner { sign: (transaction: OmniTransaction) => Promise signAndSend: (transaction: OmniTransaction) => Promise + + /** + * Signers can support multi send / batch mode + * where multiple transactions get submitted together. + * + * Examples of this are Gnosis Safe signer or a signer using an EVM multicall contract. + * + * @param {OmniTransaction[]} transactions + */ + signAndSendBatch?: (transactions: OmniTransaction[]) => Promise } export type OmniSignerFactory = EndpointBasedFactory diff --git a/packages/devtools/test/transactions/signer.test.ts b/packages/devtools/test/transactions/signer.test.ts index 8dfb3725a..e73b9fa3b 100644 --- a/packages/devtools/test/transactions/signer.test.ts +++ b/packages/devtools/test/transactions/signer.test.ts @@ -376,5 +376,133 @@ describe('transactions/signer', () => { ) }) }) + + describe(`when LZ_ENABLE_EXPERIMENTAL_BATCHED_SEND environment variable is set to '1'`, () => { + beforeAll(() => { + process.env.LZ_ENABLE_EXPERIMENTAL_BATCHED_SEND = '1' + }) + + afterAll(() => { + process.env.LZ_ENABLE_EXPERIMENTAL_BATCHED_SEND = '' + }) + + describe('when signAndSendBatch is supported', () => { + it('should only return errors if the submission fails', async () => { + await fc.assert( + fc.asyncProperty(fc.array(transactionArbitrary), async (transactions) => { + // We'll prepare some mock objects for this test + // to mock the transaction responses and receipts + const error = new Error('Failed transaction') + + // Our signAndSend will then use the map to resolve/reject transactions + const signAndSendBatch = jest.fn().mockRejectedValue(error) + const signAndSend = jest.fn().mockRejectedValue('Oh god no') + const sign = jest.fn().mockRejectedValue('Oh god no') + const signerFactory: OmniSignerFactory = jest + .fn() + .mockResolvedValue({ signAndSend, signAndSendBatch, sign }) + const signAndSendTransactions = createSignAndSend(signerFactory) + + // Now we send all the transactions to the flow and observe the output + const [successful, errors, pending] = await signAndSendTransactions(transactions) + + // Since we are executing groups of transactions in parallel, + // in general the order of successful transaction will not match the order of input transactions + expect(successful).toEqual([]) + expect(errors).toContainAllValues( + transactions.map((transaction) => ({ transaction, error })) + ) + expect(pending).toContainAllValues(transactions) + + // We also check that the signer factory has been called with the eids + for (const transaction of transactions) { + expect(signerFactory).toHaveBeenCalledWith(transaction.point.eid) + } + }) + ) + }) + + it('should only return errors if the waiting fails', async () => { + await fc.assert( + fc.asyncProperty(fc.array(transactionArbitrary), async (transactions) => { + // We'll prepare some mock objects for this test + // to mock the transaction responses and receipts + const error = new Error('Failed transaction') + // Our unsuccessful wait will throw an error + const wait = jest.fn().mockRejectedValue(error) + const response: OmniTransactionResponse = { + transactionHash: '0x0', + wait, + } + + // Our signAndSend will then use the map to resolve/reject transactions + const signAndSendBatch = jest.fn().mockResolvedValue(response) + const signAndSend = jest.fn().mockRejectedValue('Oh god no') + const sign = jest.fn().mockRejectedValue('Oh god no') + const signerFactory: OmniSignerFactory = jest + .fn() + .mockResolvedValue({ signAndSend, signAndSendBatch, sign }) + const signAndSendTransactions = createSignAndSend(signerFactory) + + // Now we send all the transactions to the flow and observe the output + const [successful, errors, pending] = await signAndSendTransactions(transactions) + + // Since we are executing groups of transactions in parallel, + // in general the order of successful transaction will not match the order of input transactions + expect(successful).toEqual([]) + expect(errors).toContainAllValues( + transactions.map((transaction) => ({ transaction, error })) + ) + expect(pending).toContainAllValues(transactions) + + // We also check that the signer factory has been called with the eids + for (const transaction of transactions) { + expect(signerFactory).toHaveBeenCalledWith(transaction.point.eid) + } + }) + ) + }) + + it('should only return successes if waiting succeeds', async () => { + await fc.assert( + fc.asyncProperty(fc.array(transactionArbitrary), async (transactions) => { + const receipt = { transactionHash: '0x0' } + + // Our successful wait will produce a receipt + const wait = jest.fn().mockResolvedValue(receipt) + const response: OmniTransactionResponse = { + transactionHash: '0x0', + wait, + } + + // Our signAndSend will then use the map to resolve/reject transactions + const signAndSendBatch = jest.fn().mockResolvedValue(response) + const signAndSend = jest.fn().mockRejectedValue('Oh god no') + const sign = jest.fn().mockRejectedValue('Oh god no') + const signerFactory: OmniSignerFactory = jest + .fn() + .mockResolvedValue({ signAndSend, signAndSendBatch, sign }) + const signAndSendTransactions = createSignAndSend(signerFactory) + + // Now we send all the transactions to the flow and observe the output + const [successful, errors, pending] = await signAndSendTransactions(transactions) + + // Since we are executing groups of transactions in parallel, + // in general the order of successful transaction will not match the order of input transactions + expect(successful).toContainAllValues( + transactions.map((transaction) => ({ transaction, receipt })) + ) + expect(errors).toEqual([]) + expect(pending).toEqual([]) + + // We also check that the signer factory has been called with the eids + for (const transaction of transactions) { + expect(signerFactory).toHaveBeenCalledWith(transaction.point.eid) + } + }) + ) + }) + }) + }) }) })