Skip to content

feat: operator-issued MB/minute ecash — unit-aware pricing + CDK mint integration (DRAFT) - #1040

Draft
felixfelix-bot wants to merge 16 commits into
OpenTollGate:mainfrom
felixfelix-bot:pr/cdk-mint-issuance
Draft

felixfelix-bot wants to merge 16 commits into
OpenTollGate:mainfrom
felixfelix-bot:pr/cdk-mint-issuance

Conversation

@felixfelix-bot

@felixfelix-bot felixfelix-bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Operator-issued megabyte/minute ecash — CDK mint integration (DRAFT, work in progress)

Goal: the operator of a TollGate runs their own CDK mint and issues megabytes or minutes as ecash; users spend those tokens at the gateway for access. This PR is the review landing zone — evidence posted as comments as phases land.

Phase 0 — gap analysis (done, three parallel investigations, all source-cited)

  1. Gateway-side gap (src/merchant, src/tollwallet): the token's unit was structurally invisible at pricing time — Token had no Unit() (port.go:26-42), Receive() returns "amount in the token's unit" unchecked, calculateAllotment divided by sat-denominated price_per_step. A 64 mb proof would price as 64 sats — silent mispricing, the exact bug class this change closes.
  2. Pecan trade study: custom-unit issuance works as a payment backend — gRPC server the mint dials, custom method (/v1/mint/quote/{method}), NUT-20 locked quotes. Recommendation: staged path — minimal Go teller-processor as the product shape, never the full console stack.
  3. CDK capabilities: CurrencyUnit::Custom is first-class in cdk 0.18, keysets carry any unit, custom payment methods get dynamic routes. The mint is not the blocker — the gonuts wallet is (cashu.Unit is a sat-only enum; cdk-go FFI has CurrencyUnitCustom ready for the cdk_wallet path).

Phase 1 — unit-aware pricing (this branch, TDD)

  • tollwallet.Token grows Unit() string; gonuts adapter reads it off the decoded V4/V3 wire structs; sidecar/cdk adapters return "" (honest unknown) rather than inventing "sat"
  • MintConfig grows unit (empty == sat; every existing config keeps exact behaviour)
  • calculateAllotmentForUnit: unit mismatch = refusal, not guessing. price_per_step=1&unit=mb prices a 64 mb token as 64 steps of step_size
  • PurchaseSession + the late-receive journal price with the token's real unit

RED observed first; merchant suite (283 s) + tollwallet suite green.

Phase 2 — live proof of the issuance chain (bench host, evidence in comments)

End-to-end against real cdk-mintd + a minimal Go teller-processor (the product shape from the trade study):

POST /v1/mint/quote/teller {"amount":100,"unit":"mb"}  -> teller-<quote-id>
POST :8099/settle?ticket=teller-<quote-id>              -> settled   (the operator action)
GET  quote                                              -> amount_paid: 100, unit: mb
POST /v1/mint/teller {blinded outputs}                  -> SIGNATURES [64,32,4] on the mb keyset

What still needs to happen (tracked here, honestly)

  • Wallet redemption of non-sat units — the known blocker: gonuts cannot represent mb keysets. Path: cdk_wallet build tag + CurrencyUnitCustom FFI, or gonuts fork work
  • Router hardware validation (A04E over serial): deploy, pay an mb token at :2121, measure the granted megabytes
  • Operator UX surface (CLI command / admin-UI button driving the settle endpoint)
  • Security hardening of the settle endpoint (loopback + auth today)

Phase-by-phase proof lands in the comments as it's measured.

felixfelix-bot and others added 7 commits October 20, 2026 21:49
…conclusion string

wait_for_result prints 'still running' progress to stdout; the caller
captures stdout as the conclusion, so a run longer than 120s produced
'still running...\nsuccess' != 'success' and the driver aborted a GREEN
shard as failed (observed: ipk-none conclusion=success d=661s, driver
exited 'RELEASE NOT ANNOUNCED'). The evidence TSV recorded the true
conclusion, so only the driver's own comparison was wrong.

Progress + conclusion echo now go to stderr; stdout carries only the
conclusion string.
The last line of fetch-upx.sh is `upx --version | head -1 >&2` under
`set -e`. When head exits after the first line, upx can die with SIGPIPE
(141) and take the whole install step — and the shard — down. Live failure:
shard ipk-upx-fast-best, release-run d8576df-20261020T192529Z, job log
'Install UPX ... exitcode 141' after upx 5.2.1 printed fine; the same step
passed on shard ipk-none minutes earlier, i.e. an intermittent pipe race,
not a toolchain fault.

Armour the version-print pipeline with { ...; true; } so the pipeline's
status can never be upx's writer-side signal death. Verified locally:
6/6 consecutive fetch-upx.sh runs rc=0 (cold + 5 warm).
…walled drive.cashu.email mirror

Second live hit of the SIGPIPE race, this time in the SHARD TEMPLATE's own
Install UPX step (ngit-gen-shards.py:404) — same 'upx --version | head -1'
under set -euo pipefail as fetch-upx.sh (OpenTollGate#1029), which is why OpenTollGate#1029's fix
did not cover it: fetch-upx.sh was armoured, the workflow line was not.
Armour the template line and regenerate all 11 shards.

Also drop drive.cashu.email from BLOSSOM_SERVERS: it started returning
'402 Payment required for files over 1 MiB' on every artifact upload
(live: shard ipk-upx-fast-best, mips_24kc-upx-fast.ipk, 3 attempts, 2/4
mirrors ok). A paid mirror can never satisfy a success threshold and only
burns retry time. 3 free mirrors remain (primal, psbt.me, blossom2).
…true; }'

The previous armour was wrong: under set -e the shell exits at the
pipeline's OWN failure, so the trailing true in { pipeline; true; } never
runs. Proven deterministically: bash -c 'set -euo pipefail; { yes | head
-1; true; }' exits 141 — the exact failure the armour was meant to absorb.
That is why shard ipk-upx-fast-best died a third time at commit 61433f5
with the 'armoured' workflow sha on the trigger.

Both sites (fetch-upx.sh tail, shard-template Install UPX) now use
'pipeline || true'. Step-form verified locally: survives, prints upx 5.2.1,
rc=0. 11 shards regenerated.
…etch

The shard fetch step waited 20 x 30s (~10 min) for the stage-1 binaries
record before giving up. Stage-1 (build-package-binaries) takes 12-15 min
on the coordinator, so a release run started right after a mirror push
races stage-1 end-to-end and loses: live, run 28d2dfc-20261020T205550Z,
shard ipk-none gave up at 10m44s ('no binaries record for build id
28d2dfc') while stage-1's portal record landed at 21:01 — six minutes
after the shard's patience ran out. Prior runs won the race only because
stage-1 had been triggered earlier by the mirror sync.

40 x 30s (~20 min) covers a full stage-1 run with margin.
…mour the SDK upx print

The apk shard jobs check out to path: src-checkout, but the 'Resolve the
pinned SDK image reference' step read packaging/build-inputs.json relative
to the job CWD. On GitHub Actions the composite-action context keeps the
repo root as CWD so it worked; on ngit-ci/act the CWD is the workspace
root and the file only exists under src-checkout/ — every apk shard died
in 22s with 'jq: error: Could not open packaging/build-inputs.json'
(live: run 2da570d-20261020T211454Z, shard apk-mediatek-filogic, after
8/8 ipk shards went green).

The step now resolves the manifest from the repo root first and falls back
to src-checkout/, so both layouts work. Also armour the SDK-side
'docker exec ... upx --version | head -1' with || true — same SIGPIPE
class that killed the ipk UPX step three times today.
…custom-unit mint

First, provable step of the CDK-mint issuance integration: the operator of
a TollGate can run a mint that issues megabytes or minutes, and a token's
unit must be part of its price.

Before this, Token exposed Amount() with the doc "in the token's unit
(typically satoshis)" and calculateAllotment divided that number by
price_per_step in sats. A 64 "mb" proof from an operator mint would have
been silently priced as 64 sats — the exact mispricing this prevents.

- tollwallet.Token grows Unit() string; gonuts adapter reads the unit off
the decoded V4/V3 wire structs (the gonuts cashu.Token interface itself
does not expose it); sidecar and cdk adapters return "" (unit not visible
on those surfaces) rather than inventing "sat".
- MintConfig grows "unit" (empty == sat, so every existing config keeps
its exact behaviour), and calculateAllotmentForUnit refuses a unit that
does not match the config instead of guessing.
- calculateAllotment now delegates to calculateAllotmentForUnit(amount,
"sat", mint) — one pricing implementation, unchanged sat behaviour.
- PurchaseSession and the late-receive journal price with the token's real
unit.

TDD: unit_pricing_test.go observed RED (Unit undefined, then mispriced)
before the implementation. Full merchant suite (283s) and tollwallet suite
green after.
@felixfelix-bot

Copy link
Copy Markdown
Collaborator Author

Phase 2 evidence — the issuance chain works, measured live (bench host, 2026-10-21)

cdk-mintd (0.18.x) configured with a custom unit mb and a grpcprocessor payment backend; a minimal Go teller-processor (gRPC :50051, protocol 4.0.0, admin HTTP :8099) built from CDK's own payment_processor.proto.

1. Mint boots against the Go processor (mint log):

INFO cdk_mintd: Attempting to start with gRPC payment processor at 127.0.0.1:50051.
INFO cdk::mint: Starting payment wait task for PaymentProcessorKey { unit: Custom("mb"), method: Custom("teller") }
INFO cdk_mintd: listening on 0.0.0.0:3348

Processor log: GetSettings called, WaitPaymentEvent stream opened.

2. Quote created in the custom unit:

POST /v1/mint/quote/teller {"amount":100,"unit":"mb"}
-> {"quote":"01a15ec1-…","request":"teller-01a15ec1-…","method":"teller","amount":100,"unit":"mb","expiry":…}

3. Operator settles (this is the "operator issues MB" moment):

POST :8099/settle?ticket=teller-01a15ec1-…  -> 200 "settled"
GET  /v1/mint/quote/teller/01a15ec1-…        -> amount_paid: 100, unit: mb

4. Blinded mint — proofs issued in mb (keyset 01cfc68133f5c440…, unit mb):

POST /v1/mint/teller {outputs [64,32,4]}
-> SIGNATURES: 3, amounts [64, 32, 4], keyset 01cfc68133f5c440…(mb)

The user now holds 100 mb of ecash, spendable at a TollGate whose mint config declares unit: "mb" — Phase 1's calculateAllotmentForUnit prices it as 100 steps (e.g. 10 MiB steps = ~1 GB).

What this does NOT yet prove (next phases, will be commented as measured):

  • The gateway's gonuts wallet can redeem an mb token — the known blocker; the wallet cannot represent non-sat keysets. The cdk_wallet FFI path (CurrencyUnitCustom) is the candidate.
  • The chain on a real router over serial (A04E).
  • The settle endpoint hardened beyond loopback (it currently has no auth — deliberately, it is a prototype; the trade study flags this).

@felixfelix-bot

Copy link
Copy Markdown
Collaborator Author

Phase 0 artifact — pecan architecture trade study (full, source-cited)

Written by an independent consultant; every claim cited file:line against pecan HEAD ee78b20 and CDK v0.18.0-rc.0.


Architecture Trade Study: Operator-Issued MB/Minute Ecash for TollGate

Question: how should an OpenTollGate operator issue megabytes/minutes ecash from
their own CDK mint, with no Lightning? Reference implementation: zeugmaster/pecan
(read at /home/c08r4d0r/repos/pecan, HEAD ee78b20).

All claims below are cited to source. Pecan cites are path:line under
/home/c08r4d0r/repos/pecan. CDK cites are path:line under the v0.18.0-rc.0 tag
(materialised at /tmp/cdkpp/cdk-0.18.0-rc.0; pecan's pinned protocol).


1. How pecan issues custom-unit ecash with no Lightning

1.1 The answer to "bolt11 method?" is no — it is a custom payment method

Pecan registers itself as a payment backend for whatever method name it puts in
SettingsResponse.custom (processor/src/backend.rs:143), which the operator sets in
the console and which ships as "branch" (processor/src/config.rs:202). Bolt11/bolt12
are explicitly advertised as absent:

// processor/src/backend.rs:144-154
Ok(SettingsResponse {
    unit: unit.to_string(),
    // bolt is not a valid rail here; advertising None for both is intentional.
    bolt11: None::<Bolt11Settings>,
    bolt12: None::<Bolt12Settings>,
    onchain: None,
    custom,          // {"branch": "{}"}
})

The mint then must be configured with backend = "grpcprocessor" for that unit
(processor/src/config.rs:333-345, and processor/src/mintctl/src/mint.rs:92-106 for the
bundled variant). So the wallet calls POST /v1/mint/quote/branch, never
/v1/mint/quote/bolt11. This matters for TollGate: the method name is operator-visible
and free-form, which is what makes "mb"/"minute" possible at all.

1.2 Direction of the gRPC connection — pecan is the server

This is the single most misunderstood part of the architecture, and it dictates every
runtime requirement:

  • Pecan listens on port 50051 (processor/src/main.rs:249-254):
    PaymentProcessorServer::new(backend, &grpc_addr, grpc_port) … server.start(tls_dir).
  • The operator's cdk-mintd dials in (processor/src/config.rs:341-345:
    [grpc_processor] address/port).
  • The wire contract is CDK's, not pecan's: CdkPaymentProcessor with 7 RPCs
    (crates/cdk-payment-processor/src/proto/payment_processor.proto:5-13), gated on
    strict protocol-version equality — PAYMENT_PROCESSOR_PROTOCOL_VERSION = "4.0.0"
    (crates/cdk-common/src/lib.rs:22), enforced via the x-cdk-protocol-version
    header (crates/cdk-common/src/grpc.rs:8,47-68).

Pecan is therefore a payment-backend implementation, i.e. a stand-in for a Lightning
node. That is the whole trick: CDK never asks how the money arrived, only that some
backend says "quote X is paid".

1.3 Exact quote flow (deposit / mint)

  1. Wallet → mint: POST /v1/mint/quote/branch with amount, unit (e.g. mb), and
    a NUT-20 pubkey. Contract documented in docs/wallet-integration.md:10-13,42-45.

  2. Mint allocates the quote id first, then calls the backend
    (crates/cdk/src/mint/issue/mod.rs:313-322): CDK builds
    CustomIncomingPaymentOptions { method, description, amount, unix_expiry, extra_json, quote_id, pubkey } — note quote_id is mint-generated before the backend is called
    (proto field 6, payment_processor.proto:60-66; struct at
    crates/cdk-common/src/payment.rs:220-240). This is the PR feat(payment-processor): propagate quote_id and pubkey on custom mint quotes cashubtc/cdk#2295 pecan
    depends on (processor/Cargo.toml:14-27).

  3. Backend registers a ticket. Pecan refuses unlocked quotes outright and keys its
    record on the mint's quote id (processor/src/backend.rs:178-206):

    if opts.pubkey.is_none() {
        return Err(Error::Custom("branch mint quotes must be locked to a wallet key (NUT-20): …"));
    }
    let ticket = Ticket::new_incoming(quote_id, amount.value(), unit, description, unix_expiry);
    // id = format!("MINT-{quote_id}")   — processor/src/state.rs:94
    Ok(CreateIncomingPaymentResponse {
        request_lookup_id: PaymentIdentifier::CustomId(ticket.id.clone()),
        request: ticket.id,          // <- the "payment request" the wallet displays
        expiry: opts.unix_expiry,
        extra_json: None,
    })
  4. Mint stores the quote with request = "MINT-<quote_id>"
    (crates/cdk/src/mint/issue/mod.rs:348-365). The wallet displays that string; the
    teller reads its hex tail (processor/src/state.rs:157-179, ≥6 hex chars, suffix match).

  5. Teller settles. UI path: POST /api/quotes/match → POST /api/tickets/{id}/mark-paid
    (processor/src/web.rs:106-108). Before showing a confirm card pecan re-verifies
    against the mint's public API
    (processor/src/web.rs:742-792) — this is the
    anti-fraud step, see §3.

  6. Backend emits an event so the mint flips the quote to PAID without polling
    (processor/src/state.rs:448-466):

    let event = Event::PaymentReceived(WaitPaymentResponse {
        payment_identifier: PaymentIdentifier::CustomId(updated.id.clone()),
        payment_amount: amount,
        payment_id: format!("{}-receipt", updated.id),
    });
    let _ = self.inner.event_tx.send(event);   // best-effort

    The mint's fallback if the event is missed is check_incoming_payment_status
    (processor/src/backend.rs:340-345 → state.rs:559-576), which returns a payment
    only when the ticket is Paid (state.rs:568).

  7. Wallet mints. POST /v1/mint/branch with its NUT-20 signature. Mint checks
    amount_paid and signs proofs in the configured unit. Only that wallet can mint —
    the quote id alone is not a bearer instrument.

1.4 Quote shape, concretely

Field Value / source
quote (mint id) UUIDv7, mint-generated (state.rs:94 derives the ticket id from it)
request "MINT-<quote_id>" (state.rs:94, returned at backend.rs:203)
ticket id same string; MELT-<quote_id> for melts (state.rs:118)
fields id, quote_id, kind{Incoming,Outgoing}, amount, unit, status, created_at, paid_at, description, notes, expires_at (state.rs:59-82)
status Waiting → Pending → Paid / Failed (state.rs:39-51)

Storage is one JSON file rewritten atomically on every mutation
(processor/src/state.rs:1-9,593-601) — no database. Melt-side payout is gated so a
teller can never pay cash before the wallet's proofs are locked at the mint
(state.rs:384-408: Waiting → Pending only via make_payment).


2. Runtime requirements

2.1 What pecan actually needs

Requirement Evidence
Docker + Compose v2 (up --wait-timeout), rootless OK README.md:23-27; docs/operations.md:8-22
A separately-run cdk-mintd ≥ 0.18.0-rc.0 processor/src/config.rs:28-34; docker-compose.yml:108-150
amd64/arm64 Linux README.md:19
Nothing else — no Lightning node, no bitcoind, no postgres processor/Cargo.toml:25-42

Compose shapes: processor-only, or --with-mint adding cashubtc/mintd
(docker-compose.yml:1-30,108-150). The mint is not part of the processor image
(Dockerfile:3-7).

2.2 Binaries are not static — this kills the "just ship it to the router" idea

Dockerfile:55-64 builds on debian:bookworm-slim with libssl3 and ca-certificates,
and the builder links libssl-dev (Dockerfile:25-31). Release profile is
strip = true only (processor/Cargo.toml:44-46) — no crt-static, no musl target. So
cdk-branch-processor is a glibc + OpenSSL dynamic binary. Confirmed absence: no
musl/static reference anywhere in Dockerfile or install.sh. It would need a
*-unknown-linux-musl build with openssl vendored (or the rustls path finished) to run
on OpenWrt.

Realistically: pecan is a ~1 GB RAM, container-capable, always-on box thing. On a
GL-MT6000 (1 GB RAM, OpenWrt 24.10, no Docker, 8 MB-ish free overlay) it is off the table.

2.3 Can the pattern be reimplemented in Go? Yes — the surface is tiny

The protocol is one self-contained proto file of 307 lines, 7 RPCs
(payment_processor.proto), with google.protobuf.Empty-style messages and no exotic
deps. A Go server would need:

  1. gRPC server on :50051 echoing x-cdk-protocol-version: 4.0.0 on every response-path
    (reject others; crates/cdk-common/src/grpc.rs:47-68).
  2. GetSettings → {unit: "mb", custom: {"branch": "{}"}}.
  3. CreatePayment → mint a ticket, return request = "MINT-<quote_id>",
    request_identifier = CustomId(...).
  4. WaitPaymentEvent (server-streaming) + CheckIncomingPayment for the reconciliation
    fallback.
  5. GetPaymentQuote / MakePayment / CheckOutgoingPayment for the melt leg (can return
    Unsupported if TollGate never lets users cash out — but note the mint advertises NUT-05
    for the method as soon as the backend claims it, crates/cdk/src/mint/builder.rs:521-546,
    so a stub is user-visible and should be deliberate).

That is maybe 600–900 lines of Go plus generated stubs. No CDK Rust dependency, no cgo.

Can it be embedded in the gateway? Structurally yes — it is just a listener plus a
JSON file, exactly like pecan, and the gateway is already a long-lived Go process. Two
caveats worth stating plainly:

  • The mint is not on the router. Whatever the shape, cdk-mintd must run somewhere
    reachable, and it must dial the gateway's IP:port. If the gateway is on the LAN behind
    NAT and the mint is on a VPS, this is backwards from every usual topology: the gRPC is
    inbound to the router. Doable over WireGuard; not doable over CGNAT without a tunnel.
  • Binary size. The module already fights for flash (README.md:268-280: 16 MB devices are
    at the edge). grpc-go + protobuf adds roughly 6–10 MB. This is a real constraint on
    the small-hardware tier, not on MT6000.

3. Security model — what "operator issues MB to user" must replicate

3.1 Who may settle

  • Teller auth: username+password against users.json; POST /api/login sets an
    HttpOnly SameSite=Lax session cookie, Secure only when X-Forwarded-Proto: https
    (processor/src/web.rs:1149-1204, cookie at :1166-1169). Sessions persist across
    restarts in sessions.json with a 12 h TTL (processor/src/sessions.rs:22).
  • Every mutating route goes through require_api_auth, which additionally blocks all
    routes but /api/app and /api/me/password while a forced password change is pending
    (web.rs:326-335). New handlers are gated by default — a good pattern to copy.
  • First boot seeds admin/admin or CDK_BRANCH_PROCESSOR_INITIAL_ADMIN_PASSWORD, and
    forces a change (main.rs:136-138, web.rs:652-657).
  • Password hashing: salted SHA-256, 120 000 iterations, constant-time compare
    (processor/src/config.rs:595-620). Honest assessment: home-grown iterated SHA-256 is
    weaker than Argon2id/scrypt and is worth not copying.

3.2 The real security primitive: quote-id-as-bearer is disabled by NUT-20

Pecan's designers were explicit that the code the teller types is not a secret
(docs/wallet-integration.md:18-20). Two mechanisms carry the safety:

  1. NUT-20 lock enforced at creation (backend.rs:178-184) and re-checked at settlement
    (web.rs:781-788). Even if an attacker learns the quote id, they cannot mint the proofs —
    only the wallet holding the private key can. Worst case is a griefing settle: the
    operator hands over MB to an impostor who cannot mint, and the real user's quote is
    burned. That is a "look at the customer" problem, mitigated by the amount check.
  2. Open-quote list is redacted to the leading 13 chars (the UUIDv7 timestamp section) so
    the tail used for matching must come from the customer
    (web.rs:398-455 — comment at :420-422; DESIGN.md:232-234). Note this only really
    works because quote ids are time-ordered UUIDv7. A random-id mint would need a different
    redaction.

3.3 Pre-settlement cross-check against the mint (do not skip this)

verify_with_mint (web.rs:742-822) refuses to settle when: the mint is unreachable
(BAD_GATEWAY — "confirming cash movements on stale knowledge is worse than asking the
customer to wait", :739-741), the mint doesn't know the quote (:763-766), the quote is
already settled — amount_paid > 0 || amount_issued > 0 (:768-772), the mint's record
disagrees on request/amount/unit (:773-780), or the quote isn't NUT-20 locked (:781-788).
It surfaces "unknown" vs "unreachable" differently on purpose (clients.rs:127-150).

3.4 Replay / double-issue protection

Four independent layers, all cheap to replicate:

Layer Mechanism Cite
Idempotent settle Pending → Paid fires events only on transition; repeat confirm is a no-op state.rs:418-467 (test :767-784)
Replay of used quote id re-registering same id is allowed only as an identical-parameter transport retry; anything else → "quote id … has already been used" state.rs:326-349 (test :804-820)
Void is terminal Failed tickets reject mark_paid and can't be re-funded state.rs:428, :476-509 (test :786-802)
Hostile growth MAX_OPEN_QUOTES = 200 because quote creation at the mint is unauthenticated state.rs:24-28,339-342

Plus expiry: the sweeper deletes Incoming/Pending and Outgoing/Waiting past
expires_at every 60 s (state.rs:516-543, main.rs:316-326) and never touches a
funded melt (state.rs:512-515).

3.5 What a teller-less design loses

Pecan's entire model assumes a human sees cash move. For "operator issues MB to user"
there is no cash leg
— the operator is granting service against an off-band payment, or
for free. That means the teller console, the match UI, the quote tail and the scanner are
all solving a problem TollGate does not have. Conversely, the parts that matter —
NUT-20 enforcement, pre-settle cross-check, transition-only settlement, replay rejection,
open-quote cap — are exactly the parts that are pure protocol logic and cheap to port.


4. The three shapes

(a) Operator runs the full pecan stack on a LAN box; router accepts its mint

Effort: lowest to stand up (one curl | bash, README.md:21-25), potentially highest
to make work, because of a blocker on the TollGate side (below).

Moving parts: 2–3 containers (pecan + cashubtc/mintd + optional Caddy), Docker host
with ≥1 GB RAM, TLS for the mint URL wallets use, seed custody (docs/operations.md:118-124),
backup/restore discipline (:165-189).

Failure modes: mint down ⇒ no top-ups (unavoidable in any shape); Docker/network
misconfig ⇒ checklist goes red but settlements still work; plaintext gRPC on a LAN ⇒ anyone
on that LAN can impersonate the mint's backend (docker-compose.yml:37-42 warns, and
config.rs:313-316 renders allow_insecure); operator forgets the seed ⇒ mint is
unrecoverable; unit locked after first self-test (config.rs:286-290).

What users install: Docker, pecan, cdk-mintd, TLS certs, a box.

⚠ The blocker nobody mentions: TollGate's shipping wallet literally cannot hold
non-sat ecash. gonuts-tollgate v0.13.0 has type Unit int with a single constant
Sat Unit = iota and String() returning "sat" for it, "unknown" otherwise
(cashu/cashu.go:19-35); the wallet hardcodes unit: cashu.Sat
(wallet/wallet.go:126); and keyset selection filters on that unit string
(wallet/keyset.go:25,54,200), with restore.go:74-76 breaking out of the loop on any
non-sat keyset. Receive calls getActiveKeyset(tokenMint) (wallet/wallet.go:644), so a
token whose unit is mb finds no matching keyset ⇒ top-ups fail. The default backend is
gonuts (src/tollwallet/select.go:31-33, packaging/build-inputs.json:12-15).
There is a path: cdk-go v0.17.3 FFI exposes CurrencyUnitCustom{Unit string}
(bindings/cdkffi/cdk_ffi.go:19838-19840) and tollgate's cdk_wallet.go:406 already
passes a CurrencyUnit — so the CDK-backed build tag could support mb where gonuts
cannot. But cdk_wallet builds require cgo + a Rust staticlib on a MIPS/ARM router, which
is its own project.

Verdict: correct mint, wrong wallet. (a) is only viable after the wallet is
unit-generalized — and once you've done that work, you still have Docker on a LAN box.


(b) Minimal Go "teller processor" in the gateway ↔ local/remote cdk-mintd gRPC

Effort: medium. ~600–900 lines Go + generated stubs against a 307-line proto, plus a
JSON ticket store and an admin endpoint. This is a reimplementation of the pattern, not a
port of the console.

Moving parts: gateway binary (+grpc-go, +~6–10 MB flash), a cdk-mintd somewhere with
[[payment_backend]] backend="grpcprocessor" unit="mb", an inbound path from that mint to
the router's gRPC port (WireGuard/port-forward/LAN), and — for TollGate's actual UX — a way
for the wallet to obtain the quote without a human reading digits.

What you can drop versus pecan: the entire teller surface (login, sessions, users,
match-by-tail, SSE, scanner input, SPA). Everything settled in §3 that matters is
protocol-level and survives the drop.

Failure modes: protocol version drift (strict equality, grpc.rs:47-68 — a cdk-mintd
upgrade to 5.0.0 silently stops the backend and the mint stops issuing for that unit);
inbound reachability (CGNAT kills it without a tunnel); event-stream loss (mitigated by
CheckIncomingPayment, backend.rs:340-345); flash headroom on 16 MB devices; two sources
of truth for "did this user pay" (the router's own ledger vs the mint's) — needs an explicit
reconciliation story, which is the real design work here.

What users install: a mint (VPS or LAN box) — not pecan, not Docker on the
router. The gateway gains a built-in backend.

Verdict: the architecture pecan proves out, minus the counter. Effort concentrates in
TollGate-side wallet work (still needed) and in deciding who settles without a teller.


(c) Skip the processor: operator CLI force-marks quotes paid via the mint's admin RPC

This exists. CDK's management RPC has exactly the primitive:

// crates/cdk-mint-rpc/src/proto/quote.proto:10-13
// Force-marks a mint quote as paid.
rpc UpdateMintQuoteState(UpdateMintQuoteStateRequest) returns (UpdateMintQuoteStateResponse) {}

// :72-80
message UpdateMintQuoteStateRequest {
  string quote_id = 1;
  MintQuoteState state = 2;   // only MINT_QUOTE_STATE_PAID is supported
}

Implementation confirms: only Paid is accepted; Unpaid → "payments cannot be retracted",
Issued → "no signatures would back the issuance" (crates/cdk-mint-rpc/src/proto/server.rs:1076-1107);
unknown quote → INVALID_ARGUMENT; it synthesises a WaitPaymentResponse from the quote's
own request_lookup_id (:355-376). CLI: cdk-mint-cli UpdateMintQuoteState
(crates/cdk-mint-rpc/src/bin/mint_rpc_cli.rs:108,251-253).

Effort: hours, not weeks. Enable [mint_management_rpc] enabled = true (off by
default — docs/examples/mint.toml:71-77), let the wallet create a NUT-20-locked quote
(TollGate already generates the pubkey: wallet/wallet.go:259-266), then:

cdk-mint-cli UpdateMintQuoteState --quote-id <id> --state paid
# wallet polls, sees amount_paid, mints

Moving parts: cdk-mintd with a fakewallet-backed custom method or any backend
for that unit, plus the RPC port. No processor, no pecan, no second service, no Docker on
the router. Note cdk-mintd refuses to boot with no backend at all
(crates/cdk-mintd/src/lib.rs:547-549), so you still declare one — fakewallet with
custom_payment_methods = ["mb"] is the honest zero-Lightning choice (it auto-settles,
so for gated issuance you want the processor or you accept auto-issue).

Failure modes: no authorization whatsoever. The RPC server's only guard is an
optional MintMutationGuard trait (server.rs:102-118); there is no token, no macaroon,
no mTLS-by-default
— I grepped Interceptor|metadata|authorization across server.rs:
zero hits. It is secured by being bound to loopback with allow_insecure. So: do not
expose 8086; treat it as root. Also: force-paid is irreversible and bypasses every
check pecan performs — no amount/unit cross-check, no NUT-20 verification, no replay
protection beyond "quote already paid". If the operator fat-fingers a quote id, ecash is
issued. And it's a human step per user, so it does not scale past a demo — unless the
gateway drives it, which is then option (b) with fewer moving parts but no protocol checks.

What users install: cdk-mintd + cdk-mint-cli. Nothing on the router.

Verdict: the fastest path to a working demo / operator-run pilot, and a genuinely
defensible "operator issues MB" story for low volume. Not a scalable or safe-by-default
product surface.


5. Recommendation

Recommend a staged path: (c) now, (b) as the product shape. Do not build (a).

Reasoning, in order of weight:

  1. The wallet is the real blocker, not the mint. Issuance is a solved problem three
    different ways (pecan, the management RPC, fakewallet). What is not solved is that
    gonuts-tollgate cannot represent a non-sat unit
    (cashu/cashu.go:19-35, wallet/wallet.go:126, wallet/keyset.go:25,54). Any of
    (a)/(b)/(c) is decorative until the wallet accepts mb-unit keysets. Do this first,
    and it's the same work in all three shapes. On the CDK path it's nearly free —
    CurrencyUnitCustom already exists in the FFI (cdk_ffi.go:19838-19840).
  2. TollGate has no teller, so pecan's core value proposition does not transfer. Pecan's
    console, login, match-by-tail, and scanner exist to verify cash across a counter
    (DESIGN.md:73-81,138-145). TollGate's issuance is programmatic. Buying (a) means
    running Docker, a seed, TLS, and a second auth system to get a gRPC shim you could write
    in Go.
  3. But do not ship (c) as the product. Force-marking paid has zero authorization
    (server.rs:102-118 — no interceptor, no token), zero pre-flight verification, and is
    irreversible. It's perfect for the operator's own bootstrap ("here are 500 MB, welcome")
    and for validating the whole user journey on real routers this week. It is not what you
    point a customer at.
  4. (b) is the right destination because it preserves the four things that actually
    matter — NUT-20-only issuance, pre-settle cross-check against the mint's own record,
    transition-only settlement, and replay rejection (state.rs:326-349,418-467;
    web.rs:742-822) — while dropping everything TollGate doesn't need. It also keeps the
    mint where it belongs (a box with disk and uptime) and keeps the router thin.
  5. Explicitly avoid the hidden cost in (b): the inbound gRPC direction. Pecan requires
    the mint to dial the processor (main.rs:249-254, config.rs:341-345). On a fleet
    of routers behind CGNAT that is upside-down. If you go this way, plan for WireGuard from
    mint → router, or invert the design and have the gateway pull paid-quote state over the
    mint's REST API (GET /v1/mint/quote/{method}/{id}, which pecan already uses for
    verification, clients.rs:130-150) with the mint settling via a server-side rule. Pull
    over HTTPS needs no inbound port on the router at all — worth a spike before committing
    to gRPC.

Rough effort: wallet unit generalization (shared prerequisite) is the largest single
item; (c) is ~1 day; (b) is ~2–3 weeks including the reconciliation ledger and an
authenticated settle path; (a) is ~1 day to install plus an unbounded tail of Docker/seed/
TLS support burden and leaves you with the wrong shape.


Appendix — files read

processor/src/{backend,state,web,clients,config,main,checks,sessions,users}.rs,
processor/Cargo.toml, mintctl/src/{mint,preflight}.rs, Dockerfile,
docker-compose.yml, README.md, DESIGN.md, PRODUCT.md,
docs/{wallet-integration,operations}.md, docs/examples/mint.toml,
docs/upstream-research/01-singleton-unit-backend.md;
CDK v0.18.0-rc.0 crates/{cdk-payment-processor,cdk-mint-rpc,cdk-mintd,cdk,cdk-common}/…;
gonuts-tollgate@v0.13.0 cashu/cashu.go, wallet/{wallet,keyset,restore}.go;
cdk-go@v0.17.3 bindings/cdkffi/cdk_ffi.go;
tollgate-module-basic-go src/tollwallet/{select,cdk_wallet}.go,
docs/cashu-compatibility-matrix.md, go.mod, packaging/build-inputs.json.

@felixfelix-bot

Copy link
Copy Markdown
Collaborator Author

Phase 3 evidence — wallet custom-unit status, honest matrix

Proven working:

  1. CurrencyUnitCustom{mb} wallets: NewWallet + ActiveKeyset against the live mb-mint returns the mb keyset (01cfc681… unit={mb}) — probe TestProbeCdkCustomUnitWallet PASS (cdk-go 0.18.1 & 0.18.2).
  2. The cdk_wallet adapter receives cdk-cli-produced V4 sat tokens end-to-end (RECEIVED 4) — control PASS.
  3. Adapter bugs found & fixed on this branch (each caught by the probe):
    • cdkToken.Mint() rendered {url} (fmt of MintUrl struct) → could never match accepted-mints; now mu.Url
    • ReceiveOptions{} (nil AmountSplitTarget) → uniffi panic; now explicit SplitTargetNone{}

Not yet working, with the failure precisely isolated:

Hand-built V4 tokens (my bench python, blinded-mint via the mint's REST API) fail inside the cdk-go FFI's Receive with the rust panic Cannot drop a runtime in a context where blocking is not allowed masking the real error as Go panic: EOF. The isolation matrix (every cell a real probe run):

token origin wallet unit keyset result
sat cdk-cli sat V1 sat PASS — RECEIVED 4
sat hand-built sat V1 sat panic: EOF
mb hand-built mb/sat V1 mb panic: EOF
mb hand-built mb/sat V2 mb panic: EOF
sat hand-built sat V2 sat panic: EOF

Conclusion (corrected): the earlier V2-keyset theory was wrong — the variable is the token's origin. cdk-cli tokens carry fields my hand-built ones don't (the working sat token's proofs include d = DLEQ {e,s,r} as bytes; my attempts to fabricate DLEQ produced malformed or out-of-range secret key). The cdk wallet's rust receive rejects the hand-built proofs; the FFI's tokio-drop panic then masks the rejection. Next step: produce the mb token with a real wallet (cdk-cli -u mb — blocked on cdk-cli not auto-claiming paid paypal quotes on this mint; alternatives: force-pay its quote via mint management RPC, or run the GonutsWallet against the mb mint once it can see the keyset).

Not affected by this uncertainty: phase 1 (unit-aware pricing) and phase 2 (issuance chain) stand on their own evidence above.

…(PR OpenTollGate#1040)

An operator running their own CDK mint can now issue megabyte- or
minute-denominated ecash and a TollGate redeems it, measured end to end on
the bench mint:

    quote teller 100 mb -> settle -> wallet minted 18 proofs / 100 mb
    -> wallet-produced token (unit "mb") -> receive 100 mb

What blocked this before was not the mint. It was three separate nil-enum
panics in the cdk-go FFI that made every custom-unit call fail as
"panic: EOF" with no diagnostic, plus a token accessor that rendered a
struct instead of a URL.

- cdkToken.Mint() returned fmt of the MintUrl struct ("{url}"), so no token
  could ever match an accepted-mints entry; it now returns the URL. Every
  cdk wallet token was rejected before unit logic ran.
- ReceiveOptions/SendOptions/SendKind/AmountSplitTarget/P2pkLockedProofSendMode
  are lowered as non-optional enums by uniffi. Leaving them zero-valued
  panics inside the converter ("invalid enum value <nil>"), which surfaced as
  EOF on the Go side. All are now set explicitly.
- cdkToken.Unit() reads Token.Unit(), mapping sat/msat/usd and the
  CurrencyUnitCustom case. The merchant prices with this value, so an
  operator-issued "mb" token can no longer be silently priced as sats.
- Wallets are opened per (mint, unit): CdkWallet carries mintUnits and keys
  its wallet map and on-disk path by unit, so an mb mint and a sat mint on
  one router do not collide.
- Merchant passes each accepted mint's configured unit to the wallet layer.
  MintConfig.Unit empty still means sat, so existing configs are unchanged.

Probes (env-gated, skipped in normal runs) record the evidence:
TestProbeCdkCustomUnitWallet, TestProbeCdkReceiveMbToken,
TestProbeCdkSelfLoopMintsWithTheWallet.

Verified: tollwallet 34.6s ok, merchant 275.7s ok, default build clean,
cdk_wallet build clean.
@felixfelix-bot

Copy link
Copy Markdown
Collaborator Author

Phase 3 — SOLVED: the operator issuance loop works end to end (commit 876d873)

The wallet blocker from the previous comment is resolved. It was three nil-enum panics in the cdk-go FFI, not a limitation of the mint, the unit, or the keyset version.

Live measurement on the bench mb-mint (cdk-mintd, custom unit mb, method teller), run against commit 876d873c:

=== RUN   TestProbeCdkSelfLoopMintsWithTheWallet
    quote: id=01a15ef8-aaf3-7775-803e-cee3806c41d0 request=teller-01a15ef8-... state=1
settle response: 200 settled
    wallet minted 18 proof(s) totalling 100 mb
    wallet-produced token: 5350 bytes, unit reported "mb"
    SELF-LOOP RECEIVED 100 mb (wallet-produced token)
--- PASS: TestProbeCdkSelfLoopMintsWithTheWallet (0.45s)

That is the whole operator flow in Go: quote → operator settles → wallet mints mb ecash → token reports unit mb → another wallet receives 100 mb.

Root causes (each one produced the same useless symptom, panic: EOF)

  1. cdkToken.Mint() returned fmt of a struct → "{http://…}", which can never match an accepted-mints entry. Every cdk wallet token was rejected before any unit logic ran. Now returns the URL.
  2. uniffi lowers SendOptions/ReceiveOptions enums as non-optional. Zero-valued AmountSplitTarget, SendKind and P2pkLockedProofSendMode panic inside the Go-side converter (invalid enum value <nil> / Invalid P2PKLockedProofSendMode enum value: 0); the rust future then never completes and Go surfaces EOF. All three are now set explicitly.
  3. No unit accessor was wired. cdkToken.Unit() now reads Token.Unit(), mapping sat/msat/usd and CurrencyUnitCustom → the operator's unit string.

Because of (1)+(2) every earlier experiment — mine and the consultants' — was measuring a masked failure. That is why the previous comment's isolation matrix looked like it implicated the keyset version; it did not.

Also in this commit

  • Wallets are opened per (mint, unit): an mb mint and a sat mint on one router no longer collide (wallet map and on-disk path are unit-keyed).
  • The merchant passes each accepted mint's configured unit down to the wallet layer. MintConfig.Unit empty still means sat, so no existing config changes behaviour.
  • gonuts backend takes the same signature and ignores it (gonuts is sat-only by construction) — a caller needing custom units selects the cdk backend.

Verified

  • tollwallet 34.6s ok, merchant 275.7s ok
  • default build clean, cdk_wallet build clean
  • Probes are env-gated and skip in normal runs: TestProbeCdkCustomUnitWallet, TestProbeCdkReceiveMbToken, TestProbeCdkSelfLoopMintsWithTheWallet

Still open (next)

  • Router hardware: none of this has run on the GL-MT6000 yet — that is the next phase (build with cdk_wallet, deploy over serial, issue from the router, spend at the captive portal).
  • The bench teller-processor's /settle endpoint is a prototype with no auth; the product shape is the stripped Go processor described in the trade study, not this bench helper.
  • Foreign-token acceptance (a token built by a different wallet against the same mint) was the harder case and is not what this commit proves — this proves the wallet's own mint→send→receive loop. Worth a follow-up: cdk-cli's token also passes Receive, so the two producers are compatible; my bench python's hand-built tokens remain rejected and are no longer on the critical path.

@felixfelix-bot

Copy link
Copy Markdown
Collaborator Author

Phase 4 — the cdk build runs on real router hardware (musl FFI solved)

The router is pure musl (OpenWrt 24.10.2), and cdk-go v0.18.1 ships only a glibc arm64 libcdk_ffi.so. Cross-linking it proved the problem exactly:

undefined reference to `open64@GLIBC_2.17'
undefined reference to `writev@GLIBC_2.17'
undefined reference to `epoll_wait@GLIBC_2.17'

So I rebuilt the FFI for aarch64-unknown-linux-musl from the crate source. The one non-obvious requirement: RUSTFLAGS="-C target-feature=-crt-static" — musl targets default to crt-static, which makes a cdylib impossible (cannot produce cdylib ... does not support these crate types). Result: 18 MB .so needing only libgcc_s.so.1 + libc.so, zero undefined GLIBC refs.

Cross-built gateway: 19 MB, NEEDED libcdk_ffi.so, libc.so, 0 undefined GLIBC.

Deployed to the bench GL-MT6000 (tollgate-0195, aarch64) and verified on device:

# ldd /usr/bin/tollgate-wrt
    /lib/ld-musl-aarch64.so.1
    libcdk_ffi.so => /usr/lib/libcdk_ffi.so     <- the musl rebuild
    libc.so => /lib/ld-musl-aarch64.so.1
    libgcc_s.so.1 => /lib/libgcc_s.so.1

And the merchant comes up against the operator's custom-unit mint:

=== Merchant Initializing ===
mint probe: url=http://192.168.1.159:3358/v1/keysets status=200 keysets=1 ok=true
Setting up wallet...
Wallet Balance: 0
=== Merchant ready ===
/etc/tollgate/cdk-wallet-mnemonic.txt   (0600, 76 bytes — cdk wallet seed created)

That is the cdk wallet path running on router hardware against an mb-unit mint, with the mint's keyset fetched over the LAN. MintConfig.Unit is what made the router open the wallet in mb rather than sat.

Two deployment traps worth recording

  1. The init script runs /usr/bin/tollgate-wrt, not /usr/bin/tollgate. Deploying to the latter silently leaves the old binary running — the log still shows the gonuts constructor and the merchant falls back to degraded mode (error adding new mint: could not find an active keyset for the unit, a gonuts string). Installing to tollgate-wrt is what switched the backend over.
  2. Serial base64 transfer wedges the BusyBox shell (line-length limit → > continuation prompt). Router-side wget from a host HTTP server works; the host firewall had to be opened for the LAN subnet.

Still open

  • The end-to-end purchase on hardware: issue an mb token from the operator side, present it at the portal, and measure the granted allotment. That is the next phase.
  • The operator issuance surface is still the bench teller-processor's unauthenticated /settle; the product shape remains the stripped Go processor from the trade study.

@felixfelix-bot

Copy link
Copy Markdown
Collaborator Author

Phase 5 — END TO END ON HARDWARE: an operator-issued megabyte token buys access

The full loop now runs on the bench GL-MT6000. Operator side: a CDK mint issuing mb-denominated ecash. Client side: a real LAN client presenting that token at the captive portal. Measured, not asserted:

1. The operator issues mb ecash (cdk wallet, custom unit):

quote id=01a15f2b-9a39-772d-9f84-f44b42429700 request=paypal:f15c4741-...
quote state=2
minted 18 proof(s) totalling 100 mb
token: 5358 bytes, unit reported "mb"

2. The client presents it to the gateway (POST / from a real br-lan client):

POST / -> 200
{"kind":1022, ..., "tags":[["p","50:76:af:2f:33:ed"],
  ["allotment","2202009600"], ["metric","bytes"], ...]}

3. The gateway granted the session — read back after payment:

GET /balance -> {"status":1,"session_active":true,"metric":"bytes",
                 "usage":1024,"allotment":2202009600,
                 "remaining":2202008576,"mac":"50:76:af:2f:33:ed"}
GET /usage        -> 1024/2202009600
GET /session-state -> {"state":"active"}

100 mb paid → 2,202,009,600 bytes granted. The arithmetic is exactly right: 100 steps × step_size 22020096 = 2,202,009,600. The token's mb unit was priced as megabytes, not as 100 satoshis — which is precisely the silent mispricing the unit gate in this PR exists to prevent (before it, this same token would have bought ~100 sats' worth of nothing).

Bonus: an access-control gate observed working, unplanned

Presenting the same token from the router's own loopback was refused:

WARN Payment refused: the client has no resolvable identity
     error="device-unresolved: no MAC found for 127.0.0.1 in DHCP leases or ARP table"

That is the "identity is the socket" doctrine enforcing itself — a payment must come from a client the gateway can resolve. Worth noting because it means any future issuance/testing tooling must POST from a real LAN client, not from the router.

What this PR now demonstrates

layer state
unit-aware pricing (MintConfig.Unit, calculateAllotmentForUnit) landed, suites green
cdk FFI wallet with custom units (3 nil-enum bugs fixed) landed, self-loop green
musl FFI build for OpenWrt routers built + running on hardware
operator issuance → client redemption on hardware measured end to end

Still open

  • The operator issuance surface is the bench teller-processor's unauthenticated /settle. The product shape remains the stripped Go processor from the trade study (NUT-20, pre-settle cross-check, replay rejection) — this PR proves the protocol path, not the hardened operator UX.
  • Minutes (min) as a unit is the same code path with a different unit string; only mb has been exercised on hardware.
  • Rollback note for the bench: the shipped gonuts binary is preserved at /usr/bin/tollgate-wrt.gonuts.bak.

Three env-gated probes that record the evidence for the operator-issued
megabyte integration (PR OpenTollGate#1040). All skip unless their env vars are set, so
normal test runs are unaffected.

- TestProbeCdkCustomUnitWallet: opens a wallet in CurrencyUnitCustom and
  reads the mint's active keyset, proving the FFI accepts custom units.
- TestProbeCdkSelfLoopMintsWithTheWallet: quote -> operator settles ->
  wallet mints -> send -> receive, entirely in Go. This is the operator
  issuance loop, and it is what proved the three nil-enum FFI panics.
- TestProbeCdkMintMbTokenForPurchase: mints an mb token and writes it to
  TOLLGATE_CDK_TOKEN_OUT so it can be presented to a gateway as payment.

Measured on the bench mint (cdk-mintd, unit mb, method teller) and the bench
GL-MT6000: 100 mb paid -> 2,202,009,600 bytes granted, i.e. 100 steps x
step_size 22020096, priced in megabytes rather than satoshis.
@felixfelix-bot

Copy link
Copy Markdown
Collaborator Author

Phase 6 — minutes (min) verified, and the step_size trap it exposed

The goal asks for megabytes or minutes. Minutes now work too, on the same hardware:

Operator mints 60 minutes:

minted 16 proof(s) totalling 60 min

Client pays, gateway grants — and the arithmetic is exact:

POST / -> 200
  ["allotment","3600000"], ["metric","milliseconds"]
GET /balance -> {"session_active":true,"metric":"milliseconds",
                 "allotment":3600000,"remaining":3600000}

60 min → 3,600,000 ms = exactly one hour. The min unit was priced as minutes and granted as milliseconds.

A configuration trap worth flagging (not a code bug, but a sharp edge)

step_size defaults to 22020096 — 21 MiB, a bytes value. With a minutes mint and metric: milliseconds, the first run of this test granted:

60 steps x 22020096 = 1,321,205,760 ms = 6.1 HOURS for a 60-minute token

The unit gate in this PR correctly refused to misprice the token, but step_size and metric are still independent operator settings that can disagree with the unit. Setting step_size: 60000 (one minute per step) gives the correct 1:1 result above.

Suggestion for review: step_size and metric are only meaningful together with the unit. A minutes mint with a bytes-oriented step_size is a plausible operator misconfiguration that currently produces a silently generous grant. Options: default step_size from metric/unit, or warn at startup when metric: milliseconds and step_size is not a plausible millisecond value. I have not changed this behaviour in this PR — flagging it for a decision.

Where the two units stand

unit metric 60-100 unit token granted verdict
mb bytes 100 2,202,009,600 B (100 x 21 MiB) correct
min milliseconds 60 3,600,000 ms (60 x 60000) correct

Both were measured on the bench GL-MT6000 with a real LAN client (50:76:af:2f:33:ed).

@felixfelix-bot

Copy link
Copy Markdown
Collaborator Author

Superset-chain scan of the whole open queue — 8 chains, 11 PRs closable

Method: for every pair of open PRs, check whether one's changed-file set is a
superset of the other's and every shared file's code changes (+/- lines
only) are identical. Empty set-difference + identical patches == containment.

Automated with find-supersets.py (no build, no router, ~2 minutes for 100 PRs).
Every claim below is mechanical.

THE BIG ONE — a 5-deep chain I had missed by hand

There is a CI-work chain where each PR adds a couple of files to the last:

#879  ci: frugality wave 1 — concurrency, timeouts, PR-path slimming, weekly scans
#881  ci: self-hosted runner — ai-legion-tbmg registered + adoption probe
#905  ci(test): count-soak lane gates the state-leak family
#924  ci(ngit): behavioral gate before announce — happy-path suite
#925  ci(release-check): installer-contract lane per release candidate
#921  ci: pr-conflict-sweep — the queue's conflict drift in one command   <-- TERMINAL

Verified independently (not just the scanner's word): comm shows nothing in
#925 that is absent from #921
, and all 18 shared files have identical code
changes
.

land #921 -> close #879, #881, #924, #925

That is 4 PRs closed with one landing. Merging any earlier one instead creates
conflicts with the later ones and leaves the CI work half-applied — which is
exactly the conflict drift #921 itself is built to detect.

Full chain list

land close shared files
#921 #879, #881, #924, #925 4 / 5 / 12 / 18 — all identical
#1030 #1028, #1029 1 / 2 — identical
#1020 #1018, #1019 8 / 10 — identical
#1014 #975 6 — identical
#999 #954 3 — identical (already reported)
#1034 #1003 1 — identical
#1031 #1028 1 — identical
#1040 #1028 1 — identical

The merge order matters

#1028 is contained by four different PRs (#1029, #1030, #1031, #1040). It is
safe to close regardless of which of those lands. Same for #879/#881, which are
contained by #905, #919, #921 and #924 — any of those closes them.

Cleanest sequence:

  1. ci: pr-conflict-sweep — the queue's conflict drift in one command #921 -> close ci: frugality wave 1 — concurrency, timeouts, PR-path slimming, weekly scans (~70% PR-minute cut) #879, ci: self-hosted runner — ai-legion-tbmg registered + adoption probe #881, ci(ngit): behavioral gate before announce — happy-path suite on the built x86_64 .apk #924, ci(release-check): installer-contract lane per release candidate (M2 remainder) #925 (4 closed)
  2. fix(deps): go mod tidy for upstream_detector + upstream_session_manager #1030 -> close fix(release): shard-driver poll must not put progress lines into the conclusion string #1028, fix(packaging): armour fetch-upx.sh against SIGPIPE exit 141 #1029 (2 closed)
  3. fix(build): stage the CVM daemon in build-sdk-package — apk lane broken since cvm landed #1020 -> close fix(hooks): unbreak pre-commit + run the SDK-Go feed audit at commit time #1018, fix(build): update-build-inputs must audit each SDK era against its own release #1019 (2 closed)
  4. docs(mptcp): bonding measurement record + status vs the four goals (gap analysis) #1014 -> close fix(deps): restore battery-green main — bech32 go.sum entries for #938's nip19 import surface #975 (1 closed)
  5. post-v0.6.0: exempt loopback from RateLimitMiddleware so the starting refusal wins (#953) #999 -> close fix(api): exempt loopback from RateLimitMiddleware so the starting refusal wins (implements #953) #954 (1 closed)
  6. fix(modules): green the deps-sync fence — align the drift classes on main #1034/fix(release): armour the shard template's UPX print; drop paywalled drive.cashu.email mirror #1031/feat: operator-issued MB/minute ecash — unit-aware pricing + CDK mint integration (DRAFT) #1040 -> whichever lands, close build(mptcp_bonding): align the go directive with the rest of the tree (1.24.2 → 1.26.0) #1003 / fix(release): shard-driver poll must not put progress lines into the conclusion string #1028

Potential: 11 PRs closed from this scan alone, with no code risk and no
re-review of identical hunks.

Why a scanner beats reading

I found four chains by hand earlier today (#999/#954, #1016/#887,
#1029/#1028, #150/#120). The scanner found eight more, including a
five-deep chain whose shape is invisible from any single PR page — each PR looks
independently mergeable, and the containment only shows up when you compare all
pairs.

Recommendation: run this as a routine before every merge wave. It costs
nothing, needs no build, and turns a queue-reduction task into a mechanical one.
The script is at ~/worktrees/pr-review/find-supersets.py.

Caveat I want to be explicit about

Containment is not a substitute for review. #921 containing #925's diff means
merging #921 applies those changes — it does not mean they are correct. For the
chain above, the CI changes still deserve a read: enabling self-hosted runners and
per-PR lanes has security implications (the kit's own notes warn that flipping a
hardware lane on without restricting the trigger hands contributors code execution
on the flashing machine).

So: close the duplicates on this evidence, review the one that remains.

@felixfelix-bot

Copy link
Copy Markdown
Collaborator Author

Phase 7 — the mint runs ON the router: "from their router" satisfied

The goal says the operator issues from their router. That now holds literally: cdk-mintd runs on the GL-MT6000 alongside the gateway.

The mint, built for the router and running there:

$ /tmp/cdk-mintd --version
cdk-mintd 0.18.0
$ netstat -ltn | grep 3398
tcp  0  0  0.0.0.0:3398  LISTEN
$ tail mint.log
INFO cdk::mint: Starting payment wait task for PaymentProcessorKey { unit: Custom("mb"), method: Known(Onchain) }
INFO cdk_mintd: listening on 0.0.0.0:3398

Built with the same musl recipe as the FFI: cargo build --release --target aarch64-unknown-linux-musl -p cdk-mintd --features fakewallet,sqlite → statically linked, 59 MB, zero NEEDED entries. Needs protoc on the build host (cdk-signatory/build.rs), and the mint config's mnemonic must be an env: or file: reference — a literal mnemonic is rejected: info.mnemonic must use an env:VARIABLE or file:/absolute/path secret reference.

The gateway pointed at its own box's mint:

mint probe: url=http://192.168.1.1:3398/v1/keysets status=200 keysets=1 ok=true
Wallet Balance: 0
=== Merchant ready ===

A client then bought access with ecash from that mint:

POST / -> 200
  ["allotment","2202009600"], ["metric","bytes"]
GET /balance -> {"session_active":true,"usage":1024,"allotment":2202009600,
                 "remaining":2202008576}
GET /usage        -> 1024/2202009600
GET /session-state -> {"state":"active"}

100 mb → 2,202,009,600 bytes, the correct 100 × 21 MiB. Mint and gateway now share one box, and the whole loop is measured there.

One failure mode worth reporting honestly

Paying with a token I built with raw HTTP (blind-mint against the router's own mint) failed:

{"code":"payment-processing-failed",
 "content":"Payment processing failed: payment processing panicked: EOF"}

The same raw-HTTP token on the LAN mint failed identically, and a wallet-produced token against the same router mint succeeded — so this is the cdk-go FFI rejecting externally-constructed tokens, not anything about running the mint on the router. cdk-cli's tokens are accepted by Receive, so wallet-to-wallet works; my hand-built proofs specifically do not, and I have not root-caused which field the FFI's Receive requires (it rejects before contacting the mint, and the rust panic masks the reason as EOF). Worth an upstream issue against cdk-go: the error-masking makes a recoverable validation failure indistinguishable from a crash.

Final state of the goal

requirement evidence
operator issues megabytes 100 mb → 2,202,009,600 B granted
operator issues minutes 60 min → 3,600,000 ms granted
from their router cdk-mintd + gateway both on the GL-MT6000
gap analysis with consultants 3 consultants + trade study (staged path)
tested on fleet router over serial A04E / tollgate-0195, every measurement above
draft PR with progress + proof this PR, phases 1–7

The remaining work is the operator-facing hardening already described in the trade study: the issuance surface is still an unauthenticated bench /settle, and the product shape is the stripped Go teller-processor (NUT-20, pre-settle cross-check, replay rejection). The protocol path is proven end to end.

…int balance

Two defects found while designing the operator-issuance UI, both introduced by
the unit work in this branch.

1. accepted_mints[].unit was missing from the config schema. The admin board
   renders settings from the schema, so the operator could not set the unit at
   all -- only hand-edit JSON -- and the schema drift test failed:

     Config struct has json tag "accepted_mints[].unit" but schema has no
     matching entry - UI won't show this field

   Added next to price_unit, with the consequence stated in the description:
   a token in any other unit is refused rather than mispriced, so the field
   must match the mint's real keyset unit.

2. GetBalanceByMint read w.wallets[mintUrl] while every writer keys wallets
   mintUrl+"|"+unit (getOrCreateWallet). On a router with a custom-unit mint
   the lookup never matched and reported 0 while the wallet held funds --
   silently, which is the worst shape for a balance. Now sums across every
   unit open for that mint.

Verified: config_manager ok (drift test now passes), tollwallet ok under
-tags cdk_wallet, go vet clean. Merchant suite running separately.
@felixfelix-bot

Copy link
Copy Markdown
Collaborator Author

Phase 8 — two defects found while designing the UI, fixed on this branch

Designing the operator-issuance screen surfaced two bugs in this branch's own unit work. Both are fixed in 8c7f860c.

1. accepted_mints[].unit was invisible to the admin board — and a drift test was failing on it:

Config struct has json tag "accepted_mints[].unit" but schema has no matching entry
    — UI won't show this field
FAIL  TestSchemaCoversAllConfigStructFields

The board renders settings from the schema, so the unit I added could not be configured through the UI at all — only by hand-editing JSON. Without it the gateway refuses every mb token as a misprice (merchant.go:2122), so the feature was unreachable for a real operator. Added next to price_unit, with the consequence stated in the field description. The drift test now passes.

2. Per-mint balance silently reported 0 for a custom-unit mint.

Every writer keys wallets mintUrl+"|"+unit (getOrCreateWallet), but the reader used the bare mint URL:

224:  wlt, ok := w.wallets[mintUrl]
441:  if wlt, ok := w.wallets[mintUrl+"|"+unit]; ok {
476:  w.wallets[mintUrl+"|"+unit] = wallet

So on a router with an operator mint the balance read zero while the wallet held funds — and it read zero silently, which is the worst shape for a money figure. Now sums across every unit open for that mint.

Verified after the fixes:

ok  config_manager   0.117s
ok  tollwallet       0.987s   (-tags cdk_wallet)
ok  merchant       279.651s
go vet              clean

One environment trap worth recording

Host test runs failed to link with a stray global Go setting:

go env CGO_LDFLAGS
  -L/home/c08r4d0r/repos/cashu_spilman_channels/.../lib/linux-arm64 -lcdk_spilman_go
/usr/bin/ld: skipping incompatible .../linux-arm64/libcdk_spilman_go.so
collect2: error: ld returned 1 exit status

A leftover CGO_LDFLAGS from unrelated work pinned an arm64 library into every cgo link, which broke the cdk_wallet test binary on an amd64 host. Tests pass with CGO_LDFLAGS="". Not this PR's defect, but it will bite anyone building this branch on this host — worth unsetting globally.

Status of the goal itself

Unchanged from the last comment, and complete: mb and min both issue and redeem on hardware, the mint runs on the router, and both forges carry the branch. These two fixes remove the blockers that stood between the working protocol path and a UI an operator could actually use — the remaining work is the issuance CLI/ubus surface and durable quote state, both described in the mockup set (OpenTollGate/tollgate-captive-portal-site#87).

@felixfelix-bot

Copy link
Copy Markdown
Collaborator Author

Phase 9 — the REAL usage pattern: a held mb wallet spent in small increments

Clarification from the operator on what this feature is for: the customer is issued mb ecash, holds it in their wallet, and spends it in small frequent transactions. Not one big payment. That is a different test than the ones above, and it exposed a configuration bug that made the unit lie.

Measured on A04E: a 10 mb wallet, spent one proof at a time — 7 separate transactions.

held wallet: 7 proofs, unit=mb, mint=http://192.168.1.1:3398
amounts: [1, 2, 2, 1, 2, 1, 1]  total=10 mb

spend 1/7: 1 mb -> HTTP 200   grant=1048576   allotment=1048576
spend 2/7: 2 mb -> HTTP 200   grant=3145728   allotment=3145728
spend 3/7: 2 mb -> HTTP 200   grant=5242880   allotment=5242880
spend 4/7: 1 mb -> HTTP 200   grant=6291456   allotment=6291456
spend 5/7: 2 mb -> HTTP 200   grant=8388608   allotment=8388608
spend 6/7: 1 mb -> HTTP 200   grant=9437184   allotment=9437184
spend 7/7: 1 mb -> HTTP 200   grant=10485760  allotment=10485760

1+2+2+1+2+1+1 = 10 mb → 10,485,760 bytes = exactly 10 MiB. The grant accumulates across separate purchases, the session stays active throughout, and usage is metered per transaction (~5–7 KB each). This is the pattern the feature exists for, working.

The bug this exposed: the unit lied by 21×

step_size was 22020096 (21 MiB — a bytes default) while the mint's unit is mb. So one megabyte of ecash bought 21 MiB of access. Every earlier test passed because it measured a single payment and the arithmetic was self-consistent — it was consistent and wrong.

Fixed on the bench to step_size: 1048576, so 1 mb = 1 MiB. This is configuration, not code, but it is the difference between a token whose denomination means something and one whose denomination is decorative. For spending in small increments it is decisive: at 21× a "1 mb" top-up is not a small transaction, it is three week-passes.

Suggestion for review: unit and step_size must agree, and nothing enforces that today. A custom-unit mint with the bytes default is the obvious misconfiguration (it is the one I fell into), and it fails generously — the operator silently gives away 21× the access. A startup check could refuse or warn when unit == "mb" and step_size is neither 1000000 nor 1048576.

A gate that behaved correctly, worth recording

The first attempt at the small-spend test failed all 7 payments with HTTP 400. The reason was in the router log, and it is the right reason:

PurchaseSession pre-flight: MAC 50:76:af:2f:33:ed not registered in NDS
after 5 probes; refusing payment before Receive

Refused before Receive — before any money moved — because the client was not registered in the captive-portal gate. That is exactly the ordering AGENTS.md demands (validations that can be done locally run before the irreversible operation). Once the client appeared in ndsctl, all 7 payments succeeded. A test harness must make the client visible to NDS first; that is a harness concern, not a product defect.

Also: the router's firewall ACCEPT for the mint port did not survive a service restart. Re-applied and persisted properly via uci add firewall rule (allow-tollgate-mint, lan, tcp, 3398, ACCEPT) rather than a bare iptables -I.

… size

step_size and the mint's declared unit are configured in two separate places
and nothing kept them in agreement. The failure mode is not a crash, it is a
silent giveaway, and it is the one this integration actually hit: on the
hardware bench an operator mint declared unit "mb" while step_size still
carried the 21 MiB bytes default (22020096), so ONE megabyte of ecash bought
21 MiB of access -- 21x the access for the same money, on every sale.

Every test passed while that was true, because a single payment's arithmetic
is self-consistent no matter how wrong the scale is. It took a held-wallet,
many-small-spend measurement to see it.

ValidateUnitPricing judges only the unit/metric pairs it knows (mb, min) and
stays silent otherwise -- guessing at an unrecognised unit's scale would
produce false refusals. Empty unit means "sat", and sat mints legitimately
carry the bytes default, so legacy configs are never judged. A zero step_size
is out of scope.

Wired into LoadConfig as a WARNING, not a hard failure: the router must keep
serving customers who already paid, but the operator has to see it in
logread. The message states the offending number, what the config would grant
instead of one unit, and the accepted values.

TDD: test written first and observed failing (undefined: ValidateUnitPricing),
then implemented. Mutation-checked by widening the mb bound to 999999999 --
the incident case then failed as required. One of my own assertions was wrong
(claimed the error should contain "21"; it contains the raw 22020096 and the
MiB equivalents, which is the more actionable form) and was corrected rather
than loosened.

Verified: config_manager ok 0.115s, go vet clean.
@felixfelix-bot

Copy link
Copy Markdown
Collaborator Author

Phase 10 — the misconfiguration now has a guard (2c486b99)

Phase 9 found that a custom-unit mint priced with another unit's step_size fails generously and silently — unit mb with the 21 MiB bytes default sold 21× the access for the same money. That was configuration, but nothing in the code kept the two settings in agreement, so the next operator would hit it too.

ValidateUnitPricing now refuses that combination.

Design choices worth reviewing:

  • It only judges pairs it knows (mb+bytes, min+milliseconds) and stays silent otherwise. A heuristic that guessed at an unrecognised unit's scale would produce false refusals on units we did not anticipate — worse than no check.
  • Empty unit means sat, and sat mints legitimately carry the bytes default, so every legacy config is untouched. This was the main regression risk and it has its own test cases.
  • It warns, it does not refuse to boot. LoadConfig logs and continues. A router that refused to start would cut off customers who already paid; the operator needs to see this in logread, not lose the network. There is an argument for failing hard on a fresh config and warning on an existing one — I did not make that distinction, and would take a reviewer's view.
  • The message is actionable, naming the offending number, what the config would actually grant, and the accepted values:
mint unit "mb" with metric "bytes" cannot be priced with step_size 22020096:
one mb of ecash would grant 22020096 units of access instead of one.
Set step_size to 1000000 (decimal MB) or 1048576 (1 MiB), or change the
mint's unit to match. Refusing rather than mispricing, because this fails
generously and silently — the operator gives away access on every sale

TDD and mutation check, both observed:

RED:     config_manager/unit_pricing_validate_test.go:102: undefined: ValidateUnitPricing
GREEN:   ok  config_manager  0.003s   (10 table cases + a content assertion)
MUTANT:  widened the mb bound to 999999999 ->
         --- FAIL: TestValidateUnitPricing/THE_INCIDENT:_mb_priced_with_the_21_MiB_bytes_default
             want error for unit="mb" metric="bytes" step_size=22020096, got nil

The mutation matters here: a validation test that cannot fail is indistinguishable from no validation, and this one is guarding money.

One of my own assertions was wrong — I had asserted the error should contain "21", which passes for the wrong reason (the raw byte count also contains it). Corrected to assert the actionable content rather than loosened.

Verified: config_manager ok 0.115s · go vet clean.

Still open, and it is the actual product ask

The goal says tokens are issued on request. Today that means running a Go test with environment variables:

TOLLGATE_CDK_MINT_FOR_PURCHASE=... TOLLGATE_CDK_PURCHASE_AMOUNT=10   go test -tags cdk_wallet -run TestProbeCdkMintMbTokenForPurchase

That is a developer harness, not an operator action. The missing piece is a tollgate wallet issue subcommand — quote → settle → mint → print token — which is also the surface a UI would call. fund/drain/balance/info already exist in the CLI tree (cli/server.go:210-219), so the extension point is clear; what needs care is that Send failing after MintTokens succeeded leaves funds in the operator's wallet rather than a handed-out token, and that path must be journaled. I have not built it — flagging it as the next concrete step rather than leaving it implied.

RequestMintQuote hardcoded PaymentMethodBolt11. A custom-unit mint does not
serve bolt11 for its custom unit, so asking for one panics inside the cdk-go
FFI and surfaces as a bare "panic: EOF" with no indication of the cause.

Measured on the A04E bench (tollgate-0195) against the router's own mint:

  panic: EOF
    cdk-go/bindings/cdkffi.(*Wallet).MintQuote(...) cdk_ffi.go:9359
    tollwallet.(*CdkWallet).RequestMintQuote(...)
    merchant.(*Merchant).IssueOnRequest(...)

The same mint advertises bolt11/mb, bolt12/mb, onchain/mb and paypal/mb, and
only the custom method produces a quote for a custom unit. This is why the
probe tests in this branch always passed PaymentMethodCustom explicitly while
the product path did not: the probes never exercised RequestMintQuote.

Now: a custom unit uses the custom method, sat keeps bolt11 as before, and the
method name is overridable by TOLLGATE_CDK_PURCHASE_METHOD so an operator mint
with a differently-named processor is not hard-coded to the bench's.

Verified: go vet clean (-tags cdk_wallet); tollgate wallet issue 5 mb against
the router mint now returns a token instead of panicking.
Adds the operator-facing issuance path the CDK mint integration exists for: the
operator asks for an amount in a unit and gets a token to hand to a customer,
who holds it in their own wallet and spends it in small transactions. Issuance
is "on request" -- no teller ceremony, no batching, no queue.

  tollgate wallet issue 5 mb [mint-url]

Issuance is a distributed state-machine change, so the order is the design:

 1. Validate locally (amount, unit, known mint) BEFORE touching the mint --
    AGENTS.md: no irreversible monetary operation before validations that can
    be done locally.
 2. RequestMintQuote -- a promise, not a payment; no value moves.
 3. Wait for the quote to be PAID. MintTokens on an unpaid quote is refused by
    the mint, but issuance must not lean on the mint as its only guard.
 4. MintTokens -- the POINT OF NO RETURN: the mint creates the ecash and marks
    the quote ISSUED. No compensating action un-mints it.
 5. Send -- splits the value into a handover token.

When Send fails after a successful MintTokens the value is IN THE OPERATOR'S
WALLET and no token was delivered. That is reported as such, explicitly,
because a plain "failed" invites a blind retry and re-minting an ISSUED quote
is the duplicate-outputs brick (OpenTollGate#257/OpenTollGate#266):

  FUNDS HELD: 5 mb were minted from quote <id> and are in the operator wallet,
  but sending the token failed: ... do NOT re-mint this quote; drain the wallet
  (tollgate wallet drain cashu) to recover the value

The boot gate (startingMerchant) and the degraded merchant both refuse issuance
rather than answering optimistically: a money-creating operation must fail
loudly when there is no reachable mint.

TDD: test written first and observed failing (undefined: IssueOnRequest), then
implemented. Six cases, all green in 0.057s. Two mutation-checked:
 - removing the unpaid-quote guard -> RefusesUnpaidQuote fails
 - making the Send failure a plain error -> SendFailureIsReportedAsFundsHeld fails

The wait bounds are merchant fields rather than constants so the timeout path is
exercised instead of skipped: the first version of the unpaid test sat for the
full 120s production timeout, which is a test that gets deleted.

Known gap, stated not hidden: the quote handle lives in process memory for the
duration of the call, so a restart between payment and MintTokens loses it and
the paid value must be recovered from the mint by quote ID. A durable quote
journal is the correct fix and is not built.

Verified: go build clean; merchant ok 277.5s, config_manager ok 0.136s, cli ok
0.453s, merchant_types ok 0.005s, upstream_session_manager ok 1.555s.
On hardware: tollgate wallet issue 5 mb against the router's own mint returned
unit=mb, proofs=[4,1], total 5 mb.
@felixfelix-bot

Copy link
Copy Markdown
Collaborator Author

Phase 11 — issuance on request now works on the router (c0cf03ce), and it exposed a cdk version skew

The operator-facing path exists and runs on hardware:

$ /tmp/tollgate-issue wallet issue 5 mb
Issued 5 mb
unit: mb
amount: 5
mint: http://192.168.1.1:3398
token: cashuBo2Fmd2h0dHA6Ly8xOTIuMTY4LjEuMTozMzk4YXVibWJhdIGiYWlIANQ...

Decoded: unit=mb, proofs=[4, 1], total 5 mb. Two commits: 998da1db (a real bug fix) and c0cf03ce (the feature). Both forges are at c0cf03ce.


Bug 1 — RequestMintQuote hardcoded bolt11, so a custom-unit mint panicked

cdk_wallet.go:351 asked for PaymentMethodBolt11 unconditionally. A custom-unit mint does not serve bolt11 for its custom unit, so the request panicked inside the FFI as a bare panic: EOF:

panic: EOF
  cdk-go/bindings/cdkffi.(*Wallet).MintQuote(...)  cdk_ffi.go:9359
  tollwallet.(*CdkWallet).RequestMintQuote(...)
  merchant.(*Merchant).IssueOnRequest(...)

This is why the probe tests in this branch always passed a custom method explicitly while the product path did not — the probes never went through RequestMintQuote. Now: custom unit → custom method, sat → bolt11, and the method name is overridable via TOLLGATE_CDK_PURCHASE_METHOD so another operator's mint is not hard-coded to this bench's processor name.

Bug 2 — a token the router issues cannot be spent at that same router

Spending a router-issued token returned HTTP 400. The notice carried the reason:

Invalid cashu token: cdk-go token decode failed: FfiError: Internal:
ErrorMessage=Invalid token: Semantic(None, "missing field `m`")

The mint's serializer emits the mint URL under f; the wallet's decoder requires m. Confirmed in cdk's own source:

// cdk/crates/cashu/src/nuts/nut00/token.rs:435
pub struct TokenV4 {
    /// Mint Url
    #[serde(rename = "m")]      // <- what the decoder requires
    pub mint_url: MintUrl,

So the two sides are different cdk versions: cdk-mintd 0.18.0 on the router writes f, and the cdk-go FFI bindings (v0.18.1) read m. This is not a TollGate bug — it is mint-serializer vs wallet-decoder skew inside cdk — but it makes the feature unusable end to end until one side is aligned.

Proof it is exactly this, and nothing else. Re-encoding the same token with m instead of f, changing nothing else:

HTTP 200
tags: [["p","50:76:af:2f:33:ed"], ["allotment","5242880"], ["metric","bytes"], ...]

5,242,880 bytes = exactly 5 MiB for a 5 mb token. The gateway prices and redeems router-issued custom-unit ecash correctly; only the field name stands in the way.

Options for the skew (a maintainer decision, not mine to make)

  1. Normalise on decode in TollGate — accept f as an alias for m when decoding V4. Small, local, unblocks the integration immediately; it also means carrying a compatibility shim for an upstream inconsistency.
  2. Pin matching cdk versions — build the mint from the same cdk revision as the FFI bindings. Cleanest, but couples the two artifacts' release cycles.
  3. Report upstream and wait — the honest answer for a cdk defect; slowest for this feature.

I lean toward (1) plus an upstream issue, because the skew fails at the worst place: the customer holds a token that looks valid and is refused by the router that issued it.


Two things that cost time, worth recording for the next person

A 400 is not always a bad token. Two spends failed because NoDogSplash had not registered the client MAC — the gateway refuses before Receive, which is the correct fund-safety order. An HTTP hit on the portal (:2050) registers the client. A harness must do that first.

Host→router-mint reachability is irrelevant to this test. The CLI issues from the router, which reaches its own mint on loopback. I spent time chasing a NoDogSplash guest-intercept that turned out not to matter.

Build environment: src/cmd/tollgate-cli is a separate Go module — it needs GOWORK=off to build, or the go.work hijacks resolution. The musl cross-compiler must be passed as an absolute path. And sha256sum -c reports FAILED when the saved filename differs from the SUMS entry even though the bytes are identical — check the hash directly before concluding a transfer was corrupt.

Where this leaves the goal

requirement status
issue megabytes from the router done — wallet issue 5 mb, token verified
issue minutes from the router path is identical; not yet measured with min on this build
gap analysis with consultants done (6 consultants across two rounds)
tested on fleet routers over serial done — every measurement above is A04E/tollgate-0195
draft PR with progress + proof this PR, phases 1–11
report what does and does not work the skew above is reported, not buried

Still not done, stated plainly: the version skew blocks the actual customer spend until it is resolved; the quote handle lives in process memory, so a restart between payment and MintTokens loses it (durable journal not built); and min has not been re-measured on this build.

A token the router issued could not be spent at that same router. The notice
carried the reason:

  Invalid cashu token: cdk-go token decode failed: FfiError: Internal:
  ErrorMessage=Invalid token: Semantic(None, "missing field `m`")

cdk-mintd 0.18.0 serializes TokenV4 with the mint URL under "f"; the cdk-go FFI
decoder requires "m", because cdk's own definition says so:

  #[serde(rename = "m")]
  pub mint_url: MintUrl,        crates/cashu/src/nuts/nut00/token.rs:435

So the mint's serializer and the wallet's decoder disagree. This is a cdk
version skew, not a TollGate defect, but it breaks the feature at the worst
possible point: the customer holds a token that looks valid and is refused by
the router that issued it.

The shim rewrites exactly one field name in one token format. Every other case
returns the input byte-identical: V3 tokens, empty strings, unparseable input,
and payloads that already carry "m" (including the ambiguous case where both
keys are present, which is left for the decoder to judge rather than guessed
at). Nothing else about the token is repaired, and a token this cannot parse
goes to the FFI unchanged so the authoritative error still surfaces.

Proof it is this and nothing else, from the bench: re-encoding the same token
with "m" and changing nothing else turned HTTP 400 into HTTP 200 with
allotment=5242880 bytes -- exactly 5 MiB for a 5 mb token.

TDD: test written first and observed failing (undefined:
normalizeV4MintFieldName), then implemented. Mutation-checked by disabling the
rewrite -- the "f" case then failed as required.

No new dependency: fxamacker/cbor/v2 was already in the module graph (indirect).

Verified: tollwallet ok 33.6s; go vet clean.
@felixfelix-bot

Copy link
Copy Markdown
Collaborator Author

Phase 12 — the loop closes: router-issued ecash is now spendable (ffc7c433)

Phase 11 root-caused the HTTP 400 to a cdk version skew (mint serializes the mint URL as f, decoder requires m). This adds the compatibility shim and proves the whole loop on hardware with no manual fixup.

On A04E, one command, then a raw POST:

$ /tmp/tollgate-issue wallet issue 3 mb
Issued 3 mb
token: cashuBo2Ftd2h0dHA6Ly8xOTIuMTY4LjEuMTozMzk4YXVibWJhdIGiYWlIANQ...

decoded: field "m" = http://192.168.1.1:3398, unit=mb, proofs=[2,1]

POST / (raw token, nothing re-encoded) -> HTTP 200
tags: [["p","50:76:af:2f:33:ed"], ["allotment","3145728"], ["metric","bytes"], ["start-time","1792625644"]]

3,145,728 bytes = exactly 3 MiB for a 3 mb token. Issue on the router, hold in the wallet, spend at the portal.

The shim (v4_field_skew.go) rewrites exactly one field name in one token format. Every other case returns the input byte-identical: V3 tokens, empty strings, unparseable input, payloads already carrying m, and the ambiguous both-keys case (left for the decoder rather than guessed). A token it cannot parse goes to the FFI unchanged so the authoritative error still surfaces. No new dependency — fxamacker/cbor/v2 was already indirect.

TDD + mutation: test first (undefined: normalizeV4MintFieldName), then implemented; disabling the rewrite makes the f case fail as required. tollwallet ok 33.6s, go vet clean.

Two refusals also verified on the router (no token, no money moved):

$ tollgate wallet issue 5 min    -> Error: No mint configured for unit "min"
$ tollgate wallet issue 5 parks  -> Error: No mint configured for unit "parks"
$ tollgate wallet issue 0 mb     -> Error: cannot issue: amount must be greater than zero

Should this shim exist?

It is a workaround for an upstream inconsistency, so it deserves a reviewer's view. The alternative is pinning the mint's cdk revision to the FFI bindings'. I chose the shim because the skew fails at the worst point — the customer holds a token that looks valid and is refused by the router that issued it — and because it is one field, narrowly scoped, with a source citation in the comment. If you would rather fix it upstream and pin versions, this commit is designed to be revertible on its own.

Remaining gaps, unchanged

  • the quote handle is process memory — a restart between payment and MintTokens loses it; the paid value must be recovered from the mint by quote ID. Durable journal not built. Still the biggest fund-safety gap in this feature.
  • min not re-measured on this build — the code path is identical and the unit is now guarded, but I have not issued a minutes token since ffc7c433.
  • the /settle endpoint used on the bench is an unauthenticated prototype, as flagged in phase 2.

@felixfelix-bot

Copy link
Copy Markdown
Collaborator Author

Phase 13 — the min unit: two blockers solved, one isolated (c0cf03ce+ffc7c433)

Following up on phase 12's "min not measured". I did not guess at this — three things were needed, and I got two of them working.

Solved: a unit added to an existing mint DB gets no keyset

supported_units is a fake-wallet field (config.rs:846), not keyset config — my earlier attribution was wrong. The real mechanism is:

[[fake_wallet.keyset_rotations]]   # config.rs:789 — "Additional keyset rotations
unit = "min"                       #  to create during mint build"

Adding it + config apply + restart created 00ceb288c44eb62b unit=min. Operational finding: a unit added to an existing mint DB does not get a keyset on restart; it needs an explicit rotation entry.

Solved: a unit needs its own payment processor

cdk registers one processor per payment_backend entry:

PaymentProcessorKey { unit: Custom("mb"), method: Custom("paypal") }   # before
PaymentProcessorKey { unit: Custom("min"), method: Custom("paypal") }  # after

payment_backend is a Vec<PaymentBackend> (config.rs:1130), so it takes an array-of-tables: the single [payment_backend] table must become [[payment_backend]] or the parser fails with duplicate key. After that the mint advertises paypal/min. The mint now has keysets and processors for both units.

config validate --file caught a bad config before it replaced a working one — worth using every time.

Not solved: the FFI still panics, and my hypothesis was wrong

wallet issue 30 min still panics panic: EOF, in uniffi's deserialiser:

cdkffi.FfiConverterMintQuote.Read -> LiftFromRustBuffer -> uniffiRustCallAsync -> Wallet.MintQuote

I hypothesised the mint omitted a field the converter needs. That is disproven — I diffed the mint's own quote responses for both units and they are structurally identical:

mb : {"quote":"01a160e4-8b42…","method":"paypal","amount":5,"amount_paid":0,
      "amount_issued":0,"updated_at":…,"unit":"mb","expiry":…}
min: {"quote":"01a160e4-8f2a…","method":"paypal","amount":5,"amount_paid":0,
      "amount_issued":0,"updated_at":…,"unit":"min","expiry":…}

Same fields, same types; only the unit string differs. So the fault is not in the response, and it is not something this PR can fix by normalising a field — it is on the wallet-construction side of the cdk-go bindings. Reporting it as isolated rather than fixed.

Control, same reconfigured mint: wallet issue 2 mb → Issued 2 mb. The mint is healthy; the failure is specific to the min path.

The unit guard proved itself on hardware

With the gateway configured for min, asking for mb was refused rather than mispriced:

Error: cannot issue mb: mint http://192.168.1.1:3398 is configured for unit "min"
— issuing a "mb" token against it would produce ecash the gateway refuses or misprices

That is the ValidateUnitPricing/unit-gate work from phases 9–10 doing exactly its job.

State left behind

Router restored to a verified working mb config (config.json.mbv, Issued 2 mb re-confirmed). The mint is strictly more capable than before: keysets and payment processors for mb and min. Backups: config.json.mbv, mint.toml.pre-min, mint.toml.pre-min2.

Next lead for min: the same min issuance succeeded earlier against the host min mint (:3368, 60 min → 3,600,000 ms) but fails against the router's mint — same code, different mint. That discriminator is the place to look, and it is the concrete next step rather than a guess.

@Amperstrand

Copy link
Copy Markdown
Collaborator

Local CI gate — PR #1040: feat: operator-issued MB/minute ecash — unit-aware pricing + CDK mint integration (DRAFT)

Run of scripts/pr-verify.sh (the local CI gate: GitHub Actions is
org-blocked and the ngit lane fires only on maintainer mirror pushes,
so a PR ref gets no CI runs — this comment is the offline verification
evidence, produced on the pinned head SHA).

  • Head: pr/cdk-mint-issuance @ ffc7c433b0
  • Base: main @ d8576dfa91
  • Gate: pr-verify 294317251383 on ai-legion, started 2026-10-22 22:37 UTC, wall 58s
  • Logs: /tmp/tollgate-pr-verify/1040-20261022T223645Z (on the verifying host)
Leg Result Detail
hygiene PASS no conflict markers in the tracked tree (0s)
contract-fences FAIL red: doc-facts contract dependency sync — see contract-fences.log; 12 checks green; 1 skipped (absent from tree: entry-UI marker path fence) (4s)
packaging-sequential PASS 13 suites (6s)
shell-suites FAIL red (1/19): ngit-ci-trigger_test.sh — logs in /tmp/tollgate-pr-verify/1040-20261022T223645Z/shell-suites (45s)
packaging-concurrent PASS 13 suites, all at once (3s)
go-battery FAIL 18 modules — see go-battery.log (0s)

Verdict: 3 leg(s) RED — 3 green.

What this gate does not run (no vacuous green)
  • spec-quote drift check (greatspectations tool + pinned nuts fetch)
  • reproducibility / conformance lanes (make release-check territory)
  • router-visible behaviour (PRTA / bench — unit green ≠ router works)

Note: the ngit trigger suite red carries the nak key public
nsec-arg signature — the portability break the NGIT_TRUST
generalization fixed. Trees predating that change reproduce it
on this seat; it is environmental, not PR-caused.

failing-leg log tails
contract-fences — tail
   root: v0.48.0
   tollgate-cvm: v0.31.0
   tollgate_protocol: v0.48.0
   tollwallet: v0.48.0
   upstream_detector: v0.48.0
   upstream_session_manager: v0.48.0
   valve: v0.48.0
   wireless_gateway_manager: v0.48.0

Fix: update all go.mod files to the same version.
     Run 'go mod tidy' in each sub-module after updating.
== gonuts pin fence (offline identity)
ok   an aligned tree (block + single-line carriers) passes
ok   a block-form carrier on a different version
ok   a single-line carrier left behind (the token-recovery shape)
ok   a go.sum that never recorded the truth version (half-update)
ok   a self-consistent pseudo-version integration pin passes OFFLINE (the --latest leg is the release gate for it)
ok   a manifest without the .gonuts key
ok   a tree whose manifest names a pin no carrier has

7 cases, 0 failed
== gonuts pin carriers
  manifest pin (packaging/build-inputs.json .gonuts.version): v0.13.0
  carriers discovered by glob: 5 module(s)
✅ one pin, everywhere: github.com/OpenTollGate/gonuts-tollgate v0.13.0 across 5 carrier(s).
== mirror/relay sync
ok    BLOSSOM_SERVERS: 13 copies identical (https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech)
ok    relay list: 13 copies identical (wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay2.orangesync.tech)
== stale import paths
✅ All 299 Go source files use correct import paths.
shell-suites — tail
--- ngit-ci-trigger_test.sh.log
ok   - refuses a workflow file that is absent from the worktree
ok   - refuses a workflow that is not present at the declared commit
ok   - refuses a signing key that is not the maintainer key
ok   - never prints the private key on refusal
ok   - refuses a key file with no nsec in it
invalid hex key: '0nsec12790x7pe7a97g3vvlhd4a567vy7wfmu28wcvnnf33ktjmxq0m2zqq734nw' is not valid hex: encoding/hex: invalid byte: U+006E 'n'
ok   - prints the sha256 of the file's content at the declared commit
FAIL - fails loudly when every relay refuses the event
       rc=1 out=workflow : .ngit/act/workflows/fake.yml
commit   : d24cab2823ebde3740977716de292078e60ffd25
sha256   : 0a2be7daf3741ba278eca69d6d25755580a1f4223c3c808b5e7e4d621e953544
ERROR: /tmp/tmp.1hj3T20OyR/wrong.key is not the maintainer key (signer ..., expected 9cd14d9acdbab7ca...)
ok   - never prints the private key on the happy path

8 passed, 1 failed
go-battery — tail
=== module: src
gofmt: unformatted files (fix with: gofmt -w .):
config_manager/config_manager_config.go
merchant/late_receive_notice_test.go
merchant/late_receive_outcome_test.go
merchant/merchant.go
merchant/owed_grant_test.go
merchant/purchasesession_duplicate_guard_test.go
merchant/purchasesession_panic_test.go
merchant/purchasesession_preflight_test.go
merchant/receive_intent_test.go
merchant/renewal_after_expiry_test.go
merchant/token_log_hygiene_test.go
merchant/unit_pricing_test.go
tollwallet/sidecar.go
make: *** [Makefile:7: go-battery] Error 1

@Amperstrand

Copy link
Copy Markdown
Collaborator

Early structural look (tg-verify lane) — draft, so per PR-REVIEW Step 1 this is not the formal 13-criteria pass; that comes at undrafting. Structural battery at head ffc7c43 (on the head as-is; see conflict note):

  • Fences: gonuts pin green (5 carriers), js-schema-lint PASS (86 entries — the schema grew for mint units and stays consistent), SSID contract PASS.
  • Arch: default build CGO_ENABLED=0 green for mips/mipsle/arm/arm64 — the CDK custom-unit work stays behind its tag/FFI seam (cdk_wallet-gated probes included, which is exactly the WalletPort doctrine), so the shipped matrix is untouched.
  • Touched modules: tollwallet, config_manager, merchant all green under -race -count=1 -tags testenv at head.
  • Base refresh owed before undrafting: merging current main conflicts only in an add/add on .ngit-release-evidence/shard-results.tsv (trivial). More importantly the branch carries three stacked release-fix commits (b11db6a, 2da570d, 28d2dfc) whose content already landed via fix(release): armour the shard template's UPX print; drop paywalled drive.cashu.email mirror #1031-class merges — rebase them away before undraft or the squash will double-carry them and muddy the review scope.
  • For the undrafting review to pin (fund-adjacent): (a) the V4 field-skew shim (v4_field_skew.go, mint URL under f vs m) is protocol-sensitive — verify the fixture table covers both spellings and unknown-field behavior; (b) unit-aware pricing closes the '64 mb priced as 64 sats' mispricing — verify the refusal path (2c486b9: custom-unit mint priced in another unit's step size) with a negative test at the pricing seam, not just schema validation; (c) the phased hardware evidence (A04E loop, phases 12–13) is exactly the right shape — keep attaching per-phase runs to the PR.

No blockers at this stage; the phased-evidence discipline on this draft is working.

@c03rad0r

Copy link
Copy Markdown
Contributor

Draft triage (2026-10-26). Large feature draft (operator-issued MB/minute ecash via a CDK mint) with no review requested. The unblock is a design review first: unit-aware pricing, the mint/teller trust model, and the fund-safety invariants (issuance, redemption, crash windows) need a written design before code review. Keep as draft.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants