You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Priority: P2 — flash durability & corruption-recovery audit for wallet.db and new durable stores (G06/G09 add writes; this issue bounds the write cost and the blast radius). #505
Priority: P2 — flash durability & corruption-recovery audit for wallet.db and new durable stores (G06/G09 add writes; this issue bounds the write cost and the blast radius).
Problem
The wallet (bbolt), the drain journal, and (if G06/G09 land) session/payment stores all write to router flash. Unknown/uncontrolled today: fsync semantics of bbolt on OpenWrt kernels (default NoSync=false? options at open), write amplification of bbolt B+tree rewrites vs the flash budget (8 MB flash routers have limited erase cycles), power-loss behavior of each file (bbolt is designed for crash safety with fsync — verify our open flags don't disable it), and corruption detection/repair (bbolt ships bolt check — not exposed anywhere).
Why it matters
Fund-relevant state on cheap NAND with power cuts is the environment; "it worked in Docker" is not evidence. A torn bbolt after power loss = potentially the whole wallet (counter lineage + proofs). Also SD-card-class storage (some deployments) silently corrupts; detection tooling is the difference between scheduled recovery and surprise loss.
Current behavior (source refs)
gonuts wallet/storage/bolt.go — bolt.Open(filepath.Join(path, "wallet.db"), 0600, nil) — default options (fsync on commit — verify against bbolt version vendored); no NoGrowSync/NoSync — good if defaults hold; prove it.
No bolt check/repair tooling exposed; no backup guidance beyond MIGRATION docs.
Desired invariant
Every fund-relevant file either survives power loss with its guarantees intact (fsync'd writes, atomic renames) or is reconstructable (journaled + documented repair path); steady-state write amplification fits the flash budget of supported devices (measured, not assumed).
Proposed scope
Audit write paths + flags for every persistent store (bbolt open flags per cgo-free bbolt fork vendored in gonuts; sessions JSON tmp+rename; drain journal; G09 store).
Measure: bytes-written per payment/payout/session-update on lab hardware (/proc/<pid>/io deltas over N ops) for both metrics; publish numbers; set budgets.
Flash-durability audit — every persistent write path, classified (code-verified at main @ cb4db7f)
Scope 1 of the issue's acceptance criteria ("audit table: file, write path, fsync, atomicity, repair"). Findings below come from walking every runtime write site in src/ plus the gonuts-tollgate v0.10.0 storage layer (bbolt v1.4.0). The provably-safe fixes and the wallet check tooling land in the linked PR; measurement and the power-loss campaign need lab hardware and are staged as follow-ups at the bottom.
Audit table
File
Write mechanism
fsync
Atomic (old-or-new)
Torn-write consequence
Recovery
wallet.db
bbolt Update() per money op via gonuts InitBolt; bolt.Open(path, 0600, nil) — defaults hold: NoSync=false (fdatasync per commit), NoGrowSync=false (fsync on grow), freelist synced
O_APPEND + fsync per line; write-only in code (operator evidence artifact, no in-code reader)
✅
n/a (append)
torn final line possible
operator-side: skip malformed tail line when reading by hand
config_backups/
os.Rename of the invalid file (backupAndLog)
❌ (dir entry)
✅ (rename)
rename lost on cut → original invalid file stays → self-heal retries next boot
automatic
Shared residuals, accepted and documented: (a) none of the tmp+rename writers fsync the directory after rename — a lost rename means the previous version survives (old-or-new, never torn; the safe direction on flash); (b) the three merchant-side stores (quote_store, receive-intent, owed-grant) each implement the atomic pattern inline — duplication is noted, consolidation onto a shared helper is deliberate follow-up scope; (c) G06/G09 persistent session/payment stores are not landed — when they land, the tmp+fsync+rename pattern above is the entry bar, not an option.
Findings that change assumptions
bbolt v1.4 does not return corruption as errors — it panics and faults. Seeded-corruption testing (truncated file; XOR'd meta pages) produced both a recoverable panic: assertion failed at open AND an unrecoverable SIGBUS (unexpected fault address … fatal error: fault) inside Tx.Check's goroutine. Any in-process corruption checker dies on exactly the inputs it exists to judge — which is why the new tollgate wallet check runs its check in a forked worker whose death is itself a corruption verdict. This is also a finding for the daemon: a torn wallet.db can crash the merchant at startup rather than degrade politely (jffs2-adjacent robustness, worth its own issue).
The issue's "verify our open flags don't disable fsync" is confirmed: gonuts passes nil options; bbolt v1.4.0 defaults are fsync-on-commit and fsync-on-grow. No action needed.
wallet check proves structure, not bytes: bbolt v1.4 has no data-page checksums, so silent bit-rot inside leaf values (proofs, keyset metadata) is undetectable by tx.Check(). The command help and runbook say so explicitly; hash-compare against backups is the byte-level answer.
identities.json was the audit's top silent-loss risk (signing key + payout addresses behind a non-atomic write with a factory-default self-heal) — now atomic like config.json.
Delivered in the PR (provably safe + tested, no engine changes)
writeFileDurably in config_manager: one shared temp+fsync+rename helper (with the pinned-inode in-place fallback) now behind SaveConfig, SaveIdentities, SaveInstallConfig; the config silently reset to factory-default mint after daemon exit during degraded mode #402-era tests (atomicity, EBUSY fallback) pass unchanged through the shared path, plus new tests pinning the same contract for identities/install and the old-file-survives-total-failure case.
tollgate wallet check (src/cmd/tollgate-cli): offline, read-only bbolt consistency check in a crash-isolated worker; healthy/corrupt/missing verdicts unit-tested with seeded DBs (including both crash shapes) and verified end-to-end against the built binary; adds bbolt v1.4.0 to the CLI module (version-synced, deps-sync check green).
docs/wallet-recovery-runbook.md: the backup → check → verdict paths (intact restart / corrupt re-init + out-of-band fund recovery), the structure-vs-bitrot caveat, and why the config files self-heal; linked from the operator guide.
Remaining acceptance items (need hardware — staged, not dropped)
Write amplification: /proc/<pid>/io write_bytes deltas over N payments/payouts/session updates per store, on the lab fleet; publish numbers and set erase-cycle budgets per device class (8 MB NOR vs SD-class).
Power-loss campaign: N=100 cut-during-write cycles (PRTA smart-plug or QEMU quit) across commit/grow/rename points, post-boot wallet check + balance assertion each cycle. Finding Add install scripts for basic implementation #1 above suggests the campaign will also probe daemon startup behavior on a torn DB.
Receive-intent/owed-grant full-file-rewrite amplification grows with journal length — quantify in the measurement pass and switch to an append/compact scheme if the numbers demand it (fund-safety rules apply; out of this PR's scope).
Audit addendum — five write paths the first pass missed (a completeness re-sweep caught them; the table above claimed exhaustiveness it didn't have. Corrections in place, fixes in the PR):
was plain os.WriteFile of the seed phrase at first generation
was ❌
was ❌
a torn first write bricks the wallet at every later boot (load of a corrupt existing seed errors out — loud, but unrecoverable without the seed). Fixed in the PR: writeMnemonicDurably (temp+fsync+rename, 0600), unit-tested under the cdk_wallet tag
O_APPEND line of bearer tokens from failed payments
was ❌
n/a (append)
an unsynced append is a token the operator can never recover after a power cut. Fixed in the PR: f.Sync() before close, the drain journal's precedent
token-fingerprint salt (src/utils/token_fingerprint.go)
O_EXCL create-once, hex-encoded
was ❌
n/a (create-once)
a lost salt on first boot silently changes every fingerprint already recorded — the exact correlation break the hex encoding exists to prevent. Fixed in the PR: f.Sync() before close
Methodology correction: the first pass walked the named stores and the save/sync/rename call sites in the packages that own them; the re-sweep grepped the write-call inventory (os.WriteFile|Create|CreateTemp|OpenFile|Rename|Sync) across all of src/ non-test code — that difference is where these five lived (a build-tagged lane, and three packages outside the store-owning ones).
Activity
Amperstrand commentedon Oct 8, 2026
Flash-durability audit — every persistent write path, classified (code-verified at
main@ cb4db7f)Scope 1 of the issue's acceptance criteria ("audit table: file, write path, fsync, atomicity, repair"). Findings below come from walking every runtime write site in
src/plus the gonuts-tollgate v0.10.0 storage layer (bbolt v1.4.0). The provably-safe fixes and thewallet checktooling land in the linked PR; measurement and the power-loss campaign need lab hardware and are staged as follow-ups at the bottom.Audit table
wallet.dbUpdate()per money op via gonutsInitBolt;bolt.Open(path, 0600, nil)— defaults hold:NoSync=false(fdatasync per commit),NoGrowSync=false(fsync on grow), freelist syncedtollgate wallet check+ runbook (was: nothing)config.jsonSaveConfig: temp+fsync+rename, in-place fallback for bind-mounted inodes (#402)identities.jsonos.WriteFile— ❌ no fsync, ❌ not atomic → fixed in the PR (writeFileDurably)owned_identities[0].privatekey, main.go:1829) and payout Lightning addresses; torn write → boot-time backup-and-factory-defaults = silent npub swap + payout target revertconfig_backups/; identity must be re-configuredinstall.jsonos.WriteFile→ fixed in the PR (same helper, bind-mount fallback keeps the cloud-lab lane working)install_timerestampedquote_store.jsonO_APPEND+ fsync per line; write-only in code (operator evidence artifact, no in-code reader)config_backups/os.Renameof the invalid file (backupAndLog)Shared residuals, accepted and documented: (a) none of the tmp+rename writers fsync the directory after rename — a lost rename means the previous version survives (old-or-new, never torn; the safe direction on flash); (b) the three merchant-side stores (
quote_store, receive-intent, owed-grant) each implement the atomic pattern inline — duplication is noted, consolidation onto a shared helper is deliberate follow-up scope; (c) G06/G09 persistent session/payment stores are not landed — when they land, the tmp+fsync+rename pattern above is the entry bar, not an option.Findings that change assumptions
panic: assertion failedat open AND an unrecoverable SIGBUS (unexpected fault address … fatal error: fault) insideTx.Check's goroutine. Any in-process corruption checker dies on exactly the inputs it exists to judge — which is why the newtollgate wallet checkruns its check in a forked worker whose death is itself a corruption verdict. This is also a finding for the daemon: a torn wallet.db can crash the merchant at startup rather than degrade politely (jffs2-adjacent robustness, worth its own issue).niloptions; bbolt v1.4.0 defaults are fsync-on-commit and fsync-on-grow. No action needed.wallet checkproves structure, not bytes: bbolt v1.4 has no data-page checksums, so silent bit-rot inside leaf values (proofs, keyset metadata) is undetectable bytx.Check(). The command help and runbook say so explicitly; hash-compare against backups is the byte-level answer.Delivered in the PR (provably safe + tested, no engine changes)
writeFileDurablyin config_manager: one shared temp+fsync+rename helper (with the pinned-inode in-place fallback) now behindSaveConfig,SaveIdentities,SaveInstallConfig; the config silently reset to factory-default mint after daemon exit during degraded mode #402-era tests (atomicity, EBUSY fallback) pass unchanged through the shared path, plus new tests pinning the same contract for identities/install and the old-file-survives-total-failure case.tollgate wallet check(src/cmd/tollgate-cli): offline, read-only bbolt consistency check in a crash-isolated worker; healthy/corrupt/missing verdicts unit-tested with seeded DBs (including both crash shapes) and verified end-to-end against the built binary; adds bbolt v1.4.0 to the CLI module (version-synced, deps-sync check green).docs/wallet-recovery-runbook.md: the backup → check → verdict paths (intact restart / corrupt re-init + out-of-band fund recovery), the structure-vs-bitrot caveat, and why the config files self-heal; linked from the operator guide.Remaining acceptance items (need hardware — staged, not dropped)
/proc/<pid>/iowrite_bytes deltas over N payments/payouts/session updates per store, on the lab fleet; publish numbers and set erase-cycle budgets per device class (8 MB NOR vs SD-class).wallet check+ balance assertion each cycle. Finding Add install scripts for basic implementation #1 above suggests the campaign will also probe daemon startup behavior on a torn DB.Amperstrand commentedon Oct 8, 2026
Audit addendum — five write paths the first pass missed (a completeness re-sweep caught them; the table above claimed exhaustiveness it didn't have. Corrections in place, fixes in the PR):
src/tollwallet/cdk_wallet.go, behindcdk_wallettag)os.WriteFileof the seed phrase at first generationwriteMnemonicDurably(temp+fsync+rename, 0600), unit-tested under thecdk_wallettagtoken-recoveryjournal (src/upstream_session_manager/token_recovery.go)O_APPENDline of bearer tokens from failed paymentsf.Sync()before close, the drain journal's precedentsrc/utils/token_fingerprint.go)O_EXCLcreate-once, hex-encodedf.Sync()before closesrc/wireless_gateway_manager/discovery_log.go:135,200).old)discovery_logrotation target.oldcovered above)Methodology correction: the first pass walked the named stores and the save/sync/rename call sites in the packages that own them; the re-sweep grepped the write-call inventory (
os.WriteFile|Create|CreateTemp|OpenFile|Rename|Sync) across all ofsrc/non-test code — that difference is where these five lived (a build-tagged lane, and three packages outside the store-owning ones).fix(wallet,utils): durable mnemonic + fsync'd token-recovery and salt…
changelog: fold the OpenTollGate#505 addendum fixes into the 743 frag…