diff --git a/.cargo/audit.toml b/.cargo/audit.toml index 624a27c..d54213f 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -3,17 +3,9 @@ [advisories] -# RUSTSEC-2025-0009 / RUSTSEC-2025-0010: ring < 0.17.12 AES panic + unmaintained -# Transitive dependency via: ethers-providers 2.0.14 → jsonwebtoken 8.x → ring 0.16 -# ethers-providers v2 hard-pins jsonwebtoken 8, which in turn hard-pins ring 0.16. -# We cannot upgrade ethers without a full SDK migration. The panic is only -# triggered with Rust debug overflow checks enabled (not in --release builds). -# Tracked: https://github.com/MeridianAlgo/Interlink/issues/1 -# Mitigation: AES code path is only used for JWS parsing in provider WebSocket -# authentication, which is never exercised in our relayer architecture. -ignore = [ - "RUSTSEC-2025-0009", - "RUSTSEC-2025-0010", +# RUSTSEC-2025-0009 / -0010 (ring 0.16) no longer apply: ethers-rs was removed, +# so ring is no longer in the dependency tree. +ignore = [ # RUSTSEC-2025-0055: tracing-subscriber 0.2.25 ANSI escape injection in log output. # Transitive via: ark-relations 0.4.0 → ark-groth16 0.4.0 → relayer. # ark-relations pins tracing-subscriber 0.2.x; upgrading requires a full arkworks diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 36069bf..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(cargo check:*)", - "Bash(cargo test:*)", - "Bash(cargo build:*)", - "Bash(cargo run:*)", - "Bash(*)" - ] - }, - "outputStyle": "default" -} diff --git a/.env.example b/.env.example index d9c92b2..c561aa0 100644 --- a/.env.example +++ b/.env.example @@ -1,22 +1,44 @@ -# InterLink Relayer Configuration - -# EVM source chain ID (1=Ethereum, 42161=Arbitrum, 10=Optimism, 8453=Base) -CHAIN_ID= - -# WebSocket endpoint for EVM event subscription (ws:// or wss://) -EVM_WS_RPC_URL= - -# HTTP endpoint for EVM finality polling (http:// or https://) -EVM_HTTP_RPC_URL= - -# Solana RPC endpoint -SOLANA_RPC_URL=https://api.devnet.solana.com - -# Deployed InterlinkGateway contract address (0x...) -GATEWAY_ADDRESS= - -# Deployed Solana Hub program ID (base58) -HUB_PROGRAM_ID= - -# Path to the relayer's Solana keypair JSON file -KEYPAIR_PATH=~/.config/solana/id.json +# InterLink Relayer Configuration + +# ── Source EVM chain ────────────────────────────────────────────────────────── +# EIP-155 chain ID (1=Ethereum, 42161=Arbitrum, 10=Optimism, 8453=Base). +# Checked against the RPC at startup; it is part of every payload hash. +CHAIN_ID= + +# HTTP JSON-RPC endpoint (event polling, finality, and withdrawal delivery). +# Must support the "finalized" block tag. +EVM_HTTP_RPC_URL= + +# Deployed InterlinkGateway contract address (0x...) +GATEWAY_ADDRESS= + +# Optional: block to start scanning from (default: current head). +START_BLOCK= +POLL_INTERVAL_SECS=4 +MAX_BLOCK_RANGE=2000 + +# ── Solana hub ──────────────────────────────────────────────────────────────── +SOLANA_RPC_URL=https://api.devnet.solana.com +HUB_PROGRAM_ID= +# Relayer's Solana keypair (must hold an ILINK stake on the hub). +KEYPAIR_PATH=~/.config/solana/id.json + +# ── Prover ──────────────────────────────────────────────────────────────────── +# Groth16 proving key; the on-chain VK must come from this file (cargo run --bin export-vk). +PROVING_KEY_PATH=keys/interlink_pk.bin +# Allow generating a local (single-party, dev-only) key if the file is missing. +ALLOW_DEV_SETUP=false + +# ── Hub → EVM withdrawals (optional) ────────────────────────────────────────── +# Key that sends releaseTokens transactions. Prefer a file over the raw variable. +EVM_PRIVATE_KEY_FILE= +# EVM_PRIVATE_KEY= +# Process hub signatures after this one on startup (default: only new ones). +HUB_START_SIGNATURE= +HUB_POLL_INTERVAL_SECS=5 + +# ── HTTP API ────────────────────────────────────────────────────────────────── +# Binds to localhost by default; the Docker image sets 0.0.0.0:8080. +API_ADDR=127.0.0.1:8080 +# Required to enable the webhook endpoints (sent as "Authorization: Bearer "). +API_TOKEN= diff --git a/.gitignore b/.gitignore index 95456db..385ddec 100644 --- a/.gitignore +++ b/.gitignore @@ -10,9 +10,14 @@ .env *.log +# Groth16 proving keys (generated by `cargo run --bin export-vk`) +keys/ + # Foundry build artifacts contracts/evm/out/ contracts/evm/cache/ # Node dependencies -node_modules/ \ No newline at end of file +node_modules/ +# Test coverage output +coverage/ diff --git a/Cargo.lock b/Cargo.lock index f49ec5f..8f2c228 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,16 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "Inflector" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" -dependencies = [ - "lazy_static", - "regex", -] - [[package]] name = "ahash" version = "0.8.12" @@ -231,37 +221,15 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "async_io_stream" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" -dependencies = [ - "futures", - "pharos", - "rustc_version", -] - [[package]] name = "auto_impl" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +checksum = "683bf733a032aec4f8954e5c0ec9d5c2183c341c49d0939ad77acc0a19fa338a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.10", ] [[package]] @@ -276,12 +244,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - [[package]] name = "base64" version = "0.21.7" @@ -383,38 +345,6 @@ dependencies = [ "serde", ] -[[package]] -name = "camino" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" -dependencies = [ - "serde_core", -] - -[[package]] -name = "cargo-platform" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" -dependencies = [ - "serde", -] - -[[package]] -name = "cargo_metadata" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d886547e41f740c616ae73108f6eb70afe6d940c7bc697cb30f13daec073037" -dependencies = [ - "camino", - "cargo-platform", - "semver", - "serde", - "serde_json", - "thiserror 1.0.69", -] - [[package]] name = "cc" version = "1.2.56" @@ -431,11 +361,22 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "num-traits", ] @@ -452,12 +393,12 @@ dependencies = [ [[package]] name = "const-hex" -version = "1.17.0" +version = "1.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bb320cac8a0750d7f25280aa97b09c26edfe161164238ecbbb31092b079e735" +checksum = "0e59eef12462b0f9b0a3620219be5d639afd79fe39dff0a42c3997061f9298b4" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "proptest", "serde_core", ] @@ -470,18 +411,19 @@ checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] name = "const_format" -version = "0.2.35" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +checksum = "a3decb4d6965448659923544a634a0571ba44cede4e11ef15a5a317126840454" dependencies = [ "const_format_proc_macros", + "konst", ] [[package]] name = "const_format_proc_macros" -version = "0.2.34" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +checksum = "941b570269a642506f08cb5b688c04bfa6cfd41a26f8bcdc49db8fd00c0ab659" dependencies = [ "proc-macro2", "quote", @@ -520,6 +462,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -529,6 +477,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -589,7 +546,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -644,12 +601,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "data-encoding" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" - [[package]] name = "der" version = "0.7.10" @@ -660,15 +611,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] - [[package]] name = "derivative" version = "2.2.0" @@ -748,12 +690,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "ecdsa" version = "0.16.9" @@ -826,24 +762,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "enr" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a3d8dc56e02f954cac8eb489772c552c473346fc34f67412bb6244fd647f7e4" -dependencies = [ - "base64 0.21.7", - "bytes", - "hex", - "k256", - "log", - "rand 0.8.5", - "rlp", - "serde", - "sha3", - "zeroize", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -873,7 +791,7 @@ dependencies = [ "serde", "serde_json", "sha3", - "thiserror 1.0.69", + "thiserror", "uint", ] @@ -908,63 +826,6 @@ dependencies = [ "uint", ] -[[package]] -name = "ethers-contract" -version = "2.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fceafa3578c836eeb874af87abacfb041f92b4da0a78a5edd042564b8ecdaaa" -dependencies = [ - "const-hex", - "ethers-contract-abigen", - "ethers-contract-derive", - "ethers-core", - "ethers-providers", - "futures-util", - "once_cell", - "pin-project", - "serde", - "serde_json", - "thiserror 1.0.69", -] - -[[package]] -name = "ethers-contract-abigen" -version = "2.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04ba01fbc2331a38c429eb95d4a570166781f14290ef9fdb144278a90b5a739b" -dependencies = [ - "Inflector", - "const-hex", - "dunce", - "ethers-core", - "eyre", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "serde", - "serde_json", - "syn 2.0.117", - "toml", - "walkdir", -] - -[[package]] -name = "ethers-contract-derive" -version = "2.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87689dcabc0051cde10caaade298f9e9093d65f6125c14575db3fd8c669a168f" -dependencies = [ - "Inflector", - "const-hex", - "ethers-contract-abigen", - "ethers-core", - "proc-macro2", - "quote", - "serde_json", - "syn 2.0.117", -] - [[package]] name = "ethers-core" version = "2.0.14" @@ -973,7 +834,6 @@ checksum = "82d80cc6ad30b14a48ab786523af33b37f28a8623fc06afd55324816ef18fb1f" dependencies = [ "arrayvec", "bytes", - "cargo_metadata", "chrono", "const-hex", "elliptic-curve", @@ -981,68 +841,18 @@ dependencies = [ "generic-array", "k256", "num_enum", - "once_cell", "open-fastrlp", "rand 0.8.5", "rlp", "serde", "serde_json", "strum", - "syn 2.0.117", "tempfile", - "thiserror 1.0.69", + "thiserror", "tiny-keccak", "unicode-xid", ] -[[package]] -name = "ethers-providers" -version = "2.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6434c9a33891f1effc9c75472e12666db2fa5a0fec4b29af6221680a6fe83ab2" -dependencies = [ - "async-trait", - "auto_impl", - "base64 0.21.7", - "bytes", - "const-hex", - "enr", - "ethers-core", - "futures-channel", - "futures-core", - "futures-timer", - "futures-util", - "hashers", - "http", - "instant", - "jsonwebtoken", - "once_cell", - "pin-project", - "reqwest", - "serde", - "serde_json", - "thiserror 1.0.69", - "tokio", - "tokio-tungstenite", - "tracing", - "tracing-futures", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "ws_stream_wasm", -] - -[[package]] -name = "eyre" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" -dependencies = [ - "indenter", - "once_cell", -] - [[package]] name = "fastrand" version = "2.3.0" @@ -1197,16 +1007,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" -[[package]] -name = "futures-timer" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" -dependencies = [ - "gloo-timers", - "send_wrapper 0.4.0", -] - [[package]] name = "futures-util" version = "0.3.32" @@ -1224,15 +1024,6 @@ dependencies = [ "slab", ] -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - [[package]] name = "generic-array" version = "0.14.9" @@ -1255,18 +1046,6 @@ dependencies = [ "wasi", ] -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] - [[package]] name = "getrandom" version = "0.4.1" @@ -1276,22 +1055,11 @@ dependencies = [ "cfg-if", "libc", "r-efi", + "rand_core 0.10.1", "wasip2", "wasip3", ] -[[package]] -name = "gloo-timers" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", -] - [[package]] name = "group" version = "0.13.0" @@ -1390,15 +1158,6 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -[[package]] -name = "hashers" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2bca93b15ea5a746f220e56587f71e73c6165eab783df9e26590069953e3c30" -dependencies = [ - "fxhash", -] - [[package]] name = "heck" version = "0.5.0" @@ -1478,20 +1237,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-rustls" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" -dependencies = [ - "futures-util", - "http", - "hyper", - "rustls", - "tokio", - "tokio-rustls", -] - [[package]] name = "hyper-tls" version = "0.5.0" @@ -1657,12 +1402,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "indenter" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" - [[package]] name = "indexmap" version = "1.9.3" @@ -1685,38 +1424,15 @@ dependencies = [ "serde_core", ] -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", -] - [[package]] name = "interlink-core" -version = "0.3.0" +version = "0.4.0" dependencies = [ - "base64 0.21.7", - "bs58", - "ed25519-dalek", - "ethers-contract", - "ethers-core", - "ethers-providers", "ff", - "futures", "halo2_proofs", "halo2curves", - "rand 0.8.5", - "rand_core 0.6.4", - "reqwest", "serde", - "serde_json", - "sha2", - "tokio", - "tracing", - "tracing-subscriber 0.3.22", + "sha3", ] [[package]] @@ -1750,20 +1466,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "jsonwebtoken" -version = "8.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6971da4d9c3aa03c3d8f3ff0f4155b534aad021292003895a469716b2a230378" -dependencies = [ - "base64 0.21.7", - "pem", - "ring 0.16.20", - "serde", - "serde_json", - "simple_asn1", -] - [[package]] name = "k256" version = "0.13.4" @@ -1784,16 +1486,31 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin 0.9.8", + "spin", ] [[package]] @@ -1913,12 +1630,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-conv" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" - [[package]] name = "num-integer" version = "0.1.46" @@ -1939,9 +1650,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -1949,9 +1660,9 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2106,50 +1817,11 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pem" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8835c273a76a90455d7344889b0964598e3316e2a79ede8e36f16bdcf2228b8" -dependencies = [ - "base64 0.13.1", -] - [[package]] name = "percent-encoding" version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pharos" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" -dependencies = [ - "futures", - "rustc_version", -] - -[[package]] -name = "pin-project" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project-lite" @@ -2200,12 +1872,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2241,11 +1907,11 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.23.10+spec-1.0.0", + "toml_edit", ] [[package]] @@ -2259,14 +1925,15 @@ dependencies = [ [[package]] name = "proptest" -version = "1.10.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ "bitflags 2.11.0", + "chacha20", + "core_detect", "num-traits", - "rand 0.9.2", - "rand_chacha 0.9.0", + "rand 0.10.3", "rand_xorshift", "regex-syntax", "unarray", @@ -2300,18 +1967,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha 0.3.1", + "rand_chacha", "rand_core 0.6.4", ] [[package]] name = "rand" -version = "0.9.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", + "getrandom 0.4.1", + "rand_core 0.10.1", ] [[package]] @@ -2324,16 +1991,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -2345,20 +2002,17 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.5" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -2431,13 +2085,12 @@ dependencies = [ "ark-serialize", "ark-snark", "ark-std", - "base64 0.21.7", + "base64", "bs58", "ed25519-dalek", "ethers-core", - "ethers-providers", "futures", - "interlink-core", + "k256", "rand 0.8.5", "reqwest", "serde", @@ -2455,7 +2108,7 @@ version = "0.11.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" dependencies = [ - "base64 0.21.7", + "base64", "bytes", "encoding_rs", "futures-core", @@ -2464,7 +2117,6 @@ dependencies = [ "http", "http-body", "hyper", - "hyper-rustls", "hyper-tls", "ipnet", "js-sys", @@ -2474,7 +2126,6 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls", "rustls-pemfile", "serde", "serde_json", @@ -2483,13 +2134,11 @@ dependencies = [ "system-configuration", "tokio", "tokio-native-tls", - "tokio-rustls", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", "winreg", ] @@ -2503,35 +2152,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "ring" -version = "0.16.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" -dependencies = [ - "cc", - "libc", - "once_cell", - "spin 0.5.2", - "untrusted 0.7.1", - "web-sys", - "winapi", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted 0.9.0", - "windows-sys 0.52.0", -] - [[package]] name = "rlp" version = "0.5.2" @@ -2582,35 +2202,13 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.21.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" -dependencies = [ - "log", - "ring 0.17.14", - "rustls-webpki", - "sct", -] - [[package]] name = "rustls-pemfile" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" dependencies = [ - "base64 0.21.7", -] - -[[package]] -name = "rustls-webpki" -version = "0.101.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring 0.17.14", - "untrusted 0.9.0", + "base64", ] [[package]] @@ -2625,15 +2223,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - [[package]] name = "scale-info" version = "2.11.6" @@ -2673,16 +2262,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring 0.17.14", - "untrusted 0.9.0", -] - [[package]] name = "sec1" version = "0.7.3" @@ -2725,22 +2304,6 @@ name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "send_wrapper" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f638d531eccd6e23b980caf34876660d38e265409d8e99b397ab71eb3612fad0" - -[[package]] -name = "send_wrapper" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" [[package]] name = "serde" @@ -2785,15 +2348,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2806,17 +2360,6 @@ dependencies = [ "serde", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "sha2" version = "0.10.9" @@ -2824,7 +2367,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2873,18 +2416,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "simple_asn1" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror 2.0.18", - "time", -] - [[package]] name = "slab" version = "0.4.12" @@ -2917,12 +2448,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "spin" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" - [[package]] name = "spin" version = "0.9.8" @@ -3007,6 +2532,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "0.1.2" @@ -3081,16 +2617,7 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl", ] [[package]] @@ -3104,17 +2631,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "thread_local" version = "1.1.9" @@ -3124,37 +2640,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tiny-keccak" version = "2.0.2" @@ -3203,7 +2688,6 @@ dependencies = [ "signal-hook-registry", "socket2 0.6.2", "tokio-macros", - "tracing", "windows-sys 0.61.2", ] @@ -3228,31 +2712,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c" -dependencies = [ - "futures-util", - "log", - "rustls", - "tokio", - "tokio-rustls", - "tungstenite", - "webpki-roots", -] - [[package]] name = "tokio-util" version = "0.7.18" @@ -3266,77 +2725,36 @@ dependencies = [ "tokio", ] -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "173f25c883cd2176a2d8212155638c0a60d484a9d84546d270866f40f56b6e49" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap 2.13.0", - "serde", - "serde_spanned", - "toml_datetime 0.6.11", - "toml_write", - "winnow", -] - -[[package]] -name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.25.19+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "dd43688c4858d7264ae1cf9a9f874a67c3feb74a883b1cd7bebd6a4ea8307218" dependencies = [ "indexmap 2.13.0", - "toml_datetime 0.7.5+spec-1.1.0", + "toml_datetime", "toml_parser", "winnow", ] [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow", ] -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - [[package]] name = "tower-service" version = "0.3.3" @@ -3375,16 +2793,6 @@ dependencies = [ "valuable", ] -[[package]] -name = "tracing-futures" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" -dependencies = [ - "pin-project", - "tracing", -] - [[package]] name = "tracing-log" version = "0.2.0" @@ -3442,26 +2850,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tungstenite" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e3dac10fd62eaf6617d3a904ae222845979aec67c615d1c842b4002c7666fb9" -dependencies = [ - "byteorder", - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.8.5", - "rustls", - "sha1", - "thiserror 1.0.69", - "url", - "utf-8", -] - [[package]] name = "typenum" version = "1.19.0" @@ -3498,18 +2886,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - [[package]] name = "url" version = "2.5.8" @@ -3522,12 +2898,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -3552,16 +2922,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - [[package]] name = "want" version = "0.3.1" @@ -3698,43 +3058,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-roots" -version = "0.25.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" - -[[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.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[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-link" version = "0.2.1" @@ -3965,9 +3288,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.14" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef" dependencies = [ "memchr", ] @@ -4076,25 +3399,6 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" -[[package]] -name = "ws_stream_wasm" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" -dependencies = [ - "async_io_stream", - "futures", - "js-sys", - "log", - "pharos", - "rustc_version", - "send_wrapper 0.6.0", - "thiserror 2.0.18", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "wyz" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 6f0731e..33c91d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,11 +9,5 @@ members = [ [workspace.metadata.cargo-udeps.ignore] development = true -# security patches. keeping the gremlins out. -# override ring to 0.17.12+. stops aes panics from rustsec-2025-0009. -# this crate is a transitive dep via ethers-providers → jsonwebtoken. -[workspace.dependencies] -ring = ">=0.17.12" - [profile.release] overflow-checks = true \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 799c8fd..bf7ea23 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,7 +47,7 @@ USER relayer WORKDIR /home/relayer # Required env vars (see .env.example): -# EVM_WS_RPC_URL, EVM_HTTP_RPC_URL, SOLANA_RPC_URL, +# CHAIN_ID, EVM_HTTP_RPC_URL, SOLANA_RPC_URL, PROVING_KEY_PATH, # GATEWAY_ADDRESS, HUB_PROGRAM_ID, KEYPAIR_PATH ENV RUST_LOG=relayer=info diff --git a/README.md b/README.md index cd39dcf..ff14205 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,42 @@ # InterLink +> **Status: experimental and unaudited. Do not use with real funds.** +> The current Groth16 circuit does not yet verify source-chain consensus or receipt +> inclusion, so proofs attest to message *format*, not to the message having happened. +> See [docs/SECURITY_REVIEW.md](docs/SECURITY_REVIEW.md) for findings and fixes, and +> [docs/SECURITY_GUARANTEES.md](docs/SECURITY_GUARANTEES.md) for what is and isn't guaranteed. +> The "goal" column in the comparison table describes targets, not the current build. + ## Overview -InterLink is a zero-knowledge interoperability protocol enabling mathematically guaranteed cross-chain state transitions. By utilizing zk-SNARKs (Halo2 with Groth16 on BN254) and a high-throughput Solana Coordination Hub, InterLink enables instant, permissionless cross-chain message passing and asset transfers with O(1) on-chain verification. +InterLink is a cross-chain bridge in development. Its goal is ZK-proven cross-chain messages: +a Groth16 (BN254) proof per message, verified on-chain against a canonical payload hash that +every gateway recomputes itself.

InterLink Architecture

-The protocol follows a **Hub-and-Spoke** architecture where Solana acts as the central settlement and verification layer, while EVM chains (Ethereum, Optimism, Arbitrum, Base, Polygon), Cosmos IBC chains, and future networks serve as spoke gateways. +The protocol follows a **Hub-and-Spoke** architecture. Solana is the settlement and verification +hub. EVM chains (Ethereum, Optimism, Arbitrum, Base, Polygon) and Cosmos chains are spoke gateways. -> **IMPORTANT – PROVER CONSISTENCY REQUIREMENT** -> -> The relayer's Halo2 prover MUST use the exact same `interlink_v1_domain` salt when generating proofs. This is strictly required to match the updated Solidity input binding logic in `InterlinkGateway.sol:L175-180`. Ensure the entire pipeline (prover -> relayer -> on-chain verification) uses consistent domain separation to prevent proof mismatches. +> **Canonical message format.** EVM gateways, the Solana hub, the relayer and the SDK all hash +> messages identically (both chain IDs, both gateway addresses, nonce, action, sender, asset, +> amount, recipient, extra data). A shared test vector pins this across all four +> implementations. See [docs/SECURITY_REVIEW.md](docs/SECURITY_REVIEW.md#canonical-message-format). --- ## Why InterLink? -| Feature | Wormhole | Stargate | Across | LiFi | **InterLink** | +| Feature | Wormhole | Stargate | Across | **InterLink: goal** | **InterLink: today** | |---|---|---|---|---|---| -| **Verification** | Guardian multisig (19) | UltraLight Client | Optimistic Oracle | Aggregated | **ZK proofs (O(1))** | -| **Settlement** | 2-15 min | 1-2 min | 5-60 min | Varies | **<30 seconds** | -| **Fees (Tier 1)** | $1-20 VAA | $0.50-5 | 0.25-1% | Varies | **0% (zero-fee)** | -| **Proof model** | Trust 13/19 guardians | Trust relayer | Trust 5 signers | Trust bridge | **Trustless math** | -| **Uptime SLA** | 99.95% (informal) | None published | None | None | **99.9% (enforced)** | -| **Governance** | Guardian multisig | Snapshot (off-chain) | UMA Oracle | None | **On-chain DAO** | -| **Audit trail** | On-chain VAA log | None | None | None | **SHA-256 hash-chain** | -| **Rate limiting** | None | None | None | 50 req/min | **100 req/min free** | -| **Bug bounty** | $50k-2M | Varies | Varies | None | **$100-500k** | -| **LP incentives** | None | STG emissions | ACX rewards | None | **Epoch-based + boost** | +| **Verification** | Guardian multisig (13/19) | DVNs | Optimistic oracle | ZK light-client proofs | Groth16 proof bound to the message, but the circuit does not yet verify source-chain consensus (trusts the proving-key holder) | +| **Settlement** | 2-15 min | 1-2 min | 5-60 min | Source finality + seconds | Source finality (~13 min on Ethereum) + proof + hub tx | +| **Fees (Tier 1)** | $1-20 VAA | $0.50-5 | 0.25-1% | 0% under $1k | Fee model exists in `fee.rs`; not enforced on-chain | +| **Return path** | Yes | Yes | Yes | Any → any | EVM → Solana mint/swap, Solana → EVM withdrawal | +| **Governance** | Guardian multisig | Off-chain | UMA | On-chain DAO | Guardian/admin keys with timelocks (simulation in `governance.rs`) | +| **Audits** | Multiple | Multiple | Multiple | Required before mainnet | None yet | --- @@ -37,58 +44,51 @@ The protocol follows a **Hub-and-Spoke** architecture where Solana acts as the c ``` interlink/ -├── circuits/ # ZK circuit definitions -│ ├── src/ -│ │ ├── merkle.rs # Poseidon Merkle inclusion proofs -│ │ ├── consensus.rs # Ethereum Sync Committee + Tendermint circuits -│ │ ├── folding.rs # Recursive proof folding pipeline -│ │ └── config.rs # Circuit configuration -│ └── Cargo.toml +├── circuits/ # Halo2 research circuits (not used by the relayer) +│ └── src/{merkle,consensus,recursion,config}.rs ├── contracts/ │ ├── evm/ │ │ ├── src/InterlinkGateway.sol # EVM spoke gateway (Solidity 0.8.28) -│ │ ├── script/Deploy.s.sol # Mainnet deployment -│ │ ├── script/DeployL2.s.sol # L2 deployment (OP/Arb/Base/Polygon) -│ │ └── test/InterlinkGateway.t.sol # Foundry fuzz tests (23 tests) -│ ├── solana/interlink-hub/ -│ │ ├── programs/interlink-hub/ # Anchor program (BN254 pairing) -│ │ └── tests/ # Devnet integration tests -│ └── cosmos/interlink-gateway/ # CosmWasm gateway (IBC-ready) +│ │ ├── script/ # Deploy, DeployAndInit, DeployL2 +│ │ └── test/InterlinkGateway.t.sol # Foundry tests incl. a real Groth16 proof +│ ├── solana/interlink-hub/ # Anchor hub program + TS integration tests +│ └── cosmos/ # CosmWasm gateway (inbound disabled: no verifier) ├── relayer/ │ ├── src/ -│ │ ├── main.rs # Entry point, env config, task spawning -│ │ ├── lib.rs # Module registry (41 modules) -│ │ └── [41 modules] # See Module Reference below -│ ├── tests/ -│ │ ├── security.rs # 30 security-focused tests -│ │ └── integration.rs # 18 end-to-end integration tests -│ └── bin/ -│ ├── benchmark.rs # Proof generation benchmarks -│ └── load_test.rs # Concurrent transfer load testing -├── interlink-core/ # Core circuit engine + payload types -├── sdk/ # @interlink/sdk TypeScript package -└── website/ # Developer portal (React + Vite) +│ │ ├── main.rs # Both pipelines: EVM → hub and hub → EVM +│ │ ├── lib.rs # Production modules vs experimental modules +│ │ └── ... +│ ├── tests/ # security.rs, integration.rs, checklist_features.rs +│ └── src/bin/ # export-vk, benchmark, load-test +├── interlink-core/ # Halo2 Poseidon chip + payload types +├── sdk/ # @interlink/sdk (TypeScript, ethers v6) +└── docs/ # SECURITY_REVIEW.md, SECURITY_GUARANTEES.md, ... ``` --- -## Relayer Module Reference (41 modules) +## Relayer Module Reference -The relayer is the heart of InterLink — a standalone Rust service with 41 specialized modules organized by domain. +Only the **Core Bridge Pipeline** modules run in the `relayer` binary. Every other module +below is standalone library or simulation code. It is tested, but it is not wired into the +running relayer and gives no runtime guarantees. ### Core Bridge Pipeline | Module | File | Description | |---|---|---| -| **listener** | `listener.rs` | WebSocket EVM event subscription with exponential backoff reconnection. Parses `MessagePublished`, `SwapInitiated`, `NFTLocked` events from gateway contracts. | -| **events** | `events.rs` | `GatewayEvent` enum with typed variants for Deposit, Swap, and NFTLock. ABI-compatible decoding from EVM log topics + data. | -| **finality** | `finality.rs` | Per-chain finality confirmation. Polls EVM HTTP RPC for block confirmations. Supports configurable confirmation counts per chain (Ethereum: 75 blocks, L2s: 1-2 blocks). | -| **prover** | `prover.rs` | Halo2 Groth16 proof generation on BN254. Domain-separated with `interlink_v1_domain` salt. Semaphore-bounded concurrent proving with `spawn_blocking`. Result-based error propagation (no panics in async runtime). | -| **submitter** | `submitter.rs` | Builds and submits raw Solana transactions with ZK proofs. Compact-u16 encoding, PDA derivation (state_registry, stake_account, vk), cached keypair loading. | -| **batch** | `batch.rs` | `BatchCollector` aggregates transfers into single settlement transactions every 5 seconds. Configurable batch size with flush-on-full. | -| **nonce** | `nonce.rs` | `DurableNoncePool` for parallel Solana settlement (10-100 concurrent nonces). Lock-free acquire/release with exhaustion alerting. | - -### Economics & Fees +| **listener** | `listener.rs` | Polls `eth_getLogs` over a block cursor for `MessagePublished`. Verifies the RPC chain ID, decodes strictly and drops logs whose fields don't reproduce the payload hash. | +| **finality** | `finality.rs` | Waits for the node's `finalized` block tag, then re-checks the receipt (block hash, status, log). Reorged events are never relayed. | +| **prover** | `prover.rs` | Groth16 (arkworks, BN254) proofs with a persistent proving key shared with `export-vk`. | +| **submitter** | `submitter.rs` | Hub `submit_proof` (attest) and `execute_transfer` / `execute_swap`, with confirmation tracking and idempotency. | +| **solana_tx** | `solana_tx.rs` | Legacy message compiler, PDA/ATA derivation (checked against the Solana CLI), Anchor discriminators. | +| **hub_outbound** | `hub_outbound.rs` | Reads finalized hub withdrawals (`OutboundMessageEvent`), accepting event logs only while the hub program is executing. | +| **evm_tx / evm_submitter** | `evm_tx.rs`, `evm_submitter.rs` | EIP-1559 signing (k256, cross-checked against ethers-core) and delivery via `releaseTokens`. | +| **events / evm** | `events.rs`, `evm.rs` | Canonical message type and minimal keccak/hex/JSON-RPC helpers (no ethers-rs at runtime). | +| **http_api** | `http_api.rs` | `/health`, `/quote`, `/compare`, `/metrics`, `/transfers/{payloadHash}` (hub on-chain status). Webhook routes need `API_TOKEN`. | +| **batch / metrics / webhook / fee / gas** | | Batching, Prometheus metrics, webhook registry, fee and gas models. | + +### Economics & Fees (experimental) | Module | File | Description | |---|---|---| @@ -166,22 +166,25 @@ The relayer is the heart of InterLink — a standalone Rust service with 41 spec ## Testing Framework -InterLink employs a multi-layered testing strategy with **460 tests across all layers**, all verified passing. +Test counts as of this branch. "Run" means executed in the latest review; the Foundry and +Anchor suites need their CLIs and were compiled but not executed there. ### Full Test Suite Summary | Layer | Tool | Tests | Status | |---|---|---|---| -| Relayer lib (unit) | `cargo test --lib` | 366 | All passing | -| Relayer security | `cargo test --test security` | 30 | All passing | -| Relayer integration | `cargo test --test integration` | 18 | All passing | -| Relayer checklist e2e | `cargo test --test checklist_features`| 9 | All passing | -| ZK Circuits | `cargo test -p circuits` | 10 | All passing | -| EVM Gateway (Solidity) | `forge test` | 23 | All passing | -| Solana Hub (devnet) | Anchor / Mocha | 4 | All passing | -| **Total** | | **460** | **All passing** | - -### Security Test Coverage (30 tests) +| Relayer lib (unit) | `cargo test -p relayer --lib` | 445 | Run, passing | +| Relayer security | `cargo test --test security` | 32 | Run, passing | +| Relayer integration | `cargo test --test integration` | 19 | Run, passing | +| Relayer checklist e2e | `cargo test --test checklist_features` | 9 | Run, passing | +| Circuits / interlink-core | `cargo test -p circuits -p interlink-core` | 15 | Run, passing | +| Solana hub (host, incl. real Groth16 proof) | `cargo test` in `contracts/solana/interlink-hub` | 11 | Run, passing | +| Cosmos gateway | `cargo test` in `contracts/cosmos` | 14 | Run, passing | +| SDK | `npm test` in `sdk` | 31 | Run, passing | +| EVM gateway (incl. real Groth16 proof) | `forge test` | 42 | Compiled with solc 0.8.28; not executed (no Foundry) | +| Solana hub integration | `anchor test` | 5 | Not executed (no Anchor CLI) | + +### Security Test Coverage (32 tests) | Module | Tests | What's Validated | |---|---|---| @@ -244,29 +247,32 @@ Advanced ZK primitives built on Halo2 over BN254: ### EVM Spoke Gateway (`contracts/evm/`) -Solidity 0.8.28 with BN254 pairing precompiles: - -- **InterlinkGateway.sol**: Message routing, swap initiation, NFT locking, ZK proof verification -- **Deploy.s.sol**: Mainnet deployment with guardian address configuration -- **DeployL2.s.sol**: L2-specific deployment (Optimism, Arbitrum, Base, Polygon) with per-chain finality calibration -- **23 Foundry tests** including fuzz tests (256 runs each) +- **InterlinkGateway.sol**: deposits (`sendCrossChainMessage`, `initiateSwap`, `lockNFT`), + verified inbound releases (`releaseTokens`) and generic calls (`executeVerifiedMessage`, + which can never target the gateway or a custodied asset). Groth16 verification uses the + BN254 precompiles. VK and remote-gateway changes have a 2-day timelock, the guardian + transfers in two steps, and emergency withdrawal only works while paused. +- **Scripts**: `Deploy.s.sol`, `DeployAndInit.s.sol` (uploads the VK from `export-vk`), + `DeployL2.s.sol`. +- **Tests**: Foundry suite including a real arkworks Groth16 proof (`testRealProof_*`). -### Solana Hub Gateway (`contracts/solana/interlink-hub/`) - -Anchor-based program with `alt_bn128` syscalls: +### Solana Hub (`contracts/solana/interlink-hub/`) - **Program ID**: `AKzpc9tvxfhLjj5AantKizK2YZgSjoyhLmYqRZy6b8Lz` -- PDA derivation: `state_registry`, `stake_account`, `vk` accounts -- `submit_proof`: Verifies ZK proof, mints/releases on destination -- `process_cross_chain_swap`: AMM integration with stake verification -- **Live on Solana devnet** with 4 passing integration tests +- `submit_proof`: a staked relayer attests a fully specified message (the hash is recomputed + on-chain and a record is kept per payload hash). +- `execute_transfer` / `execute_swap`: permissionless execution bound to the attested hash. + Creates the recipient's token account if needed. +- `burn_for_withdrawal`: burns wrapped tokens and emits an outbound message for + `releaseTokens` on the origin chain. +- Wrapped-asset and remote-gateway registries, 7-day unbonding, VK timelock. +- Host tests run a real Groth16 proof through the on-chain verifier. ### Cosmos Gateway (`contracts/cosmos/`) -CosmWasm-based gateway for IBC-connected chains: - -- Initial implementation for Tendermint consensus verification -- IBC channel management for cross-chain message relay +- Deposits (one denom per message, bound into the hash), two-step guardian. +- **Inbound execution is disabled** (`VerifierUnavailable`): CosmWasm 1.5 has no BN254 + pairing, so there is no sound verifier yet. --- @@ -311,28 +317,30 @@ CosmWasm-based gateway for IBC-connected chains: ## Security Model -### ZK Proof Verification +> Read [docs/SECURITY_REVIEW.md](docs/SECURITY_REVIEW.md) first. The protocol is unaudited. -Every cross-chain message is verified by a Halo2 Groth16 proof on BN254. The on-chain verifier performs a single pairing check — O(1) regardless of message complexity. Domain separation via `keccak256("interlink_v1_domain")` prevents cross-protocol proof reuse. +### What a proof attests today -### Multi-Layer Protection +Each message gets a Groth16 (BN254) proof whose public input is +`(payloadHash mod r + keccak256("interlink_v1_domain"))^5 + nonce`. Every verifier recomputes +`payloadHash` from the message fields, so a proof cannot be reused for different amounts, +recipients, chains or gateways. The circuit does **not** yet verify source-chain consensus or +receipt inclusion. Until it does, whoever holds the proving key can attest any message. -| Layer | Mechanism | Auto-Trigger | -|---|---|---| -| **Circuit Breaker** | Auto-pause bridge operations | ≥5 proof failures, ≥3 settlement failures, $1M outflow in 5min | -| **Guardian Pause** | Emergency manual pause | Authorized guardian key hash | -| **Threshold Multi-Sig** | 3-of-5 Ed25519 validator co-signing | All settlement transactions | -| **Rate Limiting** | Token-bucket per API key | Free: 100/min, Pro: 1000/min | -| **Retry Engine** | Exponential backoff with dead-letter queue | Per-chain optimized policies | -| **Audit Trail** | SHA-256 hash-chain append-only log | Every transfer recorded | +### Defense in depth (implemented) -### Validator Economics +| Layer | Where | +|---|---| +| Canonical payload hash recomputed on-chain | EVM gateway, Solana hub, Cosmos | +| Replay protection per payload hash | EVM `executedMessages`, hub message records | +| Custody cap: releases ≤ locked amount per asset | EVM `totalLocked` | +| Verified calls cannot touch custody | EVM `executeVerifiedMessage` | +| Timelocked VK / remote-gateway changes, two-step guardian | EVM, hub, Cosmos | +| Finalized-tag wait + receipt re-check (reorg safety) | relayer `finality.rs` | +| Relayer stake in the real ILINK mint, 7-day unbonding | hub | -- **10% fee share** distributed to validators per epoch -- **Weighted by**: stake amount × uptime percentage -- **Minimum uptime**: 90% required for reward eligibility -- **Perfect uptime bonus**: +5% additional rewards -- **Slashing**: 5% for downtime, 50% for byzantine behavior +Not wired in (library code only): the circuit breaker, rate limiter, multisig co-signing and +audit trail modules. --- @@ -340,60 +348,37 @@ Every cross-chain message is verified by a Halo2 Groth16 proof on BN254. The on- | Parameter | Value | |---|---| -| Proving System | Halo2 (IPA commitment, BN254 scalar field) | -| Elliptic Curve | BN254 (alt_bn128) | -| Hash Function | Poseidon quintic S-box (x^5), injective over BN254 | -| Domain Separation | `keccak256("interlink_v1_domain")` | -| Verification Complexity | O(1) on-chain | -| State Commitment | Sparse Merkle Trees | -| Recursion | Pairwise folding, O(log N) depth, Fiat-Shamir alpha = (C1+C2)^5 | -| Consensus (ETH) | Sync Committee 342/512 threshold | -| Consensus (Cosmos) | Tendermint >2/3 voting power | -| Settlement Target | <30 seconds end-to-end | -| Uptime SLA | 99.9% (8.76h max annual downtime) | -| Settlement SLA | <60s p99 | -| API Response SLA | <500ms p99 | +| Proving system | Groth16 over BN254 (arkworks), single public input | +| On-chain verification | EVM precompiles 0x06/0x07/0x08; Solana `alt_bn128` syscalls | +| Message hash | keccak256 of the ABI-encoded canonical message | +| Hub chain ID | `2` (both gateway addresses are also hashed) | +| Source finality | Node `finalized` tag (Ethereum ≈ 13 min; rollups per their L1 finality) | +| Trusted setup | Local single-party (dev). A multi-party ceremony is required for production | --- ## Developer Setup ### Prerequisites -- Rust (Edition 2021) -- Solana CLI (`>=1.18`) & Anchor CLI (`0.32.1`) -- Foundry (`forge`, `cast`, `anvil`) — install via `foundryup` -- Node.js & yarn (for Anchor TypeScript tests and website) +- Rust (stable), Node.js +- Solana CLI and Anchor CLI `0.30.1` (hub program) +- Foundry (`forge`) for EVM contracts -### Building the Workspace +### Building and testing ```bash -# Build all Rust crates -cargo build --release - -# Build the Solana Hub program -cd contracts/solana/interlink-hub -anchor build - -# Build EVM contracts -cd contracts/evm -forge build +cargo test --workspace # relayer, circuits, interlink-core +(cd contracts/solana/interlink-hub && cargo test) # hub host tests +(cd contracts/cosmos && cargo test) +(cd contracts/evm && forge test) +(cd sdk && npm ci && npm test) +cargo run --bin export-vk # proving key + VK for on-chain upload ``` -### Environment Variables - -```bash -# Required -EVM_WS_RPC_URL=wss://... # WebSocket for event subscription -EVM_HTTP_RPC_URL=https://... # HTTP for finality polling -SOLANA_RPC_URL=https://... # Solana RPC endpoint -GATEWAY_ADDRESS=0x... # EVM gateway contract address -HUB_PROGRAM_ID=AKzpc9... # Solana hub program ID -KEYPAIR_PATH=/path/to/keypair.json # Solana relayer keypair - -# Optional -LOG_FORMAT=json # Structured JSON logging (default: text) -BATCH_SIZE=50 # Transfers per batch (default: 50) -NONCE_POOL_SIZE=10 # Parallel Solana nonces (default: 10) -``` +### Relayer environment +See [`.env.example`](.env.example). Key variables: `CHAIN_ID` (EIP-155), `EVM_HTTP_RPC_URL`, +`GATEWAY_ADDRESS`, `SOLANA_RPC_URL`, `HUB_PROGRAM_ID`, `KEYPAIR_PATH`, `PROVING_KEY_PATH`, +and optionally `EVM_PRIVATE_KEY_FILE` (enables hub → EVM withdrawals) and `API_TOKEN` +(enables webhook endpoints). --- @@ -442,25 +427,12 @@ validator_heartbeats_total, validator_heartbeats_expected --- -## Solana Hub — Live Devnet Deployment & Testing Results - -- **Program ID:** `AKzpc9tvxfhLjj5AantKizK2YZgSjoyhLmYqRZy6b8Lz` -- **Deploy Transaction:** `59bSsMZU9GZAvaVL5mEL8NQ24Ucs4HvgpQ7i9TmnFDYPdANivm24n3b18Yb5Nx2aSZYp9ti3NmT7GF1jsd2v59ZY` -- **IDL Account:** `3YddvfRCPY6MALVoVzAN59njDkzCEQ3px2NMhszBrZpp` -- **Cluster:** Solana Devnet - -### Explicit Chain Run Results - -The simulated execution environment successfully processed the Hub operations against Devnet validators, concluding in 0 errors and matching exact settlement benchmarks. - -| Test Execution Layer | Execution Path | Validated Constraints | Latency / Result | -|---|---|---|---| -| **Hub Initialization** | `interlink::initialize_hub` | Derived PDA assignments mapped for `state_registry`, `vk`, and secure `stake_accounts`. | `[PASS] 0.42s Exec` | -| **ZK Payload Decoding** | `interlink::submit_proof` | Falsified Groth16 hashes properly reverted. Domain-separated EVM batch payload ingested safely. | `[PASS] 0.81s Exec` | -| **Token Bridging (Mint/Burn)** | `interlink::settle_cross_chain` | Dynamic synthetic SPL mappings successfully wrapped without double-spend vulnerabilities. | `[PASS] 0.53s Exec` | -| **Defi AMM Cross-Swap** | `amm::process_intent_swap` | Raydium intent simulated bridging + swapping simultaneously maintaining slippage below `<0.5%`. | `[PASS] 0.94s Exec` | +## Solana Hub — Devnet -**Final Settlement Telemetry:** All 4 internal Solana devnet operations completed smoothly processing 100% test branch execution coverage against ZK verification nodes in ~2.7s total elapsed simulated time. +An earlier version of the hub was deployed to devnet at +`AKzpc9tvxfhLjj5AantKizK2YZgSjoyhLmYqRZy6b8Lz`. The program has since changed (account +layouts, instructions, verification), so it must be redeployed before devnet testing. Results +previously listed here named instructions that do not exist in the program and were removed. --- diff --git a/contracts/cosmos/.cargo/config.toml b/contracts/cosmos/.cargo/config.toml new file mode 100644 index 0000000..1cfd0f3 --- /dev/null +++ b/contracts/cosmos/.cargo/config.toml @@ -0,0 +1,9 @@ +# CosmWasm contracts import their host functions (db_read, addr_validate, ...) +# from the VM. cosmwasm-std 1.5 declares them as plain `extern "C"` and relies +# on the linker allowing undefined symbols, which recent Rust no longer passes +# to rust-lld for wasm32-unknown-unknown. Restore it so they become wasm imports. +[target.wasm32-unknown-unknown] +rustflags = ["-C", "link-arg=--allow-undefined"] + +[alias] +wasm = "build --release --lib --target wasm32-unknown-unknown" diff --git a/contracts/cosmos/Cargo.lock b/contracts/cosmos/Cargo.lock index 07ebc81..bfce7b0 100644 --- a/contracts/cosmos/Cargo.lock +++ b/contracts/cosmos/Cargo.lock @@ -1,716 +1,717 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "ahash" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom", - "once_cell", - "version_check", -] - -[[package]] -name = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - -[[package]] -name = "bech32" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" - -[[package]] -name = "block-buffer" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bnum" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56953345e39537a3e18bdaeba4cb0c58a78c1f61f361dc0fa7c5c7340ae87c5f" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "cosmos-gateway" -version = "0.1.0" -dependencies = [ - "cosmwasm-schema", - "cosmwasm-std", - "cw-storage-plus", - "schemars", - "serde", - "thiserror", -] - -[[package]] -name = "cosmwasm-crypto" -version = "1.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9c82e56962f0f18c9a292aa59940e03a82ce15ef79b93679d5838bb8143f0df" -dependencies = [ - "digest 0.10.7", - "ed25519-zebra", - "k256", - "rand_core 0.6.4", - "thiserror", -] - -[[package]] -name = "cosmwasm-derive" -version = "1.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b804ff15a0e059c88f85ae0e868cf8c7aba9d61221e46f1ad7250f270628c7" -dependencies = [ - "syn 1.0.109", -] - -[[package]] -name = "cosmwasm-schema" -version = "1.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5526ea839acb47bbf8fff031ed9aad86e74d43f77b089255417328c3664367d5" -dependencies = [ - "cosmwasm-schema-derive", - "schemars", - "serde", - "serde_json", - "thiserror", -] - -[[package]] -name = "cosmwasm-schema-derive" -version = "1.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10f41b99f41f840765d02ae858956bb52af910755976312082e90493c67db512" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "cosmwasm-std" -version = "1.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "763340055b84e5482ed90fec8194ff7d59112267a09bbf5819c9e3edca8c052e" -dependencies = [ - "base64", - "bech32", - "bnum", - "cosmwasm-crypto", - "cosmwasm-derive", - "derivative", - "forward_ref", - "hex", - "schemars", - "serde", - "serde-json-wasm", - "sha2 0.10.9", - "static_assertions", - "thiserror", -] - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", -] - -[[package]] -name = "crypto-common" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "curve25519-dalek" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b9fdf9972b2bd6af2d913799d9ebc165ea4d2e65878e329d9c6b372c4491b61" -dependencies = [ - "byteorder", - "digest 0.9.0", - "rand_core 0.5.1", - "subtle", - "zeroize", -] - -[[package]] -name = "cw-storage-plus" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5ff29294ee99373e2cd5fd21786a3c0ced99a52fec2ca347d565489c61b723c" -dependencies = [ - "cosmwasm-std", - "schemars", - "serde", -] - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "zeroize", -] - -[[package]] -name = "derivative" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "digest" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" -dependencies = [ - "generic-array", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "const-oid", - "crypto-common", - "subtle", -] - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "ecdsa" -version = "0.16.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" -dependencies = [ - "der", - "digest 0.10.7", - "elliptic-curve", - "rfc6979", - "signature", - "spki", -] - -[[package]] -name = "ed25519-zebra" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c24f403d068ad0b359e577a77f92392118be3f3c927538f2bb544a5ecd828c6" -dependencies = [ - "curve25519-dalek", - "hashbrown", - "hex", - "rand_core 0.6.4", - "serde", - "sha2 0.9.9", - "zeroize", -] - -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct", - "crypto-bigint", - "digest 0.10.7", - "ff", - "generic-array", - "group", - "pkcs8", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", -] - -[[package]] -name = "ff" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "forward_ref" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8cbd1169bd7b4a0a20d92b9af7a7e0422888bd38a6f5ec29c1fd8c1558a272e" - -[[package]] -name = "generic-array" -version = "0.14.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" -dependencies = [ - "typenum", - "version_check", - "zeroize", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "group" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" -dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -dependencies = [ - "ahash", -] - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[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 = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "k256" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" -dependencies = [ - "cfg-if", - "ecdsa", - "elliptic-curve", - "once_cell", - "sha2 0.10.9", - "signature", -] - -[[package]] -name = "libc" -version = "0.2.182" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom", -] - -[[package]] -name = "rfc6979" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" -dependencies = [ - "hmac", - "subtle", -] - -[[package]] -name = "schemars" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" -dependencies = [ - "dyn-clone", - "schemars_derive", - "serde", - "serde_json", -] - -[[package]] -name = "schemars_derive" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn 2.0.117", -] - -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct", - "der", - "generic-array", - "pkcs8", - "subtle", - "zeroize", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde-json-wasm" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e9213a07d53faa0b8dd81e767a54a8188a242fdb9be99ab75ec576a774bfdd7" -dependencies = [ - "serde", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "serde_derive_internals" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "sha2" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" -dependencies = [ - "block-buffer 0.9.0", - "cfg-if", - "cpufeatures", - "digest 0.9.0", - "opaque-debug", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest 0.10.7", -] - -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest 0.10.7", - "rand_core 0.6.4", -] - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom", + "once_cell", + "version_check", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bech32" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bnum" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56953345e39537a3e18bdaeba4cb0c58a78c1f61f361dc0fa7c5c7340ae87c5f" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cosmos-gateway" +version = "0.1.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus", + "schemars", + "serde", + "sha2 0.10.9", + "thiserror", +] + +[[package]] +name = "cosmwasm-crypto" +version = "1.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9c82e56962f0f18c9a292aa59940e03a82ce15ef79b93679d5838bb8143f0df" +dependencies = [ + "digest 0.10.7", + "ed25519-zebra", + "k256", + "rand_core 0.6.4", + "thiserror", +] + +[[package]] +name = "cosmwasm-derive" +version = "1.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b804ff15a0e059c88f85ae0e868cf8c7aba9d61221e46f1ad7250f270628c7" +dependencies = [ + "syn 1.0.109", +] + +[[package]] +name = "cosmwasm-schema" +version = "1.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5526ea839acb47bbf8fff031ed9aad86e74d43f77b089255417328c3664367d5" +dependencies = [ + "cosmwasm-schema-derive", + "schemars", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "cosmwasm-schema-derive" +version = "1.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10f41b99f41f840765d02ae858956bb52af910755976312082e90493c67db512" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "cosmwasm-std" +version = "1.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "763340055b84e5482ed90fec8194ff7d59112267a09bbf5819c9e3edca8c052e" +dependencies = [ + "base64", + "bech32", + "bnum", + "cosmwasm-crypto", + "cosmwasm-derive", + "derivative", + "forward_ref", + "hex", + "schemars", + "serde", + "serde-json-wasm", + "sha2 0.10.9", + "static_assertions", + "thiserror", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9fdf9972b2bd6af2d913799d9ebc165ea4d2e65878e329d9c6b372c4491b61" +dependencies = [ + "byteorder", + "digest 0.9.0", + "rand_core 0.5.1", + "subtle", + "zeroize", +] + +[[package]] +name = "cw-storage-plus" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5ff29294ee99373e2cd5fd21786a3c0ced99a52fec2ca347d565489c61b723c" +dependencies = [ + "cosmwasm-std", + "schemars", + "serde", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519-zebra" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c24f403d068ad0b359e577a77f92392118be3f3c927538f2bb544a5ecd828c6" +dependencies = [ + "curve25519-dalek", + "hashbrown", + "hex", + "rand_core 0.6.4", + "serde", + "sha2 0.9.9", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "forward_ref" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8cbd1169bd7b4a0a20d92b9af7a7e0422888bd38a6f5ec29c1fd8c1558a272e" + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[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 = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2 0.10.9", + "signature", +] + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-json-wasm" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9213a07d53faa0b8dd81e767a54a8188a242fdb9be99ab75ec576a774bfdd7" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/contracts/cosmos/Cargo.toml b/contracts/cosmos/Cargo.toml index 5c3cce1..9918635 100644 --- a/contracts/cosmos/Cargo.toml +++ b/contracts/cosmos/Cargo.toml @@ -14,6 +14,7 @@ cosmwasm-schema = "1.5.0" cw-storage-plus = "1.2.0" schemars = "0.8" serde = { version = "1.0", features = ["derive"] } +sha2 = "0.10" thiserror = "1.0" [dev-dependencies] diff --git a/contracts/cosmos/src/contract.rs b/contracts/cosmos/src/contract.rs index 61c989c..0c313c9 100644 --- a/contracts/cosmos/src/contract.rs +++ b/contracts/cosmos/src/contract.rs @@ -1,669 +1,788 @@ -use cosmwasm_std::{ - entry_point, to_json_binary, BankMsg, Binary, Coin, CosmosMsg, Deps, DepsMut, Env, - MessageInfo, Response, StdResult, Uint128, -}; - -use crate::error::ContractError; -use crate::msg::{ - ConfigResponse, ExecuteMsg, InstantiateMsg, MessageStatusResponse, PausedResponse, QueryMsg, - SequenceResponse, -}; -use crate::state::{Config, CONFIG, EXECUTED_MESSAGES, PAUSED, SEQUENCE}; - -// ─── Instantiate ──────────────────────────────────────────────────────────── - -#[entry_point] -pub fn instantiate( - deps: DepsMut, - _env: Env, - _info: MessageInfo, - msg: InstantiateMsg, -) -> Result { - let guardian = deps.api.addr_validate(&msg.guardian)?; - - CONFIG.save( - deps.storage, - &Config { - guardian, - hub_chain_id: msg.hub_chain_id, - fee_rate_bps: msg.fee_rate_bps, - }, - )?; - SEQUENCE.save(deps.storage, &0u64)?; - PAUSED.save(deps.storage, &false)?; - - Ok(Response::new() - .add_attribute("action", "instantiate") - .add_attribute("hub_chain_id", msg.hub_chain_id.to_string()) - .add_attribute("fee_rate_bps", msg.fee_rate_bps.to_string())) -} - -// ─── Execute ──────────────────────────────────────────────────────────────── - -#[entry_point] -pub fn execute( - deps: DepsMut, - env: Env, - info: MessageInfo, - msg: ExecuteMsg, -) -> Result { - match msg { - ExecuteMsg::SendCrossChainMessage { - recipient, - destination_chain, - payload, - } => execute_send(deps, env, info, recipient, destination_chain, payload), - - ExecuteMsg::ExecuteVerifiedMessage { - nonce, - recipient, - amount, - proof, - public_inputs, - } => execute_verified(deps, env, info, nonce, recipient, amount, proof, public_inputs), - - ExecuteMsg::InitiateSwap { - recipient, - destination_chain, - token_out, - min_amount_out, - swap_data, - } => execute_initiate_swap( - deps, - env, - info, - recipient, - destination_chain, - token_out, - min_amount_out, - swap_data, - ), - - ExecuteMsg::Pause {} => execute_pause(deps, info), - ExecuteMsg::Unpause {} => execute_unpause(deps, info), - ExecuteMsg::EmergencyWithdraw { denom } => { - execute_emergency_withdraw(deps, env, info, denom) - } - ExecuteMsg::UpdateGuardian { new_guardian } => { - execute_update_guardian(deps, info, new_guardian) - } - } -} - -/// Lock native tokens and emit a MessagePublished event for the relayer network -fn execute_send( - deps: DepsMut, - _env: Env, - info: MessageInfo, - recipient: Vec, - destination_chain: u16, - payload: Vec, -) -> Result { - assert_not_paused(deps.as_ref())?; - - // Require at least one coin sent - let total_value: u128 = info.funds.iter().map(|c| c.amount.u128()).sum(); - if total_value == 0 { - return Err(ContractError::ZeroAmount); - } - - // Increment sequence - let sequence = SEQUENCE.load(deps.storage)?; - let new_sequence = sequence + 1; - SEQUENCE.save(deps.storage, &new_sequence)?; - - // Compute payload hash (keccak256 equivalent via sha256 on cosmos) - let payload_hash = sha256_hash(&payload); - - Ok(Response::new() - .add_attribute("action", "send_cross_chain_message") - .add_attribute("sequence", new_sequence.to_string()) - .add_attribute("sender", info.sender.to_string()) - .add_attribute("recipient", hex::encode(&recipient)) - .add_attribute("amount", total_value.to_string()) - .add_attribute("destination_chain", destination_chain.to_string()) - .add_attribute("payload_hash", hex::encode(&payload_hash))) -} - -/// Execute a verified message from the Hub, releasing locked tokens to the recipient -fn execute_verified( - deps: DepsMut, - _env: Env, - _info: MessageInfo, - nonce: u64, - recipient: String, - amount: Uint128, - proof: Vec, - public_inputs: Vec, -) -> Result { - assert_not_paused(deps.as_ref())?; - - // Replay protection - if EXECUTED_MESSAGES - .may_load(deps.storage, nonce)? - .unwrap_or(false) - { - return Err(ContractError::AlreadyExecuted { nonce }); - } - - // Verify ZK proof - verify_proof(&proof, &public_inputs)?; - - // Mark as executed - EXECUTED_MESSAGES.save(deps.storage, nonce, &true)?; - - // Validate recipient address - let recipient_addr = deps.api.addr_validate(&recipient)?; - - // Extract denom from public inputs. The public inputs encode the original - // deposit information including the denom. We parse it here. - // Format: first byte = denom length, then denom bytes, rest is payload. - let denom = if public_inputs.len() > 1 { - let denom_len = public_inputs[0] as usize; - if public_inputs.len() >= 1 + denom_len && denom_len > 0 { - String::from_utf8(public_inputs[1..1 + denom_len].to_vec()) - .unwrap_or_else(|_| "uatom".to_string()) - } else { - "uatom".to_string() - } - } else { - "uatom".to_string() - }; - - // Apply fee - let config = CONFIG.load(deps.storage)?; - let fee_amount = amount - .checked_mul(Uint128::new(config.fee_rate_bps as u128)) - .unwrap_or(Uint128::zero()) - .checked_div(Uint128::new(10_000)) - .unwrap_or(Uint128::zero()); - let release_amount = amount.checked_sub(fee_amount).unwrap_or(amount); - - // Release funds to recipient - let send_msg = CosmosMsg::Bank(BankMsg::Send { - to_address: recipient_addr.to_string(), - amount: vec![Coin { - denom: denom.clone(), - amount: release_amount, - }], - }); - - Ok(Response::new() - .add_message(send_msg) - .add_attribute("action", "execute_verified_message") - .add_attribute("nonce", nonce.to_string()) - .add_attribute("recipient", recipient) - .add_attribute("denom", denom) - .add_attribute("amount", release_amount.to_string()) - .add_attribute("fee", fee_amount.to_string())) -} - -/// Initiate a cross-chain swap by locking input tokens -fn execute_initiate_swap( - deps: DepsMut, - _env: Env, - info: MessageInfo, - recipient: Vec, - destination_chain: u16, - token_out: String, - min_amount_out: Uint128, - swap_data: Vec, -) -> Result { - assert_not_paused(deps.as_ref())?; - - let total_value: u128 = info.funds.iter().map(|c| c.amount.u128()).sum(); - if total_value == 0 { - return Err(ContractError::ZeroAmount); - } - - let sequence = SEQUENCE.load(deps.storage)?; - let new_sequence = sequence + 1; - SEQUENCE.save(deps.storage, &new_sequence)?; - - let payload_hash = sha256_hash(&swap_data); - - Ok(Response::new() - .add_attribute("action", "initiate_swap") - .add_attribute("sequence", new_sequence.to_string()) - .add_attribute("sender", info.sender.to_string()) - .add_attribute("recipient", hex::encode(&recipient)) - .add_attribute("amount_in", total_value.to_string()) - .add_attribute("token_out", token_out) - .add_attribute("min_amount_out", min_amount_out.to_string()) - .add_attribute("destination_chain", destination_chain.to_string()) - .add_attribute("payload_hash", hex::encode(&payload_hash))) -} - -// ─── Admin Functions ──────────────────────────────────────────────────────── - -fn execute_pause(deps: DepsMut, info: MessageInfo) -> Result { - assert_guardian(deps.as_ref(), &info)?; - PAUSED.save(deps.storage, &true)?; - Ok(Response::new().add_attribute("action", "pause")) -} - -fn execute_unpause(deps: DepsMut, info: MessageInfo) -> Result { - assert_guardian(deps.as_ref(), &info)?; - PAUSED.save(deps.storage, &false)?; - Ok(Response::new().add_attribute("action", "unpause")) -} - -fn execute_emergency_withdraw( - deps: DepsMut, - env: Env, - info: MessageInfo, - denom: String, -) -> Result { - assert_guardian(deps.as_ref(), &info)?; - - let config = CONFIG.load(deps.storage)?; - let balance = deps.querier.query_balance(env.contract.address, &denom)?; - - let send_msg = CosmosMsg::Bank(BankMsg::Send { - to_address: config.guardian.to_string(), - amount: vec![balance], - }); - - Ok(Response::new() - .add_message(send_msg) - .add_attribute("action", "emergency_withdraw") - .add_attribute("denom", denom)) -} - -fn execute_update_guardian( - deps: DepsMut, - info: MessageInfo, - new_guardian: String, -) -> Result { - assert_guardian(deps.as_ref(), &info)?; - - let new_addr = deps.api.addr_validate(&new_guardian)?; - CONFIG.update(deps.storage, |mut config| -> StdResult<_> { - config.guardian = new_addr; - Ok(config) - })?; - - Ok(Response::new() - .add_attribute("action", "update_guardian") - .add_attribute("new_guardian", new_guardian)) -} - -// ─── Query ────────────────────────────────────────────────────────────────── - -#[entry_point] -pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { - match msg { - QueryMsg::Config {} => { - let config = CONFIG.load(deps.storage)?; - to_json_binary(&ConfigResponse { - guardian: config.guardian.to_string(), - hub_chain_id: config.hub_chain_id, - fee_rate_bps: config.fee_rate_bps, - }) - } - QueryMsg::Sequence {} => { - let sequence = SEQUENCE.load(deps.storage)?; - to_json_binary(&SequenceResponse { sequence }) - } - QueryMsg::MessageStatus { nonce } => { - let executed = EXECUTED_MESSAGES - .may_load(deps.storage, nonce)? - .unwrap_or(false); - to_json_binary(&MessageStatusResponse { nonce, executed }) - } - QueryMsg::Paused {} => { - let paused = PAUSED.load(deps.storage)?; - to_json_binary(&PausedResponse { paused }) - } - } -} - -// ─── Helpers ──────────────────────────────────────────────────────────────── - -fn assert_guardian(deps: Deps, info: &MessageInfo) -> Result<(), ContractError> { - let config = CONFIG.load(deps.storage)?; - if info.sender != config.guardian { - return Err(ContractError::Unauthorized); - } - Ok(()) -} - -fn assert_not_paused(deps: Deps) -> Result<(), ContractError> { - let paused = PAUSED.load(deps.storage)?; - if paused { - return Err(ContractError::Paused); - } - Ok(()) -} - -/// Verify a ZK proof against public inputs. -/// -/// Validates the BN254 Groth16 proof structure and binding to public inputs. -/// CosmWasm does not currently expose a native BN254 pairing precompile, so we -/// perform structural validation and public input binding verification. -/// -/// The proof is considered valid if: -/// 1. It is exactly 256 bytes (2 G1 + 1 G2 point) -/// 2. No point component is all zeros (point at infinity = invalid proof) -/// 3. Public inputs hash matches the domain-separated commitment -/// -/// NOTE: Full pairing verification (e(A,B) * e(C',-G2) == 1) requires either -/// a chain with BN254 precompile support or a pure-Rust BN254 library. -/// When deploying on chains like Neutron or Osmosis with custom precompiles, -/// replace the structural check with a real pairing call. -fn verify_proof(proof: &[u8], public_inputs: &[u8]) -> Result<(), ContractError> { - // Proof must be exactly 256 bytes (BN254 Groth16: A(G1, 64B) + B(G2, 128B) + C(G1, 64B)) - if proof.len() != 256 { - return Err(ContractError::InvalidProof); - } - - // Validate none of the curve points are the point at infinity (all zeros) - let a_g1 = &proof[0..64]; - let b_g2 = &proof[64..192]; - let c_g1 = &proof[192..256]; - - if a_g1.iter().all(|&b| b == 0) - || b_g2.iter().all(|&b| b == 0) - || c_g1.iter().all(|&b| b == 0) - { - return Err(ContractError::InvalidProof); - } - - // Verify public input binding: the proof must commit to the correct payload. - // Compute domain-separated hash: SHA-256(public_inputs || "interlink_v1_domain") - // This must match what the relayer's prover circuit committed to. - if public_inputs.is_empty() { - return Err(ContractError::InvalidProof); - } - - let mut binding_data = Vec::with_capacity(public_inputs.len() + 19); - binding_data.extend_from_slice(public_inputs); - binding_data.extend_from_slice(b"interlink_v1_domain"); - let binding_hash = sha256_hash(&binding_data); - - // Verify the binding hash is embedded in the proof's C point region. - // The last 32 bytes of C (bytes 224..256) should contain the truncated binding hash. - // This is a soft check — full verification requires the pairing precompile. - let c_binding = &proof[224..256]; - if c_binding.iter().all(|&b| b == 0) { - return Err(ContractError::InvalidProof); - } - - // Log the binding hash for relayer verification - let _ = binding_hash; // Used in full pairing mode - - Ok(()) -} - -/// Compute SHA-256 hash using cosmwasm_std's built-in API. -fn sha256_hash(data: &[u8]) -> Vec { - // cosmwasm_std provides SHA-256 via the crypto API. - // Since we don't have deps.api here, we use a manual implementation. - // SHA-256 initial hash values (first 32 bits of fractional parts of sqrt of first 8 primes) - // - // For production, pass deps.api and use the native crypto functions. - // Here we use a compact SHA-256 via the sha2 approach. - // - // Simple but correct: use the cosmwasm hash helpers - // cosmwasm_std doesn't expose raw sha256 directly in all versions, - // so we implement a minimal version using the available primitives. - - // In CosmWasm 1.5+, we can use cosmwasm_std::HexBinary and hash via contract API. - // Fallback: use a known-good compact implementation. - sha2_256(data).to_vec() -} - -/// Minimal SHA-256 implementation for CosmWasm environments. -/// In production on chains with cosmwasm_crypto, use deps.api.sha256() instead. -fn sha2_256(data: &[u8]) -> [u8; 32] { - use std::num::Wrapping; - - const K: [u32; 64] = [ - 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, - 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, - 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, - 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, - 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, - 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, - 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, - 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, - ]; - - let mut h: [Wrapping; 8] = [ - Wrapping(0x6a09e667), Wrapping(0xbb67ae85), Wrapping(0x3c6ef372), Wrapping(0xa54ff53a), - Wrapping(0x510e527f), Wrapping(0x9b05688c), Wrapping(0x1f83d9ab), Wrapping(0x5be0cd19), - ]; - - // Pre-processing: padding - let bit_len = (data.len() as u64) * 8; - let mut msg = data.to_vec(); - msg.push(0x80); - while (msg.len() % 64) != 56 { - msg.push(0); - } - msg.extend_from_slice(&bit_len.to_be_bytes()); - - // Process each 512-bit (64-byte) block - for chunk in msg.chunks(64) { - let mut w = [Wrapping(0u32); 64]; - for i in 0..16 { - w[i] = Wrapping(u32::from_be_bytes([ - chunk[4 * i], - chunk[4 * i + 1], - chunk[4 * i + 2], - chunk[4 * i + 3], - ])); - } - for i in 16..64 { - let s0 = (w[i - 15].0.rotate_right(7)) ^ (w[i - 15].0.rotate_right(18)) ^ (w[i - 15].0 >> 3); - let s1 = (w[i - 2].0.rotate_right(17)) ^ (w[i - 2].0.rotate_right(19)) ^ (w[i - 2].0 >> 10); - w[i] = w[i - 16] + Wrapping(s0) + w[i - 7] + Wrapping(s1); - } - - let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh] = h; - - for i in 0..64 { - let s1 = Wrapping(e.0.rotate_right(6) ^ e.0.rotate_right(11) ^ e.0.rotate_right(25)); - let ch = Wrapping((e.0 & f.0) ^ ((!e.0) & g.0)); - let temp1 = hh + s1 + ch + Wrapping(K[i]) + w[i]; - let s0 = Wrapping(a.0.rotate_right(2) ^ a.0.rotate_right(13) ^ a.0.rotate_right(22)); - let maj = Wrapping((a.0 & b.0) ^ (a.0 & c.0) ^ (b.0 & c.0)); - let temp2 = s0 + maj; - - hh = g; - g = f; - f = e; - e = d + temp1; - d = c; - c = b; - b = a; - a = temp1 + temp2; - } - - h[0] = h[0] + a; h[1] = h[1] + b; h[2] = h[2] + c; h[3] = h[3] + d; - h[4] = h[4] + e; h[5] = h[5] + f; h[6] = h[6] + g; h[7] = h[7] + hh; - } - - let mut result = [0u8; 32]; - for i in 0..8 { - result[4 * i..4 * i + 4].copy_from_slice(&h[i].0.to_be_bytes()); - } - result -} - -// We need hex encoding for attributes -mod hex { - pub fn encode(data: &[u8]) -> String { - data.iter().map(|b| format!("{:02x}", b)).collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; - use cosmwasm_std::{coins, Addr}; - - fn setup() -> (cosmwasm_std::OwnedDeps, Env) { - let mut deps = mock_dependencies(); - let env = mock_env(); - let info = mock_info("guardian_addr", &[]); - - let msg = InstantiateMsg { - guardian: "guardian_addr".to_string(), - hub_chain_id: 2, // Solana - fee_rate_bps: 10, - }; - - instantiate(deps.as_mut(), env.clone(), info, msg).unwrap(); - (deps, env) - } - - #[test] - fn test_instantiate() { - let (deps, _env) = setup(); - let config = CONFIG.load(deps.as_ref().storage).unwrap(); - assert_eq!(config.guardian, Addr::unchecked("guardian_addr")); - assert_eq!(config.hub_chain_id, 2); - assert_eq!(config.fee_rate_bps, 10); - - let seq = SEQUENCE.load(deps.as_ref().storage).unwrap(); - assert_eq!(seq, 0); - - let paused = PAUSED.load(deps.as_ref().storage).unwrap(); - assert!(!paused); - } - - #[test] - fn test_send_cross_chain_message() { - let (mut deps, env) = setup(); - let info = mock_info("user1", &coins(1000, "uatom")); - - let msg = ExecuteMsg::SendCrossChainMessage { - recipient: vec![0xAA; 32], - destination_chain: 1, // Ethereum - payload: vec![1, 2, 3], - }; - - let res = execute(deps.as_mut(), env, info, msg).unwrap(); - assert_eq!(res.attributes.len(), 7); - - // Sequence should be 1 - let seq = SEQUENCE.load(deps.as_ref().storage).unwrap(); - assert_eq!(seq, 1); - } - - #[test] - fn test_send_zero_amount_fails() { - let (mut deps, env) = setup(); - let info = mock_info("user1", &[]); - - let msg = ExecuteMsg::SendCrossChainMessage { - recipient: vec![0xAA; 32], - destination_chain: 1, - payload: vec![], - }; - - let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); - assert!(matches!(err, ContractError::ZeroAmount)); - } - - #[test] - fn test_pause_unpause() { - let (mut deps, env) = setup(); - let info = mock_info("guardian_addr", &[]); - - // Pause - execute(deps.as_mut(), env.clone(), info.clone(), ExecuteMsg::Pause {}).unwrap(); - assert!(PAUSED.load(deps.as_ref().storage).unwrap()); - - // Sending should fail while paused - let user_info = mock_info("user1", &coins(1000, "uatom")); - let send_msg = ExecuteMsg::SendCrossChainMessage { - recipient: vec![], - destination_chain: 1, - payload: vec![], - }; - let err = execute(deps.as_mut(), env.clone(), user_info, send_msg).unwrap_err(); - assert!(matches!(err, ContractError::Paused)); - - // Unpause - execute(deps.as_mut(), env, info, ExecuteMsg::Unpause {}).unwrap(); - assert!(!PAUSED.load(deps.as_ref().storage).unwrap()); - } - - #[test] - fn test_unauthorized_pause() { - let (mut deps, env) = setup(); - let info = mock_info("not_guardian", &[]); - - let err = execute(deps.as_mut(), env, info, ExecuteMsg::Pause {}).unwrap_err(); - assert!(matches!(err, ContractError::Unauthorized)); - } - - #[test] - fn test_execute_verified_message() { - let (mut deps, env) = setup(); - let info = mock_info("relayer1", &[]); - - // Create a valid 256-byte proof (non-zero) - let proof = vec![0xAB; 256]; - - let msg = ExecuteMsg::ExecuteVerifiedMessage { - nonce: 1, - recipient: "recipient_addr".to_string(), - amount: Uint128::new(500), - proof, - public_inputs: vec![1, 2, 3], - }; - - let res = execute(deps.as_mut(), env.clone(), info.clone(), msg).unwrap(); - assert_eq!(res.messages.len(), 1); // Bank send message - - // Replay should fail - let msg2 = ExecuteMsg::ExecuteVerifiedMessage { - nonce: 1, - recipient: "recipient_addr".to_string(), - amount: Uint128::new(500), - proof: vec![0xAB; 256], - public_inputs: vec![1, 2, 3], - }; - let err = execute(deps.as_mut(), env, info, msg2).unwrap_err(); - assert!(matches!(err, ContractError::AlreadyExecuted { nonce: 1 })); - } - - #[test] - fn test_invalid_proof_rejected() { - let (mut deps, env) = setup(); - let info = mock_info("relayer1", &[]); - - // Wrong length proof - let msg = ExecuteMsg::ExecuteVerifiedMessage { - nonce: 2, - recipient: "recipient_addr".to_string(), - amount: Uint128::new(500), - proof: vec![0xAB; 100], // Wrong length - public_inputs: vec![], - }; - - let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); - assert!(matches!(err, ContractError::InvalidProof)); - } - - #[test] - fn test_query_config() { - let (deps, env) = setup(); - let res = query(deps.as_ref(), env, QueryMsg::Config {}).unwrap(); - let config: ConfigResponse = cosmwasm_std::from_json(res).unwrap(); - assert_eq!(config.guardian, "guardian_addr"); - assert_eq!(config.hub_chain_id, 2); - } - - #[test] - fn test_query_sequence() { - let (deps, env) = setup(); - let res = query(deps.as_ref(), env, QueryMsg::Sequence {}).unwrap(); - let seq: SequenceResponse = cosmwasm_std::from_json(res).unwrap(); - assert_eq!(seq.sequence, 0); - } -} +use cosmwasm_std::{ + entry_point, to_json_binary, Addr, BankMsg, Binary, Coin, CosmosMsg, Deps, DepsMut, Env, + MessageInfo, Response, StdResult, Uint128, +}; +use sha2::{Digest, Sha256}; + +use crate::error::ContractError; +use crate::msg::{ + ConfigResponse, ExecuteMsg, InstantiateMsg, MessageStatusResponse, PausedResponse, QueryMsg, + SequenceResponse, +}; +use crate::state::{Config, CONFIG, EXECUTED_MESSAGES, PAUSED, PENDING_GUARDIAN, SEQUENCE}; + +/// Upper bound on the bridge fee (10%). Prevents a misconfigured instance +/// from confiscating most of every transfer. +pub const MAX_FEE_RATE_BPS: u16 = 1_000; + +/// Domain tags so a hash computed for one purpose can never be reused for another. +const OUTBOUND_DOMAIN: &[u8] = b"interlink_v1/cosmos/outbound"; +const INBOUND_DOMAIN: &[u8] = b"interlink_v1/cosmos/inbound"; + +// ─── Instantiate ──────────────────────────────────────────────────────────── + +#[entry_point] +pub fn instantiate( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + if msg.fee_rate_bps > MAX_FEE_RATE_BPS { + return Err(ContractError::InvalidFeeRate { + fee_rate_bps: msg.fee_rate_bps, + max: MAX_FEE_RATE_BPS, + }); + } + let guardian = deps.api.addr_validate(&msg.guardian)?; + + CONFIG.save( + deps.storage, + &Config { + guardian, + hub_chain_id: msg.hub_chain_id, + fee_rate_bps: msg.fee_rate_bps, + }, + )?; + SEQUENCE.save(deps.storage, &0u64)?; + PAUSED.save(deps.storage, &false)?; + + Ok(Response::new() + .add_attribute("action", "instantiate") + .add_attribute("hub_chain_id", msg.hub_chain_id.to_string()) + .add_attribute("fee_rate_bps", msg.fee_rate_bps.to_string())) +} + +// ─── Execute ──────────────────────────────────────────────────────────────── + +#[entry_point] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::SendCrossChainMessage { + recipient, + destination_chain, + payload, + } => execute_send(deps, env, info, recipient, destination_chain, payload), + + ExecuteMsg::ExecuteVerifiedMessage { + source_chain, + nonce, + recipient, + denom, + amount, + proof, + } => execute_verified( + deps, + env, + source_chain, + nonce, + recipient, + denom, + amount, + proof, + ), + + ExecuteMsg::InitiateSwap { + recipient, + destination_chain, + token_out, + min_amount_out, + swap_data, + } => execute_initiate_swap( + deps, + env, + info, + recipient, + destination_chain, + token_out, + min_amount_out, + swap_data, + ), + + ExecuteMsg::Pause {} => execute_pause(deps, info), + ExecuteMsg::Unpause {} => execute_unpause(deps, info), + ExecuteMsg::EmergencyWithdraw { denom } => { + execute_emergency_withdraw(deps, env, info, denom) + } + ExecuteMsg::ProposeGuardian { new_guardian } => { + execute_propose_guardian(deps, info, new_guardian) + } + ExecuteMsg::AcceptGuardian {} => execute_accept_guardian(deps, info), + } +} + +/// Lock exactly one native coin and emit a MessagePublished event for the relayer network. +fn execute_send( + deps: DepsMut, + env: Env, + info: MessageInfo, + recipient: Vec, + destination_chain: u16, + payload: Vec, +) -> Result { + assert_not_paused(deps.as_ref())?; + validate_destination(destination_chain)?; + let coin = one_coin(&info)?; + + let sequence = next_sequence(deps.storage)?; + let payload_hash = outbound_hash( + &env, + sequence, + &info.sender, + &recipient, + &coin, + destination_chain, + &[b"send", &Sha256::digest(&payload)], + ); + + Ok(Response::new() + .add_attribute("action", "send_cross_chain_message") + .add_attribute("sequence", sequence.to_string()) + .add_attribute("sender", info.sender.to_string()) + .add_attribute("recipient", hex::encode(&recipient)) + .add_attribute("denom", coin.denom) + .add_attribute("amount", coin.amount.to_string()) + .add_attribute("destination_chain", destination_chain.to_string()) + .add_attribute("payload_hash", hex::encode(&payload_hash))) +} + +/// Execute a verified message from the Hub, releasing locked tokens to the recipient. +#[allow(clippy::too_many_arguments)] +fn execute_verified( + deps: DepsMut, + env: Env, + source_chain: u64, + nonce: u64, + recipient: String, + denom: String, + amount: Uint128, + proof: Vec, +) -> Result { + assert_not_paused(deps.as_ref())?; + + if EXECUTED_MESSAGES + .may_load(deps.storage, (source_chain, nonce))? + .unwrap_or(false) + { + return Err(ContractError::AlreadyExecuted { + source_chain, + nonce, + }); + } + if amount.is_zero() { + return Err(ContractError::ZeroAmount); + } + let recipient_addr = deps.api.addr_validate(&recipient)?; + + // Everything that decides where funds go is hashed here, on-chain, and + // becomes the proof's public input. The caller cannot substitute values. + let payload_hash = inbound_hash(&env, source_chain, nonce, &recipient_addr, &denom, amount); + verify_proof(&proof, &payload_hash, nonce)?; + + EXECUTED_MESSAGES.save(deps.storage, (source_chain, nonce), &true)?; + + let config = CONFIG.load(deps.storage)?; + let fee_amount = amount.multiply_ratio(config.fee_rate_bps as u128, 10_000u128); + let release_amount = amount - fee_amount; + + let send_msg = CosmosMsg::Bank(BankMsg::Send { + to_address: recipient_addr.to_string(), + amount: vec![Coin { + denom: denom.clone(), + amount: release_amount, + }], + }); + + Ok(Response::new() + .add_message(send_msg) + .add_attribute("action", "execute_verified_message") + .add_attribute("source_chain", source_chain.to_string()) + .add_attribute("nonce", nonce.to_string()) + .add_attribute("recipient", recipient) + .add_attribute("denom", denom) + .add_attribute("amount", release_amount.to_string()) + .add_attribute("fee", fee_amount.to_string())) +} + +/// Initiate a cross-chain swap by locking exactly one input coin. +#[allow(clippy::too_many_arguments)] +fn execute_initiate_swap( + deps: DepsMut, + env: Env, + info: MessageInfo, + recipient: Vec, + destination_chain: u16, + token_out: String, + min_amount_out: Uint128, + swap_data: Vec, +) -> Result { + assert_not_paused(deps.as_ref())?; + validate_destination(destination_chain)?; + let coin = one_coin(&info)?; + + let sequence = next_sequence(deps.storage)?; + let payload_hash = outbound_hash( + &env, + sequence, + &info.sender, + &recipient, + &coin, + destination_chain, + &[ + b"swap", + token_out.as_bytes(), + &min_amount_out.u128().to_be_bytes(), + &Sha256::digest(&swap_data), + ], + ); + + Ok(Response::new() + .add_attribute("action", "initiate_swap") + .add_attribute("sequence", sequence.to_string()) + .add_attribute("sender", info.sender.to_string()) + .add_attribute("recipient", hex::encode(&recipient)) + .add_attribute("denom", coin.denom) + .add_attribute("amount_in", coin.amount.to_string()) + .add_attribute("token_out", token_out) + .add_attribute("min_amount_out", min_amount_out.to_string()) + .add_attribute("destination_chain", destination_chain.to_string()) + .add_attribute("payload_hash", hex::encode(&payload_hash))) +} + +// ─── Admin Functions ──────────────────────────────────────────────────────── + +fn execute_pause(deps: DepsMut, info: MessageInfo) -> Result { + assert_guardian(deps.as_ref(), &info)?; + PAUSED.save(deps.storage, &true)?; + Ok(Response::new().add_attribute("action", "pause")) +} + +fn execute_unpause(deps: DepsMut, info: MessageInfo) -> Result { + assert_guardian(deps.as_ref(), &info)?; + PAUSED.save(deps.storage, &false)?; + Ok(Response::new().add_attribute("action", "unpause")) +} + +fn execute_emergency_withdraw( + deps: DepsMut, + env: Env, + info: MessageInfo, + denom: String, +) -> Result { + assert_guardian(deps.as_ref(), &info)?; + if !PAUSED.load(deps.storage)? { + return Err(ContractError::NotPaused); + } + + let config = CONFIG.load(deps.storage)?; + let balance = deps.querier.query_balance(env.contract.address, &denom)?; + + let send_msg = CosmosMsg::Bank(BankMsg::Send { + to_address: config.guardian.to_string(), + amount: vec![balance], + }); + + Ok(Response::new() + .add_message(send_msg) + .add_attribute("action", "emergency_withdraw") + .add_attribute("denom", denom)) +} + +fn execute_propose_guardian( + deps: DepsMut, + info: MessageInfo, + new_guardian: String, +) -> Result { + assert_guardian(deps.as_ref(), &info)?; + let new_addr = deps.api.addr_validate(&new_guardian)?; + PENDING_GUARDIAN.save(deps.storage, &new_addr)?; + + Ok(Response::new() + .add_attribute("action", "propose_guardian") + .add_attribute("pending_guardian", new_guardian)) +} + +fn execute_accept_guardian(deps: DepsMut, info: MessageInfo) -> Result { + let pending = PENDING_GUARDIAN + .may_load(deps.storage)? + .ok_or(ContractError::NoPendingGuardian)?; + if info.sender != pending { + return Err(ContractError::Unauthorized); + } + CONFIG.update(deps.storage, |mut config| -> StdResult<_> { + config.guardian = pending.clone(); + Ok(config) + })?; + PENDING_GUARDIAN.remove(deps.storage); + + Ok(Response::new() + .add_attribute("action", "accept_guardian") + .add_attribute("guardian", pending.to_string())) +} + +// ─── Query ────────────────────────────────────────────────────────────────── + +#[entry_point] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Config {} => { + let config = CONFIG.load(deps.storage)?; + let pending = PENDING_GUARDIAN.may_load(deps.storage)?; + to_json_binary(&ConfigResponse { + guardian: config.guardian.to_string(), + pending_guardian: pending.map(|a| a.to_string()), + hub_chain_id: config.hub_chain_id, + fee_rate_bps: config.fee_rate_bps, + }) + } + QueryMsg::Sequence {} => { + let sequence = SEQUENCE.load(deps.storage)?; + to_json_binary(&SequenceResponse { sequence }) + } + QueryMsg::MessageStatus { + source_chain, + nonce, + } => { + let executed = EXECUTED_MESSAGES + .may_load(deps.storage, (source_chain, nonce))? + .unwrap_or(false); + to_json_binary(&MessageStatusResponse { + source_chain, + nonce, + executed, + }) + } + QueryMsg::Paused {} => { + let paused = PAUSED.load(deps.storage)?; + to_json_binary(&PausedResponse { paused }) + } + } +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +fn assert_guardian(deps: Deps, info: &MessageInfo) -> Result<(), ContractError> { + let config = CONFIG.load(deps.storage)?; + if info.sender != config.guardian { + return Err(ContractError::Unauthorized); + } + Ok(()) +} + +fn assert_not_paused(deps: Deps) -> Result<(), ContractError> { + if PAUSED.load(deps.storage)? { + return Err(ContractError::Paused); + } + Ok(()) +} + +fn validate_destination(destination_chain: u16) -> Result<(), ContractError> { + if destination_chain == 0 { + return Err(ContractError::InvalidDestinationChain { + chain_id: destination_chain, + }); + } + Ok(()) +} + +/// Require exactly one non-zero coin. The previous version summed amounts +/// across *different* denoms, so 1 ATOM + 1,000 worthless IBC tokens was +/// reported as "1001" with no denom, letting the other side mint the wrong asset. +fn one_coin(info: &MessageInfo) -> Result { + match info.funds.as_slice() { + [] => Err(ContractError::ZeroAmount), + [coin] if coin.amount.is_zero() => Err(ContractError::ZeroAmount), + [coin] => Ok(coin.clone()), + _ => Err(ContractError::MultipleDenoms), + } +} + +/// Return the current sequence and advance the counter. +fn next_sequence(storage: &mut dyn cosmwasm_std::Storage) -> StdResult { + let sequence = SEQUENCE.load(storage)?; + SEQUENCE.save(storage, &(sequence + 1))?; + Ok(sequence) +} + +/// SHA-256 over length-prefixed fields. Length prefixes stop two different +/// field lists from concatenating to the same bytes. +fn hash_fields(fields: &[&[u8]]) -> [u8; 32] { + let mut hasher = Sha256::new(); + for field in fields { + hasher.update((field.len() as u32).to_be_bytes()); + hasher.update(field); + } + hasher.finalize().into() +} + +/// Commitment to an outbound message. Binds this chain and contract so the +/// same event cannot be replayed as if it came from another deployment. +fn outbound_hash( + env: &Env, + sequence: u64, + sender: &Addr, + recipient: &[u8], + coin: &Coin, + destination_chain: u16, + extra: &[&[u8]], +) -> [u8; 32] { + let mut fields: Vec<&[u8]> = vec![OUTBOUND_DOMAIN]; + let seq = sequence.to_be_bytes(); + let amount = coin.amount.u128().to_be_bytes(); + let dest = destination_chain.to_be_bytes(); + fields.extend_from_slice(&[ + env.block.chain_id.as_bytes(), + env.contract.address.as_bytes(), + &seq, + sender.as_bytes(), + recipient, + coin.denom.as_bytes(), + &amount, + &dest, + ]); + fields.extend_from_slice(extra); + hash_fields(&fields) +} + +/// Commitment to an inbound message. This is what the proof must attest to. +fn inbound_hash( + env: &Env, + source_chain: u64, + nonce: u64, + recipient: &Addr, + denom: &str, + amount: Uint128, +) -> [u8; 32] { + hash_fields(&[ + INBOUND_DOMAIN, + env.block.chain_id.as_bytes(), + env.contract.address.as_bytes(), + &source_chain.to_be_bytes(), + &nonce.to_be_bytes(), + recipient.as_bytes(), + denom.as_bytes(), + &amount.u128().to_be_bytes(), + ]) +} + +/// Verify a proof that `payload_hash` was emitted by the source chain. +/// +/// **This always fails, on purpose.** The previous implementation accepted any +/// 256-byte blob that was not all zeros, which let anyone call +/// `ExecuteVerifiedMessage` with `[0xAB; 256]` and drain every denom held by +/// the contract. +/// +/// CosmWasm 1.5 has no BN254 pairing host function, and a pure-Wasm pairing +/// costs far more gas than a block allows. Options for a real verifier: +/// - Re-target the proof system to BLS12-381 and use the native +/// `bls12_381_pairing_equality` host function (CosmWasm 2.1+). +/// - Deploy on a chain with a BN254 precompile/module and call it here. +/// - Verify on the Solana hub and relay to Cosmos over IBC with a light client. +/// +/// Until one of those exists, inbound messages stay disabled. +fn verify_proof(_proof: &[u8], _payload_hash: &[u8; 32], _nonce: u64) -> Result<(), ContractError> { + Err(ContractError::VerifierUnavailable) +} + +mod hex { + pub fn encode(data: &[u8]) -> String { + data.iter().map(|b| format!("{:02x}", b)).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; + use cosmwasm_std::{coin, coins, Addr}; + + type Deps = cosmwasm_std::OwnedDeps< + cosmwasm_std::MemoryStorage, + cosmwasm_std::testing::MockApi, + cosmwasm_std::testing::MockQuerier, + >; + + fn setup() -> (Deps, Env) { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = mock_info("guardian_addr", &[]); + + let msg = InstantiateMsg { + guardian: "guardian_addr".to_string(), + hub_chain_id: 2, // Solana + fee_rate_bps: 10, + }; + + instantiate(deps.as_mut(), env.clone(), info, msg).unwrap(); + (deps, env) + } + + fn attr<'a>(res: &'a Response, key: &str) -> &'a str { + &res.attributes.iter().find(|a| a.key == key).unwrap().value + } + + #[test] + fn test_instantiate() { + let (deps, _env) = setup(); + let config = CONFIG.load(deps.as_ref().storage).unwrap(); + assert_eq!(config.guardian, Addr::unchecked("guardian_addr")); + assert_eq!(config.hub_chain_id, 2); + assert_eq!(config.fee_rate_bps, 10); + assert_eq!(SEQUENCE.load(deps.as_ref().storage).unwrap(), 0); + assert!(!PAUSED.load(deps.as_ref().storage).unwrap()); + } + + #[test] + fn test_instantiate_rejects_excessive_fee() { + let mut deps = mock_dependencies(); + let msg = InstantiateMsg { + guardian: "guardian_addr".to_string(), + hub_chain_id: 2, + fee_rate_bps: MAX_FEE_RATE_BPS + 1, + }; + let err = instantiate(deps.as_mut(), mock_env(), mock_info("x", &[]), msg).unwrap_err(); + assert!(matches!(err, ContractError::InvalidFeeRate { .. })); + } + + #[test] + fn test_send_cross_chain_message() { + let (mut deps, env) = setup(); + let info = mock_info("user1", &coins(1000, "uatom")); + + let msg = ExecuteMsg::SendCrossChainMessage { + recipient: vec![0xAA; 32], + destination_chain: 1, // Ethereum + payload: vec![1, 2, 3], + }; + + let res = execute(deps.as_mut(), env, info, msg).unwrap(); + // First message is sequence 0 (matches EVM gateway); counter advances to 1. + assert_eq!(attr(&res, "sequence"), "0"); + assert_eq!(attr(&res, "denom"), "uatom"); + assert_eq!(attr(&res, "amount"), "1000"); + assert_eq!(SEQUENCE.load(deps.as_ref().storage).unwrap(), 1); + } + + #[test] + fn test_send_rejects_multiple_denoms() { + let (mut deps, env) = setup(); + // Previously summed to "1001" with no denom recorded. + let info = mock_info("user1", &[coin(1, "uatom"), coin(1000, "ibc/WORTHLESS")]); + let msg = ExecuteMsg::SendCrossChainMessage { + recipient: vec![0xAA; 32], + destination_chain: 1, + payload: vec![], + }; + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert!(matches!(err, ContractError::MultipleDenoms)); + } + + #[test] + fn test_payload_hash_binds_amount_and_denom() { + let (mut deps, env) = setup(); + let send = |deps: &mut Deps, funds: Vec| { + let msg = ExecuteMsg::SendCrossChainMessage { + recipient: vec![0xAA; 32], + destination_chain: 1, + payload: vec![1, 2, 3], + }; + let res = execute(deps.as_mut(), env.clone(), mock_info("user1", &funds), msg).unwrap(); + attr(&res, "payload_hash").to_string() + }; + let h1 = send(&mut deps, coins(1000, "uatom")); + let h2 = send(&mut deps, coins(1000, "uosmo")); + // Different denoms (and sequences) must never produce the same commitment. + assert_ne!(h1, h2); + } + + #[test] + fn test_send_zero_amount_fails() { + let (mut deps, env) = setup(); + let info = mock_info("user1", &[]); + + let msg = ExecuteMsg::SendCrossChainMessage { + recipient: vec![0xAA; 32], + destination_chain: 1, + payload: vec![], + }; + + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert!(matches!(err, ContractError::ZeroAmount)); + } + + #[test] + fn test_pause_unpause() { + let (mut deps, env) = setup(); + let info = mock_info("guardian_addr", &[]); + + execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::Pause {}, + ) + .unwrap(); + assert!(PAUSED.load(deps.as_ref().storage).unwrap()); + + let user_info = mock_info("user1", &coins(1000, "uatom")); + let send_msg = ExecuteMsg::SendCrossChainMessage { + recipient: vec![], + destination_chain: 1, + payload: vec![], + }; + let err = execute(deps.as_mut(), env.clone(), user_info, send_msg).unwrap_err(); + assert!(matches!(err, ContractError::Paused)); + + execute(deps.as_mut(), env, info, ExecuteMsg::Unpause {}).unwrap(); + assert!(!PAUSED.load(deps.as_ref().storage).unwrap()); + } + + #[test] + fn test_unauthorized_pause() { + let (mut deps, env) = setup(); + let info = mock_info("not_guardian", &[]); + + let err = execute(deps.as_mut(), env, info, ExecuteMsg::Pause {}).unwrap_err(); + assert!(matches!(err, ContractError::Unauthorized)); + } + + /// Regression test for the critical drain: the old verifier accepted any + /// non-zero 256-byte blob, so this exact call released funds. + #[test] + fn test_execute_verified_message_rejects_forged_proof() { + let (mut deps, env) = setup(); + let msg = ExecuteMsg::ExecuteVerifiedMessage { + source_chain: 1, + nonce: 1, + recipient: "attacker".to_string(), + denom: "uatom".to_string(), + amount: Uint128::new(500), + proof: vec![0xAB; 256], + }; + let err = execute(deps.as_mut(), env, mock_info("anyone", &[]), msg).unwrap_err(); + assert!(matches!(err, ContractError::VerifierUnavailable)); + + // Nothing was marked executed. + assert!(EXECUTED_MESSAGES + .may_load(deps.as_ref().storage, (1, 1)) + .unwrap() + .is_none()); + } + + #[test] + fn test_replay_is_scoped_per_source_chain() { + let (mut deps, env) = setup(); + EXECUTED_MESSAGES + .save(deps.as_mut().storage, (1, 7), &true) + .unwrap(); + + let msg = |source_chain| ExecuteMsg::ExecuteVerifiedMessage { + source_chain, + nonce: 7, + recipient: "recipient_addr".to_string(), + denom: "uatom".to_string(), + amount: Uint128::new(500), + proof: vec![0xAB; 256], + }; + + let err = execute(deps.as_mut(), env.clone(), mock_info("r", &[]), msg(1)).unwrap_err(); + assert!(matches!( + err, + ContractError::AlreadyExecuted { + source_chain: 1, + nonce: 7 + } + )); + + // Same nonce from another chain is not blocked by the replay guard + // (it fails later only because no verifier is configured). + let err = execute(deps.as_mut(), env, mock_info("r", &[]), msg(2)).unwrap_err(); + assert!(matches!(err, ContractError::VerifierUnavailable)); + } + + #[test] + fn test_emergency_withdraw_requires_pause() { + let (mut deps, env) = setup(); + let guardian = mock_info("guardian_addr", &[]); + let withdraw = || ExecuteMsg::EmergencyWithdraw { + denom: "uatom".to_string(), + }; + + let err = execute(deps.as_mut(), env.clone(), guardian.clone(), withdraw()).unwrap_err(); + assert!(matches!(err, ContractError::NotPaused)); + + execute( + deps.as_mut(), + env.clone(), + guardian.clone(), + ExecuteMsg::Pause {}, + ) + .unwrap(); + execute(deps.as_mut(), env, guardian, withdraw()).unwrap(); + } + + #[test] + fn test_two_step_guardian_transfer() { + let (mut deps, env) = setup(); + let propose = ExecuteMsg::ProposeGuardian { + new_guardian: "new_guardian".to_string(), + }; + execute( + deps.as_mut(), + env.clone(), + mock_info("guardian_addr", &[]), + propose, + ) + .unwrap(); + + // Still the old guardian until accepted. + assert_eq!( + CONFIG.load(deps.as_ref().storage).unwrap().guardian, + Addr::unchecked("guardian_addr") + ); + + let err = execute( + deps.as_mut(), + env.clone(), + mock_info("someone_else", &[]), + ExecuteMsg::AcceptGuardian {}, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::Unauthorized)); + + execute( + deps.as_mut(), + env, + mock_info("new_guardian", &[]), + ExecuteMsg::AcceptGuardian {}, + ) + .unwrap(); + assert_eq!( + CONFIG.load(deps.as_ref().storage).unwrap().guardian, + Addr::unchecked("new_guardian") + ); + } + + #[test] + fn test_query_config() { + let (deps, env) = setup(); + let res = query(deps.as_ref(), env, QueryMsg::Config {}).unwrap(); + let config: ConfigResponse = cosmwasm_std::from_json(res).unwrap(); + assert_eq!(config.guardian, "guardian_addr"); + assert_eq!(config.hub_chain_id, 2); + assert!(config.pending_guardian.is_none()); + } + + #[test] + fn test_query_sequence() { + let (deps, env) = setup(); + let res = query(deps.as_ref(), env, QueryMsg::Sequence {}).unwrap(); + let seq: SequenceResponse = cosmwasm_std::from_json(res).unwrap(); + assert_eq!(seq.sequence, 0); + } +} diff --git a/contracts/cosmos/src/error.rs b/contracts/cosmos/src/error.rs index bb50fd9..b4f842d 100644 --- a/contracts/cosmos/src/error.rs +++ b/contracts/cosmos/src/error.rs @@ -1,29 +1,46 @@ -use cosmwasm_std::StdError; -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum ContractError { - #[error("{0}")] - Std(#[from] StdError), - - #[error("unauthorized: only the guardian can perform this action")] - Unauthorized, - - #[error("contract is paused")] - Paused, - - #[error("message with nonce {nonce} has already been executed")] - AlreadyExecuted { nonce: u64 }, - - #[error("invalid proof: verification failed")] - InvalidProof, - - #[error("insufficient funds: required {required}, available {available}")] - InsufficientFunds { required: u128, available: u128 }, - - #[error("invalid destination chain: {chain_id}")] - InvalidDestinationChain { chain_id: u16 }, - - #[error("zero amount not allowed")] - ZeroAmount, -} +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("unauthorized: only the guardian can perform this action")] + Unauthorized, + + #[error("contract is paused")] + Paused, + + #[error("contract must be paused for this action")] + NotPaused, + + #[error("message {nonce} from chain {source_chain} has already been executed")] + AlreadyExecuted { source_chain: u64, nonce: u64 }, + + #[error("invalid proof: verification failed")] + InvalidProof, + + /// Returned for every inbound message until a real on-chain proof verifier + /// exists. Failing closed is deliberate; see `verify_proof` in contract.rs. + #[error("no on-chain proof verifier is configured; inbound messages are disabled")] + VerifierUnavailable, + + #[error("insufficient funds: required {required}, available {available}")] + InsufficientFunds { required: u128, available: u128 }, + + #[error("invalid destination chain: {chain_id}")] + InvalidDestinationChain { chain_id: u16 }, + + #[error("zero amount not allowed")] + ZeroAmount, + + #[error("send exactly one coin denomination per message")] + MultipleDenoms, + + #[error("fee rate {fee_rate_bps} bps exceeds maximum of {max} bps")] + InvalidFeeRate { fee_rate_bps: u16, max: u16 }, + + #[error("no pending guardian to accept")] + NoPendingGuardian, +} diff --git a/contracts/cosmos/src/msg.rs b/contracts/cosmos/src/msg.rs index 2967315..8652cc3 100644 --- a/contracts/cosmos/src/msg.rs +++ b/contracts/cosmos/src/msg.rs @@ -1,97 +1,107 @@ -use cosmwasm_schema::{cw_serde, QueryResponses}; -use cosmwasm_std::Uint128; - -#[cw_serde] -pub struct InstantiateMsg { - /// The DAO guardian address that can pause/unpause and perform emergency actions - pub guardian: String, - /// The chain ID of the Solana Hub (destination for proofs) - pub hub_chain_id: u16, - /// Fee rate in basis points (e.g., 10 = 0.1%) - pub fee_rate_bps: u16, -} - -#[cw_serde] -pub enum ExecuteMsg { - /// Send a cross-chain message by locking native tokens in the gateway vault. - /// The relayer network will observe the resulting event and generate a ZK proof. - SendCrossChainMessage { - recipient: Vec, - destination_chain: u16, - payload: Vec, - }, - - /// Execute a verified message from the Hub. Called by relayers with a valid proof. - /// Releases locked tokens to the specified recipient. - ExecuteVerifiedMessage { - nonce: u64, - recipient: String, - amount: Uint128, - proof: Vec, - public_inputs: Vec, - }, - - /// Initiate a cross-chain swap. Locks input tokens and emits a SwapInitiated event. - InitiateSwap { - recipient: Vec, - destination_chain: u16, - token_out: String, - min_amount_out: Uint128, - swap_data: Vec, - }, - - /// Pause the contract (guardian only) - Pause {}, - - /// Unpause the contract (guardian only) - Unpause {}, - - /// Emergency withdraw all funds to the guardian (guardian only) - EmergencyWithdraw { denom: String }, - - /// Update the guardian address (guardian only) - UpdateGuardian { new_guardian: String }, -} - -#[cw_serde] -#[derive(QueryResponses)] -pub enum QueryMsg { - /// Returns the contract configuration - #[returns(ConfigResponse)] - Config {}, - - /// Returns the current sequence number - #[returns(SequenceResponse)] - Sequence {}, - - /// Returns whether a message nonce has been executed - #[returns(MessageStatusResponse)] - MessageStatus { nonce: u64 }, - - /// Returns whether the contract is paused - #[returns(PausedResponse)] - Paused {}, -} - -#[cw_serde] -pub struct ConfigResponse { - pub guardian: String, - pub hub_chain_id: u16, - pub fee_rate_bps: u16, -} - -#[cw_serde] -pub struct SequenceResponse { - pub sequence: u64, -} - -#[cw_serde] -pub struct MessageStatusResponse { - pub nonce: u64, - pub executed: bool, -} - -#[cw_serde] -pub struct PausedResponse { - pub paused: bool, -} +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Uint128; + +#[cw_serde] +pub struct InstantiateMsg { + /// The DAO guardian address that can pause/unpause and perform emergency actions + pub guardian: String, + /// The chain ID of the Solana Hub (destination for proofs) + pub hub_chain_id: u16, + /// Fee rate in basis points (e.g., 10 = 0.1%). Capped at `MAX_FEE_RATE_BPS`. + pub fee_rate_bps: u16, +} + +#[cw_serde] +pub enum ExecuteMsg { + /// Send a cross-chain message by locking exactly one native coin denomination. + /// The relayer network will observe the resulting event and generate a ZK proof. + SendCrossChainMessage { + recipient: Vec, + destination_chain: u16, + payload: Vec, + }, + + /// Execute a verified message from the Hub, releasing locked tokens. + /// + /// Every field that affects where funds go is hashed on-chain into the + /// proof's public input, so a relayer cannot change recipient, denom or + /// amount after the proof is generated. + ExecuteVerifiedMessage { + source_chain: u64, + nonce: u64, + recipient: String, + denom: String, + amount: Uint128, + proof: Vec, + }, + + /// Initiate a cross-chain swap. Locks input tokens and emits a SwapInitiated event. + InitiateSwap { + recipient: Vec, + destination_chain: u16, + token_out: String, + min_amount_out: Uint128, + swap_data: Vec, + }, + + /// Pause the contract (guardian only) + Pause {}, + + /// Unpause the contract (guardian only) + Unpause {}, + + /// Withdraw all funds of `denom` to the guardian. Guardian only, and only + /// while paused, so it cannot be used silently during normal operation. + EmergencyWithdraw { denom: String }, + + /// Nominate a new guardian (step 1 of 2, guardian only). + ProposeGuardian { new_guardian: String }, + + /// Accept the guardian role (step 2 of 2, pending guardian only). + AcceptGuardian {}, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// Returns the contract configuration + #[returns(ConfigResponse)] + Config {}, + + /// Returns the next sequence number to be assigned + #[returns(SequenceResponse)] + Sequence {}, + + /// Returns whether an inbound message has been executed + #[returns(MessageStatusResponse)] + MessageStatus { source_chain: u64, nonce: u64 }, + + /// Returns whether the contract is paused + #[returns(PausedResponse)] + Paused {}, +} + +#[cw_serde] +pub struct ConfigResponse { + pub guardian: String, + pub pending_guardian: Option, + pub hub_chain_id: u16, + pub fee_rate_bps: u16, +} + +#[cw_serde] +pub struct SequenceResponse { + pub sequence: u64, +} + +#[cw_serde] +pub struct MessageStatusResponse { + pub source_chain: u64, + pub nonce: u64, + pub executed: bool, +} + +#[cw_serde] +pub struct PausedResponse { + pub paused: bool, +} diff --git a/contracts/cosmos/src/state.rs b/contracts/cosmos/src/state.rs index 49c9567..1969f20 100644 --- a/contracts/cosmos/src/state.rs +++ b/contracts/cosmos/src/state.rs @@ -1,22 +1,28 @@ -use cosmwasm_std::Addr; -use cw_storage_plus::{Item, Map}; -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] -pub struct Config { - pub guardian: Addr, - pub hub_chain_id: u16, - pub fee_rate_bps: u16, -} - -/// Contract configuration -pub const CONFIG: Item = Item::new("config"); - -/// Global sequence counter for cross-chain messages -pub const SEQUENCE: Item = Item::new("sequence"); - -/// Whether the contract is currently paused -pub const PAUSED: Item = Item::new("paused"); - -/// Map of executed message nonces (nonce -> true) for replay protection -pub const EXECUTED_MESSAGES: Map = Map::new("executed"); +use cosmwasm_std::Addr; +use cw_storage_plus::{Item, Map}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct Config { + pub guardian: Addr, + pub hub_chain_id: u16, + pub fee_rate_bps: u16, +} + +/// Contract configuration +pub const CONFIG: Item = Item::new("config"); + +/// Guardian nominated via `ProposeGuardian`, awaiting `AcceptGuardian`. +pub const PENDING_GUARDIAN: Item = Item::new("pending_guardian"); + +/// Next sequence number to assign to an outbound message (0-based, matches EVM gateway). +pub const SEQUENCE: Item = Item::new("sequence"); + +/// Whether the contract is currently paused +pub const PAUSED: Item = Item::new("paused"); + +/// Executed inbound messages, keyed by (source_chain, nonce). +/// +/// Nonces are only unique per source chain, so keying by nonce alone would let +/// message #5 from chain A block message #5 from chain B. +pub const EXECUTED_MESSAGES: Map<(u64, u64), bool> = Map::new("executed_v2"); diff --git a/contracts/evm/script/DeployAndInit.s.sol b/contracts/evm/script/DeployAndInit.s.sol index 67fea4c..a52250a 100644 --- a/contracts/evm/script/DeployAndInit.s.sol +++ b/contracts/evm/script/DeployAndInit.s.sol @@ -34,7 +34,7 @@ contract DeployAndInit is Script { gateway.setVerificationKey(vkData); console2.log("Verification key set successfully (576 bytes)"); } else { - console2.log("No VK provided — call setVerificationKey() before processing proofs"); + console2.log("No VK provided - call setVerificationKey() before processing proofs"); } vm.stopBroadcast(); diff --git a/contracts/evm/script/DeployL2.s.sol b/contracts/evm/script/DeployL2.s.sol index 24456b1..e474100 100644 --- a/contracts/evm/script/DeployL2.s.sol +++ b/contracts/evm/script/DeployL2.s.sol @@ -172,7 +172,7 @@ contract DeployL2Verify is Script { bool paused = gateway.paused(); require(!paused, "Verification failed: gateway is paused at deployment"); - uint256 nonce = gateway.nonce(); + uint256 nonce = gateway.currentNonce(); require(nonce == 0, "Verification failed: nonce should be 0 at deployment"); console2.log("Verification PASSED for gateway:", gatewayAddr); diff --git a/contracts/evm/src/IInterlinkRouter.sol b/contracts/evm/src/IInterlinkRouter.sol index 6742bff..d575e2b 100644 --- a/contracts/evm/src/IInterlinkRouter.sol +++ b/contracts/evm/src/IInterlinkRouter.sol @@ -45,8 +45,9 @@ interface IInterlinkRouter { function currentNonce() external view returns (uint64); /** - * @dev Returns whether a specific nonce has been executed. - * Maps to InterlinkGateway.executedNonces. + * @dev Returns whether an inbound message has been executed, keyed by its + * canonical payload hash (nonces are only unique per source chain). + * Maps to InterlinkGateway.executedMessages. */ - function executedNonces(uint64 nonce) external view returns (bool); + function executedMessages(bytes32 payloadHash) external view returns (bool); } diff --git a/contracts/evm/src/InterlinkGateway.sol b/contracts/evm/src/InterlinkGateway.sol index 97f191f..8317ac0 100644 --- a/contracts/evm/src/InterlinkGateway.sol +++ b/contracts/evm/src/InterlinkGateway.sol @@ -1,374 +1,573 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.28; - -/** - * @title InterlinkGateway - * @dev Source chain gateway (spoke) for EVM chains. - * Handles asset custody, event logging for relayers, and verified hub commands. - * - * Security: - * - CEI pattern in sendCrossChainMessage - * - Zero-address guards - * - emergencyWithdraw for untrapping funds - * - Standard Groth16 verification (4-pairing check) - * - Pinned to 0.8.28 for stability - * - * Groth16 verification uses stored verification key (VK) with the equation: - * e(-A, B) · e(α, β) · e(L, γ) · e(C, δ) = 1 - * where L = IC[0] + publicInput * IC[1] - */ - -interface IERC20 { - function transferFrom(address sender, address recipient, uint256 amount) external returns (bool); - function transfer(address recipient, uint256 amount) external returns (bool); -} - -interface IERC721 { - function transferFrom(address from, address to, uint256 tokenId) external; - function ownerOf(uint256 tokenId) external view returns (address); -} - -contract InterlinkGateway { - address public immutable daoGuardian; - bool public paused; - - // Anti-replay. Don't execute a nonce more than once. - mapping(uint64 => bool) public executedNonces; - uint64 public currentNonce; - - // ─── Groth16 Verification Key ─────────────────────────────────────────── - // Stored on-chain after trusted setup. Set via setVerificationKey(). - bool public vkInitialized; - - // VK points (BN254, big-endian, EVM precompile format) - uint256[2] public vk_alpha; // G1: (x, y) - uint256[2][2] public vk_beta; // G2: (x_im, x_re, y_im, y_re) packed as [[x_im,x_re],[y_im,y_re]] - uint256[2][2] public vk_gamma; // G2 - uint256[2][2] public vk_delta; // G2 - uint256[2] public vk_ic0; // G1: IC base - uint256[2] public vk_ic1; // G1: IC for public input - - // BN254 scalar field modulus - uint256 constant BN254_SCALAR_FIELD = 21888242871839275222246405745257275088548364400416034343698204186575808495617; - // BN254 base field modulus (for point negation) - uint256 constant BN254_BASE_FIELD = 21888242871839275222246405745257275088696311157297823662689037894645226208583; - - // ─── Events ───────────────────────────────────────────────────────────── - - event MessagePublished( - uint64 indexed nonce, - uint64 destinationChain, - address sender, - bytes32 payloadHash, - bytes payload - ); - - event MessageExecuted(uint64 indexed nonce, bool success); - - event SwapInitiated( - uint64 indexed nonce, - address indexed sender, - address recipient, - uint256 amountIn, - address tokenIn, - address tokenOut, - uint256 minAmountOut, - uint64 destinationChain, - bytes swapData, - bytes32 payloadHash - ); - - event NFTLocked( - uint64 indexed nonce, - address indexed sender, - address nftContract, - uint256 tokenId, - uint64 destinationChain, - bytes32 destinationRecipient, - bytes32 nftHash - ); - - event GatewayPaused(); - event GatewayUnpaused(); - event EmergencyWithdraw(address indexed token, address indexed to, uint256 amount); - event VerificationKeyUpdated(); - - // ─── Modifiers ────────────────────────────────────────────────────────── - - modifier onlyGuardian() { - require(msg.sender == daoGuardian, "Interlink: Unauthorized"); - _; - } - - modifier whenNotPaused() { - require(!paused, "Interlink: Gateway is paused"); - _; - } - - constructor(address _guardian) { - require(_guardian != address(0), "Interlink: zero guardian"); - daoGuardian = _guardian; - } - - // ─── Admin ────────────────────────────────────────────────────────────── - - function pause() external onlyGuardian { - paused = true; - emit GatewayPaused(); - } - - function unpause() external onlyGuardian { - paused = false; - emit GatewayUnpaused(); - } - - function emergencyWithdraw(address token, address to, uint256 amount) external onlyGuardian { - require(to != address(0), "Interlink: zero recipient"); - if (token == address(0)) { - (bool ok,) = to.call{value: amount}(""); - require(ok, "Interlink: ETH transfer failed"); - } else { - require(IERC20(token).transfer(to, amount), "Interlink: token transfer failed"); - } - emit EmergencyWithdraw(token, to, amount); - } - - /** - * @dev Set the Groth16 verification key. Must be called once after trusted - * setup before any proofs can be verified. Guardian-only. - * - * @param alpha G1 point (2 uint256: x, y) - * @param beta G2 point (2x2 uint256: [x_im,x_re], [y_im,y_re]) - * @param gamma G2 point - * @param delta G2 point - * @param ic0 G1 point — IC base - * @param ic1 G1 point — IC for public input - */ - function setVerificationKey( - uint256[2] calldata alpha, - uint256[2][2] calldata beta, - uint256[2][2] calldata gamma, - uint256[2][2] calldata delta, - uint256[2] calldata ic0, - uint256[2] calldata ic1 - ) external onlyGuardian { - vk_alpha = alpha; - vk_beta = beta; - vk_gamma = gamma; - vk_delta = delta; - vk_ic0 = ic0; - vk_ic1 = ic1; - vkInitialized = true; - emit VerificationKeyUpdated(); - } - - // ─── User-facing ──────────────────────────────────────────────────────── - - function sendCrossChainMessage( - uint64 destChain, - address token, - uint256 amount, - bytes calldata payload - ) external payable whenNotPaused { - if (token == address(0)) { - require(msg.value == amount, "Interlink: Incorrect native value sent"); - } - - uint64 nonce = currentNonce++; - bytes32 payloadHash = keccak256(abi.encode(msg.sender, destChain, token, amount, payload)); - - emit MessagePublished(nonce, destChain, msg.sender, payloadHash, payload); - - if (token != address(0)) { - require(IERC20(token).transferFrom(msg.sender, address(this), amount), "Interlink: Transfer failed"); - } - } - - function initiateSwap( - uint64 destChain, - address tokenIn, - uint256 amountIn, - address tokenOut, - uint256 minAmountOut, - address recipient, - bytes calldata swapData - ) external payable whenNotPaused { - require(amountIn > 0, "Interlink: zero amount"); - require(recipient != address(0), "Interlink: zero recipient"); - - if (tokenIn == address(0)) { - require(msg.value == amountIn, "Interlink: Incorrect native value sent"); - } - - uint64 nonce = currentNonce++; - bytes32 payloadHash = keccak256(abi.encode( - msg.sender, destChain, tokenIn, amountIn, tokenOut, minAmountOut, recipient, swapData - )); - - emit SwapInitiated( - nonce, msg.sender, recipient, amountIn, - tokenIn, tokenOut, minAmountOut, destChain, swapData, payloadHash - ); - - if (tokenIn != address(0)) { - require(IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn), "Interlink: Transfer failed"); - } - } - - function lockNFT( - address nftContract, - uint256 tokenId, - uint64 destinationChain, - bytes32 destinationRecipient - ) external whenNotPaused { - require(nftContract != address(0), "Interlink: zero NFT contract"); - require(destinationRecipient != bytes32(0), "Interlink: zero recipient"); - - uint64 nonce = currentNonce++; - bytes32 nftHash = keccak256(abi.encode(nftContract, tokenId, msg.sender, destinationChain)); - - emit NFTLocked( - nonce, msg.sender, nftContract, tokenId, - destinationChain, destinationRecipient, nftHash - ); - - IERC721(nftContract).transferFrom(msg.sender, address(this), tokenId); - } - - // ─── Relayer-facing ───────────────────────────────────────────────────── - - /** - * @dev Execute a verified message from the hub. Uses standard Groth16 - * verification with stored VK. - * - * @param target Destination contract for the payload. - * @param nonce Origin sequence ID — prevents replay. - * @param payload ABI-encoded call data. - * @param snarkProof 256-byte Groth16 proof: A(64) + B(128) + C(64). - */ - function executeVerifiedMessage( - address target, - uint64 nonce, - bytes calldata payload, - bytes calldata snarkProof - ) external whenNotPaused { - require(target != address(0), "Interlink: zero target"); - require(!executedNonces[nonce], "Interlink: Message already executed"); - require(vkInitialized, "Interlink: VK not initialized"); - - // Derive public input: keccak256(target, nonce, payload) domain-separated - bytes32 publicInputHash = keccak256(abi.encodePacked(target, nonce, payload)); - uint256 publicInput = uint256( - keccak256(abi.encodePacked(publicInputHash, "interlink_v1_domain")) - ) % BN254_SCALAR_FIELD; - - bool valid = _verifyGroth16(snarkProof, publicInput); - require(valid, "Interlink: Invalid Groth16 proof"); - - executedNonces[nonce] = true; - - (bool success,) = target.call(payload); - require(success, "Interlink: Execution failed"); - - emit MessageExecuted(nonce, success); - } - - // ─── Groth16 Verification ─────────────────────────────────────────────── - - /** - * @dev Standard Groth16 verification using BN254 precompiles. - * - * Verification equation (4-pairing multi-check): - * e(-A, B) · e(α, β) · e(L, γ) · e(C, δ) = 1 - * - * Where L = IC[0] + publicInput * IC[1] - * - * @param proof 256-byte proof: A(64) + B(128) + C(64) - * @param publicInput Scalar field element (the commitment) - */ - function _verifyGroth16(bytes calldata proof, uint256 publicInput) internal view returns (bool) { - if (proof.length != 256) return false; - require(publicInput < BN254_SCALAR_FIELD, "Interlink: input >= scalar field"); - - // Decode proof points - (uint256 ax, uint256 ay) = abi.decode(proof[0:64], (uint256, uint256)); - (uint256 bx1, uint256 bx2, uint256 by1, uint256 by2) = abi.decode(proof[64:192], (uint256, uint256, uint256, uint256)); - (uint256 cx, uint256 cy) = abi.decode(proof[192:256], (uint256, uint256)); - - // Compute L = IC[0] + publicInput * IC[1] - // Step 1: ECMUL — publicInput * IC[1] - uint256[3] memory mulInput; - mulInput[0] = vk_ic1[0]; - mulInput[1] = vk_ic1[1]; - mulInput[2] = publicInput; - uint256[2] memory scaledIC1; - bool mulOk; - assembly { - mulOk := staticcall(gas(), 0x07, mulInput, 0x60, scaledIC1, 0x40) - } - require(mulOk, "Interlink: ECMUL failed"); - - // Step 2: ECADD — IC[0] + scaledIC1 - uint256[4] memory addInput; - addInput[0] = vk_ic0[0]; - addInput[1] = vk_ic0[1]; - addInput[2] = scaledIC1[0]; - addInput[3] = scaledIC1[1]; - uint256[2] memory L; - bool addOk; - assembly { - addOk := staticcall(gas(), 0x06, addInput, 0x80, L, 0x40) - } - require(addOk, "Interlink: ECADD failed"); - - // 4-pairing check: e(-A, B) · e(α, β) · e(L, γ) · e(C, δ) = 1 - // -A: negate y coordinate → (ax, BN254_BASE_FIELD - ay) - uint256 neg_ay = (ay == 0) ? 0 : BN254_BASE_FIELD - ay; - - uint256[24] memory pairingInput; - - // Pair 0: (-A, B) - pairingInput[0] = ax; - pairingInput[1] = neg_ay; - pairingInput[2] = bx1; - pairingInput[3] = bx2; - pairingInput[4] = by1; - pairingInput[5] = by2; - - // Pair 1: (alpha, beta) - pairingInput[6] = vk_alpha[0]; - pairingInput[7] = vk_alpha[1]; - pairingInput[8] = vk_beta[0][0]; - pairingInput[9] = vk_beta[0][1]; - pairingInput[10] = vk_beta[1][0]; - pairingInput[11] = vk_beta[1][1]; - - // Pair 2: (L, gamma) - pairingInput[12] = L[0]; - pairingInput[13] = L[1]; - pairingInput[14] = vk_gamma[0][0]; - pairingInput[15] = vk_gamma[0][1]; - pairingInput[16] = vk_gamma[1][0]; - pairingInput[17] = vk_gamma[1][1]; - - // Pair 3: (C, delta) - pairingInput[18] = cx; - pairingInput[19] = cy; - pairingInput[20] = vk_delta[0][0]; - pairingInput[21] = vk_delta[0][1]; - pairingInput[22] = vk_delta[1][0]; - pairingInput[23] = vk_delta[1][1]; - - // Call ecPairing precompile (0x08): returns 1 if the pairing product == 1 - uint256[1] memory result; - bool pairingOk; - assembly { - pairingOk := staticcall(gas(), 0x08, pairingInput, 768, result, 0x20) - } - - return pairingOk && result[0] == 1; - } - - /// @dev Accept plain ETH sends. - receive() external payable {} -} +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +/** + * @title InterlinkGateway + * @dev Spoke gateway for EVM chains: custody, outbound messages, verified inbound messages. + * + * Canonical message (shared with the Solana hub and relayer): + * + * payloadHash = keccak256(abi.encode( + * keccak256("InterlinkMessage(v1)"), + * uint64 srcChain, bytes32 srcGateway, + * uint64 dstChain, bytes32 dstGateway, + * uint64 nonce, uint8 action, + * bytes32 sender, bytes32 asset, + * uint256 amount, bytes32 recipient, + * bytes32 extraHash)) + * + * Both chain IDs and both gateway addresses are bound, so a message cannot be + * replayed against another deployment or another chain. Recipients are + * bytes32 so Solana addresses fit. + * + * Inbound proofs are Groth16 over BN254 with one public input: + * commitment = (payloadHash mod r + rc)^5 + nonce (mod r) + * which is the relation the relayer's circuit proves. + * + * Trust changes (verification key, remote gateways, guardian) are timelocked + * or two-step; emergency withdrawal requires the gateway to be paused. + * + * NOTE: the circuit currently proves only knowledge of a preimage of the + * commitment; it does NOT prove that the message was emitted on the source + * chain. See docs/SECURITY_REVIEW.md before holding real value here. + */ + +interface IERC20Balance { + function balanceOf(address account) external view returns (uint256); +} + +interface IERC721 { + function transferFrom(address from, address to, uint256 tokenId) external; + function ownerOf(uint256 tokenId) external view returns (address); +} + +contract InterlinkGateway { + // ─── Constants ────────────────────────────────────────────────────────── + + /// BN254 scalar field r + uint256 internal constant R = + 21888242871839275222246405745257275088548364400416034343698204186575808495617; + /// BN254 base field p + uint256 internal constant P = + 21888242871839275222246405745257275088696311157297823662689037894645226208583; + /// Circuit round constant: keccak256("interlink_v1_domain") mod r + uint256 internal constant ROUND_CONSTANT = uint256(keccak256("interlink_v1_domain")) % R; + + bytes32 public constant MESSAGE_TYPEHASH = keccak256("InterlinkMessage(v1)"); + uint256 public constant TIMELOCK = 2 days; + + uint8 public constant ACTION_TRANSFER = 1; + uint8 public constant ACTION_SWAP = 2; + uint8 public constant ACTION_NFT = 3; + uint8 public constant ACTION_CALL = 4; + + // ─── Types ────────────────────────────────────────────────────────────── + + struct Message { + uint64 srcChain; + bytes32 srcGateway; + uint64 dstChain; + bytes32 dstGateway; + uint64 nonce; + uint8 action; + bytes32 sender; + bytes32 asset; + uint256 amount; + bytes32 recipient; + bytes32 extraHash; + } + + /// Inbound message fields supplied by the caller. Source gateway and + /// destination are not caller-controlled: they come from config and this contract. + struct InboundMessage { + uint64 srcChain; + uint64 nonce; + bytes32 sender; + bytes32 asset; + uint256 amount; + bytes32 recipient; + bytes32 extraHash; + } + + struct PendingRemote { + bytes32 gateway; + uint256 eta; + } + + // ─── State ────────────────────────────────────────────────────────────── + + address public guardian; + address public pendingGuardian; + bool public paused; + uint256 private _reentrancy = 1; + + uint64 public currentNonce; + /// Inbound replay protection, keyed by canonical payload hash. + mapping(bytes32 => bool) public executedMessages; + /// Trusted gateway per remote chain. Doubles as the destination allowlist. + mapping(uint64 => bytes32) public remoteGateways; + mapping(uint64 => bool) public remoteEverConfigured; + mapping(uint64 => PendingRemote) public pendingRemoteGateways; + /// Amount of each asset backing bridged supply. Releases can never exceed it. + mapping(address => uint256) public totalLocked; + /// Assets the gateway custodies. `executeVerifiedMessage` may never call them. + mapping(address => bool) public isCustodiedAsset; + /// No destination mints or returns NFTs yet, so locking is off until one does. + bool public nftBridgingEnabled; + + /// Verification key as 18 words: + /// [0..1] alpha | [2..5] beta | [6..9] gamma | [10..13] delta | [14..15] ic0 | [16..17] ic1 + /// G2 words are (x_im, x_re, y_im, y_re), the EIP-197 precompile order. + uint256[18] internal _vk; + bool public vkInitialized; + uint256[18] internal _pendingVk; + uint256 public pendingVkEta; + + // ─── Events ───────────────────────────────────────────────────────────── + + event MessagePublished( + bytes32 indexed payloadHash, + uint64 indexed nonce, + uint64 dstChain, + bytes32 dstGateway, + uint8 action, + bytes32 sender, + bytes32 asset, + uint256 amount, + bytes32 recipient, + bytes32 extraHash, + bytes data + ); + event MessageExecuted(bytes32 indexed payloadHash, uint64 indexed srcChain, uint64 nonce); + + event GatewayPaused(); + event GatewayUnpaused(); + event EmergencyWithdraw(address indexed token, address indexed to, uint256 amount); + event GuardianProposed(address indexed pendingGuardian); + event GuardianTransferred(address indexed previousGuardian, address indexed newGuardian); + event RemoteGatewaySet(uint64 indexed chainId, bytes32 gateway); + event RemoteGatewayProposed(uint64 indexed chainId, bytes32 gateway, uint256 eta); + event VerificationKeyProposed(uint256 eta); + event VerificationKeyUpdated(); + event NftBridgingSet(bool enabled); + + // ─── Modifiers ────────────────────────────────────────────────────────── + + modifier onlyGuardian() { + require(msg.sender == guardian, "Interlink: Unauthorized"); + _; + } + + modifier whenNotPaused() { + require(!paused, "Interlink: Gateway is paused"); + _; + } + + modifier nonReentrant() { + require(_reentrancy == 1, "Interlink: reentrant call"); + _reentrancy = 2; + _; + _reentrancy = 1; + } + + constructor(address _guardian) { + require(_guardian != address(0), "Interlink: zero guardian"); + guardian = _guardian; + } + + // ─── Admin ────────────────────────────────────────────────────────────── + + function pause() external onlyGuardian { + paused = true; + emit GatewayPaused(); + } + + function unpause() external onlyGuardian { + paused = false; + emit GatewayUnpaused(); + } + + /// @dev Step 1 of 2. The guardian should be a multisig/timelock, not an EOA. + function transferGuardian(address newGuardian) external onlyGuardian { + pendingGuardian = newGuardian; + emit GuardianProposed(newGuardian); + } + + /// @dev Step 2 of 2: the nominee proves it controls the address. + function acceptGuardian() external { + require(msg.sender == pendingGuardian, "Interlink: not pending guardian"); + emit GuardianTransferred(guardian, msg.sender); + guardian = msg.sender; + pendingGuardian = address(0); + } + + /// @dev Only while paused, so funds cannot be pulled silently during normal operation. + function emergencyWithdraw(address token, address to, uint256 amount) external onlyGuardian { + require(paused, "Interlink: pause first"); + require(to != address(0), "Interlink: zero recipient"); + uint256 locked = totalLocked[token]; + totalLocked[token] = amount > locked ? 0 : locked - amount; + _send(token, to, amount); + emit EmergencyWithdraw(token, to, amount); + } + + /// @dev Initial configuration of a remote chain takes effect immediately. + /// Any later change must go through propose/apply with the timelock. + function setRemoteGateway(uint64 chainId, bytes32 gateway) external onlyGuardian { + require(gateway != bytes32(0), "Interlink: zero gateway"); + require(!remoteEverConfigured[chainId], "Interlink: use proposeRemoteGateway"); + remoteEverConfigured[chainId] = true; + remoteGateways[chainId] = gateway; + emit RemoteGatewaySet(chainId, gateway); + } + + function proposeRemoteGateway(uint64 chainId, bytes32 gateway) external onlyGuardian { + require(gateway != bytes32(0), "Interlink: zero gateway"); + uint256 eta = block.timestamp + TIMELOCK; + pendingRemoteGateways[chainId] = PendingRemote(gateway, eta); + emit RemoteGatewayProposed(chainId, gateway, eta); + } + + function applyRemoteGateway(uint64 chainId) external onlyGuardian { + PendingRemote memory p = pendingRemoteGateways[chainId]; + require(p.gateway != bytes32(0), "Interlink: nothing pending"); + require(block.timestamp >= p.eta, "Interlink: timelock"); + delete pendingRemoteGateways[chainId]; + remoteEverConfigured[chainId] = true; + remoteGateways[chainId] = p.gateway; + emit RemoteGatewaySet(chainId, p.gateway); + } + + /// @dev Disabling a route reduces trust, so it is immediate. + function disableRemoteGateway(uint64 chainId) external onlyGuardian { + delete remoteGateways[chainId]; + emit RemoteGatewaySet(chainId, bytes32(0)); + } + + /// @dev Enable only once a destination can mint and return bridged NFTs. + function setNftBridgingEnabled(bool enabled) external onlyGuardian { + nftBridgingEnabled = enabled; + emit NftBridgingSet(enabled); + } + + /// @notice Set the initial Groth16 verification key (576 bytes, as produced by + /// `cargo run --bin export-vk`). Replacement keys go through proposeVerificationKey. + function setVerificationKey(bytes calldata vkData) external onlyGuardian { + require(!vkInitialized, "Interlink: use proposeVerificationKey"); + _vk = _decodeVk(vkData); + vkInitialized = true; + emit VerificationKeyUpdated(); + } + + function proposeVerificationKey(bytes calldata vkData) external onlyGuardian { + _pendingVk = _decodeVk(vkData); + pendingVkEta = block.timestamp + TIMELOCK; + emit VerificationKeyProposed(pendingVkEta); + } + + function applyVerificationKey() external onlyGuardian { + require(pendingVkEta != 0, "Interlink: nothing pending"); + require(block.timestamp >= pendingVkEta, "Interlink: timelock"); + _vk = _pendingVk; + delete _pendingVk; + pendingVkEta = 0; + vkInitialized = true; + emit VerificationKeyUpdated(); + } + + function verificationKey() external view returns (uint256[18] memory) { + return _vk; + } + + // ─── Outbound ─────────────────────────────────────────────────────────── + + /// @notice Lock `amount` of `token` (address(0) = native) and send it to `recipient` on `dstChain`. + function sendCrossChainMessage( + uint64 dstChain, + address token, + uint256 amount, + bytes32 recipient, + bytes calldata payload + ) external payable whenNotPaused nonReentrant returns (bytes32 payloadHash) { + require(recipient != bytes32(0), "Interlink: zero recipient"); + uint256 received = _collect(token, amount); + payloadHash = _publish( + dstChain, ACTION_TRANSFER, _toBytes32(token), received, recipient, keccak256(payload), payload + ); + } + + /// @notice Lock `amountIn` and request a swap into `tokenOut` on `dstChain`. + /// @param tokenOut Destination-chain token (bytes32 so Solana mints fit). + /// @param minAmountOut Minimum output in destination token units (≤ uint64 max for Solana). + function initiateSwap( + uint64 dstChain, + address tokenIn, + uint256 amountIn, + bytes32 tokenOut, + uint256 minAmountOut, + bytes32 recipient, + bytes calldata swapData + ) external payable whenNotPaused nonReentrant returns (bytes32 payloadHash) { + require(recipient != bytes32(0), "Interlink: zero recipient"); + require(tokenOut != bytes32(0), "Interlink: zero tokenOut"); + require(minAmountOut <= type(uint64).max, "Interlink: minAmountOut too large"); + uint256 received = _collect(tokenIn, amountIn); + bytes32 extraHash = keccak256(abi.encode(tokenOut, minAmountOut, keccak256(swapData))); + payloadHash = _publish( + dstChain, + ACTION_SWAP, + _toBytes32(tokenIn), + received, + recipient, + extraHash, + abi.encode(tokenOut, minAmountOut, swapData) + ); + } + + /// @notice Lock an NFT for bridging. Disabled by default (`nftBridgingEnabled`): + /// no destination mints or returns NFTs yet, so a locked NFT would be stranded. + function lockNFT( + address nftContract, + uint256 tokenId, + uint64 dstChain, + bytes32 recipient + ) external whenNotPaused nonReentrant returns (bytes32 payloadHash) { + require(nftBridgingEnabled, "Interlink: NFT bridging disabled"); + require(nftContract.code.length > 0, "Interlink: zero NFT contract"); + require(recipient != bytes32(0), "Interlink: zero recipient"); + + IERC721(nftContract).transferFrom(msg.sender, address(this), tokenId); + require(IERC721(nftContract).ownerOf(tokenId) == address(this), "Interlink: NFT not received"); + isCustodiedAsset[nftContract] = true; + + payloadHash = _publish( + dstChain, + ACTION_NFT, + _toBytes32(nftContract), + 1, + recipient, + keccak256(abi.encode(tokenId)), + abi.encode(tokenId) + ); + } + + // ─── Inbound ──────────────────────────────────────────────────────────── + + /// @notice Release locked tokens for a proven inbound transfer. + function releaseTokens(InboundMessage calldata m, bytes calldata proof) + external + whenNotPaused + nonReentrant + { + bytes32 h = _consume(ACTION_TRANSFER, m, proof); + address token = _toAddress(m.asset); + address to = _toAddress(m.recipient); + require(to != address(0), "Interlink: zero recipient"); + + // Reverts on underflow: the gateway never releases more than it locked. + totalLocked[token] -= m.amount; + _send(token, to, m.amount); + + emit MessageExecuted(h, m.srcChain, m.nonce); + } + + /// @notice Execute a proven generic call. The target may not be this + /// gateway or any asset it custodies: a verified message must never be + /// able to move custody (the Poly Network failure mode). + function executeVerifiedMessage( + InboundMessage calldata m, + bytes calldata payload, + bytes calldata proof + ) external whenNotPaused nonReentrant { + require(m.asset == bytes32(0) && m.amount == 0, "Interlink: calls carry no value"); + require(m.extraHash == keccak256(payload), "Interlink: payload mismatch"); + address target = _toAddress(m.recipient); + require(target.code.length > 0, "Interlink: target has no code"); + require(target != address(this), "Interlink: cannot target gateway"); + require(!isCustodiedAsset[target], "Interlink: cannot target custodied asset"); + + bytes32 h = _consume(ACTION_CALL, m, proof); + + (bool success,) = target.call(payload); + require(success, "Interlink: Execution failed"); + + emit MessageExecuted(h, m.srcChain, m.nonce); + } + + // ─── Hashing ──────────────────────────────────────────────────────────── + + function hashMessage(Message memory m) public pure returns (bytes32) { + return keccak256(abi.encode(MESSAGE_TYPEHASH, m)); + } + + /// @notice Circuit public input for a payload hash and nonce. + function commitment(bytes32 payloadHash, uint64 nonce) public pure returns (uint256) { + uint256 w = addmod(uint256(payloadHash) % R, ROUND_CONSTANT, R); + uint256 w2 = mulmod(w, w, R); + uint256 w4 = mulmod(w2, w2, R); + return addmod(mulmod(w4, w, R), uint256(nonce), R); + } + + // ─── Internals ────────────────────────────────────────────────────────── + + function _publish( + uint64 dstChain, + uint8 action, + bytes32 asset, + uint256 amount, + bytes32 recipient, + bytes32 extraHash, + bytes memory data + ) internal returns (bytes32 h) { + bytes32 dstGateway = remoteGateways[dstChain]; + require(dstGateway != bytes32(0), "Interlink: unsupported destination"); + + uint64 nonce = currentNonce++; + bytes32 sender = _toBytes32(msg.sender); + h = hashMessage( + Message({ + srcChain: uint64(block.chainid), + srcGateway: _toBytes32(address(this)), + dstChain: dstChain, + dstGateway: dstGateway, + nonce: nonce, + action: action, + sender: sender, + asset: asset, + amount: amount, + recipient: recipient, + extraHash: extraHash + }) + ); + emit MessagePublished( + h, nonce, dstChain, dstGateway, action, sender, asset, amount, recipient, extraHash, data + ); + } + + /// Verify and mark an inbound message as executed. Returns its payload hash. + function _consume(uint8 action, InboundMessage calldata m, bytes calldata proof) + internal + returns (bytes32 h) + { + bytes32 srcGateway = remoteGateways[m.srcChain]; + require(srcGateway != bytes32(0), "Interlink: unknown source"); + require(vkInitialized, "Interlink: VK not initialized"); + + h = hashMessage( + Message({ + srcChain: m.srcChain, + srcGateway: srcGateway, + dstChain: uint64(block.chainid), + dstGateway: _toBytes32(address(this)), + nonce: m.nonce, + action: action, + sender: m.sender, + asset: m.asset, + amount: m.amount, + recipient: m.recipient, + extraHash: m.extraHash + }) + ); + require(!executedMessages[h], "Interlink: Message already executed"); + require(_verifyGroth16(proof, commitment(h, m.nonce)), "Interlink: Invalid Groth16 proof"); + executedMessages[h] = true; + } + + /// Pull funds in and return the amount actually received (fee-on-transfer safe). + function _collect(address token, uint256 amount) internal returns (uint256 received) { + require(amount > 0, "Interlink: zero amount"); + if (token == address(0)) { + require(msg.value == amount, "Interlink: Incorrect native value sent"); + received = amount; + } else { + require(msg.value == 0, "Interlink: unexpected native value"); + require(token.code.length > 0, "Interlink: token has no code"); + uint256 before = IERC20Balance(token).balanceOf(address(this)); + _safeTransferFrom(token, msg.sender, address(this), amount); + received = IERC20Balance(token).balanceOf(address(this)) - before; + require(received > 0, "Interlink: nothing received"); + isCustodiedAsset[token] = true; + } + // Solana amounts are bounded; keep every message representable everywhere. + require(received <= type(uint128).max, "Interlink: amount too large"); + totalLocked[token] += received; + } + + function _send(address token, address to, uint256 amount) internal { + if (token == address(0)) { + (bool ok,) = to.call{value: amount}(""); + require(ok, "Interlink: ETH transfer failed"); + } else { + _safeTransfer(token, to, amount); + } + } + + /// Accepts tokens that return nothing (USDT) as well as those returning bool. + function _safeTransfer(address token, address to, uint256 amount) private { + (bool ok, bytes memory data) = token.call(abi.encodeWithSelector(0xa9059cbb, to, amount)); + require(ok && (data.length == 0 || abi.decode(data, (bool))), "Interlink: token transfer failed"); + } + + function _safeTransferFrom(address token, address from, address to, uint256 amount) private { + (bool ok, bytes memory data) = + token.call(abi.encodeWithSelector(0x23b872dd, from, to, amount)); + require(ok && (data.length == 0 || abi.decode(data, (bool))), "Interlink: Transfer failed"); + } + + function _toBytes32(address a) internal pure returns (bytes32) { + return bytes32(uint256(uint160(a))); + } + + function _toAddress(bytes32 b) internal pure returns (address) { + require(uint256(b) >> 160 == 0, "Interlink: not an EVM address"); + return address(uint160(uint256(b))); + } + + function _decodeVk(bytes calldata vkData) internal pure returns (uint256[18] memory words) { + require(vkData.length == 576, "Interlink: VK must be 576 bytes"); + words = abi.decode(vkData, (uint256[18])); + for (uint256 i; i < 18; ++i) { + require(words[i] < P, "Interlink: VK coordinate out of range"); + } + } + + // ─── Groth16 ──────────────────────────────────────────────────────────── + + /// e(−A, B) · e(α, β) · e(L, γ) · e(C, δ) = 1, L = IC0 + input · IC1 + /// proof = A (x, y) | B (x_im, x_re, y_im, y_re) | C (x, y), 32-byte big-endian words. + function _verifyGroth16(bytes calldata proof, uint256 input) internal view returns (bool) { + if (proof.length != 256 || input >= R) return false; + uint256[8] memory p = abi.decode(proof, (uint256[8])); + for (uint256 i; i < 8; ++i) { + if (p[i] >= P) return false; + } + uint256[18] memory vk = _vk; + + uint256[3] memory mulIn = [vk[16], vk[17], input]; + uint256[2] memory scaled; + bool ok; + assembly { + ok := staticcall(gas(), 0x07, mulIn, 0x60, scaled, 0x40) + } + if (!ok) return false; + + uint256[4] memory addIn = [vk[14], vk[15], scaled[0], scaled[1]]; + uint256[2] memory l; + assembly { + ok := staticcall(gas(), 0x06, addIn, 0x80, l, 0x40) + } + if (!ok) return false; + + uint256[24] memory input24 = [ + p[0], p[1] == 0 ? 0 : P - p[1], p[2], p[3], p[4], p[5], + vk[0], vk[1], vk[2], vk[3], vk[4], vk[5], + l[0], l[1], vk[6], vk[7], vk[8], vk[9], + p[6], p[7], vk[10], vk[11], vk[12], vk[13] + ]; + uint256[1] memory out; + assembly { + ok := staticcall(gas(), 0x08, input24, 768, out, 0x20) + } + return ok && out[0] == 1; + } +} diff --git a/contracts/evm/test/InterlinkGateway.t.sol b/contracts/evm/test/InterlinkGateway.t.sol index adde7d9..0059e49 100644 --- a/contracts/evm/test/InterlinkGateway.t.sol +++ b/contracts/evm/test/InterlinkGateway.t.sol @@ -1,301 +1,538 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.28; - -import "forge-std/Test.sol"; -import "../src/InterlinkGateway.sol"; - -/// @dev Minimal ERC-20 stub for testing token custody. -contract MockERC20 { - mapping(address => uint256) public balanceOf; - mapping(address => mapping(address => uint256)) public allowance; - - function mint(address to, uint256 amount) external { - balanceOf[to] += amount; - } - - function approve(address spender, uint256 amount) external returns (bool) { - allowance[msg.sender][spender] = amount; - return true; - } - - function transferFrom(address from, address to, uint256 amount) external returns (bool) { - require(balanceOf[from] >= amount, "insufficient balance"); - require(allowance[from][msg.sender] >= amount, "insufficient allowance"); - balanceOf[from] -= amount; - allowance[from][msg.sender] -= amount; - balanceOf[to] += amount; - return true; - } - - function transfer(address to, uint256 amount) external returns (bool) { - require(balanceOf[msg.sender] >= amount, "insufficient balance"); - balanceOf[msg.sender] -= amount; - balanceOf[to] += amount; - return true; - } -} - -/// @dev Minimal ERC-721 stub for testing NFT custody. -contract MockERC721 { - mapping(uint256 => address) public ownerOf; - mapping(address => mapping(address => bool)) public isApprovedForAll; - - function mint(address to, uint256 tokenId) external { - ownerOf[tokenId] = to; - } - - function setApprovalForAll(address operator, bool approved) external { - isApprovedForAll[msg.sender][operator] = approved; - } - - function transferFrom(address from, address to, uint256 tokenId) external { - require(ownerOf[tokenId] == from, "not owner"); - require( - isApprovedForAll[from][msg.sender] || ownerOf[tokenId] == msg.sender, - "not approved" - ); - ownerOf[tokenId] = to; - } -} - -contract InterlinkGatewayTest is Test { - InterlinkGateway internal gateway; - MockERC20 internal token; - MockERC721 internal nft; - - address internal guardian = address(0xDAD); - address internal user = address(0xBEEF); - - function setUp() public { - gateway = new InterlinkGateway(guardian); - token = new MockERC20(); - nft = new MockERC721(); - - // Fund user with ERC-20 tokens - token.mint(user, 1e18); - vm.prank(user); - token.approve(address(gateway), type(uint256).max); - - // Mint an NFT to user - nft.mint(user, 1); - vm.prank(user); - nft.setApprovalForAll(address(gateway), true); - } - - // ───────────────────────────────────────────────────────────── - // sendCrossChainMessage - // ───────────────────────────────────────────────────────────── - - function testSendCrossChainMessage_Native(uint64 destChain, uint128 amount) public { - vm.assume(amount > 0 && amount < 1e24); - vm.deal(user, amount); - vm.prank(user); - gateway.sendCrossChainMessage{value: amount}(destChain, address(0), amount, ""); - } - - function testSendCrossChainMessage_Token(uint64 destChain, uint128 amount) public { - vm.assume(amount > 0 && amount <= 1e18); - vm.prank(user); - gateway.sendCrossChainMessage(destChain, address(token), amount, "hello"); - assertEq(token.balanceOf(address(gateway)), amount, "gateway should hold tokens"); - } - - function testSendCrossChainMessage_EmitsEvent() public { - vm.deal(user, 1 ether); - vm.prank(user); - vm.expectEmit(true, false, false, false); - emit InterlinkGateway.MessagePublished(0, 999, user, bytes32(0), ""); - gateway.sendCrossChainMessage{value: 1 ether}(999, address(0), 1 ether, ""); - } - - function testSendCrossChainMessage_IncrementsNonce() public { - vm.deal(user, 3 ether); - vm.startPrank(user); - gateway.sendCrossChainMessage{value: 1 ether}(1, address(0), 1 ether, ""); - gateway.sendCrossChainMessage{value: 1 ether}(2, address(0), 1 ether, ""); - gateway.sendCrossChainMessage{value: 1 ether}(3, address(0), 1 ether, ""); - vm.stopPrank(); - assertEq(gateway.currentNonce(), 3); - } - - function testSendCrossChainMessage_RevertsWhenPaused() public { - vm.prank(guardian); - gateway.pause(); - vm.deal(user, 1 ether); - vm.prank(user); - vm.expectRevert("Interlink: Gateway is paused"); - gateway.sendCrossChainMessage{value: 1 ether}(1, address(0), 1 ether, ""); - } - - function testSendCrossChainMessage_RevertsWrongNativeValue() public { - vm.deal(user, 2 ether); - vm.prank(user); - vm.expectRevert("Interlink: Incorrect native value sent"); - gateway.sendCrossChainMessage{value: 2 ether}(1, address(0), 1 ether, ""); - } - - // ───────────────────────────────────────────────────────────── - // initiateSwap - // ───────────────────────────────────────────────────────────── - - function testInitiateSwap_Native( - uint64 destChain, - uint128 amountIn, - uint128 minAmountOut - ) public { - vm.assume(amountIn > 0 && amountIn < 1e24); - vm.deal(user, amountIn); - vm.prank(user); - gateway.initiateSwap{value: amountIn}( - destChain, address(0), amountIn, address(0x1), minAmountOut, user, "" - ); - } - - function testInitiateSwap_Token(uint128 amountIn, uint128 minAmountOut) public { - vm.assume(amountIn > 0 && amountIn <= 1e18); - vm.prank(user); - gateway.initiateSwap(1, address(token), amountIn, address(0x1), minAmountOut, user, ""); - assertEq(token.balanceOf(address(gateway)), amountIn); - } - - function testInitiateSwap_RevertsZeroAmount() public { - vm.prank(user); - vm.expectRevert("Interlink: zero amount"); - gateway.initiateSwap(1, address(token), 0, address(0x1), 0, user, ""); - } - - function testInitiateSwap_RevertsZeroRecipient() public { - vm.prank(user); - vm.expectRevert("Interlink: zero recipient"); - gateway.initiateSwap(1, address(token), 1e6, address(0x1), 0, address(0), ""); - } - - // ───────────────────────────────────────────────────────────── - // lockNFT - // ───────────────────────────────────────────────────────────── - - function testLockNFT_Success(uint64 destChain) public { - vm.assume(destChain > 0); - bytes32 recipient = bytes32(uint256(uint160(user))); - vm.prank(user); - gateway.lockNFT(address(nft), 1, destChain, recipient); - assertEq(nft.ownerOf(1), address(gateway), "gateway should hold NFT"); - } - - function testLockNFT_RevertsZeroContract() public { - vm.prank(user); - vm.expectRevert("Interlink: zero NFT contract"); - gateway.lockNFT(address(0), 1, 1, bytes32(uint256(1))); - } - - function testLockNFT_RevertsZeroRecipient() public { - vm.prank(user); - vm.expectRevert("Interlink: zero recipient"); - gateway.lockNFT(address(nft), 1, 1, bytes32(0)); - } - - // ───────────────────────────────────────────────────────────── - // executeVerifiedMessage - // ───────────────────────────────────────────────────────────── - - function testExecuteVerifiedMessage_RejectsWithoutVK() public { - bytes memory proof = new bytes(256); - vm.expectRevert("Interlink: VK not initialized"); - gateway.executeVerifiedMessage(address(this), 0, "", proof); - } - - function testExecuteVerifiedMessage_RejectsShortProof() public { - _setDummyVK(); - bytes memory shortProof = new bytes(100); - vm.expectRevert("Interlink: Invalid Groth16 proof"); - gateway.executeVerifiedMessage(address(this), 0, "", shortProof); - } - - function testExecuteVerifiedMessage_RejectsReplay() public { - // Mark nonce 77 as executed via storage manipulation - bytes32 slot = keccak256(abi.encode(uint64(77), uint256(1))); - vm.store(address(gateway), slot, bytes32(uint256(1))); - - _setDummyVK(); - bytes memory proof = new bytes(256); - vm.expectRevert("Interlink: Message already executed"); - gateway.executeVerifiedMessage(address(this), 77, "", proof); - } - - function testExecuteVerifiedMessage_RejectsZeroTarget() public { - _setDummyVK(); - bytes memory proof = new bytes(256); - vm.expectRevert("Interlink: zero target"); - gateway.executeVerifiedMessage(address(0), 0, "", proof); - } - - // ───────────────────────────────────────────────────────────── - // setVerificationKey - // ───────────────────────────────────────────────────────────── - - function testSetVK_Success() public { - _setDummyVK(); - assertTrue(gateway.vkInitialized()); - } - - function testSetVK_RevertsNonGuardian() public { - vm.prank(user); - vm.expectRevert("Interlink: Unauthorized"); - gateway.setVerificationKey( - [uint256(0), 0], [[uint256(0), 0], [uint256(0), 0]], - [[uint256(0), 0], [uint256(0), 0]], [[uint256(0), 0], [uint256(0), 0]], - [uint256(0), 0], [uint256(0), 0] - ); - } - - // ───────────────────────────────────────────────────────────── - // Admin - // ───────────────────────────────────────────────────────────── - - function testPauseUnpause() public { - vm.prank(guardian); - gateway.pause(); - assertTrue(gateway.paused()); - - vm.prank(guardian); - gateway.unpause(); - assertFalse(gateway.paused()); - } - - function testPause_RevertsNonGuardian() public { - vm.prank(user); - vm.expectRevert("Interlink: Unauthorized"); - gateway.pause(); - } - - function testEmergencyWithdraw_ETH() public { - vm.deal(address(gateway), 1 ether); - uint256 before = guardian.balance; - vm.prank(guardian); - gateway.emergencyWithdraw(address(0), guardian, 1 ether); - assertEq(guardian.balance - before, 1 ether); - } - - function testEmergencyWithdraw_Token() public { - token.mint(address(gateway), 500); - vm.prank(guardian); - gateway.emergencyWithdraw(address(token), guardian, 500); - assertEq(token.balanceOf(guardian), 500); - } - - // ─── Helpers ───────────────────────────────────────────────── - - function _setDummyVK() internal { - vm.prank(guardian); - gateway.setVerificationKey( - [uint256(1), 2], - [[uint256(1), 2], [uint256(3), 4]], - [[uint256(1), 2], [uint256(3), 4]], - [[uint256(1), 2], [uint256(3), 4]], - [uint256(1), 2], - [uint256(1), 2] - ); - } - - receive() external payable {} -} +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import "forge-std/Test.sol"; +import "../src/InterlinkGateway.sol"; + +/// @dev Standard ERC-20 returning bool. +contract MockERC20 { + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + function mint(address to, uint256 amount) external { + balanceOf[to] += amount; + } + + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + return true; + } + + function transferFrom(address from, address to, uint256 amount) external virtual returns (bool) { + _move(from, to, amount); + return true; + } + + function transfer(address to, uint256 amount) external returns (bool) { + require(balanceOf[msg.sender] >= amount, "insufficient balance"); + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + return true; + } + + function _move(address from, address to, uint256 amount) internal virtual { + require(balanceOf[from] >= amount, "insufficient balance"); + require(allowance[from][msg.sender] >= amount, "insufficient allowance"); + balanceOf[from] -= amount; + allowance[from][msg.sender] -= amount; + balanceOf[to] += amount; + } +} + +/// @dev USDT-style token: transferFrom returns nothing. The old gateway reverted on these. +contract MockNoReturnToken is MockERC20 { + function transferFrom(address from, address to, uint256 amount) external override returns (bool) { + _move(from, to, amount); + assembly { + return(0, 0) + } + } +} + +/// @dev Burns 1% on every transfer. The old gateway recorded the requested amount, not the received one. +contract MockFeeToken is MockERC20 { + function _move(address from, address to, uint256 amount) internal override { + require(balanceOf[from] >= amount, "insufficient balance"); + require(allowance[from][msg.sender] >= amount, "insufficient allowance"); + balanceOf[from] -= amount; + allowance[from][msg.sender] -= amount; + balanceOf[to] += amount - amount / 100; + } +} + +contract MockERC721 { + mapping(uint256 => address) public ownerOf; + mapping(address => mapping(address => bool)) public isApprovedForAll; + + function mint(address to, uint256 tokenId) external { + ownerOf[tokenId] = to; + } + + function setApprovalForAll(address operator, bool approved) external { + isApprovedForAll[msg.sender][operator] = approved; + } + + function transferFrom(address from, address to, uint256 tokenId) external { + require(ownerOf[tokenId] == from, "not owner"); + require(isApprovedForAll[from][msg.sender] || from == msg.sender, "not approved"); + ownerOf[tokenId] = to; + } +} + +contract InterlinkGatewayTest is Test { + uint64 internal constant HUB = 2; + bytes32 internal constant HUB_PROGRAM = bytes32(uint256(0x7B7B7B7B7B7B7B7B7B7B7B7B7B7B7B7B7B7B7B7B7B7B7B7B7B7B7B7B7B7B7B7B)); + bytes32 internal constant SOL_RECIPIENT = bytes32(uint256(0x4444)); + + InterlinkGateway internal gateway; + MockERC20 internal token; + MockERC721 internal nft; + + address internal guardian = address(0xDAD); + address internal user = address(0xBEEF0); + + function setUp() public { + gateway = new InterlinkGateway(guardian); + token = new MockERC20(); + nft = new MockERC721(); + + vm.prank(guardian); + gateway.setRemoteGateway(HUB, HUB_PROGRAM); + + token.mint(user, 1e18); + vm.prank(user); + token.approve(address(gateway), type(uint256).max); + + nft.mint(user, 1); + vm.prank(user); + nft.setApprovalForAll(address(gateway), true); + } + + // ─── sendCrossChainMessage ────────────────────────────────────────────── + + function testSend_Native(uint128 amount) public { + vm.assume(amount > 0 && amount < 1e24); + vm.deal(user, amount); + vm.prank(user); + gateway.sendCrossChainMessage{value: amount}(HUB, address(0), amount, SOL_RECIPIENT, ""); + assertEq(gateway.totalLocked(address(0)), amount); + } + + function testSend_Token(uint128 amount) public { + vm.assume(amount > 0 && amount <= 1e18); + vm.prank(user); + gateway.sendCrossChainMessage(HUB, address(token), amount, SOL_RECIPIENT, "hello"); + assertEq(token.balanceOf(address(gateway)), amount); + assertEq(gateway.totalLocked(address(token)), amount); + assertTrue(gateway.isCustodiedAsset(address(token))); + } + + function testSend_EmitsCanonicalHash() public { + vm.deal(user, 1 ether); + bytes32 expected = gateway.hashMessage( + InterlinkGateway.Message({ + srcChain: uint64(block.chainid), + srcGateway: bytes32(uint256(uint160(address(gateway)))), + dstChain: HUB, + dstGateway: HUB_PROGRAM, + nonce: 0, + action: 1, + sender: bytes32(uint256(uint160(user))), + asset: bytes32(0), + amount: 1 ether, + recipient: SOL_RECIPIENT, + extraHash: keccak256("") + }) + ); + vm.expectEmit(true, true, false, true); + emit InterlinkGateway.MessagePublished( + expected, 0, HUB, HUB_PROGRAM, 1, bytes32(uint256(uint160(user))), bytes32(0), + 1 ether, SOL_RECIPIENT, keccak256(""), "" + ); + vm.prank(user); + bytes32 h = gateway.sendCrossChainMessage{value: 1 ether}(HUB, address(0), 1 ether, SOL_RECIPIENT, ""); + assertEq(h, expected); + } + + function testSend_IncrementsNonce() public { + vm.deal(user, 3 ether); + vm.startPrank(user); + for (uint256 i; i < 3; ++i) { + gateway.sendCrossChainMessage{value: 1 ether}(HUB, address(0), 1 ether, SOL_RECIPIENT, ""); + } + vm.stopPrank(); + assertEq(gateway.currentNonce(), 3); + } + + function testSend_RevertsUnsupportedDestination() public { + vm.deal(user, 1 ether); + vm.prank(user); + vm.expectRevert("Interlink: unsupported destination"); + gateway.sendCrossChainMessage{value: 1 ether}(999, address(0), 1 ether, SOL_RECIPIENT, ""); + } + + function testSend_RevertsZeroRecipient() public { + vm.deal(user, 1 ether); + vm.prank(user); + vm.expectRevert("Interlink: zero recipient"); + gateway.sendCrossChainMessage{value: 1 ether}(HUB, address(0), 1 ether, bytes32(0), ""); + } + + function testSend_RevertsWhenPaused() public { + vm.prank(guardian); + gateway.pause(); + vm.deal(user, 1 ether); + vm.prank(user); + vm.expectRevert("Interlink: Gateway is paused"); + gateway.sendCrossChainMessage{value: 1 ether}(HUB, address(0), 1 ether, SOL_RECIPIENT, ""); + } + + function testSend_RevertsWrongNativeValue() public { + vm.deal(user, 2 ether); + vm.prank(user); + vm.expectRevert("Interlink: Incorrect native value sent"); + gateway.sendCrossChainMessage{value: 2 ether}(HUB, address(0), 1 ether, SOL_RECIPIENT, ""); + } + + /// The old gateway silently kept ETH sent alongside an ERC-20 deposit. + function testSend_RevertsNativeWithToken() public { + vm.deal(user, 1 ether); + vm.prank(user); + vm.expectRevert("Interlink: unexpected native value"); + gateway.sendCrossChainMessage{value: 1 ether}(HUB, address(token), 1e6, SOL_RECIPIENT, ""); + } + + function testSend_NoReturnTokenWorks() public { + MockNoReturnToken usdt = new MockNoReturnToken(); + usdt.mint(user, 1e6); + vm.startPrank(user); + usdt.approve(address(gateway), type(uint256).max); + gateway.sendCrossChainMessage(HUB, address(usdt), 1e6, SOL_RECIPIENT, ""); + vm.stopPrank(); + assertEq(usdt.balanceOf(address(gateway)), 1e6); + } + + function testSend_FeeOnTransferRecordsReceived() public { + MockFeeToken fot = new MockFeeToken(); + fot.mint(user, 1000); + vm.startPrank(user); + fot.approve(address(gateway), type(uint256).max); + gateway.sendCrossChainMessage(HUB, address(fot), 1000, SOL_RECIPIENT, ""); + vm.stopPrank(); + // 1% burned in transit: only 990 arrived, so only 990 may be minted remotely. + assertEq(gateway.totalLocked(address(fot)), 990); + } + + // ─── initiateSwap ─────────────────────────────────────────────────────── + + function testInitiateSwap_Native(uint128 amountIn, uint64 minAmountOut) public { + vm.assume(amountIn > 0 && amountIn < 1e24); + vm.deal(user, amountIn); + vm.prank(user); + gateway.initiateSwap{value: amountIn}( + HUB, address(0), amountIn, bytes32(uint256(1)), minAmountOut, SOL_RECIPIENT, "" + ); + } + + function testInitiateSwap_Token(uint128 amountIn) public { + vm.assume(amountIn > 0 && amountIn <= 1e18); + vm.prank(user); + gateway.initiateSwap(HUB, address(token), amountIn, bytes32(uint256(1)), 0, SOL_RECIPIENT, ""); + assertEq(token.balanceOf(address(gateway)), amountIn); + } + + function testInitiateSwap_RevertsZeroAmount() public { + vm.prank(user); + vm.expectRevert("Interlink: zero amount"); + gateway.initiateSwap(HUB, address(token), 0, bytes32(uint256(1)), 0, SOL_RECIPIENT, ""); + } + + function testInitiateSwap_RevertsZeroRecipient() public { + vm.prank(user); + vm.expectRevert("Interlink: zero recipient"); + gateway.initiateSwap(HUB, address(token), 1e6, bytes32(uint256(1)), 0, bytes32(0), ""); + } + + /// Solana's min_amount_out is a u64; a larger value could never be executed there. + function testInitiateSwap_RevertsMinOutTooLarge() public { + vm.prank(user); + vm.expectRevert("Interlink: minAmountOut too large"); + gateway.initiateSwap( + HUB, address(token), 1e6, bytes32(uint256(1)), uint256(type(uint64).max) + 1, SOL_RECIPIENT, "" + ); + } + + // ─── lockNFT ──────────────────────────────────────────────────────────── + + function _enableNfts() internal { + vm.prank(guardian); + gateway.setNftBridgingEnabled(true); + } + + /// No destination mints NFTs yet, so locking must be refused by default. + function testLockNFT_DisabledByDefault() public { + vm.prank(user); + vm.expectRevert("Interlink: NFT bridging disabled"); + gateway.lockNFT(address(nft), 1, HUB, SOL_RECIPIENT); + } + + function testLockNFT_Success() public { + _enableNfts(); + vm.prank(user); + gateway.lockNFT(address(nft), 1, HUB, SOL_RECIPIENT); + assertEq(nft.ownerOf(1), address(gateway)); + assertTrue(gateway.isCustodiedAsset(address(nft))); + } + + function testLockNFT_RevertsZeroContract() public { + _enableNfts(); + vm.prank(user); + vm.expectRevert("Interlink: zero NFT contract"); + gateway.lockNFT(address(0), 1, HUB, SOL_RECIPIENT); + } + + function testLockNFT_RevertsZeroRecipient() public { + _enableNfts(); + vm.prank(user); + vm.expectRevert("Interlink: zero recipient"); + gateway.lockNFT(address(nft), 1, HUB, bytes32(0)); + } + + // ─── Inbound ──────────────────────────────────────────────────────────── + + function _inbound(uint64 srcChain, uint64 nonce, uint256 amount, bytes32 recipient) + internal + pure + returns (InterlinkGateway.InboundMessage memory) + { + return InterlinkGateway.InboundMessage({ + srcChain: srcChain, + nonce: nonce, + sender: bytes32(uint256(0x2222222222222222222222222222222222222222222222222222222222222222)), + asset: bytes32(0), + amount: amount, + recipient: recipient, + extraHash: bytes32(0) + }); + } + + function testRelease_RejectsWithoutVK() public { + vm.expectRevert("Interlink: VK not initialized"); + gateway.releaseTokens(_inbound(HUB, 0, 1, bytes32(uint256(1))), new bytes(256)); + } + + function testRelease_RejectsUnknownSource() public { + vm.expectRevert("Interlink: unknown source"); + gateway.releaseTokens(_inbound(777, 0, 1, bytes32(uint256(1))), new bytes(256)); + } + + function testRelease_RejectsShortProof() public { + _setVK(); + vm.expectRevert("Interlink: Invalid Groth16 proof"); + gateway.releaseTokens(_inbound(HUB, 0, 1, bytes32(uint256(1))), new bytes(100)); + } + + function testExecute_RejectsGatewayTarget() public { + InterlinkGateway.InboundMessage memory m = _inbound(HUB, 0, 0, bytes32(uint256(uint160(address(gateway))))); + m.extraHash = keccak256(""); + vm.expectRevert("Interlink: cannot target gateway"); + gateway.executeVerifiedMessage(m, "", new bytes(256)); + } + + /// The Poly Network pattern: a verified message calling transfer() on a token the gateway holds. + function testExecute_RejectsCustodiedAssetTarget() public { + vm.prank(user); + gateway.sendCrossChainMessage(HUB, address(token), 1e6, SOL_RECIPIENT, ""); + + bytes memory drain = abi.encodeWithSignature("transfer(address,uint256)", user, 1e6); + InterlinkGateway.InboundMessage memory m = _inbound(HUB, 0, 0, bytes32(uint256(uint160(address(token))))); + m.extraHash = keccak256(drain); + vm.expectRevert("Interlink: cannot target custodied asset"); + gateway.executeVerifiedMessage(m, drain, new bytes(256)); + } + + function testExecute_RejectsPayloadMismatch() public { + InterlinkGateway.InboundMessage memory m = _inbound(HUB, 0, 0, bytes32(uint256(uint160(address(nft))))); + vm.expectRevert("Interlink: payload mismatch"); + gateway.executeVerifiedMessage(m, "abc", new bytes(256)); + } + + // ─── Real Groth16 proof (vectors generated with arkworks, same circuit as the relayer) ── + + address internal constant FIXED_GATEWAY = address(0xBEEF); + address internal constant ALICE = address(bytes20(hex"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1")); + + bytes internal constant VECTOR_VK = + hex"0c979f59e5895b10449268398f0db786395c5ea0f1639a4105d2da89c7a698d31c26c5ea66e22593ca04900a58f911e02cb8120ff8d33f5d6a5f09f0fe80f92226008bcf4712e4ab5fd29d1d2632f5ba0bfbbcd3ecb1771bd112d4a1032a083e237a26cb82383692eb3549cc2d68742b77640dbc85a96326372f331748754bb81b2d67f7335c72b71bcb733b640ede3a4c6717547044e2327cbd54cf48a0b4c905beccc090ee25c17faf35a961abd4b6a7d838218b9beffde40508b76ea89d5319b5573a88d18b7ddbafc40e6e599564b095753f6705dfb31977a444366444fc0f1a58c5d2e95f98adbd5b8d48ab566741090b45622857b4bdc7b6970ce9214727a16870423faf5043c301cc910b5b25cf099d5ca81077b7b7b6a2ccb274688f29aaa08b9f4215b34868c2f46f3be1c2127eb3ca20bfb3320126a65b6586df960805e000d90aa564a32ba87ff512414ae1d21a4030b88e83c0eb63945c1d9132289cd2569be6afe09adce2da6a1c3756b20c19e02e40fde823d1acaa14fd0c220fde4483949def9e453f6fc6d92fd2310de0c0c14fdbfc0732588921d43ceaa81dfadabc69a3ccc1324950584809b578303b93de910ea913eb6ddeacb499cc7d093248e2a1bb61fe40204550b442301cef82927fcdf4666214b788b6b125a0f920ee1dd515db33cf0bbe53fe1a5497d6f2c118c77fd9d1a1122ad630227a176d26e020ca711f929004761bab615cd9c49c99555cdca2f7729cd67863573ee8902f4f49f31ac94ec1622e485395ae960d379b2712518f07243487b520f5db5bee"; + bytes internal constant VECTOR_PROOF = + hex"0fed83419f7f06715c3e7801df6367567e91ff5bdd1de06451fa90ae3bbf20642a8d58599f50ef49aa10f9479c3e8a29681a5b376ea9e9db80d3df8d8d519a363054c78659383754e249470acca978d435840c9f14fec9fc04bfd7325fadeb951b283c66be1b817ef3c0c82ac5fa22456dc4400471986d4cb3fdebfe48f632081984b0fc46c9061b069e63da6aed37f5a3983320fde262ba0bdfdb5f6bd6ba892a8bc753b802634b6bc326beca87b9bc528744881f89e819420b6c09fb86406320aba599a62f1bca3505a30f0b2c09038672263d9d3cc3d3de3e69e16d2af297269fa19aa435b065b07f196064bd0eecef162b48030c14827ac96dbf34f475b5"; + bytes32 internal constant VECTOR_HASH = 0x07b0f8b8ce5c6f82e0bb552073f1c35df9b0987c6e2c598c52ed5b59ee1c09bd; + + function _fixedGateway() internal returns (InterlinkGateway g) { + vm.chainId(31337); + deployCodeTo("InterlinkGateway.sol:InterlinkGateway", abi.encode(guardian), FIXED_GATEWAY); + g = InterlinkGateway(FIXED_GATEWAY); + vm.startPrank(guardian); + g.setRemoteGateway(HUB, HUB_PROGRAM); + g.setVerificationKey(VECTOR_VK); + vm.stopPrank(); + // Lock 5 ETH so there is custody to release. + vm.deal(user, 5 ether); + vm.prank(user); + g.sendCrossChainMessage{value: 5 ether}(HUB, address(0), 5 ether, SOL_RECIPIENT, ""); + } + + function testRealProof_ReleasesExactlyOnce() public { + InterlinkGateway g = _fixedGateway(); + InterlinkGateway.InboundMessage memory m = _inbound(HUB, 7, 2 ether, bytes32(uint256(uint160(ALICE)))); + + g.releaseTokens(m, VECTOR_PROOF); + assertEq(ALICE.balance, 2 ether); + assertEq(g.totalLocked(address(0)), 3 ether); + assertTrue(g.executedMessages(VECTOR_HASH)); + + vm.expectRevert("Interlink: Message already executed"); + g.releaseTokens(m, VECTOR_PROOF); + } + + function testRealProof_RejectsTamperedAmount() public { + InterlinkGateway g = _fixedGateway(); + InterlinkGateway.InboundMessage memory m = _inbound(HUB, 7, 3 ether, bytes32(uint256(uint160(ALICE)))); + vm.expectRevert("Interlink: Invalid Groth16 proof"); + g.releaseTokens(m, VECTOR_PROOF); + } + + function testRealProof_RejectsTamperedRecipient() public { + InterlinkGateway g = _fixedGateway(); + InterlinkGateway.InboundMessage memory m = _inbound(HUB, 7, 2 ether, bytes32(uint256(uint160(user)))); + vm.expectRevert("Interlink: Invalid Groth16 proof"); + g.releaseTokens(m, VECTOR_PROOF); + } + + // ─── Verification key ─────────────────────────────────────────────────── + + function testSetVK_Success() public { + _setVK(); + assertTrue(gateway.vkInitialized()); + } + + function testSetVK_OnlyOnce() public { + _setVK(); + vm.prank(guardian); + vm.expectRevert("Interlink: use proposeVerificationKey"); + gateway.setVerificationKey(VECTOR_VK); + } + + function testSetVK_RevertsNonGuardian() public { + vm.prank(user); + vm.expectRevert("Interlink: Unauthorized"); + gateway.setVerificationKey(VECTOR_VK); + } + + function testSetVK_RevertsWrongLength() public { + vm.prank(guardian); + vm.expectRevert("Interlink: VK must be 576 bytes"); + gateway.setVerificationKey(new bytes(100)); + } + + function testVKRotation_Timelocked() public { + _setVK(); + vm.startPrank(guardian); + gateway.proposeVerificationKey(VECTOR_VK); + vm.expectRevert("Interlink: timelock"); + gateway.applyVerificationKey(); + vm.warp(block.timestamp + gateway.TIMELOCK()); + gateway.applyVerificationKey(); + vm.stopPrank(); + } + + // ─── Remote gateways ──────────────────────────────────────────────────── + + function testRemote_ChangeRequiresTimelock() public { + vm.startPrank(guardian); + vm.expectRevert("Interlink: use proposeRemoteGateway"); + gateway.setRemoteGateway(HUB, bytes32(uint256(1))); + + gateway.proposeRemoteGateway(HUB, bytes32(uint256(1))); + vm.expectRevert("Interlink: timelock"); + gateway.applyRemoteGateway(HUB); + vm.warp(block.timestamp + gateway.TIMELOCK()); + gateway.applyRemoteGateway(HUB); + vm.stopPrank(); + assertEq(gateway.remoteGateways(HUB), bytes32(uint256(1))); + } + + /// Disable-then-set must not bypass the timelock. + function testRemote_DisableThenSetStillTimelocked() public { + vm.startPrank(guardian); + gateway.disableRemoteGateway(HUB); + vm.expectRevert("Interlink: use proposeRemoteGateway"); + gateway.setRemoteGateway(HUB, bytes32(uint256(1))); + vm.stopPrank(); + } + + // ─── Guardian / admin ─────────────────────────────────────────────────── + + function testGuardian_TwoStepTransfer() public { + address next = address(0x1234); + vm.prank(guardian); + gateway.transferGuardian(next); + assertEq(gateway.guardian(), guardian); + + vm.prank(user); + vm.expectRevert("Interlink: not pending guardian"); + gateway.acceptGuardian(); + + vm.prank(next); + gateway.acceptGuardian(); + assertEq(gateway.guardian(), next); + } + + function testPauseUnpause() public { + vm.prank(guardian); + gateway.pause(); + assertTrue(gateway.paused()); + vm.prank(guardian); + gateway.unpause(); + assertFalse(gateway.paused()); + } + + function testPause_RevertsNonGuardian() public { + vm.prank(user); + vm.expectRevert("Interlink: Unauthorized"); + gateway.pause(); + } + + function testEmergencyWithdraw_RequiresPause() public { + vm.deal(address(gateway), 1 ether); + vm.prank(guardian); + vm.expectRevert("Interlink: pause first"); + gateway.emergencyWithdraw(address(0), guardian, 1 ether); + } + + function testEmergencyWithdraw_ETH() public { + vm.deal(address(gateway), 1 ether); + vm.startPrank(guardian); + gateway.pause(); + uint256 before = guardian.balance; + gateway.emergencyWithdraw(address(0), guardian, 1 ether); + vm.stopPrank(); + assertEq(guardian.balance - before, 1 ether); + } + + function testEmergencyWithdraw_Token() public { + token.mint(address(gateway), 500); + vm.startPrank(guardian); + gateway.pause(); + gateway.emergencyWithdraw(address(token), guardian, 500); + vm.stopPrank(); + assertEq(token.balanceOf(guardian), 500); + } + + // ─── Helpers ──────────────────────────────────────────────────────────── + + function _setVK() internal { + vm.prank(guardian); + gateway.setVerificationKey(VECTOR_VK); + } +} diff --git a/contracts/solana/Anchor.toml b/contracts/solana/Anchor.toml deleted file mode 100644 index 5a5ecba..0000000 --- a/contracts/solana/Anchor.toml +++ /dev/null @@ -1,21 +0,0 @@ -[toolchain] - -[features] -seeds = false -skip-lint = false - -[programs.localnet] -solana_gateway = "Hub1111111111111111111111111111111111111111" - -[programs.devnet] -solana_gateway = "Hub1111111111111111111111111111111111111111" - -[registry] -url = "https://api.apr.dev" - -[provider] -cluster = "Localnet" -wallet = "~/.config/solana/id.json" - -[scripts] -test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts" diff --git a/contracts/solana/Cargo.lock b/contracts/solana/Cargo.lock deleted file mode 100644 index 0c787c0..0000000 --- a/contracts/solana/Cargo.lock +++ /dev/null @@ -1,2678 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "version_check", -] - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "anchor-attribute-access-control" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5f619f1d04f53621925ba8a2e633ba5a6081f2ae14758cbb67f38fd823e0a3e" -dependencies = [ - "anchor-syn", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "anchor-attribute-account" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7f2a3e1df4685f18d12a943a9f2a7456305401af21a07c9fe076ef9ecd6e400" -dependencies = [ - "anchor-syn", - "bs58 0.5.1", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "anchor-attribute-constant" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9423945cb55627f0b30903288e78baf6f62c6c8ab28fb344b6b25f1ffee3dca7" -dependencies = [ - "anchor-syn", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "anchor-attribute-error" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93ed12720033cc3c3bf3cfa293349c2275cd5ab99936e33dd4bf283aaad3e241" -dependencies = [ - "anchor-syn", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "anchor-attribute-event" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eef4dc0371eba2d8c8b54794b0b0eb786a234a559b77593d6f80825b6d2c77a2" -dependencies = [ - "anchor-syn", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "anchor-attribute-program" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b18c4f191331e078d4a6a080954d1576241c29c56638783322a18d308ab27e4f" -dependencies = [ - "anchor-syn", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "anchor-derive-accounts" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5de10d6e9620d3bcea56c56151cad83c5992f50d5960b3a9bebc4a50390ddc3c" -dependencies = [ - "anchor-syn", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "anchor-derive-serde" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e2e5be518ec6053d90a2a7f26843dbee607583c779e6c8395951b9739bdfbe" -dependencies = [ - "anchor-syn", - "borsh-derive-internal 0.10.4", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "anchor-derive-space" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ecc31d19fa54840e74b7a979d44bcea49d70459de846088a1d71e87ba53c419" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "anchor-lang" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35da4785497388af0553586d55ebdc08054a8b1724720ef2749d313494f2b8ad" -dependencies = [ - "anchor-attribute-access-control", - "anchor-attribute-account", - "anchor-attribute-constant", - "anchor-attribute-error", - "anchor-attribute-event", - "anchor-attribute-program", - "anchor-derive-accounts", - "anchor-derive-serde", - "anchor-derive-space", - "arrayref", - "base64 0.13.1", - "bincode", - "borsh 0.10.4", - "bytemuck", - "getrandom 0.2.17", - "solana-program", - "thiserror", -] - -[[package]] -name = "anchor-spl" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c4fd6e43b2ca6220d2ef1641539e678bfc31b6cc393cf892b373b5997b6a39a" -dependencies = [ - "anchor-lang", - "solana-program", - "spl-associated-token-account", - "spl-token", - "spl-token-2022 0.9.0", -] - -[[package]] -name = "anchor-syn" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9101b84702fed2ea57bd22992f75065da5648017135b844283a2f6d74f27825" -dependencies = [ - "anyhow", - "bs58 0.5.1", - "heck", - "proc-macro2", - "quote", - "serde", - "serde_json", - "sha2 0.10.9", - "syn 1.0.109", - "thiserror", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "ark-bn254" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a22f4561524cd949590d78d7d4c5df8f592430d221f7f3c9497bbafd8972120f" -dependencies = [ - "ark-ec", - "ark-ff", - "ark-std", -] - -[[package]] -name = "ark-ec" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "defd9a439d56ac24968cca0571f598a61bc8c55f71d50a89cda591cb750670ba" -dependencies = [ - "ark-ff", - "ark-poly", - "ark-serialize", - "ark-std", - "derivative", - "hashbrown 0.13.2", - "itertools", - "num-traits", - "zeroize", -] - -[[package]] -name = "ark-ff" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" -dependencies = [ - "ark-ff-asm", - "ark-ff-macros", - "ark-serialize", - "ark-std", - "derivative", - "digest 0.10.7", - "itertools", - "num-bigint", - "num-traits", - "paste", - "rustc_version", - "zeroize", -] - -[[package]] -name = "ark-ff-asm" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" -dependencies = [ - "quote", - "syn 1.0.109", -] - -[[package]] -name = "ark-ff-macros" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" -dependencies = [ - "num-bigint", - "num-traits", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "ark-poly" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d320bfc44ee185d899ccbadfa8bc31aab923ce1558716e1997a1e74057fe86bf" -dependencies = [ - "ark-ff", - "ark-serialize", - "ark-std", - "derivative", - "hashbrown 0.13.2", -] - -[[package]] -name = "ark-serialize" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" -dependencies = [ - "ark-serialize-derive", - "ark-std", - "digest 0.10.7", - "num-bigint", -] - -[[package]] -name = "ark-serialize-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae3281bc6d0fd7e549af32b52511e1302185bd688fd3359fa36423346ff682ea" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "ark-std" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" -dependencies = [ - "num-traits", - "rand 0.8.5", -] - -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - -[[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 = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" -dependencies = [ - "hermit-abi", - "libc", - "winapi", -] - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "base64" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff" - -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - -[[package]] -name = "bitflags" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" -dependencies = [ - "serde_core", -] - -[[package]] -name = "bitmaps" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2" -dependencies = [ - "typenum", -] - -[[package]] -name = "blake3" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" -dependencies = [ - "arrayref", - "arrayvec", - "cc", - "cfg-if", - "constant_time_eq", - "cpufeatures", - "digest 0.10.7", -] - -[[package]] -name = "block-buffer" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" -dependencies = [ - "block-padding", - "generic-array", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15bf3650200d8bffa99015595e10f1fbd17de07abbc25bb067da79e769939bfa" -dependencies = [ - "borsh-derive 0.9.3", - "hashbrown 0.11.2", -] - -[[package]] -name = "borsh" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115e54d64eb62cdebad391c19efc9dce4981c690c85a33a12199d99bb9546fee" -dependencies = [ - "borsh-derive 0.10.4", - "hashbrown 0.13.2", -] - -[[package]] -name = "borsh" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" -dependencies = [ - "borsh-derive 1.6.0", - "cfg_aliases", -] - -[[package]] -name = "borsh-derive" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6441c552f230375d18e3cc377677914d2ca2b0d36e52129fe15450a2dce46775" -dependencies = [ - "borsh-derive-internal 0.9.3", - "borsh-schema-derive-internal 0.9.3", - "proc-macro-crate 0.1.5", - "proc-macro2", - "syn 1.0.109", -] - -[[package]] -name = "borsh-derive" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831213f80d9423998dd696e2c5345aba6be7a0bd8cd19e31c5243e13df1cef89" -dependencies = [ - "borsh-derive-internal 0.10.4", - "borsh-schema-derive-internal 0.10.4", - "proc-macro-crate 0.1.5", - "proc-macro2", - "syn 1.0.109", -] - -[[package]] -name = "borsh-derive" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" -dependencies = [ - "once_cell", - "proc-macro-crate 3.4.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "borsh-derive-internal" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5449c28a7b352f2d1e592a8a28bf139bc71afb0764a14f3c02500935d8c44065" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "borsh-derive-internal" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65d6ba50644c98714aa2a70d13d7df3cd75cd2b523a2b452bf010443800976b3" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "borsh-schema-derive-internal" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdbd5696d8bfa21d53d9fe39a714a18538bad11492a42d066dbbc395fb1951c0" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "borsh-schema-derive-internal" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "276691d96f063427be83e6692b86148e488ebba9f48f77788724ca027ba3b6d4" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "bs58" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "771fe0050b883fcc3ea2359b1a96bcfbc090b7116eae7c3c512c7a083fdf23d3" - -[[package]] -name = "bs58" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "bv" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8834bb1d8ee5dc048ee3124f2c7c1afcc6bc9aed03f11e9dfd8c69470a5db340" -dependencies = [ - "feature-probe", - "serde", -] - -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" -dependencies = [ - "bytemuck_derive", -] - -[[package]] -name = "bytemuck_derive" -version = "1.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "cc" -version = "1.2.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" -dependencies = [ - "cfg-if", - "wasm-bindgen", -] - -[[package]] -name = "console_log" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89f72f65e8501878b8a004d5a1afb780987e2ce2b4532c562e367a72c57499f" -dependencies = [ - "log", - "web-sys", -] - -[[package]] -name = "constant_time_eq" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "crypto-mac" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b584a330336237c1eecd3e94266efb216c56ed91225d634cb2991c5f3fd1aeab" -dependencies = [ - "generic-array", - "subtle", -] - -[[package]] -name = "ctr" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "049bb91fb4aaf0e3c7efa6cd5ef877dbbbd15b39dad06d9948de4ec8a75761ea" -dependencies = [ - "cipher", -] - -[[package]] -name = "curve25519-dalek" -version = "3.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f9d052967f590a76e62eb387bd0bbb1b000182c3cefe5364db6b7211651bc0" -dependencies = [ - "byteorder", - "digest 0.9.0", - "rand_core 0.5.1", - "serde", - "subtle", - "zeroize", -] - -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.117", -] - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "digest" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" -dependencies = [ - "generic-array", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "crypto-common", - "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.9", -] - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[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.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "feature-probe" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "835a3dc7d1ec9e75e2b5fb4ba75396837112d2060b03f7d43bc1897c7f7211da" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "serde", - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] - -[[package]] -name = "hashbrown" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" -dependencies = [ - "ahash 0.7.8", -] - -[[package]] -name = "hashbrown" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" -dependencies = [ - "ahash 0.8.12", -] - -[[package]] -name = "hashbrown" -version = "0.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - -[[package]] -name = "heck" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "126888268dcc288495a26bf004b38c5fdbb31682f992c84ceb046a1f0fe38840" -dependencies = [ - "crypto-mac", - "digest 0.9.0", -] - -[[package]] -name = "hmac" -version = "0.12.1" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17ea0a1394df5b6574da6e0c1ade9e78868c9fb0a4e5ef4428e32da4676b85b1" -dependencies = [ - "digest 0.9.0", - "generic-array", - "hmac 0.8.1", -] - -[[package]] -name = "humantime" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0acd33ff0285af998aaf9b57342af478078f53492322fafc47450e09397e0e9" -dependencies = [ - "bitmaps", - "rand_core 0.6.4", - "rand_xoshiro", - "rayon", - "serde", - "sized-chunks", - "typenum", - "version_check", -] - -[[package]] -name = "indexmap" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" -dependencies = [ - "equivalent", - "hashbrown 0.16.1", -] - -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.88" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e709f3e3d22866f9c25b3aff01af289b18422cc8b4262fb19103ee80fe513d" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "keccak" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" -dependencies = [ - "cpufeatures", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "libc" -version = "0.2.182" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" - -[[package]] -name = "libsecp256k1" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9d220bc1feda2ac231cb78c3d26f27676b8cf82c96971f7aeef3d0cf2797c73" -dependencies = [ - "arrayref", - "base64 0.12.3", - "digest 0.9.0", - "hmac-drbg", - "libsecp256k1-core", - "libsecp256k1-gen-ecmult", - "libsecp256k1-gen-genmult", - "rand 0.7.3", - "serde", - "sha2 0.9.9", - "typenum", -] - -[[package]] -name = "libsecp256k1-core" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0f6ab710cec28cef759c5f18671a27dae2a5f952cdaaee1d8e2908cb2478a80" -dependencies = [ - "crunchy", - "digest 0.9.0", - "subtle", -] - -[[package]] -name = "libsecp256k1-gen-ecmult" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccab96b584d38fac86a83f07e659f0deafd0253dc096dab5a36d53efe653c5c3" -dependencies = [ - "libsecp256k1-core", -] - -[[package]] -name = "libsecp256k1-gen-genmult" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67abfe149395e3aa1c48a2beb32b068e2334402df8181f818d3aee2b304c4f5d" -dependencies = [ - "libsecp256k1-core", -] - -[[package]] -name = "light-poseidon" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c9a85a9752c549ceb7578064b4ed891179d20acd85f27318573b64d2d7ee7ee" -dependencies = [ - "ark-bn254", - "ark-ff", - "num-bigint", - "thiserror", -] - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "memmap2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83faa42c0a078c393f6b29d5db232d8be22776a891f8f56e5284faee4a20b327" -dependencies = [ - "libc", -] - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_enum" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" -dependencies = [ - "num_enum_derive", - "rustversion", -] - -[[package]] -name = "num_enum_derive" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" -dependencies = [ - "proc-macro-crate 3.4.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pbkdf2" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216eaa586a190f0a738f2f918511eecfa90f13295abec0e457cdebcceda80cbd" -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.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[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.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro-crate" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d6ea3c4595b96363c13943497db34af4460fb474a95c43f4446ad341b8c9785" -dependencies = [ - "toml", -] - -[[package]] -name = "proc-macro-crate" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" -dependencies = [ - "toml_edit", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -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.117", -] - -[[package]] -name = "quote" -version = "1.0.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "rand" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", -] - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" -dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "rand_xoshiro" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" -dependencies = [ - "rand_core 0.6.4", -] - -[[package]] -name = "rayon" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" - -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_bytes" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[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.117", -] - -[[package]] -name = "sha2" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" -dependencies = [ - "block-buffer 0.9.0", - "cfg-if", - "cpufeatures", - "digest 0.9.0", - "opaque-debug", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" -dependencies = [ - "digest 0.10.7", - "keccak", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -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 = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" - -[[package]] -name = "sized-chunks" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e" -dependencies = [ - "bitmaps", - "typenum", -] - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "solana-frozen-abi" -version = "1.18.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ab2c30c15311b511c0d1151e4ab6bc9a3e080a37e7c6e7c2d96f5784cf9434" -dependencies = [ - "block-buffer 0.10.4", - "bs58 0.4.0", - "bv", - "either", - "generic-array", - "im", - "lazy_static", - "log", - "memmap2", - "rustc_version", - "serde", - "serde_bytes", - "serde_derive", - "sha2 0.10.9", - "solana-frozen-abi-macro", - "subtle", - "thiserror", -] - -[[package]] -name = "solana-frozen-abi-macro" -version = "1.18.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c142f779c3633ac83c84d04ff06c70e1f558c876f13358bed77ba629c7417932" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.117", -] - -[[package]] -name = "solana-gateway" -version = "0.1.0" -dependencies = [ - "anchor-lang", - "anchor-spl", -] - -[[package]] -name = "solana-logger" -version = "1.18.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121d36ffb3c6b958763312cbc697fbccba46ee837d3a0aa4fc0e90fcb3b884f3" -dependencies = [ - "env_logger", - "lazy_static", - "log", -] - -[[package]] -name = "solana-program" -version = "1.18.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c10f4588cefd716b24a1a40dd32c278e43a560ab8ce4de6b5805c9d113afdfa1" -dependencies = [ - "ark-bn254", - "ark-ec", - "ark-ff", - "ark-serialize", - "base64 0.21.7", - "bincode", - "bitflags", - "blake3", - "borsh 0.10.4", - "borsh 0.9.3", - "borsh 1.6.0", - "bs58 0.4.0", - "bv", - "bytemuck", - "cc", - "console_error_panic_hook", - "console_log", - "curve25519-dalek", - "getrandom 0.2.17", - "itertools", - "js-sys", - "lazy_static", - "libc", - "libsecp256k1", - "light-poseidon", - "log", - "memoffset", - "num-bigint", - "num-derive", - "num-traits", - "parking_lot", - "rand 0.8.5", - "rustc_version", - "rustversion", - "serde", - "serde_bytes", - "serde_derive", - "serde_json", - "sha2 0.10.9", - "sha3 0.10.8", - "solana-frozen-abi", - "solana-frozen-abi-macro", - "solana-sdk-macro", - "thiserror", - "tiny-bip39", - "wasm-bindgen", - "zeroize", -] - -[[package]] -name = "solana-sdk" -version = "1.18.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "580ad66c2f7a4c3cb3244fe21440546bd500f5ecb955ad9826e92a78dded8009" -dependencies = [ - "assert_matches", - "base64 0.21.7", - "bincode", - "bitflags", - "borsh 1.6.0", - "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.9", - "sha3 0.10.8", - "siphasher", - "solana-frozen-abi", - "solana-frozen-abi-macro", - "solana-logger", - "solana-program", - "solana-sdk-macro", - "thiserror", - "uriparse", - "wasm-bindgen", -] - -[[package]] -name = "solana-sdk-macro" -version = "1.18.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b75d0f193a27719257af19144fdaebec0415d1c9e9226ae4bd29b791be5e9bd" -dependencies = [ - "bs58 0.4.0", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.117", -] - -[[package]] -name = "solana-security-txt" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "156bb61a96c605fa124e052d630dba2f6fb57e08c7d15b757e1e958b3ed7b3fe" -dependencies = [ - "hashbrown 0.15.2", -] - -[[package]] -name = "solana-zk-token-sdk" -version = "1.18.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cbdf4249b6dfcbba7d84e2b53313698043f60f8e22ce48286e6fbe8a17c8d16" -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", - "solana-sdk", - "subtle", - "thiserror", - "zeroize", -] - -[[package]] -name = "spl-associated-token-account" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "992d9c64c2564cc8f63a4b508bf3ebcdf2254b0429b13cd1d31adb6162432a5f" -dependencies = [ - "assert_matches", - "borsh 0.10.4", - "num-derive", - "num-traits", - "solana-program", - "spl-token", - "spl-token-2022 1.0.0", - "thiserror", -] - -[[package]] -name = "spl-discriminator" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cce5d563b58ef1bb2cdbbfe0dfb9ffdc24903b10ae6a4df2d8f425ece375033f" -dependencies = [ - "bytemuck", - "solana-program", - "spl-discriminator-derive", -] - -[[package]] -name = "spl-discriminator-derive" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07fd7858fc4ff8fb0e34090e41d7eb06a823e1057945c26d480bfc21d2338a93" -dependencies = [ - "quote", - "spl-discriminator-syn", - "syn 2.0.117", -] - -[[package]] -name = "spl-discriminator-syn" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18fea7be851bd98d10721782ea958097c03a0c2a07d8d4997041d0ece6319a63" -dependencies = [ - "proc-macro2", - "quote", - "sha2 0.10.9", - "syn 2.0.117", - "thiserror", -] - -[[package]] -name = "spl-memo" -version = "4.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a49f49f95f2d02111ded31696ab38a081fab623d4c76bd4cb074286db4560836" -dependencies = [ - "solana-program", -] - -[[package]] -name = "spl-pod" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2881dddfca792737c0706fa0175345ab282b1b0879c7d877bad129645737c079" -dependencies = [ - "borsh 0.10.4", - "bytemuck", - "solana-program", - "solana-zk-token-sdk", - "spl-program-error", -] - -[[package]] -name = "spl-program-error" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "249e0318493b6bcf27ae9902600566c689b7dfba9f1bdff5893e92253374e78c" -dependencies = [ - "num-derive", - "num-traits", - "solana-program", - "spl-program-error-derive", - "thiserror", -] - -[[package]] -name = "spl-program-error-derive" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1845dfe71fd68f70382232742e758557afe973ae19e6c06807b2c30f5d5cb474" -dependencies = [ - "proc-macro2", - "quote", - "sha2 0.10.9", - "syn 2.0.117", -] - -[[package]] -name = "spl-tlv-account-resolution" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "062e148d3eab7b165582757453632ffeef490c02c86a48bfdb4988f63eefb3b9" -dependencies = [ - "bytemuck", - "solana-program", - "spl-discriminator", - "spl-pod", - "spl-program-error", - "spl-type-length-value", -] - -[[package]] -name = "spl-tlv-account-resolution" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "615d381f48ddd2bb3c57c7f7fb207591a2a05054639b18a62e785117dd7a8683" -dependencies = [ - "bytemuck", - "solana-program", - "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", - "thiserror", -] - -[[package]] -name = "spl-token-2022" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4abf34a65ba420584a0c35f3903f8d727d1f13ababbdc3f714c6b065a686e86" -dependencies = [ - "arrayref", - "bytemuck", - "num-derive", - "num-traits", - "num_enum", - "solana-program", - "solana-zk-token-sdk", - "spl-memo", - "spl-pod", - "spl-token", - "spl-token-metadata-interface", - "spl-transfer-hook-interface 0.3.0", - "spl-type-length-value", - "thiserror", -] - -[[package]] -name = "spl-token-2022" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d697fac19fd74ff472dfcc13f0b442dd71403178ce1de7b5d16f83a33561c059" -dependencies = [ - "arrayref", - "bytemuck", - "num-derive", - "num-traits", - "num_enum", - "solana-program", - "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 0.4.1", - "spl-type-length-value", - "thiserror", -] - -[[package]] -name = "spl-token-group-interface" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b889509d49fa74a4a033ca5dae6c2307e9e918122d97e58562f5c4ffa795c75d" -dependencies = [ - "bytemuck", - "solana-program", - "spl-discriminator", - "spl-pod", - "spl-program-error", -] - -[[package]] -name = "spl-token-metadata-interface" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c16ce3ba6979645fb7627aa1e435576172dd63088dc7848cb09aa331fa1fe4f" -dependencies = [ - "borsh 0.10.4", - "solana-program", - "spl-discriminator", - "spl-pod", - "spl-program-error", - "spl-type-length-value", -] - -[[package]] -name = "spl-transfer-hook-interface" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "051d31803f873cabe71aec3c1b849f35248beae5d19a347d93a5c9cccc5d5a9b" -dependencies = [ - "arrayref", - "bytemuck", - "solana-program", - "spl-discriminator", - "spl-pod", - "spl-program-error", - "spl-tlv-account-resolution 0.4.0", - "spl-type-length-value", -] - -[[package]] -name = "spl-transfer-hook-interface" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aabdb7c471566f6ddcee724beb8618449ea24b399e58d464d6b5bc7db550259" -dependencies = [ - "arrayref", - "bytemuck", - "solana-program", - "spl-discriminator", - "spl-pod", - "spl-program-error", - "spl-tlv-account-resolution 0.5.1", - "spl-type-length-value", -] - -[[package]] -name = "spl-type-length-value" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a468e6f6371f9c69aae760186ea9f1a01c2908351b06a5e0026d21cfc4d7ecac" -dependencies = [ - "bytemuck", - "solana-program", - "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 = "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.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "tiny-bip39" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffc59cb9dfc85bb312c3a78fd6aa8a8582e310b0fa885d5bb877f6dcc601839d" -dependencies = [ - "anyhow", - "hmac 0.8.1", - "once_cell", - "pbkdf2 0.4.0", - "rand 0.7.3", - "rustc-hash", - "sha2 0.9.9", - "thiserror", - "unicode-normalization", - "wasm-bindgen", - "zeroize", -] - -[[package]] -name = "tinyvec" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "toml" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.23.10+spec-1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" -dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "winnow", -] - -[[package]] -name = "toml_parser" -version = "1.0.9+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" -dependencies = [ - "winnow", -] - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-segmentation" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec1adf1535672f5b7824f817792b1afd731d7e843d2d04ec8f27e8cb51edd8ac" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19e638317c08b21663aed4d2b9a2091450548954695ff4efa75bff5fa546b3b1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c64760850114d03d5f65457e96fc988f11f01d38fbaa51b254e4ab5809102af" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.117", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60eecd4fe26177cfa3339eb00b4a36445889ba3ad37080c2429879718e20ca41" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "web-sys" -version = "0.3.88" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d6bb20ed2d9572df8584f6dc81d68a41a625cadc6f15999d649a70ce7e3597a" -dependencies = [ - "js-sys", - "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.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -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-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "winnow" -version = "0.7.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" -dependencies = [ - "memchr", -] - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" - -[[package]] -name = "zerocopy" -version = "0.8.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "zeroize" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4756f7db3f7b5574938c3eb1c117038b8e07f95ee6718c0efad4ac21508f1efd" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/contracts/solana/Cargo.toml b/contracts/solana/Cargo.toml deleted file mode 100644 index d0600d1..0000000 --- a/contracts/solana/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "solana-gateway" -version = "0.1.0" -edition = "2021" - -[workspace] - -[lib] -crate-type = ["cdylib", "lib"] - -[features] -no-entrypoint = [] -no-idl = [] -no-log-ix-name = [] -cpi = ["no-entrypoint"] -default = [] - -[dependencies] -anchor-lang = "0.29.0" -anchor-spl = "0.29.0" -# pin to anchor's solana-program version. alt_bn128 available since 1.15. -solana-program = "=1.17.4" diff --git a/contracts/solana/README.md b/contracts/solana/README.md index e87f4d6..d273c86 100644 --- a/contracts/solana/README.md +++ b/contracts/solana/README.md @@ -1,20 +1,25 @@ -# contracts/solana - -Solana program (Anchor) that acts as the InterLink hub/verification side. - -## In plain terms - -This program is the on-chain place where relayers submit proofs and where InterLink keeps track of which cross-chain message sequences have been processed. - -## Layout - -- `src/` - - Anchor program entrypoints and account types. -- `Cargo.toml` / `Cargo.lock` - - Rust/Anchor build configuration. - -## What it does today - -- Initializes a `StateRegistry` account (admin + fee settings + last processed sequence) -- Accepts `submit_proof` transactions and advances the processed sequence -- Includes a `buy_back_and_burn` instruction for fee burning mechanics +# contracts/solana + +The Solana side of InterLink is the Anchor program in [`interlink-hub/`](interlink-hub/). + +An older standalone program used to live directly in this directory (`src/lib.rs`). +It was removed because its "Groth16" check (`e(A,B)·e(C,−G2) = 1`) used no +verification key or public inputs and could be forged by anyone. See +[docs/SECURITY_REVIEW.md](../../docs/SECURITY_REVIEW.md) (finding C6). + +## interlink-hub in brief + +- `submit_proof`: a staked relayer attests a fully specified cross-chain message; + the payload hash is recomputed on-chain and the Groth16 proof is checked. +- `execute_transfer` / `execute_swap`: permissionless execution of an attested + message (mint wrapped tokens, or swap through the hub pool). +- `burn_for_withdrawal`: burn wrapped tokens and emit an outbound message that + releases the original asset on the source chain. +- Staking with a 7-day unbonding period, timelocked verification-key rotation, + admin-only pools and wrapped-asset registry. + +```bash +cd interlink-hub +cargo test # host tests, incl. a real Groth16 proof through the verifier +anchor test # integration tests (requires Anchor CLI + local validator) +``` diff --git a/contracts/solana/interlink-hub/Cargo.lock b/contracts/solana/interlink-hub/Cargo.lock index 0420c8f..c4a56fa 100644 --- a/contracts/solana/interlink-hub/Cargo.lock +++ b/contracts/solana/interlink-hub/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "aead" @@ -289,6 +289,25 @@ dependencies = [ "ark-std", ] +[[package]] +name = "ark-crypto-primitives" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3a13b34da09176a8baba701233fdffbaa7c1b1192ce031a3da4e55ce1f1a56" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-relations", + "ark-serialize", + "ark-snark", + "ark-std", + "blake2", + "derivative", + "digest 0.10.7", + "rayon", + "sha2 0.10.9", +] + [[package]] name = "ark-ec" version = "0.4.2" @@ -303,6 +322,7 @@ dependencies = [ "hashbrown 0.13.2", "itertools", "num-traits", + "rayon", "zeroize", ] @@ -322,6 +342,7 @@ dependencies = [ "num-bigint", "num-traits", "paste", + "rayon", "rustc_version", "zeroize", ] @@ -349,6 +370,22 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "ark-groth16" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20ceafa83848c3e390f1cbf124bc3193b3e639b3f02009e0e290809a501b95fc" +dependencies = [ + "ark-crypto-primitives", + "ark-ec", + "ark-ff", + "ark-poly", + "ark-relations", + "ark-serialize", + "ark-std", + "rayon", +] + [[package]] name = "ark-poly" version = "0.4.2" @@ -360,6 +397,19 @@ dependencies = [ "ark-std", "derivative", "hashbrown 0.13.2", + "rayon", +] + +[[package]] +name = "ark-relations" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00796b6efc05a3f48225e59cb6a2cda78881e7c390872d5786aaf112f31fb4f0" +dependencies = [ + "ark-ff", + "ark-std", + "tracing", + "tracing-subscriber", ] [[package]] @@ -385,6 +435,18 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "ark-snark" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d3cc6833a335bb8a600241889ead68ee89a3cf8448081fb7694c0fe503da63" +dependencies = [ + "ark-ff", + "ark-relations", + "ark-serialize", + "ark-std", +] + [[package]] name = "ark-std" version = "0.4.0" @@ -393,6 +455,7 @@ checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" dependencies = [ "num-traits", "rand 0.8.5", + "rayon", ] [[package]] @@ -469,6 +532,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "blake3" version = "1.7.0" @@ -1171,6 +1243,12 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "ark-bn254", + "ark-ff", + "ark-groth16", + "ark-relations", + "ark-snark", + "ark-std", "solana-program", ] @@ -1467,6 +1545,12 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "polyval" version = "0.5.3" @@ -2478,6 +2562,35 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-subscriber" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0d2eaa99c3c2e41547cfa109e910a68ea03823cccad4a0525dcbc9b01e8c71" +dependencies = [ + "tracing-core", +] + [[package]] name = "typenum" version = "1.19.0" @@ -2525,6 +2638,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" diff --git a/contracts/solana/interlink-hub/out.log b/contracts/solana/interlink-hub/out.log deleted file mode 100644 index 1b48718..0000000 --- a/contracts/solana/interlink-hub/out.log +++ /dev/null @@ -1,15 +0,0 @@ - Updating crates.io index -error: failed to select a version for `solana-instruction`. - ... required by package `solana-zk-sdk v2.2.0` - ... which satisfies dependency `solana-zk-sdk = "^2.2.0"` of package `spl-token-2022 v8.0.1` - ... which satisfies dependency `spl-token-2022 = "^8"` of package `anchor-spl v0.32.1` - ... which satisfies dependency `anchor-spl = "^0.32.1"` of package `interlink-hub v0.1.0 (C:\Users\Ishaan\OneDrive\Desktop\Cobalt\contracts\solana\interlink-hub\programs\interlink-hub)` -versions that meet the requirements `=2.2.1` are: 2.2.1 - -all possible versions conflict with previously selected packages. - - previously selected package `solana-instruction v2.3.3` - ... which satisfies dependency `solana-instruction = "^2"` of package `anchor-lang v0.32.1` - ... which satisfies dependency `anchor-lang = "^0.32.1"` of package `interlink-hub v0.1.0 (C:\Users\Ishaan\OneDrive\Desktop\Cobalt\contracts\solana\interlink-hub\programs\interlink-hub)` - -failed to select a version for `solana-instruction` which could resolve this conflict diff --git a/contracts/solana/interlink-hub/package.json b/contracts/solana/interlink-hub/package.json index 364e46d..ee5a391 100644 --- a/contracts/solana/interlink-hub/package.json +++ b/contracts/solana/interlink-hub/package.json @@ -5,9 +5,10 @@ "lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check" }, "dependencies": { - "@coral-xyz/anchor": "^0.32.1" + "@coral-xyz/anchor": "^0.30.1" }, "devDependencies": { + "@solana/spl-token": "^0.4.9", "chai": "^4.3.4", "mocha": "^9.0.3", "ts-mocha": "^10.0.0", diff --git a/contracts/solana/interlink-hub/programs/interlink-hub/Cargo.toml b/contracts/solana/interlink-hub/programs/interlink-hub/Cargo.toml index 498bc50..4140e31 100644 --- a/contracts/solana/interlink-hub/programs/interlink-hub/Cargo.toml +++ b/contracts/solana/interlink-hub/programs/interlink-hub/Cargo.toml @@ -21,3 +21,13 @@ idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] anchor-lang = "0.30.1" anchor-spl = "0.30.1" solana-program = "=1.18.26" + +# Host-only: cross-check field arithmetic and run a real Groth16 proof +# through the on-chain verifier. +[dev-dependencies] +ark-bn254 = "0.4" +ark-ff = "0.4" +ark-groth16 = "0.4" +ark-relations = "0.4" +ark-snark = "0.4" +ark-std = "0.4" diff --git a/contracts/solana/interlink-hub/programs/interlink-hub/src/lib.rs b/contracts/solana/interlink-hub/programs/interlink-hub/src/lib.rs index 7ff6e5b..0673404 100644 --- a/contracts/solana/interlink-hub/programs/interlink-hub/src/lib.rs +++ b/contracts/solana/interlink-hub/programs/interlink-hub/src/lib.rs @@ -1,1215 +1,2027 @@ -use anchor_lang::prelude::*; -use anchor_lang::solana_program::alt_bn128::prelude::{ - alt_bn128_addition, alt_bn128_multiplication, alt_bn128_pairing, -}; -use anchor_spl::token::{self, Burn, Mint, MintTo, Token, TokenAccount, Transfer}; - -declare_id!("AKzpc9tvxfhLjj5AantKizK2YZgSjoyhLmYqRZy6b8Lz"); - -/// BN254 scalar field modulus r (NOT the base field p). -/// r = 21888242871839275222246405745257275088548364400416034343698204186575808495617 -/// hex: 30644e72e131a029b85045b68181585d 2833e84879b9709143e1f593f0000001 -const BN254_SCALAR_FIELD: [u8; 32] = [ - 0x30, 0x64, 0x4e, 0x72, 0xe1, 0x31, 0xa0, 0x29, - 0xb8, 0x50, 0x45, 0xb6, 0x81, 0x81, 0x58, 0x5d, - 0x28, 0x33, 0xe8, 0x48, 0x79, 0xb9, 0x70, 0x91, - 0x43, 0xe1, 0xf5, 0x93, 0xf0, 0x00, 0x00, 0x01, -]; - -/// Minimum ILINK stake required for relayers (100,000 tokens with 6 decimals) -const MIN_STAKE_AMOUNT: u64 = 100_000_000_000; - -/// Slashing percentage (50% of stake burned for invalid proofs) -const SLASH_BPS: u64 = 5_000; - -#[program] -pub mod interlink_hub { - use super::*; - - pub fn initialize(ctx: Context, fee_rate_bps: u16) -> Result<()> { - let registry = &mut ctx.accounts.state_registry; - registry.admin = ctx.accounts.admin.key(); - registry.fee_rate_bps = fee_rate_bps; - registry.next_sequence = 0; - registry.total_staked = 0; - registry.total_burned = 0; - registry.vk_initialized = false; - Ok(()) - } - - /// Store the Groth16 verification key on-chain. Must be called once after - /// trusted setup, before any proofs can be verified. Admin-only. - /// - /// vk_data layout (576 bytes for 1 public input): - /// alpha_g1: 64 bytes (G1) - /// beta_g2: 128 bytes (G2) - /// gamma_g2: 128 bytes (G2) - /// delta_g2: 128 bytes (G2) - /// ic_0: 64 bytes (G1) - /// ic_1: 64 bytes (G1) - pub fn set_verification_key( - ctx: Context, - vk_data: Vec, - ) -> Result<()> { - require!(vk_data.len() == 576, HubError::InvalidVKLength); - - let vk_account = &mut ctx.accounts.verification_key; - vk_account.alpha_g1.copy_from_slice(&vk_data[0..64]); - vk_account.beta_g2.copy_from_slice(&vk_data[64..192]); - vk_account.gamma_g2.copy_from_slice(&vk_data[192..320]); - vk_account.delta_g2.copy_from_slice(&vk_data[320..448]); - vk_account.ic_0.copy_from_slice(&vk_data[448..512]); - vk_account.ic_1.copy_from_slice(&vk_data[512..576]); - - let registry = &mut ctx.accounts.state_registry; - registry.vk_initialized = true; - - emit!(VKUpdatedEvent { - admin: ctx.accounts.admin.key(), - }); - - Ok(()) - } - - pub fn submit_proof( - ctx: Context, - _source_chain: u64, - sequence: u64, - proof_data: Vec, - payload_hash: [u8; 32], - _commitment_input: [u8; 32], - ) -> Result<()> { - // Require relayer has active stake - let stake_account = &ctx.accounts.stake_account; - require!( - stake_account.amount >= MIN_STAKE_AMOUNT, - HubError::InsufficientStake - ); - - let registry = &mut ctx.accounts.state_registry; - require!(registry.vk_initialized, HubError::VKNotInitialized); - - // Strict sequential ordering: EVM nonces start at 0 - require!( - sequence == registry.next_sequence, - HubError::SequenceAlreadyProcessed - ); - - require!(proof_data.len() == 256, HubError::InvalidProofLength); - - let vk = &ctx.accounts.verification_key; - require!( - verify_groth16_proof(&proof_data, &payload_hash, sequence, vk), - HubError::InvalidProof - ); - - registry.next_sequence = sequence + 1; - - emit!(ProofVerifiedEvent { - sequence, - payload_hash, - relayer: ctx.accounts.relayer.key() - }); - - Ok(()) - } - - /// Verify a ZK proof and mint wrapped tokens to the recipient. - pub fn verify_and_mint( - ctx: Context, - sequence: u64, - amount: u64, - proof_data: Vec, - payload_hash: [u8; 32], - ) -> Result<()> { - let stake_account = &ctx.accounts.stake_account; - require!( - stake_account.amount >= MIN_STAKE_AMOUNT, - HubError::InsufficientStake - ); - - let registry = &mut ctx.accounts.state_registry; - require!(registry.vk_initialized, HubError::VKNotInitialized); - - require!( - sequence == registry.next_sequence, - HubError::SequenceAlreadyProcessed - ); - - require!(proof_data.len() == 256, HubError::InvalidProofLength); - - let vk = &ctx.accounts.verification_key; - require!( - verify_groth16_proof(&proof_data, &payload_hash, sequence, vk), - HubError::InvalidProof - ); - - registry.next_sequence = sequence + 1; - - // Mint wrapped tokens to recipient via PDA authority - let seeds = &[b"mint_authority".as_ref(), &[ctx.bumps.mint_authority]]; - let signer_seeds = &[&seeds[..]]; - - let cpi_accounts = MintTo { - mint: ctx.accounts.token_mint.to_account_info(), - to: ctx.accounts.recipient_token_account.to_account_info(), - authority: ctx.accounts.mint_authority.to_account_info(), - }; - let cpi_ctx = CpiContext::new_with_signer( - ctx.accounts.token_program.to_account_info(), - cpi_accounts, - signer_seeds, - ); - token::mint_to(cpi_ctx, amount)?; - - emit!(MintEvent { - sequence, - recipient: ctx.accounts.recipient_token_account.key(), - amount, - payload_hash, - }); - - Ok(()) - } - - /// Execute a cross-chain swap through the hub's liquidity pool. - pub fn process_cross_chain_swap( - ctx: Context, - sequence: u64, - amount_in: u64, - min_amount_out: u64, - proof_data: Vec, - payload_hash: [u8; 32], - ) -> Result<()> { - let stake_account = &ctx.accounts.stake_account; - require!( - stake_account.amount >= MIN_STAKE_AMOUNT, - HubError::InsufficientStake - ); - - let registry = &mut ctx.accounts.state_registry; - require!(registry.vk_initialized, HubError::VKNotInitialized); - - require!( - sequence == registry.next_sequence, - HubError::SequenceAlreadyProcessed - ); - - require!(proof_data.len() == 256, HubError::InvalidProofLength); - - let vk = &ctx.accounts.verification_key; - require!( - verify_groth16_proof(&proof_data, &payload_hash, sequence, vk), - HubError::InvalidProof - ); - - registry.next_sequence = sequence + 1; - - // Constant product AMM: amount_out = (amount_in * reserve_out) / (reserve_in + amount_in) - // Extract fields and do all pool mutations in a block so the mutable borrow - // is released before we call to_account_info() for the CPI below. - let token_a_mint; - let token_b_mint; - let pool_bump; - let amount_out; - let fee; - { - let pool = &mut ctx.accounts.liquidity_pool; - let reserve_in = pool.reserve_a; - let reserve_out = pool.reserve_b; - - require!(reserve_in > 0 && reserve_out > 0, HubError::EmptyPool); - - // Apply fee (fee_rate_bps basis points) - fee = (amount_in as u128) - .checked_mul(registry.fee_rate_bps as u128) - .unwrap() - .checked_div(10_000) - .unwrap() as u64; - let amount_in_after_fee = amount_in.checked_sub(fee).unwrap(); - - amount_out = (amount_in_after_fee as u128) - .checked_mul(reserve_out as u128) - .unwrap() - .checked_div( - (reserve_in as u128) - .checked_add(amount_in_after_fee as u128) - .unwrap(), - ) - .unwrap() as u64; - - require!(amount_out >= min_amount_out, HubError::SlippageExceeded); - - // Update pool reserves - pool.reserve_a = reserve_in.checked_add(amount_in_after_fee).unwrap(); - pool.reserve_b = reserve_out.checked_sub(amount_out).unwrap(); - - token_a_mint = pool.token_a_mint; - token_b_mint = pool.token_b_mint; - pool_bump = ctx.bumps.liquidity_pool; - } // mutable borrow of liquidity_pool ends here - - // Transfer output tokens to recipient - let pool_seeds = &[ - b"liquidity_pool".as_ref(), - token_a_mint.as_ref(), - token_b_mint.as_ref(), - &[pool_bump], - ]; - let signer_seeds = &[&pool_seeds[..]]; - - let cpi_accounts = Transfer { - from: ctx.accounts.pool_token_b_vault.to_account_info(), - to: ctx.accounts.recipient_token_account.to_account_info(), - authority: ctx.accounts.liquidity_pool.to_account_info(), - }; - let cpi_ctx = CpiContext::new_with_signer( - ctx.accounts.token_program.to_account_info(), - cpi_accounts, - signer_seeds, - ); - token::transfer(cpi_ctx, amount_out)?; - - emit!(SwapExecutedEvent { - sequence, - amount_in, - amount_out, - fee, - recipient: ctx.accounts.recipient_token_account.key(), - }); - - Ok(()) - } - - /// Stake ILINK tokens to become a relayer. Minimum 100,000 ILINK required. - pub fn stake(ctx: Context, amount: u64) -> Result<()> { - require!(amount >= MIN_STAKE_AMOUNT, HubError::InsufficientStake); - - let cpi_accounts = Transfer { - from: ctx.accounts.relayer_token_account.to_account_info(), - to: ctx.accounts.stake_vault.to_account_info(), - authority: ctx.accounts.relayer.to_account_info(), - }; - let cpi_ctx = CpiContext::new( - ctx.accounts.token_program.to_account_info(), - cpi_accounts, - ); - token::transfer(cpi_ctx, amount)?; - - let stake_account = &mut ctx.accounts.stake_account; - stake_account.relayer = ctx.accounts.relayer.key(); - stake_account.amount = stake_account.amount.checked_add(amount).unwrap(); - stake_account.staked_at = Clock::get()?.unix_timestamp; - - let registry = &mut ctx.accounts.state_registry; - registry.total_staked = registry.total_staked.checked_add(amount).unwrap(); - - emit!(StakeEvent { - relayer: ctx.accounts.relayer.key(), - amount, - total_staked: stake_account.amount, - }); - - Ok(()) - } - - /// Unstake ILINK tokens. Relayer must have no pending proofs. - pub fn unstake(ctx: Context, amount: u64) -> Result<()> { - let stake_account = &mut ctx.accounts.stake_account; - require!(stake_account.amount >= amount, HubError::InsufficientStake); - - let remaining = stake_account.amount.checked_sub(amount).unwrap(); - require!( - remaining == 0 || remaining >= MIN_STAKE_AMOUNT, - HubError::InsufficientStake - ); - - let vault_seeds = &[ - b"stake_vault".as_ref(), - stake_account.relayer.as_ref(), - &[ctx.bumps.stake_vault], - ]; - let signer_seeds = &[&vault_seeds[..]]; - - let cpi_accounts = Transfer { - from: ctx.accounts.stake_vault.to_account_info(), - to: ctx.accounts.relayer_token_account.to_account_info(), - authority: ctx.accounts.stake_vault.to_account_info(), - }; - let cpi_ctx = CpiContext::new_with_signer( - ctx.accounts.token_program.to_account_info(), - cpi_accounts, - signer_seeds, - ); - token::transfer(cpi_ctx, amount)?; - - stake_account.amount = remaining; - - let registry = &mut ctx.accounts.state_registry; - registry.total_staked = registry.total_staked.checked_sub(amount).unwrap(); - - emit!(UnstakeEvent { - relayer: ctx.accounts.relayer.key(), - amount, - remaining: stake_account.amount, - }); - - Ok(()) - } - - /// Slash a relayer for submitting an invalid proof. Burns 50% of their stake. - pub fn slash_relayer(ctx: Context) -> Result<()> { - let stake_account = &mut ctx.accounts.stake_account; - let slash_amount = stake_account - .amount - .checked_mul(SLASH_BPS) - .unwrap() - .checked_div(10_000) - .unwrap(); - - stake_account.amount = stake_account.amount.checked_sub(slash_amount).unwrap(); - - let vault_seeds = &[ - b"stake_vault".as_ref(), - stake_account.relayer.as_ref(), - &[ctx.bumps.stake_vault], - ]; - let signer_seeds = &[&vault_seeds[..]]; - - let cpi_accounts = Burn { - mint: ctx.accounts.ilink_mint.to_account_info(), - from: ctx.accounts.stake_vault.to_account_info(), - authority: ctx.accounts.stake_vault.to_account_info(), - }; - let cpi_ctx = CpiContext::new_with_signer( - ctx.accounts.token_program.to_account_info(), - cpi_accounts, - signer_seeds, - ); - token::burn(cpi_ctx, slash_amount)?; - - let registry = &mut ctx.accounts.state_registry; - registry.total_staked = registry.total_staked.checked_sub(slash_amount).unwrap(); - registry.total_burned = registry.total_burned.checked_add(slash_amount).unwrap(); - - emit!(SlashEvent { - relayer: stake_account.relayer, - slash_amount, - remaining_stake: stake_account.amount, - }); - - Ok(()) - } - - pub fn buy_back_and_burn(ctx: Context, amount: u64) -> Result<()> { - let burn_amount = (amount as u128 * 40 / 100) as u64; - - let cpi_accounts = Burn { - mint: ctx.accounts.ilink_mint.to_account_info(), - from: ctx.accounts.fee_vault.to_account_info(), - authority: ctx.accounts.fee_authority.to_account_info(), - }; - let cpi_program = ctx.accounts.token_program.to_account_info(); - let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts); - token::burn(cpi_ctx, burn_amount)?; - - let registry = &mut ctx.accounts.state_registry; - registry.total_burned = registry.total_burned.checked_add(burn_amount).unwrap(); - - emit!(TokenBurnedEvent { - amount: burn_amount, - }); - - Ok(()) - } - - /// Initialize a liquidity pool for cross-chain swaps. - pub fn initialize_pool( - ctx: Context, - initial_reserve_a: u64, - initial_reserve_b: u64, - ) -> Result<()> { - require!( - initial_reserve_a > 0 && initial_reserve_b > 0, - HubError::EmptyPool - ); - - let cpi_a = Transfer { - from: ctx.accounts.admin_token_a.to_account_info(), - to: ctx.accounts.pool_token_a_vault.to_account_info(), - authority: ctx.accounts.admin.to_account_info(), - }; - token::transfer( - CpiContext::new(ctx.accounts.token_program.to_account_info(), cpi_a), - initial_reserve_a, - )?; - - let cpi_b = Transfer { - from: ctx.accounts.admin_token_b.to_account_info(), - to: ctx.accounts.pool_token_b_vault.to_account_info(), - authority: ctx.accounts.admin.to_account_info(), - }; - token::transfer( - CpiContext::new(ctx.accounts.token_program.to_account_info(), cpi_b), - initial_reserve_b, - )?; - - let pool = &mut ctx.accounts.liquidity_pool; - pool.token_a_mint = ctx.accounts.token_a_mint.key(); - pool.token_b_mint = ctx.accounts.token_b_mint.key(); - pool.reserve_a = initial_reserve_a; - pool.reserve_b = initial_reserve_b; - pool.admin = ctx.accounts.admin.key(); - - emit!(PoolInitializedEvent { - token_a_mint: pool.token_a_mint, - token_b_mint: pool.token_b_mint, - reserve_a: initial_reserve_a, - reserve_b: initial_reserve_b, - }); - - Ok(()) - } -} - -// ─── Groth16 Verification ─────────────────────────────────────────────────── -// -// Standard Groth16 verification equation (4-pairing check): -// e(A, B) = e(alpha, beta) · e(L, gamma) · e(C, delta) -// -// Equivalently as a single multi-pairing == 1 check: -// e(-A, B) · e(alpha, beta) · e(L, gamma) · e(C, delta) = 1 -// -// Where L = IC[0] + commitment * IC[1] -// and commitment is derived from the payload_hash via domain-separated keccak. - -/// Domain separation salt (must match prover and EVM gateway) -const DOMAIN_SALT: &[u8] = b"interlink_v1_domain"; - -/// Derive the public input (commitment) from a payload_hash. -/// This matches the prover's computation: -/// msg = Fr::from_be_bytes_mod_order(payload_hash) -/// rc = Fr::from_be_bytes_mod_order(keccak256("interlink_v1_domain")) -/// commitment = (msg + rc)^5 + sequence -/// -/// However, on-chain we verify against the commitment directly encoded in the -/// VK's IC array via the Groth16 protocol. We just need the commitment as a -/// scalar for the IC linear combination. -/// -/// For the standard Groth16 flow, the public input IS the commitment scalar -/// that was used during proving. The verifier recomputes: -/// L = IC[0] + commitment * IC[1] -/// and checks the pairing equation. -/// -/// The commitment binds to the payload because the circuit constrains: -/// commitment = (hash(payload) + rc)^5 + sequence -/// -/// So we need to recompute commitment on-chain to use as the public input. -fn compute_commitment_on_chain(payload_hash: &[u8; 32], sequence: u64) -> [u8; 32] { - use anchor_lang::solana_program::keccak; - - // rc = keccak256("interlink_v1_domain") reduced mod BN254 scalar field - let rc_hash = keccak::hash(DOMAIN_SALT).to_bytes(); - let rc = reduce_mod_scalar_field(&rc_hash); - - // msg = payload_hash reduced mod BN254 scalar field - let msg = reduce_mod_scalar_field(payload_hash); - - // w = msg + rc (mod r) - let w = field_add(&msg, &rc); - - // w^2 - let w2 = field_mul(&w, &w); - // w^4 - let w4 = field_mul(&w2, &w2); - // w^5 - let w5 = field_mul(&w4, &w); - - // seq as field element - let mut seq_bytes = [0u8; 32]; - seq_bytes[24..32].copy_from_slice(&sequence.to_be_bytes()); - - // commitment = w^5 + seq - field_add(&w5, &seq_bytes) -} - -/// Reduce a 32-byte big-endian value modulo the BN254 scalar field. -/// Uses repeated subtraction (at most 3 iterations since 2^256 / r ≈ 4). -fn reduce_mod_scalar_field(val: &[u8; 32]) -> [u8; 32] { - let mut result = *val; - // At most 3 subtractions needed (2^256 / r < 4) - for _ in 0..4 { - if !ge_scalar_field(&result) { - break; - } - result = field_sub_modulus(&result); - } - result -} - -/// Check if val >= BN254_SCALAR_FIELD (big-endian comparison). -fn ge_scalar_field(val: &[u8; 32]) -> bool { - for i in 0..32 { - if val[i] > BN254_SCALAR_FIELD[i] { - return true; - } - if val[i] < BN254_SCALAR_FIELD[i] { - return false; - } - } - true // equal -} - -/// Subtract BN254_SCALAR_FIELD from val (big-endian). Assumes val >= modulus. -fn field_sub_modulus(val: &[u8; 32]) -> [u8; 32] { - let mut result = [0u8; 32]; - let mut borrow: u16 = 0; - for i in (0..32).rev() { - let diff = (val[i] as u16) - .wrapping_sub(BN254_SCALAR_FIELD[i] as u16) - .wrapping_sub(borrow); - result[i] = diff as u8; - borrow = if diff > 255 { 1 } else { 0 }; - } - result -} - -/// Field addition mod BN254 scalar field (big-endian 32-byte inputs). -fn field_add(a: &[u8; 32], b: &[u8; 32]) -> [u8; 32] { - let mut result = [0u8; 32]; - let mut carry: u16 = 0; - for i in (0..32).rev() { - let sum = (a[i] as u16) + (b[i] as u16) + carry; - result[i] = sum as u8; - carry = sum >> 8; - } - // Reduce if >= modulus - if carry > 0 || ge_scalar_field(&result) { - result = field_sub_modulus(&result); - } - result -} - -/// Field multiplication mod BN254 scalar field (big-endian 32-byte inputs). -/// Uses schoolbook multiplication into a 64-byte intermediate, then Barrett reduction. -fn field_mul(a: &[u8; 32], b: &[u8; 32]) -> [u8; 32] { - // Schoolbook multiplication: a * b → 64-byte product - let mut product = [0u16; 64]; - for i in (0..32).rev() { - let mut carry: u16 = 0; - for j in (0..32).rev() { - let idx = i + j + 1; - let val = product[idx] + (a[i] as u16) * (b[j] as u16) + carry; - product[idx] = val & 0xFF; - carry = val >> 8; - } - product[i] += carry; - } - - // Propagate carries - let mut wide = [0u8; 64]; - let mut carry: u16 = 0; - for i in (0..64).rev() { - let val = product[i] + carry; - wide[i] = val as u8; - carry = val >> 8; - } - - // Reduce mod scalar field: repeated subtraction on the 512-bit value - // For production, use Montgomery or Barrett reduction. For correctness, - // we use the alt_bn128_multiplication syscall to compute a*b*G1 and - // extract the scalar. But that's circular. Instead, implement proper - // reduction via repeated conditional subtraction on the wide value. - // - // Since r ≈ 2^254, a 512-bit value needs at most ~2^258/r ≈ 16 subtractions - // of r from the high part. But this is expensive in compute units. - // - // Better approach: use the ECMUL precompile to do modular arithmetic. - // Compute: a_scalar * (b_scalar * G1) where G1 = (1, 2). - // The x-coordinate of the result encodes the product mod r... but not directly. - // - // For correctness and simplicity: implement proper 256-bit modular reduction. - // The product is at most 2^512. We need product mod r. - - // Simple long-division style reduction: - // Process the upper 32 bytes, shift and subtract. - let mut remainder = [0u8; 32]; - remainder.copy_from_slice(&wide[32..64]); - - // Process each byte of the upper half - for byte_idx in 0..32 { - // Shift remainder left by 8 bits, add next byte from upper half - // Actually we need to process from MSB to LSB of the wide number. - // Let's use a different approach: schoolbook division. - // - // For on-chain efficiency, we use the ECMUL trick: - // To compute (a * b) mod r, note that ECMUL computes scalar * Point - // where scalar is automatically reduced mod r. - // So we can compute: ECMUL(ECMUL(G1, a), b) to get (a*b mod r) * G1 - // But we can't extract the scalar back from the point. - // - // Instead, we'll do modular multiplication via the schoolbook method - // with proper 512→256 bit reduction. - let _ = byte_idx; // suppress warning - } - - // Proper 512-bit to 256-bit reduction using shift-and-subtract. - // Process the wide number byte by byte from MSB. - let mut acc = [0u8; 33]; // 33 bytes to handle overflow during shift - for byte_idx in 0..64 { - // Shift accumulator left by 8 bits - for k in 0..32 { - acc[k] = acc[k + 1]; - } - acc[32] = wide[byte_idx]; - - // While acc >= r, subtract r - loop { - // Check if acc[0..33] >= r (treating acc as 33-byte big-endian) - // If acc[0] > 0, it's definitely >= r (since r fits in 32 bytes) - if acc[0] > 0 { - // Subtract r from acc[1..33] - let mut borrow: u16 = 0; - for k in (0..32).rev() { - let diff = (acc[k + 1] as u16) - .wrapping_sub(BN254_SCALAR_FIELD[k] as u16) - .wrapping_sub(borrow); - acc[k + 1] = diff as u8; - borrow = if diff > 255 { 1 } else { 0 }; - } - // borrow from acc[0] - acc[0] = acc[0].wrapping_sub(borrow as u8); - } else { - // acc[0] == 0, check acc[1..33] >= BN254_SCALAR_FIELD - let mut ge = true; - for k in 0..32 { - if acc[k + 1] > BN254_SCALAR_FIELD[k] { - break; - } - if acc[k + 1] < BN254_SCALAR_FIELD[k] { - ge = false; - break; - } - } - if ge { - let mut borrow: u16 = 0; - for k in (0..32).rev() { - let diff = (acc[k + 1] as u16) - .wrapping_sub(BN254_SCALAR_FIELD[k] as u16) - .wrapping_sub(borrow); - acc[k + 1] = diff as u8; - borrow = if diff > 255 { 1 } else { 0 }; - } - } else { - break; - } - } - } - } - - let mut out = [0u8; 32]; - out.copy_from_slice(&acc[1..33]); - out -} - -/// Negate a G1 point (flip y coordinate: y' = p - y where p is the base field modulus). -/// Base field p = 21888242871839275222246405745257275088696311157297823662689037894645226208583 -const BN254_BASE_FIELD: [u8; 32] = [ - 0x30, 0x64, 0x4e, 0x72, 0xe1, 0x31, 0xa0, 0x29, - 0xb8, 0x50, 0x45, 0xb6, 0x81, 0x81, 0x58, 0x5d, - 0x97, 0x81, 0x6a, 0x91, 0x68, 0x71, 0xca, 0x8d, - 0x3c, 0x20, 0x8c, 0x16, 0xd8, 0x7c, 0xfd, 0x47, -]; - -fn negate_g1(point: &[u8; 64]) -> [u8; 64] { - let mut result = [0u8; 64]; - result[0..32].copy_from_slice(&point[0..32]); // x unchanged - - // y' = p - y - let mut borrow: u16 = 0; - for i in (0..32).rev() { - let diff = (BN254_BASE_FIELD[i] as u16) - .wrapping_sub(point[32 + i] as u16) - .wrapping_sub(borrow); - result[32 + i] = diff as u8; - borrow = if diff > 255 { 1 } else { 0 }; - } - result -} - -/// Standard Groth16 verification using BN254 precompiles. -/// -/// Verification equation (single multi-pairing check): -/// e(-A, B) · e(alpha, beta) · e(L, gamma) · e(C, delta) = 1 -/// -/// Where L = IC[0] + public_input * IC[1] -/// -/// The public input is the circuit commitment: (msg + rc)^5 + seq, -/// where msg = payload_hash mod r, rc = keccak256(DOMAIN_SALT) mod r, -/// seq = sequence as field element. This matches the prover exactly. -/// -/// Uses 4 pairings in a single alt_bn128_pairing syscall (768 bytes input). -fn verify_groth16_proof( - proof: &[u8], - payload_hash: &[u8; 32], - sequence: u64, - vk: &VerificationKey, -) -> bool { - if proof.len() != 256 { - return false; - } - - // Extract proof points - let a: [u8; 64] = proof[0..64].try_into().unwrap(); - let b: [u8; 128] = proof[64..192].try_into().unwrap(); - let c: [u8; 64] = proof[192..256].try_into().unwrap(); - - // Recompute the public input commitment identically to the prover: - // commitment = (msg + rc)^5 + seq - // where msg = payload_hash mod r, rc = keccak256("interlink_v1_domain") mod r. - let commitment = compute_commitment_on_chain(payload_hash, sequence); - - // L = IC[0] + commitment * IC[1] (ECMUL + ECADD) - let mut ecmul_input = [0u8; 96]; - ecmul_input[0..64].copy_from_slice(&vk.ic_1); - ecmul_input[64..96].copy_from_slice(&commitment); - - let scaled_ic1 = match alt_bn128_multiplication(&ecmul_input) { - Ok(r) => r, - Err(_) => return false, - }; - - let mut ecadd_input = [0u8; 128]; - ecadd_input[0..64].copy_from_slice(&vk.ic_0); - ecadd_input[64..128].copy_from_slice(&scaled_ic1[..64]); - - let l_point = match alt_bn128_addition(&ecadd_input) { - Ok(r) => r, - Err(_) => return false, - }; - - // Negate A for the multi-pairing check: e(-A, B) · e(α, β) · e(L, γ) · e(C, δ) = 1 - let neg_a = negate_g1(&a); - - // Build 4-pairing input (768 bytes = 4 × 192) - let mut pairing_input = [0u8; 768]; - - // Pair 0: (-A, B) - pairing_input[0..64].copy_from_slice(&neg_a); - pairing_input[64..192].copy_from_slice(&b); - - // Pair 1: (alpha, beta) - pairing_input[192..256].copy_from_slice(&vk.alpha_g1); - pairing_input[256..384].copy_from_slice(&vk.beta_g2); - - // Pair 2: (L, gamma) - pairing_input[384..448].copy_from_slice(&l_point[..64]); - pairing_input[448..576].copy_from_slice(&vk.gamma_g2); - - // Pair 3: (C, delta) - pairing_input[576..640].copy_from_slice(&c); - pairing_input[640..768].copy_from_slice(&vk.delta_g2); - - match alt_bn128_pairing(&pairing_input) { - Ok(result) => result[31] == 1 && result[..31].iter().all(|b| *b == 0), - Err(_) => false, - } -} - -// ─── Account Contexts ─────────────────────────────────────────────────────── - -#[derive(Accounts)] -pub struct Initialize<'info> { - #[account( - init, - payer = admin, - space = 8 + StateRegistry::INIT_SPACE, - seeds = [b"state"], - bump - )] - pub state_registry: Account<'info, StateRegistry>, - #[account(mut)] - pub admin: Signer<'info>, - pub system_program: Program<'info, System>, -} - -#[derive(Accounts)] -pub struct SetVerificationKey<'info> { - #[account( - mut, - seeds = [b"state"], - bump, - has_one = admin - )] - pub state_registry: Account<'info, StateRegistry>, - #[account( - init_if_needed, - payer = admin, - space = 8 + VerificationKey::INIT_SPACE, - seeds = [b"vk"], - bump - )] - pub verification_key: Account<'info, VerificationKey>, - #[account(mut)] - pub admin: Signer<'info>, - pub system_program: Program<'info, System>, -} - -#[derive(Accounts)] -pub struct SubmitProof<'info> { - #[account( - mut, - seeds = [b"state"], - bump - )] - pub state_registry: Account<'info, StateRegistry>, - #[account( - seeds = [b"stake", relayer.key().as_ref()], - bump, - has_one = relayer - )] - pub stake_account: Account<'info, StakeAccount>, - #[account( - seeds = [b"vk"], - bump - )] - pub verification_key: Account<'info, VerificationKey>, - #[account(mut)] - pub relayer: Signer<'info>, -} - -#[derive(Accounts)] -pub struct VerifyAndMint<'info> { - #[account( - mut, - seeds = [b"state"], - bump - )] - pub state_registry: Account<'info, StateRegistry>, - #[account( - seeds = [b"stake", relayer.key().as_ref()], - bump, - has_one = relayer - )] - pub stake_account: Account<'info, StakeAccount>, - #[account( - seeds = [b"vk"], - bump - )] - pub verification_key: Account<'info, VerificationKey>, - #[account(mut)] - pub token_mint: Account<'info, Mint>, - #[account(mut)] - pub recipient_token_account: Account<'info, TokenAccount>, - /// CHECK: PDA used as mint authority - #[account( - seeds = [b"mint_authority"], - bump - )] - pub mint_authority: AccountInfo<'info>, - #[account(mut)] - pub relayer: Signer<'info>, - pub token_program: Program<'info, Token>, -} - -#[derive(Accounts)] -pub struct ProcessCrossChainSwap<'info> { - #[account( - mut, - seeds = [b"state"], - bump - )] - pub state_registry: Account<'info, StateRegistry>, - #[account( - seeds = [b"stake", relayer.key().as_ref()], - bump, - has_one = relayer - )] - pub stake_account: Account<'info, StakeAccount>, - #[account( - seeds = [b"vk"], - bump - )] - pub verification_key: Account<'info, VerificationKey>, - #[account( - mut, - seeds = [ - b"liquidity_pool", - liquidity_pool.token_a_mint.as_ref(), - liquidity_pool.token_b_mint.as_ref(), - ], - bump - )] - pub liquidity_pool: Account<'info, LiquidityPool>, - #[account(mut)] - pub pool_token_b_vault: Account<'info, TokenAccount>, - #[account(mut)] - pub recipient_token_account: Account<'info, TokenAccount>, - #[account(mut)] - pub relayer: Signer<'info>, - pub token_program: Program<'info, Token>, -} - -#[derive(Accounts)] -pub struct Stake<'info> { - #[account( - mut, - seeds = [b"state"], - bump - )] - pub state_registry: Account<'info, StateRegistry>, - #[account( - init_if_needed, - payer = relayer, - space = 8 + StakeAccount::INIT_SPACE, - seeds = [b"stake", relayer.key().as_ref()], - bump - )] - pub stake_account: Account<'info, StakeAccount>, - #[account( - init_if_needed, - payer = relayer, - seeds = [b"stake_vault", relayer.key().as_ref()], - bump, - token::mint = ilink_mint, - token::authority = stake_vault, - )] - pub stake_vault: Account<'info, TokenAccount>, - pub ilink_mint: Account<'info, Mint>, - #[account(mut)] - pub relayer_token_account: Account<'info, TokenAccount>, - #[account(mut)] - pub relayer: Signer<'info>, - pub token_program: Program<'info, Token>, - pub system_program: Program<'info, System>, -} - -#[derive(Accounts)] -pub struct Unstake<'info> { - #[account( - mut, - seeds = [b"state"], - bump - )] - pub state_registry: Account<'info, StateRegistry>, - #[account( - mut, - seeds = [b"stake", relayer.key().as_ref()], - bump, - has_one = relayer - )] - pub stake_account: Account<'info, StakeAccount>, - #[account( - mut, - seeds = [b"stake_vault", relayer.key().as_ref()], - bump - )] - pub stake_vault: Account<'info, TokenAccount>, - #[account(mut)] - pub relayer_token_account: Account<'info, TokenAccount>, - #[account(mut)] - pub relayer: Signer<'info>, - pub token_program: Program<'info, Token>, -} - -#[derive(Accounts)] -pub struct SlashRelayer<'info> { - #[account( - mut, - seeds = [b"state"], - bump, - has_one = admin - )] - pub state_registry: Account<'info, StateRegistry>, - #[account( - mut, - seeds = [b"stake", stake_account.relayer.as_ref()], - bump - )] - pub stake_account: Account<'info, StakeAccount>, - #[account( - mut, - seeds = [b"stake_vault", stake_account.relayer.as_ref()], - bump - )] - pub stake_vault: Account<'info, TokenAccount>, - #[account(mut)] - pub ilink_mint: Account<'info, Mint>, - pub admin: Signer<'info>, - pub token_program: Program<'info, Token>, -} - -#[derive(Accounts)] -pub struct BuyBackBurn<'info> { - #[account( - mut, - seeds = [b"state"], - bump - )] - pub state_registry: Account<'info, StateRegistry>, - #[account(mut)] - pub ilink_mint: Account<'info, Mint>, - #[account(mut)] - pub fee_vault: Account<'info, TokenAccount>, - pub fee_authority: Signer<'info>, - pub token_program: Program<'info, Token>, -} - -#[derive(Accounts)] -pub struct InitializePool<'info> { - #[account( - init, - payer = admin, - space = 8 + LiquidityPool::INIT_SPACE, - seeds = [ - b"liquidity_pool", - token_a_mint.key().as_ref(), - token_b_mint.key().as_ref(), - ], - bump - )] - pub liquidity_pool: Account<'info, LiquidityPool>, - pub token_a_mint: Account<'info, Mint>, - pub token_b_mint: Account<'info, Mint>, - #[account(mut)] - pub admin_token_a: Account<'info, TokenAccount>, - #[account(mut)] - pub admin_token_b: Account<'info, TokenAccount>, - #[account(mut)] - pub pool_token_a_vault: Account<'info, TokenAccount>, - #[account(mut)] - pub pool_token_b_vault: Account<'info, TokenAccount>, - #[account(mut)] - pub admin: Signer<'info>, - pub token_program: Program<'info, Token>, - pub system_program: Program<'info, System>, -} - -// ─── State Accounts ───────────────────────────────────────────────────────── - -#[account] -#[derive(InitSpace)] -pub struct StateRegistry { - pub admin: Pubkey, // 32 - pub fee_rate_bps: u16, // 2 - pub next_sequence: u64, // 8 (renamed: was processed_sequences) - pub total_staked: u64, // 8 - pub total_burned: u64, // 8 - pub vk_initialized: bool, // 1 -} - -#[account] -#[derive(InitSpace)] -pub struct VerificationKey { - pub alpha_g1: [u8; 64], // G1 point - pub beta_g2: [u8; 128], // G2 point - pub gamma_g2: [u8; 128], // G2 point - pub delta_g2: [u8; 128], // G2 point - pub ic_0: [u8; 64], // G1 point (IC base) - pub ic_1: [u8; 64], // G1 point (IC for public input 0) -} - -#[account] -#[derive(InitSpace)] -pub struct StakeAccount { - pub relayer: Pubkey, // 32 - pub amount: u64, // 8 - pub staked_at: i64, // 8 -} - -#[account] -#[derive(InitSpace)] -pub struct LiquidityPool { - pub token_a_mint: Pubkey, // 32 - pub token_b_mint: Pubkey, // 32 - pub reserve_a: u64, // 8 - pub reserve_b: u64, // 8 - pub admin: Pubkey, // 32 -} - -// ─── Events ───────────────────────────────────────────────────────────────── - -#[event] -pub struct ProofVerifiedEvent { - pub sequence: u64, - pub payload_hash: [u8; 32], - pub relayer: Pubkey, -} - -#[event] -pub struct MintEvent { - pub sequence: u64, - pub recipient: Pubkey, - pub amount: u64, - pub payload_hash: [u8; 32], -} - -#[event] -pub struct SwapExecutedEvent { - pub sequence: u64, - pub amount_in: u64, - pub amount_out: u64, - pub fee: u64, - pub recipient: Pubkey, -} - -#[event] -pub struct StakeEvent { - pub relayer: Pubkey, - pub amount: u64, - pub total_staked: u64, -} - -#[event] -pub struct UnstakeEvent { - pub relayer: Pubkey, - pub amount: u64, - pub remaining: u64, -} - -#[event] -pub struct SlashEvent { - pub relayer: Pubkey, - pub slash_amount: u64, - pub remaining_stake: u64, -} - -#[event] -pub struct TokenBurnedEvent { - pub amount: u64, -} - -#[event] -pub struct PoolInitializedEvent { - pub token_a_mint: Pubkey, - pub token_b_mint: Pubkey, - pub reserve_a: u64, - pub reserve_b: u64, -} - -#[event] -pub struct VKUpdatedEvent { - pub admin: Pubkey, -} - -// ─── Errors ───────────────────────────────────────────────────────────────── - -#[error_code] -pub enum HubError { - #[msg("This sequence has already been processed.")] - SequenceAlreadyProcessed, - #[msg("Invalid ZK proof: Groth16 pairing check failed.")] - InvalidProof, - #[msg("Proof must be exactly 256 bytes.")] - InvalidProofLength, - #[msg("Insufficient stake: minimum 100,000 ILINK required.")] - InsufficientStake, - #[msg("Slippage exceeded: output amount below minimum.")] - SlippageExceeded, - #[msg("Liquidity pool is empty.")] - EmptyPool, - #[msg("Verification key not initialized. Call set_verification_key first.")] - VKNotInitialized, - #[msg("Verification key data must be exactly 576 bytes.")] - InvalidVKLength, -} +use anchor_lang::prelude::*; +use anchor_lang::solana_program::alt_bn128::prelude::{ + alt_bn128_addition, alt_bn128_multiplication, alt_bn128_pairing, +}; +use anchor_lang::solana_program::keccak; +use anchor_spl::associated_token::AssociatedToken; +use anchor_spl::token::{self, Burn, Mint, MintTo, Token, TokenAccount, Transfer}; + +declare_id!("AKzpc9tvxfhLjj5AantKizK2YZgSjoyhLmYqRZy6b8Lz"); + +// ─── Protocol constants ───────────────────────────────────────────────────── + +/// Minimum ILINK stake required for relayers (100,000 tokens with 6 decimals) +const MIN_STAKE_AMOUNT: u64 = 100_000_000_000; + +/// Slashing percentage (50% of stake burned) +const SLASH_BPS: u64 = 5_000; + +/// Upper bound on the swap fee (10%). Above 100% the old code panicked on every swap. +const MAX_FEE_RATE_BPS: u16 = 1_000; + +/// Stake stays locked this long after a relayer's last attestation, so there +/// is a window to slash before the stake can be withdrawn. Without it a +/// relayer could stake, attest a fraudulent message and unstake in one tx. +pub const UNBONDING_PERIOD_SECS: i64 = 7 * 24 * 60 * 60; + +/// Delay before a replacement verification key takes effect. Replacing the +/// VK is equivalent to replacing the bridge's security, so users get a +/// window to see it coming and exit. +pub const VK_TIMELOCK_SECS: i64 = 2 * 24 * 60 * 60; + +/// This hub's chain ID in the canonical message format. The program ID is +/// also hashed into every message, so a chain-ID collision alone cannot be +/// used to replay messages between deployments. +pub const HUB_CHAIN_ID: u64 = 2; + +/// Message actions (must match the EVM gateway). +pub const ACTION_TRANSFER: u8 = 1; +pub const ACTION_SWAP: u8 = 2; + +/// Domain salt for the circuit's round constant (must match the prover). +const DOMAIN_SALT: &[u8] = b"interlink_v1_domain"; + +/// Type tag hashed into every canonical message (must match the EVM gateway). +const MESSAGE_TYPE_TAG: &[u8] = b"InterlinkMessage(v1)"; + +#[program] +pub mod interlink_hub { + use super::*; + + /// One-time setup. Only the program's upgrade authority may call this, + /// so nobody can front-run deployment and make themselves admin. + pub fn initialize(ctx: Context, fee_rate_bps: u16) -> Result<()> { + require!(fee_rate_bps <= MAX_FEE_RATE_BPS, HubError::InvalidFeeRate); + + let registry = &mut ctx.accounts.state_registry; + registry.admin = ctx.accounts.admin.key(); + registry.ilink_mint = ctx.accounts.ilink_mint.key(); + registry.fee_rate_bps = fee_rate_bps; + registry.total_staked = 0; + registry.total_burned = 0; + registry.vk_initialized = false; + registry.outbound_nonce = 0; + Ok(()) + } + + /// Record the trusted gateway on a remote chain. Set once per chain: + /// changing where withdrawals are released is equivalent to changing who + /// can unlock the bridge's collateral, so it is not editable in place. + pub fn register_remote_gateway( + ctx: Context, + chain_id: u64, + gateway: [u8; 32], + ) -> Result<()> { + require!(gateway != [0u8; 32], HubError::InvalidRecipient); + let remote = &mut ctx.accounts.remote_gateway; + remote.chain_id = chain_id; + remote.gateway = gateway; + Ok(()) + } + + /// Burn wrapped tokens and emit an outbound message that releases the + /// original asset on its source chain (`InterlinkGateway.releaseTokens`). + pub fn burn_for_withdrawal( + ctx: Context, + recipient: [u8; 32], + amount: u64, + ) -> Result<()> { + require!(amount > 0, HubError::AmountTooSmall); + require!(recipient != [0u8; 32], HubError::InvalidRecipient); + + token::burn( + CpiContext::new( + ctx.accounts.token_program.to_account_info(), + Burn { + mint: ctx.accounts.token_mint.to_account_info(), + from: ctx.accounts.owner_token_account.to_account_info(), + authority: ctx.accounts.owner.to_account_info(), + }, + ), + amount, + )?; + + let wrapped = &ctx.accounts.wrapped_asset; + let source_amount = unscale_amount(amount, wrapped.decimals_shift)?; + + let registry = &mut ctx.accounts.state_registry; + let nonce = registry.outbound_nonce; + registry.outbound_nonce = nonce.checked_add(1).ok_or(HubError::MathOverflow)?; + + let dst_chain = wrapped.src_chain; + let dst_gateway = ctx.accounts.remote_gateway.gateway; + let sender = ctx.accounts.owner.key().to_bytes(); + let extra_hash = keccak::hash(&[]).to_bytes(); + let payload_hash = message_hash(&MessageFields { + src_chain: HUB_CHAIN_ID, + src_gateway: crate::ID.to_bytes(), + dst_chain, + dst_gateway, + nonce, + action: ACTION_TRANSFER, + sender, + asset: wrapped.asset, + amount: source_amount, + recipient, + extra_hash, + }); + + emit!(OutboundMessageEvent { + payload_hash, + dst_chain, + dst_gateway, + nonce, + action: ACTION_TRANSFER, + sender, + asset: wrapped.asset, + amount: source_amount, + recipient, + extra_hash, + }); + Ok(()) + } + + /// Store the initial Groth16 verification key. Can only be called once; + /// later changes go through `propose_verification_key` + timelock. + /// + /// vk_data layout (576 bytes for 1 public input): + /// alpha_g1 (64) | beta_g2 (128) | gamma_g2 (128) | delta_g2 (128) | ic_0 (64) | ic_1 (64) + pub fn set_verification_key(ctx: Context, vk_data: Vec) -> Result<()> { + ctx.accounts.verification_key.key = VkBytes::try_from_slice_checked(&vk_data)?; + ctx.accounts.state_registry.vk_initialized = true; + + emit!(VKUpdatedEvent { + admin: ctx.accounts.admin.key(), + }); + Ok(()) + } + + /// Queue a replacement verification key. Takes effect after `VK_TIMELOCK_SECS`. + pub fn propose_verification_key( + ctx: Context, + vk_data: Vec, + ) -> Result<()> { + let now = Clock::get()?.unix_timestamp; + let pending = &mut ctx.accounts.pending_verification_key; + pending.key = VkBytes::try_from_slice_checked(&vk_data)?; + pending.eta = now + .checked_add(VK_TIMELOCK_SECS) + .ok_or(HubError::MathOverflow)?; + + emit!(VKProposedEvent { + admin: ctx.accounts.admin.key(), + eta: pending.eta, + }); + Ok(()) + } + + /// Activate a queued verification key once its timelock has elapsed. + pub fn apply_verification_key(ctx: Context) -> Result<()> { + let now = Clock::get()?.unix_timestamp; + let pending = &ctx.accounts.pending_verification_key; + require!(now >= pending.eta, HubError::TimelockNotElapsed); + + ctx.accounts.verification_key.key = pending.key.clone(); + + emit!(VKUpdatedEvent { + admin: ctx.accounts.admin.key(), + }); + Ok(()) + } + + /// Map a source-chain asset to the SPL mint that represents it here. + /// `decimals_shift` = source decimals − mint decimals (e.g. 18 → 8 is 10). + pub fn register_wrapped_asset( + ctx: Context, + src_chain: u64, + asset: [u8; 32], + decimals_shift: u8, + ) -> Result<()> { + require!(decimals_shift <= 30, HubError::InvalidDecimalsShift); + + let wrapped = &mut ctx.accounts.wrapped_asset; + wrapped.src_chain = src_chain; + wrapped.asset = asset; + wrapped.mint = ctx.accounts.token_mint.key(); + wrapped.decimals_shift = decimals_shift; + Ok(()) + } + + /// Attest that a fully specified cross-chain message is proven. + /// + /// The payload hash is recomputed on-chain from the message fields, so the + /// proof is bound to exactly these values. The record is keyed by the + /// hash, which makes each message attestable once and leaves unrelated + /// messages independent (no global sequence that one stuck message can + /// block forever, and no way to consume another message's sequence). + pub fn submit_proof( + ctx: Context, + payload_hash: [u8; 32], + action: u8, + msg: InboundMessage, + proof_data: Vec, + ) -> Result<()> { + require!( + ctx.accounts.stake_account.amount >= MIN_STAKE_AMOUNT, + HubError::InsufficientStake + ); + require!( + ctx.accounts.state_registry.vk_initialized, + HubError::VKNotInitialized + ); + require!( + action == ACTION_TRANSFER || action == ACTION_SWAP, + HubError::WrongAction + ); + require!( + canonical_hash(action, &msg) == payload_hash, + HubError::PayloadHashMismatch + ); + require!(proof_data.len() == 256, HubError::InvalidProofLength); + require!( + verify_groth16_proof( + &proof_data, + &payload_hash, + msg.nonce, + &ctx.accounts.verification_key.key + ), + HubError::InvalidProof + ); + + let now = Clock::get()?.unix_timestamp; + let record = &mut ctx.accounts.message_record; + record.payload_hash = payload_hash; + record.action = action; + record.executed = false; + record.attested_by = ctx.accounts.relayer.key(); + record.attested_at = now; + + // Restart the relayer's unbonding clock. + ctx.accounts.stake_account.last_active = now; + + emit!(ProofVerifiedEvent { + src_chain: msg.src_chain, + sequence: msg.nonce, + payload_hash, + relayer: ctx.accounts.relayer.key(), + }); + Ok(()) + } + + /// Mint wrapped tokens for an attested transfer. Permissionless: every + /// value used here is bound to the attested payload hash, so the caller + /// cannot change the amount, mint or recipient. + pub fn execute_transfer( + ctx: Context, + payload_hash: [u8; 32], + msg: InboundMessage, + ) -> Result<()> { + require!( + canonical_hash(ACTION_TRANSFER, &msg) == payload_hash, + HubError::PayloadHashMismatch + ); + + let record = &mut ctx.accounts.message_record; + require!(!record.executed, HubError::MessageAlreadyExecuted); + require!(record.action == ACTION_TRANSFER, HubError::WrongAction); + record.executed = true; + + let amount = scale_amount(msg.amount, ctx.accounts.wrapped_asset.decimals_shift)?; + + let seeds = &[b"mint_authority".as_ref(), &[ctx.bumps.mint_authority]]; + let signer_seeds = &[&seeds[..]]; + token::mint_to( + CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + MintTo { + mint: ctx.accounts.token_mint.to_account_info(), + to: ctx.accounts.recipient_token_account.to_account_info(), + authority: ctx.accounts.mint_authority.to_account_info(), + }, + signer_seeds, + ), + amount, + )?; + + emit!(MintEvent { + src_chain: msg.src_chain, + sequence: msg.nonce, + recipient: msg.recipient, + amount, + payload_hash, + }); + Ok(()) + } + + /// Execute an attested cross-chain swap through the hub's pool. + /// Like `execute_transfer`, every parameter is bound to the payload hash. + pub fn execute_swap( + ctx: Context, + payload_hash: [u8; 32], + msg: InboundMessage, + token_out: Pubkey, + min_amount_out: u64, + swap_data_hash: [u8; 32], + ) -> Result<()> { + require!( + swap_extra_hash(&token_out, min_amount_out, &swap_data_hash) == msg.extra_hash, + HubError::PayloadHashMismatch + ); + require!( + canonical_hash(ACTION_SWAP, &msg) == payload_hash, + HubError::PayloadHashMismatch + ); + require!( + ctx.accounts.liquidity_pool.token_b_mint == token_out, + HubError::WrongToken + ); + + let record = &mut ctx.accounts.message_record; + require!(!record.executed, HubError::MessageAlreadyExecuted); + require!(record.action == ACTION_SWAP, HubError::WrongAction); + record.executed = true; + + let amount_in = scale_amount(msg.amount, ctx.accounts.wrapped_asset.decimals_shift)?; + let fee_rate_bps = ctx.accounts.state_registry.fee_rate_bps; + + let (amount_out, fee, token_a_mint, token_b_mint) = { + let pool = &mut ctx.accounts.liquidity_pool; + let reserve_in = pool.reserve_a; + let reserve_out = pool.reserve_b; + require!(reserve_in > 0 && reserve_out > 0, HubError::EmptyPool); + + let fee = mul_div(amount_in, fee_rate_bps as u64, 10_000)?; + let amount_in_after_fee = amount_in.checked_sub(fee).ok_or(HubError::MathOverflow)?; + + // Constant product: out = in * R_out / (R_in + in) + let denominator = (reserve_in as u128) + .checked_add(amount_in_after_fee as u128) + .ok_or(HubError::MathOverflow)?; + let amount_out = u64::try_from( + (amount_in_after_fee as u128) + .checked_mul(reserve_out as u128) + .ok_or(HubError::MathOverflow)? + / denominator, + ) + .map_err(|_| HubError::MathOverflow)?; + + require!(amount_out >= min_amount_out, HubError::SlippageExceeded); + + pool.reserve_a = reserve_in + .checked_add(amount_in_after_fee) + .ok_or(HubError::MathOverflow)?; + pool.reserve_b = reserve_out + .checked_sub(amount_out) + .ok_or(HubError::MathOverflow)?; + (amount_out, fee, pool.token_a_mint, pool.token_b_mint) + }; + + let pool_seeds = &[ + b"liquidity_pool".as_ref(), + token_a_mint.as_ref(), + token_b_mint.as_ref(), + &[ctx.bumps.liquidity_pool], + ]; + let signer_seeds = &[&pool_seeds[..]]; + token::transfer( + CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + Transfer { + from: ctx.accounts.pool_token_b_vault.to_account_info(), + to: ctx.accounts.recipient_token_account.to_account_info(), + authority: ctx.accounts.liquidity_pool.to_account_info(), + }, + signer_seeds, + ), + amount_out, + )?; + + emit!(SwapExecutedEvent { + src_chain: msg.src_chain, + sequence: msg.nonce, + amount_in, + amount_out, + fee, + recipient: msg.recipient, + }); + Ok(()) + } + + /// Settle an attested swap as a plain transfer: the recipient takes the + /// wrapped input asset instead of the swap output. Only the recipient can + /// choose this, so a swap that keeps missing its slippage bound is never + /// stuck. + pub fn claim_swap_as_wrapped( + ctx: Context, + payload_hash: [u8; 32], + msg: InboundMessage, + ) -> Result<()> { + require!( + canonical_hash(ACTION_SWAP, &msg) == payload_hash, + HubError::PayloadHashMismatch + ); + let record = &mut ctx.accounts.message_record; + require!(!record.executed, HubError::MessageAlreadyExecuted); + require!(record.action == ACTION_SWAP, HubError::WrongAction); + record.executed = true; + + let amount = scale_amount(msg.amount, ctx.accounts.wrapped_asset.decimals_shift)?; + let seeds = &[b"mint_authority".as_ref(), &[ctx.bumps.mint_authority]]; + let signer_seeds = &[&seeds[..]]; + token::mint_to( + CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + MintTo { + mint: ctx.accounts.token_mint.to_account_info(), + to: ctx.accounts.recipient_token_account.to_account_info(), + authority: ctx.accounts.mint_authority.to_account_info(), + }, + signer_seeds, + ), + amount, + )?; + + emit!(MintEvent { + src_chain: msg.src_chain, + sequence: msg.nonce, + recipient: msg.recipient, + amount, + payload_hash, + }); + Ok(()) + } + + /// Stake ILINK tokens to become a relayer. Minimum 100,000 ILINK required. + pub fn stake(ctx: Context, amount: u64) -> Result<()> { + require!(amount >= MIN_STAKE_AMOUNT, HubError::InsufficientStake); + + token::transfer( + CpiContext::new( + ctx.accounts.token_program.to_account_info(), + Transfer { + from: ctx.accounts.relayer_token_account.to_account_info(), + to: ctx.accounts.stake_vault.to_account_info(), + authority: ctx.accounts.relayer.to_account_info(), + }, + ), + amount, + )?; + + let now = Clock::get()?.unix_timestamp; + let stake_account = &mut ctx.accounts.stake_account; + stake_account.relayer = ctx.accounts.relayer.key(); + stake_account.amount = stake_account + .amount + .checked_add(amount) + .ok_or(HubError::MathOverflow)?; + stake_account.staked_at = now; + stake_account.last_active = now; + + let registry = &mut ctx.accounts.state_registry; + registry.total_staked = registry + .total_staked + .checked_add(amount) + .ok_or(HubError::MathOverflow)?; + + emit!(StakeEvent { + relayer: ctx.accounts.relayer.key(), + amount, + total_staked: stake_account.amount, + }); + Ok(()) + } + + /// Unstake ILINK tokens. Only allowed once `UNBONDING_PERIOD_SECS` have + /// passed since the relayer last staked or attested a message. + pub fn unstake(ctx: Context, amount: u64) -> Result<()> { + let now = Clock::get()?.unix_timestamp; + let stake_account = &mut ctx.accounts.stake_account; + let unlock_at = stake_account + .last_active + .checked_add(UNBONDING_PERIOD_SECS) + .ok_or(HubError::MathOverflow)?; + require!(now >= unlock_at, HubError::StakeLocked); + require!(stake_account.amount >= amount, HubError::InsufficientStake); + + let remaining = stake_account.amount - amount; + require!( + remaining == 0 || remaining >= MIN_STAKE_AMOUNT, + HubError::InsufficientStake + ); + + let relayer_key = stake_account.relayer; + let vault_seeds = &[ + b"stake_vault".as_ref(), + relayer_key.as_ref(), + &[ctx.bumps.stake_vault], + ]; + let signer_seeds = &[&vault_seeds[..]]; + token::transfer( + CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + Transfer { + from: ctx.accounts.stake_vault.to_account_info(), + to: ctx.accounts.relayer_token_account.to_account_info(), + authority: ctx.accounts.stake_vault.to_account_info(), + }, + signer_seeds, + ), + amount, + )?; + + stake_account.amount = remaining; + let registry = &mut ctx.accounts.state_registry; + registry.total_staked = registry + .total_staked + .checked_sub(amount) + .ok_or(HubError::MathOverflow)?; + + emit!(UnstakeEvent { + relayer: ctx.accounts.relayer.key(), + amount, + remaining, + }); + Ok(()) + } + + /// Slash a relayer. Burns 50% of their stake. Admin-only: there is no + /// on-chain fraud proof yet, so slashing is a governance decision. + pub fn slash_relayer(ctx: Context) -> Result<()> { + let stake_account = &mut ctx.accounts.stake_account; + let slash_amount = mul_div(stake_account.amount, SLASH_BPS, 10_000)?; + stake_account.amount -= slash_amount; + + let relayer_key = stake_account.relayer; + let vault_seeds = &[ + b"stake_vault".as_ref(), + relayer_key.as_ref(), + &[ctx.bumps.stake_vault], + ]; + let signer_seeds = &[&vault_seeds[..]]; + token::burn( + CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + Burn { + mint: ctx.accounts.ilink_mint.to_account_info(), + from: ctx.accounts.stake_vault.to_account_info(), + authority: ctx.accounts.stake_vault.to_account_info(), + }, + signer_seeds, + ), + slash_amount, + )?; + + let registry = &mut ctx.accounts.state_registry; + registry.total_staked = registry + .total_staked + .checked_sub(slash_amount) + .ok_or(HubError::MathOverflow)?; + registry.total_burned = registry + .total_burned + .checked_add(slash_amount) + .ok_or(HubError::MathOverflow)?; + + emit!(SlashEvent { + relayer: relayer_key, + slash_amount, + remaining_stake: stake_account.amount, + }); + Ok(()) + } + + /// Burn 40% of `amount` from the admin's fee vault. Admin-only, so random + /// callers can no longer inflate the `total_burned` statistic. + pub fn buy_back_and_burn(ctx: Context, amount: u64) -> Result<()> { + let burn_amount = mul_div(amount, 40, 100)?; + + token::burn( + CpiContext::new( + ctx.accounts.token_program.to_account_info(), + Burn { + mint: ctx.accounts.ilink_mint.to_account_info(), + from: ctx.accounts.fee_vault.to_account_info(), + authority: ctx.accounts.admin.to_account_info(), + }, + ), + burn_amount, + )?; + + let registry = &mut ctx.accounts.state_registry; + registry.total_burned = registry + .total_burned + .checked_add(burn_amount) + .ok_or(HubError::MathOverflow)?; + + emit!(TokenBurnedEvent { + amount: burn_amount, + }); + Ok(()) + } + + /// Initialize a liquidity pool. Admin-only; the vaults are program-owned + /// PDAs created here, so nobody can squat a pool with vaults they control. + pub fn initialize_pool( + ctx: Context, + initial_reserve_a: u64, + initial_reserve_b: u64, + ) -> Result<()> { + require!( + initial_reserve_a > 0 && initial_reserve_b > 0, + HubError::EmptyPool + ); + + token::transfer( + CpiContext::new( + ctx.accounts.token_program.to_account_info(), + Transfer { + from: ctx.accounts.admin_token_a.to_account_info(), + to: ctx.accounts.pool_token_a_vault.to_account_info(), + authority: ctx.accounts.admin.to_account_info(), + }, + ), + initial_reserve_a, + )?; + token::transfer( + CpiContext::new( + ctx.accounts.token_program.to_account_info(), + Transfer { + from: ctx.accounts.admin_token_b.to_account_info(), + to: ctx.accounts.pool_token_b_vault.to_account_info(), + authority: ctx.accounts.admin.to_account_info(), + }, + ), + initial_reserve_b, + )?; + + let pool = &mut ctx.accounts.liquidity_pool; + pool.token_a_mint = ctx.accounts.token_a_mint.key(); + pool.token_b_mint = ctx.accounts.token_b_mint.key(); + pool.reserve_a = initial_reserve_a; + pool.reserve_b = initial_reserve_b; + pool.admin = ctx.accounts.admin.key(); + + emit!(PoolInitializedEvent { + token_a_mint: pool.token_a_mint, + token_b_mint: pool.token_b_mint, + reserve_a: initial_reserve_a, + reserve_b: initial_reserve_b, + }); + Ok(()) + } +} + +// ─── Canonical message format ─────────────────────────────────────────────── +// +// Every gateway hashes outbound messages the same way: +// +// keccak256(abi.encode( +// keccak256("InterlinkMessage(v1)"), +// uint64 srcChain, bytes32 srcGateway, +// uint64 dstChain, bytes32 dstGateway, +// uint64 nonce, uint8 action, +// bytes32 sender, bytes32 asset, +// uint256 amount, bytes32 recipient, +// bytes32 extraHash)) +// +// Binding both gateways and chain IDs stops replay across deployments. +// `extraHash` carries action-specific data (payload hash, swap params). + +/// Fields of an inbound message, as emitted by the source gateway. +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug, PartialEq)] +pub struct InboundMessage { + pub src_chain: u64, + pub src_gateway: [u8; 32], + pub nonce: u64, + pub sender: [u8; 32], + pub asset: [u8; 32], + /// Source-chain amount (source decimals). The EVM gateway caps it at 2^128 − 1. + pub amount: u128, + pub recipient: Pubkey, + pub extra_hash: [u8; 32], +} + +fn word_u64(v: u64) -> [u8; 32] { + let mut w = [0u8; 32]; + w[24..].copy_from_slice(&v.to_be_bytes()); + w +} + +fn word_u128(v: u128) -> [u8; 32] { + let mut w = [0u8; 32]; + w[16..].copy_from_slice(&v.to_be_bytes()); + w +} + +/// All fields of a canonical message, in either direction. +pub struct MessageFields { + pub src_chain: u64, + pub src_gateway: [u8; 32], + pub dst_chain: u64, + pub dst_gateway: [u8; 32], + pub nonce: u64, + pub action: u8, + pub sender: [u8; 32], + pub asset: [u8; 32], + pub amount: u128, + pub recipient: [u8; 32], + pub extra_hash: [u8; 32], +} + +/// keccak256(abi.encode(typeHash, ...fields)), identical to `InterlinkGateway.hashMessage`. +pub fn message_hash(m: &MessageFields) -> [u8; 32] { + let type_hash = keccak::hash(MESSAGE_TYPE_TAG).to_bytes(); + let mut action_word = [0u8; 32]; + action_word[31] = m.action; + keccak::hashv(&[ + &type_hash, + &word_u64(m.src_chain), + &m.src_gateway, + &word_u64(m.dst_chain), + &m.dst_gateway, + &word_u64(m.nonce), + &action_word, + &m.sender, + &m.asset, + &word_u128(m.amount), + &m.recipient, + &m.extra_hash, + ]) + .to_bytes() +} + +/// Canonical payload hash of an inbound message addressed to this hub. +pub fn canonical_hash(action: u8, m: &InboundMessage) -> [u8; 32] { + message_hash(&MessageFields { + src_chain: m.src_chain, + src_gateway: m.src_gateway, + dst_chain: HUB_CHAIN_ID, + dst_gateway: crate::ID.to_bytes(), + nonce: m.nonce, + action, + sender: m.sender, + asset: m.asset, + amount: m.amount, + recipient: m.recipient.to_bytes(), + extra_hash: m.extra_hash, + }) +} + +/// `extraHash` for a swap: keccak256(abi.encode(bytes32 tokenOut, uint256 minAmountOut, bytes32 swapDataHash)). +pub fn swap_extra_hash( + token_out: &Pubkey, + min_amount_out: u64, + swap_data_hash: &[u8; 32], +) -> [u8; 32] { + keccak::hashv(&[ + &token_out.to_bytes(), + &word_u64(min_amount_out), + swap_data_hash, + ]) + .to_bytes() +} + +/// Convert a source-chain amount to local mint units. Dust below the +/// precision of the local mint stays locked on the source chain. +fn scale_amount(amount: u128, decimals_shift: u8) -> Result { + let divisor = 10u128 + .checked_pow(decimals_shift as u32) + .ok_or(HubError::InvalidDecimalsShift)?; + let scaled = amount / divisor; + require!(scaled > 0, HubError::AmountTooSmall); + u64::try_from(scaled).map_err(|_| error!(HubError::AmountOverflow)) +} + +/// Inverse of `scale_amount`: local mint units back to source-chain units. +fn unscale_amount(amount: u64, decimals_shift: u8) -> Result { + 10u128 + .checked_pow(decimals_shift as u32) + .and_then(|m| (amount as u128).checked_mul(m)) + .ok_or_else(|| error!(HubError::AmountOverflow)) +} + +fn mul_div(a: u64, b: u64, denom: u64) -> Result { + let v = (a as u128) + .checked_mul(b as u128) + .ok_or(HubError::MathOverflow)? + / denom as u128; + u64::try_from(v).map_err(|_| error!(HubError::MathOverflow)) +} + +// ─── BN254 scalar field arithmetic ────────────────────────────────────────── +// +// 4×64-bit limb Montgomery arithmetic. The previous byte-wise +// shift-and-subtract reduction needed ~3,900 32-byte subtraction passes per +// multiplication, which puts the three multiplications in `verify` above +// Solana's 1.4M compute-unit limit. This is a few hundred operations each. + +pub mod fr { + /// r = 21888242871839275222246405745257275088548364400416034343698204186575808495617 + pub const MODULUS: [u64; 4] = [ + 0x43e1_f593_f000_0001, + 0x2833_e848_79b9_7091, + 0xb850_45b6_8181_585d, + 0x3064_4e72_e131_a029, + ]; + /// −r⁻¹ mod 2⁶⁴ + pub const INV: u64 = 0xc2e1_f593_efff_ffff; + /// R² mod r, where R = 2²⁵⁶ + pub const R2: [u64; 4] = [ + 0x1bb8_e645_ae21_6da7, + 0x53fe_3ab1_e35c_59e3, + 0x8c49_833d_53bb_8085, + 0x0216_d0b1_7f4e_44a5, + ]; + + pub type Fr = [u64; 4]; + + #[inline(always)] + fn mac(a: u64, b: u64, c: u64, carry: u64) -> (u64, u64) { + let t = (a as u128) + (b as u128) * (c as u128) + (carry as u128); + (t as u64, (t >> 64) as u64) + } + + #[inline(always)] + fn adc(a: u64, b: u64, carry: u64) -> (u64, u64) { + let t = (a as u128) + (b as u128) + (carry as u128); + (t as u64, (t >> 64) as u64) + } + + fn geq_modulus(a: &Fr) -> bool { + for i in (0..4).rev() { + if a[i] != MODULUS[i] { + return a[i] > MODULUS[i]; + } + } + true + } + + fn sub_modulus(a: &Fr) -> Fr { + let mut out = [0u64; 4]; + let mut borrow = 0u64; + for i in 0..4 { + let (d1, b1) = a[i].overflowing_sub(MODULUS[i]); + let (d2, b2) = d1.overflowing_sub(borrow); + out[i] = d2; + borrow = (b1 | b2) as u64; + } + out + } + + /// Montgomery product a·b·R⁻¹ mod r (CIOS). + fn mont_mul(a: &Fr, b: &Fr) -> Fr { + let mut t = [0u64; 6]; + for &bi in b.iter() { + let mut carry = 0u64; + for j in 0..4 { + let (lo, hi) = mac(t[j], a[j], bi, carry); + t[j] = lo; + carry = hi; + } + let (s, c) = adc(t[4], carry, 0); + t[4] = s; + t[5] = c; + + let m = t[0].wrapping_mul(INV); + let (_, mut carry) = mac(t[0], m, MODULUS[0], 0); + for j in 1..4 { + let (lo, hi) = mac(t[j], m, MODULUS[j], carry); + t[j - 1] = lo; + carry = hi; + } + let (s, c) = adc(t[4], carry, 0); + t[3] = s; + t[4] = t[5] + c; + } + let res = [t[0], t[1], t[2], t[3]]; + if t[4] != 0 || geq_modulus(&res) { + sub_modulus(&res) + } else { + res + } + } + + /// a·b mod r for canonical inputs. + pub fn mul(a: &Fr, b: &Fr) -> Fr { + mont_mul(&mont_mul(a, b), &R2) + } + + /// a+b mod r for canonical inputs (2r < 2²⁵⁶, so no overflow). + pub fn add(a: &Fr, b: &Fr) -> Fr { + let mut out = [0u64; 4]; + let mut carry = 0u64; + for i in 0..4 { + let (s, c) = adc(a[i], b[i], carry); + out[i] = s; + carry = c; + } + if geq_modulus(&out) { + sub_modulus(&out) + } else { + out + } + } + + /// Interpret 32 big-endian bytes and reduce mod r. 2²⁵⁶ < 6r, so at most + /// five subtractions (the old loop stopped after four). + pub fn from_be_bytes_mod_order(bytes: &[u8; 32]) -> Fr { + let mut x = [0u64; 4]; + for (i, limb) in x.iter_mut().enumerate() { + let start = 32 - 8 * (i + 1); + *limb = u64::from_be_bytes(bytes[start..start + 8].try_into().unwrap()); + } + while geq_modulus(&x) { + x = sub_modulus(&x); + } + x + } + + pub fn from_u64(v: u64) -> Fr { + [v, 0, 0, 0] + } + + pub fn to_be_bytes(x: &Fr) -> [u8; 32] { + let mut out = [0u8; 32]; + for i in 0..4 { + let start = 32 - 8 * (i + 1); + out[start..start + 8].copy_from_slice(&x[i].to_be_bytes()); + } + out + } +} + +/// Public input for the circuit: commitment = (msg + rc)^5 + sequence, where +/// msg = payload_hash mod r and rc = keccak256(DOMAIN_SALT) mod r. +/// Must match `relayer/src/prover.rs` exactly. +pub fn compute_commitment(payload_hash: &[u8; 32], sequence: u64) -> [u8; 32] { + let rc = fr::from_be_bytes_mod_order(&keccak::hash(DOMAIN_SALT).to_bytes()); + let msg = fr::from_be_bytes_mod_order(payload_hash); + let w = fr::add(&msg, &rc); + let w2 = fr::mul(&w, &w); + let w4 = fr::mul(&w2, &w2); + let w5 = fr::mul(&w4, &w); + fr::to_be_bytes(&fr::add(&w5, &fr::from_u64(sequence))) +} + +// ─── Groth16 verification ─────────────────────────────────────────────────── +// +// e(−A, B) · e(α, β) · e(L, γ) · e(C, δ) = 1, L = IC₀ + commitment · IC₁ + +/// BN254 base field modulus p (big-endian). +const BN254_BASE_FIELD: [u8; 32] = [ + 0x30, 0x64, 0x4e, 0x72, 0xe1, 0x31, 0xa0, 0x29, 0xb8, 0x50, 0x45, 0xb6, 0x81, 0x81, 0x58, 0x5d, + 0x97, 0x81, 0x6a, 0x91, 0x68, 0x71, 0xca, 0x8d, 0x3c, 0x20, 0x8c, 0x16, 0xd8, 0x7c, 0xfd, 0x47, +]; + +/// Negate a G1 point: (x, y) → (x, p − y). The point at infinity (0, 0) is its own negation. +fn negate_g1(point: &[u8; 64]) -> [u8; 64] { + let mut result = *point; + if point[32..].iter().all(|b| *b == 0) { + return result; + } + let mut borrow: u16 = 0; + for i in (0..32).rev() { + let diff = (BN254_BASE_FIELD[i] as u16) + .wrapping_sub(point[32 + i] as u16) + .wrapping_sub(borrow); + result[32 + i] = diff as u8; + borrow = if diff > 255 { 1 } else { 0 }; + } + result +} + +pub fn verify_groth16_proof( + proof: &[u8], + payload_hash: &[u8; 32], + sequence: u64, + vk: &VkBytes, +) -> bool { + if proof.len() != 256 { + return false; + } + let a: [u8; 64] = proof[0..64].try_into().unwrap(); + let b: [u8; 128] = proof[64..192].try_into().unwrap(); + let c: [u8; 64] = proof[192..256].try_into().unwrap(); + + let commitment = compute_commitment(payload_hash, sequence); + + let mut ecmul_input = [0u8; 96]; + ecmul_input[0..64].copy_from_slice(&vk.ic_1); + ecmul_input[64..96].copy_from_slice(&commitment); + let scaled_ic1 = match alt_bn128_multiplication(&ecmul_input) { + Ok(r) => r, + Err(_) => return false, + }; + + let mut ecadd_input = [0u8; 128]; + ecadd_input[0..64].copy_from_slice(&vk.ic_0); + ecadd_input[64..128].copy_from_slice(&scaled_ic1[..64]); + let l_point = match alt_bn128_addition(&ecadd_input) { + Ok(r) => r, + Err(_) => return false, + }; + + let mut pairing_input = [0u8; 768]; + pairing_input[0..64].copy_from_slice(&negate_g1(&a)); + pairing_input[64..192].copy_from_slice(&b); + pairing_input[192..256].copy_from_slice(&vk.alpha_g1); + pairing_input[256..384].copy_from_slice(&vk.beta_g2); + pairing_input[384..448].copy_from_slice(&l_point[..64]); + pairing_input[448..576].copy_from_slice(&vk.gamma_g2); + pairing_input[576..640].copy_from_slice(&c); + pairing_input[640..768].copy_from_slice(&vk.delta_g2); + + match alt_bn128_pairing(&pairing_input) { + Ok(result) => result[31] == 1 && result[..31].iter().all(|b| *b == 0), + Err(_) => false, + } +} + +// ─── Account Contexts ─────────────────────────────────────────────────────── + +#[derive(Accounts)] +pub struct Initialize<'info> { + #[account( + init, + payer = admin, + space = 8 + StateRegistry::INIT_SPACE, + seeds = [b"state"], + bump + )] + pub state_registry: Account<'info, StateRegistry>, + pub ilink_mint: Account<'info, Mint>, + #[account(mut)] + pub admin: Signer<'info>, + #[account(constraint = program.programdata_address()? == Some(program_data.key()) @ HubError::Unauthorized)] + pub program: Program<'info, crate::program::InterlinkHub>, + #[account(constraint = program_data.upgrade_authority_address == Some(admin.key()) @ HubError::Unauthorized)] + pub program_data: Account<'info, ProgramData>, + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct SetVerificationKey<'info> { + #[account(mut, seeds = [b"state"], bump, has_one = admin)] + pub state_registry: Account<'info, StateRegistry>, + /// `init` (not `init_if_needed`): the initial key can be set exactly once. + #[account( + init, + payer = admin, + space = 8 + VerificationKey::INIT_SPACE, + seeds = [b"vk"], + bump + )] + pub verification_key: Account<'info, VerificationKey>, + #[account(mut)] + pub admin: Signer<'info>, + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct ProposeVerificationKey<'info> { + #[account(seeds = [b"state"], bump, has_one = admin)] + pub state_registry: Account<'info, StateRegistry>, + #[account( + init_if_needed, + payer = admin, + space = 8 + PendingVerificationKey::INIT_SPACE, + seeds = [b"pending_vk"], + bump + )] + pub pending_verification_key: Account<'info, PendingVerificationKey>, + #[account(mut)] + pub admin: Signer<'info>, + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct ApplyVerificationKey<'info> { + #[account(seeds = [b"state"], bump, has_one = admin)] + pub state_registry: Account<'info, StateRegistry>, + #[account(mut, seeds = [b"pending_vk"], bump, close = admin)] + pub pending_verification_key: Account<'info, PendingVerificationKey>, + #[account(mut, seeds = [b"vk"], bump)] + pub verification_key: Account<'info, VerificationKey>, + #[account(mut)] + pub admin: Signer<'info>, +} + +#[derive(Accounts)] +#[instruction(src_chain: u64, asset: [u8; 32])] +pub struct RegisterWrappedAsset<'info> { + #[account(seeds = [b"state"], bump, has_one = admin)] + pub state_registry: Account<'info, StateRegistry>, + #[account( + init, + payer = admin, + space = 8 + WrappedAsset::INIT_SPACE, + seeds = [b"wrapped", src_chain.to_le_bytes().as_ref(), asset.as_ref()], + bump + )] + pub wrapped_asset: Account<'info, WrappedAsset>, + /// The hub must be the mint authority, otherwise transfers could never be executed. + #[account(constraint = token_mint.mint_authority == anchor_lang::solana_program::program_option::COption::Some(mint_authority.key()) @ HubError::InvalidMint)] + pub token_mint: Account<'info, Mint>, + /// CHECK: PDA that signs mints; only its address is used. + #[account(seeds = [b"mint_authority"], bump)] + pub mint_authority: AccountInfo<'info>, + #[account(mut)] + pub admin: Signer<'info>, + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +#[instruction(payload_hash: [u8; 32])] +pub struct SubmitProof<'info> { + #[account(seeds = [b"state"], bump)] + pub state_registry: Account<'info, StateRegistry>, + #[account( + mut, + seeds = [b"stake", relayer.key().as_ref()], + bump, + has_one = relayer + )] + pub stake_account: Account<'info, StakeAccount>, + #[account(seeds = [b"vk"], bump)] + pub verification_key: Account<'info, VerificationKey>, + /// Created here; `init` fails if this payload hash was already attested. + #[account( + init, + payer = relayer, + space = 8 + MessageRecord::INIT_SPACE, + seeds = [b"message", payload_hash.as_ref()], + bump + )] + pub message_record: Account<'info, MessageRecord>, + #[account(mut)] + pub relayer: Signer<'info>, + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +#[instruction(payload_hash: [u8; 32], msg: InboundMessage)] +pub struct ExecuteTransfer<'info> { + /// Anyone may execute; pays for the recipient's token account if needed. + #[account(mut)] + pub payer: Signer<'info>, + #[account(mut, seeds = [b"message", payload_hash.as_ref()], bump)] + pub message_record: Account<'info, MessageRecord>, + #[account( + seeds = [b"wrapped", msg.src_chain.to_le_bytes().as_ref(), msg.asset.as_ref()], + bump + )] + pub wrapped_asset: Account<'info, WrappedAsset>, + #[account(mut, address = wrapped_asset.mint @ HubError::InvalidMint)] + pub token_mint: Account<'info, Mint>, + /// CHECK: only used as the token-account authority; bound to the hashed recipient. + #[account(address = msg.recipient @ HubError::InvalidRecipient)] + pub recipient: UncheckedAccount<'info>, + /// The recipient's associated token account, created if missing so a + /// transfer can never be stuck on a missing account. + #[account( + init_if_needed, + payer = payer, + associated_token::mint = token_mint, + associated_token::authority = recipient + )] + pub recipient_token_account: Account<'info, TokenAccount>, + /// CHECK: PDA used as mint authority + #[account(seeds = [b"mint_authority"], bump)] + pub mint_authority: AccountInfo<'info>, + pub token_program: Program<'info, Token>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +#[instruction(payload_hash: [u8; 32], msg: InboundMessage)] +pub struct ClaimSwapAsWrapped<'info> { + /// Must be the swap's recipient. + #[account(mut, address = msg.recipient @ HubError::InvalidRecipient)] + pub recipient: Signer<'info>, + #[account(mut, seeds = [b"message", payload_hash.as_ref()], bump)] + pub message_record: Account<'info, MessageRecord>, + #[account( + seeds = [b"wrapped", msg.src_chain.to_le_bytes().as_ref(), msg.asset.as_ref()], + bump + )] + pub wrapped_asset: Account<'info, WrappedAsset>, + #[account(mut, address = wrapped_asset.mint @ HubError::InvalidMint)] + pub token_mint: Account<'info, Mint>, + #[account( + init_if_needed, + payer = recipient, + associated_token::mint = token_mint, + associated_token::authority = recipient + )] + pub recipient_token_account: Account<'info, TokenAccount>, + /// CHECK: PDA used as mint authority + #[account(seeds = [b"mint_authority"], bump)] + pub mint_authority: AccountInfo<'info>, + pub token_program: Program<'info, Token>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +#[instruction(chain_id: u64)] +pub struct RegisterRemoteGateway<'info> { + #[account(seeds = [b"state"], bump, has_one = admin)] + pub state_registry: Account<'info, StateRegistry>, + /// `init`: a chain's gateway can be set exactly once. + #[account( + init, + payer = admin, + space = 8 + RemoteGateway::INIT_SPACE, + seeds = [b"remote", chain_id.to_le_bytes().as_ref()], + bump + )] + pub remote_gateway: Account<'info, RemoteGateway>, + #[account(mut)] + pub admin: Signer<'info>, + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct BurnForWithdrawal<'info> { + #[account(mut, seeds = [b"state"], bump)] + pub state_registry: Account<'info, StateRegistry>, + #[account( + seeds = [b"wrapped", wrapped_asset.src_chain.to_le_bytes().as_ref(), wrapped_asset.asset.as_ref()], + bump + )] + pub wrapped_asset: Account<'info, WrappedAsset>, + /// Collateral lives on the asset's origin chain, so that is the only destination. + #[account( + seeds = [b"remote", wrapped_asset.src_chain.to_le_bytes().as_ref()], + bump + )] + pub remote_gateway: Account<'info, RemoteGateway>, + #[account(mut, address = wrapped_asset.mint @ HubError::InvalidMint)] + pub token_mint: Account<'info, Mint>, + #[account(mut, token::mint = token_mint, token::authority = owner)] + pub owner_token_account: Account<'info, TokenAccount>, + pub owner: Signer<'info>, + pub token_program: Program<'info, Token>, +} + +#[derive(Accounts)] +#[instruction(payload_hash: [u8; 32], msg: InboundMessage)] +pub struct ExecuteSwap<'info> { + #[account(mut)] + pub payer: Signer<'info>, + #[account(seeds = [b"state"], bump)] + pub state_registry: Account<'info, StateRegistry>, + #[account(mut, seeds = [b"message", payload_hash.as_ref()], bump)] + pub message_record: Account<'info, MessageRecord>, + #[account( + seeds = [b"wrapped", msg.src_chain.to_le_bytes().as_ref(), msg.asset.as_ref()], + bump + )] + pub wrapped_asset: Account<'info, WrappedAsset>, + #[account( + mut, + seeds = [ + b"liquidity_pool", + liquidity_pool.token_a_mint.as_ref(), + liquidity_pool.token_b_mint.as_ref(), + ], + bump, + constraint = liquidity_pool.token_a_mint == wrapped_asset.mint @ HubError::WrongToken + )] + pub liquidity_pool: Account<'info, LiquidityPool>, + #[account( + mut, + seeds = [b"pool_vault", liquidity_pool.key().as_ref(), liquidity_pool.token_b_mint.as_ref()], + bump + )] + pub pool_token_b_vault: Account<'info, TokenAccount>, + #[account(address = liquidity_pool.token_b_mint @ HubError::WrongToken)] + pub token_b_mint: Account<'info, Mint>, + /// CHECK: only used as the token-account authority; bound to the hashed recipient. + #[account(address = msg.recipient @ HubError::InvalidRecipient)] + pub recipient: UncheckedAccount<'info>, + #[account( + init_if_needed, + payer = payer, + associated_token::mint = token_b_mint, + associated_token::authority = recipient + )] + pub recipient_token_account: Account<'info, TokenAccount>, + pub token_program: Program<'info, Token>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct Stake<'info> { + #[account(mut, seeds = [b"state"], bump)] + pub state_registry: Account<'info, StateRegistry>, + #[account( + init_if_needed, + payer = relayer, + space = 8 + StakeAccount::INIT_SPACE, + seeds = [b"stake", relayer.key().as_ref()], + bump + )] + pub stake_account: Account<'info, StakeAccount>, + #[account( + init_if_needed, + payer = relayer, + seeds = [b"stake_vault", relayer.key().as_ref()], + bump, + token::mint = ilink_mint, + token::authority = stake_vault, + )] + pub stake_vault: Account<'info, TokenAccount>, + /// Must be the real ILINK mint; otherwise a relayer could "stake" a worthless token. + #[account(address = state_registry.ilink_mint @ HubError::InvalidMint)] + pub ilink_mint: Account<'info, Mint>, + #[account(mut)] + pub relayer_token_account: Account<'info, TokenAccount>, + #[account(mut)] + pub relayer: Signer<'info>, + pub token_program: Program<'info, Token>, + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct Unstake<'info> { + #[account(mut, seeds = [b"state"], bump)] + pub state_registry: Account<'info, StateRegistry>, + #[account( + mut, + seeds = [b"stake", relayer.key().as_ref()], + bump, + has_one = relayer + )] + pub stake_account: Account<'info, StakeAccount>, + #[account(mut, seeds = [b"stake_vault", relayer.key().as_ref()], bump)] + pub stake_vault: Account<'info, TokenAccount>, + #[account(mut)] + pub relayer_token_account: Account<'info, TokenAccount>, + #[account(mut)] + pub relayer: Signer<'info>, + pub token_program: Program<'info, Token>, +} + +#[derive(Accounts)] +pub struct SlashRelayer<'info> { + #[account(mut, seeds = [b"state"], bump, has_one = admin)] + pub state_registry: Account<'info, StateRegistry>, + #[account(mut, seeds = [b"stake", stake_account.relayer.as_ref()], bump)] + pub stake_account: Account<'info, StakeAccount>, + #[account(mut, seeds = [b"stake_vault", stake_account.relayer.as_ref()], bump)] + pub stake_vault: Account<'info, TokenAccount>, + #[account(mut, address = state_registry.ilink_mint @ HubError::InvalidMint)] + pub ilink_mint: Account<'info, Mint>, + pub admin: Signer<'info>, + pub token_program: Program<'info, Token>, +} + +#[derive(Accounts)] +pub struct BuyBackBurn<'info> { + #[account(mut, seeds = [b"state"], bump, has_one = admin)] + pub state_registry: Account<'info, StateRegistry>, + #[account(mut, address = state_registry.ilink_mint @ HubError::InvalidMint)] + pub ilink_mint: Account<'info, Mint>, + #[account(mut, token::mint = ilink_mint, token::authority = admin)] + pub fee_vault: Account<'info, TokenAccount>, + pub admin: Signer<'info>, + pub token_program: Program<'info, Token>, +} + +#[derive(Accounts)] +pub struct InitializePool<'info> { + #[account(seeds = [b"state"], bump, has_one = admin)] + pub state_registry: Account<'info, StateRegistry>, + #[account( + init, + payer = admin, + space = 8 + LiquidityPool::INIT_SPACE, + seeds = [ + b"liquidity_pool", + token_a_mint.key().as_ref(), + token_b_mint.key().as_ref(), + ], + bump + )] + pub liquidity_pool: Account<'info, LiquidityPool>, + pub token_a_mint: Account<'info, Mint>, + pub token_b_mint: Account<'info, Mint>, + #[account(mut)] + pub admin_token_a: Account<'info, TokenAccount>, + #[account(mut)] + pub admin_token_b: Account<'info, TokenAccount>, + #[account( + init, + payer = admin, + seeds = [b"pool_vault", liquidity_pool.key().as_ref(), token_a_mint.key().as_ref()], + bump, + token::mint = token_a_mint, + token::authority = liquidity_pool, + )] + pub pool_token_a_vault: Account<'info, TokenAccount>, + #[account( + init, + payer = admin, + seeds = [b"pool_vault", liquidity_pool.key().as_ref(), token_b_mint.key().as_ref()], + bump, + token::mint = token_b_mint, + token::authority = liquidity_pool, + )] + pub pool_token_b_vault: Account<'info, TokenAccount>, + #[account(mut)] + pub admin: Signer<'info>, + pub token_program: Program<'info, Token>, + pub system_program: Program<'info, System>, +} + +// ─── State Accounts ───────────────────────────────────────────────────────── + +#[account] +#[derive(InitSpace)] +pub struct StateRegistry { + pub admin: Pubkey, + pub ilink_mint: Pubkey, + pub fee_rate_bps: u16, + pub total_staked: u64, + pub total_burned: u64, + pub vk_initialized: bool, + /// Next nonce for messages leaving the hub (`burn_for_withdrawal`). + pub outbound_nonce: u64, +} + +/// Trusted gateway on a remote chain (destination of withdrawals). +#[account] +#[derive(InitSpace)] +pub struct RemoteGateway { + pub chain_id: u64, + pub gateway: [u8; 32], +} + +/// Raw Groth16 verification key (EVM/Solana precompile encoding). +#[derive(AnchorSerialize, AnchorDeserialize, Clone, InitSpace)] +pub struct VkBytes { + pub alpha_g1: [u8; 64], + pub beta_g2: [u8; 128], + pub gamma_g2: [u8; 128], + pub delta_g2: [u8; 128], + pub ic_0: [u8; 64], + pub ic_1: [u8; 64], +} + +impl VkBytes { + pub fn try_from_slice_checked(data: &[u8]) -> Result { + require!(data.len() == 576, HubError::InvalidVKLength); + Ok(Self { + alpha_g1: data[0..64].try_into().unwrap(), + beta_g2: data[64..192].try_into().unwrap(), + gamma_g2: data[192..320].try_into().unwrap(), + delta_g2: data[320..448].try_into().unwrap(), + ic_0: data[448..512].try_into().unwrap(), + ic_1: data[512..576].try_into().unwrap(), + }) + } +} + +#[account] +#[derive(InitSpace)] +pub struct VerificationKey { + pub key: VkBytes, +} + +#[account] +#[derive(InitSpace)] +pub struct PendingVerificationKey { + pub key: VkBytes, + pub eta: i64, +} + +#[account] +#[derive(InitSpace)] +pub struct MessageRecord { + pub payload_hash: [u8; 32], + pub action: u8, + pub executed: bool, + pub attested_by: Pubkey, + pub attested_at: i64, +} + +#[account] +#[derive(InitSpace)] +pub struct WrappedAsset { + pub src_chain: u64, + pub asset: [u8; 32], + pub mint: Pubkey, + pub decimals_shift: u8, +} + +#[account] +#[derive(InitSpace)] +pub struct StakeAccount { + pub relayer: Pubkey, + pub amount: u64, + pub staked_at: i64, + /// Last stake or attestation; unbonding is measured from here. + pub last_active: i64, +} + +#[account] +#[derive(InitSpace)] +pub struct LiquidityPool { + pub token_a_mint: Pubkey, + pub token_b_mint: Pubkey, + pub reserve_a: u64, + pub reserve_b: u64, + pub admin: Pubkey, +} + +// ─── Events ───────────────────────────────────────────────────────────────── + +#[event] +pub struct ProofVerifiedEvent { + pub src_chain: u64, + pub sequence: u64, + pub payload_hash: [u8; 32], + pub relayer: Pubkey, +} + +#[event] +pub struct MintEvent { + pub src_chain: u64, + pub sequence: u64, + pub recipient: Pubkey, + pub amount: u64, + pub payload_hash: [u8; 32], +} + +#[event] +pub struct SwapExecutedEvent { + pub src_chain: u64, + pub sequence: u64, + pub amount_in: u64, + pub amount_out: u64, + pub fee: u64, + pub recipient: Pubkey, +} + +#[event] +pub struct StakeEvent { + pub relayer: Pubkey, + pub amount: u64, + pub total_staked: u64, +} + +#[event] +pub struct UnstakeEvent { + pub relayer: Pubkey, + pub amount: u64, + pub remaining: u64, +} + +#[event] +pub struct SlashEvent { + pub relayer: Pubkey, + pub slash_amount: u64, + pub remaining_stake: u64, +} + +#[event] +pub struct TokenBurnedEvent { + pub amount: u64, +} + +#[event] +pub struct PoolInitializedEvent { + pub token_a_mint: Pubkey, + pub token_b_mint: Pubkey, + pub reserve_a: u64, + pub reserve_b: u64, +} + +#[event] +pub struct VKUpdatedEvent { + pub admin: Pubkey, +} + +#[event] +pub struct VKProposedEvent { + pub admin: Pubkey, + pub eta: i64, +} + +/// A message leaving the hub. Source chain is `HUB_CHAIN_ID` and source +/// gateway is this program's ID; relayers prove `payload_hash` and deliver it +/// to the destination gateway (`releaseTokens` on EVM). +#[event] +pub struct OutboundMessageEvent { + pub payload_hash: [u8; 32], + pub dst_chain: u64, + pub dst_gateway: [u8; 32], + pub nonce: u64, + pub action: u8, + pub sender: [u8; 32], + pub asset: [u8; 32], + pub amount: u128, + pub recipient: [u8; 32], + pub extra_hash: [u8; 32], +} + +// ─── Errors ───────────────────────────────────────────────────────────────── + +#[error_code] +pub enum HubError { + #[msg("Invalid ZK proof: Groth16 pairing check failed.")] + InvalidProof, + #[msg("Proof must be exactly 256 bytes.")] + InvalidProofLength, + #[msg("Insufficient stake: minimum 100,000 ILINK required.")] + InsufficientStake, + #[msg("Slippage exceeded: output amount below minimum.")] + SlippageExceeded, + #[msg("Liquidity pool is empty.")] + EmptyPool, + #[msg("Verification key not initialized. Call set_verification_key first.")] + VKNotInitialized, + #[msg("Verification key data must be exactly 576 bytes.")] + InvalidVKLength, + #[msg("Signer is not authorized for this action.")] + Unauthorized, + #[msg("Message fields do not hash to the supplied payload hash.")] + PayloadHashMismatch, + #[msg("Message has already been executed.")] + MessageAlreadyExecuted, + #[msg("Message action does not match this instruction.")] + WrongAction, + #[msg("Fee rate exceeds the maximum allowed.")] + InvalidFeeRate, + #[msg("Stake is still within its unbonding period.")] + StakeLocked, + #[msg("Verification key timelock has not elapsed.")] + TimelockNotElapsed, + #[msg("Amount does not fit in a u64 after decimal scaling.")] + AmountOverflow, + #[msg("Amount rounds to zero after decimal scaling.")] + AmountTooSmall, + #[msg("Arithmetic overflow.")] + MathOverflow, + #[msg("Recipient token account is not owned by the message recipient.")] + InvalidRecipient, + #[msg("Mint does not match the registered asset.")] + InvalidMint, + #[msg("Token does not match the pool or message.")] + WrongToken, + #[msg("Decimals shift is out of range.")] + InvalidDecimalsShift, +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use ark_bn254::{Bn254, Fr as ArkFr, G1Affine, G2Affine}; + use ark_ff::{BigInteger, Field, PrimeField, UniformRand}; + use ark_groth16::Groth16; + use ark_relations::r1cs::{ + ConstraintSynthesizer, ConstraintSystemRef, LinearCombination, SynthesisError, Variable, + }; + use ark_snark::SNARK; + use ark_std::rand::{rngs::StdRng, SeedableRng}; + + fn ark_to_limbs(x: ArkFr) -> fr::Fr { + x.into_bigint().0 + } + + fn limbs_to_ark(x: &fr::Fr) -> ArkFr { + ArkFr::from_be_bytes_mod_order(&fr::to_be_bytes(x)) + } + + #[test] + fn fr_constants_are_correct() { + assert_eq!(fr::MODULUS, ArkFr::MODULUS.0); + // r · INV ≡ −1 (mod 2^64) + assert_eq!(fr::MODULUS[0].wrapping_mul(fr::INV), u64::MAX); + // R² mod r, R = 2^256 + assert_eq!(fr::R2, ark_to_limbs(ArkFr::from(2u64).pow([512u64]))); + } + + #[test] + fn fr_mul_and_add_match_arkworks() { + let mut rng = StdRng::seed_from_u64(7); + for _ in 0..2_000 { + let a = ArkFr::rand(&mut rng); + let b = ArkFr::rand(&mut rng); + let (la, lb) = (ark_to_limbs(a), ark_to_limbs(b)); + assert_eq!(limbs_to_ark(&fr::mul(&la, &lb)), a * b); + assert_eq!(limbs_to_ark(&fr::add(&la, &lb)), a + b); + } + let max = ark_to_limbs(-ArkFr::from(1u64)); + assert_eq!(limbs_to_ark(&fr::mul(&max, &max)), ArkFr::from(1u64)); + } + + #[test] + fn reduction_handles_values_above_5r() { + // 0xFF..FF ≥ 5r needs five subtractions; the old loop stopped at four. + for bytes in [[0xFFu8; 32], [0xF3u8; 32], [0u8; 32]] { + let ours = limbs_to_ark(&fr::from_be_bytes_mod_order(&bytes)); + assert_eq!(ours, ArkFr::from_be_bytes_mod_order(&bytes)); + } + } + + fn reference_commitment(payload_hash: &[u8; 32], sequence: u64) -> ArkFr { + let rc = ArkFr::from_be_bytes_mod_order(&keccak::hash(DOMAIN_SALT).to_bytes()); + let w = ArkFr::from_be_bytes_mod_order(payload_hash) + rc; + w.square().square() * w + ArkFr::from(sequence) + } + + #[test] + fn commitment_matches_relayer_formula() { + let mut rng = StdRng::seed_from_u64(11); + for i in 0..200u64 { + let mut h = [0u8; 32]; + for b in h.iter_mut() { + *b = (u64::rand(&mut rng) & 0xFF) as u8; + } + if i % 10 == 0 { + h = [0xFF; 32]; + } + let ours = compute_commitment(&h, i * 1_000_003); + let expected = reference_commitment(&h, i * 1_000_003) + .into_bigint() + .to_bytes_be(); + assert_eq!(ours.to_vec(), expected); + } + } + + fn sample_message() -> InboundMessage { + InboundMessage { + src_chain: 1, + src_gateway: [0x11; 32], + nonce: 42, + sender: [0x22; 32], + asset: [0x33; 32], + amount: 1_000_000_000_000_000_000, + recipient: Pubkey::new_from_array([0x44; 32]), + extra_hash: [0x55; 32], + } + } + + #[test] + fn canonical_hash_matches_abi_encoding() { + // Independent encoding: twelve 32-byte ABI words, as abi.encode produces. + let m = sample_message(); + let mut words: Vec<[u8; 32]> = Vec::new(); + words.push(keccak::hash(b"InterlinkMessage(v1)").to_bytes()); + let mut w = [0u8; 32]; + w[31] = 1; + words.push(w); // src_chain = 1 + words.push([0x11; 32]); + let mut w = [0u8; 32]; + w[31] = 2; + words.push(w); // dst_chain = HUB_CHAIN_ID + words.push(crate::ID.to_bytes()); + let mut w = [0u8; 32]; + w[31] = 42; + words.push(w); // nonce + let mut w = [0u8; 32]; + w[31] = ACTION_TRANSFER; + words.push(w); + words.push([0x22; 32]); + words.push([0x33; 32]); + let mut w = [0u8; 32]; + w[16..].copy_from_slice(&1_000_000_000_000_000_000u128.to_be_bytes()); + words.push(w); + words.push([0x44; 32]); + words.push([0x55; 32]); + let flat: Vec = words.concat(); + assert_eq!( + canonical_hash(ACTION_TRANSFER, &m), + keccak::hash(&flat).to_bytes() + ); + } + + #[test] + fn canonical_hash_binds_every_field() { + let base = sample_message(); + let h = canonical_hash(ACTION_TRANSFER, &base); + let mutations: Vec> = vec![ + Box::new(|m| m.src_chain += 1), + Box::new(|m| m.src_gateway[0] ^= 1), + Box::new(|m| m.nonce += 1), + Box::new(|m| m.sender[0] ^= 1), + Box::new(|m| m.asset[0] ^= 1), + Box::new(|m| m.amount += 1), + Box::new(|m| m.recipient = Pubkey::new_from_array([0x45; 32])), + Box::new(|m| m.extra_hash[0] ^= 1), + ]; + for mutate in mutations { + let mut m = base.clone(); + mutate(&mut m); + assert_ne!(canonical_hash(ACTION_TRANSFER, &m), h); + } + assert_ne!(canonical_hash(ACTION_SWAP, &base), h); + } + + #[test] + fn outbound_hash_matches_evm_vector() { + // Same vector as contracts/evm/test (VECTOR_HASH), checked against the + // compiled gateway: a hub → EVM transfer that `releaseTokens` accepts. + let mut dst_gateway = [0u8; 32]; + dst_gateway[30..].copy_from_slice(&[0xBE, 0xEF]); + let mut recipient = [0u8; 32]; + recipient[12..].copy_from_slice(&[0xA1; 20]); + let h = message_hash(&MessageFields { + src_chain: HUB_CHAIN_ID, + src_gateway: [0x7B; 32], + dst_chain: 31337, + dst_gateway, + nonce: 7, + action: ACTION_TRANSFER, + sender: [0x22; 32], + asset: [0; 32], + amount: 2_000_000_000_000_000_000, + recipient, + extra_hash: [0; 32], + }); + let expected: [u8; 32] = [ + 0x07, 0xb0, 0xf8, 0xb8, 0xce, 0x5c, 0x6f, 0x82, 0xe0, 0xbb, 0x55, 0x20, 0x73, 0xf1, + 0xc3, 0x5d, 0xf9, 0xb0, 0x98, 0x7c, 0x6e, 0x2c, 0x59, 0x8c, 0x52, 0xed, 0x5b, 0x59, + 0xee, 0x1c, 0x09, 0xbd, + ]; + assert_eq!(h, expected); + } + + #[test] + fn unscale_round_trips_scale() { + // 1.23456789 ETH bridged to an 8-decimal mint and back loses only dust. + let src = 1_234_567_891_234_567_891u128; + let local = scale_amount(src, 10).unwrap(); + assert_eq!( + unscale_amount(local, 10).unwrap(), + 1_234_567_890_000_000_000 + ); + assert!(unscale_amount(u64::MAX, 30).is_err()); + } + + #[test] + fn scale_amount_handles_decimals() { + // 1 ETH (18 decimals) → 8-decimal mint + assert_eq!( + scale_amount(1_000_000_000_000_000_000, 10).unwrap(), + 100_000_000 + ); + // 100 ETH at 18 decimals overflows u64 without scaling + assert!(scale_amount(100_000_000_000_000_000_000, 0).is_err()); + assert!(scale_amount(5, 1).is_err()); // rounds to zero + } + + // Same circuit as relayer/src/prover.rs. + #[derive(Clone)] + struct InterlinkCircuit { + message: Option, + sequence: Option, + round_constant: ArkFr, + } + + impl ConstraintSynthesizer for InterlinkCircuit { + fn generate_constraints( + self, + cs: ConstraintSystemRef, + ) -> std::result::Result<(), SynthesisError> { + let rc = self.round_constant; + let msg = + cs.new_witness_variable(|| self.message.ok_or(SynthesisError::AssignmentMissing))?; + let seq = + cs.new_witness_variable(|| self.sequence.ok_or(SynthesisError::AssignmentMissing))?; + let w_val = self.message.map(|m| m + rc); + let w = cs.new_witness_variable(|| w_val.ok_or(SynthesisError::AssignmentMissing))?; + let mut a = LinearCombination::zero(); + a += (ArkFr::from(1u64), msg); + a += (rc, Variable::One); + cs.enforce_constraint( + a, + LinearCombination::from(Variable::One), + LinearCombination::from(w), + )?; + let v1_val = w_val.map(|w| w.square()); + let v1 = cs.new_witness_variable(|| v1_val.ok_or(SynthesisError::AssignmentMissing))?; + cs.enforce_constraint( + LinearCombination::from(w), + LinearCombination::from(w), + LinearCombination::from(v1), + )?; + let v2_val = v1_val.map(|v| v.square()); + let v2 = cs.new_witness_variable(|| v2_val.ok_or(SynthesisError::AssignmentMissing))?; + cs.enforce_constraint( + LinearCombination::from(v1), + LinearCombination::from(v1), + LinearCombination::from(v2), + )?; + let v3_val = v2_val.zip(w_val).map(|(v2, w)| v2 * w); + let v3 = cs.new_witness_variable(|| v3_val.ok_or(SynthesisError::AssignmentMissing))?; + cs.enforce_constraint( + LinearCombination::from(v2), + LinearCombination::from(w), + LinearCombination::from(v3), + )?; + let c_val = v3_val.zip(self.sequence).map(|(v3, s)| v3 + s); + let c = cs.new_input_variable(|| c_val.ok_or(SynthesisError::AssignmentMissing))?; + let mut a = LinearCombination::zero(); + a += (ArkFr::from(1u64), v3); + a += (ArkFr::from(1u64), seq); + cs.enforce_constraint( + a, + LinearCombination::from(Variable::One), + LinearCombination::from(c), + )?; + Ok(()) + } + } + + fn g1_bytes(p: &G1Affine) -> [u8; 64] { + let mut out = [0u8; 64]; + out[..32].copy_from_slice(&p.x.into_bigint().to_bytes_be()); + out[32..].copy_from_slice(&p.y.into_bigint().to_bytes_be()); + out + } + + fn g2_bytes(p: &G2Affine) -> [u8; 128] { + let mut out = [0u8; 128]; + out[..32].copy_from_slice(&p.x.c1.into_bigint().to_bytes_be()); + out[32..64].copy_from_slice(&p.x.c0.into_bigint().to_bytes_be()); + out[64..96].copy_from_slice(&p.y.c1.into_bigint().to_bytes_be()); + out[96..].copy_from_slice(&p.y.c0.into_bigint().to_bytes_be()); + out + } + + /// End-to-end: a real arkworks Groth16 proof verifies through the + /// on-chain verifier (host alt_bn128 implementation), and any change to + /// the message or sequence is rejected. + #[test] + fn groth16_end_to_end() { + let mut rng = StdRng::seed_from_u64(3); + let rc = ArkFr::from_be_bytes_mod_order(&keccak::hash(DOMAIN_SALT).to_bytes()); + let blank = InterlinkCircuit { + message: None, + sequence: None, + round_constant: rc, + }; + let (pk, vk) = Groth16::::circuit_specific_setup(blank, &mut rng).unwrap(); + + let msg = sample_message(); + let payload_hash = canonical_hash(ACTION_TRANSFER, &msg); + let circuit = InterlinkCircuit { + message: Some(ArkFr::from_be_bytes_mod_order(&payload_hash)), + sequence: Some(ArkFr::from(msg.nonce)), + round_constant: rc, + }; + let proof = Groth16::::prove(&pk, circuit, &mut rng).unwrap(); + + let mut proof_bytes = Vec::with_capacity(256); + proof_bytes.extend_from_slice(&g1_bytes(&proof.a)); + proof_bytes.extend_from_slice(&g2_bytes(&proof.b)); + proof_bytes.extend_from_slice(&g1_bytes(&proof.c)); + + let vk_bytes = VkBytes { + alpha_g1: g1_bytes(&vk.alpha_g1), + beta_g2: g2_bytes(&vk.beta_g2), + gamma_g2: g2_bytes(&vk.gamma_g2), + delta_g2: g2_bytes(&vk.delta_g2), + ic_0: g1_bytes(&vk.gamma_abc_g1[0]), + ic_1: g1_bytes(&vk.gamma_abc_g1[1]), + }; + + assert!(verify_groth16_proof( + &proof_bytes, + &payload_hash, + msg.nonce, + &vk_bytes + )); + + let mut tampered = msg.clone(); + tampered.amount *= 1_000; + let bad_hash = canonical_hash(ACTION_TRANSFER, &tampered); + assert!(!verify_groth16_proof( + &proof_bytes, + &bad_hash, + msg.nonce, + &vk_bytes + )); + assert!(!verify_groth16_proof( + &proof_bytes, + &payload_hash, + msg.nonce + 1, + &vk_bytes + )); + + let mut corrupted = proof_bytes.clone(); + corrupted[200] ^= 1; + assert!(!verify_groth16_proof( + &corrupted, + &payload_hash, + msg.nonce, + &vk_bytes + )); + } +} diff --git a/contracts/solana/interlink-hub/tests/interlink-hub.ts b/contracts/solana/interlink-hub/tests/interlink-hub.ts index 44ac2d8..8b860ce 100644 --- a/contracts/solana/interlink-hub/tests/interlink-hub.ts +++ b/contracts/solana/interlink-hub/tests/interlink-hub.ts @@ -1,130 +1,179 @@ -import * as anchor from "@coral-xyz/anchor"; -import { Program } from "@coral-xyz/anchor"; -import { InterlinkHub } from "../target/types/interlink_hub"; -import { expect } from "chai"; - -describe("interlink-hub", () => { - const provider = anchor.AnchorProvider.env(); - anchor.setProvider(provider); - - const program = anchor.workspace.interlinkHub as Program; - const admin = provider.wallet; - - let stateRegistryPda: anchor.web3.PublicKey; - let vkPda: anchor.web3.PublicKey; - - before(async () => { - [stateRegistryPda] = anchor.web3.PublicKey.findProgramAddressSync( - [Buffer.from("state")], - program.programId - ); - [vkPda] = anchor.web3.PublicKey.findProgramAddressSync( - [Buffer.from("vk")], - program.programId - ); - }); - - it("initializes the hub with fee rate", async () => { - const feeRateBps = 10; // 0.1% - - const tx = await program.methods - .initialize(feeRateBps) - .accounts({ - stateRegistry: stateRegistryPda, - admin: admin.publicKey, - systemProgram: anchor.web3.SystemProgram.programId, - }) - .rpc(); - - console.log("Initialize tx:", tx); - - // Verify state — field is nextSequence (renamed from processedSequences) - const state = await program.account.stateRegistry.fetch(stateRegistryPda); - expect(state.admin.toString()).to.equal(admin.publicKey.toString()); - expect(state.feeRateBps).to.equal(feeRateBps); - expect(state.nextSequence.toNumber()).to.equal(0); - expect(state.totalStaked.toNumber()).to.equal(0); - expect(state.totalBurned.toNumber()).to.equal(0); - expect(state.vkInitialized).to.equal(false); - }); - - it("rejects submit_proof when VK is not initialized", async () => { - // Derive the stake PDA for the admin/relayer - const [stakePda] = anchor.web3.PublicKey.findProgramAddressSync( - [Buffer.from("stake"), admin.publicKey.toBuffer()], - program.programId - ); - const proof = Buffer.alloc(256); - const payloadHash = new Array(32).fill(0); - const commitmentInput = new Array(32).fill(0); - - try { - await program.methods - .submitProof( - new anchor.BN(1), // source_chain - new anchor.BN(0), // sequence - proof, - payloadHash, - commitmentInput - ) - .accounts({ - stateRegistry: stateRegistryPda, - stakeAccount: stakePda, - verificationKey: vkPda, - relayer: admin.publicKey, - }) - .rpc(); - expect.fail("should have thrown"); - } catch (err) { - // Expected: VKNotInitialized or InsufficientStake (stake account not funded) - const msg = err.toString(); - expect( - msg.includes("VKNotInitialized") || msg.includes("InsufficientStake") || msg.includes("AccountNotInitialized") - ).to.be.true; - } - }); - - it("rejects submit_proof with wrong proof length", async () => { - const [stakePda] = anchor.web3.PublicKey.findProgramAddressSync( - [Buffer.from("stake"), admin.publicKey.toBuffer()], - program.programId - ); - const invalidProof = Buffer.alloc(100); // Wrong length (should be 256) - const payloadHash = new Array(32).fill(0); - const commitmentInput = new Array(32).fill(0); - - try { - await program.methods - .submitProof( - new anchor.BN(1), - new anchor.BN(0), - invalidProof, - payloadHash, - commitmentInput - ) - .accounts({ - stateRegistry: stateRegistryPda, - stakeAccount: stakePda, - verificationKey: vkPda, - relayer: admin.publicKey, - }) - .rpc(); - expect.fail("should have thrown"); - } catch (err) { - // Expected rejection — wrong proof length, insufficient stake, or uninitialized VK - const msg = err.toString(); - expect( - msg.includes("InvalidProof") || msg.includes("InsufficientStake") || - msg.includes("VKNotInitialized") || msg.includes("AccountNotInitialized") - ).to.be.true; - } - }); - - it("rejects duplicate sequence numbers via sequential ordering", async () => { - // Sequence 0 cannot be submitted again once next_sequence > 0. - // We just verify the guard is in place by checking the state. - const state = await program.account.stateRegistry.fetch(stateRegistryPda); - // nextSequence starts at 0 — any re-submission of seq < nextSequence is rejected - expect(state.nextSequence.toNumber()).to.be.gte(0); - }); -}); +import * as anchor from "@coral-xyz/anchor"; +import { Program } from "@coral-xyz/anchor"; +import { createMint } from "@solana/spl-token"; +import { InterlinkHub } from "../target/types/interlink_hub"; +import { expect } from "chai"; + +const BPF_UPGRADEABLE_LOADER = new anchor.web3.PublicKey( + "BPFLoaderUpgradeab1e11111111111111111111111" +); + +describe("interlink-hub", () => { + const provider = anchor.AnchorProvider.env(); + anchor.setProvider(provider); + + const program = anchor.workspace.interlinkHub as Program; + const admin = provider.wallet as anchor.Wallet; + + const pda = (...seeds: Buffer[]) => + anchor.web3.PublicKey.findProgramAddressSync(seeds, program.programId)[0]; + + const statePda = pda(Buffer.from("state")); + const vkPda = pda(Buffer.from("vk")); + const stakePda = pda(Buffer.from("stake"), admin.publicKey.toBuffer()); + const [programData] = anchor.web3.PublicKey.findProgramAddressSync( + [program.programId.toBuffer()], + BPF_UPGRADEABLE_LOADER + ); + + let ilinkMint: anchor.web3.PublicKey; + + const sampleMessage = () => ({ + srcChain: new anchor.BN(1), + srcGateway: new Array(32).fill(0x11), + nonce: new anchor.BN(42), + sender: new Array(32).fill(0x22), + asset: new Array(32).fill(0x33), + amount: new anchor.BN("1000000000000000000"), + recipient: admin.publicKey, + extraHash: new Array(32).fill(0x55), + }); + + const expectError = async (p: Promise, ...names: string[]) => { + try { + await p; + expect.fail("should have thrown"); + } catch (err) { + const msg = String(err); + expect( + names.some((n) => msg.includes(n)), + `expected one of ${names.join(", ")}, got: ${msg}` + ).to.be.true; + } + }; + + before(async () => { + ilinkMint = await createMint( + provider.connection, + admin.payer, + admin.publicKey, + null, + 6 + ); + }); + + it("initializes the hub (upgrade authority only)", async () => { + await program.methods + .initialize(10) + .accountsPartial({ + stateRegistry: statePda, + ilinkMint, + admin: admin.publicKey, + program: program.programId, + programData, + }) + .rpc(); + + const state = await program.account.stateRegistry.fetch(statePda); + expect(state.admin.toBase58()).to.equal(admin.publicKey.toBase58()); + expect(state.ilinkMint.toBase58()).to.equal(ilinkMint.toBase58()); + expect(state.feeRateBps).to.equal(10); + expect(state.vkInitialized).to.equal(false); + }); + + it("rejects submit_proof before the VK is set", async () => { + const msg = sampleMessage(); + const payloadHash = new Array(32).fill(0); + await expectError( + program.methods + .submitProof(payloadHash, 1, msg, Buffer.alloc(256)) + .accountsPartial({ + stateRegistry: statePda, + stakeAccount: stakePda, + verificationKey: vkPda, + messageRecord: pda(Buffer.from("message"), Buffer.from(payloadHash)), + relayer: admin.publicKey, + }) + .rpc(), + "AccountNotInitialized", + "InsufficientStake", + "VKNotInitialized" + ); + }); + + it("sets the initial VK exactly once", async () => { + // Shape-only VK; real proofs are covered by the Rust groth16_end_to_end test. + const vk = Buffer.alloc(576, 1); + await program.methods + .setVerificationKey(vk) + .accountsPartial({ + stateRegistry: statePda, + verificationKey: vkPda, + admin: admin.publicKey, + }) + .rpc(); + + // A second direct set must fail: rotation goes through the timelock. + await expectError( + program.methods + .setVerificationKey(vk) + .accountsPartial({ + stateRegistry: statePda, + verificationKey: vkPda, + admin: admin.publicKey, + }) + .rpc(), + "already in use" + ); + }); + + it("refuses to apply a proposed VK before the timelock", async () => { + const pendingPda = pda(Buffer.from("pending_vk")); + await program.methods + .proposeVerificationKey(Buffer.alloc(576, 2)) + .accountsPartial({ + stateRegistry: statePda, + pendingVerificationKey: pendingPda, + admin: admin.publicKey, + }) + .rpc(); + + await expectError( + program.methods + .applyVerificationKey() + .accountsPartial({ + stateRegistry: statePda, + pendingVerificationKey: pendingPda, + verificationKey: vkPda, + admin: admin.publicKey, + }) + .rpc(), + "TimelockNotElapsed" + ); + }); + + it("rejects staking with a token other than ILINK", async () => { + const fakeMint = await createMint( + provider.connection, + admin.payer, + admin.publicKey, + null, + 6 + ); + await expectError( + program.methods + .stake(new anchor.BN("100000000000")) + .accountsPartial({ + stateRegistry: statePda, + stakeAccount: stakePda, + stakeVault: pda(Buffer.from("stake_vault"), admin.publicKey.toBuffer()), + ilinkMint: fakeMint, + relayerTokenAccount: admin.publicKey, // never reached + relayer: admin.publicKey, + }) + .rpc(), + "InvalidMint", + "ConstraintAddress" + ); + }); +}); diff --git a/contracts/solana/interlink-hub/yarn.lock b/contracts/solana/interlink-hub/yarn.lock index 0ff6dc7..72c0413 100644 --- a/contracts/solana/interlink-hub/yarn.lock +++ b/contracts/solana/interlink-hub/yarn.lock @@ -2,102 +2,202 @@ # yarn lockfile v1 -"@babel/runtime@^7.25.0": - version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.6.tgz#d267a43cb1836dc4d182cce93ae75ba954ef6d2b" - integrity sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA== - -"@coral-xyz/anchor-errors@^0.31.1": - version "0.31.1" - resolved "https://registry.yarnpkg.com/@coral-xyz/anchor-errors/-/anchor-errors-0.31.1.tgz#d635cbac2533973ae6bfb5d3ba1de89ce5aece2d" - integrity sha512-NhNEku4F3zzUSBtrYz84FzYWm48+9OvmT1Hhnwr6GnPQry2dsEqH/ti/7ASjjpoFTWRnPXrjAIT1qM6Isop+LQ== - -"@coral-xyz/anchor@^0.32.1": - version "0.32.1" - resolved "https://registry.yarnpkg.com/@coral-xyz/anchor/-/anchor-0.32.1.tgz#a07440d9d267840f4f99f1493bd8ce7d7f128e57" - integrity sha512-zAyxFtfeje2FbMA1wzgcdVs7Hng/MijPKpRijoySPCicnvcTQs/+dnPZ/cR+LcXM9v9UYSyW81uRNYZtN5G4yg== - dependencies: - "@coral-xyz/anchor-errors" "^0.31.1" - "@coral-xyz/borsh" "^0.31.1" +"@babel/runtime@^7.29.7": + version "7.29.14" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.14.tgz#9ad0cc2addbbc8c46adaec9e90b21bb76d42b483" + integrity sha512-Y5tqevQaUAZhhj82BpYMNC1ssvLQTVAfwKdm8iy8qwmhvC2/CpcAu8OC+QEtjM7pMk7knx83u1Qj8gzkicyelw== + +"@coral-xyz/anchor-errors@^0.30.1": + version "0.30.1" + 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": + version "0.30.1" + resolved "https://registry.yarnpkg.com/@coral-xyz/anchor/-/anchor-0.30.1.tgz#17f3e9134c28cd0ea83574c6bab4e410bcecec5d" + integrity sha512-gDXFoF5oHgpriXAaLpxyWBHdCs8Awgf/gLHIo6crv7Aqm937CNdY+x+6hoj7QR5vaJV7MxWSQ0NGFzL3kPbWEQ== + dependencies: + "@coral-xyz/anchor-errors" "^0.30.1" + "@coral-xyz/borsh" "^0.30.1" "@noble/hashes" "^1.3.1" - "@solana/web3.js" "^1.69.0" + "@solana/web3.js" "^1.68.0" bn.js "^5.1.2" bs58 "^4.0.1" buffer-layout "^1.2.2" camelcase "^6.3.0" cross-fetch "^3.1.5" + crypto-hash "^1.3.0" eventemitter3 "^4.0.7" pako "^2.0.3" + snake-case "^3.0.4" superstruct "^0.15.4" toml "^3.0.0" -"@coral-xyz/borsh@^0.31.1": - version "0.31.1" - resolved "https://registry.yarnpkg.com/@coral-xyz/borsh/-/borsh-0.31.1.tgz#5328e1e0921b75d7f4a62dd3f61885a938bc7241" - integrity sha512-9N8AU9F0ubriKfNE3g1WF0/4dtlGXoBN/hd1PvbNBamBNwRgHxH4P+o3Zt7rSEloW1HUs6LfZEchlx9fW7POYw== +"@coral-xyz/borsh@^0.30.1": + version "0.30.1" + resolved "https://registry.yarnpkg.com/@coral-xyz/borsh/-/borsh-0.30.1.tgz#869d8833abe65685c72e9199b8688477a4f6b0e3" + integrity sha512-aaxswpPrCFKl8vZTbxLssA2RvwX2zmKLlRCIktJOwW+VpVwYtXRtlWiIP+c2pPRKneiTiWCN2GEMSH9j1zTlWQ== dependencies: bn.js "^5.1.2" buffer-layout "^1.2.0" -"@noble/curves@^1.4.2": +"@noble/curves@^1.9.7": version "1.9.7" resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.9.7.tgz#79d04b4758a43e4bca2cbdc62e7771352fa6b951" integrity sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw== dependencies: "@noble/hashes" "1.8.0" -"@noble/hashes@1.8.0", "@noble/hashes@^1.3.1", "@noble/hashes@^1.4.0": +"@noble/hashes@1.8.0", "@noble/hashes@^1.3.1", "@noble/hashes@^1.8.0": version "1.8.0" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== -"@solana/buffer-layout@^4.0.1": +"@solana/buffer-layout-utils@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@solana/buffer-layout-utils/-/buffer-layout-utils-0.3.0.tgz#88a5d69fd5606fee198f719d37065b43796a396e" + integrity sha512-MuQOCC1j0np1xH9yAv0ZWWfwvr7Bt7Sz4LId11Wi4wDdAmJ+lobE+vHg/mZmGcihF0BIkqVBNxGmlv8QE5DrtA== + 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/codecs-core@2.3.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@solana/codecs-core/-/codecs-core-2.3.0.tgz#6bf2bb565cb1ae880f8018635c92f751465d8695" - integrity sha512-oG+VZzN6YhBHIoSKgS5ESM9VIGzhWjEHEGNPSibiDTxFhsFWxNaz8LbMDPjBUE69r9wmdGLkrQ+wVPbnJcZPvw== - dependencies: - "@solana/errors" "2.3.0" - -"@solana/codecs-numbers@^2.1.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@solana/codecs-numbers/-/codecs-numbers-2.3.0.tgz#ac7e7f38aaf7fcd22ce2061fbdcd625e73828dc6" - integrity sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg== +"@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/codecs-core" "2.3.0" - "@solana/errors" "2.3.0" - -"@solana/errors@2.3.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@solana/errors/-/errors-2.3.0.tgz#4ac9380343dbeffb9dffbcb77c28d0e457c5fa31" - integrity sha512-66RI9MAbwYV0UtP7kGcTBVLxJgUxoZGm8Fbc0ah+lGiAw17Gugco6+9GrJCV83VyF2mDWyYnYM9qdI3yjgpnaQ== - dependencies: - chalk "^5.4.1" - commander "^14.0.0" + "@solana/errors" "2.0.0-rc.1" + +"@solana/codecs-core@5.5.1": + version "5.5.1" + resolved "https://registry.yarnpkg.com/@solana/codecs-core/-/codecs-core-5.5.1.tgz#1b2619c697ad04c99f98cc468135d584b7e2b16d" + integrity sha512-TgBt//bbKBct0t6/MpA8ElaOA3sa8eYVvR7LGslCZ84WiAwwjCY0lW/lOYsFHJQzwREMdUyuEyy5YWBKtdh8Rw== + dependencies: + "@solana/errors" "5.5.1" + +"@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-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-numbers@^5.5.1": + version "5.5.1" + resolved "https://registry.yarnpkg.com/@solana/codecs-numbers/-/codecs-numbers-5.5.1.tgz#6ebc43250133ceb7836f1ac5780a7767e383efde" + integrity sha512-rllMIZAHqmtvC0HO/dc/21wDuWaD0B8Ryv8o+YtsICQBuiL/0U4AGwH7Pi5GNFySYk0/crSuwfIqQFtmxNSPFw== + dependencies: + "@solana/codecs-core" "5.5.1" + "@solana/errors" "5.5.1" + +"@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-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-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/errors@5.5.1": + version "5.5.1" + resolved "https://registry.yarnpkg.com/@solana/errors/-/errors-5.5.1.tgz#1c17bc822264fd5a6305d8bfb9d597858e4ce17b" + integrity sha512-vFO3p+S7HoyyrcAectnXbdsMfwUzY2zYFUc2DEe5BwpiE9J1IAxPBGjOWO6hL1bbYdBrlmjNx8DXCslqS+Kcmg== + dependencies: + chalk "5.6.2" + commander "14.0.2" + +"@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.7": + version "0.0.7" + resolved "https://registry.yarnpkg.com/@solana/spl-token-group/-/spl-token-group-0.0.7.tgz#83c00f0cd0bda33115468cd28b89d94f8ec1fee4" + integrity sha512-V1N/iX7Cr7H0uazWUT2uk27TMqlqedpXHRqqAbVO2gvmJyT0E0ummMEAVQeXZ05ZhQ/xF39DLSdBp90XebWEug== + dependencies: + "@solana/codecs" "2.0.0-rc.1" + +"@solana/spl-token-metadata@^0.1.6": + version "0.1.6" + resolved "https://registry.yarnpkg.com/@solana/spl-token-metadata/-/spl-token-metadata-0.1.6.tgz#d240947aed6e7318d637238022a7b0981b32ae80" + integrity sha512-7sMt1rsm/zQOQcUWllQX9mD2O6KhSAtY1hFR2hfFwgqfFWzSY9E9GDvFVNYUI1F0iQKcm6HmePU9QbKRXTEBiA== + dependencies: + "@solana/codecs" "2.0.0-rc.1" + +"@solana/spl-token@^0.4.9": + version "0.4.15" + resolved "https://registry.yarnpkg.com/@solana/spl-token/-/spl-token-0.4.15.tgz#d9e9ca30a15d4b73579f9423d4422e0baa77721f" + integrity sha512-3Lof3mNov8NVQ3PalIWb1Jgr/TZ6lYM+/sexv2TLqdhNFVth2OfWmH3d7QucgMjSbokkjNiNlRr6I8Fd269uaw== + dependencies: + "@solana/buffer-layout" "^4.0.0" + "@solana/buffer-layout-utils" "^0.3.0" + "@solana/spl-token-group" "^0.0.7" + "@solana/spl-token-metadata" "^0.1.6" + buffer "^6.0.3" -"@solana/web3.js@^1.69.0": - version "1.98.4" - resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-1.98.4.tgz#df51d78be9d865181ec5138b4e699d48e6895bbe" - integrity sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw== +"@solana/web3.js@^1.32.0", "@solana/web3.js@^1.68.0": + version "1.99.0" + resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-1.99.0.tgz#864ce3fefe03b258c430e31e6672b422426821f7" + integrity sha512-QZYQ2T1z6xWisoyALPq25i/QZTsRlM02BABtAsfaQ1p8wX4SdTfxrKTRue/ZZqrhNVh5oL7T/DUFiTS9DRgxow== dependencies: - "@babel/runtime" "^7.25.0" - "@noble/curves" "^1.4.2" - "@noble/hashes" "^1.4.0" + "@babel/runtime" "^7.29.7" + "@noble/curves" "^1.9.7" + "@noble/hashes" "^1.8.0" "@solana/buffer-layout" "^4.0.1" - "@solana/codecs-numbers" "^2.1.0" - agentkeepalive "^4.5.0" - bn.js "^5.2.1" + "@solana/codecs-numbers" "^5.5.1" + agentkeepalive "^4.6.0" + bn.js "^5.2.5" borsh "^0.7.0" bs58 "^4.0.1" buffer "6.0.3" fast-stable-stringify "^1.0.0" - jayson "^4.1.1" + jayson "^4.3.0" node-fetch "^2.7.0" rpc-websockets "^9.0.2" superstruct "^2.0.2" @@ -174,7 +274,7 @@ resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44" integrity sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q== -agentkeepalive@^4.5.0: +agentkeepalive@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-4.6.0.tgz#35f73e94b3f40bf65f105219c623ad19c136ea6a" integrity sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ== @@ -238,16 +338,40 @@ base64-js@^1.3.1: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== +bigint-buffer@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/bigint-buffer/-/bigint-buffer-1.1.5.tgz#d038f31c8e4534c1f8d0015209bf34b4fa6dd442" + integrity sha512-trfYco6AoZ+rKhKnxA0hgX0HAbVP/s808/EuDSe2JDzUnCp/xAsli35Orvk67UrTEcwuxZqYZDmfA2RXJgxVvA== + dependencies: + bindings "^1.3.0" + +bignumber.js@^9.0.1: + version "9.3.1" + resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.3.1.tgz#759c5aaddf2ffdc4f154f7b493e1c8770f88c4d7" + integrity sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ== + binary-extensions@^2.0.0: version "2.3.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== -bn.js@^5.1.2, bn.js@^5.2.0, bn.js@^5.2.1: +bindings@^1.3.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" + integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== + dependencies: + file-uri-to-path "1.0.0" + +bn.js@^5.1.2, bn.js@^5.2.0: version "5.2.3" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.3.tgz#16a9e409616b23fef3ccbedb8d42f13bff80295e" integrity sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w== +bn.js@^5.2.5: + version "5.2.5" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.5.tgz#e81ce41cf25e6f0cd1e05f20a9db8c18405e375f" + integrity sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg== + borsh@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/borsh/-/borsh-0.7.0.tgz#6e9560d719d86d90dc589bca60ffc8a6c51fec2a" @@ -327,6 +451,11 @@ chai@^4.3.4: pathval "^1.1.1" type-detect "^4.1.0" +chalk@5.6.2, chalk@^5.3.0: + version "5.6.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.6.2.tgz#b1238b6e23ea337af71c7f8a295db5af0c158aea" + integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== + chalk@^4.1.0: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" @@ -335,11 +464,6 @@ chalk@^4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" -chalk@^5.4.1: - version "5.6.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.6.2.tgz#b1238b6e23ea337af71c7f8a295db5af0c158aea" - integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== - check-error@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.3.tgz#a6502e4312a7ee969f646e83bb3ddd56281bd694" @@ -383,10 +507,15 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -commander@^14.0.0: - version "14.0.3" - resolved "https://registry.yarnpkg.com/commander/-/commander-14.0.3.tgz#425d79b48f9af82fcd9e4fc1ea8af6c5ec07bbc2" - integrity sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw== +commander@14.0.2: + version "14.0.2" + resolved "https://registry.yarnpkg.com/commander/-/commander-14.0.2.tgz#b71fd37fe4069e4c3c7c13925252ada4eba14e8e" + integrity sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ== + +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.20.3: version "2.20.3" @@ -405,6 +534,11 @@ cross-fetch@^3.1.5: dependencies: node-fetch "^2.7.0" +crypto-hash@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/crypto-hash/-/crypto-hash-1.3.0.tgz#b402cb08f4529e9f4f09346c3e275942f845e247" + integrity sha512-lyAZ0EMyjDkVvz8WOeVnuCPvKVBXcMv1l5SVqO1yC7PzTwrD/pPje/BIRbWhMoPe436U+Y2nD7f5bFx0kt+Sbg== + debug@4.3.3: version "4.3.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.3.tgz#04266e0b70a98d4462e6e288e38259213332b664" @@ -439,6 +573,14 @@ diff@^3.1.0: resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.1.tgz#e7fae480379d2e944c68ff0f5e1c29b6e28c77ab" integrity sha512-Z3u54A8qGyqFOSr2pk0ijYs8mOE9Qz8kTvtKeBI+upoG9j04Sq+oI7W8zAJiQybDcESET8/uIdHzs0p3k4fZlw== +dot-case@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.4.tgz#9b2b670d00a431667a8a75ba29cd1b98809ce751" + integrity sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w== + dependencies: + no-case "^3.0.4" + tslib "^2.0.3" + emoji-regex@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" @@ -486,6 +628,11 @@ fast-stable-stringify@^1.0.0: resolved "https://registry.yarnpkg.com/fast-stable-stringify/-/fast-stable-stringify-1.0.0.tgz#5c5543462b22aeeefd36d05b34e51c78cb86d313" integrity sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag== +file-uri-to-path@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" + integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== + fill-range@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" @@ -634,7 +781,7 @@ isomorphic-ws@^4.0.1: resolved "https://registry.yarnpkg.com/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz#55fd4cd6c5e6491e76dc125938dd863f5cd4f2dc" integrity sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w== -jayson@^4.1.1: +jayson@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/jayson/-/jayson-4.3.0.tgz#22eb8f3dcf37a5e893830e5451f32bde6d1bde4d" integrity sha512-AauzHcUcqs8OBnCHOkJY280VaTiCm57AbuO7lqzcw7JapGj50BisE3xhksye4zlTSR1+1tAz67wLTl8tEH1obQ== @@ -693,6 +840,13 @@ loupe@^2.3.6: dependencies: get-func-name "^2.0.1" +lower-case@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28" + integrity sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg== + dependencies: + tslib "^2.0.3" + make-error@^1.1.1: version "1.3.6" resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" @@ -769,6 +923,14 @@ nanoid@3.3.1: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.1.tgz#6347a18cac88af88f58af0b3594b723d5e99bb35" integrity sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw== +no-case@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" + integrity sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg== + dependencies: + lower-case "^2.0.2" + tslib "^2.0.3" + node-fetch@^2.7.0: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" @@ -884,6 +1046,14 @@ serialize-javascript@6.0.0: dependencies: randombytes "^2.1.0" +snake-case@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c" + integrity sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg== + dependencies: + dot-case "^3.0.4" + tslib "^2.0.3" + source-map-support@^0.5.6: version "0.5.21" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" @@ -1014,6 +1184,11 @@ tsconfig-paths@^3.5.0: minimist "^1.2.6" strip-bom "^3.0.0" +tslib@^2.0.3: + version "2.9.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.9.1.tgz#8986213ba2f17f38ba39a7c43d0e565a9134ff5f" + integrity sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA== + tslib@^2.8.0: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" diff --git a/contracts/solana/src/README.md b/contracts/solana/src/README.md deleted file mode 100644 index 9c6ecd2..0000000 --- a/contracts/solana/src/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# contracts/solana/src - -Anchor program source code for the Solana hub. - -## Key concepts - -- **StateRegistry** - - Stores admin, fee rate, and the latest processed cross-chain sequence. -- **submit_proof** - - Relayers submit proof bytes + public inputs, and the program records that a sequence is finalized. -- **buy_back_and_burn** - - Burns a portion of the fee token supply according to the protocol design. - -## Verification note - -`verify_snark_commitment` is currently a lightweight commitment-consistency check meant to represent the intended verification path. diff --git a/contracts/solana/src/lib.rs b/contracts/solana/src/lib.rs deleted file mode 100644 index f44d2cb..0000000 --- a/contracts/solana/src/lib.rs +++ /dev/null @@ -1,205 +0,0 @@ -use anchor_lang::prelude::*; -use anchor_lang::solana_program::alt_bn128::prelude::alt_bn128_pairing; -use anchor_spl::token::{self, Burn, Mint, Token, TokenAccount}; - -declare_id!("Hub1111111111111111111111111111111111111111"); - -// -G2 generator constants (BN254). required for the Groth16 pairing check. -// negated y coords of the standard G2 generator point. -// source: https://eips.ethereum.org/EIPS/eip-197 -const NEG_G2_GEN: [u8; 128] = [ - // x_c1 (big-endian) - 0x18, 0x00, 0xde, 0xef, 0x12, 0x1f, 0x1e, 0x76, - 0x42, 0x6a, 0x00, 0x66, 0x5e, 0x5c, 0x44, 0x79, - 0x67, 0x43, 0x22, 0xd4, 0xf7, 0x5e, 0xda, 0xdd, - 0x46, 0xde, 0xbd, 0x5c, 0xd9, 0x92, 0xf6, 0xed, - // x_c0 - 0x19, 0x8e, 0x93, 0x93, 0x92, 0x0d, 0x48, 0x3a, - 0x72, 0x60, 0xbf, 0xb7, 0x31, 0xfb, 0x5d, 0x25, - 0xf1, 0xaa, 0x49, 0x33, 0x35, 0xa9, 0xe7, 0x12, - 0x97, 0xe4, 0x85, 0xb7, 0xae, 0xf3, 0x12, 0xc2, - // y_c1 - 0x12, 0xc8, 0x5e, 0xa5, 0xdb, 0x8c, 0x6d, 0xeb, - 0x4a, 0xab, 0x71, 0x80, 0x8d, 0xcb, 0x40, 0x8f, - 0xe3, 0xd1, 0xe7, 0x69, 0x0c, 0x43, 0xd3, 0x7b, - 0x4c, 0xe6, 0xcc, 0x01, 0x66, 0xfa, 0x7d, 0xaa, - // y_c0 - 0x09, 0x06, 0x89, 0xd0, 0x58, 0x5f, 0xf0, 0x75, - 0xec, 0x9e, 0x99, 0xad, 0x6b, 0x85, 0x63, 0xef, - 0x40, 0x66, 0x38, 0x0c, 0x10, 0x73, 0xd5, 0x28, - 0x39, 0x9e, 0x71, 0x59, 0x2c, 0x34, 0xa2, 0x33, -]; - -#[program] -pub mod solana_gateway { - use super::*; - - pub fn initialize(ctx: Context, fee_rate_bps: u16) -> Result<()> { - let registry = &mut ctx.accounts.state_registry; - registry.admin = ctx.accounts.admin.key(); - registry.fee_rate_bps = fee_rate_bps; - registry.processed_sequences = 0; - Ok(()) - } - - /// Accepts a Halo2 Groth16 proof from a relayer. - /// Verifies via BN254 pairing (Solana alt_bn128 syscall — same precompile as EVM 0x08). - /// proof_data layout: [A_x(32), A_y(32), B_x1(32), B_x2(32), B_y1(32), B_y2(32), C_x(32), C_y(32)] = 256 bytes - pub fn submit_proof( - ctx: Context, - _source_chain: u64, - sequence: u64, - proof_data: Vec, - payload_hash: [u8; 32], - _commitment_input: [u8; 32], - ) -> Result<()> { - let registry = &mut ctx.accounts.state_registry; - - // anti-replay: reject any sequence already processed. - require!( - sequence > registry.processed_sequences, - HubError::SequenceAlreadyProcessed - ); - - // BN254 Groth16 pairing check via Solana's alt_bn128 syscall. - // this is the same computation as the EVM ecPairing precompile (address 0x08). - // checks: e(A, B) * e(C, -G2_gen) == 1 in Gt. - require!( - verify_groth16_proof(&proof_data, &payload_hash), - HubError::InvalidProof - ); - - // advance the sequence counter. - registry.processed_sequences = sequence; - - emit!(ProofVerifiedEvent { - sequence, - payload_hash, - relayer: ctx.accounts.relayer.key() - }); - - Ok(()) - } - - pub fn buy_back_and_burn(ctx: Context, amount: u64) -> Result<()> { - // burn 40% as per whitepaper tokenomics. - let burn_amount = (amount as u128 * 40 / 100) as u64; - - let cpi_accounts = Burn { - mint: ctx.accounts.ilink_mint.to_account_info(), - from: ctx.accounts.fee_vault.to_account_info(), - authority: ctx.accounts.fee_authority.to_account_info(), - }; - let cpi_program = ctx.accounts.token_program.to_account_info(); - let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts); - token::burn(cpi_ctx, burn_amount)?; - - emit!(TokenBurnedEvent { - amount: burn_amount - }); - - Ok(()) - } -} - -/// Real BN254 Groth16 pairing verification using Solana's alt_bn128 syscall. -/// -/// Proof format (256 bytes): -/// [0..64] A — G1 point (x: 32, y: 32) -/// [64..192] B — G2 point (x_c1: 32, x_c0: 32, y_c1: 32, y_c0: 32) -/// [192..256] C — G1 point (x: 32, y: 32) -/// -/// The pairing equation: e(A, B) · e(C, −G₂_gen) = 1 in Gₜ. -/// This is equivalent to: e(A, B) = e(C, G₂_gen) -/// which is exactly what the EVM 0x08 precompile checks. -fn verify_groth16_proof(proof: &[u8], _payload_hash: &[u8; 32]) -> bool { - if proof.len() != 256 { - return false; - } - - // pack 2 pairing pairs × 192 bytes each = 384 bytes. - // pair[0]: (A_G1, B_G2) — from the proof - // pair[1]: (C_G1, -G2_gen) — C from proof, negated generator hardcoded - let mut pairing_input = [0u8; 384]; - - // pair 0: A (G1, 64 bytes) + B (G2, 128 bytes) - pairing_input[0..64].copy_from_slice(&proof[0..64]); - pairing_input[64..192].copy_from_slice(&proof[64..192]); - - // pair 1: C (G1, 64 bytes) + negated G2 generator (128 bytes) - pairing_input[192..256].copy_from_slice(&proof[192..256]); - pairing_input[256..384].copy_from_slice(&NEG_G2_GEN); - - // invoke the Solana BPF alt_bn128_pairing syscall. - // returns Ok([0u8×31, 1u8]) when product in Gt = 1, i.e. proof is valid. - match alt_bn128_pairing(&pairing_input) { - Ok(result) => result[31] == 1 && result[..31].iter().all(|b| *b == 0), - Err(_) => false, - } -} - -#[derive(Accounts)] -pub struct Initialize<'info> { - #[account( - init, - payer = admin, - space = 8 + 32 + 2 + 8, - seeds = [b"state"], - bump - )] - pub state_registry: Account<'info, StateRegistry>, - #[account(mut)] - pub admin: Signer<'info>, - pub system_program: Program<'info, System>, -} - -#[derive(Accounts)] -pub struct SubmitProof<'info> { - #[account( - mut, - seeds = [b"state"], - bump - )] - pub state_registry: Account<'info, StateRegistry>, - #[account(mut)] - pub relayer: Signer<'info>, -} - -#[derive(Accounts)] -pub struct BuyBackBurn<'info> { - #[account(mut)] - pub ilink_mint: Account<'info, Mint>, - #[account(mut)] - pub fee_vault: Account<'info, TokenAccount>, - pub fee_authority: Signer<'info>, - pub token_program: Program<'info, Token>, -} - -#[account] -pub struct StateRegistry { - pub admin: Pubkey, - pub fee_rate_bps: u16, - pub processed_sequences: u64, -} - -#[event] -pub struct ProofVerifiedEvent { - pub sequence: u64, - pub payload_hash: [u8; 32], - pub relayer: Pubkey, -} - -#[event] -pub struct TokenBurnedEvent { - pub amount: u64, -} - -#[error_code] -pub enum HubError { - #[msg("This sequence has already been processed.")] - SequenceAlreadyProcessed, - #[msg("Invalid ZK proof: BN254 pairing check failed.")] - InvalidProof, - #[msg("Proof must be exactly 256 bytes.")] - InvalidProofLength, -} diff --git a/deny.toml b/deny.toml index 1d99863..71080eb 100644 --- a/deny.toml +++ b/deny.toml @@ -14,9 +14,6 @@ ignore = [ # tracing-subscriber 0.2.25 ANSI escape injection — transitive via ark-relations 0.4.0. # Cannot upgrade without full arkworks ecosystem bump. See .cargo/audit.toml for details. { id = "RUSTSEC-2025-0055", reason = "transitive via ark-relations; no upstream fix available" }, - # ring 0.16 AES panic + unmaintained — transitive via ethers-providers → jsonwebtoken 8. - { id = "RUSTSEC-2025-0009", reason = "transitive via ethers-providers 2.x; not in hot path" }, - { id = "RUSTSEC-2025-0010", reason = "transitive via ethers-providers 2.x; not in hot path" }, ] [licenses] diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0650e12..a316f4f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,7 +1,9 @@ # Interlink Architecture & Proof System ## Proof System Design: ZK vs Wormhole VAA Model -Wormhole relies on a Guardian consensus model where 19 authorities independently verify observations and issue VAAs (Verified Action Approvals). Interlink replaces this multi-signature trust vector with **Zero-Knowledge (ZK) Proofs**. Wait implicitly, Interlink compiles cross-chain observations into single deterministic PLONK/Halo2 SNARKs that validators sign over, binding exact intent deterministically. +Wormhole relies on a Guardian consensus model where 19 authorities independently verify observations and issue VAAs (Verified Action Approvals). Interlink aims to replace this multi-signature trust vector with **Zero-Knowledge (ZK) Proofs**: a Groth16 (BN254) proof per message, bound to a canonical payload hash that every gateway recomputes on-chain (see [SECURITY_REVIEW.md](SECURITY_REVIEW.md#canonical-message-format)). + +> The current circuit proves only the commitment relation, not source-chain consensus or receipt inclusion. Until it does, security depends on whoever holds the proving key. ## Why ZK is Better for Settlement Speed Instead of waiting for multiple block confirmations + multi-signature threshold synchronization across diverse global nodes, our ZK engine runs highly parallelized proving pipelines using our `ProofPerformance` optimizations. This brings settlement latency down to sub-15s windows, bypassing standard consensus stalls. diff --git a/docs/SECURITY_GUARANTEES.md b/docs/SECURITY_GUARANTEES.md index 1c0389d..c9b8547 100644 --- a/docs/SECURITY_GUARANTEES.md +++ b/docs/SECURITY_GUARANTEES.md @@ -1,15 +1,45 @@ # Security Guarantees & Incident Procedures -## Formal Proof of ZK Circuit Correctness -Interlink’s constraint satisfiability is heavily audited using constraints compiled via Trail of Bits / PSE frameworks. By executing `formal_verification.rs`, the state guarantees that it is impossible to forge a SNARK verification assuming collision-resistance holding across the BN254 implementation. - -## Validator Slashing Conditions -Validators are aggressively slashed natively inside `staking.rs` and `validator_rewards.rs`: -- 5% base slash for 15m downtime (heartbeat timeout). -- 50% Byzantine behavioral slash + permanent revocation on active state. - -## Disaster Recovery Procedures -In case of zero-day SNARK exploits or bridging deadlocks: -1. Circuit Breaker instantly suspends inbound relays upon >$5m instantaneous TVL deviation. -2. Guardian Pause mechanisms manually freeze outbound EVM lockboxes. -3. Rollover the `byzantine_bridge.rs` checkpoint state to the final uncompromised block root. +> **Status: experimental, unaudited, not for real value.** See +> [SECURITY_REVIEW.md](SECURITY_REVIEW.md) for the full findings list. + +## What the system guarantees today + +- **Message binding.** Every gateway and the hub recompute the canonical payload hash + on-chain (source/destination chain and gateway, nonce, action, sender, asset, amount, + recipient, extra data). A relayer cannot change where funds go after a proof is made. +- **Replay protection** per canonical message hash on EVM and Solana, and per + `(source_chain, nonce)` on Cosmos. +- **Custody limits (EVM).** Releases can never exceed what was locked for that asset, and + verified generic calls cannot target the gateway or any custodied asset. +- **Timelocked trust changes.** Replacing the verification key or a remote gateway takes + 2 days on EVM and Solana. Guardian transfer is two-step. Emergency withdrawal requires + the gateway to be paused. +- **Relayer stake** must be in the real ILINK mint and stays locked for 7 days after the + relayer's last attestation. + +## What it does NOT guarantee + +- **Source-chain validity.** The Groth16 circuit proves knowledge of a preimage of the + commitment, not that the message was emitted on the source chain. Anyone holding the + proving key can attest any message. This is the main open issue (SECURITY_REVIEW C1). +- **Trusted setup.** Keys come from a local single-party setup. No ceremony has been run. +- **Audits / formal verification.** None has been performed. `relayer/src/formal_verification.rs` + is a placeholder that returns `true`. +- **Slashing.** Slashing is an admin decision; there are no on-chain fraud proofs. Downtime + slashing (`staking.rs`, `validator_rewards.rs`) lives in off-chain simulation modules that + are not connected to the hub program. +- **Cosmos inbound.** Disabled: CosmWasm 1.5 has no BN254 pairing, so there is no verifier. + +## Incident procedures + +1. **Pause** the affected EVM gateway(s) and the Cosmos gateway (guardian `pause()`). + The Solana hub has no pause yet; stop the relayers. +2. **Disable routes** with `disableRemoteGateway(chainId)` (immediate on EVM). +3. **Recover funds** only while paused, via `emergencyWithdraw`, to a guardian-controlled + multisig. +4. **Rotate the verification key** with `proposeVerificationKey` / `apply…` (2-day delay + by design). Anyone monitoring `VerificationKeyProposed` events sees it coming. + +The circuit breaker and rate limiter in `relayer/src/circuitbreaker.rs` and `ratelimit.rs` +are library code; they are **not** wired into the relayer binary. diff --git a/docs/SECURITY_REVIEW.md b/docs/SECURITY_REVIEW.md new file mode 100644 index 0000000..ab08c87 --- /dev/null +++ b/docs/SECURITY_REVIEW.md @@ -0,0 +1,187 @@ +# InterLink Security Review + +Scope: `contracts/evm`, `contracts/solana/interlink-hub`, `contracts/solana/src` (legacy, now +removed), `contracts/cosmos`, `circuits`, `interlink-core`, `relayer`, `sdk`, and the docs. +Status reflects this branch. + +**Bottom line:** the contract-level bugs below are fixed and tested, but the bridge is still +**not safe to hold real value**. The ZK proof does not attest to anything that happened on +the source chain (finding C1). Until that changes, security reduces to "whoever holds the +proving key", which is weaker than a 1-of-1 multisig because nobody can see who that is. + +| Severity | Found | Fixed | Open | +|---|---|---|---| +| Critical | 7 | 6 | 1 (C1, architectural) | +| High | 13 | 13 | — (operational follow-ups noted) | +| Medium | 11 | 10 | 1 (M11, economic design) | +| Low / Info | 11 | 10 + 1 mitigated | — | + +--- + +## Critical + +### C1. The proof does not prove the message happened — OPEN (architectural) +`relayer/src/prover.rs` proves knowledge of `(m, s)` with `(m + rc)^5 + s = C`. Every verifier +computes `C` itself from the caller-supplied payload hash, so the witness is just +`m = hash mod r`. Anyone with the proving key can produce a valid proof for **any** payload +hash, including one for a deposit that never happened. Nothing checks block headers, +consensus signatures or receipt inclusion. + +Aggravating factors: +- The trusted setup was a fresh single-party `circuit_specific_setup` on every relayer start. + Whoever runs it could also keep the toxic waste. +- The Halo2 `circuits/` crate is not used by the relayer. Its "sync committee" circuit skips + BLS signature verification ("simplified model") and never constrains the quorum flag + against the threshold, so even wired in it would prove nothing about consensus. + +What a real fix needs: a circuit (or zkVM program, e.g. SP1 / RISC Zero) that verifies source +consensus (Ethereum sync-committee BLS signatures, or Tendermint validator signatures) and a +Merkle-Patricia inclusion proof of the `MessagePublished` receipt, exposing the canonical +payload hash as a public output. Use a multi-party ceremony or a universal setup. Until +then, an interim M-of-N attestation (independent signers checking the source chain) is more +honest than a proof that attests nothing. + +The canonical message format introduced below is designed to be that circuit's public +output, so the contracts won't need to change again. + +### C2. Hub minted any amount to anyone — FIXED +`verify_and_mint` took `amount`, `recipient_token_account` and `token_mint` from the caller. +The proof covered only `(payload_hash, sequence)`. Any staked relayer could take any valid +proof and mint `u64::MAX` of any hub-controlled mint to themselves. +**Fix:** the hub recomputes the canonical payload hash on-chain from the message fields +(`canonical_hash`). Execution is permissionless but bound to that hash: a `WrappedAsset` +registry fixes the mint per `(source chain, asset)`, the recipient token account must be +owned by the hashed recipient, and amounts are decimal-scaled from the hashed amount. + +### C3. Hub swap drained the pool — FIXED +Same flaw in `process_cross_chain_swap`: `amount_in`, `min_amount_out` and recipient were +unbound. **Fix:** swap parameters are bound through `extraHash`. Pool vaults are program PDAs. + +### C4. Cosmos accepted any 256 non-zero bytes as a proof — FIXED +`verify_proof` checked only length and "not all zeros". Recipient, amount and denom were +caller-supplied. The contract's own test `test_execute_verified_message` released funds with +`[0xAB; 256]`. **Fix:** inbound execution fails closed (`VerifierUnavailable`) until a real +verifier exists (CosmWasm 1.5 has no BN254 pairing host function). All inbound fields are +hashed on-chain for when one does. Regression test added. + +### C5. Relayer stake could be a worthless token — FIXED +`stake` accepted any mint as `ilink_mint`, so a relayer could mint 100,000 of a junk token and +pass `MIN_STAKE_AMOUNT`. **Fix:** the ILINK mint is pinned in `StateRegistry` at init, and +`stake`, `slash_relayer` and `buy_back_and_burn` all check it. + +### C6. Legacy Solana program had a forgeable verifier — FIXED (deleted) +`contracts/solana/src/lib.rs` checks `e(A,B)·e(C,−G2) = 1` with no verification key and no +public inputs. `A = C = s·G1, B = G2` passes for any `s`. `sequence > processed` also lets +anyone submit `u64::MAX` and brick it. It's still configured in `contracts/solana/Anchor.toml`. +**Fix:** `contracts/solana/src` and its Anchor/Cargo config were deleted. + +### C7. EVM gateway made arbitrary calls from the custody contract — FIXED +`executeVerifiedMessage` did `target.call(payload)` from the contract holding every locked +token. With C1, any proving-key holder could call `token.transfer(attacker, balance)`. This is +the pattern behind the 2021 Poly Network hack. +**Fix:** verified calls can't target the gateway or any asset it custodies. Releasing custody +goes through a dedicated `releaseTokens` path that can never exceed `totalLocked[token]`. + +--- + +## High (all fixed) + +| # | Issue | Fix | +|---|---|---| +| H1 | EVM proof not bound to source chain, destination chain or gateway: one proof replays on every deployment sharing a VK. | Canonical message binds both chain IDs and both gateways. | +| H2 | EVM public input was `keccak(...) mod r`, but the prover's circuit outputs `(h+rc)^5+seq`. Relayer proofs could never verify on EVM. | `commitment()` matches the prover. Verified by running the compiled gateway in revm with a real arkworks proof. | +| H3 | Nonce collisions: EVM `executedNonces[nonce]` and the hub's single `next_sequence` ignored the source chain. | Replay protection keyed by canonical payload hash. | +| H4 | Hub required strict global sequencing. One stuck message (e.g. slippage) halted the bridge forever, and any relayer could consume a mint's sequence by submitting it through `submit_proof`. | Independent per-message attestation records; the action type is part of the hash. | +| H5 | Hub `field_mul` used byte-wise shift-subtract (~3,900 32-byte passes per multiplication, measured). The three multiplications alone exceed Solana's 1.4M CU cap even at 4 CU/step, so `submit_proof` could not succeed on-chain. | 4×64-bit Montgomery arithmetic, checked against arkworks on 2,000 random inputs. | +| H6 | Relayer ran a new random setup on every start, and `export-vk` ran its own. The on-chain VK never matched the running relayer. | Persistent proving key (`PROVING_KEY_PATH`) shared by relayer and `export-vk`. | +| H7 | No reorg safety: `removed` logs ignored; finality was a block-depth count (10 blocks on L2s, about 2.5s on Arbitrum; real EVM chain IDs from `.env` fell through to the default); no canonical re-check. A reorged-out deposit would be minted. | Wait for the node's `finalized` tag, then re-fetch the receipt and check block hash, status, gateway and payload hash. Non-canonical events are never retried. | +| H8 | Unchecked admin power: immutable single-key EVM guardian, instant VK replacement (EVM and Solana), `emergencyWithdraw` at any time, single-step Cosmos guardian. | Two-step guardian transfer; 2-day timelock on VK and remote-gateway changes; emergency withdraw only while paused. Operationally, the guardian should still be a multisig. | +| H9 | No unbonding: stake, attest fraud and unstake in one transaction. | 7-day unbonding from last activity. Slashing is still admin discretion; there are no on-chain fraud proofs. | +| H10 | Hub `initialize` could be front-run (first caller becomes admin). | Restricted to the program upgrade authority. | +| H11 | EVM recipients were 20-byte `address`, but the destination is Solana (32 bytes). `sendCrossChainMessage` had no recipient at all. The relayer parsed the payload *length* as the amount. | `bytes32` recipients; a unified `MessagePublished` event carrying every canonical field. | +| H12 | Cosmos deposits summed amounts across denoms (1 ATOM + 1000 junk = "1001") with no denom recorded. | Exactly one coin per message; denom bound into the hash. | +| H13 | 18-decimal `uint256` amounts vs SPL `u64`: anything over 18.4 ETH was unrepresentable, with no scaling. | `decimals_shift` per wrapped asset; EVM caps amounts at `uint128`. | + +--- + +## Medium + +| # | Issue | Status | +|---|---|---| +| M1 | USDT-style tokens reverted; fee-on-transfer tokens recorded the requested amount (insolvency); ETH sent with an ERC-20 deposit was trapped. | FIXED: safe transfer helpers, balance-delta accounting, `msg.value == 0` for tokens. | +| M2 | No destination allowlist: deposits to unsupported chains were locked forever. | FIXED: the remote-gateway allowlist means deposits can only target configured chains. | +| M3 | Anyone could squat the canonical pool PDA with vaults they controlled. `fee_rate_bps > 10000` made every swap panic. | FIXED: admin-only pools, PDA vaults, fee cap. | +| M4 | `buy_back_and_burn` was callable by anyone, so `total_burned` could be inflated. | FIXED: admin only. | +| M5 | `reduce_mod_scalar_field` stopped after 4 subtractions; ~5.5% of hashes need 5. | FIXED. | +| M6 | Pipeline: the prover permit was held through the ~13 min finality wait (8 CPUs = 8 events per 13 min); events were dropped after one quick retry; dedup marked events before success; no backfill after WS reconnect; the reconnect counter never reset; "settlement complete" was logged before confirmation. | FIXED. | +| M7 | Relayer only called `submit_proof`, so no transfer ever completed end to end. | FIXED: the relayer attests and then calls `execute_transfer` / `execute_swap`; the hub creates the recipient's token account if needed. | +| M8 | Unauthenticated `POST /webhooks/register` and `DELETE /webhooks/{id}` on the HTTP API, bound to `0.0.0.0` in Docker (SSRF/abuse). | FIXED: all `/webhooks*` routes need `Authorization: Bearer $API_TOKEN` (disabled when unset, constant-time compare); default bind is localhost outside Docker. | +| M9 | `ring` 0.16 (RUSTSEC-2025-0009/0010) in the tree via `ethers-providers → jsonwebtoken`; the `Cargo.toml` "override" didn't apply to transitive deps. | FIXED: ethers-rs removed from the runtime (HTTP-polling listener and small keccak/hex/RPC helpers instead) and the stale `interlink-core` relayer deleted. `ring` is no longer in the dependency tree, and the advisory ignores were removed. | +| M10 | No return path: no burn-for-withdrawal on Solana; NFTs lock with no destination support; failed swaps have no refund. | FIXED: hub `burn_for_withdrawal` + relayer hub→EVM delivery (`releaseTokens`); `claim_swap_as_wrapped` lets a swap recipient settle in wrapped tokens; `lockNFT` is off by default (`nftBridgingEnabled`) until a destination supports NFTs. | +| M11 | Pool `reserve_a` is virtual (input tokens stay on the source chain) and swap fees vanish. | OPEN (economic design). | + +## Low / Info + +| # | Issue | Status | +|---|---|---| +| L1 | Docs overstate security: claimed Trail of Bits/PSE audit, but `formal_verification.rs` returns `true` with a placeholder IPFS link; the circuit breaker and rate limiter aren't wired into the relayer; README says "trustless math", "<30s settlement", Halo2. | `SECURITY_GUARANTEES.md` rewritten; README status notice added. | +| L2 | ~45 of 56 relayer modules are standalone stubs the binary never uses (e.g. `check_wormhole_parity() -> true`). | FIXED: `lib.rs` and the README separate production from experimental modules; the fake functions and the `formal_verification` mock were removed. | +| L3 | `forge build` failed: `DeployAndInit.s.sol` called a non-existent `setVerificationKey(bytes)` and had a non-ASCII string; `DeployL2Verify` called non-existent `guardian()` / `nonce()`. | FIXED. | +| L4 | Files `.gitignore` excludes are still tracked: `.claude/settings.local.json`, and `interlink-hub/out.log` (stale log from another project path, leaks a local username). | FIXED: untracked (kept locally). | +| L5 | Hand-rolled SHA-256 in the Cosmos contract. | FIXED (`sha2`). | +| L6 | `@coral-xyz/anchor ^0.32.1` (TS) vs `anchor-lang 0.30.1` (Rust). | FIXED: aligned to `^0.30.1`, lockfile regenerated. | +| L7 | EVM verifier didn't range-check proof coordinates. | FIXED. | +| L8 | Chain IDs mix custom values (`2` = Solana) with EIP-155 (`2` is also Expanse). | Mitigated: gateway addresses are hashed too. | +| L9 | `interlink-core` held a second, older relayer producing Halo2 proofs the Groth16 verifiers can't check, which silently proved the nonce instead of the hash for ~81% of hashes. | FIXED: removed; `interlink-core` is now just the Poseidon chip and types. | +| L10 | The TS SDK's `transfer()` / `swap()` / `getTransferStatus()` called relayer endpoints that didn't exist; a relayer can't sign a user's deposit. | FIXED: the SDK builds and sends gateway transactions with the user's signer (incl. ERC-20 approval), tracks by payload hash, and the relayer serves `GET /transfers/{payloadHash}` from the hub's on-chain record. | +| L11 | The SDK used chain ID `900` for Solana; the contracts and relayer use `2`, so SDK-built transfers would fail with "unsupported destination". | FIXED: `ChainId.Solana = 2`. | + +--- + +## Canonical message format + +All gateways and the relayer hash outbound messages identically: + +``` +payloadHash = keccak256(abi.encode( + keccak256("InterlinkMessage(v1)"), + uint64 srcChain, bytes32 srcGateway, + uint64 dstChain, bytes32 dstGateway, + uint64 nonce, uint8 action, // 1 transfer, 2 swap, 3 NFT, 4 call + bytes32 sender, bytes32 asset, + uint256 amount, bytes32 recipient, // amount ≤ 2^128−1 + bytes32 extraHash)) // keccak(payload) / swap params / tokenId + +commitment = ((payloadHash mod r) + keccak256("interlink_v1_domain") mod r)^5 + nonce (mod r) +``` + +The same vector (`0x07b0f8b8…09bd`) is pinned in the Foundry tests, the relayer unit tests, +and was checked against the compiled gateway in an EVM. + +## Verification performed + +- **Cosmos:** 14 unit tests pass, including regression tests for C4, H12 and per-chain replay. +- **Solana hub:** 9 host tests pass, including a real arkworks Groth16 proof verified through + the on-chain verifier (and rejected after tampering), Montgomery arithmetic vs arkworks, and + canonical-hash binding of every field. Not run: an SBF build and the Anchor TS tests (the + local toolchain couldn't install platform-tools); on-chain CU usage is unmeasured. +- **EVM:** compiled with solc 0.8.28 (`via_ir`; contract, tests, all scripts). The compiled + gateway was executed in revm: a real proof releases funds; tampered amounts, corrupted + proofs, replays, gateway-targeted calls and live emergency withdrawals are rejected; + ~334k gas per release. Not run: `forge test` (Foundry not installed). +- **Relayer / workspace:** `cargo test --workspace` passes natively (relayer lib 445, + integration 19, security 32, checklist 9, circuits 10, interlink-core 5); clippy + (`-D warnings`) and `cargo fmt --check` are clean. Highlights: canonical-hash vector shared + with EVM, PDA/ATA derivation matching `solana find-program-derived-address`, EIP-1559 + signing byte-identical to ethers-core, hub-event parsing that rejects lookalike logs from + other programs, webhook auth, and proving-key persistence. +- **Relayer → EVM end to end:** the relayer's own `ProverEngine` proof and + `release_calldata` released funds through the compiled gateway in revm; replay and + tampered amounts reverted. +- **SDK:** 31 Jest tests pass (gateway calldata built, sent through a mock signer and + decoded back with a payload-hash check; shared hash vector); `tsc` clean; coverage + above thresholds. +- **Not run here:** `forge test`, the Anchor TS tests, an SBF build / on-chain CU + measurement, and the relayer against live chains. +- **Local build note:** with `ring` gone, the workspace now builds natively on ARM64 Windows + (previously it needed clang plus the x86_64 target). diff --git a/interlink-core/Cargo.toml b/interlink-core/Cargo.toml index 71fae83..3929e4e 100644 --- a/interlink-core/Cargo.toml +++ b/interlink-core/Cargo.toml @@ -1,38 +1,17 @@ -[package] -name = "interlink-core" -version = "0.3.0" -edition = "2021" - -[dependencies] -# ZK Proving -halo2_proofs = { version = "0.3.0", features = ["multicore"] } -halo2curves = "0.1.0" -ff = "0.13" - -# Async Runtime -tokio = { version = "1.32", features = ["full", "tracing"] } -futures = "0.3" - -# Networking & serialization -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -tracing = "0.1" -tracing-subscriber = "0.3" - -# Crypto & PRNG -rand = "0.8" -rand_core = { version = "0.6", features = ["getrandom"] } - -# Blockchain SDKs -ethers-core = { version = "2.0.14" } -ethers-providers = { version = "2.0.14", features = ["ws"] } -ethers-contract = { version = "2.0.14" } -reqwest = { version = "0.11", features = ["json"] } -base64 = "0.21" -ed25519-dalek = "2.0" -bs58 = "0.5" -sha2 = "0.10" - -[dev-dependencies] -# For testing circuits -halo2_proofs = { version = "0.3.0", features = ["dev-graph", "multicore"] } +[package] +name = "interlink-core" +version = "0.4.0" +edition = "2021" + +[dependencies] +# ZK Proving +halo2_proofs = { version = "0.3.0", features = ["multicore"] } +halo2curves = "0.1.0" +ff = "0.13" + +serde = { version = "1.0", features = ["derive"] } +sha3 = "0.10" + +[dev-dependencies] +# For testing circuits +halo2_proofs = { version = "0.3.0", features = ["dev-graph", "multicore"] } diff --git a/interlink-core/src/circuit.rs b/interlink-core/src/circuit.rs index e9026d2..b738031 100644 --- a/interlink-core/src/circuit.rs +++ b/interlink-core/src/circuit.rs @@ -135,7 +135,7 @@ impl Circuit for InterlinkCircuit { ) -> Result<(), Error> { let chip = PoseidonChip::::construct(config); - let hash = ethers_core::utils::keccak256(b"interlink_v1_domain"); + let hash = crate::keccak256(b"interlink_v1_domain"); let mut arr = [0u8; 8]; arr.copy_from_slice(&hash[0..8]); let rc_val = u64::from_be_bytes(arr); @@ -173,7 +173,7 @@ mod tests { let k = 5; let msg = Fr::from(12345); let seq = Fr::from(1); - let hash = ethers_core::utils::keccak256(b"interlink_v1_domain"); + let hash = crate::keccak256(b"interlink_v1_domain"); let mut arr = [0u8; 8]; arr.copy_from_slice(&hash[0..8]); let rc_val = u64::from_be_bytes(arr); @@ -242,7 +242,7 @@ impl Circuit for BatchedInterlinkCirc ) -> Result<(), Error> { let chip = PoseidonChip::::construct(config); - let hash = ethers_core::utils::keccak256(b"interlink_v1_domain"); + let hash = crate::keccak256(b"interlink_v1_domain"); let mut arr = [0u8; 8]; arr.copy_from_slice(&hash[0..8]); let rc_val = u64::from_be_bytes(arr); @@ -283,7 +283,7 @@ mod batched_tests { const BATCH_SIZE: usize = 3; let k = 6; - let hash = ethers_core::utils::keccak256(b"interlink_v1_domain"); + let hash = crate::keccak256(b"interlink_v1_domain"); let mut arr = [0u8; 8]; arr.copy_from_slice(&hash[0..8]); let rc = Fr::from(u64::from_be_bytes(arr)); diff --git a/interlink-core/src/lib.rs b/interlink-core/src/lib.rs index c487236..09224d5 100644 --- a/interlink-core/src/lib.rs +++ b/interlink-core/src/lib.rs @@ -1,8 +1,19 @@ +//! Halo2 circuit primitives (Poseidon chip) and payload types. +//! +//! The production relayer lives in the `relayer` crate and uses Groth16. +//! An older standalone relayer that used to live here was removed: it produced +//! Halo2 proofs the on-chain Groth16 verifiers cannot check, and pulled in +//! ethers-providers (and through it the vulnerable ring 0.16). + pub mod circuit; -pub mod network; -pub mod relayer; pub mod types; +/// keccak256, used to derive the circuit round constant. +pub fn keccak256(data: &[u8]) -> [u8; 32] { + use sha3::{Digest, Keccak256}; + Keccak256::digest(data).into() +} + /// Core errors for protocol execution and network failure #[derive(Debug)] pub enum InterlinkError { diff --git a/interlink-core/src/main.rs b/interlink-core/src/main.rs deleted file mode 100644 index 2ee21fc..0000000 --- a/interlink-core/src/main.rs +++ /dev/null @@ -1,23 +0,0 @@ -use interlink_core::relayer::{Relayer, RelayerConfig}; - -#[tokio::main] -async fn main() { - // Setup tracing for logging and observability - tracing_subscriber::fmt::init(); - - let config = RelayerConfig { - chain_id: 1, // Defaulting to Ethereum Mainnet ID - rpc_url: "ws://localhost:8545".to_string(), - hub_url: "https://api.devnet.solana.com".to_string(), - gateway_address: "0x0000000000000000000000000000000000000000".to_string(), - solana_program_id: "Hub1111111111111111111111111111111111111111".to_string(), - keypair_path: "~/.config/solana/id.json".to_string(), - }; - - let relayer = Relayer::new(config); - - println!("--- InterLink Relayer Node ---"); - if let Err(e) = relayer.run().await { - eprintln!("Relayer crashed with error: {:?}", e); - } -} diff --git a/interlink-core/src/network.rs b/interlink-core/src/network.rs deleted file mode 100644 index 069e3d3..0000000 --- a/interlink-core/src/network.rs +++ /dev/null @@ -1,52 +0,0 @@ -use crate::Result; -use ethers_providers::{Http, Middleware, Provider}; -use std::convert::TryFrom; -use tracing::info; - -/// simple client for talking to the chains. -pub struct NetworkClient { - pub url: String, -} - -impl NetworkClient { - /// spin up a new network client. - pub fn new(url: String) -> Self { - Self { url } - } - - /// try to connect and make sure the node is actually alive. - pub async fn connect(&self) -> Result<()> { - info!("Connecting to network at {}...", self.url); - - let provider = Provider::::try_from(&self.url) - .map_err(|e| crate::InterlinkError::NetworkError(e.to_string()))?; - - let chain_id = provider - .get_chainid() - .await - .map_err(|e| crate::InterlinkError::NetworkError(e.to_string()))?; - - info!("Connected successfully to Chain ID: {}", chain_id); - Ok(()) - } - - /// grab a block by height. might be slow depending on the node. - pub async fn get_block(&self, height: u64) -> Result> { - info!("Fetching block at height {}...", height); - - let provider = Provider::::try_from(&self.url) - .map_err(|e| crate::InterlinkError::NetworkError(e.to_string()))?; - - let block = provider - .get_block(height) - .await - .map_err(|e| crate::InterlinkError::NetworkError(e.to_string()))? - .ok_or_else(|| crate::InterlinkError::NetworkError("Block not found".to_string()))?; - - // serialize the block so we can shove it into the circuit witness. - let serialized = serde_json::to_vec(&block) - .map_err(|_| crate::InterlinkError::NetworkError("Serialization failed".to_string()))?; - - Ok(serialized) - } -} diff --git a/interlink-core/src/relayer.rs b/interlink-core/src/relayer.rs deleted file mode 100644 index e5997c0..0000000 --- a/interlink-core/src/relayer.rs +++ /dev/null @@ -1,374 +0,0 @@ -use crate::Result; -use ethers_contract::abigen; -use ethers_core::types::Address; -use ethers_providers::{Provider, StreamExt, Ws}; -use serde_json::json; -use std::str::FromStr; -use std::sync::Arc; -use tokio::sync::mpsc; -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_real_snark_generation() { - let nonce = 1u64; - let payload_hash = [1u8; 32]; - let chain_id = 1u64; - - println!("\n[TEST] Testing Real ZK-SNARK generation (BN254/Multicore)..."); - let proof = Relayer::generate_proof_sync(nonce, payload_hash, chain_id) - .expect("Proof generation failed"); - - println!("[TEST] SNARK Generated. Length: {} bytes", proof.len()); - assert!(!proof.is_empty(), "Proof should not be empty"); - assert!( - proof.len() > 100, - "Proof size indicates incorrect generation" - ); - } -} - -abigen!( - InterlinkGateway, - r#"[ - event MessagePublished(uint64 indexed nonce, uint64 destinationChain, address sender, bytes32 payloadHash, bytes payload) - ]"# -); - -pub struct RelayerConfig { - pub chain_id: u64, - pub rpc_url: String, // WebSocket URL for EVM chains - pub hub_url: String, // Solana Hub RPC URL - pub gateway_address: String, - pub solana_program_id: String, - pub keypair_path: String, -} - -pub struct Relayer { - config: Arc, -} - -impl Relayer { - pub fn new(config: RelayerConfig) -> Self { - Self { - config: Arc::new(config), - } - } - - /// Starts the main event loop and proof generation pipeline. - pub async fn run(&self) -> Result<()> { - println!( - "Initializing High-Performance Relayer for chain ID {} [WS: {}] [Hub: {}]", - self.config.chain_id, self.config.rpc_url, self.config.hub_url - ); - - let (tx, mut rx) = mpsc::channel(1024); - let ws_url = self.config.rpc_url.clone(); - let gateway_address = self.config.gateway_address.clone(); - - // Step 1: Initialize event watcher on the target chain. - tokio::spawn(async move { - if let Err(e) = Self::watch_events(&ws_url, &gateway_address, tx).await { - eprintln!("[WS ERROR] Watcher failed: {}", e); - } - }); - - // Step 2: Main processing loop for generating proofs and submitting. - while let Some((nonce, payload_hash)) = rx.recv().await { - println!( - "\n[EVENT] Raw payload received from Source Chain #{} | Nonce: {}", - self.config.chain_id, nonce - ); - let relayer_ref = Arc::clone(&self.config); - - // Offload ZK proof generation to a blocking task to prevent blocking the async runtime. - let proof_task = tokio::task::spawn_blocking(move || { - Self::generate_proof_sync(nonce, payload_hash, relayer_ref.chain_id) - }); - - match proof_task.await { - Ok(Ok(proof)) => { - let rpc_url = self.config.hub_url.clone(); - let program_id = self.config.solana_program_id.clone(); - - if let Err(e) = Self::submit_to_hub( - rpc_url, - program_id, - nonce, - payload_hash, - proof, - self.config.keypair_path.clone(), - ) - .await - { - eprintln!("[ERROR] Solana Hub Submission Failed: {:?}", e); - } - } - _ => eprintln!("[ERROR] ZK Proof Generation Failed."), - } - } - - Ok(()) - } - - async fn watch_events( - ws_url: &str, - contract_addr: &str, - tx: mpsc::Sender<(u64, [u8; 32])>, - ) -> std::result::Result<(), Box> { - let mut retry_backoff = 1; - - loop { - println!( - "[WS] Attempting to connect to WebSocket listener at {}...", - ws_url - ); - - // use ethers to catch evm events. the real deal. - match Provider::::connect(ws_url).await { - Ok(provider) => { - println!("[WS ERROR] Successfully connected to {}.", ws_url); - retry_backoff = 1; // reset backoff on success - - let client = Arc::new(provider); - let address = Address::from_str(contract_addr).map_err(|e| e.to_string())?; - let contract = InterlinkGateway::new(address, client); - - println!("[WS] Subscribing to MessagePublished events..."); - - // strictly wss for the live environment. no polls allowed. - match contract.events().subscribe().await { - Ok(mut block_stream) => { - println!("[WS] Successfully subscribed. Monitoring chain for finalized messages..."); - - while let Some(log) = block_stream.next().await { - if let Ok(event) = log { - println!( - "[WS] Captured Event: DestChain: {} | Sender: {:?} | Nonce: {}", - event.destination_chain, event.sender, event.nonce - ); - - if tx.send((event.nonce, event.payload_hash)).await.is_err() { - return Ok(()); // channel closed, exit watcher. - } - } - } - println!("[WS WARNING] Stream closed. Attempting reconnect..."); - } - Err(e) => { - eprintln!("[WS ERROR] Failed to subscribe: {}", e); - } - } - } - Err(e) => { - eprintln!( - "[WS ERROR] Watcher failed to connect: {}. Retrying in {}s...", - e, retry_backoff - ); - } - } - - tokio::time::sleep(tokio::time::Duration::from_secs(retry_backoff)).await; - retry_backoff = std::cmp::min(30, retry_backoff * 2); // exponential backoff up to 30s. - } - } - - fn generate_proof_sync(nonce: u64, hash: [u8; 32], _chain_id: u64) -> Result> { - use crate::circuit::InterlinkCircuit; - use ff::PrimeField; - use halo2_proofs::{ - plonk::{create_proof, keygen_pk, keygen_vk}, - poly::commitment::Params, - transcript::{Blake2bWrite, Challenge255}, - }; - use halo2curves::bn256::{Fr, G1Affine}; - use rand_core::OsRng; - - println!("[PROVER] Generating Real ZK-SNARK for message #{}", nonce); - - // Stage 1: Setup proving parameters - let k = 6; - let params = Params::::new(k); - - // Stage 2: Initialize circuit with payloads - let payload_f = Fr::from_repr(hash).unwrap_or(Fr::from(nonce)); - let circuit = InterlinkCircuit { - message_payload: Some(payload_f), - sequence_number: Some(Fr::from(nonce)), - }; - - // Stage 3: Generate proving and verifying keys - let vk = keygen_vk(¶ms, &circuit) - .map_err(|_| crate::InterlinkError::ProofGenerationFailed)?; - let pk = keygen_pk(¶ms, vk, &circuit) - .map_err(|_| crate::InterlinkError::ProofGenerationFailed)?; - - // Stage 4: Produce public instance parameters - let salt_hash = ethers_core::utils::keccak256(b"interlink_v1_domain"); - let mut arr = [0u8; 8]; - arr.copy_from_slice(&salt_hash[0..8]); - let rc = Fr::from(u64::from_be_bytes(arr)); - - let diff = payload_f + rc; - let sq = diff.square(); - let commitment = sq * sq * diff + Fr::from(nonce); - - // nesting is a bit of a nightmare: &[&[&[scalar]]] - // 1 circuit, 1 column, 1 value. easy. - let instances: &[&[Fr]] = &[&[commitment]]; - let instances_ref: &[&[&[Fr]]] = &[instances]; - - // Stage 5: Create final proof - let mut transcript = Blake2bWrite::<_, G1Affine, Challenge255<_>>::init(vec![]); - create_proof::( - ¶ms, - &pk, - &[circuit], - instances_ref, - OsRng, - &mut transcript, - ) - .map_err(|_| crate::InterlinkError::ProofGenerationFailed)?; - - let proof = transcript.finalize(); - println!("[PROVER] Proof successful. Size: {} bytes", proof.len()); - - Ok(proof) - } - - async fn submit_to_hub( - rpc_url: String, - program_id_str: String, - sequence: u64, - payload_hash: [u8; 32], - proof: Vec, - keypair_path: String, - ) -> crate::Result<()> { - use base64::{engine::general_purpose, Engine as _}; - use ed25519_dalek::{Signer, SigningKey}; - use reqwest::Client; - use sha2::{Digest, Sha256}; - - println!( - "[SUBMITTER] Dispatching real Signed Transaction to Solana Hub at {}...", - rpc_url - ); - - // 1. Fetch real blockhash - let client = Client::new(); - let payload_json = json!({ - "jsonrpc": "2.0", "id": 1, "method": "getLatestBlockhash", "params": [{"commitment": "confirmed"}] - }); - let res = client - .post(&rpc_url) - .json(&payload_json) - .send() - .await - .map_err(|e| crate::InterlinkError::NetworkError(e.to_string()))?; - let result_json: serde_json::Value = res.json().await.unwrap_or_default(); - let blockhash_b64 = result_json["result"]["value"]["blockhash"] - .as_str() - .ok_or_else(|| { - crate::InterlinkError::NetworkError("Failed to fetch blockhash".to_string()) - })?; - let blockhash = general_purpose::STANDARD - .decode(blockhash_b64) - .map_err(|e| crate::InterlinkError::NetworkError(e.to_string()))?; - - // 2. Load keypair and identities - let data = - std::fs::read(keypair_path.replace('~', &std::env::var("HOME").unwrap_or_default())) - .map_err(|_| { - crate::InterlinkError::NetworkError("Failed to load keypair".to_string()) - })?; - let key_bytes: Vec = serde_json::from_slice(&data) - .map_err(|_| crate::InterlinkError::NetworkError("Invalid key format".to_string()))?; - let signing_key = SigningKey::from_bytes(&key_bytes[..32].try_into().unwrap()); - let public_key = signing_key.verifying_key().to_bytes(); - let program_id_raw = bs58::decode(program_id_str) - .into_vec() - .map_err(|_| crate::InterlinkError::NetworkError("Invalid program id".to_string()))?; - - // 3. Derive PDA: [b"state"] - // Robust PDA derivation for the relayer. - // Solana PDAs MUST be off the Ed25519 curve. Relying on a blind `255` is fragile. - // We iterate downwards until we find a hash that correctly parses as an invalid curve point. - let mut registry_pda = [0u8; 32]; - for bump in (0..=255).rev() { - let mut hasher = Sha256::new(); - hasher.update(b"state"); - hasher.update([bump]); - hasher.update(&program_id_raw); - hasher.update(b"ProgramDerivedAddress"); - let result = hasher.finalize(); - - registry_pda.copy_from_slice(&result); - // If `VerifyingKey::from_bytes` errors, it's NOT a valid Edwards curve point, - // which means it is a valid PDA off-curve! - if ed25519_dalek::VerifyingKey::from_bytes(®istry_pda).is_err() { - break; - } - } - - // 4. Build Instruction Data (Anchor format) - let mut ix_data = Vec::with_capacity(8 + 8 + 8 + 4 + proof.len() + 32 + 32); - ix_data.extend_from_slice(&[0x1d, 0x11, 0x18, 0x17, 0x11, 0x1a, 0x1c, 0x12]); // sighash: 'submit_proof' - ix_data.extend_from_slice(&1u64.to_le_bytes()); // source id - ix_data.extend_from_slice(&sequence.to_le_bytes()); - ix_data.extend_from_slice(&(proof.len() as u32).to_le_bytes()); - ix_data.extend_from_slice(&proof); - ix_data.extend_from_slice(&payload_hash); - // commitment re-calc - use ff::PrimeField; - use halo2curves::bn256::Fr; - let payload_f = Fr::from_repr(payload_hash).unwrap_or(Fr::from(sequence)); - let salt_hash = ethers_core::utils::keccak256(b"interlink_v1_domain"); - let mut arr = [0u8; 8]; - arr.copy_from_slice(&salt_hash[0..8]); - let rc = Fr::from(u64::from_be_bytes(arr)); - - let commitment_f = (payload_f + rc).square() * (payload_f + rc) + Fr::from(sequence); - ix_data.extend_from_slice(&commitment_f.to_repr()); - - // 5. Build Transaction Message - let mut msg = vec![1, 0, 1, 3]; // num_sigs, num_readonly_signed, num_readonly_unsigned, num_keys - msg.extend_from_slice(&public_key); - msg.extend_from_slice(®istry_pda); - msg.extend_from_slice(&program_id_raw); - - msg.extend_from_slice(&blockhash); - msg.push(1); // 1 instruction - msg.push(2); // program_id index - msg.push(2); // num accounts in instruction - msg.push(1); // registry_pda (writable) - msg.push(0); // relayer (writable, signer) - - msg.extend_from_slice(&(ix_data.len() as u16).to_le_bytes()); - msg.extend_from_slice(&ix_data); - - // 6. Sign and Broadcast - let signature = signing_key.sign(&msg); - let mut tx = vec![1]; // num signatures - tx.extend_from_slice(&signature.to_bytes()); - tx.extend_from_slice(&msg); - - let final_json = json!({ - "jsonrpc": "2.0", "id": 1, "method": "sendTransaction", "params": [general_purpose::STANDARD.encode(&tx), {"encoding": "base64"}] - }); - - match client.post(&rpc_url).json(&final_json).send().await { - Ok(r) => { - let text = r.text().await.unwrap_or_default(); - if text.contains("error") { - eprintln!("[SUBMITTER] HUB REJECTED: {}", text); - } else { - println!("[SUBMITTER] HUB CONFIRMED: Tx Sig {}...", signature); - } - } - Err(e) => eprintln!("[ERROR] RPC Failed: {}", e), - } - - Ok(()) - } -} diff --git a/relayer/Cargo.toml b/relayer/Cargo.toml index b49f908..4091a13 100644 --- a/relayer/Cargo.toml +++ b/relayer/Cargo.toml @@ -1,48 +1,50 @@ -[package] -name = "relayer" -version = "0.1.0" -edition = "2021" - -[[bin]] -name = "relayer" -path = "src/main.rs" - -[[bin]] -name = "export-vk" -path = "src/bin/export_vk.rs" - -[[bin]] -name = "benchmark" -path = "src/bin/benchmark.rs" - -[[bin]] -name = "load-test" -path = "src/bin/load_test.rs" - -[dependencies] -futures = "0.3" -interlink-core = { path = "../interlink-core" } -tokio = { version = "1.0", features = ["full"] } -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -reqwest = { version = "0.11", features = ["json"] } -ethers-core = "2.0.14" -ethers-providers = { version = "2.0.14", features = ["ws"] } -sha2 = "0.10" -sha3 = "0.10" -ed25519-dalek = "2.0" -bs58 = "0.5" -base64 = "0.21" -rand = "0.8" - -# Groth16 proving (BN254) -ark-bn254 = "0.4" -ark-groth16 = "0.4" -ark-relations = "0.4" -ark-ff = "0.4" -ark-ec = "0.4" -ark-serialize = "0.4" -ark-std = "0.4" -ark-snark = "0.4" +[package] +name = "relayer" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "relayer" +path = "src/main.rs" + +[[bin]] +name = "export-vk" +path = "src/bin/export_vk.rs" + +[[bin]] +name = "benchmark" +path = "src/bin/benchmark.rs" + +[[bin]] +name = "load-test" +path = "src/bin/load_test.rs" + +[dependencies] +futures = "0.3" +tokio = { version = "1.0", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +reqwest = { version = "0.11", features = ["json"] } +sha2 = "0.10" +sha3 = "0.10" +k256 = { version = "0.13", features = ["ecdsa"] } +ed25519-dalek = "2.0" +bs58 = "0.5" +base64 = "0.21" +rand = "0.8" + +# Groth16 proving (BN254) +ark-bn254 = "0.4" +ark-groth16 = "0.4" +ark-relations = "0.4" +ark-ff = "0.4" +ark-ec = "0.4" +ark-serialize = "0.4" +ark-std = "0.4" +ark-snark = "0.4" + +[dev-dependencies] +# Cross-checks EIP-1559 encoding/signing in tests only (no ring: providers are not used). +ethers-core = "2.0.14" diff --git a/relayer/src/bin/export_vk.rs b/relayer/src/bin/export_vk.rs index 4eaaada..e9fea8e 100644 --- a/relayer/src/bin/export_vk.rs +++ b/relayer/src/bin/export_vk.rs @@ -1,38 +1,47 @@ -//! Export the Groth16 verification key as hex for on-chain deployment. -//! -//! Usage: -//! cargo run --bin export-vk -//! -//! Outputs: -//! - VK hex string (for EVM setVerificationKey / VK_HEX forge env var) -//! - VK as JSON array of bytes (for Solana set_verification_key) -//! -//! The VK is 576 bytes and is deterministic for a given circuit. -//! In production, replace with keys from a trusted setup ceremony. - -use relayer::prover::ProverEngine; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let engine = ProverEngine::new(0); - eprintln!("Running Groth16 trusted setup (this takes a few seconds)..."); - engine.initialize().await?; - - let vk_bytes = engine.export_vk().await?; - assert_eq!(vk_bytes.len(), 576, "VK must be 576 bytes"); - - // Hex (for EVM + forge script VK_HEX env var) - let hex: String = vk_bytes.iter().map(|b| format!("{:02x}", b)).collect(); - println!("VK_HEX=0x{}", hex); - - // JSON byte array (for Solana anchor script) - let json: Vec = vk_bytes.clone(); - let json_str = serde_json::to_string(&json)?; - println!("VK_JSON={}", json_str); - - eprintln!("\nDone. VK is {} bytes.", vk_bytes.len()); - eprintln!("Use VK_HEX with `forge script script/DeployAndInit.s.sol`"); - eprintln!("Use VK_JSON with the Solana `set_verification_key` instruction"); - - Ok(()) -} +//! Export the Groth16 verification key for on-chain deployment. +//! +//! Usage: +//! PROVING_KEY_PATH=keys/interlink_pk.bin cargo run --bin export-vk +//! +//! Reads the proving key the relayer uses (creating it on first run), so the +//! uploaded VK always matches the relayer's proofs. Previously this binary ran +//! its own random setup, so its VK never matched a running relayer. +//! +//! Outputs: +//! - VK hex (for EVM `setVerificationKey(bytes)` / VK_HEX forge env var) +//! - VK as JSON byte array (for Solana `set_verification_key`) +//! +//! WARNING: a locally created key is a single-party trusted setup. Use keys +//! from a multi-party ceremony for anything holding real value. + +use relayer::prover::ProverEngine; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let path = std::env::var("PROVING_KEY_PATH").unwrap_or_else(|_| "keys/interlink_pk.bin".into()); + let path = std::path::Path::new(&path); + + let engine = ProverEngine::new(0); + if !path.exists() { + eprintln!( + "No proving key at {}; running a LOCAL single-party setup (dev only)...", + path.display() + ); + } + engine.load_or_create(path, true).await?; + + let vk_bytes = engine.export_vk().await?; + assert_eq!(vk_bytes.len(), 576, "VK must be 576 bytes"); + + let hex: String = vk_bytes.iter().map(|b| format!("{:02x}", b)).collect(); + println!("VK_HEX=0x{}", hex); + println!("VK_JSON={}", serde_json::to_string(&vk_bytes)?); + + eprintln!( + "\nProving key: {} (keep it with the relayer; never commit it)", + path.display() + ); + eprintln!("Use VK_HEX with `forge script script/DeployAndInit.s.sol`"); + eprintln!("Use VK_JSON with the Solana `set_verification_key` instruction"); + Ok(()) +} diff --git a/relayer/src/byzantine_bridge.rs b/relayer/src/byzantine_bridge.rs index abb45ad..f6f5907 100644 --- a/relayer/src/byzantine_bridge.rs +++ b/relayer/src/byzantine_bridge.rs @@ -1,47 +1,25 @@ -//! Fault-tolerant byzantine bridge & privacy extensions +//! Byzantine fault-tolerance helpers. +//! +//! Earlier versions also contained `check_wormhole_parity()` and a +//! "privacy" encoder that returned constants; they were removed because they +//! implied guarantees the code did not provide. pub struct ByzantineBridge; impl ByzantineBridge { - /// Formal proof of safety under f < n/3 faults + /// A BFT quorum tolerates `faults` Byzantine members iff `3·faults < validators`. pub fn verify_byzantine_safety(validators: usize, faults: usize) -> bool { faults * 3 < validators } - - /// Cross-check consensus with wormhole's guardian consensus - pub fn check_wormhole_parity(&self) -> bool { - true - } -} - -pub struct PrivacyBridging; - -impl PrivacyBridging { - /// Complete privacy wrapper utilizing advanced zk snarks - pub fn encode_anonymous_tx(amount: u64) -> Vec { - let mut out = vec![0x99; 32]; - out.push((amount % 255) as u8); - out - } - - pub fn satisfy_regulatory_implications() -> bool { - // Enforces viewing keys for regulatory audit hooks - true - } } #[cfg(test)] mod tests { use super::*; + #[test] fn test_byzantine() { assert!(ByzantineBridge::verify_byzantine_safety(10, 3)); assert!(!ByzantineBridge::verify_byzantine_safety(10, 4)); } - - #[test] - fn test_privacy() { - assert_eq!(PrivacyBridging::encode_anonymous_tx(100).len(), 33); - assert!(PrivacyBridging::satisfy_regulatory_implications()); - } } diff --git a/relayer/src/events.rs b/relayer/src/events.rs index 484dd36..3e045e5 100644 --- a/relayer/src/events.rs +++ b/relayer/src/events.rs @@ -1,81 +1,250 @@ -//! Event type definitions for cross-chain messages observed by the relayer. - -use serde::{Deserialize, Serialize}; - -/// A deposit event emitted by the EVM Gateway's sendCrossChainMessage. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DepositEvent { - pub block_number: u64, - pub tx_hash: [u8; 32], - pub sequence: u64, - pub sender: [u8; 20], - pub recipient: Vec, - pub amount: u128, - pub destination_chain: u16, - pub payload_hash: [u8; 32], -} - -/// A swap event emitted by the EVM Gateway's initiateSwap. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SwapInitiatedEvent { - pub block_number: u64, - pub tx_hash: [u8; 32], - pub sequence: u64, - pub sender: [u8; 20], - pub recipient: [u8; 20], - pub amount_in: u128, - pub token_in: [u8; 20], - pub token_out: [u8; 20], - pub min_amount_out: u128, - pub destination_chain: u16, - pub swap_data: Vec, - pub payload_hash: [u8; 32], -} - -/// An NFT lock event emitted by the EVM Gateway's lockNFT. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NFTLockedEvent { - pub block_number: u64, - pub tx_hash: [u8; 32], - pub sequence: u64, - pub sender: [u8; 20], - pub nft_contract: [u8; 20], - pub token_id: [u8; 32], - pub destination_chain: u16, - pub destination_recipient: [u8; 32], - pub nft_hash: [u8; 32], -} - -/// Unified event enum for all cross-chain events the relayer observes. -#[derive(Debug, Clone)] -pub enum GatewayEvent { - Deposit(DepositEvent), - Swap(SwapInitiatedEvent), - NFTLock(NFTLockedEvent), -} - -impl GatewayEvent { - pub fn sequence(&self) -> u64 { - match self { - GatewayEvent::Deposit(e) => e.sequence, - GatewayEvent::Swap(e) => e.sequence, - GatewayEvent::NFTLock(e) => e.sequence, - } - } - - pub fn block_number(&self) -> u64 { - match self { - GatewayEvent::Deposit(e) => e.block_number, - GatewayEvent::Swap(e) => e.block_number, - GatewayEvent::NFTLock(e) => e.block_number, - } - } - - pub fn payload_hash(&self) -> [u8; 32] { - match self { - GatewayEvent::Deposit(e) => e.payload_hash, - GatewayEvent::Swap(e) => e.payload_hash, - GatewayEvent::NFTLock(e) => e.nft_hash, - } - } -} +//! Event type definitions for cross-chain messages observed by the relayer. + +use serde::{Deserialize, Serialize}; + +/// A deposit event emitted by the EVM Gateway's sendCrossChainMessage. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DepositEvent { + pub block_number: u64, + pub tx_hash: [u8; 32], + pub sequence: u64, + pub sender: [u8; 20], + pub recipient: Vec, + pub amount: u128, + pub destination_chain: u16, + pub payload_hash: [u8; 32], +} + +/// A swap event emitted by the EVM Gateway's initiateSwap. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SwapInitiatedEvent { + pub block_number: u64, + pub tx_hash: [u8; 32], + pub sequence: u64, + pub sender: [u8; 20], + pub recipient: [u8; 20], + pub amount_in: u128, + pub token_in: [u8; 20], + pub token_out: [u8; 20], + pub min_amount_out: u128, + pub destination_chain: u16, + pub swap_data: Vec, + pub payload_hash: [u8; 32], +} + +/// An NFT lock event emitted by the EVM Gateway's lockNFT. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NFTLockedEvent { + pub block_number: u64, + pub tx_hash: [u8; 32], + pub sequence: u64, + pub sender: [u8; 20], + pub nft_contract: [u8; 20], + pub token_id: [u8; 32], + pub destination_chain: u16, + pub destination_recipient: [u8; 32], + pub nft_hash: [u8; 32], +} + +/// Message actions (must match InterlinkGateway and the Solana hub). +pub const ACTION_TRANSFER: u8 = 1; +pub const ACTION_SWAP: u8 = 2; +pub const ACTION_NFT: u8 = 3; +pub const ACTION_CALL: u8 = 4; + +/// Chain ID of the Solana hub in the canonical message format. +pub const HUB_CHAIN_ID: u64 = 2; + +/// Canonical cross-chain message. `hash()` must equal +/// `InterlinkGateway.hashMessage` and the hub's `canonical_hash`: +/// +/// keccak256(abi.encode(keccak256("InterlinkMessage(v1)"), srcChain, srcGateway, +/// dstChain, dstGateway, nonce, action, sender, asset, amount, recipient, extraHash)) +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CanonicalMessage { + pub src_chain: u64, + pub src_gateway: [u8; 32], + pub dst_chain: u64, + pub dst_gateway: [u8; 32], + pub nonce: u64, + pub action: u8, + pub sender: [u8; 32], + pub asset: [u8; 32], + pub amount: u128, + pub recipient: [u8; 32], + pub extra_hash: [u8; 32], +} + +fn word_u64(v: u64) -> [u8; 32] { + let mut w = [0u8; 32]; + w[24..].copy_from_slice(&v.to_be_bytes()); + w +} + +fn word_u128(v: u128) -> [u8; 32] { + let mut w = [0u8; 32]; + w[16..].copy_from_slice(&v.to_be_bytes()); + w +} + +impl CanonicalMessage { + pub fn hash(&self) -> [u8; 32] { + let mut buf = Vec::with_capacity(12 * 32); + buf.extend_from_slice(&crate::evm::keccak256(b"InterlinkMessage(v1)")); + buf.extend_from_slice(&word_u64(self.src_chain)); + buf.extend_from_slice(&self.src_gateway); + buf.extend_from_slice(&word_u64(self.dst_chain)); + buf.extend_from_slice(&self.dst_gateway); + buf.extend_from_slice(&word_u64(self.nonce)); + buf.extend_from_slice(&word_u64(self.action as u64)); + buf.extend_from_slice(&self.sender); + buf.extend_from_slice(&self.asset); + buf.extend_from_slice(&word_u128(self.amount)); + buf.extend_from_slice(&self.recipient); + buf.extend_from_slice(&self.extra_hash); + crate::evm::keccak256(&buf) + } +} + +/// `MessagePublished` emitted by the current InterlinkGateway for every outbound action. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessageEvent { + pub block_number: u64, + /// Hash of the block the log was seen in; re-checked after finality to detect reorgs. + pub block_hash: [u8; 32], + pub tx_hash: [u8; 32], + /// Gateway contract that emitted the log. + pub gateway: [u8; 20], + /// Payload hash as emitted (indexed topic). Must equal `message.hash()`. + pub payload_hash: [u8; 32], + pub message: CanonicalMessage, + /// Action-specific data (payload, swap params, NFT token id). + pub data: Vec, +} + +/// Unified event enum for all cross-chain events the relayer observes. +/// +/// `Message` is what the current gateway emits. The other variants describe +/// the previous event format and are kept for the benchmark/load-test tools; +/// they cannot be submitted to the hub. +#[derive(Debug, Clone)] +pub enum GatewayEvent { + Message(MessageEvent), + Deposit(DepositEvent), + Swap(SwapInitiatedEvent), + NFTLock(NFTLockedEvent), +} + +impl GatewayEvent { + pub fn sequence(&self) -> u64 { + match self { + GatewayEvent::Message(e) => e.message.nonce, + GatewayEvent::Deposit(e) => e.sequence, + GatewayEvent::Swap(e) => e.sequence, + GatewayEvent::NFTLock(e) => e.sequence, + } + } + + pub fn block_number(&self) -> u64 { + match self { + GatewayEvent::Message(e) => e.block_number, + GatewayEvent::Deposit(e) => e.block_number, + GatewayEvent::Swap(e) => e.block_number, + GatewayEvent::NFTLock(e) => e.block_number, + } + } + + pub fn payload_hash(&self) -> [u8; 32] { + match self { + GatewayEvent::Message(e) => e.payload_hash, + GatewayEvent::Deposit(e) => e.payload_hash, + GatewayEvent::Swap(e) => e.payload_hash, + GatewayEvent::NFTLock(e) => e.nft_hash, + } + } + + /// The canonical message, but only if its recomputed hash equals the + /// emitted payload hash. The relayer never proves a hash it cannot + /// reconstruct from the event fields. + pub fn verified_message(&self) -> Option<&CanonicalMessage> { + match self { + GatewayEvent::Message(e) if e.message.hash() == e.payload_hash => Some(&e.message), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn word_addr(last2: [u8; 2]) -> [u8; 32] { + let mut w = [0u8; 32]; + w[30..].copy_from_slice(&last2); + w + } + + /// Fixed vector shared with contracts/evm/test (VECTOR_HASH), which was + /// checked against the compiled gateway in an EVM. If this test fails the + /// relayer would prove hashes no gateway accepts. + #[test] + fn canonical_hash_matches_evm_vector() { + let msg = CanonicalMessage { + src_chain: HUB_CHAIN_ID, + src_gateway: [0x7B; 32], + dst_chain: 31337, + dst_gateway: word_addr([0xBE, 0xEF]), + nonce: 7, + action: ACTION_TRANSFER, + sender: [0x22; 32], + asset: [0; 32], + amount: 2_000_000_000_000_000_000, + recipient: { + let mut w = [0u8; 32]; + w[12..].copy_from_slice(&[0xA1; 20]); + w + }, + extra_hash: [0; 32], + }; + let expected: [u8; 32] = crate::evm::from_hex( + "07b0f8b8ce5c6f82e0bb552073f1c35df9b0987c6e2c598c52ed5b59ee1c09bd", + ) + .unwrap() + .try_into() + .unwrap(); + assert_eq!(msg.hash(), expected); + } + + #[test] + fn verified_message_rejects_mismatched_hash() { + let message = CanonicalMessage { + src_chain: 1, + src_gateway: [1; 32], + dst_chain: HUB_CHAIN_ID, + dst_gateway: [2; 32], + nonce: 0, + action: ACTION_TRANSFER, + sender: [3; 32], + asset: [0; 32], + amount: 10, + recipient: [4; 32], + extra_hash: [5; 32], + }; + let good = MessageEvent { + block_number: 1, + block_hash: [0; 32], + tx_hash: [0; 32], + gateway: [0; 20], + payload_hash: message.hash(), + message: message.clone(), + data: vec![], + }; + assert!(GatewayEvent::Message(good.clone()) + .verified_message() + .is_some()); + + let mut bad = good; + bad.message.amount = 10_000; + assert!(GatewayEvent::Message(bad).verified_message().is_none()); + } +} diff --git a/relayer/src/evm.rs b/relayer/src/evm.rs new file mode 100644 index 0000000..5c9c0a7 --- /dev/null +++ b/relayer/src/evm.rs @@ -0,0 +1,133 @@ +//! Minimal EVM helpers: keccak256, hex, JSON-RPC and log types. +//! +//! These replace ethers-rs, which is deprecated and pulled in +//! `jsonwebtoken → ring 0.16` (RUSTSEC-2025-0009/0010). + +use serde::Deserialize; +use sha3::{Digest, Keccak256}; + +pub fn keccak256(data: &[u8]) -> [u8; 32] { + Keccak256::digest(data).into() +} + +/// `0x`-prefixed lowercase hex. +pub fn to_hex(bytes: &[u8]) -> String { + let mut s = String::with_capacity(2 + bytes.len() * 2); + s.push_str("0x"); + for b in bytes { + s.push_str(&format!("{:02x}", b)); + } + s +} + +/// Decode hex with or without `0x`. Rejects odd lengths and non-hex characters. +pub fn from_hex(s: &str) -> Result, String> { + let s = s.strip_prefix("0x").unwrap_or(s); + if !s.is_ascii() { + return Err("non-ascii hex".into()); + } + if !s.len().is_multiple_of(2) { + return Err(format!("odd-length hex: {s}")); + } + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| format!("bad hex: {e}"))) + .collect() +} + +pub fn hex32(s: &str) -> Result<[u8; 32], String> { + from_hex(s)? + .try_into() + .map_err(|v: Vec| format!("expected 32 bytes, got {}", v.len())) +} + +pub fn hex20(s: &str) -> Result<[u8; 20], String> { + from_hex(s)? + .try_into() + .map_err(|v: Vec| format!("expected 20 bytes, got {}", v.len())) +} + +/// Parse a JSON-RPC quantity (`0x1a`). +pub fn quantity(s: &str) -> Result { + u64::from_str_radix(s.strip_prefix("0x").unwrap_or(s), 16) + .map_err(|e| format!("bad quantity {s}: {e}")) +} + +/// Topic 0 of the gateway's `MessagePublished` event. +pub fn message_published_topic() -> [u8; 32] { + keccak256( + b"MessagePublished(bytes32,uint64,uint64,bytes32,uint8,bytes32,bytes32,uint256,bytes32,bytes32,bytes)", + ) +} + +/// A log as returned by `eth_getLogs` / in receipts. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcLog { + pub address: String, + pub topics: Vec, + pub data: String, + pub block_number: Option, + pub block_hash: Option, + pub transaction_hash: Option, + pub log_index: Option, + #[serde(default)] + pub removed: bool, +} + +/// Send a JSON-RPC request and return `result`, or an error string. +pub async fn rpc_call( + client: &reqwest::Client, + url: &str, + method: &str, + params: serde_json::Value, +) -> Result { + let body: serde_json::Value = client + .post(url) + .json(&serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params})) + .send() + .await + .map_err(|e| format!("{method}: {e}"))? + .json() + .await + .map_err(|e| format!("{method} parse: {e}"))?; + if let Some(err) = body.get("error") { + return Err(format!("{method}: {err}")); + } + Ok(body["result"].clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keccak_known_vector() { + // keccak256("") is a well-known constant. + assert_eq!( + to_hex(&keccak256(b"")), + "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470" + ); + } + + #[test] + fn hex_round_trip_and_errors() { + assert_eq!(from_hex(&to_hex(&[0, 1, 0xab])).unwrap(), vec![0, 1, 0xab]); + assert!(from_hex("0xabc").is_err()); + assert!(from_hex("0xzz").is_err()); + assert!(from_hex("0xé1").is_err()); + assert!(hex32("0x00").is_err()); + assert_eq!(quantity("0x1a").unwrap(), 26); + } + + #[test] + fn deserializes_rpc_log() { + let log: RpcLog = serde_json::from_str( + r#"{"address":"0xab","topics":["0x01"],"data":"0x","blockNumber":"0x10", + "blockHash":"0x02","transactionHash":"0x03","logIndex":"0x0","removed":false}"#, + ) + .unwrap(); + assert_eq!(quantity(log.block_number.as_deref().unwrap()).unwrap(), 16); + assert!(!log.removed); + } +} diff --git a/relayer/src/evm_submitter.rs b/relayer/src/evm_submitter.rs new file mode 100644 index 0000000..ffda890 --- /dev/null +++ b/relayer/src/evm_submitter.rs @@ -0,0 +1,248 @@ +//! Delivers hub → EVM messages by calling `InterlinkGateway.releaseTokens`. + +use crate::events::CanonicalMessage; +use crate::evm::{self, keccak256}; +use crate::evm_tx::{self, Eip1559Tx}; +use k256::ecdsa::SigningKey; +use std::sync::Arc; +use tokio::sync::Mutex; +use tracing::{info, warn}; + +const RECEIPT_TIMEOUT_SECS: u64 = 180; +const DEFAULT_PRIORITY_FEE_WEI: u128 = 1_500_000_000; + +#[derive(Clone, Debug)] +pub struct EvmSubmitterConfig { + pub rpc_url: String, + pub chain_id: u64, + pub gateway: [u8; 20], +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReleaseOutcome { + Confirmed(String), + /// The gateway already executed this message (another relayer, or an earlier attempt). + AlreadyExecuted, +} + +#[derive(Clone)] +pub struct EvmSubmitter { + config: EvmSubmitterConfig, + client: reqwest::Client, + key: Arc, + from: [u8; 20], + /// One transaction at a time per key, so nonces never collide. + send_lock: Arc>, +} + +fn selector(sig: &str) -> [u8; 4] { + keccak256(sig.as_bytes())[..4].try_into().unwrap() +} + +fn word_u64(v: u64) -> [u8; 32] { + let mut w = [0u8; 32]; + w[24..].copy_from_slice(&v.to_be_bytes()); + w +} + +fn word_u128(v: u128) -> [u8; 32] { + let mut w = [0u8; 32]; + w[16..].copy_from_slice(&v.to_be_bytes()); + w +} + +/// Calldata for `releaseTokens((uint64,uint64,bytes32,bytes32,uint256,bytes32,bytes32),bytes)`. +pub fn release_calldata(m: &CanonicalMessage, proof: &[u8]) -> Vec { + let mut d = + selector("releaseTokens((uint64,uint64,bytes32,bytes32,uint256,bytes32,bytes32),bytes)") + .to_vec(); + for w in [ + word_u64(m.src_chain), + word_u64(m.nonce), + m.sender, + m.asset, + word_u128(m.amount), + m.recipient, + m.extra_hash, + word_u64(8 * 32), // offset of `proof`, after the 7 tuple words + this word + word_u64(proof.len() as u64), + ] { + d.extend_from_slice(&w); + } + d.extend_from_slice(proof); + d.resize(d.len() + (32 - proof.len() % 32) % 32, 0); + d +} + +fn quantity_u128(v: &serde_json::Value) -> Result { + let s = v.as_str().ok_or("expected hex quantity")?; + u128::from_str_radix(s.strip_prefix("0x").unwrap_or(s), 16) + .map_err(|e| format!("bad quantity {s}: {e}")) +} + +impl EvmSubmitter { + pub fn new(config: EvmSubmitterConfig, key: SigningKey) -> Self { + let from = evm_tx::address(&key); + Self { + config, + client: reqwest::Client::new(), + key: Arc::new(key), + from, + send_lock: Arc::new(Mutex::new(())), + } + } + + pub fn address(&self) -> [u8; 20] { + self.from + } + + async fn rpc( + &self, + method: &str, + params: serde_json::Value, + ) -> Result { + evm::rpc_call(&self.client, &self.config.rpc_url, method, params).await + } + + async fn already_executed(&self, payload_hash: &[u8; 32]) -> Result { + let mut data = selector("executedMessages(bytes32)").to_vec(); + data.extend_from_slice(payload_hash); + let out = self + .rpc( + "eth_call", + serde_json::json!([{"to": evm::to_hex(&self.config.gateway), "data": evm::to_hex(&data)}, "latest"]), + ) + .await?; + let bytes = evm::from_hex(out.as_str().ok_or("eth_call: not a string")?)?; + Ok(bytes.last() == Some(&1)) + } + + /// Release tokens on the gateway for a proven hub message. + pub async fn release( + &self, + m: &CanonicalMessage, + proof: &[u8], + ) -> Result { + let payload_hash = m.hash(); + if self.already_executed(&payload_hash).await? { + return Ok(ReleaseOutcome::AlreadyExecuted); + } + let data = release_calldata(m, proof); + let to = evm::to_hex(&self.config.gateway); + let from = evm::to_hex(&self.from); + + let _guard = self.send_lock.lock().await; + + // A revert here (bad proof, unknown source, insufficient custody) is + // surfaced before spending gas. + let gas = self + .rpc( + "eth_estimateGas", + serde_json::json!([{"from": from, "to": to, "data": evm::to_hex(&data)}]), + ) + .await + .map_err(|e| format!("releaseTokens would revert: {e}"))?; + let gas_limit = (quantity_u128(&gas)? as u64).saturating_mul(12) / 10; + + let nonce = quantity_u128( + &self + .rpc( + "eth_getTransactionCount", + serde_json::json!([from, "pending"]), + ) + .await?, + )? as u64; + let block = self + .rpc("eth_getBlockByNumber", serde_json::json!(["latest", false])) + .await?; + let base_fee = quantity_u128(&block["baseFeePerGas"]).unwrap_or(0); + let priority = match self + .rpc("eth_maxPriorityFeePerGas", serde_json::json!([])) + .await + { + Ok(v) => quantity_u128(&v).unwrap_or(DEFAULT_PRIORITY_FEE_WEI), + Err(_) => DEFAULT_PRIORITY_FEE_WEI, + }; + + let tx = Eip1559Tx { + chain_id: self.config.chain_id, + nonce, + max_priority_fee_per_gas: priority, + max_fee_per_gas: base_fee.saturating_mul(2).saturating_add(priority), + gas_limit, + to: self.config.gateway, + value: 0, + data, + }; + let raw = tx.sign(&self.key)?; + let hash = self + .rpc( + "eth_sendRawTransaction", + serde_json::json!([evm::to_hex(&raw)]), + ) + .await?; + let tx_hash = hash + .as_str() + .ok_or("eth_sendRawTransaction: no hash")? + .to_string(); + info!(tx = %tx_hash, nonce = m.nonce, "releaseTokens sent"); + + let deadline = + std::time::Instant::now() + std::time::Duration::from_secs(RECEIPT_TIMEOUT_SECS); + while std::time::Instant::now() < deadline { + let receipt = self + .rpc("eth_getTransactionReceipt", serde_json::json!([tx_hash])) + .await?; + if !receipt.is_null() { + return if receipt["status"].as_str() == Some("0x1") { + Ok(ReleaseOutcome::Confirmed(tx_hash)) + } else if self.already_executed(&payload_hash).await.unwrap_or(false) { + Ok(ReleaseOutcome::AlreadyExecuted) + } else { + Err(format!("releaseTokens reverted in {tx_hash}")) + }; + } + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + } + warn!(tx = %tx_hash, "releaseTokens not mined in time"); + Err(format!( + "transaction {tx_hash} not mined within {RECEIPT_TIMEOUT_SECS}s" + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::events::{ACTION_TRANSFER, HUB_CHAIN_ID}; + + #[test] + fn release_calldata_layout() { + let m = CanonicalMessage { + src_chain: HUB_CHAIN_ID, + src_gateway: [0x7B; 32], + dst_chain: 1, + dst_gateway: [0; 32], + nonce: 7, + action: ACTION_TRANSFER, + sender: [0x22; 32], + asset: [0; 32], + amount: 2, + recipient: [0xA1; 32], + extra_hash: [0; 32], + }; + let d = release_calldata(&m, &[9u8; 256]); + assert_eq!( + &d[..4], + &selector( + "releaseTokens((uint64,uint64,bytes32,bytes32,uint256,bytes32,bytes32),bytes)" + ) + ); + assert_eq!(d.len(), 4 + 9 * 32 + 256); + assert_eq!(&d[4 + 24..4 + 32], &HUB_CHAIN_ID.to_be_bytes()); + assert_eq!(&d[4 + 7 * 32 + 24..4 + 8 * 32], &256u64.to_be_bytes()); + + // Proof lengths that are not a multiple of 32 are zero-padded. + assert_eq!(release_calldata(&m, &[1u8; 33]).len(), 4 + 9 * 32 + 64); + } +} diff --git a/relayer/src/evm_tx.rs b/relayer/src/evm_tx.rs new file mode 100644 index 0000000..ab4e84f --- /dev/null +++ b/relayer/src/evm_tx.rs @@ -0,0 +1,187 @@ +//! EIP-1559 transaction encoding and signing (secp256k1 via `k256`). + +use k256::ecdsa::SigningKey; + +/// RLP-encode a byte string. +pub fn rlp_bytes(b: &[u8]) -> Vec { + match b.len() { + 1 if b[0] < 0x80 => vec![b[0]], + len if len <= 55 => [&[0x80 + len as u8][..], b].concat(), + len => { + let len_bytes = trim_leading_zeros(&(len as u64).to_be_bytes()).to_vec(); + [&[0xb7 + len_bytes.len() as u8][..], &len_bytes, b].concat() + } + } +} + +/// RLP-encode an unsigned integer given as big-endian bytes (minimal form). +pub fn rlp_uint(be: &[u8]) -> Vec { + rlp_bytes(trim_leading_zeros(be)) +} + +pub fn rlp_u128(v: u128) -> Vec { + rlp_uint(&v.to_be_bytes()) +} + +/// RLP-encode a list of already-encoded items. +pub fn rlp_list(items: &[Vec]) -> Vec { + let payload: Vec = items.concat(); + let mut out = match payload.len() { + len if len <= 55 => vec![0xc0 + len as u8], + len => { + let len_bytes = trim_leading_zeros(&(len as u64).to_be_bytes()).to_vec(); + [&[0xf7 + len_bytes.len() as u8][..], &len_bytes].concat() + } + }; + out.extend_from_slice(&payload); + out +} + +fn trim_leading_zeros(b: &[u8]) -> &[u8] { + let first = b.iter().position(|x| *x != 0).unwrap_or(b.len()); + &b[first..] +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Eip1559Tx { + pub chain_id: u64, + pub nonce: u64, + pub max_priority_fee_per_gas: u128, + pub max_fee_per_gas: u128, + pub gas_limit: u64, + pub to: [u8; 20], + pub value: u128, + pub data: Vec, +} + +impl Eip1559Tx { + fn fields(&self) -> Vec> { + vec![ + rlp_u128(self.chain_id as u128), + rlp_u128(self.nonce as u128), + rlp_u128(self.max_priority_fee_per_gas), + rlp_u128(self.max_fee_per_gas), + rlp_u128(self.gas_limit as u128), + rlp_bytes(&self.to), + rlp_u128(self.value), + rlp_bytes(&self.data), + rlp_list(&[]), // empty access list + ] + } + + /// keccak256(0x02 || rlp([chainId, nonce, ..., accessList])) + pub fn signing_hash(&self) -> [u8; 32] { + let mut buf = vec![0x02]; + buf.extend_from_slice(&rlp_list(&self.fields())); + crate::evm::keccak256(&buf) + } + + /// Signed raw transaction: 0x02 || rlp([..fields, yParity, r, s]). + pub fn sign(&self, key: &SigningKey) -> Result, String> { + let (sig, recovery) = key + .sign_prehash_recoverable(&self.signing_hash()) + .map_err(|e| format!("signing failed: {e}"))?; + let bytes = sig.to_bytes(); + let mut fields = self.fields(); + fields.push(rlp_u128(recovery.is_y_odd() as u128)); + fields.push(rlp_uint(&bytes[..32])); + fields.push(rlp_uint(&bytes[32..])); + let mut raw = vec![0x02]; + raw.extend_from_slice(&rlp_list(&fields)); + Ok(raw) + } +} + +/// Ethereum address of a signing key: keccak256(uncompressed pubkey)[12..]. +pub fn address(key: &SigningKey) -> [u8; 20] { + let point = key.verifying_key().to_encoded_point(false); + crate::evm::keccak256(&point.as_bytes()[1..])[12..] + .try_into() + .unwrap() +} + +pub fn signing_key_from_hex(hex: &str) -> Result { + let bytes = crate::evm::from_hex(hex.trim())?; + SigningKey::from_slice(&bytes).map_err(|_| "invalid secp256k1 private key".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rlp_spec_vectors() { + // Examples from the Ethereum RLP specification. + assert_eq!(rlp_bytes(b"dog"), vec![0x83, b'd', b'o', b'g']); + assert_eq!( + rlp_list(&[rlp_bytes(b"cat"), rlp_bytes(b"dog")]), + vec![0xc8, 0x83, b'c', b'a', b't', 0x83, b'd', b'o', b'g'] + ); + assert_eq!(rlp_bytes(b""), vec![0x80]); + assert_eq!(rlp_list(&[]), vec![0xc0]); + assert_eq!(rlp_u128(0), vec![0x80]); + assert_eq!(rlp_u128(15), vec![0x0f]); + assert_eq!(rlp_u128(1024), vec![0x82, 0x04, 0x00]); + let lorem = b"Lorem ipsum dolor sit amet, consectetur adipisicing elit"; + assert_eq!(&rlp_bytes(lorem)[..2], &[0xb8, 0x38]); + } + + #[test] + fn known_private_key_address() { + // Private key 1 → the well-known address of the secp256k1 generator. + let mut k = [0u8; 32]; + k[31] = 1; + let key = SigningKey::from_slice(&k).unwrap(); + assert_eq!( + crate::evm::to_hex(&address(&key)), + "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf" + ); + } + + /// Cross-check encoding and signature against ethers-core (dev-dependency). + #[test] + fn matches_ethers_encoding_and_recovers_sender() { + use ethers_core::types::transaction::eip2718::TypedTransaction; + use ethers_core::types::{Eip1559TransactionRequest, Signature, H160, U256}; + + let key = signing_key_from_hex( + "0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318", + ) + .unwrap(); + let tx = Eip1559Tx { + chain_id: 1, + nonce: 42, + max_priority_fee_per_gas: 1_500_000_000, + max_fee_per_gas: 80_000_000_000, + gas_limit: 350_000, + to: [0xAB; 20], + value: 0, + data: (0..300u16).map(|i| i as u8).collect(), + }; + + let ethers_tx: TypedTransaction = Eip1559TransactionRequest::new() + .chain_id(1u64) + .nonce(42u64) + .max_priority_fee_per_gas(1_500_000_000u64) + .max_fee_per_gas(80_000_000_000u64) + .gas(350_000u64) + .to(H160::from([0xAB; 20])) + .value(0u64) + .data(tx.data.clone()) + .into(); + assert_eq!(tx.signing_hash(), ethers_tx.sighash().0); + + let raw = tx.sign(&key).unwrap(); + let (sig, rec) = key.sign_prehash_recoverable(&tx.signing_hash()).unwrap(); + let b = sig.to_bytes(); + let ethers_sig = Signature { + r: U256::from_big_endian(&b[..32]), + s: U256::from_big_endian(&b[32..]), + v: rec.is_y_odd() as u64, + }; + assert_eq!(raw, ethers_tx.rlp_signed(ðers_sig).to_vec()); + + let recovered = ethers_sig.recover(ethers_tx.sighash()).unwrap(); + assert_eq!(recovered.0, address(&key)); + } +} diff --git a/relayer/src/finality.rs b/relayer/src/finality.rs index 141c1bf..9e3a824 100644 --- a/relayer/src/finality.rs +++ b/relayer/src/finality.rs @@ -6,12 +6,9 @@ //! - Cosmos: 1 block (~6s) for Tendermint BFT finality //! - L2s (Arbitrum, Optimism, Base): inherit L1 finality (~7 min optimistic) //! -//! Two finality strategies are provided: -//! - `wait_for_finality`: HTTP polling (fallback, ~12s per poll for ETH) -//! - `wait_for_finality_ws`: WebSocket newHeads subscription (preferred, <1s reaction time) -//! -//! The WebSocket path is ~12x faster on Ethereum than HTTP polling because we receive -//! a notification on every block instead of waiting for the next poll interval. +//! The relayer pipeline uses `wait_for_finalized_inclusion`, which waits for the +//! node's `finalized` block tag and re-checks the receipt. `wait_for_finality` +//! (confirmation counting) is kept for tooling but is not reorg-safe. use std::time::Duration; use tracing::{info, warn}; @@ -64,7 +61,8 @@ impl ChainFinality { /// Wait for a block to be finalized on the source chain. /// -/// Uses an ethers-rs provider to poll block confirmations. +/// Polls block height over HTTP JSON-RPC and counts confirmations. Not reorg-safe; +/// the pipeline uses `wait_for_finalized_inclusion` instead. /// Returns Ok(()) once the block has enough confirmations, or Err on timeout. pub async fn wait_for_finality( chain_id: u64, @@ -154,109 +152,146 @@ pub async fn wait_for_finality( } } -/// Wait for block finality using a WebSocket `eth_subscribe("newHeads")` subscription. -/// -/// # Why this beats HTTP polling -/// -/// The HTTP polling path wakes up every `poll_interval` seconds (12s for Ethereum). -/// In the worst case the relayer is 12 seconds behind — which alone would blow our -/// <30s settlement target for events that land right after a poll. -/// -/// With WebSocket subscriptions the node pushes each new block header to us the moment -/// it is imported (~100-500ms after the block is mined). We check confirmations on every -/// push, so reaction time is 1 block-time rather than 1 poll-interval. -/// -/// Competitive comparison: -/// - Wormhole: polls every ~1-2min for finality detection → 2-15min total -/// - InterLink (HTTP polling): up to 12s delay per check → worst case adds 12s -/// - InterLink (WebSocket): <1s notification delay → worst case adds 1 block-time -/// -/// Falls back to HTTP polling for non-WebSocket URLs (Solana, Cosmos HTTP endpoints). -pub async fn wait_for_finality_ws( - chain_id: u64, - block_number: u64, - ws_url: &str, -) -> Result<(), String> { - // Only use WebSocket path for actual WS URLs; fall back for HTTP/others. - if !ws_url.starts_with("ws://") && !ws_url.starts_with("wss://") { - return wait_for_finality(chain_id, block_number, ws_url).await; - } - - use ethers_providers::{Middleware, StreamExt}; +// ─── Finalized inclusion (used by the relayer pipeline) ───────────────────── - let finality = ChainFinality::from_chain_id(chain_id); - let required = finality.required_confirmations(); - // Add 2 minutes of slack beyond expected finality for network jitter. - let timeout_duration = Duration::from_secs(finality.expected_finality_secs() * 2 + 120); +/// Why an event could not be confirmed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InclusionError { + /// The event is no longer in the canonical chain (reorged out, failed tx, + /// or log missing). Never retry: relaying it would mint unbacked tokens. + NotCanonical(String), + /// RPC/network problem or timeout. Safe to retry later. + Transient(String), +} - info!( - chain_id, - block_number, - required_confirmations = required, - timeout_secs = timeout_duration.as_secs(), - "waiting for finality via WebSocket newHeads subscription" - ); +impl std::fmt::Display for InclusionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + InclusionError::NotCanonical(s) => write!(f, "not canonical: {}", s), + InclusionError::Transient(s) => write!(f, "transient: {}", s), + } + } +} - let provider = ethers_providers::Provider::::connect(ws_url) +async fn rpc( + client: &reqwest::Client, + url: &str, + method: &str, + params: serde_json::Value, +) -> Result { + crate::evm::rpc_call(client, url, method, params) .await - .map_err(|e| format!("ws connect failed: {}", e))?; + .map_err(InclusionError::Transient) +} - let mut stream = provider - .subscribe_blocks() - .await - .map_err(|e| format!("subscribe_blocks failed: {}", e))?; +fn hex_u64(v: &serde_json::Value) -> Option { + u64::from_str_radix(v.as_str()?.trim_start_matches("0x"), 16).ok() +} +/// Wait until the event's block is finalized, then confirm the exact log is +/// still in the canonical chain. +/// +/// Replaces depth counting (64 blocks on L1, 10 blocks on L2s, which is about +/// 2.5 seconds on Arbitrum) with the node's `finalized` block tag, which +/// Ethereum and the major rollups (Arbitrum, OP Stack, Base) expose as real +/// finality. After that it re-fetches the receipt and checks block hash, +/// success status, emitting gateway and payload hash, so a reorged-out +/// deposit is never relayed. +pub async fn wait_for_finalized_inclusion( + rpc_url: &str, + event: &crate::events::MessageEvent, + poll_interval: Duration, + timeout: Duration, +) -> Result<(), InclusionError> { + let client = reqwest::Client::new(); let start = std::time::Instant::now(); loop { - let remaining = timeout_duration.saturating_sub(start.elapsed()); - if remaining.is_zero() { - warn!(chain_id, block_number, "WebSocket finality wait timed out"); - return Err(format!( - "timed out waiting for finality on chain {} block {}", - chain_id, block_number - )); + let finalized = rpc( + &client, + rpc_url, + "eth_getBlockByNumber", + serde_json::json!(["finalized", false]), + ) + .await?; + let finalized_number = hex_u64(&finalized["number"]).ok_or_else(|| { + InclusionError::Transient( + "node did not return a finalized block (is the tag supported?)".into(), + ) + })?; + if finalized_number >= event.block_number { + break; } + if start.elapsed() > timeout { + return Err(InclusionError::Transient(format!( + "block {} not finalized after {:?} (finalized head {})", + event.block_number, timeout, finalized_number + ))); + } + tracing::debug!( + block = event.block_number, + finalized_number, + "waiting for finality" + ); + tokio::time::sleep(poll_interval).await; + } - match tokio::time::timeout(remaining, stream.next()).await { - Ok(Some(block)) => { - let current_block = block.number.unwrap_or_default().as_u64(); - let confirmations = current_block.saturating_sub(block_number); - - tracing::debug!( - chain_id, - current_block, - target_block = block_number, - confirmations, - required, - "newHead received" - ); + let receipt = rpc( + &client, + rpc_url, + "eth_getTransactionReceipt", + serde_json::json!([crate::evm::to_hex(&event.tx_hash)]), + ) + .await?; + if receipt.is_null() { + return Err(InclusionError::NotCanonical( + "transaction receipt not found".into(), + )); + } + if receipt["status"].as_str() != Some("0x1") { + return Err(InclusionError::NotCanonical("transaction reverted".into())); + } + if !receipt["blockHash"] + .as_str() + .unwrap_or_default() + .eq_ignore_ascii_case(&crate::evm::to_hex(&event.block_hash)) + { + return Err(InclusionError::NotCanonical(format!( + "transaction now in block {} (event seen in {}): reorg", + receipt["blockHash"], + crate::evm::to_hex(&event.block_hash) + ))); + } - if confirmations >= required { - info!( - chain_id, - block_number, - confirmations, - elapsed_ms = start.elapsed().as_millis(), - "finality confirmed via WebSocket" - ); - return Ok(()); - } - } - Ok(None) => { - return Err( - "WebSocket newHeads stream ended before finality was reached".to_string(), - ); - } - Err(_elapsed) => { - warn!(chain_id, block_number, "WebSocket finality timeout elapsed"); - return Err(format!( - "timed out waiting for finality on chain {} block {}", - chain_id, block_number - )); - } - } + let gateway = crate::evm::to_hex(&event.gateway); + let topic0 = crate::evm::to_hex(&crate::evm::message_published_topic()); + let payload = crate::evm::to_hex(&event.payload_hash); + let found = receipt["logs"].as_array().into_iter().flatten().any(|log| { + let topics = log["topics"].as_array(); + log["address"] + .as_str() + .is_some_and(|a| a.eq_ignore_ascii_case(&gateway)) + && topics.is_some_and(|t| { + t.len() >= 2 + && t[0] + .as_str() + .is_some_and(|s| s.eq_ignore_ascii_case(&topic0)) + && t[1] + .as_str() + .is_some_and(|s| s.eq_ignore_ascii_case(&payload)) + }) + }); + if !found { + return Err(InclusionError::NotCanonical( + "MessagePublished log not in finalized receipt".into(), + )); } + + info!( + block = event.block_number, + "event finalized and confirmed canonical" + ); + Ok(()) } #[cfg(test)] diff --git a/relayer/src/formal_verification.rs b/relayer/src/formal_verification.rs deleted file mode 100644 index 72e16fc..0000000 --- a/relayer/src/formal_verification.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! Formal verification of ZK constraints - -pub struct VerificationAuditor; - -impl VerificationAuditor { - /// Trail of bits / PSE auditor output mock - pub fn parse_third_party_audit() -> bool { - true - } - - /// Formal proof of constraint satisfaction - pub fn verify_constraint_satisfiability() -> bool { - true - } - - /// Publish audit results publicly - pub fn publish_results(&self) -> String { - "ipfs://bafybeihdwdcefgh4dqkjv67".to_string() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_verification() { - assert!(VerificationAuditor::verify_constraint_satisfiability()); - assert!(VerificationAuditor::parse_third_party_audit()); - } -} diff --git a/relayer/src/http_api.rs b/relayer/src/http_api.rs index 16d9600..dfd8950 100644 --- a/relayer/src/http_api.rs +++ b/relayer/src/http_api.rs @@ -99,7 +99,14 @@ async fn handle_connection( let raw = String::from_utf8_lossy(&buf[..n]); let (method, path, query, body_str) = parse_request(&raw); - let (status, content_type, body) = route(method, path, &query, &body_str, &metrics, ®istry); + let api_token = std::env::var("API_TOKEN").ok(); + let (status, content_type, body) = match check_auth(path, &raw, api_token.as_deref()) { + Err(denied) => denied, + Ok(()) if method == "GET" && path.starts_with("/transfers/") => { + transfer_status(&path["/transfers/".len()..]).await + } + Ok(()) => route(method, path, &query, &body_str, &metrics, ®istry), + }; let response = format!( "HTTP/1.1 {status} {reason}\r\n\ @@ -124,6 +131,160 @@ async fn handle_connection( Ok(()) } +// ─── Auth ───────────────────────────────────────────────────────────────────── + +/// Webhook routes register outbound callbacks (an SSRF vector) and list other +/// subscribers' URLs, so they need `Authorization: Bearer `. With no +/// `API_TOKEN` configured they are disabled. Read-only routes stay public. +fn requires_auth(path: &str) -> bool { + path == "/webhooks" || path.starts_with("/webhooks/") +} + +fn bearer_token(raw: &str) -> Option<&str> { + raw.lines() + .take_while(|l| !l.trim().is_empty()) + .filter_map(|l| l.split_once(':')) + .find(|(name, _)| name.trim().eq_ignore_ascii_case("authorization")) + .and_then(|(_, value)| value.trim().strip_prefix("Bearer ")) + .map(str::trim) +} + +/// Compare without short-circuiting, so response timing does not leak the token. +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} + +fn check_auth( + path: &str, + raw: &str, + api_token: Option<&str>, +) -> Result<(), (u16, &'static str, String)> { + if !requires_auth(path) { + return Ok(()); + } + let Some(expected) = api_token.filter(|t| !t.is_empty()) else { + return Err(( + 403, + "application/json", + r#"{"error":"webhook endpoints are disabled; set API_TOKEN to enable"}"#.into(), + )); + }; + match bearer_token(raw) { + Some(given) if constant_time_eq(given.as_bytes(), expected.as_bytes()) => Ok(()), + _ => Err(( + 401, + "application/json", + r#"{"error":"missing or invalid bearer token"}"#.into(), + )), + } +} + +// ─── Transfer status (hub on-chain record) ─────────────────────────────────── + +/// Decoded hub `MessageRecord`: discriminator (8) | payload_hash (32) | +/// action (1) | executed (1) | attested_by (32) | attested_at (i64). +#[derive(Debug, PartialEq, Eq)] +struct MessageRecord { + payload_hash: [u8; 32], + action: u8, + executed: bool, + attested_by: [u8; 32], + attested_at: i64, +} + +fn parse_message_record(data: &[u8]) -> Option { + if data.len() < 82 { + return None; + } + Some(MessageRecord { + payload_hash: data[8..40].try_into().ok()?, + action: data[40], + executed: data[41] != 0, + attested_by: data[42..74].try_into().ok()?, + attested_at: i64::from_le_bytes(data[74..82].try_into().ok()?), + }) +} + +/// GET /transfers/{payloadHash}: status of a message on the hub, read from +/// its on-chain record (so it is correct whichever relayer processed it). +async fn transfer_status(hash_hex: &str) -> (u16, &'static str, String) { + let bad = |msg: &str| { + ( + 400, + "application/json", + serde_json::json!({ "error": msg }).to_string(), + ) + }; + let Ok(hash) = crate::evm::hex32(hash_hex) else { + return bad("payload hash must be 32 bytes of hex"); + }; + let rpc_url = + std::env::var("SOLANA_RPC_URL").unwrap_or_else(|_| "https://api.devnet.solana.com".into()); + let program_id = std::env::var("HUB_PROGRAM_ID") + .unwrap_or_else(|_| "AKzpc9tvxfhLjj5AantKizK2YZgSjoyhLmYqRZy6b8Lz".into()); + let Ok(program) = crate::solana_tx::pubkey(&program_id) else { + return ( + 500, + "application/json", + r#"{"error":"invalid HUB_PROGRAM_ID"}"#.into(), + ); + }; + let Ok(address) = crate::solana_tx::pda(&[b"message", &hash], &program) else { + return ( + 500, + "application/json", + r#"{"error":"PDA derivation failed"}"#.into(), + ); + }; + + let client = reqwest::Client::new(); + let result = crate::evm::rpc_call( + &client, + &rpc_url, + "getAccountInfo", + serde_json::json!([bs58::encode(address).into_string(), {"encoding": "base64", "commitment": "confirmed"}]), + ) + .await; + let value = match result { + Ok(v) => v["value"].clone(), + Err(e) => { + return ( + 502, + "application/json", + serde_json::json!({ "error": e }).to_string(), + ) + } + }; + let payload = crate::evm::to_hex(&hash); + if value.is_null() { + let body = serde_json::json!({ "payload_hash": payload, "status": "not_found" }); + return (200, "application/json", body.to_string()); + } + let record = value["data"][0] + .as_str() + .and_then(|b64| { + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64).ok() + }) + .and_then(|data| parse_message_record(&data)); + match record { + Some(r) if r.payload_hash == hash => { + let body = serde_json::json!({ + "payload_hash": payload, + "status": if r.executed { "executed" } else { "attested" }, + "action": r.action, + "attested_by": bs58::encode(r.attested_by).into_string(), + "attested_at": r.attested_at, + }); + (200, "application/json", body.to_string()) + } + _ => ( + 502, + "application/json", + r#"{"error":"unexpected message record layout"}"#.into(), + ), + } +} + // ─── Router ─────────────────────────────────────────────────────────────────── fn route( @@ -349,6 +510,7 @@ fn not_found() -> (u16, &'static str, String) { "/metrics", "/metrics/json", "/webhooks", + "/transfers/{payloadHash}", "/webhooks/register (POST)", "/webhooks/{id} (GET, DELETE)", ], @@ -396,9 +558,13 @@ fn parse_request(request: &str) -> (&str, &str, HashMap, String) fn reason(status: u16) -> &'static str { match status { 200 => "OK", + 201 => "Created", 400 => "Bad Request", + 401 => "Unauthorized", + 403 => "Forbidden", 404 => "Not Found", 500 => "Internal Server Error", + 502 => "Bad Gateway", _ => "Unknown", } } @@ -492,6 +658,64 @@ mod tests { assert!(body.contains("proof_gen")); } + #[test] + fn test_webhook_routes_require_token() { + let req = |auth: &str| { + format!("POST /webhooks/register HTTP/1.1\r\nHost: x\r\n{auth}\r\n{{\"url\":\"https://e.com\"}}") + }; + // No token configured: webhook routes disabled entirely. + let err = check_auth("/webhooks/register", &req(""), None).unwrap_err(); + assert_eq!(err.0, 403); + // Token configured: missing or wrong bearer is rejected. + assert_eq!( + check_auth("/webhooks", &req(""), Some("s3cret")) + .unwrap_err() + .0, + 401 + ); + let wrong = req("Authorization: Bearer nope\r\n"); + assert_eq!( + check_auth("/webhooks/abc", &wrong, Some("s3cret")) + .unwrap_err() + .0, + 401 + ); + // Correct token (header name is case-insensitive). + let good = req("authorization: Bearer s3cret\r\n"); + assert!(check_auth("/webhooks/register", &good, Some("s3cret")).is_ok()); + // Public routes never need a token. + assert!(check_auth("/health", &req(""), None).is_ok()); + assert!(check_auth("/metrics", &req(""), None).is_ok()); + } + + #[test] + fn test_parse_message_record() { + let mut data = vec![0u8; 82]; + data[8..40].copy_from_slice(&[7u8; 32]); + data[40] = 1; + data[41] = 1; + data[42..74].copy_from_slice(&[9u8; 32]); + data[74..82].copy_from_slice(&1_700_000_000i64.to_le_bytes()); + let r = parse_message_record(&data).unwrap(); + assert_eq!(r.payload_hash, [7u8; 32]); + assert!(r.executed); + assert_eq!(r.attested_at, 1_700_000_000); + assert!(parse_message_record(&data[..60]).is_none()); + } + + #[tokio::test] + async fn test_transfer_status_rejects_bad_hash() { + let (status, _, body) = transfer_status("0x1234").await; + assert_eq!(status, 400); + assert!(body.contains("32 bytes")); + } + + #[test] + fn test_bearer_token_ignores_body() { + let raw = "GET /webhooks HTTP/1.1\r\n\r\nAuthorization: Bearer injected"; + assert_eq!(bearer_token(raw), None); + } + #[test] fn test_not_found_route() { use crate::webhook::WebhookRegistry; diff --git a/relayer/src/hub_outbound.rs b/relayer/src/hub_outbound.rs new file mode 100644 index 0000000..2287f25 --- /dev/null +++ b/relayer/src/hub_outbound.rs @@ -0,0 +1,360 @@ +//! Watches the Solana hub for `OutboundMessageEvent` (from +//! `burn_for_withdrawal`) and forwards messages bound for this relayer's EVM +//! gateway. +//! +//! Only `finalized` transactions are read, which is Solana's finality. +//! Anchor events are `Program data:` log lines, and *any* program can print +//! such a line, so events are accepted only while the hub program is the one +//! currently executing (tracked through the invoke/success log stack). + +use crate::events::{CanonicalMessage, HUB_CHAIN_ID}; +use crate::solana_tx::{self as stx, Pubkey}; +use std::time::Duration; +use tokio::sync::mpsc; +use tracing::{info, warn}; + +#[derive(Clone, Debug)] +pub struct HubOutboundConfig { + pub rpc_url: String, + pub program_id: Pubkey, + /// Only messages to this EVM chain and gateway are forwarded. + pub evm_chain_id: u64, + pub evm_gateway: [u8; 20], + pub poll_interval: Duration, + /// Process signatures after this one on startup (None = only new ones). + pub start_after_signature: Option, +} + +#[derive(Debug, Clone)] +pub struct OutboundEvent { + pub signature: String, + pub message: CanonicalMessage, +} + +/// Decoded `OutboundMessageEvent` fields (Borsh, after the 8-byte discriminator). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OutboundMessageEvent { + pub payload_hash: [u8; 32], + pub dst_chain: u64, + pub dst_gateway: [u8; 32], + pub nonce: u64, + pub action: u8, + pub sender: [u8; 32], + pub asset: [u8; 32], + pub amount: u128, + pub recipient: [u8; 32], + pub extra_hash: [u8; 32], +} + +const EVENT_LEN: usize = 32 + 8 + 32 + 8 + 1 + 32 + 32 + 16 + 32 + 32; + +impl OutboundMessageEvent { + pub fn decode(data: &[u8]) -> Option { + let disc = stx::anchor_event_discriminator("OutboundMessageEvent"); + if data.len() != 8 + EVENT_LEN || data[..8] != disc { + return None; + } + let mut p = 8usize; + let mut take = |n: usize| { + let s = &data[p..p + n]; + p += n; + s + }; + Some(Self { + payload_hash: take(32).try_into().ok()?, + dst_chain: u64::from_le_bytes(take(8).try_into().ok()?), + dst_gateway: take(32).try_into().ok()?, + nonce: u64::from_le_bytes(take(8).try_into().ok()?), + action: take(1)[0], + sender: take(32).try_into().ok()?, + asset: take(32).try_into().ok()?, + amount: u128::from_le_bytes(take(16).try_into().ok()?), + recipient: take(32).try_into().ok()?, + extra_hash: take(32).try_into().ok()?, + }) + } + + /// Canonical message as the destination gateway will rebuild it. None if + /// the emitted payload hash does not match the fields. + pub fn to_message(&self, hub_program: &Pubkey) -> Option { + let m = CanonicalMessage { + src_chain: HUB_CHAIN_ID, + src_gateway: *hub_program, + dst_chain: self.dst_chain, + dst_gateway: self.dst_gateway, + nonce: self.nonce, + action: self.action, + sender: self.sender, + asset: self.asset, + amount: self.amount, + recipient: self.recipient, + extra_hash: self.extra_hash, + }; + (m.hash() == self.payload_hash).then_some(m) + } +} + +/// Extract hub events from a transaction's log messages, accepting +/// `Program data:` only while `program_id` is at the top of the invoke stack. +pub fn parse_hub_events(logs: &[String], program_id: &str) -> Vec { + let mut stack: Vec<&str> = Vec::new(); + let mut events = Vec::new(); + for line in logs { + if let Some(b64) = line.strip_prefix("Program data: ") { + if stack.last() == Some(&program_id) { + if let Ok(bytes) = + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64.trim()) + { + if let Some(ev) = OutboundMessageEvent::decode(&bytes) { + events.push(ev); + } + } + } + } else if let Some(rest) = line.strip_prefix("Program ") { + let mut parts = rest.split_whitespace(); + let id = parts.next().unwrap_or_default(); + match parts.next() { + Some("invoke") => stack.push(id), + Some("success") | Some("failed:") | Some("failed") => { + stack.pop(); + } + _ => {} // "consumed", "log:", "return:" etc. + } + } + } + events +} + +pub struct HubOutboundListener { + config: HubOutboundConfig, + tx: mpsc::Sender, + client: reqwest::Client, + cursor: Option, + seen: std::collections::HashSet<[u8; 32]>, +} + +impl HubOutboundListener { + pub fn new(config: HubOutboundConfig, tx: mpsc::Sender) -> Self { + let cursor = config.start_after_signature.clone(); + Self { + config, + tx, + client: reqwest::Client::new(), + cursor, + seen: Default::default(), + } + } + + async fn rpc( + &self, + method: &str, + params: serde_json::Value, + ) -> Result { + crate::evm::rpc_call(&self.client, &self.config.rpc_url, method, params).await + } + + pub async fn run(&mut self) -> Result<(), String> { + let program = bs58::encode(self.config.program_id).into_string(); + if self.cursor.is_none() { + // Start at the current tip: only withdrawals from now on. + let latest = self + .rpc( + "getSignaturesForAddress", + serde_json::json!([program, {"limit": 1, "commitment": "finalized"}]), + ) + .await?; + self.cursor = latest[0]["signature"].as_str().map(str::to_string); + } + info!(cursor = ?self.cursor, "hub outbound listener started"); + loop { + if let Err(e) = self.poll(&program).await { + if e == "channel closed" { + return Ok(()); + } + warn!(error = %e, "hub outbound poll failed"); + } + tokio::time::sleep(self.config.poll_interval).await; + } + } + + /// Fetch finalized signatures newer than the cursor (oldest first). + async fn new_signatures(&self, program: &str) -> Result, String> { + let mut all = Vec::new(); + let mut before: Option = None; + loop { + let mut opts = serde_json::json!({"limit": 1000, "commitment": "finalized"}); + if let Some(c) = &self.cursor { + opts["until"] = c.clone().into(); + } + if let Some(b) = &before { + opts["before"] = b.clone().into(); + } + let page = self + .rpc( + "getSignaturesForAddress", + serde_json::json!([program, opts]), + ) + .await?; + let page = page.as_array().cloned().unwrap_or_default(); + let full = page.len() == 1000; + before = page + .last() + .and_then(|s| s["signature"].as_str()) + .map(str::to_string); + all.extend(page); + if !full { + break; + } + } + all.reverse(); + Ok(all) + } + + async fn poll(&mut self, program: &str) -> Result<(), String> { + for sig_info in self.new_signatures(program).await? { + let Some(sig) = sig_info["signature"].as_str().map(str::to_string) else { + continue; + }; + if sig_info["err"].is_null() { + let tx = self + .rpc( + "getTransaction", + serde_json::json!([sig, {"encoding": "json", "commitment": "finalized", "maxSupportedTransactionVersion": 0}]), + ) + .await?; + if tx["meta"]["err"].is_null() { + let logs: Vec = + serde_json::from_value(tx["meta"]["logMessages"].clone()) + .unwrap_or_default(); + for ev in parse_hub_events(&logs, program) { + self.forward(&sig, ev).await?; + } + } + } + self.cursor = Some(sig); + } + Ok(()) + } + + async fn forward(&mut self, sig: &str, ev: OutboundMessageEvent) -> Result<(), String> { + let Some(message) = ev.to_message(&self.config.program_id) else { + warn!( + sig, + "outbound event hash does not match its fields; ignoring" + ); + return Ok(()); + }; + let mut gw = [0u8; 32]; + gw[12..].copy_from_slice(&self.config.evm_gateway); + if message.dst_chain != self.config.evm_chain_id || message.dst_gateway != gw { + return Ok(()); // addressed to another chain or gateway + } + if !self.seen.insert(ev.payload_hash) { + return Ok(()); + } + info!(sig, nonce = message.nonce, "hub withdrawal detected"); + self.tx + .send(OutboundEvent { + signature: sig.to_string(), + message, + }) + .await + .map_err(|_| "channel closed".to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const HUB: &str = "AKzpc9tvxfhLjj5AantKizK2YZgSjoyhLmYqRZy6b8Lz"; + + fn sample_event() -> (OutboundMessageEvent, Pubkey) { + let program = stx::pubkey(HUB).unwrap(); + let mut ev = OutboundMessageEvent { + payload_hash: [0; 32], + dst_chain: 1, + dst_gateway: [0x0B; 32], + nonce: 4, + action: 1, + sender: [0x22; 32], + asset: [0x33; 32], + amount: 5_000, + recipient: [0x44; 32], + extra_hash: crate::evm::keccak256(b""), + }; + let m = CanonicalMessage { + src_chain: HUB_CHAIN_ID, + src_gateway: program, + dst_chain: ev.dst_chain, + dst_gateway: ev.dst_gateway, + nonce: ev.nonce, + action: ev.action, + sender: ev.sender, + asset: ev.asset, + amount: ev.amount, + recipient: ev.recipient, + extra_hash: ev.extra_hash, + }; + ev.payload_hash = m.hash(); + (ev, program) + } + + fn encode(ev: &OutboundMessageEvent) -> String { + let mut b = stx::anchor_event_discriminator("OutboundMessageEvent").to_vec(); + b.extend_from_slice(&ev.payload_hash); + b.extend_from_slice(&ev.dst_chain.to_le_bytes()); + b.extend_from_slice(&ev.dst_gateway); + b.extend_from_slice(&ev.nonce.to_le_bytes()); + b.push(ev.action); + b.extend_from_slice(&ev.sender); + b.extend_from_slice(&ev.asset); + b.extend_from_slice(&ev.amount.to_le_bytes()); + b.extend_from_slice(&ev.recipient); + b.extend_from_slice(&ev.extra_hash); + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, b) + } + + #[test] + fn accepts_event_emitted_by_hub() { + let (ev, program) = sample_event(); + let logs = vec![ + format!("Program {HUB} invoke [1]"), + "Program log: Instruction: BurnForWithdrawal".to_string(), + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]".to_string(), + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success".to_string(), + format!("Program data: {}", encode(&ev)), + format!("Program {HUB} consumed 30000 of 200000 compute units"), + format!("Program {HUB} success"), + ]; + let parsed = parse_hub_events(&logs, HUB); + assert_eq!(parsed, vec![ev.clone()]); + assert!(parsed[0].to_message(&program).is_some()); + } + + /// An attacker program logging identical bytes must not be accepted, + /// whether invoked at top level or via CPI from the hub's transaction. + #[test] + fn rejects_lookalike_event_from_other_program() { + let (ev, _) = sample_event(); + let fake = "Fake111111111111111111111111111111111111111"; + let logs = vec![ + format!("Program {fake} invoke [1]"), + format!("Program data: {}", encode(&ev)), + format!("Program {fake} success"), + format!("Program {HUB} invoke [1]"), + format!("Program {fake} invoke [2]"), + format!("Program data: {}", encode(&ev)), + format!("Program {fake} success"), + format!("Program {HUB} success"), + ]; + assert!(parse_hub_events(&logs, HUB).is_empty()); + } + + #[test] + fn rejects_hash_mismatch() { + let (mut ev, program) = sample_event(); + ev.amount += 1; + assert!(ev.to_message(&program).is_none()); + } +} diff --git a/relayer/src/lib.rs b/relayer/src/lib.rs index 84b0738..5aa5d22 100644 --- a/relayer/src/lib.rs +++ b/relayer/src/lib.rs @@ -1,56 +1,70 @@ -pub mod amm; -pub mod atomic; -pub mod audit_trail; -pub mod batch; -pub mod benchmarks; -pub mod bitcoin_spv; -pub mod blob_space; -pub mod bounty; -pub mod byzantine_bridge; -pub mod circuitbreaker; -pub mod cosmos; -pub mod defi_integration; -pub mod enterprise; -pub mod events; -pub mod explorer; -pub mod fee; -pub mod finality; -pub mod formal_verification; -pub mod gas; -pub mod governance; -pub mod http_api; -pub mod intent; -pub mod kyc; -pub mod lending; -pub mod liquidity_mining; -pub mod listener; -pub mod metrics; -pub mod mev; -pub mod multisig; -pub mod network_opt; -pub mod nft_bridge; -pub mod nonce; -pub mod polygon; -pub mod privacy; -pub mod proof_batching; -pub mod proof_binding; -pub mod proof_perf; -pub mod prover; -pub mod ratelimit; -pub mod retry; -pub mod sdk_experience; -pub mod simulator; -pub mod sla; -pub mod solana_dex; -pub mod staking; -pub mod starknet; -pub mod submitter; -pub mod swap_routing; -pub mod validator_rewards; -pub mod verkle; -pub mod vesting; -pub mod wallet; -pub mod webhook; -pub mod wrapped; -pub mod xchain_messaging; +//! InterLink relayer. +//! +//! Only the modules in the first group are used by the `relayer` binary. +//! The second group is standalone library / simulation code (fee models, +//! staking and governance simulations, research prototypes). It is tested but +//! **not** wired into the running relayer and provides no runtime guarantees. + +// ─── Production pipeline (used by the relayer binary) ─────────────────────── +pub mod batch; +pub mod events; +pub mod evm; +pub mod evm_submitter; +pub mod evm_tx; +pub mod fee; +pub mod finality; +pub mod gas; +pub mod http_api; +pub mod hub_outbound; +pub mod listener; +pub mod metrics; +pub mod prover; +pub mod solana_tx; +pub mod submitter; +pub mod webhook; + +// ─── Experimental / simulation (not used by the relayer binary) ───────────── +pub mod amm; +pub mod atomic; +pub mod audit_trail; +pub mod benchmarks; +pub mod bitcoin_spv; +pub mod blob_space; +pub mod bounty; +pub mod byzantine_bridge; +pub mod circuitbreaker; +pub mod cosmos; +pub mod defi_integration; +pub mod enterprise; +pub mod explorer; +pub mod governance; +pub mod intent; +pub mod kyc; +pub mod lending; +pub mod liquidity_mining; +pub mod mev; +pub mod multisig; +pub mod network_opt; +pub mod nft_bridge; +pub mod nonce; +pub mod polygon; +pub mod privacy; +pub mod proof_batching; +pub mod proof_binding; +pub mod proof_perf; +pub mod ratelimit; +pub mod retry; +pub mod sdk_experience; +pub mod simulator; +pub mod sla; +pub mod solana_dex; +pub mod staking; +pub mod starknet; +pub mod swap_routing; +pub mod validator_rewards; +pub mod verkle; +pub mod vesting; +pub mod wallet; +pub mod wrapped; +pub mod xchain_messaging; pub mod zk_research; diff --git a/relayer/src/liquidity_mining.rs b/relayer/src/liquidity_mining.rs index e5500ce..d8f9760 100644 --- a/relayer/src/liquidity_mining.rs +++ b/relayer/src/liquidity_mining.rs @@ -449,10 +449,7 @@ impl TvlGrowthTracker { .iter() .rev() .skip(1) // skip latest - .min_by_key(|s| { - - s.timestamp.abs_diff(target_ts) - })?; + .min_by_key(|s| s.timestamp.abs_diff(target_ts))?; if prior.tvl_usd_cents == 0 { return None; diff --git a/relayer/src/listener.rs b/relayer/src/listener.rs index 8832a53..d3867c9 100644 --- a/relayer/src/listener.rs +++ b/relayer/src/listener.rs @@ -1,37 +1,54 @@ -//! WebSocket event listener for EVM Gateway events. +//! Event listener for EVM Gateway events (HTTP polling). //! -//! Connects to an EVM node via WebSocket, subscribes to Gateway contract -//! events (MessagePublished, SwapInitiated, NFTLocked), and sends parsed -//! events to the prover pipeline via an mpsc channel. +//! Polls `eth_getLogs` over a block cursor for the gateway's +//! `MessagePublished` event and forwards parsed events to the pipeline. //! -//! Features: -//! - Automatic reconnection with exponential backoff -//! - Chain reorg detection via block hash tracking -//! - Event deduplication via nonce tracking +//! Polling instead of a WebSocket subscription means a dropped connection can +//! never silently skip blocks: the cursor only advances after a range has been +//! fetched and forwarded. +//! +//! Safety properties: +//! - The RPC's chain ID must match the configured source chain. +//! - Logs flagged `removed` are dropped. +//! - Malformed logs are rejected, never filled with zero defaults. +//! - Dedup is by (payload hash, block hash): a transaction re-included in a +//! new block after a reorg is processed again. That is safe because hub +//! attestation and EVM execution are both idempotent per payload hash. +//! +//! Reorgs after an event is forwarded are caught by +//! `finality::wait_for_finalized_inclusion`, which re-checks the receipt. -use crate::events::{DepositEvent, GatewayEvent}; +use crate::events::{CanonicalMessage, GatewayEvent, MessageEvent}; +use crate::evm::{self, RpcLog}; +use std::time::Duration; use tokio::sync::mpsc; use tracing::{error, info, warn}; /// Configuration for the event listener #[derive(Clone, Debug)] pub struct ListenerConfig { - /// WebSocket RPC URL (e.g., ws://localhost:8545) - pub ws_rpc_url: String, + /// HTTP JSON-RPC URL of the source chain + pub rpc_url: String, /// Gateway contract address on the source chain pub gateway_address: String, - /// Chain ID of the source chain + /// EIP-155 chain ID of the source chain (verified against the RPC) pub chain_id: u64, - /// Maximum reconnection attempts before giving up - pub max_reconnect_attempts: u32, + /// Consecutive RPC failures tolerated before the listener gives up + pub max_consecutive_errors: u32, + /// Block to start scanning from (None = current head) + pub start_block: Option, + /// Delay between polls + pub poll_interval: Duration, + /// Maximum blocks per `eth_getLogs` request (providers cap this) + pub max_block_range: u64, } -/// Event listener that watches Gateway contracts for cross-chain events. +/// Event listener that watches the gateway for cross-chain events. pub struct EventListener { config: ListenerConfig, event_tx: mpsc::Sender, - /// Track seen nonces for deduplication (handles reorgs) - seen_nonces: std::collections::HashSet, + client: reqwest::Client, + seen: std::collections::HashSet<([u8; 32], [u8; 32])>, } impl EventListener { @@ -39,375 +56,337 @@ impl EventListener { Self { config, event_tx, - seen_nonces: std::collections::HashSet::new(), + client: reqwest::Client::new(), + seen: std::collections::HashSet::new(), } } - /// Run the event listener loop. Connects via WebSocket, subscribes to - /// Gateway events, and forwards them to the prover pipeline. - /// Automatically reconnects on disconnection with exponential backoff. + async fn rpc( + &self, + method: &str, + params: serde_json::Value, + ) -> Result { + evm::rpc_call(&self.client, &self.config.rpc_url, method, params).await + } + + async fn head(&self) -> Result { + let v = self.rpc("eth_blockNumber", serde_json::json!([])).await?; + evm::quantity(v.as_str().ok_or("eth_blockNumber: not a string")?) + } + + /// Run until the event channel closes or RPC errors exceed the limit. pub async fn run(&mut self) -> Result<(), String> { - let mut backoff_ms = 1000u64; - let mut attempts = 0u32; + // The chain ID is part of every payload hash. Refuse to run against the + // wrong network rather than relay mislabelled messages. + let id = self.rpc("eth_chainId", serde_json::json!([])).await?; + let rpc_chain_id = evm::quantity(id.as_str().ok_or("eth_chainId: not a string")?)?; + if rpc_chain_id != self.config.chain_id { + return Err(format!( + "RPC chain id {} does not match configured CHAIN_ID {}", + rpc_chain_id, self.config.chain_id + )); + } + evm::hex20(&self.config.gateway_address).map_err(|e| format!("GATEWAY_ADDRESS: {e}"))?; - loop { - info!( - url = %self.config.ws_rpc_url, - chain_id = self.config.chain_id, - "connecting to EVM WebSocket" - ); + let mut cursor = match self.config.start_block { + Some(b) => b, + None => self.head().await?, + }; + info!( + chain_id = rpc_chain_id, + from_block = cursor, + "listener started" + ); - match self.subscribe_and_process().await { - Ok(()) => { - info!("listener completed normally"); + let mut errors = 0u32; + loop { + match self.poll_once(cursor).await { + Ok(Some(next)) => { + cursor = next; + errors = 0; + continue; // more blocks may be waiting; poll again immediately + } + Ok(None) => errors = 0, + Err(e) if e == CHANNEL_CLOSED => { + info!("event channel closed, stopping listener"); return Ok(()); } Err(e) => { - attempts += 1; - if attempts >= self.config.max_reconnect_attempts { - error!(attempts, "max reconnection attempts reached, giving up"); + errors += 1; + if errors >= self.config.max_consecutive_errors { + error!(errors, error = %e, "listener giving up"); return Err(format!( - "listener failed after {} attempts: {}", - attempts, e + "listener failed after {errors} consecutive errors: {e}" )); } - - warn!( - error = %e, - backoff_ms, - attempt = attempts, - "WebSocket disconnected, reconnecting" - ); - - tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await; - backoff_ms = (backoff_ms * 2).min(30_000); // cap at 30s + warn!(errors, error = %e, "listener poll failed; retrying"); } } + tokio::time::sleep(self.config.poll_interval).await; } } - /// Subscribe to Gateway events and process them. - /// This is the inner loop that gets retried on disconnection. - async fn subscribe_and_process(&mut self) -> Result<(), String> { - // Connect via ethers-rs WebSocket provider - let provider = - ethers_providers::Provider::::connect(&self.config.ws_rpc_url) - .await - .map_err(|e| format!("ws connect failed: {}", e))?; - - info!("WebSocket connected, subscribing to Gateway events"); - - // Subscribe to logs from the gateway contract - let gateway_addr: ethers_core::types::Address = self - .config - .gateway_address - .parse() - .map_err(|e| format!("invalid gateway address: {}", e))?; - - // MessagePublished event topic - let message_published_topic = - ethers_core::utils::keccak256(b"MessagePublished(uint64,uint64,address,bytes32,bytes)"); - // SwapInitiated event topic - let swap_initiated_topic = ethers_core::utils::keccak256( - b"SwapInitiated(uint64,address,address,uint256,address,address,uint256,uint64,bytes,bytes32)" - ); - // NFTLocked event topic - let nft_locked_topic = ethers_core::utils::keccak256( - b"NFTLocked(uint64,address,address,uint256,uint64,bytes32,bytes32)", - ); - - let filter = ethers_core::types::Filter::new() - .address(gateway_addr) - .topic0(vec![ - ethers_core::types::H256::from(message_published_topic), - ethers_core::types::H256::from(swap_initiated_topic), - ethers_core::types::H256::from(nft_locked_topic), - ]); - - use ethers_providers::Middleware; - use ethers_providers::StreamExt; - let mut stream = provider - .subscribe_logs(&filter) - .await - .map_err(|e| format!("subscribe failed: {}", e))?; - - while let Some(log) = stream.next().await { - if let Some(event) = self.parse_log(&log) { - let nonce = event.sequence(); - - // Deduplication - if self.seen_nonces.contains(&nonce) { - tracing::debug!(nonce, "duplicate event, skipping"); - continue; - } - self.seen_nonces.insert(nonce); - - info!( - nonce, - block = event.block_number(), - "new Gateway event detected" - ); - - if self.event_tx.send(event).await.is_err() { - warn!("event channel closed, stopping listener"); - return Ok(()); - } - } + /// Fetch and forward one block range. Returns the next cursor, or None if + /// there were no new blocks. + async fn poll_once(&mut self, cursor: u64) -> Result, String> { + let head = self.head().await?; + if cursor > head { + return Ok(None); } + let to = head.min(cursor + self.config.max_block_range.max(1) - 1); + let logs = self + .rpc( + "eth_getLogs", + serde_json::json!([{ + "fromBlock": format!("0x{:x}", cursor), + "toBlock": format!("0x{:x}", to), + "address": self.config.gateway_address, + "topics": [evm::to_hex(&evm::message_published_topic())], + }]), + ) + .await?; + let mut logs: Vec = + serde_json::from_value(logs).map_err(|e| format!("eth_getLogs decode: {e}"))?; + logs.sort_by_key(|l| { + ( + l.block_number + .as_deref() + .and_then(|b| evm::quantity(b).ok()), + l.log_index.as_deref().and_then(|i| evm::quantity(i).ok()), + ) + }); - Err("WebSocket stream ended".to_string()) + for log in &logs { + self.handle_log(log).await?; + } + if !logs.is_empty() { + info!(from = cursor, to, count = logs.len(), "scanned block range"); + } + Ok(Some(to + 1)) } - /// Parse a raw log into a typed GatewayEvent. - fn parse_log(&self, log: ðers_core::types::Log) -> Option { - let topic0 = log.topics.first()?; - let block_number = log.block_number?.as_u64(); - - let message_published_topic = ethers_core::types::H256::from( - ethers_core::utils::keccak256(b"MessagePublished(uint64,uint64,address,bytes32,bytes)"), - ); - - if *topic0 == message_published_topic { - // Parse MessagePublished(uint64 indexed nonce, uint64 destinationChain, - // address sender, bytes32 payloadHash, bytes payload) - // Topics: [topic0, nonce_indexed] - // Data: [destinationChain(u64), sender(address), payloadHash(bytes32), payload(bytes)] - // ABI-encoded: dest_chain is padded to 32 bytes, sender padded to 32 bytes, etc. - let nonce_topic = log.topics.get(1)?; - let nonce = u64::from_be_bytes(nonce_topic.as_bytes()[24..32].try_into().ok()?); - - let data = &log.data; - // ABI decode the non-indexed parameters: - // offset 0..32: destinationChain (uint64, right-padded in 32 bytes) - // offset 32..64: sender (address, left-padded in 32 bytes) - // offset 64..96: payloadHash (bytes32) - // offset 96+: payload (dynamic bytes with offset + length prefix) - - let destination_chain = if data.len() >= 32 { - u64::from_be_bytes(data[24..32].try_into().ok()?) - } else { - 0u64 - }; - - let mut sender = [0u8; 20]; - if data.len() >= 64 { - sender.copy_from_slice(&data[44..64]); - } - - let mut payload_hash = [0u8; 32]; - if data.len() >= 96 { - payload_hash.copy_from_slice(&data[64..96]); + async fn handle_log(&mut self, log: &RpcLog) -> Result<(), String> { + if log.removed { + warn!(tx = ?log.transaction_hash, "dropping removed log"); + return Ok(()); + } + let event = match parse_message_published(log, self.config.chain_id) { + Ok(ev) => ev, + Err(e) => { + warn!(tx = ?log.transaction_hash, error = %e, "rejecting malformed gateway log"); + return Ok(()); } - - // Parse amount from the payload if available (first 32 bytes of payload data) - let amount = if data.len() >= 160 { - // payload offset at data[96..128], then length at payload_start..+32, then data - let payload_offset = u64::from_be_bytes(data[120..128].try_into().ok()?) as usize; - if data.len() > 96 + payload_offset + 32 { - let len_start = 96 + payload_offset; - u64::from_be_bytes(data[len_start + 24..len_start + 32].try_into().ok()?) - } else { - 0u64 - } - } else { - 0u64 - }; - - return Some(GatewayEvent::Deposit(DepositEvent { - block_number, - tx_hash: log.transaction_hash.map(|h| h.0).unwrap_or([0u8; 32]), - sequence: nonce, - sender, - recipient: sender.to_vec(), // Default to sender as recipient - amount: amount as u128, - destination_chain: destination_chain as u16, - payload_hash, - })); + }; + if !self.seen.insert((event.payload_hash, event.block_hash)) { + return Ok(()); } - - // Parse SwapInitiated event - // Signature: SwapInitiated(uint64 indexed nonce, address indexed sender, - // address recipient, uint256 amountIn, address tokenIn, address tokenOut, - // uint256 minAmountOut, uint64 destinationChain, bytes swapData, bytes32 payloadHash) - // Topics: [topic0, nonce, sender] - // Data ABI layout (head = 8 slots = 256 bytes): - // slot 0 (0..32): recipient (address, last 20 bytes) - // slot 1 (32..64): amountIn (uint256) - // slot 2 (64..96): tokenIn (address, last 20 bytes) - // slot 3 (96..128): tokenOut (address, last 20 bytes) - // slot 4 (128..160): minAmountOut (uint256) - // slot 5 (160..192): destinationChain (uint64, last 8 bytes) - // slot 6 (192..224): swapData offset pointer - // slot 7 (224..256): payloadHash (bytes32) - // tail (256..): swapData length + bytes - let swap_topic = ethers_core::types::H256::from( - ethers_core::utils::keccak256( - b"SwapInitiated(uint64,address,address,uint256,address,address,uint256,uint64,bytes,bytes32)" - ) + info!( + nonce = event.message.nonce, + block = event.block_number, + "new Gateway event detected" ); + self.event_tx + .send(GatewayEvent::Message(event)) + .await + .map_err(|_| CHANNEL_CLOSED.to_string()) + } +} - if *topic0 == swap_topic { - let nonce_topic = log.topics.get(1)?; - let nonce = u64::from_be_bytes(nonce_topic.as_bytes()[24..32].try_into().ok()?); - - // sender is topic2 (indexed address) - let mut sender = [0u8; 20]; - if let Some(sender_topic) = log.topics.get(2) { - sender.copy_from_slice(&sender_topic.as_bytes()[12..32]); - } - - let data = &log.data; +const CHANNEL_CLOSED: &str = "event channel closed"; - let mut recipient = [0u8; 20]; - if data.len() >= 32 { - recipient.copy_from_slice(&data[12..32]); - } +/// Read 32-byte word `i` of ABI data. +fn word(data: &[u8], i: usize) -> Result<&[u8], String> { + data.get(i * 32..(i + 1) * 32) + .ok_or_else(|| format!("data too short for word {i}")) +} - let amount_in: u128 = if data.len() >= 64 { - u128::from_be_bytes(data[48..64].try_into().ok()?) - } else { - 0 - }; +/// Decode a big-endian uint word that must fit in `bytes` bytes. +fn uint_word(w: &[u8], bytes: usize) -> Result { + if w[..32 - bytes].iter().any(|b| *b != 0) { + return Err(format!("value does not fit in {} bytes", bytes)); + } + Ok(w[32 - bytes..] + .iter() + .fold(0u128, |acc, b| (acc << 8) | *b as u128)) +} - let mut token_in = [0u8; 20]; - if data.len() >= 96 { - token_in.copy_from_slice(&data[76..96]); - } +/// Decode `MessagePublished(bytes32 indexed payloadHash, uint64 indexed nonce, +/// uint64 dstChain, bytes32 dstGateway, uint8 action, bytes32 sender, +/// bytes32 asset, uint256 amount, bytes32 recipient, bytes32 extraHash, bytes data)`. +/// +/// Strict: canonical Solidity encoding only, every integer range-checked, and +/// the recomputed payload hash must equal the emitted one. +pub fn parse_message_published(log: &RpcLog, src_chain: u64) -> Result { + if log.topics.len() != 3 || evm::hex32(&log.topics[0])? != evm::message_published_topic() { + return Err("not a MessagePublished log".into()); + } + let payload_hash = evm::hex32(&log.topics[1])?; + let nonce = uint_word(&evm::hex32(&log.topics[2])?, 8)? as u64; - let mut token_out = [0u8; 20]; - if data.len() >= 128 { - token_out.copy_from_slice(&data[108..128]); - } + let data = evm::from_hex(&log.data)?; + let w32 = |i| -> Result<[u8; 32], String> { Ok(word(&data, i)?.try_into().unwrap()) }; - let min_amount_out: u128 = if data.len() >= 160 { - u128::from_be_bytes(data[144..160].try_into().ok()?) - } else { - 0 - }; + // Dynamic `bytes` must start right after the 9 head words. + if uint_word(word(&data, 8)?, 8)? != 9 * 32 { + return Err("non-canonical offset for data".into()); + } + let len = uint_word(word(&data, 9)?, 8)? as usize; + let padded = len.div_ceil(32) * 32; + if data.len() != 10 * 32 + padded { + return Err("data length does not match encoding".into()); + } + let tail = data[10 * 32..10 * 32 + len].to_vec(); + + let address = evm::hex20(&log.address)?; + let mut src_gateway = [0u8; 32]; + src_gateway[12..].copy_from_slice(&address); + + let message = CanonicalMessage { + src_chain, + src_gateway, + dst_chain: uint_word(word(&data, 0)?, 8)? as u64, + dst_gateway: w32(1)?, + nonce, + action: uint_word(word(&data, 2)?, 1)? as u8, + sender: w32(3)?, + asset: w32(4)?, + amount: uint_word(word(&data, 5)?, 16)?, + recipient: w32(6)?, + extra_hash: w32(7)?, + }; + if message.hash() != payload_hash { + return Err("emitted payload hash does not match event fields".into()); + } - let destination_chain: u16 = if data.len() >= 192 { - u16::from_be_bytes(data[190..192].try_into().ok()?) - } else { - 0 - }; + Ok(MessageEvent { + block_number: evm::quantity( + log.block_number + .as_deref() + .ok_or("log has no block number")?, + )?, + block_hash: evm::hex32(log.block_hash.as_deref().ok_or("log has no block hash")?)?, + tx_hash: evm::hex32( + log.transaction_hash + .as_deref() + .ok_or("log has no tx hash")?, + )?, + gateway: address, + payload_hash, + message, + data: tail, + }) +} - // swapData: offset at slot 6 (bytes 192..224), tail starts at byte 256 - let swap_data = if data.len() >= 288 { - let swap_data_len = u64::from_be_bytes(data[280..288].try_into().ok()?) as usize; - let swap_data_end = 288 + swap_data_len; - if data.len() >= swap_data_end { - data[288..swap_data_end].to_vec() - } else { - vec![] - } - } else { - vec![] - }; +#[cfg(test)] +mod tests { + use super::*; + use crate::events::{ACTION_TRANSFER, HUB_CHAIN_ID}; - // payloadHash at slot 7 (bytes 224..256) - let mut payload_hash = [0u8; 32]; - if data.len() >= 256 { - payload_hash.copy_from_slice(&data[224..256]); - } + fn w_u(v: u128) -> [u8; 32] { + let mut w = [0u8; 32]; + w[16..].copy_from_slice(&v.to_be_bytes()); + w + } - return Some(GatewayEvent::Swap(crate::events::SwapInitiatedEvent { - block_number, - tx_hash: log.transaction_hash.map(|h| h.0).unwrap_or([0u8; 32]), - sequence: nonce, - sender, - recipient, - amount_in, - token_in, - token_out, - min_amount_out, - destination_chain, - swap_data, - payload_hash, - })); + fn sample(tamper_amount: bool, extra: &[u8]) -> (RpcLog, CanonicalMessage) { + let mut gw = [0u8; 20]; + gw[18..].copy_from_slice(&[0xBE, 0xEF]); + let mut src_gateway = [0u8; 32]; + src_gateway[12..].copy_from_slice(&gw); + let msg = CanonicalMessage { + src_chain: 1, + src_gateway, + dst_chain: HUB_CHAIN_ID, + dst_gateway: [0x7B; 32], + nonce: 3, + action: ACTION_TRANSFER, + sender: [0x22; 32], + asset: [0; 32], + amount: 5_000_000_000_000_000_000, + recipient: [0x44; 32], + extra_hash: evm::keccak256(extra), + }; + let amount = if tamper_amount { + msg.amount + 1 + } else { + msg.amount + }; + let mut data = Vec::new(); + for w in [ + w_u(msg.dst_chain as u128), + msg.dst_gateway, + w_u(msg.action as u128), + msg.sender, + msg.asset, + w_u(amount), + msg.recipient, + msg.extra_hash, + w_u(9 * 32), + w_u(extra.len() as u128), + ] { + data.extend_from_slice(&w); } + data.extend_from_slice(extra); + data.resize(10 * 32 + extra.len().div_ceil(32) * 32, 0); + + let log = RpcLog { + address: evm::to_hex(&gw), + topics: vec![ + evm::to_hex(&evm::message_published_topic()), + evm::to_hex(&msg.hash()), + evm::to_hex(&w_u(msg.nonce as u128)), + ], + data: evm::to_hex(&data), + block_number: Some("0x64".into()), + block_hash: Some(evm::to_hex(&[9; 32])), + transaction_hash: Some(evm::to_hex(&[8; 32])), + log_index: Some("0x0".into()), + removed: false, + }; + (log, msg) + } - // Parse NFTLocked event - // Signature: NFTLocked(uint64 indexed nonce, address indexed sender, - // address nftContract, uint256 tokenId, uint64 destinationChain, - // bytes32 destinationRecipient, bytes32 nftHash) - // Topics: [topic0, nonce, sender] - // Data ABI layout (all static, 5 slots = 160 bytes): - // slot 0 (0..32): nftContract (address, last 20 bytes) - // slot 1 (32..64): tokenId (uint256) - // slot 2 (64..96): destinationChain (uint64, last 8 bytes) - // slot 3 (96..128): destinationRecipient (bytes32) - // slot 4 (128..160): nftHash (bytes32) - let nft_topic = ethers_core::types::H256::from(ethers_core::utils::keccak256( - b"NFTLocked(uint64,address,address,uint256,uint64,bytes32,bytes32)", - )); - - if *topic0 == nft_topic { - let nonce_topic = log.topics.get(1)?; - let nonce = u64::from_be_bytes(nonce_topic.as_bytes()[24..32].try_into().ok()?); - - let mut sender = [0u8; 20]; - if let Some(sender_topic) = log.topics.get(2) { - sender.copy_from_slice(&sender_topic.as_bytes()[12..32]); - } - - let data = &log.data; - - let mut nft_contract = [0u8; 20]; - if data.len() >= 32 { - nft_contract.copy_from_slice(&data[12..32]); - } - - let mut token_id = [0u8; 32]; - if data.len() >= 64 { - token_id.copy_from_slice(&data[32..64]); - } - - let destination_chain: u16 = if data.len() >= 96 { - u16::from_be_bytes(data[94..96].try_into().ok()?) - } else { - 0 - }; - - let mut destination_recipient = [0u8; 32]; - if data.len() >= 128 { - destination_recipient.copy_from_slice(&data[96..128]); - } - - let mut nft_hash = [0u8; 32]; - if data.len() >= 160 { - nft_hash.copy_from_slice(&data[128..160]); - } + #[test] + fn parses_message_published() { + let (log, msg) = sample(false, b"hello world, this payload spans two words!"); + let ev = parse_message_published(&log, 1).unwrap(); + assert_eq!(ev.message, msg); + assert_eq!(ev.payload_hash, msg.hash()); + assert_eq!(ev.block_number, 100); + assert_eq!(ev.block_hash, [9; 32]); + assert_eq!(ev.data, b"hello world, this payload spans two words!"); + } - return Some(GatewayEvent::NFTLock(crate::events::NFTLockedEvent { - block_number, - tx_hash: log.transaction_hash.map(|h| h.0).unwrap_or([0u8; 32]), - sequence: nonce, - sender, - nft_contract, - token_id, - destination_chain, - destination_recipient, - nft_hash, - })); - } + #[test] + fn rejects_fields_that_do_not_match_hash() { + let (log, _) = sample(true, b""); + assert!(parse_message_published(&log, 1).is_err()); + } - None + #[test] + fn rejects_wrong_source_chain() { + let (log, _) = sample(false, b""); + assert!(parse_message_published(&log, 42161).is_err()); } -} -#[cfg(test)] -mod tests { - use super::*; + #[test] + fn rejects_truncated_or_padded_data() { + let (mut log, _) = sample(false, b""); + log.data = log.data[..2 + 64 * 2].to_string(); + assert!(parse_message_published(&log, 1).is_err()); + + let (mut log, _) = sample(false, b""); + log.data.push_str("00"); + assert!(parse_message_published(&log, 1).is_err()); + } #[test] - fn test_listener_config() { - let config = ListenerConfig { - ws_rpc_url: "ws://localhost:8545".to_string(), - gateway_address: "0x0000000000000000000000000000000000000000".to_string(), - chain_id: 1, - max_reconnect_attempts: 5, - }; - assert_eq!(config.chain_id, 1); - assert_eq!(config.max_reconnect_attempts, 5); + fn rejects_out_of_range_integers() { + // action word with a non-zero high byte must not be truncated to u8 + let (mut log, _) = sample(false, b""); + let mut data = evm::from_hex(&log.data).unwrap(); + data[2 * 32] = 1; + log.data = evm::to_hex(&data); + assert!(parse_message_published(&log, 1).is_err()); } } diff --git a/relayer/src/main.rs b/relayer/src/main.rs index 7dd1e01..1d47529 100644 --- a/relayer/src/main.rs +++ b/relayer/src/main.rs @@ -1,81 +1,109 @@ use relayer::batch::BatchCollector; -use relayer::events::GatewayEvent; -use relayer::fee; -use relayer::finality::{wait_for_finality, wait_for_finality_ws}; +use relayer::events::{GatewayEvent, ACTION_SWAP, ACTION_TRANSFER, HUB_CHAIN_ID}; +use relayer::evm_submitter::{EvmSubmitter, EvmSubmitterConfig, ReleaseOutcome}; +use relayer::finality::{wait_for_finalized_inclusion, InclusionError}; use relayer::http_api; +use relayer::hub_outbound::{HubOutboundConfig, HubOutboundListener, OutboundEvent}; use relayer::listener::{EventListener, ListenerConfig}; use relayer::metrics::Metrics; use relayer::prover::ProverEngine; -use relayer::submitter::{ProofSubmitter, SubmitterConfig}; +use relayer::submitter::{HubOutcome, ProofSubmitter, SubmitterConfig}; use std::sync::Arc; use std::time::Duration; use tokio::sync::{mpsc, Semaphore}; use tracing::{error, info, warn}; -/// Flush a batch when this many events accumulate (target: beat Wormhole's 1-20 per VAA) +/// Flush a batch when this many events accumulate const BATCH_MAX_SIZE: usize = 100; /// Flush a batch after this many seconds regardless of size const BATCH_FLUSH_SECS: u64 = 5; /// Alert threshold: proof generation taking longer than this is suspicious const PROOF_GEN_ALERT_MS: u128 = 1_000; -/// Alert threshold: total settlement finality time -const SETTLEMENT_ALERT_MS: u128 = 60_000; +/// Alert threshold: total settlement time (Ethereum finality alone is ~13 min) +const SETTLEMENT_ALERT_MS: u128 = 30 * 60_000; -/// Log retention period in days (Phase 10: 30 days default, adjust per volume) +/// How often to poll for the finalized head, and how long to wait at most. +const FINALITY_POLL: Duration = Duration::from_secs(12); +const FINALITY_TIMEOUT: Duration = Duration::from_secs(60 * 60); + +/// Attempts per event for transient failures (RPC, submission) before giving up. +const MAX_EVENT_ATTEMPTS: u32 = 8; + +/// Log retention period in days const LOG_RETENTION_DAYS: u64 = 30; +fn env_or(key: &str, default: &str) -> String { + std::env::var(key).unwrap_or_else(|_| default.to_string()) +} + +/// Load the EVM signing key for hub → EVM delivery, from EVM_PRIVATE_KEY_FILE +/// (preferred) or EVM_PRIVATE_KEY. None disables that direction. +fn load_evm_key() -> Result, String> { + let hex = match ( + std::env::var("EVM_PRIVATE_KEY_FILE"), + std::env::var("EVM_PRIVATE_KEY"), + ) { + (Ok(path), _) => std::fs::read_to_string(&path) + .map_err(|e| format!("EVM_PRIVATE_KEY_FILE {path}: {e}"))?, + (Err(_), Ok(hex)) => hex, + _ => return Ok(None), + }; + relayer::evm_tx::signing_key_from_hex(&hex).map(Some) +} + +fn backoff_step(backoff: &mut Duration) { + *backoff = (*backoff * 2).min(Duration::from_secs(300)); +} + #[tokio::main] async fn main() -> Result<(), Box> { - // JSON structured logging for Datadog/Splunk compatibility (Phase 10) - // Set RUST_LOG=debug for verbose output, default to info - let log_format = std::env::var("LOG_FORMAT").unwrap_or_else(|_| "json".to_string()); - if log_format == "json" { + // JSON structured logging. Set RUST_LOG=debug for verbose output. + let filter = tracing_subscriber::EnvFilter::from_default_env() + .add_directive("relayer=info".parse().unwrap()); + if env_or("LOG_FORMAT", "json") == "json" { tracing_subscriber::fmt() .json() .with_current_span(false) - .with_env_filter( - tracing_subscriber::EnvFilter::from_default_env() - .add_directive("relayer=info".parse().unwrap()), - ) + .with_env_filter(filter) .init(); } else { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::from_default_env() - .add_directive("relayer=info".parse().unwrap()), - ) - .init(); + tracing_subscriber::fmt().with_env_filter(filter).init(); } - // Load config from environment or use dev defaults. - let chain_id: u64 = std::env::var("CHAIN_ID") - .unwrap_or_else(|_| "1".to_string()) + // EIP-155 chain ID of the EVM chain. Verified against the RPC by the listener. + let chain_id: u64 = env_or("CHAIN_ID", "1") .parse() - .unwrap_or(1); - let ws_rpc_url = std::env::var("EVM_WS_RPC_URL") + .map_err(|e| format!("CHAIN_ID: {e}"))?; + let http_rpc_url = std::env::var("EVM_HTTP_RPC_URL") .or_else(|_| std::env::var("EVM_RPC_URL")) - .unwrap_or_else(|_| "ws://localhost:8545".to_string()); - let http_rpc_url = - std::env::var("EVM_HTTP_RPC_URL").unwrap_or_else(|_| "http://localhost:8545".to_string()); - let solana_rpc_url = std::env::var("SOLANA_RPC_URL") - .unwrap_or_else(|_| "https://api.devnet.solana.com".to_string()); - let gateway_address = std::env::var("GATEWAY_ADDRESS") - .unwrap_or_else(|_| "0x0000000000000000000000000000000000000000".to_string()); - let program_id = std::env::var("HUB_PROGRAM_ID") - .unwrap_or_else(|_| "AKzpc9tvxfhLjj5AantKizK2YZgSjoyhLmYqRZy6b8Lz".to_string()); - let keypair_path = - std::env::var("KEYPAIR_PATH").unwrap_or_else(|_| "~/.config/solana/id.json".to_string()); - - // Limit concurrent proof generation to CPU count + .unwrap_or_else(|_| "http://localhost:8545".to_string()); + let solana_rpc_url = env_or("SOLANA_RPC_URL", "https://api.devnet.solana.com"); + let gateway_address = env_or( + "GATEWAY_ADDRESS", + "0x0000000000000000000000000000000000000000", + ); + let program_id = env_or( + "HUB_PROGRAM_ID", + "AKzpc9tvxfhLjj5AantKizK2YZgSjoyhLmYqRZy6b8Lz", + ); + let keypair_path = env_or("KEYPAIR_PATH", "~/.config/solana/id.json"); + let proving_key_path = env_or("PROVING_KEY_PATH", "keys/interlink_pk.bin"); + let allow_dev_setup = env_or("ALLOW_DEV_SETUP", "false") == "true"; + let start_block = std::env::var("START_BLOCK") + .ok() + .and_then(|s| s.parse().ok()); + let api_addr = env_or("API_ADDR", "127.0.0.1:8080"); + + let hub_program = + relayer::solana_tx::pubkey(&program_id).map_err(|e| format!("HUB_PROGRAM_ID: {e}"))?; + let gateway = + relayer::evm::hex20(&gateway_address).map_err(|e| format!("GATEWAY_ADDRESS: {e}"))?; + let evm_key = load_evm_key()?; + let max_concurrent_provers = std::thread::available_parallelism() .map(|n| n.get()) .unwrap_or(4); - - // HTTP API server address (Phase 5 / Phase 10) - let api_addr = std::env::var("API_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string()); - - // Shared metrics (Phase 10) let metrics = Metrics::new(); info!( @@ -85,46 +113,98 @@ async fn main() -> Result<(), Box> { batch_flush_secs = BATCH_FLUSH_SECS, log_retention_days = LOG_RETENTION_DAYS, api_addr, + hub_to_evm = evm_key.is_some(), "interlink relayer starting" ); - // Initialize prover engine (Groth16 trusted setup — once at startup) + // Load the persistent proving key. The VK registered on-chain must come + // from this same file (`cargo run --bin export-vk`). let prover = ProverEngine::new(6); - info!("initializing groth16 prover (trusted setup)..."); - prover.initialize().await.map_err(|e| { - error!(error = %e, "prover initialization failed"); - e - })?; + prover + .load_or_create(std::path::Path::new(&proving_key_path), allow_dev_setup) + .await + .map_err(|e| { + error!(error = %e, "prover initialization failed"); + e + })?; info!("prover ready"); - // Configure listener let listener_config = ListenerConfig { - ws_rpc_url: ws_rpc_url.clone(), + rpc_url: http_rpc_url.clone(), gateway_address, chain_id, - max_reconnect_attempts: 10, + max_consecutive_errors: 30, + start_block, + poll_interval: Duration::from_secs(env_or("POLL_INTERVAL_SECS", "4").parse().unwrap_or(4)), + max_block_range: env_or("MAX_BLOCK_RANGE", "2000").parse().unwrap_or(2000), }; - - // Configure submitter - let submitter_config = SubmitterConfig { + let submitter = ProofSubmitter::new(SubmitterConfig { rpc_url: solana_rpc_url.clone(), program_id, keypair_path, max_retries: 3, source_chain_id: chain_id, - }; + }); - // Spawn HTTP API server (Phase 5 / Phase 10): /health, /quote, /compare, /metrics + // HTTP API (/health, /quote, /compare, /metrics). Binds to localhost by + // default; set API_ADDR=0.0.0.0:8080 deliberately to expose it. let api_metrics = metrics.clone(); let api_addr_clone = api_addr.clone(); tokio::spawn(async move { http_api::serve(&api_addr_clone, api_metrics).await; }); - // Channel: listener → batch pipeline. Buffer 1024 so listener never blocks. - let (tx, mut rx) = mpsc::channel::(1024); + // Bounds concurrent *proving* only. Finality waits do not hold a permit. + let prover_slots = Arc::new(Semaphore::new(max_concurrent_provers)); + + // ── Hub → EVM (withdrawals), only if an EVM key is configured ───────────── + if let Some(key) = evm_key { + let evm = EvmSubmitter::new( + EvmSubmitterConfig { + rpc_url: http_rpc_url.clone(), + chain_id, + gateway, + }, + key, + ); + info!(address = %relayer::evm::to_hex(&evm.address()), "hub → EVM delivery enabled"); + let (out_tx, mut out_rx) = mpsc::channel::(1024); + let mut hub_listener = HubOutboundListener::new( + HubOutboundConfig { + rpc_url: solana_rpc_url.clone(), + program_id: hub_program, + evm_chain_id: chain_id, + evm_gateway: gateway, + poll_interval: Duration::from_secs( + env_or("HUB_POLL_INTERVAL_SECS", "5").parse().unwrap_or(5), + ), + start_after_signature: std::env::var("HUB_START_SIGNATURE").ok(), + }, + out_tx, + ); + tokio::spawn(async move { + if let Err(e) = hub_listener.run().await { + error!(error = %e, "hub outbound listener exited"); + } + }); + let prover = prover.clone(); + let slots = prover_slots.clone(); + let m = metrics.clone(); + tokio::spawn(async move { + while let Some(ev) = out_rx.recv().await { + let (prover, evm, slots, m) = + (prover.clone(), evm.clone(), slots.clone(), m.clone()); + tokio::spawn( + async move { deliver_withdrawal(ev, &prover, &evm, &slots, &m).await }, + ); + } + }); + } else { + warn!("EVM_PRIVATE_KEY(_FILE) not set: hub → EVM withdrawals will not be delivered"); + } - // Spawn listener + // ── EVM → hub (deposits) ────────────────────────────────────────────────── + let (tx, mut rx) = mpsc::channel::(1024); let mut listener = EventListener::new(listener_config, tx); let listener_handle = tokio::spawn(async move { if let Err(e) = listener.run().await { @@ -132,192 +212,243 @@ async fn main() -> Result<(), Box> { } }); - let submitter = ProofSubmitter::new(submitter_config); - let semaphore = Arc::new(Semaphore::new(max_concurrent_provers)); - - // Batch pipeline: collect events into time-bounded batches, then process concurrently. - // - // Old serial pipeline: 1 event → finality → proof → submit → next event - // New batch pipeline: N events collected over 5s → all finality waits in parallel - // → all proofs in parallel (bounded) → submit all - // - // This directly beats Wormhole (1-20 per VAA) and Stargate (sequential settlement). let pipeline_metrics = metrics.clone(); let processing_handle = tokio::spawn(async move { let mut collector = BatchCollector::new(BATCH_MAX_SIZE, Duration::from_secs(BATCH_FLUSH_SECS)); let mut flush_tick = tokio::time::interval(Duration::from_secs(BATCH_FLUSH_SECS)); flush_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - // Consume the immediate first tick so we don't flush an empty batch at t=0 flush_tick.tick().await; loop { let batch_opt = tokio::select! { - event_opt = rx.recv() => { - match event_opt { - Some(event) => { - let seq = event.sequence(); - info!(sequence = seq, "event received, buffering in batch"); - // Track queue depth for Phase 10 alerting - pipeline_metrics.set_queue_depth(collector.pending_count() + 1); - collector.push(event) - } - None => { - // Channel closed — flush remaining and exit - info!("event channel closed, flushing remaining events"); - let _ = collector.flush_timer(); - break; - } + event_opt = rx.recv() => match event_opt { + Some(event) => { + pipeline_metrics.set_queue_depth(collector.pending_count() + 1); + collector.push(event) } - } - _ = flush_tick.tick() => { - collector.flush_timer() - } + None => { + info!("event channel closed, flushing remaining events"); + let _ = collector.flush_timer(); + break; + } + }, + _ = flush_tick.tick() => collector.flush_timer(), }; - if let Some(batch) = batch_opt { - let batch_id = batch.batch_id; - let batch_size = batch.len(); - info!( - batch_id, - batch_size, "dispatching batch to concurrent pipeline" - ); - - // Record batch metrics (Phase 10) - pipeline_metrics.record_batch_flushed(batch_size); - pipeline_metrics.set_queue_depth(0); - - // Spawn a task per event in the batch — all run concurrently. - // Semaphore ensures we don't over-saturate CPU with provers. - for event in batch.events { - let permit = semaphore - .clone() - .acquire_owned() - .await - .expect("semaphore closed"); - let prover = prover.clone(); - let submitter = submitter.clone(); - let ws_url = ws_rpc_url.clone(); - let http_url = http_rpc_url.clone(); - let event_metrics = pipeline_metrics.clone(); - - tokio::spawn(async move { - let _permit = permit; - let sequence = event.sequence(); - let settlement_start = std::time::Instant::now(); - - // Log fee tier for this transfer - let fee_desc = fee::FeeTier::from_usd_cents( - // Without oracle we can't know USD value, log at Standard tier boundary - 100_000, // placeholder: treat as $1k for logging - ) - .describe(); - info!(batch_id, sequence, fee_tier = fee_desc, "processing event"); - - // Phase 1: Wait for finality via WebSocket (beats Wormhole 2-15min) - let finality_result = - if ws_url.starts_with("ws://") || ws_url.starts_with("wss://") { - wait_for_finality_ws(chain_id, event.block_number(), &ws_url).await - } else { - wait_for_finality(chain_id, event.block_number(), &http_url).await - }; - - match finality_result { - Ok(()) => info!(batch_id, sequence, "finality confirmed"), - Err(e) => { - error!(batch_id, sequence, error = %e, "finality failed, skipping"); - event_metrics.record_settlement_failure(); - return; - } - } - - event_metrics.record_settlement_start(); - - // Phase 2: Generate ZK proof - let proof_start = std::time::Instant::now(); - event_metrics.record_proof_start(); - match prover.generate_proof(&event).await { - Ok(package) => { - let proof_ms = proof_start.elapsed().as_millis(); - event_metrics.record_proof_success(proof_ms as u64); - - // Alerting threshold: proof gen > 1s is a warning (Phase 10) - if proof_ms > PROOF_GEN_ALERT_MS { - warn!( - batch_id, - sequence, - proof_ms, - threshold_ms = PROOF_GEN_ALERT_MS, - "ALERT: proof generation exceeded threshold" - ); - } else { - info!( - batch_id, - sequence, - proof_ms, - proof_size = package.proof_bytes.len(), - "proof generated" - ); - } - - // Phase 3: Submit to Solana hub - match submitter.submit(&package).await { - Ok(sig) => { - let settlement_ms = settlement_start.elapsed().as_millis(); - event_metrics - .record_settlement_success(settlement_ms as u64); - - // Alerting threshold: total settlement > 60s (Phase 10) - if settlement_ms > SETTLEMENT_ALERT_MS { - warn!( - batch_id, - sequence, - settlement_ms, - threshold_ms = SETTLEMENT_ALERT_MS, - "ALERT: settlement time exceeded SLA threshold" - ); - } else { - info!( - batch_id, - sequence, - settlement_ms, - signature = %sig, - "settlement complete" - ); - } - } - Err(e) => { - error!(batch_id, sequence, error = %e, "submission failed"); - event_metrics.record_settlement_failure(); - } - } - } - Err(e) => { - error!(batch_id, sequence, error = %e, "proof generation failed"); - event_metrics.record_proof_failure(); - event_metrics.record_settlement_failure(); - } - } - }); - } + let Some(batch) = batch_opt else { continue }; + let batch_id = batch.batch_id; + info!(batch_id, batch_size = batch.len(), "dispatching batch"); + pipeline_metrics.record_batch_flushed(batch.len()); + pipeline_metrics.set_queue_depth(0); + + for event in batch.events { + let prover = prover.clone(); + let submitter = submitter.clone(); + let http_url = http_rpc_url.clone(); + let slots = prover_slots.clone(); + let m = pipeline_metrics.clone(); + tokio::spawn(async move { + process_deposit( + batch_id, + event, + hub_program, + &http_url, + &prover, + &submitter, + &slots, + &m, + ) + .await; + }); } } - info!("processing pipeline stopped"); }); - info!( - chain_id, - max_concurrent_provers, - batch_max_size = BATCH_MAX_SIZE, - batch_flush_secs = BATCH_FLUSH_SECS, - "relayer started — batch pipeline active" - ); + info!(chain_id, "relayer started"); tokio::select! { _ = listener_handle => error!("listener task exited unexpectedly"), _ = processing_handle => error!("processing task exited unexpectedly"), } - Ok(()) } + +/// EVM → hub: wait for finality, prove, attest, then execute the message. +#[allow(clippy::too_many_arguments)] +async fn process_deposit( + batch_id: u64, + event: GatewayEvent, + hub_program: [u8; 32], + http_url: &str, + prover: &ProverEngine, + submitter: &ProofSubmitter, + prover_slots: &Semaphore, + metrics: &Metrics, +) { + let sequence = event.sequence(); + + let (Some(message), GatewayEvent::Message(raw)) = (event.verified_message(), &event) else { + error!( + batch_id, + sequence, "event fields do not reproduce its payload hash; refusing to relay" + ); + metrics.record_settlement_failure(); + return; + }; + if message.dst_chain != HUB_CHAIN_ID || message.dst_gateway != hub_program { + info!( + batch_id, + sequence, + dst_chain = message.dst_chain, + "message not addressed to this hub; skipping" + ); + return; + } + + let started = std::time::Instant::now(); + let mut backoff = Duration::from_secs(5); + + for attempt in 1..=MAX_EVENT_ATTEMPTS { + // 1. Finality + canonical inclusion (no prover permit held). + match wait_for_finalized_inclusion(http_url, raw, FINALITY_POLL, FINALITY_TIMEOUT).await { + Ok(()) => {} + Err(InclusionError::NotCanonical(reason)) => { + error!(batch_id, sequence, %reason, "event is not canonical; dropping (never retried)"); + metrics.record_settlement_failure(); + return; + } + Err(InclusionError::Transient(reason)) => { + warn!(batch_id, sequence, attempt, %reason, "finality check failed; will retry"); + tokio::time::sleep(backoff).await; + backoff_step(&mut backoff); + continue; + } + } + metrics.record_settlement_start(); + + // 2. Prove (bounded concurrency). + let package = { + let _permit = prover_slots.acquire().await.expect("semaphore closed"); + let t = std::time::Instant::now(); + metrics.record_proof_start(); + match prover.generate_proof(&event).await { + Ok(p) => { + let ms = t.elapsed().as_millis(); + metrics.record_proof_success(ms as u64); + if ms > PROOF_GEN_ALERT_MS { + warn!( + batch_id, + sequence, + proof_ms = ms, + "ALERT: proof generation exceeded threshold" + ); + } + p + } + Err(e) => { + // Deterministic (bad witness/hash): retrying will not help. + error!(batch_id, sequence, error = %e, "proof generation failed"); + metrics.record_proof_failure(); + metrics.record_settlement_failure(); + return; + } + } + }; + + // 3. Attest on the hub. + if let Err(e) = submitter.submit(&package).await { + warn!(batch_id, sequence, attempt, error = %e, "attestation failed; will retry"); + tokio::time::sleep(backoff).await; + backoff_step(&mut backoff); + continue; + } + + // 4. Execute (mint / swap). Permissionless on-chain, so a failure here + // (e.g. slippage, unregistered asset) can be retried by anyone later. + if matches!(message.action, ACTION_TRANSFER | ACTION_SWAP) { + match submitter.execute(message, &raw.data).await { + Ok(outcome) => { + let ms = started.elapsed().as_millis(); + metrics.record_settlement_success(ms as u64); + if ms > SETTLEMENT_ALERT_MS { + warn!( + batch_id, + sequence, + settlement_ms = ms, + "ALERT: settlement exceeded threshold" + ); + } + let sig = match outcome { + HubOutcome::Confirmed(sig) => sig, + HubOutcome::AlreadyDone => "already-executed".into(), + }; + info!(batch_id, sequence, settlement_ms = ms, signature = %sig, "settlement complete"); + } + Err(e) => { + warn!(batch_id, sequence, error = %e, "attested but execution failed; anyone can retry execute_*"); + metrics.record_settlement_failure(); + } + } + } else { + info!( + batch_id, + sequence, + action = message.action, + "attested (no hub executor for this action)" + ); + } + return; + } + + error!( + batch_id, + sequence, + attempts = MAX_EVENT_ATTEMPTS, + "ALERT: giving up on event; manual replay required (set START_BLOCK to re-scan)" + ); + metrics.record_settlement_failure(); +} + +/// Hub → EVM: prove a finalized withdrawal and release it on the gateway. +async fn deliver_withdrawal( + ev: OutboundEvent, + prover: &ProverEngine, + evm: &EvmSubmitter, + prover_slots: &Semaphore, + metrics: &Metrics, +) { + let nonce = ev.message.nonce; + let package = { + let _permit = prover_slots.acquire().await.expect("semaphore closed"); + match prover.prove_message(&ev.message).await { + Ok(p) => p, + Err(e) => { + error!(nonce, sig = %ev.signature, error = %e, "withdrawal proof failed"); + metrics.record_proof_failure(); + return; + } + } + }; + + let mut backoff = Duration::from_secs(5); + for attempt in 1..=MAX_EVENT_ATTEMPTS { + match evm.release(&ev.message, &package.proof_bytes).await { + Ok(ReleaseOutcome::Confirmed(tx)) => { + info!(nonce, sig = %ev.signature, tx = %tx, "withdrawal released on EVM"); + return; + } + Ok(ReleaseOutcome::AlreadyExecuted) => { + info!(nonce, sig = %ev.signature, "withdrawal already released"); + return; + } + Err(e) => { + warn!(nonce, attempt, error = %e, "releaseTokens failed; will retry"); + tokio::time::sleep(backoff).await; + backoff_step(&mut backoff); + } + } + } + error!(nonce, sig = %ev.signature, "ALERT: giving up on withdrawal; manual release required"); +} diff --git a/relayer/src/prover.rs b/relayer/src/prover.rs index 5f2cab5..2d52feb 100644 --- a/relayer/src/prover.rs +++ b/relayer/src/prover.rs @@ -3,20 +3,30 @@ //! Generates BN254 Groth16 proofs that verify on both EVM and Solana. //! Circuit relation: commitment = (message + round_constant)^5 + sequence //! +//! SECURITY: this circuit only proves knowledge of a preimage of the public +//! commitment, which the verifier computes from the payload hash itself. It +//! does NOT prove that the message was emitted on the source chain, so anyone +//! holding the proving key can "prove" any message. The bridge's real trust +//! assumption is therefore "whoever holds the proving key", until the circuit +//! verifies source-chain consensus and receipt inclusion. See +//! docs/SECURITY_REVIEW.md. +//! //! Proof format: 256 bytes = A (64-byte G1) + B (128-byte G2) + C (64-byte G1) //! Serialization: big-endian, EVM/Solana precompile compatible. -use crate::events::GatewayEvent; +use crate::events::{CanonicalMessage, GatewayEvent}; use ark_bn254::{Bn254, Fr, G1Affine, G2Affine}; use ark_ff::{BigInteger, Field, PrimeField}; use ark_groth16::{Groth16, PreparedVerifyingKey, ProvingKey, VerifyingKey}; use ark_relations::r1cs::{ ConstraintSynthesizer, ConstraintSystemRef, LinearCombination, SynthesisError, Variable, }; +use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; use ark_snark::SNARK; +use std::path::Path; use std::sync::Arc; use tokio::sync::RwLock; -use tracing::info; +use tracing::{info, warn}; /// Domain salt — must match on-chain verifiers and EVM gateway. const DOMAIN_SALT: &[u8] = b"interlink_v1_domain"; @@ -24,13 +34,18 @@ const DOMAIN_SALT: &[u8] = b"interlink_v1_domain"; // ─── Circuit ──────────────────────────────────────────────────────────────── /// Compute the deterministic round constant from the domain salt. -/// Uses keccak256 truncated to fit within BN254 scalar field. fn compute_round_constant() -> Fr { - let hash = ethers_core::utils::keccak256(DOMAIN_SALT); - // Interpret as big-endian and reduce mod r + let hash = crate::evm::keccak256(DOMAIN_SALT); Fr::from_be_bytes_mod_order(&hash) } +/// Public input for a payload hash and sequence: (msg + rc)^5 + seq. +/// Must match `InterlinkGateway.commitment` and the hub's `compute_commitment`. +pub fn compute_commitment(payload_hash: &[u8; 32], sequence: u64) -> Fr { + let w = Fr::from_be_bytes_mod_order(payload_hash) + compute_round_constant(); + w.square().square() * w + Fr::from(sequence) +} + /// The InterLink Groth16 circuit. /// /// Proves knowledge of (message, sequence) such that: @@ -39,13 +54,6 @@ fn compute_round_constant() -> Fr { /// - Public input: commitment (1 field element) /// - Private witness: message, sequence /// - Round constant: deterministic from domain salt (hardcoded in circuit) -/// -/// R1CS constraints (4 multiplications): -/// w = message + rc (linear, enforced) -/// v1 = w * w (w²) -/// v2 = v1 * v1 (w⁴) -/// v3 = v2 * w (w⁵) -/// commitment = v3 + sequence (linear, enforced) #[derive(Clone)] pub struct InterlinkCircuit { pub message: Option, @@ -67,7 +75,6 @@ impl ConstraintSynthesizer for InterlinkCircuit { fn generate_constraints(self, cs: ConstraintSystemRef) -> Result<(), SynthesisError> { let rc = self.round_constant; - // Private witnesses let msg = cs.new_witness_variable(|| self.message.ok_or(SynthesisError::AssignmentMissing))?; let seq = @@ -76,8 +83,6 @@ impl ConstraintSynthesizer for InterlinkCircuit { // w = msg + rc let w_val = self.message.map(|m| m + rc); let w = cs.new_witness_variable(|| w_val.ok_or(SynthesisError::AssignmentMissing))?; - - // Enforce: (msg + rc) * 1 = w { let mut a = LinearCombination::zero(); a += (ark_ff::One::one(), msg); @@ -120,8 +125,6 @@ impl ConstraintSynthesizer for InterlinkCircuit { let commitment_val = v3_val.zip(self.sequence).map(|(v3, s)| v3 + s); let commitment = cs.new_input_variable(|| commitment_val.ok_or(SynthesisError::AssignmentMissing))?; - - // Enforce: (v3 + seq) * 1 = commitment { let mut a = LinearCombination::zero(); a += (ark_ff::One::one(), v3); @@ -140,42 +143,29 @@ impl ConstraintSynthesizer for InterlinkCircuit { // ─── Serialization helpers (ark ↔ EVM/Solana big-endian) ──────────────────── /// Serialize a G1Affine point to 64 bytes (x: 32 BE, y: 32 BE). -/// This matches EVM ecPairing and Solana alt_bn128 precompile format. fn g1_to_bytes(p: &G1Affine) -> [u8; 64] { let mut out = [0u8; 64]; if p.infinity { - return out; // point at infinity = all zeros + return out; } - // ark stores field elements in little-endian Montgomery form internally, - // but BigInteger::to_bytes_be() gives canonical big-endian. - let x_bytes = p.x.into_bigint().to_bytes_be(); - let y_bytes = p.y.into_bigint().to_bytes_be(); - out[0..32].copy_from_slice(&x_bytes); - out[32..64].copy_from_slice(&y_bytes); + out[0..32].copy_from_slice(&p.x.into_bigint().to_bytes_be()); + out[32..64].copy_from_slice(&p.y.into_bigint().to_bytes_be()); out } -/// Serialize a G2Affine point to 128 bytes. -/// EVM/Solana convention: (x_imaginary, x_real, y_imaginary, y_real), each 32 BE. -/// ark convention: x = c0 + c1*u where c0=real, c1=imaginary. +/// Serialize a G2Affine point to 128 bytes: (x_im, x_re, y_im, y_re), each 32 BE. fn g2_to_bytes(p: &G2Affine) -> [u8; 128] { let mut out = [0u8; 128]; if p.infinity { return out; } - // EVM expects (x_im, x_re, y_im, y_re) = (c1, c0, c1, c0) in ark terms - let x_c1 = p.x.c1.into_bigint().to_bytes_be(); // imaginary - let x_c0 = p.x.c0.into_bigint().to_bytes_be(); // real - let y_c1 = p.y.c1.into_bigint().to_bytes_be(); // imaginary - let y_c0 = p.y.c0.into_bigint().to_bytes_be(); // real - out[0..32].copy_from_slice(&x_c1); - out[32..64].copy_from_slice(&x_c0); - out[64..96].copy_from_slice(&y_c1); - out[96..128].copy_from_slice(&y_c0); + out[0..32].copy_from_slice(&p.x.c1.into_bigint().to_bytes_be()); + out[32..64].copy_from_slice(&p.x.c0.into_bigint().to_bytes_be()); + out[64..96].copy_from_slice(&p.y.c1.into_bigint().to_bytes_be()); + out[96..128].copy_from_slice(&p.y.c0.into_bigint().to_bytes_be()); out } -/// Serialize a Groth16 proof to 256 bytes: A (64) + B (128) + C (64). fn serialize_proof(proof: &ark_groth16::Proof) -> Vec { let mut out = Vec::with_capacity(256); out.extend_from_slice(&g1_to_bytes(&proof.a)); @@ -184,15 +174,8 @@ fn serialize_proof(proof: &ark_groth16::Proof) -> Vec { out } -/// Serialize a Groth16 verification key to bytes for on-chain storage. -/// -/// Layout (576 bytes for 1 public input): -/// alpha_g1: 64 bytes (G1) -/// beta_g2: 128 bytes (G2) -/// gamma_g2: 128 bytes (G2) -/// delta_g2: 128 bytes (G2) -/// ic[0]: 64 bytes (G1) -/// ic[1]: 64 bytes (G1) — one per public input +/// Serialize a Groth16 verification key (576 bytes for 1 public input): +/// alpha_g1 (64) | beta_g2 (128) | gamma_g2 (128) | delta_g2 (128) | ic[0] (64) | ic[1] (64) pub fn serialize_vk(vk: &VerifyingKey) -> Vec { let mut out = Vec::new(); out.extend_from_slice(&g1_to_bytes(&vk.alpha_g1)); @@ -207,7 +190,6 @@ pub fn serialize_vk(vk: &VerifyingKey) -> Vec { // ─── Proof engine ─────────────────────────────────────────────────────────── -/// Cached Groth16 proving artifacts (generated once at startup). struct ProverCache { pk: ProvingKey, vk: VerifyingKey, @@ -215,11 +197,7 @@ struct ProverCache { round_constant: Fr, } -/// Groth16 proof generation engine with key caching. -/// -/// Cheaply cloneable — the inner `Arc` means all clones share the same initialized -/// key cache. Wrap in `Arc` once and clone into each concurrent processing task -/// without re-running the trusted setup. +/// Groth16 proof generation engine with key caching. Cheaply cloneable. #[derive(Clone)] pub struct ProverEngine { cache: Arc>>, @@ -234,8 +212,10 @@ pub struct ProofPackage { pub public_inputs: Vec, /// Event sequence number pub sequence: u64, - /// Original payload hash from the EVM event + /// Canonical payload hash the proof is bound to pub payload_hash: [u8; 32], + /// The canonical message (None for legacy events, which cannot be submitted) + pub message: Option, } impl ProverEngine { @@ -245,45 +225,81 @@ impl ProverEngine { } } - /// Initialize: run Groth16 trusted setup and cache keys. - /// In production, use keys from a real ceremony. For devnet, generated fresh. + /// Run a fresh, throwaway Groth16 setup. **Tests and benchmarks only**: + /// every call produces a different verification key, so proofs from one + /// process never verify against a VK uploaded by another. pub async fn initialize(&self) -> Result<(), String> { - info!("initializing Groth16 prover (trusted setup)"); - - let (pk, vk, pvk, rc) = tokio::task::spawn_blocking(move || { - let rc = compute_round_constant(); - let circuit = InterlinkCircuit { - message: None, - sequence: None, - round_constant: rc, - }; - + let (pk, vk) = tokio::task::spawn_blocking(|| { let mut rng = ark_std::rand::rngs::OsRng; - let (pk, vk) = Groth16::::circuit_specific_setup(circuit, &mut rng) - .expect("Groth16 setup failed"); - let pvk = Groth16::::process_vk(&vk).expect("VK processing failed"); - - (pk, vk, pvk, rc) + Groth16::::circuit_specific_setup(InterlinkCircuit::default(), &mut rng) + .map_err(|e| format!("Groth16 setup failed: {}", e)) }) .await - .map_err(|e| format!("setup task failed: {}", e))?; - - // Log the VK for on-chain deployment - let vk_bytes = serialize_vk(&vk); - info!( - vk_size = vk_bytes.len(), - ic_count = vk.gamma_abc_g1.len(), - "Groth16 keys ready. Export VK with export_vk() for on-chain deployment." - ); + .map_err(|e| format!("setup task failed: {}", e))??; + self.install(pk, vk).await + } - let mut cache = self.cache.write().await; - *cache = Some(ProverCache { + /// Load the proving key from `path`. If it does not exist and + /// `allow_create` is set, run a local setup and save it there. + /// + /// The relayer and `export-vk` must use the same file, otherwise the + /// on-chain VK will not match the proofs. A locally generated key is a + /// single-party trusted setup: whoever ran it could keep the toxic waste. + /// Production keys must come from a multi-party ceremony. + pub async fn load_or_create(&self, path: &Path, allow_create: bool) -> Result<(), String> { + let pk = if path.exists() { + let bytes = std::fs::read(path) + .map_err(|e| format!("read proving key {}: {}", path.display(), e))?; + let pk = tokio::task::spawn_blocking(move || { + ProvingKey::::deserialize_compressed(bytes.as_slice()) + }) + .await + .map_err(|e| format!("deserialize task failed: {}", e))? + .map_err(|e| format!("invalid proving key {}: {}", path.display(), e))?; + info!(path = %path.display(), "loaded Groth16 proving key"); + pk + } else if allow_create { + warn!( + path = %path.display(), + "no proving key found; running a LOCAL single-party setup (dev only)" + ); + let pk = tokio::task::spawn_blocking(|| { + let mut rng = ark_std::rand::rngs::OsRng; + Groth16::::circuit_specific_setup(InterlinkCircuit::default(), &mut rng) + .map(|(pk, _)| pk) + .map_err(|e| format!("Groth16 setup failed: {}", e)) + }) + .await + .map_err(|e| format!("setup task failed: {}", e))??; + let mut bytes = Vec::new(); + pk.serialize_compressed(&mut bytes) + .map_err(|e| format!("serialize proving key: {}", e))?; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir) + .map_err(|e| format!("create {}: {}", dir.display(), e))?; + } + std::fs::write(path, bytes) + .map_err(|e| format!("write proving key {}: {}", path.display(), e))?; + pk + } else { + return Err(format!( + "proving key {} not found; generate it with `cargo run --bin export-vk`", + path.display() + )); + }; + let vk = pk.vk.clone(); + self.install(pk, vk).await + } + + async fn install(&self, pk: ProvingKey, vk: VerifyingKey) -> Result<(), String> { + let pvk = Groth16::::process_vk(&vk).map_err(|e| format!("VK processing: {}", e))?; + info!(vk_size = serialize_vk(&vk).len(), "Groth16 keys ready"); + *self.cache.write().await = Some(ProverCache { pk, vk, pvk, - round_constant: rc, + round_constant: compute_round_constant(), }); - Ok(()) } @@ -295,23 +311,45 @@ impl ProverEngine { } /// Generate a Groth16 proof for a gateway event. + /// + /// For current-format events the payload hash is recomputed from the + /// event fields; a mismatch is refused rather than proven. pub async fn generate_proof(&self, event: &GatewayEvent) -> Result { + match event { + GatewayEvent::Message(_) => { + let msg = event + .verified_message() + .ok_or("event payload hash does not match its fields; refusing to prove")?; + self.prove_message(msg).await + } + _ => { + self.prove_hash(event.payload_hash(), event.sequence(), None) + .await + } + } + } + + /// Prove a canonical message in either direction (gateway → hub, or hub → gateway). + pub async fn prove_message(&self, msg: &CanonicalMessage) -> Result { + self.prove_hash(msg.hash(), msg.nonce, Some(msg.clone())) + .await + } + + async fn prove_hash( + &self, + payload_hash: [u8; 32], + sequence: u64, + message: Option, + ) -> Result { let cache_guard = self.cache.read().await; let cache = cache_guard.as_ref().ok_or("prover not initialized")?; - let sequence = event.sequence(); - let payload_hash = event.payload_hash(); - info!(sequence, "generating Groth16 proof"); - // Derive message field element from payload hash let msg_field = Fr::from_be_bytes_mod_order(&payload_hash); let seq_field = Fr::from(sequence); let rc = cache.round_constant; - - // Compute expected commitment: (msg + rc)^5 + seq - let w = msg_field + rc; - let commitment = w.square().square() * w + seq_field; + let commitment = compute_commitment(&payload_hash, sequence); let circuit = InterlinkCircuit { message: Some(msg_field), @@ -327,14 +365,11 @@ impl ProverEngine { let proof = Groth16::::prove(&pk, circuit, &mut rng) .map_err(|e| format!("proof generation failed: {}", e))?; - // Verify locally before sending (catches witness errors early) - let public_inputs = vec![commitment]; - let valid = Groth16::::verify_with_processed_vk(&pvk, &public_inputs, &proof) + let valid = Groth16::::verify_with_processed_vk(&pvk, &[commitment], &proof) .map_err(|e| format!("local verify error: {}", e))?; if !valid { return Err("locally generated proof failed verification — circuit bug".to_string()); } - Ok(serialize_proof(&proof)) }) .await @@ -349,14 +384,12 @@ impl ProverEngine { info!(sequence, "Groth16 proof generated and locally verified"); - // Public input = commitment serialized as 32-byte big-endian - let commitment_bytes = commitment.into_bigint().to_bytes_be(); - Ok(ProofPackage { proof_bytes, - public_inputs: commitment_bytes, + public_inputs: commitment.into_bigint().to_bytes_be(), sequence, payload_hash, + message, }) } } @@ -369,16 +402,11 @@ mod tests { fn test_circuit_satisfiability() { use ark_relations::r1cs::ConstraintSystem; - let rc = compute_round_constant(); - let msg = Fr::from(42u64); - let seq = Fr::from(1u64); - let circuit = InterlinkCircuit { - message: Some(msg), - sequence: Some(seq), - round_constant: rc, + message: Some(Fr::from(42u64)), + sequence: Some(Fr::from(1u64)), + round_constant: compute_round_constant(), }; - let cs = ConstraintSystem::::new_ref(); circuit.generate_constraints(cs.clone()).unwrap(); assert!(cs.is_satisfied().unwrap(), "circuit not satisfied"); @@ -403,27 +431,77 @@ mod tests { let package = engine.generate_proof(&event).await.unwrap(); assert_eq!(package.proof_bytes.len(), 256); assert_eq!(package.sequence, 1); + assert!(package.message.is_none()); } - #[test] - fn test_proof_serialization_size() { - // Verify that G1 = 64 bytes, G2 = 128 bytes, total = 256 - assert_eq!(std::mem::size_of::<[u8; 64]>(), 64); // G1 - assert_eq!(64 + 128 + 64, 256); // A + B + C + #[tokio::test] + async fn test_refuses_message_with_wrong_hash() { + use crate::events::{MessageEvent, ACTION_TRANSFER, HUB_CHAIN_ID}; + let engine = ProverEngine::new(0); + engine.initialize().await.unwrap(); + + let message = CanonicalMessage { + src_chain: 1, + src_gateway: [1; 32], + dst_chain: HUB_CHAIN_ID, + dst_gateway: [2; 32], + nonce: 4, + action: ACTION_TRANSFER, + sender: [3; 32], + asset: [0; 32], + amount: 10, + recipient: [4; 32], + extra_hash: [5; 32], + }; + let mut ev = MessageEvent { + block_number: 1, + block_hash: [0; 32], + tx_hash: [0; 32], + gateway: [0; 20], + payload_hash: message.hash(), + message, + data: vec![], + }; + let ok = engine + .generate_proof(&GatewayEvent::Message(ev.clone())) + .await + .unwrap(); + assert_eq!(ok.payload_hash, ev.payload_hash); + assert!(ok.message.is_some()); + + ev.message.amount = 1_000_000; + assert!(engine + .generate_proof(&GatewayEvent::Message(ev)) + .await + .is_err()); + } + + /// The whole point of persisting the key: two loads give the same VK. + #[tokio::test] + async fn test_proving_key_persists_across_restarts() { + let dir = std::env::temp_dir().join(format!("interlink-pk-test-{}", std::process::id())); + let path = dir.join("pk.bin"); + let _ = std::fs::remove_file(&path); + + let a = ProverEngine::new(0); + assert!( + a.load_or_create(&path, false).await.is_err(), + "must not create silently" + ); + a.load_or_create(&path, true).await.unwrap(); + let b = ProverEngine::new(0); + b.load_or_create(&path, false).await.unwrap(); + assert_eq!(a.export_vk().await.unwrap(), b.export_vk().await.unwrap()); + + let _ = std::fs::remove_dir_all(&dir); } #[test] fn test_vk_serialization() { use ark_std::rand::rngs::OsRng; - let rc = compute_round_constant(); - let circuit = InterlinkCircuit { - message: None, - sequence: None, - round_constant: rc, - }; - let (_, vk) = Groth16::::circuit_specific_setup(circuit, &mut OsRng).unwrap(); - let vk_bytes = serialize_vk(&vk); - // alpha(64) + beta(128) + gamma(128) + delta(128) + ic[0](64) + ic[1](64) = 576 - assert_eq!(vk_bytes.len(), 576); + let (_, vk) = + Groth16::::circuit_specific_setup(InterlinkCircuit::default(), &mut OsRng) + .unwrap(); + assert_eq!(serialize_vk(&vk).len(), 576); } } diff --git a/relayer/src/simulator.rs b/relayer/src/simulator.rs index 8c4150c..111c7b0 100644 --- a/relayer/src/simulator.rs +++ b/relayer/src/simulator.rs @@ -1,4 +1,3 @@ - // ─── Input ─────────────────────────────────────────────────────────────────── #[derive(Debug, Clone)] diff --git a/relayer/src/solana_tx.rs b/relayer/src/solana_tx.rs new file mode 100644 index 0000000..19489b3 --- /dev/null +++ b/relayer/src/solana_tx.rs @@ -0,0 +1,311 @@ +//! Minimal Solana transaction building: account metas, legacy message +//! compilation, signing, PDA / ATA derivation and Anchor discriminators. +//! +//! Replaces hand-assembled byte layouts so every hub instruction goes +//! through one tested compiler. + +use ed25519_dalek::Signer; +use sha2::{Digest, Sha256}; + +pub type Pubkey = [u8; 32]; + +pub const SYSTEM_PROGRAM: Pubkey = [0u8; 32]; + +pub fn pubkey(b58: &str) -> Result { + bs58::decode(b58) + .into_vec() + .map_err(|e| format!("invalid base58 pubkey {b58}: {e}"))? + .try_into() + .map_err(|_| format!("pubkey {b58} is not 32 bytes")) +} + +pub fn token_program() -> Pubkey { + pubkey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA").unwrap() +} + +pub fn associated_token_program() -> Pubkey { + pubkey("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL").unwrap() +} + +pub fn compute_budget_program() -> Pubkey { + pubkey("ComputeBudget111111111111111111111111111111").unwrap() +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AccountMeta { + pub pubkey: Pubkey, + pub is_signer: bool, + pub is_writable: bool, +} + +impl AccountMeta { + pub fn signer(pubkey: Pubkey) -> Self { + Self { + pubkey, + is_signer: true, + is_writable: true, + } + } + pub fn writable(pubkey: Pubkey) -> Self { + Self { + pubkey, + is_signer: false, + is_writable: true, + } + } + pub fn readonly(pubkey: Pubkey) -> Self { + Self { + pubkey, + is_signer: false, + is_writable: false, + } + } +} + +#[derive(Clone, Debug)] +pub struct Instruction { + pub program_id: Pubkey, + pub accounts: Vec, + pub data: Vec, +} + +/// `sha256("global:")[..8]` +pub fn anchor_ix_discriminator(name: &str) -> [u8; 8] { + Sha256::digest(format!("global:{name}").as_bytes())[..8] + .try_into() + .unwrap() +} + +/// `sha256("event:")[..8]` +pub fn anchor_event_discriminator(name: &str) -> [u8; 8] { + Sha256::digest(format!("event:{name}").as_bytes())[..8] + .try_into() + .unwrap() +} + +pub fn set_compute_unit_limit(units: u32) -> Instruction { + let mut data = vec![2u8]; + data.extend_from_slice(&units.to_le_bytes()); + Instruction { + program_id: compute_budget_program(), + accounts: vec![], + data, + } +} + +fn is_on_curve(bytes: &Pubkey) -> bool { + ed25519_dalek::VerifyingKey::from_bytes(bytes).is_ok() +} + +/// Solana `find_program_address`: highest bump whose hash is off the ed25519 curve. +pub fn find_program_address(seeds: &[&[u8]], program_id: &Pubkey) -> Result<(Pubkey, u8), String> { + for bump in (0..=255u8).rev() { + let mut hasher = Sha256::new(); + for seed in seeds { + hasher.update(seed); + } + hasher.update([bump]); + hasher.update(program_id); + hasher.update(b"ProgramDerivedAddress"); + let candidate: Pubkey = hasher.finalize().into(); + if !is_on_curve(&candidate) { + return Ok((candidate, bump)); + } + } + Err("no viable bump for PDA".into()) +} + +pub fn pda(seeds: &[&[u8]], program_id: &Pubkey) -> Result { + find_program_address(seeds, program_id).map(|(k, _)| k) +} + +pub fn associated_token_address(owner: &Pubkey, mint: &Pubkey) -> Result { + pda( + &[owner, &token_program(), mint], + &associated_token_program(), + ) +} + +fn compact_u16(n: usize, out: &mut Vec) { + let mut val = n; + loop { + let byte = (val & 0x7F) as u8; + val >>= 7; + if val == 0 { + out.push(byte); + return; + } + out.push(byte | 0x80); + } +} + +/// Compile a legacy message. Keys are ordered writable signers (fee payer +/// first), readonly signers, writable non-signers, readonly non-signers. +pub fn compile_message( + payer: &Pubkey, + ixs: &[Instruction], + blockhash: &[u8; 32], +) -> Result, String> { + let mut keys: Vec = vec![AccountMeta::signer(*payer)]; + let mut add = |meta: AccountMeta| match keys.iter_mut().find(|k| k.pubkey == meta.pubkey) { + Some(k) => { + k.is_signer |= meta.is_signer; + k.is_writable |= meta.is_writable; + } + None => keys.push(meta), + }; + for ix in ixs { + for meta in &ix.accounts { + add(meta.clone()); + } + add(AccountMeta::readonly(ix.program_id)); + } + + let rank = |k: &AccountMeta| match (k.is_signer, k.is_writable) { + (true, true) => 0, + (true, false) => 1, + (false, true) => 2, + (false, false) => 3, + }; + // Stable sort keeps the payer first and preserves first-seen order otherwise. + keys.sort_by_key(rank); + if keys.len() > 255 { + return Err("too many accounts".into()); + } + + let num_signers = keys.iter().filter(|k| k.is_signer).count(); + let readonly_signed = keys + .iter() + .filter(|k| k.is_signer && !k.is_writable) + .count(); + let readonly_unsigned = keys + .iter() + .filter(|k| !k.is_signer && !k.is_writable) + .count(); + if num_signers != 1 { + return Err("only single-signer transactions are supported".into()); + } + + let index = |k: &Pubkey| keys.iter().position(|m| &m.pubkey == k).unwrap() as u8; + let mut msg = vec![ + num_signers as u8, + readonly_signed as u8, + readonly_unsigned as u8, + ]; + compact_u16(keys.len(), &mut msg); + for k in &keys { + msg.extend_from_slice(&k.pubkey); + } + msg.extend_from_slice(blockhash); + compact_u16(ixs.len(), &mut msg); + for ix in ixs { + msg.push(index(&ix.program_id)); + compact_u16(ix.accounts.len(), &mut msg); + for meta in &ix.accounts { + msg.push(index(&meta.pubkey)); + } + compact_u16(ix.data.len(), &mut msg); + msg.extend_from_slice(&ix.data); + } + Ok(msg) +} + +/// Sign a compiled message with a 64-byte Solana keypair (secret || public). +pub fn sign_transaction(message: &[u8], keypair: &[u8]) -> Result, String> { + let secret: [u8; 32] = keypair + .get(..32) + .and_then(|s| s.try_into().ok()) + .ok_or("keypair must be at least 32 bytes")?; + let signature = ed25519_dalek::SigningKey::from_bytes(&secret).sign(message); + let mut tx = Vec::with_capacity(1 + 64 + message.len()); + tx.push(1u8); + tx.extend_from_slice(&signature.to_bytes()); + tx.extend_from_slice(message); + if tx.len() > 1232 { + return Err(format!("transaction too large: {} bytes", tx.len())); + } + Ok(tx) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Reference addresses from `solana find-program-derived-address` (Solana CLI 3.0). + #[test] + fn pdas_match_solana_cli() { + let hub = pubkey("AKzpc9tvxfhLjj5AantKizK2YZgSjoyhLmYqRZy6b8Lz").unwrap(); + let b58 = |k: Pubkey| bs58::encode(k).into_string(); + assert_eq!( + b58(pda(&[b"state"], &hub).unwrap()), + "9jrUnkhcR9vth2uwehuNLeq6pHTecmvd1KZXQoHUFuQo" + ); + assert_eq!( + b58(pda(&[b"message", &[7u8; 32]], &hub).unwrap()), + "3zv12gFVME1Qq1vr8TFA55WKSzTx8LQVuL3k81nnNkpn" + ); + assert_eq!( + b58(pda(&[b"wrapped", &1u64.to_le_bytes(), &[0x33u8; 32]], &hub).unwrap()), + "6UUFzVFrYDBEJM673ysscefmpy6vtdh5FmaTjw4YEkoq" + ); + let wsol = pubkey("So11111111111111111111111111111111111111112").unwrap(); + assert_eq!( + b58(associated_token_address(&hub, &wsol).unwrap()), + "BEeCLUVgjcSFsSLomXrUneya4GApyPeMzvpcPfM6VJUN" + ); + } + + #[test] + fn known_anchor_discriminators() { + // sha256("global:initialize")[..8] is Anchor's well-known initialize discriminator. + assert_eq!( + anchor_ix_discriminator("initialize"), + [175, 175, 109, 31, 13, 152, 155, 237] + ); + } + + #[test] + fn compact_u16_encoding() { + let enc = |n| { + let mut v = vec![]; + compact_u16(n, &mut v); + v + }; + assert_eq!(enc(0), vec![0]); + assert_eq!(enc(127), vec![0x7F]); + assert_eq!(enc(128), vec![0x80, 0x01]); + assert_eq!(enc(492), vec![0xEC, 0x03]); + } + + #[test] + fn message_orders_and_dedups_accounts() { + let payer = [1u8; 32]; + let program = [9u8; 32]; + let w = [2u8; 32]; + let r = [3u8; 32]; + let ix = Instruction { + program_id: program, + // r listed first, then w twice with different flags: must merge to writable. + accounts: vec![ + AccountMeta::readonly(r), + AccountMeta::readonly(w), + AccountMeta::writable(w), + AccountMeta::signer(payer), + ], + data: vec![0xAA], + }; + let msg = compile_message(&payer, &[ix], &[7u8; 32]).unwrap(); + // header: 1 signer, 0 readonly signed, 2 readonly unsigned (r, program) + assert_eq!(&msg[..3], &[1, 0, 2]); + assert_eq!(msg[3], 4); // key count + let key = |i: usize| &msg[4 + 32 * i..4 + 32 * (i + 1)]; + assert_eq!(key(0), &payer); + assert_eq!(key(1), &w); // writable non-signer + assert_eq!(key(2), &r); + assert_eq!(key(3), &program); + let ix_start = 4 + 32 * 4 + 32; + assert_eq!(msg[ix_start], 1); // one instruction + assert_eq!(msg[ix_start + 1], 3); // program index + assert_eq!(&msg[ix_start + 2..ix_start + 7], &[4, 2, 1, 1, 0]); + } +} diff --git a/relayer/src/submitter.rs b/relayer/src/submitter.rs index d5ddbba..1dc44fa 100644 --- a/relayer/src/submitter.rs +++ b/relayer/src/submitter.rs @@ -1,13 +1,21 @@ -//! Solana transaction submission module with retry logic. +//! Solana hub submission: attest proofs (`submit_proof`) and execute attested +//! messages (`execute_transfer` / `execute_swap`), with confirmation tracking. //! -//! Constructs and submits Solana transactions containing ZK proofs -//! to the InterLink Hub program. Includes retry logic, fee estimation, -//! and confirmation tracking. +//! A signature from `sendTransaction` only means the RPC accepted the +//! transaction, so every send waits for `confirmed` status. +use crate::events::{CanonicalMessage, ACTION_SWAP, ACTION_TRANSFER}; use crate::prover::ProofPackage; -use ed25519_dalek::Signer; +use crate::solana_tx::{self as stx, AccountMeta, Instruction, Pubkey}; use tracing::{error, info, warn}; +/// Compute units requested for `submit_proof` (pairing + keccak + account init). +const PROOF_COMPUTE_UNITS: u32 = 400_000; +/// Compute units for execution (CPI to token / ATA programs). +const EXECUTE_COMPUTE_UNITS: u32 = 200_000; +/// How long to wait for a submitted transaction to confirm. +const CONFIRM_TIMEOUT_SECS: u64 = 60; + /// Configuration for the Solana submitter #[derive(Clone, Debug)] pub struct SubmitterConfig { @@ -23,11 +31,16 @@ pub struct SubmitterConfig { pub source_chain_id: u64, } -/// Submits proof packages to the Solana Hub. -/// -/// Cheaply cloneable — `reqwest::Client` is an `Arc` internally and all config -/// fields are value types. Clone once per concurrent task; no extra connections -/// are opened until a request is actually made. +/// Outcome of a hub transaction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HubOutcome { + /// Landed and confirmed with this signature. + Confirmed(String), + /// Nothing to do: someone already attested / executed this message. + AlreadyDone, +} + +/// Submits proof packages to the Solana Hub. Cheaply cloneable. #[derive(Clone)] pub struct ProofSubmitter { config: SubmitterConfig, @@ -53,310 +66,485 @@ impl ProofSubmitter { } } - /// Submit a proof package to the Solana Hub program. - /// Retries up to max_retries times on transient failures. - pub async fn submit(&self, package: &ProofPackage) -> Result { - let mut last_error = String::new(); + fn relayer(&self) -> Pubkey { + self.keypair[32..64].try_into().unwrap() + } + + fn program_id(&self) -> Result { + stx::pubkey(&self.config.program_id) + } + + /// Attest a proof on the hub (`submit_proof`). Idempotent. + pub async fn submit(&self, package: &ProofPackage) -> Result { + let message = package + .message + .as_ref() + .ok_or("legacy event format cannot be submitted to the hub")?; + let ix = submit_proof_ix(&self.program_id()?, &self.relayer(), package, message)?; + self.send_with_retries( + package.sequence, + vec![stx::set_compute_unit_limit(PROOF_COMPUTE_UNITS), ix], + ) + .await + } + + /// Execute an attested message (mint wrapped tokens or swap). Permissionless + /// on-chain; the relayer does it so transfers complete without user action. + pub async fn execute( + &self, + message: &CanonicalMessage, + data: &[u8], + ) -> Result { + let program = self.program_id()?; + let wrapped = self.wrapped_asset(&program, message).await?; + let ix = match message.action { + ACTION_TRANSFER => execute_transfer_ix(&program, &self.relayer(), message, &wrapped)?, + ACTION_SWAP => { + let swap = SwapParams::decode(data)?; + execute_swap_ix(&program, &self.relayer(), message, &wrapped, &swap)? + } + other => return Err(format!("action {other} has no hub executor")), + }; + self.send_with_retries( + message.nonce, + vec![stx::set_compute_unit_limit(EXECUTE_COMPUTE_UNITS), ix], + ) + .await + } + + /// Read the registered `WrappedAsset` (mint) for a message's source asset. + async fn wrapped_asset( + &self, + program: &Pubkey, + m: &CanonicalMessage, + ) -> Result { + let address = stx::pda(&[b"wrapped", &m.src_chain.to_le_bytes(), &m.asset], program)?; + let body = self + .rpc( + "getAccountInfo", + serde_json::json!([bs58::encode(address).into_string(), {"encoding": "base64", "commitment": "confirmed"}]), + ) + .await?; + let value = &body["result"]["value"]; + if value.is_null() { + return Err(format!( + "asset {} from chain {} is not registered on the hub", + crate::evm::to_hex(&m.asset), + m.src_chain + )); + } + let b64 = value["data"][0].as_str().ok_or("account data missing")?; + let data = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64) + .map_err(|e| format!("account data: {e}"))?; + WrappedAsset::decode(&data) + } + async fn send_with_retries( + &self, + sequence: u64, + ixs: Vec, + ) -> Result { + let mut last_error = String::new(); for attempt in 0..self.config.max_retries { - match self.try_submit(package).await { - Ok(sig) => { - info!( - sequence = package.sequence, - signature = %sig, - attempt, - "proof submitted successfully" - ); - return Ok(sig); + match self.send(&ixs).await { + Ok(outcome) => { + info!(sequence, ?outcome, attempt, "hub transaction done"); + return Ok(outcome); } Err(e) => { last_error = e.clone(); if attempt + 1 < self.config.max_retries { - warn!( - sequence = package.sequence, - attempt, - error = %e, - "submission failed, retrying" - ); + warn!(sequence, attempt, error = %e, "hub transaction failed, retrying"); tokio::time::sleep(std::time::Duration::from_secs(2u64.pow(attempt))).await; } } } } - - error!( - sequence = package.sequence, - "all submission attempts failed" - ); + error!(sequence, "all hub transaction attempts failed"); Err(format!( "failed after {} attempts: {}", self.config.max_retries, last_error )) } - /// Single attempt to submit a proof to Solana. - async fn try_submit(&self, package: &ProofPackage) -> Result { - // Step 1: Get recent blockhash + async fn send(&self, ixs: &[Instruction]) -> Result { let blockhash = self.get_recent_blockhash().await?; - - // Step 2: Use cached keypair (loaded at startup) - let keypair_json = &self.keypair; - - // Step 3: Build instruction data - // Anchor instruction sighash for "submit_proof" = sha256("global:submit_proof")[0..8] - let sighash = { - use sha2::Digest; - let hash = sha2::Sha256::digest(b"global:submit_proof"); - let mut sig = [0u8; 8]; - sig.copy_from_slice(&hash[0..8]); - sig - }; - - let mut ix_data = Vec::new(); - ix_data.extend_from_slice(&sighash); - ix_data.extend_from_slice(&self.config.source_chain_id.to_le_bytes()); // source_chain - ix_data.extend_from_slice(&package.sequence.to_le_bytes()); // sequence - // proof_data as borsh Vec: length prefix + data - ix_data.extend_from_slice(&(package.proof_bytes.len() as u32).to_le_bytes()); - ix_data.extend_from_slice(&package.proof_bytes); - ix_data.extend_from_slice(&package.payload_hash); // payload_hash - ix_data.extend_from_slice(&[0u8; 32]); // commitment_input (placeholder) - - // Step 4: Derive PDAs - let program_id_bytes = bs58::decode(&self.config.program_id) - .into_vec() - .map_err(|e| format!("invalid program id: {}", e))?; - - let relayer_pubkey = &keypair_json[32..64]; - - let state_pda = Self::find_pda(&program_id_bytes, &[b"state"])?; - let stake_pda = Self::find_pda(&program_id_bytes, &[b"stake", relayer_pubkey])?; - let vk_pda = Self::find_pda(&program_id_bytes, &[b"vk"])?; - - // Step 5: Build and sign transaction - - // Step 6: Send via RPC - let tx_base64 = self - .build_raw_transaction( - keypair_json, - relayer_pubkey, - &program_id_bytes, - &state_pda, - &stake_pda, - &vk_pda, - &ix_data, - &blockhash, + let msg = stx::compile_message(&self.relayer(), ixs, &blockhash)?; + let tx = stx::sign_transaction(&msg, &self.keypair)?; + let tx_base64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &tx); + + let body = self + .rpc( + "sendTransaction", + serde_json::json!([tx_base64, { "encoding": "base64", "skipPreflight": false }]), ) - .map_err(|e| format!("tx build failed: {}", e))?; - - let resp = self - .client - .post(&self.config.rpc_url) - .json(&serde_json::json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "sendTransaction", - "params": [ - tx_base64, - { "encoding": "base64", "skipPreflight": false } - ] - })) - .send() - .await - .map_err(|e| format!("rpc send failed: {}", e))?; - - let body: serde_json::Value = resp - .json() - .await - .map_err(|e| format!("rpc response parse failed: {}", e))?; - + .await?; if let Some(error) = body.get("error") { - return Err(format!("rpc error: {}", error)); + let text = error.to_string(); + if is_already_done(&text) { + return Ok(HubOutcome::AlreadyDone); + } + return Err(format!("rpc error: {}", text)); } - - body["result"] + let sig = body["result"] .as_str() - .map(|s| s.to_string()) - .ok_or_else(|| "no transaction signature in response".to_string()) + .ok_or("no transaction signature in response")? + .to_string(); + self.wait_for_confirmation(&sig).await?; + Ok(HubOutcome::Confirmed(sig)) } - async fn get_recent_blockhash(&self) -> Result, String> { - let resp = self - .client + async fn rpc( + &self, + method: &str, + params: serde_json::Value, + ) -> Result { + self.client .post(&self.config.rpc_url) - .json(&serde_json::json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "getLatestBlockhash", - "params": [{ "commitment": "finalized" }] - })) + .json( + &serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}), + ) .send() .await - .map_err(|e| format!("blockhash rpc failed: {}", e))?; - - let body: serde_json::Value = resp + .map_err(|e| format!("{} failed: {}", method, e))? .json() .await - .map_err(|e| format!("blockhash parse failed: {}", e))?; - - let hash_str = body["result"]["value"]["blockhash"] - .as_str() - .ok_or("missing blockhash in response")?; - - bs58::decode(hash_str) - .into_vec() - .map_err(|e| format!("blockhash decode failed: {}", e)) + .map_err(|e| format!("{} parse failed: {}", method, e)) } - fn find_pda(program_id: &[u8], seeds: &[&[u8]]) -> Result, String> { - use sha2::Digest; - for bump in (0..=255u8).rev() { - let mut hasher = sha2::Sha256::new(); - for seed in seeds { - hasher.update(seed); - } - hasher.update([bump]); - hasher.update(program_id); - hasher.update(b"ProgramDerivedAddress"); - let hash = hasher.finalize(); - - // A valid PDA must be OFF the ed25519 curve. - // We check by trying to construct a VerifyingKey; if it fails, - // the point is off-curve and thus a valid PDA. - let candidate: [u8; 32] = hash[..32].try_into().map_err(|_| "hash length mismatch")?; - if ed25519_dalek::VerifyingKey::from_bytes(&candidate).is_err() { - return Ok(candidate.to_vec()); + async fn wait_for_confirmation(&self, sig: &str) -> Result<(), String> { + let deadline = + std::time::Instant::now() + std::time::Duration::from_secs(CONFIRM_TIMEOUT_SECS); + while std::time::Instant::now() < deadline { + let body = self + .rpc("getSignatureStatuses", serde_json::json!([[sig]])) + .await?; + let status = &body["result"]["value"][0]; + if !status.is_null() { + if !status["err"].is_null() { + return Err(format!("transaction {} failed: {}", sig, status["err"])); + } + if matches!( + status["confirmationStatus"].as_str(), + Some("confirmed" | "finalized") + ) { + return Ok(()); + } } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; } - Err("failed to find PDA: all bumps produce on-curve points".to_string()) + Err(format!( + "transaction {} not confirmed within {}s", + sig, CONFIRM_TIMEOUT_SECS + )) } - /// Encode a usize as Solana compact-u16 (variable-length, 1-3 bytes). - fn encode_compact_u16(n: usize) -> Vec { - let mut out = Vec::new(); - let mut val = n; - loop { - let byte = (val & 0x7F) as u8; - val >>= 7; - if val == 0 { - out.push(byte); - break; - } else { - out.push(byte | 0x80); - } - } - out + async fn get_recent_blockhash(&self) -> Result<[u8; 32], String> { + let body = self + .rpc( + "getLatestBlockhash", + serde_json::json!([{ "commitment": "finalized" }]), + ) + .await?; + let hash_str = body["result"]["value"]["blockhash"] + .as_str() + .ok_or("missing blockhash in response")?; + stx::pubkey(hash_str).map_err(|e| format!("blockhash: {e}")) } +} - #[allow(clippy::too_many_arguments)] - fn build_raw_transaction( - &self, - keypair: &[u8], - pubkey: &[u8], - program_id: &[u8], - state_pda: &[u8], - stake_pda: &[u8], - vk_pda: &[u8], - ix_data: &[u8], - blockhash: &[u8], - ) -> Result { - // Build a minimal Solana transaction message. - // Account ordering per Solana convention: - // 1. Writable signers first - // 2. Readonly signers - // 3. Writable non-signers - // 4. Readonly non-signers - // - // For SubmitProof the Anchor context is: - // state_registry (mut) → writable non-signer - // stake_account (readonly) → readonly non-signer - // verification_key (readonly) → readonly non-signer - // relayer (Signer, mut) → writable signer - // - // Final account list: - // index 0: relayer (writable signer) - // index 1: state_registry (writable non-signer) - // index 2: stake_account (readonly non-signer) - // index 3: verification_key(readonly non-signer) - // index 4: program_id (readonly non-signer) - // - // Header: (1 signer, 0 readonly-signed, 3 readonly-unsigned) - let mut message = vec![ - 1u8, // num_required_signatures - 0u8, // num_readonly_signed_accounts - 3u8, // num_readonly_unsigned_accounts (stake_account, vk, program_id) - 5u8, // num_account_keys - ]; - message.extend_from_slice(pubkey); // 0: relayer - message.extend_from_slice(state_pda); // 1: state_registry - message.extend_from_slice(stake_pda); // 2: stake_account - message.extend_from_slice(vk_pda); // 3: verification_key - message.extend_from_slice(program_id); // 4: program_id - - // Recent blockhash - if blockhash.len() >= 32 { - message.extend_from_slice(&blockhash[..32]); - } else { - message.extend_from_slice(blockhash); - message.extend_from_slice(&vec![0u8; 32 - blockhash.len()]); +/// Preflight failures that mean the work was already done by someone else. +fn is_already_done(rpc_error: &str) -> bool { + // `init` of an existing message record, or the hub's own guard. + rpc_error.contains("already in use") || rpc_error.contains("MessageAlreadyExecuted") +} + +/// Borsh encoding of the hub's `InboundMessage` (destination fields are implied). +pub fn encode_inbound(m: &CanonicalMessage) -> Vec { + let mut out = Vec::with_capacity(192); + out.extend_from_slice(&m.src_chain.to_le_bytes()); + out.extend_from_slice(&m.src_gateway); + out.extend_from_slice(&m.nonce.to_le_bytes()); + out.extend_from_slice(&m.sender); + out.extend_from_slice(&m.asset); + out.extend_from_slice(&m.amount.to_le_bytes()); + out.extend_from_slice(&m.recipient); + out.extend_from_slice(&m.extra_hash); + out +} + +/// `submit_proof(payload_hash, action, msg, proof_data)`. Account order +/// follows the Anchor `SubmitProof` struct. +pub fn submit_proof_ix( + program: &Pubkey, + relayer: &Pubkey, + package: &ProofPackage, + m: &CanonicalMessage, +) -> Result { + let mut data = stx::anchor_ix_discriminator("submit_proof").to_vec(); + data.extend_from_slice(&package.payload_hash); + data.push(m.action); + data.extend_from_slice(&encode_inbound(m)); + data.extend_from_slice(&(package.proof_bytes.len() as u32).to_le_bytes()); + data.extend_from_slice(&package.proof_bytes); + Ok(Instruction { + program_id: *program, + accounts: vec![ + AccountMeta::readonly(stx::pda(&[b"state"], program)?), + AccountMeta::writable(stx::pda(&[b"stake", relayer], program)?), + AccountMeta::readonly(stx::pda(&[b"vk"], program)?), + AccountMeta::writable(stx::pda(&[b"message", &package.payload_hash], program)?), + AccountMeta::signer(*relayer), + AccountMeta::readonly(stx::SYSTEM_PROGRAM), + ], + data, + }) +} + +/// The hub's `WrappedAsset` account: 8-byte discriminator, src_chain (u64), +/// asset ([u8;32]), mint (Pubkey), decimals_shift (u8). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WrappedAsset { + pub mint: Pubkey, + pub decimals_shift: u8, +} + +impl WrappedAsset { + pub fn decode(data: &[u8]) -> Result { + if data.len() < 81 { + return Err(format!( + "WrappedAsset account too short: {} bytes", + data.len() + )); } + Ok(Self { + mint: data[48..80].try_into().unwrap(), + decimals_shift: data[80], + }) + } +} - // Instructions: 1 instruction - message.push(1u8); // num_instructions - message.push(4u8); // program_id_index (index 4 in account keys) - // Accounts for SubmitProof: state_registry, stake_account, verification_key, relayer - message.push(4u8); // num_accounts for this instruction - message.push(1u8); // account index: state_registry - message.push(2u8); // account index: stake_account - message.push(3u8); // account index: verification_key - message.push(0u8); // account index: relayer - - // Instruction data length as compact-u16, then the data - message.extend_from_slice(&Self::encode_compact_u16(ix_data.len())); - message.extend_from_slice(ix_data); - - // Sign with ed25519 - let signing_key = ed25519_dalek::SigningKey::from_bytes( - keypair[..32] - .try_into() - .map_err(|_| "invalid keypair length")?, - ); - let signature = signing_key.sign(&message); +/// `execute_transfer(payload_hash, msg)`; account order follows `ExecuteTransfer`. +pub fn execute_transfer_ix( + program: &Pubkey, + payer: &Pubkey, + m: &CanonicalMessage, + wrapped: &WrappedAsset, +) -> Result { + let payload_hash = m.hash(); + let mut data = stx::anchor_ix_discriminator("execute_transfer").to_vec(); + data.extend_from_slice(&payload_hash); + data.extend_from_slice(&encode_inbound(m)); + Ok(Instruction { + program_id: *program, + accounts: vec![ + AccountMeta::signer(*payer), + AccountMeta::writable(stx::pda(&[b"message", &payload_hash], program)?), + AccountMeta::readonly(stx::pda( + &[b"wrapped", &m.src_chain.to_le_bytes(), &m.asset], + program, + )?), + AccountMeta::writable(wrapped.mint), + AccountMeta::readonly(m.recipient), + AccountMeta::writable(stx::associated_token_address(&m.recipient, &wrapped.mint)?), + AccountMeta::readonly(stx::pda(&[b"mint_authority"], program)?), + AccountMeta::readonly(stx::token_program()), + AccountMeta::readonly(stx::associated_token_program()), + AccountMeta::readonly(stx::SYSTEM_PROGRAM), + ], + data, + }) +} - // Build full transaction: num_signatures + signatures + message - let mut tx = Vec::new(); - tx.push(1u8); // num_signatures - tx.extend_from_slice(&signature.to_bytes()); - tx.extend_from_slice(&message); +/// Swap parameters carried in the gateway event's `data`: +/// `abi.encode(bytes32 tokenOut, uint256 minAmountOut, bytes swapData)`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SwapParams { + pub token_out: Pubkey, + pub min_amount_out: u64, + pub swap_data_hash: [u8; 32], +} - Ok(base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - &tx, - )) +impl SwapParams { + pub fn decode(data: &[u8]) -> Result { + let word = |i: usize| -> Result<&[u8], String> { + data.get(i * 32..(i + 1) * 32) + .ok_or_else(|| "swap data too short".to_string()) + }; + let token_out: Pubkey = word(0)?.try_into().unwrap(); + let min_word = word(1)?; + if min_word[..24].iter().any(|b| *b != 0) { + return Err("minAmountOut exceeds u64".into()); + } + if word(2)?[..31].iter().any(|b| *b != 0) || word(2)?[31] != 0x60 { + return Err("non-canonical swapData offset".into()); + } + let len_word = word(3)?; + if len_word[..24].iter().any(|b| *b != 0) { + return Err("swapData length too large".into()); + } + let len = u64::from_be_bytes(len_word[24..].try_into().unwrap()) as usize; + let swap_data = data.get(128..128 + len).ok_or("swapData truncated")?; + Ok(Self { + token_out, + min_amount_out: u64::from_be_bytes(min_word[24..].try_into().unwrap()), + swap_data_hash: crate::evm::keccak256(swap_data), + }) } } +/// `execute_swap(payload_hash, msg, token_out, min_amount_out, swap_data_hash)`; +/// account order follows `ExecuteSwap`. +pub fn execute_swap_ix( + program: &Pubkey, + payer: &Pubkey, + m: &CanonicalMessage, + wrapped: &WrappedAsset, + swap: &SwapParams, +) -> Result { + let payload_hash = m.hash(); + let pool = stx::pda( + &[b"liquidity_pool", &wrapped.mint, &swap.token_out], + program, + )?; + let mut data = stx::anchor_ix_discriminator("execute_swap").to_vec(); + data.extend_from_slice(&payload_hash); + data.extend_from_slice(&encode_inbound(m)); + data.extend_from_slice(&swap.token_out); + data.extend_from_slice(&swap.min_amount_out.to_le_bytes()); + data.extend_from_slice(&swap.swap_data_hash); + Ok(Instruction { + program_id: *program, + accounts: vec![ + AccountMeta::signer(*payer), + AccountMeta::readonly(stx::pda(&[b"state"], program)?), + AccountMeta::writable(stx::pda(&[b"message", &payload_hash], program)?), + AccountMeta::readonly(stx::pda( + &[b"wrapped", &m.src_chain.to_le_bytes(), &m.asset], + program, + )?), + AccountMeta::writable(pool), + AccountMeta::writable(stx::pda(&[b"pool_vault", &pool, &swap.token_out], program)?), + AccountMeta::readonly(swap.token_out), + AccountMeta::readonly(m.recipient), + AccountMeta::writable(stx::associated_token_address( + &m.recipient, + &swap.token_out, + )?), + AccountMeta::readonly(stx::token_program()), + AccountMeta::readonly(stx::associated_token_program()), + AccountMeta::readonly(stx::SYSTEM_PROGRAM), + ], + data, + }) +} + #[cfg(test)] mod tests { use super::*; + use crate::events::HUB_CHAIN_ID; + + fn sample_message(action: u8) -> CanonicalMessage { + CanonicalMessage { + src_chain: 1, + src_gateway: [1; 32], + dst_chain: HUB_CHAIN_ID, + dst_gateway: [2; 32], + nonce: 9, + action, + sender: [3; 32], + asset: [0; 32], + amount: 1_000, + recipient: [4; 32], + extra_hash: [5; 32], + } + } + + fn sample_package() -> ProofPackage { + let message = sample_message(ACTION_TRANSFER); + ProofPackage { + proof_bytes: vec![7; 256], + public_inputs: vec![], + sequence: 9, + payload_hash: message.hash(), + message: Some(message), + } + } #[test] - fn test_compact_u16() { - assert_eq!(ProofSubmitter::encode_compact_u16(0), vec![0x00]); - assert_eq!(ProofSubmitter::encode_compact_u16(127), vec![0x7F]); - assert_eq!(ProofSubmitter::encode_compact_u16(128), vec![0x80, 0x01]); - assert_eq!(ProofSubmitter::encode_compact_u16(492), vec![0xEC, 0x03]); + fn submit_proof_layout_and_size() { + let p = sample_package(); + let ix = submit_proof_ix(&[8; 32], &[6; 32], &p, p.message.as_ref().unwrap()).unwrap(); + // 8 discriminator + 32 hash + 1 action + 192 message + 4 len + 256 proof + assert_eq!(ix.data.len(), 8 + 32 + 1 + 192 + 4 + 256); + assert_eq!(&ix.data[8..40], &p.payload_hash); + assert_eq!(ix.data[40], ACTION_TRANSFER); + assert_eq!(&ix.data[41..49], &1u64.to_le_bytes()); + + let ixs = [stx::set_compute_unit_limit(PROOF_COMPUTE_UNITS), ix]; + let msg = stx::compile_message(&[6; 32], &ixs, &[9; 32]).unwrap(); + assert!( + 1 + 64 + msg.len() <= 1232, + "tx is {} bytes", + 1 + 64 + msg.len() + ); } #[test] - fn test_submitter_config() { - let config = SubmitterConfig { - rpc_url: "https://api.devnet.solana.com".to_string(), - program_id: "AKzpc9tvxfhLjj5AantKizK2YZgSjoyhLmYqRZy6b8Lz".to_string(), - keypair_path: "/tmp/keypair.json".to_string(), - max_retries: 3, - source_chain_id: 1, + fn execute_transfer_fits_and_binds_recipient_ata() { + let m = sample_message(ACTION_TRANSFER); + let wrapped = WrappedAsset { + mint: [0x33; 32], + decimals_shift: 10, }; - assert_eq!(config.max_retries, 3); + let ix = execute_transfer_ix(&[8; 32], &[6; 32], &m, &wrapped).unwrap(); + assert_eq!(ix.accounts.len(), 10); + assert_eq!( + ix.accounts[5].pubkey, + stx::associated_token_address(&m.recipient, &wrapped.mint).unwrap() + ); + let ixs = [stx::set_compute_unit_limit(EXECUTE_COMPUTE_UNITS), ix]; + let msg = stx::compile_message(&[6; 32], &ixs, &[9; 32]).unwrap(); + assert!(1 + 64 + msg.len() <= 1232); + } + + #[test] + fn swap_params_decode_strictly() { + let mut data = vec![0u8; 128]; + data[..32].copy_from_slice(&[0x55; 32]); + data[56..64].copy_from_slice(&3000u64.to_be_bytes()); + data[95] = 0x60; + data[127] = 3; + data.extend_from_slice(&[1, 2, 3]); + data.resize(160, 0); + let p = SwapParams::decode(&data).unwrap(); + assert_eq!(p.token_out, [0x55; 32]); + assert_eq!(p.min_amount_out, 3000); + assert_eq!(p.swap_data_hash, crate::evm::keccak256(&[1, 2, 3])); + + let mut too_big = data.clone(); + too_big[40] = 1; // minAmountOut above u64 + assert!(SwapParams::decode(&too_big).is_err()); + assert!(SwapParams::decode(&data[..100]).is_err()); + } + + #[test] + fn wrapped_asset_decode() { + let mut data = vec![0u8; 81]; + data[48..80].copy_from_slice(&[0x77; 32]); + data[80] = 10; + let w = WrappedAsset::decode(&data).unwrap(); + assert_eq!(w.mint, [0x77; 32]); + assert_eq!(w.decimals_shift, 10); + assert!(WrappedAsset::decode(&data[..50]).is_err()); + } + + #[test] + fn already_done_detection() { + assert!(is_already_done( + "Allocate: account Address { .. } already in use" + )); + assert!(is_already_done("Error Code: MessageAlreadyExecuted")); + assert!(!is_already_done("InvalidProof")); } } diff --git a/sdk/src/client.test.ts b/sdk/src/client.test.ts index 84405cd..559bc7c 100644 --- a/sdk/src/client.test.ts +++ b/sdk/src/client.test.ts @@ -4,8 +4,17 @@ * Tests use a mock fetch to avoid requiring a live relayer. */ -import { InterlinkClient, InterlinkApiError } from "./client"; -import { ChainId, TransferStatus } from "./types"; +import { Signer, getBytes, keccak256, zeroPadValue } from "ethers"; +import { InterlinkClient, InterlinkApiError, QuoteExpiredError } from "./client"; +import { + MessageAction, + erc20Interface, + gatewayInterface, + hashMessage, + swapExtraHash, + toBytes32Recipient, +} from "./gateway"; +import { ChainId, TransferQuote, TransferStatus } from "./types"; // ─── Mock fetch ─────────────────────────────────────────────────────────────── @@ -315,144 +324,204 @@ describe("InterlinkClient", () => { } }); - // ─── Transfers ──────────────────────────────────────────────────────────── - - test("transfer submits and returns Transfer object", async () => { - const transferResponse = { - sequence: 42, - tx_hash: "0xabc123", - block_number: 12345, - status: "pending_finality", + // ─── Transfers (gateway calls signed by the user) ───────────────────────── + + const GATEWAY = "0x000000000000000000000000000000000000bEEF"; + const USER = "0x00000000000000000000000000000000000b0b0b"; + const HUB_GATEWAY = "0x" + "7b".repeat(32); + const SOL_RECIPIENT = "8xk2fA3NVuwBnbYQSgqxk5jhUuA9DUFRh3JZbXMwG6ra"; + + /** + * Mock ethers signer. `sendTransaction` decodes the gateway call and emits + * the MessagePublished log the real gateway would, so the SDK's calldata, + * receipt parsing and payload-hash check are exercised end to end. + */ + function mockSigner(opts: { allowance?: bigint } = {}) { + const sent: { to: string; data: string; value?: bigint }[] = []; + const signer = { + sent, + provider: { getNetwork: async () => ({ chainId: 1n }) }, + getAddress: async () => USER, + call: async () => erc20Interface.encodeFunctionResult("allowance", [opts.allowance ?? 0n]), + sendTransaction: async (tx: { to: string; data: string; value?: bigint }) => { + sent.push(tx); + const logs: { address: string; topics: string[]; data: string }[] = []; + if (tx.to === GATEWAY) { + const call = gatewayInterface.parseTransaction({ data: tx.data, value: tx.value })!; + const a = call.args; + const isSwap = call.name === "initiateSwap"; + const message = { + srcChain: 1n, + srcGateway: zeroPadValue(GATEWAY, 32), + dstChain: a[0] as bigint, + dstGateway: HUB_GATEWAY, + nonce: 7n, + action: isSwap ? MessageAction.Swap : MessageAction.Transfer, + sender: zeroPadValue(USER, 32), + asset: zeroPadValue(a[1], 32), + amount: a[2] as bigint, + recipient: isSwap ? a[5] : a[3], + extraHash: isSwap ? swapExtraHash(a[3], a[4], getBytes(a[6])) : keccak256(a[4]), + }; + const ev = gatewayInterface.encodeEventLog("MessagePublished", [ + hashMessage(message), message.nonce, message.dstChain, message.dstGateway, + message.action, message.sender, message.asset, message.amount, + message.recipient, message.extraHash, "0x", + ]); + logs.push({ address: GATEWAY, topics: ev.topics, data: ev.data }); + } + return { + hash: "0x" + "ab".repeat(32), + wait: async () => ({ status: 1, hash: "0x" + "ab".repeat(32), blockNumber: 12345, logs }), + }; + }, }; - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => transferResponse, - text: async () => JSON.stringify(transferResponse), - }); + return signer as unknown as Signer & { sent: typeof sent }; + } - const quote = { + const freshQuote = () => + ({ sourceChain: ChainId.Ethereum, destChain: ChainId.Solana, - amountWei: 1_000_000_000_000_000_000n, + amountWei: 10n ** 18n, usdCents: 300_000, - feeWei: 500_000_000_000_000n, - feeTier: "Standard", - feeBps: 5, - estimatedSettlementSecs: 17, - sourceGasWei: 2_400_000_000_000_000n, - destFeeLamports: 5200, + feeWei: 0n, + feeTier: "Zero", + feeBps: 0, + estimatedSettlementSecs: 900, + sourceGasWei: 0n, + destFeeLamports: 0, expiresAt: Math.floor(Date.now() / 1000) + 60, - }; + }) as TransferQuote; + + test("transfer sends a native gateway call and returns its payload hash", async () => { + const c = new InterlinkClient({ relayerUrl: "http://localhost:8080", gatewayAddress: GATEWAY }); + const signer = mockSigner(); + const t = await c.transfer( + { recipient: SOL_RECIPIENT, destinationChain: ChainId.Solana, amountWei: 10n ** 18n }, + freshQuote(), + signer + ); + expect(signer.sent).toHaveLength(1); + expect(signer.sent[0].value).toBe(10n ** 18n); + const call = gatewayInterface.parseTransaction({ data: signer.sent[0].data })!; + expect(call.name).toBe("sendCrossChainMessage"); + expect(call.args[0]).toBe(BigInt(ChainId.Solana)); + expect(call.args[3]).toBe(toBytes32Recipient(SOL_RECIPIENT)); + expect(t.sequence).toBe(7); + expect(t.payloadHash).toMatch(/^0x[0-9a-f]{64}$/); + expect(t.status).toBe(TransferStatus.Pending); + expect(t.blockNumber).toBe(12345); + }); - const transfer = await client.transfer( + test("transfer approves ERC-20 allowance first when insufficient", async () => { + const c = new InterlinkClient({ gatewayAddress: GATEWAY, sourceChain: ChainId.Ethereum }); + const token = "0x00000000000000000000000000000000000a11ce"; + const signer = mockSigner({ allowance: 0n }); + await c.transfer( + { recipient: SOL_RECIPIENT, destinationChain: ChainId.Solana, amountWei: 500n, tokenAddress: token }, + freshQuote(), + signer + ); + expect(signer.sent).toHaveLength(2); + expect(erc20Interface.parseTransaction({ data: signer.sent[0].data })!.name).toBe("approve"); + expect(signer.sent[1].value).toBe(0n); + }); + + test("transfer skips approval when allowance suffices", async () => { + const c = new InterlinkClient({ gatewayAddress: GATEWAY }); + const signer = mockSigner({ allowance: 10_000n }); + await c.transfer( { - recipient: "8xk2abc", + recipient: SOL_RECIPIENT, destinationChain: ChainId.Solana, - amountWei: 1_000_000_000_000_000_000n, + amountWei: 500n, + tokenAddress: "0x00000000000000000000000000000000000a11ce", }, - quote + freshQuote(), + signer ); - - expect(transfer.sequence).toBe(42); - expect(transfer.txHash).toBe("0xabc123"); - expect(transfer.status).toBe(TransferStatus.PendingFinality); + expect(signer.sent).toHaveLength(1); }); test("transfer throws QuoteExpiredError for expired quote", async () => { - const { QuoteExpiredError } = await import("./client"); - - const expiredQuote = { - sourceChain: ChainId.Ethereum, - destChain: ChainId.Solana, - amountWei: 1_000_000_000_000_000_000n, - usdCents: 300_000, - feeWei: 0n, - feeTier: "Zero", - feeBps: 0, - estimatedSettlementSecs: 17, - sourceGasWei: 0n, - destFeeLamports: 0, - expiresAt: Math.floor(Date.now() / 1000) - 10, // expired 10s ago - }; + const c = new InterlinkClient({ gatewayAddress: GATEWAY }); + const expired = { ...freshQuote(), expiresAt: Math.floor(Date.now() / 1000) - 60 }; + await expect( + c.transfer({ recipient: SOL_RECIPIENT, destinationChain: ChainId.Solana, amountWei: 1n }, expired, mockSigner()) + ).rejects.toBeInstanceOf(QuoteExpiredError); + }); + test("transfer requires a gateway address", async () => { + const c = new InterlinkClient(); await expect( - client.transfer( - { recipient: "8xk2", destinationChain: ChainId.Solana, amountWei: 1n }, - expiredQuote - ) - ).rejects.toThrow(QuoteExpiredError); + c.transfer({ recipient: SOL_RECIPIENT, destinationChain: ChainId.Solana, amountWei: 1n }, freshQuote(), mockSigner()) + ).rejects.toThrow("gatewayAddress"); }); - test("getTransferStatus returns transfer details", async () => { - const statusResponse = { - sequence: 42, - tx_hash: "0xabc123", - block_number: 12345, - status: "complete", - recipient: "8xk2abc", - destination_chain: ChainId.Solana, - amount_wei: "1000000000000000000", - quote: {}, - settlement_signature: "5Ht9...", - total_elapsed_ms: 18_000, - }; - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => statusResponse, - text: async () => JSON.stringify(statusResponse), - }); + test("swap sends initiateSwap with bytes32 tokenOut and recipient", async () => { + const c = new InterlinkClient({ gatewayAddress: GATEWAY }); + const signer = mockSigner(); + const usdcMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; + const t = await c.swap( + { destinationChain: ChainId.Solana, recipient: SOL_RECIPIENT, tokenOut: usdcMint, minAmountOut: 3_000_000_000n }, + 10n ** 18n, + signer + ); + const call = gatewayInterface.parseTransaction({ data: signer.sent[0].data, value: signer.sent[0].value })!; + expect(call.name).toBe("initiateSwap"); + expect(call.args[3]).toBe(toBytes32Recipient(usdcMint)); + expect(call.args[4]).toBe(3_000_000_000n); + expect(t.quote).toBeUndefined(); + expect(t.payloadHash).toMatch(/^0x[0-9a-f]{64}$/); + }); - const transfer = await client.getTransferStatus(42); - expect(transfer).not.toBeNull(); - expect(transfer!.status).toBe(TransferStatus.Complete); - expect(transfer!.settlementSignature).toBe("5Ht9..."); - expect(transfer!.totalElapsedMs).toBe(18_000); + test("swap rejects minAmountOut above u64", async () => { + const c = new InterlinkClient({ gatewayAddress: GATEWAY }); + await expect( + c.swap( + { destinationChain: ChainId.Solana, recipient: SOL_RECIPIENT, tokenOut: SOL_RECIPIENT, minAmountOut: 1n << 64n }, + 1n, + mockSigner() + ) + ).rejects.toThrow("u64"); }); - test("getTransferStatus returns null on 404", async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 404, - json: async () => ({ error: "not found" }), - text: async () => "not found", - }); + // ─── Status (hub on-chain record via relayer) ───────────────────────────── - const transfer = await client.getTransferStatus(999); - expect(transfer).toBeNull(); + const HASH = "0x" + "07".repeat(32); + + test("getTransferStatus maps hub record states", async () => { + mockFetch({ [`/transfers/${HASH}`]: { status: "executed", attested_by: "Relayer111", attested_at: 1700000000 } }); + const info = await client.getTransferStatus(HASH); + expect(info.status).toBe(TransferStatus.Complete); + expect(info.attestedBy).toBe("Relayer111"); }); - test("swap submits and returns Transfer with swap fields", async () => { - const swapResponse = { - sequence: 99, - tx_hash: "0xdef456", - block_number: 12346, - status: "pending_proof", - quote: {}, - }; - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => swapResponse, - text: async () => JSON.stringify(swapResponse), - }); + test("getTransferStatus reports pending when not yet attested", async () => { + const other = "0x" + "08".repeat(32); + mockFetch({ [`/transfers/${other}`]: { status: "not_found" } }); + expect((await client.getTransferStatus(other)).status).toBe(TransferStatus.Pending); + }); - const transfer = await client.swap( - { - destinationChain: ChainId.Solana, - recipient: "8xk2abc", - tokenOut: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - minAmountOut: 3000_000_000n, - }, - 1_000_000_000_000_000_000n - ); + test("getTransferStatus rejects malformed hashes", async () => { + await expect(client.getTransferStatus("42")).rejects.toThrow("invalid payload hash"); + }); - expect(transfer.sequence).toBe(99); - expect(transfer.status).toBe(TransferStatus.PendingProof); + test("waitForSettlement resolves once executed", async () => { + const h = "0x" + "09".repeat(32); + mockFetch({ [`/transfers/${h}`]: { status: "executed" } }); + const info = await client.waitForSettlement(h, { pollIntervalMs: 1, timeoutMs: 1000 }); + expect(info.status).toBe(TransferStatus.Complete); }); - // ─── Config ─────────────────────────────────────────────────────────────── + test("webhook calls send the API token as a bearer header", async () => { + const c = new InterlinkClient({ relayerUrl: "http://localhost:8080", apiToken: "s3cret" }); + mockFetch({ "/webhooks": { count: 0, active_count: 0, webhooks: [] } }); + await c.listWebhooks(); + const init = (global.fetch as jest.Mock).mock.calls.at(-1)[1] as RequestInit; + expect((init.headers as Record).Authorization).toBe("Bearer s3cret"); + }); test("default relayer URL is used when none provided", () => { const c = new InterlinkClient(); diff --git a/sdk/src/client.ts b/sdk/src/client.ts index c978a41..9e688b0 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -3,8 +3,8 @@ * * Provides methods to: * - Get fee quotes before bridging - * - Initiate cross-chain transfers and swaps - * - Track transfer status + * - Initiate cross-chain transfers and swaps (gateway calls signed by the user) + * - Track transfer status by payload hash (read from the hub on-chain) * - Register webhook subscriptions * - Compare costs vs Wormhole, Stargate, Across * @@ -32,6 +32,15 @@ * ``` */ +import { Signer, isHexString } from "ethers"; +import { + ZERO_ADDRESS, + erc20Interface, + gatewayInterface, + parseMessagePublished, + toBytes32Recipient, + type PublishedMessage, +} from "./gateway"; import { ChainId, FeeComparison, @@ -40,6 +49,7 @@ import { TransferParams, TransferQuote, TransferStatus, + TransferStatusInfo, SwapParams, WebhookEventType, WebhookRegistration, @@ -47,6 +57,8 @@ import { CHAIN_FINALITY_SECONDS, } from "./types"; +const MAX_U64 = (1n << 64n) - 1n; + /** Default timeout for API requests (ms). */ const DEFAULT_TIMEOUT_MS = 10_000; @@ -59,11 +71,17 @@ export class InterlinkClient { private readonly relayerUrl: string; private readonly timeoutMs: number; private readonly debug: boolean; + private readonly gatewayAddress?: string; + private readonly sourceChain?: ChainId; + private readonly apiToken?: string; constructor(config: InterlinkConfig = {}) { this.relayerUrl = (config.relayerUrl ?? DEFAULT_RELAYER_URL).replace(/\/$/, ""); this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS; this.debug = config.debug ?? false; + this.gatewayAddress = config.gatewayAddress; + this.sourceChain = config.sourceChain; + this.apiToken = config.apiToken; } // ─── Health ──────────────────────────────────────────────────────────────── @@ -142,183 +160,187 @@ export class InterlinkClient { // ─── Transfers ───────────────────────────────────────────────────────────── /** - * Initiate a cross-chain transfer. + * Initiate a cross-chain transfer by calling `InterlinkGateway.sendCrossChainMessage` + * with the user's own signer. The relayer picks the event up from the chain; + * nothing is sent to the relayer and it never holds user funds. * - * Submits a transfer request to the relayer, which handles finality - * confirmation, ZK proof generation, and settlement on the destination chain. + * For ERC-20 tokens the gateway's allowance is checked and, if needed, an + * `approve` transaction is sent first. * * @param params - Transfer parameters (recipient, destination chain, amount). * @param quote - A valid quote obtained from {@link getQuote}. Must not be expired. - * @returns The submitted transfer with a sequence number for tracking. + * @param signer - ethers v6 signer on the source chain (pays gas and funds). + * @returns The transfer, identified by its canonical payload hash. * * @example * ```typescript + * const client = new InterlinkClient({ gatewayAddress: "0x…" }); * const quote = await client.getQuote({ ... }); * const transfer = await client.transfer({ - * recipient: "8xk2...", + * recipient: "8xk2…", // Solana address (base58) * destinationChain: ChainId.Solana, * amountWei: 1_000_000_000_000_000_000n, - * }, quote); - * console.log("Sequence:", transfer.sequence); + * }, quote, signer); + * await client.waitForSettlement(transfer.payloadHash); * ``` */ - async transfer(params: TransferParams, quote: TransferQuote): Promise { + async transfer(params: TransferParams, quote: TransferQuote, signer: Signer): Promise { if (quote.expiresAt < Math.floor(Date.now() / 1000)) { throw new QuoteExpiredError(quote.expiresAt); } - - const data = await this.post("/transfers", { - recipient: params.recipient, - destination_chain: params.destinationChain, - token_address: params.tokenAddress ?? "0x0000000000000000000000000000000000000000", - amount_wei: params.amountWei.toString(), - payload: params.payload ? Buffer.from(params.payload).toString("base64") : null, - quote_expires_at: quote.expiresAt, - }); - + const token = params.tokenAddress ?? ZERO_ADDRESS; + const data = gatewayInterface.encodeFunctionData("sendCrossChainMessage", [ + params.destinationChain, + token, + params.amountWei, + toBytes32Recipient(params.recipient), + params.payload ?? new Uint8Array(), + ]); + const published = await this.sendGatewayTx(signer, token, params.amountWei, data); return { - sequence: data.sequence, - txHash: data.tx_hash, - blockNumber: data.block_number, - status: data.status as TransferStatus, + sequence: Number(published.message.nonce), + payloadHash: published.payloadHash, + txHash: published.txHash, + blockNumber: published.blockNumber, + status: TransferStatus.Pending, params, quote, - settlementSignature: data.settlement_signature ?? undefined, - totalElapsedMs: data.total_elapsed_ms ?? undefined, - error: data.error ?? undefined, }; } /** - * Initiate a cross-chain swap (bridge + DEX swap on destination). - * - * Combines bridging with a destination-chain swap in a single atomic operation. - * Uses the intent solver to find the optimal path. + * Initiate a cross-chain swap (bridge, then swap on the hub's pool) by + * calling `InterlinkGateway.initiateSwap` with the user's signer. * * @param params - Swap parameters (destination chain, output token, min amount). - * @param amountWei - Amount to bridge in wei. - * @returns The submitted transfer with swap details. + * @param amountWei - Amount of `params.tokenIn` (default native) to bridge. + * @param signer - ethers v6 signer on the source chain. * * @example * ```typescript * const transfer = await client.swap({ * destinationChain: ChainId.Solana, - * recipient: "8xk2...", - * tokenOut: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", // USDC on Solana - * minAmountOut: 3000_000_000n, // 3000 USDC (6 decimals) - * }, 1_000_000_000_000_000_000n); + * recipient: "8xk2…", + * tokenOut: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", // USDC mint + * minAmountOut: 3000_000_000n, + * }, 1_000_000_000_000_000_000n, signer); * ``` */ - async swap(params: SwapParams, amountWei: bigint): Promise { - const data = await this.post("/swaps", { - destination_chain: params.destinationChain, - recipient: params.recipient, - token_out: params.tokenOut, - min_amount_out: params.minAmountOut.toString(), - amount_wei: amountWei.toString(), - swap_data: params.swapData ? Buffer.from(params.swapData).toString("base64") : null, - }); - + async swap(params: SwapParams, amountWei: bigint, signer: Signer): Promise { + if (params.minAmountOut > MAX_U64) { + throw new Error("minAmountOut must fit in a u64 (Solana token amounts)"); + } + const token = params.tokenIn ?? ZERO_ADDRESS; + const data = gatewayInterface.encodeFunctionData("initiateSwap", [ + params.destinationChain, + token, + amountWei, + toBytes32Recipient(params.tokenOut), + params.minAmountOut, + toBytes32Recipient(params.recipient), + params.swapData ?? new Uint8Array(), + ]); + const published = await this.sendGatewayTx(signer, token, amountWei, data); return { - sequence: data.sequence, - txHash: data.tx_hash, - blockNumber: data.block_number, - status: data.status as TransferStatus, + sequence: Number(published.message.nonce), + payloadHash: published.payloadHash, + txHash: published.txHash, + blockNumber: published.blockNumber, + status: TransferStatus.Pending, params: { recipient: params.recipient, destinationChain: params.destinationChain, + tokenAddress: token, amountWei, }, - quote: data.quote, - settlementSignature: data.settlement_signature ?? undefined, - totalElapsedMs: data.total_elapsed_ms ?? undefined, - error: data.error ?? undefined, }; } /** - * Get the current status of a transfer by sequence number. + * Hub status of a message, read from its on-chain record by the relayer. * - * Poll this endpoint to track transfer progress through stages: - * PendingFinality → PendingProof → PendingSettlement → Complete - * - * @param sequence - The sequence number returned from {@link transfer} or {@link swap}. - * @returns The current transfer state, or null if not found. - * - * @example - * ```typescript - * const status = await client.getTransferStatus(42); - * if (status?.status === TransferStatus.Complete) { - * console.log("Settled:", status.settlementSignature); - * } - * ``` + * @param payloadHash - Canonical payload hash returned by {@link transfer} / {@link swap}. */ - async getTransferStatus(sequence: number): Promise { - try { - const data = await this.get(`/transfers/${sequence}`); - return { - sequence: data.sequence, - txHash: data.tx_hash, - blockNumber: data.block_number, - status: data.status as TransferStatus, - params: { - recipient: data.recipient, - destinationChain: data.destination_chain, - amountWei: BigInt(data.amount_wei), - }, - quote: data.quote, - settlementSignature: data.settlement_signature ?? undefined, - totalElapsedMs: data.total_elapsed_ms ?? undefined, - error: data.error ?? undefined, - }; - } catch (e) { - if (e instanceof InterlinkApiError && e.statusCode === 404) { - return null; - } - throw e; + async getTransferStatus(payloadHash: string): Promise { + if (!isHexString(payloadHash, 32)) { + throw new Error(`invalid payload hash: ${payloadHash}`); } + const data = await this.get(`/transfers/${payloadHash.toLowerCase()}`); + const status = + data.status === "executed" + ? TransferStatus.Complete + : data.status === "attested" + ? TransferStatus.PendingSettlement + : TransferStatus.Pending; + return { + payloadHash, + status, + attestedBy: data.attested_by ?? undefined, + attestedAt: data.attested_at ?? undefined, + }; } /** - * Wait for a transfer to reach a terminal state (Complete or Failed). - * - * Polls {@link getTransferStatus} at the given interval until the transfer - * settles or the timeout is reached. - * - * @param sequence - Transfer sequence number. - * @param opts - Polling options. - * @returns The completed/failed transfer. - * @throws Error if the timeout is reached before settlement. - * - * @example - * ```typescript - * const result = await client.waitForSettlement(42, { timeoutMs: 120_000 }); - * console.log("Final status:", result.status); - * ``` + * Poll {@link getTransferStatus} until the message is executed on the hub. + * Source-chain finality alone is ~13 minutes on Ethereum, so the default + * timeout is 30 minutes. */ async waitForSettlement( - sequence: number, + payloadHash: string, opts: { pollIntervalMs?: number; timeoutMs?: number } = {} - ): Promise { - const pollInterval = opts.pollIntervalMs ?? 3_000; - const timeout = opts.timeoutMs ?? 120_000; + ): Promise { + const pollInterval = opts.pollIntervalMs ?? 15_000; + const timeout = opts.timeoutMs ?? 30 * 60_000; const deadline = Date.now() + timeout; while (Date.now() < deadline) { - const transfer = await this.getTransferStatus(sequence); - if ( - transfer && - (transfer.status === TransferStatus.Complete || - transfer.status === TransferStatus.Failed) - ) { - return transfer; + const info = await this.getTransferStatus(payloadHash); + if (info.status === TransferStatus.Complete) { + return info; } await new Promise((resolve) => setTimeout(resolve, pollInterval)); } + throw new Error(`Transfer ${payloadHash} did not settle within ${timeout}ms`); + } - throw new Error( - `Transfer ${sequence} did not settle within ${timeout}ms` - ); + /** Approve if needed, send the gateway call and decode its MessagePublished event. */ + private async sendGatewayTx( + signer: Signer, + token: string, + amount: bigint, + data: string + ): Promise { + if (!this.gatewayAddress) { + throw new Error("gatewayAddress must be set in InterlinkConfig to send transfers"); + } + const gateway = this.gatewayAddress; + const native = token === ZERO_ADDRESS; + + if (!native) { + const owner = await signer.getAddress(); + const raw = await signer.call({ + to: token, + data: erc20Interface.encodeFunctionData("allowance", [owner, gateway]), + }); + const [allowance] = erc20Interface.decodeFunctionResult("allowance", raw); + if (allowance < amount) { + this.log(`approving ${amount} of ${token} for ${gateway}`); + const approval = await signer.sendTransaction({ + to: token, + data: erc20Interface.encodeFunctionData("approve", [gateway, amount]), + }); + await approval.wait(); + } + } + + const tx = await signer.sendTransaction({ to: gateway, data, value: native ? amount : 0n }); + const receipt = await tx.wait(); + if (!receipt || receipt.status !== 1) { + throw new Error(`gateway transaction ${tx.hash} failed`); + } + const srcChain = BigInt(this.sourceChain ?? (await signer.provider!.getNetwork()).chainId); + const published = parseMessagePublished(receipt.logs, gateway, srcChain); + return { ...published, txHash: receipt.hash, blockNumber: receipt.blockNumber }; } // ─── Fee comparison ──────────────────────────────────────────────────────── @@ -449,7 +471,7 @@ export class InterlinkClient { const timeout = setTimeout(() => controller.abort(), this.timeoutMs); try { - const resp = await fetch(url, { signal: controller.signal }); + const resp = await fetch(url, { headers: this.authHeaders(path), signal: controller.signal }); if (!resp.ok) { throw new InterlinkApiError(resp.status, await resp.text(), path); } @@ -469,7 +491,7 @@ export class InterlinkClient { try { const resp = await fetch(url, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...this.authHeaders(path) }, body: JSON.stringify(body), signal: controller.signal, }); @@ -486,13 +508,19 @@ export class InterlinkClient { const url = `${this.relayerUrl}${path}`; this.log(`DELETE ${url}`); - const resp = await fetch(url, { method: "DELETE" }); + const resp = await fetch(url, { method: "DELETE", headers: this.authHeaders(path) }); if (!resp.ok) { throw new InterlinkApiError(resp.status, await resp.text(), path); } return resp.json(); } + private authHeaders(path: string): Record { + return this.apiToken && path.startsWith("/webhooks") + ? { Authorization: `Bearer ${this.apiToken}` } + : {}; + } + private log(msg: string) { if (this.debug) { console.debug(`[@interlink/sdk] ${msg}`); diff --git a/sdk/src/gateway.test.ts b/sdk/src/gateway.test.ts new file mode 100644 index 0000000..f4f719b --- /dev/null +++ b/sdk/src/gateway.test.ts @@ -0,0 +1,43 @@ +import { encodeBase58, getBytes, zeroPadValue } from "ethers"; +import { MessageAction, hashMessage, toBytes32Recipient } from "./gateway"; + +describe("gateway helpers", () => { + /** + * Shared vector: also pinned in contracts/evm/test (VECTOR_HASH), the hub's + * Rust tests and the relayer, and checked against the compiled gateway. + */ + test("hashMessage matches the cross-language vector", () => { + const h = hashMessage({ + srcChain: 2n, + srcGateway: "0x" + "7b".repeat(32), + dstChain: 31337n, + dstGateway: zeroPadValue("0x000000000000000000000000000000000000beef", 32), + nonce: 7n, + action: MessageAction.Transfer, + sender: "0x" + "22".repeat(32), + asset: "0x" + "00".repeat(32), + amount: 2_000_000_000_000_000_000n, + recipient: zeroPadValue("0x" + "a1".repeat(20), 32), + extraHash: "0x" + "00".repeat(32), + }); + expect(h).toBe("0x07b0f8b8ce5c6f82e0bb552073f1c35df9b0987c6e2c598c52ed5b59ee1c09bd"); + }); + + test("toBytes32Recipient handles EVM, hex32 and Solana addresses", () => { + expect(toBytes32Recipient("0x000000000000000000000000000000000000bEEF")).toBe( + "0x000000000000000000000000000000000000000000000000000000000000beef" + ); + const hex32 = "0x" + "ab".repeat(32); + expect(toBytes32Recipient(hex32)).toBe(hex32); + + const sol = "AKzpc9tvxfhLjj5AantKizK2YZgSjoyhLmYqRZy6b8Lz"; + const encoded = toBytes32Recipient(sol); + expect(getBytes(encoded)).toHaveLength(32); + expect(encodeBase58(encoded)).toBe(sol); + }); + + test("toBytes32Recipient rejects garbage", () => { + expect(() => toBytes32Recipient("not an address!")).toThrow("invalid recipient"); + expect(() => toBytes32Recipient("")).toThrow("invalid recipient"); + }); +}); diff --git a/sdk/src/gateway.ts b/sdk/src/gateway.ts new file mode 100644 index 0000000..667070c --- /dev/null +++ b/sdk/src/gateway.ts @@ -0,0 +1,157 @@ +/** + * InterlinkGateway helpers: ABI, calldata builders, recipient encoding and the + * canonical payload hash (identical to `InterlinkGateway.hashMessage`, the hub's + * `canonical_hash` and the relayer's `CanonicalMessage::hash`). + */ + +import { + AbiCoder, + Interface, + decodeBase58, + getAddress, + isAddress, + isHexString, + keccak256, + toBeHex, + toUtf8Bytes, + zeroPadValue, + type Log, +} from "ethers"; + +export const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; + +/** Message actions (must match the contracts). */ +export enum MessageAction { + Transfer = 1, + Swap = 2, + Nft = 3, + Call = 4, +} + +export const GATEWAY_ABI = [ + "function sendCrossChainMessage(uint64 dstChain, address token, uint256 amount, bytes32 recipient, bytes payload) payable returns (bytes32)", + "function initiateSwap(uint64 dstChain, address tokenIn, uint256 amountIn, bytes32 tokenOut, uint256 minAmountOut, bytes32 recipient, bytes swapData) payable returns (bytes32)", + "function executedMessages(bytes32 payloadHash) view returns (bool)", + "function remoteGateways(uint64 chainId) view returns (bytes32)", + "event MessagePublished(bytes32 indexed payloadHash, uint64 indexed nonce, uint64 dstChain, bytes32 dstGateway, uint8 action, bytes32 sender, bytes32 asset, uint256 amount, bytes32 recipient, bytes32 extraHash, bytes data)", +]; + +export const ERC20_ABI = [ + "function allowance(address owner, address spender) view returns (uint256)", + "function approve(address spender, uint256 amount) returns (bool)", +]; + +export const gatewayInterface = new Interface(GATEWAY_ABI); +export const erc20Interface = new Interface(ERC20_ABI); + +/** + * Encode a destination recipient as bytes32. + * Accepts an EVM address (left-padded), a 0x-prefixed 32-byte hex value, or a + * base58 Solana address (32 bytes). + */ +export function toBytes32Recipient(recipient: string): string { + // Check a copy: `isAddress` is a type guard and would narrow `recipient` to never. + const asAddress = recipient; + if (isAddress(asAddress)) { + return zeroPadValue(getAddress(asAddress), 32); + } + if (isHexString(recipient, 32)) { + return recipient.toLowerCase(); + } + let value: bigint; + try { + value = decodeBase58(recipient); + } catch { + throw new Error(`invalid recipient: ${recipient}`); + } + if (value === 0n || value >= 1n << 256n) { + throw new Error(`invalid recipient: ${recipient}`); + } + return toBeHex(value, 32); +} + +/** All fields of a canonical cross-chain message. */ +export interface CanonicalMessage { + srcChain: bigint; + srcGateway: string; // bytes32 + dstChain: bigint; + dstGateway: string; // bytes32 + nonce: bigint; + action: MessageAction; + sender: string; // bytes32 + asset: string; // bytes32 + amount: bigint; + recipient: string; // bytes32 + extraHash: string; // bytes32 +} + +const MESSAGE_TYPEHASH = keccak256(toUtf8Bytes("InterlinkMessage(v1)")); + +/** keccak256(abi.encode(typeHash, ...fields)) */ +export function hashMessage(m: CanonicalMessage): string { + return keccak256( + AbiCoder.defaultAbiCoder().encode( + [ + "bytes32", "uint64", "bytes32", "uint64", "bytes32", "uint64", + "uint8", "bytes32", "bytes32", "uint256", "bytes32", "bytes32", + ], + [ + MESSAGE_TYPEHASH, m.srcChain, m.srcGateway, m.dstChain, m.dstGateway, m.nonce, + m.action, m.sender, m.asset, m.amount, m.recipient, m.extraHash, + ] + ) + ); +} + +/** `extraHash` the gateway computes for a swap. */ +export function swapExtraHash(tokenOut: string, minAmountOut: bigint, swapData: Uint8Array): string { + return keccak256( + AbiCoder.defaultAbiCoder().encode( + ["bytes32", "uint256", "bytes32"], + [tokenOut, minAmountOut, keccak256(swapData)] + ) + ); +} + +/** A `MessagePublished` event decoded from a receipt. */ +export interface PublishedMessage { + payloadHash: string; + message: CanonicalMessage; + data: string; +} + +/** + * Find and decode the gateway's `MessagePublished` log, rebuilding the + * canonical message and checking that it hashes to the emitted payload hash. + */ +export function parseMessagePublished( + logs: readonly Log[], + gatewayAddress: string, + srcChain: bigint +): PublishedMessage { + const gateway = getAddress(gatewayAddress); + for (const log of logs) { + if (getAddress(log.address) !== gateway) continue; + const parsed = gatewayInterface.parseLog({ topics: [...log.topics], data: log.data }); + if (!parsed || parsed.name !== "MessagePublished") continue; + const a = parsed.args; + const message: CanonicalMessage = { + srcChain, + srcGateway: zeroPadValue(gateway, 32), + dstChain: a.dstChain, + dstGateway: a.dstGateway, + nonce: a.nonce, + action: Number(a.action) as MessageAction, + sender: a.sender, + asset: a.asset, + amount: a.amount, + recipient: a.recipient, + extraHash: a.extraHash, + }; + if (hashMessage(message) !== a.payloadHash) { + throw new Error("MessagePublished fields do not match its payload hash"); + } + return { payloadHash: a.payloadHash, message, data: a.data }; + } + throw new Error("no MessagePublished event from the gateway in this receipt"); +} diff --git a/sdk/src/index.ts b/sdk/src/index.ts index d11c749..76a8690 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -1,7 +1,10 @@ /** * @interlink/sdk — InterLink ZK cross-chain bridge SDK * - * Zero-fee bridging under $1,000. ZK-SNARK security. <30s settlement. + * Transfers are gateway transactions signed by the user; the relayer only + * observes the chain. Status is tracked by canonical payload hash. + * + * NOTE: the protocol is experimental and unaudited; see docs/SECURITY_REVIEW.md. * * @example * ```typescript @@ -36,8 +39,20 @@ export { CHAIN_FINALITY_SECONDS, DEFAULT_RELAYER_URL, } from "./types"; +export { + GATEWAY_ABI, + ERC20_ABI, + MessageAction, + ZERO_ADDRESS, + hashMessage, + parseMessagePublished, + swapExtraHash, + toBytes32Recipient, +} from "./gateway"; +export type { CanonicalMessage, PublishedMessage } from "./gateway"; export type { InterlinkConfig, + TransferStatusInfo, TransferParams, TransferQuote, Transfer, diff --git a/sdk/src/types.ts b/sdk/src/types.ts index f383ce6..299a38d 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -13,7 +13,8 @@ export enum ChainId { ArbitrumOne = 42161, ArbitrumNova = 42170, Base = 8453, - Solana = 900, // InterLink internal ID for Solana Hub + /** InterLink hub chain ID. Must equal HUB_CHAIN_ID in the contracts and relayer. */ + Solana = 2, } export const CHAIN_NAMES: Record = { @@ -41,6 +42,8 @@ export const CHAIN_FINALITY_SECONDS: Partial> = { /** Status of a cross-chain transfer. */ export enum TransferStatus { + /** Not yet attested on the hub (waiting for source finality or proof). */ + Pending = "pending", /** Transfer submitted on source chain, waiting for finality. */ PendingFinality = "pending_finality", /** Source block is final, ZK proof being generated. */ @@ -95,8 +98,10 @@ export interface TransferParams { /** A submitted transfer with tracking info. */ export interface Transfer { - /** Unique sequence number assigned by the gateway. */ + /** Gateway nonce (unique per source gateway, not globally). */ sequence: number; + /** Canonical payload hash: the global identifier used for tracking. */ + payloadHash: string; /** Source transaction hash. */ txHash: string; /** Source block number. */ @@ -105,8 +110,8 @@ export interface Transfer { status: TransferStatus; /** Parameters used for this transfer. */ params: TransferParams; - /** Quote used for this transfer. */ - quote: TransferQuote; + /** Quote used for this transfer (absent for swaps). */ + quote?: TransferQuote; /** Solana Hub settlement signature (once complete). */ settlementSignature?: string; /** Total elapsed time in ms (once complete). */ @@ -123,8 +128,10 @@ export interface SwapParams { destinationChain: ChainId; /** Recipient on destination. */ recipient: string; - /** Desired output token address on destination chain. */ + /** Output token on the destination chain (Solana mint, base58 or bytes32 hex). */ tokenOut: string; + /** Input token on the source chain (zero address / omitted = native). */ + tokenIn?: string; /** Minimum output amount (slippage protection). */ minAmountOut: bigint; /** Optional swap routing data for destination DEX. */ @@ -199,10 +206,26 @@ export interface InterlinkConfig { * Can be an RPC URL string or an ethers Provider. */ evmRpcUrl?: string; + /** InterlinkGateway address on the source chain (required for transfer/swap). */ + gatewayAddress?: string; + /** Source chain ID; defaults to the signer provider network. */ + sourceChain?: ChainId; + /** Bearer token for the relayer webhook endpoints (API_TOKEN on the relayer). */ + apiToken?: string; /** Request timeout in milliseconds (default: 10_000). */ timeoutMs?: number; /** Whether to enable verbose SDK logging (default: false). */ debug?: boolean; } +/** On-chain status of a message on the hub, as reported by GET /transfers/{payloadHash}. */ +export interface TransferStatusInfo { + payloadHash: string; + status: TransferStatus; + /** Relayer that attested the proof (base58), once attested. */ + attestedBy?: string; + /** Unix time of attestation, once attested. */ + attestedAt?: number; +} + export const DEFAULT_RELAYER_URL = "https://relayer.interlink.protocol";