From 14fd4517bf479b9580129cc0f8887e8863953bf8 Mon Sep 17 00:00:00 2001 From: Zahin Mohammad Date: Thu, 7 May 2026 12:35:33 -0400 Subject: [PATCH 1/2] feat(sdk-core): read userKeySigningRequired from coinSpecific Shift the trading-account BitGo-key signing gate to read `coinSpecific.userKeySigningRequired` and fall back to the top-level `userKeySigningRequired` only when the coinSpecific subdocument does not carry it. The OFC subdocument's toJSON in bitgo-microservices flattens its fields directly into `coinSpecific`, so the field surfaces in the API response as `coinSpecific.userKeySigningRequired`. The top-level field is now `@deprecated` and will be removed in a follow-up major release. Tests cover both the coinSpecific shape and the top-level fallback, plus the precedence rule when both are present. Ticket: WCN-471 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/bitgo/trading/tradingAccount.ts | 3 +- modules/sdk-core/src/bitgo/wallet/iWallet.ts | 6 ++++ .../test/unit/bitgo/trading/tradingAccount.ts | 33 +++++++++++++++++-- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/modules/sdk-core/src/bitgo/trading/tradingAccount.ts b/modules/sdk-core/src/bitgo/trading/tradingAccount.ts index aca75014c2..9133793770 100644 --- a/modules/sdk-core/src/bitgo/trading/tradingAccount.ts +++ b/modules/sdk-core/src/bitgo/trading/tradingAccount.ts @@ -48,7 +48,8 @@ export class TradingAccount implements ITradingAccount { params: Omit ): Promise { const walletData = this.wallet.toJSON(); - if (walletData.userKeySigningRequired) { + const userKeySigningRequired = walletData.coinSpecific?.userKeySigningRequired ?? walletData.userKeySigningRequired; + if (userKeySigningRequired) { throw new Error( 'Wallet must use user key to sign ofc transaction, please provide the wallet passphrase or visit your wallet settings page to configure one.' ); diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index b2fa1d7cdf..848f465a77 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -379,6 +379,7 @@ export interface WalletCoinSpecific { pendingEcdsaTssInitialization?: boolean; features?: string[]; freezeDepositsFromShielded?: boolean; + userKeySigningRequired?: boolean; /** * Lightning coin specific data starts */ @@ -946,6 +947,11 @@ export interface WalletData { evmKeyRingReferenceWalletId?: string; isParent?: boolean; enabledChildChains?: string[]; + /** + * @deprecated Read from `coinSpecific.userKeySigningRequired` instead. Retained + * temporarily as a fallback while the field migrates from the top level to the OFC + * coinSpecific subdocument; will be removed in a follow-up major release. + */ userKeySigningRequired?: boolean; } diff --git a/modules/sdk-core/test/unit/bitgo/trading/tradingAccount.ts b/modules/sdk-core/test/unit/bitgo/trading/tradingAccount.ts index 13c324de4f..2b9a0869e4 100644 --- a/modules/sdk-core/test/unit/bitgo/trading/tradingAccount.ts +++ b/modules/sdk-core/test/unit/bitgo/trading/tradingAccount.ts @@ -52,7 +52,7 @@ describe('TradingAccount', function () { toJSON: sinon.stub().returns({ id: 'test-wallet-id', keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], - userKeySigningRequired: undefined, // default is undefined + coinSpecific: {}, }), baseCoin: mockBaseCoin, bitgo: mockBitGo, @@ -90,10 +90,25 @@ describe('TradingAccount', function () { result.should.equal(signature); }); - it('should throw if userKeySigningRequired is set and no passphrase and prv are provided', async function () { + it('should throw if coinSpecific.userKeySigningRequired is true and no passphrase and prv are provided', async function () { mockWallet.toJSON.onCall(mockWallet.toJSON.callCount).returns({ id: 'test-wallet-id', keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], + coinSpecific: { userKeySigningRequired: true }, + }); + + await tradingAccount + .signPayload({ payload }) + .should.be.rejectedWith( + 'Wallet must use user key to sign ofc transaction, please provide the wallet passphrase or visit your wallet settings page to configure one.' + ); + }); + + it('should fall back to top-level userKeySigningRequired when coinSpecific does not carry it', async function () { + mockWallet.toJSON.onCall(mockWallet.toJSON.callCount).returns({ + id: 'test-wallet-id', + keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], + coinSpecific: {}, userKeySigningRequired: true, }); @@ -104,11 +119,23 @@ describe('TradingAccount', function () { ); }); + it('should prefer coinSpecific.userKeySigningRequired over top-level when both are present', async function () { + mockWallet.toJSON.onCall(mockWallet.toJSON.callCount).returns({ + id: 'test-wallet-id', + keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], + coinSpecific: { userKeySigningRequired: false }, + userKeySigningRequired: true, + }); + + const result = await tradingAccount.signPayload({ payload }); + result.should.equal(signature); + }); + it('should throw if wallet has fewer than 2 keys and no passphrase and prv are provided', async function () { mockWallet.toJSON.onCall(mockWallet.toJSON.callCount).returns({ id: 'test-wallet-id', keys: ['user-key-id'], - userKeySigningRequired: undefined, + coinSpecific: {}, }); await tradingAccount From 0d223d5aa742a793b1df12afc4b81421784a4db8 Mon Sep 17 00:00:00 2001 From: Zahin Mohammad Date: Thu, 7 May 2026 12:35:53 -0400 Subject: [PATCH 2/2] feat(express): type coinSpecific.userKeySigningRequired on WalletResponse codec Narrow the WalletResponse `coinSpecific` codec from a bare `t.UnknownRecord` to an intersection that adds typed visibility for `coinSpecific.userKeySigningRequired` while keeping the unknown-record permissiveness intact for unrelated subdocument fields. The OFC subdocument toJSON in bitgo-microservices flattens its fields directly into coinSpecific, so the field surfaces unwrapped on the response. Add codec-decode tests covering wallets with arbitrary coinSpecific fields, the typed userKeySigningRequired shape, an empty coinSpecific, and rejection of wrong-typed values. Ticket: WCN-471 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../express/src/typedRoutes/schemas/wallet.ts | 2 +- .../express/test/unit/typedRoutes/decode.ts | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/modules/express/src/typedRoutes/schemas/wallet.ts b/modules/express/src/typedRoutes/schemas/wallet.ts index 0104fa4fa9..186293594f 100644 --- a/modules/express/src/typedRoutes/schemas/wallet.ts +++ b/modules/express/src/typedRoutes/schemas/wallet.ts @@ -189,7 +189,7 @@ export const WalletResponse = t.partial({ /** Multisig type version (e.g., 'MPCv2') */ multisigTypeVersion: t.string, /** Coin-specific wallet data */ - coinSpecific: t.UnknownRecord, + coinSpecific: t.intersection([t.partial({ userKeySigningRequired: t.boolean }), t.UnknownRecord]), /** Admin settings including policy */ admin: WalletAdmin, /** Users with access to this wallet */ diff --git a/modules/express/test/unit/typedRoutes/decode.ts b/modules/express/test/unit/typedRoutes/decode.ts index 23f349d270..fbe07ab753 100644 --- a/modules/express/test/unit/typedRoutes/decode.ts +++ b/modules/express/test/unit/typedRoutes/decode.ts @@ -20,6 +20,7 @@ import { ExpressWalletUpdateParams, } from '../../../src/typedRoutes/api/v2/expressWalletUpdate'; import { SignerMacaroonBody, SignerMacaroonParams } from '../../../src/typedRoutes/api/v2/signerMacaroon'; +import { WalletResponse } from '../../../src/typedRoutes/schemas/wallet'; export function assertDecode(codec: t.Type, input: unknown): T { const result = codec.decode(input); @@ -306,4 +307,33 @@ describe('io-ts decode tests', function () { it('express.lightning.signerMacaroon params invalid', function () { assert.throws(() => assertDecode(t.type(SignerMacaroonParams), { coin: 'lnbtc' })); }); + describe('WalletResponse coinSpecific', function () { + it('decodes wallets with arbitrary coinSpecific fields and no userKeySigningRequired', function () { + // OFC subdocument toJSON in WP flattens fields directly into coinSpecific; non-OFC + // wallets carry unrelated keys. Both shapes must decode through the permissive intersection. + const decoded = assertDecode(WalletResponse, { + id: 'wallet123', + coinSpecific: { baseAddress: '0xabc', someEthField: 1 }, + }); + assert.deepStrictEqual(decoded.coinSpecific, { baseAddress: '0xabc', someEthField: 1 }); + }); + it('decodes wallets with coinSpecific.userKeySigningRequired', function () { + const decoded = assertDecode(WalletResponse, { + id: 'wallet123', + coinSpecific: { userKeySigningRequired: true, needsKeyReshareAfterPasswordReset: false }, + }); + assert.strictEqual(decoded.coinSpecific?.userKeySigningRequired, true); + }); + it('decodes wallets with empty coinSpecific', function () { + assertDecode(WalletResponse, { id: 'wallet123', coinSpecific: {} }); + }); + it('rejects coinSpecific.userKeySigningRequired of wrong type', function () { + assert.throws(() => + assertDecode(WalletResponse, { + id: 'wallet123', + coinSpecific: { userKeySigningRequired: 'yes' }, + }) + ); + }); + }); });