Defensive: route inbound payment by ATM-npub fallback when wallet lookup fails #17

Open
opened 2026-06-14 07:08:46 +00:00 by padreug · 0 comments
Owner

Migrated from aiolabs/satmachineadmin#31 (2026-06-13). Issue numbers were reassigned in this repo; cross-refs updated.

Problem

tasks._handle_payment resolves the target machine via get_active_machine_by_wallet_id(payment.wallet_id). If no machine matches that wallet, the handler silently returns and the payment is never recorded as a dca_settlements row.

This is correct for "random unrelated payments that happen to land on a wallet that isn't ours." But it's wrong for a real failure mode demonstrated end-to-end on 2026-05-30: a kind-21000 RPC from a known ATM credits the wrong wallet (because lnbits' nostr-transport auto-account-from-npub flow created an account for the ATM's own pubkey, and the invoice routed to that account's wallet rather than the operator's). The ATM dispensed cash on the strength of the Lightning payment notification (its own view is "invoice paid, dispense"), and satmachineadmin lost the settlement entirely. No dca_settlements row, no DCA distribution legs, no commission split, no super_fee, no LP payouts. Sats sit in the auto-account wallet, recoverable but unaccounted.

Reproducer in coord-log archive/2026-05-31-pre-rotation.md (2026-05-30T21:33Z entry + thread). $20 cash-out from Sintra to Greg's machine; payment landed on auto-account a94b564f8a8d4a768972ad7e2364fdb9 (pubkey = the ATM's npub) instead of Greg's registered wallet 9927f101....

Why "proper fix lands in S6" isn't enough by itself

aiolabs/satmachineadmin#20 (S6 — roster-gated auto-account on the lnbits side) is the architectural fix: prevent the auto-account from being created in the first place, so the invoice routes to the operator's wallet instead. That's the right long-term answer.

But:

  1. S6 is Sprint 3 per the #8 epic + has a pending design refresh (see the aiolabs/satmachineadmin#20 comment thread post-dcd0874). Realistic ETA is post-S0/S2/S7. Multiple weeks.
  2. S6 lives in lnbits, not satmachineadmin. Until S6 lands on every host satmachineadmin runs against, the auto-account behaviour persists.
  3. Any operator account regression (the 2026-05-30 case was a pubkey refresh that broke a pre-existing identity collision; future operations like account migration to bunker can hit similar edges) leaves the operator silently dropping settlements.

A satmachineadmin-side defensive lookup is cheap, local, additive, and fail-safe — and crucially, makes the silent-drop failure mode loud rather than invisible. The pubkey-collision root cause is tracked separately at aiolabs/satmachineadmin#32; this issue addresses the symptom-side mitigation.

Proposed fix

In tasks._handle_payment, when get_active_machine_by_wallet_id(payment.wallet_id) returns None, attempt a fallback lookup:

machine = await get_active_machine_by_wallet_id(payment.wallet_id)
if machine is None:
    # Fallback: route by sender pubkey for the payment-landed-on-wrong-
    # wallet failure mode (see issue #17). Common cause: lnbits
    # auto-account-from-npub created a wallet for the ATM's own npub,
    # routing the invoice there instead of the registered operator's
    # wallet. Sats actually sit on the auto-account wallet, but the
    # settlement is logically owned by the dca_machines row whose
    # machine_npub matches the inbound sender pubkey.
    extra = payment.extra or {}
    sender_pubkey = extra.get("nostr_sender_pubkey")
    if sender_pubkey:
        machine = await get_active_machine_by_atm_pubkey_hex(sender_pubkey)
        if machine is not None:
            logger.warning(
                f"satmachineadmin: payment landed on wallet "
                f"{payment.wallet_id[:12]}... which is not registered to "
                f"any machine, but sender pubkey {sender_pubkey[:12]}... "
                f"matches machine {machine.id} (wallet drift — see "
                f"aiolabs/spirekeeper#17)."
            )
    if machine is None:
        return  # truly unrelated payment; original silent skip

get_active_machine_by_atm_pubkey_hex already exists in crud.py (used by the cassette consumer). The lookup is O(N over active machines) which is fine for any realistic operator fleet.

Once a machine is resolved via the fallback path, the rest of _handle_payment proceeds identically: attribution check (the existing check pairs sender_pubkey against machine.machine_npub — passes by construction here), parse_settlement, idempotent insert, distribution. The distribution legs draw from payment.wallet_id which is the auto-account wallet, so the operator's commission_split + DCA payouts come from the auto-account's balance (which has the just-credited sats). Operationally functional even though the wallet ownership is "wrong" from the operator's perspective.

Acceptance

  • When a Payment arrives at a wallet that has no dca_machines row, but payment.extra.nostr_sender_pubkey matches a registered ATM's machine_npub, the listener resolves the machine via the fallback path and records the settlement as if the payment had landed on the registered wallet.
  • WARN log per fallback-resolved settlement, with both wallet IDs + the matched machine + a reference back to this issue.
  • Distribution legs (super_fee, operator_splits, DCA) draw from payment.wallet_id (the wallet the sats actually landed in). Test on regtest with both a registered-wallet payment and a fallback-resolved payment.
  • Unit test for the fallback dispatch logic against an Account-shaped fixture + a fake get_active_machine_by_atm_pubkey_hex.
  • Settlements ingested via the fallback path render in the operator UI with the same shape as registered-wallet settlements (no special UI; the distinction is internal-audit only). Optional: a small badge / tooltip on the settlement row noting "ingested via sender-pubkey fallback" so the operator knows the routing drifted.
  • Existing happy-path settlements (registered wallet) unchanged.

Out of scope

  • The S6 / aiolabs/satmachineadmin#20 work (lnbits-side roster gating). This issue is the satmachineadmin-side complement; both can land independently. When S6 ships and disables auto-account-from-npub for bunker-registered ATMs, the fallback path here will fire less often but stays as a safety net for any future routing drift.
  • Pubkey-collision detection at machine-create time — tracked at aiolabs/satmachineadmin#32. Together with this issue they form the two layers of defence: aiolabs/satmachineadmin#32 prevents the dependency that breaks; this issue catches the symptom when the dependency does break.
  • Sweeping the auto-account wallet's balance back to the operator's main wallet. Operational concern; not a code change.
  • Operator UI to visualize "wallet drift" / "needs reconciliation" state — sufficient if the WARN log + the optional badge surface the condition.

Sequencing

Ship-able today; no upstream dependency. Half-day max.

Cross-references

  • aiolabs/satmachineadmin#20 — S6 long-term proper fix on the lnbits side.
  • aiolabs/satmachineadmin#32 — pubkey-collision detection (root-cause-side guard); pairs with this issue's symptom-side guard.
  • aiolabs/spirekeeper#8 — epic.
  • Coord-log archive/2026-05-31-pre-rotation.md 2026-05-30T21:33Z entry — the failure-mode reproducer + diagnosis.
  • crud.get_active_machine_by_atm_pubkey_hex — the lookup helper the fallback consumes (already lives there for the cassette consumer's identical sender-resolution concern).
> _Migrated from aiolabs/satmachineadmin#31 (2026-06-13). Issue numbers were reassigned in this repo; cross-refs updated._ ## Problem `tasks._handle_payment` resolves the target machine via `get_active_machine_by_wallet_id(payment.wallet_id)`. If no machine matches that wallet, the handler silently returns and the payment is never recorded as a `dca_settlements` row. This is correct for "random unrelated payments that happen to land on a wallet that isn't ours." But it's wrong for a real failure mode demonstrated end-to-end on 2026-05-30: a kind-21000 RPC from a known ATM credits the wrong wallet (because lnbits' nostr-transport auto-account-from-npub flow created an account for the ATM's own pubkey, and the invoice routed to *that* account's wallet rather than the operator's). The ATM dispensed cash on the strength of the Lightning payment notification (its own view is "invoice paid, dispense"), and satmachineadmin lost the settlement entirely. No `dca_settlements` row, no DCA distribution legs, no commission split, no super_fee, no LP payouts. Sats sit in the auto-account wallet, recoverable but unaccounted. Reproducer in coord-log `archive/2026-05-31-pre-rotation.md` (`2026-05-30T21:33Z` entry + thread). $20 cash-out from Sintra to Greg's machine; payment landed on auto-account `a94b564f8a8d4a768972ad7e2364fdb9` (pubkey = the ATM's npub) instead of Greg's registered wallet `9927f101...`. ## Why "proper fix lands in S6" isn't enough by itself `aiolabs/satmachineadmin#20` (S6 — roster-gated auto-account on the lnbits side) is the architectural fix: prevent the auto-account from being created in the first place, so the invoice routes to the operator's wallet instead. That's the right long-term answer. But: 1. **S6 is Sprint 3** per the `#8` epic + has a pending design refresh (see the `aiolabs/satmachineadmin#20` comment thread post-`dcd0874`). Realistic ETA is post-S0/S2/S7. Multiple weeks. 2. **S6 lives in lnbits**, not satmachineadmin. Until S6 lands on every host satmachineadmin runs against, the auto-account behaviour persists. 3. **Any operator account regression** (the 2026-05-30 case was a pubkey refresh that broke a pre-existing identity collision; future operations like account migration to bunker can hit similar edges) leaves the operator silently dropping settlements. A satmachineadmin-side defensive lookup is **cheap, local, additive, and fail-safe** — and crucially, makes the silent-drop failure mode loud rather than invisible. The pubkey-collision root cause is tracked separately at `aiolabs/satmachineadmin#32`; this issue addresses the symptom-side mitigation. ## Proposed fix In `tasks._handle_payment`, when `get_active_machine_by_wallet_id(payment.wallet_id)` returns None, attempt a fallback lookup: ```python machine = await get_active_machine_by_wallet_id(payment.wallet_id) if machine is None: # Fallback: route by sender pubkey for the payment-landed-on-wrong- # wallet failure mode (see issue #17). Common cause: lnbits # auto-account-from-npub created a wallet for the ATM's own npub, # routing the invoice there instead of the registered operator's # wallet. Sats actually sit on the auto-account wallet, but the # settlement is logically owned by the dca_machines row whose # machine_npub matches the inbound sender pubkey. extra = payment.extra or {} sender_pubkey = extra.get("nostr_sender_pubkey") if sender_pubkey: machine = await get_active_machine_by_atm_pubkey_hex(sender_pubkey) if machine is not None: logger.warning( f"satmachineadmin: payment landed on wallet " f"{payment.wallet_id[:12]}... which is not registered to " f"any machine, but sender pubkey {sender_pubkey[:12]}... " f"matches machine {machine.id} (wallet drift — see " f"aiolabs/spirekeeper#17)." ) if machine is None: return # truly unrelated payment; original silent skip ``` `get_active_machine_by_atm_pubkey_hex` already exists in `crud.py` (used by the cassette consumer). The lookup is O(N over active machines) which is fine for any realistic operator fleet. Once a machine is resolved via the fallback path, the rest of `_handle_payment` proceeds identically: attribution check (the existing check pairs `sender_pubkey` against `machine.machine_npub` — passes by construction here), `parse_settlement`, idempotent insert, distribution. The distribution legs draw from `payment.wallet_id` which is the auto-account wallet, so the operator's commission_split + DCA payouts come from the auto-account's balance (which has the just-credited sats). Operationally functional even though the wallet ownership is "wrong" from the operator's perspective. ## Acceptance - [ ] When a Payment arrives at a wallet that has no `dca_machines` row, but `payment.extra.nostr_sender_pubkey` matches a registered ATM's `machine_npub`, the listener resolves the machine via the fallback path and records the settlement as if the payment had landed on the registered wallet. - [ ] WARN log per fallback-resolved settlement, with both wallet IDs + the matched machine + a reference back to this issue. - [ ] Distribution legs (super_fee, operator_splits, DCA) draw from `payment.wallet_id` (the wallet the sats actually landed in). Test on regtest with both a registered-wallet payment and a fallback-resolved payment. - [ ] Unit test for the fallback dispatch logic against an Account-shaped fixture + a fake `get_active_machine_by_atm_pubkey_hex`. - [ ] Settlements ingested via the fallback path render in the operator UI with the same shape as registered-wallet settlements (no special UI; the distinction is internal-audit only). Optional: a small badge / tooltip on the settlement row noting "ingested via sender-pubkey fallback" so the operator knows the routing drifted. - [ ] Existing happy-path settlements (registered wallet) unchanged. ## Out of scope - The S6 / `aiolabs/satmachineadmin#20` work (lnbits-side roster gating). This issue is the satmachineadmin-side complement; both can land independently. When S6 ships and disables auto-account-from-npub for bunker-registered ATMs, the fallback path here will fire less often but stays as a safety net for any future routing drift. - Pubkey-collision detection at machine-create time — tracked at `aiolabs/satmachineadmin#32`. Together with this issue they form the two layers of defence: `aiolabs/satmachineadmin#32` prevents the dependency that breaks; this issue catches the symptom when the dependency does break. - Sweeping the auto-account wallet's balance back to the operator's main wallet. Operational concern; not a code change. - Operator UI to visualize "wallet drift" / "needs reconciliation" state — sufficient if the WARN log + the optional badge surface the condition. ## Sequencing Ship-able today; no upstream dependency. Half-day max. ## Cross-references - `aiolabs/satmachineadmin#20` — S6 long-term proper fix on the lnbits side. - `aiolabs/satmachineadmin#32` — pubkey-collision detection (root-cause-side guard); pairs with this issue's symptom-side guard. - `aiolabs/spirekeeper#8` — epic. - Coord-log `archive/2026-05-31-pre-rotation.md` 2026-05-30T21:33Z entry — the failure-mode reproducer + diagnosis. - `crud.get_active_machine_by_atm_pubkey_hex` — the lookup helper the fallback consumes (already lives there for the cassette consumer's identical sender-resolution concern).
Sign in to join this conversation.
No labels
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
aiolabs/spirekeeper#17
No description provided.