From 618bf7812ca12232e86970df9af7a2cd23d6e19c Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Mon, 6 Apr 2026 18:20:33 +0900 Subject: [PATCH 01/27] 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 02/27] 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 From 3a937e333e984e3684fae8a907a60de24861d0d7 Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Wed, 27 May 2026 11:00:48 +0900 Subject: [PATCH 03/27] NUT-CTF: replace broken Gnosis docs link The docs.gnosis.io/conditionaltokens/ URL returns 404. Replace with the official readthedocs site (rendered from the same gnosis/conditional-tokens-contracts repo) and link the contracts repo alongside as a stable fallback. Add Polymarket as a real-world production example of the same model in CTF.md's Terminology section. --- CTF-numeric.md | 2 +- CTF-split-merge.md | 2 +- CTF.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CTF-numeric.md b/CTF-numeric.md index 5a58dd31..c0134c5b 100644 --- a/CTF-numeric.md +++ b/CTF-numeric.md @@ -8,7 +8,7 @@ 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. +This follows the [Gnosis CTF scalar condition model](https://conditional-tokens.readthedocs.io/en/latest/) ([contracts](https://github.com/gnosis/conditional-tokens-contracts)) and uses [DLC digit-decomposition oracle attestation](https://github.com/discreetlogcontracts/dlcspecs/blob/master/Oracle.md) for interoperability. ## HI/LO Conditions diff --git a/CTF-split-merge.md b/CTF-split-merge.md index 31da9bcf..d5814e46 100644 --- a/CTF-split-merge.md +++ b/CTF-split-merge.md @@ -6,7 +6,7 @@ --- -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/). +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://conditional-tokens.readthedocs.io/en/latest/) ([contracts](https://github.com/gnosis/conditional-tokens-contracts)). Caution: Applications must verify that the mint supports both NUT-CTF and NUT-CTF-split-merge via the [info][06] endpoint. diff --git a/CTF.md b/CTF.md index 4278521a..fb4ca818 100644 --- a/CTF.md +++ b/CTF.md @@ -20,7 +20,7 @@ Caution: Applications that rely on oracle resolution must verify that the oracle ## 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/). +- **Condition**: A question with defined outcomes, resolved by an oracle. Identified by a `condition_id`. Equivalent to "condition" in the [Gnosis Conditional Token Framework](https://conditional-tokens.readthedocs.io/en/latest/) ([contracts](https://github.com/gnosis/conditional-tokens-contracts)), which this spec adapts to Cashu. [Polymarket](https://github.com/Polymarket/ctf-exchange-v2) is a large-scale production deployment of the same model on Ethereum. - **Outcome**: A single atomic result that an oracle attests to (e.g., `"YES"`, `"ALICE"`). - **Outcome collection**: A 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. From 9c36426493e6ff5060c211d36be0daee030846de Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Fri, 5 Jun 2026 14:55:52 +0900 Subject: [PATCH 04/27] NUT-CTF: unify split/merge into payoff-preserving convert Replace POST /v1/ctf/split and /v1/ctf/merge with a single POST /v1/ctf/convert that performs any payoff-preserving rebalance of conditional positions within one condition at one nesting level. Split, merge, recombine, and negative-risk-style conversion all become special cases. Core rule: for every outcome o in Omega, out(o) == in(o) - F, valid only if in(o) >= F, where F = ceil(sum input_fee_ppk / 1000). The flat fee is retained non-contingently on every outcome (real collateral the mint keeps regardless of the attested outcome), which bounds free operations. Collateral is the reserved map key '*' (regular keyset at root, parent conditional keyset when nested). Coverage is computed from registered keyset metadata, not request map keys. Canonical collection encoding fixed (enum: announcements[0] outcome index; numeric: [HI,LO]). Partitions must have >=2 elements with no full-set conditional collection. Issuance invariant: every conditional token backed by >= its face in collateral. Adds error codes 13041/13042/13043; updates CTF.md, CTF-numeric.md, error_codes, suppl, and test vectors accordingly. Design and post-implementation passes adversarially reviewed via Codex (verdict: sound to merge). --- CTF-numeric.md | 10 +- CTF-split-merge.md | 251 ++++++++++++++++++--------------- CTF.md | 26 ++-- README.md | 2 +- error_codes.md | 7 +- suppl/CTF-split-merge.md | 74 ++++++---- tests/CTF-numeric-tests.md | 30 ++-- tests/CTF-split-merge-tests.md | 221 ++++++++++++++++++++--------- 8 files changed, 389 insertions(+), 232 deletions(-) diff --git a/CTF-numeric.md b/CTF-numeric.md index c0134c5b..8a8246b5 100644 --- a/CTF-numeric.md +++ b/CTF-numeric.md @@ -174,14 +174,14 @@ Same attestation, same range: 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 +## Convert (Split and Merge) -Split and merge operations work identically to [NUT-CTF-split-merge][CTF-split-merge] enum conditions: +The [NUT-CTF-split-merge][CTF-split-merge] `convert` operation applies to numeric conditions. For canonicalisation, a numeric condition's outcome-atom set is the fixed synthetic order `Ω = ["HI", "LO"]`, independent of the digit-decomposition oracle fields. The only outcome collections are the two single atoms `HI` and `LO`; the full set `HI|LO` is the collateral/forbidden full-set and is never a conditional keyset. Convert on a numeric condition therefore reduces to: -- **Split**: Deposit collateral, receive equal amounts of HI and LO tokens -- **Merge**: Surrender equal amounts of HI and LO tokens, receive collateral back +- **Split**: Deposit collateral, receive equal amounts of HI and LO tokens (`outputs {"HI": ..., "LO": ...}`) +- **Merge**: Surrender equal amounts of HI and LO tokens, receive collateral back (`outputs {"*": ...}`) -No special handling is needed — numeric conditions always have exactly 2 outcome collections (`HI`, `LO`). +Exact-equality conservation preserves the HI/LO face vector, so the proportional redemption above is unaffected. No other special handling is needed. ## Combinatorial Markets diff --git a/CTF-split-merge.md b/CTF-split-merge.md index d5814e46..5cafad3d 100644 --- a/CTF-split-merge.md +++ b/CTF-split-merge.md @@ -1,4 +1,4 @@ -# NUT-CTF-split-merge: Conditional Token Split and Merge +# NUT-CTF-split-merge: Conditional Token Convert (Split, Merge, Recombine) `optional` @@ -6,34 +6,43 @@ --- -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://conditional-tokens.readthedocs.io/en/latest/) ([contracts](https://github.com/gnosis/conditional-tokens-contracts)). +This NUT defines a single **convert** operation for conditional tokens ([NUT-CTF][CTF]): any payoff-preserving rebalance of conditional positions within one condition at one nesting level. Convert subsumes four operations: + +- **Split**: deposit collateral, receive a complete set of conditional tokens. +- **Merge**: surrender a complete set of conditional tokens, recover collateral. +- **Recombine**: regroup outcome collections (e.g. `A` + `B|C` → `A|B|C`) without touching collateral. +- **Conversion** (negative-risk style): cross the collateral boundary mid-bundle (e.g. `A|B|C` + `B|C|D` → collateral + `B|C`). + +Inspired by the [Gnosis Conditional Token Framework](https://conditional-tokens.readthedocs.io/en/latest/) ([contracts](https://github.com/gnosis/conditional-tokens-contracts)), whose `splitPosition`/`mergePositions` operate on arbitrary partitions of any outcome collection. [Polymarket](https://github.com/Polymarket/ctf-exchange-v2)'s negative-risk position conversion is the special case where collateral appears mid-bundle; convert provides it natively with no separate adaptor. Caution: Applications must verify that the mint supports both NUT-CTF and NUT-CTF-split-merge via the [info][06] endpoint. ## Overview ``` - Register 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 + Register Convert (split) Trade Convert (merge) Attest Redeem +Wallet ────────────► Mint User ──────────────► Conditional ◄───────────► User ──────────► ... Oracle ──────► Winner ─────► + cond. info creates collateral Tokens NUT-03 complete set Signs redeem_outcome + keysets + fee F (per-OC → collateral Outcome → Regular + keysets) (− fee F) Keyset ``` -1. **Register**: Condition + 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]) +1. **Register**: Condition + partition registered via [NUT-CTF][CTF] to create conditional keysets. +2. **Convert**: `Alice` submits an input bundle of proofs and an output bundle of blinded messages; the mint signs the outputs iff the operation is payoff-preserving after a flat fee (see [Conservation Rule](#conservation-rule)). +3. **Trade**: Standard [NUT-03][03] swaps within the same conditional keyset. +4. **Attest**: Oracle signs winning outcome. +5. **Redeem**: Winners use `POST /v1/redeem_outcome` ([NUT-CTF][CTF]). -## Split Operation +## Payoff-Vector Model -Deposits collateral and returns a complete set of conditional tokens. For every unit deposited, `Alice` receives one token per outcome collection. +For a condition with outcome-atom set **Ω** (for nested conditions, the child outcomes under the parent collection), a token for outcome collection `S` with amount `a` is the **payoff vector** that pays `a` on each outcome in `cover(S)` and `0` elsewhere. **Collateral** — plain ecash on a regular keyset (root), or the parent collection's conditional keyset (nested) — is the **all-ones** vector: it pays on every outcome in Ω. -Conditions and partitions must be registered via [NUT-CTF][CTF] before splitting. +A convert is valid iff the input and output payoff vectors are equal after deducting a flat fee on every outcome. This single condition generalises split, merge, recombine, and conversion. + +## Convert Operation ```http -POST https://mint.host:3338/v1/ctf/split +POST https://mint.host:3338/v1/ctf/convert ``` **Request** of `Alice`: @@ -41,144 +50,162 @@ POST https://mint.host:3338/v1/ctf/split ```json { "condition_id": , - "inputs": , + "parent_collection_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. +- `condition_id`: 64-char hex (error 13021 if unknown). +- `parent_collection_id` (optional): 64-char hex. Defaults to all zeros for root conditions. +- `inputs` / `outputs`: objects mapping each map key to an array of `Proof` / `BlindedMessage`. A map key is either a **canonical outcome collection string** (see [Canonical Collection Encoding](#canonical-collection-encoding)) or the reserved key `"*"` denoting **collateral**: + - For root conditions (`parent_collection_id` all zeros), `"*"` uses the **regular keyset** of the collateral unit. + - For nested conditions, `"*"` uses the **parent collection's conditional keyset** — the active keyset whose `outcome_collection_id` equals `parent_collection_id`. +- Duplicate JSON member names within `inputs` or `outputs` MUST be rejected. + +**Response** of `Bob`: + +```json +{ + "signatures": { + "": , + ... + } +} +``` + +Each `signatures` array preserves the order of the corresponding `outputs` array. ```bash -curl -X POST https://mint.host:3338/v1/ctf/split \ +curl -X POST https://mint.host:3338/v1/ctf/convert \ -H "Content-Type: application/json" \ - -d '{"condition_id":"a1b2c3...","inputs":[...],"outputs":{"YES":[...],"NO":[...]}}' + -d '{"condition_id":"a1b2c3...","inputs":{"*":[...],"A":[...],"B|C":[...]},"outputs":{"A|B|C":[...]}}' ``` -### Output Requirements +### Special Cases -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] +| Operation | `inputs` | `outputs` | +| --------- | -------- | --------- | +| Split | `{"*": [...]}` | per-outcome-collection map (a partition) | +| Merge | per-outcome-collection map (a partition) | `{"*": [...]}` | +| Recombine | conditional collections only (no `"*"`) | conditional collections only (no `"*"`) | +| Conversion | conditional ± `"*"` | conditional ± `"*"` | -**Example** (binary market, 100 sats collateral): +## Conservation Rule -- `inputs`: 100 sats (regular keyset `009a1f293253e41e`) -- `outputs["YES"]`: 100 sats (conditional keyset `00abc123def456`) -- `outputs["NO"]`: 100 sats (conditional keyset `00def789abc012`) +Let: -If error 13021 is returned, `Alice` SHOULD register the condition and partition first, then retry. +- For each entry, let its **cover** be `Ω` if its keyset is the collateral keyset (the `"*"` entry), otherwise the outcome set of the canonical collection recorded in that keyset's **metadata** (see [Coverage From Keyset Metadata](#coverage-from-keyset-metadata)). The map key is validated to equal that canonical collection but is never itself used to compute coverage. +- `in(o)` = the sum of input amounts whose entry covers `o`, for each `o ∈ Ω`. +- `out(o)` = the sum of output amounts whose entry covers `o`, for each `o ∈ Ω`. +- `F` = `ceil( Σ input_fee_ppk / 1000 )` over **all** input proofs (both `"*"` and conditional), per [NUT-02][02]. This flat fee is denominated in the collateral unit. -### Mint Behavior +`Bob` MUST enforce, for **every** `o ∈ Ω`: -`Bob`: +``` +out(o) == in(o) − F +``` -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 +and MUST reject the request unless `in(o) ≥ F` for every `o ∈ Ω` (error 13041). -**Response** of `Bob`: +`in(o)` and `out(o)` are computed by expanding each token's **registered** canonical collection to its outcome set and summing overlaps **per outcome** (collections on the same side may overlap; e.g. outputs `A|B` and `B|C` both contribute to outcome `B`). -```json -{ - "signatures": { - "": , - "": , - ... - } -} -``` +This rule: -## Merge Operation +- preserves the payoff vector exactly except for the flat fee `F`, which the mint retains on **every** outcome — so the fee is real collateral the mint keeps regardless of which outcome the oracle attests (non-contingent); +- requires the input bundle to cover the fee on every outcome. For a floor-0 recombine the user includes `F` of collateral under `"*"` to satisfy `in(o) ≥ F` everywhere; +- reduces to the classical split (`each outcome collection total == sum(inputs) − F`) and merge. -Combines a complete set of conditional tokens back into collateral. Inverse of split. +### Examples -```http -POST https://mint.host:3338/v1/ctf/merge -``` +**Split** (binary market, 100 sats collateral, `F = 0`): -**Request** of `Alice`: +- `inputs["*"]`: 100 sats (regular keyset `009a1f293253e41e`) +- `outputs["YES"]`: 100 sats (conditional keyset `00abc123def456`) +- `outputs["NO"]`: 100 sats (conditional keyset `00def789abc012`) +- `in(YES)=in(NO)=100`; `out(YES)=out(NO)=100`. ✓ -```json -{ - "condition_id": , - "inputs": { - "": , - "": , - ... - }, - "outputs": -} -``` +**Recombine** (`Ω = {A,B,C,D}`, `F = 1`): -- `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. +- `inputs`: `{"*": 1, "A": 1, "B|C": 1}` → `in = (A:2, B:2, C:2, D:1)` +- `outputs`: `{"A|B|C": 1}` → `out = (A:1, B:1, C:1, D:0)` +- `out(o) == in(o) − 1` for every `o`. ✓ The mint keeps 1 on every outcome. -```bash -curl -X POST https://mint.host:3338/v1/ctf/merge \ - -H "Content-Type: application/json" \ - -d '{"condition_id":"a1b2c3...","inputs":{"YES":[...],"NO":[...]},"outputs":[...]}' -``` +**Conversion** (negative-risk style, `Ω = {A,B,C,D}`, `F = 1`): -### Input Requirements +- `inputs`: `{"A|B|C": 100, "B|C|D": 100}` → `in = (A:100, B:200, C:200, D:100)` +- `outputs`: `{"*": 99, "B|C": 100}` → `out = (A:99, B:199, C:199, D:99)` +- `out(o) == in(o) − 1` for every `o`. ✓ The holder withdraws 99 sats of spendable collateral (the bundle's guaranteed floor minus the fee) plus a `B|C` position, with no oracle witness. -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] +## Coverage From Keyset Metadata -**Response** of `Bob`: +A map key is untrusted text and MUST NOT be used to compute coverage. For every non-`"*"` entry, `Bob` MUST resolve each `Proof`/`BlindedMessage` keyset `id` to its **stored keyset metadata** (error 12001 if unknown) and verify that the metadata matches this `condition_id`, `parent_collection_id`, unit, and a canonical outcome collection. The request map key MUST equal that canonical collection (error 13041 otherwise). `cover()` is derived from the metadata, never from the request string. Every `"*"` proof/message MUST use the collateral keyset defined above (error 13017 if a regular/conditional keyset is placed under the wrong key). -```json -{ - "signatures": -} -``` +## Canonical Collection Encoding -### Merge Verification +Outcome collections have a single canonical string form, used both as map keys here and in [NUT-CTF][CTF] keyset-ID derivation, so that `A|B` and `B|A` denote one keyset: -`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. +- Outcome names are NFC-normalised. +- Components are ordered by **condition type**: + - **enum** ([NUT-CTF][CTF]): by the outcome's index in `announcements[0].oracle_event.event_descriptor.outcomes` — the same announcement that defines `outcome_count` in the [condition ID][CTF]. All announcements for a condition MUST share the same ordered outcome list, so this index is unambiguous. + - **numeric** ([NUT-CTF-numeric][CTF-numeric]): the fixed synthetic order `["HI", "LO"]`, independent of digit-oracle fields. +- Components are joined by `|`. A literal `|` in an outcome name is escaped `\|`; a literal `\` is escaped `\\`. -## Combinatorial Markets +`Bob` MUST reject (error 13041): non-canonical ordering or encoding, duplicate outcomes, empty components, unknown outcomes, malformed escapes, the reserved token `"*"`, and any **full-set** cover. -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. +## Mint Behavior -Outcome collection IDs use EC point addition ([NUT-CTF][CTF]), ensuring nesting order does not matter: `(Party_A) & (BTC_UP)` = `(BTC_UP) & (Party_A)`. +`Bob`: -When `parent_collection_id` is non-zero: +1. Looks up the condition (error 13021 if not found). Rejects if an attestation has been recorded for the condition (error 13042), or if any involved keyset is inactive (error 12002). These two conditions are the only cutoffs: maturity is **not** a cutoff, and there is no separate status check — a `violation` status implies a recorded (conflicting) attestation, and an `expired` condition implies inactive keysets, so both are already covered. Convert is allowed until an attestation is recorded, provided the keysets are still active. +2. For each entry, resolves keyset metadata and validates `condition_id`, `parent_collection_id`, unit, canonical collection == map key, and `"*"` ↔ collateral keyset placement (see [Coverage From Keyset Metadata](#coverage-from-keyset-metadata)). +3. Rejects if any two input proofs across all entries share a secret (error 11007), or if any input or output amount is not positive, or if there is no input, or if `outputs` is empty. +4. Verifies all input proofs are valid and unspent (errors 10001 / 11001). +5. Computes `in(o)`, `out(o)`, and `F`, and enforces the [Conservation Rule](#conservation-rule) for every `o ∈ Ω` (error 13041). +6. Atomically marks all input proofs spent, signs all output blinded messages, and persists the signatures in a **single transaction**. No output is signed until every input is verified and reserved. -- **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 +## Security Considerations -See [supplementary material](suppl/CTF-split-merge.md) for a full combinatorial market example. +- **Atomicity**: Convert MUST be atomic — all signatures or none. Duplicate-detection, spent-marking, and signature persistence occur in one transaction. +- **Solvency**: Let `R` be the real collateral the mint holds and `L(o)` the outstanding conditional liability at outcome `o`. The conservation rule forces the per-outcome change `ΔL(o)` to equal the real collateral change `ΔR` minus the retained fee, for every `o`. Since `R ≥ L(o)` holds at inception (every conditional token is backed at face — see [Issuance Invariant](#issuance-invariant)), convert preserves `R ≥ L(o)` for all `o`. No oracle witness is required: a payoff-preserving conversion cancels all outcome risk. +- **Non-contingent fee / DoS**: Because `F` is retained on every outcome, each convert costs the requester real collateral regardless of the eventual outcome, bounding free operations. Keysets with `input_fee_ppk == 0` make convert free and MUST be rejected for convert (error 13041) unless separate admission control is in force. Mints MAY additionally require [NUT-21][21]/[NUT-22][22] authentication, rate limits, or per-request caps on output count / distinct collections / total amount as defense-in-depth. +- **Privacy**: As with any multi-input operation, the mint observes that all inputs and outputs of one convert are co-owned; a consolidating convert can link previously unlinked tokens. +- **Depth Limits**: Mints MAY impose a maximum nesting depth via [Mint Info Setting](#mint-info-setting). -## Security Considerations +### Issuance Invariant + +Every conditional token, by any issuance path, MUST be backed by locked collateral at least equal to its face amount. `split`/`convert` satisfy this; a face-value [NUT-04][04] mint into a conditional keyset would too. Sub-face or market-priced issuance into a conditional keyset is forbidden — that is a trading operation, not minting, and would break solvency. + +## Full-Set and Reserved Key Rules + +To keep `"*"` unambiguous, the following [NUT-CTF][CTF] partition rules apply: + +- A partition MUST contain at least two outcome collections, and no single outcome collection may cover all of Ω. The full-set payoff vector is represented only as collateral, never as a conditional keyset. +- `"*"` is reserved: it is invalid as an outcome name and as an outcome collection string. + +## Combinatorial Markets + +Conditions can be nested hierarchically (e.g. "Party A wins AND BTC > $100k") by splitting parent-collection tokens into sub-condition partitions. Outcome collection IDs use EC point addition ([NUT-CTF][CTF]), so nesting order does not matter: `(Party_A) & (BTC_UP)` = `(BTC_UP) & (Party_A)`. -- **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) +When `parent_collection_id` is non-zero, the collateral side (`"*"`) is the parent collection's conditional keyset rather than a regular keyset, for both convert and [NUT-CTF][CTF] redemption. See [supplementary material](suppl/CTF-split-merge.md) for a full combinatorial example. ## 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 | +| Code | Description | +| ----- | -------------------------------------------- | +| 11007 | Duplicate inputs provided | +| 12001 | Keyset is not known | +| 12002 | Keyset is inactive | +| 13021 | Condition not found | +| 13041 | Convert payoff/fee violation | +| 13042 | Convert not permitted for this condition | +| 13043 | Full-set or single-element partition | ## Mint Info Setting @@ -193,10 +220,10 @@ The [NUT-06][06] `MintMethodSetting`: } ``` -- `supported`: Boolean indicating support +- `supported`: Boolean indicating support for the convert operation. - `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). +For a complete end-to-end example including registration, convert, trading, and redemption, see the [supplementary material](suppl/CTF-split-merge.md). [00]: 00.md [01]: 01.md diff --git a/CTF.md b/CTF.md index fb4ca818..cf7aa5d8 100644 --- a/CTF.md +++ b/CTF.md @@ -16,7 +16,7 @@ The oracle signature scheme is compatible with the [DLC specification](https://g 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. +**Related specifications:** [NUT-CTF-split-merge][CTF-split-merge] defines the `convert` operation — payoff-preserving rebalancing of conditional positions (split, merge, recombine, and conversion). [NUT-CTF-numeric][CTF-numeric] extends this framework with numeric outcome conditions. ## Terminology @@ -29,7 +29,9 @@ Caution: Applications that rely on oracle resolution must verify that the oracle ## 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 `\|`. +Outcome collections allow tokens to represent one or more outcomes. An outcome collection is either a single outcome or an OR-combination joined by `|` (e.g., `"ALICE|BOB"` = "Alice or Bob wins"). If an outcome name contains `|`, it MUST be escaped as `\|`; a literal `\` is escaped as `\\`. The token `"*"` is reserved (it denotes collateral in [NUT-CTF-split-merge][CTF-split-merge]) and is invalid as an outcome name and as an outcome collection string. + +Outcome collection strings have a single canonical form so that, e.g., `ALICE|BOB` and `BOB|ALICE` derive the same keyset: outcome names are NFC-normalised and components are ordered by the outcome's index in the oracle announcement (for numeric conditions, the fixed order `["HI", "LO"]`). See [NUT-CTF-split-merge][CTF-split-merge] for the full encoding rules. ### Partition Rules @@ -37,13 +39,14 @@ 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 +3. **Non-trivial**: A partition MUST contain at least two outcome collections, and no single outcome collection may cover all outcomes (error 13043). The full-outcome-set payoff vector is represented only as collateral, never as a conditional keyset — this keeps the reserved collateral key `"*"` unambiguous in [NUT-CTF-split-merge][CTF-split-merge]. Valid 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). +Invalid: `{"ALICE|BOB": [...], "BOB|CAROL": [...]}` (overlapping), `{"ALICE|BOB": [...]}` (incomplete), `{"ALICE|BOB|CAROL": [...]}` (full-set / single-element). ## Conditional Keysets @@ -79,10 +82,10 @@ Where `condition_id` and `outcome_collection_id` are 64-character hex strings. T ``` 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) +Redemption: Conditional keyset -> regular keyset (root) / parent keyset (nested) (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. +- **Issuance**: The mint creates conditional keysets during [partition registration](#register-partition). Users obtain conditional tokens through [NUT-CTF-split-merge][CTF-split-merge] convert (split) operations or other minting mechanisms. Every conditional token, by any issuance path, MUST be backed by locked collateral at least equal to its face amount; sub-face or market-priced issuance into a conditional keyset is forbidden (see [NUT-CTF-split-merge][CTF-split-merge] Issuance Invariant). - **Trading**: Standard [NUT-03][03] swap. All conditional keysets in a swap MUST share the same `outcome_collection_id`. No oracle witness required. - **Redemption**: After oracle attestation, winners submit tokens to `POST /v1/redeem_outcome` with oracle signatures in `Proof.witness`. @@ -99,7 +102,7 @@ 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)`. +- `outcome_count`: 1-byte unsigned integer. Derived from `len(announcements[0].oracle_event.event_descriptor.outcomes)`. All announcements MUST share the same ordered outcome list; that order (from `announcements[0]`) is the canonical outcome index used by outcome-collection encoding. The `condition_id` is partition-independent — the same oracle event always produces the same ID regardless of partitioning. @@ -297,7 +300,7 @@ curl -X POST https://mint.host:3338/v1/conditions/a1b2c3d4.../partitions \ #### Mint Behavior 1. Looks up condition (error 13021 if not found) -2. Validates partition rules (error 13037 overlapping, error 13038 incomplete) +2. Validates partition rules (error 13037 overlapping, error 13038 incomplete, error 13043 full-set or single-element) 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 @@ -417,7 +420,7 @@ POST https://mint.host:3338/v1/redeem_outcome ``` - `inputs`: `Proof` objects from a **single conditional keyset**, each with `witness` containing oracle attestation -- `outputs`: `BlindedMessage` objects using a **regular keyset** (same unit) +- `outputs`: `BlindedMessage` objects (same unit). For a root condition, outputs use a **regular keyset**. For a nested condition (the input keyset's `parent_collection_id` is non-zero), outputs use the **parent collection's conditional keyset** — the active keyset whose `outcome_collection_id` equals that `parent_collection_id` — consistent with nested convert in [NUT-CTF-split-merge][CTF-split-merge]. `Alice` MAY omit `oracle_sigs` if `Bob` has already recorded a valid attestation for this outcome collection (check via `GET /v1/conditions/{condition_id}`). @@ -445,14 +448,14 @@ Mints implementing NUT-CTF MUST enforce these rules on [NUT-03][03] swap: - 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`. +All conditional-to-regular conversions go through `POST /v1/redeem_outcome`. Movement of value **across** outcome collections within a condition (regrouping, or crossing the collateral boundary) goes through the payoff-preserving `POST /v1/ctf/convert` operation ([NUT-CTF-split-merge][CTF-split-merge]), never a NUT-03 swap. ## Redemption Verification When `Bob` receives a `POST /v1/redeem_outcome` request: 1. All inputs MUST use the same conditional keyset -2. All outputs MUST use a regular keyset (same unit) +2. All outputs MUST use a regular keyset (root condition) or the parent collection's conditional keyset (nested condition), 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 @@ -486,13 +489,14 @@ If the oracle does not attest within expected time, the mint MAY refund conditio | 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 | +| 13017 | Invalid keyset for collateral/output side | | 13020 | Invalid condition ID | | 13021 | Condition not found | | 13027 | Oracle threshold not met | | 13028 | Condition already exists | | 13037 | Overlapping outcome collections | | 13038 | Incomplete partition | +| 13043 | Full-set or single-element partition | ## Mint Info Setting diff --git a/README.md b/README.md index b84e4952..cbcfb87e 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Wallets and mints `MUST` implement all mandatory specs and `CAN` implement optio | [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-split-merge][CTF-split-merge] | Conditional Token Convert (Split, Merge, Recombine) | - | - | | [CTF-numeric][CTF-numeric] | Numeric Outcome Conditions | - | - | #### Wallets diff --git a/error_codes.md b/error_codes.md index b42f7132..446ae2be 100644 --- a/error_codes.md +++ b/error_codes.md @@ -40,12 +40,10 @@ | 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] | +| 13017 | Invalid keyset for collateral/output side | [NUT-CTF][CTF], [NUT-CTF-split-merge][CTF-split-merge] | | 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] | @@ -55,6 +53,9 @@ | 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] | +| 13041 | Convert payoff/fee violation | [NUT-CTF-split-merge][CTF-split-merge] | +| 13042 | Convert not permitted for this condition | [NUT-CTF-split-merge][CTF-split-merge] | +| 13043 | Full-set or single-element partition | [NUT-CTF][CTF], [NUT-CTF-split-merge][CTF-split-merge] | [00]: 00.md [01]: 01.md diff --git a/suppl/CTF-split-merge.md b/suppl/CTF-split-merge.md index 0f38849f..ed1df821 100644 --- a/suppl/CTF-split-merge.md +++ b/suppl/CTF-split-merge.md @@ -1,6 +1,6 @@ # 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]. +This document provides a full end-to-end example of the CTF convert lifecycle. For the normative specification, see [NUT-CTF-split-merge][CTF-split-merge]. ## Complete Example @@ -56,39 +56,41 @@ POST https://mint.host:3338/v1/conditions/a1b2c3d4e5f67890.../partitions } ``` -### Step 2: Split Collateral +### Step 2: Split Collateral (convert with `"*"` inputs) -`Alice` wants to participate with 100 sats: +`Alice` wants to participate with 100 sats. A split is a convert whose inputs are collateral under the reserved key `"*"`: **Request** of `Alice`: ```http -POST https://mint.host:3338/v1/ctf/split +POST https://mint.host:3338/v1/ctf/convert ``` ```json { "condition_id": "a1b2c3d4e5f67890...", - "inputs": [ - { - "amount": 64, - "id": "009a1f293253e41e", - "secret": "random_secret_1", - "C": "02..." - }, - { - "amount": 32, - "id": "009a1f293253e41e", - "secret": "random_secret_2", - "C": "02..." - }, - { - "amount": 4, - "id": "009a1f293253e41e", - "secret": "random_secret_3", - "C": "02..." - } - ], + "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..." }, @@ -137,6 +139,30 @@ The oracle attests that YES won by publishing a DLC attestation signature on `"Y `Alice` redeems her YES tokens via `POST /v1/redeem_outcome` ([NUT-CTF][CTF]) with `oracle_sigs` witness. Inputs use the YES conditional keyset, outputs use a regular keyset. The mint verifies the oracle signatures and returns regular proofs. +## Conversion Example (negative-risk style) + +For a four-outcome condition `Ω = {A, B, C, D}` with registered collections `A|B|C`, `B|C|D`, and `B|C`, a holder of `A|B|C` and `B|C|D` tokens converts to collateral plus a `B|C` token in one call. With `F = 1`: + +```http +POST https://mint.host:3338/v1/ctf/convert +``` + +```json +{ + "condition_id": "a1b2c3d4e5f67890...", + "inputs": { + "A|B|C": [ { "amount": 100, "id": "00abc...", "secret": "s1", "C": "02..." } ], + "B|C|D": [ { "amount": 100, "id": "00bcd...", "secret": "s2", "C": "02..." } ] + }, + "outputs": { + "*": [ { "amount": 99, "id": "009a1f293253e41e", "B_": "03..." } ], + "B|C": [ { "amount": 100, "id": "00bc0...", "B_": "03..." } ] + } +} +``` + +Per-outcome: `in = (A:100, B:200, C:200, D:100)`, `out = (A:99, B:199, C:199, D:99)`, so `out(o) == in(o) − 1` for every outcome. `Alice` withdraws 99 sats of spendable collateral (the bundle's guaranteed floor, minus the fee) without any oracle witness, and keeps a `B|C` position. The mint retains 1 sat on every outcome. + [00]: ../00.md [02]: ../02.md [03]: ../03.md diff --git a/tests/CTF-numeric-tests.md b/tests/CTF-numeric-tests.md index f6a91fc0..03bce891 100644 --- a/tests/CTF-numeric-tests.md +++ b/tests/CTF-numeric-tests.md @@ -288,19 +288,21 @@ lo_output: 80 sats # Conservation: PASS ``` -## Split and Merge +## Convert (Split and Merge) ### Test 14: Numeric market split ```shell -# Split 100 sats into HI and LO tokens +# Split 100 sats into HI and LO tokens (POST /v1/ctf/convert, collateral under "*") request_json: { "condition_id": "", - "inputs": [ - {"amount": 64, "id": "009a1f293253e41e", "secret": "secret1", "C": "02..."}, - {"amount": 32, "id": "009a1f293253e41e", "secret": "secret2", "C": "02..."}, - {"amount": 4, "id": "009a1f293253e41e", "secret": "secret3", "C": "02..."} - ], + "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..."}, @@ -332,14 +334,16 @@ request_json: { {"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..."} - ] + "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 +# NUT-CTF-split-merge convert (merge) - collateral under "*", no oracle witness needed result: PASS ``` diff --git a/tests/CTF-split-merge-tests.md b/tests/CTF-split-merge-tests.md index 16cefedf..4c70f7b3 100644 --- a/tests/CTF-split-merge-tests.md +++ b/tests/CTF-split-merge-tests.md @@ -1,6 +1,6 @@ # 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. +These test vectors provide reference data for implementing the Conditional Token Framework (CTF) convert operation (split, merge, recombine, conversion) with per-outcome collection keysets. All values are hex-encoded for reproducibility. Split and merge are the `"*"`-inputs and `"*"`-outputs special cases of `POST /v1/ctf/convert`. ## Condition ID Calculation @@ -116,7 +116,9 @@ partition_response: { } ``` -## Split Operation +## Convert Operation — Split + +A split is a `POST /v1/ctf/convert` whose inputs are collateral under the reserved key `"*"`. ### Test 6: Binary condition split request @@ -124,7 +126,7 @@ partition_response: { # Condition parameters condition_id: -# Input (100 sats collateral using regular keyset) +# Input (100 sats collateral using regular keyset, fee F = 0) input_amount: 100 input_keyset_id: 009a1f293253e41e # regular keyset @@ -132,14 +134,16 @@ input_keyset_id: 009a1f293253e41e # regular keyset yes_keyset_id: 00abc123def456 no_keyset_id: 00def789abc012 -# Split request JSON +# Convert (split) request JSON — POST /v1/ctf/convert request_json: { "condition_id": "", - "inputs": [ - {"amount": 64, "id": "009a1f293253e41e", "secret": "secret1", "C": "02..."}, - {"amount": 32, "id": "009a1f293253e41e", "secret": "secret2", "C": "02..."}, - {"amount": 4, "id": "009a1f293253e41e", "secret": "secret3", "C": "02..."} - ], + "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..."}, @@ -203,7 +207,9 @@ swap_json: { result: PASS ``` -## Merge Operation +## Convert Operation — Merge + +A merge is a `POST /v1/ctf/convert` whose outputs are collateral under the reserved key `"*"`. ### Test 9: Binary condition merge request @@ -211,8 +217,8 @@ result: PASS # Condition parameters condition_id: -# Inputs (100 sats of each outcome collection using conditional keysets) -# Outputs use regular keyset +# Inputs (100 sats of each outcome collection using conditional keysets, fee F = 0) +# Outputs are collateral under "*" request_json: { "condition_id": "", "inputs": { @@ -227,14 +233,16 @@ request_json: { {"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..."} - ] + "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 +# Input proofs use conditional keysets, output BlindedMessages are collateral ("*") # No oracle witness required (complete set cancels out) output_total: 100 ``` @@ -242,13 +250,15 @@ output_total: 100 ### Test 10: Successful merge response ```shell -# Response with signatures for collateral outputs (regular keyset) +# Response with signatures for collateral outputs under "*" (regular keyset) response_json: { - "signatures": [ - {"amount": 64, "id": "009a1f293253e41e", "C_": "02...sig1..."}, - {"amount": 32, "id": "009a1f293253e41e", "C_": "02...sig2..."}, - {"amount": 4, "id": "009a1f293253e41e", "C_": "02...sig3..."} - ] + "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) @@ -315,27 +325,28 @@ error_message: "Oracle has not attested to this outcome collection" ## Error Cases -### Test 13: Split amount mismatch +### Test 13: Convert (split) payoff mismatch ```shell -# Input total != output total for each outcome collection +# Input total != output total for some outcome (out(YES) != in(YES) - F) input_total: 100 output_yes_total: 90 # Mismatch! output_no_total: 100 -error_code: 13022 -error_message: "Split amount mismatch" +error_code: 13041 +error_message: "Convert payoff/fee violation" ``` ### Test 14: Missing outcome collection in outputs ```shell # Binary condition but only YES outputs provided +# in(NO) = 100, out(NO) = 0 -> out(NO) != in(NO) - F outcome_collections: ["YES", "NO"] outputs_provided: ["YES"] # Missing NO! -error_code: 13038 -error_message: "Incomplete partition" +error_code: 13041 +error_message: "Convert payoff/fee violation" ``` ### Test 15: Invalid condition ID @@ -361,47 +372,48 @@ error_message: "Condition not found" ### Test 17: Unequal outcome collection amounts ```shell -# Different amounts for different outcome collections +# Different amounts for different outcomes (out(NO) != in(NO) - F) input_total: 100 output_yes_total: 100 output_no_total: 50 # Different! -error_code: 13022 -error_message: "Split amount mismatch" +error_code: 13041 +error_message: "Convert payoff/fee violation" ``` -### Test 18: Merge amount mismatch +### Test 18: Convert (merge) payoff mismatch ```shell -# Input amounts don't match +# Input amounts don't match across outcomes (in(YES) != in(NO)) input_yes_total: 100 input_no_total: 80 # Mismatch! -error_code: 13025 -error_message: "Merge amount mismatch" +error_code: 13041 +error_message: "Convert payoff/fee violation" ``` ### Test 19: Missing outcome collection in merge inputs ```shell # Binary condition but only YES inputs provided +# in(NO) = 0 but out(NO) (from "*") = output_total -> violation outcome_collections: ["YES", "NO"] inputs_provided: ["YES"] # Missing NO! -error_code: 13038 -error_message: "Incomplete partition" +error_code: 13041 +error_message: "Convert payoff/fee violation" ``` ### Test 20: Output amount mismatch in merge ```shell -# Output total doesn't equal per-outcome collection input total +# Collateral output total doesn't equal per-outcome input total input_yes_total: 100 input_no_total: 100 -output_total: 50 # Should be 100! +output_total: 50 # Should be 100 (F = 0)! -error_code: 13025 -error_message: "Merge amount mismatch" +error_code: 13041 +error_message: "Convert payoff/fee violation" ``` ## Multi-Oracle Condition ID @@ -440,12 +452,14 @@ keysets: "ALICE|BOB": 00aabb11cc22dd33 "CAROL": 00ccdd44ee55ff66 -# Split request with outcome collections +# Convert (split) request with outcome collections request_json: { "condition_id": "", - "inputs": [ - {"amount": 100, "id": "009a1f293253e41e", "secret": "secret1", "C": "02..."} - ], + "inputs": { + "*": [ + {"amount": 100, "id": "009a1f293253e41e", "secret": "secret1", "C": "02..."} + ] + }, "outputs": { "ALICE|BOB": [ {"amount": 64, "id": "00aabb11cc22dd33", "B_": "03..."}, @@ -534,14 +548,16 @@ request_json: { {"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..."} - ] + "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 +# Input proofs use outcome collection keysets, outputs are collateral under "*" # Valid merge - outcome collections form complete partition merge_result: SUCCESS ``` @@ -659,13 +675,16 @@ nested_partition_response: { ```shell # Split PARTY_A tokens into PARTY_A&UP and PARTY_A&DOWN -# Inputs use PARTY_A conditional keyset (from root condition) +# Inputs are the parent collection (PARTY_A) under "*" — nested collateral # Outputs use nested condition conditional keysets request_json: { "condition_id": "", - "inputs": [ - {"amount": 100, "id": "00aa11bb22cc33dd", "secret": "party_a_secret_1", "C": "02..."} - ], + "parent_collection_id": "", + "inputs": { + "*": [ + {"amount": 100, "id": "00aa11bb22cc33dd", "secret": "party_a_secret_1", "C": "02..."} + ] + }, "outputs": { "UP": [ {"amount": 64, "id": "00cc33dd44ee55ff", "B_": "03..."}, @@ -691,6 +710,7 @@ result: PASS # Merge PARTY_A&UP and PARTY_A&DOWN back to PARTY_A tokens request_json: { "condition_id": "", + "parent_collection_id": "", "inputs": { "UP": [ {"amount": 100, "id": "00cc33dd44ee55ff", "secret": "up_secret_1", "C": "02..."} @@ -699,14 +719,16 @@ request_json: { {"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": { + "*": [ + {"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 +# Nested collateral "*" is the PARTY_A parent keyset (not a regular keyset) result: PASS ``` @@ -760,5 +782,78 @@ bob_result: FAIL (oracle signed YES, not NO) # - Bob: paid 40 sats for worthless NO tokens = -40 sats ``` +## Convert — Recombine and Conversion + +### Test 36: Recombine (floor 0, fee paid as collateral) + +```shell +# Condition Omega = {A, B, C, D}; registered collections include A, B|C, A|B|C +# Holder of A(1) + B|C(1) recombines into A|B|C(1). Fee F = 1, so the holder +# adds 1 sat of collateral under "*" to satisfy in(o) >= F on every outcome. + +request_json: { + "condition_id": "", + "inputs": { + "*": [{"amount": 1, "id": "009a1f293253e41e", "secret": "c1", "C": "02..."}], + "A": [{"amount": 1, "id": "00a000...", "secret": "a1", "C": "02..."}], + "B|C": [{"amount": 1, "id": "00bc00...", "secret": "bc1", "C": "02..."}] + }, + "outputs": { + "A|B|C": [{"amount": 1, "id": "00abc0...", "B_": "03..."}] + } +} + +# Per-outcome: in = (A:2, B:2, C:2, D:1), out = (A:1, B:1, C:1, D:0) +# out(o) == in(o) - 1 for every o. Mint retains 1 on every outcome. +result: PASS +``` + +### Test 37: Conversion (negative-risk style, collateral on output) + +```shell +# Condition Omega = {A, B, C, D}; registered collections A|B|C, B|C|D, B|C. Fee F = 1. +request_json: { + "condition_id": "", + "inputs": { + "A|B|C": [{"amount": 100, "id": "00abc0...", "secret": "s1", "C": "02..."}], + "B|C|D": [{"amount": 100, "id": "00bcd0...", "secret": "s2", "C": "02..."}] + }, + "outputs": { + "*": [{"amount": 99, "id": "009a1f293253e41e", "B_": "03..."}], + "B|C": [{"amount": 100, "id": "00bc00...", "B_": "03..."}] + } +} + +# Per-outcome: in = (A:100, B:200, C:200, D:100), out = (A:99, B:199, C:199, D:99) +# out(o) == in(o) - 1 for every o. 99 sats withdrawn as spendable collateral, no witness. +result: PASS +``` + +### Test 38: Convert fee not covered on some outcome + +```shell +# Pure recombine A(1) + B|C(1) -> A|B|C(1) WITHOUT adding collateral, F = 1. +# in = (A:1, B:1, C:1, D:0); in(D) = 0 < F -> rejected. +error_code: 13041 +error_message: "Convert payoff/fee violation" +``` + +### Test 39: Convert after attestation recorded + +```shell +# Oracle attestation already recorded for the condition. +error_code: 13042 +error_message: "Convert not permitted for this condition" +``` + +### Test 40: Full-set / single-element partition rejected at registration + +```shell +# Attempt to register a partition whose only element covers all outcomes +partition: ["ALICE|BOB|CAROL"] # full-set, single element +error_code: 13043 +error_message: "Full-set or single-element partition" +``` + [NUT-CTF-split-merge]: ../CTF-split-merge.md [NUT-CTF]: ../CTF.md From 99a884ca8f275fbe4a02c1627a1492923266c1e1 Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Sun, 7 Jun 2026 12:43:38 +0900 Subject: [PATCH 05/27] Update NUT-CTF keyset registration model Remove partition registration from the CTF specs and describe condition-time keyset creation, managed default policies, root-only convert scope, and updated numeric behavior. --- CTF-numeric.md | 13 +-- CTF-split-merge.md | 37 +++---- CTF.md | 177 +++++++++++++----------------- error_codes.md | 8 +- suppl/CTF-split-merge.md | 26 +---- tests/CTF-numeric-tests.md | 16 +-- tests/CTF-split-merge-tests.md | 190 ++++++++++----------------------- 7 files changed, 163 insertions(+), 304 deletions(-) diff --git a/CTF-numeric.md b/CTF-numeric.md index 8a8246b5..908cdb9e 100644 --- a/CTF-numeric.md +++ b/CTF-numeric.md @@ -17,7 +17,7 @@ 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. +The outcome collection keysets are always `["HI", "LO"]` for numeric conditions. ## Payout Calculation @@ -75,14 +75,15 @@ POST https://mint.host:3338/v1/conditions "condition_type": "numeric", "lo_bound": 0, "hi_bound": 100000, - "precision": 0 + "precision": 0, + "collateral": "sat" } ``` ```bash curl -X POST https://mint.host:3338/v1/conditions \ -H "Content-Type: application/json" \ - -d '{"threshold":1,"tags":[["description","BTC/USD price"]],"announcements":["fdd824..."],"condition_type":"numeric","lo_bound":0,"hi_bound":100000,"precision":0}' + -d '{"threshold":1,"tags":[["description","BTC/USD price"]],"announcements":["fdd824..."],"condition_type":"numeric","lo_bound":0,"hi_bound":100000,"precision":0,"collateral":"sat"}' ``` - `condition_type`: `"numeric"` (vs default `"enum"` for existing [NUT-CTF][CTF] conditions). When omitted, defaults to `"enum"`. @@ -98,7 +99,7 @@ curl -X POST https://mint.host:3338/v1/conditions \ } ``` -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. +The mint always creates the `HI` and `LO` keysets during numeric condition registration. If `default_keyset_creation` is `"none"`, a client MAY provide `outcome_collections` exactly as `["HI", "LO"]`; any other collection is invalid. If the mint advertises `"one-vs-rest"` or `"all"`, the client MUST omit `outcome_collections`. In all cases, `collateral` is required because numeric registration creates keysets. ## Condition ID for Numeric Conditions @@ -118,7 +119,7 @@ Where: - `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]). +`outcome_count` = 2 (always). The outcome collection keysets are always `["HI", "LO"]`. ## Oracle Witness for Digit Decomposition @@ -185,7 +186,7 @@ Exact-equality conservation preserves the HI/LO face vector, so the proportional ## 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. +Numeric conditions can participate in root-level [NUT-CTF-split-merge][CTF-split-merge] convert operations. Nested/combinatorial construction is out of scope for this version. ## Error Codes diff --git a/CTF-split-merge.md b/CTF-split-merge.md index 5cafad3d..e2b32ac7 100644 --- a/CTF-split-merge.md +++ b/CTF-split-merge.md @@ -6,7 +6,7 @@ --- -This NUT defines a single **convert** operation for conditional tokens ([NUT-CTF][CTF]): any payoff-preserving rebalance of conditional positions within one condition at one nesting level. Convert subsumes four operations: +This NUT defines a single **convert** operation for conditional tokens ([NUT-CTF][CTF]): any payoff-preserving rebalance of root-level conditional positions within one condition. Convert subsumes four operations: - **Split**: deposit collateral, receive a complete set of conditional tokens. - **Merge**: surrender a complete set of conditional tokens, recover collateral. @@ -27,7 +27,7 @@ Wallet ────────────► Mint User ─────── keysets) (− fee F) Keyset ``` -1. **Register**: Condition + partition registered via [NUT-CTF][CTF] to create conditional keysets. +1. **Register**: Condition registered via [NUT-CTF][CTF] with requested outcome collections to create conditional keysets. 2. **Convert**: `Alice` submits an input bundle of proofs and an output bundle of blinded messages; the mint signs the outputs iff the operation is payoff-preserving after a flat fee (see [Conservation Rule](#conservation-rule)). 3. **Trade**: Standard [NUT-03][03] swaps within the same conditional keyset. 4. **Attest**: Oracle signs winning outcome. @@ -35,7 +35,7 @@ Wallet ────────────► Mint User ─────── ## Payoff-Vector Model -For a condition with outcome-atom set **Ω** (for nested conditions, the child outcomes under the parent collection), a token for outcome collection `S` with amount `a` is the **payoff vector** that pays `a` on each outcome in `cover(S)` and `0` elsewhere. **Collateral** — plain ecash on a regular keyset (root), or the parent collection's conditional keyset (nested) — is the **all-ones** vector: it pays on every outcome in Ω. +For a condition with outcome-atom set **Ω**, a token for outcome collection `S` with amount `a` is the **payoff vector** that pays `a` on each outcome in `cover(S)` and `0` elsewhere. **Collateral** — plain ecash on a regular keyset — is the **all-ones** vector: it pays on every outcome in Ω. A convert is valid iff the input and output payoff vectors are equal after deducting a flat fee on every outcome. This single condition generalises split, merge, recombine, and conversion. @@ -63,10 +63,9 @@ POST https://mint.host:3338/v1/ctf/convert ``` - `condition_id`: 64-char hex (error 13021 if unknown). -- `parent_collection_id` (optional): 64-char hex. Defaults to all zeros for root conditions. +- `parent_collection_id` (optional): reserved for future nested conditions. It MUST be omitted or the all-zero 32-byte hex string; any non-zero value MUST be rejected. - `inputs` / `outputs`: objects mapping each map key to an array of `Proof` / `BlindedMessage`. A map key is either a **canonical outcome collection string** (see [Canonical Collection Encoding](#canonical-collection-encoding)) or the reserved key `"*"` denoting **collateral**: - - For root conditions (`parent_collection_id` all zeros), `"*"` uses the **regular keyset** of the collateral unit. - - For nested conditions, `"*"` uses the **parent collection's conditional keyset** — the active keyset whose `outcome_collection_id` equals `parent_collection_id`. + - `"*"` uses the **regular keyset** of the collateral unit. - Duplicate JSON member names within `inputs` or `outputs` MUST be rejected. **Response** of `Bob`: @@ -92,8 +91,8 @@ curl -X POST https://mint.host:3338/v1/ctf/convert \ | Operation | `inputs` | `outputs` | | --------- | -------- | --------- | -| Split | `{"*": [...]}` | per-outcome-collection map (a partition) | -| Merge | per-outcome-collection map (a partition) | `{"*": [...]}` | +| Split | `{"*": [...]}` | per-outcome-collection map covering every outcome | +| Merge | per-outcome-collection map covering every outcome | `{"*": [...]}` | | Recombine | conditional collections only (no `"*"`) | conditional collections only (no `"*"`) | | Conversion | conditional ± `"*"` | conditional ± `"*"` | @@ -145,7 +144,7 @@ This rule: ## Coverage From Keyset Metadata -A map key is untrusted text and MUST NOT be used to compute coverage. For every non-`"*"` entry, `Bob` MUST resolve each `Proof`/`BlindedMessage` keyset `id` to its **stored keyset metadata** (error 12001 if unknown) and verify that the metadata matches this `condition_id`, `parent_collection_id`, unit, and a canonical outcome collection. The request map key MUST equal that canonical collection (error 13041 otherwise). `cover()` is derived from the metadata, never from the request string. Every `"*"` proof/message MUST use the collateral keyset defined above (error 13017 if a regular/conditional keyset is placed under the wrong key). +A map key is untrusted text and MUST NOT be used to compute coverage. For every non-`"*"` entry, `Bob` MUST resolve each `Proof`/`BlindedMessage` keyset `id` to its **stored keyset metadata** (error 12001 if unknown) and verify that the metadata matches this `condition_id`, unit, and a canonical outcome collection. The request map key MUST equal that canonical collection (error 13041 otherwise). `cover()` is derived from the metadata, never from the request string. Every `"*"` proof/message MUST use the regular collateral keyset defined above (error 13017 if a regular/conditional keyset is placed under the wrong key). ## Canonical Collection Encoding @@ -164,7 +163,7 @@ Outcome collections have a single canonical string form, used both as map keys h `Bob`: 1. Looks up the condition (error 13021 if not found). Rejects if an attestation has been recorded for the condition (error 13042), or if any involved keyset is inactive (error 12002). These two conditions are the only cutoffs: maturity is **not** a cutoff, and there is no separate status check — a `violation` status implies a recorded (conflicting) attestation, and an `expired` condition implies inactive keysets, so both are already covered. Convert is allowed until an attestation is recorded, provided the keysets are still active. -2. For each entry, resolves keyset metadata and validates `condition_id`, `parent_collection_id`, unit, canonical collection == map key, and `"*"` ↔ collateral keyset placement (see [Coverage From Keyset Metadata](#coverage-from-keyset-metadata)). +2. Rejects any non-zero `parent_collection_id`. For each entry, resolves keyset metadata and validates `condition_id`, unit, canonical collection == map key, and `"*"` ↔ collateral keyset placement (see [Coverage From Keyset Metadata](#coverage-from-keyset-metadata)). 3. Rejects if any two input proofs across all entries share a secret (error 11007), or if any input or output amount is not positive, or if there is no input, or if `outputs` is empty. 4. Verifies all input proofs are valid and unspent (errors 10001 / 11001). 5. Computes `in(o)`, `out(o)`, and `F`, and enforces the [Conservation Rule](#conservation-rule) for every `o ∈ Ω` (error 13041). @@ -176,7 +175,7 @@ Outcome collections have a single canonical string form, used both as map keys h - **Solvency**: Let `R` be the real collateral the mint holds and `L(o)` the outstanding conditional liability at outcome `o`. The conservation rule forces the per-outcome change `ΔL(o)` to equal the real collateral change `ΔR` minus the retained fee, for every `o`. Since `R ≥ L(o)` holds at inception (every conditional token is backed at face — see [Issuance Invariant](#issuance-invariant)), convert preserves `R ≥ L(o)` for all `o`. No oracle witness is required: a payoff-preserving conversion cancels all outcome risk. - **Non-contingent fee / DoS**: Because `F` is retained on every outcome, each convert costs the requester real collateral regardless of the eventual outcome, bounding free operations. Keysets with `input_fee_ppk == 0` make convert free and MUST be rejected for convert (error 13041) unless separate admission control is in force. Mints MAY additionally require [NUT-21][21]/[NUT-22][22] authentication, rate limits, or per-request caps on output count / distinct collections / total amount as defense-in-depth. - **Privacy**: As with any multi-input operation, the mint observes that all inputs and outputs of one convert are co-owned; a consolidating convert can link previously unlinked tokens. -- **Depth Limits**: Mints MAY impose a maximum nesting depth via [Mint Info Setting](#mint-info-setting). +- **Root-only scope**: This version does not define nested/combinatorial condition construction. Mints MUST reject non-zero `parent_collection_id` values. ### Issuance Invariant @@ -184,17 +183,11 @@ Every conditional token, by any issuance path, MUST be backed by locked collater ## Full-Set and Reserved Key Rules -To keep `"*"` unambiguous, the following [NUT-CTF][CTF] partition rules apply: +To keep `"*"` unambiguous, the following [NUT-CTF][CTF] outcome collection rules apply: -- A partition MUST contain at least two outcome collections, and no single outcome collection may cover all of Ω. The full-set payoff vector is represented only as collateral, never as a conditional keyset. +- No single outcome collection may cover all of Ω. The full-set payoff vector is represented only as collateral, never as a conditional keyset. - `"*"` is reserved: it is invalid as an outcome name and as an outcome collection string. -## Combinatorial Markets - -Conditions can be nested hierarchically (e.g. "Party A wins AND BTC > $100k") by splitting parent-collection tokens into sub-condition partitions. Outcome collection IDs use EC point addition ([NUT-CTF][CTF]), so nesting order does not matter: `(Party_A) & (BTC_UP)` = `(BTC_UP) & (Party_A)`. - -When `parent_collection_id` is non-zero, the collateral side (`"*"`) is the parent collection's conditional keyset rather than a regular keyset, for both convert and [NUT-CTF][CTF] redemption. See [supplementary material](suppl/CTF-split-merge.md) for a full combinatorial example. - ## Error Codes | Code | Description | @@ -205,7 +198,7 @@ When `parent_collection_id` is non-zero, the collateral side (`"*"`) is the pare | 13021 | Condition not found | | 13041 | Convert payoff/fee violation | | 13042 | Convert not permitted for this condition | -| 13043 | Full-set or single-element partition | +| 13043 | Full-set outcome collection | ## Mint Info Setting @@ -214,14 +207,12 @@ The [NUT-06][06] `MintMethodSetting`: ```json { "CTF-split-merge": { - "supported": true, - "max_depth": + "supported": true } } ``` - `supported`: Boolean indicating support for the convert operation. -- `max_depth` (optional): Maximum nesting depth. If unspecified, only root conditions (depth 1) are supported. For a complete end-to-end example including registration, convert, trading, and redemption, see the [supplementary material](suppl/CTF-split-merge.md). diff --git a/CTF.md b/CTF.md index cf7aa5d8..bdf85c96 100644 --- a/CTF.md +++ b/CTF.md @@ -10,7 +10,7 @@ This NUT defines conditional tokens and conditional keysets for oracle-attested 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. +A **conditional keyset** is a per-outcome-collection signing keyset ([NUT-02][02]) that the mint creates during condition registration. Each outcome collection gets a unique keyset with different signing keys. The oracle signature scheme is compatible with the [DLC specification](https://github.com/discreetlogcontracts/dlcspecs/blob/master/Oracle.md), allowing Cashu mints to leverage existing DLC oracle infrastructure. @@ -22,8 +22,7 @@ Caution: Applications that rely on oracle resolution must verify that the oracle - **Condition**: A question with defined outcomes, resolved by an oracle. Identified by a `condition_id`. Equivalent to "condition" in the [Gnosis Conditional Token Framework](https://conditional-tokens.readthedocs.io/en/latest/) ([contracts](https://github.com/gnosis/conditional-tokens-contracts)), which this spec adapts to Cashu. [Polymarket](https://github.com/Polymarket/ctf-exchange-v2) is a large-scale production deployment of the same model on Ethereum. - **Outcome**: A single atomic result that an oracle attests to (e.g., `"YES"`, `"ALICE"`). -- **Outcome collection**: A 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. +- **Outcome collection**: A non-empty, non-full subset of outcomes (e.g., `"YES"`, `"ALICE|BOB"`). Each requested collection gets its own conditional keyset. Redeemable if the oracle attests to ANY outcome it contains. - **Condition ID** (`condition_id`): 32-byte tagged hash uniquely identifying a condition. Partition-independent. See [Condition ID](#condition-id). - **Outcome collection ID** (`outcome_collection_id`): 32-byte x-only public key uniquely identifying an outcome collection within a condition. See [Outcome Collection ID](#outcome-collection-id). @@ -33,24 +32,25 @@ Outcome collections allow tokens to represent one or more outcomes. An outcome c Outcome collection strings have a single canonical form so that, e.g., `ALICE|BOB` and `BOB|ALICE` derive the same keyset: outcome names are NFC-normalised and components are ordered by the outcome's index in the oracle announcement (for numeric conditions, the fixed order `["HI", "LO"]`). See [NUT-CTF-split-merge][CTF-split-merge] for the full encoding rules. -### Partition Rules +### Outcome Collection Keyset Rules -Partition keys MUST form a valid partition of all outcomes: +Condition registration MAY request any set of non-empty, non-full outcome collections: -1. **Disjoint**: No outcome appears in multiple outcome collections -2. **Complete**: Every outcome appears in exactly one outcome collection -3. **Non-trivial**: A partition MUST contain at least two outcome collections, and no single outcome collection may cover all outcomes (error 13043). The full-outcome-set payoff vector is represented only as collateral, never as a conditional keyset — this keeps the reserved collateral key `"*"` unambiguous in [NUT-CTF-split-merge][CTF-split-merge]. +1. Each requested outcome collection MUST be canonical. +2. Requested outcome collections MAY overlap. +3. Duplicate canonical outcome collections in one request MUST be rejected. +4. No single outcome collection may cover all outcomes (error 13043). The full-outcome-set payoff vector is represented only as collateral, never as a conditional keyset — this keeps the reserved collateral key `"*"` unambiguous in [NUT-CTF-split-merge][CTF-split-merge]. -Valid partitions for outcomes `["ALICE", "BOB", "CAROL"]`: +Valid requested keysets for outcomes `["A", "B", "C"]`: -- `{"ALICE": [...], "BOB": [...], "CAROL": [...]}` (individual outcomes) -- `{"ALICE|BOB": [...], "CAROL": [...]}` (one collection + one individual) +- `["A", "B", "C"]` (individual outcomes) +- `["A", "B", "C", "A|B", "B|C", "A|C"]` (all non-full collections) -Invalid: `{"ALICE|BOB": [...], "BOB|CAROL": [...]}` (overlapping), `{"ALICE|BOB": [...]}` (incomplete), `{"ALICE|BOB|CAROL": [...]}` (full-set / single-element). +Invalid: `["A|B|C"]` (full-set), `["A", "A"]` (duplicate), `["A", "D"]` (unknown outcome). ## 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]). +Each requested outcome collection gets a unique keyset created during [condition registration](#register-condition). These use the same mechanism as regular keysets ([NUT-02][02]). **Properties:** @@ -80,12 +80,12 @@ Where `condition_id` and `outcome_collection_id` are 64-character hex strings. T ## Token Lifecycle ``` -Issuance: Mint issues conditional tokens (via partition registration + keyset-specific minting) +Issuance: Mint issues conditional tokens (via condition registration + keyset-specific minting) Trading: Conditional keyset -> same/rotated conditional keyset (NUT-03 swap, same outcome_collection_id, no witness) -Redemption: Conditional keyset -> regular keyset (root) / parent keyset (nested) (POST /v1/redeem_outcome + oracle 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] convert (split) operations or other minting mechanisms. Every conditional token, by any issuance path, MUST be backed by locked collateral at least equal to its face amount; sub-face or market-priced issuance into a conditional keyset is forbidden (see [NUT-CTF-split-merge][CTF-split-merge] Issuance Invariant). +- **Issuance**: The mint creates conditional keysets during [condition registration](#register-condition). Users obtain conditional tokens through [NUT-CTF-split-merge][CTF-split-merge] convert (split) operations or other minting mechanisms. Every conditional token, by any issuance path, MUST be backed by locked collateral at least equal to its face amount; sub-face or market-priced issuance into a conditional keyset is forbidden (see [NUT-CTF-split-merge][CTF-split-merge] Issuance Invariant). - **Trading**: Standard [NUT-03][03] swap. All conditional keysets in a swap MUST share the same `outcome_collection_id`. No oracle witness required. - **Redemption**: After oracle attestation, winners submit tokens to `POST /v1/redeem_outcome` with oracle signatures in `Proof.witness`. @@ -104,7 +104,7 @@ Where: - `event_id`: UTF-8 encoded event identifier. Derived from `announcements[0].oracle_event.event_id`. All announcements MUST share the same `event_id`. - `outcome_count`: 1-byte unsigned integer. Derived from `len(announcements[0].oracle_event.event_descriptor.outcomes)`. All announcements MUST share the same ordered outcome list; that order (from `announcements[0]`) is the canonical outcome index used by outcome-collection encoding. -The `condition_id` is partition-independent — the same oracle event always produces the same ID regardless of partitioning. +The `condition_id` is independent of requested keysets — the same oracle event always produces the same ID regardless of which outcome collections are requested. > **Note:** [NUT-CTF-numeric][CTF-numeric] extends this formula with additional parameters for numeric conditions. @@ -124,7 +124,7 @@ The transport for discovering oracle announcements from oracles is unspecified. ## Condition Registry -Conditions are registered via `POST /v1/conditions` before any operations on conditional tokens. Conditional keysets are created during [partition registration](#register-partition). +Conditions are registered via `POST /v1/conditions` before any operations on conditional tokens. Conditional keysets are created during condition registration. ### Condition Info @@ -139,14 +139,6 @@ Conditions are registered via `POST /v1/conditions` before any operations on con "": , ... }, - "partitions": [ - { - "partition": , - "collateral": , - "parent_collection_id": , - "registered_at": - } - ], "attestation": { "status": , "winning_outcome": , @@ -160,12 +152,7 @@ Conditions are registered via `POST /v1/conditions` before any operations on con - `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 +- `keysets`: Flat map of all root-level requested outcome collections to keyset IDs. Use `GET /v1/conditional_keysets` for full keyset metadata. - `attestation` (optional, omitted if no attestation): - `status`: `"pending"` | `"attested"` | `"expired"` | `"violation"` - `winning_outcome`: Attested outcome string (`null` if pending) @@ -217,7 +204,7 @@ GET https://mint.host:3338/v1/conditions/{condition_id} POST https://mint.host:3338/v1/conditions ``` -Registers a new condition. Does not create keysets — keysets are created during [partition registration](#register-partition). +Registers a new condition and creates requested conditional keysets. **Request** of `Alice`: @@ -225,117 +212,90 @@ Registers a new condition. Does not create keysets — keysets are created durin { "threshold": , "tags": , - "announcements": + "announcements": , + "collateral": , + "outcome_collections": , + "fee": } ``` - `threshold`: Minimum oracles required (default: 1) - `tags`: [NIP-88][NIP-88] tag array - `announcements`: Hex-encoded oracle announcement TLV bytes +- `collateral`: Unit string for root conditions (e.g., `"sat"`). REQUIRED if `outcome_collections` is present or if the mint's default keyset creation rule creates keysets. +- `outcome_collections` (optional): Canonical outcome collections to create keysets for. If omitted, the mint applies its `default_keyset_creation` policy. If the mint advertises `default_keyset_creation` as `"one-vs-rest"` or `"all"`, clients MUST omit this field and the mint MUST reject client-defined collections. +- `fee` (optional): A non-refundable anti-spam registration fee paid as `Proof` objects. The proofs MUST be from a **regular** keyset ([NUT-02][02]) whose unit equals `collateral`; conditional-keyset proofs MUST be rejected (error 13017). The mint marks these proofs spent and retains them as revenue — they are **not** condition collateral and never enter the [NUT-CTF-split-merge][CTF-split-merge] solvency accounting. REQUIRED when the mint advertises a non-zero registration fee (see [Mint Info Setting](#mint-info-setting)); MAY be omitted when the advertised fee is `0`. See [Registration Fee](#registration-fee). **Response** of `Bob`: ```json { - "condition_id": + "condition_id": , + "keysets": { + "": , + "": , + ... + } } ``` ```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..."]}' + -d '{"threshold":1,"tags":[["description","Will BTC reach $100k?"]],"announcements":["fdd824fd..."],"collateral":"sat","outcome_collections":["YES","NO"]}' ``` #### Mint Behavior 1. Parses and verifies announcement signatures (error 13011 if failed) 2. Computes `condition_id` -3. 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`: +3. Determines requested keysets: + - If `outcome_collections` is provided and `default_keyset_creation` is `"none"`: canonicalizes and validates the requested collections. + - If `outcome_collections` is provided and `default_keyset_creation` is `"one-vs-rest"` or `"all"`: rejects the request. + - If omitted: applies `default_keyset_creation`. +4. If condition exists with matching config and requested keyset set: returns existing `condition_id` and keysets (idempotent). The mint MUST NOT charge the registration fee on this path — see [Registration Fee](#registration-fee). +5. If condition exists with different config or requested keyset set: error 13028 +6. If new: charges the registration fee (if any), stores the condition, creates keysets, and returns `condition_id` and `keysets` — all in a single atomic transaction (see [Registration Fee](#registration-fee)) -```json -{ - "collateral": , - "partition": , - "parent_collection_id": -} -``` +The mint MUST make condition registration idempotent. Mints MAY charge a [registration fee](#registration-fee) and/or require [NUT-21][21] or [NUT-22][22] authentication for DoS prevention. -- `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. +### Registration Fee -**Response** of `Bob`: +To bound condition-registration spam, a mint MAY charge a non-refundable fee per new condition, advertised via [Mint Info Setting](#mint-info-setting). The required amount, denominated in the `collateral` unit, is: -```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"]}' +required_fee = registration_fee_base + registration_fee_per_keyset * num_keysets ``` -#### Mint Behavior - -1. Looks up condition (error 13021 if not found) -2. Validates partition rules (error 13037 overlapping, error 13038 incomplete, error 13043 full-set or single-element) -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 +where `num_keysets` is the number of conditional keysets this registration creates (after step 3 above). When `registration_fee_base` and `registration_fee_per_keyset` are both `0`, registration is free and the `fee` field MAY be omitted. -**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. +When `required_fee > 0`, the mint MUST, for a **new** condition only: -**Idempotency:** The mint MUST make partition registration idempotent. +1. Require the `fee` field. Each `Proof` MUST be valid, unspent, from a regular keyset whose unit equals `collateral` (error 13017 for a conditional/wrong-unit keyset), and the sum of `fee` proof amounts MUST be at least `required_fee` (error 13044 otherwise). +2. Atomically, in a single transaction: mark the `fee` proofs spent, store the condition, and create the keysets. If any step fails, none are applied — a failed registration MUST NOT consume the fee. -**DoS prevention:** Mints MAY require [NUT-21][21] or [NUT-22][22] authentication. +The mint MUST compute `required_fee` and verify the fee **after** the idempotency check (step 4). A repeated registration that resolves to an existing condition MUST return the existing result without charging again; otherwise a client retry would either double-charge or fail because the original `fee` proofs are already spent. The registration fee is mint revenue and is independent of condition collateral and the [NUT-CTF-split-merge][CTF-split-merge] solvency invariant. ## 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. +Each outcome collection has a unique `outcome_collection_id` derived from the condition ID and outcome collection string. The result is a 32-byte x-only public key on secp256k1. ### Computation ``` -outcome_collection_id(parent_collection_id, condition_id, outcome_collection_string): +outcome_collection_id(condition_id, outcome_collection_string): 1. h = tagged_hash("Cashu_outcome_collection_id", condition_id || outcome_collection_string_bytes) 2. P = hash_to_curve(h) - 3. 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)) + 3. Return x_only(P) ``` Where: - `tagged_hash`: BIP-340 tagged hash - `hash_to_curve`: Same approach as [NUT-00][00]'s `hash_to_curve` with domain separation via tagged hash input -- `EC_add`: secp256k1 point addition -- `lift_x` / `x_only`: Per [BIP-340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) +- `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)`. +This version defines root-level outcome collections only. Combinatorial/nested condition construction is intentionally out of scope until the registration API includes an explicit way to create parent-scoped keysets. ## Conditional Keyset Discovery @@ -420,7 +380,7 @@ POST https://mint.host:3338/v1/redeem_outcome ``` - `inputs`: `Proof` objects from a **single conditional keyset**, each with `witness` containing oracle attestation -- `outputs`: `BlindedMessage` objects (same unit). For a root condition, outputs use a **regular keyset**. For a nested condition (the input keyset's `parent_collection_id` is non-zero), outputs use the **parent collection's conditional keyset** — the active keyset whose `outcome_collection_id` equals that `parent_collection_id` — consistent with nested convert in [NUT-CTF-split-merge][CTF-split-merge]. +- `outputs`: `BlindedMessage` objects (same unit). Outputs use a **regular keyset**. `Alice` MAY omit `oracle_sigs` if `Bob` has already recorded a valid attestation for this outcome collection (check via `GET /v1/conditions/{condition_id}`). @@ -455,15 +415,15 @@ All conditional-to-regular conversions go through `POST /v1/redeem_outcome`. Mov 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 (root condition) or the parent collection's conditional keyset (nested condition), same unit +2. All outputs MUST use a regular keyset with the same unit 3. If `Bob` already has a valid attestation for this outcome collection, MAY skip steps 4-5 4. Each input MUST include valid `witness` with `oracle_sigs` 5. Verify at least `threshold` signatures from distinct oracles using [DLC signing algorithm](https://github.com/discreetlogcontracts/dlcspecs/blob/master/Oracle.md#signing-algorithm) with tagged hash `"DLC/oracle/attestation/v0"` and UTF-8 NFC-normalized outcome string -6. Verify this outcome collection is the attested winner +6. Verify this outcome collection contains the attested atomic outcome ### 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 persistently record the first valid attestation (atomic winning outcome + timestamp) for each condition. This record MUST survive restarts. The mint MUST NOT process redemptions for non-winning keysets. If a valid signature for a different outcome is received (a DLC protocol violation), the mint MUST reject it and MUST log the conflict. Mints SHOULD expose violations via condition info. @@ -494,9 +454,10 @@ If the oracle does not attest within expected time, the mint MAY refund conditio | 13021 | Condition not found | | 13027 | Oracle threshold not met | | 13028 | Condition already exists | -| 13037 | Overlapping outcome collections | -| 13038 | Incomplete partition | -| 13043 | Full-set or single-element partition | +| 13037 | Duplicate canonical outcome collection | +| 13038 | Unknown outcome in outcome collection | +| 13043 | Full-set or reserved outcome collection | +| 13044 | Missing or insufficient registration fee | ## Mint Info Setting @@ -507,7 +468,10 @@ The [NUT-06][06] `MintMethodSetting` indicates support for this feature: "CTF": { "supported": true, "dlc_version": , - "vesting_period": + "vesting_period": , + "default_keyset_creation": , + "registration_fee_base": , + "registration_fee_per_keyset": } } ``` @@ -515,6 +479,13 @@ The [NUT-06][06] `MintMethodSetting` indicates support for this feature: - `supported`: Boolean indicating NUT-CTF support - `vesting_period` (optional): Seconds after `event_maturity_epoch` for redemption. Default: 30 days (2592000). `0` = no expiry. - `dlc_version`: DLC protocol version (currently `"0"`) +- `default_keyset_creation`: `"none"` (default), `"one-vs-rest"`, or `"all"`. + - `"none"`: omitted `outcome_collections` creates no enum keysets; clients MAY request explicit non-full enum collections. + - `"one-vs-rest"`: omitted `outcome_collections` creates each atomic outcome and its complement, deduplicated for binary conditions; clients MUST NOT request explicit collections. + - `"all"`: omitted `outcome_collections` creates every non-empty, non-full collection subject to mint limits; clients MUST NOT request explicit collections. + - Numeric conditions always create `HI` and `LO` keysets at registration, regardless of this policy. +- `registration_fee_base` (optional): Flat anti-spam fee charged per new condition, in the smallest unit of the condition's `collateral`. Default: `0` (free). See [Registration Fee](#registration-fee). +- `registration_fee_per_keyset` (optional): Additional fee charged per conditional keyset the registration creates, in the smallest unit of the condition's `collateral`. Default: `0`. The total required fee is `registration_fee_base + registration_fee_per_keyset * num_keysets`. [00]: 00.md [01]: 01.md diff --git a/error_codes.md b/error_codes.md index 446ae2be..5058612d 100644 --- a/error_codes.md +++ b/error_codes.md @@ -50,12 +50,12 @@ | 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] | +| 13037 | Duplicate canonical outcome collection | [NUT-CTF][CTF], [NUT-CTF-split-merge][CTF-split-merge] | +| 13038 | Unknown outcome in outcome collection | [NUT-CTF][CTF], [NUT-CTF-split-merge][CTF-split-merge] | +| 13040 | Reserved | [NUT-CTF-split-merge][CTF-split-merge] | | 13041 | Convert payoff/fee violation | [NUT-CTF-split-merge][CTF-split-merge] | | 13042 | Convert not permitted for this condition | [NUT-CTF-split-merge][CTF-split-merge] | -| 13043 | Full-set or single-element partition | [NUT-CTF][CTF], [NUT-CTF-split-merge][CTF-split-merge] | +| 13043 | Full-set or reserved outcome collection | [NUT-CTF][CTF], [NUT-CTF-split-merge][CTF-split-merge] | [00]: 00.md [01]: 01.md diff --git a/suppl/CTF-split-merge.md b/suppl/CTF-split-merge.md index ed1df821..11354ba1 100644 --- a/suppl/CTF-split-merge.md +++ b/suppl/CTF-split-merge.md @@ -18,30 +18,9 @@ POST https://mint.host:3338/v1/conditions { "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 -{ + "announcements": ["fdd824fd<...hex-encoded oracle_announcement TLV...>"], "collateral": "sat", - "partition": ["YES", "NO"] + "outcome_collections": ["YES", "NO"] } ``` @@ -49,6 +28,7 @@ POST https://mint.host:3338/v1/conditions/a1b2c3d4e5f67890.../partitions ```json { + "condition_id": "a1b2c3d4e5f67890...", "keysets": { "YES": "00abc123def456", "NO": "00def789abc012" diff --git a/tests/CTF-numeric-tests.md b/tests/CTF-numeric-tests.md index 03bce891..25859378 100644 --- a/tests/CTF-numeric-tests.md +++ b/tests/CTF-numeric-tests.md @@ -15,27 +15,19 @@ register_request: { "condition_type": "numeric", "lo_bound": 0, "hi_bound": 100000, - "precision": 0 + "precision": 0, + "collateral": "sat" } register_response: { - "condition_id": "" -} - -# Step 2: Register partition via POST /v1/conditions/{condition_id}/partitions -partition_request: { - "collateral": "sat", - "partition": ["HI", "LO"] -} - -partition_response: { + "condition_id": "", "keysets": { "HI": "00hi11keyset22", "LO": "00lo33keyset44" } } -# Partition is always ["HI", "LO"] for numeric markets +# Numeric keysets are always ["HI", "LO"]. # condition_id = tagged_hash("Cashu_condition_id", # sorted_oracle_pubkeys || event_id || outcome_count # || 0x01 || lo_bound_i64be || hi_bound_i64be || precision_i32be) diff --git a/tests/CTF-split-merge-tests.md b/tests/CTF-split-merge-tests.md index 4c70f7b3..6531a71a 100644 --- a/tests/CTF-split-merge-tests.md +++ b/tests/CTF-split-merge-tests.md @@ -4,7 +4,7 @@ These test vectors provide reference data for implementing the Conditional Token ## 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. +The condition ID is computed as `tagged_hash("Cashu_condition_id", sorted_oracle_pubkeys || event_id || outcome_count)` where `tagged_hash(tag, msg) = SHA256(SHA256(tag) || SHA256(tag) || msg)`. The condition ID is independent of requested outcome keysets. ### Test 1: Binary condition ID @@ -21,7 +21,7 @@ tag: "Cashu_condition_id" tag_utf8: 43617368755f636f6e646974696f6e5f6964 tag_hash: SHA256(tag_utf8) -# Preimage (message for tagged hash) — no partition keys +# Preimage (message for tagged hash) — no outcome keyset identifiers msg_hex: 9be6fa256a022aafc98f24a71f0e37ab2ac6fe5b208a77a3d429b4b5c59f7ce06274635f70726963655f3130306b5f3230323502 # Condition ID = SHA256(tag_hash || tag_hash || msg) @@ -38,7 +38,7 @@ 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 +# No outcome keyset identifiers — condition_id is keyset-independent ``` ### Test 3: Condition ID with special characters in question @@ -52,32 +52,24 @@ outcome_count: 2 outcome_count_byte: 02 # Condition ID uses tagged_hash (includes space, /, >, $ characters in event_id) -# No partition keys in condition_id +# No outcome keyset identifiers in condition_id ``` -## Condition and Partition Registration +## Condition Registration -### Test 4: Register condition and partition (binary) +### Test 4: Register condition with default keysets (binary) ```shell # Step 1: Register condition (POST /v1/conditions) register_request: { "threshold": 1, "tags": [["description", "Will BTC reach $100k?"]], - "announcements": [""] + "announcements": [""], + "collateral": "sat" } register_response: { - "condition_id": "" -} - -# Step 2: Register partition (POST /v1/conditions/{condition_id}/partitions) -partition_request: { - "collateral": "sat", - "partition": ["YES", "NO"] -} - -partition_response: { + "condition_id": "", "keysets": { "YES": "00abc123def456", "NO": "00def789abc012" @@ -87,31 +79,26 @@ partition_response: { # These keyset IDs are used in all subsequent split/merge/trade operations ``` -### Test 5: Three-outcome condition with partition registration +### Test 5: Three-outcome condition with one-vs-rest keysets ```shell # Step 1: Register condition register_request: { "threshold": 1, "tags": [["description", "Election winner"]], - "announcements": [""] + "announcements": [""], + "collateral": "sat" } register_response: { - "condition_id": "" -} - -# Step 2: Register partition -partition_request: { - "collateral": "sat", - "partition": ["CANDIDATE_A", "CANDIDATE_B", "CANDIDATE_C"] -} - -partition_response: { + "condition_id": "", "keysets": { "CANDIDATE_A": "00aa11bb22cc33dd", "CANDIDATE_B": "00bb22cc33dd44ee", - "CANDIDATE_C": "00cc33dd44ee55ff" + "CANDIDATE_C": "00cc33dd44ee55ff", + "CANDIDATE_A|CANDIDATE_B": "00dd44ee55ff6600", + "CANDIDATE_A|CANDIDATE_C": "00ee55ff66007711", + "CANDIDATE_B|CANDIDATE_C": "00ff660077118822" } } ``` @@ -436,7 +423,7 @@ 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 +# No outcome keyset identifiers — condition_id is keyset-independent ``` ## Outcome Collections @@ -444,10 +431,10 @@ outcome_count: 2 ### Test 22: Split with outcome collections (3-outcome condition) ```shell -# Condition with 3 outcomes, partition registered with outcome collections +# Condition with 3 outcomes, registered with root outcome collection keysets outcomes: ["ALICE", "BOB", "CAROL"] -# Partition registration returned keysets for this partition +# Condition registration returned these keysets keysets: "ALICE|BOB": 00aabb11cc22dd33 "CAROL": 00ccdd44ee55ff66 @@ -474,8 +461,8 @@ request_json: { } } -# Partition check -partition_valid: true (ALICE|BOB and CAROL cover all outcomes, disjoint) +# Payoff check +payoff_valid: true (ALICE|BOB and CAROL cover all outcomes, disjoint) ``` ### Test 23: Outcome collection redemption (oracle signs covered outcome) @@ -510,28 +497,28 @@ error_code: 13015 error_message: "Oracle has not attested to this outcome collection" ``` -### Test 25: Overlapping outcome collections error +### Test 25: Duplicate canonical outcome collection error ```shell -# Invalid partition - BOB appears in both sets -outputs_keys: ["ALICE|BOB", "BOB|CAROL"] +# Invalid request - duplicate after canonicalization +outcome_collections: ["ALICE|BOB", "BOB|ALICE"] condition_outcomes: ["ALICE", "BOB", "CAROL"] # Validation fails error_code: 13037 -error_message: "Overlapping outcome collections" +error_message: "Duplicate canonical outcome collection" ``` -### Test 26: Incomplete partition error +### Test 26: Unknown outcome collection member error ```shell -# Invalid partition - CAROL is missing -outputs_keys: ["ALICE|BOB"] +# Invalid request - DAVE is not in the oracle outcome list +outcome_collections: ["ALICE|DAVE"] condition_outcomes: ["ALICE", "BOB", "CAROL"] # Validation fails error_code: 13038 -error_message: "Incomplete partition" +error_message: "Unknown outcome in outcome collection" ``` ### Test 27: Merge with outcome collections @@ -558,7 +545,7 @@ request_json: { } # Input proofs use outcome collection keysets, outputs are collateral under "*" -# Valid merge - outcome collections form complete partition +# Valid merge - outcome collections form a complete disjoint cover merge_result: SUCCESS ``` @@ -616,130 +603,67 @@ btc_price_condition_id: b2c3d4e5f6789012345678901234567890123456789012345678901 # P_election_A + P_btc_UP = P_btc_UP + P_election_A ``` -### Test 31: Nested condition and partition registration +### Test 31: Nested parent collection rejected ```shell # Step 1a: Register root election condition (POST /v1/conditions) root_condition_request: { "threshold": 1, "tags": [["description", "Election winner"]], - "announcements": [""] + "announcements": [""], + "collateral": "sat" } 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: { + "condition_id": "", "keysets": { "PARTY_A": "00aa11bb22cc33dd", "PARTY_B": "00bb22cc33dd44ee" } } -# Step 2a: Register nested BTC price condition (POST /v1/conditions) -nested_condition_request: { - "threshold": 1, - "tags": [["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"], +# Nested/combinatorial construction is out of scope for this version. +convert_request: { + "condition_id": "", "parent_collection_id": "" } -nested_partition_response: { - "keysets": { - "UP": "00cc33dd44ee55ff", - "DOWN": "00dd44ee55ff6600" - } -} +error_code: 13041 ``` -### Test 32: Nested condition split +### Test 32: Nested condition split rejected ```shell -# Split PARTY_A tokens into PARTY_A&UP and PARTY_A&DOWN -# Inputs are the parent collection (PARTY_A) under "*" — nested collateral -# Outputs use nested condition conditional keysets +# Non-zero parent_collection_id is reserved for future nested support. request_json: { "condition_id": "", "parent_collection_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..."} - ] - } + "inputs": {"*": [{"amount": 100, "id": "009a1f293253e41e", "secret": "secret", "C": "02..."}]}, + "outputs": {"UP": [{"amount": 99, "id": "00cc33dd44ee55ff", "B_": "03..."}]} } -# Input uses PARTY_A keyset (parent outcome collection) -# Outputs use UP/DOWN keysets (nested outcome collections) -result: PASS +error_code: 13041 ``` -### Test 33: Nested condition merge +### Test 33: Nested condition merge rejected ```shell -# Merge PARTY_A&UP and PARTY_A&DOWN back to PARTY_A tokens request_json: { "condition_id": "", "parent_collection_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..."} - ] - } + "inputs": {"UP": [{"amount": 100, "id": "00cc33dd44ee55ff", "secret": "up_secret_1", "C": "02..."}]}, + "outputs": {"*": [{"amount": 99, "id": "009a1f293253e41e", "B_": "03..."}]} } -# Nested collateral "*" is the PARTY_A parent keyset (not a regular keyset) -result: PASS +error_code: 13041 ``` -### Test 34: Maximum depth exceeded +### Test 34: Maximum depth unsupported ```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" +# This version has no max_depth setting; all non-root parent_collection_id values are rejected. +error_code: 13041 +error_message: "Convert payoff/fee violation" ``` ## Complete Flow Example @@ -750,7 +674,7 @@ error_message: "Maximum condition depth exceeded" # Step 1a: Register condition (POST /v1/conditions) condition_id: -# Step 1b: Register partition (POST /v1/conditions/{condition_id}/partitions) +# Step 1b: Keysets returned by condition registration keysets: YES: 00abc123def456 NO: 00def789abc012 @@ -846,13 +770,13 @@ error_code: 13042 error_message: "Convert not permitted for this condition" ``` -### Test 40: Full-set / single-element partition rejected at registration +### Test 40: Full-set outcome collection rejected at registration ```shell -# Attempt to register a partition whose only element covers all outcomes -partition: ["ALICE|BOB|CAROL"] # full-set, single element +# Attempt to register an outcome collection that covers all outcomes +outcome_collections: ["ALICE|BOB|CAROL"] error_code: 13043 -error_message: "Full-set or single-element partition" +error_message: "Full-set or reserved outcome collection" ``` [NUT-CTF-split-merge]: ../CTF-split-merge.md From d61fda79bda2cd2892bf2e382fc245f62b66b03d Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Sun, 7 Jun 2026 13:16:04 +0900 Subject: [PATCH 06/27] feat: document CTF registration fees --- CTF.md | 2 ++ error_codes.md | 3 +++ 2 files changed, 5 insertions(+) diff --git a/CTF.md b/CTF.md index bdf85c96..e28e6d91 100644 --- a/CTF.md +++ b/CTF.md @@ -458,6 +458,8 @@ If the oracle does not attest within expected time, the mint MAY refund conditio | 13038 | Unknown outcome in outcome collection | | 13043 | Full-set or reserved outcome collection | | 13044 | Missing or insufficient registration fee | +| 13045 | Hash to curve failed | +| 13046 | EC point operation failed | ## Mint Info Setting diff --git a/error_codes.md b/error_codes.md index 5058612d..92e58d65 100644 --- a/error_codes.md +++ b/error_codes.md @@ -56,6 +56,9 @@ | 13041 | Convert payoff/fee violation | [NUT-CTF-split-merge][CTF-split-merge] | | 13042 | Convert not permitted for this condition | [NUT-CTF-split-merge][CTF-split-merge] | | 13043 | Full-set or reserved outcome collection | [NUT-CTF][CTF], [NUT-CTF-split-merge][CTF-split-merge] | +| 13044 | Missing or insufficient registration fee | [NUT-CTF][CTF] | +| 13045 | Hash to curve failed | [NUT-CTF][CTF] | +| 13046 | EC point operation failed | [NUT-CTF][CTF] | [00]: 00.md [01]: 01.md From a3f6544e6d08ab762ee934675d1e521cd2fbfe1c Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Sun, 7 Jun 2026 14:25:31 +0900 Subject: [PATCH 07/27] feat: define CTF registration fee change Add NUT-08-style blank outputs and change signatures for condition registration fees so mints retain only the authoritative required fee. --- CTF.md | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/CTF.md b/CTF.md index e28e6d91..95ff4449 100644 --- a/CTF.md +++ b/CTF.md @@ -215,7 +215,8 @@ Registers a new condition and creates requested conditional keysets. "announcements": , "collateral": , "outcome_collections": , - "fee": + "fee": , + "outputs": } ``` @@ -224,7 +225,8 @@ Registers a new condition and creates requested conditional keysets. - `announcements`: Hex-encoded oracle announcement TLV bytes - `collateral`: Unit string for root conditions (e.g., `"sat"`). REQUIRED if `outcome_collections` is present or if the mint's default keyset creation rule creates keysets. - `outcome_collections` (optional): Canonical outcome collections to create keysets for. If omitted, the mint applies its `default_keyset_creation` policy. If the mint advertises `default_keyset_creation` as `"one-vs-rest"` or `"all"`, clients MUST omit this field and the mint MUST reject client-defined collections. -- `fee` (optional): A non-refundable anti-spam registration fee paid as `Proof` objects. The proofs MUST be from a **regular** keyset ([NUT-02][02]) whose unit equals `collateral`; conditional-keyset proofs MUST be rejected (error 13017). The mint marks these proofs spent and retains them as revenue — they are **not** condition collateral and never enter the [NUT-CTF-split-merge][CTF-split-merge] solvency accounting. REQUIRED when the mint advertises a non-zero registration fee (see [Mint Info Setting](#mint-info-setting)); MAY be omitted when the advertised fee is `0`. See [Registration Fee](#registration-fee). +- `fee` (optional): Anti-spam registration fee inputs paid as `Proof` objects. The proofs MUST be from a **regular** keyset ([NUT-02][02]) whose unit equals `collateral`; conditional-keyset proofs MUST be rejected (error 13017). REQUIRED when the mint advertises a non-zero registration fee (see [Mint Info Setting](#mint-info-setting)); MAY be omitted when the advertised fee is `0`. The mint retains exactly `required_fee` as revenue and returns any excess as regular ecash change using `outputs` / `change` as described in [Registration Fee](#registration-fee). +- `outputs` (optional): Blank `BlindedMessage` objects for returning excess fee input as regular ecash change, following the [NUT-08][08] blank-output model. The `amount` field in these messages is ignored by the mint and MAY be set to any valid placeholder amount by the client. Outputs MUST use a regular keyset whose unit equals `collateral`; conditional-keyset outputs MUST be rejected (error 13017). Clients SHOULD provide enough blank outputs to represent the maximum possible change from the selected `fee` proofs. **Response** of `Bob`: @@ -235,7 +237,8 @@ Registers a new condition and creates requested conditional keysets. "": , "": , ... - } + }, + "change": } ``` @@ -255,13 +258,13 @@ curl -X POST https://mint.host:3338/v1/conditions \ - If omitted: applies `default_keyset_creation`. 4. If condition exists with matching config and requested keyset set: returns existing `condition_id` and keysets (idempotent). The mint MUST NOT charge the registration fee on this path — see [Registration Fee](#registration-fee). 5. If condition exists with different config or requested keyset set: error 13028 -6. If new: charges the registration fee (if any), stores the condition, creates keysets, and returns `condition_id` and `keysets` — all in a single atomic transaction (see [Registration Fee](#registration-fee)) +6. If new: charges the registration fee (if any), stores the condition, creates keysets, and returns `condition_id`, `keysets`, and any fee `change` — all in a single atomic transaction (see [Registration Fee](#registration-fee)) The mint MUST make condition registration idempotent. Mints MAY charge a [registration fee](#registration-fee) and/or require [NUT-21][21] or [NUT-22][22] authentication for DoS prevention. ### Registration Fee -To bound condition-registration spam, a mint MAY charge a non-refundable fee per new condition, advertised via [Mint Info Setting](#mint-info-setting). The required amount, denominated in the `collateral` unit, is: +To bound condition-registration spam, a mint MAY charge a fee per new condition, advertised via [Mint Info Setting](#mint-info-setting). The required amount, denominated in the `collateral` unit, is: ``` required_fee = registration_fee_base + registration_fee_per_keyset * num_keysets @@ -269,12 +272,20 @@ required_fee = registration_fee_base + registration_fee_per_keyset * num_keysets where `num_keysets` is the number of conditional keysets this registration creates (after step 3 above). When `registration_fee_base` and `registration_fee_per_keyset` are both `0`, registration is free and the `fee` field MAY be omitted. +The registration fee is mint-authoritative: the mint computes `num_keysets` after canonicalization, validation, default policy expansion, duplicate removal, and full-set exclusion. Clients MUST NOT rely on a locally computed keyset count as the payment authority. + +No additional [NUT-02][02] `input_fee_ppk` is charged on `fee` proofs for condition registration; `required_fee` is the sole amount retained by the mint. When `required_fee == 0`, the mint MUST NOT consume any `fee` proofs and MUST NOT sign `outputs` as fee change. + When `required_fee > 0`, the mint MUST, for a **new** condition only: 1. Require the `fee` field. Each `Proof` MUST be valid, unspent, from a regular keyset whose unit equals `collateral` (error 13017 for a conditional/wrong-unit keyset), and the sum of `fee` proof amounts MUST be at least `required_fee` (error 13044 otherwise). -2. Atomically, in a single transaction: mark the `fee` proofs spent, store the condition, and create the keysets. If any step fails, none are applied — a failed registration MUST NOT consume the fee. +2. Compute `change_amount = sum(fee) - required_fee`. +3. If `change_amount > 0`, require `outputs` with enough blank messages to return the exact change amount as regular ecash. The mint decomposes `change_amount` into amounts supported by the output keyset, assigns those amounts to the blank outputs, signs them, and returns the resulting positive-value `BlindSignature`s in `change`. The returned `change` signatures MUST preserve the order of the blank outputs they correspond to and MUST omit zero-value outputs, matching [NUT-08][08]. If exact change cannot be returned from the provided outputs, the mint MUST reject the request (error 13044) and MUST NOT consume the fee proofs. +4. Atomically, in a single transaction: mark the `fee` proofs spent, store the condition, and create the keysets. If any step fails, none are applied — a failed registration MUST NOT consume the fee. + +The response for a new paid registration MUST include `change` when `change_amount > 0`, and MAY omit `change` or return an empty array when `change_amount == 0`. -The mint MUST compute `required_fee` and verify the fee **after** the idempotency check (step 4). A repeated registration that resolves to an existing condition MUST return the existing result without charging again; otherwise a client retry would either double-charge or fail because the original `fee` proofs are already spent. The registration fee is mint revenue and is independent of condition collateral and the [NUT-CTF-split-merge][CTF-split-merge] solvency invariant. +The mint MUST compute `required_fee` and verify the fee **after** the idempotency check (step 4). A repeated registration that resolves to an existing condition MUST return the existing `condition_id` and `keysets` without charging again and without returning `change`; clients keep their unspent `fee` proofs and discard unused blank outputs. This prevents a client retry from double-charging or failing because retry proofs were already spent. The registration fee is mint revenue and is independent of condition collateral and the [NUT-CTF-split-merge][CTF-split-merge] solvency invariant. Fee change is regular ecash and is not condition collateral. ## Outcome Collection ID @@ -457,7 +468,7 @@ If the oracle does not attest within expected time, the mint MAY refund conditio | 13037 | Duplicate canonical outcome collection | | 13038 | Unknown outcome in outcome collection | | 13043 | Full-set or reserved outcome collection | -| 13044 | Missing or insufficient registration fee | +| 13044 | Missing or insufficient registration fee or change outputs | | 13045 | Hash to curve failed | | 13046 | EC point operation failed | From 399aca9a4b678c266ed8c7e834f04361301ef49f Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Sun, 7 Jun 2026 14:43:56 +0900 Subject: [PATCH 08/27] fix: clarify CTF fee change errors Use a distinct 13047 error for invalid registration fee change outputs and disambiguate the idempotency cross-reference. --- CTF.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CTF.md b/CTF.md index 95ff4449..29d0dc4e 100644 --- a/CTF.md +++ b/CTF.md @@ -280,12 +280,12 @@ When `required_fee > 0`, the mint MUST, for a **new** condition only: 1. Require the `fee` field. Each `Proof` MUST be valid, unspent, from a regular keyset whose unit equals `collateral` (error 13017 for a conditional/wrong-unit keyset), and the sum of `fee` proof amounts MUST be at least `required_fee` (error 13044 otherwise). 2. Compute `change_amount = sum(fee) - required_fee`. -3. If `change_amount > 0`, require `outputs` with enough blank messages to return the exact change amount as regular ecash. The mint decomposes `change_amount` into amounts supported by the output keyset, assigns those amounts to the blank outputs, signs them, and returns the resulting positive-value `BlindSignature`s in `change`. The returned `change` signatures MUST preserve the order of the blank outputs they correspond to and MUST omit zero-value outputs, matching [NUT-08][08]. If exact change cannot be returned from the provided outputs, the mint MUST reject the request (error 13044) and MUST NOT consume the fee proofs. +3. If `change_amount > 0`, require `outputs` with enough blank messages to return the exact change amount as regular ecash. The mint decomposes `change_amount` into amounts supported by the output keyset, assigns those amounts to the blank outputs, signs them, and returns the resulting positive-value `BlindSignature`s in `change`. The returned `change` signatures MUST preserve the order of the blank outputs they correspond to and MUST omit zero-value outputs, matching [NUT-08][08]. If exact change cannot be returned from the provided outputs, the mint MUST reject the request (error 13047) and MUST NOT consume the fee proofs. 4. Atomically, in a single transaction: mark the `fee` proofs spent, store the condition, and create the keysets. If any step fails, none are applied — a failed registration MUST NOT consume the fee. The response for a new paid registration MUST include `change` when `change_amount > 0`, and MAY omit `change` or return an empty array when `change_amount == 0`. -The mint MUST compute `required_fee` and verify the fee **after** the idempotency check (step 4). A repeated registration that resolves to an existing condition MUST return the existing `condition_id` and `keysets` without charging again and without returning `change`; clients keep their unspent `fee` proofs and discard unused blank outputs. This prevents a client retry from double-charging or failing because retry proofs were already spent. The registration fee is mint revenue and is independent of condition collateral and the [NUT-CTF-split-merge][CTF-split-merge] solvency invariant. Fee change is regular ecash and is not condition collateral. +The mint MUST compute `required_fee` and verify the fee **after** the idempotency check (Mint Behavior step 4). A repeated registration that resolves to an existing condition MUST return the existing `condition_id` and `keysets` without charging again and without returning `change`; clients keep their unspent `fee` proofs and discard unused blank outputs. This prevents a client retry from double-charging or failing because retry proofs were already spent. The registration fee is mint revenue and is independent of condition collateral and the [NUT-CTF-split-merge][CTF-split-merge] solvency invariant. Fee change is regular ecash and is not condition collateral. ## Outcome Collection ID @@ -468,9 +468,10 @@ If the oracle does not attest within expected time, the mint MAY refund conditio | 13037 | Duplicate canonical outcome collection | | 13038 | Unknown outcome in outcome collection | | 13043 | Full-set or reserved outcome collection | -| 13044 | Missing or insufficient registration fee or change outputs | +| 13044 | Missing or insufficient registration fee | | 13045 | Hash to curve failed | | 13046 | EC point operation failed | +| 13047 | Insufficient or invalid change outputs | ## Mint Info Setting From eebcb32bd3c8e861796cd812105d2179840f0ee9 Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Sat, 13 Jun 2026 10:45:08 +0900 Subject: [PATCH 09/27] Add collateral to condition info --- CTF.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CTF.md b/CTF.md index 29d0dc4e..7eaf6d98 100644 --- a/CTF.md +++ b/CTF.md @@ -134,6 +134,7 @@ Conditions are registered via `POST /v1/conditions` before any operations on con "threshold": , "tags": , "announcements": , + "collateral": , "registered_at": , "keysets": { "": , @@ -151,6 +152,7 @@ Conditions are registered via `POST /v1/conditions` before any operations on con - `threshold`: Minimum oracles required for attestation (default: 1) - `tags`: [NIP-88][NIP-88] tag array (e.g., `[["description", "..."], ["n", "BTC"]]`). Display-only metadata; does NOT affect `condition_id`. - `announcements`: Hex-encoded oracle announcement TLV bytes +- `collateral` (optional): Condition's collateral currency unit, echoing the value accepted during registration. Clients use this to verify unit consistency before building markets on a condition. - `registered_at`: Unix timestamp of registration - `keysets`: Flat map of all root-level requested outcome collections to keyset IDs. Use `GET /v1/conditional_keysets` for full keyset metadata. - `attestation` (optional, omitted if no attestation): From 1e03024e4389d2db62b9a8ca77b65babf656ce3c Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Wed, 24 Jun 2026 22:21:48 +0900 Subject: [PATCH 10/27] Specify CTF registration fees per unit Advertise registration fee schedules by collateral unit and default missing schedules to free registration. --- CTF.md | 23 ++++++--- tests/CTF-tests.md | 121 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 131 insertions(+), 13 deletions(-) diff --git a/CTF.md b/CTF.md index 7eaf6d98..0fced841 100644 --- a/CTF.md +++ b/CTF.md @@ -227,7 +227,7 @@ Registers a new condition and creates requested conditional keysets. - `announcements`: Hex-encoded oracle announcement TLV bytes - `collateral`: Unit string for root conditions (e.g., `"sat"`). REQUIRED if `outcome_collections` is present or if the mint's default keyset creation rule creates keysets. - `outcome_collections` (optional): Canonical outcome collections to create keysets for. If omitted, the mint applies its `default_keyset_creation` policy. If the mint advertises `default_keyset_creation` as `"one-vs-rest"` or `"all"`, clients MUST omit this field and the mint MUST reject client-defined collections. -- `fee` (optional): Anti-spam registration fee inputs paid as `Proof` objects. The proofs MUST be from a **regular** keyset ([NUT-02][02]) whose unit equals `collateral`; conditional-keyset proofs MUST be rejected (error 13017). REQUIRED when the mint advertises a non-zero registration fee (see [Mint Info Setting](#mint-info-setting)); MAY be omitted when the advertised fee is `0`. The mint retains exactly `required_fee` as revenue and returns any excess as regular ecash change using `outputs` / `change` as described in [Registration Fee](#registration-fee). +- `fee` (optional): Anti-spam registration fee inputs paid as `Proof` objects. The proofs MUST be from a **regular** keyset ([NUT-02][02]) whose unit equals `collateral`; conditional-keyset proofs MUST be rejected (error 13017). REQUIRED when the mint advertises a non-zero registration fee for the requested `collateral` unit (see [Mint Info Setting](#mint-info-setting)); MAY be omitted when the advertised fee for that unit is `0`. The mint retains exactly `required_fee` as revenue and returns any excess as regular ecash change using `outputs` / `change` as described in [Registration Fee](#registration-fee). - `outputs` (optional): Blank `BlindedMessage` objects for returning excess fee input as regular ecash change, following the [NUT-08][08] blank-output model. The `amount` field in these messages is ignored by the mint and MAY be set to any valid placeholder amount by the client. Outputs MUST use a regular keyset whose unit equals `collateral`; conditional-keyset outputs MUST be rejected (error 13017). Clients SHOULD provide enough blank outputs to represent the maximum possible change from the selected `fee` proofs. **Response** of `Bob`: @@ -266,13 +266,13 @@ The mint MUST make condition registration idempotent. Mints MAY charge a [regist ### Registration Fee -To bound condition-registration spam, a mint MAY charge a fee per new condition, advertised via [Mint Info Setting](#mint-info-setting). The required amount, denominated in the `collateral` unit, is: +To bound condition-registration spam, a mint MAY charge a fee per new condition, advertised per collateral unit via [Mint Info Setting](#mint-info-setting). For a registration whose `collateral` is unit `u`, the mint selects the unique advertised registration-fee setting whose `unit == u`. If no setting is advertised for `u`, both fee components are treated as `0`. The required amount, denominated in the smallest unit of `collateral`, is: ``` required_fee = registration_fee_base + registration_fee_per_keyset * num_keysets ``` -where `num_keysets` is the number of conditional keysets this registration creates (after step 3 above). When `registration_fee_base` and `registration_fee_per_keyset` are both `0`, registration is free and the `fee` field MAY be omitted. +where `registration_fee_base` and `registration_fee_per_keyset` are taken from the selected fee setting, or from the default zero-fee setting when no matching setting is advertised, and `num_keysets` is the number of conditional keysets this registration creates (after step 3 above). When `registration_fee_base` and `registration_fee_per_keyset` are both `0`, registration is free and the `fee` field MAY be omitted. The registration fee is mint-authoritative: the mint computes `num_keysets` after canonicalization, validation, default policy expansion, duplicate removal, and full-set exclusion. Clients MUST NOT rely on a locally computed keyset count as the payment authority. @@ -477,7 +477,7 @@ If the oracle does not attest within expected time, the mint MAY refund conditio ## Mint Info Setting -The [NUT-06][06] `MintMethodSetting` indicates support for this feature: +The [NUT-06][06] setting indicates support for this feature: ```json { @@ -486,8 +486,13 @@ The [NUT-06][06] `MintMethodSetting` indicates support for this feature: "dlc_version": , "vesting_period": , "default_keyset_creation": , - "registration_fee_base": , - "registration_fee_per_keyset": + "registration_fees": [ + { + "unit": , + "registration_fee_base": , + "registration_fee_per_keyset": + } + ] } } ``` @@ -500,8 +505,10 @@ The [NUT-06][06] `MintMethodSetting` indicates support for this feature: - `"one-vs-rest"`: omitted `outcome_collections` creates each atomic outcome and its complement, deduplicated for binary conditions; clients MUST NOT request explicit collections. - `"all"`: omitted `outcome_collections` creates every non-empty, non-full collection subject to mint limits; clients MUST NOT request explicit collections. - Numeric conditions always create `HI` and `LO` keysets at registration, regardless of this policy. -- `registration_fee_base` (optional): Flat anti-spam fee charged per new condition, in the smallest unit of the condition's `collateral`. Default: `0` (free). See [Registration Fee](#registration-fee). -- `registration_fee_per_keyset` (optional): Additional fee charged per conditional keyset the registration creates, in the smallest unit of the condition's `collateral`. Default: `0`. The total required fee is `registration_fee_base + registration_fee_per_keyset * num_keysets`. +- `registration_fees` (optional): Array of per-collateral-unit registration fee settings. Missing array or missing entry for a `collateral` unit means registration for that unit is free. Mints MUST NOT advertise more than one entry for the same `unit`; clients SHOULD treat duplicate entries as invalid mint configuration. +- `registration_fees[].unit`: Collateral unit this fee schedule applies to. +- `registration_fees[].registration_fee_base` (optional): Flat anti-spam fee charged per new condition, in the smallest unit of `unit`. Default: `0` (free). See [Registration Fee](#registration-fee). +- `registration_fees[].registration_fee_per_keyset` (optional): Additional fee charged per conditional keyset the registration creates, in the smallest unit of `unit`. Default: `0`. The total required fee is `registration_fee_base + registration_fee_per_keyset * num_keysets`. [00]: 00.md [01]: 01.md diff --git a/tests/CTF-tests.md b/tests/CTF-tests.md index f3249516..0c15b156 100644 --- a/tests/CTF-tests.md +++ b/tests/CTF-tests.md @@ -231,9 +231,120 @@ verification: FAIL error_code: 13027 # Oracle threshold not met ``` +## Registration Fee Tests + +### Test 12: Per-unit registration fee calculation + +```shell +# Mint info advertises different fee schedules per collateral unit +mint_info_ctf: { + "supported": true, + "dlc_version": "0", + "default_keyset_creation": "one-vs-rest", + "registration_fees": [ + { + "unit": "sat", + "registration_fee_base": 10, + "registration_fee_per_keyset": 2 + }, + { + "unit": "usd", + "registration_fee_base": 50, + "registration_fee_per_keyset": 5 + } + ] +} + +# Binary condition with collateral "sat" creates YES and NO keysets +collateral: "sat" +num_keysets: 2 + +# Required fee uses the matching "sat" schedule, not the "usd" schedule +required_fee: 10 + 2 * 2 = 14 + +register_request: { + "threshold": 1, + "tags": [["description", "Will BTC reach $100k?"]], + "announcements": [""], + "collateral": "sat", + "fee": [ + {"amount": 16, "id": "009a1f293253e41e", "secret": "fee_secret", "C": "02..."} + ], + "outputs": [ + {"amount": 1, "id": "009a1f293253e41e", "B_": "03..."} + ] +} + +# Mint retains exactly 14 sat and returns 2 sat as regular ecash change +change_amount: 2 +result: PASS +``` + +### Test 13: Missing unit fee schedule means free registration + +```shell +# Mint info has no entry for "eurc" +mint_info_ctf: { + "supported": true, + "dlc_version": "0", + "default_keyset_creation": "one-vs-rest", + "registration_fees": [ + { + "unit": "sat", + "registration_fee_base": 10, + "registration_fee_per_keyset": 2 + } + ] +} + +register_request: { + "threshold": 1, + "tags": [["description", "EURC market"]], + "announcements": [""], + "collateral": "eurc" +} + +# No "eurc" fee schedule is advertised, so both fee components default to 0 +required_fee: 0 +fee_proofs_spent: 0 +result: PASS +``` + +### Test 14: Registration fee proofs must match collateral unit + +```shell +# Mint info requires 14 sat for this binary condition +mint_info_ctf: { + "supported": true, + "dlc_version": "0", + "default_keyset_creation": "one-vs-rest", + "registration_fees": [ + { + "unit": "sat", + "registration_fee_base": 10, + "registration_fee_per_keyset": 2 + } + ] +} + +register_request: { + "threshold": 1, + "tags": [["description", "Will BTC reach $100k?"]], + "announcements": [""], + "collateral": "sat", + "fee": [ + {"amount": 50, "id": "00usdregularkeyset", "secret": "fee_secret", "C": "02..."} + ] +} + +# Fee proof keyset unit is "usd", but collateral is "sat" +error_code: 13017 +error_message: "Invalid keyset for collateral/output side" +``` + ## Error Validation Tests -### Test 12: Outcome collection not attested by oracle +### Test 15: Outcome collection not attested by oracle ```shell # Attempt to claim with outcome collection not matching attestation @@ -243,7 +354,7 @@ error_code: 13015 error_message: "Oracle has not attested to this outcome collection" ``` -### Test 13: Invalid oracle public key format +### Test 16: Invalid oracle public key format ```shell # 33-byte compressed key instead of 32-byte x-only @@ -252,7 +363,7 @@ error: Invalid oracle public key format error_code: 13010 ``` -### Test 14: Swap spanning different outcome collections +### Test 17: Swap spanning different outcome collections ```shell # Attempt NUT-03 swap with inputs from YES keyset and outputs to NO keyset @@ -272,7 +383,7 @@ error_code: 13016 error_message: "Conditional keyset swap spans different outcome collections" ``` -### Test 15: Redemption outputs must use regular keyset +### Test 18: Redemption outputs must use regular keyset ```shell # Attempt POST /v1/redeem_outcome with conditional keyset in outputs @@ -296,7 +407,7 @@ error_code: 13017 error_message: "Outputs must use a regular keyset" ``` -### Test 16: Re-register existing condition with different config +### Test 19: Re-register existing condition with different config ```shell # Condition already registered with threshold=1 From 01da44e4cea50e47dd76c17c8da4e7e31197ea54 Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Wed, 24 Jun 2026 22:29:04 +0900 Subject: [PATCH 11/27] Require advertised CTF collateral fee units Treat registration_fees entries as the allowlist for CTF collateral units and reject non-advertised units instead of defaulting them to free registration. --- CTF.md | 11 +++++++---- tests/CTF-tests.md | 9 ++++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/CTF.md b/CTF.md index 0fced841..03aa05c3 100644 --- a/CTF.md +++ b/CTF.md @@ -225,7 +225,7 @@ Registers a new condition and creates requested conditional keysets. - `threshold`: Minimum oracles required (default: 1) - `tags`: [NIP-88][NIP-88] tag array - `announcements`: Hex-encoded oracle announcement TLV bytes -- `collateral`: Unit string for root conditions (e.g., `"sat"`). REQUIRED if `outcome_collections` is present or if the mint's default keyset creation rule creates keysets. +- `collateral`: Unit string for root conditions (e.g., `"sat"`). REQUIRED if `outcome_collections` is present or if the mint's default keyset creation rule creates keysets. The unit SHOULD be advertised in `registration_fees`; mints SHOULD reject new condition registration for non-advertised collateral units (error 13048). - `outcome_collections` (optional): Canonical outcome collections to create keysets for. If omitted, the mint applies its `default_keyset_creation` policy. If the mint advertises `default_keyset_creation` as `"one-vs-rest"` or `"all"`, clients MUST omit this field and the mint MUST reject client-defined collections. - `fee` (optional): Anti-spam registration fee inputs paid as `Proof` objects. The proofs MUST be from a **regular** keyset ([NUT-02][02]) whose unit equals `collateral`; conditional-keyset proofs MUST be rejected (error 13017). REQUIRED when the mint advertises a non-zero registration fee for the requested `collateral` unit (see [Mint Info Setting](#mint-info-setting)); MAY be omitted when the advertised fee for that unit is `0`. The mint retains exactly `required_fee` as revenue and returns any excess as regular ecash change using `outputs` / `change` as described in [Registration Fee](#registration-fee). - `outputs` (optional): Blank `BlindedMessage` objects for returning excess fee input as regular ecash change, following the [NUT-08][08] blank-output model. The `amount` field in these messages is ignored by the mint and MAY be set to any valid placeholder amount by the client. Outputs MUST use a regular keyset whose unit equals `collateral`; conditional-keyset outputs MUST be rejected (error 13017). Clients SHOULD provide enough blank outputs to represent the maximum possible change from the selected `fee` proofs. @@ -266,13 +266,15 @@ The mint MUST make condition registration idempotent. Mints MAY charge a [regist ### Registration Fee -To bound condition-registration spam, a mint MAY charge a fee per new condition, advertised per collateral unit via [Mint Info Setting](#mint-info-setting). For a registration whose `collateral` is unit `u`, the mint selects the unique advertised registration-fee setting whose `unit == u`. If no setting is advertised for `u`, both fee components are treated as `0`. The required amount, denominated in the smallest unit of `collateral`, is: +To bound condition-registration spam, a mint MAY charge a fee per new condition, advertised per collateral unit via [Mint Info Setting](#mint-info-setting). The `registration_fees` array also declares which collateral units the mint supports for CTF condition registration. A mint MAY advertise fee settings for only a subset of otherwise supported currency units. CTF registration is supported only for advertised units; if no setting is advertised for the requested `collateral`, the mint SHOULD reject new condition registration (error 13048). A mint that wants free registration for a unit MUST explicitly advertise that unit with both fee components set to `0`. + +For a registration whose `collateral` is unit `u`, the mint selects the unique advertised registration-fee setting whose `unit == u`. The required amount, denominated in the smallest unit of `collateral`, is: ``` required_fee = registration_fee_base + registration_fee_per_keyset * num_keysets ``` -where `registration_fee_base` and `registration_fee_per_keyset` are taken from the selected fee setting, or from the default zero-fee setting when no matching setting is advertised, and `num_keysets` is the number of conditional keysets this registration creates (after step 3 above). When `registration_fee_base` and `registration_fee_per_keyset` are both `0`, registration is free and the `fee` field MAY be omitted. +where `registration_fee_base` and `registration_fee_per_keyset` are taken from the selected fee setting, and `num_keysets` is the number of conditional keysets this registration creates (after step 3 above). When `registration_fee_base` and `registration_fee_per_keyset` are both `0`, registration is free and the `fee` field MAY be omitted. The registration fee is mint-authoritative: the mint computes `num_keysets` after canonicalization, validation, default policy expansion, duplicate removal, and full-set exclusion. Clients MUST NOT rely on a locally computed keyset count as the payment authority. @@ -474,6 +476,7 @@ If the oracle does not attest within expected time, the mint MAY refund conditio | 13045 | Hash to curve failed | | 13046 | EC point operation failed | | 13047 | Insufficient or invalid change outputs | +| 13048 | Unsupported CTF collateral unit | ## Mint Info Setting @@ -505,7 +508,7 @@ The [NUT-06][06] setting indicates support for this feature: - `"one-vs-rest"`: omitted `outcome_collections` creates each atomic outcome and its complement, deduplicated for binary conditions; clients MUST NOT request explicit collections. - `"all"`: omitted `outcome_collections` creates every non-empty, non-full collection subject to mint limits; clients MUST NOT request explicit collections. - Numeric conditions always create `HI` and `LO` keysets at registration, regardless of this policy. -- `registration_fees` (optional): Array of per-collateral-unit registration fee settings. Missing array or missing entry for a `collateral` unit means registration for that unit is free. Mints MUST NOT advertise more than one entry for the same `unit`; clients SHOULD treat duplicate entries as invalid mint configuration. +- `registration_fees`: Array of per-collateral-unit registration fee settings. Each entry advertises support for CTF registration with that collateral unit. Mints MAY omit otherwise supported currency units from this array; clients MUST treat omitted units as unsupported for new CTF condition registration. Mints MUST NOT advertise more than one entry for the same `unit`; clients SHOULD treat duplicate entries as invalid mint configuration. - `registration_fees[].unit`: Collateral unit this fee schedule applies to. - `registration_fees[].registration_fee_base` (optional): Flat anti-spam fee charged per new condition, in the smallest unit of `unit`. Default: `0` (free). See [Registration Fee](#registration-fee). - `registration_fees[].registration_fee_per_keyset` (optional): Additional fee charged per conditional keyset the registration creates, in the smallest unit of `unit`. Default: `0`. The total required fee is `registration_fee_base + registration_fee_per_keyset * num_keysets`. diff --git a/tests/CTF-tests.md b/tests/CTF-tests.md index 0c15b156..f9b894bb 100644 --- a/tests/CTF-tests.md +++ b/tests/CTF-tests.md @@ -280,7 +280,7 @@ change_amount: 2 result: PASS ``` -### Test 13: Missing unit fee schedule means free registration +### Test 13: Missing unit fee schedule rejects registration ```shell # Mint info has no entry for "eurc" @@ -304,10 +304,9 @@ register_request: { "collateral": "eurc" } -# No "eurc" fee schedule is advertised, so both fee components default to 0 -required_fee: 0 -fee_proofs_spent: 0 -result: PASS +# No "eurc" fee schedule is advertised, so CTF registration for "eurc" is unsupported +error_code: 13048 +error_message: "Unsupported CTF collateral unit" ``` ### Test 14: Registration fee proofs must match collateral unit From 67ea55dca5013e82d8b93526c31c18a6fe84ee5f Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Sat, 1 Aug 2026 22:39:57 +0900 Subject: [PATCH 12/27] NUT-CTF-numeric: floor both payout legs, mint retains dust Closes a partition-based over-redemption gap. HI = floor(amount*hi_payout_ratio), LO = floor(amount*lo_payout_ratio), and the mint retains amount - HI - LO as dust. HI+LO <= amount always, and because floor is subaddible, splitting or merging proofs before redemption can only reduce a holder's payout, never increase it. Trades exact conservation (a full set can redeem slightly under collateral; dust is mint revenue) for partition-invariant solvency. --- CTF-numeric.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/CTF-numeric.md b/CTF-numeric.md index 908cdb9e..99c06cc3 100644 --- a/CTF-numeric.md +++ b/CTF-numeric.md @@ -32,7 +32,10 @@ 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) +- LO holder redeems: `floor(amount * lo_payout_ratio)` +- Dust: `amount - floor(amount * hi_payout_ratio) - floor(amount * lo_payout_ratio)` is retained by the mint. + +`HI + LO <= amount` always, so a single redemption never pays out more than the face value. Because `floor` is subadditive (`floor(a * r) + floor(b * r) <= floor((a + b) * r)`), splitting or consolidating proofs before redemption can only reduce a holder's payout, never increase it (see [Conservation](#conservation)). **Edge cases**: @@ -169,11 +172,11 @@ 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 +- Output: `floor(100 * 0.8)` = 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. +For a face value `amount`, the mint pays `floor(amount * hi_payout_ratio)` to HI and `floor(amount * lo_payout_ratio)` to LO, and retains the dust `amount - HI - LO` as mint revenue. Total payout is at most `amount`. This rule is partition-invariant: a holder cannot raise a payout by splitting or merging proofs (same-keyset [NUT-03][03] swaps) before redemption, because flooring each leg independently only reduces the sum. The mint MUST settle each leg with exact integer arithmetic; the dust is not condition collateral. ## Convert (Split and Merge) From a0d2335c59fa2065223926b05e9bdc38791baf33 Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Sun, 26 Jul 2026 21:01:31 +0900 Subject: [PATCH 13/27] NUT-CTF-settle: multi-party atomic settlement via /v1/ctf/convert Extension of both NUT-Exchange (#410) and NUT-CTF-split-merge (#337). Delta document specifying only the differences from both parent NUTs. P0 fixes from adversarial review (gpt-5.6-sol xhigh): - corrected H_recv and refund domain separators - explicit inherited (rules 1-7,9,11) vs replaced (rules 8,10) - request_digest binds condition_id + parent_collection_id - active-keyset cutoff inherited from split-merge - coverage requirement is multi-party-only, not inherited - removed false NUT-03 COMPLEMENTARY claim - attestation atomicity scoped to all /v1/ctf/convert commits --- CTF-settle.md | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 58 +++++++++++----------- 2 files changed, 160 insertions(+), 28 deletions(-) create mode 100644 CTF-settle.md diff --git a/CTF-settle.md b/CTF-settle.md new file mode 100644 index 00000000..3c16790a --- /dev/null +++ b/CTF-settle.md @@ -0,0 +1,130 @@ +# NUT-CTF-settle: Multi-Party Atomic Settlement + +`draft` + +`optional` + +`depends on: NUT-CTF, NUT-CTF-split-merge, NUT-02, NUT-03, NUT-06, NUT-07, NUT-09, NUT-10, NUT-11, NUT-12` + +--- + +This NUT is an extension of both [NUT-Exchange][exchange] and [NUT-CTF-split-merge][CTF-split-merge]. The key observation: [NUT-Exchange][exchange] becomes more efficient when the mint is aware of CTF-specific characteristics (per-outcome conservation, conditional keysets, attestation cutoff), and [NUT-CTF-split-merge][CTF-split-merge] gains multi-party support by reusing [NUT-Exchange][exchange]'s `PAY_TO_UNLOCK` authorisation model. This NUT combines the two on a single endpoint. + +Readers should be familiar with [NUT-Exchange][exchange] (`PAY_TO_UNLOCK`, `H_recv`, recovery, refund, the `participants` request shape, idempotency) and [NUT-CTF-split-merge][CTF-split-merge] (per-outcome conservation, coverage from keyset metadata, canonical collection encoding, fee model). This document specifies only the **differences**. + +## Request + +Same endpoint as [NUT-CTF-split-merge][CTF-split-merge]: + +```http +POST https://mint.host:3338/v1/ctf/convert +``` + +Multi-party mode is detected by the `participants` key (same request/response shape as [NUT-Exchange][exchange]): + +```json +{ + "condition_id": "", + "parent_collection_id": "", + "participants": [ + { "inputs": "", "outputs": "" }, + { "inputs": "", "outputs": "" } + ] +} +``` + +- `condition_id`: shared across all participants; 64-char hex (error 13021 if unknown). +- `parent_collection_id`: reserved for nested conditions; MUST be omitted or all-zero in v1. +- `participants`: ≥ 2 records. Every input MUST carry a `PAY_TO_UNLOCK` condition. Every proof's keyset is either the regular collateral keyset of the condition's unit, or a conditional keyset registered under this `condition_id`. + +Response: `{signatures: [...]}` — one `BlindSignature` array per participant, same as [NUT-Exchange][exchange]. + +## Differences from NUT-Exchange + +| Aspect | NUT-Exchange | This NUT | +| -------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | +| Conservation | per-class (rule 10) | per-outcome (from [CTF-split-merge]); **replaces** rule 10 | +| Keyset constraint | exactly two distinct keysets (rule 8) | collateral + conditional under one `condition_id`; **replaces** rule 8 | +| Condition tags | `offer_keyset`, `expiry`, `refund` (3 tags) | `expiry`, `refund` only (2 tags — CTF keysets have unique signing keys, so `offer_keyset` is not needed) | +| `H_recv` domain | `Cashu/PAY_TO_UNLOCK/recv` | `Cashu/ctf/convert/recv` | +| `request_digest` | `Cashu/exchange/request` over participant records only | `Cashu/ctf/convert/request` over `condition_id` + `parent_collection_id` + participant records | +| Refund digest domain | `Cashu/PAY_TO_UNLOCK/refund` | `Cashu/ctf/convert/refund` | +| Attestation cutoff | n/a | MUST reject after attestation; serialise with commit | +| Liability accounting | n/a | per-outcome `ΔL(o)` (see below) | + +**Inherited from NUT-Exchange:** rules 1–7, 9, 11; `PAY_TO_UNLOCK` condition mechanism; `H_recv` computation (entry encoding identical, domain differs); recovery; refund mechanics; idempotency; coordinator-trust properties. + +**Replaced:** rule 8 (keyset constraint) and rule 10 (conservation) — see CTF-specific validation below. + +## Differences from NUT-CTF-split-merge + +| Aspect | CTF-split-merge | This NUT | +| -------------------- | -------------------------- | ---------------------------------------------------------------------------------- | +| Participants | 1 (single-party) | ≥ 2 (multi-party) | +| Input conditions | not required | every input MUST carry `PAY_TO_UNLOCK` | +| `in(o)` / `out(o)` | single owner's bundle | summed across **all participants** | +| Keyset-active cutoff | all keysets MUST be active | **inherited** — takes precedence over NUT-Exchange's weaker "still-spendable" rule | + +Conservation rule, fee model, coverage from keyset metadata, and canonical collection encoding are inherited unchanged. + +## CTF-specific validation + +Rules 1–7, 9, 11 from [NUT-Exchange][exchange] are inherited. Rules 8 and 10 are **replaced**: + +- **Rule 8 (replaced):** Every input/output keyset MUST be active. Collateral keysets MUST be regular with unit equal to the condition's collateral unit. Conditional keysets MUST be registered under this `condition_id`. Either class is valid on either side of a convert. +- **Rule 10 (replaced):** Per-outcome conservation with `in(o)` / `out(o)` summed across all participants: `out(o) == in(o) − F` for every `o ∈ Ω` (error 13041). **Multi-party-only restriction:** at least one output MUST cover each outcome (i.e. `out(o) > 0` for every `o`). Single-party convert inherits split-merge's rule, which permits `out(o) == 0` for outcomes fully consumed by the fee. + +Additional multi-party rules: + +1. `parent_collection_id` MUST be omitted or all-zero. Advertised limits (`max_participants`, `max_inputs`, `max_outputs`, `max_request_bytes`) MUST be respected. +2. **No attestation recorded** for `condition_id` (error 13042). This check MUST serialise atomically with the convert commit — see [Attestation atomicity](#attestation-atomicity). +3. Every input carries a canonical `PAY_TO_UNLOCK` condition (2 tags). All inputs in one participant record share the same condition. Each participant's outputs hash to that `H_recv`. +4. Reject if any involved keyset has `input_fee_ppk == 0` unless admission control is in force. +5. Every `expiry` MUST be greater than the current mint clock. + +## `request_digest` + +The CTF digest commits to **all** semantic top-level fields, not just participant records: + +``` +req_canonical = condition_id || parent_collection_id_canonical || participant[0]_canonical || ... || participant[n-1]_canonical +request_digest = tagged_hash("Cashu/ctf/convert/request", req_canonical) +``` + +where `parent_collection_id_canonical` is the all-zero 32-byte hex if the field is omitted. This prevents idempotent-response replay across requests with different `condition_id` or `parent_collection_id`. + +## Attestation atomicity + +_[Not in NUT-Exchange or CTF-split-merge single-party.]_ This applies to **every** `/v1/ctf/convert` commit (single-party and multi-party). The attestation rejection MUST serialise with both the [NUT-CTF][CTF] attestation write and the convert commit. The mint MUST take the same row-level lock on the condition record as the attestation path, or use a database constraint that makes the race impossible. Without this, a settlement could commit after the oracle has resolved the condition, allowing conversion of conditional tokens whose outcome is already determined. + +## Liability accounting + +_[Not in NUT-Exchange.]_ The atomic commit updates per-outcome conditional liability counters: `ΔL(o) += out_conditional(o) − in_conditional(o)` for each `o ∈ Ω`. The reserve `R` (total collateral of the condition's unit held by the mint) and liability `L(o)` are defined and initialised by [NUT-CTF-split-merge][CTF-split-merge]'s Issuance Invariant and updated by every issuance, conversion, redemption, and refund path. Let `ΔR = collateral_in − collateral_out`. Per-outcome conservation rearranges to `ΔL(o) = ΔR − F` for every `o` — the same constant `F` on every outcome (non-contingent). Since `R ≥ L(o)` at inception and `ΔR − ΔL(o) = F ≥ 0`, convert preserves `R ≥ L(o)` for all `o`. + +## Polymarket match types + +A single convert may mix `MINT` (buy + buy on complementary outcomes), `MERGE` (sell + sell), and `COMPLEMENTARY` (buy + sell same token) matches, provided per-outcome conservation holds. All three require atomic settlement via `/v1/ctf/convert`; [NUT-03][03] cannot mix regular and conditional keysets ([NUT-CTF][CTF]). + +## Mint info + +Same [NUT-06][06] setting as [NUT-CTF-split-merge][CTF-split-merge], extended with `max_participants` (≥ 2) and `max_expiry_seconds` (same semantics as [NUT-Exchange][exchange]: bounds `PAY_TO_UNLOCK` condition lifetime at [NUT-03][03] swap time). + +## FAQ + +**Why a separate NUT?** +Single-party convert ([CTF-split-merge]) does not need `PAY_TO_UNLOCK` or any [NUT-Exchange][exchange] concept. This NUT adds the multi-party layer for readers who already know both. + +**Does the mint enforce fair pricing?** +No. Per-outcome conservation is the only structural constraint. Per-participant pricing is set off-mint. + +## References + +- [NUT-CTF][CTF] · [NUT-CTF-split-merge][CTF-split-merge] · [NUT-Exchange][exchange] +- [Polymarket CTF Exchange](https://github.com/Polymarket/ctf-exchange-v2) + +[02]: 02.md +[03]: 03.md +[06]: 06.md +[CTF]: CTF.md +[CTF-split-merge]: CTF-split-merge.md +[exchange]: https://github.com/cashubtc/nuts/pull/410 diff --git a/README.md b/README.md index cbcfb87e..1968a971 100644 --- a/README.md +++ b/README.md @@ -20,34 +20,35 @@ Wallets and mints `MUST` implement all mandatory specs and `CAN` implement optio ### Optional -| # | Description | Wallets | Mints | -| ---------------------------------- | --------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------ | -| [07][07] | Token state check | [Nutshell][py], [Nutstash][ns], [cashu-ts][ts], [cdk], [Minibits], [macadamia] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [08][08] | Overpaid Lightning fees | [Nutshell][py], [Nutstash][ns], [cashu-ts][ts], [cdk], [Minibits], [macadamia] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [09][09] | Signature restore | [Nutshell][py], [cdk], [Cashu.me][cashume], [Minibits], [macadamia] | [Nutshell][py], [cdk-mintd] | -| [10][10] | Spending conditions | [Nutshell][py], [cdk], [cashu-ts][ts], [Minibits] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [11][11] | Pay-To-Pubkey (P2PK) | [Nutshell][py], [cdk], [Cashu.me][cashume], [Minibits] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [12][12] | DLEQ proofs | [Nutshell][py], [cdk], [cashu-ts][ts] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [13][13] | Deterministic secrets | [Nutshell][py], [cashu-ts][ts], [cdk], [macadamia], [Minibits] | - | -| [14][14] | Hashed Timelock Contracts (HTLCs) | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [15][15] | Partial multi-path payments (MPP) | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [16][16] | Animated QR codes | [Cashu.me][cashume], [macadamia], [Minibits] | - | -| [17][17] | WebSocket subscriptions | [Nutshell][py], [cdk], [Cashu.me][cashume], [Minibits] | [Nutshell][py], [cdk-mintd][cdk-mintd], [nutmix] | -| [18][18] | Payment requests | [Cashu.me][cashume], [Boardwalk][bwc], [cdk], [Minibits] | - | -| [19][19] | Cached Responses | - | [Nutshell][py], [cdk-mintd] | -| [20][20] | Signature on Mint Quote | [cdk], [Nutshell][py] | [cdk-mintd], [Nutshell][py] | -| [21][21] | Clear authentication | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [22][22] | Blind authentication | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [23][23] | Payment Method: BOLT11 | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | -| [24][24] | HTTP 402 Payment Required | - | - | -| [25][25] | Payment Method: BOLT12 | [cdk], [cashu-ts][ts] | [cdk-mintd] | -| [26][26] | Payment Request Bech32m Encoding | [cdk], [cashu-ts][ts] | - | -| [27][27] | Nostr Mint Backup | [Cashu.me][cashume], [cdk] | - | -| [28][28] | Pay to Blinded Key (P2BK) | [cdk], [cashu-ts][ts] | - | -| [29][29] | Batched Mint | - | - | -| [CTF][CTF] | Conditional Token Framework | - | - | -| [CTF-split-merge][CTF-split-merge] | Conditional Token Convert (Split, Merge, Recombine) | - | - | -| [CTF-numeric][CTF-numeric] | Numeric Outcome Conditions | - | - | +| # | Description | Wallets | Mints | +| ---------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------ | +| [07][07] | Token state check | [Nutshell][py], [Nutstash][ns], [cashu-ts][ts], [cdk], [Minibits], [macadamia] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [08][08] | Overpaid Lightning fees | [Nutshell][py], [Nutstash][ns], [cashu-ts][ts], [cdk], [Minibits], [macadamia] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [09][09] | Signature restore | [Nutshell][py], [cdk], [Cashu.me][cashume], [Minibits], [macadamia] | [Nutshell][py], [cdk-mintd] | +| [10][10] | Spending conditions | [Nutshell][py], [cdk], [cashu-ts][ts], [Minibits] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [11][11] | Pay-To-Pubkey (P2PK) | [Nutshell][py], [cdk], [Cashu.me][cashume], [Minibits] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [12][12] | DLEQ proofs | [Nutshell][py], [cdk], [cashu-ts][ts] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [13][13] | Deterministic secrets | [Nutshell][py], [cashu-ts][ts], [cdk], [macadamia], [Minibits] | - | +| [14][14] | Hashed Timelock Contracts (HTLCs) | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [15][15] | Partial multi-path payments (MPP) | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [16][16] | Animated QR codes | [Cashu.me][cashume], [macadamia], [Minibits] | - | +| [17][17] | WebSocket subscriptions | [Nutshell][py], [cdk], [Cashu.me][cashume], [Minibits] | [Nutshell][py], [cdk-mintd][cdk-mintd], [nutmix] | +| [18][18] | Payment requests | [Cashu.me][cashume], [Boardwalk][bwc], [cdk], [Minibits] | - | +| [19][19] | Cached Responses | - | [Nutshell][py], [cdk-mintd] | +| [20][20] | Signature on Mint Quote | [cdk], [Nutshell][py] | [cdk-mintd], [Nutshell][py] | +| [21][21] | Clear authentication | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [22][22] | Blind authentication | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [23][23] | Payment Method: BOLT11 | [Nutshell][py], [cdk] | [Nutshell][py], [cdk-mintd], [nutmix] | +| [24][24] | HTTP 402 Payment Required | - | - | +| [25][25] | Payment Method: BOLT12 | [cdk], [cashu-ts][ts] | [cdk-mintd] | +| [26][26] | Payment Request Bech32m Encoding | [cdk], [cashu-ts][ts] | - | +| [27][27] | Nostr Mint Backup | [Cashu.me][cashume], [cdk] | - | +| [28][28] | Pay to Blinded Key (P2BK) | [cdk], [cashu-ts][ts] | - | +| [29][29] | Batched Mint | - | - | +| [CTF][CTF] | Conditional Token Framework | - | - | +| [CTF-split-merge][CTF-split-merge] | Conditional Token Convert (Split, Merge, Recombine) | - | - | +| [CTF-settle][CTF-settle] | Multi-Party Atomic Settlement | - | - | +| [CTF-numeric][CTF-numeric] | Numeric Outcome Conditions | - | - | #### Wallets @@ -108,4 +109,5 @@ Wallets and mints `MUST` implement all mandatory specs and `CAN` implement optio [29]: 29.md [CTF]: CTF.md [CTF-split-merge]: CTF-split-merge.md +[CTF-settle]: CTF-settle.md [CTF-numeric]: CTF-numeric.md From 7d1a41e38abca7402675451a23ebd139a152a097 Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Sun, 26 Jul 2026 21:42:40 +0900 Subject: [PATCH 14/27] Address review notes: rewrite vague sections, remove redundant rule - Rule 10: rewrite to explain WHY uncovered outcomes are degenerate - Rule 4: add DoS rationale for zero-fee rejection - Rule 5: remove (redundant with exchange rule 11) - request_digest: add idempotency purpose - Attestation atomicity: rewrite in plain language - Liability accounting: add colloquial explanation before formal proof --- CTF-settle.md | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/CTF-settle.md b/CTF-settle.md index 3c16790a..6a8e3348 100644 --- a/CTF-settle.md +++ b/CTF-settle.md @@ -72,19 +72,18 @@ Conservation rule, fee model, coverage from keyset metadata, and canonical colle Rules 1–7, 9, 11 from [NUT-Exchange][exchange] are inherited. Rules 8 and 10 are **replaced**: - **Rule 8 (replaced):** Every input/output keyset MUST be active. Collateral keysets MUST be regular with unit equal to the condition's collateral unit. Conditional keysets MUST be registered under this `condition_id`. Either class is valid on either side of a convert. -- **Rule 10 (replaced):** Per-outcome conservation with `in(o)` / `out(o)` summed across all participants: `out(o) == in(o) − F` for every `o ∈ Ω` (error 13041). **Multi-party-only restriction:** at least one output MUST cover each outcome (i.e. `out(o) > 0` for every `o`). Single-party convert inherits split-merge's rule, which permits `out(o) == 0` for outcomes fully consumed by the fee. +- **Rule 10 (replaced):** Per-outcome conservation with `in(o)` / `out(o)` summed across all participants: `out(o) == in(o) − F` for every `o ∈ Ω` (error 13041). Since collateral covers every outcome, an uncovered outcome (no participant receives tokens covering it) would force `out(o) = 0 = in(o) − F`, making the fee absorb all collateral — a degenerate settlement. Multi-party convert therefore requires at least one output to cover each outcome. (Single-party convert inherits split-merge's rule, which permits `out(o) == 0` when the fee legitimately consumes one outcome's value.) Additional multi-party rules: 1. `parent_collection_id` MUST be omitted or all-zero. Advertised limits (`max_participants`, `max_inputs`, `max_outputs`, `max_request_bytes`) MUST be respected. 2. **No attestation recorded** for `condition_id` (error 13042). This check MUST serialise atomically with the convert commit — see [Attestation atomicity](#attestation-atomicity). 3. Every input carries a canonical `PAY_TO_UNLOCK` condition (2 tags). All inputs in one participant record share the same condition. Each participant's outputs hash to that `H_recv`. -4. Reject if any involved keyset has `input_fee_ppk == 0` unless admission control is in force. -5. Every `expiry` MUST be greater than the current mint clock. +4. Reject if any involved keyset has `input_fee_ppk == 0` unless admission control is in force _(without `F`, anyone can submit unlimited convert requests at no cost — a free-DoS vector)_. ## `request_digest` -The CTF digest commits to **all** semantic top-level fields, not just participant records: +`request_digest` enables idempotent retries: if a client's request commits but the response is lost (timeout, connection drop), the client retries the identical request and the mint returns the cached response instead of re-processing or rejecting as double-spend. The CTF digest commits to **all** semantic top-level fields, not just participant records: ``` req_canonical = condition_id || parent_collection_id_canonical || participant[0]_canonical || ... || participant[n-1]_canonical @@ -95,11 +94,22 @@ where `parent_collection_id_canonical` is the all-zero 32-byte hex if the field ## Attestation atomicity -_[Not in NUT-Exchange or CTF-split-merge single-party.]_ This applies to **every** `/v1/ctf/convert` commit (single-party and multi-party). The attestation rejection MUST serialise with both the [NUT-CTF][CTF] attestation write and the convert commit. The mint MUST take the same row-level lock on the condition record as the attestation path, or use a database constraint that makes the race impossible. Without this, a settlement could commit after the oracle has resolved the condition, allowing conversion of conditional tokens whose outcome is already determined. +_[Not in NUT-Exchange.]_ After the oracle attests the winning outcome, the condition is resolved. If a convert commits in the same instant — between the attestation write and keyset deactivation — the mint issues new conditional tokens for an already-resolved condition. Those tokens are worthless to holders but still backed by collateral, creating phantom liabilities. + +To prevent this race, every `/v1/ctf/convert` commit (single-party and multi-party) MUST take the same row-level lock on the condition record as the [NUT-CTF][CTF] attestation write. Either the attestation records first (convert is rejected — condition resolved) or the convert commits first (attestation waits — condition still open). No race is possible. ## Liability accounting -_[Not in NUT-Exchange.]_ The atomic commit updates per-outcome conditional liability counters: `ΔL(o) += out_conditional(o) − in_conditional(o)` for each `o ∈ Ω`. The reserve `R` (total collateral of the condition's unit held by the mint) and liability `L(o)` are defined and initialised by [NUT-CTF-split-merge][CTF-split-merge]'s Issuance Invariant and updated by every issuance, conversion, redemption, and refund path. Let `ΔR = collateral_in − collateral_out`. Per-outcome conservation rearranges to `ΔL(o) = ΔR − F` for every `o` — the same constant `F` on every outcome (non-contingent). Since `R ≥ L(o)` at inception and `ΔR − ΔL(o) = F ≥ 0`, convert preserves `R ≥ L(o)` for all `o`. +_[Not in NUT-Exchange.]_ + +**In plain terms:** the mint doesn't gamble. It takes in collateral and hands out conditional tokens, but the books always balance — for any outcome the oracle might pick, the mint holds enough collateral to redeem every outstanding token. The fee it keeps is the same regardless of which outcome wins. + +**Formally:** the reserve `R` (total collateral held) and per-outcome liability `L(o)` are defined by [NUT-CTF-split-merge][CTF-split-merge]'s Issuance Invariant. Every convert preserves `R ≥ L(o)` because: + +- `ΔR = collateral_in − collateral_out` (net collateral locked by this convert). +- `ΔL(o) = out_conditional(o) − in_conditional(o)` (change in conditional liability on outcome `o`). +- Per-outcome conservation rearranges to `ΔL(o) = ΔR − F` for every `o`. +- Since `ΔR − ΔL(o) = F ≥ 0`, the solvency margin `R − L(o)` grows by `F` on every outcome. ## Polymarket match types From 40143b1f4440b20752b6367d873dcd10b4e7e55b Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Sun, 26 Jul 2026 22:07:38 +0900 Subject: [PATCH 15/27] Mark request_digest as optional in CTF-settle --- CTF-settle.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CTF-settle.md b/CTF-settle.md index 6a8e3348..fc0ded1d 100644 --- a/CTF-settle.md +++ b/CTF-settle.md @@ -81,16 +81,18 @@ Additional multi-party rules: 3. Every input carries a canonical `PAY_TO_UNLOCK` condition (2 tags). All inputs in one participant record share the same condition. Each participant's outputs hash to that `H_recv`. 4. Reject if any involved keyset has `input_fee_ppk == 0` unless admission control is in force _(without `F`, anyone can submit unlimited convert requests at no cost — a free-DoS vector)_. -## `request_digest` +## `request_digest` (optional) -`request_digest` enables idempotent retries: if a client's request commits but the response is lost (timeout, connection drop), the client retries the identical request and the mint returns the cached response instead of re-processing or rejecting as double-spend. The CTF digest commits to **all** semantic top-level fields, not just participant records: +Optional feature inherited from [NUT-Exchange][exchange] (advertised via `idempotent_retries` in [NUT-06][06] info). Enables idempotent retries: if a client's request commits but the response is lost, the client retries the identical request and the mint returns the cached response. Without it, clients fall back to [NUT-09][09] recovery, same as [NUT-03][03] swap. + +The CTF digest (when supported) commits to **all** semantic top-level fields: ``` req_canonical = condition_id || parent_collection_id_canonical || participant[0]_canonical || ... || participant[n-1]_canonical request_digest = tagged_hash("Cashu/ctf/convert/request", req_canonical) ``` -where `parent_collection_id_canonical` is the all-zero 32-byte hex if the field is omitted. This prevents idempotent-response replay across requests with different `condition_id` or `parent_collection_id`. +where `parent_collection_id_canonical` is the all-zero 32-byte hex if the field is omitted. ## Attestation atomicity From 5b9778ce4baf1e24cac071fe86102242e1d77df1 Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Sun, 26 Jul 2026 22:30:46 +0900 Subject: [PATCH 16/27] Include exchange.md and error_codes.md from #410 These files are needed because CTF-settle depends on NUT-Exchange concepts (PAY_TO_UNLOCK, H_recv, participants request shape). --- error_codes.md | 109 +++++------ exchange.md | 480 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 527 insertions(+), 62 deletions(-) create mode 100644 exchange.md diff --git a/error_codes.md b/error_codes.md index 92e58d65..135407fc 100644 --- a/error_codes.md +++ b/error_codes.md @@ -1,64 +1,51 @@ # NUT Errors -| 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 | Invalid keyset for collateral/output side | [NUT-CTF][CTF], [NUT-CTF-split-merge][CTF-split-merge] | -| 13020 | Invalid condition ID | [NUT-CTF][CTF] | -| 13021 | Condition not found | [NUT-CTF][CTF], [NUT-CTF-split-merge][CTF-split-merge] | -| 13024 | Condition not active | [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 | Duplicate canonical outcome collection | [NUT-CTF][CTF], [NUT-CTF-split-merge][CTF-split-merge] | -| 13038 | Unknown outcome in outcome collection | [NUT-CTF][CTF], [NUT-CTF-split-merge][CTF-split-merge] | -| 13040 | Reserved | [NUT-CTF-split-merge][CTF-split-merge] | -| 13041 | Convert payoff/fee violation | [NUT-CTF-split-merge][CTF-split-merge] | -| 13042 | Convert not permitted for this condition | [NUT-CTF-split-merge][CTF-split-merge] | -| 13043 | Full-set or reserved outcome collection | [NUT-CTF][CTF], [NUT-CTF-split-merge][CTF-split-merge] | -| 13044 | Missing or insufficient registration fee | [NUT-CTF][CTF] | -| 13045 | Hash to curve failed | [NUT-CTF][CTF] | -| 13046 | EC point operation failed | [NUT-CTF][CTF] | +| Code | Description | Relevant nuts | +| ----- | -------------------------------------------------- | ------------------------------------------------------ | +| 10001 | Proof verification failed | [NUT-03][03], [NUT-05][05] | +| 11001 | Proofs already spent | [NUT-03][03], [NUT-05][05] | +| 11002 | Proofs are pending | [NUT-03][03], [NUT-05][05] | +| 11003 | Outputs already signed | [NUT-03][03], [NUT-04][04], [NUT-05][05] | +| 11004 | Outputs are pending | [NUT-03][03], [NUT-04][04], [NUT-05][05] | +| 11005 | Transaction is not balanced (inputs != outputs) | [NUT-02][02], [NUT-03][03], [NUT-05][05] | +| 11006 | Amount outside of limit range | [NUT-04][04], [NUT-05][05] | +| 11007 | Duplicate inputs provided | [NUT-03][03], [NUT-04][04], [NUT-05][05] | +| 11008 | Duplicate outputs provided | [NUT-03][03], [NUT-04][04], [NUT-05][05] | +| 11009 | Inputs/Outputs of multiple units | [NUT-03][03], [NUT-04][04], [NUT-05][05] | +| 11010 | Inputs and outputs not of same unit | [NUT-03][03], [NUT-04][04], [NUT-05][05] | +| 11011 | Amountless invoice is not supported | [NUT-05][05] | +| 11012 | Amount in request does not equal invoice | [NUT-05][05] | +| 11013 | Unit in request is not supported | [NUT-04][04], [NUT-05][05] | +| 11014 | Max inputs exceeded | [NUT-03][03], [NUT-05][05] | +| 11015 | Max outputs exceeded | [NUT-03][03], [NUT-04][04], [NUT-05][05] | +| 11016 | Duplicate quote IDs provided | [NUT-29][29] | +| 11017 | Max batch size exceeded | [NUT-29][29] | +| 12001 | Keyset is not known | [NUT-02][02], [NUT-04][04] | +| 12002 | Keyset is inactive, cannot sign messages | [NUT-02][02], [NUT-03][03], [NUT-04][04] | +| 12003 | Keyset has expired | [NUT-02][02], [NUT-03][03], [NUT-04][04], [NUT-05][05] | +| 20001 | Quote request is not paid | [NUT-04][04] | +| 20002 | Quote has already been issued | [NUT-04][04] | +| 20003 | Minting is disabled | [NUT-04][04] | +| 20004 | Lightning payment failed | [NUT-05][05] | +| 20005 | Quote is pending | [NUT-04][04], [NUT-05][05], [NUT-29][29] | +| 20006 | Invoice already paid | [NUT-05][05] | +| 20007 | Quote is expired | [NUT-04][04], [NUT-05][05] | +| 20008 | Signature for mint request invalid | [NUT-20][20] | +| 20009 | Pubkey required for mint quote | [NUT-20][20] | +| 30001 | Endpoint requires clear auth | [NUT-21][21] | +| 30002 | Clear authentication failed | [NUT-21][21] | +| 31001 | Endpoint requires blind auth | [NUT-22][22] | +| 31002 | Blind authentication failed | [NUT-22][22] | +| 31003 | Maximum BAT mint amount exceeded | [NUT-22][22] | +| 31004 | BAT mint rate limit exceeded | [NUT-22][22] | +| 15001 | Unsupported or malformed `PAY_TO_UNLOCK` condition | [NUT-Exchange][exchange] | +| 15003 | Receive-output commitment (`H_recv`) mismatch | [NUT-Exchange][exchange] | +| 15004 | Offer/receive keyset relationship violated | [NUT-Exchange][exchange] | +| 15005 | Exchange settlement submitted after `expiry` | [NUT-Exchange][exchange] | +| 15006 | Refund submitted before `expiry` | [NUT-Exchange][exchange] | +| 15007 | Refund signature missing or invalid | [NUT-Exchange][exchange] | +| 15009 | Exchange request exceeds advertised limits | [NUT-Exchange][exchange] | +| 15010 | Conflicting request or reused input (idempotency) | [NUT-Exchange][exchange] | [00]: 00.md [01]: 01.md @@ -77,6 +64,4 @@ [21]: 21.md [22]: 22.md [29]: 29.md -[CTF]: CTF.md -[CTF-split-merge]: CTF-split-merge.md -[CTF-numeric]: CTF-numeric.md +[exchange]: exchange.md diff --git a/exchange.md b/exchange.md new file mode 100644 index 00000000..83ccccb7 --- /dev/null +++ b/exchange.md @@ -0,0 +1,480 @@ +# NUT-Exchange: Atomic Multi-Asset Exchange + +`draft` (final NUT name, number, and NUT-10 `kind` are provisional) + +`optional` + +`depends on: NUT-02, NUT-03, NUT-06, NUT-07, NUT-09, NUT-10, NUT-11, NUT-12` + +--- + +This NUT defines an atomic exchange of two existing Cashu asset classes at one +mint. Two or more participants each contribute bearer proofs of one asset class +and commit to exact blinded receive outputs of the other class. The mint spends +every input and signs every output in a single database transaction, or changes +nothing. + +## Premise + +### The problem + +A client-to-client atomic swap of Cashu tokens (for example an HTLC or +adaptor-signature swap) requires both wallets to remain available while they +exchange keys, commitments, signatures, mint requests, and claims. It exposes a +free-option locktime race: the party holding the swap secret (the leader) picks +when to trigger settlement inside the locktime window — completing only if price +has moved in its favor, otherwise letting the swap lapse — a free American option +on the locked rate. And a peer-to-peer swap cannot batch several independent +swaps into one mint transaction: each must be settled separately. + +A Cashu mint already validates proof signatures, maintains spentness, and issues +blind signatures. This NUT uses that existing authority as the atomic settlement +layer, removing the interactive claim sequence and the locktime free option it +creates. + +### Model + +Each participant first locks its bearer proofs to an exact receive-output +commitment via a [NUT-10][10] `PAY_TO_UNLOCK` condition. The participants' +conditioned proofs and public receive descriptors are assembled into one +settlement request and submitted to the mint. The mint validates every condition +and conserves each asset class independently, then commits all input spends and +all output signatures in one transaction — or changes nothing. + +Two preparation patterns are supported: a **direct two-party swap** where both +participants are online, and a **coordinator-mediated swap** where a relay +assembles matched participants' material (see [Preparation](#preparation)). A +coordinator or relay is **optional** and has no on-mint authority: any holder of +all the valid authorizations may submit a request. + +### Trust boundary and anonymity + +The mint is trusted for the same things [NUT-11][11] P2PK already trusts it +(rejecting invalid spends, maintaining spentness, blind signing) **plus one +atomic database commit**. No transparency or accountability layer is defined +here. Two mitigations bound the added trust: + +1. **A violation is transcript-checkable.** Any party holding the full transcript + (inputs, conditions, output commitments, signatures, expiry) can prove the mint + accepted an exchange that violates a condition or a conservation rule. This + is incidental verifiability, not a published audit log. +2. **Protocol fields carry no stable owner identity.** Fresh per-authorization + `nonce`, refund key, proof secret, and output secret prevent the mint from + performing _identity-selective_ betrayal from protocol fields alone. The mint + still sees asset classes, amounts, timing, and transport metadata, and may + still censor, deny service, or betray an exchange wholesale — the same issuer + trust NUT-11 carries. + +### Scope + +Version 1 supports: + +- one mint; +- two or more participants in one atomic two-class exchange (any N-vs-M shape, + e.g. one taker against one or more makers, or multiple makers on both sides); +- exactly two existing asset classes, one offered per side; +- exact, owner-precommitted blinded receive outputs; +- per-asset-class conservation; and +- one atomic commit (all participants settle or none). + +Version 1 does **not** support: a coordinator as a required party; cross-mint +settlement; general N-way cycles (A wants B, B wants C, C wants A) that would +require a solver; partial fills of one authorization; a mutable remaining +balance; a venue-selected price or amount range; asset creation or destruction. + +Separate `/v1/exchange` calls are independent: a failure or retry of one never +rolls back another, since each is its own database transaction. + +## Protocol + +### Terminology + +- **Asset class**: a mint keyset identified by its [NUT-02][02] `id`. A keyset's + unit is part of the class identity. Amounts from different classes MUST NOT be + added or compared. +- **Participant**: one owner in an exchange. Each offers one asset class and + receives the other. +- **Submitter**: whoever posts the settlement request to the mint — a participant + or a relay. It is not a custody role. + +### `PAY_TO_UNLOCK` condition + +A new [NUT-10][10] well-known secret `kind` named `PAY_TO_UNLOCK`. A proof +carrying it authorizes one exact exchange. + +```json +[ + "PAY_TO_UNLOCK", + { + "nonce": "", + "data": "", + "tags": [ + ["offer_keyset", ""], + ["expiry", ""], + ["refund", ""] + ] + } +] +``` + +- `data` is `H_recv`, the commitment to this participant's complete ordered + receive-output list (see [Receive-output commitment](#receive-output-commitment)). +- `offer_keyset` binds the participant's offered asset class. The receive asset class is the common `id` of all entries in the output bundle (authenticated by `H_recv`). +- `expiry` is a unix timestamp checked against the mint clock. It is the binding + window of the commitment: settlement is valid only before it and refund only + after it (the two states are mutually exclusive). It MUST be set; a short window + (seconds to minutes) keeps a resting offer reliable without long lockup. +- `refund` is a fresh x-only public key whose private half the owner retains. A + signature under it authorizes the reclaim path, valid only after `expiry`. A + wallet MUST use a fresh refund key per authorization and MUST NOT share its + private half (see [Refund](#refund)). + +The condition answers one question: + +> May this proof be consumed by a transaction that atomically creates exactly +> these blinded outputs? + +Every proof contributed by one participant MUST carry the same `offer_keyset`, +`H_recv`, `expiry`, and `refund`. Across the exchange, the set of `offer_keyset` +values MUST equal the set of receive keysets (the common output `id` of each +participant's bundle, authenticated by `H_recv`) — i.e. every asset class offered +by some participant is received by some (other) participant, and vice versa. +Per-class amount conservation is rule 10's job, so +the count of participants on each side is unconstrained: 1-vs-N, N-vs-M, and +N-vs-N are all valid two-class shapes. In the common shape, one side offers +class `X` and receives `Y`; the other side offers `Y` and receives `X`. + +### Receive-output commitment + +The receive destination is the owner's ordered list of `BlindedMessage` values. +The canonical encoding of one entry is: + +```json +{"amount": , "id": "", "B_": ""} +``` + +The commitment is a BIP-340 tagged hash over the length-prefixed concatenation +of entries in declared order. Each `entry_canonical` is the entry serialized with +the [RFC 8785][rfc8785] JSON Canonicalization Scheme (JCS): UTF-8, object keys in +lexicographic order, minimal number serialization, no insignificant whitespace. +The length prefix is a 4-byte little-endian unsigned integer recording the +**entry count** (not byte length; output lists are not capped at 255): + +``` +recv_canonical = uint32_le(len) || entry[0]_canonical || ... || entry[n-1]_canonical +H_recv = tagged_hash("Cashu/PAY_TO_UNLOCK/recv", recv_canonical) +``` + +where `tagged_hash(tag, msg) = SHA256(SHA256(tag) || SHA256(tag) || msg)`. +Amounts are unsigned 64-bit integers; the mint MUST reject outputs whose amounts +are not representable as u64. + +Duplicate entries, unknown fields, non-canonical encodings, and list-prefix +matches MUST be rejected. A separate receive public key is not required: only the +wallet that created the blinded messages knows the secrets and blinding factors +needed to unblind the mint's signatures. + +### Canonical encodings + +- **Participant record**: the JSON object `{"inputs": [...], "outputs": [...]}`, + with `inputs` sorted by `(keyset_id, secret)` and `outputs` in declared order, + serialized via [RFC 8785][rfc8785] JCS. Each `Proof` and `BlindedMessage` uses + exactly the field set defined in [NUT-00][00]; unknown or extra fields MUST be + rejected. +- **Amounts**: encoded as JSON numbers per JCS, MUST be unsigned integers in + `[0, 2^64)`. The mint MUST use checked, non-wrapping arithmetic for all sums + in validation rule 10. +- **`PAY_TO_UNLOCK` condition**: each of the three tags (`offer_keyset`, + `expiry`, `refund`) MUST appear exactly once; unknown or duplicate tags MUST be + rejected. `expiry` is decimal unix seconds without leading zeros or fractional + part. Keyset IDs use their [NUT-02][02] canonical form; the refund key is a + BIP-340 x-only pubkey in hex. +- **Participant order**: records are ordered by the lexicographically smallest + `(keyset_id, secret)` among each participant's inputs. Proof secrets are unique + across the whole request, so this is a strict total order even when several + participants share an `offer_keyset`. +- **Request digest**: + +``` +req_canonical = participant[0]_canonical || ... || participant[n-1]_canonical +request_digest = tagged_hash("Cashu/exchange/request", req_canonical) +``` + +If the mint supports idempotent retries (advertised via `idempotent_retries` in +[NUT-06][06] info), the request digest enables fast retry: a byte-identical +request returns the cached response instead of failing on double-spend. Without +this feature, clients fall back to [NUT-09][09] recovery to retrieve lost +signatures. + +Every signature or hash in this NUT is over these canonical encodings. + +### Preparation + +A `PAY_TO_UNLOCK` proof is reusable settlement material: once minted it is +consumed by the first `/v1/exchange` request that respects its committed terms, +or reclaimed by its owner. There are two preparation patterns. + +#### Two-party direct swap + +Two participants agree on an exact exchange of asset `A` for asset `B` (e.g. an +OTC trade or a direct match). Both are online. Each participant: + +1. prepares blinded receive outputs for the agreed amount of the other class and + computes `H_recv`; +2. uses an ordinary [NUT-03][03] swap to convert its bearer proofs of its offered + class into `PAY_TO_UNLOCK` proofs committed to `H_recv`, choosing an `expiry` + and a fresh `refund` key it retains; +3. verifies the [NUT-12][12] DLEQ proofs on the returned blind signatures and + checks that the conditioned proofs encode the agreed terms; +4. one participant assembles both participants' conditioned proofs and public + receive `BlindedMessage` lists and POSTs `/v1/exchange`. + +If the request fails validation, no proof is spent; each participant reclaims its +own conditioned proofs via refund after `expiry`, then retries or walks away. + +#### Coordinator-mediated swap + +A matching engine pairs orders and a relay assembles and submits the settlement +request. Each participant prepares its conditioned proofs as in the two-party +flow above. A participant that wants offline capability may prepare **multiple +lots**, each a separate `PAY_TO_UNLOCK` authorization with its own `H_recv`, its +own inputs, and its own `expiry`; each lot becomes its own participant record in +`/v1/exchange`. The submitter includes only the lots it actually matches; unused +lots remain unspent and are reclaimed by their owner via refund after their own +`expiry`. A participant that has pre-committed sufficient lots to cover any +acceptable match may disconnect before matching; a participant that prepares +conditioned proofs only after a match is agreed must be online at match time. + +The relay assembles every matched participant's conditioned proofs and public +receive descriptors into one `/v1/exchange` request and submits it. + +In both patterns the mint does not learn the condition during blind preparation; +it first sees the plaintext condition at settlement or refund, as with other +NUT-10 conditions. + +### Settlement request + +```http +POST https://mint.host:3338/v1/exchange +``` + +```json +{ + "participants": [ + { + "inputs": "", + "outputs": "" + }, + { "...": "one record per participant; N >= 2" } + ] +} +``` + +The `participants` array contains one record per participant (`N >= 2`). Records +MUST appear in canonical participant order. + +```bash +curl -X POST https://mint.host:3338/v1/exchange \ + -H "Content-Type: application/json" \ + -d '{"participants":[...]}' +``` + +#### Mint validation + +Before any mutation, the mint MUST verify all of the following: + +1. The request has two or more participant records, each containing at least one + input and one output. Advertised limits (`max_participants`, `max_inputs`, + `max_outputs`, `max_request_bytes`) MUST be respected. +2. Every proof is authentic, unspent, unique in the request, and signed by an + active or still-spendable keyset. +3. Every proof carries a supported, canonical `PAY_TO_UNLOCK` condition (each of + the four tags appearing exactly once, no unknown tags; see [Canonical + encodings](#canonical-encodings)). +4. No input proof is reused across records and every input is unique in the + request. +5. Each participant's inputs are all of that participant's `offer_keyset`. +6. Each participant's `outputs` list hashes exactly to that participant's + `H_recv`. +7. Every output in a participant's `outputs` list shares the same `id`; that + common `id` is the participant's receive keyset (authenticated by `H_recv`). +8. Exactly two distinct keysets appear across all participants' `offer_keyset` + values and their derived receive keysets, and every participant's receive + keyset differs from its own `offer_keyset`. Per-class amount conservation + (rule 10) does not require equal participant counts per class, so any + two-class shape is valid: + 1-vs-N, N-vs-M, or N-vs-N. +9. Every blinded output is unique, valid, uses an accepted keyset, and has not + been signed before. +10. For each asset class `c` independently, summed over all participants with + checked, non-wrapping unsigned 64-bit arithmetic: + `sum(inputs_c) == sum(outputs_c) + input_fees_c`, where amounts are unsigned + 64-bit integers and + `input_fees_c = (sum(input_fee_ppk over inputs with id == c) + 999) // 1000` + per [NUT-02][02]. Fees are computed and rounded **per class**, not globally. + No additional operation fee is defined in v1. +11. The request is submitted before the minimum `expiry` across all + participants' conditions (mint clock); an expired proof is not settleable. + +**Processing order.** If the mint supports idempotent retries, it first +canonicalizes the request and computes `request_digest`. If a committed response +already exists for that digest, it is returned unchanged (idempotent retry) +without re-running the rules below. Only otherwise are rules 1–11 applied, then +the atomic commit. The mint MUST finish validation before any expensive signing +or durable mutation. + +#### Atomic commit + +The mint MUST commit these effects in one database transaction: + +1. mark every selected input proof spent; +2. sign every selected blinded output; +3. persist every `BlindedMessage` and corresponding `BlindSignature` for + [NUT-09][09] restoration; and +4. if idempotent retries are supported, persist a response keyed by + `request_digest`. + +If any validation, signing, or persistence step fails, none of these effects may +commit. If idempotent retries are supported, a byte-identical retry MUST return +the previously committed result, and an input proof that already appears in a +committed response under a conflicting `request_digest` MUST fail without +mutation. + +#### Response + +```json +{ + "signatures": [ + "", + "...", + "" + ] +} +``` + +`signatures` has one entry per participant, in canonical participant order. + +### Recovery + +Recovery is the direct [NUT-07][07]/[NUT-09][09] path, with one note: because +each **owner** retained its own receive `BlindedMessage` values, the owner — not +the submitter — recovers signatures from the mint and unblinds locally. A wallet +SHOULD retry [NUT-09][09] with bounded backoff when an input is spent but no +response was received. Wallets seeking stronger metadata privacy SHOULD use an +anonymity-preserving transport for recovery polling. + +### Refund + +A `PAY_TO_UNLOCK` proof has two mutually exclusive spend paths selected by the +mint clock: + +- **Before `expiry`**: only as an input to a valid `/v1/exchange` (rules above). +- **At or after `expiry`**: only via an ordinary [NUT-03][03] swap to fresh + outputs of the offered asset class, where each refunded input carries a + `Proof.witness` containing a single BIP-340 Schnorr signature by the `refund` + private key over + + ``` + refund_digest = tagged_hash("Cashu/PAY_TO_UNLOCK/refund", canonical_swap_request) + ``` + + where `canonical_swap_request` is the [RFC 8785][rfc8785] JCS encoding of the + NUT-03 swap request object `{inputs, outputs}` (same canonicalization rule as + the settlement request). The mint verifies that the current time is at or past + the condition's `expiry`, that the signature is valid under the condition's + `refund` public key, and that the swap issues refund outputs in an **active + keyset of the same unit** as the condition's `offer_keyset` (not necessarily + the same keyset ID, since the original keyset may have been rotated and + [NUT-02][02] forbids new outputs from inactive keysets). Otherwise the refund + is rejected. The mint MUST NOT accept a refund before `expiry`, and MUST NOT + accept an expired proof in a `/v1/exchange` settlement. + +The proof is therefore committed until `expiry` — settlement is the only valid +spend — and reclaimable by the owner afterwards — refund is the only valid spend. +The `refund` signature owner-gates the reclaim path: without it, any holder of +the bearer proof could refund it to itself. + +Liveness is preserved: a `/v1/exchange` that fails validation commits nothing +(inputs stay unspent), and the owner reclaims its conditioned proofs via refund +once `expiry` passes. A keyset's own [activation/expiry lifecycle][02] is a +separate, coarse issuer-level bound and does not replace the per-authorization +`expiry`. + +### Fees + +Input fees follow [NUT-02][02] per-keyset rules, computed and rounded **per asset +class** as defined in validation rule 10, and are included in that class's +conservation. The receiver's committed amount therefore equals the offered amount +minus that asset class's input fees. No additional operation fee is defined in v1; +a mint MAY advertise one in a later revision via an explicit condition tag. + +### Mint info + +Support MUST be advertised through [NUT-06][06]: + +```json +{ + "exchange": { + "supported": true, + "version": 1, + "max_participants": "", + "max_inputs": "", + "max_outputs": "", + "max_request_bytes": "", + "idempotent_retries": "", + "max_expiry_seconds": "" + } +} +``` + +`max_participants` bounds `N` in one atomic exchange. `max_expiry_seconds` bounds +the lifetime of a `PAY_TO_UNLOCK` condition: the mint MUST reject any +[NUT-03][03] swap that mints a `PAY_TO_UNLOCK` proof whose `expiry` exceeds the +current mint clock plus `max_expiry_seconds`. + +A wallet MUST NOT create `PAY_TO_UNLOCK` proofs unless the mint advertises this +NUT and suitable bounds. Specific error codes are defined in +[error_codes.md](error_codes.md). + +## FAQ + +**Why commit to blinded outputs rather than a receive public key?** +A `BlindedMessage` already commits to amount, receive keyset, and the +wallet-chosen blinded point `B_`. Only the wallet that knows the secret and +blinding factor can unblind the signature and build the proof. An unblinded +destination key would not give the same blind-issuance guarantee and would weaken +Cashu privacy. + +**Can a submitter or coordinator steal the funds?** +No. (1) It receives only the _blinded_ receive messages `B_`; without the +blinding factors it cannot unblind the returned signatures into spendable proofs, +so it cannot steal what is received. (2) The input proofs it relays are locked by +`PAY_TO_UNLOCK` to the owner's exact receive outputs, so it cannot redirect the +value to itself — it can only submit the one authorized exchange. (3) The only +theft vector is the refund key; the owner keeps a fresh refund key per +authorization and never shares its private half. A coordinator can therefore at +worst delay notification or refuse to submit. + +## References + +- [NUT-02](02.md) · [NUT-03](03.md) · [NUT-06](06.md) · [NUT-07](07.md) · + [NUT-09](09.md) · [NUT-10](10.md) · [NUT-11](11.md) · [NUT-12](12.md) · [NUT-21](21.md) · [NUT-22](22.md) +- [Maurice Herlihy, Atomic Cross-Chain Swaps](https://arxiv.org/abs/1801.09515) + — leader/follower topology and timelock hierarchy (the _structure_ of an + adaptor-sig/HTLC swap). +- [Mazumdar et al., Towards Faster Settlement in HTLC-based Cross-Chain + Swaps](https://arxiv.org/abs/2211.15804) — the American-call-option-without-premium + framing of the free-option locktime race this NUT removes. + +[00]: 00.md +[02]: 02.md +[03]: 03.md +[06]: 06.md +[07]: 07.md +[09]: 09.md +[10]: 10.md +[11]: 11.md +[12]: 12.md +[21]: 21.md +[22]: 22.md +[rfc8785]: https://www.rfc-editor.org/rfc/rfc8785.html From 99fbbe0f5fa1fe3355de6003963439c86c2232e5 Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Mon, 27 Jul 2026 09:17:12 +0900 Subject: [PATCH 17/27] Restore offer_keyset tag in CTF-settle PAY_TO_UNLOCK condition Dropping offer_keyset was incorrect: collateral keysets are regular NUT-02 keysets that may share verification keys. Without the tag inside the signed Proof.secret, a coordinator could relabel Proof.id to a different keyset sharing the same key. Condition is now identical to NUT-Exchange (3 tags: offer_keyset, expiry, refund). --- CTF-settle.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/CTF-settle.md b/CTF-settle.md index fc0ded1d..c3f728e1 100644 --- a/CTF-settle.md +++ b/CTF-settle.md @@ -41,16 +41,16 @@ Response: `{signatures: [...]}` — one `BlindSignature` array per participant, ## Differences from NUT-Exchange -| Aspect | NUT-Exchange | This NUT | -| -------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | -| Conservation | per-class (rule 10) | per-outcome (from [CTF-split-merge]); **replaces** rule 10 | -| Keyset constraint | exactly two distinct keysets (rule 8) | collateral + conditional under one `condition_id`; **replaces** rule 8 | -| Condition tags | `offer_keyset`, `expiry`, `refund` (3 tags) | `expiry`, `refund` only (2 tags — CTF keysets have unique signing keys, so `offer_keyset` is not needed) | -| `H_recv` domain | `Cashu/PAY_TO_UNLOCK/recv` | `Cashu/ctf/convert/recv` | -| `request_digest` | `Cashu/exchange/request` over participant records only | `Cashu/ctf/convert/request` over `condition_id` + `parent_collection_id` + participant records | -| Refund digest domain | `Cashu/PAY_TO_UNLOCK/refund` | `Cashu/ctf/convert/refund` | -| Attestation cutoff | n/a | MUST reject after attestation; serialise with commit | -| Liability accounting | n/a | per-outcome `ΔL(o)` (see below) | +| Aspect | NUT-Exchange | This NUT | +| -------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | +| Conservation | per-class (rule 10) | per-outcome (from [CTF-split-merge]); **replaces** rule 10 | +| Keyset constraint | exactly two distinct keysets (rule 8) | collateral + conditional under one `condition_id`; **replaces** rule 8 | +| Condition tags | `offer_keyset`, `expiry`, `refund` (3 tags) | identical (3 tags) | +| `H_recv` domain | `Cashu/PAY_TO_UNLOCK/recv` | `Cashu/ctf/convert/recv` | +| `request_digest` | `Cashu/exchange/request` over participant records only | `Cashu/ctf/convert/request` over `condition_id` + `parent_collection_id` + participant records | +| Refund digest domain | `Cashu/PAY_TO_UNLOCK/refund` | `Cashu/ctf/convert/refund` | +| Attestation cutoff | n/a | MUST reject after attestation; serialise with commit | +| Liability accounting | n/a | per-outcome `ΔL(o)` (see below) | **Inherited from NUT-Exchange:** rules 1–7, 9, 11; `PAY_TO_UNLOCK` condition mechanism; `H_recv` computation (entry encoding identical, domain differs); recovery; refund mechanics; idempotency; coordinator-trust properties. @@ -78,7 +78,7 @@ Additional multi-party rules: 1. `parent_collection_id` MUST be omitted or all-zero. Advertised limits (`max_participants`, `max_inputs`, `max_outputs`, `max_request_bytes`) MUST be respected. 2. **No attestation recorded** for `condition_id` (error 13042). This check MUST serialise atomically with the convert commit — see [Attestation atomicity](#attestation-atomicity). -3. Every input carries a canonical `PAY_TO_UNLOCK` condition (2 tags). All inputs in one participant record share the same condition. Each participant's outputs hash to that `H_recv`. +3. Every input carries a canonical `PAY_TO_UNLOCK` condition (3 tags: `offer_keyset`, `expiry`, `refund`). All inputs in one participant record share the same condition. Each participant's outputs hash to that `H_recv`. The `offer_keyset` MUST match each proof's actual keyset (`Proof.id`). 4. Reject if any involved keyset has `input_fee_ppk == 0` unless admission control is in force _(without `F`, anyone can submit unlimited convert requests at no cost — a free-DoS vector)_. ## `request_digest` (optional) From 419c566830bed2b100ed175b410be1d548787113 Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Mon, 27 Jul 2026 10:53:20 +0900 Subject: [PATCH 18/27] Update CTF-settle: unified refund domain, FAK limitation note, rule 12 --- CTF-settle.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/CTF-settle.md b/CTF-settle.md index c3f728e1..44135565 100644 --- a/CTF-settle.md +++ b/CTF-settle.md @@ -48,11 +48,11 @@ Response: `{signatures: [...]}` — one `BlindSignature` array per participant, | Condition tags | `offer_keyset`, `expiry`, `refund` (3 tags) | identical (3 tags) | | `H_recv` domain | `Cashu/PAY_TO_UNLOCK/recv` | `Cashu/ctf/convert/recv` | | `request_digest` | `Cashu/exchange/request` over participant records only | `Cashu/ctf/convert/request` over `condition_id` + `parent_collection_id` + participant records | -| Refund digest domain | `Cashu/PAY_TO_UNLOCK/refund` | `Cashu/ctf/convert/refund` | +| Refund digest domain | `Cashu/PAY_TO_UNLOCK/refund` | identical (unified) | | Attestation cutoff | n/a | MUST reject after attestation; serialise with commit | | Liability accounting | n/a | per-outcome `ΔL(o)` (see below) | -**Inherited from NUT-Exchange:** rules 1–7, 9, 11; `PAY_TO_UNLOCK` condition mechanism; `H_recv` computation (entry encoding identical, domain differs); recovery; refund mechanics; idempotency; coordinator-trust properties. +**Inherited from NUT-Exchange:** rules 1–7, 9, 11–12; `PAY_TO_UNLOCK` condition mechanism (3 required tags; CTF does not use `alt_outputs`, `allow_change`, or `min_output_amount` — see [FAK note](#fak-limitation) below); `H_recv` computation (entry encoding identical, domain differs); recovery; refund mechanics (including witness-free preimage and unified refund domain); idempotency; coordinator-trust properties. **Replaced:** rule 8 (keyset constraint) and rule 10 (conservation) — see CTF-specific validation below. @@ -94,6 +94,10 @@ request_digest = tagged_hash("Cashu/ctf/convert/request", req_canonical) where `parent_collection_id_canonical` is the all-zero 32-byte hex if the field is omitted. +## FAK limitation + +CTF convert does **not** support `alt_outputs`, `allow_change`, or `min_output_amount`. Change outputs break per-outcome conservation (collateral covers every outcome, so a change output inflates `out(o)` for every `o`). FAK orders in CTF convert use the **micro-lot pattern** instead: multiple small-denomination input proofs sharing the same `H_recv`, with the coordinator selecting the input subset. See [NUT-CTF-split-merge][CTF-split-merge] for conservation details. + ## Attestation atomicity _[Not in NUT-Exchange.]_ After the oracle attests the winning outcome, the condition is resolved. If a convert commits in the same instant — between the attestation write and keyset deactivation — the mint issues new conditional tokens for an already-resolved condition. Those tokens are worthless to holders but still backed by collateral, creating phantom liabilities. From abf65ca55055b05ec9d7f50037a4bd084eea036e Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Mon, 27 Jul 2026 10:53:28 +0900 Subject: [PATCH 19/27] Sync exchange.md and error_codes.md from #410 (latest) --- exchange.md | 409 ++++++++++++++++++++++++---------------------------- 1 file changed, 187 insertions(+), 222 deletions(-) diff --git a/exchange.md b/exchange.md index 83ccccb7..775a108d 100644 --- a/exchange.md +++ b/exchange.md @@ -10,8 +10,8 @@ This NUT defines an atomic exchange of two existing Cashu asset classes at one mint. Two or more participants each contribute bearer proofs of one asset class -and commit to exact blinded receive outputs of the other class. The mint spends -every input and signs every output in a single database transaction, or changes +and commit to blinded receive outputs of the other class. The mint spends every +input and signs every output in a single database transaction, or changes nothing. ## Premise @@ -34,12 +34,12 @@ creates. ### Model -Each participant first locks its bearer proofs to an exact receive-output -commitment via a [NUT-10][10] `PAY_TO_UNLOCK` condition. The participants' -conditioned proofs and public receive descriptors are assembled into one -settlement request and submitted to the mint. The mint validates every condition -and conserves each asset class independently, then commits all input spends and -all output signatures in one transaction — or changes nothing. +Each participant first locks its bearer proofs to a receive-output commitment +via a [NUT-10][10] `PAY_TO_UNLOCK` condition. The participants' conditioned +proofs and public receive descriptors are assembled into one settlement request +and submitted to the mint. The mint validates every condition and conserves +each asset class independently, then commits all input spends and all output +signatures in one transaction — or changes nothing. Two preparation patterns are supported: a **direct two-party swap** where both participants are online, and a **coordinator-mediated swap** where a relay @@ -70,17 +70,16 @@ here. Two mitigations bound the added trust: Version 1 supports: - one mint; -- two or more participants in one atomic two-class exchange (any N-vs-M shape, - e.g. one taker against one or more makers, or multiple makers on both sides); +- two or more participants in one atomic two-class exchange (any N-vs-M shape); - exactly two existing asset classes, one offered per side; -- exact, owner-precommitted blinded receive outputs; +- owner-precommitted blinded receive outputs, with optional change outputs in the + offer keyset and alternative output bundles for FAK-style orders; - per-asset-class conservation; and - one atomic commit (all participants settle or none). Version 1 does **not** support: a coordinator as a required party; cross-mint -settlement; general N-way cycles (A wants B, B wants C, C wants A) that would -require a solver; partial fills of one authorization; a mutable remaining -balance; a venue-selected price or amount range; asset creation or destruction. +settlement; general N-way cycles that would require a solver; asset creation or +destruction. Separate `/v1/exchange` calls are independent: a failure or retry of one never rolls back another, since each is its own database transaction. @@ -96,58 +95,89 @@ rolls back another, since each is its own database transaction. receives the other. - **Submitter**: whoever posts the settlement request to the mint — a participant or a relay. It is not a custody role. +- **Change output**: an output in the participant's offer keyset, returning + unspent input value. Enabled by the condition's `allow_change` tag (see below). ### `PAY_TO_UNLOCK` condition -A new [NUT-10][10] well-known secret `kind` named `PAY_TO_UNLOCK`. A proof -carrying it authorizes one exact exchange. +A [NUT-10][10] well-known secret `kind` named `PAY_TO_UNLOCK`. A proof carrying +it authorises one exact exchange — or, if `alt_outputs` is present, one exchange +chosen from a finite set of owner-authorised output bundles. ```json [ "PAY_TO_UNLOCK", { "nonce": "", - "data": "", + "data": "", "tags": [ ["offer_keyset", ""], ["expiry", ""], - ["refund", ""] + ["refund", ""], + ["alt_outputs", "", "", "..."], + ["allow_change"], + ["min_output_amount", ""] ] } ] ``` -- `data` is `H_recv`, the commitment to this participant's complete ordered - receive-output list (see [Receive-output commitment](#receive-output-commitment)). -- `offer_keyset` binds the participant's offered asset class. The receive asset class is the common `id` of all entries in the output bundle (authenticated by `H_recv`). -- `expiry` is a unix timestamp checked against the mint clock. It is the binding - window of the commitment: settlement is valid only before it and refund only - after it (the two states are mutually exclusive). It MUST be set; a short window - (seconds to minutes) keeps a resting offer reliable without long lockup. -- `refund` is a fresh x-only public key whose private half the owner retains. A - signature under it authorizes the reclaim path, valid only after `expiry`. A - wallet MUST use a fresh refund key per authorization and MUST NOT share its - private half (see [Refund](#refund)). - -The condition answers one question: - -> May this proof be consumed by a transaction that atomically creates exactly -> these blinded outputs? - -Every proof contributed by one participant MUST carry the same `offer_keyset`, -`H_recv`, `expiry`, and `refund`. Across the exchange, the set of `offer_keyset` -values MUST equal the set of receive keysets (the common output `id` of each -participant's bundle, authenticated by `H_recv`) — i.e. every asset class offered -by some participant is received by some (other) participant, and vice versa. -Per-class amount conservation is rule 10's job, so -the count of participants on each side is unconstrained: 1-vs-N, N-vs-M, and -N-vs-N are all valid two-class shapes. In the common shape, one side offers -class `X` and receives `Y`; the other side offers `Y` and receives `X`. +**Required tags** (MUST appear exactly once): + +- `offer_keyset`: binds the participant's offered asset class. The mint MUST + verify `offer_keyset == Proof.id` on every input (prevents keyset-ID relabeling + when verification keys are shared across keysets). +- `expiry`: unix timestamp. Settlement valid only before it; refund only after. +- `refund`: fresh x-only public key whose private half the owner retains. + +**Optional tags** (MAY appear; each at most once): + +- `alt_outputs`: authorises a finite set of alternative output bundles in + addition to the primary `data`. Each value is a `H_recv` computed identically + to `data`. The submitted output bundle MUST hash to `data` or any listed + alternative. Enables FAK-style orders where the actual fill amount varies (each + bundle includes a different change amount). All inputs in one participant + record MUST carry the identical `alt_outputs` set. +- `allow_change`: if present, outputs MAY include entries in the offer keyset + (change outputs) in addition to entries in the receive keyset. Without this + tag, all outputs MUST use a single keyset (the receive keyset). See [Change + outputs](#change-outputs). +- `min_output_amount`: minimum total receive-keyset output amount (excluding + change). The mint MUST reject if the actual receive output is below this floor. + Prevents a coordinator from filling a tiny amount to consume the authorization + and force a refund. + +Unknown tags MUST be rejected. `expiry` is decimal unix seconds without leading +zeros. Keyset IDs use their [NUT-02][02] canonical form; the refund key is a +BIP-340 x-only pubkey in hex. + +Every proof contributed by one participant MUST carry the same condition (same +`nonce`, `data`, tags, `expiry`, `refund`). Across the exchange, the set of +`offer_keyset` values MUST equal the set of receive keysets — i.e. every asset +class offered by some participant is received by some (other) participant, and +vice versa. + +### Change outputs + +When `allow_change` is present, a participant's output bundle MAY contain entries +in both the receive keyset and the offer keyset. The receive-keyset entries are +the participant's desired receive amount; the offer-keyset entries are change +returned from unspent input. + +The mint determines the correct change amount from per-class conservation (rule +10): `change = sum(inputs_offer) − sum(outputs_offer_to_counterparties) − fees`. +The change outputs MUST be included in the committed bundle (`H_recv` or an +`alt_outputs` entry) — the coordinator cannot insert its own change outputs +because it lacks the owner's blinding factors. + +Change outputs do NOT add a third asset class. The exchange still has exactly two +keysets: the offer keyset (now appearing on both input and output sides) and the +receive keyset. ### Receive-output commitment -The receive destination is the owner's ordered list of `BlindedMessage` values. -The canonical encoding of one entry is: +The receive destination is the owner's ordered list of `BlindedMessage` values, +including any change outputs. The canonical encoding of one entry is: ```json {"amount": , "id": "", "B_": ""} @@ -158,7 +188,7 @@ of entries in declared order. Each `entry_canonical` is the entry serialized wit the [RFC 8785][rfc8785] JSON Canonicalization Scheme (JCS): UTF-8, object keys in lexicographic order, minimal number serialization, no insignificant whitespace. The length prefix is a 4-byte little-endian unsigned integer recording the -**entry count** (not byte length; output lists are not capped at 255): +**entry count** (not byte length): ``` recv_canonical = uint32_le(len) || entry[0]_canonical || ... || entry[n-1]_canonical @@ -170,9 +200,7 @@ Amounts are unsigned 64-bit integers; the mint MUST reject outputs whose amounts are not representable as u64. Duplicate entries, unknown fields, non-canonical encodings, and list-prefix -matches MUST be rejected. A separate receive public key is not required: only the -wallet that created the blinded messages knows the secrets and blinding factors -needed to unblind the mint's signatures. +matches MUST be rejected. ### Canonical encodings @@ -182,18 +210,16 @@ needed to unblind the mint's signatures. exactly the field set defined in [NUT-00][00]; unknown or extra fields MUST be rejected. - **Amounts**: encoded as JSON numbers per JCS, MUST be unsigned integers in - `[0, 2^64)`. The mint MUST use checked, non-wrapping arithmetic for all sums - in validation rule 10. -- **`PAY_TO_UNLOCK` condition**: each of the three tags (`offer_keyset`, - `expiry`, `refund`) MUST appear exactly once; unknown or duplicate tags MUST be - rejected. `expiry` is decimal unix seconds without leading zeros or fractional - part. Keyset IDs use their [NUT-02][02] canonical form; the refund key is a - BIP-340 x-only pubkey in hex. + `[0, 2^64)`. The mint MUST use checked, non-wrapping arithmetic for all sums. +- **`PAY_TO_UNLOCK` condition**: the three required tags (`offer_keyset`, + `expiry`, `refund`) MUST each appear exactly once. Optional tags (`alt_outputs`, + `allow_change`, `min_output_amount`) MAY each appear at most once. Unknown tags + MUST be rejected. `alt_outputs` values MUST be distinct 64-char hex strings; + the mint MAY enforce `max_alt_outputs` (advertised in [NUT-06][06]). - **Participant order**: records are ordered by the lexicographically smallest `(keyset_id, secret)` among each participant's inputs. Proof secrets are unique - across the whole request, so this is a strict total order even when several - participants share an `offer_keyset`. -- **Request digest**: + across the whole request, so this is a strict total order. +- **Request digest** (optional, for idempotent retries): ``` req_canonical = participant[0]_canonical || ... || participant[n-1]_canonical @@ -203,54 +229,41 @@ request_digest = tagged_hash("Cashu/exchange/request", req_canonical) If the mint supports idempotent retries (advertised via `idempotent_retries` in [NUT-06][06] info), the request digest enables fast retry: a byte-identical request returns the cached response instead of failing on double-spend. Without -this feature, clients fall back to [NUT-09][09] recovery to retrieve lost -signatures. - -Every signature or hash in this NUT is over these canonical encodings. +this feature, clients fall back to [NUT-09][09] recovery. ### Preparation -A `PAY_TO_UNLOCK` proof is reusable settlement material: once minted it is -consumed by the first `/v1/exchange` request that respects its committed terms, -or reclaimed by its owner. There are two preparation patterns. - #### Two-party direct swap -Two participants agree on an exact exchange of asset `A` for asset `B` (e.g. an -OTC trade or a direct match). Both are online. Each participant: - -1. prepares blinded receive outputs for the agreed amount of the other class and - computes `H_recv`; -2. uses an ordinary [NUT-03][03] swap to convert its bearer proofs of its offered - class into `PAY_TO_UNLOCK` proofs committed to `H_recv`, choosing an `expiry` - and a fresh `refund` key it retains; -3. verifies the [NUT-12][12] DLEQ proofs on the returned blind signatures and - checks that the conditioned proofs encode the agreed terms; -4. one participant assembles both participants' conditioned proofs and public - receive `BlindedMessage` lists and POSTs `/v1/exchange`. - -If the request fails validation, no proof is spent; each participant reclaims its -own conditioned proofs via refund after `expiry`, then retries or walks away. - -#### Coordinator-mediated swap - -A matching engine pairs orders and a relay assembles and submits the settlement -request. Each participant prepares its conditioned proofs as in the two-party -flow above. A participant that wants offline capability may prepare **multiple -lots**, each a separate `PAY_TO_UNLOCK` authorization with its own `H_recv`, its -own inputs, and its own `expiry`; each lot becomes its own participant record in -`/v1/exchange`. The submitter includes only the lots it actually matches; unused -lots remain unspent and are reclaimed by their owner via refund after their own -`expiry`. A participant that has pre-committed sufficient lots to cover any -acceptable match may disconnect before matching; a participant that prepares -conditioned proofs only after a match is agreed must be online at match time. - -The relay assembles every matched participant's conditioned proofs and public -receive descriptors into one `/v1/exchange` request and submits it. - -In both patterns the mint does not learn the condition during blind preparation; -it first sees the plaintext condition at settlement or refund, as with other -NUT-10 conditions. +Two participants agree on an exact exchange. Both are online. Each participant: + +1. prepares blinded receive outputs and computes `H_recv`; +2. uses an ordinary [NUT-03][03] swap to convert its bearer proofs into + `PAY_TO_UNLOCK` proofs committed to `H_recv`; +3. verifies the [NUT-12][12] DLEQ proofs; +4. one participant assembles both participants' material and POSTs + `/v1/exchange`. + +#### Coordinator-mediated swap with FAK support + +A matching engine pairs orders. For exact-fill orders (FOK), each participant +prepares one `H_recv` as above. For variable-fill orders (FAK), a participant +uses `alt_outputs` + `allow_change` + `min_output_amount`: + +1. Generate the receive outputs for the **maximum** fill (e.g., 100 USD). +2. For each possible fill amount (one per price tick), generate a complete bundle: + receive outputs + change outputs in the offer keyset for the unspent portion. +3. Compute `H_recv` for each bundle. Set `data` to the max-fill bundle; list the + rest in `alt_outputs`. +4. Set `min_output_amount` to the minimum acceptable receive amount. +5. Lock the full input amount in one `PAY_TO_UNLOCK` proof (one NUT-03 swap). +6. The coordinator picks the matching bundle at match time. + +One proof, one swap, tick-level granularity. The coordinator can only select +among owner-authorised bundles; it cannot alter any bundle's contents. + +A participant that has pre-committed sufficient bundles may disconnect before +matching. Unused proofs are reclaimed via refund after `expiry`. ### Settlement request @@ -270,75 +283,53 @@ POST https://mint.host:3338/v1/exchange } ``` -The `participants` array contains one record per participant (`N >= 2`). Records -MUST appear in canonical participant order. - -```bash -curl -X POST https://mint.host:3338/v1/exchange \ - -H "Content-Type: application/json" \ - -d '{"participants":[...]}' -``` - #### Mint validation -Before any mutation, the mint MUST verify all of the following: +Before any mutation, the mint MUST verify: -1. The request has two or more participant records, each containing at least one - input and one output. Advertised limits (`max_participants`, `max_inputs`, - `max_outputs`, `max_request_bytes`) MUST be respected. +1. Two or more participant records, each with ≥1 input and ≥1 output. Advertised + limits respected. 2. Every proof is authentic, unspent, unique in the request, and signed by an active or still-spendable keyset. -3. Every proof carries a supported, canonical `PAY_TO_UNLOCK` condition (each of - the four tags appearing exactly once, no unknown tags; see [Canonical - encodings](#canonical-encodings)). -4. No input proof is reused across records and every input is unique in the - request. -5. Each participant's inputs are all of that participant's `offer_keyset`. -6. Each participant's `outputs` list hashes exactly to that participant's - `H_recv`. -7. Every output in a participant's `outputs` list shares the same `id`; that - common `id` is the participant's receive keyset (authenticated by `H_recv`). +3. Every proof carries a canonical `PAY_TO_UNLOCK` condition: the three required + tags each exactly once; optional tags at most once; no unknown tags. +4. No input proof is reused across records; every input is unique. +5. Each input's `Proof.id == offer_keyset` (prevents keyset relabeling). +6. Each participant's `outputs` list hashes to the condition's `data` **or** an + `alt_outputs` entry. (If `alt_outputs` is absent, must match `data` exactly.) +7. If `allow_change` is absent: every output `id` is the same (the receive + keyset), and that keyset differs from `offer_keyset`. If `allow_change` is + present: every output `id` is either the receive keyset or the `offer_keyset`; + at least one output MUST use the receive keyset; the receive keyset MUST differ + from `offer_keyset`. 8. Exactly two distinct keysets appear across all participants' `offer_keyset` - values and their derived receive keysets, and every participant's receive - keyset differs from its own `offer_keyset`. Per-class amount conservation - (rule 10) does not require equal participant counts per class, so any - two-class shape is valid: - 1-vs-N, N-vs-M, or N-vs-N. + values and receive keysets. Per-class conservation (rule 10) does not require + equal participant counts per class, so any two-class shape is valid: 1-vs-N, + N-vs-M, or N-vs-N. 9. Every blinded output is unique, valid, uses an accepted keyset, and has not been signed before. -10. For each asset class `c` independently, summed over all participants with - checked, non-wrapping unsigned 64-bit arithmetic: - `sum(inputs_c) == sum(outputs_c) + input_fees_c`, where amounts are unsigned - 64-bit integers and +10. For each asset class `c` independently, with checked, non-wrapping u64 + arithmetic: `sum(inputs_c) == sum(outputs_c) + input_fees_c`, where `input_fees_c = (sum(input_fee_ppk over inputs with id == c) + 999) // 1000` per [NUT-02][02]. Fees are computed and rounded **per class**, not globally. - No additional operation fee is defined in v1. -11. The request is submitted before the minimum `expiry` across all - participants' conditions (mint clock); an expired proof is not settleable. +11. The request is submitted before the minimum `expiry` across all participants' + conditions. +12. If `min_output_amount` is present: the total receive-keyset output amount for + that participant MUST be ≥ `min_output_amount`. (Change outputs in the offer + keyset are excluded from this check.) -**Processing order.** If the mint supports idempotent retries, it first -canonicalizes the request and computes `request_digest`. If a committed response -already exists for that digest, it is returned unchanged (idempotent retry) -without re-running the rules below. Only otherwise are rules 1–11 applied, then -the atomic commit. The mint MUST finish validation before any expensive signing -or durable mutation. +**Processing order.** If idempotent retries are supported, canonicalize and +compute `request_digest` first. If a committed response exists, return it. +Otherwise apply rules 1–12, then atomic commit. #### Atomic commit -The mint MUST commit these effects in one database transaction: - -1. mark every selected input proof spent; -2. sign every selected blinded output; -3. persist every `BlindedMessage` and corresponding `BlindSignature` for - [NUT-09][09] restoration; and -4. if idempotent retries are supported, persist a response keyed by - `request_digest`. +The mint MUST commit in one transaction: -If any validation, signing, or persistence step fails, none of these effects may -commit. If idempotent retries are supported, a byte-identical retry MUST return -the previously committed result, and an input proof that already appears in a -committed response under a conflicting `request_digest` MUST fail without -mutation. +1. mark every input proof spent; +2. sign every blinded output; +3. persist every `BlindedMessage` / `BlindSignature` for [NUT-09][09] restoration; +4. if idempotent retries are supported, persist response keyed by `request_digest`. #### Response @@ -352,66 +343,49 @@ mutation. } ``` -`signatures` has one entry per participant, in canonical participant order. - ### Recovery -Recovery is the direct [NUT-07][07]/[NUT-09][09] path, with one note: because -each **owner** retained its own receive `BlindedMessage` values, the owner — not -the submitter — recovers signatures from the mint and unblinds locally. A wallet -SHOULD retry [NUT-09][09] with bounded backoff when an input is spent but no -response was received. Wallets seeking stronger metadata privacy SHOULD use an -anonymity-preserving transport for recovery polling. +Recovery is the direct [NUT-07][07]/[NUT-09][09] path. Because each **owner** +retained its own receive `BlindedMessage` values, the owner — not the submitter — +recovers signatures from the mint and unblinds locally. A wallet SHOULD retry +[NUT-09][09] with bounded backoff when an input is spent but no response was +received. ### Refund -A `PAY_TO_UNLOCK` proof has two mutually exclusive spend paths selected by the -mint clock: +A `PAY_TO_UNLOCK` proof has two mutually exclusive spend paths: -- **Before `expiry`**: only as an input to a valid `/v1/exchange` (rules above). +- **Before `expiry`**: only as an input to `/v1/exchange`. - **At or after `expiry`**: only via an ordinary [NUT-03][03] swap to fresh outputs of the offered asset class, where each refunded input carries a `Proof.witness` containing a single BIP-340 Schnorr signature by the `refund` - private key over + private key over: ``` - refund_digest = tagged_hash("Cashu/PAY_TO_UNLOCK/refund", canonical_swap_request) + refund_digest = tagged_hash("Cashu/PAY_TO_UNLOCK/refund", canonical_refund_request) ``` - where `canonical_swap_request` is the [RFC 8785][rfc8785] JCS encoding of the - NUT-03 swap request object `{inputs, outputs}` (same canonicalization rule as - the settlement request). The mint verifies that the current time is at or past - the condition's `expiry`, that the signature is valid under the condition's - `refund` public key, and that the swap issues refund outputs in an **active - keyset of the same unit** as the condition's `offer_keyset` (not necessarily - the same keyset ID, since the original keyset may have been rotated and - [NUT-02][02] forbids new outputs from inactive keysets). Otherwise the refund - is rejected. The mint MUST NOT accept a refund before `expiry`, and MUST NOT - accept an expired proof in a `/v1/exchange` settlement. - -The proof is therefore committed until `expiry` — settlement is the only valid -spend — and reclaimable by the owner afterwards — refund is the only valid spend. -The `refund` signature owner-gates the reclaim path: without it, any holder of -the bearer proof could refund it to itself. + where `canonical_refund_request` is the [RFC 8785][rfc8785] JCS encoding of the + swap request object `{inputs, outputs}`, with each input `Proof` serialized + **without** its `witness` field (the witness carries the signature and cannot + be included in its own preimage). The mint verifies: current time ≥ `expiry`; + signature valid under `refund` public key; swap issues outputs in an active + keyset of the same unit as `offer_keyset`. Otherwise rejected. -Liveness is preserved: a `/v1/exchange` that fails validation commits nothing -(inputs stay unspent), and the owner reclaims its conditioned proofs via refund -once `expiry` passes. A keyset's own [activation/expiry lifecycle][02] is a -separate, coarse issuer-level bound and does not replace the per-authorization -`expiry`. +The `refund` signature owner-gates the reclaim path: without it, any holder of +the bearer proof could refund it to itself. Liveness is preserved: a failed +`/v1/exchange` commits nothing, and the owner reclaims via refund once `expiry` +passes. ### Fees Input fees follow [NUT-02][02] per-keyset rules, computed and rounded **per asset -class** as defined in validation rule 10, and are included in that class's -conservation. The receiver's committed amount therefore equals the offered amount -minus that asset class's input fees. No additional operation fee is defined in v1; -a mint MAY advertise one in a later revision via an explicit condition tag. +class** as defined in rule 10. Change outputs are in the offer keyset and are +included in that class's conservation (they reduce the fee-adjusted output to the +counterparty, not the change recipient). ### Mint info -Support MUST be advertised through [NUT-06][06]: - ```json { "exchange": { @@ -422,49 +396,42 @@ Support MUST be advertised through [NUT-06][06]: "max_outputs": "", "max_request_bytes": "", "idempotent_retries": "", + "max_alt_outputs": "", "max_expiry_seconds": "" } } ``` -`max_participants` bounds `N` in one atomic exchange. `max_expiry_seconds` bounds -the lifetime of a `PAY_TO_UNLOCK` condition: the mint MUST reject any -[NUT-03][03] swap that mints a `PAY_TO_UNLOCK` proof whose `expiry` exceeds the -current mint clock plus `max_expiry_seconds`. - -A wallet MUST NOT create `PAY_TO_UNLOCK` proofs unless the mint advertises this -NUT and suitable bounds. Specific error codes are defined in -[error_codes.md](error_codes.md). +`max_alt_outputs` bounds the number of alternative `H_recv` values per +condition. `max_expiry_seconds` bounds condition lifetime at [NUT-03][03] swap +time. ## FAQ **Why commit to blinded outputs rather than a receive public key?** A `BlindedMessage` already commits to amount, receive keyset, and the wallet-chosen blinded point `B_`. Only the wallet that knows the secret and -blinding factor can unblind the signature and build the proof. An unblinded -destination key would not give the same blind-issuance guarantee and would weaken -Cashu privacy. +blinding factor can unblind the signature and build the proof. **Can a submitter or coordinator steal the funds?** -No. (1) It receives only the _blinded_ receive messages `B_`; without the -blinding factors it cannot unblind the returned signatures into spendable proofs, -so it cannot steal what is received. (2) The input proofs it relays are locked by -`PAY_TO_UNLOCK` to the owner's exact receive outputs, so it cannot redirect the -value to itself — it can only submit the one authorized exchange. (3) The only -theft vector is the refund key; the owner keeps a fresh refund key per -authorization and never shares its private half. A coordinator can therefore at -worst delay notification or refuse to submit. +No. (1) It receives only the _blinded_ receive messages; without the blinding +factors it cannot unblind signatures. (2) Inputs are locked by `PAY_TO_UNLOCK` +to owner-authorised bundles — including change outputs, which use the owner's +blinding factors. (3) The only theft vector is the refund key; the owner keeps +a fresh one per authorization. + +**How does FAK work?** +Use `alt_outputs` + `allow_change` + `min_output_amount`. One proof authorises a +finite set of output bundles (one per price tick). The coordinator picks the +matching bundle. See [Coordinator-mediated swap](#coordinator-mediated-swap-with-fak-support). ## References - [NUT-02](02.md) · [NUT-03](03.md) · [NUT-06](06.md) · [NUT-07](07.md) · - [NUT-09](09.md) · [NUT-10](10.md) · [NUT-11](11.md) · [NUT-12](12.md) · [NUT-21](21.md) · [NUT-22](22.md) + [NUT-09](09.md) · [NUT-10](10.md) · [NUT-11](11.md) · [NUT-12](12.md) - [Maurice Herlihy, Atomic Cross-Chain Swaps](https://arxiv.org/abs/1801.09515) - — leader/follower topology and timelock hierarchy (the _structure_ of an - adaptor-sig/HTLC swap). - [Mazumdar et al., Towards Faster Settlement in HTLC-based Cross-Chain - Swaps](https://arxiv.org/abs/2211.15804) — the American-call-option-without-premium - framing of the free-option locktime race this NUT removes. + Swaps](https://arxiv.org/abs/2211.15804) [00]: 00.md [02]: 02.md @@ -475,6 +442,4 @@ worst delay notification or refuse to submit. [10]: 10.md [11]: 11.md [12]: 12.md -[21]: 21.md -[22]: 22.md [rfc8785]: https://www.rfc-editor.org/rfc/rfc8785.html From 39afa9cf9e27a39df11b71026d89ab722e4901c8 Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Mon, 27 Jul 2026 11:12:12 +0900 Subject: [PATCH 20/27] Fix P0s: per-proof nonce, decimal-string amounts, field names P0-1: amounts encoded as decimal strings in canonical form (avoids IEEE-754 precision loss above 2^53 in JCS) P0-2: unique per-proof nonce, shared meaningful fields (allows multiple proofs per record without duplicate secrets) P0-3: resolved by P0-2 (micro-lots with unique nonces, same H_recv) P1-8: change determined by aggregate conservation, not per-participant P1-11: fix field name id (was keyset_id) in sorting --- exchange.md | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/exchange.md b/exchange.md index 775a108d..9c5a94fa 100644 --- a/exchange.md +++ b/exchange.md @@ -151,11 +151,13 @@ Unknown tags MUST be rejected. `expiry` is decimal unix seconds without leading zeros. Keyset IDs use their [NUT-02][02] canonical form; the refund key is a BIP-340 x-only pubkey in hex. -Every proof contributed by one participant MUST carry the same condition (same -`nonce`, `data`, tags, `expiry`, `refund`). Across the exchange, the set of -`offer_keyset` values MUST equal the set of receive keysets — i.e. every asset -class offered by some participant is received by some (other) participant, and -vice versa. +Every proof contributed by one participant MUST carry a `PAY_TO_UNLOCK` +condition with the same `data` (`H_recv`), the same tags (`offer_keyset`, +`expiry`, `refund`, and any optional tags), but a **unique `nonce` per proof**. +The `nonce` provides per-proof anti-replay; the meaningful authorisation fields +are shared. This allows multiple proofs (e.g., micro-denomination inputs) in one +record without duplicate secrets. Across the exchange, the set of `offer_keyset` +values MUST equal the set of receive keysets. ### Change outputs @@ -164,8 +166,7 @@ in both the receive keyset and the offer keyset. The receive-keyset entries are the participant's desired receive amount; the offer-keyset entries are change returned from unspent input. -The mint determines the correct change amount from per-class conservation (rule -10): `change = sum(inputs_offer) − sum(outputs_offer_to_counterparties) − fees`. +Change is determined by per-class conservation (rule 10) at the aggregate level. The change outputs MUST be included in the committed bundle (`H_recv` or an `alt_outputs` entry) — the coordinator cannot insert its own change outputs because it lacks the owner's blinding factors. @@ -186,16 +187,18 @@ including any change outputs. The canonical encoding of one entry is: The commitment is a BIP-340 tagged hash over the length-prefixed concatenation of entries in declared order. Each `entry_canonical` is the entry serialized with the [RFC 8785][rfc8785] JSON Canonicalization Scheme (JCS): UTF-8, object keys in -lexicographic order, minimal number serialization, no insignificant whitespace. -The length prefix is a 4-byte little-endian unsigned integer recording the -**entry count** (not byte length): +lexicographic order, no insignificant whitespace. **Amounts are encoded as +decimal strings** (not JSON numbers) in the canonical form to avoid IEEE-754 +precision loss above 2^53. The length prefix is a 4-byte little-endian unsigned +integer recording the **entry count** (not byte length): ``` recv_canonical = uint32_le(len) || entry[0]_canonical || ... || entry[n-1]_canonical H_recv = tagged_hash("Cashu/PAY_TO_UNLOCK/recv", recv_canonical) ``` -where `tagged_hash(tag, msg) = SHA256(SHA256(tag) || SHA256(tag) || msg)`. +where `tagged_hash(tag, msg) = SHA256(SHA256(tag) || SHA256(tag) || msg)` and +each `entry_canonical` is `{"amount":"","id":"","B_":""}`. Amounts are unsigned 64-bit integers; the mint MUST reject outputs whose amounts are not representable as u64. @@ -217,7 +220,7 @@ matches MUST be rejected. MUST be rejected. `alt_outputs` values MUST be distinct 64-char hex strings; the mint MAY enforce `max_alt_outputs` (advertised in [NUT-06][06]). - **Participant order**: records are ordered by the lexicographically smallest - `(keyset_id, secret)` among each participant's inputs. Proof secrets are unique + `(id, secret)` among each participant's inputs. Proof secrets are unique across the whole request, so this is a strict total order. - **Request digest** (optional, for idempotent retries): From 9ea3105636662d61d663959deaf5496c8a2cd2c5 Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Mon, 27 Jul 2026 11:13:32 +0900 Subject: [PATCH 21/27] Fix P0-4: require expiry before conditional keyset final_expiry Also: clarify CTF rejects optional exchange tags (alt_outputs, allow_change, min_output_amount), per-proof nonce wording. --- CTF-settle.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CTF-settle.md b/CTF-settle.md index 44135565..15289909 100644 --- a/CTF-settle.md +++ b/CTF-settle.md @@ -69,7 +69,7 @@ Conservation rule, fee model, coverage from keyset metadata, and canonical colle ## CTF-specific validation -Rules 1–7, 9, 11 from [NUT-Exchange][exchange] are inherited. Rules 8 and 10 are **replaced**: +Rules 1–7, 9, 11–12 from [NUT-Exchange][exchange] are inherited (CTF rejects the optional `alt_outputs`, `allow_change`, and `min_output_amount` tags — see [FAK limitation](#fak-limitation)). Rules 8 and 10 are **replaced**: - **Rule 8 (replaced):** Every input/output keyset MUST be active. Collateral keysets MUST be regular with unit equal to the condition's collateral unit. Conditional keysets MUST be registered under this `condition_id`. Either class is valid on either side of a convert. - **Rule 10 (replaced):** Per-outcome conservation with `in(o)` / `out(o)` summed across all participants: `out(o) == in(o) − F` for every `o ∈ Ω` (error 13041). Since collateral covers every outcome, an uncovered outcome (no participant receives tokens covering it) would force `out(o) = 0 = in(o) − F`, making the fee absorb all collateral — a degenerate settlement. Multi-party convert therefore requires at least one output to cover each outcome. (Single-party convert inherits split-merge's rule, which permits `out(o) == 0` when the fee legitimately consumes one outcome's value.) @@ -78,8 +78,9 @@ Additional multi-party rules: 1. `parent_collection_id` MUST be omitted or all-zero. Advertised limits (`max_participants`, `max_inputs`, `max_outputs`, `max_request_bytes`) MUST be respected. 2. **No attestation recorded** for `condition_id` (error 13042). This check MUST serialise atomically with the convert commit — see [Attestation atomicity](#attestation-atomicity). -3. Every input carries a canonical `PAY_TO_UNLOCK` condition (3 tags: `offer_keyset`, `expiry`, `refund`). All inputs in one participant record share the same condition. Each participant's outputs hash to that `H_recv`. The `offer_keyset` MUST match each proof's actual keyset (`Proof.id`). +3. Every input carries a canonical `PAY_TO_UNLOCK` condition (3 required tags: `offer_keyset`, `expiry`, `refund`; no optional tags). All inputs in one participant record share the same `H_recv`, `expiry`, `refund`, and `offer_keyset`, with unique per-proof nonces. Each participant's outputs hash to that `H_recv`. The `offer_keyset` MUST match each proof's actual keyset (`Proof.id`). 4. Reject if any involved keyset has `input_fee_ppk == 0` unless admission control is in force _(without `F`, anyone can submit unlimited convert requests at no cost — a free-DoS vector)_. +5. **`expiry` MUST precede** the earliest `final_expiry` among all conditional keysets registered under this `condition_id`. Without this, a proof whose `expiry` falls after all same-outcome keysets are deactivated cannot be refunded (keyset inactive) or redeemed (no attestation path for conditioned proofs) — stranding value permanently. ## `request_digest` (optional) From 1635e659da41a34af53f49ed945142246302b53c Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Mon, 27 Jul 2026 11:25:55 +0900 Subject: [PATCH 22/27] Fix P0-1 fully: decimal-string amounts in all canonical encodings Applied to participant_canonical and refund preimage (was only H_recv). Also: field name fix (id not keyset_id), max_alt_outputs MUST enforce, min_output_amount canonical grammar, removed duplicate bullet. --- exchange.md | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/exchange.md b/exchange.md index 9c5a94fa..9a4bb67e 100644 --- a/exchange.md +++ b/exchange.md @@ -208,17 +208,18 @@ matches MUST be rejected. ### Canonical encodings - **Participant record**: the JSON object `{"inputs": [...], "outputs": [...]}`, - with `inputs` sorted by `(keyset_id, secret)` and `outputs` in declared order, - serialized via [RFC 8785][rfc8785] JCS. Each `Proof` and `BlindedMessage` uses - exactly the field set defined in [NUT-00][00]; unknown or extra fields MUST be - rejected. -- **Amounts**: encoded as JSON numbers per JCS, MUST be unsigned integers in - `[0, 2^64)`. The mint MUST use checked, non-wrapping arithmetic for all sums. + with `inputs` sorted by `(id, secret)` and `outputs` in declared order, + serialized via [RFC 8785][rfc8785] JCS. **All `amount` fields (`Proof.amount` + and `BlindedMessage.amount`) are encoded as decimal strings** (not JSON numbers) + in the canonical form, to avoid IEEE-754 precision loss above 2^53. Amounts + MUST be unsigned integers in `[0, 2^64)` with no leading zeros. The mint MUST + use checked, non-wrapping arithmetic for all sums. - **`PAY_TO_UNLOCK` condition**: the three required tags (`offer_keyset`, `expiry`, `refund`) MUST each appear exactly once. Optional tags (`alt_outputs`, `allow_change`, `min_output_amount`) MAY each appear at most once. Unknown tags MUST be rejected. `alt_outputs` values MUST be distinct 64-char hex strings; - the mint MAY enforce `max_alt_outputs` (advertised in [NUT-06][06]). + the mint MUST reject if `alt_outputs` count exceeds advertised `max_alt_outputs`. + `min_output_amount` is a minimal unsigned decimal string (no leading zeros). - **Participant order**: records are ordered by the lexicographically smallest `(id, secret)` among each participant's inputs. Proof secrets are unique across the whole request, so this is a strict total order. @@ -371,9 +372,11 @@ A `PAY_TO_UNLOCK` proof has two mutually exclusive spend paths: where `canonical_refund_request` is the [RFC 8785][rfc8785] JCS encoding of the swap request object `{inputs, outputs}`, with each input `Proof` serialized **without** its `witness` field (the witness carries the signature and cannot - be included in its own preimage). The mint verifies: current time ≥ `expiry`; - signature valid under `refund` public key; swap issues outputs in an active - keyset of the same unit as `offer_keyset`. Otherwise rejected. + be included in its own preimage). **All `amount` fields are encoded as decimal + strings** (same rule as participant canonicalization). The mint verifies: + current time ≥ `expiry`; signature valid under `refund` public key; swap issues + outputs in an active keyset of the same unit as `offer_keyset`. Otherwise + rejected. The `refund` signature owner-gates the reclaim path: without it, any holder of the bearer proof could refund it to itself. Liveness is preserved: a failed From 85a241a5208a2d2016d435b0b407b469f410982d Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Mon, 27 Jul 2026 11:27:23 +0900 Subject: [PATCH 23/27] Fix P0-3: clarify micro-lots go in one record with fee accounting Fix P0-4: handle absent final_expiry via max_expiry_seconds fallback --- CTF-settle.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CTF-settle.md b/CTF-settle.md index 15289909..bf7cc26d 100644 --- a/CTF-settle.md +++ b/CTF-settle.md @@ -80,7 +80,7 @@ Additional multi-party rules: 2. **No attestation recorded** for `condition_id` (error 13042). This check MUST serialise atomically with the convert commit — see [Attestation atomicity](#attestation-atomicity). 3. Every input carries a canonical `PAY_TO_UNLOCK` condition (3 required tags: `offer_keyset`, `expiry`, `refund`; no optional tags). All inputs in one participant record share the same `H_recv`, `expiry`, `refund`, and `offer_keyset`, with unique per-proof nonces. Each participant's outputs hash to that `H_recv`. The `offer_keyset` MUST match each proof's actual keyset (`Proof.id`). 4. Reject if any involved keyset has `input_fee_ppk == 0` unless admission control is in force _(without `F`, anyone can submit unlimited convert requests at no cost — a free-DoS vector)_. -5. **`expiry` MUST precede** the earliest `final_expiry` among all conditional keysets registered under this `condition_id`. Without this, a proof whose `expiry` falls after all same-outcome keysets are deactivated cannot be refunded (keyset inactive) or redeemed (no attestation path for conditioned proofs) — stranding value permanently. +5. **`expiry` MUST precede** the earliest `final_expiry` among all conditional keysets registered under this `condition_id`. If any keyset lacks `final_expiry`, the mint MUST use its advertised `max_expiry_seconds` as the effective bound. Without this, a proof whose `expiry` falls after all same-outcome keysets are deactivated cannot be refunded or redeemed — stranding value permanently. ## `request_digest` (optional) @@ -97,7 +97,7 @@ where `parent_collection_id_canonical` is the all-zero 32-byte hex if the field ## FAK limitation -CTF convert does **not** support `alt_outputs`, `allow_change`, or `min_output_amount`. Change outputs break per-outcome conservation (collateral covers every outcome, so a change output inflates `out(o)` for every `o`). FAK orders in CTF convert use the **micro-lot pattern** instead: multiple small-denomination input proofs sharing the same `H_recv`, with the coordinator selecting the input subset. See [NUT-CTF-split-merge][CTF-split-merge] for conservation details. +CTF convert does **not** support `alt_outputs`, `allow_change`, or `min_output_amount`. Change outputs break per-outcome conservation (collateral covers every outcome, so a change output inflates `out(o)` for every `o`). FAK orders in CTF convert use the **micro-lot pattern** instead: the owner creates multiple small-denomination input proofs — all in **one participant record** with unique per-proof nonces and the same `H_recv` — and the coordinator includes the input subset that matches the agreed price. The output bundle (e.g., 100 YES) is fixed; only the input count varies. The coordinator computes the correct subset size accounting for fees (`F` depends on total input count). All proofs MUST be in a single record (not separate records) to avoid violating output-uniqueness (rule 9). ## Attestation atomicity From f01d410ed05afd49d87ae04d7a0db33035705dab Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Mon, 27 Jul 2026 11:44:46 +0900 Subject: [PATCH 24/27] Document known limitations: coordinator over-debit and blinded expiry --- CTF-settle.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CTF-settle.md b/CTF-settle.md index bf7cc26d..64f6fb0f 100644 --- a/CTF-settle.md +++ b/CTF-settle.md @@ -80,7 +80,7 @@ Additional multi-party rules: 2. **No attestation recorded** for `condition_id` (error 13042). This check MUST serialise atomically with the convert commit — see [Attestation atomicity](#attestation-atomicity). 3. Every input carries a canonical `PAY_TO_UNLOCK` condition (3 required tags: `offer_keyset`, `expiry`, `refund`; no optional tags). All inputs in one participant record share the same `H_recv`, `expiry`, `refund`, and `offer_keyset`, with unique per-proof nonces. Each participant's outputs hash to that `H_recv`. The `offer_keyset` MUST match each proof's actual keyset (`Proof.id`). 4. Reject if any involved keyset has `input_fee_ppk == 0` unless admission control is in force _(without `F`, anyone can submit unlimited convert requests at no cost — a free-DoS vector)_. -5. **`expiry` MUST precede** the earliest `final_expiry` among all conditional keysets registered under this `condition_id`. If any keyset lacks `final_expiry`, the mint MUST use its advertised `max_expiry_seconds` as the effective bound. Without this, a proof whose `expiry` falls after all same-outcome keysets are deactivated cannot be refunded or redeemed — stranding value permanently. +5. **`expiry` MUST precede** the earliest `final_expiry` among all conditional keysets registered under this `condition_id`. If any keyset lacks `final_expiry`, the mint MUST use its advertised `max_expiry_seconds` as the effective bound. Without this, a proof whose `expiry` falls after all same-outcome keysets are deactivated cannot be refunded or redeemed — stranding value permanently. _Known limitation: `expiry` is inside the blinded `Proof.secret` ([NUT-10][10]) and invisible to the mint at [NUT-03][03] swap time; the mint enforces this check at settlement, not creation. Wallets MUST set `expiry` conservatively._ ## `request_digest` (optional) @@ -97,7 +97,7 @@ where `parent_collection_id_canonical` is the all-zero 32-byte hex if the field ## FAK limitation -CTF convert does **not** support `alt_outputs`, `allow_change`, or `min_output_amount`. Change outputs break per-outcome conservation (collateral covers every outcome, so a change output inflates `out(o)` for every `o`). FAK orders in CTF convert use the **micro-lot pattern** instead: the owner creates multiple small-denomination input proofs — all in **one participant record** with unique per-proof nonces and the same `H_recv` — and the coordinator includes the input subset that matches the agreed price. The output bundle (e.g., 100 YES) is fixed; only the input count varies. The coordinator computes the correct subset size accounting for fees (`F` depends on total input count). All proofs MUST be in a single record (not separate records) to avoid violating output-uniqueness (rule 9). +CTF convert does **not** support `alt_outputs`, `allow_change`, or `min_output_amount`. Change outputs break per-outcome conservation (collateral covers every outcome, so a change output inflates `out(o)` for every `o`). FAK orders in CTF convert use the **micro-lot pattern** instead: the owner creates multiple small-denomination input proofs — all in **one participant record** with unique per-proof nonces and the same `H_recv` — and the coordinator includes the input subset that matches the agreed price. The output bundle (e.g., 100 YES) is fixed; only the input count varies. The coordinator computes the correct subset size accounting for fees (`F` depends on total input count). All proofs MUST be in a single record (not separate records) to avoid violating output-uniqueness (rule 9). _Known limitation: each proof is individually authorized; a coordinator MAY include more proofs than agreed (over-debit up to the owner's pre-authorized maximum). This is a best-execution concern, not a solvency issue. Mitigation: create proofs only for the exact price acceptable, not the theoretical maximum._ ## Attestation atomicity From 4db45b7fa2a7bdddaf1419dfafa4f8135ad477fd Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Mon, 27 Jul 2026 21:09:01 +0900 Subject: [PATCH 25/27] Refresh exchange placeholders from latest nut-atomic-exchange Stopgap before the planned nut-atomic-exchange -> main merge + rebase of this branch. - Add exchange-partial-fill.md placeholder (latest codex-reviewed version: error codes 15011-15014, Repair B worked example, decimal-string PoolEntry canonicalization, gross-debit fee clarification, canonical pool_selection bitmap, NUT-09 discard safety, mixed pool/standard participants, pool-mode tag grammar) - Update error_codes.md placeholder to latest (adds 15011-15014 + partial-fill link) - exchange.md is byte-identical between branches (no-op); README.md and all nine CTF files untouched --- error_codes.md | 5 + exchange-partial-fill.md | 248 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 exchange-partial-fill.md diff --git a/error_codes.md b/error_codes.md index 135407fc..fd9609e4 100644 --- a/error_codes.md +++ b/error_codes.md @@ -46,6 +46,10 @@ | 15007 | Refund signature missing or invalid | [NUT-Exchange][exchange] | | 15009 | Exchange request exceeds advertised limits | [NUT-Exchange][exchange] | | 15010 | Conflicting request or reused input (idempotency) | [NUT-Exchange][exchange] | +| 15011 | Pool manifest hash (`H_manifest`) mismatch | [NUT-Exchange-partial-fill][partial-fill] | +| 15012 | Pool selection does not match `outputs` | [NUT-Exchange-partial-fill][partial-fill] | +| 15013 | Pool role/keyset or two-class consistency violated | [NUT-Exchange-partial-fill][partial-fill] | +| 15014 | Pool policy violation (rate/min/max/overflow) | [NUT-Exchange-partial-fill][partial-fill] | [00]: 00.md [01]: 01.md @@ -65,3 +69,4 @@ [22]: 22.md [29]: 29.md [exchange]: exchange.md +[partial-fill]: exchange-partial-fill.md diff --git a/exchange-partial-fill.md b/exchange-partial-fill.md new file mode 100644 index 00000000..22b97893 --- /dev/null +++ b/exchange-partial-fill.md @@ -0,0 +1,248 @@ +# NUT-Exchange-partial-fill: Partial-Fill Authorization + +`draft` + +`optional` + +`depends on: NUT-Exchange, NUT-01, NUT-02, NUT-03, NUT-06, NUT-09, NUT-10, NUT-11, NUT-12` + +--- + +This NUT extends [NUT-Exchange][exchange]'s `PAY_TO_UNLOCK` condition with a **pool-based authorization mode**: the owner locks a single input and authorizes a **range** of possible output bundles, with the actual output selection determined at match time. + +In standard mode (NUT-Exchange), `data` is `H_recv` — a hash of one exact output bundle. In pool mode (this NUT), `data` is a manifest hash over a small set of pre-generated output entries in binary denominations, plus a numeric rate policy that the mint enforces. The coordinator selects any subset satisfying the policy; the mint signs only the selected entries. + +Readers should be familiar with [NUT-Exchange][exchange] (`PAY_TO_UNLOCK`, conservation rules, recovery, refund, the `participants` request shape). + +## Condition + +Pool mode is signaled by the presence of `rate_n` and `rate_d` tags. When absent, the condition uses standard mode (exact `H_recv` match, as defined in [NUT-Exchange][exchange]). In pool mode the base optional tags `alt_outputs`, `allow_change`, and `min_output_amount` MUST be absent: `alt_outputs` is incompatible with rule 6p's exact `H_manifest`, change is authorized by the manifest's change role (rule 8p), and `min_receive` supersedes `min_output_amount` (base rule 12). + +```json +[ + "PAY_TO_UNLOCK", + { + "nonce": "", + "data": "", + "tags": [ + ["offer_keyset", ""], + ["expiry", ""], + ["refund", ""], + ["rate_n", ""], + ["rate_d", ""], + ["min_receive", ""], + ["max_debit", ""] + ] + } +] +``` + +**Required tags** (same as NUT-Exchange): `offer_keyset`, `expiry`, `refund`. + +**Pool-mode tags** (presence signals pool mode; all four required together, each appearing exactly once as a minimal unsigned decimal string with no leading zeros): + +- `rate_n` / `rate_d`: minimum receive rate in integer keyset minor units: at least `rate_n` receive-keyset units for every `rate_d` offer-keyset units debited. `rate_d` MUST be greater than zero. The mint enforces `receive_total × rate_d ≥ debit_total × rate_n` using checked `u128` cross-multiplication, with no division or rounding. Implementations MUST NOT convert either total to display units before this comparison. For a sat receive keyset and a USD offer keyset whose amount `1` is one cent, one sat per cent is encoded as `rate_n = 1, rate_d = 1`. The inequality admits every better price. +- `min_receive`: minimum total receive-keyset output amount. Prevents dust fills. MUST be positive. +- `max_debit`: maximum total debit (`input_total − change_total`). Caps spending and MUST be no greater than the participant's input total. The mint MUST reject a request whose selected `change_total` exceeds `input_total`; it MUST NOT perform a wrapping subtraction. + +Every proof contributed by one participant MUST carry the same `data` and the same tags (`offer_keyset`, `expiry`, `refund`, `rate_n`, `rate_d`, `min_receive`, `max_debit`), while each proof MUST use a unique `nonce`, following the per-proof nonce rule in [NUT-Exchange][exchange]. + +## Output pools + +The owner generates two pools of `BlindedMessage` entries: + +- **Receive pool:** entries in the receive keyset, at powers-of-2 denominations (the recommended wallet construction; see below). +- **Change pool:** entries in the offer keyset, at powers-of-2 denominations covering the possible change range. + +Each entry has a `role` (`receive` or `change`), `amount`, `id` (keyset), and `B_` (blinded point). The owner retains every entry's secret and blinding factor. + +For each participant, every `receive` entry MUST have the same `id`, called that participant's receive keyset. Every `change` entry MUST have `id` equal to the condition's `offer_keyset`. Both roles MUST be present, and the receive keyset MUST differ from `offer_keyset`; therefore each participant's complete manifest contains exactly those two keyset IDs. Across the complete exchange request, the union of every participant's `offer_keyset` and derived receive keyset MUST contain exactly two distinct keyset IDs. + +The recommended wallet construction places entries at powers-of-two denominations (`1, 2, 4, 8, ..., 2^k`), because `⌈log₂(R+1)⌉` such receive entries can represent every integer from `0` through `R` by subset sum, and likewise `⌈log₂(C+1)⌉` change entries cover `0` through `C`, keeping the pool at `O(log R + log C)` entries. Cashu does not guarantee that a keyset publishes those denominations, so a wallet MUST inspect the [NUT-01][01] key maps and MUST NOT create a pool unless each keyset is active for issuance and publishes a signing key for every amount placed in that keyset's pool. The denomination sequence is a wallet-side construction: the mint authenticates only the committed manifest and verifies per entry that `(id, amount)` identifies a published signing key in an active keyset (rule 8p); it does not require any particular denomination shape. + +## Manifest hash + +The `data` field is `H_manifest`, computed over the complete ordered `pool_manifest`. `PoolEntry` is the only name for an entry in that array and has exactly these fields: + +```json +{ + "index": "", + "role": "receive|change", + "amount": "", + "id": "", + "B_": "" +} +``` + +`amount` is an unsigned 64-bit integer in the minor unit of the entry's keyset ([NUT-01][01]); `index` is the entry's zero-based position. In the canonical encoding below, both `amount` and `index` are serialized as minimal decimal strings (no leading zeros), exactly as base NUT-Exchange serializes `amount`, to avoid IEEE-754 precision loss above 2^53 ([RFC 8785][rfc8785] §3.1). `pool_manifest` MUST contain all `receive` entries first and all `change` entries second. Each entry's `index` MUST equal its zero-based position in `pool_manifest`; indices are unique and contiguous from `0` through `len(pool_manifest) − 1`. + +Each `PoolEntry` is encoded using the [RFC 8785][rfc8785] JCS defined by [NUT-Exchange][exchange], with `amount` and `index` as minimal decimal strings (object keys in lexicographic order: `B_`, `amount`, `id`, `index`, `role`): + +``` +manifest_canonical = JCS(pool_manifest[0]) || JCS(pool_manifest[1]) || ... || JCS(pool_manifest[n-1]) +H_manifest = tagged_hash("Cashu/PAY_TO_UNLOCK/manifest", manifest_canonical) +``` + +## Request format + +Pool-mode participants include the full manifest and a selection bitmap: + +```json +{ + "participants": [ + { + "inputs": "", + "outputs": "", + "pool_manifest": "", + "pool_selection": "" + } + ] +} +``` + +- `outputs`: the selected entries only — these are the `BlindedMessage` values the mint will sign. MUST be a subset of `pool_manifest`, in manifest index order. +- `pool_manifest`: the complete ordered array of `PoolEntry` values defined in [Manifest hash](#manifest-hash). The mint computes `H_manifest` from this array and verifies it against the condition's `data` field. This authenticates every candidate entry as part of the owner-created pool. +- `pool_selection`: a hex-encoded bitmap selecting which manifest entries to sign. The bitmap is exactly `⌈len(pool_manifest) / 8⌉` bytes, encoded as lowercase hex (even-length, no `0x` prefix); a string of any other byte length MUST be rejected. Bit `i` (0-indexed from the least-significant bit of the first byte) corresponds to `pool_manifest[i]`. Bit `1` = selected (include in `outputs`), bit `0` = skipped. Unused trailing bits (bit index `≥ len(pool_manifest)`) MUST be zero. The selected entries, in index order, MUST exactly match the `outputs` array. + +`pool_selection` tells the mint which authenticated candidate outputs to sign. An entry whose bit is zero is an unsigned candidate, not ecash: the mint MUST NOT sign or return it, and signing every manifest entry would violate the per-class conservation rule. The full-manifest form reveals to the mint that all listed `B_` values belong to one authorization, but an unselected value never becomes a proof. The owner MUST NOT discard any unselected entry's secret or blinding factor, or reuse its `B_`, until it has a definitive settlement outcome for the authorization: if no settlement response was received, the owner MUST first determine whether the mint spent the inputs and, if so, recover the actually-signed signatures via [NUT-09][09] by submitting every manifest entry's `BlindedMessage` (only entries the mint did not sign may then be discarded). After successful settlement, refund, or post-expiry recovery, a later transaction requiring that denomination MUST generate a fresh secret, blinding factor, and `B_`. + +Example: a five-entry manifest has receive entries at manifest indices 0, 1, and 2 and change entries at manifest indices 3 and 4. Selecting manifest entries 0, 2, and 4 produces the bitmap `0b00010101` = `0x15`; therefore `pool_selection = "15"`, and `outputs` contains `pool_manifest[0]`, `pool_manifest[2]`, and `pool_manifest[4]`, in that order. + +A single `/v1/exchange` request MAY mix pool-mode and standard-mode participants. Standard-mode participants omit `pool_manifest` and `pool_selection` and are validated under base [NUT-Exchange][exchange] rules; rule 8p derives each participant's receive keyset per mode — from the manifest for pool mode, from the outputs for standard mode. + +Version 1 uses the full manifest. Pool size is logarithmic in the representable receive and change ranges, and every request remains subject to both `max_pool_entries` per participant and NUT-Exchange's `max_request_bytes` for the complete request. The mint MUST reject a request exceeding either limit, and a wallet MUST NOT create a pool-mode authorization that cannot fit both advertised limits. For idempotent retries, a pool-mode participant's canonical record (and thus `request_digest`) includes `pool_manifest` and `pool_selection` in addition to `inputs` and `outputs`, so two requests differing only in manifest or selection cannot alias to one cached response. Merkle roots and inclusion proofs are not valid version-1 request forms. A future Merkle form MUST use a separately advertised version or mode that defines the leaf encoding, global index binding, tree construction and domain separation, proof encoding, and proof-size limits. + +Response: same as NUT-Exchange — `{signatures: [...]}`, one `BlindSignature` array per participant. Pool-mode participants receive signatures for their selected entries only. + +## Mint validation + +Pool-mode participants require additional validation beyond [NUT-Exchange][exchange] rules 1–5, 9, 11. Rule 3's tag grammar is extended for pool mode: the four pool tags are required and the base optional tags `alt_outputs`, `allow_change`, and `min_output_amount` are forbidden (error 15001); rule 12 (`min_output_amount`) is superseded by rule 9p's `min_receive`: + +6p. **Manifest hash:** hash `pool_manifest` canonically → MUST equal condition `data`. Otherwise reject (error 15011). + +7p. **Selection consistency:** the entries indicated by `pool_selection` MUST exactly match the `outputs` array (same `B_`, `amount`, `id` values in the same order), and `pool_selection` MUST be the canonical bitmap encoding from [Request format](#request-format). Otherwise reject (error 15012). + +8p. **Role/keyset and two-class consistency:** validate the entire `pool_manifest`, not only selected entries. For each pool-mode participant, all `receive` entries MUST share one `id`; all `change` entries MUST use the condition's `offer_keyset`; both roles MUST be present; and the derived receive keyset MUST differ from `offer_keyset`. No other keyset ID may occur in that manifest. Each participant's receive keyset is derived per mode — from the manifest for pool-mode participants, from the outputs under base rule 7 for standard-mode participants. Across all participants, exactly two distinct keyset IDs MUST occur among all conditions' `offer_keyset` values and all derived receive keysets. For every manifest entry, the mint MUST verify that `id` is a known, active keyset (else errors 12001/12002) and that the keyset publishes a signing key for `amount`. Violations of the role, per-mode, or two-class rules are rejected with error 15013. + +9p. **Policy:** parse `rate_n`, `rate_d`, `min_receive`, and `max_debit` as unsigned `u128` values. Reject if parsing fails, if `rate_d = 0`, or if `min_receive = 0`. Using checked `u128` addition, compute `input_total` from the participant's inputs, `receive_total` from selected receive entries, and `change_total` from selected change entries. Reject if any conversion or sum overflows, if `max_debit > input_total`, or if `change_total > input_total`. Only after those checks, compute `debit_total = input_total − change_total` with checked subtraction. Compute both rate products with checked multiplication and reject if either product overflows. Then enforce (reject with error 15014 on any failure): + +- `receive_total × rate_d ≥ debit_total × rate_n` (rate covenant) +- `receive_total ≥ min_receive` (minimum fill, which also requires at least one selected `receive` entry) +- `debit_total ≤ max_debit` (spending cap) + +`debit_total` is gross of fees. Rule 10p burns `input_fees_c` from each offer class `c`, so the value actually delivered to the opposing side is `debit_total − input_fees_offer` in a two-party exchange and, in general, `Σ(debit_total over participants offering c) − input_fees_c` per class `c`. The rate covenant and spending cap use the gross `debit_total`; the worked examples below assume zero input fees. + +10p. **Conservation:** standard per-class conservation from [NUT-Exchange][exchange] rule 10, applied with change entries in the offer keyset (rejection uses error 11005). The mint signs only `outputs`; `pool_manifest` entries are authentication material, not outputs to sign. + +NUT-Exchange rule 7's per-participant single-keyset output constraint is replaced by rule 8p for pool-mode participants, because selected pool outputs may use both that participant's receive keyset and offer keyset for change; standard-mode participants still follow base rule 7. NUT-Exchange rule 8's exactly-two-keysets invariant is not relaxed; rule 8p applies it to the receive keysets derived per mode (manifest or outputs) and to the request as a whole. + +## Example + +Alice wants to swap **10000 cents (100 USD) for 1000 sats** at a limit of 10 cents per sat. Here `input_total = 10000` cents is the value locked in the authorization and `max_debit = 10000` permits the full input to be spent, so a complete 1000-sat fill at Alice's boundary rate (10 cents/sat) returns 0 cents as change; any partial or better-price fill returns the unspent portion as change. + +### Preparation + +**Receive pool** (sats keyset, 10 entries): amounts 1, 2, 4, 8, 16, 32, 64, 128, 256, 512. Any subset sums to 0–1023. + +**Change pool** (USD keyset; amounts are cents, 14 entries): amounts 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192. These entries can represent every integer change amount from 0 through 16383 cents. + +**Manifest:** 24 entries. `H_manifest` is computed from the canonical `PoolEntry` encodings defined above. + +**Policy:** `rate_n = 1, rate_d = 10` (receive at least 1 sat per 10 cents debited, i.e., pay at most 10 cents per sat), `min_receive = 1` sat, `max_debit = 10000` cents. + +**Lock:** one or more `PAY_TO_UNLOCK` proofs totaling 10000 cents, all carrying the same manifest and policy tags and unique per-proof nonces. + +### Settlement — boundary fill of 500 sats + +Counterparty offers 500 sats for 5000 cents (the boundary rate of 10 cents/sat). + +Coordinator selects: + +- Receive: {256, 128, 64, 32, 16, 4} = 500 sats (6 entries) +- Change: {4096, 512, 256, 128, 8} = 5000 cents (5 entries) + +Mint checks: + +- `H_manifest` matches condition `data` ✓ +- Selected entries exactly match the bitmap and `outputs` ✓ +- `input_total = 10000`, `change_total = 5000`, so `debit_total = 10000 − 5000 = 5000` cents ✓ +- Rate boundary: `500 × 10 ≥ 5000 × 1` → `5000 ≥ 5000` ✓ +- `receive_total = 500 ≥ min_receive = 1` ✓ +- `debit_total = 5000 ≤ max_debit = 10000` ✓ +- Conservation (zero input fees): 10000 cents in = 5000 cents change + 5000 cents to the counterparty; 500 sats in = 500 sats out ✓ + +Equality is accepted: this is exactly Alice's limit of 10 cents per sat. If the counterparty instead offered 499 sats for the same 5000-cent debit, the mint would evaluate `499 × 10 ≥ 5000 × 1` → `4990 ≥ 5000`, which is false, and MUST reject the request. + +The mint signs Alice's 11 selected `BlindedMessage` values (6 receive + 5 change). Alice unblinds them to obtain 500 sats and 5000 cents of change. + +### Settlement — better-price partial fill of 237 sats + +Counterparty offers 237 sats for 2000 cents (20.00 USD). + +Coordinator selects: + +- Receive: {128, 64, 32, 8, 4, 1} = 237 sats (6 entries) +- Change: {4096, 2048, 1024, 512, 256, 64} = 8000 cents (6 entries) + +Mint checks: + +- `input_total = 10000`, `change_total = 8000`, so `debit_total = 2000` cents ✓ +- Rate: `237 × 10 ≥ 2000 × 1` → `2370 ≥ 2000` ✓ +- `receive_total = 237 ≥ min_receive = 1` ✓ +- `debit_total = 2000 ≤ max_debit = 10000` ✓ +- Conservation (zero input fees): 10000 cents in = 8000 cents change + 2000 cents to the counterparty; 237 sats in = 237 sats out ✓ + +The strict inequality admits this better price: Alice pays `2000 / 237` ≈ 8.44 cents per sat, less than her maximum of 10 cents per sat. The mint signs Alice's 12 selected `BlindedMessage` values (6 receive + 6 change), and Alice unblinds them to obtain 237 sats and 8000 cents of change. + +## Recovery and refund + +Identical to [NUT-Exchange][exchange]. Pool-mode proofs carry the same `expiry` and `refund` tags. After `expiry`, the owner refunds via NUT-03 swap with a `refund` signature. In the refund path the exchange never committed, so unused pool entries were never signed and may be discarded once the refund is complete; the discard safety rule in [Request format](#request-format) governs the settlement-loss case. + +## Mint info + +```json +{ + "exchange": { + "supported": true, + "version": 1, + "max_participants": "", + "max_inputs": "", + "max_outputs": "", + "max_request_bytes": "", + "idempotent_retries": "", + "max_expiry_seconds": "", + "partial_fill": true, + "max_pool_entries": "" + } +} +``` + +`max_pool_entries` bounds the total number of manifest entries per participant, while `max_request_bytes` bounds the complete serialized request, including every participant's manifest. A wallet MUST satisfy both limits and a mint MUST enforce both limits before signing or mutating state. + +## FAQ + +**Why powers-of-2 denominations?** +`⌈log₂(N)⌉` entries cover every integer from 0 to `N−1` via subset sum. For a 1000-unit range: 10 entries. This keeps wallet cost logarithmic. + +**Can the coordinator steal outputs?** +No. Every selected entry must be in Alice's authenticated manifest (verified by `H_manifest`). The coordinator can only select entries Alice pre-generated with her own blinding factors. It cannot inject its own `BlindedMessage` values. + +**Does the mint enforce best execution?** +No. The rate covenant enforces the owner's limit price. Any subset satisfying the covenant is valid. The coordinator may select the least favorable valid pair. Best execution is an off-mint concern. + +## References + +- [NUT-01](01.md) · [NUT-02](02.md) · [NUT-03](03.md) · [NUT-06](06.md) · + [NUT-09](09.md) · [NUT-10](10.md) · [NUT-11](11.md) · [NUT-12](12.md) · + [NUT-Exchange][exchange] + +[01]: 01.md +[02]: 02.md +[03]: 03.md +[06]: 06.md +[09]: 09.md +[10]: 10.md +[11]: 11.md +[12]: 12.md +[exchange]: https://github.com/cashubtc/nuts/pull/410 +[rfc8785]: https://www.rfc-editor.org/rfc/rfc8785.html From 4d7942420c0fb2ab79fe665b342f732188484a71 Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Mon, 27 Jul 2026 21:40:56 +0900 Subject: [PATCH 26/27] Add range-order support to CTF settlement; reference PR #410 CTF convert now supports pool-mode range orders (PAY_TO_UNLOCK with rate_n/rate_d) inherited from NUT-Exchange-partial-fill, replacing the micro-lot pattern. - Mode-dispatched validation: standard participants inherit base rules 1-7,9,11-12; pool participants inherit 6p/7p/9p + per-participant 8p clauses; the request-global two-keyset clause of 8p and rule 10p are replaced by CTF rule 8 and per-outcome rule 10 - Endpoint binding: manifest hashed under Cashu/ctf/convert/manifest (not base PAY_TO_UNLOCK/manifest), preventing /v1/exchange replay at /v1/ctf/convert - Rational limit-price encoding: buy at p=a/b -> rate_n=b, rate_d=a; sell reverses; fractional prices representable - request_digest includes pool_manifest/pool_selection for pool participants; Mint info gains partial_fill + max_pool_entries; max_request_bytes covers manifests - Reconcile pool-mode change with per-outcome conservation (safety = per-outcome equality, not auth/price); clarify fair-pricing FAQ (mint enforces owner limit, not best execution) - Re-point exchange-spec references to PR #410; remove local exchange.md and exchange-partial-fill.md placeholders; depends on now lists NUT-Exchange and NUT-Exchange-partial-fill --- CTF-settle.md | 63 ++++-- error_codes.md | 4 +- exchange-partial-fill.md | 248 --------------------- exchange.md | 451 --------------------------------------- 4 files changed, 42 insertions(+), 724 deletions(-) delete mode 100644 exchange-partial-fill.md delete mode 100644 exchange.md diff --git a/CTF-settle.md b/CTF-settle.md index 64f6fb0f..64763be9 100644 --- a/CTF-settle.md +++ b/CTF-settle.md @@ -4,11 +4,11 @@ `optional` -`depends on: NUT-CTF, NUT-CTF-split-merge, NUT-02, NUT-03, NUT-06, NUT-07, NUT-09, NUT-10, NUT-11, NUT-12` +depends on: NUT-CTF, NUT-CTF-split-merge, NUT-Exchange, NUT-Exchange-partial-fill, NUT-02, NUT-03, NUT-06, NUT-07, NUT-09, NUT-10, NUT-11, NUT-12 --- -This NUT is an extension of both [NUT-Exchange][exchange] and [NUT-CTF-split-merge][CTF-split-merge]. The key observation: [NUT-Exchange][exchange] becomes more efficient when the mint is aware of CTF-specific characteristics (per-outcome conservation, conditional keysets, attestation cutoff), and [NUT-CTF-split-merge][CTF-split-merge] gains multi-party support by reusing [NUT-Exchange][exchange]'s `PAY_TO_UNLOCK` authorisation model. This NUT combines the two on a single endpoint. +This NUT is an extension of both [NUT-Exchange][exchange] and [NUT-CTF-split-merge][CTF-split-merge]. The key observation: [NUT-Exchange][exchange] becomes more efficient when the mint is aware of CTF-specific characteristics (per-outcome conservation, conditional keysets, attestation cutoff), and [NUT-CTF-split-merge][CTF-split-merge] gains multi-party support by reusing [NUT-Exchange][exchange]'s `PAY_TO_UNLOCK` authorisation model. This NUT combines the two on a single endpoint. It also inherits [NUT-Exchange-partial-fill][partial-fill]'s pool-based **range orders**, so a participant may lock a fixed input and authorise a range of output bundles at a limit rate (see [Range orders](#range-orders-partial-fill)). Readers should be familiar with [NUT-Exchange][exchange] (`PAY_TO_UNLOCK`, `H_recv`, recovery, refund, the `participants` request shape, idempotency) and [NUT-CTF-split-merge][CTF-split-merge] (per-outcome conservation, coverage from keyset metadata, canonical collection encoding, fee model). This document specifies only the **differences**. @@ -36,25 +36,27 @@ Multi-party mode is detected by the `participants` key (same request/response sh - `condition_id`: shared across all participants; 64-char hex (error 13021 if unknown). - `parent_collection_id`: reserved for nested conditions; MUST be omitted or all-zero in v1. - `participants`: ≥ 2 records. Every input MUST carry a `PAY_TO_UNLOCK` condition. Every proof's keyset is either the regular collateral keyset of the condition's unit, or a conditional keyset registered under this `condition_id`. +- A participant record MAY additionally carry `pool_manifest` and `pool_selection` to authorise a **range order** (partial fill); see [Range orders](#range-orders-partial-fill). A single request MAY mix standard-mode and pool-mode participants; the mint validates each participant under its own mode's rules (standard participants follow base [NUT-Exchange][exchange] rules 6–7 and 12; pool participants follow rules 6p–9p from [NUT-Exchange-partial-fill][partial-fill]). Response: `{signatures: [...]}` — one `BlindSignature` array per participant, same as [NUT-Exchange][exchange]. ## Differences from NUT-Exchange -| Aspect | NUT-Exchange | This NUT | -| -------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | -| Conservation | per-class (rule 10) | per-outcome (from [CTF-split-merge]); **replaces** rule 10 | -| Keyset constraint | exactly two distinct keysets (rule 8) | collateral + conditional under one `condition_id`; **replaces** rule 8 | -| Condition tags | `offer_keyset`, `expiry`, `refund` (3 tags) | identical (3 tags) | -| `H_recv` domain | `Cashu/PAY_TO_UNLOCK/recv` | `Cashu/ctf/convert/recv` | -| `request_digest` | `Cashu/exchange/request` over participant records only | `Cashu/ctf/convert/request` over `condition_id` + `parent_collection_id` + participant records | -| Refund digest domain | `Cashu/PAY_TO_UNLOCK/refund` | identical (unified) | -| Attestation cutoff | n/a | MUST reject after attestation; serialise with commit | -| Liability accounting | n/a | per-outcome `ΔL(o)` (see below) | +| Aspect | NUT-Exchange | This NUT | +| -------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Conservation | per-class (rule 10) | per-outcome (from [CTF-split-merge]); **replaces** rule 10 | +| Keyset constraint | exactly two distinct keysets (rule 8) | collateral + conditional under one `condition_id`; **replaces** rule 8 | +| Condition tags | `offer_keyset`, `expiry`, `refund` (3 tags) | standard: identical (3 tags); pool (range order): + `rate_n`, `rate_d`, `min_receive`, `max_debit` | +| `H_recv` domain | `Cashu/PAY_TO_UNLOCK/recv` | `Cashu/ctf/convert/recv` (standard mode) | +| `request_digest` | `Cashu/exchange/request` over participant records only | `Cashu/ctf/convert/request` over `condition_id` + `parent_collection_id` + participant records | +| Refund digest domain | `Cashu/PAY_TO_UNLOCK/refund` | identical (unified) | +| Attestation cutoff | n/a | MUST reject after attestation; serialise with commit | +| Liability accounting | n/a | per-outcome `ΔL(o)` (see below) | +| Partial fill | pool mode (rules 6p–10p) | rules 6p, 7p, 9p and the per-participant clauses of 8p **inherited**; the request-global two-keyset clause of 8p and 10p **replaced** by rule 8 and per-outcome rule 10; manifest domain `Cashu/ctf/convert/manifest` | -**Inherited from NUT-Exchange:** rules 1–7, 9, 11–12; `PAY_TO_UNLOCK` condition mechanism (3 required tags; CTF does not use `alt_outputs`, `allow_change`, or `min_output_amount` — see [FAK note](#fak-limitation) below); `H_recv` computation (entry encoding identical, domain differs); recovery; refund mechanics (including witness-free preimage and unified refund domain); idempotency; coordinator-trust properties. +**Inherited from NUT-Exchange:** rules 1–7, 9, 11–12; `PAY_TO_UNLOCK` condition mechanism (3 required tags for standard participants; CTF does not use the base optional tags `alt_outputs`, `allow_change`, or `min_output_amount`; pool-mode range orders add `rate_n`, `rate_d`, `min_receive`, `max_debit` — see [Range orders](#range-orders-partial-fill)); `H_recv` computation (entry encoding identical, domain differs); recovery; refund mechanics (including witness-free preimage and unified refund domain); idempotency; coordinator-trust properties. -**Replaced:** rule 8 (keyset constraint) and rule 10 (conservation) — see CTF-specific validation below. +**Replaced:** rule 8 (keyset constraint) and rule 10 (conservation) for all participants; for pool-mode participants additionally the request-global two-keyset clause of rule 8p and rule 10p — see [Range orders](#range-orders-partial-fill). ## Differences from NUT-CTF-split-merge @@ -69,16 +71,16 @@ Conservation rule, fee model, coverage from keyset metadata, and canonical colle ## CTF-specific validation -Rules 1–7, 9, 11–12 from [NUT-Exchange][exchange] are inherited (CTF rejects the optional `alt_outputs`, `allow_change`, and `min_output_amount` tags — see [FAK limitation](#fak-limitation)). Rules 8 and 10 are **replaced**: +Standard participants inherit rules 1–7, 9, 11–12 from [NUT-Exchange][exchange] and reject the optional `alt_outputs`, `allow_change`, and `min_output_amount` tags. Pool-mode (range-order) participants instead inherit rules 6p, 7p, 9p and the per-participant clauses of 8p from [NUT-Exchange-partial-fill][partial-fill] (see [Range orders](#range-orders-partial-fill)). For all participants, rules 8 and 10 are **replaced**: - **Rule 8 (replaced):** Every input/output keyset MUST be active. Collateral keysets MUST be regular with unit equal to the condition's collateral unit. Conditional keysets MUST be registered under this `condition_id`. Either class is valid on either side of a convert. - **Rule 10 (replaced):** Per-outcome conservation with `in(o)` / `out(o)` summed across all participants: `out(o) == in(o) − F` for every `o ∈ Ω` (error 13041). Since collateral covers every outcome, an uncovered outcome (no participant receives tokens covering it) would force `out(o) = 0 = in(o) − F`, making the fee absorb all collateral — a degenerate settlement. Multi-party convert therefore requires at least one output to cover each outcome. (Single-party convert inherits split-merge's rule, which permits `out(o) == 0` when the fee legitimately consumes one outcome's value.) Additional multi-party rules: -1. `parent_collection_id` MUST be omitted or all-zero. Advertised limits (`max_participants`, `max_inputs`, `max_outputs`, `max_request_bytes`) MUST be respected. +1. `parent_collection_id` MUST be omitted or all-zero. Advertised limits (`max_participants`, `max_inputs`, `max_outputs`, `max_request_bytes`, `max_pool_entries`) MUST be respected. 2. **No attestation recorded** for `condition_id` (error 13042). This check MUST serialise atomically with the convert commit — see [Attestation atomicity](#attestation-atomicity). -3. Every input carries a canonical `PAY_TO_UNLOCK` condition (3 required tags: `offer_keyset`, `expiry`, `refund`; no optional tags). All inputs in one participant record share the same `H_recv`, `expiry`, `refund`, and `offer_keyset`, with unique per-proof nonces. Each participant's outputs hash to that `H_recv`. The `offer_keyset` MUST match each proof's actual keyset (`Proof.id`). +3. Every input carries a canonical `PAY_TO_UNLOCK` condition. **Standard participants** use the 3 required tags (`offer_keyset`, `expiry`, `refund`; no optional tags); all inputs in one participant record share the same `H_recv`, `expiry`, `refund`, and `offer_keyset`, with unique per-proof nonces, and each participant's outputs hash to that `H_recv`. **Pool-mode (range-order) participants** add the 4 pool tags (`rate_n`, `rate_d`, `min_receive`, `max_debit`) and set `data = H_manifest` over the domain `Cashu/ctf/convert/manifest`; see [Range orders](#range-orders-partial-fill). In both modes the `offer_keyset` MUST match each proof's actual keyset (`Proof.id`). 4. Reject if any involved keyset has `input_fee_ppk == 0` unless admission control is in force _(without `F`, anyone can submit unlimited convert requests at no cost — a free-DoS vector)_. 5. **`expiry` MUST precede** the earliest `final_expiry` among all conditional keysets registered under this `condition_id`. If any keyset lacks `final_expiry`, the mint MUST use its advertised `max_expiry_seconds` as the effective bound. Without this, a proof whose `expiry` falls after all same-outcome keysets are deactivated cannot be refunded or redeemed — stranding value permanently. _Known limitation: `expiry` is inside the blinded `Proof.secret` ([NUT-10][10]) and invisible to the mint at [NUT-03][03] swap time; the mint enforces this check at settlement, not creation. Wallets MUST set `expiry` conservatively._ @@ -93,11 +95,25 @@ req_canonical = condition_id || parent_collection_id_canonical || participant[0] request_digest = tagged_hash("Cashu/ctf/convert/request", req_canonical) ``` -where `parent_collection_id_canonical` is the all-zero 32-byte hex if the field is omitted. +where `parent_collection_id_canonical` is the all-zero 32-byte hex if the field is omitted. Each `participant_canonical` is mode-dependent: standard participants canonicalise `inputs` and `outputs`; pool-mode participants additionally include `pool_manifest` and `pool_selection` (as in [NUT-Exchange-partial-fill][partial-fill]), so two requests differing only in manifest or selection cannot alias to one cached response. -## FAK limitation +## Range orders (partial fill) -CTF convert does **not** support `alt_outputs`, `allow_change`, or `min_output_amount`. Change outputs break per-outcome conservation (collateral covers every outcome, so a change output inflates `out(o)` for every `o`). FAK orders in CTF convert use the **micro-lot pattern** instead: the owner creates multiple small-denomination input proofs — all in **one participant record** with unique per-proof nonces and the same `H_recv` — and the coordinator includes the input subset that matches the agreed price. The output bundle (e.g., 100 YES) is fixed; only the input count varies. The coordinator computes the correct subset size accounting for fees (`F` depends on total input count). All proofs MUST be in a single record (not separate records) to avoid violating output-uniqueness (rule 9). _Known limitation: each proof is individually authorized; a coordinator MAY include more proofs than agreed (over-debit up to the owner's pre-authorized maximum). This is a best-execution concern, not a solvency issue. Mitigation: create proofs only for the exact price acceptable, not the theoretical maximum._ +CTF convert supports partial fills via the pool-based **range orders** defined in [NUT-Exchange-partial-fill][partial-fill]. A participant locks a fixed input set (all proofs sharing one `H_manifest`) and authorises a **range** of output bundles at a limit rate; the coordinator selects the subset that matches the agreed price, and the mint signs only the selected entries. This replaces the earlier micro-lot input-subset pattern. + +A pool-mode participant carries the `pool_manifest` and `pool_selection` fields from [NUT-Exchange-partial-fill][partial-fill], and its inputs' `PAY_TO_UNLOCK` condition carries the pool tags `rate_n`, `rate_d`, `min_receive`, `max_debit` with `data = H_manifest`. The base optional tags `alt_outputs`, `allow_change`, and `min_output_amount` MUST be absent. A single `/v1/ctf/convert` MAY mix standard-mode and pool-mode participants; the mint validates each participant under its own mode's rules. + +**Inherited from [NUT-Exchange-partial-fill][partial-fill]:** rule 6p (manifest hash), 7p (selection consistency), 9p (policy: the rate covenant `receive_total × rate_d ≥ debit_total × rate_n`, plus `min_receive` and `max_debit`, in face-value minor units), and the **per-participant** clauses of rule 8p (receive entries share one `id`, change entries use the `offer_keyset`, both roles present). The **request-global** exactly-two-keysets clause of rule 8p is **replaced** by this NUT's rule 8 (collateral plus one or more conditional keysets under one `condition_id`), because a complementary match legitimately unions {collateral, YES, NO}. Rule 10p (per-class conservation) is **replaced** by this NUT's per-outcome rule 10. + +**Endpoint binding (CTF rule 6p):** the manifest is hashed under the CTF-specific domain `Cashu/ctf/convert/manifest`, not the base `Cashu/PAY_TO_UNLOCK/manifest` domain. This mirrors the `H_recv` domain override and prevents a pool authorization prepared for `/v1/exchange` from being replayed at `/v1/ctf/convert`, where a different conservation and fee model applies. The mint MUST reject a manifest committed under any other domain. + +**Limit-price encoding:** the rule 9p covenant is inherited unchanged. The tags are unsigned integers, so a limit price MUST be expressed as a reduced fraction over face-value minor units. For a **buy** at price `p = a/b` collateral units per conditional unit (debit ÷ receive ≤ `a/b`), the encoding is `rate_n = b`, `rate_d = a`, yielding `receive_total × a ≥ debit_total × b`. A **sell** at the same price reverses to `rate_n = a`, `rate_d = b`. Fractional prices are therefore representable (e.g. `0.60 = 3/5` → buy `rate_n = 5, rate_d = 3`). + +**Conservation:** the pool selection sets each participant's input and output contribution; per-outcome conservation `out(o) == in(o) − F` is then enforced across **all** participants as usual (rule 10). The rate covenant (9p) is a per-participant limit-price check and is independent of global conservation. Pool-mode change is conservation-safe for the same reason all change is: a collateral input `I` contributes `I` to `in(o)` for every outcome `o`, and a collateral change output `C` contributes `C` to `out(o)` for every `o`, so their net `−(I − C) = −debit_total` is uniform across outcomes and cannot inflate any single `out(o)`; the binding constraint is the per-outcome equality itself (`max_debit` and the rate covenant bound authorization and price, not conservation). + +**Input consolidation:** pool mode locks a fixed input set and charges `input_fee_ppk` on every locked input proof. A wallet SHOULD consolidate the maximum order into a single (or minimal-count) input proof before authorising a range order, so that per-input fees do not dominate a small fill or push it below the owner's rate. + +Recovery, refund, and the discard-safety rule (an owner MUST NOT discard any unselected entry's secret or blinding factor before a definitive settlement outcome) are inherited unchanged from [NUT-Exchange-partial-fill][partial-fill]. ## Attestation atomicity @@ -124,7 +140,7 @@ A single convert may mix `MINT` (buy + buy on complementary outcomes), `MERGE` ( ## Mint info -Same [NUT-06][06] setting as [NUT-CTF-split-merge][CTF-split-merge], extended with `max_participants` (≥ 2) and `max_expiry_seconds` (same semantics as [NUT-Exchange][exchange]: bounds `PAY_TO_UNLOCK` condition lifetime at [NUT-03][03] swap time). +Same [NUT-06][06] setting as [NUT-CTF-split-merge][CTF-split-merge], extended with `max_participants` (≥ 2), `max_expiry_seconds` (same semantics as [NUT-Exchange][exchange]: bounds `PAY_TO_UNLOCK` condition lifetime at [NUT-03][03] swap time), and the pool-mode fields from [NUT-Exchange-partial-fill][partial-fill]: `partial_fill` (capability bool) and `max_pool_entries` (per-participant manifest-entry cap). `max_request_bytes` bounds the complete serialized request, including every pool-mode participant's `pool_manifest` and `pool_selection`. ## FAQ @@ -132,11 +148,11 @@ Same [NUT-06][06] setting as [NUT-CTF-split-merge][CTF-split-merge], extended wi Single-party convert ([CTF-split-merge]) does not need `PAY_TO_UNLOCK` or any [NUT-Exchange][exchange] concept. This NUT adds the multi-party layer for readers who already know both. **Does the mint enforce fair pricing?** -No. Per-outcome conservation is the only structural constraint. Per-participant pricing is set off-mint. +The mint enforces each participant's own limit price (the rule 9p rate covenant for range orders) but does **not** enforce best execution or fair matching — price discovery and match selection are off-mint. Per-outcome conservation (rule 10) is the only structural constraint on the aggregate. ## References -- [NUT-CTF][CTF] · [NUT-CTF-split-merge][CTF-split-merge] · [NUT-Exchange][exchange] +- [NUT-CTF][CTF] · [NUT-CTF-split-merge][CTF-split-merge] · [NUT-Exchange][exchange] · [NUT-Exchange-partial-fill][partial-fill] - [Polymarket CTF Exchange](https://github.com/Polymarket/ctf-exchange-v2) [02]: 02.md @@ -145,3 +161,4 @@ No. Per-outcome conservation is the only structural constraint. Per-participant [CTF]: CTF.md [CTF-split-merge]: CTF-split-merge.md [exchange]: https://github.com/cashubtc/nuts/pull/410 +[partial-fill]: https://github.com/cashubtc/nuts/pull/410 diff --git a/error_codes.md b/error_codes.md index fd9609e4..c3c95ac5 100644 --- a/error_codes.md +++ b/error_codes.md @@ -68,5 +68,5 @@ [21]: 21.md [22]: 22.md [29]: 29.md -[exchange]: exchange.md -[partial-fill]: exchange-partial-fill.md +[exchange]: https://github.com/cashubtc/nuts/pull/410 +[partial-fill]: https://github.com/cashubtc/nuts/pull/410 diff --git a/exchange-partial-fill.md b/exchange-partial-fill.md deleted file mode 100644 index 22b97893..00000000 --- a/exchange-partial-fill.md +++ /dev/null @@ -1,248 +0,0 @@ -# NUT-Exchange-partial-fill: Partial-Fill Authorization - -`draft` - -`optional` - -`depends on: NUT-Exchange, NUT-01, NUT-02, NUT-03, NUT-06, NUT-09, NUT-10, NUT-11, NUT-12` - ---- - -This NUT extends [NUT-Exchange][exchange]'s `PAY_TO_UNLOCK` condition with a **pool-based authorization mode**: the owner locks a single input and authorizes a **range** of possible output bundles, with the actual output selection determined at match time. - -In standard mode (NUT-Exchange), `data` is `H_recv` — a hash of one exact output bundle. In pool mode (this NUT), `data` is a manifest hash over a small set of pre-generated output entries in binary denominations, plus a numeric rate policy that the mint enforces. The coordinator selects any subset satisfying the policy; the mint signs only the selected entries. - -Readers should be familiar with [NUT-Exchange][exchange] (`PAY_TO_UNLOCK`, conservation rules, recovery, refund, the `participants` request shape). - -## Condition - -Pool mode is signaled by the presence of `rate_n` and `rate_d` tags. When absent, the condition uses standard mode (exact `H_recv` match, as defined in [NUT-Exchange][exchange]). In pool mode the base optional tags `alt_outputs`, `allow_change`, and `min_output_amount` MUST be absent: `alt_outputs` is incompatible with rule 6p's exact `H_manifest`, change is authorized by the manifest's change role (rule 8p), and `min_receive` supersedes `min_output_amount` (base rule 12). - -```json -[ - "PAY_TO_UNLOCK", - { - "nonce": "", - "data": "", - "tags": [ - ["offer_keyset", ""], - ["expiry", ""], - ["refund", ""], - ["rate_n", ""], - ["rate_d", ""], - ["min_receive", ""], - ["max_debit", ""] - ] - } -] -``` - -**Required tags** (same as NUT-Exchange): `offer_keyset`, `expiry`, `refund`. - -**Pool-mode tags** (presence signals pool mode; all four required together, each appearing exactly once as a minimal unsigned decimal string with no leading zeros): - -- `rate_n` / `rate_d`: minimum receive rate in integer keyset minor units: at least `rate_n` receive-keyset units for every `rate_d` offer-keyset units debited. `rate_d` MUST be greater than zero. The mint enforces `receive_total × rate_d ≥ debit_total × rate_n` using checked `u128` cross-multiplication, with no division or rounding. Implementations MUST NOT convert either total to display units before this comparison. For a sat receive keyset and a USD offer keyset whose amount `1` is one cent, one sat per cent is encoded as `rate_n = 1, rate_d = 1`. The inequality admits every better price. -- `min_receive`: minimum total receive-keyset output amount. Prevents dust fills. MUST be positive. -- `max_debit`: maximum total debit (`input_total − change_total`). Caps spending and MUST be no greater than the participant's input total. The mint MUST reject a request whose selected `change_total` exceeds `input_total`; it MUST NOT perform a wrapping subtraction. - -Every proof contributed by one participant MUST carry the same `data` and the same tags (`offer_keyset`, `expiry`, `refund`, `rate_n`, `rate_d`, `min_receive`, `max_debit`), while each proof MUST use a unique `nonce`, following the per-proof nonce rule in [NUT-Exchange][exchange]. - -## Output pools - -The owner generates two pools of `BlindedMessage` entries: - -- **Receive pool:** entries in the receive keyset, at powers-of-2 denominations (the recommended wallet construction; see below). -- **Change pool:** entries in the offer keyset, at powers-of-2 denominations covering the possible change range. - -Each entry has a `role` (`receive` or `change`), `amount`, `id` (keyset), and `B_` (blinded point). The owner retains every entry's secret and blinding factor. - -For each participant, every `receive` entry MUST have the same `id`, called that participant's receive keyset. Every `change` entry MUST have `id` equal to the condition's `offer_keyset`. Both roles MUST be present, and the receive keyset MUST differ from `offer_keyset`; therefore each participant's complete manifest contains exactly those two keyset IDs. Across the complete exchange request, the union of every participant's `offer_keyset` and derived receive keyset MUST contain exactly two distinct keyset IDs. - -The recommended wallet construction places entries at powers-of-two denominations (`1, 2, 4, 8, ..., 2^k`), because `⌈log₂(R+1)⌉` such receive entries can represent every integer from `0` through `R` by subset sum, and likewise `⌈log₂(C+1)⌉` change entries cover `0` through `C`, keeping the pool at `O(log R + log C)` entries. Cashu does not guarantee that a keyset publishes those denominations, so a wallet MUST inspect the [NUT-01][01] key maps and MUST NOT create a pool unless each keyset is active for issuance and publishes a signing key for every amount placed in that keyset's pool. The denomination sequence is a wallet-side construction: the mint authenticates only the committed manifest and verifies per entry that `(id, amount)` identifies a published signing key in an active keyset (rule 8p); it does not require any particular denomination shape. - -## Manifest hash - -The `data` field is `H_manifest`, computed over the complete ordered `pool_manifest`. `PoolEntry` is the only name for an entry in that array and has exactly these fields: - -```json -{ - "index": "", - "role": "receive|change", - "amount": "", - "id": "", - "B_": "" -} -``` - -`amount` is an unsigned 64-bit integer in the minor unit of the entry's keyset ([NUT-01][01]); `index` is the entry's zero-based position. In the canonical encoding below, both `amount` and `index` are serialized as minimal decimal strings (no leading zeros), exactly as base NUT-Exchange serializes `amount`, to avoid IEEE-754 precision loss above 2^53 ([RFC 8785][rfc8785] §3.1). `pool_manifest` MUST contain all `receive` entries first and all `change` entries second. Each entry's `index` MUST equal its zero-based position in `pool_manifest`; indices are unique and contiguous from `0` through `len(pool_manifest) − 1`. - -Each `PoolEntry` is encoded using the [RFC 8785][rfc8785] JCS defined by [NUT-Exchange][exchange], with `amount` and `index` as minimal decimal strings (object keys in lexicographic order: `B_`, `amount`, `id`, `index`, `role`): - -``` -manifest_canonical = JCS(pool_manifest[0]) || JCS(pool_manifest[1]) || ... || JCS(pool_manifest[n-1]) -H_manifest = tagged_hash("Cashu/PAY_TO_UNLOCK/manifest", manifest_canonical) -``` - -## Request format - -Pool-mode participants include the full manifest and a selection bitmap: - -```json -{ - "participants": [ - { - "inputs": "", - "outputs": "", - "pool_manifest": "", - "pool_selection": "" - } - ] -} -``` - -- `outputs`: the selected entries only — these are the `BlindedMessage` values the mint will sign. MUST be a subset of `pool_manifest`, in manifest index order. -- `pool_manifest`: the complete ordered array of `PoolEntry` values defined in [Manifest hash](#manifest-hash). The mint computes `H_manifest` from this array and verifies it against the condition's `data` field. This authenticates every candidate entry as part of the owner-created pool. -- `pool_selection`: a hex-encoded bitmap selecting which manifest entries to sign. The bitmap is exactly `⌈len(pool_manifest) / 8⌉` bytes, encoded as lowercase hex (even-length, no `0x` prefix); a string of any other byte length MUST be rejected. Bit `i` (0-indexed from the least-significant bit of the first byte) corresponds to `pool_manifest[i]`. Bit `1` = selected (include in `outputs`), bit `0` = skipped. Unused trailing bits (bit index `≥ len(pool_manifest)`) MUST be zero. The selected entries, in index order, MUST exactly match the `outputs` array. - -`pool_selection` tells the mint which authenticated candidate outputs to sign. An entry whose bit is zero is an unsigned candidate, not ecash: the mint MUST NOT sign or return it, and signing every manifest entry would violate the per-class conservation rule. The full-manifest form reveals to the mint that all listed `B_` values belong to one authorization, but an unselected value never becomes a proof. The owner MUST NOT discard any unselected entry's secret or blinding factor, or reuse its `B_`, until it has a definitive settlement outcome for the authorization: if no settlement response was received, the owner MUST first determine whether the mint spent the inputs and, if so, recover the actually-signed signatures via [NUT-09][09] by submitting every manifest entry's `BlindedMessage` (only entries the mint did not sign may then be discarded). After successful settlement, refund, or post-expiry recovery, a later transaction requiring that denomination MUST generate a fresh secret, blinding factor, and `B_`. - -Example: a five-entry manifest has receive entries at manifest indices 0, 1, and 2 and change entries at manifest indices 3 and 4. Selecting manifest entries 0, 2, and 4 produces the bitmap `0b00010101` = `0x15`; therefore `pool_selection = "15"`, and `outputs` contains `pool_manifest[0]`, `pool_manifest[2]`, and `pool_manifest[4]`, in that order. - -A single `/v1/exchange` request MAY mix pool-mode and standard-mode participants. Standard-mode participants omit `pool_manifest` and `pool_selection` and are validated under base [NUT-Exchange][exchange] rules; rule 8p derives each participant's receive keyset per mode — from the manifest for pool mode, from the outputs for standard mode. - -Version 1 uses the full manifest. Pool size is logarithmic in the representable receive and change ranges, and every request remains subject to both `max_pool_entries` per participant and NUT-Exchange's `max_request_bytes` for the complete request. The mint MUST reject a request exceeding either limit, and a wallet MUST NOT create a pool-mode authorization that cannot fit both advertised limits. For idempotent retries, a pool-mode participant's canonical record (and thus `request_digest`) includes `pool_manifest` and `pool_selection` in addition to `inputs` and `outputs`, so two requests differing only in manifest or selection cannot alias to one cached response. Merkle roots and inclusion proofs are not valid version-1 request forms. A future Merkle form MUST use a separately advertised version or mode that defines the leaf encoding, global index binding, tree construction and domain separation, proof encoding, and proof-size limits. - -Response: same as NUT-Exchange — `{signatures: [...]}`, one `BlindSignature` array per participant. Pool-mode participants receive signatures for their selected entries only. - -## Mint validation - -Pool-mode participants require additional validation beyond [NUT-Exchange][exchange] rules 1–5, 9, 11. Rule 3's tag grammar is extended for pool mode: the four pool tags are required and the base optional tags `alt_outputs`, `allow_change`, and `min_output_amount` are forbidden (error 15001); rule 12 (`min_output_amount`) is superseded by rule 9p's `min_receive`: - -6p. **Manifest hash:** hash `pool_manifest` canonically → MUST equal condition `data`. Otherwise reject (error 15011). - -7p. **Selection consistency:** the entries indicated by `pool_selection` MUST exactly match the `outputs` array (same `B_`, `amount`, `id` values in the same order), and `pool_selection` MUST be the canonical bitmap encoding from [Request format](#request-format). Otherwise reject (error 15012). - -8p. **Role/keyset and two-class consistency:** validate the entire `pool_manifest`, not only selected entries. For each pool-mode participant, all `receive` entries MUST share one `id`; all `change` entries MUST use the condition's `offer_keyset`; both roles MUST be present; and the derived receive keyset MUST differ from `offer_keyset`. No other keyset ID may occur in that manifest. Each participant's receive keyset is derived per mode — from the manifest for pool-mode participants, from the outputs under base rule 7 for standard-mode participants. Across all participants, exactly two distinct keyset IDs MUST occur among all conditions' `offer_keyset` values and all derived receive keysets. For every manifest entry, the mint MUST verify that `id` is a known, active keyset (else errors 12001/12002) and that the keyset publishes a signing key for `amount`. Violations of the role, per-mode, or two-class rules are rejected with error 15013. - -9p. **Policy:** parse `rate_n`, `rate_d`, `min_receive`, and `max_debit` as unsigned `u128` values. Reject if parsing fails, if `rate_d = 0`, or if `min_receive = 0`. Using checked `u128` addition, compute `input_total` from the participant's inputs, `receive_total` from selected receive entries, and `change_total` from selected change entries. Reject if any conversion or sum overflows, if `max_debit > input_total`, or if `change_total > input_total`. Only after those checks, compute `debit_total = input_total − change_total` with checked subtraction. Compute both rate products with checked multiplication and reject if either product overflows. Then enforce (reject with error 15014 on any failure): - -- `receive_total × rate_d ≥ debit_total × rate_n` (rate covenant) -- `receive_total ≥ min_receive` (minimum fill, which also requires at least one selected `receive` entry) -- `debit_total ≤ max_debit` (spending cap) - -`debit_total` is gross of fees. Rule 10p burns `input_fees_c` from each offer class `c`, so the value actually delivered to the opposing side is `debit_total − input_fees_offer` in a two-party exchange and, in general, `Σ(debit_total over participants offering c) − input_fees_c` per class `c`. The rate covenant and spending cap use the gross `debit_total`; the worked examples below assume zero input fees. - -10p. **Conservation:** standard per-class conservation from [NUT-Exchange][exchange] rule 10, applied with change entries in the offer keyset (rejection uses error 11005). The mint signs only `outputs`; `pool_manifest` entries are authentication material, not outputs to sign. - -NUT-Exchange rule 7's per-participant single-keyset output constraint is replaced by rule 8p for pool-mode participants, because selected pool outputs may use both that participant's receive keyset and offer keyset for change; standard-mode participants still follow base rule 7. NUT-Exchange rule 8's exactly-two-keysets invariant is not relaxed; rule 8p applies it to the receive keysets derived per mode (manifest or outputs) and to the request as a whole. - -## Example - -Alice wants to swap **10000 cents (100 USD) for 1000 sats** at a limit of 10 cents per sat. Here `input_total = 10000` cents is the value locked in the authorization and `max_debit = 10000` permits the full input to be spent, so a complete 1000-sat fill at Alice's boundary rate (10 cents/sat) returns 0 cents as change; any partial or better-price fill returns the unspent portion as change. - -### Preparation - -**Receive pool** (sats keyset, 10 entries): amounts 1, 2, 4, 8, 16, 32, 64, 128, 256, 512. Any subset sums to 0–1023. - -**Change pool** (USD keyset; amounts are cents, 14 entries): amounts 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192. These entries can represent every integer change amount from 0 through 16383 cents. - -**Manifest:** 24 entries. `H_manifest` is computed from the canonical `PoolEntry` encodings defined above. - -**Policy:** `rate_n = 1, rate_d = 10` (receive at least 1 sat per 10 cents debited, i.e., pay at most 10 cents per sat), `min_receive = 1` sat, `max_debit = 10000` cents. - -**Lock:** one or more `PAY_TO_UNLOCK` proofs totaling 10000 cents, all carrying the same manifest and policy tags and unique per-proof nonces. - -### Settlement — boundary fill of 500 sats - -Counterparty offers 500 sats for 5000 cents (the boundary rate of 10 cents/sat). - -Coordinator selects: - -- Receive: {256, 128, 64, 32, 16, 4} = 500 sats (6 entries) -- Change: {4096, 512, 256, 128, 8} = 5000 cents (5 entries) - -Mint checks: - -- `H_manifest` matches condition `data` ✓ -- Selected entries exactly match the bitmap and `outputs` ✓ -- `input_total = 10000`, `change_total = 5000`, so `debit_total = 10000 − 5000 = 5000` cents ✓ -- Rate boundary: `500 × 10 ≥ 5000 × 1` → `5000 ≥ 5000` ✓ -- `receive_total = 500 ≥ min_receive = 1` ✓ -- `debit_total = 5000 ≤ max_debit = 10000` ✓ -- Conservation (zero input fees): 10000 cents in = 5000 cents change + 5000 cents to the counterparty; 500 sats in = 500 sats out ✓ - -Equality is accepted: this is exactly Alice's limit of 10 cents per sat. If the counterparty instead offered 499 sats for the same 5000-cent debit, the mint would evaluate `499 × 10 ≥ 5000 × 1` → `4990 ≥ 5000`, which is false, and MUST reject the request. - -The mint signs Alice's 11 selected `BlindedMessage` values (6 receive + 5 change). Alice unblinds them to obtain 500 sats and 5000 cents of change. - -### Settlement — better-price partial fill of 237 sats - -Counterparty offers 237 sats for 2000 cents (20.00 USD). - -Coordinator selects: - -- Receive: {128, 64, 32, 8, 4, 1} = 237 sats (6 entries) -- Change: {4096, 2048, 1024, 512, 256, 64} = 8000 cents (6 entries) - -Mint checks: - -- `input_total = 10000`, `change_total = 8000`, so `debit_total = 2000` cents ✓ -- Rate: `237 × 10 ≥ 2000 × 1` → `2370 ≥ 2000` ✓ -- `receive_total = 237 ≥ min_receive = 1` ✓ -- `debit_total = 2000 ≤ max_debit = 10000` ✓ -- Conservation (zero input fees): 10000 cents in = 8000 cents change + 2000 cents to the counterparty; 237 sats in = 237 sats out ✓ - -The strict inequality admits this better price: Alice pays `2000 / 237` ≈ 8.44 cents per sat, less than her maximum of 10 cents per sat. The mint signs Alice's 12 selected `BlindedMessage` values (6 receive + 6 change), and Alice unblinds them to obtain 237 sats and 8000 cents of change. - -## Recovery and refund - -Identical to [NUT-Exchange][exchange]. Pool-mode proofs carry the same `expiry` and `refund` tags. After `expiry`, the owner refunds via NUT-03 swap with a `refund` signature. In the refund path the exchange never committed, so unused pool entries were never signed and may be discarded once the refund is complete; the discard safety rule in [Request format](#request-format) governs the settlement-loss case. - -## Mint info - -```json -{ - "exchange": { - "supported": true, - "version": 1, - "max_participants": "", - "max_inputs": "", - "max_outputs": "", - "max_request_bytes": "", - "idempotent_retries": "", - "max_expiry_seconds": "", - "partial_fill": true, - "max_pool_entries": "" - } -} -``` - -`max_pool_entries` bounds the total number of manifest entries per participant, while `max_request_bytes` bounds the complete serialized request, including every participant's manifest. A wallet MUST satisfy both limits and a mint MUST enforce both limits before signing or mutating state. - -## FAQ - -**Why powers-of-2 denominations?** -`⌈log₂(N)⌉` entries cover every integer from 0 to `N−1` via subset sum. For a 1000-unit range: 10 entries. This keeps wallet cost logarithmic. - -**Can the coordinator steal outputs?** -No. Every selected entry must be in Alice's authenticated manifest (verified by `H_manifest`). The coordinator can only select entries Alice pre-generated with her own blinding factors. It cannot inject its own `BlindedMessage` values. - -**Does the mint enforce best execution?** -No. The rate covenant enforces the owner's limit price. Any subset satisfying the covenant is valid. The coordinator may select the least favorable valid pair. Best execution is an off-mint concern. - -## References - -- [NUT-01](01.md) · [NUT-02](02.md) · [NUT-03](03.md) · [NUT-06](06.md) · - [NUT-09](09.md) · [NUT-10](10.md) · [NUT-11](11.md) · [NUT-12](12.md) · - [NUT-Exchange][exchange] - -[01]: 01.md -[02]: 02.md -[03]: 03.md -[06]: 06.md -[09]: 09.md -[10]: 10.md -[11]: 11.md -[12]: 12.md -[exchange]: https://github.com/cashubtc/nuts/pull/410 -[rfc8785]: https://www.rfc-editor.org/rfc/rfc8785.html diff --git a/exchange.md b/exchange.md deleted file mode 100644 index 9a4bb67e..00000000 --- a/exchange.md +++ /dev/null @@ -1,451 +0,0 @@ -# NUT-Exchange: Atomic Multi-Asset Exchange - -`draft` (final NUT name, number, and NUT-10 `kind` are provisional) - -`optional` - -`depends on: NUT-02, NUT-03, NUT-06, NUT-07, NUT-09, NUT-10, NUT-11, NUT-12` - ---- - -This NUT defines an atomic exchange of two existing Cashu asset classes at one -mint. Two or more participants each contribute bearer proofs of one asset class -and commit to blinded receive outputs of the other class. The mint spends every -input and signs every output in a single database transaction, or changes -nothing. - -## Premise - -### The problem - -A client-to-client atomic swap of Cashu tokens (for example an HTLC or -adaptor-signature swap) requires both wallets to remain available while they -exchange keys, commitments, signatures, mint requests, and claims. It exposes a -free-option locktime race: the party holding the swap secret (the leader) picks -when to trigger settlement inside the locktime window — completing only if price -has moved in its favor, otherwise letting the swap lapse — a free American option -on the locked rate. And a peer-to-peer swap cannot batch several independent -swaps into one mint transaction: each must be settled separately. - -A Cashu mint already validates proof signatures, maintains spentness, and issues -blind signatures. This NUT uses that existing authority as the atomic settlement -layer, removing the interactive claim sequence and the locktime free option it -creates. - -### Model - -Each participant first locks its bearer proofs to a receive-output commitment -via a [NUT-10][10] `PAY_TO_UNLOCK` condition. The participants' conditioned -proofs and public receive descriptors are assembled into one settlement request -and submitted to the mint. The mint validates every condition and conserves -each asset class independently, then commits all input spends and all output -signatures in one transaction — or changes nothing. - -Two preparation patterns are supported: a **direct two-party swap** where both -participants are online, and a **coordinator-mediated swap** where a relay -assembles matched participants' material (see [Preparation](#preparation)). A -coordinator or relay is **optional** and has no on-mint authority: any holder of -all the valid authorizations may submit a request. - -### Trust boundary and anonymity - -The mint is trusted for the same things [NUT-11][11] P2PK already trusts it -(rejecting invalid spends, maintaining spentness, blind signing) **plus one -atomic database commit**. No transparency or accountability layer is defined -here. Two mitigations bound the added trust: - -1. **A violation is transcript-checkable.** Any party holding the full transcript - (inputs, conditions, output commitments, signatures, expiry) can prove the mint - accepted an exchange that violates a condition or a conservation rule. This - is incidental verifiability, not a published audit log. -2. **Protocol fields carry no stable owner identity.** Fresh per-authorization - `nonce`, refund key, proof secret, and output secret prevent the mint from - performing _identity-selective_ betrayal from protocol fields alone. The mint - still sees asset classes, amounts, timing, and transport metadata, and may - still censor, deny service, or betray an exchange wholesale — the same issuer - trust NUT-11 carries. - -### Scope - -Version 1 supports: - -- one mint; -- two or more participants in one atomic two-class exchange (any N-vs-M shape); -- exactly two existing asset classes, one offered per side; -- owner-precommitted blinded receive outputs, with optional change outputs in the - offer keyset and alternative output bundles for FAK-style orders; -- per-asset-class conservation; and -- one atomic commit (all participants settle or none). - -Version 1 does **not** support: a coordinator as a required party; cross-mint -settlement; general N-way cycles that would require a solver; asset creation or -destruction. - -Separate `/v1/exchange` calls are independent: a failure or retry of one never -rolls back another, since each is its own database transaction. - -## Protocol - -### Terminology - -- **Asset class**: a mint keyset identified by its [NUT-02][02] `id`. A keyset's - unit is part of the class identity. Amounts from different classes MUST NOT be - added or compared. -- **Participant**: one owner in an exchange. Each offers one asset class and - receives the other. -- **Submitter**: whoever posts the settlement request to the mint — a participant - or a relay. It is not a custody role. -- **Change output**: an output in the participant's offer keyset, returning - unspent input value. Enabled by the condition's `allow_change` tag (see below). - -### `PAY_TO_UNLOCK` condition - -A [NUT-10][10] well-known secret `kind` named `PAY_TO_UNLOCK`. A proof carrying -it authorises one exact exchange — or, if `alt_outputs` is present, one exchange -chosen from a finite set of owner-authorised output bundles. - -```json -[ - "PAY_TO_UNLOCK", - { - "nonce": "", - "data": "", - "tags": [ - ["offer_keyset", ""], - ["expiry", ""], - ["refund", ""], - ["alt_outputs", "", "", "..."], - ["allow_change"], - ["min_output_amount", ""] - ] - } -] -``` - -**Required tags** (MUST appear exactly once): - -- `offer_keyset`: binds the participant's offered asset class. The mint MUST - verify `offer_keyset == Proof.id` on every input (prevents keyset-ID relabeling - when verification keys are shared across keysets). -- `expiry`: unix timestamp. Settlement valid only before it; refund only after. -- `refund`: fresh x-only public key whose private half the owner retains. - -**Optional tags** (MAY appear; each at most once): - -- `alt_outputs`: authorises a finite set of alternative output bundles in - addition to the primary `data`. Each value is a `H_recv` computed identically - to `data`. The submitted output bundle MUST hash to `data` or any listed - alternative. Enables FAK-style orders where the actual fill amount varies (each - bundle includes a different change amount). All inputs in one participant - record MUST carry the identical `alt_outputs` set. -- `allow_change`: if present, outputs MAY include entries in the offer keyset - (change outputs) in addition to entries in the receive keyset. Without this - tag, all outputs MUST use a single keyset (the receive keyset). See [Change - outputs](#change-outputs). -- `min_output_amount`: minimum total receive-keyset output amount (excluding - change). The mint MUST reject if the actual receive output is below this floor. - Prevents a coordinator from filling a tiny amount to consume the authorization - and force a refund. - -Unknown tags MUST be rejected. `expiry` is decimal unix seconds without leading -zeros. Keyset IDs use their [NUT-02][02] canonical form; the refund key is a -BIP-340 x-only pubkey in hex. - -Every proof contributed by one participant MUST carry a `PAY_TO_UNLOCK` -condition with the same `data` (`H_recv`), the same tags (`offer_keyset`, -`expiry`, `refund`, and any optional tags), but a **unique `nonce` per proof**. -The `nonce` provides per-proof anti-replay; the meaningful authorisation fields -are shared. This allows multiple proofs (e.g., micro-denomination inputs) in one -record without duplicate secrets. Across the exchange, the set of `offer_keyset` -values MUST equal the set of receive keysets. - -### Change outputs - -When `allow_change` is present, a participant's output bundle MAY contain entries -in both the receive keyset and the offer keyset. The receive-keyset entries are -the participant's desired receive amount; the offer-keyset entries are change -returned from unspent input. - -Change is determined by per-class conservation (rule 10) at the aggregate level. -The change outputs MUST be included in the committed bundle (`H_recv` or an -`alt_outputs` entry) — the coordinator cannot insert its own change outputs -because it lacks the owner's blinding factors. - -Change outputs do NOT add a third asset class. The exchange still has exactly two -keysets: the offer keyset (now appearing on both input and output sides) and the -receive keyset. - -### Receive-output commitment - -The receive destination is the owner's ordered list of `BlindedMessage` values, -including any change outputs. The canonical encoding of one entry is: - -```json -{"amount": , "id": "", "B_": ""} -``` - -The commitment is a BIP-340 tagged hash over the length-prefixed concatenation -of entries in declared order. Each `entry_canonical` is the entry serialized with -the [RFC 8785][rfc8785] JSON Canonicalization Scheme (JCS): UTF-8, object keys in -lexicographic order, no insignificant whitespace. **Amounts are encoded as -decimal strings** (not JSON numbers) in the canonical form to avoid IEEE-754 -precision loss above 2^53. The length prefix is a 4-byte little-endian unsigned -integer recording the **entry count** (not byte length): - -``` -recv_canonical = uint32_le(len) || entry[0]_canonical || ... || entry[n-1]_canonical -H_recv = tagged_hash("Cashu/PAY_TO_UNLOCK/recv", recv_canonical) -``` - -where `tagged_hash(tag, msg) = SHA256(SHA256(tag) || SHA256(tag) || msg)` and -each `entry_canonical` is `{"amount":"","id":"","B_":""}`. -Amounts are unsigned 64-bit integers; the mint MUST reject outputs whose amounts -are not representable as u64. - -Duplicate entries, unknown fields, non-canonical encodings, and list-prefix -matches MUST be rejected. - -### Canonical encodings - -- **Participant record**: the JSON object `{"inputs": [...], "outputs": [...]}`, - with `inputs` sorted by `(id, secret)` and `outputs` in declared order, - serialized via [RFC 8785][rfc8785] JCS. **All `amount` fields (`Proof.amount` - and `BlindedMessage.amount`) are encoded as decimal strings** (not JSON numbers) - in the canonical form, to avoid IEEE-754 precision loss above 2^53. Amounts - MUST be unsigned integers in `[0, 2^64)` with no leading zeros. The mint MUST - use checked, non-wrapping arithmetic for all sums. -- **`PAY_TO_UNLOCK` condition**: the three required tags (`offer_keyset`, - `expiry`, `refund`) MUST each appear exactly once. Optional tags (`alt_outputs`, - `allow_change`, `min_output_amount`) MAY each appear at most once. Unknown tags - MUST be rejected. `alt_outputs` values MUST be distinct 64-char hex strings; - the mint MUST reject if `alt_outputs` count exceeds advertised `max_alt_outputs`. - `min_output_amount` is a minimal unsigned decimal string (no leading zeros). -- **Participant order**: records are ordered by the lexicographically smallest - `(id, secret)` among each participant's inputs. Proof secrets are unique - across the whole request, so this is a strict total order. -- **Request digest** (optional, for idempotent retries): - -``` -req_canonical = participant[0]_canonical || ... || participant[n-1]_canonical -request_digest = tagged_hash("Cashu/exchange/request", req_canonical) -``` - -If the mint supports idempotent retries (advertised via `idempotent_retries` in -[NUT-06][06] info), the request digest enables fast retry: a byte-identical -request returns the cached response instead of failing on double-spend. Without -this feature, clients fall back to [NUT-09][09] recovery. - -### Preparation - -#### Two-party direct swap - -Two participants agree on an exact exchange. Both are online. Each participant: - -1. prepares blinded receive outputs and computes `H_recv`; -2. uses an ordinary [NUT-03][03] swap to convert its bearer proofs into - `PAY_TO_UNLOCK` proofs committed to `H_recv`; -3. verifies the [NUT-12][12] DLEQ proofs; -4. one participant assembles both participants' material and POSTs - `/v1/exchange`. - -#### Coordinator-mediated swap with FAK support - -A matching engine pairs orders. For exact-fill orders (FOK), each participant -prepares one `H_recv` as above. For variable-fill orders (FAK), a participant -uses `alt_outputs` + `allow_change` + `min_output_amount`: - -1. Generate the receive outputs for the **maximum** fill (e.g., 100 USD). -2. For each possible fill amount (one per price tick), generate a complete bundle: - receive outputs + change outputs in the offer keyset for the unspent portion. -3. Compute `H_recv` for each bundle. Set `data` to the max-fill bundle; list the - rest in `alt_outputs`. -4. Set `min_output_amount` to the minimum acceptable receive amount. -5. Lock the full input amount in one `PAY_TO_UNLOCK` proof (one NUT-03 swap). -6. The coordinator picks the matching bundle at match time. - -One proof, one swap, tick-level granularity. The coordinator can only select -among owner-authorised bundles; it cannot alter any bundle's contents. - -A participant that has pre-committed sufficient bundles may disconnect before -matching. Unused proofs are reclaimed via refund after `expiry`. - -### Settlement request - -```http -POST https://mint.host:3338/v1/exchange -``` - -```json -{ - "participants": [ - { - "inputs": "", - "outputs": "" - }, - { "...": "one record per participant; N >= 2" } - ] -} -``` - -#### Mint validation - -Before any mutation, the mint MUST verify: - -1. Two or more participant records, each with ≥1 input and ≥1 output. Advertised - limits respected. -2. Every proof is authentic, unspent, unique in the request, and signed by an - active or still-spendable keyset. -3. Every proof carries a canonical `PAY_TO_UNLOCK` condition: the three required - tags each exactly once; optional tags at most once; no unknown tags. -4. No input proof is reused across records; every input is unique. -5. Each input's `Proof.id == offer_keyset` (prevents keyset relabeling). -6. Each participant's `outputs` list hashes to the condition's `data` **or** an - `alt_outputs` entry. (If `alt_outputs` is absent, must match `data` exactly.) -7. If `allow_change` is absent: every output `id` is the same (the receive - keyset), and that keyset differs from `offer_keyset`. If `allow_change` is - present: every output `id` is either the receive keyset or the `offer_keyset`; - at least one output MUST use the receive keyset; the receive keyset MUST differ - from `offer_keyset`. -8. Exactly two distinct keysets appear across all participants' `offer_keyset` - values and receive keysets. Per-class conservation (rule 10) does not require - equal participant counts per class, so any two-class shape is valid: 1-vs-N, - N-vs-M, or N-vs-N. -9. Every blinded output is unique, valid, uses an accepted keyset, and has not - been signed before. -10. For each asset class `c` independently, with checked, non-wrapping u64 - arithmetic: `sum(inputs_c) == sum(outputs_c) + input_fees_c`, where - `input_fees_c = (sum(input_fee_ppk over inputs with id == c) + 999) // 1000` - per [NUT-02][02]. Fees are computed and rounded **per class**, not globally. -11. The request is submitted before the minimum `expiry` across all participants' - conditions. -12. If `min_output_amount` is present: the total receive-keyset output amount for - that participant MUST be ≥ `min_output_amount`. (Change outputs in the offer - keyset are excluded from this check.) - -**Processing order.** If idempotent retries are supported, canonicalize and -compute `request_digest` first. If a committed response exists, return it. -Otherwise apply rules 1–12, then atomic commit. - -#### Atomic commit - -The mint MUST commit in one transaction: - -1. mark every input proof spent; -2. sign every blinded output; -3. persist every `BlindedMessage` / `BlindSignature` for [NUT-09][09] restoration; -4. if idempotent retries are supported, persist response keyed by `request_digest`. - -#### Response - -```json -{ - "signatures": [ - "", - "...", - "" - ] -} -``` - -### Recovery - -Recovery is the direct [NUT-07][07]/[NUT-09][09] path. Because each **owner** -retained its own receive `BlindedMessage` values, the owner — not the submitter — -recovers signatures from the mint and unblinds locally. A wallet SHOULD retry -[NUT-09][09] with bounded backoff when an input is spent but no response was -received. - -### Refund - -A `PAY_TO_UNLOCK` proof has two mutually exclusive spend paths: - -- **Before `expiry`**: only as an input to `/v1/exchange`. -- **At or after `expiry`**: only via an ordinary [NUT-03][03] swap to fresh - outputs of the offered asset class, where each refunded input carries a - `Proof.witness` containing a single BIP-340 Schnorr signature by the `refund` - private key over: - - ``` - refund_digest = tagged_hash("Cashu/PAY_TO_UNLOCK/refund", canonical_refund_request) - ``` - - where `canonical_refund_request` is the [RFC 8785][rfc8785] JCS encoding of the - swap request object `{inputs, outputs}`, with each input `Proof` serialized - **without** its `witness` field (the witness carries the signature and cannot - be included in its own preimage). **All `amount` fields are encoded as decimal - strings** (same rule as participant canonicalization). The mint verifies: - current time ≥ `expiry`; signature valid under `refund` public key; swap issues - outputs in an active keyset of the same unit as `offer_keyset`. Otherwise - rejected. - -The `refund` signature owner-gates the reclaim path: without it, any holder of -the bearer proof could refund it to itself. Liveness is preserved: a failed -`/v1/exchange` commits nothing, and the owner reclaims via refund once `expiry` -passes. - -### Fees - -Input fees follow [NUT-02][02] per-keyset rules, computed and rounded **per asset -class** as defined in rule 10. Change outputs are in the offer keyset and are -included in that class's conservation (they reduce the fee-adjusted output to the -counterparty, not the change recipient). - -### Mint info - -```json -{ - "exchange": { - "supported": true, - "version": 1, - "max_participants": "", - "max_inputs": "", - "max_outputs": "", - "max_request_bytes": "", - "idempotent_retries": "", - "max_alt_outputs": "", - "max_expiry_seconds": "" - } -} -``` - -`max_alt_outputs` bounds the number of alternative `H_recv` values per -condition. `max_expiry_seconds` bounds condition lifetime at [NUT-03][03] swap -time. - -## FAQ - -**Why commit to blinded outputs rather than a receive public key?** -A `BlindedMessage` already commits to amount, receive keyset, and the -wallet-chosen blinded point `B_`. Only the wallet that knows the secret and -blinding factor can unblind the signature and build the proof. - -**Can a submitter or coordinator steal the funds?** -No. (1) It receives only the _blinded_ receive messages; without the blinding -factors it cannot unblind signatures. (2) Inputs are locked by `PAY_TO_UNLOCK` -to owner-authorised bundles — including change outputs, which use the owner's -blinding factors. (3) The only theft vector is the refund key; the owner keeps -a fresh one per authorization. - -**How does FAK work?** -Use `alt_outputs` + `allow_change` + `min_output_amount`. One proof authorises a -finite set of output bundles (one per price tick). The coordinator picks the -matching bundle. See [Coordinator-mediated swap](#coordinator-mediated-swap-with-fak-support). - -## References - -- [NUT-02](02.md) · [NUT-03](03.md) · [NUT-06](06.md) · [NUT-07](07.md) · - [NUT-09](09.md) · [NUT-10](10.md) · [NUT-11](11.md) · [NUT-12](12.md) -- [Maurice Herlihy, Atomic Cross-Chain Swaps](https://arxiv.org/abs/1801.09515) -- [Mazumdar et al., Towards Faster Settlement in HTLC-based Cross-Chain - Swaps](https://arxiv.org/abs/2211.15804) - -[00]: 00.md -[02]: 02.md -[03]: 03.md -[06]: 06.md -[07]: 07.md -[09]: 09.md -[10]: 10.md -[11]: 11.md -[12]: 12.md -[rfc8785]: https://www.rfc-editor.org/rfc/rfc8785.html From 9b6b1d871a4a75c1eda8b1e34db0fb2630ac0b69 Mon Sep 17 00:00:00 2001 From: Joe Miyamoto Date: Wed, 29 Jul 2026 22:07:30 +0900 Subject: [PATCH 27/27] Pin canonical serialization; inherit coordinator authentication Canonical: condition_id and parent_collection_id are 64 lowercase hex decoded to 32 raw bytes (omitted parent = 32 zero bytes); pool participant canonical = JCS({inputs,outputs,pool_manifest}) || hex_decode(pool_selection). Coordinator auth inherited from NUT-Exchange with digest domain Cashu/ctf/convert/coordinator; coordinator_pubkey permitted in standard and pool mode; one key per request (v1). --- CTF-settle.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/CTF-settle.md b/CTF-settle.md index 64763be9..238a3adc 100644 --- a/CTF-settle.md +++ b/CTF-settle.md @@ -80,7 +80,7 @@ Additional multi-party rules: 1. `parent_collection_id` MUST be omitted or all-zero. Advertised limits (`max_participants`, `max_inputs`, `max_outputs`, `max_request_bytes`, `max_pool_entries`) MUST be respected. 2. **No attestation recorded** for `condition_id` (error 13042). This check MUST serialise atomically with the convert commit — see [Attestation atomicity](#attestation-atomicity). -3. Every input carries a canonical `PAY_TO_UNLOCK` condition. **Standard participants** use the 3 required tags (`offer_keyset`, `expiry`, `refund`; no optional tags); all inputs in one participant record share the same `H_recv`, `expiry`, `refund`, and `offer_keyset`, with unique per-proof nonces, and each participant's outputs hash to that `H_recv`. **Pool-mode (range-order) participants** add the 4 pool tags (`rate_n`, `rate_d`, `min_receive`, `max_debit`) and set `data = H_manifest` over the domain `Cashu/ctf/convert/manifest`; see [Range orders](#range-orders-partial-fill). In both modes the `offer_keyset` MUST match each proof's actual keyset (`Proof.id`). +3. Every input carries a canonical `PAY_TO_UNLOCK` condition. **Standard participants** use the 3 required tags (`offer_keyset`, `expiry`, `refund`) and optionally the inherited `coordinator_pubkey`; all inputs in one participant record share the same `H_recv`, `expiry`, `refund`, and `offer_keyset`, with unique per-proof nonces, and each participant's outputs hash to that `H_recv`. **Pool-mode (range-order) participants** add the 4 pool tags (`rate_n`, `rate_d`, `min_receive`, `max_debit`) and set `data = H_manifest` over the domain `Cashu/ctf/convert/manifest`; `coordinator_pubkey` is also permitted. See [Range orders](#range-orders-partial-fill). In both modes the `offer_keyset` MUST match each proof's actual keyset (`Proof.id`). 4. Reject if any involved keyset has `input_fee_ppk == 0` unless admission control is in force _(without `F`, anyone can submit unlimited convert requests at no cost — a free-DoS vector)_. 5. **`expiry` MUST precede** the earliest `final_expiry` among all conditional keysets registered under this `condition_id`. If any keyset lacks `final_expiry`, the mint MUST use its advertised `max_expiry_seconds` as the effective bound. Without this, a proof whose `expiry` falls after all same-outcome keysets are deactivated cannot be refunded or redeemed — stranding value permanently. _Known limitation: `expiry` is inside the blinded `Proof.secret` ([NUT-10][10]) and invisible to the mint at [NUT-03][03] swap time; the mint enforces this check at settlement, not creation. Wallets MUST set `expiry` conservatively._ @@ -91,11 +91,14 @@ Optional feature inherited from [NUT-Exchange][exchange] (advertised via `idempo The CTF digest (when supported) commits to **all** semantic top-level fields: ``` -req_canonical = condition_id || parent_collection_id_canonical || participant[0]_canonical || ... || participant[n-1]_canonical +req_canonical = bytes(condition_id, 32) || bytes(parent_collection_id, 32) || participant[0]_canonical || ... || participant[n-1]_canonical request_digest = tagged_hash("Cashu/ctf/convert/request", req_canonical) +coordinator_digest = tagged_hash("Cashu/ctf/convert/coordinator", req_canonical) ``` -where `parent_collection_id_canonical` is the all-zero 32-byte hex if the field is omitted. Each `participant_canonical` is mode-dependent: standard participants canonicalise `inputs` and `outputs`; pool-mode participants additionally include `pool_manifest` and `pool_selection` (as in [NUT-Exchange-partial-fill][partial-fill]), so two requests differing only in manifest or selection cannot alias to one cached response. +`condition_id` and `parent_collection_id` are each exactly 64 lowercase hex chars decoded to 32 raw bytes; an omitted `parent_collection_id` is 32 zero bytes (v1's only permitted explicit value). `participant_canonical` is mode-dependent: standard participants use `JCS({"inputs": ..., "outputs": ...})`; pool-mode participants use `JCS({"inputs": ..., "outputs": ..., "pool_manifest": ...}) || hex_decode(pool_selection)` (as in [NUT-Exchange-partial-fill][partial-fill]). `coordinator_sig` is excluded from `req_canonical`. + +**Coordinator authentication** is inherited from [NUT-Exchange][exchange]: if any input carries `coordinator_pubkey`, the request must carry `coordinator_sig` — a BIP-340 signature valid under the bound key over `coordinator_digest`. Version 1 permits one key per request; `coordinator_sig` must be absent otherwise and is verified before any idempotency-cache hit (error 15015 on failure). `coordinator_pubkey` is permitted in standard and pool mode. ## Range orders (partial fill)