Skip to content
Open
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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ stellar contract invoke --id <CONTRACT_ID> --source <SOLVER_SECRET_KEY> --networ
stellar contract invoke --id <CONTRACT_ID> --source <SOLVER_SECRET_KEY> --network testnet -- \
accept_intent --solver <SOLVER_ADDRESS> --intent_id <INTENT_ID>

# Read-only: solver self-checks that fill_intent would succeed before spending a transaction
stellar contract invoke --id <CONTRACT_ID> --source <ANY_SECRET_KEY> --network testnet -- \
is_intent_fillable --intent_id <INTENT_ID> --solver <SOLVER_ADDRESS>

# Solver delivers the output and closes out the intent
stellar contract invoke --id <CONTRACT_ID> --source <SOLVER_SECRET_KEY> --network testnet -- \
fill_intent --solver <SOLVER_ADDRESS> --intent_id <INTENT_ID> --fill_amount 35000000000
Expand Down
4 changes: 2 additions & 2 deletions docs/124-proof-verification-interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

---

Expand Down
5 changes: 5 additions & 0 deletions docs/129-proof-mismatch-fallback.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/132-supported-chains.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*
18 changes: 18 additions & 0 deletions docs/mainnet-deployment-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ADMIN_SECRET_KEY> \
--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
Expand Down
110 changes: 97 additions & 13 deletions intent_settlement/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Address> {
env.storage().instance().get(&DataKey::FeeRecipient)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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<Address> = env
Expand Down
88 changes: 88 additions & 0 deletions intent_settlement/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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())));
}
Loading