diff --git a/ccip-cli/src/index.ts b/ccip-cli/src/index.ts index 0ef8092d..842c111a 100755 --- a/ccip-cli/src/index.ts +++ b/ccip-cli/src/index.ts @@ -25,7 +25,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.4.2-36cc294' +const VERSION = '1.4.2-257335e' // generate:end const require = createRequire(import.meta.url) diff --git a/ccip-sdk/src/api/index.ts b/ccip-sdk/src/api/index.ts index c857a326..7b873a43 100644 --- a/ccip-sdk/src/api/index.ts +++ b/ccip-sdk/src/api/index.ts @@ -60,7 +60,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.4.2-36cc294' +export const SDK_VERSION = '1.4.2-257335e' // generate:end /** SDK telemetry header name */ diff --git a/ccip-sdk/src/evm/index.ts b/ccip-sdk/src/evm/index.ts index 6292761d..75874423 100644 --- a/ccip-sdk/src/evm/index.ts +++ b/ccip-sdk/src/evm/index.ts @@ -21,7 +21,6 @@ import { keccak256, toBeHex, toBigInt, - zeroPadValue, } from 'ethers' import type { TypedContract } from 'ethers-abitype' import { memoize } from 'micro-memoize' @@ -85,6 +84,7 @@ import { import { decodeAddress, decodeOnRampAddress, + encodeAddressToAny, getAddressBytes, getDataBytes, getSomeBlockNumberBefore, @@ -144,8 +144,7 @@ function toRateLimiterState(b: RateLimiterBucket): RateLimiterState { // Addresses <32 bytes (EVM 20B, Aptos/Solana/Sui 32B) are zero-padded to 32 bytes; // Addresses >32 bytes (e.g., TON 4+32=36B) are used as raw bytes without padding function encodeAddressToEvm(address: BytesLike): string { - const bytes = getAddressBytes(address) - return bytes.length < 32 ? zeroPadValue(bytes, 32) : hexlify(bytes) + return hexlify(encodeAddressToAny(address)) } /** typeguard for ethers Signer interface (used for `wallet`s) */ diff --git a/ccip-sdk/src/execution.test.ts b/ccip-sdk/src/execution.test.ts index 08bd7257..a919844e 100644 --- a/ccip-sdk/src/execution.test.ts +++ b/ccip-sdk/src/execution.test.ts @@ -347,6 +347,46 @@ describe('calculateManualExecProof', () => { assert.equal(result.proofFlagBits, 0n) }) + // https://api.ccip.chain.link/v2/messages/0xc9d521e2b4be8d995d7f9ffbde183e12d88ec93794d6b4329c23cb354db406a8/execution-inputs + it('should calculate manual execution proof for v1.6 Solana->TON', () => { + const merkleRoot = '0x050adeaa0cfe792abbd5e33a3ba6f2d9204052952d091f7624d1a2d23b771ad1' + const messageId = '0xc9d521e2b4be8d995d7f9ffbde183e12d88ec93794d6b4329c23cb354db406a8' + const messagesInBatch: CCIPMessage[] = [ + { + data: '0x48656c6c6f', + nonce: 0n, + messageId, + sequenceNumber: 4n, + destChainSelector: 1399300952838017768n, + sourceChainSelector: 16423721717087811551n, + sender: '9NhaY2AXejCX3c4tXufzWuv52ZG7rjTJDeb1qSo9UV7S', + feeToken: 'So11111111111111111111111111111111111111112', + receiver: 'EQD4w5mxY0V7Szh2NsZ_BfWuMY6biF42HEjBz1-8_wRO-6gC', + extraArgs: '0x181dcf1040787d0100000000000000000000000001', + tokenAmounts: [], + feeValueJuels: 14388425000000000n, + feeTokenAmount: 1547524n, + allowOutOfOrderExecution: true, + gasLimit: 25000000n, + } as any, + ] + + const lane: Lane = { + sourceChainSelector: 16423721717087811551n, + destChainSelector: 1399300952838017768n, + onRamp: 'Ccip842gzYHhvdDkSyi2YVCoAWPbYJoApMFzSxQroE9C', + version: CCIPVersion.V1_6, + } + + const result = calculateManualExecProof(messagesInBatch, lane, messageId, merkleRoot, { + logger: console, + }) + + assert.equal(result.merkleRoot, merkleRoot) + assert.equal(result.proofs.length, 0) + assert.equal(result.proofFlagBits, 0n) + }) + it('should calculate Aptos root correctly', () => { // Test with actual Aptos message structure from requests.test.ts const msgInfoString = diff --git a/ccip-sdk/src/extra-args.test.ts b/ccip-sdk/src/extra-args.test.ts index 2ee620c7..abe5c400 100644 --- a/ccip-sdk/src/extra-args.test.ts +++ b/ccip-sdk/src/extra-args.test.ts @@ -86,10 +86,12 @@ describe('encodeExtraArgs', () => { ChainFamily.TON, ) - assert.equal( - encoded, - EVMExtraArgsV2Tag + '8000000000000000000000000000000000000000000000000000000000030d4060', - ) + assert.match(encoded, /^0xb5ee9c72/) + assert.deepEqual(decodeExtraArgs(encoded, ChainFamily.TON), { + _tag: 'EVMExtraArgsV2', + gasLimit: 400000n, + allowOutOfOrderExecution: true, + }) }) it('should encode EVMExtraArgsV2 (GenericExtraArgsV2) with allowOutOfOrderExecution false', () => { @@ -98,10 +100,12 @@ describe('encodeExtraArgs', () => { ChainFamily.TON, ) - assert.equal( - encoded, - EVMExtraArgsV2Tag + '800000000000000000000000000000000000000000000000000000000003d09020', - ) + assert.match(encoded, /^0xb5ee9c72/) + assert.deepEqual(decodeExtraArgs(encoded, ChainFamily.TON), { + _tag: 'EVMExtraArgsV2', + gasLimit: 500000n, + allowOutOfOrderExecution: false, + }) }) it('should parse real Sepolia->TON message extraArgs', () => { @@ -271,8 +275,44 @@ describe('parseExtraArgs', () => { const original = { gasLimit: 400_000n, allowOutOfOrderExecution: true } const encoded = encodeExtraArgs(original, ChainFamily.TON) const decoded = decodeExtraArgs(encoded, ChainFamily.TON) + assert.match(encoded, /^0xb5ee9c72/) assert.deepEqual(decoded, { ...original, _tag: 'EVMExtraArgsV2' }) }) + + it('should round-trip TON SVMExtraArgsV1', () => { + const original = { + computeUnits: 250_000n, + accountIsWritableBitmap: 3n, + allowOutOfOrderExecution: true, + tokenReceiver: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + accounts: [ + '11111111111111111111111111111111', + 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL', + ], + } + const encoded = encodeExtraArgs(original, ChainFamily.TON) + const decoded = decodeExtraArgs(encoded, ChainFamily.TON) + + assert.match(encoded, /^0xb5ee9c72/) + assert.deepEqual(decoded, { ...original, _tag: 'SVMExtraArgsV1' }) + }) + + it('should round-trip TON SuiExtraArgsV1', () => { + const original = { + gasLimit: 350_000n, + allowOutOfOrderExecution: false, + tokenReceiver: '0x1111111111111111111111111111111111111111111111111111111111111111', + receiverObjectIds: [ + '0x2222222222222222222222222222222222222222222222222222222222222222', + '0x3333333333333333333333333333333333333333333333333333333333333333', + ], + } + const encoded = encodeExtraArgs(original, ChainFamily.TON) + const decoded = decodeExtraArgs(encoded, ChainFamily.TON) + + assert.match(encoded, /^0xb5ee9c72/) + assert.deepEqual(decoded, { ...original, _tag: 'SuiExtraArgsV1' }) + }) }) describe('encoding format differences', () => { @@ -304,7 +344,7 @@ describe('parseExtraArgs', () => { const tonEncoded = encodeExtraArgs(args, ChainFamily.TON) assert.equal(evmEncoded.substring(0, 10), EVMExtraArgsV2Tag) - assert.equal(tonEncoded.substring(0, 10), EVMExtraArgsV2Tag) + assert.equal(tonEncoded.substring(0, 10), '0xb5ee9c72') // TON's BOC encoding marker assert.notEqual(evmEncoded, tonEncoded) }) }) diff --git a/ccip-sdk/src/solana/send.ts b/ccip-sdk/src/solana/send.ts index e16ba6e9..ec45c1a4 100644 --- a/ccip-sdk/src/solana/send.ts +++ b/ccip-sdk/src/solana/send.ts @@ -10,7 +10,6 @@ import { PublicKey, } from '@solana/web3.js' import BN from 'bn.js' -import { zeroPadValue } from 'ethers' import { SolanaChain } from './index.ts' import { CCIPError } from '../errors/CCIPError.ts' @@ -22,7 +21,7 @@ import { CCIPTokenAmountInvalidError, } from '../errors/index.ts' import { type AnyMessage, type WithLogger, ChainFamily } from '../types.ts' -import { bytesToBuffer, toLeArray } from '../utils.ts' +import { bytesToBuffer, toLeArray, encodeAddressToAny } from '../utils.ts' import { IDL as CCIP_ROUTER_IDL } from './idl/1.6.0/CCIP_ROUTER.ts' import type { UnsignedSolanaTx } from './types.ts' import { resolveATA, simulateTransaction, simulationProvider } from './utils.ts' @@ -31,7 +30,7 @@ function anyToSvmMessage(message: AnyMessage): IdlTypes[ const feeTokenPubkey = message.feeToken ? new PublicKey(message.feeToken) : PublicKey.default const svmMessage: IdlTypes['SVM2AnyMessage'] = { - receiver: bytesToBuffer(zeroPadValue(message.receiver, 32)), + receiver: encodeAddressToAny(message.receiver), data: bytesToBuffer(message.data || '0x'), tokenAmounts: (message.tokenAmounts || []).map((ta) => { if (!ta.token || ta.amount < 0n) { diff --git a/ccip-sdk/src/ton/extra-args.ts b/ccip-sdk/src/ton/extra-args.ts new file mode 100644 index 00000000..8dcebddb --- /dev/null +++ b/ccip-sdk/src/ton/extra-args.ts @@ -0,0 +1,224 @@ +import { type Cell, BitReader, BitString, Builder, Slice, beginCell } from '@ton/core' +import { type BytesLike, dataSlice, hexlify, toBeHex, toBigInt } from 'ethers' + +import { CCIPExtraArgsInvalidError } from '../errors/index.ts' +import { + type EVMExtraArgsV2, + type ExtraArgs, + type SVMExtraArgsV1, + type SuiExtraArgsV1, + EVMExtraArgsV2Tag, + SVMExtraArgsV1Tag, + SuiExtraArgsV1Tag, +} from '../extra-args.ts' +import { ChainFamily } from '../types.ts' +import { bigIntReplacer, bytesToBuffer, decodeAddress, getAddressBytes } from '../utils.ts' +import { asSnakedCell, fromSnakeData } from './utils.ts' + +/** + * Checks if extraArgs is SVMExtraArgsV1 format. + */ +function isSVMExtraArgs(extraArgs: ExtraArgs): extraArgs is SVMExtraArgsV1 { + return 'computeUnits' in extraArgs +} + +/** + * Checks if extraArgs is SuiExtraArgsV1 format. + */ +function isSuiExtraArgs(extraArgs: ExtraArgs): extraArgs is SuiExtraArgsV1 { + return 'receiverObjectIds' in extraArgs +} + +/** + * Encodes extraArgs as a Cell. + * + * Supports three formats based on the destination chain: + * - GenericExtraArgsV2 (EVMExtraArgsV2) for EVM/TON/Aptos destinations + * - SVMExtraArgsV1 for Solana destinations + * - SuiExtraArgsV1 for Sui destinations + * + * @param extraArgs - Extra arguments for CCIP message + * @returns Cell encoding the extra arguments + * @throws {@link CCIPExtraArgsInvalidError} if extraArgs format is invalid + */ +export function encodeExtraArgsCell(extraArgs: ExtraArgs): Cell { + if (isSVMExtraArgs(extraArgs)) { + return encodeSVMExtraArgsCell(extraArgs) + } + if (isSuiExtraArgs(extraArgs)) { + return encodeSuiExtraArgsCell(extraArgs) + } + return encodeEVMExtraArgsCell(extraArgs) +} + +/** + * Encodes extraArgs as a Cell using the GenericExtraArgsV2 (EVMExtraArgsV2) format. + * + * Format per chainlink-ton TL-B: + * - tag: 32-bit opcode (0x181dcf10) + * - gasLimit: Maybe (1 bit flag + 256 bits if present) + * - allowOutOfOrderExecution: 1 bit + */ +function encodeEVMExtraArgsCell(extraArgs: ExtraArgs): Cell { + if ( + Object.keys(extraArgs).filter((k) => k !== '_tag').length !== 2 || + !('gasLimit' in extraArgs && 'allowOutOfOrderExecution' in extraArgs) + ) + throw new CCIPExtraArgsInvalidError(ChainFamily.TON, JSON.stringify(extraArgs, bigIntReplacer)) + + let gasLimit: bigint | null = null + if (extraArgs.gasLimit > 0n) { + gasLimit = extraArgs.gasLimit + } + + // 0x181dcf10 + return beginCell() + .storeUint(Number(EVMExtraArgsV2Tag), 32) + .storeMaybeUint(gasLimit, 256) + .storeBit(extraArgs.allowOutOfOrderExecution) + .endCell() +} + +/** + * Encodes extraArgs as a Cell using the SVMExtraArgsV1 format. + * + * Format per chainlink-ton TL-B: + * - tag: 32-bit opcode (0x1f3b3aba) + * - computeUnits: uint32 + * - accountIsWritableBitmap: uint64 + * - allowOutOfOrderExecution: bool + * - tokenReceiver: uint256 + * - accounts: SnakedCell + */ +function encodeSVMExtraArgsCell(extraArgs: SVMExtraArgsV1): Cell { + // Encode accounts as a snaked cell of uint256 values + const builderFn = (account: string) => + new Builder().storeUint(toBigInt(getAddressBytes(account)), 256) + const accountsCell = asSnakedCell(extraArgs.accounts, builderFn) + + // Encode tokenReceiver as uint256 + const tokenReceiver = extraArgs.tokenReceiver + ? toBigInt(getAddressBytes(extraArgs.tokenReceiver)) + : 0n + + return beginCell() + .storeUint(Number(SVMExtraArgsV1Tag), 32) // 0x1f3b3aba + .storeUint(Number(extraArgs.computeUnits), 32) + .storeUint(extraArgs.accountIsWritableBitmap, 64) + .storeBit(extraArgs.allowOutOfOrderExecution) + .storeUint(tokenReceiver, 256) // uint256 + .storeRef(accountsCell) // SnakedCell + .endCell() +} + +/** + * Encodes extraArgs as a Cell using the SuiExtraArgsV1 format. + * + * Format per chainlink-ton TL-B: + * - tag: 32-bit opcode (0x21ea4ca9) + * - gasLimit: uint256 + * - allowOutOfOrderExecution: bool + * - tokenReceiver: uint256 + * - receiverObjectIds: SnakedCell + */ +function encodeSuiExtraArgsCell(extraArgs: SuiExtraArgsV1): Cell { + // Encode receiverObjectIds as a snaked cell of uint256 values + const builderFn = (objectId: string) => + new Builder().storeUint(toBigInt(getAddressBytes(objectId)), 256) + const objectIdsCell = asSnakedCell(extraArgs.receiverObjectIds, builderFn) + + // Encode tokenReceiver as uint256 + const tokenReceiver = extraArgs.tokenReceiver + ? toBigInt(getAddressBytes(extraArgs.tokenReceiver)) + : 0n + + return beginCell() + .storeUint(Number(SuiExtraArgsV1Tag), 32) // 0x21ea4ca9 + .storeUint(extraArgs.gasLimit, 256) + .storeBit(extraArgs.allowOutOfOrderExecution) + .storeUint(tokenReceiver, 256) // uint256 + .storeRef(objectIdsCell) // SnakedCell + .endCell() +} + +/** + * Decodes extraArgs from a BytesLike value in the legacy EVM/TON format (EVMExtraArgsV2). + * Returns undefined if the format is invalid or does not match EVMExtraArgsV2. + */ +export function decodeLegacyEVMTONExtraArgs( + extraArgs: BytesLike, +): (EVMExtraArgsV2 & { _tag: 'EVMExtraArgsV2' }) | undefined { + let bytes + try { + bytes = bytesToBuffer(extraArgs) + if (dataSlice(bytes, 0, 4) !== EVMExtraArgsV2Tag) return + } catch { + return + } + + const slice = new Slice(new BitReader(new BitString(bytes, 32, bytes.length * 8)), []) + const gasLimit = slice.loadMaybeUintBig(256) ?? 0n + const allowOutOfOrderExecution = slice.loadBit() + + return { + _tag: 'EVMExtraArgsV2', + gasLimit, + allowOutOfOrderExecution, + } +} + +/** + * Decodes extraArgs from a TON Cell. + * Returns undefined if the format is invalid or does not match any known extraArgs format. + */ +export function decodeTONExtraArgsCell( + cell: Cell, +): + | (EVMExtraArgsV2 & { _tag: 'EVMExtraArgsV2' }) + | (SVMExtraArgsV1 & { _tag: 'SVMExtraArgsV1' }) + | (SuiExtraArgsV1 & { _tag: 'SuiExtraArgsV1' }) + | undefined { + const slice = cell.beginParse() + const tag = hexlify(slice.loadBuffer(4)) + + switch (tag) { + case EVMExtraArgsV2Tag: + return { + _tag: 'EVMExtraArgsV2', + gasLimit: slice.loadMaybeUintBig(256) ?? 0n, + allowOutOfOrderExecution: slice.loadBit(), + } + + case SVMExtraArgsV1Tag: + return { + _tag: 'SVMExtraArgsV1', + computeUnits: BigInt(slice.loadUint(32)), + accountIsWritableBitmap: slice.loadUintBig(64), + allowOutOfOrderExecution: slice.loadBit(), + tokenReceiver: decodeAddress(toBeHex(slice.loadUintBig(256), 32), ChainFamily.Solana), + accounts: + slice.remainingRefs > 0 + ? fromSnakeData(slice.loadRef(), (accountSlice) => + decodeAddress(toBeHex(accountSlice.loadUintBig(256), 32), ChainFamily.Solana), + ) + : [], + } + + case SuiExtraArgsV1Tag: + return { + _tag: 'SuiExtraArgsV1', + gasLimit: slice.loadUintBig(256), + allowOutOfOrderExecution: slice.loadBit(), + tokenReceiver: decodeAddress(toBeHex(slice.loadUintBig(256), 32), ChainFamily.Sui), + receiverObjectIds: + slice.remainingRefs > 0 + ? fromSnakeData(slice.loadRef(), (objectSlice) => + decodeAddress(toBeHex(objectSlice.loadUintBig(256), 32), ChainFamily.Sui), + ) + : [], + } + + default: + return + } +} diff --git a/ccip-sdk/src/ton/hasher.test.ts b/ccip-sdk/src/ton/hasher.test.ts index a6eb0dd9..e03c21c8 100644 --- a/ccip-sdk/src/ton/hasher.test.ts +++ b/ccip-sdk/src/ton/hasher.test.ts @@ -102,5 +102,40 @@ describe('TON hasher unit tests', () => { assert.equal(computedHash, expectedHash) }) + + // https://api.ccip.chain.link/v2/messages/0xc9d521e2b4be8d995d7f9ffbde183e12d88ec93794d6b4329c23cb354db406a8/execution-inputs + it('should hash the live stuck Solana->TON message to the committed single-leaf merkle root', () => { + const sourceChainSelector = 16423721717087811551n + const destChainSelector = 1399300952838017768n + const hasher = getTONLeafHasher({ + sourceChainSelector, + destChainSelector, + onRamp: 'Ccip842gzYHhvdDkSyi2YVCoAWPbYJoApMFzSxQroE9C', + version: CCIPVersion.V1_6, + }) + + const message: CCIPMessage_V1_6 & EVMExtraArgsV2 = { + messageId: '0xc9d521e2b4be8d995d7f9ffbde183e12d88ec93794d6b4329c23cb354db406a8', + sourceChainSelector, + destChainSelector, + sequenceNumber: 4n, + nonce: 0n, + sender: '9NhaY2AXejCX3c4tXufzWuv52ZG7rjTJDeb1qSo9UV7S', + receiver: 'EQD4w5mxY0V7Szh2NsZ_BfWuMY6biF42HEjBz1-8_wRO-6gC', + data: '0x48656c6c6f', + extraArgs: '0x181dcf1040787d0100000000000000000000000001', + tokenAmounts: [], + feeToken: 'So11111111111111111111111111111111111111112', + feeTokenAmount: 1547524n, + feeValueJuels: 14388425000000000n, + gasLimit: 25_000_000n, + allowOutOfOrderExecution: true, + } + + assert.equal( + hasher(message), + '0x050adeaa0cfe792abbd5e33a3ba6f2d9204052952d091f7624d1a2d23b771ad1', + ) + }) }) }) diff --git a/ccip-sdk/src/ton/hasher.ts b/ccip-sdk/src/ton/hasher.ts index 7d3ced78..f268bf42 100644 --- a/ccip-sdk/src/ton/hasher.ts +++ b/ccip-sdk/src/ton/hasher.ts @@ -10,7 +10,7 @@ import { import { decodeExtraArgs } from '../extra-args.ts' import type { LeafHasher } from '../hasher/common.ts' import { type CCIPMessage, type CCIPMessage_V1_6, CCIPVersion } from '../types.ts' -import { bytesToBuffer, networkInfo } from '../utils.ts' +import { getAddressBytes, networkInfo } from '../utils.ts' import { tryParseCell } from './utils.ts' // TON uses 256 bits (32 bytes) of zeros as leaf domain separator @@ -64,7 +64,7 @@ export const hashTONMetadata = ( ): string => { // Domain separator for TON messages const versionHash = BigInt(sha256(Buffer.from('Any2TVMMessageHashV1'))) - const onRampBytes = bytesToBuffer(onRamp) + const onRampBytes = Buffer.from(getAddressBytes(onRamp)) // Build metadata cell const metadataCell = beginCell() @@ -116,7 +116,7 @@ function hashV16TONMessage(message: CCIPMessage_V1_6, metadataHash: string): str .endCell() // Build sender cell with address bytes - const senderBytes = bytesToBuffer(message.sender) + const senderBytes = Buffer.from(getAddressBytes(message.sender)) const senderCell = beginCell() .storeUint(BigInt(senderBytes.length), 8) .storeBuffer(senderBytes) @@ -157,7 +157,7 @@ function buildTokenAmountsCell(tokenAmounts: readonly TokenAmount[]): Cell { // Process each token transfer for (const ta of tokenAmounts) { - const sourcePoolBytes = bytesToBuffer(ta.sourcePoolAddress) + const sourcePoolBytes = Buffer.from(getAddressBytes(ta.sourcePoolAddress)) // Extract amount const amountSource = diff --git a/ccip-sdk/src/ton/index.integration.test.ts b/ccip-sdk/src/ton/index.integration.test.ts index ccf5c024..8ba18675 100644 --- a/ccip-sdk/src/ton/index.integration.test.ts +++ b/ccip-sdk/src/ton/index.integration.test.ts @@ -7,6 +7,7 @@ import '../index.ts' import { TONChain } from './index.ts' import { crc32 } from './utils.ts' import type { CCIPMessage_V1_6_EVM } from '../evm/messages.ts' +import type { CCIPMessage, CCIPVersion } from '../types.ts' // CRC32 hex values for TON external message topics const CCIP_MESSAGE_SENT_TOPIC = crc32('CCIPMessageSent') // 0xa45d293c @@ -250,7 +251,7 @@ describe.skip('TON index integration tests', () => { }) describe('decodeMessage', () => { - let message: CCIPMessage_V1_6_EVM | undefined + let message: CCIPMessage | undefined let ccipTxHash: string | undefined let messageLog: any @@ -389,10 +390,11 @@ describe.skip('TON index integration tests', () => { it('should decode gasLimit as positive bigint', () => { assert.ok(message) - assert.equal(typeof message.gasLimit, 'bigint') - assert.ok(message.gasLimit > 0n, 'gasLimit should be positive') + assert.ok('gasLimit' in message, 'message should have gasLimit property') + assert.equal(typeof (message as CCIPMessage_V1_6_EVM).gasLimit, 'bigint') + assert.ok((message as CCIPMessage_V1_6_EVM).gasLimit > 0n, 'gasLimit should be positive') assert.ok( - message.gasLimit >= 100_000n, + (message as CCIPMessage_V1_6_EVM).gasLimit >= 100_000n, 'gasLimit should be at least 100k for cross-chain calls', ) }) diff --git a/ccip-sdk/src/ton/index.test.ts b/ccip-sdk/src/ton/index.test.ts index ee3b98d3..d98043c8 100644 --- a/ccip-sdk/src/ton/index.test.ts +++ b/ccip-sdk/src/ton/index.test.ts @@ -47,6 +47,55 @@ describe('TON index unit tests', () => { const mockNetworkInfo = networkInfo('ton-testnet') + describe('extra args codec', () => { + it('should round-trip EVM extra args through TONChain static codec', () => { + const original = { gasLimit: 400_000n, allowOutOfOrderExecution: true } + + const encoded = TONChain.encodeExtraArgs(original) + const decoded = TONChain.decodeExtraArgs(encoded) + + assert.match(encoded, /^0xb5ee9c72/) + assert.deepEqual(decoded, { ...original, _tag: 'EVMExtraArgsV2' }) + }) + + it('should round-trip SVM extra args through TONChain static codec', () => { + const original = { + computeUnits: 250_000n, + accountIsWritableBitmap: 5n, + allowOutOfOrderExecution: true, + tokenReceiver: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + accounts: [ + '11111111111111111111111111111111', + 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL', + ], + } + + const encoded = TONChain.encodeExtraArgs(original) + const decoded = TONChain.decodeExtraArgs(encoded) + + assert.match(encoded, /^0xb5ee9c72/) + assert.deepEqual(decoded, { ...original, _tag: 'SVMExtraArgsV1' }) + }) + + it('should round-trip Sui extra args through TONChain static codec', () => { + const original = { + gasLimit: 350_000n, + allowOutOfOrderExecution: false, + tokenReceiver: '0x1111111111111111111111111111111111111111111111111111111111111111', + receiverObjectIds: [ + '0x2222222222222222222222222222222222222222222222222222222222222222', + '0x3333333333333333333333333333333333333333333333333333333333333333', + ], + } + + const encoded = TONChain.encodeExtraArgs(original) + const decoded = TONChain.decodeExtraArgs(encoded) + + assert.match(encoded, /^0xb5ee9c72/) + assert.deepEqual(decoded, { ...original, _tag: 'SuiExtraArgsV1' }) + }) + }) + describe('execute', { timeout: 10e3 }, () => { const mockWalletAddress = Address.parse('EQCVYafY2dq6dxpJXxm0ugndeoCi1uohtNthyotzpcGVmaoa') diff --git a/ccip-sdk/src/ton/index.ts b/ccip-sdk/src/ton/index.ts index 2aca1eac..3922162a 100644 --- a/ccip-sdk/src/ton/index.ts +++ b/ccip-sdk/src/ton/index.ts @@ -1,32 +1,14 @@ import { Buffer } from 'buffer' -import { - type Transaction, - Address, - BitReader, - BitString, - Cell, - Slice, - beginCell, - fromNano, - toNano, -} from '@ton/core' +import { type Transaction, Address, Cell, beginCell, fromNano, toNano } from '@ton/core' import { TonClient } from '@ton/ton' import { type AxiosAdapter, getAdapter } from 'axios' -import { - type BytesLike, - dataSlice, - hexlify, - isBytesLike, - isHexString, - toBeArray, - toBeHex, -} from 'ethers' +import { type BytesLike, hexlify, isBytesLike, isHexString, toBeArray, toBeHex } from 'ethers' import { type Memoized, memoize } from 'micro-memoize' import type { PickDeep } from 'type-fest' import { streamTransactionsForAddress } from './logs.ts' -import { encodeExtraArgsCell, generateUnsignedCcipSend, getFee as getFeeImpl } from './send.ts' +import { generateUnsignedCcipSend, getFee as getFeeImpl } from './send.ts' import { type ChainContext, type ChainStatic, @@ -46,13 +28,14 @@ import { CCIPTransactionNotFoundError, CCIPWalletInvalidError, } from '../errors/index.ts' -import { type EVMExtraArgsV2, type ExtraArgs, EVMExtraArgsV2Tag } from '../extra-args.ts' +import type { EVMExtraArgsV2, ExtraArgs, SVMExtraArgsV1, SuiExtraArgsV1 } from '../extra-args.ts' import type { LeafHasher } from '../hasher/common.ts' import { buildMessageForDest, getMessagesInBatch } from '../requests.ts' import { supportedChains } from '../supported-chains.ts' import { type AnyMessage, type CCIPExecution, + type CCIPMessage, type CCIPRequest, type ChainLog, type ChainTransaction, @@ -75,6 +58,11 @@ import { sleep, } from '../utils.ts' import { generateUnsignedExecuteReport } from './exec.ts' +import { + decodeLegacyEVMTONExtraArgs, + decodeTONExtraArgsCell, + encodeExtraArgsCell, +} from './extra-args.ts' import { getTONLeafHasher } from './hasher.ts' import { type UnsignedTONTx, isTONWallet } from './types.ts' import { crc32, lookupTxByRawHash, parseJettonContent } from './utils.ts' @@ -617,7 +605,7 @@ export class TONChain extends Chain { }: { data: unknown topics?: readonly string[] - }): CCIPMessage_V1_6_EVM | undefined { + }): CCIPMessage | undefined { if (!data || typeof data !== 'string') return if (topics?.length && topics[0] !== crc32('CCIPMessageSent')) return @@ -673,8 +661,8 @@ export class TONChain extends Chain { // Load extraArgs from ref 2 const extraArgsCell = bodySlice.loadRef() - // Build extraArgs as raw hex matching reference format - const extraArgs = '0x' + extraArgsCell.bits.toString().toLowerCase().replace('_', '0') + // Serialize full cell graph so nested refs are preserved for SVM/Sui extraArgs. + const extraArgs = hexlify(extraArgsCell.toBoc()) const parsed = this.decodeExtraArgs(extraArgs) if (!parsed) return const { _tag, ...extraArgsObj } = parsed @@ -706,59 +694,39 @@ export class TONChain extends Chain { } /** - * Encodes extra args from TON messages into BOC serialization format. + * Encodes TON extra args as a BOC-serialized cell. * - * Currently only supports GenericExtraArgsV2 (EVMExtraArgsV2) encoding since TON - * lanes are only connected to EVM chains. When new lanes are planned to be added, - * this should be extended to support them (eg. Solana and SVMExtraArgsV1) + * BOC serialization preserves nested refs, which is required for SVM and Sui + * extra args that use snaked cells. * * @param args - Extra arguments containing gas limit and execution flags * @returns Hex string of BOC-encoded extra args (0x-prefixed) - * @throws {@link CCIPExtraArgsInvalidError} if args contains fields other than `gasLimit` and `allowOutOfOrderExecution` */ static encodeExtraArgs(args: ExtraArgs): string { const cell = encodeExtraArgsCell(args) - return '0x' + cell.bits.toString().toLowerCase().replace('_', '0') + return hexlify(cell.toBoc()) } /** - * Decodes extra arguments from TON messages. - * Handles both raw bit-packed data (starts with EVMExtraArgsV2 tag directly) - * and BOC-wrapped data (starts with TON BOC magic `0xb5ee9c72`, unwrapped first). - * Returns undefined if parsing fails or the tag doesn't match. + * Decodes TON extra arguments. + * Accepts BOC-serialized cells for all supported variants and legacy raw + * GenericExtraArgsV2 bits for backward compatibility. * - * Currently only supports EVMExtraArgsV2 (GenericExtraArgsV2) encoding since TON - * lanes are only connected to EVM chains. When new lanes are planned to be added, - * this should be extended to support them (eg. Solana and SVMExtraArgsV1) - * - * @param extraArgs - Extra args as hex string or bytes (raw bit-packed or BOC-wrapped) - * @returns Decoded EVMExtraArgsV2 (GenericExtraArgsV2) object or undefined if invalid + * @param extraArgs - Extra args as hex string or bytes + * @returns Decoded TON extra args object or undefined if invalid */ static decodeExtraArgs( extraArgs: BytesLike, - ): (EVMExtraArgsV2 & { _tag: 'EVMExtraArgsV2' }) | undefined { - let bytes + ): + | (EVMExtraArgsV2 & { _tag: 'EVMExtraArgsV2' }) + | (SVMExtraArgsV1 & { _tag: 'SVMExtraArgsV1' }) + | (SuiExtraArgsV1 & { _tag: 'SuiExtraArgsV1' }) + | undefined { try { - bytes = bytesToBuffer(extraArgs) - - // If the data is BOC-wrapped (starts with TON BOC magic 0xb5ee9c72), extract bits - if (dataSlice(bytes, 0, 4) !== EVMExtraArgsV2Tag) { - const cell = Cell.fromBoc(bytes)[0]! - bytes = bytesToBuffer('0x' + cell.bits.toString().toLowerCase().replace('_', '0')) - } - if (dataSlice(bytes, 0, 4) !== EVMExtraArgsV2Tag) return + const cell = Cell.fromBoc(bytesToBuffer(extraArgs))[0]! + return decodeTONExtraArgsCell(cell) } catch { - return - } - - const slice = new Slice(new BitReader(new BitString(bytes, 32, bytes.length * 8)), []) - const gasLimit = slice.loadMaybeUintBig(256) ?? 0n - const allowOutOfOrderExecution = slice.loadBit() - - return { - _tag: 'EVMExtraArgsV2', - gasLimit, - allowOutOfOrderExecution, + return decodeLegacyEVMTONExtraArgs(extraArgs) } } diff --git a/ccip-sdk/src/ton/send.ts b/ccip-sdk/src/ton/send.ts index 9da0544a..855f8ddf 100644 --- a/ccip-sdk/src/ton/send.ts +++ b/ccip-sdk/src/ton/send.ts @@ -1,12 +1,11 @@ import { type Cell, beginCell, toNano } from '@ton/core' import { type TonClient, Address } from '@ton/ton' -import { zeroPadValue } from 'ethers' +import { CCIPError, CCIPErrorCode } from '../errors/index.ts' +import type { AnyMessage, WithLogger } from '../types.ts' +import { bytesToBuffer, encodeAddressToAny } from '../utils.ts' +import { encodeExtraArgsCell } from './extra-args.ts' import type { UnsignedTONTx } from './types.ts' -import { CCIPError, CCIPErrorCode, CCIPExtraArgsInvalidError } from '../errors/index.ts' -import { type ExtraArgs, EVMExtraArgsV2Tag } from '../extra-args.ts' -import { type AnyMessage, type WithLogger, ChainFamily } from '../types.ts' -import { bigIntReplacer, bytesToBuffer, getDataBytes } from '../utils.ts' /** Opcode for Router ccipSend operation */ export const CCIP_SEND_OPCODE = 0x31768d95 @@ -45,36 +44,6 @@ function encodeTokenAmounts( return builder.endCell() } -/** - * Encodes extraArgs as a Cell using the GenericExtraArgsV2 (EVMExtraArgsV2) format. - * - * Format per chainlink-ton TL-B: - * - tag: 32-bit opcode (0x181dcf10) - * - gasLimit: Maybe (1 bit flag + 256 bits if present) - * - allowOutOfOrderExecution: 1 bit - * @param extraArgs - Extra arguments for CCIP message - * @returns Cell encoding the extra arguments - * @throws {@link CCIPExtraArgsInvalidError} if `extraArgs` contains fields other than `gasLimit` and `allowOutOfOrderExecution` - */ -export function encodeExtraArgsCell(extraArgs: ExtraArgs): Cell { - if ( - Object.keys(extraArgs).filter((k) => k !== '_tag').length !== 2 || - !('gasLimit' in extraArgs && 'allowOutOfOrderExecution' in extraArgs) - ) - throw new CCIPExtraArgsInvalidError(ChainFamily.TON, JSON.stringify(extraArgs, bigIntReplacer)) - - let gasLimit: bigint | null = null - if (extraArgs.gasLimit > 0n) { - gasLimit = extraArgs.gasLimit - } - - const builder = beginCell().storeUint(Number(EVMExtraArgsV2Tag), 32) // 0x181dcf10 - builder.storeMaybeUint(gasLimit, 256) - builder.storeBit(extraArgs.allowOutOfOrderExecution) - - return builder.endCell() -} - /** * Builds the Router ccipSend message cell. * @@ -92,8 +61,8 @@ export function buildCcipSendCell( feeTokenAddress: Address | null = null, queryId = 0n, ): Cell { - // Get receiver bytes and pad to 32 bytes for cross-chain encoding - const paddedReceiver = bytesToBuffer(zeroPadValue(getDataBytes(message.receiver), 32)) + // Get receiver bytes — use getAddressBytes to handle hex, base58 (Solana), TON raw formats + const paddedReceiver = encodeAddressToAny(message.receiver) // Data cell (ref 0) const dataCell = beginCell() @@ -162,8 +131,8 @@ export async function getFee( } // Build stack parameters for validatedFee call - const paddedReceiver = bytesToBuffer(zeroPadValue(getDataBytes(message.receiver), 32)) - const receiverSlice = beginCell().storeBuffer(paddedReceiver).endCell() + const paddedFeeReceiver = encodeAddressToAny(message.receiver) + const receiverSlice = beginCell().storeBuffer(paddedFeeReceiver).endCell() const dataCell = beginCell() .storeBuffer(bytesToBuffer(message.data || '0x')) .endCell() diff --git a/ccip-sdk/src/ton/utils.ts b/ccip-sdk/src/ton/utils.ts index c069c561..7e3fcd9f 100644 --- a/ccip-sdk/src/ton/utils.ts +++ b/ccip-sdk/src/ton/utils.ts @@ -1,4 +1,4 @@ -import { Cell, Dictionary, beginCell } from '@ton/core' +import { type Builder, type Slice, Cell, Dictionary, beginCell } from '@ton/core' import { hexlify, toBeHex } from 'ethers' import { CCIPTransactionNotFoundError } from '../errors/specialized.ts' @@ -32,6 +32,71 @@ export function extractMagicTag(cell: string | Cell): string { return hexlify(tag) } +/** + * TODO: duplicate from https://github.com/smartcontractkit/chainlink-ton/blob/main/contracts/src/utils/types.ts + */ +export function asSnakedCell(array: T[], builderFn: (item: T) => Builder): Cell { + const cells: Builder[] = [] + let builder = beginCell() + + for (const value of array) { + const itemBuilder = builderFn(value) + if (itemBuilder.refs > 3) { + throw new Error( + 'Cannot pack more than 3 refs per item, use storeRef to a cell containing the item', + ) + } + if (builder.availableBits < itemBuilder.bits || builder.availableRefs <= 1) { + cells.push(builder) + builder = beginCell() + } + builder.storeBuilder(itemBuilder) + } + cells.push(builder) + + if (cells.length === 0) { + return beginCell().endCell() + } + + // Build the linked structure from the end + let current = cells[cells.length - 1]!.endCell() + for (let i = cells.length - 2; i >= 0; i--) { + const b = cells[i]! + b.storeRef(current) + current = b.endCell() + } + return current +} + +/** + * TODO: duplicate from https://github.com/smartcontractkit/chainlink-ton/blob/main/contracts/src/utils/types.ts + */ +export function fromSnakeData(data: Cell, readerFn: (cs: Slice) => T): T[] { + const array: T[] = [] + let cs = data.beginParse() + while (!isEmpty(cs)) { + if (cs.remainingBits > 0) { + const item = readerFn(cs) + array.push(item) + } else { + cs = cs.loadRef().beginParse() + } + } + return array +} + +/** + * TODO: duplicate from https://github.com/smartcontractkit/chainlink-ton/blob/main/contracts/src/utils/types.ts + */ +export function isEmpty(slice: Slice): boolean { + const remainingBits = slice.remainingBits + const remainingRefs = slice.remainingRefs + if (remainingBits > 0 || remainingRefs > 0) { + return false + } + return true +} + /** * Parses snake format data from a cell. * Snake format: first byte indicates format (0x00), followed by string data that may span multiple cells. diff --git a/ccip-sdk/src/utils.ts b/ccip-sdk/src/utils.ts index b587616a..60d410db 100644 --- a/ccip-sdk/src/utils.ts +++ b/ccip-sdk/src/utils.ts @@ -461,6 +461,19 @@ export function getAddressBytes(address: BytesLike | readonly number[]): Uint8Ar return bytes } +/** + * Encodes remote/alien addresses for Any SRC + * + * Addresses less than 32 bytes (EVM 20B, Aptos/Solana/Sui 32B) are zero-padded to 32 bytes + * Addresses greater than 32 bytes (e.g., TON 4+32=36B) are used as raw bytes without padding + */ +export function encodeAddressToAny(address: BytesLike): Buffer { + const bytes = getAddressBytes(address) + return bytes.length < 32 + ? Buffer.concat([Buffer.alloc(32 - bytes.length), Buffer.from(bytes)]) // pad to 32 bytes + : Buffer.from(bytes) +} + /** * Converts snake_case strings to camelCase */ diff --git a/package-lock.json b/package-lock.json index 50d27ce0..081f87b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19045,20 +19045,6 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, - "node_modules/jayson/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "extraneous": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/jayson/node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -29921,20 +29907,6 @@ "node": ">= 10" } }, - "node_modules/webpack-bundle-analyzer/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "extraneous": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/webpack-bundle-analyzer/node_modules/ws": { "version": "7.5.10", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",