From 9f48b4226bf09119d3d390670651a3b0a44b6348 Mon Sep 17 00:00:00 2001 From: Chris Maree Date: Fri, 20 Sep 2024 14:41:56 +0800 Subject: [PATCH] Add initial SVM-Spoke MVP from closed repo (#611) * WIP Signed-off-by: chrismaree * WIP Signed-off-by: chrismaree * WIP Signed-off-by: chrismaree * WIP Signed-off-by: chrismaree * WIP Signed-off-by: chrismaree * WIP Signed-off-by: chrismaree * WIP Signed-off-by: chrismaree * WIP Signed-off-by: chrismaree * Update Anchor.toml Co-authored-by: Reinis Martinsons <77973553+Reinis-FRP@users.noreply.github.com> * Update scripts/svm/closeRelayerPdas.ts Co-authored-by: Reinis Martinsons <77973553+Reinis-FRP@users.noreply.github.com> * Update scripts/svm/remotePauseDeposits.ts Co-authored-by: Reinis Martinsons <77973553+Reinis-FRP@users.noreply.github.com> * Update scripts/svm/initialize.ts Co-authored-by: Reinis Martinsons <77973553+Reinis-FRP@users.noreply.github.com> * Update scripts/svm/enableRoute.ts Co-authored-by: Reinis Martinsons <77973553+Reinis-FRP@users.noreply.github.com> * Update scripts/svm/queryState.ts Co-authored-by: Reinis Martinsons <77973553+Reinis-FRP@users.noreply.github.com> * Update scripts/svm/queryDeposits.ts Co-authored-by: Reinis Martinsons <77973553+Reinis-FRP@users.noreply.github.com> * Update scripts/svm/remotePauseDeposits.ts Co-authored-by: Reinis Martinsons <77973553+Reinis-FRP@users.noreply.github.com> * Update scripts/svm/queryFills.ts Co-authored-by: Reinis Martinsons <77973553+Reinis-FRP@users.noreply.github.com> * Update scripts/svm/simpleDeposit.ts Co-authored-by: Reinis Martinsons <77973553+Reinis-FRP@users.noreply.github.com> * Update scripts/svm/simpleFill.ts Co-authored-by: Reinis Martinsons <77973553+Reinis-FRP@users.noreply.github.com> * Update scripts/svm/queryRoute.ts Co-authored-by: Reinis Martinsons <77973553+Reinis-FRP@users.noreply.github.com> * Update scripts/svm/remotePauseDeposits.ts Co-authored-by: Reinis Martinsons <77973553+Reinis-FRP@users.noreply.github.com> --------- Signed-off-by: chrismaree Co-authored-by: Reinis Martinsons <77973553+Reinis-FRP@users.noreply.github.com> --- .github/workflows/pr.yml | 2 +- .gitignore | 1 + .prettierignore | 3 +- Anchor.toml | 52 +- Cargo.lock | 991 ++++++++++++++++-- package.json | 17 +- programs/{testacross => svm-spoke}/Cargo.toml | 11 +- programs/{testacross => svm-spoke}/Xargo.toml | 0 programs/svm-spoke/src/constants.rs | 6 + programs/svm-spoke/src/constraints.rs | 19 + programs/svm-spoke/src/error.rs | 72 ++ programs/svm-spoke/src/instructions/admin.rs | 258 +++++ programs/svm-spoke/src/instructions/bundle.rs | 166 +++ .../svm-spoke/src/instructions/deposit.rs | 126 +++ programs/svm-spoke/src/instructions/fill.rs | 257 +++++ .../instructions/handle_receive_message.rs | 130 +++ programs/svm-spoke/src/instructions/mod.rs | 17 + .../svm-spoke/src/instructions/slow_fill.rs | 274 +++++ .../svm-spoke/src/instructions/testable.rs | 19 + .../src/instructions/token_bridge.rs | 124 +++ programs/svm-spoke/src/lib.rs | 188 ++++ programs/svm-spoke/src/state/fill.rs | 15 + programs/svm-spoke/src/state/mod.rs | 11 + programs/svm-spoke/src/state/root_bundle.rs | 10 + programs/svm-spoke/src/state/route.rs | 7 + programs/svm-spoke/src/state/state.rs | 16 + .../svm-spoke/src/state/transfer_liability.rs | 7 + programs/svm-spoke/src/utils.rs | 153 +++ programs/test/Cargo.toml | 22 + programs/test/Xargo.toml | 2 + programs/test/src/lib.rs | 91 ++ programs/testacross/src/lib.rs | 16 - rustfmt.toml | 1 + scripts/svm/closeRelayerPdas.ts | 129 +++ scripts/svm/enableRoute.ts | 83 ++ scripts/svm/generateExternalTypes.ts | 88 ++ scripts/svm/initialize.ts | 75 ++ scripts/svm/queryDeposits.ts | 73 ++ scripts/svm/queryFills.ts | 80 ++ scripts/svm/queryRoute.ts | 80 ++ scripts/svm/queryState.ts | 53 + scripts/svm/remotePauseDeposits.ts | 231 ++++ scripts/svm/simpleDeposit.ts | 112 ++ scripts/svm/simpleFill.ts | 141 +++ src/SvmUtils.ts | 152 +++ test/svm/SvmSpoke.Bundle.ts | 729 +++++++++++++ test/svm/SvmSpoke.Deposit.ts | 215 ++++ test/svm/SvmSpoke.Fill.ts | 271 +++++ test/svm/SvmSpoke.HandleReceiveMessage.ts | 426 ++++++++ test/svm/SvmSpoke.Ownership.ts | 157 +++ test/svm/SvmSpoke.Routes.ts | 106 ++ test/svm/SvmSpoke.SlowFill.ts | 384 +++++++ test/svm/SvmSpoke.TokenBridge.ts | 286 +++++ test/svm/SvmSpoke.common.ts | 102 ++ test/svm/Utils.Bitmap.ts | 79 ++ test/svm/Utils.Merkle.ts | 153 +++ test/svm/accounts/message_transmitter.json | 14 + test/svm/accounts/token_minter.json | 14 + test/svm/cctpHelpers.ts | 122 +++ test/svm/keys/localnet-wallet.json | 5 + test/svm/testacross.ts | 16 - test/svm/utils.ts | 149 +++ tsconfig.json | 4 + yarn.lock | 224 +++- 64 files changed, 7700 insertions(+), 137 deletions(-) rename programs/{testacross => svm-spoke}/Cargo.toml (50%) rename programs/{testacross => svm-spoke}/Xargo.toml (100%) create mode 100644 programs/svm-spoke/src/constants.rs create mode 100644 programs/svm-spoke/src/constraints.rs create mode 100644 programs/svm-spoke/src/error.rs create mode 100644 programs/svm-spoke/src/instructions/admin.rs create mode 100644 programs/svm-spoke/src/instructions/bundle.rs create mode 100644 programs/svm-spoke/src/instructions/deposit.rs create mode 100644 programs/svm-spoke/src/instructions/fill.rs create mode 100644 programs/svm-spoke/src/instructions/handle_receive_message.rs create mode 100644 programs/svm-spoke/src/instructions/mod.rs create mode 100644 programs/svm-spoke/src/instructions/slow_fill.rs create mode 100644 programs/svm-spoke/src/instructions/testable.rs create mode 100644 programs/svm-spoke/src/instructions/token_bridge.rs create mode 100644 programs/svm-spoke/src/lib.rs create mode 100644 programs/svm-spoke/src/state/fill.rs create mode 100644 programs/svm-spoke/src/state/mod.rs create mode 100644 programs/svm-spoke/src/state/root_bundle.rs create mode 100644 programs/svm-spoke/src/state/route.rs create mode 100644 programs/svm-spoke/src/state/state.rs create mode 100644 programs/svm-spoke/src/state/transfer_liability.rs create mode 100644 programs/svm-spoke/src/utils.rs create mode 100644 programs/test/Cargo.toml create mode 100644 programs/test/Xargo.toml create mode 100644 programs/test/src/lib.rs delete mode 100644 programs/testacross/src/lib.rs create mode 100644 rustfmt.toml create mode 100644 scripts/svm/closeRelayerPdas.ts create mode 100644 scripts/svm/enableRoute.ts create mode 100644 scripts/svm/generateExternalTypes.ts create mode 100644 scripts/svm/initialize.ts create mode 100644 scripts/svm/queryDeposits.ts create mode 100644 scripts/svm/queryFills.ts create mode 100644 scripts/svm/queryRoute.ts create mode 100644 scripts/svm/queryState.ts create mode 100644 scripts/svm/remotePauseDeposits.ts create mode 100644 scripts/svm/simpleDeposit.ts create mode 100644 scripts/svm/simpleFill.ts create mode 100644 src/SvmUtils.ts create mode 100644 test/svm/SvmSpoke.Bundle.ts create mode 100644 test/svm/SvmSpoke.Deposit.ts create mode 100644 test/svm/SvmSpoke.Fill.ts create mode 100644 test/svm/SvmSpoke.HandleReceiveMessage.ts create mode 100644 test/svm/SvmSpoke.Ownership.ts create mode 100644 test/svm/SvmSpoke.Routes.ts create mode 100644 test/svm/SvmSpoke.SlowFill.ts create mode 100644 test/svm/SvmSpoke.TokenBridge.ts create mode 100644 test/svm/SvmSpoke.common.ts create mode 100644 test/svm/Utils.Bitmap.ts create mode 100644 test/svm/Utils.Merkle.ts create mode 100644 test/svm/accounts/message_transmitter.json create mode 100644 test/svm/accounts/token_minter.json create mode 100644 test/svm/cctpHelpers.ts create mode 100644 test/svm/keys/localnet-wallet.json delete mode 100644 test/svm/testacross.ts create mode 100644 test/svm/utils.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 341a4f69d..986cb4344 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -81,7 +81,7 @@ jobs: - name: Test evm-hardhat shell: bash run: yarn test-evm - - name: Test svm + - name: Test svm-anchor shell: bash run: yarn test-svm forge: diff --git a/.gitignore b/.gitignore index 436c13f66..1fc9a54dd 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ storage-layouts/proposed* target **/*.rs.bk test-ledger +idls diff --git a/.prettierignore b/.prettierignore index b1261ffab..2dfbd4509 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,4 +8,5 @@ dist artifacts-zk cache-zk lib -target \ No newline at end of file +target +idls \ No newline at end of file diff --git a/Anchor.toml b/Anchor.toml index fe96b3786..85a9e917e 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -5,14 +5,58 @@ resolution = true skip-lint = false [programs.localnet] -testacross = "E4dpZS9P24pscXvPngpeUhrR98uZYMfi3VMLnLaYDA6b" +svm_spoke = "E5USYAs9DUzn6ykrWZXuEkbCnY3kzNMPGNFH2okvUvqe" +test = "GZp7L6MZ93G7TpAyxmaJ3GYgXnxH8x5oxSDmnEoob1Zu" + +[programs.devnet] +svm_spoke = "CUnrs9pnNFDw6xAybcJfLftcutevE1f63ZJn6xt6A8f6" [registry] url = "https://api.apr.dev" [provider] -cluster = "Localnet" -wallet = "~/.config/solana/id.json" +cluster = "localnet" +wallet = "test/svm/keys/localnet-wallet.json" [scripts] -test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 test/svm/**/*.ts" +test = "anchor run generateExternalTypes && yarn run ts-mocha -p ./tsconfig.json -t 1000000 test/svm/**/*.ts" +initialize = "NODE_NO_WARNINGS=1 yarn run ts-node ./scripts/svm/initialize.ts" +queryState = "NODE_NO_WARNINGS=1 yarn run ts-node ./scripts/svm/queryState.ts" +enableRoute = "NODE_NO_WARNINGS=1 yarn run ts-node ./scripts/svm/enableRoute.ts" +queryRoute = "NODE_NO_WARNINGS=1 yarn run ts-node ./scripts/svm/queryRoute.ts" +simpleDeposit = "NODE_NO_WARNINGS=1 yarn run ts-node ./scripts/svm/simpleDeposit.ts" +queryDeposits = "NODE_NO_WARNINGS=1 yarn run ts-node ./scripts/svm/queryDeposits.ts" +queryFills = "NODE_NO_WARNINGS=1 yarn run ts-node ./scripts/svm/queryFills.ts" +simpleFill = "NODE_NO_WARNINGS=1 yarn run ts-node ./scripts/svm/simpleFill.ts" +closeRelayerPdas = "NODE_NO_WARNINGS=1 yarn run ts-node ./scripts/svm/closeRelayerPdas.ts" +remotePauseDeposits = "NODE_NO_WARNINGS=1 yarn run ts-node ./scripts/svm/remotePauseDeposits.ts" +generateExternalTypes = "NODE_NO_WARNINGS=1 yarn run ts-node ./scripts/svm/generateExternalTypes.ts" + +[test.validator] +url = "https://api.devnet.solana.com" + +### Forked Circle Message Transmitter Program +[[test.validator.clone]] +address = "CCTPmbSD7gX1bxKPAmg77w8oFzNFpaQiQUWD43TKaecd" + +### Forked Circle Token Messenger Minter Program +[[test.validator.clone]] +address = "CCTPiPYPc6AsJuwueEnWgSgucamXDZwBd53dQ11YiKX3" + +### Circle Message Transmitter PDA -- Message Transmitter Config (Modified to have 0 attesters) +[[test.validator.account]] +address = "BWrwSWjbikT3H7qHAkUEbLmwDQoB4ZDJ4wcSEhSPTZCu" +filename = "test/svm/accounts/message_transmitter.json" + +### Circle Token Messenger Minter PDA -- Token Minter (Modified with token_controller set to test wallet) +[[test.validator.account]] +address = "DBD8hAwLDRQkTsu6EqviaYNGKPnsAMmQonxf7AH8ZcFY" +filename = "test/svm/accounts/token_minter.json" + +### Circle Token Messenger Minter PDA -- Token Messenger +[[test.validator.clone]] +address = "Afgq3BHEfCE7d78D2XE9Bfyu2ieDqvE24xX8KDwreBms" + +### Circle Token Messenger Minter PDA -- Ethereum Remote Token Messenger +[[test.validator.clone]] +address = "Hazwi3jFQtLKc2ughi7HFXPkpDeso7DQaMR9Ks4afh3j" diff --git a/Cargo.lock b/Cargo.lock index 0b8a9a98a..445f2b38f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,42 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "aead" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b613b8e1e3cf911a086f53f03bf286f52fd7a7258e4fa606f0ef220d39d8877" +dependencies = [ + "generic-array", +] + +[[package]] +name = "aes" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e8b47f52ea9bae42228d07ec09eb676433d7c4ed1ebdf0f1d1c29ed446f1ab8" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", + "opaque-debug", +] + +[[package]] +name = "aes-gcm-siv" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589c637f0e68c877bbd59a4599bbe849cac8e5f3e4b5a3ebae8f528cd218dcdc" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "polyval", + "subtle", + "zeroize", +] + [[package]] name = "ahash" version = "0.7.8" @@ -167,7 +203,7 @@ dependencies = [ "borsh 0.10.3", "bytemuck", "getrandom 0.2.15", - "solana-program", + "solana-program 1.18.22", "thiserror", ] @@ -196,6 +232,21 @@ dependencies = [ "serde", ] +[[package]] +name = "anchor-spl" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04bd077c34449319a1e4e0bc21cea572960c9ae0d0fefda0dd7c52fcc3c647a3" +dependencies = [ + "anchor-lang", + "spl-associated-token-account", + "spl-pod", + "spl-token", + "spl-token-2022", + "spl-token-group-interface", + "spl-token-metadata-interface", +] + [[package]] name = "anchor-syn" version = "0.30.1" @@ -346,9 +397,26 @@ checksum = "9d151e35f61089500b617991b791fc8bfd237ae50cd5950803758a179b41e67a" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" + +[[package]] +name = "assert_matches" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" + +[[package]] +name = "atty" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] [[package]] name = "autocfg" @@ -368,6 +436,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bincode" version = "1.3.3" @@ -415,6 +489,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" dependencies = [ + "block-padding", "generic-array", ] @@ -427,6 +502,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d696c370c750c948ada61c69a0ee2cbbb9c50b1019ddb86d9317157a99c2cae" + [[package]] name = "borsh" version = "0.9.3" @@ -490,10 +571,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3ef8005764f53cd4dca619f5bf64cafd4664dada50ece25e4d81de54c80cc0b" dependencies = [ "once_cell", - "proc-macro-crate 3.2.0", + "proc-macro-crate 3.1.0", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.74", "syn_derive", ] @@ -574,22 +655,22 @@ dependencies = [ [[package]] name = "bytemuck" -version = "1.18.0" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bbb0ad554ad961ddc5da507a12a29b14e4ae5bda06b19f575a3e6079d2e2ae" +checksum = "102087e286b4677862ea56cf8fc58bb2cdfa8725c40ffb80fe3a008eb7f2fc83" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" -version = "1.7.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc8b54b395f2fcfbb3d90c47b01c7f444d94d05bdeb775811dec868ac3bbc26" +checksum = "1ee891b04274a59bd38b412188e24b849617b2e45a0fd8d057deb63e7403761b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.74", ] [[package]] @@ -610,13 +691,12 @@ dependencies = [ [[package]] name = "cc" -version = "1.1.16" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9d013ecb737093c0e86b151a7b837993cf9ec6c502946cfb44bedc392421e0b" +checksum = "e9e8aabfac534be767c909e0690571677d49f41bd8465ae876fe043d52ba5292" dependencies = [ "jobserver", "libc", - "shlex", ] [[package]] @@ -631,6 +711,24 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "num-traits", +] + +[[package]] +name = "cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" +dependencies = [ + "generic-array", +] + [[package]] name = "console_error_panic_hook" version = "0.1.7" @@ -653,9 +751,9 @@ dependencies = [ [[package]] name = "constant_time_eq" -version = "0.3.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +checksum = "f7144d30dcf0fafbce74250a3963025d8d52177934239851c917d29f1df280c2" [[package]] name = "cpufeatures" @@ -717,6 +815,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "ctr" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "049bb91fb4aaf0e3c7efa6cd5ef877dbbbd15b39dad06d9948de4ec8a75761ea" +dependencies = [ + "cipher", +] + [[package]] name = "curve25519-dalek" version = "3.2.1" @@ -731,6 +838,47 @@ dependencies = [ "zeroize", ] +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.74", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.74", +] + +[[package]] +name = "derivation-path" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e5c37193a1db1d8ed868c03ec7b152175f26160a5b740e5e484143877e0adf0" + [[package]] name = "derivative" version = "2.2.0" @@ -762,12 +910,60 @@ dependencies = [ "subtle", ] +[[package]] +name = "ed25519" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91cff35c70bba8a626e3185d8cd48cc11b5437e1a5bcd15b9b5fa3c64b6dfee7" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c762bae6dcaf24c4c84667b8579785430908723d5c889f469d76a41d59cc7a9d" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand 0.7.3", + "serde", + "sha2 0.9.9", + "zeroize", +] + +[[package]] +name = "ed25519-dalek-bip32" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d2be62a4061b872c8c0873ee4fc6f101ce7b889d039f019c5fa2af471a59908" +dependencies = [ + "derivation-path", + "ed25519-dalek", + "hmac 0.12.1", + "sha2 0.10.8", +] + [[package]] name = "either" version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "env_logger" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7" +dependencies = [ + "atty", + "humantime", + "log", + "regex", + "termcolor", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -780,6 +976,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "835a3dc7d1ec9e75e2b5fb4ba75396837112d2060b03f7d43bc1897c7f7211da" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "generic-array" version = "0.14.7" @@ -850,6 +1052,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "hmac" version = "0.8.1" @@ -860,6 +1071,15 @@ dependencies = [ "digest 0.9.0", ] +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "hmac-drbg" version = "0.3.0" @@ -868,9 +1088,21 @@ checksum = "17ea0a1394df5b6574da6e0c1ade9e78868c9fb0a4e5ef4428e32da4676b85b1" dependencies = [ "digest 0.9.0", "generic-array", - "hmac", + "hmac 0.8.1", ] +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "im" version = "15.1.0" @@ -889,9 +1121,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.5.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" +checksum = "de3fc2e30ba82dd1b3911c8de1ffc143c74a914a14e99514d7637e3099df5ea0" dependencies = [ "equivalent", "hashbrown 0.14.5", @@ -947,9 +1179,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.158" +version = "0.2.155" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" [[package]] name = "libsecp256k1" @@ -1051,6 +1283,18 @@ dependencies = [ "autocfg", ] +[[package]] +name = "merlin" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" +dependencies = [ + "byteorder", + "keccak", + "rand_core 0.6.4", + "zeroize", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1069,7 +1313,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.74", ] [[package]] @@ -1090,6 +1334,27 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_enum" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" +dependencies = [ + "num_enum_derive", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" +dependencies = [ + "proc-macro-crate 3.1.0", + "proc-macro2", + "quote", + "syn 2.0.74", +] + [[package]] name = "once_cell" version = "1.19.0" @@ -1140,6 +1405,33 @@ dependencies = [ "crypto-mac", ] +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "polyval" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8419d2b623c7c0896ff2d5d96e2cb4ede590fed28fcc34934f4c33c036e620a1" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "ppv-lite86" version = "0.2.20" @@ -1160,11 +1452,11 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.2.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" +checksum = "6d37c51ca738a55da99dc0c4a34860fd675453b8b36209178c2249bb13651284" dependencies = [ - "toml_edit", + "toml_edit 0.21.1", ] [[package]] @@ -1199,11 +1491,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "qstring" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d464fae65fff2680baf48019211ce37aaec0c78e9264c84a3e484717f965104e" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "qualifier_attr" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2e25ee72f5b24d773cae88422baddefff7714f97aab68d96fe2b6fc4a28fb2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.74", +] + [[package]] name = "quote" -version = "1.0.37" +version = "1.0.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" dependencies = [ "proc-macro2", ] @@ -1354,9 +1666,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc_version" -version = "0.4.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" dependencies = [ "semver", ] @@ -1387,9 +1699,9 @@ checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "serde" -version = "1.0.209" +version = "1.0.207" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99fce0ffe7310761ca6bf9faf5115afbc19688edd00171d81b1bb1b116c63e09" +checksum = "5665e14a49a4ea1b91029ba7d3bca9f299e1f7cfa194388ccc20f14743e784f2" dependencies = [ "serde_derive", ] @@ -1405,20 +1717,20 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.209" +version = "1.0.207" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5831b979fd7b5439637af1752d535ff49f4860c0f341d1baeb6faf0f4242170" +checksum = "6aea2634c86b0e8ef2cfdc0c340baede54ec27b1e46febd7f80dffb2aa44a00e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.74", ] [[package]] name = "serde_json" -version = "1.0.128" +version = "1.0.124" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" +checksum = "66ad62847a56b3dba58cc891acd13884b9c61138d330c0d7b6181713d4fce38d" dependencies = [ "itoa", "memchr", @@ -1435,6 +1747,28 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07ff71d2c147a7b57362cead5e22f772cd52f6ab31cfcd9edcd7f6aeb2a0afbe" +dependencies = [ + "serde", + "serde_with_macros", +] + +[[package]] +name = "serde_with_macros" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "881b6f881b17d13214e5d494c939ebab463d01264ce1811e9d4ac3a882e7695f" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.74", +] + [[package]] name = "sha2" version = "0.9.9" @@ -1459,6 +1793,18 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha3" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f81199417d4e5de3f04b1e871023acea7389672c4135918f05aa9cbf2f2fa809" +dependencies = [ + "block-buffer 0.9.0", + "digest 0.9.0", + "keccak", + "opaque-debug", +] + [[package]] name = "sha3" version = "0.10.8" @@ -1470,10 +1816,16 @@ dependencies = [ ] [[package]] -name = "shlex" -version = "1.3.0" +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" + +[[package]] +name = "siphasher" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" [[package]] name = "sized-chunks" @@ -1493,9 +1845,9 @@ checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "solana-frozen-abi" -version = "1.18.23" +version = "1.18.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bfcde2fc6946c99c7e3400fadd04d1628d675bfd66cb34d461c0f3224bd27d1" +checksum = "20a6ef2db80dceb124b7bf81cca3300804bf427d2711973fc3df450ed7dfb26d" dependencies = [ "block-buffer 0.10.4", "bs58 0.4.0", @@ -1518,21 +1870,32 @@ dependencies = [ [[package]] name = "solana-frozen-abi-macro" -version = "1.18.23" +version = "1.18.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5024d241425f4e99f112ee03bfa89e526c86c7ca9bd7e13448a7f2dffb7e060" +checksum = "70088de7d4067d19a7455609e2b393e6086bd847bb39c4d2bf234fc14827ef9e" dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.77", + "syn 2.0.74", +] + +[[package]] +name = "solana-logger" +version = "1.18.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b129da15193f26db62d62ae6bb9f72361f361bcdc36054be3ab8bc04cc7a4f31" +dependencies = [ + "env_logger", + "lazy_static", + "log", ] [[package]] name = "solana-program" -version = "1.18.23" +version = "1.18.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76056fecde0fe0ece8b457b719729c17173333471c72ad41969982975a10d6e0" +checksum = "fb2b2c8babfae4cace1a25b6efa00418f3acd852cf55d7cecc0360d3c5050479" dependencies = [ "ark-bn254", "ark-ec", @@ -1573,10 +1936,10 @@ dependencies = [ "serde_derive", "serde_json", "sha2 0.10.8", - "sha3", + "sha3 0.10.8", "solana-frozen-abi", "solana-frozen-abi-macro", - "solana-sdk-macro", + "solana-sdk-macro 1.18.22", "thiserror", "tiny-bip39", "wasm-bindgen", @@ -1584,40 +1947,411 @@ dependencies = [ ] [[package]] -name = "solana-sdk-macro" -version = "1.18.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8613ca80150f7e277e773620ba65d2c5fcc3a08eb8026627d601421ab43aef" -dependencies = [ - "bs58 0.4.0", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.77", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "1.0.109" +name = "solana-program" +version = "2.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +checksum = "70306519f79aa7699264d76d7f4fe252ab22fef3a85404a748a42f8dd750653e" dependencies = [ - "proc-macro2", + "ark-bn254", + "ark-ec", + "ark-ff", + "ark-serialize", + "base64 0.22.1", + "bincode", + "bitflags", + "blake3", + "borsh 0.10.3", + "borsh 1.5.1", + "bs58 0.5.1", + "bv", + "bytemuck", + "bytemuck_derive", + "console_error_panic_hook", + "console_log", + "curve25519-dalek", + "getrandom 0.2.15", + "js-sys", + "lazy_static", + "libsecp256k1", + "log", + "memoffset", + "num-bigint", + "num-derive", + "num-traits", + "parking_lot", + "rand 0.8.5", + "rustc_version", + "rustversion", + "serde", + "serde_bytes", + "serde_derive", + "sha2 0.10.8", + "sha3 0.10.8", + "solana-sdk-macro 2.0.3", + "thiserror", + "wasm-bindgen", +] + +[[package]] +name = "solana-sdk" +version = "1.18.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e0f0def5c5af07f53d321cea7b104487b522cfff77c3cae3da361bfe956e9e" +dependencies = [ + "assert_matches", + "base64 0.21.7", + "bincode", + "bitflags", + "borsh 1.5.1", + "bs58 0.4.0", + "bytemuck", + "byteorder", + "chrono", + "derivation-path", + "digest 0.10.7", + "ed25519-dalek", + "ed25519-dalek-bip32", + "generic-array", + "hmac 0.12.1", + "itertools", + "js-sys", + "lazy_static", + "libsecp256k1", + "log", + "memmap2", + "num-derive", + "num-traits", + "num_enum", + "pbkdf2 0.11.0", + "qstring", + "qualifier_attr", + "rand 0.7.3", + "rand 0.8.5", + "rustc_version", + "rustversion", + "serde", + "serde_bytes", + "serde_derive", + "serde_json", + "serde_with", + "sha2 0.10.8", + "sha3 0.10.8", + "siphasher", + "solana-frozen-abi", + "solana-frozen-abi-macro", + "solana-logger", + "solana-program 1.18.22", + "solana-sdk-macro 1.18.22", + "thiserror", + "uriparse", + "wasm-bindgen", +] + +[[package]] +name = "solana-sdk-macro" +version = "1.18.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c55c196c8050834c391a34b58e3c9fd86b15452ef1feeeafa1dbeb9d2291dfec" +dependencies = [ + "bs58 0.4.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.74", +] + +[[package]] +name = "solana-sdk-macro" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44bfdd94b479f125a64f028c31ca6b018cf7ab1a5ebc974f175c54dd56ad58b1" +dependencies = [ + "bs58 0.5.1", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.74", +] + +[[package]] +name = "solana-security-txt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "468aa43b7edb1f9b7b7b686d5c3aeb6630dc1708e86e31343499dd5c4d775183" + +[[package]] +name = "solana-zk-token-sdk" +version = "1.18.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ee07fa523b4cfcff68de774db7aa87d2da2c4357155a90bacd9a0a0af70a99" +dependencies = [ + "aes-gcm-siv", + "base64 0.21.7", + "bincode", + "bytemuck", + "byteorder", + "curve25519-dalek", + "getrandom 0.1.16", + "itertools", + "lazy_static", + "merlin", + "num-derive", + "num-traits", + "rand 0.7.3", + "serde", + "serde_json", + "sha3 0.9.1", + "solana-program 1.18.22", + "solana-sdk", + "subtle", + "thiserror", + "zeroize", +] + +[[package]] +name = "spl-associated-token-account" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "143109d789171379e6143ef23191786dfaac54289ad6e7917cfb26b36c432b10" +dependencies = [ + "assert_matches", + "borsh 1.5.1", + "num-derive", + "num-traits", + "solana-program 1.18.22", + "spl-token", + "spl-token-2022", + "thiserror", +] + +[[package]] +name = "spl-discriminator" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "210101376962bb22bb13be6daea34656ea1cbc248fce2164b146e39203b55e03" +dependencies = [ + "bytemuck", + "solana-program 1.18.22", + "spl-discriminator-derive", +] + +[[package]] +name = "spl-discriminator-derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9e8418ea6269dcfb01c712f0444d2c75542c04448b480e87de59d2865edc750" +dependencies = [ + "quote", + "spl-discriminator-syn", + "syn 2.0.74", +] + +[[package]] +name = "spl-discriminator-syn" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c1f05593b7ca9eac7caca309720f2eafb96355e037e6d373b909a80fe7b69b9" +dependencies = [ + "proc-macro2", + "quote", + "sha2 0.10.8", + "syn 2.0.74", + "thiserror", +] + +[[package]] +name = "spl-memo" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a49f49f95f2d02111ded31696ab38a081fab623d4c76bd4cb074286db4560836" +dependencies = [ + "solana-program 1.18.22", +] + +[[package]] +name = "spl-pod" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c52d84c55efeef8edcc226743dc089d7e3888b8e3474569aa3eff152b37b9996" +dependencies = [ + "borsh 1.5.1", + "bytemuck", + "solana-program 1.18.22", + "solana-zk-token-sdk", + "spl-program-error", +] + +[[package]] +name = "spl-program-error" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45a49acb925db68aa501b926096b2164adbdcade7a0c24152af9f0742d0a602" +dependencies = [ + "num-derive", + "num-traits", + "solana-program 1.18.22", + "spl-program-error-derive", + "thiserror", +] + +[[package]] +name = "spl-program-error-derive" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d375dd76c517836353e093c2dbb490938ff72821ab568b545fd30ab3256b3e" +dependencies = [ + "proc-macro2", + "quote", + "sha2 0.10.8", + "syn 2.0.74", +] + +[[package]] +name = "spl-tlv-account-resolution" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fab8edfd37be5fa17c9e42c1bff86abbbaf0494b031b37957f2728ad2ff842ba" +dependencies = [ + "bytemuck", + "solana-program 1.18.22", + "spl-discriminator", + "spl-pod", + "spl-program-error", + "spl-type-length-value", +] + +[[package]] +name = "spl-token" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9eb465e4bf5ce1d498f05204c8089378c1ba34ef2777ea95852fc53a1fd4fb2" +dependencies = [ + "arrayref", + "bytemuck", + "num-derive", + "num-traits", + "num_enum", + "solana-program 1.18.22", + "thiserror", +] + +[[package]] +name = "spl-token-2022" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b01d1b2851964e257187c0bca43a0de38d0af59192479ca01ac3e2b58b1bd95a" +dependencies = [ + "arrayref", + "bytemuck", + "num-derive", + "num-traits", + "num_enum", + "solana-program 1.18.22", + "solana-security-txt", + "solana-zk-token-sdk", + "spl-memo", + "spl-pod", + "spl-token", + "spl-token-group-interface", + "spl-token-metadata-interface", + "spl-transfer-hook-interface", + "spl-type-length-value", + "thiserror", +] + +[[package]] +name = "spl-token-group-interface" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "014817d6324b1e20c4bbc883e8ee30a5faa13e59d91d1b2b95df98b920150c17" +dependencies = [ + "bytemuck", + "solana-program 1.18.22", + "spl-discriminator", + "spl-pod", + "spl-program-error", +] + +[[package]] +name = "spl-token-metadata-interface" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3da00495b602ebcf5d8ba8b3ecff1ee454ce4c125c9077747be49c2d62335ba" +dependencies = [ + "borsh 1.5.1", + "solana-program 1.18.22", + "spl-discriminator", + "spl-pod", + "spl-program-error", + "spl-type-length-value", +] + +[[package]] +name = "spl-transfer-hook-interface" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9b5c08a89838e5a2931f79b17f611857f281a14a2100968a3ccef352cb7414b" +dependencies = [ + "arrayref", + "bytemuck", + "solana-program 1.18.22", + "spl-discriminator", + "spl-pod", + "spl-program-error", + "spl-tlv-account-resolution", + "spl-type-length-value", +] + +[[package]] +name = "spl-type-length-value" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c872f93d0600e743116501eba2d53460e73a12c9a496875a42a7d70e034fe06d" +dependencies = [ + "bytemuck", + "solana-program 1.18.22", + "spl-discriminator", + "spl-pod", + "spl-program-error", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" + +[[package]] +name = "svm-spoke" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "anchor-spl", + "solana-program 2.0.3", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "syn" -version = "2.0.77" +version = "2.0.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" +checksum = "1fceb41e3d546d0bd83421d3409b1460cc7444cd389341a4c880fe7a042cb3d7" dependencies = [ "proc-macro2", "quote", @@ -1633,14 +2367,25 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.74", ] [[package]] -name = "testacross" +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "test" version = "0.1.0" dependencies = [ "anchor-lang", + "anchor-spl", + "svm-spoke", ] [[package]] @@ -1660,7 +2405,7 @@ checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.74", ] [[package]] @@ -1670,9 +2415,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffc59cb9dfc85bb312c3a78fd6aa8a8582e310b0fa885d5bb877f6dcc601839d" dependencies = [ "anyhow", - "hmac", + "hmac 0.8.1", "once_cell", - "pbkdf2", + "pbkdf2 0.4.0", "rand 0.7.3", "rustc-hash", "sha2 0.9.9", @@ -1715,7 +2460,7 @@ dependencies = [ "serde", "serde_spanned", "toml_datetime", - "toml_edit", + "toml_edit 0.22.20", ] [[package]] @@ -1727,6 +2472,17 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_edit" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8534fd7f78b5405e860340ad6575217ce99f38d4d5c8f2442cb5ecb50090e1" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow 0.5.40", +] + [[package]] name = "toml_edit" version = "0.22.20" @@ -1737,7 +2493,7 @@ dependencies = [ "serde", "serde_spanned", "toml_datetime", - "winnow", + "winnow 0.6.18", ] [[package]] @@ -1767,6 +2523,26 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" +[[package]] +name = "universal-hash" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f214e8f697e925001e66ec2c6e37a4ef93f0f78c2eed7814394e10c62025b05" +dependencies = [ + "generic-array", + "subtle", +] + +[[package]] +name = "uriparse" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0200d0fc04d809396c2ad43f3c95da3582a2556eba8d453c1087f4120ee352ff" +dependencies = [ + "fnv", + "lazy_static", +] + [[package]] name = "version_check" version = "0.9.5" @@ -1807,7 +2583,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.74", "wasm-bindgen-shared", ] @@ -1829,7 +2605,7 @@ checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.74", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -1850,6 +2626,46 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -1914,6 +2730,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "0.6.18" @@ -1941,7 +2766,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.74", ] [[package]] @@ -1961,5 +2786,5 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.74", ] diff --git a/package.json b/package.json index d54d2f830..0278ac70b 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,10 @@ "prettier": "prettier .", "clean-fast": "for dir in node_modules cache cache-zk artifacts artifacts-zk dist typechain; do mv \"${dir}\" \"_${dir}\"; rm -rf \"_${dir}\" &; done", "clean": "rm -rf node_modules cache cache-zk artifacts artifacts-zk dist typechain", - "build": "hardhat compile && anchor build && tsc && rsync -a --include '*/' --include '*.d.ts' --exclude '*' ./typechain ./dist/", + "build-evm": "hardhat compile", + "build-svm": "echo 'Generating IDLs...' && anchor build > /dev/null 2>&1 || true && anchor run generateExternalTypes && anchor build", + "build-ts": "tsc && rsync -a --include '*/' --include '*.d.ts' --exclude '*' ./typechain ./dist/", + "build": "yarn build-evm && yarn build-svm && yarn build-ts", "test-evm": "IS_TEST=true hardhat test", "test-svm": "anchor test", "test": "yarn test-evm && yarn test-svm", @@ -37,7 +40,7 @@ }, "dependencies": { "@across-protocol/constants": "^3.1.15", - "@coral-xyz/anchor": "^0.30.1", + "@coral-xyz/anchor": "^0.30.0", "@defi-wonderland/smock": "^2.3.4", "@eth-optimism/contracts": "^0.5.40", "@ethersproject/abstract-provider": "5.7.0", @@ -46,11 +49,16 @@ "@openzeppelin/contracts": "4.9.6", "@openzeppelin/contracts-upgradeable": "4.9.6", "@scroll-tech/contracts": "^0.1.0", + "@solana-developers/helpers": "^2.4.0", + "@solana/spl-token": "^0.4.6", + "@solana/web3.js": "^1.31.0", "@uma/common": "^2.34.0", "@uma/contracts-node": "^0.4.17", "@uma/core": "^2.56.0", "axios": "^1.7.4", - "zksync-web3": "^0.14.3" + "zksync-web3": "^0.14.3", + "bs58": "^6.0.0", + "yargs": "^17.7.2" }, "devDependencies": { "@consensys/linea-sdk": "^0.1.6", @@ -64,6 +72,7 @@ "@nomiclabs/hardhat-waffle": "2.0.3", "@openzeppelin/hardhat-upgrades": "^1.22.0", "@pinata/sdk": "^2.1.0", + "@types/bn.js": "^5.1.0", "@typechain/ethers-v5": "^11.0.0", "@typechain/hardhat": "^8.0.0", "@types/chai": "^4.3.5", @@ -97,7 +106,7 @@ "solidity-coverage": "^0.7.16", "ts-node": "^10.1.0", "typechain": "^8.1.1", - "typescript": "^4.5.2" + "typescript": "^5.6.2" }, "husky": { "hooks": { diff --git a/programs/testacross/Cargo.toml b/programs/svm-spoke/Cargo.toml similarity index 50% rename from programs/testacross/Cargo.toml rename to programs/svm-spoke/Cargo.toml index a4f1339d4..47c9a1a84 100644 --- a/programs/testacross/Cargo.toml +++ b/programs/svm-spoke/Cargo.toml @@ -1,12 +1,13 @@ [package] -name = "testacross" +name = "svm-spoke" version = "0.1.0" description = "Created with Anchor" edition = "2021" + [lib] crate-type = ["cdylib", "lib"] -name = "testacross" +name = "svm_spoke" [features] default = [] @@ -14,7 +15,9 @@ cpi = ["no-entrypoint"] no-entrypoint = [] no-idl = [] no-log-ix-name = [] -idl-build = ["anchor-lang/idl-build"] +idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] [dependencies] -anchor-lang = "0.30.1" +anchor-lang = { version = "0.30.1", features = ["init-if-needed","event-cpi"]} +anchor-spl = "0.30.1" +solana-program = "=2.0.3" \ No newline at end of file diff --git a/programs/testacross/Xargo.toml b/programs/svm-spoke/Xargo.toml similarity index 100% rename from programs/testacross/Xargo.toml rename to programs/svm-spoke/Xargo.toml diff --git a/programs/svm-spoke/src/constants.rs b/programs/svm-spoke/src/constants.rs new file mode 100644 index 000000000..766e6fcfa --- /dev/null +++ b/programs/svm-spoke/src/constants.rs @@ -0,0 +1,6 @@ +use anchor_lang::{pubkey, solana_program::pubkey::Pubkey}; + +pub const DISCRIMINATOR_SIZE: usize = 8; + +pub const MESSAGE_TRANSMITTER_PROGRAM_ID: Pubkey = + pubkey!("CCTPmbSD7gX1bxKPAmg77w8oFzNFpaQiQUWD43TKaecd"); diff --git a/programs/svm-spoke/src/constraints.rs b/programs/svm-spoke/src/constraints.rs new file mode 100644 index 000000000..d0aa612bd --- /dev/null +++ b/programs/svm-spoke/src/constraints.rs @@ -0,0 +1,19 @@ +use anchor_lang::prelude::*; + +use crate::{ + state::State, + utils::{get_self_authority_pda, get_v3_relay_hash}, + V3RelayData, +}; + +pub fn is_local_or_remote_owner(signer: &Signer, state: &Account) -> bool { + signer.key() == state.owner || signer.key() == get_self_authority_pda() +} + +pub fn is_relay_hash_valid( + relay_hash: &[u8; 32], + relay_data: &V3RelayData, + state: &Account, +) -> bool { + relay_hash == &get_v3_relay_hash(relay_data, state.chain_id) +} diff --git a/programs/svm-spoke/src/error.rs b/programs/svm-spoke/src/error.rs new file mode 100644 index 000000000..0eed7d718 --- /dev/null +++ b/programs/svm-spoke/src/error.rs @@ -0,0 +1,72 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum CalldataError { + #[msg("Invalid solidity selector")] + InvalidSelector, + #[msg("Invalid solidity argument")] + InvalidArgument, + #[msg("Invalid solidity bool argument")] + InvalidBool, + #[msg("Invalid solidity address argument")] + InvalidAddress, + #[msg("Invalid solidity uint64 argument")] + InvalidUint64, + #[msg("Invalid solidity uint128 argument")] + InvalidUint128, + #[msg("Unsupported solidity selector")] + UnsupportedSelector, +} + +#[error_code] +//TODO: try make these match with the EVM codes. also, we can split them into different error types. +pub enum CustomError { + #[msg("Only the owner can call this function!")] + NotOwner, + #[msg("The route is not enabled!")] + RouteNotEnabled, + #[msg("The fill deadline has passed!")] + FillDeadlinePassed, + #[msg("Caller is not the exclusive relayer and exclusivity deadline has not passed!")] + NotExclusiveRelayer, + #[msg("The Deposit is still within the exclusivity or deadline window!")] + WithinFillWindow, + #[msg("Invalid route PDA!")] + InvalidRoutePDA, + #[msg("The relay has already been filled!")] + AlreadyFilled, + #[msg("Invalid relay hash!")] + InvalidRelayHash, + #[msg("The fill deadline has not passed!")] + FillDeadlineNotPassed, + #[msg("Slow fill requires status of Unfilled!")] + InvalidSlowFillRequest, + #[msg("The fill status is not filled!")] + NotFilled, + #[msg("The caller is not the relayer!")] + NotRelayer, + #[msg("Cannot set time if not in test mode!")] + CannotSetCurrentTime, + #[msg("Invalid remote domain!")] + InvalidRemoteDomain, + #[msg("Invalid remote sender!")] + InvalidRemoteSender, + #[msg("Invalid Merkle proof!")] + InvalidProof, + #[msg("Account not found!")] + AccountNotFound, + #[msg("Fills are currently paused!")] + FillsArePaused, + #[msg("Invalid chain id!")] + InvalidChainId, + #[msg("Invalid mint!")] + InvalidMint, + #[msg("Leaf already claimed!")] + LeafAlreadyClaimed, + #[msg("Exceeded pending bridge amount to HubPool!")] + ExceededPendingBridgeAmount, + #[msg("Deposits are currently paused!")] + DepositsArePaused, + #[msg("Invalid fill recipient!")] + InvalidFillRecipient, +} diff --git a/programs/svm-spoke/src/instructions/admin.rs b/programs/svm-spoke/src/instructions/admin.rs new file mode 100644 index 000000000..71f3fd929 --- /dev/null +++ b/programs/svm-spoke/src/instructions/admin.rs @@ -0,0 +1,258 @@ +use anchor_lang::prelude::*; + +use anchor_spl::associated_token::AssociatedToken; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; + +use crate::constants::DISCRIMINATOR_SIZE; +use crate::constraints::is_local_or_remote_owner; + +use crate::{ + error::CustomError, + state::{RootBundle, Route, State}, +}; + +//TODO: there is too much in this file now and it should be split up somewhat. + +#[derive(Accounts)] +#[instruction(seed: u64, initial_number_of_deposits: u64, chain_id: u64)] // Add chain_id to instruction +pub struct Initialize<'info> { + #[account(init, // Use init, not init_if_needed to prevent re-initialization. + payer = signer, + space = DISCRIMINATOR_SIZE + State::INIT_SPACE, + seeds = [b"state", seed.to_le_bytes().as_ref()], + bump)] + pub state: Account<'info, State>, + + #[account(mut)] + pub signer: Signer<'info>, + + pub system_program: Program<'info, System>, +} + +pub fn initialize( + ctx: Context, + seed: u64, + initial_number_of_deposits: u64, + chain_id: u64, // Across definition of chainId for Solana. + remote_domain: u32, // CCTP domain for Mainnet Ethereum. + cross_domain_admin: Pubkey, // HubPool on Mainnet Ethereum. + testable_mode: bool, // If the contract is in testable mode, enabling time manipulation. +) -> Result<()> { + let state = &mut ctx.accounts.state; + state.owner = *ctx.accounts.signer.key; + state.seed = seed; // Set the seed in the state + state.number_of_deposits = initial_number_of_deposits; // Set initial number of deposits + state.chain_id = chain_id; + state.remote_domain = remote_domain; + state.cross_domain_admin = cross_domain_admin; + state.current_time = if testable_mode { + Clock::get()?.unix_timestamp as u32 + } else { + 0 + }; // Set current_time to system time if testable_mode is true, else 0 + Ok(()) +} + +#[event_cpi] +#[derive(Accounts)] +pub struct PauseDeposits<'info> { + #[account( + mut, + constraint = is_local_or_remote_owner(&signer, &state) @ CustomError::NotOwner + )] + pub signer: Signer<'info>, + + #[account(mut, seeds = [b"state", state.seed.to_le_bytes().as_ref()], bump)] + pub state: Account<'info, State>, +} + +pub fn pause_deposits(ctx: Context, pause: bool) -> Result<()> { + let state = &mut ctx.accounts.state; + state.paused_deposits = pause; + + emit_cpi!(PausedDeposits { is_paused: pause }); + + Ok(()) +} + +#[event_cpi] +#[derive(Accounts)] +pub struct PauseFills<'info> { + #[account( + mut, + constraint = is_local_or_remote_owner(&signer, &state) @ CustomError::NotOwner + )] + pub signer: Signer<'info>, + + #[account(mut, seeds = [b"state", state.seed.to_le_bytes().as_ref()], bump)] + pub state: Account<'info, State>, +} + +pub fn pause_fills(ctx: Context, pause: bool) -> Result<()> { + let state = &mut ctx.accounts.state; + state.paused_fills = pause; + + emit_cpi!(PausedFills { is_paused: pause }); + + Ok(()) +} + +#[derive(Accounts)] +pub struct TransferOwnership<'info> { + #[account(mut, seeds = [b"state", state.seed.to_le_bytes().as_ref()], bump)] + pub state: Account<'info, State>, + + #[account( + mut, + address = state.owner @ CustomError::NotOwner + )] + pub signer: Signer<'info>, +} + +pub fn transfer_ownership(ctx: Context, new_owner: Pubkey) -> Result<()> { + let state = &mut ctx.accounts.state; + state.owner = new_owner; + Ok(()) +} + +#[event_cpi] +#[derive(Accounts)] +pub struct SetCrossDomainAdmin<'info> { + #[account( + mut, + constraint = is_local_or_remote_owner(&signer, &state) @ CustomError::NotOwner + )] + pub signer: Signer<'info>, + + #[account(mut, seeds = [b"state", state.seed.to_le_bytes().as_ref()], bump)] + pub state: Account<'info, State>, +} + +pub fn set_cross_domain_admin( + ctx: Context, + cross_domain_admin: Pubkey, +) -> Result<()> { + let state = &mut ctx.accounts.state; + state.cross_domain_admin = cross_domain_admin; + + emit_cpi!(SetXDomainAdmin { + new_admin: cross_domain_admin, + }); + + Ok(()) +} + +#[event_cpi] +#[derive(Accounts)] +#[instruction(origin_token: [u8; 32], destination_chain_id: u64)] +pub struct SetEnableRoute<'info> { + #[account( + mut, + constraint = is_local_or_remote_owner(&signer, &state) @ CustomError::NotOwner + )] + pub signer: Signer<'info>, + + #[account(mut)] + pub payer: Signer<'info>, + + #[account(mut, seeds = [b"state", state.seed.to_le_bytes().as_ref()], bump)] + pub state: Account<'info, State>, + + #[account( + init_if_needed, + payer = payer, + space = DISCRIMINATOR_SIZE + Route::INIT_SPACE, + seeds = [b"route", origin_token.as_ref(), destination_chain_id.to_le_bytes().as_ref()], + bump + )] + pub route: Account<'info, Route>, + + #[account( + init_if_needed, + payer = payer, + associated_token::mint = origin_token_mint, + associated_token::authority = state, + associated_token::token_program = token_program + )] + pub vault: InterfaceAccount<'info, TokenAccount>, + + #[account(mint::token_program = token_program)] + pub origin_token_mint: InterfaceAccount<'info, Mint>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} + +pub fn set_enable_route( + ctx: Context, + origin_token: [u8; 32], + destination_chain_id: u64, + enabled: bool, +) -> Result<()> { + ctx.accounts.route.enabled = enabled; + + emit_cpi!(EnabledDepositRoute { + origin_token: Pubkey::new_from_array(origin_token), + destination_chain_id, + enabled, + }); + + Ok(()) +} + +#[derive(Accounts)] +pub struct RelayRootBundle<'info> { + #[account( + mut, + constraint = is_local_or_remote_owner(&signer, &state) @ CustomError::NotOwner + )] + pub signer: Signer<'info>, + + #[account(mut, seeds = [b"state", state.seed.to_le_bytes().as_ref()], bump)] + pub state: Account<'info, State>, + + // TODO: consider deriving seed from state.seed instead of state.key() as this could be cheaper (need to verify). + #[account(init, + payer = signer, + space = DISCRIMINATOR_SIZE + RootBundle::INIT_SPACE, + seeds =[b"root_bundle", state.key().as_ref(), state.root_bundle_id.to_le_bytes().as_ref()], + bump)] + pub root_bundle: Account<'info, RootBundle>, + + pub system_program: Program<'info, System>, +} + +pub fn relay_root_bundle( + ctx: Context, + relayer_refund_root: [u8; 32], + slow_relay_root: [u8; 32], +) -> Result<()> { + let state = &mut ctx.accounts.state; + let root_bundle = &mut ctx.accounts.root_bundle; + root_bundle.relayer_refund_root = relayer_refund_root; + root_bundle.slow_relay_root = slow_relay_root; + state.root_bundle_id += 1; + Ok(()) +} +#[event] +pub struct SetXDomainAdmin { + pub new_admin: Pubkey, +} + +#[event] +pub struct PausedDeposits { + pub is_paused: bool, +} + +#[event] +pub struct PausedFills { + pub is_paused: bool, +} + +#[event] +pub struct EnabledDepositRoute { + pub origin_token: Pubkey, + pub destination_chain_id: u64, + pub enabled: bool, +} diff --git a/programs/svm-spoke/src/instructions/bundle.rs b/programs/svm-spoke/src/instructions/bundle.rs new file mode 100644 index 000000000..23bc7bfc5 --- /dev/null +++ b/programs/svm-spoke/src/instructions/bundle.rs @@ -0,0 +1,166 @@ +use anchor_lang::prelude::*; +use anchor_lang::solana_program::keccak; + +use crate::{ + constants::DISCRIMINATOR_SIZE, + error::CustomError, + state::{RootBundle, State, TransferLiability}, + utils::{is_claimed, set_claimed, verify_merkle_proof}, +}; + +use anchor_spl::token_interface::{ + transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, +}; + +#[derive(Accounts)] +#[instruction(root_bundle_id: u32, relayer_refund_leaf: RelayerRefundLeaf)] +pub struct ExecuteRelayerRefundLeaf<'info> { + #[account(mut, seeds = [b"state", state.seed.to_le_bytes().as_ref()], bump)] + pub state: Account<'info, State>, + + #[account( + mut, + seeds =[b"root_bundle", state.key().as_ref(), root_bundle_id.to_le_bytes().as_ref()], bump, + realloc = DISCRIMINATOR_SIZE + RootBundle::INIT_SPACE + relayer_refund_leaf.leaf_id as usize / 8, + realloc::payer = signer, + realloc::zero = false + )] + pub root_bundle: Account<'info, RootBundle>, + + #[account(mut)] + pub signer: Signer<'info>, + + #[account( + mut, + associated_token::mint = mint, + associated_token::authority = state, + associated_token::token_program = token_program + )] + pub vault: InterfaceAccount<'info, TokenAccount>, + + #[account( + mut, + mint::token_program = token_program, + address = relayer_refund_leaf.mint_public_key @ CustomError::InvalidMint + )] + pub mint: InterfaceAccount<'info, Mint>, + + #[account( + init_if_needed, + payer = signer, + space = DISCRIMINATOR_SIZE + TransferLiability::INIT_SPACE, + seeds = [b"transfer_liability", mint.key().as_ref()], + bump + )] + pub transfer_liability: Account<'info, TransferLiability>, + + pub token_program: Interface<'info, TokenInterface>, + + pub system_program: Program<'info, System>, +} + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct RelayerRefundLeaf { + pub amount_to_return: u64, + pub chain_id: u64, + pub leaf_id: u32, + pub mint_public_key: Pubkey, + pub refund_amounts: Vec, + pub refund_accounts: Vec, +} + +impl RelayerRefundLeaf { + pub fn to_bytes(&self) -> Vec { + let mut bytes = Vec::new(); + + bytes.extend_from_slice(&self.amount_to_return.to_le_bytes()); + bytes.extend_from_slice(&self.chain_id.to_le_bytes()); + bytes.extend_from_slice(&self.leaf_id.to_le_bytes()); + bytes.extend_from_slice(self.mint_public_key.as_ref()); + + for amount in &self.refund_amounts { + bytes.extend_from_slice(&amount.to_le_bytes()); + } + for account in &self.refund_accounts { + bytes.extend_from_slice(account.as_ref()); + } + + bytes + } + + pub fn to_keccak_hash(&self) -> [u8; 32] { + let input = self.to_bytes(); + msg!("input: {:?}", input); + keccak::hash(&input).0 + } +} + +pub fn execute_relayer_refund_leaf<'info>( + ctx: Context<'_, '_, '_, 'info, ExecuteRelayerRefundLeaf<'info>>, + root_bundle_id: u32, + relayer_refund_leaf: RelayerRefundLeaf, + proof: Vec<[u8; 32]>, +) -> Result<()> { + let state = &mut ctx.accounts.state; + + let root = ctx.accounts.root_bundle.relayer_refund_root; + let leaf = relayer_refund_leaf.to_keccak_hash(); + verify_merkle_proof(root, leaf, proof)?; + + if relayer_refund_leaf.chain_id != state.chain_id { + return Err(CustomError::InvalidChainId.into()); + } + + if is_claimed( + &ctx.accounts.root_bundle.claimed_bitmap, + relayer_refund_leaf.leaf_id, + ) { + return Err(CustomError::LeafAlreadyClaimed.into()); + } + + set_claimed( + &mut ctx.accounts.root_bundle.claimed_bitmap, + relayer_refund_leaf.leaf_id, + ); + + // TODO: execute remaining parts of leaf structure such as amountToReturn. + // TODO: emit events. + + // Derive the signer seeds for the state. The vault owns the state PDA so we need to derive this to create the + // signer seeds to execute the CPI transfer from the vault to the refund recipient. + let state_seed_bytes = state.seed.to_le_bytes(); + let seeds = &[b"state", state_seed_bytes.as_ref(), &[ctx.bumps.state]]; + let signer_seeds = &[&seeds[..]]; + + for (i, amount) in relayer_refund_leaf.refund_amounts.iter().enumerate() { + let refund_account = relayer_refund_leaf.refund_accounts[i]; + let amount = *amount as u64; + + // TODO: we might be able to just use the refund_account and improve this block but it's not clear yet if that's possible. + let refund_account_info = ctx + .remaining_accounts + .iter() + .find(|account| account.key == &refund_account) + .cloned() + .ok_or(CustomError::AccountNotFound)?; + + let transfer_accounts = TransferChecked { + from: ctx.accounts.vault.to_account_info(), + mint: ctx.accounts.mint.to_account_info(), + to: refund_account_info.to_account_info(), + authority: ctx.accounts.state.to_account_info(), + }; + let cpi_context = CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + transfer_accounts, + signer_seeds, + ); + transfer_checked(cpi_context, amount, ctx.accounts.mint.decimals)?; + } + + if relayer_refund_leaf.amount_to_return > 0 { + ctx.accounts.transfer_liability.pending_to_hub_pool += relayer_refund_leaf.amount_to_return; + } + + Ok(()) +} diff --git a/programs/svm-spoke/src/instructions/deposit.rs b/programs/svm-spoke/src/instructions/deposit.rs new file mode 100644 index 000000000..6e811e6fc --- /dev/null +++ b/programs/svm-spoke/src/instructions/deposit.rs @@ -0,0 +1,126 @@ +use anchor_lang::prelude::*; + +use crate::{ + error::CustomError, + state::{Route, State}, +}; + +use anchor_spl::token_interface::{ + transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, +}; + +#[event_cpi] +#[derive(Accounts)] +#[instruction( + depositor: Pubkey, + recipient: Pubkey, + input_token: Pubkey, + output_token: Pubkey, + input_amount: u64, + output_amount: u64, + destination_chain_id: u64, + exclusive_relayer: Pubkey, + quote_timestamp: u32, + fill_deadline: u32, + exclusivity_deadline: u32, + message: Vec +)] +pub struct DepositV3<'info> { + #[account( + mut, + seeds = [b"state", state.seed.to_le_bytes().as_ref()], + bump, + constraint = !state.paused_deposits @ CustomError::DepositsArePaused + )] + pub state: Account<'info, State>, + + #[account(mut, seeds = [b"route", input_token.as_ref(), destination_chain_id.to_le_bytes().as_ref()], bump)] + pub route: Account<'info, Route>, + + #[account(mut)] + pub signer: Signer<'info>, + + #[account(mut)] + pub user_token_account: InterfaceAccount<'info, TokenAccount>, + + #[account(mut)] + pub vault: InterfaceAccount<'info, TokenAccount>, + + #[account(mut)] + pub mint: InterfaceAccount<'info, Mint>, + + pub token_program: Interface<'info, TokenInterface>, +} + +pub fn deposit_v3( + ctx: Context, + depositor: Pubkey, + recipient: Pubkey, + input_token: Pubkey, + output_token: Pubkey, + input_amount: u64, + output_amount: u64, + destination_chain_id: u64, + exclusive_relayer: Pubkey, + quote_timestamp: u32, + fill_deadline: u32, + exclusivity_deadline: u32, + message: Vec, +) -> Result<()> { + let state = &mut ctx.accounts.state; + + // TODO: I'm not totally sure how the check here is sufficient. For example can an account make their own fake + // spoke pool, create a route PDA, toggle it to enabled and then call deposit, passing in that PDA and + // enable a deposit to occur against a route that was not canonically enabled? write some tests for this and + // verify that this check is sufficient or update accordingly. + require!(ctx.accounts.route.enabled, CustomError::RouteNotEnabled); + + let transfer_accounts = TransferChecked { + from: ctx.accounts.user_token_account.to_account_info(), + mint: ctx.accounts.mint.to_account_info(), + to: ctx.accounts.vault.to_account_info(), + authority: ctx.accounts.signer.to_account_info(), + }; + let cpi_context = CpiContext::new( + ctx.accounts.token_program.to_account_info(), + transfer_accounts, + ); + transfer_checked(cpi_context, input_amount, ctx.accounts.mint.decimals)?; + + state.number_of_deposits += 1; // Increment number of deposits + + emit_cpi!(V3FundsDeposited { + input_token, + output_token, + input_amount, + output_amount, + destination_chain_id, + deposit_id: state.number_of_deposits, + quote_timestamp, + fill_deadline, + exclusivity_deadline, + depositor, + recipient, + exclusive_relayer, + message, + }); + + Ok(()) +} + +#[event] +pub struct V3FundsDeposited { + pub input_token: Pubkey, + pub output_token: Pubkey, + pub input_amount: u64, + pub output_amount: u64, + pub destination_chain_id: u64, + pub deposit_id: u64, + pub quote_timestamp: u32, + pub fill_deadline: u32, + pub exclusivity_deadline: u32, + pub depositor: Pubkey, + pub recipient: Pubkey, + pub exclusive_relayer: Pubkey, + pub message: Vec, +} diff --git a/programs/svm-spoke/src/instructions/fill.rs b/programs/svm-spoke/src/instructions/fill.rs new file mode 100644 index 000000000..f86383aa1 --- /dev/null +++ b/programs/svm-spoke/src/instructions/fill.rs @@ -0,0 +1,257 @@ +use anchor_lang::prelude::*; + +use anchor_spl::associated_token::AssociatedToken; +use anchor_spl::token_interface::{ + transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, +}; + +use crate::{ + constants::DISCRIMINATOR_SIZE, + constraints::is_relay_hash_valid, + error::CustomError, + state::{FillStatus, FillStatusAccount, State}, +}; + +#[event_cpi] +#[derive(Accounts)] +#[instruction(relay_hash: [u8; 32], relay_data: V3RelayData)] +pub struct FillV3Relay<'info> { + #[account( + mut, + seeds = [b"state", state.seed.to_le_bytes().as_ref()], + bump, + constraint = !state.paused_fills @ CustomError::FillsArePaused + )] + pub state: Account<'info, State>, + + #[account(mut)] + pub signer: Signer<'info>, + + #[account(mut)] + pub relayer: SystemAccount<'info>, + + #[account(mut)] + pub recipient: SystemAccount<'info>, + + #[account(mut)] + pub mint_account: InterfaceAccount<'info, Mint>, + + #[account( + mut, + associated_token::mint = mint_account, + associated_token::authority = relayer, + )] + pub relayer_token_account: InterfaceAccount<'info, TokenAccount>, + + #[account( + mut, + associated_token::mint = mint_account, + associated_token::authority = recipient, + )] + pub recipient_token_account: InterfaceAccount<'info, TokenAccount>, + + #[account( + init_if_needed, + payer = signer, + space = DISCRIMINATOR_SIZE + FillStatusAccount::INIT_SPACE, + seeds = [b"fills", relay_hash.as_ref()], + bump, + // Make sure caller provided relay_hash used in PDA seeds is valid. + constraint = is_relay_hash_valid(&relay_hash, &relay_data, &state) @ CustomError::InvalidRelayHash + )] + pub fill_status: Account<'info, FillStatusAccount>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct V3RelayData { + pub depositor: Pubkey, + pub recipient: Pubkey, + pub exclusive_relayer: Pubkey, + pub input_token: Pubkey, + pub output_token: Pubkey, + pub input_amount: u64, + pub output_amount: u64, + pub origin_chain_id: u64, + pub deposit_id: u32, + pub fill_deadline: u32, + pub exclusivity_deadline: u32, + pub message: Vec, +} + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, PartialEq)] +pub enum FillType { + FastFill, + ReplacedSlowFill, + SlowFill, +} + +pub fn fill_v3_relay( + ctx: Context, + relay_hash: [u8; 32], // include in props, while not using it, to enable us to access it from the #Instruction Attribute within the accounts. This enables us to pass in the relay_hash PDA. + relay_data: V3RelayData, + repayment_chain_id: u64, +) -> Result<()> { + let state = &mut ctx.accounts.state; + // TODO: Try again to pull this into a helper function. for some reason I was not able to due to passing context around of state. + let current_timestamp = if state.current_time != 0 { + state.current_time + } else { + Clock::get()?.unix_timestamp as u32 + }; + + // Check the fill status + let fill_status_account = &mut ctx.accounts.fill_status; + require!( + fill_status_account.status != FillStatus::Filled, + CustomError::AlreadyFilled + ); + + // Check if the fill deadline has passed + require!( + current_timestamp <= relay_data.fill_deadline, + CustomError::FillDeadlinePassed + ); + + // Check if the exclusivity deadline has passed or if the caller is the exclusive relayer + if relay_data.exclusive_relayer != Pubkey::default() { + require!( + current_timestamp > relay_data.exclusivity_deadline + || ctx.accounts.signer.key() == relay_data.exclusive_relayer, + CustomError::NotExclusiveRelayer + ); + } + + // Invoke the transfer_checked instruction on the token program + let transfer_accounts = TransferChecked { + from: ctx.accounts.relayer_token_account.to_account_info(), + mint: ctx.accounts.mint_account.to_account_info(), + to: ctx.accounts.recipient_token_account.to_account_info(), + authority: ctx.accounts.signer.to_account_info(), + }; + let cpi_context = CpiContext::new( + ctx.accounts.token_program.to_account_info(), + transfer_accounts, + ); + transfer_checked( + cpi_context, + relay_data.output_amount, + ctx.accounts.mint_account.decimals, + )?; + + // Update the fill status to Filled and set the relayer + fill_status_account.status = FillStatus::Filled; + fill_status_account.relayer = *ctx.accounts.signer.key; + + msg!("Tokens transferred successfully."); + + // Emit the FilledV3Relay event + let message_clone = relay_data.message.clone(); // Clone the message before it is moved + + emit_cpi!(FilledV3Relay { + input_token: relay_data.input_token, + output_token: relay_data.output_token, + input_amount: relay_data.input_amount, + output_amount: relay_data.output_amount, + repayment_chain_id, + origin_chain_id: relay_data.origin_chain_id, + deposit_id: relay_data.deposit_id, + fill_deadline: relay_data.fill_deadline, + exclusivity_deadline: relay_data.exclusivity_deadline, + exclusive_relayer: relay_data.exclusive_relayer, + relayer: *ctx.accounts.signer.key, + depositor: relay_data.depositor, + recipient: relay_data.recipient, + message: relay_data.message, + relay_execution_info: V3RelayExecutionEventInfo { + updated_recipient: relay_data.recipient, + updated_message: message_clone, + updated_output_amount: relay_data.output_amount, + fill_type: FillType::FastFill, + }, + }); + + Ok(()) +} + +#[derive(Accounts)] +#[instruction(relay_hash: [u8; 32], relay_data: V3RelayData)] +pub struct CloseFillPda<'info> { + #[account(mut, seeds = [b"state", state.seed.to_le_bytes().as_ref()], bump)] + pub state: Account<'info, State>, + + #[account( + mut, + address = fill_status.relayer @ CustomError::NotRelayer + )] + pub signer: Signer<'info>, + + #[account( + mut, + seeds = [b"fills", relay_hash.as_ref()], + bump, + close = signer, + // Make sure caller provided relay_hash used in PDA seeds is valid. + constraint = is_relay_hash_valid(&relay_hash, &relay_data, &state) @ CustomError::InvalidRelayHash + )] + pub fill_status: Account<'info, FillStatusAccount>, +} + +pub fn close_fill_pda( + ctx: Context, + relay_hash: [u8; 32], + relay_data: V3RelayData, +) -> Result<()> { + let state = &mut ctx.accounts.state; + // TODO: Try again to pull this into a helper function. for some reason I was not able to due to passing context around of state. + let current_timestamp = if state.current_time != 0 { + state.current_time + } else { + Clock::get()?.unix_timestamp as u32 + }; + + // Check if the fill status is filled + require!( + ctx.accounts.fill_status.status == FillStatus::Filled, + CustomError::NotFilled + ); + + // Check if the deposit has expired + require!( + current_timestamp > relay_data.fill_deadline, + CustomError::FillDeadlineNotPassed + ); + + Ok(()) +} + +// Events. +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct V3RelayExecutionEventInfo { + pub updated_recipient: Pubkey, + pub updated_message: Vec, + pub updated_output_amount: u64, + pub fill_type: FillType, +} + +#[event] +pub struct FilledV3Relay { + pub input_token: Pubkey, + pub output_token: Pubkey, + pub input_amount: u64, + pub output_amount: u64, + pub repayment_chain_id: u64, + pub origin_chain_id: u64, + pub deposit_id: u32, + pub fill_deadline: u32, + pub exclusivity_deadline: u32, + pub exclusive_relayer: Pubkey, + pub relayer: Pubkey, + pub depositor: Pubkey, + pub recipient: Pubkey, + pub message: Vec, + pub relay_execution_info: V3RelayExecutionEventInfo, +} diff --git a/programs/svm-spoke/src/instructions/handle_receive_message.rs b/programs/svm-spoke/src/instructions/handle_receive_message.rs new file mode 100644 index 000000000..274eea0dc --- /dev/null +++ b/programs/svm-spoke/src/instructions/handle_receive_message.rs @@ -0,0 +1,130 @@ +use anchor_lang::{ + prelude::*, + solana_program::{instruction::Instruction, program}, +}; + +use crate::{ + constants::MESSAGE_TRANSMITTER_PROGRAM_ID, + error::CalldataError, + error::CustomError, + program::SvmSpoke, + utils::{self, EncodeInstructionData}, + State, +}; + +#[derive(Accounts)] +#[instruction(params: HandleReceiveMessageParams)] +pub struct HandleReceiveMessage<'info> { + // authority_pda is a Signer to ensure that this instruction + // can only be called by Message Transmitter + #[account( + seeds = [b"message_transmitter_authority", SvmSpoke::id().as_ref()], + bump = params.authority_bump, + seeds::program = MESSAGE_TRANSMITTER_PROGRAM_ID + )] + pub authority_pda: Signer<'info>, + #[account( + constraint = params.remote_domain == state.remote_domain @ CustomError::InvalidRemoteDomain, + constraint = params.sender == state.cross_domain_admin @ CustomError::InvalidRemoteSender, + )] + pub state: Account<'info, State>, + /// CHECK: empty PDA, used in authenticating self-CPI invoked by the received message. + #[account( + seeds = [b"self_authority"], + bump, + )] + pub self_authority: UncheckedAccount<'info>, + pub program: Program<'info, SvmSpoke>, +} + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct HandleReceiveMessageParams { + pub remote_domain: u32, + pub sender: Pubkey, + pub message_body: Vec, + pub authority_bump: u8, +} + +impl<'info> HandleReceiveMessage<'info> { + pub fn handle_receive_message( + &mut self, + params: &HandleReceiveMessageParams, + ) -> Result> { + // Return instruction data for the self invoked CPI based on the received message body. + translate_message(¶ms.message_body) + } +} + +fn translate_message(data: &Vec) -> Result> { + match utils::get_solidity_selector(data)? { + s if s == utils::encode_solidity_selector("pauseDeposits(bool)") => { + let pause = utils::decode_solidity_bool(&utils::get_solidity_arg(data, 0)?)?; + + pause.encode_instruction_data("global:pause_deposits") + } + s if s == utils::encode_solidity_selector("pauseFills(bool)") => { + let pause = utils::decode_solidity_bool(&utils::get_solidity_arg(data, 0)?)?; + + pause.encode_instruction_data("global:pause_fills") + } + s if s == utils::encode_solidity_selector("setCrossDomainAdmin(address)") => { + let new_cross_domain_admin = + utils::decode_solidity_address(&utils::get_solidity_arg(data, 0)?)?; + + new_cross_domain_admin.encode_instruction_data("global:set_cross_domain_admin") + } + // TODO: Make sure to change EVM SpokePool interface using bytes32 for token addresses and uint64 for chain IDs. + s if s == utils::encode_solidity_selector("setEnableRoute(bytes32,uint64,bool)") => { + let origin_token = utils::get_solidity_arg(data, 0)?; + let destination_chain_id = + utils::decode_solidity_uint64(&utils::get_solidity_arg(data, 1)?)?; + let enabled = utils::decode_solidity_bool(&utils::get_solidity_arg(data, 2)?)?; + + (origin_token, destination_chain_id, enabled) + .encode_instruction_data("global:set_enable_route") + } + _ => Err(CalldataError::UnsupportedSelector.into()), + } +} + +// Invokes self CPI for remote domain invoked message calls. We use low level invoke_signed with seeds corresponding to +// the self_authority account and passing all remaining accounts from the context. Instruction data is obtained within +// handle_receive_message by translating the received message body into a valid instruction data for the invoked CPI. +pub fn invoke_self<'info>( + ctx: &Context<'_, '_, '_, 'info, HandleReceiveMessage<'info>>, + data: &Vec, +) -> Result<()> { + let self_authority_seeds: &[&[&[u8]]] = &[&[b"self_authority", &[ctx.bumps.self_authority]]]; + + let mut accounts = Vec::with_capacity(1 + ctx.remaining_accounts.len()); + + // Signer in self-invoked instructions is mutable when called by owner on Solana. + // We might need to reconsider signer to be separate from fee payer and self_authority can become read-only. + accounts.push(AccountMeta::new(ctx.accounts.self_authority.key(), true)); + + for acc in ctx.remaining_accounts { + if acc.is_writable { + accounts.push(AccountMeta::new(acc.key(), acc.is_signer)); + } else { + accounts.push(AccountMeta::new_readonly(acc.key(), acc.is_signer)); + } + } + + let instruction = Instruction { + program_id: crate::ID, + accounts, + data: data.to_owned(), + }; + + program::invoke_signed( + &instruction, + &[ + &[ctx.accounts.self_authority.to_account_info()], + ctx.remaining_accounts, + ] + .concat(), + self_authority_seeds, + )?; + + Ok(()) +} diff --git a/programs/svm-spoke/src/instructions/mod.rs b/programs/svm-spoke/src/instructions/mod.rs new file mode 100644 index 000000000..4be3aab76 --- /dev/null +++ b/programs/svm-spoke/src/instructions/mod.rs @@ -0,0 +1,17 @@ +mod admin; +mod bundle; +mod deposit; +mod fill; +mod handle_receive_message; +mod slow_fill; +mod testable; +mod token_bridge; + +pub use admin::*; +pub use bundle::*; +pub use deposit::*; +pub use fill::*; +pub use handle_receive_message::*; +pub use slow_fill::*; +pub use testable::*; +pub use token_bridge::*; diff --git a/programs/svm-spoke/src/instructions/slow_fill.rs b/programs/svm-spoke/src/instructions/slow_fill.rs new file mode 100644 index 000000000..1348c5518 --- /dev/null +++ b/programs/svm-spoke/src/instructions/slow_fill.rs @@ -0,0 +1,274 @@ +use anchor_lang::prelude::*; +use anchor_lang::solana_program::keccak; + +use anchor_spl::associated_token::AssociatedToken; +use anchor_spl::token_interface::{ + transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, +}; + +use crate::{ + constants::DISCRIMINATOR_SIZE, + constraints::is_relay_hash_valid, + error::CustomError, + state::{FillStatus, FillStatusAccount, RootBundle, State}, + utils::verify_merkle_proof, +}; + +// TODO: We can likely move some of the common exports to better locations. we are pulling a lot of these from fill.rs +use crate::{FillType, FilledV3Relay, V3RelayData, V3RelayExecutionEventInfo}; + +#[event_cpi] +#[derive(Accounts)] +#[instruction(relay_hash: [u8; 32], relay_data: V3RelayData)] +pub struct SlowFillV3Relay<'info> { + #[account( + mut, + seeds = [b"state", state.seed.to_le_bytes().as_ref()], + bump, + constraint = !state.paused_fills @ CustomError::FillsArePaused + )] + pub state: Account<'info, State>, + + #[account(mut)] + pub signer: Signer<'info>, + + #[account( + init_if_needed, + payer = signer, + space = DISCRIMINATOR_SIZE + FillStatusAccount::INIT_SPACE, + seeds = [b"fills", relay_hash.as_ref()], + bump, + // Make sure caller provided relay_hash used in PDA seeds is valid. + constraint = is_relay_hash_valid(&relay_hash, &relay_data, &state) @ CustomError::InvalidRelayHash + )] + pub fill_status: Account<'info, FillStatusAccount>, + pub system_program: Program<'info, System>, +} + +pub fn request_v3_slow_fill( + ctx: Context, + relay_hash: [u8; 32], // include in props, while not using it, to enable us to access it from the #Instruction Attribute within the accounts. This enables us to pass in the relay_hash PDA. + relay_data: V3RelayData, +) -> Result<()> { + let state = &mut ctx.accounts.state; + + // TODO: Try again to pull this into a helper function. for some reason I was not able to due to passing context around of state. + let current_timestamp = if state.current_time != 0 { + state.current_time + } else { + Clock::get()?.unix_timestamp as u32 + }; + + // Check if the fill is within the exclusivity window & fill deadline. + //TODO: ensure the require blocks here are equivilelent to evm. + require!( + relay_data.exclusivity_deadline < current_timestamp + && relay_data.fill_deadline < current_timestamp, + CustomError::WithinFillWindow + ); + + // Check the fill status + let fill_status_account = &mut ctx.accounts.fill_status; + require!( + fill_status_account.status == FillStatus::Unfilled, + CustomError::InvalidSlowFillRequest + ); + + // Update the fill status to RequestedSlowFill + fill_status_account.status = FillStatus::RequestedSlowFill; + fill_status_account.relayer = ctx.accounts.signer.key(); + + // Emit the RequestedV3SlowFill event + emit_cpi!(RequestedV3SlowFill { + input_token: relay_data.input_token, + output_token: relay_data.output_token, + input_amount: relay_data.input_amount, + output_amount: relay_data.output_amount, + origin_chain_id: relay_data.origin_chain_id, + deposit_id: relay_data.deposit_id, + fill_deadline: relay_data.fill_deadline, + exclusivity_deadline: relay_data.exclusivity_deadline, + exclusive_relayer: relay_data.exclusive_relayer, + depositor: relay_data.depositor, + recipient: relay_data.recipient, + message: relay_data.message, + }); + + Ok(()) +} + +// Define the V3SlowFill struct +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct V3SlowFill { + pub relay_data: V3RelayData, + pub chain_id: u64, + pub updated_output_amount: u64, +} + +impl V3SlowFill { + pub fn to_bytes(&self) -> Vec { + let mut bytes = Vec::new(); + + // Order should match the Solidity struct field order + bytes.extend_from_slice(&self.relay_data.depositor.to_bytes()); + bytes.extend_from_slice(&self.relay_data.recipient.to_bytes()); + bytes.extend_from_slice(&self.relay_data.exclusive_relayer.to_bytes()); + bytes.extend_from_slice(&self.relay_data.input_token.to_bytes()); + bytes.extend_from_slice(&self.relay_data.output_token.to_bytes()); + bytes.extend_from_slice(&self.relay_data.input_amount.to_le_bytes()); + bytes.extend_from_slice(&self.relay_data.output_amount.to_le_bytes()); + bytes.extend_from_slice(&self.relay_data.origin_chain_id.to_le_bytes()); + bytes.extend_from_slice(&self.relay_data.deposit_id.to_le_bytes()); + bytes.extend_from_slice(&self.relay_data.fill_deadline.to_le_bytes()); + bytes.extend_from_slice(&self.relay_data.exclusivity_deadline.to_le_bytes()); + bytes.extend_from_slice(&self.relay_data.message); + bytes.extend_from_slice(&self.chain_id.to_le_bytes()); + bytes.extend_from_slice(&self.updated_output_amount.to_le_bytes()); + + bytes + } + + pub fn to_keccak_hash(&self) -> [u8; 32] { + let input = self.to_bytes(); + keccak::hash(&input).0 + } +} + +// Define the V3SlowFill struct +#[event_cpi] +#[derive(Accounts)] +#[instruction(relay_hash: [u8; 32], slow_fill_leaf: V3SlowFill, root_bundle_id: u32)] +pub struct ExecuteV3SlowRelayLeaf<'info> { + #[account(mut, seeds = [b"state", state.seed.to_le_bytes().as_ref()], bump)] + pub state: Account<'info, State>, + + #[account(mut, seeds =[b"root_bundle", state.key().as_ref(), root_bundle_id.to_le_bytes().as_ref()], bump)] + pub root_bundle: Account<'info, RootBundle>, + + #[account(mut)] + pub signer: Signer<'info>, + + #[account( + mut, + seeds = [b"fills", relay_hash.as_ref()], + bump)] + pub fill_status: Account<'info, FillStatusAccount>, + + #[account( + mut, + address = slow_fill_leaf.relay_data.recipient @ CustomError::InvalidFillRecipient + )] + pub recipient: SystemAccount<'info>, + + #[account(mut)] + pub mint: InterfaceAccount<'info, Mint>, + + #[account( + mut, + associated_token::mint = mint, + associated_token::authority = recipient, + )] + pub recipient_token_account: InterfaceAccount<'info, TokenAccount>, + + #[account( + mut, + associated_token::mint = mint, + associated_token::authority = state, + )] + pub vault: InterfaceAccount<'info, TokenAccount>, + + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, +} + +pub fn execute_v3_slow_relay_leaf( + ctx: Context, + relay_hash: [u8; 32], + slow_fill_leaf: V3SlowFill, + root_bundle_id: u32, + proof: Vec<[u8; 32]>, +) -> Result<()> { + let relay_data = slow_fill_leaf.relay_data.clone(); // Clone relay_data to avoid move + + let root = ctx.accounts.root_bundle.slow_relay_root; + let leaf = slow_fill_leaf.to_keccak_hash(); + verify_merkle_proof(root, leaf, proof)?; + + // Check if the fill status is unfilled + let fill_status_account = &mut ctx.accounts.fill_status; + require!( + fill_status_account.status == FillStatus::RequestedSlowFill, + CustomError::InvalidSlowFillRequest + ); + + // Derive the signer seeds for the state + let state_seed_bytes = ctx.accounts.state.seed.to_le_bytes(); + let seeds = &[b"state", state_seed_bytes.as_ref(), &[ctx.bumps.state]]; + let signer_seeds = &[&seeds[..]]; + + // Invoke the transfer_checked instruction on the token program + let transfer_accounts = TransferChecked { + from: ctx.accounts.vault.to_account_info(), // Pull from the vault + mint: ctx.accounts.mint.to_account_info(), + to: ctx.accounts.recipient_token_account.to_account_info(), // Send to the recipient + authority: ctx.accounts.state.to_account_info(), // Authority is the state (owner of the vault) + }; + let cpi_context = CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + transfer_accounts, + signer_seeds, + ); + transfer_checked( + cpi_context, + slow_fill_leaf.updated_output_amount, + ctx.accounts.mint.decimals, + )?; + + // Update the fill status to Filled. Note we don't set the relayer here as it is set when the slow fill was requested. + fill_status_account.status = FillStatus::Filled; + + // Emit the FilledV3Relay event + let message_clone = relay_data.message.clone(); // Clone the message before it is moved + + emit_cpi!(FilledV3Relay { + input_token: relay_data.input_token, + output_token: relay_data.output_token, + input_amount: relay_data.input_amount, + output_amount: relay_data.output_amount, + repayment_chain_id: 0, // There is no repayment chain id for slow fills. + origin_chain_id: relay_data.origin_chain_id, + deposit_id: relay_data.deposit_id, + fill_deadline: relay_data.fill_deadline, + exclusivity_deadline: relay_data.exclusivity_deadline, + exclusive_relayer: relay_data.exclusive_relayer, + relayer: *ctx.accounts.signer.key, + depositor: relay_data.depositor, + recipient: relay_data.recipient, + message: relay_data.message, + relay_execution_info: V3RelayExecutionEventInfo { + updated_recipient: relay_data.recipient, + updated_message: message_clone, + updated_output_amount: slow_fill_leaf.updated_output_amount, + fill_type: FillType::SlowFill, + }, + }); + + Ok(()) +} + +// Events. +#[event] +pub struct RequestedV3SlowFill { + pub input_token: Pubkey, + pub output_token: Pubkey, + pub input_amount: u64, + pub output_amount: u64, + pub origin_chain_id: u64, + pub deposit_id: u32, + pub fill_deadline: u32, + pub exclusivity_deadline: u32, + pub exclusive_relayer: Pubkey, + pub depositor: Pubkey, + pub recipient: Pubkey, + pub message: Vec, +} diff --git a/programs/svm-spoke/src/instructions/testable.rs b/programs/svm-spoke/src/instructions/testable.rs new file mode 100644 index 000000000..56b7303de --- /dev/null +++ b/programs/svm-spoke/src/instructions/testable.rs @@ -0,0 +1,19 @@ +use anchor_lang::prelude::*; + +use crate::{error::CustomError, state::State}; + +#[derive(Accounts)] +pub struct SetCurrentTime<'info> { + #[account(mut, seeds = [b"state", state.seed.to_le_bytes().as_ref()], bump)] + pub state: Account<'info, State>, + + #[account(mut)] + pub signer: Signer<'info>, +} + +pub fn set_current_time(ctx: Context, new_time: u32) -> Result<()> { + let state = &mut ctx.accounts.state; + require!(state.current_time != 0, CustomError::CannotSetCurrentTime); // Ensure current_time is not zero + state.current_time = new_time; + Ok(()) +} diff --git a/programs/svm-spoke/src/instructions/token_bridge.rs b/programs/svm-spoke/src/instructions/token_bridge.rs new file mode 100644 index 000000000..e814ccd68 --- /dev/null +++ b/programs/svm-spoke/src/instructions/token_bridge.rs @@ -0,0 +1,124 @@ +use crate::error::CustomError; +use crate::message_transmitter::program::MessageTransmitter; +use crate::token_messenger_minter::{ + self, cpi::accounts::DepositForBurn, program::TokenMessengerMinter, types::DepositForBurnParams, +}; +use crate::{State, TransferLiability}; +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; + +#[derive(Accounts)] +pub struct BridgeTokensToHubPool<'info> { + #[account(mut)] + pub signer: Signer<'info>, + + #[account(mut)] + pub payer: Signer<'info>, + + #[account(mut, mint::token_program = token_program)] + pub mint: InterfaceAccount<'info, Mint>, + + #[account(mut, seeds = [b"state", state.seed.to_le_bytes().as_ref()], bump)] + pub state: Account<'info, State>, + + #[account(mut, seeds = [b"transfer_liability", mint.key().as_ref()], bump)] + pub transfer_liability: Account<'info, TransferLiability>, + + #[account( + mut, + associated_token::mint = mint, + associated_token::authority = state, + associated_token::token_program = token_program + )] + pub vault: InterfaceAccount<'info, TokenAccount>, + + /// CHECK: empty PDA, checked in CCTP. Seeds must be \["sender_authority"\] (CCTP Token Messenger Minter program). + pub token_messenger_minter_sender_authority: UncheckedAccount<'info>, + + /// CHECK: MessageTransmitter is checked in CCTP. Seeds must be \["message_transmitter"\] (CCTP Message Transmitter + /// program). + #[account(mut)] + pub message_transmitter: UncheckedAccount<'info>, + + /// CHECK: TokenMessenger is checked in CCTP. Seeds must be \["token_messenger"\] (CCTP Token Messenger Minter + /// program). + pub token_messenger: UncheckedAccount<'info>, + + /// CHECK: RemoteTokenMessenger is checked in CCTP. Seeds must be \["remote_token_messenger"\, + /// remote_domain.to_string()] (CCTP Token Messenger Minter program). + pub remote_token_messenger: UncheckedAccount<'info>, + + /// CHECK: TokenMinter is checked in CCTP. Seeds must be \["token_minter"\] (CCTP Token Messenger Minter program). + pub token_minter: UncheckedAccount<'info>, + + /// CHECK: LocalToken is checked in CCTP. Seeds must be \["local_token", mint\] (CCTP Token Messenger Minter + /// program). + #[account(mut)] + pub local_token: UncheckedAccount<'info>, + + /// CHECK: EventAuthority is checked in CCTP. Seeds must be \["__event_authority"\] (CCTP Token Messenger Minter + /// program). + pub event_authority: UncheckedAccount<'info>, + + #[account(mut)] + pub message_sent_event_data: Signer<'info>, + + pub message_transmitter_program: Program<'info, MessageTransmitter>, + + pub token_messenger_minter_program: Program<'info, TokenMessengerMinter>, + + pub token_program: Interface<'info, TokenInterface>, + + pub system_program: Program<'info, System>, +} + +impl<'info> BridgeTokensToHubPool<'info> { + pub fn bridge_tokens_to_hub_pool( + &mut self, + amount: u64, + bumps: &BridgeTokensToHubPoolBumps, + ) -> Result<()> { + require!( + amount <= self.transfer_liability.pending_to_hub_pool, + CustomError::ExceededPendingBridgeAmount + ); + self.transfer_liability.pending_to_hub_pool -= amount; + + // Invoke CCTP to bridge vault tokens from state account. + let cpi_program = self.token_messenger_minter_program.to_account_info(); + let cpi_accounts = DepositForBurn { + owner: self.state.to_account_info(), + event_rent_payer: self.payer.to_account_info(), + sender_authority_pda: self + .token_messenger_minter_sender_authority + .to_account_info(), + burn_token_account: self.vault.to_account_info(), + message_transmitter: self.message_transmitter.to_account_info(), + token_messenger: self.token_messenger.to_account_info(), + remote_token_messenger: self.remote_token_messenger.to_account_info(), + token_minter: self.token_minter.to_account_info(), + local_token: self.local_token.to_account_info(), + burn_token_mint: self.mint.to_account_info(), + message_sent_event_data: self.message_sent_event_data.to_account_info(), + message_transmitter_program: self.message_transmitter_program.to_account_info(), + token_messenger_minter_program: self.token_messenger_minter_program.to_account_info(), + token_program: self.token_program.to_account_info(), + system_program: self.system_program.to_account_info(), + event_authority: self.event_authority.to_account_info(), + program: self.token_messenger_minter_program.to_account_info(), + }; + let state_seed_bytes = self.state.seed.to_le_bytes(); + let state_seeds: &[&[&[u8]]] = &[&[b"state", state_seed_bytes.as_ref(), &[bumps.state]]]; + let cpi_ctx = CpiContext::new_with_signer(cpi_program, cpi_accounts, state_seeds); + let params = DepositForBurnParams { + amount, + destination_domain: self.state.remote_domain, // CCTP domain for Mainnet Ethereum. + mint_recipient: self.state.cross_domain_admin, // This is same as HubPool. + }; + token_messenger_minter::cpi::deposit_for_burn(cpi_ctx, params)?; + + // TODO: emit event on bridged tokens. + + Ok(()) + } +} diff --git a/programs/svm-spoke/src/lib.rs b/programs/svm-spoke/src/lib.rs new file mode 100644 index 000000000..e11a55475 --- /dev/null +++ b/programs/svm-spoke/src/lib.rs @@ -0,0 +1,188 @@ +use anchor_lang::prelude::*; + +declare_id!("E5USYAs9DUzn6ykrWZXuEkbCnY3kzNMPGNFH2okvUvqe"); + +// External programs from idls directory (requires `anchor run generateExternalTypes`). +declare_program!(message_transmitter); +declare_program!(token_messenger_minter); + +pub mod constants; +mod constraints; +pub mod error; +mod instructions; +mod state; +pub mod utils; + +use instructions::*; +use state::*; + +#[program] +pub mod svm_spoke { + use super::*; + + // Admin methods. + pub fn initialize( + ctx: Context, + seed: u64, + initial_number_of_deposits: u64, + chain_id: u64, + remote_domain: u32, + cross_domain_admin: Pubkey, + testable_mode: bool, + ) -> Result<()> { + instructions::initialize( + ctx, + seed, + initial_number_of_deposits, + chain_id, + remote_domain, + cross_domain_admin, + testable_mode, + ) + } + + pub fn set_current_time(ctx: Context, new_time: u32) -> Result<()> { + instructions::set_current_time(ctx, new_time) + } + + pub fn pause_deposits(ctx: Context, pause: bool) -> Result<()> { + instructions::pause_deposits(ctx, pause) + } + + pub fn relay_root_bundle( + ctx: Context, + relayer_refund_root: [u8; 32], + slow_relay_root: [u8; 32], + ) -> Result<()> { + instructions::relay_root_bundle(ctx, relayer_refund_root, slow_relay_root) + } + + pub fn execute_relayer_refund_leaf<'info>( + ctx: Context<'_, '_, '_, 'info, ExecuteRelayerRefundLeaf<'info>>, + root_bundle_id: u32, + relayer_refund_leaf: RelayerRefundLeaf, + proof: Vec<[u8; 32]>, + ) -> Result<()> { + instructions::execute_relayer_refund_leaf(ctx, root_bundle_id, relayer_refund_leaf, proof) + } + + pub fn pause_fills(ctx: Context, pause: bool) -> Result<()> { + instructions::pause_fills(ctx, pause) + } + + pub fn transfer_ownership(ctx: Context, new_owner: Pubkey) -> Result<()> { + instructions::transfer_ownership(ctx, new_owner) + } + + pub fn set_enable_route( + ctx: Context, + origin_token: [u8; 32], + destination_chain_id: u64, + enabled: bool, + ) -> Result<()> { + instructions::set_enable_route(ctx, origin_token, destination_chain_id, enabled) + } + + pub fn set_cross_domain_admin( + ctx: Context, + cross_domain_admin: Pubkey, + ) -> Result<()> { + instructions::set_cross_domain_admin(ctx, cross_domain_admin) + } + + // User methods. + pub fn deposit_v3( + ctx: Context, + depositor: Pubkey, + recipient: Pubkey, + input_token: Pubkey, + output_token: Pubkey, + input_amount: u64, + output_amount: u64, + destination_chain_id: u64, + exclusive_relayer: Pubkey, + quote_timestamp: u32, + fill_deadline: u32, + exclusivity_deadline: u32, + message: Vec, + ) -> Result<()> { + instructions::deposit_v3( + ctx, + depositor, + recipient, + input_token, + output_token, + input_amount, + output_amount, + destination_chain_id, + exclusive_relayer, + quote_timestamp, + fill_deadline, + exclusivity_deadline, + message, + ) + } + + // Relayer methods. + pub fn fill_v3_relay( + ctx: Context, + relay_hash: [u8; 32], + relay_data: V3RelayData, + repayment_chain_id: u64, + ) -> Result<()> { + instructions::fill_v3_relay(ctx, relay_hash, relay_data, repayment_chain_id) + } + + pub fn close_fill_pda( + ctx: Context, + relay_hash: [u8; 32], + relay_data: V3RelayData, + ) -> Result<()> { + instructions::close_fill_pda(ctx, relay_hash, relay_data) + } + + // CCTP methods. + pub fn handle_receive_message<'info>( + ctx: Context<'_, '_, '_, 'info, HandleReceiveMessage<'info>>, + params: HandleReceiveMessageParams, + ) -> Result<()> { + let self_ix_data = ctx.accounts.handle_receive_message(¶ms)?; + + invoke_self(&ctx, &self_ix_data)?; + + Ok(()) + } + + // Slow fill methods. + pub fn request_v3_slow_fill( + ctx: Context, + relay_hash: [u8; 32], + relay_data: V3RelayData, + ) -> Result<()> { + instructions::request_v3_slow_fill(ctx, relay_hash, relay_data) + } + + pub fn execute_v3_slow_relay_leaf( + ctx: Context, + relay_hash: [u8; 32], + slow_fill_leaf: V3SlowFill, + root_bundle_id: u32, + proof: Vec<[u8; 32]>, + ) -> Result<()> { + instructions::execute_v3_slow_relay_leaf( + ctx, + relay_hash, + slow_fill_leaf, + root_bundle_id, + proof, + ) + } + pub fn bridge_tokens_to_hub_pool( + ctx: Context, + amount: u64, + ) -> Result<()> { + ctx.accounts.bridge_tokens_to_hub_pool(amount, &ctx.bumps)?; + + Ok(()) + } +} diff --git a/programs/svm-spoke/src/state/fill.rs b/programs/svm-spoke/src/state/fill.rs new file mode 100644 index 000000000..0be8d2f93 --- /dev/null +++ b/programs/svm-spoke/src/state/fill.rs @@ -0,0 +1,15 @@ +use anchor_lang::prelude::*; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, InitSpace, PartialEq)] +pub enum FillStatus { + Unfilled, + RequestedSlowFill, + Filled, +} + +#[account] +#[derive(InitSpace)] +pub struct FillStatusAccount { + pub status: FillStatus, + pub relayer: Pubkey, +} diff --git a/programs/svm-spoke/src/state/mod.rs b/programs/svm-spoke/src/state/mod.rs new file mode 100644 index 000000000..97601d0bf --- /dev/null +++ b/programs/svm-spoke/src/state/mod.rs @@ -0,0 +1,11 @@ +pub mod fill; +pub mod root_bundle; +pub mod route; +pub mod state; +pub mod transfer_liability; + +pub use fill::*; +pub use root_bundle::*; +pub use route::*; +pub use state::*; +pub use transfer_liability::*; diff --git a/programs/svm-spoke/src/state/root_bundle.rs b/programs/svm-spoke/src/state/root_bundle.rs new file mode 100644 index 000000000..2595b2452 --- /dev/null +++ b/programs/svm-spoke/src/state/root_bundle.rs @@ -0,0 +1,10 @@ +use anchor_lang::prelude::*; + +#[account] +#[derive(InitSpace)] +pub struct RootBundle { + pub relayer_refund_root: [u8; 32], + pub slow_relay_root: [u8; 32], + #[max_len(1)] + pub claimed_bitmap: Vec, // Dynamic sized vec to store claimed status of each relayer refund root leaf. +} diff --git a/programs/svm-spoke/src/state/route.rs b/programs/svm-spoke/src/state/route.rs new file mode 100644 index 000000000..e758379d7 --- /dev/null +++ b/programs/svm-spoke/src/state/route.rs @@ -0,0 +1,7 @@ +use anchor_lang::prelude::*; + +#[account] +#[derive(InitSpace)] +pub struct Route { + pub enabled: bool, +} diff --git a/programs/svm-spoke/src/state/state.rs b/programs/svm-spoke/src/state/state.rs new file mode 100644 index 000000000..0064c86a6 --- /dev/null +++ b/programs/svm-spoke/src/state/state.rs @@ -0,0 +1,16 @@ +use anchor_lang::prelude::*; + +#[account] +#[derive(InitSpace)] +pub struct State { + pub paused_deposits: bool, + pub paused_fills: bool, + pub owner: Pubkey, + pub seed: u64, // Add a seed to the state to enable multiple deployments. + pub number_of_deposits: u64, + pub chain_id: u64, // Across definition of chainId for Solana. + pub current_time: u32, // Only used in testable mode, else set to 0 on mainnet. + pub remote_domain: u32, // CCTP domain for Mainnet Ethereum. + pub cross_domain_admin: Pubkey, // HubPool on Mainnet Ethereum. + pub root_bundle_id: u32, +} diff --git a/programs/svm-spoke/src/state/transfer_liability.rs b/programs/svm-spoke/src/state/transfer_liability.rs new file mode 100644 index 000000000..468dd0d8e --- /dev/null +++ b/programs/svm-spoke/src/state/transfer_liability.rs @@ -0,0 +1,7 @@ +use anchor_lang::prelude::*; + +#[account] +#[derive(InitSpace)] +pub struct TransferLiability { + pub pending_to_hub_pool: u64, +} diff --git a/programs/svm-spoke/src/utils.rs b/programs/svm-spoke/src/utils.rs new file mode 100644 index 000000000..bd3942705 --- /dev/null +++ b/programs/svm-spoke/src/utils.rs @@ -0,0 +1,153 @@ +use crate::error::CustomError; +use anchor_lang::prelude::*; +use anchor_lang::solana_program::keccak; +use std::mem::size_of_val; + +use crate::{ + constants::DISCRIMINATOR_SIZE, error::CalldataError, instructions::V3RelayData, + program::SvmSpoke, +}; + +pub trait EncodeInstructionData { + fn encode_instruction_data(&self, discriminator_str: &str) -> Result>; +} + +impl EncodeInstructionData for T { + fn encode_instruction_data(&self, discriminator_str: &str) -> Result> { + let mut data = Vec::with_capacity(DISCRIMINATOR_SIZE + size_of_val(self)); + data.extend_from_slice( + &anchor_lang::solana_program::hash::hash(discriminator_str.as_bytes()).to_bytes() + [..DISCRIMINATOR_SIZE], + ); + data.extend_from_slice(&self.try_to_vec()?); + + Ok(data) + } +} + +pub fn encode_solidity_selector(signature: &str) -> [u8; 4] { + let hash = anchor_lang::solana_program::keccak::hash(signature.as_bytes()); + let mut selector = [0u8; 4]; + selector.copy_from_slice(&hash.to_bytes()[..4]); + selector +} + +pub fn get_solidity_selector(data: &Vec) -> Result<[u8; 4]> { + let slice = data.get(..4).ok_or(CalldataError::InvalidSelector)?; + let array = <[u8; 4]>::try_from(slice).unwrap(); + Ok(array) +} + +pub fn get_solidity_arg(data: &Vec, index: usize) -> Result<[u8; 32]> { + let offset = 4 + 32 * index; + let slice = data + .get(offset..offset + 32) + .ok_or(CalldataError::InvalidArgument)?; + let array = <[u8; 32]>::try_from(slice).unwrap(); + Ok(array) +} + +pub fn decode_solidity_bool(data: &[u8; 32]) -> Result { + let h_value = u128::from_be_bytes(data[..16].try_into().unwrap()); + let l_value = u128::from_be_bytes(data[16..].try_into().unwrap()); + match h_value { + 0 => match l_value { + 0 => Ok(false), + 1 => Ok(true), + _ => return Err(CalldataError::InvalidBool.into()), + }, + _ => return Err(CalldataError::InvalidBool.into()), + } +} + +pub fn get_self_authority_pda() -> Pubkey { + let (pda_address, _bump) = Pubkey::find_program_address(&[b"self_authority"], &SvmSpoke::id()); + pda_address +} + +pub fn decode_solidity_uint64(data: &[u8; 32]) -> Result { + let h_value = u128::from_be_bytes(data[..16].try_into().unwrap()); + let l_value = u128::from_be_bytes(data[16..].try_into().unwrap()); + if h_value > 0 || l_value > u64::MAX as u128 { + return Err(CalldataError::InvalidUint64.into()); + } + Ok(l_value as u64) +} + +pub fn decode_solidity_address(data: &[u8; 32]) -> Result { + for i in 0..12 { + if data[i] != 0 { + return Err(CalldataError::InvalidAddress.into()); + } + } + Ok(Pubkey::new_from_array(*data)) +} + +// Across specific utilities. +pub fn get_v3_relay_hash(relay_data: &V3RelayData, chain_id: u64) -> [u8; 32] { + let mut input = relay_data.try_to_vec().unwrap(); + input.extend_from_slice(&chain_id.to_le_bytes()); + // Log the input that will be hashed + msg!("Input to be hashed: {:?}", input); + keccak::hash(&input).0 +} + +pub fn verify_merkle_proof(root: [u8; 32], leaf: [u8; 32], proof: Vec<[u8; 32]>) -> Result<()> { + msg!("Verifying merkle proof"); + let computed_root = process_proof(&proof, &leaf); + if computed_root != root { + msg!("Invalid proof: computed root does not match provided root"); + return Err(CustomError::InvalidProof.into()); + } + msg!("Merkle proof verified successfully"); + Ok(()) +} + +// The following is the rust implementation of the merkle proof verification from OpenZeppelin that can be found here: +// https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/cryptography/MerkleProof.sol +pub fn process_proof(proof: &[[u8; 32]], leaf: &[u8; 32]) -> [u8; 32] { + let mut computed_hash = *leaf; + for proof_element in proof.iter() { + computed_hash = commutative_keccak256(&computed_hash, proof_element); + } + computed_hash +} + +// See https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/cryptography/Hashes.sol +fn commutative_keccak256(a: &[u8; 32], b: &[u8; 32]) -> [u8; 32] { + if a < b { + efficient_keccak256(a, b) + } else { + efficient_keccak256(b, a) + } +} + +fn efficient_keccak256(a: &[u8; 32], b: &[u8; 32]) -> [u8; 32] { + let mut input = [0u8; 64]; + input[..32].copy_from_slice(a); + input[32..].copy_from_slice(b); + keccak::hash(&input).0 +} + +//TODO: we might want to split this utils up into different files. we have a) CCTP b) Merkle proof c) Bitmap sections. At minimum we should have more comments splitting these up. + +pub fn is_claimed(claimed_bitmap: &Vec, index: u32) -> bool { + let byte_index = (index / 8) as usize; // Index of the byte in the array + if byte_index >= claimed_bitmap.len() { + return false; // Out of bounds, treat as not claimed + } + let bit_in_byte_index = (index % 8) as usize; // Index of the bit within the byte + let claimed_byte = claimed_bitmap[byte_index]; + let mask = 1 << bit_in_byte_index; + claimed_byte & mask == mask +} + +pub fn set_claimed(claimed_bitmap: &mut Vec, index: u32) { + let byte_index = (index / 8) as usize; // Index of the byte in the array + if byte_index >= claimed_bitmap.len() { + let new_size = byte_index + 1; + claimed_bitmap.resize(new_size, 0); // Resize the Vec if necessary + } + let bit_in_byte_index = (index % 8) as usize; // Index of the bit within the byte + claimed_bitmap[byte_index] |= 1 << bit_in_byte_index; +} diff --git a/programs/test/Cargo.toml b/programs/test/Cargo.toml new file mode 100644 index 000000000..d46f2acf5 --- /dev/null +++ b/programs/test/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "test" +version = "0.1.0" +description = "Created with Anchor" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "test" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] + +[dependencies] +anchor-lang = { version = "0.30.1", features = ["init-if-needed","event-cpi"]} +anchor-spl = "0.30.1" +svm-spoke = { path = "../svm-spoke", features = ["no-entrypoint"] } diff --git a/programs/test/Xargo.toml b/programs/test/Xargo.toml new file mode 100644 index 000000000..475fb71ed --- /dev/null +++ b/programs/test/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/programs/test/src/lib.rs b/programs/test/src/lib.rs new file mode 100644 index 000000000..36af7ed17 --- /dev/null +++ b/programs/test/src/lib.rs @@ -0,0 +1,91 @@ +use anchor_lang::prelude::*; + +use svm_spoke::constants::DISCRIMINATOR_SIZE; +use svm_spoke::error::CustomError; +use svm_spoke::utils::{is_claimed, process_proof, set_claimed}; + +declare_id!("GZp7L6MZ93G7TpAyxmaJ3GYgXnxH8x5oxSDmnEoob1Zu"); + +// This program is used to test the svm_spoke program internal utils methods. It's kept separate from the svm_spoke +// as it simply exports utils methods so direct unit tests can be run against them. + +#[program] +pub mod test { + use super::*; + + // Test Bitmap. + #[derive(Accounts)] + pub struct InitializeBitmap<'info> { + #[account(init, payer = signer, space = DISCRIMINATOR_SIZE + BitmapAccount::INIT_SPACE, + seeds = [b"bitmap_account"], + bump)] + pub bitmap_account: Account<'info, BitmapAccount>, + #[account(mut)] + pub signer: Signer<'info>, + pub system_program: Program<'info, System>, + } + + pub fn initialize(ctx: Context) -> Result<()> { + let bitmap_account = &mut ctx.accounts.bitmap_account; + bitmap_account.claimed_bitmap = vec![]; // Initialize Vec with zero size + Ok(()) + } + + #[derive(Accounts)] + #[instruction(index: u32)] + pub struct UpdateBitmap<'info> { + #[account(mut, + realloc = DISCRIMINATOR_SIZE + BitmapAccount::INIT_SPACE + index as usize / 8, + realloc::payer = signer, + realloc::zero = false)] + pub bitmap_account: Account<'info, BitmapAccount>, + + #[account(mut)] + pub signer: Signer<'info>, + + pub system_program: Program<'info, System>, + } + + pub fn test_set_claimed(ctx: Context, index: u32) -> Result<()> { + let bitmap_account = &mut ctx.accounts.bitmap_account; // Change to mutable reference + set_claimed(&mut bitmap_account.claimed_bitmap, index); + Ok(()) + } + + #[derive(Accounts)] + pub struct ViewBitmap<'info> { + pub bitmap_account: Account<'info, BitmapAccount>, + } + pub fn test_is_claimed(ctx: Context, index: u32) -> Result { + let bitmap_account = &ctx.accounts.bitmap_account; + let result = is_claimed(&bitmap_account.claimed_bitmap, index); + Ok(result) + } + + // Test Merkle. + #[derive(Accounts)] + pub struct Verify {} + pub fn verify( + ctx: Context, + root: [u8; 32], + leaf: [u8; 32], + proof: Vec<[u8; 32]>, + ) -> Result<()> { + let computed_root = process_proof(&proof, &leaf); + if computed_root != root { + msg!("Invalid proof: computed root does not match provided root"); + return Err(CustomError::InvalidProof.into()); + } + msg!("Merkle proof verified successfully"); + Ok(()) + } +} + +// State. + +#[derive(InitSpace)] +#[account] +pub struct BitmapAccount { + #[max_len(1)] + pub claimed_bitmap: Vec, +} diff --git a/programs/testacross/src/lib.rs b/programs/testacross/src/lib.rs deleted file mode 100644 index f1baa4c49..000000000 --- a/programs/testacross/src/lib.rs +++ /dev/null @@ -1,16 +0,0 @@ -use anchor_lang::prelude::*; - -declare_id!("E4dpZS9P24pscXvPngpeUhrR98uZYMfi3VMLnLaYDA6b"); - -#[program] -pub mod testacross { - use super::*; - - pub fn initialize(ctx: Context) -> Result<()> { - msg!("Greetings from: {:?}", ctx.program_id); - Ok(()) - } -} - -#[derive(Accounts)] -pub struct Initialize {} diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 000000000..4e727a08b --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +reorder_imports = true \ No newline at end of file diff --git a/scripts/svm/closeRelayerPdas.ts b/scripts/svm/closeRelayerPdas.ts new file mode 100644 index 000000000..0f8be9f7c --- /dev/null +++ b/scripts/svm/closeRelayerPdas.ts @@ -0,0 +1,129 @@ +import * as anchor from "@coral-xyz/anchor"; +import { BN, Program, AnchorProvider } from "@coral-xyz/anchor"; +import { PublicKey, SystemProgram } from "@solana/web3.js"; +import { SvmSpoke } from "../../target/types/svm_spoke"; +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; +import { ethers } from "ethers"; +import { readProgramEvents } from "../../src/SvmUtils"; +import { calculateRelayHashUint8Array } from "../../src/SvmUtils"; + +// Set up the provider +const provider = AnchorProvider.env(); +anchor.setProvider(provider); +const idl = require("../../target/idl/svm_spoke.json"); +const program = new Program(idl, provider); +const programId = program.programId; // Use programId from the provider + +// Parse arguments +const argv = yargs(hideBin(process.argv)) + .option("seed", { type: "string", demandOption: true, describe: "Seed for the state account PDA" }) + .option("relayer", { type: "string", demandOption: true, describe: "Relayer public key" }).argv; + +const relayer = new PublicKey(argv.relayer); +const seed = new BN(argv.seed); + +async function closeExpiredRelays(): Promise { + console.table([ + { Property: "relayer", Value: relayer.toString() }, + { Property: "seed", Value: seed.toString() }, + { Property: "programId", Value: programId.toString() }, + ]); + + try { + const events = await readProgramEvents(provider.connection, program); + const fillEvents = events.filter( + (event) => event.name === "filledV3Relay" && new PublicKey(event.data.relayer).equals(relayer) + ); + + console.log(`Number of fill events found: ${fillEvents.length}`); + + if (fillEvents.length === 0) { + console.log("No fill events found for the given relayer."); + return; + } + + for (const event of fillEvents) { + const currentTime = Math.floor(Date.now() / 1000); + if (currentTime > event.data.fillDeadline) { + await closeFillPda(event.data); + } else { + console.log( + `Found relay with depositId: ${event.data.depositId} from source chain id: ${event.data.originChainId}, but it is not expired yet.` + ); + } + } + } catch (error) { + console.error("An error occurred while fetching the fill events:", error); + } +} + +async function closeFillPda(eventData: any): Promise { + const relayData = { + depositor: new PublicKey(eventData.depositor), + recipient: new PublicKey(eventData.recipient), + exclusiveRelayer: new PublicKey(eventData.exclusiveRelayer), + inputToken: new PublicKey(eventData.inputToken), + outputToken: new PublicKey(eventData.outputToken), + inputAmount: new BN(eventData.inputAmount), + outputAmount: new BN(eventData.outputAmount), + originChainId: new BN(eventData.originChainId), + depositId: eventData.depositId, + fillDeadline: eventData.fillDeadline, + exclusivityDeadline: eventData.exclusivityDeadline, + message: Buffer.from(eventData.message), + }; + + const [statePda] = PublicKey.findProgramAddressSync( + [Buffer.from("state"), seed.toArrayLike(Buffer, "le", 8)], + programId + ); + + // Fetch the state to get the chainId + const state = await program.account.state.fetch(statePda); + const chainId = new BN(state.chainId); + + const relayHashUint8Array = calculateRelayHashUint8Array(relayData, chainId); + + const [fillStatusPda] = PublicKey.findProgramAddressSync([Buffer.from("fills"), relayHashUint8Array], programId); + + try { + // Check if the fillStatusPda account exists + const accountInfo = await provider.connection.getAccountInfo(fillStatusPda); + if (!accountInfo) { + console.log( + `Fill Status PDA for depositId: ${eventData.depositId} from source chain id: ${eventData.originChainId} is already closed or does not exist.` + ); + return; + } + // Display additional information in a table + console.log("Found a relay to close. Relay Data:"); + console.table( + Object.entries(relayData).map(([key, value]) => ({ + key, + value: value.toString(), + })) + ); + console.table([ + { Property: "State PDA", Value: statePda.toString() }, + { Property: "Fill Status PDA", Value: fillStatusPda.toString() }, + { Property: "Relay Hash", Value: Buffer.from(relayHashUint8Array).toString("hex") }, + ]); + + const tx = await (program.methods.closeFillPda(Array.from(relayHashUint8Array), relayData) as any) + .accounts({ + state: statePda, + signer: provider.wallet.publicKey, + fillStatus: fillStatusPda, + systemProgram: SystemProgram.programId, + }) + .rpc(); + + console.log("Transaction signature:", tx); + } catch (error) { + console.error("An error occurred while closing the fill PDA:", error); + } +} + +// Run the closeExpiredRelays function +closeExpiredRelays(); diff --git a/scripts/svm/enableRoute.ts b/scripts/svm/enableRoute.ts new file mode 100644 index 000000000..daeb9a8d2 --- /dev/null +++ b/scripts/svm/enableRoute.ts @@ -0,0 +1,83 @@ +import * as anchor from "@coral-xyz/anchor"; +import { BN, Program, AnchorProvider } from "@coral-xyz/anchor"; +import { PublicKey, SystemProgram } from "@solana/web3.js"; +import { ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID, getAssociatedTokenAddressSync } from "@solana/spl-token"; +import { SvmSpoke } from "../../target/types/svm_spoke"; +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; + +// Set up the provider +const provider = AnchorProvider.env(); +anchor.setProvider(provider); +const idl = require("../../target/idl/svm_spoke.json"); +const program = new Program(idl, provider); +const programId = program.programId; + +// Parse arguments +const argv = yargs(hideBin(process.argv)) + .option("seed", { type: "string", demandOption: true, describe: "Seed for the state account PDA" }) + .option("originToken", { type: "string", demandOption: true, describe: "Origin token public key" }) + .option("chainId", { type: "string", demandOption: true, describe: "Chain ID" }) + .option("enabled", { type: "boolean", demandOption: true, describe: "Enable or disable the route" }).argv; + +const seed = new BN(argv.seed); +const originToken = Array.from(new PublicKey(argv.originToken).toBytes()); // Convert to number[] +const chainId = new BN(argv.chainId); +const enabled = argv.enabled; + +// Define the state account PDA +const [statePda, _] = PublicKey.findProgramAddressSync( + [Buffer.from("state"), seed.toArrayLike(Buffer, "le", 8)], + programId +); + +// Define the route account PDA +const [routePda] = PublicKey.findProgramAddressSync( + [Buffer.from("route"), Buffer.from(originToken), chainId.toArrayLike(Buffer, "le", 8)], + programId +); + +// Define the signer (replace with your actual signer) +const signer = provider.wallet.publicKey; + +async function enableRoute(): Promise { + console.log("Enabling route..."); + console.table([ + { Property: "seed", Value: seed.toString() }, + { Property: "originToken", Value: new PublicKey(originToken).toString() }, + { Property: "chainId", Value: chainId.toString() }, + { Property: "enabled", Value: enabled }, + { Property: "programId", Value: programId.toString() }, + { Property: "providerPublicKey", Value: provider.wallet.publicKey.toString() }, + { Property: "statePda", Value: statePda.toString() }, + { Property: "routePda", Value: routePda.toString() }, + ]); + + // Create ATA for the origin token to be stored by state (vault). + const vault = getAssociatedTokenAddressSync( + new PublicKey(originToken), + statePda, + true, + TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID + ); + + const tx = await (program.methods.setEnableRoute(originToken, chainId, enabled) as any) + .accounts({ + signer: signer, + payer: signer, + state: statePda, + route: routePda, + vault: vault, + originTokenMint: new PublicKey(originToken), + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + }) + .rpc(); + + console.log("Transaction signature:", tx); +} + +// Run the enableRoute function +enableRoute(); diff --git a/scripts/svm/generateExternalTypes.ts b/scripts/svm/generateExternalTypes.ts new file mode 100644 index 000000000..514718d41 --- /dev/null +++ b/scripts/svm/generateExternalTypes.ts @@ -0,0 +1,88 @@ +import * as anchor from "@coral-xyz/anchor"; +import { exec } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; + +const RPC_URL = "https://api.devnet.solana.com"; + +const main = async () => { + const externalPrograms = [ + { name: "message_transmitter", id: "CCTPmbSD7gX1bxKPAmg77w8oFzNFpaQiQUWD43TKaecd" }, + { name: "token_messenger_minter", id: "CCTPiPYPc6AsJuwueEnWgSgucamXDZwBd53dQ11YiKX3" }, + ]; + + for (const program of externalPrograms) { + await fetchIdl(program.name, program.id); + await convertIdl(program.name); + await generateType(program.name); + await copyIdls(program.name); + } +}; + +const fetchIdl = async (programName: string, programId: string) => { + const provider = anchor.AnchorProvider.local(RPC_URL); + + const idl = (await anchor.Program.fetchIdl(programId, provider)) as any; + + // CCTP programs have missing metadata.address + idl.metadata = { address: programId }; + + const idlDir = path.resolve(__dirname, "../../target/idl"); + const outputFilePath = path.join(idlDir, `${programName}.json`); + fs.writeFileSync(outputFilePath, JSON.stringify(idl, null, 2)); +}; + +const convertIdl = async (programName: string): Promise => { + const idlDir = path.resolve(__dirname, "../../target/idl"); + const idlFilePath = path.join(idlDir, `${programName}.json`); + + return new Promise((resolve, reject) => { + exec(`anchor idl convert --out ${idlFilePath} ${idlFilePath}`, (err, _stdout, stderr) => { + if (stderr) { + console.error(`${stderr}`); + } + if (err) { + reject(new Error(`Failed to convert ${programName} IDL`)); + } else { + resolve(); + } + }); + }); +}; + +const generateType = async (programName: string): Promise => { + const idlDir = path.resolve(__dirname, "../../target/idl"); + const idlFilePath = path.join(idlDir, `${programName}.json`); + const typesDir = path.resolve(__dirname, "../../target/types"); + const typeFilePath = path.join(typesDir, `${programName}.ts`); + + return new Promise((resolve, reject) => { + exec(`anchor idl type --out "${typeFilePath}" "${idlFilePath}"`, (err, _stdout, stderr) => { + if (stderr) { + console.error(`${stderr}`); + } + if (err) { + reject(new Error(`Failed to generate type for ${programName}`)); + } else { + resolve(); + } + }); + }); +}; + +const copyIdls = async (programName: string): Promise => { + const idlDir = path.resolve(__dirname, "../../target/idl"); + const idlsDir = path.resolve(__dirname, "../../idls"); + const idlFilePath = path.join(idlDir, `${programName}.json`); + const idlFilePathCopy = path.join(idlsDir, `${programName}.json`); + + if (!fs.existsSync(idlsDir)) fs.mkdirSync(idlsDir, { recursive: true }); + fs.copyFileSync(idlFilePath, idlFilePathCopy); +}; + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/scripts/svm/initialize.ts b/scripts/svm/initialize.ts new file mode 100644 index 000000000..781844102 --- /dev/null +++ b/scripts/svm/initialize.ts @@ -0,0 +1,75 @@ +import * as anchor from "@coral-xyz/anchor"; +import { BN, Program, AnchorProvider } from "@coral-xyz/anchor"; +import { PublicKey, SystemProgram } from "@solana/web3.js"; +import { SvmSpoke } from "../../target/types/svm_spoke"; +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; +import { evmAddressToPublicKey } from "../../src/SvmUtils"; + +// Set up the provider +const provider = AnchorProvider.env(); +anchor.setProvider(provider); +const idl = require("../../target/idl/svm_spoke.json"); +const program = new Program(idl, provider); +const programId = program.programId; + +// Parse arguments +const argv = yargs(hideBin(process.argv)) + .option("seed", { type: "string", demandOption: true, describe: "Seed for the state account PDA" }) + .option("initNumbDeposits", { type: "string", demandOption: true, describe: "Init numb of deposits" }) + .option("chainId", { type: "string", demandOption: true, describe: "Chain ID" }) + .option("remoteDomain", { type: "number", demandOption: true, describe: "CCTP domain for Mainnet Ethereum" }) + .option("crossDomainAdmin", { type: "string", demandOption: true, describe: "HubPool on Mainnet Ethereum" }).argv; + +const seed = new BN(argv.seed); +const initialNumberOfDeposits = new BN(argv.initNumbDeposits); +const chainId = new BN(argv.chainId); +const remoteDomain = argv.remoteDomain; +const crossDomainAdmin = evmAddressToPublicKey(argv.crossDomainAdmin); // Use the function to cast the value +const testableMode = false; // Hardcode testableMode to false + +// Define the state account PDA +const [statePda, _] = PublicKey.findProgramAddressSync( + [Buffer.from("state"), seed.toArrayLike(Buffer, "le", 8)], + programId +); + +// Define the signer (replace with your actual signer) +const signer = provider.wallet.publicKey; + +async function initialize(): Promise { + console.log("Initializing..."); + console.table([ + { Property: "seed", Value: seed.toString() }, + { Property: "initialNumberOfDeposits", Value: initialNumberOfDeposits.toString() }, + { Property: "programId", Value: programId.toString() }, + { Property: "providerPublicKey", Value: provider.wallet.publicKey.toString() }, + { Property: "statePda", Value: statePda.toString() }, + { Property: "chainId", Value: chainId.toString() }, + { Property: "remoteDomain", Value: remoteDomain.toString() }, + { Property: "crossDomainAdmin", Value: crossDomainAdmin.toString() }, + { Property: "testableMode", Value: testableMode.toString() }, + ]); + + const tx = await ( + program.methods.initialize( + seed, + initialNumberOfDeposits, + chainId, + remoteDomain, + crossDomainAdmin, + testableMode + ) as any + ) + .accounts({ + state: statePda, + signer: signer, + systemProgram: SystemProgram.programId, + }) + .rpc(); + + console.log("Transaction signature:", tx); +} + +// Run the initialize function +initialize(); diff --git a/scripts/svm/queryDeposits.ts b/scripts/svm/queryDeposits.ts new file mode 100644 index 000000000..774a14265 --- /dev/null +++ b/scripts/svm/queryDeposits.ts @@ -0,0 +1,73 @@ +import * as anchor from "@coral-xyz/anchor"; +import { BN, Program, AnchorProvider } from "@coral-xyz/anchor"; +import { PublicKey } from "@solana/web3.js"; +import { SvmSpoke } from "../../target/types/svm_spoke"; +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; +import { readProgramEvents } from "../../src/SvmUtils"; + +// Set up the provider +const provider = AnchorProvider.env(); +anchor.setProvider(provider); +const idl = require("../../target/idl/svm_spoke.json"); +const program = new Program(idl, provider); +const programId = program.programId; + +// Parse arguments +const argv = yargs(hideBin(process.argv)).option("seed", { + type: "string", + demandOption: true, + describe: "Seed for the state account PDA", +}).argv; + +const seed = new BN(argv.seed); + +// Define the state account PDA +const [statePda, _] = PublicKey.findProgramAddressSync( + [Buffer.from("state"), seed.toArrayLike(Buffer, "le", 8)], + programId +); + +async function queryDeposits(): Promise { + console.table([ + { Property: "seed", Value: seed.toString() }, + { Property: "programId", Value: programId.toString() }, + { Property: "statePda", Value: statePda.toString() }, + ]); + + try { + const events = await readProgramEvents(provider.connection, program); + console.log("events", events); + const depositEvents = events.filter((event) => event.name === "v3FundsDeposited"); + + if (depositEvents.length === 0) { + console.log("No deposit events found for the given seed."); + return; + } + + console.log("Deposit events fetched successfully:"); + depositEvents.forEach((event, index) => { + console.log(`Deposit Event ${index + 1}:`); + console.table([ + { Property: "inputToken", Value: new PublicKey(event.data.inputToken).toString() }, + { Property: "outputToken", Value: new PublicKey(event.data.outputToken).toString() }, + { Property: "inputAmount", Value: event.data.inputAmount.toString() }, + { Property: "outputAmount", Value: event.data.outputAmount.toString() }, + { Property: "destinationChainId", Value: event.data.destinationChainId.toString() }, + { Property: "depositId", Value: event.data.depositId.toString() }, + { Property: "quoteTimestamp", Value: event.data.quoteTimestamp.toString() }, + { Property: "fillDeadline", Value: event.data.fillDeadline.toString() }, + { Property: "exclusivityDeadline", Value: event.data.exclusivityDeadline.toString() }, + { Property: "depositor", Value: new PublicKey(event.data.depositor).toString() }, + { Property: "recipient", Value: new PublicKey(event.data.recipient).toString() }, + { Property: "exclusiveRelayer", Value: new PublicKey(event.data.exclusiveRelayer).toString() }, + { Property: "message", Value: event.data.message.toString() }, + ]); + }); + } catch (error) { + console.error("An error occurred while fetching the deposit events:", error); + } +} + +// Run the queryDeposits function +queryDeposits(); diff --git a/scripts/svm/queryFills.ts b/scripts/svm/queryFills.ts new file mode 100644 index 000000000..09a022bdc --- /dev/null +++ b/scripts/svm/queryFills.ts @@ -0,0 +1,80 @@ +import * as anchor from "@coral-xyz/anchor"; +import { BN, Program, AnchorProvider } from "@coral-xyz/anchor"; +import { PublicKey } from "@solana/web3.js"; +import { SvmSpoke } from "../../target/types/svm_spoke"; +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; +import { readProgramEvents } from "../../src/SvmUtils"; + +// Set up the provider +const provider = AnchorProvider.env(); +anchor.setProvider(provider); +const idl = require("../../target/idl/svm_spoke.json"); +const program = new Program(idl, provider); +const programId = program.programId; + +// Parse arguments +const argv = yargs(hideBin(process.argv)).option("seed", { + type: "string", + demandOption: true, + describe: "Seed for the state account PDA", +}).argv; + +const seed = new BN(argv.seed); + +// Define the state account PDA +const [statePda, _] = PublicKey.findProgramAddressSync( + [Buffer.from("state"), seed.toArrayLike(Buffer, "le", 8)], + programId +); + +async function queryFills(): Promise { + console.table([ + { Property: "seed", Value: seed.toString() }, + { Property: "programId", Value: programId.toString() }, + { Property: "statePda", Value: statePda.toString() }, + ]); + + try { + const events = await readProgramEvents(provider.connection, program); + const fillEvents = events.filter((event) => event.name === "filledV3Relay"); + + if (fillEvents.length === 0) { + console.log("No fill events found for the given seed."); + return; + } + + console.log("Fill events fetched successfully:"); + fillEvents.forEach((event, index) => { + console.log(`Fill Event ${index + 1}:`); + console.table([ + { Property: "inputToken", Value: new PublicKey(event.data.inputToken).toString() }, + { Property: "outputToken", Value: new PublicKey(event.data.outputToken).toString() }, + { Property: "inputAmount", Value: event.data.inputAmount.toString() }, + { Property: "outputAmount", Value: event.data.outputAmount.toString() }, + { Property: "repaymentChainId", Value: event.data.repaymentChainId.toString() }, + { Property: "originChainId", Value: event.data.originChainId.toString() }, + { Property: "depositId", Value: event.data.depositId.toString() }, + { Property: "fillDeadline", Value: event.data.fillDeadline.toString() }, + { Property: "exclusivityDeadline", Value: event.data.exclusivityDeadline.toString() }, + { Property: "exclusiveRelayer", Value: new PublicKey(event.data.exclusiveRelayer).toString() }, + { Property: "relayer", Value: new PublicKey(event.data.relayer).toString() }, + { Property: "depositor", Value: new PublicKey(event.data.depositor).toString() }, + { Property: "recipient", Value: new PublicKey(event.data.recipient).toString() }, + { Property: "message", Value: event.data.message.toString() }, + { + Property: "updatedRecipient", + Value: new PublicKey(event.data.relayExecutionInfo.updatedRecipient).toString(), + }, + { Property: "updatedMessage", Value: event.data.relayExecutionInfo.updatedMessage.toString() }, + { Property: "updatedOutputAmount", Value: event.data.relayExecutionInfo.updatedOutputAmount.toString() }, + { Property: "fillType", Value: event.data.relayExecutionInfo.fillType }, + ]); + }); + } catch (error) { + console.error("An error occurred while fetching the fill events:", error); + } +} + +// Run the queryFills function +queryFills(); diff --git a/scripts/svm/queryRoute.ts b/scripts/svm/queryRoute.ts new file mode 100644 index 000000000..4dabb4921 --- /dev/null +++ b/scripts/svm/queryRoute.ts @@ -0,0 +1,80 @@ +import * as anchor from "@coral-xyz/anchor"; +import { BN, Program, AnchorProvider } from "@coral-xyz/anchor"; +import { PublicKey } from "@solana/web3.js"; +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + getAssociatedTokenAddressSync, + getAccount, +} from "@solana/spl-token"; +import { SvmSpoke } from "../../target/types/svm_spoke"; +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; + +// Set up the provider +const provider = AnchorProvider.env(); +anchor.setProvider(provider); +const idl = require("../../target/idl/svm_spoke.json"); +const program = new Program(idl, provider); +const programId = program.programId; + +// Parse arguments +const argv = yargs(hideBin(process.argv)) + .option("originToken", { type: "string", demandOption: true, describe: "Origin token public key" }) + .option("chainId", { type: "string", demandOption: true, describe: "Chain ID" }).argv; + +const originToken = Array.from(new PublicKey(argv.originToken).toBytes()); // Convert to number[] +const chainId = new BN(argv.chainId); + +// Define the route account PDA +const [routePda] = PublicKey.findProgramAddressSync( + [Buffer.from("route"), Buffer.from(originToken), chainId.toArrayLike(Buffer, "le", 8)], + programId +); + +// Define the state account PDA (assuming the seed is known or can be derived) +const seed = new BN(0); // Replace with actual seed if known +const [statePda] = PublicKey.findProgramAddressSync( + [Buffer.from("state"), seed.toArrayLike(Buffer, "le", 8)], + programId +); + +// Compute the vault address +const vault = getAssociatedTokenAddressSync( + new PublicKey(originToken), + statePda, + true, + TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID +); + +async function queryRoute(): Promise { + console.log("Querying route..."); + console.table([ + { Property: "originToken", Value: new PublicKey(originToken).toString() }, + { Property: "chainId", Value: chainId.toString() }, + { Property: "programId", Value: programId.toString() }, + { Property: "routePda", Value: routePda.toString() }, + { Property: "vault", Value: vault.toString() }, + ]); + + try { + const route = await program.account.route.fetch(routePda); + const vaultAccount = await getAccount(provider.connection, vault); + + console.log("Route fetched successfully:"); + console.table([ + { Property: "Enabled", Value: route.enabled }, + { Property: "vaultBalance", Value: vaultAccount.amount.toString() }, + ]); + } catch (error) { + if (error.message.includes("Account does not exist or has no data")) { + console.log("No route has been created for the given origin token and chain ID."); + } else { + console.error("An error occurred while fetching the route:", error); + } + } +} + +// Run the queryRoute function +queryRoute(); diff --git a/scripts/svm/queryState.ts b/scripts/svm/queryState.ts new file mode 100644 index 000000000..ed786ded3 --- /dev/null +++ b/scripts/svm/queryState.ts @@ -0,0 +1,53 @@ +import * as anchor from "@coral-xyz/anchor"; +import { BN, Program, AnchorProvider } from "@coral-xyz/anchor"; +import { PublicKey } from "@solana/web3.js"; +import { SvmSpoke } from "../../target/types/svm_spoke"; +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; + +// Set up the provider +const provider = AnchorProvider.env(); +anchor.setProvider(provider); +const idl = require("../../target/idl/svm_spoke.json"); +const program = new Program(idl, provider); +const programId = program.programId; + +// Parse arguments +const argv = yargs(hideBin(process.argv)).option("seed", { + type: "string", + demandOption: true, + describe: "Seed for the state account PDA", +}).argv; + +const seed = new BN(argv.seed); + +// Define the state account PDA +const [statePda, _] = PublicKey.findProgramAddressSync( + [Buffer.from("state"), seed.toArrayLike(Buffer, "le", 8)], + programId +); + +async function queryState(): Promise { + console.log("Querying state..."); + console.table([ + { Property: "seed", Value: seed.toString() }, + { Property: "programId", Value: programId.toString() }, + { Property: "statePda", Value: statePda.toString() }, + ]); + + try { + const state = await program.account.state.fetch(statePda); + console.log("State fetched successfully:"); + console.table([ + { Property: "Owner", Value: state.owner.toString() }, + { Property: "Deposits Enabled", Value: !state.pausedDeposits }, + { Property: "Number of Deposits", Value: state.numberOfDeposits.toString() }, + { Property: "Chain ID", Value: state.chainId.toString() }, // Added chainId + ]); + } catch (error) { + console.error("An error occurred while fetching the state:", error); + } +} + +// Run the queryState function +queryState(); diff --git a/scripts/svm/remotePauseDeposits.ts b/scripts/svm/remotePauseDeposits.ts new file mode 100644 index 000000000..5ebf0c98d --- /dev/null +++ b/scripts/svm/remotePauseDeposits.ts @@ -0,0 +1,231 @@ +// This script bridges remote call to pause deposits on Solana Spoke Pool. Required environment: +// - ETHERS_PROVIDER_URL: Ethereum RPC provider URL. +// - ETHERS_MNEMONIC: Mnemonic of the wallet that will sign the sending transaction on Ethereum + +import "dotenv/config"; +import * as anchor from "@coral-xyz/anchor"; +import { AccountMeta, PublicKey } from "@solana/web3.js"; +import { SvmSpoke } from "../../target/types/svm_spoke"; +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; +import { ethers } from "ethers"; +import { MessageTransmitter } from "../../target/types/message_transmitter"; +import { decodeMessageHeader, getMessages } from "../../test/svm/cctpHelpers"; + +// Set up Solana provider. +const provider = anchor.AnchorProvider.env(); +anchor.setProvider(provider); + +// Parse arguments +const argv = yargs(hideBin(process.argv)) + .option("seed", { type: "string", demandOption: true, describe: "Seed for the state account PDA" }) + .option("pause", { type: "boolean", demandOption: false, describe: "Enable or disable deposits" }) + .option("resumeRemoteTx", { type: "string", demandOption: false, describe: "Resume receiving remote tx" }) + .check((argv) => { + if (argv.pause !== undefined && argv.resumeRemoteTx !== undefined) { + throw new Error("Options --pause and --resumeRemoteTx are mutually exclusive"); + } + if (argv.pause === undefined && argv.resumeRemoteTx === undefined) { + throw new Error("One of the options --pause or --resumeRemoteTx is required"); + } + return true; + }).argv; + +const seed = new anchor.BN(argv.seed); +const pause = argv.pause; +const resumeRemoteTx = argv.resumeRemoteTx; + +// Set up Ethereum provider. +if (!process.env.ETHERS_PROVIDER_URL) { + throw new Error("Environment variable ETHERS_PROVIDER_URL is not set"); +} +const ethersProvider = new ethers.providers.JsonRpcProvider(process.env.ETHERS_PROVIDER_URL); +if (!process.env.ETHERS_MNEMONIC) { + throw new Error("Environment variable ETHERS_MNEMONIC is not set"); +} +const ethersSigner = ethers.Wallet.fromMnemonic(process.env.ETHERS_MNEMONIC).connect(ethersProvider); + +// CCTP domains. +const remoteDomain = 0; // Ethereum +const localDomain = 5; // Solana + +// Get Solana programs and accounts. +const svmSpokeIdl = require("../../target/idl/svm_spoke.json"); +const svmSpokeProgram = new anchor.Program(svmSpokeIdl, provider); +const [statePda, _] = PublicKey.findProgramAddressSync( + [Buffer.from("state"), seed.toArrayLike(Buffer, "le", 8)], + svmSpokeProgram.programId +); +const messageTransmitterIdl = require("../../target/idl/message_transmitter.json"); +const messageTransmitterProgram = new anchor.Program(messageTransmitterIdl, provider); +const [messageTransmitterState] = PublicKey.findProgramAddressSync( + [Buffer.from("message_transmitter")], + messageTransmitterProgram.programId +); +const [authorityPda] = PublicKey.findProgramAddressSync( + [Buffer.from("message_transmitter_authority"), svmSpokeProgram.programId.toBuffer()], + messageTransmitterProgram.programId +); +const [selfAuthority] = PublicKey.findProgramAddressSync([Buffer.from("self_authority")], svmSpokeProgram.programId); +const [eventAuthority] = PublicKey.findProgramAddressSync( + [Buffer.from("__event_authority")], + svmSpokeProgram.programId +); + +async function remotePauseDeposits(): Promise { + let cluster: "devnet" | "mainnet"; + const rpcEndpoint = provider.connection.rpcEndpoint; + if (rpcEndpoint.includes("devnet")) cluster = "devnet"; + else if (rpcEndpoint.includes("mainnet")) cluster = "mainnet"; + else throw new Error(`Unsupported cluster endpoint: ${rpcEndpoint}`); + + const irisApiUrl = cluster == "devnet" ? "https://iris-api-sandbox.circle.com" : "https://iris-api.circle.com"; + + const supportedChainId = cluster == "devnet" ? 11155111 : 1; // Sepolia is bridged to devnet, Ethereum to mainnet in CCTP. + const chainId = (await ethersProvider.getNetwork()).chainId; + // TODO: improve type casting. + if ((chainId as any) !== BigInt(supportedChainId)) { + throw new Error(`Chain ID ${chainId} does not match expected Solana cluster ${cluster}`); + } + + const messageTransmitterRemoteIface = new ethers.utils.Interface([ + "function sendMessage(uint32 destinationDomain, bytes32 recipient, bytes messageBody)", + "event MessageSent(bytes message)", + ]); + + // CCTP MessageTransmitter from https://developers.circle.com/stablecoins/docs/evm-smart-contracts + const messageTransmitterRemoteAddress = + cluster == "devnet" ? "0x7865fAfC2db2093669d92c0F33AeEF291086BEFD" : "0x0a992d191deec32afe36203ad87d7d289a738f81"; + + const messageTransmitterRemote = new ethers.Contract( + messageTransmitterRemoteAddress, + messageTransmitterRemoteIface, + ethersSigner + ); + + const spokePoolIface = new ethers.utils.Interface(["function pauseDeposits(bool pause)"]); + + console.log("Remotely controlling pausedDeposits..."); + console.table([ + { Property: "seed", Value: seed.toString() }, + { Property: "chainId", Value: (chainId as any).toString() }, + { Property: "pause", Value: pause }, + { Property: "svmSpokeProgramProgramId", Value: svmSpokeProgram.programId.toString() }, + { Property: "providerPublicKey", Value: provider.wallet.publicKey.toString() }, + { Property: "statePda", Value: statePda.toString() }, + { Property: "messageTransmitterProgramId", Value: messageTransmitterProgram.programId.toString() }, + { Property: "messageTransmitterState", Value: messageTransmitterState.toString() }, + { Property: "authorityPda", Value: authorityPda.toString() }, + { Property: "selfAuthority", Value: selfAuthority.toString() }, + { Property: "eventAuthority", Value: eventAuthority.toString() }, + { Property: "messageTransmitterRemoteAddress", Value: messageTransmitterRemoteAddress }, + { Property: "remoteSender", Value: ethersSigner.address }, + ]); + + // Send pauseDeposits call from Ethereum, unless resuming a remote transaction. + let remoteTxHash: string; + if (!resumeRemoteTx) { + console.log("Sending pauseDeposits message from remote domain..."); + const calldata = spokePoolIface.encodeFunctionData("pauseDeposits", [pause]); + const sendTx = await messageTransmitterRemote.sendMessage.send( + localDomain, + svmSpokeProgram.programId.toBytes(), + calldata + ); + await sendTx.wait(); + remoteTxHash = sendTx.hash; + console.log("Message sent on remote chain, tx", remoteTxHash); + } else remoteTxHash = resumeRemoteTx; + + // Fetch attestation from CCTP attestation service. + const attestationResponse = await getMessages(remoteTxHash, remoteDomain, irisApiUrl); + const { attestation, message } = attestationResponse.messages[0]; + console.log("CCTP attestation response:", attestationResponse.messages[0]); + + // Accounts in CCTP message_transmitter receive_message instruction. + const nonce = decodeMessageHeader(Buffer.from(message.replace("0x", ""), "hex")).nonce; + const usedNonces = (await messageTransmitterProgram.methods + .getNoncePda({ + nonce: new anchor.BN(nonce.toString()), + sourceDomain: remoteDomain, + }) + .accounts({ + messageTransmitter: messageTransmitterState, + }) + .view()) as PublicKey; + const receiveMessageAccounts = { + payer: provider.wallet.publicKey, + caller: provider.wallet.publicKey, + authorityPda, + messageTransmitter: messageTransmitterState, + usedNonces, + receiver: svmSpokeProgram.programId, + systemProgram: anchor.web3.SystemProgram.programId, + }; + + // accountMetas list to pass to remaining accounts when receiving message via CCTP. + const remainingAccounts: AccountMeta[] = []; + // state in HandleReceiveMessage accounts (used for remote domain and sender authentication). + remainingAccounts.push({ + isSigner: false, + isWritable: false, + pubkey: statePda, + }); + // self_authority in HandleReceiveMessage accounts, also signer in self-invoked CPIs. + remainingAccounts.push({ + isSigner: false, + // signer in self-invoked CPIs is mutable, as Solana owner is also fee payer when not using CCTP. + isWritable: true, + pubkey: selfAuthority, + }); + // program in HandleReceiveMessage accounts. + remainingAccounts.push({ + isSigner: false, + isWritable: false, + pubkey: svmSpokeProgram.programId, + }); + // state in self-invoked CPIs (state can change as a result of remote call). + remainingAccounts.push({ + isSigner: false, + isWritable: true, + pubkey: statePda, + }); + // event_authority in self-invoked CPIs (appended by Anchor with event_cpi macro). + remainingAccounts.push({ + isSigner: false, + isWritable: false, + pubkey: eventAuthority, + }); + // program in self-invoked CPIs (appended by Anchor with event_cpi macro). + remainingAccounts.push({ + isSigner: false, + isWritable: false, + pubkey: svmSpokeProgram.programId, + }); + + // Receive remote message on Solana. + console.log("Receiving message on Solana..."); + const receiveMessageTx = await messageTransmitterProgram.methods + .receiveMessage({ + message: Buffer.from(message.replace("0x", ""), "hex"), + attestation: Buffer.from(attestation.replace("0x", ""), "hex"), + }) + .accounts(receiveMessageAccounts) + .remainingAccounts(remainingAccounts) + .rpc(); + console.log("\nReceived remote message"); + console.log("Your transaction signature", receiveMessageTx); + + // Check updated state. + const stateData = await svmSpokeProgram.account.state.fetch(statePda); + console.log("Updated pausedDeposits state to:", stateData.pausedDeposits); +} + +remotePauseDeposits() + .then(() => { + process.exit(0); + }) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/scripts/svm/simpleDeposit.ts b/scripts/svm/simpleDeposit.ts new file mode 100644 index 000000000..ace3a42ac --- /dev/null +++ b/scripts/svm/simpleDeposit.ts @@ -0,0 +1,112 @@ +import * as anchor from "@coral-xyz/anchor"; +import { BN, Program, AnchorProvider } from "@coral-xyz/anchor"; +import { PublicKey } from "@solana/web3.js"; +import { ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID, getAssociatedTokenAddressSync } from "@solana/spl-token"; +import { SvmSpoke } from "../../target/types/svm_spoke"; +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; + +// Set up the provider +const provider = AnchorProvider.env(); +anchor.setProvider(provider); +const idl = require("../../target/idl/svm_spoke.json"); +const program = new Program(idl, provider); +const programId = program.programId; + +// Parse arguments +const argv = yargs(hideBin(process.argv)) + .option("seed", { type: "string", demandOption: true, describe: "Seed for the state account PDA" }) + .option("recipient", { type: "string", demandOption: true, describe: "Recipient public key" }) + .option("inputToken", { type: "string", demandOption: true, describe: "Input token public key" }) + .option("outputToken", { type: "string", demandOption: true, describe: "Output token public key" }) + .option("inputAmount", { type: "number", demandOption: true, describe: "Input amount" }) + .option("outputAmount", { type: "number", demandOption: true, describe: "Output amount" }) + .option("destinationChainId", { type: "string", demandOption: true, describe: "Destination chain ID" }).argv; + +const seed = new BN(argv.seed); +const recipient = new PublicKey(argv.recipient); +const inputToken = new PublicKey(argv.inputToken); +const outputToken = new PublicKey(argv.outputToken); +const inputAmount = new BN(argv.inputAmount); +const outputAmount = new BN(argv.outputAmount); +const destinationChainId = new BN(argv.destinationChainId); +const exclusiveRelayer = PublicKey.default; +const quoteTimestamp = Math.floor(Date.now() / 1000); +const fillDeadline = quoteTimestamp + 3600; // 1 hour from now +const exclusivityDeadline = 0; +const message = Buffer.from([]); // Convert to Buffer + +// Define the state account PDA +const [statePda, _] = PublicKey.findProgramAddressSync( + [Buffer.from("state"), seed.toArrayLike(Buffer, "le", 8)], + programId +); + +// Define the route account PDA +const [routePda] = PublicKey.findProgramAddressSync( + [Buffer.from("route"), inputToken.toBytes(), destinationChainId.toArrayLike(Buffer, "le", 8)], + programId +); + +// Define the signer (replace with your actual signer) +const signer = provider.wallet.publicKey; + +async function depositV3(): Promise { + console.log("Depositing V3..."); + console.table([ + { property: "seed", value: seed.toString() }, + { property: "recipient", value: recipient.toString() }, + { property: "inputToken", value: inputToken.toString() }, + { property: "outputToken", value: outputToken.toString() }, + { property: "inputAmount", value: inputAmount.toString() }, + { property: "outputAmount", value: outputAmount.toString() }, + { property: "destinationChainId", value: destinationChainId.toString() }, + { property: "quoteTimestamp", value: quoteTimestamp.toString() }, + { property: "fillDeadline", value: fillDeadline.toString() }, + { property: "exclusivityDeadline", value: exclusivityDeadline.toString() }, + { property: "programId", value: programId.toString() }, + { property: "providerPublicKey", value: provider.wallet.publicKey.toString() }, + { property: "statePda", value: statePda.toString() }, + { property: "routePda", value: routePda.toString() }, + ]); + + // Create ATA for the input token to be stored by state (vault). + const vault = getAssociatedTokenAddressSync( + inputToken, + statePda, + true, + TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID + ); + + const tx = await ( + program.methods.depositV3( + signer, + recipient, + inputToken, + outputToken, + inputAmount, + outputAmount, + destinationChainId, + exclusiveRelayer, + quoteTimestamp, + fillDeadline, + exclusivityDeadline, + message + ) as any + ) + .accounts({ + state: statePda, + route: routePda, + signer: signer, + userTokenAccount: getAssociatedTokenAddressSync(inputToken, signer), + vault: vault, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .rpc(); + + console.log("Transaction signature:", tx); +} + +// Run the depositV3 function +depositV3(); diff --git a/scripts/svm/simpleFill.ts b/scripts/svm/simpleFill.ts new file mode 100644 index 000000000..96b07edc6 --- /dev/null +++ b/scripts/svm/simpleFill.ts @@ -0,0 +1,141 @@ +import * as anchor from "@coral-xyz/anchor"; +import { BN, Program, AnchorProvider } from "@coral-xyz/anchor"; +import { PublicKey, SystemProgram } from "@solana/web3.js"; +import { ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID, getAssociatedTokenAddressSync } from "@solana/spl-token"; +import { SvmSpoke } from "../../target/types/svm_spoke"; +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; +import { calculateRelayHashUint8Array } from "../../src/SvmUtils"; + +// Set up the provider +const provider = AnchorProvider.env(); +anchor.setProvider(provider); +const idl = require("../../target/idl/svm_spoke.json"); +const program = new Program(idl, provider); +const programId = program.programId; + +// Parse arguments +const argv = yargs(hideBin(process.argv)) + .option("seed", { type: "string", demandOption: true, describe: "Seed for the state account PDA" }) + .option("depositor", { type: "string", demandOption: true, describe: "Depositor public key" }) + .option("recipient", { type: "string", demandOption: true, describe: "Recipient public key" }) + .option("exclusiveRelayer", { type: "string", demandOption: false, describe: "Exclusive relayer public key" }) + .option("inputToken", { type: "string", demandOption: true, describe: "Input token public key" }) + .option("outputToken", { type: "string", demandOption: true, describe: "Output token public key" }) + .option("inputAmount", { type: "number", demandOption: true, describe: "Input amount" }) + .option("outputAmount", { type: "number", demandOption: true, describe: "Output amount" }) + .option("originChainId", { type: "string", demandOption: true, describe: "Origin chain ID" }) + .option("depositId", { type: "number", demandOption: true, describe: "Deposit ID" }) + .option("fillDeadline", { type: "number", demandOption: false, describe: "Fill deadline" }) + .option("exclusivityDeadline", { type: "number", demandOption: false, describe: "Exclusivity deadline" }) + .option("message", { type: "string", demandOption: false, describe: "Message" }).argv; + +const depositor = new PublicKey(argv.depositor); +const recipient = new PublicKey(argv.recipient); +const exclusiveRelayer = new PublicKey(argv.exclusiveRelayer || "11111111111111111111111111111111"); +const inputToken = new PublicKey(argv.inputToken); +const outputToken = new PublicKey(argv.outputToken); +const inputAmount = new BN(argv.inputAmount); +const outputAmount = new BN(argv.outputAmount); +const originChainId = new BN(argv.originChainId); +const depositId = argv.depositId; +const fillDeadline = argv.fillDeadline || Math.floor(Date.now() / 1000) + 60; // Current time + 1 minute +const exclusivityDeadline = argv.exclusivityDeadline || Math.floor(Date.now() / 1000) + 30; // Current time + 30 seconds +const message = Buffer.from(argv.message || ""); +const seed = new BN(argv.seed); + +const relayData = { + depositor, + recipient, + exclusiveRelayer, + inputToken, + outputToken, + inputAmount, + outputAmount, + originChainId, + depositId, + fillDeadline, + exclusivityDeadline, + message, +}; + +// Define the signer (replace with your actual signer) +const signer = provider.wallet.publicKey; + +async function fillV3Relay(): Promise { + console.log("Filling V3 Relay..."); + + // Define the state account PDA + const [statePda, _] = PublicKey.findProgramAddressSync( + [Buffer.from("state"), seed.toArrayLike(Buffer, "le", 8)], + programId + ); + + // Fetch the state from the on-chain program to get chainId + const state = await program.account.state.fetch(statePda); + const chainId = new BN(state.chainId); + + const relayHashUint8Array = calculateRelayHashUint8Array(relayData, chainId); + + // Define the fill status account PDA + const [fillStatusPda] = PublicKey.findProgramAddressSync([Buffer.from("fills"), relayHashUint8Array], programId); + + // Create ATA for the relayer and recipient token accounts + const relayerTokenAccount = getAssociatedTokenAddressSync( + outputToken, + signer, + true, + TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID + ); + + const recipientTokenAccount = getAssociatedTokenAddressSync( + outputToken, + recipient, + true, + TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID + ); + + console.table([ + { property: "relayHash", value: Buffer.from(relayHashUint8Array).toString("hex") }, + { property: "chainId", value: chainId.toString() }, + { property: "programId", value: programId.toString() }, + { property: "providerPublicKey", value: provider.wallet.publicKey.toString() }, + { property: "statePda", value: statePda.toString() }, + { property: "fillStatusPda", value: fillStatusPda.toString() }, + { property: "relayerTokenAccount", value: relayerTokenAccount.toString() }, + { property: "recipientTokenAccount", value: recipientTokenAccount.toString() }, + { property: "seed", value: seed.toString() }, + ]); + + console.log("Relay Data:"); + console.table( + Object.entries(relayData).map(([key, value]) => ({ + key, + value: value.toString(), + })) + ); + + const tx = await (program.methods.fillV3Relay(Array.from(relayHashUint8Array), relayData, chainId) as any) + .accounts({ + state: statePda, + signer: signer, + relayer: signer, + recipient: recipient, + mintAccount: outputToken, + relayerTokenAccount: relayerTokenAccount, + recipientTokenAccount: recipientTokenAccount, + fillStatus: fillStatusPda, + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + programId: programId, + }) + .rpc(); + + console.log("Transaction signature:", tx); +} + +// Run the fillV3Relay function +fillV3Relay(); diff --git a/src/SvmUtils.ts b/src/SvmUtils.ts new file mode 100644 index 000000000..aebb22f8a --- /dev/null +++ b/src/SvmUtils.ts @@ -0,0 +1,152 @@ +//TODO: we will need to move this to a better location and integrate it more directly with other utils & files in time. +import * as anchor from "@coral-xyz/anchor"; +import { Program } from "@coral-xyz/anchor"; +import { ethers } from "ethers"; +import { PublicKey, Connection, Finality, SignaturesForAddressOptions, Logs } from "@solana/web3.js"; + +export function findProgramAddress(label: string, program: PublicKey, extraSeeds = null) { + const seeds = [Buffer.from(anchor.utils.bytes.utf8.encode(label))]; + if (extraSeeds) { + for (const extraSeed of extraSeeds) { + if (typeof extraSeed === "string") { + seeds.push(Buffer.from(anchor.utils.bytes.utf8.encode(extraSeed))); + } else if (Array.isArray(extraSeed)) { + seeds.push(Buffer.from(extraSeed)); + } else if (Buffer.isBuffer(extraSeed)) { + seeds.push(extraSeed); + } else { + seeds.push(extraSeed.toBuffer()); + } + } + } + const res = PublicKey.findProgramAddressSync(seeds, program); + return { publicKey: res[0], bump: res[1] }; +} + +export async function readEvents( + connection: Connection, + txSignature: string, + programs, + commitment: Finality = "confirmed" +) { + const txResult = await connection.getTransaction(txSignature, { + commitment, + maxSupportedTransactionVersion: 0, + }); + + let eventAuthorities = new Map(); + for (const program of programs) { + eventAuthorities.set( + program.programId.toString(), + findProgramAddress("__event_authority", program.programId, null).publicKey.toString() + ); + } + + let events = []; + for (const ixBlock of txResult.meta.innerInstructions) { + for (const ix of ixBlock.instructions) { + for (const program of programs) { + const programStr = program.programId.toString(); + if ( + ix.accounts.length === 1 && + (txResult.transaction.message as any).accountKeys[ix.programIdIndex].toString() === programStr && + (txResult.transaction.message as any).accountKeys[ix.accounts[0]].toString() === + eventAuthorities.get(programStr) + ) { + const ixData = anchor.utils.bytes.bs58.decode(ix.data); + const eventData = anchor.utils.bytes.base64.encode(Buffer.from(new Uint8Array(ixData).slice(8))); + const event = program.coder.events.decode(eventData); + events.push({ + program: program.programId, + data: event.data, + name: event.name, + }); + } + } + } + } + + return events; +} + +export function getEvent(events, program: PublicKey, eventName: string) { + for (const event of events) { + if (event.name === eventName && program.toString() === event.program.toString()) { + return event.data; + } + } + throw new Error("Event " + eventName + " not found"); +} + +export async function readProgramEvents( + connection: Connection, + program: Program, + options?: SignaturesForAddressOptions, + finality: Finality = "confirmed" +) { + let events = []; + const pastSignatures = await connection.getSignaturesForAddress(program.programId, options, finality); + + for (const signature of pastSignatures) { + events.push(...(await readEvents(connection, signature.signature, [program], finality))); + } + return events; +} + +export async function subscribeToCpiEventsForProgram( + connection: Connection, + program: Program, + callback: (events: any[]) => void +) { + const subscriptionId = connection.onLogs( + new PublicKey(findProgramAddress("__event_authority", program.programId, null).publicKey.toString()), + async (logs: Logs) => { + callback(await readEvents(connection, logs.signature, [program], "confirmed")); + }, + "confirmed" + ); + + return subscriptionId; +} + +export const evmAddressToPublicKey = (address: string): PublicKey => { + const bytes32Address = `0x000000000000000000000000${address.replace("0x", "")}`; + return new PublicKey(ethers.utils.arrayify(bytes32Address)); +}; + +// TODO: we are inconsistant with where we are placing some utils. we have some stuff here, some stuff that we might +// want to re-use within the test directory. more over, when moving things into the canonical across repo, we should +// re-use the test utils there. +export function calculateRelayHashUint8Array(relayData: any, chainId: anchor.BN): Uint8Array { + const messageBuffer = Buffer.alloc(4); + messageBuffer.writeUInt32LE(relayData.message.length, 0); + + const contentToHash = Buffer.concat([ + relayData.depositor.toBuffer(), + relayData.recipient.toBuffer(), + relayData.exclusiveRelayer.toBuffer(), + relayData.inputToken.toBuffer(), + relayData.outputToken.toBuffer(), + relayData.inputAmount.toArrayLike(Buffer, "le", 8), + relayData.outputAmount.toArrayLike(Buffer, "le", 8), + relayData.originChainId.toArrayLike(Buffer, "le", 8), + new anchor.BN(relayData.depositId).toArrayLike(Buffer, "le", 4), + relayData.fillDeadline.toArrayLike(Buffer, "le", 4), + relayData.exclusivityDeadline.toArrayLike(Buffer, "le", 4), + messageBuffer, + relayData.message, + chainId.toArrayLike(Buffer, "le", 8), + ]); + + const relayHash = ethers.utils.keccak256(contentToHash); + const relayHashBuffer = Buffer.from(relayHash.slice(2), "hex"); + return new Uint8Array(relayHashBuffer); +} + +export const readUInt256BE = (buffer: Buffer): BigInt => { + let result = BigInt(0); + for (let i = 0; i < buffer.length; i++) { + result = (result << BigInt(8)) + BigInt(buffer[i]); + } + return result; +}; diff --git a/test/svm/SvmSpoke.Bundle.ts b/test/svm/SvmSpoke.Bundle.ts new file mode 100644 index 000000000..d8438c2c2 --- /dev/null +++ b/test/svm/SvmSpoke.Bundle.ts @@ -0,0 +1,729 @@ +import * as anchor from "@coral-xyz/anchor"; +import * as crypto from "crypto"; +import { BN } from "@coral-xyz/anchor"; +import { Keypair, PublicKey } from "@solana/web3.js"; +import { assert } from "chai"; +import { common } from "./SvmSpoke.common"; +import { MerkleTree } from "@uma/common/dist/MerkleTree"; +import { createMint, getOrCreateAssociatedTokenAccount, mintTo, TOKEN_PROGRAM_ID } from "@solana/spl-token"; +import { + relayerRefundHashFn, + randomAddress, + randomBigInt, + RelayerRefundLeaf, + RelayerRefundLeafSolana, + RelayerRefundLeafType, +} from "./utils"; + +const { provider, program, owner, initializeState, connection, chainId } = common; + +describe("svm_spoke.bundle", () => { + anchor.setProvider(provider); + + const nonOwner = Keypair.generate(); + + const relayerA = Keypair.generate(); + const relayerB = Keypair.generate(); + + let state: PublicKey, + mint: PublicKey, + relayerTA: PublicKey, + relayerTB: PublicKey, + vault: PublicKey, + transferLiability: PublicKey; + + const payer = (anchor.AnchorProvider.env().wallet as any).payer; + const initialMintAmount = 10_000_000_000; + + before(async () => { + // This test differs by having state within before, not before each block so we can have incrementing rootBundleId + // values to test against on sequential tests. + state = await initializeState(); + mint = await createMint(connection, payer, owner, owner, 6); + relayerTA = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, relayerA.publicKey)).address; + relayerTB = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, relayerB.publicKey)).address; + + vault = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, state, true)).address; + + const sig = await connection.requestAirdrop(nonOwner.publicKey, 10_000_000_000); + await provider.connection.confirmTransaction(sig); + + // mint mint to vault + await mintTo(connection, payer, mint, vault, provider.publicKey, initialMintAmount); + + const initialVaultBalance = (await connection.getTokenAccountBalance(vault)).value.amount; + assert.strictEqual( + BigInt(initialVaultBalance), + BigInt(initialMintAmount), + "Initial vault balance should be equal to the minted amount" + ); + + [transferLiability] = PublicKey.findProgramAddressSync( + [Buffer.from("transfer_liability"), mint.toBuffer()], + program.programId + ); + }); + + it("Relays Root Bundle", async () => { + const relayerRefundRootBuffer = crypto.randomBytes(32); + const relayerRefundRootArray = Array.from(relayerRefundRootBuffer); + + const slowRelayRootBuffer = crypto.randomBytes(32); + const slowRelayRootArray = Array.from(slowRelayRootBuffer); + + let stateAccountData = await program.account.state.fetch(state); + const rootBundleId = stateAccountData.rootBundleId; + const rootBundleIdBuffer = Buffer.alloc(4); + rootBundleIdBuffer.writeUInt32LE(rootBundleId); + const seeds = [Buffer.from("root_bundle"), state.toBuffer(), rootBundleIdBuffer]; + const [rootBundle] = PublicKey.findProgramAddressSync(seeds, program.programId); + + // Try to relay root bundle as non-owner + try { + await program.methods + .relayRootBundle(relayerRefundRootArray, slowRelayRootArray) + .accounts({ state: state, rootBundle, signer: nonOwner.publicKey }) + .signers([nonOwner]) + .rpc(); + assert.fail("Non-owner should not be able to relay root bundle"); + } catch (err) { + assert.include(err.toString(), "Only the owner can call this function!", "Expected owner check error"); + } + + // Relay root bundle as owner + await program.methods + .relayRootBundle(relayerRefundRootArray, slowRelayRootArray) + .accounts({ state, rootBundle, signer: owner }) + .rpc(); + + // Fetch the relayer refund root and slow relay root + let rootBundleAccountData = await program.account.rootBundle.fetch(rootBundle); + const relayerRefundRootHex = Buffer.from(rootBundleAccountData.relayerRefundRoot).toString("hex"); + const slowRelayRootHex = Buffer.from(rootBundleAccountData.slowRelayRoot).toString("hex"); + assert.isTrue( + relayerRefundRootHex === relayerRefundRootBuffer.toString("hex"), + "Relayer refund root should be set" + ); + assert.isTrue(slowRelayRootHex === slowRelayRootBuffer.toString("hex"), "Slow relay root should be set"); + + // Check that the root bundle index has been incremented + stateAccountData = await program.account.state.fetch(state); + assert.isTrue(stateAccountData.rootBundleId.toString() === "1", "Root bundle index should be 1"); + + // Relay a new root bundle + const relayerRefundRootBuffer2 = crypto.randomBytes(32); + const relayerRefundRootArray2 = Array.from(relayerRefundRootBuffer2); + + const slowRelayRootBuffer2 = crypto.randomBytes(32); + const slowRelayRootArray2 = Array.from(slowRelayRootBuffer2); + + const rootBundleIdBuffer2 = Buffer.alloc(4); + rootBundleIdBuffer2.writeUInt32LE(stateAccountData.rootBundleId); + const seeds2 = [Buffer.from("root_bundle"), state.toBuffer(), rootBundleIdBuffer2]; + const [rootBundle2] = PublicKey.findProgramAddressSync(seeds2, program.programId); + + await program.methods + .relayRootBundle(relayerRefundRootArray2, slowRelayRootArray2) + .accounts({ state, rootBundle: rootBundle2, signer: owner }) + .rpc(); + + stateAccountData = await program.account.state.fetch(state); + assert.isTrue(stateAccountData.rootBundleId.toString() === "2", "Root bundle index should be 2"); + }); + it("Simple Leaf Refunds Relayers", async () => { + const relayerRefundLeaves: RelayerRefundLeafType[] = []; + const relayerARefund = new BN(400000); + const relayerBRefund = new BN(100000); + + relayerRefundLeaves.push({ + isSolana: true, + leafId: new BN(0), + chainId: chainId, + amountToReturn: new BN(0), + mintPublicKey: mint, + refundAccounts: [relayerTA, relayerTB], + refundAmounts: [relayerARefund, relayerBRefund], + }); + + const merkleTree = new MerkleTree(relayerRefundLeaves, relayerRefundHashFn); + + const root = merkleTree.getRoot(); + const proof = merkleTree.getProof(relayerRefundLeaves[0]); + const leaf = relayerRefundLeaves[0] as RelayerRefundLeafSolana; + + let stateAccountData = await program.account.state.fetch(state); + const rootBundleId = stateAccountData.rootBundleId; + + const rootBundleIdBuffer = Buffer.alloc(4); + rootBundleIdBuffer.writeUInt32LE(rootBundleId); + const seeds = [Buffer.from("root_bundle"), state.toBuffer(), rootBundleIdBuffer]; + const [rootBundle] = PublicKey.findProgramAddressSync(seeds, program.programId); + + // Relay root bundle + await program.methods + .relayRootBundle(Array.from(root), Array.from(root)) + .accounts({ state, rootBundle, signer: owner }) + .rpc(); + + const remainingAccounts = [ + { pubkey: relayerTA, isWritable: true, isSigner: false }, + { pubkey: relayerTB, isWritable: true, isSigner: false }, + ]; + + const iVaultBal = (await connection.getTokenAccountBalance(vault)).value.amount; + const iRelayerABal = (await connection.getTokenAccountBalance(relayerTA)).value.amount; + const iRelayerBBal = (await connection.getTokenAccountBalance(relayerTB)).value.amount; + + // Verify valid leaf + const proofAsNumbers = proof.map((p) => Array.from(p)); + await program.methods + .executeRelayerRefundLeaf(stateAccountData.rootBundleId, leaf, proofAsNumbers) + .accounts({ + state: state, + rootBundle: rootBundle, + signer: owner, + vault: vault, + tokenProgram: TOKEN_PROGRAM_ID, + mint: mint, + transferLiability, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .remainingAccounts(remainingAccounts) + .rpc(); + + const fVaultBal = (await connection.getTokenAccountBalance(vault)).value.amount; + const fRelayerABal = (await connection.getTokenAccountBalance(relayerTA)).value.amount; + const fRelayerBBal = (await connection.getTokenAccountBalance(relayerTB)).value.amount; + + const totalRefund = relayerARefund.add(relayerBRefund).toString(); + + assert.strictEqual(BigInt(iVaultBal) - BigInt(fVaultBal), BigInt(totalRefund), "Vault balance"); + assert.strictEqual(BigInt(fRelayerABal) - BigInt(iRelayerABal), BigInt(relayerARefund.toString()), "Relayer A bal"); + assert.strictEqual(BigInt(fRelayerBBal) - BigInt(iRelayerBBal), BigInt(relayerBRefund.toString()), "Relayer B bal"); + + // Try to execute the same leaf again. This should fail due to the claimed bitmap. + try { + await program.methods + .executeRelayerRefundLeaf(stateAccountData.rootBundleId, leaf, proofAsNumbers) + .accounts({ + state: state, + rootBundle: rootBundle, + signer: owner, + vault: vault, + tokenProgram: TOKEN_PROGRAM_ID, + mint: mint, + transferLiability, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .remainingAccounts(remainingAccounts) + .rpc(); + assert.fail("Leaf should not be executed multiple times"); + } catch (err) { + assert.include(err.toString(), "Leaf already claimed!", "Expected claimed leaf error"); + } + }); + + it("Test Merkle Proof Verification", async () => { + const relayerRefundLeaves: RelayerRefundLeafType[] = []; + const solanaDistributions = 50; + const evmDistributions = 50; + const solanaLeafNumber = 13; + + for (let i = 0; i < solanaDistributions + 1; i++) { + relayerRefundLeaves.push({ + isSolana: true, + leafId: new BN(i), + chainId: chainId, + amountToReturn: new anchor.BN(randomBigInt(2).toString()), + mintPublicKey: mint, + refundAccounts: [relayerTA, relayerTB], + refundAmounts: [new anchor.BN(randomBigInt(2).toString()), new anchor.BN(randomBigInt(2).toString())], + }); + } + const invalidRelayerRefundLeaf = relayerRefundLeaves.pop()!; + + for (let i = 0; i < evmDistributions; i++) { + relayerRefundLeaves.push({ + isSolana: false, + leafId: BigInt(i), + chainId: randomBigInt(2), + amountToReturn: randomBigInt(), + l2TokenAddress: randomAddress(), + refundAddresses: [randomAddress(), randomAddress()], + refundAmounts: [randomBigInt(), randomBigInt()], + } as RelayerRefundLeaf); + } + + const merkleTree = new MerkleTree(relayerRefundLeaves, relayerRefundHashFn); + + const root = merkleTree.getRoot(); + const proof = merkleTree.getProof(relayerRefundLeaves[solanaLeafNumber]); + const leaf = relayerRefundLeaves[13] as RelayerRefundLeafSolana; + const proofAsNumbers = proof.map((p) => Array.from(p)); + + let stateAccountData = await program.account.state.fetch(state); + const rootBundleId = stateAccountData.rootBundleId; + const rootBundleIdBuffer = Buffer.alloc(4); + rootBundleIdBuffer.writeUInt32LE(rootBundleId); + const seeds = [Buffer.from("root_bundle"), state.toBuffer(), rootBundleIdBuffer]; + const [rootBundle] = PublicKey.findProgramAddressSync(seeds, program.programId); + + // Relay root bundle + await program.methods + .relayRootBundle(Array.from(root), Array.from(root)) + .accounts({ state, rootBundle, signer: owner }) + .rpc(); + + const remainingAccounts = [ + { pubkey: relayerTA, isWritable: true, isSigner: false }, + { pubkey: relayerTB, isWritable: true, isSigner: false }, + ]; + + const iVaultBal = (await connection.getTokenAccountBalance(vault)).value.amount; + const iRelayerABal = (await connection.getTokenAccountBalance(relayerTA)).value.amount; + const iRelayerBBal = (await connection.getTokenAccountBalance(relayerTB)).value.amount; + + // Verify valid leaf with invalid accounts + try { + const wrongRemainingAccounts = [ + { pubkey: Keypair.generate().publicKey, isWritable: true, isSigner: false }, + { pubkey: Keypair.generate().publicKey, isWritable: true, isSigner: false }, + ]; + + // Verify valid leaf + await program.methods + .executeRelayerRefundLeaf(stateAccountData.rootBundleId, leaf, proofAsNumbers) + .accounts({ + state: state, + rootBundle: rootBundle, + signer: owner, + vault: vault, + tokenProgram: TOKEN_PROGRAM_ID, + mint: mint, + transferLiability, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .remainingAccounts(wrongRemainingAccounts) + .rpc(); + } catch (err) { + assert.include(err.toString(), "Account not found"); + } + + // Verify valid leaf + await program.methods + .executeRelayerRefundLeaf(stateAccountData.rootBundleId, leaf, proofAsNumbers) + .accounts({ + state: state, + rootBundle: rootBundle, + signer: owner, + vault: vault, + tokenProgram: TOKEN_PROGRAM_ID, + mint: mint, + transferLiability, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .remainingAccounts(remainingAccounts) + .rpc(); + + const fVaultBal = (await connection.getTokenAccountBalance(vault)).value.amount; + const fRelayerABal = (await connection.getTokenAccountBalance(relayerTA)).value.amount; + const fRelayerBBal = (await connection.getTokenAccountBalance(relayerTB)).value.amount; + + const totalRefund = leaf.refundAmounts[0].add(leaf.refundAmounts[1]).toString(); + + assert.strictEqual(BigInt(iVaultBal) - BigInt(fVaultBal), BigInt(totalRefund), "Vault balance"); + assert.strictEqual( + BigInt(fRelayerABal) - BigInt(iRelayerABal), + BigInt(leaf.refundAmounts[0].toString()), + "Relayer A bal" + ); + assert.strictEqual( + BigInt(fRelayerBBal) - BigInt(iRelayerBBal), + BigInt(leaf.refundAmounts[1].toString()), + "Relayer B bal" + ); + + // Verify invalid leaf + try { + await program.methods + .executeRelayerRefundLeaf( + stateAccountData.rootBundleId, + invalidRelayerRefundLeaf as RelayerRefundLeafSolana, + proofAsNumbers + ) + .accounts({ + state: state, + rootBundle: rootBundle, + signer: owner, + vault: vault, + tokenProgram: TOKEN_PROGRAM_ID, + mint: mint, + transferLiability, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .remainingAccounts(remainingAccounts) + .rpc(); + assert.fail("Invalid leaf should not be verified"); + } catch (err) { + assert.include(err.toString(), "Invalid Merkle proof"); + } + }); + + it("Execute Leaf Refunds Relayers with invalid chain id", async () => { + const relayerRefundLeaves: RelayerRefundLeafType[] = []; + const relayerARefund = new BN(400000); + const relayerBRefund = new BN(100000); + + relayerRefundLeaves.push({ + isSolana: true, + leafId: new BN(0), + // Set chainId to 1000. this is a diffrent chainId than what is set in the initialization. This mimics trying to execute a leaf for another chain on the SVM chain. + // Set chainId to 1000. this is a diffrent chainId than what is set in the initialization. This mimics trying to execute a leaf for another chain on the SVM chain. + chainId: new BN(1000), + amountToReturn: new BN(0), + mintPublicKey: mint, + refundAccounts: [relayerTA, relayerTB], + refundAmounts: [relayerARefund, relayerBRefund], + }); + + const merkleTree = new MerkleTree(relayerRefundLeaves, relayerRefundHashFn); + + const root = merkleTree.getRoot(); + const proof = merkleTree.getProof(relayerRefundLeaves[0]); + const leaf = relayerRefundLeaves[0] as RelayerRefundLeafSolana; + + let stateAccountData = await program.account.state.fetch(state); + const rootBundleId = stateAccountData.rootBundleId; + + const rootBundleIdBuffer = Buffer.alloc(4); + rootBundleIdBuffer.writeUInt32LE(rootBundleId); + const seeds = [Buffer.from("root_bundle"), state.toBuffer(), rootBundleIdBuffer]; + const [rootBundle] = PublicKey.findProgramAddressSync(seeds, program.programId); + + // Relay root bundle + await program.methods + .relayRootBundle(Array.from(root), Array.from(root)) + .accounts({ state, rootBundle, signer: owner }) + .rpc(); + + const remainingAccounts = [ + { pubkey: relayerTA, isWritable: true, isSigner: false }, + { pubkey: relayerTB, isWritable: true, isSigner: false }, + ]; + + const proofAsNumbers = proof.map((p) => Array.from(p)); + try { + await program.methods + .executeRelayerRefundLeaf(stateAccountData.rootBundleId, leaf, proofAsNumbers) + .accounts({ + state: state, + rootBundle: rootBundle, + signer: owner, + vault: vault, + tokenProgram: TOKEN_PROGRAM_ID, + mint: mint, + transferLiability, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .remainingAccounts(remainingAccounts) + .rpc(); + } catch (err) { + assert.include(err.toString(), "Invalid chain id"); + } + }); + + it("Execute Leaf Refunds Relayers with invalid mintPublicKey", async () => { + const relayerRefundLeaves: RelayerRefundLeafType[] = []; + const relayerARefund = new BN(400000); + const relayerBRefund = new BN(100000); + + relayerRefundLeaves.push({ + isSolana: true, + leafId: new BN(0), + chainId: chainId, + amountToReturn: new BN(0), + mintPublicKey: Keypair.generate().publicKey, + refundAccounts: [relayerTA, relayerTB], + refundAmounts: [relayerARefund, relayerBRefund], + }); + + const merkleTree = new MerkleTree(relayerRefundLeaves, relayerRefundHashFn); + + const root = merkleTree.getRoot(); + const proof = merkleTree.getProof(relayerRefundLeaves[0]); + const leaf = relayerRefundLeaves[0] as RelayerRefundLeafSolana; + + let stateAccountData = await program.account.state.fetch(state); + const rootBundleId = stateAccountData.rootBundleId; + + const rootBundleIdBuffer = Buffer.alloc(4); + rootBundleIdBuffer.writeUInt32LE(rootBundleId); + const seeds = [Buffer.from("root_bundle"), state.toBuffer(), rootBundleIdBuffer]; + const [rootBundle] = PublicKey.findProgramAddressSync(seeds, program.programId); + + // Relay root bundle + await program.methods + .relayRootBundle(Array.from(root), Array.from(root)) + .accounts({ state, rootBundle, signer: owner }) + .rpc(); + + const remainingAccounts = [ + { pubkey: relayerTA, isWritable: true, isSigner: false }, + { pubkey: relayerTB, isWritable: true, isSigner: false }, + ]; + + const proofAsNumbers = proof.map((p) => Array.from(p)); + try { + await program.methods + .executeRelayerRefundLeaf(stateAccountData.rootBundleId, leaf, proofAsNumbers) + .accounts({ + state: state, + rootBundle: rootBundle, + signer: owner, + vault: vault, + tokenProgram: TOKEN_PROGRAM_ID, + mint: mint, + transferLiability, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .remainingAccounts(remainingAccounts) + .rpc(); + } catch (err) { + assert.include(err.toString(), "Invalid mint"); + } + }); + + it("Sequential Leaf Refunds Relayers", async () => { + const relayerRefundLeaves: RelayerRefundLeafType[] = []; + const relayerRefundAmount = new BN(100000); + + // Generate 5 sequential leaves + for (let i = 0; i < 5; i++) { + relayerRefundLeaves.push({ + isSolana: true, + leafId: new BN(i), + chainId: chainId, + amountToReturn: new BN(0), + mintPublicKey: mint, + refundAccounts: [relayerTA], + refundAmounts: [relayerRefundAmount], + }); + } + + const merkleTree = new MerkleTree(relayerRefundLeaves, relayerRefundHashFn); + const root = merkleTree.getRoot(); + const proof = relayerRefundLeaves.map((leaf) => merkleTree.getProof(leaf).map((p) => Array.from(p))); + + let stateAccountData = await program.account.state.fetch(state); + const rootBundleId = stateAccountData.rootBundleId; + + const rootBundleIdBuffer = Buffer.alloc(4); + rootBundleIdBuffer.writeUInt32LE(rootBundleId); + const seeds = [Buffer.from("root_bundle"), state.toBuffer(), rootBundleIdBuffer]; + const [rootBundle] = PublicKey.findProgramAddressSync(seeds, program.programId); + + // Relay root bundle + await program.methods + .relayRootBundle(Array.from(root), Array.from(root)) + .accounts({ state, rootBundle, signer: owner }) + .rpc(); + + const remainingAccounts = [{ pubkey: relayerTA, isWritable: true, isSigner: false }]; + + const iVaultBal = (await connection.getTokenAccountBalance(vault)).value.amount; + const iRelayerABal = (await connection.getTokenAccountBalance(relayerTA)).value.amount; + + // Execute all leaves + for (let i = 0; i < 5; i++) { + await program.methods + .executeRelayerRefundLeaf( + stateAccountData.rootBundleId, + relayerRefundLeaves[i] as RelayerRefundLeafSolana, + proof[i] + ) + .accounts({ + state: state, + rootBundle: rootBundle, + signer: owner, + vault: vault, + tokenProgram: TOKEN_PROGRAM_ID, + mint: mint, + transferLiability, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .remainingAccounts(remainingAccounts) + .rpc(); + } + + const fVaultBal = (await connection.getTokenAccountBalance(vault)).value.amount; + const fRelayerABal = (await connection.getTokenAccountBalance(relayerTA)).value.amount; + + const totalRefund = relayerRefundAmount.mul(new BN(5)).toString(); + + assert.strictEqual(BigInt(iVaultBal) - BigInt(fVaultBal), BigInt(totalRefund), "Vault balance"); + assert.strictEqual(BigInt(fRelayerABal) - BigInt(iRelayerABal), BigInt(totalRefund), "Relayer A bal"); + + // Try to execute the same leaves again. This should fail due to the claimed bitmap. + for (let i = 0; i < 5; i++) { + try { + await program.methods + .executeRelayerRefundLeaf( + stateAccountData.rootBundleId, + relayerRefundLeaves[i] as RelayerRefundLeafSolana, + proof[i] + ) + .accounts({ + state: state, + rootBundle: rootBundle, + signer: owner, + vault: vault, + tokenProgram: TOKEN_PROGRAM_ID, + mint: mint, + transferLiability, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .remainingAccounts(remainingAccounts) + .rpc(); + assert.fail("Leaf should not be executed multiple times"); + } catch (err) { + assert.include(err.toString(), "Leaf already claimed!", "Expected claimed leaf error"); + } + } + }); + + it("Execute Max Refunds", async () => { + const relayerRefundLeaves: RelayerRefundLeafType[] = []; + + const numberOfRefunds = 5; + + const refundAccounts: anchor.web3.PublicKey[] = []; + const refundAmounts: BN[] = []; + + for (let i = 0; i < numberOfRefunds; i++) { + const newRefundAccount = ( + await getOrCreateAssociatedTokenAccount(connection, payer, mint, Keypair.generate().publicKey) + ).address; + refundAccounts.push(newRefundAccount); + refundAmounts.push(new BN(randomBigInt(2).toString())); + } + + relayerRefundLeaves.push({ + isSolana: true, + leafId: new BN(0), + chainId: chainId, + amountToReturn: new BN(0), + mintPublicKey: mint, + refundAccounts: refundAccounts, + refundAmounts: refundAmounts, + }); + + const merkleTree = new MerkleTree(relayerRefundLeaves, relayerRefundHashFn); + + const root = merkleTree.getRoot(); + const proof = merkleTree.getProof(relayerRefundLeaves[0]); + const leaf = relayerRefundLeaves[0] as RelayerRefundLeafSolana; + + let stateAccountData = await program.account.state.fetch(state); + const rootBundleId = stateAccountData.rootBundleId; + + const rootBundleIdBuffer = Buffer.alloc(4); + rootBundleIdBuffer.writeUInt32LE(rootBundleId); + const seeds = [Buffer.from("root_bundle"), state.toBuffer(), rootBundleIdBuffer]; + const [rootBundle] = PublicKey.findProgramAddressSync(seeds, program.programId); + + // Relay root bundle + await program.methods + .relayRootBundle(Array.from(root), Array.from(root)) + .accounts({ state, rootBundle, signer: owner }) + .rpc(); + + const remainingAccounts = refundAccounts.map((account) => ({ pubkey: account, isWritable: true, isSigner: false })); + + // Verify valid leaf + const proofAsNumbers = proof.map((p) => Array.from(p)); + + await program.methods + .executeRelayerRefundLeaf(stateAccountData.rootBundleId, leaf, proofAsNumbers) + .accounts({ + state: state, + rootBundle: rootBundle, + signer: owner, + vault: vault, + tokenProgram: TOKEN_PROGRAM_ID, + mint: mint, + transferLiability, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .remainingAccounts(remainingAccounts) + .rpc(); + }); + + it("Increments pending amount to HubPool", async () => { + const initialPendingToHubPool = (await program.account.transferLiability.fetch(transferLiability)).pendingToHubPool; + + const incrementPendingToHubPool = async (amountToReturn: BN) => { + const relayerRefundLeaves: RelayerRefundLeafType[] = []; + relayerRefundLeaves.push({ + isSolana: true, + leafId: new BN(0), + chainId: chainId, + amountToReturn, + mintPublicKey: mint, + refundAccounts: [], + refundAmounts: [], + }); + const merkleTree = new MerkleTree(relayerRefundLeaves, relayerRefundHashFn); + const root = merkleTree.getRoot(); + const proof = merkleTree.getProof(relayerRefundLeaves[0]); + const leaf = relayerRefundLeaves[0] as RelayerRefundLeafSolana; + let stateAccountData = await program.account.state.fetch(state); + const rootBundleId = stateAccountData.rootBundleId; + const rootBundleIdBuffer = Buffer.alloc(4); + rootBundleIdBuffer.writeUInt32LE(rootBundleId); + const seeds = [Buffer.from("root_bundle"), state.toBuffer(), rootBundleIdBuffer]; + const [rootBundle] = PublicKey.findProgramAddressSync(seeds, program.programId); + await program.methods + .relayRootBundle(Array.from(root), Array.from(root)) + .accounts({ state, rootBundle, signer: owner }) + .rpc(); + const proofAsNumbers = proof.map((p) => Array.from(p)); + await program.methods + .executeRelayerRefundLeaf(stateAccountData.rootBundleId, leaf, proofAsNumbers) + .accounts({ + state: state, + rootBundle: rootBundle, + signer: owner, + vault: vault, + tokenProgram: TOKEN_PROGRAM_ID, + mint: mint, + transferLiability, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .rpc(); + }; + + const zeroAmountToReturn = new BN(0); + await incrementPendingToHubPool(zeroAmountToReturn); + + let pendingToHubPool = (await program.account.transferLiability.fetch(transferLiability)).pendingToHubPool; + assert.isTrue(pendingToHubPool.eq(initialPendingToHubPool), "Pending amount should not have changed"); + + const firstAmountToReturn = new BN(1_000_000); + await incrementPendingToHubPool(firstAmountToReturn); + + pendingToHubPool = (await program.account.transferLiability.fetch(transferLiability)).pendingToHubPool; + assert.isTrue( + pendingToHubPool.eq(initialPendingToHubPool.add(firstAmountToReturn)), + "Pending amount should be incremented by first amount" + ); + + const secondAmountToReturn = new BN(2_000_000); + await incrementPendingToHubPool(secondAmountToReturn); + + pendingToHubPool = (await program.account.transferLiability.fetch(transferLiability)).pendingToHubPool; + assert.isTrue( + pendingToHubPool.eq(initialPendingToHubPool.add(firstAmountToReturn.add(secondAmountToReturn))), + "Pending amount should be incremented by second amount" + ); + }); +}); diff --git a/test/svm/SvmSpoke.Deposit.ts b/test/svm/SvmSpoke.Deposit.ts new file mode 100644 index 000000000..0b45dfe2e --- /dev/null +++ b/test/svm/SvmSpoke.Deposit.ts @@ -0,0 +1,215 @@ +import * as anchor from "@coral-xyz/anchor"; +import { BN } from "@coral-xyz/anchor"; +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + createMint, + getOrCreateAssociatedTokenAccount, + mintTo, + getAccount, +} from "@solana/spl-token"; +import { PublicKey, Keypair } from "@solana/web3.js"; +import { readProgramEvents } from "../../src/SvmUtils"; +import { common } from "./SvmSpoke.common"; +const { provider, connection, program, owner, seedBalance, initializeState, depositData } = common; +const { createRoutePda, getVaultAta, assertSE, assert } = common; + +describe("svm_spoke.deposit", () => { + anchor.setProvider(provider); + + const depositor = Keypair.generate(); + const payer = anchor.AnchorProvider.env().wallet.payer; + let state: PublicKey, inputToken: PublicKey, depositorTA: PublicKey, vault: PublicKey; + let depositAccounts: any; // Re-used between tests to simplify props. + let setEnableRouteAccounts: any; // Common variable for setEnableRoute accounts + + before("Creates token mint and associated token accounts", async () => { + inputToken = await createMint(connection, payer, owner, owner, 6); + + depositorTA = (await getOrCreateAssociatedTokenAccount(connection, payer, inputToken, depositor.publicKey)).address; + await mintTo(connection, payer, inputToken, depositorTA, owner, seedBalance); + }); + + beforeEach(async () => { + state = await initializeState(); + + const routeChainId = new BN(1); + const route = createRoutePda(inputToken, routeChainId); + vault = getVaultAta(inputToken, state); + + setEnableRouteAccounts = { + signer: owner, + payer: owner, + state, + route, + vault, + originTokenMint: inputToken, // Note the Sol expects this to be named originTokenMint. + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: anchor.web3.SystemProgram.programId, + }; + + await program.methods + .setEnableRoute(inputToken.toBytes(), routeChainId, true) + .accounts(setEnableRouteAccounts) + .rpc(); + + // Set known fields in the depositData. + depositData.depositor = depositor.publicKey; + depositData.inputToken = inputToken; + + depositAccounts = { + state, + route, + signer: depositor.publicKey, + userTokenAccount: depositorTA, + vault, + mint: inputToken, + tokenProgram: TOKEN_PROGRAM_ID, + }; + }); + + it("Deposits tokens via deposit_v3 function and checks balances", async () => { + // Verify vault balance is zero before the deposit + let vaultAccount = await getAccount(connection, vault); + assertSE(vaultAccount.amount, "0", "Vault balance should be zero before the deposit"); + + // Execute the deposit_v3 call + await program.methods + .depositV3(...Object.values(depositData)) + .accounts(depositAccounts) + .signers([depositor]) + .rpc(); + + // Verify tokens leave the depositor's account + let depositorAccount = await getAccount(connection, depositorTA); + assertSE( + depositorAccount.amount, + seedBalance - depositData.inputAmount.toNumber(), + "Depositor's balance should be reduced by the deposited amount" + ); + + // Verify tokens are credited into the vault + vaultAccount = await getAccount(connection, vault); + assertSE(vaultAccount.amount, depositData.inputAmount, "Vault balance should be increased by the deposited amount"); + + // Modify depositData for the second deposit + const secondInputAmount = new BN(300000); + + // Execute the second deposit_v3 call + await program.methods + .depositV3(...Object.values({ ...depositData, inputAmount: secondInputAmount })) + .accounts(depositAccounts) + .signers([depositor]) + .rpc(); + + // Verify tokens leave the depositor's account again + depositorAccount = await getAccount(connection, depositorTA); + assertSE( + depositorAccount.amount, + seedBalance - depositData.inputAmount.toNumber() - secondInputAmount.toNumber(), + "Depositor's balance should be reduced by the total deposited amount" + ); + + // Verify tokens are credited into the vault again + vaultAccount = await getAccount(connection, vault); + assertSE( + vaultAccount.amount, + depositData.inputAmount.add(secondInputAmount), + "Vault balance should be increased by the total deposited amount" + ); + }); + + it("Verifies V3FundsDeposited after deposits", async () => { + depositData.inputAmount = depositData.inputAmount.add(new BN(69)); + + // Execute the first deposit_v3 call + await program.methods + .depositV3(...Object.values(depositData)) + .accounts(depositAccounts) + .signers([depositor]) + .rpc(); + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Fetch and verify the depositEvent + let events = await readProgramEvents(connection, program); + let event = events.find((event) => event.name === "v3FundsDeposited").data; + const expectedValues1 = { ...depositData, depositId: "1" }; // Verify the event props emitted match the depositData. + for (const [key, value] of Object.entries(expectedValues1)) { + assertSE(event[key], value, `${key} should match`); + } + + // Execute the second deposit_v3 call + await program.methods + .depositV3(...Object.values(depositData)) + .accounts(depositAccounts) + .signers([depositor]) + .rpc(); + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Fetch and verify the depositEvent for the second deposit + events = await readProgramEvents(connection, program); + event = events.find((event) => event.name === "v3FundsDeposited" && event.data.depositId.toString() === "2").data; + + const expectedValues2 = { ...depositData, depositId: "2" }; // Verify the event props emitted match the depositData. + for (const [key, value] of Object.entries(expectedValues2)) { + assertSE(event[key], value, `${key} should match`); + } + }); + + it("Fails to deposit tokens to a route that is uninitalized", async () => { + const differentChainId = new BN(2); // Different chain ID + const differentRoutePda = createRoutePda(depositData.inputToken, differentChainId); + depositAccounts.route = differentRoutePda; + + try { + await program.methods + .depositV3(...Object.values({ ...depositData, destinationChainId: differentChainId })) + .accounts(depositAccounts) + .signers([depositor]) + .rpc(); + assert.fail("Deposit should have failed for a route that is not initialized"); + } catch (err) { + assert.include(err.toString(), "AccountNotInitialized", "Expected AccountNotInitialized error"); + } + }); + + it("Fails to deposit tokens to a route that is explicitly disabled", async () => { + // Disable the route + await program.methods + .setEnableRoute(depositData.inputToken.toBytes(), depositData.destinationChainId, false) + .accounts(setEnableRouteAccounts) + .rpc(); + + try { + await program.methods + .depositV3(...Object.values(depositData)) + .accounts(depositAccounts) + .signers([depositor]) + .rpc(); + assert.fail("Deposit should have failed for a route that is explicitly disabled"); + } catch (err) { + assert.include(err.toString(), "RouteNotEnabled", "Expected RouteNotEnabled error"); + } + }); + + it("Fails to process deposit when deposits are paused", async () => { + // Pause deposits + await program.methods.pauseDeposits(true).accounts({ state, signer: owner }).rpc(); + const stateAccountData = await program.account.state.fetch(state); + assert.isTrue(stateAccountData.pausedDeposits, "Deposits should be paused"); + + // Try to deposit. This should fail because deposits are paused. + try { + await program.methods + .depositV3(...Object.values(depositData)) + .accounts(depositAccounts) + .signers([depositor]) + .rpc(); + assert.fail("Should not be able to process deposit when deposits are paused"); + } catch (err) { + assert.instanceOf(err, anchor.AnchorError); + assert.strictEqual(err.error.errorCode.code, "DepositsArePaused", "Expected error code DepositsArePaused"); + } + }); +}); diff --git a/test/svm/SvmSpoke.Fill.ts b/test/svm/SvmSpoke.Fill.ts new file mode 100644 index 000000000..136cb25ee --- /dev/null +++ b/test/svm/SvmSpoke.Fill.ts @@ -0,0 +1,271 @@ +import * as anchor from "@coral-xyz/anchor"; +import { BN } from "@coral-xyz/anchor"; +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + createMint, + getOrCreateAssociatedTokenAccount, + mintTo, + getAccount, +} from "@solana/spl-token"; +import { PublicKey, Keypair } from "@solana/web3.js"; +import { readProgramEvents, calculateRelayHashUint8Array } from "../../src/SvmUtils"; +import { common } from "./SvmSpoke.common"; +const { provider, connection, program, owner, chainId, seedBalance } = common; +const { recipient, initializeState, setCurrentTime, assertSE, assert } = common; + +describe("svm_spoke.fill", () => { + anchor.setProvider(provider); + const payer = anchor.AnchorProvider.env().wallet.payer; + const relayer = Keypair.generate(); + const otherRelayer = Keypair.generate(); + + let state: PublicKey, mint: PublicKey, relayerTA: PublicKey, recipientTA: PublicKey, otherRelayerTA: PublicKey; + + const relayAmount = 500000; + let relayData: any; // reused relay data for all tests. + let accounts: any; // Store accounts to simplify contract interactions. + + function updateRelayData(newRelayData: any) { + relayData = newRelayData; + const relayHashUint8Array = calculateRelayHashUint8Array(relayData, chainId); + const [fillStatusPDA] = PublicKey.findProgramAddressSync( + [Buffer.from("fills"), relayHashUint8Array], + program.programId + ); + + accounts = { + state, + signer: relayer.publicKey, + relayer: relayer.publicKey, + recipient: recipient, + mintAccount: mint, + relayerTA: relayerTA, + recipientTA: recipientTA, + fillStatus: fillStatusPDA, + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: anchor.web3.SystemProgram.programId, + }; + } + + before("Creates token mint and associated token accounts", async () => { + mint = await createMint(connection, payer, owner, owner, 6); + recipientTA = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, recipient)).address; + relayerTA = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, relayer.publicKey)).address; + otherRelayerTA = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, otherRelayer.publicKey)).address; + + await mintTo(connection, payer, mint, relayerTA, owner, seedBalance); + await mintTo(connection, payer, mint, otherRelayerTA, owner, seedBalance); + + await connection.requestAirdrop(relayer.publicKey, 10_000_000_000); // 10 SOL + await connection.requestAirdrop(otherRelayer.publicKey, 10_000_000_000); // 10 SOL + }); + + beforeEach(async () => { + state = await initializeState(); + + const initialRelayData = { + depositor: recipient, + recipient: recipient, + exclusiveRelayer: relayer.publicKey, + inputToken: mint, // This is lazy. it should be an encoded token from a separate domain most likely. + outputToken: mint, + inputAmount: new BN(relayAmount), + outputAmount: new BN(relayAmount), + originChainId: new BN(1), + depositId: new BN(Math.floor(Math.random() * 1000000)), // force that we always have a new deposit id. + fillDeadline: new BN(Math.floor(Date.now() / 1000) + 60), // 1 minute from now + exclusivityDeadline: new BN(Math.floor(Date.now() / 1000) + 30), // 30 seconds from now + message: Buffer.from("Test message"), + }; + + updateRelayData(initialRelayData); + }); + + it("Fills a V3 relay and verifies balances", async () => { + // Verify recipient's balance before the fill + let recipientAccount = await getAccount(connection, recipientTA); + assertSE(recipientAccount.amount, "0", "Recipient's balance should be 0 before the fill"); + + // Verify relayer's balance before the fill + let relayerAccount = await getAccount(connection, relayerTA); + assertSE(relayerAccount.amount, seedBalance, "Relayer's balance should be equal to seed balance before the fill"); + + const relayHash = calculateRelayHashUint8Array(relayData, chainId); + await program.methods.fillV3Relay(relayHash, relayData, new BN(1)).accounts(accounts).signers([relayer]).rpc(); + + // Verify relayer's balance after the fill + relayerAccount = await getAccount(connection, relayerTA); + assertSE( + relayerAccount.amount, + seedBalance - relayAmount, + "Relayer's balance should be reduced by the relay amount" + ); + + // Verify recipient's balance after the fill + recipientAccount = await getAccount(connection, recipientTA); + assertSE(recipientAccount.amount, relayAmount, "Recipient's balance should be increased by the relay amount"); + }); + + it("Verifies FilledV3Relay event after filling a relay", async () => { + const relayHash = calculateRelayHashUint8Array(relayData, chainId); + await program.methods.fillV3Relay(relayHash, relayData, new BN(1)).accounts(accounts).signers([relayer]).rpc(); + + // Fetch and verify the FilledV3Relay event + await new Promise((resolve) => setTimeout(resolve, 500)); + const events = await readProgramEvents(connection, program); + const event = events.find((event) => event.name === "filledV3Relay").data; + assert.isNotNull(event, "FilledV3Relay event should be emitted"); + + // Verify that the event data matches the relay data. + Object.keys(relayData).forEach((key) => { + assertSE(event[key], relayData[key], `${key.charAt(0).toUpperCase() + key.slice(1)} should match`); + }); + }); + + it("Fails to fill a V3 relay after the fill deadline", async () => { + updateRelayData({ ...relayData, fillDeadline: new BN(Math.floor(Date.now() / 1000) - 69) }); // 69 seconds ago + + const relayHash = calculateRelayHashUint8Array(relayData, chainId); + try { + await program.methods.fillV3Relay(relayHash, relayData, new BN(1)).accounts(accounts).signers([relayer]).rpc(); + assert.fail("Fill should have failed due to fill deadline passed"); + } catch (err) { + assert.include(err.toString(), "FillDeadlinePassed", "Expected FillDeadlinePassed error"); + } + }); + + it("Fails to fill a V3 relay by non-exclusive relayer before exclusivity deadline", async () => { + accounts.signer = otherRelayer.publicKey; + accounts.relayer = otherRelayer.publicKey; + accounts.relayerTA = otherRelayerTA; + + const relayHash = calculateRelayHashUint8Array(relayData, chainId); + try { + await program.methods + .fillV3Relay(relayHash, relayData, new BN(1)) + .accounts(accounts) + .signers([otherRelayer]) + .rpc(); + assert.fail("Fill should have failed due to non-exclusive relayer before exclusivity deadline"); + } catch (err) { + assert.include(err.toString(), "NotExclusiveRelayer", "Expected NotExclusiveRelayer error"); + } + }); + + it("Allows fill by non-exclusive relayer after exclusivity deadline", async () => { + updateRelayData({ ...relayData, exclusivityDeadline: new BN(Math.floor(Date.now() / 1000) - 30) }); + + accounts.signer = otherRelayer.publicKey; + accounts.relayer = otherRelayer.publicKey; + accounts.relayerTA = otherRelayerTA; + + const recipientAccountBefore = await getAccount(connection, recipientTA); + const relayerAccountBefore = await getAccount(connection, otherRelayerTA); + + const relayHash = calculateRelayHashUint8Array(relayData, chainId); + await program.methods.fillV3Relay(relayHash, relayData, new BN(1)).accounts(accounts).signers([otherRelayer]).rpc(); + + // Verify relayer's balance after the fill + const relayerAccountAfter = await getAccount(connection, otherRelayerTA); + assertSE( + relayerAccountAfter.amount, + BigInt(relayerAccountBefore.amount) - BigInt(relayAmount), + "Relayer's balance should be reduced by the relay amount" + ); + + // Verify recipient's balance after the fill + const recipientAccountAfter = await getAccount(connection, recipientTA); + assertSE( + recipientAccountAfter.amount, + BigInt(recipientAccountBefore.amount) + BigInt(relayAmount), + "Recipient's balance should be increased by the relay amount" + ); + }); + + it("Fails to fill a V3 relay with the same deposit data multiple times", async () => { + const relayHash = calculateRelayHashUint8Array(relayData, chainId); + + // First fill attempt + await program.methods.fillV3Relay(relayHash, relayData, new BN(1)).accounts(accounts).signers([relayer]).rpc(); + + // Second fill attempt with the same data + try { + await program.methods.fillV3Relay(relayHash, relayData, new BN(1)).accounts(accounts).signers([relayer]).rpc(); + assert.fail("Fill should have failed due to AlreadyFilled error"); + } catch (err) { + assert.include(err.toString(), "AlreadyFilled", "Expected AlreadyFilled error"); + } + }); + + it("Closes the fill PDA after the fill", async () => { + const relayHash = calculateRelayHashUint8Array(relayData, chainId); + + const closeFillPdaAccounts = { + state, + signer: relayer.publicKey, + fillStatus: accounts.fillStatus, + systemProgram: anchor.web3.SystemProgram.programId, + }; + + // Execute the fill_v3_relay call + await program.methods.fillV3Relay(relayHash, relayData, new BN(1)).accounts(accounts).signers([relayer]).rpc(); + + // Verify the fill PDA exists before closing + const fillStatusAccountBefore = await connection.getAccountInfo(accounts.fillStatus); + assert.isNotNull(fillStatusAccountBefore, "Fill PDA should exist before closing"); + + // Attempt to close the fill PDA before the fill deadline should fail. + try { + await program.methods.closeFillPda(relayHash, relayData).accounts(closeFillPdaAccounts).signers([relayer]).rpc(); + assert.fail("Closing fill PDA should have failed before fill deadline"); + } catch (err) { + assert.include(err.toString(), "FillDeadlineNotPassed", "Expected FillDeadlineNotPassed error"); + } + + // Set the current time to past the fill deadline + await setCurrentTime(program, state, relayer, relayData.fillDeadline.add(new BN(1))); + + // Close the fill PDA + await program.methods.closeFillPda(relayHash, relayData).accounts(closeFillPdaAccounts).signers([relayer]).rpc(); + + // Verify the fill PDA is closed + const fillStatusAccountAfter = await connection.getAccountInfo(accounts.fillStatus); + assert.isNull(fillStatusAccountAfter, "Fill PDA should be closed after closing"); + }); + + it("Fetches FillStatusAccount before and after fillV3Relay", async () => { + const relayHash = calculateRelayHashUint8Array(relayData, chainId); + const [fillStatusPDA] = PublicKey.findProgramAddressSync([Buffer.from("fills"), relayHash], program.programId); + + // Fetch FillStatusAccount before fillV3Relay + let fillStatusAccount = await program.account.fillStatusAccount.fetchNullable(fillStatusPDA); + assert.isNull(fillStatusAccount, "FillStatusAccount should be uninitialized before fillV3Relay"); + + // Fill the relay + await program.methods.fillV3Relay(relayHash, relayData, new BN(1)).accounts(accounts).signers([relayer]).rpc(); + + // Fetch FillStatusAccount after fillV3Relay + fillStatusAccount = await program.account.fillStatusAccount.fetch(fillStatusPDA); + assert.isNotNull(fillStatusAccount, "FillStatusAccount should be initialized after fillV3Relay"); + assert.equal(JSON.stringify(fillStatusAccount.status), `{\"filled\":{}}`, "FillStatus should be Filled"); + assert.equal(fillStatusAccount.relayer.toString(), relayer.publicKey.toString(), "Caller should be set as relayer"); + }); + + it("Fails to fill a relay when fills are paused", async () => { + // Pause fills + await program.methods.pauseFills(true).accounts({ state, signer: owner }).rpc(); + const stateAccountData = await program.account.state.fetch(state); + assert.isTrue(stateAccountData.pausedFills, "Fills should be paused"); + + // Try to fill the relay. This should fail because fills are paused. + const relayHash = calculateRelayHashUint8Array(relayData, chainId); + try { + await program.methods.fillV3Relay(relayHash, relayData, new BN(1)).accounts(accounts).signers([relayer]).rpc(); + assert.fail("Should not be able to fill relay when fills are paused"); + } catch (err) { + assert.include(err.toString(), "Fills are currently paused!", "Expected fills paused error"); + } + }); +}); diff --git a/test/svm/SvmSpoke.HandleReceiveMessage.ts b/test/svm/SvmSpoke.HandleReceiveMessage.ts new file mode 100644 index 000000000..075669996 --- /dev/null +++ b/test/svm/SvmSpoke.HandleReceiveMessage.ts @@ -0,0 +1,426 @@ +import * as anchor from "@coral-xyz/anchor"; +import { ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID, createMint } from "@solana/spl-token"; +import { Keypair } from "@solana/web3.js"; +import { assert } from "chai"; +import { ethers } from "ethers"; +import { SvmSpoke } from "../../target/types/svm_spoke"; +import { MessageTransmitter } from "../../target/types/message_transmitter"; +import { evmAddressToPublicKey } from "../../src/SvmUtils"; +import { encodeMessageHeader } from "./cctpHelpers"; +import { common } from "./SvmSpoke.common"; + +const { createRoutePda, getVaultAta } = common; + +describe("svm_spoke.handle_receive_message", () => { + anchor.setProvider(anchor.AnchorProvider.env()); + + const program = anchor.workspace.SvmSpoke as anchor.Program; + const messageTransmitterProgram = anchor.workspace.MessageTransmitter as anchor.Program; + const provider = anchor.AnchorProvider.env(); + const owner = provider.wallet.publicKey; + let state: anchor.web3.PublicKey; + let authorityPda: anchor.web3.PublicKey; + let messageTransmitterState: anchor.web3.PublicKey; + let usedNonces: anchor.web3.PublicKey; + let selfAuthority: anchor.web3.PublicKey; + let eventAuthority: anchor.web3.PublicKey; + let seed: anchor.BN; + const chainId = new anchor.BN(420); + const remoteDomain = 0; // Ethereum + const localDomain = 5; // Solana + const crossDomainAdmin = evmAddressToPublicKey(ethers.Wallet.createRandom().address); + const firstNonce = 1; + const attestation = Buffer.alloc(0); + let nonce = firstNonce; + let remainingAccounts: anchor.web3.AccountMeta[]; + const cctpMessageversion = 0; + let destinationCaller = new anchor.web3.PublicKey(new Uint8Array(32)); // We don't use permissioned caller. + let receiveMessageAccounts; + + const ethereumIface = new ethers.utils.Interface([ + "function pauseDeposits(bool pause)", + "function pauseFills(bool pause)", + "function setCrossDomainAdmin(address newCrossDomainAdmin)", + "function setEnableRoute(bytes32 originToken, uint64 destinationChainId, bool enabled)", + ]); + + beforeEach(async () => { + seed = new anchor.BN(Math.floor(Math.random() * 1000000)); + const seeds = [Buffer.from("state"), seed.toArrayLike(Buffer, "le", 8)]; + [state] = anchor.web3.PublicKey.findProgramAddressSync(seeds, program.programId); + + // Initialize the state with an initial number of deposits + await program.methods + .initialize(seed, new anchor.BN(0), chainId, remoteDomain, crossDomainAdmin, true) + .accounts({ state, signer: owner, systemProgram: anchor.web3.SystemProgram.programId }) + .rpc(); + + nonce += 1; // Increment CCTP nonce. + + // Get other required accounts. + [authorityPda] = anchor.web3.PublicKey.findProgramAddressSync( + [Buffer.from("message_transmitter_authority"), program.programId.toBuffer()], + messageTransmitterProgram.programId + ); + [messageTransmitterState] = anchor.web3.PublicKey.findProgramAddressSync( + [Buffer.from("message_transmitter")], + messageTransmitterProgram.programId + ); + [usedNonces] = anchor.web3.PublicKey.findProgramAddressSync( + [Buffer.from("used_nonces"), Buffer.from(remoteDomain.toString()), Buffer.from(firstNonce.toString())], + messageTransmitterProgram.programId + ); + [selfAuthority] = anchor.web3.PublicKey.findProgramAddressSync([Buffer.from("self_authority")], program.programId); + [eventAuthority] = anchor.web3.PublicKey.findProgramAddressSync( + [Buffer.from("__event_authority")], + program.programId + ); + + // Accounts in CCTP message_transmitter receive_message instruction. + receiveMessageAccounts = { + payer: provider.wallet.publicKey, + caller: provider.wallet.publicKey, + authorityPda, + messageTransmitter: messageTransmitterState, + usedNonces, + receiver: program.programId, + systemProgram: anchor.web3.SystemProgram.programId, + }; + + remainingAccounts = []; + // state in HandleReceiveMessage accounts (used for remote domain and sender authentication). + remainingAccounts.push({ + isSigner: false, + isWritable: false, + pubkey: state, + }); + // self_authority in HandleReceiveMessage accounts, also signer in self-invoked CPIs. + remainingAccounts.push({ + isSigner: false, + // signer in self-invoked CPIs is mutable, as Solana owner is also fee payer when not using CCTP. + isWritable: true, + pubkey: selfAuthority, + }); + // program in HandleReceiveMessage accounts. + remainingAccounts.push({ + isSigner: false, + isWritable: false, + pubkey: program.programId, + }); + // state in self-invoked CPIs (state can change as a result of remote call). + remainingAccounts.push({ + isSigner: false, + isWritable: true, + pubkey: state, + }); + // event_authority in self-invoked CPIs (appended by Anchor with event_cpi macro). + remainingAccounts.push({ + isSigner: false, + isWritable: false, + pubkey: eventAuthority, + }); + // program in self-invoked CPIs (appended by Anchor with event_cpi macro). + remainingAccounts.push({ + isSigner: false, + isWritable: false, + pubkey: program.programId, + }); + }); + + it("Block Unauthorized Message", async () => { + const unauthorizedSender = Keypair.generate().publicKey; + const calldata = ethereumIface.encodeFunctionData("pauseDeposits", [true]); + const messageBody = Buffer.from(calldata.slice(2), "hex"); + + const message = encodeMessageHeader({ + version: cctpMessageversion, + sourceDomain: remoteDomain, + destinationDomain: localDomain, + nonce: BigInt(nonce), + sender: unauthorizedSender, + recipient: program.programId, + destinationCaller, + messageBody, + }); + + try { + await messageTransmitterProgram.methods + .receiveMessage({ message, attestation }) + .accounts(receiveMessageAccounts) + .remainingAccounts(remainingAccounts) + .rpc(); + assert.fail("Should not be able to receive message from unauthorized sender"); + } catch (error) { + assert.instanceOf(error, anchor.AnchorError); + assert.strictEqual(error.error.errorCode.code, "InvalidRemoteSender", "Expected error code InvalidRemoteSender"); + } + }); + + it("Block Wrong Source Domain", async () => { + const sourceDomain = 666; + [receiveMessageAccounts.usedNonces] = anchor.web3.PublicKey.findProgramAddressSync( + [Buffer.from("used_nonces"), Buffer.from(sourceDomain.toString()), Buffer.from(firstNonce.toString())], + messageTransmitterProgram.programId + ); + + const calldata = ethereumIface.encodeFunctionData("pauseDeposits", [true]); + const messageBody = Buffer.from(calldata.slice(2), "hex"); + + const message = encodeMessageHeader({ + version: cctpMessageversion, + sourceDomain, + destinationDomain: localDomain, + nonce: BigInt(nonce), + sender: crossDomainAdmin, + recipient: program.programId, + destinationCaller, + messageBody, + }); + + try { + await messageTransmitterProgram.methods + .receiveMessage({ message, attestation }) + .accounts(receiveMessageAccounts) + .remainingAccounts(remainingAccounts) + .rpc(); + assert.fail("Should not be able to receive message from wrong source domain"); + } catch (error) { + assert.instanceOf(error, anchor.AnchorError); + assert.strictEqual(error.error.errorCode.code, "InvalidRemoteDomain", "Expected error code InvalidRemoteDomain"); + } + }); + + it("Pauses and unpauses deposits remotely", async () => { + // Pause deposits. + let calldata = ethereumIface.encodeFunctionData("pauseDeposits", [true]); + let messageBody = Buffer.from(calldata.slice(2), "hex"); + let message = encodeMessageHeader({ + version: cctpMessageversion, + sourceDomain: remoteDomain, + destinationDomain: localDomain, + nonce: BigInt(nonce), + sender: crossDomainAdmin, + recipient: program.programId, + destinationCaller, + messageBody, + }); + await messageTransmitterProgram.methods + .receiveMessage({ message, attestation }) + .accounts(receiveMessageAccounts) + .remainingAccounts(remainingAccounts) + .rpc(); + let stateData = await program.account.state.fetch(state); + assert.isTrue(stateData.pausedDeposits, "Deposits should be paused"); + + // Unpause deposits. + nonce += 1; + calldata = ethereumIface.encodeFunctionData("pauseDeposits", [false]); + messageBody = Buffer.from(calldata.slice(2), "hex"); + message = encodeMessageHeader({ + version: cctpMessageversion, + sourceDomain: remoteDomain, + destinationDomain: localDomain, + nonce: BigInt(nonce), + sender: crossDomainAdmin, + recipient: program.programId, + destinationCaller, + messageBody, + }); + await messageTransmitterProgram.methods + .receiveMessage({ message, attestation }) + .accounts(receiveMessageAccounts) + .remainingAccounts(remainingAccounts) + .rpc(); + stateData = await program.account.state.fetch(state); + assert.isFalse(stateData.pausedDeposits, "Deposits should not be paused"); + }); + + it("Pauses and unpauses fills remotely", async () => { + // Pause fills. + let calldata = ethereumIface.encodeFunctionData("pauseFills", [true]); + let messageBody = Buffer.from(calldata.slice(2), "hex"); + let message = encodeMessageHeader({ + version: cctpMessageversion, + sourceDomain: remoteDomain, + destinationDomain: localDomain, + nonce: BigInt(nonce), + sender: crossDomainAdmin, + recipient: program.programId, + destinationCaller, + messageBody, + }); + await messageTransmitterProgram.methods + .receiveMessage({ message, attestation }) + .accounts(receiveMessageAccounts) + .remainingAccounts(remainingAccounts) + .rpc(); + let stateData = await program.account.state.fetch(state); + assert.isTrue(stateData.pausedFills, "Fills should be paused"); + + // Unpause fills. + nonce += 1; + calldata = ethereumIface.encodeFunctionData("pauseFills", [false]); + messageBody = Buffer.from(calldata.slice(2), "hex"); + message = encodeMessageHeader({ + version: cctpMessageversion, + sourceDomain: remoteDomain, + destinationDomain: localDomain, + nonce: BigInt(nonce), + sender: crossDomainAdmin, + recipient: program.programId, + destinationCaller, + messageBody, + }); + await messageTransmitterProgram.methods + .receiveMessage({ message, attestation }) + .accounts(receiveMessageAccounts) + .remainingAccounts(remainingAccounts) + .rpc(); + stateData = await program.account.state.fetch(state); + assert.isFalse(stateData.pausedFills, "Fills should not be paused"); + }); + + it("Sets cross-domain admin remotely", async () => { + const newCrossDomainAdminAddress = ethers.Wallet.createRandom().address; + const newCrossDomainAdminPubkey = evmAddressToPublicKey(newCrossDomainAdminAddress); + let calldata = ethereumIface.encodeFunctionData("setCrossDomainAdmin", [newCrossDomainAdminAddress]); + let messageBody = Buffer.from(calldata.slice(2), "hex"); + let message = encodeMessageHeader({ + version: cctpMessageversion, + sourceDomain: remoteDomain, + destinationDomain: localDomain, + nonce: BigInt(nonce), + sender: crossDomainAdmin, + recipient: program.programId, + destinationCaller, + messageBody, + }); + await messageTransmitterProgram.methods + .receiveMessage({ message, attestation }) + .accounts(receiveMessageAccounts) + .remainingAccounts(remainingAccounts) + .rpc(); + let stateData = await program.account.state.fetch(state); + assert.strictEqual( + stateData.crossDomainAdmin.toString(), + newCrossDomainAdminPubkey.toString(), + "Cross-domain admin should be set" + ); + }); + + it("Enables and disables route remotely", async () => { + // Enable the route. + const originToken = Keypair.generate().publicKey; + const routeChainId = 1; + let calldata = ethereumIface.encodeFunctionData("setEnableRoute", [originToken.toBuffer(), routeChainId, true]); + let messageBody = Buffer.from(calldata.slice(2), "hex"); + let message = encodeMessageHeader({ + version: cctpMessageversion, + sourceDomain: remoteDomain, + destinationDomain: localDomain, + nonce: BigInt(nonce), + sender: crossDomainAdmin, + recipient: program.programId, + destinationCaller, + messageBody, + }); + + // Remaining accounts specific to SetEnableRoute. + const routePda = createRoutePda(originToken, new anchor.BN(routeChainId)); + const tokenMint = await createMint(provider.connection, provider.wallet.payer, owner, owner, 6); + const vault = getVaultAta(tokenMint, state); + // Same 3 remaining accounts passed for HandleReceiveMessage context. + const enableRouteRemainingAccounts = remainingAccounts.slice(0, 3); + // payer in self-invoked SetEnableRoute. + enableRouteRemainingAccounts.push({ + isSigner: true, + isWritable: true, + pubkey: provider.wallet.publicKey, + }); + // state in self-invoked SetEnableRoute. + enableRouteRemainingAccounts.push({ + isSigner: false, + isWritable: true, + pubkey: state, + }); + // route in self-invoked SetEnableRoute. + enableRouteRemainingAccounts.push({ + isSigner: false, + isWritable: true, + pubkey: routePda, + }); + // vault in self-invoked SetEnableRoute. + enableRouteRemainingAccounts.push({ + isSigner: false, + isWritable: true, + pubkey: vault, + }); + // origin_token_mint in self-invoked SetEnableRoute. + enableRouteRemainingAccounts.push({ + isSigner: false, + isWritable: false, + pubkey: tokenMint, + }); + // token_program in self-invoked SetEnableRoute. + enableRouteRemainingAccounts.push({ + isSigner: false, + isWritable: false, + pubkey: TOKEN_PROGRAM_ID, + }); + // associated_token_program in self-invoked SetEnableRoute. + enableRouteRemainingAccounts.push({ + isSigner: false, + isWritable: false, + pubkey: ASSOCIATED_TOKEN_PROGRAM_ID, + }); + // system_program in self-invoked SetEnableRoute. + enableRouteRemainingAccounts.push({ + isSigner: false, + isWritable: false, + pubkey: anchor.web3.SystemProgram.programId, + }); + // event_authority in self-invoked SetEnableRoute (appended by Anchor with event_cpi macro). + enableRouteRemainingAccounts.push({ + isSigner: false, + isWritable: false, + pubkey: eventAuthority, + }); + // program in self-invoked SetEnableRoute (appended by Anchor with event_cpi macro). + enableRouteRemainingAccounts.push({ + isSigner: false, + isWritable: false, + pubkey: program.programId, + }); + await messageTransmitterProgram.methods + .receiveMessage({ message, attestation }) + .accounts(receiveMessageAccounts) + .remainingAccounts(enableRouteRemainingAccounts) + .rpc(); + + let routeAccount = await program.account.route.fetch(routePda); + assert.isTrue(routeAccount.enabled, "Route should be enabled"); + + // Disable the route. + nonce += 1; + calldata = ethereumIface.encodeFunctionData("setEnableRoute", [originToken.toBuffer(), routeChainId, false]); + messageBody = Buffer.from(calldata.slice(2), "hex"); + message = encodeMessageHeader({ + version: cctpMessageversion, + sourceDomain: remoteDomain, + destinationDomain: localDomain, + nonce: BigInt(nonce), + sender: crossDomainAdmin, + recipient: program.programId, + destinationCaller, + messageBody, + }); + await messageTransmitterProgram.methods + .receiveMessage({ message, attestation }) + .accounts(receiveMessageAccounts) + .remainingAccounts(enableRouteRemainingAccounts) + .rpc(); + + routeAccount = await program.account.route.fetch(routePda); + assert.isFalse(routeAccount.enabled, "Route should be disabled"); + }); +}); diff --git a/test/svm/SvmSpoke.Ownership.ts b/test/svm/SvmSpoke.Ownership.ts new file mode 100644 index 000000000..127e2c132 --- /dev/null +++ b/test/svm/SvmSpoke.Ownership.ts @@ -0,0 +1,157 @@ +import * as anchor from "@coral-xyz/anchor"; +import { Keypair, PublicKey } from "@solana/web3.js"; +import { assert } from "chai"; +import { common } from "./SvmSpoke.common"; +import { readProgramEvents } from "../../src/SvmUtils"; + +const { provider, program, owner, initializeState } = common; + +describe("svm_spoke.ownership", () => { + anchor.setProvider(provider); + + const nonOwner = Keypair.generate(); + const newOwner = Keypair.generate(); + const newCrossDomainAdmin = Keypair.generate(); + let state: PublicKey; + + beforeEach(async () => { + state = await initializeState(); + }); + + it("Pauses and unpauses deposits", async () => { + assert.isFalse((await program.account.state.fetch(state)).pausedDeposits, "Deposits should not be paused"); + + // Pause deposits as owner + await program.methods.pauseDeposits(true).accounts({ state, signer: owner }).rpc(); + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Fetch the updated state + let stateAccountData = await program.account.state.fetch(state); + assert.isTrue(stateAccountData.pausedDeposits, "Deposits should be paused"); + + // Verify the PausedDeposits event + let events = await readProgramEvents(provider.connection, program); + let pausedDepositEvents = events.filter((event) => event.name === "pausedDeposits"); + assert.isTrue(pausedDepositEvents[0].data.isPaused, "PausedDeposits event should indicate deposits are paused"); + + // Unpause deposits as owner + await program.methods.pauseDeposits(false).accounts({ state, signer: owner }).rpc(); + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Fetch the updated state + stateAccountData = await program.account.state.fetch(state); + assert.isFalse(stateAccountData.pausedDeposits, "Deposits should not be paused"); + + // Verify the PausedDeposits event + events = await readProgramEvents(provider.connection, program); + pausedDepositEvents = events.filter((event) => event.name === "pausedDeposits"); + assert.isFalse(pausedDepositEvents[0].data.isPaused, "PausedDeposits event should indicate deposits are unpaused"); + + // Try to pause deposits as non-owner + try { + await program.methods + .pauseDeposits(true) + .accounts({ state, signer: nonOwner.publicKey }) + .signers([nonOwner]) + .rpc(); + assert.fail("Non-owner should not be able to pause deposits"); + } catch (err) { + assert.include(err.toString(), "Only the owner can call this function!", "Expected owner check error"); + } + }); + + it("Pauses and unpauses fills", async () => { + assert.isFalse((await program.account.state.fetch(state)).pausedFills, "Fills should not be paused"); + + // Pause fills as owner + await program.methods.pauseFills(true).accounts({ state, signer: owner }).rpc(); + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Fetch the updated state + let stateAccountData = await program.account.state.fetch(state); + assert.isTrue(stateAccountData.pausedFills, "Fills should be paused"); + + // Verify the PausedFills event + let events = await readProgramEvents(provider.connection, program); + let pausedFillEvents = events.filter((event) => event.name === "pausedFills"); + assert.isTrue(pausedFillEvents[0].data.isPaused, "PausedFills event should indicate fills are paused"); + + // Unpause fills as owner + await program.methods.pauseFills(false).accounts({ state, signer: owner }).rpc(); + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Fetch the updated state + stateAccountData = await program.account.state.fetch(state); + assert.isFalse(stateAccountData.pausedFills, "Fills should not be paused"); + + // Verify the PausedFills event + events = await readProgramEvents(provider.connection, program); + pausedFillEvents = events.filter((event) => event.name === "pausedFills"); + assert.isFalse(pausedFillEvents[0].data.isPaused, "PausedFills event should indicate fills are unpaused"); + + // Try to pause fills as non-owner + try { + await program.methods.pauseFills(true).accounts({ state, signer: nonOwner.publicKey }).signers([nonOwner]).rpc(); + assert.fail("Non-owner should not be able to pause fills"); + } catch (err) { + assert.include(err.toString(), "Only the owner can call this function!", "Expected owner check error"); + } + }); + + it("Transfers ownership", async () => { + // Transfer ownership to newOwner + await program.methods.transferOwnership(newOwner.publicKey).accounts({ state, signer: owner }).rpc(); + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Verify the new owner + let stateAccountData = await program.account.state.fetch(state); + assert.equal(stateAccountData.owner.toString(), newOwner.publicKey.toString(), "Ownership should be transferred"); + + // Try to transfer ownership as non-owner + try { + await program.methods + .transferOwnership(nonOwner.publicKey) + .accounts({ state, signer: nonOwner.publicKey }) + .signers([nonOwner]) + .rpc(); + assert.fail("Non-owner should not be able to transfer ownership"); + } catch (err) { + assert.include(err.toString(), "Only the owner can call this function!", "Expected owner check error"); + } + }); + + it("Sets cross-domain admin", async () => { + // Set cross-domain admin as owner + await program.methods.setCrossDomainAdmin(newCrossDomainAdmin.publicKey).accounts({ state, signer: owner }).rpc(); + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Verify the new cross-domain admin + let stateAccountData = await program.account.state.fetch(state); + assert.equal( + stateAccountData.crossDomainAdmin.toString(), + newCrossDomainAdmin.publicKey.toString(), + "Cross-domain admin should be set" + ); + + // Verify the SetXDomainAdmin event + let events = await readProgramEvents(provider.connection, program); + let setXDomainAdminEvents = events.filter((event) => event.name === "setXDomainAdmin"); + assert.equal( + setXDomainAdminEvents[0].data.newAdmin.toString(), + newCrossDomainAdmin.publicKey.toString(), + "SetXDomainAdmin event should indicate the new admin" + ); + + // Try to set cross-domain admin as non-owner + try { + await program.methods + .setCrossDomainAdmin(nonOwner.publicKey) + .accounts({ state, signer: nonOwner.publicKey }) + .signers([nonOwner]) + .rpc(); + assert.fail("Non-owner should not be able to set cross-domain admin"); + } catch (err) { + assert.include(err.toString(), "Only the owner can call this function!", "Expected owner check error"); + } + }); +}); diff --git a/test/svm/SvmSpoke.Routes.ts b/test/svm/SvmSpoke.Routes.ts new file mode 100644 index 000000000..c0b821e4b --- /dev/null +++ b/test/svm/SvmSpoke.Routes.ts @@ -0,0 +1,106 @@ +import * as anchor from "@coral-xyz/anchor"; +import { BN } from "@coral-xyz/anchor"; +import { ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID, createMint, getAccount } from "@solana/spl-token"; +import { PublicKey, Keypair } from "@solana/web3.js"; +import { assert } from "chai"; +import { common } from "./SvmSpoke.common"; +import { readProgramEvents } from "../../src/SvmUtils"; + +const { provider, program, owner, initializeState, createRoutePda, getVaultAta } = common; + +describe("svm_spoke.routes", () => { + anchor.setProvider(provider); + + const nonOwner = Keypair.generate(); + let state: PublicKey, tokenMint: PublicKey; + + before("Creates token mint and associated token accounts", async () => { + tokenMint = await createMint(provider.connection, provider.wallet.payer, owner, owner, 6); + }); + + beforeEach(async () => { + state = await initializeState(); + }); + + it("Sets, retrieves, and controls access to route enablement", async () => { + const originToken = Keypair.generate().publicKey; + const routeChainId = new BN(1); + + // Create a PDA for the route + const routePda = createRoutePda(originToken, routeChainId); + + // Create ATA for the origin token to be stored by state (vault). + const vault = getVaultAta(tokenMint, state); + + // Common accounts object + const accounts = { + signer: owner, + payer: owner, + state, + route: routePda, + vault: vault, + originTokenMint: tokenMint, + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: anchor.web3.SystemProgram.programId, + }; + + // Enable the route as owner + await program.methods.setEnableRoute(originToken.toBytes(), routeChainId, true).accounts(accounts).rpc(); + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Retrieve and verify the route is enabled + let routeAccount = await program.account.route.fetch(routePda); + assert.isTrue(routeAccount.enabled, "Route should be enabled"); + + // Verify the enabledDepositRoute event + let events = (await readProgramEvents(provider.connection, program)).filter( + (event) => event.name === "enabledDepositRoute" + ); + let event = events[0].data; + assert.strictEqual(event.originToken.toString(), originToken.toString(), "originToken event match"); + assert.strictEqual(event.destinationChainId.toString(), routeChainId.toString(), "destinationChainId should match"); + assert.isTrue(event.enabled, "enabledDepositRoute enabled"); + + // Disable the route as owner + await program.methods.setEnableRoute(originToken.toBytes(), routeChainId, false).accounts(accounts).rpc(); + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Retrieve and verify the route is disabled + routeAccount = await program.account.route.fetch(routePda); + assert.isFalse(routeAccount.enabled, "Route should be disabled"); + + // Verify the enabledDepositRoute event + events = (await readProgramEvents(provider.connection, program)).filter( + (event) => event.name === "enabledDepositRoute" + ); + event = events[0].data; // take most recent event, index 0. + assert.strictEqual(event.originToken.toString(), originToken.toString(), "originToken event match"); + assert.strictEqual(event.destinationChainId.toString(), routeChainId.toString(), "destinationChainId should match"); + assert.isFalse(event.enabled, "enabledDepositRoute disabled"); + + // Try to enable the route as non-owner + try { + await program.methods + .setEnableRoute(originToken.toBytes(), routeChainId, true) + .accounts({ ...accounts, signer: nonOwner.publicKey }) + .signers([nonOwner]) + .rpc(); + assert.fail("Non-owner should not be able to set route enablement"); + } catch (err) { + assert.include(err.toString(), "Only the owner can call this function!", "Expected owner check error"); + } + + // Verify the route is still disabled after non-owner attempt + routeAccount = await program.account.route.fetch(routePda); + assert.isFalse(routeAccount.enabled, "Route should still be disabled after non-owner attempt"); + + // Verify the owner of the vault is the state + const vaultAccount = await getAccount(provider.connection, vault); + assert.strictEqual(vaultAccount.owner.toBase58(), state.toBase58(), "Vault owner should be the state"); + + // Verify the owner of the state is the expected owner + const stateAccount = await program.account.state.fetch(state); + assert.strictEqual(stateAccount.owner.toBase58(), owner.toBase58(), "State owner should be the expected owner"); + }); +}); diff --git a/test/svm/SvmSpoke.SlowFill.ts b/test/svm/SvmSpoke.SlowFill.ts new file mode 100644 index 000000000..06e5e104b --- /dev/null +++ b/test/svm/SvmSpoke.SlowFill.ts @@ -0,0 +1,384 @@ +import * as anchor from "@coral-xyz/anchor"; +import { BN } from "@coral-xyz/anchor"; +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + createMint, + getOrCreateAssociatedTokenAccount, + mintTo, + getAccount, +} from "@solana/spl-token"; +import { PublicKey, Keypair } from "@solana/web3.js"; +import { readProgramEvents, calculateRelayHashUint8Array } from "../../src/SvmUtils"; +import { common } from "./SvmSpoke.common"; +import { MerkleTree } from "@uma/common/dist/MerkleTree"; +import { slowFillHashFn, SlowFillLeaf } from "./utils"; + +const { provider, connection, program, owner, chainId, seedBalance, initializeState } = common; +const { recipient, setCurrentTime, assertSE, assert } = common; + +describe("svm_spoke.slow_fill", () => { + anchor.setProvider(provider); + const payer = anchor.AnchorProvider.env().wallet.payer; + const relayer = Keypair.generate(); + const otherRelayer = Keypair.generate(); + + let state: PublicKey, + mint: PublicKey, + relayerTA: PublicKey, + recipientTA: PublicKey, + otherRelayerTA: PublicKey, + vault: PublicKey; + + const relayAmount = 500_000; + let relayData: any; // reused relay data for all tests. + let requestAccounts: any; // Store accounts to simplify program interactions. + let fillAccounts: any; + + const initialMintAmount = 10_000_000_000; + + function updateRelayData(newRelayData: any) { + relayData = newRelayData; + const relayHashUint8Array = calculateRelayHashUint8Array(relayData, chainId); + const [fillStatusPDA] = PublicKey.findProgramAddressSync( + [Buffer.from("fills"), relayHashUint8Array], + program.programId + ); + + // Accounts for requestingSlowFill. + requestAccounts = { + state, + signer: relayer.publicKey, + recipient: recipient, + fillStatus: fillStatusPDA, + systemProgram: anchor.web3.SystemProgram.programId, + }; + fillAccounts = { + state, + signer: relayer.publicKey, + relayer: relayer.publicKey, + recipient: recipient, + mintAccount: mint, + relayerTA: relayerTA, + recipientTA: recipientTA, + fillStatus: fillStatusPDA, + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: anchor.web3.SystemProgram.programId, + }; + } + + const relaySlowFillRootBundle = async () => { + //TODO: verify that the leaf structure created here is equivalent to the one created by the EVM logic. I think + // I've gotten the concatenation, endianness, etc correct but want to be sure. + const slowRelayLeafs: SlowFillLeaf[] = []; + const slowRelayLeaf: SlowFillLeaf = { + relayData: { + depositor: recipient, + recipient: recipient, + exclusiveRelayer: relayer.publicKey, + inputToken: mint, + outputToken: mint, + inputAmount: new BN(relayAmount), + outputAmount: new BN(relayAmount), + originChainId: new BN(1), + depositId: new BN(Math.floor(Math.random() * 1000000)), // Unique ID for each test. + fillDeadline: new BN(Math.floor(Date.now() / 1000) - 30), // Note we set time in past to avoid fill deadline. + exclusivityDeadline: new BN(Math.floor(Date.now() / 1000) - 60), + message: Buffer.from("Test message"), + }, + chainId, + updatedOutputAmount: new BN(relayAmount), + }; + updateRelayData(slowRelayLeaf.relayData); + + slowRelayLeafs.push(slowRelayLeaf); + + const merkleTree = new MerkleTree(slowRelayLeafs, slowFillHashFn); + + const root = merkleTree.getRoot(); + const proof = merkleTree.getProof(slowRelayLeafs[0]); + const leaf = slowRelayLeafs[0]; + + let stateAccountData = await program.account.state.fetch(state); + const rootBundleId = stateAccountData.rootBundleId; + + const rootBundleIdBuffer = Buffer.alloc(4); + rootBundleIdBuffer.writeUInt32LE(rootBundleId); + const seeds = [Buffer.from("root_bundle"), state.toBuffer(), rootBundleIdBuffer]; + const [rootBundle] = PublicKey.findProgramAddressSync(seeds, program.programId); + + // Relay root bundle + await program.methods + .relayRootBundle(Array.from(root), Array.from(root)) + .accounts({ state, rootBundle, signer: owner }) + .rpc(); + + const proofAsNumbers = proof.map((p) => Array.from(p)); + const relayHash = calculateRelayHashUint8Array(slowRelayLeaf.relayData, chainId); + + return { relayHash, leaf, rootBundleId, proofAsNumbers, rootBundle }; + }; + + before("Creates token mint and associated token accounts", async () => { + mint = await createMint(connection, payer, owner, owner, 6); + recipientTA = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, recipient)).address; + relayerTA = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, relayer.publicKey)).address; + otherRelayerTA = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, otherRelayer.publicKey)).address; + + await mintTo(connection, payer, mint, relayerTA, owner, seedBalance); + await mintTo(connection, payer, mint, otherRelayerTA, owner, seedBalance); + + await connection.requestAirdrop(relayer.publicKey, initialMintAmount); // 10 SOL + await connection.requestAirdrop(otherRelayer.publicKey, initialMintAmount); // 10 SOL + }); + + beforeEach(async () => { + state = await initializeState(); + vault = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, state, true)).address; // Initialize vault + + // mint mint to vault + await mintTo(connection, payer, mint, vault, provider.publicKey, initialMintAmount); + + const initialVaultBalance = (await connection.getTokenAccountBalance(vault)).value.amount; + assert.strictEqual( + BigInt(initialVaultBalance), + BigInt(initialMintAmount), + "Initial vault balance should be equal to the minted amount" + ); + + const initialRelayData = { + depositor: recipient, + recipient: recipient, + exclusiveRelayer: relayer.publicKey, + inputToken: mint, // This is lazy. it should be an encoded token from a separate domain most likely. + outputToken: mint, + inputAmount: new BN(relayAmount), + outputAmount: new BN(relayAmount), + originChainId: new BN(1), + depositId: 1, + fillDeadline: new BN(Math.floor(Date.now() / 1000) + 60), // 1 minute from now + exclusivityDeadline: new BN(Math.floor(Date.now() / 1000) + 30), // 30 seconds from now + message: Buffer.from("Test message"), + }; + + updateRelayData(initialRelayData); + }); + + it("Requests a V3 slow fill, verify the event & state change", async () => { + // Attempt to request a slow fill before the fillDeadline + const relayHash = calculateRelayHashUint8Array(relayData, chainId); + try { + await program.methods.requestV3SlowFill(relayHash, relayData).accounts(requestAccounts).signers([relayer]).rpc(); + assert.fail("Request should have failed due to fill deadline not passed"); + } catch (err) { + assert.include(err.toString(), "WithinFillWindow", "Expected WithinFillWindow error"); + } + + // Set the contract time to be after the fillDeadline + await setCurrentTime(program, state, relayer, relayData.fillDeadline.add(new BN(1))); + + await program.methods.requestV3SlowFill(relayHash, relayData).accounts(requestAccounts).signers([relayer]).rpc(); + + // Fetch and verify the RequestedV3SlowFill event + await new Promise((resolve) => setTimeout(resolve, 500)); + const events = await readProgramEvents(connection, program); + const event = events.find((event) => event.name === "requestedV3SlowFill").data; + assert.isNotNull(event, "RequestedV3SlowFill event should be emitted"); + + // Verify that the event data matches the relay data. + Object.keys(relayData).forEach((key) => { + assertSE(event[key], relayData[key], `${key.charAt(0).toUpperCase() + key.slice(1)} should match`); + }); + }); + + it("Fails to request a V3 slow fill if the relay has already been filled", async () => { + const relayHash = calculateRelayHashUint8Array(relayData, chainId); + + // Fill the relay first + await program.methods.fillV3Relay(relayHash, relayData, new BN(1)).accounts(fillAccounts).signers([relayer]).rpc(); + + try { + await program.methods.requestV3SlowFill(relayHash, relayData).accounts(requestAccounts).signers([relayer]).rpc(); + assert.fail("Request should have failed due to being within exclusivity window"); + } catch (err) { + assert.include(err.toString(), "WithinFillWindow", "Expected WithinFillWindow error"); + } + + // Set the contract time to be after the fillDeadline. + await setCurrentTime(program, state, relayer, relayData.fillDeadline.add(new BN(1))); + + // Attempt to request a slow fill after the relay has been filled. + try { + await program.methods.requestV3SlowFill(relayHash, relayData).accounts(requestAccounts).signers([relayer]).rpc(); + assert.fail("Request should have failed due to relay already being filled"); + } catch (err) { + assert.include(err.toString(), "InvalidSlowFillRequest", "Expected InvalidSlowFillRequest error"); + } + }); + + it("Fetches FillStatusAccount before and after requestV3SlowFill", async () => { + const relayHash = calculateRelayHashUint8Array(relayData, chainId); + const [fillStatusPDA] = PublicKey.findProgramAddressSync([Buffer.from("fills"), relayHash], program.programId); + + // Fetch FillStatusAccount before requestV3SlowFill + let fillStatusAccount = await program.account.fillStatusAccount.fetchNullable(fillStatusPDA); + assert.isNull(fillStatusAccount, "FillStatusAccount should be uninitialized before requestV3SlowFill"); + + // Set the contract time to be after the fillDeadline + await setCurrentTime(program, state, relayer, relayData.fillDeadline.add(new BN(1))); + + // Request a slow fill + await program.methods.requestV3SlowFill(relayHash, relayData).accounts(requestAccounts).signers([relayer]).rpc(); + + // Fetch FillStatusAccount after requestV3SlowFill + fillStatusAccount = await program.account.fillStatusAccount.fetch(fillStatusPDA); + assert.isNotNull(fillStatusAccount, "FillStatusAccount should be initialized after requestV3SlowFill"); + assert.equal( + JSON.stringify(fillStatusAccount.status), + `{\"requestedSlowFill\":{}}`, + "FillStatus should be RequestedSlowFill" + ); + assert.equal(fillStatusAccount.relayer.toString(), relayer.publicKey.toString(), "Caller should be set as relayer"); + }); + + it("Fails to request a V3 slow fill multiple times for the same fill", async () => { + const relayHash = calculateRelayHashUint8Array(relayData, chainId); + + // Set the contract time to be after the fillDeadline + await setCurrentTime(program, state, relayer, relayData.fillDeadline.add(new BN(1))); + + // Request a slow fill + await program.methods.requestV3SlowFill(relayHash, relayData).accounts(requestAccounts).signers([relayer]).rpc(); + + // Attempt to request a slow fill again for the same relay + try { + await program.methods.requestV3SlowFill(relayHash, relayData).accounts(requestAccounts).signers([relayer]).rpc(); + assert.fail("Request should have failed due to relay already being requested for slow fill"); + } catch (err) { + assert.include(err.toString(), "InvalidSlowFillRequest", "Expected InvalidSlowFillRequest error"); + } + }); + + it("Executes V3 slow relay leaf", async () => { + // Relay root bundle with slow fill leaf. + const { relayHash, leaf, rootBundleId, proofAsNumbers, rootBundle } = await relaySlowFillRootBundle(); + + const iVaultBal = (await connection.getTokenAccountBalance(vault)).value.amount; + const iRecipientBal = (await connection.getTokenAccountBalance(recipientTA)).value.amount; + + // Attempt to execute V3 slow relay leaf before requesting slow fill. This should fail before requested, + // even if there is a valid proof. + try { + await program.methods + .executeV3SlowRelayLeaf(relayHash, leaf, rootBundleId, proofAsNumbers) + .accounts({ + state: state, + rootBundle: rootBundle, + signer: owner, + fillStatus: requestAccounts.fillStatus, + vault: vault, + tokenProgram: TOKEN_PROGRAM_ID, + mint: mint, + recipient, + recipientTokenAccount: recipientTA, + }) + .rpc(); + assert.fail("Execution should have failed due to fill status account not being initialized"); + } catch (err) { + assert.include(err.toString(), "AccountNotInitialized", "Expected AccountNotInitialized error"); + } + + // Request V3 slow fill + await program.methods + .requestV3SlowFill(relayHash, leaf.relayData) + .accounts(requestAccounts) + .signers([relayer]) + .rpc(); + + // Execute V3 slow relay leaf after requesting slow fill + await program.methods + .executeV3SlowRelayLeaf(relayHash, leaf, rootBundleId, proofAsNumbers) + .accounts({ + state: state, + rootBundle, + signer: owner, + fillStatus: requestAccounts.fillStatus, + vault: vault, + tokenProgram: TOKEN_PROGRAM_ID, + mint: mint, + recipient, + recipientTokenAccount: recipientTA, + }) + .rpc(); + + // Verify the results + const fVaultBal = (await connection.getTokenAccountBalance(vault)).value.amount; + const fRecipientBal = (await connection.getTokenAccountBalance(recipientTA)).value.amount; + + assert.strictEqual( + BigInt(iVaultBal) - BigInt(fVaultBal), + BigInt(leaf.updatedOutputAmount.toNumber()), + "Vault balance should be reduced by relay amount" + ); + assert.strictEqual( + BigInt(fRecipientBal) - BigInt(iRecipientBal), + BigInt(leaf.updatedOutputAmount.toNumber()), + "Recipient balance should be increased by relay amount" + ); + }); + + it("Fails to request a V3 slow fill when fills are paused", async () => { + // Pause fills + await program.methods.pauseFills(true).accounts({ state, signer: owner }).rpc(); + const stateAccountData = await program.account.state.fetch(state); + assert.isTrue(stateAccountData.pausedFills, "Fills should be paused"); + + // Set the contract time to be after the fillDeadline + await setCurrentTime(program, state, relayer, relayData.fillDeadline.add(new BN(1))); + + // Attempt to request a slow fill. This should fail because fills are paused. + try { + const relayHash = calculateRelayHashUint8Array(relayData, chainId); + await program.methods.requestV3SlowFill(relayHash, relayData).accounts(requestAccounts).signers([relayer]).rpc(); + assert.fail("Request should have failed due to fills being paused"); + } catch (err) { + assert.instanceOf(err, anchor.AnchorError); + assert.strictEqual(err.error.errorCode.code, "FillsArePaused", "Expected error code FillsArePaused"); + } + }); + + it("Fails to execute V3 slow relay leaf to wrong recipient", async () => { + // Request V3 slow fill. + const { relayHash, leaf, rootBundleId, proofAsNumbers, rootBundle } = await relaySlowFillRootBundle(); + await program.methods + .requestV3SlowFill(relayHash, leaf.relayData) + .accounts(requestAccounts) + .signers([relayer]) + .rpc(); + + // Try to execute V3 slow relay leaf with wrong recipient should fail. + const wrongRecipient = Keypair.generate().publicKey; + const wrongRecipientTA = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, wrongRecipient)).address; + try { + await program.methods + .executeV3SlowRelayLeaf(relayHash, leaf, rootBundleId, proofAsNumbers) + .accounts({ + state: state, + rootBundle, + signer: owner, + fillStatus: requestAccounts.fillStatus, + vault: vault, + tokenProgram: TOKEN_PROGRAM_ID, + mint: mint, + recipient: wrongRecipient, + recipientTokenAccount: wrongRecipientTA, + }) + .rpc(); + assert.fail("Execution should have failed due to wrong recipient"); + } catch (err) { + assert.instanceOf(err, anchor.AnchorError); + assert.strictEqual(err.error.errorCode.code, "InvalidFillRecipient", "Expected error code InvalidFillRecipient"); + } + }); +}); diff --git a/test/svm/SvmSpoke.TokenBridge.ts b/test/svm/SvmSpoke.TokenBridge.ts new file mode 100644 index 000000000..de7857529 --- /dev/null +++ b/test/svm/SvmSpoke.TokenBridge.ts @@ -0,0 +1,286 @@ +import * as anchor from "@coral-xyz/anchor"; +import { PublicKey } from "@solana/web3.js"; +import { TOKEN_PROGRAM_ID, createMint, getOrCreateAssociatedTokenAccount, mintTo } from "@solana/spl-token"; +import { MerkleTree } from "@uma/common/dist/MerkleTree"; +import { common } from "./SvmSpoke.common"; +import { MessageTransmitter } from "../../target/types/message_transmitter"; +import { TokenMessengerMinter } from "../../target/types/token_messenger_minter"; +import { findProgramAddress } from "../../src/SvmUtils"; +import { RelayerRefundLeafSolana, RelayerRefundLeafType, relayerRefundHashFn } from "./utils"; +import { assert } from "chai"; +import { decodeMessageSentData } from "./cctpHelpers"; + +const { provider, program, owner, initializeState, connection, remoteDomain, chainId, crossDomainAdmin } = common; + +describe("svm_spoke.token_bridge", () => { + anchor.setProvider(provider); + + const tokenMessengerMinterProgram = anchor.workspace.TokenMessengerMinter as anchor.Program; + const messageTransmitterProgram = anchor.workspace.MessageTransmitter as anchor.Program; + + let state: PublicKey, + mint: PublicKey, + vault: PublicKey, + tokenMinter: PublicKey, + messageTransmitter: PublicKey, + tokenMessenger: PublicKey, + remoteTokenMessenger: PublicKey, + eventAuthority: PublicKey, + transferLiability: PublicKey, + localToken: PublicKey, + tokenMessengerMinterSenderAuthority: PublicKey; + + let messageSentEventData: anchor.web3.Keypair; // This will hold CCTP message data. + + let bridgeTokensToHubPoolAccounts: any; + + const payer = (anchor.AnchorProvider.env().wallet as anchor.Wallet).payer; + + const initialMintAmount = 10_000_000_000; + + before(async () => { + // token_minter state is pulled from devnet (DBD8hAwLDRQkTsu6EqviaYNGKPnsAMmQonxf7AH8ZcFY) with its + // token_controller field overridden to test wallet. + tokenMinter = findProgramAddress("token_minter", tokenMessengerMinterProgram.programId).publicKey; + + // message_transmitter state is forked from devnet (BWrwSWjbikT3H7qHAkUEbLmwDQoB4ZDJ4wcSEhSPTZCu). + messageTransmitter = findProgramAddress("message_transmitter", messageTransmitterProgram.programId).publicKey; + + // token_messenger state is forked from devnet (Afgq3BHEfCE7d78D2XE9Bfyu2ieDqvE24xX8KDwreBms). + tokenMessenger = findProgramAddress("token_messenger", tokenMessengerMinterProgram.programId).publicKey; + + // Ethereum remote_token_messenger state is forked from devnet (Hazwi3jFQtLKc2ughi7HFXPkpDeso7DQaMR9Ks4afh3j). + remoteTokenMessenger = findProgramAddress("remote_token_messenger", tokenMessengerMinterProgram.programId, [ + remoteDomain.toString(), + ]).publicKey; + + // PDA for token_messenger_minter to emit DepositForBurn event via CPI. + eventAuthority = findProgramAddress("__event_authority", tokenMessengerMinterProgram.programId).publicKey; + + // PDA, used to check that CCTP sendMessage was called by TokenMessenger + tokenMessengerMinterSenderAuthority = findProgramAddress( + "sender_authority", + tokenMessengerMinterProgram.programId + ).publicKey; + }); + + beforeEach(async () => { + // Each test will have different state and mint token. + state = await initializeState(); + mint = await createMint(connection, payer, owner, owner, 6); + vault = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, state, true)).address; + + await mintTo(connection, payer, mint, vault, provider.publicKey, initialMintAmount); + + transferLiability = findProgramAddress("transfer_liability", program.programId, [mint]).publicKey; + localToken = findProgramAddress("local_token", tokenMessengerMinterProgram.programId, [mint]).publicKey; + + // add local cctp token + const custodyTokenAccount = findProgramAddress("custody", tokenMessengerMinterProgram.programId, [mint]).publicKey; + await tokenMessengerMinterProgram.methods + .addLocalToken({}) + .accounts({ + tokenController: owner, + tokenMinter, + localToken, + custodyTokenAccount, + localTokenMint: mint, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .rpc(); + + // set max burn amount per CCTP message for local token to total mint amount. + await tokenMessengerMinterProgram.methods + .setMaxBurnAmountPerMessage({ burnLimitPerMessage: new anchor.BN(initialMintAmount) }) + .accounts({ + tokenMinter, + localToken, + }) + .rpc(); + + // Populate accounts for bridgeTokensToHubPool. + messageSentEventData = anchor.web3.Keypair.generate(); + bridgeTokensToHubPoolAccounts = { + payer: owner, + mint, + state, + transferLiability, + vault, + tokenMessengerMinterSenderAuthority, + messageTransmitter, + tokenMessenger, + remoteTokenMessenger, + tokenMinter, + localToken, + messageSentEventData: messageSentEventData.publicKey, + messageTransmitterProgram: messageTransmitterProgram.programId, + tokenMessengerMinterProgram: tokenMessengerMinterProgram.programId, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: anchor.web3.SystemProgram.programId, + eventAuthority, + }; + }); + + const initializeBridgeToHubPool = async (amountToReturn: number) => { + // Prepare root bundle with a single leaf containing amount to bridge to the HubPool. + const relayerRefundLeaves: RelayerRefundLeafType[] = []; + relayerRefundLeaves.push({ + isSolana: true, + leafId: new anchor.BN(0), + chainId, + amountToReturn: new anchor.BN(amountToReturn), + mintPublicKey: mint, + refundAccounts: [], + refundAmounts: [], + }); + const merkleTree = new MerkleTree(relayerRefundLeaves, relayerRefundHashFn); + const root = merkleTree.getRoot(); + const proof = merkleTree.getProof(relayerRefundLeaves[0]); + const leaf = relayerRefundLeaves[0] as RelayerRefundLeafSolana; + const stateAccountData = await program.account.state.fetch(state); + const rootBundleId = stateAccountData.rootBundleId; + const rootBundleIdBuffer = Buffer.alloc(4); + rootBundleIdBuffer.writeUInt32LE(rootBundleId); + const seeds = [Buffer.from("root_bundle"), state.toBuffer(), rootBundleIdBuffer]; + const [rootBundle] = PublicKey.findProgramAddressSync(seeds, program.programId); + + // Relay root bundle + await program.methods + .relayRootBundle(Array.from(root), Array.from(Buffer.alloc(32))) + .accounts({ state, rootBundle, signer: owner }) + .rpc(); + + // Execute relayer refund leaf. + const proofAsNumbers = proof.map((p) => Array.from(p)); + await program.methods + .executeRelayerRefundLeaf(stateAccountData.rootBundleId, leaf, proofAsNumbers) + .accounts({ + state, + rootBundle, + signer: owner, + vault, + mint, + transferLiability, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .rpc(); + }; + + it("Bridge all pending tokens to HubPool in single transaction", async () => { + const pendingToHubPool = 1_000_000; + + await initializeBridgeToHubPool(pendingToHubPool); + + const initialVaultBalance = (await connection.getTokenAccountBalance(vault)).value.amount; + assert.strictEqual(initialVaultBalance, initialMintAmount.toString()); + + await program.methods + .bridgeTokensToHubPool(new anchor.BN(pendingToHubPool)) + .accounts(bridgeTokensToHubPoolAccounts) + .signers([messageSentEventData]) + .rpc(); + + const finalVaultBalance = (await connection.getTokenAccountBalance(vault)).value.amount; + assert.strictEqual(finalVaultBalance, (initialMintAmount - pendingToHubPool).toString()); + + const finalPendingToHubPool = (await program.account.transferLiability.fetch(transferLiability)).pendingToHubPool; + assert.isTrue(finalPendingToHubPool.isZero(), "Invalid pending to HubPool amount"); + + const message = decodeMessageSentData( + (await messageTransmitterProgram.account.messageSent.fetch(messageSentEventData.publicKey)).message + ); + assert.strictEqual(message.destinationDomain, remoteDomain, "Invalid destination domain"); + assert.isTrue(message.messageBody.burnToken.equals(mint), "Invalid burn token"); + assert.isTrue(message.messageBody.mintRecipient.equals(crossDomainAdmin), "Invalid mint recipient"); + assert.strictEqual(message.messageBody.amount.toString(), pendingToHubPool.toString(), "Invalid amount"); + }); + + it("Bridge above pending tokens in single transaction to HubPool should fail", async () => { + const pendingToHubPool = 1_000_000; + const bridgeAmount = pendingToHubPool + 1; + + await initializeBridgeToHubPool(pendingToHubPool); + + try { + await program.methods + .bridgeTokensToHubPool(new anchor.BN(bridgeAmount)) + .accounts(bridgeTokensToHubPoolAccounts) + .signers([messageSentEventData]) + .rpc(); + assert.fail("Should not be able to bridge above pending tokens to HubPool"); + } catch (error) { + assert.instanceOf(error, anchor.AnchorError); + assert.strictEqual( + error.error.errorCode.code, + "ExceededPendingBridgeAmount", + "Expected error code ExceededPendingBridgeAmount" + ); + } + }); + + it("Bridge pending tokens to HubPool in multiple transactions", async () => { + const pendingToHubPool = 10_000_000; + const singleBridgeAmount = pendingToHubPool / 5; + + await initializeBridgeToHubPool(pendingToHubPool); + + const initialVaultBalance = (await connection.getTokenAccountBalance(vault)).value.amount; + assert.strictEqual(initialVaultBalance, initialMintAmount.toString()); + + for (let i = 0; i < 5; i++) { + const loopMessageSentEventData = anchor.web3.Keypair.generate(); + + await program.methods + .bridgeTokensToHubPool(new anchor.BN(singleBridgeAmount)) + .accounts({ ...bridgeTokensToHubPoolAccounts, messageSentEventData: loopMessageSentEventData.publicKey }) + .signers([loopMessageSentEventData]) + .rpc(); + } + + const finalVaultBalance = (await connection.getTokenAccountBalance(vault)).value.amount; + assert.strictEqual(finalVaultBalance, (initialMintAmount - pendingToHubPool).toString()); + + const finalPendingToHubPool = (await program.account.transferLiability.fetch(transferLiability)).pendingToHubPool; + assert.isTrue(finalPendingToHubPool.isZero(), "Invalid pending to HubPool amount"); + }); + + it("Bridge above pending tokens in multiple transactions to HubPool should fail", async () => { + const pendingToHubPool = 10_000_000; + const singleBridgeAmount = pendingToHubPool / 5; + + await initializeBridgeToHubPool(pendingToHubPool); + + const initialVaultBalance = (await connection.getTokenAccountBalance(vault)).value.amount; + assert.strictEqual(initialVaultBalance, initialMintAmount.toString()); + + // Bridge out first 4 tranches. + for (let i = 0; i < 4; i++) { + const loopMessageSentEventData = anchor.web3.Keypair.generate(); + + await program.methods + .bridgeTokensToHubPool(new anchor.BN(singleBridgeAmount)) + .accounts({ ...bridgeTokensToHubPoolAccounts, messageSentEventData: loopMessageSentEventData.publicKey }) + .signers([loopMessageSentEventData]) + .rpc(); + } + + // Try to bridge out more tokens in the final tranche. + try { + await program.methods + .bridgeTokensToHubPool(new anchor.BN(singleBridgeAmount + 1)) + .accounts(bridgeTokensToHubPoolAccounts) + .signers([messageSentEventData]) + .rpc(); + assert.fail("Should not be able to bridge above pending tokens to HubPool"); + } catch (error) { + assert.instanceOf(error, anchor.AnchorError); + assert.strictEqual( + error.error.errorCode.code, + "ExceededPendingBridgeAmount", + "Expected error code ExceededPendingBridgeAmount" + ); + } + }); +}); diff --git a/test/svm/SvmSpoke.common.ts b/test/svm/SvmSpoke.common.ts new file mode 100644 index 000000000..41c3c9616 --- /dev/null +++ b/test/svm/SvmSpoke.common.ts @@ -0,0 +1,102 @@ +import * as anchor from "@coral-xyz/anchor"; +import { BN, Program } from "@coral-xyz/anchor"; +import { PublicKey, Keypair } from "@solana/web3.js"; +import { ethers } from "ethers"; +import { ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID, getAssociatedTokenAddressSync } from "@solana/spl-token"; +import { SvmSpoke } from "../../target/types/svm_spoke"; +import { evmAddressToPublicKey } from "../../src/SvmUtils"; +import { assert } from "chai"; + +const provider = anchor.AnchorProvider.env(); +const program = anchor.workspace.SvmSpoke as Program; +const owner = provider.wallet.publicKey; +const chainId = new BN(420); +const remoteDomain = 0; +const crossDomainAdmin = evmAddressToPublicKey(ethers.Wallet.createRandom().address); + +const seedBalance = 20000000; +const destinationChainId = new BN(1); +const recipient = Keypair.generate().publicKey; +const exclusiveRelayer = Keypair.generate().publicKey; +const outputToken = new PublicKey("1111111111113EsMD5n1VA94D2fALdb1SAKLam8j"); // TODO: this is lazy. this is cast USDC from Eth mainnet. +const inputAmount = new BN(500000); +const outputAmount = inputAmount; +const quoteTimestamp = new BN(Math.floor(Date.now() / 1000)); +const fillDeadline = new BN(Math.floor(Date.now() / 1000) + 600); +const exclusivityDeadline = new BN(Math.floor(Date.now() / 1000) + 300); +const message = Buffer.from("Test message"); + +const initializeState = async (seed?: BN) => { + const actualSeed = seed || new BN(Math.floor(Math.random() * 1000000)); + const seeds = [Buffer.from("state"), actualSeed.toArrayLike(Buffer, "le", 8)]; + const [state] = PublicKey.findProgramAddressSync(seeds, program.programId); + await program.methods + .initialize(actualSeed, new BN(0), chainId, remoteDomain, crossDomainAdmin, true) + .accounts({ + state: state as any, + signer: owner, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .rpc(); + return state; +}; + +const createRoutePda = (originToken: PublicKey, routeChainId: BN) => { + return PublicKey.findProgramAddressSync( + [Buffer.from("route"), originToken.toBytes(), routeChainId.toArrayLike(Buffer, "le", 8)], + program.programId + )[0]; +}; + +const getVaultAta = (tokenMint: PublicKey, state: PublicKey) => { + return getAssociatedTokenAddressSync(tokenMint, state, true, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID); +}; + +async function setCurrentTime(program: Program, state: any, signer: anchor.web3.Keypair, newTime: BN) { + await program.methods.setCurrentTime(newTime).accounts({ state, signer: signer.publicKey }).signers([signer]).rpc(); +} + +function assertSE(a: any, b: any, errorMessage: string) { + assert.strictEqual(a.toString(), b.toString(), errorMessage); +} + +export const common = { + provider, + connection: provider.connection, + program, + owner, + chainId, + remoteDomain, + crossDomainAdmin, + seedBalance, + destinationChainId, + recipient, + exclusiveRelayer, + outputToken, + inputAmount, + outputAmount, + quoteTimestamp, + fillDeadline, + exclusivityDeadline, + message, + initializeState, + createRoutePda, + getVaultAta, + setCurrentTime, + assert, + assertSE, + depositData: { + depositor: null, // Placeholder, to be assigned in the test file + recipient, + inputToken: null, // Placeholder, to be assigned in the test file + outputToken, + inputAmount, + outputAmount, + destinationChainId, + exclusiveRelayer, + quoteTimestamp, + fillDeadline, + exclusivityDeadline, + message, + }, +}; diff --git a/test/svm/Utils.Bitmap.ts b/test/svm/Utils.Bitmap.ts new file mode 100644 index 000000000..4fa4f5624 --- /dev/null +++ b/test/svm/Utils.Bitmap.ts @@ -0,0 +1,79 @@ +import * as anchor from "@coral-xyz/anchor"; +import { Program } from "@coral-xyz/anchor"; +import { Test } from "../../target/types/test"; +import { assert } from "chai"; +import { SystemProgram } from "@solana/web3.js"; + +describe("utils.bitmap", () => { + anchor.setProvider(anchor.AnchorProvider.env()); + + const program = anchor.workspace.Test as Program; + const provider = anchor.AnchorProvider.env(); + + let bitmapAccount; + const signer = provider.wallet.payer; // Use the provider's signer + + before(async () => { + const seeds = [Buffer.from("bitmap_account")]; + bitmapAccount = anchor.web3.PublicKey.findProgramAddressSync(seeds, program.programId)[0]; + + // Initialize the Bitmap account + await program.methods + .initialize() + .accounts({ + bitmapAccount, + signer: signer.publicKey, + systemProgram: SystemProgram.programId, + }) + .signers([signer]) + .rpc(); + }); + it("Set and read multiple claims", async () => { + const indices = [0, 1, 42, 69, 1449, 1501]; + + for (const index of indices) { + let isClaimed = await program.methods + .testIsClaimed(index) + .accounts({ + bitmapAccount, + }) + .view(); + assert.strictEqual(isClaimed, false, `Index ${index} should not be claimed initially`); + } + + for (const index of indices) { + await program.methods + .testSetClaimed(index) + .accounts({ + bitmapAccount, + signer: signer.publicKey, + systemProgram: SystemProgram.programId, + }) + .signers([signer]) + .rpc(); + } + + for (const index of indices) { + let isClaimed = await program.methods + .testIsClaimed(index) + .accounts({ + bitmapAccount, + }) + .view(); + assert.strictEqual(isClaimed, true, `Index ${index} should be claimed after setting`); + } + + // Checking all other indices to ensure they are not claimed. + for (let i = 0; i <= Math.max(...indices); i++) { + if (!indices.includes(i)) { + let isClaimed = await program.methods + .testIsClaimed(i) + .accounts({ + bitmapAccount, + }) + .view(); + assert.strictEqual(isClaimed, false, `Index ${i} should not be claimed`); + } + } + }); +}); diff --git a/test/svm/Utils.Merkle.ts b/test/svm/Utils.Merkle.ts new file mode 100644 index 000000000..e5934213f --- /dev/null +++ b/test/svm/Utils.Merkle.ts @@ -0,0 +1,153 @@ +import * as anchor from "@coral-xyz/anchor"; +import { Program } from "@coral-xyz/anchor"; +import { Test } from "../../target/types/test"; +import { expect } from "chai"; +import { MerkleTree } from "@uma/common/dist/MerkleTree"; +import { ethers } from "ethers"; +import { BigNumberish } from "ethers"; +function randomAddress(): string { + const wallet = ethers.Wallet.createRandom(); + return wallet.address; +} +export interface RelayerRefundLeaf { + amountToReturn: BigNumberish; + chainId: BigNumberish; + refundAmounts: BigNumberish[]; + leafId: BigNumberish; + l2TokenAddress: string; + refundAddresses: string[]; +} + +export function randomBigInt(bytes = 32, signed = false) { + const sign = signed && Math.random() < 0.5 ? "-" : ""; + const byteString = "0x" + Buffer.from(ethers.utils.randomBytes(signed ? bytes - 1 : bytes)).toString("hex"); + return BigInt(sign + byteString); +} + +describe("utils.merkle", () => { + const provider = anchor.AnchorProvider.env(); + anchor.setProvider(provider); + + const program = anchor.workspace.Test as Program; + it("Test merkle proof verification Across", async () => { + const relayerRefundLeaves: RelayerRefundLeaf[] = []; + const numDistributions = 101; // Create 101 and remove the last to use as the "invalid" one. + for (let i = 0; i < numDistributions; i++) { + const numAddresses = 10; + const refundAddresses: string[] = []; + const refundAmounts: bigint[] = []; + for (let j = 0; j < numAddresses; j++) { + refundAddresses.push(randomAddress()); + refundAmounts.push(randomBigInt()); + } + relayerRefundLeaves.push({ + leafId: BigInt(i), + chainId: randomBigInt(2), + amountToReturn: randomBigInt(), + l2TokenAddress: randomAddress(), + refundAddresses, + refundAmounts, + }); + } + + // Remove the last element. + const invalidRelayerRefundLeaf = relayerRefundLeaves.pop()!; + + const abiCoder = new ethers.utils.AbiCoder(); + const hashFn = (input: RelayerRefundLeaf) => { + const encodedData = abiCoder.encode( + [ + "tuple(uint256 leafId, uint256 chainId, uint256 amountToReturn, address l2TokenAddress, address[] refundAddresses, uint256[] refundAmounts)", + ], + [ + { + leafId: input.leafId, + chainId: input.chainId, + amountToReturn: input.amountToReturn, + l2TokenAddress: input.l2TokenAddress, + refundAddresses: input.refundAddresses, + refundAmounts: input.refundAmounts, + }, + ] + ); + return ethers.utils.keccak256(encodedData); + }; + const merkleTree = new MerkleTree(relayerRefundLeaves, hashFn); + + const root = merkleTree.getRoot(); + const proof = merkleTree.getProof(relayerRefundLeaves[14]); + const leaf = ethers.utils.arrayify(hashFn(relayerRefundLeaves[14])); + + // Verify valid leaf + const tx = await program.methods + .verify( + Array.from(root), + Array.from(leaf), + proof.map((p) => Array.from(p)) + ) + .rpc(); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + const txLogs = await provider.connection.getTransaction(tx, { + commitment: "confirmed", + maxSupportedTransactionVersion: 0, + }); + + expect(txLogs?.meta?.logMessages?.some((log) => log.includes("Merkle proof verified successfully"))).to.be.true; + + // Verify that the excluded element fails to generate a proof and fails verification using the proof generated above. + const invalidLeaf = ethers.utils.arrayify(hashFn(invalidRelayerRefundLeaf)); + + let error; + try { + await program.methods + .verify( + Array.from(root), + Array.from(invalidLeaf), + proof.map((p) => Array.from(p)) + ) + .rpc(); + } catch (e) { + error = e; + } + + expect(error).to.exist; + expect(error.message.includes("Invalid Merkle proof")).to.be.true; + }); + + it("Test merkle proof verification Across tx", async () => { + // In this test we reproduce the merkle proof verification that was done in the Across tx + // https://optimistic.etherscan.io/tx/0xfecbc7584741615986fcdc54671f9d80ff802893311743c8c8cbe684681e0cf5 + // We are simulating the first executeRelayerRefundLeaf call in the tx + + const root = "0xe3dbb54612a537bd3773c7672094cf542fac507ad790032737271072643df564"; + const rootBuffer = Buffer.from(root.slice(2), "hex"); + const proof = [ + "0xb2b9a11188bce65a7420b941a150ca87cbbda966282a1cce3f4d27d882335db3", + "0x784bf6ce3abf9467400d275f33d5f17a1bfeda5c723a89d7f30450a06fbba48d", + "0x4246d917ad480dba79e5e562387d33815e51e17154c05c57beb2039a84a2887b", + "0xedb009789faae74ad05035d2457f2938c3d2671927f556eff811129a8fa5bfd0", + "0x15b97cc61cf0599b929bcee98d61049f4dd182741aa7eec24d028f4f2afe52b0", + ]; + const leaf = "0xd2a692babeae0c3399013cdeeab3c80af382a9203b723fe1fdfb7b35dd30aa5e"; + const leafBuffer = Buffer.from(leaf.slice(2), "hex"); + const proofBuffers = proof.map((p) => Buffer.from(p.slice(2), "hex")); + + // Verify valid leaf + const tx = await program.methods + .verify( + Array.from(rootBuffer), + Array.from(leafBuffer), + proofBuffers.map((b) => Array.from(b)) + ) + .rpc(); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + const txLogs = await provider.connection.getTransaction(tx, { + commitment: "confirmed", + maxSupportedTransactionVersion: 0, + }); + + expect(txLogs?.meta?.logMessages?.some((log) => log.includes("Merkle proof verified successfully"))).to.be.true; + }); +}); diff --git a/test/svm/accounts/message_transmitter.json b/test/svm/accounts/message_transmitter.json new file mode 100644 index 000000000..9264b49e7 --- /dev/null +++ b/test/svm/accounts/message_transmitter.json @@ -0,0 +1,14 @@ +{ + "pubkey": "BWrwSWjbikT3H7qHAkUEbLmwDQoB4ZDJ4wcSEhSPTZCu", + "account": { + "lamports": 2519520, + "data": [ + "Ryi0jhPLI/yAxc+QtIgKaKCt4UH53aPJt3kR23C3Hek9+WiJv/bN7AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgMXPkLSICmigreFB+d2jybd5Edtwtx3pPfloib/2zeyAxc+QtIgKaKCt4UH53aPJt3kR23C3Hek9+WiJv/bN7AAFAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAADOOeOZ4gOMQ1zAl4M9XFj16afpgAAAAAAAAAAAAAAADAsRuIUBB95uks9j47LMsXm3LyHAAgAAAAAAAAQRMAAAAAAAD/", + "base64" + ], + "owner": "CCTPmbSD7gX1bxKPAmg77w8oFzNFpaQiQUWD43TKaecd", + "executable": false, + "rentEpoch": 18446744073709551615, + "space": 234 + } +} diff --git a/test/svm/accounts/token_minter.json b/test/svm/accounts/token_minter.json new file mode 100644 index 000000000..9248be3e5 --- /dev/null +++ b/test/svm/accounts/token_minter.json @@ -0,0 +1,14 @@ +{ + "pubkey": "DBD8hAwLDRQkTsu6EqviaYNGKPnsAMmQonxf7AH8ZcFY", + "account": { + "lamports": 1405920, + "data": [ + "eoVUPzmfq85bM5EKcdWmeUGe6ftthEAn2qThLAEk1RIO7OHNYPtn0IDFz5C0iApooK3hQfndo8m3eRHbcLcd6T35aIm/9s3sAP0=", + "base64" + ], + "owner": "CCTPiPYPc6AsJuwueEnWgSgucamXDZwBd53dQ11YiKX3", + "executable": false, + "rentEpoch": 18446744073709551615, + "space": 74 + } +} diff --git a/test/svm/cctpHelpers.ts b/test/svm/cctpHelpers.ts new file mode 100644 index 000000000..e4b8b12d5 --- /dev/null +++ b/test/svm/cctpHelpers.ts @@ -0,0 +1,122 @@ +import * as anchor from "@coral-xyz/anchor"; +import { readUInt256BE } from "../../src/SvmUtils"; + +// Index positions to decode Message Header from +// https://developers.circle.com/stablecoins/docs/message-format#message-header +const HEADER_VERSION_INDEX = 0; +const SOURCE_DOMAIN_INDEX = 4; +const DESTINATION_DOMAIN_INDEX = 8; +const NONCE_INDEX = 12; +const HEADER_SENDER_INDEX = 20; +const HEADER_RECIPIENT_INDEX = 52; +const DESTINATION_CALLER_INDEX = 84; +const MESSAGE_BODY_INDEX = 116; + +// Index positions to decode Message Body for TokenMessenger from +// https://developers.circle.com/stablecoins/docs/message-format#message-body +const BODY_VERSION_INDEX = 0; +const BURN_TOKEN_INDEX = 4; +const MINT_RECIPIENT_INDEX = 36; +const AMOUNT_INDEX = 68; +const MESSAGE_SENDER_INDEX = 100; + +export type TokenMessengerMessageBody = { + version: number; + burnToken: anchor.web3.PublicKey; + mintRecipient: anchor.web3.PublicKey; + amount: BigInt; + messageSender: anchor.web3.PublicKey; +}; + +export const decodeMessageSentData = (message: Buffer) => { + const messageHeader = decodeMessageHeader(message); + + const messageBodyData = message.slice(MESSAGE_BODY_INDEX); + + const messageBody = decodeTokenMessengerMessageBody(messageBodyData); + + return { ...messageHeader, messageBody }; +}; + +export type MessageHeader = { + version: number; + sourceDomain: number; + destinationDomain: number; + nonce: bigint; + sender: anchor.web3.PublicKey; + recipient: anchor.web3.PublicKey; + destinationCaller: anchor.web3.PublicKey; + messageBody: Buffer; +}; + +export const decodeMessageHeader = (data: Buffer): MessageHeader => { + const version = data.readUInt32BE(HEADER_VERSION_INDEX); + const sourceDomain = data.readUInt32BE(SOURCE_DOMAIN_INDEX); + const destinationDomain = data.readUInt32BE(DESTINATION_DOMAIN_INDEX); + const nonce = data.readBigUInt64BE(NONCE_INDEX); + const sender = new anchor.web3.PublicKey(data.slice(HEADER_SENDER_INDEX, HEADER_SENDER_INDEX + 32)); + const recipient = new anchor.web3.PublicKey(data.slice(HEADER_RECIPIENT_INDEX, HEADER_RECIPIENT_INDEX + 32)); + const destinationCaller = new anchor.web3.PublicKey( + data.slice(DESTINATION_CALLER_INDEX, DESTINATION_CALLER_INDEX + 32) + ); + const messageBody = data.slice(MESSAGE_BODY_INDEX); + return { + version, + sourceDomain, + destinationDomain, + nonce, + sender, + recipient, + destinationCaller, + messageBody, + }; +}; + +export const decodeTokenMessengerMessageBody = (data: Buffer): TokenMessengerMessageBody => { + const version = data.readUInt32BE(BODY_VERSION_INDEX); + const burnToken = new anchor.web3.PublicKey(data.slice(BURN_TOKEN_INDEX, BURN_TOKEN_INDEX + 32)); + const mintRecipient = new anchor.web3.PublicKey(data.slice(MINT_RECIPIENT_INDEX, MINT_RECIPIENT_INDEX + 32)); + const amount = readUInt256BE(data.slice(AMOUNT_INDEX, AMOUNT_INDEX + 32)); + const messageSender = new anchor.web3.PublicKey(data.slice(MESSAGE_SENDER_INDEX, MESSAGE_SENDER_INDEX + 32)); + return { version, burnToken, mintRecipient, amount, messageSender }; +}; + +export const encodeMessageHeader = (header: MessageHeader): Buffer => { + const message = Buffer.alloc(MESSAGE_BODY_INDEX + header.messageBody.length); + + message.writeUInt32BE(header.version, HEADER_VERSION_INDEX); + message.writeUInt32BE(header.sourceDomain, SOURCE_DOMAIN_INDEX); + message.writeUInt32BE(header.destinationDomain, DESTINATION_DOMAIN_INDEX); + message.writeBigUInt64BE(header.nonce, NONCE_INDEX); + header.sender.toBuffer().copy(message, HEADER_SENDER_INDEX); + header.recipient.toBuffer().copy(message, HEADER_RECIPIENT_INDEX); + header.destinationCaller.toBuffer().copy(message, DESTINATION_CALLER_INDEX); + header.messageBody.copy(message, MESSAGE_BODY_INDEX); + + return message; +}; + +// Fetches attestation from attestation service given the txHash. +// This is copied from CCTP example scripts, but would require proper type checking in production. +export const getMessages = async (txHash: string, srcDomain: number, irisApiUrl: string) => { + console.log("Fetching attestations and messages for tx...", txHash); + let attestationResponse: any = {}; + while ( + attestationResponse.error || + !attestationResponse.messages || + attestationResponse.messages?.[0]?.attestation === "PENDING" + ) { + const response = await fetch(`${irisApiUrl}/messages/${srcDomain}/${txHash}`); + attestationResponse = await response.json(); + // Wait 2 seconds to avoid getting rate limited + if ( + attestationResponse.error || + !attestationResponse.messages || + attestationResponse.messages?.[0]?.attestation === "PENDING" + ) { + await new Promise((r) => setTimeout(r, 2000)); + } + } + + return attestationResponse; +}; diff --git a/test/svm/keys/localnet-wallet.json b/test/svm/keys/localnet-wallet.json new file mode 100644 index 000000000..85fb36792 --- /dev/null +++ b/test/svm/keys/localnet-wallet.json @@ -0,0 +1,5 @@ +[ + 198, 216, 229, 210, 21, 25, 123, 94, 191, 224, 91, 220, 80, 7, 51, 28, 203, 112, 125, 69, 25, 174, 159, 109, 251, 205, + 166, 103, 64, 183, 234, 111, 91, 51, 145, 10, 113, 213, 166, 121, 65, 158, 233, 251, 109, 132, 64, 39, 218, 164, 225, + 44, 1, 36, 213, 18, 14, 236, 225, 205, 96, 251, 103, 208 +] diff --git a/test/svm/testacross.ts b/test/svm/testacross.ts deleted file mode 100644 index a659608b8..000000000 --- a/test/svm/testacross.ts +++ /dev/null @@ -1,16 +0,0 @@ -import * as anchor from "@coral-xyz/anchor"; -import { Program } from "@coral-xyz/anchor"; -import { Testacross } from "../../target/types/testacross"; - -describe("testacross", () => { - // Configure the client to use the local cluster. - anchor.setProvider(anchor.AnchorProvider.env()); - - const program = anchor.workspace.Testacross as Program; - - it("Is initialized!", async () => { - // Add your test here. - const tx = await program.methods.initialize().rpc(); - console.log("Your transaction signature", tx); - }); -}); diff --git a/test/svm/utils.ts b/test/svm/utils.ts new file mode 100644 index 000000000..def91f238 --- /dev/null +++ b/test/svm/utils.ts @@ -0,0 +1,149 @@ +import { BN } from "@coral-xyz/anchor"; +import { PublicKey } from "@solana/web3.js"; +import { ethers } from "ethers"; +import * as crypto from "crypto"; + +export async function printLogs(connection: any, program: any, tx: any) { + const latestBlockHash = await connection.getLatestBlockhash(); + await connection.confirmTransaction( + { + blockhash: latestBlockHash.blockhash, + lastValidBlockHeight: latestBlockHash.lastValidBlockHeight, + signature: tx, + }, + "confirmed" + ); + + const txDetails = await program.provider.connection.getTransaction(tx, { + maxSupportedTransactionVersion: 0, + commitment: "confirmed", + }); + + const logs = txDetails?.meta?.logMessages || null; + + if (!logs) { + console.log("No logs found"); + } +} + +export function randomAddress(): string { + const wallet = ethers.Wallet.createRandom(); + return wallet.address; +} + +export function randomBigInt(bytes = 8, signed = false) { + const sign = signed && Math.random() < 0.5 ? "-" : ""; + const byteString = "0x" + Buffer.from(crypto.randomBytes(bytes)).toString("hex"); + return BigInt(sign + byteString); +} + +export interface RelayerRefundLeaf { + isSolana: boolean; + amountToReturn: bigint; + chainId: bigint; + refundAmounts: bigint[]; + leafId: bigint; + l2TokenAddress: string; + refundAddresses: string[]; +} + +export interface RelayerRefundLeafSolana { + isSolana: boolean; + amountToReturn: BN; + chainId: BN; + refundAmounts: BN[]; + leafId: BN; + mintPublicKey: PublicKey; + refundAccounts: PublicKey[]; +} + +export type RelayerRefundLeafType = RelayerRefundLeaf | RelayerRefundLeafSolana; + +export function calculateRelayerRefundLeafHashUint8Array(relayData: RelayerRefundLeafSolana): string { + const refundAmountsBuffer = Buffer.concat( + relayData.refundAmounts.map((amount) => { + const buf = Buffer.alloc(8); + amount.toArrayLike(Buffer, "le", 8).copy(buf); + return buf; + }) + ); + + const refundAccountsBuffer = Buffer.concat(relayData.refundAccounts.map((account) => account.toBuffer())); + + const contentToHash = Buffer.concat([ + relayData.amountToReturn.toArrayLike(Buffer, "le", 8), + relayData.chainId.toArrayLike(Buffer, "le", 8), + relayData.leafId.toArrayLike(Buffer, "le", 4), + relayData.mintPublicKey.toBuffer(), + refundAmountsBuffer, + refundAccountsBuffer, + ]); + + const relayHash = ethers.utils.keccak256(contentToHash); + return relayHash; +} + +export const relayerRefundHashFn = (input: RelayerRefundLeaf | RelayerRefundLeafSolana) => { + if (!input.isSolana) { + const abiCoder = new ethers.utils.AbiCoder(); + const encodedData = abiCoder.encode( + [ + "tuple(uint256 leafId, uint256 chainId, uint256 amountToReturn, address l2TokenAddress, address[] refundAddresses, uint256[] refundAmounts)", + ], + [ + { + leafId: input.leafId, + chainId: input.chainId, + amountToReturn: input.amountToReturn, + l2TokenAddress: (input as RelayerRefundLeaf).l2TokenAddress, // Type assertion + refundAddresses: (input as RelayerRefundLeaf).refundAddresses, // Type assertion + refundAmounts: (input as RelayerRefundLeaf).refundAmounts, // Type assertion + }, + ] + ); + return ethers.utils.keccak256(encodedData); + } else { + return calculateRelayerRefundLeafHashUint8Array(input as RelayerRefundLeafSolana); + } +}; + +export interface SlowFillLeaf { + relayData: { + depositor: PublicKey; + recipient: PublicKey; + exclusiveRelayer: PublicKey; + inputToken: PublicKey; + outputToken: PublicKey; + inputAmount: BN; + outputAmount: BN; + originChainId: BN; + depositId: BN; + fillDeadline: BN; + exclusivityDeadline: BN; + message: Buffer; + }; + chainId: BN; + updatedOutputAmount: BN; +} + +export function slowFillHashFn(slowFillLeaf: SlowFillLeaf): string { + const contentToHash = Buffer.concat([ + slowFillLeaf.relayData.depositor.toBuffer(), + slowFillLeaf.relayData.recipient.toBuffer(), + slowFillLeaf.relayData.exclusiveRelayer.toBuffer(), + slowFillLeaf.relayData.inputToken.toBuffer(), + slowFillLeaf.relayData.outputToken.toBuffer(), + slowFillLeaf.relayData.inputAmount.toArrayLike(Buffer, "le", 8), + slowFillLeaf.relayData.outputAmount.toArrayLike(Buffer, "le", 8), + slowFillLeaf.relayData.originChainId.toArrayLike(Buffer, "le", 8), + slowFillLeaf.relayData.depositId.toArrayLike(Buffer, "le", 4), + slowFillLeaf.relayData.fillDeadline.toArrayLike(Buffer, "le", 4), + slowFillLeaf.relayData.exclusivityDeadline.toArrayLike(Buffer, "le", 4), + slowFillLeaf.relayData.message, + slowFillLeaf.chainId.toArrayLike(Buffer, "le", 8), + slowFillLeaf.updatedOutputAmount.toArrayLike(Buffer, "le", 8), + ]); + + const slowFillHash = ethers.utils.keccak256(contentToHash); + return slowFillHash; +} diff --git a/tsconfig.json b/tsconfig.json index 7e8995796..33ff5e590 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,5 +18,9 @@ "tasks/enableL1TokenAcrossEcosystem.ts", "utils/utils.ts" ], + "exclude": [ + "./test/svm", // Added exclusion for /test/svm &/scripts/svm files until we fix build issues on types. + "./scripts/svm" + ], "files": ["./hardhat.config.ts"] } diff --git a/yarn.lock b/yarn.lock index 54a6a28f7..572ff0a04 100644 --- a/yarn.lock +++ b/yarn.lock @@ -321,7 +321,7 @@ resolved "https://registry.yarnpkg.com/@coral-xyz/anchor-errors/-/anchor-errors-0.30.1.tgz#bdfd3a353131345244546876eb4afc0e125bec30" integrity sha512-9Mkradf5yS5xiLWrl9WrpjqOrAV+/W2RQHDlbnAZBivoGpOs1ECjoDCkVk4aRG8ZdiFiB8zQEVlxf+8fKkmSfQ== -"@coral-xyz/anchor@^0.30.1": +"@coral-xyz/anchor@^0.30.0": version "0.30.1" resolved "https://registry.yarnpkg.com/@coral-xyz/anchor/-/anchor-0.30.1.tgz#17f3e9134c28cd0ea83574c6bab4e410bcecec5d" integrity sha512-gDXFoF5oHgpriXAaLpxyWBHdCs8Awgf/gLHIo6crv7Aqm937CNdY+x+6hoj7QR5vaJV7MxWSQ0NGFzL3kPbWEQ== @@ -2404,14 +2404,195 @@ resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz#5981a8db18b56ba38ef0efb7d995b12aa7b51918" integrity sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ== -"@solana/buffer-layout@^4.0.1": +"@solana-developers/helpers@^2.4.0": + version "2.5.5" + resolved "https://registry.yarnpkg.com/@solana-developers/helpers/-/helpers-2.5.5.tgz#ecbdf0201faccc7116cc0b6c9ec96f5c8a1ef1ce" + integrity sha512-V+O/VSOPUMiuaoaxO+8h6cfmsgVUQZFJCiyMt/0GPH+Wq4QVsiRMXat/KTKnWZO1T7exENS9vmQg1LN562Z2ZQ== + dependencies: + "@solana/spl-token" "^0.4.8" + "@solana/spl-token-metadata" "^0.1.4" + "@solana/web3.js" "^1.95.2" + bs58 "^6.0.0" + dotenv "^16.4.5" + +"@solana/buffer-layout-utils@^0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@solana/buffer-layout-utils/-/buffer-layout-utils-0.2.0.tgz#b45a6cab3293a2eb7597cceb474f229889d875ca" + integrity sha512-szG4sxgJGktbuZYDg2FfNmkMi0DYQoVjN2h7ta1W1hPrwzarcFLBq9UpX1UjNXsNpT9dn+chgprtWGioUAr4/g== + dependencies: + "@solana/buffer-layout" "^4.0.0" + "@solana/web3.js" "^1.32.0" + bigint-buffer "^1.1.5" + bignumber.js "^9.0.1" + +"@solana/buffer-layout@^4.0.0", "@solana/buffer-layout@^4.0.1": version "4.0.1" resolved "https://registry.yarnpkg.com/@solana/buffer-layout/-/buffer-layout-4.0.1.tgz#b996235eaec15b1e0b5092a8ed6028df77fa6c15" integrity sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA== dependencies: buffer "~6.0.3" -"@solana/web3.js@^1.68.0": +"@solana/codecs-core@2.0.0-preview.4": + version "2.0.0-preview.4" + resolved "https://registry.yarnpkg.com/@solana/codecs-core/-/codecs-core-2.0.0-preview.4.tgz#770826105f2f884110a21662573e7a2014654324" + integrity sha512-A0VVuDDA5kNKZUinOqHxJQK32aKTucaVbvn31YenGzHX1gPqq+SOnFwgaEY6pq4XEopSmaK16w938ZQS8IvCnw== + dependencies: + "@solana/errors" "2.0.0-preview.4" + +"@solana/codecs-core@2.0.0-rc.1": + version "2.0.0-rc.1" + resolved "https://registry.yarnpkg.com/@solana/codecs-core/-/codecs-core-2.0.0-rc.1.tgz#1a2d76b9c7b9e7b7aeb3bd78be81c2ba21e3ce22" + integrity sha512-bauxqMfSs8EHD0JKESaNmNuNvkvHSuN3bbWAF5RjOfDu2PugxHrvRebmYauvSumZ3cTfQ4HJJX6PG5rN852qyQ== + dependencies: + "@solana/errors" "2.0.0-rc.1" + +"@solana/codecs-data-structures@2.0.0-preview.4": + version "2.0.0-preview.4" + resolved "https://registry.yarnpkg.com/@solana/codecs-data-structures/-/codecs-data-structures-2.0.0-preview.4.tgz#f8a2470982a9792334737ea64000ccbdff287247" + integrity sha512-nt2k2eTeyzlI/ccutPcG36M/J8NAYfxBPI9h/nQjgJ+M+IgOKi31JV8StDDlG/1XvY0zyqugV3I0r3KAbZRJpA== + dependencies: + "@solana/codecs-core" "2.0.0-preview.4" + "@solana/codecs-numbers" "2.0.0-preview.4" + "@solana/errors" "2.0.0-preview.4" + +"@solana/codecs-data-structures@2.0.0-rc.1": + version "2.0.0-rc.1" + resolved "https://registry.yarnpkg.com/@solana/codecs-data-structures/-/codecs-data-structures-2.0.0-rc.1.tgz#d47b2363d99fb3d643f5677c97d64a812982b888" + integrity sha512-rinCv0RrAVJ9rE/rmaibWJQxMwC5lSaORSZuwjopSUE6T0nb/MVg6Z1siNCXhh/HFTOg0l8bNvZHgBcN/yvXog== + dependencies: + "@solana/codecs-core" "2.0.0-rc.1" + "@solana/codecs-numbers" "2.0.0-rc.1" + "@solana/errors" "2.0.0-rc.1" + +"@solana/codecs-numbers@2.0.0-preview.4": + version "2.0.0-preview.4" + resolved "https://registry.yarnpkg.com/@solana/codecs-numbers/-/codecs-numbers-2.0.0-preview.4.tgz#6a53b456bb7866f252d8c032c81a92651e150f66" + integrity sha512-Q061rLtMadsO7uxpguT+Z7G4UHnjQ6moVIxAQxR58nLxDPCC7MB1Pk106/Z7NDhDLHTcd18uO6DZ7ajHZEn2XQ== + dependencies: + "@solana/codecs-core" "2.0.0-preview.4" + "@solana/errors" "2.0.0-preview.4" + +"@solana/codecs-numbers@2.0.0-rc.1": + version "2.0.0-rc.1" + resolved "https://registry.yarnpkg.com/@solana/codecs-numbers/-/codecs-numbers-2.0.0-rc.1.tgz#f34978ddf7ea4016af3aaed5f7577c1d9869a614" + integrity sha512-J5i5mOkvukXn8E3Z7sGIPxsThRCgSdgTWJDQeZvucQ9PT6Y3HiVXJ0pcWiOWAoQ3RX8e/f4I3IC+wE6pZiJzDQ== + dependencies: + "@solana/codecs-core" "2.0.0-rc.1" + "@solana/errors" "2.0.0-rc.1" + +"@solana/codecs-strings@2.0.0-preview.4": + version "2.0.0-preview.4" + resolved "https://registry.yarnpkg.com/@solana/codecs-strings/-/codecs-strings-2.0.0-preview.4.tgz#4d06bb722a55a5d04598d362021bfab4bd446760" + integrity sha512-YDbsQePRWm+xnrfS64losSGRg8Wb76cjK1K6qfR8LPmdwIC3787x9uW5/E4icl/k+9nwgbIRXZ65lpF+ucZUnw== + dependencies: + "@solana/codecs-core" "2.0.0-preview.4" + "@solana/codecs-numbers" "2.0.0-preview.4" + "@solana/errors" "2.0.0-preview.4" + +"@solana/codecs-strings@2.0.0-rc.1": + version "2.0.0-rc.1" + resolved "https://registry.yarnpkg.com/@solana/codecs-strings/-/codecs-strings-2.0.0-rc.1.tgz#e1d9167075b8c5b0b60849f8add69c0f24307018" + integrity sha512-9/wPhw8TbGRTt6mHC4Zz1RqOnuPTqq1Nb4EyuvpZ39GW6O2t2Q7Q0XxiB3+BdoEjwA2XgPw6e2iRfvYgqty44g== + dependencies: + "@solana/codecs-core" "2.0.0-rc.1" + "@solana/codecs-numbers" "2.0.0-rc.1" + "@solana/errors" "2.0.0-rc.1" + +"@solana/codecs@2.0.0-preview.4": + version "2.0.0-preview.4" + resolved "https://registry.yarnpkg.com/@solana/codecs/-/codecs-2.0.0-preview.4.tgz#a1923cc78a6f64ebe656c7ec6335eb6b70405b22" + integrity sha512-gLMupqI4i+G4uPi2SGF/Tc1aXcviZF2ybC81x7Q/fARamNSgNOCUUoSCg9nWu1Gid6+UhA7LH80sWI8XjKaRog== + dependencies: + "@solana/codecs-core" "2.0.0-preview.4" + "@solana/codecs-data-structures" "2.0.0-preview.4" + "@solana/codecs-numbers" "2.0.0-preview.4" + "@solana/codecs-strings" "2.0.0-preview.4" + "@solana/options" "2.0.0-preview.4" + +"@solana/codecs@2.0.0-rc.1": + version "2.0.0-rc.1" + resolved "https://registry.yarnpkg.com/@solana/codecs/-/codecs-2.0.0-rc.1.tgz#146dc5db58bd3c28e04b4c805e6096c2d2a0a875" + integrity sha512-qxoR7VybNJixV51L0G1RD2boZTcxmwUWnKCaJJExQ5qNKwbpSyDdWfFJfM5JhGyKe9DnPVOZB+JHWXnpbZBqrQ== + dependencies: + "@solana/codecs-core" "2.0.0-rc.1" + "@solana/codecs-data-structures" "2.0.0-rc.1" + "@solana/codecs-numbers" "2.0.0-rc.1" + "@solana/codecs-strings" "2.0.0-rc.1" + "@solana/options" "2.0.0-rc.1" + +"@solana/errors@2.0.0-preview.4": + version "2.0.0-preview.4" + resolved "https://registry.yarnpkg.com/@solana/errors/-/errors-2.0.0-preview.4.tgz#056ba76b6dd900dafa70117311bec3aef0f5250b" + integrity sha512-kadtlbRv2LCWr8A9V22On15Us7Nn8BvqNaOB4hXsTB3O0fU40D1ru2l+cReqLcRPij4znqlRzW9Xi0m6J5DIhA== + dependencies: + chalk "^5.3.0" + commander "^12.1.0" + +"@solana/errors@2.0.0-rc.1": + version "2.0.0-rc.1" + resolved "https://registry.yarnpkg.com/@solana/errors/-/errors-2.0.0-rc.1.tgz#3882120886eab98a37a595b85f81558861b29d62" + integrity sha512-ejNvQ2oJ7+bcFAYWj225lyRkHnixuAeb7RQCixm+5mH4n1IA4Qya/9Bmfy5RAAHQzxK43clu3kZmL5eF9VGtYQ== + dependencies: + chalk "^5.3.0" + commander "^12.1.0" + +"@solana/options@2.0.0-preview.4": + version "2.0.0-preview.4" + resolved "https://registry.yarnpkg.com/@solana/options/-/options-2.0.0-preview.4.tgz#212d35d1da87c7efb13de4d3569ad9eb070f013d" + integrity sha512-tv2O/Frxql/wSe3jbzi5nVicIWIus/BftH+5ZR+r9r3FO0/htEllZS5Q9XdbmSboHu+St87584JXeDx3xm4jaA== + dependencies: + "@solana/codecs-core" "2.0.0-preview.4" + "@solana/codecs-data-structures" "2.0.0-preview.4" + "@solana/codecs-numbers" "2.0.0-preview.4" + "@solana/codecs-strings" "2.0.0-preview.4" + "@solana/errors" "2.0.0-preview.4" + +"@solana/options@2.0.0-rc.1": + version "2.0.0-rc.1" + resolved "https://registry.yarnpkg.com/@solana/options/-/options-2.0.0-rc.1.tgz#06924ba316dc85791fc46726a51403144a85fc4d" + integrity sha512-mLUcR9mZ3qfHlmMnREdIFPf9dpMc/Bl66tLSOOWxw4ml5xMT2ohFn7WGqoKcu/UHkT9CrC6+amEdqCNvUqI7AA== + dependencies: + "@solana/codecs-core" "2.0.0-rc.1" + "@solana/codecs-data-structures" "2.0.0-rc.1" + "@solana/codecs-numbers" "2.0.0-rc.1" + "@solana/codecs-strings" "2.0.0-rc.1" + "@solana/errors" "2.0.0-rc.1" + +"@solana/spl-token-group@^0.0.5": + version "0.0.5" + resolved "https://registry.yarnpkg.com/@solana/spl-token-group/-/spl-token-group-0.0.5.tgz#f955dcca782031c85e862b2b46878d1bb02db6c2" + integrity sha512-CLJnWEcdoUBpQJfx9WEbX3h6nTdNiUzswfFdkABUik7HVwSNA98u5AYvBVK2H93d9PGMOHAak2lHW9xr+zAJGQ== + dependencies: + "@solana/codecs" "2.0.0-preview.4" + "@solana/spl-type-length-value" "0.1.0" + +"@solana/spl-token-metadata@^0.1.3", "@solana/spl-token-metadata@^0.1.4": + version "0.1.5" + resolved "https://registry.yarnpkg.com/@solana/spl-token-metadata/-/spl-token-metadata-0.1.5.tgz#91616470d6862ec6b762e6cfcf882b8a8a24b1e8" + integrity sha512-DSBlo7vjuLe/xvNn75OKKndDBkFxlqjLdWlq6rf40StnrhRn7TDntHGLZpry1cf3uzQFShqeLROGNPAJwvkPnA== + dependencies: + "@solana/codecs" "2.0.0-rc.1" + "@solana/spl-type-length-value" "0.1.0" + +"@solana/spl-token@^0.4.6", "@solana/spl-token@^0.4.8": + version "0.4.8" + resolved "https://registry.yarnpkg.com/@solana/spl-token/-/spl-token-0.4.8.tgz#a84e4131af957fa9fbd2727e5fc45dfbf9083586" + integrity sha512-RO0JD9vPRi4LsAbMUdNbDJ5/cv2z11MGhtAvFeRzT4+hAGE/FUzRi0tkkWtuCfSIU3twC6CtmAihRp/+XXjWsA== + dependencies: + "@solana/buffer-layout" "^4.0.0" + "@solana/buffer-layout-utils" "^0.2.0" + "@solana/spl-token-group" "^0.0.5" + "@solana/spl-token-metadata" "^0.1.3" + buffer "^6.0.3" + +"@solana/spl-type-length-value@0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@solana/spl-type-length-value/-/spl-type-length-value-0.1.0.tgz#b5930cf6c6d8f50c7ff2a70463728a4637a2f26b" + integrity sha512-JBMGB0oR4lPttOZ5XiUGyvylwLQjt1CPJa6qQ5oM+MBCndfjz2TKKkw0eATlLLcYmq1jBVsNlJ2cD6ns2GR7lA== + dependencies: + buffer "^6.0.3" + +"@solana/web3.js@^1.31.0", "@solana/web3.js@^1.32.0", "@solana/web3.js@^1.68.0", "@solana/web3.js@^1.95.2": version "1.95.3" resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-1.95.3.tgz#70b5f4d76823f56b5af6403da51125fffeb65ff3" integrity sha512-O6rPUN0w2fkNqx/Z3QJMB9L225Ex10PRDH8bTaIUPZXMPV0QP8ZpPvjQnXK+upUczlRgzHzd6SjKIha1p+I6og== @@ -4387,6 +4568,11 @@ base-x@^3.0.2, base-x@^3.0.8: dependencies: safe-buffer "^5.0.1" +base-x@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/base-x/-/base-x-5.0.0.tgz#6d835ceae379130e1a4cb846a70ac4746f28ea9b" + integrity sha512-sMW3VGSX1QWVFA6l8U62MLKz29rRfpTlYdCqLdpLo1/Yd4zZwSbnUaDfciIAowAqvq7YFnWq9hrhdg1KYgc1lQ== + base64-js@^1.3.0, base64-js@^1.3.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" @@ -4762,6 +4948,13 @@ bs58@^4.0.0, bs58@^4.0.1: dependencies: base-x "^3.0.2" +bs58@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/bs58/-/bs58-6.0.0.tgz#a2cda0130558535dd281a2f8697df79caaf425d8" + integrity sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw== + dependencies: + base-x "^5.0.0" + bs58check@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/bs58check/-/bs58check-2.1.2.tgz#53b018291228d82a5aa08e7d796fdafda54aebfc" @@ -5135,6 +5328,11 @@ chalk@^3.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +chalk@^5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.3.0.tgz#67c20a7ebef70e7f3970a01f90fa210cb6860385" + integrity sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w== + change-case@3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/change-case/-/change-case-3.0.2.tgz#fd48746cce02f03f0a672577d1d3a8dc2eceb037" @@ -5546,6 +5744,11 @@ commander@3.0.2: resolved "https://registry.yarnpkg.com/commander/-/commander-3.0.2.tgz#6837c3fb677ad9933d1cfba42dd14d5117d6b39e" integrity sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow== +commander@^12.1.0: + version "12.1.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-12.1.0.tgz#01423b36f501259fdaac4d0e4d60c96c991585d3" + integrity sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA== + commander@^2.15.0, commander@^2.19.0, commander@^2.20.3: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" @@ -6347,6 +6550,11 @@ dotenv@^16.0.3, dotenv@^16.1.4: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e" integrity sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ== +dotenv@^16.4.5: + version "16.4.5" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f" + integrity sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg== + dotenv@^9.0.0: version "9.0.2" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-9.0.2.tgz#dacc20160935a37dea6364aa1bef819fb9b6ab05" @@ -15679,10 +15887,10 @@ typeorm@^0.3.16: uuid "^9.0.0" yargs "^17.6.2" -typescript@^4.5.2: - version "4.6.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.2.tgz#fe12d2727b708f4eef40f51598b3398baa9611d4" - integrity sha512-HM/hFigTBHZhLXshn9sN37H085+hQGeJHJ/X7LpBWLID/fbc2acUMfU+lGD98X81sKP+pFa9f0DZmCwB9GnbAg== +typescript@^5.6.2: + version "5.6.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.2.tgz#d1de67b6bef77c41823f822df8f0b3bcff60a5a0" + integrity sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw== typewise-core@^1.2, typewise-core@^1.2.0: version "1.2.0" @@ -17693,7 +17901,7 @@ yargs@16.2.0, yargs@^16.0.0, yargs@^16.2.0: y18n "^5.0.5" yargs-parser "^20.2.2" -yargs@^17.6.2: +yargs@^17.6.2, yargs@^17.7.2: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==