proxy wallet: sweep quotes across per-epoch currency units #23

Open
vnprc-agent wants to merge 2 commits from agent/multi-unit-sweeper into agent/nutxx-pubkey-lookup
Collaborator

The proxy's quote sweeper now mints quotes in every per-epoch currency unit, not just the hash unit 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

  • Per-unit wallet handles share one sqlite localstore and one metadata cache. Issue #17 verified the wallet schema and NUT-13 keyset counters are unit- or keyset-scoped, and units are swept sequentially within a pass, so there is no cross-unit contention; still, this is the first time multiple Wallet instances run over one store in this process.
  • Discovery no longer depends on the pubkey lookup alone: a lookup-endpoint failure (e.g. mint stops advertising a NUT-06 pubkey, or a NUT-XX regression) leaves every DB-known unit sweepable and visible in the reconcile log via the local-store union. Only quotes that never reached the DB at all (dropped SV2 notification while the lookup is also failing) wait for the lookup to recover.
  • Any unit with unissued quotes in the local store is batch-checked every pass while it has backlog, unpaid quotes included — the old single-unit behavior, generalized. Units holding permanently-unmintable quotes (e.g. expired unpaid) therefore stay in the scan and the log until settled or pruned; the cost is bounded (one sqlite read plus at most 4 status-check and 4 mint calls per unit per pass), and terminal-state eviction is deliberately left out of this PR.
  • The reconcile log format changed from Found N pending quotes to a per-unit summary line; anything scraping the old string would need updating.
  • Per-pass work per unit is bounded (200 selected, chunks of 50), so a very large backlog takes multiple passes to drain, logged each time it caps; quotes beyond the cap wait a pass even if paid, mitigated by selecting locally-mintable-looking quotes first so a stale head-of-line cannot starve them.

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 untouched channel_manager.rs test code remains; a pre-existing useless-conversion warning in wallet.rs was removed by the rewrite.
  • rustfmt: both payment files are diff-free; in lib/mod.rs the edited region was formatted and two pre-existing drift spots elsewhere in the file were deliberately left alone to keep the diff minimal.
  • The union and the chunk planner are unit-tested as pure functions; the per-chunk network paths (batch status check, batch mint, chunk-failure isolation) are not exercised by tests here.
  • NOT validated here: the issue's witness — rotate mid-mining on the dev stack with #16's manual lever, watch the sweeper mint both old- and new-unit quotes, the per-unit reconcile log converge to zero unissued, and the faucet and web UI stay functional. That needs the mint-side branch and is the orchestrator's end-to-end step.
The proxy's quote sweeper now mints quotes in every per-epoch currency unit, not just the `hash` unit 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 - Per-unit wallet handles share one sqlite localstore and one metadata cache. Issue #17 verified the wallet schema and NUT-13 keyset counters are unit- or keyset-scoped, and units are swept sequentially within a pass, so there is no cross-unit contention; still, this is the first time multiple `Wallet` instances run over one store in this process. - Discovery no longer depends on the pubkey lookup alone: a lookup-endpoint failure (e.g. mint stops advertising a NUT-06 pubkey, or a NUT-XX regression) leaves every DB-known unit sweepable and visible in the reconcile log via the local-store union. Only quotes that never reached the DB at all (dropped SV2 notification while the lookup is also failing) wait for the lookup to recover. - Any unit with unissued quotes in the local store is batch-checked every pass while it has backlog, unpaid quotes included — the old single-unit behavior, generalized. Units holding permanently-unmintable quotes (e.g. expired unpaid) therefore stay in the scan and the log until settled or pruned; the cost is bounded (one sqlite read plus at most 4 status-check and 4 mint calls per unit per pass), and terminal-state eviction is deliberately left out of this PR. - The reconcile log format changed from `Found N pending quotes` to a per-unit summary line; anything scraping the old string would need updating. - Per-pass work per unit is bounded (200 selected, chunks of 50), so a very large backlog takes multiple passes to drain, logged each time it caps; quotes beyond the cap wait a pass even if paid, mitigated by selecting locally-mintable-looking quotes first so a stale head-of-line cannot starve them. ## 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 untouched `channel_manager.rs` test code remains; a pre-existing useless-conversion warning in `wallet.rs` was removed by the rewrite. - rustfmt: both payment files are diff-free; in `lib/mod.rs` the edited region was formatted and two pre-existing drift spots elsewhere in the file were deliberately left alone to keep the diff minimal. - The union and the chunk planner are unit-tested as pure functions; the per-chunk network paths (batch status check, batch mint, chunk-failure isolation) are not exercised by tests here. - NOT validated here: the issue's witness — rotate mid-mining on the dev stack with #16's manual lever, watch the sweeper mint both old- and new-unit quotes, the per-unit reconcile log converge to zero unissued, and the faucet and web UI stay functional. That needs the mint-side branch and is the orchestrator's end-to-end step.
A CDK wallet only mints the unit it was constructed with, so after an
epoch rotation the sweeper fetched new-unit quotes but never minted
them. Split wallet construction into shared resources (one sqlite
localstore, one seed, one metadata cache) plus cheap per-unit wallet
handles built on demand. The sweeper derives the unit set from the
quotes returned by the pubkey lookup, lazily creates a handle per unit,
and runs the existing sweep body once per unit with the same P2PK
spending conditions. Mint batches are capped at 200 quotes per unit per
pass, handles are dropped once a unit has nothing left to mint, and the
reconcile log reports unissued counts per unit. The faucet and quote
notification handler keep using the base hash-unit wallet.
Author
Collaborator

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-blind localstore.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 and batch_check_mint_quote_status stay 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-side check_mint_quotes fails 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 &mut owned by the loop would express the truth; WalletBuilder::build() writes set_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.

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-blind `localstore.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 and `batch_check_mint_quote_status` stay 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-side `check_mint_quotes` fails 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 `&mut` owned by the loop would express the truth; `WalletBuilder::build()` writes `set_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.
Unit discovery was hostage to the pubkey lookup: after a restart, a
persistent lookup failure that spares other endpoints left foreign-unit
DB backlog unswept and invisible in the reconcile log. The sweep unit
set now also unions units from one unit-blind
localstore.get_unissued_mint_quotes() call (the unit filter in cdk is a
wallet-level retain, not SQL), so any unit with local backlog is swept
and logged regardless of lookup health.

The 200-per-unit cap previously bounded only batch_mint; stamping and
the batch status check ran over the whole backlog, and one giant status
POST could fail atomically every pass with zero progress. The cap now
bounds the whole per-unit pipeline: pending quotes are selected up
front (locally-mintable-looking first, so a stale head-of-line cannot
starve paid quotes behind it), capped at 200 per pass, and processed in
chunks of 50 through stamp, check, and mint. A failed chunk is logged
and skipped while the remaining chunks keep going, so a quote the mint
no longer knows wedges at most its own chunk.
Author
Collaborator

Both should-fixes landed in c38658bf; PR body updated to match.

  1. Unit discovery no longer hostage to the pubkey lookup: the sweep unit set now also unions units from one unit-blind 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.
  2. Whole pipeline bounded, not just batch_mint: up to 200 pending quotes are selected per unit per pass — locally-mintable-looking first, so a stale head-of-line cannot starve paid quotes behind the cap — then stamped, status-checked, and minted in chunks of 50; a failed chunk is logged and skipped while later chunks proceed, so an unknown quote id wedges at most its own chunk. No intra-pass backoff: the 15 s pass interval paces retries.

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.

Both should-fixes landed in c38658bf; PR body updated to match. 1. **Unit discovery no longer hostage to the pubkey lookup**: the sweep unit set now also unions units from one unit-blind `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. 2. **Whole pipeline bounded, not just batch_mint**: up to 200 pending quotes are selected per unit per pass — locally-mintable-looking first, so a stale head-of-line cannot starve paid quotes behind the cap — then stamped, status-checked, and minted in chunks of 50; a failed chunk is logged and skipped while later chunks proceed, so an unknown quote id wedges at most its own chunk. No intra-pass backoff: the 15 s pass interval paces retries. 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.
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin agent/multi-unit-sweeper:agent/multi-unit-sweeper
git switch agent/multi-unit-sweeper

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch agent/nutxx-pubkey-lookup
git merge --no-ff agent/multi-unit-sweeper
git switch agent/multi-unit-sweeper
git rebase agent/nutxx-pubkey-lookup
git switch agent/nutxx-pubkey-lookup
git merge --ff-only agent/multi-unit-sweeper
git switch agent/multi-unit-sweeper
git rebase agent/nutxx-pubkey-lookup
git switch agent/nutxx-pubkey-lookup
git merge --no-ff agent/multi-unit-sweeper
git switch agent/nutxx-pubkey-lookup
git merge --squash agent/multi-unit-sweeper
git switch agent/nutxx-pubkey-lookup
git merge --ff-only agent/multi-unit-sweeper
git switch agent/nutxx-pubkey-lookup
git merge agent/multi-unit-sweeper
git push origin agent/nutxx-pubkey-lookup
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
vnprc/hashpool!23
No description provided.