Skip to content
Merged
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
297 changes: 297 additions & 0 deletions src/SmartTransactionsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@

controller.timeoutHandle = setTimeout(() => ({}));

controller.poll(1000);

Check warning on line 450 in src/SmartTransactionsController.test.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (18.x)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 450 in src/SmartTransactionsController.test.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (20.x)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

expect(updateSmartTransactionsSpy).toHaveBeenCalled();
});
Expand Down Expand Up @@ -723,6 +723,183 @@
});
});

it('should acquire nonce for Swap transactions only', async () => {
// Create a mock for getNonceLock
const mockGetNonceLock = jest.fn().mockResolvedValue({
nextNonce: 'nextNonce',
nonceDetails: { test: 'details' },
releaseLock: jest.fn(),
});

await withController(
{
options: {
getNonceLock: mockGetNonceLock,
},
},
async ({ controller }) => {
const signedTransaction = createSignedTransaction();
const submitTransactionsApiResponse =
createSubmitTransactionsApiResponse();

// First API mock for the case without nonce
nock(API_BASE_URL)
.post(
`/networks/${ethereumChainIdDec}/submitTransactions?stxControllerVersion=${packageJson.version}`,
)
.reply(200, submitTransactionsApiResponse);

// Second API mock for the case with nonce
nock(API_BASE_URL)
.post(
`/networks/${ethereumChainIdDec}/submitTransactions?stxControllerVersion=${packageJson.version}`,
)
.reply(200, submitTransactionsApiResponse);

// Case 1: Swap transaction without nonce (should call getNonceLock)
const txParamsWithoutNonce = {
...createTxParams(),
nonce: undefined, // Explicitly undefined nonce
};

await controller.submitSignedTransactions({
signedTransactions: [signedTransaction],
txParams: txParamsWithoutNonce,
// No transactionMeta means type defaults to 'swap'
});

// Verify getNonceLock was called for the Swap
expect(mockGetNonceLock).toHaveBeenCalledTimes(1);
expect(mockGetNonceLock).toHaveBeenCalledWith(
txParamsWithoutNonce.from,
NetworkType.mainnet,
);

// Reset the mock
mockGetNonceLock.mockClear();

// Case 2: Transaction with nonce already set (should NOT call getNonceLock)
const txParamsWithNonce = createTxParams(); // This has nonce: '0'

await controller.submitSignedTransactions({
signedTransactions: [signedTransaction],
txParams: txParamsWithNonce,
});

// Verify getNonceLock was NOT called for transaction with nonce
expect(mockGetNonceLock).not.toHaveBeenCalled();
},
);
});

it('should properly set nonce on txParams and mark transaction as swap type', async () => {
// Mock with a specific nextNonce value we can verify
const mockGetNonceLock = jest.fn().mockResolvedValue({
nextNonce: 42,
nonceDetails: { test: 'nonce details' },
releaseLock: jest.fn(),
});

await withController(
{
options: {
getNonceLock: mockGetNonceLock,
},
},
async ({ controller }) => {
const signedTransaction = createSignedTransaction();
const submitTransactionsApiResponse =
createSubmitTransactionsApiResponse();
nock(API_BASE_URL)
.post(
`/networks/${ethereumChainIdDec}/submitTransactions?stxControllerVersion=${packageJson.version}`,
)
.reply(200, submitTransactionsApiResponse);

// Create txParams without nonce
const txParamsWithoutNonce = {
...createTxParams(),
nonce: undefined,
from: addressFrom,
};

await controller.submitSignedTransactions({
signedTransactions: [signedTransaction],
txParams: txParamsWithoutNonce,
// No transactionMeta provided, should default to 'swap' type
});

// Get the created smart transaction
const createdSmartTransaction =
controller.state.smartTransactionsState.smartTransactions[
ChainId.mainnet
][0];

// Verify nonce was set correctly on the txParams in the created transaction
expect(createdSmartTransaction.txParams.nonce).toBe('0x42'); // 42 as a hex string

// Verify transaction type is set to 'swap' by default
expect(createdSmartTransaction.type).toBe('swap');

// Verify nonceDetails were passed correctly
expect(createdSmartTransaction.nonceDetails).toStrictEqual({
test: 'nonce details',
});
},
);
});

it('should handle errors when acquiring nonce lock', async () => {
// Mock getNonceLock to reject with an error
const mockError = new Error('Failed to acquire nonce');
const mockGetNonceLock = jest.fn().mockRejectedValue(mockError);

// Spy on console.error to verify it's called
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();

await withController(
{
options: {
getNonceLock: mockGetNonceLock,
},
},
async ({ controller }) => {
const signedTransaction = createSignedTransaction();
const submitTransactionsApiResponse =
createSubmitTransactionsApiResponse();
nock(API_BASE_URL)
.post(
`/networks/${ethereumChainIdDec}/submitTransactions?stxControllerVersion=${packageJson.version}`,
)
.reply(200, submitTransactionsApiResponse);

// Create txParams without nonce
const txParamsWithoutNonce = {
...createTxParams(),
nonce: undefined,
from: addressFrom,
};

// Attempt to submit a transaction that will fail when acquiring nonce
await expect(
controller.submitSignedTransactions({
signedTransactions: [signedTransaction],
txParams: txParamsWithoutNonce,
}),
).rejects.toThrow('Failed to acquire nonce');

// Verify error was logged
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to acquire nonce lock:',
mockError,
);

// Cleanup spy
consoleErrorSpy.mockRestore();
},
);
});

it('submits a batch of signed transactions', async () => {
await withController(async ({ controller }) => {
const signedTransaction1 = createSignedTransaction();
Expand Down Expand Up @@ -2090,6 +2267,126 @@
);
});
});

describe('createOrUpdateSmartTransaction', () => {
beforeEach(() => {
jest
.spyOn(SmartTransactionsController.prototype, 'checkPoll')
.mockImplementation(() => ({}));
});

it('adds metaMetricsProps to new smart transactions', async () => {
const { smartTransactionsState } =
getDefaultSmartTransactionsControllerState();
const newSmartTransaction = {
uuid: 'new-uuid-test',
status: SmartTransactionStatuses.PENDING,
txParams: {
from: addressFrom,
},
};

await withController(
{
options: {
state: {
smartTransactionsState: {
...smartTransactionsState,
smartTransactions: {
[ChainId.mainnet]: [],
},
},
},
getMetaMetricsProps: jest.fn().mockResolvedValue({
accountHardwareType: 'Test Hardware',
accountType: 'test-account',
deviceModel: 'test-model',
}),
},
},
async ({ controller }) => {
controller.updateSmartTransaction(
newSmartTransaction as SmartTransaction,
);

// Allow async operations to complete
await flushPromises();

// Verify MetaMetricsProps were added
const updatedTransaction =
controller.state.smartTransactionsState.smartTransactions[
ChainId.mainnet
][0];
expect(updatedTransaction.accountHardwareType).toBe('Test Hardware');
expect(updatedTransaction.accountType).toBe('test-account');
expect(updatedTransaction.deviceModel).toBe('test-model');
},
);
});

it('continues without metaMetricsProps if adding them fails', async () => {
const { smartTransactionsState } =
getDefaultSmartTransactionsControllerState();
const newSmartTransaction = {
uuid: 'new-uuid-test',
status: SmartTransactionStatuses.PENDING,
txParams: {
from: addressFrom,
},
};

// Mock console.error to verify it's called
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();

await withController(
{
options: {
state: {
smartTransactionsState: {
...smartTransactionsState,
smartTransactions: {
[ChainId.mainnet]: [],
},
},
},
// Mock getting MetaMetricsProps to fail
getMetaMetricsProps: jest
.fn()
.mockRejectedValue(new Error('Test metrics error')),
},
},
async ({ controller }) => {
controller.updateSmartTransaction(
newSmartTransaction as SmartTransaction,
);

// Allow async operations to complete
await flushPromises();

// Verify transaction was still added even without metrics props
const updatedTransaction =
controller.state.smartTransactionsState.smartTransactions[
ChainId.mainnet
][0];
expect(updatedTransaction.uuid).toBe('new-uuid-test');

// These should be undefined since getting metrics props failed
expect(updatedTransaction.accountHardwareType).toBeUndefined();
expect(updatedTransaction.accountType).toBeUndefined();
expect(updatedTransaction.deviceModel).toBeUndefined();

// Verify the error was logged
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to add metrics props to smart transaction:',
expect.any(Error),
);

// Clean up the spy
consoleErrorSpy.mockRestore();
},
);
});
});
});

type WithControllerCallback<ReturnValue> = ({
Expand Down Expand Up @@ -2262,7 +2559,7 @@
triggerNetworStateChange,
});
} finally {
controller.stop();

Check warning on line 2562 in src/SmartTransactionsController.test.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (18.x)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 2562 in src/SmartTransactionsController.test.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (20.x)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
controller.stopAllPolling();
}
}
43 changes: 31 additions & 12 deletions src/SmartTransactionsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,9 +348,9 @@
isSmartTransactionPending,
);
if (!this.timeoutHandle && pendingTransactions?.length > 0) {
this.poll();

Check warning on line 351 in src/SmartTransactionsController.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (18.x)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 351 in src/SmartTransactionsController.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (20.x)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
} else if (this.timeoutHandle && pendingTransactions?.length === 0) {
this.stop();

Check warning on line 353 in src/SmartTransactionsController.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (18.x)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 353 in src/SmartTransactionsController.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (20.x)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
}
}

Expand All @@ -375,7 +375,7 @@
}

this.timeoutHandle = setInterval(() => {
safelyExecute(async () => this.updateSmartTransactions());

Check warning on line 378 in src/SmartTransactionsController.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (18.x)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 378 in src/SmartTransactionsController.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (20.x)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
}, this.#interval);
await safelyExecute(async () => this.updateSmartTransactions());
}
Expand Down Expand Up @@ -442,7 +442,7 @@
ethQuery = new EthQuery(provider);
}

this.#createOrUpdateSmartTransaction(smartTransaction, {

Check warning on line 445 in src/SmartTransactionsController.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (18.x)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 445 in src/SmartTransactionsController.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (20.x)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
chainId,
ethQuery,
});
Expand Down Expand Up @@ -513,12 +513,20 @@
smartTransaction.uuid,
chainId,
);
if (this.#ethQuery === undefined) {
if (ethQuery === undefined) {
throw new Error(ETH_QUERY_ERROR_MSG);
}

if (isNewSmartTransaction) {
await this.#addMetaMetricsPropsToNewSmartTransaction(smartTransaction);
try {
await this.#addMetaMetricsPropsToNewSmartTransaction(smartTransaction);
} catch (error) {
console.error(
'Failed to add metrics props to smart transaction:',
error,
);
// Continue without metrics props
}
}

this.trackStxStatusChange(
Expand Down Expand Up @@ -721,7 +729,7 @@
: originalTxMeta;

if (this.#doesTransactionNeedConfirmation(txHash)) {
this.#confirmExternalTransaction(

Check warning on line 732 in src/SmartTransactionsController.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (18.x)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 732 in src/SmartTransactionsController.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (20.x)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
// TODO: Replace 'as' assertion with correct typing for `txMeta`
txMeta as TransactionMeta,
transactionReceipt,
Expand Down Expand Up @@ -955,28 +963,34 @@
preTxBalance = new BigNumber(preTxBalanceBN).toString(16);
}
} catch (error) {
console.error('provider error', error);
console.error('ethQuery.getBalance error:', error);
}

const requiresNonce = txParams && !txParams.nonce;
let nonce;
let nonceLock;
let nonceDetails = {};

// This should only happen for Swaps. Non-swaps transactions should already have a nonce
if (requiresNonce) {
nonceLock = await this.#getNonceLock(
txParams.from,
selectedNetworkClientId,
);
nonce = hexlify(nonceLock.nextNonce);
nonceDetails = nonceLock.nonceDetails;
txParams.nonce ??= nonce;
try {
nonceLock = await this.#getNonceLock(
txParams.from,
selectedNetworkClientId,
);
nonce = hexlify(nonceLock.nextNonce);
nonceDetails = nonceLock.nonceDetails;
txParams.nonce ??= nonce;
} catch (error) {
console.error('Failed to acquire nonce lock:', error);
throw error;
}
}

const txHashes = signedTransactions.map((tx) => getTxHash(tx));
const submitTransactionResponse = {
...data,
txHash: txHashes[0], // For backward compatibility
txHash: txHashes[txHashes.length - 1], // For backward compatibility - use the last tx hash
txHashes,
};

Expand All @@ -999,8 +1013,13 @@
},
{ chainId, ethQuery },
);
} catch (error) {
console.error('Failed to create a smart transaction:', error);
throw error;
} finally {
nonceLock?.releaseLock();
if (nonceLock) {
nonceLock.releaseLock();
}
}

return submitTransactionResponse;
Expand Down
Loading