diff --git a/modules/abstract-lightning/src/codecs/api/payment.ts b/modules/abstract-lightning/src/codecs/api/payment.ts index a540518b36..300d5ccd29 100644 --- a/modules/abstract-lightning/src/codecs/api/payment.ts +++ b/modules/abstract-lightning/src/codecs/api/payment.ts @@ -88,7 +88,7 @@ export type PaymentQuery = t.TypeOf; export const SubmitPaymentParams = t.intersection([ LightningPaymentRequest, - t.type({ + t.partial({ sequenceId: optionalString, comment: optionalString, }), diff --git a/modules/abstract-lightning/src/wallet/lightning.ts b/modules/abstract-lightning/src/wallet/lightning.ts index 1f0544cb39..11f8e83647 100644 --- a/modules/abstract-lightning/src/wallet/lightning.ts +++ b/modules/abstract-lightning/src/wallet/lightning.ts @@ -104,7 +104,7 @@ export class SelfCustodialLightningWallet implements ILightningWallet { async createInvoice(params: CreateInvoiceBody): Promise { 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. diff --git a/modules/express/package.json b/modules/express/package.json index cf3b4ff612..4ba8bc49cc 100644 --- a/modules/express/package.json +++ b/modules/express/package.json @@ -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", @@ -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": { diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index 93315b26d1..958af405ab 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -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'); @@ -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) + ); } diff --git a/modules/express/src/lightning/lightningInvoiceRoutes.ts b/modules/express/src/lightning/lightningInvoiceRoutes.ts new file mode 100644 index 0000000000..b440cc9816 --- /dev/null +++ b/modules/express/src/lightning/lightningInvoiceRoutes.ts @@ -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 { + 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 { + 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); + } +} diff --git a/modules/express/test/unit/lightning/lightningInvoiceRoutes.test.ts b/modules/express/test/unit/lightning/lightningInvoiceRoutes.test.ts new file mode 100644 index 0000000000..b8266fddf5 --- /dev/null +++ b/modules/express/test/unit/lightning/lightningInvoiceRoutes.test.ts @@ -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 = {}; + 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/); + }); + }); +}); diff --git a/modules/sdk-coin-lnbtc/src/index.ts b/modules/sdk-coin-lnbtc/src/index.ts index c29a33077c..73e029dee1 100644 --- a/modules/sdk-coin-lnbtc/src/index.ts +++ b/modules/sdk-coin-lnbtc/src/index.ts @@ -1,2 +1,3 @@ export * from './lnbtc'; export * from './tlnbtc'; +export * from './register'; diff --git a/modules/sdk-coin-lnbtc/src/register.ts b/modules/sdk-coin-lnbtc/src/register.ts new file mode 100644 index 0000000000..df19023d6d --- /dev/null +++ b/modules/sdk-coin-lnbtc/src/register.ts @@ -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); +}; diff --git a/yarn.lock b/yarn.lock index 1e7fe864ac..adac9e7e4e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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/qrcode@1.5.0": version "1.5.0" resolved "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.0.tgz#6a98fe9a9a7b2a9a3167b6dde17eff999eabe40b" @@ -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" @@ -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" @@ -14234,7 +14252,7 @@ merge-descriptors@1.0.1: resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== -merge-descriptors@1.0.3: +merge-descriptors@1.0.3, 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== @@ -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" @@ -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" @@ -17417,7 +17449,7 @@ resolve@1.1.7: 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==