@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.
- package/CHANGELOG.md +41 -0
- package/jest.config.js +1 -13
- package/lib/api/index.d.ts +36 -2
- package/lib/api/index.d.ts.map +1 -1
- package/lib/api/index.js +11 -14
- package/lib/api/index.js.map +1 -1
- package/lib/api/types.d.ts +5 -16
- package/lib/api/types.d.ts.map +1 -1
- package/lib/logic/validateIntent.d.ts.map +1 -1
- package/lib/logic/validateIntent.js +38 -3
- package/lib/logic/validateIntent.js.map +1 -1
- package/lib-es/api/index.d.ts +36 -2
- package/lib-es/api/index.d.ts.map +1 -1
- package/lib-es/api/index.js +12 -15
- package/lib-es/api/index.js.map +1 -1
- package/lib-es/api/types.d.ts +5 -16
- package/lib-es/api/types.d.ts.map +1 -1
- package/lib-es/logic/validateIntent.d.ts.map +1 -1
- package/lib-es/logic/validateIntent.js +38 -3
- package/lib-es/logic/validateIntent.js.map +1 -1
- package/package.json +2 -3
- package/src/api/index-mainnet.integ.test.ts +1 -2
- package/src/api/index.integ.test.ts +1 -2
- package/src/api/index.test.ts +7 -5
- package/src/api/index.ts +20 -28
- package/src/api/types.ts +5 -17
- package/src/logic/getBalance.test.ts +118 -140
- package/src/logic/validateIntent.test.ts +281 -0
- package/src/logic/validateIntent.ts +40 -3
- package/src/network/bakers.integ.test.ts +4 -11
|
@@ -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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
|
316
|
+
async function fetchTokenBalance(
|
|
311
317
|
config: TezosCoinConfig,
|
|
312
318
|
intent: TransactionIntent
|
|
313
319
|
): Promise<bigint | undefined> {
|
|
314
|
-
if (intent.type !== 'send'
|
|
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
|
-
|
|
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
|