UNPKG

@ledgerhq/coin-tezos

Version:
321 lines 17.2 kB
"use strict"; // 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