@ledgerhq/coin-tezos
Version:
441 lines (388 loc) • 15.6 kB
text/typescript
// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0
import type { TezosCoinConfig, TezosContext } from '../config'
import { listOperations } from './listOperations'
/**
* Mainnet integration tests for listOperations with FA2 (USDt) token transfers.
* Uses a dedicated account holding FA2 USDt (tokenId 0) — see TzKT for current balance.
*
* Explorer: https://xtz-tzkt-explorer.api.vault.ledger.com (Ledger vault)
*/
const ADDRESS = 'tz1UD2zz5eFTW2Jy26kBnC3ZkdeazUgeFWST'
/** FA2 USDt contract for this account on mainnet (TzKT). */
const USDT_FA2_CONTRACT = 'KT1XnTn74bUtxHfDtBmm2bGZAQfhPbvKWR8o'
/** Expected assetReference after the LIVE-30344 fix: contract:tokenId. USDt tokenId is 0. */
const USDT_FA2_ASSET_REF = `${USDT_FA2_CONTRACT}:0`
const KT1_ASSET_REF_PATTERN = /^KT1[1-9A-HJ-NP-Za-km-z]+:\d+$/
const mainnetConfig = (): TezosCoinConfig =>
({
status: { type: 'active' },
baker: { url: 'https://tezos-bakers.api.live.ledger.com' },
explorer: { url: 'https://xtz-tzkt-explorer.api.vault.ledger.com', maxTxQuery: 100 },
node: { url: 'https://xtz-node.api.live.ledger.com' },
fees: {
minGasLimit: 0,
minRevealGasLimit: 0,
minStorageLimit: 0,
minFees: 0,
minEstimatedFees: 0,
},
}) as TezosCoinConfig
const baseOpts = { sort: 'Descending' as const, minHeight: 0 }
describe('listOperations — mainnet FA2 / convertTokenOperation', () => {
const context: TezosContext = {
config: async () => mainnetConfig(),
logger: () => undefined,
}
it('returns at least one FA2 token operation', async () => {
const [operations] = await listOperations(context, ADDRESS, baseOpts)
expect(operations.some((op) => op.asset.type === 'fa2')).toBe(true)
})
it('maps FA2 token operations via convertTokenOperation (shape)', async () => {
const [operations] = await listOperations(context, ADDRESS, { ...baseOpts, limit: 200 })
const fa2 = operations.find((op) => {
if (op.asset.type !== 'fa2') return false
return op.asset.assetReference === USDT_FA2_ASSET_REF
})
const tokenOp = fa2!
expect(tokenOp.asset.type).toBe('fa2')
if (tokenOp.asset.type !== 'fa2') {
throw new Error('expected FA2 operation')
}
const asset = tokenOp.asset
expect(asset).toMatchObject({
type: 'fa2',
assetOwner: ADDRESS,
})
expect(asset.assetReference).toMatch(KT1_ASSET_REF_PATTERN)
expect(asset.assetReference).toBe(USDT_FA2_ASSET_REF)
const unit = asset.unit!
expect(typeof unit.magnitude).toBe('number')
expect(unit.magnitude).toBeGreaterThanOrEqual(0)
expect(typeof unit.code).toBe('string')
// TzKT sometimes omits metadata; when decimals are present they should be 6 for USDt.
if (unit.magnitude > 0) {
expect(unit.magnitude).toBe(6)
expect(unit.code).toMatch(/USDt|USDT/i)
}
expect(['IN', 'OUT', 'FEES']).toContain(tokenOp.type)
expect(tokenOp.value).toBeGreaterThanOrEqual(0n)
expect(tokenOp.tx.hash).toMatch(/^o[1-9A-HJ-NP-Za-km-z]+/)
expect(tokenOp.tx.block.hash).toMatch(/^B[1-9A-HJ-NP-Za-km-z]+/)
expect(tokenOp.tx.block.height).toBeGreaterThan(0)
expect(tokenOp.tx.date).toBeInstanceOf(Date)
const idParts = tokenOp.id.match(/^(.+)-token-(\d+)$/)
expect(idParts?.[1]).toBe(tokenOp.tx.hash)
expect(idParts?.[2]).toMatch(/^\d+$/)
expect(Number.parseInt(idParts![2], 10)).toBeGreaterThanOrEqual(0)
})
it('infers direction: OUT when sending, IN when receiving (FA2)', async () => {
const [operations] = await listOperations(context, ADDRESS, { ...baseOpts, limit: 200 })
const fa2Ops = operations.filter((op) => op.asset.type === 'fa2')
for (const op of fa2Ops) {
const isSender = op.senders.includes(ADDRESS)
const isRecipient = op.recipients.includes(ADDRESS)
if (isSender && !isRecipient) {
expect(op.type).toBe('OUT')
}
if (isRecipient && !isSender) {
expect(op.type).toBe('IN')
}
if (isSender && isRecipient) {
expect(op.type).toBe('FEES')
}
}
})
it('attributes fees from parent native transaction when resolvable (FA2)', async () => {
const [operations] = await listOperations(context, ADDRESS, { ...baseOpts, limit: 200 })
const fa2Ops = operations.filter((op) => op.asset.type === 'fa2')
for (const op of fa2Ops) {
expect(op.tx.fees).toBeGreaterThanOrEqual(0n)
}
// Parent tx is joined only when it appears in `getAccountOperations` for this address;
// otherwise fees stay 0 and parent* details are empty.
const withParentHint = fa2Ops.filter((op) => {
const parentSenders = op.details?.parentSenders
const parentRecipients = op.details?.parentRecipients
return (
(Array.isArray(parentSenders) && parentSenders.length > 0) ||
(Array.isArray(parentRecipients) && parentRecipients.length > 0)
)
})
for (const op of withParentHint) {
expect(op.tx.fees).toBeGreaterThan(0n)
if (op.tx.feesPayer !== undefined) {
expect(op.tx.feesPayer).toMatch(/^tz[123][1-9A-HJ-NP-Za-km-z]+$/)
}
}
})
it('exposes convertTokenOperation details for FA2', async () => {
const [operations] = await listOperations(context, ADDRESS, baseOpts)
const fa2 = operations.find((op) => op.asset.type === 'fa2')
const conversionDetails = fa2!.details!
expect(typeof conversionDetails.assetAmount).toBe('string')
expect(Array.isArray(conversionDetails.assetSenders)).toBe(true)
expect(Array.isArray(conversionDetails.assetRecipients)).toBe(true)
expect(Array.isArray(conversionDetails.parentSenders)).toBe(true)
expect(Array.isArray(conversionDetails.parentRecipients)).toBe(true)
})
it('returns a pagination cursor with lastLevel boundary', async () => {
const [, nextToken] = await listOperations(context, ADDRESS, { ...baseOpts, limit: 3 })
expect(nextToken.length).toBeGreaterThan(0)
const parsed: unknown = JSON.parse(nextToken)
expect(parsed).toEqual(
expect.objectContaining({
lastLevel: expect.any(Number),
})
)
})
it('pagination returns disjoint operation ids across pages', async () => {
const limit = 3
const [page1, cursor] = await listOperations(context, ADDRESS, { ...baseOpts, limit })
expect(cursor.length).toBeGreaterThan(0)
const [page2] = await listOperations(context, ADDRESS, { ...baseOpts, limit, token: cursor })
const ids1 = new Set(page1.map((op) => op.id))
const overlap = page2.filter((op) => ids1.has(op.id))
expect(overlap).toHaveLength(0)
})
it('second page accepts cursor with lastLevel for both streams', async () => {
const limit = 3
const [, cursor] = await listOperations(context, ADDRESS, { ...baseOpts, limit })
expect(cursor.length).toBeGreaterThan(0)
const parsed: unknown = JSON.parse(cursor)
expect(parsed).toEqual(
expect.objectContaining({
lastLevel: expect.any(Number),
})
)
await expect(
listOperations(context, ADDRESS, { ...baseOpts, limit, token: cursor })
).resolves.toBeDefined()
})
it('does not split the same tx.hash across consecutive pages (limit 3)', async () => {
const limit = 3
const [page1, cursor] = await listOperations(context, ADDRESS, { ...baseOpts, limit })
if (cursor.length === 0) {
return
}
const [page2] = await listOperations(context, ADDRESS, { ...baseOpts, limit, token: cursor })
const hashesPage1 = new Set(page1.map((op) => op.tx.hash))
const hashesPage2 = new Set(page2.map((op) => op.tx.hash))
const overlap = [...hashesPage1].filter((h) => hashesPage2.has(h))
expect(overlap).toHaveLength(0)
})
it('does not duplicate operation ids within the first page', async () => {
const [operations] = await listOperations(context, ADDRESS, { ...baseOpts, limit: 100 })
const ids = operations.map((op) => op.id)
expect(new Set(ids).size).toBe(ids.length)
})
})
// Shadownet test address `tz1dKrT1…` was funded via faucet and put through:
// delegate to TF Test Baker, stake 500 XTZ, unstake 250 XTZ, then
// finalize_unstake (paid by a helper account, after ~2 cycles ≈ 48h).
const SHADOWNET_STAKER = 'tz1dKrT1h6d7wP8fEzMPptG6er7mLLeQjBBY'
const SHADOWNET_BAKER = 'tz3Q67aMz7gSMiQRcW729sXSfuMtkyAHYfqc'
const shadownetConfig = (): TezosCoinConfig =>
({
status: { type: 'active' },
baker: { url: 'https://tezos-bakers.api.live.ledger.com' },
explorer: { url: 'https://api.shadownet.tzkt.io', maxTxQuery: 100 },
node: { url: 'https://rpc.shadownet.teztnets.com' },
fees: {
minGasLimit: 0,
minRevealGasLimit: 0,
minStorageLimit: 0,
minFees: 0,
minEstimatedFees: 0,
},
}) as TezosCoinConfig
// To refresh: TzKT `/v1/operations/staking?sender=…` + `/v1/blocks/{level}`.
const EXPECTED_STAKE = {
id: 'ooyKCZtF84XchfEr5fjiQV5BEEBjKSCjhKW7xyY4o763yj27mLs-96147000524800',
asset: { type: 'native' },
tx: {
hash: 'ooyKCZtF84XchfEr5fjiQV5BEEBjKSCjhKW7xyY4o763yj27mLs',
fees: 797n,
feesPayer: SHADOWNET_STAKER,
block: {
hash: 'BLa8cr5yFHqozjAzEnkvKQmaDmAxeurBBGvZqdibmzCiYmR6r6L',
height: 3106279,
time: new Date('2026-04-28T20:51:21Z'),
},
date: new Date('2026-04-28T20:51:21Z'),
failed: false,
},
type: 'STAKE',
value: 500_000_000n,
senders: [SHADOWNET_STAKER],
recipients: [SHADOWNET_BAKER],
details: {
counter: 14416505,
gasLimit: 3630,
storageLimit: 0,
ledgerOpType: 'STAKE',
},
}
const EXPECTED_UNSTAKE = {
id: 'ooXrdBW4aY3613YkmsX4uzdidqUTM2Gw7cFZ7aU6irwQ6TmLeFS-96148169687040',
asset: { type: 'native' },
tx: {
hash: 'ooXrdBW4aY3613YkmsX4uzdidqUTM2Gw7cFZ7aU6irwQ6TmLeFS',
fees: 858n,
feesPayer: SHADOWNET_STAKER,
block: {
hash: 'BLMaHTGtBfh7ZM2wk5rpFHQhNtx5LC7YMdYrnokzBcosrpgqNAe',
height: 3106307,
time: new Date('2026-04-28T20:54:09Z'),
},
date: new Date('2026-04-28T20:54:09Z'),
failed: false,
},
type: 'UNSTAKE',
value: 250_000_000n,
senders: [SHADOWNET_STAKER],
recipients: [SHADOWNET_BAKER],
details: {
counter: 14416506,
gasLimit: 4250,
storageLimit: 0,
ledgerOpType: 'UNSTAKE',
},
}
const FINALIZE_FEES_PAYER = 'tz1i92Eptw7UZ8JSb8j8jBFJ9Poa4TTnSQwZ'
const EXPECTED_FINALIZE_UNSTAKE = {
id: 'oodrjyZyoyccgoE24BbMsxfSY4JnP6nrZpmw2xqzurQb7QW3HBD-98214102433792',
asset: { type: 'native' },
tx: {
hash: 'oodrjyZyoyccgoE24BbMsxfSY4JnP6nrZpmw2xqzurQb7QW3HBD',
fees: 492n,
feesPayer: FINALIZE_FEES_PAYER,
block: {
hash: 'BLvjbzKJ5cQfmncDt3CiKcuKpk87pP34t6rmm6FWE9iCFbJb1Xp',
height: 3153614,
time: new Date('2026-05-02T03:50:54Z'),
},
date: new Date('2026-05-02T03:50:54Z'),
failed: false,
},
type: 'FINALIZE_UNSTAKE',
value: 250_000_000n,
senders: [SHADOWNET_BAKER],
recipients: [SHADOWNET_STAKER],
details: {
counter: 801846,
gasLimit: 2169,
storageLimit: 0,
ledgerOpType: 'FINALIZE_UNSTAKE',
},
}
describe('listOperations — Shadownet Paris staking ops', () => {
const context: TezosContext = {
config: async () => shadownetConfig(),
logger: () => undefined,
}
it.each([
['STAKE', EXPECTED_STAKE],
['UNSTAKE', EXPECTED_UNSTAKE],
['FINALIZE_UNSTAKE', EXPECTED_FINALIZE_UNSTAKE],
])('contains a %s op matching every field of the pinned fixture', async (type, expected) => {
const [operations] = await listOperations(context, SHADOWNET_STAKER, {
...baseOpts,
limit: 100,
})
const match = operations.find((op) => op.type === type)
if (!match) throw new Error(`No ${type} op found at ${SHADOWNET_STAKER}`)
expect(match).toEqual(expected)
})
})
describe('listOperations — mainnet origination ops', () => {
// This account deployed 4 contracts — origination ops were previously silently dropped.
const ORIGINATOR = 'tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb'
const context: TezosContext = {
config: async () => mainnetConfig(),
logger: () => undefined,
}
it('surfaces origination operations with correct fees and ledgerOpType', async () => {
// Fetch with a high limit to get all ops in one page (account has ~234 ops)
const [operations] = await listOperations(context, ORIGINATOR, {
sort: 'Ascending',
minHeight: 0,
limit: 500,
})
const originations = operations.filter((op) => op.details?.ledgerOpType === 'ORIGINATION')
expect(originations.length).toBeGreaterThanOrEqual(4)
for (const op of originations) {
expect(op.type).toBe('FEES')
expect(op.value).toBe(0n)
expect(op.tx.fees).toBeGreaterThan(0n)
expect(op.tx.feesPayer).toBe(ORIGINATOR)
expect(op.senders).toContain(ORIGINATOR)
}
// Total origination fees should be at least the known historical balance gap (~5.48M mutez)
const totalFees = originations.reduce((sum, op) => sum + op.tx.fees, 0n)
expect(totalFees).toBeGreaterThanOrEqual(5_480_510n)
})
it('native ops (including internal sub-tx fees) sum to the on-chain balance', async () => {
// Paginate until all ops are fetched
let allOps: Awaited<ReturnType<typeof listOperations>>[0] = []
let token: string | undefined
do {
const [ops, next] = await listOperations(context, ORIGINATOR, {
sort: 'Ascending',
minHeight: 0,
limit: 200,
token,
})
allOps = allOps.concat(ops)
token = next || undefined
} while (token)
const nativeOps = allOps.filter((op) => op.asset.type === 'native')
let balance = 0n
for (const op of nativeOps) {
if (op.type === 'IN') balance += op.value
else if (op.type === 'OUT') balance -= op.value
}
// Subtract fees only when this account is the fee payer
for (const op of nativeOps) {
if (op.tx.feesPayer === ORIGINATOR && op.tx.fees > 0n) {
balance -= op.tx.fees
}
}
const explorerUrl = mainnetConfig().explorer.url
const resp = await fetch(`${explorerUrl}/v1/accounts/${ORIGINATOR}`)
if (!resp.ok) throw new Error(`TzKT returned ${resp.status}: ${await resp.text()}`)
const account = await resp.json()
expect(balance).toEqual(BigInt(account.balance))
}, 60_000)
})
/**
* Multi-asset FA2 integration tests (LIVE-29210).
* Uses tz1ZB8hpQJZdQeEPc3uG2cL1NXwLTPkqeAD6 which holds multiple tokens
* on the Wrapped Tokens Contract (KT18fp5rcTW7mbWDmzFwjLDUhs5MeJmagDSZ).
*/
const MULTI_ASSET_ADDRESS = 'tz1ZB8hpQJZdQeEPc3uG2cL1NXwLTPkqeAD6'
const WRAPPED_CONTRACT = 'KT18fp5rcTW7mbWDmzFwjLDUhs5MeJmagDSZ'
describe('listOperations — mainnet multi-asset FA2 (LIVE-29210)', () => {
const context: TezosContext = {
config: async () => mainnetConfig(),
logger: () => undefined,
}
it('returns a known multi-asset FA2 operation with tokenId=11', async () => {
const [operations] = await listOperations(context, MULTI_ASSET_ADDRESS, {
...baseOpts,
limit: 200,
})
// Known on-chain transfer: tokenId=11 (wMATIC), OUT to KT1V5X...
const op = operations.find(
(op) =>
'assetReference' in op.asset &&
op.asset.assetReference === `${WRAPPED_CONTRACT}:11` &&
op.tx.hash === 'ooMtaw5H7dtrgAyW5ky4jFzhQjayWn6La2h3BadHvZd56p6jm9g'
)!
expect(op.type).toBe('OUT')
expect(op.asset.type).toBe('fa2')
expect(op.value).toBe(18423705864886566n)
expect(op.senders).toEqual([MULTI_ASSET_ADDRESS])
expect(op.recipients).toEqual(['KT1V5XKmeypanMS9pR65REpqmVejWBZURuuT'])
})
})