From 18d5846dcf5b9badc633c93161a651f52dd2b51e Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Wed, 4 Nov 2026 16:48:02 +0000 Subject: [PATCH 1/3] feat(tron): estimate send fees conservatively in onAmountInput --- packages/tron-wallet-snap/CHANGELOG.md | 4 + packages/tron-wallet-snap/snap.manifest.json | 2 +- .../clientRequest/clientRequest.test.ts | 249 +++++++++++++++++- .../handlers/clientRequest/clientRequest.ts | 40 ++- .../send/FeeCalculatorService.test.ts | 57 ++++ .../src/services/send/FeeCalculatorService.ts | 24 ++ 6 files changed, 357 insertions(+), 19 deletions(-) diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index 5960cd5e5..6f6c5ac60 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `onAmountInput` now estimates send fees when `toAddress` is absent. Native TRX includes the account activation reserve, while TRC-20 sends validate the transfer fee without an activation reserve ([#554](https://github.com/MetaMask/internal-snaps/pull/554)) + ## [4.3.1] ### Fixed diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index f6ddbe027..faf94ad01 100644 --- a/packages/tron-wallet-snap/snap.manifest.json +++ b/packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "CTTNZeBEFTBQaq0XGhhBQqnz4oYmcTvRym4q23a4vdw=", + "shasum": "lrP714+xlkY0QTABjsU6mx7lAtzRVB8KgM7w1BOuptk=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.test.ts b/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.test.ts index ee8caf1e3..33437e0d2 100644 --- a/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.test.ts +++ b/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.test.ts @@ -163,7 +163,9 @@ type WithClientRequestHandlerCallback = (payload: { mockSendService: MockMethods< 'buildTransaction' | 'validateSend' | 'signAndSendTransaction' >; - mockFeeCalculatorService: MockMethods<'computeFee'>; + mockFeeCalculatorService: MockMethods< + 'computeFee' | 'getAccountActivationReserve' + >; mockStakingService: MockMethods< 'claimUnstakedTrx' | 'claimTrxStakingRewards' >; @@ -223,8 +225,11 @@ async function withClientRequestHandler( signAndSendTransaction: jest.fn(), }; - const mockFeeCalculatorService: MockMethods<'computeFee'> = { + const mockFeeCalculatorService: MockMethods< + 'computeFee' | 'getAccountActivationReserve' + > = { computeFee: jest.fn(), + getAccountActivationReserve: jest.fn(), }; const mockStakingService: MockMethods< 'claimUnstakedTrx' | 'claimTrxStakingRewards' @@ -2230,6 +2235,17 @@ describe('ClientRequestHandler - onAmountInput', () => { iconUrl: Networks[scope].nativeToken.iconUrl, }); + const createTokenAsset = (uiAmount: string): AssetEntity => ({ + assetType: 'tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + keyringAccountId: TEST_ACCOUNT_ID, + network: scope, + symbol: 'USDT', + decimals: 6, + rawAmount: trxToSun(uiAmount), + uiAmount, + iconUrl: '', + }); + const createResourceAsset = ( assetType: ResourceAsset['assetType'], uiAmount: string, @@ -2282,7 +2298,7 @@ describe('ClientRequestHandler - onAmountInput', () => { raw_data_hex: 'mock-hex', }); - it('returns valid and skips fee validation when toAddress is missing', async () => { + it('estimates fees conservatively when toAddress is missing', async () => { await withClientRequestHandler( async ({ handler, @@ -2314,15 +2330,238 @@ describe('ClientRequestHandler - onAmountInput', () => { createResourceAsset(Networks[scope].bandwidth.id, '5000', '5000'), createResourceAsset(Networks[scope].energy.id, '100000', '100000'), ]; + const builtTransaction = createMockTransferTransaction(); + const mockFees: ComputeFeeResult = [ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: nativeTokenId, + amount: '1', + fungible: true, + }, + }, + ]; mockAccountsService.findById.mockResolvedValue(mockAccount); mockAssetsService.getAccountAssetsByIDs.mockResolvedValue(mockAssets); + mockSendService.buildTransaction.mockResolvedValue(builtTransaction); + mockFeeCalculatorService.computeFee.mockResolvedValue(mockFees); + mockFeeCalculatorService.getAccountActivationReserve.mockResolvedValue( + BigNumber('1.1'), + ); const result = await handler.handle(request); expect(result).toStrictEqual({ valid: true, errors: [] }); - expect(mockSendService.buildTransaction).not.toHaveBeenCalled(); - expect(mockFeeCalculatorService.computeFee).not.toHaveBeenCalled(); + expect(mockSendService.buildTransaction).toHaveBeenCalledWith({ + fromAccountId: TEST_ACCOUNT_ID, + toAddress: mockAccount.address, + asset: mockAsset, + amount: new BigNumber('10'), + feeLimit: FEE_LIMIT, + }); + expect( + mockFeeCalculatorService.getAccountActivationReserve, + ).toHaveBeenCalledWith(scope); + }, + ); + }); + + it('returns insufficient balance to cover fee when toAddress is missing and the activation reserve cannot be covered', async () => { + await withClientRequestHandler( + async ({ + handler, + mockAccountsService, + mockAssetsService, + mockSendService, + mockFeeCalculatorService, + }) => { + const request: OnAmountInputRequest = { + jsonrpc: '2.0' as const, + id: '2', + method: ClientRequestMethod.OnAmountInput, + params: { + accountId: TEST_ACCOUNT_ID, + assetId: nativeTokenId, + value: '9', + }, + }; + + const mockAsset = createNativeAsset('10', '10000000'); + const mockAssets: [ + NativeAsset, + NativeAsset, + ResourceAsset, + ResourceAsset, + ] = [ + mockAsset, + mockAsset, + createResourceAsset(Networks[scope].bandwidth.id, '0', '0'), + createResourceAsset(Networks[scope].energy.id, '0', '0'), + ]; + const mockFees: ComputeFeeResult = [ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: nativeTokenId, + amount: '1', + fungible: true, + }, + }, + ]; + + mockAccountsService.findById.mockResolvedValue(mockAccount); + mockAssetsService.getAccountAssetsByIDs.mockResolvedValue(mockAssets); + mockSendService.buildTransaction.mockResolvedValue( + createMockTransferTransaction(), + ); + mockFeeCalculatorService.computeFee.mockResolvedValue(mockFees); + mockFeeCalculatorService.getAccountActivationReserve.mockResolvedValue( + BigNumber('1.1'), + ); + + const result = await handler.handle(request); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalanceToCoverFee }], + }); + }, + ); + }); + + it('estimates the transfer fee without an activation reserve for TRC-20 when toAddress is missing', async () => { + await withClientRequestHandler( + async ({ + handler, + mockAccountsService, + mockAssetsService, + mockSendService, + mockFeeCalculatorService, + }) => { + const assetId = + 'tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'; + const request: OnAmountInputRequest = { + jsonrpc: '2.0' as const, + id: '3', + method: ClientRequestMethod.OnAmountInput, + params: { + accountId: TEST_ACCOUNT_ID, + assetId, + value: '50', + }, + }; + + const tokenAsset = createTokenAsset('50'); + const mockAssets: [ + AssetEntity, + NativeAsset, + ResourceAsset, + ResourceAsset, + ] = [ + tokenAsset, + createNativeAsset('10', '10000000'), + createResourceAsset(Networks[scope].bandwidth.id, '0', '0'), + createResourceAsset(Networks[scope].energy.id, '0', '0'), + ]; + const mockFees: ComputeFeeResult = [ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: nativeTokenId, + amount: '1', + fungible: true, + }, + }, + ]; + + mockAccountsService.findById.mockResolvedValue(mockAccount); + mockAssetsService.getAccountAssetsByIDs.mockResolvedValue(mockAssets); + mockSendService.buildTransaction.mockResolvedValue( + createMockTransferTransaction(), + ); + mockFeeCalculatorService.computeFee.mockResolvedValue(mockFees); + + const result = await handler.handle(request); + + expect(result).toStrictEqual({ valid: true, errors: [] }); + expect(mockSendService.buildTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + toAddress: mockAccount.address, + asset: tokenAsset, + }), + ); + expect( + mockFeeCalculatorService.getAccountActivationReserve, + ).not.toHaveBeenCalled(); + }, + ); + }); + + it('returns insufficient balance to cover fee for TRC-20 when toAddress is missing and TRX cannot cover the transfer fee', async () => { + await withClientRequestHandler( + async ({ + handler, + mockAccountsService, + mockAssetsService, + mockSendService, + mockFeeCalculatorService, + }) => { + const assetId = + 'tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'; + const request: OnAmountInputRequest = { + jsonrpc: '2.0' as const, + id: '4', + method: ClientRequestMethod.OnAmountInput, + params: { + accountId: TEST_ACCOUNT_ID, + assetId, + value: '50', + }, + }; + + const mockAssets: [ + AssetEntity, + NativeAsset, + ResourceAsset, + ResourceAsset, + ] = [ + createTokenAsset('50'), + createNativeAsset('0.5', '500000'), + createResourceAsset(Networks[scope].bandwidth.id, '0', '0'), + createResourceAsset(Networks[scope].energy.id, '0', '0'), + ]; + const mockFees: ComputeFeeResult = [ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: nativeTokenId, + amount: '1', + fungible: true, + }, + }, + ]; + + mockAccountsService.findById.mockResolvedValue(mockAccount); + mockAssetsService.getAccountAssetsByIDs.mockResolvedValue(mockAssets); + mockSendService.buildTransaction.mockResolvedValue( + createMockTransferTransaction(), + ); + mockFeeCalculatorService.computeFee.mockResolvedValue(mockFees); + + const result = await handler.handle(request); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalanceToCoverFee }], + }); + expect( + mockFeeCalculatorService.getAccountActivationReserve, + ).not.toHaveBeenCalled(); }, ); }); diff --git a/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts index d70e2d6c0..d7cc90ecb 100644 --- a/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts +++ b/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -538,24 +538,21 @@ export class ClientRequestHandler { }; } - if (!toAddress) { - return { - valid: true, - errors: [], - }; - } - /** - * Estimate the fees + * Estimate the fees. When the destination is unknown, build against the + * sender's own (activated) account and reserve the activation cost + * separately, so validation covers the worst case. */ + const nativeTokenId = Networks[scope].nativeToken.id; + const isNativeToken = assetId === nativeTokenId; const sendTransaction = await this.#sendService.buildTransaction({ fromAccountId: accountId, - toAddress, + toAddress: toAddress ?? account.address, asset, amount: valueBN, feeLimit: FEE_LIMIT, }); - const fees = await this.#feeCalculatorService.computeFee({ + const quotedFees = await this.#feeCalculatorService.computeFee({ scope, transaction: sendTransaction, availableEnergy: energyBalance, @@ -563,11 +560,29 @@ export class ClientRequestHandler { feeLimit: FEE_LIMIT, }); + let fees = quotedFees; + if (!toAddress && isNativeToken) { + const activationReserve = + await this.#feeCalculatorService.getAccountActivationReserve(scope); + fees = quotedFees.map((fee) => + fee.asset.type === nativeTokenId + ? { + ...fee, + asset: { + ...fee.asset, + amount: new BigNumber(fee.asset.amount) + .plus(activationReserve) + .toString(), + }, + } + : fee, + ); + } + /** * The fee calculation already takes into account the energy and bandwidth consumption, * so we only need to make sure we have enough TRX to cover overages. */ - const nativeTokenId = Networks[scope].nativeToken.id; const trxFee = new BigNumber( fees.find((fee) => fee.asset.type === nativeTokenId)?.asset.amount ?? '0', @@ -577,8 +592,7 @@ export class ClientRequestHandler { * Don't forget that we can also be sending TRX so we must add the fees to the amount that will be * sent. */ - const totalTrxToSpend = - assetId === nativeTokenId ? valueBN.plus(trxFee) : trxFee; + const totalTrxToSpend = isNativeToken ? valueBN.plus(trxFee) : trxFee; if (totalTrxToSpend.isGreaterThan(nativeTokenBalance)) { return { diff --git a/packages/tron-wallet-snap/src/services/send/FeeCalculatorService.test.ts b/packages/tron-wallet-snap/src/services/send/FeeCalculatorService.test.ts index de6439590..c32930a6f 100644 --- a/packages/tron-wallet-snap/src/services/send/FeeCalculatorService.test.ts +++ b/packages/tron-wallet-snap/src/services/send/FeeCalculatorService.test.ts @@ -182,6 +182,63 @@ async function withFeeCalculatorService( } describe('FeeCalculatorService', () => { + describe('getAccountActivationReserve', () => { + it('reserves the default activation cost when the chain parameters lack the keys', async () => { + await withFeeCalculatorService( + async ({ feeCalculatorService, trongridApiClient }) => { + trongridApiClient.getChainParameters.mockResolvedValue([]); + + const reserve = + await feeCalculatorService.getAccountActivationReserve( + Network.Mainnet, + ); + + // Default: getCreateNewAccountFeeInSystemContract (1 TRX) + // + getCreateAccountFee Bandwidth shortfall (0.1 TRX) + expect(reserve.toString()).toBe('1.1'); + }, + ); + }); + + it('uses the live chain parameters', async () => { + await withFeeCalculatorService( + async ({ feeCalculatorService, trongridApiClient }) => { + trongridApiClient.getChainParameters.mockResolvedValue([ + { + key: 'getCreateNewAccountFeeInSystemContract', + value: 2_000_000, + }, + { key: 'getCreateAccountFee', value: 200_000 }, + ]); + + const reserve = + await feeCalculatorService.getAccountActivationReserve( + Network.Mainnet, + ); + + expect(reserve.toString()).toBe('2.2'); + }, + ); + }); + + it('throws when neither live nor cached chain parameters are available', async () => { + await withFeeCalculatorService( + async ({ feeCalculatorService, trongridApiClient }) => { + trongridApiClient.getChainParameters.mockRejectedValue( + new Error('network error'), + ); + trongridApiClient.peekCachedChainParameters.mockResolvedValue( + undefined, + ); + + await expect( + feeCalculatorService.getAccountActivationReserve(Network.Mainnet), + ).rejects.toThrow(FeeUnavailableError); + }, + ); + }); + }); + describe('computeFee', () => { const expectedMainnetBandwidthFee = { type: FeeType.Base, diff --git a/packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts b/packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts index 936f7f89c..57f46227b 100644 --- a/packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts +++ b/packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts @@ -811,6 +811,30 @@ export class FeeCalculatorService { }; } + /** + * Conservative TRX reserve for activating an unknown recipient account. + * + * A send whose destination is not known yet cannot assess whether the + * recipient is activated, so callers reserve the full activation cost: + * the `getCreateNewAccountFeeInSystemContract` burn plus the + * `getCreateAccountFee` Bandwidth shortfall, assuming the sender has no + * staked Bandwidth to cover the account creation. + * + * @param scope - The network scope to resolve chain parameters for. + * @returns Promise - The activation reserve in TRX. + */ + async getAccountActivationReserve(scope: Network): Promise { + const chainParameters = await this.#getChainParameters(scope); + const { activationFeeTrx, createAccountFeeTrx } = + this.#getActivationFeeParams(chainParameters); + + const reserve = activationFeeTrx.plus(createAccountFeeTrx); + + this.#logger.log(`Account activation reserve: ${reserve.toString()} TRX`); + + return reserve; + } + /** * Calculate memo/note fee for the transaction. * Per Tron protocol, adding a memo/note incurs a flat 1 TRX fee. From 96c6a1072d7a9eaf2a183b86a44c7e9ba0538f32 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Tue, 3 Nov 2026 09:26:48 +0000 Subject: [PATCH 2/3] feat(tron): add getMaxSendableAmount client request method Computes the maximum safe amount that can be sent for TRX and TRC-20 assets, with its fee quote. The sent asset and the account resources are read fresh, so the result never acts on stale balances. For native TRX sends the maximum leaves enough TRX to cover the network fees and the account activation; for TRC-20 sends the account must hold enough TRX to cover the transfer fee. When the destination is omitted, the conservative case is assumed and the quoted fees include a conservative account activation reserve. WPN-2374 --- packages/tron-wallet-snap/CHANGELOG.md | 1 + packages/tron-wallet-snap/snap.manifest.json | 2 +- .../clientRequest/clientRequest.test.ts | 463 ++++++++++++++++++ .../handlers/clientRequest/clientRequest.ts | 131 +++++ .../src/handlers/clientRequest/types.ts | 1 + .../src/handlers/clientRequest/validation.ts | 28 ++ 6 files changed, 625 insertions(+), 1 deletion(-) diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index 6f6c5ac60..3295f899c 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `onAmountInput` now estimates send fees when `toAddress` is absent. Native TRX includes the account activation reserve, while TRC-20 sends validate the transfer fee without an activation reserve ([#554](https://github.com/MetaMask/internal-snaps/pull/554)) +- Add the `getMaxSendableAmount` client request method, returning the maximum amount that can be sent for TRX and TRC-20 assets. The maximum leaves enough TRX for the network fees and account activation (native sends) or the transfer fee (TRC-20), and the destination is conservatively assumed unactivated when omitted ([#555](https://github.com/MetaMask/internal-snaps/pull/555)) ## [4.3.1] diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index faf94ad01..716e92066 100644 --- a/packages/tron-wallet-snap/snap.manifest.json +++ b/packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "lrP714+xlkY0QTABjsU6mx7lAtzRVB8KgM7w1BOuptk=", + "shasum": "7PH2vn+JpXgdl+jqoCmFUHVqIGLCkCM8lrzZeWQ2C8Y=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.test.ts b/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.test.ts index 33437e0d2..cf0cda018 100644 --- a/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.test.ts +++ b/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.test.ts @@ -5,6 +5,7 @@ import { } from '@metamask/keyring-api'; import { DEFAULT_METAMASK_ORIGIN } from '@metamask/snap-networks-utils'; import type { AnalyticsService } from '@metamask/snap-networks-utils'; +import { InvalidParamsError } from '@metamask/snaps-sdk'; import type { JsonRpcRequest } from '@metamask/snaps-sdk'; import type { Infer } from '@metamask/superstruct'; import { BigNumber } from 'bignumber.js'; @@ -2988,6 +2989,468 @@ describe('ClientRequestHandler - computeStakeFee', () => { }); }); +describe('ClientRequestHandler - getMaxSendableAmount', () => { + const TEST_TO_ADDRESS = 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb'; + const TEST_ACCOUNT_ADDRESS = 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx'; + const scope = Network.Mainnet; + + const createNativeAsset = (uiAmount: string): AssetEntity => + ({ + assetType: Networks[scope].nativeToken.id, + keyringAccountId: TEST_ACCOUNT_ID, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: trxToSun(uiAmount), + uiAmount, + iconUrl: '', + }) as unknown as AssetEntity; + + const createUsdtAsset = (uiAmount: string): AssetEntity => + ({ + assetType: 'tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + keyringAccountId: TEST_ACCOUNT_ID, + network: Network.Mainnet, + symbol: 'USDT', + decimals: 6, + rawAmount: trxToSun(uiAmount), + uiAmount, + iconUrl: '', + }) as unknown as AssetEntity; + + const createSendTransaction = (): + | TronwebTypes.Transaction + | TronwebTypes.Transaction + | TronwebTypes.Transaction => + ({ + txID: 'send-tx-id', + raw_data: { contract: [] }, + raw_data_hex: 'send-hex', + }) as unknown as + | TronwebTypes.Transaction + | TronwebTypes.Transaction + | TronwebTypes.Transaction; + + const createSendFees = (trxFee: string): ComputeFeeResult => [ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: Networks[scope].nativeToken.id, + amount: trxFee, + fungible: true as const, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: Networks[scope].bandwidth.id, + amount: '266', + fungible: true as const, + }, + }, + ]; + + const mockAccount = (mockAccountsService: { + findByIdOrThrow: jest.Mock; + }): void => { + mockAccountsService.findByIdOrThrow.mockResolvedValue({ + id: TEST_ACCOUNT_ID, + address: TEST_ACCOUNT_ADDRESS, + entropySource: 'test-entropy', + derivationPath: [], + } as unknown as TronKeyringAccount); + }; + + const mockAssets = ( + mockAssetsService: { getAccountAssetsByIDs: jest.Mock }, + asset: AssetEntity | null, + nativeToken: AssetEntity | null, + ): void => { + mockAssetsService.getAccountAssetsByIDs.mockResolvedValue([ + asset, + nativeToken, + { rawAmount: '5000' }, // Bandwidth + { rawAmount: '100000' }, // Energy + ] as unknown as AssetEntity[]); + }; + + it('returns the maximum TRX sendable for a known destination', async () => { + await withClientRequestHandler( + async ({ + handler, + mockAccountsService, + mockAssetsService, + mockSendService, + mockFeeCalculatorService, + }) => { + const request = { + jsonrpc: '2.0' as const, + id: '1', + method: ClientRequestMethod.GetMaxSendableAmount, + params: { + accountId: TEST_ACCOUNT_ID, + assetId: Networks[scope].nativeToken.id, + toAddress: TEST_TO_ADDRESS, + }, + }; + + const sentAsset = createNativeAsset('100'); + mockAccount(mockAccountsService); + mockAssets(mockAssetsService, sentAsset, createNativeAsset('100')); + + const builtTransaction = createSendTransaction(); + mockSendService.buildTransaction.mockResolvedValue(builtTransaction); + + const feeResult = createSendFees('0.3'); + mockFeeCalculatorService.computeFee.mockResolvedValue(feeResult); + + const result = await handler.handle(request as JsonRpcRequest); + + // The balances come from the same source as onAmountInput. + expect(mockAssetsService.getAccountAssetsByIDs).toHaveBeenCalledWith( + TEST_ACCOUNT_ID, + [ + Networks[scope].nativeToken.id, + Networks[scope].nativeToken.id, + Networks[scope].bandwidth.id, + Networks[scope].energy.id, + ], + ); + // The probe transaction is built with the full balance as the amount. + expect(mockSendService.buildTransaction).toHaveBeenCalledWith({ + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + asset: sentAsset, + amount: BigNumber('100'), + feeLimit: FEE_LIMIT, + }); + expect( + mockFeeCalculatorService.getAccountActivationReserve, + ).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ amount: '99.7' }); + }, + ); + }); + + it('reserves account activation when the destination is omitted', async () => { + await withClientRequestHandler( + async ({ + handler, + mockAccountsService, + mockAssetsService, + mockSendService, + mockFeeCalculatorService, + }) => { + const request = { + jsonrpc: '2.0' as const, + id: '2', + method: ClientRequestMethod.GetMaxSendableAmount, + params: { + accountId: TEST_ACCOUNT_ID, + assetId: Networks[scope].nativeToken.id, + }, + }; + + mockAccount(mockAccountsService); + mockAssets( + mockAssetsService, + createNativeAsset('100'), + createNativeAsset('100'), + ); + + mockSendService.buildTransaction.mockResolvedValue( + createSendTransaction(), + ); + mockFeeCalculatorService.computeFee.mockResolvedValue( + createSendFees('0.3'), + ); + mockFeeCalculatorService.getAccountActivationReserve.mockResolvedValue( + BigNumber('1.1'), + ); + + const result = await handler.handle(request as JsonRpcRequest); + + // The probe transaction is built to the sender's own address. + expect(mockSendService.buildTransaction).toHaveBeenCalledWith({ + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_ACCOUNT_ADDRESS, + asset: expect.objectContaining({ + assetType: Networks[scope].nativeToken.id, + }), + amount: BigNumber('100'), + feeLimit: FEE_LIMIT, + }); + expect( + mockFeeCalculatorService.getAccountActivationReserve, + ).toHaveBeenCalledWith(scope); + // amount = 100 - (0.3 + 1.1) + expect(result).toStrictEqual({ amount: '98.6' }); + }, + ); + }); + + it('returns 0 when the TRX balance cannot cover the fees', async () => { + await withClientRequestHandler( + async ({ + handler, + mockAccountsService, + mockAssetsService, + mockSendService, + mockFeeCalculatorService, + }) => { + const request = { + jsonrpc: '2.0' as const, + id: '3', + method: ClientRequestMethod.GetMaxSendableAmount, + params: { + accountId: TEST_ACCOUNT_ID, + assetId: Networks[scope].nativeToken.id, + toAddress: TEST_TO_ADDRESS, + }, + }; + + mockAccount(mockAccountsService); + mockAssets( + mockAssetsService, + createNativeAsset('0.1'), + createNativeAsset('0.1'), + ); + + mockSendService.buildTransaction.mockResolvedValue( + createSendTransaction(), + ); + mockFeeCalculatorService.computeFee.mockResolvedValue( + createSendFees('0.3'), + ); + + const result = await handler.handle(request as JsonRpcRequest); + + expect(result).toStrictEqual({ amount: '0' }); + }, + ); + }); + + it('returns 0 when the TRX balance is zero', async () => { + await withClientRequestHandler( + async ({ + handler, + mockAccountsService, + mockAssetsService, + mockSendService, + mockFeeCalculatorService, + }) => { + const request = { + jsonrpc: '2.0' as const, + id: '4', + method: ClientRequestMethod.GetMaxSendableAmount, + params: { + accountId: TEST_ACCOUNT_ID, + assetId: Networks[scope].nativeToken.id, + toAddress: TEST_TO_ADDRESS, + }, + }; + + mockAccount(mockAccountsService); + mockAssets( + mockAssetsService, + createNativeAsset('0'), + createNativeAsset('0'), + ); + + mockSendService.buildTransaction.mockResolvedValue( + createSendTransaction(), + ); + mockFeeCalculatorService.computeFee.mockResolvedValue( + createSendFees('0.3'), + ); + + const result = await handler.handle(request as JsonRpcRequest); + + expect(result).toStrictEqual({ amount: '0' }); + expect(mockSendService.buildTransaction).not.toHaveBeenCalled(); + expect(mockFeeCalculatorService.computeFee).not.toHaveBeenCalled(); + }, + ); + }); + + it('returns the full USDT balance when the account can cover the transfer fee', async () => { + await withClientRequestHandler( + async ({ + handler, + mockAccountsService, + mockAssetsService, + mockSendService, + mockFeeCalculatorService, + }) => { + const request = { + jsonrpc: '2.0' as const, + id: '5', + method: ClientRequestMethod.GetMaxSendableAmount, + params: { + accountId: TEST_ACCOUNT_ID, + assetId: 'tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + toAddress: TEST_TO_ADDRESS, + }, + }; + + const sentAsset = createUsdtAsset('50'); + mockAccount(mockAccountsService); + mockAssets(mockAssetsService, sentAsset, createNativeAsset('10')); + + mockSendService.buildTransaction.mockResolvedValue( + createSendTransaction(), + ); + const feeResult = createSendFees('0.3'); + mockFeeCalculatorService.computeFee.mockResolvedValue(feeResult); + + const result = await handler.handle(request as JsonRpcRequest); + + expect(mockSendService.buildTransaction).toHaveBeenCalledWith({ + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + asset: sentAsset, + amount: BigNumber('50'), + feeLimit: FEE_LIMIT, + }); + expect( + mockFeeCalculatorService.getAccountActivationReserve, + ).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ amount: '50' }); + }, + ); + }); + + it('returns 0 when TRX cannot cover the USDT transfer fee', async () => { + await withClientRequestHandler( + async ({ + handler, + mockAccountsService, + mockAssetsService, + mockSendService, + mockFeeCalculatorService, + }) => { + const request = { + jsonrpc: '2.0' as const, + id: '6', + method: ClientRequestMethod.GetMaxSendableAmount, + params: { + accountId: TEST_ACCOUNT_ID, + assetId: 'tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + toAddress: TEST_TO_ADDRESS, + }, + }; + + mockAccount(mockAccountsService); + mockAssets( + mockAssetsService, + createUsdtAsset('50'), + createNativeAsset('0.1'), + ); + + mockSendService.buildTransaction.mockResolvedValue( + createSendTransaction(), + ); + mockFeeCalculatorService.computeFee.mockResolvedValue( + createSendFees('0.3'), + ); + + const result = await handler.handle(request as JsonRpcRequest); + + expect(result).toStrictEqual({ amount: '0' }); + }, + ); + }); + + it('returns 0 when the USDT balance is zero', async () => { + await withClientRequestHandler( + async ({ + handler, + mockAccountsService, + mockAssetsService, + mockSendService, + mockFeeCalculatorService, + }) => { + const request = { + jsonrpc: '2.0' as const, + id: '7', + method: ClientRequestMethod.GetMaxSendableAmount, + params: { + accountId: TEST_ACCOUNT_ID, + assetId: 'tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + toAddress: TEST_TO_ADDRESS, + }, + }; + + mockAccount(mockAccountsService); + mockAssets( + mockAssetsService, + createUsdtAsset('0'), + createNativeAsset('10'), + ); + + const result = await handler.handle(request as JsonRpcRequest); + + expect(result).toStrictEqual({ amount: '0' }); + expect(mockSendService.buildTransaction).not.toHaveBeenCalled(); + expect(mockFeeCalculatorService.computeFee).not.toHaveBeenCalled(); + }, + ); + }); + + it('returns 0 when the account does not hold the asset', async () => { + await withClientRequestHandler( + async ({ + handler, + mockAccountsService, + mockAssetsService, + mockSendService, + mockFeeCalculatorService, + }) => { + const request = { + jsonrpc: '2.0' as const, + id: '8', + method: ClientRequestMethod.GetMaxSendableAmount, + params: { + accountId: TEST_ACCOUNT_ID, + assetId: Networks[scope].nativeToken.id, + toAddress: TEST_TO_ADDRESS, + }, + }; + + mockAccount(mockAccountsService); + mockAssets(mockAssetsService, null, null); + + const result = await handler.handle(request as JsonRpcRequest); + + expect(result).toStrictEqual({ amount: '0' }); + expect(mockSendService.buildTransaction).not.toHaveBeenCalled(); + expect(mockFeeCalculatorService.computeFee).not.toHaveBeenCalled(); + }, + ); + }); + + it('throws on an invalid destination address', async () => { + await withClientRequestHandler(async ({ handler }) => { + const request = { + jsonrpc: '2.0' as const, + id: '9', + method: ClientRequestMethod.GetMaxSendableAmount, + params: { + accountId: TEST_ACCOUNT_ID, + assetId: Networks[scope].nativeToken.id, + toAddress: 'not-a-tron-address', + }, + }; + + await expect(handler.handle(request as JsonRpcRequest)).rejects.toThrow( + InvalidParamsError, + ); + }); + }); +}); + describe('ClientRequestHandler - confirmSend validation', () => { const TEST_TO_ADDRESS = 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx'; const scope = Network.Mainnet; diff --git a/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts index d7cc90ecb..5d514faf8 100644 --- a/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts +++ b/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -57,6 +57,7 @@ import { ComputeFeeRequestStruct, ComputeFeeResponseStruct, ComputeStakeFeeRequestStruct, + GetMaxSendableAmountRequestStruct, OnAddressInputRequestStruct, OnAmountInputRequestStruct, OnConfirmSendRequestStruct, @@ -279,6 +280,8 @@ export class ClientRequestHandler { return this.#handleConfirmSend(request); case ClientRequestMethod.ComputeFee: return this.#handleComputeFee(request); + case ClientRequestMethod.GetMaxSendableAmount: + return this.#handleGetMaxSendableAmount(request); case ClientRequestMethod.ComputeStakeFee: return this.#handleComputeStakeFee(request); /** @@ -935,6 +938,134 @@ export class ClientRequestHandler { return result; } + /** + * Computes the maximum amount that can be sent for the given asset. + * + * It uses the same balance source, fee limit, and fee/reserve helpers as + * `onAmountInput`. For native TRX the maximum leaves enough TRX for the + * network fees and, when the destination is unknown, the account activation + * reserve. For TRC-20 the account must hold enough TRX for the transfer fee. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The maximum sendable amount, or 0 when no amount can pass. + * @throws {InvalidParamsError} If the params are invalid. + */ + async #handleGetMaxSendableAmount(request: JsonRpcRequest): Promise { + assertOrThrow( + request, + GetMaxSendableAmountRequestStruct, + new InvalidParamsError(), + ); + + const { accountId, assetId, toAddress } = request.params; + + const account = await this.#accountsService.findByIdOrThrow(accountId); + + const { chainId: scope } = parseTronCaipAssetType(assetId); + + const [asset, nativeTokenAsset, bandwidthAsset, energyAsset] = + await this.#assetsService.getAccountAssetsByIDs(accountId, [ + assetId, + Networks[scope].nativeToken.id, + Networks[scope].bandwidth.id, + Networks[scope].energy.id, + ]); + + if (!asset) { + return { amount: '0' }; + } + + const nativeTokenId = Networks[scope].nativeToken.id; + const isNativeToken = asset.assetType === nativeTokenId; + + const assetBalance = new BigNumber(asset.uiAmount); + const nativeTokenBalance = nativeTokenAsset + ? new BigNumber(nativeTokenAsset.uiAmount) + : ZERO; + const availableEnergy = energyAsset + ? new BigNumber(energyAsset.rawAmount) + : ZERO; + const availableBandwidth = bandwidthAsset + ? new BigNumber(bandwidthAsset.rawAmount) + : ZERO; + + const sentBalance = isNativeToken ? nativeTokenBalance : assetBalance; + if (sentBalance.isLessThanOrEqualTo(ZERO)) { + return { amount: '0' }; + } + + /** + * The send fees do not depend on the amount, so the transaction is built + * with the full balance as the amount: it yields the largest (most + * conservative) transaction size, and the fee quote stays valid for the + * maximum. The fee limit is the same one `confirmSend` uses. + */ + const transaction = await this.#sendService.buildTransaction({ + fromAccountId: account.id, + toAddress: toAddress ?? account.address, + asset, + amount: sentBalance, + feeLimit: FEE_LIMIT, + }); + + const quotedFees = await this.#feeCalculatorService.computeFee({ + scope, + transaction, + availableEnergy, + availableBandwidth, + feeLimit: FEE_LIMIT, + }); + + /** + * The destination is unknown: reserve the account activation cost on top + * of the quoted fees, so the maximum covers the worst case. TRC-20 + * transfers never activate an account, so no reserve is added for them. + */ + let fees = quotedFees; + if (!toAddress && isNativeToken) { + const activationReserve = + await this.#feeCalculatorService.getAccountActivationReserve(scope); + + fees = quotedFees.map((fee) => + fee.asset.type === nativeTokenId + ? { + ...fee, + asset: { + ...fee.asset, + amount: new BigNumber(fee.asset.amount) + .plus(activationReserve) + .toString(), + }, + } + : fee, + ); + } + + const trxFee = new BigNumber( + fees.find((fee) => fee.asset.type === nativeTokenId)?.asset.amount ?? '0', + ); + + if (isNativeToken) { + const maxAmount = nativeTokenBalance.minus(trxFee); + + if (maxAmount.isLessThanOrEqualTo(ZERO)) { + return { amount: '0' }; + } + + return { + amount: maxAmount + .decimalPlaces(asset.decimals, BigNumber.ROUND_DOWN) + .toFixed(), + }; + } + + if (trxFee.isGreaterThan(nativeTokenBalance)) { + return { amount: '0' }; + } + + return { amount: asset.uiAmount }; + } + /** * Handles the input of a stake amount. Checks if the user has enough of the asset * to do the stake. diff --git a/packages/tron-wallet-snap/src/handlers/clientRequest/types.ts b/packages/tron-wallet-snap/src/handlers/clientRequest/types.ts index 2dc96159c..1b62b78f0 100644 --- a/packages/tron-wallet-snap/src/handlers/clientRequest/types.ts +++ b/packages/tron-wallet-snap/src/handlers/clientRequest/types.ts @@ -5,6 +5,7 @@ export const ClientRequestMethod = { */ ConfirmSend: 'confirmSend', ComputeFee: 'computeFee', + GetMaxSendableAmount: 'getMaxSendableAmount', OnAddressInput: 'onAddressInput', OnAmountInput: 'onAmountInput', /** diff --git a/packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts b/packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts index f93ce48e5..cf919b9e2 100644 --- a/packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts +++ b/packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts @@ -220,6 +220,34 @@ export const ComputeStakeFeeRequestStruct = object({ params: ComputeStakeFeeRequestParamsStruct, }); +/** + * getMaxSendableAmount request validation. + */ +export const GetMaxSendableAmountRequestParamsStruct = object({ + accountId: UuidStruct, + assetId: CaipAssetTypeStruct, + toAddress: optional(TronAddressStruct), +}); + +export const GetMaxSendableAmountRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.GetMaxSendableAmount), + params: GetMaxSendableAmountRequestParamsStruct, +}); + +/** + * getMaxSendableAmount response validation: the maximum amount that makes + * onAmountInput pass, or 0 when no amount can pass. + */ +export const GetMaxSendableAmountResponseStruct = object({ + amount: string(), +}); + +export type GetMaxSendableAmountResponse = Infer< + typeof GetMaxSendableAmountResponseStruct +>; + export const OnUnstakeAmountInputRequestParamsStruct = object({ accountId: UuidStruct, assetId: NativeCaipAssetTypeStruct, From 18b0c83a4acf6a6a15a767c79eb002bc31eb3707 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Wed, 4 Nov 2026 19:34:56 +0000 Subject: [PATCH 3/3] refactor(snap-networks-utils): extract unified send flow --- packages/bitcoin-wallet-snap/CHANGELOG.md | 4 + packages/bitcoin-wallet-snap/jest.config.js | 8 +- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/HandlerMiddleware.test.ts | 16 ++ .../src/handlers/HandlerMiddleware.ts | 6 +- .../src/handlers/RpcHandler.ts | 120 +++++--- .../src/handlers/validation.ts | 17 +- packages/snap-networks-utils/CHANGELOG.md | 4 + packages/snap-networks-utils/src/index.ts | 28 ++ .../src/utils/sendFlow/router.test.ts | 260 ++++++++++++++++++ .../src/utils/sendFlow/router.ts | 132 +++++++++ .../src/utils/sendFlow/structs.test.ts | 164 +++++++++++ .../src/utils/sendFlow/structs.ts | 151 ++++++++++ .../src/utils/sendFlow/types.ts | 109 ++++++++ packages/solana-wallet-snap/CHANGELOG.md | 4 + packages/solana-wallet-snap/jest.config.js | 8 +- .../solana-wallet-snap/snap.manifest.json | 2 +- .../onClientRequest/ClientRequestHandler.ts | 115 ++++---- .../core/handlers/onClientRequest/types.ts | 10 +- .../onClientRequest/validation.test.ts | 36 +++ .../handlers/onClientRequest/validation.ts | 39 ++- .../src/core/services/send/types.ts | 8 +- .../src/core/validation/structs.ts | 10 +- packages/stellar-wallet-snap/CHANGELOG.md | 1 + packages/stellar-wallet-snap/jest.config.js | 8 +- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/clientRequest/api.ts | 96 ++++--- .../clientRequest/clientRequest.test.ts | 10 +- .../handlers/clientRequest/clientRequest.ts | 28 +- packages/tron-wallet-snap/CHANGELOG.md | 4 + packages/tron-wallet-snap/jest.config.js | 8 +- packages/tron-wallet-snap/snap.manifest.json | 2 +- .../handlers/clientRequest/clientRequest.ts | 129 +++++---- .../src/handlers/clientRequest/types.ts | 21 +- .../src/handlers/clientRequest/validation.ts | 18 +- .../src/validation/structs.ts | 10 +- 36 files changed, 1304 insertions(+), 286 deletions(-) create mode 100644 packages/snap-networks-utils/src/utils/sendFlow/router.test.ts create mode 100644 packages/snap-networks-utils/src/utils/sendFlow/router.ts create mode 100644 packages/snap-networks-utils/src/utils/sendFlow/structs.test.ts create mode 100644 packages/snap-networks-utils/src/utils/sendFlow/structs.ts create mode 100644 packages/snap-networks-utils/src/utils/sendFlow/types.ts diff --git a/packages/bitcoin-wallet-snap/CHANGELOG.md b/packages/bitcoin-wallet-snap/CHANGELOG.md index e5ce1441f..10cc1c619 100644 --- a/packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Use the shared unified send flow router, method names, error codes, and validation response type from `@metamask/snap-networks-utils` for `onAddressInput`, `onAmountInput`, `confirmSend`, and `computeFee` ([#556](https://github.com/MetaMask/internal-snaps/pull/556)) + ## [3.3.0] ### Changed diff --git a/packages/bitcoin-wallet-snap/jest.config.js b/packages/bitcoin-wallet-snap/jest.config.js index 7dc01e805..7fe570c5d 100644 --- a/packages/bitcoin-wallet-snap/jest.config.js +++ b/packages/bitcoin-wallet-snap/jest.config.js @@ -15,10 +15,10 @@ module.exports = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 77.33, - functions: 66.76, - lines: 84.81, - statements: 84.58, + branches: 78.49, + functions: 68.83, + lines: 85.49, + statements: 85.24, }, }, }; diff --git a/packages/bitcoin-wallet-snap/snap.manifest.json b/packages/bitcoin-wallet-snap/snap.manifest.json index 50ddc7a1c..4ca54d236 100644 --- a/packages/bitcoin-wallet-snap/snap.manifest.json +++ b/packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "gflKiopn6Pqmvn4qBx7ernV56ESlo6TUhNwwQJfCbOI=", + "shasum": "iG0ppVQP9bp12wraD+yPov6ofWJYGx0Q0K5Y3B5qNzM=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts b/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts index 76553a0b7..1b38f5280 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts @@ -1,4 +1,6 @@ +import { InvalidInputError } from '@metamask/snaps-sdk'; import type { GetPreferencesResult } from '@metamask/snaps-sdk'; +import { create, object, string } from '@metamask/superstruct'; import { mock } from 'jest-mock-extended'; import { BaseError, ExternalServiceError } from '../entities'; @@ -103,6 +105,20 @@ describe('HandlerMiddleware', () => { expect(mockTrackError).toHaveBeenCalledWith(thrown); }); + it('wraps a @metamask/superstruct StructError as an invalid input error', async () => { + let error: Error | undefined; + try { + create({}, object({ value: string() })); + } catch (caught) { + error = caught as Error; + } + + expect(error).toBeDefined(); + await expect( + middleware.handle(jest.fn().mockRejectedValue(error)), + ).rejects.toThrow(InvalidInputError); + }); + it('handles error successfully if instance of BaseError', async () => { const error = new BaseError('Test error', 1); const mockFn = jest.fn().mockRejectedValue(error); diff --git a/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts b/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts index 2b7a7c55e..7f2016c59 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts @@ -10,6 +10,7 @@ import { SnapError, getErrorMessage, } from '@metamask/snaps-sdk'; +import { StructError as MetamaskStructError } from '@metamask/superstruct'; import { StructError } from 'superstruct'; import type { Translator, Logger, SnapClient } from '../entities'; @@ -107,7 +108,10 @@ export class HandlerMiddleware { throw new InternalError(errMsg, error.data); } } else { - if (error instanceof StructError) { + if ( + error instanceof StructError || + error instanceof MetamaskStructError + ) { const errMsg = messages['error.0']?.message ?? 'Invalid format'; throw new InvalidInputError( `${errMsg}: ${error.message}`, diff --git a/packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index 6d7b8c2c1..1a74b7c0a 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -3,12 +3,22 @@ import { DEFAULT_METAMASK_ORIGIN, normalizeError, resolveFlowTransactionType, + SendFlowRequestRouter, + UuidStruct, + createSendFlowStructs, } from '@metamask/snap-networks-utils'; import type { ProofOfOwnershipBatchRequestItem, ProofOfOwnershipBatchResponse, } from '@metamask/snap-networks-utils'; import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; +import { + enums as enumsV3, + object as objectV3, + refine as refineV3, + string as stringV3, +} from '@metamask/superstruct'; +import { CaipAssetTypeStruct, parseCaipAssetType } from '@metamask/utils'; import type { CaipAssetType } from '@metamask/utils'; import { Verifier } from 'bip322-js'; import { @@ -39,18 +49,10 @@ import { scopeToNetwork } from './caip'; import type { TransactionFee } from './mappings'; import { mapPsbtToTransaction, mapToTransactionFees } from './mappings'; import { parsePsbt } from './parsers'; -import type { - ConfirmSendRequest, - OnAddressInputRequest, - OnAmountInputRequest, -} from './types'; import type { ValidationResponse } from './validation'; import { NO_ERRORS_RESPONSE, INVALID_RESPONSE, - ConfirmSendRequestStruct, - OnAddressInputRequestStruct, - OnAmountInputRequestStruct, RpcMethod, SendErrorCodes, validateAmount, @@ -139,6 +141,49 @@ function isSignProofOfOwnershipMessageBatchError( return Object.hasOwn(signedMessage, 'error'); } +const BitcoinCaipAssetTypeStruct = refineV3( + CaipAssetTypeStruct, + 'bitcoin-caip-asset-id', + (assetId) => parseCaipAssetType(assetId).chainId.startsWith('bip122:'), +); + +const bitcoinSendFlowStructs = createSendFlowStructs({ + // Bitcoin validates amounts and addresses inside the handlers so it can + // return an invalid response instead of an invalid params error. + address: stringV3(), + amount: stringV3(), + assetId: BitcoinCaipAssetTypeStruct, + overrides: { + OnAddressInputParams: objectV3({ + value: stringV3(), + accountId: UuidStruct, + }), + ComputeFeeParams: objectV3({ + accountId: stringV3(), + transaction: stringV3(), + scope: enumsV3(Object.values(BtcScope)), + }), + }, +}); + +type OnAddressInputParams = { value: string; accountId: string }; +type OnAmountInputParams = { + value: string; + accountId: string; + assetId: CaipAssetType; +}; +type ConfirmSendParams = { + fromAccountId: string; + toAddress: string; + amount: string; + assetId: CaipAssetType; +}; +type ComputeFeeParams = { + accountId: string; + transaction: string; + scope: BtcScope; +}; + export class RpcHandler { readonly #logger: Logger; @@ -146,6 +191,8 @@ export class RpcHandler { readonly #accountUseCases: AccountUseCases; + readonly #sendFlowRouter: SendFlowRequestRouter; + constructor( sendFlow: SendFlowUseCases, accounts: AccountUseCases, @@ -154,6 +201,22 @@ export class RpcHandler { this.#logger = logger; this.#sendFlowUseCases = sendFlow; this.#accountUseCases = accounts; + this.#sendFlowRouter = new SendFlowRequestRouter({ + structs: bitcoinSendFlowStructs, + // Preserve the existing Bitcoin behaviour of surfacing the raw request + // validation error with its format details. + createInvalidParamsError: (error): Error => error as Error, + handlers: { + onAddressInput: async (params): Promise => + this.#onAddressInput(params as OnAddressInputParams), + onAmountInput: async (params): Promise => + this.#onAmountInput(params as OnAmountInputParams), + confirmSend: async (params): Promise => + this.#confirmSend(params as ConfirmSendParams), + computeFee: async (params): Promise => + this.#computeFee(params as ComputeFeeParams), + }, + }); } async route(origin: string, request: JsonRpcRequest): Promise { @@ -162,6 +225,11 @@ export class RpcHandler { throw new FormatError('Missing params'); } + const sendFlowResponse = await this.#sendFlowRouter.handle(request); + if (sendFlowResponse !== undefined) { + return sendFlowResponse; + } + switch (method as RpcMethod) { case RpcMethod.StartSendTransactionFlow: { assert(params, CreateSendFormRequest); @@ -176,26 +244,6 @@ export class RpcHandler { params.options, ); } - case RpcMethod.ComputeFee: { - assert(params, ComputeFeeRequest); - return this.#computeFee( - params.accountId, - params.transaction, - params.scope, - ); - } - case RpcMethod.OnAddressInput: { - assert(params, OnAddressInputRequestStruct); - return this.#onAddressInput(params); - } - case RpcMethod.OnAmountInput: { - assert(params, OnAmountInputRequestStruct); - return this.#onAmountInput(params); - } - case RpcMethod.ConfirmSend: { - assert(params, ConfirmSendRequestStruct); - return await this.#confirmSend(params); - } case RpcMethod.VerifyMessage: { assert(params, VerifyMessageRequest); return this.#verifyMessage( @@ -299,11 +347,11 @@ export class RpcHandler { return { transactionId: txid.toString(), canBeMalleable }; } - async #computeFee( - accountId: string, - transaction: string, - scope: BtcScope, - ): Promise { + async #computeFee({ + accountId, + transaction, + scope, + }: ComputeFeeParams): Promise { const psbt = parsePsbt(transaction); const amount = await this.#accountUseCases.computeFee(accountId, psbt); @@ -311,7 +359,7 @@ export class RpcHandler { } async #onAddressInput( - request: OnAddressInputRequest, + request: OnAddressInputParams, ): Promise { const { value, accountId } = request; @@ -334,7 +382,7 @@ export class RpcHandler { } async #onAmountInput( - request: OnAmountInputRequest, + request: OnAmountInputParams, ): Promise { const { value, accountId } = request; @@ -380,7 +428,7 @@ export class RpcHandler { } } - async #confirmSend(request: ConfirmSendRequest): Promise { + async #confirmSend(request: ConfirmSendParams): Promise { try { const account = await this.#accountUseCases.get(request.fromAccountId); diff --git a/packages/bitcoin-wallet-snap/src/handlers/validation.ts b/packages/bitcoin-wallet-snap/src/handlers/validation.ts index 1f5bf9e1f..1c5e04535 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/validation.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/validation.ts @@ -3,6 +3,8 @@ import { Address, Amount } from '@metamask/bitcoindevkit'; import { BtcMethod } from '@metamask/keyring-api'; import { parseProofOfOwnershipMessage as parseSharedProofOfOwnershipMessage, + SendFlowErrorCodes, + SendFlowMethod, UuidStruct, } from '@metamask/snap-networks-utils'; import type { ProofOfOwnershipMessage } from '@metamask/snap-networks-utils'; @@ -28,13 +30,13 @@ import type { BitcoinAccount, CodifiedError, Logger } from '../entities'; import { ValidationError } from '../entities'; export const RpcMethod = { + OnAddressInput: SendFlowMethod.OnAddressInput, + OnAmountInput: SendFlowMethod.OnAmountInput, + ConfirmSend: SendFlowMethod.ConfirmSend, + ComputeFee: SendFlowMethod.ComputeFee, StartSendTransactionFlow: 'startSendTransactionFlow', SignAndSendTransaction: 'signAndSendTransaction', - ComputeFee: 'computeFee', VerifyMessage: 'verifyMessage', - OnAddressInput: 'onAddressInput', - OnAmountInput: 'onAmountInput', - ConfirmSend: 'confirmSend', SignRewardsMessage: 'signRewardsMessage', SignProofOfOwnership: 'signProofOfOwnership', /** @@ -46,12 +48,7 @@ export const RpcMethod = { export type RpcMethod = (typeof RpcMethod)[keyof typeof RpcMethod]; -export const SendErrorCodes = { - Required: 'Required', - Invalid: 'Invalid', - InsufficientBalance: 'InsufficientBalance', - InsufficientBalanceToCoverFee: 'InsufficientBalanceToCoverFee', -} as const; +export const SendErrorCodes = SendFlowErrorCodes; export type SendErrorCodes = (typeof SendErrorCodes)[keyof typeof SendErrorCodes]; diff --git a/packages/snap-networks-utils/CHANGELOG.md b/packages/snap-networks-utils/CHANGELOG.md index 6b8161360..91159c36c 100644 --- a/packages/snap-networks-utils/CHANGELOG.md +++ b/packages/snap-networks-utils/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add the shared unified send flow helpers: `SendFlowMethod`, `SendFlowErrorCodes`, the validation and max-sendable response structs, `createSendFlowStructs`, and `SendFlowRequestRouter`. The router owns method dispatch, request validation, and validation-response validation while each network snap supplies chain-specific CAIP-19 asset, address, amount, and transaction structs ([#556](https://github.com/MetaMask/internal-snaps/pull/556)) + ## [3.2.0] ### Added diff --git a/packages/snap-networks-utils/src/index.ts b/packages/snap-networks-utils/src/index.ts index d2e9ce157..2640d674c 100644 --- a/packages/snap-networks-utils/src/index.ts +++ b/packages/snap-networks-utils/src/index.ts @@ -227,3 +227,31 @@ export type { TrackErrorCapableProvider, TrackErrorFn, } from './utils/errors'; +export { + PositiveNumberStringStruct, + Base64Struct, + SendFlowValidationResponseStruct, + SendFlowGetMaxSendableAmountResponseStruct, + createSendFlowStructs, +} from './utils/sendFlow/structs'; +export type { + SendFlowStructs, + SendFlowParamStructOverrides, + CreateSendFlowStructsOptions, +} from './utils/sendFlow/structs'; +export { + SendFlowRequestRouter, + isSendFlowMethod, +} from './utils/sendFlow/router'; +export { SendFlowMethod, SendFlowErrorCodes } from './utils/sendFlow/types'; +export type { + SendFlowErrorCode, + SendFlowValidationResponse, + SendFlowGetMaxSendableAmountResponse, + SendFlowHandlers, + SendFlowOnAddressInputParams, + SendFlowOnAmountInputParams, + SendFlowGetMaxSendableAmountParams, + SendFlowConfirmSendParams, + SendFlowComputeFeeParams, +} from './utils/sendFlow/types'; diff --git a/packages/snap-networks-utils/src/utils/sendFlow/router.test.ts b/packages/snap-networks-utils/src/utils/sendFlow/router.test.ts new file mode 100644 index 000000000..60ee3ffe4 --- /dev/null +++ b/packages/snap-networks-utils/src/utils/sendFlow/router.test.ts @@ -0,0 +1,260 @@ +import type { JsonRpcRequest } from '@metamask/snaps-sdk'; +import { InvalidParamsError, MethodNotFoundError } from '@metamask/snaps-sdk'; +import { object, string } from '@metamask/superstruct'; + +import { SendFlowRequestRouter, isSendFlowMethod } from './router'; +import { createSendFlowStructs } from './structs'; +import { SendFlowErrorCodes, SendFlowMethod } from './types'; + +const ACCOUNT_ID = '0e6a1f4a-6f6c-4a1e-9f3d-1a2b3c4d5e6f'; +const ADDRESS = 'address'; +const ASSET_ID = 'tron:728126428/slip44:195'; + +const structs = createSendFlowStructs({ + address: string(), + overrides: { + ComputeFeeParams: object({ + transaction: string(), + accountId: string(), + scope: string(), + }), + OnAmountInputParams: object({ + accountId: string(), + assetId: string(), + value: string(), + }), + ConfirmSendParams: object({ + fromAccountId: string(), + toAddress: string(), + amount: string(), + assetId: string(), + }), + }, +}); + +function request(params: Record): JsonRpcRequest { + return { + jsonrpc: '2.0', + id: 1, + method: SendFlowMethod.OnAmountInput, + params, + } as JsonRpcRequest; +} + +describe('SendFlowRequestRouter', () => { + describe('isSendFlowMethod', () => { + it.each(Object.values(SendFlowMethod))('accepts %s', (method) => { + expect(isSendFlowMethod(method)).toBe(true); + }); + + it('rejects other methods', () => { + expect(isSendFlowMethod('signAndSendTransaction')).toBe(false); + }); + }); + + describe('handle', () => { + it('returns undefined for methods outside the unified send flow', async () => { + const router = new SendFlowRequestRouter({ + structs, + handlers: {}, + }); + + expect( + await router.handle({ + ...request({}), + method: 'signAndSendTransaction', + }), + ).toBeUndefined(); + }); + + it('dispatches onAddressInput with validated params', async () => { + const handler = jest.fn().mockResolvedValue({ + valid: true, + errors: [], + }); + const router = new SendFlowRequestRouter({ + structs, + handlers: { onAddressInput: handler }, + }); + + const result = await router.handle({ + ...request({ value: ADDRESS }), + method: SendFlowMethod.OnAddressInput, + }); + + expect(result).toStrictEqual({ valid: true, errors: [] }); + expect(handler).toHaveBeenCalledWith( + { value: ADDRESS }, + expect.objectContaining({ method: SendFlowMethod.OnAddressInput }), + ); + }); + + it('dispatches onAmountInput and validates its response', async () => { + const handler = jest.fn().mockResolvedValue({ + valid: false, + errors: [{ code: SendFlowErrorCodes.InsufficientBalance }], + }); + const router = new SendFlowRequestRouter({ + structs, + handlers: { onAmountInput: handler }, + }); + + expect( + await router.handle( + request({ + accountId: ACCOUNT_ID, + assetId: ASSET_ID, + value: '1', + }), + ), + ).toStrictEqual({ + valid: false, + errors: [{ code: SendFlowErrorCodes.InsufficientBalance }], + }); + expect(handler).toHaveBeenCalledWith( + { accountId: ACCOUNT_ID, assetId: ASSET_ID, value: '1' }, + expect.any(Object), + ); + }); + + it('rejects invalid responses', async () => { + const router = new SendFlowRequestRouter({ + structs, + handlers: { + onAmountInput: jest.fn().mockResolvedValue({ valid: 'yes' }), + }, + }); + + await expect( + router.handle( + request({ + accountId: ACCOUNT_ID, + assetId: ASSET_ID, + value: '1', + }), + ), + ).rejects.toThrow('Expected'); + }); + + it('dispatches getMaxSendableAmount and validates its response', async () => { + const handler = jest.fn().mockResolvedValue({ amount: '1.5' }); + const router = new SendFlowRequestRouter({ + structs, + handlers: { getMaxSendableAmount: handler }, + }); + + expect( + await router.handle({ + ...request({ accountId: ACCOUNT_ID, assetId: ASSET_ID }), + method: SendFlowMethod.GetMaxSendableAmount, + }), + ).toStrictEqual({ amount: '1.5' }); + }); + + it('dispatches confirmSend and computeFee without reshaping them', async () => { + const confirmSend = jest.fn().mockResolvedValue({ transactionId: 'tx' }); + const computeFee = jest.fn().mockResolvedValue([]); + const router = new SendFlowRequestRouter({ + structs, + handlers: { confirmSend, computeFee }, + }); + + expect( + await router.handle({ + ...request({ + fromAccountId: ACCOUNT_ID, + toAddress: ADDRESS, + amount: '1', + assetId: ASSET_ID, + }), + method: SendFlowMethod.ConfirmSend, + }), + ).toStrictEqual({ transactionId: 'tx' }); + + expect( + await router.handle({ + ...request({ + transaction: 'aGVsbG8=', + accountId: ACCOUNT_ID, + scope: 'tron:728126428', + }), + method: SendFlowMethod.ComputeFee, + }), + ).toStrictEqual([]); + }); + + it('returns null when confirmSend or computeFee handlers return nothing', async () => { + const router = new SendFlowRequestRouter({ + structs, + handlers: { + confirmSend: jest.fn().mockResolvedValue(undefined), + computeFee: jest.fn().mockResolvedValue(undefined), + }, + }); + + expect( + await router.handle({ + ...request({ + fromAccountId: ACCOUNT_ID, + toAddress: ADDRESS, + amount: '1', + assetId: ASSET_ID, + }), + method: SendFlowMethod.ConfirmSend, + }), + ).toBeNull(); + + expect( + await router.handle({ + ...request({ + transaction: 'aGVsbG8=', + accountId: ACCOUNT_ID, + scope: 'tron:728126428', + }), + method: SendFlowMethod.ComputeFee, + }), + ).toBeNull(); + }); + + it('throws MethodNotFoundError for unsupported send flow methods', async () => { + const router = new SendFlowRequestRouter({ + structs, + handlers: {}, + }); + + await expect( + router.handle( + request({ + accountId: ACCOUNT_ID, + assetId: ASSET_ID, + value: '1', + }), + ), + ).rejects.toThrow(MethodNotFoundError); + }); + + it('uses the provided invalid params error factory', async () => { + const customError = new Error('custom validation error'); + const router = new SendFlowRequestRouter({ + structs, + handlers: { onAmountInput: jest.fn() }, + createInvalidParamsError: (): Error => customError, + }); + + await expect( + router.handle(request({ accountId: ACCOUNT_ID })), + ).rejects.toBe(customError); + }); + + it('throws InvalidParamsError for invalid params', async () => { + const router = new SendFlowRequestRouter({ + structs, + handlers: { onAmountInput: jest.fn() }, + }); + + await expect( + router.handle(request({ accountId: ACCOUNT_ID })), + ).rejects.toThrow(InvalidParamsError); + }); + }); +}); diff --git a/packages/snap-networks-utils/src/utils/sendFlow/router.ts b/packages/snap-networks-utils/src/utils/sendFlow/router.ts new file mode 100644 index 000000000..d45bf15a9 --- /dev/null +++ b/packages/snap-networks-utils/src/utils/sendFlow/router.ts @@ -0,0 +1,132 @@ +import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; +import { InvalidParamsError, MethodNotFoundError } from '@metamask/snaps-sdk'; +import { assert, create } from '@metamask/superstruct'; + +import type { SendFlowStructs } from './structs'; +import { + SendFlowGetMaxSendableAmountResponseStruct, + SendFlowValidationResponseStruct, +} from './structs'; +import type { SendFlowHandlers, SendFlowValidationResponse } from './types'; +import { SendFlowMethod } from './types'; + +const STRUCT_BY_METHOD: Record = { + [SendFlowMethod.OnAddressInput]: 'OnAddressInputParams', + [SendFlowMethod.OnAmountInput]: 'OnAmountInputParams', + [SendFlowMethod.GetMaxSendableAmount]: 'GetMaxSendableAmountParams', + [SendFlowMethod.ConfirmSend]: 'ConfirmSendParams', + [SendFlowMethod.ComputeFee]: 'ComputeFeeParams', +}; + +const SEND_FLOW_METHODS = new Set(Object.values(SendFlowMethod)); + +function createDefaultInvalidParamsError(error: unknown): Error { + const errorToThrow = new InvalidParamsError() as Error; + errorToThrow.cause = error; + return errorToThrow; +} + +type SendFlowResponseProcessor = (response: Json) => Json; + +const PROCESS_RESPONSE_BY_METHOD: Record< + SendFlowMethod, + SendFlowResponseProcessor +> = { + [SendFlowMethod.OnAddressInput]: validateValidationResponse, + [SendFlowMethod.OnAmountInput]: validateValidationResponse, + [SendFlowMethod.GetMaxSendableAmount]: (response) => { + assert(response, SendFlowGetMaxSendableAmountResponseStruct); + return response; + }, + [SendFlowMethod.ConfirmSend]: (response) => response ?? null, + [SendFlowMethod.ComputeFee]: (response) => response ?? null, +}; + +/** + * Checks whether a JSON-RPC method belongs to the unified send flow. + * + * @param method - The JSON-RPC method. + * @returns Whether the method is a unified send flow method. + */ +export function isSendFlowMethod(method: string): method is SendFlowMethod { + return SEND_FLOW_METHODS.has(method); +} + +function validateValidationResponse( + response: Json, +): SendFlowValidationResponse { + assert(response, SendFlowValidationResponseStruct); + + return response; +} + +/** + * Routes unified send flow JSON-RPC requests to the chain-specific handlers. + * + * The router owns the shared method dispatch, request validation, and response + * validation. Each snap supplies only the methods it supports through + * {@link SendFlowHandlers}. + */ +export class SendFlowRequestRouter { + readonly #structs: SendFlowStructs; + + readonly #handlers: SendFlowHandlers; + + readonly #createInvalidParamsError: (error: unknown) => Error; + + constructor({ + structs, + handlers, + createInvalidParamsError = createDefaultInvalidParamsError, + }: { + structs: SendFlowStructs; + handlers: SendFlowHandlers; + /** + * Converts a request validation error into the error thrown to the caller. + * Snaps that surface the raw validation error (for example, Bitcoin) can + * rethrow it instead of wrapping it in `InvalidParamsError`. + */ + createInvalidParamsError?: (error: unknown) => Error; + }) { + this.#structs = structs; + this.#handlers = handlers; + this.#createInvalidParamsError = createInvalidParamsError; + } + + /** + * Handles a unified send flow request. + * + * @param request - The JSON-RPC request. + * @returns The response, or `undefined` when the method is not part of the + * unified send flow. + * @throws {MethodNotFoundError} If the method is a unified send flow method + * that the snap does not implement. + * @throws {InvalidParamsError} If the request params are invalid. + */ + async handle(request: JsonRpcRequest): Promise { + if (!isSendFlowMethod(request.method)) { + return undefined; + } + + const { method } = request; + const handler = this.#handlers[method]; + if (!handler) { + throw new MethodNotFoundError() as Error; + } + + const params = this.#validateParams(method, request.params); + const response = await ( + handler as (params: unknown, request: JsonRpcRequest) => Promise + )(params, request); + + return PROCESS_RESPONSE_BY_METHOD[method](response); + } + + #validateParams(method: SendFlowMethod, params: unknown): unknown { + try { + return create(params, this.#structs[STRUCT_BY_METHOD[method]]); + } catch (error) { + throw this.#createInvalidParamsError(error); + } + } +} diff --git a/packages/snap-networks-utils/src/utils/sendFlow/structs.test.ts b/packages/snap-networks-utils/src/utils/sendFlow/structs.test.ts new file mode 100644 index 000000000..bfd61d5ff --- /dev/null +++ b/packages/snap-networks-utils/src/utils/sendFlow/structs.test.ts @@ -0,0 +1,164 @@ +import { create, object, refine, string } from '@metamask/superstruct'; +import { CaipAssetTypeStruct, parseCaipAssetType } from '@metamask/utils'; +import type { CaipAssetType } from '@metamask/utils'; + +import { + Base64Struct, + PositiveNumberStringStruct, + createSendFlowStructs, +} from './structs'; + +const ACCOUNT_ID = '0e6a1f4a-6f6c-4a1e-9f3d-1a2b3c4d5e6f'; +const ADDRESS = 'address'; +const ASSET_ID = 'tron:728126428/slip44:195'; + +const localAssetId = refine( + CaipAssetTypeStruct, + 'local-asset', + (assetId) => parseCaipAssetType(assetId).chainId === 'tron:728126428', +); + +const structs = createSendFlowStructs({ + assetId: localAssetId, + address: string(), +}); + +describe('sendFlowStructs', () => { + describe('PositiveNumberStringStruct', () => { + it.each(['0', '1', '1.5', '100.0001'])('accepts %s', (value) => { + expect(() => create(value, PositiveNumberStringStruct)).not.toThrow(); + }); + + it.each(['', '-1', '01', '1.', '.5', 'abc'])('rejects %s', (value) => { + expect(() => create(value, PositiveNumberStringStruct)).toThrow( + 'Expected', + ); + }); + }); + + describe('Base64Struct', () => { + it.each(['aGVsbG8=', 'aGVsbG8gd29ybGQ='])('accepts %s', (value) => { + expect(() => create(value, Base64Struct)).not.toThrow(); + }); + + it.each(['not base64!', 'aGVsbG8'])('rejects %s', (value) => { + expect(() => create(value, Base64Struct)).toThrow('Expected'); + }); + }); + + describe('createSendFlowStructs', () => { + it('validates the shared onAddressInput params', () => { + expect( + create({ value: ADDRESS }, structs.OnAddressInputParams), + ).toStrictEqual({ value: ADDRESS }); + expect(() => create({ value: 1 }, structs.OnAddressInputParams)).toThrow( + 'Expected', + ); + }); + + it('validates the shared onAmountInput params', () => { + expect( + create( + { accountId: ACCOUNT_ID, assetId: ASSET_ID, value: '1.5' }, + structs.OnAmountInputParams, + ), + ).toStrictEqual({ + accountId: ACCOUNT_ID, + assetId: ASSET_ID, + value: '1.5', + }); + }); + + it('validates the shared getMaxSendableAmount params', () => { + expect( + create( + { accountId: ACCOUNT_ID, assetId: ASSET_ID }, + structs.GetMaxSendableAmountParams, + ), + ).toStrictEqual({ accountId: ACCOUNT_ID, assetId: ASSET_ID }); + }); + + it('validates the shared confirmSend params', () => { + expect( + create( + { + fromAccountId: ACCOUNT_ID, + toAddress: ADDRESS, + amount: '1.5', + assetId: ASSET_ID, + }, + structs.ConfirmSendParams, + ), + ).toStrictEqual({ + fromAccountId: ACCOUNT_ID, + toAddress: ADDRESS, + amount: '1.5', + assetId: ASSET_ID, + }); + }); + + it('validates the shared computeFee params', () => { + expect( + create( + { + transaction: 'aGVsbG8=', + accountId: ACCOUNT_ID, + scope: 'tron:728126428', + options: { type: 'TransferContract' }, + }, + structs.ComputeFeeParams, + ), + ).toStrictEqual({ + transaction: 'aGVsbG8=', + accountId: ACCOUNT_ID, + scope: 'tron:728126428', + options: { type: 'TransferContract' }, + }); + }); + + it('rejects CAIP asset IDs from another chain', () => { + expect(() => + create( + { + accountId: ACCOUNT_ID, + assetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501' as CaipAssetType, + value: '1', + }, + structs.OnAmountInputParams, + ), + ).toThrow('Expected'); + }); + + it('applies per-method overrides', () => { + const overridden = createSendFlowStructs({ + address: string(), + overrides: { + OnAmountInputParams: object({ + accountId: string(), + assetId: string(), + value: string(), + to: string(), + }), + }, + }); + + expect( + create( + { + accountId: ACCOUNT_ID, + assetId: ASSET_ID, + value: '1', + to: ADDRESS, + }, + overridden.OnAmountInputParams, + ), + ).toStrictEqual({ + accountId: ACCOUNT_ID, + assetId: ASSET_ID, + value: '1', + to: ADDRESS, + }); + }); + }); +}); diff --git a/packages/snap-networks-utils/src/utils/sendFlow/structs.ts b/packages/snap-networks-utils/src/utils/sendFlow/structs.ts new file mode 100644 index 000000000..2c2dff384 --- /dev/null +++ b/packages/snap-networks-utils/src/utils/sendFlow/structs.ts @@ -0,0 +1,151 @@ +import type { Struct } from '@metamask/superstruct'; +import { + array, + boolean, + enums, + object, + optional, + pattern, + record, + string, + unknown, +} from '@metamask/superstruct'; +import { CaipAssetTypeStruct } from '@metamask/utils'; + +import { UuidStruct } from '../uuidStruct/uuidStruct'; +import { SendFlowErrorCodes } from './types'; + +/** + * A positive decimal string, as used by the unified send flow amounts. + */ +export const PositiveNumberStringStruct = pattern( + string(), + /^(?!0\d)(\d+(\.\d+)?)$/u, +); + +/** + * A Base64-encoded string, as used by the unified send flow transactions. + */ +export const Base64Struct = pattern( + string(), + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)$/u, +); + +/** + * Validation response shared by `onAddressInput` and `onAmountInput`. + */ +export const SendFlowValidationResponseStruct = object({ + valid: boolean(), + errors: array( + object({ + code: enums(Object.values(SendFlowErrorCodes)), + }), + ), +}); + +/** + * Response shared by `getMaxSendableAmount`. + */ +export const SendFlowGetMaxSendableAmountResponseStruct = object({ + amount: PositiveNumberStringStruct, +}); + +/** + * Params structs for the unified send flow methods. + * + * `any` is used because superstruct structs are contravariant in their output + * type, and the concrete params type differs per chain. The concrete types are + * applied by the shared handler signatures. + */ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/naming-convention */ +export type SendFlowStructs = { + OnAddressInputParams: Struct; + OnAmountInputParams: Struct; + GetMaxSendableAmountParams: Struct; + ConfirmSendParams: Struct; + ComputeFeeParams: Struct; +}; +/* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/naming-convention */ + +export type SendFlowParamStructOverrides = Partial; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export type CreateSendFlowStructsOptions = { + /** + * CAIP-19 asset struct for the local chain. Snaps should refine + * `CaipAssetTypeStruct` so asset IDs from other chains are rejected. + */ + assetId?: Struct; + /** + * Address struct for the local chain. + */ + address: Struct; + /** + * Amount struct for the local chain. Defaults to a positive decimal string. + */ + amount?: Struct; + /** + * Transaction struct for `computeFee`. Defaults to a Base64 string. + */ + transaction?: Struct; + /** + * Overrides for the params of a specific method. Use this for fields that + * differ per chain, such as `to` vs `toAddress` or a required `options`. + */ + overrides?: SendFlowParamStructOverrides; +}; +/* eslint-enable @typescript-eslint/no-explicit-any */ + +/** + * Builds the params structs shared by the unified send flow methods. + * + * Chain-specific CAIP asset, address, amount, and transaction validation is + * supplied by the caller so each snap only overrides the parts that differ. + * + * @param options - Chain-specific structs and optional per-method overrides. + * @returns The params structs for every unified send flow method. + */ +export function createSendFlowStructs( + options: CreateSendFlowStructsOptions, +): SendFlowStructs { + const assetId = options.assetId ?? CaipAssetTypeStruct; + const amount = options.amount ?? PositiveNumberStringStruct; + const transaction = options.transaction ?? Base64Struct; + const { address, overrides = {} } = options; + + return { + OnAddressInputParams: + overrides.OnAddressInputParams ?? object({ value: address }), + OnAmountInputParams: + overrides.OnAmountInputParams ?? + object({ + accountId: UuidStruct, + assetId, + value: amount, + toAddress: optional(address), + }), + GetMaxSendableAmountParams: + overrides.GetMaxSendableAmountParams ?? + object({ + accountId: UuidStruct, + assetId, + toAddress: optional(address), + }), + ConfirmSendParams: + overrides.ConfirmSendParams ?? + object({ + fromAccountId: UuidStruct, + toAddress: address, + amount, + assetId, + }), + ComputeFeeParams: + overrides.ComputeFeeParams ?? + object({ + transaction, + accountId: UuidStruct, + scope: string(), + options: optional(record(string(), unknown())), + }), + }; +} diff --git a/packages/snap-networks-utils/src/utils/sendFlow/types.ts b/packages/snap-networks-utils/src/utils/sendFlow/types.ts new file mode 100644 index 000000000..7572da885 --- /dev/null +++ b/packages/snap-networks-utils/src/utils/sendFlow/types.ts @@ -0,0 +1,109 @@ +import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; +import type { CaipAssetType } from '@metamask/utils'; + +/** + * Unified send flow JSON-RPC methods shared by the network snaps. + */ +export const SendFlowMethod = { + OnAddressInput: 'onAddressInput', + OnAmountInput: 'onAmountInput', + GetMaxSendableAmount: 'getMaxSendableAmount', + ConfirmSend: 'confirmSend', + ComputeFee: 'computeFee', +} as const; + +export type SendFlowMethod = + (typeof SendFlowMethod)[keyof typeof SendFlowMethod]; + +/** + * Error codes returned by the unified send flow validation methods. + */ +export const SendFlowErrorCodes = { + Required: 'Required', + Invalid: 'Invalid', + InsufficientBalance: 'InsufficientBalance', + InsufficientBalanceToCoverFee: 'InsufficientBalanceToCoverFee', +} as const; + +export type SendFlowErrorCode = + (typeof SendFlowErrorCodes)[keyof typeof SendFlowErrorCodes]; + +/** + * Response returned by `onAddressInput` and `onAmountInput`. + */ +export type SendFlowValidationResponse = { + valid: boolean; + errors: { code: SendFlowErrorCode }[]; +}; + +/** + * Response returned by `getMaxSendableAmount`. + */ +export type SendFlowGetMaxSendableAmountResponse = { + amount: string; +}; + +export type SendFlowOnAddressInputParams = { + value: string; + [key: string]: unknown; +}; + +export type SendFlowOnAmountInputParams = { + accountId: string; + assetId: CaipAssetType; + value: string; + toAddress?: string; + [key: string]: unknown; +}; + +export type SendFlowGetMaxSendableAmountParams = { + accountId: string; + assetId: CaipAssetType; + toAddress?: string; + [key: string]: unknown; +}; + +export type SendFlowConfirmSendParams = { + fromAccountId: string; + toAddress: string; + amount: string; + assetId: CaipAssetType; + [key: string]: unknown; +}; + +export type SendFlowComputeFeeParams = { + transaction: string; + accountId: string; + scope: string; + options?: Record; + [key: string]: unknown; +}; + +/** + * Chain-specific implementations of the unified send flow methods. + * + * Snaps implement only the methods they support. The shared + * {@link SendFlowRequestRouter} validates the request and dispatches to them. + */ +export type SendFlowHandlers = { + onAddressInput?: ( + params: SendFlowOnAddressInputParams, + request: JsonRpcRequest, + ) => Promise; + onAmountInput?: ( + params: SendFlowOnAmountInputParams, + request: JsonRpcRequest, + ) => Promise; + getMaxSendableAmount?: ( + params: SendFlowGetMaxSendableAmountParams, + request: JsonRpcRequest, + ) => Promise; + confirmSend?: ( + params: SendFlowConfirmSendParams, + request: JsonRpcRequest, + ) => Promise; + computeFee?: ( + params: SendFlowComputeFeeParams, + request: JsonRpcRequest, + ) => Promise; +}; diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index f1024790b..ec73bfac6 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Use the shared unified send flow router, method names, request structs, error codes, and validation response type from `@metamask/snap-networks-utils`. Unified send flow asset IDs must now belong to a supported Solana chain ([#556](https://github.com/MetaMask/internal-snaps/pull/556)) + ## [7.2.0] ### Added diff --git a/packages/solana-wallet-snap/jest.config.js b/packages/solana-wallet-snap/jest.config.js index 15a19457e..6b6e43043 100644 --- a/packages/solana-wallet-snap/jest.config.js +++ b/packages/solana-wallet-snap/jest.config.js @@ -27,10 +27,10 @@ module.exports = { coverageThreshold: { global: { - branches: 73.19, - functions: 81.19, - lines: 88.57, - statements: 88.76, + branches: 73.1, + functions: 81.1, + lines: 88.5, + statements: 88.7, }, }, }; diff --git a/packages/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index 5d37b1d2b..bb80df9d6 100644 --- a/packages/solana-wallet-snap/snap.manifest.json +++ b/packages/solana-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "u5l2hwY3TtLBWkx6EttvbLj1AWcsf4/+Ydiwkmc63cM=", + "shasum": "Vvfrd1xp2MLimROdmXwTyLpL/IFt7PzSIB8zuMWH6Is=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts index 823b934cd..687a3a7cb 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts @@ -2,11 +2,13 @@ import { FeeType, TransactionType } from '@metamask/keyring-api'; import { DEFAULT_METAMASK_ORIGIN, normalizeError, + SendFlowRequestRouter, } from '@metamask/snap-networks-utils'; import type { Logger } from '@metamask/snap-networks-utils'; import { InvalidParamsError, MethodNotFoundError } from '@metamask/snaps-sdk'; import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; -import { assert, create } from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import { assert } from '@metamask/superstruct'; import { bytesToHex } from '@metamask/utils'; import { address as asAddress, @@ -22,7 +24,10 @@ import { FeeCalculator } from '../../fees'; import { fromTransactionToBase64String } from '../../sdk-extensions/codecs'; import type { AccountsService, ApproveTokenService } from '../../services'; import type { SendService } from '../../services/send/SendService'; -import type { OnAddressInputRequest } from '../../services/send/types'; +import type { + OnAmountInputRequest, + OnConfirmSendRequest, +} from '../../services/send/types'; import type { SolanaSignMessageBatchRequest, SolanaSignMessageBatchResult, @@ -51,6 +56,7 @@ import { SignProofOfOwnershipResponseStruct, SignRewardsMessageRequestStruct, ValidationResponseStruct, + sendFlowStructs, } from './validation'; import type { ComputeFeeResponse, @@ -71,6 +77,11 @@ function isSignMessageBatchError( return Object.hasOwn(signedMessage, 'error'); } +type OnAddressInputParams = Infer['params']; +type OnAmountInputParams = Infer['params']; +type OnConfirmSendParams = Infer['params']; +type ComputeFeeParams = Infer['params']; + export class ClientRequestHandler { readonly #accountsService: AccountsService; @@ -82,6 +93,8 @@ export class ClientRequestHandler { readonly #approveTokenService: ApproveTokenService; + readonly #sendFlowRouter: SendFlowRequestRouter; + constructor( accountsService: AccountsService, walletService: WalletService, @@ -94,6 +107,19 @@ export class ClientRequestHandler { this.#logger = logger.withPrefix('[👋 ClientRequestHandler]'); this.#sendService = sendService; this.#approveTokenService = approveTokenService; + this.#sendFlowRouter = new SendFlowRequestRouter({ + structs: sendFlowStructs, + handlers: { + onAddressInput: async (params): Promise => + this.#handleOnAddressInput(params as OnAddressInputParams), + onAmountInput: async (params, request): Promise => + this.#handleOnAmountInput(params as OnAmountInputParams, request), + confirmSend: async (params, request): Promise => + this.#handleConfirmSend(params as OnConfirmSendParams, request), + computeFee: async (params): Promise => + this.#handleComputeFee(params as ComputeFeeParams), + }, + }); } /** @@ -108,6 +134,11 @@ export class ClientRequestHandler { async handle(request: JsonRpcRequest): Promise { this.#logger.log('Handling client request', request); + const sendFlowResponse = await this.#sendFlowRouter.handle(request); + if (sendFlowResponse !== undefined) { + return sendFlowResponse; + } + const { method } = request; switch (method) { @@ -115,16 +146,8 @@ export class ClientRequestHandler { case ClientRequestMethod.SignAndSendTransactionWithoutConfirmation: return this.#handleSignAndSendWithoutConfirmation(request); - case ClientRequestMethod.ConfirmSend: - return this.#handleConfirmSend(request); case ClientRequestMethod.SignAndSendTransaction: return this.#handleSignAndSendTransaction(request); - case ClientRequestMethod.ComputeFee: - return this.#handleComputeFee(request); - case ClientRequestMethod.OnAddressInput: - return this.#handleOnAddressInput(request); - case ClientRequestMethod.OnAmountInput: - return this.#handleOnAmountInput(request); case ClientRequestMethod.SignRewardsMessage: return this.#handleSignRewardsMessage(request); case ClientRequestMethod.SignCardMessage: @@ -225,42 +248,27 @@ export class ClientRequestHandler { /** * Handles the confirmation of a send transaction. * + * @param _params - The validated send params. * @param request - The JSON-RPC request containing the method and parameters. * @returns The response to the JSON-RPC request. * @throws {InvalidParamsError} If the params are invalid. */ - async #handleConfirmSend(request: JsonRpcRequest): Promise { - try { - assert(request, OnConfirmSendRequestStruct); - } catch (error) { - const errorToThrow = new InvalidParamsError() as Error; - errorToThrow.cause = error; - throw errorToThrow; - } - const result = await this.#sendService.confirmSend(request); - - return result; + async #handleConfirmSend( + _params: OnConfirmSendParams, + request: JsonRpcRequest, + ): Promise { + return await this.#sendService.confirmSend(request as OnConfirmSendRequest); } /** * Handles the computation of a fee for a transaction. * - * @param request - The JSON-RPC request containing the method and parameters. + * @param params - The validated compute fee params. * @returns The response to the JSON-RPC request. * @throws {InvalidParamsError} If the params are invalid. */ - async #handleComputeFee(request: JsonRpcRequest): Promise { - try { - assert(request, ComputeFeeRequestStruct); - } catch (error) { - const errorToThrow = new InvalidParamsError() as Error; - errorToThrow.cause = error; - throw errorToThrow; - } - - const { - params: { transaction, scope }, - } = request; + async #handleComputeFee(params: ComputeFeeParams): Promise { + const { transaction, scope } = params; const { baseFee, priorityFee } = FeeCalculator.calculateFee(transaction); @@ -296,24 +304,15 @@ export class ClientRequestHandler { /** * Handles the input of an address. * - * @param request - The JSON-RPC request containing the method and parameters. + * @param params - The validated address params. * @returns The response to the JSON-RPC request. * @throws {InvalidParamsError} If the params are invalid. */ - async #handleOnAddressInput(request: JsonRpcRequest): Promise { - let parsed: OnAddressInputRequest; - try { - parsed = create(request, OnAddressInputRequestStruct); - } catch (error) { - const errorToThrow = new InvalidParamsError() as Error; - errorToThrow.cause = error; - throw errorToThrow; - } - - const { - params: { value, scope }, - } = parsed; - const result = await this.#sendService.onAddressInput(value, scope); + async #handleOnAddressInput(params: OnAddressInputParams): Promise { + const result = await this.#sendService.onAddressInput( + params.value, + params.scope, + ); assert(result, ValidationResponseStruct); @@ -323,20 +322,18 @@ export class ClientRequestHandler { /** * Handles the input of an amount. * + * @param _params - The validated amount params. * @param request - The JSON-RPC request containing the method and parameters. * @returns The response to the JSON-RPC request. * @throws {InvalidParamsError} If the params are invalid. */ - async #handleOnAmountInput(request: JsonRpcRequest): Promise { - try { - assert(request, OnAmountInputRequestStruct); - } catch (error) { - const errorToThrow = new InvalidParamsError() as Error; - errorToThrow.cause = error; - throw errorToThrow; - } - - const result = await this.#sendService.onAmountInput(request); + async #handleOnAmountInput( + _params: OnAmountInputParams, + request: JsonRpcRequest, + ): Promise { + const result = await this.#sendService.onAmountInput( + request as OnAmountInputRequest, + ); assert(result, ValidationResponseStruct); diff --git a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/types.ts b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/types.ts index 3904ad5ec..66626819b 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/types.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/types.ts @@ -1,11 +1,13 @@ +import { SendFlowMethod } from '@metamask/snap-networks-utils'; + export const ClientRequestMethod = { + OnAddressInput: SendFlowMethod.OnAddressInput, + OnAmountInput: SendFlowMethod.OnAmountInput, + ConfirmSend: SendFlowMethod.ConfirmSend, + ComputeFee: SendFlowMethod.ComputeFee, SignAndSendTransactionWithoutConfirmation: 'signAndSendTransactionWithoutConfirmation', - ConfirmSend: 'confirmSend', SignAndSendTransaction: 'signAndSendTransaction', - ComputeFee: 'computeFee', - OnAddressInput: 'onAddressInput', - OnAmountInput: 'onAmountInput', SignRewardsMessage: 'signRewardsMessage', SignCardMessage: 'signCardMessage', ApproveCardAmount: 'approveCardAmount', diff --git a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.test.ts b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.test.ts index deab97efb..8eaddc8c8 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.test.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.test.ts @@ -3,10 +3,12 @@ import { getBase64Codec, getUtf8Codec, pipe } from '@solana/kit'; import { CardMessageStruct, + SolanaCaipAssetTypeStruct, parseCardMessage, parseProofOfOwnershipMessage, ProofOfOwnershipMessageStruct, RewardsMessageStruct, + sendFlowStructs, } from './validation'; describe('validation', () => { @@ -392,3 +394,37 @@ describe('validation', () => { ); }); }); + +describe('send flow validation', () => { + it('accepts CAIP-19 assets on a supported Solana chain', () => { + expect( + is( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + SolanaCaipAssetTypeStruct, + ), + ).toBe(true); + }); + + it('rejects CAIP-19 assets from another chain', () => { + expect(is('tron:728126428/slip44:195', SolanaCaipAssetTypeStruct)).toBe( + false, + ); + }); + + it('rejects malformed CAIP-19 asset IDs', () => { + expect(is('not-an-asset-id', SolanaCaipAssetTypeStruct)).toBe(false); + }); + + it('builds the shared onAmountInput params from the Solana asset struct', () => { + expect( + is( + { + accountId: '0e6a1f4a-6f6c-4a1e-9f3d-1a2b3c4d5e6f', + assetId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + value: '1.5', + }, + sendFlowStructs.OnAmountInputParams, + ), + ).toBe(true); + }); +}); diff --git a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.ts b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.ts index 4b4136870..0480c3aa6 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.ts @@ -1,8 +1,10 @@ import { AssetStruct, FeeType } from '@metamask/keyring-api'; import { + createSendFlowStructs, parseProofOfOwnershipMessage as parseSharedProofOfOwnershipMessage, ProofOfOwnershipBatchErrorStruct as SignProofOfOwnershipBatchErrorStruct, ProofOfOwnershipBatchRequestParamsStruct as SignProofOfOwnershipBatchRequestParamsStruct, + SendFlowValidationResponseStruct, UuidStruct, } from '@metamask/snap-networks-utils'; import type { ProofOfOwnershipMessage } from '@metamask/snap-networks-utils'; @@ -10,7 +12,6 @@ import { literal } from '@metamask/snaps-sdk'; import type { Infer } from '@metamask/superstruct'; import { array, - boolean, defaulted, enums, is, @@ -25,11 +26,11 @@ import { JsonRpcIdStruct, JsonRpcVersionStruct, StrictHexStruct, + parseCaipAssetType, } from '@metamask/utils'; import { getBase64Codec, getUtf8Codec, pipe } from '@solana/kit'; -import { Network } from '../../constants/solana'; -import { SendErrorCodes } from '../../services/send/types'; +import { Network, isSupportedNetwork } from '../../constants/solana'; import { ScopeStringStruct, SolanaSignAndSendTransactionInputStruct, @@ -419,16 +420,7 @@ export const ApproveCardAmountResponseStruct = object({ signature: Base58Struct, }); -export const ValidationResponseStruct = object({ - valid: boolean(), - errors: array( - object({ - code: enums( - Object.values(SendErrorCodes) as [SendErrorCodes, ...SendErrorCodes[]], - ), - }), - ), -}); +export const ValidationResponseStruct = SendFlowValidationResponseStruct; export type ValidationResponse = Infer; @@ -567,3 +559,24 @@ export const SignProofOfOwnershipBatchResponseStruct = object({ export type SignProofOfOwnershipBatchResponse = Infer< typeof SignProofOfOwnershipBatchResponseStruct >; + +/** + * CAIP-19 asset struct that only accepts assets on a supported Solana chain. + */ +export const SolanaCaipAssetTypeStruct = refine( + CaipAssetTypeStruct, + 'solana-caip-asset-id', + (assetId) => isSupportedNetwork(parseCaipAssetType(assetId).chainId), +); + +/** + * Params structs for the unified send flow methods. + */ +export const sendFlowStructs = createSendFlowStructs({ + assetId: SolanaCaipAssetTypeStruct, + address: SolanaAddressStruct, + overrides: { + OnAddressInputParams: OnAddressInputRequestParamsStruct, + ComputeFeeParams: ComputeFeeRequestParamsStruct, + }, +}); diff --git a/packages/solana-wallet-snap/src/core/services/send/types.ts b/packages/solana-wallet-snap/src/core/services/send/types.ts index 5856fe79d..00ff73253 100644 --- a/packages/solana-wallet-snap/src/core/services/send/types.ts +++ b/packages/solana-wallet-snap/src/core/services/send/types.ts @@ -1,3 +1,4 @@ +import { SendFlowErrorCodes } from '@metamask/snap-networks-utils'; import type { Infer } from '@metamask/superstruct'; import type { @@ -15,12 +16,7 @@ export type OnAmountInputRequest = Infer; export type ValidationResponse = Infer; -export const SendErrorCodes = { - Required: 'Required', - Invalid: 'Invalid', - InsufficientBalanceToCoverFee: 'InsufficientBalanceToCoverFee', - InsufficientBalance: 'InsufficientBalance', -} as const; +export const SendErrorCodes = SendFlowErrorCodes; export type SendErrorCodes = (typeof SendErrorCodes)[keyof typeof SendErrorCodes]; diff --git a/packages/solana-wallet-snap/src/core/validation/structs.ts b/packages/solana-wallet-snap/src/core/validation/structs.ts index 135fe4172..a7c5fe620 100644 --- a/packages/solana-wallet-snap/src/core/validation/structs.ts +++ b/packages/solana-wallet-snap/src/core/validation/structs.ts @@ -1,6 +1,9 @@ import { CaipAssetTypeStruct, SolMethod } from '@metamask/keyring-api'; import { ExportAccountOptionsStruct } from '@metamask/keyring-api/v2'; -import { UuidStruct } from '@metamask/snap-networks-utils'; +import { + PositiveNumberStringStruct, + UuidStruct, +} from '@metamask/snap-networks-utils'; import type { Struct } from '@metamask/superstruct'; import { array, @@ -18,10 +21,7 @@ import { address } from '@solana/kit'; import { Network } from '../constants/solana'; -export const PositiveNumberStringStruct = pattern( - string(), - /^(?!0\d)(\d+(\.\d+)?)$/u, -); +export { PositiveNumberStringStruct }; /** * Keyring validations diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index f2474b936..459352df7 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Use the shared unified send flow router, method names, request structs, error codes, and validation response type from `@metamask/snap-networks-utils` ([#556](https://github.com/MetaMask/internal-snaps/pull/556)) - Client request handler `confirmSend` now throws when signing, or submission fails after the user confirms, instead of returning a JSON error response such as `{ valid: false, errors }` ([#550](https://github.com/MetaMask/internal-snaps/pull/550)) - Bump `@stellar/stellar-sdk` from `^17.2.1` to `^17.4.0` ([#545](https://github.com/MetaMask/internal-snaps/pull/545)) - Retry transient Token API, Price API, and Security Alerts API failures with bounded timeouts and circuit breaking ([#534](https://github.com/MetaMask/internal-snaps/pull/534)) diff --git a/packages/stellar-wallet-snap/jest.config.js b/packages/stellar-wallet-snap/jest.config.js index 845f8a409..69efdb386 100644 --- a/packages/stellar-wallet-snap/jest.config.js +++ b/packages/stellar-wallet-snap/jest.config.js @@ -28,10 +28,10 @@ module.exports = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 75.8, - functions: 85.61, - lines: 88.94, - statements: 88.95, + branches: 77.3, + functions: 86, + lines: 90.13, + statements: 90.14, }, }, diff --git a/packages/stellar-wallet-snap/snap.manifest.json b/packages/stellar-wallet-snap/snap.manifest.json index 304d6364a..73e7515f1 100644 --- a/packages/stellar-wallet-snap/snap.manifest.json +++ b/packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "eRuBaluqNFTt5UMhKvm2nax/kSwxAOt1WAWDBHeh2p0=", + "shasum": "2bqxGHoVUSdPEzYqIdRGmLoqkHVJY4hE1CXfNil2Iag=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts index b355b7e46..4f2e94b2e 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts @@ -1,8 +1,12 @@ import { AssetStruct, FeeType } from '@metamask/keyring-api'; import { + SendFlowErrorCodes, + SendFlowMethod, + SendFlowValidationResponseStruct, ProofOfOwnershipBatchErrorStruct as SignProofOfOwnershipBatchErrorStruct, ProofOfOwnershipBatchRequestParamsStruct, UuidStruct, + createSendFlowStructs, } from '@metamask/snap-networks-utils'; import type { Infer } from '@metamask/superstruct'; import { @@ -60,13 +64,12 @@ export { ProofOfOwnershipBatchErrorStruct as SignProofOfOwnershipBatchErrorStruc * Enum for the client request method. */ export const ClientRequestMethod = { - /** -------------------------------- Wallet Standard -------------------------------- */ - OnAddressInput: 'onAddressInput', - OnAmountInput: 'onAmountInput', - ConfirmSend: 'confirmSend', + OnAddressInput: SendFlowMethod.OnAddressInput, + OnAmountInput: SendFlowMethod.OnAmountInput, + ConfirmSend: SendFlowMethod.ConfirmSend, // Standard multichain workflow for bridge SignAndSendTransaction: 'signAndSendTransaction', - ComputeFee: 'computeFee', + ComputeFee: SendFlowMethod.ComputeFee, /** * Silent proof-of-ownership signing for `@metamask/profile-metrics-controller`. * SIP-31 client-only. @@ -84,12 +87,7 @@ export const ClientRequestMethod = { export type ClientRequestMethod = (typeof ClientRequestMethod)[keyof typeof ClientRequestMethod]; -export const MultiChainSendErrorCodes = { - Required: 'Required', - Invalid: 'Invalid', - InsufficientBalance: 'InsufficientBalance', - InsufficientBalanceToCoverFee: 'InsufficientBalanceToCoverFee', -} as const; +export const MultiChainSendErrorCodes = SendFlowErrorCodes; export type MultiChainSendErrorCodes = (typeof MultiChainSendErrorCodes)[keyof typeof MultiChainSendErrorCodes]; @@ -209,6 +207,13 @@ export const SignAndSendTransactionJsonRpcResponseStruct = object({ transactionId: StellarTransactionHashStruct, }); +/** + * Validation struct for the onAddressInput params. + */ +export const OnAddressInputParamsStruct = object({ + value: StellarAddressStruct, +}); + /* * Validation struct for the onAddressInput JSON-RPC request. */ @@ -216,25 +221,17 @@ export const OnAddressInputJsonRpcRequestStruct = assign( JsonRpcRequestStruct, object({ method: literal(ClientRequestMethod.OnAddressInput), - params: object({ - value: StellarAddressStruct, - }), + params: OnAddressInputParamsStruct, }), ); /** * Validation struct for the onAddressInput JSON-RPC response. */ -export const OnAddressInputJsonRpcResponseStruct = object({ - valid: boolean(), - errors: array( - object({ - code: string(), - }), - ), -}); +export const OnAddressInputJsonRpcResponseStruct = + SendFlowValidationResponseStruct; -const OnAmountInputParamsWireStruct = object({ +export const OnAmountInputParamsWireStruct = object({ accountId: UuidStruct, assetId: union([ KnownCaip19ClassicAssetStruct, @@ -297,16 +294,10 @@ export const OnAmountInputJsonRpcRequestStruct = refine( /** * Validation struct for the onAmountInput JSON-RPC response. */ -export const OnAmountInputJsonRpcResponseStruct = object({ - valid: boolean(), - errors: array( - object({ - code: string(), - }), - ), -}); +export const OnAmountInputJsonRpcResponseStruct = + SendFlowValidationResponseStruct; -const ConfirmSendParamsStruct = object({ +export const ConfirmSendParamsStruct = object({ fromAccountId: UuidStruct, toAddress: StellarAddressStruct, assetId: union([ @@ -382,22 +373,24 @@ export const ConfirmSendJsonRpcResponseStruct = object({ /** * Validation struct for the computeFee JSON-RPC request. */ +export const ComputeFeeParamsStruct = object({ + transaction: SwapTransactionXdrStruct, + accountId: UuidStruct, + scope: KnownCaip2ChainIdStruct, + options: optional( + object({ + visible: optional(boolean()), + type: optional(string()), + feeLimit: optional(min(integer(), 0)), + }), + ), +}); + export const ComputeFeeJsonRpcRequestStruct = assign( JsonRpcRequestStruct, object({ method: literal(ClientRequestMethod.ComputeFee), - params: object({ - transaction: SwapTransactionXdrStruct, - accountId: UuidStruct, - scope: KnownCaip2ChainIdStruct, - options: optional( - object({ - visible: optional(boolean()), - type: optional(string()), - feeLimit: optional(min(integer(), 0)), - }), - ), - }), + params: ComputeFeeParamsStruct, }), ); @@ -636,3 +629,18 @@ export type SignProofOfOwnershipBatchJsonRpcRequest = Infer< export type SignProofOfOwnershipBatchJsonRpcResponse = Infer< typeof SignProofOfOwnershipBatchJsonRpcResponseStruct >; + +/** + * Params structs for the unified send flow methods. + */ +export const stellarSendFlowStructs = createSendFlowStructs({ + address: StellarAddressStruct, + overrides: { + // onAddressInput returns an invalid response (instead of an invalid params + // error), so keep validating the address inside the handler. + OnAddressInputParams: object({ value: string() }), + OnAmountInputParams: OnAmountInputParamsWireStruct, + ConfirmSendParams: ConfirmSendParamsStruct, + ComputeFeeParams: ComputeFeeParamsStruct, + }, +}); diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/clientRequest.test.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/clientRequest.test.ts index 2e865c33d..0b2c73cbc 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/clientRequest.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/clientRequest.test.ts @@ -10,7 +10,7 @@ describe('ClientRequestHandler', () => { mockHandle.mockReset(); return new ClientRequestHandler({ handlers: { - [ClientRequestMethod.ComputeFee]: { handle: mockHandle }, + [ClientRequestMethod.ConfirmSend]: { handle: mockHandle }, } as never, }); } @@ -18,7 +18,13 @@ describe('ClientRequestHandler', () => { const request = { jsonrpc: '2.0', id: 1, - method: ClientRequestMethod.ComputeFee, + method: ClientRequestMethod.ConfirmSend, + params: { + fromAccountId: '11111111-1111-4111-8111-111111111111', + toAddress: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + assetId: 'stellar:pubnet/slip44:148', + amount: '1', + }, } as const; it('routes the request to the handler for its method', async () => { diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/clientRequest.ts index aa32a3125..9884549fa 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/clientRequest.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -1,20 +1,39 @@ +import { SendFlowRequestRouter } from '@metamask/snap-networks-utils'; import { MethodNotFoundError } from '@metamask/snaps-sdk'; import type { Json, JsonRpcRequest } from '@metamask/utils'; import { ensureError } from '@metamask/utils'; -import type { ClientRequestMethod } from './api'; -import { ClientRequestMethodStruct } from './api'; +import { + ClientRequestMethod, + ClientRequestMethodStruct, + stellarSendFlowStructs, +} from './api'; import type { IClientRequestHandler } from './base'; export class ClientRequestHandler { readonly #handlers: Record; + readonly #sendFlowRouter: SendFlowRequestRouter; + constructor({ handlers, }: { handlers: Record; }) { this.#handlers = handlers; + this.#sendFlowRouter = new SendFlowRequestRouter({ + structs: stellarSendFlowStructs, + handlers: { + onAddressInput: async (_params, request): Promise => + this.#handlers[ClientRequestMethod.OnAddressInput].handle(request), + onAmountInput: async (_params, request): Promise => + this.#handlers[ClientRequestMethod.OnAmountInput].handle(request), + confirmSend: async (_params, request): Promise => + this.#handlers[ClientRequestMethod.ConfirmSend].handle(request), + computeFee: async (_params, request): Promise => + this.#handlers[ClientRequestMethod.ComputeFee].handle(request), + }, + }); } /** @@ -28,6 +47,11 @@ export class ClientRequestHandler { * @throws {InvalidParamsError} If the params are invalid. */ async handle(request: JsonRpcRequest): Promise { + const sendFlowResponse = await this.#sendFlowRouter.handle(request); + if (sendFlowResponse !== undefined) { + return sendFlowResponse; + } + return (await this.#handleClientRequest(request)) ?? null; } diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index 3295f899c..f26f8b104 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `onAmountInput` now estimates send fees when `toAddress` is absent. Native TRX includes the account activation reserve, while TRC-20 sends validate the transfer fee without an activation reserve ([#554](https://github.com/MetaMask/internal-snaps/pull/554)) - Add the `getMaxSendableAmount` client request method, returning the maximum amount that can be sent for TRX and TRC-20 assets. The maximum leaves enough TRX for the network fees and account activation (native sends) or the transfer fee (TRC-20), and the destination is conservatively assumed unactivated when omitted ([#555](https://github.com/MetaMask/internal-snaps/pull/555)) +### Changed + +- Use the shared unified send flow router, method names, validation response and error-code types, and positive amount struct from `@metamask/snap-networks-utils` ([#556](https://github.com/MetaMask/internal-snaps/pull/556)) + ## [4.3.1] ### Fixed diff --git a/packages/tron-wallet-snap/jest.config.js b/packages/tron-wallet-snap/jest.config.js index 7f50357b9..71b6dc8cd 100644 --- a/packages/tron-wallet-snap/jest.config.js +++ b/packages/tron-wallet-snap/jest.config.js @@ -16,10 +16,10 @@ module.exports = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 75.18, - functions: 83.26, - lines: 88.01, - statements: 87.99, + branches: 75.8, + functions: 83.6, + lines: 88.19, + statements: 88.17, }, }, }; diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index 716e92066..a3acce4f1 100644 --- a/packages/tron-wallet-snap/snap.manifest.json +++ b/packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "7PH2vn+JpXgdl+jqoCmFUHVqIGLCkCM8lrzZeWQ2C8Y=", + "shasum": "lt9n/bXPhOCXHwEhdq+KCFmsyiryrtinaj/y0FwQSSc=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts index 5d514faf8..378d097c2 100644 --- a/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts +++ b/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -2,6 +2,8 @@ import { TransactionStatus } from '@metamask/keyring-api'; import { DEFAULT_METAMASK_ORIGIN, normalizeError, + SendFlowRequestRouter, + createSendFlowStructs, } from '@metamask/snap-networks-utils'; import type { AnalyticsService, Logger } from '@metamask/snap-networks-utils'; import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; @@ -10,7 +12,8 @@ import { MethodNotFoundError, UserRejectedRequestError, } from '@metamask/snaps-sdk'; -import { assert } from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import { assert, object, string } from '@metamask/superstruct'; import { bytesToHex, hexToBytes, sha256 } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import type { TronWeb, Types as TronwebTypes } from 'tronweb'; @@ -45,6 +48,10 @@ import { import { parseTronCaipAssetType } from '../../utils/caip'; import { trxToSun } from '../../utils/conversion'; import { resolveTransactionType } from '../../utils/transactionType'; +import { + TronAddressStruct, + TronCaipAssetTypeStruct, +} from '../../validation/structs'; import { assertTransactionSignerConsistency, assertTransactionStructure, @@ -54,13 +61,12 @@ import { ClientRequestMethod, SendErrorCodes } from './types'; import { ClaimTrxStakingRewardsRequestStruct, ClaimUnstakedTrxRequestStruct, - ComputeFeeRequestStruct, + ComputeFeeRequestParamsStruct, ComputeFeeResponseStruct, ComputeStakeFeeRequestStruct, - GetMaxSendableAmountRequestStruct, - OnAddressInputRequestStruct, - OnAmountInputRequestStruct, - OnConfirmSendRequestStruct, + GetMaxSendableAmountRequestParamsStruct, + OnAmountInputRequestParamsStruct, + OnConfirmSendRequestParamsStruct, OnConfirmStakeRequestStruct, OnConfirmUnstakeRequestStruct, OnStakeAmountInputRequestStruct, @@ -79,6 +85,25 @@ type TransactionRawData = TronwebTypes.Transaction['raw_data'] & { fee_limit?: number; }; +const tronSendFlowStructs = createSendFlowStructs({ + address: TronAddressStruct, + assetId: TronCaipAssetTypeStruct, + overrides: { + // onAddressInput returns an invalid response (instead of an invalid params + // error) so keep validating the address inside the handler. + OnAddressInputParams: object({ value: string() }), + OnAmountInputParams: OnAmountInputRequestParamsStruct, + ComputeFeeParams: ComputeFeeRequestParamsStruct, + }, +}); + +type OnAmountInputParams = Infer; +type ConfirmSendParams = Infer; +type ComputeFeeParams = Infer; +type GetMaxSendableAmountParams = Infer< + typeof GetMaxSendableAmountRequestParamsStruct +>; + /** * Validated proof-of-ownership signing request with its original input index. */ @@ -206,6 +231,8 @@ export class ClientRequestHandler { readonly #transactionExpirationRefresherService: TransactionExpirationRefresherService; + readonly #sendFlowRouter: SendFlowRequestRouter; + constructor({ logger, accountsService, @@ -246,6 +273,23 @@ export class ClientRequestHandler { this.#transactionsService = transactionsService; this.#transactionExpirationRefresherService = transactionExpirationRefresherService; + this.#sendFlowRouter = new SendFlowRequestRouter({ + structs: tronSendFlowStructs, + handlers: { + onAddressInput: async ({ value }): Promise => + this.#handleOnAddressInput(value), + onAmountInput: async (params): Promise => + this.#handleOnAmountInput(params as OnAmountInputParams), + getMaxSendableAmount: async (params): Promise => + this.#handleGetMaxSendableAmount( + params as GetMaxSendableAmountParams, + ), + confirmSend: async (params): Promise => + this.#handleConfirmSend(params as ConfirmSendParams), + computeFee: async (params): Promise => + this.#handleComputeFee(params as ComputeFeeParams), + }, + }); } /** @@ -261,6 +305,11 @@ export class ClientRequestHandler { async handle(request: JsonRpcRequest): Promise { this.#logger.log('Handling client request', request); + const sendFlowResponse = await this.#sendFlowRouter.handle(request); + if (sendFlowResponse !== undefined) { + return sendFlowResponse; + } + const { method } = request; switch (method as ClientRequestMethod) { @@ -272,16 +321,6 @@ export class ClientRequestHandler { /** * Unified non-EVM Send */ - case ClientRequestMethod.OnAddressInput: - return this.#handleOnAddressInput(request); - case ClientRequestMethod.OnAmountInput: - return this.#handleOnAmountInput(request); - case ClientRequestMethod.ConfirmSend: - return this.#handleConfirmSend(request); - case ClientRequestMethod.ComputeFee: - return this.#handleComputeFee(request); - case ClientRequestMethod.GetMaxSendableAmount: - return this.#handleGetMaxSendableAmount(request); case ClientRequestMethod.ComputeStakeFee: return this.#handleComputeStakeFee(request); /** @@ -462,12 +501,12 @@ export class ClientRequestHandler { /** * Handles the input of an address. * - * @param request - The JSON-RPC request containing the method and parameters. + * @param value - The address to validate. * @returns The response to the JSON-RPC request. */ - async #handleOnAddressInput(request: JsonRpcRequest): Promise { + async #handleOnAddressInput(value: string): Promise { try { - assert(request, OnAddressInputRequestStruct); + assert(value, TronAddressStruct); return { valid: true, errors: [], @@ -488,14 +527,12 @@ export class ClientRequestHandler { * - If native TRX + full amount, we need bandwidth * - * - * @param request - The JSON-RPC request containing amount and asset details. + * @param params - The amount input params. * @returns Validation result with balance and sufficiency status. */ - async #handleOnAmountInput(request: JsonRpcRequest): Promise { + async #handleOnAmountInput(params: OnAmountInputParams): Promise { try { - assert(request, OnAmountInputRequestStruct); - - const { accountId, assetId, value, toAddress } = request.params; + const { accountId, assetId, value, toAddress } = params; const account = await this.#accountsService.findById(accountId); @@ -621,17 +658,11 @@ export class ClientRequestHandler { /** * Handles the confirmation and sending of a transaction. * - * @param request - The JSON-RPC request containing transaction details. + * @param params - The confirm send params. * @returns The transaction result with hash and status. */ - async #handleConfirmSend(request: JsonRpcRequest): Promise { - assertOrThrow( - request, - OnConfirmSendRequestStruct, - new InvalidParamsError(), - ); - - const { fromAccountId, toAddress, amount, assetId } = request.params; + async #handleConfirmSend(params: ConfirmSendParams): Promise { + const { fromAccountId, toAddress, amount, assetId } = params; const account = await this.#accountsService.findById(fromAccountId); @@ -776,21 +807,17 @@ export class ClientRequestHandler { * Handles the computation of a fee for a TRON transaction. * Returns used energy, used bandwidth, and the additional TRX cost breakdown for overages. * - * @param request - The JSON-RPC request containing the method and parameters. + * @param params - The compute fee params. * @returns The response to the JSON-RPC request with the detailed fee breakdown. * @throws {InvalidParamsError} If the params are invalid. */ - async #handleComputeFee(request: JsonRpcRequest): Promise { - assertOrThrow(request, ComputeFeeRequestStruct, new InvalidParamsError()); - + async #handleComputeFee(params: ComputeFeeParams): Promise { const { - params: { - scope, - transaction: transactionBase64, - accountId, - options: { type, feeLimit }, - }, - } = request; + scope, + transaction: transactionBase64, + accountId, + options: { type, feeLimit }, + } = params; /** * Recreate the transaction object from base64-encoded raw data. @@ -946,18 +973,14 @@ export class ClientRequestHandler { * network fees and, when the destination is unknown, the account activation * reserve. For TRC-20 the account must hold enough TRX for the transfer fee. * - * @param request - The JSON-RPC request containing the method and parameters. + * @param params - The get max sendable amount params. * @returns The maximum sendable amount, or 0 when no amount can pass. * @throws {InvalidParamsError} If the params are invalid. */ - async #handleGetMaxSendableAmount(request: JsonRpcRequest): Promise { - assertOrThrow( - request, - GetMaxSendableAmountRequestStruct, - new InvalidParamsError(), - ); - - const { accountId, assetId, toAddress } = request.params; + async #handleGetMaxSendableAmount( + params: GetMaxSendableAmountParams, + ): Promise { + const { accountId, assetId, toAddress } = params; const account = await this.#accountsService.findByIdOrThrow(accountId); diff --git a/packages/tron-wallet-snap/src/handlers/clientRequest/types.ts b/packages/tron-wallet-snap/src/handlers/clientRequest/types.ts index 1b62b78f0..2fee1d300 100644 --- a/packages/tron-wallet-snap/src/handlers/clientRequest/types.ts +++ b/packages/tron-wallet-snap/src/handlers/clientRequest/types.ts @@ -1,13 +1,11 @@ +import { + SendFlowErrorCodes, + SendFlowMethod, +} from '@metamask/snap-networks-utils'; + export const ClientRequestMethod = { + ...SendFlowMethod, SignAndSendTransaction: 'signAndSendTransaction', - /** - * Unified non-EVM Send - */ - ConfirmSend: 'confirmSend', - ComputeFee: 'computeFee', - GetMaxSendableAmount: 'getMaxSendableAmount', - OnAddressInput: 'onAddressInput', - OnAmountInput: 'onAmountInput', /** * Staking + Unstaking */ @@ -36,12 +34,7 @@ export const ClientRequestMethod = { export type ClientRequestMethod = (typeof ClientRequestMethod)[keyof typeof ClientRequestMethod]; -export const SendErrorCodes = { - Required: 'Required', - Invalid: 'Invalid', - InsufficientBalance: 'InsufficientBalance', - InsufficientBalanceToCoverFee: 'InsufficientBalanceToCoverFee', -} as const; +export const SendErrorCodes = SendFlowErrorCodes; export type SendErrorCodes = (typeof SendErrorCodes)[keyof typeof SendErrorCodes]; diff --git a/packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts b/packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts index cf919b9e2..c83350644 100644 --- a/packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts +++ b/packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts @@ -3,6 +3,8 @@ import { parseProofOfOwnershipMessage as parseSharedProofOfOwnershipMessage, ProofOfOwnershipBatchRequestParamsStruct as SignProofOfOwnershipBatchRequestParamsStruct, ProofOfOwnershipBatchResponseStruct as SignProofOfOwnershipBatchResponseStruct, + SendFlowGetMaxSendableAmountResponseStruct, + SendFlowValidationResponseStruct, UuidStruct, } from '@metamask/snap-networks-utils'; import type { ProofOfOwnershipMessage } from '@metamask/snap-networks-utils'; @@ -32,7 +34,7 @@ import { ScopeStringStruct, TronAddressStruct, } from '../../validation/structs'; -import { ClientRequestMethod, SendErrorCodes } from './types'; +import { ClientRequestMethod } from './types'; /** * Validates one proof-of-ownership batch request item. @@ -127,14 +129,7 @@ export const OnAmountInputRequestStruct = object({ params: OnAmountInputRequestParamsStruct, }); -export const ValidationResponseStruct = object({ - valid: boolean(), - errors: array( - object({ - code: enums(Object.values(SendErrorCodes)), - }), - ), -}); +export const ValidationResponseStruct = SendFlowValidationResponseStruct; export type ValidationResponse = Infer; @@ -240,9 +235,8 @@ export const GetMaxSendableAmountRequestStruct = object({ * getMaxSendableAmount response validation: the maximum amount that makes * onAmountInput pass, or 0 when no amount can pass. */ -export const GetMaxSendableAmountResponseStruct = object({ - amount: string(), -}); +export const GetMaxSendableAmountResponseStruct = + SendFlowGetMaxSendableAmountResponseStruct; export type GetMaxSendableAmountResponse = Infer< typeof GetMaxSendableAmountResponseStruct diff --git a/packages/tron-wallet-snap/src/validation/structs.ts b/packages/tron-wallet-snap/src/validation/structs.ts index 99ba8f0ef..702fa5e1c 100644 --- a/packages/tron-wallet-snap/src/validation/structs.ts +++ b/packages/tron-wallet-snap/src/validation/structs.ts @@ -6,7 +6,10 @@ import { TrxAccountType, } from '@metamask/keyring-api'; import { ExportAccountOptionsStruct } from '@metamask/keyring-api/v2'; -import { UuidStruct } from '@metamask/snap-networks-utils'; +import { + PositiveNumberStringStruct, + UuidStruct, +} from '@metamask/snap-networks-utils'; import type { Infer, Struct } from '@metamask/superstruct'; import { array, @@ -40,10 +43,7 @@ import { TokenCaipAssetTypeStruct, } from '../services/assets/types'; -export const PositiveNumberStringStruct = pattern( - string(), - /^(?!0\d)(\d+(\.\d+)?)$/u, -); +export { PositiveNumberStringStruct }; /** * Keyring validations