@ledgerhq/coin-tezos
Version:
321 lines • 17.2 kB
JavaScript
;
// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0
Object.defineProperty(exports, "__esModule", { value: true });
exports.createApi = createApi;
const rejectBalanceOptions_1 = require("@ledgerhq/coin-module-framework/api/getBalance/rejectBalanceOptions");
const index_1 = require("@ledgerhq/coin-module-framework/api/index");
const craftTransactionData_1 = require("@ledgerhq/coin-module-framework/logic/craftTransactionData");
const logs_1 = require("@ledgerhq/logs");
const taquito_1 = require("@taquito/taquito");
const utils_1 = require("@taquito/utils");
const logic_1 = require("../logic");
const tezosToolkit_1 = require("../logic/tezosToolkit");
const validateAddress_1 = require("../logic/validateAddress");
const types_1 = require("../network/types");
const tzkt_1 = require("../network/tzkt");
const utils_2 = require("../utils");
// The caller builds the {@link TezosContext} (config + logger) and passes it to each method (ADR-019).
function createApi() {
return {
broadcast: (context, tx) => (0, logic_1.broadcast)(context, tx),
async call() {
throw new Error('call is not supported');
},
combine: (_context, tx, signature) => (0, logic_1.combine)(tx, signature),
craftTransaction: (context, transactionIntent, options) => craft(context, transactionIntent, options?.customFees),
craftRawTransaction: async (context, transaction, sender, publicKey, sequence) => {
const tx = await (0, logic_1.craftRawOperations)(context, transaction, sender, publicKey, sequence);
return { transaction: tx };
},
estimateFees: (context, transactionIntent) => estimate(context, transactionIntent),
getBalance: (context, address, options) => (0, rejectBalanceOptions_1.rejectBalanceOptions)(() => (0, logic_1.getBalance)(context, address), options),
lastBlock: (context) => (0, logic_1.lastBlock)(context),
listOperations: (context, address, options) => operations(context, address, options),
getStakes: (context, address, options) => (0, logic_1.getStakes)(context, address, options?.cursor),
validateIntent: (context, transactionIntent) => (0, logic_1.validateIntent)(context, transactionIntent),
getNextSequence: async (context, address) => {
const config = await context.config();
const accountInfo = await (0, tzkt_1.createTzktApi)(config).getAccountByAddress(address);
return (0, types_1.hasManagerKey)(accountInfo) ? BigInt(accountInfo.counter + 1) : 0n;
},
getAccountInfo: async (context, address) => {
const config = await context.config();
const account = await (0, tzkt_1.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: (0, types_1.hasManagerKey)(account) ? account.revealed : false };
},
getBlock: (context, height) => (0, logic_1.getBlock)(context, height),
getBlockInfo: (context, height) => (0, logic_1.getBlockInfo)(context, height),
getRewards(_context, _address, _options) {
throw new Error('getRewards is not supported');
},
register: (0, index_1.notSupported)('register'),
getValidators(_context, _options) {
throw new Error('getValidators is not supported');
},
validateAddress: (_context, address, parameters) => (0, validateAddress_1.validateAddress)(address, parameters),
craftTransactionData: (_context, intent) => (0, craftTransactionData_1.craftTransactionData)(intent),
};
}
function isTezosTransactionType(type) {
return ['send', 'delegate', 'undelegate', 'stake', 'unstake', 'finalize_unstake'].includes(type);
}
async function craft(context, transactionIntent, customFees) {
if (!isTezosTransactionType(transactionIntent.type)) {
throw new index_1.IncorrectTypeError(transactionIntent.type);
}
const config = await context.config();
const api = (0, tzkt_1.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 = (0, utils_2.resolveTezosOperationMode)(transactionIntent.type, transactionIntent.asset);
const mappedType = tezosMode === 'send_token' ? 'send_token' : transactionIntent.type;
const tokenCraftInfo = tezosMode === 'send_token' ? (0, utils_2.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 ((0, types_1.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(utils_2.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 = (0, types_1.hasManagerKey)(senderApiAcc) && !senderApiAcc.revealed;
const totalFee = Number(fee.fees || '0');
const feesConfig = config.fees;
const revealFeeForSplit = needsReveal
? Math.max(feesConfig.minFees ?? 0, (0, taquito_1.getRevealFee)(transactionIntent.sender))
: 0;
let txFee;
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 ((0, utils_1.validatePublicKey)(pk) !== utils_1.ValidationResult.VALID) {
pk = (0, utils_2.normalizePublicKeyForAddress)(pk, transactionIntent.sender) || pk;
}
// Verify the public key matches the sender address to avoid inconsistent_hash
let isPublicKeyValid = false;
try {
const derived = (0, utils_1.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 ((0, utils_1.validatePublicKey)(pk) !== utils_1.ValidationResult.VALID) {
throw new Error('Unable to normalize sender public key');
}
}
return { publicKey: pk, publicKeyHash: transactionIntent.sender };
})()
: undefined;
const { contents } = await (0, logic_1.craftTransaction)(context, accountForCraft, txForCraft, publicKeyForCraft);
const tx = await (0, logic_1.rawEncode)(config, contents);
return { transaction: tx };
}
async function estimate(context, transactionIntent) {
// avoid taquito error when estimating a 0-amount transfer during input
const config = await context.config();
const api = (0, tzkt_1.createTzktApi)(config);
const tezosModeForEstimate = (0, utils_2.resolveTezosOperationMode)(transactionIntent.type, transactionIntent.asset);
if ((tezosModeForEstimate === 'send' || tezosModeForEstimate === 'send_token') &&
transactionIntent.amount === 0n &&
!transactionIntent.useAllAmount) {
return {
value: BigInt(utils_2.DUST_MARGIN_MUTEZ),
parameters: {
gasLimit: 10000n,
storageLimit: 300n,
amount: 0n,
txFee: BigInt(utils_2.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 (!(0, types_1.hasManagerKey)(senderAccountInfo)) {
return {
value: BigInt(utils_2.DUST_MARGIN_MUTEZ),
parameters: {
gasLimit: 10000n,
storageLimit: 300n,
amount: 0n,
txFee: BigInt(utils_2.DUST_MARGIN_MUTEZ),
},
};
}
const accountBase = {
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'
? (0, utils_2.parseTezosTokenAsset)(transactionIntent.asset)
: undefined;
const transaction = {
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) {
// needed by the compiler (it can assume it's a manager-key account with respective fields)
if (!(0, types_1.hasManagerKey)(senderAccountInfo))
throw new Error('unexpected account type');
const account = xpub ? { ...accountBase, xpub } : accountBase;
return await (0, logic_1.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
(0, logs_1.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) {
// Handle PublicKeyNotFoundError
if (error?.message?.includes('Public key not found')) {
const apiAccount = await api.getAccountByAddress(transactionIntent.recipient);
const storageLimit = !(0, utils_2.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 = (0, types_1.hasManagerKey)(senderApiAcc) && !senderApiAcc.revealed;
let baseTxFee;
let txGasLimit;
try {
const toolkit = (0, tezosToolkit_1.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, (0, taquito_1.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, address, { minHeight = 0, cursor, order = 'asc' }) {
// 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 (0, logic_1.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 };
}
//# sourceMappingURL=index.js.map