From c47d74101c8c091349e3332a68850a803b96ee6a Mon Sep 17 00:00:00 2001 From: danielzhao122 Date: Wed, 24 Sep 2025 12:28:20 -0400 Subject: [PATCH 1/4] feat(express): migrated update express wallet to typed routes TICKET: WP-5416 --- modules/express/src/clientRoutes.ts | 8 +- modules/express/src/typedRoutes/api/index.ts | 4 + .../typedRoutes/api/v2/expressWalletUpdate.ts | 58 +++++++++++++++ .../test/unit/clientRoutes/expressWallet.ts | 74 +++++++++++++++++++ 4 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 modules/express/src/typedRoutes/api/v2/expressWalletUpdate.ts create mode 100644 modules/express/test/unit/clientRoutes/expressWallet.ts diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index f6433eb73b..3af37b376a 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -1005,9 +1005,11 @@ export async function handleV2EnableTokens(req: express.Request) { * Handle Update Wallet * @param req */ -async function handleWalletUpdate(req: express.Request): Promise { +export async function handleWalletUpdate( + req: ExpressApiRouteRequest<'express.wallet.update', 'put'> +): Promise { // If it's a lightning coin, use the lightning-specific handler - if (isLightningCoinName(req.params.coin)) { + if (isLightningCoinName(req.decoded.coin)) { return handleUpdateLightningWalletCoinSpecific(req); } @@ -1607,7 +1609,7 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { // generate wallet app.post('/api/v2/:coin/wallet/generate', parseBody, prepareBitGo(config), promiseWrapper(handleV2GenerateWallet)); - app.put('/express/api/v2/:coin/wallet/:id', parseBody, prepareBitGo(config), promiseWrapper(handleWalletUpdate)); + router.put('express.wallet.update', [prepareBitGo(config), typedPromiseWrapper(handleWalletUpdate)]); // change wallet passphrase app.post( diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index b4db56f42d..efe674c21d 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 { PutExpressWalletUpdate } from './v2/expressWalletUpdate'; export const ExpressApi = apiSpec({ 'express.ping': { @@ -100,6 +101,9 @@ export const ExpressApi = apiSpec({ 'express.v2.wallet.recovertoken': { post: PostWalletRecoverToken, }, + 'express.wallet.update': { + put: PutExpressWalletUpdate, + }, }); export type ExpressApi = typeof ExpressApi; diff --git a/modules/express/src/typedRoutes/api/v2/expressWalletUpdate.ts b/modules/express/src/typedRoutes/api/v2/expressWalletUpdate.ts new file mode 100644 index 0000000000..6a7eda0ca8 --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/expressWalletUpdate.ts @@ -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'; + +/** + * Parameters for Express Wallet Update + */ +export const ExpressWalletUpdateParams = { + /** Coin ticker / chain identifier */ + coin: t.string, + /** Wallet ID */ + id: t.string, +} as const; + +/** + * Request body for Express Wallet Update + */ +export const ExpressWalletUpdateBody = { + /** The host address of the lightning signer node. */ + signerHost: t.string, + /** The TLS certificate for the lighting signer node encoded to base64. */ + signerTlsCert: t.string, + /** (Optional) The signer macaroon for the lighting signer node. */ + signerMacaroon: optional(t.string), + /** The wallet passphrase (used locally to decrypt and sign). */ + passphrase: t.string, +} as const; + +/** + * Response for Express Wallet Update + */ +export const ExpressWalletUpdateResponse = { + /** Updated Wallet */ + 200: t.UnknownRecord, + /** Bad Request */ + 400: BitgoExpressError, + /** Forbidden */ + 403: BitgoExpressError, + /** Not Found */ + 404: BitgoExpressError, +} as const; + +/** + * Express - Update Wallet + * The express update wallet route is meant to be used for lightning (lnbtc/tlnbtc). + * For other coins, use the standard wallet update endpoint. + * + * @operationId express.wallet.update + */ +export const PutExpressWalletUpdate = httpRoute({ + path: '/express/api/v2/{coin}/wallet/{id}', + method: 'PUT', + request: httpRequest({ + params: ExpressWalletUpdateParams, + body: ExpressWalletUpdateBody, + }), + response: ExpressWalletUpdateResponse, +}); diff --git a/modules/express/test/unit/clientRoutes/expressWallet.ts b/modules/express/test/unit/clientRoutes/expressWallet.ts new file mode 100644 index 0000000000..98d87afa53 --- /dev/null +++ b/modules/express/test/unit/clientRoutes/expressWallet.ts @@ -0,0 +1,74 @@ +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { BitGo } from 'bitgo'; +import { common, decodeOrElse } from '@bitgo/sdk-core'; +import nock from 'nock'; +import { ExpressApiRouteRequest } from '../../../src/typedRoutes/api'; +import { ExpressWalletUpdateResponse } from '../../../src/typedRoutes/api/v2/expressWalletUpdate'; +import { handleWalletUpdate } from '../../../src/clientRoutes'; +import { apiData } from './lightning/lightningSignerFixture'; + +describe('express.wallet.update (unit)', () => { + let bitgo: TestBitGoAPI; + let bgUrl: string; + + before(async function () { + if (!nock.isActive()) { + nock.activate(); + } + bitgo = TestBitGo.decorate(BitGo, { env: 'test' }); + bitgo.initializeTestVars(); + bgUrl = common.Environments[bitgo.getEnv()].uri; + nock.disableNetConnect(); + nock.enableNetConnect('127.0.0.1'); + }); + + after(() => { + if (nock.isActive()) { + nock.restore(); + } + }); + + it('decodes successful response', async () => { + const walletId = apiData.wallet.id; + const coin = apiData.wallet.coin; + const wpGet = nock(bgUrl) + .get(`/api/v2/${coin}/wallet/${walletId}`) + .query({ includeBalance: false }) + .reply(200, apiData.wallet); + const wpKeychainNocks = [ + nock(bgUrl).get(`/api/v2/${coin}/key/${apiData.userAuthKey.id}`).reply(200, apiData.userAuthKey), + nock(bgUrl).get(`/api/v2/${coin}/key/${apiData.nodeAuthKey.id}`).reply(200, apiData.nodeAuthKey), + ]; + const wpPut = nock(bgUrl) + .put(`/api/v2/${coin}/wallet/${walletId}`) + .reply(200, { id: walletId, label: 'updated', coinSpecific: {} }); + + const req = { + bitgo, + params: { coin: coin, id: walletId }, + body: { + signerHost: 'host.example', + signerTlsCert: 'cert', + signerMacaroon: 'mac and cheeze', + passphrase: apiData.initWalletRequestBody.passphrase, + }, + decoded: { + coin: coin, + id: walletId, + signerHost: 'host.example', + signerTlsCert: 'cert', + signerMacaroon: 'mac and cheeze', + passphrase: apiData.initWalletRequestBody.passphrase, + }, + } as unknown as ExpressApiRouteRequest<'express.wallet.update', 'put'>; + + const res = await handleWalletUpdate(req); + decodeOrElse('ExpressWalletUpdateResponse200', ExpressWalletUpdateResponse[200], res, (errors) => { + throw new Error(`Response did not match expected codec: ${errors}`); + }); + + wpPut.done(); + wpGet.done(); + wpKeychainNocks.forEach((s) => s.done()); + }); +}); From 44b48eb73b8519791a67f891ced8faaf10629479 Mon Sep 17 00:00:00 2001 From: danielzhao122 Date: Thu, 25 Sep 2025 15:41:55 -0400 Subject: [PATCH 2/4] refactor: handleUpdateLightningWalletCoinSpecific to work with api-ts TICKET: WP-5416 --- .../src/lightning/lightningWalletRoutes.ts | 26 +++----- .../lightning/lightningWalletRoutes.test.ts | 62 +++---------------- .../express/test/unit/typedRoutes/decode.ts | 50 +++++++++++++++ 3 files changed, 66 insertions(+), 72 deletions(-) diff --git a/modules/express/src/lightning/lightningWalletRoutes.ts b/modules/express/src/lightning/lightningWalletRoutes.ts index fbbe8f9f53..3de6f49f37 100644 --- a/modules/express/src/lightning/lightningWalletRoutes.ts +++ b/modules/express/src/lightning/lightningWalletRoutes.ts @@ -1,23 +1,13 @@ -import * as express from 'express'; -import { ApiResponseError } from '../errors'; -import { UpdateLightningWalletClientRequest, updateWalletCoinSpecific } from '@bitgo/abstract-lightning'; -import { decodeOrElse } from '@bitgo/sdk-core'; +import { updateWalletCoinSpecific } from '@bitgo/abstract-lightning'; +import { ExpressApiRouteRequest } from '../typedRoutes/api'; -export async function handleUpdateLightningWalletCoinSpecific(req: express.Request): Promise { +export async function handleUpdateLightningWalletCoinSpecific( + req: ExpressApiRouteRequest<'express.wallet.update', 'put'> +): Promise { const bitgo = req.bitgo; - const params = decodeOrElse( - 'UpdateLightningWalletClientRequest', - UpdateLightningWalletClientRequest, - req.body, - (_) => { - // DON'T throw errors from decodeOrElse. It could leak sensitive information. - throw new ApiResponseError('Invalid request body to update lightning wallet coin specific', 400); - } - ); + const coin = bitgo.coin(req.decoded.coin); + const wallet = await coin.wallets().get({ id: req.decoded.id, includeBalance: false }); - const coin = bitgo.coin(req.params.coin); - const wallet = await coin.wallets().get({ id: req.params.id, includeBalance: false }); - - return await updateWalletCoinSpecific(wallet, params); + return await updateWalletCoinSpecific(wallet, req.decoded); } diff --git a/modules/express/test/unit/lightning/lightningWalletRoutes.test.ts b/modules/express/test/unit/lightning/lightningWalletRoutes.test.ts index d6fd40f172..728d761564 100644 --- a/modules/express/test/unit/lightning/lightningWalletRoutes.test.ts +++ b/modules/express/test/unit/lightning/lightningWalletRoutes.test.ts @@ -1,23 +1,12 @@ import * as sinon from 'sinon'; import should from 'should'; -import * as express from 'express'; -import { handleUpdateLightningWalletCoinSpecific } from '../../../src/lightning/lightningWalletRoutes'; import { BitGo } from 'bitgo'; -import { ApiResponseError } from '../../../src/errors'; +import { ExpressApiRouteRequest } from '../../../src/typedRoutes/api'; describe('Lightning Wallet Routes', () => { let bitgo; const coin = 'tlnbtc'; - const mockRequestObject = (params: { body?: any; params?: any; query?: any; bitgo?: any }) => { - const req: Partial = {}; - req.body = params.body || {}; - req.params = params.params || {}; - req.query = params.query || {}; - req.bitgo = params.bitgo; - return req as express.Request; - }; - beforeEach(() => { const walletStub = {}; const coinStub = { @@ -53,11 +42,16 @@ describe('Lightning Wallet Routes', () => { }, }); - const req = mockRequestObject({ + const req = { params: { id: 'testWalletId', coin }, body: inputParams, + decoded: { + id: 'testWalletId', + coin, + ...inputParams, + }, bitgo, - }); + } as unknown as ExpressApiRouteRequest<'express.wallet.update', 'put'>; const result = await lightningRoutes.handleUpdateLightningWalletCoinSpecific(req); @@ -70,45 +64,5 @@ describe('Lightning Wallet Routes', () => { should(secondArg).have.property('signerHost', 'signer.example.com'); should(secondArg).have.property('passphrase', 'wallet-password-123'); }); - - it('should throw error when passphrase is missing', async () => { - const invalidParams = { - signerMacaroon: 'encrypted-data', - signerHost: 'signer.example.com', - }; - - const req = mockRequestObject({ - params: { id: 'testWalletId', coin }, - body: invalidParams, - bitgo, - }); - - await should(handleUpdateLightningWalletCoinSpecific(req)) - .be.rejectedWith(ApiResponseError) - .then((error) => { - should(error.status).equal(400); - should(error.message).equal('Invalid request body to update lightning wallet coin specific'); - }); - }); - - it('should handle invalid request body', async () => { - const invalidParams = { - signerHost: 12345, // invalid type - passphrase: 'valid-pass', - }; - - const req = mockRequestObject({ - params: { id: 'testWalletId', coin }, - body: invalidParams, - bitgo, - }); - - await should(handleUpdateLightningWalletCoinSpecific(req)) - .be.rejectedWith(ApiResponseError) - .then((error) => { - should(error.status).equal(400); - should(error.message).equal('Invalid request body to update lightning wallet coin specific'); - }); - }); }); }); diff --git a/modules/express/test/unit/typedRoutes/decode.ts b/modules/express/test/unit/typedRoutes/decode.ts index 7197997358..0859c5aa5b 100644 --- a/modules/express/test/unit/typedRoutes/decode.ts +++ b/modules/express/test/unit/typedRoutes/decode.ts @@ -15,6 +15,10 @@ 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 { + ExpressWalletUpdateBody, + ExpressWalletUpdateParams, +} from '../../../src/typedRoutes/api/v2/expressWalletUpdate'; export function assertDecode(codec: t.Type, input: unknown): T { const result = codec.decode(input); @@ -243,4 +247,50 @@ describe('io-ts decode tests', function () { assertDecode(t.type(CreateAddressBody), { eip1559: { maxFeePerGas: 1, maxPriorityFeePerGas: 1 } }); assertDecode(t.type(CreateAddressBody), {}); }); + it('express.wallet.update', function () { + // missing coin + assert.throws(() => assertDecode(t.type(ExpressWalletUpdateParams), { id: 'wallet123' })); + // missing id + assert.throws(() => assertDecode(t.type(ExpressWalletUpdateParams), { coin: 'tlnbtc' })); + // missing required fields + assert.throws(() => assertDecode(t.type(ExpressWalletUpdateBody), {})); + // signerHost must be string + assert.throws(() => + assertDecode(t.type(ExpressWalletUpdateBody), { + signerHost: 123, + signerTlsCert: 'cert', + passphrase: 'p', + }) + ); + // signerTlsCert must be string + assert.throws(() => + assertDecode(t.type(ExpressWalletUpdateBody), { + signerHost: 'host.example', + signerTlsCert: 456, + passphrase: 'p', + }) + ); + // passphrase must be string and required + assert.throws(() => + assertDecode(t.type(ExpressWalletUpdateBody), { + signerHost: 'host.example', + signerTlsCert: 'cert', + }) + ); + // valid minimal + assertDecode(t.type(ExpressWalletUpdateBody), { + signerHost: 'host.example', + signerTlsCert: 'cert', + passphrase: 'p', + }); + // valid + assertDecode(t.type(ExpressWalletUpdateParams), { coin: 'tlnbtc', id: 'wallet123' }); + // valid with optional signerMacaroon + assertDecode(t.type(ExpressWalletUpdateBody), { + signerHost: 'host.example', + signerTlsCert: 'cert', + passphrase: 'p', + signerMacaroon: 'mac', + }); + }); }); From ba305af344628753ac61e31d1e1e92c3e6b95799 Mon Sep 17 00:00:00 2001 From: danielzhao122 Date: Fri, 17 Oct 2025 10:08:22 -0400 Subject: [PATCH 3/4] test(express): add supertests for wallet update ticket: WP-5416 TICKET: WP-5416 --- .../unit/typedRoutes/expressWalletUpdate.ts | 405 ++++++++++++++++++ 1 file changed, 405 insertions(+) create mode 100644 modules/express/test/unit/typedRoutes/expressWalletUpdate.ts diff --git a/modules/express/test/unit/typedRoutes/expressWalletUpdate.ts b/modules/express/test/unit/typedRoutes/expressWalletUpdate.ts new file mode 100644 index 0000000000..3f3ec8e872 --- /dev/null +++ b/modules/express/test/unit/typedRoutes/expressWalletUpdate.ts @@ -0,0 +1,405 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { agent as supertest } from 'supertest'; +import 'should'; +import 'should-http'; +import 'should-sinon'; +import '../../lib/asserts'; +import { BitGo } from 'bitgo'; +import { app } from '../../../src/expressApp'; +import { DefaultConfig } from '../../../src/config'; +import { PutExpressWalletUpdate } from '../../../src/typedRoutes/api/v2/expressWalletUpdate'; + +describe('Express Wallet Update Typed Routes Tests', function () { + let agent: ReturnType; + + before(function () { + const testApp = app(DefaultConfig); + agent = supertest(testApp); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('Success Cases', function () { + it('should successfully update lightning wallet with signer details', async function () { + const coin = 'tlnbtc'; + const walletId = 'lightningWallet123'; + const signerHost = 'https://signer.example.com'; + const signerTlsCert = 'base64encodedcert=='; + const signerMacaroon = 'base64encodedmacaroon=='; + const passphrase = 'MyWalletPassphrase123'; + + const updateResponse = { + id: walletId, + coin, + coinSpecific: { + [coin]: { + signerHost, + signerTlsCert, + }, + }, + }; + + // Stub bitgo.put() for lightning update + const putStub = sinon.stub().returns({ + send: sinon.stub().returns({ + result: sinon.stub().resolves(updateResponse), + }), + }); + + // Mock keychains for auth keys + const userAuthKey = { + id: 'userAuthKeyId123', + pub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', + encryptedPrv: 'encryptedUserAuthKey', + source: 'user' as const, + coinSpecific: { + [coin]: { + purpose: 'userAuth', + }, + }, + }; + const nodeAuthKey = { + id: 'nodeAuthKeyId456', + pub: 'xpub661MyMwAqRbcGczjuMoRm6dXaLDEhW1u34gKenbeYqAix21mdUKJyuyu5F1rzYGVxyL6tmgBUAEPrEz92mBXjByMRiJdba9wpnN37RLLAXa', + encryptedPrv: 'encryptedNodeAuthKey', + source: 'user' as const, + coinSpecific: { + [coin]: { + purpose: 'nodeAuth', + }, + }, + }; + + const keychainsGetStub = sinon.stub(); + keychainsGetStub.withArgs({ id: userAuthKey.id }).resolves(userAuthKey); + keychainsGetStub.withArgs({ id: nodeAuthKey.id }).resolves(nodeAuthKey); + + const keychainsStub = { + get: keychainsGetStub, + } as any; + + const baseCoinStub = { + getFamily: sinon.stub().returns('lnbtc'), + getChain: sinon.stub().returns(coin), + keychains: sinon.stub().returns(keychainsStub), + } as any; + + // Mock the wallet with necessary methods + const walletStub = { + url: sinon.stub().returns(`/api/v2/${coin}/wallet/${walletId}`), + coin: sinon.stub().returns(coin), + subType: sinon.stub().returns('lightningSelfCustody'), + baseCoin: baseCoinStub, + coinSpecific: sinon.stub().returns({ + keys: [userAuthKey.id, nodeAuthKey.id], + }), + bitgo: { + decrypt: sinon + .stub() + .returns( + 'xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi' + ), + encrypt: sinon.stub().callsFake(({ input }: { input: string }) => `encrypted_${input}`), + put: putStub, + }, + } 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, 'put').callsFake(putStub as any); + + const res = await agent.put(`/express/api/v2/${coin}/wallet/${walletId}`).send({ + signerHost, + signerTlsCert, + signerMacaroon, + passphrase, + }); + + res.status.should.equal(200); + res.body.should.have.property('id', walletId); + res.body.should.have.property('coin', coin); + getWalletStub.should.have.been.calledOnceWith({ id: walletId, includeBalance: false }); + }); + + it('should successfully update lightning wallet on mainnet', async function () { + const coin = 'lnbtc'; + const walletId = 'lightningWallet456'; + const signerHost = 'https://mainnet-signer.example.com'; + const signerTlsCert = 'mainnetCert=='; + const signerMacaroon = 'mainnetMacaroon=='; + const passphrase = 'SecurePassphrase456'; + + const updateResponse = { + id: walletId, + coin, + coinSpecific: { + [coin]: { + signerHost, + signerTlsCert, + }, + }, + }; + + const putStub = sinon.stub().returns({ + send: sinon.stub().returns({ + result: sinon.stub().resolves(updateResponse), + }), + }); + + const userAuthKey = { + id: 'userAuthKeyMainnet', + pub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', + encryptedPrv: 'encryptedMainnetKey', + source: 'user' as const, + coinSpecific: { + [coin]: { + purpose: 'userAuth', + }, + }, + }; + const nodeAuthKey = { + id: 'nodeAuthKeyMainnet', + pub: 'xpub661MyMwAqRbcGczjuMoRm6dXaLDEhW1u34gKenbeYqAix21mdUKJyuyu5F1rzYGVxyL6tmgBUAEPrEz92mBXjByMRiJdba9wpnN37RLLAXa', + encryptedPrv: 'encryptedMainnetNodeKey', + source: 'user' as const, + coinSpecific: { + [coin]: { + purpose: 'nodeAuth', + }, + }, + }; + + const keychainsGetStub = sinon.stub(); + keychainsGetStub.withArgs({ id: userAuthKey.id }).resolves(userAuthKey); + keychainsGetStub.withArgs({ id: nodeAuthKey.id }).resolves(nodeAuthKey); + + const keychainsStub = { + get: keychainsGetStub, + } as any; + + const baseCoinStub = { + getFamily: sinon.stub().returns('lnbtc'), + getChain: sinon.stub().returns(coin), + keychains: sinon.stub().returns(keychainsStub), + } as any; + + const walletStub = { + url: sinon.stub().returns(`/api/v2/${coin}/wallet/${walletId}`), + coin: sinon.stub().returns(coin), + subType: sinon.stub().returns('lightningSelfCustody'), + baseCoin: baseCoinStub, + coinSpecific: sinon.stub().returns({ + keys: [userAuthKey.id, nodeAuthKey.id], + }), + bitgo: { + decrypt: sinon + .stub() + .returns( + 'xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi' + ), + encrypt: sinon.stub().callsFake(({ input }: { input: string }) => `encrypted_${input}`), + put: putStub, + }, + } 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, 'put').callsFake(putStub as any); + + const res = await agent.put(`/express/api/v2/${coin}/wallet/${walletId}`).send({ + signerHost, + signerTlsCert, + signerMacaroon, + passphrase, + }); + + res.status.should.equal(200); + res.body.should.have.property('id', walletId); + }); + }); + + describe('Error Cases', function () { + it('should return 500 when bitgo.put fails', async function () { + const coin = 'tlnbtc'; + const walletId = 'wallet123'; + + const putStub = sinon.stub().returns({ + send: sinon.stub().returns({ + result: sinon.stub().rejects(new Error('API error')), + }), + }); + + const walletStub = { + url: sinon.stub().returns(`/api/v2/${coin}/wallet/${walletId}`), + coin: sinon.stub().returns(coin), + subType: sinon.stub().returns('lightningSelfCustody'), + bitgo: { + decrypt: sinon.stub().returns('decryptedPrivateKey'), + put: putStub, + }, + keys: ['userAuthKeyId', 'backupKeyId', 'nodeAuthKeyId'], + } as any; + + const getWalletStub = sinon.stub().resolves(walletStub); + const walletsStub = { get: getWalletStub } as any; + + const keychainsGetStub = sinon.stub().resolves({ + id: 'keyId', + encryptedPrv: 'encryptedPrivateKey', + }); + + const keychainsStub = { + get: keychainsGetStub, + } 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, 'put').callsFake(putStub as any); + + const res = await agent.put(`/express/api/v2/${coin}/wallet/${walletId}`).send({ + signerHost: 'https://signer.example.com', + signerTlsCert: 'cert', + passphrase: 'password', + }); + + res.status.should.equal(500); + res.body.should.have.property('error'); + }); + + it('should return 400 when signerHost is missing', async function () { + const coin = 'tlnbtc'; + const walletId = 'wallet123'; + + const res = await agent.put(`/express/api/v2/${coin}/wallet/${walletId}`).send({ + // signerHost missing + signerTlsCert: 'cert', + passphrase: 'password', + }); + + res.status.should.equal(400); + res.body.should.be.an.Array(); + res.body[0].should.match(/signerHost/); + }); + + it('should return 400 when signerTlsCert is missing', async function () { + const coin = 'tlnbtc'; + const walletId = 'wallet123'; + + const res = await agent.put(`/express/api/v2/${coin}/wallet/${walletId}`).send({ + signerHost: 'https://signer.example.com', + // signerTlsCert missing + passphrase: 'password', + }); + + res.status.should.equal(400); + res.body.should.be.an.Array(); + res.body[0].should.match(/signerTlsCert/); + }); + + it('should return 400 when passphrase is missing', async function () { + const coin = 'tlnbtc'; + const walletId = 'wallet123'; + + const res = await agent.put(`/express/api/v2/${coin}/wallet/${walletId}`).send({ + signerHost: 'https://signer.example.com', + signerTlsCert: 'cert', + // passphrase missing + }); + + res.status.should.equal(400); + res.body.should.be.an.Array(); + res.body[0].should.match(/passphrase/); + }); + + it('should return 400 when signerHost has invalid type', async function () { + const coin = 'tlnbtc'; + const walletId = 'wallet123'; + + const res = await agent.put(`/express/api/v2/${coin}/wallet/${walletId}`).send({ + signerHost: 123, // should be string + signerTlsCert: 'cert', + passphrase: 'password', + }); + + res.status.should.equal(400); + res.body.should.be.an.Array(); + res.body[0].should.match(/signerHost/); + }); + + it('should return 400 when signerTlsCert has invalid type', async function () { + const coin = 'tlnbtc'; + const walletId = 'wallet123'; + + const res = await agent.put(`/express/api/v2/${coin}/wallet/${walletId}`).send({ + signerHost: 'https://signer.example.com', + signerTlsCert: 123, // should be string + passphrase: 'password', + }); + + res.status.should.equal(400); + res.body.should.be.an.Array(); + res.body[0].should.match(/signerTlsCert/); + }); + + it('should return 400 when passphrase has invalid type', async function () { + const coin = 'tlnbtc'; + const walletId = 'wallet123'; + + const res = await agent.put(`/express/api/v2/${coin}/wallet/${walletId}`).send({ + signerHost: 'https://signer.example.com', + signerTlsCert: 'cert', + passphrase: 123, // should be string + }); + + res.status.should.equal(400); + res.body.should.be.an.Array(); + res.body[0].should.match(/passphrase/); + }); + + it('should return 400 when signerMacaroon has invalid type', async function () { + const coin = 'tlnbtc'; + const walletId = 'wallet123'; + + const res = await agent.put(`/express/api/v2/${coin}/wallet/${walletId}`).send({ + signerHost: 'https://signer.example.com', + signerTlsCert: 'cert', + signerMacaroon: { invalid: 'object' }, // should be string + passphrase: 'password', + }); + + res.status.should.equal(400); + res.body.should.be.an.Array(); + res.body[0].should.match(/signerMacaroon/); + }); + }); + + describe('Route Definition', function () { + it('should have correct route configuration', function () { + assert.strictEqual(PutExpressWalletUpdate.method, 'PUT'); + assert.strictEqual(PutExpressWalletUpdate.path, '/express/api/v2/{coin}/wallet/{id}'); + assert.ok(PutExpressWalletUpdate.request); + assert.ok(PutExpressWalletUpdate.response); + }); + }); +}); From 362b0f60c7316a1aba06e187b7df36900b1c00f4 Mon Sep 17 00:00:00 2001 From: danielzhao122 Date: Fri, 17 Oct 2025 11:45:34 -0400 Subject: [PATCH 4/4] refactor: fleshed out the response codec and updated tests to reflect this TICKET: WP-5416 --- .../typedRoutes/api/v2/expressWalletUpdate.ts | 11 +- .../express/src/typedRoutes/schemas/wallet.ts | 226 ++++++++++++++++++ .../test/unit/clientRoutes/expressWallet.ts | 18 +- .../unit/typedRoutes/expressWalletUpdate.ts | 24 ++ 4 files changed, 273 insertions(+), 6 deletions(-) create mode 100644 modules/express/src/typedRoutes/schemas/wallet.ts diff --git a/modules/express/src/typedRoutes/api/v2/expressWalletUpdate.ts b/modules/express/src/typedRoutes/api/v2/expressWalletUpdate.ts index 6a7eda0ca8..c3119eeac5 100644 --- a/modules/express/src/typedRoutes/api/v2/expressWalletUpdate.ts +++ b/modules/express/src/typedRoutes/api/v2/expressWalletUpdate.ts @@ -1,6 +1,7 @@ import * as t from 'io-ts'; import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; import { BitgoExpressError } from '../../schemas/error'; +import { WalletResponse } from '../../schemas/wallet'; /** * Parameters for Express Wallet Update @@ -30,13 +31,13 @@ export const ExpressWalletUpdateBody = { * Response for Express Wallet Update */ export const ExpressWalletUpdateResponse = { - /** Updated Wallet */ - 200: t.UnknownRecord, - /** Bad Request */ + /** Updated Wallet - Returns the wallet with updated Lightning signer configuration */ + 200: WalletResponse, + /** Bad Request - Invalid parameters or missing required fields */ 400: BitgoExpressError, - /** Forbidden */ + /** Forbidden - Insufficient permissions to update the wallet */ 403: BitgoExpressError, - /** Not Found */ + /** Not Found - Wallet not found or invalid coin type */ 404: BitgoExpressError, } as const; diff --git a/modules/express/src/typedRoutes/schemas/wallet.ts b/modules/express/src/typedRoutes/schemas/wallet.ts new file mode 100644 index 0000000000..899a45a1e2 --- /dev/null +++ b/modules/express/src/typedRoutes/schemas/wallet.ts @@ -0,0 +1,226 @@ +import * as t from 'io-ts'; + +/** + * Wallet user with permissions + */ +export const WalletUser = t.type({ + user: t.string, + permissions: t.array(t.string), +}); + +/** + * Address balance information + */ +export const AddressBalance = t.type({ + updated: t.string, + balance: t.number, + balanceString: t.string, + totalReceived: t.number, + totalSent: t.number, + confirmedBalanceString: t.string, + spendableBalanceString: t.string, +}); + +/** + * Address information + */ +export const ReceiveAddress = t.partial({ + /** Address ID */ + id: t.string, + /** The actual address string */ + address: t.string, + /** Chain index (0 for external, 1 for internal) */ + chain: t.number, + /** Address index */ + index: t.number, + /** Coin type */ + coin: t.string, + /** Wallet ID this address belongs to */ + wallet: t.string, + /** Last nonce used */ + lastNonce: t.number, + /** Coin-specific address data */ + coinSpecific: t.UnknownRecord, + /** Address balance information */ + balance: AddressBalance, + /** Address label */ + label: t.string, + /** Address type (e.g., 'p2sh', 'p2wsh') */ + addressType: t.string, +}); + +/** + * Policy rule for wallet + */ +export const PolicyRule = t.partial({ + /** Rule ID */ + id: t.string, + /** Rule type */ + type: t.string, + /** Date when rule becomes locked */ + lockDate: t.string, + /** Mutability constraint */ + mutabilityConstraint: t.string, + /** Coin this rule applies to */ + coin: t.string, + /** Rule condition */ + condition: t.UnknownRecord, + /** Rule action */ + action: t.UnknownRecord, +}); + +/** + * Wallet policy + */ +export const WalletPolicy = t.partial({ + /** Policy ID */ + id: t.string, + /** Policy creation date */ + date: t.string, + /** Policy version number */ + version: t.number, + /** Policy label */ + label: t.string, + /** Whether this is the latest version */ + latest: t.boolean, + /** Policy rules */ + rules: t.array(PolicyRule), +}); + +/** + * Admin settings for wallet + */ +export const WalletAdmin = t.partial({ + policy: WalletPolicy, +}); + +/** + * Freeze information + */ +export const WalletFreeze = t.partial({ + time: t.string, + expires: t.string, +}); + +/** + * Build defaults for wallet transactions + */ +export const BuildDefaults = t.partial({ + minFeeRate: t.number, + maxFeeRate: t.number, + feeMultiplier: t.number, + changeAddressType: t.string, + txFormat: t.string, +}); + +/** + * Custom change key signatures + */ +export const CustomChangeKeySignatures = t.partial({ + user: t.string, + backup: t.string, + bitgo: t.string, +}); + +/** + * Wallet response data + * Comprehensive wallet information returned from wallet operations + * Based on WalletData interface from sdk-core + */ +export const WalletResponse = t.partial({ + /** Wallet ID */ + id: t.string, + /** Wallet label/name */ + label: t.string, + /** Coin type (e.g., btc, tlnbtc, lnbtc) */ + coin: t.string, + /** Array of keychain IDs */ + keys: t.array(t.string), + /** Number of signatures required (m in m-of-n) */ + m: t.number, + /** Total number of keys (n in m-of-n) */ + n: t.number, + /** Number of approvals required for transactions */ + approvalsRequired: t.number, + /** Wallet balance as number */ + balance: t.number, + /** Confirmed balance as number */ + confirmedBalance: t.number, + /** Spendable balance as number */ + spendableBalance: t.number, + /** Wallet balance as string */ + balanceString: t.string, + /** Confirmed balance as string */ + confirmedBalanceString: t.string, + /** Spendable balance as string */ + spendableBalanceString: t.string, + /** Number of unspent outputs */ + unspentCount: t.number, + /** Enterprise ID this wallet belongs to */ + enterprise: t.string, + /** Wallet type (e.g., 'hot', 'cold', 'custodial') */ + type: t.string, + /** Wallet subtype (e.g., 'lightningSelfCustody') */ + subType: t.string, + /** Multisig type ('onchain' or 'tss') */ + multisigType: t.union([t.literal('onchain'), t.literal('tss')]), + /** Multisig type version (e.g., 'MPCv2') */ + multisigTypeVersion: t.string, + /** Coin-specific wallet data */ + coinSpecific: t.UnknownRecord, + /** Admin settings including policy */ + admin: WalletAdmin, + /** Users with access to this wallet */ + users: t.array(WalletUser), + /** Receive address information */ + receiveAddress: ReceiveAddress, + /** Whether the wallet can be recovered */ + recoverable: t.boolean, + /** Tags associated with the wallet */ + tags: t.array(t.string), + /** Whether backup key signing is allowed */ + allowBackupKeySigning: t.boolean, + /** Build defaults for transactions */ + buildDefaults: BuildDefaults, + /** Whether the wallet is cold storage */ + isCold: t.boolean, + /** Custodial wallet information */ + custodialWallet: t.UnknownRecord, + /** Custodial wallet ID */ + custodialWalletId: t.string, + /** Whether the wallet is deleted */ + deleted: t.boolean, + /** Whether transaction notifications are disabled */ + disableTransactionNotifications: t.boolean, + /** Freeze status */ + freeze: WalletFreeze, + /** Node ID for lightning wallets */ + nodeId: t.string, + /** Pending approvals for this wallet */ + pendingApprovals: t.array(t.UnknownRecord), + /** Start date information */ + startDate: t.UnknownRecord, + /** Custom change key signatures */ + customChangeKeySignatures: CustomChangeKeySignatures, + /** Wallet which this was migrated from */ + migratedFrom: t.string, + /** EVM keyring reference wallet ID */ + evmKeyRingReferenceWalletId: t.string, + /** Whether this is a parent wallet */ + isParent: t.boolean, + /** Enabled child chains */ + enabledChildChains: t.array(t.string), + /** Wallet flags */ + walletFlags: t.array( + t.type({ + name: t.string, + value: t.string, + }) + ), + /** Token balances */ + tokens: t.array(t.UnknownRecord), + /** NFT balances */ + nfts: t.record(t.string, t.UnknownRecord), + /** Unsupported NFT balances */ + unsupportedNfts: t.record(t.string, t.UnknownRecord), +}); diff --git a/modules/express/test/unit/clientRoutes/expressWallet.ts b/modules/express/test/unit/clientRoutes/expressWallet.ts index 98d87afa53..03b66bf1b5 100644 --- a/modules/express/test/unit/clientRoutes/expressWallet.ts +++ b/modules/express/test/unit/clientRoutes/expressWallet.ts @@ -41,7 +41,23 @@ describe('express.wallet.update (unit)', () => { ]; const wpPut = nock(bgUrl) .put(`/api/v2/${coin}/wallet/${walletId}`) - .reply(200, { id: walletId, label: 'updated', coinSpecific: {} }); + .reply(200, { + id: walletId, + label: 'updated', + coin: coin, + keys: ['key1', 'key2', 'key3'], + approvalsRequired: 1, + balance: 0, + confirmedBalance: 0, + spendableBalance: 0, + balanceString: '0', + confirmedBalanceString: '0', + spendableBalanceString: '0', + enterprise: 'testEnterprise', + multisigType: 'tss', + coinSpecific: {}, + pendingApprovals: [], + }); const req = { bitgo, diff --git a/modules/express/test/unit/typedRoutes/expressWalletUpdate.ts b/modules/express/test/unit/typedRoutes/expressWalletUpdate.ts index 3f3ec8e872..50909a341c 100644 --- a/modules/express/test/unit/typedRoutes/expressWalletUpdate.ts +++ b/modules/express/test/unit/typedRoutes/expressWalletUpdate.ts @@ -33,13 +33,25 @@ describe('Express Wallet Update Typed Routes Tests', function () { const updateResponse = { id: walletId, + label: 'Test Lightning Wallet', coin, + keys: ['key1', 'key2', 'key3'], + approvalsRequired: 1, + balance: 1000000, + confirmedBalance: 1000000, + spendableBalance: 1000000, + balanceString: '1000000', + confirmedBalanceString: '1000000', + spendableBalanceString: '1000000', + enterprise: 'enterprise123', + multisigType: 'tss' as const, coinSpecific: { [coin]: { signerHost, signerTlsCert, }, }, + pendingApprovals: [], }; // Stub bitgo.put() for lightning update @@ -141,13 +153,25 @@ describe('Express Wallet Update Typed Routes Tests', function () { const updateResponse = { id: walletId, + label: 'Mainnet Lightning Wallet', coin, + keys: ['mainnetKey1', 'mainnetKey2', 'mainnetKey3'], + approvalsRequired: 1, + balance: 5000000, + confirmedBalance: 5000000, + spendableBalance: 5000000, + balanceString: '5000000', + confirmedBalanceString: '5000000', + spendableBalanceString: '5000000', + enterprise: 'mainnetEnterprise456', + multisigType: 'tss' as const, coinSpecific: { [coin]: { signerHost, signerTlsCert, }, }, + pendingApprovals: [], }; const putStub = sinon.stub().returns({