feat(machine): secure cash-in via server-stamped create_withdraw RPC
Replaces the cash-in LNURL-withdraw creation with the secure create_withdraw
RPC (aiolabs/spirekeeper#31/#32). The ATM now sends only the hardware-attested
gross principal_sats; the operator side verifies the signer, derives fee + NET,
and stamps the link's attribution (source/nostr_sender_pubkey) from the VERIFIED
sender. Closes the dev-stack weakness where the ATM set the withdraw amount +
extra itself (could understate the fee / forge attribution).
- LnbitsClient.createWithdraw(walletId, {principal_sats, fiat_amount?, fiat_code?,
title?, wait_time?, client_ref?}) -> {link_id, lnurl, net_sats, principal_sats,
fee_sats}. Non-idempotent (mints a link) -> not retry-wrapped.
- lightning.ts generateLnurlWithdraw: createWithdrawLink -> createWithdraw; the
ATM no longer computes amount/fee/extra. LNURL-session map re-keyed on link_id
(the secure response carries no unique_hash); settlement-watch half unchanged
(subscribe_payments tag:'withdraw', link_id).
Server RPC is live on the dev stack (spirekeeper#32 registered create_withdraw),
so this is ready for the joint cash-in test. typecheck 12/12, full suite + prod
build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f4e7dcc99e
commit
9c74a28a06
4 changed files with 96 additions and 43 deletions
|
|
@ -109,33 +109,27 @@ const SESSION_SAFETY_TIMEOUT_MS = 15 * 60 * 1000
|
|||
/** Active LNURL-withdraw session */
|
||||
interface LnurlSession {
|
||||
sessionId: string
|
||||
/** Link ID for management operations (delete/update) */
|
||||
/** Link ID — the management + settlement-watch key (delete/subscribe). */
|
||||
linkId: string
|
||||
uniqueHash: string
|
||||
satsAmount: number
|
||||
status: 'active' | 'claimed' | 'expired'
|
||||
createdAt: number
|
||||
cleanup?: () => void
|
||||
}
|
||||
|
||||
/** Map of uniqueHash -> LNURL session data */
|
||||
/** Map of linkId -> LNURL session data. Keyed on link_id since the secure
|
||||
* `create_withdraw` response (spirekeeper#31) carries no `unique_hash`. */
|
||||
const lnurlSessions = new Map<string, LnurlSession>()
|
||||
|
||||
/**
|
||||
* Register a new LNURL-withdraw session
|
||||
* Register a new LNURL-withdraw session, keyed by linkId.
|
||||
*/
|
||||
function registerLnurlSession(
|
||||
sessionId: string,
|
||||
linkId: string,
|
||||
uniqueHash: string,
|
||||
satsAmount: number,
|
||||
): void {
|
||||
console.log('[LNURL Session] Registering:', uniqueHash, 'for', satsAmount, 'sats')
|
||||
function registerLnurlSession(sessionId: string, linkId: string, satsAmount: number): void {
|
||||
console.log('[LNURL Session] Registering:', linkId, 'for', satsAmount, 'sats')
|
||||
|
||||
lnurlSessions.set(uniqueHash, {
|
||||
lnurlSessions.set(linkId, {
|
||||
sessionId,
|
||||
linkId,
|
||||
uniqueHash,
|
||||
satsAmount,
|
||||
status: 'active',
|
||||
createdAt: Date.now(),
|
||||
|
|
@ -143,10 +137,10 @@ function registerLnurlSession(
|
|||
|
||||
// Safety timeout — normally cleaned up by state machine on idle transition.
|
||||
setTimeout(() => {
|
||||
const session = lnurlSessions.get(uniqueHash)
|
||||
const session = lnurlSessions.get(linkId)
|
||||
if (session && session.status === 'active') {
|
||||
console.warn('[LNURL Session] Safety timeout reached, expiring:', uniqueHash)
|
||||
expireLnurlSession(uniqueHash)
|
||||
console.warn('[LNURL Session] Safety timeout reached, expiring:', linkId)
|
||||
expireLnurlSession(linkId)
|
||||
}
|
||||
}, SESSION_SAFETY_TIMEOUT_MS)
|
||||
}
|
||||
|
|
@ -154,23 +148,23 @@ function registerLnurlSession(
|
|||
/** Invalidate an active LNURL session by cash-in sessionId. The session's
|
||||
* cleanup closure unsubscribes from LNbits and deletes the link. */
|
||||
function invalidateLnurlSessionBySessionId(sessionId: string): void {
|
||||
for (const [hash, session] of lnurlSessions.entries()) {
|
||||
for (const [linkId, session] of lnurlSessions.entries()) {
|
||||
if (session.sessionId === sessionId && session.status === 'active') {
|
||||
console.log('[LNURL Session] Invalidating previous session:', hash)
|
||||
expireLnurlSession(hash)
|
||||
console.log('[LNURL Session] Invalidating previous session:', linkId)
|
||||
expireLnurlSession(linkId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Expire a single LNURL session via its cleanup closure. */
|
||||
function expireLnurlSession(uniqueHash: string): void {
|
||||
const session = lnurlSessions.get(uniqueHash)
|
||||
function expireLnurlSession(linkId: string): void {
|
||||
const session = lnurlSessions.get(linkId)
|
||||
if (!session || session.status !== 'active') return
|
||||
|
||||
console.log('[LNURL Session] Expiring:', uniqueHash)
|
||||
console.log('[LNURL Session] Expiring:', linkId)
|
||||
session.status = 'expired'
|
||||
if (session.cleanup) session.cleanup()
|
||||
setTimeout(() => lnurlSessions.delete(uniqueHash), 60000)
|
||||
setTimeout(() => lnurlSessions.delete(linkId), 60000)
|
||||
}
|
||||
|
||||
let _lnbitsRef: LnbitsClient | null = null
|
||||
|
|
@ -651,50 +645,53 @@ function createATMServices(
|
|||
invalidateLnurlSessionBySessionId(context.cashInSessionId)
|
||||
}
|
||||
|
||||
const link = await lnbits.createWithdrawLink(lnbitsWalletId, {
|
||||
// Secure cash-in: the ATM sends only the hardware-attested gross
|
||||
// principal; the operator side verifies the signer, derives fee + NET,
|
||||
// and stamps attribution (spirekeeper#31/#32). The ATM no longer sets
|
||||
// the amount or extra. We display the returned LNURL (for NET) and
|
||||
// watch link_id for settlement.
|
||||
const link = await lnbits.createWithdraw(lnbitsWalletId, {
|
||||
principal_sats: context.satsAmount,
|
||||
fiat_amount: context.fiatCents / 100,
|
||||
fiat_code: context.currency,
|
||||
title: `bitSpire Cash-In ${context.cashInSessionId?.slice(0, 8) || 'session'}`,
|
||||
min_withdrawable: context.satsAmount,
|
||||
max_withdrawable: context.satsAmount,
|
||||
uses: 1,
|
||||
wait_time: 1,
|
||||
is_unique: false,
|
||||
client_ref: context.txid ?? context.cashInSessionId ?? undefined,
|
||||
})
|
||||
|
||||
if (!link.lnurl) {
|
||||
throw new Error(
|
||||
'[ATM Service] LNbits returned link.lnurl=null — check LNBITS_BASEURL on the server (aiolabs/withdraw#1)'
|
||||
'[ATM Service] create_withdraw returned no lnurl — check withdraw#3 / LNBITS_BASEURL on the server'
|
||||
)
|
||||
}
|
||||
const lnurl = link.lnurl.toUpperCase()
|
||||
console.log(
|
||||
`[ATM Service] create_withdraw: principal=${link.principal_sats} fee=${link.fee_sats} net=${link.net_sats} link=${link.link_id}`
|
||||
)
|
||||
|
||||
if (context.cashInSessionId) {
|
||||
registerLnurlSession(
|
||||
context.cashInSessionId,
|
||||
link.id,
|
||||
link.unique_hash,
|
||||
context.satsAmount,
|
||||
)
|
||||
// Track the NET (what the customer withdraws); keyed by link_id.
|
||||
registerLnurlSession(context.cashInSessionId, link.link_id, link.net_sats)
|
||||
const subId = await lnbits.subscribePayments(
|
||||
lnbitsWalletId,
|
||||
{ tag: 'withdraw', link_id: link.id, max_seconds: 600 },
|
||||
{ tag: 'withdraw', link_id: link.link_id, max_seconds: 600 },
|
||||
(push) => {
|
||||
console.log('[ATM Service] LNURL-withdraw claimed (LNbits push)!')
|
||||
const session = lnurlSessions.get(link.unique_hash)
|
||||
const session = lnurlSessions.get(link.link_id)
|
||||
if (session) {
|
||||
session.status = 'claimed'
|
||||
lnurlSessions.delete(link.unique_hash)
|
||||
lnurlSessions.delete(link.link_id)
|
||||
}
|
||||
if (onPaymentCallback) {
|
||||
onPaymentCallback(push.preimage ?? `lnurl-withdraw-${link.unique_hash}`)
|
||||
onPaymentCallback(push.preimage ?? `lnurl-withdraw-${link.link_id}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
// Wire per-session cleanup so abort/expiry tears it down cleanly.
|
||||
const session = lnurlSessions.get(link.unique_hash)
|
||||
const session = lnurlSessions.get(link.link_id)
|
||||
if (session) {
|
||||
session.cleanup = () => {
|
||||
void lnbits.unsubscribe(lnbitsWalletId, subId).catch(() => {})
|
||||
void lnbits.deleteWithdrawLink(lnbitsWalletId, link.id).catch(() => {})
|
||||
void lnbits.deleteWithdrawLink(lnbitsWalletId, link.link_id).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue