diff --git a/CHANGELOG.md b/CHANGELOG.md index a8f799cf..d7760b6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - SDK: `getFeeTokens` now supports CCIP v2.0 lanes (via FeeQuoter, same as v1.6) +- SDK: implemented `CCIPAPIClient.getExecutionInput(messageId: string)` +- SDK: `dest.execute` and `dest.generateUnsignedExecute` can now execute directly from a `messageId`, without the need for an explicit `source.getExecutionInput` +- SDK: `Chain` constructors context can receive a string URL for `apiClient` +- CLI: using all the above, `manual-exec` now can receive a `messageId` as positional argument (besides `txHash`), and execute from CCIP-API's `/execution-inputs` without needing a source RPC ## [1.0.0] - 2026-02-26 - Major refactoring stable diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 391221a2..e7dd01e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -209,7 +209,7 @@ constructor(network: NetworkInfo, ctx?: ChainContext) { } else if (apiClient !== undefined) { this.apiClient = apiClient // Use provided instance } else { - this.apiClient = CCIPAPIClient.fromUrl(undefined, { logger }) // Default + this.apiClient = CCIPAPIClient.fromUrl(undefined, ctx) // Default } } ``` diff --git a/ccip-api-ref/docs-cli/lane-latency.mdx b/ccip-api-ref/docs-cli/lane-latency.mdx index a1ca9175..7912f53e 100644 --- a/ccip-api-ref/docs-cli/lane-latency.mdx +++ b/ccip-api-ref/docs-cli/lane-latency.mdx @@ -40,9 +40,9 @@ The latency estimate includes time for: ## Options -| Option | Type | Default | Description | -| ----------- | ------ | ----------------------------- | ------------------- | -| `--api-url` | string | `https://api.ccip.chain.link` | Custom CCIP API URL | +| Option | Type | Default | Description | +| ------- | ------ | ----------------------------- | ------------------- | +| `--api` | string | `https://api.ccip.chain.link` | Custom CCIP API URL | See [Configuration](/cli/configuration) for global options (`--format`, `--no-api`, etc.). @@ -85,7 +85,7 @@ ccip-cli lane-latency ethereum-mainnet arbitrum-mainnet --format json ### Use custom API endpoint ```bash -ccip-cli lane-latency ethereum-mainnet arbitrum-mainnet --api-url https://staging-api.example.com +ccip-cli lane-latency ethereum-mainnet arbitrum-mainnet --api https://staging-api.example.com ``` ## Output diff --git a/ccip-api-ref/src/components/cli-builder/schemas/lane-latency.schema.ts b/ccip-api-ref/src/components/cli-builder/schemas/lane-latency.schema.ts index b4cf65d3..3f051c03 100644 --- a/ccip-api-ref/src/components/cli-builder/schemas/lane-latency.schema.ts +++ b/ccip-api-ref/src/components/cli-builder/schemas/lane-latency.schema.ts @@ -34,9 +34,9 @@ export const laneLatencySchema: CommandSchema<'laneLatency'> = { options: [ { type: 'string', - name: 'api-url', + name: 'api', label: 'API URL', - description: 'Custom CCIP API URL (defaults to api.ccip.chain.link)', + description: 'Custom CCIP API URL (defaults to https://api.ccip.chain.link)', group: 'output', placeholder: 'https://api.ccip.chain.link', }, diff --git a/ccip-cli/README.md b/ccip-cli/README.md index 8360c03c..e8ea863b 100644 --- a/ccip-cli/README.md +++ b/ccip-cli/README.md @@ -339,7 +339,7 @@ ccip-cli token -n solana-devnet -H EPUjBP3Xf76K1VKsDSc6GupBWE8uykNksCLJgXZn87CB ### `lane-latency` ```sh -ccip-cli lane-latency [--api-url ] +ccip-cli lane-latency [--api=] ``` Query real-time lane latency between source and destination chains using the CCIP API. @@ -355,7 +355,7 @@ Query real-time lane latency between source and destination chains using the CCI | Option | Description | |--------|-------------| -| `--api-url` | Custom CCIP API URL (default: api.ccip.chain.link) | +| `--api` | Custom CCIP API URL (default: https://api.ccip.chain.link) | > **Note:** This command requires CCIP API access and respects the `--no-api` flag. diff --git a/ccip-cli/src/commands/lane-latency.test.ts b/ccip-cli/src/commands/lane-latency.test.ts index f6c922d0..17b44463 100644 --- a/ccip-cli/src/commands/lane-latency.test.ts +++ b/ccip-cli/src/commands/lane-latency.test.ts @@ -91,7 +91,7 @@ describe('lane-latency command', () => { await getLaneLatencyCmd(createCtx(), { source: '1', dest: '42161', - apiUrl: 'https://custom.api.example.com/', + api: 'https://custom.api.example.com/', format: Format.json, } as Parameters[1]) diff --git a/ccip-cli/src/commands/lane-latency.ts b/ccip-cli/src/commands/lane-latency.ts index a2465112..c4d0203e 100644 --- a/ccip-cli/src/commands/lane-latency.ts +++ b/ccip-cli/src/commands/lane-latency.ts @@ -10,7 +10,7 @@ * ccip-cli lane-latency ethereum-mainnet arbitrum-mainnet * * # Use custom API URL - * ccip-cli lane-latency sepolia fuji --api-url https://custom-api.example.com + * ccip-cli lane-latency sepolia fuji --api https://custom-api.example.com * ``` * * @packageDocumentation @@ -49,10 +49,6 @@ export const builder = (yargs: Argv) => demandOption: true, describe: 'Destination network (chainId, selector, or name). Example: arbitrum-mainnet', }) - .option('api-url', { - type: 'string', - describe: 'Custom CCIP API URL (defaults to api.ccip.chain.link)', - }) /** * Handler for the lane-latency command. @@ -85,7 +81,7 @@ export async function getLaneLatencyCmd(ctx: Ctx, argv: Parameters', 'manual-exec '] +export const command = ['manualExec ', 'manual-exec '] export const describe = 'Execute manually pending or failed messages' /** @@ -54,12 +59,12 @@ export const describe = 'Execute manually pending or failed messages' */ export const builder = (yargs: Argv) => yargs - .positional('tx-hash', { + .positional('tx-hash-or-id', { type: 'string', demandOption: true, describe: 'transaction hash of the request (source) message', }) - .check(({ txHash }) => isSupportedTxHash(txHash)) + .check(({ 'tx-hash-or-id': txHashOrId }) => isSupportedTxHash(txHashOrId)) .options({ 'log-index': { type: 'number', @@ -106,17 +111,6 @@ export const builder = (yargs: Argv) => string: true, example: '--receiver-object-ids 0xabc... 0xdef...', }, - 'sender-queue': { - type: 'boolean', - describe: 'Execute all messages in sender queue, starting with the provided tx', - default: false, - }, - 'exec-failed': { - type: 'boolean', - describe: - 'Whether to re-execute failed messages (instead of just non-executed) in sender queue', - implies: 'sender-queue', - }, }) /** @@ -141,10 +135,34 @@ async function manualExec( argv: Awaited['argv']> & GlobalOpts, ) { const { logger } = ctx - // messageId not yet implemented for Solana - const [getChain, tx$] = fetchChainsFromRpcs(ctx, argv, argv.txHash) - const [source, tx] = await tx$ - const request = await selectRequest(await source.getMessagesInTx(tx), 'to know more', argv) + const [getChain, tx$] = fetchChainsFromRpcs(ctx, argv, argv.txHashOrId) + + let source: Chain | undefined, offRamp + let request$: Promise | ReturnType = (async () => { + const [source_, tx] = await tx$ + source = source_ + return selectRequest(await source_.getMessagesInTx(tx), 'to know more', argv) + })() + + let apiClient + if (argv.api !== false && isHexString(argv.txHashOrId, 32)) { + apiClient = CCIPAPIClient.fromUrl(typeof argv.api === 'string' ? argv.api : undefined, ctx) + request$ = Promise.any([request$, apiClient.getMessageById(argv.txHashOrId)]) + } + + let request + try { + request = await request$ + if ('offRampAddress' in request.message) { + offRamp = request.message.offRampAddress + } + } catch (err) { + if (err instanceof AggregateError && err.errors.length === 2) { + if (!(err.errors[0] instanceof CCIPTransactionNotFoundError)) throw err.errors[0] as Error + else if (!(err.errors[1] instanceof CCIPMessageIdNotFoundError)) throw err.errors[1] as Error + } + throw err + } switch (argv.format) { case Format.log: { @@ -161,57 +179,56 @@ async function manualExec( } const dest = await getChain(request.lane.destChainSelector) - const offRamp = await discoverOffRamp(source, dest, request.lane.onRamp, source) + // `--estimate-gas-limit` requires source + if (argv.estimateGasLimit != null && !source) + source = await getChain(request.lane.sourceChainSelector) - const verifications = await dest.getVerifications({ ...argv, offRamp, request }) - switch (argv.format) { - case Format.log: - logger.log('commit =', verifications) - break - case Format.pretty: - logger.info('Commit (dest):') - await prettyVerifications.call(ctx, dest, verifications, request) - break - case Format.json: - logger.info(JSON.stringify(verifications, bigIntReplacer, 2)) - break - } + let inputs + if (source) { + offRamp ??= await discoverOffRamp(source, dest, request.lane.onRamp, source) + const verifications = await dest.getVerifications({ ...argv, offRamp, request }) - if (argv.estimateGasLimit != null) { - let estimated = await estimateReceiveExecution({ - source, - dest, - routerOrRamp: offRamp, - message: request.message, - }) - logger.info('Estimated gasLimit override:', estimated) - estimated += Math.ceil((estimated * argv.estimateGasLimit) / 100) - const origLimit = Number( - 'ccipReceiveGasLimit' in request.message - ? request.message.ccipReceiveGasLimit - : 'gasLimit' in request.message - ? request.message.gasLimit - : request.message.computeUnits, - ) - if (origLimit >= estimated) { - logger.warn( - 'Estimated +', - argv.estimateGasLimit, - '% =', - estimated, - '< original gasLimit =', - origLimit, - '. Leaving unchanged.', + if (argv.estimateGasLimit != null) { + let estimated = await estimateReceiveExecution({ + source, + dest, + routerOrRamp: offRamp, + message: request.message, + }) + logger.info('Estimated gasLimit override:', estimated) + estimated += Math.ceil((estimated * argv.estimateGasLimit) / 100) + const origLimit = Number( + 'ccipReceiveGasLimit' in request.message + ? request.message.ccipReceiveGasLimit + : 'gasLimit' in request.message + ? request.message.gasLimit + : request.message.computeUnits, ) - } else { - argv.gasLimit = estimated + if (origLimit >= estimated) { + logger.warn( + 'Estimated +', + argv.estimateGasLimit, + '% =', + estimated, + '< original gasLimit =', + origLimit, + '. Leaving unchanged.', + ) + } else { + argv.gasLimit = estimated + } } - } - const input = await source.getExecutionInput({ ...argv, request, verifications }) + const input = await source.getExecutionInput({ ...argv, request, verifications }) + inputs = { input, offRamp } + } const [, wallet] = await loadChainWallet(dest, argv) - const receipt = await dest.execute({ ...argv, offRamp, input, wallet }) + const receipt = await dest.execute({ + ...argv, + wallet, + ...(inputs ?? { messageId: request.message.messageId }), + }) switch (argv.format) { case Format.log: diff --git a/ccip-cli/src/commands/show.ts b/ccip-cli/src/commands/show.ts index 5df6d762..95b83153 100644 --- a/ccip-cli/src/commands/show.ts +++ b/ccip-cli/src/commands/show.ts @@ -97,25 +97,27 @@ export async function showRequests(ctx: Ctx, argv: Parameters[0] const { logger } = ctx const [getChain, tx$] = fetchChainsFromRpcs(ctx, argv, argv.txHashOrId) - let source: Chain | undefined + let source: Chain | undefined, offRamp let request$ = (async () => { const [source_, tx] = await tx$ source = source_ return selectRequest(await source_.getMessagesInTx(tx), 'to know more', argv) })() - if (argv.api !== false) { + if (argv.api !== false && isHexString(argv.txHashOrId, 32)) { const apiClient = CCIPAPIClient.fromUrl( typeof argv.api === 'string' ? argv.api : undefined, ctx, ) - if (isHexString(argv.txHashOrId, 32)) { - request$ = Promise.any([request$, apiClient.getMessageById(argv.txHashOrId)]) - } + request$ = Promise.any([request$, apiClient.getMessageById(argv.txHashOrId)]) } + let request try { request = await request$ + if ('offRampAddress' in request.message) { + offRamp = request.message.offRampAddress + } } catch (err) { if (err instanceof AggregateError && err.errors.length === 2) { if (!(err.errors[0] instanceof CCIPTransactionNotFoundError)) throw err.errors[0] as Error @@ -124,6 +126,8 @@ export async function showRequests(ctx: Ctx, argv: Parameters[0] throw err } if (!source) { + // source isn't strictly needed when fetching messageId from API, but it may be useful to print + // more information, e.g. request's token symbols try { source = await getChain(request.lane.sourceChainSelector) } catch (err) { @@ -193,7 +197,7 @@ export async function showRequests(ctx: Ctx, argv: Parameters[0] })() const dest = await getChain(request.lane.destChainSelector) - const offRamp = await discoverOffRamp(source, dest, request.lane.onRamp, source) + offRamp ??= await discoverOffRamp(source, dest, request.lane.onRamp, source) let cancelWaitVerifications: (() => void) | undefined const verifications$ = (async () => { diff --git a/ccip-cli/src/index.ts b/ccip-cli/src/index.ts index 4837a19f..90954345 100755 --- a/ccip-cli/src/index.ts +++ b/ccip-cli/src/index.ts @@ -11,7 +11,7 @@ import { Format } from './commands/index.ts' util.inspect.defaultOptions.depth = 6 // print down to tokenAmounts in requests // generate:nofail // `const VERSION = '${require('./package.json').version}-${require('child_process').execSync('git rev-parse --short HEAD').toString().trim()}'` -const VERSION = '1.0.0-de8f90f' +const VERSION = '1.0.0-27ff6e8' // generate:end const globalOpts = { diff --git a/ccip-cli/src/providers/index.ts b/ccip-cli/src/providers/index.ts index 042a9673..a3458af5 100644 --- a/ccip-cli/src/providers/index.ts +++ b/ccip-cli/src/providers/index.ts @@ -7,7 +7,6 @@ import { type ChainTransaction, type EVMChain, type TONChain, - CCIPAPIClient, CCIPChainFamilyUnsupportedError, CCIPRpcNotFoundError, CCIPTransactionNotFoundError, @@ -106,11 +105,7 @@ export function fetchChainsFromRpcs( const chain$ = C.fromUrl(url, { ...ctx, apiClient: - argv.api === false - ? null - : typeof argv.api === 'string' - ? CCIPAPIClient.fromUrl(argv.api) - : undefined, + argv.api === false ? null : typeof argv.api === 'string' ? argv.api : undefined, }) chains$.push(chain$) diff --git a/ccip-sdk/src/api/index.ts b/ccip-sdk/src/api/index.ts index 760dd381..17d7996f 100644 --- a/ccip-sdk/src/api/index.ts +++ b/ccip-sdk/src/api/index.ts @@ -2,23 +2,26 @@ import { memoize } from 'micro-memoize' import type { SetRequired } from 'type-fest' import { + CCIPApiClientNotAvailableError, CCIPHttpError, CCIPLaneNotFoundError, CCIPMessageIdNotFoundError, CCIPMessageNotFoundInTxError, - CCIPNotImplementedError, CCIPTimeoutError, CCIPUnexpectedPaginationError, } from '../errors/index.ts' import { HttpStatus } from '../http-status.ts' +import { decodeMessageV1 } from '../messages.ts' import { decodeMessage } from '../requests.ts' import { type CCIPMessage, type CCIPRequest, type ChainLog, type ExecutionInput, + type Lane, type Logger, type NetworkInfo, + type OffchainTokenData, type WithLogger, CCIPVersion, ChainFamily, @@ -29,11 +32,13 @@ import { bigIntReviver, parseJson } from '../utils.ts' import type { APIErrorResponse, LaneLatencyResponse, + RawExecutionInputsResult, RawLaneLatencyResponse, RawMessageResponse, RawMessagesResponse, RawNetworkInfo, } from './types.ts' +import { calculateManualExecProof } from '../execution.ts' export type { APICCIPRequestMetadata, APIErrorResponse, LaneLatencyResponse } from './types.ts' @@ -46,7 +51,7 @@ export const DEFAULT_TIMEOUT_MS = 30000 /** SDK version string for telemetry header */ // generate:nofail // `export const SDK_VERSION = '${require('./package.json').version}-${require('child_process').execSync('git rev-parse --short HEAD').toString().trim()}'` -export const SDK_VERSION = '1.0.0-de8f90f' +export const SDK_VERSION = '1.0.0-27ff6e8' // generate:end /** SDK telemetry header name */ @@ -134,7 +139,7 @@ export class CCIPAPIClient { static { CCIPAPIClient.fromUrl = memoize( (baseUrl?: string, ctx?: CCIPAPIClientContext) => new CCIPAPIClient(baseUrl, ctx), - { maxArgs: 1 }, + { maxArgs: 1, transformKey: ([baseUrl]) => [baseUrl ?? DEFAULT_API_BASE_URL] }, ) } @@ -144,10 +149,26 @@ export class CCIPAPIClient { * @param ctx - Optional context with logger and custom fetch */ constructor(baseUrl?: string, ctx?: CCIPAPIClientContext) { + if (typeof baseUrl === 'boolean' || (baseUrl as unknown) === null) + throw new CCIPApiClientNotAvailableError({ context: { baseUrl } }) // shouldn't happen this.baseUrl = baseUrl ?? DEFAULT_API_BASE_URL this.logger = ctx?.logger ?? console this.timeoutMs = ctx?.timeoutMs ?? DEFAULT_TIMEOUT_MS this._fetch = ctx?.fetch ?? globalThis.fetch.bind(globalThis) + + this.getMessageById = memoize(this.getMessageById.bind(this), { + async: true, + expires: 4_000, + maxArgs: 1, + maxSize: 100, + }) + + this.getExecutionInput = memoize(this.getExecutionInput.bind(this), { + async: true, + expires: 4_000, + maxArgs: 1, + maxSize: 100, + }) } /** @@ -437,15 +458,109 @@ export class CCIPAPIClient { /** * Fetches the execution input for a given message by id. * @param messageId - The ID of the message to fetch the execution input for. - * @returns Either `{ encodedMessage, verifications }` or `{ message, offchainTokenData, ...proof }`, and offRamp + * @returns Either `{ encodedMessage, verifications }` or `{ message, offchainTokenData, ...proof }`, offRamp and lane */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - getExecutionInput(messageId: string): Promise { - throw new CCIPNotImplementedError(`CCIPAPIClient.getExecutionInput`) - // TODO: fetch (memoized) request with metadata from `getMessageById` - // TODO: if request doesn't contain everything needed (e.g. { + const url = `${this.baseUrl}/v2/messages/${encodeURIComponent(messageId)}/execution-inputs` + + this.logger.debug(`CCIPAPIClient: GET ${url}`) + + const response = await this._fetchWithTimeout(url, 'getExecutionInput') + if (!response.ok) { + // Try to parse structured error response from API + let apiError: APIErrorResponse | undefined + try { + apiError = parseJson(await response.text()) + } catch { + // Response body not JSON, use HTTP status only + } + + // 404 - Message not found + if (response.status === HttpStatus.NOT_FOUND) { + throw new CCIPMessageIdNotFoundError(messageId, { + context: apiError + ? { + apiErrorCode: apiError.error, + apiErrorMessage: apiError.message, + } + : undefined, + }) + } + + // Generic HTTP error for other cases + throw new CCIPHttpError(response.status, response.statusText, { + context: apiError + ? { + apiErrorCode: apiError.error, + apiErrorMessage: apiError.message, + } + : undefined, + }) + } + + const raw = JSON.parse(await response.text(), bigIntReviver) as RawExecutionInputsResult + this.logger.debug('getExecutionInput raw response:', raw) + + const offRamp = raw.offramp + let lane: Lane + if ('encodedMessage' in raw) { + // CCIP 2.0 messages use MessageV1Codec, which is chain-independent serialization + const { + sourceChainSelector, + destChainSelector, + onRampAddress: onRamp, + } = decodeMessageV1(raw.encodedMessage) + return { + sourceChainSelector, + destChainSelector, + onRamp, + offRamp, + version: CCIPVersion.V2_0, + encodedMessage: raw.encodedMessage, + verifications: (raw.ccvData ?? []).map((ccvData, i) => ({ + ccvData, + destAddress: raw.verifierAddresses[i]!, + })), + } + } + + const messagesInBatch = raw.messageBatch.map(decodeMessage) + const message = messagesInBatch.find((message) => message.messageId === messageId)! + if ('onramp' in raw && raw.onramp && raw.version) { + lane = { + sourceChainSelector: raw.sourceChainSelector, + destChainSelector: raw.destChainSelector, + onRamp: raw.onramp, + version: raw.version as CCIPVersion, + } + } else { + ;({ lane } = await this.getMessageById(messageId)) + } + + const proof = calculateManualExecProof(messagesInBatch, lane, messageId, raw.merkleRoot, this) + + const rawMessage = raw.messageBatch.find((message) => message.messageId === messageId)! + const offchainTokenData: OffchainTokenData[] = rawMessage.tokenAmounts.map(() => undefined) + if (rawMessage.usdcData?.status === 'complete') + offchainTokenData[0] = { + _tag: 'usdc', + message: rawMessage.usdcData.message_bytes_hex!, + attestation: rawMessage.usdcData.attestation!, + } + else if (rawMessage.lbtcData?.status === 'NOTARIZATION_STATUS_SESSION_APPROVED') + offchainTokenData[0] = { + _tag: 'lbtc', + message_hash: rawMessage.lbtcData.message_hash!, + attestation: rawMessage.lbtcData.attestation!, + } + + return { + offRamp, + ...lane, + message, + offchainTokenData, + ...proof, + } as ExecutionInput & Lane & { offRamp: string } } /** diff --git a/ccip-sdk/src/api/types.ts b/ccip-sdk/src/api/types.ts index d7cdf588..9b4136d7 100644 --- a/ccip-sdk/src/api/types.ts +++ b/ccip-sdk/src/api/types.ts @@ -182,3 +182,46 @@ export type APICCIPRequestMetadata = { /** Destination network metadata. */ destNetworkInfo: NetworkInfo } + +// ============================================================================ +// GET /v2/messages/${messageId}/execution-inputs search endpoint types +// ============================================================================ + +/** Raw API response from GET /v2/messages/:messageId/execution-inputs */ +export type RawExecutionInputsResult = { + offramp: string +} & ( + | { + onramp: string + sourceChainSelector: bigint + destChainSelector: bigint + version: string + } + | object +) & + ( + | { + merkleRoot?: string + messageBatch: { + [key: string]: unknown + messageId: string + tokenAmounts: { token: string; amount: string }[] + usdcData?: { + status: 'pending_confirmations' | 'complete' + attestation?: string + message_bytes_hex?: string + } + lbtcData?: { + status: 'NOTARIZATION_STATUS_SESSION_APPROVED' | 'NOTARIZATION_STATUS_SESSION_PENDING' + attestation?: string + message_hash?: string + } + }[] + } + | { + encodedMessage: string + verificationComplete?: boolean + ccvData?: string[] + verifierAddresses: string[] + } + ) diff --git a/ccip-sdk/src/chain.ts b/ccip-sdk/src/chain.ts index 25eba802..6c30d131 100644 --- a/ccip-sdk/src/chain.ts +++ b/ccip-sdk/src/chain.ts @@ -95,12 +95,13 @@ export type ChainContext = WithLogger & { * CCIP API client instance for lane information queries. * * - `undefined` (default): Creates CCIPAPIClient with {@link DEFAULT_API_BASE_URL} + * - `string`: Creates CCIPAPIClient with provided URL * - `CCIPAPIClient`: Uses provided instance (allows custom URL, fetch, etc.) * - `null`: Disables API client entirely (getLaneLatency() will throw) * * Default: `undefined` (auto-create with production endpoint) */ - apiClient?: CCIPAPIClient | null + apiClient?: CCIPAPIClient | string | null /** * Retry configuration for API fallback operations. @@ -386,11 +387,11 @@ export abstract class Chain { if (apiClient === null) { this.apiClient = null // Explicit opt-out this.apiRetryConfig = null // No retry config needed without API client - } else if (apiClient !== undefined) { + } else if (apiClient && typeof apiClient !== 'string') { this.apiClient = apiClient // Use provided instance this.apiRetryConfig = { ...DEFAULT_API_RETRY_CONFIG, ...apiRetryConfig } } else { - this.apiClient = CCIPAPIClient.fromUrl(undefined, { logger }) // Default + this.apiClient = CCIPAPIClient.fromUrl(apiClient, ctx) // default=undefined or provided string as URL this.apiRetryConfig = { ...DEFAULT_API_RETRY_CONFIG, ...apiRetryConfig } } } diff --git a/ccip-sdk/src/errors/codes.ts b/ccip-sdk/src/errors/codes.ts index 8794f198..85263f85 100644 --- a/ccip-sdk/src/errors/codes.ts +++ b/ccip-sdk/src/errors/codes.ts @@ -27,6 +27,7 @@ export const CCIPErrorCode = { MESSAGE_NOT_IN_BATCH: 'MESSAGE_NOT_IN_BATCH', MESSAGE_CHAIN_MISMATCH: 'MESSAGE_CHAIN_MISMATCH', MESSAGE_RETRIEVAL_FAILED: 'MESSAGE_RETRIEVAL_FAILED', + MESSAGE_NOT_VERIFIED_YET: 'MESSAGE_NOT_VERIFIED_YET', // Lane & Routing OFFRAMP_NOT_FOUND: 'OFFRAMP_NOT_FOUND', @@ -109,7 +110,6 @@ export const CCIPErrorCode = { LBTC_ATTESTATION_NOT_FOUND: 'LBTC_ATTESTATION_NOT_FOUND', LBTC_ATTESTATION_NOT_APPROVED: 'LBTC_ATTESTATION_NOT_APPROVED', CCTP_DECODE_FAILED: 'CCTP_DECODE_FAILED', - CCTP_MULTIPLE_EVENTS: 'CCTP_MULTIPLE_EVENTS', // Log & Event LOG_DATA_INVALID: 'LOG_DATA_INVALID', @@ -178,6 +178,7 @@ export const TRANSIENT_ERROR_CODES = new Set([ CCIPErrorCode.TRANSACTION_NOT_FINALIZED, CCIPErrorCode.MESSAGE_ID_NOT_FOUND, CCIPErrorCode.MESSAGE_BATCH_INCOMPLETE, + CCIPErrorCode.MESSAGE_NOT_VERIFIED_YET, CCIPErrorCode.COMMIT_NOT_FOUND, CCIPErrorCode.RECEIPT_NOT_FOUND, CCIPErrorCode.USDC_ATTESTATION_FAILED, diff --git a/ccip-sdk/src/errors/index.ts b/ccip-sdk/src/errors/index.ts index fd2959c3..fc2c994f 100644 --- a/ccip-sdk/src/errors/index.ts +++ b/ccip-sdk/src/errors/index.ts @@ -31,6 +31,7 @@ export { CCIPMessageInvalidError, CCIPMessageNotFoundInTxError, CCIPMessageNotInBatchError, + CCIPMessageNotVerifiedYetError, CCIPMessageRetrievalError, } from './specialized.ts' @@ -100,7 +101,6 @@ export { export { CCIPBlockTimeNotFoundError, CCIPCctpDecodeError, - CCIPCctpMultipleEventsError, CCIPExecutionReportChainMismatchError, CCIPExecutionStateInvalidError, CCIPExtraArgsLengthInvalidError, diff --git a/ccip-sdk/src/errors/recovery.ts b/ccip-sdk/src/errors/recovery.ts index fe79555e..c8d380b9 100644 --- a/ccip-sdk/src/errors/recovery.ts +++ b/ccip-sdk/src/errors/recovery.ts @@ -37,6 +37,7 @@ export const DEFAULT_RECOVERY_HINTS: Partial> = { 'Verify you are using the correct destination chain. Check that sourceChainSelector and destChainSelector match your lane.', MESSAGE_RETRIEVAL_FAILED: 'Both API and RPC failed to retrieve the message. Verify the transaction hash is correct and the transaction is confirmed. Check RPC and network connectivity.', + MESSAGE_NOT_VERIFIED_YET: 'Message not yet committed or verified; wait and retry.', MESSAGE_VERSION_INVALID: 'Ensure the source chain onRamp uses CCIP v1.6. Older message versions are not compatible with this destination.', @@ -129,7 +130,6 @@ export const DEFAULT_RECOVERY_HINTS: Partial> = { LBTC_ATTESTATION_NOT_APPROVED: 'LBTC attestation not yet approved. Wait for notarization.', CCTP_DECODE_FAILED: 'Ensure the transaction contains a valid CCTP MessageSent event. Verify this is a USDC transfer.', - CCTP_MULTIPLE_EVENTS: 'Multiple CCTP events found. Expected only one per transaction.', LOG_DATA_INVALID: 'Ensure the log data is a valid hex string from a transaction receipt.', LOG_DATA_MISSING: 'Log data is missing or not a string.', diff --git a/ccip-sdk/src/errors/specialized.ts b/ccip-sdk/src/errors/specialized.ts index 0a6066bb..bc3d39c6 100644 --- a/ccip-sdk/src/errors/specialized.ts +++ b/ccip-sdk/src/errors/specialized.ts @@ -380,6 +380,41 @@ export class CCIPMessageRetrievalError extends CCIPError { } } +/** + * Thrown when a CCIP message has not been verified yet by the offchain system. + * This is a transient error - the message needs time to be verified before execution input can be retrieved. + * + * @example + * ```typescript + * try { + * const execInput = await api.getExecutionInput(messageId) + * } catch (error) { + * if (error instanceof CCIPMessageNotVerifiedYetError) { + * console.log(`Message not verified yet, retry after ${error.retryAfterMs}ms`) + * await sleep(error.retryAfterMs ?? 15000) + * // Retry the request + * } + * } + * ``` + */ +export class CCIPMessageNotVerifiedYetError extends CCIPError { + override readonly name = 'CCIPMessageNotVerifiedYetError' + /** Creates a message not verified yet error. */ + constructor(messageId: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.MESSAGE_NOT_VERIFIED_YET, + `Message ${messageId} has not been verified yet. The offchain verification system needs time to process this message.`, + { + ...options, + isTransient: true, + retryAfterMs: 15000, + recovery: 'Wait for the message to be verified by the offchain system, then retry.', + context: { ...options?.context, messageId }, + }, + ) + } +} + // Lane & Routing /** @@ -3238,36 +3273,6 @@ export class CCIPSolanaLaneVersionUnsupportedError extends CCIPError { } } -/** - * Thrown when multiple CCTP events found in transaction. - * - * @example - * ```typescript - * try { - * const cctpData = await chain.getOffchainTokenData(request) - * } catch (error) { - * if (error instanceof CCIPCctpMultipleEventsError) { - * console.log(`Found ${error.context.count} events, expected 1`) - * } - * } - * ``` - */ -export class CCIPCctpMultipleEventsError extends CCIPError { - override readonly name = 'CCIPCctpMultipleEventsError' - /** Creates a CCTP multiple events error. */ - constructor(count: number, txSignature: string, options?: CCIPErrorOptions) { - super( - CCIPErrorCode.CCTP_MULTIPLE_EVENTS, - `Expected only 1 CcipCctpMessageSentEvent, found ${count} in transaction ${txSignature}`, - { - ...options, - isTransient: false, - context: { ...options?.context, count, txSignature }, - }, - ) - } -} - /** * Thrown when compute units exceed limit. * diff --git a/ccip-sdk/src/evm/fork.test.ts b/ccip-sdk/src/evm/fork.test.ts index f9d42452..368b1dcc 100644 --- a/ccip-sdk/src/evm/fork.test.ts +++ b/ccip-sdk/src/evm/fork.test.ts @@ -69,6 +69,9 @@ const EXEC_TEST_MSG = FUJI_TO_SEPOLIA.find( )! const SOURCE_TX_HASH = EXEC_TEST_MSG.txHash const MESSAGE_ID = EXEC_TEST_MSG.messageId +const V2_API_EXEC_MSG = { + messageId: '0x886836ec7b9adc834d45d70c4cbd05f2623f56add4e15a96e12758c941452155', +} // Second failed v1.6 message for getExecutionInput test (different from above so both can execute) const EXEC_INPUT_TEST_MSG = FUJI_TO_SEPOLIA.find( @@ -801,5 +804,41 @@ describe('EVM Fork Tests', { skip, timeout: 180_000 }, () => { 'execution state should be Success', ) }) + + it('should execute a v2.0 message via API-driven path (Fuji -> Sepolia)', async () => { + assert.ok(sepoliaInstance, 'sepolia anvil should be running') + + // Create a sepolia chain with staging API client (execution-inputs endpoint) + const stagingApi = new CCIPAPIClient('https://api.ccip.cldev.cloud', { logger: testLogger }) + const sepoliaProvider = new JsonRpcProvider( + `http://${sepoliaInstance.host}:${sepoliaInstance.port}`, + ) + const sepoliaWithApi = await EVMChain.fromProvider(sepoliaProvider, { + apiClient: stagingApi, + logger: testLogger, + }) + const w = new Wallet(ANVIL_PRIVATE_KEY, sepoliaProvider) + + // Execute via messageId only — triggers API-driven path + const execution = await sepoliaWithApi.execute({ + messageId: V2_API_EXEC_MSG.messageId, + wallet: w, + gasLimit: 500_000, + }) + + console.log( + ` executed ${V2_API_EXEC_MSG.messageId.slice(0, 10)}… via API → state=${execution.receipt.state}`, + ) + assert.equal( + execution.receipt.messageId, + V2_API_EXEC_MSG.messageId, + 'receipt messageId should match', + ) + assert.ok(execution.log.transactionHash, 'should have tx hash') + assert.ok(execution.timestamp > 0, 'should have timestamp') + assert.equal(execution.receipt.state, ExecutionState.Success) + + sepoliaWithApi.destroy?.() + }) }) }) diff --git a/ccip-sdk/src/evm/index.ts b/ccip-sdk/src/evm/index.ts index 5067f060..23a60bb5 100644 --- a/ccip-sdk/src/evm/index.ts +++ b/ccip-sdk/src/evm/index.ts @@ -46,7 +46,6 @@ import { CCIPExecTxRevertedError, CCIPHasherVersionUnsupportedError, CCIPLogDataInvalidError, - CCIPNotImplementedError, CCIPSourceChainUnsupportedError, CCIPTokenNotConfiguredError, CCIPTokenNotFoundError, @@ -117,19 +116,11 @@ import { import { estimateExecGas } from './gas.ts' import { getV12LeafHasher, getV16LeafHasher } from './hasher.ts' import { getEvmLogs } from './logs.ts' -import { - type CCIPMessage_V1_6_EVM, - type CCIPMessage_V2_0, - type CleanAddressable, - type MessageV1, - type TokenTransferV1, - decodeMessageV1, -} from './messages.ts' -export { decodeMessageV1 } -export type { MessageV1, TokenTransferV1 } +import type { CCIPMessage_V1_6_EVM, CCIPMessage_V2_0, CleanAddressable } from './messages.ts' import { encodeEVMOffchainTokenData } from './offchain.ts' import { buildMessageForDest, decodeMessage, getMessagesInBatch } from '../requests.ts' import { type UnsignedEVMTx, resultToObject } from './types.ts' +import { decodeMessageV1 } from '../messages.ts' export type { UnsignedEVMTx } /** typeguard for ethers Signer interface (used for `wallet`s) */ @@ -1163,6 +1154,13 @@ export class EVMChain extends Chain { const txGasLimit = await contract.executeSingleMessage.estimateGas( { ...message, + onRampAddress: zeroPadValue(getAddressBytes(message.onRampAddress), 32), + sender: zeroPadValue(getAddressBytes(message.sender), 32), + tokenTransfer: message.tokenTransfer.map((ta) => ({ + ...ta, + sourcePoolAddress: zeroPadValue(getAddressBytes(ta.sourcePoolAddress), 32), + sourceTokenAddress: zeroPadValue(getAddressBytes(ta.sourceTokenAddress), 32), + })), executionGasLimit: BigInt(message.executionGasLimit), ccipReceiveGasLimit: BigInt(message.ccipReceiveGasLimit), finality: BigInt(message.finality), @@ -1289,6 +1287,22 @@ export class EVMChain extends Chain { default: throw new CCIPVersionUnsupportedError(version) } + + /* Executing a message for the first time has some hard try/catches on-chain + * so we need to ensure some lower-bounds gasLimits */ + let gasLimit = await this.provider.estimateGas(manualExecTx) + if ( + 'gasLimit' in input.message && + input.message.gasLimit && + gasLimit < input.message.gasLimit + 100000n + ) + // if message requested gasLimit, ensure execution more than 100k above requested, otherwise it's clearly a try/catch fail + gasLimit = BigInt(input.message.gasLimit) + 200000n + else if ('gasLimit' in input.message && !input.message.gasLimit && gasLimit < 240000n) + // if message didn't request gasLimit, ensure execution gasLimit is above 240k (empiric) + gasLimit = 240000n + manualExecTx.gasLimit = gasLimit + return { family: ChainFamily.EVM, transactions: [manualExecTx] } } @@ -1315,7 +1329,8 @@ export class EVMChain extends Chain { const response = await submitTransaction(wallet, populatedTx, this.provider) this.logger.debug('manuallyExecute =>', response.hash) - const receipt = await response.wait(1, 60_000) + let receipt = await response.wait(0) + if (!receipt) receipt = await response.wait(1, 240_000) if (!receipt?.hash) throw new CCIPExecTxNotConfirmedError(response.hash) if (!receipt.status) throw new CCIPExecTxRevertedError(response.hash) const tx = await this.getTransaction(receipt) @@ -1553,15 +1568,13 @@ export class EVMChain extends Chain { ): Promise { const { offRamp, request } = opts if (request.lane.version >= CCIPVersion.V2_0) { - const message = request.message as CCIPMessage_V2_0 - if (!message.encodedMessage) - throw new CCIPNotImplementedError(`CCIPAPIClient getMessageById v2 encodedMessage`) + const { encodedMessage } = request.message as CCIPMessage_V2_0 const contract = new Contract( offRamp, interfaces.OffRamp_v2_0, this.provider, ) as unknown as TypedContract - const ccvs = await contract.getCCVsForMessage(message.encodedMessage) + const ccvs = await contract.getCCVsForMessage(encodedMessage) const [requiredCCVs, optionalCCVs, optionalThreshold] = ccvs.map( resultToObject, ) as unknown as CleanAddressable @@ -1575,20 +1588,24 @@ export class EVMChain extends Chain { const apiRes = await this.apiClient.getMessageById(request.message.messageId) if ('verifiers' in apiRes.message) { const verifiers = apiRes.message.verifiers as { - items: { + items?: { destAddress: string sourceAddress: string - verification: { data: string; timestamp: string } + verification?: { data: string; timestamp: string } }[] } return { verificationPolicy, - verifications: verifiers.items.map((item) => ({ - destAddress: item.destAddress, - sourceAddress: item.sourceAddress, - ccvData: item.verification.data, - timestamp: new Date(item.verification.timestamp).getTime() / 1e3, - })), + verifications: (verifiers.items ?? []) + .filter((item) => item.verification?.data) + .map((item) => ({ + destAddress: item.destAddress, + sourceAddress: item.sourceAddress, + ccvData: item.verification!.data, + ...(!!item.verification?.timestamp && { + timestamp: new Date(item.verification.timestamp).getTime() / 1e3, + }), + })), } } } diff --git a/ccip-sdk/src/evm/messages.test.ts b/ccip-sdk/src/evm/messages.test.ts index 7b5264a3..840e1fc2 100644 --- a/ccip-sdk/src/evm/messages.test.ts +++ b/ccip-sdk/src/evm/messages.test.ts @@ -4,7 +4,8 @@ import { describe, it } from 'node:test' import { concat, toBeHex } from 'ethers' import '../index.ts' // Import to ensure all chains are registered for decodeAddress -import { type SourceTokenData, decodeMessageV1, parseSourceTokenData } from './messages.ts' +import { type SourceTokenData, parseSourceTokenData } from './messages.ts' +import { decodeMessageV1 } from '../messages.ts' describe('encode/parseSourceTokenData', () => { const decoded: SourceTokenData = { diff --git a/ccip-sdk/src/evm/messages.ts b/ccip-sdk/src/evm/messages.ts index ad755630..b79c761e 100644 --- a/ccip-sdk/src/evm/messages.ts +++ b/ccip-sdk/src/evm/messages.ts @@ -3,25 +3,16 @@ import type { AbiParametersToPrimitiveTypes, ExtractAbiEvent, } from 'abitype' -import { - type Addressable, - type BytesLike, - type Result, - dataSlice, - hexlify, - toBigInt, - toNumber, -} from 'ethers' +import type { Addressable, Result } from 'ethers' import type { Simplify } from 'type-fest' -import { CCIPMessageDecodeError } from '../errors/index.ts' import type { EVMExtraArgsV2 } from '../extra-args.ts' -import type { CCIPVersion, ChainFamily, MergeArrayElements } from '../types.ts' -import { decodeAddress, getDataBytes, networkInfo } from '../utils.ts' +import type { CCIPVersion, MergeArrayElements } from '../types.ts' import type EVM2EVMOnRamp_1_5_ABI from './abi/OnRamp_1_5.ts' import type OnRamp_1_6_ABI from './abi/OnRamp_1_6.ts' import type OnRamp_2_0_ABI from './abi/OnRamp_2_0.ts' import { defaultAbiCoder } from './const.ts' +import type { MessageV1 } from '../messages.ts' /** Utility type that cleans up address types to just `string`. */ export type CleanAddressable = T extends string | Addressable @@ -32,279 +23,6 @@ export type CleanAddressable = T extends string | Addressable ? { readonly [K in keyof T]: CleanAddressable } : T -/** Token transfer in MessageV1 format. */ -export type TokenTransferV1 = { - amount: bigint - sourcePoolAddress: string - sourceTokenAddress: string - destTokenAddress: string - tokenReceiver: string - extraData: string -} - -/** MessageV1 struct matching the Solidity MessageV1Codec format. */ -export type MessageV1 = { - sourceChainSelector: bigint - destChainSelector: bigint - messageNumber: bigint - executionGasLimit: number - ccipReceiveGasLimit: number - finality: number - ccvAndExecutorHash: string - onRampAddress: string - offRampAddress: string - sender: string - receiver: string - destBlob: string - tokenTransfer: readonly TokenTransferV1[] - data: string -} - -/** - * Decodes a TokenTransferV1 from bytes. - * @param encoded - The encoded bytes. - * @param offset - The starting offset. - * @param sourceFamily - The source chain family for source addresses. - * @param destFamily - The destination chain family for dest addresses. - * @returns The decoded token transfer and the new offset. - */ -function decodeTokenTransferV1( - encoded: Uint8Array, - offset: number, - sourceFamily: ChainFamily, - destFamily: ChainFamily, -): { tokenTransfer: TokenTransferV1; newOffset: number } { - // version (1 byte) - if (offset >= encoded.length) throw new CCIPMessageDecodeError('TOKEN_TRANSFER_VERSION') - const version = encoded[offset++]! - if (version !== 1) throw new CCIPMessageDecodeError(`Invalid encoding version: ${version}`) - - // amount (32 bytes) - if (offset + 32 > encoded.length) throw new CCIPMessageDecodeError('TOKEN_TRANSFER_AMOUNT') - const amount = toBigInt(dataSlice(encoded, offset, offset + 32)) - offset += 32 - - // sourcePoolAddressLength and sourcePoolAddress - if (offset >= encoded.length) { - throw new CCIPMessageDecodeError('TOKEN_TRANSFER_SOURCE_POOL_LENGTH') - } - const sourcePoolAddressLength = encoded[offset++]! - if (offset + sourcePoolAddressLength > encoded.length) { - throw new CCIPMessageDecodeError('TOKEN_TRANSFER_SOURCE_POOL_CONTENT') - } - const sourcePoolAddress = decodeAddress( - dataSlice(encoded, offset, offset + sourcePoolAddressLength), - sourceFamily, - ) - offset += sourcePoolAddressLength - - // sourceTokenAddressLength and sourceTokenAddress - if (offset >= encoded.length) { - throw new CCIPMessageDecodeError('TOKEN_TRANSFER_SOURCE_TOKEN_LENGTH') - } - const sourceTokenAddressLength = encoded[offset++]! - if (offset + sourceTokenAddressLength > encoded.length) { - throw new CCIPMessageDecodeError('TOKEN_TRANSFER_SOURCE_TOKEN_CONTENT') - } - const sourceTokenAddress = decodeAddress( - dataSlice(encoded, offset, offset + sourceTokenAddressLength), - sourceFamily, - ) - offset += sourceTokenAddressLength - - // destTokenAddressLength and destTokenAddress - if (offset >= encoded.length) { - throw new CCIPMessageDecodeError('TOKEN_TRANSFER_DEST_TOKEN_LENGTH') - } - const destTokenAddressLength = encoded[offset++]! - if (offset + destTokenAddressLength > encoded.length) { - throw new CCIPMessageDecodeError('TOKEN_TRANSFER_DEST_TOKEN_CONTENT') - } - const destTokenAddress = decodeAddress( - dataSlice(encoded, offset, offset + destTokenAddressLength), - destFamily, - ) - offset += destTokenAddressLength - - // tokenReceiverLength and tokenReceiver - if (offset >= encoded.length) { - throw new CCIPMessageDecodeError('TOKEN_TRANSFER_TOKEN_RECEIVER_LENGTH') - } - const tokenReceiverLength = encoded[offset++]! - if (offset + tokenReceiverLength > encoded.length) { - throw new CCIPMessageDecodeError('TOKEN_TRANSFER_TOKEN_RECEIVER_CONTENT') - } - const tokenReceiver = decodeAddress( - dataSlice(encoded, offset, offset + tokenReceiverLength), - destFamily, - ) - offset += tokenReceiverLength - - // extraDataLength and extraData - if (offset + 2 > encoded.length) { - throw new CCIPMessageDecodeError('TOKEN_TRANSFER_EXTRA_DATA_LENGTH') - } - const extraDataLength = toNumber(dataSlice(encoded, offset, offset + 2)) - offset += 2 - if (offset + extraDataLength > encoded.length) { - throw new CCIPMessageDecodeError('TOKEN_TRANSFER_EXTRA_DATA_CONTENT') - } - const extraData = hexlify(dataSlice(encoded, offset, offset + extraDataLength)) - offset += extraDataLength - - return { - tokenTransfer: { - amount, - sourcePoolAddress, - sourceTokenAddress, - destTokenAddress, - tokenReceiver, - extraData, - }, - newOffset: offset, - } -} - -/** - * Decodes a MessageV1 from bytes following the v1 protocol format. - * @param encodedMessage - The encoded message bytes to decode. - * @returns The decoded MessageV1 struct. - */ -export function decodeMessageV1(encodedMessage: BytesLike): MessageV1 { - const MESSAGE_V1_BASE_SIZE = 77 - const encoded = getDataBytes(encodedMessage) - - if (encoded.length < MESSAGE_V1_BASE_SIZE) throw new CCIPMessageDecodeError('MESSAGE_MIN_SIZE') - - const version = encoded[0]! - if (version !== 1) throw new CCIPMessageDecodeError(`Invalid encoding version: ${version}`) - - // sourceChainSelector (8 bytes, big endian) - const sourceChainSelector = toBigInt(dataSlice(encoded, 1, 9)) - - // destChainSelector (8 bytes, big endian) - const destChainSelector = toBigInt(dataSlice(encoded, 9, 17)) - - // Get chain families for address decoding - const sourceNetworkInfo = networkInfo(sourceChainSelector) - const destNetworkInfo = networkInfo(destChainSelector) - const sourceFamily = sourceNetworkInfo.family - const destFamily = destNetworkInfo.family - - // messageNumber (8 bytes, big endian) - const messageNumber = toBigInt(dataSlice(encoded, 17, 25)) - - // executionGasLimit (4 bytes, big endian) - const executionGasLimit = toNumber(dataSlice(encoded, 25, 29)) - - // ccipReceiveGasLimit (4 bytes, big endian) - const ccipReceiveGasLimit = toNumber(dataSlice(encoded, 29, 33)) - - // finality (2 bytes, big endian) - const finality = toNumber(dataSlice(encoded, 33, 35)) - - // ccvAndExecutorHash (32 bytes) - const ccvAndExecutorHash = hexlify(dataSlice(encoded, 35, 67)) - - // onRampAddressLength and onRampAddress - let offset = 67 - if (offset >= encoded.length) throw new CCIPMessageDecodeError('MESSAGE_ONRAMP_ADDRESS_LENGTH') - const onRampAddressLength = encoded[offset++]! - if (offset + onRampAddressLength > encoded.length) { - throw new CCIPMessageDecodeError('MESSAGE_ONRAMP_ADDRESS_CONTENT') - } - const onRampAddress = decodeAddress( - dataSlice(encoded, offset, offset + onRampAddressLength), - sourceFamily, - ) - offset += onRampAddressLength - - // offRampAddressLength and offRampAddress - if (offset >= encoded.length) throw new CCIPMessageDecodeError('MESSAGE_OFFRAMP_ADDRESS_LENGTH') - const offRampAddressLength = encoded[offset++]! - if (offset + offRampAddressLength > encoded.length) { - throw new CCIPMessageDecodeError('MESSAGE_OFFRAMP_ADDRESS_CONTENT') - } - const offRampAddress = decodeAddress( - dataSlice(encoded, offset, offset + offRampAddressLength), - destFamily, - ) - offset += offRampAddressLength - - // senderLength and sender - if (offset >= encoded.length) throw new CCIPMessageDecodeError('MESSAGE_SENDER_LENGTH') - const senderLength = encoded[offset++]! - if (offset + senderLength > encoded.length) { - throw new CCIPMessageDecodeError('MESSAGE_SENDER_CONTENT') - } - const sender = decodeAddress(dataSlice(encoded, offset, offset + senderLength), sourceFamily) - offset += senderLength - - // receiverLength and receiver - if (offset >= encoded.length) throw new CCIPMessageDecodeError('MESSAGE_RECEIVER_LENGTH') - const receiverLength = encoded[offset++]! - if (offset + receiverLength > encoded.length) { - throw new CCIPMessageDecodeError('MESSAGE_RECEIVER_CONTENT') - } - const receiver = decodeAddress(dataSlice(encoded, offset, offset + receiverLength), destFamily) - offset += receiverLength - - // destBlobLength and destBlob - if (offset + 2 > encoded.length) throw new CCIPMessageDecodeError('MESSAGE_DEST_BLOB_LENGTH') - const destBlobLength = toNumber(dataSlice(encoded, offset, offset + 2)) - offset += 2 - if (offset + destBlobLength > encoded.length) { - throw new CCIPMessageDecodeError('MESSAGE_DEST_BLOB_CONTENT') - } - const destBlob = hexlify(dataSlice(encoded, offset, offset + destBlobLength)) - offset += destBlobLength - - // tokenTransferLength and tokenTransfer - if (offset + 2 > encoded.length) throw new CCIPMessageDecodeError('MESSAGE_TOKEN_TRANSFER_LENGTH') - const tokenTransferLength = toNumber(dataSlice(encoded, offset, offset + 2)) - offset += 2 - - // Decode token transfer, which is either 0 or 1 - const tokenTransfer: TokenTransferV1[] = [] - if (tokenTransferLength > 0) { - const expectedEnd = offset + tokenTransferLength - const result = decodeTokenTransferV1(encoded, offset, sourceFamily, destFamily) - tokenTransfer.push(result.tokenTransfer) - offset = result.newOffset - if (offset !== expectedEnd) throw new CCIPMessageDecodeError('MESSAGE_TOKEN_TRANSFER_CONTENT') - } - - // dataLength and data - if (offset + 2 > encoded.length) throw new CCIPMessageDecodeError('MESSAGE_DATA_LENGTH') - const dataLength = toNumber(dataSlice(encoded, offset, offset + 2)) - offset += 2 - if (offset + dataLength > encoded.length) { - throw new CCIPMessageDecodeError('MESSAGE_DATA_CONTENT') - } - const data = hexlify(dataSlice(encoded, offset, offset + dataLength)) - offset += dataLength - - // Ensure we've consumed all bytes - if (offset !== encoded.length) throw new CCIPMessageDecodeError('MESSAGE_FINAL_OFFSET') - - return { - sourceChainSelector, - destChainSelector, - messageNumber, - executionGasLimit, - ccipReceiveGasLimit, - finality, - ccvAndExecutorHash, - onRampAddress, - offRampAddress, - sender, - receiver, - destBlob, - tokenTransfer, - data, - } -} - // v1.2-v1.5 Message () type EVM2AnyMessageRequested = CleanAddressable< AbiParametersToPrimitiveTypes< diff --git a/ccip-sdk/src/gas.ts b/ccip-sdk/src/gas.ts index cb24558b..998bad94 100644 --- a/ccip-sdk/src/gas.ts +++ b/ccip-sdk/src/gas.ts @@ -20,6 +20,10 @@ export type EstimateMessageInput = { messageId?: string /** optional sender: zero address will be used if omitted */ sender?: string + /** optional onRampAddress */ + onRampAddress?: string + /** optional offRampAddress */ + offRampAddress?: string /** optional data: zero bytes will be used if omitted */ data?: BytesLike /** @@ -89,26 +93,29 @@ export async function estimateReceiveExecution({ if (!dest.estimateReceiveExecution) throw new CCIPMethodUnsupportedError(dest.constructor.name, 'estimateReceiveExecution') - let onRamp, offRamp: string - try { - const tnv = await source.typeAndVersion(routerOrRamp) - if (!tnv[0].includes('OnRamp')) - onRamp = await source.getOnRampForRouter(routerOrRamp, dest.network.chainSelector) - else onRamp = routerOrRamp - offRamp = await discoverOffRamp(source, dest, onRamp, source) - } catch (sourceErr) { + let onRamp: string, offRamp: string + if (message.onRampAddress) onRamp = message.onRampAddress + if (message.offRampAddress) offRamp = message.offRampAddress + if (!onRamp! || !offRamp!) try { - const tnv = await dest.typeAndVersion(routerOrRamp) - if (!tnv[0].includes('OffRamp')) - throw new CCIPContractTypeInvalidError(routerOrRamp, tnv[2], ['OffRamp']) - offRamp = routerOrRamp - const onRamps = await dest.getOnRampsForOffRamp(offRamp, source.network.chainSelector) - if (!onRamps.length) throw new CCIPOnRampRequiredError() - onRamp = onRamps[onRamps.length - 1]! - } catch { - throw sourceErr // re-throw original error + const tnv = await source.typeAndVersion(routerOrRamp) + if (!tnv[0].includes('OnRamp')) + onRamp = await source.getOnRampForRouter(routerOrRamp, dest.network.chainSelector) + else onRamp = routerOrRamp + offRamp = await discoverOffRamp(source, dest, onRamp, source) + } catch (sourceErr) { + try { + const tnv = await dest.typeAndVersion(routerOrRamp) + if (!tnv[0].includes('OffRamp')) + throw new CCIPContractTypeInvalidError(routerOrRamp, tnv[2], ['OffRamp']) + offRamp = routerOrRamp + const onRamps = await dest.getOnRampsForOffRamp(offRamp, source.network.chainSelector) + if (!onRamps.length) throw new CCIPOnRampRequiredError() + onRamp = onRamps[onRamps.length - 1]! + } catch { + throw sourceErr // re-throw original error + } } - } const destTokenAmounts = await Promise.all( (message.tokenAmounts ?? []).map(async (ta) => { diff --git a/ccip-sdk/src/messages.ts b/ccip-sdk/src/messages.ts new file mode 100644 index 00000000..ff415d75 --- /dev/null +++ b/ccip-sdk/src/messages.ts @@ -0,0 +1,278 @@ +import { type BytesLike, dataSlice, hexlify, toBigInt, toNumber } from 'ethers' + +import { CCIPMessageDecodeError } from './errors/index.ts' +import type { ChainFamily } from './types.ts' +import { decodeAddress, getDataBytes, networkInfo } from './utils.ts' + +/** Token transfer in MessageV1 format. */ +export type TokenTransferV1 = { + amount: bigint + sourcePoolAddress: string + sourceTokenAddress: string + destTokenAddress: string + tokenReceiver: string + extraData: string +} + +/** MessageV1 struct matching the Solidity MessageV1Codec format. */ +export type MessageV1 = { + sourceChainSelector: bigint + destChainSelector: bigint + messageNumber: bigint + executionGasLimit: number + ccipReceiveGasLimit: number + finality: number + ccvAndExecutorHash: string + onRampAddress: string + offRampAddress: string + sender: string + receiver: string + destBlob: string + tokenTransfer: readonly TokenTransferV1[] + data: string +} + +/** + * Decodes a TokenTransferV1 from bytes. + * @param encoded - The encoded bytes. + * @param offset - The starting offset. + * @param sourceFamily - The source chain family for source addresses. + * @param destFamily - The destination chain family for dest addresses. + * @returns The decoded token transfer and the new offset. + */ +function decodeTokenTransferV1( + encoded: Uint8Array, + offset: number, + sourceFamily: ChainFamily, + destFamily: ChainFamily, +): { tokenTransfer: TokenTransferV1; newOffset: number } { + // version (1 byte) + if (offset >= encoded.length) throw new CCIPMessageDecodeError('TOKEN_TRANSFER_VERSION') + const version = encoded[offset++]! + if (version !== 1) throw new CCIPMessageDecodeError(`Invalid encoding version: ${version}`) + + // amount (32 bytes) + if (offset + 32 > encoded.length) throw new CCIPMessageDecodeError('TOKEN_TRANSFER_AMOUNT') + const amount = toBigInt(dataSlice(encoded, offset, offset + 32)) + offset += 32 + + // sourcePoolAddressLength and sourcePoolAddress + if (offset >= encoded.length) { + throw new CCIPMessageDecodeError('TOKEN_TRANSFER_SOURCE_POOL_LENGTH') + } + const sourcePoolAddressLength = encoded[offset++]! + if (offset + sourcePoolAddressLength > encoded.length) { + throw new CCIPMessageDecodeError('TOKEN_TRANSFER_SOURCE_POOL_CONTENT') + } + const sourcePoolAddress = decodeAddress( + dataSlice(encoded, offset, offset + sourcePoolAddressLength), + sourceFamily, + ) + offset += sourcePoolAddressLength + + // sourceTokenAddressLength and sourceTokenAddress + if (offset >= encoded.length) { + throw new CCIPMessageDecodeError('TOKEN_TRANSFER_SOURCE_TOKEN_LENGTH') + } + const sourceTokenAddressLength = encoded[offset++]! + if (offset + sourceTokenAddressLength > encoded.length) { + throw new CCIPMessageDecodeError('TOKEN_TRANSFER_SOURCE_TOKEN_CONTENT') + } + const sourceTokenAddress = decodeAddress( + dataSlice(encoded, offset, offset + sourceTokenAddressLength), + sourceFamily, + ) + offset += sourceTokenAddressLength + + // destTokenAddressLength and destTokenAddress + if (offset >= encoded.length) { + throw new CCIPMessageDecodeError('TOKEN_TRANSFER_DEST_TOKEN_LENGTH') + } + const destTokenAddressLength = encoded[offset++]! + if (offset + destTokenAddressLength > encoded.length) { + throw new CCIPMessageDecodeError('TOKEN_TRANSFER_DEST_TOKEN_CONTENT') + } + const destTokenAddress = decodeAddress( + dataSlice(encoded, offset, offset + destTokenAddressLength), + destFamily, + ) + offset += destTokenAddressLength + + // tokenReceiverLength and tokenReceiver + if (offset >= encoded.length) { + throw new CCIPMessageDecodeError('TOKEN_TRANSFER_TOKEN_RECEIVER_LENGTH') + } + const tokenReceiverLength = encoded[offset++]! + if (offset + tokenReceiverLength > encoded.length) { + throw new CCIPMessageDecodeError('TOKEN_TRANSFER_TOKEN_RECEIVER_CONTENT') + } + const tokenReceiver = decodeAddress( + dataSlice(encoded, offset, offset + tokenReceiverLength), + destFamily, + ) + offset += tokenReceiverLength + + // extraDataLength and extraData + if (offset + 2 > encoded.length) { + throw new CCIPMessageDecodeError('TOKEN_TRANSFER_EXTRA_DATA_LENGTH') + } + const extraDataLength = toNumber(dataSlice(encoded, offset, offset + 2)) + offset += 2 + if (offset + extraDataLength > encoded.length) { + throw new CCIPMessageDecodeError('TOKEN_TRANSFER_EXTRA_DATA_CONTENT') + } + const extraData = hexlify(dataSlice(encoded, offset, offset + extraDataLength)) + offset += extraDataLength + + return { + tokenTransfer: { + amount, + sourcePoolAddress, + sourceTokenAddress, + destTokenAddress, + tokenReceiver, + extraData, + }, + newOffset: offset, + } +} + +/** + * Decodes a MessageV1 from bytes following the v1 protocol format. + * @param encodedMessage - The encoded message bytes to decode. + * @returns The decoded MessageV1 struct. + */ +export function decodeMessageV1(encodedMessage: BytesLike): MessageV1 { + const MESSAGE_V1_BASE_SIZE = 77 + const encoded = getDataBytes(encodedMessage) + + if (encoded.length < MESSAGE_V1_BASE_SIZE) throw new CCIPMessageDecodeError('MESSAGE_MIN_SIZE') + + const version = encoded[0]! + if (version !== 1) throw new CCIPMessageDecodeError(`Invalid encoding version: ${version}`) + + // sourceChainSelector (8 bytes, big endian) + const sourceChainSelector = toBigInt(dataSlice(encoded, 1, 9)) + + // destChainSelector (8 bytes, big endian) + const destChainSelector = toBigInt(dataSlice(encoded, 9, 17)) + + // Get chain families for address decoding + const sourceNetworkInfo = networkInfo(sourceChainSelector) + const destNetworkInfo = networkInfo(destChainSelector) + const sourceFamily = sourceNetworkInfo.family + const destFamily = destNetworkInfo.family + + // messageNumber (8 bytes, big endian) + const messageNumber = toBigInt(dataSlice(encoded, 17, 25)) + + // executionGasLimit (4 bytes, big endian) + const executionGasLimit = toNumber(dataSlice(encoded, 25, 29)) + + // ccipReceiveGasLimit (4 bytes, big endian) + const ccipReceiveGasLimit = toNumber(dataSlice(encoded, 29, 33)) + + // finality (2 bytes, big endian) + const finality = toNumber(dataSlice(encoded, 33, 35)) + + // ccvAndExecutorHash (32 bytes) + const ccvAndExecutorHash = hexlify(dataSlice(encoded, 35, 67)) + + // onRampAddressLength and onRampAddress + let offset = 67 + if (offset >= encoded.length) throw new CCIPMessageDecodeError('MESSAGE_ONRAMP_ADDRESS_LENGTH') + const onRampAddressLength = encoded[offset++]! + if (offset + onRampAddressLength > encoded.length) { + throw new CCIPMessageDecodeError('MESSAGE_ONRAMP_ADDRESS_CONTENT') + } + const onRampAddress = decodeAddress( + dataSlice(encoded, offset, offset + onRampAddressLength), + sourceFamily, + ) + offset += onRampAddressLength + + // offRampAddressLength and offRampAddress + if (offset >= encoded.length) throw new CCIPMessageDecodeError('MESSAGE_OFFRAMP_ADDRESS_LENGTH') + const offRampAddressLength = encoded[offset++]! + if (offset + offRampAddressLength > encoded.length) { + throw new CCIPMessageDecodeError('MESSAGE_OFFRAMP_ADDRESS_CONTENT') + } + const offRampAddress = decodeAddress( + dataSlice(encoded, offset, offset + offRampAddressLength), + destFamily, + ) + offset += offRampAddressLength + + // senderLength and sender + if (offset >= encoded.length) throw new CCIPMessageDecodeError('MESSAGE_SENDER_LENGTH') + const senderLength = encoded[offset++]! + if (offset + senderLength > encoded.length) { + throw new CCIPMessageDecodeError('MESSAGE_SENDER_CONTENT') + } + const sender = decodeAddress(dataSlice(encoded, offset, offset + senderLength), sourceFamily) + offset += senderLength + + // receiverLength and receiver + if (offset >= encoded.length) throw new CCIPMessageDecodeError('MESSAGE_RECEIVER_LENGTH') + const receiverLength = encoded[offset++]! + if (offset + receiverLength > encoded.length) { + throw new CCIPMessageDecodeError('MESSAGE_RECEIVER_CONTENT') + } + const receiver = decodeAddress(dataSlice(encoded, offset, offset + receiverLength), destFamily) + offset += receiverLength + + // destBlobLength and destBlob + if (offset + 2 > encoded.length) throw new CCIPMessageDecodeError('MESSAGE_DEST_BLOB_LENGTH') + const destBlobLength = toNumber(dataSlice(encoded, offset, offset + 2)) + offset += 2 + if (offset + destBlobLength > encoded.length) { + throw new CCIPMessageDecodeError('MESSAGE_DEST_BLOB_CONTENT') + } + const destBlob = hexlify(dataSlice(encoded, offset, offset + destBlobLength)) + offset += destBlobLength + + // tokenTransferLength and tokenTransfer + if (offset + 2 > encoded.length) throw new CCIPMessageDecodeError('MESSAGE_TOKEN_TRANSFER_LENGTH') + const tokenTransferLength = toNumber(dataSlice(encoded, offset, offset + 2)) + offset += 2 + + // Decode token transfer, which is either 0 or 1 + const tokenTransfer: TokenTransferV1[] = [] + if (tokenTransferLength > 0) { + const expectedEnd = offset + tokenTransferLength + const result = decodeTokenTransferV1(encoded, offset, sourceFamily, destFamily) + tokenTransfer.push(result.tokenTransfer) + offset = result.newOffset + if (offset !== expectedEnd) throw new CCIPMessageDecodeError('MESSAGE_TOKEN_TRANSFER_CONTENT') + } + + // dataLength and data + if (offset + 2 > encoded.length) throw new CCIPMessageDecodeError('MESSAGE_DATA_LENGTH') + const dataLength = toNumber(dataSlice(encoded, offset, offset + 2)) + offset += 2 + if (offset + dataLength > encoded.length) { + throw new CCIPMessageDecodeError('MESSAGE_DATA_CONTENT') + } + const data = hexlify(dataSlice(encoded, offset, offset + dataLength)) + offset += dataLength + + // Ensure we've consumed all bytes + if (offset !== encoded.length) throw new CCIPMessageDecodeError('MESSAGE_FINAL_OFFSET') + + return { + sourceChainSelector, + destChainSelector, + messageNumber, + executionGasLimit, + ccipReceiveGasLimit, + finality, + ccvAndExecutorHash, + onRampAddress, + offRampAddress, + sender, + receiver, + destBlob, + tokenTransfer, + data, + } +} diff --git a/package-lock.json b/package-lock.json index 2655ed7c..6bb175ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,6 +67,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -599,6 +600,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -2564,6 +2566,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -2586,6 +2589,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2695,6 +2699,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -3116,6 +3121,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -4323,6 +4329,7 @@ "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.9.2.tgz", "integrity": "sha512-C5wZsGuKTY8jEYsqdxhhFOe1ZDjH0uIYJ9T/jebHwkyxqnr4wW0jTkB72OMqNjsoQRcb0JN3PcSeTwFlVgzCZg==", "license": "MIT", + "peer": true, "dependencies": { "@docusaurus/core": "3.9.2", "@docusaurus/logger": "3.9.2", @@ -4619,6 +4626,7 @@ "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.9.2.tgz", "integrity": "sha512-6c4DAbR6n6nPbnZhY2V3tzpnKnGL+6aOsLvFL26VRqhlczli9eWG0VDUNoCQEPnGwDMhPS42UhSAnz5pThm5Ag==", "license": "MIT", + "peer": true, "dependencies": { "@docusaurus/mdx-loader": "3.9.2", "@docusaurus/module-type-aliases": "3.9.2", @@ -4764,6 +4772,7 @@ "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.9.2.tgz", "integrity": "sha512-lBSBiRruFurFKXr5Hbsl2thmGweAPmddhF3jb99U4EMDA5L+e5Y1rAkOS07Nvrup7HUMBDrCV45meaxZnt28nQ==", "license": "MIT", + "peer": true, "dependencies": { "@docusaurus/logger": "3.9.2", "@docusaurus/types": "3.9.2", @@ -4809,6 +4818,7 @@ "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.9.2.tgz", "integrity": "sha512-l7yk3X5VnNmATbwijJkexdhulNsQaNDwoagiwujXoxFbWLcxHQqNQ+c/IAlzrfMMOfa/8xSBZ7KEKDesE/2J7A==", "license": "MIT", + "peer": true, "dependencies": { "@docusaurus/logger": "3.9.2", "@docusaurus/utils": "3.9.2", @@ -7674,6 +7684,7 @@ "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", "license": "MIT", + "peer": true, "dependencies": { "@types/mdx": "^2.0.0" }, @@ -9401,6 +9412,7 @@ "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.98.4.tgz", "integrity": "sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.25.0", "@noble/curves": "^1.4.2", @@ -9617,6 +9629,7 @@ "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -9770,6 +9783,7 @@ "resolved": "https://registry.npmjs.org/@ton/core/-/core-0.63.0.tgz", "integrity": "sha512-uBc0WQNYVzjAwPvIazf0Ryhpv4nJd4dKIuHoj766gUdwe8sVzGM+TxKKKJETL70hh/mxACyUlR4tAwN0LWDNow==", "license": "MIT", + "peer": true, "peerDependencies": { "@ton/crypto": ">=3.2.0" } @@ -10341,6 +10355,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -10368,6 +10383,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -10586,6 +10602,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -11261,6 +11278,7 @@ "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/wevm" }, @@ -11304,6 +11322,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -11398,6 +11417,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -11462,6 +11482,7 @@ "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.47.0.tgz", "integrity": "sha512-AGtz2U7zOV4DlsuYV84tLp2tBbA7RPtLA44jbVH4TTpDcc1dIWmULjHSsunlhscbzDydnjuFlNhflR3nV4VJaQ==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/abtesting": "1.13.0", "@algolia/client-abtesting": "5.47.0", @@ -12403,6 +12424,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -13659,6 +13681,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -13978,6 +14001,7 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -14399,6 +14423,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -17440,6 +17465,7 @@ "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", "license": "MIT", + "peer": true, "dependencies": { "@sindresorhus/is": "^4.0.0", "@szmarczak/http-timer": "^4.0.5", @@ -17499,6 +17525,7 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", "license": "MIT", + "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -23239,6 +23266,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -24234,6 +24262,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.0.0", @@ -25239,6 +25268,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -25913,6 +25943,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -26432,6 +26463,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -26444,6 +26476,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -26481,6 +26514,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -26541,6 +26575,7 @@ "resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz", "integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/react": "*" }, @@ -26618,6 +26653,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -26641,6 +26677,7 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -26804,7 +26841,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -27597,6 +27635,7 @@ "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz", "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -27728,6 +27767,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -29088,6 +29128,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.4.tgz", "integrity": "sha512-ZoyXOdJjISB7/BcLTR6SEsLgKtDStYyYZVLsUtWChO4Ps20CBad7lfJKVDiejocV4ME1hLmyY0WJE3hSDcmQ2A==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -29504,6 +29545,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -29660,7 +29702,8 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.21.0", @@ -29886,6 +29929,7 @@ "integrity": "sha512-9Uu4WR9L7ZBgAl60N/h+jqmPxxvnC9nQAlnnO/OujtG2ubjnKTVUFY1XDhcMY+pCqlX3N2HsQM2QTYZIU9tJuw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 18" }, @@ -29924,6 +29968,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -30205,6 +30250,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -30847,6 +30893,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -31658,6 +31705,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" },