Compare commits

...

2 Commits

Author SHA1 Message Date
A5 Pickle 3d6492f8b5
solana: fix after cctp upgrade; uptick anchor 0.29 2024-02-08 15:21:40 -06:00
A5 Pickle 6885f97349
solana: add program and tests v1 2024-02-08 15:21:17 -06:00
108 changed files with 21844 additions and 0 deletions

8
solana/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.anchor
.env
.private
.vscode
artifacts-*
node_modules
target
ts/tests/artifacts

14
solana/.prettierrc.json Normal file
View File

@ -0,0 +1,14 @@
{
"overrides": [
{
"files": "*.ts",
"options": {
"printWidth": 100,
"tabWidth": 4,
"useTabs": false,
"singleQuote": false,
"bracketSpacing": true
}
}
]
}

140
solana/Anchor.toml Normal file
View File

@ -0,0 +1,140 @@
[toolchain]
anchor_version = "0.29.0" # CLI
solana_version = "1.16.27"
[features]
seeds = false
skip-lint = false
[workspace]
members = [
"programs/circle-integration"
]
[programs.localnet]
wormhole_circle_integration_solana = "Wormho1eCirc1e1ntegration111111111111111111"
[registry]
url = "https://api.apr.dev"
[provider]
cluster = "Localnet"
wallet = "ts/tests/keys/pFCBP4bhqdSsrWUVTgqhPsLrfEdChBK17vgFM7TxjxQ.json"
[scripts]
test = "npx ts-mocha -p ./tsconfig.json -t 1000000 ts/tests/[0-9]*.ts"
[test]
startup_wait = 30000
[test.validator]
url = "https://api.devnet.solana.com"
### At 160 ticks/s, 64 ticks per slot implies that leader rotation and voting will happen
### every 400 ms. A fast voting cadence ensures faster finality and convergence
ticks_per_slot = 8
### Forked Wormhole Circle Integration Program
[[test.validator.clone]]
address = "wcihrWf1s91vfukW7LW8ZvR1rzpeZ9BrtZ8oyPkWK5d"
### Forked Wormhole Circle Integration PDA -- Custodian
[[test.validator.clone]]
address = "4tTfYz2SqRcZWqyBk1yHyEPzHjoHNbUErQbifBkLmzbT"
### Wormhole Core Bridge Program (Testnet)
[[test.validator.clone]]
address = "3u8hJUVTA4jH1wYAyUur7FFZVQ8H635K3tSHHF4ssjQ5"
### Circle Message Transmitter Program
[[test.validator.clone]]
address = "CCTPmbSD7gX1bxKPAmg77w8oFzNFpaQiQUWD43TKaecd"
### Circle Token Messenger Minter Program
[[test.validator.clone]]
address = "CCTPiPYPc6AsJuwueEnWgSgucamXDZwBd53dQ11YiKX3"
### Mint -- USDC
[[test.validator.account]]
address = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"
filename = "ts/tests/accounts/usdc_mint.json"
### Payer Token Account -- USDC
[[test.validator.account]]
address = "6s9vuDVXZsJY1Qp29cFxKgbSmpTH2QWnrjZzPHWmFXCz"
filename = "ts/tests/accounts/usdc_payer_token.json"
### Circle Token Messenger Minter PDA -- Token Messenger
[[test.validator.account]]
address = "Afgq3BHEfCE7d78D2XE9Bfyu2ieDqvE24xX8KDwreBms"
filename = "ts/tests/accounts/token_messenger_minter/token_messenger.json"
### Circle Token Messenger Minter PDA -- Token Minter
[[test.validator.account]]
address = "DBD8hAwLDRQkTsu6EqviaYNGKPnsAMmQonxf7AH8ZcFY"
filename = "ts/tests/accounts/token_messenger_minter/token_minter.json"
### Circle Token Messenger Minter PDA -- USDC Custody Token Account
[[test.validator.account]]
address = "AEfKU8wHGtYgsXpymQ6e1cGHJJeKqCj95pw82iyRUKEs"
filename = "ts/tests/accounts/token_messenger_minter/usdc_custody_token.json"
### Circle Token Messenger Minter PDA -- USDC Local Token
[[test.validator.account]]
address = "4xt9P42CcMHXAgvemTnzineHp6owfGUcrg1xD9V7mdk1"
filename = "ts/tests/accounts/token_messenger_minter/usdc_local_token.json"
### Circle Token Messenger Minter PDA -- USDC Token Pair
[[test.validator.account]]
address = "ADcG1d7znq6wR73BJgEh7dR4vTJcETLLyfXMNZjJVwk4"
filename = "ts/tests/accounts/token_messenger_minter/usdc_token_pair.json"
### Circle Token Messenger Minter PDA -- Ethereum Remote Token Messenger
[[test.validator.account]]
address = "Hazwi3jFQtLKc2ughi7HFXPkpDeso7DQaMR9Ks4afh3j"
filename = "ts/tests/accounts/token_messenger_minter/ethereum_remote_token_messenger.json"
### Circle Token Messenger Minter PDA -- Base Remote Token Messenger
[[test.validator.account]]
address = "BWyFzH6LsnmDAaDWbGsriQ9SiiKq1CF6pbH4Ye3kzSBV"
filename = "ts/tests/accounts/token_messenger_minter/misconfigured_remote_token_messenger.json"
### Circle Message Transmitter PDA -- Message Transmitter Config
[[test.validator.account]]
address = "BWrwSWjbikT3H7qHAkUEbLmwDQoB4ZDJ4wcSEhSPTZCu"
filename = "ts/tests/accounts/message_transmitter/message_transmitter_config.json"
### Wormhole Core Bridge (Testnet) -- Config
[[test.validator.account]]
address = "6bi4JGDoRwUs9TYBuvoA7dUVyikTJDrJsJU1ew6KVLiu"
filename = "ts/tests/accounts/core_bridge_testnet/config.json"
### Wormhole Core Bridge (Testnet) -- Fee Collector
[[test.validator.account]]
address = "7s3a1ycs16d6SNDumaRtjcoyMaTDZPavzgsmS3uUZYWX"
filename = "ts/tests/accounts/core_bridge_testnet/fee_collector.json"
### Wormhole Core Bridge (Testnet) -- Guardian Set 0
[[test.validator.account]]
address = "dxZtypiKT5D9LYzdPxjvSZER9MgYfeRVU5qpMTMTRs4"
filename = "ts/tests/accounts/core_bridge_testnet/guardian_set_0.json"
### Wormhole Core Bridge Program (Mainnet)
[[test.genesis]]
address = "worm2ZoG2kUd4vFXhvjh93UUH596ayRfgQ2MgjNMTth"
program = "ts/tests/artifacts/mainnet_core_bridge.so"
### Wormhole Core Bridge (Mainnet) -- Config
[[test.validator.account]]
address = "2yVjuQwpsvdsrywzsJJVs9Ueh4zayyo5DYJbBNc3DDpn"
filename = "ts/tests/accounts/core_bridge_mainnet/config.json"
### Wormhole Core Bridge (Mainnet) -- Fee Collector
[[test.validator.account]]
address = "9bFNrXNb2WTx8fMHXCheaZqkLZ3YCCaiqTftHxeintHy"
filename = "ts/tests/accounts/core_bridge_mainnet/fee_collector.json"
### Wormhole Core Bridge (Mainnet) -- Guardian Set 0
[[test.validator.account]]
address = "DS7qfSAgYsonPpKoAjcGhX9VFjXdGkiHjEDkTidf8H2P"
filename = "ts/tests/accounts/core_bridge_mainnet/guardian_set_0.json"

2626
solana/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

46
solana/Cargo.toml Normal file
View File

@ -0,0 +1,46 @@
[workspace]
members = [
"modules/wormhole-cctp",
"programs/circle-integration",
]
resolver = "2"
[workspace.package]
edition = "2021"
version = "0.1.0-alpha.5"
authors = ["Wormhole Contributors"]
license = "Apache-2.0"
homepage = "https://wormhole.com"
repository = "https://github.com/wormhole-foundation/wormhole-circle-integration"
[workspace.dependencies.wormhole-cctp-solana]
path = "modules/wormhole-cctp"
default-features = false
[workspace.dependencies.wormhole-raw-vaas]
version = "0.1.1"
features = ["ruint", "on-chain"]
default-features = false
[workspace.dependencies.anchor-lang]
version = "0.29.0"
features = ["derive", "init-if-needed"]
[workspace.dependencies]
wormhole-io = "0.1.3"
anchor-spl = "0.29.0"
solana-program = "1.17.20"
hex = "0.4.3"
ruint = "1.9.0"
cfg-if = "1.0"
hex-literal = "0.4.1"
[profile.release]
overflow-checks = true
lto = "fat"
codegen-units = 1
[profile.release.build-override]
opt-level = 3
incremental = false
codegen-units = 1

49
solana/Makefile Normal file
View File

@ -0,0 +1,49 @@
SOLANA_CLI="v1.16.16"
ANCHOR_CLI="v0.28.0"
out_mainnet=artifacts-mainnet
out_testnet=artifacts-testnet
.PHONY: all clean check build test lint ci
all: check
check:
cargo check --all-features
clean:
anchor clean
rm -rf node_modules artifacts-mainnet artifacts-testnet ts/tests/artifacts
node_modules:
npm ci
prune_idl: node_modules ts/scripts/pruneIdlTypes.ts
cd ts/scripts && npx ts-node pruneIdlTypes.ts
build: $(out_$(NETWORK))
$(out_$(NETWORK)):
ifdef out_$(NETWORK)
anchor build -p wormhole_circle_integration_solana --arch sbf -- --features "$(NETWORK),no-idl" -- --no-default-features
mkdir -p $(out_$(NETWORK))
cp target/deploy/*.so $(out_$(NETWORK))/
endif
test: node_modules
cargo test --all-features
anchor build -p wormhole_circle_integration_solana --arch sbf -- --features testnet -- --no-default-features
mkdir -p ts/tests/artifacts && cp target/deploy/wormhole_circle_integration_solana.so ts/tests/artifacts/testnet_wormhole_circle_integration_solana.so
solana program dump -u m worm2ZoG2kUd4vFXhvjh93UUH596ayRfgQ2MgjNMTth ts/tests/artifacts/mainnet_core_bridge.so
anchor build --arch sbf -- --features integration-test -- --no-default-features
$(MAKE) prune_idl
anchor test --skip-build
lint:
cargo fmt --check
cargo clippy --no-deps --all-targets --all-features -- -D warnings
ci:
DOCKER_BUILDKIT=1 docker build -f Dockerfile.ci \
--build-arg SOLANA_CLI=$(SOLANA_CLI) \
--build-arg ANCHOR_CLI=$(ANCHOR_CLI) \
.

0
solana/README.md Normal file
View File

View File

@ -0,0 +1,35 @@
[package]
name = "wormhole-cctp-solana"
description = "Wormhole CCTP Utilities for Solana"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
[lib]
crate-type = ["cdylib", "lib"]
[features]
default = ["cpi"]
client = []
cpi = ["dep:anchor-spl", "dep:solana-program"]
mainnet = []
testnet = []
[dependencies]
wormhole-io.workspace = true
wormhole-raw-vaas.workspace = true
anchor-lang.workspace = true
anchor-spl = { optional = true, workspace = true }
solana-program = { optional = true, workspace = true }
hex.workspace = true
ruint.workspace = true
cfg-if.workspace = true
[dev-dependencies]
hex-literal.workspace = true

View File

View File

@ -0,0 +1,2 @@
mod receive_message;
pub use receive_message::*;

View File

@ -0,0 +1,15 @@
mod token_messenger_minter;
pub use token_messenger_minter::*;
use anchor_lang::prelude::*;
/// Common arguments to redeem messages via the CCTP Message Transmitter program using its receive
/// message instruction.
#[derive(Clone, AnchorSerialize, AnchorDeserialize)]
pub struct ReceiveMessageArgs {
/// CCTP message.
pub encoded_message: Vec<u8>,
/// Attestation of [encoded_message](Self::encoded_message).
pub attestation: Vec<u8>,
}

View File

@ -0,0 +1,140 @@
use anchor_lang::prelude::*;
/// Account context to invoke [receive_token_messenger_minter_message].
pub struct ReceiveTokenMessengerMinterMessage<'info> {
/// Mutable signer. Transaction payer.
pub payer: AccountInfo<'info>,
/// Signer. Specific caller, which must be encoded as the destination caller.
pub caller: AccountInfo<'info>,
/// Seeds must be \["message_transmitter_authority"\, token_messenger_minter_program] (CCTP
/// Message Transmitter program).
pub message_transmitter_authority: AccountInfo<'info>,
/// Seeds must be \["message_transmitter"\] (CCTP Message Transmitter program).
pub message_transmitter_config: AccountInfo<'info>,
/// Mutable. Seeds must be \["used_nonces", remote_domain.to_string(), first_nonce.to_string()\]
/// (CCTP Message Transmitter program).
pub used_nonces: AccountInfo<'info>,
/// CCTP Token Messenger Minter program.
pub token_messenger_minter_program: AccountInfo<'info>,
pub system_program: AccountInfo<'info>,
/// Seeds must be \["__event_authority"\] (CCTP Message Transmitter program)).
pub message_transmitter_event_authority: AccountInfo<'info>,
pub message_transmitter_program: AccountInfo<'info>,
// The following accounts are expected to be passed in as remaining accounts. These accounts are
// meant for the Token Messenger Minter program because the Message Transmitter program performs
// CPI on this program so it can mint tokens.
//
// For this integration, we are defining these accounts explicitly in this account context.
//
/// Seeds must be \["token_messenger"\] (CCTP Token Messenger Minter program).
pub token_messenger: AccountInfo<'info>,
/// Seeds must be \["remote_token_messenger"\, remote_domain.to_string()] (CCTP Token Messenger
/// Minter program).
pub remote_token_messenger: AccountInfo<'info>,
/// Seeds must be \["token_minter"\] (CCTP Token Messenger Minter program).
pub token_minter: AccountInfo<'info>,
/// Mutable. Seeds must be \["local_token", mint\] (CCTP Token Messenger Minter program).
pub local_token: AccountInfo<'info>,
/// Seeds must be \["token_pair", remote_domain.to_string(), remote_token_address\] (CCTP Token
/// Messenger Minter program).
pub token_pair: AccountInfo<'info>,
/// Mutable. Mint recipient token account, which must be encoded as the mint recipient in the
/// CCTP mesage.
pub mint_recipient: AccountInfo<'info>,
/// Mutable. Seeds must be \["custody", mint\] (CCTP Token Messenger Minter program).
pub custody_token: AccountInfo<'info>,
pub token_program: AccountInfo<'info>,
/// Seeds must be \["__event_authority"\] (CCTP Token Messenger Minter program).
pub token_messenger_minter_event_authority: AccountInfo<'info>,
}
/// Method to call the receive message instruction on the CCTP Message Transmitter program, specific
/// to receiving a Token Messenger Minter message to mint Circle-supported tokens.
///
/// NOTE: The [caller](ReceiveTokenMessengerMinterMessage::caller) account must be encoded in the
/// CCTP message as the destination caller.
pub fn receive_token_messenger_minter_message<'info>(
ctx: CpiContext<'_, '_, '_, 'info, ReceiveTokenMessengerMinterMessage<'info>>,
args: super::ReceiveMessageArgs,
) -> Result<()> {
const ANCHOR_IX_SELECTOR: [u8; 8] = [38, 144, 127, 225, 31, 225, 238, 25];
solana_program::program::invoke_signed(
&solana_program::instruction::Instruction {
program_id: crate::cctp::message_transmitter_program::ID,
accounts: ctx.to_account_metas(None),
data: (ANCHOR_IX_SELECTOR, args).try_to_vec()?,
},
&ctx.to_account_infos(),
ctx.signer_seeds,
)
.map_err(Into::into)
}
impl<'info> ToAccountMetas for ReceiveTokenMessengerMinterMessage<'info> {
fn to_account_metas(&self, _is_signer: Option<bool>) -> Vec<AccountMeta> {
vec![
AccountMeta::new(self.payer.key(), true),
AccountMeta::new_readonly(self.caller.key(), true),
AccountMeta::new_readonly(self.message_transmitter_authority.key(), false),
AccountMeta::new_readonly(self.message_transmitter_config.key(), false),
AccountMeta::new(self.used_nonces.key(), false),
AccountMeta::new_readonly(self.token_messenger_minter_program.key(), false),
AccountMeta::new_readonly(self.system_program.key(), false),
AccountMeta::new_readonly(self.message_transmitter_event_authority.key(), false),
AccountMeta::new_readonly(self.message_transmitter_program.key(), false),
AccountMeta::new_readonly(self.token_messenger.key(), false),
AccountMeta::new_readonly(self.remote_token_messenger.key(), false),
AccountMeta::new_readonly(self.token_minter.key(), false),
AccountMeta::new(self.local_token.key(), false),
AccountMeta::new_readonly(self.token_pair.key(), false),
AccountMeta::new(self.mint_recipient.key(), false),
AccountMeta::new(self.custody_token.key(), false),
AccountMeta::new_readonly(self.token_program.key(), false),
AccountMeta::new_readonly(self.token_messenger_minter_event_authority.key(), false),
AccountMeta::new_readonly(self.token_messenger_minter_program.key(), false),
]
}
}
impl<'info> ToAccountInfos<'info> for ReceiveTokenMessengerMinterMessage<'info> {
fn to_account_infos(&self) -> Vec<AccountInfo<'info>> {
vec![
self.payer.clone(),
self.caller.clone(),
self.message_transmitter_authority.clone(),
self.message_transmitter_config.clone(),
self.used_nonces.clone(),
self.token_messenger_minter_program.clone(),
self.system_program.clone(),
self.message_transmitter_event_authority.clone(),
self.token_messenger.clone(),
self.remote_token_messenger.clone(),
self.token_minter.clone(),
self.local_token.clone(),
self.token_pair.clone(),
self.mint_recipient.clone(),
self.custody_token.clone(),
self.token_program.clone(),
self.token_messenger_minter_event_authority.clone(),
]
}
}

View File

@ -0,0 +1,22 @@
#[cfg(feature = "cpi")]
pub mod cpi;
mod state;
pub use state::*;
cfg_if::cfg_if! {
if #[cfg(feature = "mainnet")] {
// Placeholder for real address
anchor_lang::declare_id!("CCTPmbSD7gX1bxKPAmg77w8oFzNFpaQiQUWD43TKaecd");
} else if #[cfg(feature = "testnet")] {
anchor_lang::declare_id!("CCTPmbSD7gX1bxKPAmg77w8oFzNFpaQiQUWD43TKaecd");
}
}
pub struct MessageTransmitter {}
impl anchor_lang::Id for MessageTransmitter {
fn id() -> solana_program::pubkey::Pubkey {
ID
}
}

View File

@ -0,0 +1,26 @@
use anchor_lang::prelude::*;
#[derive(Debug, AnchorSerialize, AnchorDeserialize, Clone)]
pub struct MessageTransmitterConfig {
pub owner: Pubkey,
pub pending_owner: Pubkey,
pub attester_manager: Pubkey,
pub pauser: Pubkey,
pub paused: bool,
pub local_domain: u32,
pub version: u32,
pub signature_threshold: u32,
pub enabled_attesters: Vec<[u8; 32]>,
pub max_message_body_size: u64,
pub next_available_nonce: u64,
}
impl anchor_lang::Discriminator for MessageTransmitterConfig {
const DISCRIMINATOR: [u8; 8] = [71, 40, 180, 142, 19, 203, 35, 252];
}
impl Owner for MessageTransmitterConfig {
fn owner() -> Pubkey {
crate::cctp::message_transmitter_program::ID
}
}

View File

@ -0,0 +1,2 @@
mod message_transmitter_config;
pub use message_transmitter_config::*;

View File

@ -0,0 +1,3 @@
pub mod message_transmitter_program;
pub mod token_messenger_minter_program;

View File

@ -0,0 +1,149 @@
use anchor_lang::prelude::*;
/// Account context to invoke [deposit_for_burn_with_caller].
pub struct DepositForBurnWithCaller<'info> {
/// Signer. This account must be the owner of `burn_token`.
//#[account(signer)]
pub burn_token_owner: AccountInfo<'info>,
//#[account(mut, signer)]
pub payer: AccountInfo<'info>,
/// Seeds must be \["sender_authority"\] (CCTP Token Messenger Minter program).
pub token_messenger_minter_sender_authority: AccountInfo<'info>,
/// Mutable. This token account must be owned by `burn_token_owner`.
//#[account(mut)]
pub burn_token: AccountInfo<'info>,
/// Mutable. Seeds must be \["message_transmitter"\] (CCTP Message Transmitter program).
//#[account(mut)]
pub message_transmitter_config: AccountInfo<'info>,
/// Seeds must be \["token_messenger"\] (CCTP Token Messenger Minter program).
pub token_messenger: AccountInfo<'info>,
/// Seeds must be \["remote_token_messenger"\, remote_domain.to_string()] (CCTP Token Messenger
/// Minter program).
pub remote_token_messenger: AccountInfo<'info>,
/// Seeds must be \["token_minter"\] (CCTP Token Messenger Minter program).
pub token_minter: AccountInfo<'info>,
/// Mutable. Seeds must be \["local_token", mint\] (CCTP Token Messenger Minter program).
//#[account(mut)]
pub local_token: AccountInfo<'info>,
/// Mutable. Mint to be burned via CCTP.
//#[account(mut)]
pub mint: AccountInfo<'info>,
//#[account(mut, signer)]
pub cctp_message: AccountInfo<'info>,
/// CCTP Message Transmitter program.
pub message_transmitter_program: AccountInfo<'info>,
/// CCTP Token Messenger Minter program.
pub token_messenger_minter_program: AccountInfo<'info>,
pub token_program: AccountInfo<'info>,
pub system_program: AccountInfo<'info>,
/// Seeds must be \["__event_authority"\] (CCTP Token Messenger Minter program).
pub event_authority: AccountInfo<'info>,
}
/// Parameters to invoke [deposit_for_burn_with_caller].
#[derive(Clone, AnchorSerialize, AnchorDeserialize)]
pub struct DepositForBurnWithCallerParams {
/// Transfer (burn) amount.
pub amount: u64,
/// CCTP domain value of the token to be transferred.
pub destination_domain: u32,
/// Recipient of assets on target network.
///
/// NOTE: In the Token Messenger Minter program IDL, this is encoded as a Pubkey, which is
/// weird because this address is one for another network. We are making it a 32-byte fixed
/// array instead.
pub mint_recipient: [u8; 32],
/// Expected caller on target network.
///
/// NOTE: In the Token Messenger Minter program IDL, this is encoded as a Pubkey, which is
/// weird because this address is one for another network. We are making it a 32-byte fixed
/// array instead.
pub destination_caller: [u8; 32],
}
/// CPI call to invoke the CCTP Token Messenger Minter program to burn Circle-supported assets.
///
/// NOTE: This instruction requires specifying a specific caller on the destination network. Only
/// this caller can mint tokens on behalf of the
/// [mint_recipient](DepositForBurnWithCallerParams::mint_recipient).
pub fn deposit_for_burn_with_caller<'info>(
ctx: CpiContext<'_, '_, '_, 'info, DepositForBurnWithCaller<'info>>,
args: DepositForBurnWithCallerParams,
) -> Result<()> {
const ANCHOR_IX_SELECTOR: [u8; 8] = [167, 222, 19, 114, 85, 21, 14, 118];
solana_program::program::invoke_signed(
&solana_program::instruction::Instruction {
program_id: crate::cctp::token_messenger_minter_program::ID,
accounts: ctx.to_account_metas(None),
data: (ANCHOR_IX_SELECTOR, args).try_to_vec()?,
},
&ctx.to_account_infos(),
ctx.signer_seeds,
)
.map_err(Into::into)
}
impl<'info> ToAccountMetas for DepositForBurnWithCaller<'info> {
fn to_account_metas(&self, _is_signer: Option<bool>) -> Vec<AccountMeta> {
vec![
AccountMeta::new_readonly(self.burn_token_owner.key(), true),
AccountMeta::new(self.payer.key(), true),
AccountMeta::new_readonly(self.token_messenger_minter_sender_authority.key(), false),
AccountMeta::new(self.burn_token.key(), false),
AccountMeta::new(self.message_transmitter_config.key(), false),
AccountMeta::new_readonly(self.token_messenger.key(), false),
AccountMeta::new_readonly(self.remote_token_messenger.key(), false),
AccountMeta::new_readonly(self.token_minter.key(), false),
AccountMeta::new(self.local_token.key(), false),
AccountMeta::new(self.mint.key(), false),
AccountMeta::new(self.cctp_message.key(), true),
AccountMeta::new_readonly(self.message_transmitter_program.key(), false),
AccountMeta::new_readonly(self.token_messenger_minter_program.key(), false),
AccountMeta::new_readonly(self.token_program.key(), false),
AccountMeta::new_readonly(self.system_program.key(), false),
AccountMeta::new_readonly(self.event_authority.key(), false),
AccountMeta::new_readonly(self.token_messenger_minter_program.key(), false),
]
}
}
impl<'info> ToAccountInfos<'info> for DepositForBurnWithCaller<'info> {
fn to_account_infos(&self) -> Vec<AccountInfo<'info>> {
vec![
self.burn_token_owner.clone(),
self.payer.clone(),
self.token_messenger_minter_sender_authority.clone(),
self.burn_token.clone(),
self.message_transmitter_config.clone(),
self.token_messenger.clone(),
self.remote_token_messenger.clone(),
self.token_minter.clone(),
self.local_token.clone(),
self.mint.clone(),
self.cctp_message.clone(),
self.message_transmitter_program.clone(),
self.token_program.clone(),
self.system_program.clone(),
self.event_authority.clone(),
]
}
}

View File

@ -0,0 +1,2 @@
mod deposit_for_burn_with_caller;
pub use deposit_for_burn_with_caller::*;

View File

@ -0,0 +1,22 @@
#[cfg(feature = "cpi")]
pub mod cpi;
mod state;
pub use state::*;
cfg_if::cfg_if! {
if #[cfg(feature = "mainnet")] {
// Placeholder for real address
anchor_lang::declare_id!("CCTPiPYPc6AsJuwueEnWgSgucamXDZwBd53dQ11YiKX3");
} else if #[cfg(feature = "testnet")] {
anchor_lang::declare_id!("CCTPiPYPc6AsJuwueEnWgSgucamXDZwBd53dQ11YiKX3");
}
}
pub struct TokenMessengerMinter {}
impl anchor_lang::Id for TokenMessengerMinter {
fn id() -> solana_program::pubkey::Pubkey {
ID
}
}

View File

@ -0,0 +1,28 @@
use anchor_lang::prelude::*;
#[derive(Debug, AnchorSerialize, AnchorDeserialize, Clone)]
pub struct LocalToken {
pub custody_token: Pubkey,
pub mint: Pubkey,
pub burn_limit_per_message: u64,
pub messages_sent: u64,
pub messages_received: u64,
pub amount_sent: u128,
pub amount_received: u128,
pub bump: u8,
pub custody_bump: u8,
}
impl LocalToken {
pub const SEED_PREFIX: &'static [u8] = b"local_token";
}
impl anchor_lang::Discriminator for LocalToken {
const DISCRIMINATOR: [u8; 8] = [159, 131, 58, 170, 193, 84, 128, 182];
}
impl Owner for LocalToken {
fn owner() -> Pubkey {
crate::cctp::token_messenger_minter_program::ID
}
}

View File

@ -0,0 +1,8 @@
mod local_token;
pub use local_token::*;
mod remote_token_messenger;
pub use remote_token_messenger::*;
mod token_pair;
pub use token_pair::*;

View File

@ -0,0 +1,21 @@
use anchor_lang::prelude::*;
#[derive(Debug, AnchorSerialize, AnchorDeserialize, Clone)]
pub struct RemoteTokenMessenger {
pub domain: u32,
pub token_messenger: [u8; 32],
}
impl RemoteTokenMessenger {
pub const SEED_PREFIX: &'static [u8] = b"remote_token_messenger";
}
impl anchor_lang::Discriminator for RemoteTokenMessenger {
const DISCRIMINATOR: [u8; 8] = [105, 115, 174, 34, 95, 233, 138, 252];
}
impl Owner for RemoteTokenMessenger {
fn owner() -> Pubkey {
crate::cctp::token_messenger_minter_program::ID
}
}

View File

@ -0,0 +1,23 @@
use anchor_lang::prelude::*;
#[derive(Debug, AnchorSerialize, AnchorDeserialize, Clone)]
pub struct TokenPair {
pub remote_domain: u32,
pub remote_token_address: [u8; 32],
pub local_token: Pubkey,
pub bump: u8,
}
impl TokenPair {
pub const SEED_PREFIX: &'static [u8] = b"token_pair";
}
impl anchor_lang::Discriminator for TokenPair {
const DISCRIMINATOR: [u8; 8] = [17, 214, 45, 176, 229, 149, 197, 71];
}
impl Owner for TokenPair {
fn owner() -> Pubkey {
crate::cctp::token_messenger_minter_program::ID
}
}

View File

@ -0,0 +1,109 @@
use crate::{cctp, messages::Deposit, wormhole::core_bridge_program};
use anchor_lang::prelude::*;
use wormhole_io::TypePrefixedPayload;
/// Arguments used to burn Circle-supported tokens and publish a Wormhole Core Bridge message.
#[derive(Debug, AnchorSerialize, AnchorDeserialize, Clone)]
pub struct BurnAndPublishArgs {
/// Token account where assets originated from. This pubkey is encoded in the [Deposit] message.
/// If this will be useful to an integrator, he should encode where the assets have been burned
/// from if it was not burned directly when calling [burn_and_publish].
pub burn_source: Option<Pubkey>,
/// Destination caller address, which is encoded in the CCTP message. Only this address can
/// receive a CCTP message via the CCTP Message Transmitter.
pub destination_caller: [u8; 32],
/// Destination CCTP domain, which is encoded both the Wormhole CCTP [Deposit] and CCTP
/// messages. This domain indicates the intended foreign network.
pub destination_cctp_domain: u32,
/// Amount of tokens to burn.
pub amount: u64,
/// Intended mint recipient on destination network.
pub mint_recipient: [u8; 32],
/// Arbitrary value which may be meaningful to an integrator. This nonce is encoded in the
/// Wormhole message.
pub wormhole_message_nonce: u32,
/// Arbitrary payload, which can be used to encode instructions or data for another network's
/// smart contract.
pub payload: Vec<u8>,
}
/// Method to publish a Wormhole Core Bridge message alongside a CCTP message that burns a
/// Circle-supported token.
///
/// NOTE: The [burn_source](BurnAndPublishArgs::burn_source) should be the token account where the
/// assets originated from. A program calling this method will not necessarily be burning assets
/// from this token account directly. So this field is used to indicate the origin of the burned
/// assets.
pub fn burn_and_publish<'info>(
cctp_ctx: CpiContext<
'_,
'_,
'_,
'info,
cctp::token_messenger_minter_program::cpi::DepositForBurnWithCaller<'info>,
>,
wormhole_ctx: CpiContext<'_, '_, '_, 'info, core_bridge_program::cpi::PostMessage<'info>>,
args: BurnAndPublishArgs,
) -> Result<u64> {
let BurnAndPublishArgs {
burn_source,
destination_caller,
destination_cctp_domain,
amount,
mint_recipient,
wormhole_message_nonce,
payload,
} = args;
let cctp_nonce = {
let mut data: &[_] = &cctp_ctx
.accounts
.message_transmitter_config
.try_borrow_data()?;
let config = crate::utils::ExternalAccount::<
cctp::message_transmitter_program::MessageTransmitterConfig,
>::try_deserialize_unchecked(&mut data)?;
// Publish message via Core Bridge. This includes paying the message fee.
core_bridge_program::cpi::post_message(
wormhole_ctx,
core_bridge_program::cpi::PostMessageArgs {
nonce: wormhole_message_nonce,
payload: Deposit {
token_address: cctp_ctx.accounts.mint.key.to_bytes(),
amount: ruint::aliases::U256::from(amount),
source_cctp_domain: config.local_domain,
destination_cctp_domain,
cctp_nonce: config.next_available_nonce,
burn_source: burn_source
.unwrap_or(cctp_ctx.accounts.burn_token.key())
.to_bytes(),
mint_recipient,
payload,
}
.to_vec_payload(),
commitment: core_bridge_program::Commitment::Finalized,
},
)?;
config.next_available_nonce
};
cctp::token_messenger_minter_program::cpi::deposit_for_burn_with_caller(
cctp_ctx,
cctp::token_messenger_minter_program::cpi::DepositForBurnWithCallerParams {
amount,
destination_domain: destination_cctp_domain,
mint_recipient,
destination_caller,
},
)?;
Ok(cctp_nonce)
}

View File

@ -0,0 +1,15 @@
mod burn_and_publish;
pub use burn_and_publish::*;
mod verify_vaa_and_mint;
pub use verify_vaa_and_mint::*;
pub use crate::{
cctp::{
message_transmitter_program::cpi::{
ReceiveMessageArgs, ReceiveTokenMessengerMinterMessage,
},
token_messenger_minter_program::cpi::DepositForBurnWithCaller,
},
wormhole::core_bridge_program::cpi::PostMessage,
};

View File

@ -0,0 +1,111 @@
use crate::{
cctp::message_transmitter_program, error::WormholeCctpError, utils::CctpMessage,
wormhole::core_bridge_program::vaa::VaaAccount,
};
use anchor_lang::prelude::*;
use wormhole_raw_vaas::cctp::WormholeCctpMessage;
/// Method to reconcile a CCTP message with a Wormhole VAA encoding the Wormhole CCTP deposit. After
/// reconciliation, the method invokes the CCTP Message Transmitter to mint the local tokens to the
/// provided token account in the account context.
///
/// This method reconciles both messages by making sure the source domain, destination domain and
/// nonce match.
///
/// NOTE: It is the integrator's responsibility to ensure that the owner of this account is Wormhole
/// Core Bridge program if this method is used. Otherwise, please use [verify_vaa_and_mint], which
/// performs the account owner check.
pub fn verify_vaa_and_mint_unchecked<'info>(
vaa: &VaaAccount<'_>,
cctp_ctx: CpiContext<
'_,
'_,
'_,
'info,
message_transmitter_program::cpi::ReceiveTokenMessengerMinterMessage<'info>,
>,
args: message_transmitter_program::cpi::ReceiveMessageArgs,
) -> Result<()> {
let msg = WormholeCctpMessage::try_from(vaa.try_payload()?)
.map_err(|_| error!(WormholeCctpError::CannotParseMessage))?;
// This should always succeed. But we keep this check just in case we add more message types
// in the future.
let deposit = msg
.deposit()
.ok_or(error!(WormholeCctpError::InvalidDepositMessage))?;
// We need to check the source domain, target domain and nonce to tie the Wormhole Circle Integration
// message to the Circle message.
let cctp_message = CctpMessage::parse(&args.encoded_message)
.map_err(|_| WormholeCctpError::InvalidCctpMessage)?;
require_eq!(
deposit.source_cctp_domain(),
cctp_message.source_domain(),
WormholeCctpError::SourceCctpDomainMismatch
);
require_eq!(
deposit.destination_cctp_domain(),
cctp_message.destination_domain(),
WormholeCctpError::DestinationCctpDomainMismatch
);
require_eq!(
deposit.cctp_nonce(),
cctp_message.nonce(),
WormholeCctpError::CctpNonceMismatch
);
// This check is defense-in-depth (but can possibly be taken out in the future). We verify that
// the mint recipient encoded in the deposit (the same one encoded in the CCTP message) is the
// mint recipient token account found in the account context.
require_keys_eq!(
cctp_ctx.accounts.mint_recipient.key(),
Pubkey::from(deposit.mint_recipient()),
WormholeCctpError::InvalidMintRecipient
);
// Invoke CCTP Messasge Transmitter, which in this case performs a CPI call to the CCTP
// Token Messenger Minter program to mint tokens.
message_transmitter_program::cpi::receive_token_messenger_minter_message(cctp_ctx, args)?;
// Done.
Ok(())
}
/// Method to reconcile a CCTP message with a Wormhole VAA encoding the Wormhole CCTP deposit. After
/// reconciliation, the method invokes the CCTP Message Transmitter to mint the local tokens to the
/// provided token account in the account context. This method returns a zero-copy [VaaAccount]
/// reader so an integrator can verify emitter information.
///
/// This method reconciles both messages by making sure the source domain, destination domain and
/// nonce match.
///
/// NOTE: In order to return a zero-copy [VaaAccount] reader, this method takes a reference to the
/// [AccountInfo] of the VAA account.
pub fn verify_vaa_and_mint<'ctx, 'info>(
vaa: &'ctx AccountInfo<'info>,
cctp_ctx: CpiContext<
'_,
'_,
'_,
'info,
message_transmitter_program::cpi::ReceiveTokenMessengerMinterMessage<'info>,
>,
args: message_transmitter_program::cpi::ReceiveMessageArgs,
) -> Result<VaaAccount<'ctx>> {
// This is a very important check. We need to make sure that the VAA account is owned by the
// Wormhole Core Bridge program. Otherwise, an attacker can create a fake VAA account.
require_keys_eq!(
*vaa.owner,
crate::wormhole::core_bridge_program::id(),
ErrorCode::ConstraintOwner
);
let vaa = VaaAccount::load(vaa)?;
verify_vaa_and_mint_unchecked(&vaa, cctp_ctx, args)?;
// Finally return the VAA account reader.
Ok(vaa)
}

View File

@ -0,0 +1,28 @@
//! Errors for the Wormhole CCTP module.
//!
//! NOTE: These error values span from 0xffff0000 to 0xffff00ff so as to not collide with an
//! integrator's errors in his program.
#[anchor_lang::error_code(offset = 0)]
pub enum WormholeCctpError {
#[msg("Cannot parse VAA payload as Wormhole CCTP message")]
CannotParseMessage = 0xffff0001,
#[msg("Cannot parse encoded CCTP message")]
InvalidCctpMessage = 0xffff0002,
#[msg("Not a Wormhole CCTP deposit message")]
InvalidDepositMessage = 0xffff0003,
#[msg("Source CCTP domain mismatch")]
SourceCctpDomainMismatch = 0xffff0010,
#[msg("Destination CCTP domain mismatch")]
DestinationCctpDomainMismatch = 0xffff0011,
#[msg("CCTP nonce mismatch")]
CctpNonceMismatch = 0xffff0012,
#[msg("Encoded mint recipient does not match mint recipient token account")]
InvalidMintRecipient = 0xffff0014,
}

View File

@ -0,0 +1,17 @@
#![doc = include_str!("../README.md")]
#![allow(clippy::result_large_err)]
pub mod cctp;
#[cfg(feature = "cpi")]
pub mod cpi;
pub mod error;
pub use wormhole_io as io;
pub mod messages;
pub mod utils;
pub mod wormhole;

View File

@ -0,0 +1,186 @@
//! Messages relevant to the Token Bridge across all networks. These messages are serialized and
//! then published via the Core Bridge program.
use std::io;
use ruint::aliases::U256;
use wormhole_io::{Readable, TypePrefixedPayload, Writeable};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Deposit {
pub token_address: [u8; 32],
pub amount: U256,
pub source_cctp_domain: u32,
pub destination_cctp_domain: u32,
pub cctp_nonce: u64,
pub burn_source: [u8; 32],
pub mint_recipient: [u8; 32],
/// NOTE: This payload length is encoded as u16.
pub payload: Vec<u8>,
}
impl TypePrefixedPayload for Deposit {
const TYPE: Option<u8> = Some(1);
}
impl Readable for Deposit {
const SIZE: Option<usize> = None;
fn read<R>(reader: &mut R) -> io::Result<Self>
where
Self: Sized,
R: io::Read,
{
let token_address = Readable::read(reader)?;
let amount = <[u8; 32]>::read(reader).map(U256::from_be_bytes)?;
let source_cctp_domain = Readable::read(reader)?;
let destination_cctp_domain = Readable::read(reader)?;
let cctp_nonce = Readable::read(reader)?;
let burn_source = Readable::read(reader)?;
let mint_recipient = Readable::read(reader)?;
let payload_len = u16::read(reader).map(usize::from)?;
let mut payload = vec![0u8; payload_len];
reader.read_exact(&mut payload)?;
Ok(Self {
token_address,
amount,
source_cctp_domain,
destination_cctp_domain,
cctp_nonce,
burn_source,
mint_recipient,
payload,
})
}
}
impl Writeable for Deposit {
fn written_size(&self) -> usize {
32 + 32 + 4 + 4 + 8 + 32 + 32 + 2 + self.payload.len()
}
fn write<W>(&self, writer: &mut W) -> std::io::Result<()>
where
Self: Sized,
W: std::io::Write,
{
self.token_address.write(writer)?;
self.amount.to_be_bytes::<32>().write(writer)?;
self.source_cctp_domain.write(writer)?;
self.destination_cctp_domain.write(writer)?;
self.cctp_nonce.write(writer)?;
self.burn_source.write(writer)?;
self.mint_recipient.write(writer)?;
u16::try_from(self.payload.len())
.map_err(|_| std::io::ErrorKind::InvalidData.into())
.and_then(|len| len.write(writer))?;
writer.write_all(&self.payload)?;
Ok(())
}
}
#[cfg(test)]
mod test {
use hex_literal::hex;
use wormhole_io::WriteableBytes;
use wormhole_raw_vaas::cctp;
use super::*;
#[derive(Debug, Clone, PartialEq, Eq)]
struct AllYourBase {
pub are: u16,
pub belong: u32,
pub to: u64,
pub us: WriteableBytes,
}
impl TypePrefixedPayload for AllYourBase {
const TYPE: Option<u8> = Some(69);
}
impl Readable for AllYourBase {
const SIZE: Option<usize> = None;
fn read<R>(reader: &mut R) -> io::Result<Self>
where
Self: Sized,
R: io::Read,
{
Ok(Self {
are: Readable::read(reader)?,
belong: Readable::read(reader)?,
to: Readable::read(reader)?,
us: Readable::read(reader)?,
})
}
}
impl Writeable for AllYourBase {
fn written_size(&self) -> usize {
2 + 4 + 8 + self.us.written_size()
}
fn write<W>(&self, writer: &mut W) -> std::io::Result<()>
where
Self: Sized,
W: std::io::Write,
{
self.are.write(writer)?;
self.belong.write(writer)?;
self.to.write(writer)?;
self.us.write(writer)?;
Ok(())
}
}
#[test]
fn serde() {
let payload = AllYourBase {
are: 42,
belong: 1337,
to: 9001,
us: b"Beep boop".to_vec().into(),
};
let deposit = Deposit {
token_address: hex!("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
amount: U256::from(69420u64),
source_cctp_domain: 5,
destination_cctp_domain: 1,
cctp_nonce: 69,
burn_source: hex!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
mint_recipient: hex!(
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
),
payload: payload.to_vec_payload(),
};
let encoded = deposit.to_vec_payload();
let msg = cctp::WormholeCctpMessage::parse(&encoded).unwrap();
let parsed = msg.deposit().unwrap();
let expected = Deposit {
token_address: parsed.token_address(),
amount: U256::from_be_bytes(parsed.amount()),
source_cctp_domain: parsed.source_cctp_domain(),
destination_cctp_domain: parsed.destination_cctp_domain(),
cctp_nonce: parsed.cctp_nonce(),
burn_source: parsed.burn_source(),
mint_recipient: parsed.mint_recipient(),
payload: payload.to_vec_payload(),
};
assert_eq!(deposit, expected);
// Check for other encoded parameters.
assert_eq!(
usize::from(parsed.payload_len()),
payload.payload_written_size()
);
// TODO: Recover by calling read_payload.
}
}

View File

@ -0,0 +1,2 @@
mod deposit;
pub use deposit::*;

View File

@ -0,0 +1,99 @@
use anchor_lang::{prelude::*, Discriminator};
/// Wrapper for external account schemas, where an Anchor [Discriminator] and [Owner] are defined.
#[derive(Debug, AnchorSerialize, AnchorDeserialize, Clone)]
pub struct ExternalAccount<T>(T)
where
T: AnchorSerialize + AnchorDeserialize + Clone + Discriminator + Owner;
impl<T> AccountDeserialize for ExternalAccount<T>
where
T: AnchorSerialize + AnchorDeserialize + Clone + Discriminator + Owner,
{
fn try_deserialize(buf: &mut &[u8]) -> Result<Self> {
require!(buf.len() >= 8, ErrorCode::AccountDidNotDeserialize);
require!(
buf[..8] == T::DISCRIMINATOR,
ErrorCode::AccountDiscriminatorMismatch,
);
Self::try_deserialize_unchecked(buf)
}
fn try_deserialize_unchecked(buf: &mut &[u8]) -> Result<Self> {
Ok(Self(T::deserialize(&mut &buf[8..])?))
}
}
impl<T> AccountSerialize for ExternalAccount<T> where
T: AnchorSerialize + AnchorDeserialize + Clone + Discriminator + Owner
{
}
impl<T> Owner for ExternalAccount<T>
where
T: AnchorSerialize + AnchorDeserialize + Clone + Discriminator + Owner,
{
fn owner() -> Pubkey {
T::owner()
}
}
impl<T> std::ops::Deref for ExternalAccount<T>
where
T: AnchorSerialize + AnchorDeserialize + Clone + Discriminator + Owner,
{
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
/// Wrapper for external account schemas, where an Anchor [Discriminator] and [Owner] are defined.
#[derive(Debug, AnchorSerialize, AnchorDeserialize, Clone)]
pub struct BoxedExternalAccount<T>(Box<T>)
where
T: AnchorSerialize + AnchorDeserialize + Clone + Discriminator + Owner;
impl<T> AccountDeserialize for BoxedExternalAccount<T>
where
T: AnchorSerialize + AnchorDeserialize + Clone + Discriminator + Owner,
{
fn try_deserialize(buf: &mut &[u8]) -> Result<Self> {
require!(buf.len() >= 8, ErrorCode::AccountDidNotDeserialize);
require!(
buf[..8] == T::DISCRIMINATOR,
ErrorCode::AccountDiscriminatorMismatch,
);
Self::try_deserialize_unchecked(buf)
}
fn try_deserialize_unchecked(buf: &mut &[u8]) -> Result<Self> {
Ok(Self(Box::new(T::deserialize(&mut &buf[8..])?)))
}
}
impl<T> AccountSerialize for BoxedExternalAccount<T> where
T: AnchorSerialize + AnchorDeserialize + Clone + Discriminator + Owner
{
}
impl<T> Owner for BoxedExternalAccount<T>
where
T: AnchorSerialize + AnchorDeserialize + Clone + Discriminator + Owner,
{
fn owner() -> Pubkey {
T::owner()
}
}
impl<T> std::ops::Deref for BoxedExternalAccount<T>
where
T: AnchorSerialize + AnchorDeserialize + Clone + Discriminator + Owner,
{
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}

View File

@ -0,0 +1,5 @@
mod accounts;
pub use accounts::*;
mod zero_copy;
pub use zero_copy::*;

View File

@ -0,0 +1,55 @@
/// Circle Message generated by the Message Transmitter program.
///
/// See https://developers.circle.com/stablecoins/docs/message-format for more info.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
pub struct CctpMessage<'a> {
span: &'a [u8],
}
impl AsRef<[u8]> for CctpMessage<'_> {
fn as_ref(&self) -> &[u8] {
self.span
}
}
impl<'a> CctpMessage<'a> {
pub fn version(&self) -> u32 {
u32::from_be_bytes(self.span[..4].try_into().unwrap())
}
pub fn source_domain(&self) -> u32 {
u32::from_be_bytes(self.span[4..8].try_into().unwrap())
}
pub fn destination_domain(&self) -> u32 {
u32::from_be_bytes(self.span[8..12].try_into().unwrap())
}
pub fn nonce(&self) -> u64 {
u64::from_be_bytes(self.span[12..20].try_into().unwrap())
}
pub fn sender(&self) -> [u8; 32] {
self.span[20..52].try_into().unwrap()
}
pub fn recipient(&self) -> [u8; 32] {
self.span[52..84].try_into().unwrap()
}
pub fn destination_caller(&self) -> [u8; 32] {
self.span[84..116].try_into().unwrap()
}
pub fn message(&self) -> &[u8] {
&self.span[116..]
}
pub fn parse(span: &'a [u8]) -> Result<CctpMessage<'a>, &'static str> {
if span.len() < 116 {
return Err("CctpMessage span too short. Need at least 116 bytes");
}
Ok(CctpMessage { span })
}
}

View File

@ -0,0 +1,4 @@
pub use wormhole_raw_vaas::cctp::{Deposit, WormholeCctpMessage, WormholeCctpPayload};
mod cctp;
pub use cctp::*;

View File

@ -0,0 +1,2 @@
mod post_message;
pub use post_message::*;

View File

@ -0,0 +1,82 @@
use crate::wormhole::core_bridge_program::{state::Config, Commitment};
use anchor_lang::{
prelude::{borsh::BorshSerialize, *},
system_program,
};
#[derive(Accounts)]
pub struct PostMessage<'info> {
#[account(mut)]
pub config: AccountInfo<'info>,
#[account(mut, signer)]
pub message: AccountInfo<'info>,
#[account(signer)]
pub emitter: AccountInfo<'info>,
#[account(mut)]
pub emitter_sequence: AccountInfo<'info>,
#[account(mut, signer)]
pub payer: AccountInfo<'info>,
#[account(mut)]
pub fee_collector: AccountInfo<'info>,
pub clock: AccountInfo<'info>,
pub system_program: AccountInfo<'info>,
pub rent: AccountInfo<'info>,
}
#[derive(Debug, AnchorSerialize, AnchorDeserialize, Clone)]
pub struct PostMessageArgs {
/// Unique id for this message.
pub nonce: u32,
/// Encoded message.
pub payload: Vec<u8>,
/// Solana commitment level for Guardian observation.
pub commitment: Commitment,
}
/// Processor to post (publish) a Wormhole message by setting up the message account for
/// Guardian observation.
///
/// A message is either created beforehand using the new Anchor instruction to process a message
/// or is created at this point.
pub fn post_message<'info>(
ctx: CpiContext<'_, '_, '_, 'info, PostMessage<'info>>,
args: PostMessageArgs,
) -> Result<()> {
// Pay Wormhole message fee.
{
let mut data: &[_] = &ctx.accounts.config.try_borrow_data()?;
let Config { fee_lamports, .. } = Config::deserialize(&mut data)?;
system_program::transfer(
CpiContext::new(
ctx.accounts.system_program.to_account_info(),
system_program::Transfer {
from: ctx.accounts.payer.to_account_info(),
to: ctx.accounts.fee_collector.to_account_info(),
},
),
fee_lamports,
)?;
}
const IX_SELECTOR: u8 = 1;
solana_program::program::invoke_signed(
&solana_program::instruction::Instruction {
program_id: crate::wormhole::core_bridge_program::id(),
accounts: ctx.to_account_metas(None),
data: (IX_SELECTOR, args).try_to_vec()?,
},
&ctx.to_account_infos(),
ctx.signer_seeds,
)
.map_err(Into::into)
}

View File

@ -0,0 +1,41 @@
#[cfg(feature = "cpi")]
pub mod cpi;
pub mod state;
pub mod vaa;
pub use vaa::VaaAccount;
use anchor_lang::prelude::*;
pub const SOLANA_CHAIN: u16 = 1;
cfg_if::cfg_if! {
if #[cfg(feature = "localnet")] {
declare_id!("Bridge1p5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o");
} else if #[cfg(feature = "mainnet")] {
declare_id!("worm2ZoG2kUd4vFXhvjh93UUH596ayRfgQ2MgjNMTth");
} else if #[cfg(feature = "testnet")] {
declare_id!("3u8hJUVTA4jH1wYAyUur7FFZVQ8H635K3tSHHF4ssjQ5");
}
}
pub struct CoreBridge;
impl Id for CoreBridge {
fn id() -> Pubkey {
ID
}
}
/// Representation of Solana's commitment levels. This enum is not exhaustive because Wormhole only
/// considers these two commitment levels in its Guardian observation.
///
/// See <https://docs.solana.com/cluster/commitments> for more info.
#[derive(Copy, Debug, AnchorSerialize, AnchorDeserialize, Clone, PartialEq, Eq)]
pub enum Commitment {
/// One confirmation.
Confirmed,
/// 32 confirmations.
Finalized,
}

View File

@ -0,0 +1,22 @@
use anchor_lang::prelude::*;
/// Account used to store the current configuration of the bridge, including tracking Wormhole fee
/// payments. For governance decrees, the guardian set index is used to determine whether a decree
/// was attested for using the latest guardian set.
#[derive(Debug, AnchorSerialize, AnchorDeserialize, Clone, PartialEq, Eq, InitSpace)]
pub struct Config {
/// The current guardian set index, used to decide which signature sets to accept.
pub guardian_set_index: u32,
/// Gap. In the old implementation, this was an amount that kept track of message fees that
/// were paid to the program's fee collector.
pub _gap_0: [u8; 8],
/// Period for how long a guardian set is valid after it has been replaced by a new one. This
/// guarantees that VAAs issued by that set can still be submitted for a certain period. In
/// this period we still trust the old guardian set.
pub guardian_set_ttl: u32,
/// Amount of lamports that needs to be paid to the protocol to post a message
pub fee_lamports: u64,
}

View File

@ -0,0 +1,2 @@
mod config;
pub use config::*;

View File

@ -0,0 +1,2 @@
mod zero_copy;
pub use zero_copy::*;

View File

@ -0,0 +1,81 @@
mod posted_vaa_v1;
pub use posted_vaa_v1::*;
use anchor_lang::prelude::*;
use wormhole_raw_vaas::Payload;
#[non_exhaustive]
pub enum VaaAccount<'a> {
PostedVaaV1(PostedVaaV1<'a>),
}
#[derive(Debug, AnchorSerialize, AnchorDeserialize, Copy, Clone)]
pub struct EmitterInfo {
pub chain: u16,
pub address: [u8; 32],
pub sequence: u64,
}
impl<'a> VaaAccount<'a> {
pub fn version(&'a self) -> u8 {
match self {
Self::PostedVaaV1(_) => 1,
}
}
pub fn try_emitter_info(&self) -> Result<EmitterInfo> {
match self {
Self::PostedVaaV1(inner) => Ok(EmitterInfo {
chain: inner.emitter_chain(),
address: inner.emitter_address(),
sequence: inner.sequence(),
}),
}
}
pub fn try_emitter_chain(&self) -> Result<u16> {
match self {
Self::PostedVaaV1(inner) => Ok(inner.emitter_chain()),
}
}
pub fn try_emitter_address(&self) -> Result<[u8; 32]> {
match self {
Self::PostedVaaV1(inner) => Ok(inner.emitter_address()),
}
}
pub fn try_timestamp(&self) -> Result<u32> {
match self {
Self::PostedVaaV1(inner) => Ok(inner.timestamp()),
}
}
pub fn try_payload(&self) -> Result<Payload> {
match self {
Self::PostedVaaV1(inner) => Ok(Payload::parse(inner.payload())),
}
}
pub fn try_digest(&self) -> Result<solana_program::keccak::Hash> {
match self {
Self::PostedVaaV1(inner) => Ok(inner.digest()),
}
}
pub fn posted_vaa_v1(&'a self) -> Option<&'a PostedVaaV1<'a>> {
match self {
Self::PostedVaaV1(inner) => Some(inner),
}
}
pub fn load(acc_info: &'a AccountInfo) -> Result<Self> {
let data = acc_info.try_borrow_data()?;
require!(data.len() > 8, ErrorCode::AccountDidNotDeserialize);
match <[u8; 8]>::try_from(&data[..8]).unwrap() {
[118, 97, 97, 1, _, _, _, _] => Ok(Self::PostedVaaV1(PostedVaaV1::new(acc_info)?)),
_ => err!(ErrorCode::AccountDidNotDeserialize),
}
}
}

View File

@ -0,0 +1,115 @@
use std::cell::Ref;
use anchor_lang::{
prelude::{
error, require, require_eq, require_keys_eq, AccountInfo, ErrorCode, Pubkey, Result,
},
solana_program::keccak,
};
pub const POSTED_VAA_V1_SEED_PREFIX: &[u8] = b"PostedVAA";
const PAYLOAD_START: usize = 95;
/// Account used to store a verified VAA.
pub struct PostedVaaV1<'a>(Ref<'a, &'a mut [u8]>);
impl<'a> PostedVaaV1<'a> {
/// Level of consistency requested by the emitter.
pub fn consistency_level(&self) -> u8 {
self.0[4]
}
/// Time the message was submitted.
pub fn timestamp(&self) -> u32 {
u32::from_le_bytes(self.0[5..9].try_into().unwrap())
}
#[cfg(feature = "no-entrypoint")]
/// Pubkey of `SignatureSet` account that represent this VAA's signature verification.
pub fn signature_set(&self) -> Pubkey {
Pubkey::try_from(&self.0[9..41]).unwrap()
}
/// Guardian set index used to verify signatures for `SignatureSet`.
///
/// NOTE: In the previous implementation, this member was referred to as the `posted_timestamp`,
/// which is zero for VAA data (posted messages and VAAs resemble the same account schema). By
/// changing this to the guardian set index, we patch a bug with verifying governance VAAs for
/// the Core Bridge (other Core Bridge implementations require that the guardian set that
/// attested for the governance VAA is the current one).
pub fn guardian_set_index(&self) -> u32 {
u32::from_le_bytes(self.0[41..45].try_into().unwrap())
}
/// Unique ID for this message.
pub fn nonce(&self) -> u32 {
u32::from_le_bytes(self.0[45..49].try_into().unwrap())
}
/// Sequence number of this message.
pub fn sequence(&self) -> u64 {
u64::from_le_bytes(self.0[49..57].try_into().unwrap())
}
/// The Wormhole chain ID denoting the origin of this message.
pub fn emitter_chain(&self) -> u16 {
u16::from_le_bytes(self.0[57..59].try_into().unwrap())
}
/// Emitter of the message.
pub fn emitter_address(&self) -> [u8; 32] {
self.0[59..91].try_into().unwrap()
}
pub fn payload_size(&self) -> usize {
u32::from_le_bytes(self.0[91..PAYLOAD_START].try_into().unwrap())
.try_into()
.unwrap()
}
/// Message payload.
pub fn payload(&self) -> &[u8] {
&self.0[PAYLOAD_START..]
}
/// Recompute the message hash, which is used derive the [PostedVaaV1] PDA address.
pub fn message_hash(&self) -> keccak::Hash {
keccak::hashv(&[
self.timestamp().to_be_bytes().as_ref(),
self.nonce().to_be_bytes().as_ref(),
self.emitter_chain().to_be_bytes().as_ref(),
&self.emitter_address(),
&self.sequence().to_be_bytes(),
&[self.consistency_level()],
self.payload(),
])
}
/// Compute digest (hash of [message_hash](Self::message_hash)).
pub fn digest(&self) -> keccak::Hash {
keccak::hash(self.message_hash().as_ref())
}
pub(super) fn new(acc_info: &'a AccountInfo) -> Result<Self> {
let parsed = Self(acc_info.try_borrow_data()?);
require!(
parsed.0.len() >= PAYLOAD_START,
ErrorCode::AccountDidNotDeserialize
);
require_eq!(
parsed.0.len(),
PAYLOAD_START + parsed.payload_size(),
ErrorCode::AccountDidNotDeserialize
);
// Recompute message hash to re-derive PDA address.
let (expected_address, _) = Pubkey::find_program_address(
&[POSTED_VAA_V1_SEED_PREFIX, parsed.message_hash().as_ref()],
&crate::wormhole::core_bridge_program::id(),
);
require_keys_eq!(*acc_info.key, expected_address, ErrorCode::ConstraintSeeds);
Ok(parsed)
}
}

View File

@ -0,0 +1 @@
pub mod core_bridge_program;

6242
solana/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
solana/package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "wormhole-cctp-solana",
"version": "0.1.0",
"description": "Wormhole Circle Integration",
"repository": {
"type": "git",
"url": "git+https://github.com/wormhole-foundation/wormhole-circle-integration.git"
},
"author": "Wormhole Contributors",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/wormhole-foundation/wormhole-circle-integration/issues"
},
"homepage": "https://github.com/wormhole-foundation/wormhole-circle-integration#readme",
"devDependencies": {
"@certusone/wormhole-sdk": "^0.10.10",
"@coral-xyz/anchor": "^0.29.0",
"@solana/spl-token": "^0.3.8",
"@solana/web3.js": "^1.87.3",
"@types/chai": "^4.3.9",
"@types/mocha": "^10.0.3",
"chai": "^4.3.10",
"dotenv": "^16.3.1",
"ethers": "^5.7.2",
"mocha": "^10.2.0",
"prettier": "^3.0.3",
"ts-mocha": "^10.0.0",
"ts-node": "^10.9.1",
"ts-results": "^3.3.0",
"typescript": "^5.2.2"
}
}

View File

@ -0,0 +1,38 @@
[package]
name = "wormhole-circle-integration-solana"
description = "Wormhole Circle Integration Program for Solana"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
[lib]
crate-type = ["cdylib", "lib"]
[features]
default = ["mainnet", "no-idl", "cpi"]
no-entrypoint = []
no-idl = []
no-log-ix-name = []
cpi = ["no-entrypoint"]
testnet = ["wormhole-cctp-solana/testnet"]
mainnet = ["wormhole-cctp-solana/mainnet"]
integration-test = ["mainnet"]
[dependencies]
wormhole-cctp-solana = { workspace = true, features = ["cpi"] }
wormhole-raw-vaas.workspace = true
anchor-lang.workspace = true
anchor-spl.workspace = true
solana-program.workspace = true
hex.workspace = true
ruint.workspace = true
cfg-if.workspace = true
[dev-dependencies]
hex-literal.workspace = true

View File

@ -0,0 +1,17 @@
//! Constants used by the Wormhole Circle Integration Program.
use anchor_lang::prelude::constant;
/// Seed for upgrade authority.
#[constant]
pub const UPGRADE_SEED_PREFIX: &[u8] = b"upgrade";
/// Seed for custody token account.
#[constant]
pub const CUSTODY_TOKEN_SEED_PREFIX: &[u8] = b"custody";
pub(crate) const GOVERNANCE_CHAIN: u16 = 1;
pub(crate) const GOVERNANCE_EMITTER: [u8; 32] = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4,
];

View File

@ -0,0 +1,32 @@
//! Errors that may arise when interacting with the Wormhole Circle Integration Program.
//!
#[anchor_lang::prelude::error_code]
pub enum CircleIntegrationError {
#[msg("InvalidGovernanceEmitter")]
InvalidGovernanceEmitter = 0x2,
#[msg("InvalidGovernanceVaa")]
InvalidGovernanceVaa = 0x4,
#[msg("InvalidGovernanceAction")]
InvalidGovernanceAction = 0x6,
#[msg("GovernanceForAnotherChain")]
GovernanceForAnotherChain = 0x8,
#[msg("ImplementationMismatch")]
ImplementationMismatch = 0x20,
#[msg("InvalidForeignChain")]
InvalidForeignChain = 0x40,
#[msg("InvalidForeignEmitter")]
InvalidForeignEmitter = 0x42,
#[msg("InvalidCctpDomain")]
InvalidCctpDomain = 0x44,
#[msg("UnknownEmitter")]
UnknownEmitter = 0x102,
}

View File

@ -0,0 +1,56 @@
#![doc = include_str!("../README.md")]
#![allow(clippy::result_large_err)]
use anchor_lang::prelude::*;
cfg_if::cfg_if! {
if #[cfg(feature = "mainnet")] {
// Placeholder for real address
declare_id!("Wormho1eCirc1e1ntegration111111111111111111");
} else if #[cfg(feature = "testnet")] {
declare_id!("wcihrWf1s91vfukW7LW8ZvR1rzpeZ9BrtZ8oyPkWK5d");
}
}
pub mod constants;
pub mod error;
mod processor;
pub(crate) use processor::*;
pub use processor::{RedeemTokensWithPayloadArgs, TransferTokensWithPayloadArgs};
pub mod state;
#[program]
pub mod wormhole_circle_integration_solana {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
processor::initialize(ctx)
}
pub fn transfer_tokens_with_payload(
ctx: Context<TransferTokensWithPayload>,
args: TransferTokensWithPayloadArgs,
) -> Result<()> {
processor::transfer_tokens_with_payload(ctx, args)
}
pub fn redeem_tokens_with_payload(
ctx: Context<RedeemTokensWithPayload>,
args: RedeemTokensWithPayloadArgs,
) -> Result<()> {
processor::redeem_tokens_with_payload(ctx, args)
}
// Governance
pub fn register_emitter_and_domain(ctx: Context<RegisterEmitterAndDomain>) -> Result<()> {
processor::register_emitter_and_domain(ctx)
}
pub fn upgrade_contract(ctx: Context<UpgradeContract>) -> Result<()> {
processor::upgrade_contract(ctx)
}
}

View File

@ -0,0 +1,27 @@
mod register_emitter_and_domain;
pub use register_emitter_and_domain::*;
mod upgrade_contract;
pub use upgrade_contract::*;
use crate::error::CircleIntegrationError;
use anchor_lang::prelude::*;
use wormhole_cctp_solana::wormhole::core_bridge_program;
use wormhole_raw_vaas::cctp::{CircleIntegrationDecree, CircleIntegrationGovPayload};
pub fn require_valid_governance_vaa<'ctx>(
vaa: &'ctx core_bridge_program::VaaAccount<'ctx>,
) -> Result<CircleIntegrationDecree<'ctx>> {
let emitter = vaa.try_emitter_info()?;
require!(
emitter.chain == crate::constants::GOVERNANCE_CHAIN
&& emitter.address == crate::constants::GOVERNANCE_EMITTER,
CircleIntegrationError::InvalidGovernanceEmitter
);
// Because emitter_chain and emitter_address getters have succeeded, we can safely unwrap this
// payload call.
CircleIntegrationGovPayload::try_from(vaa.try_payload().unwrap())
.map(|msg| msg.decree())
.map_err(|_| error!(CircleIntegrationError::InvalidGovernanceVaa))
}

View File

@ -0,0 +1,146 @@
use crate::{
error::CircleIntegrationError,
state::{ConsumedVaa, Custodian, RegisteredEmitter},
};
use anchor_lang::prelude::*;
use wormhole_cctp_solana::{
cctp::token_messenger_minter_program,
utils::ExternalAccount,
wormhole::core_bridge_program::{self, VaaAccount},
};
use wormhole_raw_vaas::cctp::CircleIntegrationGovPayload;
#[derive(Accounts)]
pub struct RegisterEmitterAndDomain<'info> {
#[account(mut)]
payer: Signer<'info>,
#[account(
seeds = [Custodian::SEED_PREFIX],
bump = custodian.bump,
)]
custodian: Account<'info, Custodian>,
/// CHECK: We will be performing zero-copy deserialization in the instruction handler.
#[account(owner = core_bridge_program::id())]
vaa: AccountInfo<'info>,
#[account(
init,
payer = payer,
space = 8 + RegisteredEmitter::INIT_SPACE,
seeds = [
RegisteredEmitter::SEED_PREFIX,
try_decree(&vaa, |decree| decree.foreign_chain())?.to_be_bytes().as_ref(),
],
bump,
)]
registered_emitter: Account<'info, RegisteredEmitter>,
#[account(
init,
payer = payer,
space = 8 + ConsumedVaa::INIT_SPACE,
seeds = [
ConsumedVaa::SEED_PREFIX,
VaaAccount::load(&vaa)?.try_digest()?.as_ref(),
],
bump,
)]
consumed_vaa: Account<'info, ConsumedVaa>,
#[account(
seeds = [
token_messenger_minter_program::RemoteTokenMessenger::SEED_PREFIX,
try_decree(&vaa, |decree| decree.cctp_domain())?.to_string().as_ref(),
],
bump,
seeds::program = token_messenger_minter_program::id(),
)]
remote_token_messenger:
Account<'info, ExternalAccount<token_messenger_minter_program::RemoteTokenMessenger>>,
system_program: Program<'info, System>,
}
#[access_control(handle_access_control(&ctx))]
pub fn register_emitter_and_domain(ctx: Context<RegisterEmitterAndDomain>) -> Result<()> {
ctx.accounts.consumed_vaa.set_inner(ConsumedVaa {
bump: ctx.bumps.consumed_vaa,
});
let vaa = core_bridge_program::VaaAccount::load(&ctx.accounts.vaa).unwrap();
let registration = CircleIntegrationGovPayload::try_from(vaa.try_payload().unwrap())
.unwrap()
.decree()
.to_register_emitter_and_domain_unchecked();
ctx.accounts
.registered_emitter
.set_inner(RegisteredEmitter {
bump: ctx.bumps.registered_emitter,
cctp_domain: registration.cctp_domain(),
chain: registration.foreign_chain(),
address: registration.foreign_emitter(),
});
// Done.
Ok(())
}
fn try_decree<F, T>(vaa_acc_info: &AccountInfo, func: F) -> Result<T>
where
T: std::fmt::Debug,
F: FnOnce(&wormhole_raw_vaas::cctp::RegisterEmitterAndDomain) -> T,
{
let vaa = core_bridge_program::VaaAccount::load(vaa_acc_info)?;
let payload = vaa.try_payload()?;
let gov_payload = CircleIntegrationGovPayload::parse(payload.as_ref())
.map_err(|_| error!(CircleIntegrationError::InvalidGovernanceVaa))?;
gov_payload
.decree()
.register_emitter_and_domain()
.map(func)
.ok_or(error!(CircleIntegrationError::InvalidGovernanceAction))
}
fn handle_access_control(ctx: &Context<RegisterEmitterAndDomain>) -> Result<()> {
let vaa = core_bridge_program::VaaAccount::load(&ctx.accounts.vaa)?;
let gov_payload = crate::processor::require_valid_governance_vaa(&vaa)?;
let registration = gov_payload
.register_emitter_and_domain()
.ok_or(error!(CircleIntegrationError::InvalidGovernanceAction))?;
// Registration is either for this chain (Solana) or for all chains (encoded as zero).
let decree_chain = registration.chain();
require!(
decree_chain == 0 || decree_chain == core_bridge_program::SOLANA_CHAIN,
CircleIntegrationError::GovernanceForAnotherChain
);
// Foreign chain and emitter address cannot be zero or Solana's.
let foreign_chain = registration.foreign_chain();
require!(
foreign_chain != 0 && foreign_chain != core_bridge_program::SOLANA_CHAIN,
CircleIntegrationError::InvalidForeignChain
);
require!(
registration.foreign_emitter() != [0; 32],
CircleIntegrationError::InvalidForeignEmitter
);
// CCTP domain must equal the one in the Remote Token Messenger account.
//
// NOTE: This statement should always pass. But we keep this check just in case the owner
// of the Token Messenger Minter program misconfigured the Remote Token Messenger account.
require!(
registration.cctp_domain() == ctx.accounts.remote_token_messenger.domain,
CircleIntegrationError::InvalidCctpDomain
);
// Done.
Ok(())
}

View File

@ -0,0 +1,139 @@
use crate::{
constants::UPGRADE_SEED_PREFIX,
error::CircleIntegrationError,
state::{ConsumedVaa, Custodian},
};
use anchor_lang::prelude::*;
use solana_program::bpf_loader_upgradeable;
use wormhole_cctp_solana::wormhole::core_bridge_program::{self, VaaAccount};
#[derive(Accounts)]
pub struct UpgradeContract<'info> {
#[account(mut)]
payer: Signer<'info>,
#[account(
seeds = [Custodian::SEED_PREFIX],
bump = custodian.bump,
)]
custodian: Account<'info, Custodian>,
/// CHECK: Posted VAA account, which will be read via zero-copy deserialization in the
/// instruction handler, which also checks this account discriminator (so there is no need to
/// check PDA seeds here).
#[account(owner = core_bridge_program::id())]
vaa: AccountInfo<'info>,
#[account(
init,
payer = payer,
space = 8 + ConsumedVaa::INIT_SPACE,
seeds = [
ConsumedVaa::SEED_PREFIX,
VaaAccount::load(&vaa)?.try_digest()?.as_ref(),
],
bump,
)]
consumed_vaa: Account<'info, ConsumedVaa>,
/// CHECK: We need this upgrade authority to invoke the BPF Loader Upgradeable program to
/// upgrade this program's executable. We verify this PDA address here out of convenience to get
/// the PDA bump seed to invoke the upgrade.
#[account(
seeds = [UPGRADE_SEED_PREFIX],
bump = custodian.upgrade_authority_bump,
)]
upgrade_authority: AccountInfo<'info>,
/// CHECK: This account receives any lamports after the result of the upgrade.
#[account(mut)]
spill: AccountInfo<'info>,
/// CHECK: Deployed implementation. The pubkey of this account is checked in access control
/// against the one encoded in the governance VAA.
#[account(mut)]
buffer: AccountInfo<'info>,
/// CHECK: Token Bridge program data needed for BPF Loader Upgradable program.
#[account(
mut,
seeds = [crate::ID.as_ref()],
bump,
seeds::program = bpf_loader_upgradeable::id(),
)]
program_data: AccountInfo<'info>,
/// CHECK: This must equal the Token Bridge program ID for the BPF Loader Upgradeable program.
#[account(
mut,
address = crate::ID
)]
this_program: AccountInfo<'info>,
/// CHECK: BPF Loader Upgradeable program needs this sysvar.
#[account(address = solana_program::sysvar::rent::id())]
rent: AccountInfo<'info>,
/// CHECK: BPF Loader Upgradeable program needs this sysvar.
#[account(address = solana_program::sysvar::clock::id())]
clock: AccountInfo<'info>,
/// CHECK: BPF Loader Upgradeable program.
#[account(address = bpf_loader_upgradeable::id())]
bpf_loader_upgradeable_program: AccountInfo<'info>,
system_program: Program<'info, System>,
}
/// Processor for contract upgrade governance decrees. This instruction handler invokes the BPF
/// Loader Upgradeable program to upgrade this program's executable to the provided buffer.
#[access_control(handle_access_control(&ctx))]
pub fn upgrade_contract(ctx: Context<UpgradeContract>) -> Result<()> {
ctx.accounts.consumed_vaa.set_inner(ConsumedVaa {
bump: ctx.bumps.consumed_vaa,
});
// Finally upgrade.
solana_program::program::invoke_signed(
&bpf_loader_upgradeable::upgrade(
&crate::ID,
&ctx.accounts.buffer.key(),
&ctx.accounts.upgrade_authority.key(),
&ctx.accounts.spill.key(),
),
&ctx.accounts.to_account_infos(),
&[&[
UPGRADE_SEED_PREFIX,
&[ctx.accounts.custodian.upgrade_authority_bump],
]],
)
.map_err(Into::into)
}
fn handle_access_control(ctx: &Context<UpgradeContract>) -> Result<()> {
msg!("okay... {:?}", ctx.accounts.vaa.key());
let vaa = core_bridge_program::VaaAccount::load(&ctx.accounts.vaa)?;
msg!("and...");
let gov_payload = crate::processor::require_valid_governance_vaa(&vaa)?;
let upgrade = gov_payload
.contract_upgrade()
.ok_or(error!(CircleIntegrationError::InvalidGovernanceAction))?;
// Make sure that the contract upgrade is intended for this network.
require_eq!(
upgrade.chain(),
core_bridge_program::SOLANA_CHAIN,
CircleIntegrationError::GovernanceForAnotherChain
);
// Read the implementation pubkey and check against the buffer in our account context.
require_keys_eq!(
Pubkey::from(upgrade.implementation()),
ctx.accounts.buffer.key(),
CircleIntegrationError::ImplementationMismatch
);
// Done.
Ok(())
}

View File

@ -0,0 +1,72 @@
use crate::{constants::UPGRADE_SEED_PREFIX, state::Custodian};
use anchor_lang::prelude::*;
use solana_program::bpf_loader_upgradeable;
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(mut)]
deployer: Signer<'info>,
#[account(
init,
payer = deployer,
space = 8 + Custodian::INIT_SPACE,
seeds = [Custodian::SEED_PREFIX],
bump,
)]
custodian: Account<'info, Custodian>,
/// CHECK: We need this upgrade authority to invoke the BPF Loader Upgradeable program to
/// upgrade this program's executable. We verify this PDA address here out of convenience to get
/// the PDA bump seed to invoke the upgrade.
#[account(
seeds = [UPGRADE_SEED_PREFIX],
bump,
)]
upgrade_authority: AccountInfo<'info>,
/// CHECK: Wormhole Circle Integration program data needed for BPF Loader Upgradable program.
#[account(
mut,
seeds = [crate::ID.as_ref()],
bump,
seeds::program = bpf_loader_upgradeable_program,
)]
program_data: AccountInfo<'info>,
/// BPF Loader Upgradeable program.
///
/// CHECK: In order to upgrade the program, we need to invoke the BPF Loader Upgradeable
/// program.
#[account(address = bpf_loader_upgradeable::id())]
bpf_loader_upgradeable_program: AccountInfo<'info>,
system_program: Program<'info, System>,
}
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
ctx.accounts.custodian.set_inner(Custodian {
bump: ctx.bumps.custodian,
upgrade_authority_bump: ctx.bumps.upgrade_authority,
});
// Finally set the upgrade authority to this program's upgrade PDA.
#[cfg(not(feature = "integration-test"))]
{
solana_program::program::invoke_signed(
&bpf_loader_upgradeable::set_upgrade_authority_checked(
&crate::ID,
&ctx.accounts.deployer.key(),
&ctx.accounts.upgrade_authority.key(),
),
&ctx.accounts.to_account_infos(),
&[&[
UPGRADE_SEED_PREFIX,
&[ctx.accounts.custodian.upgrade_authority_bump],
]],
)?;
}
// Done.
Ok(())
}

View File

@ -0,0 +1,11 @@
mod governance;
pub use governance::*;
mod initialize;
pub use initialize::*;
mod redeem_tokens_with_payload;
pub use redeem_tokens_with_payload::*;
mod transfer_tokens_with_payload;
pub use transfer_tokens_with_payload::*;

View File

@ -0,0 +1,228 @@
use crate::{
error::CircleIntegrationError,
state::{ConsumedVaa, Custodian, RegisteredEmitter},
};
use anchor_lang::prelude::*;
use anchor_spl::token;
use wormhole_cctp_solana::{
cctp::{message_transmitter_program, token_messenger_minter_program},
cpi::ReceiveMessageArgs,
utils::ExternalAccount,
wormhole::core_bridge_program::VaaAccount,
};
/// Account context to invoke [redeem_tokens_with_payload].
#[derive(Accounts)]
pub struct RedeemTokensWithPayload<'info> {
#[account(mut)]
payer: Signer<'info>,
/// This program's Wormhole (Core Bridge) emitter authority.
///
/// CHECK: Seeds must be \["emitter"\].
#[account(
seeds = [Custodian::SEED_PREFIX],
bump = custodian.bump,
)]
custodian: Account<'info, Custodian>,
/// CHECK: Must be owned by the Wormhole Core Bridge program. This account will be read via
/// zero-copy using the [VaaAccount](core_bridge_program::sdk::VaaAccount) reader.
///
/// NOTE: The owner of this account is checked in
/// [verify_vaa_and_mint](wormhole_cctp_solana::cpi::verify_vaa_and_mint).
vaa: AccountInfo<'info>,
/// Account representing that a VAA has been consumed.
///
/// CHECK: Seeds must be [emitter_address, emitter_chain, sequence]. These seeds are checked
/// when [claim_vaa](core_bridge_program::sdk::claim_vaa) is called.
///
// NOTE: Because the message is already received at this point, this claim account may not be
// needed because there should be a "Nonce already used" error already thrown by this point. But
// this will remain here as an extra layer of protection (and will be consistent with the way
// the EVM implementation is written).
#[account(
init,
payer = payer,
space = 8 + ConsumedVaa::INIT_SPACE,
seeds = [
ConsumedVaa::SEED_PREFIX,
VaaAccount::load(&vaa)?.try_digest()?.as_ref(),
],
bump,
)]
consumed_vaa: Account<'info, ConsumedVaa>,
/// Redeemer, who owns the token account that will receive the minted tokens.
///
/// CHECK: Signer who must be the owner of the `mint_recipient` token account.
mint_recipient_authority: Signer<'info>,
/// Mint recipient token account, which is encoded as the mint recipient in the CCTP message.
/// The CCTP Token Messenger Minter program will transfer the amount encoded in the CCTP message
/// from its custody account to this account.
///
/// NOTE: This account must be owned by the `mint_recipient_authority`.
#[account(
mut,
token::mint = local_token.mint,
token::authority = mint_recipient_authority,
)]
mint_recipient: Account<'info, token::TokenAccount>,
/// Registered emitter account representing a Circle Integration on another network.
///
/// Seeds must be \["registered_emitter", target_chain.to_be_bytes()\].
#[account(
seeds = [
RegisteredEmitter::SEED_PREFIX,
registered_emitter.chain.to_be_bytes().as_ref(),
],
bump = registered_emitter.bump,
)]
registered_emitter: Account<'info, RegisteredEmitter>,
/// CHECK: Seeds must be \["message_transmitter_authority"\] (CCTP Message Transmitter program).
message_transmitter_authority: UncheckedAccount<'info>,
/// CHECK: Seeds must be \["message_transmitter"\] (CCTP Message Transmitter program).
message_transmitter_config: UncheckedAccount<'info>,
/// CHECK: Mutable. Seeds must be \["used_nonces", remote_domain.to_string(),
/// first_nonce.to_string()\] (CCTP Message Transmitter program).
#[account(mut)]
used_nonces: UncheckedAccount<'info>,
/// CHECK: Seeds must be \["__event_authority"\] (CCTP Message Transmitter program).
message_transmitter_event_authority: UncheckedAccount<'info>,
/// CHECK: Seeds must be \["token_messenger"\] (CCTP Token Messenger Minter program).
token_messenger: UncheckedAccount<'info>,
/// CHECK: Seeds must be \["remote_token_messenger"\, remote_domain.to_string()] (CCTP Token
/// Messenger Minter program).
remote_token_messenger: UncheckedAccount<'info>,
/// CHECK: Seeds must be \["token_minter"\] (CCTP Token Messenger Minter program).
token_minter: UncheckedAccount<'info>,
/// Token Messenger Minter's Local Token account. This program uses the mint of this account to
/// validate the `mint_recipient` token account's mint.
///
/// Mutable. Seeds must be \["local_token", mint\] (CCTP Token Messenger Minter program).
#[account(
mut,
seeds = [
token_messenger_minter_program::LocalToken::SEED_PREFIX,
local_token.mint.as_ref(),
],
bump = local_token.bump,
seeds::program = token_messenger_minter_program,
)]
local_token: Account<'info, ExternalAccount<token_messenger_minter_program::LocalToken>>,
/// CHECK: Seeds must be \["token_pair", remote_domain.to_string(), remote_token_address\] (CCTP
/// Token Messenger Minter program).
token_pair: UncheckedAccount<'info>,
/// CHECK: Mutable. Seeds must be \["custody", mint\] (CCTP Token Messenger Minter program).
#[account(mut)]
token_messenger_minter_custody_token: UncheckedAccount<'info>,
/// CHECK: Seeds must be \["__event_authority"\] (CCTP Token Messenger Minter program).
token_messenger_minter_event_authority: UncheckedAccount<'info>,
token_messenger_minter_program:
Program<'info, token_messenger_minter_program::TokenMessengerMinter>,
message_transmitter_program: Program<'info, message_transmitter_program::MessageTransmitter>,
token_program: Program<'info, token::Token>,
system_program: Program<'info, System>,
}
/// Arguments used to invoke [redeem_tokens_with_payload].
#[derive(Debug, AnchorSerialize, AnchorDeserialize, Clone)]
pub struct RedeemTokensWithPayloadArgs {
/// CCTP message.
pub encoded_cctp_message: Vec<u8>,
/// Attestation of [encoded_cctp_message](Self::encoded_cctp_message).
pub cctp_attestation: Vec<u8>,
}
/// This instruction reconciles a Wormhole CCTP deposit message with a CCTP message to mint tokens
/// for the [mint_recipient](RedeemTokensWithPayload::mint_recipient) token account.
///
/// See [verify_vaa_and_mint](wormhole_cctp_solana::cpi::verify_vaa_and_mint) for more details.
pub fn redeem_tokens_with_payload(
ctx: Context<RedeemTokensWithPayload>,
args: RedeemTokensWithPayloadArgs,
) -> Result<()> {
ctx.accounts.consumed_vaa.set_inner(ConsumedVaa {
bump: ctx.bumps.consumed_vaa,
});
let vaa = wormhole_cctp_solana::cpi::verify_vaa_and_mint(
&ctx.accounts.vaa,
CpiContext::new_with_signer(
ctx.accounts.message_transmitter_program.to_account_info(),
message_transmitter_program::cpi::ReceiveTokenMessengerMinterMessage {
payer: ctx.accounts.payer.to_account_info(),
caller: ctx.accounts.custodian.to_account_info(),
message_transmitter_authority: ctx
.accounts
.message_transmitter_authority
.to_account_info(),
message_transmitter_config: ctx
.accounts
.message_transmitter_config
.to_account_info(),
used_nonces: ctx.accounts.used_nonces.to_account_info(),
token_messenger_minter_program: ctx
.accounts
.token_messenger_minter_program
.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
message_transmitter_event_authority: ctx
.accounts
.message_transmitter_event_authority
.to_account_info(),
message_transmitter_program: ctx
.accounts
.message_transmitter_program
.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(),
token_pair: ctx.accounts.token_pair.to_account_info(),
mint_recipient: ctx.accounts.mint_recipient.to_account_info(),
custody_token: ctx
.accounts
.token_messenger_minter_custody_token
.to_account_info(),
token_program: ctx.accounts.token_program.to_account_info(),
token_messenger_minter_event_authority: ctx
.accounts
.token_messenger_minter_event_authority
.to_account_info(),
},
&[&[Custodian::SEED_PREFIX, &[ctx.accounts.custodian.bump]]],
),
ReceiveMessageArgs {
encoded_message: args.encoded_cctp_message,
attestation: args.cctp_attestation,
},
)?;
// Validate that this message originated from a registered emitter.
let registered_emitter = &ctx.accounts.registered_emitter;
let emitter = vaa.try_emitter_info().unwrap();
require!(
emitter.chain == registered_emitter.chain && emitter.address == registered_emitter.address,
CircleIntegrationError::UnknownEmitter
);
// Done.
Ok(())
}

View File

@ -0,0 +1,262 @@
use crate::state::{Custodian, RegisteredEmitter};
use anchor_lang::prelude::*;
use anchor_spl::token;
use wormhole_cctp_solana::{
cctp::{message_transmitter_program, token_messenger_minter_program},
utils::ExternalAccount,
wormhole::core_bridge_program,
};
/// Account context to invoke [transfer_tokens_with_payload].
#[derive(Accounts)]
pub struct TransferTokensWithPayload<'info> {
#[account(mut)]
payer: Signer<'info>,
/// This program's Wormhole (Core Bridge) emitter authority.
///
/// Seeds must be \["emitter"\].
#[account(
seeds = [Custodian::SEED_PREFIX],
bump = custodian.bump,
)]
custodian: Account<'info, Custodian>,
/// Circle-supported mint.
///
/// CHECK: Mutable. This token account's mint must be the same as the one found in the CCTP
/// Token Messenger Minter program's local token account.
#[account(
mut,
address = local_token.mint,
)]
mint: AccountInfo<'info>,
/// Token account where assets are burned from. The CCTP Token Messenger Minter program will
/// burn the configured [amount](TransferTokensWithPayloadArgs::amount) from this account.
///
/// NOTE: Transfer authority must be delegated to the custodian because this instruction
/// transfers assets from this account to the custody token account.
#[account(
mut,
token::mint = mint
)]
burn_source: Account<'info, token::TokenAccount>,
/// Temporary custody token account. This account will be closed at the end of this instruction.
/// It just acts as a conduit to allow this program to be the transfer initiator in the CCTP
/// message.
///
/// Seeds must be \["custody"\].
#[account(
init,
payer = payer,
token::mint = mint,
token::authority = custodian,
seeds = [crate::constants::CUSTODY_TOKEN_SEED_PREFIX],
bump,
)]
custody_token: Account<'info, token::TokenAccount>,
/// Registered emitter account representing a foreign Circle Integration emitter. This account
/// exists only when another CCTP network is registered.
///
/// Seeds must be \["registered_emitter", target_chain.to_be_bytes()\].
#[account(
seeds = [
RegisteredEmitter::SEED_PREFIX,
registered_emitter.chain.to_be_bytes().as_ref(),
],
bump = registered_emitter.bump,
)]
registered_emitter: Account<'info, RegisteredEmitter>,
/// CHECK: Seeds must be \["Bridge"\] (Wormhole Core Bridge program).
#[account(mut)]
core_bridge_config: UncheckedAccount<'info>,
/// CHECK: Mutable signer to create Wormhole message account.
#[account(mut)]
core_message: Signer<'info>,
/// CHECK: Mutable signer to create CCTP message.
#[account(mut)]
cctp_message: Signer<'info>,
/// CHECK: Seeds must be \["Sequence"\, custodian] (Wormhole Core Bridge program).
#[account(mut)]
core_emitter_sequence: UncheckedAccount<'info>,
/// CHECK: Seeds must be \["fee_collector"\] (Wormhole Core Bridge program).
#[account(mut)]
core_fee_collector: UncheckedAccount<'info>,
/// CHECK: Seeds must be \["sender_authority"\] (CCTP Token Messenger Minter program).
token_messenger_minter_sender_authority: UncheckedAccount<'info>,
/// CHECK: Mutable. Seeds must be \["message_transmitter"\] (CCTP Message Transmitter program).
#[account(mut)]
message_transmitter_config: UncheckedAccount<'info>,
/// CHECK: Seeds must be \["token_messenger"\] (CCTP Token Messenger Minter program).
token_messenger: UncheckedAccount<'info>,
/// CHECK: Seeds must be \["remote_token_messenger"\, remote_domain.to_string()] (CCTP Token
/// Messenger Minter program).
remote_token_messenger: UncheckedAccount<'info>,
/// CHECK: Seeds must be \["token_minter"\] (CCTP Token Messenger Minter program).
token_minter: UncheckedAccount<'info>,
/// Local token account, which this program uses to validate the `mint` used to burn.
///
/// Mutable. Seeds must be \["local_token", mint\] (CCTP Token Messenger Minter program).
#[account(mut)]
local_token: Box<Account<'info, ExternalAccount<token_messenger_minter_program::LocalToken>>>,
/// CHECK: Seeds must be \["__event_authority"\] (CCTP Token Messenger Minter program).
token_messenger_minter_event_authority: UncheckedAccount<'info>,
core_bridge_program: Program<'info, core_bridge_program::CoreBridge>,
token_messenger_minter_program:
Program<'info, token_messenger_minter_program::TokenMessengerMinter>,
message_transmitter_program: Program<'info, message_transmitter_program::MessageTransmitter>,
token_program: Program<'info, token::Token>,
system_program: Program<'info, System>,
/// CHECK: Wormhole Core Bridge needs the clock sysvar based on its legacy implementation.
#[account(address = solana_program::sysvar::clock::id())]
clock: AccountInfo<'info>,
/// CHECK: Wormhole Core Bridge needs the rent sysvar based on its legacy implementation.
#[account(address = solana_program::sysvar::rent::id())]
rent: AccountInfo<'info>,
}
/// Arguments used to invoke [transfer_tokens_with_payload].
#[derive(Debug, AnchorSerialize, AnchorDeserialize, Clone)]
pub struct TransferTokensWithPayloadArgs {
/// Transfer (burn) amount.
pub amount: u64,
/// Recipient of assets on target network.
pub mint_recipient: [u8; 32],
/// Arbitrary value which may be meaningful to an integrator. This nonce is encoded in the
/// Wormhole message.
pub wormhole_message_nonce: u32,
/// Arbitrary payload, which can be used to encode instructions or data for another network's
/// smart contract.
pub payload: Vec<u8>,
}
/// This instruction invokes both Wormhole Core Bridge and CCTP Token Messenger Minter programs to
/// emit a Wormhole message associated with a CCTP message.
///
/// See [burn_and_publish](wormhole_cctp_solana::cpi::burn_and_publish) for more details.
pub fn transfer_tokens_with_payload(
ctx: Context<TransferTokensWithPayload>,
args: TransferTokensWithPayloadArgs,
) -> Result<()> {
let TransferTokensWithPayloadArgs {
amount,
mint_recipient,
wormhole_message_nonce,
payload,
} = args;
let custodian_seeds = &[Custodian::SEED_PREFIX, &[ctx.accounts.custodian.bump]];
// Because the transfer initiator in the Circle message is whoever signs to burn assets, we need
// to transfer assets from the source token account to one that belongs to this program.
token::transfer(
CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
token::Transfer {
from: ctx.accounts.burn_source.to_account_info(),
to: ctx.accounts.custody_token.to_account_info(),
authority: ctx.accounts.custodian.to_account_info(),
},
&[custodian_seeds],
),
amount,
)?;
wormhole_cctp_solana::cpi::burn_and_publish(
CpiContext::new_with_signer(
ctx.accounts
.token_messenger_minter_program
.to_account_info(),
wormhole_cctp_solana::cpi::DepositForBurnWithCaller {
burn_token_owner: ctx.accounts.custodian.to_account_info(),
payer: ctx.accounts.payer.to_account_info(),
token_messenger_minter_sender_authority: ctx
.accounts
.token_messenger_minter_sender_authority
.to_account_info(),
burn_token: ctx.accounts.custody_token.to_account_info(),
message_transmitter_config: ctx
.accounts
.message_transmitter_config
.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(),
mint: ctx.accounts.mint.to_account_info(),
cctp_message: ctx.accounts.cctp_message.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
.token_messenger_minter_event_authority
.to_account_info(),
},
&[custodian_seeds],
),
CpiContext::new_with_signer(
ctx.accounts.core_bridge_program.to_account_info(),
wormhole_cctp_solana::cpi::PostMessage {
payer: ctx.accounts.payer.to_account_info(),
message: ctx.accounts.core_message.to_account_info(),
emitter: ctx.accounts.custodian.to_account_info(),
config: ctx.accounts.core_bridge_config.to_account_info(),
emitter_sequence: ctx.accounts.core_emitter_sequence.to_account_info(),
fee_collector: ctx.accounts.core_fee_collector.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
clock: ctx.accounts.clock.to_account_info(),
rent: ctx.accounts.rent.to_account_info(),
},
&[custodian_seeds],
),
wormhole_cctp_solana::cpi::BurnAndPublishArgs {
burn_source: Some(ctx.accounts.burn_source.key()),
destination_caller: ctx.accounts.registered_emitter.address,
destination_cctp_domain: ctx.accounts.registered_emitter.cctp_domain,
amount,
mint_recipient,
wormhole_message_nonce,
payload,
},
)?;
// Finally close the custody token account.
token::close_account(CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
token::CloseAccount {
account: ctx.accounts.custody_token.to_account_info(),
destination: ctx.accounts.payer.to_account_info(),
authority: ctx.accounts.custodian.to_account_info(),
},
&[custodian_seeds],
))
}

View File

@ -0,0 +1,11 @@
use anchor_lang::prelude::*;
#[account]
#[derive(Debug, InitSpace)]
pub struct ConsumedVaa {
pub bump: u8,
}
impl ConsumedVaa {
pub const SEED_PREFIX: &'static [u8] = b"consumed-vaa";
}

View File

@ -0,0 +1,15 @@
use anchor_lang::prelude::*;
/// Emitter config account. This account is used to perform the following:
/// 1. It is the emitter authority for the Core Bridge program.
/// 2. It acts as the custody token account owner for token transfers.
#[account]
#[derive(Debug, InitSpace)]
pub struct Custodian {
pub bump: u8,
pub upgrade_authority_bump: u8,
}
impl Custodian {
pub const SEED_PREFIX: &'static [u8] = b"emitter";
}

View File

@ -0,0 +1,8 @@
mod consumed_vaa;
pub use consumed_vaa::*;
mod custodian;
pub use custodian::*;
mod registered_emitter;
pub use registered_emitter::*;

View File

@ -0,0 +1,14 @@
use anchor_lang::prelude::*;
#[account]
#[derive(Debug, InitSpace)]
pub struct RegisteredEmitter {
pub bump: u8,
pub cctp_domain: u32,
pub chain: u16,
pub address: [u8; 32],
}
impl RegisteredEmitter {
pub const SEED_PREFIX: &'static [u8] = b"registered_emitter";
}

View File

@ -0,0 +1,51 @@
import * as fs from "fs";
const BASENAME = "wormhole_circle_integration_solana";
const ROOT = `${__dirname}/../..`;
const TYPES = `${ROOT}/target/types/${BASENAME}.ts`;
const IGNORE_TYPES = [
'"name": "LocalToken"',
'"name": "TokenPair"',
'"name": "MessageTransmitterConfig"',
'"name": "WormholeCctp"',
];
main();
function main() {
if (!fs.existsSync(TYPES)) {
throw new Error("Types non-existent");
}
const types = fs.readFileSync(TYPES, "utf8").split("\n");
for (const matchStr of IGNORE_TYPES) {
while (spliceType(types, matchStr));
}
fs.writeFileSync(TYPES, types.join("\n"), "utf8");
}
function spliceType(lines: string[], matchStr: string) {
let lineNumber = 0;
let start = -1;
let spaces = -1;
for (const line of lines) {
if (line.includes(matchStr)) {
start = lineNumber - 1;
spaces = line.indexOf('"') - 2;
} else if (start > -1) {
if (line == "}".padStart(spaces + 1, " ")) {
lines[start - 1] = lines[start - 1].replace("},", "}");
lines.splice(start, lineNumber - start + 1);
return true;
} else if (line == "},".padStart(spaces + 2, " ")) {
lines.splice(start, lineNumber - start + 1);
return true;
}
}
++lineNumber;
}
return false;
}

View File

@ -0,0 +1,212 @@
import {
ChainName,
coalesceChainId,
parseVaa,
tryNativeToUint8Array,
} from "@certusone/wormhole-sdk";
import { MockEmitter, MockGuardians } from "@certusone/wormhole-sdk/lib/cjs/mock";
import { NodeWallet, postVaaSolana } from "@certusone/wormhole-sdk/lib/cjs/solana";
import { derivePostedVaaKey } from "@certusone/wormhole-sdk/lib/cjs/solana/wormhole";
import { Connection, Keypair, Transaction, sendAndConfirmTransaction } from "@solana/web3.js";
import "dotenv/config";
import { CircleIntegrationProgram } from "../src";
const PROGRAM_ID = "wcihrWf1s91vfukW7LW8ZvR1rzpeZ9BrtZ8oyPkWK5d";
// Here we go.
main();
// impl
async function main() {
let govSequence = 6920n;
const connection = new Connection("https://api.devnet.solana.com", "confirmed");
const circleIntegration = new CircleIntegrationProgram(connection, PROGRAM_ID);
if (process.env.SOLANA_PRIVATE_KEY === undefined) {
throw new Error("SOLANA_PRIVATE_KEY is undefined");
}
const payer = Keypair.fromSecretKey(Buffer.from(process.env.SOLANA_PRIVATE_KEY, "hex"));
// Set up CCTP Program.
//await intialize(circleIntegration, payer);
// Register emitter and domain.
{
const foreignChain = "sepolia";
const foreignEmitter = "0x2703483B1a5a7c577e8680de9Df8Be03c6f30e3c";
const cctpDomain = 0;
await registerEmitterAndDomain(
circleIntegration,
payer,
govSequence++,
foreignChain,
foreignEmitter,
cctpDomain,
);
}
{
const foreignChain = "avalanche";
const foreignEmitter = "0x58f4C17449c90665891C42E14D34aae7a26A472e";
const cctpDomain = 1;
await registerEmitterAndDomain(
circleIntegration,
payer,
govSequence++,
foreignChain,
foreignEmitter,
cctpDomain,
);
}
{
const foreignChain = "optimism_sepolia";
const foreignEmitter = "0x2703483B1a5a7c577e8680de9Df8Be03c6f30e3c";
const cctpDomain = 2;
await registerEmitterAndDomain(
circleIntegration,
payer,
govSequence++,
foreignChain,
foreignEmitter,
cctpDomain,
);
}
{
const foreignChain = "arbitrum_sepolia";
const foreignEmitter = "0x2703483B1a5a7c577e8680de9Df8Be03c6f30e3c";
const cctpDomain = 3;
await registerEmitterAndDomain(
circleIntegration,
payer,
govSequence++,
foreignChain,
foreignEmitter,
cctpDomain,
);
}
{
const foreignChain = "base_sepolia";
const foreignEmitter = "0x2703483B1a5a7c577e8680de9Df8Be03c6f30e3c";
const cctpDomain = 6;
await registerEmitterAndDomain(
circleIntegration,
payer,
govSequence++,
foreignChain,
foreignEmitter,
cctpDomain,
);
}
{
const foreignChain = "polygon";
const foreignEmitter = "0x2703483B1a5a7c577e8680de9Df8Be03c6f30e3c";
const cctpDomain = 7;
await registerEmitterAndDomain(
circleIntegration,
payer,
govSequence++,
foreignChain,
foreignEmitter,
cctpDomain,
);
}
}
async function intialize(circleIntegration: CircleIntegrationProgram, payer: Keypair) {
console.log("custodian", circleIntegration.custodianAddress().toString());
const ix = await circleIntegration.initializeIx(payer.publicKey);
const connection = circleIntegration.program.provider.connection;
const txSig = await sendAndConfirmTransaction(connection, new Transaction().add(ix), [payer]);
console.log("intialize", txSig);
}
async function registerEmitterAndDomain(
circleIntegration: CircleIntegrationProgram,
payer: Keypair,
govSequence: bigint,
foreignChain: ChainName,
foreignEmitter: string,
cctpDomain: number,
) {
const connection = circleIntegration.program.provider.connection;
const registeredEmitter = circleIntegration.registeredEmitterAddress(
coalesceChainId(foreignChain),
);
const emitterAddress = Array.from(tryNativeToUint8Array(foreignEmitter, foreignChain));
const exists = await connection.getAccountInfo(registeredEmitter).then((acct) => acct != null);
if (exists) {
const registered = await circleIntegration.fetchRegisteredEmitter(registeredEmitter);
if (Buffer.from(registered.address).equals(Buffer.from(emitterAddress))) {
console.log("already registered", foreignChain, foreignEmitter, cctpDomain);
return;
}
}
const govEmitter = new MockEmitter(
"0000000000000000000000000000000000000000000000000000000000000004",
1,
Number(govSequence),
);
const payload = Buffer.alloc(32 + 1 + 2 + 2 + 32 + 4);
// Action.
payload.writeUInt8(2, 32);
// Data.
payload.writeUInt16BE(1, 33); // targetChain
payload.writeUInt16BE(coalesceChainId(foreignChain), 35);
payload.set(tryNativeToUint8Array(foreignEmitter, foreignChain), 37);
payload.writeUInt32BE(cctpDomain, 69);
const moduleName = "CircleIntegration";
payload.set(Buffer.from(moduleName), 32 - moduleName.length);
const published = govEmitter.publishMessage(
0, // nonce,
payload,
0, // consistencyLevel
12345678, // timestamp
);
if (process.env.GUARDIAN_PRIVATE_KEY === undefined) {
throw new Error("GUARDIAN_PRIVATE_KEY is undefined");
}
const guardians = new MockGuardians(0, [process.env.GUARDIAN_PRIVATE_KEY]);
const vaaBuf = guardians.addSignatures(published, [0]);
await postVaaSolana(
connection,
new NodeWallet(payer).signTransaction,
circleIntegration.coreBridgeProgramId(),
payer.publicKey,
vaaBuf,
);
const vaa = derivePostedVaaKey(circleIntegration.coreBridgeProgramId(), parseVaa(vaaBuf).hash);
const ix = await circleIntegration.registerEmitterAndDomainIx({
payer: payer.publicKey,
vaa,
});
const txSig = await sendAndConfirmTransaction(connection, new Transaction().add(ix), [payer]);
console.log(
"register emitter and domain",
txSig,
"chain",
foreignChain,
"addr",
foreignEmitter,
"domain",
cctpDomain,
);
}

View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"sourceMap": true,
"inlineSourceMap": false,
"inlineSources": true,
"declaration": false,
"noEmit": false,
"target": "es2022",
"module": "commonjs",
"esModuleInterop": true,
"resolveJsonModule": true
}
}

View File

@ -0,0 +1,85 @@
import { parseVaa, tryNativeToUint8Array } from "@certusone/wormhole-sdk";
import { MockEmitter, MockGuardians } from "@certusone/wormhole-sdk/lib/cjs/mock";
import { NodeWallet, postVaaSolana } from "@certusone/wormhole-sdk/lib/cjs/solana";
import { derivePostedVaaKey } from "@certusone/wormhole-sdk/lib/cjs/solana/wormhole";
import { Connection, Keypair, Transaction, sendAndConfirmTransaction } from "@solana/web3.js";
import "dotenv/config";
import { CircleIntegrationProgram } from "../src";
const PROGRAM_ID = "wcihrWf1s91vfukW7LW8ZvR1rzpeZ9BrtZ8oyPkWK5d";
// Modify this to the new implementation address.
const NEW_IMPLEMENTATION = "HCUGGoihMthPN6d4VGpH8xUPYUofgTgqnpYtwyca7PEh";
// Here we go.
main();
// impl
async function main() {
let govSequence = 6910n;
const connection = new Connection("https://api.devnet.solana.com", "confirmed");
const circleIntegration = new CircleIntegrationProgram(connection, PROGRAM_ID);
if (process.env.SOLANA_PRIVATE_KEY === undefined) {
throw new Error("SOLANA_PRIVATE_KEY is undefined");
}
const payer = Keypair.fromSecretKey(Buffer.from(process.env.SOLANA_PRIVATE_KEY, "hex"));
await upgradeContract(circleIntegration, payer, govSequence);
}
async function upgradeContract(
circleIntegration: CircleIntegrationProgram,
payer: Keypair,
govSequence: bigint,
) {
const connection = circleIntegration.program.provider.connection;
const govEmitter = new MockEmitter(
"0000000000000000000000000000000000000000000000000000000000000004",
1,
Number(govSequence),
);
const payload = Buffer.alloc(32 + 1 + 2 + 32);
// Action.
payload.writeUInt8(3, 32);
// Data.
payload.writeUInt16BE(1, 33); // targetChain
payload.set(tryNativeToUint8Array(NEW_IMPLEMENTATION, "solana"), 35);
const moduleName = "CircleIntegration";
payload.set(Buffer.from(moduleName), 32 - moduleName.length);
const published = govEmitter.publishMessage(
0, // nonce,
payload,
0, // consistencyLevel
12345678, // timestamp
);
if (process.env.GUARDIAN_PRIVATE_KEY === undefined) {
throw new Error("GUARDIAN_PRIVATE_KEY is undefined");
}
const guardians = new MockGuardians(0, [process.env.GUARDIAN_PRIVATE_KEY]);
const vaaBuf = guardians.addSignatures(published, [0]);
await postVaaSolana(
connection,
new NodeWallet(payer).signTransaction,
circleIntegration.coreBridgeProgramId(),
payer.publicKey,
vaaBuf,
);
const vaa = derivePostedVaaKey(circleIntegration.coreBridgeProgramId(), parseVaa(vaaBuf).hash);
const ix = await circleIntegration.upgradeContractIx({
payer: payer.publicKey,
vaa,
});
const txSig = await sendAndConfirmTransaction(connection, new Transaction().add(ix), [payer]);
console.log("upgrade contract", txSig);
}

View File

@ -0,0 +1,3 @@
export { MessageTransmitterProgram } from "./messageTransmitter";
export { CctpMessage, CctpTokenBurnMessage } from "./messages";
export { TokenMessengerMinterProgram } from "./tokenMessengerMinter";

View File

@ -0,0 +1,11 @@
import { PublicKey } from "@solana/web3.js";
export class MessageSent {
rentPayer: PublicKey;
message: Buffer;
constructor(rentPayer: PublicKey, message: Buffer) {
this.rentPayer = rentPayer;
this.message = message;
}
}

View File

@ -0,0 +1,46 @@
import { BN } from "@coral-xyz/anchor";
import { PublicKey } from "@solana/web3.js";
export class MessageTransmitterConfig {
owner: PublicKey;
pendingOwner: PublicKey;
attesterManager: PublicKey;
pauser: PublicKey;
paused: boolean;
localDomain: number;
version: number;
signatureThreshold: number;
enabledAttesters: Array<PublicKey>;
maxMessageBodySize: BN;
nextAvailableNonce: BN;
constructor(
owner: PublicKey,
pendingOwner: PublicKey,
attesterManager: PublicKey,
pauser: PublicKey,
paused: boolean,
localDomain: number,
version: number,
signatureThreshold: number,
enabledAttesters: Array<PublicKey>,
maxMessageBodySize: BN,
nextAvailableNonce: BN,
) {
this.owner = owner;
this.pendingOwner = pendingOwner;
this.attesterManager = attesterManager;
this.pauser = pauser;
this.paused = paused;
this.localDomain = localDomain;
this.version = version;
this.signatureThreshold = signatureThreshold;
this.enabledAttesters = enabledAttesters;
this.maxMessageBodySize = maxMessageBodySize;
this.nextAvailableNonce = nextAvailableNonce;
}
static address(programId: PublicKey) {
return PublicKey.findProgramAddressSync([Buffer.from("message_transmitter")], programId)[0];
}
}

View File

@ -0,0 +1,17 @@
import { PublicKey } from "@solana/web3.js";
export const MAX_NONCES = 6400n;
export class UsedNonses {
static address(programId: PublicKey, remoteDomain: number, nonce: bigint) {
const firstNonce = ((nonce - 1n) / MAX_NONCES) * MAX_NONCES + 1n;
return PublicKey.findProgramAddressSync(
[
Buffer.from("used_nonces"),
Buffer.from(remoteDomain.toString()),
Buffer.from(firstNonce.toString()),
],
programId,
)[0];
}
}

View File

@ -0,0 +1,128 @@
import { Program } from "@coral-xyz/anchor";
import { Connection, PublicKey } from "@solana/web3.js";
import { CctpTokenBurnMessage } from "../messages";
import { TokenMessengerMinterProgram } from "../tokenMessengerMinter";
import { IDL, MessageTransmitter } from "../types/message_transmitter";
import { MessageSent } from "./MessageSent";
import { MessageTransmitterConfig } from "./MessageTransmitterConfig";
import { UsedNonses } from "./UsedNonces";
export const PROGRAM_IDS = ["CCTPmbSD7gX1bxKPAmg77w8oFzNFpaQiQUWD43TKaecd"] as const;
export type ProgramId = (typeof PROGRAM_IDS)[number];
export type ReceiveTokenMessengerMinterMessageAccounts = {
authority: PublicKey;
messageTransmitterConfig: PublicKey;
usedNonces: PublicKey;
tokenMessengerMinterProgram: PublicKey;
messageTransmitterEventAuthority: PublicKey;
messageTransmitterProgram: PublicKey;
tokenMessenger: PublicKey;
remoteTokenMessenger: PublicKey;
tokenMinter: PublicKey;
localToken: PublicKey;
tokenPair: PublicKey;
custodyToken: PublicKey;
eventAuthority: PublicKey;
};
export class MessageTransmitterProgram {
private _programId: ProgramId;
program: Program<MessageTransmitter>;
constructor(connection: Connection, programId?: ProgramId) {
this._programId = programId ?? testnet();
this.program = new Program(IDL, new PublicKey(this._programId), {
connection,
});
}
get ID(): PublicKey {
return this.program.programId;
}
messageTransmitterConfigAddress(): PublicKey {
return MessageTransmitterConfig.address(this.ID);
}
async fetchMessageTransmitterConfig(addr: PublicKey): Promise<MessageTransmitterConfig> {
return this.program.account.messageTransmitter.fetch(addr);
}
usedNoncesAddress(remoteDomain: number, nonce: bigint): PublicKey {
return UsedNonses.address(this.ID, remoteDomain, nonce);
}
authorityAddress(cpiProgramId: PublicKey): PublicKey {
return PublicKey.findProgramAddressSync(
[Buffer.from("message_transmitter_authority"), cpiProgramId.toBuffer()],
this.ID,
)[0];
}
eventAuthorityAddress(): PublicKey {
return PublicKey.findProgramAddressSync([Buffer.from("__event_authority")], this.ID)[0];
}
fetchMessageSent(addr: PublicKey): Promise<MessageSent> {
return this.program.account.messageSent.fetch(addr);
}
tokenMessengerMinterProgram(): TokenMessengerMinterProgram {
switch (this._programId) {
case testnet(): {
return new TokenMessengerMinterProgram(
this.program.provider.connection,
"CCTPiPYPc6AsJuwueEnWgSgucamXDZwBd53dQ11YiKX3",
);
}
case mainnet(): {
return new TokenMessengerMinterProgram(
this.program.provider.connection,
"CCTPiPYPc6AsJuwueEnWgSgucamXDZwBd53dQ11YiKX3",
);
}
default: {
throw new Error("unsupported network");
}
}
}
receiveTokenMessengerMinterMessageAccounts(
mint: PublicKey,
circleMessage: CctpTokenBurnMessage | Buffer,
): ReceiveTokenMessengerMinterMessageAccounts {
const {
cctp: { sourceDomain, nonce },
burnTokenAddress,
} = CctpTokenBurnMessage.from(circleMessage);
const tokenMessengerMinterProgram = this.tokenMessengerMinterProgram();
return {
authority: this.authorityAddress(tokenMessengerMinterProgram.ID),
messageTransmitterConfig: this.messageTransmitterConfigAddress(),
usedNonces: this.usedNoncesAddress(sourceDomain, nonce),
tokenMessengerMinterProgram: tokenMessengerMinterProgram.ID,
messageTransmitterEventAuthority: this.eventAuthorityAddress(),
messageTransmitterProgram: this.ID,
tokenMessenger: tokenMessengerMinterProgram.tokenMessengerAddress(),
remoteTokenMessenger:
tokenMessengerMinterProgram.remoteTokenMessengerAddress(sourceDomain),
tokenMinter: tokenMessengerMinterProgram.tokenMinterAddress(),
localToken: tokenMessengerMinterProgram.localTokenAddress(mint),
tokenPair: tokenMessengerMinterProgram.tokenPairAddress(sourceDomain, burnTokenAddress),
custodyToken: tokenMessengerMinterProgram.custodyTokenAddress(mint),
eventAuthority: tokenMessengerMinterProgram.eventAuthorityAddress(),
};
}
}
export function mainnet(): ProgramId {
return "CCTPmbSD7gX1bxKPAmg77w8oFzNFpaQiQUWD43TKaecd";
}
export function testnet(): ProgramId {
return "CCTPmbSD7gX1bxKPAmg77w8oFzNFpaQiQUWD43TKaecd";
}

View File

@ -0,0 +1,153 @@
import { ethers } from "ethers";
export type Cctp = {
version: number;
sourceDomain: number;
destinationDomain: number;
nonce: bigint;
sender: Array<number>;
recipient: Array<number>;
targetCaller: Array<number>;
};
// Taken from https://developers.circle.com/stablecoins/docs/message-format.
export class CctpMessage {
cctp: Cctp;
message: Buffer;
constructor(cctp: Cctp, message: Buffer) {
this.cctp = cctp;
this.message = message;
}
static from(message: CctpMessage | Buffer): CctpMessage {
if (message instanceof CctpMessage) {
return message;
} else {
return CctpMessage.decode(message);
}
}
static decode(buf: Readonly<Buffer>): CctpMessage {
const version = buf.readUInt32BE(0);
const sourceDomain = buf.readUInt32BE(4);
const destinationDomain = buf.readUInt32BE(8);
const nonce = buf.readBigUInt64BE(12);
const sender = Array.from(buf.slice(20, 52));
const recipient = Array.from(buf.slice(52, 84));
const targetCaller = Array.from(buf.slice(84, 116));
const message = buf.subarray(116);
return new CctpMessage(
{
version,
sourceDomain,
destinationDomain,
nonce,
sender,
recipient,
targetCaller,
},
message,
);
}
encode(): Buffer {
const { cctp, message } = this;
return Buffer.concat([encodeCctp(cctp), message]);
}
}
export class CctpTokenBurnMessage {
cctp: Cctp;
version: number;
burnTokenAddress: Array<number>;
mintRecipient: Array<number>;
amount: bigint;
sender: Array<number>;
constructor(
cctp: Cctp,
version: number,
burnTokenAddress: Array<number>,
mintRecipient: Array<number>,
amount: bigint,
sender: Array<number>,
) {
this.cctp = cctp;
this.version = version;
this.burnTokenAddress = burnTokenAddress;
this.mintRecipient = mintRecipient;
this.amount = amount;
this.sender = sender;
}
static from(message: CctpTokenBurnMessage | Buffer): CctpTokenBurnMessage {
if (message instanceof CctpTokenBurnMessage) {
return message;
} else {
return CctpTokenBurnMessage.decode(message);
}
}
static decode(buf: Readonly<Buffer>): CctpTokenBurnMessage {
const { cctp, message } = CctpMessage.decode(buf);
const version = message.readUInt32BE(0);
const burnTokenAddress = Array.from(message.subarray(4, 36));
const mintRecipient = Array.from(message.subarray(36, 68));
const amount = BigInt(ethers.BigNumber.from(message.subarray(68, 100)).toString());
const sender = Array.from(message.subarray(100, 132));
return new CctpTokenBurnMessage(
cctp,
version,
burnTokenAddress,
mintRecipient,
amount,
sender,
);
}
encode(): Buffer {
const buf = Buffer.alloc(132);
const { cctp, version, burnTokenAddress, mintRecipient, amount, sender } = this;
let offset = 0;
offset = buf.writeUInt32BE(version, offset);
buf.set(burnTokenAddress, offset);
offset += 32;
buf.set(mintRecipient, offset);
offset += 32;
// Special handling w/ uint256. This value will most likely encoded in < 32 bytes, so we
// jump ahead by 32 and subtract the length of the encoded value.
const encodedAmount = ethers.utils.arrayify(ethers.BigNumber.from(amount.toString()));
buf.set(encodedAmount, (offset += 32) - encodedAmount.length);
buf.set(sender, offset);
offset += 32;
return Buffer.concat([encodeCctp(cctp), buf]);
}
}
function encodeCctp(cctp: Cctp): Buffer {
const buf = Buffer.alloc(116);
const { version, sourceDomain, destinationDomain, nonce, sender, recipient, targetCaller } =
cctp;
let offset = 0;
offset = buf.writeUInt32BE(version, offset);
offset = buf.writeUInt32BE(sourceDomain, offset);
offset = buf.writeUInt32BE(destinationDomain, offset);
offset = buf.writeBigUInt64BE(nonce, offset);
buf.set(sender, offset);
offset += 32;
buf.set(recipient, offset);
offset += 32;
buf.set(targetCaller, offset);
return buf;
}

View File

@ -0,0 +1,18 @@
import { PublicKey } from "@solana/web3.js";
export class RemoteTokenMessenger {
domain: number;
tokenMessenger: Array<number>;
constructor(domain: number, tokenMessenger: Array<number>) {
this.domain = domain;
this.tokenMessenger = tokenMessenger;
}
static address(programId: PublicKey, remoteDomain: number) {
return PublicKey.findProgramAddressSync(
[Buffer.from("remote_token_messenger"), Buffer.from(remoteDomain.toString())],
programId,
)[0];
}
}

View File

@ -0,0 +1,135 @@
import { Program } from "@coral-xyz/anchor";
import { Connection, PublicKey } from "@solana/web3.js";
import { MessageTransmitterProgram } from "../messageTransmitter";
import { IDL, TokenMessengerMinter } from "../types/token_messenger_minter";
import { RemoteTokenMessenger } from "./RemoteTokenMessenger";
export const PROGRAM_IDS = ["CCTPiPYPc6AsJuwueEnWgSgucamXDZwBd53dQ11YiKX3"] as const;
export type ProgramId = (typeof PROGRAM_IDS)[number];
export type DepositForBurnWithCallerAccounts = {
senderAuthority: PublicKey;
messageTransmitterConfig: PublicKey;
tokenMessenger: PublicKey;
remoteTokenMessenger: PublicKey;
tokenMinter: PublicKey;
localToken: PublicKey;
tokenMessengerMinterEventAuthority: PublicKey;
messageTransmitterProgram: PublicKey;
tokenMessengerMinterProgram: PublicKey;
};
export class TokenMessengerMinterProgram {
private _programId: ProgramId;
program: Program<TokenMessengerMinter>;
constructor(connection: Connection, programId?: ProgramId) {
this._programId = programId ?? testnet();
this.program = new Program(IDL, new PublicKey(this._programId), {
connection,
});
}
get ID(): PublicKey {
return this.program.programId;
}
tokenMessengerAddress(): PublicKey {
return PublicKey.findProgramAddressSync([Buffer.from("token_messenger")], this.ID)[0];
}
tokenMinterAddress(): PublicKey {
return PublicKey.findProgramAddressSync([Buffer.from("token_minter")], this.ID)[0];
}
custodyTokenAddress(mint: PublicKey): PublicKey {
return PublicKey.findProgramAddressSync(
[Buffer.from("custody"), mint.toBuffer()],
this.ID,
)[0];
}
tokenPairAddress(remoteDomain: number, remoteTokenAddress: Array<number>): PublicKey {
return PublicKey.findProgramAddressSync(
[
Buffer.from("token_pair"),
Buffer.from(remoteDomain.toString()),
Buffer.from(remoteTokenAddress),
],
this.ID,
)[0];
}
remoteTokenMessengerAddress(remoteDomain: number): PublicKey {
return RemoteTokenMessenger.address(this.ID, remoteDomain);
}
async fetchRemoteTokenMessenger(addr: PublicKey): Promise<RemoteTokenMessenger> {
const { domain, tokenMessenger } =
await this.program.account.remoteTokenMessenger.fetch(addr);
return new RemoteTokenMessenger(domain, Array.from(tokenMessenger.toBuffer()));
}
localTokenAddress(mint: PublicKey): PublicKey {
return PublicKey.findProgramAddressSync(
[Buffer.from("local_token"), mint.toBuffer()],
this.ID,
)[0];
}
senderAuthorityAddress(): PublicKey {
return PublicKey.findProgramAddressSync([Buffer.from("sender_authority")], this.ID)[0];
}
eventAuthorityAddress(): PublicKey {
return PublicKey.findProgramAddressSync([Buffer.from("__event_authority")], this.ID)[0];
}
messageTransmitterProgram(): MessageTransmitterProgram {
switch (this._programId) {
case testnet(): {
return new MessageTransmitterProgram(
this.program.provider.connection,
"CCTPmbSD7gX1bxKPAmg77w8oFzNFpaQiQUWD43TKaecd",
);
}
case mainnet(): {
return new MessageTransmitterProgram(
this.program.provider.connection,
"CCTPmbSD7gX1bxKPAmg77w8oFzNFpaQiQUWD43TKaecd",
);
}
default: {
throw new Error("unsupported network");
}
}
}
depositForBurnWithCallerAccounts(
mint: PublicKey,
remoteDomain: number,
): DepositForBurnWithCallerAccounts {
const messageTransmitterProgram = this.messageTransmitterProgram();
return {
senderAuthority: this.senderAuthorityAddress(),
messageTransmitterConfig: messageTransmitterProgram.messageTransmitterConfigAddress(),
tokenMessenger: this.tokenMessengerAddress(),
remoteTokenMessenger: this.remoteTokenMessengerAddress(remoteDomain),
tokenMinter: this.tokenMinterAddress(),
localToken: this.localTokenAddress(mint),
tokenMessengerMinterEventAuthority: this.eventAuthorityAddress(),
messageTransmitterProgram: messageTransmitterProgram.ID,
tokenMessengerMinterProgram: this.ID,
};
}
}
export function mainnet(): ProgramId {
return "CCTPiPYPc6AsJuwueEnWgSgucamXDZwBd53dQ11YiKX3";
}
export function testnet(): ProgramId {
return "CCTPiPYPc6AsJuwueEnWgSgucamXDZwBd53dQ11YiKX3";
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

5
solana/ts/src/consts.ts Normal file
View File

@ -0,0 +1,5 @@
import { PublicKey } from "@solana/web3.js";
export const BPF_LOADER_UPGRADEABLE_ID = new PublicKey(
"BPFLoaderUpgradeab1e11111111111111111111111",
);

598
solana/ts/src/index.ts Normal file
View File

@ -0,0 +1,598 @@
export * from "./cctp";
export * from "./consts";
export * from "./messages";
export * from "./state";
export * from "./wormhole";
import { BN, Program } from "@coral-xyz/anchor";
import * as splToken from "@solana/spl-token";
import {
Connection,
PublicKey,
SYSVAR_CLOCK_PUBKEY,
SYSVAR_RENT_PUBKEY,
SystemProgram,
TransactionInstruction,
} from "@solana/web3.js";
import {
IDL,
WormholeCircleIntegrationSolana,
} from "../../target/types/wormhole_circle_integration_solana";
import {
CctpTokenBurnMessage,
MessageTransmitterProgram,
TokenMessengerMinterProgram,
} from "./cctp";
import { BPF_LOADER_UPGRADEABLE_ID } from "./consts";
import { ConsumedVaa, Custodian, RegisteredEmitter } from "./state";
import { VaaAccount } from "./wormhole";
export const PROGRAM_IDS = [
"Wormho1eCirc1e1ntegration111111111111111111", // mainnet placeholder
"wcihrWf1s91vfukW7LW8ZvR1rzpeZ9BrtZ8oyPkWK5d", // testnet
] as const;
export type ProgramId = (typeof PROGRAM_IDS)[number];
export type TransferTokensWithPayloadArgs = {
amount: bigint;
targetChain: number;
mintRecipient: Array<number>;
wormholeMessageNonce: number;
payload: Buffer;
};
export type PublishMessageAccounts = {
coreBridgeConfig: PublicKey;
coreEmitterSequence: PublicKey;
coreFeeCollector: PublicKey;
coreBridgeProgram: PublicKey;
};
export type WormholeCctpCommonAccounts = PublishMessageAccounts & {
wormholeCctpProgram: PublicKey;
systemProgram: PublicKey;
rent: PublicKey;
custodian: PublicKey;
custodyToken: PublicKey;
tokenMessenger: PublicKey;
tokenMinter: PublicKey;
tokenMessengerMinterSenderAuthority: PublicKey;
tokenMessengerMinterProgram: PublicKey;
messageTransmitterAuthority: PublicKey;
messageTransmitterConfig: PublicKey;
messageTransmitterProgram: PublicKey;
tokenProgram: PublicKey;
mint?: PublicKey;
localToken?: PublicKey;
tokenMessengerMinterCustodyToken?: PublicKey;
};
export type TransferTokensWithPayloadAccounts = PublishMessageAccounts & {
custodian: PublicKey;
custodyToken: PublicKey;
registeredEmitter: PublicKey;
tokenMessengerMinterSenderAuthority: PublicKey;
messageTransmitterConfig: PublicKey;
tokenMessenger: PublicKey;
remoteTokenMessenger: PublicKey;
tokenMinter: PublicKey;
localToken: PublicKey;
tokenMessengerMinterEventAuthority: PublicKey;
coreBridgeProgram: PublicKey;
tokenMessengerMinterProgram: PublicKey;
messageTransmitterProgram: PublicKey;
};
export type RedeemTokensWithPayloadAccounts = {
custodian: PublicKey;
consumedVaa: PublicKey;
mintRecipientAuthority: PublicKey;
mintRecipient: PublicKey;
registeredEmitter: PublicKey;
messageTransmitterAuthority: PublicKey;
messageTransmitterConfig: PublicKey;
usedNonces: PublicKey;
messageTransmitterEventAuthority: PublicKey;
tokenMessenger: PublicKey;
remoteTokenMessenger: PublicKey;
tokenMinter: PublicKey;
localToken: PublicKey;
tokenPair: PublicKey;
tokenMessengerMinterCustodyToken: PublicKey;
tokenMessengerMinterEventAuthority: PublicKey;
tokenMessengerMinterProgram: PublicKey;
messageTransmitterProgram: PublicKey;
};
export type SolanaWormholeCctpTxData = {
coreMessageAccount: PublicKey;
coreMessageSequence: bigint;
encodedCctpMessage: Buffer;
};
export class CircleIntegrationProgram {
private _programId: ProgramId;
program: Program<WormholeCircleIntegrationSolana>;
constructor(connection: Connection, programId?: ProgramId) {
this._programId = programId ?? testnet();
this.program = new Program(IDL, new PublicKey(this._programId), {
connection,
});
}
get ID(): PublicKey {
return this.program.programId;
}
upgradeAuthorityAddress(): PublicKey {
return PublicKey.findProgramAddressSync([Buffer.from("upgrade")], this.ID)[0];
}
programDataAddress(): PublicKey {
return PublicKey.findProgramAddressSync([this.ID.toBuffer()], BPF_LOADER_UPGRADEABLE_ID)[0];
}
custodianAddress(): PublicKey {
return Custodian.address(this.ID);
}
async fetchCustodian(addr: PublicKey): Promise<Custodian> {
const { bump, upgradeAuthorityBump } = await this.program.account.custodian.fetch(addr);
return new Custodian(bump, upgradeAuthorityBump);
}
registeredEmitterAddress(chain: number): PublicKey {
return RegisteredEmitter.address(this.ID, chain);
}
async fetchRegisteredEmitter(addr: PublicKey): Promise<RegisteredEmitter> {
const {
bump,
chain: registeredChain,
cctpDomain,
address,
} = await this.program.account.registeredEmitter.fetch(addr);
return new RegisteredEmitter(bump, cctpDomain, registeredChain, address);
}
custodyTokenAccountAddress(): PublicKey {
return PublicKey.findProgramAddressSync([Buffer.from("custody")], this.ID)[0];
}
consumedVaaAddress(vaaHash: Array<number> | Uint8Array): PublicKey {
return ConsumedVaa.address(this.ID, vaaHash);
}
commonAccounts(mint?: PublicKey): WormholeCctpCommonAccounts {
const custodian = this.custodianAddress();
const { coreBridgeConfig, coreEmitterSequence, coreFeeCollector, coreBridgeProgram } =
this.publishMessageAccounts(custodian);
const tokenMessengerMinterProgram = this.tokenMessengerMinterProgram();
const messageTransmitterProgram = this.messageTransmitterProgram();
const [localToken, tokenMessengerMinterCustodyToken] = (() => {
if (mint === undefined) {
return [undefined, undefined];
} else {
return [
tokenMessengerMinterProgram.localTokenAddress(mint),
tokenMessengerMinterProgram.custodyTokenAddress(mint),
];
}
})();
return {
wormholeCctpProgram: this.ID,
systemProgram: SystemProgram.programId,
rent: SYSVAR_RENT_PUBKEY,
custodian,
custodyToken: this.custodyTokenAccountAddress(),
coreBridgeConfig,
coreEmitterSequence,
coreFeeCollector,
coreBridgeProgram,
tokenMessenger: tokenMessengerMinterProgram.tokenMessengerAddress(),
tokenMinter: tokenMessengerMinterProgram.tokenMinterAddress(),
tokenMessengerMinterSenderAuthority:
tokenMessengerMinterProgram.senderAuthorityAddress(),
tokenMessengerMinterProgram: tokenMessengerMinterProgram.ID,
messageTransmitterAuthority: messageTransmitterProgram.authorityAddress(
tokenMessengerMinterProgram.ID,
),
messageTransmitterConfig: messageTransmitterProgram.messageTransmitterConfigAddress(),
messageTransmitterProgram: messageTransmitterProgram.ID,
tokenProgram: splToken.TOKEN_PROGRAM_ID,
mint,
localToken,
tokenMessengerMinterCustodyToken,
};
}
async initializeIx(deployer: PublicKey): Promise<TransactionInstruction> {
return this.program.methods
.initialize()
.accounts({
deployer,
custodian: this.custodianAddress(),
upgradeAuthority: this.upgradeAuthorityAddress(),
programData: this.programDataAddress(),
bpfLoaderUpgradeableProgram: BPF_LOADER_UPGRADEABLE_ID,
})
.instruction();
}
async registerEmitterAndDomainIx(accounts: {
payer: PublicKey;
vaa: PublicKey;
remoteTokenMessenger?: PublicKey;
}): Promise<TransactionInstruction> {
const { payer, vaa, remoteTokenMessenger: inputRemoteTokenMessenger } = accounts;
const vaaAcct = await VaaAccount.fetch(this.program.provider.connection, vaa);
const payload = vaaAcct.payload();
const registeredEmitter = this.registeredEmitterAddress(payload.readUInt16BE(35));
const remoteTokenMessenger = (() => {
if (payload.length >= 73) {
const cctpDomain = payload.readUInt32BE(69);
return this.tokenMessengerMinterProgram().remoteTokenMessengerAddress(cctpDomain);
} else if (inputRemoteTokenMessenger !== undefined) {
return inputRemoteTokenMessenger;
} else {
throw new Error("remoteTokenMessenger must be provided");
}
})();
return this.program.methods
.registerEmitterAndDomain()
.accounts({
payer,
custodian: this.custodianAddress(),
vaa,
consumedVaa: this.consumedVaaAddress(vaaAcct.digest()),
registeredEmitter,
remoteTokenMessenger,
})
.instruction();
}
async upgradeContractIx(accounts: {
payer: PublicKey;
vaa: PublicKey;
buffer?: PublicKey;
}): Promise<TransactionInstruction> {
const { payer, vaa, buffer: inputBuffer } = accounts;
const vaaAcct = await VaaAccount.fetch(this.program.provider.connection, vaa);
const payload = vaaAcct.payload();
return this.program.methods
.upgradeContract()
.accounts({
payer,
custodian: this.custodianAddress(),
vaa,
consumedVaa: this.consumedVaaAddress(vaaAcct.digest()),
upgradeAuthority: this.upgradeAuthorityAddress(),
spill: payer,
buffer: inputBuffer ?? new PublicKey(payload.subarray(-32)),
programData: this.programDataAddress(),
thisProgram: this.ID,
rent: SYSVAR_RENT_PUBKEY,
clock: SYSVAR_CLOCK_PUBKEY,
bpfLoaderUpgradeableProgram: BPF_LOADER_UPGRADEABLE_ID,
})
.instruction();
}
async transferTokensWithPayloadAccounts(
mint: PublicKey,
targetChain: number,
): Promise<TransferTokensWithPayloadAccounts> {
const registeredEmitter = this.registeredEmitterAddress(targetChain);
const remoteDomain = await this.fetchRegisteredEmitter(registeredEmitter).then(
(acct) => acct.cctpDomain,
);
const {
senderAuthority: tokenMessengerMinterSenderAuthority,
messageTransmitterConfig,
tokenMessenger,
remoteTokenMessenger,
tokenMinter,
localToken,
tokenMessengerMinterEventAuthority,
messageTransmitterProgram,
tokenMessengerMinterProgram,
} = this.tokenMessengerMinterProgram().depositForBurnWithCallerAccounts(mint, remoteDomain);
const custodian = this.custodianAddress();
const { coreBridgeConfig, coreEmitterSequence, coreFeeCollector, coreBridgeProgram } =
this.publishMessageAccounts(custodian);
return {
custodian,
custodyToken: this.custodyTokenAccountAddress(),
registeredEmitter,
coreBridgeConfig,
coreEmitterSequence,
coreFeeCollector,
tokenMessengerMinterSenderAuthority,
messageTransmitterConfig,
tokenMessenger,
remoteTokenMessenger,
tokenMinter,
localToken,
tokenMessengerMinterEventAuthority,
coreBridgeProgram,
tokenMessengerMinterProgram,
messageTransmitterProgram,
};
}
async transferTokensWithPayloadIx(
accounts: {
payer: PublicKey;
mint: PublicKey;
burnSource: PublicKey;
coreMessage: PublicKey;
cctpMessage: PublicKey;
},
args: TransferTokensWithPayloadArgs,
): Promise<TransactionInstruction> {
let { payer, burnSource, mint, coreMessage, cctpMessage } = accounts;
const { amount, targetChain, mintRecipient, wormholeMessageNonce, payload } = args;
const {
custodian,
custodyToken,
registeredEmitter,
coreBridgeConfig,
coreEmitterSequence,
coreFeeCollector,
coreBridgeProgram,
tokenMessengerMinterSenderAuthority,
messageTransmitterConfig,
tokenMessenger,
remoteTokenMessenger,
tokenMinter,
localToken,
tokenMessengerMinterEventAuthority,
tokenMessengerMinterProgram,
messageTransmitterProgram,
} = await this.transferTokensWithPayloadAccounts(mint, targetChain);
return this.program.methods
.transferTokensWithPayload({
amount: new BN(amount.toString()),
mintRecipient,
wormholeMessageNonce,
payload,
})
.accounts({
payer,
custodian,
mint,
burnSource,
custodyToken,
registeredEmitter,
coreBridgeConfig,
coreMessage,
cctpMessage,
coreEmitterSequence,
coreFeeCollector,
tokenMessengerMinterSenderAuthority,
messageTransmitterConfig,
tokenMessenger,
remoteTokenMessenger,
tokenMinter,
localToken,
tokenMessengerMinterEventAuthority,
coreBridgeProgram,
tokenMessengerMinterProgram,
messageTransmitterProgram,
})
.instruction();
}
async redeemTokensWithPayloadAccounts(
vaa: PublicKey,
circleMessage: CctpTokenBurnMessage | Buffer,
): Promise<RedeemTokensWithPayloadAccounts> {
const msg = CctpTokenBurnMessage.from(circleMessage);
const mintRecipient = new PublicKey(msg.mintRecipient);
const [mint, mintRecipientAuthority] = await splToken
.getAccount(this.program.provider.connection, mintRecipient)
.then((token) => [token.mint, token.owner]);
// Determine claim PDA.
const vaaAcct = await VaaAccount.fetch(this.program.provider.connection, vaa);
const { chain } = vaaAcct.emitterInfo();
const {
authority: messageTransmitterAuthority,
messageTransmitterConfig,
usedNonces,
tokenMessengerMinterProgram,
messageTransmitterEventAuthority,
messageTransmitterProgram,
tokenMessenger,
remoteTokenMessenger,
tokenMinter,
localToken,
tokenPair,
custodyToken: tokenMessengerMinterCustodyToken,
eventAuthority: tokenMessengerMinterEventAuthority,
} = this.messageTransmitterProgram().receiveTokenMessengerMinterMessageAccounts(mint, msg);
return {
custodian: this.custodianAddress(),
consumedVaa: this.consumedVaaAddress(vaaAcct.digest()),
mintRecipientAuthority,
mintRecipient,
registeredEmitter: this.registeredEmitterAddress(chain),
messageTransmitterAuthority,
messageTransmitterConfig,
usedNonces,
messageTransmitterEventAuthority,
tokenMessenger,
remoteTokenMessenger,
tokenMinter,
localToken,
tokenPair,
tokenMessengerMinterCustodyToken,
tokenMessengerMinterEventAuthority,
tokenMessengerMinterProgram,
messageTransmitterProgram,
};
}
async redeemTokensWithPayloadIx(
accounts: {
payer: PublicKey;
vaa: PublicKey;
mintRecipientAuthority?: PublicKey;
},
args: {
encodedCctpMessage: Buffer;
cctpAttestation: Buffer;
},
): Promise<TransactionInstruction> {
const { payer, vaa, mintRecipientAuthority: inputMintRecipientAuthority } = accounts;
const { encodedCctpMessage } = args;
const {
custodian,
consumedVaa,
mintRecipientAuthority,
mintRecipient,
registeredEmitter,
messageTransmitterAuthority,
messageTransmitterConfig,
usedNonces,
messageTransmitterEventAuthority,
tokenMessenger,
remoteTokenMessenger,
tokenMinter,
localToken,
tokenPair,
tokenMessengerMinterCustodyToken,
tokenMessengerMinterEventAuthority,
tokenMessengerMinterProgram,
messageTransmitterProgram,
} = await this.redeemTokensWithPayloadAccounts(vaa, encodedCctpMessage);
return this.program.methods
.redeemTokensWithPayload(args)
.accounts({
payer,
custodian,
vaa,
consumedVaa,
mintRecipientAuthority: inputMintRecipientAuthority ?? mintRecipientAuthority,
mintRecipient,
registeredEmitter,
messageTransmitterAuthority,
messageTransmitterConfig,
usedNonces,
messageTransmitterEventAuthority,
tokenMessenger,
remoteTokenMessenger,
tokenMinter,
localToken,
tokenPair,
tokenMessengerMinterCustodyToken,
tokenMessengerMinterEventAuthority,
tokenMessengerMinterProgram,
messageTransmitterProgram,
})
.instruction();
}
tokenMessengerMinterProgram(): TokenMessengerMinterProgram {
switch (this._programId) {
case testnet(): {
return new TokenMessengerMinterProgram(
this.program.provider.connection,
"CCTPiPYPc6AsJuwueEnWgSgucamXDZwBd53dQ11YiKX3",
);
}
case mainnet(): {
return new TokenMessengerMinterProgram(
this.program.provider.connection,
"CCTPiPYPc6AsJuwueEnWgSgucamXDZwBd53dQ11YiKX3",
);
}
default: {
throw new Error("unsupported network");
}
}
}
messageTransmitterProgram(): MessageTransmitterProgram {
switch (this._programId) {
case testnet(): {
return new MessageTransmitterProgram(
this.program.provider.connection,
"CCTPmbSD7gX1bxKPAmg77w8oFzNFpaQiQUWD43TKaecd",
);
}
case mainnet(): {
return new MessageTransmitterProgram(
this.program.provider.connection,
"CCTPmbSD7gX1bxKPAmg77w8oFzNFpaQiQUWD43TKaecd",
);
}
default: {
throw new Error("unsupported network");
}
}
}
publishMessageAccounts(emitter: PublicKey): PublishMessageAccounts {
const coreBridgeProgram = this.coreBridgeProgramId();
return {
coreBridgeConfig: PublicKey.findProgramAddressSync(
[Buffer.from("Bridge")],
coreBridgeProgram,
)[0],
coreEmitterSequence: PublicKey.findProgramAddressSync(
[Buffer.from("Sequence"), emitter.toBuffer()],
coreBridgeProgram,
)[0],
coreFeeCollector: PublicKey.findProgramAddressSync(
[Buffer.from("fee_collector")],
coreBridgeProgram,
)[0],
coreBridgeProgram,
};
}
coreBridgeProgramId(): PublicKey {
switch (this._programId) {
case testnet(): {
return new PublicKey("3u8hJUVTA4jH1wYAyUur7FFZVQ8H635K3tSHHF4ssjQ5");
}
case mainnet(): {
return new PublicKey("worm2ZoG2kUd4vFXhvjh93UUH596ayRfgQ2MgjNMTth");
}
default: {
throw new Error("unsupported network");
}
}
}
}
export function mainnet(): ProgramId {
return "Wormho1eCirc1e1ntegration111111111111111111";
}
export function testnet(): ProgramId {
return "wcihrWf1s91vfukW7LW8ZvR1rzpeZ9BrtZ8oyPkWK5d";
}

89
solana/ts/src/messages.ts Normal file
View File

@ -0,0 +1,89 @@
import { ethers } from "ethers";
export type DepositHeader = {
tokenAddress: Array<number>;
amount: bigint;
sourceCctpDomain: number;
destinationCctpDomain: number;
cctpNonce: bigint;
burnSource: Array<number>;
mintRecipient: Array<number>;
payloadLen: number;
};
export class Deposit {
deposit: DepositHeader;
payload: Buffer;
constructor(deposit: DepositHeader, payload: Buffer) {
this.deposit = deposit;
this.payload = payload;
}
static decode(buf: Buffer): Deposit {
if (buf.readUInt8(0) != 1) {
throw new Error("Invalid Wormhole CCTP deposit message");
}
buf = buf.subarray(1);
const tokenAddress = Array.from(buf.subarray(0, 32));
const amount = BigInt(ethers.BigNumber.from(buf.subarray(32, 64)).toString());
const sourceCctpDomain = buf.readUInt32BE(64);
const destinationCctpDomain = buf.readUInt32BE(68);
const cctpNonce = buf.readBigUint64BE(72);
const burnSource = Array.from(buf.subarray(80, 112));
const mintRecipient = Array.from(buf.subarray(112, 144));
const payloadLen = buf.readUInt16BE(144);
const payload = buf.subarray(146, 146 + payloadLen);
return new Deposit(
{
tokenAddress,
amount,
sourceCctpDomain,
destinationCctpDomain,
cctpNonce,
burnSource,
mintRecipient,
payloadLen,
},
payload,
);
}
encode(): Buffer {
const buf = Buffer.alloc(146);
const { deposit, payload } = this;
const {
tokenAddress,
amount,
sourceCctpDomain,
destinationCctpDomain,
cctpNonce,
burnSource,
mintRecipient,
payloadLen,
} = deposit;
let offset = 0;
buf.set(tokenAddress, offset);
offset += 32;
// Special handling w/ uint256. This value will most likely encoded in < 32 bytes, so we
// jump ahead by 32 and subtract the length of the encoded value.
const encodedAmount = ethers.utils.arrayify(ethers.BigNumber.from(amount.toString()));
buf.set(encodedAmount, (offset += 32) - encodedAmount.length);
offset = buf.writeUInt32BE(sourceCctpDomain, offset);
offset = buf.writeUInt32BE(destinationCctpDomain, offset);
offset = buf.writeBigUInt64BE(cctpNonce, offset);
buf.set(burnSource, offset);
offset += 32;
buf.set(mintRecipient, offset);
offset += 32;
offset = buf.writeUInt16BE(payloadLen, offset);
return Buffer.concat([Buffer.alloc(1, 1), buf, payload]);
}
}

View File

@ -0,0 +1,10 @@
import { PublicKey } from "@solana/web3.js";
export class ConsumedVaa {
static address(programId: PublicKey, vaaHash: Array<number> | Uint8Array): PublicKey {
return PublicKey.findProgramAddressSync(
[Buffer.from("consumed-vaa"), Buffer.from(vaaHash)],
new PublicKey(programId),
)[0];
}
}

View File

@ -0,0 +1,15 @@
import { PublicKey } from "@solana/web3.js";
export class Custodian {
bump: number;
upgradeAuthorityBump: number;
constructor(bump: number, upgradeAuthorityBump: number) {
this.bump = bump;
this.upgradeAuthorityBump = upgradeAuthorityBump;
}
static address(programId: PublicKey): PublicKey {
return PublicKey.findProgramAddressSync([Buffer.from("emitter")], programId)[0];
}
}

View File

@ -0,0 +1,24 @@
import { PublicKey } from "@solana/web3.js";
export class RegisteredEmitter {
bump: number;
cctpDomain: number;
chain: number;
address: Array<number>;
constructor(bump: number, cctpDomain: number, chain: number, address: Array<number>) {
this.bump = bump;
this.cctpDomain = cctpDomain;
this.chain = chain;
this.address = address;
}
static address(programId: PublicKey, chain: number): PublicKey {
const encodedChain = Buffer.alloc(2);
encodedChain.writeUInt16BE(chain, 0);
return PublicKey.findProgramAddressSync(
[Buffer.from("registered_emitter"), encodedChain],
programId,
)[0];
}
}

View File

@ -0,0 +1,3 @@
export * from "./ConsumedVaa";
export * from "./Custodian";
export * from "./RegisteredEmitter";

View File

@ -0,0 +1,177 @@
import { parseVaa } from "@certusone/wormhole-sdk";
import { Connection, PublicKey } from "@solana/web3.js";
import { ethers } from "ethers";
export type EncodedVaa = {
status: number;
writeAuthority: PublicKey;
version: number;
buf: Buffer;
};
export type PostedVaaV1 = {
consistencyLevel: number;
timestamp: number;
signatureSet: PublicKey;
guardianSetIndex: number;
nonce: number;
sequence: bigint;
emitterChain: number;
emitterAddress: Array<number>;
payload: Buffer;
};
export type EmitterInfo = {
chain: number;
address: Array<number>;
sequence: bigint;
};
export class VaaAccount {
private _encodedVaa?: EncodedVaa;
private _postedVaaV1?: PostedVaaV1;
static async fetch(connection: Connection, addr: PublicKey): Promise<VaaAccount> {
const accInfo = await connection.getAccountInfo(addr);
if (accInfo === null) {
throw new Error("no VAA account info found");
}
const { data } = accInfo;
let offset = 0;
const disc = data.subarray(offset, (offset += 8));
if (disc.equals(Uint8Array.from([226, 101, 163, 4, 133, 160, 84, 245]))) {
const status = data[offset];
offset += 1;
const writeAuthority = new PublicKey(data.subarray(offset, (offset += 32)));
const version = data[offset];
offset += 1;
const bufLen = data.readUInt32LE(offset);
offset += 4;
const buf = data.subarray(offset, (offset += bufLen));
return new VaaAccount({ encodedVaa: { status, writeAuthority, version, buf } });
} else if (disc.subarray(0, (offset -= 4)).equals(Uint8Array.from([118, 97, 97, 1]))) {
const consistencyLevel = data[offset];
offset += 1;
const timestamp = data.readUInt32LE(offset);
offset += 4;
const signatureSet = new PublicKey(data.subarray(offset, (offset += 32)));
const guardianSetIndex = data.readUInt32LE(offset);
offset += 4;
const nonce = data.readUInt32LE(offset);
offset += 4;
const sequence = data.readBigUInt64LE(offset);
offset += 8;
const emitterChain = data.readUInt16LE(offset);
offset += 2;
const emitterAddress = Array.from(data.subarray(offset, (offset += 32)));
const payloadLen = data.readUInt32LE(offset);
offset += 4;
const payload = data.subarray(offset, (offset += payloadLen));
return new VaaAccount({
postedVaaV1: {
consistencyLevel,
timestamp,
signatureSet,
guardianSetIndex,
nonce,
sequence,
emitterChain,
emitterAddress,
payload,
},
});
} else {
throw new Error("invalid VAA account data");
}
}
emitterInfo(): EmitterInfo {
if (this._encodedVaa !== undefined) {
const parsed = parseVaa(this._encodedVaa.buf);
return {
chain: parsed.emitterChain,
address: Array.from(parsed.emitterAddress),
sequence: parsed.sequence,
};
} else if (this._postedVaaV1 !== undefined) {
const { emitterChain: chain, emitterAddress: address, sequence } = this._postedVaaV1;
return {
chain,
address,
sequence,
};
} else {
throw new Error("impossible: emitterInfo() failed");
}
}
payload(): Buffer {
if (this._encodedVaa !== undefined) {
return parseVaa(this._encodedVaa.buf).payload;
} else if (this._postedVaaV1 !== undefined) {
return this._postedVaaV1.payload;
} else {
throw new Error("impossible: payload() failed");
}
}
digest(): Uint8Array {
if (this._encodedVaa !== undefined) {
return ethers.utils.arrayify(
ethers.utils.keccak256(parseVaa(this._encodedVaa.buf).hash),
);
} else if (this._postedVaaV1 !== undefined) {
const {
consistencyLevel,
timestamp,
nonce,
sequence,
emitterChain,
emitterAddress,
payload,
} = this._postedVaaV1;
let offset = 0;
const buf = Buffer.alloc(51 + payload.length);
offset = buf.writeUInt32BE(timestamp, offset);
offset = buf.writeUInt32BE(nonce, offset);
offset = buf.writeUInt16BE(emitterChain, offset);
buf.set(emitterAddress, offset);
offset += 32;
offset = buf.writeBigUInt64BE(sequence, offset);
offset = buf.writeUInt8(consistencyLevel, offset);
buf.set(payload, offset);
return ethers.utils.arrayify(ethers.utils.keccak256(ethers.utils.keccak256(buf)));
} else {
throw new Error("impossible: digest() failed");
}
}
get encodedVaa(): EncodedVaa {
if (this._encodedVaa === undefined) {
throw new Error("VaaAccount does not have encodedVaa");
}
return this._encodedVaa;
}
get postedVaaV1(): PostedVaaV1 {
if (this._postedVaaV1 === undefined) {
throw new Error("VaaAccount does not have postedVaaV1");
}
return this._postedVaaV1;
}
private constructor(data: { encodedVaa?: EncodedVaa; postedVaaV1?: PostedVaaV1 }) {
const { encodedVaa, postedVaaV1 } = data;
if (encodedVaa !== undefined && postedVaaV1 !== undefined) {
throw new Error("VaaAccount cannot have both encodedVaa and postedVaaV1");
}
this._encodedVaa = encodedVaa;
this._postedVaaV1 = postedVaaV1;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,262 @@
import { MockEmitter, MockGuardians } from "@certusone/wormhole-sdk/lib/cjs/mock";
import * as anchor from "@coral-xyz/anchor";
import { expect } from "chai";
import { CircleIntegrationProgram } from "../src";
import {
GUARDIAN_KEY,
PAYER_PRIVATE_KEY,
expectIxErr,
expectIxOk,
loadProgramBpf,
postGovVaa,
} from "./helpers";
const WORMHOLE_CORE_BRIDGE_ADDRESS = new anchor.web3.PublicKey(
"3u8hJUVTA4jH1wYAyUur7FFZVQ8H635K3tSHHF4ssjQ5",
);
const ARTIFACTS_PATH = `${__dirname}/artifacts/testnet_wormhole_circle_integration_solana.so`;
const guardians = new MockGuardians(0, [GUARDIAN_KEY]);
describe("Circle Integration -- Testnet Fork", () => {
const connection = new anchor.web3.Connection("http://localhost:8899", "processed");
const payer = anchor.web3.Keypair.fromSecretKey(PAYER_PRIVATE_KEY);
const circleIntegration = new CircleIntegrationProgram(
connection,
"wcihrWf1s91vfukW7LW8ZvR1rzpeZ9BrtZ8oyPkWK5d",
);
describe("Upgrade Contract", () => {
const localVariables = new Map<string, any>();
it("Deploy Implementation", async () => {
const implementation = await loadProgramBpf(
ARTIFACTS_PATH,
circleIntegration.upgradeAuthorityAddress(),
);
localVariables.set("implementation", implementation);
});
it("Invoke `upgrade_contract` on Forked Circle Integration", async () => {
const implementation = localVariables.get("implementation") as anchor.web3.PublicKey;
expect(localVariables.delete("implementation")).is.true;
const vaa = await postGovVaa(
connection,
payer,
guardians,
0n,
{
upgradeContract: {
targetChain: 1,
implementation,
},
},
{
coreBridgeAddress: WORMHOLE_CORE_BRIDGE_ADDRESS,
},
);
const ix = await circleIntegration.upgradeContractIx({
payer: payer.publicKey,
vaa,
});
await expectIxOk(connection, [ix], [payer]);
});
it("Deploy Same Implementation and Invoke `upgrade_contract` with Another VAA", async () => {
const implementation = await loadProgramBpf(
ARTIFACTS_PATH,
circleIntegration.upgradeAuthorityAddress(),
);
const vaa = await postGovVaa(
connection,
payer,
guardians,
1n,
{
upgradeContract: {
targetChain: 1,
implementation,
},
},
{
coreBridgeAddress: WORMHOLE_CORE_BRIDGE_ADDRESS,
},
);
const ix = await circleIntegration.upgradeContractIx({
payer: payer.publicKey,
vaa,
});
await expectIxOk(connection, [ix], [payer]);
// Save for later.
localVariables.set("vaa", vaa);
});
it("Cannot Invoke `upgrade_contract` with Same VAA", async () => {
const vaa = localVariables.get("vaa") as anchor.web3.PublicKey;
expect(localVariables.delete("vaa")).is.true;
const ix = await circleIntegration.upgradeContractIx({
payer: payer.publicKey,
vaa,
});
// NOTE: The claim account created in the upgrade contract instruction doesn't trigger
// the protection for a replay attack. The account data in the program data does. But
// we will keep this test here just in case something changes in the future.
await expectIxErr(connection, [ix], [payer], "invalid account data for instruction");
});
it("Cannot Invoke `upgrade_contract` with Implementation Mismatch", async () => {
const implementation = await loadProgramBpf(
ARTIFACTS_PATH,
circleIntegration.upgradeAuthorityAddress(),
);
const anotherImplementation = await loadProgramBpf(
ARTIFACTS_PATH,
circleIntegration.upgradeAuthorityAddress(),
);
const vaa = await postGovVaa(
connection,
payer,
guardians,
0n,
{
upgradeContract: {
targetChain: 1,
implementation: anotherImplementation,
},
},
{
coreBridgeAddress: WORMHOLE_CORE_BRIDGE_ADDRESS,
},
);
// Create the upgrade instruction, but pass a different implementation.
const ix = await circleIntegration.upgradeContractIx({
payer: payer.publicKey,
vaa,
buffer: implementation,
});
await expectIxErr(connection, [ix], [payer], "Error Code: ImplementationMismatch");
});
it("Cannot Invoke `upgrade_contract` with Invalid Governance Emitter", async () => {
const implementation = await loadProgramBpf(
ARTIFACTS_PATH,
circleIntegration.upgradeAuthorityAddress(),
);
// Create a bad governance emitter by using an invalid address.
const invalidEmitter = new MockEmitter(
circleIntegration.ID.toBuffer().toString("hex"),
1,
12121212,
);
const vaa = await postGovVaa(
connection,
payer,
guardians,
2n,
{
upgradeContract: {
targetChain: 1,
implementation,
},
},
{
coreBridgeAddress: WORMHOLE_CORE_BRIDGE_ADDRESS,
governanceEmitter: invalidEmitter,
},
);
// Create the upgrade instruction, but pass a different implementation.
const ix = await circleIntegration.upgradeContractIx({
payer: payer.publicKey,
vaa,
buffer: implementation,
});
await expectIxErr(connection, [ix], [payer], "Error Code: InvalidGovernanceEmitter");
});
it("Cannot Invoke `upgrade_contract` with Governance For Another Chain", async () => {
const implementation = await loadProgramBpf(
ARTIFACTS_PATH,
circleIntegration.upgradeAuthorityAddress(),
);
const vaa = await postGovVaa(
connection,
payer,
guardians,
2n,
{
upgradeContract: {
targetChain: 2,
implementation,
},
},
{
coreBridgeAddress: WORMHOLE_CORE_BRIDGE_ADDRESS,
},
);
const ix = await circleIntegration.upgradeContractIx({
payer: payer.publicKey,
vaa,
});
await expectIxErr(connection, [ix], [payer], "Error Code: GovernanceForAnotherChain");
});
it("Cannot Invoke `upgrade_contract` with Invalid Governance Action", async () => {
const implementation = await loadProgramBpf(
ARTIFACTS_PATH,
circleIntegration.upgradeAuthorityAddress(),
);
const vaa = await postGovVaa(
connection,
payer,
guardians,
2n,
{
registerEmitterAndDomain: {
targetChain: 1,
foreignChain: 2,
foreignEmitter: Array.from(
Buffer.from(
"000000000000000000000000deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
"hex",
),
),
cctpDomain: 0,
},
},
{
coreBridgeAddress: WORMHOLE_CORE_BRIDGE_ADDRESS,
},
);
const ix = await circleIntegration.upgradeContractIx({
payer: payer.publicKey,
vaa,
buffer: implementation,
});
await expectIxErr(connection, [ix], [payer], "Error Code: InvalidGovernanceAction");
});
});
});

View File

@ -0,0 +1,14 @@
{
"pubkey": "2yVjuQwpsvdsrywzsJJVs9Ueh4zayyo5DYJbBNc3DDpn",
"account": {
"lamports": 1057920,
"data": [
"AAAAAMbrG4wAAAAAgFEBAAoAAAAAAAAA",
"base64"
],
"owner": "worm2ZoG2kUd4vFXhvjh93UUH596ayRfgQ2MgjNMTth",
"executable": false,
"rentEpoch": 18446744073709551615,
"space": 24
}
}

View File

@ -0,0 +1,14 @@
{
"pubkey": "9bFNrXNb2WTx8fMHXCheaZqkLZ3YCCaiqTftHxeintHy",
"account": {
"lamports": 2350640070,
"data": [
"",
"base64"
],
"owner": "11111111111111111111111111111111",
"executable": false,
"rentEpoch": 18446744073709551615,
"space": 0
}
}

View File

@ -0,0 +1,14 @@
{
"pubkey": "DS7qfSAgYsonPpKoAjcGhX9VFjXdGkiHjEDkTidf8H2P",
"account": {
"lamports": 21141440,
"data": [
"AAAAAAEAAAC++kKdV80Yt/ik2RotqatK8F0PvkPJm2EAAAAA",
"base64"
],
"owner": "worm2ZoG2kUd4vFXhvjh93UUH596ayRfgQ2MgjNMTth",
"executable": false,
"rentEpoch": 18446744073709551615,
"space": 36
}
}

View File

@ -0,0 +1,14 @@
{
"pubkey": "6bi4JGDoRwUs9TYBuvoA7dUVyikTJDrJsJU1ew6KVLiu",
"account": {
"lamports": 1057920,
"data": [
"AAAAAMbrG4wAAAAAgFEBAAoAAAAAAAAA",
"base64"
],
"owner": "3u8hJUVTA4jH1wYAyUur7FFZVQ8H635K3tSHHF4ssjQ5",
"executable": false,
"rentEpoch": 18446744073709551615,
"space": 24
}
}

View File

@ -0,0 +1,14 @@
{
"pubkey": "7s3a1ycs16d6SNDumaRtjcoyMaTDZPavzgsmS3uUZYWX",
"account": {
"lamports": 2350640070,
"data": [
"",
"base64"
],
"owner": "11111111111111111111111111111111",
"executable": false,
"rentEpoch": 18446744073709551615,
"space": 0
}
}

View File

@ -0,0 +1,14 @@
{
"pubkey": "dxZtypiKT5D9LYzdPxjvSZER9MgYfeRVU5qpMTMTRs4",
"account": {
"lamports": 21141440,
"data": [
"AAAAAAEAAAC++kKdV80Yt/ik2RotqatK8F0PvkPJm2EAAAAA",
"base64"
],
"owner": "3u8hJUVTA4jH1wYAyUur7FFZVQ8H635K3tSHHF4ssjQ5",
"executable": false,
"rentEpoch": 18446744073709551615,
"space": 36
}
}

View File

@ -0,0 +1,14 @@
{
"pubkey": "BWrwSWjbikT3H7qHAkUEbLmwDQoB4ZDJ4wcSEhSPTZCu",
"account": {
"lamports": 2519520,
"data": [
"Ryi0jhPLI/wfOQgPIIpMNon4r0rVMO7Sy1fUtUxQmdUxE51OObvhOoDFz5C0iApooK3hQfndo8m3eRHbcLcd6T35aIm/9s3sgMXPkLSICmigreFB+d2jybd5Edtwtx3pPfloib/2zeyAxc+QtIgKaKCt4UH53aPJt3kR23C3Hek9+WiJv/bN7AAFAAAAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAvvpCnVfNGLf4pNkaLamrSvBdD77QBwAAAAAAACYAAAAAAAAA/w==",
"base64"
],
"owner": "CCTPmbSD7gX1bxKPAmg77w8oFzNFpaQiQUWD43TKaecd",
"executable": false,
"rentEpoch": 18446744073709551615,
"space": 234
}
}

View File

@ -0,0 +1,14 @@
{
"pubkey": "Hazwi3jFQtLKc2ughi7HFXPkpDeso7DQaMR9Ks4afh3j",
"account": {
"lamports": 1197120,
"data": [
"aXOuIl/pivwAAAAAAAAAAAAAAAAAAAAA0MPaWPVTWBQrjT4GwcMMXGEU7+g=",
"base64"
],
"owner": "CCTPiPYPc6AsJuwueEnWgSgucamXDZwBd53dQ11YiKX3",
"executable": false,
"rentEpoch": 18446744073709551615,
"space": 44
}
}

View File

@ -0,0 +1,14 @@
{
"pubkey": "BWyFzH6LsnmDAaDWbGsriQ9SiiKq1CF6pbH4Ye3kzSBV",
"account": {
"lamports": 1197120,
"data": [
"aXOuIl/pivwAAAAAAAAAAAAAAAAAAAAA0MPaWPVTWBQrjT4GwcMMXGEU7+g=",
"base64"
],
"owner": "CCTPiPYPc6AsJuwueEnWgSgucamXDZwBd53dQ11YiKX3",
"executable": false,
"rentEpoch": 18446744073709551615,
"space": 44
}
}

View File

@ -0,0 +1,14 @@
{
"pubkey": "Afgq3BHEfCE7d78D2XE9Bfyu2ieDqvE24xX8KDwreBms",
"account": {
"lamports": 1649520,
"data": [
"ogTyNJPz3WCAxc+QtIgKaKCt4UH53aPJt3kR23C3Hek9+WiJv/bN7B85CA8gikw2ifivStUw7tLLV9S1TFCZ1TETnU45u+E6pl/JidtfXUJ1nzpUYFjvzc3AvzwYmActjrRd0dgFCM4AAAAA/Q==",
"base64"
],
"owner": "CCTPiPYPc6AsJuwueEnWgSgucamXDZwBd53dQ11YiKX3",
"executable": false,
"rentEpoch": 18446744073709551615,
"space": 109
}
}

View File

@ -0,0 +1,14 @@
{
"pubkey": "DBD8hAwLDRQkTsu6EqviaYNGKPnsAMmQonxf7AH8ZcFY",
"account": {
"lamports": 1405920,
"data": [
"eoVUPzmfq86Axc+QtIgKaKCt4UH53aPJt3kR23C3Hek9+WiJv/bN7IDFz5C0iApooK3hQfndo8m3eRHbcLcd6T35aIm/9s3sAP0=",
"base64"
],
"owner": "CCTPiPYPc6AsJuwueEnWgSgucamXDZwBd53dQ11YiKX3",
"executable": false,
"rentEpoch": 18446744073709551615,
"space": 74
}
}

View File

@ -0,0 +1,14 @@
{
"pubkey": "AEfKU8wHGtYgsXpymQ6e1cGHJJeKqCj95pw82iyRUKEs",
"account": {
"lamports": 2039280,
"data": [
"O0Qss5EhV/E6kz0BNCgtAytf/s0Botvxt3kGCN8ALqe06cUFm0yAK1JhEHKELuHdBTqROz8nrg0RaRNBnQEQ50g9+D6VVAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"base64"
],
"owner": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
"executable": false,
"rentEpoch": 18446744073709551615,
"space": 165
}
}

View File

@ -0,0 +1,14 @@
{
"pubkey": "4xt9P42CcMHXAgvemTnzineHp6owfGUcrg1xD9V7mdk1",
"account": {
"lamports": 1795680,
"data": [
"n4M6qsFUgLaJOQ9DWBY8Pr5GkgAm5md6yYsX7D3P0LYdBflhsqtwJDtELLORIVfxOpM9ATQoLQMrX/7NAaLb8bd5BgjfAC6nABCl1OgAAAABAAAAAAAAAAEAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAD//w==",
"base64"
],
"owner": "CCTPiPYPc6AsJuwueEnWgSgucamXDZwBd53dQ11YiKX3",
"executable": false,
"rentEpoch": 18446744073709551615,
"space": 130
}
}

View File

@ -0,0 +1,14 @@
{
"pubkey": "ADcG1d7znq6wR73BJgEh7dR4vTJcETLLyfXMNZjJVwk4",
"account": {
"lamports": 1426800,
"data": [
"EdYtsOWVxUcAAAAAAAAAAAAAAAAAAAAAB4Zcboe59wJVN34CSs5mMMHqo38649Wj+M9UXZAg4cVbgkofjwh29UJBwfc8LZK10/cIjv4=",
"base64"
],
"owner": "CCTPiPYPc6AsJuwueEnWgSgucamXDZwBd53dQ11YiKX3",
"executable": false,
"rentEpoch": 18446744073709551615,
"space": 77
}
}

Some files were not shown because too many files have changed in this diff Show More