Skip to content

test(cloud-lab): pin that an outage-refused payment never burns the token - #512

Merged
Amperstrand merged 2 commits into
mainfrom
test/rejection-safety-lab
Sep 22, 2026
Merged

Amperstrand merged 2 commits into
mainfrom
test/rejection-safety-lab

Conversation

@Amperstrand

Copy link
Copy Markdown
Collaborator

Pins the #423-class property at the tollgate HTTP surface, in the lab that already found #480 and #481 this week.

The property

Whatever refuses a payment, the customer's ecash must survive it. Concretely for a mint outage: a token refused while the mint is down must be fully UNSPENT at the mint (NUT-07) and must still buy a session, byte for byte, once the mint recovers.

Shape

Three-phase lane, host-driven (./run-rejection-safety.sh, the run-keyset-rotation.sh pattern — the client container has no docker control, and a plain compose run would restart the stopped mint through the client's depends_on, so the outage phase runs --no-deps; that trap is why this lane did not exist as an in-container test):

  • A1 (mint up): fund the wallet, stash token + MAC
  • A2 (mint down): payment refused, no crash, tollgate alive — a 1022 here would mean offline acceptance now exists and the pin must be consciously updated alongside its settlement guarantees
  • B (mint back): NUT-07 all-UNSPENT + same token pays 200/1022

Plus a default-lane test (runs in every client invocation): a replayed token never buys a second session, and the replay doesn't crash the tollgate.

Verified

  • Full lane, isolated compose project, current main: A1/A2/B all pass (A2's 35s wall is the health-tracker detection latency)
  • Default lane: 1 passed, 3 skipped (phases correctly gated)
  • make go-battery: 16 modules green (no Go changes)

What this adds beyond existing coverage

test_mint_failure exercises degradation with fresh tokens per attempt (and its docker-gated legs skip in-container); test_swap_fees pins the below-fee refusal's spentness. The same-token-across-outage leg and the replay leg were pinned nowhere — #423 reports the burn class from the clientd side; this lane holds the line at the merchant surface.

Amperstrand added 2 commits September 22, 2026 11:13
…oken

The #423-class property, pinned at the tollgate HTTP surface. New
three-phase lane run from the host (the client has no docker control,
and 'compose run' would restart a stopped mint through depends_on — the
outage phase runs with --no-deps):

- A1 (mint up): fund and stash the token
- A2 (mint down): payment refused without a crash; a 1022 here would
  mean offline acceptance exists and must be consciously re-pinned
- B (mint back): every proof UNSPENT at the mint (NUT-07) and the same
  token still buys a session, byte for byte

Plus a default-lane replay test: a token that already bought a session
never buys another. test_mint_failure covers degradation with fresh
tokens per attempt; the same-token-across-outage leg was pinned nowhere.
@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Verification: PR #512 (rejection-safety cloud-lab lane)

Tested as-is at head cdb59991210fd8c4819b628b2fc78ff87cc86135 (MERGEABLE, 11 commits behind main, no conflicts; diff = run-rejection-safety.sh + test_rejection_safety.py + CHANGELOG link). Full lane green in an isolated lab, against a binary built --no-cache from this branch's own tree — with one methodological trap worth recording, because it changes how this lane must be run.

The trap: shared image cache can silently test someone else's binary

My first lane run passed (A1/A2/B), but the cloud-lab-op512-upstream image turned out to be a cached build from 16 minutes before the branch head existed (image 537d641e6db3, created 10:58 vs head commit 11:14) — a prior pass had used the same project name. Since main and this branch differ in real Go source (mint_health_tracker.go, network_monitor.go, gonuts v0.11.2 vs v0.12.1 via #506), that binary was not this tree's. I rebuilt with docker compose build --no-cache upstream from this worktree (fresh image 0fb0463f63c1) and re-ran everything below. Incidentally this means the property is now confirmed on both trees: main's binary (first run) and this branch's own (second run) — consistent with the body's claim.

What I ran (all in an isolated project: COMPOSE_PROJECT_NAME=cloud-lab-op512, unique op512-* container names, host ports stripped except mint-rotate :8087, subnet remapped 172.28.0.0/16 → 172.31.0.0/16, via the script's CLOUD_LAB_EXTRA_COMPOSE knob — nice that the lane supports this natively)

  • ./run-rejection-safety.sh (branch-built binary): A1 1 passed, 2 deselected in 3.39s; A2 1 passed, 2 deselected in 33.13s (the ~35 s health-tracker detection window, as the body predicts); B 1 passed, 2 deselected in 0.68s. NUT-07 all-UNSPENT + the same token bought a session (200/kind 1022) after mint recovery — the property holds.
  • Default-lane claim: pytest -q test_rejection_safety.py with no REJECTION_LANE, full stack up → 1 passed, 3 skipped in 2.14s — the replay pin ran live (first payment 200/1022, replay refused, tollgate still 200), phase tests correctly gated.
  • Root module battery (gofmt -l / go vet / go build / go test -race -count=1 -tags testenv . in src/): green. No Go code changes, as claimed.
  • Cleanup verified: no .rejection-state.json left in the checkout after the run (the EXIT trap works — no ecash stranded in the tree); lab torn down down -v, no containers/images/networks/volumes remain.

Review notes

  • No dependency on feat(merchant): pending-proof reclaimer — ambiguous send/melt outcomes self-heal (#500) #507 for this property, confirmed by reasoning over both runs: a mint-outage refusal fails at/before the swap POST (connection refused — a pre-exposure error, never an ambiguous outcome), so the reclaimer's absence can't matter; the lane passed on the pre-chore(deps): bump gonuts-tollgate v0.11.2 → v0.12.0 (melt/swap-retry counter reservation + #480 landing) #506 branch base (gonuts v0.11.2) as well as main (v0.12.1). The ambiguous-outcome class is feat(merchant): pending-proof reclaimer — ambiguous send/melt outcomes self-heal (#500) #507's lane to pin, and this PR doesn't pretend to cover it.
  • The --no-deps A2 phase and the host-driven three-phase shape are the right call for exactly the depends_on reason documented; the phase gating mirrors run-keyset-rotation.sh conventions; assert_token_unspent reuse from test_swap_fees avoids a second NUT-07 helper; session-random locally-administered MACs correctly decouple this suite from cumulative allotments. The A2 1022-acceptance comment (what it would mean if offline acceptance ever lands) is good pin hygiene.
  • CHANGELOG: correctly under [Unreleased] → Changed / Internal with the PR link (test-only change — right category). One cosmetic nit: a doubled blank line after the new bullet before the existing "Recorded the captive-portal…" entry; trim when convenient.
  • Observation for future lane runners (pre-existing, not this PR's defect): a lane run dirties tests/cloud-lab/configs/install.json in the worktree via the bind mount (install_time stamp) — restore it before discarding the worktree or it lingers in git status.

Disposition: land.

@Amperstrand
Amperstrand merged commit 7e47f6b into main Sep 22, 2026
@Amperstrand
Amperstrand deleted the test/rejection-safety-lab branch October 11, 2026 11:23
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.

1 participant