Proof registry VAA verification (#189), solver_registry (#186), proof-gated fills (#190), init test (#148) - #310
Open
driftsorbit wants to merge 4 commits into
Conversation
…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
|
@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! 🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Four issues, one branch:
proof_registrysolver_registrycrateintent_settlement(fill_intent)intent_settlementtestCloses #189
Closes #186
Closes #190
Closes #148
intent_settlementdoes not compile onmainand hasn't for many commits — ~67 errors from botched merge-conflict resolutions in earlier PRs (DataKey/Errorvariants 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:
proof_registry#189 and [High] Implement thesolver_registrycontract with tiered staking #186 are fully green.proof_registrybuilds and tests on its own (aCargo.lockis now committed so its dependency graph resolves);solver_registryis a brand-new crate that builds and tests clean and compiles to wasm.fill_intentwith mismatch fallback #190 and Add a test confirming initialize() rejects a second call #148 touchintent_settlement, so they are delivered as clean, self-contained diffs. The#190change was verified to add zero new compile errors (the error count is unchanged at 67 before/after). They can be exercised as soon as the crate is repaired.Repairing
intent_settlementis 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_messagepreviously skipped Guardian signature verification entirely and never checked the decoded emitter againstAuthorizedEmitter— it accepted a payload from any caller claiming any chain ID. Now:ProofKey::WormholeCore) through a cross-contract call. AWormholeCoretrait + generatedWormholeCoreClientdefines that boundary; a malformed VAA or an invalid / below-quorum signature set traps there and reverts — an unverified payload is never parsed.emitter_chain/emitter_addressthe Guardians signed over), not just the application payload →Error::EmitterNotAuthorized(previously unreachable).vaa_sequencecomes from the real VAA header, and a second replay axis keyed on(emitter_chain, sequence)(ProofKey::SeenVaa) catches a VAA replayed for a differentintent_id→Error::VaaAlreadyProcessed.src_chain_idis cross-checked against the signedemitter_chain(Error::EmitterChainMismatch); truncated/oversized payloads fail closed withError::InvalidPayloadinstead of panicking on an index.#[cfg(feature = "testutils")]mock_set_proof/mock_remove_proofback-door is untouched and cannot affect the verification path (gating it further is the separate security issue).Test output (
cd proof_registry && cargo test --features testutils):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.#186 —
solver_registrycontract with tiered stakingNew
solver_registry/crate (Cargo.toml,src/lib.rs,src/test.rs) mirroringintent_settlement/proof_registry.docs/solver-registry-design.md§4 —solver_registryis the canonical store forSolverRecord; it derives a reputation score and a bond/score-gated tier (0 Unranked … 4 Platinum).intent_settlement::compute_reputation_score(kept as a freescore_offn + a publiccompute_reputation_scoreview).score_test_vectorpins a shared input→output vector, also tabulated in the new interface doc, so the two implementations can't drift.min_bond/min_score_bpsare admin-tunable viaset_tier_thresholdwithin documented bounds (tier1..=4,min_bond ≤ 1,000,000 USDC,min_score_bps ≤ 9,999, strictly monotonic).fill_window_bonus_pct/slash_bpsare fixed;fee_rebate_bpsis a reserved slot (design §8) returning 0.get_tier,tier_for(pure),get_tier_table,get_fill_window_bonus_pct,get_slash_bps,get_fee_rebate_bps,get_reputation_score. Rewiringaccept_intent/slash_solverto consume the perks is deliberately out of scope (separate follow-up).record_fill/record_failure/slash) gated to the admin or a configurable writer address.intent_settlement:#[contracttype]DataKeyenum, explicit TTL bumping (docs/ttl-constants-rationale.md),#[contracterror]with unique sequential discriminants.docs/solver-registry-interface.md.solver_registrycontract with tiered staking #186.Test output (
cd solver_registry && cargo test):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_intentwith mismatch fallback (intent_settlement)Implements
docs/129-proof-mismatch-fallback.mdagainst the existing (mock-capable)proof_registry.fill_intentgainsrequire_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 howDstAllowlistEnableddefaults off.New admin entry point
set_proof_registry(registry)+get_proof_registry, storingDataKey::ProofRegistry.When
require_proofis true,validate_proof()callsProofRegistryClient::get_proof(intent_id)(cross-contract, via a new path dependency onvortex-proof-registry;proof_registrynow also emits anrlib) and enforces the docs/129 mismatch table before any transfer or storage write:ProofRegistryNotSetProofNotFoundproof.src_chain_id≠ mappedintent.src_chainProofChainMismatchproof.src_amount < intent.src_amountProofAmountInsufficientEvery rejection is a
panic_with_error!before state changes, so the intent staysAccepted, the fill window keeps running, andslash_solverremains the backstop (docs/129 §3). The amount check is against the immutableintent.src_amount, so partial fills each simply re-assert the same condition — no cumulative accounting.wormhole_chain_id()mapsintent.src_chain→ Wormhole chain ID per docs/129 §4; unknown names →SrcChainNotSupported.New
Errorvariants 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 onmain— each new variant's doc comment records the doc's intended code.SECURITY.mdthreat 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 itsmock_set_prooftestutils entry point):require_proof = falseunchanged; each of the four fallbacks; the matching-proof happy path; an unsupportedsrc_chain; and thatslash_solverstays reachable after a mismatch rejection. Existingfill_intentcall sites updated to passrequire_proof = false. These run onceintent_settlementcompiles again.#148 — Test that
initialize()rejects a second callcannot_initialize_twiceonly re-passes the original arguments, so it can't distinguish "rejected" from "accepted and silently reset". Addedinitialize_rejects_second_call_and_keeps_original_config: callsinitializea second time with three brand-new distinct addresses, assertsAlreadyInitialized, and assertsget_admin/get_fee_recipient/get_bond_tokenare all unchanged. Runs onceintent_settlementcompiles again.Toolchain notes
proof_registryandsolver_registryeach carry a committedCargo.lock— without one the transitivesoroban-sdktest deps don't resolve on current stable (ChaCha20Rng: CryptoRng).proof_registry/Cargo.tomlnow listscrate-type = ["cdylib", "rlib"]sointent_settlementcan link it forProofRegistryClient;stellar contract buildstill emits the cdylib.