Skip to content

Commit

Permalink
feat: add express endpoints for lightning invoice
Browse files Browse the repository at this point in the history
Add
- create invoice
- pay invoice

Ticket: BTC-1836

TICKET: BTC-1836
  • Loading branch information
lcovar committed Feb 26, 2025
1 parent 112da17 commit 5c0492e
Show file tree
Hide file tree
Showing 7 changed files with 252 additions and 2 deletions.
2 changes: 1 addition & 1 deletion modules/abstract-lightning/src/codecs/api/payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export type PaymentQuery = t.TypeOf<typeof PaymentQuery>;

export const SubmitPaymentParams = t.intersection([
LightningPaymentRequest,
t.type({
t.partial({
sequenceId: optionalString,
comment: optionalString,
}),
Expand Down
2 changes: 1 addition & 1 deletion modules/abstract-lightning/src/wallet/lightning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export class SelfCustodialLightningWallet implements ILightningWallet {
async createInvoice(params: CreateInvoiceBody): Promise<Invoice> {
const createInvoiceResponse = await this.wallet.bitgo
.post(this.wallet.baseCoin.url(`/wallet/${this.wallet.id()}/lightning/invoice`))
.send(CreateInvoiceBody.encode(params))
.send(t.exact(CreateInvoiceBody).encode(params))
.result();
return sdkcore.decodeOrElse(Invoice.name, Invoice, createInvoiceResponse, (error) => {
// DON'T throw errors from decodeOrElse. It could leak sensitive information.
Expand Down
13 changes: 13 additions & 0 deletions modules/express/src/clientRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
handleInitLightningWallet,
handleUnlockLightningWallet,
} from './lightning/lightningSignerRoutes';
import { handleCreateLightningInvoice, handlePayLightningInvoice } from './lightning/lightningInvoiceRoutes';
import { ProxyAgent } from 'proxy-agent';

const { version } = require('bitgo/package.json');
Expand Down Expand Up @@ -1704,4 +1705,16 @@ export function setupLightningRoutes(app: express.Application, config: Config):
promiseWrapper(handleUnlockLightningWallet)
);
app.get('/api/v2/:coin/wallet/:id/state', prepareBitGo(config), promiseWrapper(handleGetLightningWalletState));
app.post(
'/api/v2/:coin/wallet/:id/lightning/invoice',
parseBody,
prepareBitGo(config),
promiseWrapper(handleCreateLightningInvoice)
);
app.post(
'/api/v2/:coin/wallet/:id/lightning/pay',
parseBody,
prepareBitGo(config),
promiseWrapper(handlePayLightningInvoice)
);
}
44 changes: 44 additions & 0 deletions modules/express/src/lightning/lightningInvoiceRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import * as express from 'express';
import { ApiResponseError } from '../errors';
import { CreateInvoiceBody, getLightningWallet, SubmitPaymentParams } from '@bitgo/abstract-lightning';
import { decodeOrElse } from '@bitgo/sdk-core';

export async function handleCreateLightningInvoice(req: express.Request): Promise<any> {
const bitgo = req.bitgo;

try {
const params = decodeOrElse(CreateInvoiceBody.name, CreateInvoiceBody, req.body, (error) => {
throw new ApiResponseError(`Invalid request body to create lightning invoice: ${error}`, 400);
});

const coin = bitgo.coin(req.params.coin);
const wallet = await coin.wallets().get({ id: req.params.id });
const lightningWallet = getLightningWallet(wallet);

return await lightningWallet.createInvoice(params);
} catch (err) {
throw new ApiResponseError(err.message, 400);
}
}

export async function handlePayLightningInvoice(req: express.Request): Promise<any> {
const bitgo = req.bitgo;
const { passphrase } = req.body;
if (passphrase === undefined) {
throw new ApiResponseError('Missing wallet passphrase', 400);
}

try {
const params = decodeOrElse(SubmitPaymentParams.name, SubmitPaymentParams, req.body, (error) => {
throw new ApiResponseError(`Invalid request body to pay lightning invoice: ${error}`, 400);
});

const coin = bitgo.coin(req.params.coin);
const wallet = await coin.wallets().get({ id: req.params.id });
const lightningWallet = getLightningWallet(wallet);

return await lightningWallet.payInvoice(params, passphrase);
} catch (err) {
throw new ApiResponseError(err.message, 400);
}
}
184 changes: 184 additions & 0 deletions modules/express/test/unit/lightning/lightningInvoiceRoutes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import * as sinon from 'sinon';
import * as should from 'should';
import * as express from 'express';
import { handleCreateLightningInvoice, handlePayLightningInvoice } from '../../../src/lightning/lightningInvoiceRoutes';
import { PayInvoiceResponse } from '@bitgo/abstract-lightning';
import { BitGo } from 'bitgo';

describe('Lightning Invoice Routes', () => {
let bitgo;
const coin = 'tlnbtc';

const mockRequestObject = (params: { body?: any; params?: any; query?: any; bitgo?: any }) => {
const req: Partial<express.Request> = {};
req.body = params.body || {};
req.params = params.params || {};
req.query = params.query || {};
req.bitgo = params.bitgo;
return req as express.Request;
};

afterEach(() => {
sinon.restore();
});

describe('Create Lightning Invoice', () => {
it('should successfully create a lightning invoice', async () => {
const inputParams = {
valueMsat: '10000',
memo: 'test invoice',
expiry: 3600,
};

const expectedResponse = {
value: 10000,
memo: 'test invoice',
paymentHash: 'abc123',
invoice: 'lntb100u1p3h2jk3pp5yndyvx4zmv...',
walletId: 'testWalletId',
status: 'open',
expiresAt: '2025-02-21T10:00:00.000Z',
};

const createInvoiceSpy = sinon.stub().resolves(expectedResponse);
const mockLightningWallet = {
createInvoice: createInvoiceSpy,
};

// Mock the module import
const proxyquire = require('proxyquire');
const lightningRoutes = proxyquire('../../../src/lightning/lightningInvoiceRoutes', {
'@bitgo/abstract-lightning': {
getLightningWallet: () => mockLightningWallet,
},
});

const walletStub = {};
const coinStub = {
wallets: () => ({ get: sinon.stub().resolves(walletStub) }),
};

const stubBitgo = sinon.createStubInstance(BitGo as any, { coin: coinStub });

const req = mockRequestObject({
params: { id: 'testWalletId', coin },
body: inputParams,
bitgo: stubBitgo,
});

const result = await lightningRoutes.handleCreateLightningInvoice(req);

should(result).deepEqual(expectedResponse);
should(createInvoiceSpy).be.calledOnce();
const [firstArg] = createInvoiceSpy.getCall(0).args;

should(firstArg).have.property('valueMsat', BigInt(10000));
should(firstArg).have.property('memo', 'test invoice');
should(firstArg).have.property('expiry', 3600);
});

it('should fail when valueMsat is missing from request', async () => {
const inputParams = {
memo: 'test invoice',
expiry: 3600,
};

const req = mockRequestObject({
params: { id: 'testWalletId', coin },
body: inputParams,
});
req.bitgo = bitgo;

await should(handleCreateLightningInvoice(req)).be.rejectedWith(
/^Invalid request body to create lightning invoice/
);
});
});

describe('Pay Lightning Invoice', () => {
it('should successfully pay a lightning invoice', async () => {
const inputParams = {
invoice: 'lntb100u1p3h2jk3pp5yndyvx4zmv...',
amountMsat: '10000',
passphrase: 'wallet-password-12345',
randomParamThatWontBreakDecoding: 'randomValue',
};

const expectedResponse: PayInvoiceResponse = {
paymentStatus: {
paymentHash: 'xyz789',
status: 'settled',
},
txRequestState: 'delivered',
txRequestId: '123',
};

const payInvoiceStub = sinon.stub().resolves(expectedResponse);
const mockLightningWallet = {
payInvoice: payInvoiceStub,
};

// Mock the module import
const proxyquire = require('proxyquire');
const lightningRoutes = proxyquire('../../../src/lightning/lightningInvoiceRoutes', {
'@bitgo/abstract-lightning': {
getLightningWallet: () => mockLightningWallet,
},
});

const walletStub = {};
const coinStub = {
wallets: () => ({ get: sinon.stub().resolves(walletStub) }),
};
const stubBitgo = sinon.createStubInstance(BitGo as any, { coin: coinStub });

const req = mockRequestObject({
params: { id: 'testWalletId', coin },
body: inputParams,
bitgo: stubBitgo,
});
req.bitgo = bitgo;

const result = await lightningRoutes.handlePayLightningInvoice(req);

should(result).deepEqual(expectedResponse);
should(payInvoiceStub).be.calledOnce();
const [firstArg, secondArg] = payInvoiceStub.getCall(0).args;

// we decode the amountMsat string to bigint, it should be in bigint format when passed to payInvoice
should(firstArg).have.property('amountMsat', BigInt(10000));
should(firstArg).have.property('invoice', inputParams.invoice);
should(secondArg).equal('wallet-password-12345');
});

it('should throw an error if the passphrase is missing in the request params', async () => {
const inputParams = {
invoice: 'lntb100u1p3h2jk3pp5yndyvx4zmv...',
amountMsat: '10000',
};

const req = mockRequestObject({
params: { id: 'testWalletId', coin },
body: inputParams,
});
req.bitgo = bitgo;

await should(handlePayLightningInvoice(req)).be.rejectedWith('Missing wallet passphrase');
});

it('should throw an error if the invoice is missing in the request params', async () => {
const inputParams = {
amountMsat: '10000',
passphrase: 'wallet-password-12345',
};

const req = mockRequestObject({
params: { id: 'testWalletId', coin },
body: inputParams,
});
req.bitgo = bitgo;

await should(handlePayLightningInvoice(req)).be.rejectedWith(/^Invalid request body to pay lightning invoice/);
});
});
});
1 change: 1 addition & 0 deletions modules/sdk-coin-lnbtc/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './lnbtc';
export * from './tlnbtc';
export * from './register';
8 changes: 8 additions & 0 deletions modules/sdk-coin-lnbtc/src/register.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { BitGoBase } from '@bitgo/sdk-core';
import { Lnbtc } from './lnbtc';
import { Tlnbtc } from './tlnbtc';

export const register = (sdk: BitGoBase): void => {
sdk.register('lnbtc', Lnbtc.createInstance);
sdk.register('tlnbtc', Tlnbtc.createInstance);
};

0 comments on commit 5c0492e

Please sign in to comment.