@ledgerhq/coin-tezos
Version:
198 lines (160 loc) • 7.06 kB
text/typescript
// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0
import { validatePublicKey, ValidationResult } from '@taquito/utils'
import {
computeMaxStakeAmount,
normalizePublicKeyForAddress,
parseTezosTokenAsset,
partitionNativeBalance,
resolveTezosOperationMode,
STAKE_USE_ALL_RESERVE_MUTEZ,
} from './utils'
describe('parseTezosTokenAsset', () => {
it('returns null for native asset', () => {
expect(parseTezosTokenAsset({ type: 'native' })).toBeNull()
})
it('returns null when asset is undefined', () => {
expect(parseTezosTokenAsset(undefined)).toBeNull()
})
it('parses KT1-only reference as token id 0', () => {
expect(
parseTezosTokenAsset({
type: 'token',
assetReference: 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU',
})
).toEqual({
contractAddress: 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU',
tokenId: 0,
})
})
it('parses contract:tokenId reference', () => {
expect(
parseTezosTokenAsset({
type: 'token',
assetReference: 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU:42',
})
).toEqual({
contractAddress: 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU',
tokenId: 42,
})
})
it('returns null for invalid token id suffix', () => {
expect(
parseTezosTokenAsset({
type: 'token',
assetReference: 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU:abc',
})
).toBeNull()
})
it('returns null when reference does not start with KT1', () => {
expect(parseTezosTokenAsset({ type: 'token', assetReference: 'not-a-contract' })).toBeNull()
})
})
describe('resolveTezosOperationMode', () => {
it('returns send_token for send intent with token asset', () => {
expect(
resolveTezosOperationMode('send', {
type: 'token',
assetReference: 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU:0',
})
).toBe('send_token')
})
it('returns send for send intent with native asset', () => {
expect(resolveTezosOperationMode('send', { type: 'native' })).toBe('send')
})
it('maps staking intents to real staking modes regardless of asset', () => {
expect(
resolveTezosOperationMode('stake', {
type: 'token',
assetReference: 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU:0',
})
).toBe('stake')
expect(resolveTezosOperationMode('unstake', { type: 'native' })).toBe('unstake')
expect(resolveTezosOperationMode('finalize_unstake', { type: 'native' })).toBe(
'finalize_unstake'
)
})
it('maps delegation intents to delegation modes', () => {
expect(resolveTezosOperationMode('delegate', { type: 'native' })).toBe('delegate')
expect(resolveTezosOperationMode('undelegate', { type: 'native' })).toBe('undelegate')
})
})
describe('normalizePublicKeyForAddress', () => {
it('should normalize hex public key for tz2 address (secp256k1) to base58 format', () => {
const tz2Address = 'tz2F4XnSd1wjwWsthemvZQjoPER7NVSt35k3'
const hexPublicKey = '03576c19462a7d0cc3d121b1b00e92258b5f71d643c99a599fc1683f03abb7a1c2'
const expectedPublicKey = 'sppk7but7h93Ws1XhAPvdBcttVmoBDGHxdpaU8dPy5549f3eLJFAjag'
// When: normalize the hex public key to base58 format
const result = normalizePublicKeyForAddress(hexPublicKey, tz2Address)
// Then: should return the correct base58 encoded public key
expect(result).toBe(expectedPublicKey)
})
it('should normalize a 33-byte Ledger ED25519 payload (prefix byte + raw key) to a valid edpk', () => {
const tz1Address = 'tz1SVgE7L6xzEY2UpQ4UvtPkRDDMwSmcqVqr'
const ledgerEd25519Hex = '02b053b74eab7e21c5a8fc1d945ee9fa6dbe2fedb6d47a43299ff33b7f28ea179b'
const result = normalizePublicKeyForAddress(ledgerEd25519Hex, tz1Address)
expect(result!.startsWith('edpk')).toBe(true)
expect(validatePublicKey(result!)).toBe(ValidationResult.VALID)
})
it('should normalize a 32-byte raw ED25519 key to a valid edpk', () => {
const tz1Address = 'tz1SVgE7L6xzEY2UpQ4UvtPkRDDMwSmcqVqr'
const rawEd25519Hex = 'b053b74eab7e21c5a8fc1d945ee9fa6dbe2fedb6d47a43299ff33b7f28ea179b'
const result = normalizePublicKeyForAddress(rawEd25519Hex, tz1Address)
expect(result!.startsWith('edpk')).toBe(true)
expect(validatePublicKey(result!)).toBe(ValidationResult.VALID)
})
it('should refuse to encode a malformed tz1 input (e.g. a non-hex b58 string parsed to 0 bytes)', () => {
const tz1Address = 'tz1SVgE7L6xzEY2UpQ4UvtPkRDDMwSmcqVqr'
const garbage = '3s7xnj2xf2q7au3rrpg7j9k5zf3djfqgbtrswxf8d69vh94hfdtsbpni'
const result = normalizePublicKeyForAddress(garbage, tz1Address)
expect(result).toBeUndefined()
})
it('should refuse to encode a 65-byte SEC1 input for a tz1 (ED25519) address', () => {
const tz1Address = 'tz1SVgE7L6xzEY2UpQ4UvtPkRDDMwSmcqVqr'
const sec1Hex = '04' + 'ab'.repeat(64) // 65 bytes
const result = normalizePublicKeyForAddress(sec1Hex, tz1Address)
expect(result).toBeUndefined()
})
})
describe('computeMaxStakeAmount', () => {
it('subtracts already-staked funds, fees and the reserve', () => {
expect(computeMaxStakeAmount(1_000_000n, 300_000n, 0n, 1000n)).toBe(
700_000n - 1000n - STAKE_USE_ALL_RESERVE_MUTEZ
)
})
it('uses the full balance when nothing is staked', () => {
expect(computeMaxStakeAmount(1_000_000n, 0n, 0n, 1000n)).toBe(
1_000_000n - 1000n - STAKE_USE_ALL_RESERVE_MUTEZ
)
})
it('also subtracts unstaked-frozen funds (not re-stakeable until finalized)', () => {
expect(computeMaxStakeAmount(1_000_000n, 300_000n, 200_000n, 1000n)).toBe(
500_000n - 1000n - STAKE_USE_ALL_RESERVE_MUTEZ
)
})
it('clamps to 0 when staked balance covers the whole balance', () => {
expect(computeMaxStakeAmount(300_000n, 300_000n, 0n, 1000n)).toBe(0n)
expect(computeMaxStakeAmount(200_000n, 300_000n, 0n, 1000n)).toBe(0n)
})
it('clamps to 0 when the liquid balance cannot cover fees and reserve', () => {
expect(computeMaxStakeAmount(305_000n, 300_000n, 0n, 1000n)).toBe(0n)
})
})
describe('partitionNativeBalance', () => {
it('splits total into spendable and locked (staked + unstaked)', () => {
expect(partitionNativeBalance(100n, 30n, 10n)).toEqual({ spendable: 60n, locked: 40n })
})
it('treats the whole balance as spendable when nothing is frozen', () => {
expect(partitionNativeBalance(100n, 0n, 0n)).toEqual({ spendable: 100n, locked: 0n })
})
it('clamps locked to total (spendable to 0) when frozen funds exceed balance', () => {
expect(partitionNativeBalance(50n, 80n, 10n)).toEqual({ spendable: 0n, locked: 50n })
})
it('returns zero spendable when frozen funds exactly equal the balance', () => {
expect(partitionNativeBalance(100n, 70n, 30n)).toEqual({ spendable: 0n, locked: 100n })
})
it('locks staked-only and unstaked-only amounts', () => {
expect(partitionNativeBalance(100n, 40n, 0n)).toEqual({ spendable: 60n, locked: 40n })
expect(partitionNativeBalance(100n, 0n, 25n)).toEqual({ spendable: 75n, locked: 25n })
})
})