Merge pull request 'feat: secure cash-in via server-stamped create_withdraw RPC (#52)' (#66) from cash-in-create-withdraw into dev
Reviewed-on: #66
This commit is contained in:
commit
14ccbfbeda
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 */
|
/** Active LNURL-withdraw session */
|
||||||
interface LnurlSession {
|
interface LnurlSession {
|
||||||
sessionId: string
|
sessionId: string
|
||||||
/** Link ID for management operations (delete/update) */
|
/** Link ID — the management + settlement-watch key (delete/subscribe). */
|
||||||
linkId: string
|
linkId: string
|
||||||
uniqueHash: string
|
|
||||||
satsAmount: number
|
satsAmount: number
|
||||||
status: 'active' | 'claimed' | 'expired'
|
status: 'active' | 'claimed' | 'expired'
|
||||||
createdAt: number
|
createdAt: number
|
||||||
cleanup?: () => void
|
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>()
|
const lnurlSessions = new Map<string, LnurlSession>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a new LNURL-withdraw session
|
* Register a new LNURL-withdraw session, keyed by linkId.
|
||||||
*/
|
*/
|
||||||
function registerLnurlSession(
|
function registerLnurlSession(sessionId: string, linkId: string, satsAmount: number): void {
|
||||||
sessionId: string,
|
console.log('[LNURL Session] Registering:', linkId, 'for', satsAmount, 'sats')
|
||||||
linkId: string,
|
|
||||||
uniqueHash: string,
|
|
||||||
satsAmount: number,
|
|
||||||
): void {
|
|
||||||
console.log('[LNURL Session] Registering:', uniqueHash, 'for', satsAmount, 'sats')
|
|
||||||
|
|
||||||
lnurlSessions.set(uniqueHash, {
|
lnurlSessions.set(linkId, {
|
||||||
sessionId,
|
sessionId,
|
||||||
linkId,
|
linkId,
|
||||||
uniqueHash,
|
|
||||||
satsAmount,
|
satsAmount,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
|
@ -143,10 +137,10 @@ function registerLnurlSession(
|
||||||
|
|
||||||
// Safety timeout — normally cleaned up by state machine on idle transition.
|
// Safety timeout — normally cleaned up by state machine on idle transition.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const session = lnurlSessions.get(uniqueHash)
|
const session = lnurlSessions.get(linkId)
|
||||||
if (session && session.status === 'active') {
|
if (session && session.status === 'active') {
|
||||||
console.warn('[LNURL Session] Safety timeout reached, expiring:', uniqueHash)
|
console.warn('[LNURL Session] Safety timeout reached, expiring:', linkId)
|
||||||
expireLnurlSession(uniqueHash)
|
expireLnurlSession(linkId)
|
||||||
}
|
}
|
||||||
}, SESSION_SAFETY_TIMEOUT_MS)
|
}, SESSION_SAFETY_TIMEOUT_MS)
|
||||||
}
|
}
|
||||||
|
|
@ -154,23 +148,23 @@ function registerLnurlSession(
|
||||||
/** Invalidate an active LNURL session by cash-in sessionId. The session's
|
/** Invalidate an active LNURL session by cash-in sessionId. The session's
|
||||||
* cleanup closure unsubscribes from LNbits and deletes the link. */
|
* cleanup closure unsubscribes from LNbits and deletes the link. */
|
||||||
function invalidateLnurlSessionBySessionId(sessionId: string): void {
|
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') {
|
if (session.sessionId === sessionId && session.status === 'active') {
|
||||||
console.log('[LNURL Session] Invalidating previous session:', hash)
|
console.log('[LNURL Session] Invalidating previous session:', linkId)
|
||||||
expireLnurlSession(hash)
|
expireLnurlSession(linkId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Expire a single LNURL session via its cleanup closure. */
|
/** Expire a single LNURL session via its cleanup closure. */
|
||||||
function expireLnurlSession(uniqueHash: string): void {
|
function expireLnurlSession(linkId: string): void {
|
||||||
const session = lnurlSessions.get(uniqueHash)
|
const session = lnurlSessions.get(linkId)
|
||||||
if (!session || session.status !== 'active') return
|
if (!session || session.status !== 'active') return
|
||||||
|
|
||||||
console.log('[LNURL Session] Expiring:', uniqueHash)
|
console.log('[LNURL Session] Expiring:', linkId)
|
||||||
session.status = 'expired'
|
session.status = 'expired'
|
||||||
if (session.cleanup) session.cleanup()
|
if (session.cleanup) session.cleanup()
|
||||||
setTimeout(() => lnurlSessions.delete(uniqueHash), 60000)
|
setTimeout(() => lnurlSessions.delete(linkId), 60000)
|
||||||
}
|
}
|
||||||
|
|
||||||
let _lnbitsRef: LnbitsClient | null = null
|
let _lnbitsRef: LnbitsClient | null = null
|
||||||
|
|
@ -651,50 +645,53 @@ function createATMServices(
|
||||||
invalidateLnurlSessionBySessionId(context.cashInSessionId)
|
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'}`,
|
title: `bitSpire Cash-In ${context.cashInSessionId?.slice(0, 8) || 'session'}`,
|
||||||
min_withdrawable: context.satsAmount,
|
client_ref: context.txid ?? context.cashInSessionId ?? undefined,
|
||||||
max_withdrawable: context.satsAmount,
|
|
||||||
uses: 1,
|
|
||||||
wait_time: 1,
|
|
||||||
is_unique: false,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!link.lnurl) {
|
if (!link.lnurl) {
|
||||||
throw new Error(
|
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()
|
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) {
|
if (context.cashInSessionId) {
|
||||||
registerLnurlSession(
|
// Track the NET (what the customer withdraws); keyed by link_id.
|
||||||
context.cashInSessionId,
|
registerLnurlSession(context.cashInSessionId, link.link_id, link.net_sats)
|
||||||
link.id,
|
|
||||||
link.unique_hash,
|
|
||||||
context.satsAmount,
|
|
||||||
)
|
|
||||||
const subId = await lnbits.subscribePayments(
|
const subId = await lnbits.subscribePayments(
|
||||||
lnbitsWalletId,
|
lnbitsWalletId,
|
||||||
{ tag: 'withdraw', link_id: link.id, max_seconds: 600 },
|
{ tag: 'withdraw', link_id: link.link_id, max_seconds: 600 },
|
||||||
(push) => {
|
(push) => {
|
||||||
console.log('[ATM Service] LNURL-withdraw claimed (LNbits 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) {
|
if (session) {
|
||||||
session.status = 'claimed'
|
session.status = 'claimed'
|
||||||
lnurlSessions.delete(link.unique_hash)
|
lnurlSessions.delete(link.link_id)
|
||||||
}
|
}
|
||||||
if (onPaymentCallback) {
|
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.
|
// 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) {
|
if (session) {
|
||||||
session.cleanup = () => {
|
session.cleanup = () => {
|
||||||
void lnbits.unsubscribe(lnbitsWalletId, subId).catch(() => {})
|
void lnbits.unsubscribe(lnbitsWalletId, subId).catch(() => {})
|
||||||
void lnbits.deleteWithdrawLink(lnbitsWalletId, link.id).catch(() => {})
|
void lnbits.deleteWithdrawLink(lnbitsWalletId, link.link_id).catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ import type {
|
||||||
PaymentPushCallback,
|
PaymentPushCallback,
|
||||||
SubscriptionCloseCallback,
|
SubscriptionCloseCallback,
|
||||||
CreateWithdrawLinkBody,
|
CreateWithdrawLinkBody,
|
||||||
|
CreateWithdrawBody,
|
||||||
|
CreateWithdrawResult,
|
||||||
LnbitsWithdrawLink,
|
LnbitsWithdrawLink,
|
||||||
UniqueHashesResponse,
|
UniqueHashesResponse,
|
||||||
} from './types.js'
|
} from './types.js'
|
||||||
|
|
@ -388,6 +390,19 @@ export class LnbitsClient {
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cash-in: create a SERVER-STAMPED LNURL-withdraw via the secure
|
||||||
|
* `create_withdraw` RPC (aiolabs/spirekeeper#31 / #32). The ATM sends only the
|
||||||
|
* hardware-attested `principal_sats`; the operator side verifies the signer,
|
||||||
|
* derives fee + NET, and stamps the link's attribution from the verified
|
||||||
|
* sender — the machine cannot understate the fee or forge attribution. NOT
|
||||||
|
* idempotent (mints a link) → not retry-wrapped; supersedes the direct,
|
||||||
|
* client-amount `createWithdrawLink` for cash-in.
|
||||||
|
*/
|
||||||
|
async createWithdraw(walletId: string, body: CreateWithdrawBody): Promise<CreateWithdrawResult> {
|
||||||
|
return this.sendRpc<CreateWithdrawResult>('create_withdraw', { walletId, body })
|
||||||
|
}
|
||||||
|
|
||||||
async getWithdrawLink(walletId: string, id: string): Promise<LnbitsWithdrawLink> {
|
async getWithdrawLink(walletId: string, id: string): Promise<LnbitsWithdrawLink> {
|
||||||
return this.idempotent(() =>
|
return this.idempotent(() =>
|
||||||
this.sendRpc<LnbitsWithdrawLink>('lnurlw_get_link', { walletId, body: { id } }),
|
this.sendRpc<LnbitsWithdrawLink>('lnurlw_get_link', { walletId, body: { id } }),
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,8 @@ export type {
|
||||||
PaymentPushCallback,
|
PaymentPushCallback,
|
||||||
SubscriptionCloseCallback,
|
SubscriptionCloseCallback,
|
||||||
CreateWithdrawLinkBody,
|
CreateWithdrawLinkBody,
|
||||||
|
CreateWithdrawBody,
|
||||||
|
CreateWithdrawResult,
|
||||||
LnbitsWithdrawLink,
|
LnbitsWithdrawLink,
|
||||||
UniqueHashEntry,
|
UniqueHashEntry,
|
||||||
UniqueHashesResponse,
|
UniqueHashesResponse,
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,45 @@ export interface SubscribeClose {
|
||||||
// LNURL-withdraw (the `withdraw` extension's transport surface)
|
// LNURL-withdraw (the `withdraw` extension's transport surface)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cash-in request for the SECURE `create_withdraw` RPC (aiolabs/spirekeeper#31).
|
||||||
|
* The ATM supplies only the hardware-attested gross principal; the operator
|
||||||
|
* side derives fee + NET and stamps attribution from the *verified* signer, so
|
||||||
|
* the machine cannot understate the fee or forge attribution. Contrast with
|
||||||
|
* `CreateWithdrawLinkBody`, where the amount + extra were client-supplied.
|
||||||
|
*/
|
||||||
|
export interface CreateWithdrawBody {
|
||||||
|
/** Gross principal in sats — the fiat value the ATM measured. REQUIRED. */
|
||||||
|
principal_sats: number
|
||||||
|
/** Fiat amount for the settlement row + display. */
|
||||||
|
fiat_amount?: number
|
||||||
|
/** Fiat code; defaults to the machine's configured currency server-side. */
|
||||||
|
fiat_code?: string
|
||||||
|
/** Link display title. */
|
||||||
|
title?: string
|
||||||
|
/** Seconds between withdraws (default 1). */
|
||||||
|
wait_time?: number
|
||||||
|
/** Audit ref → settlement.nostr_event_id (use the ATM tx id). */
|
||||||
|
client_ref?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response from `create_withdraw` — server-derived amounts + the LNURL to show. */
|
||||||
|
export interface CreateWithdrawResult {
|
||||||
|
/** Settlement-watch key — `subscribe_payments { tag:'withdraw', link_id }`. */
|
||||||
|
link_id: string
|
||||||
|
/** bech32 LNURL — the QR the ATM displays. */
|
||||||
|
lnurl: string
|
||||||
|
/** Raw callback URL (alternative for QR generation). */
|
||||||
|
lnurl_url?: string
|
||||||
|
/** NET sats the customer receives (principal − fee). */
|
||||||
|
net_sats: number
|
||||||
|
/** Gross principal echoed back. */
|
||||||
|
principal_sats: number
|
||||||
|
/** Fee withheld (server-computed). */
|
||||||
|
fee_sats: number
|
||||||
|
k1?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateWithdrawLinkBody {
|
export interface CreateWithdrawLinkBody {
|
||||||
title: string
|
title: string
|
||||||
min_withdrawable: number
|
min_withdrawable: number
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue