mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
feat(funds): recover staged mutations
This commit is contained in:
@@ -8,10 +8,10 @@
|
||||
// mock emits either, selected by its withdrawLinkForm option, so do NOT
|
||||
// assume a default anywhere in the receive path); and the LUD-06 quote
|
||||
// request must carry the output-naming hash (as BOTH `comment` and `h`)
|
||||
// exactly when - and only when - the payRequest advertised the capability
|
||||
// (commentAllowed >= 64 or mintToHash): a new-generation mint
|
||||
// (lnurl-mint >= 0.4) refuses a quote without it, while an old mint must
|
||||
// see the byte-identical request this wallet always sent.
|
||||
// only when the payRequest advertises commentAllowed >= 64. The wallet's
|
||||
// output secret must be durably staged before that quote request is sent;
|
||||
// mintToHash alone is only an additive receipt extension, not permission
|
||||
// to mint.
|
||||
|
||||
import {afterEach, describe, expect, it} from 'vitest'
|
||||
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
|
||||
@@ -47,6 +47,7 @@ const settleLastInvoice = async (m: Mint): Promise<string> => {
|
||||
}
|
||||
|
||||
const MINT_PUBKEY = `02${'ab'.repeat(32)}`
|
||||
const persistOutput = async (): Promise<void> => undefined
|
||||
|
||||
// a mint-address (LUD-25) wire response exactly as lnurl-mint serves it:
|
||||
// node stats under their WIRE names - `nodeCapacity` is msat like every
|
||||
@@ -123,7 +124,7 @@ describe('mint-address wire contract', () => {
|
||||
|
||||
it("surfaces the mock mint's mint-address node stats through prepareMint", async () => {
|
||||
const m = await mint()
|
||||
const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000)
|
||||
const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000, {persistOutput})
|
||||
// the metadata is advertised at the mint-address endpoint itself -
|
||||
// the payRequest never carried it
|
||||
expect(prepared.nodeInfo?.nodePubkey).toBe(m.state.pubkey)
|
||||
@@ -140,7 +141,7 @@ describe('withdraw-link forms', () => {
|
||||
// conformance 0.7.0 defaults to plain https links; lnurlw:// is an
|
||||
// explicit LUD-17 transport form
|
||||
const m = await mint({testHooks: true, withdrawLinkForm: 'lnurlw'})
|
||||
const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000)
|
||||
const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000, {persistOutput})
|
||||
expect(prepared.withdrawLink).toMatch(/^lnurlw:\/\//)
|
||||
|
||||
// and the link is fully usable: settle the invoice, claim the note
|
||||
@@ -150,9 +151,9 @@ describe('withdraw-link forms', () => {
|
||||
intervalCapMs: 50,
|
||||
maxWaitMs: 5_000,
|
||||
})
|
||||
expect(claimed.rotated).toBe(true)
|
||||
expect(claimed.note.amount).toBe(21_000)
|
||||
expect(m.state.noteState(preimage)).toBe('burned')
|
||||
expect(m.state.noteState(preimage)).toBeNull()
|
||||
expect(m.state.noteState(prepared.noteSecret)).toBe('outstanding')
|
||||
})
|
||||
|
||||
it('accepts an HTTPS withdraw link', async () => {
|
||||
@@ -166,13 +167,14 @@ describe('withdraw-link forms', () => {
|
||||
minSendable: 1_000,
|
||||
maxSendable: 100_000_000_000,
|
||||
withdrawLink: 'https://mint.example/note',
|
||||
commentAllowed: 64,
|
||||
metadata: '[]',
|
||||
},
|
||||
],
|
||||
// amount-less invoice: the kit skips its amount cross-check
|
||||
['https://mint.example/pay', {pr: 'lnmock1fixture', verify: null}],
|
||||
])
|
||||
const prepared = await prepareMint('mint@mint.example', 21_000, {fetch})
|
||||
const prepared = await prepareMint('mint@mint.example', 21_000, {fetch, persistOutput})
|
||||
expect(prepared.withdrawLink).toBe('https://mint.example/note')
|
||||
// the mint-address payLink is authoritative - the payRequest came from it
|
||||
expect(prepared.mintUrl).toBe('https://mint.example/.well-known/lnurlp/mint')
|
||||
@@ -210,27 +212,39 @@ describe('quote output-naming wire contract', () => {
|
||||
return new URL(hit)
|
||||
}
|
||||
|
||||
it('sends no comment/h to a mint that never advertised output naming', async () => {
|
||||
it('rejects a mint that cannot carry the mandatory output commitment', async () => {
|
||||
const seen: string[] = []
|
||||
const prepared = await prepareMint('mint@mint.example', 21_000, {
|
||||
fetch: capturingFetch(routesFor(payRequestFixture({})), seen),
|
||||
})
|
||||
expect(prepared.mode).toBe('unnamed')
|
||||
expect(prepared.noteSecret).toBeUndefined()
|
||||
// an old mint sees the byte-identical request this wallet always sent
|
||||
const url = callbackUrl(seen)
|
||||
expect(url.searchParams.get('comment')).toBeNull()
|
||||
expect(url.searchParams.get('h')).toBeNull()
|
||||
await expect(
|
||||
prepareMint('mint@mint.example', 21_000, {
|
||||
fetch: capturingFetch(routesFor(payRequestFixture({})), seen),
|
||||
persistOutput,
|
||||
}),
|
||||
).rejects.toThrow(/64-character output commitment/i)
|
||||
expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false)
|
||||
})
|
||||
|
||||
it('sends comment AND h, both sha256 of the wallet secret, when commentAllowed >= 64', async () => {
|
||||
it('persists the wallet output before sending its hash as comment and h', async () => {
|
||||
const seen: string[] = []
|
||||
const events: string[] = []
|
||||
const prepared = await prepareMint('mint@mint.example', 21_000, {
|
||||
fetch: capturingFetch(routesFor(payRequestFixture({commentAllowed: 64})), seen),
|
||||
fetch: (input, init) => {
|
||||
const url =
|
||||
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
|
||||
events.push(url.startsWith('https://mint.example/pay?') ? 'invoice' : 'discovery')
|
||||
return capturingFetch(
|
||||
routesFor(payRequestFixture({commentAllowed: 64, mintPubkey: `03${'cd'.repeat(32)}`})),
|
||||
seen,
|
||||
)(input, init)
|
||||
},
|
||||
persistOutput: async (note) => {
|
||||
events.push('persist')
|
||||
expect(note.verified).toBe(false)
|
||||
expect(note.callback).toBe('')
|
||||
expect(note.amount).toBe(0)
|
||||
expect(note.pendingMint?.mintPubkey).toBe(MINT_PUBKEY)
|
||||
},
|
||||
})
|
||||
expect(prepared.mode).toBe('named')
|
||||
const noteSecret = prepared.noteSecret
|
||||
if (!noteSecret) throw new Error('named mode without a note secret')
|
||||
const url = callbackUrl(seen)
|
||||
// both spellings go out together: `comment` is the LUD-25 form
|
||||
// lnurl-mint >= 0.4 REQUIRES, `h` the earlier form other mints read
|
||||
@@ -239,16 +253,30 @@ describe('quote output-naming wire contract', () => {
|
||||
expect(comment).toMatch(/^[0-9a-f]{64}$/)
|
||||
expect(h).toBe(comment)
|
||||
expect(comment).toBe(hashK1(noteSecret))
|
||||
expect(prepared.mintPubkey).toBe(MINT_PUBKEY)
|
||||
expect(events.indexOf('persist')).toBeGreaterThanOrEqual(0)
|
||||
expect(events.indexOf('persist')).toBeLessThan(events.indexOf('invoice'))
|
||||
})
|
||||
|
||||
it('names the output when only the mintToHash spelling is advertised', async () => {
|
||||
it('does not request an invoice when output persistence fails', async () => {
|
||||
const seen: string[] = []
|
||||
const prepared = await prepareMint('mint@mint.example', 21_000, {
|
||||
fetch: capturingFetch(routesFor(payRequestFixture({mintToHash: true})), seen),
|
||||
})
|
||||
expect(prepared.mode).toBe('named')
|
||||
const noteSecret = prepared.noteSecret
|
||||
if (!noteSecret) throw new Error('named mode without a note secret')
|
||||
expect(callbackUrl(seen).searchParams.get('h')).toBe(hashK1(noteSecret))
|
||||
await expect(
|
||||
prepareMint('mint@mint.example', 21_000, {
|
||||
fetch: capturingFetch(routesFor(payRequestFixture({commentAllowed: 64})), seen),
|
||||
persistOutput: () => Promise.reject(new Error('storage full')),
|
||||
}),
|
||||
).rejects.toThrow(/storage full/)
|
||||
expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects mintToHash without the mandatory comment capacity', async () => {
|
||||
const seen: string[] = []
|
||||
await expect(
|
||||
prepareMint('mint@mint.example', 21_000, {
|
||||
fetch: capturingFetch(routesFor(payRequestFixture({mintToHash: true})), seen),
|
||||
persistOutput,
|
||||
}),
|
||||
).rejects.toThrow(/64-character output commitment/i)
|
||||
expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user