@ledgerhq/coin-tezos 10.0.0 → 10.2.0

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.
@@ -3,10 +3,27 @@
3
3
 
4
4
  import type { TezosCoinConfig, TezosContext } from '../config'
5
5
  import type { APITokenBalance } from '../network/types'
6
- import { HttpResponse, http } from 'msw'
7
- import { setupServer } from 'msw/node'
8
6
  import { getBalance } from './getBalance'
9
7
 
8
+ const mockedGetAccountByAddress = jest.fn()
9
+ const mockedGetTokensBalances = jest.fn()
10
+ const mockedGetUnstakeRequests = jest.fn()
11
+
12
+ jest.mock('../network/tzkt', () => {
13
+ const originalModule = jest.requireActual('../network/tzkt')
14
+ return {
15
+ ...originalModule,
16
+ createTzktApi: () => {
17
+ return {
18
+ ...originalModule.api,
19
+ getAccountByAddress: mockedGetAccountByAddress,
20
+ getTokensBalances: mockedGetTokensBalances,
21
+ getUnstakeRequests: mockedGetUnstakeRequests,
22
+ }
23
+ },
24
+ }
25
+ })
26
+
10
27
  const mockTokensBalancesWithAllInfo: APITokenBalance[] = [
11
28
  {
12
29
  id: 2398642347442177,
@@ -51,14 +68,8 @@ const mockTokensBalancesWithMissingInfo: APITokenBalance[] = [
51
68
  ]
52
69
 
53
70
  describe('getBalance', () => {
54
- const mockServer = setupServer()
55
-
56
- beforeAll(() => {
57
- mockServer.listen({ onUnhandledRequest: 'error' })
58
- })
59
-
60
- afterAll(() => {
61
- mockServer.close()
71
+ beforeEach(() => {
72
+ jest.resetAllMocks()
62
73
  })
63
74
 
64
75
  const context: TezosContext = {
@@ -70,35 +81,31 @@ describe('getBalance', () => {
70
81
  logger: () => undefined,
71
82
  }
72
83
  it('gets the balance of a Tezos account', async () => {
73
- mockServer.use(
74
- http.get('http://tezos.explorer.com/v1/accounts/tz1WvvbEGpBXGeTVbLiR6DYBe1izmgiYuZbq', () =>
75
- HttpResponse.json({ type: 'empty' })
76
- ),
77
- http.get('http://tezos.explorer.com/v1/accounts/tz1TzrmTBSuiVHV2VfMnGRMYvTEPCP42oSM8', () =>
78
- HttpResponse.json({ type: 'user', balance: 25 })
79
- ),
80
- http.get('http://tezos.explorer.com/v1/accounts/tz1TzrmTBSuiVHV2VfMnGRMYvTEPCP42oMMM', () =>
81
- HttpResponse.json({
84
+ mockedGetAccountByAddress.mockImplementation((address: string) => {
85
+ if (address === 'tz1WvvbEGpBXGeTVbLiR6DYBe1izmgiYuZbq') {
86
+ return { type: 'empty' }
87
+ } else if (address === 'tz1TzrmTBSuiVHV2VfMnGRMYvTEPCP42oSM8') {
88
+ return { type: 'user', balance: 25 }
89
+ } else if (address === 'tz1TzrmTBSuiVHV2VfMnGRMYvTEPCP42oMMM') {
90
+ return {
82
91
  type: 'user',
83
92
  balance: 15,
84
93
  delegate: { address: 'tz1TzrmTBSuiVHV2VfMnGRMYvTEPCP42oMMM' },
85
- })
86
- ),
87
- http.get('http://tezos.explorer.com/v1/tokens/balances', ({ request }) => {
88
- const url = new URL(request.url)
89
- const account = url.searchParams.get('account')
90
-
91
- // Return a mocked JSON response when the "account"
92
- // search parameter equals a specific address.
93
- if (account === 'tz1TzrmTBSuiVHV2VfMnGRMYvTEPCP42oSM8') {
94
- return HttpResponse.json(mockTokensBalancesWithAllInfo)
95
- } else if (account === 'tz1TzrmTBSuiVHV2VfMnGRMYvTEPCP42oMMM') {
96
- return HttpResponse.json(mockTokensBalancesWithMissingInfo)
97
- } else {
98
- return HttpResponse.json([])
99
94
  }
100
- })
101
- )
95
+ } else {
96
+ throw new Error('address not handled by the test: ' + address)
97
+ }
98
+ })
99
+
100
+ mockedGetTokensBalances.mockImplementation((address: string) => {
101
+ if (address === 'tz1TzrmTBSuiVHV2VfMnGRMYvTEPCP42oSM8') {
102
+ return mockTokensBalancesWithAllInfo
103
+ } else if (address === 'tz1TzrmTBSuiVHV2VfMnGRMYvTEPCP42oMMM') {
104
+ return mockTokensBalancesWithMissingInfo
105
+ } else {
106
+ return []
107
+ }
108
+ })
102
109
 
103
110
  expect(await getBalance(context, 'tz1WvvbEGpBXGeTVbLiR6DYBe1izmgiYuZbq')).toEqual([
104
111
  {
@@ -164,15 +171,9 @@ describe('getBalance', () => {
164
171
  const delegateAddress = 'tz1BakerAddr'
165
172
 
166
173
  function mockAccount(account: Record<string, unknown>) {
167
- mockServer.use(
168
- http.get(`http://tezos.explorer.com/v1/accounts/${address}`, () =>
169
- HttpResponse.json({ type: 'user', ...account })
170
- ),
171
- http.get('http://tezos.explorer.com/v1/tokens/balances', () => HttpResponse.json([])),
172
- http.get('http://tezos.explorer.com/v1/staking/unstake_requests', () =>
173
- HttpResponse.json([])
174
- )
175
- )
174
+ mockedGetAccountByAddress.mockResolvedValue({ type: 'user', ...account })
175
+ mockedGetTokensBalances.mockResolvedValue([])
176
+ mockedGetUnstakeRequests.mockResolvedValue([])
176
177
  }
177
178
 
178
179
  it('attaches a delegation Stake when only delegate is set', async () => {
@@ -271,31 +272,25 @@ describe('getBalance', () => {
271
272
  })
272
273
 
273
274
  it("attaches an unstaking Stake with state 'deactivating' when a pending request exists", async () => {
274
- mockServer.use(
275
- http.get(`http://tezos.explorer.com/v1/accounts/${address}`, () =>
276
- HttpResponse.json({
277
- type: 'user',
278
- balance: 100,
279
- stakedBalance: 30,
280
- unstakedBalance: 10,
281
- delegate: { address: delegateAddress },
282
- })
283
- ),
284
- http.get('http://tezos.explorer.com/v1/staking/unstake_requests', () =>
285
- HttpResponse.json([
286
- {
287
- id: 77,
288
- cycle: 100,
289
- baker: { address: delegateAddress },
290
- staker: { address },
291
- firstTime: '2026-05-01T00:00:00Z',
292
- status: 'pending',
293
- actualAmount: 10,
294
- },
295
- ])
296
- ),
297
- http.get('http://tezos.explorer.com/v1/tokens/balances', () => HttpResponse.json([]))
298
- )
275
+ mockedGetAccountByAddress.mockResolvedValue({
276
+ type: 'user',
277
+ balance: 100,
278
+ stakedBalance: 30,
279
+ unstakedBalance: 10,
280
+ delegate: { address: delegateAddress },
281
+ })
282
+ mockedGetTokensBalances.mockResolvedValue([])
283
+ mockedGetUnstakeRequests.mockResolvedValue([
284
+ {
285
+ id: 77,
286
+ cycle: 100,
287
+ baker: { address: delegateAddress },
288
+ staker: { address },
289
+ firstTime: '2026-05-01T00:00:00Z',
290
+ status: 'pending',
291
+ actualAmount: 10,
292
+ },
293
+ ])
299
294
 
300
295
  const result = await getBalance(context, address)
301
296
 
@@ -318,39 +313,33 @@ describe('getBalance', () => {
318
313
  })
319
314
 
320
315
  it('emits per-request unstaking and finalizable Stakes when both statuses are present', async () => {
321
- mockServer.use(
322
- http.get(`http://tezos.explorer.com/v1/accounts/${address}`, () =>
323
- HttpResponse.json({
324
- type: 'user',
325
- balance: 100,
326
- unstakedBalance: 50,
327
- delegate: { address: delegateAddress },
328
- })
329
- ),
330
- http.get('http://tezos.explorer.com/v1/staking/unstake_requests', () =>
331
- HttpResponse.json([
332
- {
333
- id: 1,
334
- cycle: 100,
335
- baker: { address: delegateAddress },
336
- staker: { address },
337
- firstTime: '2026-05-01T00:00:00Z',
338
- status: 'pending',
339
- actualAmount: 20,
340
- },
341
- {
342
- id: 2,
343
- cycle: 99,
344
- baker: { address: delegateAddress },
345
- staker: { address },
346
- firstTime: '2026-04-25T00:00:00Z',
347
- status: 'finalizable',
348
- actualAmount: 30,
349
- },
350
- ])
351
- ),
352
- http.get('http://tezos.explorer.com/v1/tokens/balances', () => HttpResponse.json([]))
353
- )
316
+ mockedGetAccountByAddress.mockResolvedValue({
317
+ type: 'user',
318
+ balance: 100,
319
+ unstakedBalance: 50,
320
+ delegate: { address: delegateAddress },
321
+ })
322
+ mockedGetUnstakeRequests.mockResolvedValue([
323
+ {
324
+ id: 1,
325
+ cycle: 100,
326
+ baker: { address: delegateAddress },
327
+ staker: { address },
328
+ firstTime: '2026-05-01T00:00:00Z',
329
+ status: 'pending',
330
+ actualAmount: 20,
331
+ },
332
+ {
333
+ id: 2,
334
+ cycle: 99,
335
+ baker: { address: delegateAddress },
336
+ staker: { address },
337
+ firstTime: '2026-04-25T00:00:00Z',
338
+ status: 'finalizable',
339
+ actualAmount: 30,
340
+ },
341
+ ])
342
+ mockedGetTokensBalances.mockResolvedValue([])
354
343
 
355
344
  const result = await getBalance(context, address)
356
345
 
@@ -396,30 +385,25 @@ describe('getBalance', () => {
396
385
  // A delegate has no `delegate` field (it is its own baker), so there is no delegation position —
397
386
  // only its self-stake and any unstake requests. The unstakedBalance > 0 exercises the
398
387
  // delegate → fetchUnstakeRequests path.
399
- mockServer.use(
400
- http.get(`http://tezos.explorer.com/v1/accounts/${address}`, () =>
401
- HttpResponse.json({
402
- type: 'delegate',
403
- balance: 100,
404
- stakedBalance: 30,
405
- unstakedBalance: 10,
406
- })
407
- ),
408
- http.get('http://tezos.explorer.com/v1/tokens/balances', () => HttpResponse.json([])),
409
- http.get('http://tezos.explorer.com/v1/staking/unstake_requests', () =>
410
- HttpResponse.json([
411
- {
412
- id: 77,
413
- cycle: 100,
414
- baker: { address },
415
- staker: { address },
416
- firstTime: '2026-05-01T00:00:00Z',
417
- status: 'pending',
418
- actualAmount: 10,
419
- },
420
- ])
421
- )
422
- )
388
+
389
+ mockedGetAccountByAddress.mockResolvedValue({
390
+ type: 'delegate',
391
+ balance: 100,
392
+ stakedBalance: 30,
393
+ unstakedBalance: 10,
394
+ })
395
+ mockedGetTokensBalances.mockResolvedValue([])
396
+ mockedGetUnstakeRequests.mockResolvedValue([
397
+ {
398
+ id: 77,
399
+ cycle: 100,
400
+ baker: { address },
401
+ staker: { address },
402
+ firstTime: '2026-05-01T00:00:00Z',
403
+ status: 'pending',
404
+ actualAmount: 10,
405
+ },
406
+ ])
423
407
 
424
408
  expect(await getBalance(context, address)).toEqual([
425
409
  { value: 100n, asset: { type: 'native' }, locked: 40n },
@@ -461,20 +445,14 @@ describe('getBalance', () => {
461
445
  })
462
446
 
463
447
  it('still excludes unstaked-frozen funds from spendable when the unstake_requests endpoint fails', async () => {
464
- mockServer.use(
465
- http.get(`http://tezos.explorer.com/v1/accounts/${address}`, () =>
466
- HttpResponse.json({
467
- type: 'user',
468
- balance: 100,
469
- unstakedBalance: 10,
470
- delegate: { address: delegateAddress },
471
- })
472
- ),
473
- http.get('http://tezos.explorer.com/v1/staking/unstake_requests', () =>
474
- HttpResponse.json({ error: 'internal' }, { status: 500 })
475
- ),
476
- http.get('http://tezos.explorer.com/v1/tokens/balances', () => HttpResponse.json([]))
477
- )
448
+ mockedGetAccountByAddress.mockResolvedValue({
449
+ type: 'user',
450
+ balance: 100,
451
+ unstakedBalance: 10,
452
+ delegate: { address: delegateAddress },
453
+ })
454
+ mockedGetUnstakeRequests.mockRejectedValue(new Error('endpoint failure'))
455
+ mockedGetTokensBalances.mockResolvedValue([])
478
456
 
479
457
  const result = await getBalance(context, address)
480
458
 
@@ -1182,6 +1182,287 @@ describe('validateIntent', () => {
1182
1182
  expect(result.amount).toBe(0n)
1183
1183
  expect(result.totalSpent).toBe(1000n)
1184
1184
  })
1185
+
1186
+ it('fixed-amount TKEY (18 dec): no error when user has enough token and XTZ for fees [LIVE-35976]', async () => {
1187
+ // 0.01 TKEY = 10^16 base units — vastly exceeds any XTZ balance in mutez, was the exact failure case
1188
+ const contract = 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU'
1189
+ const tkeyAmount = 10n ** 16n
1190
+
1191
+ mockGetTokensBalances.mockResolvedValue([
1192
+ {
1193
+ id: 1,
1194
+ account: { address: senderAddress },
1195
+ token: {
1196
+ id: 1,
1197
+ contract: { address: contract },
1198
+ tokenId: '0',
1199
+ standard: 'fa2' as const,
1200
+ metadata: { symbol: 'TKEY', decimals: '18' },
1201
+ },
1202
+ balance: tkeyAmount.toString(),
1203
+ transfersCount: 0,
1204
+ firstLevel: 0,
1205
+ firstTime: '',
1206
+ lastLevel: 0,
1207
+ lastTime: '',
1208
+ },
1209
+ ])
1210
+
1211
+ const result = await validateIntent(context, {
1212
+ intentType: 'transaction',
1213
+ asset: { type: 'token', assetReference: `${contract}:0` },
1214
+ type: 'send',
1215
+ sender: senderAddress,
1216
+ recipient: validRecipient,
1217
+ amount: tkeyAmount,
1218
+ })
1219
+
1220
+ expect(result.errors.amount).toBeUndefined()
1221
+ expect(result.amount).toBe(tkeyAmount)
1222
+ expect(result.totalSpent).toBe(1000n) // only XTZ fees; token amount not included
1223
+ })
1224
+
1225
+ it('fixed-amount token send: NotEnoughBalance when XTZ is insufficient for fees [LIVE-35976]', async () => {
1226
+ const contract = 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU'
1227
+ const tkeyAmount = 10n ** 16n
1228
+
1229
+ mockGetAccountByAddress.mockResolvedValue(makeUserAccount({ balance: 500 })) // 500 mutez < 1000 fees
1230
+ // Token balance is sufficient — this test must isolate the XTZ coverage branch only.
1231
+ mockGetTokensBalances.mockResolvedValue([
1232
+ {
1233
+ id: 1,
1234
+ account: { address: senderAddress },
1235
+ token: {
1236
+ id: 1,
1237
+ contract: { address: contract },
1238
+ tokenId: '0',
1239
+ standard: 'fa2' as const,
1240
+ metadata: { symbol: 'TKEY', decimals: '18' },
1241
+ },
1242
+ balance: tkeyAmount.toString(),
1243
+ transfersCount: 0,
1244
+ firstLevel: 0,
1245
+ firstTime: '',
1246
+ lastLevel: 0,
1247
+ lastTime: '',
1248
+ },
1249
+ ])
1250
+
1251
+ const result = await validateIntent(context, {
1252
+ intentType: 'transaction',
1253
+ asset: { type: 'token', assetReference: `${contract}:0` },
1254
+ type: 'send',
1255
+ sender: senderAddress,
1256
+ recipient: validRecipient,
1257
+ amount: tkeyAmount,
1258
+ })
1259
+
1260
+ expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
1261
+ })
1262
+
1263
+ it('fixed-amount token send: NotEnoughBalance when token balance is insufficient [LIVE-35976]', async () => {
1264
+ const contract = 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU'
1265
+ const tkeyAmount = 10n ** 16n
1266
+
1267
+ mockGetTokensBalances.mockResolvedValue([
1268
+ {
1269
+ id: 1,
1270
+ account: { address: senderAddress },
1271
+ token: {
1272
+ id: 1,
1273
+ contract: { address: contract },
1274
+ tokenId: '0',
1275
+ standard: 'fa2' as const,
1276
+ metadata: { symbol: 'TKEY', decimals: '18' },
1277
+ },
1278
+ balance: (tkeyAmount - 1n).toString(), // one unit short
1279
+ transfersCount: 0,
1280
+ firstLevel: 0,
1281
+ firstTime: '',
1282
+ lastLevel: 0,
1283
+ lastTime: '',
1284
+ },
1285
+ ])
1286
+
1287
+ const result = await validateIntent(context, {
1288
+ intentType: 'transaction',
1289
+ asset: { type: 'token', assetReference: `${contract}:0` },
1290
+ type: 'send',
1291
+ sender: senderAddress,
1292
+ recipient: validRecipient,
1293
+ amount: tkeyAmount,
1294
+ })
1295
+
1296
+ expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
1297
+ })
1298
+
1299
+ it('fixed-amount USDt (6 dec): passes correctly, not just accidentally, after fix [LIVE-35976]', async () => {
1300
+ // 0.01 USDt = 10_000 base units — previously passed only because 10_000 < XTZ balance by coincidence
1301
+ const contract = 'KT1XnTn74bagnxYoFErbDH5QRAULUDtpXkmV'
1302
+ const usdtAmount = 10_000n
1303
+
1304
+ mockGetTokensBalances.mockResolvedValue([
1305
+ {
1306
+ id: 2,
1307
+ account: { address: senderAddress },
1308
+ token: {
1309
+ id: 2,
1310
+ contract: { address: contract },
1311
+ tokenId: '0',
1312
+ standard: 'fa2' as const,
1313
+ metadata: { symbol: 'USDt', decimals: '6' },
1314
+ },
1315
+ balance: usdtAmount.toString(),
1316
+ transfersCount: 0,
1317
+ firstLevel: 0,
1318
+ firstTime: '',
1319
+ lastLevel: 0,
1320
+ lastTime: '',
1321
+ },
1322
+ ])
1323
+
1324
+ const result = await validateIntent(context, {
1325
+ intentType: 'transaction',
1326
+ asset: { type: 'token', assetReference: `${contract}:0` },
1327
+ type: 'send',
1328
+ sender: senderAddress,
1329
+ recipient: validRecipient,
1330
+ amount: usdtAmount,
1331
+ })
1332
+
1333
+ expect(result.errors.amount).toBeUndefined()
1334
+ expect(result.totalSpent).toBe(1000n) // only fees in XTZ
1335
+ })
1336
+
1337
+ it('unrevealed account + fixed-amount FA2 token send: no error and totalSpent equals reveal fees only [LIVE-35976]', async () => {
1338
+ // Unrevealed accounts skip taquito estimation and get a fixed 2000n fee.
1339
+ // Before the fix, calculateAmounts fell through to `amount + estimatedFees`, mixing token
1340
+ // base units with mutez. After the fix it correctly returns `totalSpent: estimatedFees`.
1341
+ const contract = 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU'
1342
+ const tkeyAmount = 10n ** 16n
1343
+
1344
+ mockGetAccountByAddress.mockResolvedValue(makeUserAccount({ revealed: false }))
1345
+ mockGetTokensBalances.mockResolvedValue([
1346
+ {
1347
+ id: 1,
1348
+ account: { address: senderAddress },
1349
+ token: {
1350
+ id: 1,
1351
+ contract: { address: contract },
1352
+ tokenId: '0',
1353
+ standard: 'fa2' as const,
1354
+ metadata: { symbol: 'TKEY', decimals: '18' },
1355
+ },
1356
+ balance: tkeyAmount.toString(),
1357
+ transfersCount: 0,
1358
+ firstLevel: 0,
1359
+ firstTime: '',
1360
+ lastLevel: 0,
1361
+ lastTime: '',
1362
+ },
1363
+ ])
1364
+
1365
+ const result = await validateIntent(context, {
1366
+ intentType: 'transaction',
1367
+ asset: { type: 'token', assetReference: `${contract}:0` },
1368
+ type: 'send',
1369
+ sender: senderAddress,
1370
+ recipient: validRecipient,
1371
+ amount: tkeyAmount,
1372
+ })
1373
+
1374
+ expect(mockEstimateFees).not.toHaveBeenCalled()
1375
+ expect(result.errors.amount).toBeUndefined()
1376
+ expect(result.amount).toBe(tkeyAmount)
1377
+ expect(result.totalSpent).toBe(2000n) // only XTZ reveal+tx fees; no token units mixed in
1378
+ })
1379
+
1380
+ it('unrevealed account + fixed-amount FA2 token send: NotEnoughBalance when token balance is insufficient [LIVE-35976]', async () => {
1381
+ const contract = 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU'
1382
+ const tkeyAmount = 10n ** 16n
1383
+
1384
+ mockGetAccountByAddress.mockResolvedValue(makeUserAccount({ revealed: false }))
1385
+ mockGetTokensBalances.mockResolvedValue([
1386
+ {
1387
+ id: 1,
1388
+ account: { address: senderAddress },
1389
+ token: {
1390
+ id: 1,
1391
+ contract: { address: contract },
1392
+ tokenId: '0',
1393
+ standard: 'fa2' as const,
1394
+ metadata: { symbol: 'TKEY', decimals: '18' },
1395
+ },
1396
+ balance: (tkeyAmount - 1n).toString(), // one unit short
1397
+ transfersCount: 0,
1398
+ firstLevel: 0,
1399
+ firstTime: '',
1400
+ lastLevel: 0,
1401
+ lastTime: '',
1402
+ },
1403
+ ])
1404
+
1405
+ const result = await validateIntent(context, {
1406
+ intentType: 'transaction',
1407
+ asset: { type: 'token', assetReference: `${contract}:0` },
1408
+ type: 'send',
1409
+ sender: senderAddress,
1410
+ recipient: validRecipient,
1411
+ amount: tkeyAmount,
1412
+ })
1413
+
1414
+ expect(mockEstimateFees).not.toHaveBeenCalled()
1415
+ expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
1416
+ })
1417
+
1418
+ it('send-max token: amount stays in token units when taquito reports balance_too_low', async () => {
1419
+ // Regression guard for the optimization that skips fetchTokenBalance when errors.amount is set.
1420
+ // For send-max, tokenBalance is also used as the returned amount — skipping it would cause
1421
+ // calculateAmounts to fall back to the native XTZ path and return mutez instead of token units.
1422
+ const contract = 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU'
1423
+ const tokenBalance = 5_000_000n
1424
+
1425
+ mockEstimateFees.mockResolvedValue({
1426
+ fees: 0n,
1427
+ gasLimit: 0n,
1428
+ storageLimit: 0n,
1429
+ estimatedFees: 0n,
1430
+ taquitoError: 'proto.024-PtTALLiN.contract.balance_too_low',
1431
+ })
1432
+ mockGetTokensBalances.mockResolvedValue([
1433
+ {
1434
+ id: 1,
1435
+ account: { address: senderAddress },
1436
+ token: {
1437
+ id: 1,
1438
+ contract: { address: contract },
1439
+ tokenId: '0',
1440
+ standard: 'fa2' as const,
1441
+ metadata: { symbol: 'TK', decimals: '6' },
1442
+ },
1443
+ balance: tokenBalance.toString(),
1444
+ transfersCount: 0,
1445
+ firstLevel: 0,
1446
+ firstTime: '',
1447
+ lastLevel: 0,
1448
+ lastTime: '',
1449
+ },
1450
+ ])
1451
+
1452
+ const result = await validateIntent(context, {
1453
+ intentType: 'transaction',
1454
+ asset: { type: 'token', assetReference: `${contract}:0` },
1455
+ type: 'send',
1456
+ sender: senderAddress,
1457
+ recipient: validRecipient,
1458
+ amount: 0n,
1459
+ useAllAmount: true,
1460
+ })
1461
+
1462
+ expect(result.errors.amount).toBeInstanceOf(NotEnoughBalance)
1463
+ expect(mockGetTokensBalances).toHaveBeenCalled()
1464
+ expect(result.amount).toBe(tokenBalance)
1465
+ })
1185
1466
  })
1186
1467
 
1187
1468
  describe('native XTZ send max', () => {
@@ -238,6 +238,12 @@ function calculateAmounts(
238
238
  return calculateNativeSendMaxAmountForUser(spendable, estimatedFees, estimatedAmount)
239
239
  }
240
240
 
241
+ // FA1.2/FA2 fixed-amount send: `intent.amount` is in token base units; fees are in XTZ mutez.
242
+ // Never add the token amount to the native coverage check — the units are incompatible.
243
+ if (intent.type === 'send' && parseTezosTokenAsset(intent.asset) !== null) {
244
+ return { amount: intent.amount, totalSpent: estimatedFees }
245
+ }
246
+
241
247
  const amount = intent.amount
242
248
  return { amount, totalSpent: amount + estimatedFees }
243
249
  }
@@ -307,11 +313,11 @@ async function estimateFeesForIntent(
307
313
  }
308
314
  }
309
315
 
310
- async function fetchTokenBalanceForSendMax(
316
+ async function fetchTokenBalance(
311
317
  config: TezosCoinConfig,
312
318
  intent: TransactionIntent
313
319
  ): Promise<bigint | undefined> {
314
- if (intent.type !== 'send' || !intent.useAllAmount) {
320
+ if (intent.type !== 'send') {
315
321
  return undefined
316
322
  }
317
323
 
@@ -383,7 +389,25 @@ export async function validateIntent(
383
389
  estimatedAmount = feeResult.estimatedAmount
384
390
  Object.assign(errors, feeResult.errors)
385
391
 
386
- const tokenBalanceForSendMax = await fetchTokenBalanceForSendMax(config, intent)
392
+ // Skip the TzKT call only for fixed-amount sends where errors.amount is already set — the token
393
+ // balance would only be used for coverage, which is also gated on !errors.amount.
394
+ // For send-max we always fetch: calculateAmounts uses tokenBalanceForSendMax as the sent amount,
395
+ // so skipping would cause it to fall back to the native XTZ path and return a wrong unit.
396
+ // The TzKT call is isolated in its own try-catch so that a network failure here does not reach
397
+ // the outer handler (which would wipe the already-computed estimatedFees and add a spurious
398
+ // errors.estimation on top of the real fee-estimation error).
399
+ let tokenBalance: bigint | undefined
400
+ if (errors.amount && !intent.useAllAmount) {
401
+ tokenBalance = undefined
402
+ } else {
403
+ try {
404
+ tokenBalance = await fetchTokenBalance(config, intent)
405
+ } catch {
406
+ tokenBalance = undefined // TzKT unreachable: fall back gracefully, no token coverage check
407
+ }
408
+ }
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
387
411
 
388
412
  const amounts = calculateAmounts(
389
413
  intent,
@@ -406,6 +430,19 @@ export async function validateIntent(
406
430
  )
407
431
  const balanceErrors = validateBalanceCoverage(spendable, totalSpent)
408
432
  Object.assign(errors, balanceErrors)
433
+
434
+ // Token balance coverage for fixed-amount token sends.
435
+ // (send-max is always valid by construction: amount is set to tokenBalance above.)
436
+ if (
437
+ !errors.amount &&
438
+ intent.type === 'send' &&
439
+ !intent.useAllAmount &&
440
+ tokenBalance !== undefined
441
+ ) {
442
+ if (amount > tokenBalance) {
443
+ errors.amount = new NotEnoughBalance()
444
+ }
445
+ }
409
446
  } catch (e) {
410
447
  errors.estimation = e as Error
411
448
  estimatedFees = 0n