diff --git a/CTF-numeric.md b/CTF-numeric.md new file mode 100644 index 00000000..99c06cc3 --- /dev/null +++ b/CTF-numeric.md @@ -0,0 +1,234 @@ +# NUT-CTF-numeric: Numeric Outcome Conditions + +`optional` + +`depends on: NUT-CTF, NUT-CTF-split-merge` + +--- + +This NUT defines numeric outcome conditions where the oracle attests to a numeric value (e.g., BTC/USD price) rather than an enumerated outcome. The condition has two outcome collections — **HI** and **LO** — representing the high and low ends of a range. Both HI and LO token holders receive **proportional** redemption based on the attested value's position within the range. + +This follows the [Gnosis CTF scalar condition model](https://conditional-tokens.readthedocs.io/en/latest/) ([contracts](https://github.com/gnosis/conditional-tokens-contracts)) and uses [DLC digit-decomposition oracle attestation](https://github.com/discreetlogcontracts/dlcspecs/blob/master/Oracle.md) for interoperability. + +## HI/LO Conditions + +A numeric condition has exactly 2 outcome collections: + +- **LO**: Represents the low end of the range. LO holders profit when the attested value is near or below `lo_bound`. +- **HI**: Represents the high end of the range. HI holders profit when the attested value is near or above `hi_bound`. + +The outcome collection keysets are always `["HI", "LO"]` for numeric conditions. + +## Payout Calculation + +Given range `[lo_bound, hi_bound]` and attested value `V`: + +``` +clamped_V = clamp(V, lo_bound, hi_bound) +hi_payout_ratio = (clamped_V - lo_bound) / (hi_bound - lo_bound) +lo_payout_ratio = 1 - hi_payout_ratio +``` + +For a face value of `amount`: + +- HI holder redeems: `floor(amount * hi_payout_ratio)` +- LO holder redeems: `floor(amount * lo_payout_ratio)` +- Dust: `amount - floor(amount * hi_payout_ratio) - floor(amount * lo_payout_ratio)` is retained by the mint. + +`HI + LO <= amount` always, so a single redemption never pays out more than the face value. Because `floor` is subadditive (`floor(a * r) + floor(b * r) <= floor((a + b) * r)`), splitting or consolidating proofs before redemption can only reduce a holder's payout, never increase it (see [Conservation](#conservation)). + +**Edge cases**: + +- `V <= lo_bound`: LO gets 100%, HI gets 0% +- `V >= hi_bound`: HI gets 100%, LO gets 0% + +### Example + +Range `[0, 100000]`, attested value `V = 20000`: + +``` +hi_payout_ratio = (20000 - 0) / (100000 - 0) = 0.2 +lo_payout_ratio = 1 - 0.2 = 0.8 +``` + +For 100 sats face value: + +- HI: `floor(100 * 0.2)` = 20 sats +- LO: `100 - 20` = 80 sats + +## Condition Registration + +Numeric conditions are registered via the same `POST /v1/conditions` endpoint ([NUT-CTF][CTF]) with additional fields: + +### Request Body + +**Request** of `Alice`: + +```http +POST https://mint.host:3338/v1/conditions +``` + +```json +{ + "threshold": 1, + "tags": [["description", "BTC/USD price on 2025-07-01"]], + "announcements": [ + "" + ], + "condition_type": "numeric", + "lo_bound": 0, + "hi_bound": 100000, + "precision": 0, + "collateral": "sat" +} +``` + +```bash +curl -X POST https://mint.host:3338/v1/conditions \ + -H "Content-Type: application/json" \ + -d '{"threshold":1,"tags":[["description","BTC/USD price"]],"announcements":["fdd824..."],"condition_type":"numeric","lo_bound":0,"hi_bound":100000,"precision":0,"collateral":"sat"}' +``` + +- `condition_type`: `"numeric"` (vs default `"enum"` for existing [NUT-CTF][CTF] conditions). When omitted, defaults to `"enum"`. +- `lo_bound`: Lower bound of the range (integer) +- `hi_bound`: Upper bound of the range (integer, MUST be > `lo_bound`) +- `precision`: Base-10 exponent for the oracle's digit decomposition (from the DLC event descriptor). A precision of `n` means the oracle's attested digits represent a value multiplied by `10^n`. For example, precision `0` means the digits represent the value directly, precision `-2` means the digits represent cents (divide by 100). + +**Response** of `Bob`: + +```json +{ + "condition_id": +} +``` + +The mint always creates the `HI` and `LO` keysets during numeric condition registration. If `default_keyset_creation` is `"none"`, a client MAY provide `outcome_collections` exactly as `["HI", "LO"]`; any other collection is invalid. If the mint advertises `"one-vs-rest"` or `"all"`, the client MUST omit `outcome_collections`. In all cases, `collateral` is required because numeric registration creates keysets. + +## Condition ID for Numeric Conditions + +Numeric conditions extend the [NUT-CTF][CTF] condition ID formula by appending market-specific parameters: + +``` +condition_id = tagged_hash("Cashu_condition_id", + sorted_oracle_pubkeys || event_id || outcome_count + || 0x01 || lo_bound_i64be || hi_bound_i64be || precision_i32be) +``` + +Where: + +- The first three components are identical to [NUT-CTF][CTF] +- `0x01`: 1-byte market type indicator (`0x01` = numeric). Enum markets ([NUT-CTF][CTF]) do NOT append this byte, preserving backward compatibility. +- `lo_bound_i64be`: `lo_bound` encoded as 8-byte big-endian signed integer +- `hi_bound_i64be`: `hi_bound` encoded as 8-byte big-endian signed integer +- `precision_i32be`: `precision` encoded as 4-byte big-endian signed integer + +`outcome_count` = 2 (always). The outcome collection keysets are always `["HI", "LO"]`. + +## Oracle Witness for Digit Decomposition + +The oracle signs individual digits per the [DLC specification](https://github.com/discreetlogcontracts/dlcspecs/blob/master/Oracle.md). The witness format extends [NUT-CTF][CTF]: + +```json +{ + "oracle_sigs": [ + { + "oracle_pubkey": , + "digit_sigs": + } + ] +} +``` + +- `digit_sigs`: Array of 64-byte Schnorr signatures (128-char hex strings), one per digit, in left-to-right order (most significant digit first). Each signature is on the digit's UTF-8 string representation (e.g., `"2"` for digit value 2) using the corresponding R-value (nonce point) from the oracle announcement. +- For signed numbers: the first element is a signature on `"+"` or `"-"` + +The witness uses `digit_sigs` (array of per-digit signatures) instead of `oracle_sig` (single signature) used in [NUT-CTF][CTF] enum conditions. The mint identifies which format to expect based on the `condition_type` of the condition referenced by the input keyset. + +### Verification + +The mint: + +1. Extracts the digit values from `digit_sigs` by verifying each signature against the corresponding R-value from the oracle announcement +2. Reconstructs the numeric value from the digit values (accounting for sign and `precision`) +3. Clamps the value to `[lo_bound, hi_bound]` +4. Computes the payout ratio + +## Redemption + +Both HI and LO holders can redeem at `POST /v1/redeem_outcome` ([NUT-CTF][CTF]). Unlike enum conditions where only the winning outcome collection can redeem, in numeric conditions **both outcomes can redeem** with proportional amounts. + +### HI Holder Redemption + +Given attested value `V = 20000`, range `[0, 100000]`: + +- Input: 100 sats of HI tokens + digit witness +- Payout ratio: `(20000 - 0) / (100000 - 0)` = 0.2 +- Output: `floor(100 * 0.2)` = 20 sats regular ecash +- Remaining 80 sats are not issued (HI holder's loss) + +### LO Holder Redemption + +Same attestation, same range: + +- Input: 100 sats of LO tokens + digit witness +- Payout ratio: `1 - 0.2` = 0.8 +- Output: `floor(100 * 0.8)` = 80 sats regular ecash + +### Conservation + +For a face value `amount`, the mint pays `floor(amount * hi_payout_ratio)` to HI and `floor(amount * lo_payout_ratio)` to LO, and retains the dust `amount - HI - LO` as mint revenue. Total payout is at most `amount`. This rule is partition-invariant: a holder cannot raise a payout by splitting or merging proofs (same-keyset [NUT-03][03] swaps) before redemption, because flooring each leg independently only reduces the sum. The mint MUST settle each leg with exact integer arithmetic; the dust is not condition collateral. + +## Convert (Split and Merge) + +The [NUT-CTF-split-merge][CTF-split-merge] `convert` operation applies to numeric conditions. For canonicalisation, a numeric condition's outcome-atom set is the fixed synthetic order `Ω = ["HI", "LO"]`, independent of the digit-decomposition oracle fields. The only outcome collections are the two single atoms `HI` and `LO`; the full set `HI|LO` is the collateral/forbidden full-set and is never a conditional keyset. Convert on a numeric condition therefore reduces to: + +- **Split**: Deposit collateral, receive equal amounts of HI and LO tokens (`outputs {"HI": ..., "LO": ...}`) +- **Merge**: Surrender equal amounts of HI and LO tokens, receive collateral back (`outputs {"*": ...}`) + +Exact-equality conservation preserves the HI/LO face vector, so the proportional redemption above is unaffected. No other special handling is needed. + +## Combinatorial Markets + +Numeric conditions can participate in root-level [NUT-CTF-split-merge][CTF-split-merge] convert operations. Nested/combinatorial construction is out of scope for this version. + +## Error Codes + +| Code | Description | +| ----- | -------------------------------------------- | +| 13030 | Invalid numeric range (lo_bound >= hi_bound) | +| 13031 | Digit signature verification failed | +| 13032 | Attested value outside representable range | +| 13033 | Payout calculation overflow | + +## Mint Info Setting + +The [NUT-06][06] `MintMethodSetting` indicates support for this feature: + +```json +{ + "CTF-numeric": { + "supported": true, + "max_digits": + } +} +``` + +- `supported`: Boolean indicating NUT-CTF-numeric support +- `max_digits`: Maximum number of oracle digits the mint supports (e.g., 20). Mints SHOULD reject condition registrations where the oracle announcement specifies more digits than `max_digits`. + +[00]: 00.md +[01]: 01.md +[02]: 02.md +[03]: 03.md +[04]: 04.md +[05]: 05.md +[06]: 06.md +[07]: 07.md +[08]: 08.md +[09]: 09.md +[10]: 10.md +[11]: 11.md +[12]: 12.md +[14]: 14.md +[CTF]: CTF.md +[CTF-split-merge]: CTF-split-merge.md diff --git a/CTF-settle.md b/CTF-settle.md new file mode 100644 index 00000000..238a3adc --- /dev/null +++ b/CTF-settle.md @@ -0,0 +1,167 @@ +# NUT-CTF-settle: Multi-Party Atomic Settlement + +`draft` + +`optional` + +depends on: NUT-CTF, NUT-CTF-split-merge, NUT-Exchange, NUT-Exchange-partial-fill, NUT-02, NUT-03, NUT-06, NUT-07, NUT-09, NUT-10, NUT-11, NUT-12 + +--- + +This NUT is an extension of both [NUT-Exchange][exchange] and [NUT-CTF-split-merge][CTF-split-merge]. The key observation: [NUT-Exchange][exchange] becomes more efficient when the mint is aware of CTF-specific characteristics (per-outcome conservation, conditional keysets, attestation cutoff), and [NUT-CTF-split-merge][CTF-split-merge] gains multi-party support by reusing [NUT-Exchange][exchange]'s `PAY_TO_UNLOCK` authorisation model. This NUT combines the two on a single endpoint. It also inherits [NUT-Exchange-partial-fill][partial-fill]'s pool-based **range orders**, so a participant may lock a fixed input and authorise a range of output bundles at a limit rate (see [Range orders](#range-orders-partial-fill)). + +Readers should be familiar with [NUT-Exchange][exchange] (`PAY_TO_UNLOCK`, `H_recv`, recovery, refund, the `participants` request shape, idempotency) and [NUT-CTF-split-merge][CTF-split-merge] (per-outcome conservation, coverage from keyset metadata, canonical collection encoding, fee model). This document specifies only the **differences**. + +## Request + +Same endpoint as [NUT-CTF-split-merge][CTF-split-merge]: + +```http +POST https://mint.host:3338/v1/ctf/convert +``` + +Multi-party mode is detected by the `participants` key (same request/response shape as [NUT-Exchange][exchange]): + +```json +{ + "condition_id": "", + "parent_collection_id": "", + "participants": [ + { "inputs": "", "outputs": "" }, + { "inputs": "", "outputs": "" } + ] +} +``` + +- `condition_id`: shared across all participants; 64-char hex (error 13021 if unknown). +- `parent_collection_id`: reserved for nested conditions; MUST be omitted or all-zero in v1. +- `participants`: ≥ 2 records. Every input MUST carry a `PAY_TO_UNLOCK` condition. Every proof's keyset is either the regular collateral keyset of the condition's unit, or a conditional keyset registered under this `condition_id`. +- A participant record MAY additionally carry `pool_manifest` and `pool_selection` to authorise a **range order** (partial fill); see [Range orders](#range-orders-partial-fill). A single request MAY mix standard-mode and pool-mode participants; the mint validates each participant under its own mode's rules (standard participants follow base [NUT-Exchange][exchange] rules 6–7 and 12; pool participants follow rules 6p–9p from [NUT-Exchange-partial-fill][partial-fill]). + +Response: `{signatures: [...]}` — one `BlindSignature` array per participant, same as [NUT-Exchange][exchange]. + +## Differences from NUT-Exchange + +| Aspect | NUT-Exchange | This NUT | +| -------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Conservation | per-class (rule 10) | per-outcome (from [CTF-split-merge]); **replaces** rule 10 | +| Keyset constraint | exactly two distinct keysets (rule 8) | collateral + conditional under one `condition_id`; **replaces** rule 8 | +| Condition tags | `offer_keyset`, `expiry`, `refund` (3 tags) | standard: identical (3 tags); pool (range order): + `rate_n`, `rate_d`, `min_receive`, `max_debit` | +| `H_recv` domain | `Cashu/PAY_TO_UNLOCK/recv` | `Cashu/ctf/convert/recv` (standard mode) | +| `request_digest` | `Cashu/exchange/request` over participant records only | `Cashu/ctf/convert/request` over `condition_id` + `parent_collection_id` + participant records | +| Refund digest domain | `Cashu/PAY_TO_UNLOCK/refund` | identical (unified) | +| Attestation cutoff | n/a | MUST reject after attestation; serialise with commit | +| Liability accounting | n/a | per-outcome `ΔL(o)` (see below) | +| Partial fill | pool mode (rules 6p–10p) | rules 6p, 7p, 9p and the per-participant clauses of 8p **inherited**; the request-global two-keyset clause of 8p and 10p **replaced** by rule 8 and per-outcome rule 10; manifest domain `Cashu/ctf/convert/manifest` | + +**Inherited from NUT-Exchange:** rules 1–7, 9, 11–12; `PAY_TO_UNLOCK` condition mechanism (3 required tags for standard participants; CTF does not use the base optional tags `alt_outputs`, `allow_change`, or `min_output_amount`; pool-mode range orders add `rate_n`, `rate_d`, `min_receive`, `max_debit` — see [Range orders](#range-orders-partial-fill)); `H_recv` computation (entry encoding identical, domain differs); recovery; refund mechanics (including witness-free preimage and unified refund domain); idempotency; coordinator-trust properties. + +**Replaced:** rule 8 (keyset constraint) and rule 10 (conservation) for all participants; for pool-mode participants additionally the request-global two-keyset clause of rule 8p and rule 10p — see [Range orders](#range-orders-partial-fill). + +## Differences from NUT-CTF-split-merge + +| Aspect | CTF-split-merge | This NUT | +| -------------------- | -------------------------- | ---------------------------------------------------------------------------------- | +| Participants | 1 (single-party) | ≥ 2 (multi-party) | +| Input conditions | not required | every input MUST carry `PAY_TO_UNLOCK` | +| `in(o)` / `out(o)` | single owner's bundle | summed across **all participants** | +| Keyset-active cutoff | all keysets MUST be active | **inherited** — takes precedence over NUT-Exchange's weaker "still-spendable" rule | + +Conservation rule, fee model, coverage from keyset metadata, and canonical collection encoding are inherited unchanged. + +## CTF-specific validation + +Standard participants inherit rules 1–7, 9, 11–12 from [NUT-Exchange][exchange] and reject the optional `alt_outputs`, `allow_change`, and `min_output_amount` tags. Pool-mode (range-order) participants instead inherit rules 6p, 7p, 9p and the per-participant clauses of 8p from [NUT-Exchange-partial-fill][partial-fill] (see [Range orders](#range-orders-partial-fill)). For all participants, rules 8 and 10 are **replaced**: + +- **Rule 8 (replaced):** Every input/output keyset MUST be active. Collateral keysets MUST be regular with unit equal to the condition's collateral unit. Conditional keysets MUST be registered under this `condition_id`. Either class is valid on either side of a convert. +- **Rule 10 (replaced):** Per-outcome conservation with `in(o)` / `out(o)` summed across all participants: `out(o) == in(o) − F` for every `o ∈ Ω` (error 13041). Since collateral covers every outcome, an uncovered outcome (no participant receives tokens covering it) would force `out(o) = 0 = in(o) − F`, making the fee absorb all collateral — a degenerate settlement. Multi-party convert therefore requires at least one output to cover each outcome. (Single-party convert inherits split-merge's rule, which permits `out(o) == 0` when the fee legitimately consumes one outcome's value.) + +Additional multi-party rules: + +1. `parent_collection_id` MUST be omitted or all-zero. Advertised limits (`max_participants`, `max_inputs`, `max_outputs`, `max_request_bytes`, `max_pool_entries`) MUST be respected. +2. **No attestation recorded** for `condition_id` (error 13042). This check MUST serialise atomically with the convert commit — see [Attestation atomicity](#attestation-atomicity). +3. Every input carries a canonical `PAY_TO_UNLOCK` condition. **Standard participants** use the 3 required tags (`offer_keyset`, `expiry`, `refund`) and optionally the inherited `coordinator_pubkey`; all inputs in one participant record share the same `H_recv`, `expiry`, `refund`, and `offer_keyset`, with unique per-proof nonces, and each participant's outputs hash to that `H_recv`. **Pool-mode (range-order) participants** add the 4 pool tags (`rate_n`, `rate_d`, `min_receive`, `max_debit`) and set `data = H_manifest` over the domain `Cashu/ctf/convert/manifest`; `coordinator_pubkey` is also permitted. See [Range orders](#range-orders-partial-fill). In both modes the `offer_keyset` MUST match each proof's actual keyset (`Proof.id`). +4. Reject if any involved keyset has `input_fee_ppk == 0` unless admission control is in force _(without `F`, anyone can submit unlimited convert requests at no cost — a free-DoS vector)_. +5. **`expiry` MUST precede** the earliest `final_expiry` among all conditional keysets registered under this `condition_id`. If any keyset lacks `final_expiry`, the mint MUST use its advertised `max_expiry_seconds` as the effective bound. Without this, a proof whose `expiry` falls after all same-outcome keysets are deactivated cannot be refunded or redeemed — stranding value permanently. _Known limitation: `expiry` is inside the blinded `Proof.secret` ([NUT-10][10]) and invisible to the mint at [NUT-03][03] swap time; the mint enforces this check at settlement, not creation. Wallets MUST set `expiry` conservatively._ + +## `request_digest` (optional) + +Optional feature inherited from [NUT-Exchange][exchange] (advertised via `idempotent_retries` in [NUT-06][06] info). Enables idempotent retries: if a client's request commits but the response is lost, the client retries the identical request and the mint returns the cached response. Without it, clients fall back to [NUT-09][09] recovery, same as [NUT-03][03] swap. + +The CTF digest (when supported) commits to **all** semantic top-level fields: + +``` +req_canonical = bytes(condition_id, 32) || bytes(parent_collection_id, 32) || participant[0]_canonical || ... || participant[n-1]_canonical +request_digest = tagged_hash("Cashu/ctf/convert/request", req_canonical) +coordinator_digest = tagged_hash("Cashu/ctf/convert/coordinator", req_canonical) +``` + +`condition_id` and `parent_collection_id` are each exactly 64 lowercase hex chars decoded to 32 raw bytes; an omitted `parent_collection_id` is 32 zero bytes (v1's only permitted explicit value). `participant_canonical` is mode-dependent: standard participants use `JCS({"inputs": ..., "outputs": ...})`; pool-mode participants use `JCS({"inputs": ..., "outputs": ..., "pool_manifest": ...}) || hex_decode(pool_selection)` (as in [NUT-Exchange-partial-fill][partial-fill]). `coordinator_sig` is excluded from `req_canonical`. + +**Coordinator authentication** is inherited from [NUT-Exchange][exchange]: if any input carries `coordinator_pubkey`, the request must carry `coordinator_sig` — a BIP-340 signature valid under the bound key over `coordinator_digest`. Version 1 permits one key per request; `coordinator_sig` must be absent otherwise and is verified before any idempotency-cache hit (error 15015 on failure). `coordinator_pubkey` is permitted in standard and pool mode. + +## Range orders (partial fill) + +CTF convert supports partial fills via the pool-based **range orders** defined in [NUT-Exchange-partial-fill][partial-fill]. A participant locks a fixed input set (all proofs sharing one `H_manifest`) and authorises a **range** of output bundles at a limit rate; the coordinator selects the subset that matches the agreed price, and the mint signs only the selected entries. This replaces the earlier micro-lot input-subset pattern. + +A pool-mode participant carries the `pool_manifest` and `pool_selection` fields from [NUT-Exchange-partial-fill][partial-fill], and its inputs' `PAY_TO_UNLOCK` condition carries the pool tags `rate_n`, `rate_d`, `min_receive`, `max_debit` with `data = H_manifest`. The base optional tags `alt_outputs`, `allow_change`, and `min_output_amount` MUST be absent. A single `/v1/ctf/convert` MAY mix standard-mode and pool-mode participants; the mint validates each participant under its own mode's rules. + +**Inherited from [NUT-Exchange-partial-fill][partial-fill]:** rule 6p (manifest hash), 7p (selection consistency), 9p (policy: the rate covenant `receive_total × rate_d ≥ debit_total × rate_n`, plus `min_receive` and `max_debit`, in face-value minor units), and the **per-participant** clauses of rule 8p (receive entries share one `id`, change entries use the `offer_keyset`, both roles present). The **request-global** exactly-two-keysets clause of rule 8p is **replaced** by this NUT's rule 8 (collateral plus one or more conditional keysets under one `condition_id`), because a complementary match legitimately unions {collateral, YES, NO}. Rule 10p (per-class conservation) is **replaced** by this NUT's per-outcome rule 10. + +**Endpoint binding (CTF rule 6p):** the manifest is hashed under the CTF-specific domain `Cashu/ctf/convert/manifest`, not the base `Cashu/PAY_TO_UNLOCK/manifest` domain. This mirrors the `H_recv` domain override and prevents a pool authorization prepared for `/v1/exchange` from being replayed at `/v1/ctf/convert`, where a different conservation and fee model applies. The mint MUST reject a manifest committed under any other domain. + +**Limit-price encoding:** the rule 9p covenant is inherited unchanged. The tags are unsigned integers, so a limit price MUST be expressed as a reduced fraction over face-value minor units. For a **buy** at price `p = a/b` collateral units per conditional unit (debit ÷ receive ≤ `a/b`), the encoding is `rate_n = b`, `rate_d = a`, yielding `receive_total × a ≥ debit_total × b`. A **sell** at the same price reverses to `rate_n = a`, `rate_d = b`. Fractional prices are therefore representable (e.g. `0.60 = 3/5` → buy `rate_n = 5, rate_d = 3`). + +**Conservation:** the pool selection sets each participant's input and output contribution; per-outcome conservation `out(o) == in(o) − F` is then enforced across **all** participants as usual (rule 10). The rate covenant (9p) is a per-participant limit-price check and is independent of global conservation. Pool-mode change is conservation-safe for the same reason all change is: a collateral input `I` contributes `I` to `in(o)` for every outcome `o`, and a collateral change output `C` contributes `C` to `out(o)` for every `o`, so their net `−(I − C) = −debit_total` is uniform across outcomes and cannot inflate any single `out(o)`; the binding constraint is the per-outcome equality itself (`max_debit` and the rate covenant bound authorization and price, not conservation). + +**Input consolidation:** pool mode locks a fixed input set and charges `input_fee_ppk` on every locked input proof. A wallet SHOULD consolidate the maximum order into a single (or minimal-count) input proof before authorising a range order, so that per-input fees do not dominate a small fill or push it below the owner's rate. + +Recovery, refund, and the discard-safety rule (an owner MUST NOT discard any unselected entry's secret or blinding factor before a definitive settlement outcome) are inherited unchanged from [NUT-Exchange-partial-fill][partial-fill]. + +## Attestation atomicity + +_[Not in NUT-Exchange.]_ After the oracle attests the winning outcome, the condition is resolved. If a convert commits in the same instant — between the attestation write and keyset deactivation — the mint issues new conditional tokens for an already-resolved condition. Those tokens are worthless to holders but still backed by collateral, creating phantom liabilities. + +To prevent this race, every `/v1/ctf/convert` commit (single-party and multi-party) MUST take the same row-level lock on the condition record as the [NUT-CTF][CTF] attestation write. Either the attestation records first (convert is rejected — condition resolved) or the convert commits first (attestation waits — condition still open). No race is possible. + +## Liability accounting + +_[Not in NUT-Exchange.]_ + +**In plain terms:** the mint doesn't gamble. It takes in collateral and hands out conditional tokens, but the books always balance — for any outcome the oracle might pick, the mint holds enough collateral to redeem every outstanding token. The fee it keeps is the same regardless of which outcome wins. + +**Formally:** the reserve `R` (total collateral held) and per-outcome liability `L(o)` are defined by [NUT-CTF-split-merge][CTF-split-merge]'s Issuance Invariant. Every convert preserves `R ≥ L(o)` because: + +- `ΔR = collateral_in − collateral_out` (net collateral locked by this convert). +- `ΔL(o) = out_conditional(o) − in_conditional(o)` (change in conditional liability on outcome `o`). +- Per-outcome conservation rearranges to `ΔL(o) = ΔR − F` for every `o`. +- Since `ΔR − ΔL(o) = F ≥ 0`, the solvency margin `R − L(o)` grows by `F` on every outcome. + +## Polymarket match types + +A single convert may mix `MINT` (buy + buy on complementary outcomes), `MERGE` (sell + sell), and `COMPLEMENTARY` (buy + sell same token) matches, provided per-outcome conservation holds. All three require atomic settlement via `/v1/ctf/convert`; [NUT-03][03] cannot mix regular and conditional keysets ([NUT-CTF][CTF]). + +## Mint info + +Same [NUT-06][06] setting as [NUT-CTF-split-merge][CTF-split-merge], extended with `max_participants` (≥ 2), `max_expiry_seconds` (same semantics as [NUT-Exchange][exchange]: bounds `PAY_TO_UNLOCK` condition lifetime at [NUT-03][03] swap time), and the pool-mode fields from [NUT-Exchange-partial-fill][partial-fill]: `partial_fill` (capability bool) and `max_pool_entries` (per-participant manifest-entry cap). `max_request_bytes` bounds the complete serialized request, including every pool-mode participant's `pool_manifest` and `pool_selection`. + +## FAQ + +**Why a separate NUT?** +Single-party convert ([CTF-split-merge]) does not need `PAY_TO_UNLOCK` or any [NUT-Exchange][exchange] concept. This NUT adds the multi-party layer for readers who already know both. + +**Does the mint enforce fair pricing?** +The mint enforces each participant's own limit price (the rule 9p rate covenant for range orders) but does **not** enforce best execution or fair matching — price discovery and match selection are off-mint. Per-outcome conservation (rule 10) is the only structural constraint on the aggregate. + +## References + +- [NUT-CTF][CTF] · [NUT-CTF-split-merge][CTF-split-merge] · [NUT-Exchange][exchange] · [NUT-Exchange-partial-fill][partial-fill] +- [Polymarket CTF Exchange](https://github.com/Polymarket/ctf-exchange-v2) + +[02]: 02.md +[03]: 03.md +[06]: 06.md +[CTF]: CTF.md +[CTF-split-merge]: CTF-split-merge.md +[exchange]: https://github.com/cashubtc/nuts/pull/410 +[partial-fill]: https://github.com/cashubtc/nuts/pull/410 diff --git a/CTF-split-merge.md b/CTF-split-merge.md new file mode 100644 index 00000000..e2b32ac7 --- /dev/null +++ b/CTF-split-merge.md @@ -0,0 +1,236 @@ +# NUT-CTF-split-merge: Conditional Token Convert (Split, Merge, Recombine) + +`optional` + +`depends on: NUT-CTF` + +--- + +This NUT defines a single **convert** operation for conditional tokens ([NUT-CTF][CTF]): any payoff-preserving rebalance of root-level conditional positions within one condition. Convert subsumes four operations: + +- **Split**: deposit collateral, receive a complete set of conditional tokens. +- **Merge**: surrender a complete set of conditional tokens, recover collateral. +- **Recombine**: regroup outcome collections (e.g. `A` + `B|C` → `A|B|C`) without touching collateral. +- **Conversion** (negative-risk style): cross the collateral boundary mid-bundle (e.g. `A|B|C` + `B|C|D` → collateral + `B|C`). + +Inspired by the [Gnosis Conditional Token Framework](https://conditional-tokens.readthedocs.io/en/latest/) ([contracts](https://github.com/gnosis/conditional-tokens-contracts)), whose `splitPosition`/`mergePositions` operate on arbitrary partitions of any outcome collection. [Polymarket](https://github.com/Polymarket/ctf-exchange-v2)'s negative-risk position conversion is the special case where collateral appears mid-bundle; convert provides it natively with no separate adaptor. + +Caution: Applications must verify that the mint supports both NUT-CTF and NUT-CTF-split-merge via the [info][06] endpoint. + +## Overview + +``` + Register Convert (split) Trade Convert (merge) Attest Redeem +Wallet ────────────► Mint User ──────────────► Conditional ◄───────────► User ──────────► ... Oracle ──────► Winner ─────► + cond. info creates collateral Tokens NUT-03 complete set Signs redeem_outcome + keysets + fee F (per-OC → collateral Outcome → Regular + keysets) (− fee F) Keyset +``` + +1. **Register**: Condition registered via [NUT-CTF][CTF] with requested outcome collections to create conditional keysets. +2. **Convert**: `Alice` submits an input bundle of proofs and an output bundle of blinded messages; the mint signs the outputs iff the operation is payoff-preserving after a flat fee (see [Conservation Rule](#conservation-rule)). +3. **Trade**: Standard [NUT-03][03] swaps within the same conditional keyset. +4. **Attest**: Oracle signs winning outcome. +5. **Redeem**: Winners use `POST /v1/redeem_outcome` ([NUT-CTF][CTF]). + +## Payoff-Vector Model + +For a condition with outcome-atom set **Ω**, a token for outcome collection `S` with amount `a` is the **payoff vector** that pays `a` on each outcome in `cover(S)` and `0` elsewhere. **Collateral** — plain ecash on a regular keyset — is the **all-ones** vector: it pays on every outcome in Ω. + +A convert is valid iff the input and output payoff vectors are equal after deducting a flat fee on every outcome. This single condition generalises split, merge, recombine, and conversion. + +## Convert Operation + +```http +POST https://mint.host:3338/v1/ctf/convert +``` + +**Request** of `Alice`: + +```json +{ + "condition_id": , + "parent_collection_id": , + "inputs": { + "": , + ... + }, + "outputs": { + "": , + ... + } +} +``` + +- `condition_id`: 64-char hex (error 13021 if unknown). +- `parent_collection_id` (optional): reserved for future nested conditions. It MUST be omitted or the all-zero 32-byte hex string; any non-zero value MUST be rejected. +- `inputs` / `outputs`: objects mapping each map key to an array of `Proof` / `BlindedMessage`. A map key is either a **canonical outcome collection string** (see [Canonical Collection Encoding](#canonical-collection-encoding)) or the reserved key `"*"` denoting **collateral**: + - `"*"` uses the **regular keyset** of the collateral unit. +- Duplicate JSON member names within `inputs` or `outputs` MUST be rejected. + +**Response** of `Bob`: + +```json +{ + "signatures": { + "": , + ... + } +} +``` + +Each `signatures` array preserves the order of the corresponding `outputs` array. + +```bash +curl -X POST https://mint.host:3338/v1/ctf/convert \ + -H "Content-Type: application/json" \ + -d '{"condition_id":"a1b2c3...","inputs":{"*":[...],"A":[...],"B|C":[...]},"outputs":{"A|B|C":[...]}}' +``` + +### Special Cases + +| Operation | `inputs` | `outputs` | +| --------- | -------- | --------- | +| Split | `{"*": [...]}` | per-outcome-collection map covering every outcome | +| Merge | per-outcome-collection map covering every outcome | `{"*": [...]}` | +| Recombine | conditional collections only (no `"*"`) | conditional collections only (no `"*"`) | +| Conversion | conditional ± `"*"` | conditional ± `"*"` | + +## Conservation Rule + +Let: + +- For each entry, let its **cover** be `Ω` if its keyset is the collateral keyset (the `"*"` entry), otherwise the outcome set of the canonical collection recorded in that keyset's **metadata** (see [Coverage From Keyset Metadata](#coverage-from-keyset-metadata)). The map key is validated to equal that canonical collection but is never itself used to compute coverage. +- `in(o)` = the sum of input amounts whose entry covers `o`, for each `o ∈ Ω`. +- `out(o)` = the sum of output amounts whose entry covers `o`, for each `o ∈ Ω`. +- `F` = `ceil( Σ input_fee_ppk / 1000 )` over **all** input proofs (both `"*"` and conditional), per [NUT-02][02]. This flat fee is denominated in the collateral unit. + +`Bob` MUST enforce, for **every** `o ∈ Ω`: + +``` +out(o) == in(o) − F +``` + +and MUST reject the request unless `in(o) ≥ F` for every `o ∈ Ω` (error 13041). + +`in(o)` and `out(o)` are computed by expanding each token's **registered** canonical collection to its outcome set and summing overlaps **per outcome** (collections on the same side may overlap; e.g. outputs `A|B` and `B|C` both contribute to outcome `B`). + +This rule: + +- preserves the payoff vector exactly except for the flat fee `F`, which the mint retains on **every** outcome — so the fee is real collateral the mint keeps regardless of which outcome the oracle attests (non-contingent); +- requires the input bundle to cover the fee on every outcome. For a floor-0 recombine the user includes `F` of collateral under `"*"` to satisfy `in(o) ≥ F` everywhere; +- reduces to the classical split (`each outcome collection total == sum(inputs) − F`) and merge. + +### Examples + +**Split** (binary market, 100 sats collateral, `F = 0`): + +- `inputs["*"]`: 100 sats (regular keyset `009a1f293253e41e`) +- `outputs["YES"]`: 100 sats (conditional keyset `00abc123def456`) +- `outputs["NO"]`: 100 sats (conditional keyset `00def789abc012`) +- `in(YES)=in(NO)=100`; `out(YES)=out(NO)=100`. ✓ + +**Recombine** (`Ω = {A,B,C,D}`, `F = 1`): + +- `inputs`: `{"*": 1, "A": 1, "B|C": 1}` → `in = (A:2, B:2, C:2, D:1)` +- `outputs`: `{"A|B|C": 1}` → `out = (A:1, B:1, C:1, D:0)` +- `out(o) == in(o) − 1` for every `o`. ✓ The mint keeps 1 on every outcome. + +**Conversion** (negative-risk style, `Ω = {A,B,C,D}`, `F = 1`): + +- `inputs`: `{"A|B|C": 100, "B|C|D": 100}` → `in = (A:100, B:200, C:200, D:100)` +- `outputs`: `{"*": 99, "B|C": 100}` → `out = (A:99, B:199, C:199, D:99)` +- `out(o) == in(o) − 1` for every `o`. ✓ The holder withdraws 99 sats of spendable collateral (the bundle's guaranteed floor minus the fee) plus a `B|C` position, with no oracle witness. + +## Coverage From Keyset Metadata + +A map key is untrusted text and MUST NOT be used to compute coverage. For every non-`"*"` entry, `Bob` MUST resolve each `Proof`/`BlindedMessage` keyset `id` to its **stored keyset metadata** (error 12001 if unknown) and verify that the metadata matches this `condition_id`, unit, and a canonical outcome collection. The request map key MUST equal that canonical collection (error 13041 otherwise). `cover()` is derived from the metadata, never from the request string. Every `"*"` proof/message MUST use the regular collateral keyset defined above (error 13017 if a regular/conditional keyset is placed under the wrong key). + +## Canonical Collection Encoding + +Outcome collections have a single canonical string form, used both as map keys here and in [NUT-CTF][CTF] keyset-ID derivation, so that `A|B` and `B|A` denote one keyset: + +- Outcome names are NFC-normalised. +- Components are ordered by **condition type**: + - **enum** ([NUT-CTF][CTF]): by the outcome's index in `announcements[0].oracle_event.event_descriptor.outcomes` — the same announcement that defines `outcome_count` in the [condition ID][CTF]. All announcements for a condition MUST share the same ordered outcome list, so this index is unambiguous. + - **numeric** ([NUT-CTF-numeric][CTF-numeric]): the fixed synthetic order `["HI", "LO"]`, independent of digit-oracle fields. +- Components are joined by `|`. A literal `|` in an outcome name is escaped `\|`; a literal `\` is escaped `\\`. + +`Bob` MUST reject (error 13041): non-canonical ordering or encoding, duplicate outcomes, empty components, unknown outcomes, malformed escapes, the reserved token `"*"`, and any **full-set** cover. + +## Mint Behavior + +`Bob`: + +1. Looks up the condition (error 13021 if not found). Rejects if an attestation has been recorded for the condition (error 13042), or if any involved keyset is inactive (error 12002). These two conditions are the only cutoffs: maturity is **not** a cutoff, and there is no separate status check — a `violation` status implies a recorded (conflicting) attestation, and an `expired` condition implies inactive keysets, so both are already covered. Convert is allowed until an attestation is recorded, provided the keysets are still active. +2. Rejects any non-zero `parent_collection_id`. For each entry, resolves keyset metadata and validates `condition_id`, unit, canonical collection == map key, and `"*"` ↔ collateral keyset placement (see [Coverage From Keyset Metadata](#coverage-from-keyset-metadata)). +3. Rejects if any two input proofs across all entries share a secret (error 11007), or if any input or output amount is not positive, or if there is no input, or if `outputs` is empty. +4. Verifies all input proofs are valid and unspent (errors 10001 / 11001). +5. Computes `in(o)`, `out(o)`, and `F`, and enforces the [Conservation Rule](#conservation-rule) for every `o ∈ Ω` (error 13041). +6. Atomically marks all input proofs spent, signs all output blinded messages, and persists the signatures in a **single transaction**. No output is signed until every input is verified and reserved. + +## Security Considerations + +- **Atomicity**: Convert MUST be atomic — all signatures or none. Duplicate-detection, spent-marking, and signature persistence occur in one transaction. +- **Solvency**: Let `R` be the real collateral the mint holds and `L(o)` the outstanding conditional liability at outcome `o`. The conservation rule forces the per-outcome change `ΔL(o)` to equal the real collateral change `ΔR` minus the retained fee, for every `o`. Since `R ≥ L(o)` holds at inception (every conditional token is backed at face — see [Issuance Invariant](#issuance-invariant)), convert preserves `R ≥ L(o)` for all `o`. No oracle witness is required: a payoff-preserving conversion cancels all outcome risk. +- **Non-contingent fee / DoS**: Because `F` is retained on every outcome, each convert costs the requester real collateral regardless of the eventual outcome, bounding free operations. Keysets with `input_fee_ppk == 0` make convert free and MUST be rejected for convert (error 13041) unless separate admission control is in force. Mints MAY additionally require [NUT-21][21]/[NUT-22][22] authentication, rate limits, or per-request caps on output count / distinct collections / total amount as defense-in-depth. +- **Privacy**: As with any multi-input operation, the mint observes that all inputs and outputs of one convert are co-owned; a consolidating convert can link previously unlinked tokens. +- **Root-only scope**: This version does not define nested/combinatorial condition construction. Mints MUST reject non-zero `parent_collection_id` values. + +### Issuance Invariant + +Every conditional token, by any issuance path, MUST be backed by locked collateral at least equal to its face amount. `split`/`convert` satisfy this; a face-value [NUT-04][04] mint into a conditional keyset would too. Sub-face or market-priced issuance into a conditional keyset is forbidden — that is a trading operation, not minting, and would break solvency. + +## Full-Set and Reserved Key Rules + +To keep `"*"` unambiguous, the following [NUT-CTF][CTF] outcome collection rules apply: + +- No single outcome collection may cover all of Ω. The full-set payoff vector is represented only as collateral, never as a conditional keyset. +- `"*"` is reserved: it is invalid as an outcome name and as an outcome collection string. + +## Error Codes + +| Code | Description | +| ----- | -------------------------------------------- | +| 11007 | Duplicate inputs provided | +| 12001 | Keyset is not known | +| 12002 | Keyset is inactive | +| 13021 | Condition not found | +| 13041 | Convert payoff/fee violation | +| 13042 | Convert not permitted for this condition | +| 13043 | Full-set outcome collection | + +## Mint Info Setting + +The [NUT-06][06] `MintMethodSetting`: + +```json +{ + "CTF-split-merge": { + "supported": true + } +} +``` + +- `supported`: Boolean indicating support for the convert operation. + +For a complete end-to-end example including registration, convert, trading, and redemption, see the [supplementary material](suppl/CTF-split-merge.md). + +[00]: 00.md +[01]: 01.md +[02]: 02.md +[03]: 03.md +[04]: 04.md +[05]: 05.md +[06]: 06.md +[07]: 07.md +[08]: 08.md +[09]: 09.md +[10]: 10.md +[11]: 11.md +[12]: 12.md +[14]: 14.md +[21]: 21.md +[22]: 22.md +[CTF]: CTF.md +[CTF-numeric]: CTF-numeric.md diff --git a/CTF.md b/CTF.md new file mode 100644 index 00000000..03aa05c3 --- /dev/null +++ b/CTF.md @@ -0,0 +1,534 @@ +# NUT-CTF: Conditional Token Framework + +`optional` + +`depends on: NUT-02, NUT-06` + +--- + +This NUT defines conditional tokens and conditional keysets for oracle-attested events. + +A **conditional token** is a regular Cashu token ([NUT-00][00]) signed under a conditional keyset. It can be transferred and swapped like any other Cashu token, with one additional ability: it can be redeemed for regular ecash via `POST /v1/redeem_outcome` by providing a DLC oracle's attestation signature as a witness. + +A **conditional keyset** is a per-outcome-collection signing keyset ([NUT-02][02]) that the mint creates during condition registration. Each outcome collection gets a unique keyset with different signing keys. + +The oracle signature scheme is compatible with the [DLC specification](https://github.com/discreetlogcontracts/dlcspecs/blob/master/Oracle.md), allowing Cashu mints to leverage existing DLC oracle infrastructure. + +Caution: Applications that rely on oracle resolution must verify that the oracle is trustworthy and check via the mint's [info][06] endpoint that NUT-CTF is supported. + +**Related specifications:** [NUT-CTF-split-merge][CTF-split-merge] defines the `convert` operation — payoff-preserving rebalancing of conditional positions (split, merge, recombine, and conversion). [NUT-CTF-numeric][CTF-numeric] extends this framework with numeric outcome conditions. + +## Terminology + +- **Condition**: A question with defined outcomes, resolved by an oracle. Identified by a `condition_id`. Equivalent to "condition" in the [Gnosis Conditional Token Framework](https://conditional-tokens.readthedocs.io/en/latest/) ([contracts](https://github.com/gnosis/conditional-tokens-contracts)), which this spec adapts to Cashu. [Polymarket](https://github.com/Polymarket/ctf-exchange-v2) is a large-scale production deployment of the same model on Ethereum. +- **Outcome**: A single atomic result that an oracle attests to (e.g., `"YES"`, `"ALICE"`). +- **Outcome collection**: A non-empty, non-full subset of outcomes (e.g., `"YES"`, `"ALICE|BOB"`). Each requested collection gets its own conditional keyset. Redeemable if the oracle attests to ANY outcome it contains. +- **Condition ID** (`condition_id`): 32-byte tagged hash uniquely identifying a condition. Partition-independent. See [Condition ID](#condition-id). +- **Outcome collection ID** (`outcome_collection_id`): 32-byte x-only public key uniquely identifying an outcome collection within a condition. See [Outcome Collection ID](#outcome-collection-id). + +## Outcome Collections + +Outcome collections allow tokens to represent one or more outcomes. An outcome collection is either a single outcome or an OR-combination joined by `|` (e.g., `"ALICE|BOB"` = "Alice or Bob wins"). If an outcome name contains `|`, it MUST be escaped as `\|`; a literal `\` is escaped as `\\`. The token `"*"` is reserved (it denotes collateral in [NUT-CTF-split-merge][CTF-split-merge]) and is invalid as an outcome name and as an outcome collection string. + +Outcome collection strings have a single canonical form so that, e.g., `ALICE|BOB` and `BOB|ALICE` derive the same keyset: outcome names are NFC-normalised and components are ordered by the outcome's index in the oracle announcement (for numeric conditions, the fixed order `["HI", "LO"]`). See [NUT-CTF-split-merge][CTF-split-merge] for the full encoding rules. + +### Outcome Collection Keyset Rules + +Condition registration MAY request any set of non-empty, non-full outcome collections: + +1. Each requested outcome collection MUST be canonical. +2. Requested outcome collections MAY overlap. +3. Duplicate canonical outcome collections in one request MUST be rejected. +4. No single outcome collection may cover all outcomes (error 13043). The full-outcome-set payoff vector is represented only as collateral, never as a conditional keyset — this keeps the reserved collateral key `"*"` unambiguous in [NUT-CTF-split-merge][CTF-split-merge]. + +Valid requested keysets for outcomes `["A", "B", "C"]`: + +- `["A", "B", "C"]` (individual outcomes) +- `["A", "B", "C", "A|B", "B|C", "A|C"]` (all non-full collections) + +Invalid: `["A|B|C"]` (full-set), `["A", "A"]` (duplicate), `["A", "D"]` (unknown outcome). + +## Conditional Keysets + +Each requested outcome collection gets a unique keyset created during [condition registration](#register-condition). These use the same mechanism as regular keysets ([NUT-02][02]). + +**Properties:** + +- **Signing keys**: Unique keys derived by the mint from condition parameters +- **Unit**: Matches the collateral unit (e.g., `"sat"`) +- **Discovery**: Via `GET /v1/conditional_keysets` (see [Conditional Keyset Discovery](#conditional-keyset-discovery)) +- **Active flag**: `true` during condition lifetime, `false` after resolution + vesting period +- **Expiry**: MAY use `final_expiry` corresponding to vesting period end + +### Keyset ID Derivation + +Conditional keyset IDs extend [NUT-02 V2 derivation][02] by appending condition-specific data: + +``` + + "|condition_id:" + condition_id_hex + "|outcome_collection_id:" + outcome_collection_id_hex +``` + +The version byte remains `01`. In Python: + +```python +keyset_id_bytes += f"|condition_id:{condition_id}".encode("utf-8") +keyset_id_bytes += f"|outcome_collection_id:{outcome_collection_id}".encode("utf-8") +``` + +Where `condition_id` and `outcome_collection_id` are 64-character hex strings. This binding allows wallets to independently verify a keyset's condition and outcome collection. See [supplementary material](suppl/CTF.md#keyset-id-derivation-rationale) for rationale. + +## Token Lifecycle + +``` +Issuance: Mint issues conditional tokens (via condition registration + keyset-specific minting) +Trading: Conditional keyset -> same/rotated conditional keyset (NUT-03 swap, same outcome_collection_id, no witness) +Redemption: Conditional keyset -> regular keyset (POST /v1/redeem_outcome + oracle witness) +``` + +- **Issuance**: The mint creates conditional keysets during [condition registration](#register-condition). Users obtain conditional tokens through [NUT-CTF-split-merge][CTF-split-merge] convert (split) operations or other minting mechanisms. Every conditional token, by any issuance path, MUST be backed by locked collateral at least equal to its face amount; sub-face or market-priced issuance into a conditional keyset is forbidden (see [NUT-CTF-split-merge][CTF-split-merge] Issuance Invariant). +- **Trading**: Standard [NUT-03][03] swap. All conditional keysets in a swap MUST share the same `outcome_collection_id`. No oracle witness required. +- **Redemption**: After oracle attestation, winners submit tokens to `POST /v1/redeem_outcome` with oracle signatures in `Proof.witness`. + +## Condition ID + +A condition is uniquely identified by a `condition_id` using a BIP-340 tagged hash: + +``` +condition_id = tagged_hash("Cashu_condition_id", sorted_oracle_pubkeys || event_id || outcome_count) +``` + +Where: + +- `tagged_hash(tag, msg) = SHA256(SHA256(tag) || SHA256(tag) || msg)` — [BIP-340 tagged hash](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) +- `sorted_oracle_pubkeys`: 32-byte x-only public keys, sorted lexicographically, concatenated. Derived from `announcements[].oracle_public_key`. +- `event_id`: UTF-8 encoded event identifier. Derived from `announcements[0].oracle_event.event_id`. All announcements MUST share the same `event_id`. +- `outcome_count`: 1-byte unsigned integer. Derived from `len(announcements[0].oracle_event.event_descriptor.outcomes)`. All announcements MUST share the same ordered outcome list; that order (from `announcements[0]`) is the canonical outcome index used by outcome-collection encoding. + +The `condition_id` is independent of requested keysets — the same oracle event always produces the same ID regardless of which outcome collections are requested. + +> **Note:** [NUT-CTF-numeric][CTF-numeric] extends this formula with additional parameters for numeric conditions. + +## Oracle Announcement Format + +Oracle announcements MUST use the TLV format defined in the [DLC specification](https://github.com/discreetlogcontracts/dlcspecs/blob/master/Messaging.md#the-oracle_announcement-type) (`oracle_announcement`, TLV type 55332). In API bodies, announcements are hex-encoded TLV byte strings. + +## Oracle Communication + +Oracle announcements and attestations use the [DLC specification](https://github.com/discreetlogcontracts/dlcspecs/blob/master/Oracle.md) format: + +- **Signing algorithm**: BIP 340 Schnorr signatures with tagged hash `"DLC/oracle/attestation/v0"` +- **Announcement format**: [DLC oracle announcement](https://github.com/discreetlogcontracts/dlcspecs/blob/master/Messaging.md#the-oracle_announcement-type) (TLV type 55332) +- **Event descriptors**: Enum event descriptors with UTF-8 NFC-normalized outcome strings + +The transport for discovering oracle announcements from oracles is unspecified. [NIP-88](https://github.com/nostr-protocol/nips/pull/1681) is one option. See [supplementary material](suppl/CTF.md#oracle-communication-notes) for additional notes. + +## Condition Registry + +Conditions are registered via `POST /v1/conditions` before any operations on conditional tokens. Conditional keysets are created during condition registration. + +### Condition Info + +```json +{ + "condition_id": , + "threshold": , + "tags": , + "announcements": , + "collateral": , + "registered_at": , + "keysets": { + "": , + ... + }, + "attestation": { + "status": , + "winning_outcome": , + "attested_at": + } +} +``` + +- `condition_id`: 64-character hex string (see [Condition ID](#condition-id)) +- `threshold`: Minimum oracles required for attestation (default: 1) +- `tags`: [NIP-88][NIP-88] tag array (e.g., `[["description", "..."], ["n", "BTC"]]`). Display-only metadata; does NOT affect `condition_id`. +- `announcements`: Hex-encoded oracle announcement TLV bytes +- `collateral` (optional): Condition's collateral currency unit, echoing the value accepted during registration. Clients use this to verify unit consistency before building markets on a condition. +- `registered_at`: Unix timestamp of registration +- `keysets`: Flat map of all root-level requested outcome collections to keyset IDs. Use `GET /v1/conditional_keysets` for full keyset metadata. +- `attestation` (optional, omitted if no attestation): + - `status`: `"pending"` | `"attested"` | `"expired"` | `"violation"` + - `winning_outcome`: Attested outcome string (`null` if pending) + - `attested_at`: Unix timestamp (`null` if pending) + +### Get Conditions + +```http +GET https://mint.host:3338/v1/conditions +``` + +**Query parameters:** + +- `since` (optional): Unix timestamp. Returns conditions with `registered_at >= since`. Wallets SHOULD first fetch all, then use `since` for incremental sync. +- `limit` (optional): Maximum conditions per response. +- `status` (optional, repeatable): Filter by `attestation.status`. E.g., `?status=pending&status=attested`. Conditions without `attestation` are treated as `pending`. + +Mints MUST return results ordered by `registered_at` ascending. Clients paginate by setting `since` to the last `registered_at` received and MUST deduplicate by `condition_id`. See [supplementary material](suppl/CTF.md#qa-design-decisions) for pagination rationale. + +**Response** of `Bob`: + +```json +{ + "conditions": +} +``` + +```bash +curl -X GET https://mint.host:3338/v1/conditions?status=pending&status=attested&limit=50 +``` + +### Get Condition + +```http +GET https://mint.host:3338/v1/conditions/{condition_id} +``` + +**Response** of `Bob`: + +```json +{ + "condition": +} +``` + +### Register Condition + +```http +POST https://mint.host:3338/v1/conditions +``` + +Registers a new condition and creates requested conditional keysets. + +**Request** of `Alice`: + +```json +{ + "threshold": , + "tags": , + "announcements": , + "collateral": , + "outcome_collections": , + "fee": , + "outputs": +} +``` + +- `threshold`: Minimum oracles required (default: 1) +- `tags`: [NIP-88][NIP-88] tag array +- `announcements`: Hex-encoded oracle announcement TLV bytes +- `collateral`: Unit string for root conditions (e.g., `"sat"`). REQUIRED if `outcome_collections` is present or if the mint's default keyset creation rule creates keysets. The unit SHOULD be advertised in `registration_fees`; mints SHOULD reject new condition registration for non-advertised collateral units (error 13048). +- `outcome_collections` (optional): Canonical outcome collections to create keysets for. If omitted, the mint applies its `default_keyset_creation` policy. If the mint advertises `default_keyset_creation` as `"one-vs-rest"` or `"all"`, clients MUST omit this field and the mint MUST reject client-defined collections. +- `fee` (optional): Anti-spam registration fee inputs paid as `Proof` objects. The proofs MUST be from a **regular** keyset ([NUT-02][02]) whose unit equals `collateral`; conditional-keyset proofs MUST be rejected (error 13017). REQUIRED when the mint advertises a non-zero registration fee for the requested `collateral` unit (see [Mint Info Setting](#mint-info-setting)); MAY be omitted when the advertised fee for that unit is `0`. The mint retains exactly `required_fee` as revenue and returns any excess as regular ecash change using `outputs` / `change` as described in [Registration Fee](#registration-fee). +- `outputs` (optional): Blank `BlindedMessage` objects for returning excess fee input as regular ecash change, following the [NUT-08][08] blank-output model. The `amount` field in these messages is ignored by the mint and MAY be set to any valid placeholder amount by the client. Outputs MUST use a regular keyset whose unit equals `collateral`; conditional-keyset outputs MUST be rejected (error 13017). Clients SHOULD provide enough blank outputs to represent the maximum possible change from the selected `fee` proofs. + +**Response** of `Bob`: + +```json +{ + "condition_id": , + "keysets": { + "": , + "": , + ... + }, + "change": +} +``` + +```bash +curl -X POST https://mint.host:3338/v1/conditions \ + -H "Content-Type: application/json" \ + -d '{"threshold":1,"tags":[["description","Will BTC reach $100k?"]],"announcements":["fdd824fd..."],"collateral":"sat","outcome_collections":["YES","NO"]}' +``` + +#### Mint Behavior + +1. Parses and verifies announcement signatures (error 13011 if failed) +2. Computes `condition_id` +3. Determines requested keysets: + - If `outcome_collections` is provided and `default_keyset_creation` is `"none"`: canonicalizes and validates the requested collections. + - If `outcome_collections` is provided and `default_keyset_creation` is `"one-vs-rest"` or `"all"`: rejects the request. + - If omitted: applies `default_keyset_creation`. +4. If condition exists with matching config and requested keyset set: returns existing `condition_id` and keysets (idempotent). The mint MUST NOT charge the registration fee on this path — see [Registration Fee](#registration-fee). +5. If condition exists with different config or requested keyset set: error 13028 +6. If new: charges the registration fee (if any), stores the condition, creates keysets, and returns `condition_id`, `keysets`, and any fee `change` — all in a single atomic transaction (see [Registration Fee](#registration-fee)) + +The mint MUST make condition registration idempotent. Mints MAY charge a [registration fee](#registration-fee) and/or require [NUT-21][21] or [NUT-22][22] authentication for DoS prevention. + +### Registration Fee + +To bound condition-registration spam, a mint MAY charge a fee per new condition, advertised per collateral unit via [Mint Info Setting](#mint-info-setting). The `registration_fees` array also declares which collateral units the mint supports for CTF condition registration. A mint MAY advertise fee settings for only a subset of otherwise supported currency units. CTF registration is supported only for advertised units; if no setting is advertised for the requested `collateral`, the mint SHOULD reject new condition registration (error 13048). A mint that wants free registration for a unit MUST explicitly advertise that unit with both fee components set to `0`. + +For a registration whose `collateral` is unit `u`, the mint selects the unique advertised registration-fee setting whose `unit == u`. The required amount, denominated in the smallest unit of `collateral`, is: + +``` +required_fee = registration_fee_base + registration_fee_per_keyset * num_keysets +``` + +where `registration_fee_base` and `registration_fee_per_keyset` are taken from the selected fee setting, and `num_keysets` is the number of conditional keysets this registration creates (after step 3 above). When `registration_fee_base` and `registration_fee_per_keyset` are both `0`, registration is free and the `fee` field MAY be omitted. + +The registration fee is mint-authoritative: the mint computes `num_keysets` after canonicalization, validation, default policy expansion, duplicate removal, and full-set exclusion. Clients MUST NOT rely on a locally computed keyset count as the payment authority. + +No additional [NUT-02][02] `input_fee_ppk` is charged on `fee` proofs for condition registration; `required_fee` is the sole amount retained by the mint. When `required_fee == 0`, the mint MUST NOT consume any `fee` proofs and MUST NOT sign `outputs` as fee change. + +When `required_fee > 0`, the mint MUST, for a **new** condition only: + +1. Require the `fee` field. Each `Proof` MUST be valid, unspent, from a regular keyset whose unit equals `collateral` (error 13017 for a conditional/wrong-unit keyset), and the sum of `fee` proof amounts MUST be at least `required_fee` (error 13044 otherwise). +2. Compute `change_amount = sum(fee) - required_fee`. +3. If `change_amount > 0`, require `outputs` with enough blank messages to return the exact change amount as regular ecash. The mint decomposes `change_amount` into amounts supported by the output keyset, assigns those amounts to the blank outputs, signs them, and returns the resulting positive-value `BlindSignature`s in `change`. The returned `change` signatures MUST preserve the order of the blank outputs they correspond to and MUST omit zero-value outputs, matching [NUT-08][08]. If exact change cannot be returned from the provided outputs, the mint MUST reject the request (error 13047) and MUST NOT consume the fee proofs. +4. Atomically, in a single transaction: mark the `fee` proofs spent, store the condition, and create the keysets. If any step fails, none are applied — a failed registration MUST NOT consume the fee. + +The response for a new paid registration MUST include `change` when `change_amount > 0`, and MAY omit `change` or return an empty array when `change_amount == 0`. + +The mint MUST compute `required_fee` and verify the fee **after** the idempotency check (Mint Behavior step 4). A repeated registration that resolves to an existing condition MUST return the existing `condition_id` and `keysets` without charging again and without returning `change`; clients keep their unspent `fee` proofs and discard unused blank outputs. This prevents a client retry from double-charging or failing because retry proofs were already spent. The registration fee is mint revenue and is independent of condition collateral and the [NUT-CTF-split-merge][CTF-split-merge] solvency invariant. Fee change is regular ecash and is not condition collateral. + +## Outcome Collection ID + +Each outcome collection has a unique `outcome_collection_id` derived from the condition ID and outcome collection string. The result is a 32-byte x-only public key on secp256k1. + +### Computation + +``` +outcome_collection_id(condition_id, outcome_collection_string): + 1. h = tagged_hash("Cashu_outcome_collection_id", condition_id || outcome_collection_string_bytes) + 2. P = hash_to_curve(h) + 3. Return x_only(P) +``` + +Where: + +- `tagged_hash`: BIP-340 tagged hash +- `hash_to_curve`: Same approach as [NUT-00][00]'s `hash_to_curve` with domain separation via tagged hash input +- `x_only`: Per [BIP-340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) + +This version defines root-level outcome collections only. Combinatorial/nested condition construction is intentionally out of scope until the registration API includes an explicit way to create parent-scoped keysets. + +## Conditional Keyset Discovery + +Conditional keysets are served on a dedicated endpoint, separate from `GET /v1/keysets` ([NUT-02][02]). This ensures backward compatibility and prevents conditional keysets from inflating the regular listing. + +```http +GET https://mint.host:3338/v1/conditional_keysets +``` + +**Query parameters:** + +- `since` (optional): Unix timestamp. Returns keysets with `registered_at >= since`. +- `limit` (optional): Maximum keysets per response. +- `active` (optional): Boolean filter on `active` flag. + +Mints MUST return results ordered by `registered_at` ascending. Same pagination approach as `GET /v1/conditions`. + +**Response** of `Bob`: + +Structurally identical to `GET /v1/keysets` ([NUT-02][02]) with four additional fields: + +```json +{ + "keysets": [ + { + "id": , + "unit": , + "active": , + "input_fee_ppk": , + "final_expiry": , + "condition_id": , + "outcome_collection": , + "outcome_collection_id": , + "registered_at": + } + ] +} +``` + +```bash +curl -X GET https://mint.host:3338/v1/conditional_keysets?active=true +``` + +The standard `GET /v1/keys/{keyset_id}` ([NUT-02][02]) still works for fetching public keys of a specific conditional keyset. + +## Redemption Witness + +When redeeming via `POST /v1/redeem_outcome`, each input `Proof` MUST include a `witness` with oracle attestation: + +```json +{ + "oracle_sigs": [ + { + "oracle_pubkey": , + "oracle_sig": + } + ] +} +``` + +- `oracle_sigs`: Array with at least `threshold` entries from distinct oracles + - `oracle_pubkey`: 32-byte x-only key (64-char hex) + - `oracle_sig`: 64-byte Schnorr signature (128-char hex) on the winning outcome + +Always use the array format, even for single-oracle markets (threshold=1). + +See [supplementary material](suppl/CTF.md#redemption-witness-comparison) for comparison with existing Cashu witness types. + +## Redemption Endpoint + +```http +POST https://mint.host:3338/v1/redeem_outcome +``` + +**Request** of `Alice`: + +```json +{ + "inputs": , + "outputs": +} +``` + +- `inputs`: `Proof` objects from a **single conditional keyset**, each with `witness` containing oracle attestation +- `outputs`: `BlindedMessage` objects (same unit). Outputs use a **regular keyset**. + +`Alice` MAY omit `oracle_sigs` if `Bob` has already recorded a valid attestation for this outcome collection (check via `GET /v1/conditions/{condition_id}`). + +**Response** of `Bob`: + +```json +{ + "signatures": +} +``` + +```bash +curl -X POST https://mint.host:3338/v1/redeem_outcome \ + -H "Content-Type: application/json" \ + -d '{"inputs":[...],"outputs":[...]}' +``` + +### Consequence for NUT-03 + +Mints implementing NUT-CTF MUST enforce these rules on [NUT-03][03] swap: + +- Swaps within the same conditional keyset: **allowed** (trading) +- Swaps within regular keysets (including cross-keyset): **allowed** +- Swaps where all inputs and outputs share the same `outcome_collection_id`: **allowed** (key rotation) +- Swaps spanning different `outcome_collection_id` values: **MUST reject** +- Swaps mixing conditional and regular keysets: **MUST reject** + +All conditional-to-regular conversions go through `POST /v1/redeem_outcome`. Movement of value **across** outcome collections within a condition (regrouping, or crossing the collateral boundary) goes through the payoff-preserving `POST /v1/ctf/convert` operation ([NUT-CTF-split-merge][CTF-split-merge]), never a NUT-03 swap. + +## Redemption Verification + +When `Bob` receives a `POST /v1/redeem_outcome` request: + +1. All inputs MUST use the same conditional keyset +2. All outputs MUST use a regular keyset with the same unit +3. If `Bob` already has a valid attestation for this outcome collection, MAY skip steps 4-5 +4. Each input MUST include valid `witness` with `oracle_sigs` +5. Verify at least `threshold` signatures from distinct oracles using [DLC signing algorithm](https://github.com/discreetlogcontracts/dlcspecs/blob/master/Oracle.md#signing-algorithm) with tagged hash `"DLC/oracle/attestation/v0"` and UTF-8 NFC-normalized outcome string +6. Verify this outcome collection contains the attested atomic outcome + +### Attestation Handling + +The mint MUST persistently record the first valid attestation (atomic winning outcome + timestamp) for each condition. This record MUST survive restarts. + +The mint MUST NOT process redemptions for non-winning keysets. If a valid signature for a different outcome is received (a DLC protocol violation), the mint MUST reject it and MUST log the conflict. Mints SHOULD expose violations via condition info. + +## Vesting Period + +The mint MAY deactivate conditional keysets after a vesting period following event maturity. + +- Vesting period SHOULD be at least 30 days after `event_maturity_epoch` +- Mints MUST communicate vesting period via [Mint Info Setting](#mint-info-setting) +- After expiry: keyset `active` set to `false`; mint MAY refuse redemptions and delete event data +- Wallets SHOULD prominently display the deadline and alert users as it approaches + +### Oracle Non-Attestation + +If the oracle does not attest within expected time, the mint MAY refund conditional tokens to regular ecash at its discretion. + +## Error Codes + +| Code | Description | +| ----- | ----------------------------------------------------------- | +| 13010 | Invalid oracle signature | +| 13011 | Oracle announcement verification failed | +| 13014 | Conditional keyset requires oracle witness | +| 13015 | Oracle has not attested to this outcome collection | +| 13016 | Conditional keyset swap spans different outcome collections | +| 13017 | Invalid keyset for collateral/output side | +| 13020 | Invalid condition ID | +| 13021 | Condition not found | +| 13027 | Oracle threshold not met | +| 13028 | Condition already exists | +| 13037 | Duplicate canonical outcome collection | +| 13038 | Unknown outcome in outcome collection | +| 13043 | Full-set or reserved outcome collection | +| 13044 | Missing or insufficient registration fee | +| 13045 | Hash to curve failed | +| 13046 | EC point operation failed | +| 13047 | Insufficient or invalid change outputs | +| 13048 | Unsupported CTF collateral unit | + +## Mint Info Setting + +The [NUT-06][06] setting indicates support for this feature: + +```json +{ + "CTF": { + "supported": true, + "dlc_version": , + "vesting_period": , + "default_keyset_creation": , + "registration_fees": [ + { + "unit": , + "registration_fee_base": , + "registration_fee_per_keyset": + } + ] + } +} +``` + +- `supported`: Boolean indicating NUT-CTF support +- `vesting_period` (optional): Seconds after `event_maturity_epoch` for redemption. Default: 30 days (2592000). `0` = no expiry. +- `dlc_version`: DLC protocol version (currently `"0"`) +- `default_keyset_creation`: `"none"` (default), `"one-vs-rest"`, or `"all"`. + - `"none"`: omitted `outcome_collections` creates no enum keysets; clients MAY request explicit non-full enum collections. + - `"one-vs-rest"`: omitted `outcome_collections` creates each atomic outcome and its complement, deduplicated for binary conditions; clients MUST NOT request explicit collections. + - `"all"`: omitted `outcome_collections` creates every non-empty, non-full collection subject to mint limits; clients MUST NOT request explicit collections. + - Numeric conditions always create `HI` and `LO` keysets at registration, regardless of this policy. +- `registration_fees`: Array of per-collateral-unit registration fee settings. Each entry advertises support for CTF registration with that collateral unit. Mints MAY omit otherwise supported currency units from this array; clients MUST treat omitted units as unsupported for new CTF condition registration. Mints MUST NOT advertise more than one entry for the same `unit`; clients SHOULD treat duplicate entries as invalid mint configuration. +- `registration_fees[].unit`: Collateral unit this fee schedule applies to. +- `registration_fees[].registration_fee_base` (optional): Flat anti-spam fee charged per new condition, in the smallest unit of `unit`. Default: `0` (free). See [Registration Fee](#registration-fee). +- `registration_fees[].registration_fee_per_keyset` (optional): Additional fee charged per conditional keyset the registration creates, in the smallest unit of `unit`. Default: `0`. The total required fee is `registration_fee_base + registration_fee_per_keyset * num_keysets`. + +[00]: 00.md +[01]: 01.md +[02]: 02.md +[03]: 03.md +[04]: 04.md +[05]: 05.md +[06]: 06.md +[07]: 07.md +[08]: 08.md +[09]: 09.md +[10]: 10.md +[11]: 11.md +[12]: 12.md +[14]: 14.md +[21]: 21.md +[22]: 22.md +[CTF-split-merge]: CTF-split-merge.md +[CTF-numeric]: CTF-numeric.md +[NIP-88]: https://github.com/nostr-protocol/nips/pull/1681 diff --git a/README.md b/README.md index eb653ccb..1968a971 100644 --- a/README.md +++ b/README.md @@ -20,31 +20,35 @@ Wallets and mints `MUST` implement all mandatory specs and `CAN` implement optio ### Optional -| # | Description | Wallets | Mints | -| -------- | --------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------ | -| [07][07] | Token state check | [Nutshell][py], [Nutstash][ns], [cashu-ts][ts], [cdk], [Minibits], [macadamia] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [08][08] | Overpaid Lightning fees | [Nutshell][py], [Nutstash][ns], [cashu-ts][ts], [cdk], [Minibits], [macadamia] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [09][09] | Signature restore | [Nutshell][py], [cdk], [Cashu.me][cashume], [Minibits], [macadamia] | [Nutshell][py], [cdk-mintd] | -| [10][10] | Spending conditions | [Nutshell][py], [cdk], [cashu-ts][ts], [Minibits] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [11][11] | Pay-To-Pubkey (P2PK) | [Nutshell][py], [cdk], [Cashu.me][cashume], [Minibits] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [12][12] | DLEQ proofs | [Nutshell][py], [cdk], [cashu-ts][ts] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [13][13] | Deterministic secrets | [Nutshell][py], [cashu-ts][ts], [cdk], [macadamia], [Minibits] | - | -| [14][14] | Hashed Timelock Contracts (HTLCs) | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [15][15] | Partial multi-path payments (MPP) | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [16][16] | Animated QR codes | [Cashu.me][cashume], [macadamia], [Minibits] | - | -| [17][17] | WebSocket subscriptions | [Nutshell][py], [cdk], [Cashu.me][cashume], [Minibits] | [Nutshell][py], [cdk-mintd][cdk-mintd], [nutmix] | -| [18][18] | Payment requests | [Cashu.me][cashume], [Boardwalk][bwc], [cdk], [Minibits] | - | -| [19][19] | Cached Responses | - | [Nutshell][py], [cdk-mintd] | -| [20][20] | Signature on Mint Quote | [cdk], [Nutshell][py] | [cdk-mintd], [Nutshell][py] | -| [21][21] | Clear authentication | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [22][22] | Blind authentication | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [23][23] | Payment Method: BOLT11 | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [24][24] | HTTP 402 Payment Required | - | - | -| [25][25] | Payment Method: BOLT12 | [cdk], [cashu-ts][ts] | [cdk-mintd] | -| [26][26] | Payment Request Bech32m Encoding | [cdk], [cashu-ts][ts] | - | -| [27][27] | Nostr Mint Backup | [Cashu.me][cashume], [cdk] | - | -| [28][28] | Pay to Blinded Key (P2BK) | [cdk], [cashu-ts][ts] | - | -| [29][29] | Batched Mint | - | - | +| # | Description | Wallets | Mints | +| ---------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------ | +| [07][07] | Token state check | [Nutshell][py], [Nutstash][ns], [cashu-ts][ts], [cdk], [Minibits], [macadamia] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [08][08] | Overpaid Lightning fees | [Nutshell][py], [Nutstash][ns], [cashu-ts][ts], [cdk], [Minibits], [macadamia] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [09][09] | Signature restore | [Nutshell][py], [cdk], [Cashu.me][cashume], [Minibits], [macadamia] | [Nutshell][py], [cdk-mintd] | +| [10][10] | Spending conditions | [Nutshell][py], [cdk], [cashu-ts][ts], [Minibits] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [11][11] | Pay-To-Pubkey (P2PK) | [Nutshell][py], [cdk], [Cashu.me][cashume], [Minibits] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [12][12] | DLEQ proofs | [Nutshell][py], [cdk], [cashu-ts][ts] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [13][13] | Deterministic secrets | [Nutshell][py], [cashu-ts][ts], [cdk], [macadamia], [Minibits] | - | +| [14][14] | Hashed Timelock Contracts (HTLCs) | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [15][15] | Partial multi-path payments (MPP) | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [16][16] | Animated QR codes | [Cashu.me][cashume], [macadamia], [Minibits] | - | +| [17][17] | WebSocket subscriptions | [Nutshell][py], [cdk], [Cashu.me][cashume], [Minibits] | [Nutshell][py], [cdk-mintd][cdk-mintd], [nutmix] | +| [18][18] | Payment requests | [Cashu.me][cashume], [Boardwalk][bwc], [cdk], [Minibits] | - | +| [19][19] | Cached Responses | - | [Nutshell][py], [cdk-mintd] | +| [20][20] | Signature on Mint Quote | [cdk], [Nutshell][py] | [cdk-mintd], [Nutshell][py] | +| [21][21] | Clear authentication | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [22][22] | Blind authentication | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [23][23] | Payment Method: BOLT11 | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [24][24] | HTTP 402 Payment Required | - | - | +| [25][25] | Payment Method: BOLT12 | [cdk], [cashu-ts][ts] | [cdk-mintd] | +| [26][26] | Payment Request Bech32m Encoding | [cdk], [cashu-ts][ts] | - | +| [27][27] | Nostr Mint Backup | [Cashu.me][cashume], [cdk] | - | +| [28][28] | Pay to Blinded Key (P2BK) | [cdk], [cashu-ts][ts] | - | +| [29][29] | Batched Mint | - | - | +| [CTF][CTF] | Conditional Token Framework | - | - | +| [CTF-split-merge][CTF-split-merge] | Conditional Token Convert (Split, Merge, Recombine) | - | - | +| [CTF-settle][CTF-settle] | Multi-Party Atomic Settlement | - | - | +| [CTF-numeric][CTF-numeric] | Numeric Outcome Conditions | - | - | #### Wallets @@ -103,3 +107,7 @@ Wallets and mints `MUST` implement all mandatory specs and `CAN` implement optio [27]: 27.md [28]: 28.md [29]: 29.md +[CTF]: CTF.md +[CTF-split-merge]: CTF-split-merge.md +[CTF-settle]: CTF-settle.md +[CTF-numeric]: CTF-numeric.md diff --git a/error_codes.md b/error_codes.md index 4c677a07..c3c95ac5 100644 --- a/error_codes.md +++ b/error_codes.md @@ -1,40 +1,55 @@ # NUT Errors -| Code | Description | Relevant nuts | -| ----- | ----------------------------------------------- | ---------------------------------------- | -| 10001 | Proof verification failed | [NUT-03][03], [NUT-05][05] | -| 11001 | Proofs already spent | [NUT-03][03], [NUT-05][05] | -| 11002 | Proofs are pending | [NUT-03][03], [NUT-05][05] | -| 11003 | Outputs already signed | [NUT-03][03], [NUT-04][04], [NUT-05][05] | -| 11004 | Outputs are pending | [NUT-03][03], [NUT-04][04], [NUT-05][05] | -| 11005 | Transaction is not balanced (inputs != outputs) | [NUT-02][02], [NUT-03][03], [NUT-05][05] | -| 11006 | Amount outside of limit range | [NUT-04][04], [NUT-05][05] | -| 11007 | Duplicate inputs provided | [NUT-03][03], [NUT-04][04], [NUT-05][05] | -| 11008 | Duplicate outputs provided | [NUT-03][03], [NUT-04][04], [NUT-05][05] | -| 11009 | Inputs/Outputs of multiple units | [NUT-03][03], [NUT-04][04], [NUT-05][05] | -| 11010 | Inputs and outputs not of same unit | [NUT-03][03], [NUT-04][04], [NUT-05][05] | -| 11011 | Amountless invoice is not supported | [NUT-05][05] | -| 11012 | Amount in request does not equal invoice | [NUT-05][05] | -| 11013 | Unit in request is not supported | [NUT-04][04], [NUT-05][05] | -| 11014 | Max inputs exceeded | [NUT-03][03], [NUT-05][05] | -| 11015 | Max outputs exceeded | [NUT-03][03], [NUT-04][04], [NUT-05][05] | -| 12001 | Keyset is not known | [NUT-02][02], [NUT-04][04] | -| 12002 | Keyset is inactive, cannot sign messages | [NUT-02][02], [NUT-03][03], [NUT-04][04] | -| 20001 | Quote request is not paid | [NUT-04][04] | -| 20002 | Quote has already been issued | [NUT-04][04] | -| 20003 | Minting is disabled | [NUT-04][04] | -| 20004 | Lightning payment failed | [NUT-05][05] | -| 20005 | Quote is pending | [NUT-04][04], [NUT-05][05], [NUT-29][29] | -| 20006 | Invoice already paid | [NUT-05][05] | -| 20007 | Quote is expired | [NUT-04][04], [NUT-05][05] | -| 20008 | Signature for mint request invalid | [NUT-20][20] | -| 20009 | Pubkey required for mint quote | [NUT-20][20] | -| 30001 | Endpoint requires clear auth | [NUT-21][21] | -| 30002 | Clear authentication failed | [NUT-21][21] | -| 31001 | Endpoint requires blind auth | [NUT-22][22] | -| 31002 | Blind authentication failed | [NUT-22][22] | -| 31003 | Maximum BAT mint amount exceeded | [NUT-22][22] | -| 31004 | BAT mint rate limit exceeded | [NUT-22][22] | +| Code | Description | Relevant nuts | +| ----- | -------------------------------------------------- | ------------------------------------------------------ | +| 10001 | Proof verification failed | [NUT-03][03], [NUT-05][05] | +| 11001 | Proofs already spent | [NUT-03][03], [NUT-05][05] | +| 11002 | Proofs are pending | [NUT-03][03], [NUT-05][05] | +| 11003 | Outputs already signed | [NUT-03][03], [NUT-04][04], [NUT-05][05] | +| 11004 | Outputs are pending | [NUT-03][03], [NUT-04][04], [NUT-05][05] | +| 11005 | Transaction is not balanced (inputs != outputs) | [NUT-02][02], [NUT-03][03], [NUT-05][05] | +| 11006 | Amount outside of limit range | [NUT-04][04], [NUT-05][05] | +| 11007 | Duplicate inputs provided | [NUT-03][03], [NUT-04][04], [NUT-05][05] | +| 11008 | Duplicate outputs provided | [NUT-03][03], [NUT-04][04], [NUT-05][05] | +| 11009 | Inputs/Outputs of multiple units | [NUT-03][03], [NUT-04][04], [NUT-05][05] | +| 11010 | Inputs and outputs not of same unit | [NUT-03][03], [NUT-04][04], [NUT-05][05] | +| 11011 | Amountless invoice is not supported | [NUT-05][05] | +| 11012 | Amount in request does not equal invoice | [NUT-05][05] | +| 11013 | Unit in request is not supported | [NUT-04][04], [NUT-05][05] | +| 11014 | Max inputs exceeded | [NUT-03][03], [NUT-05][05] | +| 11015 | Max outputs exceeded | [NUT-03][03], [NUT-04][04], [NUT-05][05] | +| 11016 | Duplicate quote IDs provided | [NUT-29][29] | +| 11017 | Max batch size exceeded | [NUT-29][29] | +| 12001 | Keyset is not known | [NUT-02][02], [NUT-04][04] | +| 12002 | Keyset is inactive, cannot sign messages | [NUT-02][02], [NUT-03][03], [NUT-04][04] | +| 12003 | Keyset has expired | [NUT-02][02], [NUT-03][03], [NUT-04][04], [NUT-05][05] | +| 20001 | Quote request is not paid | [NUT-04][04] | +| 20002 | Quote has already been issued | [NUT-04][04] | +| 20003 | Minting is disabled | [NUT-04][04] | +| 20004 | Lightning payment failed | [NUT-05][05] | +| 20005 | Quote is pending | [NUT-04][04], [NUT-05][05], [NUT-29][29] | +| 20006 | Invoice already paid | [NUT-05][05] | +| 20007 | Quote is expired | [NUT-04][04], [NUT-05][05] | +| 20008 | Signature for mint request invalid | [NUT-20][20] | +| 20009 | Pubkey required for mint quote | [NUT-20][20] | +| 30001 | Endpoint requires clear auth | [NUT-21][21] | +| 30002 | Clear authentication failed | [NUT-21][21] | +| 31001 | Endpoint requires blind auth | [NUT-22][22] | +| 31002 | Blind authentication failed | [NUT-22][22] | +| 31003 | Maximum BAT mint amount exceeded | [NUT-22][22] | +| 31004 | BAT mint rate limit exceeded | [NUT-22][22] | +| 15001 | Unsupported or malformed `PAY_TO_UNLOCK` condition | [NUT-Exchange][exchange] | +| 15003 | Receive-output commitment (`H_recv`) mismatch | [NUT-Exchange][exchange] | +| 15004 | Offer/receive keyset relationship violated | [NUT-Exchange][exchange] | +| 15005 | Exchange settlement submitted after `expiry` | [NUT-Exchange][exchange] | +| 15006 | Refund submitted before `expiry` | [NUT-Exchange][exchange] | +| 15007 | Refund signature missing or invalid | [NUT-Exchange][exchange] | +| 15009 | Exchange request exceeds advertised limits | [NUT-Exchange][exchange] | +| 15010 | Conflicting request or reused input (idempotency) | [NUT-Exchange][exchange] | +| 15011 | Pool manifest hash (`H_manifest`) mismatch | [NUT-Exchange-partial-fill][partial-fill] | +| 15012 | Pool selection does not match `outputs` | [NUT-Exchange-partial-fill][partial-fill] | +| 15013 | Pool role/keyset or two-class consistency violated | [NUT-Exchange-partial-fill][partial-fill] | +| 15014 | Pool policy violation (rate/min/max/overflow) | [NUT-Exchange-partial-fill][partial-fill] | [00]: 00.md [01]: 01.md @@ -53,3 +68,5 @@ [21]: 21.md [22]: 22.md [29]: 29.md +[exchange]: https://github.com/cashubtc/nuts/pull/410 +[partial-fill]: https://github.com/cashubtc/nuts/pull/410 diff --git a/suppl/CTF-split-merge.md b/suppl/CTF-split-merge.md new file mode 100644 index 00000000..11354ba1 --- /dev/null +++ b/suppl/CTF-split-merge.md @@ -0,0 +1,151 @@ +# Supplementary: NUT-CTF-split-merge Complete Example + +This document provides a full end-to-end example of the CTF convert lifecycle. For the normative specification, see [NUT-CTF-split-merge][CTF-split-merge]. + +## Complete Example + +### Step 1a: Register Condition + +First, register the condition via `POST /v1/conditions` ([NUT-CTF][CTF]): + +**Request** of `Alice`: + +```http +POST https://mint.host:3338/v1/conditions +``` + +```json +{ + "threshold": 1, + "tags": [["description", "Will BTC reach $100k by June 2025?"]], + "announcements": ["fdd824fd<...hex-encoded oracle_announcement TLV...>"], + "collateral": "sat", + "outcome_collections": ["YES", "NO"] +} +``` + +`Bob` responds with: + +```json +{ + "condition_id": "a1b2c3d4e5f67890...", + "keysets": { + "YES": "00abc123def456", + "NO": "00def789abc012" + } +} +``` + +### Step 2: Split Collateral (convert with `"*"` inputs) + +`Alice` wants to participate with 100 sats. A split is a convert whose inputs are collateral under the reserved key `"*"`: + +**Request** of `Alice`: + +```http +POST https://mint.host:3338/v1/ctf/convert +``` + +```json +{ + "condition_id": "a1b2c3d4e5f67890...", + "inputs": { + "*": [ + { + "amount": 64, + "id": "009a1f293253e41e", + "secret": "random_secret_1", + "C": "02..." + }, + { + "amount": 32, + "id": "009a1f293253e41e", + "secret": "random_secret_2", + "C": "02..." + }, + { + "amount": 4, + "id": "009a1f293253e41e", + "secret": "random_secret_3", + "C": "02..." + } + ] + }, + "outputs": { + "YES": [ + { "amount": 64, "id": "00abc123def456", "B_": "03..." }, + { "amount": 32, "id": "00abc123def456", "B_": "03..." }, + { "amount": 4, "id": "00abc123def456", "B_": "03..." } + ], + "NO": [ + { "amount": 64, "id": "00def789abc012", "B_": "03..." }, + { "amount": 32, "id": "00def789abc012", "B_": "03..." }, + { "amount": 4, "id": "00def789abc012", "B_": "03..." } + ] + } +} +``` + +`Bob` responds with: + +```json +{ + "signatures": { + "YES": [ + { "amount": 64, "id": "00abc123def456", "C_": "02..." }, + { "amount": 32, "id": "00abc123def456", "C_": "02..." }, + { "amount": 4, "id": "00abc123def456", "C_": "02..." } + ], + "NO": [ + { "amount": 64, "id": "00def789abc012", "C_": "02..." }, + { "amount": 32, "id": "00def789abc012", "C_": "02..." }, + { "amount": 4, "id": "00def789abc012", "C_": "02..." } + ] + } +} +``` + +`Alice` now holds 100 sats of YES tokens and 100 sats of NO tokens. + +### Step 3: Trading + +`Alice` believes YES will win, so she sells her NO tokens to `Carol` for 40 sats via a normal Cashu token transfer. `Carol` swaps at the mint using a standard [NUT-03][03] swap — all inputs and outputs use the NO conditional keyset. No oracle witness is needed. + +### Step 4: Oracle Attestation + +The oracle attests that YES won by publishing a DLC attestation signature on `"YES"`. + +### Step 5: Winner Redemption + +`Alice` redeems her YES tokens via `POST /v1/redeem_outcome` ([NUT-CTF][CTF]) with `oracle_sigs` witness. Inputs use the YES conditional keyset, outputs use a regular keyset. The mint verifies the oracle signatures and returns regular proofs. + +## Conversion Example (negative-risk style) + +For a four-outcome condition `Ω = {A, B, C, D}` with registered collections `A|B|C`, `B|C|D`, and `B|C`, a holder of `A|B|C` and `B|C|D` tokens converts to collateral plus a `B|C` token in one call. With `F = 1`: + +```http +POST https://mint.host:3338/v1/ctf/convert +``` + +```json +{ + "condition_id": "a1b2c3d4e5f67890...", + "inputs": { + "A|B|C": [ { "amount": 100, "id": "00abc...", "secret": "s1", "C": "02..." } ], + "B|C|D": [ { "amount": 100, "id": "00bcd...", "secret": "s2", "C": "02..." } ] + }, + "outputs": { + "*": [ { "amount": 99, "id": "009a1f293253e41e", "B_": "03..." } ], + "B|C": [ { "amount": 100, "id": "00bc0...", "B_": "03..." } ] + } +} +``` + +Per-outcome: `in = (A:100, B:200, C:200, D:100)`, `out = (A:99, B:199, C:199, D:99)`, so `out(o) == in(o) − 1` for every outcome. `Alice` withdraws 99 sats of spendable collateral (the bundle's guaranteed floor, minus the fee) without any oracle witness, and keeps a `B|C` position. The mint retains 1 sat on every outcome. + +[00]: ../00.md +[02]: ../02.md +[03]: ../03.md +[06]: ../06.md +[CTF]: ../CTF.md +[CTF-split-merge]: ../CTF-split-merge.md diff --git a/suppl/CTF.md b/suppl/CTF.md new file mode 100644 index 00000000..88b01192 --- /dev/null +++ b/suppl/CTF.md @@ -0,0 +1,61 @@ +# Supplementary: NUT-CTF Design Decisions + +## Q&A: Design Decisions + +### Why "download all, then sync" instead of server-side filtering? + +Supporting complex query combinations (filter by oracle, by unit, by date range, etc.) increases server complexity and creates a DoS vector — an attacker can craft expensive queries to burden the mint. More importantly, fine-grained server-side filtering leaks information about which conditions a wallet cares about, potentially revealing trading positions to the mint. The "download all, then sync with `since`" pattern keeps the server stateless and simple: every client gets the same data, preserving privacy. Since the total number of conditions on a single mint is expected to remain manageable, full downloads are practical. + +### Why `>=` instead of `>` for the `since` parameter? + +Unix timestamps have second-level precision. If two conditions are registered within the same second and the client uses `>` (strict greater-than), it could silently skip items that share the boundary timestamp. Using `>=` (greater-than-or-equal) guarantees that no items are missed at the cost of re-delivering boundary items. Clients MUST deduplicate by `condition_id` (or keyset `id` for the keysets endpoint), which is trivial with a local set. + +### When should users merge vs. wait for resolution? + +Merge is useful when: + +- A user holds a complete set and wants to exit their position before oracle attestation +- Market conditions change and the user wants to recover collateral immediately +- Arbitrage opportunities exist between the market price and collateral value + +Waiting for resolution is simpler when: + +- The user expects one outcome to win and wants to maximize profit +- Transaction fees make merge uneconomical + +## Keyset ID Derivation Rationale + +Without condition-specific data in the keyset ID, a wallet cannot verify from the keyset ID alone that a keyset is bound to a particular condition and outcome collection. By including `condition_id` and `outcome_collection_id` in the preimage, the wallet can recompute the keyset ID and confirm the mint's claim about which condition and outcome collection a keyset serves. + +## Redemption Witness Comparison + +The Redemption Witness extends the established Cashu pattern where `Proof.witness` carries condition-specific unlock data: + +| NUT | Witness Type | Format | Trigger | +| ------------------------- | ------------------ | ------------------------------------------ | ----------------------------------- | +| [NUT-11][11] (P2PK) | Signature | `{"signatures": [...]}` | Secret is P2PK kind ([NUT-10][10]) | +| [NUT-14][14] (HTLC) | Preimage + sig | `{"preimage": "...", "signatures": [...]}` | Secret is HTLC kind ([NUT-10][10]) | +| **NUT-CTF** (Conditional) | Oracle attestation | `{"oracle_sigs": [...]}` | Dedicated `redeem_outcome` endpoint | + +Key difference: [NUT-11][11] and [NUT-14][14] witnesses are triggered by the **secret structure** ([NUT-10][10] well-known format). NUT-CTF witnesses are triggered by the **endpoint** — the dedicated `POST /v1/redeem_outcome` endpoint requires oracle attestation. Proof secrets remain plain random strings. + +## Oracle Communication Notes + +### Note on adaptor signatures + +This specification does NOT use adaptor signatures. In Cashu's custodial model, the mint directly verifies the oracle's BIP 340 signature — no adaptor encryption/decryption is needed. + +### Note on oracle attestation optionality + +Oracle attestation is optional in principle. When the mint operator serves as the oracle (e.g., resolving disputes manually), no external attestation is needed. However, oracle attestation is useful for two reasons: (1) It provides a standardized way for mints to verify redemption claims, and (2) When combined with DLEQ Proof ([NUT-12][12]) and [Proof of Liabilities](https://gist.github.com/callebtc/ed5228d1d8cbaade0104db5d1cf63939), it can serve as a fraud proof if the mint fails to honor valid redemptions. + +[00]: ../00.md +[02]: ../02.md +[03]: ../03.md +[06]: ../06.md +[10]: ../10.md +[11]: ../11.md +[12]: ../12.md +[14]: ../14.md +[CTF]: ../CTF.md +[CTF-split-merge]: ../CTF-split-merge.md diff --git a/tests/CTF-numeric-tests.md b/tests/CTF-numeric-tests.md new file mode 100644 index 00000000..25859378 --- /dev/null +++ b/tests/CTF-numeric-tests.md @@ -0,0 +1,372 @@ +# NUT-CTF-numeric Test Vectors + +These test vectors provide reference data for implementing numeric outcome markets. All values are hex-encoded for reproducibility. + +## Numeric Market Registration + +### Test 1: Register numeric market (HI/LO) + +```shell +# Step 1: Register a numeric condition via POST /v1/conditions +register_request: { + "threshold": 1, + "tags": [["description", "BTC/USD price on 2025-07-01"], ["n", "BTC"]], + "announcements": [""], + "condition_type": "numeric", + "lo_bound": 0, + "hi_bound": 100000, + "precision": 0, + "collateral": "sat" +} + +register_response: { + "condition_id": "", + "keysets": { + "HI": "00hi11keyset22", + "LO": "00lo33keyset44" + } +} + +# Numeric keysets are always ["HI", "LO"]. +# condition_id = tagged_hash("Cashu_condition_id", +# sorted_oracle_pubkeys || event_id || outcome_count +# || 0x01 || lo_bound_i64be || hi_bound_i64be || precision_i32be) +# where outcome_count = 0x02 (always 2 for numeric) +# lo_bound_i64be = 0x0000000000000000 (0 as i64 big-endian) +# hi_bound_i64be = 0x00000000000186a0 (100000 as i64 big-endian) +# precision_i32be = 0x00000000 (0 as i32 big-endian) +``` + +### Test 2: Invalid numeric range + +```shell +# lo_bound >= hi_bound +request_json: { + "threshold": 1, + "tags": [["description", "Invalid range market"]], + "announcements": [""], + "condition_type": "numeric", + "lo_bound": 100000, + "hi_bound": 100000, + "precision": 0 +} + +error_code: 13030 +error_message: "Invalid numeric range (lo_bound >= hi_bound)" +``` + +## Payout Calculation + +### Test 3: Value in middle of range + +```shell +# Range [0, 100000], attested value V = 20000 +lo_bound: 0 +hi_bound: 100000 +attested_value: 20000 + +# Payout calculation +clamped_V: 20000 # clamp(20000, 0, 100000) = 20000 +hi_payout_ratio: 0.2 # (20000 - 0) / (100000 - 0) +lo_payout_ratio: 0.8 # 1 - 0.2 + +# For 100 sats face value +amount: 100 +hi_payout: 20 # floor(100 * 0.2) +lo_payout: 80 # 100 - 20 +total: 100 # 20 + 80 = 100 (conservation) +``` + +### Test 4: Value at lo_bound (LO gets 100%) + +```shell +# Range [0, 100000], attested value V = 0 +lo_bound: 0 +hi_bound: 100000 +attested_value: 0 + +# Payout calculation +clamped_V: 0 +hi_payout_ratio: 0.0 # (0 - 0) / (100000 - 0) +lo_payout_ratio: 1.0 # 1 - 0 + +# For 100 sats face value +amount: 100 +hi_payout: 0 # floor(100 * 0.0) +lo_payout: 100 # 100 - 0 +``` + +### Test 5: Value at hi_bound (HI gets 100%) + +```shell +# Range [0, 100000], attested value V = 100000 +lo_bound: 0 +hi_bound: 100000 +attested_value: 100000 + +# Payout calculation +clamped_V: 100000 +hi_payout_ratio: 1.0 # (100000 - 0) / (100000 - 0) +lo_payout_ratio: 0.0 # 1 - 1 + +# For 100 sats face value +amount: 100 +hi_payout: 100 # floor(100 * 1.0) +lo_payout: 0 # 100 - 100 +``` + +### Test 6: Value below lo_bound (clamped, LO gets 100%) + +```shell +# Range [10000, 100000], attested value V = 5000 (below lo_bound) +lo_bound: 10000 +hi_bound: 100000 +attested_value: 5000 + +# Payout calculation +clamped_V: 10000 # clamp(5000, 10000, 100000) = 10000 +hi_payout_ratio: 0.0 # (10000 - 10000) / (100000 - 10000) +lo_payout_ratio: 1.0 # 1 - 0 + +# For 100 sats face value +amount: 100 +hi_payout: 0 +lo_payout: 100 +``` + +### Test 7: Value above hi_bound (clamped, HI gets 100%) + +```shell +# Range [10000, 100000], attested value V = 150000 (above hi_bound) +lo_bound: 10000 +hi_bound: 100000 +attested_value: 150000 + +# Payout calculation +clamped_V: 100000 # clamp(150000, 10000, 100000) = 100000 +hi_payout_ratio: 1.0 # (100000 - 10000) / (100000 - 10000) +lo_payout_ratio: 0.0 # 1 - 1 + +# For 100 sats face value +amount: 100 +hi_payout: 100 +lo_payout: 0 +``` + +### Test 8: Rounding behavior (conservation check) + +```shell +# Range [0, 3], attested value V = 1 +# This creates a ratio that doesn't divide evenly +lo_bound: 0 +hi_bound: 3 +attested_value: 1 + +# Payout calculation +clamped_V: 1 +hi_payout_ratio: 0.3333... # 1/3 +lo_payout_ratio: 0.6666... # 2/3 + +# For 100 sats face value +amount: 100 +hi_payout: 33 # floor(100 * 1/3) = floor(33.33) = 33 +lo_payout: 67 # 100 - 33 = 67 (NOT floor(100 * 2/3) = 66) +total: 100 # 33 + 67 = 100 (conservation guaranteed) + +# Note: LO uses amount - floor(amount * hi_ratio), not floor(amount * lo_ratio) +# This ensures total HI + LO = amount exactly +``` + +## Digit-Decomposition Witness + +### Test 9: Valid digit-decomposition witness + +```shell +# Oracle attests to value 20000 using digit decomposition +# 5-digit number: digits are [2, 0, 0, 0, 0] +oracle_pubkey: 9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0 + +# Each digit gets its own Schnorr signature using the corresponding R-value +digit_0_value: "2" # Most significant digit +digit_0_sig: <64_byte_schnorr_sig_on_"2"_with_R0> +digit_1_value: "0" +digit_1_sig: <64_byte_schnorr_sig_on_"0"_with_R1> +digit_2_value: "0" +digit_2_sig: <64_byte_schnorr_sig_on_"0"_with_R2> +digit_3_value: "0" +digit_3_sig: <64_byte_schnorr_sig_on_"0"_with_R3> +digit_4_value: "0" +digit_4_sig: <64_byte_schnorr_sig_on_"0"_with_R4> + +# Reconstructed value: 2*10000 + 0*1000 + 0*100 + 0*10 + 0*1 = 20000 + +# Witness JSON (digit_sigs format) +witness_json: { + "oracle_sigs": [ + { + "oracle_pubkey": "9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0", + "digit_sigs": [ + "<128_hex_sig_on_2>", + "<128_hex_sig_on_0>", + "<128_hex_sig_on_0>", + "<128_hex_sig_on_0>", + "<128_hex_sig_on_0>" + ] + } + ] +} +``` + +### Test 10: Invalid digit signature + +```shell +# One of the digit signatures is invalid +oracle_pubkey: 9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0 + +# Digit 0 signature is invalid +digit_0_sig: ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff + +error_code: 13031 +error_message: "Digit signature verification failed" +``` + +## Redemption + +### Test 11: HI holder proportional redemption + +```shell +# Range [0, 100000], attested value V = 20000 +# HI holder redeems 100 sats +input_keyset: "00hi11keyset22" # HI conditional keyset +input_amount: 100 +attested_value: 20000 + +# HI payout = floor(100 * (20000 - 0) / (100000 - 0)) = floor(20) = 20 +output_amount: 20 +output_keyset: "009a1f293253e41e" # regular keyset + +# POST /v1/redeem_outcome with digit_sigs witness +result: PASS +``` + +### Test 12: LO holder proportional redemption + +```shell +# Same attestation as Test 11 +# LO holder redeems 100 sats +input_keyset: "00lo33keyset44" # LO conditional keyset +input_amount: 100 +attested_value: 20000 + +# LO payout = 100 - floor(100 * (20000 - 0) / (100000 - 0)) = 100 - 20 = 80 +output_amount: 80 +output_keyset: "009a1f293253e41e" # regular keyset + +# POST /v1/redeem_outcome with digit_sigs witness +result: PASS +``` + +### Test 13: Conservation across HI and LO redemptions + +```shell +# For the same attestation: +hi_input: 100 sats +lo_input: 100 sats +hi_output: 20 sats +lo_output: 80 sats + +# Total collateral in: 100 sats (from original split) +# Total redeemed out: 20 + 80 = 100 sats +# Conservation: PASS +``` + +## Convert (Split and Merge) + +### Test 14: Numeric market split + +```shell +# Split 100 sats into HI and LO tokens (POST /v1/ctf/convert, collateral under "*") +request_json: { + "condition_id": "", + "inputs": { + "*": [ + {"amount": 64, "id": "009a1f293253e41e", "secret": "secret1", "C": "02..."}, + {"amount": 32, "id": "009a1f293253e41e", "secret": "secret2", "C": "02..."}, + {"amount": 4, "id": "009a1f293253e41e", "secret": "secret3", "C": "02..."} + ] + }, + "outputs": { + "HI": [ + {"amount": 64, "id": "00hi11keyset22", "B_": "03..."}, + {"amount": 32, "id": "00hi11keyset22", "B_": "03..."}, + {"amount": 4, "id": "00hi11keyset22", "B_": "03..."} + ], + "LO": [ + {"amount": 64, "id": "00lo33keyset44", "B_": "03..."}, + {"amount": 32, "id": "00lo33keyset44", "B_": "03..."}, + {"amount": 4, "id": "00lo33keyset44", "B_": "03..."} + ] + } +} + +result: PASS +``` + +### Test 15: Numeric market merge + +```shell +# Merge HI and LO tokens back to collateral +request_json: { + "condition_id": "", + "inputs": { + "HI": [ + {"amount": 100, "id": "00hi11keyset22", "secret": "hi_secret_1", "C": "02..."} + ], + "LO": [ + {"amount": 100, "id": "00lo33keyset44", "secret": "lo_secret_1", "C": "02..."} + ] + }, + "outputs": { + "*": [ + {"amount": 64, "id": "009a1f293253e41e", "B_": "03..."}, + {"amount": 32, "id": "009a1f293253e41e", "B_": "03..."}, + {"amount": 4, "id": "009a1f293253e41e", "B_": "03..."} + ] + } +} + +# NUT-CTF-split-merge convert (merge) - collateral under "*", no oracle witness needed +result: PASS +``` + +## Error Cases + +### Test 16: Payout calculation overflow + +```shell +# Extremely large range that could cause overflow +lo_bound: 0 +hi_bound: 9999999999999999999 +attested_value: 5000000000000000000 + +error_code: 13033 +error_message: "Payout calculation overflow" +``` + +### Test 17: Attested value outside representable digit range + +```shell +# Oracle announcement specifies 3 digits (max representable: 999) +# But digit signatures reconstruct to a value outside that range +# e.g., sign byte "+" then digits "1", "0", "0", "0" = 1000 (4 digits, exceeds 3-digit max) +oracle_pubkey: 9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0 +max_representable: 999 +reconstructed: 1000 + +error_code: 13032 +error_message: "Attested value outside representable range" +``` + +[NUT-CTF-numeric]: ../CTF-numeric.md +[NUT-CTF]: ../CTF.md +[NUT-CTF-split-merge]: ../CTF-split-merge.md diff --git a/tests/CTF-split-merge-tests.md b/tests/CTF-split-merge-tests.md new file mode 100644 index 00000000..6531a71a --- /dev/null +++ b/tests/CTF-split-merge-tests.md @@ -0,0 +1,783 @@ +# NUT-CTF-split-merge Test Vectors + +These test vectors provide reference data for implementing the Conditional Token Framework (CTF) convert operation (split, merge, recombine, conversion) with per-outcome collection keysets. All values are hex-encoded for reproducibility. Split and merge are the `"*"`-inputs and `"*"`-outputs special cases of `POST /v1/ctf/convert`. + +## Condition ID Calculation + +The condition ID is computed as `tagged_hash("Cashu_condition_id", sorted_oracle_pubkeys || event_id || outcome_count)` where `tagged_hash(tag, msg) = SHA256(SHA256(tag) || SHA256(tag) || msg)`. The condition ID is independent of requested outcome keysets. + +### Test 1: Binary condition ID + +```shell +# Condition parameters +oracle_pubkey: 9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0 +event_id: "btc_price_100k_2025" +event_id_utf8: 6274635f70726963655f3130306b5f32303235 +outcome_count: 2 +outcome_count_byte: 02 + +# Tagged hash computation +tag: "Cashu_condition_id" +tag_utf8: 43617368755f636f6e646974696f6e5f6964 +tag_hash: SHA256(tag_utf8) + +# Preimage (message for tagged hash) — no outcome keyset identifiers +msg_hex: 9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce06274635f70726963655f3130306b5f3230323502 + +# Condition ID = SHA256(tag_hash || tag_hash || msg) +``` + +### Test 2: Three-outcome condition ID + +```shell +# Condition parameters +oracle_pubkey: 9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0 +event_id: "election_2024_winner" +event_id_utf8: 656c656374696f6e5f323032345f77696e6e6572 +outcome_count: 3 +outcome_count_byte: 03 + +# Condition ID = tagged_hash("Cashu_condition_id", oracle_pubkey || event_id || outcome_count) +# No outcome keyset identifiers — condition_id is keyset-independent +``` + +### Test 3: Condition ID with special characters in question + +```shell +# Condition parameters +oracle_pubkey: 79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 +event_id: "Will ETH/USD > $5000?" +event_id_utf8: 57696c6c204554482f555344203e2024353030303f +outcome_count: 2 +outcome_count_byte: 02 + +# Condition ID uses tagged_hash (includes space, /, >, $ characters in event_id) +# No outcome keyset identifiers in condition_id +``` + +## Condition Registration + +### Test 4: Register condition with default keysets (binary) + +```shell +# Step 1: Register condition (POST /v1/conditions) +register_request: { + "threshold": 1, + "tags": [["description", "Will BTC reach $100k?"]], + "announcements": [""], + "collateral": "sat" +} + +register_response: { + "condition_id": "", + "keysets": { + "YES": "00abc123def456", + "NO": "00def789abc012" + } +} + +# These keyset IDs are used in all subsequent split/merge/trade operations +``` + +### Test 5: Three-outcome condition with one-vs-rest keysets + +```shell +# Step 1: Register condition +register_request: { + "threshold": 1, + "tags": [["description", "Election winner"]], + "announcements": [""], + "collateral": "sat" +} + +register_response: { + "condition_id": "", + "keysets": { + "CANDIDATE_A": "00aa11bb22cc33dd", + "CANDIDATE_B": "00bb22cc33dd44ee", + "CANDIDATE_C": "00cc33dd44ee55ff", + "CANDIDATE_A|CANDIDATE_B": "00dd44ee55ff6600", + "CANDIDATE_A|CANDIDATE_C": "00ee55ff66007711", + "CANDIDATE_B|CANDIDATE_C": "00ff660077118822" + } +} +``` + +## Convert Operation — Split + +A split is a `POST /v1/ctf/convert` whose inputs are collateral under the reserved key `"*"`. + +### Test 6: Binary condition split request + +```shell +# Condition parameters +condition_id: + +# Input (100 sats collateral using regular keyset, fee F = 0) +input_amount: 100 +input_keyset_id: 009a1f293253e41e # regular keyset + +# Output keyset IDs from condition preparation +yes_keyset_id: 00abc123def456 +no_keyset_id: 00def789abc012 + +# Convert (split) request JSON — POST /v1/ctf/convert +request_json: { + "condition_id": "", + "inputs": { + "*": [ + {"amount": 64, "id": "009a1f293253e41e", "secret": "secret1", "C": "02..."}, + {"amount": 32, "id": "009a1f293253e41e", "secret": "secret2", "C": "02..."}, + {"amount": 4, "id": "009a1f293253e41e", "secret": "secret3", "C": "02..."} + ] + }, + "outputs": { + "YES": [ + {"amount": 64, "id": "00abc123def456", "B_": "03..."}, + {"amount": 32, "id": "00abc123def456", "B_": "03..."}, + {"amount": 4, "id": "00abc123def456", "B_": "03..."} + ], + "NO": [ + {"amount": 64, "id": "00def789abc012", "B_": "03..."}, + {"amount": 32, "id": "00def789abc012", "B_": "03..."}, + {"amount": 4, "id": "00def789abc012", "B_": "03..."} + ] + } +} + +# Each outcome collection's BlindedMessages use the outcome collection-specific keyset ID +``` + +### Test 7: Successful split response + +```shell +# Response with signatures for each outcome collection (using conditional keyset IDs) +response_json: { + "signatures": { + "YES": [ + {"amount": 64, "id": "00abc123def456", "C_": "02...sig1..."}, + {"amount": 32, "id": "00abc123def456", "C_": "02...sig2..."}, + {"amount": 4, "id": "00abc123def456", "C_": "02...sig3..."} + ], + "NO": [ + {"amount": 64, "id": "00def789abc012", "C_": "02...sig4..."}, + {"amount": 32, "id": "00def789abc012", "C_": "02...sig5..."}, + {"amount": 4, "id": "00def789abc012", "C_": "02...sig6..."} + ] + } +} + +# Each BlindSignature uses the outcome collection-specific keyset ID +``` + +## Trading (Same-Keyset Swap) + +### Test 8: Trade swap request + +```shell +# Bob receives YES tokens from Alice and swaps at mint +# All inputs and outputs use same conditional keyset +swap_json: { + "inputs": [ + {"amount": 64, "id": "00abc123def456", "secret": "received_secret_1", "C": "02..."}, + {"amount": 32, "id": "00abc123def456", "secret": "received_secret_2", "C": "02..."} + ], + "outputs": [ + {"amount": 64, "id": "00abc123def456", "B_": "03..."}, + {"amount": 32, "id": "00abc123def456", "B_": "03..."} + ] +} + +# Standard NUT-03 swap within same keyset +# No oracle witness required +# Mint verifies proofs and signs outputs with YES conditional keyset keys +result: PASS +``` + +## Convert Operation — Merge + +A merge is a `POST /v1/ctf/convert` whose outputs are collateral under the reserved key `"*"`. + +### Test 9: Binary condition merge request + +```shell +# Condition parameters +condition_id: + +# Inputs (100 sats of each outcome collection using conditional keysets, fee F = 0) +# Outputs are collateral under "*" +request_json: { + "condition_id": "", + "inputs": { + "YES": [ + {"amount": 64, "id": "00abc123def456", "secret": "yes_secret_1", "C": "02..."}, + {"amount": 32, "id": "00abc123def456", "secret": "yes_secret_2", "C": "02..."}, + {"amount": 4, "id": "00abc123def456", "secret": "yes_secret_3", "C": "02..."} + ], + "NO": [ + {"amount": 64, "id": "00def789abc012", "secret": "no_secret_1", "C": "02..."}, + {"amount": 32, "id": "00def789abc012", "secret": "no_secret_2", "C": "02..."}, + {"amount": 4, "id": "00def789abc012", "secret": "no_secret_3", "C": "02..."} + ] + }, + "outputs": { + "*": [ + {"amount": 64, "id": "009a1f293253e41e", "B_": "03..."}, + {"amount": 32, "id": "009a1f293253e41e", "B_": "03..."}, + {"amount": 4, "id": "009a1f293253e41e", "B_": "03..."} + ] + } +} + +# Input proofs use conditional keysets, output BlindedMessages are collateral ("*") +# No oracle witness required (complete set cancels out) +output_total: 100 +``` + +### Test 10: Successful merge response + +```shell +# Response with signatures for collateral outputs under "*" (regular keyset) +response_json: { + "signatures": { + "*": [ + {"amount": 64, "id": "009a1f293253e41e", "C_": "02...sig1..."}, + {"amount": 32, "id": "009a1f293253e41e", "C_": "02...sig2..."}, + {"amount": 4, "id": "009a1f293253e41e", "C_": "02...sig3..."} + ] + } +} + +# Resulting proofs use regular keyset (not condition-specific) +``` + +## Redemption (Cross-Keyset Swap) + +### Test 11: Winner redemption via POST /v1/redeem_outcome + +```shell +# Oracle attests "YES" won +oracle_pubkey: 9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0 +oracle_sig: a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890 + +# YES holder redeems conditional keyset tokens for regular keyset tokens +redeem_json: { + "inputs": [ + { + "amount": 64, + "id": "00abc123def456", + "secret": "random_secret_yes_1", + "C": "02...", + "witness": "{\"oracle_sigs\":[{\"oracle_pubkey\":\"9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0\",\"oracle_sig\":\"a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890\"}]}" + } + ], + "outputs": [ + {"amount": 64, "id": "009a1f293253e41e", "B_": "03..."} + ] +} + +# Input: YES conditional keyset (00abc123def456) with oracle witness +# Output: regular keyset (009a1f293253e41e) +# Mint verifies oracle signature per NUT-CTF +result: PASS +``` + +### Test 12: Loser cannot redeem + +```shell +# Oracle attests "YES" won, but user holds NO tokens +oracle_pubkey: 9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0 +oracle_sig: a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890 + +# NO holder attempts to redeem via POST /v1/redeem_outcome +redeem_json: { + "inputs": [ + { + "amount": 64, + "id": "00def789abc012", + "secret": "random_secret_no_1", + "C": "02...", + "witness": "{\"oracle_sigs\":[{\"oracle_pubkey\":\"9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0\",\"oracle_sig\":\"a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890\"}]}" + } + ], + "outputs": [ + {"amount": 64, "id": "009a1f293253e41e", "B_": "03..."} + ] +} + +# Verification fails: oracle signed "YES" but input keyset is for "NO" +error_code: 13015 +error_message: "Oracle has not attested to this outcome collection" +``` + +## Error Cases + +### Test 13: Convert (split) payoff mismatch + +```shell +# Input total != output total for some outcome (out(YES) != in(YES) - F) +input_total: 100 +output_yes_total: 90 # Mismatch! +output_no_total: 100 + +error_code: 13041 +error_message: "Convert payoff/fee violation" +``` + +### Test 14: Missing outcome collection in outputs + +```shell +# Binary condition but only YES outputs provided +# in(NO) = 100, out(NO) = 0 -> out(NO) != in(NO) - F +outcome_collections: ["YES", "NO"] +outputs_provided: ["YES"] # Missing NO! + +error_code: 13041 +error_message: "Convert payoff/fee violation" +``` + +### Test 15: Invalid condition ID + +```shell +# Condition ID too short +condition_id: 3a7f8d2e1b4c5a6f # Only 16 hex chars (8 bytes) + +error_code: 13020 +error_message: "Invalid condition ID" +``` + +### Test 16: Condition not found + +```shell +# Valid format but non-existent condition +condition_id: ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff + +error_code: 13021 +error_message: "Condition not found" +``` + +### Test 17: Unequal outcome collection amounts + +```shell +# Different amounts for different outcomes (out(NO) != in(NO) - F) +input_total: 100 +output_yes_total: 100 +output_no_total: 50 # Different! + +error_code: 13041 +error_message: "Convert payoff/fee violation" +``` + +### Test 18: Convert (merge) payoff mismatch + +```shell +# Input amounts don't match across outcomes (in(YES) != in(NO)) +input_yes_total: 100 +input_no_total: 80 # Mismatch! + +error_code: 13041 +error_message: "Convert payoff/fee violation" +``` + +### Test 19: Missing outcome collection in merge inputs + +```shell +# Binary condition but only YES inputs provided +# in(NO) = 0 but out(NO) (from "*") = output_total -> violation +outcome_collections: ["YES", "NO"] +inputs_provided: ["YES"] # Missing NO! + +error_code: 13041 +error_message: "Convert payoff/fee violation" +``` + +### Test 20: Output amount mismatch in merge + +```shell +# Collateral output total doesn't equal per-outcome input total +input_yes_total: 100 +input_no_total: 100 +output_total: 50 # Should be 100 (F = 0)! + +error_code: 13041 +error_message: "Convert payoff/fee violation" +``` + +## Multi-Oracle Condition ID + +### Test 21: Multi-oracle condition ID calculation + +```shell +# Condition parameters (2-of-3 threshold) +oracle_pubkeys: [ + "9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0", + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890" +] +sorted_pubkeys: [ + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0", + "a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890" +] +event_id: "btc_price_100k_2025" +outcome_count: 2 + +# condition_id = tagged_hash("Cashu_condition_id", sorted_pubkeys || event_id || outcome_count) +# No outcome keyset identifiers — condition_id is keyset-independent +``` + +## Outcome Collections + +### Test 22: Split with outcome collections (3-outcome condition) + +```shell +# Condition with 3 outcomes, registered with root outcome collection keysets +outcomes: ["ALICE", "BOB", "CAROL"] + +# Condition registration returned these keysets +keysets: + "ALICE|BOB": 00aabb11cc22dd33 + "CAROL": 00ccdd44ee55ff66 + +# Convert (split) request with outcome collections +request_json: { + "condition_id": "", + "inputs": { + "*": [ + {"amount": 100, "id": "009a1f293253e41e", "secret": "secret1", "C": "02..."} + ] + }, + "outputs": { + "ALICE|BOB": [ + {"amount": 64, "id": "00aabb11cc22dd33", "B_": "03..."}, + {"amount": 32, "id": "00aabb11cc22dd33", "B_": "03..."}, + {"amount": 4, "id": "00aabb11cc22dd33", "B_": "03..."} + ], + "CAROL": [ + {"amount": 64, "id": "00ccdd44ee55ff66", "B_": "03..."}, + {"amount": 32, "id": "00ccdd44ee55ff66", "B_": "03..."}, + {"amount": 4, "id": "00ccdd44ee55ff66", "B_": "03..."} + ] + } +} + +# Payoff check +payoff_valid: true (ALICE|BOB and CAROL cover all outcomes, disjoint) +``` + +### Test 23: Outcome collection redemption (oracle signs covered outcome) + +```shell +# Token uses ALICE|BOB conditional keyset +keyset_id: 00aabb11cc22dd33 +outcome_collection_outcomes: ["ALICE", "BOB"] + +# Oracle signs "ALICE" +oracle_attested: "ALICE" +attested_in_set: true + +# Redemption succeeds (swap to regular keyset with witness) +can_redeem: true +``` + +### Test 24: Outcome collection redemption (oracle signs uncovered outcome) + +```shell +# Token uses ALICE|BOB conditional keyset +keyset_id: 00aabb11cc22dd33 +outcome_collection_outcomes: ["ALICE", "BOB"] + +# Oracle signs "CAROL" +oracle_attested: "CAROL" +attested_in_set: false + +# Redemption fails +can_redeem: false +error_code: 13015 +error_message: "Oracle has not attested to this outcome collection" +``` + +### Test 25: Duplicate canonical outcome collection error + +```shell +# Invalid request - duplicate after canonicalization +outcome_collections: ["ALICE|BOB", "BOB|ALICE"] +condition_outcomes: ["ALICE", "BOB", "CAROL"] + +# Validation fails +error_code: 13037 +error_message: "Duplicate canonical outcome collection" +``` + +### Test 26: Unknown outcome collection member error + +```shell +# Invalid request - DAVE is not in the oracle outcome list +outcome_collections: ["ALICE|DAVE"] +condition_outcomes: ["ALICE", "BOB", "CAROL"] + +# Validation fails +error_code: 13038 +error_message: "Unknown outcome in outcome collection" +``` + +### Test 27: Merge with outcome collections + +```shell +# Merge request with outcome collections +request_json: { + "condition_id": "", + "inputs": { + "ALICE|BOB": [ + {"amount": 100, "id": "00aabb11cc22dd33", "secret": "ab_secret_1", "C": "02..."} + ], + "CAROL": [ + {"amount": 100, "id": "00ccdd44ee55ff66", "secret": "carol_secret_1", "C": "02..."} + ] + }, + "outputs": { + "*": [ + {"amount": 64, "id": "009a1f293253e41e", "B_": "03..."}, + {"amount": 32, "id": "009a1f293253e41e", "B_": "03..."}, + {"amount": 4, "id": "009a1f293253e41e", "B_": "03..."} + ] + } +} + +# Input proofs use outcome collection keysets, outputs are collateral under "*" +# Valid merge - outcome collections form a complete disjoint cover +merge_result: SUCCESS +``` + +### Test 28: Escaped pipe character in outcome name + +```shell +# Outcome name containing pipe character +outcome_name: "A|B" +escaped_name: "A\\|B" + +# This is a single outcome, not an outcome collection +parsed_outcome: ["A|B"] # Single outcome with literal pipe +``` + +## Combinatorial Condition Tests + +### Test 29: Outcome collection ID computation + +```shell +# Outcome collection ID computation (NUT-CTF algorithm) +# outcome_collection_id(parent, condition_id, outcome_collection_string): +# h = tagged_hash("Cashu_outcome_collection_id", condition_id || outcome_collection_string_bytes) +# P = hash_to_curve(h) +# If parent is identity: return x_only(P) +# Else: return x_only(EC_add(lift_x(parent), P)) + +# Root condition (parent = identity/zero) +parent_collection_id: 0000000000000000000000000000000000000000000000000000000000000000 +condition_id_A: a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd +outcome_A: "YES" +outcome_A_utf8: 594553 + +# Step 1: h = tagged_hash("Cashu_outcome_collection_id", condition_id_A || "YES") +# Step 2: P_A = hash_to_curve(h) +# Step 3: outcome_collection_id_A = x_only(P_A) (parent is identity) +``` + +### Test 30: Combinatorial condition commutativity + +```shell +# Two conditions +election_condition_id: a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd +btc_price_condition_id: b2c3d4e5f67890123456789012345678901234567890123456789012345678ef + +# Path 1: Election first, then BTC price +# Step 1a: oc_A = outcome_collection_id(0, election_condition_id, "PARTY_A") +# Step 1b: oc_AB = outcome_collection_id(oc_A, btc_price_condition_id, "UP") + +# Path 2: BTC price first, then election +# Step 2a: oc_B = outcome_collection_id(0, btc_price_condition_id, "UP") +# Step 2b: oc_BA = outcome_collection_id(oc_B, election_condition_id, "PARTY_A") + +# Commutativity: oc_AB == oc_BA +# This holds because EC point addition is commutative: +# P_election_A + P_btc_UP = P_btc_UP + P_election_A +``` + +### Test 31: Nested parent collection rejected + +```shell +# Step 1a: Register root election condition (POST /v1/conditions) +root_condition_request: { + "threshold": 1, + "tags": [["description", "Election winner"]], + "announcements": [""], + "collateral": "sat" +} + +root_condition_response: { + "condition_id": "", + "keysets": { + "PARTY_A": "00aa11bb22cc33dd", + "PARTY_B": "00bb22cc33dd44ee" + } +} + +# Nested/combinatorial construction is out of scope for this version. +convert_request: { + "condition_id": "", + "parent_collection_id": "" +} + +error_code: 13041 +``` + +### Test 32: Nested condition split rejected + +```shell +# Non-zero parent_collection_id is reserved for future nested support. +request_json: { + "condition_id": "", + "parent_collection_id": "", + "inputs": {"*": [{"amount": 100, "id": "009a1f293253e41e", "secret": "secret", "C": "02..."}]}, + "outputs": {"UP": [{"amount": 99, "id": "00cc33dd44ee55ff", "B_": "03..."}]} +} + +error_code: 13041 +``` + +### Test 33: Nested condition merge rejected + +```shell +request_json: { + "condition_id": "", + "parent_collection_id": "", + "inputs": {"UP": [{"amount": 100, "id": "00cc33dd44ee55ff", "secret": "up_secret_1", "C": "02..."}]}, + "outputs": {"*": [{"amount": 99, "id": "009a1f293253e41e", "B_": "03..."}]} +} + +error_code: 13041 +``` + +### Test 34: Maximum depth unsupported + +```shell +# This version has no max_depth setting; all non-root parent_collection_id values are rejected. +error_code: 13041 +error_message: "Convert payoff/fee violation" +``` + +## Complete Flow Example + +### Test 35: End-to-end condition lifecycle + +```shell +# Step 1a: Register condition (POST /v1/conditions) +condition_id: + +# Step 1b: Keysets returned by condition registration +keysets: + YES: 00abc123def456 + NO: 00def789abc012 + +# Step 2: Alice splits 100 sats +alice_input: 100 sats (regular keyset 009a1f293253e41e) +alice_receives: 100 sats YES tokens (keyset 00abc123def456) + 100 sats NO tokens (keyset 00def789abc012) + +# Step 3: Alice sells NO tokens to Bob for 40 sats +# Bob swaps at mint: input NO keyset -> output NO keyset (standard NUT-03 swap) + +# Step 4: Oracle attests "YES" +oracle_pubkey: 9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0 +attested_outcome: "YES" +oracle_sig: + +# Step 5: Alice redeems YES tokens +# Redeem via POST /v1/redeem_outcome: input YES keyset (00abc123def456) + witness -> output regular keyset (009a1f293253e41e) +alice_redeems: 100 sats YES tokens +alice_receives: 100 sats regular ecash + +# Step 6: Bob cannot redeem NO tokens +# Redeem via POST /v1/redeem_outcome: input NO keyset (00def789abc012) + witness -> FAILS +bob_attempts: 100 sats NO tokens +bob_result: FAIL (oracle signed YES, not NO) + +# Net result: +# - Alice: started with 100 sats, now has 100 sats + 40 sats from sale = 140 sats +# - Bob: paid 40 sats for worthless NO tokens = -40 sats +``` + +## Convert — Recombine and Conversion + +### Test 36: Recombine (floor 0, fee paid as collateral) + +```shell +# Condition Omega = {A, B, C, D}; registered collections include A, B|C, A|B|C +# Holder of A(1) + B|C(1) recombines into A|B|C(1). Fee F = 1, so the holder +# adds 1 sat of collateral under "*" to satisfy in(o) >= F on every outcome. + +request_json: { + "condition_id": "", + "inputs": { + "*": [{"amount": 1, "id": "009a1f293253e41e", "secret": "c1", "C": "02..."}], + "A": [{"amount": 1, "id": "00a000...", "secret": "a1", "C": "02..."}], + "B|C": [{"amount": 1, "id": "00bc00...", "secret": "bc1", "C": "02..."}] + }, + "outputs": { + "A|B|C": [{"amount": 1, "id": "00abc0...", "B_": "03..."}] + } +} + +# Per-outcome: in = (A:2, B:2, C:2, D:1), out = (A:1, B:1, C:1, D:0) +# out(o) == in(o) - 1 for every o. Mint retains 1 on every outcome. +result: PASS +``` + +### Test 37: Conversion (negative-risk style, collateral on output) + +```shell +# Condition Omega = {A, B, C, D}; registered collections A|B|C, B|C|D, B|C. Fee F = 1. +request_json: { + "condition_id": "", + "inputs": { + "A|B|C": [{"amount": 100, "id": "00abc0...", "secret": "s1", "C": "02..."}], + "B|C|D": [{"amount": 100, "id": "00bcd0...", "secret": "s2", "C": "02..."}] + }, + "outputs": { + "*": [{"amount": 99, "id": "009a1f293253e41e", "B_": "03..."}], + "B|C": [{"amount": 100, "id": "00bc00...", "B_": "03..."}] + } +} + +# Per-outcome: in = (A:100, B:200, C:200, D:100), out = (A:99, B:199, C:199, D:99) +# out(o) == in(o) - 1 for every o. 99 sats withdrawn as spendable collateral, no witness. +result: PASS +``` + +### Test 38: Convert fee not covered on some outcome + +```shell +# Pure recombine A(1) + B|C(1) -> A|B|C(1) WITHOUT adding collateral, F = 1. +# in = (A:1, B:1, C:1, D:0); in(D) = 0 < F -> rejected. +error_code: 13041 +error_message: "Convert payoff/fee violation" +``` + +### Test 39: Convert after attestation recorded + +```shell +# Oracle attestation already recorded for the condition. +error_code: 13042 +error_message: "Convert not permitted for this condition" +``` + +### Test 40: Full-set outcome collection rejected at registration + +```shell +# Attempt to register an outcome collection that covers all outcomes +outcome_collections: ["ALICE|BOB|CAROL"] +error_code: 13043 +error_message: "Full-set or reserved outcome collection" +``` + +[NUT-CTF-split-merge]: ../CTF-split-merge.md +[NUT-CTF]: ../CTF.md diff --git a/tests/CTF-tests.md b/tests/CTF-tests.md new file mode 100644 index 00000000..f9b894bb --- /dev/null +++ b/tests/CTF-tests.md @@ -0,0 +1,432 @@ +# NUT-CTF Test Vectors + +These test vectors provide reference data for implementing conditional keysets. All values are hex-encoded for reproducibility. + +## Conditional Token Structure (Per-Condition Keysets) + +### Test 1: YES conditional token with conditional keyset + +```shell +# Market registration returned keysets: +# YES -> keyset_id: 00abc123def456 +# NO -> keyset_id: 00def789abc012 + +# YES token proof (regular random secret, conditional keyset) +amount: 64 +keyset_id: 00abc123def456 +secret: d341ee4871f1f889041e63cf0d3823c713eea6aff01e80f1719f08f9e5be98f6 +C: 02 + +# The keyset ID identifies this as a YES conditional token +# The secret is a regular random string (no NUT-10 structure) +``` + +### Test 2: NO conditional token with conditional keyset + +```shell +# Same market as Test 1 +# NO token proof (regular random secret, conditional keyset) +amount: 64 +keyset_id: 00def789abc012 +secret: 99fce58439fc37412ab3468b73db0569322588f62fb3a49182d67e23d877824a +C: 02 + +# The keyset ID identifies this as a NO conditional token +``` + +## Outcome Collection ID Computation + +### Test 3: Outcome Collection ID (root condition) + +```shell +# outcome_collection_id(parent_collection_id, condition_id, outcome_collection_string): +# 1. h = tagged_hash("Cashu_outcome_collection_id", condition_id || outcome_collection_string_bytes) +# 2. P = hash_to_curve(h) +# 3. If parent_collection_id is identity (32 zero bytes): return x_only(P) +# Else: return x_only(EC_add(lift_x(parent_collection_id), P)) + +# Tag preimage +tag: "Cashu_outcome_collection_id" +tag_utf8: 43617368755f6f7574636f6d655f636f6c6c656374696f6e5f6964 +tag_hash: SHA256(tag_utf8) + +# Outcome collection: "YES" +outcome_collection_string: "YES" +outcome_collection_utf8: 594553 + +# Condition ID (32 bytes) +condition_id: 3a7f8d2e1b4c5a6f9e0d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f + +# Parent collection ID (root condition = identity) +parent_collection_id: 0000000000000000000000000000000000000000000000000000000000000000 + +# Step 1: h = tagged_hash("Cashu_outcome_collection_id", condition_id || outcome_collection_utf8) +# msg = condition_id || outcome_collection_utf8 +msg: 3a7f8d2e1b4c5a6f9e0d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f || 594553 +# h = SHA256(tag_hash || tag_hash || msg) + +# Step 2: P = hash_to_curve(h) +# hash_to_curve as defined in NUT-00 + +# Step 3: parent is identity, so outcome_collection_id = x_only(P) +# Result is a 32-byte x-only public key (64 hex chars) +``` + +### Test 4: Outcome Collection ID for outcome collection + +```shell +# Outcome collection: "ALICE|BOB" (outcome collection covering two outcomes) +outcome_collection_string: "ALICE|BOB" +outcome_collection_utf8: 414c4943457c424f42 + +# Condition ID (32 bytes) +condition_id: 7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d + +# Parent collection ID (root condition = identity) +parent_collection_id: 0000000000000000000000000000000000000000000000000000000000000000 + +# Step 1: h = tagged_hash("Cashu_outcome_collection_id", condition_id || outcome_collection_utf8) +# msg = condition_id || outcome_collection_utf8 +msg: 7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d || 414c4943457c424f42 + +# Step 2: P = hash_to_curve(h) +# Step 3: parent is identity, so outcome_collection_id = x_only(P) + +# Different outcome collection strings produce different outcome_collection_ids +# Different condition_ids produce different outcome_collection_ids +``` + +## Witness Validation + +### Test 5: Valid oracle redemption witness (enum) + +```shell +# Oracle signature on "YES" +oracle_pubkey: 9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0 +oracle_sig: a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890 + +# Witness JSON (oracle_sigs array format) +witness_json: {"oracle_sigs":[{"oracle_pubkey":"9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0","oracle_sig":"a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890"}]} + +# Redemption via POST /v1/redeem_outcome: conditional keyset -> regular keyset +input_keyset_id: 00abc123def456 # YES conditional keyset +output_keyset_id: 009a1f293253e41e # regular keyset + +# Oracle signature verification +outcome: "YES" +signature_check: PASS +``` + +### Test 6: Invalid oracle signature + +```shell +# Attempt to redeem with invalid signature +oracle_pubkey: 9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0 +oracle_sig: ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff + +# Witness JSON (oracle_sigs array format) +witness_json: {"oracle_sigs":[{"oracle_pubkey":"9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0","oracle_sig":"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"}]} + +# Redemption via POST /v1/redeem_outcome: conditional keyset -> regular keyset +input_keyset_id: 00abc123def456 # YES conditional keyset +output_keyset_id: 009a1f293253e41e # regular keyset + +# Oracle signature verification +outcome: "YES" +signature_check: FAIL +error_code: 13010 +``` + +### Test 7: Redemption without witness + +```shell +# Attempt to redeem via POST /v1/redeem_outcome without witness +input_keyset_id: 00abc123def456 # YES conditional keyset +output_keyset_id: 009a1f293253e41e # regular keyset +witness: null + +# POST /v1/redeem_outcome requires oracle witness +error_code: 13014 +error_message: "Conditional keyset requires oracle witness" +``` + +## Trading (Same-Keyset Swap) + +### Test 8: Valid trade swap (no witness needed) + +```shell +# Swap within same conditional keyset (trading) +input_keyset_id: 00abc123def456 # YES conditional keyset +output_keyset_id: 00abc123def456 # same YES conditional keyset + +# No witness needed - same keyset swap +witness: null +result: PASS +``` + +### Test 9: Three-outcome market keysets + +```shell +# Market with three outcomes +oracle_pubkey: 9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0 +event: "election_2024_winner" +outcomes: ["CANDIDATE_A", "CANDIDATE_B", "CANDIDATE_C"] + +# Market registration returns 3 conditional keysets +keysets: + CANDIDATE_A: 00aa11bb22cc33dd + CANDIDATE_B: 00bb22cc33dd44ee + CANDIDATE_C: 00cc33dd44ee55ff + +# Oracle signs CANDIDATE_B +signed_outcome: "CANDIDATE_B" +oracle_sig: + +# Only CANDIDATE_B keyset holders can redeem (swap to regular keyset with witness) +# CANDIDATE_A and CANDIDATE_C keyset holders cannot redeem +``` + +## Multi-Oracle Tests + +### Test 10: Two-of-three oracle threshold + +```shell +# Three oracle announcements +oracle_1_pubkey: 79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 +oracle_2_pubkey: 9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0 +oracle_3_pubkey: a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890 + +threshold: 2 +event_id: "btc_price_100k_2025" +outcomes: ["YES", "NO"] + +# Each oracle has their own announcement with their nonce +announcement_1: d834 +announcement_2: d834 +announcement_3: d834 + +# Condition ID computation (sorted pubkeys, tagged hash) +sorted_pubkeys: [oracle_1_pubkey, oracle_2_pubkey, oracle_3_pubkey] # lexicographic +condition_id: tagged_hash("Cashu_condition_id", sorted_pubkeys || event_id || outcome_count) + +# Attestations from oracles 1 and 2 (meets threshold) +oracle_1_sig_YES: <64_byte_signature_from_oracle_1> +oracle_2_sig_YES: <64_byte_signature_from_oracle_2> + +# Verification with 2 signatures +verification: PASS (threshold met: 2 >= 2) +``` + +### Test 11: Multi-oracle threshold not met + +```shell +# Same setup as Test 10 +threshold: 2 + +# Only 1 attestation provided +oracle_1_sig_YES: <64_byte_signature_from_oracle_1> + +# Verification fails +verification: FAIL +error_code: 13027 # Oracle threshold not met +``` + +## Registration Fee Tests + +### Test 12: Per-unit registration fee calculation + +```shell +# Mint info advertises different fee schedules per collateral unit +mint_info_ctf: { + "supported": true, + "dlc_version": "0", + "default_keyset_creation": "one-vs-rest", + "registration_fees": [ + { + "unit": "sat", + "registration_fee_base": 10, + "registration_fee_per_keyset": 2 + }, + { + "unit": "usd", + "registration_fee_base": 50, + "registration_fee_per_keyset": 5 + } + ] +} + +# Binary condition with collateral "sat" creates YES and NO keysets +collateral: "sat" +num_keysets: 2 + +# Required fee uses the matching "sat" schedule, not the "usd" schedule +required_fee: 10 + 2 * 2 = 14 + +register_request: { + "threshold": 1, + "tags": [["description", "Will BTC reach $100k?"]], + "announcements": [""], + "collateral": "sat", + "fee": [ + {"amount": 16, "id": "009a1f293253e41e", "secret": "fee_secret", "C": "02..."} + ], + "outputs": [ + {"amount": 1, "id": "009a1f293253e41e", "B_": "03..."} + ] +} + +# Mint retains exactly 14 sat and returns 2 sat as regular ecash change +change_amount: 2 +result: PASS +``` + +### Test 13: Missing unit fee schedule rejects registration + +```shell +# Mint info has no entry for "eurc" +mint_info_ctf: { + "supported": true, + "dlc_version": "0", + "default_keyset_creation": "one-vs-rest", + "registration_fees": [ + { + "unit": "sat", + "registration_fee_base": 10, + "registration_fee_per_keyset": 2 + } + ] +} + +register_request: { + "threshold": 1, + "tags": [["description", "EURC market"]], + "announcements": [""], + "collateral": "eurc" +} + +# No "eurc" fee schedule is advertised, so CTF registration for "eurc" is unsupported +error_code: 13048 +error_message: "Unsupported CTF collateral unit" +``` + +### Test 14: Registration fee proofs must match collateral unit + +```shell +# Mint info requires 14 sat for this binary condition +mint_info_ctf: { + "supported": true, + "dlc_version": "0", + "default_keyset_creation": "one-vs-rest", + "registration_fees": [ + { + "unit": "sat", + "registration_fee_base": 10, + "registration_fee_per_keyset": 2 + } + ] +} + +register_request: { + "threshold": 1, + "tags": [["description", "Will BTC reach $100k?"]], + "announcements": [""], + "collateral": "sat", + "fee": [ + {"amount": 50, "id": "00usdregularkeyset", "secret": "fee_secret", "C": "02..."} + ] +} + +# Fee proof keyset unit is "usd", but collateral is "sat" +error_code: 13017 +error_message: "Invalid keyset for collateral/output side" +``` + +## Error Validation Tests + +### Test 15: Outcome collection not attested by oracle + +```shell +# Attempt to claim with outcome collection not matching attestation +outcomes: ["YES", "NO"] +outcome: "MAYBE" +error_code: 13015 +error_message: "Oracle has not attested to this outcome collection" +``` + +### Test 16: Invalid oracle public key format + +```shell +# 33-byte compressed key instead of 32-byte x-only +oracle_pubkey: 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 +error: Invalid oracle public key format +error_code: 13010 +``` + +### Test 17: Swap spanning different outcome collections + +```shell +# Attempt NUT-03 swap with inputs from YES keyset and outputs to NO keyset +swap_json: { + "inputs": [ + {"amount": 64, "id": "00abc123def456", "secret": "yes_secret_1", "C": "02..."} + ], + "outputs": [ + {"amount": 64, "id": "00def789abc012", "B_": "03..."} + ] +} + +# Input keyset (00abc123def456) has outcome_collection_id for YES +# Output keyset (00def789abc012) has outcome_collection_id for NO +# Different outcome_collection_id values — MUST reject +error_code: 13016 +error_message: "Conditional keyset swap spans different outcome collections" +``` + +### Test 18: Redemption outputs must use regular keyset + +```shell +# Attempt POST /v1/redeem_outcome with conditional keyset in outputs +redeem_json: { + "inputs": [ + { + "amount": 64, + "id": "00abc123def456", + "secret": "yes_secret_1", + "C": "02...", + "witness": "{\"oracle_sigs\":[{\"oracle_pubkey\":\"9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0\",\"oracle_sig\":\"a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890\"}]}" + } + ], + "outputs": [ + {"amount": 64, "id": "00def789abc012", "B_": "03..."} + ] +} + +# Output keyset (00def789abc012) is a conditional keyset, not a regular keyset +error_code: 13017 +error_message: "Outputs must use a regular keyset" +``` + +### Test 19: Re-register existing condition with different config + +```shell +# Condition already registered with threshold=1 +# Attempt to register same oracle event with threshold=2 +register_request_1: { + "threshold": 1, + "tags": [["description", "Will BTC reach $100k?"]], + "announcements": [""] +} +# Returns: {"condition_id": ""} + +register_request_2: { + "threshold": 2, + "tags": [["description", "Will BTC reach $100k?"]], + "announcements": [""] +} + +# Same announcements but different threshold — conflict +error_code: 13028 +error_message: "Condition already exists" +``` + +[NUT-CTF]: ../CTF.md