Skip to content
22 changes: 11 additions & 11 deletions modules/express/src/clientRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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)]);
Expand Down Expand Up @@ -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)]);
}
23 changes: 6 additions & 17 deletions modules/express/src/lightning/lightningSignerRoutes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { isIP } from 'net';
import * as express from 'express';
import { decodeOrElse } from '@bitgo/sdk-core';
import {
getUtxolibNetwork,
signerMacaroonPermissions,
Expand All @@ -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;

Expand Down Expand Up @@ -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<unknown> {
export async function handleCreateSignerMacaroon(
req: ExpressApiRouteRequest<'express.lightning.signerMacaroon', 'post'>
): Promise<unknown> {
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);
Expand Down
4 changes: 4 additions & 0 deletions modules/express/src/typedRoutes/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -112,6 +113,9 @@ export const ExpressApi = apiSpec({
'express.v2.wallet.signtxtss': {
post: PostWalletTxSignTSS,
},
'express.lightning.signerMacaroon': {
post: PostSignerMacaroon,
},
});

export type ExpressApi = typeof ExpressApi;
Expand Down
58 changes: 58 additions & 0 deletions modules/express/src/typedRoutes/api/v2/signerMacaroon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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,
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions modules/express/test/unit/typedRoutes/decode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(codec: t.Type<T, unknown>, input: unknown): T {
const result = codec.decode(input);
Expand Down Expand Up @@ -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' }));
});
});