mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: drain tracked NWC handlers on stop
This commit is contained in:
@@ -14,26 +14,18 @@
|
|||||||
// foreground-only design itself is documented in the nwc.ts façade
|
// foreground-only design itself is documented in the nwc.ts façade
|
||||||
// header.
|
// header.
|
||||||
|
|
||||||
|
import {linkingPubKeyHex} from '../keys'
|
||||||
import type {NwcConnectionRecord} from '../storage/nwcConnections'
|
import type {NwcConnectionRecord} from '../storage/nwcConnections'
|
||||||
import {readNwcConnections} from '../storage/nwcConnections'
|
import {readNwcConnections} from '../storage/nwcConnections'
|
||||||
|
import {assertSavedKeyOwner} from '../storage/currentOwner'
|
||||||
|
|
||||||
import type {NwcConnectionInfo} from './connection'
|
import type {NwcConnectionInfo} from './connection'
|
||||||
import {deriveNwcWalletKey, nwcWalletPubkey} from './connection'
|
import {deriveNwcWalletKey, nwcWalletPubkey} from './connection'
|
||||||
import type {NwcServiceDeps, PendingInvoice, RequestContext} from './context'
|
import type {NwcServiceDeps, PendingInvoice, RequestContext} from './context'
|
||||||
import {dispatch} from './dispatch'
|
import {dispatch} from './dispatch'
|
||||||
import type {
|
import type {NostrEvent, NwcEncryption, NwcRequest, NwcResponse} from './protocol'
|
||||||
NostrEvent,
|
import {NWC_REQUEST_KIND, buildInfoEvent, buildResponseEvent, decryptRequest} from './protocol'
|
||||||
NwcEncryption,
|
import type {NwcSubscription} from './transport'
|
||||||
NwcRequest,
|
|
||||||
NwcResponse
|
|
||||||
} from './protocol'
|
|
||||||
import {
|
|
||||||
NWC_REQUEST_KIND,
|
|
||||||
buildInfoEvent,
|
|
||||||
buildResponseEvent,
|
|
||||||
decryptRequest
|
|
||||||
} from './protocol'
|
|
||||||
import type {NwcSubscription, NwcTransport} from './transport'
|
|
||||||
import {defaultNwcTransport} from './transport'
|
import {defaultNwcTransport} from './transport'
|
||||||
|
|
||||||
export type {NwcConnectionInfo}
|
export type {NwcConnectionInfo}
|
||||||
@@ -44,7 +36,8 @@ const MAX_REQUEST_AGE_SECONDS = 600
|
|||||||
|
|
||||||
type ConnectionRuntime = {
|
type ConnectionRuntime = {
|
||||||
info: NwcConnectionInfo
|
info: NwcConnectionInfo
|
||||||
walletSecret: Uint8Array
|
// nulled and zeroed only after every tracked handler drains
|
||||||
|
walletSecret: Uint8Array | null
|
||||||
// invoices this connection issued, by payment hash - in-memory only:
|
// invoices this connection issued, by payment hash - in-memory only:
|
||||||
// pending invoices don't survive a restart (lookup then answers
|
// pending invoices don't survive a restart (lookup then answers
|
||||||
// NOT_FOUND), same as any foreground-only wallet
|
// NOT_FOUND), same as any foreground-only wallet
|
||||||
@@ -61,34 +54,38 @@ export type NwcService = {
|
|||||||
connections: NwcConnectionInfo[]
|
connections: NwcConnectionInfo[]
|
||||||
// closes every relay subscription. In-flight handlers still finish -
|
// closes every relay subscription. In-flight handlers still finish -
|
||||||
// their changesets hold money - but no new requests are picked up
|
// their changesets hold money - but no new requests are picked up
|
||||||
stop: () => void
|
stop: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const startService = async (
|
export const startService = async (
|
||||||
linkingPrivKey: Uint8Array,
|
linkingPrivKey: Uint8Array,
|
||||||
deps: NwcServiceDeps,
|
deps: NwcServiceDeps,
|
||||||
records: NwcConnectionRecord[] = readNwcConnections()
|
records?: NwcConnectionRecord[],
|
||||||
): Promise<NwcService> => {
|
): Promise<NwcService> => {
|
||||||
const transport = deps.transport ?? (await defaultNwcTransport())
|
const transport = deps.transport ?? (await defaultNwcTransport())
|
||||||
const nowSeconds = (): number =>
|
const ownerId = linkingPubKeyHex(linkingPrivKey)
|
||||||
deps.nowSeconds?.() ?? Math.floor(Date.now() / 1000)
|
const ownedRecords = (records ?? readNwcConnections(ownerId)).filter(
|
||||||
|
(record) => record.ownerId === ownerId,
|
||||||
|
)
|
||||||
|
const nowSeconds = (): number => deps.nowSeconds?.() ?? Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
const publishResponse = async (
|
const publishResponse = async (
|
||||||
runtime: ConnectionRuntime,
|
runtime: ConnectionRuntime,
|
||||||
|
walletSecret: Uint8Array,
|
||||||
requestEventId: string,
|
requestEventId: string,
|
||||||
encryption: NwcEncryption,
|
encryption: NwcEncryption,
|
||||||
response: NwcResponse
|
response: NwcResponse,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
await transport.publish(
|
await transport.publish(
|
||||||
runtime.info.record.relays,
|
runtime.info.record.relays,
|
||||||
buildResponseEvent(
|
buildResponseEvent(
|
||||||
runtime.walletSecret,
|
walletSecret,
|
||||||
runtime.info.record.clientPubkey,
|
runtime.info.record.clientPubkey,
|
||||||
encryption,
|
encryption,
|
||||||
requestEventId,
|
requestEventId,
|
||||||
response,
|
response,
|
||||||
nowSeconds()
|
nowSeconds(),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,12 +94,12 @@ export const startService = async (
|
|||||||
const dispatchSerialized = (
|
const dispatchSerialized = (
|
||||||
runtime: ConnectionRuntime,
|
runtime: ConnectionRuntime,
|
||||||
ctx: RequestContext,
|
ctx: RequestContext,
|
||||||
request: NwcRequest
|
request: NwcRequest,
|
||||||
): Promise<NwcResponse> => {
|
): Promise<NwcResponse> => {
|
||||||
if (request.method !== 'pay_invoice') return dispatch(ctx, request)
|
if (request.method !== 'pay_invoice') return dispatch(ctx, request)
|
||||||
const run = runtime.queue.then(
|
const run = runtime.queue.then(
|
||||||
() => dispatch(ctx, request),
|
() => dispatch(ctx, request),
|
||||||
() => dispatch(ctx, request)
|
() => dispatch(ctx, request),
|
||||||
)
|
)
|
||||||
runtime.queue = run.catch(() => undefined)
|
runtime.queue = run.catch(() => undefined)
|
||||||
return run
|
return run
|
||||||
@@ -111,28 +108,53 @@ export const startService = async (
|
|||||||
const handleEvent = async (
|
const handleEvent = async (
|
||||||
runtime: ConnectionRuntime,
|
runtime: ConnectionRuntime,
|
||||||
ctx: RequestContext,
|
ctx: RequestContext,
|
||||||
event: NostrEvent
|
event: NostrEvent,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
|
const walletSecret = runtime.walletSecret
|
||||||
|
if (walletSecret === null) return
|
||||||
const at = nowSeconds()
|
const at = nowSeconds()
|
||||||
// replay safety (see the header): too-old requests are dropped
|
// replay safety (see the header): too-old requests are dropped
|
||||||
if (event.created_at < at - MAX_REQUEST_AGE_SECONDS) return
|
if (event.created_at < at - MAX_REQUEST_AGE_SECONDS) return
|
||||||
const decrypted = decryptRequest(
|
const decrypted = decryptRequest(
|
||||||
runtime.walletSecret,
|
walletSecret,
|
||||||
runtime.info.walletServicePubkey,
|
runtime.info.walletServicePubkey,
|
||||||
runtime.info.record.clientPubkey,
|
runtime.info.record.clientPubkey,
|
||||||
event,
|
event,
|
||||||
at
|
at,
|
||||||
)
|
)
|
||||||
if (decrypted === null) return
|
if (decrypted === null) return
|
||||||
if (decrypted.respond) {
|
if (decrypted.respond) {
|
||||||
await publishResponse(runtime, event.id, decrypted.encryption, decrypted.response)
|
await publishResponse(
|
||||||
|
runtime,
|
||||||
|
walletSecret,
|
||||||
|
event.id,
|
||||||
|
decrypted.encryption,
|
||||||
|
decrypted.response,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const response = await dispatchSerialized(runtime, ctx, decrypted.request)
|
const response = await dispatchSerialized(runtime, ctx, decrypted.request)
|
||||||
await publishResponse(runtime, event.id, decrypted.encryption, response)
|
await publishResponse(runtime, walletSecret, event.id, decrypted.encryption, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
const runtimes = records.map(record => {
|
let accepting = true
|
||||||
|
// interrupts long observation waits (the invoice claim poll) at stop;
|
||||||
|
// the drain below still awaits tasks that reached a fund-critical commit
|
||||||
|
const stopController = new AbortController()
|
||||||
|
const inFlight = new Set<Promise<void>>()
|
||||||
|
const track = (task: Promise<void>): void => {
|
||||||
|
inFlight.add(task)
|
||||||
|
void task.then(
|
||||||
|
() => inFlight.delete(task),
|
||||||
|
() => inFlight.delete(task),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const startBackground = (work: () => Promise<void>): boolean => {
|
||||||
|
if (!accepting) return false
|
||||||
|
track(work())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let runtimes = ownedRecords.map((record) => {
|
||||||
const walletSecret = deriveNwcWalletKey(linkingPrivKey, record.clientPubkey)
|
const walletSecret = deriveNwcWalletKey(linkingPrivKey, record.clientPubkey)
|
||||||
const runtime: ConnectionRuntime = {
|
const runtime: ConnectionRuntime = {
|
||||||
info: {record, walletServicePubkey: nwcWalletPubkey(walletSecret)},
|
info: {record, walletServicePubkey: nwcWalletPubkey(walletSecret)},
|
||||||
@@ -141,29 +163,37 @@ export const startService = async (
|
|||||||
queue: Promise.resolve(),
|
queue: Promise.resolve(),
|
||||||
// replaced below, immediately - the field exists because the
|
// replaced below, immediately - the field exists because the
|
||||||
// subscription callback closes over the runtime
|
// subscription callback closes over the runtime
|
||||||
sub: {close: () => undefined}
|
sub: {close: () => undefined},
|
||||||
}
|
}
|
||||||
const ctx: RequestContext = {
|
const ctx: RequestContext = {
|
||||||
deps,
|
deps,
|
||||||
connection: () => runtime.info,
|
connection: () => runtime.info,
|
||||||
updateRecord: updated => {
|
updateRecord: (updated) => {
|
||||||
runtime.info = {...runtime.info, record: updated}
|
runtime.info = {...runtime.info, record: updated}
|
||||||
},
|
},
|
||||||
invoices: runtime.invoices,
|
invoices: runtime.invoices,
|
||||||
nowSeconds
|
nowSeconds,
|
||||||
|
assertOwner: () => {
|
||||||
|
deps.assertCurrentOwner()
|
||||||
|
assertSavedKeyOwner(ownerId)
|
||||||
|
},
|
||||||
|
startBackground,
|
||||||
|
stopSignal: stopController.signal,
|
||||||
}
|
}
|
||||||
runtime.sub = transport.subscribe(
|
runtime.sub = transport.subscribe(
|
||||||
record.relays,
|
record.relays,
|
||||||
{
|
{
|
||||||
kinds: [NWC_REQUEST_KIND],
|
kinds: [NWC_REQUEST_KIND],
|
||||||
'#p': [runtime.info.walletServicePubkey],
|
'#p': [runtime.info.walletServicePubkey],
|
||||||
since: nowSeconds()
|
since: nowSeconds(),
|
||||||
},
|
},
|
||||||
event => {
|
(event) => {
|
||||||
void handleEvent(runtime, ctx, event).catch(err =>
|
if (!accepting) return
|
||||||
|
const handler = handleEvent(runtime, ctx, event).catch((err) => {
|
||||||
deps.onError?.(err, runtime.info)
|
deps.onError?.(err, runtime.info)
|
||||||
)
|
})
|
||||||
}
|
track(handler)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
return runtime
|
return runtime
|
||||||
})
|
})
|
||||||
@@ -171,20 +201,43 @@ export const startService = async (
|
|||||||
// info events: best-effort - a rejected publish must not sink startup;
|
// info events: best-effort - a rejected publish must not sink startup;
|
||||||
// the client learns capabilities from its first error-free exchange too
|
// the client learns capabilities from its first error-free exchange too
|
||||||
for (const runtime of runtimes) {
|
for (const runtime of runtimes) {
|
||||||
|
const walletSecret = runtime.walletSecret
|
||||||
|
if (walletSecret === null) continue
|
||||||
try {
|
try {
|
||||||
await transport.publish(
|
await transport.publish(
|
||||||
runtime.info.record.relays,
|
runtime.info.record.relays,
|
||||||
buildInfoEvent(runtime.walletSecret, nowSeconds())
|
buildInfoEvent(walletSecret, nowSeconds()),
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
deps.onError?.(
|
||||||
|
error instanceof Error ? error : new Error('NWC info publication failed.', {cause: error}),
|
||||||
|
runtime.info,
|
||||||
)
|
)
|
||||||
} catch (err) {
|
|
||||||
deps.onError?.(err, runtime.info)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const connections = runtimes.map((runtime) => runtime.info)
|
||||||
connections: runtimes.map(r => r.info),
|
let stopPromise: Promise<void> | null = null
|
||||||
stop: () => {
|
const stop = (): Promise<void> => {
|
||||||
|
if (stopPromise !== null) return stopPromise
|
||||||
|
accepting = false
|
||||||
for (const runtime of runtimes) runtime.sub.close()
|
for (const runtime of runtimes) runtime.sub.close()
|
||||||
|
stopController.abort()
|
||||||
|
stopPromise = Promise.all([...inFlight])
|
||||||
|
.then(() => undefined)
|
||||||
|
.finally(() => {
|
||||||
|
for (const runtime of runtimes) {
|
||||||
|
runtime.walletSecret?.fill(0)
|
||||||
|
runtime.walletSecret = null
|
||||||
|
runtime.invoices.clear()
|
||||||
}
|
}
|
||||||
|
runtimes = []
|
||||||
|
})
|
||||||
|
return stopPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
connections,
|
||||||
|
stop,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export type NwcTransport = {
|
|||||||
subscribe: (
|
subscribe: (
|
||||||
relays: string[],
|
relays: string[],
|
||||||
filter: NostrFilter,
|
filter: NostrFilter,
|
||||||
onEvent: (event: NostrEvent) => void
|
onEvent: (event: NostrEvent) => void,
|
||||||
) => NwcSubscription
|
) => NwcSubscription
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,11 +30,10 @@ export const defaultNwcTransport = async (): Promise<NwcTransport> => {
|
|||||||
publish: async (relays, event) => {
|
publish: async (relays, event) => {
|
||||||
const results = await Promise.allSettled(pool.publish(relays, event))
|
const results = await Promise.allSettled(pool.publish(relays, event))
|
||||||
// one honest relay accepting is enough - same rule as the backup
|
// one honest relay accepting is enough - same rule as the backup
|
||||||
if (!results.some(r => r.status === 'fulfilled')) {
|
if (!results.some((r) => r.status === 'fulfilled')) {
|
||||||
throw new Error('No relay accepted the event.')
|
throw new Error('No relay accepted the event.')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
subscribe: (relays, filter, onEvent) =>
|
subscribe: (relays, filter, onEvent) => pool.subscribeMany(relays, filter, {onevent: onEvent}),
|
||||||
pool.subscribeMany(relays, filter, {onevent: onEvent})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user