Skip to content

Commit

Permalink
Merge pull request #5592 from BitGo/BTC-1836
Browse files Browse the repository at this point in the history
eat: add express endpoints for lightning invoice
  • Loading branch information
lcovar authored Feb 26, 2025
2 parents 48aebc7 + 563c250 commit afb5a59
Show file tree
Hide file tree
Showing 9 changed files with 287 additions and 4 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
2 changes: 2 additions & 0 deletions modules/express/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@bitgo/abstract-lightning": "^2.0.0",
"@bitgo/sdk-core": "^29.0.0",
"@bitgo/utxo-lib": "^11.2.2",
"@types/proxyquire": "^1.3.31",
"argparse": "^1.0.10",
"bitgo": "^40.0.0",
"bluebird": "^3.5.3",
Expand All @@ -52,6 +53,7 @@
"lodash": "^4.17.20",
"morgan": "^1.9.1",
"proxy-agent": "6.4.0",
"proxyquire": "^2.1.3",
"superagent": "^9.0.1"
},
"devDependencies": {
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);
}
}
183 changes: 183 additions & 0 deletions modules/express/test/unit/lightning/lightningInvoiceRoutes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
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,
});

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);
};
36 changes: 34 additions & 2 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5497,6 +5497,11 @@
resolved "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz#1433419d73b2a7ebfc6918dcefd2ec0d5cd698f2"
integrity sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==

"@types/proxyquire@^1.3.31":
version "1.3.31"
resolved "https://registry.npmjs.org/@types/proxyquire/-/proxyquire-1.3.31.tgz#a008b78dad6061754e3adf2cb64b60303f68deaa"
integrity sha512-uALowNG2TSM1HNPMMOR0AJwv4aPYPhqB0xlEhkeRTMuto5hjoSPZkvgu1nbPUkz3gEPAHv4sy4DmKsurZiEfRQ==

"@types/[email protected]":
version "1.5.0"
resolved "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.0.tgz#6a98fe9a9a7b2a9a3167b6dde17eff999eabe40b"
Expand Down Expand Up @@ -10956,6 +10961,14 @@ filelist@^1.0.4:
dependencies:
minimatch "^5.0.1"

fill-keys@^1.0.2:
version "1.0.2"
resolved "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz#9a8fa36f4e8ad634e3bf6b4f3c8882551452eb20"
integrity sha512-tcgI872xXjwFF4xgQmLxi76GnwJG3g/3isB1l4/G5Z4zrbddGpBjqZCO9oEAcB5wX0Hj/5iQB3toxfO7in1hHA==
dependencies:
is-object "~1.0.1"
merge-descriptors "~1.0.0"

fill-range@^7.1.1:
version "7.1.1"
resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
Expand Down Expand Up @@ -12703,6 +12716,11 @@ is-obj@^2.0.0:
resolved "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982"
integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==

is-object@~1.0.1:
version "1.0.2"
resolved "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz#a56552e1c665c9e950b4a025461da87e72f86fcf"
integrity sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==

is-path-cwd@^2.0.0:
version "2.2.0"
resolved "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz#67d43b82664a7b5191fd9119127eb300048a9fdb"
Expand Down Expand Up @@ -14234,7 +14252,7 @@ [email protected]:
resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==

[email protected]:
[email protected], merge-descriptors@~1.0.0:
version "1.0.3"
resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5"
integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==
Expand Down Expand Up @@ -14626,6 +14644,11 @@ module-deps@^4.0.8:
through2 "^2.0.0"
xtend "^4.0.0"

module-not-found-error@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz#cf8b4ff4f29640674d6cdd02b0e3bc523c2bbdc0"
integrity sha512-pEk4ECWQXV6z2zjhRZUongnLJNUeGQJ3w6OQ5ctGwD+i5o93qjRQUk2Rt6VdNeu3sEP0AB4LcfvdebpxBRVr4g==

monocle-ts@^2.3.13:
version "2.3.13"
resolved "https://registry.npmjs.org/monocle-ts/-/monocle-ts-2.3.13.tgz#7c8af489613cd5175df2ea3c02c57c6151995e5d"
Expand Down Expand Up @@ -16681,6 +16704,15 @@ proxy-from-env@^1.0.0, proxy-from-env@^1.1.0:
resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==

proxyquire@^2.1.3:
version "2.1.3"
resolved "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.3.tgz#2049a7eefa10a9a953346a18e54aab2b4268df39"
integrity sha512-BQWfCqYM+QINd+yawJz23tbBM40VIGXOdDw3X344KcclI/gtBbdWF6SlQ4nK/bYhF9d27KYug9WzljHC6B9Ysg==
dependencies:
fill-keys "^1.0.2"
module-not-found-error "^1.0.1"
resolve "^1.11.1"

psl@^1.1.28:
version "1.15.0"
resolved "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz#bdace31896f1d97cec6a79e8224898ce93d974c6"
Expand Down Expand Up @@ -17417,7 +17449,7 @@ [email protected]:
resolved "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b"
integrity sha512-9znBF0vBcaSN3W2j7wKvdERPwqTxSpCq+if5C0WoTCyV9n24rua28jeuQ2pL/HOf+yUe/Mef+H/5p60K0Id3bg==

resolve@^1.1.3, resolve@^1.1.4, resolve@^1.1.6, resolve@^1.10.0, resolve@^1.14.2, resolve@^1.17.0, resolve@^1.22.3, resolve@^1.22.4, resolve@^1.9.0, resolve@~1.22.6:
resolve@^1.1.3, resolve@^1.1.4, resolve@^1.1.6, resolve@^1.10.0, resolve@^1.11.1, resolve@^1.14.2, resolve@^1.17.0, resolve@^1.22.3, resolve@^1.22.4, resolve@^1.9.0, resolve@~1.22.6:
version "1.22.10"
resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39"
integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==
Expand Down

0 comments on commit afb5a59

Please sign in to comment.