diff --git a/oracledesk-stellar/.gitignore b/oracledesk-stellar/.gitignore
index 272ee74..047ec8e 100644
--- a/oracledesk-stellar/.gitignore
+++ b/oracledesk-stellar/.gitignore
@@ -28,6 +28,10 @@ yarn-error.log*
.soroban/
.stellar/
+# V2 resolution specs written by scripts/seed-market.sh. The salt inside each
+# file is a secret until its market's spec is revealed, so never commit them.
+deployments/specs/
+
# Agent dry-run/local trace output (agents/core/trace.ts's LocalTraceStore)
.traces/
diff --git a/oracledesk-stellar/Cargo.lock b/oracledesk-stellar/Cargo.lock
index dbe03e8..7e6772c 100644
--- a/oracledesk-stellar/Cargo.lock
+++ b/oracledesk-stellar/Cargo.lock
@@ -1331,6 +1331,7 @@ dependencies = [
name = "resolver"
version = "0.1.0"
dependencies = [
+ "ed25519-dalek",
"soroban-sdk",
]
diff --git a/oracledesk-stellar/Makefile b/oracledesk-stellar/Makefile
index 5ee84d3..82b2522 100644
--- a/oracledesk-stellar/Makefile
+++ b/oracledesk-stellar/Makefile
@@ -26,6 +26,7 @@ build:
test: build
cargo test --workspace
+ cargo test --manifest-path scripts/spec-hash/Cargo.toml
python3 model/fpmm_model.py
cd x402 && npm test
cd agents && npm test
diff --git a/oracledesk-stellar/agents/core/attestation.test.ts b/oracledesk-stellar/agents/core/attestation.test.ts
new file mode 100644
index 0000000..b5bb606
--- /dev/null
+++ b/oracledesk-stellar/agents/core/attestation.test.ts
@@ -0,0 +1,80 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { Keypair } from "@stellar/stellar-sdk";
+import {
+ buildAttestationMessage,
+ signAttestation,
+ verifyAttestation,
+ type AttestationContext,
+} from "./attestation.js";
+
+// Shared byte-for-byte with `golden_attestation_message_and_signature` in
+// contracts/resolver/src/lib.rs.
+const SEED = Uint8Array.from({ length: 32 }, (_, i) => i);
+const CTX: AttestationContext = {
+ networkPassphrase: "Test SDF Network ; September 2015",
+ resolverId: "CC3KU5TWAATOBXLWIV4ZTXNEM44VIOY3ZTR7KTJTBOOBP7YIXMCDSWDJ",
+ marketId: 7n,
+ round: 3,
+ outcome: "No",
+ specHash: Uint8Array.from({ length: 32 }, (_, i) => 0xa0 + i),
+};
+const GOLDEN_MESSAGE =
+ "6f7261636c656465736b2d6174746573742d7631cee0302d59844d32bdca915c8203dd44b33fbb7edc19051ea37abedf28ecd4720000001200000001b6aa76760026e0dd76457999dda46739543b1bcce3f54d330b9c17ff08bb043900000000000000070000000301a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf";
+const GOLDEN_SIGNATURE =
+ "3f7bfc9882bbe894518d059b50326069523358f3da1d74b7ed90b8b395862367155ae6ac7934b3ae610e21c3dd595b7d99c2ab122037918917c96543c0cde002";
+const GOLDEN_PUBKEY = "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8";
+
+const hex = (bytes: Uint8Array) => Buffer.from(bytes).toString("hex");
+const keypair = () => Keypair.fromRawEd25519Seed(Buffer.from(SEED));
+
+test("buildAttestationMessage matches the golden vector", () => {
+ assert.equal(hex(buildAttestationMessage(CTX)), GOLDEN_MESSAGE);
+});
+
+test("signAttestation matches the golden signature (Ed25519 is deterministic)", () => {
+ const { pubkey, sig } = signAttestation(keypair(), CTX);
+ assert.equal(hex(pubkey), GOLDEN_PUBKEY);
+ assert.equal(hex(sig), GOLDEN_SIGNATURE);
+});
+
+test("verifyAttestation accepts the golden signature", () => {
+ const { pubkey, sig } = signAttestation(keypair(), CTX);
+ assert.equal(verifyAttestation(pubkey, sig, CTX), true);
+});
+
+test("verifyAttestation rejects any tampered context field", () => {
+ const { pubkey, sig } = signAttestation(keypair(), CTX);
+ const tampered: AttestationContext[] = [
+ { ...CTX, marketId: 8n },
+ { ...CTX, round: 4 },
+ { ...CTX, outcome: "Yes" },
+ { ...CTX, networkPassphrase: "Public Global Stellar Network ; September 2015" },
+ { ...CTX, resolverId: "CCILJXZCHKAIBTMJ7NSQE756OGLKTOYNSWNNZXWAYZZA4ECAUEC2CNVF" },
+ { ...CTX, specHash: Uint8Array.from({ length: 32 }, () => 1) },
+ ];
+ for (const ctx of tampered) {
+ assert.equal(verifyAttestation(pubkey, sig, ctx), false);
+ }
+});
+
+test("verifyAttestation rejects a malleated signature (S + group order)", () => {
+ const { pubkey, sig } = signAttestation(keypair(), CTX);
+ const order = (1n << 252n) + 27742317777372353535851937790883648493n;
+ const s = BigInt(`0x${Buffer.from(sig.slice(32)).reverse().toString("hex")}`) + order;
+ const malleated = Uint8Array.from(sig);
+ malleated.set(Buffer.from(s.toString(16).padStart(64, "0"), "hex").reverse(), 32);
+ assert.equal(verifyAttestation(pubkey, malleated, CTX), false);
+});
+
+test("verifyAttestation rejects malformed lengths", () => {
+ const { pubkey, sig } = signAttestation(keypair(), CTX);
+ assert.equal(verifyAttestation(pubkey.slice(1), sig, CTX), false);
+ assert.equal(verifyAttestation(pubkey, sig.slice(1), CTX), false);
+});
+
+test("buildAttestationMessage validates its inputs", () => {
+ assert.throws(() => buildAttestationMessage({ ...CTX, specHash: new Uint8Array(31) }));
+ assert.throws(() => buildAttestationMessage({ ...CTX, round: -1 }));
+ assert.throws(() => buildAttestationMessage({ ...CTX, round: 2 ** 32 }));
+});
diff --git a/oracledesk-stellar/agents/core/attestation.ts b/oracledesk-stellar/agents/core/attestation.ts
new file mode 100644
index 0000000..8ed20b1
--- /dev/null
+++ b/oracledesk-stellar/agents/core/attestation.ts
@@ -0,0 +1,100 @@
+/**
+ * Off-chain Ed25519 attestations for `resolver.submit_signatures`.
+ *
+ * A committee member signs the message built here with a plain Ed25519 key;
+ * anyone (typically the agent or a keeper) collects the signatures and submits
+ * them in one transaction. The byte layout must match
+ * `Resolver::attestation_message` in contracts/resolver/src/lib.rs exactly:
+ *
+ * "oracledesk-attest-v1" 20 ASCII bytes, domain tag
+ * || network_id 32 bytes, sha256(network passphrase)
+ * || ScVal-XDR(resolver contract address) 40 bytes for a C... address
+ * || market_id u64 big-endian
+ * || round u32 big-endian
+ * || outcome 1 byte: 0 = Yes, 1 = No
+ * || spec_hash 32 bytes, the market's resolution_hash
+ *
+ * The message is signed raw (never pre-hashed), matching the contract's
+ * `ed25519_verify`. The golden vectors in attestation.test.ts are shared with
+ * the Rust test `golden_attestation_message_and_signature`.
+ */
+
+import { createHash } from "node:crypto";
+import { Address, Keypair, StrKey } from "@stellar/stellar-sdk";
+
+const DOMAIN = Buffer.from("oracledesk-attest-v1", "ascii");
+/** Order of the Ed25519 base-point subgroup. */
+const GROUP_ORDER = (1n << 252n) + 27742317777372353535851937790883648493n;
+
+export type AttestedOutcome = "Yes" | "No";
+
+export interface AttestationContext {
+ networkPassphrase: string;
+ /** The resolver contract (`C...`) the signatures will be submitted to. */
+ resolverId: string;
+ marketId: bigint;
+ /** `resolver.current_round(marketId)` at signing time. */
+ round: number;
+ outcome: AttestedOutcome;
+ /** 32-byte `resolution_hash` of the market (from market-core `get_market`). */
+ specHash: Uint8Array;
+}
+
+export interface Attestation {
+ pubkey: Uint8Array;
+ sig: Uint8Array;
+}
+
+export function buildAttestationMessage(ctx: AttestationContext): Uint8Array {
+ if (ctx.specHash.length !== 32) {
+ throw new Error(`specHash must be 32 bytes, got ${ctx.specHash.length}`);
+ }
+ if (!Number.isInteger(ctx.round) || ctx.round < 0 || ctx.round > 0xffff_ffff) {
+ throw new Error(`round must be a u32, got ${ctx.round}`);
+ }
+ const marketId = Buffer.alloc(8);
+ marketId.writeBigUInt64BE(ctx.marketId);
+ const round = Buffer.alloc(4);
+ round.writeUInt32BE(ctx.round);
+
+ return Buffer.concat([
+ DOMAIN,
+ createHash("sha256").update(ctx.networkPassphrase).digest(),
+ Address.fromString(ctx.resolverId).toScVal().toXDR(),
+ marketId,
+ round,
+ Buffer.from([ctx.outcome === "Yes" ? 0 : 1]),
+ ctx.specHash,
+ ]);
+}
+
+export function signAttestation(keypair: Keypair, ctx: AttestationContext): Attestation {
+ const message = buildAttestationMessage(ctx);
+ return {
+ pubkey: new Uint8Array(keypair.rawPublicKey()),
+ sig: new Uint8Array(keypair.sign(Buffer.from(message))),
+ };
+}
+
+/**
+ * Pre-filter for a batch before `submit_signatures`: the contract aborts the
+ * whole batch on one invalid signature, so drop anything this rejects.
+ *
+ * Besides the Ed25519 check it rejects a non-canonical `S` (>= the group
+ * order), which the contract also rejects. It does not re-implement the
+ * contract's rejection of weak (small-order) public keys and small-order `R`,
+ * so it can pass a signature the contract refuses; that only makes the batch
+ * fail, it can never make an invalid signature count.
+ */
+export function verifyAttestation(
+ pubkey: Uint8Array,
+ sig: Uint8Array,
+ ctx: AttestationContext,
+): boolean {
+ if (pubkey.length !== 32 || sig.length !== 64) return false;
+ const s = BigInt(`0x${Buffer.from(sig.slice(32)).reverse().toString("hex")}`);
+ if (s >= GROUP_ORDER) return false;
+ const message = Buffer.from(buildAttestationMessage(ctx));
+ const key = Keypair.fromPublicKey(StrKey.encodeEd25519PublicKey(Buffer.from(pubkey)));
+ return key.verify(message, Buffer.from(sig));
+}
diff --git a/oracledesk-stellar/contracts/resolver/Cargo.toml b/oracledesk-stellar/contracts/resolver/Cargo.toml
index 9033202..206594f 100644
--- a/oracledesk-stellar/contracts/resolver/Cargo.toml
+++ b/oracledesk-stellar/contracts/resolver/Cargo.toml
@@ -15,4 +15,5 @@ doctest = false
soroban-sdk = { version = "27.0.6", features = ["experimental_spec_shaking_v2"] }
[dev-dependencies]
+ed25519-dalek = "2"
soroban-sdk = { version = "27.0.6", features = ["testutils", "experimental_spec_shaking_v2"] }
diff --git a/oracledesk-stellar/contracts/resolver/src/lib.rs b/oracledesk-stellar/contracts/resolver/src/lib.rs
index 211cfba..d05317b 100644
--- a/oracledesk-stellar/contracts/resolver/src/lib.rs
+++ b/oracledesk-stellar/contracts/resolver/src/lib.rs
@@ -1,8 +1,11 @@
#![no_std]
+#[cfg(test)]
+extern crate std;
+
use soroban_sdk::{
- contract, contracterror, contractevent, contractimpl, contracttype, xdr::ToXdr, Address,
- BytesN, Env, Symbol, Vec,
+ contract, contracterror, contractevent, contractimpl, contracttype, token, xdr::ToXdr, Address,
+ Bytes, BytesN, Env, Symbol, Vec,
};
// market-core is imported from its compiled wasm rather than as a normal
@@ -24,6 +27,23 @@ use market_core::{Client as MarketCoreClient, Outcome};
const BUMP_THRESHOLD: u32 = 30 * 17_280;
const BUMP_TO: u32 = 60 * 17_280;
+/// Most keys a weighted committee may hold. Bounds registration (O(n^2)
+/// duplicate check) and the per-call signature scan.
+pub const MAX_WEIGHTED_SIGNERS: u32 = 32;
+/// Most signatures one `submit_signatures` call accepts. A committee cannot
+/// contribute more than `MAX_WEIGHTED_SIGNERS` distinct valid signatures, so a
+/// larger batch is never useful; the recorded budget test
+/// (`max_sigs_per_call_budget`) shows this size stays far below the 400M
+/// instruction / 40 MiB transaction limits.
+pub const MAX_SIGS_PER_CALL: u32 = MAX_WEIGHTED_SIGNERS;
+/// Domain tag of the message weighted-key signers sign (20 bytes).
+const ATTEST_DOMAIN: &[u8] = b"oracledesk-attest-v1";
+
+/// Longest a market may stay unresolved after `close_time` before anyone may
+/// void it through [`Resolver::expire_market`]. A compile-time constant on
+/// purpose: an admin setter could shorten it and void markets early.
+pub const RESOLUTION_GRACE_SECS: u64 = 30 * 86_400;
+
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum OracleAsset {
@@ -53,6 +73,26 @@ pub enum ResolutionState {
Unconfigured,
SignersPending,
Finalized,
+ /// Voided by [`Resolver::expire_market`] after the resolution deadline.
+ Expired,
+ /// A bonded dispute is open; finalization is frozen until it settles.
+ Disputed,
+}
+
+impl ResolutionState {
+ /// No further reveal, attestation, resolution or void is possible.
+ fn is_terminal(self) -> bool {
+ matches!(self, Self::Finalized | Self::Expired)
+ }
+}
+
+/// Which resolution path a market was on when it expired.
+#[contracttype]
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum ExpiryMode {
+ Unrevealed,
+ Price,
+ Signers,
}
#[contracttype]
@@ -80,6 +120,77 @@ pub struct SignerProposal {
pub proposed_at: u64,
}
+/// Who rules on a dispute.
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum Escalation {
+ /// The resolver admin, through `settle_dispute`.
+ Guardian,
+ /// `k` of these members, through `escalation_attest`. Must be disjoint
+ /// from the proposing signers.
+ Committee(Vec
, u32),
+}
+
+/// Attested resolution whose proposal can be challenged with a bond during
+/// the dispute window. Hashed as `ResolutionSpec::SignersDisputable`, a
+/// separate variant, so existing `Signers` commitments are unaffected.
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct DisputableSignerConfig {
+ pub signers: Vec,
+ pub threshold: u32,
+ pub dispute_window: u64,
+ /// Collateral escrowed by `dispute`; must be positive.
+ pub dispute_bond: i128,
+ pub escalation: Escalation,
+ /// Seconds after a dispute opens before anyone may void it unresolved.
+ pub escalation_timeout: u64,
+}
+
+/// The escalation authority's ruling. `Invalid` voids the market.
+#[contracttype]
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum Verdict {
+ Yes,
+ No,
+ Invalid,
+}
+
+/// An escrowed bond. `payee` is `None` while the dispute is open and the
+/// recipient once it is settled; the bond leaves escrow only through the
+/// permissionless `claim_dispute_bond` (a pull, so a payee that cannot receive
+/// the token can never block settlement).
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Dispute {
+ pub disputer: Address,
+ pub bond: i128,
+ /// Collateral token snapshotted at dispute time so the refund uses the
+ /// same token even if market-core's collateral ever changes.
+ pub token: Address,
+ pub opened_at: u64,
+ pub payee: Option,
+}
+
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct WeightedKey {
+ /// Raw Ed25519 public key. For a `G...` account address this is
+ /// `StrKey.decodeEd25519PublicKey(address)`; `C...` addresses have none.
+ pub pubkey: BytesN<32>,
+ pub weight: u32,
+}
+
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct WeightedKeyConfig {
+ pub signers: Vec,
+ /// Total weight that must sign one outcome. Choose it above half the total
+ /// weight so two outcomes can never both reach it.
+ pub weight_threshold: u32,
+ pub dispute_window: u64,
+}
+
/// The full resolution rule a market's `resolution_hash` commits to. Hashing
/// this (rather than the bare `PriceConfig`/`SignerConfig`) keeps the two
/// modes in disjoint hash spaces even if their field encodings could
@@ -89,6 +200,41 @@ pub struct SignerProposal {
pub enum ResolutionSpec {
Price(PriceConfig),
Signers(SignerConfig),
+ WeightedKeys(WeightedKeyConfig),
+ SignersDisputable(DisputableSignerConfig),
+}
+
+/// Domain tag prepended to a [`ResolutionSpecV2`] before hashing. Every legacy
+/// preimage is the XDR of an `ScVal` and so starts with the bytes `00 00 00 10`
+/// (`ScValType::Vec`), never with ASCII, which keeps the two hash spaces
+/// disjoint. See `docs/adr/0004-spec-versioning.md`.
+pub const SPEC_DOMAIN: &str = "oracledesk:resolution-spec";
+const SPEC_V2_VERSION: u32 = 2;
+
+/// Versioned commitment envelope. Unlike a bare [`ResolutionSpec`] it binds the
+/// network and the market-core instance, and carries a creator-chosen `salt`
+/// that makes a pre-reveal brute-force of the (low-entropy) rule infeasible.
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ResolutionSpecV2 {
+ pub version: u32,
+ /// `sha256(network passphrase)`, i.e. `env.ledger().network_id()`.
+ pub network_id: BytesN<32>,
+ pub market_core: Address,
+ pub salt: BytesN<32>,
+ pub rule: ResolutionSpec,
+}
+
+/// Hash a legacy commitment is checked against: `sha256(XDR(rule))`.
+pub fn legacy_spec_hash(env: &Env, rule: &ResolutionSpec) -> BytesN<32> {
+ env.crypto().sha256(&rule.clone().to_xdr(env)).into()
+}
+
+/// Hash a V2 commitment is checked against: `sha256(SPEC_DOMAIN || XDR(spec))`.
+pub fn spec_v2_hash(env: &Env, spec: &ResolutionSpecV2) -> BytesN<32> {
+ let mut preimage = Bytes::from_slice(env, SPEC_DOMAIN.as_bytes());
+ preimage.append(&spec.clone().to_xdr(env));
+ env.crypto().sha256(&preimage).into()
}
#[contracterror]
@@ -113,6 +259,13 @@ pub enum Error {
NoProposal = 16,
AlreadyFinalized = 17,
SpecHashMismatch = 18,
+ DeadlineNotReached = 19,
+ ResolutionAvailable = 20,
+ DomainMismatch = 21,
+ StaleRound = 22,
+ TooManySignatures = 23,
+ DisputeWindowClosed = 24,
+ NoDispute = 25,
}
#[contracttype]
@@ -125,6 +278,36 @@ pub enum DataKey {
Attestation(u64, Outcome, Address),
Count(u64, Outcome),
State(u64),
+ WeightedKeys(u64),
+ /// Current attestation round of a weighted market (absent = 0).
+ Round(u64),
+ /// Outcome a key signed in a round; one signature per key per round.
+ SigSeen(u64, u32, BytesN<32>),
+ /// Accumulated signer weight for (market, round, outcome).
+ Weight(u64, u32, Outcome),
+ Disputable(u64),
+ Dispute(u64),
+ EscalationVote(u64, Address),
+ EscalationCount(u64, Verdict),
+}
+
+/// The registered signer committee, whichever kind the market committed to.
+/// Gives `finalize_signers` and `expire_market` one view of "dispute window"
+/// and "does this outcome have quorum".
+enum Committee {
+ Signers(SignerConfig),
+ Weighted(WeightedKeyConfig),
+ Disputable(DisputableSignerConfig),
+}
+
+impl Committee {
+ fn dispute_window(&self) -> u64 {
+ match self {
+ Self::Signers(cfg) => cfg.dispute_window,
+ Self::Weighted(cfg) => cfg.dispute_window,
+ Self::Disputable(cfg) => cfg.dispute_window,
+ }
+ }
}
#[contractevent]
@@ -155,6 +338,42 @@ pub struct ResolutionProposed {
pub proposed_at: u64,
}
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Disputed {
+ #[topic]
+ pub market_id: u64,
+ pub disputer: Address,
+ pub bond: i128,
+ /// What the disputer claims is correct; `None` claims the market is invalid.
+ pub alternative: Option,
+}
+
+/// `disputer_won` is true when the bond returns to the disputer because the
+/// ruling overturned the proposal. A timeout (`expire_dispute`) also refunds
+/// the bond but is reported as `verdict: Invalid, disputer_won: false`: a
+/// ruling of `Invalid` always overturns, so that pair identifies a timeout.
+/// `payout` is the escrowed bond, claimable by its payee.
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct DisputeSettled {
+ #[topic]
+ pub market_id: u64,
+ pub verdict: Verdict,
+ pub disputer_won: bool,
+ pub payout: i128,
+}
+
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MarketExpired {
+ #[topic]
+ pub market_id: u64,
+ pub close_time: u64,
+ pub deadline: u64,
+ pub mode: ExpiryMode,
+}
+
#[soroban_sdk::contractclient(name = "ReflectorPriceClient")]
pub trait ReflectorPrice {
fn price(env: Env, asset: OracleAsset, timestamp: u64) -> Option;
@@ -178,56 +397,77 @@ impl Resolver {
/// — the commitment, not the caller, is the authority. Fails if a spec
/// was already registered for this market.
pub fn register_price_spec(env: Env, market_id: u64, spec: PriceConfig) -> Result<(), Error> {
- Self::assert_unconfigured(&env, market_id)?;
- if spec.threshold < 0 || spec.max_staleness == 0 {
- return Err(Error::InvalidConfig);
- }
- Self::assert_matches_commitment(&env, market_id, &ResolutionSpec::Price(spec.clone()))?;
-
- let key = DataKey::Price(market_id);
- env.storage().persistent().set(&key, &spec);
- env.storage()
- .persistent()
- .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO);
- env.storage()
- .persistent()
- .set(&DataKey::State(market_id), &ResolutionState::Unconfigured);
- Ok(())
+ let rule = ResolutionSpec::Price(spec);
+ Self::reveal(&env, market_id, &rule, &legacy_spec_hash(&env, &rule))
}
/// See `register_price_spec` — same commit/reveal rule, signer mode.
pub fn register_signer_spec(env: Env, market_id: u64, spec: SignerConfig) -> Result<(), Error> {
- Self::assert_unconfigured(&env, market_id)?;
- if spec.signers.is_empty()
- || spec.threshold == 0
- || spec.threshold > spec.signers.len()
- || spec.dispute_window == 0
- {
+ let rule = ResolutionSpec::Signers(spec);
+ Self::reveal(&env, market_id, &rule, &legacy_spec_hash(&env, &rule))
+ }
+
+ /// See `register_price_spec` — same commit/reveal rule, signer mode whose
+ /// proposals can be challenged with a bond (see `dispute`).
+ pub fn register_disputable_spec(
+ env: Env,
+ market_id: u64,
+ spec: DisputableSignerConfig,
+ ) -> Result<(), Error> {
+ let rule = ResolutionSpec::SignersDisputable(spec);
+ Self::reveal(&env, market_id, &rule, &legacy_spec_hash(&env, &rule))
+ }
+
+ /// See `register_price_spec` — same commit/reveal rule, weighted Ed25519
+ /// committee that signs off-chain and is verified by `submit_signatures`.
+ pub fn register_weighted_key_spec(
+ env: Env,
+ market_id: u64,
+ spec: WeightedKeyConfig,
+ ) -> Result<(), Error> {
+ let rule = ResolutionSpec::WeightedKeys(spec);
+ Self::reveal(&env, market_id, &rule, &legacy_spec_hash(&env, &rule))
+ }
+
+ /// Reveals a rule committed to through a [`ResolutionSpecV2`] envelope.
+ /// The envelope must name this network and this resolver's market-core
+ /// (`DomainMismatch`), use a non-zero salt and the current `version`
+ /// (`InvalidConfig`), and hash to the market's commitment
+ /// (`SpecHashMismatch`). The inner rule is then validated and stored
+ /// exactly as the legacy entrypoints do.
+ pub fn register_spec_v2(env: Env, market_id: u64, spec: ResolutionSpecV2) -> Result<(), Error> {
+ if spec.version != SPEC_V2_VERSION || spec.salt == BytesN::from_array(&env, &[0; 32]) {
return Err(Error::InvalidConfig);
}
- Self::assert_matches_commitment(&env, market_id, &ResolutionSpec::Signers(spec.clone()))?;
-
- let key = DataKey::Signers(market_id);
- env.storage().persistent().set(&key, &spec);
- env.storage()
- .persistent()
- .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO);
- env.storage()
- .persistent()
- .set(&DataKey::State(market_id), &ResolutionState::SignersPending);
- Ok(())
+ if spec.network_id != env.ledger().network_id()
+ || spec.market_core != Self::instance_get::(&env, DataKey::MarketCore)?
+ {
+ return Err(Error::DomainMismatch);
+ }
+ Self::reveal(&env, market_id, &spec.rule, &spec_v2_hash(&env, &spec))
}
/// Guardian action: withdraws an unfinalized signer proposal, e.g. because
- /// evidence surfaced during the dispute window that it is wrong. Voting
- /// can restart once the counts are cleared by re-attesting.
+ /// evidence surfaced during the dispute window that it is wrong. For
+ /// address-based signers voting can restart once the counts are cleared by
+ /// re-attesting; a weighted-key market moves to a fresh round, which makes
+ /// every signature collected so far unusable.
pub fn cancel_proposal(env: Env, market_id: u64) -> Result<(), Error> {
Self::require_admin(&env)?;
+ Self::assert_not_disputed(&env, market_id)?;
+ let storage = env.storage().persistent();
let key = DataKey::Proposal(market_id);
- if !env.storage().persistent().has(&key) {
+ if !storage.has(&key) {
return Err(Error::NoProposal);
}
- env.storage().persistent().remove(&key);
+ storage.remove(&key);
+ if storage.has(&DataKey::WeightedKeys(market_id)) {
+ Self::persist(
+ &env,
+ &DataKey::Round(market_id),
+ &(Self::round(&env, market_id) + 1),
+ );
+ }
Ok(())
}
@@ -236,14 +476,13 @@ impl Resolver {
/// `require_resolver` check passes the same way `resolve_price` does.
pub fn guardian_void_market(env: Env, market_id: u64) -> Result<(), Error> {
Self::require_admin(&env)?;
- if Self::resolution_state(&env, market_id) == ResolutionState::Finalized {
+ if Self::resolution_state(&env, market_id).is_terminal() {
return Err(Error::AlreadyFinalized);
}
let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?;
MarketCoreClient::new(&env, &market_core).void_market(&market_id);
- env.storage()
- .persistent()
- .set(&DataKey::State(market_id), &ResolutionState::Finalized);
+ Self::refund_open_dispute(&env, market_id);
+ Self::set_state(&env, market_id, ResolutionState::Finalized);
Ok(())
}
@@ -254,59 +493,76 @@ impl Resolver {
if env.ledger().timestamp() < market.close_time {
return Err(Error::MarketNotClosed);
}
- if Self::resolution_state(&env, market_id) == ResolutionState::Finalized {
+ if Self::resolution_state(&env, market_id).is_terminal() {
return Err(Error::AlreadyFinalized);
}
- let data = ReflectorPriceClient::new(&env, &config.reflector)
- .price(&config.asset, &market.close_time)
- .ok_or(Error::PriceUnavailable)?;
- if data.timestamp > market.close_time
- || market.close_time - data.timestamp > config.max_staleness
- {
- return Err(Error::StalePrice);
+ let (outcome, data) = Self::try_price_outcome(&env, &config, market.close_time)?;
+ Self::settle_price(&env, &market_core, market_id, &outcome, &data);
+ Ok(outcome)
+ }
+
+ /// When `expire_market` becomes callable for `market_id`: `close_time` plus
+ /// [`RESOLUTION_GRACE_SECS`]. Needs only market-core's `close_time`, so it
+ /// is known before (and without) a spec reveal.
+ pub fn resolution_deadline(env: Env, market_id: u64) -> Result {
+ let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?;
+ let market = MarketCoreClient::new(&env, &market_core).get_market(&market_id);
+ Ok(Self::deadline(&market))
+ }
+
+ /// Permissionless liveness fallback: once `resolution_deadline` has passed,
+ /// anyone may void a market that is still unresolved, so collateral is
+ /// never locked behind a missing reveal, a dead feed or silent signers.
+ ///
+ /// Resolution is preferred over voiding. A price market whose feed is
+ /// valid right now is *resolved* instead (so a stalled caller can never
+ /// force a void on a resolvable market), and a market with a pending
+ /// signer proposal is refused with `ResolutionAvailable` until
+ /// `finalize_signers` settles it.
+ pub fn expire_market(env: Env, market_id: u64) -> Result<(), Error> {
+ Self::assert_not_disputed(&env, market_id)?;
+ if Self::resolution_state(&env, market_id).is_terminal() {
+ return Err(Error::AlreadyFinalized);
}
- let outcome = match config.direction {
- PriceDirection::Above => {
- if data.price > config.threshold {
- Outcome::Yes
- } else {
- Outcome::No
- }
- }
- PriceDirection::AtOrAbove => {
- if data.price >= config.threshold {
- Outcome::Yes
- } else {
- Outcome::No
- }
- }
- PriceDirection::Below => {
- if data.price < config.threshold {
- Outcome::Yes
- } else {
- Outcome::No
+ let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?;
+ let core = MarketCoreClient::new(&env, &market_core);
+ let market = core.get_market(&market_id);
+ let deadline = Self::deadline(&market);
+ if env.ledger().timestamp() < deadline {
+ return Err(Error::DeadlineNotReached);
+ }
+
+ let mode = Self::expiry_mode(&env, market_id);
+ match mode {
+ ExpiryMode::Price => {
+ let config: PriceConfig = Self::load(&env, DataKey::Price(market_id))?;
+ match Self::try_price_outcome(&env, &config, market.close_time) {
+ Ok((outcome, data)) => {
+ Self::settle_price(&env, &market_core, market_id, &outcome, &data);
+ return Ok(());
+ }
+ // Only an unusable feed lets the market expire; any other
+ // error (e.g. NotInitialized) is a real fault.
+ Err(Error::PriceUnavailable | Error::StalePrice) => {}
+ Err(other) => return Err(other),
}
}
- PriceDirection::AtOrBelow => {
- if data.price <= config.threshold {
- Outcome::Yes
- } else {
- Outcome::No
- }
+ ExpiryMode::Signers if Self::has_valid_proposal(&env, market_id)? => {
+ return Err(Error::ResolutionAvailable);
}
- };
- MarketCoreClient::new(&env, &market_core).resolve(&market_id, &outcome);
- env.storage()
- .persistent()
- .set(&DataKey::State(market_id), &ResolutionState::Finalized);
- PriceResolution {
+ ExpiryMode::Signers | ExpiryMode::Unrevealed => {}
+ }
+
+ core.void_market(&market_id);
+ Self::set_state(&env, market_id, ResolutionState::Expired);
+ MarketExpired {
market_id,
- price: data.price,
- price_timestamp: data.timestamp,
- outcome: outcome.clone(),
+ close_time: market.close_time,
+ deadline,
+ mode,
}
.publish(&env);
- Ok(outcome)
+ Ok(())
}
pub fn attest(
@@ -316,11 +572,16 @@ impl Resolver {
outcome: Outcome,
) -> Result<(), Error> {
signer.require_auth();
- let config: SignerConfig = Self::load(&env, DataKey::Signers(market_id))?;
- if !Self::is_signer(&config.signers, &signer) {
+ let (signers, threshold) = match Self::committee(&env, market_id)? {
+ Committee::Signers(cfg) => (cfg.signers, cfg.threshold),
+ Committee::Disputable(cfg) => (cfg.signers, cfg.threshold),
+ Committee::Weighted(_) => return Err(Error::WrongMode),
+ };
+ if !Self::contains(&signers, &signer) {
return Err(Error::NotSigner);
}
- if Self::resolution_state(&env, market_id) == ResolutionState::Finalized {
+ Self::assert_not_disputed(&env, market_id)?;
+ if Self::resolution_state(&env, market_id).is_terminal() {
return Err(Error::AlreadyFinalized);
}
if env.storage().persistent().has(&DataKey::Attestation(
@@ -343,7 +604,7 @@ impl Resolver {
let count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0);
let count = count + 1;
env.storage().persistent().set(&count_key, &count);
- if count >= config.threshold {
+ if count >= threshold {
let proposal = SignerProposal {
outcome: outcome.clone(),
proposed_at: env.ledger().timestamp(),
@@ -361,26 +622,103 @@ impl Resolver {
Ok(())
}
+ /// Counts Ed25519 signatures from a weighted committee. Permissionless: the
+ /// signatures, not the submitter, carry the authority. Each signature is
+ /// over the message built by `attestation_message`; non-members and keys
+ /// that already signed this round (for either outcome) are skipped, and a
+ /// single invalid signature aborts the whole batch, so submitters should
+ /// pre-filter with a verifier. Once the accumulated weight for `outcome`
+ /// first reaches the threshold a proposal is recorded and the dispute
+ /// window starts; later calls never move it.
+ pub fn submit_signatures(
+ env: Env,
+ market_id: u64,
+ outcome: Outcome,
+ round: u32,
+ sigs: Vec<(BytesN<32>, BytesN<64>)>,
+ ) -> Result<(), Error> {
+ let config: WeightedKeyConfig = Self::load(&env, DataKey::WeightedKeys(market_id))?;
+ if Self::resolution_state(&env, market_id).is_terminal() {
+ return Err(Error::AlreadyFinalized);
+ }
+ if sigs.len() > MAX_SIGS_PER_CALL {
+ return Err(Error::TooManySignatures);
+ }
+ if round != Self::round(&env, market_id) {
+ return Err(Error::StaleRound);
+ }
+ let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?;
+ let market = MarketCoreClient::new(&env, &market_core).get_market(&market_id);
+ if env.ledger().timestamp() < market.close_time {
+ return Err(Error::MarketNotClosed);
+ }
+
+ let message =
+ Self::attestation_message(&env, market_id, round, &outcome, &market.resolution_hash);
+ let storage = env.storage().persistent();
+ let before = Self::round_weight(&env, market_id, round, &outcome);
+ let mut weight = before;
+ for (pubkey, signature) in sigs.iter() {
+ let Some(key_weight) = Self::weight_of(&config, &pubkey) else {
+ continue;
+ };
+ let seen = DataKey::SigSeen(market_id, round, pubkey.clone());
+ if storage.has(&seen) {
+ continue;
+ }
+ env.crypto().ed25519_verify(&pubkey, &message, &signature);
+ Self::persist(&env, &seen, &outcome);
+ weight += key_weight;
+ }
+ if weight == before {
+ return Ok(());
+ }
+ Self::persist(
+ &env,
+ &DataKey::Weight(market_id, round, outcome.clone()),
+ &weight,
+ );
+
+ let proposal_key = DataKey::Proposal(market_id);
+ if before < config.weight_threshold
+ && weight >= config.weight_threshold
+ && !storage.has(&proposal_key)
+ {
+ let proposal = SignerProposal {
+ outcome: outcome.clone(),
+ proposed_at: env.ledger().timestamp(),
+ };
+ storage.set(&proposal_key, &proposal);
+ ResolutionProposed {
+ market_id,
+ outcome,
+ proposed_at: proposal.proposed_at,
+ }
+ .publish(&env);
+ }
+ Ok(())
+ }
+
+ /// Current attestation round of a weighted-key market; the `round`
+ /// argument of `submit_signatures` and part of every signed message.
+ pub fn current_round(env: Env, market_id: u64) -> u32 {
+ Self::round(&env, market_id)
+ }
+
pub fn finalize_signers(env: Env, market_id: u64) -> Result {
- let config: SignerConfig = Self::load(&env, DataKey::Signers(market_id))?;
+ Self::assert_not_disputed(&env, market_id)?;
+ let committee = Self::committee(&env, market_id)?;
let proposal: SignerProposal =
Self::load(&env, DataKey::Proposal(market_id)).map_err(|_| Error::NoProposal)?;
- if env.ledger().timestamp() < proposal.proposed_at + config.dispute_window {
+ if env.ledger().timestamp() < proposal.proposed_at + committee.dispute_window() {
return Err(Error::DisputeWindowOpen);
}
- let count: u32 = env
- .storage()
- .persistent()
- .get(&DataKey::Count(market_id, proposal.outcome.clone()))
- .unwrap_or(0);
- if count < config.threshold {
+ if !Self::has_quorum(&env, &committee, market_id, &proposal.outcome) {
return Err(Error::ThresholdNotReached);
}
let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?;
MarketCoreClient::new(&env, &market_core).resolve(&market_id, &proposal.outcome);
- env.storage()
- .persistent()
- .set(&DataKey::State(market_id), &ResolutionState::Finalized);
+ Self::set_state(&env, market_id, ResolutionState::Finalized);
ResolutionFinalized {
market_id,
outcome: proposal.outcome.clone(),
@@ -390,6 +728,151 @@ impl Resolver {
Ok(proposal.outcome)
}
+ /// Challenges the current proposal of a `SignersDisputable` market while
+ /// its dispute window is still open (strictly before `proposed_at +
+ /// dispute_window`, the instant `finalize_signers` becomes callable). The
+ /// disputer escrows `dispute_bond` of the collateral token, which freezes
+ /// finalization until the escalation authority rules or
+ /// `escalation_timeout` lapses. `alternative` is the outcome the disputer
+ /// claims is correct (`None`: the market is invalid); it only informs the
+ /// ruling and must differ from the proposal. The disputer may also be a
+ /// proposing signer.
+ pub fn dispute(
+ env: Env,
+ market_id: u64,
+ disputer: Address,
+ alternative: Option,
+ ) -> Result<(), Error> {
+ disputer.require_auth();
+ let config: DisputableSignerConfig = Self::load(&env, DataKey::Disputable(market_id))?;
+ Self::assert_not_disputed(&env, market_id)?;
+ if Self::resolution_state(&env, market_id).is_terminal() {
+ return Err(Error::AlreadyFinalized);
+ }
+ let proposal: SignerProposal =
+ Self::load(&env, DataKey::Proposal(market_id)).map_err(|_| Error::NoProposal)?;
+ let now = env.ledger().timestamp();
+ if now >= proposal.proposed_at.saturating_add(config.dispute_window) {
+ return Err(Error::DisputeWindowClosed);
+ }
+ if alternative == Some(proposal.outcome) {
+ return Err(Error::InvalidConfig);
+ }
+
+ let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?;
+ let token = MarketCoreClient::new(&env, &market_core)
+ .get_config()
+ .collateral;
+ token::Client::new(&env, &token).transfer(
+ &disputer,
+ env.current_contract_address(),
+ &config.dispute_bond,
+ );
+ Self::persist(
+ &env,
+ &DataKey::Dispute(market_id),
+ &Dispute {
+ disputer: disputer.clone(),
+ bond: config.dispute_bond,
+ token,
+ opened_at: now,
+ payee: None,
+ },
+ );
+ Self::set_state(&env, market_id, ResolutionState::Disputed);
+ Disputed {
+ market_id,
+ disputer,
+ bond: config.dispute_bond,
+ alternative,
+ }
+ .publish(&env);
+ Ok(())
+ }
+
+ /// Guardian escalation: the admin rules on an open dispute.
+ pub fn settle_dispute(env: Env, market_id: u64, verdict: Verdict) -> Result<(), Error> {
+ let config: DisputableSignerConfig = Self::load(&env, DataKey::Disputable(market_id))?;
+ if config.escalation != Escalation::Guardian {
+ return Err(Error::WrongMode);
+ }
+ Self::require_admin(&env)?;
+ Self::apply_verdict(&env, market_id, verdict)
+ }
+
+ /// Committee escalation: each member votes once; the first verdict to
+ /// gather `k` votes settles the dispute.
+ pub fn escalation_attest(
+ env: Env,
+ market_id: u64,
+ member: Address,
+ verdict: Verdict,
+ ) -> Result<(), Error> {
+ member.require_auth();
+ let config: DisputableSignerConfig = Self::load(&env, DataKey::Disputable(market_id))?;
+ let Escalation::Committee(members, k) = config.escalation else {
+ return Err(Error::WrongMode);
+ };
+ if !Self::contains(&members, &member) {
+ return Err(Error::NotSigner);
+ }
+ Self::open_dispute(&env, market_id)?;
+ let vote = DataKey::EscalationVote(market_id, member);
+ if env.storage().persistent().has(&vote) {
+ return Err(Error::AlreadyAttested);
+ }
+ Self::persist(&env, &vote, &true);
+ let count_key = DataKey::EscalationCount(market_id, verdict);
+ let count = env.storage().persistent().get(&count_key).unwrap_or(0u32) + 1;
+ Self::persist(&env, &count_key, &count);
+ if count >= k {
+ Self::apply_verdict(&env, market_id, verdict)?;
+ }
+ Ok(())
+ }
+
+ /// Permissionless fallback when the escalation authority never rules:
+ /// `escalation_timeout` after the dispute opened, the bond is refunded and
+ /// the market is voided, so neither side wins by default.
+ pub fn expire_dispute(env: Env, market_id: u64) -> Result<(), Error> {
+ let config: DisputableSignerConfig = Self::load(&env, DataKey::Disputable(market_id))?;
+ let dispute = Self::open_dispute(&env, market_id)?;
+ if env.ledger().timestamp() < dispute.opened_at.saturating_add(config.escalation_timeout) {
+ return Err(Error::DeadlineNotReached);
+ }
+ let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?;
+ MarketCoreClient::new(&env, &market_core).void_market(&market_id);
+ Self::refund_open_dispute(&env, market_id);
+ Self::set_state(&env, market_id, ResolutionState::Finalized);
+ DisputeSettled {
+ market_id,
+ verdict: Verdict::Invalid,
+ disputer_won: false,
+ payout: dispute.bond,
+ }
+ .publish(&env);
+ Ok(())
+ }
+
+ /// Pays a settled dispute's bond to its payee (the disputer, or the
+ /// market-core treasury when the proposal was upheld). Permissionless.
+ pub fn claim_dispute_bond(env: Env, market_id: u64) -> Result<(), Error> {
+ let key = DataKey::Dispute(market_id);
+ let dispute: Dispute = env
+ .storage()
+ .persistent()
+ .get(&key)
+ .ok_or(Error::NoDispute)?;
+ let payee = dispute.payee.ok_or(Error::DisputeActive)?;
+ env.storage().persistent().remove(&key);
+ token::Client::new(&env, &dispute.token).transfer(
+ &env.current_contract_address(),
+ &payee,
+ &dispute.bond,
+ );
+ Ok(())
+ }
+
pub fn state(env: Env, market_id: u64) -> ResolutionState {
Self::resolution_state(&env, market_id)
}
@@ -429,49 +912,414 @@ impl Resolver {
.unwrap_or(ResolutionState::Unconfigured)
}
- fn is_signer(signers: &Vec, signer: &Address) -> bool {
- for candidate in signers.iter() {
- if candidate == *signer {
- return true;
- }
- }
- false
+ /// Writes the per-market state and extends its TTL. An archived entry is
+ /// not lost (it must be restored before any transaction can touch it),
+ /// but keeping terminal state live spares every later call the restore.
+ fn set_state(env: &Env, market_id: u64, state: ResolutionState) {
+ let key = DataKey::State(market_id);
+ env.storage().persistent().set(&key, &state);
+ env.storage()
+ .persistent()
+ .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO);
}
- fn assert_unconfigured(env: &Env, market_id: u64) -> Result<(), Error> {
- if env.storage().persistent().has(&DataKey::Price(market_id))
- || env.storage().persistent().has(&DataKey::Signers(market_id))
+ fn deadline(market: &market_core::Market) -> u64 {
+ market.close_time.saturating_add(RESOLUTION_GRACE_SECS)
+ }
+
+ fn expiry_mode(env: &Env, market_id: u64) -> ExpiryMode {
+ let storage = env.storage().persistent();
+ if storage.has(&DataKey::Price(market_id)) {
+ ExpiryMode::Price
+ } else if storage.has(&DataKey::Signers(market_id))
+ || storage.has(&DataKey::Disputable(market_id))
+ || storage.has(&DataKey::WeightedKeys(market_id))
{
- return Err(Error::MarketAlreadyConfigured);
+ ExpiryMode::Signers
+ } else {
+ ExpiryMode::Unrevealed
}
- Ok(())
}
- /// Verifies `spec` hashes to the market's `resolution_hash` commitment.
- fn assert_matches_commitment(
- env: &Env,
- market_id: u64,
- spec: &ResolutionSpec,
- ) -> Result<(), Error> {
- let market_core: Address = Self::instance_get(env, DataKey::MarketCore)?;
- let market = MarketCoreClient::new(env, &market_core).get_market(&market_id);
- let bytes = spec.clone().to_xdr(env);
- let hash: BytesN<32> = env.crypto().sha256(&bytes).into();
- if hash != market.resolution_hash {
- return Err(Error::SpecHashMismatch);
+ fn assert_not_disputed(env: &Env, market_id: u64) -> Result<(), Error> {
+ if Self::resolution_state(env, market_id) == ResolutionState::Disputed {
+ return Err(Error::DisputeActive);
}
Ok(())
}
-}
-#[cfg(test)]
-mod test {
- use super::*;
- use market_core::{Category, MarketStatus};
- use soroban_sdk::{
- contract, contractimpl,
- testutils::{Address as _, Ledger, MockAuth, MockAuthInvoke},
- token::StellarAssetClient,
+ /// The dispute of `market_id` if it is still waiting for a ruling.
+ fn open_dispute(env: &Env, market_id: u64) -> Result {
+ let dispute: Dispute = env
+ .storage()
+ .persistent()
+ .get(&DataKey::Dispute(market_id))
+ .ok_or(Error::NoDispute)?;
+ if dispute.payee.is_some() {
+ return Err(Error::AlreadyFinalized);
+ }
+ Ok(dispute)
+ }
+
+ /// Marks an open dispute's bond as refundable to its disputer (a no-op if
+ /// there is none). The funds move on `claim_dispute_bond`.
+ fn refund_open_dispute(env: &Env, market_id: u64) {
+ if let Ok(mut dispute) = Self::open_dispute(env, market_id) {
+ dispute.payee = Some(dispute.disputer.clone());
+ Self::persist(env, &DataKey::Dispute(market_id), &dispute);
+ }
+ }
+
+ /// Executes the escalation authority's ruling: finalizes the market in
+ /// market-core and assigns the bond. Upholding the proposal forfeits the
+ /// bond to the treasury; any other ruling returns it to the disputer.
+ fn apply_verdict(env: &Env, market_id: u64, verdict: Verdict) -> Result<(), Error> {
+ let mut dispute = Self::open_dispute(env, market_id)?;
+ let proposal: SignerProposal = Self::load(env, DataKey::Proposal(market_id))?;
+ let market_core: Address = Self::instance_get(env, DataKey::MarketCore)?;
+ let core = MarketCoreClient::new(env, &market_core);
+
+ let upheld = match verdict {
+ Verdict::Yes => proposal.outcome == Outcome::Yes,
+ Verdict::No => proposal.outcome == Outcome::No,
+ Verdict::Invalid => false,
+ };
+ match verdict {
+ Verdict::Yes => core.resolve(&market_id, &Outcome::Yes),
+ Verdict::No => core.resolve(&market_id, &Outcome::No),
+ Verdict::Invalid => core.void_market(&market_id),
+ }
+ dispute.payee = Some(if upheld {
+ core.get_config().treasury
+ } else {
+ dispute.disputer.clone()
+ });
+ Self::persist(env, &DataKey::Dispute(market_id), &dispute);
+ Self::set_state(env, market_id, ResolutionState::Finalized);
+ DisputeSettled {
+ market_id,
+ verdict,
+ disputer_won: !upheld,
+ payout: dispute.bond,
+ }
+ .publish(env);
+ Ok(())
+ }
+
+ fn attestation_count(env: &Env, market_id: u64, outcome: &Outcome) -> u32 {
+ env.storage()
+ .persistent()
+ .get(&DataKey::Count(market_id, outcome.clone()))
+ .unwrap_or(0)
+ }
+
+ fn committee(env: &Env, market_id: u64) -> Result {
+ let storage = env.storage().persistent();
+ if let Some(cfg) = storage.get(&DataKey::Signers(market_id)) {
+ return Ok(Committee::Signers(cfg));
+ }
+ if let Some(cfg) = storage.get(&DataKey::Disputable(market_id)) {
+ return Ok(Committee::Disputable(cfg));
+ }
+ if let Some(cfg) = storage.get(&DataKey::WeightedKeys(market_id)) {
+ return Ok(Committee::Weighted(cfg));
+ }
+ Err(Error::MarketNotConfigured)
+ }
+
+ /// Whether `outcome` currently has enough attestations / signature weight
+ /// (in the current round) to be finalized.
+ fn has_quorum(env: &Env, committee: &Committee, market_id: u64, outcome: &Outcome) -> bool {
+ match committee {
+ Committee::Signers(SignerConfig { threshold, .. })
+ | Committee::Disputable(DisputableSignerConfig { threshold, .. }) => {
+ Self::attestation_count(env, market_id, outcome) >= *threshold
+ }
+ Committee::Weighted(cfg) => {
+ let round = Self::round(env, market_id);
+ Self::round_weight(env, market_id, round, outcome) >= cfg.weight_threshold
+ }
+ }
+ }
+
+ /// A proposal exists whose outcome still has quorum, so `finalize_signers`
+ /// will succeed once the dispute window elapses.
+ fn has_valid_proposal(env: &Env, market_id: u64) -> Result {
+ let Some(proposal) = env
+ .storage()
+ .persistent()
+ .get::<_, SignerProposal>(&DataKey::Proposal(market_id))
+ else {
+ return Ok(false);
+ };
+ let committee = Self::committee(env, market_id)?;
+ Ok(Self::has_quorum(
+ env,
+ &committee,
+ market_id,
+ &proposal.outcome,
+ ))
+ }
+
+ /// Sets a persistent entry and extends its TTL.
+ fn persist>(
+ env: &Env,
+ key: &DataKey,
+ value: &V,
+ ) {
+ env.storage().persistent().set(key, value);
+ env.storage()
+ .persistent()
+ .extend_ttl(key, BUMP_THRESHOLD, BUMP_TO);
+ }
+
+ fn round(env: &Env, market_id: u64) -> u32 {
+ env.storage()
+ .persistent()
+ .get(&DataKey::Round(market_id))
+ .unwrap_or(0)
+ }
+
+ fn round_weight(env: &Env, market_id: u64, round: u32, outcome: &Outcome) -> u32 {
+ env.storage()
+ .persistent()
+ .get(&DataKey::Weight(market_id, round, outcome.clone()))
+ .unwrap_or(0)
+ }
+
+ fn weight_of(config: &WeightedKeyConfig, pubkey: &BytesN<32>) -> Option {
+ config
+ .signers
+ .iter()
+ .find(|key| key.pubkey == *pubkey)
+ .map(|key| key.weight)
+ }
+
+ /// The exact bytes weighted-key signers sign (never hashed first, so any
+ /// Ed25519 library can reproduce it):
+ ///
+ /// `"oracledesk-attest-v1" || network_id(32) || ScVal-XDR(resolver address)
+ /// || market_id(u64 BE) || round(u32 BE) || outcome(0 = Yes, 1 = No)
+ /// || resolution_hash(32)`
+ ///
+ /// Every field but the address is fixed length and the address XDR is
+ /// self-delimiting, so the encoding is unambiguous. Network, resolver,
+ /// market, round and the committed spec are all bound, which stops replay
+ /// across networks, deployments, markets, cancelled rounds and committees.
+ fn attestation_message(
+ env: &Env,
+ market_id: u64,
+ round: u32,
+ outcome: &Outcome,
+ resolution_hash: &BytesN<32>,
+ ) -> Bytes {
+ let mut message = Bytes::from_slice(env, ATTEST_DOMAIN);
+ message.append(&env.ledger().network_id().into());
+ message.append(&env.current_contract_address().to_xdr(env));
+ message.extend_from_array(&market_id.to_be_bytes());
+ message.extend_from_array(&round.to_be_bytes());
+ message.push_back(match outcome {
+ Outcome::Yes => 0,
+ Outcome::No => 1,
+ });
+ message.append(&resolution_hash.clone().into());
+ message
+ }
+
+ fn disputable_config_is_valid(cfg: &DisputableSignerConfig) -> bool {
+ let committee_ok = match &cfg.escalation {
+ Escalation::Guardian => true,
+ Escalation::Committee(members, k) => {
+ *k != 0
+ && *k <= members.len()
+ && members.iter().enumerate().all(|(index, member)| {
+ !Self::contains(&cfg.signers, &member)
+ && !members.iter().take(index).any(|earlier| earlier == member)
+ })
+ }
+ };
+ committee_ok
+ && !cfg.signers.is_empty()
+ && cfg.threshold != 0
+ && cfg.threshold <= cfg.signers.len()
+ && cfg.dispute_window != 0
+ && cfg.dispute_bond > 0
+ && cfg.escalation_timeout != 0
+ }
+
+ fn weighted_config_is_valid(cfg: &WeightedKeyConfig) -> bool {
+ let count = cfg.signers.len();
+ if count == 0 || count > MAX_WEIGHTED_SIGNERS || cfg.dispute_window == 0 {
+ return false;
+ }
+ let mut total: u32 = 0;
+ for (index, key) in cfg.signers.iter().enumerate() {
+ let Some(sum) = total.checked_add(key.weight) else {
+ return false;
+ };
+ total = sum;
+ let duplicate = cfg
+ .signers
+ .iter()
+ .take(index)
+ .any(|earlier| earlier.pubkey == key.pubkey);
+ if key.weight == 0 || duplicate {
+ return false;
+ }
+ }
+ cfg.weight_threshold != 0 && cfg.weight_threshold <= total
+ }
+
+ /// Reads the committed feed at `close_time` and applies the committed
+ /// comparison. `PriceUnavailable` also covers a feed contract that traps,
+ /// so a broken oracle address cannot make `expire_market` revert forever.
+ fn try_price_outcome(
+ env: &Env,
+ config: &PriceConfig,
+ close_time: u64,
+ ) -> Result<(Outcome, PriceData), Error> {
+ let Ok(Ok(Some(data))) =
+ ReflectorPriceClient::new(env, &config.reflector).try_price(&config.asset, &close_time)
+ else {
+ return Err(Error::PriceUnavailable);
+ };
+ if data.timestamp > close_time || close_time - data.timestamp > config.max_staleness {
+ return Err(Error::StalePrice);
+ }
+ let hit = match config.direction {
+ PriceDirection::Above => data.price > config.threshold,
+ PriceDirection::AtOrAbove => data.price >= config.threshold,
+ PriceDirection::Below => data.price < config.threshold,
+ PriceDirection::AtOrBelow => data.price <= config.threshold,
+ };
+ Ok((if hit { Outcome::Yes } else { Outcome::No }, data))
+ }
+
+ fn settle_price(
+ env: &Env,
+ market_core: &Address,
+ market_id: u64,
+ outcome: &Outcome,
+ data: &PriceData,
+ ) {
+ MarketCoreClient::new(env, market_core).resolve(&market_id, outcome);
+ Self::set_state(env, market_id, ResolutionState::Finalized);
+ PriceResolution {
+ market_id,
+ price: data.price,
+ price_timestamp: data.timestamp,
+ outcome: outcome.clone(),
+ }
+ .publish(env);
+ }
+
+ fn contains(list: &Vec, address: &Address) -> bool {
+ list.iter().any(|candidate| candidate == *address)
+ }
+
+ fn assert_unconfigured(env: &Env, market_id: u64) -> Result<(), Error> {
+ if Self::resolution_state(env, market_id).is_terminal() {
+ return Err(Error::AlreadyFinalized);
+ }
+ let storage = env.storage().persistent();
+ if storage.has(&DataKey::Price(market_id))
+ || storage.has(&DataKey::Signers(market_id))
+ || storage.has(&DataKey::WeightedKeys(market_id))
+ || storage.has(&DataKey::Disputable(market_id))
+ {
+ return Err(Error::MarketAlreadyConfigured);
+ }
+ Ok(())
+ }
+
+ /// Shared reveal pipeline for every entrypoint: the market must be
+ /// unconfigured, the rule well-formed, and `commitment` (the hash of
+ /// whatever preimage the caller supplied) equal to the market's
+ /// `resolution_hash`. Validation precedes the cross-contract call so
+ /// malformed input is rejected without touching market-core.
+ fn reveal(
+ env: &Env,
+ market_id: u64,
+ rule: &ResolutionSpec,
+ commitment: &BytesN<32>,
+ ) -> Result<(), Error> {
+ Self::assert_unconfigured(env, market_id)?;
+ Self::validate_rule(rule)?;
+ Self::assert_matches_commitment(env, market_id, commitment)?;
+ Self::store_rule(env, market_id, rule);
+ Ok(())
+ }
+
+ fn validate_rule(rule: &ResolutionSpec) -> Result<(), Error> {
+ let valid = match rule {
+ ResolutionSpec::Price(cfg) => cfg.threshold >= 0 && cfg.max_staleness != 0,
+ ResolutionSpec::Signers(cfg) => {
+ !cfg.signers.is_empty()
+ && cfg.threshold != 0
+ && cfg.threshold <= cfg.signers.len()
+ && cfg.dispute_window != 0
+ }
+ ResolutionSpec::WeightedKeys(cfg) => Self::weighted_config_is_valid(cfg),
+ ResolutionSpec::SignersDisputable(cfg) => Self::disputable_config_is_valid(cfg),
+ };
+ if valid {
+ Ok(())
+ } else {
+ Err(Error::InvalidConfig)
+ }
+ }
+
+ fn store_rule(env: &Env, market_id: u64, rule: &ResolutionSpec) {
+ let storage = env.storage().persistent();
+ let (key, state) = match rule {
+ ResolutionSpec::Price(cfg) => {
+ let key = DataKey::Price(market_id);
+ storage.set(&key, cfg);
+ (key, ResolutionState::Unconfigured)
+ }
+ ResolutionSpec::Signers(cfg) => {
+ let key = DataKey::Signers(market_id);
+ storage.set(&key, cfg);
+ (key, ResolutionState::SignersPending)
+ }
+ ResolutionSpec::WeightedKeys(cfg) => {
+ let key = DataKey::WeightedKeys(market_id);
+ storage.set(&key, cfg);
+ (key, ResolutionState::SignersPending)
+ }
+ ResolutionSpec::SignersDisputable(cfg) => {
+ let key = DataKey::Disputable(market_id);
+ storage.set(&key, cfg);
+ (key, ResolutionState::SignersPending)
+ }
+ };
+ storage.extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO);
+ Self::set_state(env, market_id, state);
+ }
+
+ /// Verifies `commitment` equals the market's `resolution_hash`.
+ fn assert_matches_commitment(
+ env: &Env,
+ market_id: u64,
+ commitment: &BytesN<32>,
+ ) -> Result<(), Error> {
+ let market_core: Address = Self::instance_get(env, DataKey::MarketCore)?;
+ let market = MarketCoreClient::new(env, &market_core).get_market(&market_id);
+ if *commitment != market.resolution_hash {
+ return Err(Error::SpecHashMismatch);
+ }
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+ use market_core::{Category, MarketStatus};
+ use soroban_sdk::{
+ contract, contractimpl,
+ testutils::{Address as _, Events as _, Ledger, MockAuth, MockAuthInvoke},
+ token::{self, StellarAssetClient},
BytesN, IntoVal,
};
@@ -491,6 +1339,8 @@ mod test {
}
}
+ const NETWORK_ID: [u8; 32] = [7; 32];
+
struct Fixture {
env: Env,
admin: Address,
@@ -510,10 +1360,23 @@ mod test {
/// later via `register_price_spec` / `register_signer_spec`.
fn fixture(
spec_fn: impl FnOnce(&Env, &Address, &Address, &Address) -> ResolutionSpec,
+ ) -> Fixture {
+ fixture_with(spec_fn, |env, spec, _market_core| {
+ legacy_spec_hash(env, spec)
+ })
+ }
+
+ /// Like [`fixture`], but `commit` chooses the `resolution_hash` the market
+ /// is created with, given the rule and the market-core address, so V2
+ /// envelopes can be committed to.
+ fn fixture_with(
+ spec_fn: impl FnOnce(&Env, &Address, &Address, &Address) -> ResolutionSpec,
+ commit: impl FnOnce(&Env, &ResolutionSpec, &Address) -> BytesN<32>,
) -> Fixture {
let env = Env::default();
env.mock_all_auths();
env.ledger().set_timestamp(1_000);
+ env.ledger().set_network_id(NETWORK_ID);
let admin = Address::generate(&env);
let creator = Address::generate(&env);
@@ -528,8 +1391,6 @@ mod test {
token.mint(&creator, &(1_000 * 10_000_000_i128));
let spec = spec_fn(&env, &reflector_id, &signer_a, &signer_b);
- let resolution_hash: BytesN<32> = env.crypto().sha256(&spec.clone().to_xdr(&env)).into();
-
let market_core_id = env.register(
market_core::WASM,
(
@@ -540,6 +1401,7 @@ mod test {
100u32,
),
);
+ let resolution_hash = commit(&env, &spec, &market_core_id);
let market_core = MarketCoreClient::new(&env, &market_core_id);
let market_id = market_core.create_market(
&creator,
@@ -591,6 +1453,42 @@ mod test {
}
}
+ fn v2_envelope(
+ env: &Env,
+ market_core: &Address,
+ salt: [u8; 32],
+ rule: &ResolutionSpec,
+ ) -> ResolutionSpecV2 {
+ ResolutionSpecV2 {
+ version: 2,
+ network_id: BytesN::from_array(env, &NETWORK_ID),
+ market_core: market_core.clone(),
+ salt: BytesN::from_array(env, &salt),
+ rule: rule.clone(),
+ }
+ }
+
+ fn envelope(f: &Fixture, salt: [u8; 32]) -> ResolutionSpecV2 {
+ v2_envelope(&f.env, &f.market_core.address, salt, &f.spec)
+ }
+
+ /// A fixture whose market commits to the V2 envelope of the rule `spec_fn`
+ /// returns, salted with `salt`.
+ fn v2_fixture(
+ salt: [u8; 32],
+ spec_fn: impl FnOnce(&Env, &Address, &Address, &Address) -> ResolutionSpec,
+ ) -> Fixture {
+ fixture_with(spec_fn, move |env, rule, market_core| {
+ spec_v2_hash(env, &v2_envelope(env, market_core, salt, rule))
+ })
+ }
+
+ fn v2_signer_fixture(salt: [u8; 32]) -> Fixture {
+ v2_fixture(salt, |env, _r, a, b| {
+ ResolutionSpec::Signers(signer_spec(env, a, b))
+ })
+ }
+
#[test]
fn price_mode_resolves_from_historical_reflector_price() {
let f = fixture(|env, reflector, _a, _b| ResolutionSpec::Price(price_spec(env, reflector)));
@@ -682,23 +1580,1458 @@ mod test {
}
#[test]
- fn wrong_caller_cannot_use_guardian_functions() {
+ fn v2_reveal_rejected_once_legacy_spec_registered() {
+ let f = fixture(|env, _r, a, b| ResolutionSpec::Signers(signer_spec(env, a, b)));
+ let ResolutionSpec::Signers(spec) = f.spec.clone() else {
+ unreachable!()
+ };
+ f.resolver.register_signer_spec(&f.market_id, &spec);
+ assert_eq!(
+ f.resolver
+ .try_register_spec_v2(&f.market_id, &envelope(&f, [9; 32])),
+ Err(Ok(Error::MarketAlreadyConfigured))
+ );
+ }
+
+ #[test]
+ fn v2_reveal_accepted() {
+ let f = v2_signer_fixture([9; 32]);
+ f.resolver
+ .register_spec_v2(&f.market_id, &envelope(&f, [9; 32]));
+ assert_eq!(
+ f.resolver.state(&f.market_id),
+ ResolutionState::SignersPending
+ );
+
+ f.env.ledger().set_timestamp(2_000);
+ f.resolver.attest(&f.market_id, &f.signer_a, &Outcome::Yes);
+ f.resolver.attest(&f.market_id, &f.signer_b, &Outcome::Yes);
+ f.env.ledger().set_timestamp(2_100);
+ assert_eq!(f.resolver.finalize_signers(&f.market_id), Outcome::Yes);
+ }
+
+ #[test]
+ fn v2_price_rule_reveal_accepted() {
+ let f = v2_fixture([9; 32], |env, reflector, _a, _b| {
+ ResolutionSpec::Price(price_spec(env, reflector))
+ });
+ f.resolver
+ .register_spec_v2(&f.market_id, &envelope(&f, [9; 32]));
+ f.env.as_contract(&f.resolver_id, || {
+ assert!(f
+ .env
+ .storage()
+ .persistent()
+ .has(&DataKey::Price(f.market_id)));
+ });
+ }
+
+ #[test]
+ fn v2_wrong_network_rejected() {
+ let f = v2_signer_fixture([9; 32]);
+ let mut spec = envelope(&f, [9; 32]);
+ spec.network_id = BytesN::from_array(&f.env, &[8; 32]);
+ assert_eq!(
+ f.resolver.try_register_spec_v2(&f.market_id, &spec),
+ Err(Ok(Error::DomainMismatch))
+ );
+ }
+
+ #[test]
+ fn v2_wrong_market_core_rejected() {
+ let f = v2_signer_fixture([9; 32]);
+ let mut spec = envelope(&f, [9; 32]);
+ spec.market_core = Address::generate(&f.env);
+ assert_eq!(
+ f.resolver.try_register_spec_v2(&f.market_id, &spec),
+ Err(Ok(Error::DomainMismatch))
+ );
+ }
+
+ #[test]
+ fn v2_wrong_salt_rejected() {
+ let f = v2_signer_fixture([9; 32]);
+ assert_eq!(
+ f.resolver
+ .try_register_spec_v2(&f.market_id, &envelope(&f, [10; 32])),
+ Err(Ok(Error::SpecHashMismatch))
+ );
+ }
+
+ #[test]
+ fn v2_zero_salt_and_unknown_version_rejected() {
+ let f = v2_signer_fixture([0; 32]);
+ assert_eq!(
+ f.resolver
+ .try_register_spec_v2(&f.market_id, &envelope(&f, [0; 32])),
+ Err(Ok(Error::InvalidConfig))
+ );
+ let mut spec = envelope(&f, [9; 32]);
+ spec.version = 3;
+ assert_eq!(
+ f.resolver.try_register_spec_v2(&f.market_id, &spec),
+ Err(Ok(Error::InvalidConfig))
+ );
+ }
+
+ #[test]
+ fn legacy_reveal_of_v2_commitment_rejected() {
+ let f = v2_signer_fixture([9; 32]);
+ let ResolutionSpec::Signers(spec) = f.spec.clone() else {
+ unreachable!()
+ };
+ assert_eq!(
+ f.resolver.try_register_signer_spec(&f.market_id, &spec),
+ Err(Ok(Error::SpecHashMismatch))
+ );
+ }
+
+ /// Both preimages hash with sha256, so disjoint hashes follow from disjoint
+ /// preimages: a legacy preimage starts with the `ScValType::Vec` tag
+ /// (`00 00 00 10`) and a V2 preimage with ASCII `SPEC_DOMAIN`.
+ #[test]
+ fn v2_and_legacy_hash_spaces_disjoint() {
+ let f = v2_signer_fixture([9; 32]);
+ let env = &f.env;
+ let vec_tag = Bytes::from_array(env, &[0, 0, 0, 16]);
+ let domain_tag = Bytes::from_slice(env, &SPEC_DOMAIN.as_bytes()[..4]);
+ let mut seed = 0x9E37_79B9_7F4A_7C15_u64;
+ let mut next = move || {
+ seed ^= seed << 13;
+ seed ^= seed >> 7;
+ seed ^= seed << 17;
+ seed
+ };
+ for _ in 0..64 {
+ let rule = if next() % 2 == 0 {
+ ResolutionSpec::Price(PriceConfig {
+ reflector: f.reflector_id.clone(),
+ asset: OracleAsset::Other(Symbol::new(env, "XLM")),
+ threshold: (next() >> 1) as i128,
+ direction: PriceDirection::Above,
+ max_staleness: next() | 1,
+ })
+ } else {
+ ResolutionSpec::Signers(SignerConfig {
+ signers: Vec::from_array(env, [f.signer_a.clone()]),
+ threshold: 1,
+ dispute_window: next() | 1,
+ })
+ };
+ let salt = [(next() & 0xff) as u8 | 1; 32];
+ let spec = v2_envelope(env, &f.market_core.address, salt, &rule);
+ assert_ne!(legacy_spec_hash(env, &rule), spec_v2_hash(env, &spec));
+ assert_eq!(rule.to_xdr(env).slice(0..4), vec_tag);
+ assert_ne!(domain_tag, vec_tag);
+ }
+ }
+
+ // ----- expiry (liveness fallback) -------------------------------------
+
+ /// `close_time` in the fixture is 2_000.
+ const DEADLINE: u64 = 2_000 + RESOLUTION_GRACE_SECS;
+
+ fn signer_fixture() -> Fixture {
+ let f = fixture(|env, _r, a, b| ResolutionSpec::Signers(signer_spec(env, a, b)));
+ let ResolutionSpec::Signers(spec) = f.spec.clone() else {
+ unreachable!()
+ };
+ f.resolver.register_signer_spec(&f.market_id, &spec);
+ f
+ }
+
+ fn price_fixture(price: i128, price_timestamp: u64) -> Fixture {
let f = fixture(|env, reflector, _a, _b| ResolutionSpec::Price(price_spec(env, reflector)));
- let not_admin = Address::generate(&f.env);
- f.env.set_auths(&[]);
- assert!(f
- .resolver
- .mock_auths(&[MockAuth {
- address: ¬_admin,
- invoke: &MockAuthInvoke {
- contract: &f.resolver_id,
- fn_name: "guardian_void_market",
- args: (f.market_id,).into_val(&f.env),
- sub_invokes: &[],
- },
- }])
- .try_guardian_void_market(&f.market_id)
- .is_err());
- let _ = f.admin;
+ let ResolutionSpec::Price(spec) = f.spec.clone() else {
+ unreachable!()
+ };
+ MockReflectorClient::new(&f.env, &f.reflector_id).set_price(&price, &price_timestamp);
+ f.resolver.register_price_spec(&f.market_id, &spec);
+ f
+ }
+
+ fn assert_expired_event(f: &Fixture, mode: ExpiryMode) {
+ let expected = MarketExpired {
+ market_id: f.market_id,
+ close_time: 2_000,
+ deadline: DEADLINE,
+ mode,
+ };
+ let events = f.env.events().all().filter_by_contract(&f.resolver_id);
+ assert_eq!(
+ events,
+ [soroban_sdk::Event::to_xdr(
+ &expected,
+ &f.env,
+ &f.resolver_id
+ )]
+ );
+ assert_eq!(f.resolver.state(&f.market_id), ResolutionState::Expired);
+ assert_eq!(
+ f.market_core.get_market(&f.market_id).status,
+ MarketStatus::Void
+ );
+ }
+
+ #[test]
+ fn resolution_deadline_is_close_time_plus_grace() {
+ let f = fixture(|env, reflector, _a, _b| ResolutionSpec::Price(price_spec(env, reflector)));
+ assert_eq!(f.resolver.resolution_deadline(&f.market_id), DEADLINE);
+ }
+
+ #[test]
+ fn expire_rejects_before_deadline() {
+ let f = fixture(|env, reflector, _a, _b| ResolutionSpec::Price(price_spec(env, reflector)));
+ f.env.ledger().set_timestamp(DEADLINE - 1);
+ assert_eq!(
+ f.resolver.try_expire_market(&f.market_id),
+ Err(Ok(Error::DeadlineNotReached))
+ );
+ }
+
+ #[test]
+ fn expire_voids_unrevealed_market_after_deadline() {
+ let f = fixture(|env, reflector, _a, _b| ResolutionSpec::Price(price_spec(env, reflector)));
+ f.env.ledger().set_timestamp(DEADLINE);
+ f.resolver.expire_market(&f.market_id);
+ assert_expired_event(&f, ExpiryMode::Unrevealed);
+ }
+
+ #[test]
+ fn expire_resolves_price_market_when_feed_is_valid() {
+ let f = price_fixture(2_500, 1_999);
+ f.env.ledger().set_timestamp(DEADLINE);
+ f.resolver.expire_market(&f.market_id);
+ assert_eq!(
+ f.market_core.get_market(&f.market_id).status,
+ MarketStatus::Resolved(Outcome::Yes)
+ );
+ assert_eq!(f.resolver.state(&f.market_id), ResolutionState::Finalized);
+ }
+
+ #[test]
+ fn expire_voids_price_market_with_stale_feed() {
+ let f = price_fixture(2_500, 1);
+ f.env.ledger().set_timestamp(DEADLINE);
+ f.resolver.expire_market(&f.market_id);
+ assert_expired_event(&f, ExpiryMode::Price);
+ }
+
+ #[test]
+ fn expire_voids_price_market_whose_feed_traps() {
+ // `a` is a generated address with no contract deployed, so the feed
+ // call fails inside the host.
+ let f = fixture(|env, _reflector, a, _b| ResolutionSpec::Price(price_spec(env, a)));
+ let ResolutionSpec::Price(spec) = f.spec.clone() else {
+ unreachable!()
+ };
+ f.resolver.register_price_spec(&f.market_id, &spec);
+ f.env.ledger().set_timestamp(DEADLINE);
+ f.resolver.expire_market(&f.market_id);
+ assert_expired_event(&f, ExpiryMode::Price);
+ }
+
+ #[test]
+ fn expire_refuses_when_signer_proposal_is_finalizable() {
+ let f = signer_fixture();
+ f.env.ledger().set_timestamp(2_000);
+ f.resolver.attest(&f.market_id, &f.signer_a, &Outcome::No);
+ f.resolver.attest(&f.market_id, &f.signer_b, &Outcome::No);
+
+ f.env.ledger().set_timestamp(DEADLINE);
+ assert_eq!(
+ f.resolver.try_expire_market(&f.market_id),
+ Err(Ok(Error::ResolutionAvailable))
+ );
+ assert_eq!(f.resolver.finalize_signers(&f.market_id), Outcome::No);
+ }
+
+ #[test]
+ fn expire_waits_for_open_dispute_window() {
+ let f = signer_fixture();
+ f.env.ledger().set_timestamp(DEADLINE - 50);
+ f.resolver.attest(&f.market_id, &f.signer_a, &Outcome::Yes);
+ f.resolver.attest(&f.market_id, &f.signer_b, &Outcome::Yes);
+
+ // Deadline passes with 50s of the 100s dispute window left.
+ f.env.ledger().set_timestamp(DEADLINE);
+ assert_eq!(
+ f.resolver.try_expire_market(&f.market_id),
+ Err(Ok(Error::ResolutionAvailable))
+ );
+ f.env.ledger().set_timestamp(DEADLINE + 50);
+ assert_eq!(f.resolver.finalize_signers(&f.market_id), Outcome::Yes);
+ }
+
+ #[test]
+ fn expire_voids_signer_market_once_proposal_is_cancelled() {
+ let f = signer_fixture();
+ f.env.ledger().set_timestamp(2_000);
+ f.resolver.attest(&f.market_id, &f.signer_a, &Outcome::No);
+ f.resolver.attest(&f.market_id, &f.signer_b, &Outcome::No);
+ f.resolver.cancel_proposal(&f.market_id);
+
+ f.env.ledger().set_timestamp(DEADLINE);
+ f.resolver.expire_market(&f.market_id);
+ assert_expired_event(&f, ExpiryMode::Signers);
+ }
+
+ #[test]
+ fn register_spec_rejected_after_expiry() {
+ let f = fixture(|env, _r, a, b| ResolutionSpec::Signers(signer_spec(env, a, b)));
+ f.env.ledger().set_timestamp(DEADLINE);
+ f.resolver.expire_market(&f.market_id);
+ let ResolutionSpec::Signers(spec) = f.spec.clone() else {
+ unreachable!()
+ };
+ assert_eq!(
+ f.resolver.try_register_signer_spec(&f.market_id, &spec),
+ Err(Ok(Error::AlreadyFinalized))
+ );
+ }
+
+ #[test]
+ fn attest_rejected_after_expiry() {
+ let f = signer_fixture();
+ f.env.ledger().set_timestamp(DEADLINE);
+ f.resolver.expire_market(&f.market_id);
+ assert_eq!(
+ f.resolver
+ .try_attest(&f.market_id, &f.signer_a, &Outcome::Yes),
+ Err(Ok(Error::AlreadyFinalized))
+ );
+ }
+
+ #[test]
+ fn guardian_void_rejected_after_expiry() {
+ let f = signer_fixture();
+ f.env.ledger().set_timestamp(DEADLINE);
+ f.resolver.expire_market(&f.market_id);
+ assert_eq!(
+ f.resolver.try_guardian_void_market(&f.market_id),
+ Err(Ok(Error::AlreadyFinalized))
+ );
+ assert_eq!(
+ f.resolver.try_expire_market(&f.market_id),
+ Err(Ok(Error::AlreadyFinalized))
+ );
+ }
+
+ #[test]
+ fn expire_and_resolve_price_same_ledger_second_call_fails() {
+ let f = price_fixture(2_500, 1_999);
+ f.env.ledger().set_timestamp(DEADLINE - 1);
+ assert_eq!(
+ f.resolver.try_expire_market(&f.market_id),
+ Err(Ok(Error::DeadlineNotReached))
+ );
+ assert_eq!(f.resolver.resolve_price(&f.market_id), Outcome::Yes);
+
+ // The same timestamp is now past the deadline; whoever runs second loses.
+ f.env.ledger().set_timestamp(DEADLINE);
+ assert_eq!(
+ f.resolver.try_expire_market(&f.market_id),
+ Err(Ok(Error::AlreadyFinalized))
+ );
+ assert_eq!(
+ f.resolver.try_resolve_price(&f.market_id),
+ Err(Ok(Error::AlreadyFinalized))
+ );
+ }
+
+ #[test]
+ fn expire_is_permissionless() {
+ let f = fixture(|env, reflector, _a, _b| ResolutionSpec::Price(price_spec(env, reflector)));
+ f.env.ledger().set_timestamp(DEADLINE);
+ f.env.set_auths(&[]);
+ f.resolver.expire_market(&f.market_id);
+ assert_eq!(f.resolver.state(&f.market_id), ResolutionState::Expired);
+ }
+
+ // ----- weighted Ed25519 committee -------------------------------------
+
+ fn key(seed: u8) -> ed25519_dalek::SigningKey {
+ ed25519_dalek::SigningKey::from_bytes(&[seed; 32])
+ }
+
+ fn weighted_key(env: &Env, seed: u8, weight: u32) -> WeightedKey {
+ WeightedKey {
+ pubkey: BytesN::from_array(env, key(seed).verifying_key().as_bytes()),
+ weight,
+ }
+ }
+
+ fn weighted_config(env: &Env, weights: &[(u8, u32)], threshold: u32) -> WeightedKeyConfig {
+ let mut signers = Vec::new(env);
+ for (seed, weight) in weights {
+ signers.push_back(weighted_key(env, *seed, *weight));
+ }
+ WeightedKeyConfig {
+ signers,
+ weight_threshold: threshold,
+ dispute_window: 100,
+ }
+ }
+
+ /// Keys 1, 2, 3 with weights 2, 1, 1 (total 4) and threshold 3.
+ fn weighted_fixture() -> Fixture {
+ let f = fixture(|env, _r, _a, _b| {
+ ResolutionSpec::WeightedKeys(weighted_config(env, &[(1, 2), (2, 1), (3, 1)], 3))
+ });
+ let ResolutionSpec::WeightedKeys(spec) = f.spec.clone() else {
+ unreachable!()
+ };
+ f.resolver.register_weighted_key_spec(&f.market_id, &spec);
+ f.env.ledger().set_timestamp(2_000);
+ f
+ }
+
+ fn message_for(f: &Fixture, resolver: &Address, round: u32, outcome: &Outcome) -> Bytes {
+ let hash = f.market_core.get_market(&f.market_id).resolution_hash;
+ f.env.as_contract(resolver, || {
+ Resolver::attestation_message(&f.env, f.market_id, round, outcome, &hash)
+ })
+ }
+
+ fn sign_message(f: &Fixture, seed: u8, message: &Bytes) -> (BytesN<32>, BytesN<64>) {
+ use ed25519_dalek::Signer;
+ let signing = key(seed);
+ let signature = signing.sign(&bytes_to_vec(message)).to_bytes();
+ (
+ BytesN::from_array(&f.env, signing.verifying_key().as_bytes()),
+ BytesN::from_array(&f.env, &signature),
+ )
+ }
+
+ fn sign(f: &Fixture, seed: u8, round: u32, outcome: &Outcome) -> (BytesN<32>, BytesN<64>) {
+ sign_message(f, seed, &message_for(f, &f.resolver_id, round, outcome))
+ }
+
+ fn sigs(f: &Fixture, list: &[(BytesN<32>, BytesN<64>)]) -> Vec<(BytesN<32>, BytesN<64>)> {
+ Vec::from_slice(&f.env, list)
+ }
+
+ #[test]
+ fn weighted_keys_reach_threshold_and_finalize() {
+ let f = weighted_fixture();
+ let batch = sigs(
+ &f,
+ &[sign(&f, 1, 0, &Outcome::No), sign(&f, 2, 0, &Outcome::No)],
+ );
+ f.resolver
+ .submit_signatures(&f.market_id, &Outcome::No, &0, &batch);
+
+ assert_eq!(
+ f.resolver.try_finalize_signers(&f.market_id),
+ Err(Ok(Error::DisputeWindowOpen))
+ );
+ f.env.ledger().set_timestamp(2_100);
+ assert_eq!(f.resolver.finalize_signers(&f.market_id), Outcome::No);
+ assert_eq!(
+ f.market_core.get_market(&f.market_id).status,
+ MarketStatus::Resolved(Outcome::No)
+ );
+ }
+
+ #[test]
+ fn weights_accumulate_across_calls_and_never_restart_the_window() {
+ let f = weighted_fixture();
+ let one = |seed| sigs(&f, &[sign(&f, seed, 0, &Outcome::Yes)]);
+ f.resolver
+ .submit_signatures(&f.market_id, &Outcome::Yes, &0, &one(2));
+ f.resolver
+ .submit_signatures(&f.market_id, &Outcome::Yes, &0, &one(3));
+ assert_eq!(
+ f.resolver.try_finalize_signers(&f.market_id),
+ Err(Ok(Error::NoProposal))
+ );
+ f.resolver
+ .submit_signatures(&f.market_id, &Outcome::Yes, &0, &one(1));
+
+ // A later signature past the threshold must not push proposed_at out.
+ f.env.ledger().set_timestamp(2_050);
+ f.resolver
+ .submit_signatures(&f.market_id, &Outcome::Yes, &0, &one(1));
+ f.env.ledger().set_timestamp(2_100);
+ assert_eq!(f.resolver.finalize_signers(&f.market_id), Outcome::Yes);
+ }
+
+ #[test]
+ fn weighted_duplicate_sig_ignored() {
+ let f = weighted_fixture();
+ let dup = sign(&f, 1, 0, &Outcome::Yes);
+ let batch = sigs(&f, &[dup.clone(), dup.clone(), dup]);
+ f.resolver
+ .submit_signatures(&f.market_id, &Outcome::Yes, &0, &batch);
+ f.env.ledger().set_timestamp(2_100);
+ // Weight 2 of 3: three copies of one key must not reach the threshold.
+ assert_eq!(
+ f.resolver.try_finalize_signers(&f.market_id),
+ Err(Ok(Error::NoProposal))
+ );
+ }
+
+ #[test]
+ fn non_member_sig_ignored() {
+ let f = weighted_fixture();
+ let outsider = sign(&f, 99, 0, &Outcome::Yes);
+ f.resolver.submit_signatures(
+ &f.market_id,
+ &Outcome::Yes,
+ &0,
+ &sigs(&f, core::slice::from_ref(&outsider)),
+ );
+ assert_eq!(
+ f.resolver.try_finalize_signers(&f.market_id),
+ Err(Ok(Error::NoProposal))
+ );
+ let batch = sigs(
+ &f,
+ &[
+ outsider,
+ sign(&f, 1, 0, &Outcome::Yes),
+ sign(&f, 3, 0, &Outcome::Yes),
+ ],
+ );
+ f.resolver
+ .submit_signatures(&f.market_id, &Outcome::Yes, &0, &batch);
+ f.env.ledger().set_timestamp(2_100);
+ assert_eq!(f.resolver.finalize_signers(&f.market_id), Outcome::Yes);
+ }
+
+ #[test]
+ fn invalid_sig_aborts_batch() {
+ let f = weighted_fixture();
+ let forged = (
+ sign(&f, 2, 0, &Outcome::Yes).0,
+ sign(&f, 2, 0, &Outcome::No).1,
+ );
+ let batch = sigs(&f, &[sign(&f, 1, 0, &Outcome::Yes), forged]);
+ assert!(f
+ .resolver
+ .try_submit_signatures(&f.market_id, &Outcome::Yes, &0, &batch)
+ .is_err());
+
+ // Nothing from the aborted batch was recorded: key 1 can still sign.
+ let good = sigs(
+ &f,
+ &[sign(&f, 1, 0, &Outcome::Yes), sign(&f, 2, 0, &Outcome::Yes)],
+ );
+ f.resolver
+ .submit_signatures(&f.market_id, &Outcome::Yes, &0, &good);
+ f.env.ledger().set_timestamp(2_100);
+ assert_eq!(f.resolver.finalize_signers(&f.market_id), Outcome::Yes);
+ }
+
+ #[test]
+ fn stale_round_rejected_after_cancel() {
+ let f = weighted_fixture();
+ let old = sigs(
+ &f,
+ &[sign(&f, 1, 0, &Outcome::Yes), sign(&f, 2, 0, &Outcome::Yes)],
+ );
+ f.resolver
+ .submit_signatures(&f.market_id, &Outcome::Yes, &0, &old);
+ assert_eq!(f.resolver.current_round(&f.market_id), 0);
+
+ f.resolver.cancel_proposal(&f.market_id);
+ assert_eq!(f.resolver.current_round(&f.market_id), 1);
+ assert_eq!(
+ f.resolver
+ .try_submit_signatures(&f.market_id, &Outcome::Yes, &0, &old),
+ Err(Ok(Error::StaleRound))
+ );
+ // Relabelling the old signatures as round 1 fails verification.
+ assert!(f
+ .resolver
+ .try_submit_signatures(&f.market_id, &Outcome::Yes, &1, &old)
+ .is_err());
+
+ let fresh = sigs(
+ &f,
+ &[sign(&f, 1, 1, &Outcome::No), sign(&f, 3, 1, &Outcome::No)],
+ );
+ f.resolver
+ .submit_signatures(&f.market_id, &Outcome::No, &1, &fresh);
+ f.env.ledger().set_timestamp(2_100);
+ assert_eq!(f.resolver.finalize_signers(&f.market_id), Outcome::No);
+ }
+
+ #[test]
+ fn key_cannot_sign_both_outcomes() {
+ let f = weighted_fixture();
+ let yes = sigs(&f, &[sign(&f, 1, 0, &Outcome::Yes)]);
+ f.resolver
+ .submit_signatures(&f.market_id, &Outcome::Yes, &0, &yes);
+ // Key 1 (weight 2) already spent its vote on Yes; with it counted for
+ // No as well, keys 1 + 3 would reach the threshold of 3.
+ let no = sigs(
+ &f,
+ &[sign(&f, 1, 0, &Outcome::No), sign(&f, 3, 0, &Outcome::No)],
+ );
+ f.resolver
+ .submit_signatures(&f.market_id, &Outcome::No, &0, &no);
+ f.env.ledger().set_timestamp(2_100);
+ assert_eq!(
+ f.resolver.try_finalize_signers(&f.market_id),
+ Err(Ok(Error::NoProposal))
+ );
+ }
+
+ #[test]
+ fn wrong_network_signature_fails() {
+ let f = weighted_fixture();
+ f.env.ledger().set_network_id([8; 32]);
+ let foreign = sigs(&f, &[sign(&f, 1, 0, &Outcome::Yes)]);
+ f.env.ledger().set_network_id(NETWORK_ID);
+ assert!(f
+ .resolver
+ .try_submit_signatures(&f.market_id, &Outcome::Yes, &0, &foreign)
+ .is_err());
+ }
+
+ #[test]
+ fn wrong_resolver_signature_fails() {
+ let f = weighted_fixture();
+ let other = f
+ .env
+ .register(Resolver, (f.admin.clone(), f.market_core.address.clone()));
+ let message = message_for(&f, &other, 0, &Outcome::Yes);
+ let foreign = sigs(&f, &[sign_message(&f, 1, &message)]);
+ assert!(f
+ .resolver
+ .try_submit_signatures(&f.market_id, &Outcome::Yes, &0, &foreign)
+ .is_err());
+ }
+
+ #[test]
+ fn submit_signatures_guards() {
+ let f = weighted_fixture();
+ let batch = sigs(&f, &[sign(&f, 1, 0, &Outcome::Yes)]);
+
+ f.env.ledger().set_timestamp(1_999);
+ assert_eq!(
+ f.resolver
+ .try_submit_signatures(&f.market_id, &Outcome::Yes, &0, &batch),
+ Err(Ok(Error::MarketNotClosed))
+ );
+ f.env.ledger().set_timestamp(2_000);
+
+ let mut too_many = Vec::new(&f.env);
+ for _ in 0..=MAX_SIGS_PER_CALL {
+ too_many.push_back(batch.get(0).unwrap());
+ }
+ assert_eq!(
+ f.resolver
+ .try_submit_signatures(&f.market_id, &Outcome::Yes, &0, &too_many),
+ Err(Ok(Error::TooManySignatures))
+ );
+
+ f.env.ledger().set_timestamp(DEADLINE);
+ f.resolver.expire_market(&f.market_id);
+ assert_eq!(
+ f.resolver
+ .try_submit_signatures(&f.market_id, &Outcome::Yes, &0, &batch),
+ Err(Ok(Error::AlreadyFinalized))
+ );
+ }
+
+ #[test]
+ fn expire_refuses_a_pending_weighted_proposal() {
+ let f = weighted_fixture();
+ let batch = sigs(
+ &f,
+ &[sign(&f, 1, 0, &Outcome::Yes), sign(&f, 2, 0, &Outcome::Yes)],
+ );
+ f.resolver
+ .submit_signatures(&f.market_id, &Outcome::Yes, &0, &batch);
+ f.env.ledger().set_timestamp(DEADLINE);
+ assert_eq!(
+ f.resolver.try_expire_market(&f.market_id),
+ Err(Ok(Error::ResolutionAvailable))
+ );
+ assert_eq!(f.resolver.finalize_signers(&f.market_id), Outcome::Yes);
+ }
+
+ #[test]
+ fn max_sigs_per_call_budget() {
+ let seeds: std::vec::Vec = (1..=MAX_WEIGHTED_SIGNERS as u8).collect();
+ let weights: std::vec::Vec<(u8, u32)> = seeds.iter().map(|s| (*s, 1)).collect();
+ let f = fixture(|env, _r, _a, _b| {
+ ResolutionSpec::WeightedKeys(weighted_config(env, &weights, 17))
+ });
+ let ResolutionSpec::WeightedKeys(spec) = f.spec.clone() else {
+ unreachable!()
+ };
+ f.resolver.register_weighted_key_spec(&f.market_id, &spec);
+ f.env.ledger().set_timestamp(2_000);
+
+ let message = message_for(&f, &f.resolver_id, 0, &Outcome::Yes);
+ let batch: std::vec::Vec<_> = seeds
+ .iter()
+ .map(|seed| sign_message(&f, *seed, &message))
+ .collect();
+ f.env.cost_estimate().budget().reset_unlimited();
+ f.resolver
+ .submit_signatures(&f.market_id, &Outcome::Yes, &0, &sigs(&f, &batch));
+
+ let used = f.env.cost_estimate().resources();
+ std::println!(
+ "submit_signatures x{}: {} instructions, {} memory bytes",
+ MAX_SIGS_PER_CALL,
+ used.instructions,
+ used.mem_bytes
+ );
+ // Recorded for 32 signatures: 17.5M instructions / 1.7 MB here (native
+ // contract) and 23.1M / 2.9 MB with the compiled wasm resolver and
+ // market-core, i.e. under 6% / 7% of the limits. Network limits per
+ // transaction (`stellar network settings`, testnet and mainnet):
+ // 400M instructions, 40 MiB memory; stay under 60% of both.
+ assert!(used.instructions <= 240_000_000);
+ assert!(used.mem_bytes <= 25_165_824);
+ }
+
+ #[test]
+ fn register_weighted_validates_config() {
+ let f = fixture(|env, _r, _a, _b| {
+ ResolutionSpec::WeightedKeys(weighted_config(env, &[(1, 1)], 1))
+ });
+ let env = &f.env;
+ let duplicate_keys = weighted_config(env, &[(1, 1), (1, 1)], 1);
+ let threshold_above_total = weighted_config(env, &[(1, 1), (2, 1)], 3);
+ let zero_weight = weighted_config(env, &[(1, 0), (2, 1)], 1);
+ let zero_threshold = weighted_config(env, &[(1, 1)], 0);
+ let weight_overflow = weighted_config(env, &[(1, u32::MAX), (2, 1)], 1);
+ let mut empty = weighted_config(env, &[], 1);
+ let mut zero_window = weighted_config(env, &[(1, 1)], 1);
+ zero_window.dispute_window = 0;
+ let too_many: std::vec::Vec<(u8, u32)> = (1..=MAX_WEIGHTED_SIGNERS as u8 + 1)
+ .map(|s| (s, 1))
+ .collect();
+ let over_cap = weighted_config(env, &too_many, 1);
+ empty.dispute_window = 100;
+
+ for invalid in [
+ duplicate_keys,
+ threshold_above_total,
+ zero_weight,
+ zero_threshold,
+ weight_overflow,
+ empty,
+ zero_window,
+ over_cap,
+ ] {
+ // Validation runs before the commitment check, so the hash is moot.
+ assert_eq!(
+ f.resolver
+ .try_register_weighted_key_spec(&f.market_id, &invalid),
+ Err(Ok(Error::InvalidConfig))
+ );
+ }
+ assert_eq!(
+ f.resolver
+ .try_register_weighted_key_spec(&f.market_id, &weighted_config(env, &[(2, 1)], 1)),
+ Err(Ok(Error::SpecHashMismatch))
+ );
+ }
+
+ // ----- bonded disputes -------------------------------------------------
+
+ const BOND: i128 = 25 * 10_000_000;
+
+ struct DisputeFixture {
+ f: Fixture,
+ disputer: Address,
+ members: std::vec::Vec,
+ }
+
+ /// A `SignersDisputable` market (signers a, b, threshold 2, window 100,
+ /// escalation timeout 1_000) whose signers proposed `No` at t = 2_000.
+ /// `committee` selects a 2-of-3 escalation committee over the guardian.
+ fn dispute_fixture(committee: bool) -> DisputeFixture {
+ let members = std::cell::RefCell::new(std::vec::Vec::new());
+ let f = fixture(|env, _r, a, b| {
+ let escalation = if committee {
+ let list: std::vec::Vec = (0..3).map(|_| Address::generate(env)).collect();
+ members.borrow_mut().extend(list.iter().cloned());
+ Escalation::Committee(Vec::from_slice(env, &list), 2)
+ } else {
+ Escalation::Guardian
+ };
+ ResolutionSpec::SignersDisputable(DisputableSignerConfig {
+ signers: Vec::from_array(env, [a.clone(), b.clone()]),
+ threshold: 2,
+ dispute_window: 100,
+ dispute_bond: BOND,
+ escalation,
+ escalation_timeout: 1_000,
+ })
+ });
+ let ResolutionSpec::SignersDisputable(spec) = f.spec.clone() else {
+ unreachable!()
+ };
+ f.resolver.register_disputable_spec(&f.market_id, &spec);
+ f.env.ledger().set_timestamp(2_000);
+ f.resolver.attest(&f.market_id, &f.signer_a, &Outcome::No);
+ f.resolver.attest(&f.market_id, &f.signer_b, &Outcome::No);
+
+ let disputer = Address::generate(&f.env);
+ let collateral = f.market_core.get_config().collateral;
+ StellarAssetClient::new(&f.env, &collateral).mint(&disputer, &(2 * BOND));
+ DisputeFixture {
+ f,
+ disputer,
+ members: members.into_inner(),
+ }
+ }
+
+ fn balance(d: &DisputeFixture, who: &Address) -> i128 {
+ token::Client::new(&d.f.env, &d.f.market_core.get_config().collateral).balance(who)
+ }
+
+ /// Solvency: the resolver holds exactly the bonds it has not paid out.
+ fn assert_escrow(d: &DisputeFixture, expected: i128) {
+ assert_eq!(balance(d, &d.f.resolver_id), expected);
+ }
+
+ fn open(d: &DisputeFixture) {
+ d.f.resolver
+ .dispute(&d.f.market_id, &d.disputer, &Some(Outcome::Yes));
+ assert_escrow(d, BOND);
+ assert_eq!(
+ d.f.resolver.state(&d.f.market_id),
+ ResolutionState::Disputed
+ );
+ }
+
+ fn status(d: &DisputeFixture) -> MarketStatus {
+ d.f.market_core.get_market(&d.f.market_id).status
+ }
+
+ #[test]
+ fn dispute_freezes_finalization() {
+ let d = dispute_fixture(false);
+ open(&d);
+ d.f.env.ledger().set_timestamp(2_100);
+ assert_eq!(
+ d.f.resolver.try_finalize_signers(&d.f.market_id),
+ Err(Ok(Error::DisputeActive))
+ );
+ assert_eq!(status(&d), MarketStatus::Open);
+ }
+
+ #[test]
+ fn dispute_after_window_rejected() {
+ let d = dispute_fixture(false);
+ d.f.env.ledger().set_timestamp(2_500);
+ assert_eq!(
+ d.f.resolver.try_dispute(&d.f.market_id, &d.disputer, &None),
+ Err(Ok(Error::DisputeWindowClosed))
+ );
+ assert_escrow(&d, 0);
+ }
+
+ #[test]
+ fn dispute_at_exact_window_end() {
+ let d = dispute_fixture(false);
+ // At proposed_at + dispute_window finalization opens and disputes close.
+ d.f.env.ledger().set_timestamp(2_100);
+ assert_eq!(
+ d.f.resolver.try_dispute(&d.f.market_id, &d.disputer, &None),
+ Err(Ok(Error::DisputeWindowClosed))
+ );
+ assert_eq!(d.f.resolver.finalize_signers(&d.f.market_id), Outcome::No);
+
+ let d = dispute_fixture(false);
+ d.f.env.ledger().set_timestamp(2_099);
+ d.f.resolver.dispute(&d.f.market_id, &d.disputer, &None);
+ assert_escrow(&d, BOND);
+ }
+
+ #[test]
+ fn double_dispute_rejected() {
+ let d = dispute_fixture(false);
+ open(&d);
+ assert_eq!(
+ d.f.resolver.try_dispute(&d.f.market_id, &d.disputer, &None),
+ Err(Ok(Error::DisputeActive))
+ );
+ assert_escrow(&d, BOND);
+ }
+
+ #[test]
+ fn dispute_preconditions() {
+ let d = dispute_fixture(false);
+ assert_eq!(
+ d.f.resolver
+ .try_dispute(&d.f.market_id, &d.disputer, &Some(Outcome::No)),
+ Err(Ok(Error::InvalidConfig))
+ );
+ d.f.resolver.cancel_proposal(&d.f.market_id);
+ assert_eq!(
+ d.f.resolver.try_dispute(&d.f.market_id, &d.disputer, &None),
+ Err(Ok(Error::NoProposal))
+ );
+
+ let legacy = signer_fixture();
+ assert_eq!(
+ legacy
+ .resolver
+ .try_dispute(&legacy.market_id, &legacy.signer_a, &None),
+ Err(Ok(Error::MarketNotConfigured))
+ );
+ }
+
+ #[test]
+ fn guardian_upholds_proposal_disputer_loses_bond() {
+ let d = dispute_fixture(false);
+ open(&d);
+ let treasury = d.f.market_core.get_config().treasury;
+ d.f.resolver.settle_dispute(&d.f.market_id, &Verdict::No);
+ assert_eq!(status(&d), MarketStatus::Resolved(Outcome::No));
+ assert_eq!(
+ d.f.resolver.state(&d.f.market_id),
+ ResolutionState::Finalized
+ );
+ assert_escrow(&d, BOND);
+
+ d.f.resolver.claim_dispute_bond(&d.f.market_id);
+ assert_escrow(&d, 0);
+ assert_eq!(balance(&d, &treasury), BOND);
+ assert_eq!(balance(&d, &d.disputer), BOND);
+ assert_eq!(
+ d.f.resolver.try_claim_dispute_bond(&d.f.market_id),
+ Err(Ok(Error::NoDispute))
+ );
+ }
+
+ #[test]
+ fn guardian_overturns_proposal_disputer_refunded() {
+ let d = dispute_fixture(false);
+ open(&d);
+ d.f.resolver.settle_dispute(&d.f.market_id, &Verdict::Yes);
+ assert_eq!(status(&d), MarketStatus::Resolved(Outcome::Yes));
+ d.f.resolver.claim_dispute_bond(&d.f.market_id);
+ assert_escrow(&d, 0);
+ assert_eq!(balance(&d, &d.disputer), 2 * BOND);
+ }
+
+ #[test]
+ fn guardian_rules_invalid_market_voided() {
+ let d = dispute_fixture(false);
+ open(&d);
+ d.f.resolver
+ .settle_dispute(&d.f.market_id, &Verdict::Invalid);
+ assert_eq!(status(&d), MarketStatus::Void);
+ d.f.resolver.claim_dispute_bond(&d.f.market_id);
+ assert_eq!(balance(&d, &d.disputer), 2 * BOND);
+ assert_escrow(&d, 0);
+ }
+
+ #[test]
+ fn settlement_and_claim_require_an_open_then_settled_dispute() {
+ let d = dispute_fixture(false);
+ assert_eq!(
+ d.f.resolver
+ .try_settle_dispute(&d.f.market_id, &Verdict::Yes),
+ Err(Ok(Error::NoDispute))
+ );
+ open(&d);
+ assert_eq!(
+ d.f.resolver.try_claim_dispute_bond(&d.f.market_id),
+ Err(Ok(Error::DisputeActive))
+ );
+ assert_eq!(
+ d.f.resolver
+ .try_escalation_attest(&d.f.market_id, &d.disputer, &Verdict::Yes),
+ Err(Ok(Error::WrongMode))
+ );
+ d.f.resolver.settle_dispute(&d.f.market_id, &Verdict::Yes);
+ assert_eq!(
+ d.f.resolver
+ .try_settle_dispute(&d.f.market_id, &Verdict::No),
+ Err(Ok(Error::AlreadyFinalized))
+ );
+ }
+
+ #[test]
+ fn committee_escalation_reaches_threshold() {
+ let d = dispute_fixture(true);
+ open(&d);
+ assert_eq!(
+ d.f.resolver
+ .try_settle_dispute(&d.f.market_id, &Verdict::Yes),
+ Err(Ok(Error::WrongMode))
+ );
+ d.f.resolver
+ .escalation_attest(&d.f.market_id, &d.members[0], &Verdict::Yes);
+ d.f.resolver
+ .escalation_attest(&d.f.market_id, &d.members[1], &Verdict::No);
+ assert_eq!(status(&d), MarketStatus::Open);
+ d.f.resolver
+ .escalation_attest(&d.f.market_id, &d.members[2], &Verdict::Yes);
+ assert_eq!(status(&d), MarketStatus::Resolved(Outcome::Yes));
+ d.f.resolver.claim_dispute_bond(&d.f.market_id);
+ assert_eq!(balance(&d, &d.disputer), 2 * BOND);
+ assert_escrow(&d, 0);
+ }
+
+ #[test]
+ fn non_member_cannot_escalation_attest() {
+ let d = dispute_fixture(true);
+ open(&d);
+ assert_eq!(
+ d.f.resolver
+ .try_escalation_attest(&d.f.market_id, &d.f.signer_a, &Verdict::No),
+ Err(Ok(Error::NotSigner))
+ );
+ }
+
+ #[test]
+ fn member_cannot_vote_twice() {
+ let d = dispute_fixture(true);
+ open(&d);
+ d.f.resolver
+ .escalation_attest(&d.f.market_id, &d.members[0], &Verdict::No);
+ assert_eq!(
+ d.f.resolver
+ .try_escalation_attest(&d.f.market_id, &d.members[0], &Verdict::Yes),
+ Err(Ok(Error::AlreadyAttested))
+ );
+ assert_eq!(status(&d), MarketStatus::Open);
+ }
+
+ #[test]
+ fn committee_vote_needs_an_open_dispute() {
+ let d = dispute_fixture(true);
+ assert_eq!(
+ d.f.resolver
+ .try_escalation_attest(&d.f.market_id, &d.members[0], &Verdict::No),
+ Err(Ok(Error::NoDispute))
+ );
+ }
+
+ #[test]
+ fn escalation_timeout_voids_and_refunds() {
+ let d = dispute_fixture(false);
+ d.f.env.ledger().set_timestamp(2_050);
+ open(&d);
+ d.f.env.ledger().set_timestamp(3_049);
+ assert_eq!(
+ d.f.resolver.try_expire_dispute(&d.f.market_id),
+ Err(Ok(Error::DeadlineNotReached))
+ );
+ d.f.env.ledger().set_timestamp(3_050);
+ d.f.env.set_auths(&[]);
+ d.f.resolver.expire_dispute(&d.f.market_id);
+ assert_eq!(status(&d), MarketStatus::Void);
+ assert_eq!(
+ d.f.resolver.state(&d.f.market_id),
+ ResolutionState::Finalized
+ );
+ d.f.resolver.claim_dispute_bond(&d.f.market_id);
+ assert_eq!(balance(&d, &d.disputer), 2 * BOND);
+ assert_escrow(&d, 0);
+ }
+
+ #[test]
+ fn attest_blocked_while_disputed() {
+ let d = dispute_fixture(false);
+ open(&d);
+ assert_eq!(
+ d.f.resolver
+ .try_attest(&d.f.market_id, &d.f.signer_a, &Outcome::Yes),
+ Err(Ok(Error::DisputeActive))
+ );
+ }
+
+ #[test]
+ fn cancel_proposal_blocked_while_disputed() {
+ let d = dispute_fixture(false);
+ open(&d);
+ assert_eq!(
+ d.f.resolver.try_cancel_proposal(&d.f.market_id),
+ Err(Ok(Error::DisputeActive))
+ );
+ }
+
+ #[test]
+ fn expire_market_blocked_while_disputed() {
+ let d = dispute_fixture(false);
+ open(&d);
+ d.f.env.ledger().set_timestamp(DEADLINE);
+ assert_eq!(
+ d.f.resolver.try_expire_market(&d.f.market_id),
+ Err(Ok(Error::DisputeActive))
+ );
+ }
+
+ #[test]
+ fn guardian_void_while_disputed_refunds_bond() {
+ let d = dispute_fixture(false);
+ open(&d);
+ d.f.resolver.guardian_void_market(&d.f.market_id);
+ assert_eq!(status(&d), MarketStatus::Void);
+ d.f.resolver.claim_dispute_bond(&d.f.market_id);
+ assert_eq!(balance(&d, &d.disputer), 2 * BOND);
+ assert_escrow(&d, 0);
+ }
+
+ #[test]
+ fn undisputed_disputable_market_finalizes_normally() {
+ let d = dispute_fixture(false);
+ d.f.env.ledger().set_timestamp(2_100);
+ assert_eq!(d.f.resolver.finalize_signers(&d.f.market_id), Outcome::No);
+ assert_escrow(&d, 0);
+ }
+
+ #[test]
+ fn wrong_caller_cannot_settle_dispute() {
+ let d = dispute_fixture(false);
+ open(&d);
+ let intruder = Address::generate(&d.f.env);
+ d.f.env.set_auths(&[]);
+ assert!(d
+ .f
+ .resolver
+ .mock_auths(&[MockAuth {
+ address: &intruder,
+ invoke: &MockAuthInvoke {
+ contract: &d.f.resolver_id,
+ fn_name: "settle_dispute",
+ args: (d.f.market_id, Verdict::No).into_val(&d.f.env),
+ sub_invokes: &[],
+ }
+ }])
+ .try_settle_dispute(&d.f.market_id, &Verdict::No)
+ .is_err());
+
+ // Admin auth for this exact call succeeds.
+ d.f.resolver
+ .mock_auths(&[MockAuth {
+ address: &d.f.admin,
+ invoke: &MockAuthInvoke {
+ contract: &d.f.resolver_id,
+ fn_name: "settle_dispute",
+ args: (d.f.market_id, Verdict::No).into_val(&d.f.env),
+ sub_invokes: &[],
+ },
+ }])
+ .settle_dispute(&d.f.market_id, &Verdict::No);
+ assert_eq!(status(&d), MarketStatus::Resolved(Outcome::No));
+ }
+
+ #[test]
+ fn wrong_caller_cannot_vote_or_dispute_for_someone_else() {
+ let d = dispute_fixture(true);
+ let intruder = Address::generate(&d.f.env);
+ d.f.env.set_auths(&[]);
+ assert!(d
+ .f
+ .resolver
+ .mock_auths(&[MockAuth {
+ address: &intruder,
+ invoke: &MockAuthInvoke {
+ contract: &d.f.resolver_id,
+ fn_name: "dispute",
+ args: (d.f.market_id, d.disputer.clone(), None::).into_val(&d.f.env),
+ sub_invokes: &[],
+ },
+ }])
+ .try_dispute(&d.f.market_id, &d.disputer, &None)
+ .is_err());
+ assert_escrow(&d, 0);
+
+ d.f.env.mock_all_auths();
+ open(&d);
+ d.f.env.set_auths(&[]);
+ assert!(d
+ .f
+ .resolver
+ .mock_auths(&[MockAuth {
+ address: &intruder,
+ invoke: &MockAuthInvoke {
+ contract: &d.f.resolver_id,
+ fn_name: "escalation_attest",
+ args: (d.f.market_id, d.members[0].clone(), Verdict::Yes).into_val(&d.f.env),
+ sub_invokes: &[],
+ },
+ }])
+ .try_escalation_attest(&d.f.market_id, &d.members[0], &Verdict::Yes)
+ .is_err());
+ }
+
+ #[test]
+ fn register_disputable_validates_config() {
+ // A fresh market, so validation (which precedes the commitment check)
+ // is what rejects each config.
+ let f = fixture(|env, reflector, _a, _b| ResolutionSpec::Price(price_spec(env, reflector)));
+ let env = &f.env;
+ let outsider = Address::generate(env);
+ let valid = DisputableSignerConfig {
+ signers: Vec::from_array(env, [f.signer_a.clone(), f.signer_b.clone()]),
+ threshold: 2,
+ dispute_window: 100,
+ dispute_bond: BOND,
+ escalation: Escalation::Guardian,
+ escalation_timeout: 1_000,
+ };
+ let committee =
+ |members: &[Address], k: u32| Escalation::Committee(Vec::from_slice(env, members), k);
+ let with = |change: &dyn Fn(&mut DisputableSignerConfig)| {
+ let mut cfg = valid.clone();
+ change(&mut cfg);
+ cfg
+ };
+ for invalid in [
+ with(&|c| c.dispute_bond = 0),
+ with(&|c| c.escalation_timeout = 0),
+ with(&|c| c.dispute_window = 0),
+ with(&|c| c.threshold = 3),
+ with(&|c| c.escalation = committee(&[], 1)),
+ with(&|c| c.escalation = committee(core::slice::from_ref(&outsider), 2)),
+ // Overlaps the proposing signers.
+ with(&|c| c.escalation = committee(core::slice::from_ref(&f.signer_a), 1)),
+ with(&|c| c.escalation = committee(&[outsider.clone(), outsider.clone()], 2)),
+ ] {
+ assert_eq!(
+ f.resolver
+ .try_register_disputable_spec(&f.market_id, &invalid),
+ Err(Ok(Error::InvalidConfig))
+ );
+ }
+ assert_eq!(
+ f.resolver
+ .try_register_disputable_spec(&f.market_id, &valid),
+ Err(Ok(Error::SpecHashMismatch))
+ );
+ }
+
+ #[test]
+ fn wrong_caller_cannot_use_guardian_functions() {
+ let f = fixture(|env, reflector, _a, _b| ResolutionSpec::Price(price_spec(env, reflector)));
+ let not_admin = Address::generate(&f.env);
+ f.env.set_auths(&[]);
+ assert!(f
+ .resolver
+ .mock_auths(&[MockAuth {
+ address: ¬_admin,
+ invoke: &MockAuthInvoke {
+ contract: &f.resolver_id,
+ fn_name: "guardian_void_market",
+ args: (f.market_id,).into_val(&f.env),
+ sub_invokes: &[],
+ },
+ }])
+ .try_guardian_void_market(&f.market_id)
+ .is_err());
+ let _ = f.admin;
+ }
+}
+
+#[cfg(test)]
+fn bytes_to_vec(bytes: &Bytes) -> std::vec::Vec {
+ let mut out = std::vec![0u8; bytes.len() as usize];
+ bytes.copy_into_slice(&mut out);
+ out
+}
+
+/// Pins the exact bytes behind every legacy `resolution_hash`. A change to
+/// field names, variant names or soroban-sdk's `to_xdr` encoding changes these
+/// hashes and strands every unrevealed market, so a failure here must never be
+/// "fixed" by editing the vectors: add a new `ResolutionSpec` variant instead
+/// (see `docs/adr/0004-spec-versioning.md`).
+#[cfg(test)]
+mod golden {
+ use super::*;
+ use std::{format, string::String};
+
+ const G1: &str = "GBAKBHXK7SV7C3HGY3DDYB4QWUEGL52SEF6OO77LAD672JVXD4QCYJLF";
+ const G2: &str = "GCKBJQ6633JFYSFV6HHF2FQUN4QUX5PVE63PODYIKO2CE3RCXAF5NZCW";
+ const G3: &str = "GAJ7BAGE252F636J2HNCRCSFLALJRBY3YQK6DE7Q7DWZECWIR2VOTYZL";
+ const C1: &str = "CC3KU5TWAATOBXLWIV4ZTXNEM44VIOY3ZTR7KTJTBOOBP7YIXMCDSWDJ";
+ const C2: &str = "CCILJXZCHKAIBTMJ7NSQE756OGLKTOYNSWNNZXWAYZZA4ECAUEC2CNVF";
+ const C3: &str = "CCAMJLLDELRB4PCMI5Z526NOEPM7IMUUY2MX6ERSUFOTQBALUGEXCREV";
+
+ fn signers(env: &Env, keys: &[&str], threshold: u32, dispute_window: u64) -> ResolutionSpec {
+ let mut signers = Vec::new(env);
+ for key in keys {
+ signers.push_back(Address::from_str(env, key));
+ }
+ ResolutionSpec::Signers(SignerConfig {
+ signers,
+ threshold,
+ dispute_window,
+ })
+ }
+
+ fn price(
+ env: &Env,
+ asset: OracleAsset,
+ threshold: i128,
+ direction: PriceDirection,
+ max_staleness: u64,
+ ) -> ResolutionSpec {
+ ResolutionSpec::Price(PriceConfig {
+ reflector: Address::from_str(env, C1),
+ asset,
+ threshold,
+ direction,
+ max_staleness,
+ })
+ }
+
+ fn hex_of(env: &Env, spec: &ResolutionSpec) -> String {
+ let hash: BytesN<32> = env.crypto().sha256(&spec.clone().to_xdr(env)).into();
+ hash.to_array().iter().map(|b| format!("{b:02x}")).collect()
+ }
+
+ #[test]
+ fn golden_legacy_signer_spec_hashes() {
+ let env = Env::default();
+ let vectors = [
+ (
+ signers(&env, &[G1], 1, 5),
+ "b935a3beeebdbf7b9defc916410f19a1740a3920623230152e41a812b6f141c4",
+ ),
+ (
+ signers(&env, &[G1, G2, G3], 2, 3_600),
+ "4373448f97b0dd998a70efd9a1bfd3b3b8639bebaf682d9fc083c11f248a99ee",
+ ),
+ ];
+ for (spec, expected) in vectors {
+ assert_eq!(hex_of(&env, &spec), expected);
+ }
+ }
+
+ #[test]
+ fn golden_legacy_price_spec_hashes() {
+ let env = Env::default();
+ let xlm = || OracleAsset::Other(Symbol::new(&env, "XLM"));
+ let asset = |id: &str| OracleAsset::Stellar(Address::from_str(&env, id));
+ let vectors = [
+ (
+ price(&env, xlm(), 2_000, PriceDirection::Above, 10),
+ "b6f2458326d5f76bd9b53a93a103ea6c0fed7129c5496e91e3faef9a9a23d469",
+ ),
+ (
+ price(&env, asset(C2), 100, PriceDirection::AtOrAbove, 300),
+ "fa4cb1bbf39e94a2e699906ddd7228bd0b23ff8c4e968828485dcf3c644dadc2",
+ ),
+ (
+ price(&env, xlm(), 0, PriceDirection::Below, 600),
+ "0236202a346acee0cc18c7c47e4e31b67efa559dc2ff6ac66783f9ea5254f03d",
+ ),
+ (
+ price(
+ &env,
+ asset(C3),
+ 7_500_000_000_000,
+ PriceDirection::AtOrBelow,
+ 86_400,
+ ),
+ "c9e1a0fb8443cdf1ea0190af347ff45f54bec12499f3b5111ff81b96b4794570",
+ ),
+ ];
+ for (spec, expected) in vectors {
+ assert_eq!(hex_of(&env, &spec), expected);
+ }
+ }
+
+ /// V2 golden vector, re-derived independently from raw XDR (no Soroban) in
+ /// `scripts/spec-hash` tests. The ledger network id is fixed explicitly so
+ /// the vector never depends on the test `Env` default.
+ #[test]
+ fn golden_v2_spec_hash() {
+ use soroban_sdk::testutils::Ledger;
+ let env = Env::default();
+ let testnet_id: BytesN<32> = env
+ .crypto()
+ .sha256(&Bytes::from_slice(
+ &env,
+ b"Test SDF Network ; September 2015",
+ ))
+ .into();
+ env.ledger().set_network_id(testnet_id.to_array());
+ let spec = ResolutionSpecV2 {
+ version: 2,
+ network_id: env.ledger().network_id(),
+ market_core: Address::from_str(&env, C2),
+ salt: BytesN::from_array(&env, &core::array::from_fn(|i| i as u8 + 1)),
+ rule: signers(&env, &[G1], 1, 5),
+ };
+ let hash = spec_v2_hash(&env, &spec);
+ assert_eq!(
+ hash.to_array()
+ .iter()
+ .map(|b| format!("{b:02x}"))
+ .collect::(),
+ "27e995544f9f631be41177890b7a1be7e17c667e4e531aa08e5089dd899566ae"
+ );
+ }
+
+ /// Shared byte-for-byte with `agents/core/attestation.test.ts`: the Rust
+ /// contract and the TypeScript signer must build the identical message.
+ #[test]
+ fn golden_attestation_message_and_signature() {
+ use ed25519_dalek::Signer;
+ use soroban_sdk::testutils::Ledger;
+ let env = Env::default();
+ let testnet_id: BytesN<32> = env
+ .crypto()
+ .sha256(&Bytes::from_slice(
+ &env,
+ b"Test SDF Network ; September 2015",
+ ))
+ .into();
+ env.ledger().set_network_id(testnet_id.to_array());
+ let resolver_id = env.register_at(
+ &Address::from_str(&env, C1),
+ Resolver,
+ (Address::from_str(&env, G1), Address::from_str(&env, C2)),
+ );
+ let spec_hash = BytesN::from_array(&env, &core::array::from_fn(|i| 0xa0 + i as u8));
+
+ let message = env.as_contract(&resolver_id, || {
+ Resolver::attestation_message(&env, 7, 3, &Outcome::No, &spec_hash)
+ });
+ let message_hex: String = bytes_to_vec(&message)
+ .iter()
+ .map(|b| format!("{b:02x}"))
+ .collect();
+ assert_eq!(message_hex, "6f7261636c656465736b2d6174746573742d7631cee0302d59844d32bdca915c8203dd44b33fbb7edc19051ea37abedf28ecd4720000001200000001b6aa76760026e0dd76457999dda46739543b1bcce3f54d330b9c17ff08bb043900000000000000070000000301a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf");
+
+ let signing = ed25519_dalek::SigningKey::from_bytes(&core::array::from_fn(|i| i as u8));
+ let signature_hex: String = signing
+ .sign(&bytes_to_vec(&message))
+ .to_bytes()
+ .iter()
+ .map(|b| format!("{b:02x}"))
+ .collect();
+ assert_eq!(signature_hex, "3f7bfc9882bbe894518d059b50326069523358f3da1d74b7ed90b8b395862367155ae6ac7934b3ae610e21c3dd595b7d99c2ab122037918917c96543c0cde002");
+ }
+
+ #[contracttype]
+ #[derive(Clone)]
+ struct DeclaredAlphabetically {
+ alpha: u32,
+ beta: u32,
+ }
+
+ #[contracttype]
+ #[derive(Clone)]
+ struct DeclaredReversed {
+ beta: u32,
+ alpha: u32,
+ }
+
+ /// `#[contracttype]` structs encode as an `ScMap` keyed by field *name*,
+ /// so declaration order is irrelevant but renaming a field changes the hash.
+ #[test]
+ fn xdr_encoding_depends_on_field_names_not_declaration_order() {
+ let env = Env::default();
+ let a = DeclaredAlphabetically { alpha: 1, beta: 2 }.to_xdr(&env);
+ let b = DeclaredReversed { beta: 2, alpha: 1 }.to_xdr(&env);
+ assert_eq!(a, b);
}
}
diff --git a/oracledesk-stellar/docs/STATUS.md b/oracledesk-stellar/docs/STATUS.md
index 4adba1b..27cd8da 100644
--- a/oracledesk-stellar/docs/STATUS.md
+++ b/oracledesk-stellar/docs/STATUS.md
@@ -206,6 +206,11 @@ markers in the files themselves:
independent agent deployments from trading against the same
`market-core` markets. The treasury's caps bound one deployment's own
risk, not systemic market behavior.
+- **Expiry cannot help an oracle the creator controls.** `expire_market`
+ voids unresolvable markets 30 days after close, but a creator who commits
+ to a feed they control can still decide the outcome of a *resolvable*
+ price market; the commitment does not bind oracle identity (ADR 0001,
+ "Liveness fallback").
- **Single admin key per contract**, no multisig/timelock. A compromised
admin key has a large blast radius per contract (see
`docs/wave-issues/12-architecture-deep-dive-and-threat-model.md`, which
diff --git a/oracledesk-stellar/docs/adr/0001-resolution-commitment.md b/oracledesk-stellar/docs/adr/0001-resolution-commitment.md
index 795fd98..b902ee0 100644
--- a/oracledesk-stellar/docs/adr/0001-resolution-commitment.md
+++ b/oracledesk-stellar/docs/adr/0001-resolution-commitment.md
@@ -46,5 +46,95 @@ otherwise collide.
are simply unresolvable through the normal path; `Resolver::guardian_void_market`
(admin-gated) exists as the escape hatch for that case.
- `Outcome`/`PriceConfig`/`SignerConfig` used inside `ResolutionSpec` must stay
- stable in field order and naming: XDR encoding is structural, so reordering
- fields changes the hash of every already-committed market's spec type.
+ stable in field *names* and types: XDR encoding is structural (structs encode
+ as a map sorted by field name), so renaming a field changes the hash of every
+ already-committed market's spec type. Golden vectors now pin this.
+
+## Update
+
+The commitment format is extended by [ADR 0004](0004-spec-versioning.md): a
+salted, versioned, domain-separated `ResolutionSpecV2` envelope accepted
+alongside the legacy format described above.
+
+## Liveness fallback
+
+A market whose spec is never revealed, whose committed feed is permanently
+unusable, or whose signers go silent would otherwise lock its collateral until
+the single guardian key acts. `Resolver::expire_market` removes that dependency.
+
+* **Deadline.** `close_time + RESOLUTION_GRACE_SECS` (30 days, a compile-time
+ constant so no admin can shorten it). It reads only market-core's
+ `close_time`, so it applies to unrevealed specs and to every market created
+ before this change, with no spec migration. `resolution_deadline(market_id)`
+ exposes it.
+* **Permissionless, liveness-first.** After the deadline anyone may call
+ `expire_market`. If the market can still resolve validly it does not void:
+ a price market whose feed is usable *right now* is resolved (not voided), and a
+ market with a pending signer proposal is refused with `ResolutionAvailable`
+ until `finalize_signers` settles it (a proposal always stays finalizable by
+ anyone). Only a market with nothing resolvable is voided, through
+ market-core's existing `void_market`; `MarketExpired` records the mode
+ (`Unrevealed`, `Price` or `Signers`) so indexers can tell it from a guardian void.
+* **Terminal state.** `ResolutionState::Expired` is terminal like `Finalized`:
+ reveals, attestations, `resolve_price` and `guardian_void_market` are refused
+ afterwards, and the state's TTL is extended whenever it is written.
+* **Griefing.** A void pays every holder half per share, so it is never a
+ free win for one side. Stalling `expire_market` cannot get a correctly
+ resolvable price market voided, because that call resolves it instead.
+ Stalling the feed is only possible for a creator who committed to an oracle
+ they control, a risk that already exists (the commitment does not bind
+ oracle identity) and is a known limitation to be addressed by the guardian
+ constraints and multi-oracle follow-up work. A guardian who cancels a
+ proposal shortly before the deadline can let expiry void a market whose
+ signers are still re-attesting; that is accepted by design.
+* **Unusable feed.** A Reflector call that traps is treated as
+ `PriceUnavailable`, so a broken oracle address cannot make expiry revert
+ forever; any other error (for example `NotInitialized`) is still surfaced.
+
+## Weighted-key attestations
+
+`ResolutionSpec::WeightedKeys` commits to a committee of raw Ed25519 public
+keys with weights. Members sign off-chain; anyone submits the signatures in
+one `resolver.submit_signatures` call, which verifies each with
+`env.crypto().ed25519_verify` and sums the weight per `(market, round, outcome)`.
+The host verifies with ed25519-dalek's `verify_strict`, which rejects a
+non-canonical `S`, weak (small-order) public keys and a small-order `R`, and
+traps on failure.
+
+The signed message is raw (never hashed first) and fixed by
+`Resolver::attestation_message`:
+
+```
+msg = "oracledesk-attest-v1" 20 ASCII bytes (domain tag)
+ || network_id 32 bytes, env.ledger().network_id()
+ || resolver address ScVal XDR of the resolver address (40 bytes for C...)
+ || market_id u64 big-endian
+ || round u32 big-endian
+ || outcome 1 byte: 0 = Yes, 1 = No
+ || resolution_hash 32 bytes, the market's commitment
+```
+
+Every field but the address is fixed-length and the address XDR is
+self-delimiting, so the encoding is unambiguous. `network_id` and the resolver
+address stop replay across networks and deployments, `market_id` across
+markets, `round` across guardian cancellations, and `resolution_hash` ties a
+signature to the exact committed committee. `cancel_proposal` increments the
+round, so signatures collected before a cancellation can never be reused.
+
+Rules the contract enforces:
+
+* A key counts once per round, for one outcome only (`SigSeen`), so a signer
+ cannot contribute weight to both outcomes.
+* Non-members and already-counted keys are skipped; **one invalid signature
+ aborts the whole batch** (`ed25519_verify` traps), so submitters pre-filter
+ with `agents/core/attestation.ts`.
+* The proposal (and its dispute window) is written only when the weight first
+ reaches the threshold; extra signatures never move `proposed_at`.
+* `MAX_WEIGHTED_SIGNERS = 32` keys and `MAX_SIGS_PER_CALL = 32` signatures. 32
+ signatures cost 23.1M instructions / 2.9 MB with the compiled wasm, under 6% /
+ 7% of the 400M-instruction / 40 MiB transaction limits.
+* Choose `weight_threshold` above half the total weight so two outcomes cannot
+ both reach it; if they could, the first to reach it owns the proposal.
+
+A `G...` account's raw key is `StrKey.decodeEd25519PublicKey(address)`;
+`C...` contract addresses have no Ed25519 key and cannot be committee members.
diff --git a/oracledesk-stellar/docs/adr/0004-spec-versioning.md b/oracledesk-stellar/docs/adr/0004-spec-versioning.md
new file mode 100644
index 0000000..db014ee
--- /dev/null
+++ b/oracledesk-stellar/docs/adr/0004-spec-versioning.md
@@ -0,0 +1,104 @@
+# ADR 0004: Versioned, salted, domain-separated resolution commitments
+
+## Status
+
+Accepted. Extends [ADR 0001](0001-resolution-commitment.md).
+
+## Context
+
+ADR 0001 commits a market to `resolution_hash = sha256(XDR(ResolutionSpec))`
+at `create_market` and checks it when the spec is revealed. Three weaknesses
+appear as markets grow:
+
+1. **Encoding drift.** The hash is a function of the exact XDR bytes of
+ `ResolutionSpec`. Renaming a field, changing a type, or a soroban-sdk
+ change to `to_xdr` silently changes every committed hash and strands every
+ unrevealed market. Nothing pinned the encoding.
+2. **Pre-reveal brute force.** Specs have very little entropy (known signer
+ addresses, threshold 1, round windows, known Reflector contracts). Anyone
+ can enumerate likely specs and match them against `resolution_hash` before
+ the reveal, learning which committee will decide and opening it to bribery.
+3. **No domain separation.** The hash binds neither the network nor the
+ market-core instance. A commitment observed on one deployment is valid on
+ another, and two markets with an identical rule share a hash.
+
+## Decision
+
+### Golden vectors freeze the legacy encoding
+
+`contracts/resolver` (`mod golden`) and `scripts/spec-hash` (`golden_legacy_vectors`)
+pin the same six legacy hashes: both `OracleAsset` variants, all four
+`PriceDirection`s, and single and multi-signer lists. `#[contracttype]` structs
+encode as an `ScMap` keyed by field **name**, sorted; declaration order is
+irrelevant but renaming a field changes the hash (pinned by
+`xdr_encoding_depends_on_field_names_not_declaration_order`). The vectors were
+additionally re-derived from hand-built XDR with no Soroban code at all.
+
+### `ResolutionSpecV2` envelope
+
+```rust
+pub struct ResolutionSpecV2 {
+ pub version: u32, // must be 2
+ pub network_id: BytesN<32>, // sha256(network passphrase) == env.ledger().network_id()
+ pub market_core: Address, // must equal the resolver's configured market-core
+ pub salt: BytesN<32>, // 32 CSPRNG bytes, must be non-zero
+ pub rule: ResolutionSpec,
+}
+// commitment = sha256("oracledesk:resolution-spec" || XDR(ResolutionSpecV2))
+```
+
+`register_spec_v2(market_id, spec)` checks, in order: `version == 2` and a
+non-zero salt (`InvalidConfig`); `network_id` and `market_core`
+(`DomainMismatch`); then runs the same pipeline as the legacy entrypoints
+(unconfigured -> rule validation -> commitment equality -> store). The legacy
+entrypoints keep their behaviour and error precedence exactly.
+
+### Domain separation
+
+A legacy preimage is the XDR of an `ScVal` enum value, which is an `ScVec` and
+therefore begins with the 4-byte discriminant `00 00 00 10`. A V2 preimage
+begins with the ASCII bytes `orac`. The two preimage sets are disjoint, so a
+collision between a legacy and a V2 hash would be a SHA-256 collision.
+`v2_and_legacy_hash_spaces_disjoint` checks both the prefixes and the hashes
+over random specs.
+
+### What is deliberately not bound
+
+* **`market_id`.** The id is assigned by `create_market` *after* the creator
+ has computed the hash, so it cannot be inside the preimage without a
+ two-phase create. The salt makes two markets with the same rule hash
+ differently, which is the property that mattered.
+* **The resolver address.** Binding it would strand every committed market
+ whenever the resolver is redeployed and market-core is repointed with
+ `set_resolver`. Only the resolver market-core points to can resolve a
+ market, so replay across resolvers gains nothing.
+
+### Salt custody
+
+The salt is the only secret protecting the rule until reveal, and it is
+required to reveal. Losing it makes the market unresolvable except by
+`guardian_void_market`. `scripts/seed-market.sh` therefore generates it with
+`spec-hash gen-salt`, writes it to `deployments/specs/.json`,
+re-derives the commitment from the stored value with `spec-hash verify`, and
+only then creates the market. Any failure aborts before a market exists. The
+directory is gitignored because an unrevealed salt must not be published.
+
+### Migration policy
+
+* Legacy specs stay accepted indefinitely on existing deployments, so
+ existing testnet commitments keep resolving.
+* New tooling emits V2 only.
+* Any change to a field of `PriceConfig`, `SignerConfig`, `OracleAsset`,
+ `PriceDirection` or `ResolutionSpec` must add a **new variant or version**,
+ never edit an existing one, and must update the golden vectors in the same
+ pull request.
+
+## Consequences
+
+* A reveal is still public; V2 hides the rule only until then.
+* The V2 hash is checked on-chain against `env.ledger().network_id()`, so the
+ same envelope cannot be replayed on another network.
+* Error code `21` (`DomainMismatch`) is new; codes `1`-`18` are unchanged.
+* `spec-hash` computes both hashes by calling the resolver crate's own
+ `legacy_spec_hash` / `spec_v2_hash`, so the tool cannot drift from the
+ contract.
diff --git a/oracledesk-stellar/docs/architecture.md b/oracledesk-stellar/docs/architecture.md
index 045e342..b8cf636 100644
--- a/oracledesk-stellar/docs/architecture.md
+++ b/oracledesk-stellar/docs/architecture.md
@@ -31,7 +31,7 @@ for the creator/LP, `sweep_fees` (permissionless) to the treasury.
### resolver
-Two independent resolution modes, both ultimately calling
+Independent resolution modes, all ultimately calling
`market_core.resolve(market_id, outcome)`:
- **PriceThreshold**: reads a [Reflector](https://reflector.network) SEP-40
@@ -40,12 +40,58 @@ Two independent resolution modes, both ultimately calling
- **Attested**: m-of-n signer votes; once `threshold` signers agree, a
dispute window opens; anyone can finalize after it elapses, or a guardian
(the resolver's `admin`) can `cancel_proposal` during the window.
+- **Weighted keys**: a committee of raw Ed25519 public keys with weights.
+ Members sign a domain-separated message off-chain and anyone submits the
+ signatures to `submit_signatures` in one transaction; the weight threshold
+ starts the same dispute window. `cancel_proposal` moves the market to a new
+ round, invalidating earlier signatures. Message format: ADR 0001.
Neither mode can be configured until the revealed spec's hash matches the
market's commitment (`register_price_spec` / `register_signer_spec` — see
ADR 0001). A guardian can also `guardian_void_market` an unresolvable
market directly.
+#### Bonded disputes (`SignersDisputable`)
+
+A `SignersDisputable` spec adds an optimistic challenge to Attested mode.
+While a proposal's dispute window is open, anyone can `dispute` it by
+escrowing `dispute_bond` of the collateral token in the resolver. That
+freezes `finalize_signers`, `attest`, `cancel_proposal` and `expire_market`
+(`DisputeActive`) and escalates to the authority committed in the spec: the
+guardian (`settle_dispute`) or a `k`-of-`n` committee disjoint from the
+signers (`escalation_attest`). Upholding the proposal resolves to it and
+forfeits the bond to market-core's treasury; any other ruling resolves to the
+verdict (or voids for `Invalid`) and returns the bond. If nobody rules within
+`escalation_timeout` of the dispute, anyone may `expire_dispute`: the market is
+voided and the bond refunded. `guardian_void_market` during a dispute also
+refunds it. Bonds leave escrow only via the permissionless
+`claim_dispute_bond` (pull, so a payee that cannot receive cannot block
+settlement); the resolver's token balance always equals the bonds it still
+holds.
+
+```mermaid
+stateDiagram-v2
+ [*] --> SignersPending: register_disputable_spec
+ SignersPending --> SignersPending: attest (proposal at threshold)
+ SignersPending --> Finalized: finalize_signers (window elapsed)
+ SignersPending --> Disputed: dispute (window open, bond escrowed)
+ Disputed --> Finalized: settle_dispute / escalation_attest (k votes)
+ Disputed --> Finalized: expire_dispute (timeout, void, refund)
+ Disputed --> Finalized: guardian_void_market (void, refund)
+ SignersPending --> Expired: expire_market (no pending proposal)
+ Finalized --> [*]: claim_dispute_bond pays the payee
+```
+
+**Rewards, honestly:** a winning disputer only gets their bond back. The
+resolver holds no funds besides bonds, so there is nothing to pay a reward
+from until signers also post bonds (signer staking is separate work). The
+incentive to dispute is therefore protecting one's own position, not profit.
+
+Liveness: `resolution_deadline` is `close_time` + 30 days. After it anyone can
+call `expire_market`, which resolves the market if it still can be (valid
+price feed) and otherwise voids it through market-core, so collateral is never
+locked behind a missing reveal or a silent committee. See ADR 0001.
+
The resolver never depends on `market-core`'s Rust crate — see
[ADR 0002](adr/0002-cross-contract-calls.md) for why, and what that trades
off.
diff --git a/oracledesk-stellar/packages/bindings/resolver/src/index.ts b/oracledesk-stellar/packages/bindings/resolver/src/index.ts
index f62f4b4..c041205 100644
--- a/oracledesk-stellar/packages/bindings/resolver/src/index.ts
+++ b/oracledesk-stellar/packages/bindings/resolver/src/index.ts
@@ -53,9 +53,32 @@ export const Errors = {
15: {message:"DisputeWindowOpen"},
16: {message:"NoProposal"},
17: {message:"AlreadyFinalized"},
- 18: {message:"SpecHashMismatch"}
+ 18: {message:"SpecHashMismatch"},
+ 19: {message:"DeadlineNotReached"},
+ 20: {message:"ResolutionAvailable"},
+ 21: {message:"DomainMismatch"},
+ 22: {message:"StaleRound"},
+ 23: {message:"TooManySignatures"},
+ 24: {message:"DisputeWindowClosed"},
+ 25: {message:"NoDispute"}
}
+/**
+ * The escalation authority's ruling. `Invalid` voids the market.
+ */
+export type Verdict = {tag: "Yes", values: void} | {tag: "No", values: void} | {tag: "Invalid", values: void};
+
+
+/**
+ * Who rules on a dispute.
+ */
+export type Escalation = {tag: "Guardian", values: void} | {tag: "Committee", values: readonly [Array, u32]};
+
+/**
+ * Which resolution path a market was on when it expired.
+ */
+export type ExpiryMode = {tag: "Unrevealed", values: void} | {tag: "Price", values: void} | {tag: "Signers", values: void};
+
export type OracleAsset = {tag: "Stellar", values: readonly [string]} | {tag: "Other", values: readonly [string]};
@@ -68,18 +91,87 @@ export interface PriceConfig {
}
+export interface WeightedKey {
+ /**
+ * Raw Ed25519 public key. For a `G...` account address this is
+ * `StrKey.decodeEd25519PublicKey(address)`; `C...` addresses have none.
+ */
+pubkey: Buffer;
+ weight: u32;
+}
+
+
export interface SignerConfig {
dispute_window: u64;
signers: Array;
threshold: u32;
}
+
export type PriceDirection = {tag: "Above", values: void} | {tag: "AtOrAbove", values: void} | {tag: "Below", values: void} | {tag: "AtOrBelow", values: void};
-export type ResolutionState = {tag: "Unconfigured", values: void} | {tag: "SignersPending", values: void} | {tag: "Finalized", values: void};
+/**
+ * The full resolution rule a market's `resolution_hash` commits to. Hashing
+ * this (rather than the bare `PriceConfig`/`SignerConfig`) keeps the two
+ * modes in disjoint hash spaces even if their field encodings could
+ * otherwise collide. See `docs/adr/0001-resolution-commitment.md`.
+ */
+export type ResolutionSpec = {tag: "Price", values: readonly [PriceConfig]} | {tag: "Signers", values: readonly [SignerConfig]} | {tag: "WeightedKeys", values: readonly [WeightedKeyConfig]} | {tag: "SignersDisputable", values: readonly [DisputableSignerConfig]};
+
+
+export type ResolutionState = {tag: "Unconfigured", values: void} | {tag: "SignersPending", values: void} | {tag: "Finalized", values: void} | {tag: "Expired", values: void} | {tag: "Disputed", values: void};
+
+
+
+/**
+ * Versioned commitment envelope. Unlike a bare [`ResolutionSpec`] it binds the
+ * network and the market-core instance, and carries a creator-chosen `salt`
+ * that makes a pre-reveal brute-force of the (low-entropy) rule infeasible.
+ */
+export interface ResolutionSpecV2 {
+ market_core: string;
+ /**
+ * `sha256(network passphrase)`, i.e. `env.ledger().network_id()`.
+ */
+network_id: Buffer;
+ rule: ResolutionSpec;
+ salt: Buffer;
+ version: u32;
+}
+
+
+export interface WeightedKeyConfig {
+ dispute_window: u64;
+ signers: Array;
+ /**
+ * Total weight that must sign one outcome. Choose it above half the total
+ * weight so two outcomes can never both reach it.
+ */
+weight_threshold: u32;
+}
+
+/**
+ * Attested resolution whose proposal can be challenged with a bond during
+ * the dispute window. Hashed as `ResolutionSpec::SignersDisputable`, a
+ * separate variant, so existing `Signers` commitments are unaffected.
+ */
+export interface DisputableSignerConfig {
+ /**
+ * Collateral escrowed by `dispute`; must be positive.
+ */
+dispute_bond: i128;
+ dispute_window: u64;
+ escalation: Escalation;
+ /**
+ * Seconds after a dispute opens before anyone may void it unresolved.
+ */
+escalation_timeout: u64;
+ signers: Array;
+ threshold: u32;
+}
export interface Client {
/**
@@ -92,16 +184,67 @@ export interface Client {
*/
attest: ({market_id, signer, outcome}: {market_id: u64, signer: string, outcome: Outcome}, options?: MethodOptions) => Promise>>
+ /**
+ * Construct and simulate a dispute transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object.
+ * Challenges the current proposal of a `SignersDisputable` market while
+ * its dispute window is still open (strictly before `proposed_at +
+ * dispute_window`, the instant `finalize_signers` becomes callable). The
+ * disputer escrows `dispute_bond` of the collateral token, which freezes
+ * finalization until the escalation authority rules or
+ * `escalation_timeout` lapses. `alternative` is the outcome the disputer
+ * claims is correct (`None`: the market is invalid); it only informs the
+ * ruling and must differ from the proposal. The disputer may also be a
+ * proposing signer.
+ */
+ dispute: ({market_id, disputer, alternative}: {market_id: u64, disputer: string, alternative: Option}, options?: MethodOptions) => Promise>>
+
+ /**
+ * Construct and simulate a current_round transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object.
+ * Current attestation round of a weighted-key market; the `round`
+ * argument of `submit_signatures` and part of every signed message.
+ */
+ current_round: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>
+
+ /**
+ * Construct and simulate a expire_market transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object.
+ * Permissionless liveness fallback: once `resolution_deadline` has passed,
+ * anyone may void a market that is still unresolved, so collateral is
+ * never locked behind a missing reveal, a dead feed or silent signers.
+ *
+ * Resolution is preferred over voiding. A price market whose feed is
+ * valid right now is *resolved* instead (so a stalled caller can never
+ * force a void on a resolvable market), and a market with a pending
+ * signer proposal is refused with `ResolutionAvailable` until
+ * `finalize_signers` settles it.
+ */
+ expire_market: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>>
+
/**
* Construct and simulate a resolve_price transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object.
*/
resolve_price: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>>
+ /**
+ * Construct and simulate a expire_dispute transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object.
+ * Permissionless fallback when the escalation authority never rules:
+ * `escalation_timeout` after the dispute opened, the bond is refunded and
+ * the market is voided, so neither side wins by default.
+ */
+ expire_dispute: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>>
+
+ /**
+ * Construct and simulate a settle_dispute transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object.
+ * Guardian escalation: the admin rules on an open dispute.
+ */
+ settle_dispute: ({market_id, verdict}: {market_id: u64, verdict: Verdict}, options?: MethodOptions) => Promise>>
+
/**
* Construct and simulate a cancel_proposal transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object.
* Guardian action: withdraws an unfinalized signer proposal, e.g. because
- * evidence surfaced during the dispute window that it is wrong. Voting
- * can restart once the counts are cleared by re-attesting.
+ * evidence surfaced during the dispute window that it is wrong. For
+ * address-based signers voting can restart once the counts are cleared by
+ * re-attesting; a weighted-key market moves to a fresh round, which makes
+ * every signature collected so far unusable.
*/
cancel_proposal: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>>
@@ -110,6 +253,44 @@ export interface Client {
*/
finalize_signers: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>>
+ /**
+ * Construct and simulate a register_spec_v2 transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object.
+ * Reveals a rule committed to through a [`ResolutionSpecV2`] envelope.
+ * The envelope must name this network and this resolver's market-core
+ * (`DomainMismatch`), use a non-zero salt and the current `version`
+ * (`InvalidConfig`), and hash to the market's commitment
+ * (`SpecHashMismatch`). The inner rule is then validated and stored
+ * exactly as the legacy entrypoints do.
+ */
+ register_spec_v2: ({market_id, spec}: {market_id: u64, spec: ResolutionSpecV2}, options?: MethodOptions) => Promise>>
+
+ /**
+ * Construct and simulate a escalation_attest transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object.
+ * Committee escalation: each member votes once; the first verdict to
+ * gather `k` votes settles the dispute.
+ */
+ escalation_attest: ({market_id, member, verdict}: {market_id: u64, member: string, verdict: Verdict}, options?: MethodOptions) => Promise>>
+
+ /**
+ * Construct and simulate a submit_signatures transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object.
+ * Counts Ed25519 signatures from a weighted committee. Permissionless: the
+ * signatures, not the submitter, carry the authority. Each signature is
+ * over the message built by `attestation_message`; non-members and keys
+ * that already signed this round (for either outcome) are skipped, and a
+ * single invalid signature aborts the whole batch, so submitters should
+ * pre-filter with a verifier. Once the accumulated weight for `outcome`
+ * first reaches the threshold a proposal is recorded and the dispute
+ * window starts; later calls never move it.
+ */
+ submit_signatures: ({market_id, outcome, round, sigs}: {market_id: u64, outcome: Outcome, round: u32, sigs: Array}, options?: MethodOptions) => Promise>>
+
+ /**
+ * Construct and simulate a claim_dispute_bond transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object.
+ * Pays a settled dispute's bond to its payee (the disputer, or the
+ * market-core treasury when the proposal was upheld). Permissionless.
+ */
+ claim_dispute_bond: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>>
+
/**
* Construct and simulate a register_price_spec transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object.
* Reveals the resolution rule committed to at market creation. Anyone
@@ -120,6 +301,14 @@ export interface Client {
*/
register_price_spec: ({market_id, spec}: {market_id: u64, spec: PriceConfig}, options?: MethodOptions) => Promise>>
+ /**
+ * Construct and simulate a resolution_deadline transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object.
+ * When `expire_market` becomes callable for `market_id`: `close_time` plus
+ * [`RESOLUTION_GRACE_SECS`]. Needs only market-core's `close_time`, so it
+ * is known before (and without) a spec reveal.
+ */
+ resolution_deadline: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>>
+
/**
* Construct and simulate a guardian_void_market transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object.
* Guardian action: voids an unresolvable market directly through
@@ -134,6 +323,20 @@ export interface Client {
*/
register_signer_spec: ({market_id, spec}: {market_id: u64, spec: SignerConfig}, options?: MethodOptions) => Promise>>
+ /**
+ * Construct and simulate a register_disputable_spec transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object.
+ * See `register_price_spec` — same commit/reveal rule, signer mode whose
+ * proposals can be challenged with a bond (see `dispute`).
+ */
+ register_disputable_spec: ({market_id, spec}: {market_id: u64, spec: DisputableSignerConfig}, options?: MethodOptions) => Promise>>
+
+ /**
+ * Construct and simulate a register_weighted_key_spec transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object.
+ * See `register_price_spec` — same commit/reveal rule, weighted Ed25519
+ * committee that signs off-chain and is verified by `submit_signatures`.
+ */
+ register_weighted_key_spec: ({market_id, spec}: {market_id: u64, spec: WeightedKeyConfig}, options?: MethodOptions) => Promise>>
+
}
export class Client extends ContractClient {
static async deploy(
@@ -155,35 +358,70 @@ export class Client extends ContractClient {
constructor(public readonly options: ContractClientOptions) {
super(
new ContractSpec([ "AAAAAgAAAAAAAAAAAAAAB091dGNvbWUAAAAAAgAAAAAAAAAAAAAAA1llcwAAAAAAAAAAAAAAAAJObwAA",
- "AAAABAAAAAAAAAAAAAAABUVycm9yAAAAAAAAEgAAAAAAAAAOTm90SW5pdGlhbGl6ZWQAAAAAAAEAAAAAAAAADFVuYXV0aG9yaXplZAAAAAIAAAAAAAAADUludmFsaWRDb25maWcAAAAAAAADAAAAAAAAABdNYXJrZXRBbHJlYWR5Q29uZmlndXJlZAAAAAAEAAAAAAAAABNNYXJrZXROb3RDb25maWd1cmVkAAAAAAUAAAAAAAAACVdyb25nTW9kZQAAAAAAAAYAAAAAAAAAD01hcmtldE5vdENsb3NlZAAAAAAHAAAAAAAAABBQcmljZVVuYXZhaWxhYmxlAAAACAAAAAAAAAAKU3RhbGVQcmljZQAAAAAACQAAAAAAAAAMSW52YWxpZFByaWNlAAAACgAAAAAAAAAJTm90U2lnbmVyAAAAAAAACwAAAAAAAAAPQWxyZWFkeUF0dGVzdGVkAAAAAAwAAAAAAAAAE1RocmVzaG9sZE5vdFJlYWNoZWQAAAAADQAAAAAAAAANRGlzcHV0ZUFjdGl2ZQAAAAAAAA4AAAAAAAAAEURpc3B1dGVXaW5kb3dPcGVuAAAAAAAADwAAAAAAAAAKTm9Qcm9wb3NhbAAAAAAAEAAAAAAAAAAQQWxyZWFkeUZpbmFsaXplZAAAABEAAAAAAAAAEFNwZWNIYXNoTWlzbWF0Y2gAAAAS",
+ "AAAABAAAAAAAAAAAAAAABUVycm9yAAAAAAAAGQAAAAAAAAAOTm90SW5pdGlhbGl6ZWQAAAAAAAEAAAAAAAAADFVuYXV0aG9yaXplZAAAAAIAAAAAAAAADUludmFsaWRDb25maWcAAAAAAAADAAAAAAAAABdNYXJrZXRBbHJlYWR5Q29uZmlndXJlZAAAAAAEAAAAAAAAABNNYXJrZXROb3RDb25maWd1cmVkAAAAAAUAAAAAAAAACVdyb25nTW9kZQAAAAAAAAYAAAAAAAAAD01hcmtldE5vdENsb3NlZAAAAAAHAAAAAAAAABBQcmljZVVuYXZhaWxhYmxlAAAACAAAAAAAAAAKU3RhbGVQcmljZQAAAAAACQAAAAAAAAAMSW52YWxpZFByaWNlAAAACgAAAAAAAAAJTm90U2lnbmVyAAAAAAAACwAAAAAAAAAPQWxyZWFkeUF0dGVzdGVkAAAAAAwAAAAAAAAAE1RocmVzaG9sZE5vdFJlYWNoZWQAAAAADQAAAAAAAAANRGlzcHV0ZUFjdGl2ZQAAAAAAAA4AAAAAAAAAEURpc3B1dGVXaW5kb3dPcGVuAAAAAAAADwAAAAAAAAAKTm9Qcm9wb3NhbAAAAAAAEAAAAAAAAAAQQWxyZWFkeUZpbmFsaXplZAAAABEAAAAAAAAAEFNwZWNIYXNoTWlzbWF0Y2gAAAASAAAAAAAAABJEZWFkbGluZU5vdFJlYWNoZWQAAAAAABMAAAAAAAAAE1Jlc29sdXRpb25BdmFpbGFibGUAAAAAFAAAAAAAAAAORG9tYWluTWlzbWF0Y2gAAAAAABUAAAAAAAAAClN0YWxlUm91bmQAAAAAABYAAAAAAAAAEVRvb01hbnlTaWduYXR1cmVzAAAAAAAAFwAAAAAAAAATRGlzcHV0ZVdpbmRvd0Nsb3NlZAAAAAAYAAAAAAAAAAlOb0Rpc3B1dGUAAAAAAAAZ",
"AAAAAAAAAAAAAAAFc3RhdGUAAAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAB9AAAAAPUmVzb2x1dGlvblN0YXRlAA==",
+ "AAAAAgAAAD5UaGUgZXNjYWxhdGlvbiBhdXRob3JpdHkncyBydWxpbmcuIGBJbnZhbGlkYCB2b2lkcyB0aGUgbWFya2V0LgAAAAAAAAAAAAdWZXJkaWN0AAAAAAMAAAAAAAAAAAAAAANZZXMAAAAAAAAAAAAAAAACTm8AAAAAAAAAAAAAAAAAB0ludmFsaWQA",
"AAAAAAAAAAAAAAAGYXR0ZXN0AAAAAAADAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAZzaWduZXIAAAAAABMAAAAAAAAAB291dGNvbWUAAAAH0AAAAAdPdXRjb21lAAAAAAEAAAPpAAAAAgAAAAM=",
+ "AAAAAAAAAi5DaGFsbGVuZ2VzIHRoZSBjdXJyZW50IHByb3Bvc2FsIG9mIGEgYFNpZ25lcnNEaXNwdXRhYmxlYCBtYXJrZXQgd2hpbGUKaXRzIGRpc3B1dGUgd2luZG93IGlzIHN0aWxsIG9wZW4gKHN0cmljdGx5IGJlZm9yZSBgcHJvcG9zZWRfYXQgKwpkaXNwdXRlX3dpbmRvd2AsIHRoZSBpbnN0YW50IGBmaW5hbGl6ZV9zaWduZXJzYCBiZWNvbWVzIGNhbGxhYmxlKS4gVGhlCmRpc3B1dGVyIGVzY3Jvd3MgYGRpc3B1dGVfYm9uZGAgb2YgdGhlIGNvbGxhdGVyYWwgdG9rZW4sIHdoaWNoIGZyZWV6ZXMKZmluYWxpemF0aW9uIHVudGlsIHRoZSBlc2NhbGF0aW9uIGF1dGhvcml0eSBydWxlcyBvcgpgZXNjYWxhdGlvbl90aW1lb3V0YCBsYXBzZXMuIGBhbHRlcm5hdGl2ZWAgaXMgdGhlIG91dGNvbWUgdGhlIGRpc3B1dGVyCmNsYWltcyBpcyBjb3JyZWN0IChgTm9uZWA6IHRoZSBtYXJrZXQgaXMgaW52YWxpZCk7IGl0IG9ubHkgaW5mb3JtcyB0aGUKcnVsaW5nIGFuZCBtdXN0IGRpZmZlciBmcm9tIHRoZSBwcm9wb3NhbC4gVGhlIGRpc3B1dGVyIG1heSBhbHNvIGJlIGEKcHJvcG9zaW5nIHNpZ25lci4AAAAAAAdkaXNwdXRlAAAAAAMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAACGRpc3B1dGVyAAAAEwAAAAAAAAALYWx0ZXJuYXRpdmUAAAAD6AAAB9AAAAAHT3V0Y29tZQAAAAABAAAD6QAAAAIAAAAD",
+ "AAAABQAAAAAAAAAAAAAACERpc3B1dGVkAAAAAQAAAAhkaXNwdXRlZAAAAAQAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAAAAAAAAhkaXNwdXRlcgAAABMAAAAAAAAAAAAAAARib25kAAAACwAAAAAAAABJV2hhdCB0aGUgZGlzcHV0ZXIgY2xhaW1zIGlzIGNvcnJlY3Q7IGBOb25lYCBjbGFpbXMgdGhlIG1hcmtldCBpcyBpbnZhbGlkLgAAAAAAAAthbHRlcm5hdGl2ZQAAAAPoAAAH0AAAAAdPdXRjb21lAAAAAAAAAAAC",
+ "AAAAAgAAABdXaG8gcnVsZXMgb24gYSBkaXNwdXRlLgAAAAAAAAAACkVzY2FsYXRpb24AAAAAAAIAAAAAAAAALVRoZSByZXNvbHZlciBhZG1pbiwgdGhyb3VnaCBgc2V0dGxlX2Rpc3B1dGVgLgAAAAAAAAhHdWFyZGlhbgAAAAEAAABfYGtgIG9mIHRoZXNlIG1lbWJlcnMsIHRocm91Z2ggYGVzY2FsYXRpb25fYXR0ZXN0YC4gTXVzdCBiZSBkaXNqb2ludApmcm9tIHRoZSBwcm9wb3Npbmcgc2lnbmVycy4AAAAACUNvbW1pdHRlZQAAAAAAAAIAAAPqAAAAEwAAAAQ=",
+ "AAAAAgAAADZXaGljaCByZXNvbHV0aW9uIHBhdGggYSBtYXJrZXQgd2FzIG9uIHdoZW4gaXQgZXhwaXJlZC4AAAAAAAAAAAAKRXhwaXJ5TW9kZQAAAAAAAwAAAAAAAAAAAAAAClVucmV2ZWFsZWQAAAAAAAAAAAAAAAAABVByaWNlAAAAAAAAAAAAAAAAAAAHU2lnbmVycwA=",
"AAAAAgAAAAAAAAAAAAAAC09yYWNsZUFzc2V0AAAAAAIAAAABAAAAAAAAAAdTdGVsbGFyAAAAAAEAAAATAAAAAQAAAAAAAAAFT3RoZXIAAAAAAAABAAAAEQ==",
"AAAAAQAAAAAAAAAAAAAAC1ByaWNlQ29uZmlnAAAAAAUAAAAAAAAABWFzc2V0AAAAAAAH0AAAAAtPcmFjbGVBc3NldAAAAAAAAAAACWRpcmVjdGlvbgAAAAAAB9AAAAAOUHJpY2VEaXJlY3Rpb24AAAAAAAAAAAANbWF4X3N0YWxlbmVzcwAAAAAAAAYAAAAAAAAACXJlZmxlY3RvcgAAAAAAABMAAAAAAAAACXRocmVzaG9sZAAAAAAAAAs=",
+ "AAAAAQAAAAAAAAAAAAAAC1dlaWdodGVkS2V5AAAAAAIAAACCUmF3IEVkMjU1MTkgcHVibGljIGtleS4gRm9yIGEgYEcuLi5gIGFjY291bnQgYWRkcmVzcyB0aGlzIGlzCmBTdHJLZXkuZGVjb2RlRWQyNTUxOVB1YmxpY0tleShhZGRyZXNzKWA7IGBDLi4uYCBhZGRyZXNzZXMgaGF2ZSBub25lLgAAAAAABnB1YmtleQAAAAAD7gAAACAAAAAAAAAABndlaWdodAAAAAAABA==",
"AAAAAQAAAAAAAAAAAAAADFNpZ25lckNvbmZpZwAAAAMAAAAAAAAADmRpc3B1dGVfd2luZG93AAAAAAAGAAAAAAAAAAdzaWduZXJzAAAAA+oAAAATAAAAAAAAAAl0aHJlc2hvbGQAAAAAAAAE",
+ "AAAABQAAAAAAAAAAAAAADU1hcmtldEV4cGlyZWQAAAAAAAABAAAADm1hcmtldF9leHBpcmVkAAAAAAAEAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAKY2xvc2VfdGltZQAAAAAABgAAAAAAAAAAAAAACGRlYWRsaW5lAAAABgAAAAAAAAAAAAAABG1vZGUAAAfQAAAACkV4cGlyeU1vZGUAAAAAAAAAAAAC",
"AAAAAgAAAAAAAAAAAAAADlByaWNlRGlyZWN0aW9uAAAAAAAEAAAAAAAAAAAAAAAFQWJvdmUAAAAAAAAAAAAAAAAAAAlBdE9yQWJvdmUAAAAAAAAAAAAAAAAAAAVCZWxvdwAAAAAAAAAAAAAAAAAACUF0T3JCZWxvdwAAAA==",
+ "AAAAAgAAARNUaGUgZnVsbCByZXNvbHV0aW9uIHJ1bGUgYSBtYXJrZXQncyBgcmVzb2x1dGlvbl9oYXNoYCBjb21taXRzIHRvLiBIYXNoaW5nCnRoaXMgKHJhdGhlciB0aGFuIHRoZSBiYXJlIGBQcmljZUNvbmZpZ2AvYFNpZ25lckNvbmZpZ2ApIGtlZXBzIHRoZSB0d28KbW9kZXMgaW4gZGlzam9pbnQgaGFzaCBzcGFjZXMgZXZlbiBpZiB0aGVpciBmaWVsZCBlbmNvZGluZ3MgY291bGQKb3RoZXJ3aXNlIGNvbGxpZGUuIFNlZSBgZG9jcy9hZHIvMDAwMS1yZXNvbHV0aW9uLWNvbW1pdG1lbnQubWRgLgAAAAAAAAAADlJlc29sdXRpb25TcGVjAAAAAAAEAAAAAQAAAAAAAAAFUHJpY2UAAAAAAAABAAAH0AAAAAtQcmljZUNvbmZpZwAAAAABAAAAAAAAAAdTaWduZXJzAAAAAAEAAAfQAAAADFNpZ25lckNvbmZpZwAAAAEAAAAAAAAADFdlaWdodGVkS2V5cwAAAAEAAAfQAAAAEVdlaWdodGVkS2V5Q29uZmlnAAAAAAAAAQAAAAAAAAARU2lnbmVyc0Rpc3B1dGFibGUAAAAAAAABAAAH0AAAABZEaXNwdXRhYmxlU2lnbmVyQ29uZmlnAAA=",
"AAAAAAAAAAAAAAANX19jb25zdHJ1Y3RvcgAAAAAAAAIAAAAAAAAABWFkbWluAAAAAAAAEwAAAAAAAAALbWFya2V0X2NvcmUAAAAAEwAAAAA=",
+ "AAAAAAAAAIFDdXJyZW50IGF0dGVzdGF0aW9uIHJvdW5kIG9mIGEgd2VpZ2h0ZWQta2V5IG1hcmtldDsgdGhlIGByb3VuZGAKYXJndW1lbnQgb2YgYHN1Ym1pdF9zaWduYXR1cmVzYCBhbmQgcGFydCBvZiBldmVyeSBzaWduZWQgbWVzc2FnZS4AAAAAAAANY3VycmVudF9yb3VuZAAAAAAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAABA==",
+ "AAAAAAAAAfdQZXJtaXNzaW9ubGVzcyBsaXZlbmVzcyBmYWxsYmFjazogb25jZSBgcmVzb2x1dGlvbl9kZWFkbGluZWAgaGFzIHBhc3NlZCwKYW55b25lIG1heSB2b2lkIGEgbWFya2V0IHRoYXQgaXMgc3RpbGwgdW5yZXNvbHZlZCwgc28gY29sbGF0ZXJhbCBpcwpuZXZlciBsb2NrZWQgYmVoaW5kIGEgbWlzc2luZyByZXZlYWwsIGEgZGVhZCBmZWVkIG9yIHNpbGVudCBzaWduZXJzLgoKUmVzb2x1dGlvbiBpcyBwcmVmZXJyZWQgb3ZlciB2b2lkaW5nLiBBIHByaWNlIG1hcmtldCB3aG9zZSBmZWVkIGlzCnZhbGlkIHJpZ2h0IG5vdyBpcyAqcmVzb2x2ZWQqIGluc3RlYWQgKHNvIGEgc3RhbGxlZCBjYWxsZXIgY2FuIG5ldmVyCmZvcmNlIGEgdm9pZCBvbiBhIHJlc29sdmFibGUgbWFya2V0KSwgYW5kIGEgbWFya2V0IHdpdGggYSBwZW5kaW5nCnNpZ25lciBwcm9wb3NhbCBpcyByZWZ1c2VkIHdpdGggYFJlc29sdXRpb25BdmFpbGFibGVgIHVudGlsCmBmaW5hbGl6ZV9zaWduZXJzYCBzZXR0bGVzIGl0LgAAAAANZXhwaXJlX21hcmtldAAAAAAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAAAIAAAAD",
"AAAAAAAAAAAAAAANcmVzb2x2ZV9wcmljZQAAAAAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAB9AAAAAHT3V0Y29tZQAAAAAD",
- "AAAAAgAAAAAAAAAAAAAAD1Jlc29sdXRpb25TdGF0ZQAAAAADAAAAAAAAAAAAAAAMVW5jb25maWd1cmVkAAAAAAAAAAAAAAAOU2lnbmVyc1BlbmRpbmcAAAAAAAAAAAAAAAAACUZpbmFsaXplZAAAAA==",
+ "AAAABQAAAVlgZGlzcHV0ZXJfd29uYCBpcyB0cnVlIHdoZW4gdGhlIGJvbmQgcmV0dXJucyB0byB0aGUgZGlzcHV0ZXIgYmVjYXVzZSB0aGUKcnVsaW5nIG92ZXJ0dXJuZWQgdGhlIHByb3Bvc2FsLiBBIHRpbWVvdXQgKGBleHBpcmVfZGlzcHV0ZWApIGFsc28gcmVmdW5kcwp0aGUgYm9uZCBidXQgaXMgcmVwb3J0ZWQgYXMgYHZlcmRpY3Q6IEludmFsaWQsIGRpc3B1dGVyX3dvbjogZmFsc2VgOiBhCnJ1bGluZyBvZiBgSW52YWxpZGAgYWx3YXlzIG92ZXJ0dXJucywgc28gdGhhdCBwYWlyIGlkZW50aWZpZXMgYSB0aW1lb3V0LgpgcGF5b3V0YCBpcyB0aGUgZXNjcm93ZWQgYm9uZCwgY2xhaW1hYmxlIGJ5IGl0cyBwYXllZS4AAAAAAAAAAAAADkRpc3B1dGVTZXR0bGVkAAAAAAABAAAAD2Rpc3B1dGVfc2V0dGxlZAAAAAAEAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAHdmVyZGljdAAAAAfQAAAAB1ZlcmRpY3QAAAAAAAAAAAAAAAAMZGlzcHV0ZXJfd29uAAAAAQAAAAAAAAAAAAAABnBheW91dAAAAAAACwAAAAAAAAAC",
+ "AAAAAgAAAAAAAAAAAAAAD1Jlc29sdXRpb25TdGF0ZQAAAAAFAAAAAAAAAAAAAAAMVW5jb25maWd1cmVkAAAAAAAAAAAAAAAOU2lnbmVyc1BlbmRpbmcAAAAAAAAAAAAAAAAACUZpbmFsaXplZAAAAAAAAAAAAABEVm9pZGVkIGJ5IFtgUmVzb2x2ZXI6OmV4cGlyZV9tYXJrZXRgXSBhZnRlciB0aGUgcmVzb2x1dGlvbiBkZWFkbGluZS4AAAAHRXhwaXJlZAAAAAAAAAAAQkEgYm9uZGVkIGRpc3B1dGUgaXMgb3BlbjsgZmluYWxpemF0aW9uIGlzIGZyb3plbiB1bnRpbCBpdCBzZXR0bGVzLgAAAAAACERpc3B1dGVk",
+ "AAAAAAAAAMFQZXJtaXNzaW9ubGVzcyBmYWxsYmFjayB3aGVuIHRoZSBlc2NhbGF0aW9uIGF1dGhvcml0eSBuZXZlciBydWxlczoKYGVzY2FsYXRpb25fdGltZW91dGAgYWZ0ZXIgdGhlIGRpc3B1dGUgb3BlbmVkLCB0aGUgYm9uZCBpcyByZWZ1bmRlZCBhbmQKdGhlIG1hcmtldCBpcyB2b2lkZWQsIHNvIG5laXRoZXIgc2lkZSB3aW5zIGJ5IGRlZmF1bHQuAAAAAAAADmV4cGlyZV9kaXNwdXRlAAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAACAAAAAw==",
+ "AAAAAAAAADhHdWFyZGlhbiBlc2NhbGF0aW9uOiB0aGUgYWRtaW4gcnVsZXMgb24gYW4gb3BlbiBkaXNwdXRlLgAAAA5zZXR0bGVfZGlzcHV0ZQAAAAAAAgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAHdmVyZGljdAAAAAfQAAAAB1ZlcmRpY3QAAAAAAQAAA+kAAAACAAAAAw==",
"AAAABQAAAAAAAAAAAAAAD1ByaWNlUmVzb2x1dGlvbgAAAAABAAAAEHByaWNlX3Jlc29sdXRpb24AAAAEAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAFcHJpY2UAAAAAAAALAAAAAAAAAAAAAAAPcHJpY2VfdGltZXN0YW1wAAAAAAYAAAAAAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAAAg==",
- "AAAAAAAAAMVHdWFyZGlhbiBhY3Rpb246IHdpdGhkcmF3cyBhbiB1bmZpbmFsaXplZCBzaWduZXIgcHJvcG9zYWwsIGUuZy4gYmVjYXVzZQpldmlkZW5jZSBzdXJmYWNlZCBkdXJpbmcgdGhlIGRpc3B1dGUgd2luZG93IHRoYXQgaXQgaXMgd3JvbmcuIFZvdGluZwpjYW4gcmVzdGFydCBvbmNlIHRoZSBjb3VudHMgYXJlIGNsZWFyZWQgYnkgcmUtYXR0ZXN0aW5nLgAAAAAAAA9jYW5jZWxfcHJvcG9zYWwAAAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPpAAAAAgAAAAM=",
+ "AAAAAQAAAOBWZXJzaW9uZWQgY29tbWl0bWVudCBlbnZlbG9wZS4gVW5saWtlIGEgYmFyZSBbYFJlc29sdXRpb25TcGVjYF0gaXQgYmluZHMgdGhlCm5ldHdvcmsgYW5kIHRoZSBtYXJrZXQtY29yZSBpbnN0YW5jZSwgYW5kIGNhcnJpZXMgYSBjcmVhdG9yLWNob3NlbiBgc2FsdGAKdGhhdCBtYWtlcyBhIHByZS1yZXZlYWwgYnJ1dGUtZm9yY2Ugb2YgdGhlIChsb3ctZW50cm9weSkgcnVsZSBpbmZlYXNpYmxlLgAAAAAAAAAQUmVzb2x1dGlvblNwZWNWMgAAAAUAAAAAAAAAC21hcmtldF9jb3JlAAAAABMAAAA/YHNoYTI1NihuZXR3b3JrIHBhc3NwaHJhc2UpYCwgaS5lLiBgZW52LmxlZGdlcigpLm5ldHdvcmtfaWQoKWAuAAAAAApuZXR3b3JrX2lkAAAAAAPuAAAAIAAAAAAAAAAEcnVsZQAAB9AAAAAOUmVzb2x1dGlvblNwZWMAAAAAAAAAAAAEc2FsdAAAA+4AAAAgAAAAAAAAAAd2ZXJzaW9uAAAAAAQ=",
+ "AAAAAAAAAURHdWFyZGlhbiBhY3Rpb246IHdpdGhkcmF3cyBhbiB1bmZpbmFsaXplZCBzaWduZXIgcHJvcG9zYWwsIGUuZy4gYmVjYXVzZQpldmlkZW5jZSBzdXJmYWNlZCBkdXJpbmcgdGhlIGRpc3B1dGUgd2luZG93IHRoYXQgaXQgaXMgd3JvbmcuIEZvcgphZGRyZXNzLWJhc2VkIHNpZ25lcnMgdm90aW5nIGNhbiByZXN0YXJ0IG9uY2UgdGhlIGNvdW50cyBhcmUgY2xlYXJlZCBieQpyZS1hdHRlc3Rpbmc7IGEgd2VpZ2h0ZWQta2V5IG1hcmtldCBtb3ZlcyB0byBhIGZyZXNoIHJvdW5kLCB3aGljaCBtYWtlcwpldmVyeSBzaWduYXR1cmUgY29sbGVjdGVkIHNvIGZhciB1bnVzYWJsZS4AAAAPY2FuY2VsX3Byb3Bvc2FsAAAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAAAIAAAAD",
+ "AAAAAQAAAAAAAAAAAAAAEVdlaWdodGVkS2V5Q29uZmlnAAAAAAAAAwAAAAAAAAAOZGlzcHV0ZV93aW5kb3cAAAAAAAYAAAAAAAAAB3NpZ25lcnMAAAAD6gAAB9AAAAALV2VpZ2h0ZWRLZXkAAAAAd1RvdGFsIHdlaWdodCB0aGF0IG11c3Qgc2lnbiBvbmUgb3V0Y29tZS4gQ2hvb3NlIGl0IGFib3ZlIGhhbGYgdGhlIHRvdGFsCndlaWdodCBzbyB0d28gb3V0Y29tZXMgY2FuIG5ldmVyIGJvdGggcmVhY2ggaXQuAAAAABB3ZWlnaHRfdGhyZXNob2xkAAAABA==",
"AAAAAAAAAAAAAAAQZmluYWxpemVfc2lnbmVycwAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAB9AAAAAHT3V0Y29tZQAAAAAD",
+ "AAAAAAAAAWlSZXZlYWxzIGEgcnVsZSBjb21taXR0ZWQgdG8gdGhyb3VnaCBhIFtgUmVzb2x1dGlvblNwZWNWMmBdIGVudmVsb3BlLgpUaGUgZW52ZWxvcGUgbXVzdCBuYW1lIHRoaXMgbmV0d29yayBhbmQgdGhpcyByZXNvbHZlcidzIG1hcmtldC1jb3JlCihgRG9tYWluTWlzbWF0Y2hgKSwgdXNlIGEgbm9uLXplcm8gc2FsdCBhbmQgdGhlIGN1cnJlbnQgYHZlcnNpb25gCihgSW52YWxpZENvbmZpZ2ApLCBhbmQgaGFzaCB0byB0aGUgbWFya2V0J3MgY29tbWl0bWVudAooYFNwZWNIYXNoTWlzbWF0Y2hgKS4gVGhlIGlubmVyIHJ1bGUgaXMgdGhlbiB2YWxpZGF0ZWQgYW5kIHN0b3JlZApleGFjdGx5IGFzIHRoZSBsZWdhY3kgZW50cnlwb2ludHMgZG8uAAAAAAAAEHJlZ2lzdGVyX3NwZWNfdjIAAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAARzcGVjAAAH0AAAABBSZXNvbHV0aW9uU3BlY1YyAAAAAQAAA+kAAAACAAAAAw==",
+ "AAAAAAAAAGhDb21taXR0ZWUgZXNjYWxhdGlvbjogZWFjaCBtZW1iZXIgdm90ZXMgb25jZTsgdGhlIGZpcnN0IHZlcmRpY3QgdG8KZ2F0aGVyIGBrYCB2b3RlcyBzZXR0bGVzIHRoZSBkaXNwdXRlLgAAABFlc2NhbGF0aW9uX2F0dGVzdAAAAAAAAAMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAABm1lbWJlcgAAAAAAEwAAAAAAAAAHdmVyZGljdAAAAAfQAAAAB1ZlcmRpY3QAAAAAAQAAA+kAAAACAAAAAw==",
+ "AAAAAAAAAhRDb3VudHMgRWQyNTUxOSBzaWduYXR1cmVzIGZyb20gYSB3ZWlnaHRlZCBjb21taXR0ZWUuIFBlcm1pc3Npb25sZXNzOiB0aGUKc2lnbmF0dXJlcywgbm90IHRoZSBzdWJtaXR0ZXIsIGNhcnJ5IHRoZSBhdXRob3JpdHkuIEVhY2ggc2lnbmF0dXJlIGlzCm92ZXIgdGhlIG1lc3NhZ2UgYnVpbHQgYnkgYGF0dGVzdGF0aW9uX21lc3NhZ2VgOyBub24tbWVtYmVycyBhbmQga2V5cwp0aGF0IGFscmVhZHkgc2lnbmVkIHRoaXMgcm91bmQgKGZvciBlaXRoZXIgb3V0Y29tZSkgYXJlIHNraXBwZWQsIGFuZCBhCnNpbmdsZSBpbnZhbGlkIHNpZ25hdHVyZSBhYm9ydHMgdGhlIHdob2xlIGJhdGNoLCBzbyBzdWJtaXR0ZXJzIHNob3VsZApwcmUtZmlsdGVyIHdpdGggYSB2ZXJpZmllci4gT25jZSB0aGUgYWNjdW11bGF0ZWQgd2VpZ2h0IGZvciBgb3V0Y29tZWAKZmlyc3QgcmVhY2hlcyB0aGUgdGhyZXNob2xkIGEgcHJvcG9zYWwgaXMgcmVjb3JkZWQgYW5kIHRoZSBkaXNwdXRlCndpbmRvdyBzdGFydHM7IGxhdGVyIGNhbGxzIG5ldmVyIG1vdmUgaXQuAAAAEXN1Ym1pdF9zaWduYXR1cmVzAAAAAAAABAAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAHb3V0Y29tZQAAAAfQAAAAB091dGNvbWUAAAAAAAAAAAVyb3VuZAAAAAAAAAQAAAAAAAAABHNpZ3MAAAPqAAAD7QAAAAIAAAPuAAAAIAAAA+4AAABAAAAAAQAAA+kAAAACAAAAAw==",
"AAAABQAAAAAAAAAAAAAAElJlc29sdXRpb25Qcm9wb3NlZAAAAAAAAQAAABNyZXNvbHV0aW9uX3Byb3Bvc2VkAAAAAAMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAAAAAAAAtwcm9wb3NlZF9hdAAAAAAGAAAAAAAAAAI=",
+ "AAAAAAAAAIRQYXlzIGEgc2V0dGxlZCBkaXNwdXRlJ3MgYm9uZCB0byBpdHMgcGF5ZWUgKHRoZSBkaXNwdXRlciwgb3IgdGhlCm1hcmtldC1jb3JlIHRyZWFzdXJ5IHdoZW4gdGhlIHByb3Bvc2FsIHdhcyB1cGhlbGQpLiBQZXJtaXNzaW9ubGVzcy4AAAASY2xhaW1fZGlzcHV0ZV9ib25kAAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAACAAAAAw==",
"AAAABQAAAAAAAAAAAAAAE1Jlc29sdXRpb25GaW5hbGl6ZWQAAAAAAQAAABRyZXNvbHV0aW9uX2ZpbmFsaXplZAAAAAMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAAAAAAAAxmaW5hbGl6ZWRfYXQAAAAGAAAAAAAAAAI=",
"AAAAAAAAATpSZXZlYWxzIHRoZSByZXNvbHV0aW9uIHJ1bGUgY29tbWl0dGVkIHRvIGF0IG1hcmtldCBjcmVhdGlvbi4gQW55b25lCm1heSBjYWxsIHRoaXMgKHRoZXJlIGlzIG5vIGFkbWluIGdhdGUpIGJlY2F1c2UgdGhlIG9ubHkgdGhpbmcgdGhhdAptYWtlcyBhIHNwZWMgYWNjZXB0YWJsZSBpcyB0aGF0IGl0IGhhc2hlcyB0byBgbWFya2V0LnJlc29sdXRpb25faGFzaGAK4oCUIHRoZSBjb21taXRtZW50LCBub3QgdGhlIGNhbGxlciwgaXMgdGhlIGF1dGhvcml0eS4gRmFpbHMgaWYgYSBzcGVjCndhcyBhbHJlYWR5IHJlZ2lzdGVyZWQgZm9yIHRoaXMgbWFya2V0LgAAAAAAE3JlZ2lzdGVyX3ByaWNlX3NwZWMAAAAAAgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAEc3BlYwAAB9AAAAALUHJpY2VDb25maWcAAAAAAQAAA+kAAAACAAAAAw==",
+ "AAAAAAAAAL1XaGVuIGBleHBpcmVfbWFya2V0YCBiZWNvbWVzIGNhbGxhYmxlIGZvciBgbWFya2V0X2lkYDogYGNsb3NlX3RpbWVgIHBsdXMKW2BSRVNPTFVUSU9OX0dSQUNFX1NFQ1NgXS4gTmVlZHMgb25seSBtYXJrZXQtY29yZSdzIGBjbG9zZV90aW1lYCwgc28gaXQKaXMga25vd24gYmVmb3JlIChhbmQgd2l0aG91dCkgYSBzcGVjIHJldmVhbC4AAAAAAAATcmVzb2x1dGlvbl9kZWFkbGluZQAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAAGAAAAAw==",
"AAAAAAAAAMJHdWFyZGlhbiBhY3Rpb246IHZvaWRzIGFuIHVucmVzb2x2YWJsZSBtYXJrZXQgZGlyZWN0bHkgdGhyb3VnaAptYXJrZXQtY29yZS4gVGhlIHJlc29sdmVyIGlzIHRoZSBkaXJlY3QgY2FsbGVyLCBzbyBtYXJrZXQtY29yZSdzCmByZXF1aXJlX3Jlc29sdmVyYCBjaGVjayBwYXNzZXMgdGhlIHNhbWUgd2F5IGByZXNvbHZlX3ByaWNlYCBkb2VzLgAAAAAAFGd1YXJkaWFuX3ZvaWRfbWFya2V0AAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPpAAAAAgAAAAM=",
- "AAAAAAAAAENTZWUgYHJlZ2lzdGVyX3ByaWNlX3NwZWNgIOKAlCBzYW1lIGNvbW1pdC9yZXZlYWwgcnVsZSwgc2lnbmVyIG1vZGUuAAAAABRyZWdpc3Rlcl9zaWduZXJfc3BlYwAAAAIAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAABHNwZWMAAAfQAAAADFNpZ25lckNvbmZpZwAAAAEAAAPpAAAAAgAAAAM=" ]),
+ "AAAAAAAAAENTZWUgYHJlZ2lzdGVyX3ByaWNlX3NwZWNgIOKAlCBzYW1lIGNvbW1pdC9yZXZlYWwgcnVsZSwgc2lnbmVyIG1vZGUuAAAAABRyZWdpc3Rlcl9zaWduZXJfc3BlYwAAAAIAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAABHNwZWMAAAfQAAAADFNpZ25lckNvbmZpZwAAAAEAAAPpAAAAAgAAAAM=",
+ "AAAAAQAAANBBdHRlc3RlZCByZXNvbHV0aW9uIHdob3NlIHByb3Bvc2FsIGNhbiBiZSBjaGFsbGVuZ2VkIHdpdGggYSBib25kIGR1cmluZwp0aGUgZGlzcHV0ZSB3aW5kb3cuIEhhc2hlZCBhcyBgUmVzb2x1dGlvblNwZWM6OlNpZ25lcnNEaXNwdXRhYmxlYCwgYQpzZXBhcmF0ZSB2YXJpYW50LCBzbyBleGlzdGluZyBgU2lnbmVyc2AgY29tbWl0bWVudHMgYXJlIHVuYWZmZWN0ZWQuAAAAAAAAABZEaXNwdXRhYmxlU2lnbmVyQ29uZmlnAAAAAAAGAAAAM0NvbGxhdGVyYWwgZXNjcm93ZWQgYnkgYGRpc3B1dGVgOyBtdXN0IGJlIHBvc2l0aXZlLgAAAAAMZGlzcHV0ZV9ib25kAAAACwAAAAAAAAAOZGlzcHV0ZV93aW5kb3cAAAAAAAYAAAAAAAAACmVzY2FsYXRpb24AAAAAB9AAAAAKRXNjYWxhdGlvbgAAAAAAQ1NlY29uZHMgYWZ0ZXIgYSBkaXNwdXRlIG9wZW5zIGJlZm9yZSBhbnlvbmUgbWF5IHZvaWQgaXQgdW5yZXNvbHZlZC4AAAAAEmVzY2FsYXRpb25fdGltZW91dAAAAAAABgAAAAAAAAAHc2lnbmVycwAAAAPqAAAAEwAAAAAAAAAJdGhyZXNob2xkAAAAAAAABA==",
+ "AAAAAAAAAIFTZWUgYHJlZ2lzdGVyX3ByaWNlX3NwZWNgIOKAlCBzYW1lIGNvbW1pdC9yZXZlYWwgcnVsZSwgc2lnbmVyIG1vZGUgd2hvc2UKcHJvcG9zYWxzIGNhbiBiZSBjaGFsbGVuZ2VkIHdpdGggYSBib25kIChzZWUgYGRpc3B1dGVgKS4AAAAAAAAYcmVnaXN0ZXJfZGlzcHV0YWJsZV9zcGVjAAAAAgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAEc3BlYwAAB9AAAAAWRGlzcHV0YWJsZVNpZ25lckNvbmZpZwAAAAAAAQAAA+kAAAACAAAAAw==",
+ "AAAAAAAAAI5TZWUgYHJlZ2lzdGVyX3ByaWNlX3NwZWNgIOKAlCBzYW1lIGNvbW1pdC9yZXZlYWwgcnVsZSwgd2VpZ2h0ZWQgRWQyNTUxOQpjb21taXR0ZWUgdGhhdCBzaWducyBvZmYtY2hhaW4gYW5kIGlzIHZlcmlmaWVkIGJ5IGBzdWJtaXRfc2lnbmF0dXJlc2AuAAAAAAAacmVnaXN0ZXJfd2VpZ2h0ZWRfa2V5X3NwZWMAAAAAAAIAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAABHNwZWMAAAfQAAAAEVdlaWdodGVkS2V5Q29uZmlnAAAAAAAAAQAAA+kAAAACAAAAAw==" ]),
options
)
}
public readonly fromJSON = {
state: this.txFromJSON,
attest: this.txFromJSON>,
+ dispute: this.txFromJSON>,
+ current_round: this.txFromJSON,
+ expire_market: this.txFromJSON>,
resolve_price: this.txFromJSON>,
+ expire_dispute: this.txFromJSON>,
+ settle_dispute: this.txFromJSON>,
cancel_proposal: this.txFromJSON>,
finalize_signers: this.txFromJSON>,
+ register_spec_v2: this.txFromJSON>,
+ escalation_attest: this.txFromJSON>,
+ submit_signatures: this.txFromJSON>,
+ claim_dispute_bond: this.txFromJSON>,
register_price_spec: this.txFromJSON>,
+ resolution_deadline: this.txFromJSON>,
guardian_void_market: this.txFromJSON>,
- register_signer_spec: this.txFromJSON>
+ register_signer_spec: this.txFromJSON>,
+ register_disputable_spec: this.txFromJSON>,
+ register_weighted_key_spec: this.txFromJSON>
}
}
\ No newline at end of file
diff --git a/oracledesk-stellar/scripts/seed-market.sh b/oracledesk-stellar/scripts/seed-market.sh
index bc0faf6..f1e96ee 100755
--- a/oracledesk-stellar/scripts/seed-market.sh
+++ b/oracledesk-stellar/scripts/seed-market.sh
@@ -6,16 +6,24 @@
# The agent itself is the sole signer (threshold 1) with a short dispute
# window — good enough to demonstrate the commit/reveal flow end to end
# without needing a real oracle. For a PriceConfig-based market, compute the
-# hash with `spec-hash price ...` instead and call register_price_spec.
+# hash with `spec-hash --v2 ... price ...` instead (same envelope, different
+# rule).
+#
+# The commitment is a salted ResolutionSpecV2 (docs/adr/0004-spec-versioning.md).
+# The salt is the only secret protecting the rule until reveal and is needed to
+# reveal it, so it is written to deployments/specs/.json and
+# read back BEFORE the market is created: if that fails, no market is created.
#
# Usage: scripts/seed-market.sh [seed_amount_stroops] [initial_yes_bps] [close_in_seconds]
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
NETWORK=testnet
+NETWORK_PASSPHRASE="Test SDF Network ; September 2015"
DEPLOYER_KEY=oracledesk-deployer
AGENT_KEY=oracledesk-agent
DEPLOYMENTS=deployments/testnet.json
+SPECS_DIR=deployments/specs
SEED_AMOUNT=${1:-1500000000} # 150 USDC, comfortably above the 100 USDC MIN_SEED
INITIAL_YES_BPS=${2:-5000} # 50/50
@@ -27,17 +35,53 @@ QUESTION_TEXT=${5:-"Demo market seeded by scripts/seed-market.sh"}
TREASURY=$(jq -r .contracts.treasury "$DEPLOYMENTS")
RESOLVER=$(jq -r .contracts.resolver "$DEPLOYMENTS")
+MARKET_CORE=$(jq -r .contracts.market_core "$DEPLOYMENTS")
AGENT=$(jq -r .agent "$DEPLOYMENTS")
-[ -n "$TREASURY" ] && [ "$TREASURY" != "null" ] || { echo "error: no treasury address in $DEPLOYMENTS" >&2; exit 1; }
+for addr in "$TREASURY" "$MARKET_CORE"; do
+ [ -n "$addr" ] && [ "$addr" != "null" ] || { echo "error: no treasury/market_core address in $DEPLOYMENTS" >&2; exit 1; }
+done
-question_hash() {
+sha256_hex() {
python3 -c "import hashlib,sys; print(hashlib.sha256(sys.argv[1].encode()).hexdigest())" "$1"
}
-echo "==> computing resolution commitment (signers: [$AGENT], threshold 1)" >&2
-RESOLUTION_HASH=$(cargo run --quiet --manifest-path scripts/spec-hash/Cargo.toml -- \
- signers 1 "$DISPUTE_WINDOW" "$AGENT")
-QUESTION_HASH=$(question_hash "$QUESTION_TEXT")
+spec_hash() {
+ cargo run --quiet --manifest-path scripts/spec-hash/Cargo.toml -- "$@"
+}
+
+# $1 = salt. Prints the V2 flags spec-hash expects, one per line.
+v2_flags() {
+ printf '%s\n' --v2 --salt "$1" --network-passphrase "$NETWORK_PASSPHRASE" --market-core "$MARKET_CORE"
+}
+
+echo "==> computing salted V2 resolution commitment (signers: [$AGENT], threshold 1)" >&2
+SALT=$(spec_hash gen-salt)
+mapfile -t V2_FLAGS < <(v2_flags "$SALT")
+RESOLUTION_HASH=$(spec_hash "${V2_FLAGS[@]}" signers 1 "$DISPUTE_WINDOW" "$AGENT")
+
+echo "==> persisting the salt to $SPECS_DIR before creating the market" >&2
+mkdir -p "$SPECS_DIR"
+SPEC_FILE="$SPECS_DIR/$RESOLUTION_HASH.json"
+(
+ umask 077
+ jq -n --arg question "$QUESTION_TEXT" --arg hash "$RESOLUTION_HASH" --arg salt "$SALT" \
+ --arg network_id "$(sha256_hex "$NETWORK_PASSPHRASE")" --arg market_core "$MARKET_CORE" \
+ --arg agent "$AGENT" --argjson window "$DISPUTE_WINDOW" '{
+ market_question: $question,
+ resolution_hash: $hash,
+ salt: $salt,
+ spec: {
+ version: 2, network_id: $network_id, market_core: $market_core, salt: $salt,
+ rule: { Signers: { dispute_window: $window, signers: [$agent], threshold: 1 } }
+ }
+ }' >"$SPEC_FILE.tmp"
+ mv "$SPEC_FILE.tmp" "$SPEC_FILE"
+)
+# Prove the stored salt reproduces the commitment before any funds move.
+mapfile -t V2_FLAGS < <(v2_flags "$(jq -r .salt "$SPEC_FILE")")
+spec_hash verify "$RESOLUTION_HASH" "${V2_FLAGS[@]}" signers 1 "$DISPUTE_WINDOW" "$AGENT" >/dev/null
+
+QUESTION_HASH=$(sha256_hex "$QUESTION_TEXT")
CLOSE_TIME=$(( $(date +%s) + CLOSE_IN_SECONDS ))
echo "==> creating market (seed=$SEED_AMOUNT, close_time=$CLOSE_TIME)" >&2
@@ -53,10 +97,11 @@ MARKET_ID=$(stellar contract invoke --id "$TREASURY" --source-account "$AGENT_KE
echo "==> revealing the resolution spec to the resolver" >&2
stellar contract invoke --id "$RESOLVER" --source-account "$AGENT_KEY" --network "$NETWORK" -- \
- register_signer_spec \
+ register_spec_v2 \
--market_id "$MARKET_ID" \
- --spec "{ \"dispute_window\": $DISPUTE_WINDOW, \"signers\": [ \"$AGENT\" ], \"threshold\": 1 }"
+ --spec "$(jq -c .spec "$SPEC_FILE")"
echo "market_id=$MARKET_ID"
echo "close_time=$CLOSE_TIME"
echo "dispute_window=$DISPUTE_WINDOW"
+echo "spec_file=$SPEC_FILE"
diff --git a/oracledesk-stellar/scripts/spec-hash/Cargo.lock b/oracledesk-stellar/scripts/spec-hash/Cargo.lock
index 3ee445b..d87d9f3 100644
--- a/oracledesk-stellar/scripts/spec-hash/Cargo.lock
+++ b/oracledesk-stellar/scripts/spec-hash/Cargo.lock
@@ -826,6 +826,18 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+]
+
[[package]]
name = "group"
version = "0.13.0"
@@ -1263,6 +1275,12 @@ dependencies = [
"proc-macro2",
]
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
[[package]]
name = "rand"
version = "0.8.8"
@@ -1290,7 +1308,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
- "getrandom",
+ "getrandom 0.2.17",
]
[[package]]
@@ -1582,7 +1600,7 @@ dependencies = [
"ed25519-dalek",
"elliptic-curve",
"generic-array",
- "getrandom",
+ "getrandom 0.2.17",
"hex-literal",
"hmac",
"k256",
@@ -1722,6 +1740,7 @@ dependencies = [
name = "spec-hash"
version = "0.1.0"
dependencies = [
+ "getrandom 0.3.4",
"resolver",
"soroban-sdk",
]
@@ -1939,6 +1958,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
[[package]]
name = "wasm-bindgen"
version = "0.2.128"
@@ -2080,6 +2108,12 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
[[package]]
name = "zerocopy"
version = "0.8.57"
diff --git a/oracledesk-stellar/scripts/spec-hash/Cargo.toml b/oracledesk-stellar/scripts/spec-hash/Cargo.toml
index 0a1eb7b..aa29d5b 100644
--- a/oracledesk-stellar/scripts/spec-hash/Cargo.toml
+++ b/oracledesk-stellar/scripts/spec-hash/Cargo.toml
@@ -6,12 +6,14 @@ version = "0.1.0"
edition = "2021"
publish = false
-# Dev tool only — never deployed. Computes the same sha256(XDR(ResolutionSpec))
-# hash that contracts/resolver verifies revealed specs against, so
+# Dev tool only — never deployed. Computes the same commitment hash
+# (legacy sha256(XDR(ResolutionSpec)) or the salted V2 envelope) that
+# contracts/resolver verifies revealed specs against, so
# scripts/seed-market.sh and scripts/demo.sh can commit to a resolution spec
# off-chain before creating a market. Not a workspace member: it links
# soroban-sdk's `testutils` host to run off-chain, which contract crates must
# never do outside `#[cfg(test)]`.
[dependencies]
+getrandom = "0.3"
resolver = { path = "../../contracts/resolver" }
soroban-sdk = { version = "27.0.6", features = ["testutils"] }
diff --git a/oracledesk-stellar/scripts/spec-hash/src/lib.rs b/oracledesk-stellar/scripts/spec-hash/src/lib.rs
new file mode 100644
index 0000000..9b7c33a
--- /dev/null
+++ b/oracledesk-stellar/scripts/spec-hash/src/lib.rs
@@ -0,0 +1,561 @@
+//! Computes the commitment hash `contracts/resolver` verifies a revealed
+//! resolution spec against (see `docs/adr/0001-resolution-commitment.md` and
+//! `docs/adr/0004-spec-versioning.md`). Off-chain dev tool only; never deployed.
+//!
+//! All hashing delegates to the resolver crate's own `legacy_spec_hash` /
+//! `spec_v2_hash`, so the tool cannot drift from the contract.
+
+use resolver::{
+ legacy_spec_hash, spec_v2_hash, DisputableSignerConfig, Escalation, OracleAsset, PriceConfig,
+ PriceDirection, ResolutionSpec, ResolutionSpecV2, SignerConfig, WeightedKey, WeightedKeyConfig,
+};
+use soroban_sdk::{Address, Bytes, BytesN, Env, Symbol, Vec as SorobanVec};
+
+pub const USAGE: &str = "\
+usage:
+ spec-hash [V2 FLAGS] signers ...
+ spec-hash [V2 FLAGS] price
+ is `stellar:` or `other:`
+ is one of: above | at-or-above | below | at-or-below
+ spec-hash [V2 FLAGS] weighted-keys :...
+ spec-hash [V2 FLAGS] signers-disputable
+ ...
+ is `guardian` or `committee::,...`
+ spec-hash [V2 FLAGS] verify ...
+ spec-hash gen-salt
+
+V2 FLAGS (all four are required together; without them the legacy hash is printed):
+ --v2 --salt <64 hex> --network-passphrase --market-core ";
+
+pub enum CliError {
+ Usage(String),
+ Mismatch { expected: String, actual: String },
+}
+
+impl From for CliError {
+ fn from(message: String) -> Self {
+ CliError::Usage(message)
+ }
+}
+
+/// `--v2` envelope parameters, already parsed.
+struct V2Params {
+ salt: [u8; 32],
+ network_id: [u8; 32],
+ market_core: String,
+}
+
+pub fn to_hex(bytes: &[u8]) -> String {
+ bytes.iter().map(|b| format!("{b:02x}")).collect()
+}
+
+pub fn parse_hex32(hex: &str) -> Result<[u8; 32], String> {
+ if hex.len() != 64 || !hex.is_ascii() {
+ return Err(format!("expected 64 hex characters, got '{hex}'"));
+ }
+ let mut out = [0u8; 32];
+ for (byte, pair) in out.iter_mut().zip(hex.as_bytes().chunks(2)) {
+ let pair = std::str::from_utf8(pair).expect("ascii checked above");
+ *byte = u8::from_str_radix(pair, 16).map_err(|_| format!("invalid hex '{hex}'"))?;
+ }
+ Ok(out)
+}
+
+/// 32 bytes from the operating system's CSPRNG, for a V2 commitment salt.
+pub fn gen_salt() -> Result<[u8; 32], String> {
+ let mut salt = [0u8; 32];
+ getrandom::fill(&mut salt).map_err(|e| format!("could not read OS randomness: {e}"))?;
+ Ok(salt)
+}
+
+/// `sha256(passphrase)`: what `env.ledger().network_id()` returns on-chain.
+pub fn network_id(env: &Env, passphrase: &str) -> [u8; 32] {
+ let digest: BytesN<32> = env
+ .crypto()
+ .sha256(&Bytes::from_slice(env, passphrase.as_bytes()))
+ .into();
+ digest.to_array()
+}
+
+fn parse_address(env: &Env, value: &str) -> Result {
+ let well_formed = value.len() == 56 && (value.starts_with('G') || value.starts_with('C'));
+ if !well_formed {
+ return Err(format!("'{value}' is not a G... or C... strkey address"));
+ }
+ Ok(Address::from_str(env, value))
+}
+
+pub fn parse_signers(env: &Env, args: &[String]) -> Result {
+ let [threshold, dispute_window, signers @ ..] = args else {
+ return Err("signers needs ...".into());
+ };
+ if signers.is_empty() {
+ return Err("signers needs at least one signer address".into());
+ }
+ let threshold = threshold
+ .parse()
+ .map_err(|_| "threshold must be a u32".to_string())?;
+ let dispute_window = dispute_window
+ .parse()
+ .map_err(|_| "dispute_window must be a u64".to_string())?;
+ let mut list = SorobanVec::new(env);
+ for signer in signers {
+ list.push_back(parse_address(env, signer)?);
+ }
+ Ok(ResolutionSpec::Signers(SignerConfig {
+ signers: list,
+ threshold,
+ dispute_window,
+ }))
+}
+
+pub fn parse_price(env: &Env, args: &[String]) -> Result {
+ let [reflector, asset, threshold, direction, max_staleness] = args else {
+ return Err(
+ "price needs ".into(),
+ );
+ };
+ let asset = if let Some(address) = asset.strip_prefix("stellar:") {
+ OracleAsset::Stellar(parse_address(env, address)?)
+ } else if let Some(symbol) = asset.strip_prefix("other:") {
+ OracleAsset::Other(Symbol::new(env, symbol))
+ } else {
+ return Err("asset must be `stellar:` or `other:`".into());
+ };
+ let direction = match direction.as_str() {
+ "above" => PriceDirection::Above,
+ "at-or-above" => PriceDirection::AtOrAbove,
+ "below" => PriceDirection::Below,
+ "at-or-below" => PriceDirection::AtOrBelow,
+ other => {
+ return Err(format!(
+ "unknown direction '{other}': expected above|at-or-above|below|at-or-below"
+ ))
+ }
+ };
+ Ok(ResolutionSpec::Price(PriceConfig {
+ reflector: parse_address(env, reflector)?,
+ asset,
+ threshold: threshold
+ .parse()
+ .map_err(|_| "threshold must be an i128".to_string())?,
+ direction,
+ max_staleness: max_staleness
+ .parse()
+ .map_err(|_| "max_staleness must be a u64".to_string())?,
+ }))
+}
+
+pub fn parse_weighted_keys(env: &Env, args: &[String]) -> Result {
+ let [weight_threshold, dispute_window, keys @ ..] = args else {
+ return Err(
+ "weighted-keys needs :..."
+ .into(),
+ );
+ };
+ if keys.is_empty() {
+ return Err("weighted-keys needs at least one :".into());
+ }
+ let mut signers = SorobanVec::new(env);
+ for entry in keys {
+ let (pubkey, weight) = entry
+ .split_once(':')
+ .ok_or_else(|| format!("'{entry}' is not :"))?;
+ signers.push_back(WeightedKey {
+ pubkey: BytesN::from_array(env, &parse_hex32(pubkey)?),
+ weight: weight
+ .parse()
+ .map_err(|_| format!("weight in '{entry}' must be a u32"))?,
+ });
+ }
+ Ok(ResolutionSpec::WeightedKeys(WeightedKeyConfig {
+ signers,
+ weight_threshold: weight_threshold
+ .parse()
+ .map_err(|_| "weight_threshold must be a u32".to_string())?,
+ dispute_window: dispute_window
+ .parse()
+ .map_err(|_| "dispute_window must be a u64".to_string())?,
+ }))
+}
+
+fn parse_escalation(env: &Env, value: &str) -> Result {
+ if value == "guardian" {
+ return Ok(Escalation::Guardian);
+ }
+ let committee = value
+ .strip_prefix("committee:")
+ .and_then(|rest| rest.split_once(':'))
+ .ok_or_else(|| {
+ format!("escalation '{value}' is not `guardian` or `committee::,...`")
+ })?;
+ let k = committee
+ .0
+ .parse()
+ .map_err(|_| "committee k must be a u32".to_string())?;
+ let mut members = SorobanVec::new(env);
+ for member in committee.1.split(',') {
+ members.push_back(parse_address(env, member)?);
+ }
+ Ok(Escalation::Committee(members, k))
+}
+
+pub fn parse_signers_disputable(env: &Env, args: &[String]) -> Result {
+ let [threshold, dispute_window, bond, timeout, escalation, signers @ ..] = args else {
+ return Err("signers-disputable needs ...".into());
+ };
+ let ResolutionSpec::Signers(base) = parse_signers(
+ env,
+ &[
+ [threshold.clone(), dispute_window.clone()].as_slice(),
+ signers,
+ ]
+ .concat(),
+ )?
+ else {
+ unreachable!("parse_signers returns ResolutionSpec::Signers")
+ };
+ Ok(ResolutionSpec::SignersDisputable(DisputableSignerConfig {
+ signers: base.signers,
+ threshold: base.threshold,
+ dispute_window: base.dispute_window,
+ dispute_bond: bond
+ .parse()
+ .map_err(|_| "bond must be an i128".to_string())?,
+ escalation: parse_escalation(env, escalation)?,
+ escalation_timeout: timeout
+ .parse()
+ .map_err(|_| "escalation_timeout must be a u64".to_string())?,
+ }))
+}
+
+fn parse_rule(env: &Env, args: &[String]) -> Result {
+ match args {
+ [kind, rest @ ..] if kind == "signers" => parse_signers(env, rest),
+ [kind, rest @ ..] if kind == "price" => parse_price(env, rest),
+ [kind, rest @ ..] if kind == "weighted-keys" => parse_weighted_keys(env, rest),
+ [kind, rest @ ..] if kind == "signers-disputable" => parse_signers_disputable(env, rest),
+ _ => Err("expected `signers`, `price`, `weighted-keys` or `signers-disputable`".into()),
+ }
+}
+
+fn commitment(env: &Env, rule: ResolutionSpec, v2: Option) -> Result<[u8; 32], String> {
+ let hash = match v2 {
+ None => legacy_spec_hash(env, &rule),
+ Some(params) => spec_v2_hash(
+ env,
+ &ResolutionSpecV2 {
+ version: 2,
+ network_id: BytesN::from_array(env, ¶ms.network_id),
+ market_core: parse_address(env, ¶ms.market_core)?,
+ salt: BytesN::from_array(env, ¶ms.salt),
+ rule,
+ },
+ ),
+ };
+ Ok(hash.to_array())
+}
+
+/// Splits `--v2 --salt .. --network-passphrase .. --market-core ..` out of
+/// `args`, returning the parsed V2 parameters (if requested) and the rest.
+fn take_v2_flags(env: &Env, args: &[String]) -> Result<(Option, Vec), String> {
+ let (mut v2, mut salt, mut passphrase, mut market_core) = (false, None, None, None);
+ let mut rest = Vec::new();
+ let mut iter = args.iter();
+ while let Some(arg) = iter.next() {
+ let mut value = |flag: &str| {
+ iter.next()
+ .cloned()
+ .ok_or_else(|| format!("{flag} needs a value"))
+ };
+ match arg.as_str() {
+ "--v2" => v2 = true,
+ "--salt" => salt = Some(value("--salt")?),
+ "--network-passphrase" => passphrase = Some(value("--network-passphrase")?),
+ "--market-core" => market_core = Some(value("--market-core")?),
+ _ => rest.push(arg.clone()),
+ }
+ }
+ if !v2 {
+ if salt.is_some() || passphrase.is_some() || market_core.is_some() {
+ return Err("--salt/--network-passphrase/--market-core require --v2".into());
+ }
+ return Ok((None, rest));
+ }
+ let (Some(salt), Some(passphrase), Some(market_core)) = (salt, passphrase, market_core) else {
+ return Err("--v2 requires --salt, --network-passphrase and --market-core".into());
+ };
+ let params = V2Params {
+ salt: parse_hex32(&salt)?,
+ network_id: network_id(env, &passphrase),
+ market_core,
+ };
+ Ok((Some(params), rest))
+}
+
+/// Runs one invocation and returns the text to print on success.
+pub fn run(args: &[String]) -> Result {
+ let env = Env::default();
+ if args.first().map(String::as_str) == Some("gen-salt") {
+ return Ok(to_hex(&gen_salt()?));
+ }
+ let (v2, rest) = take_v2_flags(&env, args)?;
+ match rest.as_slice() {
+ [cmd, expected, rule @ ..] if cmd == "verify" => {
+ let expected = parse_hex32(expected)?;
+ let actual = commitment(&env, parse_rule(&env, rule)?, v2)?;
+ if expected == actual {
+ Ok("match".into())
+ } else {
+ Err(CliError::Mismatch {
+ expected: to_hex(&expected),
+ actual: to_hex(&actual),
+ })
+ }
+ }
+ [_, ..] => Ok(to_hex(&commitment(&env, parse_rule(&env, &rest)?, v2)?)),
+ [] => Err(CliError::Usage("missing command".into())),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const G1: &str = "GBAKBHXK7SV7C3HGY3DDYB4QWUEGL52SEF6OO77LAD672JVXD4QCYJLF";
+ const G2: &str = "GCKBJQ6633JFYSFV6HHF2FQUN4QUX5PVE63PODYIKO2CE3RCXAF5NZCW";
+ const G3: &str = "GAJ7BAGE252F636J2HNCRCSFLALJRBY3YQK6DE7Q7DWZECWIR2VOTYZL";
+ const C1: &str = "CC3KU5TWAATOBXLWIV4ZTXNEM44VIOY3ZTR7KTJTBOOBP7YIXMCDSWDJ";
+ const C2: &str = "CCILJXZCHKAIBTMJ7NSQE756OGLKTOYNSWNNZXWAYZZA4ECAUEC2CNVF";
+ const C3: &str = "CCAMJLLDELRB4PCMI5Z526NOEPM7IMUUY2MX6ERSUFOTQBALUGEXCREV";
+ const TESTNET: &str = "Test SDF Network ; September 2015";
+ const PUBNET: &str = "Public Global Stellar Network ; September 2015";
+
+ fn args(parts: &[&str]) -> Vec {
+ parts.iter().map(|s| s.to_string()).collect()
+ }
+
+ fn hash_of(parts: &[&str]) -> String {
+ match run(&args(parts)) {
+ Ok(out) => out,
+ Err(CliError::Usage(e)) => panic!("usage error: {e}"),
+ Err(CliError::Mismatch { .. }) => panic!("unexpected mismatch"),
+ }
+ }
+
+ /// The same vectors as `golden` in contracts/resolver: the CLI parsers and
+ /// the contract types must produce byte-identical commitments.
+ #[test]
+ fn golden_legacy_vectors() {
+ let vectors: [(&[&str], &str); 6] = [
+ (
+ &["signers", "1", "5", G1],
+ "b935a3beeebdbf7b9defc916410f19a1740a3920623230152e41a812b6f141c4",
+ ),
+ (
+ &["signers", "2", "3600", G1, G2, G3],
+ "4373448f97b0dd998a70efd9a1bfd3b3b8639bebaf682d9fc083c11f248a99ee",
+ ),
+ (
+ &["price", C1, "other:XLM", "2000", "above", "10"],
+ "b6f2458326d5f76bd9b53a93a103ea6c0fed7129c5496e91e3faef9a9a23d469",
+ ),
+ (
+ &[
+ "price",
+ C1,
+ &format!("stellar:{C2}"),
+ "100",
+ "at-or-above",
+ "300",
+ ],
+ "fa4cb1bbf39e94a2e699906ddd7228bd0b23ff8c4e968828485dcf3c644dadc2",
+ ),
+ (
+ &["price", C1, "other:XLM", "0", "below", "600"],
+ "0236202a346acee0cc18c7c47e4e31b67efa559dc2ff6ac66783f9ea5254f03d",
+ ),
+ (
+ &[
+ "price",
+ C1,
+ &format!("stellar:{C3}"),
+ "7500000000000",
+ "at-or-below",
+ "86400",
+ ],
+ "c9e1a0fb8443cdf1ea0190af347ff45f54bec12499f3b5111ff81b96b4794570",
+ ),
+ ];
+ for (spec, expected) in vectors {
+ assert_eq!(hash_of(spec), expected);
+ }
+ }
+
+ /// Re-derived from hand-built XDR (no Soroban) when this vector was added.
+ #[test]
+ fn golden_weighted_keys_vector() {
+ let (a, b) = ("11".repeat(32), "22".repeat(32));
+ assert_eq!(
+ hash_of(&[
+ "weighted-keys",
+ "2",
+ "60",
+ &format!("{a}:2"),
+ &format!("{b}:1")
+ ]),
+ "1359f80e3d5870663fd88223fc88ae327efb98b585dfd4f853af952a964defda"
+ );
+ }
+
+ /// Re-derived from hand-built XDR (no Soroban) when these vectors were added.
+ #[test]
+ fn golden_signers_disputable_vectors() {
+ let committee = format!("committee:2:{G2},{G3}");
+ let vectors: [(&[&str], &str); 2] = [
+ (
+ &[
+ "signers-disputable",
+ "1",
+ "100",
+ "250000000",
+ "1000",
+ "guardian",
+ G1,
+ ],
+ "04010c92f3d4389e3b6b54edf0740c7b6b58f0d5d79aa91253eafbd720716bcc",
+ ),
+ (
+ &[
+ "signers-disputable",
+ "1",
+ "100",
+ "250000000",
+ "1000",
+ &committee,
+ G1,
+ ],
+ "4b0cd91d8a638b3ea4c819f43b1878afa2bf031055c1dbb33b4ae774fa5748ce",
+ ),
+ ];
+ for (spec, expected) in vectors {
+ assert_eq!(hash_of(spec), expected);
+ }
+ }
+
+ const SALT: &str = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
+ const V2_GOLDEN: &str = "27e995544f9f631be41177890b7a1be7e17c667e4e531aa08e5089dd899566ae";
+
+ fn v2_args<'a>(salt: &'a str, passphrase: &'a str, market_core: &'a str) -> Vec<&'a str> {
+ vec![
+ "--v2",
+ "--salt",
+ salt,
+ "--network-passphrase",
+ passphrase,
+ "--market-core",
+ market_core,
+ "signers",
+ "1",
+ "5",
+ G1,
+ ]
+ }
+
+ #[test]
+ fn golden_v2_vector() {
+ assert_eq!(hash_of(&v2_args(SALT, TESTNET, C2)), V2_GOLDEN);
+ }
+
+ #[test]
+ fn v2_hash_changes_with_every_binding() {
+ let base = hash_of(&v2_args(SALT, TESTNET, C2));
+ let other_salt = "ff".repeat(32);
+ for variant in [
+ v2_args(&other_salt, TESTNET, C2),
+ v2_args(SALT, PUBNET, C2),
+ v2_args(SALT, TESTNET, C3),
+ ] {
+ assert_ne!(hash_of(&variant), base);
+ }
+ assert_ne!(hash_of(&["signers", "1", "5", G1]), base);
+ }
+
+ #[test]
+ fn network_id_matches_passphrase_hash() {
+ use soroban_sdk::testutils::Ledger;
+ let env = Env::default();
+ assert_eq!(
+ to_hex(&network_id(&env, TESTNET)),
+ "cee0302d59844d32bdca915c8203dd44b33fbb7edc19051ea37abedf28ecd472"
+ );
+ assert_eq!(
+ to_hex(&network_id(&env, PUBNET)),
+ "7ac33997544e3175d266bd022439b22cdb16508c01163f26e5cb2a3e1045a979"
+ );
+ env.ledger().set_network_id(network_id(&env, TESTNET));
+ assert_eq!(
+ env.ledger().network_id().to_array(),
+ network_id(&env, TESTNET)
+ );
+ }
+
+ #[test]
+ fn verify_accepts_match_and_reports_mismatch() {
+ let hash = hash_of(&["signers", "1", "5", G1]);
+ assert_eq!(
+ hash_of(&["verify", &hash, "signers", "1", "5", G1]),
+ "match"
+ );
+ let wrong = hash_of(&["signers", "1", "6", G1]);
+ let Err(CliError::Mismatch { expected, actual }) =
+ run(&args(&["verify", &wrong, "signers", "1", "5", G1]))
+ else {
+ panic!("expected a mismatch");
+ };
+ assert_eq!((expected, actual), (wrong, hash));
+ }
+
+ #[test]
+ fn gen_salt_is_32_random_bytes() {
+ let (a, b) = (hash_of(&["gen-salt"]), hash_of(&["gen-salt"]));
+ assert_eq!(a.len(), 64);
+ assert_ne!(a, b);
+ }
+
+ #[test]
+ fn rejects_malformed_input() {
+ for bad in [
+ args(&["signers", "1", "5"]),
+ args(&["signers", "x", "5", G1]),
+ args(&["signers", "1", "5", "not-an-address"]),
+ args(&["price", C1, "bogus", "1", "above", "1"]),
+ args(&["weighted-keys", "2", "60", "nocolon"]),
+ args(&["weighted-keys", "2", "60", "zz:1"]),
+ args(&[
+ "signers-disputable",
+ "1",
+ "100",
+ "250000000",
+ "1000",
+ "sometimes",
+ G1,
+ ]),
+ args(&[
+ "signers-disputable",
+ "1",
+ "100",
+ "x",
+ "1000",
+ "guardian",
+ G1,
+ ]),
+ args(&["--v2", "signers", "1", "5", G1]),
+ args(&["--salt", SALT, "signers", "1", "5", G1]),
+ args(&["verify", "abcd", "signers", "1", "5", G1]),
+ args(&[]),
+ ] {
+ assert!(matches!(run(&bad), Err(CliError::Usage(_))), "{bad:?}");
+ }
+ }
+}
diff --git a/oracledesk-stellar/scripts/spec-hash/src/main.rs b/oracledesk-stellar/scripts/spec-hash/src/main.rs
index c5b3ede..453111a 100644
--- a/oracledesk-stellar/scripts/spec-hash/src/main.rs
+++ b/oracledesk-stellar/scripts/spec-hash/src/main.rs
@@ -1,90 +1,23 @@
-//! Prints sha256(XDR(ResolutionSpec)) for a resolution spec given on the
-//! command line — the same commitment hash contracts/resolver verifies a
-//! revealed spec against (see docs/adr/0001-resolution-commitment.md).
-//! Off-chain dev tool only; never deployed.
-//!
-//! Usage:
-//! spec-hash signers ...
-//! spec-hash price
-//! is `stellar:` or `other:`
-//! is one of: above | at-or-above | below | at-or-below
+//! Prints the commitment hash for a resolution spec given on the command line,
+//! or verifies one. See `lib.rs` for the grammar and `USAGE` for examples.
-use resolver::{OracleAsset, PriceConfig, PriceDirection, ResolutionSpec, SignerConfig};
-use soroban_sdk::{xdr::ToXdr, Address, Env, Symbol, Vec};
+use spec_hash::{run, CliError, USAGE};
+use std::process::ExitCode;
-const USAGE: &str = "\
-usage:
- spec-hash signers ...
- spec-hash price
- is `stellar:` or `other:`
- is one of: above | at-or-above | below | at-or-below";
-
-fn main() {
- let args: std::vec::Vec = std::env::args().skip(1).collect();
- let env = Env::default();
-
- let spec = match args.first().map(std::string::String::as_str) {
- Some("signers") => parse_signers(&env, &args[1..]),
- Some("price") => parse_price(&env, &args[1..]),
- _ => {
- eprintln!("{USAGE}");
- std::process::exit(2);
+fn main() -> ExitCode {
+ let args: Vec = std::env::args().skip(1).collect();
+ match run(&args) {
+ Ok(output) => {
+ println!("{output}");
+ ExitCode::SUCCESS
}
- };
-
- let hash = env.crypto().sha256(&spec.to_xdr(&env));
- let bytes = hash.to_array();
- let mut hex = std::string::String::with_capacity(64);
- for b in bytes {
- hex.push_str(&std::format!("{b:02x}"));
- }
- println!("{hex}");
-}
-
-fn parse_signers(env: &Env, args: &[std::string::String]) -> ResolutionSpec {
- if args.len() < 3 {
- eprintln!("usage: spec-hash signers ...");
- std::process::exit(2);
- }
- let threshold: u32 = args[0].parse().expect("threshold must be a u32");
- let dispute_window: u64 = args[1].parse().expect("dispute_window must be a u64");
- let signers = Vec::from_slice(
- env,
- &args[2..]
- .iter()
- .map(|s| Address::from_str(env, s))
- .collect::>(),
- );
- ResolutionSpec::Signers(SignerConfig { signers, threshold, dispute_window })
-}
-
-fn parse_price(env: &Env, args: &[std::string::String]) -> ResolutionSpec {
- if args.len() != 5 {
- eprintln!(
- "usage: spec-hash price "
- );
- std::process::exit(2);
- }
- let reflector = Address::from_str(env, &args[0]);
- let asset = if let Some(rest) = args[1].strip_prefix("stellar:") {
- OracleAsset::Stellar(Address::from_str(env, rest))
- } else if let Some(rest) = args[1].strip_prefix("other:") {
- OracleAsset::Other(Symbol::new(env, rest))
- } else {
- eprintln!("asset must be `stellar:` or `other:`");
- std::process::exit(2);
- };
- let threshold: i128 = args[2].parse().expect("threshold must be an i128");
- let direction = match args[3].as_str() {
- "above" => PriceDirection::Above,
- "at-or-above" => PriceDirection::AtOrAbove,
- "below" => PriceDirection::Below,
- "at-or-below" => PriceDirection::AtOrBelow,
- other => {
- eprintln!("unknown direction '{other}': expected above|at-or-above|below|at-or-below");
- std::process::exit(2);
+ Err(CliError::Usage(message)) => {
+ eprintln!("error: {message}\n\n{USAGE}");
+ ExitCode::from(2)
+ }
+ Err(CliError::Mismatch { expected, actual }) => {
+ eprintln!("mismatch: expected {expected}, computed {actual}");
+ ExitCode::from(1)
}
- };
- let max_staleness: u64 = args[4].parse().expect("max_staleness must be a u64");
- ResolutionSpec::Price(PriceConfig { reflector, asset, threshold, direction, max_staleness })
+ }
}