mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
228 lines
7.3 KiB
TypeScript
228 lines
7.3 KiB
TypeScript
// The pure half of the NIP-47 wallet service: the request/response event
|
|
// codec - validate, decrypt, build, encrypt - with no relay I/O and no
|
|
// wallet logic (dispatch lives in service.ts).
|
|
//
|
|
// Wire shape (NIP-47):
|
|
// - the client sends kind 23194, tagged ['p', wallet-service pubkey], its
|
|
// content a JSON {method, params} encrypted to the wallet service with
|
|
// NIP-44 v2 (tag ['encryption', 'nip44_v2']) or legacy NIP-04 (tag
|
|
// ['encryption', 'nip04'], or NO encryption tag at all)
|
|
// - the wallet answers with kind 23195, tagged ['p', client pubkey] and
|
|
// ['e', request id], its content a JSON {result_type, error, result}
|
|
// encrypted back with the SAME scheme the request used
|
|
// - a kind 13194 replaceable info event advertises the supported methods
|
|
// and encryption schemes
|
|
|
|
import type {Event as NostrEvent} from 'nostr-tools/core'
|
|
import {finalizeEvent, verifyEvent} from 'nostr-tools/pure'
|
|
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
|
|
import {v2 as nip44v2} from 'nostr-tools/nip44'
|
|
|
|
export type {NostrEvent}
|
|
|
|
export const NWC_REQUEST_KIND = 23194
|
|
export const NWC_RESPONSE_KIND = 23195
|
|
export const NWC_INFO_KIND = 13194
|
|
|
|
export const NWC_METHODS = [
|
|
'get_info',
|
|
'get_balance',
|
|
'make_invoice',
|
|
'pay_invoice',
|
|
'lookup_invoice',
|
|
] as const
|
|
|
|
export type NwcMethod = (typeof NWC_METHODS)[number]
|
|
|
|
export type NwcErrorCode =
|
|
| 'RATE_LIMITED'
|
|
| 'NOT_IMPLEMENTED'
|
|
| 'INSUFFICIENT_BALANCE'
|
|
| 'QUOTA_EXCEEDED'
|
|
| 'RESTRICTED'
|
|
| 'UNAUTHORIZED'
|
|
| 'INTERNAL'
|
|
| 'UNSUPPORTED_ENCRYPTION'
|
|
| 'PAYMENT_FAILED'
|
|
| 'NOT_FOUND'
|
|
| 'OTHER'
|
|
|
|
export type NwcRequest = {
|
|
method: string
|
|
params: Record<string, unknown>
|
|
}
|
|
|
|
export type NwcResponse = {
|
|
result_type: string
|
|
error: {code: NwcErrorCode; message: string} | null
|
|
result: unknown
|
|
}
|
|
|
|
export const okResult = (method: string, result: unknown): NwcResponse => ({
|
|
result_type: method,
|
|
error: null,
|
|
result,
|
|
})
|
|
|
|
export const errResult = (method: string, code: NwcErrorCode, message: string): NwcResponse => ({
|
|
result_type: method,
|
|
error: {code, message},
|
|
result: null,
|
|
})
|
|
|
|
// the two encryption schemes this service speaks; the scheme of a request
|
|
// decides the scheme of its response (NIP-47: "Encrypted using the scheme
|
|
// requested by the client")
|
|
export type NwcEncryption = 'nip44_v2' | 'nip04'
|
|
|
|
const conversationKey = (walletSecretKey: Uint8Array, clientPubkey: string): Uint8Array =>
|
|
nip44v2.utils.getConversationKey(walletSecretKey, clientPubkey)
|
|
|
|
export const encryptFor = (
|
|
scheme: NwcEncryption,
|
|
walletSecretKey: Uint8Array,
|
|
clientPubkey: string,
|
|
plaintext: string,
|
|
): string =>
|
|
scheme === 'nip44_v2'
|
|
? nip44v2.encrypt(plaintext, conversationKey(walletSecretKey, clientPubkey))
|
|
: nip04Encrypt(walletSecretKey, clientPubkey, plaintext)
|
|
|
|
const decryptFrom = (
|
|
scheme: NwcEncryption,
|
|
walletSecretKey: Uint8Array,
|
|
clientPubkey: string,
|
|
content: string,
|
|
): string =>
|
|
scheme === 'nip44_v2'
|
|
? nip44v2.decrypt(content, conversationKey(walletSecretKey, clientPubkey))
|
|
: nip04Decrypt(walletSecretKey, clientPubkey, content)
|
|
|
|
const tagValue = (event: NostrEvent, name: string): string | undefined =>
|
|
event.tags.find((t) => t[0] === name)?.[1]
|
|
|
|
// The outcome of validating + decrypting a candidate request event:
|
|
// - a request to dispatch (encryption scheme carried so the response can
|
|
// mirror it)
|
|
// - a respondable failure: the event IS an authorized client's request,
|
|
// but its content can't be had or parsed - the client still gets a
|
|
// well-formed error answer instead of silence
|
|
// - null: not ours to answer at all (wrong kind, wrong author, forged
|
|
// signature, expired, undecryptable, or a response event echoed back) -
|
|
// dropped silently, exactly what a relay full of strangers' traffic
|
|
// demands
|
|
export type DecryptedNwcRequest =
|
|
| {respond: false; request: NwcRequest; encryption: NwcEncryption}
|
|
| {respond: true; response: NwcResponse; encryption: NwcEncryption}
|
|
|
|
export const decryptRequest = (
|
|
walletSecretKey: Uint8Array,
|
|
walletServicePubkey: string,
|
|
clientPubkey: string,
|
|
event: NostrEvent,
|
|
nowSeconds: number = Math.floor(Date.now() / 1000),
|
|
): DecryptedNwcRequest | null => {
|
|
if (event.kind !== NWC_REQUEST_KIND) return null
|
|
// only the authorized client may talk to this connection, and the
|
|
// request must actually be addressed to it
|
|
if (event.pubkey !== clientPubkey) return null
|
|
if (tagValue(event, 'p') !== walletServicePubkey) return null
|
|
if (!verifyEvent(event)) return null
|
|
// an expired request is ignored, never answered (NIP-47)
|
|
const expiration = tagValue(event, 'expiration')
|
|
if (expiration !== undefined && Number(expiration) < nowSeconds) {
|
|
return null
|
|
}
|
|
// encryption negotiation: no tag means legacy NIP-04 (NIP-47)
|
|
const advertised = tagValue(event, 'encryption')
|
|
let encryption: NwcEncryption
|
|
if (advertised === undefined || advertised === 'nip04') {
|
|
encryption = 'nip04'
|
|
} else if (advertised === 'nip44_v2') {
|
|
encryption = 'nip44_v2'
|
|
} else {
|
|
// the client asked for a scheme we don't speak - answer in the
|
|
// legacy default, the one scheme every NIP-47 client must read
|
|
return {
|
|
respond: true,
|
|
encryption: 'nip04',
|
|
response: errResult(
|
|
'',
|
|
'UNSUPPORTED_ENCRYPTION',
|
|
`Unsupported encryption scheme: ${advertised}.`,
|
|
),
|
|
}
|
|
}
|
|
let plaintext: string
|
|
try {
|
|
plaintext = decryptFrom(encryption, walletSecretKey, clientPubkey, event.content)
|
|
} catch {
|
|
// undecryptable - indistinguishable from relay noise; stay silent
|
|
return null
|
|
}
|
|
let data: unknown
|
|
try {
|
|
data = JSON.parse(plaintext)
|
|
} catch {
|
|
return {
|
|
respond: true,
|
|
encryption,
|
|
response: errResult('', 'OTHER', 'The request is not valid JSON.'),
|
|
}
|
|
}
|
|
if (
|
|
typeof data !== 'object' ||
|
|
data === null ||
|
|
typeof (data as NwcRequest).method !== 'string' ||
|
|
(data as NwcRequest).method === ''
|
|
) {
|
|
return {
|
|
respond: true,
|
|
encryption,
|
|
response: errResult('', 'OTHER', 'The request has no method.'),
|
|
}
|
|
}
|
|
const request = data as NwcRequest
|
|
const params = typeof request.params === 'object' && request.params !== null ? request.params : {}
|
|
return {respond: false, request: {method: request.method, params}, encryption}
|
|
}
|
|
|
|
// signs the kind-23195 answer to a request, mirroring its encryption
|
|
// scheme and referencing it via the 'e' tag
|
|
export const buildResponseEvent = (
|
|
walletSecretKey: Uint8Array,
|
|
clientPubkey: string,
|
|
encryption: NwcEncryption,
|
|
requestEventId: string,
|
|
response: NwcResponse,
|
|
createdAt: number = Math.floor(Date.now() / 1000),
|
|
): NostrEvent =>
|
|
finalizeEvent(
|
|
{
|
|
kind: NWC_RESPONSE_KIND,
|
|
created_at: createdAt,
|
|
tags: [
|
|
['p', clientPubkey],
|
|
['e', requestEventId],
|
|
['encryption', encryption],
|
|
],
|
|
content: encryptFor(encryption, walletSecretKey, clientPubkey, JSON.stringify(response)),
|
|
},
|
|
walletSecretKey,
|
|
)
|
|
|
|
// the replaceable info event advertising this service's capabilities
|
|
export const buildInfoEvent = (
|
|
walletSecretKey: Uint8Array,
|
|
createdAt: number = Math.floor(Date.now() / 1000),
|
|
): NostrEvent =>
|
|
finalizeEvent(
|
|
{
|
|
kind: NWC_INFO_KIND,
|
|
created_at: createdAt,
|
|
tags: [['encryption', 'nip44_v2 nip04']],
|
|
content: NWC_METHODS.join(' '),
|
|
},
|
|
walletSecretKey,
|
|
)
|