@circle-fin/provider-cctp-v2 1.0.5 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @circle-fin/provider-cctp-v2
2
2
 
3
+ ## 1.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Add `reAttest` method to `CCTPV2BridgingProvider` for handling expired attestations.
8
+
9
+ When a CCTP v2 attestation expires before the mint transaction can be completed, users can now call `reAttest()` to request a fresh attestation using the original burn transaction hash.
10
+
11
+ - **Enhanced bridge estimate response**: `EstimateResult` now includes `token`, `amount`, `source`, `destination` fields to provide complete transfer information alongside cost estimates.
12
+
13
+ ### Patch Changes
14
+
15
+ - Fix fast burn fee calculation to handle decimal basis points from IRIS API
16
+
17
+ The IRIS API returns `minimumFee` as decimal basis points (e.g., `"1.3"` for 1.3 bps), but the code was attempting to convert this directly to `BigInt`, which fails for non-integer values. This caused `"Max fee must be less than amount"` errors during FAST transfers.
18
+
3
19
  ## 1.0.5
4
20
 
5
21
  ### Patch Changes
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
  *
@@ -3020,7 +3020,9 @@ const makeApiRequest = async (url, method, isValidType, config, body) => {
3020
3020
  signal: controller.signal,
3021
3021
  };
3022
3022
  // Add body for methods that support it
3023
- if (body !== undefined && ['POST', 'PUT', 'PATCH'].includes(method)) ;
3023
+ if (body !== undefined && ['POST', 'PUT', 'PATCH'].includes(method)) {
3024
+ requestInit.body = JSON.stringify(body);
3025
+ }
3024
3026
  const response = await fetch(url, requestInit);
3025
3027
  clearTimeout(timeoutId);
3026
3028
  if (!response.ok) {
@@ -3198,6 +3200,30 @@ const pollApiWithValidation = async (url, method, isValidType, config = {}, body
3198
3200
  const pollApiGet = async (url, isValidType, config) => {
3199
3201
  return pollApiWithValidation(url, 'GET', isValidType, config);
3200
3202
  };
3203
+ /**
3204
+ * Convenience function for making POST requests with validation.
3205
+ *
3206
+ * @typeParam TResponseType - The expected response type after validation
3207
+ * @typeParam TBody - The type of the request body
3208
+ * @param url - The API endpoint URL
3209
+ * @param body - The request body
3210
+ * @param isValidType - Type guard function to validate the response
3211
+ * @param config - Optional configuration overrides
3212
+ * @returns Promise resolving to the validated response
3213
+ *
3214
+ * @example
3215
+ * ```typescript
3216
+ * const result = await pollApiPost(
3217
+ * 'https://api.example.com/submit',
3218
+ * { name: 'John', email: 'john@example.com' },
3219
+ * isSubmissionResponse,
3220
+ * { maxRetries: 3 }
3221
+ * )
3222
+ * ```
3223
+ */
3224
+ const pollApiPost = async (url, body, isValidType, config) => {
3225
+ return pollApiWithValidation(url, 'POST', isValidType, config, body);
3226
+ };
3201
3227
 
3202
3228
  /**
3203
3229
  * Valid recoverability values for error handling strategies.
@@ -3405,6 +3431,24 @@ function validateErrorDetails(details) {
3405
3431
  * stays within KitError's constraints.
3406
3432
  */
3407
3433
  const MAX_MESSAGE_LENGTH = 950;
3434
+ /**
3435
+ * Standard error message for invalid amount format.
3436
+ *
3437
+ * The SDK enforces strict dot-decimal notation for amount values. This constant
3438
+ * provides a consistent error message when users provide amounts with:
3439
+ * - Comma decimals (e.g., "1,5")
3440
+ * - Thousand separators (e.g., "1,000.50")
3441
+ * - Non-numeric characters
3442
+ * - Invalid format
3443
+ */
3444
+ 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';
3445
+ /**
3446
+ * Error message for invalid maxFee format.
3447
+ *
3448
+ * Used when validating the maxFee configuration parameter. The maxFee can be zero
3449
+ * or positive and must follow strict dot-decimal notation.
3450
+ */
3451
+ 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
3452
 
3409
3453
  /**
3410
3454
  * Structured error class for Stablecoin Kit operations.
@@ -4411,41 +4455,46 @@ var TransferSpeed;
4411
4455
  * - regexMessage: error message when the basic numeric format fails.
4412
4456
  * - maxDecimals: maximum number of decimal places allowed (e.g., 6 for USDC).
4413
4457
  */
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) {
4458
+ const createDecimalStringValidator = (options) => (schema) => {
4459
+ // Capitalize first letter of attribute name for error messages
4460
+ const capitalizedAttributeName = options.attributeName.charAt(0).toUpperCase() +
4461
+ options.attributeName.slice(1);
4462
+ return schema
4463
+ .regex(/^-?(?:\d+(?:\.\d+)?|\.\d+)$/, options.regexMessage)
4464
+ .superRefine((val, ctx) => {
4465
+ const amount = Number.parseFloat(val);
4466
+ if (Number.isNaN(amount)) {
4429
4467
  ctx.addIssue({
4430
4468
  code: zod.z.ZodIssueCode.custom,
4431
- message: `Maximum supported decimal places: ${options.maxDecimals.toString()}`,
4469
+ message: options.regexMessage,
4432
4470
  });
4433
4471
  return;
4434
4472
  }
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
- });
4473
+ // Check decimal precision if maxDecimals is specified
4474
+ if (options.maxDecimals !== undefined) {
4475
+ const decimalPart = val.split('.')[1];
4476
+ if (decimalPart && decimalPart.length > options.maxDecimals) {
4477
+ ctx.addIssue({
4478
+ code: zod.z.ZodIssueCode.custom,
4479
+ message: `Maximum supported decimal places: ${options.maxDecimals.toString()}`,
4480
+ });
4481
+ return;
4482
+ }
4483
+ }
4484
+ if (options.allowZero && amount < 0) {
4485
+ ctx.addIssue({
4486
+ code: zod.z.ZodIssueCode.custom,
4487
+ message: `${capitalizedAttributeName} must be non-negative`,
4488
+ });
4489
+ }
4490
+ else if (!options.allowZero && amount <= 0) {
4491
+ ctx.addIssue({
4492
+ code: zod.z.ZodIssueCode.custom,
4493
+ message: `${capitalizedAttributeName} must be greater than 0`,
4494
+ });
4495
+ }
4496
+ });
4497
+ };
4449
4498
  /**
4450
4499
  * Schema for validating chain definitions.
4451
4500
  * This ensures the basic structure of a chain definition is valid.
@@ -4605,7 +4654,7 @@ const bridgeParamsSchema = zod.z.object({
4605
4654
  .min(1, 'Required')
4606
4655
  .pipe(createDecimalStringValidator({
4607
4656
  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.',
4657
+ regexMessage: AMOUNT_FORMAT_ERROR_MESSAGE,
4609
4658
  attributeName: 'amount',
4610
4659
  maxDecimals: 6,
4611
4660
  })(zod.z.string())),
@@ -4618,7 +4667,7 @@ const bridgeParamsSchema = zod.z.object({
4618
4667
  .string()
4619
4668
  .pipe(createDecimalStringValidator({
4620
4669
  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.',
4670
+ regexMessage: MAX_FEE_FORMAT_ERROR_MESSAGE,
4622
4671
  attributeName: 'maxFee',
4623
4672
  maxDecimals: 6,
4624
4673
  })(zod.z.string()))
@@ -4627,6 +4676,19 @@ const bridgeParamsSchema = zod.z.object({
4627
4676
  }),
4628
4677
  });
4629
4678
 
4679
+ /**
4680
+ * Base URL for Circle's IRIS API (mainnet/production).
4681
+ *
4682
+ * The IRIS API provides attestation services for CCTP cross-chain transfers.
4683
+ */
4684
+ const IRIS_API_BASE_URL = 'https://iris-api.circle.com';
4685
+ /**
4686
+ * Base URL for Circle's IRIS API (testnet/sandbox).
4687
+ *
4688
+ * Used for development and testing on testnet chains.
4689
+ */
4690
+ const IRIS_API_SANDBOX_BASE_URL = 'https://iris-api-sandbox.circle.com';
4691
+
4630
4692
  /**
4631
4693
  * Type guard to validate the API response structure.
4632
4694
  *
@@ -4675,9 +4737,7 @@ const isFastBurnFeeResponse = (data) => {
4675
4737
  * @returns The complete API URL
4676
4738
  */
4677
4739
  function buildFastBurnFeeUrl(sourceDomain, destinationDomain, isTestnet) {
4678
- const baseUrl = isTestnet
4679
- ? 'https://iris-api-sandbox.circle.com'
4680
- : 'https://iris-api.circle.com';
4740
+ const baseUrl = isTestnet ? IRIS_API_SANDBOX_BASE_URL : IRIS_API_BASE_URL;
4681
4741
  return `${baseUrl}/v2/burn/USDC/fees/${sourceDomain.toString()}/${destinationDomain.toString()}`;
4682
4742
  }
4683
4743
  const FAST_TIER_FINALITY_THRESHOLD = 1000;
@@ -4693,15 +4753,24 @@ const FAST_TIER_FINALITY_THRESHOLD = 1000;
4693
4753
  * - Retry delays: 9 × 200 ms = 1 800 ms
4694
4754
  * - Total max time: 2 000 ms + 1 800 ms = 3 800 ms
4695
4755
  *
4756
+ * @remarks
4757
+ * The API returns basis points as a decimal (e.g., "1.3" for 1.3 bps = 0.013%).
4758
+ * This function scales the value by 100 to preserve 2 decimal places of precision.
4759
+ * Callers must divide by 1,000,000 (instead of 10,000) when calculating fees.
4760
+ *
4696
4761
  * @param sourceDomain - The source domain
4697
4762
  * @param destinationDomain - The destination domain
4698
4763
  * @param isTestnet - Whether the request is for a testnet chain
4699
- * @returns The minimum fee for a USDC fast burn operation
4764
+ * @returns The minimum fee in scaled basis points (bps × 100)
4700
4765
  * @throws Error if the input domains are invalid, the API request fails, returns invalid data, or network errors occur
4701
4766
  * @example
4702
4767
  * ```typescript
4703
- * const minimumFee = await fetchUsdcFastBurnFee(0, 6, false) // Ethereum -> Base
4704
- * console.log(minimumFee) // 1000n
4768
+ * const scaledBps = await fetchUsdcFastBurnFee(0, 6, false) // Ethereum -> Base
4769
+ * console.log(scaledBps) // 130n (representing 1.3 bps)
4770
+ *
4771
+ * // To calculate fee for an amount:
4772
+ * const amount = 1_000_000n // 1 USDC
4773
+ * const fee = (scaledBps * amount) / 1_000_000n // 130n (0.00013 USDC)
4705
4774
  * ```
4706
4775
  */
4707
4776
  async function fetchUsdcFastBurnFee(sourceDomain, destinationDomain, isTestnet) {
@@ -4727,14 +4796,16 @@ async function fetchUsdcFastBurnFee(sourceDomain, destinationDomain, isTestnet)
4727
4796
  if (!fastTier) {
4728
4797
  throw new Error(`No fast tier (finalityThreshold: ${FAST_TIER_FINALITY_THRESHOLD.toString()}) available in API response`);
4729
4798
  }
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`);
4799
+ // Convert minimumFee to scaled basis points (bigint)
4800
+ // The API returns basis points as a decimal (e.g., "1.3" for 1.3 bps = 0.013%).
4801
+ // We scale by 100 to preserve 2 decimal places of precision.
4802
+ // The caller (getMaxFee) must divide by 1,000,000 instead of 10,000.
4803
+ const feeValue = Number.parseFloat(String(fastTier.minimumFee));
4804
+ if (Number.isNaN(feeValue) || !Number.isFinite(feeValue)) {
4805
+ throw new Error(`Invalid minimumFee value: cannot parse "${String(fastTier.minimumFee)}" as a number`);
4737
4806
  }
4807
+ // Scale by 100 and round to get integer representation
4808
+ const minimumFee = BigInt(Math.round(feeValue * 100));
4738
4809
  // Validate that minimumFee is non-negative
4739
4810
  if (minimumFee < 0n) {
4740
4811
  throw new Error('Invalid minimumFee: value must be non-negative');
@@ -4909,9 +4980,7 @@ const isAttestationResponse = (obj) => {
4909
4980
  * ```
4910
4981
  */
4911
4982
  const buildIrisUrl = (sourceDomainId, transactionHash, isTestnet) => {
4912
- const baseUrl = isTestnet
4913
- ? 'https://iris-api-sandbox.circle.com'
4914
- : 'https://iris-api.circle.com';
4983
+ const baseUrl = isTestnet ? IRIS_API_SANDBOX_BASE_URL : IRIS_API_BASE_URL;
4915
4984
  const url = new URL(`${baseUrl}/v2/messages/${String(sourceDomainId)}`);
4916
4985
  url.searchParams.set('transactionHash', transactionHash);
4917
4986
  return url.toString();
@@ -4956,6 +5025,133 @@ const fetchAttestation = async (sourceDomainId, transactionHash, isTestnet, conf
4956
5025
  const effectiveConfig = { ...DEFAULT_CONFIG, ...config };
4957
5026
  return await pollApiGet(url, isAttestationResponse, effectiveConfig);
4958
5027
  };
5028
+ /**
5029
+ * Type guard that validates attestation response structure without requiring completion status.
5030
+ *
5031
+ * This is used by `fetchAttestationWithoutStatusCheck` to extract the nonce from an existing
5032
+ * attestation, even if the attestation is expired or pending. Unlike `isAttestationResponse`,
5033
+ * this function does not throw if no complete attestation is found.
5034
+ *
5035
+ * @param obj - The value to check, typically a parsed JSON response
5036
+ * @returns True if the object has valid attestation structure
5037
+ * @throws {Error} With "Invalid attestation response structure" if structure is invalid
5038
+ * @internal
5039
+ */
5040
+ const isAttestationResponseWithoutStatusCheck = (obj) => {
5041
+ if (!hasValidAttestationStructure(obj)) {
5042
+ throw new Error('Invalid attestation response structure');
5043
+ }
5044
+ return true;
5045
+ };
5046
+ /**
5047
+ * Fetches attestation data without requiring the attestation to be complete.
5048
+ *
5049
+ * This function is useful for retrieving attestation data (particularly the nonce)
5050
+ * from an existing transaction, even if the attestation has expired or is pending.
5051
+ * It uses minimal retries since we're fetching existing data, not waiting for completion.
5052
+ *
5053
+ * @param sourceDomainId - The CCTP domain ID of the source chain
5054
+ * @param transactionHash - The transaction hash to fetch attestation for
5055
+ * @param isTestnet - Whether this is for a testnet chain (true) or mainnet chain (false)
5056
+ * @param config - Optional configuration overrides
5057
+ * @returns The attestation response data (may contain incomplete/expired attestations)
5058
+ * @throws If the request fails, times out, or returns invalid data
5059
+ *
5060
+ * @example
5061
+ * ```typescript
5062
+ * // Fetch existing attestation to extract nonce for re-attestation
5063
+ * const response = await fetchAttestationWithoutStatusCheck(1, '0xabc...', true)
5064
+ * const nonce = response.messages[0]?.eventNonce
5065
+ * ```
5066
+ */
5067
+ const fetchAttestationWithoutStatusCheck = async (sourceDomainId, transactionHash, isTestnet, config = {}) => {
5068
+ const url = buildIrisUrl(sourceDomainId, transactionHash, isTestnet);
5069
+ // Use minimal retries since we're just fetching existing data
5070
+ const effectiveConfig = {
5071
+ ...DEFAULT_CONFIG,
5072
+ maxRetries: 3,
5073
+ ...config,
5074
+ };
5075
+ return await pollApiGet(url, isAttestationResponseWithoutStatusCheck, effectiveConfig);
5076
+ };
5077
+ /**
5078
+ * Builds the IRIS API URL for re-attestation requests.
5079
+ *
5080
+ * Constructs the URL for Circle's re-attestation endpoint that allows
5081
+ * requesting a fresh attestation for an expired nonce.
5082
+ *
5083
+ * @param nonce - The nonce from the original attestation
5084
+ * @param isTestnet - Whether this is for a testnet chain (true) or mainnet chain (false)
5085
+ * @returns A fully qualified URL string for the re-attestation endpoint
5086
+ *
5087
+ * @example
5088
+ * ```typescript
5089
+ * // Mainnet URL
5090
+ * const mainnetUrl = buildReAttestUrl('0xabc', false)
5091
+ * // => 'https://iris-api.circle.com/v2/reattest/0xabc'
5092
+ *
5093
+ * // Testnet URL
5094
+ * const testnetUrl = buildReAttestUrl('0xabc', true)
5095
+ * // => 'https://iris-api-sandbox.circle.com/v2/reattest/0xabc'
5096
+ * ```
5097
+ */
5098
+ const buildReAttestUrl = (nonce, isTestnet) => {
5099
+ const baseUrl = isTestnet ? IRIS_API_SANDBOX_BASE_URL : IRIS_API_BASE_URL;
5100
+ const url = new URL(`${baseUrl}/v2/reattest/${nonce}`);
5101
+ return url.toString();
5102
+ };
5103
+ /**
5104
+ * Type guard that validates the re-attestation API response structure.
5105
+ *
5106
+ * @param obj - The value to check, typically a parsed JSON response
5107
+ * @returns True if the object matches the ReAttestationResponse shape
5108
+ * @throws {Error} With "Invalid re-attestation response structure" if structure is invalid
5109
+ * @internal
5110
+ */
5111
+ const isReAttestationResponse = (obj) => {
5112
+ if (typeof obj !== 'object' ||
5113
+ obj === null ||
5114
+ !('message' in obj) ||
5115
+ !('nonce' in obj) ||
5116
+ typeof obj.message !== 'string' ||
5117
+ typeof obj.nonce !== 'string') {
5118
+ throw new Error('Invalid re-attestation response structure');
5119
+ }
5120
+ return true;
5121
+ };
5122
+ /**
5123
+ * Requests re-attestation for an expired attestation nonce.
5124
+ *
5125
+ * This function calls Circle's re-attestation API endpoint to request a fresh
5126
+ * attestation for a previously issued nonce. After calling this function,
5127
+ * you should poll `fetchAttestation` to retrieve the new attestation.
5128
+ *
5129
+ * @param nonce - The nonce from the original (expired) attestation
5130
+ * @param isTestnet - Whether this is for a testnet chain (true) or mainnet chain (false)
5131
+ * @param config - Optional configuration overrides for the request
5132
+ * @returns The re-attestation response confirming the request was accepted
5133
+ * @throws If the request fails, times out, or returns invalid data
5134
+ *
5135
+ * @example
5136
+ * ```typescript
5137
+ * // Request re-attestation for an expired nonce
5138
+ * const response = await requestReAttestation('0xabc', true)
5139
+ * console.log(response.message) // "Re-attestation successfully requested for nonce."
5140
+ *
5141
+ * // After requesting re-attestation, poll for the new attestation
5142
+ * const attestation = await fetchAttestation(domainId, txHash, true)
5143
+ * ```
5144
+ */
5145
+ const requestReAttestation = async (nonce, isTestnet, config = {}) => {
5146
+ const url = buildReAttestUrl(nonce, isTestnet);
5147
+ // Use minimal retries since we're just submitting a request, not polling for state
5148
+ const effectiveConfig = {
5149
+ ...DEFAULT_CONFIG,
5150
+ maxRetries: 3,
5151
+ ...config,
5152
+ };
5153
+ return await pollApiPost(url, {}, isReAttestationResponse, effectiveConfig);
5154
+ };
4959
5155
 
4960
5156
  const assertCCTPv2WalletContextSymbol = Symbol('assertCCTPv2WalletContext');
4961
5157
  /**
@@ -7017,6 +7213,19 @@ class CCTPV2BridgingProvider extends BridgingProvider {
7017
7213
  : Promise.resolve(0n),
7018
7214
  ]);
7019
7215
  const estimateResult = {
7216
+ token: params.token,
7217
+ amount: params.amount,
7218
+ source: {
7219
+ address: source.address,
7220
+ chain: source.chain.chain,
7221
+ },
7222
+ destination: {
7223
+ address: destination.address,
7224
+ chain: destination.chain.chain,
7225
+ ...(destination.recipientAddress && {
7226
+ recipientAddress: destination.recipientAddress,
7227
+ }),
7228
+ },
7020
7229
  gasFees: [],
7021
7230
  fees: [],
7022
7231
  };
@@ -7302,6 +7511,93 @@ class CCTPV2BridgingProvider extends BridgingProvider {
7302
7511
  throw error;
7303
7512
  }
7304
7513
  }
7514
+ /**
7515
+ * Requests a fresh attestation for an expired attestation.
7516
+ *
7517
+ * This method is used when the original attestation has expired before the mint
7518
+ * transaction could be completed. It performs three steps:
7519
+ * 1. Fetches the existing attestation data to extract the nonce
7520
+ * 2. Requests re-attestation from Circle's API using the nonce
7521
+ * 3. Polls for the fresh attestation and returns it
7522
+ *
7523
+ * @typeParam TFromAdapterCapabilities - The type representing the capabilities of the source adapter
7524
+ * @param source - The source wallet context containing the chain definition and wallet address
7525
+ * @param transactionHash - The transaction hash of the original burn transaction
7526
+ * @param config - Optional polling configuration overrides for timeout, retries, and delay
7527
+ * @returns A promise that resolves to the fresh attestation message
7528
+ * @throws {Error} With "Failed to re-attest: No nonce found for transaction" if the original
7529
+ * attestation cannot be found or has no nonce
7530
+ * @throws {Error} With "Failed to re-attest: No attestation found after re-attestation request"
7531
+ * if the fresh attestation cannot be retrieved
7532
+ * @throws {Error} With "Failed to re-attest: {details}" for other errors
7533
+ *
7534
+ * @example
7535
+ * ```typescript
7536
+ * import { CCTPV2BridgingProvider } from '@circle-fin/provider-cctp-v2'
7537
+ * import { Chains } from '@core/chains'
7538
+ *
7539
+ * const provider = new CCTPV2BridgingProvider()
7540
+ *
7541
+ * // After a mint fails due to expired attestation, request a fresh one
7542
+ * const source = {
7543
+ * adapter: viemAdapter,
7544
+ * chain: Chains.EthereumSepolia,
7545
+ * address: '0x1234...'
7546
+ * }
7547
+ *
7548
+ * try {
7549
+ * const freshAttestation = await provider.reAttest(
7550
+ * source,
7551
+ * '0xabc123...', // Original burn transaction hash
7552
+ * { timeout: 10000, maxRetries: 5 }
7553
+ * )
7554
+ *
7555
+ * // Use the fresh attestation to retry the mint
7556
+ * const mintRequest = await provider.mint(source, destination, freshAttestation)
7557
+ * const result = await mintRequest.execute()
7558
+ * } catch (error) {
7559
+ * console.error('Re-attestation failed:', error.message)
7560
+ * }
7561
+ * ```
7562
+ */
7563
+ async reAttest(source, transactionHash, config) {
7564
+ assertCCTPv2WalletContext(source);
7565
+ if (!transactionHash ||
7566
+ typeof transactionHash !== 'string' ||
7567
+ transactionHash.trim() === '') {
7568
+ throw new Error('Failed to re-attest: Invalid transaction hash');
7569
+ }
7570
+ try {
7571
+ // Merge configs: defaults <- global config <- per-call config
7572
+ const effectiveConfig = {
7573
+ ...this.config?.attestation,
7574
+ ...config,
7575
+ };
7576
+ // Step 1: Get existing attestation data to extract nonce
7577
+ const existingAttestation = await fetchAttestationWithoutStatusCheck(source.chain.cctp.domain, transactionHash, source.chain.isTestnet, effectiveConfig);
7578
+ const nonce = existingAttestation.messages[0]?.eventNonce;
7579
+ if (!nonce || typeof nonce !== 'string') {
7580
+ throw new Error('Failed to re-attest: No nonce found for transaction');
7581
+ }
7582
+ // Step 2: Request re-attestation
7583
+ await requestReAttestation(nonce, source.chain.isTestnet, effectiveConfig);
7584
+ // Step 3: Poll for fresh attestation
7585
+ const response = await fetchAttestation(source.chain.cctp.domain, transactionHash, source.chain.isTestnet, effectiveConfig);
7586
+ const message = response.messages[0];
7587
+ if (!message) {
7588
+ throw new Error('Failed to re-attest: No attestation found after re-attestation request');
7589
+ }
7590
+ return message;
7591
+ }
7592
+ catch (err) {
7593
+ const error = err instanceof Error ? err : new Error(String(err));
7594
+ // Always prefix with 'Failed to re-attest'
7595
+ if (!error.message.startsWith('Failed to re-attest')) {
7596
+ throw new Error(`Failed to re-attest: ${error.message}`);
7597
+ }
7598
+ throw error;
7599
+ }
7600
+ }
7305
7601
  /**
7306
7602
  * Checks if both source and destination chains support CCTP v2 transfers.
7307
7603
  *
@@ -7381,15 +7677,17 @@ class CCTPV2BridgingProvider extends BridgingProvider {
7381
7677
  }
7382
7678
  else if (config?.maxFee === undefined) {
7383
7679
  // 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);
7680
+ const scaledBps = await fetchUsdcFastBurnFee(source.chain.cctp.domain, destination.chain.cctp.domain, source.chain.isTestnet);
7385
7681
  // Calculate fee proportional to the transfer amount
7386
7682
  // Convert amount to minor units
7387
7683
  const amountInMinorUnits = BigInt(amount);
7388
7684
  // Calculate base fee proportional to the transfer amount
7389
- // Formula: (baseFeeInBps * amountInMinorUnits) / 10_000
7685
+ // The API returns basis points as a decimal (e.g., "1.3" for 1.3 bps).
7686
+ // fetchUsdcFastBurnFee scales by 100 to preserve precision, so we divide by 1,000,000.
7687
+ // Formula: (scaledBps * amountInMinorUnits) / 1_000_000
7390
7688
  // 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;
7689
+ // Ceiling division: (a + b - 1) / b, where b = 1_000_000, so (b - 1) = 999_999n
7690
+ const baseFee = (scaledBps * amountInMinorUnits + 999999n) / 1000000n;
7393
7691
  // Add 10% buffer to account for fee fluctuations: fee + (fee * 10 / 100) = fee + (fee / 10)
7394
7692
  maxFee = baseFee + baseFee / 10n;
7395
7693
  }
package/index.d.ts 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
  *
@@ -2614,15 +2614,39 @@ interface BridgeResult {
2614
2614
  *
2615
2615
  * This interface provides detailed information about the expected costs
2616
2616
  * for a transfer, including gas fees on different chains and protocol fees.
2617
+ * It also includes the input context (token, amount, source, destination) to
2618
+ * provide a complete view of the transfer being estimated.
2617
2619
  *
2618
2620
  * @example
2619
2621
  * ```typescript
2620
2622
  * const estimate: EstimateResult = await provider.estimate(source, dest, '100')
2623
+ * console.log('Estimating transfer of', estimate.amount, estimate.token)
2624
+ * console.log('From', estimate.source.chain.name, 'to', estimate.destination.chain.name)
2621
2625
  * console.log('Total gas fees:', estimate.gasFees.length)
2622
2626
  * console.log('Protocol fees:', estimate.fees.length)
2623
2627
  * ```
2624
2628
  */
2625
2629
  interface EstimateResult {
2630
+ /** The token being transferred */
2631
+ token: 'USDC';
2632
+ /** The amount being transferred */
2633
+ amount: string;
2634
+ /** Information about the source chain and address */
2635
+ source: {
2636
+ /** The source wallet/contract address */
2637
+ address: string;
2638
+ /** The source blockchain network */
2639
+ chain: Blockchain;
2640
+ };
2641
+ /** Information about the destination chain and address */
2642
+ destination: {
2643
+ /** The destination wallet/contract address */
2644
+ address: string;
2645
+ /** The destination blockchain network */
2646
+ chain: Blockchain;
2647
+ /** Optional custom recipient address for minted funds. */
2648
+ recipientAddress?: string;
2649
+ };
2626
2650
  /** Array of gas fees required for the transfer on different blockchains */
2627
2651
  gasFees: {
2628
2652
  /** The name of the step */
@@ -3373,6 +3397,20 @@ interface ICCTPV2BridgingProvider extends BridgingProvider<CCTPV2Actions> {
3373
3397
  * @returns A promise that resolves to the attestation message.
3374
3398
  */
3375
3399
  fetchAttestation<TFromAdapterCapabilities extends AdapterCapabilities, TToAdapterCapabilities extends AdapterCapabilities>(source: BridgeParams<TFromAdapterCapabilities, TToAdapterCapabilities>['source'], txHash: string, config?: Partial<ApiPollingConfig>): Promise<AttestationMessage>;
3400
+ /**
3401
+ * Requests a fresh attestation for an expired attestation.
3402
+ *
3403
+ * This method is used when the original attestation has expired before the mint
3404
+ * transaction could be completed. It fetches the existing attestation data to
3405
+ * extract the nonce, requests re-attestation from Circle's API, and then polls
3406
+ * for the fresh attestation.
3407
+ *
3408
+ * @param source - The source wallet context containing the chain definition and wallet address.
3409
+ * @param txHash - The transaction hash of the original burn transaction.
3410
+ * @param config - Optional polling configuration overrides.
3411
+ * @returns A promise that resolves to the fresh attestation message.
3412
+ */
3413
+ reAttest<TFromAdapterCapabilities extends AdapterCapabilities, TToAdapterCapabilities extends AdapterCapabilities>(source: BridgeParams<TFromAdapterCapabilities, TToAdapterCapabilities>['source'], txHash: string, config?: Partial<ApiPollingConfig>): Promise<AttestationMessage>;
3376
3414
  /**
3377
3415
  * Mints the amount of tokens for the destination wallet.
3378
3416
  *
@@ -3685,6 +3723,56 @@ declare class CCTPV2BridgingProvider extends BridgingProvider<CCTPV2Actions> imp
3685
3723
  * ```
3686
3724
  */
3687
3725
  fetchAttestation<TFromAdapterCapabilities extends AdapterCapabilities>(source: WalletContext<TFromAdapterCapabilities, ChainDefinitionWithCCTPv2>, transactionHash: string, config?: Partial<ApiPollingConfig>): Promise<AttestationMessage>;
3726
+ /**
3727
+ * Requests a fresh attestation for an expired attestation.
3728
+ *
3729
+ * This method is used when the original attestation has expired before the mint
3730
+ * transaction could be completed. It performs three steps:
3731
+ * 1. Fetches the existing attestation data to extract the nonce
3732
+ * 2. Requests re-attestation from Circle's API using the nonce
3733
+ * 3. Polls for the fresh attestation and returns it
3734
+ *
3735
+ * @typeParam TFromAdapterCapabilities - The type representing the capabilities of the source adapter
3736
+ * @param source - The source wallet context containing the chain definition and wallet address
3737
+ * @param transactionHash - The transaction hash of the original burn transaction
3738
+ * @param config - Optional polling configuration overrides for timeout, retries, and delay
3739
+ * @returns A promise that resolves to the fresh attestation message
3740
+ * @throws {Error} With "Failed to re-attest: No nonce found for transaction" if the original
3741
+ * attestation cannot be found or has no nonce
3742
+ * @throws {Error} With "Failed to re-attest: No attestation found after re-attestation request"
3743
+ * if the fresh attestation cannot be retrieved
3744
+ * @throws {Error} With "Failed to re-attest: {details}" for other errors
3745
+ *
3746
+ * @example
3747
+ * ```typescript
3748
+ * import { CCTPV2BridgingProvider } from '@circle-fin/provider-cctp-v2'
3749
+ * import { Chains } from '@core/chains'
3750
+ *
3751
+ * const provider = new CCTPV2BridgingProvider()
3752
+ *
3753
+ * // After a mint fails due to expired attestation, request a fresh one
3754
+ * const source = {
3755
+ * adapter: viemAdapter,
3756
+ * chain: Chains.EthereumSepolia,
3757
+ * address: '0x1234...'
3758
+ * }
3759
+ *
3760
+ * try {
3761
+ * const freshAttestation = await provider.reAttest(
3762
+ * source,
3763
+ * '0xabc123...', // Original burn transaction hash
3764
+ * { timeout: 10000, maxRetries: 5 }
3765
+ * )
3766
+ *
3767
+ * // Use the fresh attestation to retry the mint
3768
+ * const mintRequest = await provider.mint(source, destination, freshAttestation)
3769
+ * const result = await mintRequest.execute()
3770
+ * } catch (error) {
3771
+ * console.error('Re-attestation failed:', error.message)
3772
+ * }
3773
+ * ```
3774
+ */
3775
+ reAttest<TFromAdapterCapabilities extends AdapterCapabilities>(source: WalletContext<TFromAdapterCapabilities, ChainDefinitionWithCCTPv2>, transactionHash: string, config?: Partial<ApiPollingConfig>): Promise<AttestationMessage>;
3688
3776
  /**
3689
3777
  * Checks if both source and destination chains support CCTP v2 transfers.
3690
3778
  *
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
  *
@@ -3014,7 +3014,9 @@ const makeApiRequest = async (url, method, isValidType, config, body) => {
3014
3014
  signal: controller.signal,
3015
3015
  };
3016
3016
  // Add body for methods that support it
3017
- if (body !== undefined && ['POST', 'PUT', 'PATCH'].includes(method)) ;
3017
+ if (body !== undefined && ['POST', 'PUT', 'PATCH'].includes(method)) {
3018
+ requestInit.body = JSON.stringify(body);
3019
+ }
3018
3020
  const response = await fetch(url, requestInit);
3019
3021
  clearTimeout(timeoutId);
3020
3022
  if (!response.ok) {
@@ -3192,6 +3194,30 @@ const pollApiWithValidation = async (url, method, isValidType, config = {}, body
3192
3194
  const pollApiGet = async (url, isValidType, config) => {
3193
3195
  return pollApiWithValidation(url, 'GET', isValidType, config);
3194
3196
  };
3197
+ /**
3198
+ * Convenience function for making POST requests with validation.
3199
+ *
3200
+ * @typeParam TResponseType - The expected response type after validation
3201
+ * @typeParam TBody - The type of the request body
3202
+ * @param url - The API endpoint URL
3203
+ * @param body - The request body
3204
+ * @param isValidType - Type guard function to validate the response
3205
+ * @param config - Optional configuration overrides
3206
+ * @returns Promise resolving to the validated response
3207
+ *
3208
+ * @example
3209
+ * ```typescript
3210
+ * const result = await pollApiPost(
3211
+ * 'https://api.example.com/submit',
3212
+ * { name: 'John', email: 'john@example.com' },
3213
+ * isSubmissionResponse,
3214
+ * { maxRetries: 3 }
3215
+ * )
3216
+ * ```
3217
+ */
3218
+ const pollApiPost = async (url, body, isValidType, config) => {
3219
+ return pollApiWithValidation(url, 'POST', isValidType, config, body);
3220
+ };
3195
3221
 
3196
3222
  /**
3197
3223
  * Valid recoverability values for error handling strategies.
@@ -3399,6 +3425,24 @@ function validateErrorDetails(details) {
3399
3425
  * stays within KitError's constraints.
3400
3426
  */
3401
3427
  const MAX_MESSAGE_LENGTH = 950;
3428
+ /**
3429
+ * Standard error message for invalid amount format.
3430
+ *
3431
+ * The SDK enforces strict dot-decimal notation for amount values. This constant
3432
+ * provides a consistent error message when users provide amounts with:
3433
+ * - Comma decimals (e.g., "1,5")
3434
+ * - Thousand separators (e.g., "1,000.50")
3435
+ * - Non-numeric characters
3436
+ * - Invalid format
3437
+ */
3438
+ 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';
3439
+ /**
3440
+ * Error message for invalid maxFee format.
3441
+ *
3442
+ * Used when validating the maxFee configuration parameter. The maxFee can be zero
3443
+ * or positive and must follow strict dot-decimal notation.
3444
+ */
3445
+ 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
3446
 
3403
3447
  /**
3404
3448
  * Structured error class for Stablecoin Kit operations.
@@ -4405,41 +4449,46 @@ var TransferSpeed;
4405
4449
  * - regexMessage: error message when the basic numeric format fails.
4406
4450
  * - maxDecimals: maximum number of decimal places allowed (e.g., 6 for USDC).
4407
4451
  */
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) {
4452
+ const createDecimalStringValidator = (options) => (schema) => {
4453
+ // Capitalize first letter of attribute name for error messages
4454
+ const capitalizedAttributeName = options.attributeName.charAt(0).toUpperCase() +
4455
+ options.attributeName.slice(1);
4456
+ return schema
4457
+ .regex(/^-?(?:\d+(?:\.\d+)?|\.\d+)$/, options.regexMessage)
4458
+ .superRefine((val, ctx) => {
4459
+ const amount = Number.parseFloat(val);
4460
+ if (Number.isNaN(amount)) {
4423
4461
  ctx.addIssue({
4424
4462
  code: z.ZodIssueCode.custom,
4425
- message: `Maximum supported decimal places: ${options.maxDecimals.toString()}`,
4463
+ message: options.regexMessage,
4426
4464
  });
4427
4465
  return;
4428
4466
  }
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
- });
4467
+ // Check decimal precision if maxDecimals is specified
4468
+ if (options.maxDecimals !== undefined) {
4469
+ const decimalPart = val.split('.')[1];
4470
+ if (decimalPart && decimalPart.length > options.maxDecimals) {
4471
+ ctx.addIssue({
4472
+ code: z.ZodIssueCode.custom,
4473
+ message: `Maximum supported decimal places: ${options.maxDecimals.toString()}`,
4474
+ });
4475
+ return;
4476
+ }
4477
+ }
4478
+ if (options.allowZero && amount < 0) {
4479
+ ctx.addIssue({
4480
+ code: z.ZodIssueCode.custom,
4481
+ message: `${capitalizedAttributeName} must be non-negative`,
4482
+ });
4483
+ }
4484
+ else if (!options.allowZero && amount <= 0) {
4485
+ ctx.addIssue({
4486
+ code: z.ZodIssueCode.custom,
4487
+ message: `${capitalizedAttributeName} must be greater than 0`,
4488
+ });
4489
+ }
4490
+ });
4491
+ };
4443
4492
  /**
4444
4493
  * Schema for validating chain definitions.
4445
4494
  * This ensures the basic structure of a chain definition is valid.
@@ -4599,7 +4648,7 @@ const bridgeParamsSchema = z.object({
4599
4648
  .min(1, 'Required')
4600
4649
  .pipe(createDecimalStringValidator({
4601
4650
  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.',
4651
+ regexMessage: AMOUNT_FORMAT_ERROR_MESSAGE,
4603
4652
  attributeName: 'amount',
4604
4653
  maxDecimals: 6,
4605
4654
  })(z.string())),
@@ -4612,7 +4661,7 @@ const bridgeParamsSchema = z.object({
4612
4661
  .string()
4613
4662
  .pipe(createDecimalStringValidator({
4614
4663
  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.',
4664
+ regexMessage: MAX_FEE_FORMAT_ERROR_MESSAGE,
4616
4665
  attributeName: 'maxFee',
4617
4666
  maxDecimals: 6,
4618
4667
  })(z.string()))
@@ -4621,6 +4670,19 @@ const bridgeParamsSchema = z.object({
4621
4670
  }),
4622
4671
  });
4623
4672
 
4673
+ /**
4674
+ * Base URL for Circle's IRIS API (mainnet/production).
4675
+ *
4676
+ * The IRIS API provides attestation services for CCTP cross-chain transfers.
4677
+ */
4678
+ const IRIS_API_BASE_URL = 'https://iris-api.circle.com';
4679
+ /**
4680
+ * Base URL for Circle's IRIS API (testnet/sandbox).
4681
+ *
4682
+ * Used for development and testing on testnet chains.
4683
+ */
4684
+ const IRIS_API_SANDBOX_BASE_URL = 'https://iris-api-sandbox.circle.com';
4685
+
4624
4686
  /**
4625
4687
  * Type guard to validate the API response structure.
4626
4688
  *
@@ -4669,9 +4731,7 @@ const isFastBurnFeeResponse = (data) => {
4669
4731
  * @returns The complete API URL
4670
4732
  */
4671
4733
  function buildFastBurnFeeUrl(sourceDomain, destinationDomain, isTestnet) {
4672
- const baseUrl = isTestnet
4673
- ? 'https://iris-api-sandbox.circle.com'
4674
- : 'https://iris-api.circle.com';
4734
+ const baseUrl = isTestnet ? IRIS_API_SANDBOX_BASE_URL : IRIS_API_BASE_URL;
4675
4735
  return `${baseUrl}/v2/burn/USDC/fees/${sourceDomain.toString()}/${destinationDomain.toString()}`;
4676
4736
  }
4677
4737
  const FAST_TIER_FINALITY_THRESHOLD = 1000;
@@ -4687,15 +4747,24 @@ const FAST_TIER_FINALITY_THRESHOLD = 1000;
4687
4747
  * - Retry delays: 9 × 200 ms = 1 800 ms
4688
4748
  * - Total max time: 2 000 ms + 1 800 ms = 3 800 ms
4689
4749
  *
4750
+ * @remarks
4751
+ * The API returns basis points as a decimal (e.g., "1.3" for 1.3 bps = 0.013%).
4752
+ * This function scales the value by 100 to preserve 2 decimal places of precision.
4753
+ * Callers must divide by 1,000,000 (instead of 10,000) when calculating fees.
4754
+ *
4690
4755
  * @param sourceDomain - The source domain
4691
4756
  * @param destinationDomain - The destination domain
4692
4757
  * @param isTestnet - Whether the request is for a testnet chain
4693
- * @returns The minimum fee for a USDC fast burn operation
4758
+ * @returns The minimum fee in scaled basis points (bps × 100)
4694
4759
  * @throws Error if the input domains are invalid, the API request fails, returns invalid data, or network errors occur
4695
4760
  * @example
4696
4761
  * ```typescript
4697
- * const minimumFee = await fetchUsdcFastBurnFee(0, 6, false) // Ethereum -> Base
4698
- * console.log(minimumFee) // 1000n
4762
+ * const scaledBps = await fetchUsdcFastBurnFee(0, 6, false) // Ethereum -> Base
4763
+ * console.log(scaledBps) // 130n (representing 1.3 bps)
4764
+ *
4765
+ * // To calculate fee for an amount:
4766
+ * const amount = 1_000_000n // 1 USDC
4767
+ * const fee = (scaledBps * amount) / 1_000_000n // 130n (0.00013 USDC)
4699
4768
  * ```
4700
4769
  */
4701
4770
  async function fetchUsdcFastBurnFee(sourceDomain, destinationDomain, isTestnet) {
@@ -4721,14 +4790,16 @@ async function fetchUsdcFastBurnFee(sourceDomain, destinationDomain, isTestnet)
4721
4790
  if (!fastTier) {
4722
4791
  throw new Error(`No fast tier (finalityThreshold: ${FAST_TIER_FINALITY_THRESHOLD.toString()}) available in API response`);
4723
4792
  }
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`);
4793
+ // Convert minimumFee to scaled basis points (bigint)
4794
+ // The API returns basis points as a decimal (e.g., "1.3" for 1.3 bps = 0.013%).
4795
+ // We scale by 100 to preserve 2 decimal places of precision.
4796
+ // The caller (getMaxFee) must divide by 1,000,000 instead of 10,000.
4797
+ const feeValue = Number.parseFloat(String(fastTier.minimumFee));
4798
+ if (Number.isNaN(feeValue) || !Number.isFinite(feeValue)) {
4799
+ throw new Error(`Invalid minimumFee value: cannot parse "${String(fastTier.minimumFee)}" as a number`);
4731
4800
  }
4801
+ // Scale by 100 and round to get integer representation
4802
+ const minimumFee = BigInt(Math.round(feeValue * 100));
4732
4803
  // Validate that minimumFee is non-negative
4733
4804
  if (minimumFee < 0n) {
4734
4805
  throw new Error('Invalid minimumFee: value must be non-negative');
@@ -4903,9 +4974,7 @@ const isAttestationResponse = (obj) => {
4903
4974
  * ```
4904
4975
  */
4905
4976
  const buildIrisUrl = (sourceDomainId, transactionHash, isTestnet) => {
4906
- const baseUrl = isTestnet
4907
- ? 'https://iris-api-sandbox.circle.com'
4908
- : 'https://iris-api.circle.com';
4977
+ const baseUrl = isTestnet ? IRIS_API_SANDBOX_BASE_URL : IRIS_API_BASE_URL;
4909
4978
  const url = new URL(`${baseUrl}/v2/messages/${String(sourceDomainId)}`);
4910
4979
  url.searchParams.set('transactionHash', transactionHash);
4911
4980
  return url.toString();
@@ -4950,6 +5019,133 @@ const fetchAttestation = async (sourceDomainId, transactionHash, isTestnet, conf
4950
5019
  const effectiveConfig = { ...DEFAULT_CONFIG, ...config };
4951
5020
  return await pollApiGet(url, isAttestationResponse, effectiveConfig);
4952
5021
  };
5022
+ /**
5023
+ * Type guard that validates attestation response structure without requiring completion status.
5024
+ *
5025
+ * This is used by `fetchAttestationWithoutStatusCheck` to extract the nonce from an existing
5026
+ * attestation, even if the attestation is expired or pending. Unlike `isAttestationResponse`,
5027
+ * this function does not throw if no complete attestation is found.
5028
+ *
5029
+ * @param obj - The value to check, typically a parsed JSON response
5030
+ * @returns True if the object has valid attestation structure
5031
+ * @throws {Error} With "Invalid attestation response structure" if structure is invalid
5032
+ * @internal
5033
+ */
5034
+ const isAttestationResponseWithoutStatusCheck = (obj) => {
5035
+ if (!hasValidAttestationStructure(obj)) {
5036
+ throw new Error('Invalid attestation response structure');
5037
+ }
5038
+ return true;
5039
+ };
5040
+ /**
5041
+ * Fetches attestation data without requiring the attestation to be complete.
5042
+ *
5043
+ * This function is useful for retrieving attestation data (particularly the nonce)
5044
+ * from an existing transaction, even if the attestation has expired or is pending.
5045
+ * It uses minimal retries since we're fetching existing data, not waiting for completion.
5046
+ *
5047
+ * @param sourceDomainId - The CCTP domain ID of the source chain
5048
+ * @param transactionHash - The transaction hash to fetch attestation for
5049
+ * @param isTestnet - Whether this is for a testnet chain (true) or mainnet chain (false)
5050
+ * @param config - Optional configuration overrides
5051
+ * @returns The attestation response data (may contain incomplete/expired attestations)
5052
+ * @throws If the request fails, times out, or returns invalid data
5053
+ *
5054
+ * @example
5055
+ * ```typescript
5056
+ * // Fetch existing attestation to extract nonce for re-attestation
5057
+ * const response = await fetchAttestationWithoutStatusCheck(1, '0xabc...', true)
5058
+ * const nonce = response.messages[0]?.eventNonce
5059
+ * ```
5060
+ */
5061
+ const fetchAttestationWithoutStatusCheck = async (sourceDomainId, transactionHash, isTestnet, config = {}) => {
5062
+ const url = buildIrisUrl(sourceDomainId, transactionHash, isTestnet);
5063
+ // Use minimal retries since we're just fetching existing data
5064
+ const effectiveConfig = {
5065
+ ...DEFAULT_CONFIG,
5066
+ maxRetries: 3,
5067
+ ...config,
5068
+ };
5069
+ return await pollApiGet(url, isAttestationResponseWithoutStatusCheck, effectiveConfig);
5070
+ };
5071
+ /**
5072
+ * Builds the IRIS API URL for re-attestation requests.
5073
+ *
5074
+ * Constructs the URL for Circle's re-attestation endpoint that allows
5075
+ * requesting a fresh attestation for an expired nonce.
5076
+ *
5077
+ * @param nonce - The nonce from the original attestation
5078
+ * @param isTestnet - Whether this is for a testnet chain (true) or mainnet chain (false)
5079
+ * @returns A fully qualified URL string for the re-attestation endpoint
5080
+ *
5081
+ * @example
5082
+ * ```typescript
5083
+ * // Mainnet URL
5084
+ * const mainnetUrl = buildReAttestUrl('0xabc', false)
5085
+ * // => 'https://iris-api.circle.com/v2/reattest/0xabc'
5086
+ *
5087
+ * // Testnet URL
5088
+ * const testnetUrl = buildReAttestUrl('0xabc', true)
5089
+ * // => 'https://iris-api-sandbox.circle.com/v2/reattest/0xabc'
5090
+ * ```
5091
+ */
5092
+ const buildReAttestUrl = (nonce, isTestnet) => {
5093
+ const baseUrl = isTestnet ? IRIS_API_SANDBOX_BASE_URL : IRIS_API_BASE_URL;
5094
+ const url = new URL(`${baseUrl}/v2/reattest/${nonce}`);
5095
+ return url.toString();
5096
+ };
5097
+ /**
5098
+ * Type guard that validates the re-attestation API response structure.
5099
+ *
5100
+ * @param obj - The value to check, typically a parsed JSON response
5101
+ * @returns True if the object matches the ReAttestationResponse shape
5102
+ * @throws {Error} With "Invalid re-attestation response structure" if structure is invalid
5103
+ * @internal
5104
+ */
5105
+ const isReAttestationResponse = (obj) => {
5106
+ if (typeof obj !== 'object' ||
5107
+ obj === null ||
5108
+ !('message' in obj) ||
5109
+ !('nonce' in obj) ||
5110
+ typeof obj.message !== 'string' ||
5111
+ typeof obj.nonce !== 'string') {
5112
+ throw new Error('Invalid re-attestation response structure');
5113
+ }
5114
+ return true;
5115
+ };
5116
+ /**
5117
+ * Requests re-attestation for an expired attestation nonce.
5118
+ *
5119
+ * This function calls Circle's re-attestation API endpoint to request a fresh
5120
+ * attestation for a previously issued nonce. After calling this function,
5121
+ * you should poll `fetchAttestation` to retrieve the new attestation.
5122
+ *
5123
+ * @param nonce - The nonce from the original (expired) attestation
5124
+ * @param isTestnet - Whether this is for a testnet chain (true) or mainnet chain (false)
5125
+ * @param config - Optional configuration overrides for the request
5126
+ * @returns The re-attestation response confirming the request was accepted
5127
+ * @throws If the request fails, times out, or returns invalid data
5128
+ *
5129
+ * @example
5130
+ * ```typescript
5131
+ * // Request re-attestation for an expired nonce
5132
+ * const response = await requestReAttestation('0xabc', true)
5133
+ * console.log(response.message) // "Re-attestation successfully requested for nonce."
5134
+ *
5135
+ * // After requesting re-attestation, poll for the new attestation
5136
+ * const attestation = await fetchAttestation(domainId, txHash, true)
5137
+ * ```
5138
+ */
5139
+ const requestReAttestation = async (nonce, isTestnet, config = {}) => {
5140
+ const url = buildReAttestUrl(nonce, isTestnet);
5141
+ // Use minimal retries since we're just submitting a request, not polling for state
5142
+ const effectiveConfig = {
5143
+ ...DEFAULT_CONFIG,
5144
+ maxRetries: 3,
5145
+ ...config,
5146
+ };
5147
+ return await pollApiPost(url, {}, isReAttestationResponse, effectiveConfig);
5148
+ };
4953
5149
 
4954
5150
  const assertCCTPv2WalletContextSymbol = Symbol('assertCCTPv2WalletContext');
4955
5151
  /**
@@ -7011,6 +7207,19 @@ class CCTPV2BridgingProvider extends BridgingProvider {
7011
7207
  : Promise.resolve(0n),
7012
7208
  ]);
7013
7209
  const estimateResult = {
7210
+ token: params.token,
7211
+ amount: params.amount,
7212
+ source: {
7213
+ address: source.address,
7214
+ chain: source.chain.chain,
7215
+ },
7216
+ destination: {
7217
+ address: destination.address,
7218
+ chain: destination.chain.chain,
7219
+ ...(destination.recipientAddress && {
7220
+ recipientAddress: destination.recipientAddress,
7221
+ }),
7222
+ },
7014
7223
  gasFees: [],
7015
7224
  fees: [],
7016
7225
  };
@@ -7296,6 +7505,93 @@ class CCTPV2BridgingProvider extends BridgingProvider {
7296
7505
  throw error;
7297
7506
  }
7298
7507
  }
7508
+ /**
7509
+ * Requests a fresh attestation for an expired attestation.
7510
+ *
7511
+ * This method is used when the original attestation has expired before the mint
7512
+ * transaction could be completed. It performs three steps:
7513
+ * 1. Fetches the existing attestation data to extract the nonce
7514
+ * 2. Requests re-attestation from Circle's API using the nonce
7515
+ * 3. Polls for the fresh attestation and returns it
7516
+ *
7517
+ * @typeParam TFromAdapterCapabilities - The type representing the capabilities of the source adapter
7518
+ * @param source - The source wallet context containing the chain definition and wallet address
7519
+ * @param transactionHash - The transaction hash of the original burn transaction
7520
+ * @param config - Optional polling configuration overrides for timeout, retries, and delay
7521
+ * @returns A promise that resolves to the fresh attestation message
7522
+ * @throws {Error} With "Failed to re-attest: No nonce found for transaction" if the original
7523
+ * attestation cannot be found or has no nonce
7524
+ * @throws {Error} With "Failed to re-attest: No attestation found after re-attestation request"
7525
+ * if the fresh attestation cannot be retrieved
7526
+ * @throws {Error} With "Failed to re-attest: {details}" for other errors
7527
+ *
7528
+ * @example
7529
+ * ```typescript
7530
+ * import { CCTPV2BridgingProvider } from '@circle-fin/provider-cctp-v2'
7531
+ * import { Chains } from '@core/chains'
7532
+ *
7533
+ * const provider = new CCTPV2BridgingProvider()
7534
+ *
7535
+ * // After a mint fails due to expired attestation, request a fresh one
7536
+ * const source = {
7537
+ * adapter: viemAdapter,
7538
+ * chain: Chains.EthereumSepolia,
7539
+ * address: '0x1234...'
7540
+ * }
7541
+ *
7542
+ * try {
7543
+ * const freshAttestation = await provider.reAttest(
7544
+ * source,
7545
+ * '0xabc123...', // Original burn transaction hash
7546
+ * { timeout: 10000, maxRetries: 5 }
7547
+ * )
7548
+ *
7549
+ * // Use the fresh attestation to retry the mint
7550
+ * const mintRequest = await provider.mint(source, destination, freshAttestation)
7551
+ * const result = await mintRequest.execute()
7552
+ * } catch (error) {
7553
+ * console.error('Re-attestation failed:', error.message)
7554
+ * }
7555
+ * ```
7556
+ */
7557
+ async reAttest(source, transactionHash, config) {
7558
+ assertCCTPv2WalletContext(source);
7559
+ if (!transactionHash ||
7560
+ typeof transactionHash !== 'string' ||
7561
+ transactionHash.trim() === '') {
7562
+ throw new Error('Failed to re-attest: Invalid transaction hash');
7563
+ }
7564
+ try {
7565
+ // Merge configs: defaults <- global config <- per-call config
7566
+ const effectiveConfig = {
7567
+ ...this.config?.attestation,
7568
+ ...config,
7569
+ };
7570
+ // Step 1: Get existing attestation data to extract nonce
7571
+ const existingAttestation = await fetchAttestationWithoutStatusCheck(source.chain.cctp.domain, transactionHash, source.chain.isTestnet, effectiveConfig);
7572
+ const nonce = existingAttestation.messages[0]?.eventNonce;
7573
+ if (!nonce || typeof nonce !== 'string') {
7574
+ throw new Error('Failed to re-attest: No nonce found for transaction');
7575
+ }
7576
+ // Step 2: Request re-attestation
7577
+ await requestReAttestation(nonce, source.chain.isTestnet, effectiveConfig);
7578
+ // Step 3: Poll for fresh attestation
7579
+ const response = await fetchAttestation(source.chain.cctp.domain, transactionHash, source.chain.isTestnet, effectiveConfig);
7580
+ const message = response.messages[0];
7581
+ if (!message) {
7582
+ throw new Error('Failed to re-attest: No attestation found after re-attestation request');
7583
+ }
7584
+ return message;
7585
+ }
7586
+ catch (err) {
7587
+ const error = err instanceof Error ? err : new Error(String(err));
7588
+ // Always prefix with 'Failed to re-attest'
7589
+ if (!error.message.startsWith('Failed to re-attest')) {
7590
+ throw new Error(`Failed to re-attest: ${error.message}`);
7591
+ }
7592
+ throw error;
7593
+ }
7594
+ }
7299
7595
  /**
7300
7596
  * Checks if both source and destination chains support CCTP v2 transfers.
7301
7597
  *
@@ -7375,15 +7671,17 @@ class CCTPV2BridgingProvider extends BridgingProvider {
7375
7671
  }
7376
7672
  else if (config?.maxFee === undefined) {
7377
7673
  // 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);
7674
+ const scaledBps = await fetchUsdcFastBurnFee(source.chain.cctp.domain, destination.chain.cctp.domain, source.chain.isTestnet);
7379
7675
  // Calculate fee proportional to the transfer amount
7380
7676
  // Convert amount to minor units
7381
7677
  const amountInMinorUnits = BigInt(amount);
7382
7678
  // Calculate base fee proportional to the transfer amount
7383
- // Formula: (baseFeeInBps * amountInMinorUnits) / 10_000
7679
+ // The API returns basis points as a decimal (e.g., "1.3" for 1.3 bps).
7680
+ // fetchUsdcFastBurnFee scales by 100 to preserve precision, so we divide by 1,000,000.
7681
+ // Formula: (scaledBps * amountInMinorUnits) / 1_000_000
7384
7682
  // 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;
7683
+ // Ceiling division: (a + b - 1) / b, where b = 1_000_000, so (b - 1) = 999_999n
7684
+ const baseFee = (scaledBps * amountInMinorUnits + 999999n) / 1000000n;
7387
7685
  // Add 10% buffer to account for fee fluctuations: fee + (fee * 10 / 100) = fee + (fee / 10)
7388
7686
  maxFee = baseFee + baseFee / 10n;
7389
7687
  }
package/package.json CHANGED
@@ -1,7 +1,19 @@
1
1
  {
2
2
  "name": "@circle-fin/provider-cctp-v2",
3
- "version": "1.0.5",
3
+ "version": "1.1.0",
4
4
  "description": "Circle's official Cross-Chain Transfer Protocol v2 provider for native USDC bridging",
5
+ "keywords": [
6
+ "circle",
7
+ "cctp",
8
+ "usdc",
9
+ "stablecoin",
10
+ "provider",
11
+ "typescript",
12
+ "cross-chain",
13
+ "bridge",
14
+ "bridging",
15
+ "bridge-kit"
16
+ ],
5
17
  "main": "./index.cjs",
6
18
  "module": "./index.mjs",
7
19
  "types": "./index.d.ts",