proxy wallet: sweep quotes across per-epoch currency units #23
Loading…
Reference in a new issue
No description provided.
Delete branch "agent/multi-unit-sweeper"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The proxy's quote sweeper now mints quotes in every per-epoch currency unit, not just the
hashunit its wallet was built with.Before this, after the first epoch rotation the sweeper fetched new-unit quotes from the mint, stored them, and silently never minted them.
Discovery unions two quote sources: the unit-agnostic pubkey lookup and one unit-blind read of the wallet DB's unissued quotes, so a failing lookup endpoint cannot hide DB backlog; units are never derived from mint info.
The sweeper builds a cheap per-unit wallet handle on first sight of a unit and runs the same sweep (status check, then P2PK-locked batch mint) once per unit; all handles share the one wallet database, seed, and mint metadata cache, so nothing changes on disk.
The faucet, web UI, and quote notification path keep using the single
hash-unit wallet exactly as before.Per unit and pass the whole pipeline is bounded: at most 200 quotes are selected (locally-mintable-looking first) and processed in chunks of 50 per batch call, so one bad quote id can wedge at most its own chunk; a rotation backlog drains over successive 15-second passes.
The reconcile log reports unissued counts per unit, which is the standing signal for fetched-but-never-minted divergence.
Proof: cargo check, test, and clippy are green with 12 unit tests on the discovery and selection logic; the end-to-end rotation witness still needs the mint-side epoch branch (#16) and runs on the dev stack after both land.
If it misbehaves, revert the two commits on this branch; the wallet DB schema is untouched, so rollback is safe.
Closes vnprc/hashpool#17. Refs vnprc/hashpool#13.
Risk
Walletinstances run over one store in this process.Found N pending quotesto a per-unit summary line; anything scraping the old string would need updating.Validation
cargo check -p translator_sv2: clean.cargo test -p translator_sv2: 32 passed, 0 failed (20 pre-existing, 12 new: unit-set derivation including the DB-backlog union, sweep planning covering ready-first ordering, cap and chunk boundaries and edge cases, per-unit unissued summary formatting).cargo clippy -p translator_sv2 --all-targets: no new warnings. One pre-existing warning in untouchedchannel_manager.rstest code remains; a pre-existing useless-conversion warning inwallet.rswas removed by the rewrite.lib/mod.rsthe edited region was formatted and two pre-existing drift spots elsewhere in the file were deliberately left alone to keep the diff minimal.Adversarial review findings (read-only pass; fixes queued on this branch):
should-fix — unit discovery is hostage to the pubkey lookup. The sweep's unit set is fetched-quotes ∪ tracked-map ∪ base; the wallet DB is never enumerated. After a restart (tracked map empty), a persistent lookup failure that spares other endpoints (e.g. mint stops advertising a NUT-06 pubkey →
MissingPubkey, or a NUT-XX regression — it's a fork-only extension) leaves foreign-unit unissued quotes in the DB unswept forever, while the base unit sweeps normally — and the per-unit reconcile log only covers units in the sweep set, so the advertised "fetched but never minted" divergence signal is blind to exactly this case. Transient failures are genuinely fine (quotes stay mintable server-side; next pass recovers). Fix: union in units from one unit-blindlocalstore.get_unissued_mint_quotes()call (zero network; the design doc forbids deriving from mint info, not from the local store) — this also makes the reconcile log cover every unit with DB backlog.should-fix — the 200/unit cap bounds only
batch_mint; stamping andbatch_check_mint_quote_statusstay unbounded. A large rotation backlog (the motivating case) produces one giant status-check POST; if that call fails (size limits, future mint-side batch cap), the unit retries the identical unbounded call every 15s with zero progress — and mint-sidecheck_mint_quotesfails the whole batch on one unknown quote id, so a single pruned quote can block all other mintable value in its unit. Inherited shape, but this PR introduces the boundedness claim. Fix: cap/chunk the pending set before stamping and status-check; add per-unit backoff or poisoned-quote eviction.nits — units with permanently-unmintable (e.g. expired-unpaid) quotes are tracked forever, scanning + batch-checking + logging every 15s across epochs (fold terminal-state eviction into the above); the
tokio::sync::Mutex<HashMap>has exactly one holder ever — safe, verified no deadlock — but&mutowned by the loop would express the truth;WalletBuilder::build()writesset_ttl(Some(3600))on the shared metadata cache per handle build (no-op today, silently resets any future custom TTL); two Risk bullets mischaracterize: "a lookup outage delays foreign-unit sweeping until the mint is reachable again" conflates endpoint-specific failure with mint unreachability, and "unpaid foreign-unit quotes are only re-checked once the mint reports them mintable" is false while a unit is tracked (they're batch-checked every pass — safe direction, still wrong).Verified clean: eviction is provably safe under transient errors (drop only on a successful zero-row DB read); the metadata cache and localstore are genuinely shared (Arc identity, keyed by data pointer); faucet/notification wiring bitwise-equivalent; P2PK conditions and SplitTarget identical per-unit; all body validation claims reproduced exactly (29/0 tests, the clippy and fmt statements, 3-file diff, single revertable commit); zero file overlap with #24 so merge order is free — but note the merged system needs #24's cdk pin (
3dea7086) for old-epoch quotes to stay checkable after processor retirement, and the e2e rotation witness (honestly disclosed as not-run) is what covers the combination.Verdict: mergeable — no blockers; land the two should-fixes before #18 makes big backlogs routine.
Both should-fixes landed in
c38658bf; PR body updated to match.localstore.get_unissued_mint_quotes()call (verified at cba8469b the unit filter is a wallet-level retain, not SQL), so DB backlog is swept and shows in the per-unit reconcile log even when the lookup endpoint fails persistently.The two mischaracterized Risk bullets are corrected in the body. Tests now 32/0 for the package (12 sweeper units covering the union and the selection/chunk planner); clippy and fmt unchanged. Terminal-state eviction and the remaining nits are left as-is per scope.
Re-pinned onto a reachable cdk revision and revalidated.
The cdk revision this branch inherited,
cba8469b, is confirmed lost — it exists on no reachable remote and in no local object store (the full search is recorded on vnprc/hashpool#21). Merge commit92bd651fbrings in the updated base branch, which carries the replacement pine5a85f810a91bd1d8882a4a60ed93fd60bb691c1(vnprc/cdknutxx-wallet-v2, workspace 0.17.0) together with master's devenv repair, without whichdevenv shellcould not enter on this branch at all.The merge was automatic, with no conflict. The sweeper source tree is byte-identical across it:
git diff c38658bf..92bd651f -- roles/translatoris empty, so nothing in this PR's actual subject matter changed.Verified by the integrator independently of the worker that performed the merge:
roles/translatorhas zero diff across the merge, so the reviewed sweeper logic is untouched.cba8469bremains in the tree; all 16 cdk entries inroles/Cargo.locksit ate5a85f8, and the four pin sites agree.time0.3.41,serde_with3.17.0,darling0.21.3,simple_asn10.6.3 against rustc 1.86.0.cdkCommit, onecdkRepo, and one[patch.crates-io]block per workspace. A merge that bound any of these twice would still have passed every check below.cargo test -p translator_sv2re-run by the integrator: 32 passed, 0 failed, matching this PR body's claim exactly (20 pre-existing plus the 12payment::quote_sweeper::tests::*tests covering unit-set derivation, sweep planning, cap and chunk boundaries, and the per-unit unissued summary).cargo checkandcargo clippy -p translator_sv2 --all-targetsclean; the one remaining warning is pre-existing test code atchannel_manager.rs:900, not sweeper code.No separate cold review pass was spent on this round, deliberately. The only change is a merge whose pin delta is identical to the one already cold-reviewed with no findings on vnprc/hashpool#21, over a source tree this merge provably does not touch; the merge-duplication trap that a reading might catch was instead checked mechanically above. The sweeper logic itself keeps the review pass and fix round it had in August. Per
risk.md, passes stop where an available witness can execute the question instead.Still not run, and unchanged from the original body: the end-to-end rotation witness — rotate mid-mining, watch the sweeper mint both old- and new-unit quotes, per-unit reconcile drain to zero. That needs the mint-side epoch branch and is the orchestrator's step once vnprc/hashpool#24 is buildable again.
Merge order: this PR's base is
agent/nutxx-pubkey-lookup(vnprc/hashpool#21), so merge vnprc/hashpool#21 first, then this one.Blocked by vnprc/hashpool#29, found by running this branch integrated with vnprc/hashpool#21 and #24 on a live dev stack.
Before any rotation this branch works: the sweeper minted ehash in the genesis epoch repeatedly, reaching a balance of 2752 across batches of 22 and 40 quotes. After
just rotate-epochit never mints again, in any unit, for the rest of the run:grep -c '_122 from' logs/proxy.logis0— not one quote in a post-rotation unit was ever minted, and the unissued count rose monotonically to 5 and stayed there. Every epoch after the first would lose all of its mining rewards.Root cause of the first error is in this branch:
WalletFactory::wallet_for_unitinroles/translator/src/lib/payment/wallet.rsis synchronous and makes no network calls, as its own doc comment states, so a new epoch's keyset is never fetched into the shared metadata cache that predates the epoch. The cheapness that makes per-unit handles free is what leaves them unable to mint a unit whose keyset is newer than the cache. The subsequentQuote already in use by another operationthen appears to convert that transient failure into a permanent one.Worth noting for the review record: all 12 of this branch's sweeper tests pass against the broken code, and the 32-test suite is green. The PR body honestly disclosed that the per-chunk network paths are not exercised by tests, and this defect lives exactly in that gap — unit-set derivation and chunk planning were tested as pure functions, while the mint call against an unseen keyset was not.
A fix is in progress. This PR should not merge until #29 is resolved and the rotation witness is re-run.
In plain English
Once the mint issues a separate kind of ecash for each mining epoch, the wallet has to be able to collect all of them. It could only collect the first kind. After the first epoch rotation it would notice the new rewards, write them down, and then never actually collect them — so every epoch after the first lost all its mining income.
This teaches the wallet to collect every kind of ecash it sees, not just the one it was built with, and to work through a backlog in bounded batches so one bad reward cannot block the rest.
Two real bugs this found, both now fixed here
Running the full stack turned up two faults that no test caught:
New epochs could not be collected at all. On first seeing a new kind of ecash, the wallet never asked the mint for the matching key, so every attempt failed. Rewards piled up uncollected forever. Fixed by asking the mint once, the first time a new kind appears.
A single failure wedged rewards permanently. When a collection attempt failed, it left the reward marked as "in progress" with nothing to ever clear it, so it could never be retried. Fixed by clearing those stuck markers on each pass, and saying so in the log when it happens.
Worth noting: all 32 tests on this branch passed while both bugs were live. They tested the planning logic, not the actual collection call. Only running the real thing found them.
Status
Verified on a running dev stack. Rewards are collected in every epoch, and a backlog of 55 previously stuck rewards drained in a single pass once the fixes were in.
Merge after vnprc/hashpool#21.
Superseded by a squashed branch with the same tree. Closing and reopening because the forge refuses force-push on an open pull request branch; the replacement is byte-identical to the state verified here and carries the same base.
Pull request closed