Files
sattle/src/lnurlcash/ops.signature.cases.ts
T

160 lines
6.2 KiB
TypeScript

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')
})
})