Skip to content
Open
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
8 changes: 8 additions & 0 deletions modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
isV2Envelope,
} from '../baseTypes';
import { InvalidTransactionError } from '../../../errors';
import { resolveEffectiveTxParams } from '../recipientUtils';
import { CreateEddsaBitGoKeychainParams, CreateEddsaKeychainParams, KeyShare, YShare } from './types';
import baseTSSUtils from '../baseTSSUtils';
import { BaseEddsaUtils } from './base';
Expand Down Expand Up @@ -690,6 +691,13 @@ export class EddsaUtils extends baseTSSUtils<KeyShare> {
);
unsignedTx =
apiVersion === 'full' ? txRequestResolved.transactions![0].unsignedTx : txRequestResolved.unsignedTxs[0];

await this.baseCoin.verifyTransaction({
txPrebuild: { txHex: unsignedTx.serializedTxHex ?? unsignedTx.signableHex },
txParams: resolveEffectiveTxParams(txRequestResolved, params.txParams),
wallet: this.wallet,
walletType: this.wallet.multisigType(),
});
} else if (requestType === RequestType.message) {
assert(txRequestResolved.messages?.length, 'Unable to find messages in txRequest for message signing');
const message = txRequestResolved.messages[0];
Expand Down
4 changes: 3 additions & 1 deletion modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
TxRequest,
isV2Envelope,
} from '../baseTypes';
import { resolveEffectiveTxParams } from '../recipientUtils';
import { EncryptionVersion } from '../../../../api';
import { BitGoBase } from '../../../bitgoBase';
import { BaseEddsaUtils } from './base';
Expand Down Expand Up @@ -446,9 +447,10 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils {
assert(txOrMessageToSign, 'Missing signableHex in unsignedTx');
derivationPath = unsignedTx.derivationPath;
bufferContent = Buffer.from(txOrMessageToSign, 'hex');

await this.baseCoin.verifyTransaction({
txPrebuild: { txHex: unsignedTx.serializedTxHex ?? txOrMessageToSign },
txParams: params.txParams || { recipients: [] },
txParams: resolveEffectiveTxParams(txRequest, params.txParams),
wallet: this.wallet,
walletType: this.wallet.multisigType(),
});
Expand Down
9 changes: 9 additions & 0 deletions modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ export const NO_RECIPIENT_TX_TYPES = new Set([
'transferOfferWithdrawn',
'cantonCommand',
'pledge',

// SOL token account management
'closeAssociatedTokenAccount',

// ADA governance
'voteDelegation',

// CANTON multi-step transfer lifecycle
'transferAcknowledge',
]);

/**
Expand Down
176 changes: 176 additions & 0 deletions modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1835,3 +1835,179 @@ describe('signRecoveryEddsaMPCv2', () => {
);
});
});

describe('EdDSA MPCv2 signRequestBase recipient verification', () => {
let eddsaMPCv2Utils: EddsaMPCv2Utils;
let verifyTransactionStub: sinon.SinonStub;

const walletId = 'wallet-verify-test';
const signableHex = 'deadbeef';
const serializedTxHex = 'cafebabe';
const derivationPath = 'm/0';
// Dummy key — tests only verify that verifyTransaction is called before MPC signing starts.
// Real DKG key generation is avoided to prevent WASM SIGSEGV on Node 22 CI.
const dummyPrv = randomBytes(64).toString('base64');

beforeEach(async () => {
verifyTransactionStub = sinon.stub().resolves(true);

const mockBitgo = {
getEnv: sinon.stub().returns('test'),
setRequestTracer: sinon.stub(),
url: sinon.stub().callsFake((path: string) => `https://test.bitgo.com${path}`),
post: sinon.stub().returns({
send: sinon.stub().returnsThis(),
set: sinon.stub().returnsThis(),
result: sinon.stub().rejects(new Error('mock: HTTP not available')),
}),
} as unknown as BitGoBase;

const mockCoin = {
getMPCAlgorithm: sinon.stub().returns('eddsa'),
verifyTransaction: verifyTransactionStub,
} as unknown as IBaseCoin;

const mockWallet = {
id: sinon.stub().returns(walletId),
multisigType: sinon.stub().returns('tss'),
multisigTypeVersion: sinon.stub().returns('MPCv2'),
} as unknown as IWallet;

eddsaMPCv2Utils = new EddsaMPCv2Utils(mockBitgo, mockCoin, mockWallet);
sinon
.stub(eddsaMPCv2Utils as any, 'pickBitgoPubGpgKeyForSigning')
.resolves(await pgp.readKey({ armoredKey: (await generateGPGKeyPair('ed25519')).publicKey }));
});

afterEach(() => {
sinon.restore();
});

it('should call verifyTransaction with resolveEffectiveTxParams output', async () => {
const txRequest: TxRequest = {
txRequestId: 'txreq-verify-1',
walletId,
apiVersion: 'full',
transactions: [
{
unsignedTx: { signableHex, serializedTxHex, derivationPath },
signatureShares: [],
},
],
intent: {
intentType: 'payment',
recipients: [{ address: { address: 'solAddr1' }, amount: { value: '5000000', symbol: 'tsol' } }],
},
unsignedTxs: [],
} as unknown as TxRequest;

try {
await eddsaMPCv2Utils.signTxRequest({
txRequest,
txParams: { recipients: [{ address: 'solAddr1', amount: '5000000' }] },
prv: dummyPrv,
reqId: new RequestTracer(),
});
} catch {
// Expected to fail at MPC signing rounds — we only care about verifyTransaction
}

sinon.assert.calledOnce(verifyTransactionStub);
const call = verifyTransactionStub.getCall(0);
assert.strictEqual(call.args[0].txPrebuild.txHex, serializedTxHex);
assert.deepStrictEqual(call.args[0].txParams.recipients, [{ address: 'solAddr1', amount: '5000000' }]);
});

it('should resolve recipients from intent when txParams has none', async () => {
const txRequest: TxRequest = {
txRequestId: 'txreq-verify-2',
walletId,
apiVersion: 'full',
transactions: [
{
unsignedTx: { signableHex, serializedTxHex, derivationPath },
signatureShares: [],
},
],
intent: {
intentType: 'payment',
recipients: [{ address: { address: 'solAddr2' }, amount: { value: '1000', symbol: 'tsol' } }],
},
unsignedTxs: [],
} as unknown as TxRequest;

try {
await eddsaMPCv2Utils.signTxRequest({
txRequest,
prv: dummyPrv,
reqId: new RequestTracer(),
});
} catch {
// Expected to fail at MPC signing rounds
}

sinon.assert.calledOnce(verifyTransactionStub);
const call = verifyTransactionStub.getCall(0);
assert.strictEqual(call.args[0].txParams.recipients[0].address, 'solAddr2');
assert.strictEqual(call.args[0].txParams.recipients[0].amount, '1000');
});

it('should not call verifyTransaction for message signing', async () => {
const txRequest: TxRequest = {
txRequestId: 'txreq-verify-msg',
walletId,
apiVersion: 'full',
messages: [
{
messageEncoded: 'deadbeef',
derivationPath: 'm/0',
},
],
unsignedTxs: [],
} as unknown as TxRequest;

try {
await eddsaMPCv2Utils.signTxRequestForMessage({
txRequest,
prv: dummyPrv,
reqId: new RequestTracer(),
messageRaw: 'test message',
bufferToSign: Buffer.from('deadbeef', 'hex'),
});
} catch {
// Expected to fail at MPC signing rounds
}

sinon.assert.notCalled(verifyTransactionStub);
});

it('should use signableHex as fallback when serializedTxHex is missing', async () => {
const txRequest: TxRequest = {
txRequestId: 'txreq-verify-fallback',
walletId,
apiVersion: 'full',
transactions: [
{
unsignedTx: { signableHex, derivationPath },
signatureShares: [],
},
],
intent: { intentType: 'consolidate' },
unsignedTxs: [],
} as unknown as TxRequest;

try {
await eddsaMPCv2Utils.signTxRequest({
txRequest,
prv: dummyPrv,
reqId: new RequestTracer(),
});
} catch {
// Expected to fail at MPC signing rounds
}

sinon.assert.calledOnce(verifyTransactionStub);
const call = verifyTransactionStub.getCall(0);
assert.strictEqual(call.args[0].txPrebuild.txHex, signableHex);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ describe('recipientUtils', function () {
'transferOfferWithdrawn',
'cantonCommand',
'pledge',
'closeAssociatedTokenAccount',
'voteDelegation',
'transferAcknowledge',
];
expected.forEach((t) => assert.ok(NO_RECIPIENT_TX_TYPES.has(t), `${t} should be in NO_RECIPIENT_TX_TYPES`));
assert.strictEqual(NO_RECIPIENT_TX_TYPES.size, expected.length);
Expand Down
Loading