18 Commits
Author SHA1 Message Date
protom 9408cf91d6 Merge pull request #7 from tompro/release-please--branches--main--components--sattle
chore(main): release sattle 0.0.4
2026-09-04 10:25:01 +02:00
github-actions[bot] dc59f2a08d chore(main): release sattle 0.0.4 2026-08-31 12:58:10 +00:00
protom 09f7fe0287 Merge pull request #8 from tompro/fix/carve-checkpoint
fix: commit carves before the settlement wait (#6)
2026-08-31 14:57:49 +02:00
protom 9e37fee841 fix: commit send, pay and move carves at the checkpoint (#6)
The three composables commit the carve inside onCarve via the existing
commitCarve helper instead of after the flow, falling back to a URL
lookup for no-mutation carves (which fire no hook). On
failed-funds-returned with a rotation, the re-secured note is added
before the burned carved note is marked spent.
2026-08-31 14:48:03 +02:00
protom 587dd11ba8 fix(lnurlcash): commit carves before the settlement wait (#6)
A flow that carves and then waits minutes on a settlement poll used to
commit the carve only after the whole flow resolved - an abort mid-wait
left burned inputs spendable locally and stranded the fresh outputs.
ensureExactAmount now reports a mutation-bearing carve through the new
FundOperationOptions.onCarve checkpoint the moment its outcome is known;
payWithBearers and transferBetweenMints thread it through.

Because the carve may now be committed before the flows' classification
rotates run, those rotations no longer rewrite carve.note in the result -
returned funds come back as a separate verified rotatedNote, so the
committed carve and the re-secured note can both be persisted. NWC's
payChangeset adapts to the rotatedNote field; its own commit timing is
unchanged (follow-up).
2026-08-31 14:48:03 +02:00
protom f3d5cc4b31 Merge pull request #5 from tompro/feat/named-mint-outputs
feat: support LUD-25 named mint outputs (lnurl-mint v0.4.0)
2026-08-31 14:19:59 +02:00
protom 50b2427b8d fix(lnurlcash): rescue landed mutations from retry refusals
A split, merge or rotate whose answer is 'Invalid or already spent k1.'
may be the refusal of a retried request that already LANDED - believing
it strands the fresh outputs server-side and desyncs the wallet (the
input is burned at the mint but still spendable locally, so every later
operation fails). All three flows now probe a would-be output when a
refusal carries fresh secrets: live outputs are adopted (or surfaced
unverified when the probe can't tell), a genuine refusal still fails.
2026-08-31 14:03:15 +02:00
protom 48be23dceb feat(lnurlcash): probe would-be outputs before believing a mutation refusal
The redeem callback is a GET and HTTP stacks retry GETs, so a mint can
execute the first attempt and refuse the repeat as an already-spent input
(LUD-25 says SHOULD replay; real mints refuse). The kit attaches the fresh
output secrets to every refusal - probeMutationOutput tells a landed
mutation from a genuine refusal. The harness wrapper reproduces the retry
against the mock.
2026-08-31 14:03:15 +02:00
protom 4cc8e0aba2 fix: replace forgesworn with moneyer in the public mint suggestions
@mint.forgesworn.dev is shutting down; @moneyer.dev takes its place in the
curated quick-start list.
2026-08-31 13:31:36 +02:00
protom 6bb07ff9ec fix(lnurlcash): assert named claims land at the wallet's own secret
Review feedback: bind the claim answer to the secret that was polled
before building a note from it. The kit already guarantees this -
claimMintedNote returns the queried secret by construction and
fetchNoteInfo rejects a mismatched k1 echo - so the assert cannot trip
through the kit (and is therefore not separately testable); it exists so
a future kit regression fails loudly instead of tracking a wrong secret.
2026-08-31 12:54:46 +02:00
protom 637376e291 docs(lnurlcash): document the two mint output modes
Reword the rotate-after-claim invariant for named outputs and fix the
test-harness pointer (ops.testHarness.ts, not test-utils.ts).
2026-08-31 12:23:10 +02:00
protom 39a144cf50 feat(receive): accept named mints that serve no verify URL
Named mints claim through the wallet's own secret, so a missing LUD-21
verify URL no longer blocks receiving; unnamed mints still require it -
there the preimage it reveals IS the claim.
2026-08-31 12:23:10 +02:00
protom ae5064b69a feat(nwc): make_invoice against mints with wallet-named outputs
The named-mint branch claims by the wallet's secret and treats the verify
preimage as an ordinary payment receipt - but keeps the rotated guard
before handing it out, because the rescue path (a mint that credited the
preimage despite the binding) claims through claimFromPreimage, where a
failed rotate leaves that preimage a live note secret. possibleCopy is
committed exactly like the unnamed path.
2026-08-31 12:23:10 +02:00
protom c9963934aa feat(funds): keep the claimable note of a pending named transfer
An unknown-still-pending transfer to a named mint carries the note at the
wallet's own secret in its claim material - persist it unverified like the
settled-claim-failed path already does, instead of dropping it with the
result object.
2026-08-31 12:22:56 +02:00
protom 67b2122e52 feat(lnurlcash): transfer to mints with wallet-named outputs
A named target needs no verify URL: the note appearing at the wallet's own
secret is the settlement proof, and it is claimed without a rotate. The
claim material carries the secret - and the unverified note at it - from
the start, so a melt settling after the poll budget is never a lost note;
the source-note oracle classification is unchanged.
2026-08-31 12:22:56 +02:00
protom 58268b2921 feat(lnurlcash): claim mint outputs named by a wallet-chosen secret
lnurl-mint >= 0.4 refuses a mint quote without comment=sha256(secret) and
keys the note by that secret instead of the payment preimage. prepareMint
detects the capability per mint (commentAllowed >= 64 or mintToHash),
binds the quote to a wallet-drawn secret, and claimMintedNote polls the
note itself - no preimage, no rotate, since the secret never rode an
invoice. Mints that never advertised the capability get the byte-identical
old request and stay on the preimage path; a mint that took the hash but
credited the preimage is caught by the claim poll and rescued through the
quote's verify URL. The d.ts augments the conformance package's stale
published declarations for its comment-naming flags.
2026-08-31 12:22:45 +02:00
protom 9f4ae3b0f5 refactor(lnurlcash): generalize bounded polling beyond verify endpoints
pollVerifyUntilSettled keeps its exact behavior and messages, reimplemented
on a generic pollUntil so flows that watch something other than a LUD-21
verify (a named mint output appearing) share the same backoff, budget and
abort semantics.
2026-08-31 12:22:30 +02:00
protom bb147faef1 build(deps): bump lnurlcash-kit to 0.5.0 and lnurlcash-conformance to 0.4.0
The kit release line adds the LUD-25 output-naming protocol (comment/h on
the pay callback, claim polling, merge batching); the mock mint now
defaults to plain https withdraw links and serves the originally issued
invoice on verify, which the two adapted tests pinned the old shape of.
2026-08-31 12:22:21 +02:00
32 changed files with 1549 additions and 199 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
".": "0.0.3" ".": "0.0.4"
} }
+23
View File
@@ -1,5 +1,28 @@
# Changelog # Changelog
## [0.0.4](https://github.com/tompro/sattle/compare/sattle-v0.0.3...sattle-v0.0.4) (2026-08-31)
### Features
* **funds:** keep the claimable note of a pending named transfer ([c996393](https://github.com/tompro/sattle/commit/c9963934aa6bc384cd9742e099b2c72b1f69e400))
* **lnurlcash:** claim mint outputs named by a wallet-chosen secret ([58268b2](https://github.com/tompro/sattle/commit/58268b2921d05c29ed4df98891e5ab5069617dfb))
* **lnurlcash:** probe would-be outputs before believing a mutation refusal ([48be23d](https://github.com/tompro/sattle/commit/48be23dceb940b28166b242ff308600864b87dd2))
* **lnurlcash:** transfer to mints with wallet-named outputs ([67b2122](https://github.com/tompro/sattle/commit/67b2122e52399b85e4829a0b3b61224252ae8ebf))
* **nwc:** make_invoice against mints with wallet-named outputs ([ae5064b](https://github.com/tompro/sattle/commit/ae5064b69a545a9ade671b5d701d5399fe3517ed))
* **receive:** accept named mints that serve no verify URL ([39a144c](https://github.com/tompro/sattle/commit/39a144cf50d621908b79cc27c7c664f6f9fc369c))
* support LUD-25 named mint outputs (lnurl-mint v0.4.0) ([f3d5cc4](https://github.com/tompro/sattle/commit/f3d5cc4b31cf0dba845e3abc4fa1b108c34122e5))
### Bug Fixes
* commit carves before the settlement wait ([#6](https://github.com/tompro/sattle/issues/6)) ([09f7fe0](https://github.com/tompro/sattle/commit/09f7fe02872838253882451ce7ed1f818f4ace62))
* commit send, pay and move carves at the checkpoint ([#6](https://github.com/tompro/sattle/issues/6)) ([9e37fee](https://github.com/tompro/sattle/commit/9e37fee841ab1c8ecb6bf7e3d00dfd61414ce306))
* **lnurlcash:** assert named claims land at the wallet's own secret ([6bb07ff](https://github.com/tompro/sattle/commit/6bb07ff9ec5c69b01c1822471c24312b44707927))
* **lnurlcash:** commit carves before the settlement wait ([#6](https://github.com/tompro/sattle/issues/6)) ([587dd11](https://github.com/tompro/sattle/commit/587dd11ba8a413c12dde134b11b2040c9d383c86))
* **lnurlcash:** rescue landed mutations from retry refusals ([50b2427](https://github.com/tompro/sattle/commit/50b2427b8db07692a590ff2602a034385bbac7df))
* replace forgesworn with moneyer in the public mint suggestions ([4cc8e0a](https://github.com/tompro/sattle/commit/4cc8e0aba2bd7ef8e4ea7d1b0ee9b98f21d19ee7))
## [0.0.3](https://github.com/tompro/sattle/compare/sattle-v0.0.2...sattle-v0.0.3) (2026-08-24) ## [0.0.3](https://github.com/tompro/sattle/compare/sattle-v0.0.2...sattle-v0.0.3) (2026-08-24)
+10 -10
View File
@@ -1,12 +1,12 @@
{ {
"name": "sattle", "name": "sattle",
"version": "0.0.3", "version": "0.0.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sattle", "name": "sattle",
"version": "0.0.3", "version": "0.0.4",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@agicash/qr-scanner": "^0.1.2", "@agicash/qr-scanner": "^0.1.2",
@@ -23,7 +23,7 @@
"@quasar/extras": "^2.0.4", "@quasar/extras": "^2.0.4",
"@scure/bip32": "^2.3.0", "@scure/bip32": "^2.3.0",
"@scure/bip39": "^2.3.0", "@scure/bip39": "^2.3.0",
"lnurlcash-kit": "0.1.1", "lnurlcash-kit": "0.5.0",
"nostr-tools": "2.24.3", "nostr-tools": "2.24.3",
"pinia": "^4.0.2", "pinia": "^4.0.2",
"qrcode.vue": "^3.10.0", "qrcode.vue": "^3.10.0",
@@ -46,7 +46,7 @@
"eslint": "^10.8.0", "eslint": "^10.8.0",
"eslint-plugin-vue": "^10.8.0", "eslint-plugin-vue": "^10.8.0",
"globals": "^17.4.0", "globals": "^17.4.0",
"lnurlcash-conformance": "0.1.1", "lnurlcash-conformance": "0.4.0",
"postcss": "^8.5.8", "postcss": "^8.5.8",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"sass": "^1.102.0", "sass": "^1.102.0",
@@ -5961,9 +5961,9 @@
} }
}, },
"node_modules/lnurlcash-conformance": { "node_modules/lnurlcash-conformance": {
"version": "0.1.1", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/lnurlcash-conformance/-/lnurlcash-conformance-0.1.1.tgz", "resolved": "https://registry.npmjs.org/lnurlcash-conformance/-/lnurlcash-conformance-0.4.0.tgz",
"integrity": "sha512-12EXkom7x+tpkk4Pi2zanlMbDpA64hh+b5PKTPoKfahiWPG3GQbNusun1NHKpYjNwyevRISTP9487W+ZqEuEZw==", "integrity": "sha512-PsmjMrle5DE+OeTxLdBRD6ah63m9XPpNlPvB9f4FUdCtI6FbW6k5HcinMMt0mTkd9X9iOZHKG9SIqZ5HQJ0BDQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -5987,9 +5987,9 @@
} }
}, },
"node_modules/lnurlcash-kit": { "node_modules/lnurlcash-kit": {
"version": "0.1.1", "version": "0.5.0",
"resolved": "https://registry.npmjs.org/lnurlcash-kit/-/lnurlcash-kit-0.1.1.tgz", "resolved": "https://registry.npmjs.org/lnurlcash-kit/-/lnurlcash-kit-0.5.0.tgz",
"integrity": "sha512-O1rak4hcoKIBZdtbCMfQhCS4hp9HducJeJf8nEYxbPXtFm83EyPBqOAwWHYkzJMFKnyTqjJmvjjzfgL3ePQQOw==", "integrity": "sha512-ASX+/yduZjV9aLSoUXlP06dWNJ6Bt3IYrG0QoAqtvnC1h8lCs5rWVzw9RgmE0j1sZs16V2RicDlaSvON4JV8Pg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@noble/curves": "^2.3.0", "@noble/curves": "^2.3.0",
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "sattle", "name": "sattle",
"version": "0.0.3", "version": "0.0.4",
"description": "A Quasar Project", "description": "A Quasar Project",
"productName": "sattle", "productName": "sattle",
"author": "protom <office@protom.eu>", "author": "protom <office@protom.eu>",
@@ -34,7 +34,7 @@
"@quasar/extras": "^2.0.4", "@quasar/extras": "^2.0.4",
"@scure/bip32": "^2.3.0", "@scure/bip32": "^2.3.0",
"@scure/bip39": "^2.3.0", "@scure/bip39": "^2.3.0",
"lnurlcash-kit": "0.1.1", "lnurlcash-kit": "0.5.0",
"nostr-tools": "2.24.3", "nostr-tools": "2.24.3",
"pinia": "^4.0.2", "pinia": "^4.0.2",
"qrcode.vue": "^3.10.0", "qrcode.vue": "^3.10.0",
@@ -57,7 +57,7 @@
"eslint": "^10.8.0", "eslint": "^10.8.0",
"eslint-plugin-vue": "^10.8.0", "eslint-plugin-vue": "^10.8.0",
"globals": "^17.4.0", "globals": "^17.4.0",
"lnurlcash-conformance": "0.1.1", "lnurlcash-conformance": "0.4.0",
"postcss": "^8.5.8", "postcss": "^8.5.8",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"sass": "^1.102.0", "sass": "^1.102.0",
+30 -3
View File
@@ -7,7 +7,7 @@ import type { MintFee } from 'lnurlcash-kit';
import { transferBetweenMints } from '@/lnurlcash/ops'; import { transferBetweenMints } from '@/lnurlcash/ops';
import type { TransferOutcome } from '@/lnurlcash/ops'; import type { TransferOutcome } from '@/lnurlcash/ops';
import { maxNetForBalance, quoteMintFee } from '@/lnurlcash/fees'; import { maxNetForBalance, quoteMintFee } from '@/lnurlcash/fees';
import type { NewBearer } from '@/lnurlcash/types'; import type { Bearer, NewBearer } from '@/lnurlcash/types';
import { floorMsatToSat, msatToSats, satsToMsat, MSAT_PER_SAT } from '@/lnurlcash/units'; import { floorMsatToSat, msatToSats, satsToMsat, MSAT_PER_SAT } from '@/lnurlcash/units';
import { useWalletStore } from '@/stores/wallet'; import { useWalletStore } from '@/stores/wallet';
import { useMintsStore } from '@/stores/mints'; import { useMintsStore } from '@/stores/mints';
@@ -135,14 +135,28 @@ export const useMoveFundsPage = () => {
try { try {
const ownerFence = wallet.captureOwnerFence(); const ownerFence = wallet.captureOwnerFence();
const commitContext = { ownerFence, warn: warnCommitted }; const commitContext = { ownerFence, warn: warnCommitted };
// the carve commits the moment it lands server-side (onCarve), not
// after the melt + settlement wait - an abort mid-wait can then
// never leave burned inputs looking spendable or strand the outputs
const carveState: { committed?: Bearer } = {};
const transfer = await transferBetweenMints( const transfer = await transferBetweenMints(
wallet.bearers, wallet.bearers,
satsToMsat(sats), satsToMsat(sats),
targetInput.value, targetInput.value,
{ assertOwner: ownerFence }, {
assertOwner: ownerFence,
onCarve: async (carve) => {
carveState.committed = await commitCarve(wallet, carve, commitContext);
},
},
); );
stage.value = 'Confirming the result…'; stage.value = 'Confirming the result…';
const carved = await commitCarve(wallet, transfer.carve, commitContext); // a carve that mutated nothing fires no hook - the note is already
// tracked; look it up
const carved =
carveState.committed ??
wallet.bearers.find((bearer) => bearer.url === transfer.carve.note.url);
if (!carved) throw new Error('The carved note was not tracked.');
if (transfer.rescuedNote) { if (transfer.rescuedNote) {
await addCommittedBearers(wallet, [transfer.rescuedNote], commitContext); await addCommittedBearers(wallet, [transfer.rescuedNote], commitContext);
} }
@@ -163,6 +177,13 @@ export const useMoveFundsPage = () => {
); );
toast('positive', `Moved ${sats.toLocaleString()} sats.`); toast('positive', `Moved ${sats.toLocaleString()} sats.`);
} else if (transfer.outcome === 'failed-funds-returned') { } else if (transfer.outcome === 'failed-funds-returned') {
if (transfer.rotatedNote) {
// the source-probe rotate re-secured the returned funds at a
// fresh secret, burning the carved note the checkpoint already
// committed - add the new note BEFORE marking the old one spent
await addCommittedBearers(wallet, [transfer.rotatedNote], commitContext);
await wallet.markSpent(carved.id, ownerFence);
}
await activity.log( await activity.log(
'transfer', 'transfer',
`A ${sats.toLocaleString()} sat move to ${transfer.targetServer} failed - funds are back in your wallet.`, `A ${sats.toLocaleString()} sat move to ${transfer.targetServer} failed - funds are back in your wallet.`,
@@ -170,6 +191,12 @@ export const useMoveFundsPage = () => {
); );
} else if (transfer.outcome === 'unknown-still-pending') { } else if (transfer.outcome === 'unknown-still-pending') {
await wallet.markSpent(carved.id, ownerFence); await wallet.markSpent(carved.id, ownerFence);
// a named target's claim material carries the note at the wallet's
// own secret - claimable the moment the melt settles, so it is
// tracked unverified rather than lost with the result object
if (transfer.claimMaterial?.note) {
await addCommittedBearers(wallet, [transfer.claimMaterial.note], commitContext);
}
await activity.log( await activity.log(
'transfer', 'transfer',
`A move of ${sats.toLocaleString()} sats to ${transfer.targetServer} is still in flight - the note is locked.`, `A move of ${sats.toLocaleString()} sats to ${transfer.targetServer} is still in flight - the note is locked.`,
+22 -3
View File
@@ -5,6 +5,7 @@ import { decodeBolt11AmountMsat, isBolt11Invoice, resolveLnurlInput } from 'lnur
import { readClipboard } from '@/capabilities/clipboard'; import { readClipboard } from '@/capabilities/clipboard';
import { payWithBearers, UncertainOutcomeError } from '@/lnurlcash/ops'; import { payWithBearers, UncertainOutcomeError } from '@/lnurlcash/ops';
import type { PayOutcome } from '@/lnurlcash/ops'; import type { PayOutcome } from '@/lnurlcash/ops';
import type { Bearer } from '@/lnurlcash/types';
import { msatToSats, satsToMsat } from '@/lnurlcash/units'; import { msatToSats, satsToMsat } from '@/lnurlcash/units';
import { useWalletStore } from '@/stores/wallet'; import { useWalletStore } from '@/stores/wallet';
import { useActivityStore } from '@/stores/activity'; import { useActivityStore } from '@/stores/activity';
@@ -145,15 +146,26 @@ export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit
try { try {
ownerFence = wallet.captureOwnerFence(); ownerFence = wallet.captureOwnerFence();
const commitContext = { ownerFence, warn: warnCommitted }; const commitContext = { ownerFence, warn: warnCommitted };
// the carve commits the moment it lands server-side (onCarve), not
// after the settlement wait - an abort mid-wait can then never leave
// burned inputs looking spendable or strand the fresh outputs
const carveState: { committed?: Bearer } = {};
const onCarve = async (carve: Parameters<typeof commitCarve>[1]): Promise<void> => {
carveState.committed = await commitCarve(wallet, carve, commitContext);
};
const paid = await payWithBearers( const paid = await payWithBearers(
wallet.bearers, wallet.bearers,
payment.input, payment.input,
payment.kind === 'address' payment.kind === 'address'
? { amountMsat: payment.amountMsat, assertOwner: ownerFence } ? { amountMsat: payment.amountMsat, assertOwner: ownerFence, onCarve }
: { assertOwner: ownerFence }, : { assertOwner: ownerFence, onCarve },
); );
stage.value = 'Confirming the result…'; stage.value = 'Confirming the result…';
const committed = await commitCarve(wallet, paid.carve, commitContext); // a carve that mutated nothing (one note already held the exact
// amount) fires no hook - the note is already tracked; look it up
const committed =
carveState.committed ?? wallet.bearers.find((bearer) => bearer.url === paid.carve.note.url);
if (!committed) throw new Error('The carved note was not tracked.');
if (paid.rescuedNote) { if (paid.rescuedNote) {
await addCommittedBearers(wallet, [paid.rescuedNote], commitContext); await addCommittedBearers(wallet, [paid.rescuedNote], commitContext);
} }
@@ -166,6 +178,13 @@ export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit
toast('positive', `Paid ${sats} sats.`); toast('positive', `Paid ${sats} sats.`);
emit('sent'); emit('sent');
} else if (paid.outcome === 'failed-funds-returned') { } else if (paid.outcome === 'failed-funds-returned') {
if (paid.rotatedNote) {
// the classification rotate re-secured the returned funds at a
// fresh secret, burning the carved note the checkpoint already
// committed - add the new note BEFORE marking the old one spent
await addCommittedBearers(wallet, [paid.rotatedNote], commitContext);
await wallet.markSpent(committed.id, ownerFence);
}
await activity.log( await activity.log(
'transfer', 'transfer',
`A ${sats} sat payment failed - funds are back in your wallet.`, `A ${sats} sat payment failed - funds are back in your wallet.`,
+4 -1
View File
@@ -159,7 +159,10 @@ export const useReceiveLightningDialog = (
try { try {
const input = mintChoice.value === CUSTOM_MINT ? customMint.value.trim() : mintChoice.value; const input = mintChoice.value === CUSTOM_MINT ? customMint.value.trim() : mintChoice.value;
const next = await prepareMint(input, satsToMsat(sats)); const next = await prepareMint(input, satsToMsat(sats));
if (!next.verifyUrl) { // named mints claim through the wallet's own secret and may
// legitimately serve no verify URL; unnamed mints need one (the
// preimage it reveals IS the claim)
if (next.mode === 'unnamed' && !next.verifyUrl) {
formError.value = formError.value =
'This mint does not support automatic claiming, so sattle cannot receive from it. Choose a different mint.'; 'This mint does not support automatic claiming, so sattle cannot receive from it. Choose a different mint.';
return; return;
+18 -2
View File
@@ -82,10 +82,26 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) =
let ownerFence: WalletOwnerFence | undefined; let ownerFence: WalletOwnerFence | undefined;
try { try {
ownerFence = wallet.captureOwnerFence(); ownerFence = wallet.captureOwnerFence();
const fence = ownerFence;
// the carve commits the moment it lands server-side (onCarve), so
// even a crash right after can never strand the fresh outputs or
// leave burned inputs looking spendable
const carveState: { committed?: Bearer } = {};
const carve = await ensureExactAmount(wallet.bearers, satsToMsat(sats), { const carve = await ensureExactAmount(wallet.bearers, satsToMsat(sats), {
assertOwner: ownerFence, assertOwner: fence,
onCarve: async (landed) => {
carveState.committed = await commitCarve(wallet, landed, {
ownerFence: fence,
warn: warnCommitted,
}); });
const note = await commitCarve(wallet, carve, { ownerFence, warn: warnCommitted }); },
});
// a carve that mutated nothing (one note already held the exact
// amount) fires no hook - the note is already tracked; look it up
const committed =
carveState.committed ?? wallet.bearers.find((bearer) => bearer.url === carve.note.url);
if (!committed) throw new Error('The carved note was not tracked.');
const note = committed;
if (carve.change) { if (carve.change) {
await activity.log( await activity.log(
'split', 'split',
+12 -2
View File
@@ -6,7 +6,14 @@ lnurlcash-kit directly.
## FUND-CRITICAL INVARIANTS (non-negotiable) ## FUND-CRITICAL INVARIANTS (non-negotiable)
- Rotate on every receive, and immediately after claiming a fresh mint. - Rotate on every receive, and immediately after claiming a fresh mint
from an UNNAMED mint (one that keys the note by the payment preimage).
A NAMED mint (LUD-25 `comment`/`mintToHash`, required by lnurl-mint
≥ 0.4) credits the note to a wallet-chosen secret whose hash bound the
quote; that secret never rode an invoice, so the claim needs no rotate.
The claim poll itself is the placement proof: a mint that took the hash
but credited the preimage is caught there and rescued through the
quote's verify URL when one is served.
- A note's declared amount is a claim; the service's `maxWithdrawable` is - A note's declared amount is a claim; the service's `maxWithdrawable` is
authoritative. authoritative.
- A melt's "OK" only means the payment is in flight — the verify URL (or - A melt's "OK" only means the payment is in flight — the verify URL (or
@@ -61,4 +68,7 @@ test-utils.ts # mock mint harness used by *.test.ts
## TESTS ## TESTS
`npx vitest run` — node environment, `*.test.ts` next to the modules. `npx vitest run` — node environment, `*.test.ts` next to the modules.
Adversarial mock mint from lnurlcash-conformance via test-utils. Adversarial mock mint from lnurlcash-conformance via `ops.testHarness.ts`
(`conformance-mockmint.d.ts` augments its stale published declarations
with the comment-naming flags). The mock's `commentAllowed` option models
the named-mint generation; default options model the old one.
+30
View File
@@ -0,0 +1,30 @@
// The published lnurlcash-conformance 0.4.0 hand-written declarations
// (mock-mint/index.d.ts) are stale relative to their own index.mjs: the
// comment-naming flags are implemented and documented in the CHANGELOG
// ("The mock mint gains commentAllowed, commentRefusesMalformed and
// verifyOnUnnamedMint") but never made it into the type surface. This
// augmentation restores them so tests can drive the comment-named mint
// mode without `as any`. Report upstream; drop this file once a release
// ships the declarations.
//
// Semantics (from mock-mint/index.mjs):
// - commentAllowed: false (default) means the pay callback ignores the
// `comment` parameter entirely. A number advertises that many
// characters on the payRequest and reads `comment` on the callback: a
// bare 64-hex sha256 binds the minted note to the wallet's secret
// (k1 = secret, preimage opens nothing); anything else falls back to
// preimage keying and verify is withheld for that quote.
// - commentRefusesMalformed: non-compliant; refuses a non-hash comment
// instead of falling back.
// - verifyOnUnnamedMint: non-compliant; serves LUD-21 verify even on the
// no-comment fallback, where the preimage it hands out IS the note.
declare module 'lnurlcash-conformance/mock-mint' {
interface MockMintOptions {
commentAllowed?: number | false
commentRefusesMalformed?: boolean
verifyOnUnnamedMint?: boolean
}
}
export {}
+98 -10
View File
@@ -1,15 +1,21 @@
// Wire-contract coverage for the pinned lnurlcash-kit / lnurlcash-conformance // Wire-contract coverage for the pinned lnurlcash-kit 0.5.0 /
// 0.1.1 artifacts. Two contracts the app's mint discovery relies on: // lnurlcash-conformance 0.4.0 artifacts. Contracts the app's mint flows
// rely on:
// the kit must MAP the mint-address wire field `nodeCapacity` onto the // the kit must MAP the mint-address wire field `nodeCapacity` onto the
// app-facing `nodeCapacityMsat` (0.1.0 spread it under its wire name, so the // app-facing `nodeCapacityMsat` (0.1.0 spread it under its wire name, so
// typed field read undefined forever), and a payRequest withdraw link is // the typed field read undefined forever); a payRequest withdraw link is
// legal in both its HTTPS and LUD-17 `lnurlw://` forms - the published // legal in both its HTTPS and LUD-17 `lnurlw://` forms (the conformance
// conformance mock mint emits `lnurlw://` by default, so do NOT assume an // mock emits either, selected by its withdrawLinkForm option, so do NOT
// HTTPS default anywhere in the receive path. // assume a default anywhere in the receive path); and the LUD-06 quote
// request must carry the output-naming hash (as BOTH `comment` and `h`)
// exactly when - and only when - the payRequest advertised the capability
// (commentAllowed >= 64 or mintToHash): a new-generation mint
// (lnurl-mint >= 0.4) refuses a quote without it, while an old mint must
// see the byte-identical request this wallet always sent.
import {afterEach, describe, expect, it} from 'vitest' import {afterEach, describe, expect, it} from 'vitest'
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import {buildNoteUrl, fetchMintAddress} from 'lnurlcash-kit' import {buildNoteUrl, fetchMintAddress, hashK1} from 'lnurlcash-kit'
import {claimMintedNote, prepareMint} from './ops' import {claimMintedNote, prepareMint} from './ops'
import {mintAddressCacheInfo} from './trustedMints' import {mintAddressCacheInfo} from './trustedMints'
@@ -77,6 +83,20 @@ const fixtureFetch = (routes: ReadonlyArray<readonly [string, unknown]>): typeof
return impl return impl
} }
// fixtureFetch plus a record of every URL it saw, for canaries about the
// exact query the kit puts on the wire
const capturingFetch = (
routes: ReadonlyArray<readonly [string, unknown]>,
seen: string[],
): typeof fetch => {
const impl: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
seen.push(url)
return fixtureFetch(routes)(input, init)
}
return impl
}
describe('mint-address wire contract', () => { describe('mint-address wire contract', () => {
it('maps the wire nodeCapacity onto the app-facing nodeCapacityMsat', async () => { it('maps the wire nodeCapacity onto the app-facing nodeCapacityMsat', async () => {
const info = await fetchMintAddress('https://mint.example/.well-known/lnurlw/mint', { const info = await fetchMintAddress('https://mint.example/.well-known/lnurlw/mint', {
@@ -116,9 +136,10 @@ describe('mint-address wire contract', () => {
describe('withdraw-link forms', () => { describe('withdraw-link forms', () => {
it('accepts the lnurlw:// withdraw link the conformance mock mint advertises', async () => { it('accepts the lnurlw:// withdraw link the conformance mock mint advertises', async () => {
const m = await mint({testHooks: true}) // conformance 0.4.0 defaults to plain https links; lnurlw:// (the form
// lnurl-mint emits under LUD-17) is an explicit option
const m = await mint({testHooks: true, withdrawLinkForm: 'lnurlw'})
const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000) const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000)
// published conformance 0.1.1 emits lnurlw:// by default - NOT https
expect(prepared.withdrawLink).toMatch(/^lnurlw:\/\//) expect(prepared.withdrawLink).toMatch(/^lnurlw:\/\//)
// and the link is fully usable: settle the invoice, claim the note // and the link is fully usable: settle the invoice, claim the note
@@ -163,3 +184,70 @@ describe('withdraw-link forms', () => {
) )
}) })
}) })
describe('quote output-naming wire contract', () => {
const payRequestFixture = (extra: Record<string, unknown>) => ({
tag: 'payRequest',
callback: 'https://mint.example/pay',
minSendable: 1_000,
maxSendable: 100_000_000_000,
withdrawLink: 'https://mint.example/note',
metadata: '[]',
...extra,
})
const routesFor = (payRequest: Record<string, unknown>) =>
[
['https://mint.example/.well-known/lnurlw/mint', mintAddressFixture],
['https://mint.example/.well-known/lnurlp/mint', payRequest],
// amount-less invoice: the kit skips its amount cross-check
['https://mint.example/pay', {pr: 'lnmock1fixture', verify: null}],
] as const
const callbackUrl = (seen: string[]): URL => {
const hit = seen.find((url) => url.startsWith('https://mint.example/pay?'))
if (!hit) throw new Error('the quote request never hit the wire')
return new URL(hit)
}
it('sends no comment/h to a mint that never advertised output naming', async () => {
const seen: string[] = []
const prepared = await prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({})), seen),
})
expect(prepared.mode).toBe('unnamed')
expect(prepared.noteSecret).toBeUndefined()
// an old mint sees the byte-identical request this wallet always sent
const url = callbackUrl(seen)
expect(url.searchParams.get('comment')).toBeNull()
expect(url.searchParams.get('h')).toBeNull()
})
it('sends comment AND h, both sha256 of the wallet secret, when commentAllowed >= 64', async () => {
const seen: string[] = []
const prepared = await prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({commentAllowed: 64})), seen),
})
expect(prepared.mode).toBe('named')
const noteSecret = prepared.noteSecret
if (!noteSecret) throw new Error('named mode without a note secret')
const url = callbackUrl(seen)
// both spellings go out together: `comment` is the LUD-25 form
// lnurl-mint >= 0.4 REQUIRES, `h` the earlier form other mints read
const comment = url.searchParams.get('comment')
const h = url.searchParams.get('h')
expect(comment).toMatch(/^[0-9a-f]{64}$/)
expect(h).toBe(comment)
expect(comment).toBe(hashK1(noteSecret))
})
it('names the output when only the mintToHash spelling is advertised', async () => {
const seen: string[] = []
const prepared = await prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({mintToHash: true})), seen),
})
expect(prepared.mode).toBe('named')
const noteSecret = prepared.noteSecret
if (!noteSecret) throw new Error('named mode without a note secret')
expect(callbackUrl(seen).searchParams.get('h')).toBe(hashK1(noteSecret))
})
})
+124 -1
View File
@@ -11,7 +11,7 @@ import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
import {v2 as nip44v2} from 'nostr-tools/nip44' import {v2 as nip44v2} from 'nostr-tools/nip44'
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' import {buildNoteUrl, fetchNoteInfo, hashK1, noteK1} from 'lnurlcash-kit'
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import { import {
@@ -124,6 +124,129 @@ describe('service: make_invoice / lookup_invoice', () => {
await stop() await stop()
}) })
it('settles against a NAMED mint: note at the wallet secret, preimage a plain receipt', async () => {
const m = await mint({commentAllowed: 64, testHooks: true})
const {relay, walletServicePubkey, state, stop} = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
})
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
})
expect(made.error).toBeNull()
const paymentHash = made.result?.payment_hash
if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash')
}
const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
expect(settleRes.ok).toBe(true)
await waitFor(() => state.changesets.some((c) => c.add.length > 0))
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash,
})
expect(settled.error).toBeNull()
expect(settled.result?.state).toBe('settled')
// a named mint's verify preimage is an ordinary payment receipt -
// it keys nothing at the mint - so it is always safe to hand out
const preimage = requiredString(settled.result?.preimage)
expect(preimage).toMatch(/^[0-9a-f]{64}$/)
expect(m.state.noteState(preimage)).toBeNull()
// the note was claimed at the wallet's own secret - unrotated,
// because nothing exposed it
const minted = requiredValue(state.bearers.find((b) => b.id.startsWith('added-')))
expect(minted.amount).toBe(21_000)
expect(minted.verified).toBe(true)
const mintedK1 = requiredValue(noteK1(minted.url))
expect(m.state.noteState(mintedK1)).toBe('outstanding')
// the quote was bound to that secret's hash
expect([...m.state.invoices.values()].at(-1)?.boundTo).toBe(hashK1(mintedK1))
await stop()
})
it('rescues a NAMED mint that credited the preimage, and burns it before handing it out', async () => {
// the mintToHashIgnoresH adversary: advertises output naming, binds
// nothing, credits the payment hash - and (non-compliantly) still
// serves verify, so the preimage path can recover the note
const m = await mint({
commentAllowed: 64,
mintToHashIgnoresH: true,
verifyOnUnnamedMint: true,
testHooks: true,
})
const {relay, walletServicePubkey, state, stop} = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
claimPoll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 400},
})
const made = await call(relay, walletServicePubkey, 'make_invoice', {amount: 21_000})
const paymentHash = made.result?.payment_hash
if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash')
}
const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
expect(settleRes.ok).toBe(true)
await waitFor(() => state.changesets.some((c) => c.add.length > 0))
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash,
})
expect(settled.result?.state).toBe('settled')
// the rescue claimed through the preimage path and rotated off it, so
// the receipt handed to the client is a burned secret
const preimage = requiredString(settled.result?.preimage)
expect(m.state.noteState(preimage)).toBe('burned')
const minted = requiredValue(state.bearers.find((b) => b.id.startsWith('added-')))
const mintedK1 = requiredValue(noteK1(minted.url))
expect(mintedK1).not.toBe(preimage)
expect(m.state.noteState(mintedK1)).toBe('outstanding')
await stop()
})
it('withholds the preimage when the rescue claim could not rotate', async () => {
// same lying mint, but the rotate's request never lands (the mint
// dies between the claim and the rotate): the preimage may still be
// the note's secret, so it stays a secret even though the invoice
// reads settled
const m = await mint({
commentAllowed: 64,
mintToHashIgnoresH: true,
verifyOnUnnamedMint: true,
testHooks: true,
})
const rotateFailingFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
if (url.includes('/w/cb')) return Promise.reject(new Error('connection reset'))
return fetch(input, init)
}
const {relay, walletServicePubkey, state, stop} = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
claimPoll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 400},
kit: {fetch: rotateFailingFetch},
})
const made = await call(relay, walletServicePubkey, 'make_invoice', {amount: 21_000})
const paymentHash = made.result?.payment_hash
if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash')
}
const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
expect(settleRes.ok).toBe(true)
await waitFor(() => state.changesets.some((c) => c.add.length > 0))
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash,
})
expect(settled.result?.state).toBe('settled')
// settled, but NO receipt: an unrotated preimage may still be the note
expect(settled.result?.preimage).toBeUndefined()
// the note is tracked either way (it IS money)
expect(state.bearers.some((b) => b.id.startsWith('added-'))).toBe(true)
await stop()
})
it('keeps a paid invoice pending while its bearer commit is deferred', async () => { it('keeps a paid invoice pending while its bearer commit is deferred', async () => {
const m = await mint({testHooks: true}) const m = await mint({testHooks: true})
const commit = deferred() const commit = deferred()
+75 -13
View File
@@ -1,15 +1,18 @@
// The invoice registry the two-phase make_invoice / lookup_invoice pair // The invoice registry the two-phase make_invoice / lookup_invoice pair
// shares: make_invoice answers with the invoice immediately while this // shares: make_invoice answers with the invoice immediately while this
// module's settleAndClaim watches settlement in the background, claims // module's settleAndClaim watches settlement in the background, claims
// the minted note (rotating it immediately), and only THEN records the // the minted note, and only THEN records the settlement the lookup
// settlement the lookup reports. // reports.
// //
// THE PREIMAGE, in lnurlcash terms: the payment preimage IS the minted // THE PREIMAGE, in lnurlcash terms: on an UNNAMED mint the payment
// note's initial secret. NIP-47 clients expect it as the settlement // preimage IS the minted note's initial secret. NIP-47 clients expect it
// receipt, so it is handed out - but only after claimFromPreimage has // as the settlement receipt, so it is handed out - but only after
// rotated the fresh note, when that secret is burned and worthless. If // claimFromPreimage has rotated the fresh note, when that secret is
// the rotation failed (claimed.rotated === false) the preimage is // burned and worthless. If the rotation failed (claimed.rotated ===
// withheld: fund safety over spec comfort. // false) the preimage is withheld: fund safety over spec comfort. On a
// NAMED mint the note lives at a wallet-chosen secret from the start and
// the preimage opens nothing at all, so it is an ordinary payment receipt
// and always safe to hand out.
import {isPreimage, sameInvoice} from 'lnurlcash-kit' import {isPreimage, sameInvoice} from 'lnurlcash-kit'
import {sha256} from '@noble/hashes/sha2.js' import {sha256} from '@noble/hashes/sha2.js'
@@ -17,7 +20,7 @@ import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js'
import type {NewBearer} from '../types' import type {NewBearer} from '../types'
import type {PreparedMint} from '../ops' import type {PreparedMint} from '../ops'
import {claimFromPreimage} from '../ops/mint' import {claimFromPreimage, claimFromSecret} from '../ops/mint'
import {pollVerifyUntilSettled} from '../ops/shared' import {pollVerifyUntilSettled} from '../ops/shared'
import type {PollOptions} from '../ops/shared' import type {PollOptions} from '../ops/shared'
@@ -61,13 +64,14 @@ export const invoiceResult = (entry: PendingInvoice): Record<string, unknown> =>
} }
// the background half of make_invoice: watch the invoice, and once it // the background half of make_invoice: watch the invoice, and once it
// settles claim the note (rotating it immediately) and hand the fresh // settles claim the note (rotating it immediately on an unnamed mint) and
// bearer to the caller. Settlement is recorded LAST - after the rotate and // hand the fresh bearer to the caller. Settlement is recorded LAST - after
// bearer commit - so lookup can only reveal durably tracked funds and an // the claim and bearer commit - so lookup can only reveal durably tracked
// already-burned secret. // funds and, on an unnamed mint, an already-burned secret.
// Throws on any failure; the caller marks the entry failed and reports // Throws on any failure; the caller marks the entry failed and reports
// through deps.onError. // through deps.onError.
export const settleAndClaim = async (ctx: RequestContext, entry: PendingInvoice): Promise<void> => { export const settleAndClaim = async (ctx: RequestContext, entry: PendingInvoice): Promise<void> => {
if (entry.prepared.mode === 'named') return settleAndClaimNamed(ctx, entry)
if (!entry.prepared.verifyUrl) { if (!entry.prepared.verifyUrl) {
throw new Error( throw new Error(
'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.', 'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.',
@@ -114,3 +118,61 @@ export const settleAndClaim = async (ctx: RequestContext, entry: PendingInvoice)
entry.preimage = preimage entry.preimage = preimage
} }
} }
// the named-mint half of settleAndClaim: the note lands at the wallet's
// own secret, so its APPEARANCE there is the settlement observation and no
// rotate follows. When the mint also serves the quote's verify, that
// answer's preimage is kept as the NIP-47 receipt (an ordinary payment
// proof on a named mint); a named mint without verify yields a settled
// lookup with no preimage - correct, just receipt-less.
const settleAndClaimNamed = async (
ctx: RequestContext,
entry: PendingInvoice,
): Promise<void> => {
const noteSecret = entry.prepared.noteSecret
if (!noteSecret) {
throw new Error('This mint was prepared in named mode without a note secret.')
}
const prepared = entry.prepared
const claimPoll = ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL
let preimage: string | null = null
if (prepared.verifyUrl) {
// the interruptible observation half: watch settlement on verify,
// then claim by secret below (fund-critical, uninterruptible)
const result = await pollVerifyUntilSettled(
prepared.verifyUrl,
{...claimPoll, signal: ctx.stopSignal},
ctx.deps.kit ?? {},
)
if (!sameInvoice(result.pr, prepared.invoice)) {
throw new Error("The service's verify response is for a different invoice than requested.")
}
if (result.preimage && isPreimage(result.preimage)) preimage = result.preimage
}
// without a verify URL the claim poll IS the observation half, so it
// stays interruptible; after a verify-observed settlement the note is
// already there and this returns on the first check
const claimed = await claimFromSecret(
{...prepared, noteSecret},
prepared.verifyUrl ? claimPoll : {...claimPoll, signal: ctx.stopSignal},
{
...(ctx.deps.kit ?? {}),
assertOwner: ctx.assertOwner,
},
)
const add: NewBearer[] = [claimed.note]
if (claimed.possibleCopy) add.push(claimed.possibleCopy)
await ctx.deps.applyChangeset(
{add, markSpent: []},
ctx.connection(),
'make_invoice',
ctx.assertOwner,
)
entry.settledAt = ctx.nowSeconds()
entry.state = 'settled'
// the rotate guard still matters here: the rescue path (a mint that
// took the hash but credited the PREIMAGE) claims through
// claimFromPreimage, and if its rotate failed the preimage is still a
// LIVE note secret - withheld exactly like the unnamed path
if (preimage && claimed.rotated) entry.preimage = preimage
}
+7 -6
View File
@@ -23,18 +23,19 @@ import {errResult, okResult} from './protocol'
// maps a PayResult onto the store delta, mirroring PayInvoiceDialog's // maps a PayResult onto the store delta, mirroring PayInvoiceDialog's
// semantics: carve inputs lock spent, change is tracked, returned funds // semantics: carve inputs lock spent, change is tracked, returned funds
// are tracked UNMARKED, rescued secrets are tracked unverified. One // are tracked UNMARKED, rescued secrets are tracked unverified. The
// returned funds live in `rotatedNote` when the classification rotate
// re-secured them (burning the carved note in the process); one
// deliberate divergence: in the exact-match + funds-returned case the // deliberate divergence: in the exact-match + funds-returned case the
// original bearer (old k1, burned server-side by the classification // original bearer (old k1, burned server-side by that rotate) is left for
// rotate) is left for the next refresh to reconcile, exactly as the UI // the next refresh to reconcile, exactly as the UI leaves it - the money
// leaves it - the money itself sits in the re-secured note, which IS // itself sits in the re-secured note, which IS tracked.
// tracked.
export const payChangeset = (bearers: Bearer[], result: PayResult): NwcChangeset => { export const payChangeset = (bearers: Bearer[], result: PayResult): NwcChangeset => {
const add: NewBearer[] = [] const add: NewBearer[] = []
const markSpent: string[] = result.carve.consumed.map((b) => b.id) const markSpent: string[] = result.carve.consumed.map((b) => b.id)
if (result.carve.change) add.push(result.carve.change) if (result.carve.change) add.push(result.carve.change)
if (result.outcome === 'failed-funds-returned') { if (result.outcome === 'failed-funds-returned') {
add.push(result.carve.note) add.push(result.rotatedNote ?? result.carve.note)
} else { } else {
// settled / still-pending / already-spent: the carved note is gone or // settled / still-pending / already-spent: the carved note is gone or
// locked. When it was one of the wallet's own bearers (an exact-match // locked. When it was one of the wallet's own bearers (an exact-match
+41
View File
@@ -2,6 +2,7 @@ import {describe, expect, it} from 'vitest'
import {fetchNoteInfo, noteK1} from 'lnurlcash-kit' import {fetchNoteInfo, noteK1} from 'lnurlcash-kit'
import type {Bearer} from './types' import type {Bearer} from './types'
import type {CarveResult} from './ops'
import {UncertainOutcomeError, ensureExactAmount} from './ops' import {UncertainOutcomeError, ensureExactAmount} from './ops'
import {requiredValue} from './test-utils' import {requiredValue} from './test-utils'
import {makeBearer, mint, noteUrl, secret} from './ops.testHarness' import {makeBearer, mint, noteUrl, secret} from './ops.testHarness'
@@ -119,4 +120,44 @@ describe('ensureExactAmount', () => {
(await fetchNoteInfo(noteUrl(instance, requiredValue(noteK1(second.url))))).maxWithdrawable, (await fetchNoteInfo(noteUrl(instance, requiredValue(noteK1(second.url))))).maxWithdrawable,
).toBe(16_000) ).toBe(16_000)
}) })
it('reports a mutation-bearing carve through onCarve before returning', async () => {
const instance = await mint()
const bearer = await makeBearer(instance, secret('12'), 21_000)
const seen: CarveResult[] = []
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: (carve) => {
seen.push(carve)
},
})
// the hook saw exactly the returned result, once
expect(seen).toHaveLength(1)
expect(seen[0]).toBe(result)
expect(result.consumed).toHaveLength(1)
})
it('never fires onCarve for an already-exact note - nothing was burned', async () => {
const instance = await mint()
const bearer = await makeBearer(instance, secret('13'), 21_000)
let called = false
const result = await ensureExactAmount([bearer], 21_000, {
onCarve: () => {
called = true
},
})
expect(called).toBe(false)
expect(result.consumed).toEqual([])
})
it('propagates an onCarve failure - the carve landed, the caller must surface it', async () => {
const instance = await mint()
const bearer = await makeBearer(instance, secret('14'), 21_000)
await expect(
ensureExactAmount([bearer], 5_000, {
onCarve: () => {
throw new Error('commit failed')
},
}),
).rejects.toThrow(/commit failed/)
})
}) })
+121
View File
@@ -0,0 +1,121 @@
import {describe, expect, it} from 'vitest'
import {hashK1, noteK1} from 'lnurlcash-kit'
import {claimMintedNote, prepareMint} from './ops'
import {requiredValue} from './test-utils'
import type {Mint} from './ops.testHarness'
import {mint, settleLastInvoice} from './ops.testHarness'
const HEX32 = /^[0-9a-f]{64}$/
// the invoice whose quote the wallet bound to its own output hash
const boundInvoice = (instance: Mint) =>
[...instance.state.invoices.values()].find((invoice) => invoice.boundTo)
describe('mint -> claim, named output (comment/mintToHash)', () => {
it('names the quote with the hash of a wallet-chosen secret', async () => {
const instance = await mint({commentAllowed: 64, testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
expect(prepared.mode).toBe('named')
const noteSecret = requiredValue(prepared.noteSecret)
expect(noteSecret).toMatch(HEX32)
// the mint bound the quote to sha256 of the wallet's secret - the kit
// sends it as both comment and h, the mock read one of them
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(noteSecret))
})
it('claims a named output at the wallet secret, without a rotate', async () => {
const instance = await mint({commentAllowed: 64, testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
const noteSecret = requiredValue(prepared.noteSecret)
const preimage = await settleLastInvoice(instance)
const claimed = await claimMintedNote(prepared, {
intervalMs: 10,
intervalCapMs: 50,
maxWaitMs: 5_000,
})
expect(claimed.rotated).toBe(true)
expect(claimed.note.verified).toBe(true)
expect(claimed.note.amount).toBe(21_000)
expect(claimed.note.callback).toBe(`${instance.url}/w/cb`)
expect(noteK1(claimed.note.url)).toBe(noteSecret)
// no rotate happened: the note still stands at the wallet's own
// secret, and the payment preimage keys nothing at all
expect(instance.state.noteState(noteSecret)).toBe('outstanding')
expect(instance.state.noteState(preimage)).toBeNull()
})
it('names the output through the mintToHash spelling too', async () => {
const instance = await mint({mintToHash: true, testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
expect(prepared.mode).toBe('named')
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(requiredValue(prepared.noteSecret)))
const preimage = await settleLastInvoice(instance)
const claimed = await claimMintedNote(prepared, {
intervalMs: 10,
intervalCapMs: 50,
maxWaitMs: 5_000,
})
expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret)
expect(instance.state.noteState(preimage)).toBeNull()
})
it('keeps an unnamed mint on the preimage path', async () => {
const instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
expect(prepared.mode).toBe('unnamed')
expect(prepared.noteSecret).toBeUndefined()
// the quote was NOT bound - nothing named an output
expect(boundInvoice(instance)).toBeUndefined()
})
it('times out cleanly when a named mint invoice is never paid', async () => {
const instance = await mint({commentAllowed: 64, testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
await expect(
claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100}),
).rejects.toThrow(/not confirmed/i)
})
it('rescues through the preimage when the mint credited it anyway and serves verify', async () => {
// the mintToHashIgnoresH adversary: claims the capability, binds
// nothing, credits the payment hash - the preimage is still the money
const instance = await mint({
commentAllowed: 64,
mintToHashIgnoresH: true,
verifyOnUnnamedMint: true,
testHooks: true,
})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
const preimage = await settleLastInvoice(instance)
expect(instance.state.noteState(preimage)).toBe('outstanding')
const claimed = await claimMintedNote(prepared, {
intervalMs: 10,
intervalCapMs: 20,
maxWaitMs: 300,
})
expect(claimed.rotated).toBe(true)
expect(claimed.note.verified).toBe(true)
// claimed through the preimage path and rotated off it, because that
// preimage rode the invoice
expect(instance.state.noteState(preimage)).toBe('burned')
const k1 = requiredValue(noteK1(claimed.note.url))
expect(k1).not.toBe(preimage)
expect(instance.state.noteState(k1)).toBe('outstanding')
})
it('fails loudly when the mint credits the preimage and serves no verify', async () => {
const instance = await mint({commentAllowed: 64, mintToHashIgnoresH: true, testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
// verify is withheld on the unnamed fallback - there the preimage IS
// the note, so a compliant mint never serves it
expect(prepared.verifyUrl).toBeNull()
const preimage = await settleLastInvoice(instance)
await expect(
claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 200}),
).rejects.toThrow(/not confirmed/i)
// the money exists at the preimage but is unreachable without verify -
// the wallet must NOT report a success here
expect(instance.state.noteState(preimage)).toBe('outstanding')
})
})
+25 -8
View File
@@ -2,7 +2,6 @@ import {describe, expect, it} from 'vitest'
import { import {
NoteSpentError, NoteSpentError,
PendingNoteError, PendingNoteError,
buildNoteUrl,
fetchNoteInfo, fetchNoteInfo,
meltNote, meltNote,
noteK1, noteK1,
@@ -39,14 +38,32 @@ describe('mint -> claim -> rotate', () => {
const instance = await mint({testHooks: true, baseFeeMsat: 1_000, feePpm: 2_000}) const instance = await mint({testHooks: true, baseFeeMsat: 1_000, feePpm: 2_000})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 100_000) const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 100_000)
expect(prepared.grossMsat).toBeGreaterThan(100_000) expect(prepared.grossMsat).toBeGreaterThan(100_000)
const preimage = await settleLastInvoice(instance) await settleLastInvoice(instance)
const info = await fetchNoteInfo( const claimed = await claimMintedNote(prepared, {
buildNoteUrl(prepared.withdrawLink, preimage, prepared.expectedNoteValueMsat), intervalMs: 10,
) intervalCapMs: 20,
expect(info.maxWithdrawable).toBeGreaterThanOrEqual(99_000) maxWaitMs: 5_000,
expect(info.maxWithdrawable).toBeLessThanOrEqual(prepared.grossMsat) })
// the service's maxWithdrawable is authoritative: the fee is withheld
// from the gross, so the note nets between the asked value and the gross
expect(claimed.note.amount).toBeGreaterThanOrEqual(99_000)
expect(claimed.note.amount).toBeLessThanOrEqual(prepared.grossMsat)
expect(claimed.rotated).toBe(true)
})
it('refuses a verify response naming a different invoice', async () => {
const instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
await settleLastInvoice(instance)
// a verify answer that names any invoice but the requested one must
// stop the claim - the preimage it carries belongs to another payment
const other = `${prepared.invoice.slice(0, -1)}${prepared.invoice.endsWith('q') ? 'p' : 'q'}`
await expect( await expect(
claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 500}), claimMintedNote({...prepared, invoice: other}, {
intervalMs: 10,
intervalCapMs: 20,
maxWaitMs: 5_000,
}),
).rejects.toThrow(/different invoice/) ).rejects.toThrow(/different invoice/)
}) })
+25 -2
View File
@@ -51,9 +51,14 @@ describe('payWithBearers', () => {
}) })
expect(result.outcome).toBe('failed-funds-returned') expect(result.outcome).toBe('failed-funds-returned')
expect(instance.state.noteState(secret('33'))).toBe('burned') expect(instance.state.noteState(secret('33'))).toBe('burned')
const returnedK1 = requiredValue(noteK1(result.carve.note.url)) // the returned funds come back re-secured at the classification
// 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(instance.state.noteState(returnedK1)).toBe('outstanding') expect(instance.state.noteState(returnedK1)).toBe('outstanding')
expect(result.carve.note.amount).toBe(21_000) expect(rotatedNote.amount).toBe(21_000)
}) })
it('classifies a never-settling melt as unknown-still-pending', async () => { it('classifies a never-settling melt as unknown-still-pending', async () => {
@@ -72,4 +77,22 @@ describe('payWithBearers', () => {
await expect(payWithBearers([bearer], 'lnbc1pjqrstuvwxyz')).rejects.toThrow(/amount/) await expect(payWithBearers([bearer], 'lnbc1pjqrstuvwxyz')).rejects.toThrow(/amount/)
await expect(payWithBearers([bearer], 'not-an-invoice')).rejects.toThrow(/not a valid/i) await expect(payWithBearers([bearer], 'not-an-invoice')).rejects.toThrow(/not a valid/i)
}) })
it('aborts before the melt when the carve commit hook fails', async () => {
const instance = await mint()
const bearer = await makeBearer(instance, secret('36'), 21_000)
// paying 10_500 off a 21_000 note forces a split, which must commit
// through the hook before the melt may start
await expect(
payWithBearers([bearer], 'lnbc105n1pjqrstuvwxyz', {
onCarve: () => {
throw new Error('commit failed')
},
}),
).rejects.toThrow(/commit failed/)
// the split landed (the input is burned) but the melt never happened:
// nothing sits pending at the mint
expect(instance.state.noteState(secret('36'))).toBe('burned')
expect([...instance.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true)
})
}) })
+90
View File
@@ -0,0 +1,90 @@
import {describe, expect, it} from 'vitest'
import {noteK1} from 'lnurlcash-kit'
import {ensureExactAmount, receiveBearer} from './ops'
import {claimFromPreimage, prepareMint} from './ops/mint'
import {requiredValue} from './test-utils'
import {
makeBearer,
mint,
noteUrl,
retryingCbFetch,
secret,
settleLastInvoice,
} from './ops.testHarness'
// The redeem callback is a GET, and HTTP stacks retry GETs - so a mint
// can execute the first attempt of a mutation and refuse the byte-identical
// repeat as an already-spent input, which is the only answer the wallet
// ever sees. Every mutation that carries fresh secrets must probe the
// would-be outputs before believing such a refusal: landed means the
// carried secrets are the only money left.
describe('mutation retry refusals', () => {
it('rescues the outputs when the split answer is a retry refusal', async () => {
const instance = await mint()
const k1 = secret('60')
const bearer = await makeBearer(instance, k1, 50_000)
const result = await ensureExactAmount([bearer], 25_000, {fetch: retryingCbFetch()})
// the split landed on the first (unseen) attempt: the input is burned
// and both outputs are live under the secrets the wallet carried
expect(instance.state.noteState(k1)).toBe('burned')
expect(result.consumed).toHaveLength(1)
const partK1 = requiredValue(noteK1(result.note.url))
expect(partK1).not.toBe(k1)
expect(result.note.amount).toBe(25_000)
expect(instance.state.noteState(partK1)).toBe('outstanding')
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url))
expect(instance.state.noteState(changeK1)).toBe('outstanding')
})
it('rescues the combined note when the merge answer is a retry refusal', async () => {
const instance = await mint()
const a = await makeBearer(instance, secret('61'), 20_000)
const b = await makeBearer(instance, secret('62'), 5_000)
const result = await ensureExactAmount([a, b], 25_000, {fetch: retryingCbFetch()})
expect(result.consumed).toHaveLength(2)
expect(instance.state.noteState(secret('61'))).toBe('burned')
expect(instance.state.noteState(secret('62'))).toBe('burned')
const mergedK1 = requiredValue(noteK1(result.note.url))
expect(result.note.amount).toBe(25_000)
expect(instance.state.noteState(mergedK1)).toBe('outstanding')
})
it('adopts the fresh secret when the claim rotate answer is a retry refusal', async () => {
const instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
const preimage = await settleLastInvoice(instance)
const claimed = await claimFromPreimage(prepared, preimage, {fetch: retryingCbFetch()})
expect(claimed.rotated).toBe(true)
expect(claimed.note.verified).toBe(true)
expect(instance.state.noteState(preimage)).toBe('burned')
const k1 = requiredValue(noteK1(claimed.note.url))
expect(k1).not.toBe(preimage)
expect(instance.state.noteState(k1)).toBe('outstanding')
})
it('adopts the fresh secret when the receive rotate answer is a retry refusal', async () => {
const instance = await mint()
const senderK1 = secret('63')
instance.state.creditNote(senderK1, 21_000)
const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
fetch: retryingCbFetch(),
})
expect(received.rotated).toBe(true)
expect(received.note.amount).toBe(21_000)
expect(instance.state.noteState(senderK1)).toBe('burned')
const k1 = requiredValue(noteK1(received.note.url))
expect(k1).not.toBe(senderK1)
expect(instance.state.noteState(k1)).toBe('outstanding')
})
it('still fails a genuine refusal - a note spent before the split', async () => {
const instance = await mint()
const k1 = secret('64')
const bearer = await makeBearer(instance, k1, 50_000)
instance.state.settleMelt(k1) // burned elsewhere, before we ever asked
await expect(
ensureExactAmount([bearer], 25_000, {fetch: retryingCbFetch()}),
).rejects.toThrow(/spent/i)
})
})
+2
View File
@@ -1,4 +1,6 @@
import './ops.carve.cases' import './ops.carve.cases'
import './ops.mint-named.cases'
import './ops.mint-receive.cases' import './ops.mint-receive.cases'
import './ops.pay.cases' import './ops.pay.cases'
import './ops.retry-refusal.cases'
import './ops.transfer.cases' import './ops.transfer.cases'
+19
View File
@@ -69,3 +69,22 @@ export const expectBurned = async (instance: Mint, k1: string): Promise<void> =>
} }
expect(instance.state.noteState(k1)).toBe('burned') expect(instance.state.noteState(k1)).toBe('burned')
} }
// a fetch wrapper that answers every /w/cb request with the response to
// its SECOND, byte-identical send - the HTTP-stack GET retry a real client
// is subject to: the mint executes the first attempt and (with the mock's
// default retriedMutation: 'refuse', which is also lnurl-mint's behavior)
// refuses the repeat as an already-spent input, which is all the caller
// ever sees
export const retryingCbFetch = (): typeof fetch => {
const impl: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
if (url.includes('/w/cb')) {
const first = await fetch(input, init)
await first.arrayBuffer()
return fetch(input, init)
}
return fetch(input, init)
}
return impl
}
+127 -3
View File
@@ -1,8 +1,9 @@
import {describe, expect, it} from 'vitest' import {describe, expect, it} from 'vitest'
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import {noteK1} from 'lnurlcash-kit' import {hashK1, noteK1} from 'lnurlcash-kit'
import {transferBetweenMints} from './ops' import {transferBetweenMints} from './ops'
import type {CarveResult} from './ops'
import {requiredValue} from './test-utils' import {requiredValue} from './test-utils'
import {expectBurned, makeBearer, mint, secret, settleWhenRequested} from './ops.testHarness' import {expectBurned, makeBearer, mint, secret, settleWhenRequested} from './ops.testHarness'
@@ -128,9 +129,132 @@ describe('transferBetweenMints', () => {
expect(result.outcome).toBe('failed-funds-returned') expect(result.outcome).toBe('failed-funds-returned')
expect(result.mintedAtTarget).toBeUndefined() expect(result.mintedAtTarget).toBeUndefined()
expect(source.state.noteState(k1)).toBe('burned') expect(source.state.noteState(k1)).toBe('burned')
const returnedK1 = requiredValue(noteK1(result.carve.note.url)) // 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(returnedK1).not.toBe(k1)
expect(source.state.noteState(returnedK1)).toBe('outstanding') expect(source.state.noteState(returnedK1)).toBe('outstanding')
expect(result.carve.note.amount).toBe(21_000) expect(rotatedNote.amount).toBe(21_000)
})
it('moves value to a NAMED target: the note lands at the wallet secret, no rotate', async () => {
const source = await mint()
const target = await mint({commentAllowed: 64, 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.rotated).toBe(true)
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()
})
it('keeps the named claim material when the target never credits the note', async () => {
const source = await mint({meltNeverSettles: true})
const target = await mint({commentAllowed: 64, 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},
})
// 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))
}
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)
})
it('rescues a named transfer through the preimage when the target credited it anyway', async () => {
const source = await mint()
// the mintToHashIgnoresH adversary: advertises naming, credits the
// payment hash - and (non-compliantly) still serves verify
const target = await mint({
commentAllowed: 64,
mintToHashIgnoresH: true,
verifyOnUnnamedMint: true,
testHooks: true,
})
const k1 = secret('50')
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: 400},
})
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.rotated).toBe(true)
// claimed through the preimage path and rotated off it, because that
// preimage rode the invoice
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('fires onCarve after the carve landed and before the melt starts', async () => {
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)
// at hook time the melt must NOT have started: the carved note is
// still outstanding (a melt would leave it pending)
expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBe('outstanding')
},
})
await settleWhenRequested(target)
const result = await pending
expect(result.outcome).toBe('settled')
expect(seen).toHaveLength(1)
expect(seen[0]?.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)
await expect(
transferBetweenMints([bearer], 10_000, `mint@127.0.0.1:${target.port}`, {
onCarve: () => {
throw new Error('commit failed')
},
}),
).rejects.toThrow(/commit failed/)
// the split landed (the input is burned) but the melt never happened:
// nothing sits pending at the source, nothing was ever paid
expect(source.state.noteState(k1)).toBe('burned')
expect([...source.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true)
}) })
}) })
+7 -5
View File
@@ -6,9 +6,11 @@
// //
// Fund-critical invariants enforced across the flows (see the project plan): // Fund-critical invariants enforced across the flows (see the project plan):
// - rotate on every receive, and immediately after claiming a fresh mint // - rotate on every receive, and immediately after claiming a fresh mint
// (observer race: anyone who saw the unpaid invoice knows the payment // from an UNNAMED mint (observer race: anyone who saw the unpaid invoice
// hash, and the mint necessarily saw the preimage - the preimage IS the // knows the payment hash, and the mint necessarily saw the preimage -
// note secret) // the preimage IS the note secret). A NAMED mint (LUD-25 comment /
// mintToHash) credits the note to a wallet-chosen secret instead; that
// secret never rode an invoice, so no rotate follows its claim
// - a note's declared amount is a claim; the service's maxWithdrawable is // - a note's declared amount is a claim; the service's maxWithdrawable is
// authoritative // authoritative
// - a melt's "OK" only means the payment is in flight; its verify URL (or // - a melt's "OK" only means the payment is in flight; its verify URL (or
@@ -23,8 +25,8 @@
// ops/receiveBearer.ts - receiveBearer (receive a note, rotate on receive) // ops/receiveBearer.ts - receiveBearer (receive a note, rotate on receive)
// ops/pay.ts - payWithBearers (melt to bolt11 / Lightning Address) // ops/pay.ts - payWithBearers (melt to bolt11 / Lightning Address)
// ops/transfer.ts - transferBetweenMints (inter-mint move: melt at // ops/transfer.ts - transferBetweenMints (inter-mint move: melt at
// source, mint + claim + rotate at target) // source, mint + claim at target)
// ops/shared.ts - bounded verify polling, UncertainOutcomeError // ops/shared.ts - bounded polling, UncertainOutcomeError
export {UncertainOutcomeError} from './ops/shared' export {UncertainOutcomeError} from './ops/shared'
export type {PollOptions} from './ops/shared' export type {PollOptions} from './ops/shared'
+84 -8
View File
@@ -1,10 +1,16 @@
// ensureExactAmount: carve an exact amount out of the held notes, merging // ensureExactAmount: carve an exact amount out of the held notes, merging
// and/or splitting as needed, into a single fresh note worth exactly the // and/or splitting as needed, into a single fresh note worth exactly the
// target - the operation every send and every melt starts from. // target - the operation every send and every melt starts from.
//
// A carve whose mutation landed reports itself through options.onCarve
// BEFORE returning, so a caller about to do something slow (a settlement
// wait) can durably commit first - a flow aborted mid-wait must never
// leave burned inputs looking spendable or strand the fresh outputs.
import { import {
AmbiguousMutationError, AmbiguousMutationError,
mergeNotes, mergeNotes,
newSecretsOf,
noteK1, noteK1,
probeBurnedNote, probeBurnedNote,
requireNoteK1, requireNoteK1,
@@ -15,7 +21,7 @@ import {
} from 'lnurlcash-kit' } from 'lnurlcash-kit'
import type {Bearer, NewBearer} from '../types' import type {Bearer, NewBearer} from '../types'
import type {FundOperationOptions} from './shared' import type {FundOperationOptions} from './shared'
import {assertFundOwner, UncertainOutcomeError} from './shared' import {assertFundOwner, probeMutationOutput, UncertainOutcomeError} from './shared'
// the changeset stores apply after a mutation: `note`/`change` BEFORE // the changeset stores apply after a mutation: `note`/`change` BEFORE
// `consumed` - the mint call already burned every consumed input // `consumed` - the mint call already burned every consumed input
@@ -80,6 +86,14 @@ export const ensureExactAmount = async (
const total = pick.reduce((sum, b) => sum + b.amount, 0) const total = pick.reduce((sum, b) => sum + b.amount, 0)
const k1s = pick.map((b) => requireNoteK1(b.url)) const k1s = pick.map((b) => requireNoteK1(b.url))
// a carve that burned inputs reports itself the moment its outcome is
// known, before the caller's flow moves on to anything slow or
// uncertain - see FundOperationOptions.onCarve
const checkpoint = async (result: CarveResult): Promise<CarveResult> => {
await options.onCarve?.(result)
return result
}
if (pick.length === 1 && total === amountMsat) { if (pick.length === 1 && total === amountMsat) {
// already exact - hand over the note itself, untouched // already exact - hand over the note itself, untouched
return { return {
@@ -112,10 +126,10 @@ export const ensureExactAmount = async (
// inside settleNote) at it, whose own ambiguous failure would strand // inside settleNote) at it, whose own ambiguous failure would strand
// the rescued secret. Don't compound: return unverified and let a // the rescued secret. Don't compound: return unverified and let a
// refresh repair. // refresh repair.
if (merged.rescued) return {note: unverified, consumed: pick} if (merged.rescued) return checkpoint({note: unverified, consumed: pick})
try { try {
const settled = await settleNote(base.url, merged.k1, total, merged.signature, options) const settled = await settleNote(base.url, merged.k1, total, merged.signature, options)
return { return checkpoint({
note: { note: {
url: withNewK1(base.url, settled.k1, settled.amountMsat, settled.signature), url: withNewK1(base.url, settled.k1, settled.amountMsat, settled.signature),
callback: settled.callback, callback: settled.callback,
@@ -124,9 +138,9 @@ export const ensureExactAmount = async (
mintPubkey: base.mintPubkey, mintPubkey: base.mintPubkey,
}, },
consumed: pick, consumed: pick,
} })
} catch { } catch {
return {note: unverified, consumed: pick} return checkpoint({note: unverified, consumed: pick})
} }
} }
@@ -151,7 +165,7 @@ export const ensureExactAmount = async (
changeSignature = parts.changeSignature changeSignature = parts.changeSignature
partVerified = true partVerified = true
} catch (err) { } catch (err) {
if (!(err instanceof AmbiguousMutationError)) throw err if (err instanceof AmbiguousMutationError) {
// the split request may have landed despite the failure - probe one // the split request may have landed despite the failure - probe one
// input before deciding what the carried secrets are worth // input before deciding what the carried secrets are worth
const outcome = await probeBurnedNote(base.url, options) const outcome = await probeBurnedNote(base.url, options)
@@ -183,6 +197,46 @@ export const ensureExactAmount = async (
partK1 = err.newSecrets[0] partK1 = err.newSecrets[0]
changeK1 = err.newSecrets[1] changeK1 = err.newSecrets[1]
rescued = true rescued = true
} else {
// A classified refusal can still be a LANDED split: the callback is
// a GET and HTTP stacks retry GETs, so the service may have executed
// the first attempt and refused this one as an already-spent input.
// The kit attaches the fresh output secrets to every refusal - probe
// one output before deciding they are worthless.
const carried = newSecretsOf(err)
if (carried.length !== 2) throw err
const outcome = await probeMutationOutput(base.url, carried[0], options)
if (outcome === 'absent') throw err // never landed - a plain refusal
if (outcome === 'unknown') {
// can't tell whether the refusal named a retry - same limbo as
// the ambiguous case above: track the possible outputs, consume
// nothing, stop here
throw new UncertainOutcomeError(
'The split was refused, but the refusal may have named a retry of a split that already landed - the possible outputs must be tracked unverified alongside the originals until refreshed.',
[
{
url: withNewK1(base.url, carried[0], amountMsat),
callback: base.callback,
amount: amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
},
{
url: withNewK1(base.url, carried[1], total - amountMsat),
callback: base.callback,
amount: total - amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
},
],
)
}
// 'live': the split landed and this answer was its retried twin -
// the carried secrets are the only money left
partK1 = carried[0]
changeK1 = carried[1]
rescued = true
}
} }
const note: NewBearer = { const note: NewBearer = {
url: withNewK1(base.url, partK1, amountMsat, partSignature), url: withNewK1(base.url, partK1, amountMsat, partSignature),
@@ -223,7 +277,7 @@ export const ensureExactAmount = async (
if (!(error instanceof Error)) throw error if (!(error instanceof Error)) throw error
} }
} }
return {note, change, consumed: pick} return checkpoint({note, change, consumed: pick})
} }
// smallest-first accumulation until the target is covered; null when the // smallest-first accumulation until the target is covered; null when the
@@ -261,7 +315,7 @@ const mergeAmbiguitySafe = async (
const merged = await mergeNotes(base.callback, k1s, options) const merged = await mergeNotes(base.callback, k1s, options)
return {k1: merged.k1, signature: merged.signature, rescued: false} return {k1: merged.k1, signature: merged.signature, rescued: false}
} catch (err) { } catch (err) {
if (!(err instanceof AmbiguousMutationError)) throw err if (err instanceof AmbiguousMutationError) {
const outcome = await probeBurnedNote(base.url, options) const outcome = await probeBurnedNote(base.url, options)
if (outcome === 'live') throw err // nothing burned - a plain failure if (outcome === 'live') throw err // nothing burned - a plain failure
if (outcome === 'unknown') { if (outcome === 'unknown') {
@@ -281,4 +335,26 @@ const mergeAmbiguitySafe = async (
// 'gone': the burn landed - the carried secret is the only money left // 'gone': the burn landed - the carried secret is the only money left
return {k1: err.newSecrets[0], rescued: true} return {k1: err.newSecrets[0], rescued: true}
} }
// same retry-refusal rescue as the split path: the refusal may name a
// merge that already landed - probe the would-be combined note
const carried = newSecretsOf(err)
if (carried.length !== 1) throw err
const outcome = await probeMutationOutput(base.url, carried[0], options)
if (outcome === 'absent') throw err
if (outcome === 'unknown') {
throw new UncertainOutcomeError(
'The merge was refused, but the refusal may have named a retry of a merge that already landed - the possible combined note must be tracked unverified alongside the originals until refreshed.',
[
{
url: withNewK1(base.url, carried[0], total),
callback: base.callback,
amount: total,
verified: false,
mintPubkey: base.mintPubkey,
},
],
)
}
return {k1: carried[0], rescued: true}
}
} }
+208 -26
View File
@@ -1,18 +1,41 @@
// Minting: receiving over Lightning. prepareMint resolves a mint and // Minting: receiving over Lightning. prepareMint resolves a mint and
// requests the invoice; paying it brings the note into existence; // requests the invoice; paying it brings the note into existence;
// claimMintedNote watches the payment and converts the revealed preimage // claimMintedNote watches the payment and converts it into a wallet-owned
// into a rotated, wallet-owned bearer note. // bearer note.
//
// Two mint generations exist on the wire, and this module speaks both:
//
// - UNNAMED (old): the minted note's k1 IS the payment preimage. The
// claim takes the preimage from the LUD-21 verify answer and rotates
// immediately - every routing node and anyone who saw the invoice knew
// that secret.
// - NAMED (LUD-25 comment/mintToHash, lnurl-mint >= 0.4 requires it): the
// wallet names the note at quote time with comment = sha256(secret) of
// a secret it chose, and the note lands at THAT secret. The preimage
// opens nothing, so no observer race exists and no rotate follows the
// claim; the claim poll itself is the placement proof (see
// claimFromSecret).
//
// The mode is decided per mint from the payRequest's capability
// advertisement (commentAllowed >= 64 or mintToHash), never assumed.
import { import {
AmbiguousMutationError, AmbiguousMutationError,
buildNoteUrl, buildNoteUrl,
claimMintedNote as fetchMintClaim,
defaultRandomSecret,
fetchInvoiceVerification,
fetchMintAddress, fetchMintAddress,
fetchNoteInfo, fetchNoteInfo,
fetchPayRequest, fetchPayRequest,
grossUpForMintFee, grossUpForMintFee,
hashK1,
isPreimage, isPreimage,
lightningAddressUsername, lightningAddressUsername,
mintAddressUrl, mintAddressUrl,
namesMintOutput,
newSecretsOf,
NoteSpentError,
probeBurnedNote, probeBurnedNote,
requestInvoice, requestInvoice,
resolveMintInput, resolveMintInput,
@@ -21,16 +44,26 @@ import {
serverOf, serverOf,
withNewK1, withNewK1,
} from 'lnurlcash-kit' } from 'lnurlcash-kit'
import type {MintAddressInfo} from 'lnurlcash-kit' import type {MintAddressInfo, MintClaim} from 'lnurlcash-kit'
import type {NewBearer} from '../types' import type {NewBearer} from '../types'
import {ceilMsatToSat} from '../units' import {ceilMsatToSat} from '../units'
import type {PollOptions} from './shared' import type {PollOptions} from './shared'
import type {FundOperationOptions} from './shared' import type {FundOperationOptions} from './shared'
import {assertFundOwner, pollVerifyUntilSettled} from './shared' import {PollAbortedError, assertFundOwner, pollUntil, pollVerifyUntilSettled, probeMutationOutput} from './shared'
export type PreparedMint = { export type PreparedMint = {
invoice: string invoice: string
verifyUrl: string | null verifyUrl: string | null
// how this mint keys the note the invoice pays for: 'named' mints credit
// a wallet-chosen secret (LUD-25 comment/mintToHash), 'unnamed' mints
// credit the payment preimage
mode: 'named' | 'unnamed'
// named mode only: the wallet-chosen secret the minted note lands at.
// Drawn before the invoice is requested (its hash binds the quote) and
// carried in memory only - never persisted, never logged. Losing it
// after payment loses the note, but nothing here was ever recoverable
// after a crash: the invoice and verify URL die in the same memory
noteSecret?: string
// the net note value asked for - the claim cross-checks the service's // the net note value asked for - the claim cross-checks the service's
// authoritative maxWithdrawable against it // authoritative maxWithdrawable against it
expectedNoteValueMsat: number expectedNoteValueMsat: number
@@ -85,10 +118,25 @@ export const prepareMint = async (
if (grossMsat < info.minSendable || grossMsat > info.maxSendable) { if (grossMsat < info.minSendable || grossMsat > info.maxSendable) {
throw new Error("Amount is outside this mint's sendable range.") throw new Error("Amount is outside this mint's sendable range.")
} }
const invoice = await requestInvoice(info.callback, grossMsat, options) // a mint that names its outputs takes comment = sha256(secret) with the
// quote; one that doesn't gets the byte-identical request this wallet
// always sent, and its note stays keyed by the payment preimage
const named = namesMintOutput(info)
let noteSecret: string | undefined
let h: string | undefined
if (named) {
noteSecret = defaultRandomSecret()
h = hashK1(noteSecret)
}
const invoice = await requestInvoice(
info.callback,
grossMsat,
h ? {...options, h} : options,
)
const prepared: PreparedMint = { const prepared: PreparedMint = {
invoice: invoice.pr, invoice: invoice.pr,
verifyUrl: invoice.verify ?? null, verifyUrl: invoice.verify ?? null,
mode: named ? 'named' : 'unnamed',
expectedNoteValueMsat: amountMsat, expectedNoteValueMsat: amountMsat,
grossMsat, grossMsat,
mintUrl: payUrl, mintUrl: payUrl,
@@ -97,15 +145,18 @@ export const prepareMint = async (
username: lightningAddressUsername(payUrl), username: lightningAddressUsername(payUrl),
nodeInfo, nodeInfo,
} }
if (noteSecret) prepared.noteSecret = noteSecret
if (info.mintPubkey) prepared.mintPubkey = info.mintPubkey if (info.mintPubkey) prepared.mintPubkey = info.mintPubkey
return prepared return prepared
} }
export type ClaimedNote = { export type ClaimedNote = {
note: NewBearer note: NewBearer
// false when the rotate after claim failed - the note is tracked either // false when the note may be exposed to someone besides this wallet and
// way (it IS money), but the preimage was transmitted and the mint // the mint: an unnamed mint's fresh note whose rotate failed (its k1 was
// necessarily knows it, so an unrotated note must be treated as exposed // the payment preimage, transmitted and mint-known). A named mint's
// note always reports true - its secret never rode an invoice, so there
// is nothing to rotate away from
rotated: boolean rotated: boolean
// set when the rotate's answer was lost and the probe couldn't tell: the // set when the rotate's answer was lost and the probe couldn't tell: the
// possible rotated copy, to track unverified alongside `note` // possible rotated copy, to track unverified alongside `note`
@@ -113,18 +164,126 @@ export type ClaimedNote = {
rotationError?: string rotationError?: string
} }
// Polls the mint invoice's LUD-21 verify URL until the payment settles, // Polls a named mint output's informational GET until the note is there:
// then claims the note: the payment preimage IS the note secret. The claim // 'minted' resolves the wait ('spent' resolves it too - surfaced for the
// itself (an informational GET) puts that secret on the wire, and the mint // caller to reject, never retried); 'unminted' and 'pending' keep it
// has known it since it generated the invoice - so the fresh note is // going. The poll IS the placement proof for a named output: the service
// rotated immediately and unconditionally (observer race: anyone who saw // only answers 'minted' for the secret it actually credited.
// the unpaid invoice knows the payment hash), before anything else happens export const pollMintClaim = async (
// with it. withdrawLink: string,
noteSecret: string,
poll: PollOptions = {},
options: FundOperationOptions = {},
): Promise<MintClaim> =>
pollUntil(
async (fetchOptions) => {
const claim = await fetchMintClaim(withdrawLink, noteSecret, fetchOptions)
return claim.state === 'minted' || claim.state === 'spent' ? claim : null
},
'Minted note not confirmed',
poll,
options,
)
// what a claim needs once the preimage is known - PreparedMint satisfies
// this, and so does the target side of an inter-mint transfer (see
// transfer.ts)
export type ClaimTarget = {
withdrawLink: string
// the net note value asked for - a claim, cross-checked against the
// service's authoritative maxWithdrawable
expectedNoteValueMsat: number
mintPubkey?: string
}
// what a NAMED-mode claim needs: the wallet's own secret, plus the quote's
// verify URL and invoice as rescue anchors for a mint that took the hash
// but credited the preimage anyway. PreparedMint satisfies this.
export type NamedClaimTarget = ClaimTarget & {
noteSecret: string
verifyUrl?: string | null
invoice?: string
}
// The named-mode claim: the note was credited to the wallet's own secret,
// which rode to the mint as a HASH inside the invoice request - the
// payment preimage opens nothing, so the claim needs no preimage and no
// rotate follows it. The rescue covers the mint that claimed the
// capability but credited the preimage (the kit's mintToHashIgnoresH
// adversary: the wallet was told no rotate is needed while the preimage
// is still the money): when that mint still serves the quote's verify, one
// check recovers the preimage and the note is claimed through the old
// path, WITH the rotate that path exists for. When it serves none there
// is nothing to rescue with - the error stays loud.
export const claimFromSecret = async (
target: NamedClaimTarget,
poll: PollOptions = {},
options: FundOperationOptions = {},
): Promise<ClaimedNote> => {
assertFundOwner(options)
let claim: MintClaim
try {
claim = await pollMintClaim(target.withdrawLink, target.noteSecret, poll, options)
} catch (err) {
if (!(err instanceof PollAbortedError) && target.verifyUrl && target.invoice) {
try {
const result = await fetchInvoiceVerification(target.verifyUrl, options)
if (
result.settled &&
sameInvoice(result.pr, target.invoice) &&
result.preimage &&
isPreimage(result.preimage)
) {
return claimFromPreimage(target, result.preimage, options)
}
} catch {
// the rescue is best-effort - the original error stands
}
}
throw err
}
if (claim.state === 'spent') {
// a fresh wallet-chosen secret cannot already be spent - the service
// is lying about the note it just credited
throw new Error('The mint reports the freshly minted note as already spent.')
}
if (claim.k1 !== target.noteSecret) {
// belt and suspenders: the kit's claimMintedNote returns the queried
// secret by construction and fetchNoteInfo rejects a mismatched echo,
// so this cannot trip through the kit - if it ever does, building a
// note from the answer would track the wrong secret
throw new Error('The mint answered the claim for a different note secret.')
}
if (claim.amountMsat === null || !claim.callback) {
throw new Error('The mint did not return the minted note details.')
}
const note: NewBearer = {
url: buildNoteUrl(target.withdrawLink, claim.k1, claim.amountMsat),
callback: claim.callback,
amount: claim.amountMsat,
verified: true,
}
if (target.mintPubkey) note.mintPubkey = target.mintPubkey
return {note, rotated: true}
}
// Polls until the minted note is claimable, then claims it. Unnamed mints:
// watch the LUD-21 verify URL for settlement, take the preimage (which IS
// the note secret there), and claim through claimFromPreimage with its
// mandatory rotate. Named mints: watch the note itself appear at the
// wallet's own secret (see claimFromSecret).
export const claimMintedNote = async ( export const claimMintedNote = async (
prepared: PreparedMint, prepared: PreparedMint,
poll: PollOptions = {}, poll: PollOptions = {},
options: FundOperationOptions = {}, options: FundOperationOptions = {},
): Promise<ClaimedNote> => { ): Promise<ClaimedNote> => {
if (prepared.mode === 'named') {
const noteSecret = prepared.noteSecret
if (!noteSecret) {
throw new Error('This mint was prepared in named mode without a note secret.')
}
return claimFromSecret({...prepared, noteSecret}, poll, options)
}
if (!prepared.verifyUrl) { if (!prepared.verifyUrl) {
throw new Error( throw new Error(
'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.', 'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.',
@@ -144,17 +303,6 @@ export const claimMintedNote = async (
return claimFromPreimage(prepared, preimage, options) return claimFromPreimage(prepared, preimage, options)
} }
// what a claim needs once the preimage is known - PreparedMint satisfies
// this, and so does the target side of an inter-mint transfer (see
// transfer.ts)
export type ClaimTarget = {
withdrawLink: string
// the net note value asked for - a claim, cross-checked against the
// service's authoritative maxWithdrawable
expectedNoteValueMsat: number
mintPubkey?: string
}
// The claim itself, once the payment's preimage is known: the preimage IS // The claim itself, once the payment's preimage is known: the preimage IS
// the note secret. The claim (an informational GET) puts that secret on // the note secret. The claim (an informational GET) puts that secret on
// the wire, and the mint has known it since it generated the invoice - so // the wire, and the mint has known it since it generated the invoice - so
@@ -214,9 +362,43 @@ export const claimFromPreimage = async (
} else { } else {
rotationError = err.message rotationError = err.message
} }
} else {
// A classified refusal can still be a LANDED rotate: the callback is
// a GET and HTTP stacks retry GETs, so the service may have executed
// the first attempt and refused this one as an already-spent input.
// "Already spent" then PROVES the preimage note is burned - the
// carried fresh secret is the only money left, so probe it before
// believing the refusal.
const carried = newSecretsOf(err)
if (carried.length === 1) {
const outcome = await probeMutationOutput(declaredUrl, carried[0], options)
if (outcome === 'live') {
// the rotate landed and this answer was its retried twin
url = withNewK1(declaredUrl, carried[0], noteInfo.maxWithdrawable)
rotated = true
} else if (outcome === 'unknown') {
possibleCopy = {
url: withNewK1(declaredUrl, carried[0], noteInfo.maxWithdrawable),
callback: noteInfo.callback,
amount: noteInfo.maxWithdrawable,
verified: false,
}
if (mintPubkey) possibleCopy.mintPubkey = mintPubkey
// the preimage note itself may be burned - it cannot stay verified
base.verified = false
rotationError = `${err instanceof Error ? err.message : String(err)} The refusal may have named a retry of a rotation that already landed - the possible rotated copy is tracked unverified alongside this one.`
} else if (err instanceof NoteSpentError) {
// a genuine already-spent refusal with no live output: the note
// the service just described is burned - it cannot stay verified
base.verified = false
rotationError = err.message
} else { } else {
rotationError = err instanceof Error ? err.message : String(err) rotationError = err instanceof Error ? err.message : String(err)
} }
} else {
rotationError = err instanceof Error ? err.message : String(err)
}
}
} }
const claimed: ClaimedNote = {note: {...base, url}, rotated} const claimed: ClaimedNote = {note: {...base, url}, rotated}
if (possibleCopy) claimed.possibleCopy = possibleCopy if (possibleCopy) claimed.possibleCopy = possibleCopy
+26 -8
View File
@@ -41,6 +41,13 @@ export type PayResult = {
invoice: string invoice: string
amountMsat: number amountMsat: number
verifyUrl: string | null verifyUrl: string | null
// on failed-funds-returned, when the classification rotate succeeded:
// the returned funds re-secured at a fresh secret. Kept OUT of `carve`
// (which always describes the carve as committed by the onCarve
// checkpoint) so an early-committed carve and a post-wait rotation can
// both be persisted - the carved note the rotate burned must be marked
// spent, this note added
rotatedNote?: NewBearer
// a fresh secret rescued from an ambiguous rotate during outcome // a fresh secret rescued from an ambiguous rotate during outcome
// classification - the caller must track it unverified; if the rotate // classification - the caller must track it unverified; if the rotate
// landed, this is the only copy of the (returned) funds // landed, this is the only copy of the (returned) funds
@@ -56,6 +63,10 @@ export type PayOptions = {
// kit transport overrides (fetch injection, timeouts) // kit transport overrides (fetch injection, timeouts)
kit?: LnurlcashOptions kit?: LnurlcashOptions
assertOwner?: () => void assertOwner?: () => void
// carve checkpoint - committed before the melt and the settlement wait,
// so an abort mid-wait never strands the carve (see
// FundOperationOptions.onCarve)
onCarve?: FundOperationOptions['onCarve']
} }
// A melt's resolved promise only means the payment is in flight; the // A melt's resolved promise only means the payment is in flight; the
@@ -71,9 +82,13 @@ export type PayOptions = {
export const payWithBearers = async ( export const payWithBearers = async (
bearers: Bearer[], bearers: Bearer[],
input: string, input: string,
{amountMsat, poll = {}, kit = {}, assertOwner}: PayOptions = {}, {amountMsat, poll = {}, kit = {}, assertOwner, onCarve}: PayOptions = {},
): Promise<PayResult> => { ): Promise<PayResult> => {
const options: FundOperationOptions = assertOwner ? {...kit, assertOwner} : kit const options: FundOperationOptions = {
...kit,
...(assertOwner ? {assertOwner} : {}),
...(onCarve ? {onCarve} : {}),
}
let invoice: string let invoice: string
let amount: number let amount: number
const trimmed = input.trim() const trimmed = input.trim()
@@ -150,15 +165,18 @@ export const payWithBearers = async (
const rotated = await rotateNote(carve.note.callback, k1, options) const rotated = await rotateNote(carve.note.callback, k1, options)
// the rotate succeeded, so the mint restored the note - and k1 had // the rotate succeeded, so the mint restored the note - and k1 had
// been on the wire since the melt, so the rotation doubles as the // been on the wire since the melt, so the rotation doubles as the
// required re-securing of the returned funds // required re-securing of the returned funds. Reported separately
// from `carve` (see PayResult.rotatedNote): the carve may already be
// committed, and its note is what this rotate burned
return { return {
outcome: 'failed-funds-returned', outcome: 'failed-funds-returned',
carve: { carve,
...carve, rotatedNote: {
note: {
...carve.note,
url: withNewK1(carve.note.url, rotated.k1, amount, rotated.signature), url: withNewK1(carve.note.url, rotated.k1, amount, rotated.signature),
}, callback: carve.note.callback,
amount,
verified: true,
...(carve.note.mintPubkey ? {mintPubkey: carve.note.mintPubkey} : {}),
}, },
invoice, invoice,
amountMsat: amount, amountMsat: amount,
+37 -2
View File
@@ -9,6 +9,7 @@ import {
NoteSpentError, NoteSpentError,
NoteUnknownError, NoteUnknownError,
PendingNoteError, PendingNoteError,
newSecretsOf,
probeBurnedNote, probeBurnedNote,
withNewK1, withNewK1,
} from 'lnurlcash-kit' } from 'lnurlcash-kit'
@@ -16,7 +17,7 @@ import type {Bearer, NewBearer} from '../types'
import {receiveNote, secureReceivedNote} from '../receive' import {receiveNote, secureReceivedNote} from '../receive'
import type {ClaimedNote} from './mint' import type {ClaimedNote} from './mint'
import type {FundOperationOptions} from './shared' import type {FundOperationOptions} from './shared'
import {assertFundOwner} from './shared' import {assertFundOwner, probeMutationOutput} from './shared'
// NoteSpentError / NoteUnknownError / PendingNoteError from the service are // NoteSpentError / NoteUnknownError / PendingNoteError from the service are
// definitive and propagate; an unreachable service still yields the note, // definitive and propagate; an unreachable service still yields the note,
@@ -32,9 +33,43 @@ export const receiveBearer = async (
} }
try { try {
assertFundOwner(options) assertFundOwner(options)
const rotatedUrl = await secureReceivedNote(note) const rotatedUrl = await secureReceivedNote(note, options)
return {note: {...note, url: rotatedUrl}, rotated: true} return {note: {...note, url: rotatedUrl}, rotated: true}
} catch (err) { } catch (err) {
// A classified rotate refusal can still be a LANDED rotate: the
// callback is a GET and HTTP stacks retry GETs, so the service may
// have executed the first attempt and refused this one as an
// already-spent input. The refusal then proves the sender's copy is
// burned and the carried fresh secret is the only money left - probe
// it before believing the refusal.
const carried = newSecretsOf(err)
if (carried.length === 1 && !(err instanceof AmbiguousMutationError)) {
const outcome = await probeMutationOutput(note.url, carried[0], options)
if (outcome === 'live') {
return {
note: {...note, url: withNewK1(note.url, carried[0], note.amount)},
rotated: true,
}
}
if (outcome === 'unknown') {
const possibleCopy: NewBearer = {
url: withNewK1(note.url, carried[0], note.amount),
callback: note.callback,
amount: note.amount,
verified: false,
}
if (note.mintPubkey) possibleCopy.mintPubkey = note.mintPubkey
return {
// the sender's copy may be burned - it cannot stay verified
note: {...note, verified: false},
rotated: false,
possibleCopy,
rotationError: `${err instanceof Error ? err.message : String(err)} The refusal may have named a retry of a rotation that already landed - the possible rotated copy is tracked unverified alongside this one.`,
}
}
// 'absent': the refusal is genuine - fall through to the distinct
// handling below
}
// a definitive service state (dead/unknown/locked mid-melt) is not a // a definitive service state (dead/unknown/locked mid-melt) is not a
// rotation failure to warn about - it tells the holder what this note // rotation failure to warn about - it tells the holder what this note
// actually is, so it propagates distinctly // actually is, so it propagates distinctly
+72 -13
View File
@@ -2,9 +2,11 @@
// every flow that waits on a payment uses, and the uncertainty type a lost // every flow that waits on a payment uses, and the uncertainty type a lost
// mutation answer surfaces as. // mutation answer surfaces as.
import {fetchInvoiceVerification} from 'lnurlcash-kit' import {fetchInvoiceVerification, fetchNoteInfo, withNewK1} from 'lnurlcash-kit'
import type {LnurlcashOptions, VerifyResult} from 'lnurlcash-kit' import type {LnurlcashOptions, VerifyResult} from 'lnurlcash-kit'
import {NoteSpentError, NoteUnknownError, noteDeclaredAmount} from 'lnurlcash-kit'
import type {NewBearer} from '../types' import type {NewBearer} from '../types'
import type {CarveResult} from './carve'
// a mutation's answer was lost AND the probe could not tell whether it // a mutation's answer was lost AND the probe could not tell whether it
// landed - the possible outputs the fresh secrets would control, for the // landed - the possible outputs the fresh secrets would control, for the
@@ -30,12 +32,48 @@ export class PollAbortedError extends Error {
export type FundOperationOptions = LnurlcashOptions & { export type FundOperationOptions = LnurlcashOptions & {
readonly assertOwner?: () => void readonly assertOwner?: () => void
// called by a carve the moment its mutation has LANDED server-side (the
// inputs are burned, the outputs are known), before the flow moves on to
// anything slow or uncertain - the caller's one chance to durably commit
// the changeset so an abort during a long settlement wait can never
// strand the outputs or leave burned inputs looking spendable. If it
// rejects, the flow stops BEFORE anything further is spent and the
// rejection propagates: the carve state is then landed-but-maybe-
// uncommitted, which the caller must surface loudly. Never called for a
// carve that mutated nothing.
readonly onCarve?: (carve: CarveResult) => void | Promise<void>
} }
export const assertFundOwner = (options: FundOperationOptions): void => { export const assertFundOwner = (options: FundOperationOptions): void => {
options.assertOwner?.() options.assertOwner?.()
} }
// what probing a would-be mutation output can tell
export type OutputProbe = 'live' | 'absent' | 'unknown'
// A refused mutation can still have LANDED: the redeem callback is a GET
// and HTTP stacks retry GETs, so the service may have executed the first
// attempt and refused this one as an already-spent input (LUD-25 says a
// byte-identical retry SHOULD get the original success replayed; real
// mints refuse instead). The kit attaches the fresh output secrets to
// every service refusal (newSecretsOf) - so before believing a refusal,
// probe one would-be output: 'live' proves the mutation landed and the
// carried secrets are the only money left, 'absent' proves the refusal is
// genuine, 'unknown' keeps it genuinely ambiguous.
export const probeMutationOutput = async (
noteUrl: string,
secret: string,
options: LnurlcashOptions = {},
): Promise<OutputProbe> => {
try {
await fetchNoteInfo(withNewK1(noteUrl, secret, noteDeclaredAmount(noteUrl) ?? 0), options)
return 'live'
} catch (err) {
if (err instanceof NoteSpentError || err instanceof NoteUnknownError) return 'absent'
return 'unknown'
}
}
export type PollOptions = { export type PollOptions = {
// first delay between checks (doubles each round up to intervalCapMs) // first delay between checks (doubles each round up to intervalCapMs)
intervalMs?: number intervalMs?: number
@@ -76,16 +114,17 @@ const abortableSleep = (ms: number, signal: AbortSignal): Promise<void> =>
signal.addEventListener('abort', onAbort, {once: true}) signal.addEventListener('abort', onAbort, {once: true})
}) })
// polls a LUD-21/LUD-25 verify endpoint until it reports settled, with // polls `check` until it yields a value, with backoff, inside a total
// backoff, inside a total time budget. A single failed check isn't fatal - // time budget. A single failed check isn't fatal - the next round tries
// the next round tries again. Returns the settled VerifyResult; throws on // again. Returns the first value; throws on budget exhaustion, or
// budget exhaustion, or PollAbortedError when the caller's signal fires // PollAbortedError when the caller's signal fires (a hung fetch is
// (a hung fetch is interrupted too: the signal is bound into the request). // interrupted too: the signal is bound into the request).
export const pollVerifyUntilSettled = async ( export const pollUntil = async <T>(
verifyUrl: string, check: (options: LnurlcashOptions) => Promise<T | null>,
exhausted: string,
poll: PollOptions, poll: PollOptions,
options: LnurlcashOptions, options: LnurlcashOptions,
): Promise<VerifyResult> => { ): Promise<T> => {
const {intervalMs, intervalCapMs, maxWaitMs, signal} = { const {intervalMs, intervalCapMs, maxWaitMs, signal} = {
...DEFAULT_POLL, ...DEFAULT_POLL,
...poll, ...poll,
@@ -105,8 +144,8 @@ export const pollVerifyUntilSettled = async (
while (Date.now() < deadline) { while (Date.now() < deadline) {
if (signal?.aborted) throw new PollAbortedError() if (signal?.aborted) throw new PollAbortedError()
try { try {
const result = await fetchInvoiceVerification(verifyUrl, fetchOptions) const result = await check(fetchOptions)
if (result.settled) return result if (result !== null) return result
lastError = null lastError = null
} catch (err) { } catch (err) {
// the signal's own AbortError lands here on an interrupted fetch // the signal's own AbortError lands here on an interrupted fetch
@@ -118,7 +157,27 @@ export const pollVerifyUntilSettled = async (
delay = Math.min(delay * 2, intervalCapMs) delay = Math.min(delay * 2, intervalCapMs)
} }
if (lastError instanceof Error) { if (lastError instanceof Error) {
throw new Error(`Payment not confirmed: ${lastError.message}`) throw new Error(`${exhausted}: ${lastError.message}`)
} }
throw new Error('Payment not confirmed within the time budget.') throw new Error(`${exhausted} within the time budget.`)
} }
// polls a LUD-21/LUD-25 verify endpoint until it reports settled, with
// backoff, inside a total time budget. A single failed check isn't fatal -
// the next round tries again. Returns the settled VerifyResult; throws on
// budget exhaustion, or PollAbortedError when the caller's signal fires
// (a hung fetch is interrupted too: the signal is bound into the request).
export const pollVerifyUntilSettled = (
verifyUrl: string,
poll: PollOptions,
options: LnurlcashOptions,
): Promise<VerifyResult> =>
pollUntil(
async (fetchOptions) => {
const result = await fetchInvoiceVerification(verifyUrl, fetchOptions)
return result.settled ? result : null
},
'Payment not confirmed',
poll,
options,
)
+151 -19
View File
@@ -3,13 +3,24 @@
// that exist: this wallet requests an invoice FROM the target mint // that exist: this wallet requests an invoice FROM the target mint
// (grossed up for its advertised mint fee, so the note that comes out // (grossed up for its advertised mint fee, so the note that comes out
// nets the requested amount), melts source notes to pay it, then claims // nets the requested amount), melts source notes to pay it, then claims
// the target note from the revealed preimage exactly like any other // the target note exactly like any other minted receive. The target
// minted receive. The target invoice settling is the transfer's ground // invoice settling is the transfer's ground truth: it can only settle if
// truth: it can only settle if the source melt's payment arrived, and its // the source melt's payment arrived. How settling is observed and claimed
// verify response is what reveals the preimage to claim with. When it // depends on the target's minting mode (see mint.ts):
// never settles, the source note itself is the oracle - a successful //
// rotate proves the melt never burned it (funds returned), anything else // - UNNAMED target: the verify URL is the observation - its settled
// stays uncertain. // response reveals the preimage, which IS the note's secret, and the
// claim rotates off it immediately.
// - NAMED target: the note itself is the observation - the wallet named
// the output with its own secret at quote time, so the informational
// GET reporting 'minted' IS the settlement proof, and the note is
// already at a secret nobody else has seen. No verify URL is needed
// (but one is used to rescue the mint that took the hash and credited
// the preimage anyway).
//
// When settlement never confirms, the source note itself is the oracle -
// a successful rotate proves the melt never burned it (funds returned),
// anything else stays uncertain.
import { import {
AmbiguousMutationError, AmbiguousMutationError,
@@ -17,6 +28,7 @@ import {
PendingNoteError, PendingNoteError,
buildNoteUrl, buildNoteUrl,
decodeBolt11AmountMsat, decodeBolt11AmountMsat,
fetchInvoiceVerification,
isPreimage, isPreimage,
meltNote, meltNote,
noteK1, noteK1,
@@ -30,8 +42,8 @@ import type {LnurlcashOptions} from 'lnurlcash-kit'
import type {Bearer, NewBearer} from '../types' import type {Bearer, NewBearer} from '../types'
import type {CarveResult} from './carve' import type {CarveResult} from './carve'
import {ensureExactAmount} from './carve' import {ensureExactAmount} from './carve'
import type {ClaimedNote} from './mint' import type {ClaimedNote, PreparedMint} from './mint'
import {claimFromPreimage, prepareMint} from './mint' import {claimFromPreimage, pollMintClaim, prepareMint} from './mint'
import type {PollOptions} from './shared' import type {PollOptions} from './shared'
import type {FundOperationOptions} from './shared' import type {FundOperationOptions} from './shared'
import {assertFundOwner, pollVerifyUntilSettled} from './shared' import {assertFundOwner, pollVerifyUntilSettled} from './shared'
@@ -76,6 +88,10 @@ export type TransferClaimMaterial = {
// the preimage note, unverified, once the preimage is known - the // the preimage note, unverified, once the preimage is known - the
// preimage IS the note secret; the caller must track it and retry // preimage IS the note secret; the caller must track it and retry
note?: NewBearer note?: NewBearer
// named target only: the wallet-chosen secret the target note lands at.
// The note exists at this secret from the moment the invoice settles -
// the caller can rebuild the note URL from it and retry the claim
noteSecret?: string
} }
export type TransferResult = { export type TransferResult = {
@@ -85,12 +101,20 @@ export type TransferResult = {
quote: TransferQuote quote: TransferQuote
// the invoice the source note was melted to pay // the invoice the source note was melted to pay
invoice: string invoice: string
// the target invoice's verify URL - the transfer's ground truth // the target invoice's verify URL - an unnamed target's ground truth
verifyUrl: string // (a named target may not serve one at all; the note itself is observed)
verifyUrl: string | null
sourceServer: string sourceServer: string
targetServer: string targetServer: string
// the fresh target note, on 'settled' // the fresh target note, on 'settled'
mintedAtTarget?: ClaimedNote mintedAtTarget?: ClaimedNote
// on failed-funds-returned, when the source-probe rotate succeeded: the
// returned funds re-secured at a fresh secret. Kept OUT of `carve`
// (which always describes the carve as committed by the onCarve
// checkpoint) so an early-committed carve and a post-wait rotation can
// both be persisted - the carved note the rotate burned must be marked
// spent, this note added
rotatedNote?: NewBearer
// present whenever the claim could still complete later // present whenever the claim could still complete later
claimMaterial?: TransferClaimMaterial claimMaterial?: TransferClaimMaterial
// a fresh secret rescued from an ambiguous rotate while classifying the // a fresh secret rescued from an ambiguous rotate while classifying the
@@ -104,15 +128,36 @@ export type TransferOptions = {
// kit transport overrides (fetch injection, timeouts) // kit transport overrides (fetch injection, timeouts)
kit?: LnurlcashOptions kit?: LnurlcashOptions
assertOwner?: () => void assertOwner?: () => void
// carve checkpoint - committed before the melt and the target
// settlement wait, so an abort mid-wait never strands the carve (see
// FundOperationOptions.onCarve)
onCarve?: FundOperationOptions['onCarve']
}
// the claim retry material for a named target: the note at the wallet's
// own secret, unverified until a claim succeeds
const namedClaimNote = (prepared: PreparedMint, noteSecret: string): NewBearer => {
const note: NewBearer = {
url: buildNoteUrl(prepared.withdrawLink, noteSecret, prepared.expectedNoteValueMsat),
callback: '',
amount: prepared.expectedNoteValueMsat,
verified: false,
}
if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey
return note
} }
export const transferBetweenMints = async ( export const transferBetweenMints = async (
bearers: Bearer[], bearers: Bearer[],
amountMsat: number, amountMsat: number,
targetMint: string, targetMint: string,
{poll = {}, kit = {}, assertOwner}: TransferOptions = {}, {poll = {}, kit = {}, assertOwner, onCarve}: TransferOptions = {},
): Promise<TransferResult> => { ): Promise<TransferResult> => {
const options: FundOperationOptions = assertOwner ? {...kit, assertOwner} : kit const options: FundOperationOptions = {
...kit,
...(assertOwner ? {assertOwner} : {}),
...(onCarve ? {onCarve} : {}),
}
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 +165,7 @@ export const transferBetweenMints = async (
// TARGET mint - a failure here (unreachable, no minting support, amount // TARGET mint - a failure here (unreachable, no minting support, amount
// out of range) leaves every source note untouched // out of range) leaves every source note untouched
const prepared = await prepareMint(targetMint, amountMsat, options) const prepared = await prepareMint(targetMint, amountMsat, options)
if (!prepared.verifyUrl) { if (prepared.mode === 'unnamed' && !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.',
) )
@@ -153,6 +198,13 @@ export const transferBetweenMints = async (
withdrawLink: prepared.withdrawLink, withdrawLink: prepared.withdrawLink,
expectedNoteValueMsat: prepared.expectedNoteValueMsat, expectedNoteValueMsat: prepared.expectedNoteValueMsat,
} }
if (prepared.noteSecret) {
claimMaterial.noteSecret = prepared.noteSecret
// a named target's note is claimable from the wallet's secret alone,
// the moment the invoice settles - track it unverified from the start,
// so a melt that settles after the budget is never a lost note
claimMaterial.note = namedClaimNote(prepared, prepared.noteSecret)
}
// 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}
@@ -171,6 +223,81 @@ export const transferBetweenMints = async (
// payment arrived, and the source probe tells the rest // payment arrived, and the source probe tells the rest
} }
try { try {
if (prepared.mode === 'named') {
const noteSecret = prepared.noteSecret
if (!noteSecret) throw new Error('The target mint was prepared without a note secret.')
// the note appearing at the wallet's own secret IS the settlement
// proof - the target only credits it once the melt's payment landed
try {
const claim = await pollMintClaim(prepared.withdrawLink, noteSecret, poll, options)
if (
claim.state !== 'minted' ||
claim.k1 !== noteSecret ||
claim.amountMsat === null ||
!claim.callback
) {
// 'spent' on a fresh wallet-chosen secret, a claim naming a
// secret other than the one polled (impossible through the kit,
// which returns the queried secret and rejects mismatched
// echoes - asserted anyway: building from it would track the
// wrong note), or an incomplete answer: the target misbehaved -
// the way back to the note is already in the claim material
return {...base, outcome: 'settled-claim-failed', claimMaterial}
}
const note: NewBearer = {
url: buildNoteUrl(prepared.withdrawLink, claim.k1, claim.amountMsat),
callback: claim.callback,
amount: claim.amountMsat,
verified: true,
}
if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey
// no rotate: the secret never rode an invoice (see mint.ts)
return {...base, outcome: 'settled', mintedAtTarget: {note, rotated: true}}
} catch (err) {
// the note never appeared at the wallet's secret within budget.
// Rescue through the quote's verify when the target serves one:
// a mint that took the hash but credited the PREIMAGE (the
// mintToHashIgnoresH adversary) is claimable through the old path
if (verifyUrl) {
try {
const proof = await fetchInvoiceVerification(verifyUrl, options)
if (proof.settled && sameInvoice(proof.pr, invoice) && proof.preimage) {
if (isPreimage(proof.preimage)) {
try {
const claimed = await claimFromPreimage(prepared, proof.preimage, options)
return {...base, outcome: 'settled', mintedAtTarget: claimed}
} catch {
const note: NewBearer = {
url: buildNoteUrl(
prepared.withdrawLink,
proof.preimage,
prepared.expectedNoteValueMsat,
),
callback: '',
amount: prepared.expectedNoteValueMsat,
verified: false,
}
if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey
return {
...base,
outcome: 'settled-claim-failed',
claimMaterial: {...claimMaterial, note},
}
}
}
}
} catch {
// best-effort rescue - fall through to the source-note oracle
}
}
throw err
}
}
if (!verifyUrl) {
throw new Error(
'The target mint did not advertise a verify URL - a transfer there cannot auto-claim.',
)
}
const proof = await pollVerifyUntilSettled(verifyUrl, poll, options) const proof = await pollVerifyUntilSettled(verifyUrl, poll, options)
// the proof-binding rule from pay.ts, extended for the gross-up: the // the proof-binding rule from pay.ts, extended for the gross-up: the
// verify URL is scoped to this invoice's payment hash, so an exact pr // verify URL is scoped to this invoice's payment hash, so an exact pr
@@ -219,15 +346,20 @@ export const transferBetweenMints = async (
// as the way back to the money if the invoice settles later // as the way back to the money if the invoice settles later
try { try {
const rotated = await rotateNote(carve.note.callback, k1, options) const rotated = await rotateNote(carve.note.callback, k1, options)
// the rotate succeeded: the melt provably never landed, and the
// funds are back - re-secured, since the melt attempt put k1 on the
// wire. Reported separately from `carve` (see rotatedNote): the
// carve may already be committed, and its note is what this rotate
// burned
return { return {
...base, ...base,
outcome: 'failed-funds-returned', outcome: 'failed-funds-returned',
carve: { rotatedNote: {
...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),
}, callback: carve.note.callback,
amount: carve.note.amount,
verified: true,
...(carve.note.mintPubkey ? {mintPubkey: carve.note.mintPubkey} : {}),
}, },
} }
} catch (err) { } catch (err) {
+10 -4
View File
@@ -10,6 +10,7 @@ import {
NoteUnknownError, NoteUnknownError,
PendingNoteError, PendingNoteError,
} from 'lnurlcash-kit' } from 'lnurlcash-kit'
import type {LnurlcashOptions} from 'lnurlcash-kit'
import type {Bearer, NewBearer} from './types' import type {Bearer, NewBearer} from './types'
// shared by Scan and Paste: resolve whatever came in to a note URL, ask the // shared by Scan and Paste: resolve whatever came in to a note URL, ask the
@@ -65,16 +66,21 @@ export const receiveNote = async (input: string, existing: Bearer[]): Promise<Ne
// k1 on the wire) still knows the old secret - a rotate burns it and mints // k1 on the wire) still knows the old secret - a rotate burns it and mints
// a fresh one only this wallet knows. Returns the updated note URL. Throws // a fresh one only this wallet knows. Returns the updated note URL. Throws
// when the service refuses (e.g. a plain LUD-03 withdraw link that doesn't // when the service refuses (e.g. a plain LUD-03 withdraw link that doesn't
// speak lnurlcash) - the caller should warn, not fail the receive. // speak lnurlcash) - the caller should warn, not fail the receive. The
export const secureReceivedNote = async (note: { // error carries the fresh secret (kit's newSecretsOf) so the caller can
// rescue a rotate that landed despite its refusal.
export const secureReceivedNote = async (
note: {
url: string url: string
callback: string callback: string
amount: number amount: number
}): Promise<string> => { },
options: LnurlcashOptions = {},
): Promise<string> => {
const k1 = noteK1(note.url) const k1 = noteK1(note.url)
if (!k1 || !note.callback) { if (!k1 || !note.callback) {
throw new Error('Note has no callback to rotate against yet.') throw new Error('Note has no callback to rotate against yet.')
} }
const result = await rotateNote(note.callback, k1) const result = await rotateNote(note.callback, k1, options)
return withNewK1(note.url, result.k1, note.amount, result.signature) return withNewK1(note.url, result.k1, note.amount, result.signature)
} }
+2 -2
View File
@@ -213,11 +213,11 @@ describe('security policy characterization', () => {
}) })
describe('PUBLIC_MINTS', () => { describe('PUBLIC_MINTS', () => {
it('is the curated quick-start list, ported verbatim from lnurl-wallet', () => { it('is the curated quick-start list, ported from lnurl-wallet', () => {
expect(PUBLIC_MINTS).toEqual([ expect(PUBLIC_MINTS).toEqual([
'@mint.600.wtf', '@mint.600.wtf',
'@lnurl.21mint.me', '@lnurl.21mint.me',
'@mint.forgesworn.dev', '@moneyer.dev',
'@lnurl.21linz.at', '@lnurl.21linz.at',
'@minty.exe.xyz', '@minty.exe.xyz',
]) ])
+3 -2
View File
@@ -107,11 +107,12 @@ export const mintAddressCacheInfo = (
// whether this wallet has ever used it). The bare "@domain" form rather // whether this wallet has ever used it). The bare "@domain" form rather
// than spelling out "mint@domain" - still resolves to the exact same // than spelling out "mint@domain" - still resolves to the exact same
// address, just how these mints tend to actually display their own. // address, just how these mints tend to actually display their own.
// Ported verbatim from lnurl-wallet/src/trustedMints.ts. // Ported from lnurl-wallet/src/trustedMints.ts; diverges where the public
// mint landscape did (@mint.forgesworn.dev shut down, @moneyer.dev added).
export const PUBLIC_MINTS = [ export const PUBLIC_MINTS = [
'@mint.600.wtf', '@mint.600.wtf',
'@lnurl.21mint.me', '@lnurl.21mint.me',
'@mint.forgesworn.dev', '@moneyer.dev',
'@lnurl.21linz.at', '@lnurl.21linz.at',
'@minty.exe.xyz', '@minty.exe.xyz',
] ]