From 2444641a061d06bd430d31e407cc300618e3df89 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Tue, 3 Nov 2026 09:26:48 +0000 Subject: [PATCH] feat(tron): add getMaxSendableAmount client request method Computes the maximum safe amount that can be sent for TRX and TRC-20 assets, with its fee quote. The sent asset and the account resources are read fresh, so the result never acts on stale balances. For native TRX sends the maximum leaves enough TRX to cover the network fees and the account activation; for TRC-20 sends the account must hold enough TRX to cover the transfer fee. When the destination is omitted, the conservative case is assumed and the quoted fees include a conservative account activation reserve. WPN-2374 --- packages/tron-wallet-snap/CHANGELOG.md | 4 + packages/tron-wallet-snap/snap.manifest.json | 2 +- .../clientRequest/clientRequest.test.ts | 518 +++++++++++++++++- .../handlers/clientRequest/clientRequest.ts | 194 +++++++ .../src/handlers/clientRequest/types.ts | 1 + .../src/handlers/clientRequest/validation.ts | 32 ++ .../send/FeeCalculatorService.test.ts | 57 ++ .../src/services/send/FeeCalculatorService.ts | 24 + 8 files changed, 829 insertions(+), 3 deletions(-) diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index 6b8d5ddda..8c6d60e7b 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add the `getMaxSendableAmount` client request method, returning the maximum safe amount that can be sent for TRX and TRC-20 assets with its fee quote. 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 ([#549](https://github.com/MetaMask/internal-snaps/pull/549)) + ## [4.3.0] ### Changed diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index 61b49483c..1de50af43 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": "2NUzURUf52yUyJyLA/Kag7IuNSOhyllhYiCHxt02Yh4=", + "shasum": "5GgVmRgPWZRrUkQwynBygl8byu9bBE/yhr5nHSTQ3no=", "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..2a38e83df 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' @@ -2749,6 +2755,514 @@ 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 mockFreshAssets = ( + mockAssetsService: { fetchAccountAssetsByIDs: jest.Mock }, + asset: AssetEntity | null, + nativeToken: AssetEntity | null, + ): void => { + mockAssetsService.fetchAccountAssetsByIDs.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); + mockFreshAssets(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 are fetched fresh. + expect(mockAssetsService.fetchAccountAssetsByIDs).toHaveBeenCalledWith( + expect.objectContaining({ id: 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({ + valid: true, + errors: [], + maxAmount: '99.7', + balance: '100', + fees: feeResult, + }); + }, + ); + }); + + 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); + mockFreshAssets( + 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); + // maxAmount = 100 - (0.3 + 1.1) + expect(result).toStrictEqual({ + valid: true, + errors: [], + maxAmount: '98.6', + balance: '100', + fees: createSendFees('1.4'), + }); + }, + ); + }); + + it('returns InsufficientBalanceToCoverFee 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); + mockFreshAssets( + 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({ + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalanceToCoverFee }], + maxAmount: '0', + balance: '0.1', + fees: createSendFees('0.3'), + }); + }, + ); + }); + + it('returns InsufficientBalance 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); + mockFreshAssets( + 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({ + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + maxAmount: '0', + balance: '0', + fees: createSendFees('0.3'), + }); + }, + ); + }); + + 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); + mockFreshAssets(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({ + valid: true, + errors: [], + maxAmount: '50', + balance: '50', + fees: feeResult, + }); + }, + ); + }); + + it('returns InsufficientBalanceToCoverFee 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); + mockFreshAssets( + 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({ + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalanceToCoverFee }], + maxAmount: '0', + balance: '50', + fees: createSendFees('0.3'), + }); + }, + ); + }); + + it('returns InsufficientBalance 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); + mockFreshAssets( + mockAssetsService, + createUsdtAsset('0'), + createNativeAsset('10'), + ); + + const result = await handler.handle(request as JsonRpcRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + maxAmount: '0', + balance: '0', + fees: [], + }); + expect(mockSendService.buildTransaction).not.toHaveBeenCalled(); + expect(mockFeeCalculatorService.computeFee).not.toHaveBeenCalled(); + }, + ); + }); + + it('returns InsufficientBalance 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); + mockFreshAssets(mockAssetsService, null, null); + + const result = await handler.handle(request as JsonRpcRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + maxAmount: '0', + balance: '0', + fees: [], + }); + 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..4d8a8f69c 100644 --- a/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts +++ b/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -57,6 +57,7 @@ import { ComputeFeeRequestStruct, ComputeFeeResponseStruct, ComputeStakeFeeRequestStruct, + GetMaxSendableAmountRequestStruct, OnAddressInputRequestStruct, OnAmountInputRequestStruct, OnConfirmSendRequestStruct, @@ -279,6 +280,8 @@ export class ClientRequestHandler { return this.#handleConfirmSend(request); case ClientRequestMethod.ComputeFee: return this.#handleComputeFee(request); + case ClientRequestMethod.GetMaxSendableAmount: + return this.#handleGetMaxSendableAmount(request); case ClientRequestMethod.ComputeStakeFee: return this.#handleComputeStakeFee(request); /** @@ -921,6 +924,197 @@ export class ClientRequestHandler { return result; } + /** + * Computes the maximum safe amount that can be sent for the given asset, + * with its fee quote. + * + * The sent asset and the account resources are read fresh, so the result + * never acts on stale balances. For native TRX sends the maximum leaves + * enough TRX to cover the network fees and the account activation; for + * TRC-20 sends the account must hold enough TRX to cover the transfer fee, + * and the whole token balance can be sent. + * + * When `toAddress` is omitted, the conservative case is assumed: the + * destination may be an unactivated account, so the quoted fees include an + * account activation reserve. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The maximum sendable amount, the asset balance, and the fee quote. + * @throws {InvalidParamsError} If the params are invalid. + */ + async #handleGetMaxSendableAmount(request: JsonRpcRequest): Promise { + assertOrThrow( + request, + GetMaxSendableAmountRequestStruct, + new InvalidParamsError(), + ); + + const { accountId, assetId, toAddress } = request.params; + + const account = await this.#accountsService.findByIdOrThrow(accountId); + + const { chainId: scope } = parseTronCaipAssetType(assetId); + + /** + * Fetch the sent asset and the account resources fresh, so the maximum + * never acts on stale balances. + */ + const [asset, nativeTokenAsset, bandwidthAsset, energyAsset] = + await this.#assetsService.fetchAccountAssetsByIDs(account, [ + assetId, + Networks[scope].nativeToken.id, + Networks[scope].bandwidth.id, + Networks[scope].energy.id, + ]); + + if (!asset) { + return { + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + maxAmount: '0', + balance: '0', + fees: [], + }; + } + + 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; + + if (!isNativeToken && assetBalance.isLessThanOrEqualTo(ZERO)) { + return { + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + maxAmount: '0', + balance: asset.uiAmount, + fees: [], + }; + } + + /** + * 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, so the quote matches + * the transaction that will actually be signed. + */ + const transaction = await this.#sendService.buildTransaction({ + fromAccountId: account.id, + toAddress: toAddress ?? account.address, + asset, + amount: isNativeToken ? nativeTokenBalance : assetBalance, + 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, + ); + } + + /** + * The fee calculation already takes into account the energy and + * bandwidth consumption and the account activation (when the destination + * is known and not activated), so the TRX fee is everything the send + * needs on top of the amount. + */ + 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 { + valid: false, + errors: [ + { + code: nativeTokenBalance.isGreaterThan(ZERO) + ? SendErrorCodes.InsufficientBalanceToCoverFee + : SendErrorCodes.InsufficientBalance, + }, + ], + maxAmount: '0', + balance: asset.uiAmount, + fees, + }; + } + + return { + valid: true, + errors: [], + maxAmount: maxAmount + .decimalPlaces(asset.decimals, BigNumber.ROUND_DOWN) + .toFixed(), + balance: asset.uiAmount, + fees, + }; + } + + /** + * Sending a TRC-20 token does not consume TRX beyond the fees, but the + * account must hold enough TRX to cover the transfer fee. + */ + if (trxFee.isGreaterThan(nativeTokenBalance)) { + return { + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalanceToCoverFee }], + maxAmount: '0', + balance: asset.uiAmount, + fees, + }; + } + + return { + valid: true, + errors: [], + maxAmount: asset.uiAmount, + balance: asset.uiAmount, + fees, + }; + } + /** * Handles the input of a stake amount. Checks if the user has enough of the asset * to do the stake. diff --git a/packages/tron-wallet-snap/src/handlers/clientRequest/types.ts b/packages/tron-wallet-snap/src/handlers/clientRequest/types.ts index 2dc96159c..1b62b78f0 100644 --- a/packages/tron-wallet-snap/src/handlers/clientRequest/types.ts +++ b/packages/tron-wallet-snap/src/handlers/clientRequest/types.ts @@ -5,6 +5,7 @@ export const ClientRequestMethod = { */ ConfirmSend: 'confirmSend', ComputeFee: 'computeFee', + GetMaxSendableAmount: 'getMaxSendableAmount', OnAddressInput: 'onAddressInput', OnAmountInput: 'onAmountInput', /** diff --git a/packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts b/packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts index f93ce48e5..b1a16f986 100644 --- a/packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts +++ b/packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts @@ -220,6 +220,38 @@ 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 safe amount to send, + * the asset balance, and the fee quote for the send, plus the usual + * validation response. + */ +export const GetMaxSendableAmountResponseStruct = object({ + ...ValidationResponseStruct.schema, + maxAmount: string(), + balance: string(), + fees: ComputeFeeResponseStruct, +}); + +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.