Skip to content

fix(config): per-mint payment method for custom-unit mints, and make the cdk tag compile - #1185

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

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

Conversation

@felixfelix-bot

Copy link
Copy Markdown
Collaborator

Follow-up to #1040, which merged as 8e084b9d. That PR made custom units priceable; these are two defects found while working out how an operator-settled mint (Pecan) would actually attach.

1. The payment method was a hidden env var with a wrong default

if m := os.Getenv("TOLLGATE_CDK_PURCHASE_METHOD"); m != "" { return m }
return "paypal"

No real processor answers paypal — it was a bench-harness stub promoted to a shipped default. Against a mint whose processor registers any other name, every issuance quote addresses a method the mint does not have, so issuance can never succeed. It also put a per-mint fact in a per-process environment variable, so one router could not front a counter-settled operator mint and a bolt11 sat mint simultaneously.

Now accepted_mints[].payment_method (empty = the unit's default), plumbed through MintConfig -> merchant -> cdk/gonuts/select, plus its schema entry (the struct/schema drift fence caught the omission). A custom-unit mint declaring no method is refused with an actionable error rather than sent to a guess.

Measured, not assumed, against a real cdk-mintd 0.18.3 with unit = "mb" and custom_payment_methods = ["branch"]:

post_mint_custom_quote{method="branch"}: quote persisted method=branch unit=mb state=UNPAID
wait_for_processor_payments: PAID amount_paid=8
cache_post_mint_custom{method=Path("branch")}: ISSUED amount_issued=8

The returned token decodes to mb and contains no sat. Full detail in docs/evidence/custom-unit-issuance-2026-11-03.md.

2. The cdk_wallet tag had never compiled

go vet -tags cdk_wallet failed on w.ActiveKeyset(). This module pins a patched cdk-go whose accessor is FetchActiveKeyset() returning KeySetInfo; the probe was written against a different version. Because the tag is not part of the default battery, the backend that makes custom units work had zero build verification. Fixed, plus the four NewWalletPort call sites my new argument touched. go vet -tags cdk_wallet ./... is clean for the first time.

The limit this exposed — read before assuming custom units work on a router

On the default gonuts backend the same config is refused:

Wallet initialization failed: error adding new mint:
  could not find an active keyset for the unit

gonuts-tollgate hardcodes unit: cashu.Sat at wallet construction (wallet/wallet.go:128) and AddMint looks up keysets with w.unit. Custom-unit redemption is therefore reachable only through the cdk_wallet tag — which the router build cannot use in-process, because cdk-go is CGO/FFI and the module must stay CGO_ENABLED=0 (AGENTS.md). The sanctioned route is the sidecar, which is separate work.

This PR fixes the method name; it does not make a Pecan mint spendable on stock hardware, and it does not close NUT-20 quote locking (RequestMintQuote passes nil; Pecan rejects unlocked quotes).

Verification

  • scripts/go-test.sh -> RC=0, 20 modules, 0 failures
  • go vet -tags cdk_wallet ./... -> clean
  • gofmt -l over changed files -> 0
  • TestCustomUnitPaymentMethod_* pass under the tag
  • check-changelog-duplicates.py passes; fragment included

No router-visible behaviour change for a sat mint: empty unit/payment_method keeps the exact previous path.

Hermes Agent added 3 commits November 3, 2026 15:52
…gain

Two defects found while working out how a Pecan-settled mint could attach.

1. The custom-unit payment method was derived from the UNIT and defaulted to
   "paypal" via TOLLGATE_CDK_PURCHASE_METHOD:

       if m := os.Getenv("..."); m != "" { return m }
       return "paypal"

   No real processor answers "paypal" — it was a bench-harness stub promoted to
   a shipped default. Against a Pecan-backed mint, which registers the method
   "branch", every issuance quote went to a method the mint does not have, so
   issuance could never work. It also put a per-MINT fact in a per-PROCESS env
   var, so one router could not front a counter-settled operator mint and a
   bolt11 sat mint at the same time.

   Now accepted_mints[].payment_method (empty = the unit's default), plumbed
   through MintConfig -> merchant -> cdk/gonuts/select. A custom-unit mint with
   no configured method is REFUSED with an actionable error rather than sent to
   a guessed one.

2. The cdk_wallet tag had never compiled. go vet -tags cdk_wallet failed on
   w.ActiveKeyset: this module pins a patched cdk-go whose accessor is
   FetchActiveKeyset returning KeySetInfo. So the backend that makes custom
   units work was never build-verified by any test. Fixed the probe to the
   pinned API; the four NewWalletPort call sites that my new argument broke are
   updated too.  is clean for the first time.

Also adds the schema entry for payment_method (the config/struct drift fence
caught its absence) and docs/pecan-teller-integration.md, which records what C
still needs and why the method-name mismatch was the blocker.

Verification: scripts/go-test.sh RC=0, 20 modules, 0 failures; gofmt clean;
go vet -tags cdk_wallet clean; TestCustomUnitPaymentMethod_* pass under the tag.
… by gonuts

Ran a real cdk-mintd 0.18.3 with unit="mb" and
custom_payment_methods=["branch"] — the shape a Pecan-fronted mint has — and
drove IssueOnRequest against it. No funds (fakewallet); keysets, quotes and
proofs real.

Works on the cdk backend: quote -> PAID -> ISSUED, and the returned token
decodes to mb with no sat in it. The mint's own log confirms
method=branch unit=mb at every state transition.

Refused on the DEFAULT (shipping) gonuts backend with 'could not find an
active keyset for the unit'. Cause: gonuts-tollgate hardcodes
unit: cashu.Sat at wallet construction (wallet/wallet.go:128) and AddMint
looks up keysets with w.unit, so an mb-only mint has no match.

That is the real blocker for the Pecan ticket, and it is not the method name:
custom-unit redemption is reachable only via the cdk_wallet build tag, which
the router build cannot use in-process because cdk-go is CGO/FFI and the
module must stay CGO_ENABLED=0. The sanctioned route is the sidecar.

Also gitignores mbtest/, the harness that holds the mint and wallet dbs.
@felixfelix-bot

Copy link
Copy Markdown
Collaborator Author

LGTM — approve. The cdk_wallet-tagged suite finally ran (the fork was cached from an earlier attempt), so this is a real result rather than a skip:

$ cd src/tollwallet && go test -count=1 -tags cdk_wallet .
ok  github.com/OpenTollGate/tollgate-module-basic-go/src/tollwallet   27.047s

$ cd src/tollwallet && go test -tags cdk_wallet -run TestCustomUnitPaymentMethod -v .
--- PASS: TestCustomUnitPaymentMethod_ComesFromConfigPerMint (0.00s)
--- PASS: TestCustomUnitPaymentMethod_NoConfiguredMethodIsNotGuessed (0.00s)

$ cd src/config_manager && go test -race -count=1 .
ok  github.com/OpenTollGate/tollgate-module-basic-go/src/config_manager   1.843s

This is the first PR in this sweep where the CDK path was actually executed — worth noting because #1136 and #1155 could only be verified in their default (gonuts) configuration.

The bug is a good one, and the fix matches it

The test header states the failure precisely: the payment method was derived from the unit and defaulted to "paypal" — a name left over from a bench harness that no real processor answers. Against a Pecan-backed mint registering "branch", every issuance quote went to a method the mint does not have, the mint refused, and the operator could not issue anything.

Making the method a per-mint config fact rather than a unit-derived guess is the correct model, and the reason given is the decisive one: "one router can front a counter-settled operator mint and a bolt11 sat mint at the same time, which a unit-derived guess cannot express." That is a genuine architectural constraint, not a preference.

The second test is the one I would have asked for: TestCustomUnitPaymentMethod_NoConfiguredMethodIsNotGuessed — asserting that an absent config value is not silently defaulted. A guess that falls back to a plausible-looking string is exactly how the original "paypal" survived; refusing to guess is the fail-closed behaviour.

One thing to check before merge

src/tollwallet/cdk_wallet.go is +29 here, and the same file is touched by #1137 (+67/-6, the gofmt-red PR I reviewed) and #1136 (the WalletPort parity contract). Three PRs on one file, one of which is currently red on gofmt. Sequence #1137's formatting fix first so this does not inherit a file that fails the battery's gofmt step.

Also: this PR carries cdk_wallet-tagged tests, but CI's go-test matrix runs ./... without -tags cdk_wallet — the same gap I flagged on #1136 and #1155. These two new tests will never run in CI as configured. That is a repo-level gap rather than this PR's fault, but it means the regression you just fixed has no automatic guard.

No changelog fragment concern — one is included (1185-degraded-banner-real-cause.fixed.md), though note that fragment is about the banner, not the payment method, so it may be describing a different change than the code here. Worth confirming the fragment matches the diff.

@felixfelix-bot

Copy link
Copy Markdown
Collaborator Author

Follow-up on my own review — the changelog fragment on this branch is wrong, and it is empty.

I flagged this as "worth confirming" and then confirmed it:

$ cat changelog.d/1185-degraded-banner-real-cause.fixed.md | head -3
(empty — 0 bytes)

$ git diff --name-only upstream/main...HEAD | grep -E "main.go|degraded"
(no matches — the diff never touches the banner)

So two defects in one file, both worth fixing before merge:

  1. The file is empty. scripts/changelog-assemble.py check requires a fragment body to start with - ; an empty fragment is exactly the failure it reports today on main for 756-ap-rebind-guarded.fixed.md:

    error: changelog.d/756-ap-rebind-guarded.fixed.md: empty fragment
    

    This PR would add a second instance of a defect the repo already has one of.

  2. The name describes a different change. 1185-degraded-banner-real-cause claims to be about the degraded banner, but this diff is the per-mint payment method fix (cdk_wallet.go, custom_method_test.go, config_schema.go, merchant.go). The banner work is fix(merchant): degraded-mode hygiene — startup banner names the real recovery path; dead error seam removed (#1135) #1140 / fix(main): degraded banner reads the recorded reason, not the collapsed one (fixes #1174's dead branch) #1186. A fragment named for the banner will fold a payment-method bullet under an unrelated heading at release time — the exact "wrong reference into the release notes" class that check-changelog-fragments.py (added by test(contract): four drift fences — fragment identity, toolchain ceiling, SSID matcher table, clean-container lane #775) exists to catch.

Suggested replacement:

changelog.d/1185-per-mint-payment-method.fixed.md

- **Fixed**: a custom-unit mint's payment method now comes from config per mint instead of being derived from the unit and defaulted to `paypal` — a name no real processor answers, so against a Pecan-backed mint registering `branch` every issuance quote was refused and the operator could not issue anything ([#1185](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/1185))

Everything else in my review stands

The code is right and the tests pass for real (-tags cdk_wallet executed, 27.047s; TestCustomUnitPaymentMethod_* both PASS; config_manager green). The per-mint model is the correct fix and the "do not guess" test is the one that matters.

The two sequencing notes also stand: cdk_wallet.go is shared with #1137 (currently gofmt-red) and #1136, and the cdk_wallet-tagged tests here will not run in CI's tagless ./... matrix.

Minor process note for this PR's author: an empty fragment file is caught by changelog-assemble.py check, so running the repo's own contract check before pushing would have surfaced it.

felixfelix-bot added a commit to felixfelix-bot/tollgate-module-basic-go that referenced this pull request Nov 3, 2026
The fragment cited OpenTollGate#1185, which is a different PR (per-mint payment method
for custom-unit mints). A fragment pointing at the wrong PR links the
changelog entry to unrelated work at fold time.
@felixfelix-bot

Copy link
Copy Markdown
Collaborator Author

Review — PR #1185, head fix(config): per-mint payment method for custom-unit mints, and make the cdk tag compile

Verdict: land. Both defects are real, the fixes are at the right layer, and I verified the one claim that would be easiest to get wrong — that the cdk_wallet tag actually compiles under both CGO settings.

Verified

$ CGO_ENABLED=1 go build -tags cdk_wallet ./...     # rc=0
$ CGO_ENABLED=0 go build -tags cdk_wallet ./...
    imports github.com/cashubtc/cdk-go/bindings/cdkffi: build constraints exclude all Go files
$ go build ./...                                     # root, no tag: rc=0

That last pair is the important one and it matches what #1040's write-up recorded: cdk_wallet needs the FFI, therefore CGO, and therefore cannot ship on OpenWrt. The tag compiling under CGO and refusing under CGO_ENABLED=0 is correct behaviour, not a defect — the pure-Go build keeps working.

Tests, with the tag (they are //go:build cdk_wallet-gated, which is why a plain go test reports "no tests to run" — worth knowing before concluding they are missing):

$ go test -tags cdk_wallet -run TestCustomUnitPaymentMethod -v ./src/tollwallet
--- PASS: TestCustomUnitPaymentMethod_ComesFromConfigPerMint
--- PASS: TestCustomUnitPaymentMethod_NoConfiguredMethodIsNotGuessed

All NewWalletPort callers are updated for the new mintMethods parameter — I grepped rather than assuming: merchant.go:599, select.go:39, and six test call sites. Config schema is wired too (config_schema.go:80, payment_method, Editable: true, default empty).

On defect 1 — the wrong default

return "paypal" as a fallback for a custom unit is a textbook bench artefact promoted to a shipped default: no real processor answers it, so every issuance quote addresses a method the mint does not have and issuance can never succeed. Replacing a guess with a refusal that names the field and the operator's concrete case:

mint %s issues unit %q but no payment_method is configured: … Pecan registers "branch" … set accepted_mints[].payment_method

is right. A refusal that tells you which config key to set is worth more than a default that silently cannot work.

The layer move matters more than the default: a per-mint fact was living in a per-process environment variable, so one router fronting a counter-settled operator mint and a bolt11 sat mint could not address both. mintMethods map[string]string on the wallet, threaded from WalletConfig, is the correct shape.

On defect 2

Moving customUnitPaymentMethod from a free function to a method on *CdkWallet so it can read w.mintMethods is the minimal change that makes the per-mint lookup possible. Fine.

One thing to fix before merge

TOLLGATE_CDK_PURCHASE_METHOD still appears in three docs as live syntax, two of them as if it were still honoured:

  • docs/pecan-teller-integration.md:43 quotes the old code verbatim (if m := os.Getenv("TOLLGATE_CDK_PURCHASE_METHOD") …)
  • docs/pecan-teller-integration.md:92 still lists "retire TOLLGATE_CDK_PURCHASE_METHOD" as a future step
  • src/tollwallet/cdk_purchase_mint_probe_test.go:21 shows it in a usage comment

If the variable is gone, the docs should say it is gone (and that payment_method replaces it) rather than leave an operator following a retired instruction. This PR retires it; the docs should land in the same change or the next one, not never.

Not verified

  • No real mint, no Pecan. The per-mint lookup and the refusal path are unit-tested; whether a real Pecan mint accepts payment_method: "branch" end to end is the cloud-lab/router lane's question, and docs/evidence/custom-unit-issuance-2026-11-03.md is where that evidence belongs.
  • No cdk_wallet-tagged full test run. I ran the two targeted tests, not the whole suite under the tag.

Context

main moved to 8e084b9d (#1040). The known red in src/merchant is TestMerchantDegraded_StorageMmap_*, fixed by #1160 — not this PR.


Reviewed against PR-REVIEW.md. Evidence: go build with and without -tags cdk_wallet under both CGO_ENABLED settings; targeted tagged tests; grep of every NewWalletPort call site; schema/fixture read.

@Amperstrand

Copy link
Copy Markdown
Collaborator

Local CI gate — PR #1185: fix(config): per-mint payment method for custom-unit mints, and make the cdk tag compile

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 @ 152c29a27a
  • Base: main @ 8e084b9dc1
  • Gate: pr-verify 6f304ed0e72d on ai-legion, started 2026-11-05 03:01 UTC, wall 635s
  • Logs: /tmp/tollgate-pr-verify/1185-20261105T025052Z (on the verifying host)
Leg Result Detail
hygiene PASS no conflict markers in the tracked tree (0s)
contract-fences FAIL red: dependency sync — see contract-fences.log; 13 checks green (5s)
packaging-sequential FAIL red (1/17): backend-api-owner-network_test.sh — logs in /tmp/tollgate-pr-verify/1185-20261105T025052Z/packaging-sequential (21s)
shell-suites PASS 23 suites (uci-defaults + ngit pipeline) (74s)
packaging-concurrent FAIL red (1/17): backend-api-owner-network_test.sh — logs in /tmp/tollgate-pr-verify/1185-20261105T025052Z/packaging-concurrent (8s)
go-battery PASS 19 modules, gofmt/vet/build/-race (527s)

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)
failing-leg log tails
contract-fences — tail
  DEPENDENCY DRIFT DETECTED
============================================================

🔴 github.com/cashubtc/cdk-go — 2 versions:
   cli: v0.17.3
   merchant: v0.17.3
   root: v0.18.1
   tollwallet: v0.17.3

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.14.1
  carriers discovered by glob: 5 module(s)
✅ one pin, everywhere: github.com/OpenTollGate/gonuts-tollgate v0.14.1 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 377 Go source files use correct import paths.
packaging-sequential — tail
--- backend-api-owner-network_test.sh.log
ok   the fragment guards exactly :2121
ok   packaging/files/etc/nftables.d/30-backend-firewall.nft is an include fragment (no own table declaration)
ok   the chain hooks input at priority -1 (the drop is authoritative)
ok   chain 'backend_input_firewall' is declared once
== the admin-board and LuCI guards are still captive-bridge-scoped
ok   31-admin-board-not-guest-reachable.nft is scoped by $tg_portal_if (the captive bridge's define)
ok   32-luci-not-guest-reachable.nft is scoped by $tg_portal_if (the captive bridge's define)
== the recipe installs the rule
ok   local-build-ipk.sh installs 30-backend-firewall.nft
ok   packaging/Makefile installs the 30-backend-firewall.nft rule via its *.nft glob
ok   packaging/Makefile FILES_ lists /etc/nftables.d/30-backend-firewall.nft
== the fragment parses
FAIL nft -c rejects the fragment — 'nft -f' would fail the whole fw4 reload

passed=17 failed=1
packaging-concurrent — tail
--- backend-api-owner-network_test.sh.log
ok   the fragment guards exactly :2121
ok   packaging/files/etc/nftables.d/30-backend-firewall.nft is an include fragment (no own table declaration)
ok   the chain hooks input at priority -1 (the drop is authoritative)
ok   chain 'backend_input_firewall' is declared once
== the admin-board and LuCI guards are still captive-bridge-scoped
ok   31-admin-board-not-guest-reachable.nft is scoped by $tg_portal_if (the captive bridge's define)
ok   32-luci-not-guest-reachable.nft is scoped by $tg_portal_if (the captive bridge's define)
== the recipe installs the rule
ok   local-build-ipk.sh installs 30-backend-firewall.nft
ok   packaging/Makefile installs the 30-backend-firewall.nft rule via its *.nft glob
ok   packaging/Makefile FILES_ lists /etc/nftables.d/30-backend-firewall.nft
== the fragment parses
FAIL nft -c rejects the fragment — 'nft -f' would fail the whole fw4 reload

passed=17 failed=1

@Amperstrand Amperstrand left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code is verified good; the changelog fragment points at the wrong PR and must be fixed before merge.

Fragment (blocking, mechanical): changelog.d/1150-custom-unit-payment-method.changed.md is named after — and both bullets link — #1150, which is a different, already-merged PR (Cobrador peering / SSID names / portal pin). Rename to 1183-style convention, i.e. 1185-custom-unit-payment-method.changed.md, and point both link tails at this PR. Related shape nit: the fold expects one bullet per fragment ("the file body is the bullet"); the second bullet (cdk tag compile fix) deserves its own 1185-cdk-wallet-tag-compiles.fixed.md.

Code verified at head 152c29a27a28caad7d06e88aa07e71e1a793412f:

  • Plumbing is coherent end to end: MintConfig.PaymentMethod → schema entry → newFullMerchant's per-mint map → NewWalletPort/OpenWallet → the cdk backend's quote method; the gonuts backend takes it for signature parity and ignores it, documented as such.
  • The refusal-instead-of-guess in RequestMintQuote is the right call, with an actionable error naming the config key.
  • Gates re-run: src/tollwallet default tests ok (59s suite); go vet -tags cdk_wallet ./... clean — confirming the tag-compiles claim; src/config_manager and targeted src/merchant tests ok; js-schema-lint.mjs PASS (113 entries — the struct/schema fence agrees); check-changelog-duplicates.py clean.
  • Sat-mint neutrality holds: empty unit/payment_method keeps the previous path.

Dependency flag (as the body honestly states, worth keeping visible): custom-unit support on the default backend depends on gonuts-tollgate #51 (unit-parametric wallet), which is OPEN with changes requested — wallet/wallet.go still hardcodes cashu.Sat. Until #51 lands and is tagged/bumped, accepted_mints[].payment_method is only effective behind the cdk_wallet tag, which the router build cannot use in-process (CGO constraint). That is a declared limitation, not a defect in this PR, but reviewers/operators reading the changelog bullet should not conclude Pecan-style mints work on stock hardware from this merge alone. The NUT-20 quote-locking gap (RequestMintQuote passes nil) is likewise declared open.

Disposition: request-changes for the fragment rename/links only — with that fixed this lands, with the gonuts #51 dependency noted in the merge train.

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.

2 participants