diff --git a/CHANGELOG.md b/CHANGELOG.md index b5e5cea..caa195c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- SDK: Simplified token transfer API - `tokenTransfer()` factory for single-token transfers without manual `extraArgs`/`data` configuration - SDK: Browser compatibility - explicit `buffer` dependency and imports for cross-platform support - CI: Added `publint` and `@arethetypeswrong/cli` validation for package exports - ESLint: `import/no-nodejs-modules` rule prevents Node.js-only imports in SDK diff --git a/ccip-cli/src/commands/send.ts b/ccip-cli/src/commands/send.ts index b179670..150dc49 100644 --- a/ccip-cli/src/commands/send.ts +++ b/ccip-cli/src/commands/send.ts @@ -1,9 +1,9 @@ import { - type AnyMessage, type CCIPVersion, type ChainStatic, type EVMChain, type ExtraArgs, + type FullMessage, CCIPArgumentInvalidError, CCIPChainFamilyUnsupportedError, CCIPTokenNotFoundError, @@ -287,7 +287,8 @@ async function sendMessage( feeTokenInfo = await source.getTokenInfo(nativeToken) } - const message: AnyMessage = { + const message: FullMessage = { + kind: 'full' as const, receiver, data, extraArgs: extraArgs as ExtraArgs, diff --git a/ccip-cli/src/index.ts b/ccip-cli/src/index.ts index 40807c6..067dd97 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 = '0.93.0-e6b317b' +const VERSION = '0.93.0-20c6ea2' // generate:end const globalOpts = { diff --git a/ccip-sdk/src/aptos/index.ts b/ccip-sdk/src/aptos/index.ts index b6ebd37..97db6d3 100644 --- a/ccip-sdk/src/aptos/index.ts +++ b/ccip-sdk/src/aptos/index.ts @@ -57,6 +57,7 @@ import { isAptosAccount, } from './types.ts' import type { LeafHasher } from '../hasher/common.ts' +import { normalizeMessage } from '../message-normalizer.ts' import { supportedChains } from '../supported-chains.ts' import { type CCIPExecution, @@ -530,7 +531,8 @@ export class AptosChain extends Chain { destChainSelector, message, }: Parameters[0]): Promise { - return getFee(this.provider, router, destChainSelector, message) + const msg = normalizeMessage(message, destChainSelector) + return getFee(this.provider, router, destChainSelector, msg) } /** {@inheritDoc Chain.generateUnsignedSendMessage} */ @@ -538,13 +540,19 @@ export class AptosChain extends Chain { opts: Parameters[0], ): Promise { const { sender, router, destChainSelector, message } = opts - if (!message.fee) message.fee = await this.getFee(opts) + + const msg = normalizeMessage(message, destChainSelector) + const fee = 'fee' in message ? message.fee : undefined + + if (!fee) msg.fee = await this.getFee({ router, destChainSelector, message: msg }) + else msg.fee = fee + const tx = await generateUnsignedCcipSend( this.provider, sender, router, destChainSelector, - message as SetRequired, + msg as SetRequired, opts, ) return { diff --git a/ccip-sdk/src/chain.ts b/ccip-sdk/src/chain.ts index d4eca19..4276a97 100644 --- a/ccip-sdk/src/chain.ts +++ b/ccip-sdk/src/chain.ts @@ -19,10 +19,10 @@ import type { SuiExtraArgsV1, } from './extra-args.ts' import type { LeafHasher } from './hasher/common.ts' +import type { MessageInput } from './message.ts' import type { UnsignedSolanaTx } from './solana/types.ts' import type { UnsignedTONTx } from './ton/types.ts' import { - type AnyMessage, type CCIPCommit, type CCIPExecution, type CCIPMessage, @@ -153,15 +153,50 @@ export type UnsignedTx = { } /** - * Common options for [[generateUnsignedSendMessage]] and [[sendMessage]] Chain methods + * Common options for [[generateUnsignedSendMessage]] and [[sendMessage]] Chain methods. + * + * Accepts both simplified `tokenTransfer()` and full `message()` formats. + * + * @example Token transfer (simplified) + * ```typescript + * import { tokenTransfer } from '@chainlink/ccip-sdk' + * + * await chain.sendMessage({ + * router: '0x...', + * destChainSelector: 4949039107694359620n, + * message: tokenTransfer({ + * receiver: '0x...', + * token: usdcAddress, + * amount: 1_000_000n, + * }), + * wallet: signer, + * }) + * ``` + * + * @example Full message (complete control) + * ```typescript + * import { message } from '@chainlink/ccip-sdk' + * + * await chain.sendMessage({ + * router: '0x...', + * destChainSelector: 4949039107694359620n, + * message: message({ + * receiver: '0x...', + * data: '0x1234', + * extraArgs: { gasLimit: 500_000n, allowOutOfOrderExecution: true }, + * tokenAmounts: [{ token: usdcAddress, amount: 1_000_000n }], + * }), + * wallet: signer, + * }) + * ``` */ export type SendMessageOpts = { /** Router address on this chain */ router: string /** Destination network selector. */ destChainSelector: bigint - /** Message to send. If `fee` is omitted, it'll be calculated */ - message: AnyMessage & { fee?: bigint } + /** Message to send. Accepts tokenTransfer() or message() factory outputs. If `fee` is omitted, it'll be calculated. */ + message: MessageInput & { fee?: bigint } /** Approve the maximum amount of tokens to transfer */ approveMax?: boolean } diff --git a/ccip-sdk/src/evm/index.ts b/ccip-sdk/src/evm/index.ts index bcf0520..99c2b13 100644 --- a/ccip-sdk/src/evm/index.ts +++ b/ccip-sdk/src/evm/index.ts @@ -62,6 +62,7 @@ import { SuiExtraArgsV1Tag, } from '../extra-args.ts' import type { LeafHasher } from '../hasher/common.ts' +import { normalizeMessage } from '../message-normalizer.ts' import { supportedChains } from '../supported-chains.ts' import { type CCIPExecution, @@ -960,17 +961,19 @@ export class EVMChain extends Chain { destChainSelector, message, }: Parameters[0]): Promise { + const msg = normalizeMessage(message, destChainSelector) + const contract = new Contract( router, interfaces.Router, this.provider, ) as unknown as TypedContract return contract.getFee(destChainSelector, { - receiver: zeroPadValue(getAddressBytes(message.receiver), 32), - data: hexlify(message.data), - tokenAmounts: message.tokenAmounts ?? [], - feeToken: message.feeToken ?? ZeroAddress, - extraArgs: hexlify((this.constructor as typeof EVMChain).encodeExtraArgs(message.extraArgs)), + receiver: zeroPadValue(getAddressBytes(msg.receiver), 32), + data: hexlify(msg.data), + tokenAmounts: msg.tokenAmounts ?? [], + feeToken: msg.feeToken ?? ZeroAddress, + extraArgs: hexlify((this.constructor as typeof EVMChain).encodeExtraArgs(msg.extraArgs)), }) } @@ -983,21 +986,25 @@ export class EVMChain extends Chain { opts: Parameters[0], ): Promise { const { sender, router, destChainSelector, message } = opts - if (!message.fee) message.fee = await this.getFee(opts) - const feeToken = message.feeToken ?? ZeroAddress - const receiver = zeroPadValue(getAddressBytes(message.receiver), 32) - const data = hexlify(message.data) - const extraArgs = hexlify( - (this.constructor as typeof EVMChain).encodeExtraArgs(message.extraArgs), - ) + + const msg = normalizeMessage(message, destChainSelector) + const fee = 'fee' in message ? message.fee : undefined + + if (!fee) msg.fee = await this.getFee({ router, destChainSelector, message: msg }) + else msg.fee = fee + + const feeToken = msg.feeToken ?? ZeroAddress + const receiver = zeroPadValue(getAddressBytes(msg.receiver), 32) + const data = hexlify(msg.data) + const extraArgs = hexlify((this.constructor as typeof EVMChain).encodeExtraArgs(msg.extraArgs)) // make sure to approve once per token, for the total amount (including fee, if needed) - const amountsToApprove = (message.tokenAmounts ?? []).reduce( + const amountsToApprove = (msg.tokenAmounts ?? []).reduce( (acc, { token, amount }) => ({ ...acc, [token]: (acc[token] ?? 0n) + amount }), {} as { [token: string]: bigint }, ) if (feeToken !== ZeroAddress) - amountsToApprove[feeToken] = (amountsToApprove[feeToken] ?? 0n) + message.fee + amountsToApprove[feeToken] = (amountsToApprove[feeToken] ?? 0n) + msg.fee const approveTxs = ( await Promise.all( @@ -1025,14 +1032,14 @@ export class EVMChain extends Chain { { receiver, data, - tokenAmounts: message.tokenAmounts ?? [], + tokenAmounts: msg.tokenAmounts ?? [], extraArgs, feeToken, }, { from: sender, // if native fee, include it in value; otherwise, it's transferedFrom feeToken - ...(feeToken === ZeroAddress && { value: message.fee }), + ...(feeToken === ZeroAddress && { value: msg.fee }), }, ) const txRequests = [...approveTxs, sendTx] as SetRequired[] diff --git a/ccip-sdk/src/index.ts b/ccip-sdk/src/index.ts index 9476cf5..9626010 100644 --- a/ccip-sdk/src/index.ts +++ b/ccip-sdk/src/index.ts @@ -8,9 +8,20 @@ export type { ChainStatic, LogFilter, RateLimiterState, + SendMessageOpts, TokenInfo, TokenPoolRemote, } from './chain.ts' + +// Message types and factory functions +export type { + FullMessage, + FullMessageParams, + MessageInput, + TokenTransferMessage, + TokenTransferParams, +} from './message.ts' +export { isFullMessage, isTokenTransfer, message, tokenTransfer } from './message.ts' export { calculateManualExecProof, discoverOffRamp } from './execution.ts' export { type EVMExtraArgsV1, diff --git a/ccip-sdk/src/message-defaults.test.ts b/ccip-sdk/src/message-defaults.test.ts new file mode 100644 index 0000000..308ba26 --- /dev/null +++ b/ccip-sdk/src/message-defaults.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { getDefaultExtraArgs } from './message-defaults.ts' +import { ChainFamily } from './types.ts' + +describe('getDefaultExtraArgs', () => { + const receiver = '0x1234567890123456789012345678901234567890' + + describe('EVM defaults', () => { + it('should return EVMExtraArgsV2 with gasLimit=0 and allowOutOfOrderExecution=true', () => { + const result = getDefaultExtraArgs(ChainFamily.EVM, receiver) + + assert.equal((result as { gasLimit: bigint }).gasLimit, 0n) + assert.equal((result as { allowOutOfOrderExecution: boolean }).allowOutOfOrderExecution, true) + assert.equal('computeUnits' in result, false) + assert.equal('tokenReceiver' in result, false) + }) + }) + + describe('Solana defaults', () => { + it('should return SVMExtraArgsV1 with tokenReceiver set to receiver', () => { + const result = getDefaultExtraArgs(ChainFamily.Solana, receiver) + + assert.equal(result.allowOutOfOrderExecution, true) + assert.equal('computeUnits' in result, true) + assert.equal((result as { computeUnits: bigint }).computeUnits, 0n) + assert.equal((result as { accountIsWritableBitmap: bigint }).accountIsWritableBitmap, 0n) + assert.equal((result as { tokenReceiver: string }).tokenReceiver, receiver) + assert.deepEqual((result as { accounts: string[] }).accounts, []) + }) + + it('should convert BytesLike receiver to hex string for tokenReceiver', () => { + const bytesReceiver = new Uint8Array([0x12, 0x34, 0x56, 0x78]) + const result = getDefaultExtraArgs(ChainFamily.Solana, bytesReceiver) + + assert.equal((result as { tokenReceiver: string }).tokenReceiver, '0x12345678') + }) + }) + + describe('Aptos defaults', () => { + it('should return EVMExtraArgsV2 with gasLimit=0 and allowOutOfOrderExecution=true', () => { + const result = getDefaultExtraArgs(ChainFamily.Aptos, receiver) + + assert.equal((result as { gasLimit: bigint }).gasLimit, 0n) + assert.equal((result as { allowOutOfOrderExecution: boolean }).allowOutOfOrderExecution, true) + assert.equal('computeUnits' in result, false) + assert.equal('tokenReceiver' in result, false) + }) + }) + + describe('Sui defaults', () => { + it('should return SuiExtraArgsV1 with tokenReceiver set to receiver', () => { + const result = getDefaultExtraArgs(ChainFamily.Sui, receiver) + + assert.equal((result as { gasLimit: bigint }).gasLimit, 0n) + assert.equal((result as { allowOutOfOrderExecution: boolean }).allowOutOfOrderExecution, true) + assert.equal((result as { tokenReceiver: string }).tokenReceiver, receiver) + assert.deepEqual((result as { receiverObjectIds: string[] }).receiverObjectIds, []) + }) + + it('should convert BytesLike receiver to hex string for tokenReceiver', () => { + const bytesReceiver = new Uint8Array([0xab, 0xcd, 0xef]) + const result = getDefaultExtraArgs(ChainFamily.Sui, bytesReceiver) + + assert.equal((result as { tokenReceiver: string }).tokenReceiver, '0xabcdef') + }) + }) + + describe('TON defaults', () => { + it('should return EVMExtraArgsV2 with gasLimit=0 and allowOutOfOrderExecution=true', () => { + const result = getDefaultExtraArgs(ChainFamily.TON, receiver) + + assert.equal((result as { gasLimit: bigint }).gasLimit, 0n) + assert.equal((result as { allowOutOfOrderExecution: boolean }).allowOutOfOrderExecution, true) + assert.equal('computeUnits' in result, false) + assert.equal('tokenReceiver' in result, false) + }) + }) + + describe('immutability', () => { + it('should return a new object each time (no shared state)', () => { + const result1 = getDefaultExtraArgs(ChainFamily.EVM, receiver) + const result2 = getDefaultExtraArgs(ChainFamily.EVM, receiver) + + assert.notEqual(result1, result2) + assert.deepEqual(result1, result2) + }) + }) +}) diff --git a/ccip-sdk/src/message-defaults.ts b/ccip-sdk/src/message-defaults.ts new file mode 100644 index 0000000..a4f250c --- /dev/null +++ b/ccip-sdk/src/message-defaults.ts @@ -0,0 +1,100 @@ +import { type BytesLike, hexlify } from 'ethers' + +import type { EVMExtraArgsV2, SVMExtraArgsV1, SuiExtraArgsV1 } from './extra-args.ts' +import { ChainFamily } from './types.ts' + +/** + * Union of all ExtraArgs types used for default values. + * + * Chain family to type mapping: + * - EVM, Aptos, TON → {@link EVMExtraArgsV2} (gasLimit + allowOutOfOrderExecution) + * - Solana → {@link SVMExtraArgsV1} (computeUnits + tokenReceiver + accounts) + * - Sui → {@link SuiExtraArgsV1} (gasLimit + tokenReceiver + receiverObjectIds) + */ +export type DefaultExtraArgs = EVMExtraArgsV2 | SVMExtraArgsV1 | SuiExtraArgsV1 + +/** + * Default extraArgs by destination chain family. + * Used when normalizing {@link TokenTransferMessage} to internal {@link FullMessage} format. + * + * @remarks + * Design decisions: + * - `gasLimit`/`computeUnits`: 0 = use chain's default (sufficient for token-only transfers) + * - `allowOutOfOrderExecution`: true = better UX, no ordering constraints for simple transfers + * - `tokenReceiver`: populated dynamically from receiver address (Solana/Sui only) + * + * @internal + */ +const DEFAULT_EXTRA_ARGS: Readonly> = { + [ChainFamily.EVM]: { + gasLimit: 0n, + allowOutOfOrderExecution: true, + }, + [ChainFamily.Solana]: { + computeUnits: 0n, + accountIsWritableBitmap: 0n, + allowOutOfOrderExecution: true, + tokenReceiver: '', // Populated dynamically from receiver + accounts: [], + }, + [ChainFamily.Aptos]: { + gasLimit: 0n, + allowOutOfOrderExecution: true, + }, + [ChainFamily.Sui]: { + gasLimit: 0n, + allowOutOfOrderExecution: true, + tokenReceiver: '', // Populated dynamically from receiver + receiverObjectIds: [], + }, + [ChainFamily.TON]: { + gasLimit: 0n, + allowOutOfOrderExecution: true, + }, +} as const + +/** + * Get default extraArgs for a destination chain family. + * + * Handles chain-specific field population: + * - Solana: Sets `tokenReceiver` to the destination receiver address + * - Sui: Sets `tokenReceiver` to the destination receiver address + * - EVM/Aptos/TON: Returns base defaults (no dynamic fields) + * + * @param destFamily - Destination chain family (determines extraArgs structure) + * @param receiver - Destination receiver address. For Solana/Sui, this is used as + * the `tokenReceiver` field. Can be a hex string or bytes. + * @returns ExtraArgs with sensible defaults for token-only transfers. + * The returned object is a shallow copy (safe to mutate). + * + * @example + * ```typescript + * const extraArgs = getDefaultExtraArgs(ChainFamily.Solana, '0x1234...') + * // { computeUnits: 0n, tokenReceiver: '0x1234...', ... } + * ``` + */ +export function getDefaultExtraArgs( + destFamily: ChainFamily, + receiver: BytesLike, +): DefaultExtraArgs { + const base = DEFAULT_EXTRA_ARGS[destFamily] + const receiverStr = typeof receiver === 'string' ? receiver : hexlify(receiver) + + // Solana requires tokenReceiver - default to receiver address + if (destFamily === ChainFamily.Solana) { + return { + ...base, + tokenReceiver: receiverStr, + } as SVMExtraArgsV1 + } + + // Sui requires tokenReceiver - default to receiver address + if (destFamily === ChainFamily.Sui) { + return { + ...base, + tokenReceiver: receiverStr, + } as SuiExtraArgsV1 + } + + return { ...base } +} diff --git a/ccip-sdk/src/message-normalizer.test.ts b/ccip-sdk/src/message-normalizer.test.ts new file mode 100644 index 0000000..460c2b4 --- /dev/null +++ b/ccip-sdk/src/message-normalizer.test.ts @@ -0,0 +1,212 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { normalizeMessage, normalizeMessageWithFee } from './message-normalizer.ts' +import { message, tokenTransfer } from './message.ts' +import { networkInfo } from './utils.ts' + +// Use networkInfo for readable chain selector access +const selector = (name: string) => networkInfo(name).chainSelector + +describe('normalizeMessage', () => { + describe('with FullMessage input', () => { + it('should return FullMessage unchanged', () => { + const fullMsg = message({ + receiver: '0x1234567890123456789012345678901234567890', + data: '0xabcdef', + extraArgs: { gasLimit: 500_000n, allowOutOfOrderExecution: false }, + tokenAmounts: [{ token: '0xtoken', amount: 100n }], + feeToken: '0xfeeToken', + }) + + const result = normalizeMessage(fullMsg, selector('ethereum-mainnet')) + + assert.equal(result.kind, 'full') + assert.equal(result.receiver, fullMsg.receiver) + assert.equal(result.data, fullMsg.data) + assert.deepEqual(result.extraArgs, fullMsg.extraArgs) + assert.deepEqual(result.tokenAmounts, fullMsg.tokenAmounts) + assert.equal(result.feeToken, fullMsg.feeToken) + }) + + it('should preserve custom extraArgs from FullMessage', () => { + const customExtraArgs = { + gasLimit: 1_000_000n, + allowOutOfOrderExecution: false, + } + const fullMsg = message({ + receiver: '0x1234', + data: '0x', + extraArgs: customExtraArgs, + }) + + const result = normalizeMessage(fullMsg, selector('ethereum-mainnet')) + + assert.deepEqual(result.extraArgs, customExtraArgs) + }) + }) + + describe('with TokenTransferMessage input', () => { + const tokenMsg = tokenTransfer({ + receiver: '0x1234567890123456789012345678901234567890', + token: '0xabcdef1234567890abcdef1234567890abcdef12', + amount: 1_000_000n, + }) + + it('should convert to FullMessage with kind=full', () => { + const result = normalizeMessage(tokenMsg, selector('ethereum-mainnet')) + + assert.equal(result.kind, 'full') + }) + + it('should set data to empty bytes (0x)', () => { + const result = normalizeMessage(tokenMsg, selector('ethereum-mainnet')) + + assert.equal(result.data, '0x') + }) + + it('should wrap token in tokenAmounts array', () => { + const result = normalizeMessage(tokenMsg, selector('ethereum-mainnet')) + + assert.deepEqual(result.tokenAmounts, [{ token: tokenMsg.token, amount: tokenMsg.amount }]) + }) + + it('should preserve feeToken if provided', () => { + const msgWithFee = tokenTransfer({ + receiver: '0x1234', + token: '0xtoken', + amount: 100n, + feeToken: '0xfeeToken', + }) + + const result = normalizeMessage(msgWithFee, selector('ethereum-mainnet')) + + assert.equal(result.feeToken, '0xfeeToken') + }) + + describe('EVM destination', () => { + it('should apply EVM defaults for extraArgs', () => { + const result = normalizeMessage(tokenMsg, selector('ethereum-mainnet')) + + assert.equal((result.extraArgs as { gasLimit: bigint }).gasLimit, 0n) + assert.equal( + (result.extraArgs as { allowOutOfOrderExecution: boolean }).allowOutOfOrderExecution, + true, + ) + assert.equal('computeUnits' in result.extraArgs, false) + }) + }) + + describe('Solana destination', () => { + it('should apply Solana defaults with tokenReceiver', () => { + const result = normalizeMessage(tokenMsg, selector('solana-mainnet')) + + assert.equal( + (result.extraArgs as { allowOutOfOrderExecution: boolean }).allowOutOfOrderExecution, + true, + ) + assert.equal('computeUnits' in result.extraArgs, true) + assert.equal( + (result.extraArgs as { tokenReceiver: string }).tokenReceiver, + tokenMsg.receiver, + ) + }) + }) + + describe('Aptos destination', () => { + it('should apply Aptos defaults for extraArgs', () => { + const result = normalizeMessage(tokenMsg, selector('aptos-mainnet')) + + assert.equal((result.extraArgs as { gasLimit: bigint }).gasLimit, 0n) + assert.equal( + (result.extraArgs as { allowOutOfOrderExecution: boolean }).allowOutOfOrderExecution, + true, + ) + }) + }) + + describe('Sui destination', () => { + it('should apply Sui defaults with tokenReceiver', () => { + const result = normalizeMessage(tokenMsg, selector('sui-mainnet')) + + assert.equal((result.extraArgs as { gasLimit: bigint }).gasLimit, 0n) + assert.equal( + (result.extraArgs as { allowOutOfOrderExecution: boolean }).allowOutOfOrderExecution, + true, + ) + assert.equal( + (result.extraArgs as { tokenReceiver: string }).tokenReceiver, + tokenMsg.receiver, + ) + }) + }) + + describe('TON destination', () => { + it('should apply TON defaults for extraArgs', () => { + const result = normalizeMessage(tokenMsg, selector('ton-mainnet')) + + assert.equal((result.extraArgs as { gasLimit: bigint }).gasLimit, 0n) + assert.equal( + (result.extraArgs as { allowOutOfOrderExecution: boolean }).allowOutOfOrderExecution, + true, + ) + }) + }) + }) +}) + +describe('normalizeMessageWithFee', () => { + it('should attach fee to normalized message when provided', () => { + const tokenMsg = tokenTransfer({ + receiver: '0x1234', + token: '0xtoken', + amount: 100n, + }) + + const result = normalizeMessageWithFee( + { ...tokenMsg, fee: 50_000n }, + selector('ethereum-mainnet'), + ) + + assert.equal(result.fee, 50_000n) + }) + + it('should not add fee property when fee is undefined', () => { + const tokenMsg = tokenTransfer({ + receiver: '0x1234', + token: '0xtoken', + amount: 100n, + }) + + const result = normalizeMessageWithFee(tokenMsg, selector('ethereum-mainnet')) + + assert.equal('fee' in result, false) + }) + + it('should preserve fee from FullMessage input', () => { + const fullMsg = message({ + receiver: '0x1234', + data: '0x', + extraArgs: { gasLimit: 0n, allowOutOfOrderExecution: true }, + }) + + const result = normalizeMessageWithFee( + { ...fullMsg, fee: 123_456n }, + selector('ethereum-mainnet'), + ) + + assert.equal(result.fee, 123_456n) + }) + + it('should handle fee=0n correctly', () => { + const tokenMsg = tokenTransfer({ + receiver: '0x1234', + token: '0xtoken', + amount: 100n, + }) + + const result = normalizeMessageWithFee({ ...tokenMsg, fee: 0n }, selector('ethereum-mainnet')) + + assert.equal(result.fee, 0n) + }) +}) diff --git a/ccip-sdk/src/message-normalizer.ts b/ccip-sdk/src/message-normalizer.ts new file mode 100644 index 0000000..cbd42a0 --- /dev/null +++ b/ccip-sdk/src/message-normalizer.ts @@ -0,0 +1,101 @@ +import { getDefaultExtraArgs } from './message-defaults.ts' +import { type FullMessage, type MessageInput, isTokenTransfer } from './message.ts' +import type { ChainFamily } from './types.ts' +import { networkInfo } from './utils.ts' + +/** + * Internal message format used by chain implementations. + * + * This is the canonical format that all chain methods (`getFee`, `sendMessage`, etc.) + * work with internally. It's a {@link FullMessage} (with `kind: 'full'`) plus an + * optional `fee` field for send operations. + * + * @remarks + * This type should not be exposed to end users. It's an internal implementation + * detail of the normalization layer. + */ +export type NormalizedMessage = FullMessage & { fee?: bigint } + +/** + * Normalizes any {@link MessageInput} to the internal {@link FullMessage} format. + * + * Transformation rules: + * - **FullMessage**: Returned as-is (already has all required data) + * - **TokenTransferMessage**: Converted to FullMessage with: + * - `kind`: `'full'` + * - `data`: `'0x'` (empty calldata) + * - `extraArgs`: Chain-specific defaults via {@link getDefaultExtraArgs} + * - `tokenAmounts`: Single-element array with `[{ token, amount }]` + * - `feeToken`: Preserved from input + * + * This is a **pure function** with no side effects. + * + * @param message - User-provided message (token transfer or full) + * @param destChainSelector - CCIP destination chain selector. Used to determine + * the chain family for applying appropriate extraArgs defaults. + * @returns Normalized message ready for chain implementation. + * + * @throws CCIPChainNotFoundError if `destChainSelector` is not a known CCIP chain selector. + * + * @example + * ```typescript + * // TokenTransferMessage gets converted + * const normalized = normalizeMessage( + * tokenTransfer({ receiver: '0x...', token: usdc, amount: 100n }), + * ETHEREUM_SELECTOR + * ) + * // Result: { kind: 'full', data: '0x', extraArgs: {...}, tokenAmounts: [...] } + * ``` + */ +export function normalizeMessage( + message: MessageInput, + destChainSelector: bigint, +): NormalizedMessage { + if (!isTokenTransfer(message)) { + return message + } + + const destNetwork = networkInfo(destChainSelector) + const destFamily = destNetwork.family as ChainFamily + + return { + kind: 'full' as const, + receiver: message.receiver, + data: '0x', // Empty calldata for token transfers + extraArgs: getDefaultExtraArgs(destFamily, message.receiver), + tokenAmounts: [{ token: message.token, amount: message.amount }], + feeToken: message.feeToken, + } +} + +/** + * Normalizes message with fee attached (for sendMessage operations). + * + * This is a convenience wrapper around {@link normalizeMessage} that also + * handles the optional `fee` field used in send operations. + * + * @param message - Message input with optional `fee` property + * @param destChainSelector - CCIP destination chain selector + * @returns Normalized message. If input has `fee` defined, it's preserved in output. + * + * @throws CCIPChainNotFoundError if `destChainSelector` is not a known CCIP chain selector. + * + * @example + * ```typescript + * const msg = normalizeMessageWithFee( + * { ...tokenTransfer({ receiver, token, amount }), fee: 50000n }, + * destSelector + * ) + * // msg.fee === 50000n + * ``` + */ +export function normalizeMessageWithFee( + message: MessageInput & { fee?: bigint }, + destChainSelector: bigint, +): NormalizedMessage { + const normalized = normalizeMessage(message, destChainSelector) + if ('fee' in message && message.fee !== undefined) { + return { ...normalized, fee: message.fee } + } + return normalized +} diff --git a/ccip-sdk/src/message.test.ts b/ccip-sdk/src/message.test.ts new file mode 100644 index 0000000..1b49293 --- /dev/null +++ b/ccip-sdk/src/message.test.ts @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { isFullMessage, isTokenTransfer, message, tokenTransfer } from './message.ts' + +describe('tokenTransfer factory', () => { + it('should create TokenTransferMessage with kind=token', () => { + const msg = tokenTransfer({ + receiver: '0x1234567890123456789012345678901234567890', + token: '0xabcdef1234567890abcdef1234567890abcdef12', + amount: 1_000_000n, + }) + + assert.equal(msg.kind, 'token') + assert.equal(msg.receiver, '0x1234567890123456789012345678901234567890') + assert.equal(msg.token, '0xabcdef1234567890abcdef1234567890abcdef12') + assert.equal(msg.amount, 1_000_000n) + assert.equal(msg.feeToken, undefined) + }) + + it('should create TokenTransferMessage with feeToken', () => { + const msg = tokenTransfer({ + receiver: '0x1234567890123456789012345678901234567890', + token: '0xabcdef1234567890abcdef1234567890abcdef12', + amount: 500_000n, + feeToken: '0xfeeToken0000000000000000000000000000000', + }) + + assert.equal(msg.kind, 'token') + assert.equal(msg.feeToken, '0xfeeToken0000000000000000000000000000000') + }) + + it('should create immutable message (Object.freeze)', () => { + const msg = tokenTransfer({ + receiver: '0x1234567890123456789012345678901234567890', + token: '0xabcdef1234567890abcdef1234567890abcdef12', + amount: 1_000_000n, + }) + + assert.ok(Object.isFrozen(msg)) + }) +}) + +describe('message factory', () => { + it('should create FullMessage with kind=full', () => { + const msg = message({ + receiver: '0x1234567890123456789012345678901234567890', + data: '0x1234abcd', + extraArgs: { gasLimit: 500_000n, allowOutOfOrderExecution: true }, + }) + + assert.equal(msg.kind, 'full') + assert.equal(msg.receiver, '0x1234567890123456789012345678901234567890') + assert.equal(msg.data, '0x1234abcd') + assert.deepEqual(msg.extraArgs, { gasLimit: 500_000n, allowOutOfOrderExecution: true }) + assert.equal(msg.tokenAmounts, undefined) + assert.equal(msg.feeToken, undefined) + }) + + it('should create FullMessage with tokenAmounts', () => { + const msg = message({ + receiver: '0x1234567890123456789012345678901234567890', + data: '0x', + extraArgs: { gasLimit: 0n, allowOutOfOrderExecution: false }, + tokenAmounts: [ + { token: '0xtoken1', amount: 100n }, + { token: '0xtoken2', amount: 200n }, + ], + }) + + assert.equal(msg.kind, 'full') + assert.deepEqual(msg.tokenAmounts, [ + { token: '0xtoken1', amount: 100n }, + { token: '0xtoken2', amount: 200n }, + ]) + }) + + it('should create immutable message (Object.freeze)', () => { + const msg = message({ + receiver: '0x1234567890123456789012345678901234567890', + data: '0x', + extraArgs: { gasLimit: 0n, allowOutOfOrderExecution: true }, + }) + + assert.ok(Object.isFrozen(msg)) + }) +}) + +describe('isTokenTransfer type guard', () => { + it('should return true for TokenTransferMessage', () => { + const msg = tokenTransfer({ + receiver: '0x1234567890123456789012345678901234567890', + token: '0xtoken', + amount: 100n, + }) + + assert.ok(isTokenTransfer(msg)) + }) + + it('should return false for FullMessage', () => { + const msg = message({ + receiver: '0x1234567890123456789012345678901234567890', + data: '0x', + extraArgs: { gasLimit: 0n, allowOutOfOrderExecution: true }, + }) + + assert.ok(!isTokenTransfer(msg)) + }) +}) + +describe('isFullMessage type guard', () => { + it('should return true for FullMessage', () => { + const msg = message({ + receiver: '0x1234567890123456789012345678901234567890', + data: '0x', + extraArgs: { gasLimit: 0n, allowOutOfOrderExecution: true }, + }) + + assert.ok(isFullMessage(msg)) + }) + + it('should return false for TokenTransferMessage', () => { + const msg = tokenTransfer({ + receiver: '0x1234567890123456789012345678901234567890', + token: '0xtoken', + amount: 100n, + }) + + assert.ok(!isFullMessage(msg)) + }) +}) + +describe('type discrimination', () => { + it('should narrow types correctly with kind check', () => { + // Create messages as union type to test discrimination + const messages = [ + tokenTransfer({ + receiver: '0x123', + token: '0xtoken', + amount: 100n, + }), + message({ + receiver: '0x123', + data: '0x', + extraArgs: { gasLimit: 0n, allowOutOfOrderExecution: true }, + }), + ] + + // Type narrowing with kind property on union + for (const msg of messages) { + if (msg.kind === 'token') { + // TypeScript narrows to TokenTransferMessage + assert.equal(typeof msg.token, 'string') + assert.equal(typeof msg.amount, 'bigint') + } else { + // TypeScript narrows to FullMessage + assert.equal(typeof msg.data, 'string') + assert.ok('extraArgs' in msg) + } + } + }) +}) diff --git a/ccip-sdk/src/message.ts b/ccip-sdk/src/message.ts new file mode 100644 index 0000000..f1b0411 --- /dev/null +++ b/ccip-sdk/src/message.ts @@ -0,0 +1,184 @@ +import type { BytesLike } from 'ethers' + +import type { ExtraArgs } from './extra-args.ts' + +/** + * Token transfer message - simplified format for single token transfers. + * Use the `tokenTransfer()` factory function to create instances. + * + * The SDK automatically handles: + * - `receiver`: Encoded to the correct format for the destination chain (32-byte padding) + * - `data`: Empty (`0x`) + * - `extraArgs`: Chain-appropriate defaults with `allowOutOfOrderExecution: true` + * + * @example + * ```typescript + * import { tokenTransfer } from '@chainlink/ccip-sdk' + * + * const msg = tokenTransfer({ + * receiver: '0x...', + * token: usdcAddress, + * amount: 1_000_000n, + * }) + * ``` + */ +export type TokenTransferMessage = { + /** Discriminant tag - set automatically by factory, do not modify */ + readonly kind: 'token' + /** + * Receiver address on destination chain. + * Accepts multiple formats - the SDK encodes automatically: + * - EVM hex address: `'0x1234...abcd'` + * - Solana base58: `'5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d'` + * - Raw bytes: `Uint8Array` + */ + readonly receiver: BytesLike + /** Token contract address to transfer */ + readonly token: string + /** Amount to transfer (in token's smallest unit) */ + readonly amount: bigint + /** Fee payment token (optional, defaults to native) */ + readonly feeToken?: string +} + +/** + * Full message - complete control over all CCIP message fields. + * Use the `message()` factory function to create instances. + * + * Use this when you need: + * - Custom calldata (`data`) + * - Specific `extraArgs` (gas limit, out-of-order execution, etc.) + * - Multiple token transfers + * + * @example + * ```typescript + * import { message } from '@chainlink/ccip-sdk' + * + * const msg = message({ + * receiver: '0x...', + * data: '0x1234', + * extraArgs: { gasLimit: 500_000n, allowOutOfOrderExecution: true }, + * tokenAmounts: [{ token: usdc, amount: 1_000_000n }], + * }) + * ``` + */ +export type FullMessage = { + /** Discriminant tag - set automatically by factory, do not modify */ + readonly kind: 'full' + /** + * Receiver address on destination chain. + * Accepts multiple formats - the SDK encodes automatically: + * - EVM hex address: `'0x1234...abcd'` + * - Solana base58: `'5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d'` + * - Raw bytes: `Uint8Array` + */ + readonly receiver: BytesLike + /** Arbitrary data payload */ + readonly data: BytesLike + /** Chain-specific execution arguments */ + readonly extraArgs: ExtraArgs + /** Tokens to transfer (optional) */ + readonly tokenAmounts?: readonly { token: string; amount: bigint }[] + /** Fee payment token (optional, defaults to native) */ + readonly feeToken?: string +} + +/** + * Union type for all message formats accepted by getFee/sendMessage. + */ +export type MessageInput = TokenTransferMessage | FullMessage + +/** + * Input parameters for tokenTransfer factory (excludes internal 'kind' tag). + */ +export type TokenTransferParams = Omit + +/** + * Input parameters for message factory (excludes internal 'kind' tag). + */ +export type FullMessageParams = Omit + +/** + * Factory function to create a token transfer message. + * The discriminant tag is set automatically - users never need to specify it. + * + * Defaults applied during normalization: + * - `data`: `0x` (empty) + * - `extraArgs`: Chain-appropriate defaults based on destination + * - `allowOutOfOrderExecution`: `true` + * + * @param params - Token transfer parameters + * @returns Immutable TokenTransferMessage with 'kind' tag set + * + * @example + * ```typescript + * import { tokenTransfer } from '@chainlink/ccip-sdk' + * + * const fee = await chain.getFee(router, destSelector, tokenTransfer({ + * receiver: recipientAddress, + * token: usdcAddress, + * amount: 1_000_000n, + * })) + * ``` + */ +export function tokenTransfer(params: TokenTransferParams): TokenTransferMessage { + return Object.freeze({ + kind: 'token' as const, + receiver: params.receiver, + token: params.token, + amount: params.amount, + ...(params.feeToken !== undefined && { feeToken: params.feeToken }), + }) +} + +/** + * Factory function to create a full CCIP message with complete control. + * The discriminant tag is set automatically - users never need to specify it. + * + * @param params - Full message parameters + * @returns Immutable FullMessage with 'kind' tag set + * + * @example + * ```typescript + * import { message } from '@chainlink/ccip-sdk' + * + * const fee = await chain.getFee(router, destSelector, message({ + * receiver: recipientAddress, + * data: '0x1234abcd', + * extraArgs: { gasLimit: 500_000n, allowOutOfOrderExecution: true }, + * tokenAmounts: [{ token: usdcAddress, amount: 1_000_000n }], + * })) + * ``` + */ +export function message(params: FullMessageParams): FullMessage { + return Object.freeze({ + kind: 'full' as const, + receiver: params.receiver, + data: params.data, + extraArgs: params.extraArgs, + ...(params.tokenAmounts !== undefined && { tokenAmounts: params.tokenAmounts }), + ...(params.feeToken !== undefined && { feeToken: params.feeToken }), + }) +} + +/** + * Type guard to check if message is a token transfer. + * Uses discriminant tag for O(1) type narrowing. + * + * @param msg - Message to check + * @returns True if message is a TokenTransferMessage + */ +export function isTokenTransfer(msg: MessageInput): msg is TokenTransferMessage { + return msg.kind === 'token' +} + +/** + * Type guard to check if message is a full message. + * Uses discriminant tag for O(1) type narrowing. + * + * @param msg - Message to check + * @returns True if message is a FullMessage + */ +export function isFullMessage(msg: MessageInput): msg is FullMessage { + return msg.kind === 'full' +} diff --git a/ccip-sdk/src/solana/index.ts b/ccip-sdk/src/solana/index.ts index bfb0cdc..f29dead 100644 --- a/ccip-sdk/src/solana/index.ts +++ b/ccip-sdk/src/solana/index.ts @@ -61,6 +61,7 @@ import { } from '../errors/index.ts' import { type EVMExtraArgsV2, type ExtraArgs, EVMExtraArgsV2Tag } from '../extra-args.ts' import type { LeafHasher } from '../hasher/common.ts' +import { normalizeMessage } from '../message-normalizer.ts' import SELECTORS from '../selectors.ts' import { supportedChains } from '../supported-chains.ts' import { @@ -976,7 +977,8 @@ export class SolanaChain extends Chain { /** {@inheritDoc Chain.getFee} */ getFee({ router, destChainSelector, message }: Parameters[0]): Promise { - return getFee(this, router, destChainSelector, message) + const msg = normalizeMessage(message, destChainSelector) + return getFee(this, router, destChainSelector, msg) } /** @@ -989,13 +991,19 @@ export class SolanaChain extends Chain { opts: Parameters[0], ): Promise { const { sender, router, destChainSelector, message } = opts - if (!message.fee) message.fee = await this.getFee(opts) + + const msg = normalizeMessage(message, destChainSelector) + const fee = 'fee' in message ? message.fee : undefined + + if (!fee) msg.fee = await this.getFee({ router, destChainSelector, message: msg }) + else msg.fee = fee + return generateUnsignedCcipSend( this, new PublicKey(sender), new PublicKey(router), destChainSelector, - message as SetRequired, + msg as SetRequired, opts, ) }