Repository navigation
feat: operator-issued MB/minute ecash — unit-aware pricing + CDK mint integration (DRAFT) - #1040
felixfelix-bot wants to merge 16 commits into
Conversation
…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.
Phase 2 evidence — the issuance chain works, measured live (bench host, 2026-10-21)
1. Mint boots against the Go processor (mint log): Processor log: 2. Quote created in the custom unit: 3. Operator settles (this is the "operator issues MB" moment): 4. Blinded mint — proofs issued in mb (keyset The user now holds 100 mb of ecash, spendable at a TollGate whose mint config declares What this does NOT yet prove (next phases, will be commented as measured):
|
Phase 0 artifact — pecan architecture trade study (full, source-cited)Written by an independent consultant; every claim cited Architecture Trade Study: Operator-Issued MB/Minute Ecash for TollGateQuestion: how should an OpenTollGate operator issue megabytes/minutes ecash from All claims below are cited to source. Pecan cites are 1. How pecan issues custom-unit ecash with no Lightning1.1 The answer to "bolt11 method?" is no — it is a custom payment methodPecan registers itself as a payment backend for whatever method name it puts in // 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 1.2 Direction of the gRPC connection — pecan is the serverThis is the single most misunderstood part of the architecture, and it dictates every
Pecan is therefore a payment-backend implementation, i.e. a stand-in for a Lightning 1.3 Exact quote flow (deposit / mint)
1.4 Quote shape, concretely
Storage is one JSON file rewritten atomically on every mutation 2. Runtime requirements2.1 What pecan actually needs
Compose shapes: processor-only, or 2.2 Binaries are not static — this kills the "just ship it to the router" idea
Realistically: pecan is a ~1 GB RAM, container-capable, always-on box thing. On a 2.3 Can the pattern be reimplemented in Go? Yes — the surface is tinyThe protocol is one self-contained proto file of 307 lines, 7 RPCs
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
3. Security model — what "operator issues MB to user" must replicate3.1 Who may settle
3.2 The real security primitive: quote-id-as-bearer is disabled by NUT-20Pecan's designers were explicit that the code the teller types is not a secret
3.3 Pre-settlement cross-check against the mint (do not skip this)
3.4 Replay / double-issue protectionFour independent layers, all cheap to replicate:
Plus expiry: the sweeper deletes 3.5 What a teller-less design losesPecan's entire model assumes a human sees cash move. For "operator issues MB to user" 4. The three shapes(a) Operator runs the full pecan stack on a LAN box; router accepts its mintEffort: lowest to stand up (one Moving parts: 2–3 containers (pecan + Failure modes: mint down ⇒ no top-ups (unavoidable in any shape); Docker/network What users install: Docker, pecan, cdk-mintd, TLS certs, a box. ⚠ The blocker nobody mentions: TollGate's shipping wallet literally cannot hold Verdict: correct mint, wrong wallet. (a) is only viable after the wallet is (b) Minimal Go "teller processor" in the gateway ↔ local/remote
|
Phase 3 evidence — wallet custom-unit status, honest matrixProven working:
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
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 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.
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 That is the whole operator flow in Go: quote → operator settles → wallet mints mb ecash → token reports unit Root causes (each one produced the same useless symptom,
|
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 So I rebuilt the FFI for Cross-built gateway: 19 MB, Deployed to the bench GL-MT6000 (tollgate-0195, aarch64) and verified on device: And the merchant comes up against the operator's custom-unit mint: That is the cdk wallet path running on router hardware against an Two deployment traps worth recording
Still open
|
Phase 5 — END TO END ON HARDWARE: an operator-issued megabyte token buys accessThe full loop now runs on the bench GL-MT6000. Operator side: a CDK mint issuing 1. The operator issues mb ecash (cdk wallet, custom unit): 2. The client presents it to the gateway (POST / from a real br-lan client): 3. The gateway granted the session — read back after payment: 100 mb paid → 2,202,009,600 bytes granted. The arithmetic is exactly right: Bonus: an access-control gate observed working, unplannedPresenting the same token from the router's own loopback was refused: 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
Still open
|
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.
Phase 6 — minutes (
|
| 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).
Superset-chain scan of the whole open queue — 8 chains, 11 PRs closableMethod: for every pair of open PRs, check whether one's changed-file set is a Automated with THE BIG ONE — a 5-deep chain I had missed by handThere is a CI-work chain where each PR adds a couple of files to the last: Verified independently (not just the scanner's word):
|
| 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:
- 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)
- 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)
- 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)
- 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)
- 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)
- 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.
Phase 7 — the mint runs ON the router: "from their router" satisfiedThe goal says the operator issues from their router. That now holds literally: The mint, built for the router and running there: Built with the same musl recipe as the FFI: The gateway pointed at its own box's mint: A client then bought access with ecash from that mint: 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 honestlyPaying with a token I built with raw HTTP (blind-mint against the router's own mint) failed: 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 Final state of the goal
The remaining work is the operator-facing hardening already described in the trade study: the issuance surface is still an unauthenticated bench |
…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.
Phase 8 — two defects found while designing the UI, fixed on this branchDesigning the operator-issuance screen surfaced two bugs in this branch's own unit work. Both are fixed in 1. 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 2. Per-mint balance silently reported 0 for a custom-unit mint. Every writer keys wallets 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: One environment trap worth recordingHost test runs failed to link with a stray global Go setting: A leftover Status of the goal itselfUnchanged from the last comment, and complete: |
Phase 9 — the REAL usage pattern: a held mb wallet spent in small incrementsClarification 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.
The bug this exposed: the unit lied by 21×
Fixed on the bench to Suggestion for review: A gate that behaved correctly, worth recordingThe 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: Refused before Also: the router's firewall ACCEPT for the mint port did not survive a service restart. Re-applied and persisted properly via |
… 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.
Phase 10 — the misconfiguration now has a guard (
|
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.
Phase 11 — issuance on request now works on the router (
|
| 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.
Phase 12 — the loop closes: router-issued ecash is now spendable (
|
Phase 13 — the
|
Local CI gate — PR #1040: feat: operator-issued MB/minute ecash — unit-aware pricing + CDK mint integration (DRAFT)Run of
Verdict: 3 leg(s) RED — 3 green. What this gate does not run (no vacuous green)
failing-leg log tailscontract-fences — tailshell-suites — tailgo-battery — tail |
|
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
No blockers at this stage; the phased-evidence discipline on this draft is working. |
… branch rows are a superset of main's)
|
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. |
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)
src/merchant,src/tollwallet): the token's unit was structurally invisible at pricing time —Tokenhad noUnit()(port.go:26-42),Receive()returns "amount in the token's unit" unchecked,calculateAllotmentdivided by sat-denominatedprice_per_step. A 64 mb proof would price as 64 sats — silent mispricing, the exact bug class this change closes./v1/mint/quote/{method}), NUT-20 locked quotes. Recommendation: staged path — minimal Go teller-processor as the product shape, never the full console stack.CurrencyUnit::Customis 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.Unitis a sat-only enum;cdk-goFFI hasCurrencyUnitCustomready for thecdk_walletpath).Phase 1 — unit-aware pricing (this branch, TDD)
tollwallet.TokengrowsUnit() string; gonuts adapter reads it off the decoded V4/V3 wire structs; sidecar/cdk adapters return "" (honest unknown) rather than inventing "sat"MintConfiggrowsunit(empty == sat; every existing config keeps exact behaviour)calculateAllotmentForUnit: unit mismatch = refusal, not guessing.price_per_step=1&unit=mbprices a 64 mb token as 64 steps ofstep_sizePurchaseSession+ the late-receive journal price with the token's real unitRED 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):What still needs to happen (tracked here, honestly)
mbkeysets. Path:cdk_walletbuild tag +CurrencyUnitCustomFFI, or gonuts fork workPhase-by-phase proof lands in the comments as it's measured.