UNPKG

@ledgerhq/coin-tezos

Version:
511 lines (449 loc) 15.8 kB
// 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) }) }) })