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 }) + } }