@ledgerhq/coin-tezos 10.2.1 → 10.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/lib/api/index.d.ts +1 -3
  3. package/lib/api/index.d.ts.map +1 -1
  4. package/lib/api/index.js +4 -9
  5. package/lib/api/index.js.map +1 -1
  6. package/lib/logic/craftRawOperations.js +1 -1
  7. package/lib/logic/craftRawOperations.js.map +1 -1
  8. package/lib/logic/craftTransaction.d.ts.map +1 -1
  9. package/lib/logic/craftTransaction.js +1 -2
  10. package/lib/logic/craftTransaction.js.map +1 -1
  11. package/lib/logic/estimateFees.d.ts.map +1 -1
  12. package/lib/logic/estimateFees.js +51 -6
  13. package/lib/logic/estimateFees.js.map +1 -1
  14. package/lib/logic/estimateRevealLimits.d.ts +2 -2
  15. package/lib/logic/estimateRevealLimits.d.ts.map +1 -1
  16. package/lib/logic/estimateRevealLimits.js +3 -3
  17. package/lib/logic/estimateRevealLimits.js.map +1 -1
  18. package/lib/logic/getBalance.d.ts.map +1 -1
  19. package/lib/logic/getBalance.js +2 -3
  20. package/lib/logic/getBalance.js.map +1 -1
  21. package/lib/logic/getBlock.js +6 -6
  22. package/lib/logic/getBlock.js.map +1 -1
  23. package/lib/logic/getStakes.d.ts +2 -1
  24. package/lib/logic/getStakes.d.ts.map +1 -1
  25. package/lib/logic/getStakes.js +8 -9
  26. package/lib/logic/getStakes.js.map +1 -1
  27. package/lib/logic/listOperations.d.ts.map +1 -1
  28. package/lib/logic/listOperations.js +16 -16
  29. package/lib/logic/listOperations.js.map +1 -1
  30. package/lib/logic/validateIntent.d.ts.map +1 -1
  31. package/lib/logic/validateIntent.js +39 -9
  32. package/lib/logic/validateIntent.js.map +1 -1
  33. package/lib/network/bakers.d.ts +6 -5
  34. package/lib/network/bakers.d.ts.map +1 -1
  35. package/lib/network/bakers.js +13 -12
  36. package/lib/network/bakers.js.map +1 -1
  37. package/lib/network/tzkt.d.ts +8 -8
  38. package/lib/network/tzkt.d.ts.map +1 -1
  39. package/lib/network/tzkt.js +13 -11
  40. package/lib/network/tzkt.js.map +1 -1
  41. package/lib/types/errors.d.ts +3 -0
  42. package/lib/types/errors.d.ts.map +1 -1
  43. package/lib/types/errors.js +8 -1
  44. package/lib/types/errors.js.map +1 -1
  45. package/lib/utils.d.ts +17 -0
  46. package/lib/utils.d.ts.map +1 -1
  47. package/lib/utils.js +31 -1
  48. package/lib/utils.js.map +1 -1
  49. package/lib-es/api/index.d.ts +1 -3
  50. package/lib-es/api/index.d.ts.map +1 -1
  51. package/lib-es/api/index.js +5 -10
  52. package/lib-es/api/index.js.map +1 -1
  53. package/lib-es/logic/craftRawOperations.js +1 -1
  54. package/lib-es/logic/craftRawOperations.js.map +1 -1
  55. package/lib-es/logic/craftTransaction.d.ts.map +1 -1
  56. package/lib-es/logic/craftTransaction.js +1 -2
  57. package/lib-es/logic/craftTransaction.js.map +1 -1
  58. package/lib-es/logic/estimateFees.d.ts.map +1 -1
  59. package/lib-es/logic/estimateFees.js +52 -7
  60. package/lib-es/logic/estimateFees.js.map +1 -1
  61. package/lib-es/logic/estimateRevealLimits.d.ts +2 -2
  62. package/lib-es/logic/estimateRevealLimits.d.ts.map +1 -1
  63. package/lib-es/logic/estimateRevealLimits.js +3 -3
  64. package/lib-es/logic/estimateRevealLimits.js.map +1 -1
  65. package/lib-es/logic/getBalance.d.ts.map +1 -1
  66. package/lib-es/logic/getBalance.js +2 -3
  67. package/lib-es/logic/getBalance.js.map +1 -1
  68. package/lib-es/logic/getBlock.js +6 -6
  69. package/lib-es/logic/getBlock.js.map +1 -1
  70. package/lib-es/logic/getStakes.d.ts +2 -1
  71. package/lib-es/logic/getStakes.d.ts.map +1 -1
  72. package/lib-es/logic/getStakes.js +8 -9
  73. package/lib-es/logic/getStakes.js.map +1 -1
  74. package/lib-es/logic/listOperations.d.ts.map +1 -1
  75. package/lib-es/logic/listOperations.js +16 -16
  76. package/lib-es/logic/listOperations.js.map +1 -1
  77. package/lib-es/logic/validateIntent.d.ts.map +1 -1
  78. package/lib-es/logic/validateIntent.js +42 -12
  79. package/lib-es/logic/validateIntent.js.map +1 -1
  80. package/lib-es/network/bakers.d.ts +6 -5
  81. package/lib-es/network/bakers.d.ts.map +1 -1
  82. package/lib-es/network/bakers.js +13 -12
  83. package/lib-es/network/bakers.js.map +1 -1
  84. package/lib-es/network/tzkt.d.ts +8 -8
  85. package/lib-es/network/tzkt.d.ts.map +1 -1
  86. package/lib-es/network/tzkt.js +13 -11
  87. package/lib-es/network/tzkt.js.map +1 -1
  88. package/lib-es/types/errors.d.ts +3 -0
  89. package/lib-es/types/errors.d.ts.map +1 -1
  90. package/lib-es/types/errors.js +6 -0
  91. package/lib-es/types/errors.js.map +1 -1
  92. package/lib-es/utils.d.ts +17 -0
  93. package/lib-es/utils.d.ts.map +1 -1
  94. package/lib-es/utils.js +29 -0
  95. package/lib-es/utils.js.map +1 -1
  96. package/package.json +3 -4
  97. package/src/api/index.test.ts +32 -0
  98. package/src/api/index.ts +5 -13
  99. package/src/logic/craftRawOperations.ts +1 -1
  100. package/src/logic/craftTransaction.ts +1 -2
  101. package/src/logic/estimateFees.test.ts +124 -2
  102. package/src/logic/estimateFees.ts +59 -6
  103. package/src/logic/estimateRevealLimits.ts +11 -7
  104. package/src/logic/getBalance.ts +9 -6
  105. package/src/logic/getBlock.ts +6 -6
  106. package/src/logic/getStakes.ts +13 -8
  107. package/src/logic/listOperations.ts +23 -13
  108. package/src/logic/validateIntent.test.ts +218 -2
  109. package/src/logic/validateIntent.ts +49 -9
  110. package/src/network/bakers.integ.test.ts +3 -2
  111. package/src/network/bakers.test.ts +2 -1
  112. package/src/network/bakers.ts +15 -13
  113. package/src/network/tzkt.test.ts +29 -30
  114. package/src/network/tzkt.ts +38 -23
  115. package/src/types/errors.ts +7 -0
  116. package/src/utils.ts +32 -0
@@ -14,6 +14,7 @@ import {
14
14
  NotEnoughBalance,
15
15
  AmountRequired,
16
16
  InvalidAddressBecauseDestinationIsAlsoSource,
17
+ UnexpectedGetBalanceError,
17
18
  } from '@ledgerhq/coin-module-framework/errors'
18
19
  import { validateAddress, ValidationResult } from '@taquito/utils'
19
20
  import { hasManagerKey } from '../network/types'
@@ -23,6 +24,7 @@ import {
23
24
  MustDelegateBeforeStaking,
24
25
  NotEnoughBalanceToDelegate,
25
26
  TezosNotEnoughStaked,
27
+ TezosStakeAmountTooSmall,
26
28
  TezosStakeBlockedByPendingUnstake,
27
29
  } from '../types/errors'
28
30
  import {
@@ -30,6 +32,7 @@ import {
30
32
  parseTezosTokenAsset,
31
33
  partitionNativeBalance,
32
34
  resolveTezosOperationMode,
35
+ STAKE_ESTIMATION_PROBE_MUTEZ,
33
36
  } from '../utils'
34
37
  import { estimateFees } from './estimateFees'
35
38
 
@@ -155,6 +158,9 @@ function mapTaquitoErrors(taquitoError: string, intentType: string): Record<stri
155
158
  errors.amount = new TezosNotEnoughStaked()
156
159
  } else if (taquitoError.endsWith('contract.must_be_delegated_to_stake')) {
157
160
  errors.amount = new MustDelegateBeforeStaking()
161
+ } else if (taquitoError.endsWith('stake_amount_too_small')) {
162
+ // Below one staking pseudotoken the stake mints nothing; see STAKE_ESTIMATION_PROBE_MUTEZ.
163
+ errors.amount = new TezosStakeAmountTooSmall()
158
164
  } else if (
159
165
  taquitoError.endsWith('cannot_stake_with_unfinalizable_unstake_requests_to_another_delegate')
160
166
  ) {
@@ -165,10 +171,10 @@ function mapTaquitoErrors(taquitoError: string, intentType: string): Record<stri
165
171
  // Re-delegating (or staking) to the current baker leaves the delegate unchanged; the node
166
172
  // rejects it. Surfaces for both `delegate` and `stake` intents as "already delegated".
167
173
  errors.recipient = new InvalidAddressBecauseAlreadyDelegated()
168
- } else if (taquitoError.includes('empty_implicit_contract')) {
174
+ } else if (taquitoError.endsWith('empty_implicit_contract')) {
169
175
  errors.amount =
170
176
  intentType === 'stake' ? new NotEnoughBalance() : new NotEnoughBalanceToDelegate()
171
- } else if (taquitoError.includes('script_rejected')) {
177
+ } else if (taquitoError.endsWith('script_rejected')) {
172
178
  errors.amount = new NotEnoughBalance()
173
179
  } else {
174
180
  errors.amount = new Error(taquitoError)
@@ -397,17 +403,25 @@ export async function validateIntent(
397
403
  // the outer handler (which would wipe the already-computed estimatedFees and add a spurious
398
404
  // errors.estimation on top of the real fee-estimation error).
399
405
  let tokenBalance: bigint | undefined
406
+ let tokenBalanceError: unknown
400
407
  if (errors.amount && !intent.useAllAmount) {
401
408
  tokenBalance = undefined
402
409
  } else {
403
410
  try {
404
411
  tokenBalance = await fetchTokenBalance(config, intent)
405
- } catch {
406
- tokenBalance = undefined // TzKT unreachable: fall back gracefully, no token coverage check
412
+ } catch (e) {
413
+ tokenBalance = undefined
414
+ tokenBalanceError = e
407
415
  }
408
416
  }
409
- // send-max uses the full token balance as the sent amount; fixed-amount only needs it for coverage
410
- const tokenBalanceForSendMax = intent.useAllAmount ? tokenBalance : undefined
417
+
418
+ // Never pass `undefined` for a token send-max: that triggers the native-branch/wrong-unit
419
+ // fallback described above. An unresolved balance becomes 0n; the guards below set the error.
420
+ const isTokenSendMax =
421
+ intent.type === 'send' &&
422
+ !!intent.useAllAmount &&
423
+ resolveTezosOperationMode(intent.type, intent.asset) === 'send_token'
424
+ const tokenBalanceForSendMax = isTokenSendMax ? (tokenBalance ?? 0n) : undefined
411
425
 
412
426
  const amounts = calculateAmounts(
413
427
  intent,
@@ -419,10 +433,36 @@ export async function validateIntent(
419
433
  amount = amounts.amount
420
434
  totalSpent = amounts.totalSpent
421
435
 
422
- if (intent.type === 'stake' && intent.useAllAmount && amount === 0n && !errors.amount) {
436
+ // An unreachable TzKT is not an empty balance. Both block, but reporting NotEnoughBalance
437
+ // here would tell a user who holds the token that they have insufficient funds.
438
+ if (isTokenSendMax && tokenBalanceError !== undefined && !errors.amount) {
439
+ errors.amount = new UnexpectedGetBalanceError(undefined, tokenBalanceError)
440
+ }
441
+
442
+ // A send-max resolving to 0n must block. validateBalanceCoverage below only compares native
443
+ // spendable against totalSpent, which a 0-amount transaction always satisfies, so nothing
444
+ // else catches it.
445
+ // `finalize_unstake` is excluded: it is parameter-less and 0n by construction.
446
+ if (
447
+ (intent.type === 'send' || intent.type === 'stake') &&
448
+ intent.useAllAmount &&
449
+ amount === 0n &&
450
+ !errors.amount
451
+ ) {
423
452
  errors.amount = new NotEnoughBalance()
424
453
  }
425
454
 
455
+ // Estimation simulates the probe, never the resolved max; below the probe the stake may still
456
+ // mint no pseudotoken and be refused at injection.
457
+ if (
458
+ intent.type === 'stake' &&
459
+ intent.useAllAmount &&
460
+ amount < STAKE_ESTIMATION_PROBE_MUTEZ &&
461
+ !errors.amount
462
+ ) {
463
+ errors.amount = new TezosStakeAmountTooSmall()
464
+ }
465
+
426
466
  const { spendable } = partitionNativeBalance(
427
467
  BigInt(senderInfo.balance),
428
468
  BigInt(senderInfo.stakedBalance ?? 0),
@@ -431,8 +471,8 @@ export async function validateIntent(
431
471
  const balanceErrors = validateBalanceCoverage(spendable, totalSpent)
432
472
  Object.assign(errors, balanceErrors)
433
473
 
434
- // Token balance coverage for fixed-amount token sends.
435
- // (send-max is always valid by construction: amount is set to tokenBalance above.)
474
+ // Token balance coverage for fixed-amount token sends. send-max needs none: amount is the
475
+ // token balance itself, and the zero/unresolved cases already errored above.
436
476
  if (
437
477
  !errors.amount &&
438
478
  intent.type === 'send' &&
@@ -1,16 +1,17 @@
1
1
  // SPDX-FileCopyrightText: © 2026 LEDGER SAS
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import type { TezosCoinConfig } from '../config'
4
+ import type { TezosCoinConfig, TezosContext } from '../config'
5
5
  import { listBakers } from '../network/bakers'
6
6
  import whitelist from '../network/bakers.whitelist-default'
7
7
  import { mockConfig } from '../test/config'
8
8
 
9
9
  describe('tezos bakers', () => {
10
10
  const config: TezosCoinConfig = mockConfig
11
+ const context: TezosContext = { config: async () => config, logger: jest.fn() }
11
12
 
12
13
  test('atleast 10 whitelisted bakers are online', async () => {
13
- const onlineBakersCount = (await listBakers(config, whitelist))
14
+ const onlineBakersCount = (await listBakers(context, whitelist))
14
15
  .map((baker) => baker.address)
15
16
  .filter((address) => whitelist.includes(address)).length
16
17
  expect(onlineBakersCount).toBeGreaterThan(10)
@@ -7,6 +7,7 @@ import { mockConfig } from '../test/config'
7
7
  import { asBaker, cache } from './bakers'
8
8
 
9
9
  const mockNetworkFn = jest.fn()
10
+ const mockLogger = jest.fn()
10
11
  jest.mock('@ledgerhq/live-network/network', () => ({
11
12
  __esModule: true,
12
13
  default: (...args: unknown[]) => mockNetworkFn(...args),
@@ -183,7 +184,7 @@ describe('Tezos Baker', () => {
183
184
  })
184
185
 
185
186
  it('Ledger Baker should be first in the list', async () => {
186
- const bakers = await cache(config)
187
+ const bakers = await cache(config, mockLogger)
187
188
  expect(bakers[0].name).toBe('Ledger by Kiln')
188
189
  })
189
190
  })
@@ -1,11 +1,11 @@
1
1
  // SPDX-FileCopyrightText: © 2026 LEDGER SAS
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import type { TezosCoinConfig } from '../config'
4
+ import type { TezosCoinConfig, TezosContext } from '../config'
5
5
  import type { Baker } from '../types'
6
+ import type { Logger } from '@ledgerhq/coin-module-framework/config'
6
7
  import { makeLRUCache } from '@ledgerhq/live-network/cache'
7
8
  import network from '@ledgerhq/live-network/network'
8
- import { log } from '@ledgerhq/logs'
9
9
  import { ledgerValidatorAddress } from './bakers.whitelist-default'
10
10
 
11
11
  export type TezosApiBaker = {
@@ -40,7 +40,7 @@ export type TezosBakerFeature = {
40
40
  }
41
41
 
42
42
  export const cache = makeLRUCache(
43
- async (config: TezosCoinConfig): Promise<Baker[]> => {
43
+ async (config: TezosCoinConfig, logger: Logger): Promise<Baker[]> => {
44
44
  const bakers: Baker[] = []
45
45
  const TEZOS_API_BASE_URL = config.baker.url
46
46
  const { data } = await network<TezosApiBaker[]>({
@@ -48,7 +48,7 @@ export const cache = makeLRUCache(
48
48
  })
49
49
 
50
50
  if (data && Array.isArray(data)) {
51
- log('tezos/bakers', 'found ' + data.length + ' bakers')
51
+ logger('tezos/bakers', 'found ' + data.length + ' bakers')
52
52
  data
53
53
  .filter((raw) => raw.status === 'active' && raw.delegation.enabled)
54
54
  .forEach((raw) => {
@@ -64,7 +64,7 @@ export const cache = makeLRUCache(
64
64
  const ledgerBakerIndex = bakers.findIndex((baker) => baker.address === ledgerValidatorAddress)
65
65
  const [ledgerBaker] = bakers.splice(ledgerBakerIndex, 1)
66
66
 
67
- log('tezos/bakers', 'loaded ' + bakers.length + ' bakers')
67
+ logger('tezos/bakers', 'loaded ' + bakers.length + ' bakers')
68
68
  return [ledgerBaker, ...bakers]
69
69
  },
70
70
  (config: TezosCoinConfig) => config.baker.url
@@ -72,8 +72,9 @@ export const cache = makeLRUCache(
72
72
 
73
73
  let _lastBakers: Baker[] = []
74
74
 
75
- export const fetchAllBakers = async (config: TezosCoinConfig): Promise<Baker[]> => {
76
- const allBakers = await cache.force(config)
75
+ export const fetchAllBakers = async (context: TezosContext): Promise<Baker[]> => {
76
+ const config = await context.config()
77
+ const allBakers = await cache.force(config, context.logger)
77
78
  _lastBakers = allBakers
78
79
  return allBakers
79
80
  }
@@ -86,10 +87,11 @@ export const listBakersWithDefault = (whitelistAddresses: string[]): Baker[] =>
86
87
  }
87
88
 
88
89
  export const listBakers = async (
89
- config: TezosCoinConfig,
90
+ context: TezosContext,
90
91
  whitelistAddresses: string[]
91
92
  ): Promise<Baker[]> => {
92
- _lastBakers = await cache(config)
93
+ const config = await context.config()
94
+ _lastBakers = await cache(config, context.logger)
93
95
  return whitelist(_lastBakers, whitelistAddresses)
94
96
  }
95
97
 
@@ -97,12 +99,12 @@ export function getBakerSync(addr: string): Baker | undefined {
97
99
  return _lastBakers.find((baker) => baker.address === addr)
98
100
  }
99
101
 
100
- export async function loadBaker(config: TezosCoinConfig, addr: string): Promise<Baker | undefined> {
102
+ export async function loadBaker(context: TezosContext, addr: string): Promise<Baker | undefined> {
101
103
  const cacheBaker = getBakerSync(addr)
102
104
  if (cacheBaker) return cacheBaker
103
- const bakers = await cache(config)
104
- const baker = bakers.find((baker) => baker.address === addr)
105
- return baker
105
+ const config = await context.config()
106
+ const bakers = await cache(config, context.logger)
107
+ return bakers.find((baker) => baker.address === addr)
106
108
  }
107
109
 
108
110
  export const asBaker = (data: TezosApiBaker): Baker | undefined => {
@@ -1,7 +1,7 @@
1
1
  // SPDX-FileCopyrightText: © 2026 LEDGER SAS
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import type { TezosCoinConfig } from '../config'
4
+ import type { TezosCoinConfig, TezosContext } from '../config'
5
5
  // Import fetchBlockTransactions / fetchBlockTokenTransfers / fetchBlockDelegations through the index re-export
6
6
  // so that network/index.ts line 4 is exercised and its coverage is tracked.
7
7
  import type {
@@ -18,7 +18,6 @@ import type { DeepPartial, DeepPartialReturn } from '@ledgerhq/coin-module-frame
18
18
  // tzkt.ts imports `network` from "@ledgerhq/live-network" (the package root, not the /network
19
19
  // subpath). We mock the same path so Jest's module registry intercepts those calls.
20
20
  import network from '@ledgerhq/live-network'
21
- import { log } from '@ledgerhq/logs'
22
21
  import { mockConfig } from '../test/config'
23
22
  import { createTzktApi } from './tzkt'
24
23
  import {
@@ -30,9 +29,10 @@ import {
30
29
  } from '.'
31
30
 
32
31
  jest.mock('@ledgerhq/live-network')
33
- jest.mock('@ledgerhq/logs')
34
32
  const mockedNetwork = jest.mocked<DeepPartialReturn<typeof network>>(network)
35
- const mockedLog = jest.mocked(log)
33
+ const mockLogger = jest.fn()
34
+ const config: TezosCoinConfig = mockConfig
35
+ const context: TezosContext = { config: async () => config, logger: mockLogger }
36
36
 
37
37
  // The tzkt module is NOT mocked here. `createTzktApi(config)` returns a plain object whose methods
38
38
  // close over `config.explorer.url` and delegate to the mocked `network`. Each method also calls its
@@ -43,7 +43,6 @@ const mockedLog = jest.mocked(log)
43
43
  // The paginated helpers (`fetchAllTransactions`, `fetchBlock*`) are top-level functions that build
44
44
  // their OWN fresh `createTzktApi(config)` instance internally, so spying on `api` cannot intercept
45
45
  // them. Those tests therefore drive the mocked `network` directly.
46
- const config: TezosCoinConfig = mockConfig
47
46
  const api = createTzktApi(config)
48
47
 
49
48
  // ---------------------------------------------------------------------------
@@ -808,7 +807,7 @@ describe('tzkt network API', () => {
808
807
  it('returns empty when the first page is empty', async () => {
809
808
  mockedNetwork.mockReturnValue(networkResponse([]))
810
809
 
811
- const result = await fetchAllTransactions(config, 'tz1acc')
810
+ const result = await fetchAllTransactions(context, 'tz1acc')
812
811
 
813
812
  expect(result).toEqual([])
814
813
  })
@@ -819,7 +818,7 @@ describe('tzkt network API', () => {
819
818
  .mockReturnValueOnce(networkResponse([makeOperation(2)]))
820
819
  .mockReturnValueOnce(networkResponse([]))
821
820
 
822
- const result = await fetchAllTransactions(config, 'tz1acc')
821
+ const result = await fetchAllTransactions(context, 'tz1acc')
823
822
 
824
823
  expect(result).toEqual([makeOperation(1), makeOperation(2)])
825
824
  expect(mockedNetwork).toHaveBeenCalledTimes(3)
@@ -840,10 +839,10 @@ describe('tzkt network API', () => {
840
839
  const opMissingId: DeepPartial<APIOperation> = { ...makeOperation(1), id: 0 }
841
840
  mockedNetwork.mockReturnValue(networkResponse([opMissingId]))
842
841
 
843
- const result = await fetchAllTransactions(config, 'tz1acc')
842
+ const result = await fetchAllTransactions(context, 'tz1acc')
844
843
 
845
844
  expect(result).toEqual([opMissingId])
846
- expect(mockedLog).toHaveBeenCalledWith('tezos', 'id missing!')
845
+ expect(mockLogger).toHaveBeenCalledWith('tezos', 'id missing!')
847
846
  })
848
847
 
849
848
  it('stops after maxTxQuery iterations', async () => {
@@ -852,7 +851,7 @@ describe('tzkt network API', () => {
852
851
  let nextId = 0
853
852
  mockedNetwork.mockImplementation(() => networkResponse([makeOperation(++nextId)]))
854
853
 
855
- const result = await fetchAllTransactions(config, 'tz1acc')
854
+ const result = await fetchAllTransactions(context, 'tz1acc')
856
855
 
857
856
  expect(mockedNetwork).toHaveBeenCalledTimes(mockConfig.explorer.maxTxQuery)
858
857
  expect(result).toHaveLength(mockConfig.explorer.maxTxQuery)
@@ -992,7 +991,7 @@ describe('tzkt network API', () => {
992
991
  it('returns an empty array when the first page is empty', async () => {
993
992
  mockedNetwork.mockReturnValue(networkResponse([]))
994
993
 
995
- const result = await fetchBlockTransactions(config, 100)
994
+ const result = await fetchBlockTransactions(context, 100)
996
995
 
997
996
  expect(result).toEqual([])
998
997
  })
@@ -1001,7 +1000,7 @@ describe('tzkt network API', () => {
1001
1000
  const txs = [makeTxItem(1), makeTxItem(2)]
1002
1001
  mockedNetwork.mockReturnValue(networkResponse(txs))
1003
1002
 
1004
- const result = await fetchBlockTransactions(config, 100)
1003
+ const result = await fetchBlockTransactions(context, 100)
1005
1004
 
1006
1005
  expect(result).toEqual(txs)
1007
1006
  })
@@ -1014,7 +1013,7 @@ describe('tzkt network API', () => {
1014
1013
  .mockReturnValueOnce(networkResponse(fullPage))
1015
1014
  .mockReturnValueOnce(networkResponse(lastPage))
1016
1015
 
1017
- const result = await fetchBlockTransactions(config, 100)
1016
+ const result = await fetchBlockTransactions(context, 100)
1018
1017
 
1019
1018
  expect(result).toHaveLength(1002)
1020
1019
  expect(mockedNetwork).toHaveBeenCalledTimes(2)
@@ -1031,11 +1030,11 @@ describe('tzkt network API', () => {
1031
1030
  const fullPage = Array.from({ length: 1000 }, (_, i) => makeTxItem(i + 1))
1032
1031
  mockedNetwork.mockReturnValue(networkResponse(fullPage))
1033
1032
 
1034
- const result = await fetchBlockTransactions(config, 100)
1033
+ const result = await fetchBlockTransactions(context, 100)
1035
1034
 
1036
1035
  expect(mockedNetwork).toHaveBeenCalledTimes(100)
1037
1036
  expect(result).toHaveLength(100_000)
1038
- expect(mockedLog).toHaveBeenCalledWith(
1037
+ expect(mockLogger).toHaveBeenCalledWith(
1039
1038
  'tezos',
1040
1039
  expect.stringContaining('fetchBlockTransactions: maxTxQuery limit reached at level 100')
1041
1040
  )
@@ -1051,7 +1050,7 @@ describe('tzkt network API', () => {
1051
1050
  it('returns an empty array when the first page is empty', async () => {
1052
1051
  mockedNetwork.mockReturnValue(networkResponse([]))
1053
1052
 
1054
- const result = await fetchBlockTokenTransfers(config, 100)
1053
+ const result = await fetchBlockTokenTransfers(context, 100)
1055
1054
 
1056
1055
  expect(result).toEqual([])
1057
1056
  })
@@ -1060,7 +1059,7 @@ describe('tzkt network API', () => {
1060
1059
  const items = [makeTokenItem(1), makeTokenItem(2)]
1061
1060
  mockedNetwork.mockReturnValue(networkResponse(items))
1062
1061
 
1063
- const result = await fetchBlockTokenTransfers(config, 100)
1062
+ const result = await fetchBlockTokenTransfers(context, 100)
1064
1063
 
1065
1064
  expect(result).toEqual(items)
1066
1065
  })
@@ -1073,7 +1072,7 @@ describe('tzkt network API', () => {
1073
1072
  .mockReturnValueOnce(networkResponse(fullPage))
1074
1073
  .mockReturnValueOnce(networkResponse(lastPage))
1075
1074
 
1076
- const result = await fetchBlockTokenTransfers(config, 100)
1075
+ const result = await fetchBlockTokenTransfers(context, 100)
1077
1076
 
1078
1077
  expect(result).toHaveLength(1001)
1079
1078
  expect(mockedNetwork).toHaveBeenCalledTimes(2)
@@ -1088,11 +1087,11 @@ describe('tzkt network API', () => {
1088
1087
  const fullPage = Array.from({ length: 1000 }, (_, i) => makeTokenItem(i + 1))
1089
1088
  mockedNetwork.mockReturnValue(networkResponse(fullPage))
1090
1089
 
1091
- const result = await fetchBlockTokenTransfers(config, 100)
1090
+ const result = await fetchBlockTokenTransfers(context, 100)
1092
1091
 
1093
1092
  expect(mockedNetwork).toHaveBeenCalledTimes(100)
1094
1093
  expect(result).toHaveLength(100_000)
1095
- expect(mockedLog).toHaveBeenCalledWith(
1094
+ expect(mockLogger).toHaveBeenCalledWith(
1096
1095
  'tezos',
1097
1096
  expect.stringContaining('fetchBlockTokenTransfers: maxTxQuery limit reached at level 100')
1098
1097
  )
@@ -1147,7 +1146,7 @@ describe('tzkt network API', () => {
1147
1146
  it('returns an empty array when the first page is empty', async () => {
1148
1147
  mockedNetwork.mockReturnValue(networkResponse([]))
1149
1148
 
1150
- const result = await fetchBlockDelegations(config, 100)
1149
+ const result = await fetchBlockDelegations(context, 100)
1151
1150
 
1152
1151
  expect(result).toEqual([])
1153
1152
  })
@@ -1156,7 +1155,7 @@ describe('tzkt network API', () => {
1156
1155
  const items = [makeDelegationItem(1), makeDelegationItem(2)]
1157
1156
  mockedNetwork.mockReturnValue(networkResponse(items))
1158
1157
 
1159
- const result = await fetchBlockDelegations(config, 100)
1158
+ const result = await fetchBlockDelegations(context, 100)
1160
1159
 
1161
1160
  expect(result).toEqual(items)
1162
1161
  })
@@ -1169,7 +1168,7 @@ describe('tzkt network API', () => {
1169
1168
  .mockReturnValueOnce(networkResponse(fullPage))
1170
1169
  .mockReturnValueOnce(networkResponse(lastPage))
1171
1170
 
1172
- const result = await fetchBlockDelegations(config, 100)
1171
+ const result = await fetchBlockDelegations(context, 100)
1173
1172
 
1174
1173
  expect(result).toHaveLength(1001)
1175
1174
  expect(mockedNetwork).toHaveBeenCalledTimes(2)
@@ -1184,11 +1183,11 @@ describe('tzkt network API', () => {
1184
1183
  const fullPage = Array.from({ length: 1000 }, (_, i) => makeDelegationItem(i + 1))
1185
1184
  mockedNetwork.mockReturnValue(networkResponse(fullPage))
1186
1185
 
1187
- const result = await fetchBlockDelegations(config, 100)
1186
+ const result = await fetchBlockDelegations(context, 100)
1188
1187
 
1189
1188
  expect(mockedNetwork).toHaveBeenCalledTimes(100)
1190
1189
  expect(result).toHaveLength(100_000)
1191
- expect(mockedLog).toHaveBeenCalledWith(
1190
+ expect(mockLogger).toHaveBeenCalledWith(
1192
1191
  'tezos',
1193
1192
  expect.stringContaining('fetchBlockDelegations: maxTxQuery limit reached at level 100')
1194
1193
  )
@@ -1243,7 +1242,7 @@ describe('tzkt network API', () => {
1243
1242
  it('returns an empty array when the first page is empty', async () => {
1244
1243
  mockedNetwork.mockReturnValue(networkResponse([]))
1245
1244
 
1246
- const result = await fetchBlockStaking(config, 100)
1245
+ const result = await fetchBlockStaking(context, 100)
1247
1246
 
1248
1247
  expect(result).toEqual([])
1249
1248
  })
@@ -1252,7 +1251,7 @@ describe('tzkt network API', () => {
1252
1251
  const items = [makeStakingItem(1), makeStakingItem(2)]
1253
1252
  mockedNetwork.mockReturnValue(networkResponse(items))
1254
1253
 
1255
- const result = await fetchBlockStaking(config, 100)
1254
+ const result = await fetchBlockStaking(context, 100)
1256
1255
 
1257
1256
  expect(result).toEqual(items)
1258
1257
  })
@@ -1265,7 +1264,7 @@ describe('tzkt network API', () => {
1265
1264
  .mockReturnValueOnce(networkResponse(fullPage))
1266
1265
  .mockReturnValueOnce(networkResponse(lastPage))
1267
1266
 
1268
- const result = await fetchBlockStaking(config, 100)
1267
+ const result = await fetchBlockStaking(context, 100)
1269
1268
 
1270
1269
  expect(result).toHaveLength(1001)
1271
1270
  expect(mockedNetwork).toHaveBeenCalledTimes(2)
@@ -1280,11 +1279,11 @@ describe('tzkt network API', () => {
1280
1279
  const fullPage = Array.from({ length: 1000 }, (_, i) => makeStakingItem(i + 1))
1281
1280
  mockedNetwork.mockReturnValue(networkResponse(fullPage))
1282
1281
 
1283
- const result = await fetchBlockStaking(config, 100)
1282
+ const result = await fetchBlockStaking(context, 100)
1284
1283
 
1285
1284
  expect(mockedNetwork).toHaveBeenCalledTimes(100)
1286
1285
  expect(result).toHaveLength(100_000)
1287
- expect(mockedLog).toHaveBeenCalledWith(
1286
+ expect(mockLogger).toHaveBeenCalledWith(
1288
1287
  'tezos',
1289
1288
  expect.stringContaining('fetchBlockStaking: maxTxQuery limit reached at level 100')
1290
1289
  )
@@ -1,7 +1,7 @@
1
1
  // SPDX-FileCopyrightText: © 2026 LEDGER SAS
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import type { TezosCoinConfig } from '../config'
4
+ import type { TezosCoinConfig, TezosContext } from '../config'
5
5
  import type {
6
6
  APIAccount,
7
7
  APIBlock,
@@ -19,7 +19,6 @@ import type {
19
19
  } from './types'
20
20
  import URL from 'url'
21
21
  import network from '@ledgerhq/live-network'
22
- import { log } from '@ledgerhq/logs'
23
22
 
24
23
  /** TzKT hard-caps `limit` at 10 000; we use a safer page size to stay well under that. */
25
24
  const BLOCK_PAGE_SIZE = 1000
@@ -466,10 +465,11 @@ export type TzktApi = ReturnType<typeof createTzktApi>
466
465
 
467
466
  // TODO this has same purpose as api/listOperations
468
467
  export const fetchAllTransactions = async (
469
- config: TezosCoinConfig,
468
+ context: TezosContext,
470
469
  address: string,
471
470
  lastId?: number
472
471
  ): Promise<APIOperation[]> => {
472
+ const config = await context.config()
473
473
  const api = createTzktApi(config)
474
474
  let ops: APIOperation[] = []
475
475
  let maxIteration = config.explorer.maxTxQuery
@@ -485,7 +485,7 @@ export const fetchAllTransactions = async (
485
485
  if (!last) return ops
486
486
  lastId = last.id
487
487
  if (!lastId) {
488
- log('tezos', 'id missing!')
488
+ context.logger('tezos', 'id missing!')
489
489
  return ops
490
490
  }
491
491
  } while (--maxIteration)
@@ -500,11 +500,13 @@ export const fetchAllTransactions = async (
500
500
  * A safety cap (`maxTxQuery`) prevents infinite loops on pathological responses.
501
501
  */
502
502
  async function fetchBlockPaginated<T extends { id: number }>(
503
- config: TezosCoinConfig,
504
- pageFn: (level: number, cursor?: number) => Promise<T[]>,
503
+ context: TezosContext,
504
+ pageFnFactory: (config: TezosCoinConfig) => (level: number, cursor?: number) => Promise<T[]>,
505
505
  level: number,
506
506
  label: string
507
507
  ): Promise<T[]> {
508
+ const config = await context.config()
509
+ const pageFn = pageFnFactory(config)
508
510
  const items: T[] = []
509
511
  let cursor: number | undefined
510
512
  let maxIteration = config.explorer.maxTxQuery
@@ -516,45 +518,58 @@ async function fetchBlockPaginated<T extends { id: number }>(
516
518
  cursor = page.at(-1)!.id
517
519
  } while (--maxIteration > 0)
518
520
  if (maxIteration === 0) {
519
- log('tezos', `${label}: maxTxQuery limit reached at level ${level}, result may be incomplete`)
521
+ context.logger(
522
+ 'tezos',
523
+ `${label}: maxTxQuery limit reached at level ${level}, result may be incomplete`
524
+ )
520
525
  }
521
526
  return items
522
527
  }
523
528
 
524
- export const fetchBlockTransactions = (config: TezosCoinConfig, level: number) =>
529
+ export const fetchBlockTransactions = (context: TezosContext, level: number) =>
525
530
  fetchBlockPaginated(
526
- config,
527
- createTzktApi(config).getBlockTransactionsPage,
531
+ context,
532
+ (config) => createTzktApi(config).getBlockTransactionsPage,
528
533
  level,
529
534
  'fetchBlockTransactions'
530
535
  )
531
536
 
532
- export const fetchBlockTokenTransfers = (config: TezosCoinConfig, level: number) =>
537
+ export const fetchBlockTokenTransfers = (context: TezosContext, level: number) =>
533
538
  fetchBlockPaginated(
534
- config,
535
- createTzktApi(config).getBlockTokenTransfersPage,
539
+ context,
540
+ (config) => createTzktApi(config).getBlockTokenTransfersPage,
536
541
  level,
537
542
  'fetchBlockTokenTransfers'
538
543
  )
539
544
 
540
- export const fetchBlockDelegations = (config: TezosCoinConfig, level: number) =>
545
+ export const fetchBlockDelegations = (context: TezosContext, level: number) =>
541
546
  fetchBlockPaginated(
542
- config,
543
- createTzktApi(config).getBlockDelegationsPage,
547
+ context,
548
+ (config) => createTzktApi(config).getBlockDelegationsPage,
544
549
  level,
545
550
  'fetchBlockDelegations'
546
551
  )
547
552
 
548
- export const fetchBlockStaking = (config: TezosCoinConfig, level: number) =>
549
- fetchBlockPaginated(config, createTzktApi(config).getBlockStakingPage, level, 'fetchBlockStaking')
553
+ export const fetchBlockStaking = (context: TezosContext, level: number) =>
554
+ fetchBlockPaginated(
555
+ context,
556
+ (config) => createTzktApi(config).getBlockStakingPage,
557
+ level,
558
+ 'fetchBlockStaking'
559
+ )
550
560
 
551
- export const fetchBlockOriginations = (config: TezosCoinConfig, level: number) =>
561
+ export const fetchBlockOriginations = (context: TezosContext, level: number) =>
552
562
  fetchBlockPaginated(
553
- config,
554
- createTzktApi(config).getBlockOriginationsPage,
563
+ context,
564
+ (config) => createTzktApi(config).getBlockOriginationsPage,
555
565
  level,
556
566
  'fetchBlockOriginations'
557
567
  )
558
568
 
559
- export const fetchBlockReveals = (config: TezosCoinConfig, level: number) =>
560
- fetchBlockPaginated(config, createTzktApi(config).getBlockRevealsPage, level, 'fetchBlockReveals')
569
+ export const fetchBlockReveals = (context: TezosContext, level: number) =>
570
+ fetchBlockPaginated(
571
+ context,
572
+ (config) => createTzktApi(config).getBlockRevealsPage,
573
+ level,
574
+ 'fetchBlockReveals'
575
+ )
@@ -59,3 +59,10 @@ export class TezosStakeBlockedByPendingUnstake extends Error {
59
59
  this.name = 'TezosStakeBlockedByPendingUnstake'
60
60
  }
61
61
  }
62
+
63
+ export class TezosStakeAmountTooSmall extends Error {
64
+ constructor() {
65
+ super('Stake amount too small to mint any staking pseudotoken')
66
+ this.name = 'TezosStakeAmountTooSmall'
67
+ }
68
+ }
package/src/utils.ts CHANGED
@@ -20,6 +20,38 @@ export const DUST_MARGIN_MUTEZ = 500
20
20
  // Tezos rejects `empty_implicit_delegated_contract`; stake-max leaves this buffer.
21
21
  export const STAKE_USE_ALL_RESERVE_MUTEZ = 10_000n
22
22
 
23
+ /**
24
+ * Amount pre-estimated for a stake whose real amount isn't known yet (stake-max), and the floor
25
+ * a resolved stake-max must clear: only the probe is simulated, never the resolved amount.
26
+ *
27
+ * Octez rejects a stake that mints no pseudotoken (`stake_amount_too_small`): issued
28
+ * pseudotokens are `floor(amount * pseudotokens / staked_tez)`, so 1 mutez fails for any
29
+ * delegate whose stakers accrued rewards — a 2 mutez floor across delegates in 2026-09.
30
+ * https://gitlab.com/tezos/tezos/-/commit/b8339f8893e1da45e73ea5c4b1b82a0dfefb503c
31
+ */
32
+ export const STAKE_ESTIMATION_PROBE_MUTEZ = 1_000n
33
+
34
+ /**
35
+ * Suffixes of the protocol error ids `validateIntent` turns into field errors. Estimation reports
36
+ * these as `taquitoError` and the api returns them, so the mapping gets its turn instead of the
37
+ * raw id reaching the user. Any other protocol error keeps propagating — callers retry on it.
38
+ */
39
+ export const MAPPED_TAQUITO_ERRORS = [
40
+ 'balance_too_low',
41
+ 'cannot_stake_with_unfinalizable_unstake_requests_to_another_delegate',
42
+ 'contract.must_be_delegated_to_stake',
43
+ 'delegate.unchanged',
44
+ 'empty_implicit_contract',
45
+ 'script_rejected',
46
+ 'stake_amount_too_small',
47
+ 'staking.too_much_unstaked',
48
+ 'subtraction_underflow',
49
+ ]
50
+
51
+ export function isMappedTaquitoError(id: string): boolean {
52
+ return MAPPED_TAQUITO_ERRORS.some((mapped) => id.endsWith(mapped))
53
+ }
54
+
23
55
  /** Stake "Use Max": staked and unstaked-frozen funds count toward `balance` but aren't (re-)stakeable until finalized, so subtract both. */
24
56
  export function computeMaxStakeAmount(
25
57
  balance: bigint,