UNPKG

@ledgerhq/coin-tezos

Version:
359 lines (338 loc) 13.5 kB
// SPDX-FileCopyrightText: © 2026 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 import type { TezosCoinConfig, TezosContext } from '../config' import type { TezosOperationMode } from '../types/model' import type { Estimate } from '@taquito/taquito' import { log } from '@ledgerhq/logs' import { COST_PER_BYTE, getRevealFee, ORIGINATION_SIZE } from '@taquito/taquito' import { validatePublicKey, ValidationResult } from '@taquito/utils' import { UnsupportedTransactionMode } from '../types/errors' import { computeMaxStakeAmount, createFallbackEstimation, createMockSigner, DUST_MARGIN_MUTEZ, MIN_SUGGESTED_FEE_SMALL_TRANSFER, OP_SIZE_XTZ_TRANSFER, normalizePublicKeyForAddress, partitionNativeBalance, } from '../utils' import { getTezosToolkit } from './tezosToolkit' export type CoreAccountInfo = { address: string balance: bigint stakedBalance?: bigint unstakedBalance?: bigint revealed: boolean xpub?: string } export type CoreTransactionInfo = { mode: TezosOperationMode recipient: string amount: bigint useAllAmount?: boolean /** FA2 token contract; required when `mode` is `send_token` */ contractAddress?: string /** FA2 token id; required when `mode` is `send_token` */ tokenId?: number } export type EstimatedFees = { fees: bigint gasLimit: bigint storageLimit: bigint estimatedFees: bigint amount?: bigint taquitoError?: string } /** * Fetch the transaction fees for a transaction * * @param {Account} account * @param {Transaction} transaction */ export async function estimateFees( context: TezosContext, { account, transaction, }: { account: CoreAccountInfo transaction: CoreTransactionInfo } ): Promise<EstimatedFees> { const config = await context.config() // Normalize public key (hex -> base58) when provided (may be undefined for unrevealed accounts) // before the device is connected const encodedPubKey = account.xpub ? normalizePublicKeyForAddress(account.xpub, account.address) : undefined const tezosToolkit = getTezosToolkit(config) if (encodedPubKey && validatePublicKey(encodedPubKey) === ValidationResult.VALID) { tezosToolkit.setProvider({ signer: createMockSigner(account.address, encodedPubKey) }) } else { tezosToolkit.setProvider({ signer: createMockSigner(account.address, '') }) } const estimation: EstimatedFees = { fees: 0n, gasLimit: 0n, storageLimit: 0n, estimatedFees: 0n, } // For legacy compatibility if (account.balance === 0n) { return transaction.useAllAmount ? { ...estimation, amount: 0n } : estimation } const spendableForMax = Number( partitionNativeBalance( account.balance, account.stakedBalance ?? 0n, account.unstakedBalance ?? 0n ).spendable ) let amount = transaction.amount const coerceMinAmountForEstimation = (transaction.useAllAmount && (transaction.mode === 'send' || transaction.mode === 'stake' || transaction.mode === 'unstake')) || (amount === 0n && transaction.mode !== 'send_token') if (coerceMinAmountForEstimation) { amount = 1n // send/stake max or zero-amount pre-estimation (taquito refuses 0); not used for FA2 send_token } try { if ( (transaction.mode === 'send' || transaction.mode === 'send_token') && !transaction.recipient ) { return { ...estimation, ...createFallbackEstimation(config), } } let estimate: Estimate switch (transaction.mode) { case 'send': estimate = await tezosToolkit.estimate.transfer({ mutez: true, to: transaction.recipient, amount: Number(amount), source: account.address, // avoid requiring signer for estimation storageLimit: ORIGINATION_SIZE, // https://github.com/TezTech/eztz/blob/master/PROTO_003_FEES.md for originating an account }) break case 'delegate': estimate = await tezosToolkit.estimate.setDelegate({ source: account.address, delegate: transaction.recipient, }) break case 'undelegate': estimate = await tezosToolkit.estimate.setDelegate({ source: account.address, }) break case 'stake': estimate = await tezosToolkit.estimate.stake({ amount: Number(amount), mutez: true, }) break case 'unstake': estimate = await tezosToolkit.estimate.unstake({ amount: Number(amount), mutez: true, }) break case 'finalize_unstake': estimate = await tezosToolkit.estimate.finalizeUnstake({}) break case 'send_token': { if (!transaction.contractAddress || transaction.tokenId === undefined) { throw new Error('FA2 transfer requires contractAddress and tokenId') } const tokenContract = await tezosToolkit.contract.at(transaction.contractAddress) const transferParams = tokenContract.methods .transfer([ { from_: account.address, txs: [ { to_: transaction.recipient, token_id: transaction.tokenId, amount: amount, }, ], }, ]) .toTransferParams({ mutez: true }) estimate = await tezosToolkit.estimate.transfer({ ...transferParams, source: account.address, }) break } default: throw new UnsupportedTransactionMode('unsupported mode', { mode: transaction.mode }) } const minFees = config.fees.minFees ?? 0 const mainOpFee = Math.max(minFees, estimate.suggestedFeeMutez) const revealFee = account.revealed ? 0n : BigInt(getRevealFeeForEstimation(config, account.address)) // NOTE: send-max only applies to native XTZ transfer, not FA2 if (transaction.useAllAmount && transaction.mode === 'send') { // Reserve `mainOpFee`, not the raw taquito suggestion: `estimatedFees` below reports the // `minFees` floor, so subtracting the (lower) suggestion here yields an amount whose // `amount + estimatedFees` exceeds the spendable balance, and validateIntent then rejects // the very max it was handed (LIVE-28506). // NOTE: from https://github.com/ecadlabs/taquito/blob/master/integration-tests/__tests__/contract/empty-implicit-account-into-new-implicit-account.spec.ts#L37 const totalFees = estimate.burnFeeMutez > 0 ? mainOpFee + estimate.burnFeeMutez - 20 * COST_PER_BYTE // 20 is storage buffer : mainOpFee const maxAmount = spendableForMax - (totalFees + Number(revealFee)) // NOTE: from https://github.com/ecadlabs/taquito/blob/a70c64c4b105381bb9f1d04c9c70e8ef26e9241c/integration-tests/contract-empty-implicit-account-into-new-implicit-account.spec.ts#L33 // Temporary fix, see https://gitlab.com/tezos/tezos/-/issues/1754 // we need to increase the gasLimit and fee returned by the estimation const MINIMAL_FEE_PER_GAS_MUTEZ = 0.1 const incr = DUST_MARGIN_MUTEZ * MINIMAL_FEE_PER_GAS_MUTEZ + Number(estimate.opSize) const maxMinusBuff = maxAmount - (DUST_MARGIN_MUTEZ - incr) estimation.amount = maxMinusBuff > 0 ? BigInt(maxMinusBuff) : 0n log('tezos-send-max', 'send-max fee inputs', { minFees, mainOpFee, suggestedFeeMutez: estimate.suggestedFeeMutez, burnFeeMutez: estimate.burnFeeMutez, opSize: Number(estimate.opSize), revealFee: Number(revealFee), }) } else if (transaction.useAllAmount && transaction.mode === 'stake') { estimation.amount = computeMaxStakeAmount( BigInt(account.balance), account.stakedBalance ?? 0n, account.unstakedBalance ?? 0n, BigInt(mainOpFee) + revealFee ) } else if (transaction.useAllAmount && transaction.mode === 'unstake') { // unstake-max draws from the staked balance, not the spendable balance estimation.amount = account.stakedBalance ?? 0n } else { estimation.amount = transaction.amount } estimation.fees = BigInt(mainOpFee) estimation.gasLimit = BigInt(estimate.gasLimit) estimation.storageLimit = BigInt(estimate.storageLimit) estimation.estimatedFees = estimation.fees + revealFee } catch (e) { if (typeof e !== 'object' || !e) throw e if ('id' in e) { estimation.taquitoError = (e as { id: string }).id log('taquito-error', 'taquito got error ' + e.id) } else if ('status' in e) { const errorMessage = String((e as unknown as { message: string }).message || '') if ( errorMessage.includes('Public key not found') || errorMessage.includes('wallet or contract API') ) { log( 'taquito-network-error', 'Recipient address not found (new account), using default fees', { transaction: transaction, } ) const fallback = createFallbackEstimation(config) estimation.fees = fallback.fees estimation.gasLimit = fallback.gasLimit estimation.storageLimit = fallback.storageLimit estimation.estimatedFees = fallback.fees if (!account.revealed) { estimation.estimatedFees = estimation.estimatedFees + BigInt(getRevealFeeForEstimation(config, account.address)) } // Handle useAllAmount also for send mode when estimation falls back if (transaction.useAllAmount && transaction.mode === 'send') { // Approximate Taquito behavior for send-max using stable constants const suggestedFee = transaction.mode === 'send' ? MIN_SUGGESTED_FEE_SMALL_TRANSFER : Number(estimation.fees) // For display consistency in tests, align fees to suggestedFee in send-max if (transaction.mode === 'send') { estimation.fees = BigInt(suggestedFee) estimation.estimatedFees = BigInt(suggestedFee) if (!account.revealed) { estimation.estimatedFees = estimation.estimatedFees + BigInt(getRevealFeeForEstimation(config, account.address)) } } const burnFeeMutez = Number(estimation.storageLimit) * COST_PER_BYTE const totalFees = suggestedFee + (burnFeeMutez > 0 ? burnFeeMutez - 20 * COST_PER_BYTE : 0) const revealFee = account.revealed ? 0 : getRevealFeeForEstimation(config, account.address) const maxAmount = spendableForMax - (totalFees + revealFee) const MINIMAL_FEE_PER_GAS_MUTEZ = 0.1 const incr = OP_SIZE_XTZ_TRANSFER + DUST_MARGIN_MUTEZ * MINIMAL_FEE_PER_GAS_MUTEZ const maxMinusBuff = maxAmount - (DUST_MARGIN_MUTEZ - incr) estimation.amount = maxMinusBuff > 0 ? BigInt(Math.floor(maxMinusBuff)) : 0n } else { // preserve input amount in fallback for readability/tests estimation.amount = transaction.amount } } else { log('taquito-network-error', errorMessage, { transaction: transaction, }) throw e } } else { const msg = String((e as any).message || '') if (msg.includes('No signer has been configured')) { const fallback = createFallbackEstimation(config) estimation.fees = fallback.fees estimation.gasLimit = fallback.gasLimit estimation.storageLimit = fallback.storageLimit estimation.estimatedFees = fallback.estimatedFees if (!account.revealed) { estimation.estimatedFees = estimation.estimatedFees + BigInt(getRevealFeeForEstimation(config, account.address)) } if (transaction.useAllAmount && transaction.mode === 'send') { const suggestedFee = transaction.mode === 'send' ? MIN_SUGGESTED_FEE_SMALL_TRANSFER : Number(estimation.fees) if (transaction.mode === 'send') { estimation.fees = BigInt(suggestedFee) estimation.estimatedFees = BigInt(suggestedFee) if (!account.revealed) { estimation.estimatedFees = estimation.estimatedFees + BigInt(getRevealFeeForEstimation(config, account.address)) } } const burnFeeMutez = Number(estimation.storageLimit) * COST_PER_BYTE const totalFees = suggestedFee + (burnFeeMutez > 0 ? burnFeeMutez - 20 * COST_PER_BYTE : 0) const revealFee = account.revealed ? 0 : getRevealFeeForEstimation(config, account.address) const maxAmount = spendableForMax - (totalFees + revealFee) const MINIMAL_FEE_PER_GAS_MUTEZ = 0.1 const incr = OP_SIZE_XTZ_TRANSFER + DUST_MARGIN_MUTEZ * MINIMAL_FEE_PER_GAS_MUTEZ const maxMinusBuff = maxAmount - (DUST_MARGIN_MUTEZ - incr) estimation.amount = maxMinusBuff > 0 ? BigInt(Math.floor(maxMinusBuff)) : 0n } else { // preserve input amount in fallback for readability/tests estimation.amount = transaction.amount } } else { throw e } } } return estimation } function getRevealFeeForEstimation(config: TezosCoinConfig, address: string): number { const minFees = config.fees.minFees ?? 0 return Math.max(minFees, getRevealFee(address)) }