Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions modules/express/src/typedRoutes/api/v2/generateWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
51 changes: 51 additions & 0 deletions modules/express/test/unit/typedRoutes/generateWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down Expand Up @@ -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 () {
Expand Down
3 changes: 3 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/iWallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down
5 changes: 4 additions & 1 deletion modules/sdk-core/src/bitgo/wallet/wallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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();
Expand Down
101 changes: 101 additions & 0 deletions modules/sdk-core/test/unit/bitgo/wallet/walletsGoAccount.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading