diff --git a/README.md b/README.md index 8d1c0fb..30792e1 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Target use case: prediction-market resolution. Statements like "Kanye West's Del ## How It Works -1. **Assert** — someone posts a statement, a USDC bond, and a Resolution Spec (the rubric; stored off-chain on Arweave, its hash on-chain) +1. **Assert** — someone posts a statement, a USDC bond, and a Resolution Spec (the rubric; stored off-chain on Arweave, with a retrievable transaction ID and SHA-256 digest on-chain) 2. **Wait** — liveness window where anyone can dispute 3. **Undisputed** — if no dispute, resolves `True` 4. **Disputed** — the first dispute triggers on-chain LLM resolution: a single trusted off-chain resolver posts the verdict via the resolver-gated `submit_llm_resolution` instruction `[Built]`; the off-chain service that makes the LLM call is `[MVP-target]` (the former 3-feed Switchboard council was removed per [ADR-0002](docs/adr/0002-trusted-llm-resolver.md)) @@ -25,7 +25,7 @@ The LLM layer is deliberately trusted, not trustless: a wrong verdict is challen - `True` — verified under the spec - `False` — contradicted under the spec -- `Unresolvable` — cannot be decided under the spec (ambiguous, conflicting, premature, or no outcome reached the vote's supermajority). `[MVP-target]` Settles **no-fault**: both bonds are returned, no one is slashed, and the assertion is voided (today any non-`True` outcome is settled like `False`) ([ADR-0005](docs/adr/0005-no-fault-unresolvable.md)). +- `Unresolvable` — cannot be decided under the spec (ambiguous, conflicting, premature, or no outcome reached the vote's supermajority). `[Built]` Settles **no-fault**: all assertion/dispute bonds are returned, no fee is taken, no one is slashed, and the assertion is voided ([ADR-0005](docs/adr/0005-no-fault-unresolvable.md)). ## Voting `[MVP-target]` @@ -102,10 +102,10 @@ The optimistic core is built: the account model, the six-state machine (`Asserte These pieces are v1 targets, not yet shipped: -- **Trusted LLM resolver** — the on-chain half is built: `submit_llm_resolution` is gated on a dedicated `ProtocolConfig.resolver` key (separate from `authority`, so a leaked hot resolver key can only post a challengeable verdict) and accepts only `True`/`False`/`Unresolvable`. The off-chain service that makes the real LLM call and posts verdicts is the remaining target; the former 3-feed Switchboard council was removed per ADR-0002. On-chain LLM provenance (prompt/response/evidence hashing) is deferred to a `[Vision]` trust-minimized resolver. +- **Trusted LLM resolver** — the on-chain instruction and off-chain worker are built: `submit_llm_resolution` is gated on a rotatable `ProtocolConfig.resolver` key (separate from `authority`), and the worker verifies each Resolution Spec before calling Gemini and posting `True`/`False`/`Unresolvable`. Live Google Search grounding remains disabled until the team supplies a paid Gemini key. On-chain provenance hashes remain a `[Vision]` item. - **Private staked voting** — opening a vote sets up the round, but real MagicBlock private voting (delegation, ER settlement) is the MVP target. -- **Resolution Spec on Arweave** — the on-chain `auxiliary_hash` field exists; off-chain Arweave storage and integrity-checking is planned. -- **No-fault settlement & reward split** — `Unresolvable` no-fault settlement and the share-based settlement split (`llm_disputer_reward_share_bps`, `vote_disputer_reward_share_bps`, `voter_reward_share_bps`, `treasury_share_bps`) are planned; only `protocol_fee_bps` is applied today. +- **Resolution Spec upload UX** — `create_assertion` now requires a bounded `ar://#sha256=` reference, and the resolver fetches and verifies it before judging. Product-side Arweave upload UI is still planned. +- **Share-based reward split** — `Unresolvable` no-fault settlement is built. The share-based settlement split (`llm_disputer_reward_share_bps`, `vote_disputer_reward_share_bps`, `voter_reward_share_bps`, `treasury_share_bps`) is still planned; only `protocol_fee_bps` is applied to decisive settlements today. - **Field names** — state and config fields still carry the legacy `pusd` prefix; a later PR renames them to `usdc` to match the committed asset. ## Vision (post-MVP) diff --git a/docs/architecture.md b/docs/architecture.md index 7b17474..edfb33b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,12 +13,12 @@ This document describes the target architecture for the prediction-market wedge. - `PendingLLM` and `PendingVote` are intermediary states: a dispute has been accepted, but the next resolution layer is not finished or active yet. `[Built]` - `Resolved` is terminal: `outcome` is set and irreversible consumers can settle. `[Built]` - The statement lives onchain as a fixed-size byte array (null-terminated string, max 280 bytes). `[Built]` -- The Resolution Spec lives **off-chain on Arweave**; only `auxiliary_hash` is stored onchain (max 128 bytes) so anyone can fetch the spec and verify integrity. The spec **is the source of truth** — the LLM resolver and voters apply it, they do not adjudicate universal reality. `[MVP-target]` (see [ADR-0001](adr/0001-rubric-relative-truth.md)) +- The Resolution Spec lives **off-chain on Arweave**; `auxiliary_hash` stores a bounded `ar://#sha256=` reference so anyone can fetch the exact transaction and verify its bytes. The spec **is the source of truth** — the LLM resolver and voters apply it, they do not adjudicate universal reality. `[Built]` (upload UI remains product work; see [ADR-0001](adr/0001-rubric-relative-truth.md)) - **USDC** is the single collateral asset for bonds, slashing, rewards, and fees. The mint is a config field (`pusd_mint`, slated to rename to `usdc_mint`) so localnet/devnet can use a test mint, but the protocol commits to USDC. `[MVP-target]` (see [ADR-0004](adr/0004-single-asset-usdc.md)) - Governance is the `authority` keypair. There is no separate governance token in the MVP. `[MVP-target]` - LLM resolution is a **single trusted off-chain resolver** that posts the verdict via `submit_llm_resolution`, gated on the dedicated `ProtocolConfig.resolver` key `[Built]`; the off-chain resolver service is `[MVP-target]`. The former 3-feed Switchboard council was removed per [ADR-0002](adr/0002-trusted-llm-resolver.md). Binding LLM provenance hashes onchain is not part of the MVP — it is deferred to `[Vision]`. - Final escalation is a **private, USDC-staked vote on a MagicBlock ephemeral rollup** with linear weight (1 USDC = 1 vote) and Schelling-point slashing. A single outcome must reach `supermajority_bps`, otherwise the vote resolves `Unresolvable`. `[MVP-target]` (see [ADR-0003](adr/0003-private-staked-voting.md)) -- `Unresolvable` settles **no-fault**: both bonds returned, nobody slashed, assertion voided. `[MVP-target]` (see [ADR-0005](adr/0005-no-fault-unresolvable.md)) +- `Unresolvable` settles **no-fault**: all assertion/dispute bonds are returned, no fee is taken, nobody is slashed, and the assertion is voided. `[Built]` (see [ADR-0005](adr/0005-no-fault-unresolvable.md)) > Note on field names: account fields still carry the legacy `pusd` prefix (`assertion_bond_amount_pusd`, `bond_amount_pusd`, `pusd_mint`, …). These are USDC; the `*_pusd → *_usdc` / `pusd_mint → usdc_mint` rename is a separate follow-up PR. @@ -54,7 +54,7 @@ pub struct AssertionAccount { } ``` -`auxiliary_hash` is the hash of the off-chain Resolution Spec; resolving the assertion means applying that spec, not judging absolute truth. +`auxiliary_hash` is the off-chain Resolution Spec reference (`ar://<43-character-transaction-id>#sha256=<64-character-hex-digest>`); resolving the assertion means fetching, integrity-checking, and applying that spec, not judging absolute truth. Implementation notes: @@ -66,12 +66,13 @@ Implementation notes: - When voting is active, `state = VOTING`; the LLM result remains the challenged result until the vote resolves. - When finalized, `state = RESOLVED` and `outcome` is set. -### Resolution Spec (Arweave + `auxiliary_hash`) `[MVP-target]` +### Resolution Spec (Arweave + `auxiliary_hash`) `[Built]` The Resolution Spec is the asserter-supplied rubric — authoritative sources and their priority, key definitions, ambiguity handling, and when the statement becomes resolvable. It is the single most important artifact after the statement itself, because **it is the source of truth**: True means "true under this assertion's spec, correctly applied," not a universal fact (see [ADR-0001](adr/0001-rubric-relative-truth.md)). - The spec lives off-chain on **Arweave** (permanent, content-addressed) and must stay retrievable and integrity-checkable for the whole assertion lifecycle. -- Only its hash is stored onchain in `AssertionAccount.auxiliary_hash` (≤128 bytes), so anyone can fetch the spec and verify it matches. +- `AssertionAccount.auxiliary_hash` stores `ar://<43-character-transaction-id>#sha256=<64-character-hex-digest>` (120 bytes): the transaction ID makes it retrievable and the digest authenticates the exact downloaded bytes. +- `create_assertion` rejects malformed references. The resolver enforces a fetch timeout and size bound, then verifies SHA-256 and UTF-8 before the spec reaches the model. Missing or mismatched content produces no verdict and is retried. - The LLM resolver and the voters **apply** the spec; disputes and votes are framed as "applying this spec, the answer is X," not "in reality it's X." - Vetting the spec is the integrator's responsibility: garbage spec in → garbage truth out, faithfully applied. An integrator must read the spec before trusting an outcome. @@ -93,7 +94,7 @@ pub struct LlmDisputeAccount { } ``` -`settlement_resolution` records the outcome this dispute settled against: if the LLM result is not challenged, it is the LLM result; if it is challenged, it is the final vote result. Settlement does **not** use a blanket `!= True ⇒ disputer correct` rule — a `True`/`False` outcome slashes the wrong side via the configured share split, while an `Unresolvable` outcome settles no-fault (both bonds returned, nobody slashed). See [ADR-0005](adr/0005-no-fault-unresolvable.md) and [resolution.md](resolution.md). `[MVP-target]` +`settlement_resolution` records the outcome this dispute settled against: if the LLM result is not challenged, it is the LLM result; if it is challenged, it is the final vote result. Settlement does **not** use a blanket `!= True ⇒ disputer correct` rule — a `True`/`False` outcome slashes the wrong side, while an `Unresolvable` outcome settles no-fault (all principals returned, no fee, nobody slashed). See [ADR-0005](adr/0005-no-fault-unresolvable.md) and [resolution.md](resolution.md). `[Built]` ### `VoteDisputeAccount` `[Built]` @@ -115,7 +116,7 @@ pub struct VoteDisputeAccount { } ``` -`settlement_resolution` is the final vote outcome. As above, `True`/`False` slashes the side that backed the other answer and an `Unresolvable` vote settles no-fault `[MVP-target]` (today the code treats any non-`True` outcome like `False`). +`settlement_resolution` is the final vote outcome. As above, `True`/`False` slashes the side that backed the other answer and an `Unresolvable` vote settles no-fault. `[Built]` ### `BondVault` `[Built]` @@ -139,7 +140,7 @@ pub struct LlmResolutionRound { } ``` -`outcome` is an outcome code: `0 = True`, `1 = False`, `3 = Unresolvable`. The code `2` (`TooEarly`) persists in the constants and `validate_outcome_code` still accepts it, but `submit_llm_resolution` rejects it; merging it into `Unresolvable` is `[MVP-target]` (see [ADR-0005](adr/0005-no-fault-unresolvable.md)). Today the resolver key sets `outcome` from an argument via `submit_llm_resolution`; the `[MVP-target]` off-chain service sets it from its single LLM call. +`outcome` is an outcome code: `0 = True`, `1 = False`, `3 = Unresolvable`. The deprecated code `2` (`TooEarly`) persists as a constant, but the shared outcome validator rejects it; premature claims resolve as `Unresolvable` per [ADR-0005](adr/0005-no-fault-unresolvable.md). Today the resolver key sets `outcome` from an argument via `submit_llm_resolution`; the `[MVP-target]` off-chain service sets it from its single LLM call. ### `VoteResolutionRound` `[Built]` (struct) / `[MVP-target]` (private vote) @@ -195,12 +196,14 @@ pub struct ProtocolConfig { pub llm_challenge_window_seconds: i64, pub vote_setup_window_seconds: i64, pub voting_window_seconds: i64, + pub llm_resolution_timeout_seconds: i64, pub bump: u8, } ``` - `authority` is governance for the MVP (no separate governance token). -- `resolver` is the only key allowed to post LLM verdicts (`submit_llm_resolution`). It is deliberately separate from `authority`: the resolver is a hot key held by the off-chain service, and a leak exposes only a challengeable verdict, not governance. +- `resolver` is the only key allowed to post LLM verdicts (`submit_llm_resolution`). It is deliberately separate from `authority`: the resolver is a hot key held by the off-chain service, and a leak exposes only a challengeable verdict, not governance. Governance can rotate it through `update_resolver`. +- `llm_resolution_timeout_seconds` bounds how long an assertion can remain `PendingLLM`; after it expires, anyone can call `recover_pending_llm` to post a challengeable `Unresolvable` fallback. - `supermajority_bps` is a **config field** (e.g. `6700` in tests), not a hardcoded constant: it is the weighted threshold a single outcome must reach in a vote, otherwise the outcome is `Unresolvable`. - The reward-share fields (`llm_disputer_reward_share_bps`, `vote_disputer_reward_share_bps`, `voter_reward_share_bps`, `treasury_share_bps`) divide the slashed pot. Today only `protocol_fee_bps` is applied `[Built]`; the full share-based split is `[MVP-target]`. @@ -213,7 +216,7 @@ An SPL token account owned by the protocol authority. Configured in `ProtocolCon The instruction names below are stable for this PR. 1. `create_assertion` `[Built]` - - Stores the statement and the Resolution Spec hash (`auxiliary_hash`). + - Stores the statement and validated Resolution Spec reference (`auxiliary_hash`). - Locks the asserter's USDC bond. - Sets `state = ASSERTED`, `outcome = OUTCOME_NONE`, and `liveness_deadline`. @@ -224,42 +227,50 @@ The instruction names below are stable for this PR. - Creates `LlmResolutionRound`. - Sets `state = PENDING_LLM`, `dispute_count = 1`, and round/dispute pointers on `AssertionAccount`. -3. `submit_llm_resolution` `[Built]` _(instruction)_ / `[MVP-target]` _(off-chain resolver service)_ +3. `submit_llm_resolution` `[Built]` - Gated to `protocol_config.resolver` — a dedicated key, separate from `authority`, so a leaked hot resolver key can only post a challengeable verdict. - Accepts only `True`, `False`, or `Unresolvable`; `TooEarly` is rejected per [ADR-0005](adr/0005-no-fault-unresolvable.md). - Sets `AssertionAccount.state = ASSERTED_LLM` and opens the LLM challenge deadline. - - The `[MVP-target]` off-chain trusted resolver makes a single LLM call and posts the verdict through this instruction; on-chain provenance hashing is deferred to `[Vision]`. (The name previously belonged to the removed Switchboard council instruction — [ADR-0002](adr/0002-trusted-llm-resolver.md).) + - The off-chain trusted resolver verifies the assertion's Resolution Spec, makes a single LLM call, and posts the verdict through this instruction. On-chain provenance hashing is deferred to `[Vision]`. -4. `finalize_llm_resolution` `[Built]` +4. `recover_pending_llm` `[Built]` + - Permissionless after `requested_at + llm_resolution_timeout_seconds` while the assertion is still `PENDING_LLM`. + - Posts `Unresolvable` and opens the normal challenge window. It races safely with the resolver because both paths require `PENDING_LLM`. + +5. `finalize_llm_resolution` `[Built]` - Allowed after the LLM challenge window if no vote dispute exists. - Sets `state = RESOLVED` and `outcome = LlmResolutionRound.outcome`. - - Sets `LlmDisputeAccount.settlement_resolution` and settles bonds (share-based split / no-fault is `[MVP-target]`). + - Sets `LlmDisputeAccount.settlement_resolution` and settles bonds. `Unresolvable` returns both principals with no fee `[Built]`; the share-based split for decisive outcomes remains `[MVP-target]`. -5. `challenge_llm_resolution` `[Built]` +6. `challenge_llm_resolution` `[Built]` - Allowed while the assertion is `ASSERTED_LLM` and before the LLM challenge deadline. - Locks the second disputer's USDC bond. - Creates `VoteDisputeAccount` with `challenged_llm_resolution = LlmResolutionRound.outcome`. - Creates `VoteResolutionRound`. - Sets `state = PENDING_VOTE`, `dispute_count = 2`, and round/dispute pointers on `AssertionAccount`. -6. `open_vote` `[Built]` (state plumbing) / `[MVP-target]` (real ER delegation) +7. `open_vote` `[Built]` (state plumbing) / `[MVP-target]` (real ER delegation) - Sets voting deadlines on `VoteResolutionRound`. - Moves the assertion from `PENDING_VOTE` to `VOTING`. - Sets `delegated = BOOL_TRUE`. In the MVP this delegates the vote state to the MagicBlock ephemeral rollup; auth policy for opening the vote is still to be finalized (currently permissionless for liveness). -7. `finalize_vote_resolution_placeholder` `[Built]` (state plumbing) / `[MVP-target]` (real tally) +8. `finalize_vote_resolution_placeholder` `[Built]` (state plumbing) / `[MVP-target]` (real tally) - Allowed after the voting window expires. - Sets `VoteResolutionRound.final_outcome` (currently from an argument; in the MVP from the private tally, applying `supermajority_bps` → `Unresolvable` and the MagicBlock commit/undelegate). - Sets `AssertionAccount.state = RESOLVED` and `AssertionAccount.outcome`. - Sets settlement fields on both dispute accounts and settles bonds. -8. `finalize_undisputed` `[Built]` +9. `finalize_undisputed` `[Built]` - Allowed after `liveness_deadline` if no dispute exists. - Sets `state = RESOLVED` and `outcome = TRUE`. - Returns the asserter bond minus configured fees. -9. `initialize_protocol_config` `[Built]` - - Authority-gated bootstrap of the `ProtocolConfig` singleton. +10. `initialize_protocol_config` `[Built]` + - Verifies the signer against the program-data account's upgrade authority before creating the singleton, preventing first-initializer takeover. + - Rejects a default resolver or a resolver equal to governance. + +11. `update_resolver` `[Built]` + - Governance-only rotation of the hot resolver key; rejects default, governance, and no-op values. ## State Machine `[Built]` @@ -294,7 +305,7 @@ Voting Resolved(VoteResolutionRound.final_outcome) ``` -When a terminal outcome is `Unresolvable`, settlement is no-fault: both bonds returned, nobody slashed, the assertion is voided (re-assert later if it becomes determinable). See [ADR-0005](adr/0005-no-fault-unresolvable.md). +When a terminal outcome is `Unresolvable`, settlement is no-fault: all assertion/dispute bonds are returned, no fee is taken, nobody is slashed, and the assertion is voided (re-assert later if it becomes determinable). See [ADR-0005](adr/0005-no-fault-unresolvable.md). ## Integrator Contract @@ -302,7 +313,7 @@ Prediction markets and other consumers should read assertion id, statement, auxi Integrator rules: -- The `auxiliary_hash` points at the off-chain Resolution Spec on Arweave. An integrator **must read the spec** to judge whether an outcome is meaningful for its use case — outcomes are relative to the spec, not to absolute reality. +- The `auxiliary_hash` points at and authenticates the off-chain Resolution Spec on Arweave. An integrator **must read and verify the spec** to judge whether an outcome is meaningful for its use case — outcomes are relative to the spec, not to absolute reality. - In `ASSERTED`, the current non-final answer is the optimistic default `True`. - In `ASSERTED_LLM`, the current non-final answer is `LlmResolutionRound.outcome`. - In `PENDING_VOTE` and `VOTING`, the LLM answer is under challenge; final answer is not available until `RESOLVED`. diff --git a/docs/glossary.md b/docs/glossary.md index bb6f5ed..eae1c9f 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -23,8 +23,8 @@ The on-chain object created when an asserter posts a statement, a USDC bond, and **Statement** — `statement: [u8; 280]` `[Built]` The short human-readable sentence whose truth is asserted. Null-terminated, stored on-chain. -**Resolution Spec** — `auxiliary_hash` + off-chain content `[MVP-target]` -The asserter-supplied rubric that defines _how_ the statement resolves: authoritative sources and their priority, key definitions, ambiguity handling, and when the statement becomes resolvable. **It is the source of truth** — the LLM resolver and voters apply it; they do not judge absolute reality. The spec lives off-chain on Arweave (permanent, content-addressed); only its hash is stored on-chain (`auxiliary_hash`, ≤128 bytes), so anyone can fetch it and verify integrity. See [ADR-0001](adr/0001-rubric-relative-truth.md). +**Resolution Spec** — `auxiliary_hash` + off-chain content `[Built]` +The asserter-supplied rubric that defines _how_ the statement resolves: authoritative sources and their priority, key definitions, ambiguity handling, and when the statement becomes resolvable. **It is the source of truth** — the LLM resolver and voters apply it; they do not judge absolute reality. The spec lives off-chain on Arweave; `auxiliary_hash` stores `ar://#sha256=` so anyone can fetch and verify the exact bytes. See [ADR-0001](adr/0001-rubric-relative-truth.md). _Avoid_: "auxiliary data" framed as optional hints; "evidence" — it is the spec, not a hint. **Rubric-relative truth** `[MVP-target]` @@ -49,8 +49,8 @@ The terminal result on the assertion; `OUTCOME_NONE` (255) until `state == Resol - `True` (0) `[Built]` — verified under the spec. - `False` (1) `[Built]` — contradicted under the spec. -- `Unresolvable` (3) `[MVP-target]` — cannot be decided under the spec (ambiguous, conflicting, premature, or below the vote's supermajority). Settles **no-fault** (see Economics). -- `TooEarly` (2) — the code value persists and `validate_outcome_code` still accepts it; no path is intended to emit it. **Merging it into `Unresolvable` is `[MVP-target]`.** _Avoid_ treating it as a distinct outcome. +- `Unresolvable` (3) `[Built]` — cannot be decided under the spec (ambiguous, conflicting, premature, or below the vote's supermajority). Settles **no-fault** (see Economics). +- `TooEarly` (2) — deprecated. The code value persists, but the shared outcome validator rejects it; use `Unresolvable`. _Avoid_ treating it as a distinct outcome. - `None` (255) — sentinel for unset. ## Participants @@ -60,7 +60,7 @@ The terminal result on the assertion; `OUTCOME_NONE` (255) until `state == Resol **LLM Disputer** `[Built]` — the first disputer; challenges the default `True` and triggers LLM resolution. **Vote Disputer** `[Built]` — the second disputer; challenges the LLM verdict and triggers the staked vote. **Voter** `[MVP-target]` — anyone who stakes USDC into a specific vote round; weight is linear in stake; wrong-side stake is slashed, right-side stake earns rewards. -**LLM Resolver** `[MVP-target]` — a trusted off-chain service that calls one LLM and posts the verdict via `submit_llm_resolution` `[Built]`, gated on the dedicated `ProtocolConfig.resolver` key. (On-chain LLM provenance hashing is deferred to `[Vision]`.) See [ADR-0002](adr/0002-trusted-llm-resolver.md). +**LLM Resolver** `[Built]` — a trusted off-chain service that verifies the Resolution Spec, calls one LLM, and posts the verdict via `submit_llm_resolution`, gated on the dedicated, governance-rotatable `ProtocolConfig.resolver` key. Live search grounding awaits a paid Gemini key; on-chain provenance hashing is deferred to `[Vision]`. See [ADR-0002](adr/0002-trusted-llm-resolver.md). _Avoid_: "council" for the resolver — the 3-feed Switchboard council was removed per ADR-0002 and was never the resolver design. **Integrator** — any app consuming Opal outcomes; must read the Resolution Spec to judge whether an outcome is meaningful for its use case, and should require `state == Resolved` before irreversible settlement. @@ -75,7 +75,7 @@ _Avoid_: calling the asset "pusd" (it is USDC — `pusd` is only the legacy on-c **Schelling-point vote** `[MVP-target]` — the staked vote is a coordination game on the truth: losing-side voters are slashed and winning-side voters are paid from the losing side, so the equilibrium is to vote the spec's honest answer. Security comes from this slashing, not from any weight curve. See [ADR-0003](adr/0003-private-staked-voting.md). -**No-fault settlement** `[MVP-target]` — when an assertion resolves `Unresolvable`, all bonds are returned and no one is slashed; the assertion is voided. See [ADR-0005](adr/0005-no-fault-unresolvable.md). +**No-fault settlement** `[Built]` — when an assertion resolves `Unresolvable`, all bonds are returned, no fee is taken, and no one is slashed; the assertion is voided. See [ADR-0005](adr/0005-no-fault-unresolvable.md). _Avoid_: the legacy `!= True ⇒ disputer correct` rule, which slashed on indeterminate outcomes. **Settlement Split** `[MVP-target]` — the slashed pot is divided by configured shares (`llm_disputer_reward_share_bps`, `vote_disputer_reward_share_bps`, `voter_reward_share_bps`, `treasury_share_bps`, summing ≤ 100%). Today only `protocol_fee_bps` is applied `[Built]`; the share-based split is `[MVP-target]`. diff --git a/docs/resolution.md b/docs/resolution.md index 5e1dcef..5557cd9 100644 --- a/docs/resolution.md +++ b/docs/resolution.md @@ -9,13 +9,13 @@ Throughout, "truth" means **rubric-relative truth**: the answer is judged agains Every assertion includes: 1. An on-chain `statement`: a short natural-language sentence (max 280 bytes, null-terminated). `[Built]` -2. An on-chain `auxiliary_hash`: the content hash of the off-chain **Resolution Spec** (max 128 bytes). `[Built]` (the field); `[MVP-target]` (the spec workflow) +2. An on-chain `auxiliary_hash`: `ar://<43-character-transaction-id>#sha256=<64-character-hex-digest>` for the off-chain **Resolution Spec** (120 of the available 128 bytes). `[Built]` -### The Resolution Spec `[MVP-target]` +### The Resolution Spec `[Built]` The Resolution Spec is the asserter-supplied rubric that defines _how_ the statement resolves: authoritative sources and their priority, key definitions, ambiguity handling, and when the statement becomes resolvable. **It is the source of truth.** The LLM resolver and the voters _apply_ the spec; they do not adjudicate absolute reality, so the same statement text can resolve differently across assertions. See [ADR-0001](adr/0001-rubric-relative-truth.md). -The spec lives off-chain on **Arweave** (permanent, content-addressed); only its hash is stored on-chain in `auxiliary_hash`, so anyone can fetch the spec and verify its integrity. The protocol does not enforce a schema for the spec content. If the spec is vague, missing, or contradictory, the statement is more likely to resolve `Unresolvable` — vetting the spec before trusting an outcome is the integrator's responsibility. +The spec lives off-chain on **Arweave** (permanent, content-addressed). The on-chain `auxiliary_hash` stores both its transaction ID and SHA-256 digest in the bounded form `ar://#sha256=`, so anyone can retrieve the exact transaction and verify its bytes. `create_assertion` validates this reference format; the resolver enforces fetch timeout/size limits, verifies the digest and UTF-8, and only then passes the spec to the model. The protocol does not enforce a schema for the spec content. If the spec is vague or contradictory, the statement is more likely to resolve `Unresolvable` — vetting the spec before trusting an outcome is the integrator's responsibility. ## Outcome Rules @@ -27,10 +27,10 @@ The evidence, applied through the spec, verifies the statement. **`False` (1)** `[Built]` The evidence, applied through the spec, contradicts the statement. -**`Unresolvable` (3)** `[MVP-target]` +**`Unresolvable` (3)** `[Built]` The statement cannot be decided under the spec: source priority is unclear, evidence conflicts or is unavailable, the statement is ambiguous, the spec is too weak, the truth does not exist yet, or the vote failed to reach `supermajority_bps`. Settles **no-fault** (see [Settlement Logic](#settlement-logic)). -**`TooEarly` (2)** — the code constant persists and `validate_outcome_code` still accepts it (0–3); `submit_llm_resolution` explicitly rejects it, though `finalize_vote_resolution_placeholder` can still technically accept it until the vote path is rebuilt. **Merging it into `Unresolvable` is `[MVP-target]`**: "the real-world truth does not exist yet" is just one way to be `Unresolvable`, and the target is that it settles identically. See [ADR-0005](adr/0005-no-fault-unresolvable.md). +**`TooEarly` (2)** — deprecated. The code constant persists, but the shared outcome validator rejects it. "The real-world truth does not exist yet" is represented by `Unresolvable`. See [ADR-0005](adr/0005-no-fault-unresolvable.md). **`None` (255)** `[Built]` — sentinel for unset; the value of `outcome` until `state == Resolved`. @@ -57,7 +57,7 @@ When an assertion is created (`create_assertion`): - `state = Asserted` - `outcome = None` (255) - `liveness_deadline` is set from `liveness_window_seconds` -- `auxiliary_hash` points to the off-chain Resolution Spec +- `auxiliary_hash` points to and authenticates the off-chain Resolution Spec While `Asserted`, the statement is treated as `True` by default. The liveness window is the only time a first dispute can be filed; if it expires undisputed, the assertion can be finalized `True` (`finalize_undisputed`). @@ -71,7 +71,9 @@ When the first dispute is filed (`dispute_assertion`): - `state = PendingLLM` - `dispute_count = 1` -The on-chain half is `[Built]`: `submit_llm_resolution` posts the verdict and is gated on a dedicated `ProtocolConfig.resolver` key — deliberately separate from `authority`, so a leaked hot resolver key can only post a challengeable verdict, not act as governance. It accepts only `True`, `False`, or `Unresolvable` (`TooEarly` is rejected per [ADR-0005](adr/0005-no-fault-unresolvable.md)). The off-chain **trusted resolver** service that makes the single LLM call and submits through this instruction is `[MVP-target]`. The LLM layer does not need to be trustless because the staked vote backstops it (a wrong verdict is challengeable). Binding LLM provenance (prompt/response/evidence hashes) on-chain for auditability is **not part of the MVP**; it is deferred to a `[Vision]` trust-minimized/permissionless resolver. +The resolver path is `[Built]`: `submit_llm_resolution` posts the verdict and is gated on a dedicated, governance-rotatable `ProtocolConfig.resolver` key — deliberately separate from `authority`, so a leaked hot resolver key can only post a challengeable verdict, not act as governance. The off-chain worker fetches and verifies the Resolution Spec, makes the LLM call, and submits only `True`, `False`, or `Unresolvable`. Live search grounding remains operationally deferred until a paid Gemini key is available. Binding prompt/response/evidence hashes on-chain is a post-MVP `[Vision]` item. + +If no resolver verdict arrives by `requested_at + llm_resolution_timeout_seconds`, anyone may call `recover_pending_llm`. It transitions the round to a challengeable `Unresolvable` result and opens the standard LLM challenge window; a later no-fault finalization returns both principals. The resolver submission and recovery paths share the `PendingLLM` guard, so only one can win. On localnet, integration tests call `submit_llm_resolution` directly with a test resolver keypair; the former `mock-llm` feature and `submit_mock_llm_resolution` were removed when the real instruction landed. (An earlier instruction of the same name belonged to the 3-feed Switchboard council — `set_council_feeds`, `council_feeds`, `switchboard_*`, and `*_hash` fields — removed per [ADR-0002](adr/0002-trusted-llm-resolver.md); it was compiled but never operationally stood up.) @@ -136,7 +138,7 @@ If the liveness window expires with no dispute: - asserter bond is returned minus the protocol fee - treasury receives the fee -### Indeterminate (`Unresolvable`) — no-fault `[MVP-target]` +### Indeterminate (`Unresolvable`) — no-fault `[Built]` If any path produces `Unresolvable`, the assertion settles **no-fault**: @@ -156,7 +158,7 @@ If `finalize_llm_resolution` settles a `True` or `False` outcome (no vote disput - the asserter wins: receives their bond + the LLM disputer bond, minus fee - treasury receives the fee -> Target `[MVP-target]`: `Unresolvable` takes the no-fault path above and does not flow through this win/lose branch. Today the code has no no-fault path — `Unresolvable` (like any `outcome != True`) still routes through the disputer-wins branch here (`llm_dispute_correct = final_outcome != OUTCOME_TRUE`), settling like `False`; this is the legacy behavior [ADR-0005](adr/0005-no-fault-unresolvable.md) replaces. +`Unresolvable` takes the no-fault path above and does not flow through this win/lose branch. This replaces the legacy `outcome != True` behavior described in [ADR-0005](adr/0005-no-fault-unresolvable.md). ### Second Dispute (Vote Resolution) — `True` / `False` `[MVP-target]` diff --git a/docs/tokenomics.md b/docs/tokenomics.md index e253a40..a1c3cee 100644 --- a/docs/tokenomics.md +++ b/docs/tokenomics.md @@ -54,9 +54,7 @@ llm_dispute_correct = settlement_resolution != True vote_dispute_correct = settlement_resolution != challenged_llm_resolution ``` -Today the LLM-dispute rule slashes the asserter on **any** non-`True` outcome (`False`, plus the still-accepted `TooEarly`/`Unresolvable` codes), not only on a genuine `False`. This is the legacy `!= True` rule, which wrongly assigns fault on indeterminate outcomes. - -> **No-fault override** `[MVP-target]`. When `settlement_resolution` is `Unresolvable`, neither side is "correct" or "wrong" — the assertion settles no-fault (below). This **replaces** the legacy `settlement_resolution != True ⇒ disputer correct` rule, which wrongly slashed the asserter (or, under a True-fallback, the disputer) whenever the outcome was merely indeterminate. Only a genuine `True`/`False` settlement assigns fault. See [ADR-0005](adr/0005-no-fault-unresolvable.md). +> **No-fault override** `[Built]`. When `settlement_resolution` is `Unresolvable`, neither side is "correct" or "wrong" — the assertion settles no-fault (below). This **replaces** the legacy `settlement_resolution != True ⇒ disputer correct` rule, which wrongly slashed the asserter whenever the outcome was merely indeterminate. Only a genuine `True`/`False` settlement assigns fault. See [ADR-0005](adr/0005-no-fault-unresolvable.md). ## Settlement Defaults @@ -68,7 +66,7 @@ If the liveness window expires with no dispute: - the asserter bond is returned minus the protocol fee (`protocol_fee_bps`), - the treasury receives the fee. -### Unresolvable — No-Fault `[MVP-target]` +### Unresolvable — No-Fault `[Built]` If the settled outcome is `Unresolvable` (ambiguous, conflicting, premature, or below the vote's `supermajority_bps`): @@ -76,7 +74,7 @@ If the settled outcome is `Unresolvable` (ambiguous, conflicting, premature, or - no one is slashed and no fee is taken, - the assertion is **voided** — re-assert later if it becomes determinable. -Merging `TooEarly` into `Unresolvable` is `[MVP-target]`; the `OUTCOME_TOO_EARLY` (2) constant persists and `validate_outcome_code` still accepts it, but no path is intended to emit it. See [ADR-0005](adr/0005-no-fault-unresolvable.md). +The deprecated `OUTCOME_TOO_EARLY` (2) constant persists, but the shared outcome validator rejects it. Prematurity is represented by `Unresolvable`. See [ADR-0005](adr/0005-no-fault-unresolvable.md). ### First Dispute Settles Correct (`False`) `[Built]` diff --git a/programs/opal/src/constants.rs b/programs/opal/src/constants.rs index ee5629d..a3d7cf7 100644 --- a/programs/opal/src/constants.rs +++ b/programs/opal/src/constants.rs @@ -10,6 +10,14 @@ pub const VOTE_ROUND_SEED: &[u8] = b"vote_round"; pub const MAX_STATEMENT_LEN: usize = 280; pub const MAX_AUXILIARY_HASH_LEN: usize = 128; +pub const RESOLUTION_SPEC_REFERENCE_PREFIX: &[u8] = b"ar://"; +pub const RESOLUTION_SPEC_REFERENCE_DIGEST_SEPARATOR: &[u8] = b"#sha256="; +pub const ARWEAVE_TRANSACTION_ID_LEN: usize = 43; +pub const SHA256_HEX_LEN: usize = 64; +pub const RESOLUTION_SPEC_REFERENCE_LEN: usize = RESOLUTION_SPEC_REFERENCE_PREFIX.len() + + ARWEAVE_TRANSACTION_ID_LEN + + RESOLUTION_SPEC_REFERENCE_DIGEST_SEPARATOR.len() + + SHA256_HEX_LEN; pub const ASSERTION_STATE_ASSERTED: u8 = 0; pub const ASSERTION_STATE_PENDING_LLM: u8 = 1; diff --git a/programs/opal/src/errors.rs b/programs/opal/src/errors.rs index 5ef77c4..61184a8 100644 --- a/programs/opal/src/errors.rs +++ b/programs/opal/src/errors.rs @@ -38,6 +38,8 @@ pub enum OpalError { StatementTooLong, #[msg("Auxiliary hash exceeds max length")] AuxiliaryHashTooLong, + #[msg("Invalid Resolution Spec reference")] + InvalidResolutionSpecReference, #[msg("Invalid treasury token account")] InvalidTreasuryAccount, #[msg("Invalid asserter token account")] diff --git a/programs/opal/src/instructions/create_assertion.rs b/programs/opal/src/instructions/create_assertion.rs index 2be57c8..0ba1f91 100644 --- a/programs/opal/src/instructions/create_assertion.rs +++ b/programs/opal/src/instructions/create_assertion.rs @@ -5,7 +5,7 @@ use crate::{ }, errors::OpalError, state::{AssertionAccount, ProtocolConfig}, - utils::checked_add_i64, + utils::{checked_add_i64, validate_resolution_spec_reference}, }; use anchor_lang::prelude::*; use anchor_spl::token::{self, Mint, Token, TokenAccount, Transfer}; @@ -75,6 +75,7 @@ pub fn handler(ctx: Context, args: CreateAssertionArgs) -> Resu hash_bytes.len() <= MAX_AUXILIARY_HASH_LEN, OpalError::AuxiliaryHashTooLong ); + validate_resolution_spec_reference(&args.auxiliary_hash)?; let protocol_config = ctx.accounts.protocol_config.load()?; require!( diff --git a/programs/opal/src/instructions/finalize_llm_resolution.rs b/programs/opal/src/instructions/finalize_llm_resolution.rs index cac31e4..10ddee4 100644 --- a/programs/opal/src/instructions/finalize_llm_resolution.rs +++ b/programs/opal/src/instructions/finalize_llm_resolution.rs @@ -1,7 +1,7 @@ use crate::{ constants::{ ASSERTION_SEED, ASSERTION_STATE_ASSERTED_LLM, ASSERTION_STATE_RESOLVED, BOND_VAULT_SEED, - LLM_DISPUTE_SEED, LLM_ROUND_SEED, OUTCOME_TRUE, PROTOCOL_CONFIG_SEED, + LLM_DISPUTE_SEED, LLM_ROUND_SEED, OUTCOME_TRUE, OUTCOME_UNRESOLVABLE, PROTOCOL_CONFIG_SEED, }, errors::OpalError, state::{AssertionAccount, LlmDisputeAccount, LlmResolutionRound, ProtocolConfig}, @@ -154,31 +154,32 @@ pub fn handler( OpalError::InvalidTreasuryAccount ); - // !TBD: Reserved outcomes (TooEarly, Unresolvable) need explicit economic handling. - // Currently they are treated identically to False (disputer wins). - let llm_dispute_correct = final_outcome != OUTCOME_TRUE; - - let (asserter_payout, llm_disputer_payout, treasury_fee) = if llm_dispute_correct { - let fee = checked_bps(assertion_bond, protocol_config.protocol_fee_bps)?; - let llm_payout = llm_dispute_bond - .checked_add( - assertion_bond - .checked_sub(fee) - .ok_or(OpalError::MathOverflow)?, - ) - .ok_or(OpalError::MathOverflow)?; - (0, llm_payout, fee) - } else { - let fee = checked_bps(llm_dispute_bond, protocol_config.protocol_fee_bps)?; - let asserter_total = assertion_bond - .checked_add( - llm_dispute_bond - .checked_sub(fee) - .ok_or(OpalError::MathOverflow)?, - ) - .ok_or(OpalError::MathOverflow)?; - (asserter_total, 0, fee) - }; + let (asserter_payout, llm_disputer_payout, treasury_fee) = + if final_outcome == OUTCOME_UNRESOLVABLE { + // Nobody is at fault when the assertion cannot be decided under its + // resolution spec. Return both principals and charge no protocol fee. + (assertion_bond, llm_dispute_bond, 0) + } else if final_outcome != OUTCOME_TRUE { + let fee = checked_bps(assertion_bond, protocol_config.protocol_fee_bps)?; + let llm_payout = llm_dispute_bond + .checked_add( + assertion_bond + .checked_sub(fee) + .ok_or(OpalError::MathOverflow)?, + ) + .ok_or(OpalError::MathOverflow)?; + (0, llm_payout, fee) + } else { + let fee = checked_bps(llm_dispute_bond, protocol_config.protocol_fee_bps)?; + let asserter_total = assertion_bond + .checked_add( + llm_dispute_bond + .checked_sub(fee) + .ok_or(OpalError::MathOverflow)?, + ) + .ok_or(OpalError::MathOverflow)?; + (asserter_total, 0, fee) + }; let assertion_id = assertion.id; let bump = assertion.bump; diff --git a/programs/opal/src/instructions/finalize_vote_resolution_placeholder.rs b/programs/opal/src/instructions/finalize_vote_resolution_placeholder.rs index 9294d6b..51d3503 100644 --- a/programs/opal/src/instructions/finalize_vote_resolution_placeholder.rs +++ b/programs/opal/src/instructions/finalize_vote_resolution_placeholder.rs @@ -1,7 +1,8 @@ use crate::{ constants::{ ASSERTION_SEED, ASSERTION_STATE_RESOLVED, ASSERTION_STATE_VOTING, BOND_VAULT_SEED, - LLM_DISPUTE_SEED, OUTCOME_TRUE, PROTOCOL_CONFIG_SEED, VOTE_DISPUTE_SEED, VOTE_ROUND_SEED, + LLM_DISPUTE_SEED, OUTCOME_TRUE, OUTCOME_UNRESOLVABLE, PROTOCOL_CONFIG_SEED, + VOTE_DISPUTE_SEED, VOTE_ROUND_SEED, }, errors::OpalError, state::{ @@ -195,56 +196,68 @@ pub fn handler( OpalError::InvalidTreasuryAccount ); - // !TBD: Reserved outcomes (TooEarly, Unresolvable) need explicit economic handling. - // Currently they are treated identically to False (disputer wins). - let llm_dispute_correct = final_outcome != OUTCOME_TRUE; - let vote_dispute_correct = final_outcome != vote_dispute.challenged_llm_resolution; - - let (mut asserter_payout, mut llm_disputer_payout, stage_a_fee, stage_winner) = - if llm_dispute_correct { - let fee = checked_bps(assertion_bond, protocol_config.protocol_fee_bps)?; - let payout = llm_bond - .checked_add( - assertion_bond - .checked_sub(fee) - .ok_or(OpalError::MathOverflow)?, - ) - .ok_or(OpalError::MathOverflow)?; - (0, payout, fee, StageWinner::LlmDisputer) + let (asserter_payout, llm_disputer_payout, vote_disputer_payout, treasury_fee) = + if final_outcome == OUTCOME_UNRESOLVABLE { + // The final arbiter made no determination. Return every bond in full + // and do not assign fault or charge a protocol fee. + (assertion_bond, llm_bond, vote_bond, 0) } else { - let fee = checked_bps(llm_bond, protocol_config.protocol_fee_bps)?; - let payout = assertion_bond - .checked_add(llm_bond.checked_sub(fee).ok_or(OpalError::MathOverflow)?) - .ok_or(OpalError::MathOverflow)?; - (payout, 0, fee, StageWinner::Asserter) - }; - - let (vote_disputer_payout, stage_b_fee, stage_winner_bonus) = if vote_dispute_correct { - (vote_bond, 0, 0) - } else { - let fee = checked_bps(vote_bond, protocol_config.protocol_fee_bps)?; - let bonus = vote_bond.checked_sub(fee).ok_or(OpalError::MathOverflow)?; - (0, fee, bonus) - }; - - if stage_winner_bonus > 0 { - match stage_winner { - StageWinner::Asserter => { - asserter_payout = asserter_payout - .checked_add(stage_winner_bonus) - .ok_or(OpalError::MathOverflow)?; - } - StageWinner::LlmDisputer => { - llm_disputer_payout = llm_disputer_payout - .checked_add(stage_winner_bonus) - .ok_or(OpalError::MathOverflow)?; + let llm_dispute_correct = final_outcome != OUTCOME_TRUE; + let vote_dispute_correct = final_outcome != vote_dispute.challenged_llm_resolution; + + let (mut asserter_payout, mut llm_disputer_payout, stage_a_fee, stage_winner) = + if llm_dispute_correct { + let fee = checked_bps(assertion_bond, protocol_config.protocol_fee_bps)?; + let payout = llm_bond + .checked_add( + assertion_bond + .checked_sub(fee) + .ok_or(OpalError::MathOverflow)?, + ) + .ok_or(OpalError::MathOverflow)?; + (0, payout, fee, StageWinner::LlmDisputer) + } else { + let fee = checked_bps(llm_bond, protocol_config.protocol_fee_bps)?; + let payout = assertion_bond + .checked_add(llm_bond.checked_sub(fee).ok_or(OpalError::MathOverflow)?) + .ok_or(OpalError::MathOverflow)?; + (payout, 0, fee, StageWinner::Asserter) + }; + + let (vote_disputer_payout, stage_b_fee, stage_winner_bonus) = if vote_dispute_correct { + (vote_bond, 0, 0) + } else { + let fee = checked_bps(vote_bond, protocol_config.protocol_fee_bps)?; + let bonus = vote_bond.checked_sub(fee).ok_or(OpalError::MathOverflow)?; + (0, fee, bonus) + }; + + if stage_winner_bonus > 0 { + match stage_winner { + StageWinner::Asserter => { + asserter_payout = asserter_payout + .checked_add(stage_winner_bonus) + .ok_or(OpalError::MathOverflow)?; + } + StageWinner::LlmDisputer => { + llm_disputer_payout = llm_disputer_payout + .checked_add(stage_winner_bonus) + .ok_or(OpalError::MathOverflow)?; + } + } } - } - } - let treasury_fee = stage_a_fee - .checked_add(stage_b_fee) - .ok_or(OpalError::MathOverflow)?; + let treasury_fee = stage_a_fee + .checked_add(stage_b_fee) + .ok_or(OpalError::MathOverflow)?; + + ( + asserter_payout, + llm_disputer_payout, + vote_disputer_payout, + treasury_fee, + ) + }; let assertion_id = assertion.id; let bump = assertion.bump; diff --git a/programs/opal/src/instructions/initialize_protocol_config.rs b/programs/opal/src/instructions/initialize_protocol_config.rs index 3b38935..9c65133 100644 --- a/programs/opal/src/instructions/initialize_protocol_config.rs +++ b/programs/opal/src/instructions/initialize_protocol_config.rs @@ -23,6 +23,7 @@ pub struct InitializeProtocolConfigArgs { pub llm_challenge_window_seconds: i64, pub vote_setup_window_seconds: i64, pub voting_window_seconds: i64, + pub llm_resolution_timeout_seconds: i64, } #[derive(Accounts)] @@ -46,6 +47,16 @@ pub struct InitializeProtocolConfig<'info> { )] pub treasury_pusd: Account<'info, TokenAccount>, + #[account( + constraint = program.programdata_address()? == Some(program_data.key()) @ OpalError::Unauthorized, + )] + pub program: Program<'info, crate::program::Opal>, + + #[account( + constraint = program_data.upgrade_authority_address == Some(authority.key()) @ OpalError::Unauthorized, + )] + pub program_data: Account<'info, ProgramData>, + pub system_program: Program<'info, System>, } @@ -53,13 +64,8 @@ pub fn handler( ctx: Context, args: InitializeProtocolConfigArgs, ) -> Result<()> { - // !TBD: PLACEHOLDER — uncomment before mainnet to restrict init to deployer. - // require!(ctx.accounts.authority.key() == crate::ID, OpalError::Unauthorized); - - // The config is immutable after init, so a mis-set resolver would permanently - // brick LLM resolution. require!( - !is_pubkey_default(&args.resolver), + !is_pubkey_default(&args.resolver) && args.resolver != ctx.accounts.authority.key(), OpalError::ConfigInvariantViolation ); require!( @@ -87,7 +93,8 @@ pub fn handler( args.liveness_window_seconds > 0 && args.llm_challenge_window_seconds > 0 && args.vote_setup_window_seconds >= 0 - && args.voting_window_seconds > 0, + && args.voting_window_seconds > 0 + && args.llm_resolution_timeout_seconds > 0, OpalError::InvalidWindowConfiguration ); @@ -122,6 +129,7 @@ pub fn handler( config.llm_challenge_window_seconds = args.llm_challenge_window_seconds; config.vote_setup_window_seconds = args.vote_setup_window_seconds; config.voting_window_seconds = args.voting_window_seconds; + config.llm_resolution_timeout_seconds = args.llm_resolution_timeout_seconds; config.bump = ctx.bumps.protocol_config; Ok(()) diff --git a/programs/opal/src/instructions/mod.rs b/programs/opal/src/instructions/mod.rs index a38378e..901e46e 100644 --- a/programs/opal/src/instructions/mod.rs +++ b/programs/opal/src/instructions/mod.rs @@ -6,7 +6,9 @@ pub mod finalize_undisputed; pub mod finalize_vote_resolution_placeholder; pub mod initialize_protocol_config; pub mod open_vote; +pub mod recover_pending_llm; pub mod submit_llm_resolution; +pub mod update_resolver; #[allow(ambiguous_glob_reexports)] pub use challenge_llm_resolution::*; @@ -25,4 +27,8 @@ pub use initialize_protocol_config::*; #[allow(ambiguous_glob_reexports)] pub use open_vote::*; #[allow(ambiguous_glob_reexports)] +pub use recover_pending_llm::*; +#[allow(ambiguous_glob_reexports)] pub use submit_llm_resolution::*; +#[allow(ambiguous_glob_reexports)] +pub use update_resolver::*; diff --git a/programs/opal/src/instructions/recover_pending_llm.rs b/programs/opal/src/instructions/recover_pending_llm.rs new file mode 100644 index 0000000..eced8f5 --- /dev/null +++ b/programs/opal/src/instructions/recover_pending_llm.rs @@ -0,0 +1,88 @@ +use crate::{ + constants::{ + ASSERTION_SEED, ASSERTION_STATE_ASSERTED_LLM, ASSERTION_STATE_PENDING_LLM, LLM_ROUND_SEED, + OUTCOME_NONE, OUTCOME_UNRESOLVABLE, PROTOCOL_CONFIG_SEED, + }, + errors::OpalError, + state::{AssertionAccount, LlmResolutionRound, ProtocolConfig}, + utils::checked_add_i64, +}; +use anchor_lang::prelude::*; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] +pub struct RecoverPendingLlmArgs { + pub assertion_id: Pubkey, +} + +#[derive(Accounts)] +#[instruction(args: RecoverPendingLlmArgs)] +pub struct RecoverPendingLlm<'info> { + pub finalizer: Signer<'info>, + + #[account( + seeds = [PROTOCOL_CONFIG_SEED], + bump, + )] + pub protocol_config: AccountLoader<'info, ProtocolConfig>, + + #[account( + mut, + seeds = [ASSERTION_SEED, args.assertion_id.as_ref()], + bump = assertion.load()?.bump, + )] + pub assertion: AccountLoader<'info, AssertionAccount>, + + #[account( + mut, + seeds = [LLM_ROUND_SEED, assertion.key().as_ref()], + bump = llm_resolution_round.load()?.bump, + )] + pub llm_resolution_round: AccountLoader<'info, LlmResolutionRound>, +} + +pub fn handler(ctx: Context, _args: RecoverPendingLlmArgs) -> Result<()> { + let now = Clock::get()?.unix_timestamp; + let config = ctx.accounts.protocol_config.load()?; + let assertion = ctx.accounts.assertion.load()?; + let llm_round = ctx.accounts.llm_resolution_round.load()?; + + require!( + assertion.state == ASSERTION_STATE_PENDING_LLM, + OpalError::InvalidState + ); + require!( + llm_round.assertion == ctx.accounts.assertion.key(), + OpalError::AssertionLinkMismatch + ); + require!( + llm_round.dispute == assertion.llm_dispute, + OpalError::RoundLinkMismatch + ); + require!( + assertion.llm_resolution_round == ctx.accounts.llm_resolution_round.key(), + OpalError::RoundLinkMismatch + ); + require!(llm_round.outcome == OUTCOME_NONE, OpalError::AlreadySettled); + + let recovery_deadline = checked_add_i64( + llm_round.requested_at, + config.llm_resolution_timeout_seconds, + )?; + require!(now >= recovery_deadline, OpalError::DeadlineNotReached); + let challenge_deadline = checked_add_i64(now, config.llm_challenge_window_seconds)?; + + drop(config); + drop(assertion); + drop(llm_round); + + let mut llm_round = ctx.accounts.llm_resolution_round.load_mut()?; + llm_round.outcome = OUTCOME_UNRESOLVABLE; + llm_round.resolved_at = now; + llm_round.challenge_deadline = challenge_deadline; + + let mut assertion = ctx.accounts.assertion.load_mut()?; + assertion.state = ASSERTION_STATE_ASSERTED_LLM; + assertion.llm_challenge_deadline = challenge_deadline; + + Ok(()) +} diff --git a/programs/opal/src/instructions/submit_llm_resolution.rs b/programs/opal/src/instructions/submit_llm_resolution.rs index 01639a9..9262422 100644 --- a/programs/opal/src/instructions/submit_llm_resolution.rs +++ b/programs/opal/src/instructions/submit_llm_resolution.rs @@ -1,7 +1,7 @@ use crate::{ constants::{ ASSERTION_SEED, ASSERTION_STATE_ASSERTED_LLM, ASSERTION_STATE_PENDING_LLM, LLM_ROUND_SEED, - OUTCOME_TOO_EARLY, PROTOCOL_CONFIG_SEED, + PROTOCOL_CONFIG_SEED, }, errors::OpalError, state::{AssertionAccount, LlmResolutionRound, ProtocolConfig}, @@ -58,9 +58,6 @@ pub fn handler(ctx: Context, args: SubmitLlmResolutionArgs) ); let outcome = validate_outcome_code(args.outcome_code)?; - // TooEarly is merged into Unresolvable per ADR-0005; the resolver emits - // only True, False, or Unresolvable. - require!(outcome != OUTCOME_TOO_EARLY, OpalError::InvalidOutcomeCode); let now = Clock::get()?.unix_timestamp; let challenge_deadline = checked_add_i64(now, protocol_config.llm_challenge_window_seconds)?; diff --git a/programs/opal/src/instructions/update_resolver.rs b/programs/opal/src/instructions/update_resolver.rs new file mode 100644 index 0000000..1bc7242 --- /dev/null +++ b/programs/opal/src/instructions/update_resolver.rs @@ -0,0 +1,45 @@ +use crate::{ + constants::PROTOCOL_CONFIG_SEED, errors::OpalError, state::ProtocolConfig, + utils::is_pubkey_default, +}; +use anchor_lang::prelude::*; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] +pub struct UpdateResolverArgs { + pub new_resolver: Pubkey, +} + +#[derive(Accounts)] +pub struct UpdateResolver<'info> { + pub authority: Signer<'info>, + + #[account( + mut, + seeds = [PROTOCOL_CONFIG_SEED], + bump = protocol_config.load()?.bump, + )] + pub protocol_config: AccountLoader<'info, ProtocolConfig>, +} + +pub fn handler(ctx: Context, args: UpdateResolverArgs) -> Result<()> { + let mut config = ctx.accounts.protocol_config.load_mut()?; + require!( + ctx.accounts.authority.key() == config.authority, + OpalError::Unauthorized + ); + require!( + !is_pubkey_default(&args.new_resolver) + && args.new_resolver != config.authority + && args.new_resolver != config.resolver, + OpalError::ConfigInvariantViolation + ); + + let previous_resolver = config.resolver; + config.resolver = args.new_resolver; + msg!( + "resolver rotated from {} to {}", + previous_resolver, + args.new_resolver + ); + Ok(()) +} diff --git a/programs/opal/src/lib.rs b/programs/opal/src/lib.rs index 7a769c5..32f5546 100644 --- a/programs/opal/src/lib.rs +++ b/programs/opal/src/lib.rs @@ -22,7 +22,9 @@ pub use instructions::initialize_protocol_config::{ InitializeProtocolConfig, InitializeProtocolConfigArgs, }; pub use instructions::open_vote::{OpenVote, OpenVoteArgs}; +pub use instructions::recover_pending_llm::{RecoverPendingLlm, RecoverPendingLlmArgs}; pub use instructions::submit_llm_resolution::{SubmitLlmResolution, SubmitLlmResolutionArgs}; +pub use instructions::update_resolver::{UpdateResolver, UpdateResolverArgs}; declare_id!("8NCcxyAzKiAHxJ9DMnADtxShYutS9w81wHcXqgCavTBy"); @@ -66,6 +68,17 @@ pub mod opal { instructions::submit_llm_resolution::handler(ctx, args) } + pub fn recover_pending_llm( + ctx: Context, + args: RecoverPendingLlmArgs, + ) -> Result<()> { + instructions::recover_pending_llm::handler(ctx, args) + } + + pub fn update_resolver(ctx: Context, args: UpdateResolverArgs) -> Result<()> { + instructions::update_resolver::handler(ctx, args) + } + pub fn finalize_llm_resolution( ctx: Context, args: FinalizeLlmResolutionArgs, diff --git a/programs/opal/src/state/protocol_config.rs b/programs/opal/src/state/protocol_config.rs index ca89f8c..4565ee3 100644 --- a/programs/opal/src/state/protocol_config.rs +++ b/programs/opal/src/state/protocol_config.rs @@ -20,5 +20,6 @@ pub struct ProtocolConfig { pub llm_challenge_window_seconds: i64, pub vote_setup_window_seconds: i64, pub voting_window_seconds: i64, + pub llm_resolution_timeout_seconds: i64, pub bump: u8, } diff --git a/programs/opal/src/utils.rs b/programs/opal/src/utils.rs index 8b9976c..6be98bd 100644 --- a/programs/opal/src/utils.rs +++ b/programs/opal/src/utils.rs @@ -1,17 +1,47 @@ use crate::{ - constants::{BPS_DENOMINATOR, OUTCOME_NONE, OUTCOME_UNRESOLVABLE, TIMESTAMP_NONE}, + constants::{ + ARWEAVE_TRANSACTION_ID_LEN, BPS_DENOMINATOR, OUTCOME_FALSE, OUTCOME_NONE, OUTCOME_TRUE, + OUTCOME_UNRESOLVABLE, RESOLUTION_SPEC_REFERENCE_DIGEST_SEPARATOR, + RESOLUTION_SPEC_REFERENCE_LEN, RESOLUTION_SPEC_REFERENCE_PREFIX, SHA256_HEX_LEN, + TIMESTAMP_NONE, + }, errors::OpalError, }; use anchor_lang::prelude::*; pub fn validate_outcome_code(outcome_code: u8) -> Result { require!( - outcome_code <= OUTCOME_UNRESOLVABLE, + matches!( + outcome_code, + OUTCOME_TRUE | OUTCOME_FALSE | OUTCOME_UNRESOLVABLE + ), OpalError::InvalidOutcomeCode ); Ok(outcome_code) } +pub fn validate_resolution_spec_reference(reference: &str) -> Result<()> { + let bytes = reference.as_bytes(); + let transaction_id_start = RESOLUTION_SPEC_REFERENCE_PREFIX.len(); + let transaction_id_end = transaction_id_start + ARWEAVE_TRANSACTION_ID_LEN; + let digest_separator_end = + transaction_id_end + RESOLUTION_SPEC_REFERENCE_DIGEST_SEPARATOR.len(); + let digest_end = digest_separator_end + SHA256_HEX_LEN; + + let valid = bytes.len() == RESOLUTION_SPEC_REFERENCE_LEN + && bytes.starts_with(RESOLUTION_SPEC_REFERENCE_PREFIX) + && bytes[transaction_id_start..transaction_id_end] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + && bytes[transaction_id_end..digest_separator_end] + == *RESOLUTION_SPEC_REFERENCE_DIGEST_SEPARATOR + && bytes[digest_separator_end..digest_end] + .iter() + .all(u8::is_ascii_hexdigit); + require!(valid, OpalError::InvalidResolutionSpecReference); + Ok(()) +} + pub fn checked_bps(amount: u64, bps: u16) -> Result { let result = (amount as u128) .checked_mul(bps as u128) diff --git a/tests/opal.test.ts b/tests/opal.test.ts index ee5e28d..0ce949f 100644 --- a/tests/opal.test.ts +++ b/tests/opal.test.ts @@ -13,6 +13,7 @@ import idl from '../target/idl/opal.json'; import type { Opal } from '../target/types/opal'; const TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'); +const BPF_UPGRADEABLE_LOADER_ID = new PublicKey('BPFLoaderUpgradeab1e11111111111111111111111'); const SEEDS = { PROTOCOL_CONFIG: Buffer.from('protocol_config'), @@ -41,8 +42,14 @@ const OUTCOME = { NONE: 255, }; +const RESOLUTION_SPEC_REFERENCE = `ar://${'A'.repeat(43)}#sha256=${'0'.repeat(64)}`; + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +function programDataAddress(programId: PublicKey): PublicKey { + return PublicKey.findProgramAddressSync([programId.toBuffer()], BPF_UPGRADEABLE_LOADER_ID)[0]; +} + async function fund(connection: Connection, pk: PublicKey, lamports: number) { const sig = await connection.requestAirdrop(pk, lamports); await connection.confirmTransaction(sig, 'confirmed'); @@ -167,12 +174,15 @@ async function setupProtocol( llmChallengeWindowSeconds: new BN(3), voteSetupWindowSeconds: new BN(1), votingWindowSeconds: new BN(3), + llmResolutionTimeoutSeconds: new BN(2), }) .accounts({ authority: authority.publicKey, protocolConfig: configPda, pusdMint: token.mint, treasuryPusd: treasuryAta, + program: program.programId, + programData: programDataAddress(program.programId), systemProgram: SystemProgram.programId, }) .signers([authority]) @@ -202,7 +212,12 @@ class TestContext { return new Assertion(id, derivePDAs(id, this.program.programId)); } - async createAssertion(a: Assertion, statement: string, bond: number, auxiliaryHash = 'hash') { + async createAssertion( + a: Assertion, + statement: string, + bond: number, + auxiliaryHash = RESOLUTION_SPEC_REFERENCE + ) { return this.program.methods .createAssertion({ assertionId: a.id, @@ -256,7 +271,26 @@ class TestContext { .rpc({ commitment: 'confirmed' }); } - async finalizeLlmResolution(a: Assertion) { + async recoverPendingLlm(a: Assertion) { + return this.program.methods + .recoverPendingLlm({ assertionId: a.id }) + .accounts({ + finalizer: this.provider.wallet.publicKey, + protocolConfig: this.proto.configPda, + assertion: a.pdas.assertion, + llmResolutionRound: a.pdas.llmRound, + }) + .rpc({ commitment: 'confirmed' }); + } + + async finalizeLlmResolution( + a: Assertion, + accountOverrides: Partial<{ + asserterPusd: PublicKey; + llmDisputerPusd: PublicKey; + treasuryPusd: PublicKey; + }> = {} + ) { return this.program.methods .finalizeLlmResolution({ assertionId: a.id }) .accounts({ @@ -271,6 +305,7 @@ class TestContext { llmDisputerPusd: this.token.llmDisputerAta, treasuryPusd: this.proto.treasuryAta, tokenProgram: TOKEN_PROGRAM_ID, + ...accountOverrides, }) .rpc({ commitment: 'confirmed' }); } @@ -379,8 +414,59 @@ describe('opal', () => { token = await buildTokenEnv(connection); }); - it('rejects invalid protocol config', async () => { + async function attemptProtocolInitialization( + authority: Keypair, + resolver: PublicKey, + assertionBondMinPusd: number, + programData = programDataAddress(program.programId) + ) { + const [configPda] = PublicKey.findProgramAddressSync( + [SEEDS.PROTOCOL_CONFIG], + program.programId + ); + const treasuryAta = ( + await getOrCreateAssociatedTokenAccount( + connection, + token.mintAuthority, + token.mint, + token.treasury.publicKey + ) + ).address; + + return program.methods + .initializeProtocolConfig({ + resolver, + assertionBondMinPusd: new BN(assertionBondMinPusd), + llmDisputeBondRatioBps: 5000, + voteDisputeBondRatioBps: 3000, + protocolFeeBps: 250, + llmDisputerRewardShareBps: 3000, + voteDisputerRewardShareBps: 2500, + voterRewardShareBps: 2500, + treasuryShareBps: 2000, + supermajorityBps: 6700, + livenessWindowSeconds: new BN(86400), + llmChallengeWindowSeconds: new BN(43200), + voteSetupWindowSeconds: new BN(3600), + votingWindowSeconds: new BN(86400), + llmResolutionTimeoutSeconds: new BN(300), + }) + .accounts({ + authority: authority.publicKey, + protocolConfig: configPda, + pusdMint: token.mint, + treasuryPusd: treasuryAta, + program: program.programId, + programData, + systemProgram: SystemProgram.programId, + }) + .signers([authority]) + .rpc({ commitment: 'confirmed' }); + } + + it('rejects initialization by a non-upgrade authority', async () => { const authority = Keypair.generate(); + const resolver = Keypair.generate(); await fund(connection, authority.publicKey, 10_000_000_000); const [configPda] = PublicKey.findProgramAddressSync( @@ -399,8 +485,8 @@ describe('opal', () => { await expect( program.methods .initializeProtocolConfig({ - resolver: authority.publicKey, - assertionBondMinPusd: new BN(0), + resolver: resolver.publicKey, + assertionBondMinPusd: new BN(100), llmDisputeBondRatioBps: 5000, voteDisputeBondRatioBps: 3000, protocolFeeBps: 250, @@ -413,12 +499,15 @@ describe('opal', () => { llmChallengeWindowSeconds: new BN(43200), voteSetupWindowSeconds: new BN(3600), votingWindowSeconds: new BN(86400), + llmResolutionTimeoutSeconds: new BN(300), }) .accounts({ authority: authority.publicKey, protocolConfig: configPda, pusdMint: token.mint, treasuryPusd: treasuryAta, + program: program.programId, + programData: programDataAddress(program.programId), systemProgram: SystemProgram.programId, }) .signers([authority]) @@ -427,8 +516,7 @@ describe('opal', () => { }); it('rejects protocol config with default resolver', async () => { - const authority = Keypair.generate(); - await fund(connection, authority.publicKey, 10_000_000_000); + const authority = provider.wallet.payer; const [configPda] = PublicKey.findProgramAddressSync( [SEEDS.PROTOCOL_CONFIG], @@ -460,12 +548,15 @@ describe('opal', () => { llmChallengeWindowSeconds: new BN(43200), voteSetupWindowSeconds: new BN(3600), votingWindowSeconds: new BN(86400), + llmResolutionTimeoutSeconds: new BN(300), }) .accounts({ authority: authority.publicKey, protocolConfig: configPda, pusdMint: token.mint, treasuryPusd: treasuryAta, + program: program.programId, + programData: programDataAddress(program.programId), systemProgram: SystemProgram.programId, }) .signers([authority]) @@ -473,18 +564,94 @@ describe('opal', () => { ).rejects.toThrow(); }); + it('rejects initialization with an unrelated ProgramData account', async () => { + await expect( + attemptProtocolInitialization( + provider.wallet.payer, + Keypair.generate().publicKey, + 100, + program.programId + ) + ).rejects.toThrow(); + }); + + it('rejects invalid protocol economics from the upgrade authority', async () => { + await expect( + attemptProtocolInitialization(provider.wallet.payer, Keypair.generate().publicKey, 0) + ).rejects.toThrow(); + }); + + it('rejects using the governance authority as the hot resolver', async () => { + await expect( + attemptProtocolInitialization(provider.wallet.payer, provider.wallet.publicKey, 100) + ).rejects.toThrow(); + }); + it('initializes protocol', async () => { proto = await setupProtocol(program, token, provider.wallet.payer); ctx = new TestContext(program, provider, connection, token, proto); }); + it('rotates the resolver only through governance', async () => { + const previousResolver = proto.resolver; + const nextResolver = Keypair.generate(); + const unauthorized = Keypair.generate(); + + await expect( + program.methods + .updateResolver({ newResolver: nextResolver.publicKey }) + .accounts({ + authority: unauthorized.publicKey, + protocolConfig: proto.configPda, + }) + .signers([unauthorized]) + .rpc({ commitment: 'confirmed' }) + ).rejects.toThrow(); + + for (const invalidResolver of [ + PublicKey.default, + proto.authority.publicKey, + proto.resolver.publicKey, + ]) { + await expect( + program.methods + .updateResolver({ newResolver: invalidResolver }) + .accounts({ + authority: proto.authority.publicKey, + protocolConfig: proto.configPda, + }) + .signers([proto.authority]) + .rpc({ commitment: 'confirmed' }) + ).rejects.toThrow(); + } + + await program.methods + .updateResolver({ newResolver: nextResolver.publicKey }) + .accounts({ + authority: proto.authority.publicKey, + protocolConfig: proto.configPda, + }) + .signers([proto.authority]) + .rpc({ commitment: 'confirmed' }); + + const config = await program.account.protocolConfig.fetch(proto.configPda); + expect(config.resolver.equals(nextResolver.publicKey)).toBe(true); + proto.resolver = nextResolver; + + const a = ctx.newAssertion(); + await ctx.createAssertion(a, 'Rotated resolver authorization', 200); + await ctx.disputeAssertion(a); + await expect(ctx.submitLlmResolution(a, OUTCOME.TRUE, previousResolver)).rejects.toThrow(); + await ctx.submitLlmResolution(a, OUTCOME.TRUE, nextResolver); + }); + it('undisputed path: creates, waits, finalizes with correct payouts', async () => { const a = ctx.newAssertion(); const bond = 200; const asserterStart = await balanceOf(connection, token.asserterAta); const treasuryStart = await balanceOf(connection, proto.treasuryAta); - await ctx.createAssertion(a, 'Bitcoin > $100k by 2026', bond, 'hash123'); + await ctx.createAssertion(a, 'Bitcoin > $100k by 2026', bond); const acc = await ctx.fetchAssertion(a); expect(acc.state).toBe(STATE.ASSERTED); @@ -509,9 +676,11 @@ describe('opal', () => { const a = ctx.newAssertion(); const bond = 200; - await ctx.createAssertion(a, 'ETH flips BTC', bond, 'abc'); + const asserterStart = await balanceOf(connection, token.asserterAta); + await ctx.createAssertion(a, 'ETH flips BTC', bond); const disputerStart = await balanceOf(connection, token.llmDisputerAta); + const treasuryStart = await balanceOf(connection, proto.treasuryAta); await ctx.disputeAssertion(a); @@ -541,12 +710,44 @@ describe('opal', () => { // disputer was correct (outcome != TRUE). Net gain = assertion bond - fee = 195. expect(await balanceOf(connection, token.llmDisputerAta)).toBe(disputerStart + 195); + const settledTotal = + (await balanceOf(connection, token.asserterAta)) + + (await balanceOf(connection, token.llmDisputerAta)) + + (await balanceOf(connection, proto.treasuryAta)); + expect(settledTotal).toBe(asserterStart + disputerStart + treasuryStart); + expect(await balanceOf(connection, a.pdas.bondVault)).toBe(0); + }); + + it('conserves all tokens when the LLM resolves True', async () => { + const a = ctx.newAssertion(); + const startTotal = + (await balanceOf(connection, token.asserterAta)) + + (await balanceOf(connection, token.llmDisputerAta)) + + (await balanceOf(connection, proto.treasuryAta)); + + await ctx.createAssertion(a, 'LLM true conservation', 200); + await ctx.disputeAssertion(a); + await ctx.submitLlmResolution(a, OUTCOME.TRUE); + await sleep(4000); + await ctx.finalizeLlmResolution(a); + + const settledTotal = + (await balanceOf(connection, token.asserterAta)) + + (await balanceOf(connection, token.llmDisputerAta)) + + (await balanceOf(connection, proto.treasuryAta)); + expect(settledTotal).toBe(startTotal); + expect(await balanceOf(connection, a.pdas.bondVault)).toBe(0); }); it('full escalation path: escalates to vote and resolves', async () => { const a = ctx.newAssertion(); + const startTotal = + (await balanceOf(connection, token.asserterAta)) + + (await balanceOf(connection, token.llmDisputerAta)) + + (await balanceOf(connection, token.voteDisputerAta)) + + (await balanceOf(connection, proto.treasuryAta)); - await ctx.createAssertion(a, 'Solana TPS > 10000', 500, 'perf'); + await ctx.createAssertion(a, 'Solana TPS > 10000', 500); await ctx.disputeAssertion(a); await ctx.submitLlmResolution(a, 0); await ctx.challengeLlmResolution(a); @@ -576,6 +777,39 @@ describe('opal', () => { const vr = await ctx.fetchVoteRound(a); expect(vr.finalOutcome).toBe(OUTCOME.FALSE); + + const settledTotal = + (await balanceOf(connection, token.asserterAta)) + + (await balanceOf(connection, token.llmDisputerAta)) + + (await balanceOf(connection, token.voteDisputerAta)) + + (await balanceOf(connection, proto.treasuryAta)); + expect(settledTotal).toBe(startTotal); + expect(await balanceOf(connection, a.pdas.bondVault)).toBe(0); + }); + + it('conserves all tokens when the vote resolves True', async () => { + const a = ctx.newAssertion(); + const startTotal = + (await balanceOf(connection, token.asserterAta)) + + (await balanceOf(connection, token.llmDisputerAta)) + + (await balanceOf(connection, token.voteDisputerAta)) + + (await balanceOf(connection, proto.treasuryAta)); + + await ctx.createAssertion(a, 'Vote true conservation', 500); + await ctx.disputeAssertion(a); + await ctx.submitLlmResolution(a, OUTCOME.FALSE); + await ctx.challengeLlmResolution(a); + await ctx.openVote(a); + await sleep(5000); + await ctx.finalizeVoteResolutionPlaceholder(a, OUTCOME.TRUE); + + const settledTotal = + (await balanceOf(connection, token.asserterAta)) + + (await balanceOf(connection, token.llmDisputerAta)) + + (await balanceOf(connection, token.voteDisputerAta)) + + (await balanceOf(connection, proto.treasuryAta)); + expect(settledTotal).toBe(startTotal); + expect(await balanceOf(connection, a.pdas.bondVault)).toBe(0); }); it('error: premature finalizeUndisputed', async () => { @@ -590,6 +824,13 @@ describe('opal', () => { await expect(ctx.createAssertion(a, 'Fail', 50)).rejects.toThrow(); }); + it('error: malformed Resolution Spec reference', async () => { + const a = ctx.newAssertion(); + await expect( + ctx.createAssertion(a, 'Missing verifiable spec', 200, 'devnet-rehearsal-no-spec') + ).rejects.toThrow(); + }); + it('error: disputing after liveness deadline', async () => { const a = ctx.newAssertion(); await ctx.createAssertion(a, 'Late dispute', 200); @@ -630,8 +871,34 @@ describe('opal', () => { await expect(ctx.submitLlmResolution(a, OUTCOME.FALSE)).rejects.toThrow(); }); - it('accepts an Unresolvable verdict from the resolver', async () => { + it('recovers a timed-out PendingLLM round as challengeable Unresolvable', async () => { + const a = ctx.newAssertion(); + await ctx.createAssertion(a, 'Resolver outage recovery', 200); + await ctx.disputeAssertion(a); + + await expect(ctx.recoverPendingLlm(a)).rejects.toThrow(); + await sleep(3000); + await ctx.recoverPendingLlm(a); + + const round = await ctx.fetchLlmRound(a); + expect(round.outcome).toBe(OUTCOME.UNRESOLVABLE); + expect(round.resolvedAt.toNumber()).toBeGreaterThan(0); + expect(round.challengeDeadline.toNumber()).toBeGreaterThan(0); + + const assertion = await ctx.fetchAssertion(a); + expect(assertion.state).toBe(STATE.ASSERTED_LLM); + expect(assertion.llmChallengeDeadline.toNumber()).toBe(round.challengeDeadline.toNumber()); + + await expect(ctx.recoverPendingLlm(a)).rejects.toThrow(); + await expect(ctx.submitLlmResolution(a, OUTCOME.TRUE)).rejects.toThrow(); + }); + + it('settles an Unresolvable LLM verdict no-fault', async () => { const a = ctx.newAssertion(); + const asserterStart = await balanceOf(connection, token.asserterAta); + const llmDisputerStart = await balanceOf(connection, token.llmDisputerAta); + const treasuryStart = await balanceOf(connection, proto.treasuryAta); + await ctx.createAssertion(a, 'Undecidable claim', 200); await ctx.disputeAssertion(a); await ctx.submitLlmResolution(a, OUTCOME.UNRESOLVABLE); @@ -641,6 +908,69 @@ describe('opal', () => { const acc = await ctx.fetchAssertion(a); expect(acc.state).toBe(STATE.ASSERTED_LLM); + + await sleep(4000); + await ctx.finalizeLlmResolution(a); + + const resolved = await ctx.fetchAssertion(a); + expect(resolved.state).toBe(STATE.RESOLVED); + expect(resolved.outcome).toBe(OUTCOME.UNRESOLVABLE); + + const dispute = await ctx.fetchLlmDispute(a); + expect(dispute.settlementResolution).toBe(OUTCOME.UNRESOLVABLE); + + expect(await balanceOf(connection, token.asserterAta)).toBe(asserterStart); + expect(await balanceOf(connection, token.llmDisputerAta)).toBe(llmDisputerStart); + expect(await balanceOf(connection, proto.treasuryAta)).toBe(treasuryStart); + expect(await balanceOf(connection, a.pdas.bondVault)).toBe(0); + }); + + it('settles an Unresolvable vote no-fault', async () => { + const a = ctx.newAssertion(); + const asserterStart = await balanceOf(connection, token.asserterAta); + const llmDisputerStart = await balanceOf(connection, token.llmDisputerAta); + const voteDisputerStart = await balanceOf(connection, token.voteDisputerAta); + const treasuryStart = await balanceOf(connection, proto.treasuryAta); + + await ctx.createAssertion(a, 'No vote outcome reaches supermajority', 500); + await ctx.disputeAssertion(a); + await ctx.submitLlmResolution(a, OUTCOME.TRUE); + await ctx.challengeLlmResolution(a); + await ctx.openVote(a); + await sleep(5000); + + await ctx.finalizeVoteResolutionPlaceholder(a, OUTCOME.UNRESOLVABLE); + + const resolved = await ctx.fetchAssertion(a); + expect(resolved.state).toBe(STATE.RESOLVED); + expect(resolved.outcome).toBe(OUTCOME.UNRESOLVABLE); + + const llmDispute = await ctx.fetchLlmDispute(a); + expect(llmDispute.settlementResolution).toBe(OUTCOME.UNRESOLVABLE); + + const voteDispute = await ctx.fetchVoteDispute(a); + expect(voteDispute.settlementResolution).toBe(OUTCOME.UNRESOLVABLE); + + const voteRound = await ctx.fetchVoteRound(a); + expect(voteRound.finalOutcome).toBe(OUTCOME.UNRESOLVABLE); + + expect(await balanceOf(connection, token.asserterAta)).toBe(asserterStart); + expect(await balanceOf(connection, token.llmDisputerAta)).toBe(llmDisputerStart); + expect(await balanceOf(connection, token.voteDisputerAta)).toBe(voteDisputerStart); + expect(await balanceOf(connection, proto.treasuryAta)).toBe(treasuryStart); + expect(await balanceOf(connection, a.pdas.bondVault)).toBe(0); + }); + + it('rejects TooEarly from the vote placeholder', async () => { + const a = ctx.newAssertion(); + await ctx.createAssertion(a, 'Deprecated vote outcome', 200); + await ctx.disputeAssertion(a); + await ctx.submitLlmResolution(a, OUTCOME.TRUE); + await ctx.challengeLlmResolution(a); + await ctx.openVote(a); + await sleep(5000); + + await expect(ctx.finalizeVoteResolutionPlaceholder(a, OUTCOME.TOO_EARLY)).rejects.toThrow(); }); it('error: challengeLlmResolution after challenge deadline', async () => { @@ -666,11 +996,11 @@ describe('opal', () => { const a2 = ctx.newAssertion(); // create and dispute assertion1 - await ctx.createAssertion(a1, 'A1', 200, 'a1'); + await ctx.createAssertion(a1, 'A1', 200); await ctx.disputeAssertion(a1); // create assertion2 (undisputed) so we can try to pass its dispute for assertion1 - await ctx.createAssertion(a2, 'A2', 200, 'a2'); + await ctx.createAssertion(a2, 'A2', 200); // finalizeLlmResolution with assertion1 but llmDispute from assertion2 // should fail because the dispute doesn't link back to assertion1 @@ -693,4 +1023,38 @@ describe('opal', () => { .rpc({ commitment: 'confirmed' }) ).rejects.toThrow(); }); + + it('rejects substituted payout token accounts without moving funds', async () => { + const a = ctx.newAssertion(); + await ctx.createAssertion(a, 'Payout account substitution', 200); + await ctx.disputeAssertion(a); + await ctx.submitLlmResolution(a, OUTCOME.FALSE); + await sleep(4000); + + const before = { + asserter: await balanceOf(connection, token.asserterAta), + llmDisputer: await balanceOf(connection, token.llmDisputerAta), + treasury: await balanceOf(connection, proto.treasuryAta), + vault: await balanceOf(connection, a.pdas.bondVault), + }; + + await expect( + ctx.finalizeLlmResolution(a, { asserterPusd: token.voteDisputerAta }) + ).rejects.toThrow(); + await expect( + ctx.finalizeLlmResolution(a, { llmDisputerPusd: token.voteDisputerAta }) + ).rejects.toThrow(); + await expect( + ctx.finalizeLlmResolution(a, { treasuryPusd: token.voteDisputerAta }) + ).rejects.toThrow(); + + expect(await balanceOf(connection, token.asserterAta)).toBe(before.asserter); + expect(await balanceOf(connection, token.llmDisputerAta)).toBe(before.llmDisputer); + expect(await balanceOf(connection, proto.treasuryAta)).toBe(before.treasury); + expect(await balanceOf(connection, a.pdas.bondVault)).toBe(before.vault); + + await ctx.finalizeLlmResolution(a); + expect((await ctx.fetchAssertion(a)).state).toBe(STATE.RESOLVED); + expect(await balanceOf(connection, a.pdas.bondVault)).toBe(0); + }); });