v2 follow-up: review-cycle findings (HIGH/MEDIUM/NITS) after fix bundle 1 #11
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
Single tracking issue for review findings on the v2-bitspire branch that were deferred after fix bundle 1 shipped (commit
3ede66f, which closed the wallet-IDOR security finding + concurrency H1/H2/H3 + added the retry endpoint).A formal security pass + architectural review ran on the branch at the P3e checkpoint (10 commits at that point). Fix bundle 1 addressed the HIGH-severity security issue and three load-bearing concurrency findings. Everything else from those two reviews lives here for follow-up.
HIGH-priority remaining (architectural review)
H4. Decouple invoice listener from distribution
tasks._handle_paymentawaitsprocess_settlementsynchronously. Each settlement does N+M internalcreate_invoice/pay_invoiceround-trips (N = LP count, M = split legs + super fee). A slow machine (50 LPs, stalled internal payments) freezes the global LNbits invoice queue for every extension on the node, not just satmachineadmin.Fix: spawn
process_settlementon a task —asyncio.create_task(process_settlement(settlement.id))— and let the listener move on. The new claim-based concurrency guards already prevent double-processing on listener re-fires, so this is safe.Files:
tasks.py(line ~85).H5. Fallback path silently strands sats
bitspire._parse_fallbacksetsexchange_rate=0.0when bitSpire'sPayment.extrais absent.distribution._pay_dca_distributionsthen logs a warning and leavesnet_satsorphaned in the machine wallet — but the settlement is still marked'processed'. From the operator's dashboard, this looks identical to a clean process; they don't know that 258k sats are stuck and need manual reconciliation.Fix: either mark such settlements as
'partial'instead of'processed', or write adca_paymentsrow withleg_type='dca',status='skipped',amount_sats=settlement.net_sats,error_message='no exchange rate'. The skipped-leg approach is consistent with M8 below (super-fee leg skipped because no super wallet).Awaits
aiolabs/lamassu-next#44for the cleaner long-term fix (bitSpire populatesPayment.extraso fallback is unreachable in practice).Files:
bitspire.py,distribution.py.H6. Partial-dispense recomputes split using current
super_fee_pctdistribution.apply_partial_dispense_and_redistributereadssuper_fee_pctfromsuper_configat the time of partial-dispense, breaking the "absolute fields are the source of truth" invariant the schema and the v2 design were built on. If super raises the rate between landing and partial-dispense, the operator's slice shrinks unfairly.Fix: re-derive the new platform/operator split from the ratio of the original
platform_fee_satstocommission_satson the settlement row, not from the current super_config:Files:
distribution.py(around line 260-264).MEDIUM findings
M1.
calculate_distributionfilter threshold unit ambiguitymin_balance_threshold=0.01incalculations.pyis "0.01 of whatever the caller'sclient_balancesdict says it is" (fiat in our case). Docstring example uses fiat literals; comment indistribution.pydoesn't anchor the unit. Make the threshold a caller-supplied named arg or rename tomin_fiat_balanceand document.M2. DCA leg
amount_fiatcan slightly exceed remaining balance_pay_dca_distributionsdoescap_sats = int(remaining_fiat * exchange_rate)thenamount_fiat = round(amount_sats / exchange_rate, 2). The floor at cap + round at fiat can pushamount_fiatslightly aboveremaining_fiat, putting the LP at a small negative balance inget_client_balance_summary. Use floor onamount_fiat.M3. N+1 query in DCA distribution
_pay_dca_distributionscallsget_client_balance_summary(client.id)inside a loop over flow-mode clients. Each call does two queries + aget_machine. For a machine with 50 LPs that's 150 DB round-trips per settlement. Replace with a single grouped JOIN that returns(client_id, balance)per active flow-mode LP at the machine. Same applies to any operator dashboard endpoint that hits/balanceper LP row.M4.
char(10)newline embed is SQLite-only idiomapply_partial_dispenseandappend_settlement_noteincrud.pyusechar(10)to embed newlines into thenotesblob. SQLite supportschar(10)but PG idiom ischr(10). Both happen to acceptchar(10), but cleaner is to construct the joined string in Python and pass as a single parameter — DB-agnostic.M5.
hourly_transaction_pollingis deadtasks.pyhas a no-ophourly_transaction_pollingstub but__init__.pystill spawns a permanent task for it. Delete the function + the task spawn + the import. (See fix bundle 3 below for the cleanup-cluster proposal.)M6. Placeholder debug log in
__init__.py__init__.py:12-15carries the scaffolding template's "you can debug in your extension using…" log line. Useful during scaffolding; now noise on every boot. Replace with a meaningful "satmachineadmin v2 loaded" line or delete.M7. Catch-all stub in
views_api.pyis unauthenticatedviews_api.v2_in_progress_stubreturns 503 for any unmatched/api/v1/dca/*request without auth. Leaks the extension's existence to unauthenticated probes. With P3a-e all shipped, the stale "landing in P2+" comment is misleading; delete the stub entirely (P3f and P6 will land their own endpoints).M8. Super-fee leg silently no-ops when wallet unset
_pay_super_feewarns to logs and returns whensuper_fee_wallet_idis None, but the settlement still progresses to'processed'and theplatform_fee_satsvalue remains in the machine wallet. Operator audit doesn't reflect it. Same fix as H5 — record adca_paymentsrow withleg_type='super_fee', status='skipped'.M9.
settle_lp_balancedesign note (not a bug)distribution.settle_lp_balancerecords its leg withsettlement_id=Nonesince it's not tied to a bitSpire settlement.get_client_balance_summarycorrectly counts bothdca+settlementleg_types against the LP balance, so the math is consistent. Worth documenting in the model comment thatsettlementlegs are intentionally independent of any parentdca_settlementsrow.M10.
Update*Datamodels can't unset fieldsAll
Update*DataPydantic models useOptional[str] = NonewithNonemeaning "no change" in the CRUD update. Operators can never clearsuper_fee_wallet_id(or other fields) once set — only overwrite. Adopt a sentinel pattern (e.g. accept""as "clear") or use Pydantic'sField(...)with a discriminatedUnset/SetToValuewrapper.M11.
replace_commission_splitsis not atomiccrud.pydoes DELETE + N INSERTs without a transaction. A crash between DELETE and the last INSERT leaves the operator with a partial ruleset that doesn't sum to 1.0. Wrap in a transaction.M12.
dca_settlements.fiat_amountdefaults to 0.0 in fallback pathSettlement still proceeds with
fiat_amount=0.0+exchange_rate=0(skips DCA distribution per H5). Column isNOT NULL DECIMAL(10,2), so the zero is a valid number that aggregates into operator reports as "0 GTQ on this tx" — operators see a settlement row with zero fiat and don't know it represents an unprocessed transaction. Either makefiat_amountnullable or set status to'errored'immediately on fallback path.NITS
bitspire._parse_extrafalls back to_parse_fallbackwhen extra is malformed, but the caller-facing(data, used_fallback)tuple saysused_fallback=False. Inconsistent. Reflect actual fallback usage in bothdata.used_fallback_splitand the returned tuple._pay_internalusesdatetime.now()(naive);settle_lp_balanceusesdatetime.now(timezone.utc). Pick one.views_api.pycatch-all comment references P2+ but P3a-e have shipped (see M7).__init__.py:8importshourly_transaction_pollingeven though it's a no-op (see M5).transaction_processor.py(1274 lines) is orphaned. Delete.migrations.pymixes v1 m001-m004 with v2 m005-m007. Collapse to a singlem001_initial_v2_schemabefore any operator with v1 data exists in the wild (per CLAUDE.md, "production ready" is stretched here).calculations.calculate_commissiondocstring still says "Lamassu transaction"; rename to "Lamassu-formula" since it's now reused viabitspire._parse_fallback.calculations.allocate_operator_split_legsparameterleg_pcts: listshould belist[float]for clarity; return-> listshould be-> list[int].crud.append_settlement_notehasfrom datetime import timezoneinline — hoist to module-level imports.CreateMachineData.commission_in_unit_rangeandUpdateMachineData.commission_in_unit_rangeare identical validators. Factor into a sharedPctFieldtype or function.bitspire.parse_settlementwarning log lackspayment_hash[:12]— useful for grep'ing logs when the operator knows the hash but not the npub._payment_tag(machine)writes the full Nostr npub on every Payment.tag. Consider truncating or usingmachine.id(shorter, opaque, still uniquely identifies).Reviewer-proposed fix bundles (already partially shipped)
Fix bundle 1 — security + concurrency ✅ SHIPPED (commit
3ede66f)dca_machines.wallet_id(defence-in-depth).claim_settlement_for_processingoptimistic-lock pattern.reset_settlement_for_retry+ newPOST /api/v1/dca/settlements/{id}/retryendpoint.Fix bundle 2 — Decouple listener + audit skipped legs ⬜ TODO
asyncio.create_task(process_settlement(...))intasks._handle_payment(closes H4).dca_paymentsrows withstatus='skipped'in_pay_super_fee/_pay_dca_distributionswhen sats are stranded (closes H5 + M8).'skipped'to the documented leg-status enum.Fix bundle 3 — Dead-code purge ⬜ TODO
views_api.v2_in_progress_stub+ its catch-all route (M7 + N3).git rm transaction_processor.py(N5; 1274 lines of dead Lamassu code).m001..m007into a singlem001_initial_v2_schema(N6).hourly_transaction_polling+ its__init__.pyspawn + import (M5 + N4).__init__.py(M6).Standalone — H6 ⬜ TODO
What the review confirmed is RIGHT (preserve)
platform_fee_sats/operator_fee_satsondca_settlements(not percentages). This is the load-bearing design choice for the post-v1 customer-discount engine — keeping it as audit-grade absolutes makes the discount engine ship without a breaking migration. Documented inmigrations.py:300-306.payment_hashas idempotency key (notbitspire_event_id). LN payment_hash is globally unique and always present._machine_owned_by/_client_owned_byhelpers.test_two_stage_split.py.Related
aiolabs/satmachineadmin#9— v2 epicaiolabs/satmachineadmin#10— future dedicated audit tableaiolabs/lamassu-next#44—Payment.extrasplit metadata (turns off the fallback path)2026-05-26 — fix bundles 2 + 3 + H6 status update
Three bundles of this tracker's TODOs have shipped since the original filing:
Fix bundle 2 — shipped at
ecef916 fix(v2): decouple listener + skipped-leg audittasks._handle_paymentnow spawnsasyncio.create_task(process_settlement(...)); the listener returns immediately.dca_paymentsrow withstatus='skipped'+error_message='no exchange rate'so the operator dashboard surfaces it.Fix bundle 3 — shipped at
b968371 chore(v2): dead-code purgehourly_transaction_pollingstub deleted)v2_in_progress_stubdeleted)transaction_processor.pydeleted)m001_satmachine_v2_initial)Standalone — shipped on the v2-bitspire branch
distribution.py:apply_partial_dispense_and_redistribute.Still open (re-triage candidates)
Suggested next step: split the remaining MEDIUM items into their own focused issues (this tracking pattern is fine for a deferred batch but hard to make progress on as a checklist). NITS can stay batched.
➡️ Migrated to aiolabs/spirekeeper#7 (aiolabs/spirekeeper#7).
The v2-bitspire line of this extension now lives in its own repo,
aiolabs/spirekeeper. Tracking for this issue continues there; closing here. (Issue numbers were reassigned in the new repo.)