@circle-fin/provider-cctp-v2 1.0.5 → 1.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/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Copyright (c) 2025, Circle Internet Group, Inc. All rights reserved.
2
+ * Copyright (c) 2026, Circle Internet Group, Inc. All rights reserved.
3
3
  *
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  *
@@ -379,8 +379,11 @@ const ArcTestnet = defineChain({
379
379
  name: 'Arc Testnet',
380
380
  title: 'ArcTestnet',
381
381
  nativeCurrency: {
382
- name: 'Arc',
383
- symbol: 'Arc',
382
+ name: 'USDC',
383
+ symbol: 'USDC',
384
+ // Arc uses native USDC with 18 decimals for gas payments (EVM standard).
385
+ // Note: The ERC-20 USDC contract at usdcAddress uses 6 decimals.
386
+ // See: https://docs.arc.network/arc/references/contract-addresses
384
387
  decimals: 18,
385
388
  },
386
389
  chainId: 5042002,
@@ -3014,7 +3017,9 @@ const makeApiRequest = async (url, method, isValidType, config, body) => {
3014
3017
  signal: controller.signal,
3015
3018
  };
3016
3019
  // Add body for methods that support it
3017
- if (body !== undefined && ['POST', 'PUT', 'PATCH'].includes(method)) ;
3020
+ if (body !== undefined && ['POST', 'PUT', 'PATCH'].includes(method)) {
3021
+ requestInit.body = JSON.stringify(body);
3022
+ }
3018
3023
  const response = await fetch(url, requestInit);
3019
3024
  clearTimeout(timeoutId);
3020
3025
  if (!response.ok) {
@@ -3192,6 +3197,30 @@ const pollApiWithValidation = async (url, method, isValidType, config = {}, body
3192
3197
  const pollApiGet = async (url, isValidType, config) => {
3193
3198
  return pollApiWithValidation(url, 'GET', isValidType, config);
3194
3199
  };
3200
+ /**
3201
+ * Convenience function for making POST requests with validation.
3202
+ *
3203
+ * @typeParam TResponseType - The expected response type after validation
3204
+ * @typeParam TBody - The type of the request body
3205
+ * @param url - The API endpoint URL
3206
+ * @param body - The request body
3207
+ * @param isValidType - Type guard function to validate the response
3208
+ * @param config - Optional configuration overrides
3209
+ * @returns Promise resolving to the validated response
3210
+ *
3211
+ * @example
3212
+ * ```typescript
3213
+ * const result = await pollApiPost(
3214
+ * 'https://api.example.com/submit',
3215
+ * { name: 'John', email: 'john@example.com' },
3216
+ * isSubmissionResponse,
3217
+ * { maxRetries: 3 }
3218
+ * )
3219
+ * ```
3220
+ */
3221
+ const pollApiPost = async (url, body, isValidType, config) => {
3222
+ return pollApiWithValidation(url, 'POST', isValidType, config, body);
3223
+ };
3195
3224
 
3196
3225
  /**
3197
3226
  * Valid recoverability values for error handling strategies.
@@ -3399,6 +3428,24 @@ function validateErrorDetails(details) {
3399
3428
  * stays within KitError's constraints.
3400
3429
  */
3401
3430
  const MAX_MESSAGE_LENGTH = 950;
3431
+ /**
3432
+ * Standard error message for invalid amount format.
3433
+ *
3434
+ * The SDK enforces strict dot-decimal notation for amount values. This constant
3435
+ * provides a consistent error message when users provide amounts with:
3436
+ * - Comma decimals (e.g., "1,5")
3437
+ * - Thousand separators (e.g., "1,000.50")
3438
+ * - Non-numeric characters
3439
+ * - Invalid format
3440
+ */
3441
+ const AMOUNT_FORMAT_ERROR_MESSAGE = 'Amount must be a numeric string with dot (.) as decimal separator (e.g., "10.5", "100"), with no thousand separators or comma decimals';
3442
+ /**
3443
+ * Error message for invalid maxFee format.
3444
+ *
3445
+ * Used when validating the maxFee configuration parameter. The maxFee can be zero
3446
+ * or positive and must follow strict dot-decimal notation.
3447
+ */
3448
+ const MAX_FEE_FORMAT_ERROR_MESSAGE = 'maxFee must be a numeric string with dot (.) as decimal separator (e.g., "1", "0.5", ".5", "1.5"), with no thousand separators or comma decimals';
3402
3449
 
3403
3450
  /**
3404
3451
  * Structured error class for Stablecoin Kit operations.
@@ -3594,6 +3641,12 @@ const BalanceError = {
3594
3641
  code: 9001,
3595
3642
  name: 'BALANCE_INSUFFICIENT_TOKEN',
3596
3643
  type: 'BALANCE',
3644
+ },
3645
+ /** Insufficient native token (ETH/SOL/etc) for gas fees */
3646
+ INSUFFICIENT_GAS: {
3647
+ code: 9002,
3648
+ name: 'BALANCE_INSUFFICIENT_GAS',
3649
+ type: 'BALANCE',
3597
3650
  }};
3598
3651
 
3599
3652
  /**
@@ -3816,7 +3869,7 @@ function createValidationErrorFromZod(zodError, context) {
3816
3869
  *
3817
3870
  * @param chain - The blockchain network where the balance check failed
3818
3871
  * @param token - The token symbol (e.g., 'USDC', 'ETH')
3819
- * @param rawError - The original error from the underlying system (optional)
3872
+ * @param trace - Optional trace context to include in error (can include rawError and additional debugging data)
3820
3873
  * @returns KitError with insufficient token balance details
3821
3874
  *
3822
3875
  * @example
@@ -3829,24 +3882,71 @@ function createValidationErrorFromZod(zodError, context) {
3829
3882
  *
3830
3883
  * @example
3831
3884
  * ```typescript
3832
- * // With raw error for debugging
3885
+ * // With trace context for debugging
3833
3886
  * try {
3834
3887
  * await transfer(...)
3835
3888
  * } catch (error) {
3836
- * throw createInsufficientTokenBalanceError('Base', 'USDC', error)
3889
+ * throw createInsufficientTokenBalanceError('Base', 'USDC', {
3890
+ * rawError: error,
3891
+ * balance: '1000000',
3892
+ * amount: '5000000',
3893
+ * })
3837
3894
  * }
3838
3895
  * ```
3839
3896
  */
3840
- function createInsufficientTokenBalanceError(chain, token, rawError) {
3897
+ function createInsufficientTokenBalanceError(chain, token, trace) {
3841
3898
  return new KitError({
3842
3899
  ...BalanceError.INSUFFICIENT_TOKEN,
3843
3900
  recoverability: 'FATAL',
3844
3901
  message: `Insufficient ${token} balance on ${chain}`,
3845
3902
  cause: {
3846
3903
  trace: {
3904
+ ...trace,
3847
3905
  chain,
3848
3906
  token,
3849
- rawError,
3907
+ },
3908
+ },
3909
+ });
3910
+ }
3911
+ /**
3912
+ * Creates error for insufficient gas funds.
3913
+ *
3914
+ * This error is thrown when a wallet does not have enough native tokens
3915
+ * (ETH, SOL, etc.) to pay for transaction gas fees. The error is FATAL
3916
+ * as it requires user intervention to add gas funds.
3917
+ *
3918
+ * @param chain - The blockchain network where the gas check failed
3919
+ * @param trace - Optional trace context to include in error (can include rawError and additional debugging data)
3920
+ * @returns KitError with insufficient gas details
3921
+ *
3922
+ * @example
3923
+ * ```typescript
3924
+ * import { createInsufficientGasError } from '@core/errors'
3925
+ *
3926
+ * throw createInsufficientGasError('Ethereum')
3927
+ * // Message: "Insufficient gas funds on Ethereum"
3928
+ * ```
3929
+ *
3930
+ * @example
3931
+ * ```typescript
3932
+ * // With trace context for debugging
3933
+ * throw createInsufficientGasError('Ethereum', {
3934
+ * rawError: error,
3935
+ * gasRequired: '21000',
3936
+ * gasAvailable: '10000',
3937
+ * walletAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb',
3938
+ * })
3939
+ * ```
3940
+ */
3941
+ function createInsufficientGasError(chain, trace) {
3942
+ return new KitError({
3943
+ ...BalanceError.INSUFFICIENT_GAS,
3944
+ recoverability: 'FATAL',
3945
+ message: `Insufficient gas funds on ${chain}`,
3946
+ cause: {
3947
+ trace: {
3948
+ ...trace,
3949
+ chain,
3850
3950
  },
3851
3951
  },
3852
3952
  });
@@ -3908,6 +4008,38 @@ function isKitError(error) {
3908
4008
  function isFatalError(error) {
3909
4009
  return isKitError(error) && error.recoverability === 'FATAL';
3910
4010
  }
4011
+ /**
4012
+ * Safely extracts error message from any error type.
4013
+ *
4014
+ * This utility handles different error types gracefully, extracting
4015
+ * meaningful messages from Error instances, string errors, or providing
4016
+ * a fallback for unknown error types. Never throws.
4017
+ *
4018
+ * @param error - Unknown error to extract message from
4019
+ * @returns Error message string, or fallback message
4020
+ *
4021
+ * @example
4022
+ * ```typescript
4023
+ * import { getErrorMessage } from '@core/errors'
4024
+ *
4025
+ * try {
4026
+ * await riskyOperation()
4027
+ * } catch (error) {
4028
+ * const message = getErrorMessage(error)
4029
+ * console.log('Error occurred:', message)
4030
+ * // Works with Error, KitError, string, or any other type
4031
+ * }
4032
+ * ```
4033
+ */
4034
+ function getErrorMessage(error) {
4035
+ if (error instanceof Error) {
4036
+ return error.message;
4037
+ }
4038
+ if (typeof error === 'string') {
4039
+ return error;
4040
+ }
4041
+ return 'An unknown error occurred';
4042
+ }
3911
4043
 
3912
4044
  /**
3913
4045
  * Validates data against a Zod schema with enhanced error reporting.
@@ -4405,41 +4537,46 @@ var TransferSpeed;
4405
4537
  * - regexMessage: error message when the basic numeric format fails.
4406
4538
  * - maxDecimals: maximum number of decimal places allowed (e.g., 6 for USDC).
4407
4539
  */
4408
- const createDecimalStringValidator = (options) => (schema) => schema
4409
- .regex(/^-?(?:\d+(?:\.\d+)?|\.\d+)$/, options.regexMessage)
4410
- .superRefine((val, ctx) => {
4411
- const amount = Number.parseFloat(val);
4412
- if (Number.isNaN(amount)) {
4413
- ctx.addIssue({
4414
- code: z.ZodIssueCode.custom,
4415
- message: options.regexMessage,
4416
- });
4417
- return;
4418
- }
4419
- // Check decimal precision if maxDecimals is specified
4420
- if (options.maxDecimals !== undefined) {
4421
- const decimalPart = val.split('.')[1];
4422
- if (decimalPart && decimalPart.length > options.maxDecimals) {
4540
+ const createDecimalStringValidator = (options) => (schema) => {
4541
+ // Capitalize first letter of attribute name for error messages
4542
+ const capitalizedAttributeName = options.attributeName.charAt(0).toUpperCase() +
4543
+ options.attributeName.slice(1);
4544
+ return schema
4545
+ .regex(/^-?(?:\d+(?:\.\d+)?|\.\d+)$/, options.regexMessage)
4546
+ .superRefine((val, ctx) => {
4547
+ const amount = Number.parseFloat(val);
4548
+ if (Number.isNaN(amount)) {
4423
4549
  ctx.addIssue({
4424
4550
  code: z.ZodIssueCode.custom,
4425
- message: `Maximum supported decimal places: ${options.maxDecimals.toString()}`,
4551
+ message: options.regexMessage,
4426
4552
  });
4427
4553
  return;
4428
4554
  }
4429
- }
4430
- if (options.allowZero && amount < 0) {
4431
- ctx.addIssue({
4432
- code: z.ZodIssueCode.custom,
4433
- message: `${options.attributeName} must be non-negative`,
4434
- });
4435
- }
4436
- else if (!options.allowZero && amount <= 0) {
4437
- ctx.addIssue({
4438
- code: z.ZodIssueCode.custom,
4439
- message: `${options.attributeName} must be greater than 0`,
4440
- });
4441
- }
4442
- });
4555
+ // Check decimal precision if maxDecimals is specified
4556
+ if (options.maxDecimals !== undefined) {
4557
+ const decimalPart = val.split('.')[1];
4558
+ if (decimalPart && decimalPart.length > options.maxDecimals) {
4559
+ ctx.addIssue({
4560
+ code: z.ZodIssueCode.custom,
4561
+ message: `Maximum supported decimal places: ${options.maxDecimals.toString()}`,
4562
+ });
4563
+ return;
4564
+ }
4565
+ }
4566
+ if (options.allowZero && amount < 0) {
4567
+ ctx.addIssue({
4568
+ code: z.ZodIssueCode.custom,
4569
+ message: `${capitalizedAttributeName} must be non-negative`,
4570
+ });
4571
+ }
4572
+ else if (!options.allowZero && amount <= 0) {
4573
+ ctx.addIssue({
4574
+ code: z.ZodIssueCode.custom,
4575
+ message: `${capitalizedAttributeName} must be greater than 0`,
4576
+ });
4577
+ }
4578
+ });
4579
+ };
4443
4580
  /**
4444
4581
  * Schema for validating chain definitions.
4445
4582
  * This ensures the basic structure of a chain definition is valid.
@@ -4599,7 +4736,7 @@ const bridgeParamsSchema = z.object({
4599
4736
  .min(1, 'Required')
4600
4737
  .pipe(createDecimalStringValidator({
4601
4738
  allowZero: false,
4602
- regexMessage: 'Amount must be a numeric string with dot (.) as decimal separator (e.g., "0.1", ".1", "10.5", "1000.50"), with no thousand separators or comma decimals.',
4739
+ regexMessage: AMOUNT_FORMAT_ERROR_MESSAGE,
4603
4740
  attributeName: 'amount',
4604
4741
  maxDecimals: 6,
4605
4742
  })(z.string())),
@@ -4612,7 +4749,7 @@ const bridgeParamsSchema = z.object({
4612
4749
  .string()
4613
4750
  .pipe(createDecimalStringValidator({
4614
4751
  allowZero: true,
4615
- regexMessage: 'maxFee must be a numeric string with dot (.) as decimal separator (e.g., "1", "0.5", ".5", "1.5"), with no thousand separators or comma decimals.',
4752
+ regexMessage: MAX_FEE_FORMAT_ERROR_MESSAGE,
4616
4753
  attributeName: 'maxFee',
4617
4754
  maxDecimals: 6,
4618
4755
  })(z.string()))
@@ -4621,6 +4758,19 @@ const bridgeParamsSchema = z.object({
4621
4758
  }),
4622
4759
  });
4623
4760
 
4761
+ /**
4762
+ * Base URL for Circle's IRIS API (mainnet/production).
4763
+ *
4764
+ * The IRIS API provides attestation services for CCTP cross-chain transfers.
4765
+ */
4766
+ const IRIS_API_BASE_URL = 'https://iris-api.circle.com';
4767
+ /**
4768
+ * Base URL for Circle's IRIS API (testnet/sandbox).
4769
+ *
4770
+ * Used for development and testing on testnet chains.
4771
+ */
4772
+ const IRIS_API_SANDBOX_BASE_URL = 'https://iris-api-sandbox.circle.com';
4773
+
4624
4774
  /**
4625
4775
  * Type guard to validate the API response structure.
4626
4776
  *
@@ -4669,9 +4819,7 @@ const isFastBurnFeeResponse = (data) => {
4669
4819
  * @returns The complete API URL
4670
4820
  */
4671
4821
  function buildFastBurnFeeUrl(sourceDomain, destinationDomain, isTestnet) {
4672
- const baseUrl = isTestnet
4673
- ? 'https://iris-api-sandbox.circle.com'
4674
- : 'https://iris-api.circle.com';
4822
+ const baseUrl = isTestnet ? IRIS_API_SANDBOX_BASE_URL : IRIS_API_BASE_URL;
4675
4823
  return `${baseUrl}/v2/burn/USDC/fees/${sourceDomain.toString()}/${destinationDomain.toString()}`;
4676
4824
  }
4677
4825
  const FAST_TIER_FINALITY_THRESHOLD = 1000;
@@ -4687,15 +4835,24 @@ const FAST_TIER_FINALITY_THRESHOLD = 1000;
4687
4835
  * - Retry delays: 9 × 200 ms = 1 800 ms
4688
4836
  * - Total max time: 2 000 ms + 1 800 ms = 3 800 ms
4689
4837
  *
4838
+ * @remarks
4839
+ * The API returns basis points as a decimal (e.g., "1.3" for 1.3 bps = 0.013%).
4840
+ * This function scales the value by 100 to preserve 2 decimal places of precision.
4841
+ * Callers must divide by 1,000,000 (instead of 10,000) when calculating fees.
4842
+ *
4690
4843
  * @param sourceDomain - The source domain
4691
4844
  * @param destinationDomain - The destination domain
4692
4845
  * @param isTestnet - Whether the request is for a testnet chain
4693
- * @returns The minimum fee for a USDC fast burn operation
4846
+ * @returns The minimum fee in scaled basis points (bps × 100)
4694
4847
  * @throws Error if the input domains are invalid, the API request fails, returns invalid data, or network errors occur
4695
4848
  * @example
4696
4849
  * ```typescript
4697
- * const minimumFee = await fetchUsdcFastBurnFee(0, 6, false) // Ethereum -> Base
4698
- * console.log(minimumFee) // 1000n
4850
+ * const scaledBps = await fetchUsdcFastBurnFee(0, 6, false) // Ethereum -> Base
4851
+ * console.log(scaledBps) // 130n (representing 1.3 bps)
4852
+ *
4853
+ * // To calculate fee for an amount:
4854
+ * const amount = 1_000_000n // 1 USDC
4855
+ * const fee = (scaledBps * amount) / 1_000_000n // 130n (0.00013 USDC)
4699
4856
  * ```
4700
4857
  */
4701
4858
  async function fetchUsdcFastBurnFee(sourceDomain, destinationDomain, isTestnet) {
@@ -4721,14 +4878,16 @@ async function fetchUsdcFastBurnFee(sourceDomain, destinationDomain, isTestnet)
4721
4878
  if (!fastTier) {
4722
4879
  throw new Error(`No fast tier (finalityThreshold: ${FAST_TIER_FINALITY_THRESHOLD.toString()}) available in API response`);
4723
4880
  }
4724
- // Convert minimumFee to bigint
4725
- let minimumFee;
4726
- try {
4727
- minimumFee = BigInt(fastTier.minimumFee);
4728
- }
4729
- catch {
4730
- throw new Error(`Invalid minimumFee value: cannot convert "${String(fastTier.minimumFee)}" to bigint`);
4881
+ // Convert minimumFee to scaled basis points (bigint)
4882
+ // The API returns basis points as a decimal (e.g., "1.3" for 1.3 bps = 0.013%).
4883
+ // We scale by 100 to preserve 2 decimal places of precision.
4884
+ // The caller (getMaxFee) must divide by 1,000,000 instead of 10,000.
4885
+ const feeValue = Number.parseFloat(String(fastTier.minimumFee));
4886
+ if (Number.isNaN(feeValue) || !Number.isFinite(feeValue)) {
4887
+ throw new Error(`Invalid minimumFee value: cannot parse "${String(fastTier.minimumFee)}" as a number`);
4731
4888
  }
4889
+ // Scale by 100 and round to get integer representation
4890
+ const minimumFee = BigInt(Math.round(feeValue * 100));
4732
4891
  // Validate that minimumFee is non-negative
4733
4892
  if (minimumFee < 0n) {
4734
4893
  throw new Error('Invalid minimumFee: value must be non-negative');
@@ -4903,9 +5062,7 @@ const isAttestationResponse = (obj) => {
4903
5062
  * ```
4904
5063
  */
4905
5064
  const buildIrisUrl = (sourceDomainId, transactionHash, isTestnet) => {
4906
- const baseUrl = isTestnet
4907
- ? 'https://iris-api-sandbox.circle.com'
4908
- : 'https://iris-api.circle.com';
5065
+ const baseUrl = isTestnet ? IRIS_API_SANDBOX_BASE_URL : IRIS_API_BASE_URL;
4909
5066
  const url = new URL(`${baseUrl}/v2/messages/${String(sourceDomainId)}`);
4910
5067
  url.searchParams.set('transactionHash', transactionHash);
4911
5068
  return url.toString();
@@ -4950,6 +5107,133 @@ const fetchAttestation = async (sourceDomainId, transactionHash, isTestnet, conf
4950
5107
  const effectiveConfig = { ...DEFAULT_CONFIG, ...config };
4951
5108
  return await pollApiGet(url, isAttestationResponse, effectiveConfig);
4952
5109
  };
5110
+ /**
5111
+ * Type guard that validates attestation response structure without requiring completion status.
5112
+ *
5113
+ * This is used by `fetchAttestationWithoutStatusCheck` to extract the nonce from an existing
5114
+ * attestation, even if the attestation is expired or pending. Unlike `isAttestationResponse`,
5115
+ * this function does not throw if no complete attestation is found.
5116
+ *
5117
+ * @param obj - The value to check, typically a parsed JSON response
5118
+ * @returns True if the object has valid attestation structure
5119
+ * @throws {Error} With "Invalid attestation response structure" if structure is invalid
5120
+ * @internal
5121
+ */
5122
+ const isAttestationResponseWithoutStatusCheck = (obj) => {
5123
+ if (!hasValidAttestationStructure(obj)) {
5124
+ throw new Error('Invalid attestation response structure');
5125
+ }
5126
+ return true;
5127
+ };
5128
+ /**
5129
+ * Fetches attestation data without requiring the attestation to be complete.
5130
+ *
5131
+ * This function is useful for retrieving attestation data (particularly the nonce)
5132
+ * from an existing transaction, even if the attestation has expired or is pending.
5133
+ * It uses minimal retries since we're fetching existing data, not waiting for completion.
5134
+ *
5135
+ * @param sourceDomainId - The CCTP domain ID of the source chain
5136
+ * @param transactionHash - The transaction hash to fetch attestation for
5137
+ * @param isTestnet - Whether this is for a testnet chain (true) or mainnet chain (false)
5138
+ * @param config - Optional configuration overrides
5139
+ * @returns The attestation response data (may contain incomplete/expired attestations)
5140
+ * @throws If the request fails, times out, or returns invalid data
5141
+ *
5142
+ * @example
5143
+ * ```typescript
5144
+ * // Fetch existing attestation to extract nonce for re-attestation
5145
+ * const response = await fetchAttestationWithoutStatusCheck(1, '0xabc...', true)
5146
+ * const nonce = response.messages[0]?.eventNonce
5147
+ * ```
5148
+ */
5149
+ const fetchAttestationWithoutStatusCheck = async (sourceDomainId, transactionHash, isTestnet, config = {}) => {
5150
+ const url = buildIrisUrl(sourceDomainId, transactionHash, isTestnet);
5151
+ // Use minimal retries since we're just fetching existing data
5152
+ const effectiveConfig = {
5153
+ ...DEFAULT_CONFIG,
5154
+ maxRetries: 3,
5155
+ ...config,
5156
+ };
5157
+ return await pollApiGet(url, isAttestationResponseWithoutStatusCheck, effectiveConfig);
5158
+ };
5159
+ /**
5160
+ * Builds the IRIS API URL for re-attestation requests.
5161
+ *
5162
+ * Constructs the URL for Circle's re-attestation endpoint that allows
5163
+ * requesting a fresh attestation for an expired nonce.
5164
+ *
5165
+ * @param nonce - The nonce from the original attestation
5166
+ * @param isTestnet - Whether this is for a testnet chain (true) or mainnet chain (false)
5167
+ * @returns A fully qualified URL string for the re-attestation endpoint
5168
+ *
5169
+ * @example
5170
+ * ```typescript
5171
+ * // Mainnet URL
5172
+ * const mainnetUrl = buildReAttestUrl('0xabc', false)
5173
+ * // => 'https://iris-api.circle.com/v2/reattest/0xabc'
5174
+ *
5175
+ * // Testnet URL
5176
+ * const testnetUrl = buildReAttestUrl('0xabc', true)
5177
+ * // => 'https://iris-api-sandbox.circle.com/v2/reattest/0xabc'
5178
+ * ```
5179
+ */
5180
+ const buildReAttestUrl = (nonce, isTestnet) => {
5181
+ const baseUrl = isTestnet ? IRIS_API_SANDBOX_BASE_URL : IRIS_API_BASE_URL;
5182
+ const url = new URL(`${baseUrl}/v2/reattest/${nonce}`);
5183
+ return url.toString();
5184
+ };
5185
+ /**
5186
+ * Type guard that validates the re-attestation API response structure.
5187
+ *
5188
+ * @param obj - The value to check, typically a parsed JSON response
5189
+ * @returns True if the object matches the ReAttestationResponse shape
5190
+ * @throws {Error} With "Invalid re-attestation response structure" if structure is invalid
5191
+ * @internal
5192
+ */
5193
+ const isReAttestationResponse = (obj) => {
5194
+ if (typeof obj !== 'object' ||
5195
+ obj === null ||
5196
+ !('message' in obj) ||
5197
+ !('nonce' in obj) ||
5198
+ typeof obj.message !== 'string' ||
5199
+ typeof obj.nonce !== 'string') {
5200
+ throw new Error('Invalid re-attestation response structure');
5201
+ }
5202
+ return true;
5203
+ };
5204
+ /**
5205
+ * Requests re-attestation for an expired attestation nonce.
5206
+ *
5207
+ * This function calls Circle's re-attestation API endpoint to request a fresh
5208
+ * attestation for a previously issued nonce. After calling this function,
5209
+ * you should poll `fetchAttestation` to retrieve the new attestation.
5210
+ *
5211
+ * @param nonce - The nonce from the original (expired) attestation
5212
+ * @param isTestnet - Whether this is for a testnet chain (true) or mainnet chain (false)
5213
+ * @param config - Optional configuration overrides for the request
5214
+ * @returns The re-attestation response confirming the request was accepted
5215
+ * @throws If the request fails, times out, or returns invalid data
5216
+ *
5217
+ * @example
5218
+ * ```typescript
5219
+ * // Request re-attestation for an expired nonce
5220
+ * const response = await requestReAttestation('0xabc', true)
5221
+ * console.log(response.message) // "Re-attestation successfully requested for nonce."
5222
+ *
5223
+ * // After requesting re-attestation, poll for the new attestation
5224
+ * const attestation = await fetchAttestation(domainId, txHash, true)
5225
+ * ```
5226
+ */
5227
+ const requestReAttestation = async (nonce, isTestnet, config = {}) => {
5228
+ const url = buildReAttestUrl(nonce, isTestnet);
5229
+ // Use minimal retries since we're just submitting a request, not polling for state
5230
+ const effectiveConfig = {
5231
+ ...DEFAULT_CONFIG,
5232
+ maxRetries: 3,
5233
+ ...config,
5234
+ };
5235
+ return await pollApiPost(url, {}, isReAttestationResponse, effectiveConfig);
5236
+ };
4953
5237
 
4954
5238
  const assertCCTPv2WalletContextSymbol = Symbol('assertCCTPv2WalletContext');
4955
5239
  /**
@@ -5187,6 +5471,170 @@ mintAddress) => {
5187
5471
  }
5188
5472
  };
5189
5473
 
5474
+ /**
5475
+ * Converts a block number to bigint with validation.
5476
+ *
5477
+ * @param value - The block number value to convert (bigint, number, or string)
5478
+ * @returns The validated block number as a bigint
5479
+ * @throws KitError If the value is invalid (empty string, non-integer, negative, etc.)
5480
+ * @internal
5481
+ */
5482
+ const toBlockNumber = (value) => {
5483
+ if (value === null || value === undefined) {
5484
+ throw createValidationFailedError('blockNumber', value, 'cannot be null or undefined');
5485
+ }
5486
+ // Empty string edge case - BigInt('') === 0n which is misleading
5487
+ if (value === '') {
5488
+ throw createValidationFailedError('blockNumber', value, 'cannot be empty string');
5489
+ }
5490
+ // For numbers, validate before BigInt conversion
5491
+ if (typeof value === 'number') {
5492
+ if (!Number.isFinite(value) || !Number.isInteger(value)) {
5493
+ throw createValidationFailedError('blockNumber', value, 'must be a finite integer');
5494
+ }
5495
+ }
5496
+ let result;
5497
+ try {
5498
+ result = BigInt(value);
5499
+ }
5500
+ catch {
5501
+ throw createValidationFailedError('blockNumber', value, 'cannot be converted to BigInt');
5502
+ }
5503
+ if (result < 0n) {
5504
+ throw createValidationFailedError('blockNumber', result.toString(), 'must be non-negative');
5505
+ }
5506
+ return result;
5507
+ };
5508
+ /**
5509
+ * Determines whether an attestation has expired based on the current block number.
5510
+ *
5511
+ * An attestation expires when the destination chain's current block number is greater
5512
+ * than or equal to the expiration block specified in the attestation message.
5513
+ * Slow transfers and re-attested messages have `expirationBlock: '0'` and never expire.
5514
+ *
5515
+ * @param attestation - The attestation message containing expiration block information
5516
+ * @param currentBlockNumber - The current block number on the destination chain (bigint, number, or string)
5517
+ * @returns `true` if the attestation has expired, `false` if still valid or never expires
5518
+ * @throws KitError If currentBlockNumber or expirationBlock is invalid
5519
+ *
5520
+ * @example
5521
+ * ```typescript
5522
+ * import { isAttestationExpired } from '@circle-fin/cctp-v2-provider'
5523
+ *
5524
+ * // Check if attestation is expired on EVM chain
5525
+ * const publicClient = await adapter.getPublicClient(destinationChain)
5526
+ * const currentBlock = await publicClient.getBlockNumber()
5527
+ * const expired = isAttestationExpired(attestation, currentBlock)
5528
+ *
5529
+ * if (expired) {
5530
+ * const freshAttestation = await provider.reAttest(source, burnTxHash)
5531
+ * }
5532
+ * ```
5533
+ *
5534
+ * @example
5535
+ * ```typescript
5536
+ * // Check on Solana
5537
+ * const slot = await adapter.getConnection(destinationChain).getSlot()
5538
+ * const expired = isAttestationExpired(attestation, slot)
5539
+ * ```
5540
+ */
5541
+ const isAttestationExpired = (attestation, currentBlockNumber) => {
5542
+ const currentBlock = toBlockNumber(currentBlockNumber);
5543
+ const expiration = toBlockNumber(attestation.decodedMessage.decodedMessageBody.expirationBlock);
5544
+ // 0n means it never expires (re-attested messages and slow transfers)
5545
+ if (expiration === 0n) {
5546
+ return false;
5547
+ }
5548
+ // Attestation is expired if current block >= expiration block
5549
+ return currentBlock >= expiration;
5550
+ };
5551
+ /**
5552
+ * Calculates the number of blocks remaining until an attestation expires.
5553
+ *
5554
+ * Returns the difference between the expiration block and the current block number.
5555
+ * Returns `null` if the attestation has `expirationBlock: '0'` (never expires).
5556
+ * Returns `0n` or a negative bigint if the attestation has already expired.
5557
+ *
5558
+ * @param attestation - The attestation message containing expiration block information
5559
+ * @param currentBlockNumber - The current block number on the destination chain (bigint, number, or string)
5560
+ * @returns The number of blocks until expiry as a bigint, or `null` if the attestation never expires
5561
+ * @throws KitError If currentBlockNumber or expirationBlock is invalid
5562
+ *
5563
+ * @example
5564
+ * ```typescript
5565
+ * import { getBlocksUntilExpiry } from '@circle-fin/cctp-v2-provider'
5566
+ *
5567
+ * const publicClient = await adapter.getPublicClient(destinationChain)
5568
+ * const currentBlock = await publicClient.getBlockNumber()
5569
+ * const blocksRemaining = getBlocksUntilExpiry(attestation, currentBlock)
5570
+ *
5571
+ * if (blocksRemaining === null) {
5572
+ * console.log('Attestation never expires')
5573
+ * } else if (blocksRemaining <= 0n) {
5574
+ * console.log('Attestation has expired')
5575
+ * } else {
5576
+ * console.log(`${blocksRemaining} blocks until expiry`)
5577
+ * }
5578
+ * ```
5579
+ */
5580
+ const getBlocksUntilExpiry = (attestation, currentBlockNumber) => {
5581
+ const currentBlock = toBlockNumber(currentBlockNumber);
5582
+ const expiration = toBlockNumber(attestation.decodedMessage.decodedMessageBody.expirationBlock);
5583
+ // 0n means it never expires (re-attested messages and slow transfers)
5584
+ if (expiration === 0n) {
5585
+ return null;
5586
+ }
5587
+ // Return the difference (can be negative if expired)
5588
+ return expiration - currentBlock;
5589
+ };
5590
+ /**
5591
+ * Determines whether a mint failure was caused by an expired attestation.
5592
+ *
5593
+ * This function inspects the error thrown during a mint operation to detect
5594
+ * if the failure is due to the attestation's expiration block being exceeded.
5595
+ * When this returns `true`, the caller should use `reAttest()` to obtain a
5596
+ * fresh attestation before retrying the mint.
5597
+ *
5598
+ * @param error - The error thrown during the mint operation
5599
+ * @returns `true` if the error indicates the attestation has expired, `false` otherwise
5600
+ *
5601
+ * @example
5602
+ * ```typescript
5603
+ * import { isMintFailureRelatedToAttestation } from '@circle-fin/cctp-v2-provider'
5604
+ *
5605
+ * try {
5606
+ * await mintRequest.execute()
5607
+ * } catch (error) {
5608
+ * if (isMintFailureRelatedToAttestation(error)) {
5609
+ * // Attestation expired - get a fresh one
5610
+ * const freshAttestation = await provider.reAttest(source, burnTxHash)
5611
+ * const newMintRequest = await provider.mint(source, destination, freshAttestation)
5612
+ * await newMintRequest.execute()
5613
+ * } else {
5614
+ * throw error
5615
+ * }
5616
+ * }
5617
+ * ```
5618
+ */
5619
+ const isMintFailureRelatedToAttestation = (error) => {
5620
+ if (error === null || error === undefined) {
5621
+ return false;
5622
+ }
5623
+ const errorString = getErrorMessage(error).toLowerCase();
5624
+ // Check for Solana "MessageExpired" error pattern
5625
+ // Full error: "AnchorError thrown in ...handle_receive_finalized_message.rs:169.
5626
+ // Error Code: MessageExpired. Error Number: 6016. Error Message: Message has expired."
5627
+ if (errorString.includes('messageexpired')) {
5628
+ return true;
5629
+ }
5630
+ // Check for EVM attestation expiry errors
5631
+ // Contract reverts with: "Message expired and must be re-signed"
5632
+ if (errorString.includes('message') && errorString.includes('expired')) {
5633
+ return true;
5634
+ }
5635
+ return false;
5636
+ };
5637
+
5190
5638
  /**
5191
5639
  * Checks if a decoded attestation field matches the corresponding transfer parameter.
5192
5640
  * If the values do not match, appends a descriptive error message to the errors array.
@@ -6030,21 +6478,64 @@ const validateBalanceForTransaction = async (params) => {
6030
6478
  // Extract chain name from operationContext
6031
6479
  const chainName = extractChainInfo(operationContext.chain).name;
6032
6480
  // Create KitError with rich context in trace
6033
- const error = createInsufficientTokenBalanceError(chainName, token);
6034
- // Enhance error with additional context for debugging
6035
- if (error.cause) {
6036
- const existingTrace = typeof error.cause.trace === 'object' && error.cause.trace
6037
- ? error.cause.trace
6038
- : {};
6039
- error.cause.trace = {
6040
- ...existingTrace,
6041
- balance: balance.toString(),
6042
- amount,
6043
- tokenAddress,
6044
- walletAddress: operationContext.address,
6045
- };
6046
- }
6047
- throw error;
6481
+ throw createInsufficientTokenBalanceError(chainName, token, {
6482
+ balance: balance.toString(),
6483
+ amount,
6484
+ tokenAddress,
6485
+ walletAddress: operationContext.address,
6486
+ });
6487
+ }
6488
+ };
6489
+
6490
+ /**
6491
+ * Validate that the adapter has sufficient native token balance for transaction fees.
6492
+ *
6493
+ * This function checks if the adapter's current native token balance (ETH, SOL, etc.)
6494
+ * is greater than zero. It throws a KitError with code 9002 (BALANCE_INSUFFICIENT_GAS)
6495
+ * if the balance is zero, indicating the wallet cannot pay for transaction fees.
6496
+ *
6497
+ * @param params - The validation parameters containing adapter and operation context.
6498
+ * @returns A promise that resolves to void if validation passes.
6499
+ * @throws {KitError} When the adapter's native balance is zero (code: 9002).
6500
+ *
6501
+ * @example
6502
+ * ```typescript
6503
+ * import { validateNativeBalanceForTransaction } from '@core/adapter'
6504
+ * import { createViemAdapterFromPrivateKey } from '@circle-fin/adapter-viem-v2'
6505
+ * import { isKitError, ERROR_TYPES } from '@core/errors'
6506
+ *
6507
+ * const adapter = createViemAdapterFromPrivateKey({
6508
+ * privateKey: '0x...',
6509
+ * chain: 'Ethereum',
6510
+ * })
6511
+ *
6512
+ * try {
6513
+ * await validateNativeBalanceForTransaction({
6514
+ * adapter,
6515
+ * operationContext: { chain: 'Ethereum' },
6516
+ * })
6517
+ * console.log('Native balance validation passed')
6518
+ * } catch (error) {
6519
+ * if (isKitError(error) && error.type === ERROR_TYPES.BALANCE) {
6520
+ * console.error('Insufficient gas funds:', error.message)
6521
+ * }
6522
+ * }
6523
+ * ```
6524
+ */
6525
+ const validateNativeBalanceForTransaction = async (params) => {
6526
+ const { adapter, operationContext } = params;
6527
+ const balancePrepared = await adapter.prepareAction('native.balanceOf', {
6528
+ walletAddress: operationContext.address,
6529
+ }, operationContext);
6530
+ const balance = await balancePrepared.execute();
6531
+ if (BigInt(balance) === 0n) {
6532
+ // Extract chain name from operationContext
6533
+ const chainName = extractChainInfo(operationContext.chain).name;
6534
+ // Create KitError with rich context in trace
6535
+ throw createInsufficientGasError(chainName, {
6536
+ balance: '0',
6537
+ walletAddress: operationContext.address,
6538
+ });
6048
6539
  }
6049
6540
  };
6050
6541
 
@@ -6921,16 +7412,28 @@ class CCTPV2BridgingProvider extends BridgingProvider {
6921
7412
  async bridge(params) {
6922
7413
  // CCTP-specific bridge params validation (includes base validation)
6923
7414
  assertCCTPv2BridgeParams(params);
6924
- const { source, amount, token } = params;
7415
+ const { source, destination, amount, token } = params;
6925
7416
  // Extract operation context from source wallet context for balance validation
6926
- const operationContext = this.extractOperationContext(source);
6927
- // Validate balance for transaction
7417
+ const sourceOperationContext = this.extractOperationContext(source);
7418
+ // Validate USDC balance for transaction on source chain
6928
7419
  await validateBalanceForTransaction({
6929
7420
  adapter: source.adapter,
6930
7421
  amount,
6931
7422
  token,
6932
7423
  tokenAddress: source.chain.usdcAddress,
6933
- operationContext,
7424
+ operationContext: sourceOperationContext,
7425
+ });
7426
+ // Validate native balance > 0 for gas fees on source chain
7427
+ await validateNativeBalanceForTransaction({
7428
+ adapter: source.adapter,
7429
+ operationContext: sourceOperationContext,
7430
+ });
7431
+ // Extract operation context from destination wallet context
7432
+ const destinationOperationContext = this.extractOperationContext(destination);
7433
+ // Validate native balance > 0 for gas fees on destination chain
7434
+ await validateNativeBalanceForTransaction({
7435
+ adapter: destination.adapter,
7436
+ operationContext: destinationOperationContext,
6934
7437
  });
6935
7438
  return bridge(params, this);
6936
7439
  }
@@ -7011,6 +7514,19 @@ class CCTPV2BridgingProvider extends BridgingProvider {
7011
7514
  : Promise.resolve(0n),
7012
7515
  ]);
7013
7516
  const estimateResult = {
7517
+ token: params.token,
7518
+ amount: params.amount,
7519
+ source: {
7520
+ address: source.address,
7521
+ chain: source.chain.chain,
7522
+ },
7523
+ destination: {
7524
+ address: destination.address,
7525
+ chain: destination.chain.chain,
7526
+ ...(destination.recipientAddress && {
7527
+ recipientAddress: destination.recipientAddress,
7528
+ }),
7529
+ },
7014
7530
  gasFees: [],
7015
7531
  fees: [],
7016
7532
  };
@@ -7296,6 +7812,93 @@ class CCTPV2BridgingProvider extends BridgingProvider {
7296
7812
  throw error;
7297
7813
  }
7298
7814
  }
7815
+ /**
7816
+ * Requests a fresh attestation for an expired attestation.
7817
+ *
7818
+ * This method is used when the original attestation has expired before the mint
7819
+ * transaction could be completed. It performs three steps:
7820
+ * 1. Fetches the existing attestation data to extract the nonce
7821
+ * 2. Requests re-attestation from Circle's API using the nonce
7822
+ * 3. Polls for the fresh attestation and returns it
7823
+ *
7824
+ * @typeParam TFromAdapterCapabilities - The type representing the capabilities of the source adapter
7825
+ * @param source - The source wallet context containing the chain definition and wallet address
7826
+ * @param transactionHash - The transaction hash of the original burn transaction
7827
+ * @param config - Optional polling configuration overrides for timeout, retries, and delay
7828
+ * @returns A promise that resolves to the fresh attestation message
7829
+ * @throws {Error} With "Failed to re-attest: No nonce found for transaction" if the original
7830
+ * attestation cannot be found or has no nonce
7831
+ * @throws {Error} With "Failed to re-attest: No attestation found after re-attestation request"
7832
+ * if the fresh attestation cannot be retrieved
7833
+ * @throws {Error} With "Failed to re-attest: {details}" for other errors
7834
+ *
7835
+ * @example
7836
+ * ```typescript
7837
+ * import { CCTPV2BridgingProvider } from '@circle-fin/provider-cctp-v2'
7838
+ * import { Chains } from '@core/chains'
7839
+ *
7840
+ * const provider = new CCTPV2BridgingProvider()
7841
+ *
7842
+ * // After a mint fails due to expired attestation, request a fresh one
7843
+ * const source = {
7844
+ * adapter: viemAdapter,
7845
+ * chain: Chains.EthereumSepolia,
7846
+ * address: '0x1234...'
7847
+ * }
7848
+ *
7849
+ * try {
7850
+ * const freshAttestation = await provider.reAttest(
7851
+ * source,
7852
+ * '0xabc123...', // Original burn transaction hash
7853
+ * { timeout: 10000, maxRetries: 5 }
7854
+ * )
7855
+ *
7856
+ * // Use the fresh attestation to retry the mint
7857
+ * const mintRequest = await provider.mint(source, destination, freshAttestation)
7858
+ * const result = await mintRequest.execute()
7859
+ * } catch (error) {
7860
+ * console.error('Re-attestation failed:', error.message)
7861
+ * }
7862
+ * ```
7863
+ */
7864
+ async reAttest(source, transactionHash, config) {
7865
+ assertCCTPv2WalletContext(source);
7866
+ if (!transactionHash ||
7867
+ typeof transactionHash !== 'string' ||
7868
+ transactionHash.trim() === '') {
7869
+ throw new Error('Failed to re-attest: Invalid transaction hash');
7870
+ }
7871
+ try {
7872
+ // Merge configs: defaults <- global config <- per-call config
7873
+ const effectiveConfig = {
7874
+ ...this.config?.attestation,
7875
+ ...config,
7876
+ };
7877
+ // Step 1: Get existing attestation data to extract nonce
7878
+ const existingAttestation = await fetchAttestationWithoutStatusCheck(source.chain.cctp.domain, transactionHash, source.chain.isTestnet, effectiveConfig);
7879
+ const nonce = existingAttestation.messages[0]?.eventNonce;
7880
+ if (!nonce || typeof nonce !== 'string') {
7881
+ throw new Error('Failed to re-attest: No nonce found for transaction');
7882
+ }
7883
+ // Step 2: Request re-attestation
7884
+ await requestReAttestation(nonce, source.chain.isTestnet, effectiveConfig);
7885
+ // Step 3: Poll for fresh attestation
7886
+ const response = await fetchAttestation(source.chain.cctp.domain, transactionHash, source.chain.isTestnet, effectiveConfig);
7887
+ const message = response.messages[0];
7888
+ if (!message) {
7889
+ throw new Error('Failed to re-attest: No attestation found after re-attestation request');
7890
+ }
7891
+ return message;
7892
+ }
7893
+ catch (err) {
7894
+ const error = err instanceof Error ? err : new Error(String(err));
7895
+ // Always prefix with 'Failed to re-attest'
7896
+ if (!error.message.startsWith('Failed to re-attest')) {
7897
+ throw new Error(`Failed to re-attest: ${error.message}`);
7898
+ }
7899
+ throw error;
7900
+ }
7901
+ }
7299
7902
  /**
7300
7903
  * Checks if both source and destination chains support CCTP v2 transfers.
7301
7904
  *
@@ -7375,15 +7978,17 @@ class CCTPV2BridgingProvider extends BridgingProvider {
7375
7978
  }
7376
7979
  else if (config?.maxFee === undefined) {
7377
7980
  // If the max fee is not provided and the transfer speed is fast, dynamically calculate the max fee
7378
- const baseFeeInBps = await fetchUsdcFastBurnFee(source.chain.cctp.domain, destination.chain.cctp.domain, source.chain.isTestnet);
7981
+ const scaledBps = await fetchUsdcFastBurnFee(source.chain.cctp.domain, destination.chain.cctp.domain, source.chain.isTestnet);
7379
7982
  // Calculate fee proportional to the transfer amount
7380
7983
  // Convert amount to minor units
7381
7984
  const amountInMinorUnits = BigInt(amount);
7382
7985
  // Calculate base fee proportional to the transfer amount
7383
- // Formula: (baseFeeInBps * amountInMinorUnits) / 10_000
7986
+ // The API returns basis points as a decimal (e.g., "1.3" for 1.3 bps).
7987
+ // fetchUsdcFastBurnFee scales by 100 to preserve precision, so we divide by 1,000,000.
7988
+ // Formula: (scaledBps * amountInMinorUnits) / 1_000_000
7384
7989
  // We use ceiling division (round up) to ensure sufficient fees and avoid failover to slow burn
7385
- // Ceiling division: (a + b - 1) / b, where b = 10_000, so (b - 1) = 9_999n
7386
- const baseFee = (baseFeeInBps * amountInMinorUnits + 9999n) / 10000n;
7990
+ // Ceiling division: (a + b - 1) / b, where b = 1_000_000, so (b - 1) = 999_999n
7991
+ const baseFee = (scaledBps * amountInMinorUnits + 999999n) / 1000000n;
7387
7992
  // Add 10% buffer to account for fee fluctuations: fee + (fee * 10 / 100) = fee + (fee / 10)
7388
7993
  maxFee = baseFee + baseFee / 10n;
7389
7994
  }
@@ -7487,5 +8092,5 @@ class CCTPV2BridgingProvider extends BridgingProvider {
7487
8092
  }
7488
8093
  }
7489
8094
 
7490
- export { CCTPV2BridgingProvider, getMintRecipientAccount };
8095
+ export { CCTPV2BridgingProvider, getBlocksUntilExpiry, getMintRecipientAccount, isAttestationExpired, isMintFailureRelatedToAttestation };
7491
8096
  //# sourceMappingURL=index.mjs.map