From ab48ac7ff9cca964542f111b171c3795cab234d8 Mon Sep 17 00:00:00 2001 From: Zahin Mohammad Date: Tue, 21 Jul 2026 16:55:46 +0000 Subject: [PATCH] feat(sdk-core,express): plumb userKeySigningRequired through generateWallet Add optional `userKeySigningRequired?: boolean` to `GenerateWalletOptions` and `GenerateGoAccountWalletOptionsCodec` in `@bitgo/sdk-core`, and forward it into the `coinSpecific` payload sent to `POST /wallet/add` when creating OFC trading wallets via `generateGoAccountWallet`. Add the same field to the Express `GenerateWalletBody` io-ts codec so that the Express route `POST /api/v2/{coin}/wallet/generate` accepts and forwards the field to the SDK. Because Express routes pass `req.decoded` directly to `coin.wallets().generateWallet()`, no additional handler changes are required. Why: CAAS clients create OFC wallets through Express or the SDK, not by hitting the raw wallet-platform endpoint directly. Without this change, the `userKeySigningRequired` flag accepted by the WP `v2.wallet.add` endpoint (WCN-1441) was unreachable via the public SDK/Express surface, so CAAS clients had no way to opt out of user-key signing at wallet creation time. Tests: new `walletsGoAccount.ts` unit tests verify that `userKeySigningRequired: false` is forwarded in `coinSpecific.ofc`, that `true` is also forwarded when explicitly set, and that `coinSpecific` is omitted when the field is absent. Express typed-route tests verify the codec accepts `false` and rejects non-boolean values. Ticket: WCN-1442 Session-Id: 59053095-e233-45ac-87ae-816c84beb3fd Task-Id: 161e3fff-cf2b-4145-8924-0b3a51a9f676 --- .../src/typedRoutes/api/v2/generateWallet.ts | 2 + .../test/unit/typedRoutes/generateWallet.ts | 51 +++++++++ modules/sdk-core/src/bitgo/wallet/iWallets.ts | 3 + modules/sdk-core/src/bitgo/wallet/wallets.ts | 5 +- .../unit/bitgo/wallet/walletsGoAccount.ts | 101 ++++++++++++++++++ 5 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 modules/sdk-core/test/unit/bitgo/wallet/walletsGoAccount.ts diff --git a/modules/express/src/typedRoutes/api/v2/generateWallet.ts b/modules/express/src/typedRoutes/api/v2/generateWallet.ts index 0493aa4cd6..a076ed8944 100644 --- a/modules/express/src/typedRoutes/api/v2/generateWallet.ts +++ b/modules/express/src/typedRoutes/api/v2/generateWallet.ts @@ -55,6 +55,8 @@ export const GenerateWalletBody = { passphrase: t.string, }) ), + /** OFC only. When false, BitGo KMS signs OFC payloads on behalf of the user without requiring a user-key signature. Defaults to true (user must sign). */ + userKeySigningRequired: optional(t.boolean), } as const; export const GenerateWalletResponse200 = t.union([ diff --git a/modules/express/test/unit/typedRoutes/generateWallet.ts b/modules/express/test/unit/typedRoutes/generateWallet.ts index bf2ff0a16c..b38c613e88 100644 --- a/modules/express/test/unit/typedRoutes/generateWallet.ts +++ b/modules/express/test/unit/typedRoutes/generateWallet.ts @@ -440,6 +440,42 @@ describe('Generate Wallet Typed Routes Tests', function () { evmKeyRingReferenceWalletId ); }); + + it('should forward userKeySigningRequired to generateWallet for OFC wallet', async function () { + const coin = 'tofc'; + const label = 'OFC Test Wallet'; + const enterprise = 'enterprise123'; + + const mockWallet = { + id: 'ofcWallet123', + coin, + label, + toJSON: sinon.stub().returns({ id: 'ofcWallet123', coin, label }), + }; + + const walletResponse = { + wallet: mockWallet, + userKeychain: { id: 'userKey123' }, + }; + + const generateWalletStub = sinon.stub().resolves(walletResponse); + const walletsStub = { generateWallet: generateWalletStub } as any; + const coinStub = { wallets: sinon.stub().returns(walletsStub) } as any; + + sinon.stub(BitGo.prototype, 'coin').returns(coinStub); + + const res = await agent.post(`/api/v2/${coin}/wallet/generate`).send({ + label, + enterprise, + passphrase: 'test-passphrase', + type: 'trading', + userKeySigningRequired: false, + }); + + res.status.should.equal(200); + generateWalletStub.should.have.been.calledOnce(); + generateWalletStub.firstCall.args[0].should.have.property('userKeySigningRequired', false); + }); }); describe('Codec Validation', function () { @@ -575,6 +611,21 @@ describe('Generate Wallet Typed Routes Tests', function () { res.body.should.have.property('error'); res.body.error.should.match(/disableTransactionNotifications/); }); + + it('should return 400 when userKeySigningRequired is not boolean', async function () { + const coin = 'tofc'; + + const res = await agent.post(`/api/v2/${coin}/wallet/generate`).send({ + label: 'Test OFC Wallet', + enterprise: 'enterprise123', + type: 'trading', + userKeySigningRequired: 'false', + }); + + res.status.should.equal(400); + res.body.should.have.property('error'); + res.body.error.should.match(/userKeySigningRequired/); + }); }); describe('Handler Errors', function () { diff --git a/modules/sdk-core/src/bitgo/wallet/iWallets.ts b/modules/sdk-core/src/bitgo/wallet/iWallets.ts index 55aa835c6a..ff6945acea 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallets.ts @@ -262,6 +262,8 @@ export interface GenerateWalletOptions { encryptionVersion?: EncryptionVersion; /** Delegates user/backup key creation to an external signer (onchain multisig only). */ createKeychainCallback?: CreateKeychainCallback; + /** OFC only. When false, BitGo KMS signs OFC payloads on behalf of the user without requiring a user-key signature. Defaults to true (user must sign). */ + userKeySigningRequired?: boolean; } export interface GenerateWalletWithExternalSignerOptions @@ -305,6 +307,7 @@ export const GenerateGoAccountWalletOptionsCodec = t.intersection( t.partial({ // Codec intentionally accepts only 2: v1 is the implicit default and never sent on the wire. encryptionVersion: t.literal(2), + userKeySigningRequired: t.boolean, }), ], 'GenerateGoAccountWalletOptions' diff --git a/modules/sdk-core/src/bitgo/wallet/wallets.ts b/modules/sdk-core/src/bitgo/wallet/wallets.ts index 9a27d73e7b..3eeeb267ac 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallets.ts @@ -234,7 +234,7 @@ export class Wallets implements IWallets { const reqId = new RequestTracer(); this.bitgo.setRequestTracer(reqId); - const { label, passphrase, enterprise, passcodeEncryptionCode, encryptionVersion } = params; + const { label, passphrase, enterprise, passcodeEncryptionCode, encryptionVersion, userKeySigningRequired } = params; const keychain = this.baseCoin.keychains().create(); @@ -259,6 +259,9 @@ export class Wallets implements IWallets { type: 'trading', enterprise, keys: [userKeychain.id], + ...(userKeySigningRequired !== undefined && { + coinSpecific: { [this.baseCoin.getChain()]: { userKeySigningRequired } }, + }), }; const newWallet = await this.bitgo.post(this.baseCoin.url('/wallet/add')).send(walletParams).result(); diff --git a/modules/sdk-core/test/unit/bitgo/wallet/walletsGoAccount.ts b/modules/sdk-core/test/unit/bitgo/wallet/walletsGoAccount.ts new file mode 100644 index 0000000000..da2ba22d9f --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/wallet/walletsGoAccount.ts @@ -0,0 +1,101 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import 'should'; +import { Wallets } from '../../../../src/bitgo/wallet/wallets'; + +describe('Wallets - GoAccount (OFC trading) wallet creation', function () { + let wallets: Wallets; + let mockBitGo: any; + let mockBaseCoin: any; + let mockKeychains: any; + let postChain: any; + + const userPrv = 'xprvSomeUserPrivateKey'; + const userPub = + 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8'; + + beforeEach(function () { + mockKeychains = { + create: sinon.stub().returns({ pub: userPub, prv: userPrv }), + add: sinon.stub().resolves({ id: 'user-key-id', pub: userPub, encryptedPrv: 'encrypted-prv' }), + }; + + const mockWalletData = { id: 'wallet-id', keys: ['user-key-id'] }; + + postChain = { + send: sinon.stub(), + }; + postChain.send.returns({ + result: sinon.stub().resolves(mockWalletData), + }); + + mockBitGo = { + post: sinon.stub().returns(postChain), + encrypt: sinon + .stub() + .callsFake( + async ({ password, input }: { password: string; input: string }) => `encrypted:${password}:${input}` + ), + setRequestTracer: sinon.stub(), + }; + + mockBaseCoin = { + getFamily: sinon.stub().returns('ofc'), + getChain: sinon.stub().returns('ofc'), + getDefaultMultisigType: sinon.stub().returns('onchain'), + supportsTss: sinon.stub().returns(false), + keychains: sinon.stub().returns(mockKeychains), + url: sinon.stub().returns('/test/url'), + }; + + wallets = new Wallets(mockBitGo, mockBaseCoin); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('should forward userKeySigningRequired: false in coinSpecific when provided', async function () { + await wallets.generateWallet({ + label: 'Test OFC Wallet', + passphrase: 'test-passphrase', + enterprise: 'enterprise-123', + passcodeEncryptionCode: 'pce-code', + type: 'trading', + userKeySigningRequired: false, + }); + + assert.ok(postChain.send.calledOnce, 'POST /wallet/add should be called once'); + const sentParams = postChain.send.firstCall.args[0]; + sentParams.should.have.property('coinSpecific'); + sentParams.coinSpecific.should.have.property('ofc'); + sentParams.coinSpecific.ofc.should.have.property('userKeySigningRequired', false); + }); + + it('should forward userKeySigningRequired: true in coinSpecific when explicitly set', async function () { + await wallets.generateWallet({ + label: 'Test OFC Wallet', + passphrase: 'test-passphrase', + enterprise: 'enterprise-123', + passcodeEncryptionCode: 'pce-code', + type: 'trading', + userKeySigningRequired: true, + }); + + const sentParams = postChain.send.firstCall.args[0]; + sentParams.coinSpecific.ofc.should.have.property('userKeySigningRequired', true); + }); + + it('should omit coinSpecific when userKeySigningRequired is not provided', async function () { + await wallets.generateWallet({ + label: 'Test OFC Wallet', + passphrase: 'test-passphrase', + enterprise: 'enterprise-123', + passcodeEncryptionCode: 'pce-code', + type: 'trading', + }); + + const sentParams = postChain.send.firstCall.args[0]; + sentParams.should.not.have.property('coinSpecific'); + }); +});