Release v1.1.0 - Add WebLN API support for Lightning wallet integration
- Add window.webln API for web app Lightning wallet integration
- Implement webln.enable(), getInfo(), sendPayment(), makeInvoice() methods
- Add WebLN permission prompts with proper amount display for payments
- Dispatch webln:ready and webln:enabled events per WebLN standard
- Add NWC client caching for persistent wallet connections
- Implement inline BOLT11 invoice amount parsing
- Always prompt for sendPayment (security-critical, irreversible)
- Add signMessage/verifyMessage stubs that return "not supported"
- Fix response handling for undefined returns in content script
Files modified:
- projects/common/src/lib/models/nostr.ts (WeblnMethod, ExtensionMethod types)
- projects/common/src/lib/models/webln.ts (new - WebLN response types)
- projects/common/src/public-api.ts (export webln types)
- projects/{chrome,firefox}/src/plebian-signer-extension.ts (window.webln)
- projects/{chrome,firefox}/src/background.ts (WebLN handlers)
- projects/{chrome,firefox}/src/background-common.ts (WebLN permissions)
- projects/{chrome,firefox}/public/prompt.html (WebLN cards)
- projects/{chrome,firefox}/src/prompt.ts (WebLN method handling)
- projects/common/src/lib/services/storage/types.ts (ExtensionMethod type)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,14 +3,23 @@ import {
|
||||
backgroundLogNip07Action,
|
||||
backgroundLogPermissionStored,
|
||||
NostrHelper,
|
||||
NwcClient,
|
||||
NwcConnection_DECRYPTED,
|
||||
WeblnMethod,
|
||||
Nip07Method,
|
||||
GetInfoResponse,
|
||||
SendPaymentResponse,
|
||||
RequestInvoiceResponse,
|
||||
} from '@common';
|
||||
import {
|
||||
BackgroundRequestMessage,
|
||||
checkPermissions,
|
||||
checkWeblnPermissions,
|
||||
debug,
|
||||
getBrowserSessionData,
|
||||
getPosition,
|
||||
handleUnlockRequest,
|
||||
isWeblnMethod,
|
||||
nip04Decrypt,
|
||||
nip04Encrypt,
|
||||
nip44Decrypt,
|
||||
@@ -27,6 +36,73 @@ import {
|
||||
import browser from 'webextension-polyfill';
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
// Cache for NWC clients to avoid reconnecting for each request
|
||||
const nwcClientCache = new Map<string, NwcClient>();
|
||||
|
||||
/**
|
||||
* Get or create an NWC client for a connection
|
||||
*/
|
||||
async function getNwcClient(connection: NwcConnection_DECRYPTED): Promise<NwcClient> {
|
||||
const cached = nwcClientCache.get(connection.id);
|
||||
if (cached && cached.isConnected()) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const client = new NwcClient({
|
||||
walletPubkey: connection.walletPubkey,
|
||||
relayUrl: connection.relayUrl,
|
||||
secret: connection.secret,
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
nwcClientCache.set(connection.id, client);
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse invoice amount from a BOLT11 invoice string
|
||||
* Returns amount in satoshis, or undefined if no amount specified
|
||||
*/
|
||||
function parseInvoiceAmount(invoice: string): number | undefined {
|
||||
try {
|
||||
// BOLT11 invoices start with 'ln' followed by network prefix and amount
|
||||
// Format: ln[network][amount][multiplier]1[data]
|
||||
// Examples: lnbc1500n1... (1500 sat), lnbc1m1... (0.001 BTC = 100000 sat)
|
||||
const match = invoice.toLowerCase().match(/^ln(bc|tb|tbs|bcrt)(\d+)([munp])?1/);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const amountStr = match[2];
|
||||
const multiplier = match[3];
|
||||
|
||||
let amount = parseInt(amountStr, 10);
|
||||
|
||||
// Apply multiplier (amount is in BTC by default)
|
||||
switch (multiplier) {
|
||||
case 'm': // milli-bitcoin (0.001 BTC)
|
||||
amount = amount * 100000;
|
||||
break;
|
||||
case 'u': // micro-bitcoin (0.000001 BTC)
|
||||
amount = amount * 100;
|
||||
break;
|
||||
case 'n': // nano-bitcoin (0.000000001 BTC) = 0.1 sat
|
||||
amount = Math.floor(amount / 10);
|
||||
break;
|
||||
case 'p': // pico-bitcoin (0.000000000001 BTC) = 0.0001 sat
|
||||
amount = Math.floor(amount / 10000);
|
||||
break;
|
||||
default:
|
||||
// No multiplier means BTC
|
||||
amount = amount * 100000000;
|
||||
}
|
||||
|
||||
return amount;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
type Relays = Record<string, { read: boolean; write: boolean }>;
|
||||
|
||||
const openPrompts = new Map<
|
||||
@@ -116,8 +192,12 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Process the NIP-07 request
|
||||
return processNip07Request(request as BackgroundRequestMessage);
|
||||
// Process the request (NIP-07 or WebLN)
|
||||
const req = request as BackgroundRequestMessage;
|
||||
if (isWeblnMethod(req.method)) {
|
||||
return processWeblnRequest(req);
|
||||
}
|
||||
return processNip07Request(req);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -149,7 +229,7 @@ async function processNip07Request(req: BackgroundRequestMessage): Promise<any>
|
||||
browserSessionData,
|
||||
currentIdentity,
|
||||
req.host,
|
||||
req.method,
|
||||
req.method as Nip07Method,
|
||||
req.params
|
||||
);
|
||||
debug(`permissionState result: ${permissionState}`);
|
||||
@@ -282,3 +362,146 @@ async function processNip07Request(req: BackgroundRequestMessage): Promise<any>
|
||||
throw new Error(`Not supported request method '${req.method}'.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a WebLN request after vault is unlocked
|
||||
*/
|
||||
async function processWeblnRequest(req: BackgroundRequestMessage): Promise<any> {
|
||||
const browserSessionData = await getBrowserSessionData();
|
||||
|
||||
if (!browserSessionData) {
|
||||
throw new Error('Plebeian Signer vault not unlocked by the user.');
|
||||
}
|
||||
|
||||
const nwcConnections = browserSessionData.nwcConnections ?? [];
|
||||
const method = req.method as WeblnMethod;
|
||||
|
||||
// webln.enable just checks if NWC is configured
|
||||
if (method === 'webln.enable') {
|
||||
if (nwcConnections.length === 0) {
|
||||
throw new Error('No wallet configured. Please add an NWC connection in Plebeian Signer settings.');
|
||||
}
|
||||
debug('WebLN enabled');
|
||||
return { enabled: true }; // Return explicit value (undefined gets filtered by content script)
|
||||
}
|
||||
|
||||
// All other methods require an NWC connection
|
||||
const defaultConnection = nwcConnections[0];
|
||||
if (!defaultConnection) {
|
||||
throw new Error('No wallet configured. Please add an NWC connection in Plebeian Signer settings.');
|
||||
}
|
||||
|
||||
// Check reckless mode (but still prompt for payments)
|
||||
const recklessApprove = await shouldRecklessModeApprove(req.host);
|
||||
|
||||
// Check WebLN permissions
|
||||
const permissionState = recklessApprove && method !== 'webln.sendPayment' && method !== 'webln.keysend'
|
||||
? true
|
||||
: checkWeblnPermissions(browserSessionData, req.host, method);
|
||||
|
||||
if (permissionState === false) {
|
||||
throw new Error('Permission denied');
|
||||
}
|
||||
|
||||
if (permissionState === undefined) {
|
||||
// Ask user for permission
|
||||
const width = 375;
|
||||
const height = 600;
|
||||
const { top, left } = await getPosition(width, height);
|
||||
|
||||
// For sendPayment, include the invoice amount in the prompt data
|
||||
let promptParams = req.params ?? {};
|
||||
if (method === 'webln.sendPayment' && req.params?.paymentRequest) {
|
||||
const amountSats = parseInvoiceAmount(req.params.paymentRequest);
|
||||
promptParams = { ...promptParams, amountSats };
|
||||
}
|
||||
|
||||
const base64Event = Buffer.from(
|
||||
JSON.stringify(promptParams, undefined, 2)
|
||||
).toString('base64');
|
||||
|
||||
const response = await new Promise<PromptResponse>((resolve, reject) => {
|
||||
const id = crypto.randomUUID();
|
||||
openPrompts.set(id, { resolve, reject });
|
||||
browser.windows.create({
|
||||
type: 'popup',
|
||||
url: `prompt.html?method=${method}&host=${req.host}&id=${id}&nick=WebLN&event=${base64Event}`,
|
||||
height,
|
||||
width,
|
||||
top,
|
||||
left,
|
||||
});
|
||||
});
|
||||
|
||||
debug(response);
|
||||
|
||||
// Store permission for non-payment methods
|
||||
if ((response === 'approve' || response === 'reject') && method !== 'webln.sendPayment' && method !== 'webln.keysend') {
|
||||
const policy = response === 'approve' ? 'allow' : 'deny';
|
||||
await storePermission(
|
||||
browserSessionData,
|
||||
null, // WebLN has no identity
|
||||
req.host,
|
||||
method,
|
||||
policy
|
||||
);
|
||||
await backgroundLogPermissionStored(req.host, method, policy);
|
||||
}
|
||||
|
||||
if (['reject', 'reject-once'].includes(response)) {
|
||||
throw new Error('Permission denied');
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the WebLN method
|
||||
let result: any;
|
||||
const client = await getNwcClient(defaultConnection);
|
||||
|
||||
switch (method) {
|
||||
case 'webln.getInfo': {
|
||||
const info = await client.getInfo();
|
||||
result = {
|
||||
node: {
|
||||
alias: info.alias,
|
||||
pubkey: info.pubkey,
|
||||
color: info.color,
|
||||
},
|
||||
} as GetInfoResponse;
|
||||
debug('webln.getInfo result:');
|
||||
debug(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'webln.sendPayment': {
|
||||
const invoice = req.params.paymentRequest;
|
||||
const payResult = await client.payInvoice({ invoice });
|
||||
result = { preimage: payResult.preimage } as SendPaymentResponse;
|
||||
debug('webln.sendPayment result:');
|
||||
debug(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'webln.makeInvoice': {
|
||||
// Convert sats to millisats (NWC uses millisats)
|
||||
const amountSats = typeof req.params.amount === 'string'
|
||||
? parseInt(req.params.amount, 10)
|
||||
: req.params.amount ?? req.params.defaultAmount ?? 0;
|
||||
const amountMsat = amountSats * 1000;
|
||||
|
||||
const invoiceResult = await client.makeInvoice({
|
||||
amount: amountMsat,
|
||||
description: req.params.defaultMemo,
|
||||
});
|
||||
result = { paymentRequest: invoiceResult.invoice } as RequestInvoiceResponse;
|
||||
debug('webln.makeInvoice result:');
|
||||
debug(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'webln.keysend':
|
||||
throw new Error('keysend is not yet supported');
|
||||
|
||||
default:
|
||||
throw new Error(`Not supported WebLN method '${method}'.`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user