Skip to content

Proof registry VAA verification (#189), solver_registry (#186), proof-gated fills (#190), init test (#148) - #310

Open
driftsorbit wants to merge 4 commits into
stellar-vortex-protocol:mainfrom
driftsorbit:feat/proof-registry-vaa-solver-registry-proof-gated-fills
Open

Proof registry VAA verification (#189), solver_registry (#186), proof-gated fills (#190), init test (#148)#310
driftsorbit wants to merge 4 commits into
stellar-vortex-protocol:mainfrom
driftsorbit:feat/proof-registry-vaa-solver-registry-proof-gated-fills

Conversation

@driftsorbit

Copy link
Copy Markdown

Summary

Four issues, one branch:

Issue Area State
#189 proof_registry ✅ implemented, builds (native + wasm), 20 tests pass
#186 new solver_registry crate ✅ implemented, builds (native + wasm), 23 tests pass
#190 intent_settlement (fill_intent) ✅ implemented as reviewable diffs — see the build-status note below
#148 intent_settlement test ✅ test added — see the build-status note below

Closes #189
Closes #186
Closes #190
Closes #148


⚠️ Build-status note (please read before reviewing)

intent_settlement does not compile on main and hasn't for many commits — ~67 errors from botched merge-conflict resolutions in earlier PRs (DataKey / Error variants and constants that are used but no longer declared, plus two duplicate #[contracterror] discriminants). That breakage is pre-existing and out of scope for these four issues.

Consequences for this PR:

Repairing intent_settlement is effectively its own task and would roughly double this PR; happy to do it as a follow-up (or first) if maintainers prefer.


#189 — Real Wormhole VAA verification + emitter authorization (proof_registry)

receive_message previously skipped Guardian signature verification entirely and never checked the decoded emitter against AuthorizedEmitter — it accepted a payload from any caller claiming any chain ID. Now:

  • Signature verification is delegated to the Wormhole Core contract (address at ProofKey::WormholeCore) through a cross-contract call. A WormholeCore trait + generated WormholeCoreClient defines that boundary; a malformed VAA or an invalid / below-quorum signature set traps there and reverts — an unverified payload is never parsed.
  • Emitter allowlist is enforced against the VAA envelope (emitter_chain / emitter_address the Guardians signed over), not just the application payload → Error::EmitterNotAuthorized (previously unreachable).
  • vaa_sequence comes from the real VAA header, and a second replay axis keyed on (emitter_chain, sequence) (ProofKey::SeenVaa) catches a VAA replayed for a different intent_idError::VaaAlreadyProcessed.
  • Payload src_chain_id is cross-checked against the signed emitter_chain (Error::EmitterChainMismatch); truncated/oversized payloads fail closed with Error::InvalidPayload instead of panicking on an index.
  • The #[cfg(feature = "testutils")] mock_set_proof / mock_remove_proof back-door is untouched and cannot affect the verification path (gating it further is the separate security issue).
  • Module doc comment rewritten to describe the production behaviour.

Test output (cd proof_registry && cargo test --features testutils):

running 20 tests
test test::get_authorized_emitter_returns_none_if_unset ... ok
test test::get_proof_returns_none_for_unknown_intent ... ok
test test::has_proof_returns_false_for_unknown_intent ... ok
test test::initialize_records_wormhole_core ... ok
test test::initialize_succeeds_once ... ok
test test::mock_remove_proof_clears_stored_record ... ok
test test::mock_set_proof_injects_controllable_record ... ok
test test::mock_set_proof_rejects_duplicate ... ok
test test::receive_message_decodes_large_src_amount ... ok
test test::receive_message_rejects_chain_mismatch ... ok
test test::receive_message_rejects_duplicate_intent_id ... ok
test test::receive_message_rejects_tampered_payload ... ok
test test::receive_message_rejects_unauthorized_emitter ... ok
test test::receive_message_rejects_when_no_emitter_configured_for_chain ... ok
test test::receive_message_rejects_wrong_payload_length ... ok
test test::receive_message_rejects_replayed_sequence ... ok
test test::receive_message_stores_verified_proof ... ok
test test::receive_message_traps_on_truncated_vaa ... ok
test test::remove_authorized_emitter_clears_entry ... ok
test test::set_and_get_authorized_emitter ... ok

test result: ok. 20 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

The four fixtures the issue asks for: receive_message_stores_verified_proof (valid VAA), receive_message_rejects_unauthorized_emitter, receive_message_rejects_tampered_payload (Core boundary traps), receive_message_rejects_replayed_sequence. Only the Wormhole Core call is mocked; everything downstream is real logic under test. Wasm: 24,438 bytes.


#186solver_registry contract with tiered staking

New solver_registry/ crate (Cargo.toml, src/lib.rs, src/test.rs) mirroring intent_settlement / proof_registry.

  • Option A of docs/solver-registry-design.md §4 — solver_registry is the canonical store for SolverRecord; it derives a reputation score and a bond/score-gated tier (0 Unranked … 4 Platinum).
  • Reputation formula ported byte-for-byte from intent_settlement::compute_reputation_score (kept as a free score_of fn + a public compute_reputation_score view). score_test_vector pins a shared input→output vector, also tabulated in the new interface doc, so the two implementations can't drift.
  • 5-row tier table matches the design doc exactly. min_bond / min_score_bps are admin-tunable via set_tier_threshold within documented bounds (tier 1..=4, min_bond ≤ 1,000,000 USDC, min_score_bps ≤ 9,999, strictly monotonic). fill_window_bonus_pct / slash_bps are fixed; fee_rebate_bps is a reserved slot (design §8) returning 0.
  • Stable read interface for a later settlement integration: get_tier, tier_for (pure), get_tier_table, get_fill_window_bonus_pct, get_slash_bps, get_fee_rebate_bps, get_reputation_score. Rewiring accept_intent / slash_solver to consume the perks is deliberately out of scope (separate follow-up).
  • Settlement write path (record_fill / record_failure / slash) gated to the admin or a configurable writer address.
  • Storage conventions match intent_settlement: #[contracttype] DataKey enum, explicit TTL bumping (docs/ttl-constants-rationale.md), #[contracterror] with unique sequential discriminants.
  • ABI documented in docs/solver-registry-interface.md.
  • CI wiring for the new crate is intentionally left to the separate "bring proof_registry-style crates into CI" issue referenced by [High] Implement the solver_registry contract with tiered staking #186.

Test output (cd solver_registry && cargo test):

running 23 tests
test test::deregister_returns_full_bond ... ok
test test::get_reputation_score_none_for_unknown ... ok
test test::initialize_seeds_the_design_doc_tier_table ... ok
test test::cannot_initialize_twice ... ok
test test::perk_getters_match_table ... ok
test test::record_failure_lowers_score ... ok
test test::register_locks_bond_and_counts_solver ... ok
test test::record_fill_updates_volume_and_score ... ok
test test::score_test_vector ... ok
test test::register_rejects_zero ... ok
test test::register_rejects_bond_below_floor ... ok
test test::register_twice_rejected ... ok
test test::set_tier_threshold_changes_gating ... ok
test test::set_tier_threshold_rejects_non_monotonic ... ok
test test::set_tier_threshold_rejects_out_of_bounds ... ok
test test::set_tier_threshold_rejects_tier_zero ... ok
test test::slash_uses_the_tier_specific_bps ... ok
test test::slash_demotes_tier_and_pays_fee_recipient ... ok
test test::stake_then_unstake_back_to_floor ... ok
test test::tier_for_hits_each_threshold_exactly ... ok
test test::zero_fills_pins_tier_to_unranked_regardless_of_bond ... ok
test test::unstake_more_than_bond_rejected ... ok
test test::writer_can_drive_write_path_and_strangers_cannot ... ok

test result: ok. 23 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Covers tier boundary transitions (score exactly on a threshold), tier demotion on slash, and the zero-fills edge case. Wasm: 34,449 bytes.


#190 — Proof-gated fill_intent with mismatch fallback (intent_settlement)

Implements docs/129-proof-mismatch-fallback.md against the existing (mock-capable) proof_registry.

  • fill_intent gains require_proof: bool (docs/124 §4.2). false — the value every existing call site passes — reads no registry and is byte-for-byte identical to today's behaviour, mirroring how DstAllowlistEnabled defaults off.

  • New admin entry point set_proof_registry(registry) + get_proof_registry, storing DataKey::ProofRegistry.

  • When require_proof is true, validate_proof() calls ProofRegistryClient::get_proof(intent_id) (cross-contract, via a new path dependency on vortex-proof-registry; proof_registry now also emits an rlib) and enforces the docs/129 mismatch table before any transfer or storage write:

    Condition Error docs/129
    no registry configured ProofRegistryNotSet §2.4
    no proof for the intent ProofNotFound §2.3
    proof.src_chain_id ≠ mapped intent.src_chain ProofChainMismatch §2.2
    proof.src_amount < intent.src_amount ProofAmountInsufficient §2.1

    Every rejection is a panic_with_error! before state changes, so the intent stays Accepted, the fill window keeps running, and slash_solver remains the backstop (docs/129 §3). The amount check is against the immutable intent.src_amount, so partial fills each simply re-assert the same condition — no cumulative accounting.

  • wormhole_chain_id() maps intent.src_chain → Wormhole chain ID per docs/129 §4; unknown names → SrcChainNotSupported.

  • New Error variants use a fresh discriminant block (30–34). docs/129 assigns the logical codes 24–27, but 24 is already taken (InvalidTokenInterface) and 22/23 already carry duplicate discriminants on main — each new variant's doc comment records the doc's intended code.

  • SECURITY.md threat model updated: the "solver self-reporting" trust assumption now describes the optional cryptographic gate and exactly when it does / does not apply.

Tests added to test.rs (proof_registry wired in as a dev-dependency, proofs injected via its mock_set_proof testutils entry point): require_proof = false unchanged; each of the four fallbacks; the matching-proof happy path; an unsupported src_chain; and that slash_solver stays reachable after a mismatch rejection. Existing fill_intent call sites updated to pass require_proof = false. These run once intent_settlement compiles again.


#148 — Test that initialize() rejects a second call

cannot_initialize_twice only re-passes the original arguments, so it can't distinguish "rejected" from "accepted and silently reset". Added initialize_rejects_second_call_and_keeps_original_config: calls initialize a second time with three brand-new distinct addresses, asserts AlreadyInitialized, and asserts get_admin / get_fee_recipient / get_bond_token are all unchanged. Runs once intent_settlement compiles again.


Toolchain notes

  • proof_registry and solver_registry each carry a committed Cargo.lock — without one the transitive soroban-sdk test deps don't resolve on current stable (ChaCha20Rng: CryptoRng).
  • proof_registry/Cargo.toml now lists crate-type = ["cdylib", "rlib"] so intent_settlement can link it for ProofRegistryClient; stellar contract build still emits the cdylib.

…orization

receive_message previously skipped Guardian signature verification and never
checked the decoded emitter against the AuthorizedEmitter allowlist, so it
accepted a payload from any caller claiming any chain ID. Implement the
production path:

- Delegate signature verification to the Wormhole Core contract (address at
  ProofKey::WormholeCore) via a cross-contract call. A WormholeCore trait +
  generated WormholeCoreClient defines that boundary; a malformed VAA or an
  invalid/below-quorum signature set traps there and reverts the call, so an
  unverified payload is never parsed.
- Enforce the emitter allowlist against the VAA *envelope* (emitter_chain /
  emitter_address the Guardians signed over), not just the application payload,
  with a distinct Error::EmitterNotAuthorized so off-chain monitors can tell
  the failure modes apart.
- Populate ProofRecord.vaa_sequence from the real VAA header and add a second
  replay axis keyed on (emitter_chain, sequence) via ProofKey::SeenVaa, so a
  VAA replayed for a different intent_id is still rejected
  (Error::VaaAlreadyProcessed).
- Cross-check that the payload's self-declared src_chain_id matches the signed
  emitter_chain (Error::EmitterChainMismatch); malformed/truncated payloads
  fail closed with Error::InvalidPayload rather than panicking on an index.
- Fix the pre-existing Bytes::get()/String::from_bytes() misuse in the decode
  path so the crate compiles on the pinned soroban-sdk.

The #[cfg(feature = "testutils")] mock_set_proof / mock_remove_proof back-door
is unchanged and remains a separate entry-point that cannot affect the
verification path. Cargo.lock is committed so the dev-dependency graph resolves
reproducibly.

Tests (cargo test --features testutils): 20 passing, covering a valid VAA, an
unauthorized emitter, a tampered payload (Core boundary traps), a replayed
sequence, duplicate intent_id, chain mismatch, wrong payload length, and a
truncated VAA. Only the Wormhole Core call is mocked; everything downstream is
the real logic under test. Builds to wasm (24,438 bytes).

Closes stellar-vortex-protocol#189
Implements the standalone solver_registry contract from
docs/solver-registry-design.md — the top unchecked item on the README roadmap,
which previously had only a design doc.

- Option A of the design (§4): solver_registry is the canonical store for a
  solver's bond and fill history (SolverRecord). It derives a reputation score
  and a bond/score-gated tier (0 Unranked … 4 Platinum).
- Reputation formula ported byte-for-byte from
  intent_settlement::compute_reputation_score, kept as a free `score_of` fn with
  a public `compute_reputation_score` view. score_test_vector pins a shared
  input->output vector (also tabulated in the interface doc) so the two
  implementations cannot drift.
- 5-row tier table matches the design doc exactly. min_bond / min_score_bps are
  admin-tunable via set_tier_threshold within documented bounds (tier 1..=4,
  min_bond <= 1,000,000 USDC, min_score_bps <= 9,999, strictly monotonic across
  tiers). fill_window_bonus_pct / slash_bps are fixed; fee_rebate_bps is a
  reserved slot (design §8) returning 0.
- Read interface for a later intent_settlement integration: get_tier,
  tier_for (pure), get_tier_table, get_fill_window_bonus_pct, get_slash_bps,
  get_fee_rebate_bps, get_reputation_score. Rewiring accept_intent /
  slash_solver to consume the perks is deliberately left as a follow-up.
- Settlement write path (record_fill / record_failure / slash) gated to the
  admin or a configurable writer address (explicit `caller`, mirroring
  intent_settlement::pause). slash takes bond * slash_bps(tier) / 10_000
  (min 1) to the fee recipient and returns (slash_amount, new_tier).
- Storage mirrors intent_settlement conventions: #[contracttype] DataKey enum,
  explicit TTL bumping (docs/ttl-constants-rationale.md), #[contracterror] with
  unique sequential discriminants.
- ABI documented in docs/solver-registry-interface.md.

CI wiring for the new crate is intentionally left to the separate
"bring proof_registry-style crates into CI" issue referenced by stellar-vortex-protocol#186.

Tests (cargo test): 23 passing — tier-table seeding, register/stake/unstake/
deregister, the write path and its auth, tier boundary transitions (score
exactly on a threshold via tier_for), tier demotion on slash, the zero-fills
edge case, and every threshold-tuning bound. Builds to wasm (34,449 bytes).

Closes stellar-vortex-protocol#186
Wires fill_intent to optionally cross-check a ProofRegistry record before
accepting a solver's claimed fill, implementing the fallback behaviour from
docs/129-proof-mismatch-fallback.md.

- fill_intent gains a `require_proof: bool` parameter (docs/124 §4.2). When
  false — the value every existing call site passes — no registry is read and
  behaviour is byte-for-byte identical to before, mirroring how
  DstAllowlistEnabled defaults off.
- New admin entry point set_proof_registry(registry) + get_proof_registry view,
  storing DataKey::ProofRegistry.
- When require_proof is true, validate_proof() calls
  ProofRegistryClient::get_proof(intent_id) (cross-contract, via a new path
  dependency on vortex-proof-registry; proof_registry now also builds an rlib)
  and enforces the docs/129 mismatch table before any token transfer or
  storage write:
    - no registry configured        -> ProofRegistryNotSet   (§2.4)
    - no proof for the intent        -> ProofNotFound          (§2.3)
    - proof.src_chain_id != mapped   -> ProofChainMismatch     (§2.2)
    - proof.src_amount < src_amount  -> ProofAmountInsufficient (§2.1)
  Every rejection is a panic_with_error! before state changes, so the intent
  stays Accepted and slash_solver remains the backstop (docs/129 §3). The
  amount check is against the immutable intent.src_amount, so partial fills
  each simply re-assert the same condition — no cumulative accounting.
- wormhole_chain_id() maps intent.src_chain to a Wormhole chain ID per
  docs/129 §4; unknown names -> SrcChainNotSupported.
- New Error variants use a fresh discriminant block (30-34); docs/129 assigns
  the logical codes 24-27 but 24 is already taken and 22/23 carry pre-existing
  duplicate discriminants from earlier merges — each variant documents the
  doc's intended code.
- SECURITY.md threat model updated: the "solver self-reporting" assumption now
  describes the optional cryptographic gate and when it does / does not apply.

Tests added to test.rs (proof_registry wired in as a dev-dependency, proofs
injected via its mock_set_proof testutils entry point): require_proof = false
unchanged, each of the four fallbacks, the matching-proof happy path, an
unsupported src_chain, and that slash_solver stays reachable after a mismatch
rejection. Existing fill_intent call sites updated to pass require_proof = false.

NOTE: intent_settlement does not currently compile on main — ~67 pre-existing
errors from botched merge-conflict resolutions in earlier PRs (dropped DataKey/
Error variants and constants, duplicate #[contracterror] discriminants). These
changes add zero new compile errors (verified: the error count is unchanged at
67) but cannot be exercised until that breakage is repaired, which is out of
scope here.

Closes stellar-vortex-protocol#190
…th different args

The existing cannot_initialize_twice only re-passes the original arguments, so
it cannot distinguish "rejected" from "accepted and silently reset". Add
initialize_rejects_second_call_and_keeps_original_config, which calls
initialize a second time with three brand-new distinct addresses, asserts it
fails with AlreadyInitialized, and asserts get_admin / get_fee_recipient /
get_bond_token are all unchanged from the first call.

(Runs once intent_settlement compiles again — see the stellar-vortex-protocol#190 commit note on the
pre-existing build breakage on main.)

Closes stellar-vortex-protocol#148
@drips-wave

drips-wave Bot commented Aug 28, 2026

Copy link
Copy Markdown

@driftsorbit Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant