@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
@@ -1,9 +1,8 @@
1
1
  // SPDX-FileCopyrightText: © 2026 LEDGER SAS
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import type { TezosConfig } from '../config'
4
+ import type { TezosContext } from '../config'
5
5
  import type { getTezosToolkit } from './tezosToolkit'
6
- import { log } from '@ledgerhq/logs'
7
6
  import { getRevealFee, getRevealGasLimit } from '@taquito/taquito'
8
7
 
9
8
  type RevealLimits = { fee: number; gasLimit: number; storageLimit: number }
@@ -14,19 +13,24 @@ type RevealLimits = { fee: number; gasLimit: number; storageLimit: number }
14
13
  * (some addresses error with "inconsistent_hash").
15
14
  */
16
15
  export async function estimateRevealLimits(
16
+ context: TezosContext,
17
17
  tezosToolkit: ReturnType<typeof getTezosToolkit>,
18
- address: string,
19
- feesConfig: TezosConfig['fees']
18
+ address: string
20
19
  ): Promise<RevealLimits> {
20
+ const { fees: feesConfig } = await context.config()
21
21
  type RevealEstimate = { gasLimit?: number; storageLimit?: number; suggestedFeeMutez?: number }
22
22
  let revealEstimate: RevealEstimate | undefined
23
23
  try {
24
24
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
25
25
  revealEstimate = (await tezosToolkit.estimate.reveal()) as RevealEstimate | undefined
26
26
  } catch (error) {
27
- log('estimate-error', 'error estimating reveal fees, trying to use getRevealGasLimit', {
28
- error,
29
- })
27
+ context.logger(
28
+ 'estimate-error',
29
+ 'error estimating reveal fees, trying to use getRevealGasLimit',
30
+ {
31
+ error,
32
+ }
33
+ )
30
34
  revealEstimate = {
31
35
  gasLimit: getRevealGasLimit(address),
32
36
  suggestedFeeMutez: getRevealFee(address),
@@ -3,7 +3,6 @@
3
3
 
4
4
  import type { TezosContext } from '../config'
5
5
  import type { Balance } from '@ledgerhq/coin-module-framework/api/index'
6
- import { log } from '@ledgerhq/logs'
7
6
  import { hasManagerKey } from '../network/types'
8
7
  import { createTzktApi } from '../network/tzkt'
9
8
  import { partitionNativeBalance } from '../utils'
@@ -60,14 +59,18 @@ export async function getBalance(context: TezosContext, address: string): Promis
60
59
  const normalized = BigInt(apiAccount.balance)
61
60
 
62
61
  const unstakeRequests = await fetchUnstakeRequests(config, address, apiAccount).catch((error) => {
63
- log('coin:tezos', 'getBalance: fetchUnstakeRequests failed; degrading stakes to []', {
64
- error,
65
- address,
66
- })
62
+ context.logger(
63
+ 'coin:tezos',
64
+ 'getBalance: fetchUnstakeRequests failed; degrading stakes to []',
65
+ {
66
+ error,
67
+ address,
68
+ }
69
+ )
67
70
  return []
68
71
  })
69
72
 
70
- const stakes = buildStakesForAccount(address, apiAccount, unstakeRequests)
73
+ const stakes = buildStakesForAccount(context.logger, address, apiAccount, unstakeRequests)
71
74
  const stakedBalance = BigInt(apiAccount.stakedBalance ?? 0)
72
75
  const unstakedBalance = BigInt(apiAccount.unstakedBalance ?? 0)
73
76
  const { locked } = partitionNativeBalance(normalized, stakedBalance, unstakedBalance)
@@ -534,12 +534,12 @@ export async function getBlock(context: TezosContext, height: number): Promise<B
534
534
  ] = await Promise.all([
535
535
  tzkt.getBlockByLevel(height),
536
536
  tzkt.getBlockByLevel(height - 1),
537
- fetchBlockTransactions(config, height),
538
- fetchBlockTokenTransfers(config, height),
539
- fetchBlockDelegations(config, height),
540
- fetchBlockStaking(config, height),
541
- fetchBlockOriginations(config, height),
542
- fetchBlockReveals(config, height),
537
+ fetchBlockTransactions(context, height),
538
+ fetchBlockTokenTransfers(context, height),
539
+ fetchBlockDelegations(context, height),
540
+ fetchBlockStaking(context, height),
541
+ fetchBlockOriginations(context, height),
542
+ fetchBlockReveals(context, height),
543
543
  ])
544
544
 
545
545
  // Token transfers triggered by originations from other blocks carry an
@@ -4,7 +4,7 @@
4
4
  import type { TezosCoinConfig, TezosContext } from '../config'
5
5
  import type { APIAccount, APIManagerAccount, APIUnstakeRequest } from '../network/types'
6
6
  import type { Cursor, Page, Stake } from '@ledgerhq/coin-module-framework/api/types'
7
- import { log } from '@ledgerhq/logs'
7
+ import type { Logger } from '@ledgerhq/coin-module-framework/config'
8
8
  import { hasManagerKey } from '../network/types'
9
9
  import { createTzktApi } from '../network/tzkt'
10
10
  import { partitionNativeBalance } from '../utils'
@@ -28,17 +28,21 @@ export function fetchUnstakeRequests(
28
28
  : Promise.resolve([])
29
29
  }
30
30
 
31
- function unstakeRequestToStake(address: string, req: APIUnstakeRequest): Stake | null {
31
+ function unstakeRequestToStake(
32
+ logger: Logger,
33
+ address: string,
34
+ req: APIUnstakeRequest
35
+ ): Stake | null {
32
36
  // TzKT's status filter is unreliable; never build a position for a finalized request.
33
37
  if (req.status !== 'pending' && req.status !== 'finalizable') {
34
- log('coin:tezos', 'unstakeRequestToStake: dropping non-active unstake request', {
38
+ logger('coin:tezos', 'unstakeRequestToStake: dropping non-active unstake request', {
35
39
  requestId: req.id,
36
40
  status: req.status,
37
41
  })
38
42
  return null
39
43
  }
40
44
  if (req.actualAmount <= 0) {
41
- log('coin:tezos', 'unstakeRequestToStake: dropping non-positive unstake request', {
45
+ logger('coin:tezos', 'unstakeRequestToStake: dropping non-positive unstake request', {
42
46
  requestId: req.id,
43
47
  actualAmount: req.actualAmount,
44
48
  })
@@ -46,7 +50,7 @@ function unstakeRequestToStake(address: string, req: APIUnstakeRequest): Stake |
46
50
  }
47
51
  const createdAt = new Date(req.firstTime)
48
52
  if (!Number.isFinite(createdAt.getTime())) {
49
- log('coin:tezos', 'unstakeRequestToStake: dropping unstake request with invalid firstTime', {
53
+ logger('coin:tezos', 'unstakeRequestToStake: dropping unstake request with invalid firstTime', {
50
54
  requestId: req.id,
51
55
  firstTime: req.firstTime,
52
56
  })
@@ -67,6 +71,7 @@ function unstakeRequestToStake(address: string, req: APIUnstakeRequest): Stake |
67
71
  }
68
72
 
69
73
  export function buildStakesForAccount(
74
+ logger: Logger,
70
75
  address: string,
71
76
  account: APIManagerAccount,
72
77
  unstakeRequests: APIUnstakeRequest[]
@@ -80,7 +85,7 @@ export function buildStakesForAccount(
80
85
 
81
86
  const unstakeStakes: Stake[] = []
82
87
  for (const req of unstakeRequests) {
83
- const stake = unstakeRequestToStake(address, req)
88
+ const stake = unstakeRequestToStake(logger, address, req)
84
89
  if (stake) unstakeStakes.push(stake)
85
90
  }
86
91
 
@@ -93,7 +98,7 @@ export function buildStakesForAccount(
93
98
  unstakedBalance
94
99
  )
95
100
  if (locked < stakedBalance + unstakedBalance) {
96
- log('coin:tezos', 'buildStakesForAccount: balance < staked + unstaked, clamping to 0', {
101
+ logger('coin:tezos', 'buildStakesForAccount: balance < staked + unstaked, clamping to 0', {
97
102
  address,
98
103
  balance: balance.toString(),
99
104
  stakedBalance: stakedBalance.toString(),
@@ -137,5 +142,5 @@ export async function getStakes(
137
142
  const accountInfo = await createTzktApi(config).getAccountByAddress(address)
138
143
  if (!hasManagerKey(accountInfo)) return { items: [] }
139
144
  const unstakeRequests = await fetchUnstakeRequests(config, address, accountInfo)
140
- return { items: buildStakesForAccount(address, accountInfo, unstakeRequests) }
145
+ return { items: buildStakesForAccount(context.logger, address, accountInfo, unstakeRequests) }
141
146
  }
@@ -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, TezosContext } from '../config'
4
+ import type { TezosContext } from '../config'
5
5
  import type { AccountsGetOperationsOptions } from '../network/types'
6
6
  import type {
7
7
  APIDelegationType,
@@ -14,7 +14,7 @@ import type {
14
14
  TokenTransfersGetOptions,
15
15
  } from '../network/types'
16
16
  import type { Operation } from '@ledgerhq/coin-module-framework/api/types'
17
- import { log } from '@ledgerhq/logs'
17
+ import type { Logger } from '@ledgerhq/coin-module-framework/config'
18
18
  import { STAKING_ACTION_TO_OP_TYPE } from '../constants'
19
19
  import { createTzktApi } from '../network'
20
20
  import {
@@ -69,6 +69,7 @@ function maxLevel<T extends { level: number }>(items: T[]): number | undefined {
69
69
 
70
70
  /** When the API returned a full page, the trailing block level may be incomplete — drop it. */
71
71
  function trimPartialLastLevel<T extends { level: number; id: number }>(
72
+ logger: Logger,
72
73
  items: T[],
73
74
  full: boolean
74
75
  ): { trimmed: T[]; intraLevelLastId?: number } {
@@ -78,7 +79,7 @@ function trimPartialLastLevel<T extends { level: number; id: number }>(
78
79
  const lastLevel = last.level
79
80
  const filtered = items.filter((op) => op.level !== lastLevel)
80
81
  if (filtered.length === 0) {
81
- log('coin:tezos', 'listOperations: full page single level — keeping all rows', {
82
+ logger('coin:tezos', 'listOperations: full page single level — keeping all rows', {
82
83
  lastLevel,
83
84
  count: items.length,
84
85
  })
@@ -251,6 +252,7 @@ function keepNativeOp(op: APIOperation, address: string): op is ConvertibleOpera
251
252
  }
252
253
 
253
254
  function convertNativeOps(
255
+ logger: Logger,
254
256
  ops: APIOperation[],
255
257
  address: string,
256
258
  stakingBlockHashes: Map<number, string>
@@ -282,7 +284,7 @@ function convertNativeOps(
282
284
  return ops
283
285
  .filter((op): op is ConvertibleOperation => keepNativeOp(op, address))
284
286
  .map((op) => {
285
- const converted = convertOperation(address, op, stakingBlockHashes)
287
+ const converted = convertOperation(logger, address, op, stakingBlockHashes)
286
288
  const extraFee = internalFeesByHash.get(converted.tx.hash)
287
289
  if (extraFee) {
288
290
  // Add internal sub-tx fees to the parent operation, then clear to avoid
@@ -368,8 +370,8 @@ export async function listOperations(
368
370
  const nativePage = clampPage(nativeOpsRaw, limit)
369
371
  const tokenPage = clampPage(tokenTransfersRaw, limit)
370
372
 
371
- const nativeTrimmed = trimPartialLastLevel(nativePage.sliced, nativePage.full)
372
- const tokenTrimmed = trimPartialLastLevel(tokenPage.sliced, tokenPage.full)
373
+ const nativeTrimmed = trimPartialLastLevel(context.logger, nativePage.sliced, nativePage.full)
374
+ const tokenTrimmed = trimPartialLastLevel(context.logger, tokenPage.sliced, tokenPage.full)
373
375
 
374
376
  // when both streams are exhausted (neither hit the page limit),
375
377
  // we already have every op — alignment would silently discard ops
@@ -388,7 +390,7 @@ export async function listOperations(
388
390
  ? tokenTrimmed.trimmed
389
391
  : alignToBoundary(tokenTrimmed.trimmed, boundary, sort)
390
392
 
391
- const stakingBlockHashes = await fetchMissingStakingBlockHashes(config, nativeAligned)
393
+ const stakingBlockHashes = await fetchMissingStakingBlockHashes(context, nativeAligned)
392
394
 
393
395
  const parentMap = buildParentMap(nativeAligned)
394
396
 
@@ -401,7 +403,12 @@ export async function listOperations(
401
403
  tokenTrimmed.intraLevelLastId
402
404
  )
403
405
 
404
- const filteredNativeOps = convertNativeOps(nativeAligned, address, stakingBlockHashes)
406
+ const filteredNativeOps = convertNativeOps(
407
+ context.logger,
408
+ nativeAligned,
409
+ address,
410
+ stakingBlockHashes
411
+ )
405
412
  const tokenConverted = convertTokenOps(tokenAligned, parentMap, address)
406
413
 
407
414
  const sortedOperations = [...filteredNativeOps, ...tokenConverted].sort(
@@ -424,7 +431,7 @@ type ConvertibleOperation =
424
431
  * failures are swallowed: callers fall back to `""` for levels not in the map.
425
432
  */
426
433
  async function fetchMissingStakingBlockHashes(
427
- config: TezosCoinConfig,
434
+ context: TezosContext,
428
435
  ops: readonly APIOperation[]
429
436
  ): Promise<Map<number, string>> {
430
437
  const missingLevels = new Set<number>()
@@ -432,10 +439,11 @@ async function fetchMissingStakingBlockHashes(
432
439
  if (isAPIStakingType(op) && !op.block) missingLevels.add(op.level)
433
440
  }
434
441
  if (missingLevels.size === 0) return new Map()
442
+ const config = await context.config()
435
443
  try {
436
444
  return await createTzktApi(config).getBlockHashesByLevels([...missingLevels])
437
445
  } catch (err) {
438
- log('coin:tezos', 'fetchMissingStakingBlockHashes: skipped on fetch error', {
446
+ context.logger('coin:tezos', 'fetchMissingStakingBlockHashes: skipped on fetch error', {
439
447
  reason: String(err),
440
448
  })
441
449
  return new Map()
@@ -487,6 +495,7 @@ function resolveAmount(operation: ConvertibleOperation): bigint {
487
495
  }
488
496
 
489
497
  function resolveNormalizedType(
498
+ logger: Logger,
490
499
  operation: ConvertibleOperation,
491
500
  address: string,
492
501
  targetAddress: string | undefined,
@@ -499,7 +508,7 @@ function resolveNormalizedType(
499
508
  if (isAPIRevealType(operation)) return 'REVEAL'
500
509
  if (isAPIOriginationType(operation)) return operation.contractBalance > 0 ? 'OUT' : 'FEES'
501
510
  if (!isAPITransactionType(operation)) {
502
- log('coin:tezos', '(logic/operations): Unknown operation type, defaulting to OUT')
511
+ logger('coin:tezos', '(logic/operations): Unknown operation type, defaulting to OUT')
503
512
  return 'OUT'
504
513
  }
505
514
  const isOut = operation.sender?.address === address
@@ -529,6 +538,7 @@ function getLedgerOpType(
529
538
  }
530
539
 
531
540
  function convertOperation(
541
+ logger: Logger,
532
542
  address: string,
533
543
  operation: ConvertibleOperation,
534
544
  stakingBlockHashes: Map<number, string>
@@ -544,7 +554,7 @@ function convertOperation(
544
554
  const targetAddress = resolveTargetAddress(operation)
545
555
  // reveal has no meaningful target; staking resolves senders/recipients separately.
546
556
  if (!targetAddress && !isAPIRevealType(operation) && !isAPIStakingType(operation)) {
547
- log('coin:tezos', '(logic/operations): No target address found for operation', operation)
557
+ logger('coin:tezos', '(logic/operations): No target address found for operation', operation)
548
558
  }
549
559
 
550
560
  const { senders, recipients } = isAPIStakingType(operation)
@@ -561,7 +571,7 @@ function convertOperation(
561
571
  BigInt(operation.bakerFee ?? 0) +
562
572
  BigInt(operation.allocationFee ?? 0)
563
573
 
564
- const normalizedType = resolveNormalizedType(operation, address, targetAddress, amount)
574
+ const normalizedType = resolveNormalizedType(logger, operation, address, targetAddress, amount)
565
575
 
566
576
  // Tezos uses "applied" for every success operation (something else=failed )
567
577
  const hasFailed = operation.status && operation.status !== 'applied'
@@ -8,15 +8,17 @@ import {
8
8
  InvalidAddressBecauseDestinationIsAlsoSource,
9
9
  AmountRequired,
10
10
  NotEnoughBalance,
11
+ UnexpectedGetBalanceError,
11
12
  } from '@ledgerhq/coin-module-framework/errors'
12
13
  import {
13
14
  InvalidAddressBecauseAlreadyDelegated,
14
15
  MustDelegateBeforeStaking,
15
16
  NotEnoughBalanceToDelegate,
16
17
  TezosNotEnoughStaked,
18
+ TezosStakeAmountTooSmall,
17
19
  TezosStakeBlockedByPendingUnstake,
18
20
  } from '../types/errors'
19
- import { STAKE_USE_ALL_RESERVE_MUTEZ } from '../utils'
21
+ import { MAPPED_TAQUITO_ERRORS, STAKE_USE_ALL_RESERVE_MUTEZ } from '../utils'
20
22
  import { validateIntent } from './validateIntent'
21
23
 
22
24
  const mockEstimateFees = jest.fn()
@@ -386,6 +388,35 @@ describe('validateIntent', () => {
386
388
  expect(result.amount).toBe(0n)
387
389
  })
388
390
 
391
+ it('should return TezosStakeAmountTooSmall when stake useAllAmount resolves max below the probe', async () => {
392
+ mockGetAccountByAddress.mockResolvedValue(
393
+ makeUserAccount({
394
+ delegate: { alias: 'baker', address: validRecipient, active: true },
395
+ delegationLevel: 1,
396
+ })
397
+ )
398
+ mockEstimateFees.mockResolvedValueOnce({
399
+ fees: 1000n,
400
+ gasLimit: 10000n,
401
+ storageLimit: 0n,
402
+ estimatedFees: 1000n,
403
+ amount: 999n,
404
+ })
405
+
406
+ const result = await validateIntent(context, {
407
+ intentType: 'staking',
408
+ asset: { type: 'native' },
409
+ type: 'stake',
410
+ sender: senderAddress,
411
+ recipient: '',
412
+ amount: 0n,
413
+ useAllAmount: true,
414
+ })
415
+
416
+ expect(result.errors.amount).toBeInstanceOf(TezosStakeAmountTooSmall)
417
+ expect(result.amount).toBe(999n)
418
+ })
419
+
389
420
  it('excludes already-staked funds when stake useAllAmount falls back to balance computation', async () => {
390
421
  mockGetAccountByAddress.mockResolvedValue(
391
422
  makeUserAccount({
@@ -986,6 +1017,65 @@ describe('validateIntent', () => {
986
1017
  expect(result.errors.amount).toBeInstanceOf(MustDelegateBeforeStaking)
987
1018
  })
988
1019
 
1020
+ it('maps stake_amount_too_small to TezosStakeAmountTooSmall', async () => {
1021
+ mockGetAccountByAddress.mockResolvedValue(
1022
+ makeUserAccount({
1023
+ delegate: { alias: 'baker', address: validRecipient, active: true },
1024
+ delegationLevel: 1,
1025
+ })
1026
+ )
1027
+ mockEstimateFees.mockResolvedValue({
1028
+ fees: 0n,
1029
+ gasLimit: 0n,
1030
+ storageLimit: 0n,
1031
+ estimatedFees: 500n,
1032
+ taquitoError: 'proto.025-PsUshuai.block_validation_plugin.stake_amount_too_small',
1033
+ })
1034
+
1035
+ const result = await validateIntent(context, {
1036
+ intentType: 'staking',
1037
+ asset: { type: 'native' },
1038
+ type: 'stake',
1039
+ sender: senderAddress,
1040
+ recipient: '',
1041
+ amount: 1n,
1042
+ })
1043
+
1044
+ expect(result.errors.amount).toBeInstanceOf(TezosStakeAmountTooSmall)
1045
+ })
1046
+
1047
+ it.each(MAPPED_TAQUITO_ERRORS)(
1048
+ 'maps %s, which estimation reports instead of throwing, to a dedicated error',
1049
+ async (id) => {
1050
+ mockGetAccountByAddress.mockResolvedValue(
1051
+ makeUserAccount({
1052
+ delegate: { alias: 'baker', address: validRecipient, active: true },
1053
+ delegationLevel: 1,
1054
+ })
1055
+ )
1056
+ mockEstimateFees.mockResolvedValue({
1057
+ fees: 0n,
1058
+ gasLimit: 0n,
1059
+ storageLimit: 0n,
1060
+ estimatedFees: 500n,
1061
+ taquitoError: `proto.025-PsUshuai.${id}`,
1062
+ })
1063
+
1064
+ const result = await validateIntent(context, {
1065
+ intentType: 'staking',
1066
+ asset: { type: 'native' },
1067
+ type: 'stake',
1068
+ sender: senderAddress,
1069
+ recipient: '',
1070
+ amount: 1000n,
1071
+ })
1072
+
1073
+ const error = result.errors.amount ?? result.errors.recipient
1074
+ expect(error).toBeInstanceOf(Error)
1075
+ expect(error?.name).not.toBe('Error')
1076
+ }
1077
+ )
1078
+
989
1079
  it('maps cannot_stake_with_unfinalizable_unstake_requests_to_another_delegate to TezosStakeBlockedByPendingUnstake for stake', async () => {
990
1080
  mockGetAccountByAddress.mockResolvedValue(
991
1081
  makeUserAccount({
@@ -1163,7 +1253,7 @@ describe('validateIntent', () => {
1163
1253
  expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
1164
1254
  })
1165
1255
 
1166
- it('should set amount to 0n when useAllAmount and token balance row is missing', async () => {
1256
+ it('should set amount to 0n and NotEnoughBalance when useAllAmount and token balance row is missing [LIVE-36768]', async () => {
1167
1257
  const contract = 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU'
1168
1258
  mockGetTokensBalances.mockResolvedValue([])
1169
1259
 
@@ -1179,6 +1269,113 @@ describe('validateIntent', () => {
1179
1269
 
1180
1270
  expect(result.amount).toBe(0n)
1181
1271
  expect(result.totalSpent).toBe(1000n)
1272
+ expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
1273
+ })
1274
+
1275
+ it('send-max on a 0-balance token account blocks with NotEnoughBalance even when parent XTZ account has funds for fees [LIVE-36768]', async () => {
1276
+ const contract = 'KT1XnTn74bagnxYoFErbDH5QRAULUDtpXkmV'
1277
+
1278
+ mockGetAccountByAddress.mockResolvedValue(makeUserAccount({ balance: 5_000_000 }))
1279
+ mockGetTokensBalances.mockResolvedValue([
1280
+ {
1281
+ id: 2,
1282
+ account: { address: senderAddress },
1283
+ token: {
1284
+ id: 2,
1285
+ contract: { address: contract },
1286
+ tokenId: '0',
1287
+ standard: 'fa2' as const,
1288
+ metadata: { symbol: 'USDt', decimals: '6' },
1289
+ },
1290
+ balance: '0',
1291
+ transfersCount: 0,
1292
+ firstLevel: 0,
1293
+ firstTime: '',
1294
+ lastLevel: 0,
1295
+ lastTime: '',
1296
+ },
1297
+ ])
1298
+
1299
+ const result = await validateIntent(context, {
1300
+ intentType: 'transaction',
1301
+ asset: { type: 'token', assetReference: `${contract}:0` },
1302
+ type: 'send',
1303
+ sender: senderAddress,
1304
+ recipient: validRecipient,
1305
+ amount: 0n,
1306
+ useAllAmount: true,
1307
+ })
1308
+
1309
+ expect(result.amount).toBe(0n)
1310
+ expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
1311
+ })
1312
+
1313
+ it('send-max on a token account with a positive balance still resolves with no error [LIVE-36768 regression guard]', async () => {
1314
+ const contract = 'KT1XnTn74bagnxYoFErbDH5QRAULUDtpXkmV'
1315
+ const tokenBalance = '10000'
1316
+
1317
+ mockGetTokensBalances.mockResolvedValue([
1318
+ {
1319
+ id: 2,
1320
+ account: { address: senderAddress },
1321
+ token: {
1322
+ id: 2,
1323
+ contract: { address: contract },
1324
+ tokenId: '0',
1325
+ standard: 'fa2' as const,
1326
+ metadata: { symbol: 'USDt', decimals: '6' },
1327
+ },
1328
+ balance: tokenBalance,
1329
+ transfersCount: 0,
1330
+ firstLevel: 0,
1331
+ firstTime: '',
1332
+ lastLevel: 0,
1333
+ lastTime: '',
1334
+ },
1335
+ ])
1336
+
1337
+ const result = await validateIntent(context, {
1338
+ intentType: 'transaction',
1339
+ asset: { type: 'token', assetReference: `${contract}:0` },
1340
+ type: 'send',
1341
+ sender: senderAddress,
1342
+ recipient: validRecipient,
1343
+ amount: 0n,
1344
+ useAllAmount: true,
1345
+ })
1346
+
1347
+ expect(result.errors.amount).toBeUndefined()
1348
+ expect(result.amount).toBe(BigInt(tokenBalance))
1349
+ })
1350
+
1351
+ it('send-max token: an unreachable TzKT blocks instead of returning the XTZ balance as a token amount [LIVE-36768]', async () => {
1352
+ // The wrong-unit fallback: with no token balance, calculateAmounts returned
1353
+ // spendable-XTZ-minus-fees (4_999_000 mutez) as the token amount, so the user could have
1354
+ // signed a 4.999 USDt transfer they never chose.
1355
+ const contract = 'KT1XnTn74bagnxYoFErbDH5QRAULUDtpXkmV'
1356
+ const cause = new Error('TzKT unreachable')
1357
+
1358
+ mockGetAccountByAddress.mockResolvedValue(makeUserAccount({ balance: 5_000_000 }))
1359
+ mockGetTokensBalances.mockRejectedValue(cause)
1360
+
1361
+ const result = await validateIntent(context, {
1362
+ intentType: 'transaction',
1363
+ asset: { type: 'token', assetReference: `${contract}:0` },
1364
+ type: 'send',
1365
+ sender: senderAddress,
1366
+ recipient: validRecipient,
1367
+ amount: 0n,
1368
+ useAllAmount: true,
1369
+ })
1370
+
1371
+ expect(result.amount).toBe(0n)
1372
+ expect(result.amount).not.toBe(4_999_000n)
1373
+ expect(result.estimatedFees).toBe(1000n) // fee estimation is preserved, not wiped
1374
+ // A holder of the token must not be told they have insufficient funds.
1375
+ const amountError: Error = result.errors.amount
1376
+ expect(amountError).toBeInstanceOf(UnexpectedGetBalanceError)
1377
+ expect(amountError).not.toBeInstanceOf(NotEnoughBalance)
1378
+ expect(amountError).toHaveProperty('cause', cause)
1182
1379
  })
1183
1380
 
1184
1381
  it('fixed-amount TKEY (18 dec): no error when user has enough token and XTZ for fees [LIVE-35976]', async () => {
@@ -1577,6 +1774,25 @@ describe('validateIntent', () => {
1577
1774
  expect(result.amount).toBe(1_999_000n)
1578
1775
  expect(result.totalSpent).toBe(2_000_000n)
1579
1776
  })
1777
+
1778
+ it('blocks native send-max when spendable balance is exactly the fees [LIVE-36768]', async () => {
1779
+ // The native counterpart: totalSpent equals spendable exactly, so the
1780
+ // `totalSpent > spendable` coverage check passes and a 0-XTZ send used to go through.
1781
+ mockGetAccountByAddress.mockResolvedValue(makeUserAccount({ balance: 1000 })) // == estimatedFees
1782
+
1783
+ const result = await validateIntent(context, {
1784
+ intentType: 'transaction',
1785
+ asset: { type: 'native' },
1786
+ type: 'send',
1787
+ sender: senderAddress,
1788
+ recipient: validRecipient,
1789
+ amount: 0n,
1790
+ useAllAmount: true,
1791
+ })
1792
+
1793
+ expect(result.amount).toBe(0n)
1794
+ expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
1795
+ })
1580
1796
  })
1581
1797
 
1582
1798
  describe('successful validation', () => {