Skip to content
Open
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
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ BITCOIN_RPC_PASSWORD=btc.rpc.pass
# --------------------------------------------
# Lightning Configs (optional)
# --------------------------------------------
# valid types: lnd | cln
# valid types: lnd | cln | lnbits
LIGHTNING_TYPE=lnd
LIGHTNING_RPC_HOST=localhost
LIGHTNING_RPC_PORT=8447
Expand All @@ -35,6 +35,9 @@ LIGHTNING_CERT=/path/to/cert
# cln configs
#LIGHTNING_KEY=/path/to/client.key
#LIGHTNING_CA=/path/to/ca.pem
# lnbits configs
#LIGHTNING_API_URL=http://your.lnbits.server
#LIGHTNING_API_KEY=your_wallet_api_key_here

# --------------------------------------------
# Taproot Configs (optional)
Expand Down
2 changes: 2 additions & 0 deletions src/server/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export const config = (): Config => {
cert: process.env.LIGHTNING_CERT,
key: process.env.LIGHTNING_KEY,
ca: process.env.LIGHTNING_CA,
api_url: replaceLocalhostInDocker(process.env.LIGHTNING_API_URL),
api_key: process.env.LIGHTNING_API_KEY,
};

const taproot_assets = {
Expand Down
2 changes: 2 additions & 0 deletions src/server/config/configuration.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export type Config = {
cert: string;
key: string;
ca: string;
api_url: string;
api_key: string;
};
taproot_assets: {
type: string;
Expand Down
1 change: 1 addition & 0 deletions src/server/modules/lightning/lightning.enums.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export enum LightningType {
LND = 'lnd',
CLN = 'cln',
LNBITS = 'lnbits',
}

export enum LightningAddressType {
Expand Down
3 changes: 2 additions & 1 deletion src/server/modules/lightning/lightning/lightning.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import {Module} from '@nestjs/common';
import {FetchModule} from '@server/modules/fetch/fetch.module';
import {LndModule} from '@server/modules/lightning/lnd/lnd.module';
import {ClnModule} from '@server/modules/lightning/cln/cln.module';
import {LnbitsModule} from '@server/modules/lightning/lnbits/lnbits.module';
/* Local Dependencies */
import {LightningService} from './lightning.service';

@Module({
imports: [FetchModule, LndModule, ClnModule],
imports: [FetchModule, LndModule, ClnModule, LnbitsModule],
providers: [LightningService],
exports: [LightningService],
})
Expand Down
15 changes: 15 additions & 0 deletions src/server/modules/lightning/lightning/lightning.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {ConfigService} from '@nestjs/config';
/* Native Dependencies */
import {LndService} from '@server/modules/lightning/lnd/lnd.service';
import {ClnService} from '@server/modules/lightning/cln/cln.service';
import {LnbitsService} from '@server/modules/lightning/lnbits/lnbits.service';
/* Local Dependencies */
import {LightningService} from './lightning.service';
import {OrchardErrorCode} from '@server/modules/error/error.types';
Expand All @@ -14,6 +15,7 @@ describe('LightningService', () => {
let config_service: jest.Mocked<ConfigService>;
let lnd_service: jest.Mocked<LndService>;
let cln_service: jest.Mocked<ClnService>;
let lnbits_service: jest.Mocked<LnbitsService>;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
Expand All @@ -30,13 +32,26 @@ describe('LightningService', () => {
mapClnRequest: jest.fn(),
},
},
{
provide: LnbitsService,
useValue: {
initializeLightningClient: jest.fn(),
mapLnbitsInfo: jest.fn(),
mapLnbitsChannelBalance: jest.fn(),
mapLnbitsRequest: jest.fn(),
getLnbitsInfo: jest.fn(),
getLnbitsBalance: jest.fn(),
decodeLnbitsInvoice: jest.fn(),
},
},
],
}).compile();

lightning_service = module.get<LightningService>(LightningService);
config_service = module.get(ConfigService);
lnd_service = module.get(LndService);
cln_service = module.get(ClnService);
lnbits_service = module.get(LnbitsService);
});

it('should be defined', () => {
Expand Down
11 changes: 9 additions & 2 deletions src/server/modules/lightning/lightning/lightning.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {OrchardErrorCode} from '@server/modules/error/error.types';
import {LightningType} from '@server/modules/lightning/lightning.enums';
import {LndService} from '@server/modules/lightning/lnd/lnd.service';
import {ClnService} from '@server/modules/lightning/cln/cln.service';
import {LnbitsService} from '@server/modules/lightning/lnbits/lnbits.service';
/* Local Dependencies */
import {LightningInfo, LightningChannelBalance, LightningRequest} from './lightning.types';

Expand All @@ -14,22 +15,25 @@ export class LightningService implements OnModuleInit {
private readonly logger = new Logger(LightningService.name);

private grpc_client: any = null;
private http_client: any = null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only one of (http_client or grpc_client) is being used at a time, so you may want to make a single client var to simplify.

private type: LightningType;

constructor(
private configService: ConfigService,
private lndService: LndService,
private clnService: ClnService,
private lnbitsService: LnbitsService,
) {}

public async onModuleInit() {
this.type = this.configService.get('lightning.type');
this.initializeGrpcClients();
this.initializeClients();
}

private initializeGrpcClients() {
private initializeClients() {
if (this.type === 'lnd') this.grpc_client = this.lndService.initializeLightningClient();
if (this.type === 'cln') this.grpc_client = this.clnService.initializeLightningClient();
if (this.type === 'lnbits') this.http_client = this.lnbitsService.initializeLightningClient();
}

private makeGrpcRequest(method: string, request: any): Promise<any> {
Expand All @@ -49,6 +53,7 @@ export class LightningService implements OnModuleInit {
async getLightningInfo(): Promise<LightningInfo> {
if (this.type === 'lnd') return this.makeGrpcRequest('GetInfo', {});
if (this.type === 'cln') return this.clnService.mapClnInfo(await this.makeGrpcRequest('Getinfo', {}));
if (this.type === 'lnbits') return this.lnbitsService.mapLnbitsInfo(await this.lnbitsService.getLnbitsInfo());
}

async getLightningChannelBalance(): Promise<LightningChannelBalance> {
Expand All @@ -59,10 +64,12 @@ export class LightningService implements OnModuleInit {
await this.makeGrpcRequest('ListPeerChannels', {}),
);
}
if (this.type === 'lnbits') return this.lnbitsService.mapLnbitsChannelBalance(await this.lnbitsService.getLnbitsBalance());
}

async getLightningRequest(request: string): Promise<LightningRequest> {
if (this.type === 'lnd') return this.lndService.mapLndRequest(await this.makeGrpcRequest('DecodePayReq', {pay_req: request}));
if (this.type === 'cln') return this.clnService.mapClnRequest(await this.makeGrpcRequest('Decode', {string: request}));
if (this.type === 'lnbits') return this.lnbitsService.mapLnbitsRequest(await this.lnbitsService.decodeLnbitsInvoice(request));
}
}
28 changes: 28 additions & 0 deletions src/server/modules/lightning/lnbits/lnbits.helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/* Application Dependencies */
import {LightningRequestType} from '@server/modules/lightning/lightning.enums';

export function mapRequestDescription(description: string | null): string | null {
if (!description) return null;
if (description === '') return null;
return description;
}

export function mapRequestExpiry(request: any): number | null {
if (request?.expires_at) return Number(request.expires_at);
if (request?.expiry) return Number(request.expiry);
return null;
}

export function mapRequestType(type?: string): LightningRequestType {
return LightningRequestType.BOLT11_INVOICE;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function always returns BOLT11_INVOICE so why do you have type in the function?

}

export function mapLnbitsError(error: any): string {
if (error?.detail) {
if (Array.isArray(error.detail)) {
return error.detail.map((d: any) => d.msg || d).join(', ');
}
return error.detail;
}
return error?.message || 'Unknown LNbits error';
}
14 changes: 14 additions & 0 deletions src/server/modules/lightning/lnbits/lnbits.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/* Core Dependencies */
import {Module} from '@nestjs/common';
/* Application Dependencies */
import {CredentialModule} from '@server/modules/credential/credential.module';
import {FetchModule} from '@server/modules/fetch/fetch.module';
/* Local Dependencies */
import {LnbitsService} from './lnbits.service';

@Module({
imports: [CredentialModule, FetchModule],
providers: [LnbitsService],
exports: [LnbitsService],
})
export class LnbitsModule {}
116 changes: 116 additions & 0 deletions src/server/modules/lightning/lnbits/lnbits.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/* Core Dependencies */
import {Test, TestingModule} from '@nestjs/testing';
import {expect} from '@jest/globals';
import {ConfigService} from '@nestjs/config';
/* Application Dependencies */
import {CredentialService} from '@server/modules/credential/credential.service';
import {FetchService} from '@server/modules/fetch/fetch.service';
/* Local Dependencies */
import {LnbitsService} from './lnbits.service';

describe('LnbitsService', () => {
let lnbits_service: LnbitsService;
let config_service: jest.Mocked<ConfigService>;
let credential_service: jest.Mocked<CredentialService>;
let fetch_service: jest.Mocked<FetchService>;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
LnbitsService,
{provide: ConfigService, useValue: {get: jest.fn()}},
{provide: CredentialService, useValue: {loadPemOrPath: jest.fn()}},
{provide: FetchService, useValue: {fetchWithProxy: jest.fn()}},
],
}).compile();

lnbits_service = module.get<LnbitsService>(LnbitsService);
config_service = module.get(ConfigService);
credential_service = module.get(CredentialService);
fetch_service = module.get(FetchService);
});

it('should be defined', () => {
expect(lnbits_service).toBeDefined();
});

describe('initializeLightningClient', () => {
it('should initialize successfully with valid credentials', () => {
config_service.get.mockReturnValueOnce('http://localhost:5000'); // api_url
config_service.get.mockReturnValueOnce('test_api_key'); // api_key
credential_service.loadPemOrPath.mockReturnValue(Buffer.from('test_api_key'));

const client = lnbits_service.initializeLightningClient();
expect(client).toBe(lnbits_service);
});

it('should throw error with missing credentials', () => {
config_service.get.mockReturnValue(null);

expect(() => lnbits_service.initializeLightningClient()).toThrow('Failed to initialize LNbits client');
});
});

describe('mapLnbitsRequest', () => {
it('should map LNbits request correctly', () => {
const lnbits_request = {
type: 'bolt11',
valid: true,
description: 'Test payment',
expires_at: 1234567890,
};

const result = lnbits_service.mapLnbitsRequest(lnbits_request);

expect(result.valid).toBe(true);
expect(result.description).toBe('Test payment');
expect(result.expiry).toBe(1234567890);
});
});

describe('mapLnbitsInfo', () => {
it('should map LNbits info correctly', async () => {
const lnbits_info = {
version: '1.0.0',
identity_pubkey: 'test_pubkey',
alias: 'Test Node',
block_height: 800000,
testnet: false,
};

const result = await lnbits_service.mapLnbitsInfo(lnbits_info);

expect(result.version).toBe('1.0.0');
expect(result.identity_pubkey).toBe('test_pubkey');
expect(result.alias).toBe('Test Node');
expect(result.block_height).toBe(800000);
expect(result.testnet).toBe(false);
expect(result.num_active_channels).toBe(0); // LNbits no maneja canales
});
});

describe('mapLnbitsChannelBalance', () => {
it('should map LNbits balance correctly', async () => {
const balance = 100000; // 100k sats

const result = await lnbits_service.mapLnbitsChannelBalance(balance);

expect(result.balance).toBe('100000');
expect(result.local_balance.sat).toBe('100000');
expect(result.local_balance.msat).toBe('100000000');
expect(result.remote_balance.sat).toBe('0');
});
});

describe('mapLnbitsAddresses', () => {
it('should map bitcoin address correctly', async () => {
const address = 'bc1qtest123456789abcdef';

const result = await lnbits_service.mapLnbitsAddresses(address);

expect(result.account_with_addresses).toHaveLength(1);
expect(result.account_with_addresses[0].addresses[0].address).toBe(address);
expect(result.account_with_addresses[0].name).toBe('LNbits Onchain');
});
});
});
Loading
Loading