diff --git a/CHANGELOG.md b/CHANGELOG.md index b930b2a..caa94e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Preserve LNURL-pay invoice millisatoshi precision by creating invoices with LND `value_msat` instead of truncating callback amounts to sats ### Added +- Add an opt-in LNURL-pay regtest fixture with switchable invoice callback errors for payment retry journeys - Add Pubky marketplace test fixture behind the `marketplace` compose profile: Pubky testnet, Paykit Server `722ef268` built from pinned source, and the `pubky-marketplace` driver CLI (`up`, `seed`, `purchase`, `mine`, `verify`, `seller-auth`) for the Bitkit marketplace wallet journey with a headless or Bitkit-approved seller; see `docs/pubky-marketplace.md` - Add opt-in rc56 Payment Request fixture services for linked-peer and deadline-history wallet journeys - Homegate Docker Compose service with dedicated PostgreSQL storage, local homeserver admin mock, and README setup flow diff --git a/README.md b/README.md index 8036b71..790b206 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,33 @@ A complete Docker-based development environment for Bitcoin and Lightning Networ - `/generate/bolt11` - Generate Bolt11 invoice (`?amount=` sats, `?amount_msat=` msats) - `/.well-known/lnurlp/:username` - Lightning Address +### LNURL-pay callback fixture + +The optional `lnurl-pay` profile serves LNURL-pay metadata and controlled invoice callback responses on port `3010`. Each Compose project has its own in-memory mode, which starts as `error` and stays there until explicitly changed. Restarting the container resets it to `error`. + +```bash +docker compose --profile lnurl-pay up -d --build --wait lnurl-server-fixture +curl -fsS http://localhost:3010/health +# Read the encoded LNURL and QR image for the fixed payment endpoint. +curl -fsS http://localhost:3010/generate/pay +curl -fsS http://localhost:3010/pay/fixture +# Initially returns HTTP 200 with LNURL status ERROR and a reason, on every request. +curl -fsS 'http://localhost:3010/pay/fixture/callback?amount=100001' +# Change the same callback to return a fresh signed regtest invoice. +curl -fsS -X POST http://localhost:3010/fixture \ + -H 'Content-Type: application/json' -d '{"mode":"healthy"}' +curl -fsS 'http://localhost:3010/pay/fixture/callback?amount=100001' +# Reset before another journey; GET /fixture reads the current mode. +curl -fsS -X POST http://localhost:3010/fixture \ + -H 'Content-Type: application/json' -d '{"mode":"error"}' +# Verify the fixture's HTTP contract and signed invoice properties. +docker compose --profile lnurl-pay exec -T lnurl-server-fixture node --test pay-fixture.test.js +``` + +Use the address and forwarded port reachable by the wallet when requesting `/generate/pay` or `/pay/fixture`: the response derives its URLs from the request's host, including any remapped port. Set `LNURL_FIXTURE_DOMAIN` before starting the service if the wallet must use a different origin (for example `http://10.0.2.2:3010` for an Android emulator). + +Healthy invoices use the requested amount in millisatoshis and bind the exact metadata with a SHA-256 description hash. They are signed, freshly generated `lnbcrt` invoices with a one-hour expiry and payment secret. This fixture supports invoice fetching, decoding and callback retry journeys; it has no Lightning node or channels and cannot settle payments. Use the regular LNURL server with LND for actual payments. Its controls are unauthenticated and intended only for disposable local test environments. + ### VSS Server - **Port**: 5050 diff --git a/docker-compose.yml b/docker-compose.yml index ebe2c80..5ba2678 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -156,6 +156,23 @@ services: ports: - "3000:3000" + lnurl-server-fixture: + profiles: [lnurl-pay] + image: bitkit-docker/lnurl-server:pay-fixture + build: + context: ./lnurl-server + dockerfile: Dockerfile.pay-fixture + environment: + PORT: '3010' + LNURL_FIXTURE_DOMAIN: ${LNURL_FIXTURE_DOMAIN:-} + ports: + - '3010:3010' + healthcheck: + test: ['CMD', 'node', '-e', "fetch('http://127.0.0.1:3010/health').then(r => { if (!r.ok) process.exit(1); }).catch(() => process.exit(1))"] + interval: 5s + timeout: 3s + retries: 12 + postgres: container_name: postgres image: postgres:15-alpine diff --git a/lnurl-server/Dockerfile.pay-fixture b/lnurl-server/Dockerfile.pay-fixture new file mode 100644 index 0000000..46408c9 --- /dev/null +++ b/lnurl-server/Dockerfile.pay-fixture @@ -0,0 +1,8 @@ +FROM node:22-alpine +WORKDIR /app +# This fixture uses the JavaScript secp256k1 fallback and does not open SQLite. +COPY package*.json ./ +RUN npm ci --omit=dev --ignore-scripts +COPY pay-fixture.js pay-fixture.test.js ./ +EXPOSE 3010 +CMD ["node", "pay-fixture.js"] diff --git a/lnurl-server/pay-fixture.js b/lnurl-server/pay-fixture.js new file mode 100644 index 0000000..83ea725 --- /dev/null +++ b/lnurl-server/pay-fixture.js @@ -0,0 +1,87 @@ +const express = require('express'); +const { createHash, randomBytes } = require('crypto'); +const bolt11 = require('bolt11'); +const { encode } = require('lnurl'); +const QRCode = require('qrcode'); + +// Disposable invoices for fetching and decoding, without an LND wallet or channels. +// The public test key has no funds and is never used by a Lightning node. +const testKey = '01'.padStart(64, '0'); +const metadata = JSON.stringify([['text/plain', 'LNURL-pay regtest fixture']]); +const minSendable = 1000; +const maxSendable = 1000000000; +const reason = 'LNURL fixture invoice callback unavailable'; + +function createApp() { + const app = express(); + let mode = 'error'; + app.use(express.json()); + app.use((req, res, next) => { + res.set('Cache-Control', 'no-store'); + next(); + }); + + const origin = (req) => (process.env.LNURL_FIXTURE_DOMAIN || `${req.protocol}://${req.get('host')}`).replace(/\/$/, ''); + const error = (res, message) => res.json({ status: 'ERROR', reason: message }); + + app.get('/health', (req, res) => res.json({ status: 'OK', network: 'regtest', mode })); + app.get('/fixture', (req, res) => res.json({ mode })); + app.post('/fixture', (req, res) => { + if (!['error', 'healthy'].includes(req.body.mode)) { + return res.status(400).json({ status: 'ERROR', reason: 'mode must be error or healthy' }); + } + mode = req.body.mode; + res.json({ mode }); + }); + + app.get('/pay/fixture', (req, res) => res.json({ + tag: 'payRequest', + callback: `${origin(req)}/pay/fixture/callback`, + minSendable, + maxSendable, + metadata, + commentAllowed: 0 + })); + + app.get('/pay/fixture/callback', (req, res) => { + const { amount } = req.query; + if (typeof amount !== 'string' || !/^\d+$/.test(amount) || + !Number.isSafeInteger(Number(amount)) || Number(amount) < minSendable || Number(amount) > maxSendable) { + return error(res, 'amount must be an integer within the advertised millisatoshi range'); + } + if (mode === 'error') return error(res, reason); + + const invoice = bolt11.encode({ + network: { bech32: 'bcrt', pubKeyHash: 111, scriptHash: 196, validWitnessVersions: [0, 1] }, + millisatoshis: amount, + tags: [ + { tagName: 'payment_hash', data: createHash('sha256').update(randomBytes(32)).digest('hex') }, + { tagName: 'payment_secret', data: randomBytes(32).toString('hex') }, + { tagName: 'purpose_commit_hash', data: createHash('sha256').update(metadata).digest('hex') }, + { tagName: 'expire_time', data: 3600 }, + { tagName: 'min_final_cltv_expiry', data: 18 } + ] + }); + res.json({ pr: bolt11.sign(invoice, testKey).paymentRequest, routes: [] }); + }); + + app.get('/generate/pay', async (req, res, next) => { + try { + const url = `${origin(req)}/pay/fixture`; + const lnurl = encode(url); + res.json({ url, lnurl, qrCode: await QRCode.toDataURL(lnurl), type: 'pay' }); + } catch (err) { + next(err); + } + }); + app.use((err, req, res, next) => res.status(500).json({ status: 'ERROR', reason: err.message })); + return app; +} + +if (require.main === module) { + const server = createApp().listen(process.env.PORT || 3010, '0.0.0.0'); + process.on('SIGTERM', () => server.close()); + process.on('SIGINT', () => server.close()); +} + +module.exports = { createApp }; diff --git a/lnurl-server/pay-fixture.test.js b/lnurl-server/pay-fixture.test.js new file mode 100644 index 0000000..41c0704 --- /dev/null +++ b/lnurl-server/pay-fixture.test.js @@ -0,0 +1,62 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { createHash } = require('crypto'); +const bolt11 = require('bolt11'); +const { decode } = require('lnurl'); +const { createApp } = require('./pay-fixture'); + +test('LNURL metadata stays available while callbacks fail until explicitly switched healthy', async (t) => { + const server = createApp().listen(0, '127.0.0.1'); + await new Promise((resolve) => server.once('listening', resolve)); + t.after(() => { + server.closeAllConnections(); + server.close(); + }); + const base = `http://127.0.0.1:${server.address().port}`; + const get = async (path) => { + const res = await fetch(`${base}${path}`); + assert.equal(res.status, 200); + assert.equal(res.headers.get('cache-control'), 'no-store'); + return res.json(); + }; + const setMode = (mode) => fetch(`${base}/fixture`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode }) + }); + + const generated = await get('/generate/pay'); + assert.equal(decode(generated.lnurl), `${base}/pay/fixture`); + assert.match(generated.qrCode, /^data:image\/png;base64,/); + const pay = await get('/pay/fixture'); + assert.equal(pay.tag, 'payRequest'); + assert.deepEqual(JSON.parse(pay.metadata), [['text/plain', 'LNURL-pay regtest fixture']]); + assert.equal(pay.callback, `${base}/pay/fixture/callback`); + const callbackPath = '/pay/fixture/callback?amount=100001'; + for (let attempt = 0; attempt < 2; attempt++) { + assert.equal((await get(callbackPath)).status, 'ERROR'); + } + assert.equal((await get('/fixture')).mode, 'error'); + assert.equal((await setMode('invalid')).status, 400); + assert.equal((await get('/fixture')).mode, 'error'); + assert.equal((await setMode('healthy')).status, 200); + assert.deepEqual(await get('/pay/fixture'), pay); + + const response = await get(callbackPath); + const invoice = bolt11.decode(response.pr); + assert.equal(invoice.network.bech32, 'bcrt'); + assert.equal(invoice.millisatoshis, '100001'); + assert.equal(invoice.tagsObject.purpose_commit_hash, createHash('sha256').update(pay.metadata).digest('hex')); + assert.equal(invoice.payeeNodeKey, '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'); + assert.equal(invoice.tagsObject.payment_secret.length, 64); + assert.ok(invoice.timeExpireDate > Date.now() / 1000); + assert.deepEqual(response.routes, []); + assert.notEqual((await get(callbackPath)).pr, response.pr); + + for (const amount of ['', '0', '999', '1000000001', '1000.5', '1000x', '-1000', '9007199254740993']) { + assert.equal((await get(`/pay/fixture/callback?amount=${amount}`)).status, 'ERROR'); + } + assert.equal((await get('/pay/fixture/callback')).status, 'ERROR'); + assert.equal((await get('/pay/fixture/callback?amount=1000&amount=2000')).status, 'ERROR'); + assert.equal((await get('/health')).network, 'regtest'); + await setMode('error'); + assert.equal((await get(callbackPath)).status, 'ERROR'); +});