lnurlcash-kit 0.6.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 +54 -0
- package/README.md +47 -21
- package/dist/index.d.ts +8 -2
- package/dist/index.js +76 -13
- package/llms.txt +30 -11
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,60 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
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
|
+
|
|
5
59
|
## 0.6.0 - 2026-08-31
|
|
6
60
|
|
|
7
61
|
- `namesMintOutput()` now requires `commentAllowed >= 64`; the additive
|
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
|
-
|
|
63
|
-
|
|
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
|
|
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.
|
|
111
|
-
as idempotent, and an LNURLcash mutation is not —
|
|
112
|
-
input.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
123
|
-
|
|
122
|
+
development, by two different mechanisms.
|
|
123
|
+
|
|
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
|
+
```
|
|
124
133
|
|
|
125
|
-
|
|
126
|
-
|
|
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 {
|
|
@@ -161,8 +178,9 @@ do not mint. Existing notes still redeem through ordinary LUD-03.
|
|
|
161
178
|
|
|
162
179
|
## Offline verification
|
|
163
180
|
|
|
164
|
-
|
|
165
|
-
|
|
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:
|
|
166
184
|
|
|
167
185
|
```
|
|
168
186
|
message = "LNURLcash:" || amount_msat || ":" || hex(sha256(k1))
|
|
@@ -170,6 +188,14 @@ digest = sha256(sha256("Lightning Signed Message:" || message))
|
|
|
170
188
|
sig = 65 bytes, r || s || recovery_id
|
|
171
189
|
```
|
|
172
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
|
+
|
|
173
199
|
`verifyNoteSignature` recovers the pubkey and compares it to `mintPubkey`.
|
|
174
200
|
It accepts the recovery id at either end, because lnurl-mint once emitted
|
|
175
201
|
the reverse layout and other implementations may still; trying both is safe,
|
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
|
|
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
|
|
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, {
|
|
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, {
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
@@ -1196,7 +1259,7 @@ var claimMintedNote = async (withdrawLink, k1, options = {}) => {
|
|
|
1196
1259
|
|
|
1197
1260
|
// src/settle.ts
|
|
1198
1261
|
var normaliseHost = (value) => serverOf(value.trim().replace(/^@/, "")).toLowerCase();
|
|
1199
|
-
var settleNoteForValue = async (noteUrl, { mints, minMsat, requireSignature = false }, options = {}) => {
|
|
1262
|
+
var settleNoteForValue = async (noteUrl, { mints, minMsat, requireSignature: requireSignature2 = false }, options = {}) => {
|
|
1200
1263
|
const url = resolveNoteInput(noteUrl);
|
|
1201
1264
|
if (!url) {
|
|
1202
1265
|
throw new RequestRefusedError(
|
|
@@ -1212,7 +1275,7 @@ var settleNoteForValue = async (noteUrl, { mints, minMsat, requireSignature = fa
|
|
|
1212
1275
|
}
|
|
1213
1276
|
const k1 = requireNoteK1(url);
|
|
1214
1277
|
const info = await fetchNoteInfo(url, options);
|
|
1215
|
-
if (
|
|
1278
|
+
if (requireSignature2) {
|
|
1216
1279
|
const signature = noteSignature(url);
|
|
1217
1280
|
if (!signature) {
|
|
1218
1281
|
throw new ServiceRejectedError("This note carries no signature.");
|
|
@@ -1371,4 +1434,4 @@ var createClient = (options = {}) => ({
|
|
|
1371
1434
|
settleNoteForValue: (noteUrl, terms) => settleNoteForValue(noteUrl, terms, options)
|
|
1372
1435
|
});
|
|
1373
1436
|
|
|
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 };
|
|
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
|
|
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
|
|
68
|
-
or NoteUnknownError from a mutation, because
|
|
69
|
-
GET looks like
|
|
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
|
|
116
|
-
|
|
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.
|
|
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.
|
|
63
|
+
"lnurlcash-conformance": "^0.6.0",
|
|
64
64
|
"tsup": "^8.5.0",
|
|
65
65
|
"typescript": "^5.7.0",
|
|
66
66
|
"vitest": "^3.0.0"
|