UNPKG

@ledgerhq/coin-tezos

Version:
337 lines 15.4 kB
"use strict"; // SPDX-FileCopyrightText: © 2026 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 Object.defineProperty(exports, "__esModule", { value: true }); exports.validateIntent = validateIntent; const errors_1 = require("@ledgerhq/coin-module-framework/errors"); const utils_1 = require("@taquito/utils"); const types_1 = require("../network/types"); const tzkt_1 = require("../network/tzkt"); const errors_2 = require("../types/errors"); const utils_2 = require("../utils"); const estimateFees_1 = require("./estimateFees"); function resolveValidationOperationMode(intent) { switch (intent.type) { case 'stake': case 'unstake': case 'finalize_unstake': return intent.type; default: return (0, utils_2.resolveTezosOperationMode)(intent.type, intent.asset); } } function validateStrictlyPositiveAmount(amount) { if (amount === 0n) { return new errors_1.AmountRequired(); } if (amount < 0n) { return new errors_1.NotEnoughBalance(); } return undefined; } /** * Validates basic recipient and amount for send transactions */ function validateBasicSendParams(intent) { const errors = {}; if (intent.type !== 'send') { return errors; } if (!intent.recipient) { errors.recipient = new errors_1.RecipientRequired(''); } else if ((0, utils_1.validateAddress)(intent.recipient) !== utils_1.ValidationResult.VALID) { errors.recipient = new errors_1.InvalidAddress(undefined, { currencyName: 'Tezos' }); } else if (intent.sender === intent.recipient) { errors.recipient = new errors_1.InvalidAddressBecauseDestinationIsAlsoSource(); } if (intent.amount === 0n && !intent.useAllAmount) { errors.amount = new errors_1.AmountRequired(); } else if (intent.amount < 0n) { errors.amount = new errors_1.NotEnoughBalance(); } return errors; } function validateStakeConstraints(intent, senderInfo) { // Staking requires an active delegate. A registered baker (`type: "delegate"`) is its own // baker (self-delegated) so it is always eligible, even though tzkt reports no `delegate` // field for it; only plain wallets without a delegate must delegate first. const isSelfBaker = senderInfo.type === 'delegate'; if (!isSelfBaker && !senderInfo.delegate?.address) { return { amount: new errors_2.MustDelegateBeforeStaking() }; } if (intent.useAllAmount) { return {}; } const amountError = validateStrictlyPositiveAmount(intent.amount); return amountError ? { amount: amountError } : {}; } function validateUnstakeConstraints(intent, senderInfo) { const stakedBalance = BigInt(senderInfo.stakedBalance ?? 0); if (stakedBalance <= 0n) { return { amount: new errors_2.TezosNotEnoughStaked() }; } if (intent.useAllAmount) { return {}; } const amountError = validateStrictlyPositiveAmount(intent.amount); if (amountError) { return { amount: amountError }; } if (intent.amount > stakedBalance) { return { amount: new errors_2.TezosNotEnoughStaked() }; } return {}; } function validateFinalizeUnstakeConstraints(finalizable) { return finalizable <= 0n ? { amount: new errors_1.NotEnoughBalance() } : {}; } function validateTransactionConstraints(intent, senderInfo, finalizable) { switch (intent.type) { case 'stake': return validateStakeConstraints(intent, senderInfo); case 'unstake': return validateUnstakeConstraints(intent, senderInfo); case 'finalize_unstake': return validateFinalizeUnstakeConstraints(finalizable); default: return {}; } } /** * Maps Taquito-specific errors to our error types */ function mapTaquitoErrors(taquitoError, intentType) { const errors = {}; if (taquitoError.endsWith('balance_too_low') || taquitoError.endsWith('subtraction_underflow')) { errors.amount = new errors_1.NotEnoughBalance(); } else if (taquitoError.endsWith('staking.too_much_unstaked')) { errors.amount = new errors_2.TezosNotEnoughStaked(); } else if (taquitoError.endsWith('contract.must_be_delegated_to_stake')) { errors.amount = new errors_2.MustDelegateBeforeStaking(); } else if (taquitoError.endsWith('cannot_stake_with_unfinalizable_unstake_requests_to_another_delegate')) { // Changing delegate implicitly unstakes the frozen deposit toward the old delegate; the // protocol blocks staking with the new delegate until that unstake finalizes (~4 days). errors.amount = new errors_2.TezosStakeBlockedByPendingUnstake(); } else if (taquitoError.endsWith('delegate.unchanged')) { // Re-delegating (or staking) to the current baker leaves the delegate unchanged; the node // rejects it. Surfaces for both `delegate` and `stake` intents as "already delegated". errors.recipient = new errors_2.InvalidAddressBecauseAlreadyDelegated(); } else if (taquitoError.includes('empty_implicit_contract')) { errors.amount = intentType === 'stake' ? new errors_1.NotEnoughBalance() : new errors_2.NotEnoughBalanceToDelegate(); } else if (taquitoError.includes('script_rejected')) { errors.amount = new errors_1.NotEnoughBalance(); } else { errors.amount = new Error(taquitoError); } return errors; } function calculateNativeSendMaxAmountForUser(spendable, estimatedFees, estimatedAmount) { const amountFallback = spendable > estimatedFees ? spendable - estimatedFees : 0n; const hasPositiveEstimatedAmount = estimatedAmount !== undefined && estimatedAmount > 0n; const amount = hasPositiveEstimatedAmount ? estimatedAmount : amountFallback; return { amount, totalSpent: amount + estimatedFees }; } /** * Calculates final amounts based on transaction type * @param tokenBalanceForSendMax When set, FA2 send-max: full token amount; fees are paid in XTZ only */ function calculateAmounts(intent, senderInfo, estimatedFees, estimatedAmount, tokenBalanceForSendMax) { if (intent.type === 'stake') { if (!intent.useAllAmount) { return { amount: intent.amount, totalSpent: intent.amount + estimatedFees }; } if (estimatedAmount !== undefined) { return { amount: estimatedAmount, totalSpent: estimatedAmount + estimatedFees }; } // Mirrors estimateFees() stake-max formula for the !revealed short-circuit path. const amount = (0, utils_2.computeMaxStakeAmount)(BigInt(senderInfo.balance), BigInt(senderInfo.stakedBalance ?? 0), BigInt(senderInfo.unstakedBalance ?? 0), estimatedFees); return { amount, totalSpent: amount + estimatedFees }; } if (intent.type === 'unstake') { const stakedBalance = BigInt(senderInfo.stakedBalance ?? 0); const amount = intent.useAllAmount ? stakedBalance : intent.amount; return { amount, totalSpent: estimatedFees }; } if (intent.type === 'finalize_unstake') { return { amount: 0n, totalSpent: estimatedFees }; } if (intent.type === 'send' && intent.useAllAmount) { if (tokenBalanceForSendMax !== undefined) { return { amount: tokenBalanceForSendMax, totalSpent: estimatedFees }; } const { spendable } = (0, utils_2.partitionNativeBalance)(BigInt(senderInfo.balance), BigInt(senderInfo.stakedBalance ?? 0), BigInt(senderInfo.unstakedBalance ?? 0)); return calculateNativeSendMaxAmountForUser(spendable, estimatedFees, estimatedAmount); } // FA1.2/FA2 fixed-amount send: `intent.amount` is in token base units; fees are in XTZ mutez. // Never add the token amount to the native coverage check — the units are incompatible. if (intent.type === 'send' && (0, utils_2.parseTezosTokenAsset)(intent.asset) !== null) { return { amount: intent.amount, totalSpent: estimatedFees }; } const amount = intent.amount; return { amount, totalSpent: amount + estimatedFees }; } /** * Tezos `balance` includes staked + unstaked-frozen funds that can't pay fees/transfers, so the * caller must pass the spendable portion (total minus both), not the raw total. */ function validateBalanceCoverage(spendableBalance, totalSpent) { const errors = {}; if (totalSpent > spendableBalance) { errors.amount = new errors_1.NotEnoughBalance(); } return errors; } async function estimateFeesForIntent(context, intent, senderInfo) { if (!senderInfo.revealed) { return { estimatedFees: 2000n, estimatedAmount: undefined, errors: {} }; } const tezosMode = resolveValidationOperationMode(intent); const tokenInfo = tezosMode === 'send_token' ? (0, utils_2.parseTezosTokenAsset)(intent.asset) : undefined; const estimation = await (0, estimateFees_1.estimateFees)(context, { account: { address: intent.sender, revealed: senderInfo.revealed, balance: BigInt(senderInfo.balance), stakedBalance: BigInt(senderInfo.stakedBalance ?? 0), unstakedBalance: BigInt(senderInfo.unstakedBalance ?? 0), xpub: intent.senderPublicKey ?? senderInfo.publicKey, }, transaction: { mode: tezosMode, recipient: intent.recipient, // finalize_unstake is a parameter-less operation; normalize amount to 0n so // fee estimation and the returned validation amount stay consistent. amount: intent.type === 'finalize_unstake' ? 0n : intent.amount, useAllAmount: !!intent.useAllAmount, ...(tokenInfo && { contractAddress: tokenInfo.contractAddress, tokenId: tokenInfo.tokenId, }), }, }); const errors = {}; if (estimation.taquitoError) { Object.assign(errors, mapTaquitoErrors(estimation.taquitoError, intent.type)); } return { estimatedFees: estimation.estimatedFees, estimatedAmount: estimation.amount, errors, }; } async function fetchTokenBalance(config, intent) { if (intent.type !== 'send') { return undefined; } const tezosMode = (0, utils_2.resolveTezosOperationMode)(intent.type, intent.asset); if (tezosMode !== 'send_token') { return undefined; } const tokenInfo = (0, utils_2.parseTezosTokenAsset)(intent.asset); if (!tokenInfo) { return undefined; } const tokenBalances = await (0, tzkt_1.createTzktApi)(config).getTokensBalances(intent.sender, { contractAddress: tokenInfo.contractAddress, tokenId: tokenInfo.tokenId, }); const row = tokenBalances.find((b) => b.token.contract.address === tokenInfo.contractAddress && Number(b.token.tokenId) === tokenInfo.tokenId); return row ? BigInt(row.balance) : 0n; } // Coverage is checked against live TzKT state (senderInfo) below, not the framework-provided // balances: the synced spendableBalance can lag between consecutive operations. async function validateIntent(context, intent) { const config = await context.config(); const api = (0, tzkt_1.createTzktApi)(config); const errors = {}; const warnings = {}; let estimatedFees; let estimatedAmount; let amount; let totalSpent; const basicErrors = validateBasicSendParams(intent); Object.assign(errors, basicErrors); if (Object.keys(errors).length > 0) { return { errors, warnings, estimatedFees: 0n, amount: 0n, totalSpent: 0n }; } try { const senderInfo = await api.getAccountByAddress(intent.sender); if (!(0, types_1.hasManagerKey)(senderInfo)) throw new Error('unexpected account type'); // Finalizable amount lives on /v1/staking/unstake_requests, not the account // endpoint; only `finalize_unstake` validation needs it. const finalizable = intent.type === 'finalize_unstake' ? await api.getUnstakeRequestsFinalizable(intent.sender) : 0n; const constraintErrors = validateTransactionConstraints(intent, senderInfo, finalizable); Object.assign(errors, constraintErrors); if (Object.keys(errors).length > 0) { // Echo intent.amount (not 0n): the desktop AmountField hides the error when amount is 0. return { errors, warnings, estimatedFees: 0n, amount: intent.amount, totalSpent: 0n }; } const feeResult = await estimateFeesForIntent(context, intent, senderInfo); estimatedFees = feeResult.estimatedFees; estimatedAmount = feeResult.estimatedAmount; Object.assign(errors, feeResult.errors); // Skip the TzKT call only for fixed-amount sends where errors.amount is already set — the token // balance would only be used for coverage, which is also gated on !errors.amount. // For send-max we always fetch: calculateAmounts uses tokenBalanceForSendMax as the sent amount, // so skipping would cause it to fall back to the native XTZ path and return a wrong unit. // The TzKT call is isolated in its own try-catch so that a network failure here does not reach // the outer handler (which would wipe the already-computed estimatedFees and add a spurious // errors.estimation on top of the real fee-estimation error). let tokenBalance; if (errors.amount && !intent.useAllAmount) { tokenBalance = undefined; } else { try { tokenBalance = await fetchTokenBalance(config, intent); } catch { tokenBalance = undefined; // TzKT unreachable: fall back gracefully, no token coverage check } } // send-max uses the full token balance as the sent amount; fixed-amount only needs it for coverage const tokenBalanceForSendMax = intent.useAllAmount ? tokenBalance : undefined; const amounts = calculateAmounts(intent, senderInfo, estimatedFees, estimatedAmount, tokenBalanceForSendMax); amount = amounts.amount; totalSpent = amounts.totalSpent; if (intent.type === 'stake' && intent.useAllAmount && amount === 0n && !errors.amount) { errors.amount = new errors_1.NotEnoughBalance(); } const { spendable } = (0, utils_2.partitionNativeBalance)(BigInt(senderInfo.balance), BigInt(senderInfo.stakedBalance ?? 0), BigInt(senderInfo.unstakedBalance ?? 0)); const balanceErrors = validateBalanceCoverage(spendable, totalSpent); Object.assign(errors, balanceErrors); // Token balance coverage for fixed-amount token sends. // (send-max is always valid by construction: amount is set to tokenBalance above.) if (!errors.amount && intent.type === 'send' && !intent.useAllAmount && tokenBalance !== undefined) { if (amount > tokenBalance) { errors.amount = new errors_1.NotEnoughBalance(); } } } catch (e) { errors.estimation = e; estimatedFees = 0n; amount = intent.amount; totalSpent = intent.amount; } return { errors, warnings, estimatedFees, amount, totalSpent }; } //# sourceMappingURL=validateIntent.js.map