@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.cjs 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
  *
@@ -385,8 +385,11 @@ const ArcTestnet = defineChain({
385
385
  name: 'Arc Testnet',
386
386
  title: 'ArcTestnet',
387
387
  nativeCurrency: {
388
- name: 'Arc',
389
- symbol: 'Arc',
388
+ name: 'USDC',
389
+ symbol: 'USDC',
390
+ // Arc uses native USDC with 18 decimals for gas payments (EVM standard).
391
+ // Note: The ERC-20 USDC contract at usdcAddress uses 6 decimals.
392
+ // See: https://docs.arc.network/arc/references/contract-addresses
390
393
  decimals: 18,
391
394
  },
392
395
  chainId: 5042002,
@@ -3020,7 +3023,9 @@ const makeApiRequest = async (url, method, isValidType, config, body) => {
3020
3023
  signal: controller.signal,
3021
3024
  };
3022
3025
  // Add body for methods that support it
3023
- if (body !== undefined && ['POST', 'PUT', 'PATCH'].includes(method)) ;
3026
+ if (body !== undefined && ['POST', 'PUT', 'PATCH'].includes(method)) {
3027
+ requestInit.body = JSON.stringify(body);
3028
+ }
3024
3029
  const response = await fetch(url, requestInit);
3025
3030
  clearTimeout(timeoutId);
3026
3031
  if (!response.ok) {
@@ -3198,6 +3203,30 @@ const pollApiWithValidation = async (url, method, isValidType, config = {}, body
3198
3203
  const pollApiGet = async (url, isValidType, config) => {
3199
3204
  return pollApiWithValidation(url, 'GET', isValidType, config);
3200
3205
  };
3206
+ /**
3207
+ * Convenience function for making POST requests with validation.
3208
+ *
3209
+ * @typeParam TResponseType - The expected response type after validation
3210
+ * @typeParam TBody - The type of the request body
3211
+ * @param url - The API endpoint URL
3212
+ * @param body - The request body
3213
+ * @param isValidType - Type guard function to validate the response
3214
+ * @param config - Optional configuration overrides
3215
+ * @returns Promise resolving to the validated response
3216
+ *
3217
+ * @example
3218
+ * ```typescript
3219
+ * const result = await pollApiPost(
3220
+ * 'https://api.example.com/submit',
3221
+ * { name: 'John', email: 'john@example.com' },
3222
+ * isSubmissionResponse,
3223
+ * { maxRetries: 3 }
3224
+ * )
3225
+ * ```
3226
+ */
3227
+ const pollApiPost = async (url, body, isValidType, config) => {
3228
+ return pollApiWithValidation(url, 'POST', isValidType, config, body);
3229
+ };
3201
3230
 
3202
3231
  /**
3203
3232
  * Valid recoverability values for error handling strategies.
@@ -3405,6 +3434,24 @@ function validateErrorDetails(details) {
3405
3434
  * stays within KitError's constraints.
3406
3435
  */
3407
3436
  const MAX_MESSAGE_LENGTH = 950;
3437
+ /**
3438
+ * Standard error message for invalid amount format.
3439
+ *
3440
+ * The SDK enforces strict dot-decimal notation for amount values. This constant
3441
+ * provides a consistent error message when users provide amounts with:
3442
+ * - Comma decimals (e.g., "1,5")
3443
+ * - Thousand separators (e.g., "1,000.50")
3444
+ * - Non-numeric characters
3445
+ * - Invalid format
3446
+ */
3447
+ 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';
3448
+ /**
3449
+ * Error message for invalid maxFee format.
3450
+ *
3451
+ * Used when validating the maxFee configuration parameter. The maxFee can be zero
3452
+ * or positive and must follow strict dot-decimal notation.
3453
+ */
3454
+ 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';
3408
3455
 
3409
3456
  /**
3410
3457
  * Structured error class for Stablecoin Kit operations.
@@ -3600,6 +3647,12 @@ const BalanceError = {
3600
3647
  code: 9001,
3601
3648
  name: 'BALANCE_INSUFFICIENT_TOKEN',
3602
3649
  type: 'BALANCE',
3650
+ },
3651
+ /** Insufficient native token (ETH/SOL/etc) for gas fees */
3652
+ INSUFFICIENT_GAS: {
3653
+ code: 9002,
3654
+ name: 'BALANCE_INSUFFICIENT_GAS',
3655
+ type: 'BALANCE',
3603
3656
  }};
3604
3657
 
3605
3658
  /**
@@ -3822,7 +3875,7 @@ function createValidationErrorFromZod(zodError, context) {
3822
3875
  *
3823
3876
  * @param chain - The blockchain network where the balance check failed
3824
3877
  * @param token - The token symbol (e.g., 'USDC', 'ETH')
3825
- * @param rawError - The original error from the underlying system (optional)
3878
+ * @param trace - Optional trace context to include in error (can include rawError and additional debugging data)
3826
3879
  * @returns KitError with insufficient token balance details
3827
3880
  *
3828
3881
  * @example
@@ -3835,24 +3888,71 @@ function createValidationErrorFromZod(zodError, context) {
3835
3888
  *
3836
3889
  * @example
3837
3890
  * ```typescript
3838
- * // With raw error for debugging
3891
+ * // With trace context for debugging
3839
3892
  * try {
3840
3893
  * await transfer(...)
3841
3894
  * } catch (error) {
3842
- * throw createInsufficientTokenBalanceError('Base', 'USDC', error)
3895
+ * throw createInsufficientTokenBalanceError('Base', 'USDC', {
3896
+ * rawError: error,
3897
+ * balance: '1000000',
3898
+ * amount: '5000000',
3899
+ * })
3843
3900
  * }
3844
3901
  * ```
3845
3902
  */
3846
- function createInsufficientTokenBalanceError(chain, token, rawError) {
3903
+ function createInsufficientTokenBalanceError(chain, token, trace) {
3847
3904
  return new KitError({
3848
3905
  ...BalanceError.INSUFFICIENT_TOKEN,
3849
3906
  recoverability: 'FATAL',
3850
3907
  message: `Insufficient ${token} balance on ${chain}`,
3851
3908
  cause: {
3852
3909
  trace: {
3910
+ ...trace,
3853
3911
  chain,
3854
3912
  token,
3855
- rawError,
3913
+ },
3914
+ },
3915
+ });
3916
+ }
3917
+ /**
3918
+ * Creates error for insufficient gas funds.
3919
+ *
3920
+ * This error is thrown when a wallet does not have enough native tokens
3921
+ * (ETH, SOL, etc.) to pay for transaction gas fees. The error is FATAL
3922
+ * as it requires user intervention to add gas funds.
3923
+ *
3924
+ * @param chain - The blockchain network where the gas check failed
3925
+ * @param trace - Optional trace context to include in error (can include rawError and additional debugging data)
3926
+ * @returns KitError with insufficient gas details
3927
+ *
3928
+ * @example
3929
+ * ```typescript
3930
+ * import { createInsufficientGasError } from '@core/errors'
3931
+ *
3932
+ * throw createInsufficientGasError('Ethereum')
3933
+ * // Message: "Insufficient gas funds on Ethereum"
3934
+ * ```
3935
+ *
3936
+ * @example
3937
+ * ```typescript
3938
+ * // With trace context for debugging
3939
+ * throw createInsufficientGasError('Ethereum', {
3940
+ * rawError: error,
3941
+ * gasRequired: '21000',
3942
+ * gasAvailable: '10000',
3943
+ * walletAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb',
3944
+ * })
3945
+ * ```
3946
+ */
3947
+ function createInsufficientGasError(chain, trace) {
3948
+ return new KitError({
3949
+ ...BalanceError.INSUFFICIENT_GAS,
3950
+ recoverability: 'FATAL',
3951
+ message: `Insufficient gas funds on ${chain}`,
3952
+ cause: {
3953
+ trace: {
3954
+ ...trace,
3955
+ chain,
3856
3956
  },
3857
3957
  },
3858
3958
  });
@@ -3914,6 +4014,38 @@ function isKitError(error) {
3914
4014
  function isFatalError(error) {
3915
4015
  return isKitError(error) && error.recoverability === 'FATAL';
3916
4016
  }
4017
+ /**
4018
+ * Safely extracts error message from any error type.
4019
+ *
4020
+ * This utility handles different error types gracefully, extracting
4021
+ * meaningful messages from Error instances, string errors, or providing
4022
+ * a fallback for unknown error types. Never throws.
4023
+ *
4024
+ * @param error - Unknown error to extract message from
4025
+ * @returns Error message string, or fallback message
4026
+ *
4027
+ * @example
4028
+ * ```typescript
4029
+ * import { getErrorMessage } from '@core/errors'
4030
+ *
4031
+ * try {
4032
+ * await riskyOperation()
4033
+ * } catch (error) {
4034
+ * const message = getErrorMessage(error)
4035
+ * console.log('Error occurred:', message)
4036
+ * // Works with Error, KitError, string, or any other type
4037
+ * }
4038
+ * ```
4039
+ */
4040
+ function getErrorMessage(error) {
4041
+ if (error instanceof Error) {
4042
+ return error.message;
4043
+ }
4044
+ if (typeof error === 'string') {
4045
+ return error;
4046
+ }
4047
+ return 'An unknown error occurred';
4048
+ }
3917
4049
 
3918
4050
  /**
3919
4051
  * Validates data against a Zod schema with enhanced error reporting.
@@ -4411,41 +4543,46 @@ var TransferSpeed;
4411
4543
  * - regexMessage: error message when the basic numeric format fails.
4412
4544
  * - maxDecimals: maximum number of decimal places allowed (e.g., 6 for USDC).
4413
4545
  */
4414
- const createDecimalStringValidator = (options) => (schema) => schema
4415
- .regex(/^-?(?:\d+(?:\.\d+)?|\.\d+)$/, options.regexMessage)
4416
- .superRefine((val, ctx) => {
4417
- const amount = Number.parseFloat(val);
4418
- if (Number.isNaN(amount)) {
4419
- ctx.addIssue({
4420
- code: zod.z.ZodIssueCode.custom,
4421
- message: options.regexMessage,
4422
- });
4423
- return;
4424
- }
4425
- // Check decimal precision if maxDecimals is specified
4426
- if (options.maxDecimals !== undefined) {
4427
- const decimalPart = val.split('.')[1];
4428
- if (decimalPart && decimalPart.length > options.maxDecimals) {
4546
+ const createDecimalStringValidator = (options) => (schema) => {
4547
+ // Capitalize first letter of attribute name for error messages
4548
+ const capitalizedAttributeName = options.attributeName.charAt(0).toUpperCase() +
4549
+ options.attributeName.slice(1);
4550
+ return schema
4551
+ .regex(/^-?(?:\d+(?:\.\d+)?|\.\d+)$/, options.regexMessage)
4552
+ .superRefine((val, ctx) => {
4553
+ const amount = Number.parseFloat(val);
4554
+ if (Number.isNaN(amount)) {
4429
4555
  ctx.addIssue({
4430
4556
  code: zod.z.ZodIssueCode.custom,
4431
- message: `Maximum supported decimal places: ${options.maxDecimals.toString()}`,
4557
+ message: options.regexMessage,
4432
4558
  });
4433
4559
  return;
4434
4560
  }
4435
- }
4436
- if (options.allowZero && amount < 0) {
4437
- ctx.addIssue({
4438
- code: zod.z.ZodIssueCode.custom,
4439
- message: `${options.attributeName} must be non-negative`,
4440
- });
4441
- }
4442
- else if (!options.allowZero && amount <= 0) {
4443
- ctx.addIssue({
4444
- code: zod.z.ZodIssueCode.custom,
4445
- message: `${options.attributeName} must be greater than 0`,
4446
- });
4447
- }
4448
- });
4561
+ // Check decimal precision if maxDecimals is specified
4562
+ if (options.maxDecimals !== undefined) {
4563
+ const decimalPart = val.split('.')[1];
4564
+ if (decimalPart && decimalPart.length > options.maxDecimals) {
4565
+ ctx.addIssue({
4566
+ code: zod.z.ZodIssueCode.custom,
4567
+ message: `Maximum supported decimal places: ${options.maxDecimals.toString()}`,
4568
+ });
4569
+ return;
4570
+ }
4571
+ }
4572
+ if (options.allowZero && amount < 0) {
4573
+ ctx.addIssue({
4574
+ code: zod.z.ZodIssueCode.custom,
4575
+ message: `${capitalizedAttributeName} must be non-negative`,
4576
+ });
4577
+ }
4578
+ else if (!options.allowZero && amount <= 0) {
4579
+ ctx.addIssue({
4580
+ code: zod.z.ZodIssueCode.custom,
4581
+ message: `${capitalizedAttributeName} must be greater than 0`,
4582
+ });
4583
+ }
4584
+ });
4585
+ };
4449
4586
  /**
4450
4587
  * Schema for validating chain definitions.
4451
4588
  * This ensures the basic structure of a chain definition is valid.
@@ -4605,7 +4742,7 @@ const bridgeParamsSchema = zod.z.object({
4605
4742
  .min(1, 'Required')
4606
4743
  .pipe(createDecimalStringValidator({
4607
4744
  allowZero: false,
4608
- 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.',
4745
+ regexMessage: AMOUNT_FORMAT_ERROR_MESSAGE,
4609
4746
  attributeName: 'amount',
4610
4747
  maxDecimals: 6,
4611
4748
  })(zod.z.string())),
@@ -4618,7 +4755,7 @@ const bridgeParamsSchema = zod.z.object({
4618
4755
  .string()
4619
4756
  .pipe(createDecimalStringValidator({
4620
4757
  allowZero: true,
4621
- 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.',
4758
+ regexMessage: MAX_FEE_FORMAT_ERROR_MESSAGE,
4622
4759
  attributeName: 'maxFee',
4623
4760
  maxDecimals: 6,
4624
4761
  })(zod.z.string()))
@@ -4627,6 +4764,19 @@ const bridgeParamsSchema = zod.z.object({
4627
4764
  }),
4628
4765
  });
4629
4766
 
4767
+ /**
4768
+ * Base URL for Circle's IRIS API (mainnet/production).
4769
+ *
4770
+ * The IRIS API provides attestation services for CCTP cross-chain transfers.
4771
+ */
4772
+ const IRIS_API_BASE_URL = 'https://iris-api.circle.com';
4773
+ /**
4774
+ * Base URL for Circle's IRIS API (testnet/sandbox).
4775
+ *
4776
+ * Used for development and testing on testnet chains.
4777
+ */
4778
+ const IRIS_API_SANDBOX_BASE_URL = 'https://iris-api-sandbox.circle.com';
4779
+
4630
4780
  /**
4631
4781
  * Type guard to validate the API response structure.
4632
4782
  *
@@ -4675,9 +4825,7 @@ const isFastBurnFeeResponse = (data) => {
4675
4825
  * @returns The complete API URL
4676
4826
  */
4677
4827
  function buildFastBurnFeeUrl(sourceDomain, destinationDomain, isTestnet) {
4678
- const baseUrl = isTestnet
4679
- ? 'https://iris-api-sandbox.circle.com'
4680
- : 'https://iris-api.circle.com';
4828
+ const baseUrl = isTestnet ? IRIS_API_SANDBOX_BASE_URL : IRIS_API_BASE_URL;
4681
4829
  return `${baseUrl}/v2/burn/USDC/fees/${sourceDomain.toString()}/${destinationDomain.toString()}`;
4682
4830
  }
4683
4831
  const FAST_TIER_FINALITY_THRESHOLD = 1000;
@@ -4693,15 +4841,24 @@ const FAST_TIER_FINALITY_THRESHOLD = 1000;
4693
4841
  * - Retry delays: 9 × 200 ms = 1 800 ms
4694
4842
  * - Total max time: 2 000 ms + 1 800 ms = 3 800 ms
4695
4843
  *
4844
+ * @remarks
4845
+ * The API returns basis points as a decimal (e.g., "1.3" for 1.3 bps = 0.013%).
4846
+ * This function scales the value by 100 to preserve 2 decimal places of precision.
4847
+ * Callers must divide by 1,000,000 (instead of 10,000) when calculating fees.
4848
+ *
4696
4849
  * @param sourceDomain - The source domain
4697
4850
  * @param destinationDomain - The destination domain
4698
4851
  * @param isTestnet - Whether the request is for a testnet chain
4699
- * @returns The minimum fee for a USDC fast burn operation
4852
+ * @returns The minimum fee in scaled basis points (bps × 100)
4700
4853
  * @throws Error if the input domains are invalid, the API request fails, returns invalid data, or network errors occur
4701
4854
  * @example
4702
4855
  * ```typescript
4703
- * const minimumFee = await fetchUsdcFastBurnFee(0, 6, false) // Ethereum -> Base
4704
- * console.log(minimumFee) // 1000n
4856
+ * const scaledBps = await fetchUsdcFastBurnFee(0, 6, false) // Ethereum -> Base
4857
+ * console.log(scaledBps) // 130n (representing 1.3 bps)
4858
+ *
4859
+ * // To calculate fee for an amount:
4860
+ * const amount = 1_000_000n // 1 USDC
4861
+ * const fee = (scaledBps * amount) / 1_000_000n // 130n (0.00013 USDC)
4705
4862
  * ```
4706
4863
  */
4707
4864
  async function fetchUsdcFastBurnFee(sourceDomain, destinationDomain, isTestnet) {
@@ -4727,14 +4884,16 @@ async function fetchUsdcFastBurnFee(sourceDomain, destinationDomain, isTestnet)
4727
4884
  if (!fastTier) {
4728
4885
  throw new Error(`No fast tier (finalityThreshold: ${FAST_TIER_FINALITY_THRESHOLD.toString()}) available in API response`);
4729
4886
  }
4730
- // Convert minimumFee to bigint
4731
- let minimumFee;
4732
- try {
4733
- minimumFee = BigInt(fastTier.minimumFee);
4734
- }
4735
- catch {
4736
- throw new Error(`Invalid minimumFee value: cannot convert "${String(fastTier.minimumFee)}" to bigint`);
4887
+ // Convert minimumFee to scaled basis points (bigint)
4888
+ // The API returns basis points as a decimal (e.g., "1.3" for 1.3 bps = 0.013%).
4889
+ // We scale by 100 to preserve 2 decimal places of precision.
4890
+ // The caller (getMaxFee) must divide by 1,000,000 instead of 10,000.
4891
+ const feeValue = Number.parseFloat(String(fastTier.minimumFee));
4892
+ if (Number.isNaN(feeValue) || !Number.isFinite(feeValue)) {
4893
+ throw new Error(`Invalid minimumFee value: cannot parse "${String(fastTier.minimumFee)}" as a number`);
4737
4894
  }
4895
+ // Scale by 100 and round to get integer representation
4896
+ const minimumFee = BigInt(Math.round(feeValue * 100));
4738
4897
  // Validate that minimumFee is non-negative
4739
4898
  if (minimumFee < 0n) {
4740
4899
  throw new Error('Invalid minimumFee: value must be non-negative');
@@ -4909,9 +5068,7 @@ const isAttestationResponse = (obj) => {
4909
5068
  * ```
4910
5069
  */
4911
5070
  const buildIrisUrl = (sourceDomainId, transactionHash, isTestnet) => {
4912
- const baseUrl = isTestnet
4913
- ? 'https://iris-api-sandbox.circle.com'
4914
- : 'https://iris-api.circle.com';
5071
+ const baseUrl = isTestnet ? IRIS_API_SANDBOX_BASE_URL : IRIS_API_BASE_URL;
4915
5072
  const url = new URL(`${baseUrl}/v2/messages/${String(sourceDomainId)}`);
4916
5073
  url.searchParams.set('transactionHash', transactionHash);
4917
5074
  return url.toString();
@@ -4956,6 +5113,133 @@ const fetchAttestation = async (sourceDomainId, transactionHash, isTestnet, conf
4956
5113
  const effectiveConfig = { ...DEFAULT_CONFIG, ...config };
4957
5114
  return await pollApiGet(url, isAttestationResponse, effectiveConfig);
4958
5115
  };
5116
+ /**
5117
+ * Type guard that validates attestation response structure without requiring completion status.
5118
+ *
5119
+ * This is used by `fetchAttestationWithoutStatusCheck` to extract the nonce from an existing
5120
+ * attestation, even if the attestation is expired or pending. Unlike `isAttestationResponse`,
5121
+ * this function does not throw if no complete attestation is found.
5122
+ *
5123
+ * @param obj - The value to check, typically a parsed JSON response
5124
+ * @returns True if the object has valid attestation structure
5125
+ * @throws {Error} With "Invalid attestation response structure" if structure is invalid
5126
+ * @internal
5127
+ */
5128
+ const isAttestationResponseWithoutStatusCheck = (obj) => {
5129
+ if (!hasValidAttestationStructure(obj)) {
5130
+ throw new Error('Invalid attestation response structure');
5131
+ }
5132
+ return true;
5133
+ };
5134
+ /**
5135
+ * Fetches attestation data without requiring the attestation to be complete.
5136
+ *
5137
+ * This function is useful for retrieving attestation data (particularly the nonce)
5138
+ * from an existing transaction, even if the attestation has expired or is pending.
5139
+ * It uses minimal retries since we're fetching existing data, not waiting for completion.
5140
+ *
5141
+ * @param sourceDomainId - The CCTP domain ID of the source chain
5142
+ * @param transactionHash - The transaction hash to fetch attestation for
5143
+ * @param isTestnet - Whether this is for a testnet chain (true) or mainnet chain (false)
5144
+ * @param config - Optional configuration overrides
5145
+ * @returns The attestation response data (may contain incomplete/expired attestations)
5146
+ * @throws If the request fails, times out, or returns invalid data
5147
+ *
5148
+ * @example
5149
+ * ```typescript
5150
+ * // Fetch existing attestation to extract nonce for re-attestation
5151
+ * const response = await fetchAttestationWithoutStatusCheck(1, '0xabc...', true)
5152
+ * const nonce = response.messages[0]?.eventNonce
5153
+ * ```
5154
+ */
5155
+ const fetchAttestationWithoutStatusCheck = async (sourceDomainId, transactionHash, isTestnet, config = {}) => {
5156
+ const url = buildIrisUrl(sourceDomainId, transactionHash, isTestnet);
5157
+ // Use minimal retries since we're just fetching existing data
5158
+ const effectiveConfig = {
5159
+ ...DEFAULT_CONFIG,
5160
+ maxRetries: 3,
5161
+ ...config,
5162
+ };
5163
+ return await pollApiGet(url, isAttestationResponseWithoutStatusCheck, effectiveConfig);
5164
+ };
5165
+ /**
5166
+ * Builds the IRIS API URL for re-attestation requests.
5167
+ *
5168
+ * Constructs the URL for Circle's re-attestation endpoint that allows
5169
+ * requesting a fresh attestation for an expired nonce.
5170
+ *
5171
+ * @param nonce - The nonce from the original attestation
5172
+ * @param isTestnet - Whether this is for a testnet chain (true) or mainnet chain (false)
5173
+ * @returns A fully qualified URL string for the re-attestation endpoint
5174
+ *
5175
+ * @example
5176
+ * ```typescript
5177
+ * // Mainnet URL
5178
+ * const mainnetUrl = buildReAttestUrl('0xabc', false)
5179
+ * // => 'https://iris-api.circle.com/v2/reattest/0xabc'
5180
+ *
5181
+ * // Testnet URL
5182
+ * const testnetUrl = buildReAttestUrl('0xabc', true)
5183
+ * // => 'https://iris-api-sandbox.circle.com/v2/reattest/0xabc'
5184
+ * ```
5185
+ */
5186
+ const buildReAttestUrl = (nonce, isTestnet) => {
5187
+ const baseUrl = isTestnet ? IRIS_API_SANDBOX_BASE_URL : IRIS_API_BASE_URL;
5188
+ const url = new URL(`${baseUrl}/v2/reattest/${nonce}`);
5189
+ return url.toString();
5190
+ };
5191
+ /**
5192
+ * Type guard that validates the re-attestation API response structure.
5193
+ *
5194
+ * @param obj - The value to check, typically a parsed JSON response
5195
+ * @returns True if the object matches the ReAttestationResponse shape
5196
+ * @throws {Error} With "Invalid re-attestation response structure" if structure is invalid
5197
+ * @internal
5198
+ */
5199
+ const isReAttestationResponse = (obj) => {
5200
+ if (typeof obj !== 'object' ||
5201
+ obj === null ||
5202
+ !('message' in obj) ||
5203
+ !('nonce' in obj) ||
5204
+ typeof obj.message !== 'string' ||
5205
+ typeof obj.nonce !== 'string') {
5206
+ throw new Error('Invalid re-attestation response structure');
5207
+ }
5208
+ return true;
5209
+ };
5210
+ /**
5211
+ * Requests re-attestation for an expired attestation nonce.
5212
+ *
5213
+ * This function calls Circle's re-attestation API endpoint to request a fresh
5214
+ * attestation for a previously issued nonce. After calling this function,
5215
+ * you should poll `fetchAttestation` to retrieve the new attestation.
5216
+ *
5217
+ * @param nonce - The nonce from the original (expired) attestation
5218
+ * @param isTestnet - Whether this is for a testnet chain (true) or mainnet chain (false)
5219
+ * @param config - Optional configuration overrides for the request
5220
+ * @returns The re-attestation response confirming the request was accepted
5221
+ * @throws If the request fails, times out, or returns invalid data
5222
+ *
5223
+ * @example
5224
+ * ```typescript
5225
+ * // Request re-attestation for an expired nonce
5226
+ * const response = await requestReAttestation('0xabc', true)
5227
+ * console.log(response.message) // "Re-attestation successfully requested for nonce."
5228
+ *
5229
+ * // After requesting re-attestation, poll for the new attestation
5230
+ * const attestation = await fetchAttestation(domainId, txHash, true)
5231
+ * ```
5232
+ */
5233
+ const requestReAttestation = async (nonce, isTestnet, config = {}) => {
5234
+ const url = buildReAttestUrl(nonce, isTestnet);
5235
+ // Use minimal retries since we're just submitting a request, not polling for state
5236
+ const effectiveConfig = {
5237
+ ...DEFAULT_CONFIG,
5238
+ maxRetries: 3,
5239
+ ...config,
5240
+ };
5241
+ return await pollApiPost(url, {}, isReAttestationResponse, effectiveConfig);
5242
+ };
4959
5243
 
4960
5244
  const assertCCTPv2WalletContextSymbol = Symbol('assertCCTPv2WalletContext');
4961
5245
  /**
@@ -5193,6 +5477,170 @@ mintAddress) => {
5193
5477
  }
5194
5478
  };
5195
5479
 
5480
+ /**
5481
+ * Converts a block number to bigint with validation.
5482
+ *
5483
+ * @param value - The block number value to convert (bigint, number, or string)
5484
+ * @returns The validated block number as a bigint
5485
+ * @throws KitError If the value is invalid (empty string, non-integer, negative, etc.)
5486
+ * @internal
5487
+ */
5488
+ const toBlockNumber = (value) => {
5489
+ if (value === null || value === undefined) {
5490
+ throw createValidationFailedError('blockNumber', value, 'cannot be null or undefined');
5491
+ }
5492
+ // Empty string edge case - BigInt('') === 0n which is misleading
5493
+ if (value === '') {
5494
+ throw createValidationFailedError('blockNumber', value, 'cannot be empty string');
5495
+ }
5496
+ // For numbers, validate before BigInt conversion
5497
+ if (typeof value === 'number') {
5498
+ if (!Number.isFinite(value) || !Number.isInteger(value)) {
5499
+ throw createValidationFailedError('blockNumber', value, 'must be a finite integer');
5500
+ }
5501
+ }
5502
+ let result;
5503
+ try {
5504
+ result = BigInt(value);
5505
+ }
5506
+ catch {
5507
+ throw createValidationFailedError('blockNumber', value, 'cannot be converted to BigInt');
5508
+ }
5509
+ if (result < 0n) {
5510
+ throw createValidationFailedError('blockNumber', result.toString(), 'must be non-negative');
5511
+ }
5512
+ return result;
5513
+ };
5514
+ /**
5515
+ * Determines whether an attestation has expired based on the current block number.
5516
+ *
5517
+ * An attestation expires when the destination chain's current block number is greater
5518
+ * than or equal to the expiration block specified in the attestation message.
5519
+ * Slow transfers and re-attested messages have `expirationBlock: '0'` and never expire.
5520
+ *
5521
+ * @param attestation - The attestation message containing expiration block information
5522
+ * @param currentBlockNumber - The current block number on the destination chain (bigint, number, or string)
5523
+ * @returns `true` if the attestation has expired, `false` if still valid or never expires
5524
+ * @throws KitError If currentBlockNumber or expirationBlock is invalid
5525
+ *
5526
+ * @example
5527
+ * ```typescript
5528
+ * import { isAttestationExpired } from '@circle-fin/cctp-v2-provider'
5529
+ *
5530
+ * // Check if attestation is expired on EVM chain
5531
+ * const publicClient = await adapter.getPublicClient(destinationChain)
5532
+ * const currentBlock = await publicClient.getBlockNumber()
5533
+ * const expired = isAttestationExpired(attestation, currentBlock)
5534
+ *
5535
+ * if (expired) {
5536
+ * const freshAttestation = await provider.reAttest(source, burnTxHash)
5537
+ * }
5538
+ * ```
5539
+ *
5540
+ * @example
5541
+ * ```typescript
5542
+ * // Check on Solana
5543
+ * const slot = await adapter.getConnection(destinationChain).getSlot()
5544
+ * const expired = isAttestationExpired(attestation, slot)
5545
+ * ```
5546
+ */
5547
+ const isAttestationExpired = (attestation, currentBlockNumber) => {
5548
+ const currentBlock = toBlockNumber(currentBlockNumber);
5549
+ const expiration = toBlockNumber(attestation.decodedMessage.decodedMessageBody.expirationBlock);
5550
+ // 0n means it never expires (re-attested messages and slow transfers)
5551
+ if (expiration === 0n) {
5552
+ return false;
5553
+ }
5554
+ // Attestation is expired if current block >= expiration block
5555
+ return currentBlock >= expiration;
5556
+ };
5557
+ /**
5558
+ * Calculates the number of blocks remaining until an attestation expires.
5559
+ *
5560
+ * Returns the difference between the expiration block and the current block number.
5561
+ * Returns `null` if the attestation has `expirationBlock: '0'` (never expires).
5562
+ * Returns `0n` or a negative bigint if the attestation has already expired.
5563
+ *
5564
+ * @param attestation - The attestation message containing expiration block information
5565
+ * @param currentBlockNumber - The current block number on the destination chain (bigint, number, or string)
5566
+ * @returns The number of blocks until expiry as a bigint, or `null` if the attestation never expires
5567
+ * @throws KitError If currentBlockNumber or expirationBlock is invalid
5568
+ *
5569
+ * @example
5570
+ * ```typescript
5571
+ * import { getBlocksUntilExpiry } from '@circle-fin/cctp-v2-provider'
5572
+ *
5573
+ * const publicClient = await adapter.getPublicClient(destinationChain)
5574
+ * const currentBlock = await publicClient.getBlockNumber()
5575
+ * const blocksRemaining = getBlocksUntilExpiry(attestation, currentBlock)
5576
+ *
5577
+ * if (blocksRemaining === null) {
5578
+ * console.log('Attestation never expires')
5579
+ * } else if (blocksRemaining <= 0n) {
5580
+ * console.log('Attestation has expired')
5581
+ * } else {
5582
+ * console.log(`${blocksRemaining} blocks until expiry`)
5583
+ * }
5584
+ * ```
5585
+ */
5586
+ const getBlocksUntilExpiry = (attestation, currentBlockNumber) => {
5587
+ const currentBlock = toBlockNumber(currentBlockNumber);
5588
+ const expiration = toBlockNumber(attestation.decodedMessage.decodedMessageBody.expirationBlock);
5589
+ // 0n means it never expires (re-attested messages and slow transfers)
5590
+ if (expiration === 0n) {
5591
+ return null;
5592
+ }
5593
+ // Return the difference (can be negative if expired)
5594
+ return expiration - currentBlock;
5595
+ };
5596
+ /**
5597
+ * Determines whether a mint failure was caused by an expired attestation.
5598
+ *
5599
+ * This function inspects the error thrown during a mint operation to detect
5600
+ * if the failure is due to the attestation's expiration block being exceeded.
5601
+ * When this returns `true`, the caller should use `reAttest()` to obtain a
5602
+ * fresh attestation before retrying the mint.
5603
+ *
5604
+ * @param error - The error thrown during the mint operation
5605
+ * @returns `true` if the error indicates the attestation has expired, `false` otherwise
5606
+ *
5607
+ * @example
5608
+ * ```typescript
5609
+ * import { isMintFailureRelatedToAttestation } from '@circle-fin/cctp-v2-provider'
5610
+ *
5611
+ * try {
5612
+ * await mintRequest.execute()
5613
+ * } catch (error) {
5614
+ * if (isMintFailureRelatedToAttestation(error)) {
5615
+ * // Attestation expired - get a fresh one
5616
+ * const freshAttestation = await provider.reAttest(source, burnTxHash)
5617
+ * const newMintRequest = await provider.mint(source, destination, freshAttestation)
5618
+ * await newMintRequest.execute()
5619
+ * } else {
5620
+ * throw error
5621
+ * }
5622
+ * }
5623
+ * ```
5624
+ */
5625
+ const isMintFailureRelatedToAttestation = (error) => {
5626
+ if (error === null || error === undefined) {
5627
+ return false;
5628
+ }
5629
+ const errorString = getErrorMessage(error).toLowerCase();
5630
+ // Check for Solana "MessageExpired" error pattern
5631
+ // Full error: "AnchorError thrown in ...handle_receive_finalized_message.rs:169.
5632
+ // Error Code: MessageExpired. Error Number: 6016. Error Message: Message has expired."
5633
+ if (errorString.includes('messageexpired')) {
5634
+ return true;
5635
+ }
5636
+ // Check for EVM attestation expiry errors
5637
+ // Contract reverts with: "Message expired and must be re-signed"
5638
+ if (errorString.includes('message') && errorString.includes('expired')) {
5639
+ return true;
5640
+ }
5641
+ return false;
5642
+ };
5643
+
5196
5644
  /**
5197
5645
  * Checks if a decoded attestation field matches the corresponding transfer parameter.
5198
5646
  * If the values do not match, appends a descriptive error message to the errors array.
@@ -6036,21 +6484,64 @@ const validateBalanceForTransaction = async (params) => {
6036
6484
  // Extract chain name from operationContext
6037
6485
  const chainName = extractChainInfo(operationContext.chain).name;
6038
6486
  // Create KitError with rich context in trace
6039
- const error = createInsufficientTokenBalanceError(chainName, token);
6040
- // Enhance error with additional context for debugging
6041
- if (error.cause) {
6042
- const existingTrace = typeof error.cause.trace === 'object' && error.cause.trace
6043
- ? error.cause.trace
6044
- : {};
6045
- error.cause.trace = {
6046
- ...existingTrace,
6047
- balance: balance.toString(),
6048
- amount,
6049
- tokenAddress,
6050
- walletAddress: operationContext.address,
6051
- };
6052
- }
6053
- throw error;
6487
+ throw createInsufficientTokenBalanceError(chainName, token, {
6488
+ balance: balance.toString(),
6489
+ amount,
6490
+ tokenAddress,
6491
+ walletAddress: operationContext.address,
6492
+ });
6493
+ }
6494
+ };
6495
+
6496
+ /**
6497
+ * Validate that the adapter has sufficient native token balance for transaction fees.
6498
+ *
6499
+ * This function checks if the adapter's current native token balance (ETH, SOL, etc.)
6500
+ * is greater than zero. It throws a KitError with code 9002 (BALANCE_INSUFFICIENT_GAS)
6501
+ * if the balance is zero, indicating the wallet cannot pay for transaction fees.
6502
+ *
6503
+ * @param params - The validation parameters containing adapter and operation context.
6504
+ * @returns A promise that resolves to void if validation passes.
6505
+ * @throws {KitError} When the adapter's native balance is zero (code: 9002).
6506
+ *
6507
+ * @example
6508
+ * ```typescript
6509
+ * import { validateNativeBalanceForTransaction } from '@core/adapter'
6510
+ * import { createViemAdapterFromPrivateKey } from '@circle-fin/adapter-viem-v2'
6511
+ * import { isKitError, ERROR_TYPES } from '@core/errors'
6512
+ *
6513
+ * const adapter = createViemAdapterFromPrivateKey({
6514
+ * privateKey: '0x...',
6515
+ * chain: 'Ethereum',
6516
+ * })
6517
+ *
6518
+ * try {
6519
+ * await validateNativeBalanceForTransaction({
6520
+ * adapter,
6521
+ * operationContext: { chain: 'Ethereum' },
6522
+ * })
6523
+ * console.log('Native balance validation passed')
6524
+ * } catch (error) {
6525
+ * if (isKitError(error) && error.type === ERROR_TYPES.BALANCE) {
6526
+ * console.error('Insufficient gas funds:', error.message)
6527
+ * }
6528
+ * }
6529
+ * ```
6530
+ */
6531
+ const validateNativeBalanceForTransaction = async (params) => {
6532
+ const { adapter, operationContext } = params;
6533
+ const balancePrepared = await adapter.prepareAction('native.balanceOf', {
6534
+ walletAddress: operationContext.address,
6535
+ }, operationContext);
6536
+ const balance = await balancePrepared.execute();
6537
+ if (BigInt(balance) === 0n) {
6538
+ // Extract chain name from operationContext
6539
+ const chainName = extractChainInfo(operationContext.chain).name;
6540
+ // Create KitError with rich context in trace
6541
+ throw createInsufficientGasError(chainName, {
6542
+ balance: '0',
6543
+ walletAddress: operationContext.address,
6544
+ });
6054
6545
  }
6055
6546
  };
6056
6547
 
@@ -6927,16 +7418,28 @@ class CCTPV2BridgingProvider extends BridgingProvider {
6927
7418
  async bridge(params) {
6928
7419
  // CCTP-specific bridge params validation (includes base validation)
6929
7420
  assertCCTPv2BridgeParams(params);
6930
- const { source, amount, token } = params;
7421
+ const { source, destination, amount, token } = params;
6931
7422
  // Extract operation context from source wallet context for balance validation
6932
- const operationContext = this.extractOperationContext(source);
6933
- // Validate balance for transaction
7423
+ const sourceOperationContext = this.extractOperationContext(source);
7424
+ // Validate USDC balance for transaction on source chain
6934
7425
  await validateBalanceForTransaction({
6935
7426
  adapter: source.adapter,
6936
7427
  amount,
6937
7428
  token,
6938
7429
  tokenAddress: source.chain.usdcAddress,
6939
- operationContext,
7430
+ operationContext: sourceOperationContext,
7431
+ });
7432
+ // Validate native balance > 0 for gas fees on source chain
7433
+ await validateNativeBalanceForTransaction({
7434
+ adapter: source.adapter,
7435
+ operationContext: sourceOperationContext,
7436
+ });
7437
+ // Extract operation context from destination wallet context
7438
+ const destinationOperationContext = this.extractOperationContext(destination);
7439
+ // Validate native balance > 0 for gas fees on destination chain
7440
+ await validateNativeBalanceForTransaction({
7441
+ adapter: destination.adapter,
7442
+ operationContext: destinationOperationContext,
6940
7443
  });
6941
7444
  return bridge(params, this);
6942
7445
  }
@@ -7017,6 +7520,19 @@ class CCTPV2BridgingProvider extends BridgingProvider {
7017
7520
  : Promise.resolve(0n),
7018
7521
  ]);
7019
7522
  const estimateResult = {
7523
+ token: params.token,
7524
+ amount: params.amount,
7525
+ source: {
7526
+ address: source.address,
7527
+ chain: source.chain.chain,
7528
+ },
7529
+ destination: {
7530
+ address: destination.address,
7531
+ chain: destination.chain.chain,
7532
+ ...(destination.recipientAddress && {
7533
+ recipientAddress: destination.recipientAddress,
7534
+ }),
7535
+ },
7020
7536
  gasFees: [],
7021
7537
  fees: [],
7022
7538
  };
@@ -7302,6 +7818,93 @@ class CCTPV2BridgingProvider extends BridgingProvider {
7302
7818
  throw error;
7303
7819
  }
7304
7820
  }
7821
+ /**
7822
+ * Requests a fresh attestation for an expired attestation.
7823
+ *
7824
+ * This method is used when the original attestation has expired before the mint
7825
+ * transaction could be completed. It performs three steps:
7826
+ * 1. Fetches the existing attestation data to extract the nonce
7827
+ * 2. Requests re-attestation from Circle's API using the nonce
7828
+ * 3. Polls for the fresh attestation and returns it
7829
+ *
7830
+ * @typeParam TFromAdapterCapabilities - The type representing the capabilities of the source adapter
7831
+ * @param source - The source wallet context containing the chain definition and wallet address
7832
+ * @param transactionHash - The transaction hash of the original burn transaction
7833
+ * @param config - Optional polling configuration overrides for timeout, retries, and delay
7834
+ * @returns A promise that resolves to the fresh attestation message
7835
+ * @throws {Error} With "Failed to re-attest: No nonce found for transaction" if the original
7836
+ * attestation cannot be found or has no nonce
7837
+ * @throws {Error} With "Failed to re-attest: No attestation found after re-attestation request"
7838
+ * if the fresh attestation cannot be retrieved
7839
+ * @throws {Error} With "Failed to re-attest: {details}" for other errors
7840
+ *
7841
+ * @example
7842
+ * ```typescript
7843
+ * import { CCTPV2BridgingProvider } from '@circle-fin/provider-cctp-v2'
7844
+ * import { Chains } from '@core/chains'
7845
+ *
7846
+ * const provider = new CCTPV2BridgingProvider()
7847
+ *
7848
+ * // After a mint fails due to expired attestation, request a fresh one
7849
+ * const source = {
7850
+ * adapter: viemAdapter,
7851
+ * chain: Chains.EthereumSepolia,
7852
+ * address: '0x1234...'
7853
+ * }
7854
+ *
7855
+ * try {
7856
+ * const freshAttestation = await provider.reAttest(
7857
+ * source,
7858
+ * '0xabc123...', // Original burn transaction hash
7859
+ * { timeout: 10000, maxRetries: 5 }
7860
+ * )
7861
+ *
7862
+ * // Use the fresh attestation to retry the mint
7863
+ * const mintRequest = await provider.mint(source, destination, freshAttestation)
7864
+ * const result = await mintRequest.execute()
7865
+ * } catch (error) {
7866
+ * console.error('Re-attestation failed:', error.message)
7867
+ * }
7868
+ * ```
7869
+ */
7870
+ async reAttest(source, transactionHash, config) {
7871
+ assertCCTPv2WalletContext(source);
7872
+ if (!transactionHash ||
7873
+ typeof transactionHash !== 'string' ||
7874
+ transactionHash.trim() === '') {
7875
+ throw new Error('Failed to re-attest: Invalid transaction hash');
7876
+ }
7877
+ try {
7878
+ // Merge configs: defaults <- global config <- per-call config
7879
+ const effectiveConfig = {
7880
+ ...this.config?.attestation,
7881
+ ...config,
7882
+ };
7883
+ // Step 1: Get existing attestation data to extract nonce
7884
+ const existingAttestation = await fetchAttestationWithoutStatusCheck(source.chain.cctp.domain, transactionHash, source.chain.isTestnet, effectiveConfig);
7885
+ const nonce = existingAttestation.messages[0]?.eventNonce;
7886
+ if (!nonce || typeof nonce !== 'string') {
7887
+ throw new Error('Failed to re-attest: No nonce found for transaction');
7888
+ }
7889
+ // Step 2: Request re-attestation
7890
+ await requestReAttestation(nonce, source.chain.isTestnet, effectiveConfig);
7891
+ // Step 3: Poll for fresh attestation
7892
+ const response = await fetchAttestation(source.chain.cctp.domain, transactionHash, source.chain.isTestnet, effectiveConfig);
7893
+ const message = response.messages[0];
7894
+ if (!message) {
7895
+ throw new Error('Failed to re-attest: No attestation found after re-attestation request');
7896
+ }
7897
+ return message;
7898
+ }
7899
+ catch (err) {
7900
+ const error = err instanceof Error ? err : new Error(String(err));
7901
+ // Always prefix with 'Failed to re-attest'
7902
+ if (!error.message.startsWith('Failed to re-attest')) {
7903
+ throw new Error(`Failed to re-attest: ${error.message}`);
7904
+ }
7905
+ throw error;
7906
+ }
7907
+ }
7305
7908
  /**
7306
7909
  * Checks if both source and destination chains support CCTP v2 transfers.
7307
7910
  *
@@ -7381,15 +7984,17 @@ class CCTPV2BridgingProvider extends BridgingProvider {
7381
7984
  }
7382
7985
  else if (config?.maxFee === undefined) {
7383
7986
  // If the max fee is not provided and the transfer speed is fast, dynamically calculate the max fee
7384
- const baseFeeInBps = await fetchUsdcFastBurnFee(source.chain.cctp.domain, destination.chain.cctp.domain, source.chain.isTestnet);
7987
+ const scaledBps = await fetchUsdcFastBurnFee(source.chain.cctp.domain, destination.chain.cctp.domain, source.chain.isTestnet);
7385
7988
  // Calculate fee proportional to the transfer amount
7386
7989
  // Convert amount to minor units
7387
7990
  const amountInMinorUnits = BigInt(amount);
7388
7991
  // Calculate base fee proportional to the transfer amount
7389
- // Formula: (baseFeeInBps * amountInMinorUnits) / 10_000
7992
+ // The API returns basis points as a decimal (e.g., "1.3" for 1.3 bps).
7993
+ // fetchUsdcFastBurnFee scales by 100 to preserve precision, so we divide by 1,000,000.
7994
+ // Formula: (scaledBps * amountInMinorUnits) / 1_000_000
7390
7995
  // We use ceiling division (round up) to ensure sufficient fees and avoid failover to slow burn
7391
- // Ceiling division: (a + b - 1) / b, where b = 10_000, so (b - 1) = 9_999n
7392
- const baseFee = (baseFeeInBps * amountInMinorUnits + 9999n) / 10000n;
7996
+ // Ceiling division: (a + b - 1) / b, where b = 1_000_000, so (b - 1) = 999_999n
7997
+ const baseFee = (scaledBps * amountInMinorUnits + 999999n) / 1000000n;
7393
7998
  // Add 10% buffer to account for fee fluctuations: fee + (fee * 10 / 100) = fee + (fee / 10)
7394
7999
  maxFee = baseFee + baseFee / 10n;
7395
8000
  }
@@ -7494,5 +8099,8 @@ class CCTPV2BridgingProvider extends BridgingProvider {
7494
8099
  }
7495
8100
 
7496
8101
  exports.CCTPV2BridgingProvider = CCTPV2BridgingProvider;
8102
+ exports.getBlocksUntilExpiry = getBlocksUntilExpiry;
7497
8103
  exports.getMintRecipientAccount = getMintRecipientAccount;
8104
+ exports.isAttestationExpired = isAttestationExpired;
8105
+ exports.isMintFailureRelatedToAttestation = isMintFailureRelatedToAttestation;
7498
8106
  //# sourceMappingURL=index.cjs.map