From a5b6b6089956d6dcbf336c70f26c90dde84f55ee Mon Sep 17 00:00:00 2001 From: Wayne Wen Date: Mon, 27 Jul 2026 11:57:57 -0400 Subject: [PATCH] feat(sdk-core): pass attestation through pending approvals Add optional withdrawal intent attestation to pending approval options. Forward the payload through SDK approval requests and Express v1/v2 codecs. Keep preApprove and halfSigned behavior unchanged so multisig approvals still rebuild or sign before the final PUT. Add focused codec, route, and signing tests. TICKET: WCN-541 --- modules/bitgo/test/v2/unit/pendingApproval.ts | 25 +++++++++ .../src/typedRoutes/api/v1/pendingApproval.ts | 3 ++ .../src/typedRoutes/api/v2/pendingApproval.ts | 3 ++ .../test/unit/typedRoutes/pendingApproval.ts | 52 +++++++++++++++++++ .../unit/typedRoutes/pendingApprovalV2.ts | 49 +++++++++++++++++ .../bitgo/pendingApproval/iPendingApproval.ts | 3 ++ .../bitgo/pendingApproval/pendingApproval.ts | 7 ++- 7 files changed, 140 insertions(+), 2 deletions(-) diff --git a/modules/bitgo/test/v2/unit/pendingApproval.ts b/modules/bitgo/test/v2/unit/pendingApproval.ts index 5f0bf0abdf..fd3f6094b6 100644 --- a/modules/bitgo/test/v2/unit/pendingApproval.ts +++ b/modules/bitgo/test/v2/unit/pendingApproval.ts @@ -33,6 +33,12 @@ describe('Pending Approvals:', () => { const coin = 'tbtc'; const walletId = 'wallet_id'; + const attestation = { + signature: 'signature', + credentialId: 'credential-id', + clientDataJSON: 'client-data-json', + authenticatorData: 'authenticator-data', + }; const pendingApprovalData: PendingApprovalData = { id: 'pa0', @@ -149,6 +155,25 @@ describe('Pending Approvals:', () => { paScope.isDone().should.be.true(); }); + it('should pass attestation without bypassing multisig half-signing', async () => { + const pendingApproval = new PendingApproval(bitgo, basecoin, pendingApprovalData, wallet); + const halfSignedTxHex = 'half-signed-transaction'; + const paScope = nock(bgUrl) + .put(`/api/v2/${coin}/pendingapprovals/${pendingApprovalData.id}`, { + state: 'approved', + attestation, + halfSigned: { txHex: halfSignedTxHex }, + }) + .reply(200, { + ...pendingApprovalData, + state: 'approved', + }); + + await pendingApproval.approve({ tx: halfSignedTxHex, attestation }); + + paScope.isDone().should.be.true(); + }); + function testRecreateTransaction(coinName: string, recreateTransaction: boolean, type: Type) { it(`[${coinName}] should ${ recreateTransaction ? 'not ' : '' diff --git a/modules/express/src/typedRoutes/api/v1/pendingApproval.ts b/modules/express/src/typedRoutes/api/v1/pendingApproval.ts index 81c5b9abce..edc26ad7fd 100644 --- a/modules/express/src/typedRoutes/api/v1/pendingApproval.ts +++ b/modules/express/src/typedRoutes/api/v1/pendingApproval.ts @@ -1,5 +1,6 @@ import * as t from 'io-ts'; import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; +import { AttestationPayload } from '@bitgo/public-types'; import { BitgoExpressError } from '../../schemas/error'; export const pendingApprovalRequestParams = { @@ -14,6 +15,8 @@ export const pendingApprovalRequestBody = { walletPassphrase: optional(t.string), /** One-time password for 2FA verification */ otp: optional(t.string), + /** WebAuthn proof that the approver authorized the pending transaction intent */ + attestation: optional(AttestationPayload), /** Pre-signed transaction hex to use instead of reconstructing (for transactionRequest approvals) */ tx: optional(t.string), /** Extended private key as alternative to walletPassphrase (for transactionRequest approvals) */ diff --git a/modules/express/src/typedRoutes/api/v2/pendingApproval.ts b/modules/express/src/typedRoutes/api/v2/pendingApproval.ts index 60caa852a6..e4325ccf0a 100644 --- a/modules/express/src/typedRoutes/api/v2/pendingApproval.ts +++ b/modules/express/src/typedRoutes/api/v2/pendingApproval.ts @@ -1,5 +1,6 @@ import * as t from 'io-ts'; import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; +import { AttestationPayload } from '@bitgo/public-types'; import { BitgoExpressError } from '../../schemas/error'; /** @@ -22,6 +23,8 @@ export const PendingApprovalRequestBody = { walletPassphrase: optional(t.string), /** Second factor authentication token */ otp: optional(t.string), + /** WebAuthn proof that the approver authorized the pending transaction intent */ + attestation: optional(AttestationPayload), /** Transaction hex to use instead of the original transaction */ tx: optional(t.string), /** Private key in string form, if walletPassphrase is not available */ diff --git a/modules/express/test/unit/typedRoutes/pendingApproval.ts b/modules/express/test/unit/typedRoutes/pendingApproval.ts index 28b535c824..b2b6585556 100644 --- a/modules/express/test/unit/typedRoutes/pendingApproval.ts +++ b/modules/express/test/unit/typedRoutes/pendingApproval.ts @@ -17,6 +17,13 @@ import { setupAgent } from '../../lib/testutil'; */ describe('PendingApproval codec tests', function () { + const attestation = { + signature: 'signature', + credentialId: 'credential-id', + clientDataJSON: 'client-data-json', + authenticatorData: 'authenticator-data', + }; + describe('pendingApprovalRequestParams', function () { it('should validate valid params', function () { const validParams = { @@ -52,6 +59,7 @@ describe('PendingApproval codec tests', function () { state: 'approved', walletPassphrase: 'mySecurePassword', otp: '123456', + attestation, tx: 'transactionHexString', xprv: 'xprvString', previewPendingTxs: true, @@ -62,6 +70,7 @@ describe('PendingApproval codec tests', function () { assert.strictEqual(decoded.state, validBody.state); assert.strictEqual(decoded.walletPassphrase, validBody.walletPassphrase); assert.strictEqual(decoded.otp, validBody.otp); + assert.deepStrictEqual(decoded.attestation, attestation); assert.strictEqual(decoded.tx, validBody.tx); assert.strictEqual(decoded.xprv, validBody.xprv); assert.strictEqual(decoded.previewPendingTxs, validBody.previewPendingTxs); @@ -77,6 +86,7 @@ describe('PendingApproval codec tests', function () { assert.strictEqual(decoded.state, validBody.state); assert.strictEqual(decoded.walletPassphrase, undefined); assert.strictEqual(decoded.otp, undefined); + assert.strictEqual(decoded.attestation, undefined); assert.strictEqual(decoded.tx, undefined); assert.strictEqual(decoded.xprv, undefined); assert.strictEqual(decoded.previewPendingTxs, undefined); @@ -173,6 +183,21 @@ describe('PendingApproval codec tests', function () { }); }); + it('should reject an incomplete attestation', function () { + const invalidBody = { + state: 'approved', + attestation: { + credentialId: 'credential-id', + clientDataJSON: 'client-data-json', + authenticatorData: 'authenticator-data', + }, + }; + + assert.throws(() => { + assertDecode(t.type(pendingApprovalRequestBody), invalidBody); + }); + }); + it('should reject body with non-string tx', function () { const invalidBody = { state: 'approved', @@ -390,6 +415,33 @@ describe('PendingApproval codec tests', function () { assert.strictEqual(result.body.state, 'approved'); }); + it('should pass attestation to approve', async function () { + const approvalId = '123456789abcdef'; + const requestBody = { + state: 'approved', + walletPassphrase: 'mySecurePassword', + attestation, + }; + const approve = sinon.stub().resolves(mockApprovedResponse); + const mockPendingApprovalObject = { + approve, + reject: sinon.stub().resolves(mockRejectedResponse), + }; + + 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); + sinon.assert.calledWith(approve, sinon.match({ walletPassphrase: 'mySecurePassword', attestation })); + }); + it('should successfully approve with xprv', 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 d305c8d587..5ba1d38910 100644 --- a/modules/express/test/unit/typedRoutes/pendingApprovalV2.ts +++ b/modules/express/test/unit/typedRoutes/pendingApprovalV2.ts @@ -20,6 +20,13 @@ import { BitGo } from 'bitgo'; import { setupAgent } from '../../lib/testutil'; describe('V2 PendingApproval API Tests', function () { + const attestation = { + signature: 'signature', + credentialId: 'credential-id', + clientDataJSON: 'client-data-json', + authenticatorData: 'authenticator-data', + }; + describe('Codec Validation Tests', function () { describe('PendingApprovalParams', function () { it('should validate valid params', function () { @@ -50,6 +57,7 @@ describe('V2 PendingApproval API Tests', function () { state: 'approved', walletPassphrase: 'mySecurePassword', otp: '123456', + attestation, tx: 'transactionHexString', xprv: 'xprvString', previewPendingTxs: true, @@ -60,6 +68,7 @@ describe('V2 PendingApproval API Tests', function () { assert.strictEqual(decoded.state, validBody.state); assert.strictEqual(decoded.walletPassphrase, validBody.walletPassphrase); assert.strictEqual(decoded.otp, validBody.otp); + assert.deepStrictEqual(decoded.attestation, attestation); assert.strictEqual(decoded.tx, validBody.tx); assert.strictEqual(decoded.xprv, validBody.xprv); assert.strictEqual(decoded.previewPendingTxs, validBody.previewPendingTxs); @@ -77,6 +86,15 @@ describe('V2 PendingApproval API Tests', function () { assert.throws(() => assertDecode(t.type(PendingApprovalRequestBody), { walletPassphrase: 12345 })); assert.throws(() => assertDecode(t.type(PendingApprovalRequestBody), { otp: 123456 })); assert.throws(() => assertDecode(t.type(PendingApprovalRequestBody), { previewPendingTxs: 'true' })); + assert.throws(() => + assertDecode(t.type(PendingApprovalRequestBody), { + attestation: { + credentialId: 'credential-id', + clientDataJSON: 'client-data-json', + authenticatorData: 'authenticator-data', + }, + }) + ); }); }); @@ -432,6 +450,37 @@ describe('V2 PendingApproval API Tests', function () { sinon.assert.calledWith(mockPendingApprovalObject.approve, sinon.match({ otp: '123456' })); }); + it('should pass attestation to approve', async function () { + const coin = 'tbtc'; + const approvalId = '123456789abcdef'; + const requestBody = { + state: 'approved', + walletPassphrase: 'mySecurePassword', + attestation, + }; + const approve = sinon.stub().resolves(mockApprovedResponse); + const mockPendingApprovalObject = { + approve, + reject: sinon.stub().resolves(mockRejectedResponse), + }; + 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(requestBody); + + assert.strictEqual(result.status, 200); + sinon.assert.calledWith(approve, sinon.match({ walletPassphrase: 'mySecurePassword', attestation })); + }); + it('should handle transaction request with fanout', async function () { const coin = 'tbtc'; const approvalId = '123456789abcdef'; diff --git a/modules/sdk-core/src/bitgo/pendingApproval/iPendingApproval.ts b/modules/sdk-core/src/bitgo/pendingApproval/iPendingApproval.ts index 3b42d0b26d..444f997725 100644 --- a/modules/sdk-core/src/bitgo/pendingApproval/iPendingApproval.ts +++ b/modules/sdk-core/src/bitgo/pendingApproval/iPendingApproval.ts @@ -1,3 +1,4 @@ +import type { AttestationPayload } from '@bitgo/public-types'; import { IRequestTracer } from '../../api'; export enum OwnerType { @@ -28,6 +29,8 @@ export enum Type { export interface ApproveOptions { walletPassphrase?: string; otp?: string; + /** WebAuthn proof that the approver authorized the pending transaction intent. */ + attestation?: AttestationPayload; tx?: string; xprv?: string; previewPendingTxs?: boolean; diff --git a/modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts b/modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts index 822ef5bab4..12f2dd1912 100644 --- a/modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts +++ b/modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts @@ -3,6 +3,7 @@ */ import * as _ from 'lodash'; import * as utxolib from '@bitgo/utxo-lib'; +import type { AttestationPayload } from '@bitgo/public-types'; import { IBaseCoin } from '../baseCoin'; import { BitGoBase } from '../bitgoBase'; import { @@ -31,11 +32,10 @@ type PreApproveResult = { halfSigned?: string; }; -// TODO(WCN-541): add optional attestation pass-through for PUT /pendingapprovals/:id (deferred -// until multisig attestation, WCN-539, is verified end-to-end on staging). type ApprovePendingApprovalRequestBody = { state: 'approved'; otp: string | undefined; + attestation?: AttestationPayload; halfSigned?: string | Omit; }; @@ -185,6 +185,9 @@ export class PendingApproval implements IPendingApproval { const transaction = await this.preApprove(params, reqId); const approvalParams: ApprovePendingApprovalRequestBody = { state: 'approved', otp: params.otp }; + if (params.attestation) { + approvalParams.attestation = params.attestation; + } if (transaction) { // if the transaction already has a half signed property, we take that directly approvalParams.halfSigned = transaction.halfSigned || transaction;