diff --git a/CHANGELOG.md b/CHANGELOG.md index 935afb1..71ad4ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,29 @@ first deploys to mainnet. ## [Unreleased] +### Added + +- **`proof_registry` pause/circuit-breaker**: `pause`/`unpause`/`is_paused` + gate `receive_message` for incident response, mirroring + `intent_settlement`'s existing pause mechanism. `get_proof`/`has_proof` + remain available during a pause (#264). +- **`is_intent_fillable` view** on `intent_settlement`: lets off-chain solver + bots self-check whether a `fill_intent` call would pass its pre-transfer + guards (intent exists, state `Accepted`, caller matches `intent.solver`, + deadline not passed) before spending a transaction (#259). +- **Proof expiry/freshness**: `proof_registry::get_fresh_proof` rejects a + `ProofRecord` older than the new `PROOF_VALIDITY_WINDOW` (1 hour) with a + dedicated `ProofStale` error, distinct from `ProofNotFound` (#254). +- **`src_chain`-to-Wormhole-chain-ID mapping**: + `IntentSettlement::src_chain_to_wormhole_id` is the single source of truth + translating canonical `src_chain` strings to their numeric Wormhole chain + ID, failing closed with `SrcChainNotSupported` for unmapped chains (#253). + ### Fixed +- `intent_settlement/src/test.rs`: restored a missing closing brace in + `pauser_cannot_unpause` (left unclosed by a prior merge) that made the + file unparseable and broke `cargo fmt`/`cargo test` for the whole crate. - `deregister_solver` now refuses to return a solver's bond while they hold an `Accepted` intent, closing a path to dodge `slash_solver` by withdrawing before the fill window expired. diff --git a/README.md b/README.md index 313e91e..e3a53d4 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,10 @@ stellar contract invoke --id --source --networ stellar contract invoke --id --source --network testnet -- \ accept_intent --solver --intent_id +# Read-only: solver self-checks that fill_intent would succeed before spending a transaction +stellar contract invoke --id --source --network testnet -- \ + is_intent_fillable --intent_id --solver + # Solver delivers the output and closes out the intent stellar contract invoke --id --source --network testnet -- \ fill_intent --solver --intent_id --fill_amount 35000000000 diff --git a/docs/124-proof-verification-interface.md b/docs/124-proof-verification-interface.md index edf43dc..61c598d 100644 --- a/docs/124-proof-verification-interface.md +++ b/docs/124-proof-verification-interface.md @@ -352,11 +352,11 @@ confirmed. | Question | Deferred to | |----------|-------------| | Who runs the VAA relay bot (solver, Vortex, or permissionless)? | Implementation | -| Chain ID namespace mapping (EVM chain ID → Wormhole chain ID) | Implementation | +| Chain ID namespace mapping (EVM chain ID → Wormhole chain ID) | Resolved — issue #253, `IntentSettlement::src_chain_to_wormhole_id` | | Grace period if proof arrives after fill window but fill was honest | v2 dispute resolution | | `ProofRegistry` upgrade authority (same Admin or separate?) | Implementation | | Handling non-EVM source chains (Solana, Cosmos) | Future spike | -| Proof expiry (how long is a proof valid after receipt?) | Implementation | +| Proof expiry (how long is a proof valid after receipt?) | Resolved — issue #254, `PROOF_VALIDITY_WINDOW` | --- diff --git a/docs/129-proof-mismatch-fallback.md b/docs/129-proof-mismatch-fallback.md index 4f59b77..836e520 100644 --- a/docs/129-proof-mismatch-fallback.md +++ b/docs/129-proof-mismatch-fallback.md @@ -175,6 +175,11 @@ used in validation and must be kept in sync with the supported-chains list Strings not in this table: `fill_intent` panics with `Error::SrcChainNotSupported` (a new error code, separate from the allowlist variant). +**Implemented** (issue #253): this table is realized as +`IntentSettlement::src_chain_to_wormhole_id` in `intent_settlement/src/lib.rs`, +tested against every chain in the table above. `fill_intent` itself does not +yet call it — that wiring is issue #5's proof-gated fill logic. + --- ## 5. Dispute Path for Contested Proofs diff --git a/docs/132-supported-chains.md b/docs/132-supported-chains.md index f51a095..2697e31 100644 --- a/docs/132-supported-chains.md +++ b/docs/132-supported-chains.md @@ -208,6 +208,10 @@ mapping table lives in §4 of [129-proof-mismatch-fallback.md](./129-proof-misma and must be kept in sync with the canonical strings listed in §2 of this document. +**Implemented** (issue #253): `IntentSettlement::src_chain_to_wormhole_id` in +`intent_settlement/src/lib.rs` is the single source of truth for this mapping. +An unmapped `src_chain` string fails closed with `Error::SrcChainNotSupported`. + --- *Closes #132* diff --git a/docs/mainnet-deployment-runbook.md b/docs/mainnet-deployment-runbook.md index 7c9168a..0a82ba7 100644 --- a/docs/mainnet-deployment-runbook.md +++ b/docs/mainnet-deployment-runbook.md @@ -419,6 +419,24 @@ stellar contract invoke \ unpause ``` +### Pause the proof registry (admin only) + +`proof_registry` has its own independent pause flag (issue #264), separate +from `intent_settlement`'s. Use this if you suspect a forged-proof attack or +other proof-ingestion incident: + +```bash +stellar contract invoke \ + --id $PROOF_REGISTRY_CONTRACT_ID \ + --source \ + --network mainnet -- \ + pause +``` + +Effect: `receive_message` reverts with `ContractPaused (8)`. `get_proof` and +`has_proof` remain available. Resume with the same `unpause` invocation used +for `intent_settlement`, targeted at `$PROOF_REGISTRY_CONTRACT_ID`. + ### Rotate admin key If the admin key is compromised, use `transfer_admin`. This requires diff --git a/intent_settlement/src/lib.rs b/intent_settlement/src/lib.rs index 6915565..a2c7675 100644 --- a/intent_settlement/src/lib.rs +++ b/intent_settlement/src/lib.rs @@ -408,6 +408,10 @@ pub enum Error { /// If `src_chain` is unknown this error is never raised — unknown chains /// bypass token-format validation so the allowlist remains the sole gate. InvalidSrcToken = 28, + /// Issue #253: `src_chain` has no entry in the `src_chain`-to-Wormhole- + /// chain-ID mapping table (`src_chain_to_wormhole_id`). Fails closed + /// rather than defaulting to chain ID 0 for an unmapped/future chain. + SrcChainNotSupported = 29, } // ─── Contract ───────────────────────────────────────────────────────────────── @@ -1439,19 +1443,10 @@ impl IntentSettlement { // Boundary semantics: the fill-window deadline is EXCLUSIVE for filling. // `now >= intent.deadline` rejects at the boundary second (`now == deadline`) // so the full [accepted_at, accepted_at + FILL_WINDOW) window is available - // to the solver. - if now >= intent.deadline { - panic_with_error!(&env, Error::FillWindowExpired); - } - - match &intent.state { - IntentState::Accepted => {} - IntentState::Filled => panic_with_error!(&env, Error::IntentAlreadyFilled), - _ => panic_with_error!(&env, Error::IntentNotAccepted), - } - - if intent.solver.as_ref() != Some(&solver) { - panic_with_error!(&env, Error::Unauthorized); + // to the solver. Shared with `is_intent_fillable` via `check_fill_guards` + // (issue #259) so the two can never silently drift apart. + if let Err(e) = Self::check_fill_guards(&intent, &solver, now) { + panic_with_error!(&env, e); } if fill_amount <= 0 { @@ -1958,6 +1953,29 @@ impl IntentSettlement { } } + /// Whether `fill_intent(solver, intent_id, ..)` would currently pass all + /// of its pre-transfer guards: intent exists, state is `Accepted`, `solver` + /// matches `intent.solver`, and the fill-window deadline hasn't passed. + /// Mirrors `is_solver_eligible`'s precedent, letting off-chain solver bots + /// self-check before spending a transaction (issue #259). Uses the same + /// boundary semantics `fill_intent` itself uses via `check_fill_guards`, + /// so the two can never disagree. Does not predict whether the token + /// transfer itself would succeed (e.g. insufficient solver balance) — + /// this checks contract-state preconditions only. Never panics: returns + /// `false` for a nonexistent `intent_id`. + pub fn is_intent_fillable(env: Env, intent_id: BytesN<32>, solver: Address) -> bool { + let intent: IntentRecord = match env + .storage() + .persistent() + .get(&DataKey::Intent(intent_id)) + { + Some(intent) => intent, + None => return false, + }; + let now = env.ledger().timestamp(); + Self::check_fill_guards(&intent, &solver, now).is_ok() + } + /// Returns the current fee recipient address, or `None` before initialization. pub fn get_fee_recipient(env: Env) -> Option
{ env.storage().instance().get(&DataKey::FeeRecipient) @@ -2223,6 +2241,51 @@ impl IntentSettlement { // Unknown chain: skip validation — forward-compatible with future chains. } + /// Translates a canonical `src_chain` string (per + /// `docs/132-supported-chains.md` §2) to its numeric Wormhole chain ID, + /// for comparison against `proof.src_chain_id` once proof-gated fills + /// (issue #5) are wired up. Single source of truth for this mapping — + /// kept in sync with `docs/129-proof-mismatch-fallback.md` §4 (issue #253). + /// + /// Fails closed: an unmapped/future `src_chain` string panics with + /// `Error::SrcChainNotSupported` rather than defaulting to chain ID 0. + pub fn src_chain_to_wormhole_id(env: Env, src_chain: String) -> u32 { + let chain_len = src_chain.len(); + let chain_is = |literal: &[u8]| -> bool { + if chain_len as usize != literal.len() { + return false; + } + let mut i = 0u32; + while i < chain_len { + if src_chain.get(i) != literal[i as usize] as u32 { + return false; + } + i += 1; + } + true + }; + + if chain_is(b"ethereum") { + 2 + } else if chain_is(b"base") { + 30 + } else if chain_is(b"polygon") { + 5 + } else if chain_is(b"arbitrum") { + 23 + } else if chain_is(b"optimism") { + 24 + } else if chain_is(b"avalanche") { + 6 + } else if chain_is(b"bsc") { + 4 + } else if chain_is(b"solana") { + 1 + } else { + panic_with_error!(&env, Error::SrcChainNotSupported) + } + } + fn require_admin(env: &Env) { let admin: Address = env .storage() @@ -2267,6 +2330,27 @@ impl IntentSettlement { } } + /// The pre-transfer guard sequence shared between `fill_intent` and + /// `is_intent_fillable` (issue #259): intent state is `Accepted`, `solver` + /// matches `intent.solver`, and `now` is before the fill-window deadline. + /// Extracted so the two call sites can never silently drift apart. + fn check_fill_guards(intent: &IntentRecord, solver: &Address, now: u64) -> Result<(), Error> { + // Boundary semantics: the fill-window deadline is EXCLUSIVE for filling + // (issue #26) — `now >= intent.deadline` rejects at the boundary second. + if now >= intent.deadline { + return Err(Error::FillWindowExpired); + } + match &intent.state { + IntentState::Accepted => {} + IntentState::Filled => return Err(Error::IntentAlreadyFilled), + _ => return Err(Error::IntentNotAccepted), + } + if intent.solver.as_ref() != Some(solver) { + return Err(Error::Unauthorized); + } + Ok(()) + } + /// Add `token` to the enumerable allowlist (#117), if not already present. fn add_to_dst_token_list(env: &Env, token: &Address) { let mut list: Vec
= env diff --git a/intent_settlement/src/test.rs b/intent_settlement/src/test.rs index 9ed8561..974250e 100644 --- a/intent_settlement/src/test.rs +++ b/intent_settlement/src/test.rs @@ -380,6 +380,8 @@ fn pauser_cannot_unpause() { "unpause must require admin auth, not the pauser; got: {:?}", auths ); +} + #[test] fn pause_blocks_fill_intent() { let ctx = setup(); @@ -1299,6 +1301,58 @@ fn fill_by_wrong_solver_fails() { assert_eq!(res, Err(Ok(Error::Unauthorized.into()))); } +#[test] +fn is_intent_fillable_matches_fill_intent_outcome() { + let ctx = setup(); + ctx.register_solver(); + let id = ctx.submit(); + ctx.client().accept_intent(&ctx.solver, &id); + + // Genuinely fillable: matches a real fill_intent success. + assert!(ctx.client().is_intent_fillable(&id, &ctx.solver)); + ctx.dst_admin().mint(&ctx.solver, &FILL); + ctx.client().fill_intent(&ctx.solver, &id, &FILL); +} + +#[test] +fn is_intent_fillable_false_for_wrong_solver() { + let ctx = setup(); + ctx.register_solver(); + let id = ctx.submit(); + ctx.client().accept_intent(&ctx.solver, &id); + + let other = Address::generate(&ctx.env); + ctx.bond_admin().mint(&other, &BOND); + ctx.client().register_solver(&other, &BOND); + + assert!(!ctx.client().is_intent_fillable(&id, &other)); + ctx.dst_admin().mint(&other, &FILL); + let res = ctx.client().try_fill_intent(&other, &id, &FILL); + assert_eq!(res, Err(Ok(Error::Unauthorized.into()))); +} + +#[test] +fn is_intent_fillable_false_after_deadline() { + let ctx = setup(); + ctx.register_solver(); + let id = ctx.submit(); + ctx.client().accept_intent(&ctx.solver, &id); + + ctx.pass_time(FILL_WINDOW + 1); + assert!(!ctx.client().is_intent_fillable(&id, &ctx.solver)); + + ctx.dst_admin().mint(&ctx.solver, &FILL); + let res = ctx.client().try_fill_intent(&ctx.solver, &id, &FILL); + assert_eq!(res, Err(Ok(Error::FillWindowExpired.into()))); +} + +#[test] +fn is_intent_fillable_false_for_nonexistent_intent() { + let ctx = setup(); + let bogus_id = BytesN::from_array(&ctx.env, &[9u8; 32]); + assert!(!ctx.client().is_intent_fillable(&bogus_id, &ctx.solver)); +} + // ─── Cancellation ─────────────────────────────────────────────────────────────── #[test] @@ -2871,3 +2925,37 @@ fn unknown_chain_bypasses_token_format_validation() { &deadline, ); } + +// ─── #253 src_chain-to-Wormhole-chain-ID mapping ───────────────────────────────── + +/// Every chain in the README's Supported Source Chains table round-trips +/// correctly through `src_chain_to_wormhole_id`. +#[test] +fn src_chain_to_wormhole_id_covers_every_supported_chain() { + let ctx = setup(); + let c = ctx.client(); + let cases: &[(&str, u32)] = &[ + ("ethereum", 2), + ("base", 30), + ("polygon", 5), + ("arbitrum", 23), + ("optimism", 24), + ("avalanche", 6), + ("bsc", 4), + ("solana", 1), + ]; + for (chain, expected_id) in cases { + let chain_str = String::from_str(&ctx.env, chain); + assert_eq!(c.src_chain_to_wormhole_id(&chain_str), *expected_id); + } +} + +/// An unknown/future chain string is explicitly rejected rather than +/// defaulting to chain ID 0. +#[test] +fn src_chain_to_wormhole_id_rejects_unknown_chain() { + let ctx = setup(); + let chain_str = String::from_str(&ctx.env, "cosmos"); + let res = ctx.client().try_src_chain_to_wormhole_id(&chain_str); + assert_eq!(res, Err(Ok(Error::SrcChainNotSupported.into()))); +} diff --git a/proof_registry/src/lib.rs b/proof_registry/src/lib.rs index fd68b33..1909c03 100644 --- a/proof_registry/src/lib.rs +++ b/proof_registry/src/lib.rs @@ -29,6 +29,16 @@ use soroban_sdk::{ BytesN, Env, String, Symbol, }; +/// Issue #254: how long (in seconds) a `ProofRecord` remains usable to gate a +/// `fill_intent` call after `receive_message` stores it. Chosen to comfortably +/// exceed `intent_settlement`'s 300-second `FILL_WINDOW` plus realistic +/// VAA-relay latency (1–20 minutes across the bridge protocols compared in +/// `docs/bridge-protocol-comparison.md`), so a proof arriving even somewhat +/// late is never spuriously rejected as stale. This is distinct from Soroban +/// storage-TTL archival (issue #51) — this is business-logic staleness, not +/// ledger-entry expiry. +pub const PROOF_VALIDITY_WINDOW: u64 = 3600; + #[cfg(test)] mod test; @@ -48,6 +58,10 @@ pub enum ProofKey { AuthorizedEmitter(u32), // u32 wraps u16 — Soroban contracttype requires u32 /// Verified proof record keyed by Vortex `intent_id`. Proof(BytesN<32>), + /// Boolean flag (`true` = paused). Set by `pause()` and cleared by + /// `unpause()`. When `true`, `receive_message` rejects new proofs. + /// Absent until first `pause()` call (defaults to `false`). + Paused, } // ─── Data Types ─────────────────────────────────────────────────────────────── @@ -97,6 +111,12 @@ pub enum Error { InvalidPayload = 6, /// Contract not initialized (`Admin` key absent). NotInitialized = 7, + /// `receive_message` called while the registry is paused. + ContractPaused = 8, + /// `get_fresh_proof` found a `ProofRecord` older than + /// `PROOF_VALIDITY_WINDOW`. Distinct from `ProofNotFound` — the proof + /// exists but is too stale to gate a fill. + ProofStale = 9, } // ─── Contract ───────────────────────────────────────────────────────────────── @@ -157,6 +177,32 @@ impl ProofRegistry { .get(&ProofKey::AuthorizedEmitter(chain_id)) } + /// Admin-only: halt `receive_message` for incident response (issue #264), + /// mirroring `intent_settlement`'s `pause`/`unpause` mechanism. Unlike + /// `intent_settlement` (issue #120), there is no separate narrow-scoped + /// pauser role here — admin-only is sufficient for this registry's first + /// version. `get_proof`/`has_proof` remain available during a pause. + pub fn pause(env: Env) { + Self::require_admin(&env); + env.storage().instance().set(&ProofKey::Paused, &true); + env.events().publish((Symbol::new(&env, "paused"),), true); + } + + /// Admin-only: lift a pause and resume accepting proofs. + pub fn unpause(env: Env) { + Self::require_admin(&env); + env.storage().instance().set(&ProofKey::Paused, &false); + env.events().publish((Symbol::new(&env, "paused"),), false); + } + + /// Whether `receive_message` is currently halted. + pub fn is_paused(env: Env) -> bool { + env.storage() + .instance() + .get(&ProofKey::Paused) + .unwrap_or(false) + } + // ── Message Receipt ─────────────────────────────────────────────────────── /// Receive and verify a Wormhole VAA, then store the decoded proof. @@ -185,6 +231,10 @@ impl ProofRegistry { /// [86..102] src_amount (i128, big-endian) /// ``` pub fn receive_message(env: Env, vaa: Bytes) { + if Self::is_paused(env.clone()) { + panic_with_error!(&env, Error::ContractPaused); + } + // Payload must be exactly 102 bytes. if vaa.len() != 102 { panic_with_error!(&env, Error::InvalidPayload); @@ -268,6 +318,29 @@ impl ProofRegistry { .has(&ProofKey::Proof(intent_id)) } + /// Return `intent_id`'s `ProofRecord` only if it exists and is still + /// fresh (`now - received_at <= PROOF_VALIDITY_WINDOW`). Panics with + /// `Error::ProofNotFound` if no proof was received, or + /// `Error::ProofStale` if one exists but has aged out (issue #254). + /// This is the entry point `fill_intent`'s proof check (issue #5) is + /// intended to call — `get_proof`/`has_proof` remain raw, freshness-blind + /// reads for other callers. + pub fn get_fresh_proof(env: Env, intent_id: BytesN<32>) -> ProofRecord { + let record: ProofRecord = env + .storage() + .persistent() + .get(&ProofKey::Proof(intent_id)) + .unwrap_or_else(|| panic_with_error!(&env, Error::ProofNotFound)); + let now = env.ledger().timestamp(); + // Boundary: exactly at the validity window is still fresh (inclusive), + // matching this codebase's documented inclusive/exclusive convention + // (issue #26) — validity holds through the boundary second itself. + if now - record.received_at > PROOF_VALIDITY_WINDOW { + panic_with_error!(&env, Error::ProofStale); + } + record + } + // ── Test Back-Door ──────────────────────────────────────────────────────── /// **Test-only** (available only when the `testutils` Cargo feature is @@ -281,6 +354,11 @@ impl ProofRegistry { /// The method is intentionally not guarded by admin auth in the mock so /// that any test address can call it. A production implementation would /// not expose this method at all. + /// + /// Issue #264: deliberately ignores the pause flag. This is test-setup + /// scaffolding, not the production message-receipt path `pause` protects; + /// tests that need to assert paused-`receive_message` behavior call + /// `receive_message` directly. #[cfg(feature = "testutils")] pub fn mock_set_proof(env: Env, record: ProofRecord) { // Reject replays (same as receive_message) so tests that accidentally diff --git a/proof_registry/src/test.rs b/proof_registry/src/test.rs index 3d16eb4..b22bdd7 100644 --- a/proof_registry/src/test.rs +++ b/proof_registry/src/test.rs @@ -161,6 +161,63 @@ fn receive_message_stores_proof() { assert_eq!(record.src_amount, 1_000_000_000); } +#[test] +fn get_fresh_proof_returns_record_when_fresh() { + let ctx = setup(); + let c = ctx.client(); + + let intent_id = make_intent_id(&ctx.env, 95); + let payload = make_payload(&ctx.env, &intent_id, 2, 1_000_000_000); + c.receive_message(&payload); + + let record = c.get_fresh_proof(&intent_id); + assert_eq!(record.intent_id, intent_id); +} + +#[test] +fn get_fresh_proof_rejects_stale_proof() { + let ctx = setup(); + let c = ctx.client(); + + let intent_id = make_intent_id(&ctx.env, 96); + let payload = make_payload(&ctx.env, &intent_id, 2, 1_000_000_000); + c.receive_message(&payload); + + ctx.env + .ledger() + .with_mut(|li| li.timestamp += crate::PROOF_VALIDITY_WINDOW + 1); + + let res = c.try_get_fresh_proof(&intent_id); + assert_eq!(res, Err(Ok(Error::ProofStale.into()))); +} + +#[test] +fn get_fresh_proof_accepts_exact_boundary() { + let ctx = setup(); + let c = ctx.client(); + + let intent_id = make_intent_id(&ctx.env, 97); + let payload = make_payload(&ctx.env, &intent_id, 2, 1_000_000_000); + c.receive_message(&payload); + + // Exactly at the validity window boundary: still fresh (inclusive). + ctx.env + .ledger() + .with_mut(|li| li.timestamp += crate::PROOF_VALIDITY_WINDOW); + let record = c.get_fresh_proof(&intent_id); + assert_eq!(record.intent_id, intent_id); +} + +#[test] +fn get_fresh_proof_rejects_missing_proof() { + let ctx = setup(); + let c = ctx.client(); + + let intent_id = make_intent_id(&ctx.env, 98); + let res = c.try_get_fresh_proof(&intent_id); + assert_eq!(res, Err(Ok(Error::ProofNotFound.into()))); +} + #[test] fn receive_message_rejects_duplicate_intent_id() { let ctx = setup(); @@ -191,6 +248,53 @@ fn receive_message_rejects_wrong_payload_length() { assert_eq!(res, Err(Ok(Error::InvalidPayload.into()))); } +#[test] +fn receive_message_succeeds_while_unpaused() { + let ctx = setup(); + let c = ctx.client(); + assert!(!c.is_paused()); + + let intent_id = make_intent_id(&ctx.env, 90); + let payload = make_payload(&ctx.env, &intent_id, 2, 1_000_000_000); + c.receive_message(&payload); + assert!(c.has_proof(&intent_id)); +} + +#[test] +fn receive_message_rejects_while_paused() { + let ctx = setup(); + let c = ctx.client(); + + c.pause(); + assert!(c.is_paused()); + + let intent_id = make_intent_id(&ctx.env, 91); + let payload = make_payload(&ctx.env, &intent_id, 2, 1_000_000_000); + let res = c.try_receive_message(&payload); + assert_eq!(res, Err(Ok(Error::ContractPaused.into()))); +} + +#[test] +fn reads_remain_available_while_paused() { + let ctx = setup(); + let c = ctx.client(); + + let intent_id = make_intent_id(&ctx.env, 92); + let payload = make_payload(&ctx.env, &intent_id, 2, 1_000_000_000); + c.receive_message(&payload); + + c.pause(); + assert!(c.has_proof(&intent_id)); + assert!(c.get_proof(&intent_id).is_some()); + + c.unpause(); + assert!(!c.is_paused()); + let intent_id2 = make_intent_id(&ctx.env, 93); + let payload2 = make_payload(&ctx.env, &intent_id2, 2, 1); + c.receive_message(&payload2); + assert!(c.has_proof(&intent_id2)); +} + #[test] fn receive_message_decodes_src_amount_correctly() { let ctx = setup();