lnurlcash-kit 0.4.0 → 0.6.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,8 +1,54 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.6.0 - 2026-08-31
6
+
7
+ - `namesMintOutput()` now requires `commentAllowed >= 64`; the additive
8
+ `mintToHash` advertisement alone no longer authorizes minting.
9
+ - `fetchPayRequest()` rejects a minting payRequest that cannot carry the
10
+ mandatory 64-character commitment, so a caller cannot proceed into an
11
+ invoice flow that has no conforming output name.
12
+ - Mint requests carrying an output hash continue to send identical
13
+ `comment` and `h` fields. Documentation now treats the former as mandatory
14
+ LUD-25 and the latter as the Moneyer/ForgeSworn receipt extension.
15
+
3
16
  Semantic versioning. While the LUD-25 draft is unmerged, `0.x` minor bumps
4
17
  may carry breaking changes; pin an exact version.
5
18
 
19
+ ## 0.5.0 - 2026-08-29
20
+
21
+ **A large merge is folded in batches rather than sent as one over-long
22
+ URL.** LUD-25 bounds a merge by ordinary URL length, not by anything in the
23
+ protocol: every repeated `k1=` costs about 68 characters, and browsers,
24
+ servers and proxies commonly cap a whole URL near 2000. Past roughly 28
25
+ notes `mergeNotes` built a request that something upstream truncates,
26
+ turning a large merge into a malformed one rather than a clean refusal. It
27
+ now measures the URL this SERVICE's own callback actually produces and
28
+ folds the inputs batch into batch, each merge's output carried into the
29
+ next, as the draft advises.
30
+
31
+ A fold can fail with value already moved, which a single merge never could:
32
+ once a batch has landed, the carried note is one the SERVICE has already
33
+ minted, worth every batch folded so far, and the fold holds its only copy.
34
+ It is returned with the failure whatever the class of failure - a network
35
+ drop, a policy refusal, a k1 count the mint caps at, an input mid-melt
36
+ elsewhere - so a caller that persists what `newSecretsOf` hands it cannot
37
+ lose the fold's own output. The error's class is preserved, so a caller
38
+ telling pending apart from spent still can.
39
+
40
+ Batches are bounded by note count as well as URL length. No LUD-25 field
41
+ advertises a SERVICE's own limit on how many `k1` one request may name, and
42
+ the limits in the wild are tighter than 2000 characters allows - moneyer
43
+ defaults to 21, lnurl-mint to 100 - so batching on length alone built
44
+ requests a conforming mint refuses outright. The default is 20, under the
45
+ tightest cap known.
46
+
47
+ `mergeBatches(callback, k1s, options?)` is exported for callers that want to
48
+ plan the batches themselves - `{budget, maxNotes}`, or a bare number for the
49
+ budget as before. A caller that has learned a SERVICE's real limit should
50
+ pass it.
51
+
6
52
  ## 0.4.0 - 2026-08-26
7
53
 
8
54
  **Breaking: `restoreNotes` asks by hash, and no longer discloses note
package/README.md CHANGED
@@ -154,18 +154,10 @@ callback — it is only observable as the note becoming spendable again. Other
154
154
  operations on that `k1` raise `PendingNoteError` meanwhile; retry, never
155
155
  read it as spent.
156
156
 
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.
157
+ **5. Persist the mint secret before requesting the invoice.** Current
158
+ LUD-25 requires `comment=hex(sha256(secret))`; there is no preimage-backed
159
+ creation fallback. If the payRequest cannot carry that 64-character comment,
160
+ do not mint. Existing notes still redeem through ordinary LUD-03.
169
161
 
170
162
  ## Offline verification
171
163
 
@@ -297,18 +289,19 @@ can poll LUD-21 `verify` with the payment hash inside it and take the
297
289
  preimage the moment it settles, which is what a QR code on a desktop screen
298
290
  hands out.
299
291
 
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.
292
+ A current-draft mint binds the note to the hash supplied in the mandatory
293
+ LUD-12 comment. The kit repeats the same value as `h` for the additive
294
+ Moneyer/ForgeSworn receipt extension. You chose the secret, nobody else ever
295
+ had it, and the preimage is ordinary payment proof.
303
296
 
304
297
  ```ts
305
298
  import {
306
299
  fetchPayRequest, requestInvoice, claimMintedNote,
307
- deriveNoteRoot, deriveNoteSecret, hashK1
300
+ deriveNoteRoot, deriveNoteSecret, hashK1, namesMintOutput
308
301
  } from 'lnurlcash-kit'
309
302
 
310
303
  const pay = await fetchPayRequest(payUrl) // a Lightning Address resolves here
311
- if (!pay.mintToHash) { /* preimage path, rotate on claim */ }
304
+ if (!namesMintOutput(pay)) throw new Error('mint lacks commentAllowed: 64')
312
305
 
313
306
  const root = deriveNoteRoot(seed)
314
307
  const k1 = deriveNoteSecret(root, 'mint.example', nextIndex)
@@ -322,26 +315,20 @@ const claim = await claimMintedNote(pay.withdrawLink!, k1)
322
315
  // 'minted' -> claim.amountMsat is what it is worth, claim.callback melts it
323
316
  ```
324
317
 
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:
318
+ Ask before you buy. `commentAllowed >= 64` is the normative minting
319
+ capability. `mintToHash` describes only the additive `h` and receipt fields:
329
320
 
330
321
  | Where | What it means |
331
322
  | --- | --- |
332
- | `PayRequestInfo.mintToHash` | "I accept an `h`." **Decide from this one.** |
323
+ | `PayRequestInfo.commentAllowed` | room for the mandatory hash comment; required for minting |
324
+ | `PayRequestInfo.mintToHash` | "I also accept the matching `h` extension." |
333
325
  | `MintAddressInfo.mintToHash` | the same fact on the experimental discovery document |
334
326
  | `InvoiceResult.mintToHash` | "I bound *this quote* to the hash you named" |
335
327
 
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.
328
+ Decide whether minting is possible from `commentAllowed` on the payRequest;
329
+ never substitute the mint-address extension field. Anything other than
330
+ boolean `true` is no for `mintToHash`, but that affects only extension receipt
331
+ handling. The note remains comment-bound either way.
345
332
 
346
333
  **Persist the secret before you ask for the invoice.** Paying for a note and
347
334
  then losing the secret is the one way this is worse than the preimage scheme,
package/dist/index.d.ts CHANGED
@@ -112,6 +112,11 @@ type SplitResult = {
112
112
  changeSignature?: string;
113
113
  };
114
114
  declare const splitNote: (callback: string, k1s: string[], amountMsat: number, options?: LnurlcashOptions) => Promise<SplitResult>;
115
+ type MergeBatchOptions = {
116
+ budget?: number;
117
+ maxNotes?: number;
118
+ };
119
+ declare const mergeBatches: (callback: string, k1s: string[], options?: MergeBatchOptions | number) => string[][];
115
120
  declare const mergeNotes: (callback: string, k1s: string[], options?: LnurlcashOptions) => Promise<RotateResult>;
116
121
  type SettledNote = {
117
122
  k1: string;
@@ -337,4 +342,4 @@ declare const createClient: (options?: LnurlcashOptions) => {
337
342
  };
338
343
  type LnurlcashClient = ReturnType<typeof createClient>;
339
344
 
340
- 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, 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 };
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 };
package/dist/index.js CHANGED
@@ -948,8 +948,39 @@ var splitNote = async (callback, k1s, amountMsat, options = {}) => {
948
948
  throw keepingOutputs(err, [newK1, changeK1]);
949
949
  }
950
950
  };
951
+ var MAX_URL_CHARS = 2e3;
952
+ var MAX_NOTES_PER_BATCH = 20;
953
+ var mergeBatches = (callback, k1s, options = {}) => {
954
+ const { budget = MAX_URL_CHARS, maxNotes = MAX_NOTES_PER_BATCH } = typeof options === "number" ? { budget: options } : options;
955
+ const placeholder = "0".repeat(64);
956
+ try {
957
+ new URL(callback);
958
+ } catch {
959
+ throw new RequestRefusedError("The service provided an invalid callback URL.");
960
+ }
961
+ const fits = (candidate, carried) => {
962
+ const url = new URL(callback);
963
+ if (carried) url.searchParams.append("k1", placeholder);
964
+ for (const k1 of candidate) url.searchParams.append("k1", k1);
965
+ url.searchParams.append("h", placeholder);
966
+ return url.href.length <= budget;
967
+ };
968
+ const batches = [];
969
+ let batch = [];
970
+ for (const k1 of k1s) {
971
+ const next = [...batch, k1];
972
+ if (batch.length > 0 && (next.length > maxNotes || !fits(next, batches.length > 0))) {
973
+ batches.push(batch);
974
+ batch = [k1];
975
+ } else batch = next;
976
+ }
977
+ if (batch.length > 0) batches.push(batch);
978
+ return batches;
979
+ };
951
980
  var mergeNotes = async (callback, k1s, options = {}) => {
952
981
  const opts = resolveOptions(options);
982
+ const batches = mergeBatches(callback, k1s);
983
+ if (batches.length > 1) return foldNotes(callback, batches, opts, options);
953
984
  const newK1 = opts.randomSecret();
954
985
  try {
955
986
  const result = await mergeNotesWithHash(callback, k1s, hashK1(newK1), options);
@@ -961,6 +992,34 @@ var mergeNotes = async (callback, k1s, options = {}) => {
961
992
  throw keepingOutputs(err, [newK1]);
962
993
  }
963
994
  };
995
+ var foldNotes = async (callback, batches, opts, options) => {
996
+ let carried = null;
997
+ let signature;
998
+ for (const batch of batches) {
999
+ const inputs = carried === null ? batch : [carried, ...batch];
1000
+ const newK1 = opts.randomSecret();
1001
+ try {
1002
+ const result = await mergeNotesWithHash(callback, inputs, hashK1(newK1), options);
1003
+ carried = newK1;
1004
+ signature = result.signature;
1005
+ } catch (err) {
1006
+ const live = carried === null ? [newK1] : [carried, newK1];
1007
+ if (err instanceof AmbiguousMintError) {
1008
+ throw new AmbiguousMutationError(err.message, live);
1009
+ }
1010
+ if (carried === null) throw keepingOutputs(err, live);
1011
+ if (err instanceof ServiceRejectedError) {
1012
+ err.newSecrets = live;
1013
+ throw err;
1014
+ }
1015
+ throw new AmbiguousMutationError(
1016
+ err instanceof Error ? err.message : String(err),
1017
+ live
1018
+ );
1019
+ }
1020
+ }
1021
+ return { k1: carried, signature };
1022
+ };
964
1023
  var settleNote = async (baseUrl, k1, expectedAmountMsat, signature, options = {}) => {
965
1024
  const info = await fetchNoteInfo(
966
1025
  withNewK1(baseUrl, k1, expectedAmountMsat, signature),
@@ -989,14 +1048,23 @@ var fetchPayRequest = async (url, options = {}) => {
989
1048
  throw new ProtocolError("Not a payRequest (unexpected response).");
990
1049
  }
991
1050
  const mintFee = typeof body.metadata === "string" ? parseMintFee(body.metadata) : null;
1051
+ const commentAllowed = asNumber(body.commentAllowed);
1052
+ if (body.withdrawLink !== void 0 && typeof body.withdrawLink !== "string") {
1053
+ throw new ProtocolError("A minting payRequest has an invalid withdrawLink.");
1054
+ }
1055
+ if (typeof body.withdrawLink === "string" && !(typeof commentAllowed === "number" && commentAllowed >= 64)) {
1056
+ throw new ProtocolError(
1057
+ "A minting payRequest must allow a 64-character output commitment."
1058
+ );
1059
+ }
992
1060
  return {
993
1061
  ...body,
994
1062
  mintFee: mintFee ?? void 0,
995
1063
  mintToHash: asBoolean(body.mintToHash),
996
- commentAllowed: asNumber(body.commentAllowed)
1064
+ commentAllowed
997
1065
  };
998
1066
  };
999
- var namesMintOutput = (info) => info.mintToHash === true || typeof info.commentAllowed === "number" && info.commentAllowed >= 64;
1067
+ var namesMintOutput = (info) => typeof info.commentAllowed === "number" && info.commentAllowed >= 64;
1000
1068
  var asBoundMintCommitment = (value) => {
1001
1069
  if (!value || typeof value !== "object") return void 0;
1002
1070
  const raw = value;
@@ -1303,4 +1371,4 @@ var createClient = (options = {}) => ({
1303
1371
  settleNoteForValue: (noteUrl, terms) => settleNoteForValue(noteUrl, terms, options)
1304
1372
  });
1305
1373
 
1306
- 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, 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 };
1374
+ 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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lnurlcash-kit",
3
- "version": "0.4.0",
3
+ "version": "0.6.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.5.0",
64
64
  "tsup": "^8.5.0",
65
65
  "typescript": "^5.7.0",
66
66
  "vitest": "^3.0.0"