From 1c4aac01c6777d79bbeb276587ce5c7d5214f421 Mon Sep 17 00:00:00 2001 From: Support Bot Date: Tue, 28 Jul 2026 09:52:28 +0000 Subject: [PATCH] feat(express): add cancel wallet share express route Add a typed DELETE /api/v2/{coin}/walletshare/{id} route to the BitGo Express server so callers can cancel outgoing wallet share requests. The SDK's Wallets.cancelShare() already issued the correct HTTP DELETE to the BitGo API, but BitGo Express had no handler for this endpoint. The accept-share endpoint had a similar gap that was addressed with app.post(); this change adds a first-class typed route following the same pattern as shareWallet. Changes: - modules/express/src/typedRoutes/api/v2/cancelWalletShare.ts: new typed route definition (params, response codecs, httpRoute export) - modules/express/src/typedRoutes/api/index.ts: register DeleteCancelWalletShare under 'express.wallet.cancelShare' inside ExpressWalletManagementApiSpec - modules/express/src/clientRoutes.ts: export handleV2CancelWalletShare handler and register it with router.delete - modules/express/test/unit/clientRoutes/cancelWalletShare.ts: unit tests for the new handler - modules/bitgo/test/v2/unit/wallets.ts: unit tests for Wallets.cancelShare() (DELETE /walletshare/:id) Ticket: WCI-1164 Session-Id: 39708184-a685-4405-b0cf-0c296a1bf0d3 Task-Id: 7c07317a-7a0a-4ead-8e23-6f96f60d1755 --- modules/bitgo/test/v2/unit/wallets.ts | 28 ++++++ modules/express/src/clientRoutes.ts | 10 ++ modules/express/src/typedRoutes/api/index.ts | 4 + .../typedRoutes/api/v2/cancelWalletShare.ts | 47 ++++++++++ .../unit/clientRoutes/cancelWalletShare.ts | 91 +++++++++++++++++++ 5 files changed, 180 insertions(+) create mode 100644 modules/express/src/typedRoutes/api/v2/cancelWalletShare.ts create mode 100644 modules/express/test/unit/clientRoutes/cancelWalletShare.ts diff --git a/modules/bitgo/test/v2/unit/wallets.ts b/modules/bitgo/test/v2/unit/wallets.ts index c9e2c3999f..faeb536d5b 100644 --- a/modules/bitgo/test/v2/unit/wallets.ts +++ b/modules/bitgo/test/v2/unit/wallets.ts @@ -4440,6 +4440,34 @@ describe('V2 Wallets:', function () { }); }); + describe('cancelShare', function () { + it('should send DELETE to /walletshare/:id and return the result', async function () { + const bitgo = TestBitGo.decorate(BitGo, { env: 'mock' }); + bitgo.initializeTestVars(); + const basecoin = bitgo.coin('tbtc'); + const wallets = basecoin.wallets(); + const bgUrl = common.Environments[bitgo.getEnv()].uri; + const shareId = 'abc123shareId'; + + nock(bgUrl) + .delete(`/api/v2/tbtc/walletshare/${shareId}`) + .reply(200, { changed: true, state: 'canceled' }); + + const result = await wallets.cancelShare({ walletShareId: shareId }); + result.should.have.property('changed', true); + result.should.have.property('state', 'canceled'); + }); + + it('should throw if walletShareId is missing', async function () { + const bitgo = TestBitGo.decorate(BitGo, { env: 'mock' }); + bitgo.initializeTestVars(); + const basecoin = bitgo.coin('tbtc'); + const wallets = basecoin.wallets(); + + await wallets.cancelShare({}).should.be.rejectedWith('walletShareId must be a string'); + }); + }); + describe('List Wallets:', function () { it('should list wallets with skipReceiveAddress = true', async function () { const bitgo = TestBitGo.decorate(BitGo, { env: 'mock' }); diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index dd5fdc99a2..dc36b9195e 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -862,6 +862,15 @@ async function handleV2AcceptWalletShare(req: express.Request) { return coin.wallets().acceptShare(params); } +/** + * handle cancel wallet share + */ +export async function handleV2CancelWalletShare(req: ExpressApiRouteRequest<'express.wallet.cancelShare', 'delete'>) { + const bitgo = req.bitgo; + const coin = bitgo.coin(req.decoded.coin); + return coin.wallets().cancelShare({ walletShareId: req.decoded.id }); +} + /** * handle wallet sign transaction */ @@ -2060,6 +2069,7 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { router.post('express.v2.address.derive', [prepareBitGo(config), typedPromiseWrapper(handleV2DeriveAddress)]); router.post('express.wallet.share', [prepareBitGo(config), typedPromiseWrapper(handleV2ShareWallet)]); + router.delete('express.wallet.cancelShare', [prepareBitGo(config), typedPromiseWrapper(handleV2CancelWalletShare)]); app.post( '/api/v2/:coin/walletshare/:id/acceptshare', parseBody, diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index 85adc8a2ea..83d694f525 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -40,6 +40,7 @@ import { PostCoinSignTx } from './v2/coinSignTx'; import { PostWalletSignTx } from './v2/walletSignTx'; import { PostWalletTxSignTSS } from './v2/walletTxSignTSS'; import { PostShareWallet } from './v2/shareWallet'; +import { DeleteCancelWalletShare } from './v2/cancelWalletShare'; import { PutExpressWalletUpdate } from './v2/expressWalletUpdate'; import { PostFanoutUnspents } from './v2/fanoutUnspents'; import { PostSendMany } from './v2/sendmany'; @@ -345,6 +346,9 @@ export const ExpressWalletManagementApiSpec = apiSpec({ 'express.wallet.share': { post: PostShareWallet, }, + 'express.wallet.cancelShare': { + delete: DeleteCancelWalletShare, + }, 'express.wallet.update': { put: PutExpressWalletUpdate, }, diff --git a/modules/express/src/typedRoutes/api/v2/cancelWalletShare.ts b/modules/express/src/typedRoutes/api/v2/cancelWalletShare.ts new file mode 100644 index 0000000000..4f8d03ce34 --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/cancelWalletShare.ts @@ -0,0 +1,47 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest } from '@api-ts/io-ts-http'; +import { BitgoExpressError } from '../../schemas/error'; +import { ShareState } from '../../schemas/wallet'; + +/** + * Path parameters for canceling a wallet share + */ +export const CancelWalletShareParams = { + /** A cryptocurrency or token ticker symbol. */ + coin: t.string, + /** The wallet share ID to cancel. */ + id: t.string, +} as const; + +/** + * Response for canceling a wallet share + */ +export const CancelWalletShareResponse200 = t.type({ + /** Whether the share state was changed by this operation. */ + changed: t.boolean, + /** Current state of the wallet share after the operation. */ + state: ShareState, +}); + +export const CancelWalletShareResponse = { + 200: CancelWalletShareResponse200, + 400: BitgoExpressError, +} as const; + +/** + * Cancel a pending wallet share invitation + * + * Cancels an outgoing wallet share that has not yet been accepted. + * Only the user who created the share can cancel it. + * + * @operationId express.wallet.cancelShare + * @tag Express + */ +export const DeleteCancelWalletShare = httpRoute({ + path: '/api/v2/{coin}/walletshare/{id}', + method: 'DELETE', + request: httpRequest({ + params: CancelWalletShareParams, + }), + response: CancelWalletShareResponse, +}); diff --git a/modules/express/test/unit/clientRoutes/cancelWalletShare.ts b/modules/express/test/unit/clientRoutes/cancelWalletShare.ts new file mode 100644 index 0000000000..8a0e969e20 --- /dev/null +++ b/modules/express/test/unit/clientRoutes/cancelWalletShare.ts @@ -0,0 +1,91 @@ +import * as sinon from 'sinon'; +import 'should-http'; +import 'should-sinon'; +import '../../lib/asserts'; +import nock from 'nock'; +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { BitGo } from 'bitgo'; +import { BaseCoin, Wallets, decodeOrElse, common } from '@bitgo/sdk-core'; +import { ExpressApiRouteRequest } from '../../../src/typedRoutes/api'; +import { handleV2CancelWalletShare } from '../../../src/clientRoutes'; +import { CancelWalletShareResponse } from '../../../src/typedRoutes/api/v2/cancelWalletShare'; + +describe('Cancel Wallet Share (typed handler)', () => { + let bitgo: TestBitGoAPI; + + before(async function () { + if (!nock.isActive()) { + nock.activate(); + } + bitgo = TestBitGo.decorate(BitGo, { env: 'test' }); + bitgo.initializeTestVars(); + nock.disableNetConnect(); + nock.enableNetConnect('127.0.0.1'); + }); + + after(() => { + if (nock.isActive()) { + nock.restore(); + } + }); + + it('should call cancelShare and return a typed response', async () => { + const coin = 'tbtc'; + const shareId = 'abc123shareId'; + + const cancelResponse = { + changed: true, + state: 'canceled', + }; + + const cancelShareStub = sinon.stub().resolves(cancelResponse); + const coinStub = sinon.createStubInstance(BaseCoin, { + wallets: sinon.stub<[], Wallets>().returns({ + cancelShare: cancelShareStub, + } as any), + }); + + const stubBitgo = sinon.createStubInstance(BitGo, { coin: sinon.stub<[string]>().returns(coinStub) }); + + const req = { + bitgo: stubBitgo, + decoded: { + coin, + id: shareId, + }, + } as unknown as ExpressApiRouteRequest<'express.wallet.cancelShare', 'delete'>; + + const res = await handleV2CancelWalletShare(req); + + cancelShareStub.calledOnceWith({ walletShareId: shareId }).should.be.true(); + decodeOrElse('CancelWalletShareResponse200', CancelWalletShareResponse[200], res, (errors) => { + throw new Error(`Response did not match expected codec: ${errors}`); + }); + }); + + it('should pass the walletShareId from the route param to cancelShare', async () => { + const coin = 'tbtc'; + const shareId = 'someOtherShareId'; + + const cancelShareStub = sinon.stub().resolves({ changed: false, state: 'canceled' }); + const coinStub = sinon.createStubInstance(BaseCoin, { + wallets: sinon.stub<[], Wallets>().returns({ + cancelShare: cancelShareStub, + } as any), + }); + + const stubBitgo = sinon.createStubInstance(BitGo, { coin: sinon.stub<[string]>().returns(coinStub) }); + + const req = { + bitgo: stubBitgo, + decoded: { + coin, + id: shareId, + }, + } as unknown as ExpressApiRouteRequest<'express.wallet.cancelShare', 'delete'>; + + await handleV2CancelWalletShare(req); + + cancelShareStub.calledOnceWith({ walletShareId: shareId }).should.be.true(); + }); +});