Skip to content

Commit ded3b89

Browse files
committed
feat(sdk-core): added OFC BitGo signing on trading accounts object
Ticket: WCN-217-1
1 parent 4c753e1 commit ded3b89

4 files changed

Lines changed: 167 additions & 2 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { ITradingNetwork } from './network';
22

33
export interface SignPayloadParameters {
44
payload: string | Record<string, unknown>;
5-
walletPassphrase: string;
5+
walletPassphrase?: string;
66
}
77

88
export interface ITradingAccount {

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

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,47 @@ 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 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
3030
* @returns hex-encoded signature of the payload
3131
*/
3232
async signPayload(params: SignPayloadParameters): Promise<string> {
33+
// if no passphrase is provided, attempt to sign using the wallet's bitgo key remotely
34+
if (!params.walletPassphrase) {
35+
return this.signPayloadByBitGoKey(params);
36+
}
37+
// if a passphrase is provided, we must be trying to sign using the user private key - decrypt and sign locally
38+
return this.signPayloadByUserKey(params);
39+
}
40+
41+
/**
42+
* Signs the payload of a trading account via the trading account BitGo key stored in a remote KMS
43+
* @param params
44+
* @private
45+
*/
46+
private async signPayloadByBitGoKey(params: Omit<SignPayloadParameters, 'walletPassphrase'>): Promise<string> {
47+
const walletData = this.wallet.toJSON();
48+
if (walletData.userKeySigningRequired) {
49+
throw new Error('Wallet must use user key to sign ofc transaction, please provide the wallet passphrase');
50+
}
51+
if (walletData.keys.length < 2) {
52+
throw new Error('Wallet does not support BitGo signing');
53+
}
54+
55+
const url = this.wallet.url('/tx/sign');
56+
const { signature } = await this.wallet.bitgo.post(url).send(params.payload).result();
57+
58+
return signature;
59+
}
60+
61+
/**
62+
* Signs the payload of a trading account locally by fetching the user's encrypted private key and decrypt using passphrase
63+
* @param params
64+
* @private
65+
*/
66+
private async signPayloadByUserKey(params: SignPayloadParameters): Promise<string> {
3367
const key = (await this.wallet.baseCoin.keychains().get({ id: this.wallet.keyIds()[0] })) as any;
3468
const prv = this.wallet.bitgo.decrypt({
3569
input: key.encryptedPrv,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -909,6 +909,7 @@ export interface WalletData {
909909
evmKeyRingReferenceWalletId?: string;
910910
isParent?: boolean;
911911
enabledChildChains?: string[];
912+
userKeySigningRequired?: string;
912913
}
913914

914915
export interface RecoverTokenOptions {
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* @prettier
3+
*/
4+
import sinon from 'sinon';
5+
import 'should';
6+
import { TradingAccount } from '../../../../src/bitgo/trading/tradingAccount';
7+
8+
describe('TradingAccount', function () {
9+
let tradingAccount: TradingAccount;
10+
let mockBitGo: any;
11+
let mockWallet: any;
12+
let mockBaseCoin: any;
13+
14+
const enterpriseId = 'test-enterprise-id';
15+
const walletPassphrase = 'test-passphrase';
16+
const encryptedPrv = 'encrypted-prv';
17+
const decryptedPrv = 'decrypted-prv';
18+
const signature = 'aabbccdd';
19+
20+
beforeEach(function () {
21+
const postStub = sinon.stub();
22+
postStub.returns({
23+
send: sinon.stub().returns({
24+
result: sinon.stub().resolves({ signature }),
25+
}),
26+
});
27+
28+
mockBitGo = {
29+
post: postStub,
30+
decrypt: sinon.stub().returns(decryptedPrv),
31+
};
32+
33+
mockBaseCoin = {
34+
keychains: sinon.stub().returns({
35+
get: sinon.stub().resolves({ encryptedPrv }),
36+
}),
37+
signMessage: sinon.stub().resolves(Buffer.from(signature, 'hex')),
38+
};
39+
40+
mockWallet = {
41+
id: sinon.stub().returns('test-wallet-id'),
42+
keyIds: sinon.stub().returns(['user-key-id', 'backup-key-id', 'bitgo-key-id']),
43+
url: sinon.stub().returns('https://example.com/wallet/test-wallet-id/tx/sign'),
44+
toJSON: sinon.stub().returns({
45+
id: 'test-wallet-id',
46+
keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'],
47+
userKeySigningRequired: undefined,
48+
}),
49+
baseCoin: mockBaseCoin,
50+
bitgo: mockBitGo,
51+
};
52+
53+
tradingAccount = new TradingAccount(enterpriseId, mockWallet, mockBitGo);
54+
});
55+
56+
afterEach(function () {
57+
sinon.restore();
58+
});
59+
60+
describe('signPayload', function () {
61+
const payload = { data: 'test-payload' };
62+
const payloadString = 'test-payload-string';
63+
64+
describe('without walletPassphrase (BitGo remote signing)', function () {
65+
it('should sign using the BitGo key remotely when no passphrase is provided', async function () {
66+
const result = await tradingAccount.signPayload({ payload });
67+
68+
mockWallet.toJSON.calledOnce.should.be.true();
69+
mockWallet.url.calledWith('/tx/sign').should.be.true();
70+
mockBitGo.post.calledOnce.should.be.true();
71+
result.should.equal(signature);
72+
});
73+
74+
it('should sign a string payload remotely when no passphrase is provided', async function () {
75+
const result = await tradingAccount.signPayload({ payload: payloadString });
76+
77+
result.should.equal(signature);
78+
});
79+
80+
it('should throw if userKeySigningRequired is set and no passphrase is provided', async function () {
81+
mockWallet.toJSON.returns({
82+
id: 'test-wallet-id',
83+
keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'],
84+
userKeySigningRequired: 'true',
85+
});
86+
87+
await tradingAccount
88+
.signPayload({ payload })
89+
.should.be.rejectedWith(
90+
'Wallet must use user key to sign ofc transaction, please provide the wallet passphrase'
91+
);
92+
});
93+
94+
it('should throw if wallet has fewer than 2 keys and no passphrase is provided', async function () {
95+
mockWallet.toJSON.returns({
96+
id: 'test-wallet-id',
97+
keys: ['user-key-id'],
98+
userKeySigningRequired: undefined,
99+
});
100+
101+
await tradingAccount.signPayload({ payload }).should.be.rejectedWith('Wallet does not support BitGo signing');
102+
});
103+
});
104+
105+
describe('with walletPassphrase (local user key signing)', function () {
106+
it('should decrypt the user key and sign the payload locally', async function () {
107+
const result = await tradingAccount.signPayload({ payload, walletPassphrase });
108+
109+
mockBaseCoin.keychains().get.calledWith({ id: 'user-key-id' }).should.be.true();
110+
mockBitGo.decrypt.calledWith({ input: encryptedPrv, password: walletPassphrase }).should.be.true();
111+
mockBaseCoin.signMessage.calledOnce.should.be.true();
112+
result.should.equal(Buffer.from(signature, 'hex').toString('hex'));
113+
});
114+
115+
it('should stringify a Record payload before signing locally', async function () {
116+
await tradingAccount.signPayload({ payload, walletPassphrase });
117+
118+
const signMessageCall = mockBaseCoin.signMessage.getCall(0);
119+
signMessageCall.args[1].should.equal(JSON.stringify(payload));
120+
});
121+
122+
it('should pass a string payload directly to signMessage', async function () {
123+
await tradingAccount.signPayload({ payload: payloadString, walletPassphrase });
124+
125+
const signMessageCall = mockBaseCoin.signMessage.getCall(0);
126+
signMessageCall.args[1].should.equal(payloadString);
127+
});
128+
});
129+
});
130+
});

0 commit comments

Comments
 (0)