Skip to content

feat(resolver): fix #29 #32 #31 #30 versioned commitments, expiry, weighted attestations and disputes - #157

Closed
AderonkeAfolabi wants to merge 1 commit into
OracleDesk:mainfrom
AderonkeAfolabi:feat/resolver-29-32-31-30
Closed

AderonkeAfolabi wants to merge 1 commit into
OracleDesk:mainfrom
AderonkeAfolabi:feat/resolver-29-32-31-30

Conversation

@AderonkeAfolabi

Copy link
Copy Markdown
Contributor

Closes #29
Closes #32
Closes #31
Closes #30

This PR hardens and extends the resolver contract in four connected ways:

All four live in contracts/resolver. They share one reveal pipeline and one
committee abstraction, so each new resolution mode works with the V2
commitment envelope and the expiry fallback without extra code.

#29 — Versioned, salted, domain-separated commitments

Problem. resolution_hash = sha256(XDR(ResolutionSpec)) had three gaps:

  • Nothing pinned the encoding, so a field rename or an SDK change would
    silently strand every unrevealed market.
  • Low-entropy specs could be brute-forced before reveal, exposing the
    committee.
  • The hash bound neither the network nor the market-core instance, so
    commitments could be replayed.

Fix and new flow.

  • Golden vectors pin six legacy hashes: both OracleAsset variants, all
    four PriceDirections, and single and multi-signer specs. They are asserted
    in the resolver and again in spec-hash, plus a test proving structs encode
    by sorted field name. The vectors were also re-derived from hand-built XDR
    outside Soroban.

  • ResolutionSpecV2 { version, network_id, market_core, salt, rule }. The
    commitment is sha256("oracledesk:resolution-spec" || XDR(envelope)).

  • register_spec_v2 rejects:

    • a wrong version or an all-zero salt (InvalidConfig);
    • a wrong network or market-core (DomainMismatch = 21).

    It then runs the same reveal pipeline as the legacy entrypoints.

  • Legacy entrypoints are unchanged in behaviour and error precedence. The
    existing tests pass unmodified.

  • Disjoint hash spaces. Every legacy preimage starts with the ScVal::Vec
    tag 00 00 00 10, and every V2 preimage starts with ASCII orac, so the
    two cannot collide short of a SHA-256 collision. A property test checks this.

  • scripts/spec-hash is split into a tested lib.rs and a thin main.rs.
    It adds --v2 --salt --network-passphrase --market-core, gen-salt (OS
    CSPRNG) and verify (exit 1 on mismatch). It hashes through the resolver's
    own legacy_spec_hash / spec_v2_hash, so it cannot drift from the
    contract.

  • scripts/seed-market.sh generates a salt and writes it to
    deployments/specs/<resolution_hash>.json (gitignored). It re-derives the
    commitment from the stored file, and only then creates the market and
    reveals with register_spec_v2. If the salt cannot be persisted, no market
    is created.

  • New ADR 0004 documents the threat model, salt custody, what is
    deliberately not bound (market_id, the resolver address) and the
    migration policy. ADR 0001 links to it.

Why it matters. Encoding drift is now caught by CI. The rule stays hidden
until reveal. A commitment cannot be replayed on another network or
deployment. Existing testnet commitments keep resolving.

#32 — Resolution deadline and permissionless expiry

Problem. A market whose spec is never revealed, whose feed is permanently
stale, or whose signers go silent stayed Open forever. Collateral was locked
until the single guardian key chose to act.

Fix and new flow.

  • RESOLUTION_GRACE_SECS (30 days) is a compile-time constant, so no
    admin can shorten it.

  • resolution_deadline(market_id) is a view. It derives the deadline from
    market-core's close_time alone, so it applies to unrevealed and
    pre-existing markets with no migration.

  • expire_market(market_id) needs no auth and is liveness-first. After the
    deadline:

    • a price market whose feed is valid now is resolved, not voided;
    • a market with a pending signer proposal returns ResolutionAvailable = 20
      so finalize_signers can settle it;
    • otherwise the market is voided through market-core, and MarketExpired
      is emitted with ExpiryMode::{Unrevealed, Price, Signers}.

    Before the deadline the call returns DeadlineNotReached = 19.

  • Broken feeds. A feed contract that traps, or an address with no
    contract, is treated as "price unavailable", so a broken oracle address
    cannot block expiry forever.

  • ResolutionState::Expired is terminal. All former == Finalized checks
    use is_terminal(), and registration now refuses terminal markets.

  • Refactor. resolve_price now uses a shared try_price_outcome and
    settle_price.

  • Docs. The griefing analysis is in ADR 0001 ("Liveness fallback"), and
    docs/architecture.md and docs/STATUS.md are updated.

Why it matters. Holders can always reach a settlement (resolution, or the
void payout), with no dependency on one admin key, and expiry can never void
a market that can still resolve correctly.

#31 — Aggregated off-chain Ed25519 attestations with weighted thresholds

Problem. Attested resolution needed every signer to send their own funded
transaction, and every signer had equal weight.

Fix and new flow.

  • New spec ResolutionSpec::WeightedKeys(WeightedKeyConfig) with raw
    Ed25519 keys and weights, revealed with register_weighted_key_spec.
    Validation requires 1–32 unique keys, positive weights, an overflow-checked
    total and 0 < threshold ≤ total.
  • Signing. Members sign a domain-separated message off-chain. It binds the
    network id, resolver address, market id, round, outcome and the market's
    resolution_hash; the format is in ADR 0001.
  • submit_signatures(market_id, outcome, round, sigs) is permissionless.
    It verifies each signature with ed25519_verify (strict, host-side). It
    skips non-members and keys already counted this round for either outcome,
    and accumulates weight per (market, round, outcome). A proposal is written
    only when the threshold is first reached, so proposed_at can never be
    pushed out.
  • Rounds. cancel_proposal moves a weighted market to a new round, so
    previously collected signatures cannot be replayed. current_round exposes
    the round to signers.
  • MAX_SIGS_PER_CALL = 32. 32 signatures cost 23.1M instructions /
    2.9 MB with the compiled wasm, under 6% and 7% of the 400M / 40 MiB
    transaction limits. Larger committees can also accumulate weight across
    calls.
  • agents/core/attestation.ts adds buildAttestationMessage,
    signAttestation and verifyAttestation. Golden vectors are shared byte for
    byte with the Rust test.
  • spec-hash weighted-keys is added.

Why it matters. One transaction, paid by anyone, can resolve a market for a
committee of keys that need no funded Stellar accounts. Weighted committees
are expressible, and replay across networks, deployments, markets and
cancelled rounds is impossible.

#30 — Bonded optimistic dispute mechanism

Problem. The "dispute window" let no one but the guardian dispute, and
Error::DisputeActive was defined but unused.

Fix and new flow.

  • New spec ResolutionSpec::SignersDisputable(DisputableSignerConfig)
    adds a bond, an escalation authority and an escalation timeout. The
    authority is Escalation::Guardian, or Escalation::Committee(members, k)
    disjoint from the signers. Revealed with register_disputable_spec.
  • dispute(market_id, disputer, alternative). During the window, anyone
    escrows the bond in the resolver, with the token snapshotted at dispute
    time. This sets Disputed and emits Disputed. While disputed,
    finalize_signers, attest, cancel_proposal and expire_market return
    DisputeActive.
  • Settlement. By the guardian through settle_dispute, or by k
    committee votes through escalation_attest:
    • upholding the proposal resolves to it and forfeits the bond to
      market-core's treasury;
    • any other verdict resolves to that outcome (or voids for Invalid) and
      refunds the bond.
  • Timeout. expire_dispute is permissionless after escalation_timeout.
    It voids the market and refunds the bond. guardian_void_market during a
    dispute also refunds.
  • Pull payouts. Bonds leave escrow only through the permissionless
    claim_dispute_bond, so a payee that cannot receive never blocks
    settlement. The tests assert after every step that the resolver's balance
    equals the bonds it still holds.
  • No reward field. A winning disputer recovers their bond and nothing
    more, as the architecture doc states.

Why it matters. Anyone holding evidence can freeze a wrong proposal and
force a ruling. The outcome no longer depends on the guardian noticing in
time, and nothing can stay stuck, because every dispute has a timeout.

Compatibility

Changed

Testing

All commands were run from oracledesk-stellar/, on this branch and on a
fresh clone with the same patches applied:

cargo fmt --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo test --manifest-path scripts/spec-hash/Cargo.toml
(cd scripts/spec-hash && cargo clippy --all-targets -- -D warnings)
bash -n scripts/seed-market.sh
stellar contract build --package resolver
stellar contract bindings typescript --wasm target/wasm32v1-none/release/resolver.wasm --output-dir /tmp/bind-resolver --overwrite
cp /tmp/bind-resolver/src/index.ts packages/bindings/resolver/src/index.ts
for pkg in market-core resolver treasury reasoning-registry; do (cd packages/bindings/$pkg && npm ci && npm run build); done
(cd agents && npm ci && npm test && npx tsc --noEmit)

Results:

Check Result
cargo fmt --check clean
cargo clippy --workspace --all-targets -- -D warnings clean
cargo test --workspace 103 passed (market-core 17, reasoning-registry 6, resolver 69, treasury 11)
spec-hash clippy + tests clean, 9 passed
bash -n scripts/seed-market.sh clean
stellar contract build --package resolver builds, 21 exported functions
Bindings npm run build (all 4 packages) pass
agents: npm test / npx tsc --noEmit 31 passed (24 existing + 7 new) / clean

Additional verification:

  • Golden hashes (legacy, V2, weighted-keys, signers-disputable) and the attestation message and Ed25519 signature were re-derived independently in Python from hand-built XDR. All match byte for byte.
  • scripts/seed-market.sh ran end to end against a stub stellar CLI using deployments/testnet.json. The spec file existed before agent_create_market. With the spec directory unwritable, the script exited 1 before any stellar call.
  • Budget: 32 signatures measured at 23.1M instructions / 2.9 MB with the compiled wasm. Limits were read live with stellar network settings (testnet and mainnet: 400M instructions, 40 MiB).
  • Rebuilding market-core from unchanged source gives the same wasm hash, so the vendored market_core.wasm files remain valid.

Not run: the manual testnet flow from #29's test plan (create → register_spec_v2 → attest → finalize). It needs the new resolver deployed with funded identities.

Scope Notes

  • Contracts: only contracts/resolver changed. market-core, treasury and reasoning-registry source, and both vendored market_core.wasm files, are untouched.
  • No frontend changes. x402/ is untouched. In agents/ only a library helper (core/attestation.ts) was added; no CLI or strategy wiring.
  • Bindings: only packages/bindings/resolver was regenerated; the other contracts' interfaces did not change.
  • No deployment and no change to deployments/testnet.json. Existing testnet commitments keep resolving through the legacy entrypoints.
  • CI (.github/workflows/ci.yml) is unchanged. make test runs the spec-hash tests, but CI only runs cargo test --workspace; a spec-hash CI step is left to maintainers.
  • Add a committed resolution deadline with permissionless liveness-fallback voiding #32 asked for a doc-comment edit on market-core's void_market. It was deliberately not made: doc comments are embedded in the wasm, so the edit would change the vendored market_core.wasm that the issue requires to stay identical.
  • Open the PR against develop, not main. The repo's enforce-develop-base workflow closes contributor PRs into main. develop does not exist on the remote yet, so a maintainer may need to create it first.
  • Not included in the commit: implementation.md and this PR_NOTES.md. There are no CODEBASE_INDEX.md, .cursor/ or test snapshot files in the change; test_snapshots/ is gitignored.

Push Command

git push -u origin feat/resolver-29-32-31-30

…ighted attestations and disputes

- #29: salted, domain-separated ResolutionSpecV2 with register_spec_v2, golden vectors, spec-hash --v2/gen-salt/verify, seed-market reveals via V2, ADR 0004
- #32: permissionless expire_market after close_time + 30 days, ResolutionState::Expired, MarketExpired event
- #31: WeightedKeys spec with off-chain Ed25519 submit_signatures, rounds, budget-capped batches, agents/core/attestation.ts
- #30: SignersDisputable spec with bonded dispute, guardian/committee escalation, timeout and pull-based bond claims

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@drips-wave

drips-wave Bot commented Oct 16, 2026

Copy link
Copy Markdown

@AderonkeAfolabi 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

@github-actions

Copy link
Copy Markdown

👋 Thanks for the contribution! This repository only accepts contributor pull requests into the develop branch. PRs raised against main cannot be merged, so this one has been closed automatically.

Please re-open your PR with base: develop (rebase your branch onto develop first if needed). See the note at the bottom of the issue you're working on.

@github-actions github-actions Bot closed this Oct 16, 2026
@AderonkeAfolabi
AderonkeAfolabi deleted the feat/resolver-29-32-31-30 branch October 16, 2026 19:16
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