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 payer chooses a 32-byte preimage
Pand suppliesH = SHA256(P)and an amountA. The mint creates a HODL invoice. Once fully accepted, it signs denomination tokens whose total equalsA. Issuance does not revealP. - 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 for1, 2, 4, 8, ...sats, through the supported monetary limit. - 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. - 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.
- 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.
- 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.
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 demoThe 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.
poetry run tmp.cash-mint --backend fake --data data/mint-v3For manual testing with the web wallet, enable automatic fake funding:
poetry run tmp.cash-mint --backend fake --data data/local-fake --fake-auto-acceptThis 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 6TLS 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.
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-syncA 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=4096After 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=2016The 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.macaroonA 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-mintcd ~/tmp.cash/web-wallet
npm run devOpen 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-1The 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.
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 120The 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.
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.
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.
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.
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.
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.
make test
make check
make demo
make regtestThe 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.