feat(extensions): add extension loader infrastructure (#3)
Some checks are pending
Docker Compose Actions Workflow / test (push) Waiting to run

## Summary

- Adds a modular extension system for Lightning.Pub enabling third-party plugins
- Provides isolated SQLite databases per extension for data safety
- Implements ExtensionContext API for accessing Lightning.Pub services (payments, Nostr, storage)
- Supports RPC method registration with automatic namespacing
- Includes HTTP route handling for protocols like LNURL
- Event routing for payment receipts and Nostr events
- Comprehensive documentation with architecture overview and working examples

## Key Components

- `src/extensions/types.ts` - Core extension interfaces
- `src/extensions/loader.ts` - Extension discovery, loading, and lifecycle management
- `src/extensions/context.ts` - Bridge between extensions and Lightning.Pub services
- `src/extensions/database.ts` - SQLite isolation with WAL mode
- `src/extensions/README.md` - Full documentation with examples

## ExtensionContext API

| Method | Description |
|--------|-------------|
| `getApplication()` | Get application info |
| `createInvoice()` | Create Lightning invoice |
| `payInvoice()` | Pay Lightning invoice |
| `getLnurlPayInfo()` | Get LNURL-pay info for a user (enables Lightning Address/zaps) |
| `sendEncryptedDM()` | Send Nostr DM (NIP-44) |
| `publishNostrEvent()` | Publish Nostr event |
| `registerMethod()` | Register RPC method |
| `onPaymentReceived()` | Subscribe to payment callbacks |
| `onNostrEvent()` | Subscribe to Nostr events |

## Test plan

- [x] Review extension loader code for correctness
- [x] Verify TypeScript compilation succeeds
- [x] Test extension discovery from `src/extensions/` directory
- [x] Test RPC method registration and routing
- [x] Test database isolation between extensions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: boufni95 <boufni95@gmail.com>
Co-authored-by: Patrick Mulligan <patjmulligan@protonmail.com>
Reviewed-on: #3
This commit is contained in:
padreug 2026-04-02 18:47:55 +00:00
parent 72c9872b23
commit 77e5772afd
47 changed files with 10187 additions and 4828 deletions

View file

@ -1,4 +1,4 @@
// @generated by protobuf-ts 2.8.1
// @generated by protobuf-ts 2.11.1
// @generated from protobuf file "walletunlocker.proto" (package "lnrpc", syntax proto3)
// tslint:disable
import { ServiceType } from "@protobuf-ts/runtime-rpc";
@ -10,7 +10,6 @@ import type { IBinaryReader } from "@protobuf-ts/runtime";
import { UnknownFieldHandler } from "@protobuf-ts/runtime";
import type { PartialMessage } from "@protobuf-ts/runtime";
import { reflectionMergePartial } from "@protobuf-ts/runtime";
import { MESSAGE_TYPE } from "@protobuf-ts/runtime";
import { MessageType } from "@protobuf-ts/runtime";
import { ChanBackupSnapshot } from "./lightning.js";
/**
@ -23,7 +22,7 @@ export interface GenSeedRequest {
* to encrypt the generated aezeed cipher seed. When using REST, this field
* must be encoded as base64.
*
* @generated from protobuf field: bytes aezeed_passphrase = 1;
* @generated from protobuf field: bytes aezeed_passphrase = 1
*/
aezeedPassphrase: Uint8Array;
/**
@ -32,7 +31,7 @@ export interface GenSeedRequest {
* specified, then a fresh set of randomness will be used to create the seed.
* When using REST, this field must be encoded as base64.
*
* @generated from protobuf field: bytes seed_entropy = 2;
* @generated from protobuf field: bytes seed_entropy = 2
*/
seedEntropy: Uint8Array;
}
@ -48,7 +47,7 @@ export interface GenSeedResponse {
* Otherwise, then the daemon will attempt to recover the wallet state linked
* to this cipher seed.
*
* @generated from protobuf field: repeated string cipher_seed_mnemonic = 1;
* @generated from protobuf field: repeated string cipher_seed_mnemonic = 1
*/
cipherSeedMnemonic: string[];
/**
@ -56,7 +55,7 @@ export interface GenSeedResponse {
* enciphered_seed are the raw aezeed cipher seed bytes. This is the raw
* cipher text before run through our mnemonic encoding scheme.
*
* @generated from protobuf field: bytes enciphered_seed = 2;
* @generated from protobuf field: bytes enciphered_seed = 2
*/
encipheredSeed: Uint8Array;
}
@ -71,7 +70,7 @@ export interface InitWalletRequest {
* password is required to unlock the daemon. When using REST, this field
* must be encoded as base64.
*
* @generated from protobuf field: bytes wallet_password = 1;
* @generated from protobuf field: bytes wallet_password = 1
*/
walletPassword: Uint8Array;
/**
@ -80,7 +79,7 @@ export interface InitWalletRequest {
* cipher seed obtained by the user. This may have been generated by the
* GenSeed method, or be an existing seed.
*
* @generated from protobuf field: repeated string cipher_seed_mnemonic = 2;
* @generated from protobuf field: repeated string cipher_seed_mnemonic = 2
*/
cipherSeedMnemonic: string[];
/**
@ -89,7 +88,7 @@ export interface InitWalletRequest {
* to encrypt the generated aezeed cipher seed. When using REST, this field
* must be encoded as base64.
*
* @generated from protobuf field: bytes aezeed_passphrase = 3;
* @generated from protobuf field: bytes aezeed_passphrase = 3
*/
aezeedPassphrase: Uint8Array;
/**
@ -100,7 +99,7 @@ export interface InitWalletRequest {
* window of zero indicates that no addresses should be recovered, such after
* the first initialization of the wallet.
*
* @generated from protobuf field: int32 recovery_window = 4;
* @generated from protobuf field: int32 recovery_window = 4
*/
recoveryWindow: number;
/**
@ -112,7 +111,7 @@ export interface InitWalletRequest {
* funds, lnd begin to carry out the data loss recovery protocol in order to
* recover the funds in each channel from a remote force closed transaction.
*
* @generated from protobuf field: lnrpc.ChanBackupSnapshot channel_backups = 5;
* @generated from protobuf field: lnrpc.ChanBackupSnapshot channel_backups = 5
*/
channelBackups?: ChanBackupSnapshot;
/**
@ -122,7 +121,7 @@ export interface InitWalletRequest {
* admin macaroon returned in the response MUST be stored by the caller of the
* RPC as otherwise all access to the daemon will be lost!
*
* @generated from protobuf field: bool stateless_init = 6;
* @generated from protobuf field: bool stateless_init = 6
*/
statelessInit: boolean;
/**
@ -140,7 +139,7 @@ export interface InitWalletRequest {
* extended_master_key_birthday_timestamp or a "safe" default value will be
* used.
*
* @generated from protobuf field: string extended_master_key = 7;
* @generated from protobuf field: string extended_master_key = 7
*/
extendedMasterKey: string;
/**
@ -153,7 +152,7 @@ export interface InitWalletRequest {
* which case lnd will start scanning from the first SegWit block (481824 on
* mainnet).
*
* @generated from protobuf field: uint64 extended_master_key_birthday_timestamp = 8;
* @generated from protobuf field: uint64 extended_master_key_birthday_timestamp = 8
*/
extendedMasterKeyBirthdayTimestamp: bigint;
/**
@ -164,7 +163,7 @@ export interface InitWalletRequest {
* any of the keys and _needs_ to be run with a remote signer that has the
* corresponding private keys and can serve signing RPC requests.
*
* @generated from protobuf field: lnrpc.WatchOnly watch_only = 9;
* @generated from protobuf field: lnrpc.WatchOnly watch_only = 9
*/
watchOnly?: WatchOnly;
/**
@ -173,7 +172,7 @@ export interface InitWalletRequest {
* provided when initializing the wallet rather than letting lnd generate one
* on its own.
*
* @generated from protobuf field: bytes macaroon_root_key = 10;
* @generated from protobuf field: bytes macaroon_root_key = 10
*/
macaroonRootKey: Uint8Array;
}
@ -189,7 +188,7 @@ export interface InitWalletResponse {
* caller. Otherwise a copy of this macaroon is also persisted on disk by the
* daemon, together with other macaroon files.
*
* @generated from protobuf field: bytes admin_macaroon = 1;
* @generated from protobuf field: bytes admin_macaroon = 1
*/
adminMacaroon: Uint8Array;
}
@ -205,7 +204,7 @@ export interface WatchOnly {
* should be left at its default value of 0 in which case lnd will start
* scanning from the first SegWit block (481824 on mainnet).
*
* @generated from protobuf field: uint64 master_key_birthday_timestamp = 1;
* @generated from protobuf field: uint64 master_key_birthday_timestamp = 1
*/
masterKeyBirthdayTimestamp: bigint;
/**
@ -215,7 +214,7 @@ export interface WatchOnly {
* required by some hardware wallets for proper identification and signing. The
* bytes must be in big-endian order.
*
* @generated from protobuf field: bytes master_key_fingerprint = 2;
* @generated from protobuf field: bytes master_key_fingerprint = 2
*/
masterKeyFingerprint: Uint8Array;
/**
@ -226,7 +225,7 @@ export interface WatchOnly {
* scope (m/1017'/<coin_type>'/<account>'), where account is the key family as
* defined in `keychain/derivation.go` (currently indices 0 to 9).
*
* @generated from protobuf field: repeated lnrpc.WatchOnlyAccount accounts = 3;
* @generated from protobuf field: repeated lnrpc.WatchOnlyAccount accounts = 3
*/
accounts: WatchOnlyAccount[];
}
@ -239,7 +238,7 @@ export interface WatchOnlyAccount {
* Purpose is the first number in the derivation path, must be either 49, 84
* or 1017.
*
* @generated from protobuf field: uint32 purpose = 1;
* @generated from protobuf field: uint32 purpose = 1
*/
purpose: number;
/**
@ -248,7 +247,7 @@ export interface WatchOnlyAccount {
* for purposes 49 and 84. It only needs to be set to 1 for purpose 1017 on
* testnet or regtest.
*
* @generated from protobuf field: uint32 coin_type = 2;
* @generated from protobuf field: uint32 coin_type = 2
*/
coinType: number;
/**
@ -259,14 +258,14 @@ export interface WatchOnlyAccount {
* one account for each of the key families defined in `keychain/derivation.go`
* (currently indices 0 to 9)
*
* @generated from protobuf field: uint32 account = 3;
* @generated from protobuf field: uint32 account = 3
*/
account: number;
/**
*
* The extended public key at depth 3 for the given account.
*
* @generated from protobuf field: string xpub = 4;
* @generated from protobuf field: string xpub = 4
*/
xpub: string;
}
@ -280,7 +279,7 @@ export interface UnlockWalletRequest {
* will be required to decrypt on-disk material that the daemon requires to
* function properly. When using REST, this field must be encoded as base64.
*
* @generated from protobuf field: bytes wallet_password = 1;
* @generated from protobuf field: bytes wallet_password = 1
*/
walletPassword: Uint8Array;
/**
@ -291,7 +290,7 @@ export interface UnlockWalletRequest {
* window of zero indicates that no addresses should be recovered, such after
* the first initialization of the wallet.
*
* @generated from protobuf field: int32 recovery_window = 2;
* @generated from protobuf field: int32 recovery_window = 2
*/
recoveryWindow: number;
/**
@ -303,7 +302,7 @@ export interface UnlockWalletRequest {
* funds, lnd begin to carry out the data loss recovery protocol in order to
* recover the funds in each channel from a remote force closed transaction.
*
* @generated from protobuf field: lnrpc.ChanBackupSnapshot channel_backups = 3;
* @generated from protobuf field: lnrpc.ChanBackupSnapshot channel_backups = 3
*/
channelBackups?: ChanBackupSnapshot;
/**
@ -311,7 +310,7 @@ export interface UnlockWalletRequest {
* stateless_init is an optional argument instructing the daemon NOT to create
* any *.macaroon files in its file system.
*
* @generated from protobuf field: bool stateless_init = 4;
* @generated from protobuf field: bool stateless_init = 4
*/
statelessInit: boolean;
}
@ -329,7 +328,7 @@ export interface ChangePasswordRequest {
* current_password should be the current valid passphrase used to unlock the
* daemon. When using REST, this field must be encoded as base64.
*
* @generated from protobuf field: bytes current_password = 1;
* @generated from protobuf field: bytes current_password = 1
*/
currentPassword: Uint8Array;
/**
@ -337,7 +336,7 @@ export interface ChangePasswordRequest {
* new_password should be the new passphrase that will be needed to unlock the
* daemon. When using REST, this field must be encoded as base64.
*
* @generated from protobuf field: bytes new_password = 2;
* @generated from protobuf field: bytes new_password = 2
*/
newPassword: Uint8Array;
/**
@ -347,7 +346,7 @@ export interface ChangePasswordRequest {
* admin macaroon returned in the response MUST be stored by the caller of the
* RPC as otherwise all access to the daemon will be lost!
*
* @generated from protobuf field: bool stateless_init = 3;
* @generated from protobuf field: bool stateless_init = 3
*/
statelessInit: boolean;
/**
@ -356,7 +355,7 @@ export interface ChangePasswordRequest {
* rotate the macaroon root key when set to true. This will invalidate all
* previously generated macaroons.
*
* @generated from protobuf field: bool new_macaroon_root_key = 4;
* @generated from protobuf field: bool new_macaroon_root_key = 4
*/
newMacaroonRootKey: boolean;
}
@ -373,7 +372,7 @@ export interface ChangePasswordResponse {
* safely by the caller. Otherwise a copy of this macaroon is also persisted on
* disk by the daemon, together with other macaroon files.
*
* @generated from protobuf field: bytes admin_macaroon = 1;
* @generated from protobuf field: bytes admin_macaroon = 1
*/
adminMacaroon: Uint8Array;
}
@ -386,8 +385,9 @@ class GenSeedRequest$Type extends MessageType<GenSeedRequest> {
]);
}
create(value?: PartialMessage<GenSeedRequest>): GenSeedRequest {
const message = { aezeedPassphrase: new Uint8Array(0), seedEntropy: new Uint8Array(0) };
globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this });
const message = globalThis.Object.create((this.messagePrototype!));
message.aezeedPassphrase = new Uint8Array(0);
message.seedEntropy = new Uint8Array(0);
if (value !== undefined)
reflectionMergePartial<GenSeedRequest>(this, message, value);
return message;
@ -440,8 +440,9 @@ class GenSeedResponse$Type extends MessageType<GenSeedResponse> {
]);
}
create(value?: PartialMessage<GenSeedResponse>): GenSeedResponse {
const message = { cipherSeedMnemonic: [], encipheredSeed: new Uint8Array(0) };
globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this });
const message = globalThis.Object.create((this.messagePrototype!));
message.cipherSeedMnemonic = [];
message.encipheredSeed = new Uint8Array(0);
if (value !== undefined)
reflectionMergePartial<GenSeedResponse>(this, message, value);
return message;
@ -502,8 +503,15 @@ class InitWalletRequest$Type extends MessageType<InitWalletRequest> {
]);
}
create(value?: PartialMessage<InitWalletRequest>): InitWalletRequest {
const message = { walletPassword: new Uint8Array(0), cipherSeedMnemonic: [], aezeedPassphrase: new Uint8Array(0), recoveryWindow: 0, statelessInit: false, extendedMasterKey: "", extendedMasterKeyBirthdayTimestamp: 0n, macaroonRootKey: new Uint8Array(0) };
globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this });
const message = globalThis.Object.create((this.messagePrototype!));
message.walletPassword = new Uint8Array(0);
message.cipherSeedMnemonic = [];
message.aezeedPassphrase = new Uint8Array(0);
message.recoveryWindow = 0;
message.statelessInit = false;
message.extendedMasterKey = "";
message.extendedMasterKeyBirthdayTimestamp = 0n;
message.macaroonRootKey = new Uint8Array(0);
if (value !== undefined)
reflectionMergePartial<InitWalletRequest>(this, message, value);
return message;
@ -603,8 +611,8 @@ class InitWalletResponse$Type extends MessageType<InitWalletResponse> {
]);
}
create(value?: PartialMessage<InitWalletResponse>): InitWalletResponse {
const message = { adminMacaroon: new Uint8Array(0) };
globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this });
const message = globalThis.Object.create((this.messagePrototype!));
message.adminMacaroon = new Uint8Array(0);
if (value !== undefined)
reflectionMergePartial<InitWalletResponse>(this, message, value);
return message;
@ -648,12 +656,14 @@ class WatchOnly$Type extends MessageType<WatchOnly> {
super("lnrpc.WatchOnly", [
{ no: 1, name: "master_key_birthday_timestamp", kind: "scalar", T: 4 /*ScalarType.UINT64*/, L: 0 /*LongType.BIGINT*/ },
{ no: 2, name: "master_key_fingerprint", kind: "scalar", T: 12 /*ScalarType.BYTES*/ },
{ no: 3, name: "accounts", kind: "message", repeat: 1 /*RepeatType.PACKED*/, T: () => WatchOnlyAccount }
{ no: 3, name: "accounts", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => WatchOnlyAccount }
]);
}
create(value?: PartialMessage<WatchOnly>): WatchOnly {
const message = { masterKeyBirthdayTimestamp: 0n, masterKeyFingerprint: new Uint8Array(0), accounts: [] };
globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this });
const message = globalThis.Object.create((this.messagePrototype!));
message.masterKeyBirthdayTimestamp = 0n;
message.masterKeyFingerprint = new Uint8Array(0);
message.accounts = [];
if (value !== undefined)
reflectionMergePartial<WatchOnly>(this, message, value);
return message;
@ -714,8 +724,11 @@ class WatchOnlyAccount$Type extends MessageType<WatchOnlyAccount> {
]);
}
create(value?: PartialMessage<WatchOnlyAccount>): WatchOnlyAccount {
const message = { purpose: 0, coinType: 0, account: 0, xpub: "" };
globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this });
const message = globalThis.Object.create((this.messagePrototype!));
message.purpose = 0;
message.coinType = 0;
message.account = 0;
message.xpub = "";
if (value !== undefined)
reflectionMergePartial<WatchOnlyAccount>(this, message, value);
return message;
@ -782,8 +795,10 @@ class UnlockWalletRequest$Type extends MessageType<UnlockWalletRequest> {
]);
}
create(value?: PartialMessage<UnlockWalletRequest>): UnlockWalletRequest {
const message = { walletPassword: new Uint8Array(0), recoveryWindow: 0, statelessInit: false };
globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this });
const message = globalThis.Object.create((this.messagePrototype!));
message.walletPassword = new Uint8Array(0);
message.recoveryWindow = 0;
message.statelessInit = false;
if (value !== undefined)
reflectionMergePartial<UnlockWalletRequest>(this, message, value);
return message;
@ -845,14 +860,26 @@ class UnlockWalletResponse$Type extends MessageType<UnlockWalletResponse> {
super("lnrpc.UnlockWalletResponse", []);
}
create(value?: PartialMessage<UnlockWalletResponse>): UnlockWalletResponse {
const message = {};
globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this });
const message = globalThis.Object.create((this.messagePrototype!));
if (value !== undefined)
reflectionMergePartial<UnlockWalletResponse>(this, message, value);
return message;
}
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: UnlockWalletResponse): UnlockWalletResponse {
return target ?? this.create();
let message = target ?? this.create(), end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
}
}
return message;
}
internalBinaryWrite(message: UnlockWalletResponse, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
let u = options.writeUnknownFields;
@ -876,8 +903,11 @@ class ChangePasswordRequest$Type extends MessageType<ChangePasswordRequest> {
]);
}
create(value?: PartialMessage<ChangePasswordRequest>): ChangePasswordRequest {
const message = { currentPassword: new Uint8Array(0), newPassword: new Uint8Array(0), statelessInit: false, newMacaroonRootKey: false };
globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this });
const message = globalThis.Object.create((this.messagePrototype!));
message.currentPassword = new Uint8Array(0);
message.newPassword = new Uint8Array(0);
message.statelessInit = false;
message.newMacaroonRootKey = false;
if (value !== undefined)
reflectionMergePartial<ChangePasswordRequest>(this, message, value);
return message;
@ -941,8 +971,8 @@ class ChangePasswordResponse$Type extends MessageType<ChangePasswordResponse> {
]);
}
create(value?: PartialMessage<ChangePasswordResponse>): ChangePasswordResponse {
const message = { adminMacaroon: new Uint8Array(0) };
globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this });
const message = globalThis.Object.create((this.messagePrototype!));
message.adminMacaroon = new Uint8Array(0);
if (value !== undefined)
reflectionMergePartial<ChangePasswordResponse>(this, message, value);
return message;