Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions ccip-cli/src/commands/send.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {
type AnyMessage,
type CCIPVersion,
type ChainStatic,
type EVMChain,
type ExtraArgs,
type FullMessage,
CCIPArgumentInvalidError,
CCIPChainFamilyUnsupportedError,
CCIPTokenNotFoundError,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion ccip-cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
14 changes: 11 additions & 3 deletions ccip-sdk/src/aptos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -530,21 +531,28 @@ export class AptosChain extends Chain<typeof ChainFamily.Aptos> {
destChainSelector,
message,
}: Parameters<Chain['getFee']>[0]): Promise<bigint> {
return getFee(this.provider, router, destChainSelector, message)
const msg = normalizeMessage(message, destChainSelector)
return getFee(this.provider, router, destChainSelector, msg)
}

/** {@inheritDoc Chain.generateUnsignedSendMessage} */
async generateUnsignedSendMessage(
opts: Parameters<Chain['generateUnsignedSendMessage']>[0],
): Promise<UnsignedAptosTx> {
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<typeof message, 'fee'>,
msg as SetRequired<typeof msg, 'fee'>,
opts,
)
return {
Expand Down
43 changes: 39 additions & 4 deletions ccip-sdk/src/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down
39 changes: 23 additions & 16 deletions ccip-sdk/src/evm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -960,17 +961,19 @@ export class EVMChain extends Chain<typeof ChainFamily.EVM> {
destChainSelector,
message,
}: Parameters<Chain['getFee']>[0]): Promise<bigint> {
const msg = normalizeMessage(message, destChainSelector)

const contract = new Contract(
router,
interfaces.Router,
this.provider,
) as unknown as TypedContract<typeof Router_ABI>
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)),
})
}

Expand All @@ -983,21 +986,25 @@ export class EVMChain extends Chain<typeof ChainFamily.EVM> {
opts: Parameters<Chain['generateUnsignedSendMessage']>[0],
): Promise<UnsignedEVMTx> {
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(
Expand Down Expand Up @@ -1025,14 +1032,14 @@ export class EVMChain extends Chain<typeof ChainFamily.EVM> {
{
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<typeof sendTx, 'from'>[]
Expand Down
11 changes: 11 additions & 0 deletions ccip-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
90 changes: 90 additions & 0 deletions ccip-sdk/src/message-defaults.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
Loading
Loading