Skip to content

feat: add sequential batch support #5762

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/transaction-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add sequential batch support when `publishBatchHook` is not defined ([#5762](https://github.com/MetaMask/core/pull/5762))

## [54.4.0]

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,10 @@ export class TransactionController extends BaseController<
publicKeyEIP7702: this.#publicKeyEIP7702,
request,
updateTransaction: this.#updateTransactionInternal.bind(this),
publishTransaction: (
ethQuery: EthQuery,
transactionMeta: TransactionMeta,
) => this.#publishTransaction(ethQuery, transactionMeta) as Promise<Hex>,
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
import type EthQuery from '@metamask/eth-query';
import { rpcErrors } from '@metamask/rpc-errors';
import type { Hex } from '@metamask/utils';

import { SequentialPublishBatchHook } from './SequentialPublishBatchHook';
import { flushPromises } from '../../../../tests/helpers';
import type { PublishBatchHookTransaction, TransactionMeta } from '../types';

jest.mock('@metamask/controller-utils', () => ({
query: jest.fn(),
}));

const queryMock = jest.requireMock('@metamask/controller-utils').query;

const TRANSACTION_CHECK_INTERVAL = 5000; // 5 seconds
const MAX_TRANSACTION_CHECK_ATTEMPTS = 60; // 5 minutes

const TRANSACTION_HASH_MOCK = '0x123';
const TRANSACTION_HASH_2_MOCK = '0x456';
const NETWORK_CLIENT_ID_MOCK = 'testNetworkClientId';
const TRANSACTION_ID_MOCK = 'testTransactionId';
const TRANSACTION_ID_2_MOCK = 'testTransactionId2';
const RECEIPT_STATUS_SUCCESS = '0x1';
const RECEIPT_STATUS_FAILURE = '0x0';
const TRANSACTION_SIGNED_MOCK =
'0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890';
const TRANSACTION_SIGNED_2_MOCK =
'0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567891';
const TRANSACTION_PARAMS_MOCK = {
from: '0x1234567890abcdef1234567890abcdef12345678' as Hex,
to: '0xabcdef1234567890abcdef1234567890abcdef12' as Hex,
value: '0x1' as Hex,
};
const TRANSACTION_1_MOCK = {
id: TRANSACTION_ID_MOCK,
signedTx: TRANSACTION_SIGNED_MOCK,
params: TRANSACTION_PARAMS_MOCK,
} as PublishBatchHookTransaction;
const TRANSACTION_2_MOCK = {
id: TRANSACTION_ID_2_MOCK,
signedTx: TRANSACTION_SIGNED_2_MOCK,
params: TRANSACTION_PARAMS_MOCK,
} as PublishBatchHookTransaction;

const TRANSACTION_META_MOCK = {
id: TRANSACTION_ID_MOCK,
rawTx: '0xabcdef',
} as TransactionMeta;

const TRANSACTION_META_2_MOCK = {
id: TRANSACTION_ID_2_MOCK,
rawTx: '0x123456',
} as TransactionMeta;

describe('SequentialPublishBatchHook', () => {
let publishTransactionMock: jest.MockedFn<
(ethQuery: EthQuery, transactionMeta: TransactionMeta) => Promise<Hex>
>;
let getTransactionMock: jest.MockedFn<(id: string) => TransactionMeta>;
let getEthQueryMock: jest.MockedFn<(networkClientId: string) => EthQuery>;
let ethQueryInstanceMock: EthQuery;

beforeEach(() => {
jest.resetAllMocks();

publishTransactionMock = jest.fn();
getTransactionMock = jest.fn();
getEthQueryMock = jest.fn();

ethQueryInstanceMock = {} as EthQuery;
getEthQueryMock.mockReturnValue(ethQueryInstanceMock);

getTransactionMock.mockImplementation((id) => {
if (id === TRANSACTION_ID_MOCK) {
return TRANSACTION_META_MOCK;
}
if (id === TRANSACTION_ID_2_MOCK) {
return TRANSACTION_META_2_MOCK;
}
throw new Error(`Transaction with ID ${id} not found`);
});
});

afterEach(() => {
jest.clearAllMocks();
});

describe('getHook', () => {
it('publishes transactions sequentially and waits for confirmation', async () => {
queryMock
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({
status: 'empty',
})
.mockResolvedValue({
status: RECEIPT_STATUS_SUCCESS,
});

const transactions: PublishBatchHookTransaction[] = [
TRANSACTION_1_MOCK,
TRANSACTION_2_MOCK,
];

publishTransactionMock
.mockResolvedValueOnce(TRANSACTION_HASH_MOCK)
.mockResolvedValueOnce(TRANSACTION_HASH_2_MOCK);

const sequentialPublishBatchHook = new SequentialPublishBatchHook({
publishTransaction: publishTransactionMock,
getTransaction: getTransactionMock,
getEthQuery: getEthQueryMock,
});

const hook = sequentialPublishBatchHook.getHook();

const result = await hook({
from: '0x123',
networkClientId: NETWORK_CLIENT_ID_MOCK,
transactions,
});

expect(publishTransactionMock).toHaveBeenCalledTimes(2);
expect(publishTransactionMock).toHaveBeenNthCalledWith(
1,
ethQueryInstanceMock,
TRANSACTION_META_MOCK,
);
expect(publishTransactionMock).toHaveBeenNthCalledWith(
2,
ethQueryInstanceMock,
TRANSACTION_META_2_MOCK,
);

expect(queryMock).toHaveBeenCalledTimes(4);
expect(queryMock).toHaveBeenCalledWith(
ethQueryInstanceMock,
'getTransactionReceipt',
[TRANSACTION_HASH_MOCK],
);
expect(queryMock).toHaveBeenCalledWith(
ethQueryInstanceMock,
'getTransactionReceipt',
[TRANSACTION_HASH_2_MOCK],
);

expect(result).toStrictEqual({
results: [
{ transactionHash: TRANSACTION_HASH_MOCK },
{ transactionHash: TRANSACTION_HASH_2_MOCK },
],
});
});

it('throws if a transaction fails to publish', async () => {
const transactions: PublishBatchHookTransaction[] = [TRANSACTION_1_MOCK];

publishTransactionMock.mockRejectedValueOnce(
new Error('Failed to publish transaction'),
);

const sequentialPublishBatchHook = new SequentialPublishBatchHook({
publishTransaction: publishTransactionMock,
getTransaction: getTransactionMock,
getEthQuery: getEthQueryMock,
});

const hook = sequentialPublishBatchHook.getHook();

await expect(
hook({
from: '0x123',
networkClientId: NETWORK_CLIENT_ID_MOCK,
transactions,
}),
).rejects.toThrow(
rpcErrors.internal('Failed to publish sequential batch transaction'),
);

expect(publishTransactionMock).toHaveBeenCalledTimes(1);
expect(publishTransactionMock).toHaveBeenCalledWith(
ethQueryInstanceMock,
TRANSACTION_META_MOCK,
);
expect(queryMock).not.toHaveBeenCalled();
});

it('throws if a transaction is not confirmed', async () => {
const transactions: PublishBatchHookTransaction[] = [TRANSACTION_1_MOCK];

publishTransactionMock.mockResolvedValueOnce(TRANSACTION_HASH_MOCK);

queryMock.mockResolvedValueOnce({
status: RECEIPT_STATUS_FAILURE,
});

const sequentialPublishBatchHook = new SequentialPublishBatchHook({
publishTransaction: publishTransactionMock,
getTransaction: getTransactionMock,
getEthQuery: getEthQueryMock,
});

const hook = sequentialPublishBatchHook.getHook();

await expect(
hook({
from: '0x123',
networkClientId: NETWORK_CLIENT_ID_MOCK,
transactions,
}),
).rejects.toThrow(`Failed to publish sequential batch transaction`);

expect(publishTransactionMock).toHaveBeenCalledTimes(1);
expect(publishTransactionMock).toHaveBeenCalledWith(
ethQueryInstanceMock,
TRANSACTION_META_MOCK,
);
expect(queryMock).toHaveBeenCalledTimes(1);
expect(queryMock).toHaveBeenCalledWith(
ethQueryInstanceMock,
'getTransactionReceipt',
[TRANSACTION_HASH_MOCK],
);
});

it('returns false if transaction confirmation exceeds max attempts', async () => {
jest.useFakeTimers();

const transactions: PublishBatchHookTransaction[] = [TRANSACTION_1_MOCK];

publishTransactionMock.mockResolvedValueOnce(TRANSACTION_HASH_MOCK);

queryMock.mockImplementation(undefined);

const sequentialPublishBatchHook = new SequentialPublishBatchHook({
publishTransaction: publishTransactionMock,
getTransaction: getTransactionMock,
getEthQuery: getEthQueryMock,
});

const hook = sequentialPublishBatchHook.getHook();

const hookPromise = hook({
from: '0x123',
networkClientId: NETWORK_CLIENT_ID_MOCK,
transactions,
});

// Advance time 60 times by the interval (5s) to simulate 60 polling attempts
for (let i = 0; i < MAX_TRANSACTION_CHECK_ATTEMPTS; i++) {
jest.advanceTimersByTime(TRANSACTION_CHECK_INTERVAL);
await flushPromises();
}

jest.advanceTimersByTime(TRANSACTION_CHECK_INTERVAL);

await expect(hookPromise).rejects.toThrow(
'Failed to publish sequential batch transaction',
);

expect(publishTransactionMock).toHaveBeenCalledTimes(1);
expect(queryMock).toHaveBeenCalledTimes(MAX_TRANSACTION_CHECK_ATTEMPTS);

jest.useRealTimers();
});
});
});
Loading