@forgesworn/moneyer 0.1.0-next.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.
Files changed (118) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/LICENSE +21 -0
  3. package/README.md +133 -0
  4. package/SECURITY.md +29 -0
  5. package/THREAT-MODEL.md +104 -0
  6. package/dist/backends/cln.d.ts +5 -0
  7. package/dist/backends/cln.js +130 -0
  8. package/dist/backends/fake-bolt11.d.ts +6 -0
  9. package/dist/backends/fake-bolt11.js +60 -0
  10. package/dist/backends/fake.d.ts +17 -0
  11. package/dist/backends/fake.js +104 -0
  12. package/dist/backends/lnd.d.ts +5 -0
  13. package/dist/backends/lnd.js +191 -0
  14. package/dist/backends/types.d.ts +38 -0
  15. package/dist/backends/types.js +25 -0
  16. package/dist/cli.d.ts +2 -0
  17. package/dist/cli.js +61 -0
  18. package/dist/config.d.ts +44 -0
  19. package/dist/config.js +100 -0
  20. package/dist/index.d.ts +10 -0
  21. package/dist/index.js +23 -0
  22. package/dist/landing.d.ts +8 -0
  23. package/dist/landing.js +63 -0
  24. package/dist/melt.d.ts +17 -0
  25. package/dist/melt.js +91 -0
  26. package/dist/server.d.ts +24 -0
  27. package/dist/server.js +502 -0
  28. package/dist/signing.d.ts +6 -0
  29. package/dist/signing.js +37 -0
  30. package/dist/store.d.ts +53 -0
  31. package/dist/store.js +245 -0
  32. package/dist/web-assets.d.ts +12 -0
  33. package/dist/web-assets.js +51 -0
  34. package/llms.txt +64 -0
  35. package/package.json +76 -0
  36. package/web/dist/art/plate-l.webp +0 -0
  37. package/web/dist/art/plate-p.webp +0 -0
  38. package/web/dist/assets/cinzel-latin-400-normal-C8jUSQqm.woff +0 -0
  39. package/web/dist/assets/cinzel-latin-400-normal-DnUIPmzd.woff2 +0 -0
  40. package/web/dist/assets/cinzel-latin-700-normal-C-gK7hA8.woff +0 -0
  41. package/web/dist/assets/cinzel-latin-700-normal-Dkw14w9r.woff2 +0 -0
  42. package/web/dist/assets/cinzel-latin-ext-400-normal-DJ0Lq8y-.woff +0 -0
  43. package/web/dist/assets/cinzel-latin-ext-400-normal-XQK_CSAr.woff2 +0 -0
  44. package/web/dist/assets/cinzel-latin-ext-700-normal-C24KFjuG.woff2 +0 -0
  45. package/web/dist/assets/cinzel-latin-ext-700-normal-CORa-yIv.woff +0 -0
  46. package/web/dist/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  47. package/web/dist/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  48. package/web/dist/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
  49. package/web/dist/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
  50. package/web/dist/assets/ibm-plex-mono-cyrillic-600-normal-CTOM6hUh.woff2 +0 -0
  51. package/web/dist/assets/ibm-plex-mono-cyrillic-600-normal-fLZuRloM.woff +0 -0
  52. package/web/dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  53. package/web/dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  54. package/web/dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
  55. package/web/dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
  56. package/web/dist/assets/ibm-plex-mono-cyrillic-ext-600-normal-9HEixskS.woff +0 -0
  57. package/web/dist/assets/ibm-plex-mono-cyrillic-ext-600-normal-V-xxqcpd.woff2 +0 -0
  58. package/web/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  59. package/web/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  60. package/web/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  61. package/web/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  62. package/web/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  63. package/web/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
  64. package/web/dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  65. package/web/dist/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  66. package/web/dist/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
  67. package/web/dist/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
  68. package/web/dist/assets/ibm-plex-mono-latin-ext-600-normal-D38SheWl.woff2 +0 -0
  69. package/web/dist/assets/ibm-plex-mono-latin-ext-600-normal-DmB0ttJJ.woff +0 -0
  70. package/web/dist/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  71. package/web/dist/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  72. package/web/dist/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
  73. package/web/dist/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
  74. package/web/dist/assets/ibm-plex-mono-vietnamese-600-normal-D2EvbN8M.woff2 +0 -0
  75. package/web/dist/assets/ibm-plex-mono-vietnamese-600-normal-iLQfcSjf.woff +0 -0
  76. package/web/dist/assets/index-BGYBQuiQ.css +1 -0
  77. package/web/dist/assets/index-CIuRQd9k.js +130 -0
  78. package/web/dist/assets/spectral-cyrillic-400-italic-C6o5RXh-.woff2 +0 -0
  79. package/web/dist/assets/spectral-cyrillic-400-italic-C95RxGpc.woff +0 -0
  80. package/web/dist/assets/spectral-cyrillic-400-normal-Bx9Tn3WZ.woff2 +0 -0
  81. package/web/dist/assets/spectral-cyrillic-400-normal-X3p2PSzL.woff +0 -0
  82. package/web/dist/assets/spectral-cyrillic-500-italic-BLpmaxIM.woff +0 -0
  83. package/web/dist/assets/spectral-cyrillic-500-italic-D14WfPba.woff2 +0 -0
  84. package/web/dist/assets/spectral-cyrillic-600-normal-Dl75bDKp.woff2 +0 -0
  85. package/web/dist/assets/spectral-cyrillic-600-normal-veT-ELBO.woff +0 -0
  86. package/web/dist/assets/spectral-cyrillic-ext-400-italic-C6Gmo4JY.woff +0 -0
  87. package/web/dist/assets/spectral-cyrillic-ext-400-italic-CZOVIkJA.woff2 +0 -0
  88. package/web/dist/assets/spectral-cyrillic-ext-400-normal--n_iQ4wr.woff +0 -0
  89. package/web/dist/assets/spectral-cyrillic-ext-400-normal-B4-YHs9i.woff2 +0 -0
  90. package/web/dist/assets/spectral-cyrillic-ext-500-italic-CxkamsWL.woff +0 -0
  91. package/web/dist/assets/spectral-cyrillic-ext-500-italic-DGZ5KH5h.woff2 +0 -0
  92. package/web/dist/assets/spectral-cyrillic-ext-600-normal-BVATWin6.woff +0 -0
  93. package/web/dist/assets/spectral-cyrillic-ext-600-normal-CvRHnPuy.woff2 +0 -0
  94. package/web/dist/assets/spectral-latin-400-italic-CDO25mTW.woff2 +0 -0
  95. package/web/dist/assets/spectral-latin-400-italic-Ds-NcNVC.woff +0 -0
  96. package/web/dist/assets/spectral-latin-400-normal-CTVgUekv.woff2 +0 -0
  97. package/web/dist/assets/spectral-latin-400-normal-D34IWA1h.woff +0 -0
  98. package/web/dist/assets/spectral-latin-500-italic-CqeZqSS8.woff2 +0 -0
  99. package/web/dist/assets/spectral-latin-500-italic-DxwFSvPk.woff +0 -0
  100. package/web/dist/assets/spectral-latin-600-normal-DE3OxvnQ.woff2 +0 -0
  101. package/web/dist/assets/spectral-latin-600-normal-DXnQJnYb.woff +0 -0
  102. package/web/dist/assets/spectral-latin-ext-400-italic-BcKU4A_a.woff +0 -0
  103. package/web/dist/assets/spectral-latin-ext-400-italic-BgTM1Mst.woff2 +0 -0
  104. package/web/dist/assets/spectral-latin-ext-400-normal-BHbqXr-7.woff +0 -0
  105. package/web/dist/assets/spectral-latin-ext-400-normal-XDLFwhqg.woff2 +0 -0
  106. package/web/dist/assets/spectral-latin-ext-500-italic-DMigwuN9.woff2 +0 -0
  107. package/web/dist/assets/spectral-latin-ext-500-italic-UnF3HUpx.woff +0 -0
  108. package/web/dist/assets/spectral-latin-ext-600-normal-DKlDBbgb.woff2 +0 -0
  109. package/web/dist/assets/spectral-latin-ext-600-normal-DnKmvInL.woff +0 -0
  110. package/web/dist/assets/spectral-vietnamese-400-italic-D8dhja4j.woff +0 -0
  111. package/web/dist/assets/spectral-vietnamese-400-italic-DO3bsfcj.woff2 +0 -0
  112. package/web/dist/assets/spectral-vietnamese-400-normal-C7CRHYqk.woff2 +0 -0
  113. package/web/dist/assets/spectral-vietnamese-400-normal-CWkMWCzb.woff +0 -0
  114. package/web/dist/assets/spectral-vietnamese-500-italic-e7VYE3f9.woff2 +0 -0
  115. package/web/dist/assets/spectral-vietnamese-500-italic-q7ilnnst.woff +0 -0
  116. package/web/dist/assets/spectral-vietnamese-600-normal-ZNiwCzjU.woff2 +0 -0
  117. package/web/dist/assets/spectral-vietnamese-600-normal-esmjhrFa.woff +0 -0
  118. package/web/dist/index.html +19 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,40 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-08-20
4
+
5
+ Initial implementation.
6
+
7
+ - The six LUD-25 endpoints: payRequest, mint address discovery, pay
8
+ callback, LUD-21 verify (mint and melt sides), informational GET, and
9
+ the mutating callback (melt, rotate, split, merge).
10
+ - Funding sources: cln (clnrest) and lnd (REST), both with
11
+ caller-supplied invoice preimages; a fake backend for development and
12
+ tests, refused outside `--dev`.
13
+ - SQLite store keyed by note id (sha256 of the secret) - secrets are never
14
+ persisted. Synchronous atomic mutations.
15
+ - In-process note signing with a dedicated mint key, agreeing byte for
16
+ byte with lnurlcash-kit's verifier.
17
+ - The melt discipline: respond-then-pay, confirm-before-restore,
18
+ pending-not-guessed, reconciliation at startup and on demand.
19
+ - Passes the lnurlcash-conformance grader, read-only and spending runs;
20
+ the grader executes inside the test suite.
21
+ - The LUD-25 fee algebra on mutations: base_fee_msat deducted from every
22
+ split's change (never the requested amount), (n-1) base fees refunded
23
+ into a merge of n notes, 'insufficient value' when change cannot cover
24
+ the fee or would land at zero.
25
+ - The mint's own website at GET /: mint a note in the browser (invoice
26
+ QR, LUD-21 countdown polling, claim-and-immediately-rotate so the
27
+ verify-exposed preimage dies before the note is shown, offline
28
+ signature verification in front of the user, scratch-off silver over the QR), check
29
+ a note (live/spent/unknown/pending in plain words), the terms and the
30
+ funding node. vite + anime.js + lnurlcash-kit, served from memory by
31
+ the mint process; falls back to a self-contained zero-script landing
32
+ page when no web build exists. `MONEYER_WALLET_URL` links minted notes
33
+ into a companion wallet's claim route.
34
+ - Node statistics (capacity, channels, peers) on the discovery endpoint,
35
+ best-effort from the funding source.
36
+ - Shared funding sources are a supported deployment: the melt path
37
+ pre-checks the node's payment history and surfaces the node's
38
+ "payment already exists" refusal as a distinct, note-restoring
39
+ outcome, closing the cross-mint melt replay a shared node otherwise
40
+ allows.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TheCryptoDonkey
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # moneyer
2
+
3
+ > An LNURLcash (LUD-25) mint. A moneyer was the mediaeval craftsman licensed
4
+ > to strike coins; this one strikes Lightning bearer notes.
5
+
6
+ moneyer is an independent implementation of the LUD-25 draft: paying an
7
+ invoice it issues mints a bearer note whose spend secret is that invoice's
8
+ payment preimage, and a note's holder can rotate, split, merge and melt it
9
+ against the withdraw callback. It passes the full
10
+ [lnurlcash-conformance](https://github.com/TheCryptoDonkey/lnurlcash-conformance)
11
+ grader, including the spending checks, and the grader runs in this repo's
12
+ own test suite.
13
+
14
+ Reference stack, by dni (MIT): the [LUD-25 draft](https://github.com/lnurl/luds/pull/301),
15
+ [lnurl-mint](https://github.com/dni/lnurl-mint), [lnurl-wallet](https://github.com/dni/lnurl-wallet).
16
+ moneyer shares no code with them; where their behaviour encodes a safety
17
+ lesson, that behaviour is kept deliberately and tested.
18
+
19
+ ## What it is
20
+
21
+ - TypeScript, ESM, Node 24+. `node:http` and `node:sqlite`; no web
22
+ framework, no ORM.
23
+ - Funding sources: **cln** (clnrest) and **lnd** (REST), both of which
24
+ accept a caller-supplied invoice preimage - the capability a LUD-25 mint
25
+ cannot exist without. phoenixd and NIP-47 `make_invoice` do not offer it,
26
+ which is why neither can back a mint. A **fake** backend exists for
27
+ development and tests and refuses to run outside `--dev`.
28
+ - Notes are stored by id, `sha256(k1)` - the store never holds a spend
29
+ secret. A freshly minted note's preimage lives only with the payer and
30
+ the funding source.
31
+ - Signs every note it mints with its own mint key (secp256k1, the standard
32
+ `Lightning Signed Message` construction) for LUD-25 offline verification.
33
+ - The melt discipline: reply OK when the note is reserved, pay in the
34
+ background, burn only on confirmed payment, restore only on confirmed
35
+ non-payment, and park everything else as pending for reconciliation -
36
+ which also runs at startup, so a crash mid-melt never guesses.
37
+
38
+ ## Run
39
+
40
+ Not on npm yet - build from source, with the sibling repos it links
41
+ against until those publish:
42
+
43
+ ```bash
44
+ git clone https://github.com/TheCryptoDonkey/lnurlcash-kit
45
+ git clone https://github.com/TheCryptoDonkey/lnurlcash-conformance
46
+ git clone https://github.com/forgesworn/moneyer
47
+ (cd lnurlcash-kit && npm install && npm run build)
48
+ cd moneyer && npm install && npm run build && npm run web:build
49
+ ```
50
+
51
+ ```bash
52
+ # development: fake funding source, in-memory store, a funded note printed
53
+ node dist/cli.js --dev
54
+
55
+ # production shape
56
+ MONEYER_BACKEND=cln \
57
+ MONEYER_BACKEND_URL=https://127.0.0.1:3010 \
58
+ MONEYER_BACKEND_RUNE=... \
59
+ MONEYER_SIGNING_KEY=<32 bytes hex> \
60
+ MONEYER_PUBLIC_ORIGIN=https://mint.example \
61
+ MONEYER_DB=/var/lib/moneyer/mint.sqlite \
62
+ node dist/cli.js
63
+ ```
64
+
65
+ The mint is then payable at `mint@mint.example` (and the bare-domain `_`
66
+ alias). Configuration is environment-only; see `src/config.ts` for the
67
+ full `MONEYER_*` set including fees (`MONEYER_BASE_FEE_MSAT`,
68
+ `MONEYER_FEE_PPM`), limits, and `MONEYER_SUNSET` for winding down without
69
+ stranding holders.
70
+
71
+ ## The website
72
+
73
+ `GET /` serves the mint's own site (`web/`, vite + anime.js, built by
74
+ `npm run web:build` into memory-served static files). It is a wallet-grade
75
+ LNURLcash client in its own right, driven entirely by `lnurlcash-kit`
76
+ against the same endpoints every wallet uses:
77
+
78
+ - **Mint a note in the browser**: amount in, fee grossed up and shown
79
+ before the invoice exists, invoice QR (tap opens a wallet), LUD-21
80
+ polling with a countdown, and on settlement the note is claimed and
81
+ **immediately rotated** - the preimage any invoice-observer could poll
82
+ out of verify is dead before the note is shown. The rotated note's
83
+ signature is verified against the mint's advertised key in front of the
84
+ user, and the QR arrives under scratch-off silver foil, rubbed away like a scratch card.
85
+ - **Check a note**: live value, spent/unknown/pending classified in plain
86
+ words, offline signature verification.
87
+ - `MONEYER_WALLET_URL` (optional) links minted notes straight into a
88
+ companion web wallet's `#/claim` route.
89
+
90
+ Without a web build the server falls back to a self-contained, zero-script
91
+ landing page, so an npm-installed mint still has a face.
92
+
93
+ As a library:
94
+
95
+ ```ts
96
+ import {createMoneyer, configFromEnv} from '@forgesworn/moneyer'
97
+ const mint = await createMoneyer(configFromEnv())
98
+ ```
99
+
100
+ ## Endpoints
101
+
102
+ | | |
103
+ | --- | --- |
104
+ | `/.well-known/lnurlp/<user>` | LUD-16 payRequest; paying mints a note |
105
+ | `/.well-known/lnurlw/<user>` | LUD-25 mint address discovery (experimental) |
106
+ | `/p/cb` | LUD-06 pay callback; issues the mint invoice |
107
+ | `/verify/<hash>` | LUD-21 verify, for mint invoices and melt payments |
108
+ | `/w` | LUD-03 informational GET |
109
+ | `/w/cb` | the mutating callback: melt, rotate, split, merge |
110
+
111
+ ## Testing
112
+
113
+ ```bash
114
+ npm test # vitest: protocol, signing, melt discipline, conformance
115
+ ```
116
+
117
+ The conformance grader runs inside the suite against an in-process mint,
118
+ read-only and spending paths both. The cln and lnd backends are direct
119
+ ports of the reference mint's semantics but have not yet been exercised
120
+ against live nodes; treat them as beta until they have.
121
+
122
+ ## Dogfood
123
+
124
+ Part of the ForgeSworn LNURLcash stack: built on
125
+ [`lnurlcash-kit`](https://github.com/TheCryptoDonkey/lnurlcash-kit) (fee
126
+ maths, hashing, signature agreement) and
127
+ [`farrier-kit`](https://github.com/forgesworn/farrier-kit) (BOLT-11
128
+ decoding and preimage verification on the melt path). The companion wallet
129
+ is [`@forgesworn/notecase`](https://github.com/forgesworn/notecase).
130
+
131
+ ## Licence
132
+
133
+ MIT.
package/SECURITY.md ADDED
@@ -0,0 +1,29 @@
1
+ # Security policy
2
+
3
+ moneyer moves real money. If you find a way to make it move the wrong way,
4
+ please report it privately.
5
+
6
+ ## Reporting
7
+
8
+ Open a GitHub security advisory on this repository (Security tab, "Report
9
+ a vulnerability"), or contact the maintainer privately. Please do not open
10
+ a public issue for anything that could cost an operator or a holder funds
11
+ before a fix exists.
12
+
13
+ Include what you can: the endpoint or flow, a reproduction against
14
+ `moneyer --dev` (never against someone's live mint), and what an attacker
15
+ gains.
16
+
17
+ ## Scope
18
+
19
+ In scope: anything that burns, mints, restores or reveals a note contrary
20
+ to the rules in THREAT-MODEL.md; anything that makes the melt discipline
21
+ guess; anything that lets a request cross the store's atomicity.
22
+
23
+ Out of scope: denial of service against an unproxied dev deployment,
24
+ vulnerabilities in the funding source itself, and social engineering.
25
+
26
+ ## Supported versions
27
+
28
+ Pre-1.0: only the latest release is supported. Pin an exact version; the
29
+ LUD-25 spec is still a draft and wire behaviour may follow it.
@@ -0,0 +1,104 @@
1
+ # Threat model
2
+
3
+ moneyer holds other people's money as bearer liabilities. The store keeps
4
+ note ids (hashes), amounts and states; the spend secrets exist only as
5
+ invoice preimages at the funding source and in holders' wallets.
6
+
7
+ ## Assets
8
+
9
+ - The outstanding note set: every `outstanding` row is money owed to
10
+ whoever holds its secret.
11
+ - The funding source credentials (rune or macaroon): full spending power
12
+ over the mint's liquidity.
13
+ - The mint signing key: whoever holds it can issue "verified" notes.
14
+
15
+ ## Adversaries and defences
16
+
17
+ **A holder replaying or racing mutations.** Every mutation validates and
18
+ transitions state in one synchronous SQLite transaction with no await
19
+ inside it; two concurrent callbacks for the same k1 cannot both win. A
20
+ burned k1 answers `Invalid or already spent k1.` atomically. And every
21
+ LNURL endpoint answers GET only, so a preflight or any other non-GET
22
+ request carrying a callback's query string cannot mutate anything.
23
+
24
+ **A holder inflating a merge or split with a repeated k1.** Duplicated k1
25
+ parameters in one request are refused outright; they would otherwise count
26
+ one note's value twice into the output.
27
+
28
+ **A holder claiming an output id that already exists.** `h`/`h2` may not
29
+ collide with any existing note OR any mint invoice's payment hash, settled
30
+ or not. The invoice case is the subtle one: `/verify` hands a settled mint
31
+ invoice's preimage to its payer, and that preimage is the k1 of whatever
32
+ note carries the hash as its id - minting "over" such an id would point a
33
+ future payer's money at a stranger's note.
34
+
35
+ **A holder melting into the mint's own invoice.** Refused synchronously:
36
+ paying it would route the funding source's money at itself, which real
37
+ nodes handle inconsistently.
38
+
39
+ **A holder reusing an invoice across melts.** Refused: the funding source
40
+ dedupes payments by hash, so the second melt would be "confirmed" against
41
+ the first payment and burn a note without moving funds.
42
+
43
+ **A holder replaying a melt across mints sharing one funding source.**
44
+ Sharing a node between mints is a supported deployment, and it creates a
45
+ dedupe gap no mint's own tables can see: an invoice melted at mint A is
46
+ unknown to mint B, whose confirm-by-hash would read A's completed payment
47
+ as its own success and burn B's note with no funds moving. Closed twice
48
+ over: the melt callback synchronously asks the NODE whether it ever paid
49
+ (or is still paying) the hash and refuses if so, and if the foreign
50
+ payment lands in the race between that check and the send, the node's own
51
+ "payment already exists" refusal is surfaced as a distinct
52
+ PaymentAlreadyKnownError and the note restores - nothing went out on this
53
+ mint's behalf, so nothing is guessed. Do not share a funding source with
54
+ a mint implementation that lacks the equivalent guard: its side of the
55
+ same replay stays open.
56
+
57
+ **A malicious payee holding a hodl invoice.** A clean failure report from
58
+ the funding source is never trusted on its own: the note restores only
59
+ once `isPaymentComplete` returns a terminal false. A payment stuck
60
+ in-flight leaves the note pending rather than restorable - the alternative
61
+ is letting the holder melt the same value twice.
62
+
63
+ **A funding source lying about an invoice.** The invoice returned by
64
+ `createInvoice` is decoded and must commit to the preimage moneyer chose
65
+ and the amount requested, or it is never handed out - otherwise a payer's
66
+ money would mint an unclaimable note.
67
+
68
+ **Process death.** Melts are journalled before payment; reconciliation
69
+ runs at startup and on demand, resolving pending melts by asking the
70
+ funding source, never by assumption.
71
+
72
+ **The verify endpoint as an oracle.** `/verify` serves bearer material
73
+ (the preimage). It is only served once the funding source reports
74
+ settlement, is fetched live rather than cached, and has a real off switch
75
+ (`MONEYER_VERIFY=0` makes it a 404). Anyone who saw the unpaid invoice can
76
+ poll it, which is why wallets must rotate immediately on claim - stated in
77
+ the spec and enforced by the companion wallet.
78
+
79
+ ## Out of scope
80
+
81
+ - Compromise of the host or the funding source credentials: at that point
82
+ the attacker IS the mint.
83
+ - Lightning-level attacks against the funding source (channel jamming,
84
+ probing) - the node's own concern.
85
+ - TLS termination: moneyer expects a reverse proxy in production and binds
86
+ to loopback by default.
87
+
88
+ ## Known limitations
89
+
90
+ - The cln and lnd backends are unexercised against live nodes (direct
91
+ ports of the reference mint's logic). Run `--dev` traffic and the
92
+ conformance grader against a staging deployment before taking real money.
93
+ - No rate limiting is built in; put it at the proxy. This matters most for
94
+ `/p/cb`: each unauthenticated call creates a real invoice at the funding
95
+ source. moneyer sweeps its own unsettled invoices once their bolt11
96
+ expiry has passed, but the node's side of that growth is the operator's
97
+ to bound (cln's autoclean, or an equivalent cron) - and a proxy limit is
98
+ what keeps the RPC pile-up and the node database from growing at all.
99
+ - A fee-free configuration pays the melt routing-fee floor (0.5% of the
100
+ amount or 5000 msat) out of its own channel balance, and mint-and-melt
101
+ cycling costs a griefer nothing. Set `MONEYER_BASE_FEE_MSAT` to cover the
102
+ floor before taking real traffic.
103
+ - `node:sqlite` is a single-writer store; moneyer is a single-process
104
+ service by design. Do not run two instances against one database.
@@ -0,0 +1,5 @@
1
+ import { type LightningBackend } from './types.ts';
2
+ export declare const createClnBackend: (config: {
3
+ url: string;
4
+ rune: string;
5
+ }) => LightningBackend;
@@ -0,0 +1,130 @@
1
+ import { bytesToHex, randomBytes } from '@noble/hashes/utils.js';
2
+ import { verifyPreimage } from 'farrier-kit/preimage';
3
+ import { PaymentAlreadyKnownError, PaymentFailedError, PaymentPendingError } from "./types.js";
4
+ // Core Lightning over the clnrest plugin. cln's `invoice` accepts a
5
+ // caller-supplied preimage, which is the whole reason it can back a mint.
6
+ //
7
+ // Not yet exercised against a live node - the semantics are a direct port
8
+ // of the reference mint's cln backend, which is.
9
+ //
10
+ // TLS: the node's certificate must be one this process trusts. For a
11
+ // self-signed clnrest cert, point NODE_EXTRA_CA_CERTS at it.
12
+ // xpay's documented failure codes.
13
+ const PAY_FAILURE_REASONS = {
14
+ 203: "The invoice's destination permanently rejected this payment.",
15
+ 205: 'Could not find a route to pay this invoice.',
16
+ 207: 'This invoice has expired.',
17
+ 219: 'This invoice has already been paid.'
18
+ };
19
+ export const createClnBackend = (config) => {
20
+ const call = async (path, body, timeoutMs = 15_000) => {
21
+ const res = await fetch(`${config.url}${path}`, {
22
+ method: 'POST',
23
+ headers: { 'content-type': 'application/json', Rune: config.rune },
24
+ body: JSON.stringify(body),
25
+ signal: AbortSignal.timeout(timeoutMs)
26
+ });
27
+ const json = await res.json().catch(() => null);
28
+ return { ok: res.ok, status: res.status, json };
29
+ };
30
+ const mustCall = async (path, body, timeoutMs) => {
31
+ const res = await call(path, body, timeoutMs);
32
+ if (!res.ok) {
33
+ throw new Error(`cln ${path} failed (${res.status}): ${JSON.stringify(res.json)?.slice(0, 200)}`);
34
+ }
35
+ return res.json;
36
+ };
37
+ return {
38
+ name: 'cln',
39
+ async createInvoice({ amountMsat, preimageHex, memo }) {
40
+ const result = await mustCall('/v1/invoice', {
41
+ amount_msat: amountMsat,
42
+ label: bytesToHex(randomBytes(16)),
43
+ description: memo,
44
+ preimage: preimageHex
45
+ });
46
+ if (typeof result?.bolt11 !== 'string')
47
+ throw new Error('cln did not return a bolt11 invoice.');
48
+ return { pr: result.bolt11 };
49
+ },
50
+ async payInvoice({ pr, feeLimitMsat }) {
51
+ // xpay resolves once it stops retrying, which is NOT proof that no
52
+ // HTLC it already sent remains outstanding - the caller confirms via
53
+ // isPaymentComplete before restoring anything either way. The
54
+ // timeout sits above xpay's own default 60s retry_for so a clean
55
+ // failure response is not turned into an ambiguous one at the wire.
56
+ const res = await call('/v1/xpay', { invstring: pr, maxfee: feeLimitMsat }, 90_000);
57
+ if (!res.ok) {
58
+ const code = res.json?.code;
59
+ // 219: this node already paid that hash. On a shared node that is
60
+ // somebody else's payment - nothing went out for THIS call, so it
61
+ // is a distinct, safely-restorable refusal rather than a failure.
62
+ if (code === 219) {
63
+ throw new PaymentAlreadyKnownError('cln already has a payment for this hash');
64
+ }
65
+ const reason = (typeof code === 'number' && PAY_FAILURE_REASONS[code]) ||
66
+ res.json?.message ||
67
+ `Payment failed (${res.status}).`;
68
+ throw new PaymentFailedError(reason);
69
+ }
70
+ const preimageHex = res.json?.payment_preimage;
71
+ if (typeof preimageHex !== 'string')
72
+ throw new Error('cln did not return a payment_preimage.');
73
+ const amountMsat = res.json?.amount_msat;
74
+ const amountSentMsat = res.json?.amount_sent_msat;
75
+ const feeMsat = typeof amountMsat === 'number' && typeof amountSentMsat === 'number'
76
+ ? amountSentMsat - amountMsat
77
+ : null;
78
+ return { preimageHex, feeMsat };
79
+ },
80
+ async isPaymentComplete(paymentHashHex) {
81
+ const result = await mustCall('/v1/listpays', { payment_hash: paymentHashHex });
82
+ const pays = result?.pays ?? [];
83
+ // "pending" is not "confirmed not paid" - an HTLC may still be locked
84
+ // at the final hop.
85
+ if (pays.some(pay => pay?.status === 'pending')) {
86
+ throw new PaymentPendingError('cln reports the payment still pending');
87
+ }
88
+ return pays.some(pay => pay?.status === 'complete');
89
+ },
90
+ async isInvoiceSettled(paymentHashHex) {
91
+ const result = await mustCall('/v1/listinvoices', { payment_hash: paymentHashHex });
92
+ const invoices = result?.invoices ?? [];
93
+ return invoices.length > 0 && invoices[0]?.status === 'paid';
94
+ },
95
+ async invoicePreimage(paymentHashHex) {
96
+ const result = await mustCall('/v1/listinvoices', { payment_hash: paymentHashHex });
97
+ const invoice = (result?.invoices ?? [])[0];
98
+ if (!invoice || invoice.status !== 'paid')
99
+ return null;
100
+ const preimageHex = invoice.payment_preimage;
101
+ if (typeof preimageHex !== 'string' || !verifyPreimage(preimageHex, paymentHashHex))
102
+ return null;
103
+ return preimageHex;
104
+ },
105
+ async paymentPreimage(paymentHashHex) {
106
+ const result = await mustCall('/v1/listpays', { payment_hash: paymentHashHex });
107
+ for (const pay of result?.pays ?? []) {
108
+ if (pay?.status === 'complete' && typeof pay.preimage === 'string')
109
+ return pay.preimage;
110
+ }
111
+ return null;
112
+ },
113
+ async nodeInfo() {
114
+ const info = await mustCall('/v1/getinfo', {});
115
+ const id = info?.id;
116
+ const address = (info?.address ?? [])[0];
117
+ const uri = id && address?.address && address?.port ? `${id}@${address.address}:${address.port}` : id;
118
+ const color = typeof info?.color === 'string' ? `#${info.color.replace(/^#/, '')}` : undefined;
119
+ const numChannels = Number(info?.num_active_channels);
120
+ const numPeers = Number(info?.num_peers);
121
+ return {
122
+ ...(info?.alias ? { alias: info.alias } : {}),
123
+ ...(uri ? { uri } : {}),
124
+ ...(color && /^#[0-9a-fA-F]{6}$/.test(color) ? { color } : {}),
125
+ ...(Number.isSafeInteger(numChannels) ? { numChannels } : {}),
126
+ ...(Number.isSafeInteger(numPeers) ? { numPeers } : {})
127
+ };
128
+ }
129
+ };
130
+ };
@@ -0,0 +1,6 @@
1
+ export declare const fakeBolt11: (args: {
2
+ amountMsat: number;
3
+ paymentHashHex: string;
4
+ memo?: string;
5
+ timestamp?: number;
6
+ }) => string;
@@ -0,0 +1,60 @@
1
+ import { bech32 } from '@scure/base';
2
+ import { sha256 } from '@noble/hashes/sha2.js';
3
+ import { secp256k1 } from '@noble/curves/secp256k1.js';
4
+ import { hexToBytes, utf8ToBytes, randomBytes } from '@noble/hashes/utils.js';
5
+ // A syntactically COMPLETE BOLT-11 invoice that nothing can ever pay.
6
+ //
7
+ // The conformance mock mint gets away with an HRP and filler because the
8
+ // reference wallet only reads the human-readable part. This stack holds
9
+ // itself to farrier-kit's full decode - checksum, tagged fields, signature
10
+ // layout - so the fake funding source has to emit the real grammar: a
11
+ // mainnet HRP with the exact amount, a p field carrying the true payment
12
+ // hash, s and 9 so modern parsers see a payment secret and its feature
13
+ // bits, d for the memo, and a recoverable signature by a fixed throwaway
14
+ // key that no Lightning node has ever announced. Unpayable by
15
+ // construction; decodable by anything.
16
+ const FAKE_NODE_KEY = hexToBytes('4242424242424242424242424242424242424242424242424242424242424242');
17
+ const fiveToEight = (words) => {
18
+ let acc = 0;
19
+ let bits = 0;
20
+ const out = [];
21
+ for (const word of words) {
22
+ acc = (acc << 5) | word;
23
+ bits += 5;
24
+ while (bits >= 8) {
25
+ bits -= 8;
26
+ out.push((acc >> bits) & 0xff);
27
+ }
28
+ }
29
+ if (bits > 0)
30
+ out.push((acc << (8 - bits)) & 0xff);
31
+ return new Uint8Array(out);
32
+ };
33
+ const tagged = (type, data) => [
34
+ type,
35
+ data.length >> 5,
36
+ data.length & 31,
37
+ ...data
38
+ ];
39
+ const amountHrp = (msat) => (msat % 100 === 0 ? `${msat / 100}n` : `${msat * 10}p`);
40
+ export const fakeBolt11 = (args) => {
41
+ const hrp = `lnbc${amountHrp(args.amountMsat)}`;
42
+ const words = [];
43
+ const timestamp = args.timestamp ?? Math.floor(Date.now() / 1000);
44
+ for (let i = 6; i >= 0; i--)
45
+ words.push((timestamp / 2 ** (5 * i)) & 31);
46
+ // p - payment hash
47
+ words.push(...tagged(1, Array.from(bech32.toWords(hexToBytes(args.paymentHashHex)))));
48
+ // d - description
49
+ words.push(...tagged(13, Array.from(bech32.toWords(utf8ToBytes(args.memo ?? 'moneyer fake invoice')))));
50
+ // s - payment secret (random; nothing will ever present it)
51
+ words.push(...tagged(16, Array.from(bech32.toWords(randomBytes(32)))));
52
+ // 9 - features: var_onion_optin (bit 9) and payment_secret (bit 15), both
53
+ // optional, as 20 bits of 5-bit words
54
+ words.push(...tagged(5, [1, 0, 16, 0]));
55
+ const signed = sha256(new Uint8Array([...utf8ToBytes(hrp), ...fiveToEight(words)]));
56
+ const lead = secp256k1.sign(signed, FAKE_NODE_KEY, { format: 'recovered', prehash: false });
57
+ const signature = new Uint8Array([...lead.subarray(1), lead[0]]);
58
+ words.push(...Array.from(bech32.toWords(signature)));
59
+ return bech32.encode(hrp, words, false);
60
+ };
@@ -0,0 +1,17 @@
1
+ import { type LightningBackend } from './types.ts';
2
+ export type FakePayMode = 'succeed' | 'fail-clean' | 'fail-then-paid' | 'ambiguous-paid' | 'ambiguous-unpaid' | 'ambiguous-pending';
3
+ export type FakeBackend = LightningBackend & {
4
+ control: {
5
+ settleInvoice(paymentHashHex: string): void;
6
+ setPayMode(mode: FakePayMode): void;
7
+ resolvePayment(paymentHashHex: string, status: 'complete' | 'failed'): void;
8
+ registerPaymentPreimage(paymentHashHex: string, preimageHex: string): void;
9
+ seedForeignPayment(paymentHashHex: string, status?: 'complete' | 'pending'): void;
10
+ invoiceByHash(paymentHashHex: string): {
11
+ preimageHex: string;
12
+ amountMsat: number;
13
+ settled: boolean;
14
+ } | undefined;
15
+ };
16
+ };
17
+ export declare const createFakeBackend: () => FakeBackend;
@@ -0,0 +1,104 @@
1
+ import { bytesToHex } from '@noble/hashes/utils.js';
2
+ import { sha256 } from '@noble/hashes/sha2.js';
3
+ import { hexToBytes } from '@noble/hashes/utils.js';
4
+ import { bolt11PaymentHash } from 'farrier-kit/bolt11';
5
+ import { fakeBolt11 } from "./fake-bolt11.js";
6
+ import { PaymentAlreadyKnownError, PaymentFailedError, PaymentPendingError } from "./types.js";
7
+ export const createFakeBackend = () => {
8
+ const invoices = new Map();
9
+ const payments = new Map();
10
+ const knownPreimages = new Map();
11
+ let payMode = 'succeed';
12
+ return {
13
+ name: 'fake',
14
+ async createInvoice({ amountMsat, preimageHex, memo }) {
15
+ const paymentHashHex = bytesToHex(sha256(hexToBytes(preimageHex)));
16
+ const pr = fakeBolt11({ amountMsat, paymentHashHex, memo });
17
+ invoices.set(paymentHashHex, { preimageHex, amountMsat, settled: false });
18
+ return { pr };
19
+ },
20
+ async payInvoice({ pr }) {
21
+ const paymentHashHex = bolt11PaymentHash(pr);
22
+ if (!paymentHashHex)
23
+ throw new PaymentFailedError('That is not a decodable invoice.');
24
+ // Real nodes dedupe sends by payment hash: a hash this node already
25
+ // holds a payment for is refused, exactly as lnd and cln refuse it.
26
+ if (payments.has(paymentHashHex)) {
27
+ throw new PaymentAlreadyKnownError('this node already has a payment for that hash');
28
+ }
29
+ const preimageHex = knownPreimages.get(paymentHashHex) ?? null;
30
+ switch (payMode) {
31
+ case 'succeed':
32
+ payments.set(paymentHashHex, { status: 'complete', preimageHex });
33
+ return { preimageHex, feeMsat: 0 };
34
+ case 'fail-clean':
35
+ payments.set(paymentHashHex, { status: 'failed', preimageHex: null });
36
+ throw new PaymentFailedError('Could not find a route to pay this invoice.');
37
+ case 'fail-then-paid':
38
+ payments.set(paymentHashHex, { status: 'complete', preimageHex });
39
+ throw new PaymentFailedError('Timed out trying to find a route to pay this invoice.');
40
+ case 'ambiguous-paid':
41
+ payments.set(paymentHashHex, { status: 'complete', preimageHex });
42
+ throw new Error('connection reset mid-payment');
43
+ case 'ambiguous-unpaid':
44
+ payments.set(paymentHashHex, { status: 'failed', preimageHex: null });
45
+ throw new Error('connection reset mid-payment');
46
+ case 'ambiguous-pending':
47
+ payments.set(paymentHashHex, { status: 'pending', preimageHex });
48
+ throw new Error('connection reset mid-payment');
49
+ }
50
+ },
51
+ async isPaymentComplete(paymentHashHex) {
52
+ const payment = payments.get(paymentHashHex);
53
+ if (!payment)
54
+ return false;
55
+ if (payment.status === 'pending') {
56
+ throw new PaymentPendingError('the payment has no terminal outcome yet');
57
+ }
58
+ return payment.status === 'complete';
59
+ },
60
+ async isInvoiceSettled(paymentHashHex) {
61
+ return invoices.get(paymentHashHex)?.settled ?? false;
62
+ },
63
+ async invoicePreimage(paymentHashHex) {
64
+ const invoice = invoices.get(paymentHashHex);
65
+ return invoice?.settled ? invoice.preimageHex : null;
66
+ },
67
+ async paymentPreimage(paymentHashHex) {
68
+ const payment = payments.get(paymentHashHex);
69
+ return payment?.status === 'complete' ? payment.preimageHex : null;
70
+ },
71
+ async nodeInfo() {
72
+ return { alias: 'moneyer (fake funding source)', color: '#c9ced8' };
73
+ },
74
+ control: {
75
+ settleInvoice(paymentHashHex) {
76
+ const invoice = invoices.get(paymentHashHex);
77
+ if (!invoice)
78
+ throw new Error(`no fake invoice for ${paymentHashHex}`);
79
+ invoice.settled = true;
80
+ },
81
+ setPayMode(mode) {
82
+ payMode = mode;
83
+ },
84
+ resolvePayment(paymentHashHex, status) {
85
+ const payment = payments.get(paymentHashHex);
86
+ if (!payment)
87
+ throw new Error(`no fake payment for ${paymentHashHex}`);
88
+ payment.status = status;
89
+ },
90
+ registerPaymentPreimage(paymentHashHex, preimageHex) {
91
+ knownPreimages.set(paymentHashHex, preimageHex);
92
+ const payment = payments.get(paymentHashHex);
93
+ if (payment?.status === 'complete')
94
+ payment.preimageHex = preimageHex;
95
+ },
96
+ seedForeignPayment(paymentHashHex, status = 'complete') {
97
+ payments.set(paymentHashHex, { status, preimageHex: null });
98
+ },
99
+ invoiceByHash(paymentHashHex) {
100
+ return invoices.get(paymentHashHex);
101
+ }
102
+ }
103
+ };
104
+ };
@@ -0,0 +1,5 @@
1
+ import { type LightningBackend } from './types.ts';
2
+ export declare const createLndBackend: (config: {
3
+ url: string;
4
+ macaroon: string;
5
+ }) => LightningBackend;