Formatting

This commit is contained in:
2026-09-05 18:30:35 +02:00
parent 454a9db1d5
commit 4caa075052
133 changed files with 8260 additions and 8181 deletions
+199 -206
View File
@@ -1,15 +1,12 @@
import {describe, expect, it} from 'vitest'
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import {hashK1, noteK1} from 'lnurlcash-kit'
import { describe, expect, it } from 'vitest';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { hashK1, noteK1 } from 'lnurlcash-kit';
import {transferBetweenMints as transferBetweenMintsEngine} from './ops'
import {
OutputSecretAllocationRequiredError,
TransferMeltCheckpointRequiredError,
} from './ops'
import type {CarveResult, TransferOptions} from './ops'
import type {Bearer} from './types'
import {requiredValue} from './test-utils'
import { transferBetweenMints as transferBetweenMintsEngine } from './ops';
import { OutputSecretAllocationRequiredError, TransferMeltCheckpointRequiredError } from './ops';
import type { CarveResult, TransferOptions } from './ops';
import type { Bearer } from './types';
import { requiredValue } from './test-utils';
import {
allocateOutputSecrets,
expectBurned,
@@ -18,7 +15,7 @@ import {
persistOutput,
secret,
settleWhenRequested,
} from './ops.testHarness'
} from './ops.testHarness';
const transferBetweenMints = (
bearers: Bearer[],
@@ -31,313 +28,309 @@ const transferBetweenMints = (
onMeltReady: () => undefined,
...options,
persistOutput,
})
});
describe('transferBetweenMints', () => {
const fastPoll = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}
const fastPoll = { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 };
it('draws the target and source secrets from the caller allocation path', async () => {
const source = await mint()
const target = await mint({testHooks: true})
const bearer = await makeBearer(source, secret('91'), 21_000)
const targetSecret = secret('92')
const sourceRecoverySecret = secret('93')
const allocations: Array<{server: string; count: number}> = []
const meltReadiness: string[] = []
const source = await mint();
const target = await mint({ testHooks: true });
const bearer = await makeBearer(source, secret('91'), 21_000);
const targetSecret = secret('92');
const sourceRecoverySecret = secret('93');
const allocations: Array<{ server: string; count: number }> = [];
const meltReadiness: string[] = [];
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll,
allocateOutputSecrets: (server, count) => {
allocations.push({server, count})
const secrets = allocations.length === 1 ? [targetSecret] : [sourceRecoverySecret]
return Promise.resolve(secrets.slice(0, count))
allocations.push({ server, count });
const secrets = allocations.length === 1 ? [targetSecret] : [sourceRecoverySecret];
return Promise.resolve(secrets.slice(0, count));
},
onMeltReady: (_carve, recovery) => {
meltReadiness.push(recovery)
meltReadiness.push(recovery);
},
})
await settleWhenRequested(target)
const result = await pending
});
await settleWhenRequested(target);
const result = await pending;
expect(result.outcome).toBe('settled')
expect(result.outcome).toBe('settled');
// the staged target note first, then the source recovery secret - an
// exact source carve allocates nothing
expect(allocations).toEqual([
{server: `127.0.0.1:${target.port}`, count: 1},
{server: `127.0.0.1:${source.port}`, count: 1},
])
expect(meltReadiness).toEqual([sourceRecoverySecret])
expect(noteK1(requiredValue(result.mintedAtTarget).note.url)).toBe(targetSecret)
})
{ server: `127.0.0.1:${target.port}`, count: 1 },
{ server: `127.0.0.1:${source.port}`, count: 1 },
]);
expect(meltReadiness).toEqual([sourceRecoverySecret]);
expect(noteK1(requiredValue(result.mintedAtTarget).note.url)).toBe(targetSecret);
});
it('refuses the source melt without its durable checkpoint', async () => {
const source = await mint()
const target = await mint({testHooks: true})
const bearer = await makeBearer(source, secret('98'), 21_000)
const source = await mint();
const target = await mint({ testHooks: true });
const bearer = await makeBearer(source, secret('98'), 21_000);
await expect(
transferBetweenMintsEngine([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
allocateOutputSecrets,
persistOutput,
}),
).rejects.toBeInstanceOf(TransferMeltCheckpointRequiredError)
).rejects.toBeInstanceOf(TransferMeltCheckpointRequiredError);
// the target stage happened (invoice exists) but the source note is
// untouched and nothing melted
expect(source.state.noteState(secret('98'))).toBe('outstanding')
})
expect(source.state.noteState(secret('98'))).toBe('outstanding');
});
it('refuses at target staging without an allocation path', async () => {
const source = await mint()
const target = await mint({testHooks: true})
const bearer = await makeBearer(source, secret('99'), 21_000)
const source = await mint();
const target = await mint({ testHooks: true });
const bearer = await makeBearer(source, secret('99'), 21_000);
await expect(
transferBetweenMintsEngine([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
onMeltReady: () => undefined,
persistOutput,
}),
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError)
expect(source.state.noteState(secret('99'))).toBe('outstanding')
})
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
expect(source.state.noteState(secret('99'))).toBe('outstanding');
});
it('moves value to another mint at a wallet-chosen target secret', async () => {
const source = await mint()
const target = await mint({testHooks: true})
const k1 = secret('40')
const bearer = await makeBearer(source, k1, 21_000)
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/)
});
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.note.amount).toBe(21_000)
expect(claimed.note.verified).toBe(true)
expect(target.state.noteState(preimage)).toBeNull()
const newK1 = requiredValue(noteK1(claimed.note.url))
expect(newK1).not.toBe(preimage)
expect(target.state.noteState(newK1)).toBe('outstanding')
})
});
expect(result.sourceServer).not.toBe(result.targetServer);
await expectBurned(source, k1);
const claimed = requiredValue(result.mintedAtTarget);
expect(claimed.note.amount).toBe(21_000);
expect(claimed.note.verified).toBe(true);
expect(target.state.noteState(preimage)).toBeNull();
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)
let persisted = false
const source = await mint();
const target = await mint();
const k1 = secret('41');
const bearer = await makeBearer(source, k1, 5_000);
let persisted = false;
await expect(
transferBetweenMintsEngine([bearer], 50_000, `mint@127.0.0.1:${target.port}`, {
persistOutput: () => {
persisted = true
persisted = true;
},
}),
).rejects.toThrow(/enough/)
expect(persisted).toBe(false)
expect(source.state.noteState(k1)).toBe('outstanding')
})
).rejects.toThrow(/enough/);
expect(persisted).toBe(false);
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)
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')
})
).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')
})
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('reports a definitively spent source before waiting on the target', async () => {
const source = await mint()
const target = await mint()
const k1 = secret('54')
const bearer = await makeBearer(source, k1, 21_000)
source.state.settleMelt(k1)
const source = await mint();
const target = await mint();
const k1 = secret('54');
const bearer = await makeBearer(source, k1, 21_000);
source.state.settleMelt(k1);
const result = await transferBetweenMints(
[bearer],
21_000,
`mint@127.0.0.1:${target.port}`,
)
const result = await transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`);
expect(result.outcome).toBe('note-already-spent')
})
expect(result.outcome).toBe('note-already-spent');
});
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 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)
})
});
await settleWhenRequested(target);
const result = await pending;
expect(result.outcome).toBe('settled');
await expectBurned(source, k1);
expect(result.mintedAtTarget?.note.amount).toBe(21_000);
});
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)
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')
})
).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 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')
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');
// the returned funds come back re-secured at the source-probe rotate's
// fresh secret - carried as rotatedNote, NOT folded into the carve
// (which an onCarve checkpoint may already have committed)
const rotatedNote = requiredValue(result.rotatedNote)
expect(rotatedNote.verified).toBe(true)
const returnedK1 = requiredValue(noteK1(rotatedNote.url))
expect(returnedK1).not.toBe(k1)
expect(source.state.noteState(returnedK1)).toBe('outstanding')
expect(rotatedNote.amount).toBe(21_000)
})
const rotatedNote = requiredValue(result.rotatedNote);
expect(rotatedNote.verified).toBe(true);
const returnedK1 = requiredValue(noteK1(rotatedNote.url));
expect(returnedK1).not.toBe(k1);
expect(source.state.noteState(returnedK1)).toBe('outstanding');
expect(rotatedNote.amount).toBe(21_000);
});
it('lands the target note at the staged wallet secret without rotating', async () => {
const source = await mint()
const target = await mint({testHooks: true})
const k1 = secret('48')
const bearer = await makeBearer(source, k1, 21_000)
const source = await mint();
const target = await mint({ testHooks: true });
const k1 = secret('48');
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')
await expectBurned(source, k1)
const claimed = requiredValue(result.mintedAtTarget)
expect(claimed.note.amount).toBe(21_000)
expect(claimed.note.verified).toBe(true)
const newK1 = requiredValue(noteK1(claimed.note.url))
});
const preimage = await settleWhenRequested(target);
const result = await pending;
expect(result.outcome).toBe('settled');
await expectBurned(source, k1);
const claimed = requiredValue(result.mintedAtTarget);
expect(claimed.note.amount).toBe(21_000);
expect(claimed.note.verified).toBe(true);
const newK1 = requiredValue(noteK1(claimed.note.url));
// the note stands at the wallet's own secret - the quote was bound to
// its hash, and the payment preimage keys nothing at the target
expect([...target.state.invoices.values()].at(-1)?.boundTo).toBe(hashK1(newK1))
expect(target.state.noteState(newK1)).toBe('outstanding')
expect(target.state.noteState(preimage)).toBeNull()
})
expect([...target.state.invoices.values()].at(-1)?.boundTo).toBe(hashK1(newK1));
expect(target.state.noteState(newK1)).toBe('outstanding');
expect(target.state.noteState(preimage)).toBeNull();
});
it('keeps the named claim material when the target never credits the note', async () => {
const source = await mint({meltNeverSettles: true})
const target = await mint({testHooks: true})
const k1 = secret('49')
const bearer = await makeBearer(source, k1, 21_000)
const source = await mint({ meltNeverSettles: true });
const target = await mint({ testHooks: true });
const k1 = secret('49');
const bearer = await makeBearer(source, k1, 21_000);
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300},
})
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
});
// the target invoice exists (the quote is bound) but the source melt
// never lands, so nothing is ever credited at the wallet's secret
while (target.state.invoices.size === 0) {
await new Promise((resolve) => setTimeout(resolve, 10))
await new Promise((resolve) => setTimeout(resolve, 10));
}
const result = await pending
expect(result.outcome).toBe('unknown-still-pending')
expect(result.mintedAtTarget).toBeUndefined()
const result = await pending;
expect(result.outcome).toBe('unknown-still-pending');
expect(result.mintedAtTarget).toBeUndefined();
// the wallet's secret is the way back to the money if the melt's
// payment arrives later - and the note at that secret is tracked
// unverified, so a late settlement is never a lost note
const noteSecret = requiredValue(result.claimMaterial?.noteSecret)
expect(noteSecret).toMatch(/^[0-9a-f]{64}$/)
const material = requiredValue(result.claimMaterial?.note)
expect(material.verified).toBe(false)
expect(noteK1(material.url)).toBe(noteSecret)
})
const noteSecret = requiredValue(result.claimMaterial?.noteSecret);
expect(noteSecret).toMatch(/^[0-9a-f]{64}$/);
const material = requiredValue(result.claimMaterial?.note);
expect(material.verified).toBe(false);
expect(noteK1(material.url)).toBe(noteSecret);
});
it('fires onCarve before the carve or melt reaches the mint', async () => {
const source = await mint()
const target = await mint({testHooks: true})
const bearer = await makeBearer(source, secret('51'), 21_000)
const seen: CarveResult[] = []
const source = await mint();
const target = await mint({ testHooks: true });
const bearer = await makeBearer(source, secret('51'), 21_000);
const seen: CarveResult[] = [];
const pending = transferBetweenMints([bearer], 10_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll,
onCarve: (carve) => {
seen.push(carve)
seen.push(carve);
if (carve.consumed.length === 0) {
expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBeNull()
expect(source.state.noteState(requiredValue(noteK1(bearer.url)))).toBe('outstanding')
expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBeNull();
expect(source.state.noteState(requiredValue(noteK1(bearer.url)))).toBe('outstanding');
} else {
expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBe('outstanding')
expect(source.state.noteState(requiredValue(noteK1(bearer.url)))).toBe('burned')
expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBe('outstanding');
expect(source.state.noteState(requiredValue(noteK1(bearer.url)))).toBe('burned');
}
},
})
await settleWhenRequested(target)
const result = await pending
expect(result.outcome).toBe('settled')
expect(seen).toHaveLength(2)
expect(seen[0]?.consumed).toEqual([])
expect(seen[1]?.consumed.map((entry) => entry.id)).toEqual([bearer.id])
})
});
await settleWhenRequested(target);
const result = await pending;
expect(result.outcome).toBe('settled');
expect(seen).toHaveLength(2);
expect(seen[0]?.consumed).toEqual([]);
expect(seen[1]?.consumed.map((entry) => entry.id)).toEqual([bearer.id]);
});
it('aborts before the melt when the carve commit hook fails', async () => {
const source = await mint()
const target = await mint({testHooks: true})
const k1 = secret('52')
const bearer = await makeBearer(source, k1, 21_000)
const source = await mint();
const target = await mint({ testHooks: true });
const k1 = secret('52');
const bearer = await makeBearer(source, k1, 21_000);
await expect(
transferBetweenMints([bearer], 10_000, `mint@127.0.0.1:${target.port}`, {
onCarve: () => {
throw new Error('commit failed')
throw new Error('commit failed');
},
}),
).rejects.toThrow(/commit failed/)
).rejects.toThrow(/commit failed/);
// The checkpoint failed before either split or melt reached the source.
expect(source.state.noteState(k1)).toBe('outstanding')
expect([...source.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true)
})
expect(source.state.noteState(k1)).toBe('outstanding');
expect([...source.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true);
});
it('aborts before an exact-note melt when the final checkpoint fails', async () => {
const source = await mint()
const target = await mint()
const k1 = secret('53')
const bearer = await makeBearer(source, k1, 21_000)
const source = await mint();
const target = await mint();
const k1 = secret('53');
const bearer = await makeBearer(source, k1, 21_000);
await expect(
transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
onMeltReady: () => {
throw new Error('source link commit failed')
throw new Error('source link commit failed');
},
}),
).rejects.toThrow(/source link commit failed/)
).rejects.toThrow(/source link commit failed/);
expect(source.state.noteState(k1)).toBe('outstanding')
})
})
expect(source.state.noteState(k1)).toBe('outstanding');
});
});