Skip to content

feat: token recovery tool + AI audit prompts + speccheck - #340

Closed
Amperstrand wants to merge 5 commits into
mainfrom
feat/token-recovery-tool
Closed

Amperstrand wants to merge 5 commits into
mainfrom
feat/token-recovery-tool

Conversation

@Amperstrand

Copy link
Copy Markdown
Collaborator

Token Recovery Tool

Standalone Go tool (scripts/token-recovery/) that recovers Cashu tokens from the tokens-to-recover.txt file written when autopay fails (e.g., NDS #88 gate-open bug).

How it works

  1. Parses each line: timestamp | mint_url | token | error
  2. Decodes token (V3/V4)
  3. Checks proof state at mint via POST /v1/checkstate (NUT-07)
  4. If UNSPENT → calls Wallet.Receive(token, swapToTrusted=true)
  5. If SPENT → skips (value already consumed)
  6. Reports: X recovered, Y spent, Z failed

Usage

# Dry run — check what is recoverable
go run scripts/token-recovery/ -file /etc/tollgate/tokens-to-recover.txt -dry-run

# Actually recover
go run scripts/token-recovery/ -file /etc/tollgate/tokens-to-recover.txt

# Cross-compile for router
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o recover scripts/token-recovery/
scp recover root@router:/tmp/
ssh root@router /tmp/recover

Amperstrand added 2 commits July 30, 2026 15:37
Update example from old 'TollGate-ABCD-2.4GHz-1' (band suffix)
to simplified 'TollGate-A1B2' (random chars only).
Token Recovery Tool:
Standalone Go tool that recovers Cashu tokens from
tokens-to-recover.txt (written when autopay fails with NDS gate errors).
Checks proof state at mint via NUT-07 checkstate, recovers unspent
tokens via Wallet.Receive(). Supports --dry-run.

Also includes:
- docs/ai-audit/ — 3 AI audit prompt templates (NUT compliance, security, deps)
- make/speccheck.sh — one-command greatspectations spec checking

Tested against testnut.cashu.exchange with real tokens.
Under set -e, a non-zero exit from 'spectate check' (drift found)
aborted the script before the coverage report and propagated as the
script's exit status. Drift is this script's report, not its failure
mode — report and exit 0; infra errors (install/clone) still abort.
CI gating can invert this deliberately when wired in.

Review findings: cold-code review 2026-08-17 item 3.
@Amperstrand
Amperstrand marked this pull request as ready for review August 18, 2026 07:03
@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Cold-review item 3 + housekeeping: resolved (8ef6a26, CI green, PR undrafted).

speccheck exit semantics — fixed here, with a corrected diagnosis. The script had no exit $? (that part of the finding doesn't match the file); the actual mechanism is set -e + a non-zero exit from spectate check when drift is found, which aborted before the coverage report and propagated as the script's status. Now: || true on both report commands + explicit exit 0; infra failures (install/clone) still abort via set -e. Functionally verified with a stub spectate exiting 1 on drift: script reports and exits 0.

Consolidation: this PR is now the single home of the audit tooling — make/speccheck.sh + docs/ai-audit/ were previously duplicated (and diverging: greatspectate vs spectate command names) across this branch and #327; #327's rebuild dropped its copies. Note also this PR's tree never contained the ELF binary (7de4e476) — that was #341/#343 only, both since rebuilt clean.

@felixfelix-bot felixfelix-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cold review — new findings only (1 issue previously raised, not repeated).

2 major:

  1. PENDING proofs are treated as recoverable. The code only checks s.State != nut07.Spent to set allSpent=false, so PENDING (in-flight) proofs are treated as UNSPENT and Wallet.Receive() is called on them. This can cause failed receives or double-spend attempts. Should check s.State == nut07.Unspent explicitly instead

  2. Wallet is loaded per-token inside processLine via wallet.LoadWallet(config). For multiple recoverable tokens, this opens/closes the wallet database repeatedly, risking lock contention or data loss. Wallet should be loaded once before the loop and reused

4 minor (non-blocking):

  • Dry-run returns "failed" for recoverable tokens — summary counts them as ❌ failed instead of a distinct "recoverable" status
  • p.Secret[:16] printed in error output — leaks partial proof secret to stdout/logs
  • No HTTP timeout on client.PostCheckProofState call — unresponsive mint hangs the tool indefinitely
  • p.Secret[:16] will panic with slice out of bounds if a malformed token has a secret shorter than 16 bytes

The NUT-07 Ys list format is correct. The speccheck.sh fix (set -e + non-zero from spectate check) is verified correct.

…leak

- PENDING proofs were treated as recoverable (state != Spent), so the
  tool could receive in-flight proofs — double-spend attempt or failed
  receive. Recoverable now requires State == Unspent for every proof;
  PENDING gets its own status and a retry-later message.
- Wallet loads once per run (lazy, first recoverable token) instead of
  per line — LoadWallet locks the bolt store, per-token loads contend.
- hash_to_curve error no longer prints p.Secret[:16] — proof secrets
  spend tokens; the slice would also panic on short secrets.
- Dry-run counts recoverable tokens separately instead of as ❌ failed.
- No timeout change needed: gonuts client already uses a 30s
  http.Client timeout (wallet/client/client.go:34).

Review findings: cold review 2026-08-18 (majors 1-2 + minors 1,2,3*4).
@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Cold-review response — both majors + minors addressed in 03ccf14 (CI green):

  • Major 1 (PENDING treated as recoverable): confirmed real, fixed. Recoverable now requires State == Unspent for every proof; PENDING gets its own status (⏳, retry-later message) — receiving in-flight proofs was a double-spend attempt.
  • Major 2 (wallet per line): fixed — one lazily-loaded wallet handle for the whole run (LoadWallet locks the bolt store; per-line loads contended). Receive operates on each token's own mint; first recoverable token's mint sets the swap-to-trusted default.
  • Minors: dry-run recoverables now counted as 💰 not ❌; p.Secret[:16] removed from error output entirely (secrets spend tokens — also fixes the short-secret panic); no timeout change needed — the gonuts client already wraps all HTTP in a 30s http.Client timeout (wallet/client/client.go:34), so the finding was stale. README summary line updated for the new statuses.

@felixfelix-bot felixfelix-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-reviewed: 03ccf14 verified earlier (State == Unspent with retry-later for PENDING, single wallet per run, secret fragment removed, dry-run separate counting) + 3ca91f5 adds a belt-and-braces 30s bound on checkstate on top. Accepting the stale-timeout note (gonuts client already wraps 30s) — harmless either way. Good to merge.

@felixfelix-bot

Copy link
Copy Markdown
Contributor

Closing — rebased onto main as #354. Conflicts resolved, build verified.

@felixfelix-bot

Copy link
Copy Markdown
Contributor

SUPERSEDED by #354 — this branch was rebased onto main as rebase/pr-340 and a clean PR was opened at #354. Please close this PR.

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