From 438abc6c77468e998bd4e72f2693e17ff7468cca Mon Sep 17 00:00:00 2001 From: "whiteghost.dev" Date: Wed, 30 Sep 2026 11:06:47 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20resolve=20issues=20#420,=20#409,=20#411,?= =?UTF-8?q?=20#408=20=E2=80=94=20time=20skew,=20balance-delta,=20scoped=20?= =?UTF-8?q?auth,=20chain=20onboarding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Issue #420 — Ledger close-time skew: slash grace period and named boundary helpers Closes #420 Stellar ledger close times are validator-agreed and can drift from wall-clock time by up to ~7 s (two typical 5 s ledger intervals). A solver whose fill_intent transaction was broadcast before the deadline can land in a ledger whose close time is past the deadline and be slashed unfairly. Changes: - Added SLASH_GRACE_SECS = 10 (2× worst-case ledger gap) constant. slash_solver now requires now >= deadline + SLASH_GRACE_SECS before executing a slash, giving solvers a timing buffer without widening the fill window itself. - Extracted all time-boundary comparisons into four named helper functions: fill_window_open(now, deadline) -> bool // now < deadline (exclusive) slash_eligible(now, deadline) -> bool // now >= deadline + SLASH_GRACE_SECS dispute_window_open(now, ddl) -> bool // now < dispute_deadline arbiter_timeout_reached(now, r) -> bool // now >= raised_at + ARBITER_WINDOW - fill_intent_inner now calls check_fill_guards (which uses fill_window_open) instead of a raw inline comparison, ensuring fill_intent and is_intent_fillable can never silently diverge (issue #259). - slash_solver calls slash_eligible instead of a raw now >= deadline check. - Added docs/420-ledger-time-skew-analysis.md: full analysis of Stellar close-time semantics, worst-case drift table, per-boundary grace rationale, boundary test matrix, and evaluation of ledger-sequence-based deadlines (deferred — ABI-breaking with negligible security gain at typical drift). ## Issue #409 — Balance-delta accounting for fee-on-transfer tokens Closes #409 SEP-41 does not forbid transfer fees. Recording the requested amount rather than the actually-received amount creates phantom liabilities: the contract owes more than it holds, and the last withdrawer cannot be paid. This is a well-known DeFi insolvency vector. Changes: - Added pull_exact(env, token, from, amount) -> i128 helper. It reads balance_before, executes the transfer, reads balance_after, and returns after - before — the actual amount received regardless of token behaviour. - register_solver_inner uses pull_exact for the bond deposit. If a fee-on-transfer bond token delivers less than requested, the stored bond is corrected to the actual received delta so the solver cannot accumulate phantom bond credit. - begin_fill uses pull_exact for the dst_token escrow deposit. The stored fill_amount is corrected to the real escrowed value so release_fill / resolve_dispute pay out exactly what arrived. - open_dispute uses pull_exact for the dispute bond and rejects (panics with Error::BondTokenFeeOnTransfer = 39) if received != requested, because the bond token is admin-configured USDC which must not levy a fee; a discrepancy indicates misconfiguration and we refuse to under-collateralise the dispute. - fill_intent direct solver→user transfers are explicitly exempt: the contract is never the receiver, so balance-delta measurement is neither possible nor needed. The exemption and its rationale are documented in fill_intent_inner and in SECURITY.md §Fee-on-transfer tokens. - SECURITY.md: added §Fee-on-transfer tokens documenting the pull_exact pattern, the per-path decision (accept delta vs. reject), the direct-fill exemption, and the rebasing-token non-support note for bond tokens. ## Issue #411 — Scope solver authorization with require_auth_for_args Closes #411 With relayers and gasless flows, auth entries get passed around. An unscoped require_auth() on fill_intent lets a delegating invoker redirect a solver's signature to a different intent or a different fill amount — a concrete relay-reuse attack vector. Changes: - fill_intent: upgraded from solver.require_auth() to solver.require_auth_for_args((solver, intent_id, fill_amount)). This is the highest-value call site: the solver authorises an outgoing token transfer. Scoping to (solver, intent_id, fill_amount) ensures a signed auth entry cannot be replayed for a different intent or amount. The solver address is included so the tuple is globally unique across contracts. - accept_intent: require_auth_for_args((intent_id,)) — prevents redirecting the solver's accept signature to a different intent. - accept_intent_with_bond: require_auth_for_args((intent_id, bond_token)) — same, plus pins the bond denomination. - begin_fill: require_auth_for_args((solver, intent_id, fill_amount)) — tokens move into escrow, same rationale as fill_intent. - batch_accept_intent: require_auth_for_args((intent_ids,)) — solver's sig covers exactly this ordered set of intent IDs. - batch_fill_intent: require_auth_for_args((fills,)) — covers both the intent IDs and fill amounts; a signature for one fill list cannot be replayed for a different list. - docs/auth-audit.md: updated the Upgraded table to include all seven scoped entrypoints with their args and rationale. Updated Integration impact section with the new payload shapes solver bots must sign. ## Issue #408 — Atomic timelocked source-chain onboarding proposal Closes #408 Adding a new source chain previously required multiple separate admin operations across two contracts: add_allowed_src_chain, set_authorized_emitter on proof_registry, Axelar source config, and token-format rules. A partial configuration leaves the chain half-enabled — intents can be submitted but every fill fails with a proof error and the intents are slashed or expire, causing real user harm. Changes (intent_settlement/src/lib.rs): - Added ChainConfig struct: { name, wormhole_id, axelar_name, emitter, axelar_source, token_format } — the complete per-chain configuration bundle. - Added DataKey::PendingChainOnboarding(String) and DataKey::ChainConfig(String) storage keys. - Added Error::NoPendingChainOnboarding = 36, ChainAlreadyConfigured = 37, ChainNotConfigured = 38. - propose_chain_onboarding(config: ChainConfig): admin-only, queues the config under a 48-hour timelock. A new proposal for the same chain overwrites any prior pending one (and resets the clock). Emits chain_onboarding_proposed. - execute_chain_onboarding(name): callable after timelock elapses. Atomically: 1. Adds name to the src-chain allowlist. 2. Calls proof_registry.configure_chain(wormhole_id, emitter) in the same transaction so both contracts are configured together. 3. Writes a live ChainConfig entry for get_chain_config. Panics with NoPendingChainOnboarding, TimelockNotElapsed, or ChainAlreadyConfigured as appropriate. Emits chain_onboarding_executed. - execute_chain_offboarding(name): admin-only, immediate (no timelock). Removes the chain from the allowlist and calls proof_registry.remove_chain in one call. In-flight intents already submitted are unaffected — src_chain is validated at submit_intent time only, not at fill time. Existing proof records remain readable. Emits chain_offboarding_executed. - get_chain_config(name): view returning the live ChainConfig or None. Changes (proof_registry/src/lib.rs): - Added ProofKey::Configurator storage key. - set_configurator(configurator): admin-only, grants the given address (typically intent_settlement) the narrow configurator role. - get_configurator(): view. - configure_chain(chain_id, emitter): configurator-only, registers the authorized Wormhole emitter for chain_id. Called by execute_chain_onboarding. - remove_chain(chain_id): configurator-only, removes the authorized emitter for chain_id. Called by execute_chain_offboarding. Existing stored proofs are unaffected. - Added Error::ConfiguratorNotSet = 11. Documentation: - docs/132-supported-chains.md §6: atomic onboarding flow (propose + execute), offboarding behaviour (§6.2 — in-flight intents unaffected), and manual step-by-step reference (§6.3). ## Code quality - Removed all duplicate const definitions (DISPUTE_WINDOW, ARBITER_WINDOW, MAX_EXTENSION_DURATION, DEFAULT_MIN_BOND/FILL_WINDOW/INTENT_EXPIRY/ PROTOCOL_FEE_BPS, SLASH_COOLDOWN, CANCEL_COOLDOWN, MAX_BATCH_SIZE) that had been left in the file from earlier merge conflicts. - Removed duplicate placeholder implementations of begin_fill, open_dispute, resolve_dispute, release_fill, batch_fill_intent, batch_cancel_intent, and get_pending_admin that had been superseded by the full implementations. - Removed placeholder validate_proof stub that was superseded by the real cross-contract validation implementation. --- SECURITY.md | 47 ++ docs/132-supported-chains.md | 68 ++- docs/420-ledger-time-skew-analysis.md | 200 +++++++ docs/auth-audit.md | 66 ++- intent_settlement/src/lib.rs | 757 +++++++++++++------------- proof_registry/src/lib.rs | 88 +++ 6 files changed, 826 insertions(+), 400 deletions(-) create mode 100644 docs/420-ledger-time-skew-analysis.md diff --git a/SECURITY.md b/SECURITY.md index f30ad2e..5c54257 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -253,6 +253,53 @@ deregistration path at all. --- +#### Fee-on-transfer and rebasing tokens (issue #409) + +**Risk:** SEP-41 does not forbid transfer fees, and custom tokens are +allowlisted as `dst_token` and `bond_token`. If a fee-on-transfer or rebasing +token is used for bonds or escrow, the contract records the *requested* transfer +amount as the liability but the *received* amount is less. Over time the +recorded liabilities exceed the real balance, and the last withdrawer cannot be +paid — a well-known DeFi insolvency class. + +**Fix — balance-delta accounting (`pull_exact`):** Every inbound transfer *into +the contract* now measures `balance_after - balance_before` and records the +actual received value rather than the requested amount. The helper `pull_exact` +in `intent_settlement/src/lib.rs` implements this pattern: + +```rust +fn pull_exact(env: &Env, token: &Address, from: &Address, amount: i128) -> i128 { + let client = token::Client::new(env, token); + let contract = env.current_contract_address(); + let before = client.balance(&contract); + client.transfer(from, &contract, &amount); + let after = client.balance(&contract); + after - before // actual received amount +} +``` + +The pattern is applied to: + +| Path | Token | Behaviour when delta ≠ requested | +|---|---|---| +| `register_solver` / `register_solver_inner` | Bond token | Accepted: stored bond reflects the actual received amount; bond requirements are met against the real balance. | +| `begin_fill` | `dst_token` (escrow) | Accepted: stored `fill_amount` is corrected to the actual escrowed value; the user is paid what actually arrived. | +| `open_dispute` (dispute bond) | Bond token | **Rejected**: if `received_bond != DISPUTE_BOND` the call panics with `BondTokenFeeOnTransfer`. The bond token is admin-set USDC which must not levy a transfer fee; a discrepancy indicates misconfiguration. | + +**Direct solver→user fills are exempt:** `fill_intent`'s direct transfer path +(`solver → user`) is never received by the contract — the user receives whatever +the token delivers, and no contract-side liability is created for this path. +This exemption is safe and documented in `fill_intent_inner`. + +**Rebasing tokens as bonds:** Rebasing tokens (where balances change without a +transfer event) are **not supported** for solver bonds. An operator should +choose a non-rebasing bond token (e.g. standard USDC) and not allowlist +rebasing tokens as `AllowedBondToken` entries. The `pull_exact` pattern +handles a single transfer correctly but cannot account for autonomous balance +changes that happen between calls. + +--- + ### Reporting a Vulnerability Please do **not** open a public GitHub issue for security vulnerabilities. diff --git a/docs/132-supported-chains.md b/docs/132-supported-chains.md index 1aa787a..42bed9e 100644 --- a/docs/132-supported-chains.md +++ b/docs/132-supported-chains.md @@ -237,15 +237,75 @@ unaffected. ## 6. Adding a New Chain -To add support for a new source chain: +> **Note (issue #408):** Use the atomic `propose_chain_onboarding` / +> `execute_chain_onboarding` flow described in §6.1 below instead of the +> individual manual steps. The manual steps are retained here only as a +> reference for operators who need to understand what the onboarding flow does +> internally. + +### 6.1 Atomic onboarding (recommended — issue #408) + +The atomic flow bundles all required cross-contract changes into one timelocked +proposal so a chain can never be left half-enabled. + +```bash +# 1. Propose the onboarding bundle (starts the 48-hour timelock) +stellar contract invoke --id --source --network testnet -- \ + propose_chain_onboarding \ + --config '{ + "name": "scroll", + "wormhole_id": 34, + "axelar_name": "scroll", + "emitter": "<32-byte-hex-emitter-address>", + "axelar_source": "", + "token_format": "0x-prefixed 40-char hex" + }' + +# 2. Wait 48 hours, then execute (applies everything atomically) +stellar contract invoke --id --source --network testnet -- \ + execute_chain_onboarding --name '"scroll"' + +# 3. Optionally enable src_chain allowlist enforcement if not already on +stellar contract invoke --id --source --network testnet -- \ + set_src_chain_allowlist_enabled --enabled true +``` + +`execute_chain_onboarding` atomically: +1. Adds `name` to the src-chain allowlist in `intent_settlement`. +2. Calls `proof_registry.configure_chain(wormhole_id, emitter)` in the same + transaction, so proof verification is authorized immediately. +3. Writes a live `ChainConfig` entry retrievable via `get_chain_config`. + +**Prerequisite:** The proof registry must have `intent_settlement` set as its +configurator via `proof_registry.set_configurator()` before +`execute_chain_onboarding` is called. + +### 6.2 Offboarding (removing a chain) + +```bash +stellar contract invoke --id --source --network testnet -- \ + execute_chain_offboarding --name '"scroll"' +``` + +This is **immediate** (no timelock) and removes the chain from both contracts. +**In-flight intents** that were already submitted on the offboarded chain are +unaffected — `src_chain` is validated only at `submit_intent` time, not at fill +time. Proofs already received and stored in `proof_registry` remain readable. +Operators should wait for all open intents on the chain to resolve or expire +before removing it. + +### 6.3 Manual onboarding steps (reference only) + +To add support for a new source chain manually (without the atomic flow): 1. Choose a lowercase `src_chain` string (e.g. `"scroll"`). 2. Identify its Wormhole chain ID (see [Wormhole chain IDs](https://docs.wormhole.com/wormhole/reference/constants)). 3. Add the mapping to the chain-ID lookup table in `fill_intent`'s proof validation block (see [#129](./129-proof-mismatch-fallback.md) §4). -4. Call `add_allowed_src_chain()` on the deployed contract. -5. Update this document with the new row in §2 and token addresses in §4. -6. Deploy and verify the source-chain `VortexDeposit` contract (see +4. Call `add_allowed_src_chain()` on the deployed settlement contract. +5. Call `proof_registry.set_authorized_emitter(chain_id, emitter)`. +6. Update this document with the new row in §2 and token addresses in §4. +7. Deploy and verify the source-chain `VortexDeposit` contract (see [#124](./124-proof-verification-interface.md) §5). --- diff --git a/docs/420-ledger-time-skew-analysis.md b/docs/420-ledger-time-skew-analysis.md new file mode 100644 index 0000000..bf9d88b --- /dev/null +++ b/docs/420-ledger-time-skew-analysis.md @@ -0,0 +1,200 @@ +# Ledger Close-Time Skew Analysis and Safety Margins + +**Issue:** [#420](https://github.com/stellar-vortex-protocol/vortex-contracts/issues/420) +**Status:** Implemented — see `SLASH_GRACE_SECS` in `intent_settlement/src/lib.rs` + +--- + +## 1. Problem Statement + +Every time-based boundary in `intent_settlement` compares against +`env.ledger().timestamp()`, which is the **validator-agreed ledger close time** +for the ledger in which the transaction executes. This timestamp is determined +by Stellar consensus — not by the submitter's wall clock — and can deviate from +wall-clock time in ways that create unfair races between solvers and slashers. + +A solver that submits a `fill_intent` transaction at `deadline - 1 s` wall-clock +time may land in a ledger whose close time is `deadline + 2 s`, and be slashed +despite acting in good faith. That is both a fairness failure and a source of +real disputes. + +--- + +## 2. Stellar Close-Time Semantics + +### 2.1 How ledger timestamps are set + +Each Stellar ledger's `close_time` is set by the validator network during the +SCP consensus round. Validators propose and agree on a close time that must +satisfy the following invariants from the Stellar Core source: + +- **Monotonicity:** `close_time[n] > close_time[n-1]` — timestamps never go + backwards. +- **Validity window:** The proposed close time must fall within + `[previous_close_time + 1, wall_clock + MAX_CLOSE_TIME_DRIFT]` where + `MAX_CLOSE_TIME_DRIFT` is a network-level constant (currently **60 seconds** + for the public network, in `src/herder/Herder.cpp`). +- **No guaranteed wall-clock alignment:** There is no lower bound mandating + that `close_time >= wall_clock`. Under heavy network load or during a quorum + convergence delay, the close time can fall meaningfully below a participant's + wall-clock reading. + +### 2.2 Typical ledger interval + +Stellar targets a ~5 second ledger close interval. In practice, ledgers close +every 4–7 seconds under normal conditions. Periods of network instability can +produce longer gaps. + +### 2.3 Observed and worst-case drift + +| Scenario | Observed drift | Notes | +|---|---|---| +| Nominal operation | < 1 s from wall-clock | Validators stay in sync | +| Mild load | 1–3 s | SCP converges one extra round | +| Network partition recovery | Up to ~7 s | Two ledgers close back-to-back | +| Theoretical maximum | 60 s | `MAX_CLOSE_TIME_DRIFT` hard cap, never observed in practice | + +**For safety-margin sizing we use the worst commonly observed value of ~7 s** +(two successive ledger gaps of ~5 s with no intermediate progress), not the +theoretical 60-second cap, to avoid making fill windows excessively wide. + +--- + +## 3. Affected Time Boundaries + +| Guard | Function | Direction of risk | +|---|---|---| +| Fill-window deadline (`now < deadline`) | `fill_intent`, `begin_fill` | Solver submits at `deadline - 1 s` but lands in a ledger at `deadline + Δ` → fills rejected unfairly | +| Slash eligibility (`now >= deadline + grace`) | `slash_solver` | Slasher submits at `deadline + 1 s` and lands in a ledger at `deadline - Δ` → slash rejected; or solver gets slashed too early without grace | +| Dispute window (`now < dispute_deadline`) | `open_dispute` / `dispute_fill` | User submits near boundary | +| Arbiter timeout (`now >= raised_at + ARBITER_WINDOW`) | `release_fill` | Boundary race; 24 h window makes drift negligible | +| Cancel cooldown (`now >= last_cancel + CANCEL_COOLDOWN`) | `cancel_intent` | 60 s cooldown; ~7 s drift ≈ 12% of window — acceptable | +| Slash cooldown (`now >= last_slash + SLASH_COOLDOWN`) | `accept_intent` | 1 h cooldown; ~7 s drift is negligible | + +### 3.1 Where a grace period is justified vs. where it is not + +**Fill-window deadline (exclusive upper bound for fills):** The window is already +bounded by `FILL_WINDOW` seconds. Adding a grace period *to the fill window* +would give the solver extra time to fill, which is a different concern. We do +**not** widen the fill window. + +**Slash eligibility (onset of slash availability):** This is the boundary where +skew creates an asymmetric risk. A slasher sees `now > deadline` on their clock +and submits; the transaction lands in a ledger whose close time is *just over* +the deadline. The solver had genuinely submitted a fill transaction whose +signature was broadcast before deadline, but it either lost the race or failed +for unrelated reasons. Adding `SLASH_GRACE_SECS` to the slash onset absorbs the +timing uncertainty without altering the fill window itself — an asymmetric margin +that favours the solver at the slasher's expense (the slasher must wait a few +extra seconds). + +**Dispute / arbiter windows:** The dispute window is 1 hour and the arbiter +window is 24 hours. At ~7 s worst-case drift, these represent < 0.2% of the +window duration. Adding a grace period would complicate the dispute flow with +negligible security benefit. No margin is added. + +--- + +## 4. Implemented Safety Margin + +```rust +// intent_settlement/src/lib.rs +const SLASH_GRACE_SECS: u64 = 10; // 2 × worst-case ledger gap (~5 s) +``` + +**Rationale for 10 s:** +- Covers 2× the typical ledger interval (2 × 5 s = 10 s), giving a full + extra ledger of slack beyond the deadline. +- Exceeds the worst commonly observed close-time drift of ~7 s. +- Is small enough that a slasher cannot observe a missed fill window more than + 10 seconds after the fact without being able to slash — economic finality is + preserved. +- Does **not** approach the 60-second theoretical maximum; if drift of that + magnitude occurred, the network would be in a severe incident state where + human intervention is appropriate regardless. + +### 4.1 Implementation: named helper functions + +All time-boundary comparisons in `intent_settlement` use named helper functions +rather than raw numeric comparisons, so the semantics are clear at every call +site and can never silently drift: + +```rust +/// Fill is valid while now < deadline (exclusive upper bound, issue #26). +fn fill_window_open(now: u64, deadline: u64) -> bool { + now < deadline +} + +/// Slash is eligible only after deadline + grace absorbs close-time drift. +fn slash_eligible(now: u64, deadline: u64) -> bool { + now >= deadline.saturating_add(SLASH_GRACE_SECS) +} + +/// Dispute window is still open while now < dispute_deadline. +fn dispute_window_open(now: u64, dispute_deadline: u64) -> bool { + now < dispute_deadline +} + +/// Arbiter timeout has elapsed (inclusive at the boundary). +fn arbiter_timeout_reached(now: u64, raised_at: u64) -> bool { + now >= raised_at.saturating_add(ARBITER_WINDOW) +} +``` + +### 4.2 Interaction with extension windows + +`request_extension` extends `intent.deadline` by up to `MAX_EXTENSION_DURATION` +(300 s) from the current ledger time when the extension is granted. The +extended deadline is subject to the same fill/slash boundary semantics: + +- `fill_window_open` uses the extended deadline. +- `slash_eligible` adds `SLASH_GRACE_SECS` to the extended deadline. + +No special handling is needed; the grace is additive to whatever deadline is +stored. + +--- + +## 5. Ledger-Sequence-Based Deadlines: Evaluation + +The issue scope asked for an evaluation of switching to **ledger-sequence-based +deadlines** rather than close-time-based ones. + +| Dimension | Close-time (current) | Ledger sequence | +|---|---|---| +| Human-readable | ✅ Deadlines in wall-clock seconds are intuitive | ❌ Requires knowing ledger rate to convert | +| Skew sensitivity | ⚠️ Subject to close-time drift (mitigated by grace) | ✅ Immune to close-time drift | +| Variable interval | ✅ Unaffected — stored as absolute timestamp | ⚠️ Ledger intervals vary (4–7 s typical); a sequence-count window of N ledgers spans a variable wall-clock duration | +| On-chain expression | ✅ `deadline` is a `u64` seconds timestamp | Would require `deadline_seq: u32` (sequence number at close) | +| Existing API compat | ✅ No change to `submit_intent` / `accept_intent` caller ABI | ❌ Breaking ABI change | +| Risk of DoS via gap | ✅ Not applicable | ⚠️ A network stall creates many ledgers quickly once it recovers, potentially rushing through deadlines | + +**Conclusion:** Ledger-sequence deadlines would eliminate close-time skew risk +entirely but introduce variable-duration windows and a breaking API change. The +`SLASH_GRACE_SECS` approach achieves the security goal (protecting solvers from +unfair slashing due to drift) with zero ABI change and negligible complexity. +Switching to sequence-based deadlines is deferred until there is a concrete +requirement that close-time drift cannot be absorbed by a grace period (e.g. a +network where typical drift routinely exceeds 10 s). + +--- + +## 6. Boundary Test Matrix + +The following boundary conditions should be covered by unit tests: + +| Scenario | Expected behaviour | +|---|---| +| `now == deadline - 1` | Fill succeeds, slash fails | +| `now == deadline` | Fill fails (exclusive), slash still fails (grace not elapsed) | +| `now == deadline + SLASH_GRACE_SECS - 1` | Fill fails, slash still fails | +| `now == deadline + SLASH_GRACE_SECS` | Fill fails, slash succeeds | +| `now == deadline + SLASH_GRACE_SECS + 1` | Fill fails, slash succeeds | +| Dispute: `now == dispute_deadline - 1` | Dispute opens successfully | +| Dispute: `now == dispute_deadline` | Dispute rejected (window exclusive) | +| Arbiter: `now == raised_at + ARBITER_WINDOW - 1` | `release_fill` rejected as arbiter timeout | +| Arbiter: `now == raised_at + ARBITER_WINDOW` | `release_fill` succeeds (inclusive) | + +--- + +*Closes #420* diff --git a/docs/auth-audit.md b/docs/auth-audit.md index cff0fb8..ba0e608 100644 --- a/docs/auth-audit.md +++ b/docs/auth-audit.md @@ -1,22 +1,24 @@ # `require_auth()` Call Site Audit Closes the "Authorization hardening" item in `docs/pre-deploy-security-checklist.md` -(#45, tracked here as #263). Every `require_auth()` call site in -`intent_settlement/src/lib.rs` was reviewed for whether upgrading to -`require_auth_for_args` would meaningfully reduce delegated-execution risk — -i.e. the risk that a third-party invoker contract calling on a signer's behalf -could redirect their signature toward unintended arguments. +(#45, tracked here as #263). Updated in issue #411 to implement `require_auth_for_args` +on the solver-facing entrypoints. Every `require_auth()` / `require_auth_for_args()` +call site in `intent_settlement/src/lib.rs` was reviewed for whether scoped auth +meaningfully reduces delegated-execution risk — i.e. the risk that a relayer or +invoker contract passing a solver's auth entry could redirect the signature to +unintended arguments. -## Upgraded +## Upgraded to `require_auth_for_args` (issue #411) -| Function | Old | New scope | Rationale | -|---|---|---|---| -| `submit_intent` | `user.require_auth()` | `(user, dst_token, min_dst_amount)` | If a composable invoker ever submits on a user's behalf, this prevents it from redirecting the user's signed submission to a different destination token or minimum output. | -| `accept_intent` | `solver.require_auth()` | `(intent_id,)` | Prevents a delegating invoker contract from having a solver accept a different intent than the one the solver actually signed for. | -| `fill_intent` | `solver.require_auth()` | `(solver, intent_id, fill_amount)` | Highest-value call site — the auth gates an outgoing token transfer. Prevents a delegating invoker from filling a different intent, or a different amount, than the solver signed for. | - -`accept_intent`'s batch wrapper (`accept_intent_batch`) delegates to -`accept_intent` per element and needed no separate change. +| Function | Scoped args | Rationale | +|---|---|---| +| `submit_intent` | `(user, dst_token, min_dst_amount)` | Prevents a composable invoker from redirecting the user's signed submission to a different destination token or minimum output. | +| `accept_intent` | `(intent_id,)` | Prevents a delegating invoker from having a solver accept a different intent than the one the solver actually signed for. Scoping to `intent_id` is the minimal sufficient scope since the bond token is always the default for this entrypoint. | +| `accept_intent_with_bond` | `(intent_id, bond_token)` | Same as `accept_intent` plus the specific bond token, preventing redirection to a different intent or a different bond denomination. | +| `fill_intent` | `(solver, intent_id, fill_amount)` | Highest-value call site — the auth gates an outgoing token transfer. Prevents a delegating invoker from filling a different intent, or a different amount, than the solver signed for. The solver address is included so the tuple is globally unique (not just per-contract). | +| `begin_fill` | `(solver, intent_id, fill_amount)` | Same rationale as `fill_intent` — tokens move into escrow. Scoping prevents replay across intents or amounts. | +| `batch_accept_intent` | `(intent_ids,)` — the full `Vec>` | The solver's sig covers exactly this ordered set; replaying it for a different list of intents is rejected. | +| `batch_fill_intent` | `(fills,)` — the full `Vec<(BytesN<32>, i128)>` | Covers both the intent IDs and fill amounts; a signature for one fill list cannot be replayed for a different list. | ## Kept as `require_auth()` @@ -24,21 +26,37 @@ could redirect their signature toward unintended arguments. |---|---|---| | `initialize` | `admin` | One-time setup; the signer *is* the value being recorded as admin — no sub-scope to narrow. | | `propose_fee_recipient` | stored `admin` | Single global admin capability; no meaningful sub-scope within "being admin". | -| `accept_fee_recipient` | `new_fee_recipient` | Recipient proves ownership of their own address; the timelock and pending-proposal match (`pending != new_fee_recipient` check) already constrain which proposal can be accepted. | +| `accept_fee_recipient` | `new_fee_recipient` | Recipient proves ownership of their own address; the timelock and pending-proposal match already constrain which proposal can be accepted. | | `propose_admin_transfer` | stored `admin` | Same as `propose_fee_recipient`. | | `accept_admin_transfer` | `new_admin` | Same as `accept_fee_recipient`. | | `register_solver` | `solver` | Solver consents to locking their own bond funds; simple self-action with no delegated-execution surface. | | `deregister_solver` | `solver` | Solver-only self-action. | | `withdraw_bond` | `solver` | Solver-only self-action on their own bond. | -| `cancel_intent` | `user` | Simple "cancel my own intent" self-action; an explicit `intent.user != user` ownership check runs immediately after, providing defence-in-depth. | -| `request_extension` | `solver` | Grants at most one grace-period extension per intent; no funds move and no cross-intent redirection is possible (the intent is loaded and ownership-checked before use). | -| `require_admin` (helper; gates `unpause`, `set_pauser`, dst-token allowlist admin functions) | `admin` | Single admin address with uniform authority across these functions — no per-argument capability to scope. | -| `require_admin_or_pauser` (helper; gates `pause`) | `admin` or `pauser` | Same reasoning as `require_admin`; the admin/pauser check already precedes the auth call. | +| `cancel_intent` | `user` | Simple "cancel my own intent" self-action; an explicit `intent.user != user` ownership check runs immediately after. | +| `request_extension` | `solver` | At most one extension per intent; no funds move and no cross-intent redirection is possible. | +| `require_admin` (helper) | `admin` | Uniform admin authority — no per-argument capability to scope. | +| `require_admin_or_pauser` (helper) | `admin` or `pauser` | Same as `require_admin`. | ## Integration impact -`require_auth_for_args` changes the exact signed-payload shape a client must -build. `submit_intent`, `accept_intent`, and `fill_intent` are called -respectively by user-facing clients and solver bots — see -`docs/solver-integration-guide.md` for the updated payload shapes solver bot -authors must sign. +`require_auth_for_args` changes the signed-payload shape clients must build. + +- **`submit_intent`:** user wallets must sign over `(user, dst_token, min_dst_amount)`. +- **`accept_intent`:** solver bots must sign over `(intent_id,)`. +- **`accept_intent_with_bond`:** solver bots must sign over `(intent_id, bond_token)`. +- **`fill_intent`:** solver bots must sign over `(solver, intent_id, fill_amount)`. +- **`begin_fill`:** solver bots must sign over `(solver, intent_id, fill_amount)`. +- **`batch_accept_intent`:** solver bots must sign over the full `Vec>` of intent IDs. +- **`batch_fill_intent`:** solver bots must sign over the full `Vec<(BytesN<32>, i128)>` of (intent_id, fill_amount) pairs. + +See `docs/solver-integration-guide.md` for the updated payload shapes solver bot +authors must sign. The Soroban SDK's `IntoVal` implementation serializes these +tuples in canonical XDR order, which is what on-chain auth verification expects. + +## Negative-test coverage + +Tests must verify that a `MockAuth` entry built for intent A is rejected when +submitted for intent B. See `intent_settlement/src/test.rs` for the +`test_accept_intent_auth_scoping` and `test_fill_intent_auth_scoping` test cases +that build auth entries manually and confirm cross-intent replay fails with +`Unauthorized`. diff --git a/intent_settlement/src/lib.rs b/intent_settlement/src/lib.rs index 71c0018..c0bf6d9 100644 --- a/intent_settlement/src/lib.rs +++ b/intent_settlement/src/lib.rs @@ -42,6 +42,17 @@ const PROTOCOL_FEE_BPS: i128 = 5; // 0.05% /// always economically punished. const SLASH_BPS: i128 = 1_000; // 10% +/// Issue #420 — grace period added to the fill deadline before `slash_solver` +/// may execute. This absorbs the worst-case ledger close-time drift on +/// Stellar (up to ±7 s per ledger, documented in +/// `docs/420-ledger-time-skew-analysis.md`). +/// +/// A solver whose fill transaction lands in a ledger whose close time is +/// within `SLASH_GRACE_SECS` of the nominal deadline is protected from being +/// slashed for that race. The fill window stays at `FILL_WINDOW` — the grace +/// only widens the slash eligibility window, not the fill eligibility window. +const SLASH_GRACE_SECS: u64 = 10; // 2 × worst-case ledger gap (~5 s) + /// Issue #188 — dispute-resolution flow (docs/dispute-resolution-design.md). /// /// `DISPUTE_WINDOW` is the period, starting at `begin_fill`, during which the @@ -62,24 +73,13 @@ const ARBITER_WINDOW: u64 = 86_400; // 24 hours const MAX_BOND_TOKENS: u32 = 8; /// Dispute-resolution parameters (issue #48, #233): -/// When a solver delivers tokens (begin_fill), the user has DISPUTE_WINDOW seconds -/// to open a dispute. If no dispute is raised, release_fill() can execute after -/// the window closes. If a dispute is raised, the arbiter has ARBITER_WINDOW -/// seconds to resolve it; if unresolved, the timeout releases escrow to the user. -const DISPUTE_WINDOW: u64 = 3600; // 1 hour: time for user to notice and contest fill -const ARBITER_WINDOW: u64 = 86400; // 24 hours: time for arbiter to resolve +/// Anti-griefing bond a user must post when calling `open_dispute` / `dispute_fill`. const DISPUTE_BOND: i128 = 1 * 10_000_000; // 1 USDC: anti-griefing bond from user /// Upper bound on the number of intent IDs `list_open_intents` returns per /// call (issue #249), bounding the resource cost of paginated reads. const MAX_PAGE_SIZE: u32 = 100; -/// After being slashed a solver must wait this many seconds before they can -/// accept new intents. Used by `accept_intent`'s cooldown guard and by -/// `get_slash_cooldown_remaining` (issue #256), which both derive from the -/// same `slash_cooldown_remaining` helper so they can never disagree. -const SLASH_COOLDOWN: u64 = 3600; // 1 hour - /// Upper bound on the number of `src_chain`/`dst_token` entries a solver may /// declare via `set_solver_routes` (issue #255), to keep per-solver route /// storage bounded. @@ -93,26 +93,6 @@ const MAX_ROUTE_ENTRIES: u32 = 20; /// (#116). const ADMIN_TIMELOCK_DELAY: u64 = 172_800; // 48 hours -// ── Defaults seeded into `ProtocolConfig` by `initialize`, and the fallback -// `load_config` returns for contracts deployed before the configurable-params -// feature existed. They mirror the historical compile-time constants above. -const DEFAULT_MIN_BOND: i128 = MIN_BOND; -const DEFAULT_FILL_WINDOW: u64 = FILL_WINDOW; -const DEFAULT_INTENT_EXPIRY: u64 = INTENT_EXPIRY; -const DEFAULT_PROTOCOL_FEE_BPS: i128 = PROTOCOL_FEE_BPS; - -// ── `set_config` bounds. A parameter outside any of these ranges is rejected -// with `Error::InvalidConfig`. -const MAX_PROTOCOL_FEE_BPS: i128 = 1_000; // 10% hard cap on the protocol fee -const MIN_FILL_WINDOW_SECS: u64 = 60; // a solver needs at least a minute to fill -const MIN_INTENT_EXPIRY_SECS: u64 = 300; // and must always exceed the fill window -const MIN_BOND_FLOOR: i128 = 10_000_000; // one 7-decimal USDC unit - -// ── Cooldowns / limits enforced outside `ProtocolConfig`. -const SLASH_COOLDOWN: u64 = 3600; // 1 hour a slashed solver must wait before accepting again -const CANCEL_COOLDOWN: u64 = 3600; // 1 hour between a user's successive intent cancellations -const MAX_EXTENSION_DURATION: u64 = 300; // one extra fill window granted by `request_extension` - // ── Storage-migration schema version (#194). Bumped whenever a `migrate()` // body is added for a new release; `initialize` stamps fresh deploys with the // current value and `migrate` refuses to run once the contract is already at @@ -134,14 +114,6 @@ const BPS_DENOMINATOR: i128 = 10_000; // That is a comfortable safety margin while rejecting only fat-fingered inputs. pub const MAX_AMOUNT: i128 = 1_000_000_000_000_000_000_000_000_000_000i128; // 10^30 -const MAX_BATCH_SIZE: u32 = 100; -const MAX_EXTENSION_DURATION: u64 = 600; // 10 minutes - -const DEFAULT_MIN_BOND: i128 = MIN_BOND; -const DEFAULT_FILL_WINDOW: u64 = FILL_WINDOW; -const DEFAULT_INTENT_EXPIRY: u64 = INTENT_EXPIRY; -const DEFAULT_PROTOCOL_FEE_BPS: i128 = PROTOCOL_FEE_BPS; - // Soroban archives ledger entries that go too long without being touched. // Persistent Intent/Solver records get their TTL bumped on every write so // they don't need to be manually restored before later calls can read them. @@ -402,10 +374,52 @@ pub enum DataKey { /// touches this key, so proof-gating is fully opt-in and defaults off /// exactly like `DstAllowlistEnabled`. ProofRegistry, + + // ── Issue #408 — atomic chain onboarding ───────────────────────────────── + + /// **Instance storage.** Pending `propose_chain_onboarding` proposal: + /// maps a chain name (`String`) to `(ChainConfig, eta: u64)` where `eta` + /// is the earliest ledger timestamp at which `execute_chain_onboarding` + /// may apply it. + PendingChainOnboarding(String), + + /// **Instance storage.** Live `ChainConfig` for a chain that has been + /// fully onboarded via `execute_chain_onboarding`. Keyed by canonical + /// chain name (e.g. `"ethereum"`). Absent for chains that have never + /// been onboarded or have been removed via `execute_chain_offboarding`. + ChainConfig(String), } // ─── Data Structs ───────────────────────────────────────────────────────────── +/// Issue #408 — full configuration bundle for a single source chain. +/// +/// `propose_chain_onboarding` queues this struct under a timelock; +/// `execute_chain_onboarding` applies it atomically to both +/// `intent_settlement` (src-chain allowlist) and `proof_registry` +/// (authorized emitter). +/// +/// **Fields:** +/// * `name` — canonical lowercase string used in `submit_intent`'s +/// `src_chain` field (e.g. `"ethereum"`). +/// * `wormhole_id` — Wormhole chain ID (e.g. 2 for Ethereum). +/// * `axelar_name` — Axelar source-chain identifier string (e.g. `"Ethereum"`). +/// * `emitter` — 32-byte Wormhole emitter address for this chain. +/// * `axelar_source`— Axelar source-contract address string. +/// * `token_format` — human-readable description of the `src_token` format +/// (e.g. `"0x-prefixed 40-char hex"`); stored for reference, +/// not validated on-chain. +#[contracttype] +#[derive(Clone)] +pub struct ChainConfig { + pub name: String, + pub wormhole_id: u32, + pub axelar_name: String, + pub emitter: BytesN<32>, + pub axelar_source: String, + pub token_format: String, +} + /// Admin-configurable protocol parameters. Stored as a single instance-storage /// entry so all values are read/written atomically. #[contracttype] @@ -756,6 +770,25 @@ pub enum Error { /// submitting `user`. Self-referral is rejected to prevent a user from /// gaming the referral programme by naming their own address. SelfReferral = 35, + + // ── Issue #408 — atomic chain onboarding ───────────────────────────────── + + /// `execute_chain_onboarding` was called with no matching pending proposal. + NoPendingChainOnboarding = 36, + /// A chain onboarding / offboarding admin action was performed on a chain + /// that is already in the target state (already onboarded or not present). + ChainAlreadyConfigured = 37, + /// `execute_chain_offboarding` was called for a chain that is not + /// currently onboarded. + ChainNotConfigured = 38, + + // ── Issue #409 — balance-delta accounting ───────────────────────────────── + + /// The dispute bond token charged a transfer fee (received amount ≠ + /// requested amount). The bond token must be a non-fee-on-transfer token; + /// this error signals a misconfiguration. Documented in SECURITY.md + /// §Fee-on-transfer tokens. + BondTokenFeeOnTransfer = 39, } // ─── Contract ───────────────────────────────────────────────────────────────── @@ -1396,6 +1429,150 @@ impl IntentSettlement { .unwrap_or(false) } + // ── Atomic chain onboarding (issue #408) ────────────────────────────────── + // + // Adding a new source chain today requires multiple separate admin + // operations across two contracts: `add_allowed_src_chain`, + // `set_authorized_emitter` on proof_registry, Axelar source config, and + // token-format rules. A partial configuration leaves the chain + // half-enabled — intents can be submitted but fills fail with proof + // errors and the intents are slashed or expire. + // + // `propose_chain_onboarding` / `execute_chain_onboarding` bundle all + // changes into one timelocked proposal that is applied atomically in a + // single transaction (settlement calls the registry as the configurator). + // + // **Offboarding behaviour:** `execute_chain_offboarding` removes the chain + // from the allowlist and strips the authorized emitter. In-flight intents + // that were already submitted and accepted are *unaffected* — the intent's + // `src_chain` field is informational and the fill path does not re-validate + // it against the allowlist. Proofs already stored in the registry remain + // readable. Operators should wait for all open intents on the offboarded + // chain to resolve before removing the chain. + + /// Admin-only: queue a full source-chain onboarding bundle under a + /// 48-hour timelock. A new proposal for the same chain overwrites any + /// prior pending proposal (and resets the clock), so the admin can + /// correct a misconfiguration before execution. + /// + /// Emits `chain_onboarding_proposed(name, wormhole_id, eta)`. + pub fn propose_chain_onboarding(env: Env, config: ChainConfig) { + Self::require_admin(&env); + let eta = env.ledger().timestamp() + ADMIN_TIMELOCK_DELAY; + env.storage() + .instance() + .set(&DataKey::PendingChainOnboarding(config.name.clone()), &(config.clone(), eta)); + Self::bump_instance_ttl(&env); + env.events().publish( + (Symbol::new(&env, "chain_onboarding_proposed"),), + (config.name, config.wormhole_id, eta), + ); + } + + /// Admin-only: execute a pending chain-onboarding proposal once the + /// 48-hour timelock has elapsed. Applies all changes atomically: + /// + /// 1. Adds `config.name` to the src-chain allowlist. + /// 2. Calls `proof_registry.configure_chain(wormhole_id, emitter)` so the + /// registry authorizes VAAs from this chain in the same transaction. + /// 3. Writes a live `ChainConfig` entry for `get_chain_config`. + /// + /// Panics with `NoPendingChainOnboarding` if no proposal exists, + /// `TimelockNotElapsed` if the delay hasn't passed, or + /// `ChainAlreadyConfigured` if the chain is already active. + /// + /// Emits `chain_onboarding_executed(name, wormhole_id)`. + pub fn execute_chain_onboarding(env: Env, name: String) { + Self::require_admin(&env); + + let (config, eta): (ChainConfig, u64) = env + .storage() + .instance() + .get(&DataKey::PendingChainOnboarding(name.clone())) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoPendingChainOnboarding)); + + if env.ledger().timestamp() < eta { + panic_with_error!(&env, Error::TimelockNotElapsed); + } + + if env.storage().instance().has(&DataKey::ChainConfig(name.clone())) { + panic_with_error!(&env, Error::ChainAlreadyConfigured); + } + + // 1. Add to the src-chain allowlist. + env.storage() + .instance() + .set(&DataKey::AllowedSrcChain(config.name.clone()), &true); + + // 2. Configure the proof registry (cross-contract call). + // The registry must have called set_configurator(this_contract) first. + if let Some(registry_addr) = env.storage().instance().get::<_, Address>(&DataKey::ProofRegistry) { + let registry = vortex_proof_registry::ProofRegistryClient::new(&env, ®istry_addr); + registry.configure_chain(&config.wormhole_id, &config.emitter); + } + + // 3. Persist the live config and remove the pending proposal. + env.storage() + .instance() + .set(&DataKey::ChainConfig(config.name.clone()), &config); + env.storage() + .instance() + .remove(&DataKey::PendingChainOnboarding(name.clone())); + + Self::bump_instance_ttl(&env); + env.events().publish( + (Symbol::new(&env, "chain_onboarding_executed"),), + (config.name, config.wormhole_id), + ); + } + + /// Admin-only: remove a previously onboarded chain. Strips it from the + /// allowlist and from the proof registry's emitter table in one call. + /// + /// In-flight intents already submitted for this chain are not affected — + /// the src-chain field is validated only at submission time, not at fill + /// time. Documented in `docs/132-supported-chains.md §6`. + /// + /// Panics with `ChainNotConfigured` if the chain is not currently active. + /// Emits `chain_offboarding_executed(name)`. + pub fn execute_chain_offboarding(env: Env, name: String) { + Self::require_admin(&env); + + let config: ChainConfig = env + .storage() + .instance() + .get(&DataKey::ChainConfig(name.clone())) + .unwrap_or_else(|| panic_with_error!(&env, Error::ChainNotConfigured)); + + // Remove from allowlist. + env.storage() + .instance() + .remove(&DataKey::AllowedSrcChain(name.clone())); + + // Remove from proof registry. + if let Some(registry_addr) = env.storage().instance().get::<_, Address>(&DataKey::ProofRegistry) { + let registry = vortex_proof_registry::ProofRegistryClient::new(&env, ®istry_addr); + registry.remove_chain(&config.wormhole_id); + } + + // Remove the live config entry. + env.storage() + .instance() + .remove(&DataKey::ChainConfig(name.clone())); + + Self::bump_instance_ttl(&env); + env.events().publish( + (Symbol::new(&env, "chain_offboarding_executed"),), + name, + ); + } + + /// View: return the live `ChainConfig` for `name`, or `None` if the chain + /// has not been onboarded (or has been offboarded). + pub fn get_chain_config(env: Env, name: String) -> Option { + env.storage().instance().get(&DataKey::ChainConfig(name)) + } + // ── Pause Control ───────────────────────────────────────────────────────── /// Admin-only: designate (or rotate) the address that may call `pause` @@ -1642,9 +1819,20 @@ impl IntentSettlement { Self::add_to_solver_list(&env, &solver); } - // ── Interaction: pull bond in ──────────────────────────────────────── - let client = token::Client::new(&env, &bond_token); - client.transfer(&solver, &env.current_contract_address(), &bond_amount); + // ── Interaction: pull bond in (balance-delta, #409) ────────────────── + // Use pull_exact to measure the actual received amount so fee-on- + // transfer bond tokens don't create a fictitious liability. + let received = Self::pull_exact(&env, &bond_token, &solver, bond_amount); + // Re-derive new_bond using the actual received delta rather than the + // requested bond_amount, then re-apply to the record already persisted. + if received != bond_amount { + // Correct the stored bond for the fee discrepancy. + let corrected = existing_bond + received; + Self::set_solver_bond_amount(&env, &mut record, &bond_token, corrected); + env.storage() + .persistent() + .set(&DataKey::Solver(solver.clone()), &record); + } env.events().publish( (Symbol::new(&env, "solver_registered"), solver), @@ -2035,6 +2223,12 @@ impl IntentSettlement { /// Issue #187: thin wrapper over `accept_intent_with_bond` pinned to the /// legacy default bond token. pub fn accept_intent(env: Env, solver: Address, intent_id: BytesN<32>) { + // Auth audit (#411): scoped to (intent_id,) so a delegated-execution + // invoker cannot redirect the solver's signed authorization to a + // different intent than the one they chose to accept. + solver.require_auth_for_args( + (intent_id.clone(),).into_val(&env), + ); let bond_token = Self::load_bond_token(&env); Self::accept_intent_inner(env, solver, intent_id, bond_token); } @@ -2048,6 +2242,11 @@ impl IntentSettlement { intent_id: BytesN<32>, bond_token: Address, ) { + // Auth audit (#411): scoped to (intent_id, bond_token) so the solver's + // signed authorization cannot be redirected to a different intent. + solver.require_auth_for_args( + (intent_id.clone(), bond_token.clone()).into_val(&env), + ); Self::accept_intent_inner(env, solver, intent_id, bond_token); } @@ -2057,16 +2256,16 @@ impl IntentSettlement { intent_id: BytesN<32>, bond_token: Address, ) { - // Auth audit: require_auth() is correct. The solver must sign to - // voluntarily take on the fill obligation and bond risk. - solver.require_auth(); - Self::accept_intent_inner(env, solver, intent_id); + // Both `accept_intent` and `accept_intent_with_bond` have already called + // `require_auth_for_args` before reaching here (#411). No additional + // `require_auth` call is needed in this body. + Self::accept_intent_body(env, solver, intent_id, bond_token); } - /// Body of `accept_intent` without the `solver.require_auth()` gate. Shared + /// Body of `accept_intent` without the `solver.require_auth*` gate. Shared /// with `batch_accept_intent`, which authorises the solver once per batch - /// (`require_auth()` is one-shot per address per invocation). - fn accept_intent_inner(env: Env, solver: Address, intent_id: BytesN<32>) { + /// (`require_auth_for_args` is one-shot per address per invocation). + fn accept_intent_body(env: Env, solver: Address, intent_id: BytesN<32>, bond_token: Address) { Self::require_not_paused(&env); Self::bump_instance_ttl(&env); @@ -2204,14 +2403,17 @@ impl IntentSettlement { fill_amount: i128, require_proof: bool, ) { - // Auth audit: require_auth() is correct. The solver must sign to - // authorise the token transfer from their address to the user and fee - // recipient. This is the highest-value call site: the solver authorises - // a token transfer, so the auth is load-bearing. require_auth_for_args - // scoped to (solver, intent_id, fill_amount) would meaningfully tighten - // the scope if a delegated-execution pattern is ever introduced — noted - // as the strongest candidate for future hardening. - solver.require_auth(); + // Auth audit (#411): scoped to (solver, intent_id, fill_amount) — the + // highest-value call site in the contract. The solver authorises a + // token transfer, so this auth is load-bearing. Scoping to the solver + // address, intent ID, and fill amount ensures a relayer or delegating + // invoker cannot redirect the solver's signed authorization to a + // different intent or a different fill amount than those the solver + // explicitly approved. The solver address is included so the auth + // entry is globally unique across contracts, not just within this one. + solver.require_auth_for_args( + (solver.clone(), intent_id.clone(), fill_amount).into_val(&env), + ); Self::fill_intent_inner(env, solver, intent_id, fill_amount); } @@ -2227,11 +2429,11 @@ impl IntentSettlement { let mut intent = Self::load_intent(&env, &intent_id); let now = env.ledger().timestamp(); - // 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. Shared with `is_intent_fillable` via `check_fill_guards` - // (issue #259) so the two can never silently drift apart. + // Boundary semantics (#420): fill-window deadline is EXCLUSIVE — the + // `fill_window_open` helper enforces `now < deadline`. Using the named + // helper (rather than an inline comparison) ensures this call site and + // `is_intent_fillable` / `check_fill_guards` can never silently drift + // apart. Issue #259 documents this sharing contract. if let Err(e) = Self::check_fill_guards(&intent, &solver, now) { panic_with_error!(&env, e); } @@ -2345,6 +2547,15 @@ impl IntentSettlement { // ── Interactions: token transfers (state already committed above) ──── // Solver delivers this fill's output to the user, then separately pays // the protocol fee. Each transfer happens exactly once. + // + // Issue #409 exemption: direct solver→user fills are NOT wrapped in + // `pull_exact` because the contract is never the receiver of these + // tokens — the user gets whatever the token delivers, and our contract + // never records a balance-based liability for this path. The balance- + // delta pattern is only needed for inbound transfers *into* the contract + // (bonds via `register_solver`, escrow via `begin_fill`, dispute bonds + // via `open_dispute`). This exemption is documented in SECURITY.md + // §Fee-on-transfer tokens. let dst_client = token::Client::new(&env, &intent.dst_token); // Solver delivers the full requested output to the user. @@ -2469,228 +2680,6 @@ impl IntentSettlement { intent_id.clone(), ); } - - /// Solver begins fill by depositing dst_token into escrow. Starts dispute window. - /// Replaces the direct transfer in fill_intent once this design is implemented. - /// For now, this is a placeholder establishing the interface. - pub fn begin_fill(env: Env, solver: Address, intent_id: BytesN<32>, fill_amount: i128) { - solver.require_auth(); - Self::require_not_paused(&env); - Self::bump_instance_ttl(&env); - - let mut intent: IntentRecord = env - .storage() - .persistent() - .get(&DataKey::Intent(intent_id.clone())) - .unwrap_or_else(|| panic_with_error!(&env, Error::IntentNotFound)); - - if intent.solver.as_ref() != Some(&solver) { - panic_with_error!(&env, Error::Unauthorized); - } - - if intent.state != IntentState::Accepted { - panic_with_error!(&env, Error::IntentNotAccepted); - } - - let now = env.ledger().timestamp(); - if now >= intent.deadline { - panic_with_error!(&env, Error::FillWindowExpired); - } - - // Transition to Filling and set dispute window deadline - intent.state = IntentState::Filling; - intent.dispute_deadline = Some(now + DISPUTE_WINDOW); - - env.storage() - .persistent() - .set(&DataKey::Intent(intent_id.clone()), &intent); - Self::bump_intent_ttl(&env, &intent_id); - - env.events().publish( - (Symbol::new(&env, "fill_begun"),), - (intent_id, solver, fill_amount), - ); - } - - /// User opens a dispute within the dispute window. Requires paying a bond. - /// Transitions intent to Disputed state. - pub fn open_dispute(env: Env, user: Address, intent_id: BytesN<32>) { - user.require_auth(); - Self::bump_instance_ttl(&env); - - let mut intent: IntentRecord = env - .storage() - .persistent() - .get(&DataKey::Intent(intent_id.clone())) - .unwrap_or_else(|| panic_with_error!(&env, Error::IntentNotFound)); - - if intent.user != user { - panic_with_error!(&env, Error::Unauthorized); - } - - if intent.state != IntentState::Filling { - panic_with_error!(&env, Error::NoDisputeOpen); - } - - let now = env.ledger().timestamp(); - if let Some(deadline) = intent.dispute_deadline { - if now >= deadline { - panic_with_error!(&env, Error::DisputeWindowExpired); - } - } else { - panic_with_error!(&env, Error::NoFillEscrowed); - } - - // Pull dispute bond from user - let bond_token: Address = env - .storage() - .instance() - .get(&DataKey::BondToken) - .unwrap(); - let bond_client = token::Client::new(&env, &bond_token); - bond_client.transfer_from(&user, &env.current_contract_address(), &user, &DISPUTE_BOND); - - intent.state = IntentState::Disputed; - intent.dispute_raised_at = Some(now); - - env.storage() - .persistent() - .set(&DataKey::Intent(intent_id.clone()), &intent); - Self::bump_intent_ttl(&env, &intent_id); - - env.events().publish( - (Symbol::new(&env, "dispute_opened"),), - (intent_id, user), - ); - } - - /// Arbiter resolves a dispute. Transitions intent to Resolved and handles bond/escrow. - pub fn resolve_dispute( - env: Env, - arbiter: Address, - intent_id: BytesN<32>, - resolution: DisputeResolution, - ) { - arbiter.require_auth(); - Self::bump_instance_ttl(&env); - - // For now, arbiter is the admin. In v2, this could be a separate arbiter role. - Self::require_admin(&env); - - let mut intent: IntentRecord = env - .storage() - .persistent() - .get(&DataKey::Intent(intent_id.clone())) - .unwrap_or_else(|| panic_with_error!(&env, Error::IntentNotFound)); - - if intent.state != IntentState::Disputed { - panic_with_error!(&env, Error::NoDisputeOpen); - } - - let now = env.ledger().timestamp(); - if let Some(raised_at) = intent.dispute_raised_at { - if now >= raised_at + ARBITER_WINDOW { - panic_with_error!(&env, Error::ArbiterWindowExpired); - } - } else { - panic_with_error!(&env, Error::NoDisputeOpen); - } - - let bond_token: Address = env - .storage() - .instance() - .get(&DataKey::BondToken) - .unwrap(); - let bond_client = token::Client::new(&env, &bond_token); - - intent.state = IntentState::Resolved; - intent.resolution = Some(resolution.clone()); - - match resolution { - DisputeResolution::Upheld => { - // Refund bond to user, slash solver - bond_client.transfer(&env.current_contract_address(), &intent.user, &DISPUTE_BOND); - - if let Some(solver) = &intent.solver { - // Slash solver's bond - let mut solver_record: SolverRecord = env - .storage() - .persistent() - .get(&DataKey::Solver(solver.clone())) - .unwrap(); - let slash_amount = solver_record.bond_amount / 10; - solver_record.bond_amount = solver_record.bond_amount.saturating_sub(slash_amount); - env.storage() - .persistent() - .set(&DataKey::Solver(solver.clone()), &solver_record); - Self::bump_solver_ttl(&env, solver); - - // Transfer slashed bond to fee recipient - let fee_recipient: Address = env - .storage() - .instance() - .get(&DataKey::FeeRecipient) - .unwrap(); - bond_client.transfer(&env.current_contract_address(), &fee_recipient, &slash_amount); - } - } - DisputeResolution::Dismissed => { - // Forfeit bond to fee recipient - let fee_recipient: Address = env - .storage() - .instance() - .get(&DataKey::FeeRecipient) - .unwrap(); - bond_client.transfer(&env.current_contract_address(), &fee_recipient, &DISPUTE_BOND); - } - } - - env.storage() - .persistent() - .set(&DataKey::Intent(intent_id.clone()), &intent); - Self::bump_intent_ttl(&env, &intent_id); - - env.events().publish( - (Symbol::new(&env, "dispute_resolved"),), - (intent_id, resolution), - ); - } - - /// Permissionless: release escrowed fill after dispute window closes without a dispute. - pub fn release_fill(env: Env, intent_id: BytesN<32>) { - Self::bump_instance_ttl(&env); - - let mut intent: IntentRecord = env - .storage() - .persistent() - .get(&DataKey::Intent(intent_id.clone())) - .unwrap_or_else(|| panic_with_error!(&env, Error::IntentNotFound)); - - if intent.state != IntentState::Filling { - panic_with_error!(&env, Error::NoFillEscrowed); - } - - let now = env.ledger().timestamp(); - if let Some(deadline) = intent.dispute_deadline { - if now < deadline { - panic_with_error!(&env, Error::DisputeWindowExpired); - } - } else { - panic_with_error!(&env, Error::NoFillEscrowed); - } - - // Transition to Filled (this is a simplified version; full impl would handle token release) - intent.state = IntentState::Filled; - intent.filled_at = Some(now); - - env.storage() - .persistent() - .set(&DataKey::Intent(intent_id.clone()), &intent); - Self::bump_intent_ttl(&env, &intent_id); - - env.events().publish((Symbol::new(&env, "fill_released"),), intent_id); - } - /// Permissionless: slash a solver that accepted but didn't fill within FILL_WINDOW pub fn slash_solver(env: Env, intent_id: BytesN<32>) { Self::bump_instance_ttl(&env); @@ -2703,12 +2692,12 @@ impl IntentSettlement { panic_with_error!(&env, Error::IntentNotAccepted); } - // Boundary semantics: the fill-window deadline is INCLUSIVE for slashing. - // The guard `now < intent.deadline` is false when `now == deadline`, so - // slashing becomes valid at the deadline second itself (not strictly after). - // Fill window available to solver: [accepted_at, accepted_at + FILL_WINDOW). - // Slash window: [accepted_at + FILL_WINDOW, ∞). - if now < intent.deadline { + // Boundary semantics (#420): slashing requires now >= deadline + SLASH_GRACE_SECS. + // The grace period absorbs ledger close-time drift so a solver whose fill + // transaction races the deadline is not slashed unfairly. The fill window + // itself is unchanged — fills remain valid until `now < deadline`. + // See docs/420-ledger-time-skew-analysis.md for the full analysis. + if !Self::slash_eligible(now, intent.deadline) { panic_with_error!(&env, Error::FillWindowExpired); // not expired yet } @@ -3093,7 +3082,9 @@ impl IntentSettlement { /// must bring `total_filled` to at least `min_dst_amount`. Partial fills /// keep using `fill_intent`. pub fn begin_fill(env: Env, solver: Address, intent_id: BytesN<32>, fill_amount: i128) { - solver.require_auth(); + solver.require_auth_for_args( + (&solver, &intent_id, &fill_amount).into_val(&env), + ); Self::require_not_paused(&env); Self::bump_instance_ttl(&env); @@ -3111,8 +3102,8 @@ impl IntentSettlement { } let now = env.ledger().timestamp(); - // Fill-window deadline is EXCLUSIVE, matching `fill_intent`. - if now >= intent.deadline { + // Fill-window boundary (#420): fill is valid while fill_window_open. + if !Self::fill_window_open(now, intent.deadline) { panic_with_error!(&env, Error::FillWindowExpired); } if fill_amount <= 0 { @@ -3133,12 +3124,17 @@ impl IntentSettlement { .set(&DataKey::Intent(intent_id.clone()), &intent); Self::bump_intent_ttl(&env, &intent_id); - // ── Interaction: pull the output into escrow ───────────────────────── - token::Client::new(&env, &intent.dst_token).transfer( - &solver, - &env.current_contract_address(), - &fill_amount, - ); + // ── Interaction: pull the output into escrow (balance-delta, #409) ─── + // Measure the actual amount received so that on release_fill / resolve_dispute + // the user is paid exactly what arrived, not what was requested. + let escrowed = Self::pull_exact(&env, &intent.dst_token, &solver, fill_amount); + if escrowed != fill_amount { + // Correct the stored fill_amount to the actual escrowed value. + intent.fill_amount = Some(escrowed); + env.storage() + .persistent() + .set(&DataKey::Intent(intent_id.clone()), &intent); + } env.events().publish( (Symbol::new(&env, "fill_begun"), solver), @@ -3573,10 +3569,15 @@ impl IntentSettlement { if intent_ids.len() > MAX_BATCH_SIZE { panic_with_error!(&env, Error::BatchTooLarge); } - solver.require_auth(); + // Auth audit (#411): scope to the full id list so the solver's sig + // cannot be reused for a different set of intents. + solver.require_auth_for_args( + (intent_ids.clone(),).into_val(&env), + ); for intent_id in intent_ids { - Self::accept_intent_inner(env.clone(), solver.clone(), intent_id); + let bond_token = Self::load_bond_token(&env); + Self::accept_intent_inner(env.clone(), solver.clone(), intent_id, bond_token); } } @@ -3597,7 +3598,12 @@ impl IntentSettlement { if fills.len() > MAX_BATCH_SIZE { panic_with_error!(&env, Error::BatchTooLarge); } - solver.require_auth(); + // Auth audit (#411): scope to the full (intent_id, fill_amount) list so + // the solver's signed authorization cannot be replayed for a different + // set of intents or amounts than those they actually signed for. + solver.require_auth_for_args( + (fills.clone(),).into_val(&env), + ); for (intent_id, fill_amount) in fills { Self::fill_intent_inner(env.clone(), solver.clone(), intent_id, fill_amount); @@ -3629,50 +3635,6 @@ impl IntentSettlement { Self::stamp_cancel_cooldown(&env, &user, now); } - /// Fill multiple intents in a single transaction. - /// - /// Each element of `fills` is `(intent_id, fill_amount)`. All fills are - /// processed atomically — if any individual fill fails the entire batch - /// reverts. - /// - /// Bounded by [`MAX_BATCH_SIZE`] to prevent resource exhaustion. - /// See `docs/149-resource-cost-per-entrypoint.md` for the per-item - /// write-entry analysis that justifies the chosen limit. - pub fn batch_fill_intent( - env: Env, - solver: Address, - fills: soroban_sdk::Vec<(BytesN<32>, i128)>, - ) { - if fills.len() > MAX_BATCH_SIZE as usize { - panic_with_error!(&env, Error::ZeroAmount); // No dedicated error; reuse nearest - } - - for (intent_id, fill_amount) in fills { - Self::fill_intent(env.clone(), solver.clone(), intent_id, fill_amount); - } - } - - /// Cancel multiple Open intents belonging to `user` in a single - /// transaction. - /// - /// All cancellations are processed atomically — if any individual cancel - /// fails the entire batch reverts. - /// - /// Bounded by [`MAX_BATCH_SIZE`] to prevent resource exhaustion. - pub fn batch_cancel_intent( - env: Env, - user: Address, - intent_ids: soroban_sdk::Vec>, - ) { - if intent_ids.len() > MAX_BATCH_SIZE as usize { - panic_with_error!(&env, Error::ZeroAmount); // No dedicated error; reuse nearest - } - - for intent_id in intent_ids { - Self::cancel_intent(env.clone(), user.clone(), intent_id); - } - } - // ── Fill Window Extension ───────────────────────────────────────────────── /// Per-intent cumulative fill-window extension budget for `solver`, in @@ -4086,13 +4048,6 @@ impl IntentSettlement { env.storage().instance().get(&DataKey::Admin) } - /// Pending admin-transfer proposal, if any: `(new_admin, eta)` where `eta` - /// is the ledger timestamp at which `accept_admin_transfer` may execute it. - pub fn get_pending_admin(env: Env) -> Option<(Address, u64)> { - env.storage().instance().get(&DataKey::PendingAdmin) - } - - /// /// - `total_intents` — cumulative count of intents ever submitted. /// - `total_volume` — cumulative dst-token units delivered across all fills. @@ -4519,6 +4474,39 @@ impl IntentSettlement { } } + /// Issue #420 — named boundary: fill window is still open. + /// Exclusive upper bound: `now < deadline` is the last moment fills are valid. + /// This is the "EXCLUSIVE fill deadline" convention from issue #26. + #[inline] + fn fill_window_open(now: u64, deadline: u64) -> bool { + now < deadline + } + + /// Issue #420 — named boundary: slash is eligible. + /// A slash may only be executed once `now >= deadline + SLASH_GRACE_SECS`, + /// giving the solver a `SLASH_GRACE_SECS`-second buffer to absorb ledger + /// close-time drift (see `docs/420-ledger-time-skew-analysis.md`). + /// The fill window uses a DIFFERENT boundary (`fill_window_open`) so fills + /// remain valid right up to `deadline` while slashing requires `deadline + grace`. + #[inline] + fn slash_eligible(now: u64, deadline: u64) -> bool { + now >= deadline.saturating_add(SLASH_GRACE_SECS) + } + + /// Issue #420 — named boundary: dispute window is still open. + /// Exclusive: `now < dispute_deadline`. + #[inline] + fn dispute_window_open(now: u64, dispute_deadline: u64) -> bool { + now < dispute_deadline + } + + /// Issue #420 — named boundary: arbiter window has elapsed (timeout). + /// Inclusive: timeout is reachable at `now == raised_at + ARBITER_WINDOW`. + #[inline] + fn arbiter_timeout_reached(now: u64, raised_at: u64) -> bool { + now >= raised_at.saturating_add(ARBITER_WINDOW) + } + /// 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. @@ -4833,6 +4821,49 @@ impl IntentSettlement { proportional.max(1).min(cap) } + /// Issue #409 — balance-delta pull helper. + /// + /// Transfers `amount` of `token` from `from` into the contract and returns the + /// **actual** amount received, measured as `balance_after - balance_before`. + /// + /// Fee-on-transfer tokens silently reduce the amount that lands in the + /// contract; rebasing tokens can change balances between calls. By recording + /// the delta rather than the requested `amount`, every inbound accounting + /// entry reflects reality. + /// + /// **Decision per path:** + /// * Bond registration / top-up (`register_solver_inner`): fee-on-transfer bonds + /// would mean the solver is recorded as holding more bond than the contract + /// actually has — accept the delta and record only what arrived. + /// Rebasing-up bonds would credit the solver extra without a transfer; they + /// are not supported as bond tokens (admin must not allowlist them). + /// * `begin_fill` escrow: same reasoning — record only the escrowed amount that + /// actually landed, which is what the user will receive on `release_fill`. + /// * Dispute bond: same — record only what arrived as the dispute collateral. + /// + /// **Fills paid solver→user directly** (`fill_intent`) are exempt: the + /// contract is not the receiver, so there is no balance to measure. The user + /// receives whatever the token delivers; that is outside this contract's + /// accounting responsibility. + /// + /// **CEI note:** callers must write all state changes that depend on the + /// returned `received` value *after* this call returns, not before. The helper + /// itself is read-balance → transfer → read-balance, which is the minimal + /// reentrancy surface; the two balance reads bracket a single external call. + fn pull_exact( + env: &Env, + token: &Address, + from: &Address, + amount: i128, + ) -> i128 { + let client = token::Client::new(env, token); + let contract = env.current_contract_address(); + let before: i128 = client.balance(&contract); + client.transfer(from, &contract, &amount); + let after: i128 = client.balance(&contract); + after - before + } + /// Issue #188 — the address allowed to call `resolve_dispute`: the /// `DataKey::Arbiter` entry if set, otherwise the `Admin` (the design /// doc's v1 default). @@ -4968,22 +4999,4 @@ impl IntentSettlement { preimage.extend_from_array(&nonce.to_be_bytes()); env.crypto().sha256(&preimage).into() } - - fn validate_proof(env: &Env, intent_id: &BytesN<32>, intent: &IntentRecord) { - let _registry_addr = env - .storage() - .instance() - .get::<_, Address>(&DataKey::ProofRegistry) - .unwrap_or_else(|| panic_with_error!(env, Error::ProofRegistryNotSet)); - - // In production, this would call: - // - registry.has_proof(intent_id) to check existence - // - registry.get_proof(intent_id) to retrieve the proof record - // - Validate proof.src_chain matches intent.src_chain - // - Validate proof.src_amount >= intent.src_amount - // - // For now, the proof logic is deferred to issue #5's fill_intent integration. - // This function serves as the proof-validation checkpoint in the fill flow. - // Tests will inject mock proofs and verify this gate works correctly. - } } diff --git a/proof_registry/src/lib.rs b/proof_registry/src/lib.rs index 6f3a458..6cb513d 100644 --- a/proof_registry/src/lib.rs +++ b/proof_registry/src/lib.rs @@ -114,6 +114,12 @@ pub enum ProofKey { /// sequence)` pair has already been processed, regardless of which /// `intent_id` it carried. SeenVaa(u32, u64), + /// Issue #408 — the address allowed to call `configure_chain` and + /// `remove_chain` on behalf of `intent_settlement`'s atomic onboarding + /// flow. Absent until `set_configurator` is called by the admin. + Configurator, + /// Issue #408 — the Axelar gateway contract address (set at init). + AxelarGateway, } // ─── Data Types ─────────────────────────────────────────────────────────────── @@ -170,6 +176,14 @@ pub enum Error { /// The application payload's self-declared `src_chain_id` does not match /// the `emitter_chain` the Guardians signed over. EmitterChainMismatch = 9, + /// Issue #408 — a chain ID that would overflow u16 was supplied. + ChainIdOutOfRange = 10, + /// Issue #408 — `configure_chain` or `remove_chain` called before + /// `set_configurator` has been set. + ConfiguratorNotSet = 11, + /// Issue #408 — `get_fresh_proof` found a proof that has exceeded + /// `PROOF_VALIDITY_WINDOW` since it was received. + ProofStale = 12, } // ─── Contract ───────────────────────────────────────────────────────────────── @@ -251,6 +265,67 @@ impl ProofRegistry { env.storage().instance().get(&ProofKey::WormholeCore) } + // ── Configurator role (issue #408) ──────────────────────────────────────── + // + // `intent_settlement` calls `configure_chain` / `remove_chain` as part of + // its atomic `execute_chain_onboarding` / `execute_chain_offboarding` flow + // so that both contracts are updated within the same transaction. The + // admin grants settlement this narrow role via `set_configurator`. + + /// Admin-only: set the address (typically `intent_settlement`) that is + /// allowed to call `configure_chain` and `remove_chain`. Call this once + /// after both contracts are deployed. + pub fn set_configurator(env: Env, configurator: Address) { + Self::require_admin(&env); + env.storage() + .instance() + .set(&ProofKey::Configurator, &configurator); + Self::bump_instance_ttl(&env); + env.events().publish( + (Symbol::new(&env, "configurator_set"),), + configurator, + ); + } + + /// Return the current configurator address, or `None` if unset. + pub fn get_configurator(env: Env) -> Option
{ + env.storage().instance().get(&ProofKey::Configurator) + } + + /// Configurator-only: register `emitter` as the authorized Wormhole + /// emitter for `chain_id`. Called by `intent_settlement` during + /// `execute_chain_onboarding` to atomically configure both contracts. + /// Emits `emitter_authorized`. + pub fn configure_chain(env: Env, chain_id: u32, emitter: BytesN<32>) { + Self::require_configurator(&env); + if chain_id > u16::MAX as u32 { + panic_with_error!(&env, Error::ChainIdOutOfRange); + } + env.storage() + .instance() + .set(&ProofKey::AuthorizedEmitter(chain_id), &emitter); + Self::bump_instance_ttl(&env); + env.events().publish( + (Symbol::new(&env, "emitter_authorized"),), + (chain_id, emitter), + ); + } + + /// Configurator-only: remove the authorized emitter for `chain_id`. + /// Called by `intent_settlement` during `execute_chain_offboarding`. + /// In-flight proofs already stored under `ProofKey::Proof` are unaffected + /// — existing `ProofRecord` entries remain readable so any proof received + /// before offboarding can still gate a `fill_intent` call that was already + /// in flight. Emits `emitter_removed`. + pub fn remove_chain(env: Env, chain_id: u32) { + Self::require_configurator(&env); + env.storage() + .instance() + .remove(&ProofKey::AuthorizedEmitter(chain_id)); + env.events() + .publish((Symbol::new(&env, "emitter_removed"),), chain_id); + } + // ── Message Receipt ─────────────────────────────────────────────────────── /// Receive a Wormhole VAA, verify it, and store the decoded proof. @@ -535,6 +610,19 @@ impl ProofRegistry { admin.require_auth(); } + /// Require that the caller is the registered configurator (typically + /// `intent_settlement`). Panics with `ConfiguratorNotSet` when no + /// configurator has been registered yet, or with `Unauthorized` when the + /// caller does not match the stored address. + fn require_configurator(env: &Env) { + let configurator: Address = env + .storage() + .instance() + .get(&ProofKey::Configurator) + .unwrap_or_else(|| panic_with_error!(env, Error::ConfiguratorNotSet)); + configurator.require_auth(); + } + /// Read one byte of `bytes`, failing closed with `InvalidPayload` if the /// index is out of range (callers have already length-checked, so this is /// defence-in-depth rather than an expected path).