@ledgerhq/coin-tezos
Version:
511 lines (449 loc) • 15.8 kB
text/typescript
// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0
import type { TezosCoinConfig, TezosContext } from '../config'
import { getStakes } from './getStakes'
const mockGetAccountByAddress = jest.fn()
const mockGetUnstakeRequests = jest.fn()
jest.mock('../network/tzkt', () => ({
__esModule: true,
createTzktApi: () => ({
getAccountByAddress: (...args: unknown[]) => mockGetAccountByAddress(...args),
getUnstakeRequests: (...args: unknown[]) => mockGetUnstakeRequests(...args),
}),
}))
describe('getStakes', () => {
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,
}
const makeRequest = (
overrides: Partial<{
id: number
cycle: number
bakerAddress: string
stakerAddress: string
firstTime: string
status: 'pending' | 'finalizable' | 'finalized'
actualAmount: number
}>
) => ({
id: overrides.id ?? 1,
cycle: overrides.cycle ?? 100,
baker: { address: overrides.bakerAddress ?? 'tz1KqTpEZ7Yob7QbPE4Hy4Wo8fHG8LhKxZSx' },
staker: { address: overrides.stakerAddress ?? 'tz1TzrmTBSuiVHV2VfMnGRMYvTEPCP42oSM8' },
firstTime: overrides.firstTime ?? '2026-05-01T00:00:00Z',
status: overrides.status ?? ('pending' as const),
actualAmount: overrides.actualAmount ?? 10,
})
beforeEach(() => {
jest.clearAllMocks()
mockGetUnstakeRequests.mockResolvedValue([])
})
describe('account types', () => {
it('should return empty stakes for non-delegated account', async () => {
mockGetAccountByAddress.mockResolvedValue({
type: 'user',
address: 'tz1WvvbEGpBXGeTVbLiR6DYBe1izmgiYuZbq',
publicKey: 'edpk...',
balance: 1000000,
revealed: true,
counter: 0,
delegationLevel: 0,
delegationTime: '2021-01-01T00:00:00Z',
numTransactions: 0,
firstActivityTime: '2021-01-01T00:00:00Z',
})
const result = await getStakes(context, 'tz1WvvbEGpBXGeTVbLiR6DYBe1izmgiYuZbq')
expect(result.items).toEqual([])
})
it('should return empty stakes for non user account', async () => {
mockGetAccountByAddress.mockResolvedValue({
type: 'empty',
address: 'tz1EmptyAccount',
counter: 0,
})
const result = await getStakes(context, 'tz1EmptyAccount')
expect(result.items).toEqual([])
})
it('should return stake position for non-delegated account with stakedBalance > 0', async () => {
const address = 'tz1NoDelegateStaker'
mockGetAccountByAddress.mockResolvedValue({
type: 'user',
address,
publicKey: 'edpk...',
balance: 100,
stakedBalance: 30,
revealed: true,
counter: 0,
delegationLevel: 0,
delegationTime: '2021-01-01T00:00:00Z',
numTransactions: 0,
firstActivityTime: '2021-01-01T00:00:00Z',
})
const result = await getStakes(context, address)
expect(result.items).toEqual([
{
uid: `stake-${address}`,
address,
state: 'active',
asset: { type: 'native' },
amount: 30n,
actions: [],
},
])
})
it('should return unstaking position for non-delegated account with unstakedBalance > 0', async () => {
const address = 'tz1NoDelegateUnstaker'
const bakerAddress = 'tz1BakerOfUnstake'
mockGetAccountByAddress.mockResolvedValue({
type: 'user',
address,
publicKey: 'edpk...',
balance: 100,
unstakedBalance: 10,
revealed: true,
counter: 0,
delegationLevel: 0,
delegationTime: '2021-01-01T00:00:00Z',
numTransactions: 0,
firstActivityTime: '2021-01-01T00:00:00Z',
})
mockGetUnstakeRequests.mockResolvedValue([
makeRequest({ id: 42, bakerAddress, actualAmount: 10, firstTime: '2026-05-01T00:00:00Z' }),
])
const result = await getStakes(context, address)
expect(result.items).toEqual([
{
uid: `unstaking-42`,
address,
delegate: bakerAddress,
state: 'deactivating',
createdAt: new Date('2026-05-01T00:00:00Z'),
asset: { type: 'native' },
amount: 10n,
actions: [],
},
])
})
})
describe('delegated accounts', () => {
const address = 'tz1TzrmTBSuiVHV2VfMnGRMYvTEPCP42oSM8'
const delegateAddress = 'tz1KqTpEZ7Yob7QbPE4Hy4Wo8fHG8LhKxZSx'
function makeAccount(overrides: Record<string, unknown> = {}) {
return {
type: 'user' as const,
address,
publicKey: 'edpk...',
balance: 5000000,
revealed: true,
counter: 0,
delegate: { alias: 'Test Delegate', address: delegateAddress, active: true },
delegationLevel: 100,
delegationTime: '2021-01-01T00:00:00Z',
numTransactions: 10,
firstActivityTime: '2021-01-01T00:00:00Z',
...overrides,
}
}
it('should return delegation stake for delegated account with no staking', async () => {
mockGetAccountByAddress.mockResolvedValue(makeAccount())
const result = await getStakes(context, address)
expect(result.items).toEqual([
{
uid: `delegation-${address}`,
address,
delegate: delegateAddress,
state: 'active',
asset: { type: 'native' },
amount: 5000000n,
actions: [],
},
])
})
it('should handle account with zero balance but delegation', async () => {
mockGetAccountByAddress.mockResolvedValue(makeAccount({ balance: 0 }))
const result = await getStakes(context, address)
expect(result.items).toEqual([
{
uid: `delegation-${address}`,
address,
delegate: delegateAddress,
state: 'active',
asset: { type: 'native' },
amount: 0n,
actions: [],
},
])
})
it('should return delegation + stake when stakedBalance > 0', async () => {
mockGetAccountByAddress.mockResolvedValue(makeAccount({ balance: 100, stakedBalance: 30 }))
const result = await getStakes(context, address)
expect(result.items).toEqual([
{
uid: `delegation-${address}`,
address,
delegate: delegateAddress,
state: 'active',
asset: { type: 'native' },
amount: 70n,
actions: [],
},
{
uid: `stake-${address}`,
address,
delegate: delegateAddress,
state: 'active',
asset: { type: 'native' },
amount: 30n,
actions: [],
},
])
})
it('should return delegation + unstaking when unstakedBalance > 0', async () => {
mockGetAccountByAddress.mockResolvedValue(makeAccount({ balance: 100, unstakedBalance: 10 }))
mockGetUnstakeRequests.mockResolvedValue([
makeRequest({
id: 7,
bakerAddress: delegateAddress,
actualAmount: 10,
firstTime: '2026-05-01T00:00:00Z',
}),
])
const result = await getStakes(context, address)
expect(result.items).toEqual([
{
uid: `delegation-${address}`,
address,
delegate: delegateAddress,
state: 'active',
asset: { type: 'native' },
amount: 90n,
actions: [],
},
{
uid: `unstaking-7`,
address,
delegate: delegateAddress,
state: 'deactivating',
createdAt: new Date('2026-05-01T00:00:00Z'),
asset: { type: 'native' },
amount: 10n,
actions: [],
},
])
})
it('should return all three positions when delegate, stakedBalance and unstakedBalance are set', async () => {
mockGetAccountByAddress.mockResolvedValue(
makeAccount({ balance: 100, stakedBalance: 30, unstakedBalance: 10 })
)
mockGetUnstakeRequests.mockResolvedValue([
makeRequest({
id: 11,
bakerAddress: delegateAddress,
actualAmount: 10,
firstTime: '2026-05-02T00:00:00Z',
}),
])
const result = await getStakes(context, address)
expect(result.items).toEqual([
{
uid: `delegation-${address}`,
address,
delegate: delegateAddress,
state: 'active',
asset: { type: 'native' },
amount: 60n,
actions: [],
},
{
uid: `stake-${address}`,
address,
delegate: delegateAddress,
state: 'active',
asset: { type: 'native' },
amount: 30n,
actions: [],
},
{
uid: `unstaking-11`,
address,
delegate: delegateAddress,
state: 'deactivating',
createdAt: new Date('2026-05-02T00:00:00Z'),
asset: { type: 'native' },
amount: 10n,
actions: [],
},
])
const total = result.items.reduce((sum, i) => sum + i.amount, 0n)
expect(total).toBe(100n)
})
})
describe('finalizable unstakes', () => {
const address = 'tz1FinalizableStaker'
const delegateAddress = 'tz1KqTpEZ7Yob7QbPE4Hy4Wo8fHG8LhKxZSx'
function makeAccount(overrides: Record<string, unknown> = {}) {
return {
type: 'user' as const,
address,
publicKey: 'edpk...',
balance: 1000,
revealed: true,
counter: 0,
delegationLevel: 0,
delegationTime: '2026-01-01T00:00:00Z',
numTransactions: 0,
firstActivityTime: '2026-01-01T00:00:00Z',
...overrides,
}
}
it('emits one position per unstake request mixing pending and finalizable', async () => {
mockGetAccountByAddress.mockResolvedValue(
makeAccount({
balance: 1000,
unstakedBalance: 100,
delegate: { alias: 'B', address: delegateAddress, active: true },
})
)
mockGetUnstakeRequests.mockResolvedValue([
makeRequest({
id: 1,
bakerAddress: delegateAddress,
actualAmount: 60,
firstTime: '2026-05-01T00:00:00Z',
status: 'pending',
}),
makeRequest({
id: 2,
bakerAddress: delegateAddress,
actualAmount: 40,
firstTime: '2026-04-25T00:00:00Z',
status: 'finalizable',
}),
])
const result = await getStakes(context, address)
expect(result.items).toEqual([
{
uid: `delegation-${address}`,
address,
delegate: delegateAddress,
state: 'active',
asset: { type: 'native' },
amount: 900n,
actions: [],
},
{
uid: `unstaking-1`,
address,
delegate: delegateAddress,
state: 'deactivating',
createdAt: new Date('2026-05-01T00:00:00Z'),
asset: { type: 'native' },
amount: 60n,
actions: [],
},
{
uid: `finalizable-2`,
address,
delegate: delegateAddress,
state: 'inactive',
createdAt: new Date('2026-04-25T00:00:00Z'),
asset: { type: 'native' },
amount: 40n,
actions: [],
},
])
})
it("preserves the unstake-request baker, not the account's current delegate", async () => {
const previousBaker = 'tz1PreviousBaker'
mockGetAccountByAddress.mockResolvedValue(makeAccount({ balance: 1000, unstakedBalance: 50 }))
mockGetUnstakeRequests.mockResolvedValue([
makeRequest({
id: 99,
bakerAddress: previousBaker,
actualAmount: 50,
firstTime: '2026-05-03T00:00:00Z',
}),
])
const result = await getStakes(context, address)
const unstaking = result.items.find((i) => i.uid === 'unstaking-99')
expect(unstaking?.delegate).toBe(previousBaker)
})
it('skips requests with non-positive actualAmount', async () => {
mockGetAccountByAddress.mockResolvedValue(makeAccount({ balance: 1000, unstakedBalance: 10 }))
mockGetUnstakeRequests.mockResolvedValue([
makeRequest({ id: 3, actualAmount: 0 }),
makeRequest({ id: 4, actualAmount: 10, firstTime: '2026-05-04T00:00:00Z' }),
])
const result = await getStakes(context, address)
expect(result.items.filter((i) => i.uid.startsWith('unstaking-'))).toHaveLength(1)
expect(result.items.find((i) => i.uid === 'unstaking-4')?.amount).toBe(10n)
})
it('skips the unstake-requests endpoint entirely when unstakedBalance is 0', async () => {
mockGetAccountByAddress.mockResolvedValue(makeAccount({ balance: 1000, unstakedBalance: 0 }))
const result = await getStakes(context, address)
expect(result.items).toEqual([])
expect(mockGetUnstakeRequests).not.toHaveBeenCalled()
})
it('skips unstake requests with an invalid firstTime', async () => {
mockGetAccountByAddress.mockResolvedValue(makeAccount({ balance: 1000, unstakedBalance: 30 }))
mockGetUnstakeRequests.mockResolvedValue([
makeRequest({ id: 1, actualAmount: 10, firstTime: 'not-a-date' }),
makeRequest({ id: 2, actualAmount: 20, firstTime: '2026-05-04T00:00:00Z' }),
])
const result = await getStakes(context, address)
expect(result.items.map((i) => i.uid)).toEqual(['unstaking-2'])
})
it('drops finalized requests (funds already returned to spendable)', async () => {
mockGetAccountByAddress.mockResolvedValue(makeAccount({ balance: 1000, unstakedBalance: 30 }))
mockGetUnstakeRequests.mockResolvedValue([
makeRequest({ id: 1, actualAmount: 480, status: 'finalized' }),
makeRequest({ id: 2, actualAmount: 30, firstTime: '2026-05-04T00:00:00Z' }),
])
const result = await getStakes(context, address)
expect(result.items.map((i) => i.uid)).toEqual(['unstaking-2'])
expect(result.items.find((i) => i.uid === 'unstaking-2')?.amount).toBe(30n)
})
it('propagates rejection when getUnstakeRequests fails', async () => {
mockGetAccountByAddress.mockResolvedValue(makeAccount({ balance: 100, unstakedBalance: 10 }))
mockGetUnstakeRequests.mockRejectedValueOnce(new Error('tzkt 500'))
await expect(getStakes(context, address)).rejects.toThrow('tzkt 500')
})
})
describe('delegation amount clamping', () => {
const address = 'tz1Reorg'
const delegateAddress = 'tz1Baker'
it('clamps delegation amount to 0 when balance < stakedBalance (TzKT reorg edge)', async () => {
mockGetAccountByAddress.mockResolvedValue({
type: 'user',
address,
publicKey: 'edpk...',
balance: 50,
stakedBalance: 80,
revealed: true,
counter: 0,
delegate: { alias: 'B', address: delegateAddress, active: true },
delegationLevel: 100,
delegationTime: '2026-01-01T00:00:00Z',
numTransactions: 0,
firstActivityTime: '2026-01-01T00:00:00Z',
})
const result = await getStakes(context, address)
const delegation = result.items.find((i) => i.uid === `delegation-${address}`)
expect(delegation?.amount).toBe(0n)
})
})
})