@forgesworn/moneyer 0.1.2 → 0.2.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/dist/landing.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { MINT_KNOWS, MINT_KNOWS_HEADING } from "./privacy.js";
1
2
  import { applyMintFee } from 'lnurlcash-kit';
2
3
  // The mint's face: one self-contained page at GET /, no build step, no
3
4
  // external assets. It states what a visitor needs before trusting a mint
@@ -7,6 +8,7 @@ import { applyMintFee } from 'lnurlcash-kit';
7
8
  const escapeHtml = (value) => value.replace(/[&<>"']/g, char => `&#${char.charCodeAt(0)};`);
8
9
  export const landingPage = (args) => {
9
10
  const { config, host, mintPubkey, nodeInfo } = args;
11
+ const stats = args.stats ?? null;
10
12
  const address = `${config.username}@${host}`;
11
13
  const fee = config.mintFee;
12
14
  const feeLine = fee
@@ -14,12 +16,37 @@ export const landingPage = (args) => {
14
16
  : 'none';
15
17
  const maxNet = fee ? applyMintFee(config.maxSendableMsat, fee) : config.maxSendableMsat;
16
18
  const sats = (msat) => `${(msat / 1000).toLocaleString('en-GB')} sat`;
19
+ const title = config.name ?? nodeInfo.alias ?? 'moneyer';
20
+ // Contacts are shown as text, not links: a mailto or an npub someone
21
+ // else chose is not something this page should hand a click to. The
22
+ // terms are a link because a URL the operator set is the point of it.
23
+ // "coverage 1.92x (outstanding 48,120 sat, node 92,400 sat)", or the
24
+ // ratio alone in ratio-only mode. A mint with nothing outstanding has
25
+ // no ratio to state, so it says so in words instead.
26
+ const coverageLine = (() => {
27
+ if (!stats)
28
+ return null;
29
+ if (stats.coverage !== undefined) {
30
+ const ratio = `${stats.coverage.toFixed(2)}\u00d7`;
31
+ return stats.outstandingMsat === undefined || stats.localBalanceMsat === undefined
32
+ ? ratio
33
+ : `${ratio} (outstanding ${sats(stats.outstandingMsat)}, node ${sats(stats.localBalanceMsat)})`;
34
+ }
35
+ if (stats.outstandingMsat === 0)
36
+ return 'nothing outstanding';
37
+ return stats.outstandingMsat === undefined ? null : `outstanding ${sats(stats.outstandingMsat)}`;
38
+ })();
39
+ const contacts = [
40
+ config.contact?.email ? { label: 'email', value: config.contact.email } : null,
41
+ config.contact?.nostr ? { label: 'nostr', value: config.contact.nostr } : null,
42
+ config.contact?.url ? { label: 'contact', value: config.contact.url } : null
43
+ ].filter(entry => entry !== null);
17
44
  return `<!doctype html>
18
45
  <html lang="en">
19
46
  <head>
20
47
  <meta charset="utf-8"/>
21
48
  <meta name="viewport" content="width=device-width, initial-scale=1"/>
22
- <title>${escapeHtml(host)} - an LNURLcash mint</title>
49
+ <title>${escapeHtml(title)} - an LNURLcash mint</title>
23
50
  <meta name="description" content="A moneyer strikes Lightning bearer notes. Pay ${escapeHtml(address)} and the invoice's preimage becomes your note."/>
24
51
  <style>
25
52
  :root{--bg:#0e0f12;--raise:#16181d;--line:rgba(226,233,242,.09);--ink:#eef1f6;--dim:#98a0ac;--accent:#c9ced8;--accent-deep:#8f97a4}
@@ -40,13 +67,19 @@ h1 small{display:block;font-size:16px;color:var(--dim);font-weight:500;margin-to
40
67
  .kv span{color:var(--dim)}
41
68
  .kv code{font-family:ui-monospace,Menlo,monospace;font-size:12.5px;word-break:break-all;text-align:right}
42
69
  p.small{color:var(--dim);font-size:13.5px;line-height:1.65;text-align:center}
70
+ .knows{background:var(--raise);border:1px solid var(--line);border-radius:20px;padding:20px 22px;display:flex;flex-direction:column;gap:10px}
71
+ .knows h2{font-size:15px;letter-spacing:.04em;text-transform:uppercase;color:var(--dim);font-weight:600}
72
+ .knows p{font-size:14px;line-height:1.65}
73
+ .motd{background:var(--raise);border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:14px;padding:14px 18px;font-size:14.5px;line-height:1.6}
74
+ .motd b{display:block;font-size:12px;letter-spacing:.09em;text-transform:uppercase;color:var(--dim);margin-bottom:4px}
43
75
  a{color:var(--accent)}
44
76
  </style>
45
77
  </head>
46
78
  <body>
47
79
  <main>
48
80
  <svg class="mark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7.6v8.8"/><path d="M15.4 9.4c-.7-1.1-1.9-1.8-3.4-1.8-2 0-3.6 1.1-3.6 2.7 0 3.4 7.2 1.8 7.2 5 0 1.6-1.6 2.7-3.6 2.7-1.5 0-2.7-.7-3.4-1.8"/></svg>
49
- <h1>${escapeHtml(nodeInfo.alias ?? 'moneyer')}<small>An LNURLcash mint. Pay the address below and the invoice's payment preimage <em>is</em> your bearer note - money as a secret you hold.</small></h1>
81
+ <h1>${escapeHtml(title)}<small>An LNURLcash mint. Pay the address below and the invoice's payment preimage <em>is</em> your bearer note - money as a secret you hold.</small></h1>
82
+ ${config.motd ? `<div class="motd"><b>notice</b>${escapeHtml(config.motd)}</div>` : ''}
50
83
  <div class="addr"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 4.5 13.5H11L9.5 22 18 10.5h-6.5L13 2z"/></svg>${escapeHtml(address)}</div>
51
84
  <div class="card">
52
85
  <div class="kv"><span>mints</span><b>${sats(config.minSendableMsat)} to ${sats(config.maxSendableMsat)}</b></div>
@@ -54,8 +87,15 @@ a{color:var(--accent)}
54
87
  <div class="kv"><span>largest note</span><b>${sats(maxNet)}</b></div>
55
88
  ${mintPubkey ? `<div class="kv"><span>notes signed by</span><code>${escapeHtml(mintPubkey)}</code></div>` : '<div class="kv"><span>note signatures</span><b>not offered</b></div>'}
56
89
  ${nodeInfo.uri ? `<div class="kv"><span>node</span><code>${escapeHtml(nodeInfo.uri)}</code></div>` : ''}
90
+ ${coverageLine ? `<div class="kv"><span>coverage</span><b>${escapeHtml(coverageLine)}</b></div>` : ''}
57
91
  ${config.sunset ? '<div class="kv"><span>status</span><b>sunsetting - redeem only</b></div>' : ''}
92
+ ${contacts.map(entry => `<div class="kv"><span>${entry.label}</span><code>${escapeHtml(entry.value)}</code></div>`).join('\n')}
93
+ ${config.tosUrl ? `<div class="kv"><span>terms</span><a href="${escapeHtml(config.tosUrl)}" rel="noopener noreferrer">${escapeHtml(config.tosUrl)}</a></div>` : ''}
58
94
  </div>
95
+ <section class="knows">
96
+ <h2>${escapeHtml(MINT_KNOWS_HEADING)}</h2>
97
+ ${MINT_KNOWS.map(paragraph => `<p>${escapeHtml(paragraph)}</p>`).join('\n')}
98
+ </section>
59
99
  <p class="small">Works with any LUD-25 wallet - <a href="https://github.com/forgesworn/notecase">notecase</a> among them. Verify a note offline against the signing key above.<br/>Independent implementation of the <a href="https://github.com/lnurl/luds/pull/301">LNURLcash draft</a> - graded by <a href="https://github.com/TheCryptoDonkey/lnurlcash-conformance">lnurlcash-conformance</a>.</p>
60
100
  </main>
61
101
  </body>
package/dist/melt.d.ts CHANGED
@@ -5,6 +5,7 @@ export type MeltJob = {
5
5
  noteId: string;
6
6
  pr: string;
7
7
  amountMsat: number;
8
+ payAmountMsat?: number;
8
9
  };
9
10
  export type MeltDeps = {
10
11
  store: NoteStore;
package/dist/melt.js CHANGED
@@ -32,7 +32,8 @@ export const runMelt = async (job, deps) => {
32
32
  try {
33
33
  outcome = await deps.backend.payInvoice({
34
34
  pr: job.pr,
35
- feeLimitMsat: deps.feeLimitMsat(job.amountMsat)
35
+ feeLimitMsat: deps.feeLimitMsat(job.amountMsat),
36
+ ...(job.payAmountMsat !== undefined ? { amountMsat: job.payAmountMsat } : {})
36
37
  });
37
38
  }
38
39
  catch (err) {
@@ -0,0 +1,39 @@
1
+ import { NoteStore } from './store.ts';
2
+ export declare const NIP98_KIND = 27235;
3
+ export declare const NIP98_WINDOW_SECS = 60;
4
+ export declare const NAME_RULE: RegExp;
5
+ export declare const ALWAYS_RESERVED: readonly ["_", "admin", "mint"];
6
+ export declare const FREE_NAMES_PER_PUBKEY = 3;
7
+ export type Nip98Failure = {
8
+ reason: string;
9
+ };
10
+ export type Nip98Success = {
11
+ pubkey: string;
12
+ };
13
+ export declare const validateNip98: (authorization: string | undefined, request: {
14
+ url: string;
15
+ method: string;
16
+ body: string;
17
+ nowSecs?: number;
18
+ }) => Nip98Success | Nip98Failure;
19
+ export type NameRefusal = {
20
+ reason: string;
21
+ status: number;
22
+ };
23
+ export type NameGranted = {
24
+ name: string;
25
+ pubkey: string;
26
+ paidMsat: number;
27
+ };
28
+ export declare const isRefusal: (result: NameGranted | NameRefusal) => result is NameRefusal;
29
+ export declare const registerName: (args: {
30
+ store: NoteStore;
31
+ pubkey: string;
32
+ body: {
33
+ name?: unknown;
34
+ note?: unknown;
35
+ };
36
+ priceMsat: number | undefined;
37
+ reserved: string[];
38
+ host: string;
39
+ }) => NameGranted | NameRefusal;
package/dist/names.js ADDED
@@ -0,0 +1,172 @@
1
+ import { sha256 } from '@noble/hashes/sha2.js';
2
+ import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
3
+ import { verifyEvent } from 'nostr-tools/pure';
4
+ import { hashK1, noteK1 } from 'lnurlcash-kit';
5
+ import { NotePendingError, NoteStore, NoteUnavailableError } from "./store.js";
6
+ // Self-service lightning addresses.
7
+ //
8
+ // A name here is a lightning address whose payouts are bearer notes
9
+ // gift-wrapped to the holder's own Nostr key: custodial for the seconds
10
+ // between the zap settling and the wrap going out, theirs afterwards.
11
+ // Anyone with an npub can have one, which is a reason to run a mint and a
12
+ // reason to install a wallet.
13
+ //
14
+ // The identity is the NIP-98 signature and nothing else. There is no
15
+ // account, no password and no recovery: the key that signed the
16
+ // registration owns the name.
17
+ export const NIP98_KIND = 27235;
18
+ // Sixty seconds, as NIP-98 suggests. Long enough for a slow phone, short
19
+ // enough that a captured Authorization header is worth little.
20
+ export const NIP98_WINDOW_SECS = 60;
21
+ // Three to thirty-two characters, starting with a letter or digit. The
22
+ // same shape a lightning address local part and a NIP-05 name can both
23
+ // carry without quoting.
24
+ export const NAME_RULE = /^[a-z0-9][a-z0-9_.-]{2,31}$/;
25
+ // Never registrable, whatever the mint is called: `_` is LUD-16's
26
+ // bare-domain alias, and the other two are what a person types when they
27
+ // mean the operator.
28
+ export const ALWAYS_RESERVED = ['_', 'admin', 'mint'];
29
+ // How many free names one pubkey may hold. Paid registration needs no cap
30
+ // - the price is the cap.
31
+ export const FREE_NAMES_PER_PUBKEY = 3;
32
+ // NIP-98 over one request. Deliberately checked here rather than through
33
+ // a helper: the payload tag must commit to the RAW body this handler
34
+ // read, not to a re-serialisation of it, or a client whose JSON differs
35
+ // from ours by a space is refused for no reason.
36
+ export const validateNip98 = (authorization, request) => {
37
+ const header = authorization?.trim();
38
+ if (!header)
39
+ return { reason: 'Missing NIP-98 Authorization header.' };
40
+ const match = /^Nostr\s+(.+)$/i.exec(header);
41
+ if (!match)
42
+ return { reason: 'Authorization must be "Nostr <base64 event>".' };
43
+ let event;
44
+ try {
45
+ event = JSON.parse(Buffer.from(match[1].trim(), 'base64').toString('utf8'));
46
+ }
47
+ catch {
48
+ return { reason: 'Authorization is not a base64 Nostr event.' };
49
+ }
50
+ if (event?.kind !== NIP98_KIND)
51
+ return { reason: `Authorization event must be kind ${NIP98_KIND}.` };
52
+ if (!verifyEvent(event))
53
+ return { reason: 'Authorization event signature does not verify.' };
54
+ const nowSecs = request.nowSecs ?? Math.floor(Date.now() / 1000);
55
+ if (Math.abs(nowSecs - event.created_at) > NIP98_WINDOW_SECS) {
56
+ return { reason: `Authorization event is not within ${NIP98_WINDOW_SECS} seconds of now.` };
57
+ }
58
+ const tag = (name) => event.tags.find(t => t[0] === name)?.[1];
59
+ // The URL is compared without its query string, and with a trailing
60
+ // slash ignored: those are the two differences a proxy or an HTTP
61
+ // client introduces on its own.
62
+ const canonical = (value) => {
63
+ try {
64
+ const url = new URL(value);
65
+ return `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ };
71
+ const signedUrl = tag('u');
72
+ if (!signedUrl || canonical(signedUrl) === null || canonical(signedUrl) !== canonical(request.url)) {
73
+ return { reason: 'Authorization event was signed for a different URL.' };
74
+ }
75
+ if ((tag('method') ?? '').toUpperCase() !== request.method.toUpperCase()) {
76
+ return { reason: 'Authorization event was signed for a different method.' };
77
+ }
78
+ const payload = tag('payload');
79
+ if (request.body.length > 0) {
80
+ if (!payload)
81
+ return { reason: 'Authorization event has no payload tag for this body.' };
82
+ if (payload.toLowerCase() !== bytesToHex(sha256(utf8ToBytes(request.body)))) {
83
+ return { reason: 'Authorization event payload tag does not match the body.' };
84
+ }
85
+ }
86
+ return { pubkey: event.pubkey };
87
+ };
88
+ const refuse = (reason, status) => ({ reason, status });
89
+ export const isRefusal = (result) => 'reason' in result;
90
+ // The whole registration, minus the transport. Burning the note and
91
+ // inserting the name are the two mutations, in that order: a name that
92
+ // exists without payment is worse than a note burned without a name,
93
+ // and the INSERT is what settles a race between two people asking for
94
+ // the same name at once.
95
+ export const registerName = (args) => {
96
+ const { store, pubkey, priceMsat } = args;
97
+ if (priceMsat === undefined)
98
+ return refuse('This mint is not registering names.', 404);
99
+ const raw = typeof args.body.name === 'string' ? args.body.name.trim().toLowerCase() : '';
100
+ if (!NAME_RULE.test(raw)) {
101
+ return refuse('A name is 3 to 32 characters of a-z, 0-9, dot, dash or underscore, starting with a letter or digit.', 400);
102
+ }
103
+ if ([...ALWAYS_RESERVED, ...args.reserved.map(name => name.toLowerCase())].includes(raw)) {
104
+ return refuse('That name is reserved.', 403);
105
+ }
106
+ if (store.zapName(raw))
107
+ return refuse('That name is taken.', 409);
108
+ let paidMsat = 0;
109
+ let noteId;
110
+ if (priceMsat === 0) {
111
+ // Free names are rationed per key. The per-address limit is the
112
+ // reverse proxy's job; this is the one the mint can enforce itself.
113
+ if (store.zapNameCountFor(pubkey) >= FREE_NAMES_PER_PUBKEY) {
114
+ return refuse(`One key may hold ${FREE_NAMES_PER_PUBKEY} free names.`, 403);
115
+ }
116
+ }
117
+ else {
118
+ const offered = typeof args.body.note === 'string' ? args.body.note.trim() : '';
119
+ if (!offered)
120
+ return refuse(`This name costs ${priceMsat} msat - send a note of this mint in "note".`, 402);
121
+ // A bare secret is accepted as readily as a full note URL; a URL for
122
+ // somebody else's mint is not, however good the note is. The URL is
123
+ // read here rather than through the kit's resolver, which refuses a
124
+ // plain-http service - correct for a wallet reaching a stranger, and
125
+ // wrong for a mint reading a note it minted itself.
126
+ let k1 = null;
127
+ if (/^[0-9a-f]{64}$/i.test(offered)) {
128
+ k1 = offered.toLowerCase();
129
+ }
130
+ else {
131
+ let url;
132
+ try {
133
+ url = new URL(offered.replace(/^lnurlw:\/\//i, 'https://'));
134
+ }
135
+ catch {
136
+ return refuse('That is not a note.', 400);
137
+ }
138
+ if (url.host !== args.host)
139
+ return refuse('That note is not from this mint.', 400);
140
+ k1 = noteK1(url.toString());
141
+ }
142
+ if (!k1)
143
+ return refuse('That is not a note.', 400);
144
+ const note = store.noteById(hashK1(k1));
145
+ if (!note || note.state === 'burned')
146
+ return refuse('That note is spent or was never minted here.', 400);
147
+ if (note.state === 'pending')
148
+ return refuse('That note has a melt in flight.', 409);
149
+ if (note.amountMsat < priceMsat) {
150
+ return refuse(`That note is worth ${note.amountMsat} msat and a name costs ${priceMsat} msat.`, 402);
151
+ }
152
+ // The note is burned outright, with no change: the whole of it pays
153
+ // for the name, which is why the price is published and a wallet
154
+ // splits before it asks. Liabilities drop by exactly this much and
155
+ // the sats become revenue.
156
+ noteId = note.id;
157
+ paidMsat = note.amountMsat;
158
+ }
159
+ try {
160
+ store.buyZapName({ name: raw, pubkey, ...(noteId ? { noteId } : {}), paidMsat });
161
+ }
162
+ catch (err) {
163
+ if (err instanceof NotePendingError)
164
+ return refuse('That note has a melt in flight.', 409);
165
+ if (err instanceof NoteUnavailableError)
166
+ return refuse('That note is spent or was never minted here.', 400);
167
+ // The INSERT lost a race. Nothing was burned: the transaction took
168
+ // the name and the note together or not at all.
169
+ return refuse('That name is taken.', 409);
170
+ }
171
+ return { name: raw, pubkey, paidMsat };
172
+ };
@@ -0,0 +1,2 @@
1
+ export declare const MINT_KNOWS_HEADING = "What the mint knows";
2
+ export declare const MINT_KNOWS: readonly string[];
@@ -0,0 +1,14 @@
1
+ // What the mint knows, in one place so the README, the landing page and
2
+ // the mint's own site cannot drift apart on it.
3
+ //
4
+ // The one property a LUD-25 mint cannot offer is blindness. A reader
5
+ // arriving from an ecash wallet will assume otherwise unless told, so
6
+ // this is written to be read before anyone trusts the mint with sats,
7
+ // not buried in a threat model.
8
+ export const MINT_KNOWS_HEADING = 'What the mint knows';
9
+ export const MINT_KNOWS = [
10
+ 'This mint knows every note it has issued and what each is worth. It knows every rotate, split and merge, the links between them - which note became which, and when - and the network address the request came from. It knows the invoice a melt paid.',
11
+ 'It does not know who holds a note between those operations. A note handed to someone else offline leaves no trace here until they rotate it, which is one reason a wallet rotates on receipt.',
12
+ 'The wallet-side mitigations are weak, and worth naming as weak. A Tor or SOCKS proxy hides the address, not the links. Rotating at unpredictable times blurs the timing, not the graph. Nothing a holder does stops this mint seeing the chain of notes it struck.',
13
+ 'The design was chosen anyway because it needs no new cryptography, any LUD-03 wallet can cash a note out, and verifying a note offline needs a signature and nothing else. The privacy story is trust the operator, and that is worth saying plainly.'
14
+ ];
package/dist/server.d.ts CHANGED
@@ -2,6 +2,8 @@ import type { MoneyerConfig } from './config.ts';
2
2
  import { NoteStore } from './store.ts';
3
3
  import { type NoteSigner } from './signing.ts';
4
4
  import { type LightningBackend } from './backends/types.ts';
5
+ import { type NostrTransport, type ZapBridge } from './zap.ts';
6
+ import { type MintStats } from './stats.ts';
5
7
  import { type WebAssets } from './web-assets.ts';
6
8
  export type Moneyer = {
7
9
  url: string;
@@ -10,7 +12,11 @@ export type Moneyer = {
10
12
  store: NoteStore;
11
13
  backend: LightningBackend;
12
14
  signer: NoteSigner | null;
15
+ zap: ZapBridge | null;
13
16
  reconcile: () => Promise<void>;
17
+ stats: () => Promise<MintStats>;
18
+ publishStats: () => Promise<void>;
19
+ publishAnnouncement: () => Promise<void>;
14
20
  close: () => Promise<void>;
15
21
  };
16
22
  export type MoneyerDeps = {
@@ -19,6 +25,13 @@ export type MoneyerDeps = {
19
25
  log?: (message: string) => void;
20
26
  confirmDelaysMs?: number[];
21
27
  webAssets?: WebAssets | null;
28
+ nostr?: NostrTransport;
29
+ zapPollMs?: number;
30
+ statsPublishMs?: number;
22
31
  };
32
+ export declare const describeFee: (fee: {
33
+ baseFeeMsat: number;
34
+ feePpm: number;
35
+ }, roundedToSat: boolean) => string;
23
36
  export declare const createMoneyer: (config: MoneyerConfig, deps?: MoneyerDeps) => Promise<Moneyer>;
24
37
  export declare const sweepExpiredMintInvoices: (store: NoteStore, nowMs?: number) => number;