From 89b9b1c26d1c9e00fa44ed742c308aff803e60da Mon Sep 17 00:00:00 2001 From: David McFadzean Date: Fri, 27 Feb 2026 19:09:37 -0500 Subject: [PATCH 1/3] feat: Add Lightning wallet support via LNbits integration (#136) Per-DID Lightning wallets through Drawbridge gateway: - LNbits HTTP client for account/wallet/invoice/payment operations - Drawbridge Lightning routes (wallet, balance, invoice, pay, check) - DrawbridgeClient extending GatekeeperClient with Lightning methods - DrawbridgeInterface extending GatekeeperInterface in gatekeeper types - Keymaster Lightning methods with credential storage in wallet IDInfo - Keymaster client, API routes, and CLI commands for all Lightning ops - 23 unit tests covering wallet lifecycle, payments, and error cases Co-Authored-By: Claude Opus 4.6 --- .../src/components/NostrApproval.tsx | 20 +- docs/lightning-wallet-design.md | 79 ++++ jest.config.js | 2 + packages/common/src/errors.ts | 16 + packages/gatekeeper/package.json | 8 + packages/gatekeeper/rollup.cjs.config.js | 1 + packages/gatekeeper/src/drawbridge-client.ts | 71 ++++ packages/gatekeeper/src/gatekeeper-client.ts | 4 +- packages/gatekeeper/src/types.ts | 35 ++ packages/keymaster/src/cli.ts | 79 ++++ packages/keymaster/src/keymaster-client.ts | 67 ++++ packages/keymaster/src/keymaster.ts | 115 +++++- packages/keymaster/src/types.ts | 21 + services/drawbridge/server/src/config.ts | 3 + .../drawbridge/server/src/drawbridge-api.ts | 78 ++++ services/drawbridge/server/src/lnbits.ts | 105 +++++ .../keymaster/server/src/keymaster-api.ts | 213 +++++++++- tests/keymaster/client.test.ts | 1 + tests/keymaster/lightning.test.ts | 378 ++++++++++++++++++ 19 files changed, 1280 insertions(+), 16 deletions(-) create mode 100644 docs/lightning-wallet-design.md create mode 100644 packages/gatekeeper/src/drawbridge-client.ts create mode 100644 services/drawbridge/server/src/lnbits.ts create mode 100644 tests/keymaster/lightning.test.ts diff --git a/apps/chrome-extension/src/components/NostrApproval.tsx b/apps/chrome-extension/src/components/NostrApproval.tsx index e2a38d9a..5922d488 100644 --- a/apps/chrome-extension/src/components/NostrApproval.tsx +++ b/apps/chrome-extension/src/components/NostrApproval.tsx @@ -38,6 +38,15 @@ export default function NostrApproval({ requestId, autoApprove }: NostrApprovalP ); }, [requestId]); + const sendError = useCallback((error: string) => { + setProcessing(false); + chrome.runtime.sendMessage({ + action: "NOSTR_RESPONSE", + id: requestId, + error, + }, () => window.close()); + }, [requestId]); + const handleApprove = useCallback(async () => { if (!keymaster) { sendError("Wallet not initialized"); @@ -72,7 +81,7 @@ export default function NostrApproval({ requestId, autoApprove }: NostrApprovalP } catch (error: any) { sendError(error?.message || String(error)); } - }, [keymaster, method, params, currentDID, requestId, alwaysApprove, origin]); + }, [keymaster, method, params, currentDID, requestId, alwaysApprove, origin, sendError]); // Auto-approve when ready (getPublicKey or remembered origin) useEffect(() => { @@ -82,15 +91,6 @@ export default function NostrApproval({ requestId, autoApprove }: NostrApprovalP } }, [autoApprove, loading, method, keymaster, handleApprove]); - function sendError(error: string) { - setProcessing(false); - chrome.runtime.sendMessage({ - action: "NOSTR_RESPONSE", - id: requestId, - error, - }, () => window.close()); - } - function handleDeny() { sendError("User denied the request"); } diff --git a/docs/lightning-wallet-design.md b/docs/lightning-wallet-design.md new file mode 100644 index 00000000..1fbe0b8c --- /dev/null +++ b/docs/lightning-wallet-design.md @@ -0,0 +1,79 @@ +# Lightning Wallet Integration — Design Document + +## Problem + +Archon agents need Lightning Network payment capabilities (zaps, L402 paywalls, peer-to-peer payments). Each agent identity (DID) needs its own Lightning wallet so funds are isolated. + +## Architecture + +LNbits is the Lightning backend, hosted internally. Agents never interact with LNbits directly — all Lightning operations go through Drawbridge, the public-facing API gateway. + +``` +Agent (Keymaster) ──POST──▶ Drawbridge ──POST──▶ LNbits + │ │ (internal) + encrypted env var + wallet (server URL) + (per-DID + credentials) +``` + +**Keymaster** manages agent identities and wallets. It knows about Drawbridge (its gateway) but has no knowledge of LNbits. + +**Drawbridge** is the public gateway. It knows the LNbits server URL (env var) and proxies all Lightning operations. It is stateless per-wallet — it doesn't store any per-DID state. + +**LNbits** is never exposed publicly. Each DID gets its own LNbits account (via `POST /api/v1/account`), providing full isolation at the account level. + +## Credential Flow + +**Wallet creation:** +1. Agent calls `addLightning()` on Keymaster +2. Keymaster POSTs to Drawbridge `/lightning/wallet` +3. Drawbridge calls LNbits `POST /api/v1/account` to create a new account with an initial wallet +4. LNbits returns `walletId`, `adminKey` (spend), `invoiceKey` (read-only) +5. Drawbridge passes these back to Keymaster +6. Keymaster stores them in the agent's encrypted wallet under `idInfo.lightning` + +**Subsequent operations:** +1. Agent calls a Lightning method (e.g. `createLightningInvoice`) +2. Keymaster reads stored credentials from wallet +3. Keymaster POSTs to Drawbridge with the relevant key (`adminKey` for spending, `invoiceKey` for read-only) +4. Drawbridge forwards to LNbits with that key in the `X-Api-Key` header +5. Result flows back to the agent + +This means Drawbridge needs no per-agent state — credentials travel with each request. + +## Security Model + +- **LNbits URL**: Only in Drawbridge env var. Keymaster and agents never learn the LNbits server address. +- **Per-DID keys** (`adminKey`, `invoiceKey`): Stored only in the agent's encrypted wallet. Never in the public DID document. Sent to Drawbridge per-request over HTTPS. +- **`adminKey`** authorizes spending. Only sent for `pay` operations. +- **`invoiceKey`** is read-only. Used for balance checks, invoice creation, and payment status queries. +- **No shared account key**: Each DID gets its own LNbits account via `POST /api/v1/account` (no auth required). Drawbridge holds no account-level secrets — only the LNbits server URL. + +## Operations + +All Drawbridge Lightning endpoints use POST to keep keys out of URLs and query strings. + +| Operation | Key Used | Description | +|---|---|---| +| Create wallet | *(none — unauthenticated LNbits call)* | Creates a new LNbits account+wallet for a DID | +| Get balance | `invoiceKey` | Returns balance in satoshis | +| Create invoice | `invoiceKey` | Creates a BOLT11 payment request | +| Pay invoice | `adminKey` | Pays an external BOLT11 invoice | +| Check payment | `invoiceKey` | Checks whether an invoice has been paid | + +Wallet creation is idempotent — calling it again for a DID that already has credentials returns the existing config without hitting LNbits. + +## Graceful Degradation + +Two error modes, clearly distinguished: + +1. **Lightning unavailable**: Keymaster is connected to a plain Gatekeeper (no Drawbridge), or Drawbridge has no LNbits configured. The agent gets a clean error and can continue using all non-Lightning features. + +2. **Lightning not configured**: The agent hasn't created a wallet yet (no `addLightning()` call). Error tells them to set up Lightning first. + +This ensures agents that don't need Lightning are completely unaffected, and agents connected to infrastructure without Lightning get actionable errors rather than cryptic failures. + +## Multi-Identity Support + +All operations accept an optional identity parameter. An agent managing multiple DIDs can create separate wallets for each and operate on any of them. Funds are fully isolated between DIDs. Removing Lightning credentials from a DID only deletes the local keys — the LNbits wallet continues to exist (this is intentional; credentials could be backed up or recovered). diff --git a/jest.config.js b/jest.config.js index a6ccfbe3..07b2b354 100644 --- a/jest.config.js +++ b/jest.config.js @@ -20,6 +20,7 @@ const config = { '^@didcid/gatekeeper$': '/packages/gatekeeper/src/gatekeeper.ts', '^@didcid/gatekeeper/types$': '/packages/gatekeeper/src/types.ts', '^@didcid/gatekeeper/client$': '/packages/gatekeeper/src/gatekeeper-client.ts', + '^@didcid/gatekeeper/drawbridge$': '/packages/gatekeeper/src/drawbridge-client.ts', '^@didcid/gatekeeper/db/(.*)$': '/packages/gatekeeper/src/db/$1', '^@didcid/ipfs/helia$': '/packages/ipfs/src/helia-client.ts', '^@didcid/ipfs/utils$': '/packages/ipfs/src/utils.ts', @@ -30,6 +31,7 @@ const config = { '^@didcid/cipher/passphrase': '/packages/cipher/src/passphrase.ts', '^\\.\\/typeGuards\\.js$': '/packages/keymaster/src/db/typeGuards.ts', '^\\.\\/db\\/typeGuards\\.js$': '/packages/keymaster/src/db/typeGuards.ts', + '^\\.\\/gatekeeper-client\\.js$': '/packages/gatekeeper/src/gatekeeper-client.ts', '^\\.\\/abstract-json\\.js$': '/packages/gatekeeper/src/db/abstract-json.ts', '^\\.\\/cipher-base\\.js$': '/packages/cipher/src/cipher-base.ts', '^\\.\\/jwe\\.js$': '/packages/cipher/src/jwe.ts', diff --git a/packages/common/src/errors.ts b/packages/common/src/errors.ts index 05aa73b0..c67b6cf9 100644 --- a/packages/common/src/errors.ts +++ b/packages/common/src/errors.ts @@ -50,6 +50,22 @@ export class UnknownIDError extends ArchonError { } } +export class LightningNotConfiguredError extends ArchonError { + static type = 'Lightning not configured'; + + constructor(detail?: string) { + super(LightningNotConfiguredError.type, detail); + } +} + +export class LightningUnavailableError extends ArchonError { + static type = 'Lightning service unavailable'; + + constructor(detail?: string) { + super(LightningUnavailableError.type, detail); + } +} + // For unit tests export class ExpectedExceptionError extends ArchonError { static type = 'Expected to throw an exception'; diff --git a/packages/gatekeeper/package.json b/packages/gatekeeper/package.json index 21bc52a8..9d5845fd 100644 --- a/packages/gatekeeper/package.json +++ b/packages/gatekeeper/package.json @@ -34,6 +34,11 @@ "require": "./dist/cjs/gatekeeper-client.cjs", "types": "./dist/types/gatekeeper-client.d.ts" }, + "./drawbridge": { + "import": "./dist/esm/drawbridge-client.js", + "require": "./dist/cjs/drawbridge-client.cjs", + "types": "./dist/types/drawbridge-client.d.ts" + }, "./db/json": { "import": "./dist/esm/db/json.js", "require": "./dist/cjs/db/json.cjs", @@ -73,6 +78,9 @@ "client": [ "./dist/types/gatekeeper-client.d.ts" ], + "drawbridge": [ + "./dist/types/drawbridge-client.d.ts" + ], "db/json": [ "./dist/types/db/json.d.ts" ], diff --git a/packages/gatekeeper/rollup.cjs.config.js b/packages/gatekeeper/rollup.cjs.config.js index 22e4bb14..3048f519 100644 --- a/packages/gatekeeper/rollup.cjs.config.js +++ b/packages/gatekeeper/rollup.cjs.config.js @@ -15,6 +15,7 @@ const config = { 'node': 'dist/esm/node.js', 'gatekeeper': 'dist/esm/gatekeeper.js', 'gatekeeper-client': 'dist/esm/gatekeeper-client.js', + 'drawbridge-client': 'dist/esm/drawbridge-client.js', 'db/json': 'dist/esm/db/json.js', 'db/json-cache': 'dist/esm/db/json-cache.js', 'db/json-memory': 'dist/esm/db/json-memory.js', diff --git a/packages/gatekeeper/src/drawbridge-client.ts b/packages/gatekeeper/src/drawbridge-client.ts new file mode 100644 index 00000000..2c491a4f --- /dev/null +++ b/packages/gatekeeper/src/drawbridge-client.ts @@ -0,0 +1,71 @@ +import { + DrawbridgeInterface, + GatekeeperClientOptions, + LightningBalance, + LightningConfig, + LightningInvoice, + LightningPayment, + LightningPaymentStatus, +} from './types.js'; +import GatekeeperClient from './gatekeeper-client.js'; + +function throwError(error: any): never { + if (error.response) { + throw error.response.data; + } + throw error.message; +} + +export default class DrawbridgeClient extends GatekeeperClient implements DrawbridgeInterface { + + static override async create(options: GatekeeperClientOptions): Promise { + const client = new DrawbridgeClient(); + await client.connect(options); + return client; + } + + async createLightningWallet(name: string): Promise { + try { + const response = await this.axios.post(`${this.API}/lightning/wallet`, { name }); + return response.data; + } catch (error) { + throwError(error); + } + } + + async getLightningBalance(invoiceKey: string): Promise { + try { + const response = await this.axios.post(`${this.API}/lightning/balance`, { invoiceKey }); + return response.data; + } catch (error) { + throwError(error); + } + } + + async createLightningInvoice(invoiceKey: string, amount: number, memo: string): Promise { + try { + const response = await this.axios.post(`${this.API}/lightning/invoice`, { invoiceKey, amount, memo }); + return response.data; + } catch (error) { + throwError(error); + } + } + + async payLightningInvoice(adminKey: string, bolt11: string): Promise { + try { + const response = await this.axios.post(`${this.API}/lightning/pay`, { adminKey, bolt11 }); + return response.data; + } catch (error) { + throwError(error); + } + } + + async checkLightningPayment(invoiceKey: string, paymentHash: string): Promise { + try { + const response = await this.axios.post(`${this.API}/lightning/payment`, { invoiceKey, paymentHash }); + return response.data; + } catch (error) { + throwError(error); + } + } +} diff --git a/packages/gatekeeper/src/gatekeeper-client.ts b/packages/gatekeeper/src/gatekeeper-client.ts index d9ec9596..0a199924 100644 --- a/packages/gatekeeper/src/gatekeeper-client.ts +++ b/packages/gatekeeper/src/gatekeeper-client.ts @@ -26,8 +26,8 @@ function throwError(error: AxiosError | any): never { } export default class GatekeeperClient implements GatekeeperInterface { - private API: string; - private axios: AxiosInstance; + protected API: string; + protected axios: AxiosInstance; // Factory method static async create(options: GatekeeperClientOptions): Promise { diff --git a/packages/gatekeeper/src/types.ts b/packages/gatekeeper/src/types.ts index 1c6f7b00..44fed6da 100644 --- a/packages/gatekeeper/src/types.ts +++ b/packages/gatekeeper/src/types.ts @@ -169,6 +169,41 @@ export interface GatekeeperInterface { search(query: { where: Record }): Promise; } +// Lightning types used by DrawbridgeInterface + +export interface LightningConfig { + walletId: string; + adminKey: string; + invoiceKey: string; +} + +export interface LightningBalance { + balance: number; +} + +export interface LightningInvoice { + paymentRequest: string; + paymentHash: string; +} + +export interface LightningPayment { + paymentHash: string; +} + +export interface LightningPaymentStatus { + paid: boolean; + preimage?: string; + paymentHash: string; +} + +export interface DrawbridgeInterface extends GatekeeperInterface { + createLightningWallet(name: string): Promise; + getLightningBalance(invoiceKey: string): Promise; + createLightningInvoice(invoiceKey: string, amount: number, memo: string): Promise; + payLightningInvoice(adminKey: string, bolt11: string): Promise; + checkLightningPayment(invoiceKey: string, paymentHash: string): Promise; +} + export interface DidRegistration { height?: number; index?: number; diff --git a/packages/keymaster/src/cli.ts b/packages/keymaster/src/cli.ts index e8c14b0a..81657a91 100644 --- a/packages/keymaster/src/cli.ts +++ b/packages/keymaster/src/cli.ts @@ -797,6 +797,85 @@ program } }); +// Lightning commands +program + .command('add-lightning [id]') + .description('Create a Lightning wallet for a DID') + .action(async (id) => { + try { + const config = await keymaster.addLightning(id); + console.log(JSON.stringify(config, null, 4)); + } + catch (error: any) { + console.error(error.error || error.message || error); + } + }); + +program + .command('remove-lightning [id]') + .description('Remove Lightning wallet from a DID') + .action(async (id) => { + try { + await keymaster.removeLightning(id); + console.log(UPDATE_OK); + } + catch (error: any) { + console.error(error.error || error.message || error); + } + }); + +program + .command('lightning-balance [id]') + .description('Check Lightning wallet balance') + .action(async (id) => { + try { + const balance = await keymaster.getLightningBalance(id); + console.log(`${balance.balance} sats`); + } + catch (error: any) { + console.error(error.error || error.message || error); + } + }); + +program + .command('lightning-invoice [id]') + .description('Create a Lightning invoice to receive sats') + .action(async (amount, memo, id) => { + try { + const invoice = await keymaster.createLightningInvoice(parseInt(amount), memo, id); + console.log(JSON.stringify(invoice, null, 4)); + } + catch (error: any) { + console.error(error.error || error.message || error); + } + }); + +program + .command('lightning-pay [id]') + .description('Pay a Lightning invoice') + .action(async (bolt11, id) => { + try { + const payment = await keymaster.payLightningInvoice(bolt11, id); + console.log(JSON.stringify(payment, null, 4)); + } + catch (error: any) { + console.error(error.error || error.message || error); + } + }); + +program + .command('lightning-check [id]') + .description('Check status of a Lightning payment') + .action(async (paymentHash, id) => { + try { + const status = await keymaster.checkLightningPayment(paymentHash, id); + console.log(JSON.stringify(status, null, 4)); + } + catch (error: any) { + console.error(error.error || error.message || error); + } + }); + // Group commands program .command('create-group ') diff --git a/packages/keymaster/src/keymaster-client.ts b/packages/keymaster/src/keymaster-client.ts index 3da81baf..51e8a33c 100644 --- a/packages/keymaster/src/keymaster-client.ts +++ b/packages/keymaster/src/keymaster-client.ts @@ -21,6 +21,11 @@ import { IssueCredentialsOptions, KeymasterClientOptions, KeymasterInterface, + LightningConfig, + LightningBalance, + LightningInvoice, + LightningPayment, + LightningPaymentStatus, NostrKeys, NostrEvent, NoticeMessage, @@ -474,6 +479,68 @@ export default class KeymasterClient implements KeymasterInterface { } } + // Lightning + + async addLightning(id?: string): Promise { + try { + const response = await this.axios.post(`${this.API}/lightning`, { id }); + return response.data; + } + catch (error) { + throwError(error); + } + } + + async removeLightning(id?: string): Promise { + try { + const response = await this.axios.delete(`${this.API}/lightning`, { data: { id } }); + return response.data.ok; + } + catch (error) { + throwError(error); + } + } + + async getLightningBalance(id?: string): Promise { + try { + const response = await this.axios.post(`${this.API}/lightning/balance`, { id }); + return response.data; + } + catch (error) { + throwError(error); + } + } + + async createLightningInvoice(amount: number, memo: string, id?: string): Promise { + try { + const response = await this.axios.post(`${this.API}/lightning/invoice`, { amount, memo, id }); + return response.data; + } + catch (error) { + throwError(error); + } + } + + async payLightningInvoice(bolt11: string, id?: string): Promise { + try { + const response = await this.axios.post(`${this.API}/lightning/pay`, { bolt11, id }); + return response.data; + } + catch (error) { + throwError(error); + } + } + + async checkLightningPayment(paymentHash: string, id?: string): Promise { + try { + const response = await this.axios.post(`${this.API}/lightning/payment`, { paymentHash, id }); + return response.data; + } + catch (error) { + throwError(error); + } + } + async resolveDID( id: string, options?: ResolveDIDOptions diff --git a/packages/keymaster/src/keymaster.ts b/packages/keymaster/src/keymaster.ts index 3f3ea8ca..9227b80b 100644 --- a/packages/keymaster/src/keymaster.ts +++ b/packages/keymaster/src/keymaster.ts @@ -5,9 +5,12 @@ import { InvalidDIDError, InvalidParameterError, KeymasterError, - UnknownIDError + UnknownIDError, + LightningNotConfiguredError, + LightningUnavailableError, } from '@didcid/common/errors'; import { + DrawbridgeInterface, GatekeeperInterface, DidCidDocument, DocumentMetadata, @@ -39,6 +42,11 @@ import { IssueCredentialsOptions, KeymasterInterface, KeymasterOptions, + LightningConfig, + LightningBalance, + LightningInvoice, + LightningPayment, + LightningPaymentStatus, NostrKeys, NostrEvent, NoticeMessage, @@ -1750,6 +1758,111 @@ export default class Keymaster implements KeymasterInterface { }; } + // Lightning helpers + + private requireDrawbridge(): DrawbridgeInterface { + const gk = this.gatekeeper as DrawbridgeInterface; + if (typeof gk.createLightningWallet !== 'function') { + throw new LightningUnavailableError('Gateway does not support Lightning'); + } + return gk; + } + + private getLightningConfig(idInfo: IDInfo): LightningConfig { + const config = idInfo.lightning as LightningConfig | undefined; + if (!config) { + throw new LightningNotConfiguredError(`No Lightning wallet configured for ${idInfo.did}`); + } + return config; + } + + // Lightning methods + + async addLightning(name?: string): Promise { + const gateway = this.requireDrawbridge(); + + let result!: LightningConfig; + + await this.mutateWallet(async (wallet) => { + const idInfo = await this.fetchIdInfo(name, wallet); + + if (idInfo.lightning) { + result = idInfo.lightning as LightningConfig; + return; + } + + const walletName = `archon-${idInfo.did.split(':').pop()?.substring(0, 12)}`; + const created = await gateway.createLightningWallet(walletName); + + idInfo.lightning = { + walletId: created.walletId, + adminKey: created.adminKey, + invoiceKey: created.invoiceKey, + } as LightningConfig; + + result = idInfo.lightning as LightningConfig; + }); + + return result; + } + + async removeLightning(name?: string): Promise { + await this.mutateWallet(async (wallet) => { + const idInfo = await this.fetchIdInfo(name, wallet); + delete idInfo.lightning; + }); + return true; + } + + async getLightningBalance(name?: string): Promise { + const gateway = this.requireDrawbridge(); + const wallet = await this.loadWallet(); + const idInfo = await this.fetchIdInfo(name, wallet); + const config = this.getLightningConfig(idInfo); + return gateway.getLightningBalance(config.invoiceKey); + } + + async createLightningInvoice(amount: number, memo: string, name?: string): Promise { + if (!amount || amount <= 0) { + throw new InvalidParameterError('amount'); + } + if (!memo) { + throw new InvalidParameterError('memo'); + } + const gateway = this.requireDrawbridge(); + const wallet = await this.loadWallet(); + const idInfo = await this.fetchIdInfo(name, wallet); + const config = this.getLightningConfig(idInfo); + return gateway.createLightningInvoice(config.invoiceKey, amount, memo); + } + + async payLightningInvoice(bolt11: string, name?: string): Promise { + if (!bolt11) { + throw new InvalidParameterError('bolt11'); + } + const gateway = this.requireDrawbridge(); + const wallet = await this.loadWallet(); + const idInfo = await this.fetchIdInfo(name, wallet); + const config = this.getLightningConfig(idInfo); + return gateway.payLightningInvoice(config.adminKey, bolt11); + } + + async checkLightningPayment(paymentHash: string, name?: string): Promise { + if (!paymentHash) { + throw new InvalidParameterError('paymentHash'); + } + const gateway = this.requireDrawbridge(); + const wallet = await this.loadWallet(); + const idInfo = await this.fetchIdInfo(name, wallet); + const config = this.getLightningConfig(idInfo); + const data = await gateway.checkLightningPayment(config.invoiceKey, paymentHash); + return { + paid: data.paid, + preimage: data.preimage, + paymentHash, + }; + } + async testAgent(id: string): Promise { const doc = await this.resolveDID(id); return doc.didDocumentRegistration?.type === 'agent'; diff --git a/packages/keymaster/src/types.ts b/packages/keymaster/src/types.ts index 4d9623d9..9f0330bd 100644 --- a/packages/keymaster/src/types.ts +++ b/packages/keymaster/src/types.ts @@ -4,6 +4,11 @@ import { DidCidDocument, ResolveDIDOptions, Proof, + LightningConfig, + LightningBalance, + LightningInvoice, + LightningPayment, + LightningPaymentStatus, } from '@didcid/gatekeeper/types'; export type { NostrKeys, NostrEvent } from '@didcid/cipher/types'; @@ -235,6 +240,14 @@ export interface WalletBase { updateWallet(mutator: (wallet: StoredWallet) => void | Promise): Promise; } +export type { + LightningConfig, + LightningBalance, + LightningInvoice, + LightningPayment, + LightningPaymentStatus, +} from '@didcid/gatekeeper/types'; + export interface KeymasterOptions { passphrase: string; gatekeeper: GatekeeperInterface; @@ -335,6 +348,14 @@ export interface KeymasterInterface { exportNsec(id?: string): Promise; signNostrEvent(event: NostrEvent): Promise; + // Lightning + addLightning(id?: string): Promise; + removeLightning(id?: string): Promise; + getLightningBalance(id?: string): Promise; + createLightningInvoice(amount: number, memo: string, id?: string): Promise; + payLightningInvoice(bolt11: string, id?: string): Promise; + checkLightningPayment(paymentHash: string, id?: string): Promise; + // DIDs resolveDID(did: string, options?: ResolveDIDOptions): Promise; updateDID(id: string, doc: DidCidDocument): Promise; diff --git a/services/drawbridge/server/src/config.ts b/services/drawbridge/server/src/config.ts index 848296e7..a9a01044 100644 --- a/services/drawbridge/server/src/config.ts +++ b/services/drawbridge/server/src/config.ts @@ -19,6 +19,9 @@ const config = { rateLimitMax: process.env.ARCHON_DRAWBRIDGE_RATE_LIMIT_MAX ? parseInt(process.env.ARCHON_DRAWBRIDGE_RATE_LIMIT_MAX) : 100, rateLimitWindow: process.env.ARCHON_DRAWBRIDGE_RATE_LIMIT_WINDOW ? parseInt(process.env.ARCHON_DRAWBRIDGE_RATE_LIMIT_WINDOW) : 60, + // LNbits + lnbitsUrl: process.env.ARCHON_DRAWBRIDGE_LNBITS_URL || '', + // Redis redisUrl: process.env.ARCHON_REDIS_URL || 'redis://localhost:6379', }; diff --git a/services/drawbridge/server/src/drawbridge-api.ts b/services/drawbridge/server/src/drawbridge-api.ts index 94486525..4c455a9d 100644 --- a/services/drawbridge/server/src/drawbridge-api.ts +++ b/services/drawbridge/server/src/drawbridge-api.ts @@ -14,6 +14,7 @@ import { RedisStore } from './store.js'; import { createAuthMiddleware } from './middleware/auth.js'; import { handlePaymentCompletion, handleRevokeMacaroon, handleL402Status, handleGetPayments } from './middleware/l402-auth.js'; import { loadPricingFromEnv } from './pricing.js'; +import * as lnbits from './lnbits.js'; import type { L402Options, DrawbridgeStore } from './types.js'; const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); @@ -507,6 +508,83 @@ async function main() { } }); + // --- LNbits Lightning wallet routes --- + + v1router.post('/lightning/wallet', async (req, res) => { + if (!config.lnbitsUrl) { + res.status(503).json({ error: 'Lightning (LNbits) not configured' }); + return; + } + try { + const { name } = req.body; + const result = await lnbits.createWallet(config.lnbitsUrl, name || 'archon'); + res.json(result); + } catch (error: any) { + logger.error({ err: error }, 'LNbits error'); + res.status(502).json({ error: error.message || 'LNbits error' }); + } + }); + + v1router.post('/lightning/balance', async (req, res) => { + if (!config.lnbitsUrl) { + res.status(503).json({ error: 'Lightning (LNbits) not configured' }); + return; + } + try { + const { invoiceKey } = req.body; + const balance = await lnbits.getBalance(config.lnbitsUrl, invoiceKey); + res.json({ balance }); + } catch (error: any) { + logger.error({ err: error }, 'LNbits error'); + res.status(502).json({ error: error.message || 'LNbits error' }); + } + }); + + v1router.post('/lightning/invoice', async (req, res) => { + if (!config.lnbitsUrl) { + res.status(503).json({ error: 'Lightning (LNbits) not configured' }); + return; + } + try { + const { invoiceKey, amount, memo } = req.body; + const result = await lnbits.createInvoice(config.lnbitsUrl, invoiceKey, amount, memo); + res.json(result); + } catch (error: any) { + logger.error({ err: error }, 'LNbits error'); + res.status(502).json({ error: error.message || 'LNbits error' }); + } + }); + + v1router.post('/lightning/pay', async (req, res) => { + if (!config.lnbitsUrl) { + res.status(503).json({ error: 'Lightning (LNbits) not configured' }); + return; + } + try { + const { adminKey, bolt11 } = req.body; + const result = await lnbits.payInvoice(config.lnbitsUrl, adminKey, bolt11); + res.json(result); + } catch (error: any) { + logger.error({ err: error }, 'LNbits error'); + res.status(502).json({ error: error.message || 'LNbits error' }); + } + }); + + v1router.post('/lightning/payment', async (req, res) => { + if (!config.lnbitsUrl) { + res.status(503).json({ error: 'Lightning (LNbits) not configured' }); + return; + } + try { + const { invoiceKey, paymentHash } = req.body; + const status = await lnbits.checkPayment(config.lnbitsUrl, invoiceKey, paymentHash); + res.json({ ...status, paymentHash }); + } catch (error: any) { + logger.error({ err: error }, 'LNbits error'); + res.status(502).json({ error: error.message || 'LNbits error' }); + } + }); + // Mount router app.use('/api/v1', v1router); diff --git a/services/drawbridge/server/src/lnbits.ts b/services/drawbridge/server/src/lnbits.ts new file mode 100644 index 00000000..966ff66f --- /dev/null +++ b/services/drawbridge/server/src/lnbits.ts @@ -0,0 +1,105 @@ +import axios from 'axios'; +import { LightningUnavailableError } from './errors.js'; + +/** Create a new LNbits account with an initial wallet (one account per DID). */ +export async function createWallet( + url: string, + walletName: string +): Promise<{ walletId: string; adminKey: string; invoiceKey: string }> { + try { + const response = await axios.post( + `${url}/api/v1/account`, + { name: walletName } + ); + return { + walletId: response.data.id, + adminKey: response.data.adminkey, + invoiceKey: response.data.inkey, + }; + } catch (error: any) { + const detail = error.response?.data?.detail || error.code || error.message; + throw new LightningUnavailableError(String(detail)); + } +} + +/** Get wallet balance in sats. Uses invoiceKey (read-only). */ +export async function getBalance( + url: string, + invoiceKey: string +): Promise { + try { + const response = await axios.get(`${url}/api/v1/wallet`, { + headers: { 'X-Api-Key': invoiceKey }, + }); + // LNbits returns balance in millisats + return Math.floor(response.data.balance / 1000); + } catch (error: any) { + const detail = error.response?.data?.detail || error.code || error.message; + throw new LightningUnavailableError(String(detail)); + } +} + +/** Create a Lightning invoice to receive sats. Uses invoiceKey. */ +export async function createInvoice( + url: string, + invoiceKey: string, + amount: number, + memo: string +): Promise<{ paymentRequest: string; paymentHash: string }> { + try { + const response = await axios.post( + `${url}/api/v1/payments`, + { out: false, amount, memo }, + { headers: { 'X-Api-Key': invoiceKey } } + ); + return { + paymentRequest: response.data.payment_request, + paymentHash: response.data.payment_hash, + }; + } catch (error: any) { + const detail = error.response?.data?.detail || error.code || error.message; + throw new LightningUnavailableError(String(detail)); + } +} + +/** Pay a bolt11 invoice. Uses adminKey (spending key). */ +export async function payInvoice( + url: string, + adminKey: string, + bolt11: string +): Promise<{ paymentHash: string }> { + try { + const response = await axios.post( + `${url}/api/v1/payments`, + { out: true, bolt11 }, + { headers: { 'X-Api-Key': adminKey } } + ); + return { + paymentHash: response.data.payment_hash, + }; + } catch (error: any) { + const detail = error.response?.data?.detail || error.code || error.message; + throw new LightningUnavailableError(String(detail)); + } +} + +/** Check payment status by payment hash. Uses invoiceKey. */ +export async function checkPayment( + url: string, + invoiceKey: string, + paymentHash: string +): Promise<{ paid: boolean; preimage?: string }> { + try { + const response = await axios.get( + `${url}/api/v1/payments/${paymentHash}`, + { headers: { 'X-Api-Key': invoiceKey } } + ); + return { + paid: response.data.paid === true, + preimage: response.data.preimage, + }; + } catch (error: any) { + const detail = error.response?.data?.detail || error.code || error.message; + throw new LightningUnavailableError(String(detail)); + } +} diff --git a/services/keymaster/server/src/keymaster-api.ts b/services/keymaster/server/src/keymaster-api.ts index 674c2236..7f2943ab 100644 --- a/services/keymaster/server/src/keymaster-api.ts +++ b/services/keymaster/server/src/keymaster-api.ts @@ -4,7 +4,7 @@ import morgan from 'morgan'; import path from 'path'; import { fileURLToPath } from 'url'; -import GatekeeperClient from '@didcid/gatekeeper/client'; +import DrawbridgeClient from '@didcid/gatekeeper/drawbridge'; import Keymaster from '@didcid/keymaster'; import { WalletBase } from '@didcid/keymaster/types'; import WalletJson from '@didcid/keymaster/wallet/json'; @@ -165,7 +165,7 @@ if (serveClient) { }); } -let gatekeeper: GatekeeperClient; +let gatekeeper: DrawbridgeClient; let keymaster: Keymaster; let serverReady = false; @@ -1886,6 +1886,213 @@ v1router.post('/nostr/sign', async (req, res) => { } }); +// Lightning routes + +/** + * @swagger + * /lightning: + * post: + * summary: Create a Lightning wallet for the current identity. + * requestBody: + * required: false + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * description: Optional identity name or DID. + * responses: + * 200: + * description: Lightning wallet configuration. + * 400: + * description: Error creating Lightning wallet. + */ +v1router.post('/lightning', async (req, res) => { + try { + const { id } = req.body; + const config = await keymaster.addLightning(id); + res.json(config); + } catch (error: any) { + res.status(400).send({ error: error.toString() }); + } +}); + +/** + * @swagger + * /lightning: + * delete: + * summary: Remove Lightning wallet from the current identity. + * requestBody: + * required: false + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * description: Optional identity name or DID. + * responses: + * 200: + * description: Success. + * 400: + * description: Error removing Lightning wallet. + */ +v1router.delete('/lightning', async (req, res) => { + try { + const { id } = req.body; + const ok = await keymaster.removeLightning(id); + res.json({ ok }); + } catch (error: any) { + res.status(400).send({ error: error.toString() }); + } +}); + +/** + * @swagger + * /lightning/balance: + * post: + * summary: Get Lightning wallet balance for the current identity. + * requestBody: + * required: false + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * description: Optional identity name or DID. + * responses: + * 200: + * description: Balance in sats. + * 400: + * description: Error getting balance. + */ +v1router.post('/lightning/balance', async (req, res) => { + try { + const { id } = req.body; + const balance = await keymaster.getLightningBalance(id); + res.json(balance); + } catch (error: any) { + res.status(400).send({ error: error.toString() }); + } +}); + +/** + * @swagger + * /lightning/invoice: + * post: + * summary: Create a Lightning invoice to receive sats. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - amount + * - memo + * properties: + * amount: + * type: number + * description: Amount in sats. + * memo: + * type: string + * description: Invoice description. + * id: + * type: string + * description: Optional identity name or DID. + * responses: + * 200: + * description: Lightning invoice. + * 400: + * description: Error creating invoice. + */ +v1router.post('/lightning/invoice', async (req, res) => { + try { + const { amount, memo, id } = req.body; + const invoice = await keymaster.createLightningInvoice(amount, memo, id); + res.json(invoice); + } catch (error: any) { + res.status(400).send({ error: error.toString() }); + } +}); + +/** + * @swagger + * /lightning/pay: + * post: + * summary: Pay a Lightning invoice. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - bolt11 + * properties: + * bolt11: + * type: string + * description: BOLT11 invoice string. + * id: + * type: string + * description: Optional identity name or DID. + * responses: + * 200: + * description: Payment result. + * 400: + * description: Error paying invoice. + */ +v1router.post('/lightning/pay', async (req, res) => { + try { + const { bolt11, id } = req.body; + const payment = await keymaster.payLightningInvoice(bolt11, id); + res.json(payment); + } catch (error: any) { + res.status(400).send({ error: error.toString() }); + } +}); + +/** + * @swagger + * /lightning/payment: + * post: + * summary: Check status of a Lightning payment. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - paymentHash + * properties: + * paymentHash: + * type: string + * description: Payment hash to check. + * id: + * type: string + * description: Optional identity name or DID. + * responses: + * 200: + * description: Payment status. + * 400: + * description: Error checking payment. + */ +v1router.post('/lightning/payment', async (req, res) => { + try { + const { paymentHash, id } = req.body; + const status = await keymaster.checkLightningPayment(paymentHash, id); + res.json(status); + } catch (error: any) { + res.status(400).send({ error: error.toString() }); + } +}); + /** * @swagger * /challenge: @@ -6849,7 +7056,7 @@ async function initWallet() { const port = config.keymasterPort; const server = app.listen(port, config.bindAddress, async () => { - gatekeeper = new GatekeeperClient(); + gatekeeper = new DrawbridgeClient(); await gatekeeper.connect({ url: config.gatekeeperURL, diff --git a/tests/keymaster/client.test.ts b/tests/keymaster/client.test.ts index 74eab548..0ff4ba36 100644 --- a/tests/keymaster/client.test.ts +++ b/tests/keymaster/client.test.ts @@ -116,6 +116,7 @@ describe('isReady', () => { it('should timeout if not ready', async () => { nock(KeymasterURL) .get(Endpoints.ready) + .times(3) .reply(200, { ready: false }); const keymaster = await KeymasterClient.create({ diff --git a/tests/keymaster/lightning.test.ts b/tests/keymaster/lightning.test.ts new file mode 100644 index 00000000..c5a28d75 --- /dev/null +++ b/tests/keymaster/lightning.test.ts @@ -0,0 +1,378 @@ +import Gatekeeper from '@didcid/gatekeeper'; +import Keymaster from '@didcid/keymaster'; +import CipherNode from '@didcid/cipher/node'; +import DbJsonMemory from '@didcid/gatekeeper/db/json-memory'; +import WalletJsonMemory from '@didcid/keymaster/wallet/json-memory'; +import { UnknownIDError, LightningNotConfiguredError, LightningUnavailableError } from '@didcid/common/errors'; +import HeliaClient from '@didcid/ipfs/helia'; + +let ipfs: HeliaClient; +let gatekeeper: any; +let wallet: WalletJsonMemory; +let cipher: CipherNode; +let keymaster: Keymaster; + +let calls: Array<{ method: string; args: any[] }>; + +function trackCall(method: string, ...args: any[]) { + calls.push({ method, args }); +} + +beforeAll(async () => { + ipfs = new HeliaClient(); + await ipfs.start(); +}); + +afterAll(async () => { + if (ipfs) { + await ipfs.stop(); + } +}); + +beforeEach(() => { + const db = new DbJsonMemory('test'); + const baseGatekeeper = new Gatekeeper({ db, ipfs, registries: ['local', 'hyperswarm', 'BTC:signet'] }); + wallet = new WalletJsonMemory(); + cipher = new CipherNode(); + + calls = []; + + // Create a gatekeeper proxy that adds DrawbridgeInterface Lightning methods + gatekeeper = Object.create(baseGatekeeper); + + gatekeeper.createLightningWallet = (name: string) => { + trackCall('createLightningWallet', name); + return Promise.resolve({ walletId: 'w1', adminKey: 'admin1', invoiceKey: 'invoice1' }); + }; + + gatekeeper.getLightningBalance = (invoiceKey: string) => { + trackCall('getLightningBalance', invoiceKey); + return Promise.resolve({ balance: 1000 }); + }; + + gatekeeper.createLightningInvoice = (invoiceKey: string, amount: number, memo: string) => { + trackCall('createLightningInvoice', invoiceKey, amount, memo); + return Promise.resolve({ paymentRequest: 'lnbc100...', paymentHash: 'hash123' }); + }; + + gatekeeper.payLightningInvoice = (adminKey: string, bolt11: string) => { + trackCall('payLightningInvoice', adminKey, bolt11); + return Promise.resolve({ paymentHash: 'out-hash' }); + }; + + gatekeeper.checkLightningPayment = (invoiceKey: string, paymentHash: string) => { + trackCall('checkLightningPayment', invoiceKey, paymentHash); + return Promise.resolve({ paid: true, preimage: 'preimage123', paymentHash }); + }; + + keymaster = new Keymaster({ + gatekeeper, wallet, cipher, passphrase: 'passphrase', + }); +}); + +describe('addLightning', () => { + it('should create a Lightning wallet for the current ID', async () => { + await keymaster.createId('Bob'); + + const config = await keymaster.addLightning(); + + expect(config).toStrictEqual({ + walletId: 'w1', + adminKey: 'admin1', + invoiceKey: 'invoice1', + }); + }); + + it('should store credentials in wallet IDInfo', async () => { + await keymaster.createId('Bob'); + + await keymaster.addLightning(); + + const walletData = await keymaster.loadWallet(); + const bobInfo = Object.values(walletData.ids).find(id => id.did); + expect(bobInfo?.lightning).toStrictEqual({ + walletId: 'w1', + adminKey: 'admin1', + invoiceKey: 'invoice1', + }); + }); + + it('should be idempotent on repeat call', async () => { + await keymaster.createId('Bob'); + + const config1 = await keymaster.addLightning(); + const config2 = await keymaster.addLightning(); + + expect(config1).toStrictEqual(config2); + const walletCalls = calls.filter(c => c.method === 'createLightningWallet'); + expect(walletCalls.length).toBe(1); + }); + + it('should create separate wallets for different DIDs', async () => { + let walletCounter = 0; + gatekeeper.createLightningWallet = (name: string) => { + walletCounter++; + trackCall('createLightningWallet', name); + return Promise.resolve({ + walletId: `w-${walletCounter}`, + adminKey: `admin-${walletCounter}`, + invoiceKey: `invoice-${walletCounter}`, + }); + }; + + await keymaster.createId('Alice'); + const aliceConfig = await keymaster.addLightning(); + + await keymaster.createId('Bob'); + const bobConfig = await keymaster.addLightning(); + + expect(aliceConfig.walletId).not.toBe(bobConfig.walletId); + const walletCalls = calls.filter(c => c.method === 'createLightningWallet'); + expect(walletCalls.length).toBe(2); + }); + + it('should create wallet for a named ID', async () => { + gatekeeper.createLightningWallet = (name: string) => { + trackCall('createLightningWallet', name); + return Promise.resolve({ + walletId: 'w-alice', + adminKey: 'admin-alice', + invoiceKey: 'invoice-alice', + }); + }; + + await keymaster.createId('Alice'); + await keymaster.createId('Bob'); + + const config = await keymaster.addLightning('Alice'); + + expect(config.walletId).toBe('w-alice'); + }); + + it('should throw for unknown ID name', async () => { + await keymaster.createId('Bob'); + + try { + await keymaster.addLightning('Unknown'); + throw new Error('Expected exception'); + } + catch (error: any) { + expect(error.type).toBe(UnknownIDError.type); + } + }); + + it('should throw when no ID exists', async () => { + try { + await keymaster.addLightning(); + throw new Error('Expected exception'); + } + catch (error: any) { + expect(error).toBeDefined(); + } + }); +}); + +describe('removeLightning', () => { + it('should remove Lightning config from IDInfo', async () => { + await keymaster.createId('Bob'); + + await keymaster.addLightning(); + + const ok = await keymaster.removeLightning(); + expect(ok).toBe(true); + + const walletData = await keymaster.loadWallet(); + const bobInfo = Object.values(walletData.ids).find(id => id.did); + expect(bobInfo?.lightning).toBeUndefined(); + }); + + it('should succeed even if Lightning was not configured', async () => { + await keymaster.createId('Bob'); + + const ok = await keymaster.removeLightning(); + expect(ok).toBe(true); + }); + + it('should throw for unknown ID name', async () => { + await keymaster.createId('Bob'); + + try { + await keymaster.removeLightning('Unknown'); + throw new Error('Expected exception'); + } + catch (error: any) { + expect(error.type).toBe(UnknownIDError.type); + } + }); +}); + +describe('getLightningBalance', () => { + it('should return balance from gateway', async () => { + await keymaster.createId('Bob'); + await keymaster.addLightning(); + + const result = await keymaster.getLightningBalance(); + + expect(result.balance).toBe(1000); + + const balanceCalls = calls.filter(c => c.method === 'getLightningBalance'); + expect(balanceCalls.length).toBe(1); + expect(balanceCalls[0].args).toStrictEqual(['invoice1']); + }); + + it('should throw when Lightning not configured', async () => { + await keymaster.createId('Bob'); + + try { + await keymaster.getLightningBalance(); + throw new Error('Expected exception'); + } + catch (error: any) { + expect(error.type).toBe(LightningNotConfiguredError.type); + } + }); +}); + +describe('createLightningInvoice', () => { + it('should create an invoice via gateway', async () => { + await keymaster.createId('Bob'); + await keymaster.addLightning(); + + const invoice = await keymaster.createLightningInvoice(100, 'test payment'); + + expect(invoice.paymentRequest).toBe('lnbc100...'); + expect(invoice.paymentHash).toBe('hash123'); + }); + + it('should throw for invalid amount', async () => { + await keymaster.createId('Bob'); + + try { + await keymaster.createLightningInvoice(0, 'test'); + throw new Error('Expected exception'); + } + catch (error: any) { + expect(error.type).toBe('Invalid parameter'); + } + }); + + it('should throw for missing memo', async () => { + await keymaster.createId('Bob'); + + try { + await keymaster.createLightningInvoice(100, ''); + throw new Error('Expected exception'); + } + catch (error: any) { + expect(error.type).toBe('Invalid parameter'); + } + }); + + it('should throw when Lightning not configured', async () => { + await keymaster.createId('Bob'); + + try { + await keymaster.createLightningInvoice(100, 'test'); + throw new Error('Expected exception'); + } + catch (error: any) { + expect(error.type).toBe(LightningNotConfiguredError.type); + } + }); +}); + +describe('payLightningInvoice', () => { + it('should pay an invoice via gateway', async () => { + await keymaster.createId('Bob'); + await keymaster.addLightning(); + + const payment = await keymaster.payLightningInvoice('lnbc100...'); + + expect(payment.paymentHash).toBe('out-hash'); + + const payCalls = calls.filter(c => c.method === 'payLightningInvoice'); + expect(payCalls.length).toBe(1); + expect(payCalls[0].args).toStrictEqual(['admin1', 'lnbc100...']); + }); + + it('should throw for empty bolt11', async () => { + await keymaster.createId('Bob'); + + try { + await keymaster.payLightningInvoice(''); + throw new Error('Expected exception'); + } + catch (error: any) { + expect(error.type).toBe('Invalid parameter'); + } + }); + + it('should throw when Lightning not configured', async () => { + await keymaster.createId('Bob'); + + try { + await keymaster.payLightningInvoice('lnbc100...'); + throw new Error('Expected exception'); + } + catch (error: any) { + expect(error.type).toBe(LightningNotConfiguredError.type); + } + }); +}); + +describe('checkLightningPayment', () => { + it('should check payment status via gateway', async () => { + await keymaster.createId('Bob'); + await keymaster.addLightning(); + + const status = await keymaster.checkLightningPayment('hash123'); + + expect(status.paid).toBe(true); + expect(status.preimage).toBe('preimage123'); + expect(status.paymentHash).toBe('hash123'); + }); + + it('should throw for empty payment hash', async () => { + await keymaster.createId('Bob'); + + try { + await keymaster.checkLightningPayment(''); + throw new Error('Expected exception'); + } + catch (error: any) { + expect(error.type).toBe('Invalid parameter'); + } + }); + + it('should throw when Lightning not configured', async () => { + await keymaster.createId('Bob'); + + try { + await keymaster.checkLightningPayment('hash123'); + throw new Error('Expected exception'); + } + catch (error: any) { + expect(error.type).toBe(LightningNotConfiguredError.type); + } + }); +}); + +describe('Lightning without Drawbridge', () => { + it('should throw when gatekeeper has no Lightning methods', async () => { + const db = new DbJsonMemory('test'); + const plainGatekeeper = new Gatekeeper({ db, ipfs, registries: ['local', 'hyperswarm', 'BTC:signet'] }); + const plainKeymaster = new Keymaster({ + gatekeeper: plainGatekeeper, wallet, cipher, passphrase: 'passphrase', + }); + + await plainKeymaster.createId('Bob'); + + try { + await plainKeymaster.addLightning(); + throw new Error('Expected exception'); + } + catch (error: any) { + expect(error.type).toBe(LightningUnavailableError.type); + } + }); +}); From 14cdd42cbe3ffdf1d5cb122714d39c95996410da Mon Sep 17 00:00:00 2001 From: David McFadzean Date: Sat, 28 Feb 2026 13:46:41 -0500 Subject: [PATCH 2/3] fix: CLI uses DrawbridgeClient, add LNbits URL to config - CLI was using GatekeeperClient which lacks Lightning methods - Add ARCHON_DRAWBRIDGE_LNBITS_URL to sample.env and docker-compose Co-Authored-By: Claude Opus 4.6 --- docker-compose.drawbridge.yml | 1 + packages/keymaster/src/cli.ts | 4 ++-- sample.env | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docker-compose.drawbridge.yml b/docker-compose.drawbridge.yml index 5b93ad7f..9907012d 100644 --- a/docker-compose.drawbridge.yml +++ b/docker-compose.drawbridge.yml @@ -11,6 +11,7 @@ services: - ARCHON_ADMIN_API_KEY=${ARCHON_ADMIN_API_KEY} - ARCHON_GATEKEEPER_URL=http://gatekeeper:4224 - ARCHON_REDIS_URL=redis://redis:6379 + - ARCHON_DRAWBRIDGE_LNBITS_URL=${ARCHON_DRAWBRIDGE_LNBITS_URL:-} - ARCHON_DRAWBRIDGE_CLN_REST_URL=${ARCHON_DRAWBRIDGE_CLN_REST_URL:-https://cln-mainnet-node:3001} - ARCHON_DRAWBRIDGE_CLN_RUNE=${ARCHON_DRAWBRIDGE_CLN_RUNE:-} - ARCHON_DRAWBRIDGE_MACAROON_SECRET=${ARCHON_DRAWBRIDGE_MACAROON_SECRET:-} diff --git a/packages/keymaster/src/cli.ts b/packages/keymaster/src/cli.ts index 81657a91..f13a3832 100644 --- a/packages/keymaster/src/cli.ts +++ b/packages/keymaster/src/cli.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from 'url'; import dotenv from 'dotenv'; import Keymaster from './keymaster.js'; -import GatekeeperClient from '@didcid/gatekeeper/client'; +import DrawbridgeClient from '@didcid/gatekeeper/drawbridge'; import CipherNode from '@didcid/cipher/node'; import WalletJson from './db/json.js'; import WalletSQLite from './db/sqlite.js'; @@ -1793,7 +1793,7 @@ async function run() { try { // Initialize gatekeeper client - const gatekeeper = new GatekeeperClient(); + const gatekeeper = new DrawbridgeClient(); await gatekeeper.connect({ url: gatekeeperURL, waitUntilReady: true, diff --git a/sample.env b/sample.env index 5d451046..8cafbc95 100644 --- a/sample.env +++ b/sample.env @@ -141,6 +141,7 @@ ARCHON_DRAWBRIDGE_INVOICE_EXPIRY=3600 ARCHON_DRAWBRIDGE_RATE_LIMIT_MAX=100 ARCHON_DRAWBRIDGE_RATE_LIMIT_WINDOW=60 ARCHON_DRAWBRIDGE_CLN_REST_URL=https://cln-mainnet-node:3001 +ARCHON_DRAWBRIDGE_LNBITS_URL= # CLN rune and macaroon secret are auto-generated by Docker entrypoint # Override only for non-Docker or custom setups: # ARCHON_DRAWBRIDGE_CLN_RUNE= From 49a07518f04142140e100f6827503ad50eeae785 Mon Sep 17 00:00:00 2001 From: David McFadzean Date: Sat, 28 Feb 2026 14:31:17 -0500 Subject: [PATCH 3/3] fix: Handle LNbits balance field name variation LNbits returns balance in msats under "balance" (older) or "balance_msat" (newer). Try both fields. Co-Authored-By: Claude Opus 4.6 --- services/drawbridge/server/src/lnbits.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/services/drawbridge/server/src/lnbits.ts b/services/drawbridge/server/src/lnbits.ts index 966ff66f..f57c35ec 100644 --- a/services/drawbridge/server/src/lnbits.ts +++ b/services/drawbridge/server/src/lnbits.ts @@ -31,8 +31,9 @@ export async function getBalance( const response = await axios.get(`${url}/api/v1/wallet`, { headers: { 'X-Api-Key': invoiceKey }, }); - // LNbits returns balance in millisats - return Math.floor(response.data.balance / 1000); + // LNbits returns balance in msats (field is "balance" or "balance_msat") + const msats = response.data.balance ?? response.data.balance_msat ?? 0; + return Math.floor(msats / 1000); } catch (error: any) { const detail = error.response?.data?.detail || error.code || error.message; throw new LightningUnavailableError(String(detail));