From ded3b89b2212ab2518135e9967f0a1a110a71f70 Mon Sep 17 00:00:00 2001 From: alextse-bg Date: Thu, 23 Apr 2026 11:35:56 -0400 Subject: [PATCH 1/3] feat(sdk-core): added OFC BitGo signing on trading accounts object Ticket: WCN-217-1 --- .../src/bitgo/trading/iTradingAccount.ts | 2 +- .../src/bitgo/trading/tradingAccount.ts | 36 ++++- modules/sdk-core/src/bitgo/wallet/iWallet.ts | 1 + .../test/unit/bitgo/trading/tradingAccount.ts | 130 ++++++++++++++++++ 4 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 modules/sdk-core/test/unit/bitgo/trading/tradingAccount.ts diff --git a/modules/sdk-core/src/bitgo/trading/iTradingAccount.ts b/modules/sdk-core/src/bitgo/trading/iTradingAccount.ts index 462c7e25ba..cb8c7bf63c 100644 --- a/modules/sdk-core/src/bitgo/trading/iTradingAccount.ts +++ b/modules/sdk-core/src/bitgo/trading/iTradingAccount.ts @@ -2,7 +2,7 @@ import { ITradingNetwork } from './network'; export interface SignPayloadParameters { payload: string | Record; - walletPassphrase: string; + walletPassphrase?: 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..b8ed923d16 100644 --- a/modules/sdk-core/src/bitgo/trading/tradingAccount.ts +++ b/modules/sdk-core/src/bitgo/trading/tradingAccount.ts @@ -23,13 +23,47 @@ 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) { + 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 stored in a remote KMS + * @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'); + } + if (walletData.keys.length < 2) { + throw new Error('Wallet does not support BitGo signing'); + } + + const url = this.wallet.url('/tx/sign'); + const { signature } = await this.wallet.bitgo.post(url).send(params.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, diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 1f65b5a977..62d790d9c0 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -909,6 +909,7 @@ export interface WalletData { evmKeyRingReferenceWalletId?: string; isParent?: boolean; enabledChildChains?: string[]; + userKeySigningRequired?: string; } export interface RecoverTokenOptions { 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..6e796e1d29 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/trading/tradingAccount.ts @@ -0,0 +1,130 @@ +/** + * @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; + + const enterpriseId = 'test-enterprise-id'; + const walletPassphrase = 'test-passphrase'; + const encryptedPrv = 'encrypted-prv'; + const decryptedPrv = 'decrypted-prv'; + const signature = 'aabbccdd'; + + beforeEach(function () { + const postStub = sinon.stub(); + postStub.returns({ + send: sinon.stub().returns({ + result: sinon.stub().resolves({ signature }), + }), + }); + + mockBitGo = { + post: postStub, + decrypt: sinon.stub().returns(decryptedPrv), + }; + + mockBaseCoin = { + keychains: sinon.stub().returns({ + get: sinon.stub().resolves({ encryptedPrv }), + }), + signMessage: sinon.stub().resolves(Buffer.from(signature, 'hex')), + }; + + 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('signPayload', function () { + const payload = { data: 'test-payload' }; + const payloadString = 'test-payload-string'; + + describe('without walletPassphrase (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(); + result.should.equal(signature); + }); + + 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 is 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' + ); + }); + + it('should throw if wallet has fewer than 2 keys and no passphrase is 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'); + }); + }); + + 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); + }); + }); + }); +}); From 3e4c1a5f8fe9f40f164387ec2e2aba4294eaa746 Mon Sep 17 00:00:00 2001 From: alextse-bg Date: Thu, 23 Apr 2026 12:38:18 -0400 Subject: [PATCH 2/3] feat(sdk-core): added OFC BitGo signing on wallet and coins object allow wallet and coins object to sign using the BitGo key if the passphrase is not provided during signing Ticket: WCN-217-2 --- modules/sdk-core/src/coins/ofc.ts | 21 +++ modules/sdk-core/src/coins/ofcToken.ts | 20 ++- .../bitgo/wallet/ofcWalletSignTransaction.ts | 79 ++++++++++ modules/sdk-core/test/unit/coins/ofc.ts | 146 ++++++++++++++++++ 4 files changed, 262 insertions(+), 4 deletions(-) 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/coins/ofc.ts b/modules/sdk-core/src/coins/ofc.ts index 4e089ba5a8..ab284d7071 100644 --- a/modules/sdk-core/src/coins/ofc.ts +++ b/modules/sdk-core/src/coins/ofc.ts @@ -15,6 +15,7 @@ import { SignTransactionOptions, VerifyAddressOptions, VerifyTransactionOptions, + Wallet, } from '../'; export class Ofc extends BaseCoin { @@ -104,6 +105,26 @@ export class Ofc extends BaseCoin { throw new MethodNotImplementedError(); } + /** + * Signs a message using a trading wallet's BitGo Key + * @param wallet - uses the BitGo key of this trading wallet to sign the message remotely in a KMS + * @param message + */ + async signMessage(wallet: Wallet, message: string): Promise; + /** + * Signs a message using the private key + * @param key - uses the private key to sign the message + * @param message + */ + async signMessage(key: { prv: string }, message: string): Promise; + async signMessage(keyOrWallet: { prv: string } | Wallet, message: string): Promise { + if (!(keyOrWallet instanceof Wallet)) { + return super.signMessage(keyOrWallet as { prv: string }, message); + } + const signatureHexString = await (keyOrWallet as Wallet).toTradingAccount().signPayload({ payload: message }); + return Buffer.from(signatureHexString, 'hex'); + } + /** @inheritDoc */ auditDecryptedKey(params: AuditDecryptedKeyParams) { throw new MethodNotImplementedError(); diff --git a/modules/sdk-core/src/coins/ofcToken.ts b/modules/sdk-core/src/coins/ofcToken.ts index 42cef18e75..e100285443 100644 --- a/modules/sdk-core/src/coins/ofcToken.ts +++ b/modules/sdk-core/src/coins/ofcToken.ts @@ -9,6 +9,7 @@ import { SignTransactionOptions as BaseSignTransactionOptions, SignedTransaction, ITransactionRecipient, + Wallet, } from '../'; import { isBolt11Invoice } from '../lightning'; @@ -18,7 +19,8 @@ export interface SignTransactionOptions extends BaseSignTransactionOptions { txPrebuild: { payload: string; }; - prv: string; + prv?: string; + wallet?: Wallet; } export { OfcTokenConfig }; @@ -107,15 +109,25 @@ export class OfcToken extends Ofc { } /** - * Assemble keychain and half-sign prebuilt transaction + * Signs a half-signed OFC transaction. + * Signs the transaction remotely using the BitGo key if prv is not provided. * @param params * @returns {Promise} */ async signTransaction(params: SignTransactionOptions): Promise { const txPrebuild = params.txPrebuild; const payload = txPrebuild.payload; - const signatureBuffer = (await this.signMessage(params, payload)) as any; - const signature: string = signatureBuffer.toString('hex'); + + let signature: string; + if (params.wallet) { + signature = await params.wallet.toTradingAccount().signPayload({ payload, walletPassphrase: params.prv }); + } else if (params.prv) { + const signatureBuffer = (await this.signMessage({ prv: params.prv }, payload)) as any; + signature = signatureBuffer.toString('hex'); + } else { + throw new Error('You must pass in either one of wallet or prv'); + } + return { halfSigned: { payload, signature } } as any; } 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..4f0d26bbe4 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/wallet/ofcWalletSignTransaction.ts @@ -0,0 +1,79 @@ +/** + * @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); + }); +}); 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..780c288645 --- /dev/null +++ b/modules/sdk-core/test/unit/coins/ofc.ts @@ -0,0 +1,146 @@ +/** + * @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 superSignMessageStub = sinon.stub(BaseCoin.prototype, 'signMessage').resolves(expectedResult); + + const key = { + prv: 'xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqhuCo36EkzGH6qiT9mJHBvuPKtLRYD4NxFb5hgXMQBB2LLT6mxLDHHo', + }; + 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'); + sinon.stub(BaseCoin.prototype, 'signMessage').resolves(signatureBytes); + + const result = await ofcToken.signTransaction({ txPrebuild: { payload }, prv: 'test-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 superSignMessageStub = sinon.stub(BaseCoin.prototype, 'signMessage').resolves(signatureBytes); + const prv = 'test-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'); + }); + }); + }); +}); From 16edea3d5bbabfbbbb9cd423edfeb6f0984f73d9 Mon Sep 17 00:00:00 2001 From: mrdanish26 Date: Thu, 23 Apr 2026 15:06:38 -0700 Subject: [PATCH 3/3] feat(express): support optional passphrase in handleV2OFCSignPayload When no walletPassphrase is present in the request body or environment, pass undefined to tradingAccount.signPayload() instead of throwing. The SDK routes passphrase-less signing through KMS internally. Ticket: WCN-215-1 --- modules/express/src/clientRoutes.ts | 13 ++- .../test/unit/typedRoutes/ofcSignPayload.ts | 93 +++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index b1f151031f..cddc7b8c2a 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -403,6 +403,15 @@ function getWalletPwFromEnv(walletId: string): string { return walletPw; } +/** + * Returns the wallet passphrase from the environment, or undefined if not set. + * Unlike getWalletPwFromEnv, this does not throw when the env variable is absent. + * Use this when the passphrase is optional (e.g. KMS-backed wallets). + */ +function findWalletPwFromEnv(walletId: string): string | undefined { + return process.env[`WALLET_${walletId}_PASSPHRASE`]; +} + async function getEncryptedPrivKey(path: string, walletId: string): Promise { const privKeyFile = await fs.readFile(path, { encoding: 'utf8' }); const encryptedPrivKey = JSON.parse(privKeyFile); @@ -629,7 +638,9 @@ export async function handleV2OFCSignPayload( throw new ApiResponseError(`Could not find OFC wallet ${walletId}`, 404); } - const walletPassphrase = bodyWalletPassphrase || getWalletPwFromEnv(wallet.id()); + // Prefer the passphrase from the request body; fall back to the env var. + // If neither is present, pass undefined — signPayload() routes to KMS internally. + const walletPassphrase = bodyWalletPassphrase ?? findWalletPwFromEnv(wallet.id()); const tradingAccount = wallet.toTradingAccount(); const stringifiedPayload = typeof payload === 'string' ? payload : JSON.stringify(payload); const signature = await tradingAccount.signPayload({ diff --git a/modules/express/test/unit/typedRoutes/ofcSignPayload.ts b/modules/express/test/unit/typedRoutes/ofcSignPayload.ts index 59cdd54f4d..9b0685183d 100644 --- a/modules/express/test/unit/typedRoutes/ofcSignPayload.ts +++ b/modules/express/test/unit/typedRoutes/ofcSignPayload.ts @@ -223,10 +223,103 @@ describe('OfcSignPayload codec tests', function () { const decodedResponse = assertDecode(OfcSignPayloadResponse200, result.body); assert.strictEqual(decodedResponse.signature, mockSignPayloadResponse.signature); + // Verify env passphrase was forwarded to signPayload + const signCall = mockTradingAccount.signPayload.getCall(0); + assert.ok(signCall, 'tradingAccount.signPayload should have been called'); + assert.strictEqual(signCall.args[0].walletPassphrase, 'env_passphrase', 'env passphrase should be forwarded'); + // Cleanup environment variable delete process.env['WALLET_ofc-wallet-id-123_PASSPHRASE']; }); + it('should pass undefined walletPassphrase to signPayload when no passphrase in body or env (KMS path)', async function () { + const requestBody = { + walletId: 'ofc-wallet-id-no-passphrase', + payload: { amount: '1000000', currency: 'USD' }, + // no walletPassphrase + }; + + // Ensure no env var is set for this wallet + delete process.env['WALLET_ofc-wallet-id-no-passphrase_PASSPHRASE']; + + const mockTradingAccount = { + signPayload: sinon.stub().resolves(mockSignPayloadResponse.signature), + }; + + const mockWallet = { + id: () => requestBody.walletId, + toTradingAccount: sinon.stub().returns(mockTradingAccount), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post('/api/v2/ofc/signPayload') + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + const decodedResponse = assertDecode(OfcSignPayloadResponse200, result.body); + assert.strictEqual(decodedResponse.signature, mockSignPayloadResponse.signature); + + // signPayload must be called with walletPassphrase=undefined so the SDK routes to KMS + const signCall = mockTradingAccount.signPayload.getCall(0); + assert.ok(signCall, 'tradingAccount.signPayload should have been called'); + assert.strictEqual( + signCall.args[0].walletPassphrase, + undefined, + 'walletPassphrase should be undefined to trigger KMS signing' + ); + }); + + it('should prefer body walletPassphrase over env passphrase', async function () { + const requestBody = { + walletId: 'ofc-wallet-id-123', + payload: { amount: '500' }, + walletPassphrase: 'body_passphrase', + }; + + // Set a different env passphrase — body should win + process.env['WALLET_ofc-wallet-id-123_PASSPHRASE'] = 'env_passphrase'; + + const mockTradingAccount = { + signPayload: sinon.stub().resolves(mockSignPayloadResponse.signature), + }; + + const mockWallet = { + id: () => requestBody.walletId, + toTradingAccount: sinon.stub().returns(mockTradingAccount), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post('/api/v2/ofc/signPayload') + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + + // body passphrase should take precedence + const signCall = mockTradingAccount.signPayload.getCall(0); + assert.ok(signCall, 'tradingAccount.signPayload should have been called'); + assert.strictEqual( + signCall.args[0].walletPassphrase, + 'body_passphrase', + 'body passphrase should take precedence over env' + ); + + delete process.env['WALLET_ofc-wallet-id-123_PASSPHRASE']; + }); + it('should successfully sign complex nested JSON payload', async function () { const requestBody = { walletId: 'ofc-wallet-id-123',