@ledgerhq/coin-tezos
Version:
422 lines (394 loc) • 16.9 kB
text/typescript
// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0
import type { TezosCoinConfig, TezosContext } from '../config'
import type { CoreAccountInfo, CoreTransactionInfo, EstimatedFees } from '../logic/estimateFees'
import type { TezosOperationMode } from '../types/model'
import type { TezosAccountInfo, TezosFeeEstimation } from './types'
import type {
CraftedTransaction,
Cursor,
ListOperationsOptions,
Page,
Reward,
Validator,
} from '@ledgerhq/coin-module-framework/api/index'
import type {
BalanceOptions,
CoinModuleApi,
FeeEstimation,
TransactionIntent,
} from '@ledgerhq/coin-module-framework/api/types'
import { rejectBalanceOptions } from '@ledgerhq/coin-module-framework/api/getBalance/rejectBalanceOptions'
import {
IncorrectTypeError,
notSupported,
type Operation,
} from '@ledgerhq/coin-module-framework/api/index'
import { craftTransactionData } from '@ledgerhq/coin-module-framework/logic/craftTransactionData'
import { log } from '@ledgerhq/logs'
import { getRevealFee } from '@taquito/taquito'
import { getPkhfromPk, validatePublicKey, ValidationResult } from '@taquito/utils'
import {
broadcast,
combine,
craftRawOperations,
craftTransaction,
estimateFees,
getBalance,
getBlock,
getBlockInfo,
getStakes,
lastBlock,
listOperations,
rawEncode,
validateIntent,
} from '../logic'
import { getTezosToolkit } from '../logic/tezosToolkit'
import { validateAddress } from '../logic/validateAddress'
import { hasManagerKey } from '../network/types'
import { createTzktApi } from '../network/tzkt'
import {
DUST_MARGIN_MUTEZ,
hasEmptyBalance,
normalizePublicKeyForAddress,
parseTezosTokenAsset,
resolveTezosOperationMode,
} from '../utils'
// The caller builds the {@link TezosContext} (config + logger) and passes it to each method (ADR-019).
export function createApi(): CoinModuleApi<TezosCoinConfig> {
return {
broadcast: (context, tx) => broadcast(context, tx),
async call() {
throw new Error('call is not supported')
},
combine: (_context, tx, signature) => combine(tx, signature),
craftTransaction: (context, transactionIntent, options) =>
craft(context, transactionIntent, options?.customFees),
craftRawTransaction: async (
context,
transaction: string,
sender: string,
publicKey: string,
sequence: bigint
): Promise<CraftedTransaction> => {
const tx = await craftRawOperations(context, transaction, sender, publicKey, sequence)
return { transaction: tx }
},
estimateFees: (context, transactionIntent) => estimate(context, transactionIntent),
getBalance: (context, address: string, options?: BalanceOptions) =>
rejectBalanceOptions(() => getBalance(context, address), options),
lastBlock: (context) => lastBlock(context),
listOperations: (context, address, options) => operations(context, address, options),
getStakes: (context, address, options) => getStakes(context, address, options?.cursor),
validateIntent: (context, transactionIntent) => validateIntent(context, transactionIntent),
getNextSequence: async (context, address: string) => {
const config = await context.config()
const accountInfo = await createTzktApi(config).getAccountByAddress(address)
return hasManagerKey(accountInfo) ? BigInt(accountInfo.counter + 1) : 0n
},
getAccountInfo: async (context, address: string): Promise<TezosAccountInfo> => {
const config = await context.config()
const account = await createTzktApi(config).getAccountByAddress(address)
// Manager-key accounts (plain wallets "user" and registered bakers "delegate") carry a
// reveal state; empty / non-existent accounts are treated as unrevealed (no public key
// published on-chain yet).
return { type: 'tezos', revealed: hasManagerKey(account) ? account.revealed : false }
},
getBlock: (context, height) => getBlock(context, height),
getBlockInfo: (context, height) => getBlockInfo(context, height),
getRewards(_context, _address: string, _options?: { cursor?: Cursor }): Promise<Page<Reward>> {
throw new Error('getRewards is not supported')
},
register: notSupported('register'),
getValidators(_context, _options?: { cursor?: Cursor }): Promise<Page<Validator>> {
throw new Error('getValidators is not supported')
},
validateAddress: (_context, address, parameters) => validateAddress(address, parameters),
craftTransactionData: (_context, intent) => craftTransactionData(intent),
}
}
function isTezosTransactionType(
type: string
): type is 'send' | 'delegate' | 'undelegate' | 'stake' | 'unstake' | 'finalize_unstake' {
return ['send', 'delegate', 'undelegate', 'stake', 'unstake', 'finalize_unstake'].includes(type)
}
async function craft(
context: TezosContext,
transactionIntent: TransactionIntent,
customFees?: FeeEstimation
): Promise<CraftedTransaction> {
if (!isTezosTransactionType(transactionIntent.type)) {
throw new IncorrectTypeError(transactionIntent.type)
}
const config = await context.config()
const api = createTzktApi(config)
// Always estimate to get gasLimit/storageLimit
const estimation = await estimate(context, transactionIntent)
const fee = {
fees: (customFees?.value ?? estimation.value).toString(),
gasLimit: estimation.parameters?.gasLimit?.toString(),
storageLimit: estimation.parameters?.storageLimit?.toString(),
}
const tezosMode = resolveTezosOperationMode(transactionIntent.type, transactionIntent.asset)
const mappedType: TezosOperationMode =
tezosMode === 'send_token' ? 'send_token' : (transactionIntent.type as TezosOperationMode)
const tokenCraftInfo =
tezosMode === 'send_token' ? parseTezosTokenAsset(transactionIntent.asset)! : undefined
let amountToUse = tezosMode === 'finalize_unstake' ? 0n : transactionIntent.amount
if (tezosMode === 'send' && transactionIntent.useAllAmount) {
const senderInfo = await api.getAccountByAddress(transactionIntent.sender)
if (hasManagerKey(senderInfo)) {
// Use the amount calculated by the estimation which includes proper buffers and adjustments
if (estimation.parameters?.amount !== undefined) {
amountToUse = estimation.parameters.amount
} else {
// Fallback to the original calculation if estimation doesn't provide amount
const bal = BigInt(senderInfo.balance)
const feeBI = BigInt(fee.fees || '0')
const dustMargin = BigInt(DUST_MARGIN_MUTEZ)
const totalToDeduct = feeBI + dustMargin
amountToUse = bal > totalToDeduct ? bal - totalToDeduct : 0n
}
} else {
amountToUse = 0n
}
}
const accountForCraft = {
address: transactionIntent.sender,
}
const senderApiAcc = await api.getAccountByAddress(transactionIntent.sender)
const needsReveal = hasManagerKey(senderApiAcc) && !senderApiAcc.revealed
const totalFee = Number(fee.fees || '0')
const feesConfig = config.fees
const revealFeeForSplit = needsReveal
? Math.max(feesConfig.minFees ?? 0, getRevealFee(transactionIntent.sender))
: 0
let txFee: number
if (customFees) {
txFee = needsReveal ? Math.max(totalFee - revealFeeForSplit, 0) : totalFee
} else if (estimation.parameters?.txFee !== undefined) {
txFee = Number(estimation.parameters.txFee)
} else {
txFee = needsReveal ? Math.max(totalFee - revealFeeForSplit, 0) : totalFee
}
const txForCraft = {
type: mappedType,
recipient: transactionIntent.recipient,
amount: amountToUse,
fee: { ...fee, fees: txFee.toString() },
...(tokenCraftInfo && {
contractAddress: tokenCraftInfo.contractAddress,
tokenId: tokenCraftInfo.tokenId,
}),
}
const publicKeyForCraft =
needsReveal && transactionIntent.senderPublicKey
? (() => {
// Accept either base58 or hex from device, and map curve using sender address
let pk = transactionIntent.senderPublicKey
if (validatePublicKey(pk) !== ValidationResult.VALID) {
pk = normalizePublicKeyForAddress(pk, transactionIntent.sender) || pk
}
// Verify the public key matches the sender address to avoid inconsistent_hash
let isPublicKeyValid = false
try {
const derived = getPkhfromPk(pk)
isPublicKeyValid = derived === transactionIntent.sender
} catch {
// getPkhfromPk failed = will fallback to basic validation below
isPublicKeyValid = false
}
if (!isPublicKeyValid) {
// If derivation failed/doesn't match, check if the key is atleast valid format
if (validatePublicKey(pk) !== ValidationResult.VALID) {
throw new Error('Unable to normalize sender public key')
}
}
return { publicKey: pk, publicKeyHash: transactionIntent.sender }
})()
: undefined
const { contents } = await craftTransaction(
context,
accountForCraft,
txForCraft,
publicKeyForCraft
)
const tx = await rawEncode(config, contents)
return { transaction: tx }
}
async function estimate(
context: TezosContext,
transactionIntent: TransactionIntent
): Promise<TezosFeeEstimation> {
// avoid taquito error when estimating a 0-amount transfer during input
const config = await context.config()
const api = createTzktApi(config)
const tezosModeForEstimate = resolveTezosOperationMode(
transactionIntent.type,
transactionIntent.asset
)
if (
(tezosModeForEstimate === 'send' || tezosModeForEstimate === 'send_token') &&
transactionIntent.amount === 0n &&
!transactionIntent.useAllAmount
) {
return {
value: BigInt(DUST_MARGIN_MUTEZ),
parameters: {
gasLimit: 10000n,
storageLimit: 300n,
amount: 0n,
txFee: BigInt(DUST_MARGIN_MUTEZ),
},
}
}
const senderAccountInfo = await api.getAccountByAddress(transactionIntent.sender)
// If the sender is not a manager-key account (user or delegate), return default estimation values
if (!hasManagerKey(senderAccountInfo)) {
return {
value: BigInt(DUST_MARGIN_MUTEZ),
parameters: {
gasLimit: 10000n,
storageLimit: 300n,
amount: 0n,
txFee: BigInt(DUST_MARGIN_MUTEZ),
},
}
}
const accountBase: CoreAccountInfo = {
address: transactionIntent.sender,
revealed: senderAccountInfo.revealed,
balance: BigInt(senderAccountInfo.balance),
stakedBalance: BigInt(senderAccountInfo.stakedBalance ?? 0),
unstakedBalance: BigInt(senderAccountInfo.unstakedBalance ?? 0),
}
const tokenEstimationInfo =
tezosModeForEstimate === 'send_token'
? parseTezosTokenAsset(transactionIntent.asset)!
: undefined
const transaction: CoreTransactionInfo = {
mode: tezosModeForEstimate,
recipient: transactionIntent.recipient,
amount: tezosModeForEstimate === 'finalize_unstake' ? 0n : transactionIntent.amount,
useAllAmount: !!transactionIntent.useAllAmount,
...(tokenEstimationInfo && {
contractAddress: tokenEstimationInfo.contractAddress,
tokenId: tokenEstimationInfo.tokenId,
}),
}
async function logicEstimate(xpub?: string): Promise<EstimatedFees> {
// needed by the compiler (it can assume it's a manager-key account with respective fields)
if (!hasManagerKey(senderAccountInfo)) throw new Error('unexpected account type')
const account = xpub ? { ...accountBase, xpub } : accountBase
return await estimateFees(context, { account, transaction })
}
const xpub = transactionIntent.senderPublicKey ?? senderAccountInfo.publicKey
try {
// try intent public key first and fallback to tzkt public key
let estimation
try {
estimation = await logicEstimate(xpub)
} catch (error) {
// for some unknown reason, on some address the estimation fails with that error:
// {"kind":"permanent","id":"proto.023-PtSeouLo.contract.manager.inconsistent_hash","public_key":"sppk7aMmdpDZc9KHjJBWac53NVoK4kfYbTC39EbmEzpZizjENonbHQD","expected_hash":"tz2BHzkaizWwCmhYswwTQCycgT8mXFH8QTL5","provided_hash":"tz2R3ynJBBzFZYtbx1Ywmvd8n6z2ZH3rXAQ6"}
// it's not clear why this happens, it couldn't be further investigated
// so we fallback to make an estimation without the public key
// there is a test that covers this, see "fallback to an estimation without the public key" index-mainnet.integ.test.ts
log('estimate-error', 'error estimating fees, trying without pubkey', { error })
estimation = await logicEstimate()
}
if (
estimation.taquitoError &&
!estimation.taquitoError.includes('delegate.unchanged') &&
!estimation.taquitoError.includes('subtraction_underflow') &&
!estimation.taquitoError.includes('balance_too_low') &&
!estimation.taquitoError.includes('script_rejected') &&
!estimation.taquitoError.includes(
'cannot_stake_with_unfinalizable_unstake_requests_to_another_delegate'
)
) {
throw new Error(`Fees estimation failed: ${estimation.taquitoError}`)
}
return {
value: estimation.estimatedFees,
parameters: {
gasLimit: estimation.gasLimit,
storageLimit: estimation.storageLimit,
amount: estimation.amount,
txFee: estimation.fees,
},
}
} catch (error: any) {
// Handle PublicKeyNotFoundError
if (error?.message?.includes('Public key not found')) {
const apiAccount = await api.getAccountByAddress(transactionIntent.recipient)
const storageLimit =
!hasEmptyBalance(apiAccount) || transactionIntent.type === 'stake' ? 0n : 277n
// Check if account needs reveal for proper fee calculation
const senderApiAcc = await api.getAccountByAddress(transactionIntent.sender)
const needsReveal = hasManagerKey(senderApiAcc) && !senderApiAcc.revealed
let baseTxFee: bigint
let txGasLimit: bigint
try {
const toolkit = getTezosToolkit(config)
const simpleEstimate = await toolkit.estimate.transfer({
to: transactionIntent.recipient,
amount: Number(transactionIntent.amount),
mutez: true,
source: transactionIntent.sender,
})
// Use Taquito estimation, respecting minFees from config
baseTxFee = BigInt(Math.max(config.fees.minFees, simpleEstimate.suggestedFeeMutez))
txGasLimit = BigInt(simpleEstimate.gasLimit)
} catch {
// When estimation fails because the sender is unrevealed (PublicKeyNotFoundError),
// fallback to a conservative gas value suitable for typical new-account XTZ transfers.
// This buffer (~2500 gas) is more than enough for a standard transfer that actually uses ~1420 gas.
// The fee is computed according to Taquito's calculation so it will satisfy the Tezos prefilter rule:
// total_fees >= ceil(100 + 0.1*total_gas + op_size)
// We use a base of 120 instead of 100 to mimic Taquito and minimize rejected low-fee ops.
const SAFE_FALLBACK_GAS = 2500 // covers typical new-account transfer (~1420) with buffer
const FALLBACK_OP_SIZE_BYTES = 154 // typical forged size for a simple XTZ transfer
txGasLimit = BigInt(SAFE_FALLBACK_GAS)
baseTxFee = BigInt(
Math.max(
config.fees.minFees,
Math.ceil(120 + 0.1 * SAFE_FALLBACK_GAS + FALLBACK_OP_SIZE_BYTES)
)
)
}
const revealFee = needsReveal
? BigInt(Math.max(config.fees.minFees ?? 0, getRevealFee(transactionIntent.sender)))
: 0n
const totalFee = baseTxFee + revealFee
return {
value: totalFee,
parameters: {
gasLimit: txGasLimit,
storageLimit,
amount: 0n,
txFee: baseTxFee,
},
}
} else {
// Re-throw other errors
throw error
}
}
}
async function operations(
context: TezosContext,
address: string,
{ minHeight = 0, cursor, order = 'asc' }: ListOperationsOptions
): Promise<Page<Operation>> {
// FIXME This wrapper hard-codes limit: 1000 and ignores any caller-provided limit from ListOperationsOptions. Either
// forward options.limit (as a soft/capped limit) or throw a "not supported" error when limit is set to match the
// ListOperationsOptions contract.
const [items, newNextCursor] = await listOperations(context, address, {
limit: 1000, // Increased limit to 1000 to ensure delegation information is available when displaying account details (temporary fix until proper pagination is implemented).
token: cursor,
sort: order === 'asc' ? 'Ascending' : 'Descending',
minHeight: minHeight,
})
return { items, next: newNextCursor || undefined }
}