From 395592d5762a3d57b6b4c621eeec5c944879b5a9 Mon Sep 17 00:00:00 2001 From: Daniel Zhao Date: Thu, 11 Sep 2025 16:46:54 -0400 Subject: [PATCH 1/8] feat(express): migrate lightningSignerMacaroon to typed routes TICKET: WP-5445 --- modules/express/src/clientRoutes.ts | 22 +++++----- .../src/lightning/lightningSignerRoutes.ts | 24 +++-------- modules/express/src/typedRoutes/api/index.ts | 4 ++ .../src/typedRoutes/api/v2/signerMacaroon.ts | 43 +++++++++++++++++++ .../lightning/lightningSignerRoutes.ts | 9 +++- .../express/test/unit/typedRoutes/decode.ts | 13 ++++++ 6 files changed, 86 insertions(+), 29 deletions(-) create mode 100644 modules/express/src/typedRoutes/api/v2/signerMacaroon.ts diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index f6433eb73b..6a24663878 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -1550,11 +1550,6 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { router.post('express.decrypt', [prepareBitGo(config), typedPromiseWrapper(handleDecrypt)]); router.post('express.encrypt', [prepareBitGo(config), typedPromiseWrapper(handleEncrypt)]); router.post('express.verifyaddress', [prepareBitGo(config), typedPromiseWrapper(handleVerifyAddress)]); - router.post('express.lightning.initWallet', [prepareBitGo(config), typedPromiseWrapper(handleInitLightningWallet)]); - router.post('express.lightning.unlockWallet', [ - prepareBitGo(config), - typedPromiseWrapper(handleUnlockLightningWallet), - ]); router.post('express.calculateminerfeeinfo', [ prepareBitGo(config), typedPromiseWrapper(handleCalculateMinerFeeInfo), @@ -1577,7 +1572,6 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { ); router.post('express.v1.wallet.signTransaction', [prepareBitGo(config), typedPromiseWrapper(handleSignTransaction)]); - router.get('express.lightning.getState', [prepareBitGo(config), typedPromiseWrapper(handleGetLightningWalletState)]); app.post('/api/v1/wallet/:id/simpleshare', parseBody, prepareBitGo(config), promiseWrapper(handleShareWallet)); router.post('express.v1.wallet.acceptShare', [prepareBitGo(config), typedPromiseWrapper(handleAcceptShare)]); @@ -1764,10 +1758,16 @@ export function setupEnclavedExpressRoutes(app: express.Application, config: Con } export function setupLightningSignerNodeRoutes(app: express.Application, config: Config): void { - app.post( - '/api/v2/:coin/wallet/:id/signermacaroon', - parseBody, + const router = createExpressRouter(); + app.use(router); + router.post('express.lightning.initWallet', [prepareBitGo(config), typedPromiseWrapper(handleInitLightningWallet)]); + router.post('express.lightning.signerMacaroon', [ prepareBitGo(config), - promiseWrapper(handleCreateSignerMacaroon) - ); + typedPromiseWrapper(handleCreateSignerMacaroon), + ]); + router.post('express.lightning.unlockWallet', [ + prepareBitGo(config), + typedPromiseWrapper(handleUnlockLightningWallet), + ]); + router.get('express.lightning.getState', [prepareBitGo(config), typedPromiseWrapper(handleGetLightningWalletState)]); } diff --git a/modules/express/src/lightning/lightningSignerRoutes.ts b/modules/express/src/lightning/lightningSignerRoutes.ts index 8ca7c6ac07..e5dbe97fc2 100644 --- a/modules/express/src/lightning/lightningSignerRoutes.ts +++ b/modules/express/src/lightning/lightningSignerRoutes.ts @@ -1,6 +1,4 @@ import { isIP } from 'net'; -import * as express from 'express'; -import { decodeOrElse } from '@bitgo/sdk-core'; import { getUtxolibNetwork, signerMacaroonPermissions, @@ -13,11 +11,11 @@ import { } from '@bitgo/abstract-lightning'; import * as utxolib from '@bitgo/utxo-lib'; import { Buffer } from 'buffer'; -import { ExpressApiRouteRequest } from '../typedRoutes/api'; -import { CreateSignerMacaroonRequest, GetWalletStateResponse } from './codecs'; +import { GetWalletStateResponse } from './codecs'; import { LndSignerClient } from './lndSignerClient'; import { ApiResponseError } from '../errors'; +import type { ExpressApiRouteRequest } from '../typedRoutes/api'; type Decrypt = (params: { input: string; password: string }) => string; @@ -106,28 +104,20 @@ export async function handleInitLightningWallet( /** * Handle the request to create a signer macaroon from remote signer LND for a wallet. */ -export async function handleCreateSignerMacaroon(req: express.Request): Promise { +export async function handleCreateSignerMacaroon( + req: ExpressApiRouteRequest<'express.lightning.signerMacaroon', 'post'> +): Promise { const bitgo = req.bitgo; - const coinName = req.params.coin; + const { walletId, passphrase, addIpCaveatToMacaroon } = req.decoded; + const coinName = req.decoded.coin; if (!isLightningCoinName(coinName)) { throw new ApiResponseError(`Invalid coin to create signer macaroon: ${coinName}. Must be a lightning coin.`, 400); } const coin = bitgo.coin(coinName); - const walletId = req.params.id; if (typeof walletId !== 'string') { throw new ApiResponseError(`Invalid wallet id: ${walletId}`, 400); } - const { passphrase, addIpCaveatToMacaroon } = decodeOrElse( - CreateSignerMacaroonRequest.name, - CreateSignerMacaroonRequest, - req.body, - (_) => { - // DON'T throw errors from decodeOrElse. It could leak sensitive information. - throw new ApiResponseError('Invalid request body to create signer macaroon', 400); - } - ); - const wallet = await coin.wallets().get({ id: walletId, includeBalance: false }); if (wallet.subType() !== 'lightningSelfCustody') { throw new ApiResponseError(`not a self custodial lighting wallet ${walletId}`, 400); diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index b4db56f42d..b9fe9a8d41 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -26,6 +26,7 @@ import { PostCreateAddress } from './v2/createAddress'; import { PutFanoutUnspents } from './v1/fanoutUnspents'; import { PostOfcSignPayload } from './v2/ofcSignPayload'; import { PostWalletRecoverToken } from './v2/walletRecoverToken'; +import { PostSignerMacaroon } from './v2/signerMacaroon'; export const ExpressApi = apiSpec({ 'express.ping': { @@ -100,6 +101,9 @@ export const ExpressApi = apiSpec({ 'express.v2.wallet.recovertoken': { post: PostWalletRecoverToken, }, + 'express.lightning.signerMacaroon': { + post: PostSignerMacaroon, + }, }); export type ExpressApi = typeof ExpressApi; diff --git a/modules/express/src/typedRoutes/api/v2/signerMacaroon.ts b/modules/express/src/typedRoutes/api/v2/signerMacaroon.ts new file mode 100644 index 0000000000..0d49e481cd --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/signerMacaroon.ts @@ -0,0 +1,43 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; +import { BitgoExpressError } from '../../schemas/error'; + +/** + * Path parameters for creating a signer macaroon + * @property {string} coin - A lightning coin name (e.g, lnbtc). + * @property {string} walletId - The ID of the wallet. + */ +export const SignerMacaroonParams = { + coin: t.string, + walletId: t.string, +}; + +/** + * Request body for creating a signer macaroon + * @property {string} passphrase - Passphrase to decrypt the admin macaroon of the signer node. + * @property {boolean} addIpCaveatToMacaroon - If true, adds an IP caveat to the generated signer macaroon. + */ +export const SignerMacaroonBody = { + passphrase: t.string, + addIpCaveatToMacaroon: optional(t.boolean), +}; + +/** + * Lightning - Create signer macaroon + * + * This is only used for self-custody lightning. Create the signer macaroon for the watch-only Lightning Network Daemon (LND) node. This macaroon derives from the signer node admin macaroon and is used by the watch-only node to request signatures from the signer node for operational tasks. Returns the updated wallet with the encrypted signer macaroon in the `coinSpecific` response field. + * + * @operationId express.lightning.signerMacaroon + */ +export const PostSignerMacaroon = httpRoute({ + method: 'POST', + path: '/api/v2/{coin}/wallet/{walletId}/signermacaroon', + request: httpRequest({ + params: SignerMacaroonParams, + body: SignerMacaroonBody, + }), + response: { + 200: t.UnknownRecord, + 400: BitgoExpressError, + }, +}); diff --git a/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts b/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts index 805de7ebdd..55ac112abd 100644 --- a/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts +++ b/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts @@ -131,11 +131,18 @@ describe('Lightning signer routes', () => { params: { coin: 'tlnbtc', id: 'fakeid', + walletId: 'fakeid', + }, + decoded: { + coin: 'tlnbtc', + walletId: apiData.wallet.id, + passphrase: apiData.signerMacaroonRequestBody.passphrase, + addIpCaveatToMacaroon, }, config: { lightningSignerFileSystemPath: 'lightningSignerFileSystemPath', }, - } as unknown as express.Request; + } as any; try { await handleCreateSignerMacaroon(req); diff --git a/modules/express/test/unit/typedRoutes/decode.ts b/modules/express/test/unit/typedRoutes/decode.ts index 7197997358..c0cc7d94be 100644 --- a/modules/express/test/unit/typedRoutes/decode.ts +++ b/modules/express/test/unit/typedRoutes/decode.ts @@ -15,6 +15,7 @@ import { import { UnlockLightningWalletBody, UnlockLightningWalletParams } from '../../../src/typedRoutes/api/v2/unlockWallet'; import { OfcSignPayloadBody } from '../../../src/typedRoutes/api/v2/ofcSignPayload'; import { CreateAddressBody, CreateAddressParams } from '../../../src/typedRoutes/api/v2/createAddress'; +import { SignerMacaroonBody, SignerMacaroonParams } from '../../../src/typedRoutes/api/v2/signerMacaroon'; export function assertDecode(codec: t.Type, input: unknown): T { const result = codec.decode(input); @@ -243,4 +244,16 @@ describe('io-ts decode tests', function () { assertDecode(t.type(CreateAddressBody), { eip1559: { maxFeePerGas: 1, maxPriorityFeePerGas: 1 } }); assertDecode(t.type(CreateAddressBody), {}); }); + it('express.lightning.signerMacaroon body valid', function () { + assertDecode(t.type(SignerMacaroonBody), { passphrase: 'pw', addIpCaveatToMacaroon: true }); + }); + it('express.lightning.signerMacaroon body valid (missing addIpCaveatToMacaroon)', function () { + assertDecode(t.type(SignerMacaroonBody), { passphrase: 'pw' }); + }); + it('express.lightning.signerMacaroon params valid', function () { + assertDecode(t.type(SignerMacaroonParams), { coin: 'lnbtc', walletId: 'wid123' }); + }); + it('express.lightning.signerMacaroon params invalid', function () { + assert.throws(() => assertDecode(t.type(SignerMacaroonParams), { coin: 'lnbtc' })); + }); }); From 7e45d7c43c14d8c16a09d191c9d311ccb6325e4c Mon Sep 17 00:00:00 2001 From: Daniel Zhao Date: Thu, 11 Sep 2025 17:11:20 -0400 Subject: [PATCH 2/8] refactor: minor edit to how we extract coinName TICKET: WP-5445 --- modules/express/src/lightning/lightningSignerRoutes.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/modules/express/src/lightning/lightningSignerRoutes.ts b/modules/express/src/lightning/lightningSignerRoutes.ts index e5dbe97fc2..daaf5f552b 100644 --- a/modules/express/src/lightning/lightningSignerRoutes.ts +++ b/modules/express/src/lightning/lightningSignerRoutes.ts @@ -108,8 +108,7 @@ export async function handleCreateSignerMacaroon( req: ExpressApiRouteRequest<'express.lightning.signerMacaroon', 'post'> ): Promise { const bitgo = req.bitgo; - const { walletId, passphrase, addIpCaveatToMacaroon } = req.decoded; - const coinName = req.decoded.coin; + const { coin: coinName, walletId, passphrase, addIpCaveatToMacaroon } = req.decoded; if (!isLightningCoinName(coinName)) { throw new ApiResponseError(`Invalid coin to create signer macaroon: ${coinName}. Must be a lightning coin.`, 400); } From 5a7e6f5b4a079d6488c7a73fa82ffb9ec6a8370f Mon Sep 17 00:00:00 2001 From: Daniel Zhao Date: Wed, 17 Sep 2025 16:17:59 -0400 Subject: [PATCH 3/8] refactor: removed any type cast and added reponse const variable TICKET: WP-5445 --- .../src/typedRoutes/api/v2/signerMacaroon.ts | 21 +++++++++++++------ .../lightning/lightningSignerRoutes.ts | 2 +- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/modules/express/src/typedRoutes/api/v2/signerMacaroon.ts b/modules/express/src/typedRoutes/api/v2/signerMacaroon.ts index 0d49e481cd..63026f040b 100644 --- a/modules/express/src/typedRoutes/api/v2/signerMacaroon.ts +++ b/modules/express/src/typedRoutes/api/v2/signerMacaroon.ts @@ -10,7 +10,7 @@ import { BitgoExpressError } from '../../schemas/error'; export const SignerMacaroonParams = { coin: t.string, walletId: t.string, -}; +} as const; /** * Request body for creating a signer macaroon @@ -20,7 +20,19 @@ export const SignerMacaroonParams = { export const SignerMacaroonBody = { passphrase: t.string, addIpCaveatToMacaroon: optional(t.boolean), -}; +} as const; + +/** + * Response + * - 200: Returns the updated wallet. On success, the wallet's `coinSpecific` includes the generated signer macaroon (derived from the signer node admin macaroon), optionally with an IP caveat. + * - 400: BitGo Express error payload when macaroon creation cannot proceed (e.g., invalid coin, wallet not self‑custody lightning, missing encrypted signer admin macaroon, or external IP not set when an IP caveat is requested). + * + * See platform spec: POST /api/v2/{coin}/wallet/{walletId}/signermacaroon + */ +export const SignerMacaroonResponse = { + 200: t.UnknownRecord, + 400: BitgoExpressError, +} as const; /** * Lightning - Create signer macaroon @@ -36,8 +48,5 @@ export const PostSignerMacaroon = httpRoute({ params: SignerMacaroonParams, body: SignerMacaroonBody, }), - response: { - 200: t.UnknownRecord, - 400: BitgoExpressError, - }, + response: SignerMacaroonResponse, }); diff --git a/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts b/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts index 55ac112abd..05b68bd70a 100644 --- a/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts +++ b/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts @@ -142,7 +142,7 @@ describe('Lightning signer routes', () => { config: { lightningSignerFileSystemPath: 'lightningSignerFileSystemPath', }, - } as any; + } as unknown as ExpressApiRouteRequest<'express.lightning.signerMacaroon', 'post'>; try { await handleCreateSignerMacaroon(req); From f27c7aadab58488c7e92b6ba696f6432f2dd98e2 Mon Sep 17 00:00:00 2001 From: Daniel Zhao Date: Wed, 17 Sep 2025 16:51:44 -0400 Subject: [PATCH 4/8] refactor: attempt to fix import TICKET: WP-5445 --- modules/express/src/lightning/lightningSignerRoutes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/express/src/lightning/lightningSignerRoutes.ts b/modules/express/src/lightning/lightningSignerRoutes.ts index daaf5f552b..dc0a814745 100644 --- a/modules/express/src/lightning/lightningSignerRoutes.ts +++ b/modules/express/src/lightning/lightningSignerRoutes.ts @@ -15,7 +15,7 @@ import { Buffer } from 'buffer'; import { GetWalletStateResponse } from './codecs'; import { LndSignerClient } from './lndSignerClient'; import { ApiResponseError } from '../errors'; -import type { ExpressApiRouteRequest } from '../typedRoutes/api'; +import { ExpressApiRouteRequest } from '../typedRoutes/api'; type Decrypt = (params: { input: string; password: string }) => string; From ec5453a6b227313bf7b712c9da1dd9df8d70bac1 Mon Sep 17 00:00:00 2001 From: Daniel Zhao Date: Thu, 18 Sep 2025 16:29:34 -0400 Subject: [PATCH 5/8] docs: refactored the jsdocs TICKET: WP-5445 --- modules/express/src/typedRoutes/api/v2/signerMacaroon.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/modules/express/src/typedRoutes/api/v2/signerMacaroon.ts b/modules/express/src/typedRoutes/api/v2/signerMacaroon.ts index 63026f040b..82cec002f3 100644 --- a/modules/express/src/typedRoutes/api/v2/signerMacaroon.ts +++ b/modules/express/src/typedRoutes/api/v2/signerMacaroon.ts @@ -8,7 +8,9 @@ import { BitgoExpressError } from '../../schemas/error'; * @property {string} walletId - The ID of the wallet. */ export const SignerMacaroonParams = { + /** A lightning coin name (e.g, lnbtc). */ coin: t.string, + /** The ID of the wallet. */ walletId: t.string, } as const; @@ -18,7 +20,9 @@ export const SignerMacaroonParams = { * @property {boolean} addIpCaveatToMacaroon - If true, adds an IP caveat to the generated signer macaroon. */ export const SignerMacaroonBody = { + /** Passphrase to decrypt the admin macaroon of the signer node. */ passphrase: t.string, + /** If true, adds an IP caveat to the generated signer macaroon. */ addIpCaveatToMacaroon: optional(t.boolean), } as const; @@ -30,7 +34,9 @@ export const SignerMacaroonBody = { * See platform spec: POST /api/v2/{coin}/wallet/{walletId}/signermacaroon */ export const SignerMacaroonResponse = { + /** The updated wallet with the generated signer macaroon. */ 200: t.UnknownRecord, + /** BitGo Express error payload. */ 400: BitgoExpressError, } as const; From 644435aed267f68bc0401973116644b937a5a3bf Mon Sep 17 00:00:00 2001 From: Daniel Zhao Date: Fri, 19 Sep 2025 10:10:44 -0400 Subject: [PATCH 6/8] refactor: added decoding to UT TICKET: WP-5445 --- .../unit/clientRoutes/lightning/lightningSignerRoutes.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts b/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts index 05b68bd70a..4df947ac43 100644 --- a/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts +++ b/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts @@ -2,11 +2,10 @@ import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { BitGo } from 'bitgo'; import { common, decodeOrElse } from '@bitgo/sdk-core'; import nock from 'nock'; -import * as express from 'express'; import * as sinon from 'sinon'; import * as fs from 'fs'; import { UnlockLightningWalletResponse } from '../../../../src/typedRoutes/api/v2/unlockWallet'; - +import { SignerMacaroonResponse } from '../../../../src/typedRoutes/api/v2/signerMacaroon'; import { lightningSignerConfigs, apiData, signerApiData } from './lightningSignerFixture'; import { handleCreateSignerMacaroon, @@ -145,7 +144,10 @@ describe('Lightning signer routes', () => { } as unknown as ExpressApiRouteRequest<'express.lightning.signerMacaroon', 'post'>; try { - await handleCreateSignerMacaroon(req); + const res = await handleCreateSignerMacaroon(req); + decodeOrElse('PostLightningInitWallet.response.200', SignerMacaroonResponse[200], res, (_) => { + throw new Error('Response did not match expected codec'); + }); } catch (e) { if (!includeWatchOnlyIp || addIpCaveatToMacaroon) { throw e; From 3ff273abfcfa144c550abf2ecb8c0e92e07dca5a Mon Sep 17 00:00:00 2001 From: Daniel Zhao Date: Fri, 19 Sep 2025 10:41:35 -0400 Subject: [PATCH 7/8] refactor: changed label of decodeOrElse TICKET: WP-5445 --- .../test/unit/clientRoutes/lightning/lightningSignerRoutes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts b/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts index 4df947ac43..8029090399 100644 --- a/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts +++ b/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts @@ -145,7 +145,7 @@ describe('Lightning signer routes', () => { try { const res = await handleCreateSignerMacaroon(req); - decodeOrElse('PostLightningInitWallet.response.200', SignerMacaroonResponse[200], res, (_) => { + decodeOrElse('SignerMacaroonResponse200', SignerMacaroonResponse[200], res, (_) => { throw new Error('Response did not match expected codec'); }); } catch (e) { From ee326a2f1f2fdefa21a65edaf3d1273b693dbbb1 Mon Sep 17 00:00:00 2001 From: danielzhao122 Date: Fri, 17 Oct 2025 16:27:06 -0400 Subject: [PATCH 8/8] test(express): add supertests TICKET: WP-5445 --- .../src/typedRoutes/api/v2/signerMacaroon.ts | 5 +- .../test/unit/typedRoutes/signerMacaroon.ts | 457 ++++++++++++++++++ 2 files changed, 461 insertions(+), 1 deletion(-) create mode 100644 modules/express/test/unit/typedRoutes/signerMacaroon.ts diff --git a/modules/express/src/typedRoutes/api/v2/signerMacaroon.ts b/modules/express/src/typedRoutes/api/v2/signerMacaroon.ts index 82cec002f3..0def3b3070 100644 --- a/modules/express/src/typedRoutes/api/v2/signerMacaroon.ts +++ b/modules/express/src/typedRoutes/api/v2/signerMacaroon.ts @@ -43,7 +43,10 @@ export const SignerMacaroonResponse = { /** * Lightning - Create signer macaroon * - * This is only used for self-custody lightning. Create the signer macaroon for the watch-only Lightning Network Daemon (LND) node. This macaroon derives from the signer node admin macaroon and is used by the watch-only node to request signatures from the signer node for operational tasks. Returns the updated wallet with the encrypted signer macaroon in the `coinSpecific` response field. + * This is only used for self-custody lightning. + * Create the signer macaroon for the watch-only Lightning Network Daemon (LND) node. + * This macaroon derives from the signer node admin macaroon and is used by the watch-only node to request signatures from the signer node for operational tasks. + * Returns the updated wallet with the encrypted signer macaroon in the `coinSpecific` response field. * * @operationId express.lightning.signerMacaroon */ diff --git a/modules/express/test/unit/typedRoutes/signerMacaroon.ts b/modules/express/test/unit/typedRoutes/signerMacaroon.ts new file mode 100644 index 0000000000..6945d661ed --- /dev/null +++ b/modules/express/test/unit/typedRoutes/signerMacaroon.ts @@ -0,0 +1,457 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as fs from 'fs'; +import { agent as supertest } from 'supertest'; +import 'should'; +import 'should-http'; +import 'should-sinon'; +import '../../lib/asserts'; +import { BitGo } from 'bitgo'; +import { PostSignerMacaroon } from '../../../src/typedRoutes/api/v2/signerMacaroon'; +import { LndSignerClient } from '../../../src/lightning/lndSignerClient'; + +describe('Signer Macaroon Typed Routes Tests', function () { + let agent: ReturnType; + const tempFilePath = '/tmp/test-lightning-signer.json'; + + before(function () { + // Create a temporary JSON file for lightning signer config + fs.writeFileSync(tempFilePath, JSON.stringify({})); + + const { app } = require('../../../src/expressApp'); + // Configure app with lightning signer enabled + const config = { + ...require('../../../src/config').DefaultConfig, + lightningSignerFileSystemPath: tempFilePath, + }; + const testApp = app(config); + agent = supertest(testApp); + }); + + after(function () { + // Clean up the temporary file + if (fs.existsSync(tempFilePath)) { + fs.unlinkSync(tempFilePath); + } + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('Success Cases', function () { + it('should successfully create signer macaroon without IP caveat', async function () { + const coin = 'tlnbtc'; + const walletId = 'lightningWallet123'; + const passphrase = 'MyWalletPassphrase123'; + + const walletResponse = { + id: walletId, + coin, + coinSpecific: { + [coin]: { + encryptedSignerMacaroon: 'encrypted_new_signer_macaroon', + encryptedSignerAdminMacaroon: 'encrypted_admin_macaroon', + signerHost: 'https://signer.example.com', + signerTlsCert: 'base64cert==', + watchOnlyExternalIp: '192.168.1.100', + keys: ['userAuthKeyId', 'nodeAuthKeyId'], + }, + }, + }; + + // Stub LndSignerClient.create + // Use a valid base64 macaroon converted to hex for the mock + const validMacaroonBase64 = + 'AgEDbG5kAvgBAwoQMgU7rDi802Yqg/tHll24nhIBMBoWCgdhZGRyZXNzEgRyZWFkEgV3cml0ZRoTCgRpbmZvEgRyZWFkEgV3cml0ZRoXCghpbnZvaWNlcxIEcmVhZBIFd3JpdGUaIQoIbWFjYXJvb24SCGdlbmVyYXRlEgRyZWFkEgV3cml0ZRoWCgdtZXNzYWdlEgRyZWFkEgV3cml0ZRoXCghvZmZjaGFpbhIEcmVhZBIFd3JpdGUaFgoHb25jaGFpbhIEcmVhZBIFd3JpdGUaFAoFcGVlcnMSBHJlYWQSBXdyaXRlGhgKBnNpZ25lchIIZ2VuZXJhdGUSBHJlYWQAAAYgZKiUvEzxGd2QKGUS+9R5ZWevG09S06fMJUnt+k1XXXQ='; + const validMacaroonHex = Buffer.from(validMacaroonBase64, 'base64').toString('hex'); + + const mockLndClient = { + bakeMacaroon: sinon.stub().resolves({ macaroon: validMacaroonHex }), + } as any; + sinon.stub(LndSignerClient, 'create').resolves(mockLndClient); + + // Mock keychains for updateWalletCoinSpecific + const userAuthKey = { + id: 'userAuthKeyId', + pub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', + encryptedPrv: 'encrypted_user_auth_prv', + source: 'user' as const, + coinSpecific: { + [coin]: { purpose: 'userAuth' as const }, + }, + }; + + const nodeAuthKey = { + id: 'nodeAuthKeyId', + pub: 'xpub661MyMwAqRbcGczjuMoRm6dXaLDEhW1u34gKenbeYqAix21mdUKJyuyu5F1rzYGVxyL6tmgBUAEPrEz92mBXjByMRiJdba9wpnN37RLLAXa', + encryptedPrv: 'encrypted_node_auth_prv', + source: 'user' as const, + coinSpecific: { + [coin]: { purpose: 'nodeAuth' as const }, + }, + }; + + const keychainsGetStub = sinon.stub(); + keychainsGetStub.withArgs({ id: 'userAuthKeyId' }).resolves(userAuthKey); + keychainsGetStub.withArgs({ id: 'nodeAuthKeyId' }).resolves(nodeAuthKey); + const keychainsStub = { get: keychainsGetStub } as any; + + // Stub the BitGo.put call that updateWalletCoinSpecific makes + const putStub = sinon.stub().returns({ + send: sinon.stub().returns({ + result: sinon.stub().resolves(walletResponse), + }), + }); + + // Stub wallet methods + const walletStub = { + subType: sinon.stub().returns('lightningSelfCustody'), + coin: sinon.stub().returns(coin), + coinSpecific: sinon.stub().returns({ + encryptedSignerAdminMacaroon: 'encrypted_admin_macaroon', + watchOnlyExternalIp: '192.168.1.100', + keys: ['userAuthKeyId', 'nodeAuthKeyId'], + }), + url: sinon.stub().returns(`/api/v2/${coin}/wallet/${walletId}`), + bitgo: { + decrypt: sinon + .stub() + .returns( + 'xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi' + ), + encrypt: sinon.stub().callsFake(({ input }: { input: string }) => `encrypted_${input}`), + put: putStub, + }, + baseCoin: { + getFamily: sinon.stub().returns('lnbtc'), + getChain: sinon.stub().returns(coin), + keychains: sinon.stub().returns(keychainsStub), + }, + } as any; + + const getWalletStub = sinon.stub().resolves(walletStub); + const walletsStub = { get: getWalletStub } as any; + const coinStub = { + wallets: sinon.stub().returns(walletsStub), + keychains: sinon.stub().returns(keychainsStub), + } as any; + + sinon.stub(BitGo.prototype, 'coin').returns(coinStub); + sinon.stub(BitGo.prototype, 'decrypt').callsFake(walletStub.bitgo.decrypt); + sinon.stub(BitGo.prototype, 'put').callsFake(putStub as any); + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/signermacaroon`).send({ + passphrase, + }); + + res.status.should.equal(200); + res.body.should.have.property('id', walletId); + res.body.should.have.property('coin', coin); + res.body.should.have.property('coinSpecific'); + res.body.coinSpecific.should.have.property(coin); + res.body.coinSpecific[coin].should.have.property('encryptedSignerMacaroon'); + + getWalletStub.should.have.been.calledOnceWith({ id: walletId, includeBalance: false }); + }); + + it('should successfully create signer macaroon with IP caveat', async function () { + const coin = 'lnbtc'; + const walletId = 'lightningWallet456'; + const passphrase = 'MyWalletPassphrase456'; + + const walletResponse = { + id: walletId, + coin, + coinSpecific: { + [coin]: { + encryptedSignerMacaroon: 'encrypted_new_signer_macaroon_with_ip', + encryptedSignerAdminMacaroon: 'encrypted_admin_macaroon', + signerHost: 'https://signer.example.com', + signerTlsCert: 'base64cert==', + watchOnlyExternalIp: '10.0.0.5', + keys: ['userAuthKeyId', 'nodeAuthKeyId'], + }, + }, + }; + + // Stub LndSignerClient.create + // Use a valid base64 macaroon converted to hex for the mock + const validMacaroonBase64 = + 'AgEDbG5kAvgBAwoQMgU7rDi802Yqg/tHll24nhIBMBoWCgdhZGRyZXNzEgRyZWFkEgV3cml0ZRoTCgRpbmZvEgRyZWFkEgV3cml0ZRoXCghpbnZvaWNlcxIEcmVhZBIFd3JpdGUaIQoIbWFjYXJvb24SCGdlbmVyYXRlEgRyZWFkEgV3cml0ZRoWCgdtZXNzYWdlEgRyZWFkEgV3cml0ZRoXCghvZmZjaGFpbhIEcmVhZBIFd3JpdGUaFgoHb25jaGFpbhIEcmVhZBIFd3JpdGUaFAoFcGVlcnMSBHJlYWQSBXdyaXRlGhgKBnNpZ25lchIIZ2VuZXJhdGUSBHJlYWQAAAYgZKiUvEzxGd2QKGUS+9R5ZWevG09S06fMJUnt+k1XXXQ='; + const validMacaroonHex = Buffer.from(validMacaroonBase64, 'base64').toString('hex'); + + const mockLndClient = { + bakeMacaroon: sinon.stub().resolves({ macaroon: validMacaroonHex }), + } as any; + sinon.stub(LndSignerClient, 'create').resolves(mockLndClient); + + // Mock keychains for updateWalletCoinSpecific + const userAuthKey = { + id: 'userAuthKeyId', + pub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', + encryptedPrv: 'encrypted_user_auth_prv', + source: 'user' as const, + coinSpecific: { + [coin]: { purpose: 'userAuth' as const }, + }, + }; + + const nodeAuthKey = { + id: 'nodeAuthKeyId', + pub: 'xpub661MyMwAqRbcGczjuMoRm6dXaLDEhW1u34gKenbeYqAix21mdUKJyuyu5F1rzYGVxyL6tmgBUAEPrEz92mBXjByMRiJdba9wpnN37RLLAXa', + encryptedPrv: 'encrypted_node_auth_prv', + source: 'user' as const, + coinSpecific: { + [coin]: { purpose: 'nodeAuth' as const }, + }, + }; + + const keychainsGetStub = sinon.stub(); + keychainsGetStub.withArgs({ id: 'userAuthKeyId' }).resolves(userAuthKey); + keychainsGetStub.withArgs({ id: 'nodeAuthKeyId' }).resolves(nodeAuthKey); + const keychainsStub = { get: keychainsGetStub } as any; + + // Stub the BitGo.put call that updateWalletCoinSpecific makes + const putStub = sinon.stub().returns({ + send: sinon.stub().returns({ + result: sinon.stub().resolves(walletResponse), + }), + }); + + // Stub wallet methods + const walletStub = { + subType: sinon.stub().returns('lightningSelfCustody'), + coin: sinon.stub().returns(coin), + coinSpecific: sinon.stub().returns({ + encryptedSignerAdminMacaroon: 'encrypted_admin_macaroon', + watchOnlyExternalIp: '10.0.0.5', + keys: ['userAuthKeyId', 'nodeAuthKeyId'], + }), + url: sinon.stub().returns(`/api/v2/${coin}/wallet/${walletId}`), + bitgo: { + decrypt: sinon + .stub() + .returns( + 'xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi' + ), + encrypt: sinon.stub().callsFake(({ input }: { input: string }) => `encrypted_${input}`), + put: putStub, + }, + baseCoin: { + getFamily: sinon.stub().returns('lnbtc'), + getChain: sinon.stub().returns(coin), + keychains: sinon.stub().returns(keychainsStub), + }, + } as any; + + const getWalletStub = sinon.stub().resolves(walletStub); + const walletsStub = { get: getWalletStub } as any; + const coinStub = { + wallets: sinon.stub().returns(walletsStub), + keychains: sinon.stub().returns(keychainsStub), + } as any; + + sinon.stub(BitGo.prototype, 'coin').returns(coinStub); + sinon.stub(BitGo.prototype, 'decrypt').callsFake(walletStub.bitgo.decrypt); + sinon.stub(BitGo.prototype, 'put').callsFake(putStub as any); + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/signermacaroon`).send({ + passphrase, + addIpCaveatToMacaroon: true, + }); + + res.status.should.equal(200); + res.body.should.have.property('id', walletId); + res.body.should.have.property('coin', coin); + res.body.should.have.property('coinSpecific'); + res.body.coinSpecific.should.have.property(coin); + res.body.coinSpecific[coin].should.have.property('encryptedSignerMacaroon'); + + getWalletStub.should.have.been.calledOnceWith({ id: walletId, includeBalance: false }); + }); + }); + + describe('Codec Validation', function () { + it('should return 400 when passphrase is missing', async function () { + const coin = 'tlnbtc'; + const walletId = 'lightningWallet123'; + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/signermacaroon`).send({ + addIpCaveatToMacaroon: true, + }); + + res.status.should.equal(400); + res.body.should.be.an.Array(); + res.body[0].should.match(/passphrase/); + }); + + it('should return 400 when passphrase is not a string', async function () { + const coin = 'tlnbtc'; + const walletId = 'lightningWallet123'; + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/signermacaroon`).send({ + passphrase: 12345, + }); + + res.status.should.equal(400); + res.body.should.be.an.Array(); + res.body[0].should.match(/passphrase/); + }); + + it('should return 400 when addIpCaveatToMacaroon is not a boolean', async function () { + const coin = 'tlnbtc'; + const walletId = 'lightningWallet123'; + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/signermacaroon`).send({ + passphrase: 'MyPassphrase', + addIpCaveatToMacaroon: 'true', + }); + + res.status.should.equal(400); + res.body.should.be.an.Array(); + res.body[0].should.match(/addIpCaveatToMacaroon/); + }); + }); + + describe('Handler Validation', function () { + it('should return 400 when coin is not a lightning coin', async function () { + const coin = 'tbtc'; // Not a lightning coin + const walletId = 'wallet123'; + const passphrase = 'MyPassphrase'; + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/signermacaroon`).send({ + passphrase, + }); + + res.status.should.equal(400); + res.body.should.have.property('error'); + res.body.error.should.match(/Invalid coin to create signer macaroon/); + }); + + it('should return 400 when wallet is not self-custody lightning', async function () { + const coin = 'tlnbtc'; + const walletId = 'custodialWallet123'; + const passphrase = 'MyPassphrase'; + + // Stub wallet that is NOT self-custody + const walletStub = { + subType: sinon.stub().returns('lightningCustody'), + } as any; + + const getWalletStub = sinon.stub().resolves(walletStub); + const walletsStub = { get: getWalletStub } as any; + const coinStub = { wallets: sinon.stub().returns(walletsStub) } as any; + + sinon.stub(BitGo.prototype, 'coin').returns(coinStub); + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/signermacaroon`).send({ + passphrase, + }); + + res.status.should.equal(400); + res.body.should.have.property('error'); + res.body.error.should.match(/not a self custodial lighting wallet/); + }); + + it('should return 400 when encrypted admin macaroon is missing', async function () { + const coin = 'tlnbtc'; + const walletId = 'lightningWallet123'; + const passphrase = 'MyPassphrase'; + + // Stub LndSignerClient.create + const mockLndClient = {} as any; + sinon.stub(LndSignerClient, 'create').resolves(mockLndClient); + + // Stub wallet without encryptedSignerAdminMacaroon + const walletStub = { + subType: sinon.stub().returns('lightningSelfCustody'), + coinSpecific: sinon.stub().returns({ + // Missing encryptedSignerAdminMacaroon + watchOnlyExternalIp: '192.168.1.100', + }), + } as any; + + const getWalletStub = sinon.stub().resolves(walletStub); + const walletsStub = { get: getWalletStub } as any; + const coinStub = { wallets: sinon.stub().returns(walletsStub) } as any; + + sinon.stub(BitGo.prototype, 'coin').returns(coinStub); + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/signermacaroon`).send({ + passphrase, + }); + + res.status.should.equal(400); + res.body.should.have.property('error'); + res.body.error.should.match(/Missing encryptedSignerAdminMacaroon/); + }); + + it('should return 400 when IP caveat is requested but external IP is not set', async function () { + const coin = 'tlnbtc'; + const walletId = 'lightningWallet123'; + const passphrase = 'MyPassphrase'; + + // Stub wallet without watchOnlyExternalIp + const walletStub = { + subType: sinon.stub().returns('lightningSelfCustody'), + coinSpecific: sinon.stub().returns({ + encryptedSignerAdminMacaroon: 'encrypted_admin_macaroon', + // Missing watchOnlyExternalIp + }), + } as any; + + const getWalletStub = sinon.stub().resolves(walletStub); + const walletsStub = { get: getWalletStub } as any; + const coinStub = { wallets: sinon.stub().returns(walletsStub) } as any; + + sinon.stub(BitGo.prototype, 'coin').returns(coinStub); + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/signermacaroon`).send({ + passphrase, + addIpCaveatToMacaroon: true, + }); + + res.status.should.equal(400); + res.body.should.have.property('error'); + res.body.error.should.match(/Cannot create signer macaroon because the external IP is not set/); + }); + + it('should return 500 when external IP is invalid', async function () { + const coin = 'tlnbtc'; + const walletId = 'lightningWallet123'; + const passphrase = 'MyPassphrase'; + + // Stub wallet with invalid IP + const walletStub = { + subType: sinon.stub().returns('lightningSelfCustody'), + coinSpecific: sinon.stub().returns({ + encryptedSignerAdminMacaroon: 'encrypted_admin_macaroon', + watchOnlyExternalIp: 'not-an-ip-address', + }), + } as any; + + const getWalletStub = sinon.stub().resolves(walletStub); + const walletsStub = { get: getWalletStub } as any; + const coinStub = { wallets: sinon.stub().returns(walletsStub) } as any; + + sinon.stub(BitGo.prototype, 'coin').returns(coinStub); + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/signermacaroon`).send({ + passphrase, + addIpCaveatToMacaroon: true, + }); + + res.status.should.equal(500); + res.body.should.have.property('error'); + res.body.error.should.match(/Invalid IP address/); + }); + }); + + describe('Route Definition', function () { + it('should have correct route metadata', function () { + assert.strictEqual(PostSignerMacaroon.method, 'POST'); + assert.strictEqual(PostSignerMacaroon.path, '/api/v2/{coin}/wallet/{walletId}/signermacaroon'); + }); + }); +});