feat(cash-in): secure create_withdraw nostr-transport RPC (#31) #32

Merged
padreug merged 3 commits from feat/secure-cashin-rpc into main 2026-06-22 13:54:44 +00:00
Owner

Implements aiolabs/spirekeeper#31 (the operator-side half). Builds on #30 (merged).

What

A create_withdraw nostr-transport RPC so the ATM no longer supplies the withdraw amount, fee, or attribution. The ATM sends a bunker-signed kind-21000 create_withdraw with just the gross principal_sats (the hardware-attested fiat value); the handler derives everything else server-side:

  • Attribution = the verified transport sender_pubkey (never read from the body), matched to an active machine on the authenticated wallet.
  • Fee = round(principal × super_cash_in) + round(principal × operator_cash_in) — per-leg rounding so it matches parse_settlement exactly (fee_mismatch = 0).
  • Net = principal − fee → the withdraw amount the customer receives.
  • Stamps extra={source:bitspire, type:cash_in, principal_sats, fee_sats, nostr_sender_pubkey:<verified>, nostr_event_id}.

Customer claims the NET link → payout carries the stamped extra (aiolabs/withdraw#3) → _handle_payment records the cash_in settlement (#30) with cryptographic attribution. This closes the vector where lnurlw_create_link let the ATM set min/max_withdrawable + extra freely (gross amount, no fee, forgeable attribution).

Why it's safe to register this way

Extensions register transport RPCs directly — withdraw/__init__.py:56 already does register_rpc("lnurlw_create_link", …, AUTH_WALLET). We mirror it: register_rpc("create_withdraw", handle_create_withdraw, AUTH_WALLET) in spirekeeper_start, soft-failing on lnbits without register_rpc.

Verification

  • Reload logs spirekeeper: registered nostr-transport RPC 'create_withdraw'.
  • ruff clean; black formatted.
  • Downstream (stamped link → redeem → cash_in settlement + super-fee payout) already proven during the withdraw#3 / #30 validation; this PR moves the stamping from a hand-rolled link to the verified RPC handler.

Wire schema

Pinned in #31 (request principal_sats + context; amount/fee/attribution server-derived; response {link_id, lnurl, net_sats, principal_sats, fee_sats}). bitspire is building LnbitsClient.createWithdraw against it.

Follow-ups (not in this PR)

  • super_config.max_cash_in_sats config field + UI — the handler already enforces it defensively via getattr (None = no cap).
  • Joint end-to-end test with bitspire's createWithdraw consumer over a real signed RPC.

🤖 Generated with Claude Code

Implements aiolabs/spirekeeper#31 (the operator-side half). Builds on #30 (merged). ## What A `create_withdraw` nostr-transport RPC so the ATM no longer supplies the withdraw amount, fee, or attribution. The ATM sends a **bunker-signed kind-21000 `create_withdraw`** with just the gross `principal_sats` (the hardware-attested fiat value); the handler derives everything else **server-side**: - **Attribution** = the *verified* transport `sender_pubkey` (never read from the body), matched to an active machine on the authenticated wallet. - **Fee** = `round(principal × super_cash_in) + round(principal × operator_cash_in)` — per-leg rounding so it matches `parse_settlement` exactly (`fee_mismatch = 0`). - **Net** = `principal − fee` → the withdraw amount the customer receives. - Stamps `extra={source:bitspire, type:cash_in, principal_sats, fee_sats, nostr_sender_pubkey:<verified>, nostr_event_id}`. Customer claims the NET link → payout carries the stamped extra (aiolabs/withdraw#3) → `_handle_payment` records the `cash_in` settlement (#30) with cryptographic attribution. This closes the vector where `lnurlw_create_link` let the ATM set `min/max_withdrawable` + `extra` freely (gross amount, no fee, forgeable attribution). ## Why it's safe to register this way Extensions register transport RPCs directly — `withdraw/__init__.py:56` already does `register_rpc("lnurlw_create_link", …, AUTH_WALLET)`. We mirror it: `register_rpc("create_withdraw", handle_create_withdraw, AUTH_WALLET)` in `spirekeeper_start`, soft-failing on lnbits without `register_rpc`. ## Verification - Reload logs `spirekeeper: registered nostr-transport RPC 'create_withdraw'`. - `ruff` clean; `black` formatted. - Downstream (stamped link → redeem → `cash_in` settlement + super-fee payout) already proven during the withdraw#3 / #30 validation; this PR moves the stamping from a hand-rolled link to the verified RPC handler. ## Wire schema Pinned in #31 (request `principal_sats` + context; amount/fee/attribution server-derived; response `{link_id, lnurl, net_sats, principal_sats, fee_sats}`). bitspire is building `LnbitsClient.createWithdraw` against it. ## Follow-ups (not in this PR) - `super_config.max_cash_in_sats` config field + UI — the handler already enforces it defensively via `getattr` (None = no cap). - Joint end-to-end test with bitspire's `createWithdraw` consumer over a real signed RPC. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(cash-in): secure create_withdraw nostr-transport RPC (#31)
Some checks failed
ci.yml / feat(cash-in): secure `create_withdraw` nostr-transport RPC (#31) (pull_request) Failing after 0s
607b71e796
Adds a server-side cash-in RPC so the ATM no longer supplies the withdraw
amount, fee, or attribution. The ATM sends a bunker-signed kind-21000
`create_withdraw` with just the gross `principal_sats` (the hardware-
attested fiat value); the handler derives everything else SERVER-SIDE:

- attribution = the VERIFIED transport `sender_pubkey` (never read from the
  body), matched to an active machine on the authenticated wallet;
- fee = round(principal × super_cash_in) + round(principal × operator_cash_in),
  per-leg rounding so it matches parse_settlement exactly (fee_mismatch=0);
- net = principal − fee → the withdraw amount the customer receives;
- stamps `extra={source:bitspire, type:cash_in, principal_sats, fee_sats,
  nostr_sender_pubkey:<verified>, nostr_event_id}` onto the link.

The customer claims the NET link; the payout carries the stamped extra
(aiolabs/withdraw#3) and `_handle_payment` records the cash_in settlement
(spirekeeper#30) with cryptographic attribution — closing the vector where
`lnurlw_create_link` let the ATM set amount/fee/attribution freely.

Registered via `register_rpc("create_withdraw", …, AUTH_WALLET)` (extensions
register RPCs directly — withdraw already does). Soft-fails on lnbits without
`register_rpc`. Per-tx cap reads `super_config.max_cash_in_sats` defensively
(getattr) — the config field/UI is a fast-follow.

Wire schema pinned in #31. Depends on #30 (consumer-side settlement fix).
feat(cash-in): super_config.max_cash_in_sats per-tx cap + UI (#31)
Some checks failed
ci.yml / feat(cash-in): super_config.max_cash_in_sats per-tx cap + UI (#31) (pull_request) Failing after 0s
9abf695fd5
Wires the server-side per-transaction cash-in ceiling the `create_withdraw`
handler already enforces (it read the value defensively via getattr; this
makes it a first-class config field).

- migrations.py m012: ADD COLUMN super_config.max_cash_in_sats INTEGER (NULL
  = no cap).
- models.py: SuperConfig.max_cash_in_sats + UpdateSuperConfigData field with a
  >= 0 validator.
- super-fee dialog: a "Max cash-in per transaction (sats)" input; blank sends
  null (the PUT skips null, preserving the current value — set 0 to reject
  every cash-in). crud `update_super_config` and the PUT endpoint flow the
  field through automatically (dynamic dict update; check_super_user gated).

Why a sats cap and not the bunker ACL: the ACL / usage caps (#28) gate call
*rate*, not *sats*, and `principal_sats` is necessarily ATM-attested — so a
single in-rate call could request an arbitrarily large payout. This bounds a
compromised/buggy machine to one capped transaction.

Verified on the dev stack: m012 runs, the model round-trips the column
(GET returns the set value), and a negative value is rejected.
fix(cash-in): return bech32 LNURL, not the raw URL
Some checks failed
ci.yml / fix(cash-in): return bech32 LNURL, not the raw URL (pull_request) Failing after 0s
f67cb49bc3
`Lnurl.__str__` is the underlying URL, so `str(lnurl)` returned
`http://<baseurl>/withdraw/...` instead of the bech32 `LNURL1…` — wallets
need the encoded LNURL-withdraw (lud01). Use `str(lnurl.bech32)` and add
`lnurl_url` (the raw URL) alongside, mirroring withdraw's _populate_lnurl
field convention. (Note: the encoded URL still derives from LNBITS_BASEURL —
that must be an externally reachable https URL for a real wallet to claim.)
padreug deleted branch feat/secure-cashin-rpc 2026-06-22 13:54:44 +00:00
Sign in to join this conversation.
No reviewers
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!32
No description provided.