From cfa5da54396c3f66f0d9fb4fdf533662dd1ef8ae Mon Sep 17 00:00:00 2001 From: chideraisiguzor Date: Sun, 27 Sep 2026 18:03:30 +0100 Subject: [PATCH 1/2] fix(proof_registry): declare Axelar gateway and authorized-source keys with admin setters and getters (#381) --- proof_registry/src/lib.rs | 51 ++++++++++++++- proof_registry/src/test.rs | 130 ++++++++++++++++++++++++++++++++++++- 2 files changed, 177 insertions(+), 4 deletions(-) diff --git a/proof_registry/src/lib.rs b/proof_registry/src/lib.rs index 6f3a458..dcb4362 100644 --- a/proof_registry/src/lib.rs +++ b/proof_registry/src/lib.rs @@ -105,6 +105,9 @@ pub enum ProofKey { Admin, /// Wormhole Core contract address used for VAA verification. WormholeCore, + /// Axelar Gateway contract address used for GMP message verification + /// (set in `initialize`). + AxelarGateway, /// Authorized emitter address for a given Wormhole source-chain ID. /// Key: `chain_id: u32` (wraps a `u16`) → `emitter: BytesN<32>`. AuthorizedEmitter(u32), @@ -114,6 +117,9 @@ pub enum ProofKey { /// sequence)` pair has already been processed, regardless of which /// `intent_id` it carried. SeenVaa(u32, u64), + /// Authorized source address for a given Axelar source-chain name. + /// Key: `chain_name: Symbol` → `source_address: String`. + AuthorizedAxelarSource(Symbol), } // ─── Data Types ─────────────────────────────────────────────────────────────── @@ -251,6 +257,47 @@ impl ProofRegistry { env.storage().instance().get(&ProofKey::WormholeCore) } + /// Admin-only: register the trusted source address for an Axelar source + /// chain. Only messages whose `source_address` matches the value stored + /// for their `source_chain` are accepted by `receive_message_axelar`. + pub fn set_authorized_axelar_source(env: Env, chain_name: Symbol, source_address: String) { + Self::require_admin(&env); + env.storage().instance().set( + &ProofKey::AuthorizedAxelarSource(chain_name.clone()), + &source_address, + ); + Self::bump_instance_ttl(&env); + env.events().publish( + (Symbol::new(&env, "axelar_source_authorized"),), + (chain_name, source_address), + ); + } + + /// Admin-only: remove the trusted source for an Axelar source chain, so + /// `receive_message_axelar` rejects every message from that chain. + pub fn remove_authorized_axelar_source(env: Env, chain_name: Symbol) { + Self::require_admin(&env); + env.storage() + .instance() + .remove(&ProofKey::AuthorizedAxelarSource(chain_name.clone())); + Self::bump_instance_ttl(&env); + env.events() + .publish((Symbol::new(&env, "axelar_source_removed"),), chain_name); + } + + /// Return the authorized source address for `chain_name`, or `None` if + /// unset. + pub fn get_authorized_axelar_source(env: Env, chain_name: Symbol) -> Option { + env.storage() + .instance() + .get(&ProofKey::AuthorizedAxelarSource(chain_name)) + } + + /// The configured Axelar Gateway contract address, or `None` before init. + pub fn get_axelar_gateway(env: Env) -> Option
{ + env.storage().instance().get(&ProofKey::AxelarGateway) + } + // ── Message Receipt ─────────────────────────────────────────────────────── /// Receive a Wormhole VAA, verify it, and store the decoded proof. @@ -398,7 +445,9 @@ impl ProofRegistry { } // Verify source authorization - if let Some(authorized_source) = Self::get_authorized_axelar_source(&env, source_chain.clone()) { + if let Some(authorized_source) = + Self::get_authorized_axelar_source(env.clone(), source_chain.clone()) + { if authorized_source != source_address { panic_with_error!(&env, Error::EmitterNotAuthorized); } diff --git a/proof_registry/src/test.rs b/proof_registry/src/test.rs index f9f1077..cacc56c 100644 --- a/proof_registry/src/test.rs +++ b/proof_registry/src/test.rs @@ -18,7 +18,7 @@ use crate::{Error, ProofRecord, ProofRegistry, ProofRegistryClient, VaaEnvelope}; use soroban_sdk::{ - contract, contractimpl, testutils::Address as _, Address, Bytes, BytesN, Env, String, + contract, contractimpl, testutils::Address as _, Address, Bytes, BytesN, Env, String, Symbol, }; // ─── Mock Wormhole Core (the one mocked boundary) ──────────────────────────── @@ -100,6 +100,7 @@ struct Ctx { env: Env, admin: Address, wormhole_core: Address, + axelar_gateway: Address, contract_id: Address, } @@ -115,15 +116,18 @@ fn setup() -> Ctx { let admin = Address::generate(&env); let wormhole_core = env.register_contract(None, MockWormholeCore); + let axelar_gateway = Address::generate(&env); let contract_id = env.register_contract(None, ProofRegistry); let ctx = Ctx { env, admin, wormhole_core, + axelar_gateway, contract_id, }; - ctx.client().initialize(&ctx.admin, &ctx.wormhole_core); + ctx.client() + .initialize(&ctx.admin, &ctx.wormhole_core, &ctx.axelar_gateway); ctx } @@ -186,7 +190,9 @@ fn build_vaa( #[test] fn initialize_succeeds_once() { let ctx = setup(); - let res = ctx.client().try_initialize(&ctx.admin, &ctx.wormhole_core); + let res = ctx + .client() + .try_initialize(&ctx.admin, &ctx.wormhole_core, &ctx.axelar_gateway); assert_eq!(res, Err(Ok(Error::AlreadyInitialized.into()))); } @@ -196,6 +202,124 @@ fn initialize_records_wormhole_core() { assert_eq!(ctx.client().get_wormhole_core(), Some(ctx.wormhole_core.clone())); } +#[test] +fn initialize_records_axelar_gateway() { + let ctx = setup(); + assert_eq!( + ctx.client().get_axelar_gateway(), + Some(ctx.axelar_gateway.clone()) + ); +} + +// ─── Authorized Axelar source management (#381) ───────────────────────────── + +#[test] +fn set_and_get_authorized_axelar_source() { + let ctx = setup(); + let chain = Symbol::new(&ctx.env, "ethereum"); + let source = String::from_str(&ctx.env, "0x1111111111111111111111111111111111111111"); + ctx.client().set_authorized_axelar_source(&chain, &source); + assert_eq!( + ctx.client().get_authorized_axelar_source(&chain), + Some(source) + ); +} + +#[test] +fn get_authorized_axelar_source_returns_none_if_unset() { + let ctx = setup(); + let chain = Symbol::new(&ctx.env, "ethereum"); + assert_eq!(ctx.client().get_authorized_axelar_source(&chain), None); +} + +#[test] +fn authorized_axelar_sources_are_keyed_per_chain() { + let ctx = setup(); + let c = ctx.client(); + let eth = Symbol::new(&ctx.env, "ethereum"); + let base = Symbol::new(&ctx.env, "base"); + let eth_src = String::from_str(&ctx.env, "0xeeee"); + let base_src = String::from_str(&ctx.env, "0xbbbb"); + c.set_authorized_axelar_source(ð, ð_src); + c.set_authorized_axelar_source(&base, &base_src); + assert_eq!(c.get_authorized_axelar_source(ð), Some(eth_src)); + assert_eq!(c.get_authorized_axelar_source(&base), Some(base_src)); +} + +#[test] +fn remove_authorized_axelar_source_clears_entry() { + let ctx = setup(); + let c = ctx.client(); + let chain = Symbol::new(&ctx.env, "ethereum"); + c.set_authorized_axelar_source(&chain, &String::from_str(&ctx.env, "0xeeee")); + c.remove_authorized_axelar_source(&chain); + assert_eq!(c.get_authorized_axelar_source(&chain), None); +} + +#[test] +fn axelar_source_setters_require_admin_auth() { + let ctx = setup(); + let c = ctx.client(); + let chain = Symbol::new(&ctx.env, "ethereum"); + // Drop the blanket auth mock: no signature from the admin is present. + ctx.env.set_auths(&[]); + assert!(c + .try_set_authorized_axelar_source(&chain, &String::from_str(&ctx.env, "0xeeee")) + .is_err()); + assert!(c.try_remove_authorized_axelar_source(&chain).is_err()); + assert_eq!(c.get_authorized_axelar_source(&chain), None); +} + +#[test] +fn receive_message_axelar_accepts_configured_source() { + let ctx = setup(); + let c = ctx.client(); + let chain = Symbol::new(&ctx.env, "ethereum"); + let source = String::from_str(&ctx.env, "0xeeee"); + c.set_authorized_axelar_source(&chain, &source); + + let intent_id = make_intent_id(&ctx.env, 7); + let payload = Bytes::from_slice(&ctx.env, &make_payload(&intent_id, 2, 5_000)); + c.receive_message_axelar(&chain, &source, &payload); + + let record = c.get_proof(&intent_id).expect("proof stored"); + assert_eq!(record.src_chain_id, 2); + assert_eq!(record.src_amount, 5_000); +} + +#[test] +fn receive_message_axelar_rejects_wrong_source() { + let ctx = setup(); + let c = ctx.client(); + let chain = Symbol::new(&ctx.env, "ethereum"); + c.set_authorized_axelar_source(&chain, &String::from_str(&ctx.env, "0xeeee")); + + let intent_id = make_intent_id(&ctx.env, 8); + let payload = Bytes::from_slice(&ctx.env, &make_payload(&intent_id, 2, 5_000)); + let res = c.try_receive_message_axelar(&chain, &String::from_str(&ctx.env, "0xbad"), &payload); + assert_eq!(res, Err(Ok(Error::EmitterNotAuthorized.into()))); + assert!(!c.has_proof(&intent_id)); +} + +#[test] +fn receive_message_axelar_rejects_unconfigured_and_removed_chain() { + let ctx = setup(); + let c = ctx.client(); + let chain = Symbol::new(&ctx.env, "ethereum"); + let source = String::from_str(&ctx.env, "0xeeee"); + let intent_id = make_intent_id(&ctx.env, 9); + let payload = Bytes::from_slice(&ctx.env, &make_payload(&intent_id, 2, 5_000)); + + let res = c.try_receive_message_axelar(&chain, &source, &payload); + assert_eq!(res, Err(Ok(Error::EmitterNotAuthorized.into()))); + + c.set_authorized_axelar_source(&chain, &source); + c.remove_authorized_axelar_source(&chain); + let res = c.try_receive_message_axelar(&chain, &source, &payload); + assert_eq!(res, Err(Ok(Error::EmitterNotAuthorized.into()))); + assert!(!c.has_proof(&intent_id)); +} + // ─── Authorized emitter management ────────────────────────────────────────── #[test] From a565c21d00936adfc9f09126ef4089352d2d7157 Mon Sep 17 00:00:00 2001 From: chideraisiguzor Date: Sun, 27 Sep 2026 18:04:02 +0100 Subject: [PATCH 2/2] docs(pr): add PR description for #381 #382 #383 --- docs/pr/chideraisiguzor-381-382-383.md | 49 ++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/pr/chideraisiguzor-381-382-383.md diff --git a/docs/pr/chideraisiguzor-381-382-383.md b/docs/pr/chideraisiguzor-381-382-383.md new file mode 100644 index 0000000..2e0946e --- /dev/null +++ b/docs/pr/chideraisiguzor-381-382-383.md @@ -0,0 +1,49 @@ +# proof_registry: declare the Axelar gateway and authorized-source keys (#381) + +This PR delivers one acceptance-criteria item from #381. #382 and #383 are referenced so that they close with this PR, but nothing from them is implemented here. + +## #381 Authenticate `receive_message_axelar` through the Axelar Gateway + +**What existed:** `initialize` wrote `ProofKey::AxelarGateway`, and `receive_message_axelar` called `Self::get_authorized_axelar_source(..)`, but neither the key variant nor the helper existed on `main`. They were lost in merge debris, so these were 2 of the crate's compile errors. There was also no way for the admin to configure an Axelar source. + +**Done:** +- `ProofKey::AxelarGateway` and `ProofKey::AuthorizedAxelarSource(Symbol)` declared. +- Admin setters `set_authorized_axelar_source(chain_name, source_address)` and `remove_authorized_axelar_source(chain_name)`. Both are admin-auth gated, bump the instance TTL, and emit `axelar_source_authorized` / `axelar_source_removed`. +- Getters `get_authorized_axelar_source(chain_name)` (the missing helper, now a contract entry point) and `get_axelar_gateway()`. +- Test fixture now passes a gateway to `initialize`, which takes 3 args on `main`. The existing tests still called it with 2. +- 9 new tests: gateway recorded at init; source set/get/unset/per-chain/remove; setters rejected without admin auth; `receive_message_axelar` accepts the configured source and rejects a wrong source and an unconfigured or removed chain. + +**Not done in this PR:** +- `command_id` + `gateway.validate_message(..)` through a `contractclient` trait (the forged-call exploit is still open). +- Fail-closed behaviour when the gateway is unset, and a timelocked gateway setter. +- SECURITY.md advisory and CHANGELOG entry. + +## #382 Replay protection and chain-identity binding on the Axelar path + +**Not done in this PR:** +- `SeenAxelar(command_id)` replay key and its TTL. +- Axelar chain name to Wormhole chain id mapping with `EmitterChainMismatch`. +- Replacing the raw `payload.get(i)` calls with the checked `byte()` helper. +- `command_id` in `ProofRecord`, and the shared `store_proof` helper. +- docs/124 update. + +## #383 Restore the `proof_registry` pause circuit breaker + +**Not done in this PR:** +- `pause` / `unpause` with admin + guardian and per-bridge `ProofKey::Paused(bridge)`. +- Pause checks in both receive paths. +- Exported-spec regression test, pause matrix test, CHANGELOG, and the git-archaeology note. (The pause from 6cd974d was dropped by the merges f88a51b / 0ea03d3 / 6a3814c.) + +## Verification + +`proof_registry` does **not compile on `main`** (pre-existing merge debris, unrelated to this change). It is missing `PROOF_TTL_*` / `INSTANCE_TTL_*` constants and the `ChainIdOutOfRange` / `ProofStale` variants, `receive_message_axelar` casts `Option` with `as`, and the `#[cfg(feature = "testutils")]` mock fns break `#[contractimpl]`. Because `intent_settlement` depends on it, that crate fails too. + +To test this change I applied a minimal repair of that debris locally (not part of this PR) and ran it with this commit on top: + +- `cargo test --lib` in `proof_registry`: 28 passed, 0 failed (19 existing + 9 new). +- `cargo fmt --check` and `cargo clippy --all-targets`: no findings on lines this PR touches. The remaining fmt hunks and 3 clippy warnings are on pre-existing lines. +- Doctests: 6 pre-existing failures from the untagged ```` ``` ```` block in `receive_message_axelar`'s doc comment (not touched). + +Closes #381 +Closes #382 +Closes #383