Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions lnurl-server/Dockerfile.pay-fixture
Original file line number Diff line number Diff line change
@@ -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"]
87 changes: 87 additions & 0 deletions lnurl-server/pay-fixture.js
Original file line number Diff line number Diff line change
@@ -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 };
62 changes: 62 additions & 0 deletions lnurl-server/pay-fixture.test.js
Original file line number Diff line number Diff line change
@@ -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');
});