feat: NUT-CTF conditional tokens for prediction markets - #1666
Draft
joemphilips wants to merge 55 commits into
Draft
feat: NUT-CTF conditional tokens for prediction markets#1666joemphilips wants to merge 55 commits into
joemphilips wants to merge 55 commits into
Conversation
joemphilips
force-pushed
the
ctf
branch
2 times, most recently
from
February 28, 2026 10:01
175bfa9 to
f713358
Compare
Implements the NUT-CTF specification for conditional tokens using DLC oracles. - Condition registration with NIP-88 tags metadata - Conditional keyset derivation per outcome - CTF split/merge operations - Outcome redemption with oracle attestation verification - Numeric outcome markets with boundary keysets - Multi-oracle threshold attestation support - SQL migrations for conditions, partitions, and attestation storage - Conditional-tokens feature flag gating all CTF code
Cargo.toml lists bindings/{dart,swift,kotlin}/rust as workspace members but the Dockerfile never copied the bindings directory, causing the Docker build to fail.
Required for docker-compose healthcheck (curl -f http://localhost:8085/v1/info).
Conditional keysets were being stored in the shared `keyset` table, which caused `reload_keys_from_db`'s `HashMap<CurrencyUnit, Id>` collapse to randomly mark a conditional keyset as the 'primary active sat', breaking wallet keychain binding over `/v1/keys`.
- Move conditional keysets to a new `conditional_keyset` table with a 'one active per outcome_collection_id' unique partial index. The primary `keyset` table keeps its 'one active per unit' invariant.
- Hide conditional keysets from the public NUT-01/NUT-02 list endpoints (`/v1/keys`, `/v1/keysets`). Per-ID lookups (`/v1/keys/{id}`) stay open so wallets holding a conditional token can still fetch its keys.
- Replace `SignatoryKeySet::is_conditional` with `condition_id: Option<String>` propagated from `MintKeySetInfo`, so the filter uses the DLC binding directly.
- Insert new conditional keysets directly into the in-memory signing map instead of full-DB reloads, removing O(N\xc2\xb2) behavior in `register_partition`.
- Share SQL row parsing and cursor-pagination helpers between the public NUT-CTF listing endpoint and the internal reload path, fix `>=` vs `>` on the `since` cursor, and add a `created_at` index on `conditional_keyset`.
The original 20260216 migration was modified in-place (4d958587) to rename conditional_keysets -> conditional_keyset with a new schema. Existing deployments that ran the old version skip the migration by name, leaving the new table missing. This adds a separate migration that drops the old table and creates the correct one.
The original 20260216 migration created a description column but the current code expects tags_json. Add column, migrate existing data into NIP-88 tag arrays, and keep description for safe rollback.
The tags_json ALTER TABLE was included in 20260420000000 which had already been applied. Split into a separate 20260420010000 migration so it runs on existing deployments.
The CDK SQL parser treats : as placeholder prefix, so :: in Postgres type cast syntax triggers InvalidPlaceholder error.
Fresh DBs already have tags_json from the base migration. Guard the ALTER TABLE with IF NOT EXISTS (postgres) and make SQLite a no-op.
The DLC spec signs over raw Writeable::write() bytes, not TLV-wrapped bytes. verify_announcement_signature() was hashing full write_as_tlv() output (including BigSize type+length prefix), causing verification to fail for real oracle announcements. Tests passed because both signing and verifying used the same wrong bytes. Fix: delegate to announcement.validate(&secp) which uses correct serialization. Update test_helpers to strip TLV prefix before signing so test announcements produce spec-compliant signatures.
Add Conditional Tokens, CTF Split/Merge, and CTF Numeric to the supported features section on the mint HTML index page when the conditional-tokens feature flag is enabled.
Verify inputs and balance before record_attestation in both enum and numeric redemption paths. Pre-fix, any party with a valid public oracle witness could permanently set winning_outcome on a condition without holding real conditional proofs, locking out all losing-side holders. Also: cap OracleWitness.oracle_sigs at MAX_ORACLE_WITNESS_SIGS to bound per-entry work; derive is_root in CTF split/merge from the partition matching the request keys instead of any() over all partitions; sort announcement/tag arrays in register_condition idempotency check to match condition_id's order-insensitive semantics; add (attestation_status, created_at) composite index for paginated condition queries. Adds test_failed_redeem_does_not_persist_attestation as a regression guard.
Checkpoint of in-progress P19 atomic-swap CTF work for remote planning visibility.
Support CDK_MINTD_LOGGING_FORMAT=text|json, keep text as the default, and honor RUST_LOG before falling back to the existing daemon filters.
Add debug context when the mint rejects conditional swaps because inputs or outputs do not share the same condition outcome keyset.
Implement the NUT-CTF convert endpoint, canonical collection ordering, updated errors, and nested redemption validation.
…d-median aggregation
Crates touched: cashu, cdk-exchange-rate, cdk-postgres. Public types/traits introduced: RateConvertingPayment, RateConvertingPaymentConfig, RateConvertingPaymentError, RateQuoteStore, DynRateQuoteStore, RateQuoteRecord, ParkedPaymentRecord, RateQuoteStoreError, InMemoryRateQuoteStore, PostgresRateQuoteStore. Tests added: payment::tests::sats_for_fiat_applies_ceil_and_buffer, payment::tests::snapshot_json_contains_stored_terms. Verification: cargo build -j 7 -p cashu -p cdk-exchange-rate -p cdk-postgres; cargo test -j 7 -p cashu -p cdk-exchange-rate -p cdk-postgres; cargo clippy -p cashu -p cdk-exchange-rate -p cdk-postgres -- -D warnings. ADR deviation: Postgres store follows the repository's existing tokio-postgres/cdk-sql-common storage pattern instead of adding sqlx.
Crates touched: cdk-exchange-rate. Public types/traits introduced: DEFAULT_RATE_QUOTE_TTL_SECS and Default for RateConvertingPaymentConfig. Tests added: payment::tests::config_defaults_to_120_second_ttl, payment::tests::effective_expiry_uses_decorator_ttl. Verification before commit: cargo build -j 7 -p cdk-exchange-rate; cargo test -j 7 -p cdk-exchange-rate; cargo clippy -p cdk-exchange-rate -- -D warnings. ADR deviation: none.
Crates touched: cdk-exchange-rate and cdk-mint-rpc. Public types/traits introduced: RateQuoteControlHandle, UnitQuoteState, MintRPCServer::with_rate_quote_control, SetUnitQuoteState RPC messages, SetUnitIssuanceCap RPC messages. Tests added: payment::tests::quote_control_rejects_paused_mint_unit, payment::tests::quote_control_reserves_against_cap, payment::tests::quote_control_releases_reservation_after_expiry. Verification before commit: cargo build -j 7 -p cdk-exchange-rate -p cdk-mint-rpc; cargo test -j 7 -p cdk-exchange-rate -p cdk-mint-rpc; cargo clippy -p cdk-exchange-rate -p cdk-mint-rpc -- -D warnings. ADR deviation: RPC handlers are wired to an optional shared decorator control handle; mintd config installs the handle in the following rate_quoter wiring commit.
Crates touched: cdk-exchange-rate and cdk-mintd. Public types/traits introduced: PaymentErrorAdapter, SharedMintPayment, config::RateQuoter. Tests added: config::tests::test_rate_quoter_config_parse. Verification before commit: cargo build -j 7 -p cdk-exchange-rate -p cdk-mintd; cargo test -j 7 -p cdk-exchange-rate -p cdk-mintd; cargo clippy -p cdk-exchange-rate -p cdk-mintd -- -D warnings. ADR deviation: rate_quoter accepts source identifiers or URLs for the built-in Coinbase/Kraken/Bitstamp sources; unrecognized URLs fail closed. SQLite mintd uses the in-memory quote store while Postgres mintd uses PostgresRateQuoteStore.
Crates touched: cdk-exchange-rate. Public types/traits introduced: parked_payment_event_count. Tests added: usd_mint_quote_persists_snapshot_and_credits_quoted_usd, late_payment_uses_stored_terms_after_expiry, parked_payment_suppresses_upstream_event_after_quote_store_failure, cap_reservation_rejects_then_releases_after_expiry, pause_state_blocks_and_unblocks_mint_quotes, forced_melt_failure_releases_proof_reservation (ignored). Verification before commit: cargo build -j 7 -p cdk-exchange-rate; cargo test -j 7 -p cdk-exchange-rate; cargo clippy -p cdk-exchange-rate -- -D warnings. ADR deviation: forced melt failure proof-release remains an ignored WS6.6 skeleton because it needs full mintd wiring.
…ring conversions B2: every oracle rate is u64 sats per WHOLE fiat unit; quote amounts stay in minor subunits and conversions divide by fiat_subunit_scale (USD=100). Renames the snapshot JSON field to aggregated_rate_sats_per_fiat_unit and adds REAL-semantics tests (100-cent quote at 1,000 sats/USD with 100 bps buffer invoices 1,010 sats). B3: melt quotes convert the bolt11 SAT amount into fiat subunits in the mint-favoring direction: ceil(sats x scale x (1+buffer) / rate); fees are converted the same way and persisted as fiat_fee_subunits. M1/M4 defaults: rate-quote TTL default 90s; buffer default 100 bps. M7: all money math is u128 multiply-then-divide with explicit ceil per path; integer decimal parsing of source BTC/fiat prices (no f64); Postgres binds use checked u64->i64 conversions. Minors: true trimmed median of survivors with a documented floor rule for even counts (mint-favoring); single-flight cache refresh so concurrent snapshot calls do not fan out duplicate source fetches.
M3: new RateQuoteStore::park_or_credit looks up quoted terms for a received payment and parks the payment in the same store operation when none exist - one transaction in cdk-postgres, one lock scope in the in-memory store - so the missing-record detection and the parked write can never diverge. M5 groundwork: per-unit control state (mint/melt pause, issuance cap, outstanding issued counter, buffer-surplus reserve) is persisted via new store ops with atomic SQL increments and loaded back through load_unit_controls. Backed by a new rate_unit_control table. M4 groundwork: quote records carry sats_unbuffered so the settle path can book the buffer portion of each paid quote to the persisted per-unit surplus-reserve counter (reserve, not revenue). B4 groundwork: mark_settled flips a settled flag exactly once per lookup id, gating one-shot outstanding/surplus counter adjustments against double-apply from the event stream and check_incoming_payment_status.
…h-safe reservation ordering B4: the per-unit cap now covers persisted outstanding issuance (issued minus melted) plus pending unexpired reservations plus the new request. Mint credits grow the outstanding counter exactly once via the store's one-shot mark_settled gate; Paid melts shrink it by amount plus fee. B5: a cap of 0 (including the never-configured default) refuses all new mint quotes. Zero is fail-closed, never unlimited; operators opt in to headroom explicitly. M2: cap headroom is reserved BEFORE the inner invoice is created, under a provisional key that is rekeyed to the inner lookup id on success and released on every failure path (inner-invoice failure, terms-persistence failure). Terms still persist before the quote returns upstream. Reservation expiry is now a lazy sweep instead of spawned timers, so rekeying cannot orphan a reservation. M3: the payment-event interceptor uses the store's atomic park_or_credit, so missing-record detection and the parked write can no longer race or diverge. M4: the buffer portion of each paid mint quote (received sats minus the unbuffered sat cost) is booked to the persisted per-unit buffer-surplus reserve counter, observable separately from revenue via the control handle and store. M5: the control handle write-throughs pause/cap changes to the store and reloads pause/cap/outstanding/surplus via load_persisted; management RPC setters surface persistence failures as gRPC errors. M8: decorator-level forced-melt-failure test asserts the Failed status propagates and outstanding liability is untouched; the full-stack PENDING->UNPAID proof-release assert stays an ignored stub citing the ADR-023 WS6 verification duty.
… reload, and TLS fail-closed startup B1: rate_quoter.quorum splits into min_fetched (default 3) and min_survived (default 2) wired to the aggregator's min_sources/min_survived; quorum stays accepted as a serde alias for min_fetched and min_survived > min_fetched is rejected at startup. M1: rate_quoter.ttl_secs outside the 60-120 second range fails startup; the rate snapshot is part of the quoted terms so quotes must expire before it goes materially stale. M5: mintd builds the control handle over the persisted quote store and reloads pause/cap/outstanding state at startup; config per_unit_caps only seed units with no persisted control record so RPC-set operator values win across restarts. M6: when fiat rate-quoted units are enabled and the management RPC's TLS directory is missing, mintd refuses to start with a clear error instead of silently serving the pause/cap control plane unencrypted.
Exercises the companion-table migration, quote-terms round trip (including sats_unbuffered and fee subunits), transactional park_or_credit on both branches, one-shot mark_settled semantics, and unit-control persistence with floored outstanding subtraction. Follows the existing cdk-postgres convention: per-test isolated schema against CDK_MINTD_DATABASE_URL/PG_DB_URL or the default local dev database.
Settle quotes and unit-control counters through one store operation so restart accounting cannot reopen issuance headroom after a crash.
Exercise a USD rate-converted mint through the real wallet/mint path and assert failed melts release proofs and preserve outstanding liability.
Adds test_verify_csharp_fixture_attestation_parity to dlc.rs tests. Uses the same DDK/kormir-produced (P, R, outcome="Yes", sig) vector as DlcAttestationParityFixtureTests in BitCaster.MatchingEngine.Unit. Both sides verify the tagged_hash("DLC/oracle/attestation/v0", outcome) + BIP-340 Schnorr path must accept exactly the same attestation bytes.
ADR-023 mandates rejecting nonzero production USD caps when USD buffer configuration is absent. Enforce that invariant for configured caps, persisted caps loaded at startup, and management RPC cap changes. Refuse silent volatile rate-quote store fallback for fiat rate quoting unless rate_quoter.allow_in_memory_store is explicitly set, so quote terms, pause state, parked payments, and cap counters do not fail open across restarts. Tests: cargo build -p cdk-mintd -p cdk-exchange-rate -j 7; cargo test -p cdk-exchange-rate -p cdk-mintd -j 7; cargo test -p cdk-mint-rpc -j 7.
Without COPY Cargo.lock, the nix/cargo build regenerates the lock file and may resolve a newer time crate version (0.3.47+) that has an E0119 conflicting-impl error for HourBase in cdk-common. Without flake.lock, nix develop may fetch a different rust-overlay revision. Adding --locked enforces the committed Cargo.lock version, preventing this class of dependency drift in CI docker builds.
Persist the registered collateral unit with conditions and include it in condition info responses so matching-engine USD registration can fail closed on missing or mismatched mint collateral. Add regression coverage for USD collateral echo and legacy stored conditions without collateral.
Support milli-cent fiat scaling, add an msat/sat payment decorator, and register msat processors for sat backends.
The registration fee config values are in the base asset (sat/cent) but proof amounts are in the collateral unit (msat/milli-cent). Without scaling, the mint compares 3 (base fee) against 3000 (msat proofs), causing incorrect change output calculation and 'Insufficient or invalid registration fee change outputs' error.
The auth feature was removed in upstream commit 1264a9f (cashubtc#1599) but CTF commits reintroduced #[cfg(feature = "auth")] guards incorrectly. Remove the cfg blocks; auth token path now always runs, get_auth_token returns None when no auth is configured.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Reference implementation of NUT-CTF: Conditional Tokens for Prediction Markets.
This is a work-in-progress draft. More details to follow as the spec stabilizes.