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 5960cd5e5..f26f8b104 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,15 @@ 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)) +- 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 f6ddbe027..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": "CTTNZeBEFTBQaq0XGhhBQqnz4oYmcTvRym4q23a4vdw=", + "shasum": "lt9n/bXPhOCXHwEhdq+KCFmsyiryrtinaj/y0FwQSSc=", "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..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'; @@ -163,7 +164,9 @@ type WithClientRequestHandlerCallback = (payload: { mockSendService: MockMethods< 'buildTransaction' | 'validateSend' | 'signAndSendTransaction' >; - mockFeeCalculatorService: MockMethods<'computeFee'>; + mockFeeCalculatorService: MockMethods< + 'computeFee' | 'getAccountActivationReserve' + >; mockStakingService: MockMethods< 'claimUnstakedTrx' | 'claimTrxStakingRewards' >; @@ -223,8 +226,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 +2236,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 +2299,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 +2331,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(); }, ); }); @@ -2749,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 d70e2d6c0..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,12 +61,12 @@ import { ClientRequestMethod, SendErrorCodes } from './types'; import { ClaimTrxStakingRewardsRequestStruct, ClaimUnstakedTrxRequestStruct, - ComputeFeeRequestStruct, + ComputeFeeRequestParamsStruct, ComputeFeeResponseStruct, ComputeStakeFeeRequestStruct, - OnAddressInputRequestStruct, - OnAmountInputRequestStruct, - OnConfirmSendRequestStruct, + GetMaxSendableAmountRequestParamsStruct, + OnAmountInputRequestParamsStruct, + OnConfirmSendRequestParamsStruct, OnConfirmStakeRequestStruct, OnConfirmUnstakeRequestStruct, OnStakeAmountInputRequestStruct, @@ -78,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. */ @@ -205,6 +231,8 @@ export class ClientRequestHandler { readonly #transactionExpirationRefresherService: TransactionExpirationRefresherService; + readonly #sendFlowRouter: SendFlowRequestRouter; + constructor({ logger, accountsService, @@ -245,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), + }, + }); } /** @@ -260,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) { @@ -271,14 +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.ComputeStakeFee: return this.#handleComputeStakeFee(request); /** @@ -459,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: [], @@ -485,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); @@ -538,24 +578,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 +600,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 +632,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 { @@ -604,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); @@ -759,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. @@ -921,6 +965,130 @@ 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 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( + params: GetMaxSendableAmountParams, + ): Promise { + const { accountId, assetId, toAddress } = 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..2fee1d300 100644 --- a/packages/tron-wallet-snap/src/handlers/clientRequest/types.ts +++ b/packages/tron-wallet-snap/src/handlers/clientRequest/types.ts @@ -1,12 +1,11 @@ +import { + SendFlowErrorCodes, + SendFlowMethod, +} from '@metamask/snap-networks-utils'; + export const ClientRequestMethod = { + ...SendFlowMethod, SignAndSendTransaction: 'signAndSendTransaction', - /** - * Unified non-EVM Send - */ - ConfirmSend: 'confirmSend', - ComputeFee: 'computeFee', - OnAddressInput: 'onAddressInput', - OnAmountInput: 'onAmountInput', /** * Staking + Unstaking */ @@ -35,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 f93ce48e5..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; @@ -220,6 +215,33 @@ 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 = + SendFlowGetMaxSendableAmountResponseStruct; + +export type GetMaxSendableAmountResponse = Infer< + typeof GetMaxSendableAmountResponseStruct +>; + export const OnUnstakeAmountInputRequestParamsStruct = object({ accountId: UuidStruct, assetId: NativeCaipAssetTypeStruct, 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. 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