@ledgerhq/coin-tezos
Version:
478 lines (445 loc) • 14 kB
text/typescript
// 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: [],
},
})
})
})
})