Skip to content
a1denvalu3Public

About

ecash, but with no custodian.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

tmp.cash

An experimental Python mint and wallet for denominated tokens backed by held Lightning payments. Credentials use Pointcheval–Sanders (PS) signatures on BLS12-381. A token can split into smaller denominations and transfer privately while its original HTLC remains held. A Lightning melt can spend tokens from several HTLCs, including user-funded routing fees and returned change.

Tokens expire with their backing. Settlement, cancellation, or timeout ends the claim. Holders must redeem before the published redemption cutoff. There are no user balances or continuing redemption rights after settlement.

The offline protocol guide illustrates the v3 flow.

The web wallet uses local modified Coco and cashu-ts workspaces for this protocol. Run cd web-wallet && npm ci && npm run dev and open http://127.0.0.1:5173 for the explainer, then choose Open wallet (or visit http://127.0.0.1:5173/wallet/ directly) with your mint running. It supports HODL funding, private token transfers, combined Lightning melts, expiry tracking, encrypted backups, and retries after a reload. Mint endpoints enable browser CORS.

The lifecycle

  1. The payer chooses a 32-byte preimage P and supplies H = SHA256(P) and an amount A. The mint creates a HODL invoice. Once fully accepted, it signs denomination tokens whose total equals A. Issuance does not reveal P.
  2. The signed backing attribute is h = HashToFr(domain || A || H). Every fragment retains this original HTLC amount, independently of its own denomination. One keyset contains independent PS keys for 1, 2, 4, 8, ... sats, through the supported monetary limit.
  3. A swap has many inputs and many outputs, all sharing one hidden h. Public denomination values must sum equally. A single common-witness proof establishes equality across every input presentation and output commitment. Swaps can split, combine, or refresh fragments of one HTLC; they cannot move value between different backing hashes.
  4. A melt can combine denominations from multiple HTLCs to pay one ordinary BOLT11 invoice. It reveals each backing preimage, reserves every input nullifier, and pays from the mint's outgoing liquidity. The receiver invoice has its own independent payment hash.
  5. The mint charges invoice value plus the actual routing fee, rounded up from millisatoshis to whole sats. It returns unused fee reserve and any additional input value as denomination tokens tied to their original HTLCs.
  6. A partial melt leaves the original hold pending. The mint settles a known preimage when the entire HTLC value has been consumed including fees, or at the published cutoff near the node's hold-cancellation deadline. Any outstanding fragments then expire. With no revealed preimage, the mint cannot settle; the hold eventually times out and tokens lose their backing.

For example, a 1,000-sat hold can become Alice's 990 sats and Bob's 10 sats. Bob can pay a 9-sat invoice with a 1-sat fee reserve. If routing is free, he receives 1 sat of change and the hold remains pending. At scheduled settlement, Alice's 990 sats and Bob's 1 sat expire if still outstanding.

This intentionally forfeits unredeemed token value at settlement. Collecting an HTLC collects its full amount, even if only a fraction was melted. The mint does not turn the remainder into user accounts or an ongoing ecash liability. A first revealed preimage also gives the mint the technical ability to settle early; delaying settlement is mint policy. This remains a trusted, experimental protocol, not a cryptographic guarantee against a dishonest mint.

Run the demo

poetry run tmp.cash-mint logs the selected backend's balances during startup. Spark reports its SDK wallet balance; LND reports local and remote channel balances, unsettled channel funds, and confirmed and unconfirmed on-chain funds separately. Held backing payments are distinct from these operator balances. The fake backend has no real wallet balance. If the lookup fails or exceeds 15 seconds, the mint logs that balances are unavailable and continues starting.

Requires Python 3.10+ and Poetry:

poetry install
make demo

The project is tmp.cash. The installed commands are tmp.cash for the wallet and tmp.cash-mint for the server; mint remains a short server alias. Python integrations import tmp_cash. The browser workspaces expose this protocol through their tmp-cash subpaths and TmpCash types.

Mint configuration uses TMP_CASH_* variables. Existing CASHU_* settings remain accepted for upgrades. Environment variables override .env; within each source, the new names take precedence. Keep the existing data directory and mint seed when upgrading an existing v3 installation.

The rebrand preserves v3 token encodings, cryptographic domains, browser storage identifiers, and encrypted backup formats so existing funds and backups remain readable. Vendored Cashu/Coco package names, upstream attribution, and external cashu-regtest fixture names retain their original names. When moving the web wallet to a different origin, export an encrypted backup from the old origin and restore it in the new one; browser storage does not transfer between origins.

The persistent fake backend runs in a temporary directory. The demo mints 1,000 sats, sends exactly 10 through a private split, pays 9 sats, returns unused fee reserve, and settles at the cutoff so remaining tokens expire. No Lightning node or server is needed.

Run a mint

poetry run tmp.cash-mint --backend fake --data data/mint-v3

For manual testing with the web wallet, enable automatic fake funding:

poetry run tmp.cash-mint --backend fake --data data/local-fake --fake-auto-accept

This accepts each open, unexpired fake funding invoice when it is created or checked, including existing waiting invoices after a restart with the option enabled. The web wallet can mint ecash automatically on its next payment check. The hold stays ACCEPTED; normal settlement and expiry still apply. Canceled, expired, and settled invoices stay closed. It uses simulated sats throughout.

You can also set TMP_CASH_FAKE_AUTO_ACCEPT=true in .env or the environment. --no-fake-auto-accept overrides that setting. The default is off, retaining explicit tmp.cash dev-accept control for tests of unpaid invoices and delayed payments. This setting requires the fake backend.

For LND, copy .env.example to .env, configure the node's REST endpoint, TLS certificate and macaroon, and choose a new v3 mint directory:

poetry run tmp.cash-mint --backend lnd --data data/lnd-v3 \
  --lnd-endpoint https://127.0.0.1:8081 \
  --lnd-cert /path/to/tls.cert \
  --lnd-macaroon /path/to/admin.macaroon \
  --fee-limit-sat 10 \
  --lnd-hold-expiry-delta 18 \
  --lnd-token-lifetime-blocks 2016 \
  --settle-margin-blocks 6

TLS verification is enabled. The mint loads .env in the current directory; environment variables override the file and CLI arguments override both. Run one server process per database and retain its seed across restarts. The mint needs outgoing liquidity for payments while its incoming holds remain pending.

Exactly one mint process may use a database: at startup the mint takes an exclusive flock on mint.sqlite3.lock next to the database and keeps it for the process lifetime. A second process fails immediately with "database is already in use by another mint process"; stop the other mint instead of pointing it at a new database. Single-process ownership is what allows the mint to clear orphaned melt executor locks when it first acquires the database; reopening the same database inside a running process never clears live locks.

New LND funding invoices request a redeemable lifetime of 2,016 blocks by default, approximately two weeks at Bitcoin's average block interval. Configure it with TMP_CASH_LND_TOKEN_LIFETIME_BLOCKS in .env or --lnd-token-lifetime-blocks. The mint adds the node cancellation delta and its settlement margin to the invoice's final-hop CLTV delta: defaults request 2,040 blocks. The invoice's payment window remains separate (one hour by default). Existing HTLCs keep their original deadlines. Regtest blocks advance when mined, so this is a block lifetime rather than a two-week wall-clock timer.

GET /v1/info exposes default_token_lifetime_blocks and funding_htlc_cltv_delta; the wallet shows the mint's policy in Settings. These fields are null for backends without a configured block lifetime. The accepted HTLC's observed deadline remains authoritative. The total requested CLTV delta cannot exceed 65,535 blocks. Payers and forwarding nodes must allow a route timelock longer than the final-hop delta. LND's default max-cltv-expiry is 2,016 blocks, too short for this policy; set max-cltv-expiry=4096 in the payer's lnd.conf, restart that node, and use payinvoice --cltv_limit 4096. Some routes or wallets may refuse this longer hold. The mint does not shorten the requested lifetime to bypass those limits.

--lnd-hold-expiry-delta must match the node's invoices.holdexpirydelta. LND can cancel accepted holds before their CLTV expiry. The redemption cutoff is earliest accepted HTLC expiry − node cancellation delta − settlement margin; multipart payments use the earliest accepted part. The default settlement margin is 6 blocks before cancellation. The fake backend uses a 120-second margin before its wall-clock expiry (--settle-margin-seconds). Choose margins for your node's operating conditions. The background worker runs every 5 seconds; it cannot settle through a node outage or while stopped.

Local LND regtest with the web wallet

Use the existing funded ~/cashu-regtest network. lnd-1 pays the backing hold, lnd-3 backs the mint through REST on port 8081, and lnd-1 receives melts. To resume stopped nodes without resetting their data:

cd ~/cashu-regtest
docker compose -p cashu up -d bitcoind lnd-1 lnd-2 lnd-3
source ./docker-scripts.sh
bitcoin-cli-sim -generate 1
cashu-lightning-sync

A fresh block lets LND report synced again after the regtest chain has been idle. Mining advances backing deadlines, so use one block for this check.

The direct payer lnd-1 needs this setting in ~/cashu-regtest/data/lnd-1/lnd.conf (under [Application Options]):

max-cltv-expiry=4096

After adding it, run docker compose -p cashu restart lnd-1 and wait for lncli-sim 1 getinfo to report synced. The current local payer is configured.

The mint's .env should contain:

TMP_CASH_MINT_BACKEND=lnd
TMP_CASH_MINT_DATA=data/lnd-v3
TMP_CASH_LND_ENDPOINT=https://localhost:8081
TMP_CASH_LND_CERT=data/lnd-manual/tls.cert
TMP_CASH_LND_MACAROON=data/lnd-manual/admin.macaroon
TMP_CASH_LND_HOLD_EXPIRY_DELTA=18
TMP_CASH_LND_TOKEN_LIFETIME_BLOCKS=2016

The local setup already has those credential files. If they are missing or the regtest node has been recreated, copy them from the current mint node:

cd ~/tmp.cash
mkdir -p data/lnd-manual
docker cp cashu-lnd-3-1:/root/.lnd/tls.cert data/lnd-manual/tls.cert
docker cp cashu-lnd-3-1:/root/.lnd/data/chain/bitcoin/regtest/admin.macaroon data/lnd-manual/admin.macaroon
chmod 600 data/lnd-manual/admin.macaroon

A fixture reset can replace LND's TLS certificate, macaroon and node identity. Stale credentials make POST /v1/htlcs/check return 503 even while /v1/keys returns 200. After a reset, refresh the credentials and restart the mint. If the node identity changed, its old invoices are gone: retain the old mint directory and seed, then set TMP_CASH_MINT_DATA to a fresh directory for the new node. Old tokens lose their backing. Reload the wallet so it loads the new keyset; closed claims remain in its history as expired. Reapply the payer's max-cltv-expiry=4096 setting if its node configuration was also reset.

Start the mint and wallet in separate terminals:

cd ~/tmp.cash
poetry run tmp.cash-mint
cd ~/tmp.cash/web-wallet
npm run dev

Open http://127.0.0.1:5173 and connect to http://127.0.0.1:3338 in Settings. Choose Add sats, enter an amount such as 1,000, and copy the funding invoice. Pay it from the regtest payer:

cd ~/cashu-regtest
source ./docker-scripts.sh
lncli-sim 1 payinvoice --force --cltv_limit 4096 '<funding invoice>'

This terminal remains pending while the HTLC is held. The web wallet credits ecash once funding is accepted, before the original Lightning payment settles. Leave that terminal open and create a payout invoice in another terminal:

cd ~/cashu-regtest
source ./docker-scripts.sh
lncli-sim 1 addinvoice --amt 100 | jq -r '.payment_request'

Paste the invoice into Pay with Lightning and confirm. A partial melt keeps the original hold pending; its remaining tokens retain the backing deadline. For an automated check using temporary mint databases and this network's direct mint-to-receiver route, run:

cd ~/tmp.cash
poetry run python tests/regtest/run.py \
  --payer-container cashu-lnd-2-1 \
  --receiver-container cashu-lnd-1-1

The full-value melt in that check requires a zero-fee route. The web wallet can use routes with fees by reserving extra tokens for the payment.

Spark with the Breez Python SDK

Install the optional SDK and select a dedicated mint wallet and data directory:

poetry install -E spark
poetry run tmp.cash-mint --backend spark --data data/spark-v3 \
  --spark-mnemonic-file /path/to/mint-spark.mnemonic \
  --spark-network mainnet --fee-limit-sat 10 --settle-margin-seconds 120

The mnemonic file contains the Spark wallet's seed phrase. Alternatively, set TMP_CASH_SPARK_MNEMONIC in the environment or .env. Set TMP_CASH_SPARK_API_KEY to your Breez API key. Retain the wallet mnemonic, mint seed, spark.sqlite3 and spark-sdk/ storage together across restarts. Run one mint process for this wallet and do not share its SDK storage with another application. The mint's Spark wallet needs its own spendable Bitcoin balance for outgoing melts. Incoming invoice fees must be zero; the adapter rejects funding quotes with a fee so issued denominations equal the full backing amount.

The adapter extends Nutshell's SparkL2Wallet integration with Breez conditional payments: incoming BOLT11 invoices use the user's payment hash, and the mint waits for a funded pending HTLC before issuing tokens. It supplies the preimage through claim_htlc_payment only when settling. The redemption cutoff uses the funded HTLC's expiry_time minus --settle-margin-seconds, independently of BOLT11 invoice expiry. Returned, expired and claimed contracts permanently close their tokens; partial melts do not create post-settlement user balances. Backing checks use SDK-synchronized payment history. SDK 0.17.2 can retain cached state when an internal synchronization fails, so these snapshots are not independent proof that a contract remains held.

The default mainnet Lightspark SSP advertised a 60-block minimum final-hop CLTV delta in two unpaid HODL invoices checked on 2026-10-12, with invoice payment windows of 60 and 3,600 seconds. The Spark mainnet backend pins this as SPARK_MAINNET_MIN_FINAL_CLTV_EXPIRY_DELTA and rejects an incoming invoice whose delta differs. /v1/info publishes funding_htlc_cltv_delta: 60, and the wallet shows it in Settings. Private regtest providers retain their own CLTV policy. Recheck the production SSP with poetry run python tests/spark_mainnet_policy.py; it generates unpaid invoices for an ephemeral identity and does not require a Breez API key.

Sixty blocks is approximately ten hours at Bitcoin's average block interval, but it is a minimum relative delta rather than an observed expiry height. Payer padding and multipart funding can affect the actual Lightning timeout, and the Spark backing contract can expire sooner. The mint therefore continues to enforce the funded contract's SDK-reported expiry and settlement margin; the constant does not give Spark tokens a ten-hour or two-week lifetime.

Melts always pay through Lightning so a payment preimage is available. The quoted Lightning fee must fit the user's reserve and the server limit. Before sending, the adapter saves the SDK's transfer UUID and original fee cap. After a lost response it checks all pages of SDK payment history and reuses the same UUID and fee cap if the payment is not yet cached. Uncertain outcomes keep inputs reserved; a completed payment without its preimage remains pending. The initial SDK send waits up to 30 seconds for completion. If it is still pending, the web wallet checks the exact saved request automatically and shows a pending status when its bounded checks end. Pending never means payment failed. The web wallet submits the invoice without a fee override. It displays and uses the mint's quoted fee_reserve; any unused amount returns with its change while the backing remains live.

Accepted incoming holds and their funded deadlines are persisted. A temporarily missing SDK receive record produces an unavailable-status error, rather than an empty backing list or cancellation at the unpaid invoice's expiry. Confirmed settlement, return, or funded-contract expiry remains terminal.

The pinned Breez SDK supports mainnet and regtest. Private regtest requires --spark-network regtest --spark-config-file /path/to/spark-regtest.json. The JSON supplies coordinator_identifier, threshold, signing_operators (each with id, identifier, address, identity_public_key, optional ca_cert_pem), ssp_url, ssp_identity_public_key, esplora_url, and optional schema_endpoint (default graphql/spark/rc). Public operator defaults are never used for regtest. Against a funded local cashu-regtest Spark fixture, run make regtest-spark to exercise real held funding, private swaps, partial melts, delayed claims and full-redemption settlement. CASHU_REGTEST_DIR and CASHU_REGTEST_PROJECT select the fixture directory and Compose project. The local OpenSSP fixture needs the split-recovery and HODL receive patches in ~/cashu-regtest/spark/ssp-build/. Its spark/export-config.sh exports the running stack's public certificates and endpoints to a connection JSON file. The live runner verifies held funding, private swaps, partial and combined melts with change, delayed claims, and full-redemption settlement against that fixture.

Fund the mint's backend wallet

Set TMP_CASH_ADMIN_TOKEN in the mint's environment or .env and restart the mint. Generate a dedicated operator token with openssl rand -hex 32. For the VPS systemd setup, add it to /etc/tmp.cash/mint.env and run sudo systemctl restart tmp.cash-mint.

The operator-only POST /v1/admin/fund endpoint creates an ordinary Lightning invoice for the requested sats amount using whichever backend the mint is running. Paying it funds the backend's spendable balance for outgoing payouts. It does not issue ecash, create a backing HTLC, or require a user-supplied payment hash. Spark reuses the mint's SDK connection and storage; LND creates a standard invoice through its existing REST connection. The fake backend returns a simulated, payable invoice for testing. Every backend implements the same create_funding_invoice interface; no second wallet process is needed.

Export the same operator token in the shell making the request:

export TMP_CASH_ADMIN_TOKEN='your-generated-operator-token'
curl --fail-with-body -sS https://mint.example.com/v1/admin/fund \
  -H "Authorization: Bearer $TMP_CASH_ADMIN_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"amount":100000,"expiry":3600}'

The JSON response contains request (the BOLT11 invoice to pay), amount, unit, payment_hash, expiry (a Unix timestamp), and the backend's receive fee in sats. Spark receive fees may apply; LND and fake report zero. To print only the invoice, pipe the response through jq -r '.request'. expiry in the request is optional and defaults to 3,600 seconds; its allowed range is 60–86,400 seconds. amount must be a positive whole number of sats within the protocol's monetary limit.

The route is excluded from OpenAPI/Swagger and the wallet UI. It returns 404 when TMP_CASH_ADMIN_TOKEN is unset and requires a bearer token when enabled. Use HTTPS for remote requests; on the VPS itself, http://127.0.0.1:3338/v1/admin/fund also works. Keep the operator token out of URLs and frontend configuration.

Wallet commands

The default mint is http://127.0.0.1:3338 and the default wallet database is data/wallet-v3.sqlite3. Select another mint or wallet using global flags:

poetry run tmp.cash --mint http://127.0.0.1:3338 --wallet data/alice-v3.sqlite3 \
  mint 1000 <payment_hash> --preimage <32-byte-lowercase-hex>

The command prints the HODL invoice and waits for funding. Resume an interrupted mint with mint --quote <quote_id>; its saved commitments and secrets are reused. The preimage is stored locally and carried with token proofs.

# Split automatically, retaining local change. Send chooses one backing HTLC.
poetry run tmp.cash --wallet data/alice-v3.sqlite3 send 10
# If several HTLCs can fund the send, choose --payment-hash <hash>.

# Receiver checks the token's backing hashes, then refreshes ownership.
poetry run tmp.cash --wallet data/bob-v3.sqlite3 receive <cashuPS-token>

# Can combine different HTLCs. Covers invoice + reserve and returns fee change.
poetry run tmp.cash --wallet data/bob-v3.sqlite3 pay <bolt11> --fee-reserve 1

poetry run tmp.cash htlcs
# Check explicit hashes instead of the backings stored in this wallet:
poetry run tmp.cash htlcs <payment_hash> [<payment_hash> ...]
poetry run tmp.cash list
poetry run tmp.cash balance --refresh --verbose
poetry run tmp.cash pending
poetry run tmp.cash retry <operation_id>
poetry run tmp.cash abandon <operation_id>

melt aliases pay. Repeating --token-id selects specific inputs for a melt, or recovers the exact bundle of SENT proofs after lost send output. decode prints a bearer bundle without spending it. Fake development helpers are dev-accept <hash> and dev-invoice <amount>.

A send requires one HTLC with enough value. A received bundle containing multiple HTLCs is processed as separate swaps, each with its own saved recovery operation. All receive requests and input proofs are saved atomically before the first swap is submitted, so interruption cannot discard later backings. An interrupted split send also retains its requested amount; retry marks exactly those outgoing proofs SENT while keeping change available. Payments may combine backings. An unavailable or incomplete backing check fails the acceptance check. Wallets request only known backing hashes. These lookups disclose the inspected HTLCs to the mint; checking several together can associate them with one wallet.

Private swaps hide the backing hash from the mint, so the server does not check an individual HTLC's liveness during a swap. Wallets check before sending or receiving. Even a swap submitted after expiry preserves the original backing attribute; the mint rejects its outputs at redemption. Refreshing a signature cannot extend the HTLC's lifetime.

Local LIVE state and an unrefreshed balance are not proof of current backing. balance --refresh marks unavailable claims EXPIRED; it never revives spent proofs. The backing response is a mint-reported snapshot, not independent collateral verification, and backing can change after the check.

Melt fees, allocation and recovery

POST /v1/melt/quote/bolt11 returns the exact payout invoice, amount, fee reserve and invoice expiry. The default reserve equals --fee-limit-sat; the wallet may request a smaller reserve, including zero. A reserve above the server limit is rejected. LND receives that authorized limit via SendPaymentV2. The returned and tracked fee_msat is retained through retries; the charged fee is ceil(fee_msat / 1000).

A combined melt charges HTLC groups in order of their first appearance in the inputs. It consumes the first group's selected value before charging the next. Change stays with its original group. Before sending, the wallet prepares one owner commitment for each possible binary change denomination per group. Once the actual fee is known, the mint signs only the required subset. All input metadata, input order, the exact melt quote and change commitments are bound into every spending proof.

The mint reserves input nullifiers, each group's selected face value and the payout hash in one SQLite transaction. It retains the revealed backing preimages before payment. A confirmed payment, change signatures and consumed value are stored atomically before full-consumption settlement. A response lost after payout is recovered from Lightning's tracked payment without paying again. Exact retries return the original signatures and receipt.

Each melt outbox has a durable payment attempt and an execution lock, armed in the same transaction as the reservation (the Nutshell PR #8 model). While an executor owns the attempt, status pollers and the recovery worker only see a pending melt: they never consult backend status, release inputs or pay again. The first send is marked submitted atomically beforehand; a Spark send uses an attempt-derived uuid5 idempotency key, so a recovered outbox resumes the same send instead of starting a new one. An explicit pending outcome unlocks the outbox for later reconciliation, and a backend error before the first submission unlocks it for a later retry. A transport or backend error after submission is uncertain: the outbox fails closed with its lock held rather than letting another poller consume a stale failure record while the real payment may still resolve. A restart clears such orphaned executor locks (only on the process's first acquisition of the database), after which recovery reconciles the tracked payment only; it never sends again. If the backend no longer has any record of the payment, the outbox stays pending and its inputs stay reserved — restart alone does not resolve it, so reconcile the node manually before considering the inputs lost.

New melts are refused once any involved backing is within one settlement margin of its redemption cutoff (twice the margin before the hold expiry itself), before anything is reserved. For LND, the mint also passes a remaining-block cltv_limit to SendPaymentV2 so routes that cannot resolve before the earliest backing cutoff are refused. Once a payment is submitted, recovery remains responsible for its outcome and is not blocked by this lockout. This shrinks the late-resolution window but cannot atomically guarantee that an external Lightning HTLC resolves before an independent backing HTLC; that boundary is inherent to the protocol.

A definite payout failure releases all inputs and is retained as a terminal failure for that quote. Replaying that request never starts another payment; a new quote is required to authorize another attempt. An uncertain or pending payout keeps its inputs reserved. Python wallet abandonment requires every input to be unspent and the saved operation to be unsubmitted. Once a request may have reached the mint, retry preserves its recovery secrets. Releasing an unsubmitted operation preserves each input's original LIVE or SENT state. Definite failed melt requests can release their inputs after all are checked unspent, because the mint retains the terminal failure for that exact request. Failure cannot make a disclosed preimage unknown again: the mint retains it for scheduled settlement. A paid melt receipt says PAID, regardless of whether its backing HTLCs remain held or have subsequently closed.

Already authorized payments may still resolve after the cutoff. Recovery continues to reconcile those payments, but creates no claim against a closed HTLC. Such change is reported as expired_change, rather than a user balance or newly spendable token. New melts against closed claims are rejected.

If a definite failure arrives only after an involved backing was settled or expired, the failure is terminal: the reservation is released, the quote is retained with expired, and the mint returns HTTP 410 ("melt payment failed; backing has expired"). The credentials are never presented as newly reusable: their wire nullifiers stay UNSPENT (no signature was issued), but server-side backing checks prevent redemption. The Python wallet clears the pending operation and marks its inputs locally EXPIRED; on a later refresh, inputs whose backing is still live (a mixed-HTLC melt) revive to their original state, while inputs whose backing closed stay expired. This is not a post-settlement refund; no user balance is created.

Payout invoices with an expiry more than 24 hours in the future are rejected at quote time. Expired melt quotes with no melt and no failed tombstone are pruned at most every five minutes; every quote needed for pending, paid, failed or exact-retry recovery is retained.

Cryptography

For input i under denomination key a_i, the randomized presentation contains κ_i = h·Y_h2[a_i] + o_i·G2. For output j under denomination key b_j, the blinded commitment is B_j = h·Y_h1[b_j] + t_j·G1.

One Fiat–Shamir linear proof uses witnesses (h, o_1, ..., o_n, t_1, ..., t_m) to prove all these equations simultaneously. The shared witness establishes all-to-all equality of h, without an n × m collection of pairwise proofs. Each input independently proves credential authenticity and ownership of its nullifier. The full ordered input/output destination transcript is bound into the ownership and equality proofs.

The mint separately checks sum(a_i) = sum(b_j), rejects duplicate inputs and output commitments, consumes all input nullifiers atomically, and returns fresh blind signatures. The client subtracts t_j·U_j to unblind each output. Independent denomination keys prevent relabeling small credentials as larger ones. Denominations and transaction totals are visible to the mint; backing hashes, backing amounts, h and preimages remain hidden during swaps.

A keyset ID commits to the complete ordered denomination/public-parameter map. Credentials use that common ID; verification selects the key by denomination. h commits to the full 32-byte hash and original amount before scalar reduction.

API and limits

FastAPI provides schemas at /docs.

Endpoint Purpose
GET /v1/info, GET /v1/keys Capabilities and denomination public keys
POST /v1/htlcs/check Fresh backing status and cutoffs for supplied payment hashes
POST /v1/mint/quote/bolt11 Supplied-hash HODL invoice
GET /v1/mint/quote/bolt11/{quote} Funding/issuance status
POST /v1/mint/bolt11 Issue a denomination batch for the full funded amount
POST /v1/swap Private, value-conserving, same-HTLC many-to-many swap
POST /v1/melt/quote/bolt11 Payout quote and routing fee reserve
POST /v1/melt/bolt11 Combined melt and original-HTLC fee change
POST /v1/checkstate UNSPENT, PENDING, or SPENT nullifier states

Backing checks require a JSON body with 1–100 lowercase, 64-character payment hashes. Duplicate hashes are checked once. For example:

{"payment_hashes": ["<payment_hash>", "<payment_hash>"]}

The response retains checked_at, block_height, and htlcs; the latter lists only requested, issued, still-pending backings with remaining value. Unknown, unissued, and closed backings are omitted. A backend lookup failure returns 503, not an empty successful result. Responses use Cache-Control: no-store. Knowing a payment hash permits querying it; this is not proof of token ownership. No preimage or credential is required or exposed.

The unfiltered GET /v1/htlcs/pending endpoint has been removed. Upgrade the mint and wallets together; older wallets cannot perform backing checks against this API. Python clients use check_htlcs(hashes) and browser clients use checkHTLCs(hashes). Both deduplicate hashes and split larger checks into batches of at most 100. An empty local wallet performs no backing request. There is no public endpoint to enumerate all pending holds. Targeted checks reduce response size and backend work, but disclose which backings a client checks even though swap proofs themselves hide the backing hash.

Successful mint, swap and melt POST responses are cached in memory per server process for five minutes, with at most 1,024 entries and 16 MiB of serialized responses. Retries with the same validated request reuse the response, and concurrent identical requests share successful work. Errors and pending melts are not cached. Quotes and state queries always reach the mint. The cache is an optimization; durable database recovery still handles retries after eviction, expiry or restart.

Expensive synchronous validation and signing (mint issuance, swaps, checkstate, melt proofs and paid-melt signing) runs through a bounded admission of four concurrent operations with a half-second admission timeout, returning 503 when busy instead of queueing unbounded work. Deployments behind Nginx should also apply per-IP rate limiting (for example 10 r/s, burst 60, HTTP 429) to API locations; the application-level bounds remain authoritative for non-Nginx deployments.

Batches allow up to 256 inputs or outputs; melt change commitments also have a 256-slot limit. For very large mixed-HTLC batches, use smaller payments or select fewer inputs. Bearer bundles use experimental cashuPS JSON/base64 encoding, with token version 3, and are incompatible with ordinary Cashu wallets.

Version-2 tokens, saved operations and databases are deliberately rejected. There is no automatic conversion of old whole-claim balances into v3. Preserve old databases and complete their claims using the previous version; use fresh mint and wallet directories for this protocol.

If poetry run tmp.cash-mint reports a previous protocol, check TMP_CASH_MINT_DATA in your existing .env: it overrides the default v3 directory. For an old LND setup using data/lnd-manual, set TMP_CASH_MINT_DATA=data/lnd-v3 and restart. Keep the old directory, including its database and seed.

Verification

make test
make check
make demo
make regtest

The live runner uses an existing funded, synced cashu-regtest network and does not provision nodes, fund channels or mine blocks. Its full-value melt requests a zero fee reserve, requiring a fee-free route (normally a direct channel) to avoid needing an extra token beyond the held amount. CLI options can override node containers, REST endpoint and node hold-cancellation delta.

Source attribution is retained in PROVENANCE.md. MIT licensed.

About

ecash, but with no custodian.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages