@ledgerhq/coin-tezos 10.2.2 → 10.2.6

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 (121) hide show
  1. package/CHANGELOG.md +53 -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 +49 -26
  5. package/lib/api/index.js.map +1 -1
  6. package/lib/logic/craftRawOperations.js +3 -4
  7. package/lib/logic/craftRawOperations.js.map +1 -1
  8. package/lib/logic/craftTransaction.d.ts +6 -0
  9. package/lib/logic/craftTransaction.d.ts.map +1 -1
  10. package/lib/logic/craftTransaction.js +2 -3
  11. package/lib/logic/craftTransaction.js.map +1 -1
  12. package/lib/logic/estimateFees.d.ts +7 -1
  13. package/lib/logic/estimateFees.d.ts.map +1 -1
  14. package/lib/logic/estimateFees.js +81 -6
  15. package/lib/logic/estimateFees.js.map +1 -1
  16. package/lib/logic/estimateRevealLimits.d.ts +2 -2
  17. package/lib/logic/estimateRevealLimits.d.ts.map +1 -1
  18. package/lib/logic/estimateRevealLimits.js +3 -3
  19. package/lib/logic/estimateRevealLimits.js.map +1 -1
  20. package/lib/logic/getBalance.d.ts.map +1 -1
  21. package/lib/logic/getBalance.js +2 -3
  22. package/lib/logic/getBalance.js.map +1 -1
  23. package/lib/logic/getBlock.js +6 -6
  24. package/lib/logic/getBlock.js.map +1 -1
  25. package/lib/logic/getStakes.d.ts +2 -1
  26. package/lib/logic/getStakes.d.ts.map +1 -1
  27. package/lib/logic/getStakes.js +8 -9
  28. package/lib/logic/getStakes.js.map +1 -1
  29. package/lib/logic/listOperations.d.ts.map +1 -1
  30. package/lib/logic/listOperations.js +16 -16
  31. package/lib/logic/listOperations.js.map +1 -1
  32. package/lib/logic/validateIntent.d.ts.map +1 -1
  33. package/lib/logic/validateIntent.js +14 -2
  34. package/lib/logic/validateIntent.js.map +1 -1
  35. package/lib/network/bakers.d.ts +6 -5
  36. package/lib/network/bakers.d.ts.map +1 -1
  37. package/lib/network/bakers.js +13 -12
  38. package/lib/network/bakers.js.map +1 -1
  39. package/lib/network/tzkt.d.ts +8 -8
  40. package/lib/network/tzkt.d.ts.map +1 -1
  41. package/lib/network/tzkt.js +13 -11
  42. package/lib/network/tzkt.js.map +1 -1
  43. package/lib/types/errors.d.ts +3 -0
  44. package/lib/types/errors.d.ts.map +1 -1
  45. package/lib/types/errors.js +8 -1
  46. package/lib/types/errors.js.map +1 -1
  47. package/lib/utils.d.ts +17 -0
  48. package/lib/utils.d.ts.map +1 -1
  49. package/lib/utils.js +31 -1
  50. package/lib/utils.js.map +1 -1
  51. package/lib-es/api/index.d.ts +1 -3
  52. package/lib-es/api/index.d.ts.map +1 -1
  53. package/lib-es/api/index.js +50 -27
  54. package/lib-es/api/index.js.map +1 -1
  55. package/lib-es/logic/craftRawOperations.js +3 -4
  56. package/lib-es/logic/craftRawOperations.js.map +1 -1
  57. package/lib-es/logic/craftTransaction.d.ts +6 -0
  58. package/lib-es/logic/craftTransaction.d.ts.map +1 -1
  59. package/lib-es/logic/craftTransaction.js +2 -3
  60. package/lib-es/logic/craftTransaction.js.map +1 -1
  61. package/lib-es/logic/estimateFees.d.ts +7 -1
  62. package/lib-es/logic/estimateFees.d.ts.map +1 -1
  63. package/lib-es/logic/estimateFees.js +82 -8
  64. package/lib-es/logic/estimateFees.js.map +1 -1
  65. package/lib-es/logic/estimateRevealLimits.d.ts +2 -2
  66. package/lib-es/logic/estimateRevealLimits.d.ts.map +1 -1
  67. package/lib-es/logic/estimateRevealLimits.js +3 -3
  68. package/lib-es/logic/estimateRevealLimits.js.map +1 -1
  69. package/lib-es/logic/getBalance.d.ts.map +1 -1
  70. package/lib-es/logic/getBalance.js +2 -3
  71. package/lib-es/logic/getBalance.js.map +1 -1
  72. package/lib-es/logic/getBlock.js +6 -6
  73. package/lib-es/logic/getBlock.js.map +1 -1
  74. package/lib-es/logic/getStakes.d.ts +2 -1
  75. package/lib-es/logic/getStakes.d.ts.map +1 -1
  76. package/lib-es/logic/getStakes.js +8 -9
  77. package/lib-es/logic/getStakes.js.map +1 -1
  78. package/lib-es/logic/listOperations.d.ts.map +1 -1
  79. package/lib-es/logic/listOperations.js +16 -16
  80. package/lib-es/logic/listOperations.js.map +1 -1
  81. package/lib-es/logic/validateIntent.d.ts.map +1 -1
  82. package/lib-es/logic/validateIntent.js +16 -4
  83. package/lib-es/logic/validateIntent.js.map +1 -1
  84. package/lib-es/network/bakers.d.ts +6 -5
  85. package/lib-es/network/bakers.d.ts.map +1 -1
  86. package/lib-es/network/bakers.js +13 -12
  87. package/lib-es/network/bakers.js.map +1 -1
  88. package/lib-es/network/tzkt.d.ts +8 -8
  89. package/lib-es/network/tzkt.d.ts.map +1 -1
  90. package/lib-es/network/tzkt.js +13 -11
  91. package/lib-es/network/tzkt.js.map +1 -1
  92. package/lib-es/types/errors.d.ts +3 -0
  93. package/lib-es/types/errors.d.ts.map +1 -1
  94. package/lib-es/types/errors.js +6 -0
  95. package/lib-es/types/errors.js.map +1 -1
  96. package/lib-es/utils.d.ts +17 -0
  97. package/lib-es/utils.d.ts.map +1 -1
  98. package/lib-es/utils.js +29 -0
  99. package/lib-es/utils.js.map +1 -1
  100. package/package.json +3 -4
  101. package/src/api/index.test.ts +208 -0
  102. package/src/api/index.ts +55 -28
  103. package/src/logic/craftRawOperations.ts +3 -4
  104. package/src/logic/craftTransaction.test.ts +30 -0
  105. package/src/logic/craftTransaction.ts +8 -3
  106. package/src/logic/estimateFees.test.ts +224 -2
  107. package/src/logic/estimateFees.ts +92 -7
  108. package/src/logic/estimateRevealLimits.ts +11 -7
  109. package/src/logic/getBalance.ts +9 -6
  110. package/src/logic/getBlock.ts +6 -6
  111. package/src/logic/getStakes.ts +13 -8
  112. package/src/logic/listOperations.ts +23 -13
  113. package/src/logic/validateIntent.test.ts +90 -1
  114. package/src/logic/validateIntent.ts +18 -2
  115. package/src/network/bakers.integ.test.ts +3 -2
  116. package/src/network/bakers.test.ts +2 -1
  117. package/src/network/bakers.ts +15 -13
  118. package/src/network/tzkt.test.ts +29 -30
  119. package/src/network/tzkt.ts +38 -23
  120. package/src/types/errors.ts +7 -0
  121. package/src/utils.ts +32 -0
@@ -8,6 +8,8 @@ import {
8
8
  createSendTransactionIntentMockData,
9
9
  createStakingTransactionIntentMockData,
10
10
  } from '@ledgerhq/coin-module-framework/test-utils'
11
+ import { getRevealFee } from '@taquito/taquito'
12
+ import { MAPPED_TAQUITO_ERRORS } from '../utils'
11
13
  import { createApi } from './index'
12
14
 
13
15
  // The tzkt factory `createTzktApi(config)` builds a fresh object on every call, so a spy on a
@@ -298,6 +300,69 @@ describe('craftTransaction', () => {
298
300
  )
299
301
  })
300
302
 
303
+ it('splits the reported total so the crafted batch fees sum back to it', async () => {
304
+ // 300 no longer clamps both former reveal-fee sources to the same floor: the estimation
305
+ // reported Taquito's static constant while the reveal carried the node's live suggestion.
306
+ const minFees = 300
307
+ const unrevealedSender = 'tz1VSUr8wwNhLAzempoch5d6nLRSNtxK8LBr'
308
+ const txFee = 2000n
309
+ const staticRevealFee = BigInt(getRevealFee(unrevealedSender))
310
+ const reportedTotal = txFee + staticRevealFee
311
+
312
+ const contextLowMinFees = makeContext({
313
+ status: { type: 'active' },
314
+ baker: { url: 'https://baker.example.com' },
315
+ explorer: { url: 'foo', maxTxQuery: 1 },
316
+ node: { url: 'bar' },
317
+ fees: {
318
+ minGasLimit: 600,
319
+ minRevealGasLimit: 300,
320
+ minStorageLimit: 0,
321
+ minFees,
322
+ minEstimatedFees: minFees,
323
+ },
324
+ })
325
+
326
+ const unrevealedAccount: APIAccount = {
327
+ type: 'user',
328
+ balance: 1000000,
329
+ revealed: false,
330
+ address: unrevealedSender,
331
+ publicKey: 'edpktest',
332
+ counter: 0,
333
+ delegationLevel: 0,
334
+ delegationTime: '2021-01-01T00:00:00Z',
335
+ numTransactions: 0,
336
+ firstActivityTime: '2021-01-01T00:00:00Z',
337
+ }
338
+ mockGetAccountByAddress
339
+ .mockResolvedValueOnce(unrevealedAccount)
340
+ .mockResolvedValueOnce(unrevealedAccount)
341
+
342
+ logicEstimateFees.mockResolvedValue({
343
+ estimatedFees: reportedTotal,
344
+ fees: txFee,
345
+ gasLimit: 10000n,
346
+ storageLimit: 0n,
347
+ parameters: { gasLimit: 10000n, storageLimit: 0n, txFee },
348
+ })
349
+
350
+ await api.craftTransaction(
351
+ contextLowMinFees,
352
+ createSendTransactionIntentMockData({
353
+ type: 'send',
354
+ sender: unrevealedSender,
355
+ senderPublicKey: '00d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a',
356
+ recipient: 'tz1recipient',
357
+ amount: 1000n,
358
+ })
359
+ )
360
+
361
+ const craftedFee = logicCraftTransactionMock.mock.calls[0][2].fee
362
+ expect(BigInt(craftedFee.fees) + BigInt(craftedFee.revealFees)).toBe(reportedTotal)
363
+ expect(craftedFee.revealFees).toBe(staticRevealFee.toString())
364
+ })
365
+
301
366
  it('craft transaction with customFee splits total for unrevealed delegate so total equals customFee', async () => {
302
367
  const minFees = 1000
303
368
  const unrevealedSender = 'tz2TaTpo31sAiX2HBJUTLLdUnqVJR4QjLy1V'
@@ -584,6 +649,118 @@ describe('estimateFees', () => {
584
649
  expect(result.parameters?.storageLimit).toBe(DEFAULT_STORAGE_LIMIT)
585
650
  })
586
651
 
652
+ it('does not retry keyless for a token estimate after an unrelated failure', async () => {
653
+ // A keyless retry here would surface as PublicKeyNotFoundError and hide the real error.
654
+ const nodeError = Object.assign(new Error('Http error response: (500)'), {
655
+ status: 500,
656
+ body: '[{"kind":"permanent","id":"proto.025-PsUshuai.gas_limit_too_high"}]',
657
+ })
658
+ logicEstimateFees.mockRejectedValue(nodeError)
659
+
660
+ await expect(
661
+ api.estimateFees(context, {
662
+ intentType: 'transaction',
663
+ type: 'send',
664
+ sender: 'tz1test',
665
+ recipient: 'tz1recipient',
666
+ amount: 1000n,
667
+ asset: { type: 'token', assetReference: 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU:0' },
668
+ })
669
+ ).rejects.toThrow('Http error response: (500)')
670
+
671
+ expect(logicEstimateFees).toHaveBeenCalledTimes(1)
672
+ logicEstimateFees.mockReset()
673
+ })
674
+
675
+ it('still retries keyless for a native estimate after an unrelated failure', async () => {
676
+ // Native estimation has no gas cap yet, so it hits the same block-gas overflow and depends
677
+ // on the keyless retry to reach the fallback. Without it an ordinary send reports no fees.
678
+ const blockOverflow = Object.assign(new Error('Http error response: (500)'), {
679
+ status: 500,
680
+ body: '[{"kind":"temporary","id":"proto.025-PsUshuai.gas_exhausted.block"}]',
681
+ })
682
+ logicEstimateFees
683
+ .mockRejectedValueOnce(blockOverflow)
684
+ .mockRejectedValueOnce(new Error('Public key not found'))
685
+
686
+ const unrevealedSender = 'tz1unrevealed'
687
+ const unrevealedAccount: APIAccount = {
688
+ type: 'user',
689
+ balance: 1000000,
690
+ revealed: false,
691
+ address: unrevealedSender,
692
+ publicKey: undefined,
693
+ counter: 0,
694
+ delegationLevel: 0,
695
+ delegationTime: '2021-01-01T00:00:00Z',
696
+ numTransactions: 0,
697
+ firstActivityTime: '2021-01-01T00:00:00Z',
698
+ }
699
+ mockGetAccountByAddress.mockResolvedValue(unrevealedAccount)
700
+ mockGetTezosToolkit.mockReturnValueOnce({
701
+ estimate: { transfer: jest.fn().mockRejectedValue(new Error('Public key not found')) },
702
+ })
703
+
704
+ const result = await api.estimateFees(
705
+ context,
706
+ createSendTransactionIntentMockData({
707
+ type: 'send',
708
+ sender: unrevealedSender,
709
+ recipient: 'tz1recipient',
710
+ amount: 1000n,
711
+ })
712
+ )
713
+
714
+ expect(logicEstimateFees).toHaveBeenCalledTimes(2)
715
+ expect(result.parameters?.gasLimit).toBe(2500n)
716
+ logicEstimateFees.mockReset()
717
+ })
718
+
719
+ it('retries keyless for contract.manager.inconsistent_hash', async () => {
720
+ const inconsistentHash = Object.assign(new Error('Http error response: (500)'), {
721
+ status: 500,
722
+ body: '[{"kind":"permanent","id":"proto.025-PsUshuai.contract.manager.inconsistent_hash"}]',
723
+ })
724
+ logicEstimateFees.mockRejectedValueOnce(inconsistentHash).mockResolvedValueOnce({
725
+ estimatedFees: DEFAULT_ESTIMATED_FEES,
726
+ gasLimit: DEFAULT_GAS_LIMIT,
727
+ storageLimit: DEFAULT_STORAGE_LIMIT,
728
+ })
729
+
730
+ const result = await api.estimateFees(
731
+ context,
732
+ createSendTransactionIntentMockData({
733
+ type: 'send',
734
+ sender: 'tz1test',
735
+ recipient: 'tz1recipient',
736
+ amount: 1000n,
737
+ asset: { type: 'token', assetReference: 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU:0' },
738
+ })
739
+ )
740
+
741
+ expect(logicEstimateFees).toHaveBeenCalledTimes(2)
742
+ expect(result.parameters?.gasLimit).toBe(DEFAULT_GAS_LIMIT)
743
+ logicEstimateFees.mockReset()
744
+ })
745
+
746
+ it('does not answer a token estimate with native fallback constants', async () => {
747
+ // Native fallback limits under-provision an FA2 transfer, which then fails on chain.
748
+ logicEstimateFees.mockRejectedValue(new Error('Public key not found'))
749
+
750
+ await expect(
751
+ api.estimateFees(context, {
752
+ intentType: 'transaction',
753
+ type: 'send',
754
+ sender: 'tz1test',
755
+ recipient: 'tz1recipient',
756
+ amount: 1000n,
757
+ asset: { type: 'token', assetReference: 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU:0' },
758
+ })
759
+ ).rejects.toThrow('Public key not found')
760
+
761
+ logicEstimateFees.mockReset()
762
+ })
763
+
587
764
  it('should not throw for delegate.unchanged errors', async () => {
588
765
  logicEstimateFees.mockResolvedValue({
589
766
  estimatedFees: DEFAULT_ESTIMATED_FEES,
@@ -641,6 +818,37 @@ describe('estimateFees', () => {
641
818
  })
642
819
  })
643
820
 
821
+ it.each(MAPPED_TAQUITO_ERRORS)(
822
+ 'should not throw for %s, which validateIntent maps',
823
+ async (id) => {
824
+ logicEstimateFees.mockResolvedValue({
825
+ estimatedFees: DEFAULT_ESTIMATED_FEES,
826
+ gasLimit: DEFAULT_GAS_LIMIT,
827
+ storageLimit: DEFAULT_STORAGE_LIMIT,
828
+ taquitoError: `proto.025-PsUshuai.${id}`,
829
+ })
830
+ const result = await api.estimateFees(
831
+ context,
832
+ createStakingTransactionIntentMockData({
833
+ type: 'stake',
834
+ mode: 'delegate',
835
+ sender: 'tz1test',
836
+ recipient: 'tz1validator',
837
+ amount: 1n,
838
+ valAddress: 'tz1validator',
839
+ })
840
+ )
841
+
842
+ expect(result).toEqual({
843
+ value: DEFAULT_ESTIMATED_FEES,
844
+ parameters: {
845
+ gasLimit: DEFAULT_GAS_LIMIT,
846
+ storageLimit: DEFAULT_STORAGE_LIMIT,
847
+ },
848
+ })
849
+ }
850
+ )
851
+
644
852
  it('returns total estimation for unrevealed delegate when minFees applied (composite)', async () => {
645
853
  const unrevealedSender = 'tz2TaTpo31sAiX2HBJUTLLdUnqVJR4QjLy1V'
646
854
  mockGetAccountByAddress.mockResolvedValueOnce({
package/src/api/index.ts CHANGED
@@ -20,8 +20,6 @@ import type {
20
20
  import { rejectBalanceOptions } from '@ledgerhq/coin-module-framework/api/getBalance/rejectBalanceOptions'
21
21
  import { IncorrectTypeError } from '@ledgerhq/coin-module-framework/api/index'
22
22
  import { craftTransactionData } from '@ledgerhq/coin-module-framework/logic/craftTransactionData'
23
- import { log } from '@ledgerhq/logs'
24
- import { getRevealFee } from '@taquito/taquito'
25
23
  import { getPkhfromPk, validatePublicKey, ValidationResult } from '@taquito/utils'
26
24
  import {
27
25
  broadcast,
@@ -38,6 +36,7 @@ import {
38
36
  rawEncode,
39
37
  validateIntent,
40
38
  } from '../logic'
39
+ import { getRevealFeeForEstimation } from '../logic/estimateFees'
41
40
  import { getTezosToolkit } from '../logic/tezosToolkit'
42
41
  import { validateAddress } from '../logic/validateAddress'
43
42
  import { hasManagerKey } from '../network/types'
@@ -45,6 +44,7 @@ import { createTzktApi } from '../network/tzkt'
45
44
  import {
46
45
  DUST_MARGIN_MUTEZ,
47
46
  hasEmptyBalance,
47
+ isMappedTaquitoError,
48
48
  normalizePublicKeyForAddress,
49
49
  parseTezosTokenAsset,
50
50
  resolveTezosOperationMode,
@@ -107,6 +107,27 @@ function isTezosTransactionType(
107
107
  ): type is 'send' | 'delegate' | 'undelegate' | 'stake' | 'unstake' | 'finalize_unstake' {
108
108
  return ['send', 'delegate', 'undelegate', 'stake', 'unstake', 'finalize_unstake'].includes(type)
109
109
  }
110
+ /**
111
+ * Whether a failed estimation is the `contract.manager.inconsistent_hash` case that the
112
+ * keyless retry exists for. Taquito surfaces it as an HTTP error whose body carries the
113
+ * protocol error id.
114
+ */
115
+ function isInconsistentHashError(error: unknown): boolean {
116
+ if (typeof error !== 'object' || error === null) {
117
+ return false
118
+ }
119
+ const parts: unknown[] = []
120
+ if ('message' in error) {
121
+ parts.push(error.message)
122
+ }
123
+ if ('body' in error) {
124
+ parts.push(error.body)
125
+ }
126
+ return parts.some(
127
+ (part) => typeof part === 'string' && part.includes('contract.manager.inconsistent_hash')
128
+ )
129
+ }
130
+
110
131
  async function craft(
111
132
  context: TezosContext,
112
133
  transactionIntent: TransactionIntent,
@@ -160,25 +181,24 @@ async function craft(
160
181
  const needsReveal = hasManagerKey(senderApiAcc) && !senderApiAcc.revealed
161
182
  const totalFee = Number(fee.fees || '0')
162
183
 
163
- const feesConfig = config.fees
164
184
  const revealFeeForSplit = needsReveal
165
- ? Math.max(feesConfig.minFees ?? 0, getRevealFee(transactionIntent.sender))
185
+ ? getRevealFeeForEstimation(config, transactionIntent.sender)
166
186
  : 0
167
187
 
168
- let txFee: number
169
- if (customFees) {
170
- txFee = needsReveal ? Math.max(totalFee - revealFeeForSplit, 0) : totalFee
171
- } else if (estimation.parameters?.txFee !== undefined) {
172
- txFee = Number(estimation.parameters.txFee)
173
- } else {
174
- txFee = needsReveal ? Math.max(totalFee - revealFeeForSplit, 0) : totalFee
175
- }
188
+ // The device compares the sum of the batch fees against the single total it was handed, so the
189
+ // split has to close exactly.
190
+ const desiredTxFee =
191
+ !customFees && estimation.parameters?.txFee !== undefined
192
+ ? Number(estimation.parameters.txFee)
193
+ : totalFee - revealFeeForSplit
194
+ const txFee = Math.min(Math.max(desiredTxFee, 0), totalFee)
195
+ const revealFee = totalFee - txFee
176
196
 
177
197
  const txForCraft = {
178
198
  type: mappedType,
179
199
  recipient: transactionIntent.recipient,
180
200
  amount: amountToUse,
181
- fee: { ...fee, fees: txFee.toString() },
201
+ fee: { ...fee, fees: txFee.toString(), revealFees: revealFee.toString() },
182
202
  ...(tokenCraftInfo && {
183
203
  contractAddress: tokenCraftInfo.contractAddress,
184
204
  tokenId: tokenCraftInfo.tokenId,
@@ -304,21 +324,20 @@ async function estimate(
304
324
  // it's not clear why this happens, it couldn't be further investigated
305
325
  // so we fallback to make an estimation without the public key
306
326
  // there is a test that covers this, see "fallback to an estimation without the public key" index-mainnet.integ.test.ts
307
- log('estimate-error', 'error estimating fees, trying without pubkey', { error })
327
+ //
328
+ // A token estimate retries only for that error. Otherwise the keyless retry throws
329
+ // PublicKeyNotFoundError, and the native-transfer fallback answering it under-provisions
330
+ // the FA2 transfer. Other modes still depend on reaching that fallback.
331
+ if (tezosModeForEstimate === 'send_token' && !isInconsistentHashError(error)) {
332
+ throw error
333
+ }
334
+ context.logger('estimate-error', 'error estimating fees, trying without pubkey', { error })
308
335
  estimation = await logicEstimate()
309
336
  }
310
337
 
311
- if (
312
- estimation.taquitoError &&
313
- !estimation.taquitoError.includes('delegate.unchanged') &&
314
- !estimation.taquitoError.includes('subtraction_underflow') &&
315
- !estimation.taquitoError.includes('balance_too_low') &&
316
- !estimation.taquitoError.includes('script_rejected') &&
317
- !estimation.taquitoError.includes(
318
- 'cannot_stake_with_unfinalizable_unstake_requests_to_another_delegate'
319
- )
320
- ) {
321
- throw new Error(`Fees estimation failed: ${estimation.taquitoError}`)
338
+ const { taquitoError } = estimation
339
+ if (taquitoError && !isMappedTaquitoError(taquitoError)) {
340
+ throw new Error(`Fees estimation failed: ${taquitoError}`)
322
341
  }
323
342
 
324
343
  return {
@@ -333,6 +352,14 @@ async function estimate(
333
352
  } catch (error: unknown) {
334
353
  // Handle PublicKeyNotFoundError
335
354
  if (error instanceof Error && error.message.includes('Public key not found')) {
355
+ // Everything below describes a native tez transfer. An FA2 transfer costs several times
356
+ // more gas and the amounts vary per contract, so these values silently under-provision
357
+ // it: the operation is crafted correctly and the chain rejects it with gas_exhausted,
358
+ // charging the fee.
359
+ if (tezosModeForEstimate === 'send_token') {
360
+ throw error
361
+ }
362
+
336
363
  const apiAccount = await api.getAccountByAddress(transactionIntent.recipient)
337
364
  const storageLimit =
338
365
  !hasEmptyBalance(apiAccount) || transactionIntent.type === 'stake' ? 0n : 277n
@@ -358,11 +385,11 @@ async function estimate(
358
385
  } catch {
359
386
  // When estimation fails because the sender is unrevealed (PublicKeyNotFoundError),
360
387
  // fallback to a conservative gas value suitable for typical new-account XTZ transfers.
361
- // This buffer (~2500 gas) is more than enough for a standard transfer that actually uses ~1420 gas.
388
+ // A standard transfer consumes about 2169 gas, so 2500 leaves a small margin.
362
389
  // The fee is computed according to Taquito's calculation so it will satisfy the Tezos prefilter rule:
363
390
  // total_fees >= ceil(100 + 0.1*total_gas + op_size)
364
391
  // We use a base of 120 instead of 100 to mimic Taquito and minimize rejected low-fee ops.
365
- const SAFE_FALLBACK_GAS = 2500 // covers typical new-account transfer (~1420) with buffer
392
+ const SAFE_FALLBACK_GAS = 2500
366
393
  const FALLBACK_OP_SIZE_BYTES = 154 // typical forged size for a simple XTZ transfer
367
394
  txGasLimit = BigInt(SAFE_FALLBACK_GAS)
368
395
  baseTxFee = BigInt(
@@ -374,7 +401,7 @@ async function estimate(
374
401
  }
375
402
 
376
403
  const revealFee = needsReveal
377
- ? BigInt(Math.max(config.fees.minFees ?? 0, getRevealFee(transactionIntent.sender)))
404
+ ? BigInt(getRevealFeeForEstimation(config, transactionIntent.sender))
378
405
  : 0n
379
406
  const totalFee = baseTxFee + revealFee
380
407
 
@@ -98,7 +98,7 @@ export async function craftRawOperations(
98
98
  if (getPkhfromPk(normalizedPk) !== sender) {
99
99
  throw new Error('craftRawOperations: public key does not match the sender address')
100
100
  }
101
- const reveal = await estimateRevealLimits(tezosToolkit, sender, feesConfig)
101
+ const reveal = await estimateRevealLimits(context, tezosToolkit, sender)
102
102
  contents.push({
103
103
  kind: OpKind.REVEAL,
104
104
  source: sender,
@@ -232,9 +232,8 @@ async function estimateOperations(
232
232
  `craftRawOperations: expected ${expected} estimate(s) for ${operations.length} operation(s), got ${estimates.length}`
233
233
  )
234
234
  }
235
- // Keep the trailing N estimates, dropping a prepended reveal estimate if present. The
236
- // REVEAL op itself uses estimateRevealLimits (config-clamped, consistent with
237
- // craftTransaction) rather than this batch-prepended value, which is discarded.
235
+ // Keep the trailing N estimates, dropping a prepended reveal estimate if present. The REVEAL
236
+ // op itself uses the config-clamped estimateRevealLimits value, not this one.
238
237
  return estimates.slice(estimates.length - operations.length)
239
238
  }
240
239
 
@@ -177,6 +177,36 @@ describe('craftTransaction', () => {
177
177
  ])
178
178
  })
179
179
 
180
+ it('stamps the reveal fee the caller supplies, not the node estimate', async () => {
181
+ // The device asserts that the batch fees sum to the single total the caller reported, which a
182
+ // reveal priced from the node would break.
183
+ mockTezosToolkit.rpc.getContract.mockResolvedValue({ counter: '1' })
184
+ mockTezosToolkit.estimate.reveal.mockResolvedValue({
185
+ gasLimit: 271,
186
+ storageLimit: 0,
187
+ suggestedFeeMutez: 310,
188
+ })
189
+ currentConfig = {
190
+ ...mockConfig,
191
+ fees: { ...mockConfig.fees, minFees: 0, minRevealGasLimit: 0 },
192
+ }
193
+
194
+ const account = { address: 'tz1...' }
195
+ const transaction: TransactionInput = {
196
+ type: 'send',
197
+ recipient: 'tz2...',
198
+ amount: BigInt(1000),
199
+ fee: { fees: '100', gasLimit: '200', storageLimit: '300', revealFees: '334' },
200
+ }
201
+ const publicKey = { publicKey: 'publicKey', publicKeyHash: 'publicKeyHash' }
202
+
203
+ const result = await craftTransaction(context, account, transaction, publicKey)
204
+
205
+ expect(result.contents[0]).toEqual(
206
+ expect.objectContaining({ kind: OpKind.REVEAL, fee: '334', gas_limit: '271' })
207
+ )
208
+ })
209
+
180
210
  describe('when suggested fee is above or below minFees floor', () => {
181
211
  it('craft transaction when default estimation is greater than minFees', async () => {
182
212
  mockTezosToolkit.rpc.getContract.mockResolvedValue({ counter: '1' })
@@ -13,6 +13,12 @@ export type TransactionFee = {
13
13
  fees?: string
14
14
  gasLimit?: string
15
15
  storageLimit?: string
16
+ /**
17
+ * Fee to stamp on the leading reveal operation. The caller sets it so the batch fees sum to the
18
+ * total it already reported. Without it the reveal keeps its own node estimate, which that
19
+ * total never accounted for.
20
+ */
21
+ revealFees?: string
16
22
  }
17
23
  export type TransactionType =
18
24
  | 'OUT'
@@ -72,12 +78,11 @@ export async function craftTransaction(
72
78
  const contents: OperationContents[] = []
73
79
 
74
80
  if (publicKey !== undefined) {
75
- const feesConfig = config.fees
76
- const reveal = await estimateRevealLimits(tezosToolkit, address, feesConfig)
81
+ const reveal = await estimateRevealLimits(context, tezosToolkit, address)
77
82
 
78
83
  contents.push({
79
84
  kind: OpKind.REVEAL,
80
- fee: reveal.fee.toString(),
85
+ fee: transaction.fee.revealFees ?? reveal.fee.toString(),
81
86
  gas_limit: reveal.gasLimit.toString(),
82
87
  storage_limit: reveal.storageLimit.toString(),
83
88
  source: publicKey.publicKeyHash,