@ledgerhq/coin-tezos
Version:
1,360 lines (1,232 loc) • 46.6 kB
text/typescript
// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0
import type { TezosCoinConfig, TezosContext } from '../config'
import type {
APIBlock,
APIDelegationType,
APIRevealType,
APIStakingType,
APITokenTransfer,
APITransactionType,
} from '../network/types'
import { listOperations } from './listOperations'
const mockGetAccountOperations = jest.fn()
const mockGetAccountTokenTransfers = jest.fn()
const mockGetBlockHashesByLevels = jest.fn()
jest.mock('../network', () => ({
createTzktApi: () => ({
getAccountOperations: async (address: string, options: unknown) =>
mockGetAccountOperations(address, options),
getAccountTokenTransfers: async (address: string, options: unknown) =>
mockGetAccountTokenTransfers(address, options),
getBlockHashesByLevels: async (levels: number[]) => mockGetBlockHashesByLevels(levels),
}),
}))
const context: TezosContext = {
config: async () => ({}) as unknown as TezosCoinConfig,
logger: () => undefined,
}
const options: {
sort: 'Ascending' | 'Descending'
minHeight: number
limit?: number
} = {
sort: 'Ascending',
minHeight: 0,
}
describe('listOperations', () => {
afterEach(() => {
mockGetAccountOperations.mockReset()
mockGetAccountTokenTransfers.mockReset()
mockGetBlockHashesByLevels.mockReset()
})
beforeEach(() => {
mockGetAccountOperations.mockResolvedValue([])
mockGetAccountTokenTransfers.mockResolvedValue([])
})
it('forwards level.lt from cursor on both streams when sort is Descending', async () => {
await listOperations(context, 'tz1PaginationAddr', {
...options,
sort: 'Descending',
token: JSON.stringify({ lastLevel: 5_000_000 }),
})
expect(mockGetAccountOperations).toHaveBeenCalledWith(
'tz1PaginationAddr',
expect.objectContaining({
'level.lt': 5_000_000,
sort: 'Descending',
'level.ge': options.minHeight,
})
)
expect(mockGetAccountTokenTransfers).toHaveBeenCalledWith(
'tz1PaginationAddr',
expect.objectContaining({
'level.lt': 5_000_000,
sort: 'Descending',
'level.ge': options.minHeight,
})
)
})
it('forwards level.ge continuation when sort is Ascending and cursor has lastLevel', async () => {
await listOperations(context, 'tz1AscPage2', {
...options,
sort: 'Ascending',
minHeight: 0,
token: JSON.stringify({ lastLevel: 100 }),
})
expect(mockGetAccountOperations).toHaveBeenCalledWith(
'tz1AscPage2',
expect.objectContaining({
'level.ge': 101,
sort: 'Ascending',
})
)
expect(mockGetAccountTokenTransfers).toHaveBeenCalledWith(
'tz1AscPage2',
expect.objectContaining({
'level.ge': 101,
sort: 'Ascending',
})
)
})
it('ignores legacy id-based pagination cursor (fresh level window)', async () => {
await listOperations(context, 'tz1Legacy', {
...options,
token: JSON.stringify({ nativeLastId: 111, tokenLastId: 222 }),
})
const [, nativeOpts] = mockGetAccountOperations.mock.calls[0]
const [, tokenOpts] = mockGetAccountTokenTransfers.mock.calls[0]
expect(nativeOpts).not.toHaveProperty('level.lt')
expect(nativeOpts).not.toHaveProperty('lastId')
expect(tokenOpts).not.toHaveProperty('level.lt')
expect(tokenOpts).not.toHaveProperty('lastId')
})
it('ignores invalid pagination cursor JSON', async () => {
await listOperations(context, 'tz1BadCursor', {
...options,
token: 'not-valid-json{',
})
const [, nativeOpts] = mockGetAccountOperations.mock.calls[0]
const [, tokenOpts] = mockGetAccountTokenTransfers.mock.calls[0]
expect(nativeOpts).not.toHaveProperty('level.lt')
expect(tokenOpts).not.toHaveProperty('level.lt')
})
it('should return no operations', async () => {
// Given
// When
const [results, token] = await listOperations(context, 'any address', options)
// Then
expect(results).toEqual([])
expect(token).toEqual('')
})
const someDestinationAddress = 'tz3Vq38qYD3GEbWcXHMLt5PaASZrkDtEiA8D'
const someSenderAddress = 'tz2CVMDVA16dD9A7kpWym2ptGDhs5zUhwWXr'
const someHash = 'ooY7YKLgWE8mrELbsDLEtPsxFNaLdqRbbRR1b1FXDA6DwasAFe4'
const commonTx = {
counter: 65214462,
hash: someHash,
gasLimit: 4,
storageLimit: 5,
level: 2702551,
block: 'BMJ1ZQ6',
timestamp: '2022-09-12T01:36:59Z',
sender: {
address: someSenderAddress,
},
}
const delegate: APIDelegationType = {
...commonTx,
type: 'delegation',
id: 111,
amount: 724846,
prevDelegate: {
address: someDestinationAddress,
},
newDelegate: null,
storageFee: 1,
bakerFee: 2,
allocationFee: 3,
}
const undelegate: APIDelegationType = {
...delegate,
id: 222,
prevDelegate: null,
newDelegate: { address: someDestinationAddress },
}
const transfer: APITransactionType = {
...delegate,
id: 333,
initiator: null,
type: 'transaction',
status: 'applied',
target: { address: someDestinationAddress },
}
const reveal: APIRevealType = {
...commonTx,
id: 444,
type: 'reveal',
bakerFee: 4700,
status: 'applied',
}
it.each([
['undelegate', undelegate, 'DELEGATE', 'DELEGATE', 0n],
['delegate', delegate, 'UNDELEGATE', 'UNDELEGATE', 0n],
['transfer', transfer, 'OUT', undefined, 724846n],
])(
'should return %s operation with proper recipient list',
async (_label, operation, expectedType, expectedLedgerOpType, expectedAmount) => {
// Given
mockGetAccountOperations.mockResolvedValue([operation])
// When — use someSenderAddress so transaction ops pass the sender/target filter
const [results] = await listOperations(context, someSenderAddress, options)
// Then
expect(results).toEqual([
{
id: `${operation.hash}-${operation.id}`,
asset: { type: 'native' },
details: {
counter: operation.counter,
gasLimit: operation.gasLimit,
storageLimit: operation.storageLimit,
ledgerOpType: expectedLedgerOpType,
},
senders: [someSenderAddress],
recipients: [someDestinationAddress],
tx: {
block: {
hash: operation.block,
height: operation.level,
time: new Date(operation.timestamp),
},
date: new Date(operation.timestamp),
hash: operation.hash,
failed: false,
fees: BigInt(
(operation.allocationFee ?? 0) +
(operation.bakerFee ?? 0) +
(operation.storageFee ?? 0)
),
feesPayer: someSenderAddress,
},
type: expectedType,
value: expectedAmount,
},
])
}
)
it.each([
['undelegate', undelegate],
['delegate', delegate],
['transfer', transfer],
['reveal', reveal],
])(
'should return %s operation with pagination cursor lastLevel when page is full',
async (_label, operation) => {
// Given
mockGetAccountOperations.mockResolvedValue([operation])
// When
const [results, token] = await listOperations(context, someSenderAddress, {
...options,
limit: 1,
sort: 'Descending',
})
// Then
expect(results.length).toEqual(1)
expect(JSON.parse(token)).toEqual(
expect.objectContaining({
lastLevel: operation.level,
nativeLastId: operation.id,
})
)
}
)
it.each([
['undelegate', undelegate, 'DELEGATE'],
['delegate', delegate, 'UNDELEGATE'],
['transfer', transfer, undefined],
['reveal', reveal, 'REVEAL'],
])(
'should return %s operation with expected details',
async (_label, operation, expectedLedgerOpType) => {
// Given
mockGetAccountOperations.mockResolvedValue([operation])
// When
const [results, _] = await listOperations(context, someSenderAddress, options)
// Then
expect(results.length).toEqual(1)
expect(results[0].details).toEqual({
counter: 65214462,
gasLimit: 4,
storageLimit: 5,
ledgerOpType: expectedLedgerOpType,
})
}
)
it.each([
{ ...undelegate, newDelegate: null, prevDelegate: null },
{ ...transfer, target: null },
])('should return empty recipient list when no target can be found', async (operation) => {
// Given
mockGetAccountOperations.mockResolvedValue([operation])
// When
const [results, token] = await listOperations(context, someSenderAddress, options)
// Then
expect(results.length).toEqual(1)
expect(results[0].recipients).toEqual([])
expect(token).toEqual('')
})
it.each([
['undelegate', { ...undelegate, storageFee: 1, bakerFee: 2, allocationFee: 3 }],
['delegate', { ...delegate, storageFee: 1, bakerFee: 2, allocationFee: 3 }],
['transfer', { ...transfer, storageFee: 1, bakerFee: 2, allocationFee: 3 }],
])('should compute the fees properly for %s operation', async (_label, operation) => {
// Given
mockGetAccountOperations.mockResolvedValue([operation])
// When
const [results, _] = await listOperations(context, someSenderAddress, options)
// Then
expect(results.length).toEqual(1)
expect(results[0].tx.fees).toEqual(BigInt(6))
})
it('should return empty sender list when no sender can be found', async () => {
// Given
const operation = { ...undelegate, sender: null }
mockGetAccountOperations.mockResolvedValue([operation])
// When
const [results, token] = await listOperations(context, 'any address', options)
// Then
expect(results.length).toEqual(1)
expect(results[0].senders).toEqual([])
expect(token).toEqual('')
})
it('should order the results in descending order even if the sort option is set to ascending', async () => {
const op1 = { ...undelegate, level: '1', timestamp: '2022-09-12T01:00:00Z' }
const op2 = { ...undelegate, level: '2', timestamp: '2022-09-12T01:01:00Z' }
mockGetAccountOperations.mockResolvedValue([op1, op2])
const [results, _] = await listOperations(context, 'any address', options)
expect(results.map((op) => op.tx.block.height)).toEqual(['2', '1'])
})
describe('feesPayer', () => {
it('uses initiator as feesPayer when present (internal transaction)', async () => {
const initiatorAddress = 'tz1NKVAxzJusWgKewn4LEViPSQVRE5Kg6XFV'
const contractAddress = 'KT1WPEis2WhAc2FciM2tZVn8qe6pCBe9HkDp'
const internalTx: APITransactionType = {
...transfer,
id: 555,
initiator: { address: initiatorAddress },
sender: { address: contractAddress },
target: { address: someDestinationAddress },
}
mockGetAccountOperations.mockResolvedValue([internalTx])
const [results] = await listOperations(context, someDestinationAddress, options)
expect(results[0]).toMatchObject({
tx: {
feesPayer: initiatorAddress,
},
senders: [contractAddress],
recipients: [someDestinationAddress],
})
})
it('omits feesPayer when both sender and initiator are null', async () => {
const txNoSender: APITransactionType = {
...transfer,
id: 557,
initiator: null,
sender: null,
}
mockGetAccountOperations.mockResolvedValue([txNoSender])
const [results] = await listOperations(context, someDestinationAddress, options)
expect(results[0].tx.feesPayer).toBeUndefined()
expect(results[0]).toMatchObject({
senders: [],
recipients: [someDestinationAddress],
})
})
it('uses sender as feesPayer for delegation (no initiator field)', async () => {
mockGetAccountOperations.mockResolvedValue([delegate])
const [results] = await listOperations(context, 'any address', options)
expect(results[0]).toMatchObject({
tx: {
feesPayer: someSenderAddress,
},
senders: [someSenderAddress],
recipients: [someDestinationAddress],
})
})
it('uses sender as feesPayer for reveal', async () => {
mockGetAccountOperations.mockResolvedValue([reveal])
const [results] = await listOperations(context, 'any address', options)
expect(results[0]).toMatchObject({
tx: {
feesPayer: someSenderAddress,
},
senders: [someSenderAddress],
recipients: [],
})
})
})
it('filters out internal sub-transactions where account is neither sender nor target', async () => {
const contractAddress = 'KT1WPEis2WhAc2FciM2tZVn8qe6pCBe9HkDp'
const thirdPartyAddress = 'tz1Pci9FqMTqADRXqgD3ZDsdfEFcPqMTqA8D'
// Top-level: user sends to contract
const topLevelTx: APITransactionType = {
...transfer,
id: 700,
amount: 100000,
target: { address: contractAddress },
}
// Internal: contract forwards to a third party (neither sender nor target is our account)
const internalTx: APITransactionType = {
...transfer,
id: 701,
amount: 90000,
initiator: { address: someSenderAddress },
sender: { address: contractAddress },
target: { address: thirdPartyAddress },
}
mockGetAccountOperations.mockResolvedValue([topLevelTx, internalTx])
const [results] = await listOperations(context, someSenderAddress, options)
// Only the top-level tx should be kept; the internal sub-tx is filtered out
expect(results).toHaveLength(1)
expect(results[0].senders).toEqual([someSenderAddress])
expect(results[0].recipients).toEqual([contractAddress])
expect(results[0].value).toEqual(100000n)
})
it('accumulates fees from filtered internal sub-txs onto the parent operation', async () => {
const contractAddress = 'KT1WPEis2WhAc2FciM2tZVn8qe6pCBe9HkDp'
const thirdPartyAddress = 'tz1Pci9FqMTqADRXqgD3ZDsdfEFcPqMTqA8D'
// Top-level: user sends to contract (bakerFee = 4749)
const topLevelTx: APITransactionType = {
...transfer,
id: 800,
amount: 1000,
bakerFee: 4749,
storageFee: 0,
allocationFee: 0,
target: { address: contractAddress },
}
// Internal sub-tx 1: contract calls another contract, storage charged to initiator
const internalTx1: APITransactionType = {
...transfer,
id: 801,
amount: 0,
bakerFee: 0,
storageFee: 17000,
allocationFee: 0,
initiator: { address: someSenderAddress },
sender: { address: contractAddress },
target: { address: thirdPartyAddress },
}
// Internal sub-tx 2: another internal call with storage fee
const internalTx2: APITransactionType = {
...transfer,
id: 802,
amount: 0,
bakerFee: 0,
storageFee: 500,
allocationFee: 0,
initiator: { address: someSenderAddress },
sender: { address: contractAddress },
target: { address: thirdPartyAddress },
}
mockGetAccountOperations.mockResolvedValue([topLevelTx, internalTx1, internalTx2])
const [results] = await listOperations(context, someSenderAddress, options)
// Only top-level tx kept, but fees include internal sub-tx storage costs
expect(results).toHaveLength(1)
expect(results[0].tx.fees).toEqual(4749n + 17000n + 500n)
})
it('FA2 token transfers (tokenId 0), attributes fees from parent tx', async () => {
const fa2: APITokenTransfer & { hash: string } = {
id: 9001,
level: 100,
timestamp: '2023-06-01T12:00:00Z',
token: {
id: 1,
contract: { address: 'KT1TokenContract' },
tokenId: '0',
standard: 'fa2',
metadata: { name: 'Tok', symbol: 'TOK', decimals: '6' },
},
from: { address: someSenderAddress },
to: { address: someDestinationAddress },
amount: '1000000',
transactionId: transfer.id,
hash: someHash,
}
const appliedTransfer = { ...transfer, status: 'applied' as const, level: 100 }
mockGetAccountOperations.mockResolvedValue([appliedTransfer])
mockGetAccountTokenTransfers.mockResolvedValue([fa2])
const [results, next] = await listOperations(context, someDestinationAddress, {
...options,
limit: 1,
sort: 'Descending',
})
const tokenOp = results.find((o) => o.asset.type === 'fa2')
expect(JSON.parse(next)).toEqual(
expect.objectContaining({
lastLevel: 100,
nativeLastId: transfer.id,
tokenLastId: 9001,
})
)
expect(tokenOp).toMatchObject({
type: 'IN',
value: 1_000_000n,
asset: {
type: 'fa2',
assetReference: 'KT1TokenContract:0',
assetOwner: someDestinationAddress,
unit: { magnitude: 6, name: 'Tok', code: 'TOK' },
},
tx: {
hash: someHash,
fees: 0n,
feesPayer: someSenderAddress,
failed: false,
block: { hash: 'BMJ1ZQ6', height: 100, time: new Date(fa2.timestamp) },
},
details: {
ledgerOpType: 'IN',
assetAmount: '1000000',
assetSenders: [someSenderAddress],
assetRecipients: [someDestinationAddress],
parentSenders: [someSenderAddress],
parentRecipients: [someDestinationAddress],
},
})
expect(results.length).toBe(2)
})
it('FA2 uses transfer.block as tx.block.hash even when a parent native transaction is joined', async () => {
const fa2WithBlock: APITokenTransfer & { hash: string; block: string } = {
id: 9002,
level: 100,
timestamp: '2023-06-01T12:00:00Z',
token: {
id: 1,
contract: { address: 'KT1TokenContract' },
tokenId: '0',
standard: 'fa2',
metadata: { decimals: '6' },
},
from: { address: someSenderAddress },
to: { address: someDestinationAddress },
amount: '1',
transactionId: transfer.id,
hash: someHash,
block: 'BLK_FROM_TRANSFER',
}
const appliedTransfer = {
...transfer,
status: 'applied' as const,
block: 'BLK_FROM_PARENT',
}
mockGetAccountOperations.mockResolvedValue([appliedTransfer])
mockGetAccountTokenTransfers.mockResolvedValue([fa2WithBlock])
const [results] = await listOperations(context, someDestinationAddress, options)
const tokenOp = results.find((o) => o.asset.type === 'fa2')
expect(tokenOp!.tx.block.hash).toBe('BLK_FROM_TRANSFER')
})
it('drops failed incoming native transactions (target is the listed account)', async () => {
const failedIn: APITransactionType = {
...transfer,
id: 6001,
status: 'failed',
sender: { address: someSenderAddress },
target: { address: someDestinationAddress },
}
mockGetAccountOperations.mockResolvedValue([failedIn])
const [results] = await listOperations(context, someDestinationAddress, options)
expect(results).toEqual([])
})
it('keeps failed outgoing native transactions', async () => {
const failedOut: APITransactionType = {
...transfer,
id: 6002,
status: 'failed',
sender: { address: someSenderAddress },
target: { address: someDestinationAddress },
}
mockGetAccountOperations.mockResolvedValue([failedOut])
const [results] = await listOperations(context, someSenderAddress, options)
expect(results).toHaveLength(1)
const out = results[0]
expect(out!.tx.failed).toBe(true)
expect(out!.type).toBe('OUT')
})
it('normalizes applied transfer as IN when the account is only the recipient', async () => {
const incoming: APITransactionType = {
...transfer,
id: 7001,
status: 'applied',
sender: { address: someSenderAddress },
target: { address: someDestinationAddress },
}
mockGetAccountOperations.mockResolvedValue([incoming])
const [results] = await listOperations(context, someDestinationAddress, options)
expect(results).toHaveLength(1)
const row = results[0]
expect(row!.type).toBe('IN')
expect(row!.details?.ledgerOpType).toBeUndefined()
})
it('normalizes self-transfer as FEES with ledgerOpType FEES', async () => {
const selfTx: APITransactionType = {
...transfer,
id: 7002,
status: 'applied',
amount: 100,
sender: { address: someSenderAddress },
target: { address: someSenderAddress },
}
mockGetAccountOperations.mockResolvedValue([selfTx])
const [results] = await listOperations(context, someSenderAddress, options)
expect(results).toHaveLength(1)
const selfRow = results[0]
expect(selfRow!.type).toBe('FEES')
expect(selfRow!.details?.ledgerOpType).toBe('FEES')
})
it('normalizes zero-amount transfer as FEES', async () => {
const zeroTx: APITransactionType = {
...transfer,
id: 7003,
status: 'applied',
amount: 0,
sender: { address: someSenderAddress },
target: { address: someDestinationAddress },
}
mockGetAccountOperations.mockResolvedValue([zeroTx])
const [results] = await listOperations(context, someSenderAddress, options)
expect(results).toHaveLength(1)
const zeroRow = results[0]
expect(zeroRow!.type).toBe('FEES')
expect(zeroRow!.details?.ledgerOpType).toBe('FEES')
})
it('applies limit to native operations returned from the explorer', async () => {
const op1 = { ...undelegate, id: 8001 }
const op2 = { ...undelegate, id: 8002 }
const op3 = { ...undelegate, id: 8003 }
mockGetAccountOperations.mockResolvedValue([op1, op2, op3])
const [results] = await listOperations(context, 'any address', { ...options, limit: 2 })
expect(results).toHaveLength(2)
})
it('FA2 token transfer as OUT when the account is only the sender', async () => {
const fa2Out: APITokenTransfer & { hash: string } = {
id: 9101,
level: 101,
timestamp: '2023-06-01T13:00:00Z',
token: {
id: 2,
contract: { address: 'KT1Out' },
tokenId: '0',
standard: 'fa2',
metadata: { decimals: '0' },
},
from: { address: someSenderAddress },
to: { address: someDestinationAddress },
amount: '500',
transactionId: transfer.id,
hash: 'ooTokenOutHash',
}
mockGetAccountOperations.mockResolvedValue([{ ...transfer, status: 'applied' as const }])
mockGetAccountTokenTransfers.mockResolvedValue([fa2Out])
const [results] = await listOperations(context, someSenderAddress, options)
const tokenOut = results.find((o) => o.asset.type === 'fa2')
expect(tokenOut!.type).toBe('OUT')
expect(tokenOut!.details?.ledgerOpType).toBe('OUT')
})
it('FA2 self-transfer (same from and to as account) is typed FEES', async () => {
const fa2Self: APITokenTransfer & { hash: string } = {
id: 9102,
level: 102,
timestamp: '2023-06-01T14:00:00Z',
token: {
id: 3,
contract: { address: 'KT1Self' },
tokenId: '0',
standard: 'fa2',
},
from: { address: someSenderAddress },
to: { address: someSenderAddress },
amount: '1',
hash: 'ooTokenSelfHash',
}
mockGetAccountOperations.mockResolvedValue([])
mockGetAccountTokenTransfers.mockResolvedValue([fa2Self])
const [results] = await listOperations(context, someSenderAddress, options)
const tokenSelf = results.find((o) => o.asset.type === 'fa2')
expect(tokenSelf!.type).toBe('FEES')
expect(tokenSelf!.details?.ledgerOpType).toBe('FEES')
})
it('FA2 transfer without parent transaction omits fees and falls back to transfer.block hash', async () => {
const fa2Orphan: APITokenTransfer & { hash: string; block: string } = {
id: 9103,
level: 103,
timestamp: '2023-06-01T15:00:00Z',
token: {
id: 4,
contract: { address: 'KT1Orphan' },
tokenId: '0',
standard: 'fa2',
},
from: { address: someSenderAddress },
to: { address: someDestinationAddress },
amount: '10',
hash: 'ooOrphanHash',
block: 'BLK_ORPHAN',
}
mockGetAccountOperations.mockResolvedValue([])
mockGetAccountTokenTransfers.mockResolvedValue([fa2Orphan])
const [results] = await listOperations(context, someDestinationAddress, options)
const orphan = results.find((o) => o.asset.type === 'fa2')
expect(orphan!.tx.fees).toBe(0n)
expect(orphan!.tx.feesPayer).toBeUndefined()
expect(orphan!.tx.block.hash).toBe('BLK_ORPHAN')
expect(orphan!.tx.failed).toBe(false)
})
it('FA2 transfer without parent and without transfer.block falls back to empty block hash', async () => {
const fa2NoBlock: APITokenTransfer & { hash: string } = {
id: 9104,
level: 104,
timestamp: '2023-06-01T16:00:00Z',
token: {
id: 5,
contract: { address: 'KT1NoBlock' },
tokenId: '0',
standard: 'fa2',
},
from: { address: someSenderAddress },
to: { address: someDestinationAddress },
amount: '10',
hash: 'ooNoBlockHash',
}
mockGetAccountOperations.mockResolvedValue([])
mockGetAccountTokenTransfers.mockResolvedValue([fa2NoBlock])
const [results] = await listOperations(context, someDestinationAddress, options)
const noBlock = results.find((o) => o.asset.type === 'fa2')
expect(noBlock!.tx.block.hash).toBe('')
})
it('trims partial bottom block on full page (descending) so levels are not split across pages', async () => {
const opHigh: APITransactionType = {
...transfer,
id: 80010,
level: 2000,
status: 'applied' as const,
}
const opLow: APITransactionType = {
...transfer,
id: 80011,
level: 1999,
status: 'applied' as const,
}
mockGetAccountOperations.mockResolvedValue([opHigh, opLow])
mockGetAccountTokenTransfers.mockResolvedValue([])
const [results] = await listOperations(context, someSenderAddress, {
sort: 'Descending',
minHeight: 0,
limit: 2,
})
expect(results).toHaveLength(1)
expect(results[0]?.tx.block.height).toBe(2000)
})
it('emits nativeLastId on full single-level native page and forwards lastId on next request', async () => {
const op1 = { ...transfer, id: 900_001, level: 5000, status: 'applied' as const }
const op2 = { ...transfer, id: 900_002, level: 5000, status: 'applied' as const }
mockGetAccountOperations.mockResolvedValueOnce([op1, op2]).mockResolvedValueOnce([])
mockGetAccountTokenTransfers.mockResolvedValue([])
const [, token] = await listOperations(context, someSenderAddress, {
sort: 'Descending',
minHeight: 0,
limit: 2,
})
expect(JSON.parse(token)).toEqual(
expect.objectContaining({
lastLevel: 5000,
nativeLastId: 900_002,
})
)
await listOperations(context, someSenderAddress, {
sort: 'Descending',
minHeight: 0,
limit: 2,
token,
})
expect(mockGetAccountOperations).toHaveBeenLastCalledWith(
someSenderAddress,
expect.objectContaining({
'level.lt': 5001,
lastId: 900_002,
sort: 'Descending',
'level.ge': 0,
limit: 2,
})
)
})
it('emits tokenLastId on full single-level token page and forwards id.lt on next request', async () => {
const fa2a: APITokenTransfer & { hash: string } = {
id: 9105,
level: 6000,
timestamp: '2023-06-01T17:00:00Z',
token: {
id: 6,
contract: { address: 'KT1IntraTok' },
tokenId: '0',
standard: 'fa2',
},
from: { address: someSenderAddress },
to: { address: someDestinationAddress },
amount: '1',
hash: 'ooTokA',
}
const fa2b: APITokenTransfer & { hash: string } = {
...fa2a,
id: 9106,
hash: 'ooTokB',
}
mockGetAccountOperations.mockResolvedValue([])
mockGetAccountTokenTransfers.mockResolvedValueOnce([fa2b, fa2a]).mockResolvedValueOnce([])
const [, cursor] = await listOperations(context, someDestinationAddress, {
sort: 'Descending',
minHeight: 0,
limit: 2,
})
expect(JSON.parse(cursor)).toEqual(
expect.objectContaining({
lastLevel: 6000,
tokenLastId: 9105,
})
)
await listOperations(context, someDestinationAddress, {
sort: 'Descending',
minHeight: 0,
limit: 2,
token: cursor,
})
expect(mockGetAccountTokenTransfers).toHaveBeenLastCalledWith(
someDestinationAddress,
expect.objectContaining({
'level.lt': 6001,
'id.lt': 9105,
sort: 'Descending',
'level.ge': 0,
limit: 2,
})
)
})
it('aligns native and token streams to the same boundary level (descending)', async () => {
const parentTx: APITransactionType = {
...transfer,
id: 80020,
level: 1000,
status: 'applied' as const,
}
const opOlder: APITransactionType = {
...transfer,
id: 80021,
level: 500,
status: 'applied' as const,
}
const fa2At1000: APITokenTransfer & { hash: string } = {
id: 80030,
level: 1000,
timestamp: '2023-06-01T12:00:00Z',
token: {
id: 1,
contract: { address: 'KT1TokenContract' },
tokenId: '0',
standard: 'fa2',
metadata: { decimals: '6' },
},
from: { address: someSenderAddress },
to: { address: someDestinationAddress },
amount: '1',
transactionId: parentTx.id,
hash: someHash,
}
const fa2Old: APITokenTransfer & { hash: string } = {
...fa2At1000,
id: 80031,
level: 400,
hash: 'ooOldTok',
}
mockGetAccountOperations.mockResolvedValue([parentTx, opOlder])
mockGetAccountTokenTransfers.mockResolvedValue([fa2At1000, fa2Old])
const [results] = await listOperations(context, someDestinationAddress, {
sort: 'Descending',
minHeight: 0,
limit: 2,
})
expect(results.some((o) => o.tx.hash === 'ooOldTok')).toBe(false)
expect(results.filter((o) => o.asset.type === 'native')).toHaveLength(1)
expect(results.filter((o) => o.asset.type === 'fa2')).toHaveLength(1)
expect(results.find((o) => o.asset.type === 'fa2')?.tx.hash).toBe(someHash)
})
describe('both streams exhausted: skip alignment, return all ops', () => {
const nativeOp1: APITransactionType = {
...transfer,
id: 70_001,
level: 500,
status: 'applied' as const,
}
const nativeOp2: APITransactionType = {
...transfer,
id: 70_002,
level: 600,
status: 'applied' as const,
}
const tokenAtLowLevel: APITokenTransfer & { hash: string } = {
id: 71_001,
level: 100,
timestamp: '2024-06-01T00:00:00Z',
token: {
id: 1,
contract: { address: 'KT1Low' },
tokenId: '0',
standard: 'fa2',
},
from: { address: someSenderAddress },
to: { address: someDestinationAddress },
amount: '1',
hash: 'ooLowLevelToken',
}
it('ascending: all ops returned in one page when both streams are exhausted', async () => {
// 2 native ops (500, 600) + 1 token (100), limit=10 → neither full.
// Without fix: boundary=100, native ops aligned away and lost.
// With fix: bothExhausted=true, boundary skipped, all 3 ops returned.
mockGetAccountOperations.mockResolvedValueOnce([nativeOp1, nativeOp2])
mockGetAccountTokenTransfers.mockResolvedValueOnce([tokenAtLowLevel])
const [page, cursor] = await listOperations(context, someDestinationAddress, {
sort: 'Ascending',
minHeight: 0,
limit: 10,
})
expect(page.filter((o) => o.asset.type === 'native')).toHaveLength(2)
expect(page.filter((o) => o.asset.type === 'fa2')).toHaveLength(1)
expect(cursor).toBe('')
})
it('descending: all ops returned in one page when both streams are exhausted', async () => {
// Same data, descending. Without fix: boundary=500, token op lost.
mockGetAccountOperations.mockResolvedValueOnce([nativeOp2, nativeOp1])
mockGetAccountTokenTransfers.mockResolvedValueOnce([tokenAtLowLevel])
const [page, cursor] = await listOperations(context, someDestinationAddress, {
sort: 'Descending',
minHeight: 0,
limit: 10,
})
expect(page.filter((o) => o.asset.type === 'native')).toHaveLength(2)
expect(page.filter((o) => o.asset.type === 'fa2')).toHaveLength(1)
expect(cursor).toBe('')
})
})
it('native empty, token has ops: returns token ops without cursor', async () => {
const fa2: APITokenTransfer & { hash: string } = {
id: 72_001,
level: 300,
timestamp: '2024-06-01T00:00:00Z',
token: {
id: 1,
contract: { address: 'KT1Tok' },
tokenId: '0',
standard: 'fa2',
},
from: { address: someSenderAddress },
to: { address: someDestinationAddress },
amount: '5',
hash: 'ooOnlyToken',
}
mockGetAccountOperations.mockResolvedValueOnce([])
mockGetAccountTokenTransfers.mockResolvedValueOnce([fa2])
const [results, cursor] = await listOperations(context, someDestinationAddress, {
sort: 'Descending',
minHeight: 0,
limit: 10,
})
expect(results).toHaveLength(1)
expect(results[0]?.asset.type).toBe('fa2')
expect(cursor).toBe('')
})
it('token full, native not full: cursor emitted via tokenFull', async () => {
// token returns exactly limit ops → full, native returns fewer → not full.
// Alignment runs normally, cursor emitted because tokenFull=true.
const nativeHigh: APITransactionType = {
...transfer,
id: 73_001,
level: 2000,
status: 'applied' as const,
}
const fa2Low: APITokenTransfer & { hash: string } = {
id: 73_002,
level: 800,
timestamp: '2024-06-01T00:00:00Z',
token: {
id: 1,
contract: { address: 'KT1TokFull' },
tokenId: '0',
standard: 'fa2',
},
from: { address: someSenderAddress },
to: { address: someDestinationAddress },
amount: '1',
hash: 'ooTokFull1',
}
const fa2High: APITokenTransfer & { hash: string } = {
...fa2Low,
id: 73_003,
level: 2000,
hash: 'ooTokFull2',
}
// limit=2: token returns 2 (full), native returns 1 (not full)
mockGetAccountOperations.mockResolvedValueOnce([nativeHigh])
mockGetAccountTokenTransfers.mockResolvedValueOnce([fa2High, fa2Low])
const [, cursor] = await listOperations(context, someDestinationAddress, {
sort: 'Descending',
minHeight: 0,
limit: 2,
})
expect(cursor).not.toBe('')
})
describe('FA2 assetReference must include tokenId', () => {
const makeFA2Transfer = (
tokenId: string,
contractAddress: string,
id: number
): APITokenTransfer & { hash: string } => ({
id,
level: 200,
timestamp: '2024-01-01T00:00:00Z',
token: {
id: 1,
contract: { address: contractAddress },
tokenId,
standard: 'fa2',
},
from: { address: someSenderAddress },
to: { address: someDestinationAddress },
amount: '100',
hash: 'ooLive30344Hash',
})
it('includes tokenId in assetReference for a non-zero tokenId', async () => {
mockGetAccountOperations.mockResolvedValue([])
mockGetAccountTokenTransfers.mockResolvedValue([
makeFA2Transfer('42', 'KT1MultiToken', 10_001),
])
const [results] = await listOperations(context, someDestinationAddress, options)
const op = results.find((o) => o.asset.type === 'fa2')
expect(op!.asset).toMatchObject({ assetReference: 'KT1MultiToken:42' })
})
it('includes :0 suffix in assetReference when tokenId is 0', async () => {
mockGetAccountOperations.mockResolvedValue([])
mockGetAccountTokenTransfers.mockResolvedValue([makeFA2Transfer('0', 'KT1Standard', 10_002)])
const [results] = await listOperations(context, someDestinationAddress, options)
const op = results.find((o) => o.asset.type === 'fa2')
expect(op!.asset).toMatchObject({ assetReference: 'KT1Standard:0' })
})
it('defaults to :0 suffix when tokenId is undefined', async () => {
const fa2NoTokenId: APITokenTransfer & { hash: string } = {
...makeFA2Transfer('0', 'KT1NoId', 10_003),
// @ts-expect-error testing missing tokenId
token: { id: 1, contract: { address: 'KT1NoId' }, standard: 'fa2' },
}
mockGetAccountOperations.mockResolvedValue([])
mockGetAccountTokenTransfers.mockResolvedValue([fa2NoTokenId])
const [results] = await listOperations(context, someDestinationAddress, options)
const op = results.find((o) => o.asset.type === 'fa2')
expect(op!.asset).toMatchObject({ assetReference: 'KT1NoId:0' })
})
})
describe('staking operations (Paris adaptive issuance)', () => {
const stakerAddress = 'tz1dKrT1h6d7wP8fEzMPptG6er7mLLeQjBBY'
const bakerAddress = 'tz3Q67aMz7gSMiQRcW729sXSfuMtkyAHYfqc'
function makeStaking(action: APIStakingType['action'], amount: number): APIStakingType {
return {
...commonTx,
id: 555_000 + (action === 'stake' ? 1 : action === 'unstake' ? 2 : 3),
type: 'staking',
action,
amount,
requestedAmount: amount,
sender: { address: stakerAddress },
staker: { address: stakerAddress },
baker: { address: bakerAddress, alias: 'TF Test Baker' },
counter: commonTx.counter + 1,
bakerFee: 800,
status: 'applied',
}
}
it.each([
['stake', 'STAKE', 500_000_000n, [stakerAddress], [bakerAddress]],
['unstake', 'UNSTAKE', 250_000_000n, [stakerAddress], [bakerAddress]],
['finalize', 'FINALIZE_UNSTAKE', 0n, [bakerAddress], [stakerAddress]],
] as const)(
'maps action %s to operation type %s with amount as value',
async (action, expectedType, expectedAmount, expectedSenders, expectedRecipients) => {
const op = makeStaking(action, Number(expectedAmount))
mockGetAccountOperations.mockResolvedValue([op])
const [results] = await listOperations(context, stakerAddress, options)
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
type: expectedType,
value: expectedAmount,
senders: expectedSenders,
recipients: expectedRecipients,
details: expect.objectContaining({ ledgerOpType: expectedType }),
})
}
)
it('surfaces finalize_unstake to the staker even when a third party pays the gas', async () => {
const helperAddress = 'tz1i92Eptw7UZ8JSb8j8jBFJ9Poa4TTnSQwZ'
const op: APIStakingType = {
...makeStaking('finalize', 250_000_000),
sender: { address: helperAddress },
}
mockGetAccountOperations.mockResolvedValue([op])
const [results] = await listOperations(context, stakerAddress, options)
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
type: 'FINALIZE_UNSTAKE',
senders: [bakerAddress],
recipients: [stakerAddress],
tx: expect.objectContaining({ feesPayer: helperAddress }),
})
})
it('uses sender as feesPayer for staking operations', async () => {
mockGetAccountOperations.mockResolvedValue([makeStaking('stake', 100)])
const [results] = await listOperations(context, stakerAddress, options)
expect(results[0].tx.feesPayer).toBe(stakerAddress)
})
it('falls back to requestedAmount when amount is missing on a failed staking op', async () => {
const op = {
...makeStaking('stake', 0),
status: 'failed',
amount: undefined,
requestedAmount: 500_000_000,
} as unknown as APIStakingType
mockGetAccountOperations.mockResolvedValue([op])
const [results] = await listOperations(context, stakerAddress, options)
expect(results).toHaveLength(1)
expect(results[0].value).toBe(500_000_000n)
})
it('returns 0 when both amount and requestedAmount are missing on a staking op', async () => {
const op = {
...makeStaking('stake', 0),
status: 'failed',
amount: undefined,
requestedAmount: undefined,
} as unknown as APIStakingType
mockGetAccountOperations.mockResolvedValue([op])
const [results] = await listOperations(context, stakerAddress, options)
expect(results).toHaveLength(1)
expect(results[0].value).toBe(0n)
})
it('includes staking ops alongside transfers in pagination', async () => {
const stake = makeStaking('stake', 100)
mockGetAccountOperations.mockResolvedValue([stake])
const [results, token] = await listOperations(context, stakerAddress, options)
expect(results.length).toBe(1)
expect(token).toBe('')
})
it('backfills missing block hashes via a single batched request, deduped per level', async () => {
const stakeNoBlock = { ...makeStaking('stake', 100), block: undefined, level: 3106279 }
const unstakeNoBlock = { ...makeStaking('unstake', 50), block: undefined, level: 3106279 }
const finalizeNoBlock = { ...makeStaking('finalize', 0), block: undefined, level: 3106400 }
mockGetAccountOperations.mockResolvedValue([stakeNoBlock, unstakeNoBlock, finalizeNoBlock])
mockGetBlockHashesByLevels.mockImplementation(
async (levels: number[]) => new Map(levels.map((level) => [level, `BL-fetched-${level}`]))
)
const [results] = await listOperations(context, stakerAddress, options)
expect(results).toHaveLength(3)
expect(results.map((r) => r.tx.block.hash).sort()).toEqual([
'BL-fetched-3106279',
'BL-fetched-3106279',
'BL-fetched-3106400',
])
expect(mockGetBlockHashesByLevels).toHaveBeenCalledTimes(1)
expect(mockGetBlockHashesByLevels.mock.calls[0][0].sort()).toEqual([3106279, 3106400])
})
it('does not call getBlockHashesByLevels when staking ops already carry a block (string)', async () => {
mockGetAccountOperations.mockResolvedValue([makeStaking('stake', 100)])
await listOperations(context, stakerAddress, options)
expect(mockGetBlockHashesByLevels).not.toHaveBeenCalled()
})
it('uses block.hash when TzKT inlines the full block object on staking ops', async () => {
const inlineBlock = {
cycle: 1,
level: 3106307,
hash: 'BLMaHTGtBfh7ZM2wk5rpFHQhNtx5LC7YMdYrnokzBcosrpgqNAe',
timestamp: '2026-04-28T20:54:09Z',
} as unknown as APIBlock
const stakeWithObjectBlock = {
...makeStaking('stake', 100),
block: inlineBlock,
level: 3106307,
}
mockGetAccountOperations.mockResolvedValue([stakeWithObjectBlock])
const [results] = await listOperations(context, stakerAddress, options)
expect(results).toHaveLength(1)
expect(results[0].tx.block.hash).toBe(inlineBlock.hash)
expect(mockGetBlockHashesByLevels).not.toHaveBeenCalled()
})
it('falls back to empty hash when the batched fetch fails', async () => {
const op = { ...makeStaking('stake', 100), block: undefined, level: 4_200_000 }
mockGetAccountOperations.mockResolvedValue([op])
mockGetBlockHashesByLevels.mockRejectedValue(new Error('rpc 503'))
const [results] = await listOperations(context, stakerAddress, options)
expect(results).toHaveLength(1)
expect(results[0].tx.block.hash).toBe('')
})
it('falls back to empty hash for levels missing from the batched response', async () => {
const present = { ...makeStaking('stake', 100), block: undefined, level: 4_200_000 }
const absent = { ...makeStaking('unstake', 50), block: undefined, level: 4_200_001 }
mockGetAccountOperations.mockResolvedValue([present, absent])
mockGetBlockHashesByLevels.mockResolvedValue(new Map([[4_200_000, 'BL-4200000']]))
const [results] = await listOperations(context, stakerAddress, options)
const byLevel = Object.fromEntries(results.map((r) => [r.tx.block.height, r.tx.block.hash]))
expect(byLevel[4_200_000]).toBe('BL-4200000')
expect(byLevel[4_200_001]).toBe('')
})
})
describe('origination operations', () => {
const originatorAddress = 'tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb'
function makeOrigination(overrides: Record<string, unknown> = {}) {
return {
type: 'origination',
id: 621018843447296,
hash: 'onhQ1vG21TosVKCWrwBeYKkRMnTGe34D2ZiaSs6jGQYpw9Z9r1m',
level: 3723919,
timestamp: '2023-06-17T15:31:51Z',
block: 'BL5gN6AzyYzstxxheqdbYvFiqHUWZFCsS7knAE8AtiLRJeDTEZ5',
counter: 10150476,
sender: { address: originatorAddress },
bakerFee: 5980,
storageFee: 1398750,
allocationFee: 64250,
gasLimit: 3494,
storageLimit: 5852,
contractBalance: 0,
status: 'applied',
originatedContract: { address: 'KT1PM9HfQ9ZkQoG4UwtuZjrnUywQhokxJxRo' },
...overrides,
}
}
it('surfaces origination as a FEES operation with correct fees', async () => {
mockGetAccountOperations.mockResolvedValue([makeOrigination()])
const [results] = await listOperations(context, originatorAddress, options)
expect(results).toHaveLength(1)
expect(results[0].type).toBe('FEES')
expect(results[0].value).toBe(0n)
expect(results[0].tx.fees).toBe(5980n + 1398750n + 64250n)
expect(results[0].tx.feesPayer).toBe(originatorAddress)
expect(results[0].details?.ledgerOpType).toBe('ORIGINATION')
})
it('surfaces origination as OUT when contractBalance > 0', async () => {
mockGetAccountOperations.mockResolvedValue([makeOrigination({ contractBalance: 1000000 })])
const [results] = await listOperations(context, originatorAddress, options)
expect(results).toHaveLength(1)
expect(results[0].type).toBe('OUT')
expect(results[0].value).toBe(1000000n)
})
it('includes counter in details', async () => {
mockGetAccountOperations.mockResolvedValue([makeOrigination()])
const [results] = await listOperations(context, originatorAddress, options)
expect(results[0].details?.counter).toBe(10150476)
})
})
})