
@ Egge
2025-03-12 18:49:00
Welcome to Built with Cashu-TS, a series dedicated to crafting cool applications powered by Cashu and its TypeScript library, Cashu-TS. In this first post, we'll dive into creating a tiny, personal Lightning Address server!
> [!NOTE]
> Quick note: To keep things concise and easy to follow, the examples provided here aren't production-grade code. I'll clearly highlight spots where I've intentionally simplified or taken shortcuts.
## What we are building
Today we are building a Lightning Address server. The server is responsible for returning a Lightning Invoice whenever someone tries to pay your Lightning Address. The exact flow is described in LUD16, but here is a quick rundown:
1. User enters your Lightning Address into their wallet
2. Wallet constructs the matching URL as per LUD16 and sends a GET request
3. Server creates a JSON response with some metadata (min amount, max amount, callback url, etc.) and returns it
4. Wallet displays metadata and upon user interaction sends a second SET request to the callback url including the specified amount.
5. Server fetches an invoice for the requested amount and returns it
Usually the invoices are fetched from a Lightning Node. But today we are using a Cashu mint as our Lightning provider.
## Setup the project
Our Lightning Address server will be written in TypeScript using the express framework. First we got to initialise a new project and install our dependencies.
```sh
mkdir tiny-lud16
cd tiny-lud16
npm init
npm i express cors @cashu/cashu-ts
npm i -D typescript esbuild @types/node @types/cors @types/express
```
### Adding a build script
Because we are using TypeScript we need to add a build step to execute our code (recent versions of node support direct execution of node, but this is the "traditional" way). We are using esbuild to compile our code to JavaScript
> [!NOTE]
> esbuild does not check types. If you want to make sure your code typechecks use `tsc`
**build.js**
```js
#!/usr/bin/env node
const esbuild = require("esbuild");
esbuild
.build({
outdir: "dist/",
format: "cjs",
platform: "node",
entryPoints: ["src/index.ts"],
bundle: true,
sourcemap: "external",
})
.then(() => {
console.log("Server built sucessfully");
});
```
Now we can build our project using `node build.js` and then run our project with `node dist/index.js`
## Configuration
Before we start working on our web server we need to set some options. For this we create `/src/config.ts`
- `USERNAME` will be the address part in front of the `@`.
- `HOSTNAME` is the URL (including the protocol) the server will run on
- `MINT_URL` is the URL of the mint that we want to use to generate invoices and receive token from.
- `MIN_AMOUNT` and `MAX_AMOUNT` are LNURL specific settings that define the range of amounts in mSats that we want to allow.
> [!NOTE]
> Because the smalles amount in the `sat` unit in Cashu is 1 Sat, `MIN_AMOUNT` can not be smaller than 1000
```ts
export const USERNAME = "egge";
export const HOSTNAME = " https://test.test";
export const MINT_URL = " https://mint.minibits.cash/Bitcoin";
export const MIN_AMOUNT = 1000;
export const MAX_AMOUNT = 10000;
```
## Adding some utility
To keep our request handler clean, we will put some of the utility functions in a separate file `src/utils.ts`.
```ts
import { HOSTNAME, MAX_AMOUNT, MIN_AMOUNT, USERNAME } from "./config";
export function createLnurlResponse() {
return {
callback: `${HOSTNAME}/.well-known/lnurlp/${USERNAME}`,
maxSendable: MAX_AMOUNT,
minSendable: MIN_AMOUNT,
metadata: JSON.stringify([
["text/plain", "A cashu lightning address... Neat!"],
]),
tag: "payRequest",
};
}
export function isValidAmount(amountInSats: number) {
return (
amount >= MIN_AMOUNT && amount <= MAX_AMOUNT && Number.isInteger(amount)
);
}
```
The `createLnurlResponse` function creates the response for the first call to our LNURL endpoint. This structure is defined in LUD16 and in our case it does not rely on any state, other than the configuration constants we defined in `src/config.ts`. This object contains the metadata that is the response of step 3 in our flow.
The `isValidAmount` function helps us determine whether the amount we will receive in Step 4 is valid. We check whether it is within the boundaries of our `MIN_AMOUNT` and `MAX_AMOUNT`. Because we will convert the requested amount from mSats into sats, we need to check whether this converted amount is an integer.
## Adding out wallet backend
This blog series is about awesome Cashu use cases, so of course our "Lightning backend" is a mint. We are using the `@cashu/cashu-ts` npm package to streamline Cashu interaction.
```ts
import {
CashuMint,
CashuWallet,
getEncodedToken,
Proof,
} from "@cashu/cashu-ts";
import { MINT_URL } from "./config";
import { resolve } from "path";
import { existsSync, mkdirSync, writeFileSync } from "fs";
const mint = new CashuMint(MINT_URL);
const wallet = new CashuWallet(mint);
export async function createInvoiceAndHandlePayment(amount: number) {
const { quote, request } = await wallet.createMintQuote(amount);
const interval = setInterval(async () => {
const stateRes = await wallet.checkMintQuote(quote);
if (stateRes.state === "PAID") {
const proofs = await wallet.mintProofs(amount, quote);
clearInterval(interval);
const token = turnProofsIntoToken(proofs);
saveTokenLocally(token);
}
}, 10000);
return request;
}
function turnProofsIntoToken(proofs: Proof[]) {
return getEncodedToken({ mint: MINT_URL, proofs });
}
function saveTokenLocally(token: string) {
const tokenDirPath = resolve(__dirname, "../token");
if (!existsSync(tokenDirPath)) {
mkdirSync(tokenDirPath);
}
writeFileSync(resolve(tokenDirPath, `${Date.now()}_token.txt`), token);
}
```
The first thing we do here is instantiating a CashuWallet class from Cashu-TS. This class will take care of the Cashu operations required to create an invoice and mint tokens.
Then we create a utility function that will handle our invoice creation and later make sure to check whether an invoice was paid. `wallet.createMintQuote` will talk to the mint to create a mint quote. The mint returns a `MintQuoteReponse` that includes the ID of the quote as well as the invoice (`request`) that needs to be paid before the Cashu proofs can be minted. This `request` is what we will return to the payer later. Once the mint quote is created we will start polling the mint for it's payment state using `wallet.checkMintQuote`. As soon as the state changes to `"PAID"` we know that the payment was done and we can mint the proofs using Cashu-TS' `mintProofs` method. This returns some Cashu proofs that we will serialize into a Cashu Token and save to our disk using the `saveTokenLocally` function.
> [!NOTE]
> In this example we use `setInterval` to poll for a payment update. In the real world you would use a proper request queue for this to make sure we do not spam the mint with too many requests at the same time
> Also saving the token to disk is not ideal. You could instead send yourself a nostr DM or post it to a webhook
## Adding the handler
Because our LNURL endpoint and our callback endpoint are the same, we only need a single route handler. This route handler will take care of any GET request coming in at `/.well-known/lnurlp/USERNAME`. Wether it is a callback or not can be determined by checking the `amount` query parameter.
```ts
import { NextFunction, Request, Response } from "express";
import { createLnurlResponse, isValidAmount } from "./utils";
import { createInvoiceAndHandlePayment } from "./wallet";
export const lud16Controller = async (
req: Request<unknown, unknown, unknown, { amount: string }>,
res: Response,
next: NextFunction,
) => {
try {
if (!req.query.amount) {
res.json(createLnurlResponse());
return;
}
const parsedAmount = parseInt(req.query.amount);
const mintAmount = parsedAmount / 1000;
const isValid = isValidAmount(mintAmount);
if (!isValid) {
throw new Error("Invalid Amount");
}
const invoice = await createInvoiceAndHandlePayment(mintAmount);
res.json({
pr: invoice,
routes: [],
});
} catch (e) {
next(e);
}
};
```
Let's take this handler function apart and see hat is happening here.
First we check whether the `amount` query parameter is present. If it is not, we now that we are currently in step 3 of our LNURL flow. In this case all we need to do is create the expected metadata object using our `createLnurlResponse` utility and return it to the caller.
If the parameter is present we are in step 5 of our flow and the real work begins. As mentioned above we need to first convert the amount, which is in mSats as per LUD16 into sats to be compatible with our mint running the `sat` unit. Because query parameters are always `string`, we use the built-in `parseInt` to parse the string into a `number`. We then check whether the amount is valid using our `isValidAmount` utility. If it is not, we throw an error which will get caught and passed to express' built in error middleware.
> [!NOTE]
> The error returned by the express middleware is a basic error page without proper error codes. Usually you would define error classed and a custom middleware to take care of this.
Once we made sure that the amount is valid the Cashu logic takes place. We pass the amount to `createInvoiceAndHandlePayment` to create an invoice and start the state polling behind the scenes. At the end of the function we simply return the mint's invoice in a JSON reponse as per LUD16.
## Adding the route
The last step of the process is to add our route handler to the right path of our web server. This path is defined in LUD16: `<domain>/.well-known/lnurlp/<username>`. We create our web server and add the route handler in `/src/index.ts`.
```ts
import express from "express";
import { USERNAME } from "./config";
import { lud16Controller } from "./controller";
const app = express();
app.get("/.well-known/lnurlp/" + USERNAME, lud16Controller);
app.listen(8080, () => {
console.log("Server running on port 8080");
});
```
This snippet is very straight forward. We create an express app, add the route handler to handle GET requests at our desired path and then tell the server to listen on port 8080.
## Conclusion
With just a few lines of code and without using our own Lightning backend we have built a working LNURL Lightning Address server. This is one of the features I love so much about Cashu: It enables new Lightning and Bitcoin use cases. I hope you enjoyed this first part of the new series. Please make sure to leave your feedback 💜🥜