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.