@ledgerhq/coin-tezos
Version:
1,087 lines (988 loc) • 34.2 kB
text/typescript
// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0
import type { TezosCoinConfig, TezosContext } from '../config'
import type { APIAccount } from '../network/types'
import type { BalanceOptions, Operation } from '@ledgerhq/coin-module-framework/api/types'
import type { TransactionIntent } from '@ledgerhq/coin-module-framework/api/types'
import { createApi } from './index'
// The tzkt factory `createTzktApi(config)` builds a fresh object on every call, so a spy on a
// single instance would not intercept the calls the source makes internally. We mock the module
// so `createTzktApi` always returns the SAME shared object, and expose its `getAccountByAddress`
// jest.fn as `mockGetAccountByAddress` for the assertions below (which used to target
// `mockGetAccountByAddress`).
const mockGetAccountByAddress = jest.fn()
jest.mock('../network/tzkt', () => ({
createTzktApi: () => ({ getAccountByAddress: mockGetAccountByAddress }),
}))
const DEFAULT_ESTIMATED_FEES = 300n
const DEFAULT_GAS_LIMIT = 30n
const DEFAULT_STORAGE_LIMIT = 40n
const logicGetTransactions = jest.fn()
const logicEstimateFees = jest.fn()
const logicCraftTransactionMock = jest.fn(
(_context: unknown, _account: unknown, _transaction: { fee: { fees: string } }) => {
return { type: undefined, contents: undefined }
}
)
const logicCraftRawOperationsMock = jest.fn()
jest.mock('../logic', () => ({
listOperations: async () => logicGetTransactions(),
estimateFees: (...args: unknown[]) => logicEstimateFees(...args),
craftTransaction: (context: unknown, account: unknown, transaction: { fee: { fees: string } }) =>
logicCraftTransactionMock(context, account, transaction),
craftRawOperations: (...args: unknown[]) => logicCraftRawOperationsMock(...args),
rawEncode: () => Promise.resolve('tz1heMGVHQnx7ALDcDKqez8fan64Eyicw4DJ'),
}))
const mockGetTezosToolkit = jest.fn()
jest.mock('../logic/tezosToolkit', () => ({
getTezosToolkit: () => mockGetTezosToolkit(),
}))
mockGetAccountByAddress.mockResolvedValue({
type: 'user',
balance: 1000,
revealed: true,
address: 'tz1test',
publicKey: 'edpktest',
counter: 0,
delegationLevel: 0,
delegationTime: '2021-01-01T00:00:00Z',
numTransactions: 0,
firstActivityTime: '2021-01-01T00:00:00Z',
} as APIAccount)
const api = createApi()
/** Builds a TezosContext carrying the given coin config (see logic/getBalance.test.ts). */
function makeContext(config: unknown): TezosContext {
return {
config: async () => config as TezosCoinConfig,
logger: () => undefined,
}
}
const context = makeContext({
baker: {
url: 'https://baker.example.com',
},
explorer: {
url: 'foo',
maxTxQuery: 1,
},
node: {
url: 'bar',
},
fees: {
minGasLimit: 1,
minRevealGasLimit: 1,
minStorageLimit: 1,
minFees: 1,
minEstimatedFees: 2,
},
})
describe('craftRawTransaction', () => {
afterEach(() => {
logicCraftRawOperationsMock.mockClear()
})
it('delegates to craftRawOperations and wraps the result', async () => {
logicCraftRawOperationsMock.mockResolvedValue('03deadbeef')
const rawTransaction = '[{"kind":"transaction","destination":"tz1recipient","amount":"1000"}]'
const result = await api.craftRawTransaction(
context,
rawTransaction,
'tz1sender',
'edpkpublickey',
5n
)
expect(logicCraftRawOperationsMock).toHaveBeenCalledWith(
expect.anything(),
rawTransaction,
'tz1sender',
'edpkpublickey',
5n
)
expect(result).toEqual({ transaction: '03deadbeef' })
})
})
describe('get operations', () => {
afterEach(() => {
logicGetTransactions.mockClear()
})
it('could return no operation', async () => {
logicGetTransactions.mockResolvedValue([[], ''])
const { items: operations, next: token } = await api.listOperations(context, 'addr', {
minHeight: 100,
order: 'asc',
})
expect(operations).toEqual([])
expect(token).toBeUndefined()
})
const op: Operation = {
id: 'blockhash',
asset: { type: 'native' },
tx: {
hash: 'opHash',
fees: BigInt(100),
block: {
hash: 'blockHash',
height: 123456,
time: new Date(),
},
date: new Date(),
failed: false,
},
type: 'transaction',
value: BigInt(1000),
senders: ['tz1Sender'],
recipients: ['tz1Recipient'],
}
it('only does 1 iteration', async () => {
logicGetTransactions.mockResolvedValue([[op], '888'])
const { items: operations, next: token } = await api.listOperations(context, 'addr', {
minHeight: 100,
order: 'asc',
})
expect(logicGetTransactions).toHaveBeenCalledTimes(1)
expect(operations.length).toBe(1)
expect(token).toEqual('888')
})
})
describe('craftTransaction', () => {
beforeEach(() => jest.clearAllMocks())
describe('when suggested fee is above or below minFees floor', () => {
it('craft transaction when default estimation is greater than minFees', async () => {
const estimatedFees = 500n
logicEstimateFees.mockResolvedValue({
estimatedFees,
gasLimit: DEFAULT_GAS_LIMIT,
storageLimit: DEFAULT_STORAGE_LIMIT,
parameters: {
gasLimit: DEFAULT_GAS_LIMIT,
storageLimit: DEFAULT_STORAGE_LIMIT,
txFee: 500n,
},
})
await api.craftTransaction(context, {
intentType: 'transaction',
type: 'send',
sender: 'tz1test',
recipient: 'tz1recipient',
amount: 1000n,
} as TransactionIntent)
expect(logicEstimateFees).toHaveBeenCalledTimes(1)
expect(logicCraftTransactionMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ address: 'tz1test' }),
expect.objectContaining({
fee: expect.objectContaining({
fees: estimatedFees.toString(),
gasLimit: DEFAULT_GAS_LIMIT.toString(),
storageLimit: DEFAULT_STORAGE_LIMIT.toString(),
}),
})
)
})
it('craft transaction when default estimation is lesser than minFees', async () => {
const estimatedFees = 50n
logicEstimateFees.mockResolvedValue({
estimatedFees,
gasLimit: DEFAULT_GAS_LIMIT,
storageLimit: DEFAULT_STORAGE_LIMIT,
parameters: {
gasLimit: DEFAULT_GAS_LIMIT,
storageLimit: DEFAULT_STORAGE_LIMIT,
txFee: 50n,
},
})
await api.craftTransaction(context, {
intentType: 'transaction',
type: 'send',
sender: 'tz1test',
recipient: 'tz1recipient',
amount: 1000n,
} as TransactionIntent)
expect(logicCraftTransactionMock).toHaveBeenCalledWith(
expect.anything(),
expect.any(Object),
expect.objectContaining({
fee: expect.objectContaining({ fees: estimatedFees.toString() }),
})
)
})
})
describe('with customFee', () => {
it('craft transaction with customFee when default estimation is greater than customFee', async () => {
const defaultEstimatedFees = 500n
const customFee = 100n
logicEstimateFees.mockResolvedValue({
estimatedFees: defaultEstimatedFees,
gasLimit: DEFAULT_GAS_LIMIT,
storageLimit: DEFAULT_STORAGE_LIMIT,
parameters: {
gasLimit: DEFAULT_GAS_LIMIT,
storageLimit: DEFAULT_STORAGE_LIMIT,
},
})
await api.craftTransaction(
context,
{
intentType: 'transaction',
type: 'send',
sender: 'tz1test',
recipient: 'tz1recipient',
amount: 1000n,
} as TransactionIntent,
{ customFees: { value: customFee } }
)
expect(logicCraftTransactionMock).toHaveBeenCalledWith(
expect.anything(),
expect.any(Object),
expect.objectContaining({
fee: expect.objectContaining({ fees: customFee.toString() }),
})
)
})
it('craft transaction with customFee when default estimation is lesser than customFee', async () => {
const customFee = 500n
logicEstimateFees.mockResolvedValue({
estimatedFees: 100n,
gasLimit: DEFAULT_GAS_LIMIT,
storageLimit: DEFAULT_STORAGE_LIMIT,
parameters: {
gasLimit: DEFAULT_GAS_LIMIT,
storageLimit: DEFAULT_STORAGE_LIMIT,
},
})
await api.craftTransaction(
context,
{
intentType: 'transaction',
type: 'send',
sender: 'tz1test',
recipient: 'tz1recipient',
amount: 1000n,
} as TransactionIntent,
{ customFees: { value: customFee } }
)
expect(logicCraftTransactionMock).toHaveBeenCalledWith(
expect.anything(),
expect.any(Object),
expect.objectContaining({
fee: expect.objectContaining({ fees: customFee.toString() }),
})
)
})
it('craft transaction with customFee splits total for unrevealed delegate so total equals customFee', async () => {
const minFees = 1000
const unrevealedSender = 'tz2TaTpo31sAiX2HBJUTLLdUnqVJR4QjLy1V'
const delegateRecipient = 'tz3Vq38qYD3GEbWcXHMLt5PaASZrkDtEiA8D'
const totalCustomFee = BigInt(minFees * 2)
const contextMinFees1000 = makeContext({
baker: { url: 'https://baker.example.com' },
explorer: { url: 'foo', maxTxQuery: 1 },
node: { url: 'bar' },
fees: {
minGasLimit: 600,
minRevealGasLimit: 300,
minStorageLimit: 0,
minFees,
minEstimatedFees: minFees,
},
})
const unrevealedAccount = {
type: 'user' as const,
balance: 1000000,
revealed: false,
address: unrevealedSender,
publicKey: 'sppktest',
counter: 0,
delegationLevel: 0,
delegationTime: '2021-01-01T00:00:00Z',
numTransactions: 0,
firstActivityTime: '2021-01-01T00:00:00Z',
} as APIAccount
mockGetAccountByAddress
.mockResolvedValueOnce(unrevealedAccount)
.mockResolvedValueOnce(unrevealedAccount)
logicEstimateFees.mockResolvedValue({
estimatedFees: totalCustomFee,
fees: 1000n,
gasLimit: 10000n,
storageLimit: 0n,
parameters: {
gasLimit: 10000n,
storageLimit: 0n,
txFee: 1000n,
},
})
await api.craftTransaction(
contextMinFees1000,
{
intentType: 'staking',
type: 'delegate',
sender: unrevealedSender,
senderPublicKey: '021bab48f41fc555e0fcf322a28e31b56f4369242f65324758ec8bbae3e84109a5',
recipient: delegateRecipient,
amount: 0n,
} as TransactionIntent,
{ value: totalCustomFee }
)
expect(logicCraftTransactionMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ address: unrevealedSender }),
expect.objectContaining({
fee: expect.objectContaining({
fees: String(minFees),
gasLimit: '10000',
storageLimit: '0',
}),
})
)
})
})
it.each(['stake', 'unstake'] as const)(
'passes %s mode and explicit amount through to craftTransaction',
async (type) => {
logicEstimateFees.mockResolvedValue({
estimatedFees: DEFAULT_ESTIMATED_FEES,
fees: DEFAULT_ESTIMATED_FEES,
gasLimit: DEFAULT_GAS_LIMIT,
storageLimit: DEFAULT_STORAGE_LIMIT,
})
await api.craftTransaction(context, {
intentType: 'staking',
type,
sender: 'tz1test',
recipient: '',
amount: 1234n,
} as TransactionIntent)
expect(logicCraftTransactionMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ address: 'tz1test' }),
expect.objectContaining({
type,
amount: 1234n,
})
)
}
)
it('passes finalize_unstake mode with zero amount through to craftTransaction', async () => {
logicEstimateFees.mockResolvedValue({
estimatedFees: DEFAULT_ESTIMATED_FEES,
fees: DEFAULT_ESTIMATED_FEES,
gasLimit: DEFAULT_GAS_LIMIT,
storageLimit: DEFAULT_STORAGE_LIMIT,
})
await api.craftTransaction(context, {
intentType: 'staking',
type: 'finalize_unstake',
sender: 'tz1test',
recipient: '',
amount: 999n,
} as TransactionIntent)
expect(logicCraftTransactionMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ address: 'tz1test' }),
expect.objectContaining({
type: 'finalize_unstake',
amount: 0n,
})
)
})
})
describe('estimateFees', () => {
beforeEach(() => jest.clearAllMocks())
describe('when suggested fee is above or below minFees floor', () => {
it('estimate fees when default estimation is greater than minFees', async () => {
const estimatedFees = 500n
logicEstimateFees.mockResolvedValue({
estimatedFees,
gasLimit: DEFAULT_GAS_LIMIT,
storageLimit: DEFAULT_STORAGE_LIMIT,
})
const result = await api.estimateFees(context, {
intentType: 'transaction',
type: 'send',
sender: 'tz1test',
recipient: 'tz1recipient',
amount: 1000n,
} as TransactionIntent)
expect(result.value).toBe(estimatedFees)
expect(result.parameters?.gasLimit).toBe(DEFAULT_GAS_LIMIT)
expect(result.parameters?.storageLimit).toBe(DEFAULT_STORAGE_LIMIT)
})
it('estimate fees when default estimation is lesser than minFees', async () => {
const estimatedFees = 100n
logicEstimateFees.mockResolvedValue({
estimatedFees,
gasLimit: DEFAULT_GAS_LIMIT,
storageLimit: DEFAULT_STORAGE_LIMIT,
})
const result = await api.estimateFees(context, {
intentType: 'transaction',
type: 'send',
sender: 'tz1test',
recipient: 'tz1recipient',
amount: 1000n,
} as TransactionIntent)
expect(result.value).toBe(estimatedFees)
expect(result.parameters?.gasLimit).toBe(DEFAULT_GAS_LIMIT)
expect(result.parameters?.storageLimit).toBe(DEFAULT_STORAGE_LIMIT)
})
})
it('returns estimation from logic module', async () => {
logicEstimateFees.mockResolvedValue({
estimatedFees: DEFAULT_ESTIMATED_FEES,
gasLimit: DEFAULT_GAS_LIMIT,
storageLimit: DEFAULT_STORAGE_LIMIT,
})
const result = await api.estimateFees(context, {
intentType: 'transaction',
type: 'send',
sender: 'tz1test',
recipient: 'tz1recipient',
amount: 1000n,
} as TransactionIntent)
expect(result).toEqual({
value: DEFAULT_ESTIMATED_FEES,
parameters: {
gasLimit: DEFAULT_GAS_LIMIT,
storageLimit: DEFAULT_STORAGE_LIMIT,
},
})
})
it('forwards stakedBalance and unstakedBalance to the logic estimate for send-max', async () => {
mockGetAccountByAddress.mockResolvedValueOnce({
type: 'user',
balance: 1000,
revealed: true,
address: 'tz1test',
publicKey: 'edpktest',
counter: 0,
delegationLevel: 0,
delegationTime: '2021-01-01T00:00:00Z',
numTransactions: 0,
firstActivityTime: '2021-01-01T00:00:00Z',
stakedBalance: 200,
unstakedBalance: 300,
})
logicEstimateFees.mockResolvedValue({
estimatedFees: DEFAULT_ESTIMATED_FEES,
gasLimit: DEFAULT_GAS_LIMIT,
storageLimit: DEFAULT_STORAGE_LIMIT,
amount: 500n,
})
await api.estimateFees(context, {
intentType: 'transaction',
type: 'send',
sender: 'tz1test',
recipient: 'tz1recipient',
amount: 0n,
useAllAmount: true,
} as TransactionIntent)
expect(logicEstimateFees).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
account: expect.objectContaining({ stakedBalance: 200n, unstakedBalance: 300n }),
})
)
})
it('should throw taquito errors', async () => {
logicEstimateFees.mockResolvedValue({
estimatedFees: DEFAULT_ESTIMATED_FEES,
gasLimit: DEFAULT_GAS_LIMIT,
storageLimit: DEFAULT_STORAGE_LIMIT,
taquitoError: 'test',
})
await expect(
api.estimateFees(context, {
intentType: 'transaction',
type: 'send',
sender: 'tz1test',
recipient: 'tz1recipient',
amount: 1000n,
} as TransactionIntent)
).rejects.toThrow('Fees estimation failed: test')
})
it('should not throw for FA2 script_rejected errors (returns estimation)', async () => {
logicEstimateFees.mockResolvedValue({
estimatedFees: DEFAULT_ESTIMATED_FEES,
gasLimit: DEFAULT_GAS_LIMIT,
storageLimit: DEFAULT_STORAGE_LIMIT,
taquitoError: 'proto.024-PtTALLiN.michelson_v1.script_rejected',
})
const result = await api.estimateFees(context, {
intentType: 'transaction',
type: 'send',
sender: 'tz1test',
recipient: 'tz1recipient',
amount: 1000n,
asset: { type: 'token', assetReference: 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU:0' },
} as TransactionIntent)
expect(result.value).toBe(DEFAULT_ESTIMATED_FEES)
expect(result.parameters?.gasLimit).toBe(DEFAULT_GAS_LIMIT)
expect(result.parameters?.storageLimit).toBe(DEFAULT_STORAGE_LIMIT)
})
it('should not throw for delegate.unchanged errors', async () => {
logicEstimateFees.mockResolvedValue({
estimatedFees: DEFAULT_ESTIMATED_FEES,
gasLimit: DEFAULT_GAS_LIMIT,
storageLimit: DEFAULT_STORAGE_LIMIT,
taquitoError: 'proto.022-PsRiotum.delegate.unchanged',
})
const result = await api.estimateFees(context, {
intentType: 'staking',
type: 'delegate',
sender: 'tz1test',
recipient: 'tz1validator',
amount: 0n,
} as TransactionIntent)
expect(result).toEqual({
value: DEFAULT_ESTIMATED_FEES,
parameters: {
gasLimit: DEFAULT_GAS_LIMIT,
storageLimit: DEFAULT_STORAGE_LIMIT,
},
})
})
it('should not throw for cannot_stake_with_unfinalizable_unstake errors', async () => {
logicEstimateFees.mockResolvedValue({
estimatedFees: DEFAULT_ESTIMATED_FEES,
gasLimit: DEFAULT_GAS_LIMIT,
storageLimit: DEFAULT_STORAGE_LIMIT,
taquitoError:
'proto.alpha.cannot_stake_with_unfinalizable_unstake_requests_to_another_delegate',
})
const result = await api.estimateFees(context, {
intentType: 'staking',
type: 'stake',
sender: 'tz1test',
recipient: 'tz1validator',
amount: 1000n,
} as TransactionIntent)
expect(result).toEqual({
value: DEFAULT_ESTIMATED_FEES,
parameters: {
gasLimit: DEFAULT_GAS_LIMIT,
storageLimit: DEFAULT_STORAGE_LIMIT,
},
})
})
it('returns total estimation for unrevealed delegate when minFees applied (composite)', async () => {
const unrevealedSender = 'tz2TaTpo31sAiX2HBJUTLLdUnqVJR4QjLy1V'
mockGetAccountByAddress.mockResolvedValueOnce({
type: 'user',
balance: 1000000,
revealed: false,
address: unrevealedSender,
publicKey: undefined,
counter: 0,
delegationLevel: 0,
delegationTime: '2021-01-01T00:00:00Z',
numTransactions: 0,
firstActivityTime: '2021-01-01T00:00:00Z',
} as APIAccount)
const expectedTotalFees = 2000n
logicEstimateFees.mockResolvedValue({
estimatedFees: expectedTotalFees,
fees: 1000n,
gasLimit: DEFAULT_GAS_LIMIT,
storageLimit: DEFAULT_STORAGE_LIMIT,
})
const result = await api.estimateFees(context, {
intentType: 'staking',
type: 'delegate',
sender: unrevealedSender,
recipient: 'tz3Vq38qYD3GEbWcXHMLt5PaASZrkDtEiA8D',
amount: 0n,
} as TransactionIntent)
expect(result.value).toBe(expectedTotalFees)
expect(result.parameters.gasLimit).toBe(DEFAULT_GAS_LIMIT)
expect(result.parameters.storageLimit).toBe(DEFAULT_STORAGE_LIMIT)
})
it.each([
['stake', 1234n, 1234n],
['unstake', 1234n, 1234n],
['finalize_unstake', 999n, 0n],
] as const)('delegates %s estimation to the logic layer', async (type, intentAmount, amount) => {
logicEstimateFees.mockResolvedValue({
estimatedFees: DEFAULT_ESTIMATED_FEES,
fees: DEFAULT_ESTIMATED_FEES,
gasLimit: DEFAULT_GAS_LIMIT,
storageLimit: DEFAULT_STORAGE_LIMIT,
})
const result = await api.estimateFees(context, {
intentType: 'staking',
type,
sender: 'tz1test',
recipient: '',
amount: intentAmount,
} as TransactionIntent)
expect(result.value).toBe(DEFAULT_ESTIMATED_FEES)
expect(logicEstimateFees).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
transaction: expect.objectContaining({
mode: type,
amount,
}),
})
)
})
it('fallback when Taquito throws Public key not found returns total with minFees for reveal (unrevealed)', async () => {
const unrevealedSender = 'tz2TaTpo31sAiX2HBJUTLLdUnqVJR4QjLy1V'
const delegateRecipient = 'tz3Vq38qYD3GEbWcXHMLt5PaASZrkDtEiA8D'
const minFees = 1000
const contextMinFees1000 = makeContext({
baker: { url: 'https://baker.example.com' },
explorer: { url: 'foo', maxTxQuery: 1 },
node: { url: 'bar' },
fees: {
minGasLimit: 600,
minRevealGasLimit: 300,
minStorageLimit: 0,
minFees,
minEstimatedFees: minFees,
},
})
logicEstimateFees.mockImplementation(() => Promise.reject(new Error('Public key not found')))
const defaultUserForTry = {
type: 'user' as const,
balance: 1000,
revealed: true,
address: unrevealedSender,
publicKey: 'edpktest',
counter: 0,
delegationLevel: 0,
delegationTime: '2021-01-01T00:00:00Z',
numTransactions: 0,
firstActivityTime: '2021-01-01T00:00:00Z',
} as APIAccount
const recipientAccount = {
type: 'user' as const,
balance: 1000000,
address: delegateRecipient,
counter: 0,
delegationLevel: 0,
delegationTime: '2021-01-01T00:00:00Z',
numTransactions: 0,
firstActivityTime: '2021-01-01T00:00:00Z',
} as APIAccount
const senderUnrevealedAccount = {
type: 'user' as const,
balance: 1000000,
revealed: false,
address: unrevealedSender,
publicKey: undefined,
counter: 0,
delegationLevel: 0,
delegationTime: '2021-01-01T00:00:00Z',
numTransactions: 0,
firstActivityTime: '2021-01-01T00:00:00Z',
} as APIAccount
mockGetAccountByAddress
.mockImplementationOnce(() => Promise.resolve(defaultUserForTry))
.mockImplementationOnce(() => Promise.resolve(recipientAccount))
.mockImplementationOnce(() => Promise.resolve(senderUnrevealedAccount))
mockGetTezosToolkit.mockReturnValueOnce({
estimate: {
transfer: jest.fn().mockResolvedValue({
suggestedFeeMutez: 100,
gasLimit: 10000,
storageLimit: 0,
burnFeeMutez: 0,
opSize: 100,
}),
},
})
const result = await api.estimateFees(contextMinFees1000, {
intentType: 'staking',
type: 'delegate',
sender: unrevealedSender,
recipient: delegateRecipient,
amount: 0n,
} as TransactionIntent)
expect(result.value).toBe(2000n)
expect(result.parameters?.txFee).toBe(1000n)
expect(result.parameters?.gasLimit).toBe(10000n)
expect(result.parameters?.storageLimit).toBe(0n)
logicEstimateFees.mockReset()
})
it('uses static fallback gas when both logicEstimate and toolkit.estimate.transfer throw (revealed sender)', async () => {
// SAFE_FALLBACK_GAS = 2500, FALLBACK_OP_SIZE_BYTES = 154
// baseTxFee = max(minFees=1, ceil(120 + 0.1*2500 + 154)) = max(1, 524) = 524
// createApi here to reset the global coinConfig to minFees=1 (other tests may leave it at a higher value)
const contextLowMinFees = makeContext({
baker: { url: 'https://baker.example.com' },
explorer: { url: 'foo', maxTxQuery: 1 },
node: { url: 'bar' },
fees: {
minGasLimit: 1,
minRevealGasLimit: 1,
minStorageLimit: 1,
minFees: 1,
minEstimatedFees: 2,
},
})
const revealedSender = 'tz1test'
const recipient = 'tz2recipient'
const revealedAccount = {
type: 'user' as const,
balance: 1000000,
revealed: true,
address: revealedSender,
publicKey: 'edpktest',
counter: 0,
delegationLevel: 0,
delegationTime: '2021-01-01T00:00:00Z',
numTransactions: 0,
firstActivityTime: '2021-01-01T00:00:00Z',
} as APIAccount
const recipientAccount = {
type: 'user' as const,
balance: 1000000,
revealed: true,
address: recipient,
publicKey: 'edpkrecipient',
counter: 0,
delegationLevel: 0,
delegationTime: '2021-01-01T00:00:00Z',
numTransactions: 0,
firstActivityTime: '2021-01-01T00:00:00Z',
} as APIAccount
mockGetAccountByAddress
.mockResolvedValueOnce(revealedAccount) // initial sender lookup
.mockResolvedValueOnce(recipientAccount) // recipient lookup in catch block
.mockResolvedValueOnce(revealedAccount) // sender lookup for needsReveal in catch block
logicEstimateFees.mockRejectedValue(new Error('Public key not found'))
mockGetTezosToolkit.mockReturnValueOnce({
estimate: {
transfer: jest.fn().mockRejectedValue(new Error('Public key not found')),
},
})
const result = await api.estimateFees(contextLowMinFees, {
intentType: 'transaction',
type: 'send',
sender: revealedSender,
recipient,
amount: 1000n,
} as TransactionIntent)
// No reveal needed → revealFee = 0 → total = baseTxFee = 524
expect(result.value).toBe(524n)
expect(result.parameters?.txFee).toBe(524n)
expect(result.parameters?.gasLimit).toBe(2500n)
expect(result.parameters?.storageLimit).toBe(0n)
logicEstimateFees.mockReset()
})
it('uses static fallback gas and adds reveal fee when sender is unrevealed', async () => {
// baseTxFee = max(minFees=1, ceil(120 + 0.1*2500 + 154)) = 524
// revealFee = max(0, getRevealFee(tz2address)) — tz2 addresses have a non-zero reveal fee
// createApi here to reset the global coinConfig to minFees=1 (other tests may leave it at a higher value)
const contextLowMinFees = makeContext({
baker: { url: 'https://baker.example.com' },
explorer: { url: 'foo', maxTxQuery: 1 },
node: { url: 'bar' },
fees: {
minGasLimit: 1,
minRevealGasLimit: 1,
minStorageLimit: 1,
minFees: 1,
minEstimatedFees: 2,
},
})
const unrevealedSender = 'tz2TaTpo31sAiX2HBJUTLLdUnqVJR4QjLy1V'
const recipient = 'tz1recipient'
const unrevealedAccount = {
type: 'user' as const,
balance: 1000000,
revealed: false,
address: unrevealedSender,
publicKey: undefined,
counter: 0,
delegationLevel: 0,
delegationTime: '2021-01-01T00:00:00Z',
numTransactions: 0,
firstActivityTime: '2021-01-01T00:00:00Z',
} as APIAccount
const recipientAccount = {
type: 'user' as const,
balance: 1000000,
revealed: true,
address: recipient,
publicKey: 'edpkrecipient',
counter: 0,
delegationLevel: 0,
delegationTime: '2021-01-01T00:00:00Z',
numTransactions: 0,
firstActivityTime: '2021-01-01T00:00:00Z',
} as APIAccount
mockGetAccountByAddress
.mockResolvedValueOnce(unrevealedAccount) // initial sender lookup
.mockResolvedValueOnce(recipientAccount) // recipient lookup in catch block
.mockResolvedValueOnce(unrevealedAccount) // sender lookup for needsReveal in catch block
logicEstimateFees.mockRejectedValue(new Error('Public key not found'))
mockGetTezosToolkit.mockReturnValueOnce({
estimate: {
transfer: jest.fn().mockRejectedValue(new Error('Public key not found')),
},
})
const result = await api.estimateFees(contextLowMinFees, {
intentType: 'transaction',
type: 'send',
sender: unrevealedSender,
recipient,
amount: 1000n,
} as TransactionIntent)
// baseTxFee = 524n; revealFee > 0 (tz2 secp256k1 address)
// total > 524n and txFee stays at 524n
expect(result.parameters?.txFee).toBe(524n)
expect(result.parameters?.gasLimit).toBe(2500n)
expect(result.parameters?.storageLimit).toBe(0n)
expect(result.value).toBeGreaterThan(524n)
logicEstimateFees.mockReset()
})
it('uses minFees floor when it exceeds the static fallback calculation', async () => {
// With minFees = 2000 > ceil(120 + 0.1*2500 + 154) = 524 → baseTxFee = 2000
const minFees = 2000
const contextHighMinFees = makeContext({
baker: { url: 'https://baker.example.com' },
explorer: { url: 'foo', maxTxQuery: 1 },
node: { url: 'bar' },
fees: {
minGasLimit: 1,
minRevealGasLimit: 1,
minStorageLimit: 1,
minFees,
minEstimatedFees: minFees,
},
})
const revealedAccount = {
type: 'user' as const,
balance: 1000000,
revealed: true,
address: 'tz1test',
publicKey: 'edpktest',
counter: 0,
delegationLevel: 0,
delegationTime: '2021-01-01T00:00:00Z',
numTransactions: 0,
firstActivityTime: '2021-01-01T00:00:00Z',
} as APIAccount
const recipientAccount = {
type: 'user' as const,
balance: 1000000,
revealed: true,
address: 'tz2recipient',
publicKey: 'edpkrecipient',
counter: 0,
delegationLevel: 0,
delegationTime: '2021-01-01T00:00:00Z',
numTransactions: 0,
firstActivityTime: '2021-01-01T00:00:00Z',
} as APIAccount
mockGetAccountByAddress
.mockResolvedValueOnce(revealedAccount)
.mockResolvedValueOnce(recipientAccount)
.mockResolvedValueOnce(revealedAccount)
logicEstimateFees.mockRejectedValue(new Error('Public key not found'))
mockGetTezosToolkit.mockReturnValueOnce({
estimate: {
transfer: jest.fn().mockRejectedValue(new Error('Public key not found')),
},
})
const result = await api.estimateFees(contextHighMinFees, {
intentType: 'transaction',
type: 'send',
sender: 'tz1test',
recipient: 'tz2recipient',
amount: 1000n,
} as TransactionIntent)
expect(result.value).toBe(BigInt(minFees))
expect(result.parameters?.txFee).toBe(BigInt(minFees))
expect(result.parameters?.gasLimit).toBe(2500n)
logicEstimateFees.mockReset()
})
})
describe('getBalance', () => {
it('should throw an exception when options is provided', async () => {
await expect(
api.getBalance(context, 'random address', {} as unknown as BalanceOptions)
).rejects.toMatchObject({ name: 'InvalidParameterError' })
})
})
describe('getAccountInfo', () => {
// `getAccountInfo` is optional on the generic CoinModuleApi (ADR-045 / LIVE-33431); coin-tezos
// always implements it, so the non-null assertions below are safe.
afterEach(() => {
mockGetAccountByAddress.mockClear()
})
it('returns revealed:true for a revealed user account', async () => {
mockGetAccountByAddress.mockResolvedValueOnce({
type: 'user',
balance: 1000,
revealed: true,
address: 'tz1test',
publicKey: 'edpktest',
counter: 0,
delegationLevel: 0,
delegationTime: '2021-01-01T00:00:00Z',
numTransactions: 0,
firstActivityTime: '2021-01-01T00:00:00Z',
} as APIAccount)
await expect(api.getAccountInfo!(context, 'tz1test')).resolves.toEqual({
type: 'tezos',
revealed: true,
})
expect(mockGetAccountByAddress).toHaveBeenCalledWith('tz1test')
})
it('returns revealed:false for an unrevealed user account', async () => {
mockGetAccountByAddress.mockResolvedValueOnce({
type: 'user',
balance: 1000,
revealed: false,
address: 'tz2unrevealed',
publicKey: '',
counter: 0,
delegationLevel: 0,
delegationTime: '2021-01-01T00:00:00Z',
numTransactions: 0,
firstActivityTime: '2021-01-01T00:00:00Z',
} as APIAccount)
await expect(api.getAccountInfo!(context, 'tz2unrevealed')).resolves.toEqual({
type: 'tezos',
revealed: false,
})
expect(mockGetAccountByAddress).toHaveBeenCalledWith('tz2unrevealed')
})
it('returns revealed:true for a registered baker (delegate) account', async () => {
// tzkt reports bakers with type "delegate" (not "user"), still carrying revealed:true.
// Regression test for LIVE-34256: bakers must not be reported as unrevealed.
mockGetAccountByAddress.mockResolvedValueOnce({
type: 'delegate',
balance: 616109,
revealed: true,
address: 'tz3RDC3Jdn4j15J7bBHZd29EUee9gVB1CxD9',
publicKey: 'p2pktest',
counter: 18,
} as APIAccount)
await expect(
api.getAccountInfo!(context, 'tz3RDC3Jdn4j15J7bBHZd29EUee9gVB1CxD9')
).resolves.toEqual({
type: 'tezos',
revealed: true,
})
expect(mockGetAccountByAddress).toHaveBeenCalledWith('tz3RDC3Jdn4j15J7bBHZd29EUee9gVB1CxD9')
})
it('returns revealed:false for an empty / non-existent account', async () => {
mockGetAccountByAddress.mockResolvedValueOnce({
type: 'empty',
address: 'tz1empty',
counter: 0,
} as APIAccount)
await expect(api.getAccountInfo!(context, 'tz1empty')).resolves.toEqual({
type: 'tezos',
revealed: false,
})
expect(mockGetAccountByAddress).toHaveBeenCalledWith('tz1empty')
})
it('propagates errors from getAccountByAddress', async () => {
mockGetAccountByAddress.mockRejectedValueOnce(new Error('boom'))
await expect(api.getAccountInfo!(context, 'tz1boom')).rejects.toThrow('boom')
})
})