import {describe, expect, it} from 'vitest' import {noteK1} from 'lnurlcash-kit' import type {CarveResult} from './ops' import {ensureExactAmount, UnsupportedMultiBatchMergeError} from './ops' import {requiredValue} from './test-utils' import {makeBearer, mint, secret} from './ops.testHarness' const allocatedSecrets = (...secrets: string[]): ((server: string, count: number) => Promise) => { let served = false return (_server, count) => { if (served) throw new Error('The test expected a single allocation.') served = true return Promise.resolve(secrets.slice(0, count)) } } describe('ensureExactAmount crash safety', () => { it('stages both split secrets before either hash reaches the mint', async () => { const instance = await mint() const sourceSecret = secret('70') const targetSecret = secret('71') const changeSecret = secret('72') const bearer = await makeBearer(instance, sourceSecret, 21_000) let staged: CarveResult | undefined const observingFetch: typeof fetch = (input, init) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url if (url.includes('/w/cb')) { expect(staged).toBeDefined() expect(noteK1(requiredValue(staged).note.url)).toBe(targetSecret) expect(noteK1(requiredValue(requiredValue(staged).change).url)).toBe(changeSecret) expect(instance.state.noteState(sourceSecret)).toBe('outstanding') } return fetch(input, init) } const result = await ensureExactAmount([bearer], 5_000, { fetch: observingFetch, allocateOutputSecrets: allocatedSecrets(targetSecret, changeSecret), onCarve: (prepared) => { staged = prepared }, }) expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id]) }) it('stages the merge secret before its hash reaches the mint', async () => { const instance = await mint() const firstSecret = secret('73') const secondSecret = secret('74') const mergedSecret = secret('75') const first = await makeBearer(instance, firstSecret, 3_000) const second = await makeBearer(instance, secondSecret, 4_000) let staged: CarveResult | undefined const observingFetch: typeof fetch = (input, init) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url if (url.includes('/w/cb')) { expect(staged).toBeDefined() expect(noteK1(requiredValue(staged).note.url)).toBe(mergedSecret) expect(instance.state.noteState(firstSecret)).toBe('outstanding') expect(instance.state.noteState(secondSecret)).toBe('outstanding') } return fetch(input, init) } await ensureExactAmount([first, second], 7_000, { fetch: observingFetch, allocateOutputSecrets: allocatedSecrets(mergedSecret), onCarve: (prepared) => { staged = prepared }, }) expect(staged?.consumed.map((entry) => entry.id)).toEqual([first.id, second.id]) }) it('does not expose a staged split output to a second ambiguous rotation', async () => { const instance = await mint() const bearer = await makeBearer(instance, secret('76'), 21_000) let mutationCount = 0 const dropSecondMutation: typeof fetch = async (input, init) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url if (!url.includes('/w/cb')) return fetch(input, init) mutationCount += 1 const response = await fetch(input, init) if (mutationCount === 2) { await response.arrayBuffer() throw new Error('second mutation response lost') } return response } const result = await ensureExactAmount([bearer], 5_000, { fetch: dropSecondMutation, mutationRetries: 0, allocateOutputSecrets: allocatedSecrets(secret('77'), secret('78')), onCarve: () => undefined, }) expect(mutationCount).toBe(1) const targetSecret = requiredValue(noteK1(result.note.url)) const changeSecret = requiredValue(noteK1(requiredValue(result.change).url)) expect(instance.state.noteState(targetSecret)).toBe('outstanding') expect(instance.state.noteState(changeSecret)).toBe('outstanding') }) it('rejects a multi-batch merge before any partial fold can land', async () => { const instance = await mint() const bearers = await Promise.all( Array.from({length: 21}, (_, index) => makeBearer(instance, secret((128 + index).toString(16)), 1_000), ), ) let mutationCount = 0 const countingFetch: typeof fetch = (input, init) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url if (url.includes('/w/cb')) mutationCount += 1 return fetch(input, init) } const failure = ensureExactAmount(bearers, 21_000, { fetch: countingFetch, onCarve: () => undefined, }) await expect(failure).rejects.toBeInstanceOf(UnsupportedMultiBatchMergeError) expect(mutationCount).toBe(0) expect( bearers.every( (bearer) => instance.state.noteState(requiredValue(noteK1(bearer.url))) === 'outstanding', ), ).toBe(true) }) it('checks ownership again immediately before a merge mutation', async () => { const instance = await mint() const firstSecret = secret('79') const secondSecret = secret('7a') const first = await makeBearer(instance, firstSecret, 3_000) const second = await makeBearer(instance, secondSecret, 4_000) let ownerChecks = 0 let mutationCount = 0 const countingFetch: typeof fetch = (input, init) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url if (url.includes('/w/cb')) mutationCount += 1 return fetch(input, init) } await expect( ensureExactAmount([first, second], 7_000, { fetch: countingFetch, allocateOutputSecrets: allocatedSecrets(secret('7b')), assertOwner: () => { ownerChecks += 1 if (ownerChecks === 3) throw new Error('wallet owner changed') }, onCarve: () => undefined, }), ).rejects.toThrow(/owner changed/) expect(mutationCount).toBe(0) expect(instance.state.noteState(firstSecret)).toBe('outstanding') expect(instance.state.noteState(secondSecret)).toBe('outstanding') }) })