# Acknowledgements (https://docs.zpredict.site/docs/acknowledgements)

The open-source work zPredict stands on.

| Project | What we use |
|---|---|
| [polytune](https://github.com/sine-fdn/polytune) and [Garble](https://github.com/sine-fdn/garble-lang) by SINE | 3-party MPC (WRK17 authenticated garbling) and the circuit language. Vendored at rev `47dc378` (MIT) with preprocessing split from the online phase |
| [librustzcash](https://github.com/zcash/librustzcash) and [zcash-devtool](https://github.com/zcash/zcash-devtool) | `pczt`, `orchard` (Ironwood), `zcash_primitives`, `zcash_client_backend`; wallets, proving and broadcast |
| [Zcash Foundation FROST](https://github.com/ZcashFoundation/frost) and [frost-zcash-demo](https://github.com/ZcashFoundation/frost-zcash-demo) | `frost-core` and RedPallas (`reddsa`) for the DKG and threshold signing |
| [Zebra](https://github.com/ZcashFoundation/zebra) and [lightwalletd](https://github.com/zcash/lightwalletd) | The regtest chain for end-to-end tests; `zec.rocks` and `testnet.zec.rocks` for the hosted demo |
| [Renegade](https://github.com/renegade-fi/renegade) | The idea of an MPC dark pool. No Renegade code is used |
| [noble](https://paulmillr.com/noble/) | `@noble/hashes`, `@noble/ciphers` in the SDK |
| [Fumadocs](https://fumadocs.dev) | This documentation site (Next.js, MDX, search) |
| [MotionSites](https://motionsites.ai) | The ASCII Studio design language of the site, the app and these docs |
| [Polymarket](https://polymarket.com) | Public market data (Gamma and CLOB APIs) for the mirrored markets, fetched by the relayers. zPredict is not affiliated with Polymarket |
| [JetBrains Mono](https://www.jetbrains.com/lp/mono/) via [Fontsource](https://fontsource.org) | The one typeface, self-hosted |

zPredict is MIT-licensed and was built for ZECATHON 2026.

# Circuits (https://docs.zpredict.site/docs/circuits)

The Garble clearing circuit (orders, balances, credentials, withdrawals), the resolution circuit, and what they cost in gates and seconds.

[`packages/circuit`](https://github.com/zpredict/zpredict/tree/main/packages/circuit) holds two programs in the [Garble](https://github.com/sine-fdn/garble-lang) language, run as 3-party MPC with [polytune](https://github.com/sine-fdn/polytune) (vendored at rev `47dc378` in [`vendor/polytune`](https://github.com/zpredict/zpredict/tree/main/vendor/polytune), with preprocessing split from the online phase). Each relayer is one party and feeds in only its own shares. The plaintext references are in [`packages/core`](https://github.com/zpredict/zpredict/tree/main/packages/core); differential tests check the circuits against them.

## Clearing ([`clear.garble.rs`](https://github.com/zpredict/zpredict/blob/main/packages/circuit/src/clear.garble.rs))
**Inputs.** Each party's shares of a fixed-size batch of orders (`DEFAULT_SLOTS` = 8, up to `MAX_SLOTS` = 21, padded with dummies), of the whole balance table (`id | balance | key share`, `DEFAULT_ROWS` = 32, 48 on the demo), of the withdrawal requests (`DEFAULT_WITHDRAWALS` = 2), and its private mock flags. Public: the open market ids (`DEFAULT_MARKETS` = 6 slots) and a credit per row (deposits and judge credits, which every relayer already sees). If the parties disagree on any public input, nothing moves.

**Inside**, with no data-dependent control flow:

1. Rebuild orders (`s0 ^ s1 ^ s2`) and the table; add credits.
2. **Credentials:** rebuild each row's 128-bit key from the three key shares. An order or withdrawal uses a row only if its rebuilt tag equals that key (~21k AND gates at 8 slots × 32 rows).
3. **Withdrawals** first: each is taken only if its row covers it and no party flagged the row as mock. A public bit per slot says whether it went through.
4. **Reserve:** in batch order, each order reserves its worst case (`size × 2 × price` half-ticks) from its row. Unfunded, unknown market or malformed → dummy.
5. **Cross** within each market: YES `p` against NO `q` when `p + q ≥ 100`, at the midpoint. Instead of sorting, each side's sizes are laid end to end and a YES/NO pair fills by the overlap of their intervals (N×N comparisons); once a pair stops crossing, every later pair does too.
6. Debit rows what their orders actually paid.

**Outputs.** Each party's XOR share of every slot's 10-byte fill record and every row's new balance, the withdrawal bits, and one public clearing price per market (volume-weighted YES cost, rounded down). Unfilled and dummy slots are all-zero records, indistinguishable from filled ones to a relayer.

## Resolution ([`resolve.garble.rs`](https://github.com/zpredict/zpredict/blob/main/packages/circuit/src/resolve.garble.rs))
When all three nodes agree a market resolved and on its outcome, a second, small program runs over **every** position (each batched order share with its fill share), `DEFAULT_POSITIONS` = 16 per run: one unit per winning contract goes into the row whose key equals the order's tag, as shares. Nobody learns which positions were in that market, filled or won. Reference: `zpredict_core::resolve`.

## Cost
Three processes over localhost on an M4 Pro, release build (`cargo run --release -p zpredict-circuit --example bench <runs> <rows> <slots>`), from [`lib.rs`](https://github.com/zpredict/zpredict/blob/main/packages/circuit/src/lib.rs) and [`deploy/README.md`](https://github.com/zpredict/zpredict/blob/main/deploy/README.md):

| Slots × rows | Markets | AND gates | Preprocessing | Online | Compile |
|---|---|---|---|---|---|
| 8 × 32 | 4 | 290,543 | 5.7 s | 0.45–0.53 s | 6.5 s |
| 8 × 48 | 4 | 385,321 | 8.0 s | 0.61–0.77 s | 17 s |
| 8 × 48 | 6 | 393,999 | +3% | same | |
| 8 × 64 | 4 | 480,287 | 10.0 s | 0.78–0.97 s | 28 s |
| Resolution, 16 positions × 32 rows | | 158,335 | ~3.5 s total | | |

Crossing costs ~N² AND gates; each table row ~8k, each market slot ~4.3k. Each party uses about one core. Peak RSS at 48 rows (Linux): r0 ~1.1 GB, r1/r2 ~0.55 GB.

**Preprocessing ahead.** About 92% of a run is input-independent preprocessing, so the relayers prepare one run ahead and hold it ([#94](https://github.com/zpredict/zpredict/issues/94)); a window with orders runs only the online part. A held run is used only if all three hold the same one. On the hosted VPS (~5.7× slower than the M4 Pro) a fill shows about 25 s after the order, mostly waiting for the window and the run.

## Tests
```sh
cargo test -p zpredict-circuit                      # differential + resolution tests
cargo test -p zpredict-circuit a_relayer_cannot_act_as_an_account
cargo test -p zpredict-circuit mock_rows_never_withdraw
```

[`tests/differential.rs`](https://github.com/zpredict/zpredict/blob/main/packages/circuit/tests/differential.rs) checks the circuit against `clear_with_balances` (`circuit_matches_clear_with_balances`, `other_shapes_match`), that a relayer can't act as an account, that mock rows never withdraw, and that disagreeing public inputs are an error; [`tests/resolution.rs`](https://github.com/zpredict/zpredict/blob/main/packages/circuit/tests/resolution.rs) does the same for `resolve`.

# Contributing (https://docs.zpredict.site/docs/contributing)

Issue first, small conventional commits, CI green, and the privacy rules every change keeps.

The rules live in [CONTRIBUTING.md](https://github.com/zpredict/zpredict/blob/main/CONTRIBUTING.md).

## Workflow
1. **Issue first.** Labels: one `type:`, the `area:`s it touches, one `priority:`, plus `privacy` if it changes what anyone can learn.
2. **Branch** off `main`: `feat/…`, `fix/…`, `refactor/…`, `docs/…`, `chore/…`.
3. **Small commits**, [Conventional Commits](https://www.conventionalcommits.org/): `feat(core): …`.
4. **PR** with `Closes #n`, the issue's labels, what changed, and test evidence. Add a comment saying where review should focus.
5. Merge commits only; branches auto-delete. `main` is protected and requires CI.

## Checks
The Husky pre-commit hook runs Biome on staged files and `cargo fmt --check`. Before you open a PR:

```sh
cargo fmt --all && cargo clippy --all-targets -- -D warnings && cargo test
pnpm check && pnpm build && pnpm test
```

## Privacy rules
A leak disqualifies the project, so every PR keeps to these:

- No transparent addresses anywhere.
- No third-party requests from the frontends (these docs included).
- No analytics, no IP logs.
- Never send two order shares to the same origin.
- Never commit keys or seeds.

## These docs
Pages are MDX in [`apps/docs/content/docs`](https://github.com/zpredict/zpredict/tree/main/apps/docs/content/docs); the sidebar order is `meta.json`. Every claim must be true of `main`, with a link to the source. Run them locally:

```sh
pnpm --filter @zpredict/docs dev          # http://localhost:3002
pnpm --filter @zpredict/docs lint:links   # internal links, anchors and GitHub paths resolve
pnpm --filter @zpredict/docs build && pnpm --filter @zpredict/docs test
```

# Evidence (https://docs.zpredict.site/docs/evidence)

The test suites, the end-to-end runs and their outputs, the testnet transactions, and how to check each claim yourself.

Every claim in these docs should be checkable from the repository. This page lists where.

## Test suites
| Command | Covers |
|---|---|
| `cargo test --workspace` | Unit tests next to the code in every crate; [`packages/circuit/tests`](https://github.com/zpredict/zpredict/tree/main/packages/circuit/tests) (circuit vs plaintext reference, credentials, mock rows); [`packages/settle/tests`](https://github.com/zpredict/zpredict/tree/main/packages/settle/tests) (memo and outcome vectors); relayer API contract tests such as `orders_contract`. Slow real-MPC and live-network tests are `#[ignore]` |
| `pnpm test` | `@zpredict/sdk` against the Rust test vectors ([`core`](https://github.com/zpredict/zpredict/blob/main/packages/core/testdata/vectors.json), [`memo`](https://github.com/zpredict/zpredict/blob/main/packages/settle/testdata/memo-vectors.json), [`outcome`](https://github.com/zpredict/zpredict/blob/main/packages/settle/testdata/outcome-vectors.json)); the web app's chart, markets and mock clearing; these docs (link linter, smoke test) |
| `pnpm build` | Builds every app, including the privacy gates of the app and these docs (no third-party URLs in what the browser loads) |
| `pnpm check:ci` | Biome lint and format |

CI ([`ci.yml`](https://github.com/zpredict/zpredict/blob/main/.github/workflows/ci.yml)) runs all of them, plus `cargo fmt --check` and `clippy -D warnings`, on every pull request; `main` requires it to pass.

## End-to-end runs (regtest)
| Script | Proves |
|---|---|
| [`scripts/relayer-e2e.sh`](https://github.com/zpredict/zpredict/blob/main/scripts/relayer-e2e.sh) | Deposit → trade → resolution → withdrawal through three real relayers; forged withdrawals by each relayer refused; payout is v6, Ironwood only, `transparent in+out 0`; no unsealed secret left |
| [`scripts/settle-e2e.sh`](https://github.com/zpredict/zpredict/blob/main/scripts/settle-e2e.sh) | Sealed DKG → two deposits → one batched 2-of-3 payout → mined; a one-zatoshi tampered vector is refused |
| [`scripts/scan-e2e.sh`](https://github.com/zpredict/zpredict/blob/main/scripts/scan-e2e.sh) | A tagged deposit is found by all three relayers' scanners, each opening only its memo slot (`MEMO_FROM=sdk` builds the request with the SDK) |

Regtest output recorded in [`packages/settle/README.md`](https://github.com/zpredict/zpredict/blob/main/packages/settle/README.md) (each run is a fresh chain, so ids differ):

```
settle-payout: refused to sign: action 0: 4000000 zats to an address not in the payout vector
txid 8f5c8135addb23796c87f557ccb01dd1a859363b64a6680d48f01644c7d4cf00  version 6  height 123  ironwood actions 3  orchard actions 0  transparent in+out 0
```

## Testnet
| What | Value |
|---|---|
| Escrow (Ironwood-only UA) | `utest18yg3cneyjgxxknjm0j9020hmx5jp2wm67qn2p3rfmflct9hwlt363gzl50qeu3ms2u044hq8jzuzkl2tq8xa4m0ukzvlpqgtvu96ffyy` |
| FROST group key (`ak`) | `a0cff44d86411e03d1ae3a76982d38bf0aac73db9568a2496324167486dbc534` |
| Escrow birthday | testnet block 4,530,716 (key ceremony, 2026-10-23) |

> **info:** **Testnet transactions: to be added.** Deposit and payout txids from the hosted demo go here once recorded (deposit txid, payout txid, block height, and the explorer-independent check below). Until then the regtest runs above are the on-chain evidence.

| Kind | txid | Height |
|---|---|---|
| Deposit | _pending_ | |
| Batched payout | _pending_ | |

Check a txid against your own node: `zcash-cli getrawtransaction <txid> 1` should show version 6, Ironwood actions, and no transparent inputs or outputs.

## Check the live claims
```sh
curl -s https://r0.zpredict.site/group-key          # the same key on r0, r1, r2
curl -s https://r0.zpredict.site/markets | jq '.[] | select(.status=="resolved") | {id, outcome, blockHash, outcomeSig}'
for i in 0 1 2; do curl -s "https://r$i.zpredict.site/view?after=0" | head -c 200; echo; done   # digests only
curl -sI https://app.zpredict.site | grep -i content-security-policy
curl -sI https://docs.zpredict.site/docs/introduction | grep -i content-security-policy
```

The full list of checks, with what to expect, is in [PRIVACY.md "Verify it yourself"](https://github.com/zpredict/zpredict/blob/main/PRIVACY.md#verify-it-yourself).

# FAQ (https://docs.zpredict.site/docs/faq)

Short answers to what people ask first.

## Is it live? Is it real money?
It runs on Zcash **testnet**, with markets resolved from mainnet data. Judge mode gives mock credit that can never be withdrawn. See [Status](https://docs.zpredict.site/docs/status).

## Who holds my money?
A FROST 2-of-3 escrow on the Ironwood pool. Any two relayers can move it together; one alone cannot. On the demo all three run on one VM, so that split is not real there. See [Your money](https://docs.zpredict.site/docs/your-money).

## What can a relayer see?
Random-looking shares, the amounts and times of deposits and withdrawals, and the public price. Not your orders, side, size, balance, or which withdrawal is yours. See [Privacy](https://docs.zpredict.site/docs/privacy).

## Why is the price public?
A market needs a price. Each crossing batch reveals one clearing price per market: that a cross happened and at what price, never who, how many or what size.

## Why didn't my order fill?
Orders only cross within the batch they land in and don't rest between batches. On the demo, tick **Take the other side (demo)** to add a counter-order from your own account.

## Why fixed deposit sizes?
1, 5 and 25 units (0.01, 0.05 and 0.25 ZEC). An odd amount would be a fingerprint on chain; other amounts are never credited.

## Can a relayer steal from me or trade as me?
Not alone. Your account's tag never leaves your browser whole, and the circuit only lets a request use your row if its shares rebuild your key. Two colluding relayers could move escrow funds (FROST 2-of-3). See [Threat model](https://docs.zpredict.site/docs/threat-model).

## Who decides the Polymarket markets?
Polymarket (UMA). The relayers each read Polymarket's public record and sign only an outcome all three read the same. Nobody can recompute it from a block. See [Markets & resolution](https://docs.zpredict.site/docs/markets#mirrored-polymarket-markets).

## How do I check an outcome?
Resolved markets carry the block hash and height and a FROST signature. `verifyOutcome` in the SDK checks it against the group key all three relayers serve; the app runs it in your browser.

## Do these docs track me?
No. No analytics, no cookies, no third-party requests: fonts, images and search are served from docs.zpredict.site.

## Is this for agents too?
Yes: [/llms.txt](https://docs.zpredict.site/llms.txt) lists every page, [/llms-full.txt](https://docs.zpredict.site/llms-full.txt) has them all in one file, and any page is Markdown with `.md` appended (or `Accept: text/markdown`).

# How it works (https://docs.zpredict.site/docs/how-it-works)

Deposit, order, crossing in MPC, resolution, withdrawal. What happens at each step and who sees what.

Three relayers, run by different operators in the model, share the work. Each one only ever holds one share of anything secret. The full protocol is [DESIGN.md](https://github.com/zpredict/zpredict/blob/main/DESIGN.md).

## 1. Your account
Your browser makes a random 16-byte **tag**. It is the account's only credential and never leaves the browser whole (it lives in `localStorage`). Its public label is `id = BLAKE2b-64(tag)`. Relayer `i` gets only its own share `sᵢ` of the tag: `s₀` and `s₁` are BLAKE2b of the tag, and `s₂ = tag ⊕ s₀ ⊕ s₁`. The circuit rebuilds each balance row's key from the three shares, so a relayer holding only `sᵢ` and the id cannot trade, withdraw or read a balance as you.

## 2. Deposit
You send a fixed-denomination note (1, 5 or 25 units; 1 unit = 0.01 ZEC) to the escrow's Ironwood-only address. The 512-byte memo carries three slots, each encrypted to one relayer and holding `id ‖ sᵢ`. No order data ever goes on chain. A deposit is credited after 10 confirmations, and only once **all three** relayers' own scans found it. See [Your money](https://docs.zpredict.site/docs/your-money).

## 3. Order
`order = {market, side, price, size, key = tag}` is encoded in 26 bytes and split as `[r₀, r₁, order ⊕ r₀ ⊕ r₁]`. Share `i` goes by its own HTTPS request to relayer `i` only, with a fresh random read token. The SDK refuses to run unless the three relayer URLs are three distinct origins ([`submitOrder`](https://docs.zpredict.site/docs/sdk#submitorder)).

## 4. Crossing
Every batch window (5 s on the demo) the relayers agree on the orders all three hold, then run the clearing circuit as three MPC parties. Inside it:

- the orders and the whole balance table are rebuilt from shares, and public credits are added;
- each order reserves its worst case from the row whose key equals its tag (no such row, or not enough funds → it becomes a dummy);
- YES at `p` crosses NO at `q` when `p + q ≥ 100`, at the midpoint `p* = (p + 100 − q) / 2`.

Out come XOR-shared fill records and new balances, plus **one public clearing price per market**. Only the owner, holding all three shares, learns whether its order filled. Orders don't rest between batches. Details: [Circuits](https://docs.zpredict.site/docs/circuits).

## 5. Resolution
At the resolve height (+10 confirmations), each relayer evaluates the market's rule against its own node. Only an outcome all three computed identically is signed, FROST 2-of-3, and published with the block hash and height so anyone can recompute and verify it. A second small MPC program then pays one unit per winning contract into the winners' rows, without anyone learning which positions won. See [Markets & resolution](https://docs.zpredict.site/docs/markets).

## 6. Withdrawal
You ask all three relayers to pay an amount to a **fresh shielded address**, each with a fresh XOR share of your tag. The circuit debits your row only if the shares rebuild its key and it covers the amount. Debited withdrawals are paid in one batched Ironwood transaction that relayers 0 and 1 sign only after re-deriving it themselves. See [Settlement & FROST](https://docs.zpredict.site/docs/settlement).

## Who sees what
| | Sees |
|---|---|
| Chain observer | That shielded Ironwood transactions happen |
| One relayer | Random-looking shares, deposit and withdrawal amounts and times, the public price |
| Two relayers | The same for orders; they **can** move escrow funds (2-of-3) |
| All three | Everything |

The full table, with the demo's caveats, is on [Threat model](https://docs.zpredict.site/docs/threat-model).

# Introduction (https://docs.zpredict.site/docs/introduction)

A prediction market on Zcash where nobody learns that you bet, which side you took, or how much. Price public, participants private.

zPredict is a shielded prediction market on Zcash, built for ZECATHON 2026 (Private Markets track). Everyone sees the odds. Nobody sees who bet.

- **Orders** are split in your browser into three XOR shares, one per relayer. A single share is uniformly random.
- **Matching** happens inside 3-party MPC ([polytune](https://github.com/sine-fdn/polytune)): the relayers cross YES against NO at the midpoint and check your balance and your account credential in-circuit. Only your fill (to you) and one public clearing price per market come out.
- **Collateral** sits in a FROST 2-of-3 escrow on Zcash's Ironwood shielded pool. Withdrawals are batched into one shielded transaction, and every signer re-derives what it signs.
- **Outcomes** come from Zcash mainnet block data (or, for mirrored markets, from Polymarket), are FROST-signed by the relayers, and are verified in your browser.

Reading orders needs all three relayers to collude; moving funds needs two. The hosted demo runs all three on one VM behind Cloudflare, so read it as a working protocol, not a working trust split. See [Privacy](https://docs.zpredict.site/docs/privacy) and [Status](https://docs.zpredict.site/docs/status).

## Try it
1. Open [app.zpredict.site](https://app.zpredict.site) and press **Judge mode**. You get mock testnet collateral without a deposit (it can never be withdrawn).
2. Pick a market and place an order. Trading alone? Tick **Take the other side (demo)** to add a labelled self-trade, so you see a fill.
3. Open the **Relayer View** (`/trade/`): three panels of noise, with your own shares lit.

## Where to go next
| If you want to | Read |
|---|---|
| Understand the flow end to end | [How it works](https://docs.zpredict.site/docs/how-it-works) |
| Know what each party can learn | [Privacy](https://docs.zpredict.site/docs/privacy), [Threat model](https://docs.zpredict.site/docs/threat-model) |
| Build against the relayers | [Relayer API](https://docs.zpredict.site/docs/relayer-api), [SDK](https://docs.zpredict.site/docs/sdk) |
| Run the stack yourself | [Run a relayer](https://docs.zpredict.site/docs/run-a-relayer) |
| Check the claims | [Evidence](https://docs.zpredict.site/docs/evidence) |

## Source
Everything is in one MIT-licensed monorepo, [zpredict/zpredict](https://github.com/zpredict/zpredict). The protocol is specified in [DESIGN.md](https://github.com/zpredict/zpredict/blob/main/DESIGN.md) and audited in [PRIVACY.md](https://github.com/zpredict/zpredict/blob/main/PRIVACY.md); these pages are written from them and from the code, and link to both.

| Path | What |
|---|---|
| [`apps/relayer`](https://github.com/zpredict/zpredict/tree/main/apps/relayer) | Relayer: HTTP API, Noise peer links, MPC party, deposits, payouts |
| [`apps/web`](https://github.com/zpredict/zpredict/tree/main/apps/web), [`apps/landing`](https://github.com/zpredict/zpredict/tree/main/apps/landing), [`apps/docs`](https://github.com/zpredict/zpredict/tree/main/apps/docs) | The app, the site, these docs |
| [`packages/core`](https://github.com/zpredict/zpredict/tree/main/packages/core), [`packages/circuit`](https://github.com/zpredict/zpredict/tree/main/packages/circuit) | Encodings and the clearing rule; the Garble circuits |
| [`packages/settle`](https://github.com/zpredict/zpredict/tree/main/packages/settle), [`packages/oracle`](https://github.com/zpredict/zpredict/tree/main/packages/oracle), [`packages/plugins`](https://github.com/zpredict/zpredict/tree/main/packages/plugins) | Escrow scan and FROST payouts; market resolution |
| [`packages/sdk`](https://github.com/zpredict/zpredict/tree/main/packages/sdk) | The browser client |

# Markets & resolution (https://docs.zpredict.site/docs/markets)

Chain-data markets, the hourly rolling market, mirrored Polymarket markets and whose word each one trusts.

A market is `{id, question, resolution rule, close height, resolve height}`. Prices are ticks from 1 to 99 cents. A matched YES/NO pair locks exactly 1 unit of collateral and pays 1 unit to the winning side. Each rule is a plugin implementing the `Resolver` trait from [`packages/oracle`](https://github.com/zpredict/zpredict/tree/main/packages/oracle); a new market type is a new plugin.

## Chain-data markets
Resolved from **Zcash mainnet** blocks, read by each relayer from its own lightwalletd connection (`zec.rocks:443` on the demo). Anyone can recompute them.

| Plugin | Question | Rule |
|---|---|---|
| [`nu7-activation`](https://github.com/zpredict/zpredict/blob/main/packages/plugins/nu7-activation/src/lib.rs) | Will NU7 activate at height H? | YES iff block H's coinbase carries the NU7 consensus branch ID |
| [`ironwood-pool`](https://github.com/zpredict/zpredict/blob/main/packages/plugins/ironwood-pool/src/lib.rs) | Will the Ironwood pool hold ≥ N notes at H? | The Ironwood commitment tree size at the end of block H |
| [`shielded-actions`](https://github.com/zpredict/zpredict/blob/main/packages/plugins/shielded-actions/src/lib.rs) | Will block H contain ≥ N shielded actions? | Sapling spends + outputs + Orchard + Ironwood actions in the compact block |

## Hourly rolling market
[`rolling.rs`](https://github.com/zpredict/zpredict/blob/main/apps/relayer/src/markets/rolling.rs) makes a new market every hour of nominal block time: "Will mainnet block H contain ≥ N shielded actions?", with H one hour ahead (48 blocks at 75 s; 144 blocks at 25 s from NU7 at 3,543,405), trading closing 5 minutes before H, and N the median of the 24 blocks before the window, so YES and NO are about even. Ids are `1,000,000 + window`, derived from heights only, so all three relayers make the same market without talking. Resolved rolling markets stay listed for 24 h.

## Mirrored Polymarket markets
[`packages/plugins/polymarket`](https://github.com/zpredict/zpredict/blob/main/packages/plugins/polymarket/src/lib.rs) mirrors a binary Yes/No Polymarket market: the same question, traded on zPredict's own private book. Each relayer reads `gamma-api.polymarket.com/markets/slug/<slug>` itself, checks the conditionId and that the outcomes are exactly `["Yes","No"]`, and resolves once the market is `closed` **and** `umaResolutionStatus` is `"resolved"`: YES iff the Yes outcome price is exactly 1 (a 50-50 settlement is NO). A proposed or disputed UMA answer leaves it unresolved.

> **warn:** **The oracle for these markets is Polymarket (UMA), not chain data.** The relayers attest what Polymarket published (each fetches it on its own, and the outcome is signed only if all three read the same), but nobody can recompute it from a block. A wrong or disputed Polymarket resolution is zPredict's resolution. Trading stays exactly as private as on any other market.

The relayers also fetch Polymarket's Yes price and a week of hourly history, at most every 5 minutes ([`reference.rs`](https://github.com/zpredict/zpredict/blob/main/apps/relayer/src/reference.rs)), and serve it as `reference` in `/markets`. The browser never contacts Polymarket; the app shows the market page only as a plain outbound link.

## Signed outcomes
Only a resolution all three relayers computed identically is signed, FROST 2-of-3 with the escrow's key shares, as an ordinary RedPallas signature under the group key. The signed bytes are `"zPredict outcome v1\n"` followed by compact JSON:

```json
{"market":1,"outcome":"yes","height":3543405,"blockHash":"…"}
{"market":11,"outcome":"no","source":"polymarket","conditionId":"0x…"}
```

The two forms have disjoint keys, so one can never verify as the other. [`verifyOutcome`](https://docs.zpredict.site/docs/sdk#verifyoutcome) checks them in the browser; [`outcome-vectors.json`](https://github.com/zpredict/zpredict/blob/main/packages/settle/testdata/outcome-vectors.json) pins the format for Rust and TypeScript. Without a sealed key share a relayer still resolves, and the app shows "Resolved (unsigned)"; nothing is ever signed with a stand-in key.

## Capacity
One clearing run has `DEFAULT_MARKETS = 6` market slots. The demo uses all six: NU7 activation, Ironwood ≥ 900,000 notes, three mirrored markets on the US midterms (two House-control rows of one event and the Texas Senate race), and the hourly rolling market. The rolling generator never opens a market past the cap; a seventh open market would need a second MPC run per window. The live list is in [`deploy/production/relayer.toml`](https://github.com/zpredict/zpredict/blob/main/deploy/production/relayer.toml) and at `GET /markets`.

# Privacy (https://docs.zpredict.site/docs/privacy)

Who can learn what, through which data flow, the open caveats of the hosted demo, and how to check each claim yourself.

This page summarises [PRIVACY.md](https://github.com/zpredict/zpredict/blob/main/PRIVACY.md), the result of an adversarial audit (#15, 2026-10-23) where every claim names its evidence.

**Summary.** Order privacy is 3-of-3 (all three relayers must collude); funds are 2-of-3 (FROST). Nothing transparent touches the product.

> **warn:** **Read the hosted demo as "one party can see everything".** Cloudflare terminates TLS for all three relayers, so it sees every share in clear plus client IPs ([#88](https://github.com/zpredict/zpredict/issues/88), open). And all three relayers, and the key ceremony, run on **one VM**, whose operator could read every share. The protocol's guarantees assume three independent operators with TLS ending at each relayer.

## Who learns what
| Party | Learns | Never learns |
|---|---|---|
| Chain observer | That shielded Ironwood transactions happen; ZIP 317 fees | Depositors, bets, sides, sizes, winners, payout recipients |
| Anyone (public API) | Markets, outcomes and signatures, one clearing price per market per crossing batch; a digest per share received (how many orders arrive, and when); batch number and peer health | Any share, orderId, account id, balance or fill |
| Network observer | That your IP talks to `*.zpredict.site`; sizes and timing | Contents (TLS); with Tor, your IP |
| **Cloudflare (as deployed)** | **Everything the relayers receive, in clear, and client IPs** (#88) | — |
| **Demo VM operator** | **Everything** | — |
| One relayer | Each deposit's amount, time and account id; each withdrawal's amount, address and time; its own share of everything; the public price | Orders, sides, prices, sizes, positions, balances, any tag, whose withdrawal it is (except via #90 and #91) |
| Two relayers | As one, **and they can move escrow funds** | Orders and tags |
| All three relayers | Everything | — |
| Counterparty | Its own fill at the midpoint; if a single pair crossed, the midpoint and its own limit give yours | Who you are |
| lightwalletd operators (`zec.rocks`, `testnet.zec.rocks`) | The relayer VM's IP syncing and broadcasting | Users |
| Polymarket | The relayer VM's IP fetching public records for the mirrored markets, about every 5 min | Users, orders. The browser never contacts Polymarket |

## Leak checklist
| | Item | Evidence |
|---|---|---|
| ✅ | No transparent address in any product path | [`payout.rs`](https://github.com/zpredict/zpredict/blob/main/packages/settle/src/payout.rs) re-encodes payees Ironwood-only; `verify.rs` refuses transparent, Sapling or Orchard parts; the e2e scripts assert `transparent in+out 0` |
| ✅ | Fixed deposit denominations; order size never on chain | `DENOMINATIONS`; orders exist only as XOR shares |
| ⚠️ | Withdrawals don't link to deposits | Shielded on chain; relayers see amount, address and time (#91) |
| ✅ | Memos carry no order data and never the tag | [`memo-vectors.json`](https://github.com/zpredict/zpredict/blob/main/packages/settle/testdata/memo-vectors.json) |
| ✅ | Each order share goes only to its own relayer | `submitOrder` + `distinct()` in [`packages/sdk`](https://github.com/zpredict/zpredict/tree/main/packages/sdk) |
| ✅ | Public feeds can't be recombined | `/view` publishes BLAKE2b-128 digests, no shares, no orderIds |
| ✅ | One relayer can't collect fill shares | Per-relayer read token ([`orders.rs`](https://github.com/zpredict/zpredict/blob/main/apps/relayer/src/api/orders.rs)) |
| ✅ | One relayer can't read balances or act as an account | `?key=sᵢ` gate; the circuit checks the 128-bit key (`a_relayer_cannot_act_as_an_account`) |
| ⚠️ | TLS ends at each relayer | Cloudflare terminates TLS for all three (#88) |
| ✅ | No third-party requests from the frontends | Self-only CSP; build gates; these docs too (below) |
| ⚠️ | No analytics | We ship none; Cloudflare injects a beacon (blocked by CSP) and NEL headers ([#89](https://github.com/zpredict/zpredict/issues/89)) |
| ✅ | No IP logs | nginx `access_log off`, `error_log /dev/null`, client-IP headers stripped; Caddy and the relayers log no addresses |
| ⚠️ | Request metadata doesn't link actions to an account | Poll jitter, no balance read on fill; connection-level linkage open ([#90](https://github.com/zpredict/zpredict/issues/90)) |
| ✅ | Viewing keys and key shares never leave the relayers or appear in logs | Sealed at rest ([Settlement & FROST](https://docs.zpredict.site/docs/settlement#secrets-at-rest)) |
| ✅ | No secrets in git | `trufflehog git file://. --no-verification` over all history |

## Accepted limitations
- **Single-VM demo** and **Cloudflare** (above).
- **Demo counterparty.** "Take the other side (demo)" sends a judge's order and its exact opposite together from the same account, so a relayer can guess they came from one browser. It still can't read either.
- **Relayers see deposit and withdrawal amounts**, addresses and times, and the account id behind each deposit.
- **Key-burn DoS.** A dishonest relayer can pre-register shares for an id it learned and burn that row: funds locked, never stolen.
- **Public price** per market per crossing batch, and order arrival counts through `/view`.
- **Two relayers can move escrow funds**, while order privacy needs all three.
- **Not quantum-recoverable** escrow notes (ZIP 2005; see [Threat model](https://docs.zpredict.site/docs/threat-model)).
- **Mirrored markets trust Polymarket** ([Markets](https://docs.zpredict.site/docs/markets#mirrored-polymarket-markets)).

## These docs
docs.zpredict.site makes no third-party requests: the font (JetBrains Mono) and every image are self-hosted, search runs on this server, and there are no analytics or cookies. Each page carries a CSP with a per-request script nonce and `default-src 'none'`, no remote origin anywhere. `pnpm build` fails if the client bundle references a third-party host, and the smoke test fails if a rendered page would fetch one ([`apps/docs`](https://github.com/zpredict/zpredict/tree/main/apps/docs)). Links to GitHub and elsewhere are plain links; nothing loads until you click.

## Verify it yourself
```sh
# Public views hold digests only: nothing to XOR.
for i in 0 1 2; do curl -s "https://r$i.zpredict.site/view?after=0" | head -c 300; echo; done
# Fill shares need the browser's token.
curl -s https://r0.zpredict.site/orders/00000000000000000000000000000000
# CSP of the app: self-only, connect-src = the three relayers.
curl -sI https://app.zpredict.site | grep -i 'content-security-policy\|referrer'
# Is TLS still at Cloudflare (#88)? "server: cloudflare" means yes.
curl -sI https://r1.zpredict.site/health | grep -i 'server\|cf-ray'
# Code: one relayer can't act as an account; the full flow pays to Ironwood only.
cargo test -p zpredict-circuit a_relayer_cannot_act_as_an_account
scripts/relayer-e2e.sh
```

# Relayer API (https://docs.zpredict.site/docs/relayer-api)

Every HTTP endpoint the three relayers serve, the JSON they take and return, and the byte encodings behind the hex.

Each relayer is its own origin: `https://r0.zpredict.site`, `https://r1.zpredict.site`, `https://r2.zpredict.site`. The browser talks to each one directly; share `i` of anything goes only to relayer `i`. Routes are in [`apps/relayer/src/api/mod.rs`](https://github.com/zpredict/zpredict/blob/main/apps/relayer/src/api/mod.rs); the spec is [DESIGN.md "Relayer API"](https://github.com/zpredict/zpredict/blob/main/DESIGN.md#relayer-api).

## Conventions
- JSON in and out. CORS allows only the web origin. No cookies or auth headers; clients send `credentials: "omit"` and `no-referrer`. Every response is `no-store`; bodies are capped at 1 KiB.
- No IP logging and no access logs with client addresses.
- Hex is lowercase; `hexN` means N hex characters.
- Costs and balances are in **half-ticks**: 1 unit of collateral = 200 half-ticks.

## Endpoints
| Call | Reply |
|---|---|
| `GET /health` | `{relayer: 0\|1\|2, height, batch, peers: boolean[]}` |
| `GET /markets` | `Market[]` (below) |
| `POST /orders` `{orderId: hex32, share: hex52, token: hex32}` | `202 {batch}`: the batch the share joins. `token` is fresh and random per order and relayer. A second share for the same orderId is `409` |
| `GET /orders/:orderId?token=hex32` | `{status: "pending"\|"filled"\|"unfilled", fillShare?: hex20}`. A missing or wrong token answers `404` like an unknown order, so one relayer (which knows every orderId) can't collect the other fill shares |
| `GET /accounts/:id?key=hex32` | `{balanceShare: hex16, seat?: "seated"\|"waiting"\|"none", mock?: boolean}`. `id` is hex16 (u64 LE), `key` this relayer's share of the tag, else `403`; unknown ids hold a zero share |
| `GET /memo-key` | `{relayer, memoKey: hex64}`: this relayer's X25519 public key for its deposit-memo slot; `404` without one |
| `GET /group-key` | `{relayer, groupKey: hex64, threshold: 2}`: the escrow's FROST group key, which outcome signatures verify against; `404` while signing is off |
| `GET /view?after=<seq>` | `{items: [{seq, kind: "order"\|"fill", digest: hex32}]}`: one item per share received, as `BLAKE2b-128(personal "zPredictViewItem", share)`; never the share or orderId. Powers the Relayer View |
| `POST /judge/claim` `{account: hex16, keyShare: hex32}` | `200 {}`. **Mock, testnet only.** A repeat for the same id and share is `200` with no second credit; a different share for a known id is `409`; a full table is `503` |
| `POST /withdrawals` `{withdrawalId: hex32, share: hex32, amount: half-ticks, address}` | `202 {}`; only with `[settlement]` on |
| `GET /withdrawals/:withdrawalId` | `{status: "pending"\|"refused"\|"debited"\|"paying"\|"paid", txid?, payout?: "retrying"}` |

After a batch, **every** batched order is `filled` with a `fillShare`, because a relayer cannot tell a fill from a miss. The owner combines the three shares and reads the record's `filled` flag. `unfilled` only means "expired before batching" (one of the browser's three POSTs never arrived).

## Market
```ts
{
  id, question, plugin, params, closeHeight, resolveHeight,
  status: "open" | "closed" | "resolved",
  outcome?: "yes" | "no",
  outcomeSig?: hex128,          // FROST 2-of-3 signature, R ‖ S
  blockHash?: hex64, blockHeight?: number,       // chain-data markets
  source?: "polymarket", conditionId?: string,   // mirrored markets
  rolling?: true,
  category?, event?: {id, title, outcome?}, opensAt?, rules?,  // display only, never signed
  lastPrice?: number, history?: number[], historyAt?: number[],
  reference?: {source: "polymarket", price, url, updatedAt?, history: [{t, price}]},
}
```

- `lastPrice` is cents per YES contract at the latest cross (the midpoint, so it can end in .5); `history` the last clearing prices, oldest first. Both are absent until the first fill. They are public by design: the batch reveals that a cross happened and at what price, never who, which orders, how many or what size.
- `historyAt` is optional: the app falls back to one point per cross when a relayer doesn't serve it ([#121](https://github.com/zpredict/zpredict/issues/121)).
- `reference` is mirrored markets only, fetched by the relayers, never by the browser.

## Encodings
All little-endian, identical in `zpredict-core` and `@zpredict/sdk`, pinned by [`packages/core/testdata/vectors.json`](https://github.com/zpredict/zpredict/blob/main/packages/core/testdata/vectors.json).

| Value | Bytes | Layout |
|---|---|---|
| Order (and each share) | 26 | `market u32 \| side u8 (0 yes, 1 no) \| price u8 (1..=99) \| size u32 \| key [16]`, split as `[r0, r1, order ^ r0 ^ r1]` |
| Fill record | 10 | `size u32 \| total_cost u32 \| side u8 \| flags u8 (bit0 = filled)`, summed over every match; unfilled is all zero |
| Balance share | 8 | `u64` half-ticks |
| Account tag | 16 | Random, browser-made. `id = BLAKE2b-64(personal "zPredictAccount1", tag)`; `sᵢ = BLAKE2b-128(personal "zPredictKeyShare", tag ‖ i)` for i = 0, 1; `s₂ = tag ⊕ s₀ ⊕ s₁` |
| Deposit memo | 512 | `0xFF \| 0x02 \| epk (32) \| slot₀ \| slot₁ \| slot₂`, zero after byte 154 ([`memo-vectors.json`](https://github.com/zpredict/zpredict/blob/main/packages/settle/testdata/memo-vectors.json)) |
| orderId | 16 | Random; links the three shares of one order and nothing else |
| Withdrawal share | 16 | The tag, freshly XOR-split; amount and address are public |

Orders above `MAX_SIZE` (1,000,000 contracts) are rejected, so `total_cost` always fits a `u32`.

## Try it
```sh
curl -s https://r0.zpredict.site/health
curl -s https://r0.zpredict.site/markets | jq '.[] | {id, question, status, lastPrice}'
curl -s https://r0.zpredict.site/group-key
```

# Run a relayer (https://docs.zpredict.site/docs/run-a-relayer)

Three relayers on your machine with real MPC, the full regtest end-to-end run, and how the hosted demo is deployed through Coolify.

The relayer is one Rust binary, [`apps/relayer`](https://github.com/zpredict/zpredict/tree/main/apps/relayer) (`zpredict-relayer`). Its config keys are documented in [`apps/relayer/README.md`](https://github.com/zpredict/zpredict/blob/main/apps/relayer/README.md#config).

## Prerequisites
Node 24 with pnpm 10, Rust 1.91.1. The regtest runs also need Docker, `uv`, `jq` and `openssl`.

```sh
pnpm install
cargo test --workspace     # slow real-MPC and live-network tests stay #[ignore]
```

## 1. App with mock relayers
```sh
pnpm --filter @zpredict/web dev:mock    # app + three mock relayers on :3000
```

No Rust needed. The mocks implement the relayer API in one process and combine the shares **in the clear** (with bot orders for liquidity); real relayers never see each other's shares.

## 2. Three real relayers
```sh
export CARGO_TARGET_DIR=$HOME/.cache/zpredict-target   # optional, shared build cache
apps/relayer/scripts/dev.sh                            # API :4101-4103, peers :4201-4203, real MPC
# second terminal
NEXT_PUBLIC_RELAYERS=http://localhost:4101,http://localhost:4102,http://localhost:4103 \
  pnpm --filter @zpredict/web dev                      # http://localhost:3000
```

[`dev.sh`](https://github.com/zpredict/zpredict/blob/main/apps/relayer/scripts/dev.sh) generates Noise keys into `apps/relayer/config/keys/` on first run and keeps state in `config/data/` (both gitignored; delete `data` to start fresh). Startup compiles the circuit (~6 s) and preprocesses the first run (~6 s). Then click **Judge mode** and trade; a fill appears about half a 4 s window plus 0.5 s of MPC later.

`INSECURE=1 apps/relayer/scripts/dev.sh` swaps in the plaintext dev matcher: batches finish instantly and **relayer 0 sees every order**. It exists only behind the `dev-insecure` cargo feature plus `insecure_matcher = true`. Never deploy it.

One relayer by hand: `zpredict-relayer <config.toml>`; a key pair: `zpredict-relayer keygen <prefix>`.

## 3. Regtest end to end
```sh
scripts/relayer-e2e.sh                # full run
SKIP_BUILD=1 scripts/relayer-e2e.sh   # reuse built tools
KEEP=1 scripts/relayer-e2e.sh         # leave regtest and the relayers running
```

[`relayer-e2e.sh`](https://github.com/zpredict/zpredict/blob/main/scripts/relayer-e2e.sh) starts Zebra regtest and lightwalletd ([`deploy/regtest`](https://github.com/zpredict/zpredict/tree/main/deploy/regtest)), runs the sealed FROST DKG, and makes two tagged 25-unit deposits. Three real relayers (MPC over Noise, settlement on) then scan and credit them; YES 60 × 10 crosses NO 45 × 10 at 57.5¢; every node resolves the market YES and the resolution MPC pays A; **each relayer's forged withdrawal for A is refused**; A withdraws to a fresh address; relayers 0 and 1 verify-then-sign one payout; it is mined as v6, Ironwood only, and the payee holds exactly `(5850 − 2) × 5000` zats. Finally it checks no relayer secret is left unsealed. State lives in `~/.cache/zpredict-run`, wiped every run.

## 4. Production (the hosted demo)
[`deploy/README.md`](https://github.com/zpredict/zpredict/blob/main/deploy/README.md) is the operator's guide. In short:

| URL | Coolify resource (project `zpredict`) | Loopback port |
|---|---|---|
| zpredict.site | `landing` ([`apps/landing/Dockerfile`](https://github.com/zpredict/zpredict/blob/main/apps/landing/Dockerfile)) | 3170 |
| app.zpredict.site | `web` ([`apps/web/Dockerfile`](https://github.com/zpredict/zpredict/blob/main/apps/web/Dockerfile)) | 3171 |
| r0–r2.zpredict.site | compose `relayers` ([`compose.yaml`](https://github.com/zpredict/zpredict/blob/main/deploy/production/compose.yaml)) | 3172–3174 |
| docs.zpredict.site | `docs` ([`apps/docs/Dockerfile`](https://github.com/zpredict/zpredict/blob/main/apps/docs/Dockerfile)) | 3175 |

- Request path: browser → Cloudflare → host nginx on the VPS (TLS, [`nginx.conf`](https://github.com/zpredict/zpredict/blob/main/deploy/production/nginx.conf): no access log, client-IP headers stripped) → container on `127.0.0.1:<port>`. Coolify builds the images on the VPS from `main`; nothing goes to a registry.
- A merge to `main` runs CI, then [`deploy.yml`](https://github.com/zpredict/zpredict/blob/main/.github/workflows/deploy.yml) asks Coolify to deploy every uuid in `COOLIFY_RESOURCES`.
- The three relayers share one image ([`relayer.Dockerfile`](https://github.com/zpredict/zpredict/blob/main/deploy/production/relayer.Dockerfile), distroless, non-root) and one [`relayer.toml`](https://github.com/zpredict/zpredict/blob/main/deploy/production/relayer.toml); `ZPREDICT_INDEX` picks the party. Noise keys and store keys are Coolify secrets mounted under `/run/secrets`. Each has its own data volume; never delete one while orders are open.
- Health: `https://rN.zpredict.site/health` (`peers` all `true` once a batch ran).

> **warn:** All three relayers run on **one VM**. The security model needs three independent operators on separate hosts; the demo shows the protocol working, not the trust split.

# SDK (https://docs.zpredict.site/docs/sdk)

@zpredict/sdk: order encoding, XOR sharing, deposit memos, withdrawals, outcome verification and the relayer client, as used by the web app.

[`packages/sdk`](https://github.com/zpredict/zpredict/tree/main/packages/sdk) is the browser side of zPredict. It mirrors `zpredict-core` byte for byte and talks to the three relayers, sending each one only its own share. Dependencies: `@noble/hashes` and `@noble/ciphers` (BLAKE2b, ChaCha20-Poly1305); X25519 is WebCrypto. It is a workspace package (`"@zpredict/sdk": "workspace:*"`), not published to npm.

```sh
pnpm --filter @zpredict/sdk build   # tsc → dist/
pnpm --filter @zpredict/sdk test    # node --test, incl. the Rust test vectors
```

Every function that talks to more than one relayer first calls `distinct(relayers)`, which throws unless the three URLs are three different origins, so no host ever receives two shares.

## Setup
```ts
import { RELAYERS, newTag, type Relayers } from "@zpredict/sdk";

const relayers: Relayers = RELAYERS; // the hosted testnet relayers r0, r1, r2
const tag = newTag();               // 16 random bytes, hex: the account's credential. Keep it.
```

`RELAYERS` is `["https://r0.zpredict.site", "https://r1.zpredict.site", "https://r2.zpredict.site"]`.

## Accounts
```ts
import { accountHex, claimJudge, getAccount, keyShares, TableFullError } from "@zpredict/sdk";

accountHex(tag);   // public id, hex16
keyShares(tag);    // [s0, s1, s2]: relayer i's share of the tag, deterministic

await claimJudge(relayers, tag);   // Judge mode (testnet): mock credit; safe to retry
const { balance, seat, mock } = await getAccount(relayers, tag); // balance in half-ticks
```

`claimJudge` throws `TableFullError` when the balance table has no free row. `getBalance(relayers, tag)` returns only the balance.

## submitOrder
```ts
import { submitOrder, submitWithCounterparty, getFill } from "@zpredict/sdk";

const order = { market: 1, side: 0, price: 60, size: 10, key: tag } as const; // YES at 60¢ × 10
const { orderId, batch, shares, tokens } = await submitOrder(relayers, order);

// Poll until the batch ran. Keep `tokens`: relayer i answers only to tokens[i].
const res = await getFill(relayers, orderId, tokens);
if (res.status === "filled" && res.fill?.filled) {
  const avgCents = res.fill.totalCost / res.fill.size / 2;
}
```

- `side`: `0` = YES, `1` = NO. `price`: 1..=99. `size` ≤ `MAX_SIZE` (1,000,000).
- `getFill` returns `pending`, `unfilled` (expired before batching) or `filled` with the decoded record; it throws if the relayers disagree.
- `submitWithCounterparty(relayers, order)` is the **demo** counterparty: it also sends `counterOrder(order)` (the other side at `100 − price`, same account), so a lone judge sees a self-trade fill that nets out at resolution.

## Deposits
```ts
import { encodeMemo, depositUri, getMemoKeys } from "@zpredict/sdk";

const keys = await getMemoKeys(relayers);       // each fetched from its own relayer, checked in order
const memo = await encodeMemo(tag, keys);       // 512 bytes; fresh ephemeral key per memo
const uri = depositUri(escrowUa, 5, memo);      // ZIP 321: zcash:<ua>?amount=0.05&memo=…
```

`DENOMINATIONS` is `[1, 5, 25]` units; `depositUri` refuses any other amount. `unitsToZec(5)` is `"0.05"`.

## Withdrawals
```ts
import { submitWithdrawal, getWithdrawal, payoutZats } from "@zpredict/sdk";

const { withdrawalId } = await submitWithdrawal(relayers, tag, 5000n, freshShieldedUa);
const { status, txid } = await getWithdrawal(relayers, withdrawalId); // relayer 0 pays, so its view
payoutZats(5000n); // (5000 − 2) × 5000 zats: 2 half-ticks go toward the fee
```

`amount` is in half-ticks and must exceed `WITHDRAWAL_FEE` (2). Use a fresh unified address with an Orchard-shaped receiver; payouts go to the Ironwood pool.

## verifyOutcome
```ts
import { getGroupKey, getMarkets, verifyOutcome } from "@zpredict/sdk";

const groupKey = await getGroupKey(relayers);   // null unless all three serve the same key
const markets = await getMarkets(relayers[0]);
for (const m of markets) {
  if (m.status === "resolved" && groupKey) console.log(m.id, verifyOutcome(m, groupKey));
}
```

`verifyOutcome` rebuilds the signed message (`outcomeMessage`: block form or Polymarket form) and checks the RedPallas signature over Pallas in plain BigInt arithmetic; only public data is involved.

## Low level
| Export | What |
|---|---|
| `encodeOrder`, `decodeOrder`, `decodeFill`, `decodeBalance` | The byte layouts in [Relayer API](https://docs.zpredict.site/docs/relayer-api#encodings) |
| `split(bytes)`, `combine(shares)` | Three XOR shares and back |
| `viewDigest(share)` | The digest `/view` publishes for a share, to find your own items |
| `getView(url, after)` | One relayer's `/view` |
| `ORDER_LEN`, `FILL_LEN`, `BALANCE_LEN`, `HALF_TICKS_PER_UNIT`, `UNIT_ZATS`, `ZATS_PER_HALF_TICK` | Constants |
| `Market`, `Health`, `OrderStatus`, `WithdrawalStatus`, … | Reply types |

Source: [`index.ts`](https://github.com/zpredict/zpredict/blob/main/packages/sdk/src/index.ts), [`memo.ts`](https://github.com/zpredict/zpredict/blob/main/packages/sdk/src/memo.ts), [`withdraw.ts`](https://github.com/zpredict/zpredict/blob/main/packages/sdk/src/withdraw.ts), [`outcome.ts`](https://github.com/zpredict/zpredict/blob/main/packages/sdk/src/outcome.ts).

# Settlement & FROST (https://docs.zpredict.site/docs/settlement)

The FROST 2-of-3 key generation, batched Ironwood payouts, verify-then-sign, and how every relayer secret is sealed at rest.

[`packages/settle`](https://github.com/zpredict/zpredict/tree/main/packages/settle) scans the escrow, builds batched payouts and signs them. It uses Zcash Foundation's `frost-core` 2.2 (RedPallas from `reddsa` at `ed49e9c`) **as a library** for both the DKG and signing, on the librustzcash release of 2026-10-21 (`pczt` 0.10, `orchard` 0.16 with the Ironwood pool, `zcash_primitives` 0.31). Details: [`packages/settle/README.md`](https://github.com/zpredict/zpredict/blob/main/packages/settle/README.md).

## Key generation (DKG)
`settle-payout dkg` runs a 2-of-3 FROST (RedPallas) DKG for `r0 r1 r2`. Each share and the escrow's UFVK are **sealed as they are made**; no share is ever in a clear file. The escrow's full viewing key is the FROST group key `ak` plus random `nk`/`rivk`, the same construction as `zcash-sign generate`, and its address is Ironwood-only.

The hosted testnet escrow came from one ceremony on 2026-10-23, inside a throwaway container with `/work` on tmpfs ([`ceremony.sh`](https://github.com/zpredict/zpredict/blob/main/deploy/production/ceremony.sh)): DKG, reseal each share under that relayer's own store key into its volume, one memo key per relayer, then relayer 0's watch-only wallet, sealed. Plaintext keys only ever existed in that container's memory, tmpfs and pipes.

> **warn:** On the demo the DKG ran all three parties in one process on the one VM. Independent operators would run the DKG rounds between hosts over the Noise channel.

## Batched payouts
| Step | What happens |
|---|---|
| Debit | The clearing circuit takes each withdrawal from its row (or refuses it) |
| Create | Relayer 0 builds **one PCZT** from the escrow wallet: an Ironwood output per payout, change to the escrow's internal scope, ZIP 317 fee, wallet-default padding |
| Prove | Relayer 0 proves in the background (zcash-devtool), off the signing path |
| Sign | Relayers 0 and 1 run one FROST session for every spend, exchanging round messages over the Noise peer links |
| Broadcast | Relayer 0 records `paying` first (so a crash never pays twice), combines, broadcasts, and tells the others the txid |

## Verify, then sign
Each signer runs `verify_then_sign` on its own copy of the PCZT against its own ledger's payout vector. It refuses unless nothing is transparent, Sapling or Orchard; every output matches its commitment; every payout is present exactly once and decrypts; every other non-zero output goes back to the escrow's internal scope; and the fee is exactly ZIP 317. It then checks that each signing request carries the recomputed sighash and the right randomizer. The e2e script signs against a vector tampered by one zatoshi and requires a refusal:

```
settle-payout: refused to sign: action 0: 4000000 zats to an address not in the payout vector
```

## Why a new signer
The planned `zcash-sign` from frost-zcash-demo cannot sign post-NU7 PCZTs (it is pinned to `zcash_protocol` 0.10 / `pczt` 0.8.0-rc.1, where NU7 is a placeholder branch id). `zpredict-settle` computes the shielded sighash with the v6 rule for v6 transactions and takes randomizers from both the Orchard and the Ironwood bundles.

## Secrets at rest
Relayer secrets are sealed with ChaCha20-Poly1305 ([`store.rs`](https://github.com/zpredict/zpredict/blob/main/packages/settle/src/store.rs)) under a 32-byte store key, `ZPREDICT_STORE_KEY`, which only ever exists in the environment (a Coolify secret in production). Each sealed file binds its label as associated data, so one secret can't be passed off as another.

| Secret | Holder | At rest |
|---|---|---|
| FROST key share | each relayer | `share.sealed` |
| Escrow UFVK (shared viewing key) | every relayer | `ufvk.sealed` |
| Deposit-memo key (X25519) | each relayer | `memo.sealed`; public half at `GET /memo-key` |
| Escrow wallet | relayer 0 (prover) | `escrow-wallet.sealed`, opened into tmpfs only |
| Noise key | each relayer | Coolify compose secret under `/run/secrets` |
| Account tag | your browser | `localStorage` only |

Outcome signatures use the same shares: see [Markets & resolution](https://docs.zpredict.site/docs/markets#signed-outcomes).

## Run it
```sh
scripts/settle-e2e.sh    # regtest → sealed DKG → two deposits → one batched payout → mined
scripts/scan-e2e.sh      # regtest → DKG → a tagged deposit → r0, r1, r2 scan it
```

Both need Docker, Rust 1.91.1, `uv`, `jq` and `openssl`. See [Run a relayer](https://docs.zpredict.site/docs/run-a-relayer) and [Evidence](https://docs.zpredict.site/docs/evidence).

# Status (https://docs.zpredict.site/docs/status)

What runs for real, what is a labelled mock, and what is still open, as of the main branch.

zPredict is a ZECATHON 2026 testnet demo. Collateral and settlement are on **Zcash testnet**; markets resolve from **mainnet** data. Nothing here is audited by a third party; the privacy audit in [PRIVACY.md](https://github.com/zpredict/zpredict/blob/main/PRIVACY.md) is the team's own adversarial review.

## Real
| Piece | Where it runs |
|---|---|
| Browser XOR sharing, one share per relayer | [`@zpredict/sdk`](https://docs.zpredict.site/docs/sdk), the hosted app |
| 3-party MPC matching (polytune), balances and 128-bit account credentials checked in-circuit | The hosted relayers; [`packages/circuit`](https://docs.zpredict.site/docs/circuits) |
| Resolution payouts in MPC | Hosted relayers; regtest e2e |
| Chain-data resolution from mainnet blocks, FROST-signed outcomes | Hosted relayers (`[settlement]` on, `GET /group-key`) |
| FROST 2-of-3 DKG, sealed key shares | One ceremony on 2026-10-23 for the testnet escrow |
| Fixed-denomination deposits with encrypted memos, scanned by all three | Testnet escrow; `scan-e2e.sh` on regtest |
| Batched, verify-then-sign Ironwood payouts; forged withdrawals refused | `relayer-e2e.sh` and `settle-e2e.sh` on regtest |
| Hourly rolling markets | Hosted relayers |
| Mirrored Polymarket markets (US midterms), resolved per Polymarket | Hosted relayers; oracle trust in [Markets](https://docs.zpredict.site/docs/markets#mirrored-polymarket-markets) |

## Mock, and labelled as such
| Piece | Why |
|---|---|
| **Judge mode** credit | 25 units of mock testnet collateral without a deposit, so judges can trade without a faucet. Flagged by every relayer and never withdrawable ([Your money](https://docs.zpredict.site/docs/your-money#judge-credits-are-not-money)). Testnet only |
| **Demo counterparty** ("Take the other side (demo)") | A lone judge has nobody to cross with; this adds the exact opposite order from the same account, a self-trade that nets out. No bot, resting book or relayer-side liquidity exists |
| `pnpm --filter @zpredict/web dev:mock` | Local mock relayers that combine shares in the clear, for UI work only |

## Demo limits
- **One VM, behind Cloudflare.** All three relayers and the DKG run on one machine, and Cloudflare terminates TLS for all three ([#88](https://github.com/zpredict/zpredict/issues/88)). Either can see everything. The model needs independent operators.
- **48 accounts** in the balance table; rows are not reclaimed yet ([#64](https://github.com/zpredict/zpredict/issues/64)).
- **Six open markets** per MPC run.
- A fill shows about 25 s after the order on the hosted VPS.

## Open
| Issue | What |
|---|---|
| [#88](https://github.com/zpredict/zpredict/issues/88), [#89](https://github.com/zpredict/zpredict/issues/89) | Cloudflare TLS termination; injected beacon and NEL headers |
| [#90](https://github.com/zpredict/zpredict/issues/90), [#91](https://github.com/zpredict/zpredict/issues/91) | Request metadata and withdrawal amount/timing linkage |
| [#64](https://github.com/zpredict/zpredict/issues/64) | Reclaim balance-table rows after a full withdrawal |
| [#121](https://github.com/zpredict/zpredict/issues/121) | Relayers serve `historyAt` for price history |
| [#124](https://github.com/zpredict/zpredict/issues/124) | Deploy robustness after the relayer outage of 2026-10-23 |
| [#75](https://github.com/zpredict/zpredict/issues/75) | Build the relayer image in CI to avoid deploy downtime |

## Is it up?
```sh
for i in 0 1 2; do curl -s https://r$i.zpredict.site/health; echo; done
```

`peers` all `true` means the three relayers reached each other in the last batch.

# Threat model (https://docs.zpredict.site/docs/threat-model)

Funds are 2-of-3, order privacy is 3-of-3. What each observer and each coalition of relayers can do, and the attacks that remain.

From [DESIGN.md "Threat model"](https://github.com/zpredict/zpredict/blob/main/DESIGN.md#threat-model).

| Observer | Learns | Doesn't learn |
|---|---|---|
| Chain observer | The escrow address exists and makes batched shielded transactions | Who bet, which side, how much, who won |
| Any single relayer | "A 5-unit deposit arrived for account X"; "someone withdrew A to address Y" | Orders, sides, prices, positions, balances, whose withdrawal it was; any account's tag, so it can't trade, withdraw or read a balance as anyone |
| 2 colluding relayers | Same as one for orders; **can move escrow funds** (FROST 2-of-3) | Orders (polytune needs all 3 to collude); tags (the third share is missing) |
| All 3 colluding | Everything | — |
| Counterparty | That a fill happened at the midpoint | Who you are, your size beyond the fill |
| Network observer | That someone talks to a zPredict relayer | Contents (TLS); your IP if you use Tor |
| Cloudflare (as deployed, until #88) | Everything the three relayers receive, in clear, plus client IPs: in effect all three colluding | — once the relayer hostnames are DNS-only |
| Demo VM operator | Everything: all three relayers and the DKG ran on one machine | — with three independent operators |

**Stated up front:** funds are 2-of-3, order privacy is 3-of-3. The demo concentrates all three relayers on one VM behind Cloudflare.

## MPC security
polytune runs WRK17 authenticated garbling with distributed preprocessing (no trusted dealer). An honest relayer's shares stay private even if the other two collude and cheat; a cheater can only make the run abort, which is retried next window. Peer links are Noise `KK` with pinned static keys.

## Accounts: a credential no relayer sees
A relayer knows each account's public id and its own share of the tag, never the tag ([#85](https://github.com/zpredict/zpredict/issues/85)). Orders and withdrawals carry the tag only as fresh XOR shares, and the circuit uses a row only for a request whose shares rebuild that row's key. So a relayer posing as a browser with what it knows gets a dummy order or a refused withdrawal: test `a_relayer_cannot_act_as_an_account` in [`differential.rs`](https://github.com/zpredict/zpredict/blob/main/packages/circuit/tests/differential.rs), and the forged withdrawals in [`relayer-e2e.sh`](https://github.com/zpredict/zpredict/blob/main/scripts/relayer-e2e.sh). Balance reads are gated the same way: relayer `i` serves a balance share only to a caller holding `sᵢ`.

**Key burn.** A dishonest relayer could try to *choose* the other relayers' shares for an id it learned, by sending a judge claim or a deposit memo of its own for that id first. Honest shares are deterministic in the tag, so the victim's later deposit shows a different share for a known id, and that relayer then **burns** the key (fresh random share every batch). The row is lost, never handed over: the same denial of service a relayer can always cause by aborting the MPC.

## Signers check what they sign
Before a FROST signature on a payout, each signer recomputes the sighash from the PCZT and checks every output against the agreed payout vector (`verify_then_sign`). A coordinator that changes one output by one zatoshi gets a refusal. See [Settlement & FROST](https://docs.zpredict.site/docs/settlement).

## Not quantum-recoverable (ZIP 2005)
The escrow's spend authority `ak` is the FROST group key, so its viewing key cannot be derived from a seed; `nk` and `rivk` are random, generated once at the DKG. Escrow notes therefore don't use the ZIP 2005 derivation that would let a key holder recover them after a quantum break of discrete log. Acceptable for a hackathon escrow that holds collateral for days; a production escrow needs a FROST-compatible ZIP 2005 derivation, or a sweep to a recoverable key first.

## Open issues
| Issue | Effect |
|---|---|
| [#88](https://github.com/zpredict/zpredict/issues/88) | Cloudflare terminates TLS for all three relayers |
| [#89](https://github.com/zpredict/zpredict/issues/89) | Cloudflare injects an analytics beacon (CSP blocks it) and NEL headers |
| [#90](https://github.com/zpredict/zpredict/issues/90) | Connection-level metadata can link orders and withdrawals to an account id |
| [#91](https://github.com/zpredict/zpredict/issues/91) | Withdrawal amount and timing can link deposits to payouts at the relayers |

# Your money (https://docs.zpredict.site/docs/your-money)

The FROST 2-of-3 escrow, fixed-denomination deposits with an encrypted memo, batched withdrawals to fresh addresses, and why judge credit never pays out.

## Units
| | Value |
|---|---|
| 1 unit of collateral | 0.01 ZEC = 1,000,000 zats |
| 1 unit | 200 half-ticks (balances and costs are in half-ticks) |
| 1 half-tick | 5,000 zats |
| One matched YES/NO contract | locks 1 unit, pays 1 unit to the winner |

## The escrow
Collateral sits at one **Ironwood-only unified address** whose spend authority is a FROST 2-of-3 group key from a distributed key generation. No relayer holds the whole key: any two must sign to move funds, and one alone cannot. The testnet escrow from the key ceremony (2026-10-23, [`deploy/README.md`](https://github.com/zpredict/zpredict/blob/main/deploy/README.md)):

```
utest18yg3cneyjgxxknjm0j9020hmx5jp2wm67qn2p3rfmflct9hwlt363gzl50qeu3ms2u044hq8jzuzkl2tq8xa4m0ukzvlpqgtvu96ffyy
```

Its group key `a0cff44d86411e03d1ae3a76982d38bf0aac73db9568a2496324167486dbc534` is served by all three relayers at `GET /group-key`; it also signs outcomes.

## Deposits
- **Fixed denominations only: 1, 5 or 25 units** ([`DENOMINATIONS`](https://github.com/zpredict/zpredict/blob/main/packages/sdk/src/memo.ts)). Any other amount, or a note without a readable memo slot, is flagged and never credited.
- **The memo** (ZIP 302 `0xFF`, 512 bytes): `0xFF | 0x02 | epk | slot₀ | slot₁ | slot₂`. Slot `i` is ChaCha20-Poly1305 of `id ‖ sᵢ` under a key from X25519 with relayer `i`'s memo key and a fresh ephemeral key. Each relayer can open only its own slot, so none ever reads your tag, and no order data or amount is in the memo. The app hands your wallet a ZIP 321 payment request ([`depositUri`](https://docs.zpredict.site/docs/sdk#deposits)).
- **Crediting:** after 10 confirmations, and only when all three relayers' own escrow scans found the deposit. The credit enters the circuit as a public per-row input. No single relayer can credit money the chain does not show.

## Withdrawals
1. You send all three relayers `POST /withdrawals` with the amount, a **fresh shielded address**, and a fresh XOR share of your tag each.
2. In the next batch, the circuit takes the amount from the row whose key the shares rebuild, only if it covers it, before orders reserve funds. A public bit per slot (two slots per batch) says whether it went through; it does not say whose it was.
3. Debited withdrawals are paid in **one** v6 transaction with an Ironwood output per payee and change back to the escrow, ZIP 317 fee, wallet-default padding. Each withdrawal keeps 2 half-ticks (10,000 zats) toward the fee: you receive `(amount − 2) × 5,000` zats.
4. Status: `pending → debited → paying → paid` (with the txid), or `refused`. A payout the escrow cannot cover, or one a node misses, shows `payout: "retrying"` and is retried with backoff; it never stops clearing.

Relayers do see each withdrawal's amount, address and time (they need them to pay), so amount and timing can link a deposit to a payout at the relayers ([#91](https://github.com/zpredict/zpredict/issues/91)). Withdraw round amounts at unremarkable times if that matters to you.

## Judge credits are not money
Judge mode (testnet only) opens an account with **mock** credit: 25 units, no deposit. Because a judge claim shows every relayer the new account's id, each relayer flags that row as mock, feeds its flags into the circuit as private input, and the circuit refuses any withdrawal from a flagged row if any relayer flagged it ([#104](https://github.com/zpredict/zpredict/issues/104), test `mock_rows_never_withdraw`). The refusal looks like any uncovered withdrawal. A judge account that later receives a real deposit stays mock: deposit with a fresh tag. Mainnet builds refuse judge mode.

## Limits on the demo
- The balance table has 48 rows on the hosted demo (one per funded account); when it is full, Judge mode answers "table full". Rows are not reclaimed yet ([#64](https://github.com/zpredict/zpredict/issues/64)).
- Mock and real accounts trade in the same markets, so a real account can win mock credit, and the escrow can hold less than real accounts are owed. A payout it can't cover is retried. That is acceptable on testnet only.
