@circle-fin/app-kit 1.12.0 → 1.12.1

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.
@@ -8330,6 +8330,7 @@ const swapTokenEnumSchema = zod.z.enum([
8330
8330
  [Blockchain.Arbitrum_Sepolia]: '0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d',
8331
8331
  [Blockchain.Avalanche_Fuji]: '0x5425890298aed601595a70AB815c96711a31Bc65',
8332
8332
  [Blockchain.Base_Sepolia]: '0x036CbD53842c5426634e7929541eC2318f3dCF7e',
8333
+ [Blockchain.Celo_Alfajores_Testnet]: '0x2F25deB3848C207fc8E0c34035B3Ba7fC157602B',
8333
8334
  [Blockchain.Codex_Testnet]: '0x6d7f141b6819C2c9CC2f818e6ad549E7Ca090F8f',
8334
8335
  [Blockchain.Cronos_Testnet]: '0xEb33dc5fac03833e132593659e1dE7256aB59794',
8335
8336
  [Blockchain.Edge_Testnet]: '0x2d9F7CAD728051AA35Ecdc472a14cf8cDF5CFD6B',
@@ -9917,6 +9918,44 @@ function assertBridgeParams(params, schema) {
9917
9918
  }
9918
9919
  }
9919
9920
 
9921
+ /**
9922
+ * Canonical list of actions that do not prepare or submit transactions.
9923
+ *
9924
+ * @internal
9925
+ */ const READ_ACTION_KEYS = [
9926
+ 'token.allowance',
9927
+ 'token.balanceOf',
9928
+ 'token.name',
9929
+ 'native.balanceOf',
9930
+ 'usdc.allowance',
9931
+ 'usdc.balanceOf',
9932
+ 'usdc.name',
9933
+ 'gateway.v1.isDelegate',
9934
+ 'gateway.v1.withdrawingBalance',
9935
+ 'gateway.v1.withdrawalBlock',
9936
+ 'gateway.v1.signBurnIntents'
9937
+ ];
9938
+ const READ_ACTION_KEY_SET = new Set(READ_ACTION_KEYS);
9939
+ /**
9940
+ * Check whether a runtime value identifies a read action.
9941
+ *
9942
+ * @param action - The value to classify.
9943
+ * @returns Whether the value is a registered read-action key.
9944
+ *
9945
+ * @example
9946
+ * ```typescript
9947
+ * import { isReadActionKey } from '@core/adapter'
9948
+ *
9949
+ * if (isReadActionKey(value)) {
9950
+ * await adapter.readAction(value, params, context)
9951
+ * }
9952
+ * ```
9953
+ *
9954
+ * @internal
9955
+ */ function isReadActionKey(action) {
9956
+ return READ_ACTION_KEY_SET.has(action);
9957
+ }
9958
+
9920
9959
  /**
9921
9960
  * Resolves an operation context into concrete chain and address values.
9922
9961
  *
@@ -9996,6 +10035,72 @@ function assertBridgeParams(params, schema) {
9996
10035
  };
9997
10036
  }
9998
10037
 
10038
+ /**
10039
+ * Create the standard error for a missing or non-read action.
10040
+ *
10041
+ * @param action - The unsupported action value.
10042
+ * @returns A fatal unsupported-action error.
10043
+ *
10044
+ * @internal
10045
+ */ function createUnsupportedReadActionError(action) {
10046
+ return new KitError({
10047
+ ...InputError.UNSUPPORTED_ACTION,
10048
+ recoverability: 'FATAL',
10049
+ message: `Read action "${String(action)}" is not registered in this adapter.`
10050
+ });
10051
+ }
10052
+ /**
10053
+ * Execute a read through the adapter's dedicated read seam when available.
10054
+ *
10055
+ * @remarks
10056
+ * Fall back to the legacy `prepareAction().execute()` contract so providers
10057
+ * remain runtime-compatible with adapter versions released before `readAction`.
10058
+ * Consumers must upgrade their adapter package for reads to bypass custom
10059
+ * `prepareAction` wrappers.
10060
+ *
10061
+ * @typeParam TAdapterCapabilities - The adapter capabilities type.
10062
+ * @typeParam TActionKey - The read action key.
10063
+ * @param adapter - The adapter that owns the read action.
10064
+ * @param action - The read action to execute.
10065
+ * @param params - The parameters for the read action.
10066
+ * @param ctx - The operation context.
10067
+ * @returns The raw read-action result.
10068
+ * @throws {KitError} When `action` is not a supported read-action key.
10069
+ *
10070
+ * @example
10071
+ * ```typescript
10072
+ * import { executeAdapterReadAction } from '@core/adapter'
10073
+ * import { Ethereum } from '@core/chains'
10074
+ *
10075
+ * const allowance = await executeAdapterReadAction(
10076
+ * adapter,
10077
+ * 'token.allowance',
10078
+ * { tokenAddress, delegate },
10079
+ * { chain: Ethereum },
10080
+ * )
10081
+ * ```
10082
+ *
10083
+ * @internal
10084
+ */ async function executeAdapterReadAction(adapter, action, params, ctx) {
10085
+ if (!isReadActionKey(action)) {
10086
+ throw createUnsupportedReadActionError(action);
10087
+ }
10088
+ const runtimeAdapter = adapter;
10089
+ if (typeof runtimeAdapter.readAction === 'function') {
10090
+ return runtimeAdapter.readAction(action, params, ctx);
10091
+ }
10092
+ let request;
10093
+ try {
10094
+ request = await adapter.prepareAction(action, params, ctx);
10095
+ } catch (error) {
10096
+ if (error instanceof Error && error.message === `Action ${action} is not supported`) {
10097
+ throw createUnsupportedReadActionError(action);
10098
+ }
10099
+ throw error;
10100
+ }
10101
+ return request.execute();
10102
+ }
10103
+
9999
10104
  /**
10000
10105
  * Schema for validating hexadecimal strings with '0x' prefix.
10001
10106
  *
@@ -10205,16 +10310,15 @@ function assertBridgeParams(params, schema) {
10205
10310
  * ```
10206
10311
  */ const validateBalanceForTransaction = async (params)=>{
10207
10312
  const { amount, adapter, token, tokenAddress, operationContext } = params;
10208
- const balancePrepared = await adapter.prepareAction('usdc.balanceOf', {
10313
+ const balance = await executeAdapterReadAction(adapter, 'usdc.balanceOf', {
10209
10314
  walletAddress: operationContext.address
10210
10315
  }, operationContext);
10211
- const balance = await balancePrepared.execute();
10212
- if (BigInt(balance) < BigInt(amount)) {
10316
+ if (BigInt(String(balance)) < BigInt(amount)) {
10213
10317
  // Extract chain name from operationContext
10214
10318
  const chainName = extractChainInfo(operationContext.chain).name;
10215
10319
  // Create KitError with rich context in trace
10216
10320
  throw createInsufficientTokenBalanceError(chainName, token, {
10217
- balance: balance.toString(),
10321
+ balance: String(balance),
10218
10322
  amount,
10219
10323
  tokenAddress,
10220
10324
  walletAddress: operationContext.address
@@ -12607,6 +12711,25 @@ const DEPOSIT_FOR_BURN_GAS_LIMIT_EVM = 500_000n // buffered worst 474_078 (Sei 3
12607
12711
  ;
12608
12712
  const RECEIVE_MESSAGE_GAS_LIMIT_EVM = 400_000n // observed max 310_839; clears Cronos' calldata floor ~10x
12609
12713
  ;
12714
+ /**
12715
+ * The gas floor for each bridge step, keyed by step name.
12716
+ *
12717
+ * Two places need these and they must agree: each step module passes its floor
12718
+ * to `executePreparedChainRequest` for submission, and
12719
+ * `CCTPV2BridgingProvider.estimate()` quotes the resulting limit so a caller
12720
+ * can fund a wallet. A transaction is only admitted when the sender holds
12721
+ * `gasLimit * maxFeePerGas`, so a quote taken from anything other than the
12722
+ * submitted limit under-reports what the wallet actually needs — historically
12723
+ * the quote sat ~2.5x below the reserved limit.
12724
+ *
12725
+ * Both sides read this map so the two cannot drift apart. Change a floor here
12726
+ * and the quote moves with it; point one side at a different value and the
12727
+ * divergence is visible in review rather than silent at runtime.
12728
+ */ const BRIDGE_STEP_GAS_FLOORS_EVM = {
12729
+ approve: APPROVE_GAS_LIMIT_EVM,
12730
+ burn: DEPOSIT_FOR_BURN_GAS_LIMIT_EVM,
12731
+ mint: RECEIVE_MESSAGE_GAS_LIMIT_EVM
12732
+ };
12610
12733
  /**
12611
12734
  * The minimum finality threshold for CCTPv2 transfers.
12612
12735
  *
@@ -12625,1595 +12748,1595 @@ const RECEIVE_MESSAGE_GAS_LIMIT_EVM = 400_000n // observed max 310_839; clears C
12625
12748
  };
12626
12749
 
12627
12750
  /**
12628
- * Default configuration values for the attestation fetcher.
12629
- * @internal
12630
- */ const DEFAULT_CONFIG = {
12631
- timeout: 2_000,
12632
- maxRetries: 30 * 20,
12633
- retryDelay: 2_000,
12634
- headers: {
12635
- 'Content-Type': 'application/json'
12636
- }
12637
- };
12638
- /**
12639
- * Merges caller-provided polling overrides on top of {@link DEFAULT_CONFIG}.
12640
- *
12641
- * Headers are merged independently so caller-supplied headers augment the
12642
- * defaults (such as `Content-Type`) rather than replacing them wholesale.
12643
- *
12644
- * @param config - Caller-provided polling configuration overrides
12645
- * @param internalDefaults - Internal defaults applied before `config` (for example a
12646
- * reduced `maxRetries` for one-shot requests); `config` still wins on conflict
12647
- * @returns The effective polling configuration
12648
- * @internal
12649
- */ const mergeAttestationConfig = (config, internalDefaults = {})=>({
12650
- ...DEFAULT_CONFIG,
12651
- ...internalDefaults,
12652
- ...config,
12653
- headers: {
12654
- ...DEFAULT_CONFIG.headers,
12655
- ...internalDefaults.headers,
12656
- ...config.headers
12657
- }
12658
- });
12659
- /**
12660
- * Type guard that verifies if an unknown value matches the AttestationMessage shape
12661
- * and has all required properties.
12751
+ * CCTP bridge step names that can occur in the bridging flow.
12662
12752
  *
12663
- * @param obj - The value to check, typically an element from the messages array
12664
- * @returns True if the object matches the AttestationMessage shape, false otherwise
12665
- * @internal
12666
- */ const isValidAttestationMessage = (obj)=>{
12667
- return typeof obj === 'object' && obj !== null && 'message' in obj && 'eventNonce' in obj && 'attestation' in obj && 'decodedMessage' in obj && 'cctpVersion' in obj && 'status' in obj && typeof obj.status === 'string';
12753
+ * This object provides type safety for step names and represents all possible
12754
+ * steps that can be executed during a CCTP bridge operation. Using const assertions
12755
+ * makes this tree-shakable and follows modern TypeScript best practices.
12756
+ */ const CCTPv2StepName = {
12757
+ approve: 'approve',
12758
+ burn: 'burn',
12759
+ fetchAttestation: 'fetchAttestation',
12760
+ mint: 'mint',
12761
+ reAttest: 'reAttest'
12668
12762
  };
12669
12763
  /**
12670
- * Type guard that verifies if an attestation message is complete.
12764
+ * Conditional step transition rules for CCTP bridge flow.
12671
12765
  *
12672
- * @param message - The attestation message to check
12673
- * @returns True if the message status is 'complete', false otherwise
12674
- * @internal
12675
- */ const isCompleteAttestation = (message)=>{
12676
- return message.status === 'complete';
12766
+ * Rules are evaluated in order - the first matching condition determines the next step.
12767
+ * This approach supports flexible flow logic and makes it easy to extend with new patterns.
12768
+ */ const STEP_TRANSITION_RULES = {
12769
+ // Starting state - no steps executed yet
12770
+ '': [
12771
+ {
12772
+ condition: ()=>true,
12773
+ nextStep: CCTPv2StepName.approve,
12774
+ reason: 'Start with approval step',
12775
+ isActionable: true
12776
+ }
12777
+ ],
12778
+ // After Approve step
12779
+ [CCTPv2StepName.approve]: [
12780
+ {
12781
+ condition: (ctx)=>ctx.lastStep?.state === 'success',
12782
+ nextStep: CCTPv2StepName.burn,
12783
+ reason: 'Approval successful, proceed to burn',
12784
+ isActionable: true
12785
+ },
12786
+ {
12787
+ condition: (ctx)=>ctx.lastStep?.state === 'error',
12788
+ nextStep: CCTPv2StepName.approve,
12789
+ reason: 'Retry failed approval',
12790
+ isActionable: true
12791
+ },
12792
+ {
12793
+ condition: (ctx)=>ctx.lastStep?.state === 'noop',
12794
+ nextStep: CCTPv2StepName.burn,
12795
+ reason: 'No approval needed, proceed to burn',
12796
+ isActionable: true
12797
+ },
12798
+ {
12799
+ condition: (ctx)=>ctx.lastStep?.state === 'pending',
12800
+ nextStep: CCTPv2StepName.approve,
12801
+ reason: 'Continue pending approval',
12802
+ isActionable: false
12803
+ }
12804
+ ],
12805
+ // After Burn step
12806
+ [CCTPv2StepName.burn]: [
12807
+ {
12808
+ condition: (ctx)=>ctx.lastStep?.state === 'success',
12809
+ nextStep: CCTPv2StepName.fetchAttestation,
12810
+ reason: 'Burn successful, fetch attestation',
12811
+ isActionable: true
12812
+ },
12813
+ {
12814
+ condition: (ctx)=>ctx.lastStep?.state === 'error',
12815
+ nextStep: CCTPv2StepName.burn,
12816
+ reason: 'Retry failed burn',
12817
+ isActionable: true
12818
+ },
12819
+ {
12820
+ condition: (ctx)=>ctx.lastStep?.state === 'pending',
12821
+ nextStep: CCTPv2StepName.burn,
12822
+ reason: 'Continue pending burn',
12823
+ isActionable: false
12824
+ }
12825
+ ],
12826
+ // After FetchAttestation step
12827
+ [CCTPv2StepName.fetchAttestation]: [
12828
+ {
12829
+ condition: (ctx)=>ctx.lastStep?.state === 'success',
12830
+ nextStep: CCTPv2StepName.mint,
12831
+ reason: 'Attestation fetched, proceed to mint',
12832
+ isActionable: true
12833
+ },
12834
+ {
12835
+ condition: (ctx)=>ctx.lastStep?.state === 'error',
12836
+ nextStep: CCTPv2StepName.fetchAttestation,
12837
+ reason: 'Retry fetching attestation',
12838
+ isActionable: true
12839
+ },
12840
+ {
12841
+ condition: (ctx)=>ctx.lastStep?.state === 'pending',
12842
+ nextStep: CCTPv2StepName.fetchAttestation,
12843
+ reason: 'Continue pending attestation fetch',
12844
+ isActionable: false
12845
+ }
12846
+ ],
12847
+ // After Mint step
12848
+ [CCTPv2StepName.mint]: [
12849
+ {
12850
+ condition: (ctx)=>ctx.lastStep?.state === 'success',
12851
+ nextStep: null,
12852
+ reason: 'Bridge completed successfully',
12853
+ isActionable: false
12854
+ },
12855
+ {
12856
+ condition: (ctx)=>ctx.lastStep?.state === 'error',
12857
+ nextStep: CCTPv2StepName.mint,
12858
+ reason: 'Retry failed mint',
12859
+ isActionable: true
12860
+ },
12861
+ {
12862
+ condition: (ctx)=>ctx.lastStep?.state === 'pending',
12863
+ nextStep: CCTPv2StepName.mint,
12864
+ reason: 'Continue pending mint',
12865
+ isActionable: false
12866
+ }
12867
+ ],
12868
+ // After ReAttest step
12869
+ [CCTPv2StepName.reAttest]: [
12870
+ {
12871
+ condition: (ctx)=>ctx.lastStep?.state === 'success',
12872
+ nextStep: CCTPv2StepName.mint,
12873
+ reason: 'Re-attestation successful, proceed to mint',
12874
+ isActionable: true
12875
+ },
12876
+ {
12877
+ condition: (ctx)=>ctx.lastStep?.state === 'error',
12878
+ nextStep: CCTPv2StepName.mint,
12879
+ reason: 'Re-attestation failed, retry mint to re-initiate recovery',
12880
+ isActionable: true
12881
+ },
12882
+ {
12883
+ condition: (ctx)=>ctx.lastStep?.state === 'pending',
12884
+ nextStep: CCTPv2StepName.mint,
12885
+ reason: 'Re-attestation pending, retry mint to re-initiate recovery',
12886
+ isActionable: true
12887
+ }
12888
+ ]
12677
12889
  };
12678
12890
  /**
12679
- * Type guard that verifies if an unknown value has the correct structure
12680
- * for an AttestationResponse, regardless of attestation completion status.
12891
+ * Analyze bridge steps to determine retry feasibility and continuation point.
12681
12892
  *
12682
- * @param obj - The value to check, typically a parsed JSON response
12683
- * @returns True if the object matches the AttestationResponse shape
12684
- * @internal
12685
- */ const hasValidAttestationStructure = (obj)=>{
12686
- if (typeof obj !== 'object' || obj === null || !('messages' in obj) || !Array.isArray(obj.messages)) {
12687
- return false;
12688
- }
12689
- const messages = obj.messages;
12690
- // Validate all messages have the correct shape
12691
- return messages.every(isValidAttestationMessage);
12692
- };
12693
- /**
12694
- * Type guard that verifies if an unknown value matches the AttestationResponse shape
12695
- * and contains a complete attestation.
12893
+ * This function examines the current state of bridge steps to determine the optimal
12894
+ * continuation strategy. It uses a rule-based approach that makes it easy to extend
12895
+ * with new flow patterns and step types in the future.
12696
12896
  *
12697
- * This function performs runtime validation to ensure that the provided value
12698
- * conforms to the expected structure of an AttestationResponse and has at least
12699
- * one complete attestation. It checks that:
12700
- * 1. The value has valid AttestationResponse structure
12701
- * 2. At least one message has status 'complete'
12897
+ * The current analysis supports the standard CCTP flow:
12898
+ * **Traditional flow**: Approve Burn FetchAttestation Mint
12702
12899
  *
12703
- * @remarks
12704
- * This type guard is used internally by the attestation fetcher to validate
12705
- * responses from the IRIS API before processing them. It provides runtime
12706
- * type safety for data coming from the network and ensures we have a complete
12707
- * attestation before proceeding.
12708
- *
12709
- * If the response has valid structure but no complete attestation yet,
12710
- * it throws a retryable error. If the response structure is invalid,
12711
- * it throws a non-retryable validation error.
12900
+ * Key features:
12901
+ * - Rule-based transitions: Easy to extend with new step types and logic
12902
+ * - Context-aware decisions: Considers execution history and step states
12903
+ * - Actionable logic: Distinguishes between steps requiring user action vs waiting
12904
+ * - Terminal states: Properly handles completion and non-actionable states
12712
12905
  *
12713
- * @param obj - The value to check, typically a parsed JSON response
12714
- * @returns True if the object matches the AttestationResponse shape and has a complete attestation
12715
- * @throws {Error} With "Invalid attestation response structure" if structure is invalid (non-retryable)
12716
- * @throws {Error} With "Attestation not ready" if no complete attestation yet (retryable)
12906
+ * @param bridgeResult - The bridge result containing step execution history.
12907
+ * @returns Analysis result with continuation step and actionability information.
12908
+ * @throws Error when bridgeResult is invalid or contains no steps array.
12717
12909
  *
12718
12910
  * @example
12719
12911
  * ```typescript
12720
- * const response = await fetch('https://iris-api.circle.com/...')
12721
- * const data = await response.json()
12912
+ * import { analyzeSteps } from './analyzeSteps'
12722
12913
  *
12723
- * if (isAttestationResponse(data)) {
12724
- * // TypeScript now knows data is AttestationResponse with at least one complete attestation
12725
- * const completeMessage = data.messages.find(msg => msg.status === 'complete')
12726
- * console.log('Found complete attestation:', completeMessage.attestation)
12914
+ * // Failed approval step (requires user action)
12915
+ * const bridgeResult = {
12916
+ * steps: [
12917
+ * { name: 'Approve', state: 'error', errorMessage: 'User rejected' }
12918
+ * ]
12727
12919
  * }
12728
- * ```
12729
- */ const isAttestationResponse = (obj)=>{
12730
- // First check if the structure is valid
12731
- if (!hasValidAttestationStructure(obj)) {
12732
- // If structure is invalid, this is a permanent failure - don't retry
12733
- throw new Error('Invalid attestation response structure');
12734
- }
12735
- // Then check if at least one message is complete
12736
- if (!obj.messages.some(isCompleteAttestation)) {
12737
- // If no complete message, this is a temporary state - allow retry
12738
- throw new Error('Attestation not ready');
12739
- }
12740
- return true;
12741
- };
12742
- /**
12743
- * Builds the IRIS API URL for fetching attestation data from Circle's CCTP service.
12744
12920
  *
12745
- * Constructs a properly formatted URL for the IRIS API v2 endpoint that provides
12746
- * attestation messages for cross-chain transfers. The URL includes both the source
12747
- * domain identifier and the transaction hash as query parameters. The base URL
12748
- * is selected based on whether the operation is for testnet or mainnet.
12749
- *
12750
- * @param sourceDomainId - The CCTP domain ID of the source chain (numeric or string)
12751
- * @param transactionHash - The transaction hash of the burn operation to fetch attestation for
12752
- * @param isTestnet - Whether this is for a testnet chain (true) or mainnet chain (false)
12753
- * @returns A fully qualified URL string for the IRIS API endpoint
12921
+ * const analysis = analyzeSteps(bridgeResult)
12922
+ * // Result: { continuationStep: 'Approve', isRetryable: true,
12923
+ * // reason: 'Retry failed approval' }
12924
+ * ```
12754
12925
  *
12755
12926
  * @example
12756
12927
  * ```typescript
12757
- * // Mainnet URL
12758
- * const mainnetUrl = buildIrisUrl(1, '0xabc...', false)
12759
- * // => 'https://iris-api.circle.com/v2/messages/1?transactionHash=0xabc...'
12928
+ * // Pending transaction (requires waiting, not actionable)
12929
+ * const bridgeResult = {
12930
+ * steps: [
12931
+ * { name: 'Approve', state: 'pending' }
12932
+ * ]
12933
+ * }
12760
12934
  *
12761
- * // Testnet URL
12762
- * const testnetUrl = buildIrisUrl(1, '0xdef...', true)
12763
- * // => 'https://iris-api-sandbox.circle.com/v2/messages/1?transactionHash=0xdef...'
12935
+ * const analysis = analyzeSteps(bridgeResult)
12936
+ * // Result: { continuationStep: 'Approve', isRetryable: false,
12937
+ * // reason: 'Continue pending approval' }
12764
12938
  * ```
12765
- */ const buildIrisUrl = (sourceDomainId, transactionHash, isTestnet)=>{
12766
- const baseUrl = isTestnet ? IRIS_API_SANDBOX_BASE_URL : IRIS_API_BASE_URL;
12767
- const url = new URL(`${baseUrl}/v2/messages/${String(sourceDomainId)}`);
12768
- url.searchParams.set('transactionHash', transactionHash);
12769
- return url.toString();
12770
- };
12771
- /**
12772
- * Fetches attestation data from the IRIS API with retry and timeout handling.
12773
- *
12774
- * Polls the IRIS API until a complete attestation is available. The default
12775
- * window is sized for slow source chains where finality may take many
12776
- * confirmations.
12777
- *
12778
- * Defaults (see `DEFAULT_CONFIG`):
12779
- * - Per-attempt timeout: 2 000 ms (each HTTP request aborts after 2 s)
12780
- * - Retry delay: 2 000 ms between attempts
12781
- * - Max retries: 600 (30 × 20)
12782
- * - Total worst-case polling window: 600 × (2 000 ms + 2 000 ms) ≈ 40 minutes
12783
- *
12784
- * @param sourceDomainId - The CCTP domain ID.
12785
- * @param transactionHash - The transaction hash to fetch attestation for.
12786
- * @param isTestnet - Whether this is for a testnet chain (true) or mainnet chain (false)
12787
- * @param config - Optional configuration overrides for the attestation fetcher
12788
- * @returns The attestation response data.
12789
- * @throws If the request fails, times out, or returns invalid data.
12790
12939
  *
12791
12940
  * @example
12792
12941
  * ```typescript
12793
- * // Fetch attestation for mainnet transaction
12794
- * const response = await fetchAttestation(1, '0xabc...', false)
12795
- * console.log(`Found ${response.messages.length} attestation messages`)
12942
+ * // Completed bridge (nothing to do)
12943
+ * const bridgeResult = {
12944
+ * steps: [
12945
+ * { name: 'Approve', state: 'success' },
12946
+ * { name: 'Burn', state: 'success' },
12947
+ * { name: 'FetchAttestation', state: 'success' },
12948
+ * { name: 'Mint', state: 'success' }
12949
+ * ]
12950
+ * }
12796
12951
  *
12797
- * // Fetch with custom timeout
12798
- * const response2 = await fetchAttestation(1, '0xdef...', true, {
12799
- * timeout: 5000,
12800
- * maxRetries: 5
12801
- * })
12952
+ * const analysis = analyzeSteps(bridgeResult)
12953
+ * // Result: { continuationStep: null, isRetryable: false,
12954
+ * // reason: 'Bridge completed successfully' }
12802
12955
  * ```
12803
- */ const fetchAttestation = async (sourceDomainId, transactionHash, isTestnet, config = {})=>{
12804
- const url = buildIrisUrl(sourceDomainId, transactionHash, isTestnet);
12805
- const effectiveConfig = mergeAttestationConfig(config);
12806
- return await pollApiGet(url, isAttestationResponse, effectiveConfig);
12956
+ */ const analyzeSteps = (bridgeResult)=>{
12957
+ // Input validation
12958
+ if (!bridgeResult || !Array.isArray(bridgeResult.steps)) {
12959
+ throw new Error('Invalid bridgeResult: must contain a steps array');
12960
+ }
12961
+ const { steps } = bridgeResult;
12962
+ // Build execution context from step history
12963
+ const context = buildFlowContext(steps);
12964
+ // Determine continuation logic using rule engine
12965
+ const continuation = determineContinuationFromRules(context);
12966
+ return {
12967
+ continuationStep: continuation.nextStep,
12968
+ isActionable: continuation.isActionable,
12969
+ completedSteps: Array.from(context.completedSteps),
12970
+ failedSteps: Array.from(context.failedSteps),
12971
+ reason: continuation.reason
12972
+ };
12807
12973
  };
12808
12974
  /**
12809
- * Type guard that validates attestation response structure without requiring completion status.
12810
- *
12811
- * This is used by `fetchAttestationWithoutStatusCheck` to extract the nonce from an existing
12812
- * attestation, even if the attestation is expired or pending. Unlike `isAttestationResponse`,
12813
- * this function does not throw if no complete attestation is found.
12975
+ * Build flow context from the execution history.
12814
12976
  *
12815
- * @param obj - The value to check, typically a parsed JSON response
12816
- * @returns True if the object has valid attestation structure
12817
- * @throws {Error} With "Invalid attestation response structure" if structure is invalid
12818
- * @internal
12819
- */ const isAttestationResponseWithoutStatusCheck = (obj)=>{
12820
- if (!hasValidAttestationStructure(obj)) {
12821
- throw new Error('Invalid attestation response structure');
12977
+ * @param steps - Array of executed bridge steps.
12978
+ * @returns Flow context with execution state and history.
12979
+ */ function buildFlowContext(steps) {
12980
+ const completedSteps = new Set();
12981
+ const failedSteps = new Set();
12982
+ let lastStep;
12983
+ // Process step history to build context
12984
+ for (const step of steps){
12985
+ if (step.state === 'success' || step.state === 'noop') {
12986
+ completedSteps.add(step.name);
12987
+ } else if (step.state === 'error') {
12988
+ failedSteps.add(step.name);
12989
+ }
12990
+ // Track the last step for continuation logic
12991
+ lastStep = {
12992
+ name: step.name,
12993
+ state: step.state
12994
+ };
12822
12995
  }
12823
- return true;
12824
- };
12996
+ return {
12997
+ completedSteps,
12998
+ failedSteps,
12999
+ ...lastStep && {
13000
+ lastStep
13001
+ }
13002
+ };
13003
+ }
12825
13004
  /**
12826
- * Fetches attestation data without requiring the attestation to be complete.
13005
+ * Determine continuation step using the rule engine.
12827
13006
  *
12828
- * This function is useful for retrieving attestation data (particularly the nonce)
12829
- * from an existing transaction, even if the attestation has expired or is pending.
12830
- * It uses minimal retries since we're fetching existing data, not waiting for completion.
13007
+ * @param context - The flow context with execution history.
13008
+ * @returns Continuation decision with next step and actionability information.
13009
+ */ function determineContinuationFromRules(context) {
13010
+ const lastStepName = context.lastStep?.name;
13011
+ // Handle initial state when no steps have been executed
13012
+ if (lastStepName === undefined) {
13013
+ const rules = STEP_TRANSITION_RULES[''];
13014
+ const matchingRule = rules?.find((rule)=>rule.condition(context));
13015
+ if (!matchingRule) {
13016
+ return {
13017
+ nextStep: null,
13018
+ isActionable: false,
13019
+ reason: 'No initial state rule found'
13020
+ };
13021
+ }
13022
+ return {
13023
+ nextStep: matchingRule.nextStep,
13024
+ isActionable: matchingRule.isActionable,
13025
+ reason: matchingRule.reason
13026
+ };
13027
+ }
13028
+ // A step with an empty name is ambiguous and should be treated as an unrecoverable state.
13029
+ if (lastStepName === '') {
13030
+ return {
13031
+ nextStep: null,
13032
+ isActionable: false,
13033
+ reason: 'No transition rules defined for step with empty name'
13034
+ };
13035
+ }
13036
+ const rules = STEP_TRANSITION_RULES[lastStepName];
13037
+ if (!rules) {
13038
+ return {
13039
+ nextStep: null,
13040
+ isActionable: false,
13041
+ reason: `No transition rules defined for step: ${lastStepName}`
13042
+ };
13043
+ }
13044
+ // Find the first matching rule
13045
+ const matchingRule = rules.find((rule)=>rule.condition(context));
13046
+ if (!matchingRule) {
13047
+ return {
13048
+ nextStep: null,
13049
+ isActionable: false,
13050
+ reason: `No matching transition rule for current context`
13051
+ };
13052
+ }
13053
+ return {
13054
+ nextStep: matchingRule.nextStep,
13055
+ isActionable: matchingRule.isActionable,
13056
+ reason: matchingRule.reason
13057
+ };
13058
+ }
13059
+
13060
+ /**
13061
+ * Find a step by name in the bridge result.
12831
13062
  *
12832
- * @param sourceDomainId - The CCTP domain ID of the source chain
12833
- * @param transactionHash - The transaction hash to fetch attestation for
12834
- * @param isTestnet - Whether this is for a testnet chain (true) or mainnet chain (false)
12835
- * @param config - Optional configuration overrides
12836
- * @returns The attestation response data (may contain incomplete/expired attestations)
12837
- * @throws If the request fails, times out, or returns invalid data
13063
+ * @param result - The bridge result to search.
13064
+ * @param stepName - The name of the step to find.
13065
+ * @returns The step if found, undefined otherwise.
12838
13066
  *
12839
13067
  * @example
12840
13068
  * ```typescript
12841
- * // Fetch existing attestation to extract nonce for re-attestation
12842
- * const response = await fetchAttestationWithoutStatusCheck(1, '0xabc...', true)
12843
- * const nonce = response.messages[0]?.eventNonce
12844
- * ```
12845
- */ const fetchAttestationWithoutStatusCheck = async (sourceDomainId, transactionHash, isTestnet, config = {})=>{
12846
- const url = buildIrisUrl(sourceDomainId, transactionHash, isTestnet);
12847
- // Use minimal retries since we're just fetching existing data
12848
- const effectiveConfig = mergeAttestationConfig(config, {
12849
- maxRetries: 3
12850
- });
12851
- return await pollApiGet(url, isAttestationResponseWithoutStatusCheck, effectiveConfig);
12852
- };
13069
+ * import { findStepByName } from './findStep'
13070
+ *
13071
+ * const burnStep = findStepByName(result, 'burn')
13072
+ * if (burnStep) {
13073
+ * console.log('Burn tx:', burnStep.txHash)
13074
+ * }
13075
+ * ```
13076
+ */ function findStepByName(result, stepName) {
13077
+ return result.steps.find((step)=>step.name === stepName);
13078
+ }
12853
13079
  /**
12854
- * Type guard that validates attestation response has expirationBlock === '0'.
13080
+ * Find a pending step by name and return it with its index.
12855
13081
  *
12856
- * This is used after requestReAttestation() to poll until the attestation
12857
- * is fully re-processed and has a zero expiration block (never expires).
12858
- * The expiration block transitions from non-zero to zero when Circle
12859
- * completes processing the re-attestation request.
13082
+ * Searches for a step that matches both the step name and has a pending state.
12860
13083
  *
12861
- * @param obj - The value to check, typically a parsed JSON response
12862
- * @returns True if the attestation has expirationBlock === '0'
12863
- * @throws {Error} With "Re-attestation not yet complete" if expirationBlock is not '0'
13084
+ * @param result - The bridge result containing steps to search through.
13085
+ * @param stepName - The step name to find (e.g., 'burn', 'mint', 'fetchAttestation').
13086
+ * @returns An object containing the step and its index in the steps array.
13087
+ * @throws KitError if the specified pending step is not found.
12864
13088
  *
12865
13089
  * @example
12866
13090
  * ```typescript
12867
- * // After requesting re-attestation, use this to validate the response
12868
- * const response = await pollApiGet(url, isReAttestedAttestationResponse, config)
12869
- * // response.messages[0].decodedMessage.decodedMessageBody.expirationBlock === '0'
12870
- * ```
13091
+ * import { findPendingStep } from './findStep'
12871
13092
  *
12872
- * @internal
12873
- */ const isReAttestedAttestationResponse = (obj)=>{
12874
- // First validate the basic structure and completion status
12875
- // This will throw appropriate errors for invalid structure or incomplete attestation
12876
- if (!isAttestationResponse(obj)) ;
12877
- // Check if the first message has expirationBlock === '0'
12878
- const expirationBlock = obj.messages[0]?.decodedMessage?.decodedMessageBody?.expirationBlock;
12879
- if (expirationBlock !== '0') {
12880
- // Re-attestation not yet complete - allow retry via polling
12881
- throw new Error('Re-attestation not yet complete: waiting for expirationBlock to become 0');
13093
+ * const { step, index } = findPendingStep(result, 'burn')
13094
+ * console.log('Pending step:', step.name, 'at index:', index)
13095
+ * ```
13096
+ */ function findPendingStep(result, stepName) {
13097
+ const index = result.steps.findIndex((step)=>step.name === stepName && step.state === 'pending');
13098
+ if (index === -1) {
13099
+ throw new KitError({
13100
+ ...InputError.VALIDATION_FAILED,
13101
+ recoverability: 'FATAL',
13102
+ message: `Pending step "${stepName}" not found in result`
13103
+ });
12882
13104
  }
12883
- return true;
12884
- };
13105
+ const step = result.steps[index];
13106
+ if (!step) {
13107
+ throw new KitError({
13108
+ ...InputError.VALIDATION_FAILED,
13109
+ recoverability: 'FATAL',
13110
+ message: 'Pending step is undefined'
13111
+ });
13112
+ }
13113
+ return {
13114
+ step,
13115
+ index
13116
+ };
13117
+ }
12885
13118
  /**
12886
- * Fetches attestation data and polls until expirationBlock === '0'.
13119
+ * Get the burn transaction hash from bridge result.
12887
13120
  *
12888
- * This function is used after calling requestReAttestation() to wait until
12889
- * the attestation is fully re-processed. The expirationBlock transitions
12890
- * from non-zero to zero when Circle completes the re-attestation.
13121
+ * @param result - The bridge result.
13122
+ * @returns The burn transaction hash, or undefined if not found.
12891
13123
  *
12892
- * @param sourceDomainId - The CCTP domain ID of the source chain
12893
- * @param transactionHash - The transaction hash to fetch attestation for
12894
- * @param isTestnet - Whether this is for a testnet chain (true) or mainnet chain (false)
12895
- * @param config - Optional configuration overrides
12896
- * @returns The re-attested attestation response with expirationBlock === '0'
12897
- * @throws If the request fails, times out, or expirationBlock never becomes 0
13124
+ * @example
13125
+ * ```typescript
13126
+ * import { getBurnTxHash } from './findStep'
13127
+ *
13128
+ * const burnTxHash = getBurnTxHash(result)
13129
+ * if (burnTxHash) {
13130
+ * console.log('Burn tx hash:', burnTxHash)
13131
+ * }
13132
+ * ```
13133
+ */ function getBurnTxHash(result) {
13134
+ return findStepByName(result, CCTPv2StepName.burn)?.txHash;
13135
+ }
13136
+ /**
13137
+ * Get the attestation data from bridge result.
13138
+ *
13139
+ * @param result - The bridge result.
13140
+ * @returns The attestation data, or undefined if not found.
12898
13141
  *
12899
13142
  * @example
12900
13143
  * ```typescript
12901
- * // After requesting re-attestation
12902
- * await requestReAttestation(nonce, isTestnet)
13144
+ * import { getAttestationData } from './findStep'
12903
13145
  *
12904
- * // Poll until expirationBlock becomes 0
12905
- * const response = await fetchReAttestedAttestation(domainId, txHash, isTestnet)
12906
- * // response.messages[0].decodedMessage.decodedMessageBody.expirationBlock === '0'
13146
+ * const attestation = getAttestationData(result)
13147
+ * if (attestation) {
13148
+ * console.log('Attestation:', attestation.message)
13149
+ * }
12907
13150
  * ```
12908
- */ const fetchReAttestedAttestation = async (sourceDomainId, transactionHash, isTestnet, config = {})=>{
12909
- const url = buildIrisUrl(sourceDomainId, transactionHash, isTestnet);
12910
- const effectiveConfig = mergeAttestationConfig(config);
12911
- return await pollApiGet(url, isReAttestedAttestationResponse, effectiveConfig);
12912
- };
13151
+ */ function getAttestationData(result) {
13152
+ // Prefer reAttest data (most recent attestation after expiry)
13153
+ const reAttestStep = findStepByName(result, CCTPv2StepName.reAttest);
13154
+ if (reAttestStep?.state === 'success' && reAttestStep.data) {
13155
+ return reAttestStep.data;
13156
+ }
13157
+ // Fall back to fetchAttestation step
13158
+ const fetchStep = findStepByName(result, CCTPv2StepName.fetchAttestation);
13159
+ return fetchStep?.data;
13160
+ }
13161
+
12913
13162
  /**
12914
- * Builds the IRIS API URL for re-attestation requests.
13163
+ * Check if the analysis indicates a non-actionable pending state.
12915
13164
  *
12916
- * Constructs the URL for Circle's re-attestation endpoint that allows
12917
- * requesting a fresh attestation for an expired nonce.
13165
+ * A pending state is non-actionable when there's a continuation step but
13166
+ * the analysis marks it as not actionable, typically because we need to
13167
+ * wait for an ongoing operation to complete.
12918
13168
  *
12919
- * @param nonce - The nonce from the original attestation
12920
- * @param isTestnet - Whether this is for a testnet chain (true) or mainnet chain (false)
12921
- * @returns A fully qualified URL string for the re-attestation endpoint
13169
+ * @param analysis - The step analysis result from analyzeSteps.
13170
+ * @param result - The bridge result to check for pending steps.
13171
+ * @returns True if there is a pending step that we should wait for.
12922
13172
  *
12923
13173
  * @example
12924
13174
  * ```typescript
12925
- * // Mainnet URL
12926
- * const mainnetUrl = buildReAttestUrl('0xabc', false)
12927
- * // => 'https://iris-api.circle.com/v2/reattest/0xabc'
13175
+ * import { hasPendingState } from './stepUtils'
13176
+ * import { analyzeSteps } from '../analyzeSteps'
12928
13177
  *
12929
- * // Testnet URL
12930
- * const testnetUrl = buildReAttestUrl('0xabc', true)
12931
- * // => 'https://iris-api-sandbox.circle.com/v2/reattest/0xabc'
13178
+ * const analysis = analyzeSteps(bridgeResult)
13179
+ * if (hasPendingState(analysis, bridgeResult)) {
13180
+ * // Wait for the pending operation to complete
13181
+ * }
12932
13182
  * ```
12933
- */ const buildReAttestUrl = (nonce, isTestnet)=>{
12934
- const baseUrl = isTestnet ? IRIS_API_SANDBOX_BASE_URL : IRIS_API_BASE_URL;
12935
- const url = new URL(`${baseUrl}/v2/reattest/${nonce}`);
12936
- return url.toString();
12937
- };
12938
- /**
12939
- * Type guard that validates the re-attestation API response structure.
13183
+ */ /**
13184
+ * Evaluate a transaction receipt and return the corresponding step state
13185
+ * and error message. Centralises the success/revert/unconfirmed logic so
13186
+ * every call-site behaves identically.
12940
13187
  *
12941
- * @param obj - The value to check, typically a parsed JSON response
12942
- * @returns True if the object matches the ReAttestationResponse shape
12943
- * @throws {Error} With "Invalid re-attestation response structure" if structure is invalid
12944
- * @internal
12945
- */ const isReAttestationResponse = (obj)=>{
12946
- if (typeof obj !== 'object' || obj === null || !('message' in obj) || !('nonce' in obj) || typeof obj.message !== 'string' || typeof obj.nonce !== 'string') {
12947
- throw new Error('Invalid re-attestation response structure');
13188
+ * @param receipt - The transaction receipt containing status and block info.
13189
+ * @param txHash - The transaction hash used in error messages.
13190
+ * @returns An object with `state` and an optional `errorMessage`.
13191
+ *
13192
+ * @example
13193
+ * ```typescript
13194
+ * const outcome = evaluateTransactionOutcome(receipt, '0xabc...')
13195
+ * step.state = outcome.state
13196
+ * if (outcome.errorMessage) step.errorMessage = outcome.errorMessage
13197
+ * ```
13198
+ */ function evaluateTransactionOutcome(receipt, txHash) {
13199
+ if (receipt.status === 'success' && receipt.blockNumber) {
13200
+ return {
13201
+ state: 'success'
13202
+ };
12948
13203
  }
12949
- return true;
12950
- };
13204
+ return {
13205
+ state: 'error',
13206
+ errorMessage: receipt.status === 'reverted' ? `Transaction ${txHash} was reverted` : 'Transaction was not confirmed on-chain'
13207
+ };
13208
+ }
13209
+ function hasPendingState(analysis, result) {
13210
+ // Check if there's a continuation step that's marked as non-actionable
13211
+ if (analysis.continuationStep === null || analysis.isActionable) {
13212
+ return false;
13213
+ }
13214
+ // Verify that the continuation step actually exists and is in pending state
13215
+ const pendingStep = result.steps.find((step)=>step.name === analysis.continuationStep && step.state === 'pending');
13216
+ return pendingStep !== undefined;
13217
+ }
12951
13218
  /**
12952
- * Requests re-attestation for an expired attestation nonce.
12953
- *
12954
- * This function calls Circle's re-attestation API endpoint to request a fresh
12955
- * attestation for a previously issued nonce. After calling this function,
12956
- * you should poll `fetchAttestation` to retrieve the new attestation.
13219
+ * Check if the step is the last one in the execution flow.
12957
13220
  *
12958
- * @param nonce - The nonce from the original (expired) attestation
12959
- * @param isTestnet - Whether this is for a testnet chain (true) or mainnet chain (false)
12960
- * @param config - Optional configuration overrides for the request
12961
- * @returns The re-attestation response confirming the request was accepted
12962
- * @throws If the request fails, times out, or returns invalid data
13221
+ * @param step - The step object to check.
13222
+ * @param stepNames - The ordered list of step names in the execution flow.
13223
+ * @returns True if this is the last step in the flow.
12963
13224
  *
12964
13225
  * @example
12965
13226
  * ```typescript
12966
- * // Request re-attestation for an expired nonce
12967
- * const response = await requestReAttestation('0xabc', true)
12968
- * console.log(response.message) // "Re-attestation successfully requested for nonce."
13227
+ * import { isLastStep } from './stepUtils'
12969
13228
  *
12970
- * // After requesting re-attestation, poll for the new attestation
12971
- * const attestation = await fetchAttestation(domainId, txHash, true)
13229
+ * const stepNames = ['approve', 'burn', 'fetchAttestation', 'mint']
13230
+ * isLastStep({ name: 'mint' }, stepNames) // true
13231
+ * isLastStep({ name: 'burn' }, stepNames) // false
12972
13232
  * ```
12973
- */ const requestReAttestation = async (nonce, isTestnet, config = {})=>{
12974
- const url = buildReAttestUrl(nonce, isTestnet);
12975
- // Use minimal retries since we're just submitting a request, not polling for state
12976
- const effectiveConfig = mergeAttestationConfig(config, {
12977
- maxRetries: 3
12978
- });
12979
- return await pollApiPost(url, {}, isReAttestationResponse, effectiveConfig);
12980
- };
12981
-
13233
+ */ function isLastStep(step, stepNames) {
13234
+ const stepIndex = stepNames.indexOf(step.name);
13235
+ return stepIndex === -1 || stepIndex >= stepNames.length - 1;
13236
+ }
12982
13237
  /**
12983
- * Type guard that checks if the relayer has confirmed the mint transaction.
13238
+ * Wait for a pending transaction to complete.
12984
13239
  *
12985
- * This function validates that:
12986
- * 1. The response has valid AttestationResponse structure
12987
- * 2. At least one message has forwardState === 'CONFIRMED' (or 'COMPLETE') and a valid forwardTxHash
13240
+ * Poll the adapter until the transaction is confirmed on-chain and return
13241
+ * the updated step with success or error state based on the receipt.
12988
13242
  *
12989
- * If forwardState is 'FAILED', throws a non-retryable KitError.
12990
- * If forwardState is 'PENDING' or not present, throws a RETRYABLE KitError to continue polling.
13243
+ * @param pendingStep - The full step object containing the transaction hash.
13244
+ * @param adapter - The adapter to use for waiting.
13245
+ * @param chain - The chain where the transaction was submitted.
13246
+ * @returns The updated step object with success or error state.
12991
13247
  *
12992
- * @param obj - The value to check, typically a parsed JSON response
12993
- * @returns True if the relayer has confirmed the mint
12994
- * @throws {KitError} With FATAL recoverability if structure is invalid
12995
- * @throws {KitError} With RESUMABLE recoverability if forwardState is 'FAILED'
12996
- * @throws {KitError} With RETRYABLE recoverability if still pending
12997
- * @internal
12998
- */ const isRelayerMintConfirmed = (obj)=>{
12999
- // First check if the structure is valid
13000
- if (!hasValidAttestationStructure(obj)) {
13001
- throw new KitError({
13002
- ...InputError.VALIDATION_FAILED,
13003
- recoverability: 'FATAL',
13004
- message: 'Invalid attestation response structure from IRIS API.'
13005
- });
13006
- }
13007
- // Find the first message (typically there's only one)
13008
- const message = obj.messages[0];
13009
- if (!message) {
13248
+ * @throws KitError when the pending step has no transaction hash.
13249
+ *
13250
+ * @example
13251
+ * ```typescript
13252
+ * import { waitForPendingTransaction } from './bridgeStepUtils'
13253
+ *
13254
+ * const pendingStep = { name: 'burn', state: 'pending', txHash: '0x123...' }
13255
+ * const updatedStep = await waitForPendingTransaction(pendingStep, adapter, chain)
13256
+ * // updatedStep.state is now 'success' or 'error'
13257
+ * ```
13258
+ */ async function waitForPendingTransaction(pendingStep, adapter, chain) {
13259
+ if (!pendingStep.txHash) {
13010
13260
  throw new KitError({
13011
13261
  ...InputError.VALIDATION_FAILED,
13012
13262
  recoverability: 'FATAL',
13013
- message: 'No attestation messages found in IRIS API response.'
13014
- });
13015
- }
13016
- // Check for FAILED state - this is a permanent failure
13017
- if (message.forwardState === 'FAILED') {
13018
- throw new KitError({
13019
- ...NetworkError.RELAYER_FORWARD_FAILED,
13020
- recoverability: 'RESUMABLE',
13021
- message: 'Circle relayer failed to forward the mint transaction. The mint may still have succeeded if another party submitted it. Check the recipient wallet balance before retrying. If the mint did not occur, you can manually submit it using the attestation data in the error cause.',
13022
- cause: {
13023
- trace: {
13024
- eventNonce: message.eventNonce,
13025
- attestation: message.attestation,
13026
- message: message.message
13027
- }
13028
- }
13263
+ message: `Cannot wait for pending ${pendingStep.name}: no transaction hash available`
13029
13264
  });
13030
13265
  }
13031
- // Check if mint is confirmed (or complete) with a valid transaction hash
13032
- // We accept both CONFIRMED and COMPLETE since COMPLETE implies CONFIRMED
13033
- if ((message.forwardState === 'CONFIRMED' || message.forwardState === 'COMPLETE') && typeof message.forwardTxHash === 'string' && message.forwardTxHash.trim().length > 0) {
13034
- return true;
13035
- }
13036
- // Still pending or not yet processed - throw RETRYABLE error to continue polling
13037
- throw new KitError({
13038
- ...NetworkError.RELAYER_PENDING,
13039
- recoverability: 'RETRYABLE',
13040
- message: 'Relayer mint not ready. Waiting for confirmation.'
13266
+ const txHash = pendingStep.txHash;
13267
+ const txReceipt = await retryAsync(async ()=>adapter.waitForTransaction(txHash, undefined, chain), {
13268
+ isRetryable: (err)=>isRetryableError$1(parseBlockchainError(err, {
13269
+ chain: chain.name,
13270
+ txHash
13271
+ }))
13041
13272
  });
13042
- };
13273
+ const outcome = evaluateTransactionOutcome(txReceipt, txHash);
13274
+ return {
13275
+ ...pendingStep,
13276
+ state: outcome.state,
13277
+ data: txReceipt,
13278
+ explorerUrl: buildExplorerUrl(chain, txHash),
13279
+ ...outcome.errorMessage ? {
13280
+ errorMessage: outcome.errorMessage
13281
+ } : {}
13282
+ };
13283
+ }
13043
13284
  /**
13044
- * Polls the attestation API until the relayer's mint transaction is confirmed.
13285
+ * Wait for a pending step to complete.
13045
13286
  *
13046
- * This function is used when `useForwarder` is enabled. Instead of the user
13047
- * submitting the mint transaction, Circle's Orbit relayer handles it automatically.
13048
- * This function polls until the relayer has submitted and confirmed the mint transaction.
13287
+ * For transaction steps: waits for the transaction to be confirmed.
13288
+ * For attestation: re-executes the attestation fetch.
13049
13289
  *
13050
- * @remarks
13051
- * - Uses a 20-minute timeout by default (600 retries × 2 seconds)
13052
- * - Throws immediately if `forwardState` is 'FAILED'
13053
- * - Waits for `forwardState` to be 'CONFIRMED' or 'COMPLETE' (COMPLETE implies CONFIRMED)
13054
- * - Returns the attestation message with `forwardTxHash` populated
13290
+ * @typeParam TFromAdapterCapabilities - The capabilities of the source adapter.
13291
+ * @typeParam TToAdapterCapabilities - The capabilities of the destination adapter.
13292
+ * @param pendingStep - The full step object (with name, state, txHash, data, etc.) to resolve.
13293
+ * @param adapter - The adapter to use.
13294
+ * @param chain - The chain where the step is executing.
13295
+ * @param context - The retry context.
13296
+ * @param result - The bridge result.
13297
+ * @param provider - The CCTP v2 bridging provider.
13298
+ * @returns The resolved step object with updated state.
13055
13299
  *
13056
- * @param sourceDomainId - The CCTP domain ID of the source chain
13057
- * @param transactionHash - The transaction hash of the burn operation
13058
- * @param isTestnet - Whether this is for a testnet chain (true) or mainnet (false)
13059
- * @param config - Optional configuration overrides for polling behavior
13060
- * @returns The attestation message with confirmed forwardTxHash
13061
- * @throws {KitError} With code 'NETWORK_RELAYER_FORWARD_FAILED' if relayer failed
13062
- * @throws {KitError} If timeout is reached while still pending
13300
+ * @throws KitError when fetching attestation but burn transaction hash is not found.
13063
13301
  *
13064
13302
  * @example
13065
13303
  * ```typescript
13066
- * const attestation = await fetchRelayerMint(0, '0xabc...', false)
13067
- * console.log('Relayer mint tx:', attestation.forwardTxHash)
13304
+ * import { waitForStepToComplete } from './bridgeStepUtils'
13305
+ *
13306
+ * const pendingStep = { name: 'burn', state: 'pending', txHash: '0x123...' }
13307
+ * const updatedStep = await waitForStepToComplete(
13308
+ * pendingStep,
13309
+ * adapter,
13310
+ * chain,
13311
+ * context,
13312
+ * result,
13313
+ * provider,
13314
+ * )
13315
+ * // updatedStep.state is now 'success' or 'error'
13068
13316
  * ```
13069
- */ const fetchRelayerMint = async (sourceDomainId, transactionHash, isTestnet, config = {})=>{
13070
- const url = buildIrisUrl(sourceDomainId, transactionHash, isTestnet);
13071
- const effectiveConfig = mergeAttestationConfig(config);
13072
- let response;
13073
- try {
13074
- response = await pollApiGet(url, isRelayerMintConfirmed, effectiveConfig);
13075
- } catch (error) {
13076
- // Enrich RELAYER_FORWARD_FAILED errors with the burn transaction hash
13077
- if (error instanceof KitError && error.name === 'NETWORK_RELAYER_FORWARD_FAILED') {
13317
+ */ async function waitForStepToComplete(pendingStep, adapter, chain, context, result, provider) {
13318
+ if (pendingStep.name === CCTPv2StepName.fetchAttestation) {
13319
+ // For attestation, re-run the fetch (it has built-in polling)
13320
+ const burnTxHash = getBurnTxHash(result);
13321
+ if (!burnTxHash) {
13078
13322
  throw new KitError({
13079
- ...NetworkError.RELAYER_FORWARD_FAILED,
13080
- recoverability: error.recoverability,
13081
- message: error.message,
13082
- cause: {
13083
- ...error.cause,
13084
- trace: {
13085
- ...error.cause?.trace,
13086
- burnTxHash: transactionHash
13087
- }
13088
- }
13323
+ ...InputError.VALIDATION_FAILED,
13324
+ recoverability: 'FATAL',
13325
+ message: 'Cannot fetch attestation: burn transaction hash not found'
13089
13326
  });
13090
13327
  }
13091
- throw error;
13092
- }
13093
- // Return the first message (which should have forwardTxHash)
13094
- // Note: This check is needed for TypeScript type safety even though
13095
- // isRelayerMintConfirmed validates messages[0] exists. The type guard
13096
- // narrows the type at the call site, but TypeScript can't infer that
13097
- // the array still has elements after pollApiGet returns.
13098
- const message = response.messages[0];
13099
- if (!message) {
13100
- throw new KitError({
13101
- ...InputError.VALIDATION_FAILED,
13102
- recoverability: 'FATAL',
13103
- message: 'No attestation messages found in response after polling.'
13104
- });
13328
+ const sourceAddress = result.source.address;
13329
+ const attestation = await provider.fetchAttestation({
13330
+ chain: result.source.chain,
13331
+ adapter: context.from,
13332
+ address: sourceAddress
13333
+ }, burnTxHash);
13334
+ return {
13335
+ ...pendingStep,
13336
+ state: 'success',
13337
+ data: attestation
13338
+ };
13105
13339
  }
13106
- return message;
13107
- };
13340
+ // For transaction steps, wait for the transaction to complete
13341
+ return waitForPendingTransaction(pendingStep, adapter, chain);
13342
+ }
13108
13343
 
13109
- const assertCCTPv2WalletContextSymbol = Symbol('assertCCTPv2WalletContext');
13110
13344
  /**
13111
- * Asserts that the provided parameters match the CCTPv2 wallet context interface.
13112
- * The validation includes:
13113
- * - Basic wallet context validation (adapter, address, chain)
13114
- * - CCTPv2-specific chain validation (must be an EVM chain)
13345
+ * Multiplier applied to a successful gas estimate before it is submitted.
13115
13346
  *
13116
- * @param params - The parameters to validate
13117
- * @throws {KitError} If validation fails with INPUT_VALIDATION_FAILED code (1098), with details about which properties failed
13347
+ * Estimates are exact, not padded: Sei returns 109_739 for an approve that
13348
+ * consumes 107_717 (1.9% headroom). Chains that price storage in large steps
13349
+ * can exceed the estimate if state changes between estimation and inclusion,
13350
+ * so the estimate is padded before use.
13118
13351
  *
13119
- * @example
13120
- * ```typescript
13121
- * import { assertCCTPv2WalletContext } from '@circle-fin/provider-cctp-v2'
13122
- * import { Ethereum } from '@core/chains'
13352
+ * @remarks
13353
+ * This buffer alone does NOT cover Sei's ~51_500 per-new-slot step at approve
13354
+ * scale (25% of ~110_000 is only ~27_500). For approve, the FLOOR is what
13355
+ * covers a slot that exists at estimation time and is consumed before
13356
+ * inclusion — so do not lower `APPROVE_GAS_LIMIT_EVM` on the reasoning that
13357
+ * the estimate covers it. For burn the buffer does cover a step (25% of
13358
+ * ~300_000 exceeds 51_500).
13359
+ */ const GAS_ESTIMATE_BUFFER_PERCENT = 125n;
13360
+ /**
13361
+ * Resolve the gas limit for an EVM request as `max(estimate * buffer, floor)`.
13123
13362
  *
13124
- * // Prepare wallet context
13125
- * const context = {
13126
- * adapter: {
13127
- * prepare: async () => ({ data: 'prepared transaction' }),
13128
- * waitForTransaction: async () => ({ status: 'confirmed' })
13129
- * },
13130
- * address: '0x1234567890123456789012345678901234567890',
13131
- * chain: {
13132
- * ...Ethereum,
13133
- * usdcAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
13134
- * cctp: {
13135
- * domain: 1,
13136
- * contracts: {
13137
- * v2: {
13138
- * tokenMessenger: '0xTokenMessenger',
13139
- * messageTransmitter: '0xMessageTransmitter'
13140
- * }
13141
- * }
13142
- * }
13143
- * }
13144
- * }
13363
+ * Estimates first so chains whose real cost exceeds the floor are covered by
13364
+ * their own measurement, and falls back to the floor whenever estimation is
13365
+ * unavailable or under-reports. Estimation failure is never fatal here: before
13366
+ * floors existed these requests were submitted with a pinned limit and no
13367
+ * estimate at all, so degrading to the floor is never worse than the previous
13368
+ * behaviour.
13145
13369
  *
13146
- * // This will throw if validation fails
13147
- * assertCCTPv2WalletContext(context)
13370
+ * @param request - The prepared EVM request to size a gas limit for
13371
+ * @param gasFloor - The minimum gas limit to submit, in gas units
13372
+ * @returns The gas limit to submit, in gas units
13373
+ * @throws Never — estimation failures degrade to `gasFloor`
13148
13374
  *
13149
- * // If we get here, context is guaranteed to be valid
13150
- * console.log('CCTPv2 wallet context is valid')
13375
+ * @example
13376
+ * ```typescript
13377
+ * const gasLimit = await resolveGasLimit(request, 150_000)
13151
13378
  * ```
13152
- */ function assertCCTPv2WalletContext(params) {
13153
- // First validate basic wallet context
13154
- validateWithStateTracking(params, walletContextSchema, 'CCTPv2 wallet context', assertCCTPv2WalletContextSymbol);
13155
- // After validation, we know params is WalletContext
13156
- const context = params;
13157
- // Validate USDC support
13158
- if (context.chain.usdcAddress === null) {
13159
- throw createInvalidChainError(context.chain.name, 'Does not have USDC configured');
13160
- }
13161
- // Validate CCTPv2 support
13162
- if (!isCCTPV2Supported(context.chain)) {
13163
- throw createInvalidChainError(context.chain.name, 'Does not support CCTPv2');
13164
- }
13165
- }
13166
-
13167
- const assertCCTPv2BridgeParamsSymbol = Symbol('assertCCTPv2BridgeParams');
13168
- /**
13169
- * Asserts that the provided parameters match the CCTPv2 bridge parameters interface.
13170
- * The validation includes:
13171
- * - Basic parameter structure and types
13172
- * - Amount validation (non-empty numeric string \> 0)
13173
- * - Wallet address format validation (must be valid Ethereum address)
13174
- * - Chain definition validation (must be a valid chain with required properties)
13175
- * - Adapter validation (must implement required methods)
13176
- * - Optional config validation (transfer speed and max fee)
13177
- * - Network compatibility (source and destination chains must both be testnet or both mainnet)
13178
- * - CCTPv2-specific wallet context validations
13379
+ */ const resolveGasLimit = async (request, gasFloor)=>{
13380
+ try {
13381
+ // Deliberately called without a `fallback`: both the viem and ethers
13382
+ // adapters *return* the supplied fallback object when estimation reverts
13383
+ // rather than throwing, which would set the estimate to the floor and then
13384
+ // multiply it by the buffer below. Omitting it routes reverts through the
13385
+ // catch, so a failed estimate degrades to exactly the floor.
13386
+ const estimate = await request.estimate();
13387
+ // The arithmetic stays inside the try on purpose. `EstimatedGas.gas` is
13388
+ // typed `bigint`, but adapters are a public extension point and may be
13389
+ // implemented in plain JS, so a non-bigint `gas` would throw here
13390
+ // ("Cannot mix BigInt and other types"). Guarding it keeps the documented
13391
+ // contract — estimation never aborts a step, it degrades to the floor.
13392
+ const buffered = estimate.gas * GAS_ESTIMATE_BUFFER_PERCENT / 100n;
13393
+ // Convert before comparing: Math.max throws on BigInt operands, and gas
13394
+ // units are far below Number.MAX_SAFE_INTEGER so the narrowing is lossless.
13395
+ return Math.max(Number(buffered), gasFloor);
13396
+ } catch {
13397
+ // Estimation is best-effort; the floor is the known-safe value.
13398
+ return gasFloor;
13399
+ }
13400
+ };
13401
+ /**
13402
+ * Executes a prepared chain request and returns the result as a bridge step.
13179
13403
  *
13180
- * @param params - The parameters to validate
13181
- * @throws {KitError} If validation fails, with details about which properties failed
13404
+ * This function takes a prepared chain request (containing transaction data) and executes
13405
+ * it using the appropriate adapter. It handles the execution details and formats
13406
+ * the result as a standardized bridge step with transaction details and explorer URLs.
13407
+ *
13408
+ * @param params - The execution parameters containing:
13409
+ * - `name`: The name of the step
13410
+ * - `request`: The prepared chain request containing transaction data
13411
+ * - `adapter`: The adapter that will execute the transaction
13412
+ * - `confirmations`: The number of confirmations to wait for (defaults to 1)
13413
+ * - `timeout`: The timeout for the request in milliseconds
13414
+ * - `gasFloor`: Optional minimum gas limit (number); the request is submitted
13415
+ * with `max(estimate * 1.25, gasFloor)`. Ignored for non-EVM requests
13416
+ * @returns The bridge step with the transaction details and explorer URL
13417
+ * @throws If the transaction execution fails
13182
13418
  *
13183
13419
  * @example
13184
13420
  * ```typescript
13185
- * import { assertCCTPv2BridgeParams } from '@circle-fin/provider-cctp-v2'
13186
- * import { Ethereum, Base } from '@core/chains'
13187
- *
13188
- * // Prepare transfer parameters
13189
- * const params = {
13190
- * amount: '100.50',
13191
- * source: {
13192
- * adapter: sourceAdapter,
13193
- * address: '0xSourceAddress',
13194
- * chain: {
13195
- * ...Ethereum,
13196
- * cctp: {
13197
- * domain: 1,
13198
- * contracts: {
13199
- * v2: {
13200
- * tokenMessenger: '0xTokenMessenger',
13201
- * messageTransmitter: '0xMessageTransmitter'
13202
- * }
13203
- * }
13204
- * }
13205
- * }
13206
- * },
13207
- * destination: {
13208
- * adapter: destAdapter,
13209
- * address: '0xDestAddress',
13210
- * chain: {
13211
- * ...Base,
13212
- * cctp: {
13213
- * domain: 2,
13214
- * contracts: {
13215
- * v2: {
13216
- * tokenMessenger: '0xTokenMessenger',
13217
- * messageTransmitter: '0xMessageTransmitter'
13218
- * }
13219
- * }
13220
- * }
13221
- * }
13222
- * },
13223
- * token: 'USDC',
13224
- * config: {
13225
- * transferSpeed: 'FAST',
13226
- * maxFee: '1000000'
13227
- * }
13228
- * }
13229
- *
13230
- * // This will throw if validation fails
13231
- * assertCCTPv2BridgeParams(params)
13232
- *
13233
- * // If we get here, params is guaranteed to be valid
13234
- * console.log('CCTPv2 transfer parameters are valid')
13421
+ * const step = await executePreparedChainRequest({
13422
+ * name: 'approve',
13423
+ * request: preparedRequest,
13424
+ * adapter: adapter,
13425
+ * confirmations: 2,
13426
+ * timeout: 30000
13427
+ * })
13428
+ * console.log('Transaction hash:', step.txHash)
13235
13429
  * ```
13236
- */ function assertCCTPv2BridgeParams(params) {
13237
- // First validate basic bridge params
13238
- validateWithStateTracking(params, bridgeParamsSchema, 'CCTPv2 bridge parameters', assertCCTPv2BridgeParamsSymbol);
13239
- // After validation, we know params is CCTPV2BridgeParams
13240
- const bridgeParams = params;
13241
- // Enforce that source and destination chains are either testnet or mainnet
13242
- if (bridgeParams.source.chain.isTestnet !== bridgeParams.destination.chain.isTestnet) {
13243
- throw createNetworkMismatchError(bridgeParams.source.chain, bridgeParams.destination.chain);
13244
- }
13245
- assertCCTPV2Support(bridgeParams.source.chain, bridgeParams.destination.chain);
13246
- // Validate that the destination chain supports forwarding when forwarder is enabled
13247
- assertForwarderRouteSupport(bridgeParams.source.chain, bridgeParams.destination.chain, bridgeParams.destination.useForwarder);
13248
- /**
13249
- * Enforce that if fee is defined then feeRecipient must be defined.
13250
- * We do not do this in the validation function itself because we want to allow
13251
- * optional properties when calling `provider.bridge()` due to the custom fee
13252
- * configuration being possible at the kit level as well.
13253
- */ if (bridgeParams.config?.customFee?.value !== undefined && bridgeParams.config?.customFee?.recipientAddress === undefined) {
13254
- throw createValidationFailedError$1('recipientAddress', bridgeParams.config.customFee.value, 'Custom fee is defined but fee recipient is not. Please provide a fee recipient.');
13255
- }
13256
- // Check if this is a forwarder-only destination (no adapter, requires useForwarder: true)
13257
- const isForwarderOnly = bridgeParams.destination.useForwarder === true && !('adapter' in bridgeParams.destination && bridgeParams.destination.adapter);
13258
- // Forwarder-only destinations require recipientAddress
13259
- if (isForwarderOnly) {
13260
- if (!bridgeParams.destination.recipientAddress?.trim()) {
13261
- throw createValidationFailedError$1('recipientAddress', bridgeParams.destination.recipientAddress, 'recipientAddress is required when using forwarder without a destination adapter.');
13430
+ */ async function executePreparedChainRequest({ name, request, adapter, chain, confirmations = 1, timeout, gasFloor }) {
13431
+ const step = {
13432
+ name,
13433
+ state: 'pending'
13434
+ };
13435
+ try {
13436
+ /**
13437
+ * No-op requests are not executed.
13438
+ * We return a noop step instead.
13439
+ */ if (request.type === 'noop') {
13440
+ step.state = 'noop';
13441
+ return step;
13442
+ }
13443
+ const txHash = request.type === 'evm' && gasFloor !== undefined ? await request.execute({
13444
+ gasLimit: await resolveGasLimit(request, gasFloor)
13445
+ }) : await request.execute();
13446
+ step.txHash = txHash;
13447
+ const retryOptions = {
13448
+ isRetryable: (err)=>isRetryableError$1(parseBlockchainError(err, {
13449
+ chain: chain.name,
13450
+ txHash
13451
+ }))
13452
+ };
13453
+ if (timeout !== undefined) {
13454
+ retryOptions.deadlineMs = Date.now() + timeout;
13455
+ }
13456
+ const transaction = await retryAsync(async ()=>adapter.waitForTransaction(txHash, {
13457
+ confirmations,
13458
+ timeout
13459
+ }, chain), retryOptions);
13460
+ const outcome = evaluateTransactionOutcome(transaction, txHash);
13461
+ step.state = outcome.state;
13462
+ step.data = transaction;
13463
+ // Generate explorer URL for the step
13464
+ step.explorerUrl = buildExplorerUrl(chain, txHash);
13465
+ if (outcome.errorMessage) {
13466
+ step.errorMessage = outcome.errorMessage;
13467
+ // Transaction was mined but reverted on-chain.
13468
+ step.errorCategory = 'chain_revert';
13469
+ }
13470
+ } catch (err) {
13471
+ step.state = 'error';
13472
+ step.error = err;
13473
+ // Sequential path does not yet attempt fine-grained classification of
13474
+ // pre-submission errors (user_rejected, capability errors, etc.). Mark
13475
+ // as `unknown` so consumers can at least detect the category is
13476
+ // populated uniformly across batched and sequential flows.
13477
+ step.errorCategory = 'unknown';
13478
+ // Optionally parse for common blockchain error formats
13479
+ if (err instanceof Error) {
13480
+ step.errorMessage = err.message;
13481
+ } else if (typeof err === 'object' && err != null && 'message' in err) {
13482
+ step.errorMessage = String(err.message);
13483
+ } else {
13484
+ step.errorMessage = `Unknown error occurred during ${name} step.`;
13262
13485
  }
13263
13486
  }
13264
- // Validate CCTP v2 specific requirements for source wallet
13265
- assertCCTPv2WalletContext(bridgeParams.source);
13266
- // Validate that source adapter supports the chain (defense-in-depth)
13267
- bridgeParams.source.adapter.validateChainSupport(bridgeParams.source.chain);
13268
- // Only validate destination wallet context and adapter if not forwarder-only
13269
- if (!isForwarderOnly) {
13270
- assertCCTPv2WalletContext(bridgeParams.destination);
13271
- // Validate that destination adapter supports the chain (defense-in-depth)
13272
- bridgeParams.destination.adapter.validateChainSupport(bridgeParams.destination.chain);
13273
- }
13487
+ return step;
13274
13488
  }
13489
+
13275
13490
  /**
13276
- * Validate CCTP v2 support on both chains
13277
- */ /**
13278
- * Throws a KitError if the given chain does not support CCTP v2.
13279
- *
13280
- * @param chain - The chain to check for CCTP v2 support
13281
- * @param otherChain - The other chain in the route (for error context)
13282
- * @param isSource - Whether this is the source chain (for error context)
13283
- */ function assertCCTPV2Support(source, destination) {
13284
- if (!isCCTPV2Supported(source) || !isCCTPV2Supported(destination)) {
13285
- throw createUnsupportedRouteError(source.name, destination.name);
13491
+ * Default configuration values for the attestation fetcher.
13492
+ * @internal
13493
+ */ const DEFAULT_CONFIG = {
13494
+ timeout: 2_000,
13495
+ maxRetries: 30 * 20,
13496
+ retryDelay: 2_000,
13497
+ headers: {
13498
+ 'Content-Type': 'application/json'
13286
13499
  }
13287
- }
13500
+ };
13288
13501
  /**
13289
- * Validates that the forwarder (relaying) feature is compatible with the route.
13290
- *
13291
- * Checks the destination chain's `cctp.forwarderSupported.destination` property
13292
- * to determine whether the chain supports receiving forwarded transfers.
13502
+ * Merges caller-provided polling overrides on top of {@link DEFAULT_CONFIG}.
13293
13503
  *
13294
- * @param source - The source chain definition
13295
- * @param destination - The destination chain definition
13296
- * @param useForwarder - Whether the forwarder is enabled on the destination
13297
- * @throws {KitError} If the forwarder is enabled and the destination chain does not support forwarding
13298
- */ function assertForwarderRouteSupport(source, destination, useForwarder) {
13299
- if (useForwarder === true && !destination.cctp?.forwarderSupported.destination) {
13300
- throw new KitError({
13301
- ...InputError.UNSUPPORTED_ROUTE,
13302
- recoverability: 'FATAL',
13303
- message: `Route from ${source.name} to ${destination.name} with forwarder is not supported (destination chain does not support forwarding).`,
13304
- cause: {
13305
- trace: {
13306
- source: source.name,
13307
- destination: destination.name
13308
- }
13309
- }
13310
- });
13311
- }
13312
- }
13313
-
13314
- /**
13315
- * Checks if a decoded attestation field matches the corresponding transfer parameter.
13316
- * If the values do not match, appends a descriptive error message to the errors array.
13504
+ * Headers are merged independently so caller-supplied headers augment the
13505
+ * defaults (such as `Content-Type`) rather than replacing them wholesale.
13317
13506
  *
13318
- * @param field - The name of the field being compared (for error reporting)
13319
- * @param decoded - The value decoded from the attestation message
13320
- * @param param - The expected value from the transfer parameters
13321
- * @param errors - The array to which error messages will be appended if a mismatch is found
13322
- */ function checkFieldMismatch(field, decoded, param, errors) {
13323
- if (decoded !== param) {
13324
- errors.push(`${field} mismatch: decoded=${String(decoded)}, params=${String(param)}`);
13325
- }
13326
- }
13507
+ * @param config - Caller-provided polling configuration overrides
13508
+ * @param internalDefaults - Internal defaults applied before `config` (for example a
13509
+ * reduced `maxRetries` for one-shot requests); `config` still wins on conflict
13510
+ * @returns The effective polling configuration
13511
+ * @internal
13512
+ */ const mergeAttestationConfig = (config, internalDefaults = {})=>({
13513
+ ...DEFAULT_CONFIG,
13514
+ ...internalDefaults,
13515
+ ...config,
13516
+ headers: {
13517
+ ...DEFAULT_CONFIG.headers,
13518
+ ...internalDefaults.headers,
13519
+ ...config.headers
13520
+ }
13521
+ });
13327
13522
  /**
13328
- * Asserts that the decoded message from attestation matches the provided transfer params.
13329
- * Throws KitError if any field mismatches, with clear error messages.
13523
+ * Type guard that verifies if an unknown value matches the AttestationMessage shape
13524
+ * and has all required properties.
13330
13525
  *
13331
- * @param attestation - The attestation message containing the decoded message
13332
- * @param params - The transfer parameters to validate against
13333
- * @throws {@link KitError} If any field mismatches
13334
- */ async function assertCCTPv2AttestationParams(attestation, params) {
13335
- const errors = [];
13336
- const message = attestation.decodedMessage;
13337
- const messageBody = message.decodedMessageBody;
13338
- // Use recipientAddress if provided, otherwise use destination.address
13339
- const destinationAddressForMint = params.destination.recipientAddress ?? params.destination.address;
13340
- const mintRecipient = await getMintRecipientAccount(params.destination.chain.type, destinationAddressForMint, params.destination.chain.usdcAddress);
13341
- let sender;
13342
- if (hasCustomContractSupport(params.source.chain, 'bridge')) {
13343
- if (params.source.chain.type === 'solana') {
13344
- // Solana: User → Bridge contract → CCTP (user remains sender)
13345
- sender = params.source.address;
13346
- } else {
13347
- // Other chains (like EVM): Bridge contract CCTP (bridge contract becomes sender)
13348
- sender = params.source.chain.kitContracts?.bridge;
13349
- }
13350
- } else {
13351
- sender = params.source.address;
13352
- }
13353
- checkFieldMismatch('sourceDomain', message.sourceDomain, params.source.chain.cctp.domain.toString(), errors);
13354
- checkFieldMismatch('destinationDomain', message.destinationDomain, params.destination.chain.cctp.domain.toString(), errors);
13355
- checkFieldMismatch('minFinalityThreshold', message.minFinalityThreshold, CCTPv2MinFinalityThreshold[params.config.transferSpeed ?? 'FAST'].toString(), errors);
13356
- checkFieldMismatch('sender', params.source.chain.type === 'evm' ? messageBody.messageSender.toLowerCase() : messageBody.messageSender, params.source.chain.type === 'evm' ? sender?.toLowerCase() : sender, errors);
13357
- checkFieldMismatch('recipient', params.destination.chain.type === 'evm' ? messageBody.mintRecipient.toLowerCase() : messageBody.mintRecipient, params.destination.chain.type === 'evm' ? mintRecipient.toLowerCase() : mintRecipient, errors);
13358
- checkFieldMismatch('amount', messageBody.amount, params.amount.toString(), errors);
13359
- checkFieldMismatch('burnToken', messageBody.burnToken.toLowerCase(), params.source.chain.usdcAddress.toLowerCase(), errors);
13360
- if (errors.length > 0) {
13361
- const errorMessage = 'Attestation validation failed: received attestation does not match expected transfer parameters';
13362
- const firstError = errors[0] ?? '';
13363
- throw new KitError({
13364
- ...InputError.VALIDATION_FAILED,
13365
- recoverability: 'FATAL',
13366
- message: `${errorMessage}: ${firstError}`,
13367
- cause: {
13368
- trace: {
13369
- validationErrors: errors
13370
- }
13371
- }
13372
- });
13526
+ * @param obj - The value to check, typically an element from the messages array
13527
+ * @returns True if the object matches the AttestationMessage shape, false otherwise
13528
+ * @internal
13529
+ */ const isValidAttestationMessage = (obj)=>{
13530
+ return typeof obj === 'object' && obj !== null && 'message' in obj && 'eventNonce' in obj && 'attestation' in obj && 'decodedMessage' in obj && 'cctpVersion' in obj && 'status' in obj && typeof obj.status === 'string';
13531
+ };
13532
+ /**
13533
+ * Type guard that verifies if an attestation message is complete.
13534
+ *
13535
+ * @param message - The attestation message to check
13536
+ * @returns True if the message status is 'complete', false otherwise
13537
+ * @internal
13538
+ */ const isCompleteAttestation = (message)=>{
13539
+ return message.status === 'complete';
13540
+ };
13541
+ /**
13542
+ * Type guard that verifies if an unknown value has the correct structure
13543
+ * for an AttestationResponse, regardless of attestation completion status.
13544
+ *
13545
+ * @param obj - The value to check, typically a parsed JSON response
13546
+ * @returns True if the object matches the AttestationResponse shape
13547
+ * @internal
13548
+ */ const hasValidAttestationStructure = (obj)=>{
13549
+ if (typeof obj !== 'object' || obj === null || !('messages' in obj) || !Array.isArray(obj.messages)) {
13550
+ return false;
13373
13551
  }
13374
- }
13375
-
13552
+ const messages = obj.messages;
13553
+ // Validate all messages have the correct shape
13554
+ return messages.every(isValidAttestationMessage);
13555
+ };
13376
13556
  /**
13377
- * Assert that `params` is a well-formed {@link BurnWithFeesParams} object.
13557
+ * Type guard that verifies if an unknown value matches the AttestationResponse shape
13558
+ * and contains a complete attestation.
13378
13559
  *
13379
- * Validates the full public-boundary input before any field destructuring,
13380
- * `BigInt()` coercion, or adapter preparation runs, so malformed JS-caller
13381
- * inputs always produce typed `KitError` validation failures.
13560
+ * This function performs runtime validation to ensure that the provided value
13561
+ * conforms to the expected structure of an AttestationResponse and has at least
13562
+ * one complete attestation. It checks that:
13563
+ * 1. The value has valid AttestationResponse structure
13564
+ * 2. At least one message has status 'complete'
13382
13565
  *
13383
- * Checks performed (in order):
13384
- * - `params` must be a non-null plain object
13385
- * - `source` valid CCTP v2 wallet context (via `assertCCTPv2WalletContext`)
13386
- * - `destinationChain` present and supports CCTP v2
13387
- * - source and destination chains must both be testnet or both mainnet
13388
- * - source and destination chains must differ
13389
- * - `executor` — non-empty string
13390
- * - `amount` — bigint or non-empty string coercible to bigint
13391
- * - `feeTotalAmount` — bigint or non-empty string coercible to bigint
13392
- * - `feeToken` — valid EVM address (`0x` + 40 hex chars)
13393
- * - `claim.signedQuote` — valid `0x`-prefixed hex string
13394
- * - `claim.refundAddress` — valid EVM address
13395
- * - `hookData` — valid `0x`-prefixed hex string when present
13566
+ * @remarks
13567
+ * This type guard is used internally by the attestation fetcher to validate
13568
+ * responses from the IRIS API before processing them. It provides runtime
13569
+ * type safety for data coming from the network and ensures we have a complete
13570
+ * attestation before proceeding.
13396
13571
  *
13397
- * @param params - The value to validate.
13398
- * @throws {KitError} If any field is missing or invalid.
13572
+ * If the response has valid structure but no complete attestation yet,
13573
+ * it throws a retryable error. If the response structure is invalid,
13574
+ * it throws a non-retryable validation error.
13575
+ *
13576
+ * @param obj - The value to check, typically a parsed JSON response
13577
+ * @returns True if the object matches the AttestationResponse shape and has a complete attestation
13578
+ * @throws {Error} With "Invalid attestation response structure" if structure is invalid (non-retryable)
13579
+ * @throws {Error} With "Attestation not ready" if no complete attestation yet (retryable)
13399
13580
  *
13400
13581
  * @example
13401
13582
  * ```typescript
13402
- * assertBurnWithFeesParams(params)
13403
- * // params is now typed as BurnWithFeesParams and safe to use
13404
- * const { source, destinationChain, amount } = params
13583
+ * const response = await fetch('https://iris-api.circle.com/...')
13584
+ * const data = await response.json()
13585
+ *
13586
+ * if (isAttestationResponse(data)) {
13587
+ * // TypeScript now knows data is AttestationResponse with at least one complete attestation
13588
+ * const completeMessage = data.messages.find(msg => msg.status === 'complete')
13589
+ * console.log('Found complete attestation:', completeMessage.attestation)
13590
+ * }
13405
13591
  * ```
13406
- */ function assertBurnWithFeesParams(params) {
13407
- if (params === null || typeof params !== 'object' || Array.isArray(params)) {
13408
- throw createValidationFailedError$1('params', params, 'Must be a non-null plain object');
13409
- }
13410
- const p = params;
13411
- // Source wallet context
13412
- assertCCTPv2WalletContext(p['source']);
13413
- const source = p['source'];
13414
- // destinationChain
13415
- const destinationChain = p['destinationChain'];
13416
- if (destinationChain === null || destinationChain === undefined || typeof destinationChain !== 'object') {
13417
- throw createValidationFailedError$1('destinationChain', destinationChain, 'Must be a chain definition object');
13418
- }
13419
- if (!isCCTPV2Supported(destinationChain)) {
13420
- throw createValidationFailedError$1('destinationChain', destinationChain, 'destinationChain must support CCTP v2');
13421
- }
13422
- const dest = destinationChain;
13423
- // Testnet / mainnet mismatch
13424
- if (source.chain.isTestnet !== dest.isTestnet) {
13425
- throw createNetworkMismatchError(source.chain, dest);
13426
- }
13427
- // Same-chain guard
13428
- if (source.chain.name === dest.name) {
13429
- throw createUnsupportedRouteError(source.chain.name, dest.name);
13430
- }
13431
- // executor
13432
- const executor = p['executor'];
13433
- if (typeof executor !== 'string' || executor === '') {
13434
- throw createValidationFailedError$1('executor', executor, 'A GenericExecutor address is required (used for both mintRecipient and destinationCaller)');
13435
- }
13436
- // amount
13437
- const rawAmount = p['amount'];
13438
- if (typeof rawAmount !== 'bigint' && typeof rawAmount !== 'string' || rawAmount === '') {
13439
- throw createValidationFailedError$1('amount', rawAmount, 'Must be a bigint or a numeric string');
13440
- }
13441
- try {
13442
- BigInt(rawAmount);
13443
- } catch {
13444
- throw createValidationFailedError$1('amount', rawAmount, 'Must be a valid integer value coercible to bigint');
13445
- }
13446
- // feeTotalAmount
13447
- const rawFee = p['feeTotalAmount'];
13448
- if (typeof rawFee !== 'bigint' && typeof rawFee !== 'string' || rawFee === '') {
13449
- throw createValidationFailedError$1('feeTotalAmount', rawFee, 'Must be a bigint or a numeric string');
13450
- }
13451
- try {
13452
- BigInt(rawFee);
13453
- } catch {
13454
- throw createValidationFailedError$1('feeTotalAmount', rawFee, 'Must be a valid integer value coercible to bigint');
13455
- }
13456
- // feeToken
13457
- if (!evmAddressSchema.safeParse(p['feeToken']).success) {
13458
- throw createValidationFailedError$1('feeToken', p['feeToken'], 'Must be a valid EVM address (use the zero address for a native fee)');
13459
- }
13460
- // claim
13461
- const rawClaim = p['claim'];
13462
- if (rawClaim === null || typeof rawClaim !== 'object' || Array.isArray(rawClaim)) {
13463
- throw createValidationFailedError$1('claim', rawClaim, 'Must be an object with signedQuote and refundAddress');
13464
- }
13465
- const claim = rawClaim;
13466
- if (!hexStringSchema.safeParse(claim['signedQuote']).success) {
13467
- throw createValidationFailedError$1('claim.signedQuote', claim['signedQuote'], 'Must be a valid hex string starting with 0x');
13468
- }
13469
- if (!evmAddressSchema.safeParse(claim['refundAddress']).success) {
13470
- throw createValidationFailedError$1('claim.refundAddress', claim['refundAddress'], 'Must be a valid EVM address');
13592
+ */ const isAttestationResponse = (obj)=>{
13593
+ // First check if the structure is valid
13594
+ if (!hasValidAttestationStructure(obj)) {
13595
+ // If structure is invalid, this is a permanent failure - don't retry
13596
+ throw new Error('Invalid attestation response structure');
13471
13597
  }
13472
- // hookData (optional)
13473
- const hookData = p['hookData'];
13474
- if (hookData !== undefined && hookData !== '' && !hexStringSchema.safeParse(hookData).success) {
13475
- throw createValidationFailedError$1('hookData', hookData, 'Must be a valid hex string starting with 0x');
13598
+ // Then check if at least one message is complete
13599
+ if (!obj.messages.some(isCompleteAttestation)) {
13600
+ // If no complete message, this is a temporary state - allow retry
13601
+ throw new Error('Attestation not ready');
13476
13602
  }
13477
- }
13478
-
13603
+ return true;
13604
+ };
13479
13605
  /**
13480
- * CCTP bridge step names that can occur in the bridging flow.
13606
+ * Builds the IRIS API URL for fetching attestation data from Circle's CCTP service.
13481
13607
  *
13482
- * This object provides type safety for step names and represents all possible
13483
- * steps that can be executed during a CCTP bridge operation. Using const assertions
13484
- * makes this tree-shakable and follows modern TypeScript best practices.
13485
- */ const CCTPv2StepName = {
13486
- approve: 'approve',
13487
- burn: 'burn',
13488
- fetchAttestation: 'fetchAttestation',
13489
- mint: 'mint',
13490
- reAttest: 'reAttest'
13608
+ * Constructs a properly formatted URL for the IRIS API v2 endpoint that provides
13609
+ * attestation messages for cross-chain transfers. The URL includes both the source
13610
+ * domain identifier and the transaction hash as query parameters. The base URL
13611
+ * is selected based on whether the operation is for testnet or mainnet.
13612
+ *
13613
+ * @param sourceDomainId - The CCTP domain ID of the source chain (numeric or string)
13614
+ * @param transactionHash - The transaction hash of the burn operation to fetch attestation for
13615
+ * @param isTestnet - Whether this is for a testnet chain (true) or mainnet chain (false)
13616
+ * @returns A fully qualified URL string for the IRIS API endpoint
13617
+ *
13618
+ * @example
13619
+ * ```typescript
13620
+ * // Mainnet URL
13621
+ * const mainnetUrl = buildIrisUrl(1, '0xabc...', false)
13622
+ * // => 'https://iris-api.circle.com/v2/messages/1?transactionHash=0xabc...'
13623
+ *
13624
+ * // Testnet URL
13625
+ * const testnetUrl = buildIrisUrl(1, '0xdef...', true)
13626
+ * // => 'https://iris-api-sandbox.circle.com/v2/messages/1?transactionHash=0xdef...'
13627
+ * ```
13628
+ */ const buildIrisUrl = (sourceDomainId, transactionHash, isTestnet)=>{
13629
+ const baseUrl = isTestnet ? IRIS_API_SANDBOX_BASE_URL : IRIS_API_BASE_URL;
13630
+ const url = new URL(`${baseUrl}/v2/messages/${String(sourceDomainId)}`);
13631
+ url.searchParams.set('transactionHash', transactionHash);
13632
+ return url.toString();
13491
13633
  };
13492
13634
  /**
13493
- * Conditional step transition rules for CCTP bridge flow.
13635
+ * Fetches attestation data from the IRIS API with retry and timeout handling.
13494
13636
  *
13495
- * Rules are evaluated in order - the first matching condition determines the next step.
13496
- * This approach supports flexible flow logic and makes it easy to extend with new patterns.
13497
- */ const STEP_TRANSITION_RULES = {
13498
- // Starting state - no steps executed yet
13499
- '': [
13500
- {
13501
- condition: ()=>true,
13502
- nextStep: CCTPv2StepName.approve,
13503
- reason: 'Start with approval step',
13504
- isActionable: true
13505
- }
13506
- ],
13507
- // After Approve step
13508
- [CCTPv2StepName.approve]: [
13509
- {
13510
- condition: (ctx)=>ctx.lastStep?.state === 'success',
13511
- nextStep: CCTPv2StepName.burn,
13512
- reason: 'Approval successful, proceed to burn',
13513
- isActionable: true
13514
- },
13515
- {
13516
- condition: (ctx)=>ctx.lastStep?.state === 'error',
13517
- nextStep: CCTPv2StepName.approve,
13518
- reason: 'Retry failed approval',
13519
- isActionable: true
13520
- },
13521
- {
13522
- condition: (ctx)=>ctx.lastStep?.state === 'noop',
13523
- nextStep: CCTPv2StepName.burn,
13524
- reason: 'No approval needed, proceed to burn',
13525
- isActionable: true
13526
- },
13527
- {
13528
- condition: (ctx)=>ctx.lastStep?.state === 'pending',
13529
- nextStep: CCTPv2StepName.approve,
13530
- reason: 'Continue pending approval',
13531
- isActionable: false
13532
- }
13533
- ],
13534
- // After Burn step
13535
- [CCTPv2StepName.burn]: [
13536
- {
13537
- condition: (ctx)=>ctx.lastStep?.state === 'success',
13538
- nextStep: CCTPv2StepName.fetchAttestation,
13539
- reason: 'Burn successful, fetch attestation',
13540
- isActionable: true
13541
- },
13542
- {
13543
- condition: (ctx)=>ctx.lastStep?.state === 'error',
13544
- nextStep: CCTPv2StepName.burn,
13545
- reason: 'Retry failed burn',
13546
- isActionable: true
13547
- },
13548
- {
13549
- condition: (ctx)=>ctx.lastStep?.state === 'pending',
13550
- nextStep: CCTPv2StepName.burn,
13551
- reason: 'Continue pending burn',
13552
- isActionable: false
13553
- }
13554
- ],
13555
- // After FetchAttestation step
13556
- [CCTPv2StepName.fetchAttestation]: [
13557
- {
13558
- condition: (ctx)=>ctx.lastStep?.state === 'success',
13559
- nextStep: CCTPv2StepName.mint,
13560
- reason: 'Attestation fetched, proceed to mint',
13561
- isActionable: true
13562
- },
13563
- {
13564
- condition: (ctx)=>ctx.lastStep?.state === 'error',
13565
- nextStep: CCTPv2StepName.fetchAttestation,
13566
- reason: 'Retry fetching attestation',
13567
- isActionable: true
13568
- },
13569
- {
13570
- condition: (ctx)=>ctx.lastStep?.state === 'pending',
13571
- nextStep: CCTPv2StepName.fetchAttestation,
13572
- reason: 'Continue pending attestation fetch',
13573
- isActionable: false
13574
- }
13575
- ],
13576
- // After Mint step
13577
- [CCTPv2StepName.mint]: [
13578
- {
13579
- condition: (ctx)=>ctx.lastStep?.state === 'success',
13580
- nextStep: null,
13581
- reason: 'Bridge completed successfully',
13582
- isActionable: false
13583
- },
13584
- {
13585
- condition: (ctx)=>ctx.lastStep?.state === 'error',
13586
- nextStep: CCTPv2StepName.mint,
13587
- reason: 'Retry failed mint',
13588
- isActionable: true
13589
- },
13590
- {
13591
- condition: (ctx)=>ctx.lastStep?.state === 'pending',
13592
- nextStep: CCTPv2StepName.mint,
13593
- reason: 'Continue pending mint',
13594
- isActionable: false
13595
- }
13596
- ],
13597
- // After ReAttest step
13598
- [CCTPv2StepName.reAttest]: [
13599
- {
13600
- condition: (ctx)=>ctx.lastStep?.state === 'success',
13601
- nextStep: CCTPv2StepName.mint,
13602
- reason: 'Re-attestation successful, proceed to mint',
13603
- isActionable: true
13604
- },
13605
- {
13606
- condition: (ctx)=>ctx.lastStep?.state === 'error',
13607
- nextStep: CCTPv2StepName.mint,
13608
- reason: 'Re-attestation failed, retry mint to re-initiate recovery',
13609
- isActionable: true
13610
- },
13611
- {
13612
- condition: (ctx)=>ctx.lastStep?.state === 'pending',
13613
- nextStep: CCTPv2StepName.mint,
13614
- reason: 'Re-attestation pending, retry mint to re-initiate recovery',
13615
- isActionable: true
13616
- }
13617
- ]
13637
+ * Polls the IRIS API until a complete attestation is available. The default
13638
+ * window is sized for slow source chains where finality may take many
13639
+ * confirmations.
13640
+ *
13641
+ * Defaults (see `DEFAULT_CONFIG`):
13642
+ * - Per-attempt timeout: 2 000 ms (each HTTP request aborts after 2 s)
13643
+ * - Retry delay: 2 000 ms between attempts
13644
+ * - Max retries: 600 (30 × 20)
13645
+ * - Total worst-case polling window: 600 × (2 000 ms + 2 000 ms) ≈ 40 minutes
13646
+ *
13647
+ * @param sourceDomainId - The CCTP domain ID.
13648
+ * @param transactionHash - The transaction hash to fetch attestation for.
13649
+ * @param isTestnet - Whether this is for a testnet chain (true) or mainnet chain (false)
13650
+ * @param config - Optional configuration overrides for the attestation fetcher
13651
+ * @returns The attestation response data.
13652
+ * @throws If the request fails, times out, or returns invalid data.
13653
+ *
13654
+ * @example
13655
+ * ```typescript
13656
+ * // Fetch attestation for mainnet transaction
13657
+ * const response = await fetchAttestation(1, '0xabc...', false)
13658
+ * console.log(`Found ${response.messages.length} attestation messages`)
13659
+ *
13660
+ * // Fetch with custom timeout
13661
+ * const response2 = await fetchAttestation(1, '0xdef...', true, {
13662
+ * timeout: 5000,
13663
+ * maxRetries: 5
13664
+ * })
13665
+ * ```
13666
+ */ const fetchAttestation = async (sourceDomainId, transactionHash, isTestnet, config = {})=>{
13667
+ const url = buildIrisUrl(sourceDomainId, transactionHash, isTestnet);
13668
+ const effectiveConfig = mergeAttestationConfig(config);
13669
+ return await pollApiGet(url, isAttestationResponse, effectiveConfig);
13670
+ };
13671
+ /**
13672
+ * Type guard that validates attestation response structure without requiring completion status.
13673
+ *
13674
+ * This is used by `fetchAttestationWithoutStatusCheck` to extract the nonce from an existing
13675
+ * attestation, even if the attestation is expired or pending. Unlike `isAttestationResponse`,
13676
+ * this function does not throw if no complete attestation is found.
13677
+ *
13678
+ * @param obj - The value to check, typically a parsed JSON response
13679
+ * @returns True if the object has valid attestation structure
13680
+ * @throws {Error} With "Invalid attestation response structure" if structure is invalid
13681
+ * @internal
13682
+ */ const isAttestationResponseWithoutStatusCheck = (obj)=>{
13683
+ if (!hasValidAttestationStructure(obj)) {
13684
+ throw new Error('Invalid attestation response structure');
13685
+ }
13686
+ return true;
13687
+ };
13688
+ /**
13689
+ * Fetches attestation data without requiring the attestation to be complete.
13690
+ *
13691
+ * This function is useful for retrieving attestation data (particularly the nonce)
13692
+ * from an existing transaction, even if the attestation has expired or is pending.
13693
+ * It uses minimal retries since we're fetching existing data, not waiting for completion.
13694
+ *
13695
+ * @param sourceDomainId - The CCTP domain ID of the source chain
13696
+ * @param transactionHash - The transaction hash to fetch attestation for
13697
+ * @param isTestnet - Whether this is for a testnet chain (true) or mainnet chain (false)
13698
+ * @param config - Optional configuration overrides
13699
+ * @returns The attestation response data (may contain incomplete/expired attestations)
13700
+ * @throws If the request fails, times out, or returns invalid data
13701
+ *
13702
+ * @example
13703
+ * ```typescript
13704
+ * // Fetch existing attestation to extract nonce for re-attestation
13705
+ * const response = await fetchAttestationWithoutStatusCheck(1, '0xabc...', true)
13706
+ * const nonce = response.messages[0]?.eventNonce
13707
+ * ```
13708
+ */ const fetchAttestationWithoutStatusCheck = async (sourceDomainId, transactionHash, isTestnet, config = {})=>{
13709
+ const url = buildIrisUrl(sourceDomainId, transactionHash, isTestnet);
13710
+ // Use minimal retries since we're just fetching existing data
13711
+ const effectiveConfig = mergeAttestationConfig(config, {
13712
+ maxRetries: 3
13713
+ });
13714
+ return await pollApiGet(url, isAttestationResponseWithoutStatusCheck, effectiveConfig);
13618
13715
  };
13619
13716
  /**
13620
- * Analyze bridge steps to determine retry feasibility and continuation point.
13621
- *
13622
- * This function examines the current state of bridge steps to determine the optimal
13623
- * continuation strategy. It uses a rule-based approach that makes it easy to extend
13624
- * with new flow patterns and step types in the future.
13625
- *
13626
- * The current analysis supports the standard CCTP flow:
13627
- * **Traditional flow**: Approve → Burn → FetchAttestation → Mint
13717
+ * Type guard that validates attestation response has expirationBlock === '0'.
13628
13718
  *
13629
- * Key features:
13630
- * - Rule-based transitions: Easy to extend with new step types and logic
13631
- * - Context-aware decisions: Considers execution history and step states
13632
- * - Actionable logic: Distinguishes between steps requiring user action vs waiting
13633
- * - Terminal states: Properly handles completion and non-actionable states
13719
+ * This is used after requestReAttestation() to poll until the attestation
13720
+ * is fully re-processed and has a zero expiration block (never expires).
13721
+ * The expiration block transitions from non-zero to zero when Circle
13722
+ * completes processing the re-attestation request.
13634
13723
  *
13635
- * @param bridgeResult - The bridge result containing step execution history.
13636
- * @returns Analysis result with continuation step and actionability information.
13637
- * @throws Error when bridgeResult is invalid or contains no steps array.
13724
+ * @param obj - The value to check, typically a parsed JSON response
13725
+ * @returns True if the attestation has expirationBlock === '0'
13726
+ * @throws {Error} With "Re-attestation not yet complete" if expirationBlock is not '0'
13638
13727
  *
13639
13728
  * @example
13640
13729
  * ```typescript
13641
- * import { analyzeSteps } from './analyzeSteps'
13730
+ * // After requesting re-attestation, use this to validate the response
13731
+ * const response = await pollApiGet(url, isReAttestedAttestationResponse, config)
13732
+ * // response.messages[0].decodedMessage.decodedMessageBody.expirationBlock === '0'
13733
+ * ```
13642
13734
  *
13643
- * // Failed approval step (requires user action)
13644
- * const bridgeResult = {
13645
- * steps: [
13646
- * { name: 'Approve', state: 'error', errorMessage: 'User rejected' }
13647
- * ]
13648
- * }
13735
+ * @internal
13736
+ */ const isReAttestedAttestationResponse = (obj)=>{
13737
+ // First validate the basic structure and completion status
13738
+ // This will throw appropriate errors for invalid structure or incomplete attestation
13739
+ if (!isAttestationResponse(obj)) ;
13740
+ // Check if the first message has expirationBlock === '0'
13741
+ const expirationBlock = obj.messages[0]?.decodedMessage?.decodedMessageBody?.expirationBlock;
13742
+ if (expirationBlock !== '0') {
13743
+ // Re-attestation not yet complete - allow retry via polling
13744
+ throw new Error('Re-attestation not yet complete: waiting for expirationBlock to become 0');
13745
+ }
13746
+ return true;
13747
+ };
13748
+ /**
13749
+ * Fetches attestation data and polls until expirationBlock === '0'.
13649
13750
  *
13650
- * const analysis = analyzeSteps(bridgeResult)
13651
- * // Result: { continuationStep: 'Approve', isRetryable: true,
13652
- * // reason: 'Retry failed approval' }
13653
- * ```
13751
+ * This function is used after calling requestReAttestation() to wait until
13752
+ * the attestation is fully re-processed. The expirationBlock transitions
13753
+ * from non-zero to zero when Circle completes the re-attestation.
13754
+ *
13755
+ * @param sourceDomainId - The CCTP domain ID of the source chain
13756
+ * @param transactionHash - The transaction hash to fetch attestation for
13757
+ * @param isTestnet - Whether this is for a testnet chain (true) or mainnet chain (false)
13758
+ * @param config - Optional configuration overrides
13759
+ * @returns The re-attested attestation response with expirationBlock === '0'
13760
+ * @throws If the request fails, times out, or expirationBlock never becomes 0
13654
13761
  *
13655
13762
  * @example
13656
13763
  * ```typescript
13657
- * // Pending transaction (requires waiting, not actionable)
13658
- * const bridgeResult = {
13659
- * steps: [
13660
- * { name: 'Approve', state: 'pending' }
13661
- * ]
13662
- * }
13764
+ * // After requesting re-attestation
13765
+ * await requestReAttestation(nonce, isTestnet)
13663
13766
  *
13664
- * const analysis = analyzeSteps(bridgeResult)
13665
- * // Result: { continuationStep: 'Approve', isRetryable: false,
13666
- * // reason: 'Continue pending approval' }
13767
+ * // Poll until expirationBlock becomes 0
13768
+ * const response = await fetchReAttestedAttestation(domainId, txHash, isTestnet)
13769
+ * // response.messages[0].decodedMessage.decodedMessageBody.expirationBlock === '0'
13667
13770
  * ```
13771
+ */ const fetchReAttestedAttestation = async (sourceDomainId, transactionHash, isTestnet, config = {})=>{
13772
+ const url = buildIrisUrl(sourceDomainId, transactionHash, isTestnet);
13773
+ const effectiveConfig = mergeAttestationConfig(config);
13774
+ return await pollApiGet(url, isReAttestedAttestationResponse, effectiveConfig);
13775
+ };
13776
+ /**
13777
+ * Builds the IRIS API URL for re-attestation requests.
13778
+ *
13779
+ * Constructs the URL for Circle's re-attestation endpoint that allows
13780
+ * requesting a fresh attestation for an expired nonce.
13781
+ *
13782
+ * @param nonce - The nonce from the original attestation
13783
+ * @param isTestnet - Whether this is for a testnet chain (true) or mainnet chain (false)
13784
+ * @returns A fully qualified URL string for the re-attestation endpoint
13668
13785
  *
13669
13786
  * @example
13670
13787
  * ```typescript
13671
- * // Completed bridge (nothing to do)
13672
- * const bridgeResult = {
13673
- * steps: [
13674
- * { name: 'Approve', state: 'success' },
13675
- * { name: 'Burn', state: 'success' },
13676
- * { name: 'FetchAttestation', state: 'success' },
13677
- * { name: 'Mint', state: 'success' }
13678
- * ]
13679
- * }
13788
+ * // Mainnet URL
13789
+ * const mainnetUrl = buildReAttestUrl('0xabc', false)
13790
+ * // => 'https://iris-api.circle.com/v2/reattest/0xabc'
13680
13791
  *
13681
- * const analysis = analyzeSteps(bridgeResult)
13682
- * // Result: { continuationStep: null, isRetryable: false,
13683
- * // reason: 'Bridge completed successfully' }
13792
+ * // Testnet URL
13793
+ * const testnetUrl = buildReAttestUrl('0xabc', true)
13794
+ * // => 'https://iris-api-sandbox.circle.com/v2/reattest/0xabc'
13684
13795
  * ```
13685
- */ const analyzeSteps = (bridgeResult)=>{
13686
- // Input validation
13687
- if (!bridgeResult || !Array.isArray(bridgeResult.steps)) {
13688
- throw new Error('Invalid bridgeResult: must contain a steps array');
13689
- }
13690
- const { steps } = bridgeResult;
13691
- // Build execution context from step history
13692
- const context = buildFlowContext(steps);
13693
- // Determine continuation logic using rule engine
13694
- const continuation = determineContinuationFromRules(context);
13695
- return {
13696
- continuationStep: continuation.nextStep,
13697
- isActionable: continuation.isActionable,
13698
- completedSteps: Array.from(context.completedSteps),
13699
- failedSteps: Array.from(context.failedSteps),
13700
- reason: continuation.reason
13701
- };
13796
+ */ const buildReAttestUrl = (nonce, isTestnet)=>{
13797
+ const baseUrl = isTestnet ? IRIS_API_SANDBOX_BASE_URL : IRIS_API_BASE_URL;
13798
+ const url = new URL(`${baseUrl}/v2/reattest/${nonce}`);
13799
+ return url.toString();
13702
13800
  };
13703
13801
  /**
13704
- * Build flow context from the execution history.
13802
+ * Type guard that validates the re-attestation API response structure.
13705
13803
  *
13706
- * @param steps - Array of executed bridge steps.
13707
- * @returns Flow context with execution state and history.
13708
- */ function buildFlowContext(steps) {
13709
- const completedSteps = new Set();
13710
- const failedSteps = new Set();
13711
- let lastStep;
13712
- // Process step history to build context
13713
- for (const step of steps){
13714
- if (step.state === 'success' || step.state === 'noop') {
13715
- completedSteps.add(step.name);
13716
- } else if (step.state === 'error') {
13717
- failedSteps.add(step.name);
13718
- }
13719
- // Track the last step for continuation logic
13720
- lastStep = {
13721
- name: step.name,
13722
- state: step.state
13723
- };
13804
+ * @param obj - The value to check, typically a parsed JSON response
13805
+ * @returns True if the object matches the ReAttestationResponse shape
13806
+ * @throws {Error} With "Invalid re-attestation response structure" if structure is invalid
13807
+ * @internal
13808
+ */ const isReAttestationResponse = (obj)=>{
13809
+ if (typeof obj !== 'object' || obj === null || !('message' in obj) || !('nonce' in obj) || typeof obj.message !== 'string' || typeof obj.nonce !== 'string') {
13810
+ throw new Error('Invalid re-attestation response structure');
13724
13811
  }
13725
- return {
13726
- completedSteps,
13727
- failedSteps,
13728
- ...lastStep && {
13729
- lastStep
13730
- }
13731
- };
13732
- }
13812
+ return true;
13813
+ };
13733
13814
  /**
13734
- * Determine continuation step using the rule engine.
13815
+ * Requests re-attestation for an expired attestation nonce.
13735
13816
  *
13736
- * @param context - The flow context with execution history.
13737
- * @returns Continuation decision with next step and actionability information.
13738
- */ function determineContinuationFromRules(context) {
13739
- const lastStepName = context.lastStep?.name;
13740
- // Handle initial state when no steps have been executed
13741
- if (lastStepName === undefined) {
13742
- const rules = STEP_TRANSITION_RULES[''];
13743
- const matchingRule = rules?.find((rule)=>rule.condition(context));
13744
- if (!matchingRule) {
13745
- return {
13746
- nextStep: null,
13747
- isActionable: false,
13748
- reason: 'No initial state rule found'
13749
- };
13750
- }
13751
- return {
13752
- nextStep: matchingRule.nextStep,
13753
- isActionable: matchingRule.isActionable,
13754
- reason: matchingRule.reason
13755
- };
13756
- }
13757
- // A step with an empty name is ambiguous and should be treated as an unrecoverable state.
13758
- if (lastStepName === '') {
13759
- return {
13760
- nextStep: null,
13761
- isActionable: false,
13762
- reason: 'No transition rules defined for step with empty name'
13763
- };
13764
- }
13765
- const rules = STEP_TRANSITION_RULES[lastStepName];
13766
- if (!rules) {
13767
- return {
13768
- nextStep: null,
13769
- isActionable: false,
13770
- reason: `No transition rules defined for step: ${lastStepName}`
13771
- };
13772
- }
13773
- // Find the first matching rule
13774
- const matchingRule = rules.find((rule)=>rule.condition(context));
13775
- if (!matchingRule) {
13776
- return {
13777
- nextStep: null,
13778
- isActionable: false,
13779
- reason: `No matching transition rule for current context`
13780
- };
13781
- }
13782
- return {
13783
- nextStep: matchingRule.nextStep,
13784
- isActionable: matchingRule.isActionable,
13785
- reason: matchingRule.reason
13786
- };
13787
- }
13788
-
13789
- /**
13790
- * Find a step by name in the bridge result.
13817
+ * This function calls Circle's re-attestation API endpoint to request a fresh
13818
+ * attestation for a previously issued nonce. After calling this function,
13819
+ * you should poll `fetchAttestation` to retrieve the new attestation.
13791
13820
  *
13792
- * @param result - The bridge result to search.
13793
- * @param stepName - The name of the step to find.
13794
- * @returns The step if found, undefined otherwise.
13821
+ * @param nonce - The nonce from the original (expired) attestation
13822
+ * @param isTestnet - Whether this is for a testnet chain (true) or mainnet chain (false)
13823
+ * @param config - Optional configuration overrides for the request
13824
+ * @returns The re-attestation response confirming the request was accepted
13825
+ * @throws If the request fails, times out, or returns invalid data
13795
13826
  *
13796
13827
  * @example
13797
13828
  * ```typescript
13798
- * import { findStepByName } from './findStep'
13829
+ * // Request re-attestation for an expired nonce
13830
+ * const response = await requestReAttestation('0xabc', true)
13831
+ * console.log(response.message) // "Re-attestation successfully requested for nonce."
13799
13832
  *
13800
- * const burnStep = findStepByName(result, 'burn')
13801
- * if (burnStep) {
13802
- * console.log('Burn tx:', burnStep.txHash)
13803
- * }
13833
+ * // After requesting re-attestation, poll for the new attestation
13834
+ * const attestation = await fetchAttestation(domainId, txHash, true)
13804
13835
  * ```
13805
- */ function findStepByName(result, stepName) {
13806
- return result.steps.find((step)=>step.name === stepName);
13807
- }
13836
+ */ const requestReAttestation = async (nonce, isTestnet, config = {})=>{
13837
+ const url = buildReAttestUrl(nonce, isTestnet);
13838
+ // Use minimal retries since we're just submitting a request, not polling for state
13839
+ const effectiveConfig = mergeAttestationConfig(config, {
13840
+ maxRetries: 3
13841
+ });
13842
+ return await pollApiPost(url, {}, isReAttestationResponse, effectiveConfig);
13843
+ };
13844
+
13808
13845
  /**
13809
- * Find a pending step by name and return it with its index.
13810
- *
13811
- * Searches for a step that matches both the step name and has a pending state.
13846
+ * Type guard that checks if the relayer has confirmed the mint transaction.
13812
13847
  *
13813
- * @param result - The bridge result containing steps to search through.
13814
- * @param stepName - The step name to find (e.g., 'burn', 'mint', 'fetchAttestation').
13815
- * @returns An object containing the step and its index in the steps array.
13816
- * @throws KitError if the specified pending step is not found.
13848
+ * This function validates that:
13849
+ * 1. The response has valid AttestationResponse structure
13850
+ * 2. At least one message has forwardState === 'CONFIRMED' (or 'COMPLETE') and a valid forwardTxHash
13817
13851
  *
13818
- * @example
13819
- * ```typescript
13820
- * import { findPendingStep } from './findStep'
13852
+ * If forwardState is 'FAILED', throws a non-retryable KitError.
13853
+ * If forwardState is 'PENDING' or not present, throws a RETRYABLE KitError to continue polling.
13821
13854
  *
13822
- * const { step, index } = findPendingStep(result, 'burn')
13823
- * console.log('Pending step:', step.name, 'at index:', index)
13824
- * ```
13825
- */ function findPendingStep(result, stepName) {
13826
- const index = result.steps.findIndex((step)=>step.name === stepName && step.state === 'pending');
13827
- if (index === -1) {
13855
+ * @param obj - The value to check, typically a parsed JSON response
13856
+ * @returns True if the relayer has confirmed the mint
13857
+ * @throws {KitError} With FATAL recoverability if structure is invalid
13858
+ * @throws {KitError} With RESUMABLE recoverability if forwardState is 'FAILED'
13859
+ * @throws {KitError} With RETRYABLE recoverability if still pending
13860
+ * @internal
13861
+ */ const isRelayerMintConfirmed = (obj)=>{
13862
+ // First check if the structure is valid
13863
+ if (!hasValidAttestationStructure(obj)) {
13828
13864
  throw new KitError({
13829
13865
  ...InputError.VALIDATION_FAILED,
13830
13866
  recoverability: 'FATAL',
13831
- message: `Pending step "${stepName}" not found in result`
13867
+ message: 'Invalid attestation response structure from IRIS API.'
13832
13868
  });
13833
13869
  }
13834
- const step = result.steps[index];
13835
- if (!step) {
13870
+ // Find the first message (typically there's only one)
13871
+ const message = obj.messages[0];
13872
+ if (!message) {
13836
13873
  throw new KitError({
13837
13874
  ...InputError.VALIDATION_FAILED,
13838
13875
  recoverability: 'FATAL',
13839
- message: 'Pending step is undefined'
13876
+ message: 'No attestation messages found in IRIS API response.'
13840
13877
  });
13841
13878
  }
13842
- return {
13843
- step,
13844
- index
13845
- };
13846
- }
13879
+ // Check for FAILED state - this is a permanent failure
13880
+ if (message.forwardState === 'FAILED') {
13881
+ throw new KitError({
13882
+ ...NetworkError.RELAYER_FORWARD_FAILED,
13883
+ recoverability: 'RESUMABLE',
13884
+ message: 'Circle relayer failed to forward the mint transaction. The mint may still have succeeded if another party submitted it. Check the recipient wallet balance before retrying. If the mint did not occur, you can manually submit it using the attestation data in the error cause.',
13885
+ cause: {
13886
+ trace: {
13887
+ eventNonce: message.eventNonce,
13888
+ attestation: message.attestation,
13889
+ message: message.message
13890
+ }
13891
+ }
13892
+ });
13893
+ }
13894
+ // Check if mint is confirmed (or complete) with a valid transaction hash
13895
+ // We accept both CONFIRMED and COMPLETE since COMPLETE implies CONFIRMED
13896
+ if ((message.forwardState === 'CONFIRMED' || message.forwardState === 'COMPLETE') && typeof message.forwardTxHash === 'string' && message.forwardTxHash.trim().length > 0) {
13897
+ return true;
13898
+ }
13899
+ // Still pending or not yet processed - throw RETRYABLE error to continue polling
13900
+ throw new KitError({
13901
+ ...NetworkError.RELAYER_PENDING,
13902
+ recoverability: 'RETRYABLE',
13903
+ message: 'Relayer mint not ready. Waiting for confirmation.'
13904
+ });
13905
+ };
13847
13906
  /**
13848
- * Get the burn transaction hash from bridge result.
13849
- *
13850
- * @param result - The bridge result.
13851
- * @returns The burn transaction hash, or undefined if not found.
13907
+ * Polls the attestation API until the relayer's mint transaction is confirmed.
13852
13908
  *
13853
- * @example
13854
- * ```typescript
13855
- * import { getBurnTxHash } from './findStep'
13909
+ * This function is used when `useForwarder` is enabled. Instead of the user
13910
+ * submitting the mint transaction, Circle's Orbit relayer handles it automatically.
13911
+ * This function polls until the relayer has submitted and confirmed the mint transaction.
13856
13912
  *
13857
- * const burnTxHash = getBurnTxHash(result)
13858
- * if (burnTxHash) {
13859
- * console.log('Burn tx hash:', burnTxHash)
13860
- * }
13861
- * ```
13862
- */ function getBurnTxHash(result) {
13863
- return findStepByName(result, CCTPv2StepName.burn)?.txHash;
13864
- }
13865
- /**
13866
- * Get the attestation data from bridge result.
13913
+ * @remarks
13914
+ * - Uses a 20-minute timeout by default (600 retries × 2 seconds)
13915
+ * - Throws immediately if `forwardState` is 'FAILED'
13916
+ * - Waits for `forwardState` to be 'CONFIRMED' or 'COMPLETE' (COMPLETE implies CONFIRMED)
13917
+ * - Returns the attestation message with `forwardTxHash` populated
13867
13918
  *
13868
- * @param result - The bridge result.
13869
- * @returns The attestation data, or undefined if not found.
13919
+ * @param sourceDomainId - The CCTP domain ID of the source chain
13920
+ * @param transactionHash - The transaction hash of the burn operation
13921
+ * @param isTestnet - Whether this is for a testnet chain (true) or mainnet (false)
13922
+ * @param config - Optional configuration overrides for polling behavior
13923
+ * @returns The attestation message with confirmed forwardTxHash
13924
+ * @throws {KitError} With code 'NETWORK_RELAYER_FORWARD_FAILED' if relayer failed
13925
+ * @throws {KitError} If timeout is reached while still pending
13870
13926
  *
13871
13927
  * @example
13872
13928
  * ```typescript
13873
- * import { getAttestationData } from './findStep'
13874
- *
13875
- * const attestation = getAttestationData(result)
13876
- * if (attestation) {
13877
- * console.log('Attestation:', attestation.message)
13878
- * }
13929
+ * const attestation = await fetchRelayerMint(0, '0xabc...', false)
13930
+ * console.log('Relayer mint tx:', attestation.forwardTxHash)
13879
13931
  * ```
13880
- */ function getAttestationData(result) {
13881
- // Prefer reAttest data (most recent attestation after expiry)
13882
- const reAttestStep = findStepByName(result, CCTPv2StepName.reAttest);
13883
- if (reAttestStep?.state === 'success' && reAttestStep.data) {
13884
- return reAttestStep.data;
13932
+ */ const fetchRelayerMint = async (sourceDomainId, transactionHash, isTestnet, config = {})=>{
13933
+ const url = buildIrisUrl(sourceDomainId, transactionHash, isTestnet);
13934
+ const effectiveConfig = mergeAttestationConfig(config);
13935
+ let response;
13936
+ try {
13937
+ response = await pollApiGet(url, isRelayerMintConfirmed, effectiveConfig);
13938
+ } catch (error) {
13939
+ // Enrich RELAYER_FORWARD_FAILED errors with the burn transaction hash
13940
+ if (error instanceof KitError && error.name === 'NETWORK_RELAYER_FORWARD_FAILED') {
13941
+ throw new KitError({
13942
+ ...NetworkError.RELAYER_FORWARD_FAILED,
13943
+ recoverability: error.recoverability,
13944
+ message: error.message,
13945
+ cause: {
13946
+ ...error.cause,
13947
+ trace: {
13948
+ ...error.cause?.trace,
13949
+ burnTxHash: transactionHash
13950
+ }
13951
+ }
13952
+ });
13953
+ }
13954
+ throw error;
13885
13955
  }
13886
- // Fall back to fetchAttestation step
13887
- const fetchStep = findStepByName(result, CCTPv2StepName.fetchAttestation);
13888
- return fetchStep?.data;
13889
- }
13956
+ // Return the first message (which should have forwardTxHash)
13957
+ // Note: This check is needed for TypeScript type safety even though
13958
+ // isRelayerMintConfirmed validates messages[0] exists. The type guard
13959
+ // narrows the type at the call site, but TypeScript can't infer that
13960
+ // the array still has elements after pollApiGet returns.
13961
+ const message = response.messages[0];
13962
+ if (!message) {
13963
+ throw new KitError({
13964
+ ...InputError.VALIDATION_FAILED,
13965
+ recoverability: 'FATAL',
13966
+ message: 'No attestation messages found in response after polling.'
13967
+ });
13968
+ }
13969
+ return message;
13970
+ };
13890
13971
 
13972
+ const assertCCTPv2WalletContextSymbol = Symbol('assertCCTPv2WalletContext');
13891
13973
  /**
13892
- * Check if the analysis indicates a non-actionable pending state.
13893
- *
13894
- * A pending state is non-actionable when there's a continuation step but
13895
- * the analysis marks it as not actionable, typically because we need to
13896
- * wait for an ongoing operation to complete.
13974
+ * Asserts that the provided parameters match the CCTPv2 wallet context interface.
13975
+ * The validation includes:
13976
+ * - Basic wallet context validation (adapter, address, chain)
13977
+ * - CCTPv2-specific chain validation (must be an EVM chain)
13897
13978
  *
13898
- * @param analysis - The step analysis result from analyzeSteps.
13899
- * @param result - The bridge result to check for pending steps.
13900
- * @returns True if there is a pending step that we should wait for.
13979
+ * @param params - The parameters to validate
13980
+ * @throws {KitError} If validation fails with INPUT_VALIDATION_FAILED code (1098), with details about which properties failed
13901
13981
  *
13902
13982
  * @example
13903
13983
  * ```typescript
13904
- * import { hasPendingState } from './stepUtils'
13905
- * import { analyzeSteps } from '../analyzeSteps'
13984
+ * import { assertCCTPv2WalletContext } from '@circle-fin/provider-cctp-v2'
13985
+ * import { Ethereum } from '@core/chains'
13906
13986
  *
13907
- * const analysis = analyzeSteps(bridgeResult)
13908
- * if (hasPendingState(analysis, bridgeResult)) {
13909
- * // Wait for the pending operation to complete
13987
+ * // Prepare wallet context
13988
+ * const context = {
13989
+ * adapter: {
13990
+ * prepare: async () => ({ data: 'prepared transaction' }),
13991
+ * waitForTransaction: async () => ({ status: 'confirmed' })
13992
+ * },
13993
+ * address: '0x1234567890123456789012345678901234567890',
13994
+ * chain: {
13995
+ * ...Ethereum,
13996
+ * usdcAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
13997
+ * cctp: {
13998
+ * domain: 1,
13999
+ * contracts: {
14000
+ * v2: {
14001
+ * tokenMessenger: '0xTokenMessenger',
14002
+ * messageTransmitter: '0xMessageTransmitter'
14003
+ * }
14004
+ * }
14005
+ * }
14006
+ * }
13910
14007
  * }
13911
- * ```
13912
- */ /**
13913
- * Evaluate a transaction receipt and return the corresponding step state
13914
- * and error message. Centralises the success/revert/unconfirmed logic so
13915
- * every call-site behaves identically.
13916
14008
  *
13917
- * @param receipt - The transaction receipt containing status and block info.
13918
- * @param txHash - The transaction hash used in error messages.
13919
- * @returns An object with `state` and an optional `errorMessage`.
14009
+ * // This will throw if validation fails
14010
+ * assertCCTPv2WalletContext(context)
13920
14011
  *
13921
- * @example
13922
- * ```typescript
13923
- * const outcome = evaluateTransactionOutcome(receipt, '0xabc...')
13924
- * step.state = outcome.state
13925
- * if (outcome.errorMessage) step.errorMessage = outcome.errorMessage
14012
+ * // If we get here, context is guaranteed to be valid
14013
+ * console.log('CCTPv2 wallet context is valid')
13926
14014
  * ```
13927
- */ function evaluateTransactionOutcome(receipt, txHash) {
13928
- if (receipt.status === 'success' && receipt.blockNumber) {
13929
- return {
13930
- state: 'success'
13931
- };
13932
- }
13933
- return {
13934
- state: 'error',
13935
- errorMessage: receipt.status === 'reverted' ? `Transaction ${txHash} was reverted` : 'Transaction was not confirmed on-chain'
13936
- };
13937
- }
13938
- function hasPendingState(analysis, result) {
13939
- // Check if there's a continuation step that's marked as non-actionable
13940
- if (analysis.continuationStep === null || analysis.isActionable) {
13941
- return false;
14015
+ */ function assertCCTPv2WalletContext(params) {
14016
+ // First validate basic wallet context
14017
+ validateWithStateTracking(params, walletContextSchema, 'CCTPv2 wallet context', assertCCTPv2WalletContextSymbol);
14018
+ // After validation, we know params is WalletContext
14019
+ const context = params;
14020
+ // Validate USDC support
14021
+ if (context.chain.usdcAddress === null) {
14022
+ throw createInvalidChainError(context.chain.name, 'Does not have USDC configured');
14023
+ }
14024
+ // Validate CCTPv2 support
14025
+ if (!isCCTPV2Supported(context.chain)) {
14026
+ throw createInvalidChainError(context.chain.name, 'Does not support CCTPv2');
13942
14027
  }
13943
- // Verify that the continuation step actually exists and is in pending state
13944
- const pendingStep = result.steps.find((step)=>step.name === analysis.continuationStep && step.state === 'pending');
13945
- return pendingStep !== undefined;
13946
14028
  }
14029
+
14030
+ const assertCCTPv2BridgeParamsSymbol = Symbol('assertCCTPv2BridgeParams');
13947
14031
  /**
13948
- * Check if the step is the last one in the execution flow.
14032
+ * Asserts that the provided parameters match the CCTPv2 bridge parameters interface.
14033
+ * The validation includes:
14034
+ * - Basic parameter structure and types
14035
+ * - Amount validation (non-empty numeric string \> 0)
14036
+ * - Wallet address format validation (must be valid Ethereum address)
14037
+ * - Chain definition validation (must be a valid chain with required properties)
14038
+ * - Adapter validation (must implement required methods)
14039
+ * - Optional config validation (transfer speed and max fee)
14040
+ * - Network compatibility (source and destination chains must both be testnet or both mainnet)
14041
+ * - CCTPv2-specific wallet context validations
13949
14042
  *
13950
- * @param step - The step object to check.
13951
- * @param stepNames - The ordered list of step names in the execution flow.
13952
- * @returns True if this is the last step in the flow.
14043
+ * @param params - The parameters to validate
14044
+ * @throws {KitError} If validation fails, with details about which properties failed
13953
14045
  *
13954
14046
  * @example
13955
14047
  * ```typescript
13956
- * import { isLastStep } from './stepUtils'
14048
+ * import { assertCCTPv2BridgeParams } from '@circle-fin/provider-cctp-v2'
14049
+ * import { Ethereum, Base } from '@core/chains'
13957
14050
  *
13958
- * const stepNames = ['approve', 'burn', 'fetchAttestation', 'mint']
13959
- * isLastStep({ name: 'mint' }, stepNames) // true
13960
- * isLastStep({ name: 'burn' }, stepNames) // false
14051
+ * // Prepare transfer parameters
14052
+ * const params = {
14053
+ * amount: '100.50',
14054
+ * source: {
14055
+ * adapter: sourceAdapter,
14056
+ * address: '0xSourceAddress',
14057
+ * chain: {
14058
+ * ...Ethereum,
14059
+ * cctp: {
14060
+ * domain: 1,
14061
+ * contracts: {
14062
+ * v2: {
14063
+ * tokenMessenger: '0xTokenMessenger',
14064
+ * messageTransmitter: '0xMessageTransmitter'
14065
+ * }
14066
+ * }
14067
+ * }
14068
+ * }
14069
+ * },
14070
+ * destination: {
14071
+ * adapter: destAdapter,
14072
+ * address: '0xDestAddress',
14073
+ * chain: {
14074
+ * ...Base,
14075
+ * cctp: {
14076
+ * domain: 2,
14077
+ * contracts: {
14078
+ * v2: {
14079
+ * tokenMessenger: '0xTokenMessenger',
14080
+ * messageTransmitter: '0xMessageTransmitter'
14081
+ * }
14082
+ * }
14083
+ * }
14084
+ * }
14085
+ * },
14086
+ * token: 'USDC',
14087
+ * config: {
14088
+ * transferSpeed: 'FAST',
14089
+ * maxFee: '1000000'
14090
+ * }
14091
+ * }
14092
+ *
14093
+ * // This will throw if validation fails
14094
+ * assertCCTPv2BridgeParams(params)
14095
+ *
14096
+ * // If we get here, params is guaranteed to be valid
14097
+ * console.log('CCTPv2 transfer parameters are valid')
13961
14098
  * ```
13962
- */ function isLastStep(step, stepNames) {
13963
- const stepIndex = stepNames.indexOf(step.name);
13964
- return stepIndex === -1 || stepIndex >= stepNames.length - 1;
14099
+ */ function assertCCTPv2BridgeParams(params) {
14100
+ // First validate basic bridge params
14101
+ validateWithStateTracking(params, bridgeParamsSchema, 'CCTPv2 bridge parameters', assertCCTPv2BridgeParamsSymbol);
14102
+ // After validation, we know params is CCTPV2BridgeParams
14103
+ const bridgeParams = params;
14104
+ // Enforce that source and destination chains are either testnet or mainnet
14105
+ if (bridgeParams.source.chain.isTestnet !== bridgeParams.destination.chain.isTestnet) {
14106
+ throw createNetworkMismatchError(bridgeParams.source.chain, bridgeParams.destination.chain);
14107
+ }
14108
+ assertCCTPV2Support(bridgeParams.source.chain, bridgeParams.destination.chain);
14109
+ // Validate that the destination chain supports forwarding when forwarder is enabled
14110
+ assertForwarderRouteSupport(bridgeParams.source.chain, bridgeParams.destination.chain, bridgeParams.destination.useForwarder);
14111
+ /**
14112
+ * Enforce that if fee is defined then feeRecipient must be defined.
14113
+ * We do not do this in the validation function itself because we want to allow
14114
+ * optional properties when calling `provider.bridge()` due to the custom fee
14115
+ * configuration being possible at the kit level as well.
14116
+ */ if (bridgeParams.config?.customFee?.value !== undefined && bridgeParams.config?.customFee?.recipientAddress === undefined) {
14117
+ throw createValidationFailedError$1('recipientAddress', bridgeParams.config.customFee.value, 'Custom fee is defined but fee recipient is not. Please provide a fee recipient.');
14118
+ }
14119
+ // Check if this is a forwarder-only destination (no adapter, requires useForwarder: true)
14120
+ const isForwarderOnly = bridgeParams.destination.useForwarder === true && !('adapter' in bridgeParams.destination && bridgeParams.destination.adapter);
14121
+ // Forwarder-only destinations require recipientAddress
14122
+ if (isForwarderOnly) {
14123
+ if (!bridgeParams.destination.recipientAddress?.trim()) {
14124
+ throw createValidationFailedError$1('recipientAddress', bridgeParams.destination.recipientAddress, 'recipientAddress is required when using forwarder without a destination adapter.');
14125
+ }
14126
+ }
14127
+ // Validate CCTP v2 specific requirements for source wallet
14128
+ assertCCTPv2WalletContext(bridgeParams.source);
14129
+ // Validate that source adapter supports the chain (defense-in-depth)
14130
+ bridgeParams.source.adapter.validateChainSupport(bridgeParams.source.chain);
14131
+ // Only validate destination wallet context and adapter if not forwarder-only
14132
+ if (!isForwarderOnly) {
14133
+ assertCCTPv2WalletContext(bridgeParams.destination);
14134
+ // Validate that destination adapter supports the chain (defense-in-depth)
14135
+ bridgeParams.destination.adapter.validateChainSupport(bridgeParams.destination.chain);
14136
+ }
13965
14137
  }
13966
14138
  /**
13967
- * Wait for a pending transaction to complete.
13968
- *
13969
- * Poll the adapter until the transaction is confirmed on-chain and return
13970
- * the updated step with success or error state based on the receipt.
13971
- *
13972
- * @param pendingStep - The full step object containing the transaction hash.
13973
- * @param adapter - The adapter to use for waiting.
13974
- * @param chain - The chain where the transaction was submitted.
13975
- * @returns The updated step object with success or error state.
14139
+ * Validate CCTP v2 support on both chains
14140
+ */ /**
14141
+ * Throws a KitError if the given chain does not support CCTP v2.
13976
14142
  *
13977
- * @throws KitError when the pending step has no transaction hash.
14143
+ * @param chain - The chain to check for CCTP v2 support
14144
+ * @param otherChain - The other chain in the route (for error context)
14145
+ * @param isSource - Whether this is the source chain (for error context)
14146
+ */ function assertCCTPV2Support(source, destination) {
14147
+ if (!isCCTPV2Supported(source) || !isCCTPV2Supported(destination)) {
14148
+ throw createUnsupportedRouteError(source.name, destination.name);
14149
+ }
14150
+ }
14151
+ /**
14152
+ * Validates that the forwarder (relaying) feature is compatible with the route.
13978
14153
  *
13979
- * @example
13980
- * ```typescript
13981
- * import { waitForPendingTransaction } from './bridgeStepUtils'
14154
+ * Checks the destination chain's `cctp.forwarderSupported.destination` property
14155
+ * to determine whether the chain supports receiving forwarded transfers.
13982
14156
  *
13983
- * const pendingStep = { name: 'burn', state: 'pending', txHash: '0x123...' }
13984
- * const updatedStep = await waitForPendingTransaction(pendingStep, adapter, chain)
13985
- * // updatedStep.state is now 'success' or 'error'
13986
- * ```
13987
- */ async function waitForPendingTransaction(pendingStep, adapter, chain) {
13988
- if (!pendingStep.txHash) {
14157
+ * @param source - The source chain definition
14158
+ * @param destination - The destination chain definition
14159
+ * @param useForwarder - Whether the forwarder is enabled on the destination
14160
+ * @throws {KitError} If the forwarder is enabled and the destination chain does not support forwarding
14161
+ */ function assertForwarderRouteSupport(source, destination, useForwarder) {
14162
+ if (useForwarder === true && !destination.cctp?.forwarderSupported.destination) {
13989
14163
  throw new KitError({
13990
- ...InputError.VALIDATION_FAILED,
14164
+ ...InputError.UNSUPPORTED_ROUTE,
13991
14165
  recoverability: 'FATAL',
13992
- message: `Cannot wait for pending ${pendingStep.name}: no transaction hash available`
14166
+ message: `Route from ${source.name} to ${destination.name} with forwarder is not supported (destination chain does not support forwarding).`,
14167
+ cause: {
14168
+ trace: {
14169
+ source: source.name,
14170
+ destination: destination.name
14171
+ }
14172
+ }
13993
14173
  });
13994
14174
  }
13995
- const txHash = pendingStep.txHash;
13996
- const txReceipt = await retryAsync(async ()=>adapter.waitForTransaction(txHash, undefined, chain), {
13997
- isRetryable: (err)=>isRetryableError$1(parseBlockchainError(err, {
13998
- chain: chain.name,
13999
- txHash
14000
- }))
14001
- });
14002
- const outcome = evaluateTransactionOutcome(txReceipt, txHash);
14003
- return {
14004
- ...pendingStep,
14005
- state: outcome.state,
14006
- data: txReceipt,
14007
- explorerUrl: buildExplorerUrl(chain, txHash),
14008
- ...outcome.errorMessage ? {
14009
- errorMessage: outcome.errorMessage
14010
- } : {}
14011
- };
14012
14175
  }
14176
+
14013
14177
  /**
14014
- * Wait for a pending step to complete.
14015
- *
14016
- * For transaction steps: waits for the transaction to be confirmed.
14017
- * For attestation: re-executes the attestation fetch.
14018
- *
14019
- * @typeParam TFromAdapterCapabilities - The capabilities of the source adapter.
14020
- * @typeParam TToAdapterCapabilities - The capabilities of the destination adapter.
14021
- * @param pendingStep - The full step object (with name, state, txHash, data, etc.) to resolve.
14022
- * @param adapter - The adapter to use.
14023
- * @param chain - The chain where the step is executing.
14024
- * @param context - The retry context.
14025
- * @param result - The bridge result.
14026
- * @param provider - The CCTP v2 bridging provider.
14027
- * @returns The resolved step object with updated state.
14028
- *
14029
- * @throws KitError when fetching attestation but burn transaction hash is not found.
14178
+ * Checks if a decoded attestation field matches the corresponding transfer parameter.
14179
+ * If the values do not match, appends a descriptive error message to the errors array.
14030
14180
  *
14031
- * @example
14032
- * ```typescript
14033
- * import { waitForStepToComplete } from './bridgeStepUtils'
14181
+ * @param field - The name of the field being compared (for error reporting)
14182
+ * @param decoded - The value decoded from the attestation message
14183
+ * @param param - The expected value from the transfer parameters
14184
+ * @param errors - The array to which error messages will be appended if a mismatch is found
14185
+ */ function checkFieldMismatch(field, decoded, param, errors) {
14186
+ if (decoded !== param) {
14187
+ errors.push(`${field} mismatch: decoded=${String(decoded)}, params=${String(param)}`);
14188
+ }
14189
+ }
14190
+ /**
14191
+ * Asserts that the decoded message from attestation matches the provided transfer params.
14192
+ * Throws KitError if any field mismatches, with clear error messages.
14034
14193
  *
14035
- * const pendingStep = { name: 'burn', state: 'pending', txHash: '0x123...' }
14036
- * const updatedStep = await waitForStepToComplete(
14037
- * pendingStep,
14038
- * adapter,
14039
- * chain,
14040
- * context,
14041
- * result,
14042
- * provider,
14043
- * )
14044
- * // updatedStep.state is now 'success' or 'error'
14045
- * ```
14046
- */ async function waitForStepToComplete(pendingStep, adapter, chain, context, result, provider) {
14047
- if (pendingStep.name === CCTPv2StepName.fetchAttestation) {
14048
- // For attestation, re-run the fetch (it has built-in polling)
14049
- const burnTxHash = getBurnTxHash(result);
14050
- if (!burnTxHash) {
14051
- throw new KitError({
14052
- ...InputError.VALIDATION_FAILED,
14053
- recoverability: 'FATAL',
14054
- message: 'Cannot fetch attestation: burn transaction hash not found'
14055
- });
14056
- }
14057
- const sourceAddress = result.source.address;
14058
- const attestation = await provider.fetchAttestation({
14059
- chain: result.source.chain,
14060
- adapter: context.from,
14061
- address: sourceAddress
14062
- }, burnTxHash);
14063
- return {
14064
- ...pendingStep,
14065
- state: 'success',
14066
- data: attestation
14067
- };
14194
+ * @param attestation - The attestation message containing the decoded message
14195
+ * @param params - The transfer parameters to validate against
14196
+ * @throws {@link KitError} If any field mismatches
14197
+ */ async function assertCCTPv2AttestationParams(attestation, params) {
14198
+ const errors = [];
14199
+ const message = attestation.decodedMessage;
14200
+ const messageBody = message.decodedMessageBody;
14201
+ // Use recipientAddress if provided, otherwise use destination.address
14202
+ const destinationAddressForMint = params.destination.recipientAddress ?? params.destination.address;
14203
+ const mintRecipient = await getMintRecipientAccount(params.destination.chain.type, destinationAddressForMint, params.destination.chain.usdcAddress);
14204
+ let sender;
14205
+ if (hasCustomContractSupport(params.source.chain, 'bridge')) {
14206
+ if (params.source.chain.type === 'solana') {
14207
+ // Solana: User Bridge contract → CCTP (user remains sender)
14208
+ sender = params.source.address;
14209
+ } else {
14210
+ // Other chains (like EVM): Bridge contract → CCTP (bridge contract becomes sender)
14211
+ sender = params.source.chain.kitContracts?.bridge;
14212
+ }
14213
+ } else {
14214
+ sender = params.source.address;
14215
+ }
14216
+ checkFieldMismatch('sourceDomain', message.sourceDomain, params.source.chain.cctp.domain.toString(), errors);
14217
+ checkFieldMismatch('destinationDomain', message.destinationDomain, params.destination.chain.cctp.domain.toString(), errors);
14218
+ checkFieldMismatch('minFinalityThreshold', message.minFinalityThreshold, CCTPv2MinFinalityThreshold[params.config.transferSpeed ?? 'FAST'].toString(), errors);
14219
+ checkFieldMismatch('sender', params.source.chain.type === 'evm' ? messageBody.messageSender.toLowerCase() : messageBody.messageSender, params.source.chain.type === 'evm' ? sender?.toLowerCase() : sender, errors);
14220
+ checkFieldMismatch('recipient', params.destination.chain.type === 'evm' ? messageBody.mintRecipient.toLowerCase() : messageBody.mintRecipient, params.destination.chain.type === 'evm' ? mintRecipient.toLowerCase() : mintRecipient, errors);
14221
+ checkFieldMismatch('amount', messageBody.amount, params.amount.toString(), errors);
14222
+ checkFieldMismatch('burnToken', messageBody.burnToken.toLowerCase(), params.source.chain.usdcAddress.toLowerCase(), errors);
14223
+ if (errors.length > 0) {
14224
+ const errorMessage = 'Attestation validation failed: received attestation does not match expected transfer parameters';
14225
+ const firstError = errors[0] ?? '';
14226
+ throw new KitError({
14227
+ ...InputError.VALIDATION_FAILED,
14228
+ recoverability: 'FATAL',
14229
+ message: `${errorMessage}: ${firstError}`,
14230
+ cause: {
14231
+ trace: {
14232
+ validationErrors: errors
14233
+ }
14234
+ }
14235
+ });
14068
14236
  }
14069
- // For transaction steps, wait for the transaction to complete
14070
- return waitForPendingTransaction(pendingStep, adapter, chain);
14071
14237
  }
14072
14238
 
14073
14239
  /**
14074
- * Multiplier applied to a successful gas estimate before it is submitted.
14075
- *
14076
- * Estimates are exact, not padded: Sei returns 109_739 for an approve that
14077
- * consumes 107_717 (1.9% headroom). Chains that price storage in large steps
14078
- * can exceed the estimate if state changes between estimation and inclusion,
14079
- * so the estimate is padded before use.
14240
+ * Assert that `params` is a well-formed {@link BurnWithFeesParams} object.
14080
14241
  *
14081
- * @remarks
14082
- * This buffer alone does NOT cover Sei's ~51_500 per-new-slot step at approve
14083
- * scale (25% of ~110_000 is only ~27_500). For approve, the FLOOR is what
14084
- * covers a slot that exists at estimation time and is consumed before
14085
- * inclusion — so do not lower `APPROVE_GAS_LIMIT_EVM` on the reasoning that
14086
- * the estimate covers it. For burn the buffer does cover a step (25% of
14087
- * ~300_000 exceeds 51_500).
14088
- */ const GAS_ESTIMATE_BUFFER_PERCENT = 125n;
14089
- /**
14090
- * Resolve the gas limit for an EVM request as `max(estimate * buffer, floor)`.
14242
+ * Validates the full public-boundary input before any field destructuring,
14243
+ * `BigInt()` coercion, or adapter preparation runs, so malformed JS-caller
14244
+ * inputs always produce typed `KitError` validation failures.
14091
14245
  *
14092
- * Estimates first so chains whose real cost exceeds the floor are covered by
14093
- * their own measurement, and falls back to the floor whenever estimation is
14094
- * unavailable or under-reports. Estimation failure is never fatal here: before
14095
- * floors existed these requests were submitted with a pinned limit and no
14096
- * estimate at all, so degrading to the floor is never worse than the previous
14097
- * behaviour.
14246
+ * Checks performed (in order):
14247
+ * - `params` must be a non-null plain object
14248
+ * - `source` valid CCTP v2 wallet context (via `assertCCTPv2WalletContext`)
14249
+ * - `destinationChain` present and supports CCTP v2
14250
+ * - source and destination chains must both be testnet or both mainnet
14251
+ * - source and destination chains must differ
14252
+ * - `executor` — non-empty string
14253
+ * - `amount` — bigint or non-empty string coercible to bigint
14254
+ * - `feeTotalAmount` — bigint or non-empty string coercible to bigint
14255
+ * - `feeToken` — valid EVM address (`0x` + 40 hex chars)
14256
+ * - `claim.signedQuote` — valid `0x`-prefixed hex string
14257
+ * - `claim.refundAddress` — valid EVM address
14258
+ * - `hookData` — valid `0x`-prefixed hex string when present
14098
14259
  *
14099
- * @param request - The prepared EVM request to size a gas limit for
14100
- * @param gasFloor - The minimum gas limit to submit, in gas units
14101
- * @returns The gas limit to submit, in gas units
14102
- * @throws Never — estimation failures degrade to `gasFloor`
14260
+ * @param params - The value to validate.
14261
+ * @throws {KitError} If any field is missing or invalid.
14103
14262
  *
14104
14263
  * @example
14105
14264
  * ```typescript
14106
- * const gasLimit = await resolveGasLimit(request, 150_000)
14265
+ * assertBurnWithFeesParams(params)
14266
+ * // params is now typed as BurnWithFeesParams and safe to use
14267
+ * const { source, destinationChain, amount } = params
14107
14268
  * ```
14108
- */ const resolveGasLimit = async (request, gasFloor)=>{
14269
+ */ function assertBurnWithFeesParams(params) {
14270
+ if (params === null || typeof params !== 'object' || Array.isArray(params)) {
14271
+ throw createValidationFailedError$1('params', params, 'Must be a non-null plain object');
14272
+ }
14273
+ const p = params;
14274
+ // Source wallet context
14275
+ assertCCTPv2WalletContext(p['source']);
14276
+ const source = p['source'];
14277
+ // destinationChain
14278
+ const destinationChain = p['destinationChain'];
14279
+ if (destinationChain === null || destinationChain === undefined || typeof destinationChain !== 'object') {
14280
+ throw createValidationFailedError$1('destinationChain', destinationChain, 'Must be a chain definition object');
14281
+ }
14282
+ if (!isCCTPV2Supported(destinationChain)) {
14283
+ throw createValidationFailedError$1('destinationChain', destinationChain, 'destinationChain must support CCTP v2');
14284
+ }
14285
+ const dest = destinationChain;
14286
+ // Testnet / mainnet mismatch
14287
+ if (source.chain.isTestnet !== dest.isTestnet) {
14288
+ throw createNetworkMismatchError(source.chain, dest);
14289
+ }
14290
+ // Same-chain guard
14291
+ if (source.chain.name === dest.name) {
14292
+ throw createUnsupportedRouteError(source.chain.name, dest.name);
14293
+ }
14294
+ // executor
14295
+ const executor = p['executor'];
14296
+ if (typeof executor !== 'string' || executor === '') {
14297
+ throw createValidationFailedError$1('executor', executor, 'A GenericExecutor address is required (used for both mintRecipient and destinationCaller)');
14298
+ }
14299
+ // amount
14300
+ const rawAmount = p['amount'];
14301
+ if (typeof rawAmount !== 'bigint' && typeof rawAmount !== 'string' || rawAmount === '') {
14302
+ throw createValidationFailedError$1('amount', rawAmount, 'Must be a bigint or a numeric string');
14303
+ }
14109
14304
  try {
14110
- // Deliberately called without a `fallback`: both the viem and ethers
14111
- // adapters *return* the supplied fallback object when estimation reverts
14112
- // rather than throwing, which would set the estimate to the floor and then
14113
- // multiply it by the buffer below. Omitting it routes reverts through the
14114
- // catch, so a failed estimate degrades to exactly the floor.
14115
- const estimate = await request.estimate();
14116
- // The arithmetic stays inside the try on purpose. `EstimatedGas.gas` is
14117
- // typed `bigint`, but adapters are a public extension point and may be
14118
- // implemented in plain JS, so a non-bigint `gas` would throw here
14119
- // ("Cannot mix BigInt and other types"). Guarding it keeps the documented
14120
- // contract — estimation never aborts a step, it degrades to the floor.
14121
- const buffered = estimate.gas * GAS_ESTIMATE_BUFFER_PERCENT / 100n;
14122
- // Convert before comparing: Math.max throws on BigInt operands, and gas
14123
- // units are far below Number.MAX_SAFE_INTEGER so the narrowing is lossless.
14124
- return Math.max(Number(buffered), gasFloor);
14305
+ BigInt(rawAmount);
14125
14306
  } catch {
14126
- // Estimation is best-effort; the floor is the known-safe value.
14127
- return gasFloor;
14307
+ throw createValidationFailedError$1('amount', rawAmount, 'Must be a valid integer value coercible to bigint');
14308
+ }
14309
+ // feeTotalAmount
14310
+ const rawFee = p['feeTotalAmount'];
14311
+ if (typeof rawFee !== 'bigint' && typeof rawFee !== 'string' || rawFee === '') {
14312
+ throw createValidationFailedError$1('feeTotalAmount', rawFee, 'Must be a bigint or a numeric string');
14128
14313
  }
14129
- };
14130
- /**
14131
- * Executes a prepared chain request and returns the result as a bridge step.
14132
- *
14133
- * This function takes a prepared chain request (containing transaction data) and executes
14134
- * it using the appropriate adapter. It handles the execution details and formats
14135
- * the result as a standardized bridge step with transaction details and explorer URLs.
14136
- *
14137
- * @param params - The execution parameters containing:
14138
- * - `name`: The name of the step
14139
- * - `request`: The prepared chain request containing transaction data
14140
- * - `adapter`: The adapter that will execute the transaction
14141
- * - `confirmations`: The number of confirmations to wait for (defaults to 1)
14142
- * - `timeout`: The timeout for the request in milliseconds
14143
- * - `gasFloor`: Optional minimum gas limit (number); the request is submitted
14144
- * with `max(estimate * 1.25, gasFloor)`. Ignored for non-EVM requests
14145
- * @returns The bridge step with the transaction details and explorer URL
14146
- * @throws If the transaction execution fails
14147
- *
14148
- * @example
14149
- * ```typescript
14150
- * const step = await executePreparedChainRequest({
14151
- * name: 'approve',
14152
- * request: preparedRequest,
14153
- * adapter: adapter,
14154
- * confirmations: 2,
14155
- * timeout: 30000
14156
- * })
14157
- * console.log('Transaction hash:', step.txHash)
14158
- * ```
14159
- */ async function executePreparedChainRequest({ name, request, adapter, chain, confirmations = 1, timeout, gasFloor }) {
14160
- const step = {
14161
- name,
14162
- state: 'pending'
14163
- };
14164
14314
  try {
14165
- /**
14166
- * No-op requests are not executed.
14167
- * We return a noop step instead.
14168
- */ if (request.type === 'noop') {
14169
- step.state = 'noop';
14170
- return step;
14171
- }
14172
- const txHash = request.type === 'evm' && gasFloor !== undefined ? await request.execute({
14173
- gasLimit: await resolveGasLimit(request, gasFloor)
14174
- }) : await request.execute();
14175
- step.txHash = txHash;
14176
- const retryOptions = {
14177
- isRetryable: (err)=>isRetryableError$1(parseBlockchainError(err, {
14178
- chain: chain.name,
14179
- txHash
14180
- }))
14181
- };
14182
- if (timeout !== undefined) {
14183
- retryOptions.deadlineMs = Date.now() + timeout;
14184
- }
14185
- const transaction = await retryAsync(async ()=>adapter.waitForTransaction(txHash, {
14186
- confirmations,
14187
- timeout
14188
- }, chain), retryOptions);
14189
- const outcome = evaluateTransactionOutcome(transaction, txHash);
14190
- step.state = outcome.state;
14191
- step.data = transaction;
14192
- // Generate explorer URL for the step
14193
- step.explorerUrl = buildExplorerUrl(chain, txHash);
14194
- if (outcome.errorMessage) {
14195
- step.errorMessage = outcome.errorMessage;
14196
- // Transaction was mined but reverted on-chain.
14197
- step.errorCategory = 'chain_revert';
14198
- }
14199
- } catch (err) {
14200
- step.state = 'error';
14201
- step.error = err;
14202
- // Sequential path does not yet attempt fine-grained classification of
14203
- // pre-submission errors (user_rejected, capability errors, etc.). Mark
14204
- // as `unknown` so consumers can at least detect the category is
14205
- // populated uniformly across batched and sequential flows.
14206
- step.errorCategory = 'unknown';
14207
- // Optionally parse for common blockchain error formats
14208
- if (err instanceof Error) {
14209
- step.errorMessage = err.message;
14210
- } else if (typeof err === 'object' && err != null && 'message' in err) {
14211
- step.errorMessage = String(err.message);
14212
- } else {
14213
- step.errorMessage = `Unknown error occurred during ${name} step.`;
14214
- }
14315
+ BigInt(rawFee);
14316
+ } catch {
14317
+ throw createValidationFailedError$1('feeTotalAmount', rawFee, 'Must be a valid integer value coercible to bigint');
14318
+ }
14319
+ // feeToken
14320
+ if (!evmAddressSchema.safeParse(p['feeToken']).success) {
14321
+ throw createValidationFailedError$1('feeToken', p['feeToken'], 'Must be a valid EVM address (use the zero address for a native fee)');
14322
+ }
14323
+ // claim
14324
+ const rawClaim = p['claim'];
14325
+ if (rawClaim === null || typeof rawClaim !== 'object' || Array.isArray(rawClaim)) {
14326
+ throw createValidationFailedError$1('claim', rawClaim, 'Must be an object with signedQuote and refundAddress');
14327
+ }
14328
+ const claim = rawClaim;
14329
+ if (!hexStringSchema.safeParse(claim['signedQuote']).success) {
14330
+ throw createValidationFailedError$1('claim.signedQuote', claim['signedQuote'], 'Must be a valid hex string starting with 0x');
14331
+ }
14332
+ if (!evmAddressSchema.safeParse(claim['refundAddress']).success) {
14333
+ throw createValidationFailedError$1('claim.refundAddress', claim['refundAddress'], 'Must be a valid EVM address');
14334
+ }
14335
+ // hookData (optional)
14336
+ const hookData = p['hookData'];
14337
+ if (hookData !== undefined && hookData !== '' && !hexStringSchema.safeParse(hookData).success) {
14338
+ throw createValidationFailedError$1('hookData', hookData, 'Must be a valid hex string starting with 0x');
14215
14339
  }
14216
- return step;
14217
14340
  }
14218
14341
 
14219
14342
  /**
@@ -14244,7 +14367,7 @@ function hasPendingState(analysis, result) {
14244
14367
  adapter: params.source.adapter,
14245
14368
  chain: params.source.chain,
14246
14369
  request: await provider.approve(params.source, approvalAmount),
14247
- gasFloor: Number(APPROVE_GAS_LIMIT_EVM)
14370
+ gasFloor: Number(BRIDGE_STEP_GAS_FLOORS_EVM.approve)
14248
14371
  });
14249
14372
  }
14250
14373
 
@@ -14272,7 +14395,7 @@ function hasPendingState(analysis, result) {
14272
14395
  adapter: params.source.adapter,
14273
14396
  chain: params.source.chain,
14274
14397
  request: await provider.burn(params),
14275
- gasFloor: Number(DEPOSIT_FOR_BURN_GAS_LIMIT_EVM)
14398
+ gasFloor: Number(BRIDGE_STEP_GAS_FLOORS_EVM.burn)
14276
14399
  });
14277
14400
  }
14278
14401
 
@@ -14368,7 +14491,7 @@ function hasPendingState(analysis, result) {
14368
14491
  // eth_estimateGas does not account for, returning a below-floor value
14369
14492
  // without reverting. The floor covers those; chains that cost more than the
14370
14493
  // floor are covered by their own estimate.
14371
- gasFloor: Number(RECEIVE_MESSAGE_GAS_LIMIT_EVM)
14494
+ gasFloor: Number(BRIDGE_STEP_GAS_FLOORS_EVM.mint)
14372
14495
  });
14373
14496
  // Add forwarded: false for non-relayer mints
14374
14497
  return {
@@ -14891,7 +15014,7 @@ const mockAttestationMessage = {
14891
15014
  return step;
14892
15015
  }
14893
15016
 
14894
- var version$2 = "1.10.2";
15017
+ var version$2 = "1.11.0";
14895
15018
  var pkg$2 = {
14896
15019
  version: version$2};
14897
15020
 
@@ -15850,10 +15973,39 @@ function assertCCTPV2Config(config) {
15850
15973
  // CCTP-specific transfer params validation (includes base validation)
15851
15974
  assertCCTPv2BridgeParams(params);
15852
15975
  const { source, destination, amount } = params;
15853
- const estimateBurn = async ()=>{
15854
- const burn = await this.burn(params);
15855
- return await burn.estimate(undefined, await source.adapter.calculateTransactionFee(hasCustomContractSupport(source.chain, 'bridge') ? CUSTOM_BURN_GAS_ESTIMATE_EVM : DEPOSIT_FOR_BURN_GAS_ESTIMATE_EVM, undefined, source.chain));
15976
+ /**
15977
+ * Price the gas a step will RESERVE on-chain, not what it will spend.
15978
+ *
15979
+ * A transaction is only admitted when the sender holds
15980
+ * `gasLimit * gasPrice`, and `executePreparedChainRequest` submits
15981
+ * `max(estimate * buffer, floor)`. Quoting a bare `estimate()` therefore
15982
+ * under-reports what a wallet must hold to send at all: the floor governs
15983
+ * on virtually every chain, which put the burn quote ~2.5x below the real
15984
+ * requirement and left anyone funding from it unable to submit.
15985
+ *
15986
+ * Delegates to the same `resolveGasLimit` the execute path calls, so a
15987
+ * quote and the limit later submitted for that step cannot drift apart.
15988
+ * That also means an estimation failure degrades to the floor here exactly
15989
+ * as it does on execution, rather than surfacing as a failed quote — the
15990
+ * floor is what would be submitted, so it is the honest number to quote.
15991
+ *
15992
+ * Non-EVM requests are unchanged: `executePreparedChainRequest` only
15993
+ * applies a floor when `request.type === 'evm'`, so there is no reserved
15994
+ * limit to quote on other chains.
15995
+ */ const quoteReservedGas = async (request, gasFloor, priceGas, // Ignored on EVM — only evaluated and used on non-EVM paths.
15996
+ nonEvmFallbackGasEstimate)=>{
15997
+ if (request.type !== 'evm') {
15998
+ return nonEvmFallbackGasEstimate === undefined ? await request.estimate() : await request.estimate(undefined, await priceGas(nonEvmFallbackGasEstimate()));
15999
+ }
16000
+ // resolveGasLimit returns number; gas units are well below Number.MAX_SAFE_INTEGER,
16001
+ // so the Number() → resolveGasLimit → BigInt() round-trip is lossless.
16002
+ return await priceGas(BigInt(await resolveGasLimit(request, Number(gasFloor))));
15856
16003
  };
16004
+ const priceGasFor = (ctx)=>async (gasUnits)=>await ctx.adapter.calculateTransactionFee(gasUnits, undefined, ctx.chain);
16005
+ const priceSourceGas = priceGasFor(source);
16006
+ const priceDestinationGas = priceGasFor(destination);
16007
+ const estimateApprove = async ()=>await quoteReservedGas(await this.approve(source, amount), BRIDGE_STEP_GAS_FLOORS_EVM.approve, priceSourceGas);
16008
+ const estimateBurn = async ()=>await quoteReservedGas(await this.burn(params), BRIDGE_STEP_GAS_FLOORS_EVM.burn, priceSourceGas, ()=>hasCustomContractSupport(source.chain, 'bridge') ? CUSTOM_BURN_GAS_ESTIMATE_EVM : DEPOSIT_FOR_BURN_GAS_ESTIMATE_EVM);
15857
16009
  // Only estimate Mint gas when not using forwarder (user pays gas)
15858
16010
  // When useForwarder=true, Circle's Orbit relayer handles and pays for the mint
15859
16011
  const useForwarder = destination.useForwarder === true;
@@ -15862,12 +16014,11 @@ function assertCCTPV2Config(config) {
15862
16014
  return null // Skip mint estimation when forwarder handles it
15863
16015
  ;
15864
16016
  }
15865
- const mint = await this.mint(source, destination, mockAttestationMessage);
15866
- return await mint.estimate(undefined, await destination.adapter.calculateTransactionFee(RECEIVE_MESSAGE_GAS_ESTIMATE_EVM, undefined, destination.chain));
16017
+ return await quoteReservedGas(await this.mint(source, destination, mockAttestationMessage), BRIDGE_STEP_GAS_FLOORS_EVM.mint, priceDestinationGas, ()=>RECEIVE_MESSAGE_GAS_ESTIMATE_EVM);
15867
16018
  };
15868
16019
  // Parallelize all independent async operations
15869
16020
  const [approveEstimate, depositForBurnFee, receiveMessageFee, feeEstimates] = await Promise.allSettled([
15870
- this.approve(source, amount).then(async (approve)=>approve.estimate()),
16021
+ estimateApprove(),
15871
16022
  estimateBurn(),
15872
16023
  estimateMint(),
15873
16024
  this.getMaxFee(params)
@@ -17507,7 +17658,7 @@ registerKit(`${pkg$3.name}/${pkg$3.version}`);
17507
17658
  };
17508
17659
 
17509
17660
  var name$1 = "@circle-fin/swap-kit";
17510
- var version$1 = "1.5.1";
17661
+ var version$1 = "1.5.2";
17511
17662
  var pkg$1 = {
17512
17663
  name: name$1,
17513
17664
  version: version$1};
@@ -19450,7 +19601,7 @@ new Set(Object.values(Blockchain));
19450
19601
  registerKit(`${pkg$1.name}/${pkg$1.version}`);
19451
19602
 
19452
19603
  var name = "@circle-fin/earn-kit";
19453
- var version = "1.5.0";
19604
+ var version = "1.5.1";
19454
19605
  var pkg = {
19455
19606
  name: name,
19456
19607
  version: version};