@ledgerhq/coin-tezos
Version:
1,615 lines • 51.6 kB
text/typescript
// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0
import type { TezosCoinConfig, TezosContext } from '../config'
import type { TransactionIntent } from '@ledgerhq/coin-module-framework/api/types'
import {
RecipientRequired,
InvalidAddress,
InvalidAddressBecauseDestinationIsAlsoSource,
AmountRequired,
NotEnoughBalance,
} from '@ledgerhq/coin-module-framework/errors'
import {
InvalidAddressBecauseAlreadyDelegated,
MustDelegateBeforeStaking,
NotEnoughBalanceToDelegate,
TezosNotEnoughStaked,
TezosStakeBlockedByPendingUnstake,
} from '../types/errors'
import { STAKE_USE_ALL_RESERVE_MUTEZ } from '../utils'
import { validateIntent } from './validateIntent'
const mockEstimateFees = jest.fn()
jest.mock('./estimateFees', () => ({
estimateFees: (...args: unknown[]) => mockEstimateFees(...args),
}))
const mockGetAccountByAddress = jest.fn()
const mockGetTokensBalances = jest.fn()
const mockGetUnstakeRequestsFinalizable = jest.fn()
jest.mock('../network/tzkt', () => ({
__esModule: true,
createTzktApi: () => ({
getAccountByAddress: (...args: unknown[]) => mockGetAccountByAddress(...args),
getTokensBalances: (...args: unknown[]) => mockGetTokensBalances(...args),
getUnstakeRequestsFinalizable: (...args: unknown[]) =>
mockGetUnstakeRequestsFinalizable(...args),
}),
}))
describe('validateIntent', () => {
const senderAddress = 'tz1TzrmTBSuiVHV2VfMnGRMYvTEPCP42oSM8'
const validRecipient = 'tz1KqTpEZ7Yob7QbPE4Hy4Wo8fHG8LhKxZSx'
const makeUserAccount = (overrides: Record<string, unknown> = {}) => ({
type: 'user' as const,
address: senderAddress,
publicKey: 'edpk...',
balance: 5000000,
revealed: true,
counter: 0,
delegationLevel: 0,
delegationTime: '2021-01-01T00:00:00Z',
numTransactions: 0,
firstActivityTime: '2021-01-01T00:00:00Z',
...overrides,
})
const context: TezosContext = {
config: async () =>
({
status: { type: 'active' },
baker: { url: 'http://baker.example.com' },
explorer: { url: 'http://tezos.explorer.com', maxTxQuery: 100 },
node: { url: 'http://tezos.node.com' },
fees: {
minGasLimit: 600,
minRevealGasLimit: 300,
minStorageLimit: 0,
minFees: 500,
minEstimatedFees: 500,
},
}) as unknown as TezosCoinConfig,
logger: () => undefined,
}
beforeEach(() => {
jest.clearAllMocks()
mockEstimateFees.mockResolvedValue({
fees: 1000n,
gasLimit: 10000n,
storageLimit: 0n,
estimatedFees: 1000n,
})
mockGetAccountByAddress.mockResolvedValue(makeUserAccount())
mockGetTokensBalances.mockResolvedValue([])
mockGetUnstakeRequestsFinalizable.mockResolvedValue(0n)
})
describe('recipient validation', () => {
it('should return RecipientRequired error when recipient is missing', async () => {
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: senderAddress,
recipient: '',
amount: 1000n,
})
expect(result.errors.recipient).toBeInstanceOf(RecipientRequired)
})
it('should return InvalidAddress error for invalid recipient', async () => {
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: senderAddress,
recipient: 'invalid_address',
amount: 1000n,
})
expect(result.errors.recipient).toBeInstanceOf(InvalidAddress)
})
it('should return InvalidAddressBecauseDestinationIsAlsoSource when sender equals recipient', async () => {
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: senderAddress,
recipient: senderAddress,
amount: 1000n,
})
expect(result.errors.recipient).toBeInstanceOf(InvalidAddressBecauseDestinationIsAlsoSource)
})
})
describe('amount validation', () => {
it('should return AmountRequired error when amount is zero and not useAllAmount', async () => {
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount: 0n,
useAllAmount: false,
})
expect(result.errors.amount).toBeInstanceOf(AmountRequired)
})
it('should not return AmountRequired error when useAllAmount is true', async () => {
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount: 0n,
useAllAmount: true,
})
expect(result.errors.amount).toBeUndefined()
})
it('should return NotEnoughBalance when amount is negative', async () => {
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount: -1n,
})
expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
})
it('uses the staked amount as amount and amount + fees as totalSpent for stake', async () => {
mockGetAccountByAddress.mockResolvedValue(
makeUserAccount({
delegate: { alias: 'baker', address: validRecipient, active: true },
delegationLevel: 1,
})
)
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'stake',
sender: senderAddress,
recipient: '',
amount: 2500n,
})
expect(result).toEqual({
errors: {},
warnings: {},
estimatedFees: 1000n,
amount: 2500n,
totalSpent: 3500n,
})
})
it('uses only fees as totalSpent for unstake', async () => {
mockGetAccountByAddress.mockResolvedValue(makeUserAccount({ stakedBalance: 4000 }))
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'unstake',
sender: senderAddress,
recipient: '',
amount: 2500n,
})
expect(result).toEqual({
errors: {},
warnings: {},
estimatedFees: 1000n,
amount: 2500n,
totalSpent: 1000n,
})
})
it('uses amount 0 and only fees as totalSpent for finalize_unstake', async () => {
mockGetUnstakeRequestsFinalizable.mockResolvedValue(1234n)
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'finalize_unstake',
sender: senderAddress,
recipient: '',
amount: 0n,
} satisfies TransactionIntent)
expect(result).toEqual({
errors: {},
warnings: {},
estimatedFees: 1000n,
amount: 0n,
totalSpent: 1000n,
})
})
})
describe('transaction constraints', () => {
it('allows send-max on a delegated account with no error or warning and resolves the max amount', async () => {
mockGetAccountByAddress.mockResolvedValue(
makeUserAccount({
delegate: { alias: 'baker', address: validRecipient, active: true },
delegationLevel: 1,
})
)
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount: 0n,
useAllAmount: true,
})
expect(result.errors).toEqual({})
expect(result.warnings).toEqual({})
// balance 5_000_000 - fees 1_000
expect(result.amount).toBe(4999000n)
expect(result.totalSpent).toBe(5000000n)
})
it('should return MustDelegateBeforeStaking when stake intent has no delegate', async () => {
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'stake',
sender: senderAddress,
recipient: '',
amount: 1n,
})
expect(result.errors.amount).toBeInstanceOf(MustDelegateBeforeStaking)
expect(mockEstimateFees).not.toHaveBeenCalled()
})
it('allows a registered baker (delegate account) to stake without MustDelegateBeforeStaking', async () => {
// A baker is self-delegated: tzkt returns type "delegate" with no `delegate` field, but it
// must still be allowed to stake. Regression for LIVE-34256.
mockGetAccountByAddress.mockResolvedValue({
type: 'delegate',
address: senderAddress,
publicKey: 'edpk...',
balance: 5000000,
revealed: true,
counter: 0,
})
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'stake',
sender: senderAddress,
recipient: '',
amount: 1000n,
})
// No validation errors at all, and fee estimation was reached — a MustDelegateBeforeStaking
// short-circuit would return before estimation (see the "no delegate" case above).
expect(result.errors).toEqual({})
expect(mockEstimateFees).toHaveBeenCalled()
})
it('should return AmountRequired when stake amount is zero', async () => {
mockGetAccountByAddress.mockResolvedValue(
makeUserAccount({
delegate: { alias: 'baker', address: validRecipient, active: true },
delegationLevel: 1,
})
)
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'stake',
sender: senderAddress,
recipient: '',
amount: 0n,
})
expect(result.errors.amount).toBeInstanceOf(AmountRequired)
expect(mockEstimateFees).not.toHaveBeenCalled()
})
it('should skip AmountRequired and resolve max amount when stake useAllAmount is true', async () => {
mockGetAccountByAddress.mockResolvedValue(
makeUserAccount({
delegate: { alias: 'baker', address: validRecipient, active: true },
delegationLevel: 1,
})
)
mockEstimateFees.mockResolvedValueOnce({
fees: 1000n,
gasLimit: 10000n,
storageLimit: 0n,
estimatedFees: 1000n,
amount: 4_500_000n,
})
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'stake',
sender: senderAddress,
recipient: '',
amount: 0n,
useAllAmount: true,
})
expect(result.errors.amount).toBeUndefined()
expect(mockEstimateFees).toHaveBeenCalledTimes(1)
expect(result.amount).toBe(4_500_000n)
})
it('should return NotEnoughBalance when stake useAllAmount resolves max to 0n', async () => {
mockGetAccountByAddress.mockResolvedValue(
makeUserAccount({
delegate: { alias: 'baker', address: validRecipient, active: true },
delegationLevel: 1,
})
)
mockEstimateFees.mockResolvedValueOnce({
fees: 1000n,
gasLimit: 10000n,
storageLimit: 0n,
estimatedFees: 1000n,
amount: 0n,
})
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'stake',
sender: senderAddress,
recipient: '',
amount: 0n,
useAllAmount: true,
})
expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
expect(result.amount).toBe(0n)
})
it('excludes already-staked funds when stake useAllAmount falls back to balance computation', async () => {
mockGetAccountByAddress.mockResolvedValue(
makeUserAccount({
balance: 5_000_000,
stakedBalance: 1_000_000,
delegate: { alias: 'baker', address: validRecipient, active: true },
delegationLevel: 1,
})
)
// No `amount` field => estimatedAmount is undefined, exercising the fallback path.
mockEstimateFees.mockResolvedValueOnce({
fees: 1000n,
gasLimit: 10000n,
storageLimit: 0n,
estimatedFees: 1000n,
})
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'stake',
sender: senderAddress,
recipient: '',
amount: 0n,
useAllAmount: true,
})
expect(result.errors.amount).toBeUndefined()
expect(result.amount).toBe(4_000_000n - 1000n - STAKE_USE_ALL_RESERVE_MUTEZ)
})
it('excludes unstaked-frozen funds when stake useAllAmount falls back to balance computation', async () => {
mockGetAccountByAddress.mockResolvedValue(
makeUserAccount({
balance: 5_000_000,
stakedBalance: 1_000_000,
unstakedBalance: 500_000,
delegate: { alias: 'baker', address: validRecipient, active: true },
delegationLevel: 1,
})
)
// No `amount` field => estimatedAmount is undefined, exercising the fallback path.
mockEstimateFees.mockResolvedValueOnce({
fees: 1000n,
gasLimit: 10000n,
storageLimit: 0n,
estimatedFees: 1000n,
})
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'stake',
sender: senderAddress,
recipient: '',
amount: 0n,
useAllAmount: true,
})
expect(result.errors.amount).toBeUndefined()
expect(result.amount).toBe(3_500_000n - 1000n - STAKE_USE_ALL_RESERVE_MUTEZ)
})
it('should return TezosNotEnoughStaked when unstake has no staked balance', async () => {
mockGetAccountByAddress.mockResolvedValue(makeUserAccount({ stakedBalance: 0 }))
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'unstake',
sender: senderAddress,
recipient: '',
amount: 1n,
})
expect(result.errors.amount).toBeInstanceOf(TezosNotEnoughStaked)
expect(mockEstimateFees).not.toHaveBeenCalled()
})
it('should return AmountRequired when unstake amount is zero', async () => {
mockGetAccountByAddress.mockResolvedValue(makeUserAccount({ stakedBalance: 4000 }))
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'unstake',
sender: senderAddress,
recipient: '',
amount: 0n,
})
expect(result.errors.amount).toBeInstanceOf(AmountRequired)
expect(mockEstimateFees).not.toHaveBeenCalled()
})
it('should return TezosNotEnoughStaked when unstake amount exceeds staked balance', async () => {
mockGetAccountByAddress.mockResolvedValue(makeUserAccount({ stakedBalance: 4000 }))
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'unstake',
sender: senderAddress,
recipient: '',
amount: 5000n,
})
expect(result.errors.amount).toBeInstanceOf(TezosNotEnoughStaked)
expect(result.amount).toBe(5000n)
expect(mockEstimateFees).not.toHaveBeenCalled()
})
it('should resolve unstake useAllAmount to full stakedBalance', async () => {
mockGetAccountByAddress.mockResolvedValue(makeUserAccount({ stakedBalance: 4000 }))
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'unstake',
sender: senderAddress,
recipient: '',
amount: 0n,
useAllAmount: true,
})
expect(result.errors).toEqual({})
expect(result.amount).toBe(4000n)
expect(result.totalSpent).toBe(1000n)
})
it('should return TezosNotEnoughStaked when unstake useAllAmount with zero stakedBalance', async () => {
mockGetAccountByAddress.mockResolvedValue(makeUserAccount({ stakedBalance: 0 }))
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'unstake',
sender: senderAddress,
recipient: '',
amount: 0n,
useAllAmount: true,
})
expect(result.errors.amount).toBeInstanceOf(TezosNotEnoughStaked)
expect(mockEstimateFees).not.toHaveBeenCalled()
})
it("should return NotEnoughBalance when unstake useAllAmount but liquid balance can't cover fees", async () => {
mockGetAccountByAddress.mockResolvedValue(
makeUserAccount({ balance: 500, stakedBalance: 4000 })
)
mockEstimateFees.mockResolvedValueOnce({
fees: 1000n,
gasLimit: 10000n,
storageLimit: 0n,
estimatedFees: 1000n,
})
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'unstake',
sender: senderAddress,
recipient: '',
amount: 0n,
useAllAmount: true,
})
expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
})
it('should return NotEnoughBalance when finalize_unstake has nothing finalizable', async () => {
mockGetUnstakeRequestsFinalizable.mockResolvedValue(0n)
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'finalize_unstake',
sender: senderAddress,
recipient: '',
amount: 0n,
} satisfies TransactionIntent)
expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
expect(mockEstimateFees).not.toHaveBeenCalled()
})
})
describe('balance validation', () => {
it('should return NotEnoughBalance error when amount exceeds balance', async () => {
const balance = 1000000
const amount = 2000000
mockGetAccountByAddress.mockResolvedValue(makeUserAccount({ balance }))
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount: BigInt(amount),
})
expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
})
it('should pass validation when amount is within balance', async () => {
const amount = 1000000n
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount,
})
expect(result.errors.amount).toBeUndefined()
expect(result.amount).toBe(amount)
})
it("returns NotEnoughBalance when funds are staked/frozen and spendable can't cover fees", async () => {
// balance includes the staked funds (total > fees), but spendable (total - staked) is 0
mockGetAccountByAddress.mockResolvedValue(
makeUserAccount({ balance: 5_000_000, stakedBalance: 5_000_000 })
)
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'undelegate',
sender: senderAddress,
recipient: '',
amount: 0n,
})
expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
})
it('passes delegate when spendable covers fees despite most funds being staked', async () => {
mockGetAccountByAddress.mockResolvedValue(
makeUserAccount({ balance: 5_000_000, stakedBalance: 4_000_000 })
)
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'delegate',
sender: senderAddress,
recipient: validRecipient,
amount: 0n,
})
expect(result.errors.amount).toBeUndefined()
})
it('passes a send that spends exactly the spendable balance (totalSpent === spendable boundary)', async () => {
// spendable = balance - staked = 1_000_000; amount (999_000) + mocked fees (1_000) lands exactly
// on it. The coverage check is strictly-greater, so the boundary must NOT error.
mockGetAccountByAddress.mockResolvedValue(
makeUserAccount({ balance: 2_000_000, stakedBalance: 1_000_000 })
)
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount: 999_000n,
})
expect(result.errors.amount).toBeUndefined()
expect(result.totalSpent).toBe(1_000_000n)
})
it('returns NotEnoughBalance for a send when unstaked-frozen funds leave too little liquid', async () => {
// Reproduces the consecutive-send failure: unstaked funds are pending withdrawal (frozen),
// so liquid spendable is only 220 mutez — far below amount + fees.
mockGetAccountByAddress.mockResolvedValue(
makeUserAccount({ balance: 300_322, unstakedBalance: 300_102 })
)
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount: 100n,
})
expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
})
})
describe('transaction types', () => {
it('should pass validation for delegate transaction', async () => {
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'delegate',
sender: senderAddress,
recipient: validRecipient,
amount: 0n,
})
expect(result.errors).toEqual({})
})
it('should pass validation for undelegate transaction', async () => {
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'undelegate',
sender: senderAddress,
recipient: '',
amount: 0n,
})
expect(result.errors).toEqual({})
})
it('should pass validation for stake transaction when already delegated', async () => {
mockGetAccountByAddress.mockResolvedValue(
makeUserAccount({
delegate: { alias: 'baker', address: validRecipient, active: true },
delegationLevel: 1,
})
)
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'stake',
sender: senderAddress,
recipient: '',
amount: 1000n,
})
expect(result.errors).toEqual({})
})
it('should pass validation for unstake transaction', async () => {
mockGetAccountByAddress.mockResolvedValue(makeUserAccount({ stakedBalance: 4000 }))
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'unstake',
sender: senderAddress,
recipient: '',
amount: 1000n,
})
expect(result.errors).toEqual({})
})
it('should pass validation for finalize_unstake transaction', async () => {
mockGetUnstakeRequestsFinalizable.mockResolvedValue(4000n)
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'finalize_unstake',
sender: senderAddress,
recipient: '',
amount: 0n,
} satisfies TransactionIntent)
expect(result.errors).toEqual({})
})
})
describe('taquito error mapping', () => {
it('maps balance_too_low to NotEnoughBalance for send', async () => {
mockEstimateFees.mockResolvedValue({
fees: 0n,
gasLimit: 0n,
storageLimit: 0n,
estimatedFees: 500n,
taquitoError: 'proto.001-PtAtLas.contract.balance_too_low',
})
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount: 1000n,
})
expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
})
it('maps balance_too_low to NotEnoughBalance for stake', async () => {
mockGetAccountByAddress.mockResolvedValue(
makeUserAccount({
delegate: { alias: 'baker', address: validRecipient, active: true },
delegationLevel: 1,
})
)
mockEstimateFees.mockResolvedValue({
fees: 0n,
gasLimit: 0n,
storageLimit: 0n,
estimatedFees: 500n,
taquitoError: 'proto.001-PtAtLas.contract.balance_too_low',
})
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'stake',
sender: senderAddress,
recipient: '',
amount: 1n,
})
expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
})
it('maps subtraction_underflow to NotEnoughBalance for non-stake', async () => {
mockEstimateFees.mockResolvedValue({
fees: 0n,
gasLimit: 0n,
storageLimit: 0n,
estimatedFees: 500n,
taquitoError: 'some.path.subtraction_underflow',
})
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount: 1000n,
})
expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
})
it('maps delegate.unchanged to InvalidAddressBecauseAlreadyDelegated for stake', async () => {
mockGetAccountByAddress.mockResolvedValue(
makeUserAccount({
delegate: { alias: 'baker', address: validRecipient, active: true },
delegationLevel: 1,
})
)
mockEstimateFees.mockResolvedValue({
fees: 0n,
gasLimit: 0n,
storageLimit: 0n,
estimatedFees: 500n,
taquitoError: 'proto.024-PtTALLiN.delegate.unchanged',
})
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'stake',
sender: senderAddress,
recipient: '',
amount: 1n,
})
expect(result.errors.recipient).toBeInstanceOf(InvalidAddressBecauseAlreadyDelegated)
})
it('maps delegate.unchanged to InvalidAddressBecauseAlreadyDelegated for delegate', async () => {
mockGetAccountByAddress.mockResolvedValue(
makeUserAccount({
delegate: { alias: 'baker', address: validRecipient, active: true },
delegationLevel: 1,
})
)
mockEstimateFees.mockResolvedValue({
fees: 0n,
gasLimit: 0n,
storageLimit: 0n,
estimatedFees: 500n,
taquitoError: 'proto.024-PtTALLiN.delegate.unchanged',
})
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'delegate',
sender: senderAddress,
recipient: validRecipient,
amount: 0n,
})
expect(result.errors.recipient).toBeInstanceOf(InvalidAddressBecauseAlreadyDelegated)
})
it('maps empty_implicit_contract to NotEnoughBalanceToDelegate', async () => {
mockEstimateFees.mockResolvedValue({
fees: 0n,
gasLimit: 0n,
storageLimit: 0n,
estimatedFees: 500n,
taquitoError: 'proto.empty_implicit_contract',
})
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'delegate',
sender: senderAddress,
recipient: validRecipient,
amount: 0n,
})
expect(result.errors.amount).toBeInstanceOf(NotEnoughBalanceToDelegate)
})
it('maps empty_implicit_contract to NotEnoughBalance for stake', async () => {
mockGetAccountByAddress.mockResolvedValue(
makeUserAccount({
delegate: { alias: 'baker', address: validRecipient, active: true },
delegationLevel: 1,
})
)
mockEstimateFees.mockResolvedValue({
fees: 0n,
gasLimit: 0n,
storageLimit: 0n,
estimatedFees: 500n,
taquitoError: 'proto.empty_implicit_contract',
})
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'stake',
sender: senderAddress,
recipient: '',
amount: 1n,
})
expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
})
it('maps staking.too_much_unstaked to TezosNotEnoughStaked for unstake', async () => {
mockGetAccountByAddress.mockResolvedValue(makeUserAccount({ stakedBalance: 4000 }))
mockEstimateFees.mockResolvedValue({
fees: 0n,
gasLimit: 0n,
storageLimit: 0n,
estimatedFees: 500n,
taquitoError: 'proto.alpha.staking.too_much_unstaked',
})
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'unstake',
sender: senderAddress,
recipient: '',
amount: 1000n,
})
expect(result.errors.amount).toBeInstanceOf(TezosNotEnoughStaked)
})
it('maps contract.must_be_delegated_to_stake to MustDelegateBeforeStaking', async () => {
mockGetAccountByAddress.mockResolvedValue(
makeUserAccount({
delegate: { alias: 'baker', address: validRecipient, active: true },
delegationLevel: 1,
})
)
mockEstimateFees.mockResolvedValue({
fees: 0n,
gasLimit: 0n,
storageLimit: 0n,
estimatedFees: 500n,
taquitoError: 'proto.alpha.contract.must_be_delegated_to_stake',
})
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'stake',
sender: senderAddress,
recipient: '',
amount: 1000n,
})
expect(result.errors.amount).toBeInstanceOf(MustDelegateBeforeStaking)
})
it('maps cannot_stake_with_unfinalizable_unstake_requests_to_another_delegate to TezosStakeBlockedByPendingUnstake for stake', async () => {
mockGetAccountByAddress.mockResolvedValue(
makeUserAccount({
delegate: { alias: 'baker', address: validRecipient, active: true },
delegationLevel: 1,
})
)
mockEstimateFees.mockResolvedValue({
fees: 0n,
gasLimit: 0n,
storageLimit: 0n,
estimatedFees: 500n,
taquitoError:
'proto.alpha.cannot_stake_with_unfinalizable_unstake_requests_to_another_delegate',
})
const result = await validateIntent(context, {
intentType: 'staking',
asset: { type: 'native' },
type: 'stake',
sender: senderAddress,
recipient: '',
amount: 1000n,
})
expect(result.errors.amount).toBeInstanceOf(TezosStakeBlockedByPendingUnstake)
expect(result.errors.amount?.name).toBe('TezosStakeBlockedByPendingUnstake')
})
it('maps unknown taquito errors to a generic Error on amount', async () => {
mockEstimateFees.mockResolvedValue({
fees: 0n,
gasLimit: 0n,
storageLimit: 0n,
estimatedFees: 500n,
taquitoError: 'proto.unknown_rpc_failure',
})
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount: 1000n,
})
expect(result.errors.amount).toBeInstanceOf(Error)
expect(result.errors.amount?.message).toBe('proto.unknown_rpc_failure')
})
})
describe('account fetch and reveal', () => {
it('uses fixed estimated fees when account is not revealed and skips estimateFees', async () => {
mockGetAccountByAddress.mockResolvedValue(makeUserAccount({ revealed: false }))
const amount = 1000000n
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount,
})
expect(mockEstimateFees).not.toHaveBeenCalled()
expect(result.estimatedFees).toBe(2000n)
expect(result.amount).toBe(amount)
expect(result.totalSpent).toBe(amount + 2000n)
})
it('sets estimation error when getAccountByAddress rejects', async () => {
mockGetAccountByAddress.mockRejectedValue(new Error('network failure'))
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount: 1000n,
})
expect(result.errors.estimation).toBeInstanceOf(Error)
expect(result.errors.estimation?.message).toBe('network failure')
expect(result.estimatedFees).toBe(0n)
expect(result.amount).toBe(1000n)
expect(result.totalSpent).toBe(1000n)
})
it('sets estimation error when account type is not user', async () => {
mockGetAccountByAddress.mockResolvedValue({
type: 'empty',
address: senderAddress,
counter: 0,
})
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount: 1000n,
})
expect(result.errors.estimation).toBeInstanceOf(Error)
expect(result.estimatedFees).toBe(0n)
})
})
describe('FA2 token validation', () => {
it('should set amount to full FA2 token balance when useAllAmount is true', async () => {
const contract = 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU'
const tokenBalance = '1234567890'
mockGetTokensBalances.mockResolvedValue([
{
id: 1,
account: { address: senderAddress },
token: {
id: 1,
contract: { address: contract },
tokenId: '0',
standard: 'fa2' as const,
metadata: { symbol: 'TK', decimals: '6' },
},
balance: tokenBalance,
transfersCount: 0,
firstLevel: 0,
firstTime: '',
lastLevel: 0,
lastTime: '',
},
])
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'token', assetReference: `${contract}:0` },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount: 0n,
useAllAmount: true,
})
expect(mockGetTokensBalances).toHaveBeenCalledWith(senderAddress, {
contractAddress: contract,
tokenId: 0,
})
expect(result.errors.amount).toBeUndefined()
expect(result.amount).toBe(BigInt(tokenBalance))
expect(result.totalSpent).toBe(1000n)
})
it('should return NotEnoughBalance when estimation reports script_rejected (FA2_INSUFFICIENT_BALANCE)', async () => {
mockEstimateFees.mockResolvedValue({
fees: 0n,
gasLimit: 0n,
storageLimit: 0n,
estimatedFees: 0n,
taquitoError: 'proto.024-PtTALLiN.michelson_v1.script_rejected',
})
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'token', assetReference: 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU:0' },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount: 1000000n,
})
expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
})
it('should set amount to 0n when useAllAmount and token balance row is missing', async () => {
const contract = 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU'
mockGetTokensBalances.mockResolvedValue([])
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'token', assetReference: `${contract}:0` },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount: 0n,
useAllAmount: true,
})
expect(result.amount).toBe(0n)
expect(result.totalSpent).toBe(1000n)
})
it('fixed-amount TKEY (18 dec): no error when user has enough token and XTZ for fees [LIVE-35976]', async () => {
// 0.01 TKEY = 10^16 base units — vastly exceeds any XTZ balance in mutez, was the exact failure case
const contract = 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU'
const tkeyAmount = 10n ** 16n
mockGetTokensBalances.mockResolvedValue([
{
id: 1,
account: { address: senderAddress },
token: {
id: 1,
contract: { address: contract },
tokenId: '0',
standard: 'fa2' as const,
metadata: { symbol: 'TKEY', decimals: '18' },
},
balance: tkeyAmount.toString(),
transfersCount: 0,
firstLevel: 0,
firstTime: '',
lastLevel: 0,
lastTime: '',
},
])
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'token', assetReference: `${contract}:0` },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount: tkeyAmount,
})
expect(result.errors.amount).toBeUndefined()
expect(result.amount).toBe(tkeyAmount)
expect(result.totalSpent).toBe(1000n) // only XTZ fees; token amount not included
})
it('fixed-amount token send: NotEnoughBalance when XTZ is insufficient for fees [LIVE-35976]', async () => {
const contract = 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU'
const tkeyAmount = 10n ** 16n
mockGetAccountByAddress.mockResolvedValue(makeUserAccount({ balance: 500 })) // 500 mutez < 1000 fees
// Token balance is sufficient — this test must isolate the XTZ coverage branch only.
mockGetTokensBalances.mockResolvedValue([
{
id: 1,
account: { address: senderAddress },
token: {
id: 1,
contract: { address: contract },
tokenId: '0',
standard: 'fa2' as const,
metadata: { symbol: 'TKEY', decimals: '18' },
},
balance: tkeyAmount.toString(),
transfersCount: 0,
firstLevel: 0,
firstTime: '',
lastLevel: 0,
lastTime: '',
},
])
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'token', assetReference: `${contract}:0` },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount: tkeyAmount,
})
expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
})
it('fixed-amount token send: NotEnoughBalance when token balance is insufficient [LIVE-35976]', async () => {
const contract = 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU'
const tkeyAmount = 10n ** 16n
mockGetTokensBalances.mockResolvedValue([
{
id: 1,
account: { address: senderAddress },
token: {
id: 1,
contract: { address: contract },
tokenId: '0',
standard: 'fa2' as const,
metadata: { symbol: 'TKEY', decimals: '18' },
},
balance: (tkeyAmount - 1n).toString(), // one unit short
transfersCount: 0,
firstLevel: 0,
firstTime: '',
lastLevel: 0,
lastTime: '',
},
])
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'token', assetReference: `${contract}:0` },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount: tkeyAmount,
})
expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
})
it('fixed-amount USDt (6 dec): passes correctly, not just accidentally, after fix [LIVE-35976]', async () => {
// 0.01 USDt = 10_000 base units — previously passed only because 10_000 < XTZ balance by coincidence
const contract = 'KT1XnTn74bagnxYoFErbDH5QRAULUDtpXkmV'
const usdtAmount = 10_000n
mockGetTokensBalances.mockResolvedValue([
{
id: 2,
account: { address: senderAddress },
token: {
id: 2,
contract: { address: contract },
tokenId: '0',
standard: 'fa2' as const,
metadata: { symbol: 'USDt', decimals: '6' },
},
balance: usdtAmount.toString(),
transfersCount: 0,
firstLevel: 0,
firstTime: '',
lastLevel: 0,
lastTime: '',
},
])
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'token', assetReference: `${contract}:0` },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount: usdtAmount,
})
expect(result.errors.amount).toBeUndefined()
expect(result.totalSpent).toBe(1000n) // only fees in XTZ
})
it('unrevealed account + fixed-amount FA2 token send: no error and totalSpent equals reveal fees only [LIVE-35976]', async () => {
// Unrevealed accounts skip taquito estimation and get a fixed 2000n fee.
// Before the fix, calculateAmounts fell through to `amount + estimatedFees`, mixing token
// base units with mutez. After the fix it correctly returns `totalSpent: estimatedFees`.
const contract = 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU'
const tkeyAmount = 10n ** 16n
mockGetAccountByAddress.mockResolvedValue(makeUserAccount({ revealed: false }))
mockGetTokensBalances.mockResolvedValue([
{
id: 1,
account: { address: senderAddress },
token: {
id: 1,
contract: { address: contract },
tokenId: '0',
standard: 'fa2' as const,
metadata: { symbol: 'TKEY', decimals: '18' },
},
balance: tkeyAmount.toString(),
transfersCount: 0,
firstLevel: 0,
firstTime: '',
lastLevel: 0,
lastTime: '',
},
])
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'token', assetReference: `${contract}:0` },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount: tkeyAmount,
})
expect(mockEstimateFees).not.toHaveBeenCalled()
expect(result.errors.amount).toBeUndefined()
expect(result.amount).toBe(tkeyAmount)
expect(result.totalSpent).toBe(2000n) // only XTZ reveal+tx fees; no token units mixed in
})
it('unrevealed account + fixed-amount FA2 token send: NotEnoughBalance when token balance is insufficient [LIVE-35976]', async () => {
const contract = 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU'
const tkeyAmount = 10n ** 16n
mockGetAccountByAddress.mockResolvedValue(makeUserAccount({ revealed: false }))
mockGetTokensBalances.mockResolvedValue([
{
id: 1,
account: { address: senderAddress },
token: {
id: 1,
contract: { address: contract },
tokenId: '0',
standard: 'fa2' as const,
metadata: { symbol: 'TKEY', decimals: '18' },
},
balance: (tkeyAmount - 1n).toString(), // one unit short
transfersCount: 0,
firstLevel: 0,
firstTime: '',
lastLevel: 0,
lastTime: '',
},
])
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'token', assetReference: `${contract}:0` },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount: tkeyAmount,
})
expect(mockEstimateFees).not.toHaveBeenCalled()
expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
})
it('send-max token: amount stays in token units when taquito reports balance_too_low', async () => {
// Regression guard for the optimization that skips fetchTokenBalance when errors.amount is set.
// For send-max, tokenBalance is also used as the returned amount — skipping it would cause
// calculateAmounts to fall back to the native XTZ path and return mutez instead of token units.
const contract = 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU'
const tokenBalance = 5_000_000n
mockEstimateFees.mockResolvedValue({
fees: 0n,
gasLimit: 0n,
storageLimit: 0n,
estimatedFees: 0n,
taquitoError: 'proto.024-PtTALLiN.contract.balance_too_low',
})
mockGetTokensBalances.mockResolvedValue([
{
id: 1,
account: { address: senderAddress },
token: {
id: 1,
contract: { address: contract },
tokenId: '0',
standard: 'fa2' as const,
metadata: { symbol: 'TK', decimals: '6' },
},
balance: tokenBalance.toString(),
transfersCount: 0,
firstLevel: 0,
firstTime: '',
lastLevel: 0,
lastTime: '',
},
])
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'token', assetReference: `${contract}:0` },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount: 0n,
useAllAmount: true,
})
expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
expect(mockGetTokensBalances).toHaveBeenCalled()
expect(result.amount).toBe(tokenBalance)
})
})
describe('native XTZ send max', () => {
it('should fall back to balance minus fees when estimateFees returns amount 0n', async () => {
mockEstimateFees.mockResolvedValue({
fees: 1000n,
gasLimit: 10000n,
storageLimit: 0n,
estimatedFees: 1000n,
amount: 0n,
})
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount: 0n,
useAllAmount: true,
})
expect(mockGetTokensBalances).not.toHaveBeenCalled()
expect(result.amount).toBe(4999000n)
expect(result.totalSpent).toBe(5000000n)
})
it('should prefer positive estimatedAmount from estimateFees over balance minus fees', async () => {
const estimatedFromTaquito = 3000000n
mockEstimateFees.mockResolvedValue({
fees: 1000n,
gasLimit: 10000n,
storageLimit: 0n,
estimatedFees: 1000n,
amount: estimatedFromTaquito,
})
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount: 0n,
useAllAmount: true,
})
expect(result.amount).toBe(estimatedFromTaquito)
expect(result.totalSpent).toBe(estimatedFromTaquito + 1000n)
})
it('excludes staked and unstaked funds from the send-max fallback', async () => {
mockGetAccountByAddress.mockResolvedValue(
makeUserAccount({ balance: 5_000_000, stakedBalance: 1_000_000, unstakedBalance: 500_000 })
)
mockEstimateFees.mockResolvedValue({
fees: 1000n,
gasLimit: 10000n,
storageLimit: 0n,
estimatedFees: 1000n,
amount: 0n,
})
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount: 0n,
useAllAmount: true,
})
expect(result.amount).toBe(3_499_000n)
expect(result.totalSpent).toBe(3_500_000n)
})
it('resolves a positive send-max for a delegated account with staked funds (LIVE-28506)', async () => {
// Regression for LIVE-28506: a staking account (necessarily delegated, since you must
// delegate before staking) doing Send Max used to report max spendable = 0 — a soft
// "don't empty a delegated account" error short-circuited the amount computation. Send Max
// must now surface the liquid balance (total minus staked) minus fees: a positive amount
// whenever that liquid balance exceeds fees (as it does here), instead of short-circuiting
// to 0. (It can still be 0 when fees exceed the liquid balance — not the case tested here.)
mockEstimateFees.mockResolvedValue({
fees: 1000n,
gasLimit: 10000n,
storageLimit: 0n,
estimatedFees: 1000n,
amount: 0n,
})
mockGetAccountByAddress.mockResolvedValue(
makeUserAccount({
balance: 5_000_000,
stakedBalance: 3_000_000,
delegate: { alias: 'baker', address: validRecipient, active: true },
delegationLevel: 1,
})
)
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount: 0n,
useAllAmount: true,
})
expect(result.errors).toEqual({})
expect(result.warnings).toEqual({})
// spendable = balance 5_000_000 - staked 3_000_000 = 2_000_000; minus fees 1_000
expect(result.amount).toBe(1_999_000n)
expect(result.totalSpent).toBe(2_000_000n)
})
})
describe('successful validation', () => {
it('should return valid result with correct values', async () => {
const amount = 1000000n
const estimatedFees = 1500n
mockEstimateFees.mockResolvedValue({
fees: 1000n,
gasLimit: 10000n,
storageLimit: 0n,
estimatedFees,
})
const result = await validateIntent(context, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: senderAddress,
recipient: validRecipient,
amount,
})
expect(result).toMatchObject({
errors: {},
warnings: {},
estimatedFees,
amount,
totalSpent: amount + estimatedFees,
})
})
})
})