Skip to content

Commit 338719c

Browse files
committed
chore: fixup
TICKET: WCN-217
1 parent e385447 commit 338719c

5 files changed

Lines changed: 504 additions & 6 deletions

File tree

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

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

3+
/**
4+
* Parameters for the signing a payload from the trading account. If both walletPassphrase and prv is not provided the BitGo key will be used
5+
* @param payload - The payload to sign
6+
* @param walletPassphrase - The passphrase of the wallet that will be used to decrypt the user key and sign the payload.
7+
* @param prv - The decrypted prv of the wallet used to sign the payload
8+
*/
39
export interface SignPayloadParameters {
410
payload: string | Record<string, unknown>;
5-
walletPassphrase: string;
11+
walletPassphrase?: string;
12+
prv?: string;
613
}
714

815
export interface ITradingAccount {

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

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,60 @@ 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 && !params.prv) {
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
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(
50+
'Wallet must use user key to sign ofc transaction, please provide the wallet passphrase or visit your wallet settings page to configure one.'
51+
);
52+
}
53+
if (walletData.keys.length < 2) {
54+
throw new Error(
55+
'Wallet does not support BitGo signing. Please reach out to support@bitgo.com to resolve this issue.'
56+
);
57+
}
58+
59+
// 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
60+
const url = this.wallet.url('/tx/sign');
61+
const payload = typeof params.payload !== 'string' ? JSON.stringify(params.payload) : params.payload;
62+
const { signature } = await this.wallet.bitgo.post(url).send({ payload }).result();
63+
64+
return signature;
65+
}
66+
67+
/**
68+
* Signs the payload of a trading account locally by fetching the user's encrypted private key and decrypt using passphrase
69+
* @param params
70+
* @private
71+
*/
72+
private async signPayloadByUserKey(params: SignPayloadParameters): Promise<string> {
3373
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-
});
74+
const prv =
75+
params.prv ??
76+
this.wallet.bitgo.decrypt({
77+
input: key.encryptedPrv,
78+
password: params.walletPassphrase,
79+
});
3880
const payload = typeof params.payload === 'string' ? params.payload : JSON.stringify(params.payload);
3981
return ((await this.wallet.baseCoin.signMessage({ prv }, payload)) as any).toString('hex');
4082
}
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
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+
let sendStub: sinon.SinonStub;
14+
15+
const enterpriseId = 'test-enterprise-id';
16+
const walletPassphrase = 'test-passphrase';
17+
const encryptedPrv = 'encrypted-prv';
18+
const decryptedPrv = 'decrypted-prv';
19+
const signature = 'aabbccdd';
20+
const payload = { data: 'test-payload' };
21+
const payloadString = JSON.stringify(payload);
22+
23+
beforeEach(function () {
24+
sendStub = sinon.stub();
25+
sendStub.withArgs({ payload: payloadString }).returns({ result: sinon.stub().resolves({ signature }) });
26+
27+
mockBitGo = {
28+
post: sinon.stub().returns({ send: sendStub }),
29+
decrypt: sinon.stub().returns(decryptedPrv),
30+
};
31+
32+
mockBaseCoin = {
33+
keychains: sinon.stub().returns({
34+
get: sinon.stub().resolves({ encryptedPrv }),
35+
}),
36+
signMessage: sinon.stub().callsFake(async (key: { prv: string }) => {
37+
if (key.prv === decryptedPrv) {
38+
return Buffer.from(signature, 'hex');
39+
}
40+
throw new Error(`signMessage called with unexpected prv: ${key.prv}`);
41+
}),
42+
};
43+
44+
mockWallet = {
45+
id: sinon.stub().returns('test-wallet-id'),
46+
keyIds: sinon.stub().returns(['user-key-id', 'backup-key-id', 'bitgo-key-id']),
47+
url: sinon.stub().returns('https://example.com/wallet/test-wallet-id/tx/sign'),
48+
toJSON: sinon.stub().returns({
49+
id: 'test-wallet-id',
50+
keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'],
51+
userKeySigningRequired: undefined,
52+
}),
53+
baseCoin: mockBaseCoin,
54+
bitgo: mockBitGo,
55+
};
56+
57+
tradingAccount = new TradingAccount(enterpriseId, mockWallet, mockBitGo);
58+
});
59+
60+
afterEach(function () {
61+
sinon.restore();
62+
});
63+
64+
describe('id', function () {
65+
it('should return the wallet id', function () {
66+
tradingAccount.id.should.equal('test-wallet-id');
67+
mockWallet.id.calledOnce.should.be.true();
68+
});
69+
});
70+
71+
describe('signPayload', function () {
72+
describe('without walletPassphrase or prv (BitGo remote signing)', function () {
73+
it('should sign using the BitGo key remotely when no passphrase is provided', async function () {
74+
const result = await tradingAccount.signPayload({ payload });
75+
76+
mockWallet.toJSON.calledOnce.should.be.true();
77+
mockWallet.url.calledWith('/tx/sign').should.be.true();
78+
mockBitGo.post.calledOnce.should.be.true();
79+
sendStub.calledWith({ payload: JSON.stringify(payload) }).should.be.true();
80+
result.should.equal(signature);
81+
});
82+
83+
it('should send a string payload as { payload } without stringification when no passphrase is provided', async function () {
84+
await tradingAccount.signPayload({ payload: payloadString });
85+
sendStub.calledWith({ payload: payloadString }).should.be.true();
86+
});
87+
88+
it('should sign a string payload remotely when no passphrase is provided', async function () {
89+
const result = await tradingAccount.signPayload({ payload: payloadString });
90+
91+
result.should.equal(signature);
92+
});
93+
94+
it('should throw if userKeySigningRequired is set and no passphrase and prv are provided', async function () {
95+
mockWallet.toJSON.returns({
96+
id: 'test-wallet-id',
97+
keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'],
98+
userKeySigningRequired: 'true',
99+
});
100+
101+
await tradingAccount
102+
.signPayload({ payload })
103+
.should.be.rejectedWith(
104+
'Wallet must use user key to sign ofc transaction, please provide the wallet passphrase or visit your wallet settings page to configure one.'
105+
);
106+
});
107+
108+
it('should throw if wallet has fewer than 2 keys and no passphrase and prv are provided', async function () {
109+
mockWallet.toJSON.returns({
110+
id: 'test-wallet-id',
111+
keys: ['user-key-id'],
112+
userKeySigningRequired: undefined,
113+
});
114+
115+
await tradingAccount
116+
.signPayload({ payload })
117+
.should.be.rejectedWith(
118+
'Wallet does not support BitGo signing. Please reach out to support@bitgo.com to resolve this issue.'
119+
);
120+
});
121+
});
122+
123+
describe('with walletPassphrase (local user key signing)', function () {
124+
it('should decrypt the user key and sign the payload locally', async function () {
125+
const result = await tradingAccount.signPayload({ payload, walletPassphrase });
126+
127+
mockBaseCoin.keychains().get.calledWith({ id: 'user-key-id' }).should.be.true();
128+
mockBitGo.decrypt.calledWith({ input: encryptedPrv, password: walletPassphrase }).should.be.true();
129+
mockBaseCoin.signMessage.calledOnce.should.be.true();
130+
result.should.equal(Buffer.from(signature, 'hex').toString('hex'));
131+
});
132+
133+
it('should stringify a Record payload before signing locally', async function () {
134+
await tradingAccount.signPayload({ payload, walletPassphrase });
135+
136+
const signMessageCall = mockBaseCoin.signMessage.getCall(0);
137+
signMessageCall.args[1].should.equal(JSON.stringify(payload));
138+
});
139+
140+
it('should pass a string payload directly to signMessage', async function () {
141+
await tradingAccount.signPayload({ payload: payloadString, walletPassphrase });
142+
143+
const signMessageCall = mockBaseCoin.signMessage.getCall(0);
144+
signMessageCall.args[1].should.equal(payloadString);
145+
});
146+
});
147+
148+
describe('with both walletPassphrase and prv', function () {
149+
it('should use prv directly and not call decrypt when both are provided', async function () {
150+
await tradingAccount.signPayload({ payload, walletPassphrase, prv: decryptedPrv });
151+
152+
mockBitGo.decrypt.called.should.be.false();
153+
const signMessageCall = mockBaseCoin.signMessage.getCall(0);
154+
signMessageCall.args[0].should.deepEqual({ prv: decryptedPrv });
155+
});
156+
});
157+
158+
describe('with prv (local user key signing without decryption)', function () {
159+
it('should sign using the provided prv without calling decrypt', async function () {
160+
const result = await tradingAccount.signPayload({ payload, prv: decryptedPrv });
161+
162+
mockBitGo.decrypt.called.should.be.false();
163+
mockBaseCoin.signMessage.calledOnce.should.be.true();
164+
result.should.equal(Buffer.from(signature, 'hex').toString('hex'));
165+
});
166+
167+
it('should not use BitGo remote signing when prv is provided', async function () {
168+
await tradingAccount.signPayload({ payload, prv: decryptedPrv });
169+
170+
mockBitGo.post.called.should.be.false();
171+
});
172+
173+
it('should pass the prv directly to signMessage', async function () {
174+
await tradingAccount.signPayload({ payload, prv: decryptedPrv });
175+
176+
const signMessageCall = mockBaseCoin.signMessage.getCall(0);
177+
signMessageCall.args[0].should.deepEqual({ prv: decryptedPrv });
178+
});
179+
180+
it('should stringify a Record payload before signing with prv', async function () {
181+
await tradingAccount.signPayload({ payload, prv: decryptedPrv });
182+
183+
const signMessageCall = mockBaseCoin.signMessage.getCall(0);
184+
signMessageCall.args[1].should.equal(JSON.stringify(payload));
185+
});
186+
187+
it('should pass a string payload directly to signMessage when signing with prv', async function () {
188+
await tradingAccount.signPayload({ payload: payloadString, prv: decryptedPrv });
189+
190+
const signMessageCall = mockBaseCoin.signMessage.getCall(0);
191+
signMessageCall.args[1].should.equal(payloadString);
192+
});
193+
});
194+
});
195+
});
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* @prettier
3+
*/
4+
import sinon from 'sinon';
5+
import 'should';
6+
import { Wallet } from '../../../../src';
7+
8+
describe('Wallet - OFC signTransaction', function () {
9+
let wallet: Wallet;
10+
let mockBitGo: any;
11+
let mockBaseCoin: any;
12+
let mockWalletData: any;
13+
14+
beforeEach(function () {
15+
mockBitGo = {
16+
url: sinon.stub().returns('https://test.bitgo.com'),
17+
post: sinon.stub(),
18+
get: sinon.stub(),
19+
setRequestTracer: sinon.stub(),
20+
};
21+
22+
mockBaseCoin = {
23+
getFamily: sinon.stub().returns('ofc'),
24+
url: sinon.stub().returns('https://test.bitgo.com/wallet'),
25+
keychains: sinon.stub(),
26+
supportsTss: sinon.stub().returns(false),
27+
getMPCAlgorithm: sinon.stub(),
28+
presignTransaction: sinon.stub().resolvesArg(0),
29+
keyIdsForSigning: sinon.stub().returns([0]),
30+
signTransaction: sinon.stub().resolves({ halfSigned: { payload: 'test', signature: 'aabbcc' } }),
31+
};
32+
33+
mockWalletData = {
34+
id: 'test-wallet-id',
35+
coin: 'ofcusdt',
36+
keys: ['user-key', 'backup-key', 'bitgo-key'],
37+
multisigType: 'onchain',
38+
enterprise: 'ent-id',
39+
};
40+
41+
wallet = new Wallet(mockBitGo, mockBaseCoin, mockWalletData);
42+
});
43+
44+
afterEach(function () {
45+
sinon.restore();
46+
});
47+
48+
it('should pass wallet instance to baseCoin.signTransaction', async function () {
49+
const txPrebuild = { txInfo: { payload: '{"amount":"100"}' } } as any;
50+
const prv = 'test-prv';
51+
52+
await wallet.signTransaction({ txPrebuild, prv });
53+
54+
mockBaseCoin.signTransaction.calledOnce.should.be.true();
55+
const callArgs = mockBaseCoin.signTransaction.getCall(0).args[0];
56+
callArgs.wallet.should.equal(wallet);
57+
});
58+
59+
it('should pass prv to baseCoin.signTransaction when provided directly', async function () {
60+
const txPrebuild = { txInfo: { payload: '{"amount":"100"}' } } as any;
61+
const prv = 'test-prv';
62+
63+
await wallet.signTransaction({ txPrebuild, prv });
64+
65+
const callArgs = mockBaseCoin.signTransaction.getCall(0).args[0];
66+
callArgs.prv.should.equal(prv);
67+
});
68+
69+
it('should pass wallet instance to baseCoin.signTransaction even when no prv is available', async function () {
70+
sinon.stub(wallet, 'getUserPrv').returns(undefined as any);
71+
const txPrebuild = { txInfo: { payload: '{"amount":"100"}' } } as any;
72+
73+
await wallet.signTransaction({ txPrebuild });
74+
75+
mockBaseCoin.signTransaction.calledOnce.should.be.true();
76+
const callArgs = mockBaseCoin.signTransaction.getCall(0).args[0];
77+
callArgs.wallet.should.equal(wallet);
78+
});
79+
80+
it('should return the result from baseCoin.signTransaction', async function () {
81+
const txPrebuild = { txInfo: { payload: '{"amount":"100"}' } } as any;
82+
const prv = 'test-prv';
83+
84+
const result = await wallet.signTransaction({ txPrebuild, prv });
85+
86+
result.should.deepEqual({ halfSigned: { payload: 'test', signature: 'aabbcc' } });
87+
});
88+
});

0 commit comments

Comments
 (0)