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
9 changes: 8 additions & 1 deletion modules/sdk-core/src/bitgo/trading/iTradingAccount.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { ITradingNetwork } from './network';

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

export interface ITradingAccount {
Expand Down
52 changes: 47 additions & 5 deletions modules/sdk-core/src/bitgo/trading/tradingAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,60 @@ export class TradingAccount implements ITradingAccount {
}

/**
* Signs an arbitrary payload with the user key on this trading account
* Signs an arbitrary payload. Use the user key if passphrase is provided, or the BitGo key if not.
* @param params
* @param params.payload arbitrary payload object (string | Record<string, unknown>)
* @param params.walletPassphrase passphrase on this trading account, used to unlock the account user key
* @returns hex-encoded signature of the payload
*/
async signPayload(params: SignPayloadParameters): Promise<string> {
// if no passphrase is provided, attempt to sign using the wallet's bitgo key remotely
if (!params.walletPassphrase && !params.prv) {
return this.signPayloadByBitGoKey(params);
}
// if a passphrase is provided, we must be trying to sign using the user private key - decrypt and sign locally
return this.signPayloadByUserKey(params);
}

/**
* Signs the payload of a trading account via the trading account BitGo key
* @param params
* @private
*/
private async signPayloadByBitGoKey(params: Omit<SignPayloadParameters, 'walletPassphrase'>): Promise<string> {
const walletData = this.wallet.toJSON();
if (walletData.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.'
);
}
if (walletData.keys.length < 2) {
throw new Error(
'Wallet does not support BitGo signing. Please reach out to support@bitgo.com to resolve this issue.'
);
}

// 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
const url = this.wallet.url('/tx/sign');
const payload = typeof params.payload !== 'string' ? JSON.stringify(params.payload) : params.payload;
const { signature } = await this.wallet.bitgo.post(url).send({ payload }).result();

return signature;
}

/**
* Signs the payload of a trading account locally by fetching the user's encrypted private key and decrypt using passphrase
* @param params
* @private
*/
private async signPayloadByUserKey(params: SignPayloadParameters): Promise<string> {
const key = (await this.wallet.baseCoin.keychains().get({ id: this.wallet.keyIds()[0] })) as any;
const prv = this.wallet.bitgo.decrypt({
input: key.encryptedPrv,
password: params.walletPassphrase,
});
const prv =
params.prv ??
this.wallet.bitgo.decrypt({
input: key.encryptedPrv,
password: params.walletPassphrase,
});
const payload = typeof params.payload === 'string' ? params.payload : JSON.stringify(params.payload);
return ((await this.wallet.baseCoin.signMessage({ prv }, payload)) as any).toString('hex');
}
Expand Down
195 changes: 195 additions & 0 deletions modules/sdk-core/test/unit/bitgo/trading/tradingAccount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/**
* @prettier
*/
import sinon from 'sinon';
import 'should';
import { TradingAccount } from '../../../../src/bitgo/trading/tradingAccount';

describe('TradingAccount', function () {
let tradingAccount: TradingAccount;
let mockBitGo: any;
let mockWallet: any;
let mockBaseCoin: any;
let sendStub: sinon.SinonStub;

const enterpriseId = 'test-enterprise-id';
const walletPassphrase = 'test-passphrase';
const encryptedPrv = 'encrypted-prv';
const decryptedPrv = 'decrypted-prv';
const signature = 'aabbccdd';
const payload = { data: 'test-payload' };
const payloadString = JSON.stringify(payload);

beforeEach(function () {
sendStub = sinon.stub();
sendStub.withArgs({ payload: payloadString }).returns({ result: sinon.stub().resolves({ signature }) });

mockBitGo = {
post: sinon.stub().returns({ send: sendStub }),
decrypt: sinon.stub().returns(decryptedPrv),
};

mockBaseCoin = {
keychains: sinon.stub().returns({
get: sinon.stub().resolves({ encryptedPrv }),
}),
signMessage: sinon.stub().callsFake(async (key: { prv: string }) => {
if (key.prv === decryptedPrv) {
return Buffer.from(signature, 'hex');
}
throw new Error(`signMessage called with unexpected prv: ${key.prv}`);
}),
};

mockWallet = {
id: sinon.stub().returns('test-wallet-id'),
keyIds: sinon.stub().returns(['user-key-id', 'backup-key-id', 'bitgo-key-id']),
url: sinon.stub().returns('https://example.com/wallet/test-wallet-id/tx/sign'),
toJSON: sinon.stub().returns({
id: 'test-wallet-id',
keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'],
userKeySigningRequired: undefined,
}),
baseCoin: mockBaseCoin,
bitgo: mockBitGo,
};

tradingAccount = new TradingAccount(enterpriseId, mockWallet, mockBitGo);
});

afterEach(function () {
sinon.restore();
});

describe('id', function () {
it('should return the wallet id', function () {
tradingAccount.id.should.equal('test-wallet-id');
mockWallet.id.calledOnce.should.be.true();
});
});

describe('signPayload', function () {
describe('without walletPassphrase or prv (BitGo remote signing)', function () {
it('should sign using the BitGo key remotely when no passphrase is provided', async function () {
const result = await tradingAccount.signPayload({ payload });

mockWallet.toJSON.calledOnce.should.be.true();
mockWallet.url.calledWith('/tx/sign').should.be.true();
mockBitGo.post.calledOnce.should.be.true();
sendStub.calledWith({ payload: JSON.stringify(payload) }).should.be.true();
result.should.equal(signature);
});

it('should send a string payload as { payload } without stringification when no passphrase is provided', async function () {
await tradingAccount.signPayload({ payload: payloadString });
sendStub.calledWith({ payload: payloadString }).should.be.true();
});

it('should sign a string payload remotely when no passphrase is provided', async function () {
const result = await tradingAccount.signPayload({ payload: payloadString });

result.should.equal(signature);
});

it('should throw if userKeySigningRequired is set and no passphrase and prv are provided', async function () {
mockWallet.toJSON.returns({
id: 'test-wallet-id',
keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'],
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 throw if wallet has fewer than 2 keys and no passphrase and prv are provided', async function () {
mockWallet.toJSON.returns({
id: 'test-wallet-id',
keys: ['user-key-id'],
userKeySigningRequired: undefined,
});

await tradingAccount
.signPayload({ payload })
.should.be.rejectedWith(
'Wallet does not support BitGo signing. Please reach out to support@bitgo.com to resolve this issue.'
);
});
});

describe('with walletPassphrase (local user key signing)', function () {
it('should decrypt the user key and sign the payload locally', async function () {
const result = await tradingAccount.signPayload({ payload, walletPassphrase });

mockBaseCoin.keychains().get.calledWith({ id: 'user-key-id' }).should.be.true();
mockBitGo.decrypt.calledWith({ input: encryptedPrv, password: walletPassphrase }).should.be.true();
mockBaseCoin.signMessage.calledOnce.should.be.true();
result.should.equal(Buffer.from(signature, 'hex').toString('hex'));
});

it('should stringify a Record payload before signing locally', async function () {
await tradingAccount.signPayload({ payload, walletPassphrase });

const signMessageCall = mockBaseCoin.signMessage.getCall(0);
signMessageCall.args[1].should.equal(JSON.stringify(payload));
});

it('should pass a string payload directly to signMessage', async function () {
await tradingAccount.signPayload({ payload: payloadString, walletPassphrase });

const signMessageCall = mockBaseCoin.signMessage.getCall(0);
signMessageCall.args[1].should.equal(payloadString);
});
});

describe('with both walletPassphrase and prv', function () {
it('should use prv directly and not call decrypt when both are provided', async function () {
await tradingAccount.signPayload({ payload, walletPassphrase, prv: decryptedPrv });

mockBitGo.decrypt.called.should.be.false();
const signMessageCall = mockBaseCoin.signMessage.getCall(0);
signMessageCall.args[0].should.deepEqual({ prv: decryptedPrv });
});
});

describe('with prv (local user key signing without decryption)', function () {
it('should sign using the provided prv without calling decrypt', async function () {
const result = await tradingAccount.signPayload({ payload, prv: decryptedPrv });

mockBitGo.decrypt.called.should.be.false();
mockBaseCoin.signMessage.calledOnce.should.be.true();
result.should.equal(Buffer.from(signature, 'hex').toString('hex'));
});

it('should not use BitGo remote signing when prv is provided', async function () {
await tradingAccount.signPayload({ payload, prv: decryptedPrv });

mockBitGo.post.called.should.be.false();
});

it('should pass the prv directly to signMessage', async function () {
await tradingAccount.signPayload({ payload, prv: decryptedPrv });

const signMessageCall = mockBaseCoin.signMessage.getCall(0);
signMessageCall.args[0].should.deepEqual({ prv: decryptedPrv });
});

it('should stringify a Record payload before signing with prv', async function () {
await tradingAccount.signPayload({ payload, prv: decryptedPrv });

const signMessageCall = mockBaseCoin.signMessage.getCall(0);
signMessageCall.args[1].should.equal(JSON.stringify(payload));
});

it('should pass a string payload directly to signMessage when signing with prv', async function () {
await tradingAccount.signPayload({ payload: payloadString, prv: decryptedPrv });

const signMessageCall = mockBaseCoin.signMessage.getCall(0);
signMessageCall.args[1].should.equal(payloadString);
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* @prettier
*/
import sinon from 'sinon';
import 'should';
import { Wallet } from '../../../../src';

describe('Wallet - OFC signTransaction', function () {
let wallet: Wallet;
let mockBitGo: any;
let mockBaseCoin: any;
let mockWalletData: any;

beforeEach(function () {
mockBitGo = {
url: sinon.stub().returns('https://test.bitgo.com'),
post: sinon.stub(),
get: sinon.stub(),
setRequestTracer: sinon.stub(),
};

mockBaseCoin = {
getFamily: sinon.stub().returns('ofc'),
url: sinon.stub().returns('https://test.bitgo.com/wallet'),
keychains: sinon.stub(),
supportsTss: sinon.stub().returns(false),
getMPCAlgorithm: sinon.stub(),
presignTransaction: sinon.stub().resolvesArg(0),
keyIdsForSigning: sinon.stub().returns([0]),
signTransaction: sinon.stub().resolves({ halfSigned: { payload: 'test', signature: 'aabbcc' } }),
};

mockWalletData = {
id: 'test-wallet-id',
coin: 'ofcusdt',
keys: ['user-key', 'backup-key', 'bitgo-key'],
multisigType: 'onchain',
enterprise: 'ent-id',
};

wallet = new Wallet(mockBitGo, mockBaseCoin, mockWalletData);
});

afterEach(function () {
sinon.restore();
});

it('should pass wallet instance to baseCoin.signTransaction', async function () {
const txPrebuild = { txInfo: { payload: '{"amount":"100"}' } } as any;
const prv = 'test-prv';

await wallet.signTransaction({ txPrebuild, prv });

mockBaseCoin.signTransaction.calledOnce.should.be.true();
const callArgs = mockBaseCoin.signTransaction.getCall(0).args[0];
callArgs.wallet.should.equal(wallet);
});

it('should pass prv to baseCoin.signTransaction when provided directly', async function () {
const txPrebuild = { txInfo: { payload: '{"amount":"100"}' } } as any;
const prv = 'test-prv';

await wallet.signTransaction({ txPrebuild, prv });

const callArgs = mockBaseCoin.signTransaction.getCall(0).args[0];
callArgs.prv.should.equal(prv);
});

it('should pass wallet instance to baseCoin.signTransaction even when no prv is available', async function () {
sinon.stub(wallet, 'getUserPrv').returns(undefined as any);
const txPrebuild = { txInfo: { payload: '{"amount":"100"}' } } as any;

await wallet.signTransaction({ txPrebuild });

mockBaseCoin.signTransaction.calledOnce.should.be.true();
const callArgs = mockBaseCoin.signTransaction.getCall(0).args[0];
callArgs.wallet.should.equal(wallet);
});

it('should return the result from baseCoin.signTransaction', async function () {
const txPrebuild = { txInfo: { payload: '{"amount":"100"}' } } as any;
const prv = 'test-prv';

const result = await wallet.signTransaction({ txPrebuild, prv });

result.should.deepEqual({ halfSigned: { payload: 'test', signature: 'aabbcc' } });
});
});
Loading
Loading