Skip to content

feat(sdk-core): add EdDSA MPCv2 full 3-round signing orchestration#8697

Open
Marzooqa wants to merge 1 commit intoWCI-153from
WCI-154
Open

feat(sdk-core): add EdDSA MPCv2 full 3-round signing orchestration#8697
Marzooqa wants to merge 1 commit intoWCI-153from
WCI-154

Conversation

@Marzooqa
Copy link
Copy Markdown
Contributor

@Marzooqa Marzooqa commented May 5, 2026

  • Add signTxRequest, signTxRequestForMessage, and signRequestBase to EddsaMPCv2Utils implementing WASM round 0 + API rounds 1 and 2
  • Verify PGP signatures on BitGo round1Output and round2Output
  • Return latestTxRequest after round 2 (round 3 + send deferred to next PR)
  • Add signTxRequest unit tests using nock with real BitGo DSG sessions

Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com

TICKET: WCI-154

@linear-code
Copy link
Copy Markdown

linear-code Bot commented May 5, 2026

@Marzooqa Marzooqa changed the title feat(sdk-core): add EdDSA MPCv2 signing rounds 1 and 2 feat(sdk-core): add EdDSA MPCv2 full 3-round signing orchestration May 6, 2026
@Marzooqa Marzooqa marked this pull request as ready for review May 6, 2026 15:14
@Marzooqa Marzooqa requested review from a team as code owners May 6, 2026 15:14
Copy link
Copy Markdown
Contributor

@zahin-mohammad zahin-mohammad left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against base #8687. Built locally — tsc --noEmit clean in sdk-core, signTxRequest.ts test passes (5/5).

The orchestration faithfully mirrors ECDSA's EcdsaMPCv2Utils.signRequestBase and the protocol structure looks right: WASM rounds 0/1/2 interleaved with API rounds 1/2/3, no client-side verification on round 3 (WP finalises). Nothing here freezes public API surface — the two new public methods (signTxRequest, signTxRequestForMessage) match the existing ITSSUtils/ECDSA shape, and signRequestBase is private. So everything below is follow-up safe.

Most significant finding is the missing mpcv2PartyId plumbing — backup signing is implicitly unsupported here even though the helpers from #8687 already accept the party id. ECDSA supports it; this is a behavioral gap vs the parallel path.

See inline comments.

}

// ── WASM Round 0 ──────────────────────────────────────────────────────────
const userDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.USER);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Backup-signer support gap. params.mpcv2PartyId (declared on TSSParamsWithPrv for exactly this purpose) is silently dropped. ECDSA's signRequestBase plumbs it through to both DSG init and the share helpers (see ecdsaMPCv2.ts:784, 792, 827, 845, 876, 893); here the user is hardcoded to MPCv2PartiesEnum.USER.

The helpers from #8687 already accept partyId: 0 | 1, so plumbing is mechanical:

const partyId = params.mpcv2PartyId ?? 0;
const userParty = partyId === 0 ? MPCv2PartiesEnum.USER : MPCv2PartiesEnum.BACKUP;
const userDsg = new EddsaMPSDsg.DSG(userParty);
userDsg.initDsg(userKeyShare, bufferContent, derivationPath, MPCv2PartiesEnum.BITGO);
// pass partyId through each getEddsaSignatureShareRoundN call

Follow-up safe — but worth filing as a Linear ticket so it doesn't get lost. Without it, EdDSA MPCv2 backup-signer flow can't go through this path.

* API Round 1 → send userMsg1 / recv bitgoMsg1
* WASM Round 1 → handleIncomingMessages([userMsg1, bitgoMsg1]) (produces userMsg2)
* API Round 2 → send userMsg2 / recv bitgoMsg2
* WASM Round 2 → handleIncomingMessagexs([userMsg2, bitgoMsg2]) (produces userMsg3)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo: handleIncomingMessagexshandleIncomingMessages.

txOrMessageToSign = unsignedTx.signableHex;
derivationPath = unsignedTx.derivationPath;
bufferContent = Buffer.from(txOrMessageToSign, 'hex');
assert(txOrMessageToSign, 'Missing signableHex in unsignedTx');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assert ordering — the assert runs after Buffer.from(txOrMessageToSign, 'hex'). If signableHex is undefined, Buffer.from(undefined, 'hex') throws a TypeError first and the helpful assertion message never surfaces. Same pattern at L388-389 for the message branch. Suggest moving the assert above the Buffer.from call:

txOrMessageToSign = unsignedTx.signableHex;
assert(txOrMessageToSign, 'Missing signableHex in unsignedTx');
bufferContent = Buffer.from(txOrMessageToSign, 'hex');

);

// ── WASM Round 1 ──────────────────────────────────────────────────────────
const [userMsg2] = userDsg.handleIncomingMessages([userMsg1, bitgoDeserializedMsg1]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defensive: handleIncomingMessages returns DeserializedMessages (an array). If WASM ever returns [] or an unexpected length, userMsg2 becomes undefined and the next getEddsaSignatureShareRound2(undefined, ...) crashes with a confusing PGP error. A targeted assert(userMsg2, 'WASM round 1 produced no message') after the destructure would surface the real failure. Same applies to userMsg3 at L478.


const parsedBitGoToUserSigShareRoundTwo = decodeWithCodec(
EddsaMPCv2SignatureShareRound2Output,
JSON.parse(txRequestSignatureShares[txRequestSignatureShares.length - 1].share),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fragile share lookup: txRequestSignatureShares[length - 1] assumes BitGo's response share is always last. ECDSA does the same thing so it's not a regression, but it would be more robust to filter explicitly:

const bitgoShare = txRequestSignatureShares.find(
  (s) => s.from === SignatureShareType.BITGO && s.to === SignatureShareType.USER
);
assert(bitgoShare, 'Missing BitGo round-2 share');

Follow-up safe; flagging here so the pattern doesn't propagate further.

}

/**
* Signs the message associated with the transaction request.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note (not blocking, pre-existing pattern): signTxRequestForMessage accepts a TSSParamsForMessageWithPrv which includes messageRaw: string and bufferToSign: Buffer, but signRequestBase ignores both — it derives bufferContent from txRequest.messages[0].messageEncoded. ECDSA does the same thing so this PR isn't introducing the issue, but it's a quiet footgun: a caller who passes bufferToSign thinking that's what gets signed will be confused when actual signing uses the txRequest's messageEncoded. Worth filing as a separate cleanup ticket against the shared TSSParamsForMessageWithPrv contract.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.should.be.rejectedWith(/Unexpected signature share response/);
});

it('successfully signs a txRequest for an mps hot wallet after receiving multiple 429 errors', async function () {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage gap: rate-limit retries are tested for round 2 only. Rounds 1 and 3 use the same sendSignatureShareV2 retry path but aren't exercised. A parameterised helper that injects 429s on each round in turn would be cheap to add.

nockPromises[0].isDone().should.be.true();
nockPromises[1].isDone().should.be.true();
nockPromises[2].isDone().should.be.true();
nockPromises[3].isDone().should.be.true();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage gaps that pair with the mpcv2PartyId finding on the impl side:

  • Tests only cover party 0 (USER); BACKUP path is untested.
  • No round-2 tampered-signature negative test (round 1 negative is at L198, but round 2's verifyBitGoEddsaMessageRound2 could swallow tampering with no test catching it).
  • No test for malformed/missing round-3 server response — currently any failure there surfaces as a confusing sendTxRequest error rather than something localised to round 3.

@Marzooqa Marzooqa force-pushed the WCI-153 branch 2 times, most recently from 969535a to 3d581de Compare May 7, 2026 13:10
- Add signTxRequest, signTxRequestForMessage, and signRequestBase to
  EddsaMPCv2Utils implementing the full online DSG protocol:
  WASM round 0, API rounds 1-3, and sendTxRequest
- Support BACKUP party signing via mpcv2PartyId param
- Verify PGP signatures on BitGo round1Output and round2Output
- Use pickBitgoPubGpgKeyForSigning with isEddsaMpcv2=true for ed25519 key
- Use decodeWithCodec for type-safe parsing of signature share responses
- Move getBitgoSignatureShare helper to tss/common.ts; use .find() by
  from/to instead of fragile array-index lookup
- Add defensive asserts after each WASM and API round
- Add comprehensive signTxRequest unit tests covering:
  - tx signing and message signing (all 3 rounds + send)
  - BACKUP party signing path
  - 429 rate-limit retry handling on rounds 1, 2, and 3
  - rejection after 4+ consecutive 429 errors
  - rejection on wrong round1Output type
  - rejection on malformed round3 response

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

TICKET: WCI-154
Copy link
Copy Markdown
Contributor

@zahin-mohammad zahin-mohammad left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — gated on #8687 landing first (per the DO NOT MERGE label).

All 8 prior comments are resolved in this revision:

  • Backup-signer plumbing: params.mpcv2PartyId now flows through DSG init + all three getSignatureShareRound{One,Two,Three} calls; signerShareType derived from partyId
  • ✅ Typo handleIncomingMessagexshandleIncomingMessages
  • ✅ Assert ordering moved before Buffer.from(...) in both tx and message branches
  • ✅ Defensive assert(userMsg2, ...) / assert(userMsg3, ...) after each handleIncomingMessages destructure
  • ✅ Fragile [length - 1] share lookup replaced with new getBitgoSignatureShare(shares, signerShareType) helper in tss/common.ts that filters by from === BITGO && to === signerShareType
  • messageRaw/bufferToSign footgun tracked as follow-up WCI-362
  • ✅ Rate-limit retry tests added for rounds 1 and 3 (round 2 was already covered)
  • ✅ Test coverage: BACKUP path ("signs a txRequest with backup key") + malformed round-3 response added. Round-2 tampered-signature is covered at the helper level in #8687 (verifyBitGoMessageRoundTwo should throw on a tampered message), so the orchestration-level gap is acceptable

Residual nits — non-blocking, address in a follow-up

  1. Inconsistent error style in signRequestBase. The GPG-key check uses if (!bitgoGpgPubKey) throw new Error(...) while every other invariant in this method uses assert(...). Unifying on assert(bitgoGpgPubKey, 'Missing BitGo GPG key for MPCv2') would keep the failure-mode style consistent.
  2. Unnamed boolean args to pickBitgoPubGpgKeyForSigning(true, params.reqId, txRequest.enterpriseId, true). This is a pre-existing pattern carried over from ECDSA, not introduced here, but the call site reads as flag soup. Worth a separate cleanup ticket against the helper signature (named-options object or named consts) so the pattern doesn't propagate further.
  3. Round-2 orchestration-level negative test for a wrong-type response would mirror the existing should throw if round 1 response has wrong type and round the symmetry out. Not strictly needed since verifyBitGoMessageRoundTwo is unit-tested at the helper layer, but cheap to add.

None of these block — happy to approve as-is.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants