mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
feat: scope NWC storage to wallet owners
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
// The NWC wallet service end to end: connection strings and key
|
||||
// derivation, the request/response cycle over an in-memory relay (the
|
||||
// transport is injected - no network), every method against the
|
||||
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
|
||||
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
|
||||
// requests never execute, and a settled preimage only ever reveals an
|
||||
// already-rotated (burned) note secret.
|
||||
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest'
|
||||
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
|
||||
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
|
||||
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
|
||||
import {v2 as nip44v2} from 'nostr-tools/nip44'
|
||||
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
|
||||
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
|
||||
|
||||
import {
|
||||
NWC_INFO_KIND,
|
||||
NWC_REQUEST_KIND,
|
||||
NWC_RESPONSE_KIND,
|
||||
buildConnectionString,
|
||||
connectionInfoOf,
|
||||
createConnection,
|
||||
deriveNwcWalletKey,
|
||||
migrateLegacyNwcStorage,
|
||||
parseConnectionString,
|
||||
readNwcEnabled,
|
||||
readNwcConnections,
|
||||
startService,
|
||||
writeNwcEnabled,
|
||||
writeNwcConnections,
|
||||
} from './nwc'
|
||||
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
|
||||
import type {NostrFilter} from './nwc/transport'
|
||||
import type {NwcChangeset} from './nwc'
|
||||
import type {Bearer} from './types'
|
||||
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
|
||||
import {requiredValue, stubLocalStorage} from './test-utils'
|
||||
|
||||
import {
|
||||
CLIENT_PUBKEY,
|
||||
CLIENT_SECRET,
|
||||
FAST_POLL,
|
||||
LINKING_KEY,
|
||||
OTHER_LINKING_KEY,
|
||||
OTHER_OWNER_ID,
|
||||
OWNER_ID,
|
||||
RELAYS,
|
||||
STRANGER_SECRET,
|
||||
clientRequest,
|
||||
createFakeRelay,
|
||||
deferred,
|
||||
foreignConnectionFixture,
|
||||
methodRequest,
|
||||
nowSeconds,
|
||||
storeForeignConnection,
|
||||
waitFor,
|
||||
} from './nwc.testProtocol'
|
||||
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
|
||||
describe('service ownership', () => {
|
||||
it('subscribes only current-owner records and leaves foreign budgets untouched', async () => {
|
||||
const current = createConnection(LINKING_KEY, {
|
||||
relays: RELAYS,
|
||||
budget: {maxMsat: 1000, periodMs: 1000},
|
||||
clientSecret: CLIENT_SECRET,
|
||||
now: 0,
|
||||
})
|
||||
const foreign = foreignConnectionFixture({maxMsat: 2000, periodMs: 2000}, 0)
|
||||
storeForeignConnection(foreign.record)
|
||||
const relay = createFakeRelay()
|
||||
|
||||
const service = await startService(LINKING_KEY, {
|
||||
assertCurrentOwner: () => undefined,
|
||||
getBearers: () => [],
|
||||
getDefaultMint: () => null,
|
||||
applyChangeset: () => Promise.resolve(),
|
||||
transport: relay.transport,
|
||||
nowSeconds,
|
||||
})
|
||||
|
||||
expect(service.connections.map((connection) => connection.record)).toEqual([current.record])
|
||||
expect(relay.subscriptionCount()).toBe(1)
|
||||
expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0)
|
||||
expect(service.connections).not.toContainEqual(foreign)
|
||||
await service.stop()
|
||||
})
|
||||
|
||||
it('ignores foreign records handed in through the records snapshot', async () => {
|
||||
// the injected-records path bypasses storage, so the service's own
|
||||
// owner filter is the only boundary here (stale-ownership probe: a
|
||||
// snapshot from a previous wallet must not be served)
|
||||
const current = createConnection(LINKING_KEY, {
|
||||
relays: RELAYS,
|
||||
budget: {maxMsat: 1000, periodMs: 1000},
|
||||
clientSecret: CLIENT_SECRET,
|
||||
now: 0,
|
||||
})
|
||||
const foreign = foreignConnectionFixture({maxMsat: 2000, periodMs: 2000}, 0)
|
||||
const relay = createFakeRelay()
|
||||
|
||||
const service = await startService(
|
||||
LINKING_KEY,
|
||||
{
|
||||
assertCurrentOwner: () => undefined,
|
||||
getBearers: () => [],
|
||||
getDefaultMint: () => null,
|
||||
applyChangeset: () => Promise.resolve(),
|
||||
transport: relay.transport,
|
||||
nowSeconds,
|
||||
},
|
||||
[current.record, foreign.record],
|
||||
)
|
||||
|
||||
expect(service.connections.map((connection) => connection.record)).toEqual([current.record])
|
||||
expect(relay.subscriptionCount()).toBe(1)
|
||||
// the foreign snapshot record must not be persisted for the new owner
|
||||
expect(readNwcConnections(OWNER_ID)).toEqual([current.record])
|
||||
await service.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('service: info and balance', () => {
|
||||
it('publishes a kind-13194 info event on startup', async () => {
|
||||
const {relay, walletServicePubkey, stop} = await startTestService({})
|
||||
const info = requiredValue(relay.published.find((e) => e.kind === NWC_INFO_KIND))
|
||||
expect(info.pubkey).toBe(walletServicePubkey)
|
||||
expect(info.content).toContain('pay_invoice')
|
||||
expect(info.content).toContain('make_invoice')
|
||||
expect(info.tags).toContainEqual(['encryption', 'nip44_v2 nip04'])
|
||||
await stop()
|
||||
})
|
||||
|
||||
it('answers get_info with the connection identity and method list', async () => {
|
||||
const {relay, walletServicePubkey, stop} = await startTestService({})
|
||||
const response = await call(relay, walletServicePubkey, 'get_info', {})
|
||||
expect(response.error).toBeNull()
|
||||
expect(response.result_type).toBe('get_info')
|
||||
expect(response.result).toMatchObject({
|
||||
alias: 'sattle',
|
||||
pubkey: walletServicePubkey,
|
||||
methods: ['get_info', 'get_balance', 'make_invoice', 'pay_invoice', 'lookup_invoice'],
|
||||
})
|
||||
await stop()
|
||||
})
|
||||
|
||||
it('answers get_balance with the spendable total only', async () => {
|
||||
const m = await mint()
|
||||
const {relay, walletServicePubkey, state, stop} = await startTestService({})
|
||||
state.bearers = [
|
||||
await makeBearer(m, 'aa'.repeat(32), 21_000),
|
||||
await makeBearer(m, 'bb'.repeat(32), 5_000),
|
||||
{...(await makeBearer(m, 'cc'.repeat(32), 99_000)), spent: true},
|
||||
]
|
||||
const response = await call(relay, walletServicePubkey, 'get_balance', {})
|
||||
expect(response.error).toBeNull()
|
||||
expect(response.result).toEqual({balance: 26_000})
|
||||
await stop()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user