Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions docs/pr/chideraisiguzor-381-382-383.md
Original file line number Diff line number Diff line change
@@ -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<u8>` 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
51 changes: 50 additions & 1 deletion proof_registry/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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 ───────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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<String> {
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<Address> {
env.storage().instance().get(&ProofKey::AxelarGateway)
}

// ── Message Receipt ───────────────────────────────────────────────────────

/// Receive a Wormhole VAA, verify it, and store the decoded proof.
Expand Down Expand Up @@ -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);
}
Expand Down
130 changes: 127 additions & 3 deletions proof_registry/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) ────────────────────────────
Expand Down Expand Up @@ -100,6 +100,7 @@ struct Ctx {
env: Env,
admin: Address,
wormhole_core: Address,
axelar_gateway: Address,
contract_id: Address,
}

Expand All @@ -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
}

Expand Down Expand Up @@ -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())));
}

Expand All @@ -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(&eth, &eth_src);
c.set_authorized_axelar_source(&base, &base_src);
assert_eq!(c.get_authorized_axelar_source(&eth), 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]
Expand Down