lnurlcash-kit 0.12.0 → 0.13.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,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0 - 2026-09-11
4
+
5
+ **A plain note is unsigned.** LUD-25 Part 2 certifies `cp1` notes only, and
6
+ the reference mint and moneyer now answer a rotate, split or merge to a hash
7
+ output with a bare `{"status":"OK"}`. This library follows.
8
+
9
+ - `requireSignatures` now defaults to **false**. A hash output that comes
10
+ back unsigned is the spec, not a fault: `signature` (and `changeSignature`)
11
+ is undefined, and no `UnverifiableNoteError` is raised. Set it true to
12
+ keep demanding the old Part 1 signature over the hash.
13
+ - A `cp1` output is owed its `cs1` certificate regardless of the option: a
14
+ rotate, split or merge naming one that comes back without `sig` (or
15
+ `sig2` for a `cp1` change) raises `UnverifiableNoteError`, carrying the
16
+ fresh secrets as before.
17
+ - New option `requireMintPubkey`, default true, takes over the
18
+ `withdrawRequest` check that `requireSignatures` used to carry. A Part
19
+ 1-only mint that publishes no `mintPubkey` is admitted with it set false.
20
+ - Graded by lnurlcash-conformance 0.10.0's response vectors, which now
21
+ carry `output`/`change: "cp1"` on the cases that mint a `cp1` note.
22
+
23
+ If you relied on the default to refuse unsigned plain notes, set
24
+ `requireSignatures: true`; if you only ever wanted verifiable notes, hold
25
+ `cp1` notes, which are the only kind the spec makes verifiable.
26
+
27
+ ## 0.12.1 - 2026-09-11
28
+
29
+ - `fetchNoteInfo` compares the mint's echoed `k1` with the one it asked
30
+ about as the note each names (`noteIdOf`), not as strings. One Part 2 note
31
+ has many valid `ck1`s: anyone can turn one into its high-S twin, and its
32
+ holder can re-sign, so a mint echoing another spelling of the same note
33
+ was reported as having handed back a different note. An echo that names no
34
+ note at all is still refused, and a Part 1 secret compares as it always did.
35
+
3
36
  ## 0.12.0 - 2026-09-11
4
37
 
5
38
  **A Part 2 branch rooted in a Nostr key.**
package/README.md CHANGED
@@ -58,14 +58,14 @@ 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. 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)
61
+ // A plain note comes back unsigned: LUD-25 Part 2 certifies cp1 notes
62
+ // only, and a hash has nothing to attest to without disclosing the secret.
63
+ // To hold something a recipient can check offline, rotate into a cp1 key
64
+ // (see Part 2 below) and verify its cs1 against info.mintPubkey.
65
65
  ```
66
66
 
67
67
  Every request function takes options last — `fetch`, `timeoutMs`, `offline`,
68
- `randomSecret`, `requireSignatures`, `mutationRetries`.
68
+ `randomSecret`, `requireSignatures`, `requireMintPubkey`, `mutationRetries`.
69
69
  `createClient(options)` binds one set once:
70
70
 
71
71
  ```ts
@@ -189,12 +189,15 @@ sig = 65 bytes, r || s || recovery_id
189
189
  ```
190
190
 
191
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.
192
+ compressed secp256k1 key, is refused with a `ProtocolError`; pass
193
+ `requireMintPubkey: false` for a Part 1-only mint that publishes none. A
194
+ mutation to a `cp1` output the service confirms but does not certify raises
195
+ `UnverifiableNoteError` — which **carries the fresh secrets**, because the
196
+ mutation landed and the note it minted is real; read them with
197
+ `newSecretsOf` and persist them before anything else. A mutation to a plain
198
+ hash output is unsigned by design and comes back with `signature`
199
+ undefined; pass `requireSignatures: true` to demand the old Part 1
200
+ signature over the hash instead, as mints issued before the Part 2 rewrite.
198
201
 
199
202
  `verifyNoteSignature` recovers the pubkey and compares it to `mintPubkey`.
200
203
  It accepts the recovery id at either end, because lnurl-mint once emitted
@@ -422,8 +425,9 @@ Three things worth knowing:
422
425
  - **`i` is any uint32**, serialised as 4 bytes big-endian, never hardened.
423
426
  lnurl-wallet and lnurl-mint agree on that; the spec does not say.
424
427
 
425
- `test/vectors/part2.json` was generated from lnurl-wallet and checked against
426
- lnurl-mint. It moves into lnurlcash-conformance next.
428
+ The tests grade against lnurlcash-conformance's `vectors/part2.json`, built
429
+ from the primitives there and identical to vectors generated from lnurl-wallet
430
+ and checked against lnurl-mint.
427
431
 
428
432
  **A branch rooted in a Nostr key.** A holder with no BIP-39 words, such as a
429
433
  hardware signer that keeps only its identity key, or a wallet that never made
@@ -431,7 +435,7 @@ any, can still be paid to keys of its own. `deriveNostrAddressNode(secretKey,
431
435
  host)` takes the branch from the key that owns the lightning address:
432
436
  `HMAC-SHA256(key = secret key, msg = "LNURLcash/nostr-seed")`, then the path
433
437
  above unchanged. heartwood-esp32 derives exactly this on the device, graded
434
- against the same `test/vectors/nostr-seed.json`, so its notes come back from
438
+ against the same values as conformance's `vectors/nostr-seed.json`, so its notes come back from
435
439
  its nsec (or the phrase the nsec came from) without the device. This is ours,
436
440
  not LUD-25's; a mint sees an ordinary `cx1` either way.
437
441
 
package/dist/index.d.ts CHANGED
@@ -14,6 +14,7 @@ type LnurlcashOptions = {
14
14
  offline?: boolean;
15
15
  randomSecret?: RandomSecret;
16
16
  requireSignatures?: boolean;
17
+ requireMintPubkey?: boolean;
17
18
  mutationRetries?: number;
18
19
  };
19
20
 
package/dist/index.js CHANGED
@@ -690,7 +690,8 @@ var resolveOptions = (options = {}) => ({
690
690
  timeoutMs: options.timeoutMs ?? 3e4,
691
691
  offline: options.offline ?? false,
692
692
  randomSecret: options.randomSecret ?? defaultRandomSecret,
693
- requireSignatures: options.requireSignatures ?? true,
693
+ requireSignatures: options.requireSignatures ?? false,
694
+ requireMintPubkey: options.requireMintPubkey ?? true,
694
695
  // A negative or non-finite count is read as none rather than thrown on:
695
696
  // this is a resilience knob, and refusing the whole operation over it
696
697
  // would be a worse answer than not retrying.
@@ -964,10 +965,11 @@ var fetchNoteInfo = async (url, options = {}) => {
964
965
  }
965
966
  assertWithdrawRequestShape(body, {
966
967
  requireK1: true,
967
- requireMintPubkey: opts.requireSignatures
968
+ requireMintPubkey: opts.requireMintPubkey
968
969
  });
969
970
  const queried = noteK1(url);
970
- if (queried && body.k1.toLowerCase() !== queried) {
971
+ const echoed = noteIdOf(body.k1);
972
+ if (queried && (echoed === null || echoed !== noteIdOf(queried))) {
971
973
  throw new ProtocolError(
972
974
  "The service echoed back a different k1 than was queried - the note may have been redeemed elsewhere, or the service isn't spec-compliant."
973
975
  );
@@ -990,7 +992,7 @@ var fetchNoteInfoByHash = async (withdrawLink, h, options = {}) => {
990
992
  }
991
993
  assertWithdrawRequestShape(body, {
992
994
  requireK1: false,
993
- requireMintPubkey: opts.requireSignatures
995
+ requireMintPubkey: opts.requireMintPubkey
994
996
  });
995
997
  const info = body;
996
998
  const payLink = sameOriginPayLink(body.payLink, reqUrl);
@@ -1138,8 +1140,13 @@ var replayableCallbackRequest = async (callback, params, options) => {
1138
1140
  }
1139
1141
  throw lastError;
1140
1142
  };
1141
- var requireSignature = (value, options, what) => {
1143
+ var requireSignature = (value, options, what, output) => {
1142
1144
  if (typeof value === "string" && value.length > 0) return value;
1145
+ if (isCp1(output)) {
1146
+ throw new UnverifiableNoteError(
1147
+ `The service confirmed the ${what} to a cp1 output but returned no cs1 certificate, which LUD-25 Part 2 requires, so the note it just minted cannot be verified offline. The note exists - keep the key.`
1148
+ );
1149
+ }
1143
1150
  if (!resolveOptions(options).requireSignatures) return void 0;
1144
1151
  throw new UnverifiableNoteError(
1145
1152
  `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.`
@@ -1172,7 +1179,7 @@ var rotateNoteWithHash = async (callback, k1, h, options = {}) => {
1172
1179
  ],
1173
1180
  options
1174
1181
  );
1175
- return { signature: requireSignature(body.sig, options, "rotate") };
1182
+ return { signature: requireSignature(body.sig, options, "rotate", h) };
1176
1183
  };
1177
1184
  var splitNoteWithHash = async (callback, k1s, amountMsat, h, h2, options = {}) => {
1178
1185
  const body = await replayableCallbackRequest(
@@ -1186,8 +1193,8 @@ var splitNoteWithHash = async (callback, k1s, amountMsat, h, h2, options = {}) =
1186
1193
  options
1187
1194
  );
1188
1195
  return {
1189
- signature: requireSignature(body.sig, options, "split"),
1190
- changeSignature: requireSignature(body.sig2, options, "split's change")
1196
+ signature: requireSignature(body.sig, options, "split", h),
1197
+ changeSignature: requireSignature(body.sig2, options, "split's change", h2)
1191
1198
  };
1192
1199
  };
1193
1200
  var mergeNotesWithHash = async (callback, k1s, h, options = {}) => {
@@ -1196,7 +1203,7 @@ var mergeNotesWithHash = async (callback, k1s, h, options = {}) => {
1196
1203
  [...k1s.map((k1) => ["k1", k1]), outputParam(h, 1)],
1197
1204
  options
1198
1205
  );
1199
- return { signature: requireSignature(body.sig, options, "merge") };
1206
+ return { signature: requireSignature(body.sig, options, "merge", h) };
1200
1207
  };
1201
1208
  var keepingOutputs = (err, newSecrets) => {
1202
1209
  if (err instanceof NoteSpentError || err instanceof NoteUnknownError) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lnurlcash-kit",
3
- "version": "0.12.0",
3
+ "version": "0.13.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.8.0",
63
+ "lnurlcash-conformance": "^0.10.0",
64
64
  "tsup": "^8.5.0",
65
65
  "typescript": "^5.7.0",
66
66
  "vitest": "^3.0.0"