diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index 958af405ab..c966ea0b96 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -54,6 +54,10 @@ import { handleUnlockLightningWallet, } from './lightning/lightningSignerRoutes'; import { handleCreateLightningInvoice, handlePayLightningInvoice } from './lightning/lightningInvoiceRoutes'; +import { + handleListLightningInvoices, + handleUpdateLightningWalletCoinSpecific, +} from './lightning/lightningWalletRoutes'; import { ProxyAgent } from 'proxy-agent'; const { version } = require('bitgo/package.json'); @@ -1717,4 +1721,15 @@ export function setupLightningRoutes(app: express.Application, config: Config): prepareBitGo(config), promiseWrapper(handlePayLightningInvoice) ); + app.get( + '/api/v2/:coin/wallet/:id/lightning/invoices', + prepareBitGo(config), + promiseWrapper(handleListLightningInvoices) + ); + app.put( + '/api/v2/:coin/wallet/:id/coinspecific', + parseBody, + prepareBitGo(config), + promiseWrapper(handleUpdateLightningWalletCoinSpecific) + ); } diff --git a/modules/express/src/lightning/lightningWalletRoutes.ts b/modules/express/src/lightning/lightningWalletRoutes.ts new file mode 100644 index 0000000000..dbd2ac33c4 --- /dev/null +++ b/modules/express/src/lightning/lightningWalletRoutes.ts @@ -0,0 +1,41 @@ +import * as express from 'express'; +import { ApiResponseError } from '../errors'; +import { getLightningWallet, InvoiceQuery, UpdateLightningWalletSignedRequest } from '@bitgo/abstract-lightning'; +import { decodeOrElse } from '@bitgo/sdk-core'; +import * as t from 'io-ts'; + +const UpdateWalletRequest = t.intersection([ + UpdateLightningWalletSignedRequest, + t.type({ + passphrase: t.string, + }), +]); + +export async function handleListLightningInvoices(req: express.Request): Promise { + const bitgo = req.bitgo; + + const params = decodeOrElse(InvoiceQuery.name, InvoiceQuery, req.query, (error) => { + throw new ApiResponseError(`Invalid query parameters for listing lightning invoices: ${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.listInvoices(params); +} + +export async function handleUpdateLightningWalletCoinSpecific(req: express.Request): Promise { + const bitgo = req.bitgo; + + const { passphrase, ...params } = decodeOrElse('UpdateWalletRequest', UpdateWalletRequest, req.body, (_) => { + // DON'T throw errors from decodeOrElse. It could leak sensitive information. + throw new ApiResponseError('Invalid request body to update lightning wallet coin specific', 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.updateWalletCoinSpecific(params, passphrase); +} diff --git a/modules/express/test/unit/lightning/lightningWalletRoutes.test.ts b/modules/express/test/unit/lightning/lightningWalletRoutes.test.ts new file mode 100644 index 0000000000..dae43425b4 --- /dev/null +++ b/modules/express/test/unit/lightning/lightningWalletRoutes.test.ts @@ -0,0 +1,186 @@ +import * as sinon from 'sinon'; +import * as should from 'should'; +import * as express from 'express'; +import { + handleListLightningInvoices, + handleUpdateLightningWalletCoinSpecific, +} from '../../../src/lightning/lightningWalletRoutes'; +import { BitGo } from 'bitgo'; +import { InvoiceInfo } from '@bitgo/abstract-lightning'; + +describe('Lightning Wallet Routes', () => { + let bitgo; + const coin = 'tlnbtc'; + + const mockRequestObject = (params: { body?: any; params?: any; query?: any; bitgo?: any }) => { + const req: Partial = {}; + 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('List Lightning Invoices', () => { + it('should successfully list lightning invoices', async () => { + const queryParams = { + status: 'open', + limit: '10', + startDate: '2024-01-01', + endDate: '2024-01-31', + }; + + const mockResponse: InvoiceInfo[] = [ + { + invoice: 'lntb100u1p3h2jk3pp5...', + paymentHash: 'abc123', + valueMsat: 10000n, + walletId: 'testWalletId', + status: 'open', + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + const listInvoicesSpy = sinon.stub().resolves(mockResponse); + const mockLightningWallet = { + listInvoices: listInvoicesSpy, + }; + + const proxyquire = require('proxyquire'); + const lightningRoutes = proxyquire('../../../src/lightning/lightningWalletRoutes', { + '@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 }, + query: queryParams, + bitgo: stubBitgo, + }); + + const result = await lightningRoutes.handleListLightningInvoices(req); + + should(result).deepEqual(mockResponse); + should(listInvoicesSpy).be.calledOnce(); + const [firstArg] = listInvoicesSpy.getCall(0).args; + + should(firstArg).have.property('status', 'open'); + should(firstArg).have.property('limit', 10n); + should(firstArg.startDate).be.instanceOf(Date); + should(firstArg.endDate).be.instanceOf(Date); + }); + + it('should handle invalid query parameters', async () => { + const invalidQuery = { + status: 'invalidStatus', + limit: 'notANumber', + }; + + const req = mockRequestObject({ + params: { id: 'testWalletId', coin }, + query: invalidQuery, + }); + req.bitgo = bitgo; + + await should(handleListLightningInvoices(req)).be.rejectedWith( + /Invalid query parameters for listing lightning invoices: Invalid value '"invalidStatus"' supplied to InvoiceQuery\.status\.0, expected "open"/ + ); + }); + }); + + describe('Update Wallet Coin Specific', () => { + it('should successfully update wallet coin specific data', async () => { + const inputParams = { + encryptedSignerMacaroon: 'encrypted-macaroon-data', + signerHost: 'signer.example.com', + passphrase: 'wallet-password-123', + }; + + const expectedResponse = { + coinSpecific: { + updated: true, + }, + }; + + const updateStub = sinon.stub().resolves(expectedResponse); + const mockLightningWallet = { + updateWalletCoinSpecific: updateStub, + }; + + const proxyquire = require('proxyquire'); + const lightningRoutes = proxyquire('../../../src/lightning/lightningWalletRoutes', { + '@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.handleUpdateLightningWalletCoinSpecific(req); + + should(result).deepEqual(expectedResponse); + should(updateStub).be.calledOnce(); + const [firstArg, secondArg] = updateStub.getCall(0).args; + should(firstArg).have.property('encryptedSignerMacaroon', 'encrypted-macaroon-data'); + should(firstArg).have.property('signerHost', 'signer.example.com'); + should(secondArg).equal('wallet-password-123'); + }); + + it('should throw error when passphrase is missing', async () => { + const invalidParams = { + encryptedSignerMacaroon: 'encrypted-data', + signerHost: 'signer.example.com', + }; + + const req = mockRequestObject({ + params: { id: 'testWalletId', coin }, + body: invalidParams, + }); + req.bitgo = bitgo; + + await should(handleUpdateLightningWalletCoinSpecific(req)).be.rejectedWith( + 'Invalid request body to update lightning wallet coin specific' + ); + }); + + it('should handle invalid request body', async () => { + const invalidParams = { + signerHost: 12345, // invalid type + passphrase: 'valid-pass', + }; + + const req = mockRequestObject({ + params: { id: 'testWalletId', coin }, + body: invalidParams, + }); + req.bitgo = bitgo; + + await should(handleUpdateLightningWalletCoinSpecific(req)).be.rejectedWith( + 'Invalid request body to update lightning wallet coin specific' + ); + }); + }); +});