@ledgerhq/coin-tezos
Version:
334 lines • 15.3 kB
JavaScript
// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0
import { InvalidAddress, RecipientRequired, NotEnoughBalance, AmountRequired, InvalidAddressBecauseDestinationIsAlsoSource, } from '@ledgerhq/coin-module-framework/errors';
import { validateAddress, ValidationResult } from '@taquito/utils';
import { hasManagerKey } from '../network/types';
import { createTzktApi } from '../network/tzkt';
import { InvalidAddressBecauseAlreadyDelegated, MustDelegateBeforeStaking, NotEnoughBalanceToDelegate, TezosNotEnoughStaked, TezosStakeBlockedByPendingUnstake, } from '../types/errors';
import { computeMaxStakeAmount, parseTezosTokenAsset, partitionNativeBalance, resolveTezosOperationMode, } from '../utils';
import { estimateFees } from './estimateFees';
function resolveValidationOperationMode(intent) {
switch (intent.type) {
case 'stake':
case 'unstake':
case 'finalize_unstake':
return intent.type;
default:
return resolveTezosOperationMode(intent.type, intent.asset);
}
}
function validateStrictlyPositiveAmount(amount) {
if (amount === 0n) {
return new AmountRequired();
}
if (amount < 0n) {
return new 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 RecipientRequired('');
}
else if (validateAddress(intent.recipient) !== ValidationResult.VALID) {
errors.recipient = new InvalidAddress(undefined, { currencyName: 'Tezos' });
}
else if (intent.sender === intent.recipient) {
errors.recipient = new InvalidAddressBecauseDestinationIsAlsoSource();
}
if (intent.amount === 0n && !intent.useAllAmount) {
errors.amount = new AmountRequired();
}
else if (intent.amount < 0n) {
errors.amount = new 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 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 TezosNotEnoughStaked() };
}
if (intent.useAllAmount) {
return {};
}
const amountError = validateStrictlyPositiveAmount(intent.amount);
if (amountError) {
return { amount: amountError };
}
if (intent.amount > stakedBalance) {
return { amount: new TezosNotEnoughStaked() };
}
return {};
}
function validateFinalizeUnstakeConstraints(finalizable) {
return finalizable <= 0n ? { amount: new 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 NotEnoughBalance();
}
else if (taquitoError.endsWith('staking.too_much_unstaked')) {
errors.amount = new TezosNotEnoughStaked();
}
else if (taquitoError.endsWith('contract.must_be_delegated_to_stake')) {
errors.amount = new 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 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 InvalidAddressBecauseAlreadyDelegated();
}
else if (taquitoError.includes('empty_implicit_contract')) {
errors.amount =
intentType === 'stake' ? new NotEnoughBalance() : new NotEnoughBalanceToDelegate();
}
else if (taquitoError.includes('script_rejected')) {
errors.amount = new 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 = 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 } = 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' && 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 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' ? parseTezosTokenAsset(intent.asset) : undefined;
const estimation = await 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 = resolveTezosOperationMode(intent.type, intent.asset);
if (tezosMode !== 'send_token') {
return undefined;
}
const tokenInfo = parseTezosTokenAsset(intent.asset);
if (!tokenInfo) {
return undefined;
}
const tokenBalances = await 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.
export async function validateIntent(context, intent) {
const config = await context.config();
const api = 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 (!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 NotEnoughBalance();
}
const { spendable } = 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 NotEnoughBalance();
}
}
}
catch (e) {
errors.estimation = e;
estimatedFees = 0n;
amount = intent.amount;
totalSpent = intent.amount;
}
return { errors, warnings, estimatedFees, amount, totalSpent };
}
//# sourceMappingURL=validateIntent.js.map