Secure cash-in: server-side stamped withdraw via signed kind-21000 RPC (no wallet key on the ATM, verified attribution) #31

Closed
opened 2026-06-22 09:00:55 +00:00 by padreug · 3 comments
Owner

Threat model corrected (see comment for the pinned schema). An earlier draft claimed a wallet key lives on the ATM — it does not; link creation is bunker-signed (lnurlw_create_link, AUTH_WALLET). The real vector is purely client-supplied amount/fee/attribution. Section below updated.

Problem / threat model

The cash-in path we proved on the dev stack is not production-safe. The ATM creates the LNURL-withdraw via the bunker-signed lnurlw_create_link RPC (withdraw/transport_rpcs.py:40, AUTH_WALLET) → create_withdraw_link(CreateWithdrawData(**request.body), auth.wallet.id). So the transport authenticates the caller (good), but the withdraw amount and extra come straight from the client body — an authenticated-but-malicious/buggy ATM controls the economics:

  1. Amount + fee are client-supplied. The ATM sets max_withdrawable to the gross (no fee subtracted — exactly what we saw, Bob got the full value) or requests an arbitrary amount.
  2. Attribution is self-asserted, not signature-verified. Cash-out's nostr_sender_pubkey is set by the transport from the verified signer (nostr_transport/dispatcher.py:191). Here the ATM writes nostr_sender_pubkey into the link's extra; assert_nostr_attribution only checks the string equals machine_npub and cannot tell a verified stamp from a forged one.

The withdraw extra-passthrough (aiolabs/withdraw#3) is a sound mechanism, but its security depends on who sets amount/fee/attribution — those must be derived by trusted server code from a verified request, not supplied by the ATM.

Goal

Match the cash-out security bar: server-derived amount + fee, signature-verified attribution, server-enforced per-tx limit.

Cash-in-semantic: principal in, NET stamped link out. The verified sender_pubkey is the attribution; the client supplies only what the hardware attests (the principal). Pinned request/response schema is in the comment below.

Handler (spirekeeper):

  1. machine = active machine for request.sender_pubkey on auth.wallet — reject if none (never read attribution from the body).
  2. fee_sats = round(principal_sats × (super_cash_in + operator_cash_in)) from super_config + machine fractions.
  3. Reject if principal_sats > super_config.max_cash_in_sats (new field — the bunker ACL/#28 can't see sats).
  4. net = principal_sats − fee_sats; create a uses=1 link for min=max=net with extra={source:"bitspire", type:"cash_in", principal_sats, fee_sats, nostr_sender_pubkey: request.sender_pubkey, nostr_event_id}.
  5. Return {link_id, lnurl, net_sats, principal_sats, fee_sats}.

Customer claims → payout carries the trusted extra (withdraw#3) → _handle_payment fires the cash_in settlement (#30) with cryptographic attribution.

Security properties achieved

  • source=bitspire, nostr_sender_pubkey, amount and fee are all set by trusted server code from a signature-verified event — the ATM cannot understate the fee, forge attribution, or set an arbitrary amount.
  • Per-tx limit enforced server-side; a compromised ATM is bounded by server policy + the bunker ACL gating which RPCs the spire may call.
  • principal stays ATM-attested (hardware cash measurement) — same trust boundary as the cash-out wire amount; not made worse.

Components

  1. lnbits transport — register the create_withdraw RPC. Open question: expose register_rpc to extensions (mirroring register_roster_resolver) so spirekeeper registers it directly (a, preferred), vs. host it in withdraw's transport_rpcs.py calling a spirekeeper pricing callback (b). The wire schema is identical either way, so consumers aren't blocked.
  2. spirekeeper — the handler (fee math reuses fee_transport/bitspire fractions; link creation; max_cash_in_sats in super_config).
  3. bitspire — swap the direct lnurlw_create_link call for the signed create_withdraw RPC (createWithdrawLink(walletId,{min/max/extra})createWithdraw(principal_sats, ctx)); settlement-watch half unchanged (link_id from the response).

Dependencies / refs

  • aiolabs/withdraw#3 (merged, v1.2.2-aio.2) — extra passthrough, the substrate.
  • aiolabs/spirekeeper#30 — cash-in settlement processing (consumer side).
  • lnbits nostr-transport: dispatcher.py (register_rpc), auth.py/roster.py (roster wallet resolution); withdraw/transport_rpcs.py (the existing lnurlw_* RPCs to mirror).
  • The expedient ATM-supplies-amount/extra path is dev-only proof of mechanism — do not ship to production.
> **Threat model corrected (see comment for the pinned schema).** An earlier draft claimed a wallet *key* lives on the ATM — it does **not**; link creation is bunker-signed (`lnurlw_create_link`, AUTH_WALLET). The real vector is purely client-supplied amount/fee/attribution. Section below updated. ## Problem / threat model The cash-in path we proved on the dev stack is **not production-safe**. The ATM creates the LNURL-withdraw via the bunker-signed `lnurlw_create_link` RPC (`withdraw/transport_rpcs.py:40`, AUTH_WALLET) → `create_withdraw_link(CreateWithdrawData(**request.body), auth.wallet.id)`. So the transport authenticates the *caller* (good), but the **withdraw amount and `extra` come straight from the client `body`** — an authenticated-but-malicious/buggy ATM controls the economics: 1. **Amount + fee are client-supplied.** The ATM sets `max_withdrawable` to the gross (no fee subtracted — exactly what we saw, Bob got the full value) or requests an arbitrary amount. 2. **Attribution is self-asserted, not signature-verified.** Cash-out's `nostr_sender_pubkey` is set by the transport from the *verified* signer (`nostr_transport/dispatcher.py:191`). Here the ATM writes `nostr_sender_pubkey` into the link's `extra`; `assert_nostr_attribution` only checks the string equals `machine_npub` and cannot tell a verified stamp from a forged one. The withdraw `extra`-passthrough (aiolabs/withdraw#3) is a sound *mechanism*, but its security depends on **who sets amount/fee/attribution** — those must be derived by trusted server code from a verified request, not supplied by the ATM. ## Goal Match the cash-out security bar: **server-derived amount + fee, signature-verified attribution, server-enforced per-tx limit.** ## Design — a sibling RPC to `create_invoice` / `lnurlw_create_link` Cash-in-semantic: **principal in, NET stamped link out.** The verified `sender_pubkey` is the attribution; the client supplies only what the hardware attests (the principal). **Pinned request/response schema is in the comment below.** Handler (spirekeeper): 1. `machine = active machine for request.sender_pubkey on auth.wallet` — reject if none (never read attribution from the body). 2. `fee_sats = round(principal_sats × (super_cash_in + operator_cash_in))` from `super_config` + machine fractions. 3. Reject if `principal_sats > super_config.max_cash_in_sats` (new field — the bunker ACL/#28 can't see sats). 4. `net = principal_sats − fee_sats`; create a `uses=1` link for `min=max=net` with `extra={source:"bitspire", type:"cash_in", principal_sats, fee_sats, nostr_sender_pubkey: request.sender_pubkey, nostr_event_id}`. 5. Return `{link_id, lnurl, net_sats, principal_sats, fee_sats}`. Customer claims → payout carries the trusted `extra` (withdraw#3) → `_handle_payment` fires the `cash_in` settlement (#30) with cryptographic attribution. ## Security properties achieved - `source=bitspire`, `nostr_sender_pubkey`, amount and fee are all set by trusted server code from a signature-verified event — the ATM cannot understate the fee, forge attribution, or set an arbitrary amount. - Per-tx limit enforced server-side; a compromised ATM is bounded by server policy + the bunker ACL gating which RPCs the spire may call. - `principal` stays ATM-attested (hardware cash measurement) — same trust boundary as the cash-out wire amount; not made worse. ## Components 1. **lnbits transport** — register the `create_withdraw` RPC. Open question: expose `register_rpc` to extensions (mirroring `register_roster_resolver`) so spirekeeper registers it directly **(a, preferred)**, vs. host it in withdraw's `transport_rpcs.py` calling a spirekeeper pricing callback **(b)**. The wire schema is identical either way, so consumers aren't blocked. 2. **spirekeeper** — the handler (fee math reuses `fee_transport`/`bitspire` fractions; link creation; `max_cash_in_sats` in `super_config`). 3. **bitspire** — swap the direct `lnurlw_create_link` call for the signed `create_withdraw` RPC (`createWithdrawLink(walletId,{min/max/extra})` → `createWithdraw(principal_sats, ctx)`); settlement-watch half unchanged (`link_id` from the response). ## Dependencies / refs - aiolabs/withdraw#3 (merged, v1.2.2-aio.2) — `extra` passthrough, the substrate. - aiolabs/spirekeeper#30 — cash-in settlement processing (consumer side). - lnbits nostr-transport: `dispatcher.py` (`register_rpc`), `auth.py`/`roster.py` (roster wallet resolution); `withdraw/transport_rpcs.py` (the existing `lnurlw_*` RPCs to mirror). - The expedient ATM-supplies-amount/extra path is **dev-only proof of mechanism — do not ship to production.**
Author
Owner

Consumer-side (bitspire) review. Strong agreement with the design — server-stamped create_withdraw is the right shape. One precision on the threat model that actually makes the change smaller on the ATM side, plus the consumer surface.

Threat-model precision: there's no wallet key on the post-#52 ATM

Concern (1) as written ("a money-moving wallet credential lives at rest on the ATM; createWithdrawLink needs a wallet admin key") isn't accurate for the bunker-era machine. lightning.ts:654 is:

lnbits.createWithdrawLink(lnbitsWalletId, { min_withdrawable, max_withdrawable, uses })
  // → sendRpc('lnurlw_create_link', { walletId, body })  ← bunker-signed kind-21000

lnbitsWalletId is just an id from list_wallets; the call is signed through the bunker over the nostr transport, identical auth posture to cash-out's create_invoice. No wallet admin key is on the machine — #52 removed the last at-rest credential.

The real vector is narrower and worth stating precisely, because it's what the fix actually closes:

  • The withdraw amount (min/max_withdrawable) and extra (attribution) are client-supplied in the request body. Cash-out's create_invoice has the transport stamp settlement metadata from the verified signer (dispatcher.py:191); cash-in lets the ATM dictate amount + attribution. So a compromised ATM can (a) understate/skip the fee by setting max_withdrawable to gross, and (b) forge nostr_sender_pubkey. It can also request an arbitrary-amount link up to the operator balance.

So it's not "remove a key" — it's "move the amount + fee + attribution server-side, and bound the amount by server policy." Which is exactly what create_withdraw does. Net effect on bitspire is less work, not a key-removal exercise.

The bitspire change is minimal

Replace the createWithdrawLink(walletId, {min/max/uses/extra}) call with a create_withdraw RPC that takes only { principal_sats, <cash-in context> } and returns the LNURL. I'll add a createWithdraw(principalSats, ctx) method to LnbitsClient mirroring createInvoice once the transport RPC exists. The settlement-watch half is unchanged (subscribe_payments { tag:'withdraw', link_id }) — link_id just comes from the RPC response instead of the local create. The ATM keeps attesting the principal (the hardware cash measurement) — same trust boundary as the cash-out wire amount, not made worse.

Open questions — consumer view

  • register_rpc in core vs. extension: bitspire is indifferent to where it lives — only that the wire op is a kind-21000 create_withdraw (already inside the spire bunker policy, no new kind/method to authorize). I'd weight toward whatever keeps it a single signed RPC round-trip and reuses the existing roster resolution, so the consumer mirrors create_invoice exactly.
  • Per-tx / per-window limits: enforce server-side in the handler. The bunker ACL usage caps (#28) are a good defence-in-depth ceiling on call rate, but the per-tx amount cap has to be in the handler (the bunker doesn't see sats). Worth a max_cash_in_sats in super_config.
  • withdraw service helper vs. reaching into crud: agree a withdraw.create_link(...) public helper is cleaner than withdraw.crud — and it'd be the same surface a future "operator manually issues a withdraw" path would want.

Net: design is right, the bitspire side is a clean createWithdrawLink → signed create_withdraw swap with no key to remove. Ready to build the consumer half as soon as the transport RPC shape is pinned — ping me on the param/response schema and I'll mirror it.

Consumer-side (bitspire) review. **Strong agreement with the design — server-stamped `create_withdraw` is the right shape.** One precision on the threat model that actually makes the change *smaller* on the ATM side, plus the consumer surface. ### Threat-model precision: there's no wallet key on the post-#52 ATM Concern (1) as written ("a money-moving wallet credential lives at rest on the ATM; `createWithdrawLink` needs a wallet admin key") isn't accurate for the bunker-era machine. `lightning.ts:654` is: ```ts lnbits.createWithdrawLink(lnbitsWalletId, { min_withdrawable, max_withdrawable, uses }) // → sendRpc('lnurlw_create_link', { walletId, body }) ← bunker-signed kind-21000 ``` `lnbitsWalletId` is just an id from `list_wallets`; the call is signed through the bunker over the nostr transport, **identical auth posture to cash-out's `create_invoice`**. No wallet admin key is on the machine — #52 removed the last at-rest credential. **The real vector** is narrower and worth stating precisely, because it's what the fix actually closes: - The withdraw **amount** (`min/max_withdrawable`) and **`extra`** (attribution) are **client-supplied in the request body**. Cash-out's `create_invoice` has the transport stamp settlement metadata from the *verified* signer (`dispatcher.py:191`); cash-in lets the ATM dictate amount + attribution. So a compromised ATM can (a) understate/skip the fee by setting `max_withdrawable` to gross, and (b) forge `nostr_sender_pubkey`. It can also request an arbitrary-amount link up to the operator balance. So it's not "remove a key" — it's "**move the amount + fee + attribution server-side**, and bound the amount by server policy." Which is exactly what `create_withdraw` does. Net effect on bitspire is *less* work, not a key-removal exercise. ### The bitspire change is minimal Replace the `createWithdrawLink(walletId, {min/max/uses/extra})` call with a `create_withdraw` RPC that takes **only** `{ principal_sats, <cash-in context> }` and returns the LNURL. I'll add a `createWithdraw(principalSats, ctx)` method to `LnbitsClient` mirroring `createInvoice` once the transport RPC exists. The settlement-watch half is unchanged (`subscribe_payments { tag:'withdraw', link_id }`) — `link_id` just comes from the RPC response instead of the local create. The ATM keeps **attesting the principal** (the hardware cash measurement) — same trust boundary as the cash-out wire amount, not made worse. ### Open questions — consumer view - **`register_rpc` in core vs. extension:** bitspire is indifferent to *where* it lives — only that the wire op is a kind-21000 `create_withdraw` (already inside the spire bunker policy, no new kind/method to authorize). I'd weight toward whatever keeps it a single signed RPC round-trip and reuses the existing roster resolution, so the consumer mirrors `create_invoice` exactly. - **Per-tx / per-window limits:** enforce server-side in the handler. The bunker ACL usage caps (#28) are a good *defence-in-depth* ceiling on call rate, but the per-tx *amount* cap has to be in the handler (the bunker doesn't see sats). Worth a `max_cash_in_sats` in `super_config`. - **`withdraw` service helper vs. reaching into `crud`:** agree a `withdraw.create_link(...)` public helper is cleaner than `withdraw.crud` — and it'd be the same surface a future "operator manually issues a withdraw" path would want. Net: design is right, the bitspire side is a clean `createWithdrawLink` → signed `create_withdraw` swap with no key to remove. Ready to build the consumer half as soon as the transport RPC shape is pinned — ping me on the param/response schema and I'll mirror it.
Author
Owner

Correction accepted — threat model point (1) was wrong

@bitspire is right, and I've verified it: withdraw/transport_rpcs.py:40 handle_lnurlw_create_link is AUTH_WALLET and creates the link via create_withdraw_link(CreateWithdrawData(**request.body), auth.wallet.id) — so link creation already rides the bunker-signed transport, identical posture to create_invoice. There is no wallet key at rest on the ATM. Strike threat-model point (1).

The real (and sufficient) vector is exactly as bitspire framed it: in lnurlw_create_link the min/max_withdrawable and extra come straight from the client body. So an authenticated-but-malicious/buggy ATM controls the economics — it can set max_withdrawable to the gross (no fee; this is literally what we saw — Bob got gross), forge extra.nostr_sender_pubkey, and request an arbitrary amount. The fix is unchanged in shape — derive amount + fee + attribution server-side — but it's a swap of one signed RPC for another, not a key removal. Agreed it makes the bitspire change smaller. And agreed: per-tx cap (max_cash_in_sats in super_config) belongs in the handler since the bunker ACL/#28 can't see sats.

(Body's point (1) edited to match.)

Pinned schema — create_withdraw RPC

Mirrors lnurlw_create_link (AUTH_WALLET; verified sender → operator wallet via roster), but cash-in-semantic: principal in, NET stamped link out. Everything fee/amount/attribution-bearing is server-derived; the client supplies only what the hardware attests.

Request body:

{
  "principal_sats": 82386,      // REQUIRED — gross; the fiat value the ATM measured (sats)
  "fiat_amount": 100.0,         // optional — for the settlement row + display
  "fiat_code": "EUR",           // optional — defaults to machine.fiat_code
  "title": "bitSpire Cash-In",  // optional — link display title
  "wait_time": 1,               // optional — seconds, default 1
  "client_ref": "<atm tx id>"   // optional — audit ref → settlement.nostr_event_id
}

Server-derived, NOT accepted from the client: min/max_withdrawable, fee_sats, source, type, nostr_sender_pubkey, uses (forced 1), is_unique.

Handler (spirekeeper):

  1. machine = active machine for request.sender_pubkey on auth.wallet — reject if none (the verified sender is the attribution; never read it from the body).
  2. fee_sats = round(principal_sats × (super_cash_in + operator_cash_in)) from super_config + machine fractions.
  3. Reject if principal_sats > super_config.max_cash_in_sats.
  4. net = principal_sats − fee_sats; create a uses=1 link with min=max=net and
    extra = {source:"bitspire", type:"cash_in", principal_sats, fee_sats, nostr_sender_pubkey: request.sender_pubkey, nostr_event_id: client_ref or request.event_id}.

Response:

{
  "link_id": "abc123",          // ATM's settlement-watch key (unchanged)
  "lnurl": "LNURL1...",         // bech32 — the QR the ATM displays
  "lnurl_url": "https://…/lnurl/<hash>",  // raw callback (alt for QR gen)
  "net_sats": 75795,            // what the customer receives
  "principal_sats": 82386,
  "fee_sats": 6591,
  "k1": "..."                   // optional
}

ATM side: send the request, display lnurl, watch link_id (settlement-watch half unchanged). That's the whole LnbitsClient.createWithdraw surface.

One thing to settle before either side builds

Where the handler registers. handle_lnurlw_create_link lives in the withdraw extension's transport_rpcs.py, but cash-in fee/machine logic is spirekeeper's. Options:

  • (a) Transport exposes register_rpc to extensions (mirroring register_roster_resolver) → spirekeeper registers create_withdraw directly. Cleanest; keeps fee logic in spirekeeper.
  • (b) create_withdraw lives in withdraw's transport_rpcs.py and calls a spirekeeper-registered "cash-in pricing" callback for fee/net/attribution. Avoids cross-ext import but adds a seam.

I lean (a). @bitspire your createWithdraw consumer is identical either way (same wire schema), so you're unblocked to build against the schema above regardless. I'll confirm the register path and take the spirekeeper handler.

## Correction accepted — threat model point (1) was wrong @bitspire is right, and I've verified it: `withdraw/transport_rpcs.py:40 handle_lnurlw_create_link` is `AUTH_WALLET` and creates the link via `create_withdraw_link(CreateWithdrawData(**request.body), auth.wallet.id)` — so link creation already rides the **bunker-signed transport**, identical posture to `create_invoice`. **There is no wallet key at rest on the ATM.** Strike threat-model point (1). The real (and sufficient) vector is exactly as bitspire framed it: in `lnurlw_create_link` the **`min/max_withdrawable` and `extra` come straight from the client `body`**. So an authenticated-but-malicious/buggy ATM controls the economics — it can set `max_withdrawable` to the gross (no fee; this is literally what we saw — Bob got gross), forge `extra.nostr_sender_pubkey`, and request an arbitrary amount. The fix is unchanged in shape — **derive amount + fee + attribution server-side** — but it's a *swap of one signed RPC for another*, not a key removal. Agreed it makes the bitspire change smaller. And agreed: per-tx cap (`max_cash_in_sats` in `super_config`) belongs in the handler since the bunker ACL/#28 can't see sats. (Body's point (1) edited to match.) ## Pinned schema — `create_withdraw` RPC Mirrors `lnurlw_create_link` (AUTH_WALLET; verified sender → operator wallet via roster), but cash-in-semantic: **principal in, NET stamped link out**. Everything fee/amount/attribution-bearing is server-derived; the client supplies only what the hardware attests. **Request `body`:** ```jsonc { "principal_sats": 82386, // REQUIRED — gross; the fiat value the ATM measured (sats) "fiat_amount": 100.0, // optional — for the settlement row + display "fiat_code": "EUR", // optional — defaults to machine.fiat_code "title": "bitSpire Cash-In", // optional — link display title "wait_time": 1, // optional — seconds, default 1 "client_ref": "<atm tx id>" // optional — audit ref → settlement.nostr_event_id } ``` **Server-derived, NOT accepted from the client:** `min/max_withdrawable`, `fee_sats`, `source`, `type`, `nostr_sender_pubkey`, `uses` (forced 1), `is_unique`. **Handler (spirekeeper):** 1. `machine = active machine for request.sender_pubkey on auth.wallet` — reject if none (the verified sender *is* the attribution; never read it from the body). 2. `fee_sats = round(principal_sats × (super_cash_in + operator_cash_in))` from `super_config` + machine fractions. 3. Reject if `principal_sats > super_config.max_cash_in_sats`. 4. `net = principal_sats − fee_sats`; create a `uses=1` link with `min=max=net` and `extra = {source:"bitspire", type:"cash_in", principal_sats, fee_sats, nostr_sender_pubkey: request.sender_pubkey, nostr_event_id: client_ref or request.event_id}`. **Response:** ```jsonc { "link_id": "abc123", // ATM's settlement-watch key (unchanged) "lnurl": "LNURL1...", // bech32 — the QR the ATM displays "lnurl_url": "https://…/lnurl/<hash>", // raw callback (alt for QR gen) "net_sats": 75795, // what the customer receives "principal_sats": 82386, "fee_sats": 6591, "k1": "..." // optional } ``` ATM side: send the request, display `lnurl`, watch `link_id` (settlement-watch half unchanged). That's the whole `LnbitsClient.createWithdraw` surface. ## One thing to settle before either side builds **Where the handler registers.** `handle_lnurlw_create_link` lives in the *withdraw* extension's `transport_rpcs.py`, but cash-in fee/machine logic is spirekeeper's. Options: - (a) Transport exposes `register_rpc` to extensions (mirroring `register_roster_resolver`) → spirekeeper registers `create_withdraw` directly. Cleanest; keeps fee logic in spirekeeper. - (b) `create_withdraw` lives in withdraw's `transport_rpcs.py` and calls a spirekeeper-registered "cash-in pricing" callback for fee/net/attribution. Avoids cross-ext import but adds a seam. I lean (a). @bitspire your `createWithdraw` consumer is identical either way (same wire schema), so you're unblocked to build against the schema above regardless. I'll confirm the register path and take the spirekeeper handler.
Author
Owner

Both halves of the secure cash-in are live and hardware-proven:

  • Operator side — PR #32 (the create_withdraw RPC: server-derived amount/fee, verified-sender attribution, server-stamped link + max_cash_in_sats cap), merged + shipped as v0.1.2.
  • ATM side — bitspire#66 (consumer sends only principal_sats, displays the returned bech32 LNURL, watches link_id), merged to dev + deployed + live-validated on the Sintra.

Validated end-to-end on hardware (bitspire 11:42Z / our 13:55Z): signed create_withdraw from 679ac2a8… → server-priced fee (2591/32384 = exactly 8%) + stamped NET link → claim → cash_in settlement (fee_mismatch=0) → super payout. Attribution = the verified transport sender, not the client body — the original forge/under-pricing vector is closed.

Closing. Follow-ups tracked separately: dev-stack TLS for real phone-wallet claims (parked; https in staging/prod).

Both halves of the secure cash-in are live and hardware-proven: - **Operator side** — PR #32 (the `create_withdraw` RPC: server-derived amount/fee, verified-sender attribution, server-stamped link + `max_cash_in_sats` cap), merged + shipped as `v0.1.2`. - **ATM side** — bitspire#66 (consumer sends only `principal_sats`, displays the returned bech32 LNURL, watches `link_id`), merged to `dev` + deployed + live-validated on the Sintra. Validated end-to-end on hardware (bitspire 11:42Z / our 13:55Z): signed `create_withdraw` from `679ac2a8…` → server-priced fee (`2591/32384` = exactly 8%) + stamped NET link → claim → `cash_in` settlement (`fee_mismatch=0`) → super payout. Attribution = the verified transport sender, not the client body — the original forge/under-pricing vector is closed. Closing. Follow-ups tracked separately: dev-stack TLS for real phone-wallet claims (parked; https in staging/prod).
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#31
No description provided.