@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/CHANGELOG.md +27 -0
- package/index.cjs +688 -80
- package/index.d.ts +232 -5
- package/index.mjs +686 -81
- package/package.json +13 -1
package/index.cjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Copyright (c)
|
|
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: '
|
|
389
|
-
symbol: '
|
|
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
|
|
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
|
|
3891
|
+
* // With trace context for debugging
|
|
3839
3892
|
* try {
|
|
3840
3893
|
* await transfer(...)
|
|
3841
3894
|
* } catch (error) {
|
|
3842
|
-
* throw createInsufficientTokenBalanceError('Base', 'USDC',
|
|
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,
|
|
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
|
-
|
|
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) =>
|
|
4415
|
-
|
|
4416
|
-
.
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
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:
|
|
4557
|
+
message: options.regexMessage,
|
|
4432
4558
|
});
|
|
4433
4559
|
return;
|
|
4434
4560
|
}
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
4704
|
-
* console.log(
|
|
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
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
4736
|
-
throw new Error(`Invalid minimumFee value: cannot
|
|
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
|
-
|
|
6040
|
-
|
|
6041
|
-
|
|
6042
|
-
|
|
6043
|
-
|
|
6044
|
-
|
|
6045
|
-
|
|
6046
|
-
|
|
6047
|
-
|
|
6048
|
-
|
|
6049
|
-
|
|
6050
|
-
|
|
6051
|
-
|
|
6052
|
-
|
|
6053
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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 =
|
|
7392
|
-
const baseFee = (
|
|
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
|