@ledgerhq/coin-tezos 10.2.4 → 10.2.7

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/src/api/index.ts CHANGED
@@ -20,7 +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 { getRevealFee } from '@taquito/taquito'
24
23
  import { getPkhfromPk, validatePublicKey, ValidationResult } from '@taquito/utils'
25
24
  import {
26
25
  broadcast,
@@ -37,6 +36,7 @@ import {
37
36
  rawEncode,
38
37
  validateIntent,
39
38
  } from '../logic'
39
+ import { getRevealFeeForEstimation } from '../logic/estimateFees'
40
40
  import { getTezosToolkit } from '../logic/tezosToolkit'
41
41
  import { validateAddress } from '../logic/validateAddress'
42
42
  import { hasManagerKey } from '../network/types'
@@ -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,6 +324,13 @@ 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
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
+ }
307
334
  context.logger('estimate-error', 'error estimating fees, trying without pubkey', { error })
308
335
  estimation = await logicEstimate()
309
336
  }
@@ -325,6 +352,14 @@ async function estimate(
325
352
  } catch (error: unknown) {
326
353
  // Handle PublicKeyNotFoundError
327
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
+
328
363
  const apiAccount = await api.getAccountByAddress(transactionIntent.recipient)
329
364
  const storageLimit =
330
365
  !hasEmptyBalance(apiAccount) || transactionIntent.type === 'stake' ? 0n : 277n
@@ -350,11 +385,11 @@ async function estimate(
350
385
  } catch {
351
386
  // When estimation fails because the sender is unrevealed (PublicKeyNotFoundError),
352
387
  // fallback to a conservative gas value suitable for typical new-account XTZ transfers.
353
- // 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.
354
389
  // The fee is computed according to Taquito's calculation so it will satisfy the Tezos prefilter rule:
355
390
  // total_fees >= ceil(100 + 0.1*total_gas + op_size)
356
391
  // We use a base of 120 instead of 100 to mimic Taquito and minimize rejected low-fee ops.
357
- const SAFE_FALLBACK_GAS = 2500 // covers typical new-account transfer (~1420) with buffer
392
+ const SAFE_FALLBACK_GAS = 2500
358
393
  const FALLBACK_OP_SIZE_BYTES = 154 // typical forged size for a simple XTZ transfer
359
394
  txGasLimit = BigInt(SAFE_FALLBACK_GAS)
360
395
  baseTxFee = BigInt(
@@ -366,7 +401,7 @@ async function estimate(
366
401
  }
367
402
 
368
403
  const revealFee = needsReveal
369
- ? BigInt(Math.max(config.fees.minFees ?? 0, getRevealFee(transactionIntent.sender)))
404
+ ? BigInt(getRevealFeeForEstimation(config, transactionIntent.sender))
370
405
  : 0n
371
406
  const totalFee = baseTxFee + revealFee
372
407
 
@@ -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'
@@ -76,7 +82,7 @@ export async function craftTransaction(
76
82
 
77
83
  contents.push({
78
84
  kind: OpKind.REVEAL,
79
- fee: reveal.fee.toString(),
85
+ fee: transaction.fee.revealFees ?? reveal.fee.toString(),
80
86
  gas_limit: reveal.gasLimit.toString(),
81
87
  storage_limit: reveal.storageLimit.toString(),
82
88
  source: publicKey.publicKeyHash,
@@ -54,9 +54,19 @@ describe('estimateFees', () => {
54
54
  contract: {
55
55
  at: jest.fn(),
56
56
  },
57
+ rpc: {
58
+ getConstants: jest.fn(),
59
+ },
57
60
  setProvider: jest.fn(),
58
61
  }
59
62
 
63
+ // Mainnet values; the two are equal, which is what makes a reveal + operation batch
64
+ // overflow the block limit unless the operation's gas is capped.
65
+ const MAINNET_GAS_CONSTANTS = {
66
+ hard_gas_limit_per_operation: 1040000,
67
+ hard_gas_limit_per_block: 1040000,
68
+ }
69
+
60
70
  let currentConfig: TezosCoinConfig
61
71
  const context: TezosContext = {
62
72
  config: async () => currentConfig,
@@ -72,6 +82,7 @@ describe('estimateFees', () => {
72
82
  jest
73
83
  .mocked<DeepPartialReturn<typeof getTezosToolkit>>(getTezosToolkit)
74
84
  .mockReturnValue(mockTezosToolkit)
85
+ mockTezosToolkit.rpc.getConstants.mockResolvedValue(MAINNET_GAS_CONSTANTS)
75
86
  currentConfig = mkConfig(defaultFeesConfig)
76
87
  })
77
88
 
@@ -779,6 +790,95 @@ describe('estimateFees', () => {
779
790
  expect(result.amount).toBe(100n)
780
791
  })
781
792
 
793
+ it('send_token unrevealed caps gas so the reveal batch fits the block limit', async () => {
794
+ const { transfer } = setupFa2ContractMock()
795
+ mockTezosToolkit.contract.at.mockResolvedValue({ methods: { transfer } })
796
+ mockTezosToolkit.estimate.transfer.mockResolvedValue({
797
+ suggestedFeeMutez: 600,
798
+ gasLimit: 4000,
799
+ storageLimit: 0,
800
+ burnFeeMutez: 0,
801
+ opSize: 250,
802
+ })
803
+
804
+ await estimateFees(context, {
805
+ account: unrevealedAccount,
806
+ transaction: {
807
+ mode: 'send_token',
808
+ recipient: 'tz1VSUr8wwNhLAzempoch5d6nLRSNtxK8LBr',
809
+ amount: BigInt(100),
810
+ contractAddress: 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU',
811
+ tokenId: 0,
812
+ },
813
+ })
814
+
815
+ // min(1040000, 1040000 / 3): leaves room for the reveal Taquito prepends, so the
816
+ // simulation does not overflow hard_gas_limit_per_block.
817
+ expect(mockTezosToolkit.estimate.transfer).toHaveBeenCalledWith(
818
+ expect.objectContaining({ gasLimit: 346666 })
819
+ )
820
+ })
821
+
822
+ it('send_token derives the gas cap from protocol constants', async () => {
823
+ mockTezosToolkit.rpc.getConstants.mockResolvedValue({
824
+ hard_gas_limit_per_operation: 900000,
825
+ hard_gas_limit_per_block: 2400000,
826
+ })
827
+ const { transfer } = setupFa2ContractMock()
828
+ mockTezosToolkit.contract.at.mockResolvedValue({ methods: { transfer } })
829
+ mockTezosToolkit.estimate.transfer.mockResolvedValue({
830
+ suggestedFeeMutez: 600,
831
+ gasLimit: 4000,
832
+ storageLimit: 0,
833
+ burnFeeMutez: 0,
834
+ opSize: 250,
835
+ })
836
+
837
+ await estimateFees(context, {
838
+ account: unrevealedAccount,
839
+ transaction: {
840
+ mode: 'send_token',
841
+ recipient: 'tz1VSUr8wwNhLAzempoch5d6nLRSNtxK8LBr',
842
+ amount: BigInt(100),
843
+ contractAddress: 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU',
844
+ tokenId: 0,
845
+ },
846
+ })
847
+
848
+ // min(900000, 2400000 / 3): the block-derived bound is the tighter one here.
849
+ expect(mockTezosToolkit.estimate.transfer).toHaveBeenCalledWith(
850
+ expect.objectContaining({ gasLimit: 800000 })
851
+ )
852
+ })
853
+
854
+ it('send_token revealed passes no gas cap', async () => {
855
+ const { transfer } = setupFa2ContractMock()
856
+ mockTezosToolkit.contract.at.mockResolvedValue({ methods: { transfer } })
857
+ mockTezosToolkit.estimate.transfer.mockResolvedValue({
858
+ suggestedFeeMutez: 600,
859
+ gasLimit: 4000,
860
+ storageLimit: 0,
861
+ burnFeeMutez: 0,
862
+ opSize: 250,
863
+ })
864
+
865
+ await estimateFees(context, {
866
+ account: revealedAccount,
867
+ transaction: {
868
+ mode: 'send_token',
869
+ recipient: 'tz1VSUr8wwNhLAzempoch5d6nLRSNtxK8LBr',
870
+ amount: BigInt(100),
871
+ contractAddress: 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU',
872
+ tokenId: 0,
873
+ },
874
+ })
875
+
876
+ // No reveal is prepended, so the batch cannot overflow and Taquito keeps its own default.
877
+ const params = mockTezosToolkit.estimate.transfer.mock.calls.at(-1)?.[0]
878
+ expect(params).not.toHaveProperty('gasLimit')
879
+ expect(mockTezosToolkit.rpc.getConstants).not.toHaveBeenCalled()
880
+ })
881
+
782
882
  it('send_token unrevealed adds reveal fee to estimatedFees', async () => {
783
883
  const { transfer } = setupFa2ContractMock()
784
884
  mockTezosToolkit.contract.at.mockResolvedValue({ methods: { transfer } })
@@ -190,9 +190,13 @@ export async function estimateFees(
190
190
  },
191
191
  ])
192
192
  .toTransferParams({ mutez: true })
193
+ const revealBatchGasLimit = account.revealed
194
+ ? undefined
195
+ : await gasLimitForRevealBatch(tezosToolkit)
193
196
  estimate = await tezosToolkit.estimate.transfer({
194
197
  ...transferParams,
195
198
  source: account.address,
199
+ ...(revealBatchGasLimit !== undefined && { gasLimit: revealBatchGasLimit }),
196
200
  })
197
201
  break
198
202
  }
@@ -405,7 +409,35 @@ function protocolErrorIds(text: string): string[] {
405
409
  }
406
410
  }
407
411
 
408
- function getRevealFeeForEstimation(config: TezosCoinConfig, address: string): number {
412
+ /**
413
+ * Gas limit to hand Taquito when the source account is unrevealed.
414
+ *
415
+ * Taquito prepends a reveal to any operation from an unrevealed account, then gives the
416
+ * operation itself `hard_gas_limit_per_operation` for the simulation. Mainnet sets
417
+ * `hard_gas_limit_per_block` to that same value, so the reveal's own gas tips the batch past
418
+ * the block limit and the node answers HTTP 500 `gas_exhausted.block`. Without a cap the
419
+ * estimation fails and the caller falls back to native-transfer constants, which are far too
420
+ * small for an FA2 transfer.
421
+ *
422
+ * Mirrors Taquito's own batch adjustment, `min(perOperation, perBlock / (opCount + 1))`, so
423
+ * the value tracks the protocol rather than a hardcoded figure.
424
+ */
425
+ async function gasLimitForRevealBatch(
426
+ tezosToolkit: ReturnType<typeof getTezosToolkit>
427
+ ): Promise<number> {
428
+ const constants = await tezosToolkit.rpc.getConstants()
429
+ const perOperation = Number(constants.hard_gas_limit_per_operation)
430
+ const perBlock = Number(constants.hard_gas_limit_per_block)
431
+ const REVEAL_PLUS_OPERATION = 2
432
+ return Math.floor(Math.min(perOperation, perBlock / (REVEAL_PLUS_OPERATION + 1)))
433
+ }
434
+
435
+ /**
436
+ * Reveal fee the estimation reports as part of the total, derived from Taquito's static per-curve
437
+ * table rather than a node estimate. The account is unrevealed and the public key is often absent
438
+ * at estimation time, so `estimate.reveal()` is not reliably available here.
439
+ */
440
+ export function getRevealFeeForEstimation(config: TezosCoinConfig, address: string): number {
409
441
  const minFees = config.fees.minFees ?? 0
410
442
  return Math.max(minFees, getRevealFee(address))
411
443
  }