Skip to content

Commit 39f1a27

Browse files
authored
Merge pull request #8666 from BitGo/WCN-217
feat(sdk-core): added OFC BitGo signing on trading accounts object
2 parents 4c67535 + c9e064a commit 39f1a27

9 files changed

Lines changed: 449 additions & 15 deletions

File tree

modules/express/src/clientRoutes.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,15 @@ function getWalletPwFromEnv(walletId: string): string {
405405
return walletPw;
406406
}
407407

408+
/**
409+
* Returns the wallet passphrase from the environment, or undefined if not set.
410+
* Unlike getWalletPwFromEnv, this does not throw when the env variable is absent.
411+
* Use this when the passphrase is optional (e.g. KMS-backed wallets).
412+
*/
413+
function findWalletPwFromEnv(walletId: string): string | undefined {
414+
return process.env[`WALLET_${walletId}_PASSPHRASE`];
415+
}
416+
408417
async function getEncryptedPrivKey(path: string, walletId: string): Promise<string> {
409418
const privKeyFile = await fs.readFile(path, { encoding: 'utf8' });
410419
const encryptedPrivKey = JSON.parse(privKeyFile);
@@ -631,7 +640,9 @@ export async function handleV2OFCSignPayload(
631640
throw new ApiResponseError(`Could not find OFC wallet ${walletId}`, 404);
632641
}
633642

634-
const walletPassphrase = bodyWalletPassphrase || getWalletPwFromEnv(wallet.id());
643+
// Prefer the passphrase from the request body; fall back to the env var.
644+
// If neither is present, pass undefined — signPayload() routes to KMS internally.
645+
const walletPassphrase = bodyWalletPassphrase ?? findWalletPwFromEnv(wallet.id());
635646
const tradingAccount = wallet.toTradingAccount();
636647
const stringifiedPayload = typeof payload === 'string' ? payload : JSON.stringify(payload);
637648
const signature = await tradingAccount.signPayload({

modules/express/test/unit/typedRoutes/ofcSignPayload.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,10 +223,103 @@ describe('OfcSignPayload codec tests', function () {
223223
const decodedResponse = assertDecode(OfcSignPayloadResponse200, result.body);
224224
assert.strictEqual(decodedResponse.signature, mockSignPayloadResponse.signature);
225225

226+
// Verify env passphrase was forwarded to signPayload
227+
const signCall = mockTradingAccount.signPayload.getCall(0);
228+
assert.ok(signCall, 'tradingAccount.signPayload should have been called');
229+
assert.strictEqual(signCall.args[0].walletPassphrase, 'env_passphrase', 'env passphrase should be forwarded');
230+
226231
// Cleanup environment variable
227232
delete process.env['WALLET_ofc-wallet-id-123_PASSPHRASE'];
228233
});
229234

235+
it('should pass undefined walletPassphrase to signPayload when no passphrase in body or env (KMS path)', async function () {
236+
const requestBody = {
237+
walletId: 'ofc-wallet-id-no-passphrase',
238+
payload: { amount: '1000000', currency: 'USD' },
239+
// no walletPassphrase
240+
};
241+
242+
// Ensure no env var is set for this wallet
243+
delete process.env['WALLET_ofc-wallet-id-no-passphrase_PASSPHRASE'];
244+
245+
const mockTradingAccount = {
246+
signPayload: sinon.stub().resolves(mockSignPayloadResponse.signature),
247+
};
248+
249+
const mockWallet = {
250+
id: () => requestBody.walletId,
251+
toTradingAccount: sinon.stub().returns(mockTradingAccount),
252+
};
253+
254+
const walletsGetStub = sinon.stub().resolves(mockWallet);
255+
const mockWallets = { get: walletsGetStub };
256+
const mockCoin = { wallets: sinon.stub().returns(mockWallets) };
257+
sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any);
258+
259+
const result = await agent
260+
.post('/api/v2/ofc/signPayload')
261+
.set('Authorization', 'Bearer test_access_token_12345')
262+
.set('Content-Type', 'application/json')
263+
.send(requestBody);
264+
265+
assert.strictEqual(result.status, 200);
266+
const decodedResponse = assertDecode(OfcSignPayloadResponse200, result.body);
267+
assert.strictEqual(decodedResponse.signature, mockSignPayloadResponse.signature);
268+
269+
// signPayload must be called with walletPassphrase=undefined so the SDK routes to KMS
270+
const signCall = mockTradingAccount.signPayload.getCall(0);
271+
assert.ok(signCall, 'tradingAccount.signPayload should have been called');
272+
assert.strictEqual(
273+
signCall.args[0].walletPassphrase,
274+
undefined,
275+
'walletPassphrase should be undefined to trigger KMS signing'
276+
);
277+
});
278+
279+
it('should prefer body walletPassphrase over env passphrase', async function () {
280+
const requestBody = {
281+
walletId: 'ofc-wallet-id-123',
282+
payload: { amount: '500' },
283+
walletPassphrase: 'body_passphrase',
284+
};
285+
286+
// Set a different env passphrase — body should win
287+
process.env['WALLET_ofc-wallet-id-123_PASSPHRASE'] = 'env_passphrase';
288+
289+
const mockTradingAccount = {
290+
signPayload: sinon.stub().resolves(mockSignPayloadResponse.signature),
291+
};
292+
293+
const mockWallet = {
294+
id: () => requestBody.walletId,
295+
toTradingAccount: sinon.stub().returns(mockTradingAccount),
296+
};
297+
298+
const walletsGetStub = sinon.stub().resolves(mockWallet);
299+
const mockWallets = { get: walletsGetStub };
300+
const mockCoin = { wallets: sinon.stub().returns(mockWallets) };
301+
sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any);
302+
303+
const result = await agent
304+
.post('/api/v2/ofc/signPayload')
305+
.set('Authorization', 'Bearer test_access_token_12345')
306+
.set('Content-Type', 'application/json')
307+
.send(requestBody);
308+
309+
assert.strictEqual(result.status, 200);
310+
311+
// body passphrase should take precedence
312+
const signCall = mockTradingAccount.signPayload.getCall(0);
313+
assert.ok(signCall, 'tradingAccount.signPayload should have been called');
314+
assert.strictEqual(
315+
signCall.args[0].walletPassphrase,
316+
'body_passphrase',
317+
'body passphrase should take precedence over env'
318+
);
319+
320+
delete process.env['WALLET_ofc-wallet-id-123_PASSPHRASE'];
321+
});
322+
230323
it('should successfully sign complex nested JSON payload', async function () {
231324
const requestBody = {
232325
walletId: 'ofc-wallet-id-123',

modules/sdk-core/src/bitgo/trading/iTradingAccount.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
11
import { ITradingNetwork } from './network';
22

3+
/**
4+
* Parameters for the signing a payload from the trading account.
5+
* If both walletPassphrase and prv are not provided, the BitGo key will be used.
6+
*
7+
* @note If wallet has userKeySigningRequired set to true, then attempting to sign with BitGo key will throw.
8+
*
9+
* @param payload - The payload to sign
10+
* @param walletPassphrase - The passphrase of the wallet that will be used to decrypt the user key and sign the payload.
11+
* @param prv - The decrypted user key prv used to sign the payload
12+
*/
313
export interface SignPayloadParameters {
414
payload: string | Record<string, unknown>;
5-
walletPassphrase: string;
15+
walletPassphrase?: string;
16+
prv?: string;
617
}
718

819
export interface ITradingAccount {

modules/sdk-core/src/bitgo/trading/network/network.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export class TradingNetwork implements ITradingNetwork {
109109

110110
/**
111111
* Prepare an allocation for submission
112-
* @param {string} walletPassphrase ofc wallet passphrase
112+
* @param {string} walletPassphrase ofc wallet passphrase - required only when signing via user key
113113
* @param {string} connectionId connection to whom to make the allocation or deallocation
114114
* @param {string=} clientExternalId one time generated uuid v4
115115
* @param {string} currency currency for which the allocation should be made. e.g. btc / tbtc
@@ -130,10 +130,7 @@ export class TradingNetwork implements ITradingNetwork {
130130
}
131131

132132
const payload = JSON.stringify(body);
133-
134-
const prv = await this.wallet.getPrv({ walletPassphrase });
135-
const signedBuffer: Buffer = await this.wallet.baseCoin.signMessage({ prv }, payload);
136-
const signature = signedBuffer.toString('hex');
133+
const signature = await this.wallet.toTradingAccount().signPayload({ payload, walletPassphrase });
137134

138135
return {
139136
...body,

modules/sdk-core/src/bitgo/trading/network/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ export type GetNetworkAllocationByIdResponse = {
125125
};
126126

127127
export type PrepareNetworkAllocationParams = Omit<CreateNetworkAllocationParams, 'payload' | 'signature'> & {
128-
walletPassphrase: string;
128+
walletPassphrase?: string;
129129
clientExternalId?: string;
130130
nonce?: string;
131131
};

modules/sdk-core/src/bitgo/trading/tradingAccount.ts

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,77 @@ export class TradingAccount implements ITradingAccount {
2323
}
2424

2525
/**
26-
* Signs an arbitrary payload with the user key on this trading account
26+
* Signs an arbitrary payload. Use the user key if passphrase/prv is provided, or the BitGo key if not.
2727
* @param params
2828
* @param params.payload arbitrary payload object (string | Record<string, unknown>)
2929
* @param params.walletPassphrase passphrase on this trading account, used to unlock the account user key
30+
* @param params.prv user private key, used to sign the payload locally
3031
* @returns hex-encoded signature of the payload
3132
*/
3233
async signPayload(params: SignPayloadParameters): Promise<string> {
33-
const key = (await this.wallet.baseCoin.keychains().get({ id: this.wallet.keyIds()[0] })) as any;
34-
const prv = this.wallet.bitgo.decrypt({
35-
input: key.encryptedPrv,
36-
password: params.walletPassphrase,
37-
});
34+
// if no passphrase is provided, attempt to sign using the wallet's bitgo key remotely
35+
if (!params.walletPassphrase && !params.prv) {
36+
return this.signPayloadByBitGoKey(params);
37+
}
38+
// if a passphrase is provided, we must be trying to sign using the user private key - decrypt and sign locally
39+
return this.signPayloadByUserKey(params);
40+
}
41+
42+
/**
43+
* Signs the payload of a trading account via the trading account BitGo key
44+
* @param params
45+
* @private
46+
*/
47+
private async signPayloadByBitGoKey(
48+
params: Omit<SignPayloadParameters, 'walletPassphrase' | 'prv'>
49+
): Promise<string> {
50+
const walletData = this.wallet.toJSON();
51+
if (walletData.userKeySigningRequired) {
52+
throw new Error(
53+
'Wallet must use user key to sign ofc transaction, please provide the wallet passphrase or visit your wallet settings page to configure one.'
54+
);
55+
}
56+
if (walletData.keys.length < 2) {
57+
throw new Error(
58+
'Wallet does not support BitGo signing. Please reach out to support@bitgo.com to resolve this issue.'
59+
);
60+
}
61+
62+
// we do not parse the payload here, we instead sends the payload as a stringified JSON to be signed, just like how we process it locally
63+
const url = this.wallet.url('/tx/sign');
64+
const payload = typeof params.payload !== 'string' ? JSON.stringify(params.payload) : params.payload;
65+
const { signature } = await this.wallet.bitgo.post(url).send({ payload }).result();
66+
67+
return signature;
68+
}
69+
70+
/**
71+
* Signs the payload of a trading account locally by fetching the user's encrypted private key and decrypt using passphrase
72+
* @param params
73+
* @private
74+
*/
75+
private async signPayloadByUserKey(params: SignPayloadParameters): Promise<string> {
76+
if (!params.prv && !params.walletPassphrase) {
77+
throw new Error(
78+
'Must provide either prv or walletPassphrase to sign payload using user key. Please provide the wallet passphrase or visit your wallet settings page to configure one.'
79+
);
80+
}
81+
82+
let prv: string;
83+
if (params.prv) {
84+
prv = params.prv;
85+
} else {
86+
const key = await this.wallet.baseCoin.keychains().get({ id: this.wallet.keyIds()[0] });
87+
if (!key.encryptedPrv) {
88+
throw new Error('Expected encryptedPrv to be present on user keychain.');
89+
}
90+
prv = this.wallet.bitgo.decrypt({
91+
input: key.encryptedPrv,
92+
password: params.walletPassphrase,
93+
});
94+
}
3895
const payload = typeof params.payload === 'string' ? params.payload : JSON.stringify(params.payload);
39-
return ((await this.wallet.baseCoin.signMessage({ prv }, payload)) as any).toString('hex');
96+
return (await this.wallet.baseCoin.signMessage({ prv }, payload)).toString('hex');
4097
}
4198

4299
/**

modules/sdk-core/src/bitgo/wallet/iWallet.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -946,6 +946,7 @@ export interface WalletData {
946946
evmKeyRingReferenceWalletId?: string;
947947
isParent?: boolean;
948948
enabledChildChains?: string[];
949+
userKeySigningRequired?: boolean;
949950
}
950951

951952
export interface RecoverTokenOptions {

0 commit comments

Comments
 (0)