Skip to content

Proposal: (NegRisk) Linked Multi-outcome event markets #1313

@kluless13

Description

@kluless13

Summary

Add multi-outcome event markets (NegRisk) to TRUF Network's prediction market system. Multiple binary markets are linked to a single event where exactly one outcome is true. A convert/merge operation enables capital efficiency - users don't need to lock collateral per-market when positions net out across the event.

This builds on top of the existing orderbook (migrations 030-042) with minimal changes to existing code. Bracket-style markets and split/merge operations already exist - NegRisk adds the event linkage layer and a cross-market convert operation.


Motivation

Economic indicator markets are naturally multi-outcome. Examples on TRUF data:

Event Outcomes
"CPI YoY range for March 2026" <2.0%, 2.0-2.5%, 2.5-3.0%, 3.0-3.5%, >3.5%
"Next Fed rate decision" Cut 50bp, Cut 25bp, Hold, Hike 25bp
"Unemployment rate bracket Q2" <3.5%, 3.5-4.0%, 4.0-4.5%, >4.5%
"Which month does CPI peak?" Jan, Feb, Mar, Apr, May, Jun

Without NegRisk, each outcome is an independent market. A market maker providing liquidity on 5 outcomes needs 5x the collateral. With NegRisk, they need 1x - the same $1 backs all outcomes since exactly one wins.

Capital efficiency comparison (5-outcome event, $10K per market):

Model Collateral needed LP capital efficiency
Independent markets $50,000 1x
NegRisk linked $10,000 5x

Current Architecture (unchanged)

For reference, the existing schema this proposal extends:

ob_queries          - Market definitions (hash, settle_time, bridge, etc.)
ob_positions        - Unified orderbook + holdings (price encoding: <0=buy, 0=hold, >0=sell)
ob_participants     - Wallet ID mapping
ob_rewards          - LP reward snapshots

Key existing operations:

  • place_split_limit_order - Mints YES+NO pair for $1, holds YES, sells NO
  • match_mint - Two complementary buys (YES@P1 + NO@P2, P1+P2=100) mint new pairs
  • match_burn - Two complementary sells destroy pairs, unlock collateral
  • Collateral: amount * price * 10^16 wei for buys, amount * 10^18 for splits

None of these change. NegRisk adds a layer on top.


Proposed Schema Changes

New Table: ob_events

Links multiple ob_queries markets to a single event.

CREATE TABLE IF NOT EXISTS ob_events (
  id INT PRIMARY KEY,
  hash BYTEA NOT NULL UNIQUE,          -- Deterministic hash of event params
  name TEXT NOT NULL,                    -- Human-readable description
  num_outcomes INT NOT NULL,             -- Number of linked markets
  settled BOOLEAN DEFAULT false NOT NULL,
  winning_query_id INT,                  -- Which market's TRUE won
  settle_time INT8 NOT NULL,
  created_at INT8 NOT NULL,
  creator BYTEA NOT NULL,
  bridge TEXT NOT NULL,

  CONSTRAINT chk_ob_events_outcomes CHECK (num_outcomes >= 2 AND num_outcomes <= 20),
  FOREIGN KEY (winning_query_id) REFERENCES ob_queries(id)
);

CREATE INDEX IF NOT EXISTS idx_ob_events_settled ON ob_events(settled);
CREATE INDEX IF NOT EXISTS idx_ob_events_settle_time ON ob_events(settle_time);

Modified Table: ob_queries

Add nullable FK to link markets to events. NULL = standalone (backwards compatible).

ALTER TABLE ob_queries ADD COLUMN event_id INT REFERENCES ob_events(id);
ALTER TABLE ob_queries ADD COLUMN outcome_index INT;  -- 0-based position in event

CREATE INDEX IF NOT EXISTS idx_ob_queries_event ON ob_queries(event_id);

-- Constraint: if event_id is set, outcome_index must be set
ALTER TABLE ob_queries ADD CONSTRAINT chk_event_outcome
  CHECK ((event_id IS NULL AND outcome_index IS NULL) OR
         (event_id IS NOT NULL AND outcome_index IS NOT NULL AND outcome_index >= 0));

New Table: ob_event_collateral

Tracks the number of "complete sets" minted per participant for an event. A complete set = 1 TRUE share of every outcome.

CREATE TABLE IF NOT EXISTS ob_event_collateral (
  event_id INT NOT NULL,
  participant_id INT NOT NULL,
  complete_sets INT8 NOT NULL DEFAULT 0,  -- Number of full sets held

  PRIMARY KEY (event_id, participant_id),
  FOREIGN KEY (event_id) REFERENCES ob_events(id) ON DELETE CASCADE,
  FOREIGN KEY (participant_id) REFERENCES ob_participants(id),
  CONSTRAINT chk_complete_sets CHECK (complete_sets >= 0)
);

New Actions

create_linked_event

Creates an event with N binary markets atomically. Each market shares the same bridge and settle_time.

CREATE OR REPLACE ACTION create_linked_event(
  $bridge TEXT,
  $name TEXT,
  $outcome_names TEXT[],        -- ["<2.0%", "2.0-2.5%", "2.5-3.0%", ">3.0%"]
  $settle_time INT8,
  $max_spread INT,
  $min_order_size INT8
) PUBLIC RETURNS (event_id INT)

Logic:

  1. Validate bridge, params, ARRAY_LENGTH($outcome_names) >= 2
  2. Create ob_events row
  3. For each outcome name, create an ob_queries row with event_id set and unique query_components derived from event hash + outcome index
  4. Charge creation fee (2 TRUF per market, or 2 TRUF total - TBD)
  5. Return event_id

split_event (Cash -> Complete Set)

Deposit $1 per set, receive 1 TRUE share of every outcome in the event.

CREATE OR REPLACE ACTION split_event(
  $event_id INT,
  $amount INT8              -- Number of complete sets to mint
) PUBLIC

Logic:

  1. Validate event exists, not settled
  2. Lock collateral: $amount * 10^18 wei (= $1.00 per set)
  3. For each market in event: INSERT holding (price=0, outcome=TRUE, amount=$amount)
  4. Update ob_event_collateral.complete_sets += $amount
  5. Maintain binary parity: also mint FALSE shares (see Parity section below)

merge_event (Complete Set -> Cash)

Burn 1 TRUE share of every outcome, receive $1 per set.

CREATE OR REPLACE ACTION merge_event(
  $event_id INT,
  $amount INT8              -- Number of complete sets to burn
) PUBLIC

Logic:

  1. Validate event exists, not settled
  2. Verify participant holds >= $amount TRUE shares (price=0) in EVERY market of the event
  3. For each market: DELETE or reduce TRUE holding by $amount
  4. Unlock collateral: $amount * 10^18 wei
  5. Update ob_event_collateral.complete_sets -= $amount
  6. Also burn corresponding FALSE shares

settle_event

Settle all linked markets at once. Exactly one outcome wins.

CREATE OR REPLACE ACTION settle_event(
  $event_id INT
) PUBLIC

Logic:

  1. Validate event not settled, past settle_time
  2. For each market in event: check attestation result
  3. Exactly one market's attestation must return TRUE - error if 0 or >1
  4. Mark winning market, mark event as settled
  5. Call process_settlement on each market:
    • Winning market: TRUE holders get 98 cents (2% fee), FALSE holders lose
    • Losing markets: FALSE holders get 98 cents, TRUE holders lose
  6. Distribute collected fees to LPs (per-event pool)

Binary Parity in NegRisk

The existing system enforces total_true_shares == total_false_shares per market. NegRisk must maintain this.

Split creates both TRUE and FALSE shares:
When splitting, we mint TRUE shares the user holds, plus FALSE shares that are effectively "the other side." For NegRisk:

  • Split 1 set of 4-outcome event:
    • Market 1: +1 TRUE (user holds), +1 FALSE (to event collateral pool)
    • Market 2: +1 TRUE (user holds), +1 FALSE (to event collateral pool)
    • Market 3: +1 TRUE (user holds), +1 FALSE (to event collateral pool)
    • Market 4: +1 TRUE (user holds), +1 FALSE (to event collateral pool)

The FALSE shares back the system's obligation: when the event settles, the losing TRUE shares are worthless, but the FALSE shares of losing markets pay out. The math works because exactly one TRUE wins ($0.98) and N-1 FALSE shares win ($0.98 each), but the total payout = 1 complete set = $1 minus the 2% fee.

Alternative (simpler): Don't create FALSE shares at split. Instead, modify validate_market_collateral to account for complete sets. Complete sets are self-collateralizing since exactly one wins. This avoids phantom FALSE shares cluttering the orderbook.

Recommendation: The simpler approach. Complete sets tracked in ob_event_collateral provide the collateral backing. Individual market binary parity only needs to hold for independently-traded shares (from place_split_limit_order on individual markets).


Validation Changes

validate_event_collateral (new)

CREATE OR REPLACE ACTION validate_event_collateral($event_id INT)
PUBLIC VIEW RETURNS (
  valid BOOL,
  num_markets INT,
  total_complete_sets INT8,
  vault_balance NUMERIC(78, 0),
  expected_collateral NUMERIC(78, 0)
)

Logic:

  • Expected collateral = (complete_sets * 10^18) + SUM(individual market collateral for non-event positions)
  • Vault balance from bridge.info()
  • Must hold across all unsettled events sharing the same bridge

Modify validate_market_collateral

Add awareness of event-linked markets. When calculating expected collateral, exclude complete sets (they're covered by ob_event_collateral).


LP Rewards for NegRisk

Two options - needs team input:

Option A: Per-market (current behavior, no change)

Each market in the event has its own LP reward scoring. LPs get rewards for providing liquidity on individual markets. Simple, no code changes to sample_lp_rewards.

Pro: No changes needed.
Con: LPs must provide liquidity on each market separately. Doesn't reward event-level market making.

Option B: Per-event (new)

LP rewards scored at the event level. If you provide liquidity across all outcomes of an event, you get a bonus multiplier.

Pro: Incentivizes full event coverage.
Con: More complex scoring, new code in sample_lp_rewards.


Attestation for NegRisk Events

Current binary attestation actions (040) support:

  • price_above_threshold - "CPI > 2.5%"
  • price_below_threshold - "CPI < 2.5%"
  • value_in_range - "CPI between 2.5% and 3.0%"
  • value_equals - "CPI = 2.5% +/- 0.1%"

For NegRisk bracket events, each outcome uses value_in_range with non-overlapping ranges:

Outcome Attestation
CPI <2.0% price_below_threshold(stream, 2.0)
CPI 2.0-2.5% value_in_range(stream, 2.0, 2.5)
CPI 2.5-3.0% value_in_range(stream, 2.5, 3.0)
CPI >3.0% price_above_threshold(stream, 3.0)

Validation: settle_event must verify exactly one attestation returns TRUE. If the ranges are properly non-overlapping, this is guaranteed by the data. But edge cases (value exactly on boundary) need defined behavior - suggest exclusive lower bound, inclusive upper: [min, max).

New attestation action needed: None. Existing actions cover all bracket patterns. The event settlement logic just iterates each market's attestation.


Migration Plan (incremental PRs)

PR Migration What Risk
1 043 ob_events table + event_id/outcome_index on ob_queries Low - additive schema
2 044 ob_event_collateral table + create_linked_event action Low - new action
3 045 split_event + merge_event actions Medium - collateral logic
4 046 settle_event action Medium - settlement changes
5 047 validate_event_collateral + modify existing validation Medium - consensus-critical
6 - Go extension: add event settlement to tn_settlement High - extension change

Backwards compatible: All changes are additive. Standalone markets (event_id=NULL) work exactly as before. No existing actions are modified in PRs 1-4.

Dependency chain: 1 → 2 → 3 → 4 → 5 → 6. Each PR is independently mergeable and functional up to that point.

PR 1 - Schema Foundation

  • Add ob_events table (id, hash, name, num_outcomes, settled, settle_time, etc.)
  • Add event_id + outcome_index nullable columns to ob_queries
  • Add indexes and constraints
  • Pure DDL - no logic, no actions. Nothing references these new columns yet, so nothing can break. Existing standalone markets are completely unaffected (event_id defaults to NULL).
  • Testing: Run migration, verify all existing markets still query, trade, and settle normally. Verify new columns exist with correct types and constraints.

PR 2 - Event Creation

  • Add ob_event_collateral table
  • Add create_linked_event action - takes a bridge, name, array of outcome names, and atomically creates one ob_events row + N ob_queries rows linked to it
  • Each child market gets event_id set and a unique outcome_index (0-based)
  • Testing: Call create_linked_event with 4 outcomes, verify 1 event + 4 markets created with correct event_id and outcome_index. Verify standalone create_query still works independently. Verify constraint rejects num_outcomes < 2 and > 20.

PR 3 - Split + Merge (Collateral Operations)

  • split_event - lock $1 per set, mint 1 TRUE share in every market of the event, track complete sets in ob_event_collateral
  • merge_event - burn 1 TRUE share from every market, unlock $1 per set, decrement complete sets
  • This is where collateral logic gets real. Must handle: insufficient holdings for merge, settled events (reject), partial sets (reject - must hold TRUE in ALL markets)
  • Testing: Split 100 sets → verify 100 TRUE shares in each market + collateral locked. Merge 50 back → verify 50 remain + collateral partially unlocked. Attempt merge of more than held → must fail. Attempt split on settled event → must fail. Verify standalone place_split_limit_order still works independently on non-event markets.

PR 4 - Event Settlement

  • settle_event - iterate each market's attestation, verify exactly one returns TRUE, pay winners, distribute fees
  • Winning market: TRUE holders get $0.98 (2% fee), FALSE holders lose
  • Losing markets: TRUE holders lose, FALSE holders get $0.98
  • Refund all open orders on all markets in the event
  • Testing: Create event, split sets, trade shares between participants, trigger attestation, settle. Verify payouts are correct for winners and losers. Test edge cases: 0 attestations return TRUE (must error), 2+ attestations return TRUE (must error). Verify standalone market settlement via process_settlement is unchanged.

PR 5 - Validation Update

  • New validate_event_collateral action - verifies bridge holds enough for all complete sets + individual positions
  • Modify existing validate_market_collateral to skip complete set collateral for event-linked markets (those are covered by event-level validation). This is a conditional branch: if event_id IS NULL, existing logic runs unchanged.
  • Consensus-critical - if validation is wrong, the network rejects blocks
  • Testing: Extensive. Validate after splits, merges, trades, partial merges, settlements. Verify standalone market validation is byte-for-byte identical to pre-change behavior. Run against existing test suite to confirm no regressions.

PR 6 - Go Extension (Co-authored with TRUF Team)

  • Modify the tn_settlement Go extension to recognize events and broadcast settle_event instead of settle_market for event-linked markets (the extension currently broadcasts settle_market, which internally calls process_settlement)
  • This extension runs automatically every ~5 minutes in the Kwil settlement loop
  • Highest risk - touches the Go extensions that run inside the Kwil node itself. Should be co-authored or at minimum reviewed closely by the TRUF core team.
  • Testing: Deploy to testnet, create event markets, let settlement extension trigger automatically. Verify correct settlement behavior. Verify standalone markets still settle on their normal schedule.

Open Questions for TRUF Team

  1. Creation fee: 2 TRUF per market in the event, or flat 2 TRUF per event?
  2. Max outcomes: Proposed cap of 20 per event - is this reasonable?
  3. LP rewards: Per-market (Option A) or per-event (Option B) to start?
  4. Boundary behavior (critical for NegRisk correctness): Current value_in_range uses [min, max] (inclusive both ends). For adjacent brackets (e.g., 2.0-2.5% and 2.5-3.0%), a value landing exactly on 2.5% would match BOTH, violating the "exactly one outcome" invariant. Should boundaries be [min, max) or (min, max]?
  5. Indexer: Does the prediction market indexer need event-level endpoints? e.g., GET /v0/prediction-market/events/{id}
  6. Convert naming: Polymarket calls these "split" and "merge." TRUF already has place_split_limit_order. Should we use different terminology to avoid confusion? Suggestions: split_event/merge_event or mint_set/burn_set.

Example: CPI Bracket Market

Event: "CPI YoY for March 2026"
Bridge: hoodi_tt2
Outcomes: 4

Market 1 (query_id=50): CPI < 2.0%    → price_below_threshold(cpi_stream, 2.0)
Market 2 (query_id=51): CPI 2.0-2.5%  → value_in_range(cpi_stream, 2.0, 2.5)
Market 3 (query_id=52): CPI 2.5-3.0%  → value_in_range(cpi_stream, 2.5, 3.0)
Market 4 (query_id=53): CPI > 3.0%    → price_above_threshold(cpi_stream, 3.0)

User flow:

  1. Alice calls split_event(event_id=1, amount=100) → locks $100, gets 100 TRUE shares of each market
  2. Alice sells TRUE shares on markets 1, 3, 4 (she thinks CPI will be 2.0-2.5%)
  3. Alice keeps 100 TRUE shares on market 2
  4. Bob buys Alice's sells on markets 1, 3, 4
  5. March CPI comes in at 2.3% → attestation for market 2 returns TRUE
  6. settle_event(1) pays market 2 TRUE holders $0.98/share, refunds open orders, distributes 2% fees to LPs

Alice P&L: Sold shares on 3 markets (revenue) + won market 2 ($0.98 * 100) - initial $100 cost
Capital used: $100 (not $400 as independent markets would require)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions