diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index b9572b7d0c..90c76d33b4 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)]); @@ -1759,10 +1753,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..dc0a814745 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 { ExpressApiRouteRequest } from '../typedRoutes/api'; type Decrypt = (params: { input: string; password: string }) => string; @@ -106,28 +104,19 @@ 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 { 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); } 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 ee377b2ac8..88a840548d 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'; import { PostCoinSignTx } from './v2/coinSignTx'; import { PostWalletSignTx } from './v2/walletSignTx'; import { PostWalletTxSignTSS } from './v2/walletTxSignTSS'; @@ -112,6 +113,9 @@ export const ExpressApi = apiSpec({ 'express.v2.wallet.signtxtss': { post: PostWalletTxSignTSS, }, + '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..0def3b3070 --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/signerMacaroon.ts @@ -0,0 +1,61 @@ +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 = { + /** A lightning coin name (e.g, lnbtc). */ + coin: t.string, + /** The ID of the wallet. */ + walletId: t.string, +} as const; + +/** + * 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 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; + +/** + * 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 = { + /** The updated wallet with the generated signer macaroon. */ + 200: t.UnknownRecord, + /** BitGo Express error payload. */ + 400: BitgoExpressError, +} as const; + +/** + * 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: SignerMacaroonResponse, +}); diff --git a/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts b/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts index 805de7ebdd..8029090399 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, @@ -131,14 +130,24 @@ 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 unknown as ExpressApiRouteRequest<'express.lightning.signerMacaroon', 'post'>; try { - await handleCreateSignerMacaroon(req); + const res = await handleCreateSignerMacaroon(req); + decodeOrElse('SignerMacaroonResponse200', SignerMacaroonResponse[200], res, (_) => { + throw new Error('Response did not match expected codec'); + }); } catch (e) { if (!includeWatchOnlyIp || addIpCaveatToMacaroon) { throw e; 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' })); + }); }); 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'); + }); + }); +});