diff --git a/ccip-cli/src/commands/send.ts b/ccip-cli/src/commands/send.ts index 95d80533..ea848dc8 100644 --- a/ccip-cli/src/commands/send.ts +++ b/ccip-cli/src/commands/send.ts @@ -31,7 +31,7 @@ import { getDataBytes, networkInfo, } from '@chainlink/ccip-sdk/src/index.ts' -import { type BytesLike, formatUnits, toUtf8Bytes } from 'ethers' +import { type BytesLike, ZeroAddress, formatUnits, toUtf8Bytes } from 'ethers' import type { Argv } from 'yargs' import type { GlobalOpts } from '../index.ts' @@ -262,7 +262,14 @@ async function sendMessage( } let feeToken, feeTokenInfo - if (argv.feeToken) { + const feeTokenArg = (argv.feeToken ?? '').toLowerCase() + if (feeTokenArg === 'native' || feeTokenArg === 'hbar') { + // CCIP Directory lists native HBAR as a fee token on Hedera; use ZeroAddress so EVM sends msg.value + feeToken = ZeroAddress + const wrappedNative = await source.getNativeTokenForRouter(argv.router) + feeTokenInfo = await source.getTokenInfo(wrappedNative) + feeTokenInfo = { ...feeTokenInfo, symbol: feeTokenInfo.symbol.replace(/^W/, '') || 'HBAR' } + } else if (argv.feeToken) { try { feeToken = (source.constructor as ChainStatic).getAddress(argv.feeToken) feeTokenInfo = await source.getTokenInfo(feeToken) @@ -279,6 +286,7 @@ async function sendMessage( } } else { const nativeToken = await source.getNativeTokenForRouter(argv.router) + feeToken = nativeToken feeTokenInfo = await source.getTokenInfo(nativeToken) } @@ -315,9 +323,11 @@ async function sendMessage( const balance = await source.getBalance({ holder: walletAddress, token: feeToken }) if (balance < fee) { const symbol = - feeTokenInfo.symbol.startsWith('W') && !feeToken - ? feeTokenInfo.symbol.substring(1) - : feeTokenInfo.symbol + feeToken === ZeroAddress + ? feeTokenInfo.symbol + : feeTokenInfo.symbol.startsWith('W') + ? feeTokenInfo.symbol.substring(1) + : feeTokenInfo.symbol throw new CCIPInsufficientBalanceError( formatUnits(balance, feeTokenInfo.decimals), formatUnits(fee, feeTokenInfo.decimals), diff --git a/ccip-cli/src/providers/sui.ts b/ccip-cli/src/providers/sui.ts index 7e2163cb..0b7e47b3 100644 --- a/ccip-cli/src/providers/sui.ts +++ b/ccip-cli/src/providers/sui.ts @@ -1,4 +1,5 @@ import { CCIPArgumentInvalidError, bytesToBuffer } from '@chainlink/ccip-sdk/src/index.ts' +import { decodeSuiPrivateKey } from '@mysten/sui/cryptography' import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519' /** @@ -9,6 +10,11 @@ import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519' export function loadSuiWallet({ wallet: walletOpt }: { wallet?: unknown }) { if (typeof walletOpt !== 'string') throw new CCIPArgumentInvalidError('wallet', String(walletOpt)) + if (walletOpt.startsWith('suiprivkey')) { + const { secretKey } = decodeSuiPrivateKey(walletOpt) + return Ed25519Keypair.fromSecretKey(secretKey) + } + const keyBytes = bytesToBuffer(walletOpt) return Ed25519Keypair.fromSecretKey(keyBytes) } diff --git a/ccip-sdk/src/chain.ts b/ccip-sdk/src/chain.ts index 7e8431f6..6eb512bb 100644 --- a/ccip-sdk/src/chain.ts +++ b/ccip-sdk/src/chain.ts @@ -26,6 +26,7 @@ import { getOffchainTokenData } from './offchain.ts' import { getMessagesInTx } from './requests.ts' import { DEFAULT_GAS_LIMIT } from './shared/constants.ts' import type { UnsignedSolanaTx } from './solana/types.ts' +import type { UnsignedSuiTx } from './sui/types.ts' import type { UnsignedTONTx } from './ton/types.ts' import { type AnyMessage, @@ -282,7 +283,7 @@ export type UnsignedTx = { [ChainFamily.Solana]: UnsignedSolanaTx [ChainFamily.Aptos]: UnsignedAptosTx [ChainFamily.TON]: UnsignedTONTx - [ChainFamily.Sui]: never // TODO + [ChainFamily.Sui]: UnsignedSuiTx [ChainFamily.Unknown]: never } diff --git a/ccip-sdk/src/evm/abi/FeeQuoter_2_0.ts b/ccip-sdk/src/evm/abi/FeeQuoter_2_0.ts new file mode 100644 index 00000000..2a2c7f0c --- /dev/null +++ b/ccip-sdk/src/evm/abi/FeeQuoter_2_0.ts @@ -0,0 +1,542 @@ +export default [ + // FeeQuoter 2.0.0 ABI + { + type: 'constructor', + inputs: [ + { + name: 'staticConfig', + type: 'tuple', + internalType: 'structFeeQuoter.StaticConfig', + components: [ + { + name: 'maxFeeJuelsPerMsg', + type: 'uint96', + internalType: 'uint96', + }, + { + name: 'linkToken', + type: 'address', + internalType: 'address', + }, + ], + }, + { + name: 'priceUpdaters', + type: 'address[]', + internalType: 'address[]', + }, + { + name: 'tokenTransferFeeConfigArgs', + type: 'tuple[]', + internalType: 'structFeeQuoter.TokenTransferFeeConfigArgs[]', + components: [ + { + name: 'destChainSelector', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'tokenTransferFeeConfigs', + type: 'tuple[]', + internalType: 'structFeeQuoter.TokenTransferFeeConfigSingleTokenArgs[]', + components: [ + { + name: 'token', + type: 'address', + internalType: 'address', + }, + { + name: 'tokenTransferFeeConfig', + type: 'tuple', + internalType: 'structFeeQuoter.TokenTransferFeeConfig', + components: [ + { + name: 'feeUSDCents', + type: 'uint32', + internalType: 'uint32', + }, + { + name: 'destGasOverhead', + type: 'uint32', + internalType: 'uint32', + }, + { + name: 'destBytesOverhead', + type: 'uint32', + internalType: 'uint32', + }, + { + name: 'isEnabled', + type: 'bool', + internalType: 'bool', + }, + ], + }, + ], + }, + ], + }, + { + name: 'destChainConfigArgs', + type: 'tuple[]', + internalType: 'structFeeQuoter.DestChainConfigArgs[]', + components: [ + { + name: 'destChainSelector', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'destChainConfig', + type: 'tuple', + internalType: 'structFeeQuoter.DestChainConfig', + components: [ + { name: 'isEnabled', type: 'bool', internalType: 'bool' }, + { + name: 'maxDataBytes', + type: 'uint32', + internalType: 'uint32', + }, + { + name: 'maxPerMsgGasLimit', + type: 'uint32', + internalType: 'uint32', + }, + { + name: 'destGasOverhead', + type: 'uint32', + internalType: 'uint32', + }, + { + name: 'destGasPerPayloadByteBase', + type: 'uint8', + internalType: 'uint8', + }, + { + name: 'chainFamilySelector', + type: 'bytes4', + internalType: 'bytes4', + }, + { + name: 'defaultTokenFeeUSDCents', + type: 'uint16', + internalType: 'uint16', + }, + { + name: 'defaultTokenDestGasOverhead', + type: 'uint32', + internalType: 'uint32', + }, + { + name: 'defaultTxGasLimit', + type: 'uint32', + internalType: 'uint32', + }, + { + name: 'networkFeeUSDCents', + type: 'uint16', + internalType: 'uint16', + }, + { + name: 'linkFeeMultiplierPercent', + type: 'uint8', + internalType: 'uint8', + }, + ], + }, + ], + }, + ], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'getFeeTokens', + inputs: [], + outputs: [{ name: '', type: 'address[]', internalType: 'address[]' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getTokenPrice', + inputs: [{ name: 'token', type: 'address', internalType: 'address' }], + outputs: [ + { + name: '', + type: 'tuple', + internalType: 'structInternal.TimestampedPackedUint224', + components: [ + { name: 'value', type: 'uint224', internalType: 'uint224' }, + { name: 'timestamp', type: 'uint32', internalType: 'uint32' }, + ], + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getTokenPrices', + inputs: [{ name: 'tokens', type: 'address[]', internalType: 'address[]' }], + outputs: [ + { + name: '', + type: 'tuple[]', + internalType: 'structInternal.TimestampedPackedUint224[]', + components: [ + { name: 'value', type: 'uint224', internalType: 'uint224' }, + { name: 'timestamp', type: 'uint32', internalType: 'uint32' }, + ], + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getValidatedTokenPrice', + inputs: [{ name: 'token', type: 'address', internalType: 'address' }], + outputs: [{ name: '', type: 'uint224', internalType: 'uint224' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getDestinationChainGasPrice', + inputs: [ + { + name: 'destChainSelector', + type: 'uint64', + internalType: 'uint64', + }, + ], + outputs: [ + { + name: '', + type: 'tuple', + internalType: 'structInternal.TimestampedPackedUint224', + components: [ + { name: 'value', type: 'uint224', internalType: 'uint224' }, + { name: 'timestamp', type: 'uint32', internalType: 'uint32' }, + ], + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getTokenTransferFee', + inputs: [ + { + name: 'destChainSelector', + type: 'uint64', + internalType: 'uint64', + }, + { name: 'token', type: 'address', internalType: 'address' }, + ], + outputs: [ + { name: 'feeUSDCents', type: 'uint32', internalType: 'uint32' }, + { name: 'destGasOverhead', type: 'uint32', internalType: 'uint32' }, + { name: 'destBytesOverhead', type: 'uint32', internalType: 'uint32' }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'quoteGasForExec', + inputs: [ + { + name: 'destChainSelector', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'nonCalldataGas', + type: 'uint32', + internalType: 'uint32', + }, + { + name: 'calldataSize', + type: 'uint32', + internalType: 'uint32', + }, + { name: 'feeToken', type: 'address', internalType: 'address' }, + ], + outputs: [ + { name: 'totalGas', type: 'uint32', internalType: 'uint32' }, + { name: 'gasCostInUsdCents', type: 'uint256', internalType: 'uint256' }, + { name: 'feeTokenPrice', type: 'uint256', internalType: 'uint256' }, + { + name: 'premiumPercentMultiplier', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getValidatedFee', + inputs: [ + { + name: 'destChainSelector', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'message', + type: 'tuple', + internalType: 'structClient.EVM2AnyMessage', + components: [ + { name: 'receiver', type: 'bytes', internalType: 'bytes' }, + { name: 'data', type: 'bytes', internalType: 'bytes' }, + { + name: 'tokenAmounts', + type: 'tuple[]', + internalType: 'structClient.EVMTokenAmount[]', + components: [ + { + name: 'token', + type: 'address', + internalType: 'address', + }, + { + name: 'amount', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + name: 'feeToken', + type: 'address', + internalType: 'address', + }, + { name: 'extraArgs', type: 'bytes', internalType: 'bytes' }, + ], + }, + ], + outputs: [ + { + name: 'feeTokenAmount', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getStaticConfig', + inputs: [], + outputs: [ + { + name: '', + type: 'tuple', + internalType: 'structFeeQuoter.StaticConfig', + components: [ + { + name: 'maxFeeJuelsPerMsg', + type: 'uint96', + internalType: 'uint96', + }, + { + name: 'linkToken', + type: 'address', + internalType: 'address', + }, + ], + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'typeAndVersion', + inputs: [], + outputs: [{ name: '', type: 'string', internalType: 'string' }], + stateMutability: 'view', + }, + { + type: 'error', + name: 'TokenNotSupported', + inputs: [{ name: 'token', type: 'address', internalType: 'address' }], + }, + { + type: 'error', + name: 'FeeTokenNotSupported', + inputs: [{ name: 'token', type: 'address', internalType: 'address' }], + }, + { + type: 'error', + name: 'NoGasPriceAvailable', + inputs: [ + { + name: 'destChainSelector', + type: 'uint64', + internalType: 'uint64', + }, + ], + }, + { + type: 'error', + name: 'InvalidDestBytesOverhead', + inputs: [ + { name: 'token', type: 'address', internalType: 'address' }, + { + name: 'destBytesOverhead', + type: 'uint32', + internalType: 'uint32', + }, + ], + }, + { + type: 'error', + name: 'MessageGasLimitTooHigh', + inputs: [], + }, + { + type: 'error', + name: 'MessageComputeUnitLimitTooHigh', + inputs: [], + }, + { + type: 'error', + name: 'DestinationChainNotEnabled', + inputs: [ + { + name: 'destChainSelector', + type: 'uint64', + internalType: 'uint64', + }, + ], + }, + { + type: 'error', + name: 'InvalidExtraArgsTag', + inputs: [], + }, + { + type: 'error', + name: 'InvalidExtraArgsData', + inputs: [], + }, + { + type: 'error', + name: 'SourceTokenDataTooLarge', + inputs: [{ name: 'token', type: 'address', internalType: 'address' }], + }, + { + type: 'error', + name: 'InvalidDestChainConfig', + inputs: [ + { + name: 'destChainSelector', + type: 'uint64', + internalType: 'uint64', + }, + ], + }, + { + type: 'error', + name: 'MessageFeeTooHigh', + inputs: [ + { name: 'msgFeeJuels', type: 'uint256', internalType: 'uint256' }, + { + name: 'maxFeeJuelsPerMsg', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'InvalidStaticConfig', + inputs: [], + }, + { + type: 'error', + name: 'MessageTooLarge', + inputs: [ + { name: 'maxSize', type: 'uint256', internalType: 'uint256' }, + { name: 'actualSize', type: 'uint256', internalType: 'uint256' }, + ], + }, + { + type: 'error', + name: 'UnsupportedNumberOfTokens', + inputs: [ + { + name: 'numberOfTokens', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'maxNumberOfTokensPerMsg', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'InvalidChainFamilySelector', + inputs: [ + { + name: 'chainFamilySelector', + type: 'bytes4', + internalType: 'bytes4', + }, + ], + }, + { + type: 'error', + name: 'InvalidTokenReceiver', + inputs: [], + }, + { + type: 'error', + name: 'TooManySVMExtraArgsAccounts', + inputs: [ + { name: 'numAccounts', type: 'uint256', internalType: 'uint256' }, + { name: 'maxAccounts', type: 'uint256', internalType: 'uint256' }, + ], + }, + { + type: 'error', + name: 'InvalidSVMExtraArgsWritableBitmap', + inputs: [ + { + name: 'accountIsWritableBitmap', + type: 'uint64', + internalType: 'uint64', + }, + { name: 'numAccounts', type: 'uint256', internalType: 'uint256' }, + ], + }, + { + type: 'error', + name: 'TooManySuiExtraArgsReceiverObjectIds', + inputs: [ + { + name: 'numReceiverObjectIds', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'maxReceiverObjectIds', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'TokenTransferConfigMustBeEnabled', + inputs: [ + { + name: 'destChainSelector', + type: 'uint64', + internalType: 'uint64', + }, + { name: 'token', type: 'address', internalType: 'address' }, + ], + }, +] as const diff --git a/ccip-sdk/src/evm/const.ts b/ccip-sdk/src/evm/const.ts index 86815537..d1ec91a4 100644 --- a/ccip-sdk/src/evm/const.ts +++ b/ccip-sdk/src/evm/const.ts @@ -4,7 +4,8 @@ import { type EventFragment, AbiCoder, Interface } from 'ethers' import Token_ABI from './abi/BurnMintERC677Token.ts' import CommitStore_1_2_ABI from './abi/CommitStore_1_2.ts' import CommitStore_1_5_ABI from './abi/CommitStore_1_5.ts' -import FeeQuoter_ABI from './abi/FeeQuoter_1_6.ts' +import FeeQuoter_1_6_ABI from './abi/FeeQuoter_1_6.ts' +import FeeQuoter_2_0_ABI from './abi/FeeQuoter_2_0.ts' import TokenPool_1_5_ABI from './abi/LockReleaseTokenPool_1_5.ts' import TokenPool_1_5_1_ABI from './abi/LockReleaseTokenPool_1_5_1.ts' import TokenPool_1_6_ABI from './abi/LockReleaseTokenPool_1_6_1.ts' @@ -35,7 +36,9 @@ export const interfaces = { Router: new Interface(Router_ABI), Token: new Interface(Token_ABI), TokenAdminRegistry: new Interface(TokenAdminRegistry_ABI), - FeeQuoter: new Interface(FeeQuoter_ABI), + FeeQuoter: new Interface(FeeQuoter_1_6_ABI), + FeeQuoter_v1_6: new Interface(FeeQuoter_1_6_ABI), + FeeQuoter_v2_0: new Interface(FeeQuoter_2_0_ABI), TokenPool_v1_5_1: new Interface(TokenPool_1_5_1_ABI), TokenPool_v1_5: new Interface(TokenPool_1_5_ABI), TokenPool_v1_6: new Interface(TokenPool_1_6_ABI), diff --git a/ccip-sdk/src/evm/index.ts b/ccip-sdk/src/evm/index.ts index 896fc652..083db31b 100644 --- a/ccip-sdk/src/evm/index.ts +++ b/ccip-sdk/src/evm/index.ts @@ -1015,7 +1015,19 @@ export class EVMChain extends Chain { { from: sender, // if native fee, include it in value; otherwise, it's transferedFrom feeToken - ...(feeToken === ZeroAddress && { value: message.fee }), + ...(feeToken === ZeroAddress && { + value: await (async () => { + // On chains like Hedera where the native token has fewer than 18 decimals + // (e.g. WHBAR has 8 decimals / tinybars), the fee is returned in those units. + // EVM transactions expect value in wei (18 decimals), so scale up accordingly. + const nativeToken = await this.getNativeTokenForRouter(router) + const { decimals: nativeDecimals } = await this.getTokenInfo(nativeToken) + const evmDecimals = (this.constructor as typeof EVMChain).decimals + return nativeDecimals < evmDecimals + ? message.fee * 10n ** BigInt(evmDecimals - nativeDecimals) + : message.fee + })(), + }), }, ) const txRequests = [...approveTxs, sendTx] as SetRequired[] diff --git a/ccip-sdk/src/sui/index.ts b/ccip-sdk/src/sui/index.ts index 2d35d165..1df371a1 100644 --- a/ccip-sdk/src/sui/index.ts +++ b/ccip-sdk/src/sui/index.ts @@ -4,7 +4,7 @@ import type { Keypair } from '@mysten/sui/cryptography' import { SuiGraphQLClient } from '@mysten/sui/graphql' import { Transaction } from '@mysten/sui/transactions' import { isValidSuiAddress, isValidTransactionDigest, normalizeSuiAddress } from '@mysten/sui/utils' -import { type BytesLike, dataLength, hexlify, isBytesLike, isHexString } from 'ethers' +import { type BytesLike, concat, dataLength, hexlify, isBytesLike, isHexString } from 'ethers' import type { PickDeep, SetOptional } from 'type-fest' import { @@ -36,10 +36,11 @@ import { CCIPSuiLogInvalidError, CCIPTopicsInvalidError, } from '../errors/index.ts' +import { EVMExtraArgsV2Tag } from '../extra-args.ts' import type { EVMExtraArgsV2, ExtraArgs, SVMExtraArgsV1, SuiExtraArgsV1 } from '../extra-args.ts' import type { LeafHasher } from '../hasher/common.ts' -import { decodeMessage, getMessagesInBatch } from '../requests.ts' -import { decodeMoveExtraArgs, getMoveAddress } from '../shared/bcs-codecs.ts' +import { buildMessageForDest, decodeMessage, getMessagesInBatch } from '../requests.ts' +import { BcsEVMExtraArgsV2Codec, decodeMoveExtraArgs, getMoveAddress } from '../shared/bcs-codecs.ts' import { supportedChains } from '../supported-chains.ts' import { type AnyMessage, @@ -71,7 +72,9 @@ import { type TokenConfig, buildManualExecutionPTB, } from './manuallyExec/index.ts' -import type { CCIPMessage_V1_6_Sui } from './types.ts' +import { getFee as getFeeForSend, buildCcipSendPTB } from './send.ts' +import { encodeSuiExtraArgsV1 } from './types.ts' +import type { CCIPMessage_V1_6_Sui, UnsignedSuiTx } from './types.ts' const DEFAULT_GAS_LIMIT = 1000000n @@ -498,42 +501,39 @@ export class SuiChain extends Chain { * @throws {@link CCIPError} if token address is invalid or metadata cannot be loaded */ async getTokenInfo(token: string): Promise<{ symbol: string; decimals: number }> { - const normalizedTokenAddress = normalizeSuiAddress(token) - if (!isValidSuiAddress(normalizedTokenAddress)) { - throw new CCIPError(CCIPErrorCode.UNKNOWN, 'Error loading Sui token metadata') - } - - const objectResponse = await this.client.getObject({ - id: normalizedTokenAddress, - options: { showType: true }, - }) - - const getCoinFromMetadata = (metadata: string) => { - // Extract the type parameter from CoinMetadata<...> - const match = metadata.match(/CoinMetadata<(.+)>$/) + let coinType: string - if (!match || !match[1]) { - throw new CCIPError(CCIPErrorCode.UNKNOWN, `Invalid metadata format: ${metadata}`) + if (token.includes('::')) { + // Coin type string (e.g., "0x2::sui::SUI" or "0xabc::module::TYPE") + coinType = token + } else { + const normalizedTokenAddress = normalizeSuiAddress(token) + if (!isValidSuiAddress(normalizedTokenAddress)) { + throw new CCIPError(CCIPErrorCode.UNKNOWN, 'Error loading Sui token metadata') } - return match[1] - } + const objectResponse = await this.client.getObject({ + id: normalizedTokenAddress, + options: { showType: true }, + }) - let coinType: string - const objectType = objectResponse.data?.type + const getCoinFromMetadata = (metadata: string) => { + const match = metadata.match(/CoinMetadata<(.+)>$/) + if (!match || !match[1]) { + throw new CCIPError(CCIPErrorCode.UNKNOWN, `Invalid metadata format: ${metadata}`) + } + return match[1] + } - // Check if this is a CoinMetadata object or a coin type string - if (objectType?.includes('CoinMetadata')) { - coinType = getCoinFromMetadata(objectType) - } else if (token.includes('::')) { - // This is a coin type string (e.g., "0xabc::coin::COIN") - coinType = token - } else { - // This is a package address or unknown format - throw new CCIPError( - CCIPErrorCode.UNKNOWN, - `Token address ${token} is not a CoinMetadata object or coin type. Expected format: package::module::Type`, - ) + const objectType = objectResponse.data?.type + if (objectType?.includes('CoinMetadata')) { + coinType = getCoinFromMetadata(objectType) + } else { + throw new CCIPError( + CCIPErrorCode.UNKNOWN, + `Token address ${token} is not a CoinMetadata object or coin type. Expected format: package::module::Type`, + ) + } } if (coinType.split('::').length < 3) { @@ -610,8 +610,15 @@ export class SuiChain extends Chain { * @returns Encoded extra arguments as a hex string. * @throws {@link CCIPNotImplementedError} always (not yet implemented) */ - static encodeExtraArgs(_extraArgs: ExtraArgs): string { - throw new CCIPNotImplementedError() + static encodeExtraArgs(extraArgs: ExtraArgs): string { + if ('gasLimit' in extraArgs && 'allowOutOfOrderExecution' in extraArgs) { + // EVM-style extra args: use BCS encoding with EVMExtraArgsV2Tag (same as Aptos) + return concat([EVMExtraArgsV2Tag, BcsEVMExtraArgsV2Codec.serialize(extraArgs).toBytes()]) + } + if ('tokenReceiver' in extraArgs && 'receiverObjectIds' in extraArgs) { + return encodeSuiExtraArgsV1(extraArgs as SuiExtraArgsV1) + } + throw new CCIPNotImplementedError('SuiChain.encodeExtraArgs: unsupported extra args format') } /** @@ -711,20 +718,90 @@ export class SuiChain extends Chain { } /** {@inheritDoc Chain.getFee} */ - async getFee(_opts: Parameters[0]): Promise { - return Promise.reject(new CCIPNotImplementedError('SuiChain.getFee')) + async getFee(opts: Parameters[0]): Promise { + const populatedMessage = buildMessageForDest( + opts.message, + networkInfo(opts.destChainSelector).family, + ) + return getFeeForSend(this.client, opts.router, opts.destChainSelector, populatedMessage) } /** {@inheritDoc Chain.generateUnsignedSendMessage} */ - override generateUnsignedSendMessage( - _opts: Parameters[0], - ): Promise { - return Promise.reject(new CCIPNotImplementedError('SuiChain.generateUnsignedSendMessage')) + override async generateUnsignedSendMessage( + opts: Parameters[0], + ): Promise { + const populatedMessage = buildMessageForDest( + opts.message, + networkInfo(opts.destChainSelector).family, + ) + const fee = + opts.message.fee ?? (await this.getFee({ ...opts, message: populatedMessage })) + const message = { ...populatedMessage, fee } + const tx = await buildCcipSendPTB( + this.client, + opts.sender, + opts.router, + opts.destChainSelector, + message, + ) + const txBytes = await tx.build({ client: this.client }) + return { + family: ChainFamily.Sui, + transactions: [txBytes], + } } /** {@inheritDoc Chain.sendMessage} */ - async sendMessage(_opts: Parameters[0]): Promise { - return Promise.reject(new CCIPNotImplementedError('SuiChain.sendMessage')) + async sendMessage(opts: Parameters[0]): Promise { + const wallet = opts.wallet as Keypair + const sender = wallet.toSuiAddress() + + const populatedMessage = buildMessageForDest( + opts.message, + networkInfo(opts.destChainSelector).family, + ) + const fee = + opts.message.fee ?? (await this.getFee({ ...opts, message: populatedMessage })) + const message = { ...populatedMessage, fee } + + const tx = await buildCcipSendPTB( + this.client, + sender, + opts.router, + opts.destChainSelector, + message, + ) + + this.logger.info('Sending Sui CCIP message...') + let result: SuiTransactionBlockResponse + try { + result = await this.client.signAndExecuteTransaction({ + signer: wallet, + transaction: tx, + options: { + showEffects: true, + showEvents: true, + }, + }) + } catch (e) { + throw new CCIPError( + CCIPErrorCode.TRANSACTION_NOT_FINALIZED, + `Failed to send Sui CCIP transaction: ${(e as Error).message}`, + ) + } + + if (result.effects?.status.status !== 'success') { + const errorMsg = result.effects?.status.error || 'Unknown error' + throw new CCIPError(CCIPErrorCode.UNKNOWN, `Sui CCIP send reverted: ${errorMsg}`) + } + + this.logger.info(`Waiting for Sui transaction ${result.digest} to be finalized...`) + await this.client.waitForTransaction({ + digest: result.digest, + options: { showEffects: true, showEvents: true }, + }) + + return (await this.getMessagesInTx(await this.getTransaction(result.digest)))[0]! } /** {@inheritDoc Chain.generateUnsignedExecute} */ diff --git a/ccip-sdk/src/sui/send.ts b/ccip-sdk/src/sui/send.ts new file mode 100644 index 00000000..7df8ddcd --- /dev/null +++ b/ccip-sdk/src/sui/send.ts @@ -0,0 +1,438 @@ +import type { SuiClient } from '@mysten/sui/client' +import { Transaction } from '@mysten/sui/transactions' +import { normalizeSuiAddress } from '@mysten/sui/utils' +import { getBytes, hexlify, zeroPadValue } from 'ethers' + +import { CCIPError, CCIPErrorCode } from '../errors/index.ts' +import { encodeExtraArgs } from '../extra-args.ts' +import type { AnyMessage } from '../types.ts' +import { ChainFamily } from '../types.ts' +import { getAddressBytes, getDataBytes } from '../utils.ts' +import { fetchTokenConfigs, getLatestPackageId, getObjectRef } from './objects.ts' +import { getCcipStateAddress } from './discovery.ts' + +const SUI_CLOCK = '0x6' +const SUI_NATIVE_COIN_TYPE = '0x2::sui::SUI' + +/** + * Discovers the onramp package for a given destination chain from the router. + */ +export async function discoverOnRamp( + client: SuiClient, + routerPkg: string, + destChainSelector: bigint, +): Promise<{ onRampPkg: string; routerStateId: string }> { + const routerAddress = routerPkg.includes('::') ? routerPkg : routerPkg + '::router' + const routerPkgId = routerAddress.split('::')[0]! + + const ownedObjs = await client.getOwnedObjects({ + owner: routerPkgId, + filter: { StructType: `${routerPkgId}::router::RouterStatePointer` }, + options: { showContent: true }, + }) + + const pointer = ownedObjs.data[0]?.data + if (!pointer?.content || pointer.content.dataType !== 'moveObject') { + throw new CCIPError(CCIPErrorCode.UNKNOWN, 'RouterStatePointer not found') + } + + const parentId = (pointer.content.fields as Record)['router_object_id'] as string + if (!parentId) throw new CCIPError(CCIPErrorCode.UNKNOWN, 'router_object_id not found in pointer') + + const { deriveObjectID } = await import('./objects.ts') + const routerStateId = deriveObjectID(parentId, new TextEncoder().encode('RouterState')) + + const tx = new Transaction() + tx.moveCall({ + target: `${routerPkgId}::router::get_on_ramp`, + arguments: [tx.object(routerStateId), tx.pure.u64(destChainSelector)], + }) + + const result = await client.devInspectTransactionBlock({ + sender: normalizeSuiAddress('0x0'), + transactionBlock: tx, + }) + + if (result.error || !result.results?.[0]?.returnValues?.[0]) { + throw new CCIPError( + CCIPErrorCode.UNKNOWN, + `Failed to get onramp for dest chain ${destChainSelector}: ${result.error}`, + ) + } + + const { bcs } = await import('@mysten/sui/bcs') + const addrBytes = result.results[0].returnValues[0][0] + const onRampPkg = normalizeSuiAddress(hexlify(bcs.Address.parse(new Uint8Array(addrBytes)))) + + return { onRampPkg, routerStateId } +} + +/** + * Resolves all objects needed for a Sui CCIP send transaction. + */ +export async function resolveSendObjects( + client: SuiClient, + routerPkg: string, + destChainSelector: bigint, +) { + const { onRampPkg } = await discoverOnRamp(client, routerPkg, destChainSelector) + const onRampAddress = onRampPkg + '::onramp' + + const ccipAddress = await getCcipStateAddress(onRampAddress, client) + const ccipObjectRef = await getObjectRef(ccipAddress, client) + const onRampState = await getObjectRef(onRampAddress, client) + + return { onRampPkg, onRampAddress, ccipAddress, ccipObjectRef, onRampState } +} + +/** + * Discovers the CoinMetadata object ID for SUI from the fee quoter's configured fee tokens. + * We can't use getCoinMetadata() for SUI because it returns a Currency object on newer Sui, + * not the CoinMetadata object expected by the onramp contract. + */ +async function discoverSuiFeeTokenMetadata( + client: SuiClient, + ccipPkg: string, + ccipObjectRef: string, +): Promise<{ coinType: string; metadataId: string }> { + const tx = new Transaction() + tx.moveCall({ + target: `${ccipPkg}::fee_quoter::get_fee_tokens`, + arguments: [tx.object(ccipObjectRef)], + }) + const result = await client.devInspectTransactionBlock({ + sender: normalizeSuiAddress('0x0'), + transactionBlock: tx, + }) + if (result.error || !result.results?.[0]?.returnValues?.[0]) { + throw new CCIPError(CCIPErrorCode.UNKNOWN, 'Failed to get fee tokens from fee quoter') + } + + const bytes = result.results[0].returnValues[0][0] + const count = bytes[0]! + let offset = 1 + for (let i = 0; i < count; i++) { + const addr = normalizeSuiAddress( + '0x' + Buffer.from(bytes.slice(offset, offset + 32)).toString('hex'), + ) + offset += 32 + + const obj = await client.getObject({ id: addr, options: { showType: true } }) + const objType = obj.data?.type + if (objType?.includes('::sui::SUI')) { + return { coinType: SUI_NATIVE_COIN_TYPE, metadataId: addr } + } + } + + throw new CCIPError(CCIPErrorCode.UNKNOWN, 'SUI not found among configured fee tokens') +} + +/** + * Gets the fee for a CCIP send from Sui. + * + * get_fee(&CCIPObjectRef, &Clock, u64, vector, vector, + * vector
, vector, &CoinMetadata, vector) -> u64 + */ +export async function getFee( + client: SuiClient, + routerPkg: string, + destChainSelector: bigint, + message: AnyMessage, +): Promise { + const { onRampPkg, ccipAddress, ccipObjectRef } = await resolveSendObjects( + client, + routerPkg, + destChainSelector, + ) + + const receiver = Array.from(getBytes(zeroPadValue(getDataBytes(message.receiver), 32))) + const data = Array.from(getDataBytes(message.data || '0x')) + const tokenAddresses = (message.tokenAmounts ?? []).map((ta) => ta.token) + const tokenAmounts = (message.tokenAmounts ?? []).map((ta) => Number(ta.amount)) + const extraArgs = Array.from(getBytes(encodeExtraArgs(message.extraArgs, ChainFamily.Sui))) + + const ccipPkg = ccipAddress.split('::')[0]! + const feeToken = await discoverSuiFeeTokenMetadata(client, ccipPkg, ccipObjectRef) + + const tx = new Transaction() + tx.moveCall({ + target: `${onRampPkg}::onramp::get_fee`, + typeArguments: [feeToken.coinType], + arguments: [ + tx.object(ccipObjectRef), + tx.object(SUI_CLOCK), + tx.pure.u64(destChainSelector), + tx.pure.vector('u8', receiver), + tx.pure.vector('u8', data), + tx.pure.vector('address', tokenAddresses), + tx.pure.vector('u64', tokenAmounts), + tx.object(feeToken.metadataId), + tx.pure.vector('u8', extraArgs), + ], + }) + + const result = await client.devInspectTransactionBlock({ + sender: normalizeSuiAddress('0x0'), + transactionBlock: tx, + }) + + if (result.error || !result.results?.[0]?.returnValues?.[0]) { + throw new CCIPError( + CCIPErrorCode.UNKNOWN, + `Failed to get fee: ${result.error || 'no return value'}`, + ) + } + + const feeBytes = result.results[0].returnValues[0][0] + const { bcs } = await import('@mysten/sui/bcs') + return bcs.u64().parse(new Uint8Array(feeBytes)) +} + +/** + * Builds a Programmable Transaction Block for ccip_send on Sui. + * + * The PTB flow: + * 1. create_token_transfer_params(token_receiver) + * 2. For each token: lock_or_burn(...) on the token pool + * 3. Split fee coin from sender's SUI + * 4. ccip_send(ccipRef, onrampState, clock, destSelector, receiver, data, + * tokenParams, suiMetadata, feeCoin, extraArgs, ctx) + */ +export async function buildCcipSendPTB( + client: SuiClient, + sender: string, + routerPkg: string, + destChainSelector: bigint, + message: AnyMessage & { fee: bigint }, +): Promise { + const { onRampPkg, ccipAddress, ccipObjectRef, onRampState } = await resolveSendObjects( + client, + routerPkg, + destChainSelector, + ) + + const receiver = Array.from(getBytes(zeroPadValue(getDataBytes(message.receiver), 32))) + const data = Array.from(getDataBytes(message.data || '0x')) + const extraArgs = Array.from(getBytes(encodeExtraArgs(message.extraArgs, ChainFamily.Sui))) + + const ccipPkg = ccipAddress.split('::')[0]! + const feeToken = await discoverSuiFeeTokenMetadata(client, ccipPkg, ccipObjectRef) + + const tx = new Transaction() + + // Encode tokenReceiver for create_token_transfer_params + // When transferring tokens, the receiver needs the token; use the message receiver as default + let tokenReceiverBytes: number[] = [] + if (message.extraArgs && 'tokenReceiver' in message.extraArgs && message.extraArgs.tokenReceiver) { + tokenReceiverBytes = Array.from(getAddressBytes(message.extraArgs.tokenReceiver as string)) + } else if (message.tokenAmounts?.length) { + tokenReceiverBytes = Array.from(getBytes(zeroPadValue(getDataBytes(message.receiver), 32))) + } + + // Step 1: Create token transfer params + const tokenParams = tx.moveCall({ + target: `${ccipAddress.split('::')[0]}::onramp_state_helper::create_token_transfer_params`, + arguments: [tx.pure.vector('u8', tokenReceiverBytes)], + }) + + // Step 2: Process token transfers (lock_or_burn for each token) + if (message.tokenAmounts?.length) { + const tokenConfigs = await fetchSendTokenConfigs( + client, + ccipAddress, + ccipObjectRef, + message.tokenAmounts.map((ta) => ta.token), + ) + + for (let i = 0; i < message.tokenAmounts.length; i++) { + const ta = message.tokenAmounts[i]! + const config = tokenConfigs[i]! + + // Get the sender's coins of this type and split the right amount + const coinType = config.tokenType + const coins = await client.getCoins({ owner: sender, coinType }) + if (!coins.data.length) { + throw new CCIPError( + CCIPErrorCode.INSUFFICIENT_BALANCE, + `No ${coinType} coins found for sender`, + ) + } + + let tokenCoin + if (coins.data.length === 1) { + const [primary] = tx.splitCoins(tx.object(coins.data[0]!.coinObjectId), [ta.amount]) + tokenCoin = primary! + } else { + const primary = tx.object(coins.data[0]!.coinObjectId) + if (coins.data.length > 1) { + tx.mergeCoins( + primary, + coins.data.slice(1).map((c) => tx.object(c.coinObjectId)), + ) + } + const [split] = tx.splitCoins(primary, [ta.amount]) + tokenCoin = split! + } + + // Call lock_or_burn on the token pool + tx.moveCall({ + target: `${config.tokenPoolPackageId}::${config.tokenPoolModule}::lock_or_burn`, + typeArguments: [coinType], + arguments: [ + tx.object(ccipObjectRef), + tokenParams, + tokenCoin, + tx.pure.u64(destChainSelector), + ...config.lockOrBurnParams.map((p) => tx.object(p)), + ], + }) + } + } + + // Step 3: Split fee coin from sender's SUI gas coin + const [feeCoin] = tx.splitCoins(tx.gas, [message.fee]) + + // Step 4: ccip_send (returns message_id as vector) + tx.moveCall({ + target: `${onRampPkg}::onramp::ccip_send`, + typeArguments: [feeToken.coinType], + arguments: [ + tx.object(ccipObjectRef), + tx.object(onRampState), + tx.object(SUI_CLOCK), + tx.pure.u64(destChainSelector), + tx.pure.vector('u8', receiver), + tx.pure.vector('u8', data), + tokenParams, + tx.object(feeToken.metadataId), + feeCoin!, + tx.pure.vector('u8', extraArgs), + ], + }) + + // Return the remaining fee coin (Coin doesn't have Drop) + tx.transferObjects([feeCoin!], sender) + + return tx +} + +/** + * Fetches token pool configs needed for lock_or_burn on the send (onramp) side. + */ +async function fetchSendTokenConfigs( + client: SuiClient, + ccipAddress: string, + ccipObjectRef: string, + tokenAddresses: string[], +) { + const ccipPkg = ccipAddress.split('::')[0]! + const configs = [] + + for (const tokenAddr of tokenAddresses) { + // Resolve coin metadata ID from the token address + let coinMetadataId: string + if (tokenAddr.includes('::')) { + const metadata = await client.getCoinMetadata({ coinType: tokenAddr }) + if (!metadata?.id) { + throw new CCIPError( + CCIPErrorCode.UNKNOWN, + `CoinMetadata not found for ${tokenAddr}`, + ) + } + coinMetadataId = metadata.id + } else { + coinMetadataId = tokenAddr + } + + // Get pool address + const tx1 = new Transaction() + tx1.moveCall({ + target: `${ccipPkg}::token_admin_registry::get_pool`, + arguments: [tx1.object(ccipObjectRef), tx1.pure.address(coinMetadataId)], + }) + const result1 = await client.devInspectTransactionBlock({ + sender: normalizeSuiAddress('0x0'), + transactionBlock: tx1, + }) + if (result1.error || !result1.results?.[0]?.returnValues?.[0]) { + throw new CCIPError(CCIPErrorCode.UNKNOWN, `Failed to get pool for ${tokenAddr}`) + } + const poolAddr = normalizeSuiAddress( + '0x' + Buffer.from(result1.results[0].returnValues[0][0]).toString('hex'), + ) + if (poolAddr === normalizeSuiAddress('0x0')) { + throw new CCIPError(CCIPErrorCode.UNKNOWN, `No token pool registered for ${tokenAddr}`) + } + + // Get full token config including lockOrBurnParams + const tx2 = new Transaction() + tx2.moveCall({ + target: `${ccipPkg}::token_admin_registry::get_token_config_struct`, + arguments: [tx2.object(ccipObjectRef), tx2.pure.address(coinMetadataId)], + }) + const result2 = await client.devInspectTransactionBlock({ + sender: normalizeSuiAddress('0x0'), + transactionBlock: tx2, + }) + if (result2.error || !result2.results?.[0]?.returnValues?.[0]) { + throw new CCIPError(CCIPErrorCode.UNKNOWN, `Failed to get token config for ${tokenAddr}`) + } + + const configBytes = result2.results[0].returnValues[0][0] + let offset = 0 + + // TokenPoolPackageId (32 bytes) + const tokenPoolPackageId = normalizeSuiAddress( + '0x' + Buffer.from(configBytes.slice(offset, offset + 32)).toString('hex'), + ) + offset += 32 + + // TokenPoolModule (String) + const modLen = configBytes[offset]! + offset += 1 + const tokenPoolModule = new TextDecoder().decode( + new Uint8Array(configBytes.slice(offset, offset + modLen)), + ) + offset += modLen + + // TokenType (ascii::String) + const typeLen = configBytes[offset]! + offset += 1 + const tokenType = new TextDecoder().decode( + new Uint8Array(configBytes.slice(offset, offset + typeLen)), + ) + offset += typeLen + + // Skip Administrator (32 bytes) + PendingAdministrator (32 bytes) + offset += 64 + + // Skip TokenPoolTypeProof (ascii::String) + const proofLen = configBytes[offset]! + offset += 1 + proofLen + + // LockOrBurnParams (vector
) + const lobCount = configBytes[offset]! + offset += 1 + const lockOrBurnParams: string[] = [] + for (let i = 0; i < lobCount; i++) { + lockOrBurnParams.push( + normalizeSuiAddress( + '0x' + Buffer.from(configBytes.slice(offset, offset + 32)).toString('hex'), + ), + ) + offset += 32 + } + + // Prepend 0x to tokenType if needed for full coin type + const fullTokenType = tokenType.startsWith('0x') ? tokenType : '0x' + tokenType + + configs.push({ + tokenPoolPackageId, + tokenPoolModule, + tokenType: fullTokenType, + lockOrBurnParams, + }) + } + + return configs +} diff --git a/ccip-sdk/src/sui/types.ts b/ccip-sdk/src/sui/types.ts index 467e6901..60e78c27 100644 --- a/ccip-sdk/src/sui/types.ts +++ b/ccip-sdk/src/sui/types.ts @@ -3,11 +3,18 @@ import { concat } from 'ethers' import { type SuiExtraArgsV1, SuiExtraArgsV1Tag } from '../extra-args.ts' import type { CCIPMessage_V1_6 } from '../types.ts' +import { ChainFamily } from '../types.ts' import { getAddressBytes, getDataBytes } from '../utils.ts' /** Sui-specific CCIP v1.6 message type with Sui extra args. */ export type CCIPMessage_V1_6_Sui = CCIPMessage_V1_6 & SuiExtraArgsV1 +/** Unsigned Sui transaction, serialized as bytes. */ +export type UnsignedSuiTx = { + family: typeof ChainFamily.Sui + transactions: [Uint8Array] +} + export const SuiExtraArgsV1Codec = bcs.struct('SuiExtraArgsV1', { gasLimit: bcs.u64(), allowOutOfOrderExecution: bcs.bool(),