mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
fix: fence inter-mint transfer operations
This commit is contained in:
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
rotateNote,
|
rotateNote,
|
||||||
sameInvoice,
|
sameInvoice,
|
||||||
serverOf,
|
serverOf,
|
||||||
withNewK1
|
withNewK1,
|
||||||
} from 'lnurlcash-kit'
|
} from 'lnurlcash-kit'
|
||||||
import type {LnurlcashOptions} from 'lnurlcash-kit'
|
import type {LnurlcashOptions} from 'lnurlcash-kit'
|
||||||
import type {Bearer, NewBearer} from '../types'
|
import type {Bearer, NewBearer} from '../types'
|
||||||
@@ -33,7 +33,8 @@ import {ensureExactAmount} from './carve'
|
|||||||
import type {ClaimedNote} from './mint'
|
import type {ClaimedNote} from './mint'
|
||||||
import {claimFromPreimage, prepareMint} from './mint'
|
import {claimFromPreimage, prepareMint} from './mint'
|
||||||
import type {PollOptions} from './shared'
|
import type {PollOptions} from './shared'
|
||||||
import {pollVerifyUntilSettled} from './shared'
|
import type {FundOperationOptions} from './shared'
|
||||||
|
import {assertFundOwner, pollVerifyUntilSettled} from './shared'
|
||||||
|
|
||||||
export type TransferOutcome =
|
export type TransferOutcome =
|
||||||
// the melt settled and the target note was claimed (and rotated)
|
// the melt settled and the target note was claimed (and rotated)
|
||||||
@@ -102,15 +103,16 @@ export type TransferOptions = {
|
|||||||
poll?: PollOptions
|
poll?: PollOptions
|
||||||
// kit transport overrides (fetch injection, timeouts)
|
// kit transport overrides (fetch injection, timeouts)
|
||||||
kit?: LnurlcashOptions
|
kit?: LnurlcashOptions
|
||||||
|
assertOwner?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const transferBetweenMints = async (
|
export const transferBetweenMints = async (
|
||||||
bearers: Bearer[],
|
bearers: Bearer[],
|
||||||
amountMsat: number,
|
amountMsat: number,
|
||||||
targetMint: string,
|
targetMint: string,
|
||||||
{poll = {}, kit = {}}: TransferOptions = {}
|
{poll = {}, kit = {}, assertOwner}: TransferOptions = {},
|
||||||
): Promise<TransferResult> => {
|
): Promise<TransferResult> => {
|
||||||
const options = kit
|
const options: FundOperationOptions = assertOwner ? {...kit, assertOwner} : kit
|
||||||
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
|
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
|
||||||
throw new Error('Amount must be a positive whole number of msat.')
|
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)
|
const prepared = await prepareMint(targetMint, amountMsat, options)
|
||||||
if (!prepared.verifyUrl) {
|
if (!prepared.verifyUrl) {
|
||||||
throw new Error(
|
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
|
const verifyUrl = prepared.verifyUrl
|
||||||
@@ -129,19 +131,17 @@ export const transferBetweenMints = async (
|
|||||||
// goes nowhere (melt pays an invoice; the same mint's invoice just
|
// goes nowhere (melt pays an invoice; the same mint's invoice just
|
||||||
// re-mints into itself, paying fees for nothing)
|
// re-mints into itself, paying fees for nothing)
|
||||||
const eligible = bearers.filter(
|
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) {
|
if (eligible.length > 0 && offTarget.length === 0) {
|
||||||
throw new Error(
|
throw new Error("That's the mint these notes are already on - pick a different target.")
|
||||||
'That\'s the mint these notes are already on - pick a different target.'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
const quote: TransferQuote = {
|
const quote: TransferQuote = {
|
||||||
requestedMsat: amountMsat,
|
requestedMsat: amountMsat,
|
||||||
grossMsat: prepared.grossMsat,
|
grossMsat: prepared.grossMsat,
|
||||||
targetMintFeeMsat: prepared.grossMsat - amountMsat,
|
targetMintFeeMsat: prepared.grossMsat - amountMsat,
|
||||||
sourceMeltFeeReserveMsat: 0
|
sourceMeltFeeReserveMsat: 0,
|
||||||
}
|
}
|
||||||
// carving burns its inputs server-side, so it happens only once the
|
// carving burns its inputs server-side, so it happens only once the
|
||||||
// target is known good and the invoice exists
|
// target is known good and the invoice exists
|
||||||
@@ -151,12 +151,13 @@ export const transferBetweenMints = async (
|
|||||||
const claimMaterial: TransferClaimMaterial = {
|
const claimMaterial: TransferClaimMaterial = {
|
||||||
invoice,
|
invoice,
|
||||||
withdrawLink: prepared.withdrawLink,
|
withdrawLink: prepared.withdrawLink,
|
||||||
expectedNoteValueMsat: prepared.expectedNoteValueMsat
|
expectedNoteValueMsat: prepared.expectedNoteValueMsat,
|
||||||
}
|
}
|
||||||
// from here on the carve's fresh secrets exist only in this result - the
|
// from here on the carve's fresh secrets exist only in this result - the
|
||||||
// flow never throws again; every outcome carries them
|
// flow never throws again; every outcome carries them
|
||||||
const base = {carve, quote, invoice, verifyUrl, sourceServer, targetServer}
|
const base = {carve, quote, invoice, verifyUrl, sourceServer, targetServer}
|
||||||
const k1 = requireNoteK1(carve.note.url)
|
const k1 = requireNoteK1(carve.note.url)
|
||||||
|
if (carve.consumed.length === 0) assertFundOwner(options)
|
||||||
try {
|
try {
|
||||||
await meltNote(carve.note.callback, k1, invoice, options)
|
await meltNote(carve.note.callback, k1, invoice, options)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -197,20 +198,16 @@ export const transferBetweenMints = async (
|
|||||||
// the melt settled - the money is now the preimage note at the
|
// the melt settled - the money is now the preimage note at the
|
||||||
// target and nowhere else; surface it rather than lose it
|
// target and nowhere else; surface it rather than lose it
|
||||||
const note: NewBearer = {
|
const note: NewBearer = {
|
||||||
url: buildNoteUrl(
|
url: buildNoteUrl(prepared.withdrawLink, proof.preimage, prepared.expectedNoteValueMsat),
|
||||||
prepared.withdrawLink,
|
|
||||||
proof.preimage,
|
|
||||||
prepared.expectedNoteValueMsat
|
|
||||||
),
|
|
||||||
callback: '',
|
callback: '',
|
||||||
amount: prepared.expectedNoteValueMsat,
|
amount: prepared.expectedNoteValueMsat,
|
||||||
verified: false
|
verified: false,
|
||||||
}
|
}
|
||||||
if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey
|
if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
outcome: 'settled-claim-failed',
|
outcome: 'settled-claim-failed',
|
||||||
claimMaterial: {...claimMaterial, note}
|
claimMaterial: {...claimMaterial, note},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -229,14 +226,9 @@ export const transferBetweenMints = async (
|
|||||||
...carve,
|
...carve,
|
||||||
note: {
|
note: {
|
||||||
...carve.note,
|
...carve.note,
|
||||||
url: withNewK1(
|
url: withNewK1(carve.note.url, rotated.k1, carve.note.amount, rotated.signature),
|
||||||
carve.note.url,
|
},
|
||||||
rotated.k1,
|
},
|
||||||
carve.note.amount,
|
|
||||||
rotated.signature
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof PendingNoteError || err instanceof NoteSpentError) {
|
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),
|
url: withNewK1(carve.note.url, err.newSecrets[0], carve.note.amount),
|
||||||
callback: carve.note.callback,
|
callback: carve.note.callback,
|
||||||
amount: carve.note.amount,
|
amount: carve.note.amount,
|
||||||
verified: false
|
verified: false,
|
||||||
}
|
}
|
||||||
if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey
|
if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey
|
||||||
return {...base, outcome: 'failed-funds-returned', rescuedNote}
|
return {...base, outcome: 'failed-funds-returned', rescuedNote}
|
||||||
|
|||||||
Reference in New Issue
Block a user