Skip to content

NUT-Exchange: Atomic Multi-Asset Exchange - #410

Open
joemphilips wants to merge 17 commits into
cashubtc:mainfrom
joemphilips:nut-atomic-exchange
Open

NUT-Exchange: Atomic Multi-Asset Exchange#410
joemphilips wants to merge 17 commits into
cashubtc:mainfrom
joemphilips:nut-atomic-exchange

Conversation

@joemphilips

Copy link
Copy Markdown

Adds an optional NUT for an atomic exchange of two existing Cashu asset classes at one mint. Participants lock bearer proofs to exact blinded receive outputs via a new NUT-10 PAY_TO_UNLOCK condition; the mint validates the conditions, conserves each asset class independently, and commits all input spends and output signatures in one database transaction — or nothing.

Context. This branched out of ongoing work on prediction markets with Cashu (see #337). The key observation: we do not need HTLC-based atomic swaps — genuinely troublesome, as anyone who has dealt with Lightning knows — like we usually reach for in Bitcoin, when both assets are managed by a single mint, because Cashu has a different trust model. A Cashu mint already validates proofs, tracks spentness, and issues blind signatures, so it can serve directly as the atomic settlement layer. This removes the interactive claim sequence, the both-wallets-online requirement, and the free-option locktime race of a client-to-client swap.

Custody is enforced by blind signatures plus the condition binding (any submitter/relay cannot steal or redirect); recovery via NUT-09; post-expiry refund authorized by a signed refund key. Trust floor matches NUT-11 P2PK plus one atomic commit; no transparency layer.

Draft for discussion — final NUT number/name, endpoint, and fee policy are open.

@joemphilips
joemphilips force-pushed the nut-atomic-exchange branch from 4b6a0cc to f579c33 Compare July 23, 2026 01:52
@joemphilips
joemphilips marked this pull request as ready for review July 23, 2026 01:54
@joemphilips

Copy link
Copy Markdown
Author

Manual self-review done. Open for discussion

- Two preparation patterns: a direct two-party swap (both online) and a
  coordinator-mediated swap (relay assembles; an offline FOK taker plus online
  makers/FAK).
- Remove swap_id entirely: request_digest over canonical participant records is
  the sole idempotency/replay key; the request body is {"participants":[...]}.
- Remove expiry: reclaim is available anytime via the refund key, so no safety
  property needs a deadline; order validity/staleness is the coordinator's
  concern and a keyset's lifecycle bounds the rest.
- refund is the sole owner-authorization key (settlement is keyless/structural);
  document why it cannot be omitted (bearer reclaim must be owner-gated).
- Be explicit about participation: only an exact-fill (FOK) order can be
  pre-authorized and go offline; FAK and resting makers must be online at match
  (blinding-budget lemma).
- error_codes: drop 15002, 15005, 15006; fix 15010 wording.
Refund-anytime made a conditioned proof a revocable offer rather than a
commitment, and reintroduced the free cancellation option (retract when price
turns against the committer) that this NUT exists to eliminate — structurally
the same regression as the adaptor-sig leader's locktime race.

- Add a required `expiry` tag to PAY_TO_UNLOCK, checked against the mint clock.
- Settlement valid only before `expiry`; refund valid only after (non-overlapping
  states, no boundary race). This makes the commitment binding for its window.
- Validation rule 11 enforces settlement-before-expiry; the refund section
  requires refund-after-expiry.
- Mint info: advertise `max_expiry_seconds`.
- error_codes: restore 15005 (settlement after expiry) and 15006 (refund before
  expiry).

Concerns on unit and exact-amount commitment need no change: a keyset id is
derived over its unit (NUT-02), so offer_keyset/receive_keyset already bind the
asset; the offered amount is committed by the input proof amounts and the
received amount by H_recv, linked by per-class conservation.
User-reviewed revision (notes 1-20) plus adversarial-review fixes:

Applied user notes:

- remove redundant 'This NUT transfers...' paragraph (keep provisional-name caveat on status line)

- rewrite 'matched against several counterparties' as a peer-to-peer batch limitation

- delete the smart-contract-vs-HTLC analogy paragraph

- rename 'star' to two-class throughout; rule 8 already permits any N-vs-M shape

- replace FOK/FAK framing with the multi-lot pattern (each lot is its own participant record; submitter includes only matched lots; unused lots refund after their own expiry)

- remove the blinding-budget lemma

- shorten Recovery to two sentences (owner, not submitter, recovers)

- rewrite Refund section; drop 'non-overlap' wording

- remove 'Why the refund key is required' paragraph

- remove Coordinator and relay role section (fold one sentence into Model)

- remove Resting-order optionality section entirely

- remove seven redundant FAQ entries

Adversarial-review fixes (codex-rescue):

- fix multiset-equality bug in PAY_TO_UNLOCK condition prose that rejected valid 1-vs-N and N-vs-M shapes (use set equality)

- define canonical_swap_request as JCS of {inputs, outputs}; specify Proof.witness as a single BIP-340 Schnorr signature over refund_digest

- refund may issue in any active keyset of the same unit, not strictly the same keyset ID (handles keyset rotation)

- enforce max_expiry_seconds at NUT-03 swap time (reject conditions with expiry > now + max_expiry_seconds)

- rule 1: each participant record must have at least one input and one output

- Canonical encodings: four condition tags each appear exactly once; expiry is decimal unix seconds

- amounts are unsigned 64-bit; rule 10 uses checked non-wrapping arithmetic

- clarify uint32_le(len) in H_recv as entry count, not byte length

- tighten premise claim to locktime free option only (no orderbook discussion)

- soften 'indiscriminate betrayal' privacy claim

- fix typos: 'settellable', lowercase 'see'
receive_keyset was authenticated inside H_recv via each BlindedMessage.id.

The tag carried zero information not already present in the output bundle.

Adversarially reviewed by codex (gpt-5.6-sol, xhigh): receive_keyset is

safe to remove; offer_keyset is kept because it is inside the signed

Proof.secret and prevents keyset-ID relabeling when verification keys

are reused across keysets (which NUT-02 does not forbid).

Condition now has 3 tags: offer_keyset, expiry, refund (was 4).

Rules 7 and 8 updated to derive the receive keyset from output IDs.
Idempotent retries via request_digest are now an optional mint feature,

advertised via idempotent_retries in NUT-06 info. Clients fall back to

NUT-09 recovery when not supported, same as NUT-03 swap.
joemphilips added a commit to joemphilips/nuts that referenced this pull request Jul 26, 2026
These files are needed because CTF-settle depends on NUT-Exchange

concepts (PAY_TO_UNLOCK, H_recv, participants request shape).
…fixes

Track 1 - Fix P0 refund bugs:

- refund preimage excludes Proof.witness (was self-referential)

- unified refund domain (Cashu/PAY_TO_UNLOCK/refund)

Track 2 - Relax rules 7-8 for change outputs:

- allow_change tag authorises outputs in offer keyset

- rule 7 revised: outputs may use receive or offer keyset

- rule 8 unchanged (still exactly two keysets)

Track 3 - FAK support:

- alt_outputs tag: finite set of alternative H_recv values

- min_output_amount tag: minimum receive floor

- one proof, one swap, tick-level granularity

Track 4 - Other fixes:

- rule 3: three required tags + optional tags (was four)

- rule 5: offer_keyset == Proof.id check

- rule 12: min_output_amount enforcement

- max_alt_outputs in mint info
Synced from nut-ctf-settle. P0-1: decimal-string amounts.

P0-2: unique per-proof nonce. P1-8/P1-11 fixes.
joemphilips added a commit to joemphilips/nuts that referenced this pull request Jul 27, 2026
@joemphilips
joemphilips force-pushed the nut-atomic-exchange branch from 5b09b8c to 79893ff Compare July 27, 2026 04:50
New NUT extending PAY_TO_UNLOCK with binary output pools and a

mint-enforced rate covenant. Enables FAK orders where the actual fill

amount is determined at match time.

Design based on consultation with codex (gpt-5.6-sol, xhigh).

Key mechanism: O(log N) BlindedMessages in powers-of-2 denominations,

one manifest hash, one cross-multiply policy check. Coordinator selects

any subset satisfying the rate inequality; better prices auto-admitted.
… pool fields

- intro: order-type-agnostic (FAK only in FAQ)

- powers-of-2: mandatory, not recommended (Cashu convention)

- pool_manifest/pool_selection: full PoolEntry schema + bitmap example

- add NUT-00 link reference
P0-1: rate encoding in raw minor units (rate_n=1, rate_d=1)

P0-2: two-class invariant restored (exactly two keysets)

P0-3: rate_d > 0, checked u128 arithmetic, reject change > input

P0-4: powers-of-2 requires keyset inspection (NUT-01 dependency added)

P0-5: nonce contradiction fixed (same data/tags, unique nonce)

User notes:

- pool_selection: unselected entries discarded, not swapped

- PoolEntry: single name, defined once

- data size: full manifest for v1, Merkle reserved for future

- example: integer cents (10000 cents = 100 USD)

- boundary check: 500 sats for 500 cents (exact limit, 499 rejected)

- better price: 237 sats for 200 cents (strict inequality)

- alt_outputs FAQ removed (undefined)
- Register pool-mode errors 15011-15014 (6p's bogus 13041 -> 15011; 7p-9p now coded; 10p reuses 11005)

- Repair worked example (Repair B): rate_n=1 rate_d=10, max_debit=10000; fix selected-entry counts (boundary 11, better-price 12)

- Canonicalize PoolEntry amount/index as decimal strings (IEEE-754 safety above 2^53)

- Clarify debit_total is gross; rule 10 burns input_fees per class; examples assume zero fees

- Define canonical pool_selection bitmap (ceil(n/8) bytes, lowercase hex, reject other lengths)

- Require NUT-09 recovery over full manifest before discarding unselected entries (settlement-loss safety)

- Allow mixed pool/standard participants; rule 8p derives receive keyset per mode

- Forbid base optional tags (alt_outputs/allow_change/min_output_amount) in pool mode; min_receive supersedes rule 12

- Reframe powers-of-2 as wallet construction; mint validates per-entry keys only

- Include pool_manifest/pool_selection in idempotent request_digest

- Drop redundant 'at least one receive entry selected' (implied by min_receive>=1)
joemphilips added a commit to joemphilips/nuts that referenced this pull request Jul 27, 2026
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 cashubtc#410; remove local exchange.md and exchange-partial-fill.md placeholders; depends on now lists NUT-Exchange and NUT-Exchange-partial-fill
Coordinator auth (optional, v1): a PAY_TO_UNLOCK proof may carry coordinator_pubkey (BIP-340 x-only key); if any input does, the request must carry coordinator_sig, a BIP-340 signature over tagged_hash(Cashu/PAY_TO_UNLOCK/coordinator, req_canonical). One key per request (v1); coordinator_sig must be absent otherwise; verified before any idempotency-cache hit. Prevents a user double-submitting the same authorization to two coordinators; NUT-07 stays advisory and atomic spent/pending checks remain.

Canonical: pool participant canonical = JCS({inputs,outputs,pool_manifest}) || hex_decode(pool_selection). New error 15015 for coordinator-auth failures.
joemphilips added a commit to joemphilips/nuts that referenced this pull request Aug 1, 2026
Extension of both NUT-Exchange (cashubtc#410) and NUT-CTF-split-merge (cashubtc#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
joemphilips added a commit to joemphilips/nuts that referenced this pull request Aug 1, 2026
These files are needed because CTF-settle depends on NUT-Exchange

concepts (PAY_TO_UNLOCK, H_recv, participants request shape).
joemphilips added a commit to joemphilips/nuts that referenced this pull request Aug 1, 2026
joemphilips added a commit to joemphilips/nuts that referenced this pull request Aug 1, 2026
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 cashubtc#410; remove local exchange.md and exchange-partial-fill.md placeholders; depends on now lists NUT-Exchange and NUT-Exchange-partial-fill
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

1 participant