lnurlcash-kit 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,72 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.7.0 - 2026-09-04
6
+
7
+ **Offline verification is mandatory, and this library now insists on it.**
8
+ LUD-25 stopped treating a note signature as optional: a SERVICE MUST
9
+ publish `mintPubkey` and MUST sign every note a rotate, split or merge
10
+ mints. A wallet that quietly accepted unsigned notes was handing its holder
11
+ something nobody downstream could check, which is exactly the gap offline
12
+ verification exists to close.
13
+
14
+ - `fetchNoteInfo` and `fetchNoteInfoByHash` refuse a `withdrawRequest` that
15
+ publishes no `mintPubkey`, or one that is not a 33-byte compressed
16
+ secp256k1 key. `WithdrawRequestInfo.mintPubkey` is typed as present.
17
+ - `rotateNote`, `splitNote`, `mergeNotes` and their `*WithHash` forms throw
18
+ the new `UnverifiableNoteError` when the SERVICE confirms the mutation but
19
+ returns no `sig` (or no `sig2` on a split's change).
20
+ - **That error carries the secrets.** The mutation landed - `status` was OK -
21
+ so the note exists at the hash the wallet disclosed and its secret is the
22
+ only key to that value. `newSecretsOf()` reads them exactly as it reads
23
+ them off an ambiguous mutation. Enforcing conformance must never be the
24
+ thing that destroys the money.
25
+ - `requireSignatures: false` opts out, for a mint that predates the
26
+ requirement. One option, stated once, at the call site that needs it.
27
+
28
+ **A mutation whose answer was lost is now re-sent, and usually completes.**
29
+ LUD-25 gained a "Retrying a mutation" section: a SERVICE MUST answer a
30
+ byte-identical rotate, split or merge with the success it already returned,
31
+ signature and all, rather than with the already-spent refusal its burned
32
+ inputs would otherwise earn.
33
+
34
+ That closes the sharpest edge in the protocol. Every mutation is a GET,
35
+ HTTP treats GET as idempotent, and stacks retry one whose connection
36
+ dropped - browsers on a stale keep-alive, Go's `net/http` on a reused
37
+ connection, the JDK's `HttpClient` with no way to switch it off. The mint
38
+ saw the request twice, answered the second as already spent, and the wallet
39
+ was told a mutation had not happened while a note sat at the hash it had
40
+ disclosed. Now the second answer is the first one.
41
+
42
+ - `mutationRetries` defaults to 1. Set 0 for the previous behaviour.
43
+ - Only rotate, split and merge. A melt is **never** retried: it carries
44
+ `pr`, is paid out asynchronously, and the replay rule does not cover it.
45
+ - Only an ambiguous failure is retried. A definitive refusal is the
46
+ SERVICE's considered answer and asking again cannot improve it.
47
+ - The retry re-sends the identical request rather than rebuilding it. The
48
+ replay is matched on the k1 set, `h`, `h2` and `amount`, so a freshly
49
+ generated secret would make the second attempt a different mutation - and
50
+ a second real burn.
51
+
52
+ Against a SERVICE that has not implemented the replay rule, retrying leaves
53
+ a caller exactly where giving up would have: the same secrets on the same
54
+ error, and the same instruction to go and ask what the note at each hash is
55
+ worth.
56
+
57
+ Requires `lnurlcash-conformance` 0.6.0, whose vectors carry the same MUSTs.
58
+
59
+ ## 0.6.0 - 2026-08-31
60
+
61
+ - `namesMintOutput()` now requires `commentAllowed >= 64`; the additive
62
+ `mintToHash` advertisement alone no longer authorizes minting.
63
+ - `fetchPayRequest()` rejects a minting payRequest that cannot carry the
64
+ mandatory 64-character commitment, so a caller cannot proceed into an
65
+ invoice flow that has no conforming output name.
66
+ - Mint requests carrying an output hash continue to send identical
67
+ `comment` and `h` fields. Documentation now treats the former as mandatory
68
+ LUD-25 and the latter as the Moneyer/ForgeSworn receipt extension.
69
+
3
70
  Semantic versioning. While the LUD-25 draft is unmerged, `0.x` minor bumps
4
71
  may carry breaking changes; pin an exact version.
5
72
 
package/README.md CHANGED
@@ -58,14 +58,15 @@ console.log(info.maxWithdrawable, 'msat')
58
58
  // that GET put the secret on the wire, so rotate it
59
59
  const fresh = await rotateNote(info.callback, info.k1)
60
60
 
61
- // and check the mint really issued it, without asking anyone
62
- if (info.mintPubkey && fresh.signature) {
63
- verifyNoteSignature(fresh.k1, info.maxWithdrawable, fresh.signature, info.mintPubkey)
64
- }
61
+ // and check the mint really issued it, without asking anyone. Both fields
62
+ // are guaranteed here: LUD-25 requires the mint to publish mintPubkey and
63
+ // to sign what it mints, and this library refuses a mint that does neither.
64
+ verifyNoteSignature(fresh.k1, info.maxWithdrawable, fresh.signature!, info.mintPubkey)
65
65
  ```
66
66
 
67
67
  Every request function takes options last — `fetch`, `timeoutMs`, `offline`,
68
- `randomSecret`. `createClient(options)` binds one set once:
68
+ `randomSecret`, `requireSignatures`, `mutationRetries`.
69
+ `createClient(options)` binds one set once:
69
70
 
70
71
  ```ts
71
72
  const client = createClient({timeoutMs: 10_000})
@@ -107,23 +108,39 @@ try {
107
108
 
108
109
  `RequestRefusedError` is the opposite and safe: nothing left the process.
109
110
 
110
- **3. Your HTTP stack must not retry.** Every mutation is a GET, HTTP treats GET
111
- as idempotent, and an LNURLcash mutation is not — the first attempt burns the
112
- input. A retried mutation is answered "already spent", which reads as a
113
- *definitive* rejection, so the fresh secret gets discarded along with the note
114
- the service just minted. Node's `fetch` does not retry on its own, but a
115
- browser will resend an idempotent request that failed on a stale pooled
116
- connection, and any retry wrapper, service worker or proxy in front of this
117
- will do the same. If you pass your own `fetch`, do not make it retry these.
118
-
119
- This is not hypothetical: the same hazard broke the
111
+ **3. A retried mutation is now a replay, not a double spend.** Every mutation
112
+ is a GET, HTTP treats GET as idempotent, and an LNURLcash mutation is not —
113
+ the first attempt burns the input. For most of this draft's life that was the
114
+ sharpest edge in the protocol: a stack that resent a dropped GET got "already
115
+ spent" for the second attempt, which reads as a *definitive* rejection, so the
116
+ fresh secret got discarded along with the note the service had just minted.
117
+ Node's `fetch` does not retry on its own, but a browser resends an idempotent
118
+ request that failed on a stale pooled connection, and Go and the JDK do the
119
+ same by their own routes — the hazard broke the
120
120
  [Kotlin](https://github.com/TheCryptoDonkey/lnurlcash-kotlin) and
121
121
  [Go](https://github.com/TheCryptoDonkey/lnurlcash-go) siblings during
122
- development, by two different mechanisms, and is now a named scenario in the
123
- conformance vectors.
122
+ development, by two different mechanisms.
124
123
 
125
- Because a retry cannot always be prevented, a mutation refused with the
126
- input already spent or unknown carries its outputs anyway:
124
+ LUD-25 closed it. A service MUST answer a byte-identical rotate, split or
125
+ merge with the success it already returned, signature and all. So this library
126
+ re-sends one whose answer was lost, and an unstoppable transport retry is now
127
+ simply invisible:
128
+
129
+ ```ts
130
+ // the connection dropped after the mint applied this. It completes anyway.
131
+ const fresh = await rotateNote(callback, oldK1)
132
+ ```
133
+
134
+ `mutationRetries` sets how many times (default 1; `0` restores the old
135
+ give-up-at-once behaviour). Only rotate, split and merge are re-sent — never a
136
+ melt, which carries `pr`, is paid asynchronously and has no replay guarantee —
137
+ and only an ambiguous failure, never a refusal the service actually
138
+ considered. The re-sent request is byte-identical, because the replay is
139
+ matched on the k1 set, `h`, `h2` and `amount`.
140
+
141
+ A service that has not implemented the rule answers the second attempt as
142
+ already spent, exactly as before. So the old defence stays: a mutation refused
143
+ with the input already spent or unknown carries its outputs anyway:
127
144
 
128
145
  ```ts
129
146
  try {
@@ -154,23 +171,16 @@ callback — it is only observable as the note becoming spendable again. Other
154
171
  operations on that `k1` raise `PendingNoteError` meanwhile; retry, never
155
172
  read it as spent.
156
173
 
157
- **5. Rotate the instant you claim a minted note.** The preimage that mints a
158
- note is generated by the service, and if it serves
159
- [LUD-21](https://github.com/lnurl/luds/blob/luds/21.md) `verify`, *anyone*
160
- who saw the unpaid invoice can poll for it — the payment hash travels inside
161
- the invoice. First rotater wins. A wallet that rotates on settlement wins by
162
- construction; a human copying a preimage by hand does not.
163
-
164
- That is a race, and the way to win a race is not to enter it. Where a mint
165
- advertises `mintToHash`, name the note you are buying and the preimage is
166
- not its secret at all: see
167
- [Minting a note you named yourself](#minting-a-note-you-named-yourself).
168
- The rule above stands for every mint that does not offer it.
174
+ **5. Persist the mint secret before requesting the invoice.** Current
175
+ LUD-25 requires `comment=hex(sha256(secret))`; there is no preimage-backed
176
+ creation fallback. If the payRequest cannot carry that 64-character comment,
177
+ do not mint. Existing notes still redeem through ordinary LUD-03.
169
178
 
170
179
  ## Offline verification
171
180
 
172
- A service may sign each note with its Lightning node identity key, so a
173
- holder can confirm issuer and amount with nothing but the note:
181
+ Mandatory, and enforced here. A service MUST publish `mintPubkey` and MUST
182
+ sign every note a rotate, split or merge mints, so a holder can confirm
183
+ issuer and amount with nothing but the note:
174
184
 
175
185
  ```
176
186
  message = "LNURLcash:" || amount_msat || ":" || hex(sha256(k1))
@@ -178,6 +188,14 @@ digest = sha256(sha256("Lightning Signed Message:" || message))
178
188
  sig = 65 bytes, r || s || recovery_id
179
189
  ```
180
190
 
191
+ A `withdrawRequest` publishing no `mintPubkey`, or one that is not a 33-byte
192
+ compressed secp256k1 key, is refused with a `ProtocolError`. A mutation the
193
+ service confirms but does not sign raises `UnverifiableNoteError` — which
194
+ **carries the fresh secrets**, because the mutation landed and the note it
195
+ minted is real; read them with `newSecretsOf` and persist them before
196
+ anything else. Pass `requireSignatures: false` to deal with a mint that
197
+ predates the requirement.
198
+
181
199
  `verifyNoteSignature` recovers the pubkey and compares it to `mintPubkey`.
182
200
  It accepts the recovery id at either end, because lnurl-mint once emitted
183
201
  the reverse layout and other implementations may still; trying both is safe,
@@ -297,18 +315,19 @@ can poll LUD-21 `verify` with the payment hash inside it and take the
297
315
  preimage the moment it settles, which is what a QR code on a desktop screen
298
316
  hands out.
299
317
 
300
- A mint can instead bind the note to a hash you supply, the same `h` you
301
- already send on every rotate, split and merge. Then you chose the secret,
302
- nobody else ever had it, and the preimage is an ordinary payment proof.
318
+ A current-draft mint binds the note to the hash supplied in the mandatory
319
+ LUD-12 comment. The kit repeats the same value as `h` for the additive
320
+ Moneyer/ForgeSworn receipt extension. You chose the secret, nobody else ever
321
+ had it, and the preimage is ordinary payment proof.
303
322
 
304
323
  ```ts
305
324
  import {
306
325
  fetchPayRequest, requestInvoice, claimMintedNote,
307
- deriveNoteRoot, deriveNoteSecret, hashK1
326
+ deriveNoteRoot, deriveNoteSecret, hashK1, namesMintOutput
308
327
  } from 'lnurlcash-kit'
309
328
 
310
329
  const pay = await fetchPayRequest(payUrl) // a Lightning Address resolves here
311
- if (!pay.mintToHash) { /* preimage path, rotate on claim */ }
330
+ if (!namesMintOutput(pay)) throw new Error('mint lacks commentAllowed: 64')
312
331
 
313
332
  const root = deriveNoteRoot(seed)
314
333
  const k1 = deriveNoteSecret(root, 'mint.example', nextIndex)
@@ -322,26 +341,20 @@ const claim = await claimMintedNote(pay.withdrawLink!, k1)
322
341
  // 'minted' -> claim.amountMsat is what it is worth, claim.callback melts it
323
342
  ```
324
343
 
325
- Ask before you buy. `mintToHash` is how a mint says it accepts the parameter,
326
- and reading it first is the difference between naming your own note and paying
327
- for one whose secret three other parties can learn. It turns up in three
328
- places, saying three different things:
344
+ Ask before you buy. `commentAllowed >= 64` is the normative minting
345
+ capability. `mintToHash` describes only the additive `h` and receipt fields:
329
346
 
330
347
  | Where | What it means |
331
348
  | --- | --- |
332
- | `PayRequestInfo.mintToHash` | "I accept an `h`." **Decide from this one.** |
349
+ | `PayRequestInfo.commentAllowed` | room for the mandatory hash comment; required for minting |
350
+ | `PayRequestInfo.mintToHash` | "I also accept the matching `h` extension." |
333
351
  | `MintAddressInfo.mintToHash` | the same fact on the experimental discovery document |
334
352
  | `InvoiceResult.mintToHash` | "I bound *this quote* to the hash you named" |
335
353
 
336
- Prefer the payRequest: it is the only endpoint every mint has, it is where
337
- your wallet already is when it is about to mint, and it sits next to the
338
- `withdrawLink` the draft already hangs there for LNURLcash's sake. Fall back
339
- to `fetchMintAddress` for a mint that only advertises on that document. A mint
340
- that says it in neither place ignores the `h`, keys the note by the preimage
341
- as it always has, and the verify path is unchanged. Anything that is not
342
- exactly `true` is a no, everywhere, and `false` on the invoice result is
343
- silence rather than a refusal, so decide from the advertisement and claim by
344
- probing.
354
+ Decide whether minting is possible from `commentAllowed` on the payRequest;
355
+ never substitute the mint-address extension field. Anything other than
356
+ boolean `true` is no for `mintToHash`, but that affects only extension receipt
357
+ handling. The note remains comment-bound either way.
345
358
 
346
359
  **Persist the secret before you ask for the invoice.** Paying for a note and
347
360
  then losing the secret is the one way this is worse than the preimage scheme,
package/dist/index.d.ts CHANGED
@@ -13,6 +13,8 @@ type LnurlcashOptions = {
13
13
  timeoutMs?: number;
14
14
  offline?: boolean;
15
15
  randomSecret?: RandomSecret;
16
+ requireSignatures?: boolean;
17
+ mutationRetries?: number;
16
18
  };
17
19
 
18
20
  type MintFee = {
@@ -41,7 +43,7 @@ type WithdrawRequestInfo = {
41
43
  minWithdrawable: number;
42
44
  maxWithdrawable: number;
43
45
  defaultDescription?: string;
44
- mintPubkey?: string;
46
+ mintPubkey: string;
45
47
  payLink?: string;
46
48
  };
47
49
  declare const fetchNoteInfo: (url: string, options?: LnurlcashOptions) => Promise<WithdrawRequestInfo>;
@@ -308,6 +310,10 @@ declare class HashLookupUnsupportedError extends LnurlcashError {
308
310
  }
309
311
  declare class AmbiguousMintError extends LnurlcashError {
310
312
  }
313
+ declare class UnverifiableNoteError extends LnurlcashError {
314
+ newSecrets: string[];
315
+ constructor(message: string, newSecrets?: string[]);
316
+ }
311
317
  declare class AmbiguousMutationError extends AmbiguousMintError {
312
318
  readonly newSecrets: string[];
313
319
  constructor(message: string, newSecrets: string[]);
@@ -342,4 +348,4 @@ declare const createClient: (options?: LnurlcashOptions) => {
342
348
  };
343
349
  type LnurlcashClient = ReturnType<typeof createClient>;
344
350
 
345
- export { AmbiguousMintError, AmbiguousMutationError, type BoundMintCommitment, type DecodeOptions, HashLookupUnsupportedError, type HashedMutationResult, type HashedSplitResult, InsufficientValueError, type InvoiceRequestOptions, type InvoiceResult, type LnurlcashClient, LnurlcashError, type LnurlcashOptions, type MeltResult, type MintAddressInfo, type MintClaim, type MintContact, type MintFee, type MintFeeBand, type NoteInfoByHash, NoteSpentError, NoteUnknownError, PAYMENT_REQUEST_PREFIX, type PayRequestInfo, type PaymentRequest, type PaymentRequestMethodDetails, PendingNoteError, ProtocolError, type RandomSecret, RequestRefusedError, type RestoreOptions, type RestoreResult, type RestoredNote, type RotateResult, ServiceRejectedError, type SettleForValueOptions, type SettledForValue, type SettledNote, type SignatureCheck, type SplitResult, type UnresolvedIndex, type ValidatedBoundMintReceipt, type VerifyResult, type WithdrawRequestInfo, type WithdrawSuccessResponse, applyMintFee, buildNoteInfoUrlByHash, buildNoteUrl, claimMintedNote, classifyNoteError, createClient, decodeBolt11AmountMsat, decodePaymentRequest, defaultRandomSecret, deriveNoteRoot, deriveNoteSecret, derivedSecretSource, describeMintFee, encodePaymentRequest, fetchInvoiceVerification, fetchMintAddress, fetchNoteInfo, fetchNoteInfoByHash, fetchPayRequest, formatFeePercent, fromBech32Lnurl, fromLud17, grossUpForMintFee, hashK1, isAllowedServiceUrl, isBech32Lnurl, isBolt11Invoice, isLightningAddress, isPaymentRequest, isPreimage, isValidNoteInput, lightningAddressUsername, meltNote, mergeBatches, mergeNotes, mergeNotesWithHash, mintAddressUrl, mintFeeBand, namesMintOutput, newSecretsOf, noteDeclaredAmount, noteK1, noteSignature, noteSignatureDigest, noteSignatureDigestForHash, noteSignatureMessage, noteSignatureMessageForHash, parseMintFee, paymentRequestAmountMsat, probeBurnedNote, requestInvoice, requireBoundMintQuote, requireNoteK1, resolveLnurlInput, resolveMintInput, resolveNoteInput, restoreNotes, rotateNote, rotateNoteWithHash, sameInvoice, serverOf, settleNote, settleNoteForValue, splitNote, splitNoteWithHash, toBech32Lnurl, toLud17w, validateBoundMintReceipt, verifyNoteSignature, verifyNoteSignatureAgainst, verifyNoteSignatureHash, verifyNoteSignatureHashAgainst, withNewK1, withinMintFeeBand, withoutK1 };
351
+ export { AmbiguousMintError, AmbiguousMutationError, type BoundMintCommitment, type DecodeOptions, HashLookupUnsupportedError, type HashedMutationResult, type HashedSplitResult, InsufficientValueError, type InvoiceRequestOptions, type InvoiceResult, type LnurlcashClient, LnurlcashError, type LnurlcashOptions, type MeltResult, type MintAddressInfo, type MintClaim, type MintContact, type MintFee, type MintFeeBand, type NoteInfoByHash, NoteSpentError, NoteUnknownError, PAYMENT_REQUEST_PREFIX, type PayRequestInfo, type PaymentRequest, type PaymentRequestMethodDetails, PendingNoteError, ProtocolError, type RandomSecret, RequestRefusedError, type RestoreOptions, type RestoreResult, type RestoredNote, type RotateResult, ServiceRejectedError, type SettleForValueOptions, type SettledForValue, type SettledNote, type SignatureCheck, type SplitResult, type UnresolvedIndex, UnverifiableNoteError, type ValidatedBoundMintReceipt, type VerifyResult, type WithdrawRequestInfo, type WithdrawSuccessResponse, applyMintFee, buildNoteInfoUrlByHash, buildNoteUrl, claimMintedNote, classifyNoteError, createClient, decodeBolt11AmountMsat, decodePaymentRequest, defaultRandomSecret, deriveNoteRoot, deriveNoteSecret, derivedSecretSource, describeMintFee, encodePaymentRequest, fetchInvoiceVerification, fetchMintAddress, fetchNoteInfo, fetchNoteInfoByHash, fetchPayRequest, formatFeePercent, fromBech32Lnurl, fromLud17, grossUpForMintFee, hashK1, isAllowedServiceUrl, isBech32Lnurl, isBolt11Invoice, isLightningAddress, isPaymentRequest, isPreimage, isValidNoteInput, lightningAddressUsername, meltNote, mergeBatches, mergeNotes, mergeNotesWithHash, mintAddressUrl, mintFeeBand, namesMintOutput, newSecretsOf, noteDeclaredAmount, noteK1, noteSignature, noteSignatureDigest, noteSignatureDigestForHash, noteSignatureMessage, noteSignatureMessageForHash, parseMintFee, paymentRequestAmountMsat, probeBurnedNote, requestInvoice, requireBoundMintQuote, requireNoteK1, resolveLnurlInput, resolveMintInput, resolveNoteInput, restoreNotes, rotateNote, rotateNoteWithHash, sameInvoice, serverOf, settleNote, settleNoteForValue, splitNote, splitNoteWithHash, toBech32Lnurl, toLud17w, validateBoundMintReceipt, verifyNoteSignature, verifyNoteSignatureAgainst, verifyNoteSignatureHash, verifyNoteSignatureHashAgainst, withNewK1, withinMintFeeBand, withoutK1 };
package/dist/index.js CHANGED
@@ -264,6 +264,13 @@ var HashLookupUnsupportedError = class extends LnurlcashError {
264
264
  };
265
265
  var AmbiguousMintError = class extends LnurlcashError {
266
266
  };
267
+ var UnverifiableNoteError = class extends LnurlcashError {
268
+ newSecrets;
269
+ constructor(message, newSecrets = []) {
270
+ super(message);
271
+ this.newSecrets = newSecrets;
272
+ }
273
+ };
267
274
  var AmbiguousMutationError = class extends AmbiguousMintError {
268
275
  newSecrets;
269
276
  constructor(message, newSecrets) {
@@ -283,6 +290,7 @@ var InsufficientValueError = class extends ServiceRejectedError {
283
290
  };
284
291
  var newSecretsOf = (err) => {
285
292
  if (err instanceof AmbiguousMutationError) return err.newSecrets;
293
+ if (err instanceof UnverifiableNoteError) return err.newSecrets;
286
294
  if (err instanceof ServiceRejectedError) return err.newSecrets ?? [];
287
295
  return [];
288
296
  };
@@ -465,8 +473,18 @@ var resolveOptions = (options = {}) => ({
465
473
  fetch: options.fetch ?? ((...args) => globalThis.fetch(...args)),
466
474
  timeoutMs: options.timeoutMs ?? 3e4,
467
475
  offline: options.offline ?? false,
468
- randomSecret: options.randomSecret ?? defaultRandomSecret
476
+ randomSecret: options.randomSecret ?? defaultRandomSecret,
477
+ requireSignatures: options.requireSignatures ?? true,
478
+ // A negative or non-finite count is read as none rather than thrown on:
479
+ // this is a resilience knob, and refusing the whole operation over it
480
+ // would be a worse answer than not retrying.
481
+ mutationRetries: normaliseRetries(options.mutationRetries)
469
482
  });
483
+ var normaliseRetries = (value) => {
484
+ if (value === void 0) return 1;
485
+ if (!Number.isFinite(value) || value <= 0) return 0;
486
+ return Math.floor(value);
487
+ };
470
488
  var REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
471
489
  var MAX_REDIRECTS = 5;
472
490
  var MAX_BODY_BYTES = 1048576;
@@ -699,10 +717,17 @@ var verifyNoteSignature = (k1, amountMsat, signatureHex, mintPubkeys) => verifyN
699
717
  var verifyNoteSignatureHash = (h, amountMsat, signatureHex, mintPubkeys) => verifyNoteSignatureHashAgainst(h, amountMsat, signatureHex, mintPubkeys).valid;
700
718
 
701
719
  // src/client.ts
702
- var assertWithdrawRequestShape = (body, { requireK1 }) => {
720
+ var COMPRESSED_PUBKEY = /^0[23][0-9a-f]{64}$/;
721
+ var isCompressedPubkey = (value) => typeof value === "string" && COMPRESSED_PUBKEY.test(value.trim().toLowerCase());
722
+ var assertWithdrawRequestShape = (body, { requireK1, requireMintPubkey }) => {
703
723
  if (body?.tag !== "withdrawRequest" || typeof body.callback !== "string" || requireK1 && typeof body.k1 !== "string" || typeof body.maxWithdrawable !== "number" || !Number.isSafeInteger(body.maxWithdrawable) || body.maxWithdrawable < 0 || body.minWithdrawable !== void 0 && (typeof body.minWithdrawable !== "number" || !Number.isSafeInteger(body.minWithdrawable) || body.minWithdrawable < 0 || body.minWithdrawable > body.maxWithdrawable)) {
704
724
  throw new ProtocolError("Not a withdrawRequest (unexpected response).");
705
725
  }
726
+ if (requireMintPubkey && !isCompressedPubkey(body.mintPubkey)) {
727
+ throw new ProtocolError(
728
+ body.mintPubkey === void 0 ? "This service publishes no mintPubkey, so its notes cannot be verified offline (LUD-25 requires one)." : "This service published a mintPubkey that is not a 33-byte compressed secp256k1 key."
729
+ );
730
+ }
706
731
  };
707
732
  var fetchNoteInfo = async (url, options = {}) => {
708
733
  const opts = resolveOptions(options);
@@ -715,7 +740,10 @@ var fetchNoteInfo = async (url, options = {}) => {
715
740
  if (err instanceof ServiceRejectedError) throw classifyNoteError(err.reason);
716
741
  throw err;
717
742
  }
718
- assertWithdrawRequestShape(body, { requireK1: true });
743
+ assertWithdrawRequestShape(body, {
744
+ requireK1: true,
745
+ requireMintPubkey: opts.requireSignatures
746
+ });
719
747
  const queried = noteK1(url);
720
748
  if (queried && body.k1.toLowerCase() !== queried) {
721
749
  throw new ProtocolError(
@@ -738,7 +766,10 @@ var fetchNoteInfoByHash = async (withdrawLink, h, options = {}) => {
738
766
  if (err instanceof ServiceRejectedError) throw classifyNoteError(err.reason);
739
767
  throw err;
740
768
  }
741
- assertWithdrawRequestShape(body, { requireK1: false });
769
+ assertWithdrawRequestShape(body, {
770
+ requireK1: false,
771
+ requireMintPubkey: opts.requireSignatures
772
+ });
742
773
  const info = body;
743
774
  const payLink = sameOriginPayLink(body.payLink, reqUrl);
744
775
  if (payLink === void 0) delete info.payLink;
@@ -857,6 +888,28 @@ var callbackRequest = async (callback, params, options) => {
857
888
  }
858
889
  return body;
859
890
  };
891
+ var replayableCallbackRequest = async (callback, params, options) => {
892
+ const { mutationRetries } = resolveOptions(options);
893
+ let lastError;
894
+ for (let attempt = 0; ; attempt++) {
895
+ try {
896
+ return await callbackRequest(callback, params, options);
897
+ } catch (err) {
898
+ if (!(err instanceof AmbiguousMintError) || attempt >= mutationRetries) {
899
+ throw err;
900
+ }
901
+ lastError = err;
902
+ }
903
+ }
904
+ throw lastError;
905
+ };
906
+ var requireSignature = (value, options, what) => {
907
+ if (typeof value === "string" && value.length > 0) return value;
908
+ if (!resolveOptions(options).requireSignatures) return void 0;
909
+ throw new UnverifiableNoteError(
910
+ `The service confirmed the ${what} but returned no signature, so the note it just minted cannot be verified offline. The note exists - keep the secret.`
911
+ );
912
+ };
860
913
  var meltNote = async (callback, k1, pr, options = {}) => {
861
914
  const body = await callbackRequest(
862
915
  callback,
@@ -872,7 +925,7 @@ var meltNote = async (callback, k1, pr, options = {}) => {
872
925
  };
873
926
  };
874
927
  var rotateNoteWithHash = async (callback, k1, h, options = {}) => {
875
- const body = await callbackRequest(
928
+ const body = await replayableCallbackRequest(
876
929
  callback,
877
930
  [
878
931
  ["k1", k1],
@@ -880,10 +933,10 @@ var rotateNoteWithHash = async (callback, k1, h, options = {}) => {
880
933
  ],
881
934
  options
882
935
  );
883
- return { signature: body.sig };
936
+ return { signature: requireSignature(body.sig, options, "rotate") };
884
937
  };
885
938
  var splitNoteWithHash = async (callback, k1s, amountMsat, h, h2, options = {}) => {
886
- const body = await callbackRequest(
939
+ const body = await replayableCallbackRequest(
887
940
  callback,
888
941
  [
889
942
  ...k1s.map((k1) => ["k1", k1]),
@@ -893,20 +946,26 @@ var splitNoteWithHash = async (callback, k1s, amountMsat, h, h2, options = {}) =
893
946
  ],
894
947
  options
895
948
  );
896
- return { signature: body.sig, changeSignature: body.sig2 };
949
+ return {
950
+ signature: requireSignature(body.sig, options, "split"),
951
+ changeSignature: requireSignature(body.sig2, options, "split's change")
952
+ };
897
953
  };
898
954
  var mergeNotesWithHash = async (callback, k1s, h, options = {}) => {
899
- const body = await callbackRequest(
955
+ const body = await replayableCallbackRequest(
900
956
  callback,
901
957
  [...k1s.map((k1) => ["k1", k1]), ["h", h]],
902
958
  options
903
959
  );
904
- return { signature: body.sig };
960
+ return { signature: requireSignature(body.sig, options, "merge") };
905
961
  };
906
962
  var keepingOutputs = (err, newSecrets) => {
907
963
  if (err instanceof NoteSpentError || err instanceof NoteUnknownError) {
908
964
  err.newSecrets = newSecrets;
909
965
  }
966
+ if (err instanceof UnverifiableNoteError) {
967
+ err.newSecrets = newSecrets;
968
+ }
910
969
  return err;
911
970
  };
912
971
  var rotateNote = async (callback, k1, options = {}) => {
@@ -1012,6 +1071,10 @@ var foldNotes = async (callback, batches, opts, options) => {
1012
1071
  err.newSecrets = live;
1013
1072
  throw err;
1014
1073
  }
1074
+ if (err instanceof UnverifiableNoteError) {
1075
+ err.newSecrets = live;
1076
+ throw err;
1077
+ }
1015
1078
  throw new AmbiguousMutationError(
1016
1079
  err instanceof Error ? err.message : String(err),
1017
1080
  live
@@ -1048,14 +1111,23 @@ var fetchPayRequest = async (url, options = {}) => {
1048
1111
  throw new ProtocolError("Not a payRequest (unexpected response).");
1049
1112
  }
1050
1113
  const mintFee = typeof body.metadata === "string" ? parseMintFee(body.metadata) : null;
1114
+ const commentAllowed = asNumber(body.commentAllowed);
1115
+ if (body.withdrawLink !== void 0 && typeof body.withdrawLink !== "string") {
1116
+ throw new ProtocolError("A minting payRequest has an invalid withdrawLink.");
1117
+ }
1118
+ if (typeof body.withdrawLink === "string" && !(typeof commentAllowed === "number" && commentAllowed >= 64)) {
1119
+ throw new ProtocolError(
1120
+ "A minting payRequest must allow a 64-character output commitment."
1121
+ );
1122
+ }
1051
1123
  return {
1052
1124
  ...body,
1053
1125
  mintFee: mintFee ?? void 0,
1054
1126
  mintToHash: asBoolean(body.mintToHash),
1055
- commentAllowed: asNumber(body.commentAllowed)
1127
+ commentAllowed
1056
1128
  };
1057
1129
  };
1058
- var namesMintOutput = (info) => info.mintToHash === true || typeof info.commentAllowed === "number" && info.commentAllowed >= 64;
1130
+ var namesMintOutput = (info) => typeof info.commentAllowed === "number" && info.commentAllowed >= 64;
1059
1131
  var asBoundMintCommitment = (value) => {
1060
1132
  if (!value || typeof value !== "object") return void 0;
1061
1133
  const raw = value;
@@ -1187,7 +1259,7 @@ var claimMintedNote = async (withdrawLink, k1, options = {}) => {
1187
1259
 
1188
1260
  // src/settle.ts
1189
1261
  var normaliseHost = (value) => serverOf(value.trim().replace(/^@/, "")).toLowerCase();
1190
- var settleNoteForValue = async (noteUrl, { mints, minMsat, requireSignature = false }, options = {}) => {
1262
+ var settleNoteForValue = async (noteUrl, { mints, minMsat, requireSignature: requireSignature2 = false }, options = {}) => {
1191
1263
  const url = resolveNoteInput(noteUrl);
1192
1264
  if (!url) {
1193
1265
  throw new RequestRefusedError(
@@ -1203,7 +1275,7 @@ var settleNoteForValue = async (noteUrl, { mints, minMsat, requireSignature = fa
1203
1275
  }
1204
1276
  const k1 = requireNoteK1(url);
1205
1277
  const info = await fetchNoteInfo(url, options);
1206
- if (requireSignature) {
1278
+ if (requireSignature2) {
1207
1279
  const signature = noteSignature(url);
1208
1280
  if (!signature) {
1209
1281
  throw new ServiceRejectedError("This note carries no signature.");
@@ -1362,4 +1434,4 @@ var createClient = (options = {}) => ({
1362
1434
  settleNoteForValue: (noteUrl, terms) => settleNoteForValue(noteUrl, terms, options)
1363
1435
  });
1364
1436
 
1365
- export { AmbiguousMintError, AmbiguousMutationError, HashLookupUnsupportedError, InsufficientValueError, LnurlcashError, NoteSpentError, NoteUnknownError, PAYMENT_REQUEST_PREFIX, PendingNoteError, ProtocolError, RequestRefusedError, ServiceRejectedError, applyMintFee, buildNoteInfoUrlByHash, buildNoteUrl, claimMintedNote, classifyNoteError, createClient, decodeBolt11AmountMsat, decodePaymentRequest, defaultRandomSecret, deriveNoteRoot, deriveNoteSecret, derivedSecretSource, describeMintFee, encodePaymentRequest, fetchInvoiceVerification, fetchMintAddress, fetchNoteInfo, fetchNoteInfoByHash, fetchPayRequest, formatFeePercent, fromBech32Lnurl, fromLud17, grossUpForMintFee, hashK1, isAllowedServiceUrl, isBech32Lnurl, isBolt11Invoice, isLightningAddress, isPaymentRequest, isPreimage, isValidNoteInput, lightningAddressUsername, meltNote, mergeBatches, mergeNotes, mergeNotesWithHash, mintAddressUrl, mintFeeBand, namesMintOutput, newSecretsOf, noteDeclaredAmount, noteK1, noteSignature, noteSignatureDigest, noteSignatureDigestForHash, noteSignatureMessage, noteSignatureMessageForHash, parseMintFee, paymentRequestAmountMsat, probeBurnedNote, requestInvoice, requireBoundMintQuote, requireNoteK1, resolveLnurlInput, resolveMintInput, resolveNoteInput, restoreNotes, rotateNote, rotateNoteWithHash, sameInvoice, serverOf, settleNote, settleNoteForValue, splitNote, splitNoteWithHash, toBech32Lnurl, toLud17w, validateBoundMintReceipt, verifyNoteSignature, verifyNoteSignatureAgainst, verifyNoteSignatureHash, verifyNoteSignatureHashAgainst, withNewK1, withinMintFeeBand, withoutK1 };
1437
+ export { AmbiguousMintError, AmbiguousMutationError, HashLookupUnsupportedError, InsufficientValueError, LnurlcashError, NoteSpentError, NoteUnknownError, PAYMENT_REQUEST_PREFIX, PendingNoteError, ProtocolError, RequestRefusedError, ServiceRejectedError, UnverifiableNoteError, applyMintFee, buildNoteInfoUrlByHash, buildNoteUrl, claimMintedNote, classifyNoteError, createClient, decodeBolt11AmountMsat, decodePaymentRequest, defaultRandomSecret, deriveNoteRoot, deriveNoteSecret, derivedSecretSource, describeMintFee, encodePaymentRequest, fetchInvoiceVerification, fetchMintAddress, fetchNoteInfo, fetchNoteInfoByHash, fetchPayRequest, formatFeePercent, fromBech32Lnurl, fromLud17, grossUpForMintFee, hashK1, isAllowedServiceUrl, isBech32Lnurl, isBolt11Invoice, isLightningAddress, isPaymentRequest, isPreimage, isValidNoteInput, lightningAddressUsername, meltNote, mergeBatches, mergeNotes, mergeNotesWithHash, mintAddressUrl, mintFeeBand, namesMintOutput, newSecretsOf, noteDeclaredAmount, noteK1, noteSignature, noteSignatureDigest, noteSignatureDigestForHash, noteSignatureMessage, noteSignatureMessageForHash, parseMintFee, paymentRequestAmountMsat, probeBurnedNote, requestInvoice, requireBoundMintQuote, requireNoteK1, resolveLnurlInput, resolveMintInput, resolveNoteInput, restoreNotes, rotateNote, rotateNoteWithHash, sameInvoice, serverOf, settleNote, settleNoteForValue, splitNote, splitNoteWithHash, toBech32Lnurl, toLud17w, validateBoundMintReceipt, verifyNoteSignature, verifyNoteSignatureAgainst, verifyNoteSignatureHash, verifyNoteSignatureHashAgainst, withNewK1, withinMintFeeBand, withoutK1 };
package/llms.txt CHANGED
@@ -18,10 +18,10 @@ Reference wallet: https://github.com/dni/lnurl-wallet
18
18
  ## Core API
19
19
 
20
20
  resolveNoteInput(text) -> url | null accepts bech32 LNURL, lnurlw://, https
21
- fetchNoteInfo(url, opts?) -> {callback, k1, maxWithdrawable, mintPubkey?}
22
- rotateNote(callback, k1, opts?) -> {k1, signature?}
23
- splitNote(callback, k1s, amountMsat, opts?) -> {k1, change, signature?, changeSignature?}
24
- mergeNotes(callback, k1s, opts?) -> {k1, signature?}
21
+ fetchNoteInfo(url, opts?) -> {callback, k1, maxWithdrawable, mintPubkey}
22
+ rotateNote(callback, k1, opts?) -> {k1, signature}
23
+ splitNote(callback, k1s, amountMsat, opts?) -> {k1, change, signature, changeSignature}
24
+ mergeNotes(callback, k1s, opts?) -> {k1, signature}
25
25
  meltNote(callback, k1, bolt11, opts?) -> {pr?, verify?}
26
26
  settleNote(baseUrl, k1, expectedMsat, sig?, opts?) -> {k1, amountMsat, signature?, callback}
27
27
  settleNoteForValue(noteUrl, {mints, minMsat, requireSignature?}, opts?)
@@ -53,7 +53,11 @@ paymentRequestAmountMsat(req) -> msat request amount is in SAT, this is the
53
53
  parseMintFee(metadata) / applyMintFee(gross, fee) / grossUpForMintFee(net, fee)
54
54
  createClient(opts) -> all of the above with opts bound
55
55
 
56
- Options (always last): {fetch?, timeoutMs?, offline?, randomSecret?}
56
+ Options (always last): {fetch?, timeoutMs?, offline?, randomSecret?,
57
+ requireSignatures?, mutationRetries?}
58
+ requireSignatures defaults true: refuse a mint that publishes no
59
+ mintPubkey or returns an unsigned mutation. mutationRetries defaults 1:
60
+ re-send a rotate/split/merge whose answer was lost, never a melt.
57
61
 
58
62
  ## Rules an implementation MUST follow
59
63
 
@@ -64,12 +68,23 @@ Options (always last): {fetch?, timeoutMs?, offline?, randomSecret?}
64
68
  3. On ANY error from a mutation call newSecretsOf(err) FIRST, persist what
65
69
  it returns, then call probeBurnedNote to learn what happened. Treating a
66
70
  failure as a failure destroys money the service may already have minted.
67
- AmbiguousMutationError always carries secrets; so does a NoteSpentError
68
- or NoteUnknownError from a mutation, because that is also what a retried
69
- GET looks like once its first attempt burned the input.
71
+ AmbiguousMutationError always carries secrets; so does UnverifiableNoteError,
72
+ and so does a NoteSpentError or NoteUnknownError from a mutation, because
73
+ that is also what a retried GET looks like at a service that has not
74
+ implemented the replay rule.
70
75
  4. RequestRefusedError means nothing was sent - safe to treat as no-op.
71
76
  5. A melt's OK means IN FLIGHT, not spent. PendingNoteError means retry, not
72
77
  spent.
78
+ 5b. Offline verification is MANDATORY. A conforming service publishes
79
+ mintPubkey on every withdrawRequest and returns sig (and sig2 on a split)
80
+ from every rotate/split/merge. This library refuses a service that does
81
+ not; UnverifiableNoteError means the mutation LANDED unsigned, so keep its
82
+ secrets. Opt out per call with requireSignatures: false.
83
+ 5c. A retried rotate/split/merge MUST be answered by the service as a replay
84
+ of the original success, so re-sending one whose answer was lost is safe
85
+ and usually completes it. Re-send the IDENTICAL request: the replay is
86
+ matched on the k1 set, h, h2 and amount, so a fresh secret makes it a
87
+ different mutation and a second real burn. Never re-send a melt.
73
88
  6. Rotate immediately after claiming a minted note: the mint generated that
74
89
  preimage, and LUD-21 verify exposes it to anyone who saw the invoice.
75
90
  Does not apply to a note minted with `h` - see 13.
@@ -112,14 +127,18 @@ Options (always last): {fetch?, timeoutMs?, offline?, randomSecret?}
112
127
  RequestRefusedError nothing sent, note untouched
113
128
  ServiceRejectedError processed and refused (definitive)
114
129
  PendingNoteError a melt is in flight on this k1 - retry
115
- NoteSpentError authoritative: already burned - but from a MUTATION it
116
- may be a retry whose first attempt landed; read
130
+ NoteSpentError authoritative: already burned - but from a MUTATION at
131
+ a service that will not replay a retry, it may be a
132
+ retry whose first attempt landed; read
117
133
  newSecretsOf(err) before believing it
118
134
  NoteUnknownError service does not recognise it
119
135
  AmbiguousMintError outcome UNKNOWN - assume nothing
120
136
  AmbiguousMutationError carries .newSecrets - persist them
137
+ UnverifiableNoteError the mutation LANDED and came back unsigned. The note is
138
+ real; carries .newSecrets - persist them
121
139
  newSecretsOf(err) -> string[] the secrets any error is carrying, or none
122
- ProtocolError a non-mutating response did not match the spec
140
+ ProtocolError a non-mutating response did not match the spec, which
141
+ includes a withdrawRequest publishing no mintPubkey
123
142
 
124
143
  ## Conformance
125
144
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lnurlcash-kit",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "LNURLcash (LUD-25) bearer note client for TypeScript - mint, rotate, split, merge, melt, and verify offline",
5
5
  "author": "TheCryptoDonkey",
6
6
  "license": "MIT",
@@ -60,7 +60,7 @@
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/node": "^26.2.0",
63
- "lnurlcash-conformance": "^0.3.0",
63
+ "lnurlcash-conformance": "^0.6.0",
64
64
  "tsup": "^8.5.0",
65
65
  "typescript": "^5.7.0",
66
66
  "vitest": "^3.0.0"