Skip to content
Draft
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/snap/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]

### Fixed

- Extend transaction expiration when showing sign confirmation to prevent "Transaction too old" errors

## [1.20.0]

### Added
Expand Down
2 changes: 1 addition & 1 deletion packages/snap/snap.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/snap-tron-wallet.git"
},
"source": {
"shasum": "4ePTerkid6qLfLohShjnEntXl74FyR6QOKVMglucJdg=",
"shasum": "DAaO28Lcv7iR+30fmMdk4Abu47Kr3/b8DnnMLFET/IE=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
Expand Down
280 changes: 280 additions & 0 deletions packages/snap/src/services/confirmation/ConfirmationHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
import type { Transaction } from 'tronweb/lib/esm/types';

import { ConfirmationHandler } from './ConfirmationHandler';
import type { SnapClient } from '../../clients/snap/SnapClient';
import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory';
import { Network } from '../../constants';
import type { TronKeyringAccount } from '../../entities';
import { TronMultichainMethod } from '../../handlers/keyring-types';
import { render as renderConfirmSignTransaction } from '../../ui/confirmation/views/ConfirmSignTransaction/render';
import type { State, UnencryptedStateValue } from '../state/State';

// Import mocked functions

// Mock the render functions
jest.mock('../../ui/confirmation/views/ConfirmSignMessage/render', () => ({
render: jest.fn().mockResolvedValue(true),
}));

jest.mock('../../ui/confirmation/views/ConfirmSignTransaction/render', () => ({
render: jest.fn().mockResolvedValue(true),
}));

jest.mock(
'../../ui/confirmation/views/ConfirmTransactionRequest/render',
() => ({
render: jest.fn().mockResolvedValue(true),
}),
);

describe('ConfirmationHandler', () => {
const mockAccount: TronKeyringAccount = {
id: '123e4567-e89b-12d3-a456-426614174000',
address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8',
options: {},
methods: ['signMessage', 'signTransaction'],
type: 'tron:eoa',
scopes: [Network.Mainnet, Network.Shasta],
entropySource: 'entropy-source-1' as any,
derivationPath: "m/44'/195'/0'/0/0",
index: 0,
};

let confirmationHandler: ConfirmationHandler;
let mockSnapClient: jest.Mocked<SnapClient>;
let mockState: jest.Mocked<State<UnencryptedStateValue>>;
let mockTronWebFactory: jest.Mocked<TronWebFactory>;
let mockTronWeb: any;

// Sample transaction data
const originalRawDataHex = 'abcd1234';
const extendedRawDataHex = 'extended5678';
const originalExpiration = Date.now() + 60_000; // 60 seconds from now
const extendedExpiration = originalExpiration + 300_000; // +5 minutes

/* eslint-disable @typescript-eslint/naming-convention */
const mockRawData: Transaction['raw_data'] = {
contract: [],
ref_block_bytes: '1234',
ref_block_hash: 'abcd5678',
expiration: originalExpiration,
timestamp: Date.now(),
};

const mockExtendedRawData: Transaction['raw_data'] = {
...mockRawData,
expiration: extendedExpiration,
};
/* eslint-enable @typescript-eslint/naming-convention */

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

/* eslint-disable @typescript-eslint/naming-convention */
// Create mock extended transaction
const mockExtendedTransaction: Transaction = {
visible: true,
txID: 'new-tx-id',
raw_data: mockExtendedRawData,
raw_data_hex: extendedRawDataHex,
};
/* eslint-enable @typescript-eslint/naming-convention */

mockTronWeb = {
utils: {
deserializeTx: {
deserializeTransaction: jest.fn().mockReturnValue(mockRawData),
},
},
transactionBuilder: {
extendExpiration: jest.fn().mockResolvedValue(mockExtendedTransaction),
},
};

mockSnapClient = {} as jest.Mocked<SnapClient>;

mockState = {} as jest.Mocked<State<UnencryptedStateValue>>;

mockTronWebFactory = {
createClient: jest.fn().mockReturnValue(mockTronWeb),
} as any;

confirmationHandler = new ConfirmationHandler({
snapClient: mockSnapClient,
state: mockState,
tronWebFactory: mockTronWebFactory,
});
});

describe('handleKeyringRequest', () => {
describe('SignTransaction requests', () => {
it('extends transaction expiration before showing confirmation dialog', async () => {
const request = {
id: 'request-1',
account: mockAccount.id,
scope: Network.Mainnet,
origin: 'https://dapp.example.com',
request: {
method: TronMultichainMethod.SignTransaction,
params: {
address: mockAccount.address,
transaction: {
rawDataHex: originalRawDataHex,
type: 'TransferContract',
},
},
},
};

await confirmationHandler.handleKeyringRequest({
request: request as any,
account: mockAccount,
});

// Verify TronWeb was used to deserialize the transaction
expect(
mockTronWeb.utils.deserializeTx.deserializeTransaction,
).toHaveBeenCalledWith('TransferContract', originalRawDataHex);

// Verify extendExpiration was called with correct parameters
/* eslint-disable @typescript-eslint/naming-convention */
expect(
mockTronWeb.transactionBuilder.extendExpiration,
).toHaveBeenCalledWith(
expect.objectContaining({
raw_data: mockRawData,
raw_data_hex: originalRawDataHex,
}),
300_000, // 5 minutes in milliseconds
);
/* eslint-enable @typescript-eslint/naming-convention */

// Verify the request was updated with the extended rawDataHex
expect(request.request.params.transaction.rawDataHex).toBe(
extendedRawDataHex,
);

// Verify render was called with the extended raw data
expect(renderConfirmSignTransaction).toHaveBeenCalledWith(
request,
mockAccount,
mockExtendedRawData,
);
});

it('returns true when user approves the transaction', async () => {
const request = {
id: 'request-1',
account: mockAccount.id,
scope: Network.Mainnet,
origin: 'https://dapp.example.com',
request: {
method: TronMultichainMethod.SignTransaction,
params: {
address: mockAccount.address,
transaction: {
rawDataHex: originalRawDataHex,
type: 'TransferContract',
},
},
},
};

(renderConfirmSignTransaction as jest.Mock).mockResolvedValueOnce(true);

const result = await confirmationHandler.handleKeyringRequest({
request: request as any,
account: mockAccount,
});

expect(result).toBe(true);
});

it('returns false when user rejects the transaction', async () => {
const request = {
id: 'request-1',
account: mockAccount.id,
scope: Network.Mainnet,
origin: 'https://dapp.example.com',
request: {
method: TronMultichainMethod.SignTransaction,
params: {
address: mockAccount.address,
transaction: {
rawDataHex: originalRawDataHex,
type: 'TransferContract',
},
},
},
};

(renderConfirmSignTransaction as jest.Mock).mockResolvedValueOnce(
false,
);

const result = await confirmationHandler.handleKeyringRequest({
request: request as any,
account: mockAccount,
});

expect(result).toBe(false);
});

it('handles TriggerSmartContract transaction types', async () => {
const request = {
id: 'request-1',
account: mockAccount.id,
scope: Network.Mainnet,
origin: 'https://dapp.example.com',
request: {
method: TronMultichainMethod.SignTransaction,
params: {
address: mockAccount.address,
transaction: {
rawDataHex: originalRawDataHex,
type: 'TriggerSmartContract',
},
},
},
};

await confirmationHandler.handleKeyringRequest({
request: request as any,
account: mockAccount,
});

expect(
mockTronWeb.utils.deserializeTx.deserializeTransaction,
).toHaveBeenCalledWith('TriggerSmartContract', originalRawDataHex);

expect(
mockTronWeb.transactionBuilder.extendExpiration,
).toHaveBeenCalled();
});
});

describe('unsupported methods', () => {
it('throws error for unhandled keyring request methods', async () => {
const request = {
id: 'request-1',
account: mockAccount.id,
scope: Network.Mainnet,
origin: 'https://dapp.example.com',
request: {
method: 'unsupportedMethod' as any,
params: {},
},
};

await expect(
confirmationHandler.handleKeyringRequest({
request: request as any,
account: mockAccount,
}),
).rejects.toThrow(
'Unhandled keyring request method: unsupportedMethod',
);
});
});
});
});
68 changes: 64 additions & 4 deletions packages/snap/src/services/confirmation/ConfirmationHandler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { assert } from '@metamask/superstruct';
import { bytesToHex, hexToBytes, sha256 } from '@metamask/utils';
import type { Transaction } from 'tronweb/lib/esm/types';

import type { SnapClient } from '../../clients/snap/SnapClient';
import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory';
Expand Down Expand Up @@ -90,12 +92,12 @@ export class ConfirmationHandler {
const {
scope,
request: {
params: {
transaction: { rawDataHex, type },
},
params: { transaction: transactionParams },
},
} = request;

const { rawDataHex, type } = transactionParams;

// Create a TronWeb instance for transaction deserialization
const tronWeb = this.#tronWebFactory.createClient(scope);

Expand All @@ -105,14 +107,72 @@ export class ConfirmationHandler {
rawDataHex,
);

// Extend the transaction expiration to give users more time to review
// dApps typically set ~60 second expiration which can expire during review
const extendedTransaction = await this.#extendTransactionExpiration(
tronWeb,
rawData,
rawDataHex,
);

// Update the request with the extended transaction's rawDataHex
// This ensures the confirmation dialog and subsequent signing use the extended transaction
transactionParams.rawDataHex = extendedTransaction.raw_data_hex;

const result = await renderConfirmSignTransaction(
request,
account,
rawData,
extendedTransaction.raw_data,
);
return result === true;
}

/**
* Extends transaction expiration by 5 minutes to prevent "Transaction too old" errors.
* dApps typically create transactions with ~60 second expiration, but users may need
* more time to review security alerts and transaction details.
*
* @param tronWeb - TronWeb instance for the network
* @param rawData - Deserialized transaction raw data
* @param rawDataHex - Original hex-encoded raw data
* @returns Transaction with extended expiration
*/
async #extendTransactionExpiration(
tronWeb: ReturnType<TronWebFactory['createClient']>,
rawData: Transaction['raw_data'],
rawDataHex: string,
): Promise<Transaction> {
// Build a Transaction object from the deserialized data
const txID = bytesToHex(await sha256(hexToBytes(rawDataHex))).slice(2);

const transaction: Transaction = {
visible: true,
txID,
// eslint-disable-next-line @typescript-eslint/naming-convention
raw_data: rawData,
// eslint-disable-next-line @typescript-eslint/naming-convention
raw_data_hex: rawDataHex,
};

// Extend expiration by 5 minutes (300,000 milliseconds)
// This gives users adequate time to review the transaction details and security alerts
const EXPIRATION_EXTENSION_MS = 300_000;

const extendedTransaction =
await tronWeb.transactionBuilder.extendExpiration(
transaction,
EXPIRATION_EXTENSION_MS,
);

this.#logger.info('Extended transaction expiration', {
originalExpiration: rawData.expiration,
newExpiration: extendedTransaction.raw_data.expiration,
extensionMs: EXPIRATION_EXTENSION_MS,
});

return extendedTransaction;
}

async confirmTransactionRequest({
scope,
fromAddress,
Expand Down
Loading