mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
feat(lnurlcash): probe would-be outputs before believing a mutation refusal
The redeem callback is a GET and HTTP stacks retry GETs, so a mint can execute the first attempt and refuse the repeat as an already-spent input (LUD-25 says SHOULD replay; real mints refuse). The kit attaches the fresh output secrets to every refusal - probeMutationOutput tells a landed mutation from a genuine refusal. The harness wrapper reproduces the retry against the mock.
This commit is contained in:
@@ -69,3 +69,22 @@ export const expectBurned = async (instance: Mint, k1: string): Promise<void> =>
|
|||||||
}
|
}
|
||||||
expect(instance.state.noteState(k1)).toBe('burned')
|
expect(instance.state.noteState(k1)).toBe('burned')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a fetch wrapper that answers every /w/cb request with the response to
|
||||||
|
// its SECOND, byte-identical send - the HTTP-stack GET retry a real client
|
||||||
|
// is subject to: the mint executes the first attempt and (with the mock's
|
||||||
|
// default retriedMutation: 'refuse', which is also lnurl-mint's behavior)
|
||||||
|
// refuses the repeat as an already-spent input, which is all the caller
|
||||||
|
// ever sees
|
||||||
|
export const retryingCbFetch = (): typeof fetch => {
|
||||||
|
const impl: typeof fetch = async (input, init) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
|
||||||
|
if (url.includes('/w/cb')) {
|
||||||
|
const first = await fetch(input, init)
|
||||||
|
await first.arrayBuffer()
|
||||||
|
return fetch(input, init)
|
||||||
|
}
|
||||||
|
return fetch(input, init)
|
||||||
|
}
|
||||||
|
return impl
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
// every flow that waits on a payment uses, and the uncertainty type a lost
|
// every flow that waits on a payment uses, and the uncertainty type a lost
|
||||||
// mutation answer surfaces as.
|
// mutation answer surfaces as.
|
||||||
|
|
||||||
import {fetchInvoiceVerification} from 'lnurlcash-kit'
|
import {fetchInvoiceVerification, fetchNoteInfo, withNewK1} from 'lnurlcash-kit'
|
||||||
import type {LnurlcashOptions, VerifyResult} from 'lnurlcash-kit'
|
import type {LnurlcashOptions, VerifyResult} from 'lnurlcash-kit'
|
||||||
|
import {NoteSpentError, NoteUnknownError, noteDeclaredAmount} from 'lnurlcash-kit'
|
||||||
import type {NewBearer} from '../types'
|
import type {NewBearer} from '../types'
|
||||||
|
|
||||||
// a mutation's answer was lost AND the probe could not tell whether it
|
// a mutation's answer was lost AND the probe could not tell whether it
|
||||||
@@ -36,6 +37,32 @@ export const assertFundOwner = (options: FundOperationOptions): void => {
|
|||||||
options.assertOwner?.()
|
options.assertOwner?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// what probing a would-be mutation output can tell
|
||||||
|
export type OutputProbe = 'live' | 'absent' | 'unknown'
|
||||||
|
|
||||||
|
// A refused mutation can still have LANDED: the redeem callback is a GET
|
||||||
|
// and HTTP stacks retry GETs, so the service may have executed the first
|
||||||
|
// attempt and refused this one as an already-spent input (LUD-25 says a
|
||||||
|
// byte-identical retry SHOULD get the original success replayed; real
|
||||||
|
// mints refuse instead). The kit attaches the fresh output secrets to
|
||||||
|
// every service refusal (newSecretsOf) - so before believing a refusal,
|
||||||
|
// probe one would-be output: 'live' proves the mutation landed and the
|
||||||
|
// carried secrets are the only money left, 'absent' proves the refusal is
|
||||||
|
// genuine, 'unknown' keeps it genuinely ambiguous.
|
||||||
|
export const probeMutationOutput = async (
|
||||||
|
noteUrl: string,
|
||||||
|
secret: string,
|
||||||
|
options: LnurlcashOptions = {},
|
||||||
|
): Promise<OutputProbe> => {
|
||||||
|
try {
|
||||||
|
await fetchNoteInfo(withNewK1(noteUrl, secret, noteDeclaredAmount(noteUrl) ?? 0), options)
|
||||||
|
return 'live'
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NoteSpentError || err instanceof NoteUnknownError) return 'absent'
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type PollOptions = {
|
export type PollOptions = {
|
||||||
// first delay between checks (doubles each round up to intervalCapMs)
|
// first delay between checks (doubles each round up to intervalCapMs)
|
||||||
intervalMs?: number
|
intervalMs?: number
|
||||||
|
|||||||
Reference in New Issue
Block a user