From 338719cf3ac1306799a73d480e1077a11e7ab787 Mon Sep 17 00:00:00 2001 From: alextse-bg Date: Wed, 29 Apr 2026 14:58:29 -0400 Subject: [PATCH] chore: fixup TICKET: WCN-217 --- .../src/bitgo/trading/iTradingAccount.ts | 9 +- .../src/bitgo/trading/tradingAccount.ts | 52 ++++- .../test/unit/bitgo/trading/tradingAccount.ts | 195 ++++++++++++++++++ .../bitgo/wallet/ofcWalletSignTransaction.ts | 88 ++++++++ modules/sdk-core/test/unit/coins/ofc.ts | 166 +++++++++++++++ 5 files changed, 504 insertions(+), 6 deletions(-) create mode 100644 modules/sdk-core/test/unit/bitgo/trading/tradingAccount.ts create mode 100644 modules/sdk-core/test/unit/bitgo/wallet/ofcWalletSignTransaction.ts create mode 100644 modules/sdk-core/test/unit/coins/ofc.ts diff --git a/modules/sdk-core/src/bitgo/trading/iTradingAccount.ts b/modules/sdk-core/src/bitgo/trading/iTradingAccount.ts index 462c7e25ba..dc2466d3cb 100644 --- a/modules/sdk-core/src/bitgo/trading/iTradingAccount.ts +++ b/modules/sdk-core/src/bitgo/trading/iTradingAccount.ts @@ -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; - walletPassphrase: string; + walletPassphrase?: string; + prv?: string; } export interface ITradingAccount { diff --git a/modules/sdk-core/src/bitgo/trading/tradingAccount.ts b/modules/sdk-core/src/bitgo/trading/tradingAccount.ts index 40489647ae..714185c23f 100644 --- a/modules/sdk-core/src/bitgo/trading/tradingAccount.ts +++ b/modules/sdk-core/src/bitgo/trading/tradingAccount.ts @@ -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) * @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 { + // 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): Promise { + 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 { 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'); } diff --git a/modules/sdk-core/test/unit/bitgo/trading/tradingAccount.ts b/modules/sdk-core/test/unit/bitgo/trading/tradingAccount.ts new file mode 100644 index 0000000000..90b9c16303 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/trading/tradingAccount.ts @@ -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); + }); + }); + }); +}); diff --git a/modules/sdk-core/test/unit/bitgo/wallet/ofcWalletSignTransaction.ts b/modules/sdk-core/test/unit/bitgo/wallet/ofcWalletSignTransaction.ts new file mode 100644 index 0000000000..278291bf12 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/wallet/ofcWalletSignTransaction.ts @@ -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' } }); + }); +}); diff --git a/modules/sdk-core/test/unit/coins/ofc.ts b/modules/sdk-core/test/unit/coins/ofc.ts new file mode 100644 index 0000000000..d9969314cf --- /dev/null +++ b/modules/sdk-core/test/unit/coins/ofc.ts @@ -0,0 +1,166 @@ +/** + * @prettier + */ +import sinon from 'sinon'; +import 'should'; +import { Ofc } from '../../../src/coins/ofc'; +import { OfcToken } from '../../../src/coins/ofcToken'; +import { BaseCoin } from '../../../src/bitgo/baseCoin/baseCoin'; +import { Wallet } from '../../../src'; + +const TEST_TOKEN_CONFIG = { + coin: 'ofcusdt', + decimalPlaces: 6, + name: 'OFCUSDT', + type: 'ofcusdt', + backingCoin: 'usdt', + isFiat: false, +}; + +describe('Ofc / OfcToken', function () { + let mockBitGo: any; + + beforeEach(function () { + mockBitGo = { url: sinon.stub().returns('https://test.bitgo.com') }; + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('signMessage', function () { + let ofc: Ofc; + + beforeEach(function () { + ofc = new Ofc(mockBitGo); + }); + + describe('with a Wallet instance', function () { + it('should delegate to wallet.toTradingAccount().signPayload() and return a Buffer', async function () { + const hexSignature = 'deadbeef'; + const signPayloadStub = sinon.stub().resolves(hexSignature); + + const mockBaseCoin = { supportsTss: sinon.stub().returns(false), getMPCAlgorithm: sinon.stub() }; + const walletData = { + id: 'wallet-id', + keys: ['key1', 'key2', 'key3'], + multisigType: 'onchain', + enterprise: 'ent-id', + }; + const wallet = new Wallet(mockBitGo, mockBaseCoin as any, walletData); + sinon.stub(wallet, 'toTradingAccount').returns({ signPayload: signPayloadStub } as any); + + const message = 'test message'; + const result = await ofc.signMessage(wallet, message); + + signPayloadStub.calledOnceWith({ payload: message }).should.be.true(); + result.should.deepEqual(Buffer.from(hexSignature, 'hex')); + }); + }); + + describe('with a prv key', function () { + it('should delegate to the base class signMessage', async function () { + const expectedResult = Buffer.from('basesignature', 'hex'); + const key = { + prv: 'xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqhuCo36EkzGH6qiT9mJHBvuPKtLRYD4NxFb5hgXMQBB2LLT6mxLDHHo', + }; + const superSignMessageStub = sinon + .stub(BaseCoin.prototype, 'signMessage') + .callsFake(async (k: { prv: string }) => { + if (k.prv === key.prv) { + return expectedResult; + } + throw new Error(`signMessage called with unexpected prv: ${k.prv}`); + }); + + const message = 'test message'; + const result = await ofc.signMessage(key, message); + + superSignMessageStub.calledOnceWith(key, message).should.be.true(); + result.should.equal(expectedResult); + }); + }); + }); + + describe('signTransaction (OfcToken)', function () { + let ofcToken: OfcToken; + const payload = '{"amount":"100","from":"alice","to":"bob"}'; + + beforeEach(function () { + ofcToken = new OfcToken(mockBitGo, TEST_TOKEN_CONFIG); + }); + + describe('with wallet and no prv (BitGo remote signing)', function () { + it('should call wallet.toTradingAccount().signPayload() without a passphrase', async function () { + const hexSignature = 'aabbccdd'; + const signPayloadStub = sinon.stub().resolves(hexSignature); + const mockWallet = { toTradingAccount: sinon.stub().returns({ signPayload: signPayloadStub }) }; + + const result = await ofcToken.signTransaction({ txPrebuild: { payload }, wallet: mockWallet as any }); + + signPayloadStub.calledOnceWith({ payload, walletPassphrase: undefined }).should.be.true(); + result.should.deepEqual({ halfSigned: { payload, signature: hexSignature } }); + }); + }); + + describe('with wallet and prv (local signing routed through wallet)', function () { + it('should call wallet.toTradingAccount().signPayload() with the wallet passphrase', async function () { + const hexSignature = 'aabbccdd'; + const passphrase = 'test-passphrase'; + const signPayloadStub = sinon.stub().resolves(hexSignature); + const mockWallet = { toTradingAccount: sinon.stub().returns({ signPayload: signPayloadStub }) }; + + const result = await ofcToken.signTransaction({ + txPrebuild: { payload }, + wallet: mockWallet as any, + prv: passphrase, + }); + + signPayloadStub.calledOnceWith({ payload, walletPassphrase: passphrase }).should.be.true(); + result.should.deepEqual({ halfSigned: { payload, signature: hexSignature } }); + }); + }); + + describe('with prv only (local signing without wallet)', function () { + it('should sign locally and return the correct halfSigned result', async function () { + const signatureBytes = Buffer.from('ccddee', 'hex'); + const prv = 'test-prv'; + sinon.stub(BaseCoin.prototype, 'signMessage').callsFake(async (k: { prv: string }) => { + if (k.prv === prv) { + return signatureBytes; + } + throw new Error(`signMessage called with unexpected prv: ${k.prv}`); + }); + + const result = await ofcToken.signTransaction({ txPrebuild: { payload }, prv }); + + result.should.deepEqual({ halfSigned: { payload, signature: signatureBytes.toString('hex') } }); + }); + + it('should pass the prv to signMessage', async function () { + const signatureBytes = Buffer.from('ccddee', 'hex'); + const prv = 'test-prv'; + const superSignMessageStub = sinon + .stub(BaseCoin.prototype, 'signMessage') + .callsFake(async (k: { prv: string }) => { + if (k.prv === prv) { + return signatureBytes; + } + throw new Error(`signMessage called with unexpected prv: ${k.prv}`); + }); + + await ofcToken.signTransaction({ txPrebuild: { payload }, prv }); + + superSignMessageStub.calledOnceWith({ prv }, payload).should.be.true(); + }); + }); + + describe('with neither wallet nor prv', function () { + it('should throw an error', async function () { + await ofcToken + .signTransaction({ txPrebuild: { payload } }) + .should.be.rejectedWith('You must pass in either one of wallet or prv'); + }); + }); + }); +});