diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index 5d4d44cf2..f671fa6f4 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- `onAmountInput` now estimates send fees when `toAddress` is not provided ([#554](https://github.com/MetaMask/internal-snaps/pull/554)) + - Previously, `onAmountInput` returned true for all cases when `toAddress` was not provided + - Native TRX and TRC-10 transfers now add a pessimistic account activation fee on top of the estimated transfer fee; TRC-20 transfers only estimate the transfer fee + - Fee estimation now fails instead of treating an account whose on-chain lookup failed as inactive + ## [4.4.1] ### Fixed diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index 5f4e0b57d..547acafca 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": "6utZnFuKIcTKzQewn4or+Bt5AISVF5/tKvSWrMEs82U=", + "shasum": "pCTCCOaBFZYfrwMWa62fBMjRDmpqjxEgO8Aw8oxnwyE=", "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 ac6dd07f7..eb2dfba9d 100644 --- a/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.test.ts +++ b/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.test.ts @@ -19,7 +19,9 @@ import { FEE_LIMIT, Network, Networks, + NULL_ADDRESS, TRACK_TX_INTERVAL, + ZERO, } from '../../constants'; import type { AssetEntity, @@ -164,7 +166,9 @@ type WithClientRequestHandlerCallback = (payload: { mockSendService: MockMethods< 'buildTransaction' | 'validateSend' | 'signAndSendTransaction' >; - mockFeeCalculatorService: MockMethods<'computeFee'>; + mockFeeCalculatorService: MockMethods< + 'computeFee' | 'getAccountActivationFee' + >; mockStakingService: MockMethods< 'claimUnstakedTrx' | 'claimTrxStakingRewards' >; @@ -224,8 +228,11 @@ async function withClientRequestHandler( signAndSendTransaction: vi.fn(), }; - const mockFeeCalculatorService: MockMethods<'computeFee'> = { + const mockFeeCalculatorService: MockMethods< + 'computeFee' | 'getAccountActivationFee' + > = { computeFee: vi.fn(), + getAccountActivationFee: vi.fn(), }; const mockStakingService: MockMethods< 'claimUnstakedTrx' | 'claimTrxStakingRewards' @@ -2231,6 +2238,28 @@ 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 createTrc10Asset = (uiAmount: string): AssetEntity => ({ + assetType: 'tron:728126428/trc10:1002000', + keyringAccountId: TEST_ACCOUNT_ID, + network: scope, + symbol: 'TRC10', + decimals: 6, + rawAmount: trxToSun(uiAmount), + uiAmount, + iconUrl: '', + }); + const createResourceAsset = ( assetType: ResourceAsset['assetType'], uiAmount: string, @@ -2283,7 +2312,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, @@ -2315,15 +2344,401 @@ 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.getAccountActivationFee.mockResolvedValue({ + trxFee: BigNumber('1.1'), + bandwidthNeeded: ZERO, + }); 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: NULL_ADDRESS, + asset: mockAsset, + amount: new BigNumber('10'), + feeLimit: FEE_LIMIT, + }); + expect(mockFeeCalculatorService.computeFee).toHaveBeenCalledWith( + expect.objectContaining({ skipAccountActivation: true }), + ); + expect( + mockFeeCalculatorService.getAccountActivationFee, + ).toHaveBeenCalledWith({ + scope, + toAddress: undefined, + senderAddress: mockAccount.address, + }); + }, + ); + }); + + it('treats an empty toAddress as an unknown destination', async () => { + await withClientRequestHandler( + async ({ + handler, + mockAccountsService, + mockAssetsService, + mockSendService, + mockFeeCalculatorService, + }) => { + const request: OnAmountInputRequest = { + jsonrpc: '2.0' as const, + id: '1', + method: ClientRequestMethod.OnAmountInput, + params: { + accountId: TEST_ACCOUNT_ID, + assetId: nativeTokenId, + value: '10', + toAddress: '', + }, + }; + + const mockAsset = createNativeAsset('100', '100000000'); + const mockAssets: [ + NativeAsset, + NativeAsset, + ResourceAsset, + ResourceAsset, + ] = [ + mockAsset, + mockAsset, + createResourceAsset(Networks[scope].bandwidth.id, '5000', '5000'), + createResourceAsset(Networks[scope].energy.id, '100000', '100000'), + ]; + 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.getAccountActivationFee.mockResolvedValue({ + trxFee: BigNumber('1.1'), + bandwidthNeeded: ZERO, + }); + + const result = await handler.handle(request); + + expect(result).toStrictEqual({ valid: true, errors: [] }); + expect(mockSendService.buildTransaction).toHaveBeenCalledWith({ + fromAccountId: TEST_ACCOUNT_ID, + toAddress: NULL_ADDRESS, + asset: mockAsset, + amount: new BigNumber('10'), + feeLimit: FEE_LIMIT, + }); + expect(mockFeeCalculatorService.computeFee).toHaveBeenCalledWith( + expect.objectContaining({ skipAccountActivation: true }), + ); + expect( + mockFeeCalculatorService.getAccountActivationFee, + ).toHaveBeenCalledWith({ + scope, + toAddress: undefined, + senderAddress: mockAccount.address, + }); + }, + ); + }); + + 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.getAccountActivationFee.mockResolvedValue({ + trxFee: BigNumber('1.1'), + bandwidthNeeded: ZERO, + }); + + 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: NULL_ADDRESS, + asset: tokenAsset, + }), + ); + expect( + mockFeeCalculatorService.getAccountActivationFee, + ).not.toHaveBeenCalled(); + }, + ); + }); + + it('adds the activation reserve for TRC-10 when toAddress is missing', async () => { + await withClientRequestHandler( + async ({ + handler, + mockAccountsService, + mockAssetsService, + mockSendService, + mockFeeCalculatorService, + }) => { + const tokenAsset = createTrc10Asset('50'); + const request: OnAmountInputRequest = { + jsonrpc: '2.0' as const, + id: '3', + method: ClientRequestMethod.OnAmountInput, + params: { + accountId: TEST_ACCOUNT_ID, + assetId: tokenAsset.assetType, + value: '50', + }, + }; + + const mockAssets: [ + AssetEntity, + NativeAsset, + ResourceAsset, + ResourceAsset, + ] = [ + tokenAsset, + createNativeAsset('1', '1000000'), + 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: '0.5', + fungible: true, + }, + }, + ]; + + mockAccountsService.findById.mockResolvedValue(mockAccount); + mockAssetsService.getAccountAssetsByIDs.mockResolvedValue(mockAssets); + mockSendService.buildTransaction.mockResolvedValue( + createMockTransferTransaction(), + ); + mockFeeCalculatorService.computeFee.mockResolvedValue(mockFees); + mockFeeCalculatorService.getAccountActivationFee.mockResolvedValue({ + trxFee: BigNumber('1.1'), + bandwidthNeeded: ZERO, + }); + + const result = await handler.handle(request); + + // 0.5 transfer fee alone fits in 1 TRX, but the 1.1 activation + // reserve does not. + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalanceToCoverFee }], + }); + expect( + mockFeeCalculatorService.getAccountActivationFee, + ).toHaveBeenCalledWith({ + scope, + toAddress: undefined, + senderAddress: mockAccount.address, + }); + }, + ); + }); + + 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.getAccountActivationFee, + ).not.toHaveBeenCalled(); }, ); }); @@ -2395,6 +2810,7 @@ describe('ClientRequestHandler - onAmountInput', () => { availableEnergy: BigNumber('100000'), availableBandwidth: BigNumber('5000'), feeLimit: FEE_LIMIT, + skipAccountActivation: false, }); }, ); diff --git a/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts index d70e2d6c0..383a51b04 100644 --- a/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts +++ b/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -22,6 +22,7 @@ import { FEE_LIMIT, Network, Networks, + NULL_ADDRESS, TRACK_TX_INTERVAL, ZERO, } from '../../constants'; @@ -509,7 +510,8 @@ export class ClientRequestHandler { /** * Check if we have enough of the asset we want to send... */ - const { chainId: scope } = parseTronCaipAssetType(assetId); + const { chainId: scope, assetNamespace } = + parseTronCaipAssetType(assetId); const [asset, nativeTokenAsset, bandwidthAsset, energyAsset] = await this.#assetsService.getAccountAssetsByIDs(accountId, [ @@ -538,19 +540,20 @@ export class ClientRequestHandler { }; } - if (!toAddress) { - return { - valid: true, - errors: [], - }; - } - /** - * Estimate the fees + * Estimate the fees. When the destination is unknown, build against the + * zero address so the transfer fee can be quoted. The sender's own + * address cannot be used because Tron rejects self-transfers. The probe + * is only a quote vehicle, so activation is skipped here and reserved + * separately below. */ + const nativeTokenId = Networks[scope].nativeToken.id; + const isNativeToken = assetId === nativeTokenId; + const destination = + toAddress === '' ? NULL_ADDRESS : (toAddress ?? NULL_ADDRESS); const sendTransaction = await this.#sendService.buildTransaction({ fromAccountId: accountId, - toAddress, + toAddress: destination, asset, amount: valueBN, feeLimit: FEE_LIMIT, @@ -561,24 +564,43 @@ export class ClientRequestHandler { availableEnergy: energyBalance, availableBandwidth: bandwidthBalance, feeLimit: FEE_LIMIT, + skipAccountActivation: !toAddress, }); /** * 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( + const baseTrxFee = new BigNumber( fees.find((fee) => fee.asset.type === nativeTokenId)?.asset.amount ?? '0', ); + /** + * An unknown destination is assumed inactive, so add its activation + * cost: the 1 TRX burn plus the 0.1 TRX Bandwidth shortfall when the + * sender's staked Bandwidth cannot cover the account creation. Only + * native TRX and TRC-10 transfers activate recipients; TRC-20 sends do + * not. + */ + const activationTrxFee = + !toAddress && (isNativeToken || assetNamespace === 'trc10') + ? ( + await this.#feeCalculatorService.getAccountActivationFee({ + scope, + toAddress: undefined, + senderAddress: account.address, + }) + ).trxFee + : ZERO; + /** * 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 : ZERO) + .plus(baseTrxFee) + .plus(activationTrxFee); if (totalTrxToSpend.isGreaterThan(nativeTokenBalance)) { return { diff --git a/packages/tron-wallet-snap/src/services/send/FeeCalculatorService.test.ts b/packages/tron-wallet-snap/src/services/send/FeeCalculatorService.test.ts index f3b17fc26..969a5329f 100644 --- a/packages/tron-wallet-snap/src/services/send/FeeCalculatorService.test.ts +++ b/packages/tron-wallet-snap/src/services/send/FeeCalculatorService.test.ts @@ -183,6 +183,155 @@ async function withFeeCalculatorService( } describe('FeeCalculatorService', () => { + describe('getAccountActivationFee', () => { + const senderAddress = 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx'; + + it('assumes an unknown destination is inactive and burns the default activation cost', async () => { + await withFeeCalculatorService( + async ({ feeCalculatorService, trongridApiClient }) => { + trongridApiClient.getChainParameters.mockResolvedValue([]); + + const fee = await feeCalculatorService.getAccountActivationFee({ + scope: Network.Mainnet, + toAddress: undefined, + senderAddress, + }); + + // Default: getCreateNewAccountFeeInSystemContract (1 TRX) + // + getCreateAccountFee Bandwidth shortfall (0.1 TRX) + expect(fee.trxFee.toString()).toBe('1.1'); + expect(fee.bandwidthNeeded.toString()).toBe('0'); + expect( + trongridApiClient.getAccountInfoByAddress, + ).not.toHaveBeenCalled(); + }, + ); + }); + + it('uses staked Bandwidth for the account-creation quota when it is enough', async () => { + await withFeeCalculatorService( + async ({ feeCalculatorService, tronHttpClient }) => { + tronHttpClient.getAccountResources.mockResolvedValue({ + NetLimit: 100, + NetUsed: 0, + freeNetLimit: 600, + freeNetUsed: 0, + }); + + const fee = await feeCalculatorService.getAccountActivationFee({ + scope: Network.Mainnet, + toAddress: undefined, + senderAddress, + }); + + expect(fee.trxFee.toString()).toBe('1'); + expect(fee.bandwidthNeeded.toString()).toBe('100'); + }, + ); + }); + + 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 fee = await feeCalculatorService.getAccountActivationFee({ + scope: Network.Mainnet, + toAddress: undefined, + senderAddress, + }); + + expect(fee.trxFee.toString()).toBe('2.2'); + }, + ); + }); + + it('returns no activation cost when the destination is already activated', async () => { + await withFeeCalculatorService( + async ({ feeCalculatorService, trongridApiClient, tronHttpClient }) => { + trongridApiClient.getAccountInfoByAddress.mockResolvedValue({ + address: 'TExistingActiveAddress123456789012', + balance: 1000000, + } as TronAccount); + + const fee = await feeCalculatorService.getAccountActivationFee({ + scope: Network.Mainnet, + toAddress: 'TExistingActiveAddress123456789012', + senderAddress, + }); + + expect(fee.trxFee.toString()).toBe('0'); + expect(fee.bandwidthNeeded.toString()).toBe('0'); + expect(tronHttpClient.getAccountResources).not.toHaveBeenCalled(); + }, + ); + }); + + it('charges activation when the destination is not activated', async () => { + await withFeeCalculatorService( + async ({ feeCalculatorService, trongridApiClient }) => { + trongridApiClient.getAccountInfoByAddress.mockRejectedValue( + new TrongridAccountNotFoundError(), + ); + + const fee = await feeCalculatorService.getAccountActivationFee({ + scope: Network.Mainnet, + toAddress: 'TNewAddress123456789012345678901234', + senderAddress, + }); + + expect(fee.trxFee.toString()).toBe('1.1'); + }, + ); + }); + + it('throws when the activation lookup fails for a reason other than not-found', async () => { + await withFeeCalculatorService( + async ({ feeCalculatorService, trongridApiClient, snapClient }) => { + const error = new Error('network error'); + trongridApiClient.getAccountInfoByAddress.mockRejectedValue(error); + + await expect( + feeCalculatorService.getAccountActivationFee({ + scope: Network.Mainnet, + toAddress: 'TExistingAddress12345678901234567', + senderAddress, + }), + ).rejects.toThrow(FeeUnavailableError); + + expect(snapClient.trackError).toHaveBeenCalledWith(error); + }, + ); + }); + + 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.getAccountActivationFee({ + scope: Network.Mainnet, + toAddress: undefined, + senderAddress, + }), + ).rejects.toThrow(FeeUnavailableError); + }, + ); + }); + }); + describe('computeFee', () => { const expectedMainnetBandwidthFee = { type: FeeType.Base, @@ -978,7 +1127,7 @@ describe('FeeCalculatorService', () => { ); }); - it('tracks unexpected errors when account activation check fails', async () => { + it('fails and tracks unexpected errors when the account activation check fails', async () => { await withFeeCalculatorService( async ({ feeCalculatorService, @@ -991,14 +1140,55 @@ describe('FeeCalculatorService', () => { trongridApiClient.getAccountInfoByAddress.mockRejectedValue(error); mockStakedBandwidth(tronHttpClient, 1000); - await feeCalculatorService.computeFee({ + await expect( + feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction: getTransactionExample('native'), + availableEnergy: ZERO, + availableBandwidth: BigNumber(1000000), + }), + ).rejects.toThrow(FeeUnavailableError); + + expect(snapClient.trackError).toHaveBeenCalledWith(error); + }, + ); + }); + + it('does not check activation when skipAccountActivation is set', async () => { + await withFeeCalculatorService( + async ({ feeCalculatorService, trongridApiClient }) => { + const result = await feeCalculatorService.computeFee({ scope: Network.Mainnet, transaction: getTransactionExample('native'), availableEnergy: ZERO, availableBandwidth: BigNumber(1000000), + skipAccountActivation: true, }); - expect(snapClient.trackError).toHaveBeenCalledWith(error); + // No activation fee is added and no recipient lookup happens. + expect( + trongridApiClient.getAccountInfoByAddress, + ).not.toHaveBeenCalled(); + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '0', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + amount: '266', + fungible: true, + }, + }, + ]); }, ); }); diff --git a/packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts b/packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts index 936f7f89c..861aecfe0 100644 --- a/packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts +++ b/packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts @@ -28,6 +28,7 @@ import { } from '../../constants'; import { FeeUnavailableError } from './errors'; import type { + AccountActivationFee, ActivationAssessment, ActivationTransfer, ComputeFeeResult, @@ -281,21 +282,31 @@ export class FeeCalculatorService { * Check if an account is activated (exists on the network). * An account is considered not activated if it has never received any assets. * + * A failed lookup is not the same as an inactive account: treating it as one + * would silently charge an activation fee. Fail loudly instead. + * * @param scope - The network scope to check * @param address - The TRON address to check - * @returns Promise - True if the account is activated, false otherwise + * @returns Promise - True if the account is activated, false if it + * does not exist yet + * @throws {@link FeeUnavailableError} when activation cannot be determined */ async #isAccountActivated(scope: Network, address: string): Promise { try { await this.#trongridApiClient.getAccountInfoByAddress(scope, address); return true; } catch (error) { - if (!(error instanceof TrongridAccountNotFoundError)) { - await this.#snapClient.trackError(error as Error); + if (error instanceof TrongridAccountNotFoundError) { + this.#logger.log(`Account ${address} is not activated on ${scope}`); + return false; } - // If the account is not found, it means it's not activated - this.#logger.log(`Account ${address} is not activated on ${scope}`); - return false; + + await this.#snapClient.trackError(error as Error); + this.#logger.warn( + { error }, + `Failed to determine activation for ${address} on ${scope}`, + ); + throw new FeeUnavailableError(); } } @@ -811,6 +822,86 @@ export class FeeCalculatorService { }; } + /** + * Activation cost for a recipient that is known, or assumed, to be inactive. + * + * The account-creation burn always applies. The create-account Bandwidth + * quota is either consumed from the sender's staked Bandwidth, or replaced + * by the `getCreateAccountFee` burn when there is not enough of it. Free + * daily Bandwidth cannot pay for activation. + * + * @param options - The activation context. + * @param options.scope - The network scope to resolve chain parameters for. + * @param options.isActivated - Whether the recipient already exists on-chain. + * @param options.senderAddress - The transaction owner (hex or Base58). + * @returns The TRX fee and Bandwidth consumed by activation. + */ + async #calculateActivationFee({ + scope, + isActivated, + senderAddress, + }: { + scope: Network; + isActivated: boolean; + senderAddress: string | undefined; + }): Promise { + if (isActivated) { + return { trxFee: ZERO, bandwidthNeeded: ZERO }; + } + + const { activationFeeTrx, createAccountFeeTrx, createAccountBandwidth } = + this.#getActivationFeeParams(await this.#getChainParameters(scope)); + + const stakedBandwidth = await this.#getSenderStakedBandwidth( + scope, + senderAddress, + ); + + if (stakedBandwidth.isGreaterThanOrEqualTo(createAccountBandwidth)) { + return { + trxFee: activationFeeTrx, + bandwidthNeeded: createAccountBandwidth, + }; + } + + return { + trxFee: activationFeeTrx.plus(createAccountFeeTrx), + bandwidthNeeded: ZERO, + }; + } + + /** + * Account activation fee for a send. + * + * Omitting `toAddress` means the destination is not known yet, so the + * recipient is conservatively assumed to be inactive. + * + * @param options - The activation context. + * @param options.scope - The network scope to resolve chain parameters for. + * @param options.toAddress - The recipient address, if known. + * @param options.senderAddress - The transaction owner (hex or Base58). + * @returns The TRX fee and Bandwidth consumed by activation. + */ + async getAccountActivationFee({ + scope, + toAddress, + senderAddress, + }: { + scope: Network; + toAddress: string | undefined; + senderAddress: string | undefined; + }): Promise { + const isActivated = + toAddress !== undefined && + (await this.#isAccountActivated(scope, toAddress)); + + return this.#calculateActivationFee({ + scope, + isActivated, + senderAddress, + }); + } + /** * Calculate memo/note fee for the transaction. * Per Tron protocol, adding a memo/note incurs a flat 1 TRX fee. @@ -900,6 +991,9 @@ export class FeeCalculatorService { * @param params.availableEnergy - Available energy from account * @param params.availableBandwidth - Available bandwidth from account * @param params.feeLimit - Optional fee limit in SUN to use for fallback energy calculation + * @param params.skipAccountActivation - Skip recipient activation assessment. + * Use for quote-only probes whose recipient is not the real destination; the + * caller adds the activation reserve itself. * @returns Promise - Complete fee breakdown */ async computeFee({ @@ -908,12 +1002,14 @@ export class FeeCalculatorService { availableEnergy, availableBandwidth, feeLimit, + skipAccountActivation = false, }: { scope: Network; transaction: Transaction; availableEnergy: BigNumber; availableBandwidth: BigNumber; feeLimit?: number; + skipAccountActivation?: boolean; }): Promise { this.#logger.log( 'Calculating fee for transaction ', @@ -927,54 +1023,30 @@ export class FeeCalculatorService { feeLimit, ); - const activation = await this.#assessAccountActivation({ - scope, - transaction, - }); - const { isActivatingAccount } = activation; + const activation = skipAccountActivation + ? { isActivatingAccount: false, ownerAddress: undefined } + : await this.#assessAccountActivation({ + scope, + transaction, + }); + + /** + * Calculate the total TRX cost from all sources... + */ + let totalTrxCost = ZERO; let bandwidthNeeded: BigNumber; let bandwidthToPayInTRX = ZERO; - let accountActivationFees = ZERO; - let createAccountBandwidthFee = ZERO; - if (isActivatingAccount) { - const chainParameters = await this.#getChainParameters(scope); - const { activationFeeTrx, createAccountFeeTrx, createAccountBandwidth } = - this.#getActivationFeeParams(chainParameters); - - accountActivationFees = activationFeeTrx; - - const stakedBandwidth = await this.#getSenderStakedBandwidth( + if (activation.isActivatingAccount) { + const activationFee = await this.#calculateActivationFee({ scope, - activation.ownerAddress, - ); - const hasEnoughStakedBandwidth = stakedBandwidth.isGreaterThanOrEqualTo( - createAccountBandwidth, - ); + isActivated: false, + senderAddress: activation.ownerAddress, + }); - if (hasEnoughStakedBandwidth) { - bandwidthNeeded = createAccountBandwidth; - this.#logger.log( - { - createAccountBandwidth: createAccountBandwidth.toString(), - stakedBandwidth: stakedBandwidth.toString(), - }, - 'Account activation covered by staked Bandwidth', - ); - } else { - // Free daily Bandwidth cannot pay for activation. Burn getCreateAccountFee - // instead of tx-size * getTransactionFee. - bandwidthNeeded = ZERO; - createAccountBandwidthFee = createAccountFeeTrx; - this.#logger.log( - { - createAccountFeeTrx: createAccountFeeTrx.toString(), - stakedBandwidth: stakedBandwidth.toString(), - }, - 'Account activation Bandwidth shortfall paid in TRX', - ); - } + bandwidthNeeded = activationFee.bandwidthNeeded; + totalTrxCost = totalTrxCost.plus(activationFee.trxFee); } else { bandwidthNeeded = this.#calculateBandwidth(transaction); const hasEnoughBandwidth = @@ -987,11 +1059,6 @@ export class FeeCalculatorService { ZERO, ); - /** - * Calculate the total TRX cost from all sources... - */ - let totalTrxCost = ZERO; - /** * First, overages in bandwidth and energy */ @@ -1020,18 +1087,7 @@ export class FeeCalculatorService { } /** - * Second, account activation fees (1 TRX burn + optional 0.1 TRX Bandwidth shortfall) - */ - if (accountActivationFees.isGreaterThan(0)) { - totalTrxCost = totalTrxCost.plus(accountActivationFees); - } - - if (createAccountBandwidthFee.isGreaterThan(0)) { - totalTrxCost = totalTrxCost.plus(createAccountBandwidthFee); - } - - /** - * Third, memo/note fee + * Second, memo/note fee */ const memoFee = this.#memoFee(transaction); @@ -1040,7 +1096,7 @@ export class FeeCalculatorService { } /** - * Fourth, WitnessCreateContract account-upgrade burn (default 9,999 TRX) + * Third, WitnessCreateContract account-upgrade burn (default 9,999 TRX) */ const accountUpgradeFee = await this.#accountUpgradeFee({ scope, diff --git a/packages/tron-wallet-snap/src/services/send/types.ts b/packages/tron-wallet-snap/src/services/send/types.ts index 9f60ea225..b32dc2ace 100644 --- a/packages/tron-wallet-snap/src/services/send/types.ts +++ b/packages/tron-wallet-snap/src/services/send/types.ts @@ -1,4 +1,5 @@ import type { FeeType } from '@metamask/keyring-api'; +import type { BigNumber } from 'bignumber.js'; import type { SendErrorCodes } from '../../handlers/clientRequest/types'; import type { @@ -37,6 +38,11 @@ export type ActivationAssessment = { ownerAddress: string | undefined; }; +export type AccountActivationFee = { + trxFee: BigNumber; + bandwidthNeeded: BigNumber; +}; + export type SendValidationErrorCode = | (typeof SendErrorCodes)['InsufficientBalance'] | (typeof SendErrorCodes)['InsufficientBalanceToCoverFee'];