feat: inter-mint transfer op with fee quote and ambiguity-safe outcomes

This commit is contained in:
2026-08-19 22:27:14 +02:00
parent 7e091cd6ed
commit 142734b903
4 changed files with 499 additions and 4 deletions
+199 -1
View File
@@ -25,7 +25,8 @@ import {
ensureExactAmount,
payWithBearers,
prepareMint,
receiveBearer
receiveBearer,
transferBetweenMints
} from './ops'
type Mint = Awaited<ReturnType<typeof createMockMint>>
@@ -81,6 +82,26 @@ const settleLastInvoice = async (m: Mint): Promise<string> => {
return m.state.invoices.get(paymentHash)!.preimage
}
// waits for a mint to have an invoice at all, then settles it - for flows
// that request the invoice deep inside a single awaited call (transfer),
// where the test has to play the arriving payment mid-flight
const settleWhenRequested = async (m: Mint): Promise<string> => {
for (let i = 0; i < 200 && m.state.invoices.size === 0; i++) {
await new Promise(resolve => setTimeout(resolve, 10))
}
return settleLastInvoice(m)
}
// the mock burns a melted note 20ms after the melt - a transfer can
// resolve off the TARGET's settlement faster than that, so source-burn
// assertions wait for the mock's own timer instead of racing it
const expectBurned = async (m: Mint, k1: string): Promise<void> => {
for (let i = 0; i < 200 && m.state.noteState(k1) !== 'burned'; i++) {
await new Promise(resolve => setTimeout(resolve, 10))
}
expect(m.state.noteState(k1)).toBe('burned')
}
describe('ensureExactAmount', () => {
it('returns an already-exact note untouched, burning nothing', async () => {
const m = await mint()
@@ -406,3 +427,180 @@ describe('payWithBearers', () => {
).rejects.toThrow(/not a valid/i)
})
})
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}
)
// the transfer is now waiting on the target invoice settling - the
// mock mints can't actually pay each other, so the settle hook plays
// the melt's payment arriving
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 = result.mintedAtTarget!
expect(claimed.rotated).toBe(true)
expect(claimed.note.amount).toBe(21_000)
expect(claimed.note.verified).toBe(true)
// the preimage is the secret the target mint necessarily saw - after
// the rotate it is worthless there, and the wallet's fresh secret is
// the only live note
expect(target.state.noteState(preimage)).toBe('burned')
const newK1 = 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 m = await mint()
const k1 = secret('42')
const bearer = await makeBearer(m, k1, 21_000)
await expect(
transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${m.port}`)
).rejects.toThrow(/different target/)
expect(m.state.noteState(k1)).toBe('outstanding')
})
it('moves nothing when the target mint is unreachable', async () => {
const source = await mint()
// not via the mint() helper - a dead server stays out of afterEach
const dead = await createMockMint()
const deadAddress = `mint@127.0.0.1:${dead.port}`
await dead.close()
const k1 = secret('43')
// a note LARGER than the transfer amount, so a premature carve would
// show up here as a burn
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 () => {
// unconfirmedMutation: the melt's response confirms nothing, so the
// melt's outcome is uncertain - the target invoice settling is the
// transfer's ground truth
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')
// the melt had landed despite its lost answer - the source note is
// gone, and the target note came out the other end
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 () => {
// echoWrongK1: the target settles the invoice and reveals the
// preimage, but its informational GET then breaks the claim
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)
// the preimage IS the note secret - surfaced unverified, not lost
const note = result.claimMaterial?.note
expect(note).toBeDefined()
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')
// covers the requested net exactly - but not the grossed-up invoice
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()
// the classification rotate re-secured the note (the melt had put its
// k1 on the wire): the old secret is burned, the fresh one in the
// result is outstanding at the full amount
expect(source.state.noteState(k1)).toBe('burned')
const returnedK1 = 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)
})
})