From 618bf7812ca12232e86970df9af7a2cd23d6e19c Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Mon, 6 Apr 2026 18:20:33 +0900 Subject: [PATCH 1/2] NUT-CTF: conditional tokens for prediction markets Add NUT-CTF (Conditional Token Framework), NUT-CTF-split-merge (split/merge operations), and NUT-CTF-numeric (numeric outcome conditions) specs with test vectors, supplementary material, error codes, and README entries. Squashed from 9 commits after rebasing onto upstream/main to resolve NUT-28/29 filename conflicts (upstream now uses those numbers for P2BK and Batched Mint). --- CTF-numeric.md | 230 ++++++++++ CTF-split-merge.md | 218 ++++++++++ CTF.md | 533 +++++++++++++++++++++++ README.md | 56 +-- error_codes.md | 93 ++-- suppl/CTF-split-merge.md | 145 +++++++ suppl/CTF.md | 61 +++ tests/CTF-numeric-tests.md | 354 +++++++++++++++ tests/CTF-split-merge-tests.md | 764 +++++++++++++++++++++++++++++++++ tests/CTF-tests.md | 255 +++++++++++ 10 files changed, 2649 insertions(+), 60 deletions(-) create mode 100644 CTF-numeric.md create mode 100644 CTF-split-merge.md create mode 100644 CTF.md create mode 100644 suppl/CTF-split-merge.md create mode 100644 suppl/CTF.md create mode 100644 tests/CTF-numeric-tests.md create mode 100644 tests/CTF-split-merge-tests.md create mode 100644 tests/CTF-tests.md diff --git a/CTF-numeric.md b/CTF-numeric.md new file mode 100644 index 00000000..5a58dd31 --- /dev/null +++ b/CTF-numeric.md @@ -0,0 +1,230 @@ +# 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 + +**Request** of `Alice`: + +```http +POST https://mint.host:3338/v1/conditions +``` + +```json +{ + "threshold": 1, + "tags": [["description", "BTC/USD price on 2025-07-01"]], + "announcements": [ + "" + ], + "condition_type": "numeric", + "lo_bound": 0, + "hi_bound": 100000, + "precision": 0 +} +``` + +```bash +curl -X POST https://mint.host:3338/v1/conditions \ + -H "Content-Type: application/json" \ + -d '{"threshold":1,"tags":[["description","BTC/USD price"]],"announcements":["fdd824..."],"condition_type":"numeric","lo_bound":0,"hi_bound":100000,"precision":0}' +``` + +- `condition_type`: `"numeric"` (vs default `"enum"` for existing [NUT-CTF][CTF] conditions). When omitted, defaults to `"enum"`. +- `lo_bound`: Lower bound of the range (integer) +- `hi_bound`: Upper bound of the range (integer, MUST be > `lo_bound`) +- `precision`: Base-10 exponent for the oracle's digit decomposition (from the DLC event descriptor). A precision of `n` means the oracle's attested digits represent a value multiplied by `10^n`. For example, precision `0` means the digits represent the value directly, precision `-2` means the digits represent cents (divide by 100). + +**Response** of `Bob`: + +```json +{ + "condition_id": +} +``` + +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": , + "digit_sigs": + } + ] +} +``` + +- `digit_sigs`: Array of 64-byte Schnorr signatures (128-char hex strings), one per digit, in left-to-right order (most significant digit first). Each signature is on the digit's UTF-8 string representation (e.g., `"2"` for digit value 2) using the corresponding R-value (nonce point) from the oracle announcement. +- For signed numbers: the first element is a signature on `"+"` or `"-"` + +The witness uses `digit_sigs` (array of per-digit signatures) instead of `oracle_sig` (single signature) used in [NUT-CTF][CTF] enum conditions. The mint identifies which format to expect based on the `condition_type` of the condition referenced by the input keyset. + +### Verification + +The mint: + +1. Extracts the digit values from `digit_sigs` by verifying each signature against the corresponding R-value from the oracle announcement +2. Reconstructs the numeric value from the digit values (accounting for sign and `precision`) +3. Clamps the value to `[lo_bound, hi_bound]` +4. Computes the payout ratio + +## Redemption + +Both HI and LO holders can redeem at `POST /v1/redeem_outcome` ([NUT-CTF][CTF]). Unlike enum conditions where only the winning outcome collection can redeem, in numeric conditions **both outcomes can redeem** with proportional amounts. + +### HI Holder Redemption + +Given attested value `V = 20000`, range `[0, 100000]`: + +- Input: 100 sats of HI tokens + digit witness +- Payout ratio: `(20000 - 0) / (100000 - 0)` = 0.2 +- Output: `floor(100 * 0.2)` = 20 sats regular ecash +- Remaining 80 sats are not issued (HI holder's loss) + +### LO Holder Redemption + +Same attestation, same range: + +- Input: 100 sats of LO tokens + digit witness +- Payout ratio: `1 - 0.2` = 0.8 +- Output: `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..31da9bcf --- /dev/null +++ b/CTF-split-merge.md @@ -0,0 +1,218 @@ +# 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]). Users can deposit collateral to receive complete sets of conditional tokens (split), or surrender complete sets to recover collateral (merge). Inspired by the [Gnosis Conditional Token Framework](https://docs.gnosis.io/conditionaltokens/). + +Caution: Applications must verify that the mint supports both NUT-CTF and NUT-CTF-split-merge via the [info][06] endpoint. + +## Overview + +``` + 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 +``` + +1. **Register**: Condition + partition registered via [NUT-CTF][CTF] to create conditional keysets +2. **Split**: `Alice` deposits collateral, receives complete set of conditional tokens +3. **Trade**: Standard [NUT-03][03] swaps within same conditional keyset +4. **Attest**: Oracle signs winning outcome +5. **Redeem**: Winners use `POST /v1/redeem_outcome` ([NUT-CTF][CTF]) + +## Split Operation + +Deposits collateral and returns a complete set of conditional tokens. For every unit deposited, `Alice` receives one token per outcome collection. + +Conditions and partitions must be registered via [NUT-CTF][CTF] before splitting. + +```http +POST https://mint.host:3338/v1/ctf/split +``` + +**Request** of `Alice`: + +```json +{ + "condition_id": , + "inputs": , + "outputs": { + "": , + "": , + ... + } +} +``` + +- `condition_id`: 64-char hex. Returns error 13021 if unknown. +- `inputs`: `Proof` objects as collateral. Regular keyset for root conditions; parent collection's conditional keyset for nested. +- `outputs`: Object mapping each outcome collection to `BlindedMessage` arrays. Each MUST use the outcome-collection-specific keyset ID from partition registration. + +```bash +curl -X POST https://mint.host:3338/v1/ctf/split \ + -H "Content-Type: application/json" \ + -d '{"condition_id":"a1b2c3...","inputs":[...],"outputs":{"YES":[...],"NO":[...]}}' +``` + +### Output Requirements + +1. Output keys MUST form a previously registered partition +2. Each outcome collection's total amount MUST be identical +3. Each `BlindedMessage` MUST use the correct keyset ID +4. `sum(each_outcome_collection_outputs) = sum(inputs) - fees(inputs)` per [NUT-02][02] + +**Example** (binary market, 100 sats collateral): + +- `inputs`: 100 sats (regular keyset `009a1f293253e41e`) +- `outputs["YES"]`: 100 sats (conditional keyset `00abc123def456`) +- `outputs["NO"]`: 100 sats (conditional keyset `00def789abc012`) + +If error 13021 is returned, `Alice` SHOULD register the condition and partition first, then retry. + +### Mint Behavior + +`Bob`: + +1. Looks up condition (error 13021 if not found) +2. Validates output keys form a valid partition (error 13037/13038) +3. Validates keysets exist for all outcome collections (error 12001 if unknown) +4. Validates correct keyset IDs and equal amounts across outcome collections +5. Signs blinded messages + +**Response** of `Bob`: + +```json +{ + "signatures": { + "": , + "": , + ... + } +} +``` + +## Merge Operation + +Combines a complete set of conditional tokens back into collateral. Inverse of split. + +```http +POST https://mint.host:3338/v1/ctf/merge +``` + +**Request** of `Alice`: + +```json +{ + "condition_id": , + "inputs": { + "": , + "": , + ... + }, + "outputs": +} +``` + +- `condition_id`: 64-char hex (error 13021 if unknown) +- `inputs`: Object mapping each outcome collection to `Proof` arrays with correct keyset IDs +- `outputs`: `BlindedMessage` objects for collateral. Regular keyset for root; parent keyset for nested. + +```bash +curl -X POST https://mint.host:3338/v1/ctf/merge \ + -H "Content-Type: application/json" \ + -d '{"condition_id":"a1b2c3...","inputs":{"YES":[...],"NO":[...]},"outputs":[...]}' +``` + +### Input Requirements + +1. Input keys MUST form a valid partition +2. Each outcome collection's amount MUST be identical +3. Each `Proof` MUST use the correct keyset ID +4. `sum(outputs) = per_outcome_collection_amount - fees(all_inputs)` per [NUT-02][02] + +**Response** of `Bob`: + +```json +{ + "signatures": +} +``` + +### Merge Verification + +`Bob` MUST verify: (1) valid conditional keysets for the condition, (2) complete partition, (3) equal amounts, (4) correct output amount. No oracle witness required — the complete set cancels all risk. + +## Combinatorial Markets + +Conditions can be nested hierarchically. A user could bet on "Party A wins AND BTC > $100k" by splitting Party A tokens into BTC price sub-conditions. + +Outcome collection IDs use EC point addition ([NUT-CTF][CTF]), ensuring nesting order does not matter: `(Party_A) & (BTC_UP)` = `(BTC_UP) & (Party_A)`. + +When `parent_collection_id` is non-zero: + +- **Split inputs**: Parent collection's conditional keyset (not regular) +- **Merge outputs**: Parent collection's conditional keyset +- **Redemption**: Outputs go to parent keyset instead of regular + +See [supplementary material](suppl/CTF-split-merge.md) for a full combinatorial market example. + +## Security Considerations + +- **Atomicity**: Split and merge MUST be atomic — all signatures or none +- **Amount Conservation**: Split always creates ALL outcome collections with equal amounts; merge requires equal amounts of all +- **Depth Limits**: Mints MAY impose maximum nesting depth via [Mint Info Setting](#mint-info-setting) + +## 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 | + +## Mint Info Setting + +The [NUT-06][06] `MintMethodSetting`: + +```json +{ + "CTF-split-merge": { + "supported": true, + "max_depth": + } +} +``` + +- `supported`: Boolean indicating support +- `max_depth` (optional): Maximum nesting depth. If unspecified, only root conditions (depth 1) are supported. + +For a complete end-to-end example including registration, split, trading, and redemption, see the [supplementary material](suppl/CTF-split-merge.md). + +[00]: 00.md +[01]: 01.md +[02]: 02.md +[03]: 03.md +[04]: 04.md +[05]: 05.md +[06]: 06.md +[07]: 07.md +[08]: 08.md +[09]: 09.md +[10]: 10.md +[11]: 11.md +[12]: 12.md +[14]: 14.md +[21]: 21.md +[22]: 22.md +[CTF]: CTF.md +[CTF-numeric]: CTF-numeric.md diff --git a/CTF.md b/CTF.md new file mode 100644 index 00000000..4278521a --- /dev/null +++ b/CTF.md @@ -0,0 +1,533 @@ +# NUT-CTF: Conditional Token Framework + +`optional` + +`depends on: NUT-02, NUT-06` + +--- + +This NUT defines conditional tokens and conditional keysets for oracle-attested events. + +A **conditional token** is a regular Cashu token ([NUT-00][00]) signed under a conditional keyset. It can be transferred and swapped like any other Cashu token, with one additional ability: it can be redeemed for regular ecash via `POST /v1/redeem_outcome` by providing a DLC oracle's attestation signature as a witness. + +A **conditional keyset** is a per-outcome-collection signing keyset ([NUT-02][02]) that the mint creates during partition registration. Each outcome collection gets a unique keyset with different signing keys. + +The oracle signature scheme is compatible with the [DLC specification](https://github.com/discreetlogcontracts/dlcspecs/blob/master/Oracle.md), allowing Cashu mints to leverage existing DLC oracle infrastructure. + +Caution: Applications that rely on oracle resolution must verify that the oracle is trustworthy and check via the mint's [info][06] endpoint that NUT-CTF is supported. + +**Related specifications:** [NUT-CTF-split-merge][CTF-split-merge] defines split/merge operations for creating and dissolving complete sets of conditional tokens. [NUT-CTF-numeric][CTF-numeric] extends this framework with numeric outcome conditions. + +## Terminology + +- **Condition**: A question with defined outcomes, resolved by an oracle. Identified by a `condition_id`. Equivalent to "condition" in the [Gnosis Conditional Token Framework](https://docs.gnosis.io/conditionaltokens/). +- **Outcome**: A single atomic result that an oracle attests to (e.g., `"YES"`, `"ALICE"`). +- **Outcome collection**: A subset of outcomes, defined by a partition element (e.g., `"YES"`, `"ALICE|BOB"`). Each gets its own conditional keyset. Redeemable if the oracle attests to ANY outcome it contains. +- **Partition**: A division of all outcomes into disjoint, complete outcome collections. +- **Condition ID** (`condition_id`): 32-byte tagged hash uniquely identifying a condition. Partition-independent. See [Condition ID](#condition-id). +- **Outcome collection ID** (`outcome_collection_id`): 32-byte x-only public key uniquely identifying an outcome collection within a condition. See [Outcome Collection ID](#outcome-collection-id). + +## Outcome Collections + +Outcome collections allow tokens to represent one or more outcomes. An outcome collection is either a single outcome or an OR-combination joined by `|` (e.g., `"ALICE|BOB"` = "Alice or Bob wins"). If an outcome name contains `|`, it MUST be escaped as `\|`. + +### Partition Rules + +Partition keys MUST form a valid partition of all outcomes: + +1. **Disjoint**: No outcome appears in multiple outcome collections +2. **Complete**: Every outcome appears in exactly one outcome collection + +Valid partitions for outcomes `["ALICE", "BOB", "CAROL"]`: + +- `{"ALICE": [...], "BOB": [...], "CAROL": [...]}` (individual outcomes) +- `{"ALICE|BOB": [...], "CAROL": [...]}` (one collection + one individual) + +Invalid: `{"ALICE|BOB": [...], "BOB|CAROL": [...]}` (overlapping), `{"ALICE|BOB": [...]}` (incomplete). + +## Conditional Keysets + +Each outcome collection gets a unique keyset created during [partition registration](#register-partition). These use the same mechanism as regular keysets ([NUT-02][02]). + +**Properties:** + +- **Signing keys**: Unique keys derived by the mint from condition parameters +- **Unit**: Matches the collateral unit (e.g., `"sat"`) +- **Discovery**: Via `GET /v1/conditional_keysets` (see [Conditional Keyset Discovery](#conditional-keyset-discovery)) +- **Active flag**: `true` during condition lifetime, `false` after resolution + vesting period +- **Expiry**: MAY use `final_expiry` corresponding to vesting period end + +### Keyset ID Derivation + +Conditional keyset IDs extend [NUT-02 V2 derivation][02] by appending condition-specific data: + +``` + + "|condition_id:" + condition_id_hex + "|outcome_collection_id:" + outcome_collection_id_hex +``` + +The version byte remains `01`. In Python: + +```python +keyset_id_bytes += f"|condition_id:{condition_id}".encode("utf-8") +keyset_id_bytes += f"|outcome_collection_id:{outcome_collection_id}".encode("utf-8") +``` + +Where `condition_id` and `outcome_collection_id` are 64-character hex strings. This binding allows wallets to independently verify a keyset's condition and outcome collection. See [supplementary material](suppl/CTF.md#keyset-id-derivation-rationale) for rationale. + +## Token Lifecycle + +``` +Issuance: Mint issues conditional tokens (via 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 [NUT-CTF-split-merge][CTF-split-merge] split operations or other minting mechanisms. +- **Trading**: Standard [NUT-03][03] swap. All conditional keysets in a swap MUST share the same `outcome_collection_id`. No oracle witness required. +- **Redemption**: After oracle attestation, winners submit tokens to `POST /v1/redeem_outcome` with oracle signatures in `Proof.witness`. + +## Condition ID + +A condition is uniquely identified by a `condition_id` using a BIP-340 tagged hash: + +``` +condition_id = tagged_hash("Cashu_condition_id", sorted_oracle_pubkeys || event_id || outcome_count) +``` + +Where: + +- `tagged_hash(tag, msg) = SHA256(SHA256(tag) || SHA256(tag) || msg)` — [BIP-340 tagged hash](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) +- `sorted_oracle_pubkeys`: 32-byte x-only public keys, sorted lexicographically, concatenated. Derived from `announcements[].oracle_public_key`. +- `event_id`: UTF-8 encoded event identifier. Derived from `announcements[0].oracle_event.event_id`. All announcements MUST share the same `event_id`. +- `outcome_count`: 1-byte unsigned integer. Derived from `len(announcements[0].oracle_event.event_descriptor.outcomes)`. + +The `condition_id` is partition-independent — the same oracle event always produces the same ID regardless of partitioning. + +> **Note:** [NUT-CTF-numeric][CTF-numeric] extends this formula with additional parameters for numeric conditions. + +## Oracle Announcement Format + +Oracle announcements MUST use the TLV format defined in the [DLC specification](https://github.com/discreetlogcontracts/dlcspecs/blob/master/Messaging.md#the-oracle_announcement-type) (`oracle_announcement`, TLV type 55332). In API bodies, announcements are hex-encoded TLV byte strings. + +## Oracle Communication + +Oracle announcements and attestations use the [DLC specification](https://github.com/discreetlogcontracts/dlcspecs/blob/master/Oracle.md) format: + +- **Signing algorithm**: BIP 340 Schnorr signatures with tagged hash `"DLC/oracle/attestation/v0"` +- **Announcement format**: [DLC oracle announcement](https://github.com/discreetlogcontracts/dlcspecs/blob/master/Messaging.md#the-oracle_announcement-type) (TLV type 55332) +- **Event descriptors**: Enum event descriptors with UTF-8 NFC-normalized outcome strings + +The transport for discovering oracle announcements from oracles is unspecified. [NIP-88](https://github.com/nostr-protocol/nips/pull/1681) is one option. See [supplementary material](suppl/CTF.md#oracle-communication-notes) for additional notes. + +## Condition Registry + +Conditions are registered via `POST /v1/conditions` before any operations on conditional tokens. Conditional keysets are created during [partition registration](#register-partition). + +### Condition Info + +```json +{ + "condition_id": , + "threshold": , + "tags": , + "announcements": , + "registered_at": , + "keysets": { + "": , + ... + }, + "partitions": [ + { + "partition": , + "collateral": , + "parent_collection_id": , + "registered_at": + } + ], + "attestation": { + "status": , + "winning_outcome": , + "attested_at": + } +} +``` + +- `condition_id`: 64-character hex string (see [Condition ID](#condition-id)) +- `threshold`: Minimum oracles required for attestation (default: 1) +- `tags`: [NIP-88][NIP-88] tag array (e.g., `[["description", "..."], ["n", "BTC"]]`). Display-only metadata; does NOT affect `condition_id`. +- `announcements`: Hex-encoded oracle announcement TLV bytes +- `registered_at`: Unix timestamp of registration +- `keysets`: Flat map of ALL outcome collections to keyset IDs across all root-level partitions. Shared outcome collections appear once. Nested keysets (non-zero `parent_collection_id`) are not included — use `GET /v1/conditional_keysets`. +- `partitions`: Array of registered partitions: + - `partition`: Partition keys (e.g., `["YES", "NO"]`) + - `collateral`: Unit string for root (e.g., `"sat"`), or `outcome_collection_id` hex for nested + - `parent_collection_id`: 64-char hex; all zeros for root conditions + - `registered_at`: Unix timestamp +- `attestation` (optional, omitted if no attestation): + - `status`: `"pending"` | `"attested"` | `"expired"` | `"violation"` + - `winning_outcome`: Attested outcome string (`null` if pending) + - `attested_at`: Unix timestamp (`null` if pending) + +### Get Conditions + +```http +GET https://mint.host:3338/v1/conditions +``` + +**Query parameters:** + +- `since` (optional): Unix timestamp. Returns conditions with `registered_at >= since`. Wallets SHOULD first fetch all, then use `since` for incremental sync. +- `limit` (optional): Maximum conditions per response. +- `status` (optional, repeatable): Filter by `attestation.status`. E.g., `?status=pending&status=attested`. Conditions without `attestation` are treated as `pending`. + +Mints MUST return results ordered by `registered_at` ascending. Clients paginate by setting `since` to the last `registered_at` received and MUST deduplicate by `condition_id`. See [supplementary material](suppl/CTF.md#qa-design-decisions) for pagination rationale. + +**Response** of `Bob`: + +```json +{ + "conditions": +} +``` + +```bash +curl -X GET https://mint.host:3338/v1/conditions?status=pending&status=attested&limit=50 +``` + +### Get Condition + +```http +GET https://mint.host:3338/v1/conditions/{condition_id} +``` + +**Response** of `Bob`: + +```json +{ + "condition": +} +``` + +### Register Condition + +```http +POST https://mint.host:3338/v1/conditions +``` + +Registers a new condition. Does not create keysets — keysets are created during [partition registration](#register-partition). + +**Request** of `Alice`: + +```json +{ + "threshold": , + "tags": , + "announcements": +} +``` + +- `threshold`: Minimum oracles required (default: 1) +- `tags`: [NIP-88][NIP-88] tag array +- `announcements`: Hex-encoded oracle announcement TLV bytes + +**Response** of `Bob`: + +```json +{ + "condition_id": +} +``` + +```bash +curl -X POST https://mint.host:3338/v1/conditions \ + -H "Content-Type: application/json" \ + -d '{"threshold":1,"tags":[["description","Will BTC reach $100k?"]],"announcements":["fdd824fd..."]}' +``` + +#### Mint Behavior + +1. Parses and verifies announcement signatures (error 13011 if failed) +2. Computes `condition_id` +3. If condition exists with matching config: returns existing `condition_id` (idempotent) +4. If condition exists with different config: error 13028 +5. If new: stores and returns `condition_id` + +The mint MUST make condition registration idempotent. Mints MAY require [NUT-21][21] or [NUT-22][22] authentication for DoS prevention. + +### Register Partition + +```http +POST https://mint.host:3338/v1/conditions/{condition_id}/partitions +``` + +Registers a partition and creates conditional keysets. + +**Request** of `Alice`: + +```json +{ + "collateral": , + "partition": , + "parent_collection_id": +} +``` + +- `collateral`: Unit string for root (e.g., `"sat"`), or `outcome_collection_id` hex for nested +- `partition`: Partition keys (e.g., `["ALICE|BOB", "CAROL"]`). MUST satisfy [Partition Rules](#partition-rules). +- `parent_collection_id` (optional): 64-char hex. Defaults to all zeros for root conditions. + +**Response** of `Bob`: + +```json +{ + "keysets": { + "": , + "": , + ... + } +} +``` + +```bash +curl -X POST https://mint.host:3338/v1/conditions/a1b2c3d4.../partitions \ + -H "Content-Type: application/json" \ + -d '{"collateral":"sat","partition":["YES","NO"]}' +``` + +#### Mint Behavior + +1. Looks up condition (error 13021 if not found) +2. Validates partition rules (error 13037 overlapping, error 13038 incomplete) +3. If `parent_collection_id` is non-zero: verifies the referenced collection exists (error 13021 if not) +4. For each outcome collection: computes `outcome_collection_id`, reuses existing keyset or creates new one +5. Returns keyset map + +**Key property:** Keysets are per `outcome_collection_id`, not per partition. If two partitions include the same outcome collection (e.g., both include `"CAROL"`), they share the same keyset. This makes tokens fungible across partitions. + +**Idempotency:** The mint MUST make partition registration idempotent. + +**DoS prevention:** Mints MAY require [NUT-21][21] or [NUT-22][22] authentication. + +## Outcome Collection ID + +Each outcome collection has a unique `outcome_collection_id` derived from the condition ID, outcome collection string, and optional parent collection ID. The result is a 32-byte x-only public key on secp256k1. + +### 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`: BIP-340 tagged hash +- `hash_to_curve`: Same approach as [NUT-00][00]'s `hash_to_curve` with domain separation via tagged hash input +- `EC_add`: secp256k1 point addition +- `lift_x` / `x_only`: Per [BIP-340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) + +Because EC point addition is commutative, nesting order does not matter in combinatorial markets — `(Party_A) & (BTC_UP)` produces the same ID as `(BTC_UP) & (Party_A)`. + +## Conditional Keyset Discovery + +Conditional keysets are served on a dedicated endpoint, separate from `GET /v1/keysets` ([NUT-02][02]). This ensures backward compatibility and prevents conditional keysets from inflating the regular listing. + +```http +GET https://mint.host:3338/v1/conditional_keysets +``` + +**Query parameters:** + +- `since` (optional): Unix timestamp. Returns keysets with `registered_at >= since`. +- `limit` (optional): Maximum keysets per response. +- `active` (optional): Boolean filter on `active` flag. + +Mints MUST return results ordered by `registered_at` ascending. Same pagination approach as `GET /v1/conditions`. + +**Response** of `Bob`: + +Structurally identical to `GET /v1/keysets` ([NUT-02][02]) with four additional fields: + +```json +{ + "keysets": [ + { + "id": , + "unit": , + "active": , + "input_fee_ppk": , + "final_expiry": , + "condition_id": , + "outcome_collection": , + "outcome_collection_id": , + "registered_at": + } + ] +} +``` + +```bash +curl -X GET https://mint.host:3338/v1/conditional_keysets?active=true +``` + +The standard `GET /v1/keys/{keyset_id}` ([NUT-02][02]) still works for fetching public keys of a specific conditional keyset. + +## Redemption Witness + +When redeeming via `POST /v1/redeem_outcome`, each input `Proof` MUST include a `witness` with oracle attestation: + +```json +{ + "oracle_sigs": [ + { + "oracle_pubkey": , + "oracle_sig": + } + ] +} +``` + +- `oracle_sigs`: Array with at least `threshold` entries from distinct oracles + - `oracle_pubkey`: 32-byte x-only key (64-char hex) + - `oracle_sig`: 64-byte Schnorr signature (128-char hex) on the winning outcome + +Always use the array format, even for single-oracle markets (threshold=1). + +See [supplementary material](suppl/CTF.md#redemption-witness-comparison) for comparison with existing Cashu witness types. + +## Redemption Endpoint + +```http +POST https://mint.host:3338/v1/redeem_outcome +``` + +**Request** of `Alice`: + +```json +{ + "inputs": , + "outputs": +} +``` + +- `inputs`: `Proof` objects from a **single conditional keyset**, each with `witness` containing oracle attestation +- `outputs`: `BlindedMessage` objects using a **regular keyset** (same unit) + +`Alice` MAY omit `oracle_sigs` if `Bob` has already recorded a valid attestation for this outcome collection (check via `GET /v1/conditions/{condition_id}`). + +**Response** of `Bob`: + +```json +{ + "signatures": +} +``` + +```bash +curl -X POST https://mint.host:3338/v1/redeem_outcome \ + -H "Content-Type: application/json" \ + -d '{"inputs":[...],"outputs":[...]}' +``` + +### Consequence for NUT-03 + +Mints implementing NUT-CTF MUST enforce these rules on [NUT-03][03] swap: + +- Swaps within the same conditional keyset: **allowed** (trading) +- Swaps within regular keysets (including cross-keyset): **allowed** +- Swaps where all inputs and outputs share the same `outcome_collection_id`: **allowed** (key rotation) +- Swaps spanning different `outcome_collection_id` values: **MUST reject** +- Swaps mixing conditional and regular keysets: **MUST reject** + +All conditional-to-regular conversions go through `POST /v1/redeem_outcome`. + +## Redemption Verification + +When `Bob` receives a `POST /v1/redeem_outcome` request: + +1. All inputs MUST use the same conditional keyset +2. All outputs MUST use a regular keyset (same unit) +3. If `Bob` already has a valid attestation for this outcome collection, MAY skip steps 4-5 +4. Each input MUST include valid `witness` with `oracle_sigs` +5. Verify at least `threshold` signatures from distinct oracles using [DLC signing algorithm](https://github.com/discreetlogcontracts/dlcspecs/blob/master/Oracle.md#signing-algorithm) with tagged hash `"DLC/oracle/attestation/v0"` and UTF-8 NFC-normalized outcome string +6. Verify this outcome collection is the attested winner + +### Attestation Handling + +The mint MUST persistently record the first valid attestation (winning outcome + timestamp) for each condition. This record MUST survive restarts. + +The mint MUST NOT process redemptions for non-winning keysets. If a valid signature for a different outcome is received (a DLC protocol violation), the mint MUST reject it and MUST log the conflict. Mints SHOULD expose violations via condition info. + +## Vesting Period + +The mint MAY deactivate conditional keysets after a vesting period following event maturity. + +- Vesting period SHOULD be at least 30 days after `event_maturity_epoch` +- Mints MUST communicate vesting period via [Mint Info Setting](#mint-info-setting) +- After expiry: keyset `active` set to `false`; mint MAY refuse redemptions and delete event data +- Wallets SHOULD prominently display the deadline and alert users as it approaches + +### Oracle Non-Attestation + +If the oracle does not attest within expected time, the mint MAY refund conditional tokens to regular ecash at its discretion. + +## Error Codes + +| Code | Description | +| ----- | ----------------------------------------------------------- | +| 13010 | Invalid oracle signature | +| 13011 | Oracle announcement verification failed | +| 13014 | Conditional keyset requires oracle witness | +| 13015 | Oracle has not attested to this outcome collection | +| 13016 | Conditional keyset swap spans different outcome collections | +| 13017 | 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): Seconds after `event_maturity_epoch` for redemption. Default: 30 days (2592000). `0` = no expiry. +- `dlc_version`: DLC protocol version (currently `"0"`) + +[00]: 00.md +[01]: 01.md +[02]: 02.md +[03]: 03.md +[04]: 04.md +[05]: 05.md +[06]: 06.md +[07]: 07.md +[08]: 08.md +[09]: 09.md +[10]: 10.md +[11]: 11.md +[12]: 12.md +[14]: 14.md +[21]: 21.md +[22]: 22.md +[CTF-split-merge]: CTF-split-merge.md +[CTF-numeric]: CTF-numeric.md +[NIP-88]: https://github.com/nostr-protocol/nips/pull/1681 diff --git a/README.md b/README.md index eb653ccb..b84e4952 100644 --- a/README.md +++ b/README.md @@ -20,31 +20,34 @@ Wallets and mints `MUST` implement all mandatory specs and `CAN` implement optio ### Optional -| # | Description | Wallets | Mints | -| -------- | --------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------ | -| [07][07] | Token state check | [Nutshell][py], [Nutstash][ns], [cashu-ts][ts], [cdk], [Minibits], [macadamia] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [08][08] | Overpaid Lightning fees | [Nutshell][py], [Nutstash][ns], [cashu-ts][ts], [cdk], [Minibits], [macadamia] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [09][09] | Signature restore | [Nutshell][py], [cdk], [Cashu.me][cashume], [Minibits], [macadamia] | [Nutshell][py], [cdk-mintd] | -| [10][10] | Spending conditions | [Nutshell][py], [cdk], [cashu-ts][ts], [Minibits] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [11][11] | Pay-To-Pubkey (P2PK) | [Nutshell][py], [cdk], [Cashu.me][cashume], [Minibits] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [12][12] | DLEQ proofs | [Nutshell][py], [cdk], [cashu-ts][ts] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [13][13] | Deterministic secrets | [Nutshell][py], [cashu-ts][ts], [cdk], [macadamia], [Minibits] | - | -| [14][14] | Hashed Timelock Contracts (HTLCs) | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [15][15] | Partial multi-path payments (MPP) | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [16][16] | Animated QR codes | [Cashu.me][cashume], [macadamia], [Minibits] | - | -| [17][17] | WebSocket subscriptions | [Nutshell][py], [cdk], [Cashu.me][cashume], [Minibits] | [Nutshell][py], [cdk-mintd][cdk-mintd], [nutmix] | -| [18][18] | Payment requests | [Cashu.me][cashume], [Boardwalk][bwc], [cdk], [Minibits] | - | -| [19][19] | Cached Responses | - | [Nutshell][py], [cdk-mintd] | -| [20][20] | Signature on Mint Quote | [cdk], [Nutshell][py] | [cdk-mintd], [Nutshell][py] | -| [21][21] | Clear authentication | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [22][22] | Blind authentication | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [23][23] | Payment Method: BOLT11 | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [24][24] | HTTP 402 Payment Required | - | - | -| [25][25] | Payment Method: BOLT12 | [cdk], [cashu-ts][ts] | [cdk-mintd] | -| [26][26] | Payment Request Bech32m Encoding | [cdk], [cashu-ts][ts] | - | -| [27][27] | Nostr Mint Backup | [Cashu.me][cashume], [cdk] | - | -| [28][28] | Pay to Blinded Key (P2BK) | [cdk], [cashu-ts][ts] | - | -| [29][29] | Batched Mint | - | - | +| # | Description | Wallets | Mints | +| ---------------------------------- | --------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------ | +| [07][07] | Token state check | [Nutshell][py], [Nutstash][ns], [cashu-ts][ts], [cdk], [Minibits], [macadamia] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [08][08] | Overpaid Lightning fees | [Nutshell][py], [Nutstash][ns], [cashu-ts][ts], [cdk], [Minibits], [macadamia] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [09][09] | Signature restore | [Nutshell][py], [cdk], [Cashu.me][cashume], [Minibits], [macadamia] | [Nutshell][py], [cdk-mintd] | +| [10][10] | Spending conditions | [Nutshell][py], [cdk], [cashu-ts][ts], [Minibits] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [11][11] | Pay-To-Pubkey (P2PK) | [Nutshell][py], [cdk], [Cashu.me][cashume], [Minibits] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [12][12] | DLEQ proofs | [Nutshell][py], [cdk], [cashu-ts][ts] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [13][13] | Deterministic secrets | [Nutshell][py], [cashu-ts][ts], [cdk], [macadamia], [Minibits] | - | +| [14][14] | Hashed Timelock Contracts (HTLCs) | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [15][15] | Partial multi-path payments (MPP) | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [16][16] | Animated QR codes | [Cashu.me][cashume], [macadamia], [Minibits] | - | +| [17][17] | WebSocket subscriptions | [Nutshell][py], [cdk], [Cashu.me][cashume], [Minibits] | [Nutshell][py], [cdk-mintd][cdk-mintd], [nutmix] | +| [18][18] | Payment requests | [Cashu.me][cashume], [Boardwalk][bwc], [cdk], [Minibits] | - | +| [19][19] | Cached Responses | - | [Nutshell][py], [cdk-mintd] | +| [20][20] | Signature on Mint Quote | [cdk], [Nutshell][py] | [cdk-mintd], [Nutshell][py] | +| [21][21] | Clear authentication | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [22][22] | Blind authentication | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [23][23] | Payment Method: BOLT11 | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [24][24] | HTTP 402 Payment Required | - | - | +| [25][25] | Payment Method: BOLT12 | [cdk], [cashu-ts][ts] | [cdk-mintd] | +| [26][26] | Payment Request Bech32m Encoding | [cdk], [cashu-ts][ts] | - | +| [27][27] | Nostr Mint Backup | [Cashu.me][cashume], [cdk] | - | +| [28][28] | Pay to Blinded Key (P2BK) | [cdk], [cashu-ts][ts] | - | +| [29][29] | Batched Mint | - | - | +| [CTF][CTF] | Conditional Token Framework | - | - | +| [CTF-split-merge][CTF-split-merge] | Conditional Token Split and Merge | - | - | +| [CTF-numeric][CTF-numeric] | Numeric Outcome Conditions | - | - | #### Wallets @@ -103,3 +106,6 @@ Wallets and mints `MUST` implement all mandatory specs and `CAN` implement optio [27]: 27.md [28]: 28.md [29]: 29.md +[CTF]: CTF.md +[CTF-split-merge]: CTF-split-merge.md +[CTF-numeric]: CTF-numeric.md diff --git a/error_codes.md b/error_codes.md index 4c677a07..b42f7132 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], [NUT-29][29] | -| 20006 | Invoice already paid | [NUT-05][05] | -| 20007 | Quote is expired | [NUT-04][04], [NUT-05][05] | -| 20008 | Signature for mint request invalid | [NUT-20][20] | -| 20009 | Pubkey required for mint quote | [NUT-20][20] | -| 30001 | Endpoint requires clear auth | [NUT-21][21] | -| 30002 | Clear authentication failed | [NUT-21][21] | -| 31001 | Endpoint requires blind auth | [NUT-22][22] | -| 31002 | Blind authentication failed | [NUT-22][22] | -| 31003 | Maximum BAT mint amount exceeded | [NUT-22][22] | -| 31004 | BAT mint rate limit exceeded | [NUT-22][22] | +| Code | Description | Relevant nuts | +| ----- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| 10001 | Proof verification failed | [NUT-03][03], [NUT-05][05], [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], [NUT-29][29] | +| 20006 | Invoice already paid | [NUT-05][05] | +| 20007 | Quote is expired | [NUT-04][04], [NUT-05][05] | +| 20008 | Signature for mint request invalid | [NUT-20][20] | +| 20009 | Pubkey required for mint quote | [NUT-20][20] | +| 30001 | Endpoint requires clear auth | [NUT-21][21] | +| 30002 | Clear authentication failed | [NUT-21][21] | +| 31001 | Endpoint requires blind auth | [NUT-22][22] | +| 31002 | Blind authentication failed | [NUT-22][22] | +| 31003 | Maximum BAT mint amount exceeded | [NUT-22][22] | +| 31004 | BAT mint rate limit exceeded | [NUT-22][22] | +| 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 @@ -53,3 +73,6 @@ [21]: 21.md [22]: 22.md [29]: 29.md +[CTF]: CTF.md +[CTF-split-merge]: CTF-split-merge.md +[CTF-numeric]: CTF-numeric.md diff --git a/suppl/CTF-split-merge.md b/suppl/CTF-split-merge.md new file mode 100644 index 00000000..0f38849f --- /dev/null +++ b/suppl/CTF-split-merge.md @@ -0,0 +1,145 @@ +# Supplementary: NUT-CTF-split-merge Complete Example + +This document provides a full end-to-end example of the CTF split/merge lifecycle. For the normative specification, see [NUT-CTF-split-merge][CTF-split-merge]. + +## Complete Example + +### Step 1a: Register Condition + +First, register the condition via `POST /v1/conditions` ([NUT-CTF][CTF]): + +**Request** of `Alice`: + +```http +POST https://mint.host:3338/v1/conditions +``` + +```json +{ + "threshold": 1, + "tags": [["description", "Will BTC reach $100k by June 2025?"]], + "announcements": ["fdd824fd<...hex-encoded oracle_announcement TLV...>"] +} +``` + +`Bob` responds with: + +```json +{ + "condition_id": "a1b2c3d4e5f67890..." +} +``` + +### Step 1b: Register Partition + +**Request** of `Alice`: + +```http +POST https://mint.host:3338/v1/conditions/a1b2c3d4e5f67890.../partitions +``` + +```json +{ + "collateral": "sat", + "partition": ["YES", "NO"] +} +``` + +`Bob` responds with: + +```json +{ + "keysets": { + "YES": "00abc123def456", + "NO": "00def789abc012" + } +} +``` + +### Step 2: Split Collateral + +`Alice` wants to participate with 100 sats: + +**Request** of `Alice`: + +```http +POST https://mint.host:3338/v1/ctf/split +``` + +```json +{ + "condition_id": "a1b2c3d4e5f67890...", + "inputs": [ + { + "amount": 64, + "id": "009a1f293253e41e", + "secret": "random_secret_1", + "C": "02..." + }, + { + "amount": 32, + "id": "009a1f293253e41e", + "secret": "random_secret_2", + "C": "02..." + }, + { + "amount": 4, + "id": "009a1f293253e41e", + "secret": "random_secret_3", + "C": "02..." + } + ], + "outputs": { + "YES": [ + { "amount": 64, "id": "00abc123def456", "B_": "03..." }, + { "amount": 32, "id": "00abc123def456", "B_": "03..." }, + { "amount": 4, "id": "00abc123def456", "B_": "03..." } + ], + "NO": [ + { "amount": 64, "id": "00def789abc012", "B_": "03..." }, + { "amount": 32, "id": "00def789abc012", "B_": "03..." }, + { "amount": 4, "id": "00def789abc012", "B_": "03..." } + ] + } +} +``` + +`Bob` responds with: + +```json +{ + "signatures": { + "YES": [ + { "amount": 64, "id": "00abc123def456", "C_": "02..." }, + { "amount": 32, "id": "00abc123def456", "C_": "02..." }, + { "amount": 4, "id": "00abc123def456", "C_": "02..." } + ], + "NO": [ + { "amount": 64, "id": "00def789abc012", "C_": "02..." }, + { "amount": 32, "id": "00def789abc012", "C_": "02..." }, + { "amount": 4, "id": "00def789abc012", "C_": "02..." } + ] + } +} +``` + +`Alice` now holds 100 sats of YES tokens and 100 sats of NO tokens. + +### Step 3: Trading + +`Alice` believes YES will win, so she sells her NO tokens to `Carol` for 40 sats via a normal Cashu token transfer. `Carol` swaps at the mint using a standard [NUT-03][03] swap — all inputs and outputs use the NO conditional keyset. No oracle witness is needed. + +### Step 4: Oracle Attestation + +The oracle attests that YES won by publishing a DLC attestation signature on `"YES"`. + +### Step 5: Winner Redemption + +`Alice` redeems her YES tokens via `POST /v1/redeem_outcome` ([NUT-CTF][CTF]) with `oracle_sigs` witness. Inputs use the YES conditional keyset, outputs use a regular keyset. The mint verifies the oracle signatures and returns regular proofs. + +[00]: ../00.md +[02]: ../02.md +[03]: ../03.md +[06]: ../06.md +[CTF]: ../CTF.md +[CTF-split-merge]: ../CTF-split-merge.md diff --git a/suppl/CTF.md b/suppl/CTF.md new file mode 100644 index 00000000..88b01192 --- /dev/null +++ b/suppl/CTF.md @@ -0,0 +1,61 @@ +# Supplementary: NUT-CTF Design Decisions + +## Q&A: Design Decisions + +### Why "download all, then sync" instead of server-side filtering? + +Supporting complex query combinations (filter by oracle, by unit, by date range, etc.) increases server complexity and creates a DoS vector — an attacker can craft expensive queries to burden the mint. More importantly, fine-grained server-side filtering leaks information about which conditions a wallet cares about, potentially revealing trading positions to the mint. The "download all, then sync with `since`" pattern keeps the server stateless and simple: every client gets the same data, preserving privacy. Since the total number of conditions on a single mint is expected to remain manageable, full downloads are practical. + +### Why `>=` instead of `>` for the `since` parameter? + +Unix timestamps have second-level precision. If two conditions are registered within the same second and the client uses `>` (strict greater-than), it could silently skip items that share the boundary timestamp. Using `>=` (greater-than-or-equal) guarantees that no items are missed at the cost of re-delivering boundary items. Clients MUST deduplicate by `condition_id` (or keyset `id` for the keysets endpoint), which is trivial with a local set. + +### When should users merge vs. wait for resolution? + +Merge is useful when: + +- A user holds a complete set and wants to exit their position before oracle attestation +- Market conditions change and the user wants to recover collateral immediately +- Arbitrage opportunities exist between the market price and collateral value + +Waiting for resolution is simpler when: + +- The user expects one outcome to win and wants to maximize profit +- Transaction fees make merge uneconomical + +## Keyset ID Derivation Rationale + +Without condition-specific data in the keyset ID, a wallet cannot verify from the keyset ID alone that a keyset is bound to a particular condition and outcome collection. By including `condition_id` and `outcome_collection_id` in the preimage, the wallet can recompute the keyset ID and confirm the mint's claim about which condition and outcome collection a keyset serves. + +## Redemption Witness Comparison + +The Redemption Witness extends the established Cashu pattern where `Proof.witness` carries condition-specific unlock data: + +| NUT | Witness Type | Format | Trigger | +| ------------------------- | ------------------ | ------------------------------------------ | ----------------------------------- | +| [NUT-11][11] (P2PK) | Signature | `{"signatures": [...]}` | Secret is P2PK kind ([NUT-10][10]) | +| [NUT-14][14] (HTLC) | Preimage + sig | `{"preimage": "...", "signatures": [...]}` | Secret is HTLC kind ([NUT-10][10]) | +| **NUT-CTF** (Conditional) | Oracle attestation | `{"oracle_sigs": [...]}` | Dedicated `redeem_outcome` endpoint | + +Key difference: [NUT-11][11] and [NUT-14][14] witnesses are triggered by the **secret structure** ([NUT-10][10] well-known format). NUT-CTF witnesses are triggered by the **endpoint** — the dedicated `POST /v1/redeem_outcome` endpoint requires oracle attestation. Proof secrets remain plain random strings. + +## Oracle Communication Notes + +### Note on adaptor signatures + +This specification does NOT use adaptor signatures. In Cashu's custodial model, the mint directly verifies the oracle's BIP 340 signature — no adaptor encryption/decryption is needed. + +### Note on oracle attestation optionality + +Oracle attestation is optional in principle. When the mint operator serves as the oracle (e.g., resolving disputes manually), no external attestation is needed. However, oracle attestation is useful for two reasons: (1) It provides a standardized way for mints to verify redemption claims, and (2) When combined with DLEQ Proof ([NUT-12][12]) and [Proof of Liabilities](https://gist.github.com/callebtc/ed5228d1d8cbaade0104db5d1cf63939), it can serve as a fraud proof if the mint fails to honor valid redemptions. + +[00]: ../00.md +[02]: ../02.md +[03]: ../03.md +[06]: ../06.md +[10]: ../10.md +[11]: ../11.md +[12]: ../12.md +[14]: ../14.md +[CTF]: ../CTF.md +[CTF-split-merge]: ../CTF-split-merge.md diff --git a/tests/CTF-numeric-tests.md b/tests/CTF-numeric-tests.md new file mode 100644 index 00000000..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 From 572e190b501c74703615cf3d7062a82c08f65780 Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Mon, 6 Apr 2026 20:53:29 +0900 Subject: [PATCH 2/2] Update test vectors according to the recent update --- tests/CTF-numeric-tests.md | 46 +++++++++++++++++------ tests/CTF-split-merge-tests.md | 8 ++-- tests/CTF-tests.md | 67 ++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 16 deletions(-) diff --git a/tests/CTF-numeric-tests.md b/tests/CTF-numeric-tests.md index eeec89f1..f6a91fc0 100644 --- a/tests/CTF-numeric-tests.md +++ b/tests/CTF-numeric-tests.md @@ -7,20 +7,28 @@ These test vectors provide reference data for implementing numeric outcome marke ### Test 1: Register numeric market (HI/LO) ```shell -# Register a numeric market via POST /v1/conditions -request_json: { - "collateral": "sat", +# Step 1: Register a numeric condition via POST /v1/conditions +register_request: { "threshold": 1, - "description": "BTC/USD price on 2025-07-01", + "tags": [["description", "BTC/USD price on 2025-07-01"], ["n", "BTC"]], "announcements": [""], - "market_type": "numeric", + "condition_type": "numeric", "lo_bound": 0, "hi_bound": 100000, "precision": 0 } -response_json: { - "condition_id": "", +register_response: { + "condition_id": "" +} + +# Step 2: Register partition via POST /v1/conditions/{condition_id}/partitions +partition_request: { + "collateral": "sat", + "partition": ["HI", "LO"] +} + +partition_response: { "keysets": { "HI": "00hi11keyset22", "LO": "00lo33keyset44" @@ -29,9 +37,10 @@ response_json: { # Partition is always ["HI", "LO"] for numeric markets # condition_id = tagged_hash("Cashu_condition_id", -# oracle_pubkey || event_id || 0x02 || "HI" + 0x00 + "LO" +# sorted_oracle_pubkeys || event_id || outcome_count # || 0x01 || lo_bound_i64be || hi_bound_i64be || precision_i32be) -# where lo_bound_i64be = 0x0000000000000000 (0 as i64 big-endian) +# where outcome_count = 0x02 (always 2 for numeric) +# lo_bound_i64be = 0x0000000000000000 (0 as i64 big-endian) # hi_bound_i64be = 0x00000000000186a0 (100000 as i64 big-endian) # precision_i32be = 0x00000000 (0 as i32 big-endian) ``` @@ -41,11 +50,10 @@ response_json: { ```shell # lo_bound >= hi_bound request_json: { - "collateral": "sat", "threshold": 1, - "description": "Invalid range market", + "tags": [["description", "Invalid range market"]], "announcements": [""], - "market_type": "numeric", + "condition_type": "numeric", "lo_bound": 100000, "hi_bound": 100000, "precision": 0 @@ -349,6 +357,20 @@ error_code: 13033 error_message: "Payout calculation overflow" ``` +### Test 17: Attested value outside representable digit range + +```shell +# Oracle announcement specifies 3 digits (max representable: 999) +# But digit signatures reconstruct to a value outside that range +# e.g., sign byte "+" then digits "1", "0", "0", "0" = 1000 (4 digits, exceeds 3-digit max) +oracle_pubkey: 9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0 +max_representable: 999 +reconstructed: 1000 + +error_code: 13032 +error_message: "Attested value outside representable range" +``` + [NUT-CTF-numeric]: ../CTF-numeric.md [NUT-CTF]: ../CTF.md [NUT-CTF-split-merge]: ../CTF-split-merge.md diff --git a/tests/CTF-split-merge-tests.md b/tests/CTF-split-merge-tests.md index b41cbab4..16cefedf 100644 --- a/tests/CTF-split-merge-tests.md +++ b/tests/CTF-split-merge-tests.md @@ -63,7 +63,7 @@ outcome_count_byte: 02 # Step 1: Register condition (POST /v1/conditions) register_request: { "threshold": 1, - "description": "Will BTC reach $100k?", + "tags": [["description", "Will BTC reach $100k?"]], "announcements": [""] } @@ -93,7 +93,7 @@ partition_response: { # Step 1: Register condition register_request: { "threshold": 1, - "description": "Election winner", + "tags": [["description", "Election winner"]], "announcements": [""] } @@ -606,7 +606,7 @@ btc_price_condition_id: b2c3d4e5f6789012345678901234567890123456789012345678901 # Step 1a: Register root election condition (POST /v1/conditions) root_condition_request: { "threshold": 1, - "description": "Election winner", + "tags": [["description", "Election winner"]], "announcements": [""] } @@ -630,7 +630,7 @@ root_partition_response: { # Step 2a: Register nested BTC price condition (POST /v1/conditions) nested_condition_request: { "threshold": 1, - "description": "BTC price conditional on Party A win", + "tags": [["description", "BTC price conditional on Party A win"]], "announcements": [""] } diff --git a/tests/CTF-tests.md b/tests/CTF-tests.md index f5dde1e5..f3249516 100644 --- a/tests/CTF-tests.md +++ b/tests/CTF-tests.md @@ -252,4 +252,71 @@ error: Invalid oracle public key format error_code: 13010 ``` +### Test 14: Swap spanning different outcome collections + +```shell +# Attempt NUT-03 swap with inputs from YES keyset and outputs to NO keyset +swap_json: { + "inputs": [ + {"amount": 64, "id": "00abc123def456", "secret": "yes_secret_1", "C": "02..."} + ], + "outputs": [ + {"amount": 64, "id": "00def789abc012", "B_": "03..."} + ] +} + +# Input keyset (00abc123def456) has outcome_collection_id for YES +# Output keyset (00def789abc012) has outcome_collection_id for NO +# Different outcome_collection_id values — MUST reject +error_code: 13016 +error_message: "Conditional keyset swap spans different outcome collections" +``` + +### Test 15: Redemption outputs must use regular keyset + +```shell +# Attempt POST /v1/redeem_outcome with conditional keyset in outputs +redeem_json: { + "inputs": [ + { + "amount": 64, + "id": "00abc123def456", + "secret": "yes_secret_1", + "C": "02...", + "witness": "{\"oracle_sigs\":[{\"oracle_pubkey\":\"9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce0\",\"oracle_sig\":\"a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890\"}]}" + } + ], + "outputs": [ + {"amount": 64, "id": "00def789abc012", "B_": "03..."} + ] +} + +# Output keyset (00def789abc012) is a conditional keyset, not a regular keyset +error_code: 13017 +error_message: "Outputs must use a regular keyset" +``` + +### Test 16: Re-register existing condition with different config + +```shell +# Condition already registered with threshold=1 +# Attempt to register same oracle event with threshold=2 +register_request_1: { + "threshold": 1, + "tags": [["description", "Will BTC reach $100k?"]], + "announcements": [""] +} +# Returns: {"condition_id": ""} + +register_request_2: { + "threshold": 2, + "tags": [["description", "Will BTC reach $100k?"]], + "announcements": [""] +} + +# Same announcements but different threshold — conflict +error_code: 13028 +error_message: "Condition already exists" +``` + [NUT-CTF]: ../CTF.md