diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 3e958920..ee52a666 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -321,14 +321,6 @@ "count": 1 } }, - "packages/tron-wallet-snap/src/services/send/FeeCalculatorService.test.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 1 - }, - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, "packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts": { "no-restricted-syntax": { "count": 1 diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index 7f35c742..d6439316 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Disclose the mandatory 9,999 TRX `WitnessCreateContract` account-upgrade burn on confirmation ([#73](https://github.com/MetaMask/internal-snaps/pull/73)) + - Include `getAccountUpgradeCost` in fee calculation so Network fee and insufficient-balance checks reflect the irreversible burn + - Replace the generic "Unsupported contract for simulation" copy with Super Representative candidate disclosure for this contract type + ## [2.0.0] ### Changed diff --git a/packages/tron-wallet-snap/locales/en.json b/packages/tron-wallet-snap/locales/en.json index ff2835dd..7111ee22 100644 --- a/packages/tron-wallet-snap/locales/en.json +++ b/packages/tron-wallet-snap/locales/en.json @@ -64,6 +64,9 @@ "confirmation.estimatedChanges.unsupportedContract": { "message": "Unsupported contract for simulation" }, + "confirmation.estimatedChanges.witnessCreate": { + "message": "Registers your account as a Super Representative candidate and permanently burns 9,999 TRX." + }, "confirmation.simulationTitleAPIError": { "message": "Because of an error, we couldn't check for security alerts." }, diff --git a/packages/tron-wallet-snap/locales/es.json b/packages/tron-wallet-snap/locales/es.json index 10c56146..7c707a0d 100644 --- a/packages/tron-wallet-snap/locales/es.json +++ b/packages/tron-wallet-snap/locales/es.json @@ -64,6 +64,9 @@ "confirmation.estimatedChanges.unsupportedContract": { "message": "Contrato no compatible con simulación" }, + "confirmation.estimatedChanges.witnessCreate": { + "message": "Registra tu cuenta como candidato a Super Representante y quema permanentemente 9.999 TRX." + }, "confirmation.simulationTitleAPIError": { "message": "Debido a un error, no pudimos verificar alertas de seguridad." }, diff --git a/packages/tron-wallet-snap/messages.json b/packages/tron-wallet-snap/messages.json index 52c6e271..1f26af06 100644 --- a/packages/tron-wallet-snap/messages.json +++ b/packages/tron-wallet-snap/messages.json @@ -62,6 +62,9 @@ "confirmation.estimatedChanges.unsupportedContract": { "message": "Unsupported contract for simulation" }, + "confirmation.estimatedChanges.witnessCreate": { + "message": "Registers your account as a Super Representative candidate and permanently burns 9,999 TRX." + }, "confirmation.simulationTitleAPIError": { "message": "Because of an error, we couldn't check for security alerts." }, diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index 82993016..5f3cab2a 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": "0TZh63x04Gncx8UgHBoWnQ4RmX6kmTL6RmJTyqgJ7G8=", + "shasum": "9qRM4qCjRIdWCy/txZ3wHH42cMj7UsDKpqyhJ4R69Dk=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/tron-wallet-snap/src/constants/index.ts b/packages/tron-wallet-snap/src/constants/index.ts index 7dbb15d0..10d7293a 100644 --- a/packages/tron-wallet-snap/src/constants/index.ts +++ b/packages/tron-wallet-snap/src/constants/index.ts @@ -8,6 +8,12 @@ export const SUN_IN_TRX = 1_000_000; export const FALLBACK_GET_TRANSACTION_FEE_SUN = 1000; export const FALLBACK_GET_ENERGY_FEE_SUN = 100; export const FALLBACK_ENERGY_PRICE_SUN = 420; +/** + * Default `getAccountUpgradeCost` (WitnessCreateContract burn) in SUN = 9,999 TRX. + * + * @see https://developers.tron.network/docs/super-representatives + */ +export const FALLBACK_ACCOUNT_UPGRADE_COST_SUN = 9_999_000_000; /** * 101 TRX */ 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 8d28c79b..7f52cea9 100644 --- a/packages/tron-wallet-snap/src/services/send/FeeCalculatorService.test.ts +++ b/packages/tron-wallet-snap/src/services/send/FeeCalculatorService.test.ts @@ -1,5 +1,6 @@ import { FeeType } from '@metamask/keyring-api'; import { BigNumber } from 'bignumber.js'; +import type { Transaction } from 'tronweb/lib/esm/types'; import type { SnapClient } from '../../clients/snap/SnapClient'; import type { TriggerConstantContractResponse } from '../../clients/tron-http'; @@ -19,7 +20,9 @@ import { FeeUnavailableError } from './errors'; import { FeeCalculatorService } from './FeeCalculatorService'; // Helper to get transaction examples in the expected format -const getTransactionExample = (type: 'native' | 'trc10' | 'trc20'): any => { +const getTransactionExample = ( + type: 'native' | 'trc10' | 'trc20', +): Transaction => { let mockData; switch (type) { case 'native': @@ -42,11 +45,11 @@ const getTransactionExample = (type: 'native' | 'trc10' | 'trc20'): any => { txID: mockData.txID, raw_data_hex: mockData.raw_data_hex, raw_data: mockData.raw_data, - }; + } as Transaction; }; // Helper to create a large transaction by modifying the TRC20 example -const createLargeTransaction = (): any => { +const createLargeTransaction = (): Transaction => { const baseTransaction = getTransactionExample('trc20'); // Modify the data field to be much larger to simulate bandwidth issues const largeData = 'b'.repeat(2000); @@ -238,7 +241,9 @@ describe('FeeCalculatorService', () => { describe('System contract scenarios (no energy needed)', () => { // Helper to create a mock transaction with a specific contract type - const createSystemContractTransaction = (contractType: string): any => { + const createSystemContractTransaction = ( + contractType: string, + ): Transaction => { const baseTransaction = getTransactionExample('native'); return { ...baseTransaction, @@ -2168,12 +2173,197 @@ describe('FeeCalculatorService', () => { }); }); + describe('WitnessCreateContract account upgrade fee scenarios', () => { + const createWitnessCreateTransaction = (): Transaction => { + const base = getTransactionExample('native'); + return { + ...base, + raw_data: { + ...base.raw_data, + contract: [ + { + parameter: { + value: { + owner_address: '41a7d8a35b260395c14aa456297662092ba3b76fc0', + url: '68747470733a2f2f6578616d706c652e636f6d', + }, + type_url: + 'type.googleapis.com/protocol.WitnessCreateContract', + }, + type: 'WitnessCreateContract', + }, + ], + }, + }; + }; + + it('adds 9999 TRX account upgrade fee for WitnessCreateContract when chain param is present', async () => { + await withFeeCalculatorService( + async ({ feeCalculatorService, trongridApiClient }) => { + trongridApiClient.getChainParameters.mockResolvedValue([ + { key: 'getTransactionFee', value: 1000 }, + { key: 'getEnergyFee', value: 100 }, + { key: 'getAccountUpgradeCost', value: 9_999_000_000 }, + ]); + + const transaction = createWitnessCreateTransaction(); + const availableEnergy = ZERO; + const availableBandwidth = BigNumber(1000000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '9999', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + amount: '266', + fungible: true, + }, + }, + ]); + }, + ); + }); + + it('falls back to 9999 TRX when getAccountUpgradeCost is missing from chain parameters', async () => { + await withFeeCalculatorService( + async ({ feeCalculatorService, trongridApiClient }) => { + trongridApiClient.getChainParameters.mockResolvedValue([ + { key: 'getTransactionFee', value: 1000 }, + { key: 'getEnergyFee', value: 100 }, + ]); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction: createWitnessCreateTransaction(), + availableEnergy: ZERO, + availableBandwidth: BigNumber(1000000), + }); + + expect(result[0]).toStrictEqual({ + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '9999', + fungible: true, + }, + }); + }, + ); + }); + + it('adds account upgrade fee on top of bandwidth TRX cost', async () => { + await withFeeCalculatorService( + async ({ feeCalculatorService, trongridApiClient }) => { + trongridApiClient.getChainParameters.mockResolvedValue([ + { key: 'getTransactionFee', value: 1000 }, + { key: 'getEnergyFee', value: 100 }, + { key: 'getAccountUpgradeCost', value: 9_999_000_000 }, + ]); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction: createWitnessCreateTransaction(), + availableEnergy: ZERO, + availableBandwidth: ZERO, + }); + + // Bandwidth: 266 * 1000 SUN = 0.266 TRX + 9999 TRX upgrade = 9999.266 TRX + expect(result[0]).toStrictEqual({ + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '9999.266', + fungible: true, + }, + }); + }, + ); + }); + + it('uses the on-chain getAccountUpgradeCost value when it differs from the default', async () => { + await withFeeCalculatorService( + async ({ feeCalculatorService, trongridApiClient }) => { + trongridApiClient.getChainParameters.mockResolvedValue([ + { key: 'getTransactionFee', value: 1000 }, + { key: 'getEnergyFee', value: 100 }, + { key: 'getAccountUpgradeCost', value: 5_000_000_000 }, // 5000 TRX + ]); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction: createWitnessCreateTransaction(), + availableEnergy: ZERO, + availableBandwidth: BigNumber(1000000), + }); + + expect(result[0]?.asset.amount).toBe('5000'); + }, + ); + }); + + it('does not add account upgrade fee for non-WitnessCreate contracts', async () => { + await withFeeCalculatorService(async ({ feeCalculatorService }) => { + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction: getTransactionExample('native'), + availableEnergy: ZERO, + availableBandwidth: BigNumber(1000000), + }); + + expect(result[0]?.asset.amount).toBe('0'); + }); + }); + + it('falls back to 9999 TRX when chain parameters are unavailable', async () => { + await withFeeCalculatorService( + async ({ feeCalculatorService, trongridApiClient }) => { + trongridApiClient.getChainParameters.mockRejectedValue( + new Error('TronGrid unavailable'), + ); + trongridApiClient.peekCachedChainParameters.mockResolvedValue( + undefined, + ); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction: createWitnessCreateTransaction(), + availableEnergy: ZERO, + availableBandwidth: BigNumber(1000000), + }); + + // Enough bandwidth so we never need getTransactionFee; upgrade fee + // still discloses the default burn via fallback. + expect(result[0]?.asset.amount).toBe('9999'); + }, + ); + }); + }); + describe('Memo fee scenarios', () => { // Helper to add a memo (raw_data.data) to a transaction const addMemoToTransaction = ( - transaction: any, + transaction: Transaction, memoHex: string, - ): any => ({ + ): Transaction => ({ ...transaction, raw_data: { ...transaction.raw_data, @@ -2382,7 +2572,11 @@ describe('FeeCalculatorService', () => { describe('Graceful failure when TronGrid is unavailable', () => { // Native transfer with insufficient bandwidth so a TRX fee is owed and // the chain-params conversion path is exercised. - const buildNativeTxWithBandwidthOverage = () => ({ + const buildNativeTxWithBandwidthOverage = (): { + transaction: Transaction; + availableEnergy: BigNumber; + availableBandwidth: BigNumber; + } => ({ transaction: getTransactionExample('native'), availableEnergy: ZERO, availableBandwidth: BigNumber(100), // < 266 bytes needed diff --git a/packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts b/packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts index d324bf70..3ce69dc2 100644 --- a/packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts +++ b/packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts @@ -14,6 +14,7 @@ import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient import type { Network } from '../../constants'; import { ACCOUNT_ACTIVATION_FEE_TRX, + FALLBACK_ACCOUNT_UPGRADE_COST_SUN, FALLBACK_ENERGY_PRICE_SUN, FALLBACK_GET_ENERGY_FEE_SUN, FALLBACK_GET_TRANSACTION_FEE_SUN, @@ -707,6 +708,64 @@ export class FeeCalculatorService { return ZERO; } + /** + * Calculate the irreversible account-upgrade burn for WitnessCreateContract. + * + * Registering as a Super Representative candidate burns + * `getAccountUpgradeCost` TRX (default 9,999). This is a protocol-level debit + * separate from bandwidth/energy, and must be disclosed before signing. + * + * @see https://developers.tron.network/docs/super-representatives + * @param options - The options object + * @param options.scope - The network scope to resolve chain parameters for + * @param options.transaction - The transaction to inspect + * @returns Promise - The upgrade burn fee in TRX (0 if not WitnessCreate) + */ + async #accountUpgradeFee({ + scope, + transaction, + }: { + scope: Network; + transaction: Transaction; + }): Promise { + const contracts = transaction.raw_data.contract; + + if (!contracts || contracts.length === 0) { + return ZERO; + } + + const hasWitnessCreate = contracts.some( + (contract) => (contract.type as string) === 'WitnessCreateContract', + ); + + if (!hasWitnessCreate) { + return ZERO; + } + + let upgradeCostSun = FALLBACK_ACCOUNT_UPGRADE_COST_SUN; + + try { + const chainParameters = await this.#getChainParameters(scope); + upgradeCostSun = + chainParameters.find((param) => param.key === 'getAccountUpgradeCost') + ?.value ?? FALLBACK_ACCOUNT_UPGRADE_COST_SUN; + } catch (error) { + // Still disclose the default burn when chain parameters are unavailable. + this.#logger.warn( + { error }, + 'Failed to fetch account upgrade cost, using fallback 9999 TRX', + ); + } + + const upgradeCostTrx = BigNumber(upgradeCostSun).div(SUN_IN_TRX); + + this.#logger.log( + `WitnessCreateContract detected, adding ${upgradeCostTrx.toString()} TRX account upgrade fee`, + ); + + return upgradeCostTrx; + } + /** * Calculate complete fee breakdown for a TRON transaction. * Supports both signed and unsigned transactions. @@ -813,6 +872,18 @@ export class FeeCalculatorService { totalTrxCost = totalTrxCost.plus(memoFee); } + /** + * Fourth, WitnessCreateContract account-upgrade burn (default 9,999 TRX) + */ + const accountUpgradeFee = await this.#accountUpgradeFee({ + scope, + transaction, + }); + + if (accountUpgradeFee.isGreaterThan(0)) { + totalTrxCost = totalTrxCost.plus(accountUpgradeFee); + } + /** * Build result array - TRX MUST always be first element, even if 0 */ diff --git a/packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.test.tsx b/packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.test.tsx index 158199ba..708c47e8 100644 --- a/packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.test.tsx +++ b/packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.test.tsx @@ -244,4 +244,39 @@ describe('ConfirmSignTransaction', () => { expect(texts).not.toContain(SIMULATION_ERROR_TITLE); }); + + it('shows unsupported-contract copy for skipped simulations of unknown contracts', () => { + const texts = renderTexts( + buildContext({ + transaction: { + rawDataHex: '0a02beef', + type: 'VoteWitnessContract', + }, + scan: buildScanResult({ + simulationStatus: SimulationStatus.Skipped, + }), + }), + ); + + expect(texts).toContain('Unsupported contract for simulation'); + }); + + it('discloses WitnessCreateContract operation and 9,999 TRX burn when simulation is skipped', () => { + const texts = renderTexts( + buildContext({ + transaction: { + rawDataHex: '0a02beef', + type: 'WitnessCreateContract', + }, + scan: buildScanResult({ + simulationStatus: SimulationStatus.Skipped, + }), + }), + ); + + expect(texts).not.toContain('Unsupported contract for simulation'); + expect(texts).toContain( + 'Registers your account as a Super Representative candidate and permanently burns 9,999 TRX.', + ); + }); }); diff --git a/packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx b/packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx index cdd6a415..3dfccc74 100644 --- a/packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx +++ b/packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx @@ -62,6 +62,11 @@ export const ConfirmSignTransaction = ({ let estimatedChangesSection: ComponentOrElement | null = null; if (preferences.simulateOnChainActions) { if (scan?.simulationStatus === SimulationStatus.Skipped) { + const skippedChangesMessage = + context.transaction.type === 'WitnessCreateContract' + ? translate('confirmation.estimatedChanges.witnessCreate') + : translate('confirmation.estimatedChanges.unsupportedContract'); + estimatedChangesSection = (
@@ -74,9 +79,7 @@ export const ConfirmSignTransaction = ({ - - {translate('confirmation.estimatedChanges.unsupportedContract')} - + {skippedChangesMessage}
); } else {