UNPKG

@ledgerhq/coin-tezos

Version:
478 lines (445 loc) 14 kB
// SPDX-FileCopyrightText: © 2026 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 import type { TezosCoinConfig, TezosContext } from '../config' import type { APITokenBalance } from '../network/types' import { getBalance } from './getBalance' const mockedGetAccountByAddress = jest.fn() const mockedGetTokensBalances = jest.fn() const mockedGetUnstakeRequests = jest.fn() jest.mock('../network/tzkt', () => { const originalModule = jest.requireActual('../network/tzkt') return { ...originalModule, createTzktApi: () => { return { ...originalModule.api, getAccountByAddress: mockedGetAccountByAddress, getTokensBalances: mockedGetTokensBalances, getUnstakeRequests: mockedGetUnstakeRequests, } }, } }) const mockTokensBalancesWithAllInfo: APITokenBalance[] = [ { id: 2398642347442177, account: { address: 'tz1TzrmTBSuiVHV2VfMnGRMYvTEPCP42oSM8' }, token: { id: 113484097388545, contract: { alias: 'BTCtz', address: 'KT1T87QbpXEVgkwsNPzz8iRoah3SS3D1MDmh' }, tokenId: '0', standard: 'fa2', metadata: { name: 'BTCtez', symbol: 'BTCtz', decimals: '8', }, }, balance: '2000', transfersCount: 2, firstLevel: 11909116, firstTime: '2026-02-12T14:16:16Z', lastLevel: 11969607, lastTime: '2026-02-16T20:05:40Z', }, ] const mockTokensBalancesWithMissingInfo: APITokenBalance[] = [ { id: 2398642347442177, account: { address: 'tz1TzrmTBSuiVHV2VfMnGRMYvTEPCP42oMMM' }, token: { id: 113484097388545, contract: { alias: 'BTCtz', address: 'KT1T87QbpXEVgkwsNPzz8iRoah3SS3D1MDmh' }, tokenId: '0', standard: 'fa2', }, balance: '2000', transfersCount: 2, firstLevel: 11909116, firstTime: '2026-02-12T14:16:16Z', lastLevel: 11969607, lastTime: '2026-02-16T20:05:40Z', }, ] describe('getBalance', () => { beforeEach(() => { jest.resetAllMocks() }) const context: TezosContext = { config: async () => ({ status: { type: 'active' }, explorer: { url: 'http://tezos.explorer.com' }, }) as unknown as TezosCoinConfig, logger: () => undefined, } it('gets the balance of a Tezos account', async () => { mockedGetAccountByAddress.mockImplementation((address: string) => { if (address === 'tz1WvvbEGpBXGeTVbLiR6DYBe1izmgiYuZbq') { return { type: 'empty' } } else if (address === 'tz1TzrmTBSuiVHV2VfMnGRMYvTEPCP42oSM8') { return { type: 'user', balance: 25 } } else if (address === 'tz1TzrmTBSuiVHV2VfMnGRMYvTEPCP42oMMM') { return { type: 'user', balance: 15, delegate: { address: 'tz1TzrmTBSuiVHV2VfMnGRMYvTEPCP42oMMM' }, } } else { throw new Error('address not handled by the test: ' + address) } }) mockedGetTokensBalances.mockImplementation((address: string) => { if (address === 'tz1TzrmTBSuiVHV2VfMnGRMYvTEPCP42oSM8') { return mockTokensBalancesWithAllInfo } else if (address === 'tz1TzrmTBSuiVHV2VfMnGRMYvTEPCP42oMMM') { return mockTokensBalancesWithMissingInfo } else { return [] } }) expect(await getBalance(context, 'tz1WvvbEGpBXGeTVbLiR6DYBe1izmgiYuZbq')).toEqual([ { value: 0n, asset: { type: 'native' }, }, ]) expect(await getBalance(context, 'tz1TzrmTBSuiVHV2VfMnGRMYvTEPCP42oSM8')).toEqual([ { value: BigInt(25), asset: { type: 'native' }, }, { value: BigInt('2000'), asset: { type: 'fa2', assetReference: 'KT1T87QbpXEVgkwsNPzz8iRoah3SS3D1MDmh:0', assetOwner: 'tz1TzrmTBSuiVHV2VfMnGRMYvTEPCP42oSM8', name: 'BTCtz', unit: { magnitude: 8, name: 'BTCtez', code: 'BTCtz' }, }, }, ]) expect(await getBalance(context, 'tz1TzrmTBSuiVHV2VfMnGRMYvTEPCP42oMMM')).toEqual([ { value: 15n, asset: { type: 'native' }, }, { value: 15n, asset: { type: 'native' }, stake: { uid: 'delegation-tz1TzrmTBSuiVHV2VfMnGRMYvTEPCP42oMMM', address: 'tz1TzrmTBSuiVHV2VfMnGRMYvTEPCP42oMMM', delegate: 'tz1TzrmTBSuiVHV2VfMnGRMYvTEPCP42oMMM', state: 'active', asset: { type: 'native', }, amount: 15n, actions: [], }, }, { value: 2000n, asset: { type: 'fa2', assetReference: 'KT1T87QbpXEVgkwsNPzz8iRoah3SS3D1MDmh:0', assetOwner: 'tz1TzrmTBSuiVHV2VfMnGRMYvTEPCP42oMMM', name: 'BTCtz', unit: { code: 'BTCtz', magnitude: 0, name: 'BTCtz', }, }, }, ]) }) describe('staking positions (Paris upgrade)', () => { const address = 'tz1StakingAddr' const delegateAddress = 'tz1BakerAddr' function mockAccount(account: Record<string, unknown>) { mockedGetAccountByAddress.mockResolvedValue({ type: 'user', ...account }) mockedGetTokensBalances.mockResolvedValue([]) mockedGetUnstakeRequests.mockResolvedValue([]) } it('attaches a delegation Stake when only delegate is set', async () => { mockAccount({ balance: 100, delegate: { address: delegateAddress } }) expect(await getBalance(context, address)).toEqual([ { value: 100n, asset: { type: 'native' } }, { value: 100n, asset: { type: 'native', }, stake: { uid: `delegation-${address}`, address, delegate: delegateAddress, state: 'active', asset: { type: 'native', }, amount: 100n, actions: [], }, }, ]) }) it('attaches a stake Stake when stakedBalance > 0 (no delegate)', async () => { mockAccount({ balance: 100, stakedBalance: 30 }) expect(await getBalance(context, address)).toEqual([ { value: 100n, asset: { type: 'native' }, locked: 30n }, { value: 30n, asset: { type: 'native', }, stake: { uid: `stake-${address}`, address, state: 'active', asset: { type: 'native', }, amount: 30n, actions: [], }, }, ]) }) it('splits delegation and stake amounts when both delegate and stakedBalance are set', async () => { mockAccount({ balance: 100, stakedBalance: 30, delegate: { address: delegateAddress }, }) expect(await getBalance(context, address)).toEqual([ { value: 100n, asset: { type: 'native' }, locked: 30n }, { value: 70n, asset: { type: 'native', }, stake: { uid: `delegation-${address}`, address, delegate: delegateAddress, state: 'active', asset: { type: 'native', }, amount: 70n, actions: [], }, }, { value: 30n, asset: { type: 'native', }, stake: { uid: `stake-${address}`, address, delegate: delegateAddress, state: 'active', asset: { type: 'native', }, amount: 30n, actions: [], }, }, ]) }) it("attaches an unstaking Stake with state 'deactivating' when a pending request exists", async () => { mockedGetAccountByAddress.mockResolvedValue({ type: 'user', balance: 100, stakedBalance: 30, unstakedBalance: 10, delegate: { address: delegateAddress }, }) mockedGetTokensBalances.mockResolvedValue([]) mockedGetUnstakeRequests.mockResolvedValue([ { id: 77, cycle: 100, baker: { address: delegateAddress }, staker: { address }, firstTime: '2026-05-01T00:00:00Z', status: 'pending', actualAmount: 10, }, ]) const result = await getBalance(context, address) expect(result).toHaveLength(4) expect(result[0]).toEqual({ value: 100n, asset: { type: 'native' }, locked: 40n }) expect(result[3]).toEqual({ value: 10n, asset: { type: 'native' }, stake: { uid: 'unstaking-77', address, delegate: delegateAddress, state: 'deactivating', createdAt: new Date('2026-05-01T00:00:00Z'), asset: { type: 'native' }, amount: 10n, actions: [], }, }) }) it('emits per-request unstaking and finalizable Stakes when both statuses are present', async () => { mockedGetAccountByAddress.mockResolvedValue({ type: 'user', balance: 100, unstakedBalance: 50, delegate: { address: delegateAddress }, }) mockedGetUnstakeRequests.mockResolvedValue([ { id: 1, cycle: 100, baker: { address: delegateAddress }, staker: { address }, firstTime: '2026-05-01T00:00:00Z', status: 'pending', actualAmount: 20, }, { id: 2, cycle: 99, baker: { address: delegateAddress }, staker: { address }, firstTime: '2026-04-25T00:00:00Z', status: 'finalizable', actualAmount: 30, }, ]) mockedGetTokensBalances.mockResolvedValue([]) const result = await getBalance(context, address) expect(result[0]).toEqual({ value: 100n, asset: { type: 'native' }, locked: 50n }) const stakes = result.filter((b) => b.stake).map((b) => b.stake) expect(stakes).toEqual([ { uid: `delegation-${address}`, address, delegate: delegateAddress, state: 'active', asset: { type: 'native' }, amount: 50n, actions: [], }, { uid: `unstaking-1`, address, delegate: delegateAddress, state: 'deactivating', createdAt: new Date('2026-05-01T00:00:00Z'), asset: { type: 'native' }, amount: 20n, actions: [], }, { uid: `finalizable-2`, address, delegate: delegateAddress, state: 'inactive', createdAt: new Date('2026-04-25T00:00:00Z'), asset: { type: 'native' }, amount: 30n, actions: [], }, ]) }) it("returns the baker's own balance, self-stake and unstake positions for a delegate account (LIVE-34256)", async () => { // tzkt reports a registered baker with type "delegate" (not "user"). Before the fix this // fell through to a 0 native balance and no stakes; the account appeared empty in Ledger Live. // A delegate has no `delegate` field (it is its own baker), so there is no delegation position — // only its self-stake and any unstake requests. The unstakedBalance > 0 exercises the // delegate → fetchUnstakeRequests path. mockedGetAccountByAddress.mockResolvedValue({ type: 'delegate', balance: 100, stakedBalance: 30, unstakedBalance: 10, }) mockedGetTokensBalances.mockResolvedValue([]) mockedGetUnstakeRequests.mockResolvedValue([ { id: 77, cycle: 100, baker: { address }, staker: { address }, firstTime: '2026-05-01T00:00:00Z', status: 'pending', actualAmount: 10, }, ]) expect(await getBalance(context, address)).toEqual([ { value: 100n, asset: { type: 'native' }, locked: 40n }, { value: 30n, asset: { type: 'native' }, stake: { uid: `stake-${address}`, address, state: 'active', asset: { type: 'native' }, amount: 30n, actions: [], }, }, { value: 10n, asset: { type: 'native' }, stake: { uid: 'unstaking-77', address, delegate: address, state: 'deactivating', createdAt: new Date('2026-05-01T00:00:00Z'), asset: { type: 'native' }, amount: 10n, actions: [], }, }, ]) }) it('returns only the primary native Balance when no staking activity', async () => { mockAccount({ balance: 50 }) expect(await getBalance(context, address)).toEqual([ { value: 50n, asset: { type: 'native' } }, ]) }) it('still excludes unstaked-frozen funds from spendable when the unstake_requests endpoint fails', async () => { mockedGetAccountByAddress.mockResolvedValue({ type: 'user', balance: 100, unstakedBalance: 10, delegate: { address: delegateAddress }, }) mockedGetUnstakeRequests.mockRejectedValue(new Error('endpoint failure')) mockedGetTokensBalances.mockResolvedValue([]) const result = await getBalance(context, address) // Breakdown rows are lost, but locked/delegated still derive from the account-level unstakedBalance. expect(result).toHaveLength(2) expect(result[0]).toEqual({ value: 100n, asset: { type: 'native' }, locked: 10n }) expect(result[1]).toEqual({ value: 90n, asset: { type: 'native' }, stake: { uid: `delegation-${address}`, address, delegate: delegateAddress, state: 'active', asset: { type: 'native' }, amount: 90n, actions: [], }, }) }) }) })