diff --git a/src/lnurlcash/ops.transfer.cases.ts b/src/lnurlcash/ops.transfer.cases.ts new file mode 100644 index 0000000..6cedf05 --- /dev/null +++ b/src/lnurlcash/ops.transfer.cases.ts @@ -0,0 +1,136 @@ +import {describe, expect, it} from 'vitest' +import {createMockMint} from 'lnurlcash-conformance/mock-mint' +import {noteK1} from 'lnurlcash-kit' + +import {transferBetweenMints} from './ops' +import {requiredValue} from './test-utils' +import {expectBurned, makeBearer, mint, secret, settleWhenRequested} from './ops.testHarness' + +describe('transferBetweenMints', () => { + const fastPoll = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000} + + it('moves value to another mint: melt at source, claim + rotate at target', async () => { + const source = await mint() + const target = await mint({testHooks: true}) + const k1 = secret('40') + const bearer = await makeBearer(source, k1, 21_000) + const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { + poll: fastPoll, + }) + const preimage = await settleWhenRequested(target) + const result = await pending + expect(result.outcome).toBe('settled') + expect(result.invoice).toMatch(/^lnbc/) + expect(result.quote).toEqual({ + requestedMsat: 21_000, + grossMsat: 21_000, + targetMintFeeMsat: 0, + sourceMeltFeeReserveMsat: 0, + }) + expect(result.sourceServer).not.toBe(result.targetServer) + await expectBurned(source, k1) + const claimed = requiredValue(result.mintedAtTarget) + expect(claimed.rotated).toBe(true) + expect(claimed.note.amount).toBe(21_000) + expect(claimed.note.verified).toBe(true) + expect(target.state.noteState(preimage)).toBe('burned') + const newK1 = requiredValue(noteK1(claimed.note.url)) + expect(newK1).not.toBe(preimage) + expect(target.state.noteState(newK1)).toBe('outstanding') + }) + + it('refuses an amount no source mint can cover', async () => { + const source = await mint() + const target = await mint() + const k1 = secret('41') + const bearer = await makeBearer(source, k1, 5_000) + await expect( + transferBetweenMints([bearer], 50_000, `mint@127.0.0.1:${target.port}`), + ).rejects.toThrow(/enough/) + expect(source.state.noteState(k1)).toBe('outstanding') + }) + + it('rejects a transfer onto the mint the notes are already on', async () => { + const instance = await mint() + const k1 = secret('42') + const bearer = await makeBearer(instance, k1, 21_000) + await expect( + transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${instance.port}`), + ).rejects.toThrow(/different target/) + expect(instance.state.noteState(k1)).toBe('outstanding') + }) + + it('moves nothing when the target mint is unreachable', async () => { + const source = await mint() + const dead = await createMockMint() + const deadAddress = `mint@127.0.0.1:${dead.port}` + await dead.close() + const k1 = secret('43') + const bearer = await makeBearer(source, k1, 50_000) + await expect(transferBetweenMints([bearer], 21_000, deadAddress)).rejects.toThrow() + expect(source.state.noteState(k1)).toBe('outstanding') + }) + + it('recovers from a melt whose answer was lost once the target invoice settles', async () => { + const source = await mint({unconfirmedMutation: true}) + const target = await mint({testHooks: true}) + const k1 = secret('44') + const bearer = await makeBearer(source, k1, 21_000) + const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { + poll: fastPoll, + }) + await settleWhenRequested(target) + const result = await pending + expect(result.outcome).toBe('settled') + await expectBurned(source, k1) + expect(result.mintedAtTarget?.note.amount).toBe(21_000) + expect(result.mintedAtTarget?.rotated).toBe(true) + }) + + it('surfaces the claimable preimage note when the claim fails after a settled melt', async () => { + const source = await mint() + const target = await mint({testHooks: true, echoWrongK1: true}) + const k1 = secret('45') + const bearer = await makeBearer(source, k1, 21_000) + const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { + poll: fastPoll, + }) + const preimage = await settleWhenRequested(target) + const result = await pending + expect(result.outcome).toBe('settled-claim-failed') + await expectBurned(source, k1) + const note = requiredValue(result.claimMaterial?.note) + expect(noteK1(note.url)).toBe(preimage) + expect(note.verified).toBe(false) + expect(note.amount).toBe(21_000) + expect(result.claimMaterial?.withdrawLink).toContain(`${target.port}`) + }) + + it('grosses the carve up for the target mint fee, refusing when only the net is covered', async () => { + const source = await mint() + const target = await mint({baseFeeMsat: 1_000, feePpm: 2_000}) + const k1 = secret('46') + const bearer = await makeBearer(source, k1, 100_000) + await expect( + transferBetweenMints([bearer], 100_000, `mint@127.0.0.1:${target.port}`), + ).rejects.toThrow(/enough/) + expect(source.state.noteState(k1)).toBe('outstanding') + }) + + it('restores the source note, re-secured, when the melt fails', async () => { + const source = await mint({meltAlwaysFails: true}) + const target = await mint({testHooks: true}) + const k1 = secret('47') + const bearer = await makeBearer(source, k1, 21_000) + const result = await transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { + poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, + }) + expect(result.outcome).toBe('failed-funds-returned') + expect(result.mintedAtTarget).toBeUndefined() + expect(source.state.noteState(k1)).toBe('burned') + const returnedK1 = requiredValue(noteK1(result.carve.note.url)) + expect(returnedK1).not.toBe(k1) + expect(source.state.noteState(returnedK1)).toBe('outstanding') + expect(result.carve.note.amount).toBe(21_000) + }) +}) diff --git a/src/lnurlcash/ops/transfer.ts b/src/lnurlcash/ops/transfer.ts index 793976e..db49837 100644 --- a/src/lnurlcash/ops/transfer.ts +++ b/src/lnurlcash/ops/transfer.ts @@ -24,7 +24,7 @@ import { rotateNote, sameInvoice, serverOf, - withNewK1 + withNewK1, } from 'lnurlcash-kit' import type {LnurlcashOptions} from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' @@ -33,7 +33,8 @@ import {ensureExactAmount} from './carve' import type {ClaimedNote} from './mint' import {claimFromPreimage, prepareMint} from './mint' import type {PollOptions} from './shared' -import {pollVerifyUntilSettled} from './shared' +import type {FundOperationOptions} from './shared' +import {assertFundOwner, pollVerifyUntilSettled} from './shared' export type TransferOutcome = // the melt settled and the target note was claimed (and rotated) @@ -102,15 +103,16 @@ export type TransferOptions = { poll?: PollOptions // kit transport overrides (fetch injection, timeouts) kit?: LnurlcashOptions + assertOwner?: () => void } export const transferBetweenMints = async ( bearers: Bearer[], amountMsat: number, targetMint: string, - {poll = {}, kit = {}}: TransferOptions = {} + {poll = {}, kit = {}, assertOwner}: TransferOptions = {}, ): Promise => { - const options = kit + const options: FundOperationOptions = assertOwner ? {...kit, assertOwner} : kit if (!Number.isInteger(amountMsat) || amountMsat <= 0) { throw new Error('Amount must be a positive whole number of msat.') } @@ -120,7 +122,7 @@ export const transferBetweenMints = async ( const prepared = await prepareMint(targetMint, amountMsat, options) if (!prepared.verifyUrl) { throw new Error( - 'The target mint did not advertise a verify URL - a transfer there cannot auto-claim.' + 'The target mint did not advertise a verify URL - a transfer there cannot auto-claim.', ) } const verifyUrl = prepared.verifyUrl @@ -129,19 +131,17 @@ export const transferBetweenMints = async ( // goes nowhere (melt pays an invoice; the same mint's invoice just // re-mints into itself, paying fees for nothing) const eligible = bearers.filter( - b => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url) + (b) => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url), ) - const offTarget = eligible.filter(b => serverOf(b.url) !== targetServer) + const offTarget = eligible.filter((b) => serverOf(b.url) !== targetServer) if (eligible.length > 0 && offTarget.length === 0) { - throw new Error( - 'That\'s the mint these notes are already on - pick a different target.' - ) + throw new Error("That's the mint these notes are already on - pick a different target.") } const quote: TransferQuote = { requestedMsat: amountMsat, grossMsat: prepared.grossMsat, targetMintFeeMsat: prepared.grossMsat - amountMsat, - sourceMeltFeeReserveMsat: 0 + sourceMeltFeeReserveMsat: 0, } // carving burns its inputs server-side, so it happens only once the // target is known good and the invoice exists @@ -151,12 +151,13 @@ export const transferBetweenMints = async ( const claimMaterial: TransferClaimMaterial = { invoice, withdrawLink: prepared.withdrawLink, - expectedNoteValueMsat: prepared.expectedNoteValueMsat + expectedNoteValueMsat: prepared.expectedNoteValueMsat, } // from here on the carve's fresh secrets exist only in this result - the // flow never throws again; every outcome carries them const base = {carve, quote, invoice, verifyUrl, sourceServer, targetServer} const k1 = requireNoteK1(carve.note.url) + if (carve.consumed.length === 0) assertFundOwner(options) try { await meltNote(carve.note.callback, k1, invoice, options) } catch (err) { @@ -197,20 +198,16 @@ export const transferBetweenMints = async ( // the melt settled - the money is now the preimage note at the // target and nowhere else; surface it rather than lose it const note: NewBearer = { - url: buildNoteUrl( - prepared.withdrawLink, - proof.preimage, - prepared.expectedNoteValueMsat - ), + url: buildNoteUrl(prepared.withdrawLink, proof.preimage, prepared.expectedNoteValueMsat), callback: '', amount: prepared.expectedNoteValueMsat, - verified: false + verified: false, } if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey return { ...base, outcome: 'settled-claim-failed', - claimMaterial: {...claimMaterial, note} + claimMaterial: {...claimMaterial, note}, } } } catch { @@ -229,14 +226,9 @@ export const transferBetweenMints = async ( ...carve, note: { ...carve.note, - url: withNewK1( - carve.note.url, - rotated.k1, - carve.note.amount, - rotated.signature - ) - } - } + url: withNewK1(carve.note.url, rotated.k1, carve.note.amount, rotated.signature), + }, + }, } } catch (err) { if (err instanceof PendingNoteError || err instanceof NoteSpentError) { @@ -251,7 +243,7 @@ export const transferBetweenMints = async ( url: withNewK1(carve.note.url, err.newSecrets[0], carve.note.amount), callback: carve.note.callback, amount: carve.note.amount, - verified: false + verified: false, } if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey return {...base, outcome: 'failed-funds-returned', rescuedNote}