From c3d88f5918bcd7b9af96d625a2e4ddec18b79736 Mon Sep 17 00:00:00 2001 From: Support Bot Date: Wed, 16 Sep 2026 09:15:32 +0000 Subject: [PATCH] fix(pending-approval): cancel pending approvals Route cancellation requests to the pending approval cancel API and send the canceled state from both SDK implementations. Extend the V2 response contract and add endpoint and SDK coverage. This preserves rejection semantics while allowing pending transactions to be canceled explicitly instead of being reported as rejected. Ticket: WCI-1631 Session-Id: 8aad1ca5-334f-4dee-a4e7-bd8cd69a92e3 Task-Id: c637ca13-8d29-4521-b341-6bf9a94fbc44 --- modules/bitgo/test/v2/unit/pendingApproval.ts | 17 +++++++++ modules/express/src/clientRoutes.ts | 6 +++ .../src/typedRoutes/api/v1/pendingApproval.ts | 8 ++-- .../src/typedRoutes/api/v2/pendingApproval.ts | 3 +- .../test/unit/typedRoutes/pendingApproval.ts | 33 ++++++++++++++++ .../unit/typedRoutes/pendingApprovalV2.ts | 38 +++++++++++++++++++ modules/sdk-api/src/v1/pendingapproval.ts | 6 ++- .../bitgo/pendingApproval/iPendingApproval.ts | 1 + .../bitgo/pendingApproval/pendingApproval.ts | 6 +-- 9 files changed, 107 insertions(+), 11 deletions(-) diff --git a/modules/bitgo/test/v2/unit/pendingApproval.ts b/modules/bitgo/test/v2/unit/pendingApproval.ts index 8a52608dc7..270b1851a0 100644 --- a/modules/bitgo/test/v2/unit/pendingApproval.ts +++ b/modules/bitgo/test/v2/unit/pendingApproval.ts @@ -174,6 +174,23 @@ describe('Pending Approvals:', () => { paScope.isDone().should.be.true(); }); + it('should cancel a pending approval without approving or rejecting it', async () => { + const pendingApproval = new PendingApproval(bitgo, basecoin, pendingApprovalData, wallet); + const paScope = nock(bgUrl) + .put(`/api/v2/${coin}/pendingapprovals/${pendingApprovalData.id}`, { + state: 'canceled', + }) + .reply(200, { + ...pendingApprovalData, + state: State.CANCELED, + }); + + const response = await pendingApproval.cancel(); + + response.state.should.equal(State.CANCELED); + paScope.isDone().should.be.true(); + }); + function testRecreateTransaction(coinName: string, recreateTransaction: boolean, type: Type) { it(`[${coinName}] should ${ recreateTransaction ? 'not ' : '' diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index bdcad288ab..6697b88cc3 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -263,6 +263,9 @@ function handleApproveTransaction(req: ExpressApiRouteRequest<'express.v1.pendin if (params.state === 'approved') { return pendingApproval.approve(params); } + if (params.state === 'canceled') { + return pendingApproval.cancel(params); + } return pendingApproval.reject(params); }); } @@ -833,6 +836,9 @@ async function handleV2PendingApproval(req: ExpressApiRouteRequest<'express.pend if (params.state === 'approved') { return pendingApproval.approve(params); } + if (params.state === 'canceled') { + return pendingApproval.cancel(params); + } return pendingApproval.reject(params); } diff --git a/modules/express/src/typedRoutes/api/v1/pendingApproval.ts b/modules/express/src/typedRoutes/api/v1/pendingApproval.ts index edc26ad7fd..bc2ad8bde1 100644 --- a/modules/express/src/typedRoutes/api/v1/pendingApproval.ts +++ b/modules/express/src/typedRoutes/api/v1/pendingApproval.ts @@ -9,7 +9,7 @@ export const pendingApprovalRequestParams = { }; export const pendingApprovalRequestBody = { - /** State of the approval: 'approved' to approve, any other value or omit to reject (defaults to rejection) */ + /** State of the approval: 'approved' to approve, 'canceled' to cancel, any other value or omit to reject */ state: optional(t.string), /** Wallet passphrase for transaction signing (required for transactionRequest approvals unless tx or xprv provided) */ walletPassphrase: optional(t.string), @@ -30,9 +30,9 @@ export const pendingApprovalRequestBody = { /** * Approve or reject a pending approval * - * Updates the state of a pending approval to either 'approved' or 'rejected' based on the - * state parameter. If state is 'approved', the approval is approved; otherwise, it is rejected. - * Default behavior (when state is omitted) is rejection. + * Updates the state of a pending approval to 'approved', 'canceled', or 'rejected' based on + * the state parameter. If state is 'approved', the approval is approved; if state is 'canceled', + * it is canceled; otherwise, it is rejected. Default behavior is rejection. * * **Approval Types:** * - **transactionRequest**: Transaction approvals (may require walletPassphrase for signing) diff --git a/modules/express/src/typedRoutes/api/v2/pendingApproval.ts b/modules/express/src/typedRoutes/api/v2/pendingApproval.ts index e4325ccf0a..f786d1629a 100644 --- a/modules/express/src/typedRoutes/api/v2/pendingApproval.ts +++ b/modules/express/src/typedRoutes/api/v2/pendingApproval.ts @@ -17,7 +17,7 @@ export const PendingApprovalParams = { * Request body for approving or rejecting a pending approval */ export const PendingApprovalRequestBody = { - /** New state for the pending approval: 'approved' to approve, omit or 'rejected' to reject */ + /** New state: 'approved' to approve, 'canceled' to cancel, omit or 'rejected' to reject */ state: optional(t.string), /** Passphrase to decrypt the user key on the wallet */ walletPassphrase: optional(t.string), @@ -48,6 +48,7 @@ export const PendingApprovalState = t.union([ t.literal('approved'), t.literal('processing'), t.literal('rejected'), + t.literal('canceled'), ]); /** diff --git a/modules/express/test/unit/typedRoutes/pendingApproval.ts b/modules/express/test/unit/typedRoutes/pendingApproval.ts index b2b6585556..15e683ac5f 100644 --- a/modules/express/test/unit/typedRoutes/pendingApproval.ts +++ b/modules/express/test/unit/typedRoutes/pendingApproval.ts @@ -321,6 +321,13 @@ describe('PendingApproval codec tests', function () { enterprise: 'enterprise123', }; + const mockCanceledResponse = { + id: 'approval123', + state: 'canceled', + wallet: 'wallet123', + enterprise: 'enterprise123', + }; + afterEach(function () { sinon.restore(); }); @@ -387,6 +394,32 @@ describe('PendingApproval codec tests', function () { assert.strictEqual(result.body.enterprise, mockRejectedResponse.enterprise); }); + it('should successfully cancel pending approval', async function () { + const approvalId = '123456789abcdef'; + const requestBody = { state: 'canceled' }; + const cancel = sinon.stub().resolves(mockCanceledResponse); + + const mockPendingApprovalObject = { + approve: sinon.stub().resolves(mockApprovedResponse), + reject: sinon.stub().resolves(mockRejectedResponse), + cancel, + }; + + sinon.stub(BitGo.prototype, 'pendingApprovals').returns({ + get: sinon.stub().resolves(mockPendingApprovalObject), + } as any); + + const result = await agent + .put(`/api/v1/pendingapprovals/${approvalId}/express`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + assert.strictEqual(result.body.state, 'canceled'); + sinon.assert.calledOnce(cancel); + }); + it('should successfully approve with otp', async function () { const approvalId = '123456789abcdef'; const requestBody = { diff --git a/modules/express/test/unit/typedRoutes/pendingApprovalV2.ts b/modules/express/test/unit/typedRoutes/pendingApprovalV2.ts index 5ba1d38910..0084aa6225 100644 --- a/modules/express/test/unit/typedRoutes/pendingApprovalV2.ts +++ b/modules/express/test/unit/typedRoutes/pendingApprovalV2.ts @@ -321,6 +321,14 @@ describe('V2 PendingApproval API Tests', function () { wallet: 'wallet123', }; + const mockCanceledResponse = { + id: 'approval123', + state: 'canceled', + creator: 'user123', + info: { type: 'transactionRequest' }, + wallet: 'wallet123', + }; + afterEach(function () { sinon.restore(); }); @@ -389,6 +397,36 @@ describe('V2 PendingApproval API Tests', function () { sinon.assert.calledOnce(mockPendingApprovalObject.reject); }); + it('should successfully cancel pending approval', async function () { + const coin = 'tbtc'; + const approvalId = '123456789abcdef'; + const cancel = sinon.stub().resolves(mockCanceledResponse); + + const mockPendingApprovalObject = { + approve: sinon.stub().resolves(mockApprovedResponse), + reject: sinon.stub().resolves(mockRejectedResponse), + cancel, + }; + + const mockCoin = { + pendingApprovals: sinon.stub().returns({ + get: sinon.stub().resolves(mockPendingApprovalObject), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .put(`/api/v2/${coin}/pendingapprovals/${approvalId}`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send({ state: 'canceled' }); + + assert.strictEqual(result.status, 200); + assert.strictEqual(result.body.state, 'canceled'); + sinon.assert.calledOnce(cancel); + }); + it('should default to rejection with empty body', async function () { const coin = 'tbtc'; const approvalId = '123456789abcdef'; diff --git a/modules/sdk-api/src/v1/pendingapproval.ts b/modules/sdk-api/src/v1/pendingapproval.ts index daff3f1c66..49aa7777cf 100644 --- a/modules/sdk-api/src/v1/pendingapproval.ts +++ b/modules/sdk-api/src/v1/pendingapproval.ts @@ -359,10 +359,12 @@ PendingApproval.prototype.reject = function (params, callback) { // // cancel -// rejects the pending approval +// cancels the pending approval // PendingApproval.prototype.cancel = function (params, callback) { - return this.reject(params, callback); + return Promise.resolve(this.bitgo.put(this.url()).send({ state: 'canceled' }).result()) + .then(callback) + .catch(callback); }; export = PendingApproval; diff --git a/modules/sdk-core/src/bitgo/pendingApproval/iPendingApproval.ts b/modules/sdk-core/src/bitgo/pendingApproval/iPendingApproval.ts index a28396b052..24a83e8f73 100644 --- a/modules/sdk-core/src/bitgo/pendingApproval/iPendingApproval.ts +++ b/modules/sdk-core/src/bitgo/pendingApproval/iPendingApproval.ts @@ -16,6 +16,7 @@ export enum State { APPROVED = 'approved', PROCESSING = 'processing', REJECTED = 'rejected', + CANCELED = 'canceled', } export enum Type { diff --git a/modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts b/modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts index cb867e3c1e..ab7c2b3ea3 100644 --- a/modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts +++ b/modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts @@ -225,13 +225,11 @@ export class PendingApproval implements IPendingApproval { } /** - * Alias for PendingApproval.reject() - * - * @deprecated + * Sets this PendingApproval to a canceled state. * @param params */ async cancel(params: Record = {}): Promise { - return await this.reject(params); + return await this.bitgo.put(this.url()).send({ state: 'canceled' }).result(); } /**