@ledgerhq/coin-tezos 10.2.0 → 10.2.2

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 (79) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/lib/api/index.d.ts +1 -1
  3. package/lib/api/index.d.ts.map +1 -1
  4. package/lib/api/index.js +1 -1
  5. package/lib/api/index.js.map +1 -1
  6. package/lib/logic/craftRawOperations.js +26 -17
  7. package/lib/logic/craftRawOperations.js.map +1 -1
  8. package/lib/logic/estimateFees.js +4 -4
  9. package/lib/logic/estimateFees.js.map +1 -1
  10. package/lib/logic/listOperations.d.ts.map +1 -1
  11. package/lib/logic/listOperations.js +9 -7
  12. package/lib/logic/listOperations.js.map +1 -1
  13. package/lib/logic/validateIntent.d.ts.map +1 -1
  14. package/lib/logic/validateIntent.js +26 -8
  15. package/lib/logic/validateIntent.js.map +1 -1
  16. package/lib/network/tzkt.js +1 -1
  17. package/lib/network/tzkt.js.map +1 -1
  18. package/lib/test/config.d.ts +2 -22
  19. package/lib/test/config.d.ts.map +1 -1
  20. package/lib/test/config.js.map +1 -1
  21. package/lib/utils.d.ts +0 -3
  22. package/lib/utils.d.ts.map +1 -1
  23. package/lib/utils.js +17 -3
  24. package/lib/utils.js.map +1 -1
  25. package/lib-es/api/index.d.ts +1 -1
  26. package/lib-es/api/index.d.ts.map +1 -1
  27. package/lib-es/api/index.js +1 -1
  28. package/lib-es/api/index.js.map +1 -1
  29. package/lib-es/logic/craftRawOperations.js +26 -17
  30. package/lib-es/logic/craftRawOperations.js.map +1 -1
  31. package/lib-es/logic/estimateFees.js +4 -4
  32. package/lib-es/logic/estimateFees.js.map +1 -1
  33. package/lib-es/logic/listOperations.d.ts.map +1 -1
  34. package/lib-es/logic/listOperations.js +9 -7
  35. package/lib-es/logic/listOperations.js.map +1 -1
  36. package/lib-es/logic/validateIntent.d.ts.map +1 -1
  37. package/lib-es/logic/validateIntent.js +27 -9
  38. package/lib-es/logic/validateIntent.js.map +1 -1
  39. package/lib-es/network/tzkt.js +1 -1
  40. package/lib-es/network/tzkt.js.map +1 -1
  41. package/lib-es/test/config.d.ts +2 -22
  42. package/lib-es/test/config.d.ts.map +1 -1
  43. package/lib-es/test/config.js.map +1 -1
  44. package/lib-es/utils.d.ts +0 -3
  45. package/lib-es/utils.d.ts.map +1 -1
  46. package/lib-es/utils.js +17 -3
  47. package/lib-es/utils.js.map +1 -1
  48. package/package.json +2 -2
  49. package/src/api/index-mainnet.integ.test.ts +40 -50
  50. package/src/api/index.integ.test.ts +60 -45
  51. package/src/api/index.test.ts +229 -180
  52. package/src/api/index.ts +5 -4
  53. package/src/logic/broadcast.integ.test.ts +2 -7
  54. package/src/logic/craftRawOperations.test.ts +11 -10
  55. package/src/logic/craftRawOperations.ts +35 -19
  56. package/src/logic/craftTransaction.integ.test.ts +2 -2
  57. package/src/logic/craftTransaction.test.ts +63 -95
  58. package/src/logic/estimateFees.integ.test.ts +19 -20
  59. package/src/logic/estimateFees.test.ts +30 -63
  60. package/src/logic/estimateFees.ts +4 -4
  61. package/src/logic/getBalance.test.ts +6 -6
  62. package/src/logic/getBlock.integ.test.ts +20 -21
  63. package/src/logic/getBlock.test.ts +44 -51
  64. package/src/logic/getBlockInfo.integ.test.ts +13 -14
  65. package/src/logic/getBlockInfo.test.ts +8 -7
  66. package/src/logic/getStakes.test.ts +17 -17
  67. package/src/logic/listOperations.integ.test.ts +40 -37
  68. package/src/logic/listOperations.test.ts +34 -31
  69. package/src/logic/listOperations.ts +19 -19
  70. package/src/logic/validateIntent.test.ts +145 -20
  71. package/src/logic/validateIntent.ts +32 -8
  72. package/src/network/bakers.integ.test.ts +1 -1
  73. package/src/network/bakers.test.ts +7 -15
  74. package/src/network/tzkt.integ.test.ts +13 -14
  75. package/src/network/tzkt.test.ts +194 -259
  76. package/src/network/tzkt.ts +1 -1
  77. package/src/test/config.ts +3 -1
  78. package/src/utils.ts +18 -3
  79. package/.oxlintrc.json +0 -20
@@ -1,14 +1,14 @@
1
1
  // SPDX-FileCopyrightText: © 2026 LEDGER SAS
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import type { TezosCoinConfig, TezosContext } from '../config'
5
- import type { TransactionIntent } from '@ledgerhq/coin-module-framework/api/types'
4
+ import type { TezosContext } from '../config'
6
5
  import {
7
6
  RecipientRequired,
8
7
  InvalidAddress,
9
8
  InvalidAddressBecauseDestinationIsAlsoSource,
10
9
  AmountRequired,
11
10
  NotEnoughBalance,
11
+ UnexpectedGetBalanceError,
12
12
  } from '@ledgerhq/coin-module-framework/errors'
13
13
  import {
14
14
  InvalidAddressBecauseAlreadyDelegated,
@@ -57,20 +57,19 @@ describe('validateIntent', () => {
57
57
  })
58
58
 
59
59
  const context: TezosContext = {
60
- config: async () =>
61
- ({
62
- status: { type: 'active' },
63
- baker: { url: 'http://baker.example.com' },
64
- explorer: { url: 'http://tezos.explorer.com', maxTxQuery: 100 },
65
- node: { url: 'http://tezos.node.com' },
66
- fees: {
67
- minGasLimit: 600,
68
- minRevealGasLimit: 300,
69
- minStorageLimit: 0,
70
- minFees: 500,
71
- minEstimatedFees: 500,
72
- },
73
- }) as unknown as TezosCoinConfig,
60
+ config: async () => ({
61
+ status: { type: 'active' },
62
+ baker: { url: 'http://baker.example.com' },
63
+ explorer: { url: 'http://tezos.explorer.com', maxTxQuery: 100 },
64
+ node: { url: 'http://tezos.node.com' },
65
+ fees: {
66
+ minGasLimit: 600,
67
+ minRevealGasLimit: 300,
68
+ minStorageLimit: 0,
69
+ minFees: 500,
70
+ minEstimatedFees: 500,
71
+ },
72
+ }),
74
73
  logger: () => undefined,
75
74
  }
76
75
 
@@ -229,7 +228,7 @@ describe('validateIntent', () => {
229
228
  sender: senderAddress,
230
229
  recipient: '',
231
230
  amount: 0n,
232
- } satisfies TransactionIntent)
231
+ })
233
232
 
234
233
  expect(result).toEqual({
235
234
  errors: {},
@@ -569,7 +568,7 @@ describe('validateIntent', () => {
569
568
  sender: senderAddress,
570
569
  recipient: '',
571
570
  amount: 0n,
572
- } satisfies TransactionIntent)
571
+ })
573
572
 
574
573
  expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
575
574
  expect(mockEstimateFees).not.toHaveBeenCalled()
@@ -757,7 +756,7 @@ describe('validateIntent', () => {
757
756
  sender: senderAddress,
758
757
  recipient: '',
759
758
  amount: 0n,
760
- } satisfies TransactionIntent)
759
+ })
761
760
 
762
761
  expect(result.errors).toEqual({})
763
762
  })
@@ -1165,7 +1164,7 @@ describe('validateIntent', () => {
1165
1164
  expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
1166
1165
  })
1167
1166
 
1168
- it('should set amount to 0n when useAllAmount and token balance row is missing', async () => {
1167
+ it('should set amount to 0n and NotEnoughBalance when useAllAmount and token balance row is missing [LIVE-36768]', async () => {
1169
1168
  const contract = 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU'
1170
1169
  mockGetTokensBalances.mockResolvedValue([])
1171
1170
 
@@ -1181,6 +1180,113 @@ describe('validateIntent', () => {
1181
1180
 
1182
1181
  expect(result.amount).toBe(0n)
1183
1182
  expect(result.totalSpent).toBe(1000n)
1183
+ expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
1184
+ })
1185
+
1186
+ it('send-max on a 0-balance token account blocks with NotEnoughBalance even when parent XTZ account has funds for fees [LIVE-36768]', async () => {
1187
+ const contract = 'KT1XnTn74bagnxYoFErbDH5QRAULUDtpXkmV'
1188
+
1189
+ mockGetAccountByAddress.mockResolvedValue(makeUserAccount({ balance: 5_000_000 }))
1190
+ mockGetTokensBalances.mockResolvedValue([
1191
+ {
1192
+ id: 2,
1193
+ account: { address: senderAddress },
1194
+ token: {
1195
+ id: 2,
1196
+ contract: { address: contract },
1197
+ tokenId: '0',
1198
+ standard: 'fa2' as const,
1199
+ metadata: { symbol: 'USDt', decimals: '6' },
1200
+ },
1201
+ balance: '0',
1202
+ transfersCount: 0,
1203
+ firstLevel: 0,
1204
+ firstTime: '',
1205
+ lastLevel: 0,
1206
+ lastTime: '',
1207
+ },
1208
+ ])
1209
+
1210
+ const result = await validateIntent(context, {
1211
+ intentType: 'transaction',
1212
+ asset: { type: 'token', assetReference: `${contract}:0` },
1213
+ type: 'send',
1214
+ sender: senderAddress,
1215
+ recipient: validRecipient,
1216
+ amount: 0n,
1217
+ useAllAmount: true,
1218
+ })
1219
+
1220
+ expect(result.amount).toBe(0n)
1221
+ expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
1222
+ })
1223
+
1224
+ it('send-max on a token account with a positive balance still resolves with no error [LIVE-36768 regression guard]', async () => {
1225
+ const contract = 'KT1XnTn74bagnxYoFErbDH5QRAULUDtpXkmV'
1226
+ const tokenBalance = '10000'
1227
+
1228
+ mockGetTokensBalances.mockResolvedValue([
1229
+ {
1230
+ id: 2,
1231
+ account: { address: senderAddress },
1232
+ token: {
1233
+ id: 2,
1234
+ contract: { address: contract },
1235
+ tokenId: '0',
1236
+ standard: 'fa2' as const,
1237
+ metadata: { symbol: 'USDt', decimals: '6' },
1238
+ },
1239
+ balance: tokenBalance,
1240
+ transfersCount: 0,
1241
+ firstLevel: 0,
1242
+ firstTime: '',
1243
+ lastLevel: 0,
1244
+ lastTime: '',
1245
+ },
1246
+ ])
1247
+
1248
+ const result = await validateIntent(context, {
1249
+ intentType: 'transaction',
1250
+ asset: { type: 'token', assetReference: `${contract}:0` },
1251
+ type: 'send',
1252
+ sender: senderAddress,
1253
+ recipient: validRecipient,
1254
+ amount: 0n,
1255
+ useAllAmount: true,
1256
+ })
1257
+
1258
+ expect(result.errors.amount).toBeUndefined()
1259
+ expect(result.amount).toBe(BigInt(tokenBalance))
1260
+ })
1261
+
1262
+ it('send-max token: an unreachable TzKT blocks instead of returning the XTZ balance as a token amount [LIVE-36768]', async () => {
1263
+ // The wrong-unit fallback: with no token balance, calculateAmounts returned
1264
+ // spendable-XTZ-minus-fees (4_999_000 mutez) as the token amount, so the user could have
1265
+ // signed a 4.999 USDt transfer they never chose.
1266
+ const contract = 'KT1XnTn74bagnxYoFErbDH5QRAULUDtpXkmV'
1267
+ const cause = new Error('TzKT unreachable')
1268
+
1269
+ mockGetAccountByAddress.mockResolvedValue(makeUserAccount({ balance: 5_000_000 }))
1270
+ mockGetTokensBalances.mockRejectedValue(cause)
1271
+
1272
+ const result = await validateIntent(context, {
1273
+ intentType: 'transaction',
1274
+ asset: { type: 'token', assetReference: `${contract}:0` },
1275
+ type: 'send',
1276
+ sender: senderAddress,
1277
+ recipient: validRecipient,
1278
+ amount: 0n,
1279
+ useAllAmount: true,
1280
+ })
1281
+
1282
+ expect(result.amount).toBe(0n)
1283
+ expect(result.amount).not.toBe(4_999_000n)
1284
+ expect(result.estimatedFees).toBe(1000n) // fee estimation is preserved, not wiped
1285
+ // A holder of the token must not be told they have insufficient funds.
1286
+ const amountError: Error = result.errors.amount
1287
+ expect(amountError).toBeInstanceOf(UnexpectedGetBalanceError)
1288
+ expect(amountError).not.toBeInstanceOf(NotEnoughBalance)
1289
+ expect(amountError).toHaveProperty('cause', cause)
1184
1290
  })
1185
1291
 
1186
1292
  it('fixed-amount TKEY (18 dec): no error when user has enough token and XTZ for fees [LIVE-35976]', async () => {
@@ -1579,6 +1685,25 @@ describe('validateIntent', () => {
1579
1685
  expect(result.amount).toBe(1_999_000n)
1580
1686
  expect(result.totalSpent).toBe(2_000_000n)
1581
1687
  })
1688
+
1689
+ it('blocks native send-max when spendable balance is exactly the fees [LIVE-36768]', async () => {
1690
+ // The native counterpart: totalSpent equals spendable exactly, so the
1691
+ // `totalSpent > spendable` coverage check passes and a 0-XTZ send used to go through.
1692
+ mockGetAccountByAddress.mockResolvedValue(makeUserAccount({ balance: 1000 })) // == estimatedFees
1693
+
1694
+ const result = await validateIntent(context, {
1695
+ intentType: 'transaction',
1696
+ asset: { type: 'native' },
1697
+ type: 'send',
1698
+ sender: senderAddress,
1699
+ recipient: validRecipient,
1700
+ amount: 0n,
1701
+ useAllAmount: true,
1702
+ })
1703
+
1704
+ expect(result.amount).toBe(0n)
1705
+ expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
1706
+ })
1582
1707
  })
1583
1708
 
1584
1709
  describe('successful validation', () => {
@@ -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'
@@ -397,17 +398,25 @@ export async function validateIntent(
397
398
  // the outer handler (which would wipe the already-computed estimatedFees and add a spurious
398
399
  // errors.estimation on top of the real fee-estimation error).
399
400
  let tokenBalance: bigint | undefined
401
+ let tokenBalanceError: unknown
400
402
  if (errors.amount && !intent.useAllAmount) {
401
403
  tokenBalance = undefined
402
404
  } else {
403
405
  try {
404
406
  tokenBalance = await fetchTokenBalance(config, intent)
405
- } catch {
406
- tokenBalance = undefined // TzKT unreachable: fall back gracefully, no token coverage check
407
+ } catch (e) {
408
+ tokenBalance = undefined
409
+ tokenBalanceError = e
407
410
  }
408
411
  }
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
412
+
413
+ // Never pass `undefined` for a token send-max: that triggers the native-branch/wrong-unit
414
+ // fallback described above. An unresolved balance becomes 0n; the guards below set the error.
415
+ const isTokenSendMax =
416
+ intent.type === 'send' &&
417
+ !!intent.useAllAmount &&
418
+ resolveTezosOperationMode(intent.type, intent.asset) === 'send_token'
419
+ const tokenBalanceForSendMax = isTokenSendMax ? (tokenBalance ?? 0n) : undefined
411
420
 
412
421
  const amounts = calculateAmounts(
413
422
  intent,
@@ -419,7 +428,22 @@ export async function validateIntent(
419
428
  amount = amounts.amount
420
429
  totalSpent = amounts.totalSpent
421
430
 
422
- if (intent.type === 'stake' && intent.useAllAmount && amount === 0n && !errors.amount) {
431
+ // An unreachable TzKT is not an empty balance. Both block, but reporting NotEnoughBalance
432
+ // here would tell a user who holds the token that they have insufficient funds.
433
+ if (isTokenSendMax && tokenBalanceError !== undefined && !errors.amount) {
434
+ errors.amount = new UnexpectedGetBalanceError(undefined, tokenBalanceError)
435
+ }
436
+
437
+ // A send-max resolving to 0n must block. validateBalanceCoverage below only compares native
438
+ // spendable against totalSpent, which a 0-amount transaction always satisfies, so nothing
439
+ // else catches it.
440
+ // `finalize_unstake` is excluded: it is parameter-less and 0n by construction.
441
+ if (
442
+ (intent.type === 'send' || intent.type === 'stake') &&
443
+ intent.useAllAmount &&
444
+ amount === 0n &&
445
+ !errors.amount
446
+ ) {
423
447
  errors.amount = new NotEnoughBalance()
424
448
  }
425
449
 
@@ -431,8 +455,8 @@ export async function validateIntent(
431
455
  const balanceErrors = validateBalanceCoverage(spendable, totalSpent)
432
456
  Object.assign(errors, balanceErrors)
433
457
 
434
- // Token balance coverage for fixed-amount token sends.
435
- // (send-max is always valid by construction: amount is set to tokenBalance above.)
458
+ // Token balance coverage for fixed-amount token sends. send-max needs none: amount is the
459
+ // token balance itself, and the zero/unresolved cases already errored above.
436
460
  if (
437
461
  !errors.amount &&
438
462
  intent.type === 'send' &&
@@ -444,7 +468,7 @@ export async function validateIntent(
444
468
  }
445
469
  }
446
470
  } catch (e) {
447
- errors.estimation = e as Error
471
+ errors.estimation = e instanceof Error ? e : new Error(String(e))
448
472
  estimatedFees = 0n
449
473
  amount = intent.amount
450
474
  totalSpent = intent.amount
@@ -7,7 +7,7 @@ import whitelist from '../network/bakers.whitelist-default'
7
7
  import { mockConfig } from '../test/config'
8
8
 
9
9
  describe('tezos bakers', () => {
10
- const config: TezosCoinConfig = mockConfig as TezosCoinConfig
10
+ const config: TezosCoinConfig = mockConfig
11
11
 
12
12
  test('atleast 10 whitelisted bakers are online', async () => {
13
13
  const onlineBakersCount = (await listBakers(config, whitelist))
@@ -3,16 +3,18 @@
3
3
 
4
4
  import type { TezosCoinConfig } from '../config'
5
5
  import type { TezosApiBaker } from './bakers'
6
- import network from '@ledgerhq/live-network/network'
7
6
  import { mockConfig } from '../test/config'
8
7
  import { asBaker, cache } from './bakers'
9
8
 
10
- jest.mock('@ledgerhq/live-network/network')
11
- const mockedNetwork = jest.mocked(network)
9
+ const mockNetworkFn = jest.fn()
10
+ jest.mock('@ledgerhq/live-network/network', () => ({
11
+ __esModule: true,
12
+ default: (...args: unknown[]) => mockNetworkFn(...args),
13
+ }))
12
14
 
13
15
  // `cache` is keyed by config (its LRU resolver is `config.baker.url`), so it must be called with a
14
16
  // config. Previously the config was provided via `coinConfig.setCoinConfig(...)`.
15
- const config = mockConfig as TezosCoinConfig
17
+ const config: TezosCoinConfig = mockConfig
16
18
 
17
19
  const data: TezosApiBaker[] = [
18
20
  {
@@ -177,17 +179,7 @@ describe('Tezos Baker', () => {
177
179
 
178
180
  describe('Get bakers from cache', () => {
179
181
  beforeEach(() => {
180
- const response = {
181
- data,
182
- status: 200,
183
- headers: {} as any,
184
- statusText: '',
185
- config: {
186
- headers: {} as any,
187
- },
188
- }
189
-
190
- mockedNetwork.mockReturnValue(Promise.resolve(response))
182
+ mockNetworkFn.mockResolvedValue({ data })
191
183
  })
192
184
 
193
185
  it('Ledger Baker should be first in the list', async () => {
@@ -16,20 +16,19 @@ import { createTzktApi } from './tzkt'
16
16
  const MULTI_ASSET_HOLDER = 'tz1ZB8hpQJZdQeEPc3uG2cL1NXwLTPkqeAD6'
17
17
  const WRAPPED_CONTRACT = 'KT18fp5rcTW7mbWDmzFwjLDUhs5MeJmagDSZ'
18
18
 
19
- const mainnetConfig = (): TezosCoinConfig =>
20
- ({
21
- status: { type: 'active' },
22
- baker: { url: 'https://tezos-bakers.api.live.ledger.com' },
23
- explorer: { url: 'https://xtz-tzkt-explorer.api.vault.ledger.com', maxTxQuery: 100 },
24
- node: { url: 'https://xtz-node.api.live.ledger.com' },
25
- fees: {
26
- minGasLimit: 0,
27
- minRevealGasLimit: 0,
28
- minStorageLimit: 0,
29
- minFees: 0,
30
- minEstimatedFees: 0,
31
- },
32
- }) as TezosCoinConfig
19
+ const mainnetConfig = (): TezosCoinConfig => ({
20
+ status: { type: 'active' },
21
+ baker: { url: 'https://tezos-bakers.api.live.ledger.com' },
22
+ explorer: { url: 'https://xtz-tzkt-explorer.api.vault.ledger.com', maxTxQuery: 100 },
23
+ node: { url: 'https://xtz-node.api.live.ledger.com' },
24
+ fees: {
25
+ minGasLimit: 0,
26
+ minRevealGasLimit: 0,
27
+ minStorageLimit: 0,
28
+ minFees: 0,
29
+ minEstimatedFees: 0,
30
+ },
31
+ })
33
32
 
34
33
  describe('TzKT multi-asset FA2 — mainnet', () => {
35
34
  const config: TezosCoinConfig = mainnetConfig()