diff --git a/Anchor.toml b/Anchor.toml index 2659136a9..840b5df12 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -8,6 +8,7 @@ skip-lint = false multicall_handler = "HaQe51FWtnmaEcuYEfPA7MRCXKrtqptat4oJdJ8zV5Be" svm_spoke = "DLv3NggMiSaef97YCkew5xKUHDh13tVGZ7tydt3ZeAru" test = "8tsEfDSiE4WUMf97oyyyasLAvWwjeRZb2GByh4w7HckA" +sponsored_cctp_src_periphery = "CPr4bRvkVKcSCLyrQpkZrRrwGzQeVAXutFU8WupuBLXq" [programs.devnet] multicall_handler = "Fk1RpqsfeWt8KnFCTW9NQVdVxYvxuqjGn6iPB9wrmM8h" @@ -56,6 +57,9 @@ findFillStatusFromFillStatusPda = "NODE_NO_WARNINGS=1 yarn run ts-node ./scripts nativeDeposit = "NODE_NO_WARNINGS=1 yarn run ts-node ./scripts/svm/nativeDeposit.ts" squadsIdlUpgrade = "NODE_NO_WARNINGS=1 yarn run ts-node ./scripts/svm/squadsIdlUpgrade.ts" +[test] +upgradeable = true + [test.validator] url = "https://api.mainnet-beta.solana.com" @@ -84,3 +88,28 @@ address = "Afgq3BHEfCE7d78D2XE9Bfyu2ieDqvE24xX8KDwreBms" ### Circle Token Messenger Minter PDA -- Ethereum Remote Token Messenger [[test.validator.clone]] address = "Hazwi3jFQtLKc2ughi7HFXPkpDeso7DQaMR9Ks4afh3j" + +### Forked Circle MessageTransmitterV2 Program +[[test.validator.clone]] +address = "CCTPV2Sm4AdWt5296sk4P66VBZ7bEhcARwFaaS9YPbeC" + +### Forked Circle TokenMessengerMinterV2 Program +[[test.validator.clone]] +address = "CCTPV2vPZJS2u2BBsUoscuikbYjnpFmbFsvVuJdgUMQe" + +### Forked Circle MessageTransmitterV2 PDA -- Message Transmitter Config +[[test.validator.clone]] +address = "W1k5ijkaSTo5iA5zChNpfzcy796fLhkBxfmJuR8W8HU" + +### Forked Circle TokenMessengerMinterV2 PDA -- Token Messenger +[[test.validator.clone]] +address = "AawthJCGRmggpfv9MMWV6Jmo9cue4gL9wUZgRBShg58W" + +### Circle TokenMessengerMinterV2 PDA -- Ethereum Remote Token Messenger +[[test.validator.clone]] +address = "3EzN2mcmdfSNGXRCAixSpTteK6ywdmFDZZWvkMnznFt9" + +### Circle TokenMessengerMinterV2 PDA -- Token Minter (Modified with token_controller set to test wallet) +[[test.validator.account]] +address = "E1bQJ8eMMn3zmeSewW3HQ8zmJr7KR75JonbwAtWx2bux" +filename = "test/svm/accounts/token_minter_v2.json" diff --git a/Cargo.lock b/Cargo.lock index 27b96e891..77b479fce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -554,6 +554,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-mac" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b584a330336237c1eecd3e94266efb216c56ed91225d634cb2991c5f3fd1aeab" +dependencies = [ + "generic-array", + "subtle", +] + [[package]] name = "ctr" version = "0.9.2" @@ -721,6 +731,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "hmac" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "126888268dcc288495a26bf004b38c5fdbb31682f992c84ceb046a1f0fe38840" +dependencies = [ + "crypto-mac", + "digest 0.9.0", +] + [[package]] name = "hmac" version = "0.12.1" @@ -730,6 +750,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac-drbg" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17ea0a1394df5b6574da6e0c1ade9e78868c9fb0a4e5ef4428e32da4676b85b1" +dependencies = [ + "digest 0.9.0", + "generic-array", + "hmac 0.8.1", +] + [[package]] name = "indexmap" version = "2.7.0" @@ -804,14 +835,33 @@ dependencies = [ "arrayref", "base64 0.12.3", "digest 0.9.0", - "libsecp256k1-core", - "libsecp256k1-gen-ecmult", - "libsecp256k1-gen-genmult", + "libsecp256k1-core 0.2.2", + "libsecp256k1-gen-ecmult 0.2.1", + "libsecp256k1-gen-genmult 0.2.1", "rand 0.7.3", "serde", "sha2 0.9.9", ] +[[package]] +name = "libsecp256k1" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79019718125edc905a079a70cfa5f3820bc76139fc91d6f9abc27ea2a887139" +dependencies = [ + "arrayref", + "base64 0.22.1", + "digest 0.9.0", + "hmac-drbg", + "libsecp256k1-core 0.3.0", + "libsecp256k1-gen-ecmult 0.3.0", + "libsecp256k1-gen-genmult 0.3.0", + "rand 0.8.5", + "serde", + "sha2 0.9.9", + "typenum", +] + [[package]] name = "libsecp256k1-core" version = "0.2.2" @@ -823,13 +873,33 @@ dependencies = [ "subtle", ] +[[package]] +name = "libsecp256k1-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be9b9bb642d8522a44d533eab56c16c738301965504753b03ad1de3425d5451" +dependencies = [ + "crunchy", + "digest 0.9.0", + "subtle", +] + [[package]] name = "libsecp256k1-gen-ecmult" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccab96b584d38fac86a83f07e659f0deafd0253dc096dab5a36d53efe653c5c3" dependencies = [ - "libsecp256k1-core", + "libsecp256k1-core 0.2.2", +] + +[[package]] +name = "libsecp256k1-gen-ecmult" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3038c808c55c87e8a172643a7d87187fc6c4174468159cb3090659d55bcb4809" +dependencies = [ + "libsecp256k1-core 0.3.0", ] [[package]] @@ -838,7 +908,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67abfe149395e3aa1c48a2beb32b068e2334402df8181f818d3aee2b304c4f5d" dependencies = [ - "libsecp256k1-core", + "libsecp256k1-core 0.2.2", +] + +[[package]] +name = "libsecp256k1-gen-genmult" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db8d6ba2cec9eacc40e6e8ccc98931840301f1006e95647ceb2dd5c3aa06f7c" +dependencies = [ + "libsecp256k1-core 0.3.0", ] [[package]] @@ -1926,7 +2005,7 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baa3120b6cdaa270f39444f5093a90a7b03d296d362878f7a6991d6de3bbe496" dependencies = [ - "libsecp256k1", + "libsecp256k1 0.6.0", "solana-define-syscall", "thiserror 2.0.12", ] @@ -1952,7 +2031,7 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36187af2324f079f65a675ec22b31c24919cb4ac22c79472e85d819db9bbbc15" dependencies = [ - "hmac", + "hmac 0.12.1", "pbkdf2", "sha2 0.10.8", ] @@ -2528,6 +2607,16 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "sponsored-cctp-src-periphery" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "anchor-spl", + "libsecp256k1 0.7.2", + "solana-security-txt", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/programs/sponsored-cctp-src-periphery/Cargo.toml b/programs/sponsored-cctp-src-periphery/Cargo.toml new file mode 100644 index 000000000..ce908f827 --- /dev/null +++ b/programs/sponsored-cctp-src-periphery/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "sponsored-cctp-src-periphery" +version = "0.1.0" +description = "Created with Anchor" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "sponsored_cctp_src_periphery" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] +test = [] + +[dependencies] +anchor-lang = { version = "0.31.1", features = ["event-cpi"]} +anchor-spl = "0.31.1" +libsecp256k1 = "0.7.2" +solana-security-txt = "1.1.1" diff --git a/programs/sponsored-cctp-src-periphery/Xargo.toml b/programs/sponsored-cctp-src-periphery/Xargo.toml new file mode 100644 index 000000000..475fb71ed --- /dev/null +++ b/programs/sponsored-cctp-src-periphery/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/programs/sponsored-cctp-src-periphery/src/error.rs b/programs/sponsored-cctp-src-periphery/src/error.rs new file mode 100644 index 000000000..895e6e0e6 --- /dev/null +++ b/programs/sponsored-cctp-src-periphery/src/error.rs @@ -0,0 +1,46 @@ +use anchor_lang::prelude::*; + +// Common Errors with EVM SponsoredCCTPSrcPeriphery. +#[error_code] +pub enum CommonError { + #[msg("Invalid quote signature")] + InvalidSignature, + #[msg("Invalid quote deadline")] + InvalidDeadline, + #[msg("Invalid source domain")] + InvalidSourceDomain, +} + +// SVM specific errors. +#[error_code] +pub enum SvmError { + #[msg("Only the upgrade authority can call this instruction")] + NotUpgradeAuthority, + #[msg("Invalid program data account")] + InvalidProgramData, + #[msg("Cannot set time if not in test mode")] + CannotSetCurrentTime, + #[msg("Invalid burn_token key")] + InvalidBurnToken, + #[msg("Amount must be greater than 0")] + AmountNotPositive, + #[msg("The quote deadline has not passed!")] + QuoteDeadlineNotPassed, + #[msg("New signer unchanged")] + SignerUnchanged, + #[msg("Invalid quote data length")] + InvalidQuoteDataLength, +} + +// EVM decoding errors. +#[error_code] +pub enum DataDecodingError { + #[msg("Cannot decode to u32")] + CannotDecodeToU32, + #[msg("Cannot decode to u64")] + CannotDecodeToU64, + #[msg("Cannot decode to i64")] + CannotDecodeToI64, + #[msg("Cannot decode bytes")] + CannotDecodeBytes, +} diff --git a/programs/sponsored-cctp-src-periphery/src/event.rs b/programs/sponsored-cctp-src-periphery/src/event.rs new file mode 100644 index 000000000..a908a8013 --- /dev/null +++ b/programs/sponsored-cctp-src-periphery/src/event.rs @@ -0,0 +1,41 @@ +use anchor_lang::prelude::*; + +#[event] +pub struct SignerSet { + pub old_signer: Pubkey, + pub new_signer: Pubkey, +} + +#[event] +pub struct WithdrawnRentFund { + pub amount: u64, + pub recipient: Pubkey, +} + +#[event] +pub struct SponsoredDepositForBurn { + pub quote_nonce: Vec, // Nonce is bytes32 random value, but it is more readable in logs expressed as encoded data blob. + pub origin_sender: Pubkey, + pub final_recipient: Pubkey, + pub quote_deadline: i64, + pub max_bps_to_sponsor: u64, + pub max_user_slippage_bps: u64, + pub final_token: Pubkey, + pub signature: Vec, +} + +#[event] +pub struct CreatedEventAccount { + pub message_sent_event_data: Pubkey, +} + +#[event] +pub struct ReclaimedEventAccount { + pub message_sent_event_data: Pubkey, +} + +#[event] +pub struct ReclaimedUsedNonceAccount { + pub nonce: Vec, // Nonce is bytes32 random value, but it is more readable in logs expressed as encoded data blob. + pub used_nonce: Pubkey, // PDA derived from above nonce that got closed. +} diff --git a/programs/sponsored-cctp-src-periphery/src/instructions/admin.rs b/programs/sponsored-cctp-src-periphery/src/instructions/admin.rs new file mode 100644 index 000000000..20f227b65 --- /dev/null +++ b/programs/sponsored-cctp-src-periphery/src/instructions/admin.rs @@ -0,0 +1,143 @@ +use anchor_lang::{ + prelude::*, + system_program::{self, Transfer}, +}; + +use crate::{ + error::SvmError, + event::{SignerSet, WithdrawnRentFund}, + program, + state::State, + utils::initialize_current_time, +}; + +#[event_cpi] +#[derive(Accounts)] +#[instruction(params: InitializeParams)] +pub struct Initialize<'info> { + #[account( + mut, + address = program_data.upgrade_authority_address.unwrap_or_default() @ SvmError::NotUpgradeAuthority + )] + pub signer: Signer<'info>, + + #[account(init, payer = signer, space = State::DISCRIMINATOR.len() + State::INIT_SPACE, seeds = [b"state"], bump)] + pub state: Account<'info, State>, + + #[account(address = this_program.programdata_address()?.unwrap_or_default() @ SvmError::InvalidProgramData)] + pub program_data: Account<'info, ProgramData>, + + // This is duplicate of program account added by event_cpi, but we need it to access its programdata_address. + pub this_program: Program<'info, program::SponsoredCctpSrcPeriphery>, + + pub system_program: Program<'info, System>, +} + +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct InitializeParams { + pub source_domain: u32, + pub signer: Pubkey, +} + +pub fn initialize(ctx: Context, params: &InitializeParams) -> Result<()> { + let state = &mut ctx.accounts.state; + + // Set current time in test mode (no-op in production). + initialize_current_time(state)?; + + // Set immutable source CCTP domain. + state.source_domain = params.source_domain; + + // Set and log initial quote signer. + state.signer = params.signer; + emit_cpi!(SignerSet { old_signer: Pubkey::default(), new_signer: params.signer }); + + Ok(()) +} + +#[event_cpi] +#[derive(Accounts)] +pub struct SetSigner<'info> { + #[account( + mut, + address = program_data.upgrade_authority_address.unwrap_or_default() @ SvmError::NotUpgradeAuthority + )] + pub signer: Signer<'info>, + + #[account(mut, seeds = [b"state"], bump)] + pub state: Account<'info, State>, + + #[account(address = this_program.programdata_address()?.unwrap_or_default() @ SvmError::InvalidProgramData)] + pub program_data: Account<'info, ProgramData>, + + // This is duplicate of program account added by event_cpi, but we need it to access its programdata_address. + pub this_program: Program<'info, program::SponsoredCctpSrcPeriphery>, +} + +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct SetSignerParams { + pub new_signer: Pubkey, +} + +// Setting the quote signer to invalid address, including Pubkey::default(), would effectively disable deposits. +pub fn set_signer(ctx: Context, params: &SetSignerParams) -> Result<()> { + let state = &mut ctx.accounts.state; + + let old_signer = state.signer; + if params.new_signer == old_signer { + return err!(SvmError::SignerUnchanged); + } + + // Set and log old/new quote signer. + state.signer = params.new_signer; + emit_cpi!(SignerSet { old_signer, new_signer: params.new_signer }); + + Ok(()) +} + +#[event_cpi] +#[derive(Accounts)] +pub struct WithdrawRentFund<'info> { + #[account( + mut, + address = program_data.upgrade_authority_address.unwrap_or_default() @ SvmError::NotUpgradeAuthority + )] + pub signer: Signer<'info>, + + #[account(mut, seeds = [b"rent_fund"], bump)] + pub rent_fund: SystemAccount<'info>, + + /// CHECK: Upgrade authority can withdraw from rent_fund to any account. + #[account(mut)] + pub recipient: UncheckedAccount<'info>, + + #[account(address = this_program.programdata_address()?.unwrap_or_default() @ SvmError::InvalidProgramData)] + pub program_data: Account<'info, ProgramData>, + + // This is duplicate of program account added by event_cpi, but we need it to access its programdata_address. + pub this_program: Program<'info, program::SponsoredCctpSrcPeriphery>, + + pub system_program: Program<'info, System>, +} + +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct WithdrawRentFundParams { + pub amount: u64, +} + +pub fn withdraw_rent_fund(ctx: Context, params: &WithdrawRentFundParams) -> Result<()> { + if params.amount == 0 { + return err!(SvmError::AmountNotPositive); + } + + let cpi_accounts = + Transfer { from: ctx.accounts.rent_fund.to_account_info(), to: ctx.accounts.recipient.to_account_info() }; + let rent_fund_seeds: &[&[&[u8]]] = &[&[b"rent_fund", &[ctx.bumps.rent_fund]]]; + let cpi_ctx = + CpiContext::new_with_signer(ctx.accounts.system_program.to_account_info(), cpi_accounts, rent_fund_seeds); + system_program::transfer(cpi_ctx, params.amount)?; + + emit_cpi!(WithdrawnRentFund { amount: params.amount, recipient: ctx.accounts.recipient.key() }); + + Ok(()) +} diff --git a/programs/sponsored-cctp-src-periphery/src/instructions/deposit.rs b/programs/sponsored-cctp-src-periphery/src/instructions/deposit.rs new file mode 100644 index 000000000..bc37d202f --- /dev/null +++ b/programs/sponsored-cctp-src-periphery/src/instructions/deposit.rs @@ -0,0 +1,299 @@ +use anchor_lang::{prelude::*, system_program}; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; + +pub use crate::message_transmitter_v2::types::ReclaimEventAccountParams; +use crate::{ + error::{CommonError, SvmError}, + event::{CreatedEventAccount, ReclaimedEventAccount, ReclaimedUsedNonceAccount, SponsoredDepositForBurn}, + message_transmitter_v2::{self, program::MessageTransmitterV2}, + state::{State, UsedNonce}, + token_messenger_minter_v2::{ + self, cpi::accounts::DepositForBurnWithHook, program::TokenMessengerMinterV2, + types::DepositForBurnWithHookParams, + }, + utils::{get_current_time, validate_signature, SponsoredCCTPQuote, NONCE_END, NONCE_START}, +}; + +#[event_cpi] +#[derive(Accounts)] +#[instruction(params: DepositForBurnParams)] +pub struct DepositForBurn<'info> { + #[account(mut)] + pub signer: Signer<'info>, + + #[account(seeds = [b"state"], bump)] + pub state: Account<'info, State>, + + #[account(mut, seeds = [b"rent_fund"], bump)] + pub rent_fund: SystemAccount<'info>, + + #[account( + init, // Enforces that a given quote nonce can be used only once during the quote deadline. + payer = signer, + space = UsedNonce::DISCRIMINATOR.len() + UsedNonce::INIT_SPACE, + seeds = [ + b"used_nonce", + ¶ms.quote[NONCE_START..NONCE_END], // Safe to use as all quote params up to nonce are fixed length. + ], + bump + )] + pub used_nonce: Account<'info, UsedNonce>, + + #[account( + mut, + associated_token::mint = burn_token, + associated_token::authority = signer, + associated_token::token_program = token_program + )] + pub depositor_token_account: InterfaceAccount<'info, TokenAccount>, + + #[account( + mut, + constraint = + burn_token.key() == SponsoredCCTPQuote::new(¶ms.quote)?.burn_token()? + @ SvmError::InvalidBurnToken, + mint::token_program = token_program, + )] + pub burn_token: InterfaceAccount<'info, Mint>, + + /// CHECK: denylist PDA, checked in CCTP. Seeds must be ["denylist_account", signer.key()] (CCTP + // TokenMessengerMinterV2 program). + pub denylist_account: UncheckedAccount<'info>, + + /// CHECK: empty PDA, checked in CCTP. Seeds must be ["sender_authority"] (CCTP TokenMessengerMinterV2 program). + pub token_messenger_minter_sender_authority: UncheckedAccount<'info>, + + /// CHECK: MessageTransmitter is checked in CCTP. Seeds must be ["message_transmitter"] (CCTP TokenMessengerMinterV2 + // program). + #[account(mut)] + pub message_transmitter: UncheckedAccount<'info>, + + /// CHECK: TokenMessenger is checked in CCTP. Seeds must be ["token_messenger"] (CCTP TokenMessengerMinterV2 + // program). + pub token_messenger: UncheckedAccount<'info>, + + /// CHECK: RemoteTokenMessenger is checked in CCTP. Seeds must be ["remote_token_messenger", + // remote_domain.to_string()] (CCTP TokenMessengerMinterV2 program). + pub remote_token_messenger: UncheckedAccount<'info>, + + /// CHECK: TokenMinter is checked in CCTP. Seeds must be ["token_minter"] (CCTP TokenMessengerMinterV2 program). + pub token_minter: UncheckedAccount<'info>, + + /// CHECK: LocalToken is checked in CCTP. Seeds must be ["local_token", mint.key()] (CCTP TokenMessengerMinterV2 + // program). + #[account(mut)] + pub local_token: UncheckedAccount<'info>, + + /// CHECK: EventAuthority is checked in CCTP. Seeds must be ["__event_authority"] (CCTP TokenMessengerMinterV2 + // program). + pub cctp_event_authority: UncheckedAccount<'info>, + + // Account to store MessageSent CCTP event data in. Any non-PDA uninitialized address. + #[account(mut)] + pub message_sent_event_data: Signer<'info>, + + pub message_transmitter_program: Program<'info, MessageTransmitterV2>, + + pub token_messenger_minter_program: Program<'info, TokenMessengerMinterV2>, + + pub token_program: Interface<'info, TokenInterface>, + + pub system_program: Program<'info, System>, +} + +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct DepositForBurnParams { + pub quote: Vec, + pub signature: Vec, +} + +pub fn deposit_for_burn(ctx: Context, params: &DepositForBurnParams) -> Result<()> { + // Repay user for used_nonce account creation as the rent_fund account will receive its balance upon closing. + refund_used_nonce_creation(&ctx)?; + + let state = &ctx.accounts.state; + + let quote = SponsoredCCTPQuote::new(¶ms.quote)?; + validate_signature(state.signer, "e, ¶ms.signature)?; + + let quote_deadline = quote.deadline()?; + if quote_deadline < get_current_time(state)? { + return err!(CommonError::InvalidDeadline); + } + if quote.source_domain()? != state.source_domain { + return err!(CommonError::InvalidSourceDomain); + } + + let amount = quote.amount()?; + let destination_domain = quote.destination_domain()?; + let mint_recipient = quote.mint_recipient()?; + let destination_caller = quote.destination_caller()?; + let max_fee = quote.max_fee()?; + let min_finality_threshold = quote.min_finality_threshold()?; + let hook_data = quote.hook_data()?; + + // Record the quote deadline as it should be safe to close the used_nonce account after this time. + ctx.accounts.used_nonce.quote_deadline = quote_deadline; + + // Invoke CCTPv2 to bridge user tokens. This burns user tokens directly by inheriting the signer privileges. The + // side effect is that the user signer address will show up as messageSender on the destination chain, not the + // authority of this program. This is still acceptable in the current flow where SponsoredCCTPDstPeriphery contract + // on the destination chain revalidates the quote signature. + let cpi_program = ctx.accounts.token_messenger_minter_program.to_account_info(); + let cpi_accounts = DepositForBurnWithHook { + owner: ctx.accounts.signer.to_account_info(), + event_rent_payer: ctx.accounts.rent_fund.to_account_info(), + sender_authority_pda: ctx.accounts.token_messenger_minter_sender_authority.to_account_info(), + burn_token_account: ctx.accounts.depositor_token_account.to_account_info(), + denylist_account: ctx.accounts.denylist_account.to_account_info(), + message_transmitter: ctx.accounts.message_transmitter.to_account_info(), + token_messenger: ctx.accounts.token_messenger.to_account_info(), + remote_token_messenger: ctx.accounts.remote_token_messenger.to_account_info(), + token_minter: ctx.accounts.token_minter.to_account_info(), + local_token: ctx.accounts.local_token.to_account_info(), + burn_token_mint: ctx.accounts.burn_token.to_account_info(), + message_sent_event_data: ctx.accounts.message_sent_event_data.to_account_info(), + message_transmitter_program: ctx.accounts.message_transmitter_program.to_account_info(), + token_messenger_minter_program: ctx.accounts.token_messenger_minter_program.to_account_info(), + token_program: ctx.accounts.token_program.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + event_authority: ctx.accounts.cctp_event_authority.to_account_info(), + program: ctx.accounts.token_messenger_minter_program.to_account_info(), + }; + let rent_fund_seeds: &[&[&[u8]]] = &[&[b"rent_fund", &[ctx.bumps.rent_fund]]]; + let cpi_ctx = CpiContext::new_with_signer(cpi_program, cpi_accounts, rent_fund_seeds); + let cpi_params = DepositForBurnWithHookParams { + amount, + destination_domain, + mint_recipient, + destination_caller, + max_fee, + min_finality_threshold, + hook_data, + }; + token_messenger_minter_v2::cpi::deposit_for_burn_with_hook(cpi_ctx, cpi_params)?; + + emit_cpi!(SponsoredDepositForBurn { + quote_nonce: quote.nonce()?.to_vec(), + origin_sender: ctx.accounts.signer.key(), + final_recipient: quote.final_recipient()?, + quote_deadline, + max_bps_to_sponsor: quote.max_bps_to_sponsor()?, + max_user_slippage_bps: quote.max_user_slippage_bps()?, + final_token: quote.final_token()?, + signature: params.signature.clone(), + }); + + emit_cpi!(CreatedEventAccount { message_sent_event_data: ctx.accounts.message_sent_event_data.key() }); + + Ok(()) +} + +fn refund_used_nonce_creation(ctx: &Context) -> Result<()> { + let anchor_rent = Rent::get()?; + let space = UsedNonce::DISCRIMINATOR.len() + UsedNonce::INIT_SPACE; + + // Actual cost for the user might have been lower if somebody had pre-funded the used_nonce account, but that should + // be of no concern as the rent_fund account will receive the whole balance upon its closure. + let lamports = anchor_rent.minimum_balance(space); + + let cpi_accounts = system_program::Transfer { + from: ctx.accounts.rent_fund.to_account_info(), + to: ctx.accounts.signer.to_account_info(), + }; + let rent_fund_seeds: &[&[&[u8]]] = &[&[b"rent_fund", &[ctx.bumps.rent_fund]]]; + let cpi_context = + CpiContext::new_with_signer(ctx.accounts.system_program.to_account_info(), cpi_accounts, rent_fund_seeds); + system_program::transfer(cpi_context, lamports)?; + + Ok(()) +} + +#[event_cpi] +#[derive(Accounts)] +pub struct ReclaimEventAccount<'info> { + #[account(mut, seeds = [b"rent_fund"], bump)] + pub rent_fund: SystemAccount<'info>, + + /// CHECK: MessageTransmitter is checked in CCTP. Seeds must be ["message_transmitter"] (CCTP TokenMessengerMinterV2 + // program). + #[account(mut)] + pub message_transmitter: UncheckedAccount<'info>, + + /// CHECK: MessageSent is checked in CCTP, must be the same account as in DepositForBurn. + #[account(mut)] + pub message_sent_event_data: UncheckedAccount<'info>, + + pub message_transmitter_program: Program<'info, MessageTransmitterV2>, +} + +pub fn reclaim_event_account(ctx: Context, params: &ReclaimEventAccountParams) -> Result<()> { + let cpi_program = ctx.accounts.message_transmitter_program.to_account_info(); + let cpi_accounts = message_transmitter_v2::cpi::accounts::ReclaimEventAccount { + payee: ctx.accounts.rent_fund.to_account_info(), + message_transmitter: ctx.accounts.message_transmitter.to_account_info(), + message_sent_event_data: ctx.accounts.message_sent_event_data.to_account_info(), + }; + let rent_fund_seeds: &[&[&[u8]]] = &[&[b"rent_fund", &[ctx.bumps.rent_fund]]]; + let cpi_ctx = CpiContext::new_with_signer(cpi_program, cpi_accounts, rent_fund_seeds); + message_transmitter_v2::cpi::reclaim_event_account(cpi_ctx, params.clone())?; + + emit_cpi!(ReclaimedEventAccount { message_sent_event_data: ctx.accounts.message_sent_event_data.key() }); + + Ok(()) +} + +#[event_cpi] +#[derive(Accounts)] +#[instruction(params: UsedNonceAccountParams)] +pub struct ReclaimUsedNonceAccount<'info> { + #[account(seeds = [b"state"], bump)] + pub state: Account<'info, State>, + + #[account(mut, seeds = [b"rent_fund"], bump)] + pub rent_fund: SystemAccount<'info>, + + #[account(mut,close = rent_fund, seeds = [b"used_nonce", ¶ms.nonce.as_ref()], bump)] + pub used_nonce: Account<'info, UsedNonce>, +} + +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct UsedNonceAccountParams { + pub nonce: [u8; 32], +} + +pub fn reclaim_used_nonce_account( + ctx: Context, + params: &UsedNonceAccountParams, +) -> Result<()> { + if ctx.accounts.used_nonce.quote_deadline >= get_current_time(&ctx.accounts.state)? { + return err!(SvmError::QuoteDeadlineNotPassed); + } + + emit_cpi!(ReclaimedUsedNonceAccount { nonce: params.nonce.to_vec(), used_nonce: ctx.accounts.used_nonce.key() }); + + Ok(()) +} + +#[derive(Accounts)] +#[instruction(_params: UsedNonceAccountParams)] +pub struct GetUsedNonceCloseInfo<'info> { + #[account(seeds = [b"state"], bump)] + pub state: Account<'info, State>, + + #[account(seeds = [b"used_nonce", &_params.nonce.as_ref()], bump)] + pub used_nonce: Account<'info, UsedNonce>, +} + +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct UsedNonceCloseInfo { + pub can_close_after: i64, + pub can_close_now: bool, +} + +pub fn get_used_nonce_close_info(ctx: Context) -> Result { + let can_close_after = ctx.accounts.used_nonce.quote_deadline; + let can_close_now = can_close_after < get_current_time(&ctx.accounts.state)?; + + Ok(UsedNonceCloseInfo { can_close_after, can_close_now }) +} diff --git a/programs/sponsored-cctp-src-periphery/src/instructions/mod.rs b/programs/sponsored-cctp-src-periphery/src/instructions/mod.rs new file mode 100644 index 000000000..8ffd139e9 --- /dev/null +++ b/programs/sponsored-cctp-src-periphery/src/instructions/mod.rs @@ -0,0 +1,5 @@ +pub mod admin; +pub mod deposit; + +pub use admin::*; +pub use deposit::*; diff --git a/programs/sponsored-cctp-src-periphery/src/lib.rs b/programs/sponsored-cctp-src-periphery/src/lib.rs new file mode 100644 index 000000000..163c81d2a --- /dev/null +++ b/programs/sponsored-cctp-src-periphery/src/lib.rs @@ -0,0 +1,209 @@ +#![allow(unexpected_cfgs)] + +mod error; +mod event; +mod instructions; +mod state; +mod utils; + +use anchor_lang::prelude::*; + +use instructions::*; +use utils::*; + +#[cfg(not(feature = "no-entrypoint"))] +solana_security_txt::security_txt! { + name: "Across Sponsored CCTP Source Periphery", + project_url: "https://across.to", + contacts: "email:bugs@across.to", + policy: "https://docs.across.to/resources/bug-bounty", + preferred_languages: "en", + source_code: "https://github.com/across-protocol/contracts/tree/master/programs/sponsored-cctp-src-periphery", + auditors: "OpenZeppelin" +} + +declare_id!("CPr4bRvkVKcSCLyrQpkZrRrwGzQeVAXutFU8WupuBLXq"); + +// External programs from idls directory (requires anchor run generateExternalTypes). +declare_program!(message_transmitter_v2); +declare_program!(token_messenger_minter_v2); + +/// # Across Sponsored CCTP Source Periphery +/// +/// Source chain periphery program for users to interact with to start a sponsored or a non-sponsored flow that allows +/// custom Accross-supported flows on destination chain. Uses Circle's CCTPv2 as an underlying bridge + +#[program] +pub mod sponsored_cctp_src_periphery { + use super::*; + + /// Initializes immutable program state and sets the trusted EVM quote signer. + /// + /// This can only be called once by the upgrade authority. It stores the local CCTP source domain and the + /// quote `signer` that must authorize sponsored deposits. + /// + /// Required Accounts: + /// - signer (Signer, Writable): Must be the program upgrade authority. + /// - state (Writable): Program state PDA. Seed: ["state"]. + /// - program_data (Account): Program data account to verify the upgrade authority. + /// - this_program (Program): This program account, used to resolve `programdata_address`. + /// - system_program (Program): System program for account creation. + /// + /// Parameters: + /// - source_domain: CCTP domain for this chain (e.g., 5 for Solana). + /// - signer: EVM address (encoded as `Pubkey`) authorized to sign sponsored quotes. + pub fn initialize(ctx: Context, params: InitializeParams) -> Result<()> { + instructions::initialize(ctx, ¶ms) + } + + /// Updates the trusted EVM quote signer. + /// + /// Only callable by the upgrade authority. Setting this to an invalid address (including `Pubkey::default()`) will + /// effectively disable deposits. + /// + /// Required Accounts: + /// - signer (Signer, Writable): Must be the program upgrade authority. + /// - state (Writable): Program state PDA. Seed: ["state"]. + /// - program_data (Account): Program data account to verify the upgrade authority. + /// - this_program (Program): This program account, used to resolve `programdata_address`. + /// + /// Parameters: + /// - new_signer: New EVM signer address (encoded as `Pubkey`). + pub fn set_signer(ctx: Context, params: SetSignerParams) -> Result<()> { + instructions::set_signer(ctx, ¶ms) + } + + /// Withdraws lamports from the rent fund PDA to an arbitrary recipient. + /// + /// The rent fund is used to sponsor temporary account creation (e.g., CCTP event accounts or per-quote nonce PDAs). + /// Only callable by the upgrade authority. + /// + /// Required Accounts: + /// - signer (Signer, Writable): Must be the program upgrade authority. + /// - rent_fund (SystemAccount, Writable): PDA holding lamports used for rent sponsorship. Seed: ["rent_fund"]. + /// - recipient (UncheckedAccount, Writable): Destination account for the withdrawn lamports. + /// - program_data (Account): Program data account to verify the upgrade authority. + /// - this_program (Program): This program account, used to resolve `programdata_address`. + /// - system_program (Program): System program for transfers. + /// + /// Parameters: + /// - amount: Amount of lamports to transfer to the recipient. + pub fn withdraw_rent_fund(ctx: Context, params: WithdrawRentFundParams) -> Result<()> { + instructions::withdraw_rent_fund(ctx, ¶ms) + } + + /// Verifies a sponsored CCTP quote, records its nonce, and burns the user's tokens via CCTPv2 with hook data. + /// + /// The user's depositor ATA is burned via `deposit_for_burn_with_hook` CPI on the CCTPv2. The rent cost for the + /// per-quote `used_nonce` PDA is refunded to the signer from the `rent_fund` and `rent_fund` also funds the + /// creation of CCTP `MessageSent` event account. + /// On success, this emits a `SponsoredDepositForBurn` event to be consumed by offchain infrastructure. This also + /// emits a `CreatedEventAccount` event containing the address of the created CCTP `MessageSent` event account that + /// can be reclaimed later using the `reclaim_event_account` instruction. + /// + /// Required Accounts: + /// - signer (Signer, Writable): The user authorizing the burn. + /// - state (Account): Program state PDA. Seed: ["state"]. + /// - rent_fund (SystemAccount, Writable): PDA used to sponsor rent and event accounts. Seed: ["rent_fund"]. + /// - used_nonce (Account, Writable, Init): Per-quote nonce PDA. Seed: ["used_nonce", nonce]. + /// - depositor_token_account (InterfaceAccount, Writable): Signer ATA of the burn token. + /// - burn_token (InterfaceAccount, Mutable): Mint of the token to burn. Must match quote.burn_token. + /// - denylist_account (Unchecked): CCTP denylist PDA, validated within CCTP. + /// - token_messenger_minter_sender_authority (Unchecked): CCTP sender authority PDA. + /// - message_transmitter (Unchecked, Mutable): CCTP MessageTransmitter account. + /// - token_messenger (Unchecked): CCTP TokenMessenger account. + /// - remote_token_messenger (Unchecked): Remote TokenMessenger account for destination domain. + /// - token_minter (Unchecked): CCTP TokenMinter account. + /// - local_token (Unchecked, Mutable): Local token account (CCTP). + /// - cctp_event_authority (Unchecked): CCTP event authority account. + /// - message_sent_event_data (Signer, Mutable): Fresh account to store CCTP MessageSent event data. + /// - message_transmitter_program (Program): CCTPv2 MessageTransmitter program. + /// - token_messenger_minter_program (Program): CCTPv2 TokenMessengerMinter program. + /// - token_program (Interface): SPL token program. + /// - system_program (Program): System program. + /// + /// Parameters: + /// - quote: ABI-encoded quote bytes (fixed length) containing burn parameters and hook data. + /// - signature: 65-byte EVM signature authorizing the quote by the trusted signer. + /// + /// Notes: + /// - The upgrade authority must have set the valid EVM signer for this instruction to succeed. + /// - The operator of this program must have funded the `rent_fund` PDA with sufficient lamports to cover + /// rent for the `used_nonce` PDA and the CCTP `MessageSent` event account. + pub fn deposit_for_burn(ctx: Context, params: DepositForBurnParams) -> Result<()> { + instructions::deposit_for_burn(ctx, ¶ms) + } + + /// Reclaims the CCTP `MessageSent` event account, returning rent to the rent fund. + /// + /// Required Accounts: + /// - rent_fund (SystemAccount, Writable): PDA to receive reclaimed lamports. Seed: ["rent_fund"]. + /// - message_transmitter (Unchecked, Mutable): CCTP MessageTransmitter account. + /// - message_sent_event_data (Unchecked, Mutable): The event account created during `deposit_for_burn`. + /// - message_transmitter_program (Program): CCTPv2 MessageTransmitter program. + /// + /// Parameters: + /// - params: Parameters required by CCTP to reclaim the event account. + /// + /// Notes: + /// - This can only be called after the CCTP attestation service has processed the message and sufficient time has + /// passed since the `MessageSent` event was created. The operator can track the closable accounts from the + /// emitted `CreatedEventAccount` events and using the `EVENT_ACCOUNT_WINDOW_SECONDS` set in CCTP program. + pub fn reclaim_event_account(ctx: Context, params: ReclaimEventAccountParams) -> Result<()> { + instructions::reclaim_event_account(ctx, ¶ms) + } + + /// Closes a `used_nonce` PDA once its quote deadline has passed, returning rent to the rent fund. + /// + /// Required Accounts: + /// - state (Account): Program state PDA. Seed: ["state"]. Used to fetch current time. + /// - rent_fund (SystemAccount, Writable): PDA receiving lamports upon close. Seed: ["rent_fund"]. + /// - used_nonce (Account, Writable, Close=rent_fund): PDA to close. Seed: ["used_nonce", nonce]. + /// + /// Parameters: + /// - params.nonce: The 32-byte nonce identifying the PDA to close. + /// + /// Notes: + /// - This can only be called after the quote's deadline has passed. The operator can track closable `used_nonce` + /// accounts from the emitted `SponsoredDepositForBurn` events (`quote_nonce` and `quote_deadline`) and using the + /// `get_used_nonce_close_info` helper. + pub fn reclaim_used_nonce_account( + ctx: Context, + params: UsedNonceAccountParams, + ) -> Result<()> { + instructions::reclaim_used_nonce_account(ctx, ¶ms) + } + + /// Returns whether a `used_nonce` PDA can be closed now and the timestamp after which it can be closed. + /// + /// This is a convenience "view" helper for off-chain systems to determine when rent can be reclaimed for a + /// specific quote nonce. + /// + /// Required Accounts: + /// - state (Account): Program state PDA. Seed: ["state"]. + /// - used_nonce (Account): The `used_nonce` PDA. Seed: ["used_nonce", nonce]. + /// + /// Parameters: + /// - _params.nonce: The 32-byte nonce identifying the PDA to check. + /// + /// Returns: + /// - UsedNonceCloseInfo { can_close_after, can_close_now } + pub fn get_used_nonce_close_info( + ctx: Context, + _params: UsedNonceAccountParams, + ) -> Result { + instructions::get_used_nonce_close_info(ctx) + } + + /// Sets the current time in test mode. No-op on mainnet builds. + /// + /// Required Accounts: + /// - state (Writable): Program state PDA. Seed: ["state"]. + /// - signer (Signer): Any signer. Only enabled when built with `--features test`. + /// + /// Parameters: + /// - new_time: New unix timestamp to set for tests. + pub fn set_current_time(ctx: Context, params: SetCurrentTimeParams) -> Result<()> { + utils::set_current_time(ctx, params) + } +} diff --git a/programs/sponsored-cctp-src-periphery/src/state.rs b/programs/sponsored-cctp-src-periphery/src/state.rs new file mode 100644 index 000000000..37d2dde7a --- /dev/null +++ b/programs/sponsored-cctp-src-periphery/src/state.rs @@ -0,0 +1,15 @@ +use anchor_lang::prelude::*; + +#[account] +#[derive(InitSpace)] +pub struct State { + pub source_domain: u32, // Immutable CCTP domain for the chain this program is deployed on (e.g. 5 for Solana). + pub signer: Pubkey, // The authorized signer for sponsored CCTP quotes. + pub current_time: i64, // Only used in testable mode, else set to 0 on mainnet. +} + +#[account] +#[derive(InitSpace)] +pub struct UsedNonce { + pub quote_deadline: i64, // Quote deadline is used to determine when it is safe to close the nonce account. +} diff --git a/programs/sponsored-cctp-src-periphery/src/utils/mod.rs b/programs/sponsored-cctp-src-periphery/src/utils/mod.rs new file mode 100644 index 000000000..414f897f2 --- /dev/null +++ b/programs/sponsored-cctp-src-periphery/src/utils/mod.rs @@ -0,0 +1,7 @@ +pub mod signer; +pub mod sponsored_cctp_quote; +pub mod testable_utils; + +pub use signer::*; +pub use sponsored_cctp_quote::*; +pub use testable_utils::*; diff --git a/programs/sponsored-cctp-src-periphery/src/utils/signer.rs b/programs/sponsored-cctp-src-periphery/src/utils/signer.rs new file mode 100644 index 000000000..a0c2956ac --- /dev/null +++ b/programs/sponsored-cctp-src-periphery/src/utils/signer.rs @@ -0,0 +1,59 @@ +use anchor_lang::{ + prelude::*, + solana_program::{keccak, secp256k1_recover::secp256k1_recover}, +}; +use libsecp256k1::Signature as EVMSignature; + +use crate::{error::CommonError, utils::SponsoredCCTPQuote}; + +pub const QUOTE_SIGNATURE_LENGTH: usize = 65; + +/// Utility function to recover the quote signer EVM address. +/// Based on CCTP's `recover_attester` function in +/// https://github.com/circlefin/solana-cctp-contracts/blob/03f7dec786eb9affa68688954f62917edeed2e35/programs/v2/message-transmitter-v2/src/state.rs +fn recover_signer(quote_hash: &[u8; 32], quote_signature: &[u8]) -> Result { + // secp256k1_recover doesn't validate input parameters lengths, so check the signature. No need to check hash as it + // is fixed size array. + if quote_signature.len() != QUOTE_SIGNATURE_LENGTH { + return err!(CommonError::InvalidSignature); + } + + // Extract and validate recovery id from the signature. + let ethereum_recovery_id = quote_signature[QUOTE_SIGNATURE_LENGTH - 1]; + if !(27..=30).contains(ðereum_recovery_id) { + return err!(CommonError::InvalidSignature); + } + let recovery_id = ethereum_recovery_id - 27; + + // Reject high-s value signatures to prevent malleability. + let signature = EVMSignature::parse_standard_slice("e_signature[0..QUOTE_SIGNATURE_LENGTH - 1]) + .map_err(|_| CommonError::InvalidSignature)?; + if signature.s.is_high() { + return err!(CommonError::InvalidSignature); + } + + // Recover quote signer's public key. + let public_key = secp256k1_recover(quote_hash, recovery_id, "e_signature[0..QUOTE_SIGNATURE_LENGTH - 1]) + .map_err(|_| CommonError::InvalidSignature)?; + + // Hash public key and return last 20 bytes (EVM address) as Pubkey. + let mut address = keccak::hash(public_key.to_bytes().as_slice()).to_bytes(); + address[0..12].iter_mut().for_each(|x| { + *x = 0; + }); + + Ok(Pubkey::new_from_array(address)) +} + +pub fn validate_signature( + expected_signer: Pubkey, + quote: &SponsoredCCTPQuote, + quote_signature: &Vec, +) -> Result<()> { + let recovered_signer = recover_signer("e.evm_typed_hash()?, quote_signature)?; + if recovered_signer != expected_signer { + return err!(CommonError::InvalidSignature); + } + + Ok(()) +} diff --git a/programs/sponsored-cctp-src-periphery/src/utils/sponsored_cctp_quote.rs b/programs/sponsored-cctp-src-periphery/src/utils/sponsored_cctp_quote.rs new file mode 100644 index 000000000..50b451afc --- /dev/null +++ b/programs/sponsored-cctp-src-periphery/src/utils/sponsored_cctp_quote.rs @@ -0,0 +1,287 @@ +use anchor_lang::{prelude::*, solana_program::keccak}; + +use crate::error::{DataDecodingError, SvmError}; + +// Macro to define the SponsoredCCTPQuote fields as an enum with associated constants for ordinal, start, end, count, +// and minimum total bytes. +macro_rules! define_quote_fields { + ($($V:ident),+ $(,)?) => { + #[derive(Copy, Clone)] + pub enum SponsoredCCTPQuoteFields { $($V),+ } + + impl SponsoredCCTPQuoteFields { + pub const fn ordinal(self) -> usize { self as usize } + + pub const fn start(self) -> usize { self.ordinal() * Self::WORD_SIZE } + + pub const fn end(self) -> usize { + self.start() + Self::WORD_SIZE + } + + pub const WORD_SIZE: usize = 32; + + pub const COUNT: usize = [$(SponsoredCCTPQuoteFields::$V),+].len(); + + pub const MIN_TOTAL_BYTES: usize = Self::COUNT * Self::WORD_SIZE; + } + }; +} + +// Define the SponsoredCCTPQuote fields using the macro. Each field corresponds to a 32-byte word in the ABI encoded +// SponsoredCCTPQuote struct, except for the last actionData field which is variable length bytes and would be encoded +// to the multiple of 32 bytes (minimum is 64 bytes for the offset and length) on EVM. +define_quote_fields!( + SourceDomain, + DestinationDomain, + MintRecipient, + Amount, + BurnToken, + DestinationCaller, + MaxFee, + MinFinalityThreshold, + Nonce, + Deadline, + MaxBpsToSponsor, + MaxUserSlippageBps, + FinalRecipient, + FinalToken, + ExecutionMode, + ActionDataOffset, + ActionDataLength +); + +// Compile-time guarantees that below fields passed in hookData are contiguous, ordered and actionData is the last. +const _: () = { + assert!((SponsoredCCTPQuoteFields::Deadline as usize) == (SponsoredCCTPQuoteFields::Nonce as usize) + 1); + assert!((SponsoredCCTPQuoteFields::MaxBpsToSponsor as usize) == (SponsoredCCTPQuoteFields::Deadline as usize) + 1); + assert!( + (SponsoredCCTPQuoteFields::MaxUserSlippageBps as usize) + == (SponsoredCCTPQuoteFields::MaxBpsToSponsor as usize) + 1 + ); + assert!( + (SponsoredCCTPQuoteFields::FinalRecipient as usize) + == (SponsoredCCTPQuoteFields::MaxUserSlippageBps as usize) + 1 + ); + assert!((SponsoredCCTPQuoteFields::FinalToken as usize) == (SponsoredCCTPQuoteFields::FinalRecipient as usize) + 1); + assert!((SponsoredCCTPQuoteFields::ExecutionMode as usize) == (SponsoredCCTPQuoteFields::FinalToken as usize) + 1); + assert!( + (SponsoredCCTPQuoteFields::ActionDataOffset as usize) == (SponsoredCCTPQuoteFields::ExecutionMode as usize) + 1 + ); + assert!( + (SponsoredCCTPQuoteFields::ActionDataLength as usize) + == (SponsoredCCTPQuoteFields::ActionDataOffset as usize) + 1 + ); + assert!((SponsoredCCTPQuoteFields::ActionDataLength as usize) == SponsoredCCTPQuoteFields::COUNT - 1); +}; + +pub const MIN_QUOTE_DATA_LENGTH: usize = SponsoredCCTPQuoteFields::MIN_TOTAL_BYTES; + +pub const HOOK_DATA_START: usize = SponsoredCCTPQuoteFields::Nonce.start(); + +pub const NONCE_START: usize = SponsoredCCTPQuoteFields::Nonce.start(); +pub const NONCE_END: usize = SponsoredCCTPQuoteFields::Nonce.end(); + +pub struct SponsoredCCTPQuote<'a> { + data: &'a [u8], +} + +impl<'a> SponsoredCCTPQuote<'a> { + pub fn new(quote_bytes: &'a [u8]) -> Result { + // Encoded quote data must be at least MIN_QUOTE_DATA_LENGTH bytes long and must be a multiple of 32 bytes. + let quote_bytes_len = quote_bytes.len(); + if quote_bytes_len < MIN_QUOTE_DATA_LENGTH || quote_bytes_len % SponsoredCCTPQuoteFields::WORD_SIZE != 0 { + return err!(SvmError::InvalidQuoteDataLength); + } + + Ok(Self { data: quote_bytes }) + } + + /// EVM-compatible typed hash used for signature verification. + /// + /// Mirrors the Solidity implementation: + /// - The full quote hash is split into **two parts** to avoid the EVM stack too deep” issue in the contract. + /// - The dynamic `actionData` is hashed separately (`keccak256(actionData)`) and that 32-byte digest is included as + /// the last field of `hash2`. + /// - Finally, `typedDataHash = keccak256(abi.encode(hash1, hash2))`. + /// + /// Rust implementation detail: + /// - We use `keccak::hashv(&[...])` to hash the bytewise concatenation of slices without building intermediate + /// buffers (no memcopy). + pub fn evm_typed_hash(&self) -> Result<[u8; 32]> { + Ok(keccak::hashv(&[&self.hash1(), &self.hash2()?]).to_bytes()) + } + + /// `hash1` (EVM: first part) = keccak256(abi.encode(sourceDomain, destinationDomain, mintRecipient, amount, + /// burnToken, destinationCaller, maxFee, minFinalityThreshold)) + /// + /// These are all static ABI fields, so `abi.encode(...)` is exactly the concatenation of their 32-byte words + /// already present in the head. + fn hash1(&self) -> [u8; 32] { + let start = SponsoredCCTPQuoteFields::SourceDomain.start(); + let end = SponsoredCCTPQuoteFields::MinFinalityThreshold.end(); + + // Safe: start and end are derived from SponsoredCCTPQuoteFields, so this should always be in-bounds. + keccak::hash(&self.data[start..end]).to_bytes() + } + + /// `hash2` (EVM: second part) = keccak256(abi.encode(nonce, deadline, maxBpsToSponsor, maxUserSlippageBps, + /// finalRecipient, finalToken, executionMode, keccak256(actionData))) + /// + /// We hash the static words (`Nonce..ExecutionMode`) directly from the head with appended `keccak(actionData)` as a + /// `bytes32` (static) value. + fn hash2(&self) -> Result<[u8; 32]> { + let start = SponsoredCCTPQuoteFields::Nonce.start(); + let end = SponsoredCCTPQuoteFields::ExecutionMode.end(); + + // Safe: start and end are derived from SponsoredCCTPQuoteFields, so this should always be in-bounds. + Ok(keccak::hashv(&[&self.data[start..end], &self.action_data_hash()?]).to_bytes()) + } + + /// `keccak256(actionData)` — hashes only the dynamic bytes content (not ABI-encoded), matching the Solidity side's + /// `keccak256(quote.actionData)`. + fn action_data_hash(&self) -> Result<[u8; 32]> { + // actionData bytes start immediately after its length word. + let start = SponsoredCCTPQuoteFields::ActionDataLength.end(); + let length = self.get_action_data_len_checked()?; + + // Safe: get_action_data_len_checked() ensures that the actionData bytes are within bounds. + Ok(keccak::hash(&self.data[start..start + length]).to_bytes()) + } + + pub fn source_domain(&self) -> Result { + Self::decode_to_u32(self.get_field_word(SponsoredCCTPQuoteFields::SourceDomain)) + } + + pub fn destination_domain(&self) -> Result { + Self::decode_to_u32(self.get_field_word(SponsoredCCTPQuoteFields::DestinationDomain)) + } + + pub fn mint_recipient(&self) -> Result { + Self::decode_to_pubkey(self.get_field_word(SponsoredCCTPQuoteFields::MintRecipient)) + } + + pub fn amount(&self) -> Result { + Self::decode_to_u64(self.get_field_word(SponsoredCCTPQuoteFields::Amount)) + } + + pub fn burn_token(&self) -> Result { + Self::decode_to_pubkey(self.get_field_word(SponsoredCCTPQuoteFields::BurnToken)) + } + + pub fn destination_caller(&self) -> Result { + Self::decode_to_pubkey(self.get_field_word(SponsoredCCTPQuoteFields::DestinationCaller)) + } + + pub fn max_fee(&self) -> Result { + Self::decode_to_u64(self.get_field_word(SponsoredCCTPQuoteFields::MaxFee)) + } + + pub fn min_finality_threshold(&self) -> Result { + Self::decode_to_u32(self.get_field_word(SponsoredCCTPQuoteFields::MinFinalityThreshold)) + } + + pub fn nonce(&self) -> Result<[u8; 32]> { + Self::decode_to_bytes32(self.get_field_word(SponsoredCCTPQuoteFields::Nonce)) + } + + pub fn deadline(&self) -> Result { + Self::decode_to_i64(self.get_field_word(SponsoredCCTPQuoteFields::Deadline)) + } + + pub fn max_bps_to_sponsor(&self) -> Result { + Self::decode_to_u64(self.get_field_word(SponsoredCCTPQuoteFields::MaxBpsToSponsor)) + } + + pub fn max_user_slippage_bps(&self) -> Result { + Self::decode_to_u64(self.get_field_word(SponsoredCCTPQuoteFields::MaxUserSlippageBps)) + } + + pub fn final_recipient(&self) -> Result { + Self::decode_to_pubkey(self.get_field_word(SponsoredCCTPQuoteFields::FinalRecipient)) + } + + pub fn final_token(&self) -> Result { + Self::decode_to_pubkey(self.get_field_word(SponsoredCCTPQuoteFields::FinalToken)) + } + + pub fn hook_data(&self) -> Result> { + // Safe: HOOK_DATA_START is derived from SponsoredCCTPQuoteFields, so this should always be in-bounds. + let mut hook_data = self.data[HOOK_DATA_START..].to_vec(); + + self.get_action_data_len_checked()?; // We only need the check, not the length here. + + // Patch the actionData offset relative to the HOOK_DATA_START. + let hook_data_action_data_offset = + (SponsoredCCTPQuoteFields::ActionDataLength.start() - HOOK_DATA_START) as u64; + let offset_start = SponsoredCCTPQuoteFields::ActionDataOffset.start() - HOOK_DATA_START; + hook_data[offset_start + 24..offset_start + 32].copy_from_slice(&hook_data_action_data_offset.to_be_bytes()); + + Ok(hook_data) + } + + fn get_action_data_len_checked(&self) -> Result { + // Verify the actionData bytes offset points to its length field. + let quote_action_data_offset = + Self::decode_to_u64(self.get_field_word(SponsoredCCTPQuoteFields::ActionDataOffset))?; + if quote_action_data_offset != (SponsoredCCTPQuoteFields::ActionDataLength.start() as u64) { + return err!(DataDecodingError::CannotDecodeBytes); + } + + // Verify the encoded quote data has sufficient length to hold the actionData bytes (constructor only checks the + // minimum length to hold empty actionData bytes). + let action_data_length = + Self::decode_to_u64(self.get_field_word(SponsoredCCTPQuoteFields::ActionDataLength))? as usize; + if action_data_length > self.data.len() - MIN_QUOTE_DATA_LENGTH { + return err!(DataDecodingError::CannotDecodeBytes); + } + + Ok(action_data_length) + } + + fn get_field_word(&self, field: SponsoredCCTPQuoteFields) -> &[u8; 32] { + let start = field.start(); + let end = field.end(); + // Safe: start and end are derived from SponsoredCCTPQuoteFields, so this should always be in-bounds. + let data_slice = &self.data[start..end]; + // Safe: data_slice is exactly 32 bytes long, so we can convert it to [u8; 32]. + <&[u8; 32]>::try_from(data_slice).unwrap() + } + + fn decode_to_u32(data: &[u8; 32]) -> Result { + if data[..28].iter().any(|&b| b != 0) { + return err!(DataDecodingError::CannotDecodeToU32); + } + // Safe: data[28..] is exactly 4 bytes long, so we can convert it to [u8; 4]. + Ok(u32::from_be_bytes(data[28..].try_into().unwrap())) + } + + fn decode_to_u64(data: &[u8; 32]) -> Result { + if data[..24].iter().any(|&b| b != 0) { + return err!(DataDecodingError::CannotDecodeToU64); + } + // Safe: data[24..] is exactly 8 bytes long, so we can convert it to [u8; 8]. + Ok(u64::from_be_bytes(data[24..].try_into().unwrap())) + } + + fn decode_to_i64(data: &[u8; 32]) -> Result { + if data[..24].iter().any(|&b| b != 0) { + return err!(DataDecodingError::CannotDecodeToI64); + } + // Safe: data[24..] is exactly 8 bytes long, so we can convert it to [u8; 8]. + let v_u64 = u64::from_be_bytes(data[24..].try_into().unwrap()); + match i64::try_from(v_u64) { + Ok(v_i64) => Ok(v_i64), + Err(_) => err!(DataDecodingError::CannotDecodeToI64), + } + } + + fn decode_to_pubkey(data: &[u8; 32]) -> Result { + // Wrap in Result just to have consistency with decoding other field types that might error. + Ok(Pubkey::from(*data)) + } + + fn decode_to_bytes32(data: &[u8; 32]) -> Result<[u8; 32]> { + // Wrap in Result just to have consistency with decoding other field types that might error. + Ok(*data) + } +} diff --git a/programs/sponsored-cctp-src-periphery/src/utils/testable_utils.rs b/programs/sponsored-cctp-src-periphery/src/utils/testable_utils.rs new file mode 100644 index 000000000..64f3fd6ec --- /dev/null +++ b/programs/sponsored-cctp-src-periphery/src/utils/testable_utils.rs @@ -0,0 +1,52 @@ +use anchor_lang::prelude::*; + +use crate::state::State; + +#[derive(Accounts)] +pub struct SetCurrentTime<'info> { + #[account(mut, seeds = [b"state"], bump)] + pub state: Account<'info, State>, + + pub signer: Signer<'info>, +} + +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct SetCurrentTimeParams { + pub new_time: i64, +} + +pub fn set_current_time(ctx: Context, _params: SetCurrentTimeParams) -> Result<()> { + let _state = &mut ctx.accounts.state; + + #[cfg(not(feature = "test"))] + { + err!(crate::error::SvmError::CannotSetCurrentTime) + } + + #[cfg(feature = "test")] + { + _state.current_time = _params.new_time; + Ok(()) + } +} + +pub fn initialize_current_time(_state: &mut State) -> Result<()> { + #[cfg(feature = "test")] + { + _state.current_time = Clock::get()?.unix_timestamp; + } + + Ok(()) +} + +pub fn get_current_time(_state: &State) -> Result { + #[cfg(not(feature = "test"))] + { + Ok(Clock::get()?.unix_timestamp) + } + + #[cfg(feature = "test")] + { + Ok(_state.current_time) + } +} diff --git a/scripts/svm/buildHelpers/generateSvmClients.ts b/scripts/svm/buildHelpers/generateSvmClients.ts index 55da5b7cf..d565c40be 100644 --- a/scripts/svm/buildHelpers/generateSvmClients.ts +++ b/scripts/svm/buildHelpers/generateSvmClients.ts @@ -8,6 +8,7 @@ import { TokenMessengerMinterIdl, MessageTransmitterV2Idl, TokenMessengerMinterV2Idl, + SponsoredCctpSrcPeripheryIdl, } from "../../../src/svm/assets"; import path from "path"; export const clientsPath = path.join(__dirname, "..", "..", "..", "src", "svm", "clients"); @@ -31,3 +32,6 @@ codama.accept(renderJavaScriptVisitor(path.join(clientsPath, "MessageTransmitter codama = createFromRoot(rootNodeFromAnchor(TokenMessengerMinterV2Idl as AnchorIdl)); codama.accept(renderJavaScriptVisitor(path.join(clientsPath, "TokenMessengerMinterV2"))); + +codama = createFromRoot(rootNodeFromAnchor(SponsoredCctpSrcPeripheryIdl as AnchorIdl)); +codama.accept(renderJavaScriptVisitor(path.join(clientsPath, "SponsoredCctpSrcPeriphery"))); diff --git a/src/svm/clients/index.ts b/src/svm/clients/index.ts index a504fcf8b..e5857a0c7 100644 --- a/src/svm/clients/index.ts +++ b/src/svm/clients/index.ts @@ -4,6 +4,7 @@ import * as MessageTransmitterClient from "./MessageTransmitter"; import * as TokenMessengerMinterClient from "./TokenMessengerMinter"; import * as MessageTransmitterV2Client from "./MessageTransmitterV2"; import * as TokenMessengerMinterV2Client from "./TokenMessengerMinterV2"; +import * as SponsoredCctpSrcPeripheryClient from "./SponsoredCctpSrcPeriphery"; export { MulticallHandlerClient, @@ -12,4 +13,5 @@ export { TokenMessengerMinterClient, MessageTransmitterV2Client, TokenMessengerMinterV2Client, + SponsoredCctpSrcPeripheryClient, }; diff --git a/src/svm/web3-v1/cctpHelpers.ts b/src/svm/web3-v1/cctpHelpers.ts index 167ae25f0..520f43fdd 100644 --- a/src/svm/web3-v1/cctpHelpers.ts +++ b/src/svm/web3-v1/cctpHelpers.ts @@ -3,7 +3,7 @@ import { array, object, optional, string, Struct } from "superstruct"; import { readUInt256BE } from "./relayHashUtils"; // Index positions to decode Message Header from -// https://developers.circle.com/stablecoins/docs/message-format#message-header +// https://developers.circle.com/cctp/v1/message-format#cctp-v1-message-header const HEADER_VERSION_INDEX = 0; const SOURCE_DOMAIN_INDEX = 4; const DESTINATION_DOMAIN_INDEX = 8; @@ -14,7 +14,7 @@ const DESTINATION_CALLER_INDEX = 84; const MESSAGE_BODY_INDEX = 116; // Index positions to decode Message Body for TokenMessenger from -// https://developers.circle.com/stablecoins/docs/message-format#message-body +// https://developers.circle.com/cctp/v1/message-format#cctp-v1-message-body const BODY_VERSION_INDEX = 0; const BURN_TOKEN_INDEX = 4; const MINT_RECIPIENT_INDEX = 36; diff --git a/src/svm/web3-v1/cctpV2Helpers.ts b/src/svm/web3-v1/cctpV2Helpers.ts new file mode 100644 index 000000000..d4fe1b48d --- /dev/null +++ b/src/svm/web3-v1/cctpV2Helpers.ts @@ -0,0 +1,117 @@ +import * as anchor from "@coral-xyz/anchor"; +import { readUInt256BE } from "./relayHashUtils"; + +// Index positions to decode Message Header from +// https://developers.circle.com/cctp/technical-guide#message-header +const HEADER_VERSION_INDEX = 0; +const SOURCE_DOMAIN_INDEX = 4; +const DESTINATION_DOMAIN_INDEX = 8; +const NONCE_INDEX = 12; +const HEADER_SENDER_INDEX = 44; +const HEADER_RECIPIENT_INDEX = 76; +const DESTINATION_CALLER_INDEX = 108; +const MIN_FINALITY_THRESHOLD_INDEX = 140; +const FINALITY_THRESHOLD_EXECUTED_INDEX = 144; +const MESSAGE_BODY_INDEX = 148; + +// Index positions to decode Message Body for TokenMessengerV2 from +// https://developers.circle.com/cctp/technical-guide#message-body +const BODY_VERSION_INDEX = 0; +const BURN_TOKEN_INDEX = 4; +const MINT_RECIPIENT_INDEX = 36; +const AMOUNT_INDEX = 68; +const MESSAGE_SENDER_INDEX = 100; +const MAX_FEE_INDEX = 132; +const FEE_EXECUTED_INDEX = 164; +const EXPIRATION_BLOCK = 196; +const HOOK_DATA_INDEX = 228; + +/** + * Type for the body of a TokenMessengerV2 message. + */ +export type TokenMessengerV2MessageBody = { + version: number; + burnToken: anchor.web3.PublicKey; + mintRecipient: anchor.web3.PublicKey; + amount: BigInt; + messageSender: anchor.web3.PublicKey; + maxFee: BigInt; + feeExecuted: BigInt; + expirationBlock: BigInt; + hookData: Buffer; +}; + +/** + * Type for the header of a CCTPv2 message. + */ +export type MessageHeaderV2 = { + version: number; + sourceDomain: number; + destinationDomain: number; + nonce: BigInt; + sender: anchor.web3.PublicKey; + recipient: anchor.web3.PublicKey; + destinationCaller: anchor.web3.PublicKey; + minFinalityThreshold: number; + finalityThresholdExecuted: number; + messageBody: Buffer; +}; + +/** + * Decodes a CCTPv2 message into a MessageHeaderV2 and TokenMessengerV2MessageBody. + */ +export const decodeMessageSentDataV2 = (message: Buffer) => { + const messageHeader = decodeMessageHeaderV2(message); + + const messageBodyData = message.slice(MESSAGE_BODY_INDEX); + + const messageBody = decodeTokenMessengerV2MessageBody(messageBodyData); + + return { ...messageHeader, messageBody }; +}; + +/** + * Decodes a CCTPv2 message header. + */ +export const decodeMessageHeaderV2 = (data: Buffer): MessageHeaderV2 => { + const version = data.readUInt32BE(HEADER_VERSION_INDEX); + const sourceDomain = data.readUInt32BE(SOURCE_DOMAIN_INDEX); + const destinationDomain = data.readUInt32BE(DESTINATION_DOMAIN_INDEX); + const nonce = readUInt256BE(data.slice(NONCE_INDEX, NONCE_INDEX + 32)); + const sender = new anchor.web3.PublicKey(data.slice(HEADER_SENDER_INDEX, HEADER_SENDER_INDEX + 32)); + const recipient = new anchor.web3.PublicKey(data.slice(HEADER_RECIPIENT_INDEX, HEADER_RECIPIENT_INDEX + 32)); + const destinationCaller = new anchor.web3.PublicKey( + data.slice(DESTINATION_CALLER_INDEX, DESTINATION_CALLER_INDEX + 32) + ); + const minFinalityThreshold = data.readUInt32BE(MIN_FINALITY_THRESHOLD_INDEX); + const finalityThresholdExecuted = data.readUInt32BE(FINALITY_THRESHOLD_EXECUTED_INDEX); + const messageBody = data.slice(MESSAGE_BODY_INDEX); + return { + version, + sourceDomain, + destinationDomain, + nonce, + sender, + recipient, + destinationCaller, + minFinalityThreshold, + finalityThresholdExecuted, + messageBody, + }; +}; + +/** + * Decodes a TokenMessenger message body. + */ +export const decodeTokenMessengerV2MessageBody = (data: Buffer): TokenMessengerV2MessageBody => { + const version = data.readUInt32BE(BODY_VERSION_INDEX); + const burnToken = new anchor.web3.PublicKey(data.slice(BURN_TOKEN_INDEX, BURN_TOKEN_INDEX + 32)); + const mintRecipient = new anchor.web3.PublicKey(data.slice(MINT_RECIPIENT_INDEX, MINT_RECIPIENT_INDEX + 32)); + const amount = readUInt256BE(data.slice(AMOUNT_INDEX, AMOUNT_INDEX + 32)); + const messageSender = new anchor.web3.PublicKey(data.slice(MESSAGE_SENDER_INDEX, MESSAGE_SENDER_INDEX + 32)); + const maxFee = readUInt256BE(data.slice(MAX_FEE_INDEX, MAX_FEE_INDEX + 32)); + const feeExecuted = readUInt256BE(data.slice(FEE_EXECUTED_INDEX, FEE_EXECUTED_INDEX + 32)); + const expirationBlock = readUInt256BE(data.slice(EXPIRATION_BLOCK, EXPIRATION_BLOCK + 32)); + const hookData = data.slice(HOOK_DATA_INDEX); + return { version, burnToken, mintRecipient, amount, messageSender, maxFee, feeExecuted, expirationBlock, hookData }; +}; diff --git a/src/svm/web3-v1/index.ts b/src/svm/web3-v1/index.ts index dad16bb73..dbbd53044 100644 --- a/src/svm/web3-v1/index.ts +++ b/src/svm/web3-v1/index.ts @@ -8,3 +8,4 @@ export * from "./programConnectors"; export * from "./constants"; export * from "./helpers"; export * from "./cctpHelpers"; +export * from "./cctpV2Helpers"; diff --git a/src/svm/web3-v1/programConnectors.ts b/src/svm/web3-v1/programConnectors.ts index db86f2200..78bde1d91 100644 --- a/src/svm/web3-v1/programConnectors.ts +++ b/src/svm/web3-v1/programConnectors.ts @@ -14,6 +14,8 @@ import { MessageTransmitterV2Idl, TokenMessengerMinterV2Anchor, TokenMessengerMinterV2Idl, + SponsoredCctpSrcPeripheryAnchor, + SponsoredCctpSrcPeripheryIdl, } from "../assets"; import { getSolanaChainId, isSolanaDevnet } from "./helpers"; @@ -73,3 +75,8 @@ export function getTokenMessengerMinterV2Program(provider: AnchorProvider, optio const id = resolveProgramId("TokenMessengerMinterV2", provider, options); return getConnectedProgram(TokenMessengerMinterV2Idl, provider, id); } + +export function getSponsoredCctpSrcPeripheryProgram(provider: AnchorProvider, options?: ProgramOptions) { + const id = resolveProgramId("SponsoredCctpSrcPeriphery", provider, options); + return getConnectedProgram(SponsoredCctpSrcPeripheryIdl, provider, id); +} diff --git a/src/svm/web3-v1/transactionUtils.ts b/src/svm/web3-v1/transactionUtils.ts index 1fb43e442..6fa587a83 100644 --- a/src/svm/web3-v1/transactionUtils.ts +++ b/src/svm/web3-v1/transactionUtils.ts @@ -1,5 +1,6 @@ import { web3 } from "@coral-xyz/anchor"; import { + AddressLookupTableAccount, AddressLookupTableProgram, Connection, Keypair, @@ -15,7 +16,8 @@ import { export async function sendTransactionWithLookupTable( connection: Connection, instructions: TransactionInstruction[], - sender: Keypair + sender: Keypair, + additionalSigners: Keypair[] = [] ): Promise<{ txSignature: string; lookupTableAddress: PublicKey }> { // Maximum number of accounts that can be added to Address Lookup Table (ALT) in a single transaction. const maxExtendedAccounts = 30; @@ -78,7 +80,7 @@ export async function sendTransactionWithLookupTable( ); // Sign and submit the versioned transaction. - versionedTx.sign([sender]); + versionedTx.sign([sender, ...additionalSigners]); const txSignature = await connection.sendTransaction(versionedTx); // Confirm the versioned transaction @@ -90,3 +92,36 @@ export async function sendTransactionWithLookupTable( return { txSignature, lookupTableAddress }; } + +/** + * Sends a transaction using existing Address Lookup Table. + */ +export async function sendTransactionWithExistingLookupTable( + connection: Connection, + instructions: TransactionInstruction[], + lookupTableAccount: AddressLookupTableAccount, + sender: Keypair, + additionalSigners: Keypair[] = [] +): Promise { + // Create the versioned transaction + const versionedTx = new VersionedTransaction( + new TransactionMessage({ + payerKey: sender.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions, + }).compileToV0Message([lookupTableAccount]) + ); + + // Sign and submit the versioned transaction. + versionedTx.sign([sender, ...additionalSigners]); + const txSignature = await connection.sendTransaction(versionedTx); + + // Confirm the versioned transaction + let block = await connection.getLatestBlockhash(); + await connection.confirmTransaction( + { signature: txSignature, blockhash: block.blockhash, lastValidBlockHeight: block.lastValidBlockHeight }, + "confirmed" + ); + + return txSignature; +} diff --git a/test/svm/SponsoredCctpSrc.Deposit.ts b/test/svm/SponsoredCctpSrc.Deposit.ts new file mode 100644 index 000000000..e919505ee --- /dev/null +++ b/test/svm/SponsoredCctpSrc.Deposit.ts @@ -0,0 +1,669 @@ +import * as anchor from "@coral-xyz/anchor"; +import { BN, Program, workspace } from "@coral-xyz/anchor"; +import { createMint, getOrCreateAssociatedTokenAccount, mintTo, TOKEN_PROGRAM_ID, getAccount } from "@solana/spl-token"; +import { + AddressLookupTableAccount, + AddressLookupTableProgram, + Keypair, + PublicKey, + sendAndConfirmTransaction, + SendTransactionError, + SystemProgram, + Transaction, +} from "@solana/web3.js"; +import { assert } from "chai"; +import * as crypto from "crypto"; +import { ethers } from "ethers"; +import { TokenMessengerMinterV2 } from "../../target/types/token_messenger_minter_v2"; +import { MessageTransmitterV2 } from "../../src/svm/assets/message_transmitter_v2"; +import { program, provider, connection, initializeState, owner, createQuoteSigner } from "./SponsoredCctpSrc.common"; +import { SponsoredCCTPQuote, HookData } from "./SponsoredCctpSrc.types"; +import { + findProgramAddress, + sendTransactionWithExistingLookupTable, + readEventsUntilFound, + decodeMessageSentDataV2, +} from "../../src/svm/web3-v1"; + +describe("sponsored_cctp_src_periphery.deposit", () => { + anchor.setProvider(provider); + + const tokenMessengerMinterV2Program = workspace.TokenMessengerMinterV2 as Program; + const messageTransmitterV2Program = workspace.MessageTransmitterV2 as Program; + + const { payer } = anchor.AnchorProvider.env().wallet as anchor.Wallet; + + const depositor = Keypair.generate(); + const operator = Keypair.generate(); + const { quoteSigner, quoteSignerPubkey } = createQuoteSigner(); + + const tokenDecimals = 6; + const seedBalance = BigInt(ethers.utils.parseUnits("1000000", tokenDecimals).toString()); + const burnAmount = ethers.utils.parseUnits("1000", 6); + const remoteDomain = new BN(0); // Ethereum + const mintRecipient = ethers.utils.arrayify(ethers.utils.id("mintRecipient")); + const destinationCaller = ethers.utils.arrayify(ethers.utils.id("destinationCaller")); + const finalRecipient = ethers.utils.arrayify(ethers.utils.id("finalRecipient")); + const finalToken = ethers.utils.arrayify(ethers.utils.id("finalToken")); + const maxFee = 100; + const minFinalityThreshold = 5; + const maxBpsToSponsor = 500; + const maxUserSlippageBps = 1000; + const executionMode = 0; // DirectToCore + const actionData = "0x"; // Empty in DirectToCore mode + + let sourceDomain: number; + let messageSentEventData: Keypair; + let lookupTableAccount: AddressLookupTableAccount; + let state: PublicKey, + tokenProgram: PublicKey, + burnToken: PublicKey, + depositorTokenAccount: PublicKey, + denylistAccount: PublicKey, + tokenMessengerMinterSenderAuthority: PublicKey, + messageTransmitter: PublicKey, + tokenMessenger: PublicKey, + remoteTokenMessenger: PublicKey, + tokenMinter: PublicKey, + localToken: PublicKey, + cctpEventAuthority: PublicKey, + rentFund: PublicKey; + + const getDenyList = (user: PublicKey): PublicKey => { + const [denyList] = PublicKey.findProgramAddressSync( + [Buffer.from("denylist_account"), user.toBuffer()], + tokenMessengerMinterV2Program.programId + ); + return denyList; + }; + + const signSponsoredCCTPQuote = (signer: ethers.Wallet, quoteData: SponsoredCCTPQuote): Buffer => { + const encodedPart1 = ethers.utils.defaultAbiCoder.encode( + ["uint32", "uint32", "bytes32", "uint256", "bytes32", "bytes32", "uint256", "uint32"], + [ + quoteData.sourceDomain, + quoteData.destinationDomain, + quoteData.mintRecipient, + quoteData.amount, + quoteData.burnToken, + quoteData.destinationCaller, + quoteData.maxFee, + quoteData.minFinalityThreshold, + ] + ); + const encodedPart2 = ethers.utils.defaultAbiCoder.encode( + ["bytes32", "uint256", "uint256", "uint256", "bytes32", "bytes32", "uint8", "bytes32"], + [ + quoteData.nonce, + quoteData.deadline, + quoteData.maxBpsToSponsor, + quoteData.maxUserSlippageBps, + quoteData.finalRecipient, + quoteData.finalToken, + quoteData.executionMode, + ethers.utils.keccak256(quoteData.actionData), + ] + ); + const hash1 = ethers.utils.keccak256(encodedPart1); + const hash2 = ethers.utils.keccak256(encodedPart2); + const encodedHexString = ethers.utils.defaultAbiCoder.encode(["bytes32", "bytes32"], [hash1, hash2]); + const digest = ethers.utils.keccak256(encodedHexString); + + // Create simple ECDSA signature over the encoded quote data hash. + const signatureHexString = ethers.utils.joinSignature(signer._signingKey().signDigest(digest)); + return Buffer.from(ethers.utils.arrayify(signatureHexString)); + }; + + const getEncodedQuoteWithSignature = ( + signer: ethers.Wallet, + quoteData: SponsoredCCTPQuote + ): { quote: Buffer; signature: Buffer } => { + const encodedHexString = ethers.utils.defaultAbiCoder.encode( + [ + "uint32", + "uint32", + "bytes32", + "uint256", + "bytes32", + "bytes32", + "uint256", + "uint32", + "bytes32", + "uint256", + "uint256", + "uint256", + "bytes32", + "bytes32", + "uint8", + "bytes", + ], + [ + quoteData.sourceDomain, + quoteData.destinationDomain, + quoteData.mintRecipient, + quoteData.amount, + quoteData.burnToken, + quoteData.destinationCaller, + quoteData.maxFee, + quoteData.minFinalityThreshold, + quoteData.nonce, + quoteData.deadline, + quoteData.maxBpsToSponsor, + quoteData.maxUserSlippageBps, + quoteData.finalRecipient, + quoteData.finalToken, + quoteData.executionMode, + quoteData.actionData, + ] + ); + const encodedQuote = Buffer.from(ethers.utils.arrayify(encodedHexString)); + + const signature = signSponsoredCCTPQuote(signer, quoteData); + + return { quote: encodedQuote, signature }; + }; + + const getHookDataFromQuote = (quoteData: SponsoredCCTPQuote): Buffer => { + const encodedHexString = ethers.utils.defaultAbiCoder.encode( + ["bytes32", "uint256", "uint256", "uint256", "bytes32", "bytes32", "uint8", "bytes"], + [ + quoteData.nonce, + quoteData.deadline, + quoteData.maxBpsToSponsor, + quoteData.maxUserSlippageBps, + quoteData.finalRecipient, + quoteData.finalToken, + quoteData.executionMode, + quoteData.actionData, + ] + ); + + return Buffer.from(ethers.utils.arrayify(encodedHexString)); + }; + + const decodeHookData = (data: Buffer | Uint8Array | string): HookData => { + const ABI_TYPES = [ + "bytes32", // nonce + "uint256", // deadline + "uint256", // maxBpsToSponsor + "uint256", // maxUserSlippageBps + "bytes32", // finalRecipient + "bytes32", // finalToken + "uint8", // executionMode + "bytes", // actionData + ] as const; + + const decoded = ethers.utils.defaultAbiCoder.decode(ABI_TYPES, data); + + const [ + nonce, + deadline, + maxBpsToSponsor, + maxUserSlippageBps, + finalRecipient, + finalToken, + executionMode, + actionData, + ] = decoded as [string, ethers.BigNumber, ethers.BigNumber, ethers.BigNumber, string, string, number, string]; + + return { + nonce, + deadline, + maxBpsToSponsor, + maxUserSlippageBps, + finalRecipient, + finalToken, + executionMode, + actionData, + }; + }; + + const setupBurnToken = async () => { + burnToken = await createMint(connection, payer, owner, owner, tokenDecimals, undefined, undefined, tokenProgram); + + depositorTokenAccount = ( + await getOrCreateAssociatedTokenAccount( + connection, + payer, + burnToken, + depositor.publicKey, + undefined, + undefined, + undefined, + tokenProgram + ) + ).address; + await mintTo( + connection, + payer, + burnToken, + depositorTokenAccount, + owner, + seedBalance, + undefined, + undefined, + tokenProgram + ); + + // Add local CCTP token (test wallet is overridden as token controller in Anchor.toml). + [localToken] = PublicKey.findProgramAddressSync( + [Buffer.from("local_token"), burnToken.toBuffer()], + tokenMessengerMinterV2Program.programId + ); + const [custodyTokenAccount] = PublicKey.findProgramAddressSync( + [Buffer.from("custody"), burnToken.toBuffer()], + tokenMessengerMinterV2Program.programId + ); + const addLocalTokenAccounts = { + tokenController: owner, + tokenMinter, + localToken, + custodyTokenAccount, + localTokenMint: burnToken, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + program: tokenMessengerMinterV2Program.programId, + eventAuthority: cctpEventAuthority, + }; + await tokenMessengerMinterV2Program.methods.addLocalToken({}).accounts(addLocalTokenAccounts).rpc(); + + // Set max burn amount per CCTP message for local token to total mint amount. + const setMaxBurnAmountPerMessageAccounts = { + tokenMinter, + localToken, + program: tokenMessengerMinterV2Program.programId, + eventAuthority: cctpEventAuthority, + }; + await tokenMessengerMinterV2Program.methods + .setMaxBurnAmountPerMessage({ burnLimitPerMessage: new BN(seedBalance.toString()) }) + .accounts(setMaxBurnAmountPerMessageAccounts) + .rpc(); + }; + + const setupCctpAccounts = () => { + denylistAccount = getDenyList(depositor.publicKey); + tokenMessengerMinterSenderAuthority = findProgramAddress( + "sender_authority", + tokenMessengerMinterV2Program.programId + ).publicKey; + messageTransmitter = findProgramAddress("message_transmitter", messageTransmitterV2Program.programId).publicKey; + tokenMessenger = findProgramAddress("token_messenger", tokenMessengerMinterV2Program.programId).publicKey; + remoteTokenMessenger = findProgramAddress("remote_token_messenger", tokenMessengerMinterV2Program.programId, [ + remoteDomain.toString(), + ]).publicKey; + tokenMinter = findProgramAddress("token_minter", tokenMessengerMinterV2Program.programId).publicKey; + cctpEventAuthority = findProgramAddress("__event_authority", tokenMessengerMinterV2Program.programId).publicKey; + }; + + const setupLookupTable = async () => { + // These accounts should be the same for all deposits that have the same burnToken. + const eventAuthority = findProgramAddress("__event_authority", program.programId).publicKey; + const lookupAddresses = [ + state, + burnToken, + tokenMessengerMinterSenderAuthority, + messageTransmitter, + tokenMessenger, + tokenMinter, + localToken, + cctpEventAuthority, + messageTransmitterV2Program.programId, + tokenMessengerMinterV2Program.programId, + tokenProgram, + SystemProgram.programId, + eventAuthority, + rentFund, + ]; + + // Create instructions for creating and extending the ALT. + const [lookupTableInstruction, lookupTableAddress] = await AddressLookupTableProgram.createLookupTable({ + authority: owner, + payer: owner, + recentSlot: await connection.getSlot(), + }); + + // Submit the ALT creation transaction + await sendAndConfirmTransaction(connection, new Transaction().add(lookupTableInstruction), [payer], { + commitment: "confirmed", + skipPreflight: true, + }); + + // Extend the ALT with all accounts. + const extendInstruction = AddressLookupTableProgram.extendLookupTable({ + lookupTable: lookupTableAddress, + authority: owner, + payer: owner, + addresses: lookupAddresses, + }); + + await sendAndConfirmTransaction(connection, new Transaction().add(extendInstruction), [payer], { + commitment: "confirmed", + skipPreflight: true, + }); + + // Wait for slot to advance. ALTs only active after slot advance. + const initialSlot = await connection.getSlot(); + while ((await connection.getSlot()) === initialSlot) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + // Fetch the AddressLookupTableAccount. + const fetchedLookupTableAccount = (await connection.getAddressLookupTable(lookupTableAddress)).value; + if (fetchedLookupTableAccount === null) throw new Error("AddressLookupTableAccount not fetched"); + lookupTableAccount = fetchedLookupTableAccount; + }; + + const setupRentFund = async () => { + rentFund = findProgramAddress("rent_fund", program.programId).publicKey; + await connection.requestAirdrop(rentFund, 1_000_000_000); // 1 SOL + }; + + const getUsedNonce = (nonce: Buffer): PublicKey => { + const [usedNonce] = PublicKey.findProgramAddressSync([Buffer.from("used_nonce"), nonce], program.programId); + return usedNonce; + }; + + before(async () => { + await connection.requestAirdrop(depositor.publicKey, 10_000_000_000); // 10 SOL + await connection.requestAirdrop(operator.publicKey, 10_000_000_000); // 10 SOL + + setupCctpAccounts(); + + await setupRentFund(); + + ({ state, sourceDomain } = await initializeState({ signer: quoteSignerPubkey })); + + tokenProgram = TOKEN_PROGRAM_ID; + await setupBurnToken(); + await setupLookupTable(); + }); + + beforeEach(async () => { + messageSentEventData = Keypair.generate(); + }); + + it("Sponsored CCTP deposit", async () => { + const nonce = crypto.randomBytes(32); + const usedNonce = getUsedNonce(nonce); + const deadline = ethers.BigNumber.from(Math.floor(Date.now() / 1000) + 3600); + + const quoteData: SponsoredCCTPQuote = { + sourceDomain, + destinationDomain: remoteDomain.toNumber(), + mintRecipient: ethers.utils.hexlify(mintRecipient), + amount: burnAmount, + burnToken: ethers.utils.hexlify(burnToken.toBuffer()), + destinationCaller: ethers.utils.hexlify(destinationCaller), + maxFee, + minFinalityThreshold, + nonce: ethers.utils.hexlify(nonce), + deadline, + maxBpsToSponsor, + maxUserSlippageBps, + finalRecipient: ethers.utils.hexlify(finalRecipient), + finalToken: ethers.utils.hexlify(finalToken), + executionMode, + actionData, + }; + const { quote, signature } = getEncodedQuoteWithSignature(quoteSigner, quoteData); + + const depositAccounts = { + signer: depositor.publicKey, + payer: depositor.publicKey, + state, + rentFund, + usedNonce, + depositorTokenAccount, + burnToken, + denylistAccount, + tokenMessengerMinterSenderAuthority, + messageTransmitter, + tokenMessenger, + remoteTokenMessenger, + tokenMinter, + localToken, + cctpEventAuthority, + tokenProgram, + messageSentEventData: messageSentEventData.publicKey, + program: program.programId, + }; + const depositIx = await program.methods + .depositForBurn({ quote, signature }) + .accounts(depositAccounts) + .instruction(); + const txSignature = await sendTransactionWithExistingLookupTable( + connection, + [depositIx], + lookupTableAccount, + depositor, + [messageSentEventData] + ); + + const depositorTokenAmount = (await getAccount(connection, depositorTokenAccount)).amount; + const expectedDepositorTokenAmount = seedBalance - BigInt(burnAmount.toString()); + assert.strictEqual( + depositorTokenAmount.toString(), + expectedDepositorTokenAmount.toString(), + "Depositor token amount mismatch" + ); + + const events = await readEventsUntilFound(connection, txSignature, [program]); + + const depositEvent = events.find((event) => event.name === "sponsoredDepositForBurn")?.data; + assert.isNotNull(depositEvent, "SponsoredDepositForBurn event should be emitted"); + assert.isTrue(depositEvent.quoteNonce.equals(nonce), "Invalid quoteNonce"); + assert.strictEqual(depositEvent.originSender.toString(), depositor.publicKey.toString(), "Invalid originSender"); + assert.strictEqual( + depositEvent.finalRecipient.toString(), + new PublicKey(finalRecipient).toString(), + "Invalid finalRecipient" + ); + assert.strictEqual(depositEvent.quoteDeadline.toString(), deadline.toString(), "Invalid quoteDeadline"); + assert.strictEqual(depositEvent.maxBpsToSponsor.toString(), maxBpsToSponsor.toString(), "Invalid maxBpsToSponsor"); + assert.strictEqual( + depositEvent.maxUserSlippageBps.toString(), + maxUserSlippageBps.toString(), + "Invalid maxUserSlippageBps" + ); + assert.strictEqual(depositEvent.finalToken.toString(), new PublicKey(finalToken).toString(), "Invalid finalToken"); + assert.strictEqual(depositEvent.finalToken.toString(), new PublicKey(finalToken).toString(), "Invalid finalToken"); + assert.isTrue(depositEvent.signature.equals(Buffer.from(signature)), "Invalid signature"); + + const createdEventAccountEvent = events.find((event) => event.name === "createdEventAccount")?.data; + assert.strictEqual( + createdEventAccountEvent.messageSentEventData.toString(), + messageSentEventData.publicKey.toString(), + "Invalid messageSentEventData" + ); + + const message = decodeMessageSentDataV2( + (await messageTransmitterV2Program.account.messageSent.fetch(messageSentEventData.publicKey)).message + ); + assert.strictEqual(message.destinationDomain, remoteDomain.toNumber(), "Invalid destination domain"); + assert.strictEqual( + message.destinationCaller.toString(), + new PublicKey(destinationCaller).toString(), + "Invalid destinationCaller" + ); + assert.strictEqual(message.minFinalityThreshold, minFinalityThreshold, "Invalid minFinalityThreshold"); + assert.strictEqual(message.messageBody.burnToken.toString(), burnToken.toString(), "Invalid burnToken"); + assert.strictEqual( + message.messageBody.mintRecipient.toString(), + new PublicKey(mintRecipient).toString(), + "Invalid mintRecipient" + ); + assert.strictEqual(message.messageBody.amount.toString(), burnAmount.toString(), "Invalid amount"); + assert.strictEqual( + message.messageBody.messageSender.toString(), + depositor.publicKey.toString(), + "Invalid messageSender" + ); + assert.strictEqual(message.messageBody.maxFee.toString(), maxFee.toString(), "Invalid maxFee"); + const expectedHookData = getHookDataFromQuote(quoteData); + assert.isTrue(message.messageBody.hookData.equals(expectedHookData), "Invalid hookData"); + + const usedNonceCloseInfo = await program.methods.getUsedNonceCloseInfo({ nonce: Array.from(nonce) }).view(); + assert.strictEqual(usedNonceCloseInfo.canCloseAfter.toString(), deadline.toString(), "Invalid canCloseAfter"); + assert.isFalse(usedNonceCloseInfo.canCloseNow, "Used nonce should not be closable now"); + }); + + it("Reclaim used_nonce account", async () => { + const nonce = crypto.randomBytes(32); + const usedNonce = getUsedNonce(nonce); + const deadline = ethers.BigNumber.from(Math.floor(Date.now() / 1000) + 3600); + + const quoteData: SponsoredCCTPQuote = { + sourceDomain, + destinationDomain: remoteDomain.toNumber(), + mintRecipient: ethers.utils.hexlify(mintRecipient), + amount: burnAmount, + burnToken: ethers.utils.hexlify(burnToken.toBuffer()), + destinationCaller: ethers.utils.hexlify(destinationCaller), + maxFee, + minFinalityThreshold, + nonce: ethers.utils.hexlify(nonce), + deadline, + maxBpsToSponsor, + maxUserSlippageBps, + finalRecipient: ethers.utils.hexlify(finalRecipient), + finalToken: ethers.utils.hexlify(finalToken), + executionMode, + actionData, + }; + const { quote, signature } = getEncodedQuoteWithSignature(quoteSigner, quoteData); + + const depositAccounts = { + signer: depositor.publicKey, + payer: depositor.publicKey, + state, + rentFund, + usedNonce, + depositorTokenAccount, + burnToken, + denylistAccount, + tokenMessengerMinterSenderAuthority, + messageTransmitter, + tokenMessenger, + remoteTokenMessenger, + tokenMinter, + localToken, + cctpEventAuthority, + tokenProgram, + messageSentEventData: messageSentEventData.publicKey, + program: program.programId, + }; + const depositIx = await program.methods + .depositForBurn({ quote, signature }) + .accounts(depositAccounts) + .instruction(); + await sendTransactionWithExistingLookupTable(connection, [depositIx], lookupTableAccount, depositor, [ + messageSentEventData, + ]); + + const reclaimIx = await program.methods + .reclaimUsedNonceAccount({ nonce: Array.from(nonce) }) + .accountsPartial({ usedNonce }) + .instruction(); + + try { + await sendAndConfirmTransaction(connection, new Transaction().add(reclaimIx), [operator]); + assert.fail("Reclaim used nonce account should have failed"); + } catch (err: any) { + assert.instanceOf(err, SendTransactionError); + const logs = await (err as SendTransactionError).getLogs(connection); + assert.isTrue( + logs.some((log) => log.includes("QuoteDeadlineNotPassed")), + "Expected QuoteDeadlineNotPassed error log" + ); + } + + const usedNonceLamports = await connection.getBalance(usedNonce); + const rentFundLamportsBefore = await connection.getBalance(rentFund); + + await program.methods.setCurrentTime({ newTime: new BN(deadline.add(1).toString()) }).rpc(); + + await sendAndConfirmTransaction(connection, new Transaction().add(reclaimIx), [operator]); + + try { + await program.account.usedNonce.fetch(usedNonce); + assert.fail("Fetching closed account should have failed"); + } catch (err: any) { + assert.instanceOf(err, Error); + assert.include((err as Error).message, "Account does not exist", "Expected account not found error"); + } + + const rentFundLamportsAfter = await connection.getBalance(rentFund); + assert.strictEqual( + rentFundLamportsAfter, + rentFundLamportsBefore + usedNonceLamports, + "Rent fund should receive all lamports from reclaimed used nonce account" + ); + }); + + it("Deposit with maximum actionData length", async () => { + const nonce = crypto.randomBytes(32); + const usedNonce = getUsedNonce(nonce); + const deadline = ethers.BigNumber.from(Math.floor(Date.now() / 1000) + 3600); + const executionMode = 1; // ArbitraryActionsToCore + const actionDataLenth = 128; // Larger actionData would exceed the transaction message size limits on Solana. + const actionData = crypto.randomBytes(actionDataLenth); + + const quoteData: SponsoredCCTPQuote = { + sourceDomain, + destinationDomain: remoteDomain.toNumber(), + mintRecipient: ethers.utils.hexlify(mintRecipient), + amount: burnAmount, + burnToken: ethers.utils.hexlify(burnToken.toBuffer()), + destinationCaller: ethers.utils.hexlify(destinationCaller), + maxFee, + minFinalityThreshold, + nonce: ethers.utils.hexlify(nonce), + deadline, + maxBpsToSponsor, + maxUserSlippageBps, + finalRecipient: ethers.utils.hexlify(finalRecipient), + finalToken: ethers.utils.hexlify(finalToken), + executionMode, + actionData: ethers.utils.hexlify(actionData), + }; + const { quote, signature } = getEncodedQuoteWithSignature(quoteSigner, quoteData); + + const depositAccounts = { + signer: depositor.publicKey, + payer: depositor.publicKey, + state, + rentFund, + usedNonce, + depositorTokenAccount, + burnToken, + denylistAccount, + tokenMessengerMinterSenderAuthority, + messageTransmitter, + tokenMessenger, + remoteTokenMessenger, + tokenMinter, + localToken, + cctpEventAuthority, + tokenProgram, + messageSentEventData: messageSentEventData.publicKey, + program: program.programId, + }; + const depositIx = await program.methods + .depositForBurn({ quote, signature }) + .accounts(depositAccounts) + .instruction(); + await sendTransactionWithExistingLookupTable(connection, [depositIx], lookupTableAccount, depositor, [ + messageSentEventData, + ]); + + const message = decodeMessageSentDataV2( + (await messageTransmitterV2Program.account.messageSent.fetch(messageSentEventData.publicKey)).message + ); + const expectedHookData = getHookDataFromQuote(quoteData); + assert.isTrue(message.messageBody.hookData.equals(expectedHookData), "Invalid hookData"); + + // Above check for encoded hookData should implicitly verify action data, but add explicit test for clarity. + const decodedHookData = decodeHookData(message.messageBody.hookData); + assert.strictEqual(decodedHookData.actionData, ethers.utils.hexlify(actionData), "Invalid actionData"); + }); +}); diff --git a/test/svm/SponsoredCctpSrc.common.ts b/test/svm/SponsoredCctpSrc.common.ts new file mode 100644 index 000000000..9e9e198da --- /dev/null +++ b/test/svm/SponsoredCctpSrc.common.ts @@ -0,0 +1,41 @@ +import * as anchor from "@coral-xyz/anchor"; +import { BN, Program } from "@coral-xyz/anchor"; +import { PublicKey } from "@solana/web3.js"; +import { randomBytes } from "crypto"; +import { ethers } from "ethers"; +import { evmAddressToPublicKey } from "../../src/svm/web3-v1"; +import { SponsoredCctpSrcPeriphery } from "../../target/types/sponsored_cctp_src_periphery"; + +export const provider = anchor.AnchorProvider.env(); +export const program = anchor.workspace.SponsoredCctpSrcPeriphery as Program; +export const connection = provider.connection; +export const owner = provider.wallet.publicKey; +const solanaDomain = 5; // CCTP domain. + +export function createQuoteSigner(): { quoteSigner: ethers.Wallet; quoteSignerPubkey: PublicKey } { + const quoteSigner = ethers.Wallet.createRandom(); + const quoteSignerPubkey = evmAddressToPublicKey(quoteSigner.address); + return { quoteSigner, quoteSignerPubkey }; +} + +export function getProgramData(): PublicKey { + const [programData] = PublicKey.findProgramAddressSync( + [program.programId.toBuffer()], + new PublicKey("BPFLoaderUpgradeab1e11111111111111111111111") + ); + return programData; +} + +export async function initializeState({ + sourceDomain = solanaDomain, + signer = PublicKey.default, +}: { seed?: BN; sourceDomain?: number; signer?: PublicKey } = {}) { + const seeds = [Buffer.from("state")]; + const [state] = PublicKey.findProgramAddressSync(seeds, program.programId); + const programData = getProgramData(); + await program.methods + .initialize({ sourceDomain, signer }) + .accounts({ program: program.programId, programData }) + .rpc(); + return { programData, state, sourceDomain, signer }; +} diff --git a/test/svm/SponsoredCctpSrc.types.ts b/test/svm/SponsoredCctpSrc.types.ts new file mode 100644 index 000000000..43744577a --- /dev/null +++ b/test/svm/SponsoredCctpSrc.types.ts @@ -0,0 +1,31 @@ +import { ethers } from "ethers"; + +export interface SponsoredCCTPQuote { + sourceDomain: number; // uint32 + destinationDomain: number; // uint32 + mintRecipient: string; // bytes32 + amount: ethers.BigNumberish; // uint256 + burnToken: string; // bytes32 + destinationCaller: string; // bytes32 + maxFee: ethers.BigNumberish; // uint256 + minFinalityThreshold: number; // uint32 + nonce: string; // bytes32 + deadline: ethers.BigNumberish; // uint256 + maxBpsToSponsor: ethers.BigNumberish; // uint256 + maxUserSlippageBps: ethers.BigNumberish; // uint256 + finalRecipient: string; // bytes32 + finalToken: string; // bytes32 + executionMode: number; // uint8 + actionData: ethers.BytesLike; // bytes +} + +export interface HookData { + nonce: string; // bytes32 + deadline: ethers.BigNumber; // uint256 + maxBpsToSponsor: ethers.BigNumber; // uint256 + maxUserSlippageBps: ethers.BigNumber; // uint256 + finalRecipient: string; // bytes32 + finalToken: string; // bytes32 + executionMode: number; // uint8 + actionData: string; // bytes +} diff --git a/test/svm/accounts/token_minter_v2.json b/test/svm/accounts/token_minter_v2.json new file mode 100644 index 000000000..5eb487faf --- /dev/null +++ b/test/svm/accounts/token_minter_v2.json @@ -0,0 +1,14 @@ +{ + "pubkey": "E1bQJ8eMMn3zmeSewW3HQ8zmJr7KR75JonbwAtWx2bux", + "account": { + "lamports": 16405944, + "data": [ + "eoVUPzmfq85bM5EKcdWmeUGe6ftthEAn2qThLAEk1RIO7OHNYPtn0CiFKkYy2TG1vThbV7ZYvgOycmNmQKb37bZ9rkzpRonvAP0=", + "base64" + ], + "owner": "CCTPV2vPZJS2u2BBsUoscuikbYjnpFmbFsvVuJdgUMQe", + "executable": false, + "rentEpoch": 18446744073709551615, + "space": 74 + } +}