import {describe, expect, it} from 'vitest' import {noteK1, noteSignature} from 'lnurlcash-kit' import {ensureExactAmount} from './ops' import {requiredValue} from './test-utils' import { allocateOutputSecrets, makeBearer, mint, noteUrl, secret, signatureKeysFor, } from './ops.testHarness' import {fetchNoteInfo} from 'lnurlcash-kit' // Landed split/merge outputs carry the mint's signature in their URL (sig // param), and earn verified:true only when that signature checks against a // trusted CURRENT or PREVIOUS key for the mint. Invalid, missing, or // wrong-key signatures - and mints with no trusted keys at all - keep the // staged outputs unverified; the money is tracked either way (the notes // exist at the mint), only the offline-verifiable badge is withheld. describe('landed mutation signature verification', () => { it('verifies a landed split against the current key and preserves its signature', async () => { const instance = await mint() const bearer = await makeBearer(instance, secret('a0'), 21_000) const result = await ensureExactAmount([bearer], 5_000, { onCarve: () => undefined, allocateOutputSecrets, mintSignatureKeys: signatureKeysFor(instance), }) expect(result.note.verified).toBe(true) const signature = noteSignature(result.note.url) expect(signature).not.toBeNull() expect(noteSignature(requiredValue(result.change).url)).not.toBeNull() // change stays unverified (its true post-fee value is unknown until a // refresh) even though its signature is preserved expect(result.change?.verified).toBe(false) }) it('verifies a landed merge against the current key and preserves its signature', async () => { const instance = await mint() const first = await makeBearer(instance, secret('a1'), 3_000) const second = await makeBearer(instance, secret('a2'), 4_000) const result = await ensureExactAmount([first, second], 7_000, { onCarve: () => undefined, allocateOutputSecrets, mintSignatureKeys: signatureKeysFor(instance), }) expect(result.note.verified).toBe(true) expect(noteSignature(result.note.url)).not.toBeNull() }) it('verifies against the previous key after the mint rotated its signing key', async () => { // the mint signs every output under its RETIRED key while advertising // the current one - exactly the rekey window a holder must tolerate const instance = await mint({ previousPrivateKey: '22'.repeat(32), signWithPreviousKey: true, }) const bearer = await makeBearer(instance, secret('a3'), 21_000) const result = await ensureExactAmount([bearer], 5_000, { onCarve: () => undefined, allocateOutputSecrets, mintSignatureKeys: signatureKeysFor(instance), }) expect(instance.state.previousPubkeys).toHaveLength(1) expect(result.note.verified).toBe(true) expect(noteSignature(result.note.url)).not.toBeNull() }) it('rejects verification when the previous key is not trusted', async () => { const instance = await mint({ previousPrivateKey: '22'.repeat(32), signWithPreviousKey: true, }) const bearer = await makeBearer(instance, secret('a4'), 21_000) const result = await ensureExactAmount([bearer], 5_000, { onCarve: () => undefined, allocateOutputSecrets, // only the current key is trusted - the signature under the retired // key must not verify mintSignatureKeys: () => [instance.state.pubkey], }) expect(result.note.verified).toBe(false) // the note and its signature are still tracked, recoverable by refresh expect(noteSignature(result.note.url)).not.toBeNull() const partK1 = requiredValue(noteK1(result.note.url)) expect(instance.state.noteState(partK1)).toBe('outstanding') expect(instance.state.noteState(secret('a4'))).toBe('burned') }) it('keeps a wrong-key signature staged unverified', async () => { const instance = await mint() const bearer = await makeBearer(instance, secret('a5'), 21_000) const stranger = await mint({privateKey: '33'.repeat(32)}) const result = await ensureExactAmount([bearer], 5_000, { onCarve: () => undefined, allocateOutputSecrets, // the only trusted key on file belongs to a DIFFERENT mint mintSignatureKeys: () => [stranger.state.pubkey], }) expect(result.note.verified).toBe(false) expect(noteSignature(result.note.url)).not.toBeNull() expect(instance.state.noteState(secret('a5'))).toBe('burned') expect( instance.state.noteState(requiredValue(noteK1(result.note.url))), ).toBe('outstanding') }) it('keeps a landed split unsigned by the mint staged unverified and consumed', async () => { const instance = await mint({signatures: false}) const k1 = secret('a6') const bearer = await makeBearer(instance, k1, 21_000) const result = await ensureExactAmount([bearer], 5_000, { onCarve: () => undefined, allocateOutputSecrets, mintSignatureKeys: signatureKeysFor(instance), }) // the mutation landed (the kit refused the missing signature, the // rescue probe proved the burn): the inputs are retired, the staged // outputs are tracked at exactly their staged secrets, unverified expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id]) expect(result.note.verified).toBe(false) expect(noteSignature(result.note.url)).toBeNull() expect(instance.state.noteState(k1)).toBe('burned') const partK1 = requiredValue(noteK1(result.note.url)) expect(instance.state.noteState(partK1)).toBe('outstanding') expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000) }) it('never verifies a landed mutation without any trusted keys on file', async () => { const instance = await mint() const bearer = await makeBearer(instance, secret('a7'), 21_000) const result = await ensureExactAmount([bearer], 5_000, { onCarve: () => undefined, allocateOutputSecrets, // no key source at all: nothing may be trusted blindly }) expect(result.note.verified).toBe(false) expect(noteSignature(result.note.url)).not.toBeNull() expect( instance.state.noteState(requiredValue(noteK1(result.note.url))), ).toBe('outstanding') }) })