Skip to content
Merged
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
10 changes: 0 additions & 10 deletions modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1146,16 +1146,6 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils {
params.reqId
);

// Messages are combined and marked delivered by wallet-platform inside
// the final share submission itself (the signed-message Kafka event is
// published there too): by the time this call returns, a message request
// is already terminal. POSTing /send afterwards — as the transaction
// flow does — would be rejected by wallet-platform's pendingDelivery
// assert on the already-delivered request. Re-fetch the request instead,
// mirroring the legacy MPCv1 ECDSA message path.
if (requestType === RequestType.message) {
return getTxRequest(this.bitgo, txRequest.walletId, txRequest.txRequestId, params.reqId);
}
return sendTxRequest(this.bitgo, txRequest.walletId, txRequest.txRequestId, requestType, params.reqId);
}

Expand Down
205 changes: 0 additions & 205 deletions modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,8 @@ import {
SignatureShareRecord,
SignatureShareType,
TxRequest,
RequestTracer,
} from '../../../../../../src';
import { bitgoGpgKey } from './gpgKeys';
import * as openpgp from 'openpgp';

describe('ECDSA MPC v2', async () => {
let userShare: Buffer;
Expand Down Expand Up @@ -1384,206 +1382,3 @@ describe('EcdsaMPCv2Utils.createKeychainsWithExternalSigner', function () {
);
});
});

describe('signRequestBase message finalization', () => {
let sandbox: sinon.SinonSandbox;

beforeEach(() => {
sandbox = sinon.createSandbox();
});

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

const walletID = '62fe536a6b4cf70007acb48c0e7bb0b0';
const fakeReqId = { inc: sinon.stub(), toString: sinon.stub().returns('test-req') } as unknown as RequestTracer;

let userShare: Buffer;
let bitgoKeyShare: Buffer;
before('generate key shares for finalization tests', async () => {
const [userDkgSession, , bitgoDkgSession] = await DklsUtils.generateDKGKeyShares();
userShare = userDkgSession.getKeyShare();
bitgoKeyShare = bitgoDkgSession.getKeyShare();
});

/**
* Drives signRequestBase through the REAL DKLS protocol: the user-side DSG
* session is the one the class builds itself; the BitGo-side session is a
* real DSG advanced by this test's mocked HTTP layer on every /sign POST,
* using the signBitgoMPCv2RoundX helpers. Only the HTTP transport is faked
* (bitgo.get / bitgo.post chains), which is exactly the surface the
* finalization-tail decision acts on: GET txrequests vs POST /send.
*/
const runFullSigningFlow = async (params: { requestType: 'tx' | 'message'; txRequest: TxRequest }) => {
const postedUrls: string[] = [];
const getUrls: string[] = [];
const hashBuffer = Buffer.alloc(32, 1);
const derivationPath = 'm/0';

// The BitGo-side session signs the same hash buffer the user session is
// given via the getMpcv2HashBuffer stub below.
const bitgoSession = new DklsDsg.Dsg(bitgoKeyShare, 2, derivationPath, hashBuffer);

// Carrier shaped for the signBitgoMPCv2RoundX helpers, which index
// transactions[0]; the response is remapped back to the real resource.
const carrier = { transactions: [{ signatureShares: [] }] } as unknown as TxRequest;
let signPostCount = 0;

const mockBg = {
getEnv: () => 'test',
setRequestTracer: () => undefined,
url: (p: string) => p,
get: (url: string) => ({
query() {
return this;
},
retry() {
return this;
},
async result() {
getUrls.push(url);
return {
txRequests: [
{
...params.txRequest,
state: 'delivered',
[params.requestType === 'message' ? 'messages' : 'transactions']: [
{
state: 'signed',
txHash: '0xsigned-message-hash',
signatureShares: [],
...(params.requestType === 'tx'
? { unsignedTx: { derivationPath, signableHex: 'abcd', serializedTxHex: 'abcd' } }
: {}),
},
],
},
],
};
},
}),
post: (url: string) => {
postedUrls.push(url);
return {
_body: undefined as unknown,
send(body?: unknown) {
this._body = body;
return this;
},
async result() {
if (!url.endsWith('/sign')) {
return { ...params.txRequest, state: 'delivered' };
}
signPostCount += 1;
const body = this._body as { signatureShares: SignatureShareRecord[]; signerGpgPublicKey: string };
const userShareRecord = body.signatureShares[0];
if (signPostCount === 1) {
await signBitgoMPCv2Round1(bitgoSession, carrier, userShareRecord, body.signerGpgPublicKey);
} else if (signPostCount === 2) {
await signBitgoMPCv2Round2(bitgoSession, carrier, userShareRecord, body.signerGpgPublicKey);
} else {
await signBitgoMPCv2Round3(bitgoSession, userShareRecord, body.signerGpgPublicKey);
}
const shares = carrier.transactions![0].signatureShares;
return {
...params.txRequest,
[params.requestType === 'message' ? 'messages' : 'transactions']: [{ signatureShares: shares }],
} as unknown as TxRequest;
},
};
},
} as unknown as BitGoBase;

const mockCoin = {
getMPCAlgorithm: () => 'ecdsa',
verifyTransaction: () => Promise.resolve(true),
getConfig: () => ({ family: 'eth' }),
} as unknown as IBaseCoin;
const mockWallet = {
id: () => walletID,
multisigType: () => 'tss',
multisigTypeVersion: () => 'MPCv2',
};
const utils = new EcdsaMPCv2Utils(mockBg, mockCoin, mockWallet as never);
sandbox
.stub(utils as unknown as Record<string, unknown>, 'pickBitgoPubGpgKeyForSigning')
.resolves(await openpgp.readKey({ armoredKey: bitgoGpgKey.public }));
sandbox.stub(utils as unknown as Record<string, unknown>, 'getMpcv2HashBuffer').returns(hashBuffer);

const baseParams = {
txRequest: params.txRequest,
prv: userShare.toString('base64'),
reqId: fakeReqId,
};
const result =
params.requestType === 'message'
? await utils.signTxRequestForMessage({
...baseParams,
messageRaw: 'raw',
messageEncoded: 'abcd',
bufferToSign: Buffer.from('abcd', 'hex'),
})
: await utils.signTxRequest({
...baseParams,
txParams: { recipients: [{ address: '0x' + '00'.repeat(20), amount: '1000' }] },
});

return { result, postedUrls, getUrls };
};

it('message requests finalize via GET (wallet-platform delivers at share ingestion), never POST /send', async () => {
const txRequestId = 'msg-txreq-finalization';
const { result, postedUrls, getUrls } = await runFullSigningFlow({
requestType: 'message',
txRequest: {
txRequestId,
walletId: walletID,
apiVersion: 'full',
state: 'pendingDelivery',
messages: [{ messageRaw: 'raw', messageEncoded: 'abcd', derivationPath: 'm/0' }],
} as unknown as TxRequest,
});

assert.strictEqual(result.state, 'delivered');
assert.strictEqual(
postedUrls.filter((url) => url.endsWith('/send')).length,
0,
'message signing must not POST /send (wallet-platform already delivered at share ingestion)'
);
assert.ok(
postedUrls.every((url) => url.includes(`/txrequests/${txRequestId}/messages/0/sign`)),
'all message POSTs must be share submissions'
);
// The park-check GET is skipped (object txRequest) so the only GET is the
// final re-fetch.
assert.strictEqual(getUrls.length, 1);
assert.ok(getUrls[0].includes('/txrequests'));
});

it('transaction requests still finalize via POST /send', async () => {
const txRequestId = 'tx-txreq-finalization';
const { result, postedUrls } = await runFullSigningFlow({
requestType: 'tx',
txRequest: {
txRequestId,
walletId: walletID,
apiVersion: 'full',
state: 'pendingDelivery',
transactions: [
{
unsignedTx: { derivationPath: 'm/0', signableHex: 'abcd', serializedTxHex: 'abcd' },
signatureShares: [],
},
],
} as unknown as TxRequest,
});

assert.strictEqual(result.state, 'delivered');
assert.strictEqual(
postedUrls.filter((url) => url.endsWith('/transactions/0/send')).length,
1,
'transaction signing must POST /send to broadcast'
);
});
});
Loading