diff --git a/CTF-numeric.md b/CTF-numeric.md new file mode 100644 index 00000000..88d186d0 --- /dev/null +++ b/CTF-numeric.md @@ -0,0 +1,222 @@ +# 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://docs.gnosis.io/conditionaltokens/) 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 partition is 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: `amount - floor(amount * hi_payout_ratio)` (ensures no rounding loss) + +**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 + +```json +{ + "threshold": 1, + "description": "BTC/USD price on 2025-07-01", + "announcements": [ + "" + ], + "condition_type": "numeric", + "lo_bound": 0, + "hi_bound": 100000, + "precision": 0 +} +``` + +- `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 Body + +```json +{ + "condition_id": "" +} +``` + +After condition registration, the wallet registers the partition via `POST /v1/conditions/{condition_id}/partitions` ([NUT-CTF][CTF]) with `"partition": ["HI", "LO"]` and the desired `collateral` to create the conditional 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 partition is always `["HI", "LO"]` and is registered separately via `POST /v1/conditions/{condition_id}/partitions` ([NUT-CTF][CTF]). + +## 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": "<32-byte x-only key>", + "digit_sigs": [ + "<64-byte Schnorr sig on digit 0>", + "<64-byte Schnorr sig on digit 1>", + "<64-byte Schnorr sig on digit N>" + ] + } + ] +} +``` + +- `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: `100 - floor(100 * 0.2)` = 80 sats regular ecash + +### Conservation + +The mint MUST ensure that for a given face `amount`, total HI redemption + total LO redemption = `amount` (minus fees). The `amount - floor(amount * hi_payout_ratio)` formula for LO guarantees this by avoiding independent rounding. + +## Split and Merge + +Split and merge operations work identically to [NUT-CTF-split-merge][CTF-split-merge] enum conditions: + +- **Split**: Deposit collateral, receive equal amounts of HI and LO tokens +- **Merge**: Surrender equal amounts of HI and LO tokens, receive collateral back + +No special handling is needed — numeric conditions always have exactly 2 outcome collections (`HI`, `LO`). + +## Combinatorial Markets + +Numeric conditions can participate in [NUT-CTF-split-merge][CTF-split-merge] combinatorial markets. The `parent_collection_id` and `collateral` fields work the same way as for enum conditions. For example, a user could split election tokens into numeric BTC price sub-conditions. + +## 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-split-merge.md b/CTF-split-merge.md new file mode 100644 index 00000000..fef30db0 --- /dev/null +++ b/CTF-split-merge.md @@ -0,0 +1,525 @@ +# NUT-CTF-split-merge: Conditional Token Split and Merge + +`optional` + +`depends on: NUT-CTF` + +--- + +This NUT defines split and merge operations for conditional tokens ([NUT-CTF][CTF]). Inspired by the [Gnosis Conditional Token Framework](https://docs.gnosis.io/conditionaltokens/), these operations allow users to deposit collateral and receive complete sets of conditional tokens for a condition, or merge complete sets back into collateral. For conditional token definitions, keysets, conditions, and redemption, see [NUT-CTF][CTF]. + +Caution: This specification relies on [NUT-CTF][CTF] for oracle attestation. Applications must verify that the mint supports both NUT-CTF and NUT-CTF-split-merge by checking the mint's [info][06] endpoint. + +## Terminology + +This NUT uses the terminology defined in [NUT-CTF][CTF], including **condition**, **outcome collection**, **partition**, **condition ID**, **outcome collection ID**, **conditional keyset**, and **conditional token**. + +No additional terms are introduced by this NUT. + +## Motivation + +Conditional tokens enable powerful prediction market use cases: + +1. **Complete outcome collection sets** - Users receive tokens for ALL outcome collections, ensuring the condition is fully collateralized +2. **Trading** - Conditional tokens can be freely traded before resolution via standard [NUT-03][03] swaps +3. **Atomic splits** - A single operation creates all conditional tokens, preventing partial positions +4. **Conditional keysets** - Each outcome collection has a unique keyset, enabling trustless token identification +5. **Outcome collections** - Tokens can represent multiple outcomes (e.g., "not Trump" covering all other candidates) +6. **Combinatorial markets** - Hierarchical nesting of conditions via collection IDs enables complex multi-condition markets + +By following the Gnosis CTF model, this specification provides a proven approach to conditional tokens adapted for the Cashu ecosystem. + +## Overview + +The CTF lifecycle consists of: + +1. **Register**: Condition is registered via [NUT-CTF][CTF], then partition is registered to create conditional keysets +2. **Split**: User deposits collateral -> receives complete set of conditional tokens (conditional keysets) +3. **Trade**: Users trade conditional tokens (standard NUT-03 swaps within same conditional keyset) +4. **Oracle Attests**: Oracle signs the winning outcome +5. **Redeem**: Winners redeem conditional keyset tokens for regular keyset tokens (`POST /v1/redeem_outcome` + oracle witness, [NUT-CTF][CTF]) + +``` + Register Split Trade Attest Redeem +Wallet ────────────► Mint User ──────────────► Conditional ◄────────────► Oracle ────────► Winner ──────────► + cond. info creates 100 sats Tokens NUT-03 Signs redeem_outcome + keysets (YES+NO Swap Outcome → Regular + keysets) Keyset +``` + +## Split Operation + +The split operation allows users to deposit collateral and receive a complete set of conditional tokens. For every unit of collateral deposited, the user receives one token for each possible outcome collection. + +Conditions must be registered via `POST /v1/conditions` and partitions via `POST /v1/conditions/{condition_id}/partitions` ([NUT-CTF][CTF]) before splitting. The wallet needs the keyset IDs returned by partition registration to construct `BlindedMessage`s. + +### Endpoint + +``` +POST /v1/ctf/split +``` + +### Request Body + +```json +{ + "condition_id": , + "inputs": , + "outputs": { + "": , + "": , + ... + } +} +``` + +- `condition_id`: The 32-byte condition identifier (64 hex characters). Must reference a registered condition ([NUT-CTF][CTF]). Returns error 13021 (Condition not found) if unknown. +- `inputs`: Array of `Proof` objects used as collateral (see [NUT-00][00]). For root conditions, these use a **regular keyset**. For nested conditions, these use the **conditional keyset** identified by the parent collection's outcome collection (see [Combinatorial Markets](#combinatorial-markets)). +- `outputs`: Object mapping each outcome collection to an array of `BlindedMessage` objects. Each `BlindedMessage` MUST use the **outcome-collection-specific keyset ID** returned during condition registration. Keys use pipe-separated notation for outcome collections (e.g., `"ALICE|BOB"`). + +If the mint returns error 13021 (Condition not found), the wallet SHOULD register the condition and partition using the [NUT-CTF][CTF] registration endpoints and retry the split. + +### Output Requirements + +1. The output keys MUST form a previously registered partition (keysets must exist for all outcome collections). If keysets do not exist, the wallet should register the partition first via `POST /v1/conditions/{condition_id}/partitions` ([NUT-CTF][CTF]). +2. Each output key's total amount MUST be identical +3. Each `BlindedMessage` MUST use the keyset ID corresponding to its outcome collection +4. Fees are deducted from collateral inputs before splitting: `sum(each_outcome_collection_outputs) = sum(inputs) - fees(inputs)`, where `fees(inputs)` is calculated per [NUT-02][02] from each input proof's keyset `input_fee_ppk` + +For example, with 100 sats collateral in a binary market: + +- `inputs`: 100 sats total (regular keyset `009a1f293253e41e`) +- `outputs["YES"]`: 100 sats total in blinded messages (conditional keyset `00abc123def456`) +- `outputs["NO"]`: 100 sats total in blinded messages (conditional keyset `00def789abc012`) + +With outcome collections in a 3-outcome condition: + +- `inputs`: 100 sats total (regular keyset) +- `outputs["ALICE|BOB"]`: 100 sats total in blinded messages (conditional keyset for ALICE|BOB) +- `outputs["CAROL"]`: 100 sats total in blinded messages (conditional keyset for CAROL) + +### Mint Behavior + +When processing a split request, the mint: + +1. Looks up the condition by `condition_id` +2. Returns error 13021 (Condition not found) if the condition is not registered +3. Validates that output keys form a valid partition (error 13037 if overlapping, error 13038 if incomplete) +4. Validates that keysets exist for all outcome collections in the output (error 12001 if any keyset is unknown — the wallet should register the partition first) +5. Validates each `BlindedMessage` uses the correct conditional keyset ID +6. Validates that all outcome collections have the same total output amount, and that this amount equals `sum(inputs) - fees(inputs)` +7. Signs the blinded messages using the outcome-collection-specific keyset keys + +### Response Body + +```json +{ + "signatures": { + "": , + "": , + ... + } +} +``` + +- `signatures`: Object mapping each outcome collection to an array of `BlindSignature` objects + +Each `BlindSignature` corresponds to a `BlindedMessage` in the request, creating conditional tokens signed under the conditional keyset. + +## Merge Operation + +The merge operation allows users to combine a complete set of conditional tokens back into collateral. This is the inverse of split: for every unit of each conditional token surrendered, the user receives one unit of collateral. + +The condition and partition must have been registered via [NUT-CTF][CTF]. Use the `condition_id` from condition registration. + +### Endpoint + +``` +POST /v1/ctf/merge +``` + +### Request Body + +```json +{ + "condition_id": , + "inputs": { + "": , + "": , + ... + }, + "outputs": +} +``` + +- `condition_id`: The 32-byte condition identifier (64 hex characters). Must reference a registered condition. Returns error 13021 (Condition not found) if unknown. +- `inputs`: Object mapping each outcome collection to an array of `Proof` objects. Each `Proof` uses the **outcome-collection-specific keyset ID** for its outcome collection. Keys use pipe-separated notation for outcome collections. +- `outputs`: Array of `BlindedMessage` objects for the collateral to receive. For root conditions, these use a **regular keyset ID**. For nested conditions, these use the **parent outcome collection's keyset ID**. + +### Input Requirements + +1. The input keys MUST form a valid partition of all outcomes (see [Partition Rules][CTF]) +2. Each input key's amount MUST be identical +3. Each `Proof` MUST use the keyset ID corresponding to its outcome collection +4. Fees are deducted from conditional inputs before producing collateral: `sum(outputs) = per_outcome_collection_amount - fees(all_inputs)`, where `fees(all_inputs)` is calculated per [NUT-02][02] from all input proofs across all outcome collections + +For example, in a binary market with 100 sats of each conditional token: + +- `inputs["YES"]`: 100 sats total (conditional keyset `00abc123def456`) +- `inputs["NO"]`: 100 sats total (conditional keyset `00def789abc012`) +- `outputs`: 100 sats total in blinded messages (regular keyset `009a1f293253e41e`) + +With outcome collections in a 3-outcome condition: + +- `inputs["ALICE|BOB"]`: 100 sats total (conditional keyset for ALICE|BOB) +- `inputs["CAROL"]`: 100 sats total (conditional keyset for CAROL) +- `outputs`: 100 sats total in blinded messages (regular keyset) + +### Response Body + +```json +{ + "signatures": +} +``` + +Each `BlindSignature` corresponds to a `BlindedMessage` in the request, creating regular (non-conditional) proofs. + +### Merge Verification + +The mint MUST verify: + +1. All input proofs use valid conditional keysets for the specified condition +2. All outcome collections for the condition are present (complete partition) +3. All outcome collection amounts are equal +4. Output amount equals the per-outcome-collection input amount +5. No oracle witness is required -- the complete set cancels out all risk + +## Trading and Redemption + +See [NUT-CTF][CTF] for trading (NUT-03 swap within the same conditional keyset) and redemption (`POST /v1/redeem_outcome` with oracle witness). Wallets SHOULD remove conditional tokens for non-winning outcome collections from the user's balance after oracle attestation. + +## Combinatorial Markets + +Combinatorial markets allow conditions to be nested hierarchically. For example, a user could bet on "Party A wins the election AND BTC price is above $100k" by splitting a "Party A" conditional token into sub-outcome-collections for BTC price. + +Following the [Gnosis CTF](https://docs.gnosis.io/conditionaltokens/) design, combinatorial markets use **outcome collection IDs** — points on the secp256k1 elliptic curve — to ensure order-independent nesting (commutativity). The key insight: `(A|B) & (LO)` must produce the same token regardless of whether you split Choice→Score or Score→Choice. + +Outcome collection IDs are computed using the algorithm defined in [NUT-CTF][CTF]. Because EC point addition is commutative, nesting order does not matter — see [NUT-CTF Outcome Collection ID][CTF] for details. + +### Split/Merge for Nested Markets + +When `parent_collection_id` is non-zero: + +- **Split inputs**: Instead of regular keyset tokens, inputs MUST use the conditional keyset identified by the parent collection's outcome collection +- **Merge outputs**: Instead of regular keyset tokens, outputs use the parent collection's conditional keyset +- **Redemption**: `POST /v1/redeem_outcome` outputs go to the parent collection's keyset instead of a regular keyset + +### Combinatorial Market Example + +Consider two markets: + +1. **Election**: outcomes `["PARTY_A", "PARTY_B"]` +2. **BTC Price**: outcomes `["UP", "DOWN"]` + +A user wants to bet on "Party A wins AND BTC goes up": + +``` +Step 1: Split 100 sat → PARTY_A (100 sat) + PARTY_B (100 sat) + [root market, parent_collection_id = 0] + +Step 2: Split PARTY_A (100 sat) → PARTY_A&UP (100 sat) + PARTY_A&DOWN (100 sat) + [nested market, parent_collection_id = outcome_collection_id(0, election_condition_id, "PARTY_A")] + [collateral = outcome_collection_id of PARTY_A in the election market] + +Step 3: Sell PARTY_A&DOWN tokens, keep PARTY_A&UP tokens + +Step 4a: Election oracle attests "PARTY_A" → redeem PARTY_A&UP to PARTY_A tokens +Step 4b: BTC oracle attests "UP" → redeem PARTY_A tokens to regular sat +``` + +The order of Steps 4a and 4b can be reversed because of outcome collection ID commutativity. + +## Error Codes + +| Code | Description | +| ----- | -------------------------------- | +| 13021 | Condition not found | +| 13022 | Split amount mismatch | +| 13024 | Condition not active | +| 13025 | Merge amount mismatch | +| 13037 | Overlapping outcome collections | +| 13038 | Incomplete partition | +| 13040 | Maximum condition depth exceeded | + +## Complete Example + +### Step 1a: Register Condition + +First, register the condition via `POST /v1/conditions` ([NUT-CTF][CTF]): + +**Request:** + +```json +{ + "threshold": 1, + "description": "Will BTC reach $100k by June 2025?", + "announcements": ["fdd824fd<...hex-encoded oracle_announcement TLV...>"] +} +``` + +**Response:** + +```json +{ + "condition_id": "a1b2c3d4e5f67890..." +} +``` + +The mint parses the announcement TLV, verifies the announcement signature, and stores the condition configuration. + +### Step 1b: Register Partition + +Then, register the partition via `POST /v1/conditions/{condition_id}/partitions` ([NUT-CTF][CTF]): + +**Request:** + +```json +{ + "collateral": "sat", + "partition": ["YES", "NO"] +} +``` + +**Response:** + +```json +{ + "keysets": { + "YES": "00abc123def456", + "NO": "00def789abc012" + } +} +``` + +The mint creates conditional keysets for each outcome collection and returns the keyset IDs. + +### Step 2: Split Collateral + +Alice wants to participate with 100 sats. She uses the `condition_id` and conditional keyset IDs from registration: + +**Request:** + +```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..." } + ] + } +} +``` + +**Response:** + +```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 worth of YES tokens (keyset `00abc123def456`) +- 100 sats worth of NO tokens (keyset `00def789abc012`) + +### Step 3: Trading + +Alice believes YES will win, so she sells her NO tokens to Bob for 40 sats. This is a normal Cashu token transfer -- Alice serializes the NO keyset proofs and sends them to Bob. Bob swaps at the mint: + +```json +{ + "inputs": [ + { + "amount": 64, + "id": "00def789abc012", + "secret": "bobs_received_secret_1", + "C": "02..." + }, + { + "amount": 32, + "id": "00def789abc012", + "secret": "bobs_received_secret_2", + "C": "02..." + }, + { + "amount": 4, + "id": "00def789abc012", + "secret": "bobs_received_secret_3", + "C": "02..." + } + ], + "outputs": [ + { "amount": 64, "id": "00def789abc012", "B_": "03..." }, + { "amount": 32, "id": "00def789abc012", "B_": "03..." }, + { "amount": 4, "id": "00def789abc012", "B_": "03..." } + ] +} +``` + +All inputs and outputs use the NO conditional keyset (`00def789abc012`). No oracle witness is needed. + +### Step 4: Oracle Attestation + +On July 1, 2025, the oracle attests that YES won by publishing a signature: + +``` +outcome = "YES" +signature = Sign(sk, "YES", "attestation/v0") +``` + +### Step 5: Winner Redemption + +Alice redeems her YES tokens via `POST /v1/redeem_outcome`: + +```json +{ + "inputs": [ + { + "amount": 64, + "id": "00abc123def456", + "secret": "alices_yes_secret_1", + "C": "02...", + "witness": "{\"oracle_sigs\":[{\"oracle_pubkey\":\"9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0\",\"oracle_sig\":\"9a0b85c6e2d8f1234567890abcdef1234567890abcdef1234567890abcdef12349a0b85c6e2d8f1234567890abcdef1234567890abcdef1234567890abcdef1234\"}]}" + }, + { + "amount": 32, + "id": "00abc123def456", + "secret": "alices_yes_secret_2", + "C": "02...", + "witness": "{\"oracle_sigs\":[{\"oracle_pubkey\":\"9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0\",\"oracle_sig\":\"9a0b85c6e2d8f1234567890abcdef1234567890abcdef1234567890abcdef12349a0b85c6e2d8f1234567890abcdef1234567890abcdef1234567890abcdef1234\"}]}" + }, + { + "amount": 4, + "id": "00abc123def456", + "secret": "alices_yes_secret_3", + "C": "02...", + "witness": "{\"oracle_sigs\":[{\"oracle_pubkey\":\"9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0\",\"oracle_sig\":\"9a0b85c6e2d8f1234567890abcdef1234567890abcdef1234567890abcdef12349a0b85c6e2d8f1234567890abcdef1234567890abcdef1234567890abcdef1234\"}]}" + } + ], + "outputs": [ + { "amount": 64, "id": "009a1f293253e41e", "B_": "03..." }, + { "amount": 32, "id": "009a1f293253e41e", "B_": "03..." }, + { "amount": 4, "id": "009a1f293253e41e", "B_": "03..." } + ] +} +``` + +This request is sent to `POST /v1/redeem_outcome`. Inputs use the YES conditional keyset (`00abc123def456`) with `oracle_sigs` witness. Outputs use the regular keyset (`009a1f293253e41e`). The mint verifies the oracle signatures per [NUT-CTF][CTF] and returns regular proofs. + +## Security Considerations + +This NUT inherits the trust model and collateral safety properties described in [NUT-CTF][CTF]. The following considerations are specific to split/merge operations: + +### Atomicity + +Split and merge operations MUST be atomic — either all output signatures are returned or none are. A partial split (some outcome collections signed but not others) would leave the system in an inconsistent state. + +### Amount Conservation + +1. **Complete Sets**: Split always creates ALL outcome collections, preventing partial collateralization +2. **Equal Amounts**: Each outcome collection receives equal token amounts +3. **Merge Symmetry**: Merge requires equal amounts of all outcome collections, ensuring no value is created or destroyed + +### Combinatorial Market Depth + +Mints MAY impose a maximum nesting depth to prevent complexity explosion. The maximum depth SHOULD be communicated via the [Mint Info Setting](#mint-info-setting). Wallets SHOULD check the mint's maximum depth before attempting to register nested conditions. + +## Q&A: Design Decisions + +### 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 + +## Mint Info Setting + +The [NUT-06][06] `MintMethodSetting` indicates support for this feature: + +```json +{ + "CTF-split-merge": { + "supported": true, + "max_depth": + } +} +``` + +- `supported`: Boolean indicating NUT-CTF-split-merge support +- `max_depth` (optional): Maximum nesting depth for combinatorial markets. If not specified, only root conditions (depth 1) are supported. + +[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..b1d6fc5f --- /dev/null +++ b/CTF.md @@ -0,0 +1,705 @@ +# 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 the dedicated `POST /v1/redeem_outcome` endpoint 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 for a specific outcome collection. Each outcome collection gets a unique keyset with different signing keys. The keyset is what distinguishes conditional tokens from regular tokens — the tokens themselves are standard Cashu proofs. + +The oracle signature scheme is compatible with the [DLC (Discreet Log Contracts) specification](https://github.com/discreetlogcontracts/dlcspecs/blob/master/Oracle.md), allowing Cashu mints to leverage existing DLC oracle infrastructure. See [Oracle Communication](#oracle-communication) for details. + +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 split/merge operations for creating and dissolving complete sets of conditional tokens. [NUT-CTF-numeric][CTF-numeric] extends this framework with numeric outcome conditions. Both are optional but highly recommended for prediction market use cases. + +## Terminology + +- **Condition**: A question with defined outcomes, resolved by an oracle. Identified by a `condition_id` computed from oracle parameters. Equivalent to "condition" in the [Gnosis Conditional Token Framework](https://docs.gnosis.io/conditionaltokens/). +- **Outcome**: A single atomic result that an oracle attests to (e.g., `"YES"`, `"ALICE"`). The oracle signs an outcome string using the [DLC signing algorithm](https://github.com/discreetlogcontracts/dlcspecs/blob/master/Oracle.md#signing-algorithm). +- **Outcome collection**: A subset of a condition's outcomes, defined by a partition element (e.g., `"YES"`, `"ALICE|BOB"`). Each outcome collection gets its own conditional keyset. An outcome collection is redeemable if the oracle attests to ANY outcome contained in it. Equivalent to "index set" in the Gnosis CTF. +- **Partition**: A division of all outcomes into disjoint, complete outcome collections. +- **Condition ID** (`condition_id`): A 32-byte tagged hash uniquely identifying a condition, computed from oracle announcements. Partition-independent — the same oracle event always produces the same `condition_id` regardless of how it is partitioned. See [Condition ID](#condition-id). +- **Outcome collection ID** (`outcome_collection_id`): A 32-byte x-only public key uniquely identifying a specific outcome collection within a condition. See [Outcome Collection ID](#outcome-collection-id). +- **Conditional keyset**: A per-outcome-collection signing keyset created by the mint during partition registration. Each outcome collection gets its own keyset with unique signing keys. +- **Conditional token**: A Cashu token signed under a conditional keyset. Conditional tokens can be traded freely and redeemed after oracle attestation. + +## Outcome Collections + +Outcome collections allow tokens to represent one or more outcomes. An outcome collection is either a single outcome or an OR-combination of outcomes joined by `|`. This enables positions like "not Trump" (covering all other candidates) without requiring users to hold separate tokens for each alternative outcome. + +### Outcome Collection Notation + +Outcome collections use pipe-separated outcome names: + +- `"ALICE"` - Single outcome (standard) +- `"ALICE|BOB"` - Outcome collection covering outcomes ALICE or BOB +- `"ALICE|BOB|CAROL"` - Outcome collection covering three outcomes + +If an outcome name contains the `|` character, it MUST be escaped as `\|`. + +### Outcome Collection Keysets + +Each outcome collection gets its own conditional keyset. The keyset maps to the set of outcomes it covers. For a 3-outcome condition `["ALICE", "BOB", "CAROL"]` with partition `{"ALICE|BOB": [...], "CAROL": [...]}`, the mint creates: + +- `ALICE|BOB` → keyset ID `00aabb11cc22dd33` +- `CAROL` → keyset ID `00ccdd44ee55ff66` + +Each outcome collection also has an `outcome_collection_id` computed using the algorithm defined in [Outcome Collection ID](#outcome-collection-id). + +### Partition Rules + +When registering a partition, the partition keys must form a valid **partition** of all outcomes: + +1. **Disjoint**: No outcome can appear in multiple outcome collections +2. **Complete**: Every outcome in the event descriptor must appear in exactly one outcome collection + +Valid partitions for a 3-outcome condition `["ALICE", "BOB", "CAROL"]`: + +- `{"ALICE": [...], "BOB": [...], "CAROL": [...]}` (individual outcomes) +- `{"ALICE|BOB": [...], "CAROL": [...]}` (one outcome collection + one individual) +- `{"ALICE|BOB|CAROL": [...]}` (single outcome collection covering all - essentially a no-op) + +Invalid partitions: + +- `{"ALICE|BOB": [...], "BOB|CAROL": [...]}` - Overlapping (BOB appears twice) +- `{"ALICE|BOB": [...]}` - Incomplete (CAROL missing) + +### Redemption with Outcome Collections + +A conditional keyset is redeemable if the oracle attests to ANY outcome covered by the outcome collection: + +The oracle signing algorithm remains unchanged - the oracle signs the single winning outcome. The mint checks if that outcome is in the keyset's outcome collection. + +## Conditional Keysets + +Each outcome collection is represented by a **unique keyset** created by the mint during [partition registration](#register-partition). These conditional keysets use the same mechanism as regular keysets ([NUT-02][02]) -- the only difference is that they are associated with a specific outcome collection. + +### Keyset Properties + +- **Signing keys**: Each conditional keyset has unique signing keys derived by the mint from condition parameters +- **Unit**: Matches the collateral unit (e.g., `"sat"`) +- **Discovery**: Conditional keysets are served via the dedicated `GET /v1/conditional_keysets` endpoint with `condition_id`, `outcome_collection`, and `outcome_collection_id` fields (see [Conditional Keyset Discovery](#conditional-keyset-discovery)) +- **Active flag**: Set to `true` during condition lifetime, `false` after resolution + vesting period +- **Expiry**: Conditional keysets MAY use a `final_expiry` timestamp corresponding to the vesting period end + +### Keyset ID Derivation + +Conditional keyset IDs extend the [NUT-02 V2 derivation][02] by appending condition-specific data to the preimage before hashing. This binds the keyset ID to its condition and outcome collection, allowing wallets to independently verify the binding without trusting the mint. + +The extended preimage appends `|condition_id:` and `|outcome_collection_id:` after the standard NUT-02 V2 fields: + +``` + + "|condition_id:" + condition_id_hex + "|outcome_collection_id:" + outcome_collection_id_hex +``` + +The version byte remains `01`, same as regular keysets. + +That is, the [NUT-02 V2 `derive_keyset_id_v2`][02] function is extended by appending two additional fields to `keyset_id_bytes` before hashing: + +```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") +``` + +Here, `condition_id` and `outcome_collection_id` are the 64-character hex strings from [Condition ID](#condition-id) and [Outcome Collection ID](#outcome-collection-id) respectively. + +**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. + +## Token Lifecycle + +``` +Issuance: Mint issues conditional tokens (via partition 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 [partition registration](#register-partition). Users obtain conditional tokens through mechanisms such as the [NUT-CTF-split-merge][CTF-split-merge] split operation, AMM-based issuance, or any other minting mechanism the mint supports. +- **Trading**: Conditional tokens are transferred and swapped using standard [NUT-03][03] swap. All conditional keysets involved in a swap (inputs and outputs) MUST share the same `outcome_collection_id`. This permits both same-keyset swaps (normal trading) and cross-keyset swaps where the outcome collection is unchanged (key rotation from an old inactive conditional keyset to a new active one). No oracle witness is required. +- **Redemption**: After oracle attestation, winners submit conditional keyset tokens to `POST /v1/redeem_outcome` with oracle signatures in `Proof.witness`, receiving regular keyset tokens in return. + +> **Note:** [NUT-CTF-split-merge][CTF-split-merge] defines structured split/merge operations for the CTF pattern, allowing users to atomically split collateral into complete sets of conditional tokens and merge them back. + +## Condition ID + +A condition is uniquely identified by a `condition_id` computed from the condition parameters using a BIP-340 tagged hash. This uses SHA256 tagged hashing instead of keccak256 for Bitcoin ecosystem consistency. + +### Condition ID Calculation + +``` +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) convention +- `sorted_oracle_pubkeys`: One or more 32-byte x-only public keys of the oracles (same format as NUT-CTF), sorted lexicographically (byte-wise) and concatenated. For multi-oracle conditions with threshold `t` of `n` oracles, all `n` oracle pubkeys are included. Derived from `announcements[].oracle_public_key`. +- `event_id`: UTF-8 encoded event identifier string. Derived from `announcements[0].oracle_event.event_id`. All announcements in a multi-oracle condition MUST share the same `event_id`. +- `outcome_count`: 1-byte unsigned integer representing the number of outcomes. Derived from `len(announcements[0].oracle_event.event_descriptor.outcomes)`. This prevents collisions between events with the same oracle and event_id but different outcome counts. + +The `condition_id` is a 32-byte value encoded as a 64-character hexadecimal string. Since all components are derived from the announcements, the `condition_id` can be computed by either the wallet or the mint. The `condition_id` is partition-independent — it identifies the condition (a specific combination of oracles and event), not how it is partitioned. Note that "condition" is distinct from "event": an event is what a single oracle attests to, while a condition binds one or more oracles (with a threshold) to the same event_id. This allows the same condition to support multiple partitions simultaneously, with tokens for shared outcome collections being fungible across partitions. + +**Rationale for sorting oracle pubkeys:** Sorting ensures that two equivalent conditions always produce the same `condition_id` regardless of the order oracles are listed. + +> **Note:** [NUT-CTF-numeric][CTF-numeric] extends this formula with additional market-specific parameters for numeric conditions. Enum conditions (this NUT) use only the base formula above. + +## 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 request and response 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. This includes: + +- **Signing algorithm**: BIP 340 Schnorr signatures with DLC tagged hashing (`"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 defining possible outcomes as UTF-8 strings with NFC normalization + +This NUT defines the wallet-to-mint communication for conditional tokens (via the HTTP API endpoints above). However, the transport for discovering oracle announcements and receiving attestations **from oracles** is unspecified. [NIP-88](https://github.com/nostr-protocol/nips/pull/1681) (DLC oracle event kinds over Nostr) is one option for gossip-based oracle discovery and attestation distribution between wallets/mints and oracles. + +> **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. + +## Condition Registry + +Conditions are registered via `POST /v1/conditions` before any operations on conditional tokens. The mint maintains a registry of conditions with their parameters. Conditional keysets are created during [partition registration](#register-partition). + +### Condition Info + +```json +{ + "condition_id": , + "threshold": , + "description": , + "announcements": , + "registered_at": , + "keysets": { + "": , + ... + }, + "partitions": [ + { + "partition": , + "collateral": , + "parent_collection_id": , + "registered_at": + } + ], + "attestation": { + "status": , + "winning_outcome": , + "attested_at": + } +} +``` + +- `condition_id`: Computed condition identifier (derived from announcements, see [Condition ID Calculation](#condition-id-calculation)) +- `threshold`: Minimum number of oracles required for attestation (default: 1) +- `description`: Human-readable condition description +- `announcements`: Array of hex-encoded oracle announcement TLV bytes (see [Oracle Announcement Format](#oracle-announcement-format)) +- `registered_at`: Unix timestamp of when this condition was registered at the mint +- `keysets`: Object mapping each outcome collection to its keyset ID. This is a flat map of ALL registered outcome collections across all partitions at the root level (`parent_collection_id` = 0). If two partitions share an outcome collection (e.g., both include `"CAROL"`), it appears once with the same keyset. For combinatorial markets, keysets with non-zero `parent_collection_id` are not included here — use `GET /v1/conditional_keysets` to discover nested keysets. +- `partitions`: Array of registered partitions for this condition. Each entry contains: + - `partition`: Array of partition keys (e.g., `["YES", "NO"]` or `["ALICE|BOB", "CAROL"]`) + - `collateral`: For root conditions: a [NUT-00][00] unit string (e.g., `"sat"`, `"usd"`). For nested conditions: an `outcome_collection_id` (hex string) identifying the parent outcome collection whose tokens serve as collateral. + - `parent_collection_id`: 32-byte x-only public key (64 hex chars) representing the parent outcome collection. `"0000000000000000000000000000000000000000000000000000000000000000"` for root conditions. Used in combinatorial markets (see [NUT-CTF-split-merge][CTF-split-merge] for split/merge operations on nested markets). + - `registered_at`: Unix timestamp of when this partition was registered at the mint +- `attestation` (optional): Attestation state for this condition. Omitted if the mint does not yet have an attestation. + - `status`: One of `"pending"` (no attestation yet), `"attested"` (oracle has attested, redemption active), `"expired"` (vesting period ended), `"violation"` (observed more than 1 attestation for this condition) + - `winning_outcome`: The attested outcome string. `null` if `status` is `"pending"`. For numeric conditions ([NUT-CTF-numeric][CTF-numeric]), this is the reconstructed numeric value as a string. + - `attested_at`: Unix timestamp of when the attestation was recorded. `null` if `status` is `"pending"`. + +The `oracle_pubkeys`, `event_id`, `outcomes`, and `maturity` fields can be derived by parsing the announcement TLV and are not included separately in the Condition Info. + +### Get Conditions + +``` +GET /v1/conditions +``` + +**Query parameters:** + +- `since` (optional): Unix timestamp. If provided, returns only conditions with `registered_at >= since`. Wallets SHOULD first fetch all conditions (omitting `since`) to build a complete local cache, then use `since` for incremental synchronization on subsequent requests — store the highest `registered_at` seen and pass it as `since` next time. +- `limit` (optional): Maximum number of conditions to return per request. +- `status` (optional, repeatable): Filter by `attestation.status` value. Supports multiple values, e.g. `?status=pending&status=attested`. Valid values: `pending`, `attested`, `expired`, `violation`. When omitted, returns all conditions regardless of attestation status. Conditions without an `attestation` field are treated as `pending`. + +Mints MUST return results ordered by `registered_at` ascending (oldest first). + +Returns an array of available conditions: + +```json +{ + "conditions": +} +``` + +> **Pagination:** Clients paginate by setting `since` to the `registered_at` of the last item received. Because `since` uses `>=` semantics, items sharing the same timestamp as the cursor will re-appear; clients MUST deduplicate by `condition_id`. See [Q&A: Design Decisions](#qa-design-decisions) for rationale. + +### Get Condition + +``` +GET /v1/conditions/{condition_id} +``` + +Returns details for a specific condition: + +```json +{ + "condition": +} +``` + +### Register Condition + +``` +POST /v1/conditions +``` + +Registers a new condition. This is analogous to the `prepareCondition` operation in the Gnosis CTF. Condition registration does not create keysets — keysets are created during [partition registration](#register-partition). + +#### Request Body + +```json +{ + "threshold": , + "description": , + "announcements": +} +``` + +- `threshold`: Minimum number of oracles required for attestation (default: 1) +- `description`: Human-readable condition description +- `announcements`: Array of hex-encoded oracle announcement TLV bytes (see [Oracle Announcement Format](#oracle-announcement-format)) + +#### Response Body + +```json +{ + "condition_id": +} +``` + +- `condition_id`: The 32-byte condition identifier computed from the announcements (64 hex characters) + +#### Mint Behavior + +1. Parses announcements, verifies announcement signatures (error 13011 if verification fails) +2. Computes `condition_id` from the provided announcements +3. If condition already exists with matching configuration: returns existing `condition_id` (idempotent) +4. If condition already exists with different configuration: returns error 13028 +5. If condition is new: stores configuration, returns `condition_id` + +The mint MUST make condition registration idempotent — repeated calls with the same announcements MUST return the same `condition_id`. + +Mints MAY protect the `POST /v1/conditions` endpoint against abuse by requiring authentication via [NUT-21][21] or [NUT-22][22]. When blind authentication ([NUT-22][22]) is required, each condition registration costs a blind auth token, providing economic DoS prevention without IP-based rate limiting. + +#### Errors + +- 13011: Oracle announcement verification failed +- 13028: Condition already exists (with different configuration) + +### Register Partition + +``` +POST /v1/conditions/{condition_id}/partitions +``` + +Registers a partition for a condition and creates conditional keysets. Wallets must register a partition before any conditional token issuance, because keyset IDs must be known in advance to construct `BlindedMessage`s. + +#### Request Body + +```json +{ + "collateral": , + "partition": , + "parent_collection_id": +} +``` + +- `collateral`: For root conditions: a [NUT-00][00] unit string (e.g., `"sat"`, `"usd"`). For nested conditions: an `outcome_collection_id` (hex string) identifying the parent outcome collection whose tokens serve as collateral. +- `partition`: Array of partition keys defining the outcome collection grouping. Each key is a single outcome name or a pipe-separated outcome collection (e.g., `["ALICE|BOB", "CAROL"]`). The partition MUST satisfy the [Partition Rules](#partition-rules). +- `parent_collection_id` (optional): A 32-byte x-only public key (64 hex chars) representing the parent outcome collection. Defaults to 32 zero bytes (`"0000000000000000000000000000000000000000000000000000000000000000"`) for root conditions. Used in combinatorial markets (see [NUT-CTF-split-merge][CTF-split-merge] for split/merge operations on nested markets). + +#### Response Body + +```json +{ + "keysets": { + "": , + "": , + ... + } +} +``` + +- `keysets`: Object mapping each outcome collection in the partition to its keyset ID + +#### Mint Behavior + +1. Looks up the condition by `condition_id` (error 13021 if not found) +2. Validates the partition satisfies the [Partition Rules](#partition-rules) against the condition's outcomes (error 13037 if overlapping, error 13038 if incomplete) +3. If `parent_collection_id` is non-zero: the mint **MUST** verify that the referenced collection ID corresponds to an active outcome collection from a previously registered condition. If not found, return error 13021 (Condition not found). +4. For each outcome collection in the partition: + - Computes `outcome_collection_id` using the algorithm in [Outcome Collection ID](#outcome-collection-id) with the request's `parent_collection_id` + - If a keyset already exists for this `outcome_collection_id`: reuses it + - If not: creates a new conditional keyset +5. Returns keyset map + +**Key property:** Keysets are per `outcome_collection_id`, not per partition. If partition A includes `{CAROL}` and partition B also includes `{CAROL}`, they share the same keyset. This is what makes tokens fungible across partitions sharing the same outcome collection. + +**Idempotency:** The mint MUST make partition registration idempotent. If the same partition is registered twice for the same condition (with the same collateral and `parent_collection_id`), the mint MUST return the existing keysets without error. This allows wallets to safely retry failed requests and simplifies coordination when multiple wallets independently register the same partition. + +**DoS prevention:** Partition registration creates keysets, which is resource-intensive. Mints MAY protect this endpoint against abuse by requiring authentication via [NUT-21][21] or [NUT-22][22], similar to condition registration. + +#### Errors + +- 13021: Condition not found (also returned when `parent_collection_id` references a non-existent collection) +- 13037: Overlapping outcome collections +- 13038: Incomplete partition + +## Outcome Collection ID + +Each outcome collection has a unique `outcome_collection_id` derived from the condition ID, outcome collection string, and an optional parent collection ID. The result is a 32-byte x-only public key (64-character hex string) on the secp256k1 curve. + +### Outcome Collection ID Computation + +``` +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 the identity (32 zero bytes): + Return x_only(P) + Else: + parent_point = lift_x(parent_collection_id) + Return x_only(EC_add(parent_point, P)) +``` + +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) convention +- `condition_id`: 32-byte condition identifier (see [Condition ID](#condition-id)) +- `outcome_collection_string_bytes`: UTF-8 encoded outcome collection string (e.g., `"YES"` or `"ALICE|BOB"`) +- `hash_to_curve`: A deterministic method to map a 32-byte hash to a secp256k1 point, using the same approach as [NUT-00][00]'s `hash_to_curve` but with domain separation via the tagged hash input +- `EC_add`: Standard elliptic curve point addition on secp256k1 +- `lift_x`: Recover a full EC point from an x-only public key per [BIP-340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) +- `x_only`: Extract the 32-byte x-coordinate from an EC point +- `parent_collection_id`: A 32-byte x-only public key representing the parent outcome collection. For root conditions, this is 32 zero bytes (the identity). For nested conditions in combinatorial markets, this is the `outcome_collection_id` of the parent outcome collection. + +The `outcome_collection_id` serves as a globally unique identifier for a specific outcome collection within a specific condition and is used as collateral reference in combinatorial markets. + +### Commutativity + +Because EC point addition is commutative and associative: + +``` +outcome_collection_id(outcome_collection_id(0, condition_A, oc_A), condition_B, oc_B) + = outcome_collection_id(outcome_collection_id(0, condition_B, oc_B), condition_A, oc_A) +``` + +This means that in combinatorial markets, nesting order does not matter — `(Party_A) & (BTC_UP)` produces the same `outcome_collection_id` as `(BTC_UP) & (Party_A)`, regardless of which condition is split first. + +## Conditional Keyset Discovery + +Conditional keysets are served on a dedicated endpoint, separate from the standard `GET /v1/keysets` endpoint ([NUT-02][02]). This separation ensures backward compatibility — wallets that do not support NUT-CTF will not see conditional keysets in the regular keyset list, avoiding confusion with unknown fields or keyset types. It also prevents conditional keysets from inflating the regular keyset listing. + +### Endpoint + +``` +GET /v1/conditional_keysets +``` + +### Query Parameters + +- `since` (optional): Unix timestamp. If provided, returns only keysets with `registered_at >= since`. Same sync pattern as `GET /v1/conditions` — wallets SHOULD first fetch all keysets (omitting `since`), then use `since` for incremental sync. +- `limit` (optional): Maximum number of keysets to return per request. +- `active` (optional): Boolean filter on the keyset's `active` flag. E.g. `?active=true` returns only active keysets. When omitted, returns all keysets regardless of active status. + +Mints MUST return results ordered by `registered_at` ascending (oldest first). + +> **Pagination:** Same cursor-based approach as `GET /v1/conditions` — set `since` to the `registered_at` of the last item received and deduplicate by keyset `id`. See [Q&A: Design Decisions](#qa-design-decisions) for rationale. + +### Response + +The response is structurally identical to the `GET /v1/keysets` response ([NUT-02][02]), with four additional fields that are always present on every entry: + +```json +{ + "keysets": [ + { + "id": , + "unit": , + "active": , + "input_fee_ppk": , + "final_expiry": , + "condition_id": , + "outcome_collection": , + "outcome_collection_id": , + "registered_at": + }, + ... + ] +} +``` + +- `condition_id`: 32-byte condition identifier (64-character hex string). Corresponds to the `condition_id` from [condition registration](#condition-registry). +- `outcome_collection`: The outcome collection string this keyset represents (e.g., `"YES"`, `"ALICE|BOB"` for outcome collections). +- `outcome_collection_id`: 32-byte outcome collection identifier (64-character hex string). Computed using the algorithm in [Outcome Collection ID](#outcome-collection-id). +- `registered_at`: Unix timestamp of when this keyset was created during partition registration. Used as the cursor for `since`-based pagination. + +All four fields (`condition_id`, `outcome_collection`, `outcome_collection_id`, `registered_at`) MUST be present for every keyset entry in this response. + +> **Note:** The standard `GET /v1/keys/{keyset_id}` endpoint ([NUT-02][02]) still works for fetching the public keys of a specific conditional keyset by its ID. + +### Example + +```json +{ + "keysets": [ + { + "id": "00abc123def456", + "unit": "sat", + "active": true, + "input_fee_ppk": 0, + "final_expiry": 1753920000, + "condition_id": "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd", + "outcome_collection": "YES", + "outcome_collection_id": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "registered_at": 1700000000 + }, + { + "id": "00def789abc012", + "unit": "sat", + "active": true, + "input_fee_ppk": 0, + "final_expiry": 1753920000, + "condition_id": "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd", + "outcome_collection": "NO", + "outcome_collection_id": "f4a1b55298fc2c259bfcf5d9aa7fc8a3538bf52f575ac045ba5a6a02c8963c966", + "registered_at": 1700000000 + } + ] +} +``` + +## Redemption Witness + +When redeeming conditional keyset tokens via `POST /v1/redeem_outcome`, each input `Proof` must include a `witness` field containing the oracle's attestation. The `witness` is a serialized JSON string: + +```json +{ + "oracle_sigs": [ + { + "oracle_pubkey": , + "oracle_sig": + } + ] +} +``` + +- `oracle_sigs`: Array of oracle attestation objects. Must contain at least `threshold` entries from distinct oracles. + - `oracle_pubkey`: The oracle's 32-byte x-only public key (64-character hex string) identifying which oracle produced this attestation + - `oracle_sig`: The oracle's 64-byte Schnorr signature (128-character hex string) on the winning outcome string + +Always use the array format, even for single-oracle markets (threshold=1). This avoids two code paths for implementers. + +### Example Witness + +Single oracle (threshold=1): + +```json +{ + "oracle_sigs": [ + { + "oracle_pubkey": "9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0", + "oracle_sig": "a1b2c3d4...64_bytes...f1e2d3a4" + } + ] +} +``` + +Multi-oracle (threshold=2): + +```json +{ + "oracle_sigs": [ + { + "oracle_pubkey": "9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0", + "oracle_sig": "a1b2c3d4...64_bytes...f1e2d3a4" + }, + { + "oracle_pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "oracle_sig": "b2c3d4e5...64_bytes...e2f3a4b5" + } + ] +} +``` + +### Relationship to Existing Witness Types + +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. + +## Redemption Endpoint + +Redemption uses a dedicated endpoint rather than overloading the standard [swap][03] operation. This makes the redemption intent unambiguous and avoids complex cross-keyset detection logic in [NUT-03][03]. + +### Endpoint + +``` +POST /v1/redeem_outcome +``` + +### Request Body + +```json +{ + "inputs": , + "outputs": +} +``` + +- `inputs`: Array of `Proof` objects from a **single conditional keyset**, each with `witness` containing oracle attestation (see [Redemption Witness](#redemption-witness)) +- `outputs`: Array of `BlindedMessage` objects using a **regular keyset** (same unit as the conditional keyset) + +Wallets MAY omit the `oracle_sigs` from the witness if the mint has already received a valid attestation for the same outcome collection. If the mint has previously verified and recorded the winning attestation, it MAY skip `oracle_sigs` verification and perform the redemption based on its stored attestation state. Wallets can check the attestation state via `GET /v1/conditions/{condition_id}` to determine whether the mint has already recorded an attestation. + +### Response Body + +```json +{ + "signatures": +} +``` + +Each `BlindSignature` corresponds to a `BlindedMessage` in the request, creating regular (non-conditional) proofs. + +### Consequence for NUT-03 + +Mints implementing NUT-CTF **MUST** enforce the following rules on [NUT-03][03] swap requests involving conditional keysets. These rules prevent redemption of conditional tokens without oracle attestation while still allowing key rotation. + +Within [NUT-03][03]: + +- Swaps within the same conditional keyset (trading) work as normal +- Swaps within regular keysets work as normal, including cross-keyset swaps between regular keysets (e.g., for keyset rotation) +- Swaps where all inputs and all outputs use conditional keysets that share the same `outcome_collection_id` are permitted (key rotation) +- Swaps where conditional keyset inputs and outputs span different `outcome_collection_id` values **MUST** be rejected +- Swaps that mix conditional and regular keysets across inputs and outputs **MUST** be rejected + +All conditional-to-regular keyset conversions go through `POST /v1/redeem_outcome`. + +## Redemption Verification + +When the mint receives a `POST /v1/redeem_outcome` request, it MUST perform the following verification: + +1. All inputs MUST use the same conditional keyset +2. All outputs MUST use a regular keyset (same unit as the input conditional keyset) +3. If the mint has already recorded a valid attestation for this outcome collection, it MAY skip steps 4-5 and proceed directly to step 6 +4. Each input MUST include a valid `witness` with `oracle_sigs` +5. Verify `oracle_sigs` contains at least `threshold` entries from distinct oracles (identified by `oracle_pubkey`). For each entry, verify `oracle_sig` against the `oracle_pubkey` using the [DLC signing algorithm](https://github.com/discreetlogcontracts/dlcspecs/blob/master/Oracle.md#signing-algorithm) with tagged hash `"DLC/oracle/attestation/v0"` and the UTF-8 NFC-normalized outcome string. For numeric conditions ([NUT-CTF-numeric][CTF-numeric]), see the extended digit-decomposition witness verification. +6. The mint MUST verify that this outcome collection is the attested winner (see [Attestation Handling](#attestation-handling)) + +### Attestation Handling + +When an oracle attests to a winning outcome, the mint **MUST** persistently record the first valid attestation (winning outcome and timestamp) for each condition. This record **MUST** survive mint restarts. + +The mint **MUST NOT** process redemptions for non-winning conditional keysets, even if a valid signature for those outcomes were somehow produced (e.g., through oracle key compromise). + +Mints **MUST** reject redemption attempts for non-winning conditional keysets after the first valid attestation is processed. For numeric conditions ([NUT-CTF-numeric][CTF-numeric]), both HI and LO keysets can redeem proportionally — see [NUT-CTF-numeric][CTF-numeric] for the extended redemption rules. + +If the mint receives a valid oracle signature for a different outcome of the same condition (a DLC protocol violation), the mint **MUST** reject it and **MUST** log the conflict. Mints SHOULD expose detected DLC violations to users via the condition info endpoint. + +## Vesting Period + +The mint MAY deactivate conditional keysets and stop honoring redemptions after a vesting period following event maturity. This vesting period allows users adequate time to redeem their winning conditional tokens after oracle attestation. + +**Recommendation**: The vesting period SHOULD be at least 30 days after `event_maturity_epoch`. Mints MUST clearly communicate their vesting period via the [Mint Info Setting](#mint-info-setting). + +After the vesting period expires: + +- The mint sets conditional keyset `active` to `false` +- The mint MAY refuse to process redemptions for expired conditional keysets +- The mint MAY delete all event-related data +- Users SHOULD be aware of the vesting period and redeem before expiry + +> **Wallet warning**: Wallets SHOULD prominently display the vesting period deadline to users holding winning conditional tokens. Funds that are not redeemed before the vesting period expires may be permanently lost. Wallets SHOULD alert users as the deadline approaches. + +### Oracle Non-Attestation + +If the oracle does not publish a valid attestation within the expected time, the mint MAY refund conditional tokens back to regular ecash at a price of its choosing. This allows the mint to unwind markets where the oracle has become unresponsive or failed to attest, preventing user funds from being locked indefinitely. + +## 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 | Outputs must use a regular keyset | +| 13020 | Invalid condition ID | +| 13021 | Condition not found | +| 13027 | Oracle threshold not met | +| 13028 | Condition already exists | +| 13037 | Overlapping outcome collections | +| 13038 | Incomplete partition | + +## Mint Info Setting + +The [NUT-06][06] `MintMethodSetting` indicates support for this feature: + +```json +{ + "CTF": { + "supported": true, + "dlc_version": , + "vesting_period": + } +} +``` + +- `supported`: Boolean indicating NUT-CTF support +- `vesting_period`: (optional) Number of seconds after `event_maturity_epoch` during which the mint will honor redemptions. If not specified, implementations SHOULD assume a minimum of 30 days (2592000 seconds). A value of `0` indicates no expiry. +- `dlc_version` ... The latest version of the DLC protocol that it supports. As the time of writing `"0"` is the only DLC protocol version. + +## 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. + +[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 diff --git a/README.md b/README.md index 60d2f47a..ddce4252 100644 --- a/README.md +++ b/README.md @@ -20,29 +20,32 @@ 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] | - | -| [27][27] | Nostr Mint Backup | [Cashu.me][cashume], [cdk] | - | +| # | 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] | - | +| [27][27] | Nostr Mint Backup | [Cashu.me][cashume], [cdk] | - | +| [CTF][CTF] | Conditional Token Framework | - | - | +| [CTF-split-merge][CTF-split-merge] | Conditional Token Split and Merge | - | - | +| [CTF-numeric][CTF-numeric] | Numeric Outcome Conditions | - | - | #### Wallets: @@ -102,3 +105,6 @@ Wallets and mints `MUST` implement all mandatory specs and `CAN` implement optio [25]: 25.md [26]: 26.md [27]: 27.md +[CTF]: CTF.md +[CTF-split-merge]: CTF-split-merge.md +[CTF-numeric]: CTF-numeric.md diff --git a/error_codes.md b/error_codes.md index eb368af8..d8cce984 100644 --- a/error_codes.md +++ b/error_codes.md @@ -1,40 +1,60 @@ # 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] | -| 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], [NUT-CTF][CTF], [NUT-CTF-split-merge][CTF-split-merge] | +| 11001 | Proofs already spent | [NUT-03][03], [NUT-05][05], [NUT-CTF][CTF], [NUT-CTF-split-merge][CTF-split-merge] | +| 11002 | Proofs are pending | [NUT-03][03], [NUT-05][05], [NUT-CTF][CTF], [NUT-CTF-split-merge][CTF-split-merge] | +| 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], [NUT-CTF-split-merge][CTF-split-merge] | +| 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], [NUT-CTF][CTF], [NUT-CTF-split-merge][CTF-split-merge] | +| 11008 | Duplicate outputs provided | [NUT-03][03], [NUT-04][04], [NUT-05][05], [NUT-CTF-split-merge][CTF-split-merge] | +| 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], [NUT-CTF][CTF], [NUT-CTF-split-merge][CTF-split-merge] | +| 12002 | Keyset is inactive, cannot sign messages | [NUT-02][02], [NUT-03][03], [NUT-04][04], [NUT-CTF][CTF], [NUT-CTF-split-merge][CTF-split-merge] | +| 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] | +| 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] | +| 13010 | Invalid oracle signature | [NUT-CTF][CTF] | +| 13011 | Oracle announcement verification failed | [NUT-CTF][CTF] | +| 13014 | Conditional keyset requires oracle witness | [NUT-CTF][CTF] | +| 13015 | Oracle has not attested to this outcome collection | [NUT-CTF][CTF] | +| 13016 | Conditional keyset swap spans different outcome collections | [NUT-CTF][CTF] | +| 13017 | Outputs must use a regular keyset | [NUT-CTF][CTF] | +| 13020 | Invalid condition ID | [NUT-CTF][CTF] | +| 13021 | Condition not found | [NUT-CTF][CTF], [NUT-CTF-split-merge][CTF-split-merge] | +| 13022 | Split amount mismatch | [NUT-CTF-split-merge][CTF-split-merge] | +| 13024 | Condition not active | [NUT-CTF-split-merge][CTF-split-merge] | +| 13025 | Merge amount mismatch | [NUT-CTF-split-merge][CTF-split-merge] | +| 13027 | Oracle threshold not met | [NUT-CTF][CTF] | +| 13028 | Condition already exists | [NUT-CTF][CTF] | +| 13030 | Invalid numeric range (lo_bound >= hi_bound) | [NUT-CTF-numeric][CTF-numeric] | +| 13031 | Digit signature verification failed | [NUT-CTF-numeric][CTF-numeric] | +| 13032 | Attested value outside representable range | [NUT-CTF-numeric][CTF-numeric] | +| 13033 | Payout calculation overflow | [NUT-CTF-numeric][CTF-numeric] | +| 13037 | Overlapping outcome collections | [NUT-CTF][CTF], [NUT-CTF-split-merge][CTF-split-merge] | +| 13038 | Incomplete partition | [NUT-CTF][CTF], [NUT-CTF-split-merge][CTF-split-merge] | +| 13040 | Maximum condition depth exceeded | [NUT-CTF-split-merge][CTF-split-merge] | [00]: 00.md [01]: 01.md @@ -52,3 +72,6 @@ [20]: 20.md [21]: 21.md [22]: 22.md +[CTF]: CTF.md +[CTF-split-merge]: CTF-split-merge.md +[CTF-numeric]: CTF-numeric.md diff --git a/tests/CTF-numeric-tests.md b/tests/CTF-numeric-tests.md new file mode 100644 index 00000000..eeec89f1 --- /dev/null +++ b/tests/CTF-numeric-tests.md @@ -0,0 +1,354 @@ +# 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 +# Register a numeric market via POST /v1/conditions +request_json: { + "collateral": "sat", + "threshold": 1, + "description": "BTC/USD price on 2025-07-01", + "announcements": [""], + "market_type": "numeric", + "lo_bound": 0, + "hi_bound": 100000, + "precision": 0 +} + +response_json: { + "condition_id": "", + "keysets": { + "HI": "00hi11keyset22", + "LO": "00lo33keyset44" + } +} + +# Partition is always ["HI", "LO"] for numeric markets +# condition_id = tagged_hash("Cashu_condition_id", +# oracle_pubkey || event_id || 0x02 || "HI" + 0x00 + "LO" +# || 0x01 || lo_bound_i64be || hi_bound_i64be || precision_i32be) +# where 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: { + "collateral": "sat", + "threshold": 1, + "description": "Invalid range market", + "announcements": [""], + "market_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 +``` + +## Split and Merge + +### Test 14: Numeric market split + +```shell +# Split 100 sats into HI and LO tokens +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..."} + ] +} + +# Standard NUT-CTF-split-merge merge - 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" +``` + +[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..b41cbab4 --- /dev/null +++ b/tests/CTF-split-merge-tests.md @@ -0,0 +1,764 @@ +# NUT-CTF-split-merge Test Vectors + +These test vectors provide reference data for implementing the Conditional Token Framework (CTF) with per-outcome collection keysets. All values are hex-encoded for reproducibility. + +## 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 partition-independent. + +### 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 partition keys +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 partition keys — condition_id is partition-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 partition keys in condition_id +``` + +## Condition and Partition Registration + +### Test 4: Register condition and partition (binary) + +```shell +# Step 1: Register condition (POST /v1/conditions) +register_request: { + "threshold": 1, + "description": "Will BTC reach $100k?", + "announcements": [""] +} + +register_response: { + "condition_id": "" +} + +# Step 2: Register partition (POST /v1/conditions/{condition_id}/partitions) +partition_request: { + "collateral": "sat", + "partition": ["YES", "NO"] +} + +partition_response: { + "keysets": { + "YES": "00abc123def456", + "NO": "00def789abc012" + } +} + +# These keyset IDs are used in all subsequent split/merge/trade operations +``` + +### Test 5: Three-outcome condition with partition registration + +```shell +# Step 1: Register condition +register_request: { + "threshold": 1, + "description": "Election winner", + "announcements": [""] +} + +register_response: { + "condition_id": "" +} + +# Step 2: Register partition +partition_request: { + "collateral": "sat", + "partition": ["CANDIDATE_A", "CANDIDATE_B", "CANDIDATE_C"] +} + +partition_response: { + "keysets": { + "CANDIDATE_A": "00aa11bb22cc33dd", + "CANDIDATE_B": "00bb22cc33dd44ee", + "CANDIDATE_C": "00cc33dd44ee55ff" + } +} +``` + +## Split Operation + +### Test 6: Binary condition split request + +```shell +# Condition parameters +condition_id: + +# Input (100 sats collateral using regular keyset) +input_amount: 100 +input_keyset_id: 009a1f293253e41e # regular keyset + +# Output keyset IDs from condition preparation +yes_keyset_id: 00abc123def456 +no_keyset_id: 00def789abc012 + +# Split request JSON +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 +``` + +## Merge Operation + +### Test 9: Binary condition merge request + +```shell +# Condition parameters +condition_id: + +# Inputs (100 sats of each outcome collection using conditional keysets) +# Outputs use regular keyset +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 use regular keyset +# No oracle witness required (complete set cancels out) +output_total: 100 +``` + +### Test 10: Successful merge response + +```shell +# Response with signatures for collateral outputs (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: Split amount mismatch + +```shell +# Input total != output total for each outcome collection +input_total: 100 +output_yes_total: 90 # Mismatch! +output_no_total: 100 + +error_code: 13022 +error_message: "Split amount mismatch" +``` + +### Test 14: Missing outcome collection in outputs + +```shell +# Binary condition but only YES outputs provided +outcome_collections: ["YES", "NO"] +outputs_provided: ["YES"] # Missing NO! + +error_code: 13038 +error_message: "Incomplete partition" +``` + +### 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 outcome collections +input_total: 100 +output_yes_total: 100 +output_no_total: 50 # Different! + +error_code: 13022 +error_message: "Split amount mismatch" +``` + +### Test 18: Merge amount mismatch + +```shell +# Input amounts don't match +input_yes_total: 100 +input_no_total: 80 # Mismatch! + +error_code: 13025 +error_message: "Merge amount mismatch" +``` + +### Test 19: Missing outcome collection in merge inputs + +```shell +# Binary condition but only YES inputs provided +outcome_collections: ["YES", "NO"] +inputs_provided: ["YES"] # Missing NO! + +error_code: 13038 +error_message: "Incomplete partition" +``` + +### Test 20: Output amount mismatch in merge + +```shell +# Output total doesn't equal per-outcome collection input total +input_yes_total: 100 +input_no_total: 100 +output_total: 50 # Should be 100! + +error_code: 13025 +error_message: "Merge amount mismatch" +``` + +## 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 partition keys — condition_id is partition-independent +``` + +## Outcome Collections + +### Test 22: Split with outcome collections (3-outcome condition) + +```shell +# Condition with 3 outcomes, partition registered with outcome collections +outcomes: ["ALICE", "BOB", "CAROL"] + +# Partition registration returned keysets for this partition +keysets: + "ALICE|BOB": 00aabb11cc22dd33 + "CAROL": 00ccdd44ee55ff66 + +# 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..."} + ] + } +} + +# Partition check +partition_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: Overlapping outcome collections error + +```shell +# Invalid partition - BOB appears in both sets +outputs_keys: ["ALICE|BOB", "BOB|CAROL"] +condition_outcomes: ["ALICE", "BOB", "CAROL"] + +# Validation fails +error_code: 13037 +error_message: "Overlapping outcome collections" +``` + +### Test 26: Incomplete partition error + +```shell +# Invalid partition - CAROL is missing +outputs_keys: ["ALICE|BOB"] +condition_outcomes: ["ALICE", "BOB", "CAROL"] + +# Validation fails +error_code: 13038 +error_message: "Incomplete partition" +``` + +### 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 use regular keyset +# Valid merge - outcome collections form complete partition +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 condition and partition registration + +```shell +# Step 1a: Register root election condition (POST /v1/conditions) +root_condition_request: { + "threshold": 1, + "description": "Election winner", + "announcements": [""] +} + +root_condition_response: { + "condition_id": "" +} + +# Step 1b: Register root partition (POST /v1/conditions/{election_condition_id}/partitions) +root_partition_request: { + "collateral": "sat", + "partition": ["PARTY_A", "PARTY_B"] +} + +root_partition_response: { + "keysets": { + "PARTY_A": "00aa11bb22cc33dd", + "PARTY_B": "00bb22cc33dd44ee" + } +} + +# Step 2a: Register nested BTC price condition (POST /v1/conditions) +nested_condition_request: { + "threshold": 1, + "description": "BTC price conditional on Party A win", + "announcements": [""] +} + +nested_condition_response: { + "condition_id": "" +} + +# Step 2b: Register nested partition (POST /v1/conditions/{btc_price_condition_id}/partitions) +# parent_collection_id = outcome_collection_id(0, election_condition_id, "PARTY_A") +# collateral = outcome_collection_id of PARTY_A in election condition +nested_partition_request: { + "collateral": "", + "partition": ["UP", "DOWN"], + "parent_collection_id": "" +} + +nested_partition_response: { + "keysets": { + "UP": "00cc33dd44ee55ff", + "DOWN": "00dd44ee55ff6600" + } +} +``` + +### Test 32: Nested condition split + +```shell +# Split PARTY_A tokens into PARTY_A&UP and PARTY_A&DOWN +# Inputs use PARTY_A conditional keyset (from root condition) +# Outputs use nested condition conditional keysets +request_json: { + "condition_id": "", + "inputs": [ + {"amount": 100, "id": "00aa11bb22cc33dd", "secret": "party_a_secret_1", "C": "02..."} + ], + "outputs": { + "UP": [ + {"amount": 64, "id": "00cc33dd44ee55ff", "B_": "03..."}, + {"amount": 32, "id": "00cc33dd44ee55ff", "B_": "03..."}, + {"amount": 4, "id": "00cc33dd44ee55ff", "B_": "03..."} + ], + "DOWN": [ + {"amount": 64, "id": "00dd44ee55ff6600", "B_": "03..."}, + {"amount": 32, "id": "00dd44ee55ff6600", "B_": "03..."}, + {"amount": 4, "id": "00dd44ee55ff6600", "B_": "03..."} + ] + } +} + +# Input uses PARTY_A keyset (parent outcome collection) +# Outputs use UP/DOWN keysets (nested outcome collections) +result: PASS +``` + +### Test 33: Nested condition merge + +```shell +# Merge PARTY_A&UP and PARTY_A&DOWN back to PARTY_A tokens +request_json: { + "condition_id": "", + "inputs": { + "UP": [ + {"amount": 100, "id": "00cc33dd44ee55ff", "secret": "up_secret_1", "C": "02..."} + ], + "DOWN": [ + {"amount": 100, "id": "00dd44ee55ff6600", "secret": "down_secret_1", "C": "02..."} + ] + }, + "outputs": [ + {"amount": 64, "id": "00aa11bb22cc33dd", "B_": "03..."}, + {"amount": 32, "id": "00aa11bb22cc33dd", "B_": "03..."}, + {"amount": 4, "id": "00aa11bb22cc33dd", "B_": "03..."} + ] +} + +# Outputs use PARTY_A keyset (parent outcome collection), not regular keyset +result: PASS +``` + +### Test 34: Maximum depth exceeded + +```shell +# Attempt to prepare condition at depth exceeding max_depth +# Mint's max_depth = 2 +# Attempting depth 3 preparation +error_code: 13040 +error_message: "Maximum condition depth exceeded" +``` + +## Complete Flow Example + +### Test 35: End-to-end condition lifecycle + +```shell +# Step 1a: Register condition (POST /v1/conditions) +condition_id: + +# Step 1b: Register partition (POST /v1/conditions/{condition_id}/partitions) +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 +``` + +[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..f5dde1e5 --- /dev/null +++ b/tests/CTF-tests.md @@ -0,0 +1,255 @@ +# 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 +``` + +## Error Validation Tests + +### Test 12: 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 13: 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 +``` + +[NUT-CTF]: ../CTF.md