Skip to content

feat(sdk-coin-dot): add MPCv2 signed hot recovery support - #9484

Open
ralph-bitgo[bot] wants to merge 1 commit into
masterfrom
wci-1227-dot-mpcv2-recovery
Open

feat(sdk-coin-dot): add MPCv2 signed hot recovery support#9484
ralph-bitgo[bot] wants to merge 1 commit into
masterfrom
wci-1227-dot-mpcv2-recovery

Conversation

@ralph-bitgo

@ralph-bitgo ralph-bitgo Bot commented Aug 12, 2026

Copy link
Copy Markdown

What

  • Adds isMpcv2SigningMaterial() and addRecoverySignature() private methods to Dot (modules/sdk-coin-dot/src/dot.ts), and updates recover() to detect the keycard format once (MPCv1 JSON vs MPCv2 CBOR) and dispatch signing accordingly.
  • Reuses the shared EdDSA MPCv2 recovery helpers already extracted into @bitgo/sdk-core (getEddsaSigningMaterial, signEddsaMpcV2RecoveryTx) rather than duplicating logic — same pattern as sdk-coin-sol (WCI-398) and abstract-substrate (WCI-1276/WCI-1239).
  • The MPCv2 path runs the MPS DSG protocol locally and hands the raw 64-byte Ed25519 signature to the transaction builder without manually prepending the 0x00 Substrate discriminant, because Transaction#constructSignedPayload already prepends it unconditionally for every signature (the same mechanism the pre-existing MPCv1 path relies on). Manually prepending it, as literally described in the ticket and mirrored from the already-merged abstract-substrate code, double-prefixes and corrupts the signature — verified empirically by round-tripping a signed transaction through decode.
  • Adds a describe('Recover Transactions (MPCv2):') test block covering: MPCv2 signed recovery returning { serializedTx, scanIndex }, MPCv1 regression (still uses getTSSSignature), mismatched bitgoKey vs keycard commonKeyChain throwing, and a cryptographic verification (via nacl.sign.detached.verify against the transaction's signable payload and derived pubkey) that the extrinsic signature is exactly 64 bytes with the discriminant applied once — not just a length check, which would not catch the double-prefix corruption class.
  • recoverConsolidations() MPCv2 support is intentionally out of scope here — tracked separately in WCI-1236.

Why

Wallets are migrating EdDSA MPC from the Zengo implementation (MPCv1) to Silence Labs (MPCv2). DOT's recover() only handled MPCv1 keycards, so self-custody hot recovery would fail for any wallet backed by an MPCv2 keycard. This closes that gap for DOT, matching the detection + signing path already shipped for SOL (WCI-398) and the shared helpers extracted for Substrate coins (WCI-1276/WCI-1239).

Test plan

  • yarn unit-test in modules/sdk-coin-dot — 205 passing, 0 failing (includes the 5 new MPCv2 tests)
  • yarn build:cjs (tsc --build) — compiles cleanly
  • yarn eslint --quiet src/dot.ts test/unit/dot.ts — no errors
  • Manually confirmed the "0x00 discriminant" test fails if the double-prefix bug is reintroduced (temporarily re-added the manual prefix, observed the cryptographic verification correctly fail, then reverted)

Ticket: WCI-1227

@linear-code

linear-code Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

WCI-1227

@bitgo-ai-agent-dev
bitgo-ai-agent-dev Bot force-pushed the wci-1227-dot-mpcv2-recovery branch from 0a4c3b5 to 02798b6 Compare August 12, 2026 11:32
@ralph-bitgo
ralph-bitgo Bot force-pushed the wci-1227-dot-mpcv2-recovery branch from 02798b6 to a079856 Compare August 12, 2026 11:32
@vibhavgo
vibhavgo force-pushed the wci-1227-dot-mpcv2-recovery branch 3 times, most recently from 47b6045 to 5b6afb7 Compare August 13, 2026 06:43
@vibhavgo
vibhavgo marked this pull request as ready for review August 13, 2026 07:08
@vibhavgo
vibhavgo requested a review from a team as a code owner August 13, 2026 07:08
@vibhavgo

Copy link
Copy Markdown
Contributor

AI-generated review — auto-generated by Claude Code (wallet-platform:review-pr). Please use your own judgment before acting on any findings.

Phase 1 — Summary

Adds MPCv2 signed hot recovery support for DOT (sdk-coin-dot), matching the detection+dispatch pattern already shipped for SOL (WCI-398) and abstract-substrate (WCI-1276/WCI-1239). The change introduces getEddsaSigningMaterial() and addRecoverySignature() private methods on Dot, rewires recover() to dispatch on keycard format, and notably does not manually prepend the Substrate 0x00 discriminant (the builder already does it unconditionally — double-prefixing was found and fixed empirically).

Files changed: modules/sdk-coin-dot/src/dot.ts (+237/−48), modules/sdk-coin-dot/test/unit/dot.ts (+159 net). Total: 2 files, ~285 effective additions.

flowchart TD
    A["recover()"] --> B{isUnsignedSweep?}
    B -- yes --> C[Return unsigned serializedTx]
    B -- no --> D["assert userKey / backupKey / walletPassphrase"]
    D --> E["getEddsaSigningMaterial(userKey, passphrase)"]
    E --> F{signingMaterial.version === 'v2'?}
    F -- MPCv2 --> G["signDotMpcV2Recovery() → rawSig (64 bytes)"]
    G --> H["txnBuilder.addSignature(pub, rawSig)"]
    F -- MPCv1 --> I["decryptKeychainPrivateKey(backupKey)"]
    I --> J["EDDSAMethods.getTSSSignature(...)"]
    J --> K["txnBuilder.addSignature(pub, signatureHex)"]
    H --> L["txnBuilder.build() → serializedTx"]
    K --> L
Loading

Phase 2 — Ticket Alignment

Ticket: WCI-1227

Implied scope: "Add MPCv2 signed hot recovery for DOT." The implementation matches exactly — detection, dispatch, MPCv1 regression guard, and a cryptographic correctness test (including the 0x00 discriminant double-prefix finding). The PR body also notes WCI-1236 as a deliberate out-of-scope item (recoverConsolidations()), which is appropriate.

✅ Implementation aligns with ticket scope.


Phase 3 — Index Analysis

No new database queries introduced.


Phase 4 — Type Safety

Scanned all added lines for as any, !., @ts-ignore, and double casts.

Finding Location Assessment
JSON.parse(signingMaterial.userPrv) as EDDSAMethodTypes.UserSigningMaterial dot.ts Existing pattern carried forward; no as any
(Dot.prototype as unknown as { getAccountInfo: sinon.SinonStub }) dot.ts test Uses as unknown as (correct BitGo pattern; not as any)
JSON.parse(backupPrv) as EDDSAMethodTypes.BackupSigningMaterial dot.ts Same as above

No as any, @ts-ignore, or unsafe !. non-null assertions found. ✅


Phase 5 — MPCv2 Correctness & Test Quality

Correctness

Check Result Notes
getEddsaSigningMaterial called with whitespace-stripped userKey? userKey.replace(/\s/g, '') passed to sharedGetEddsaSigningMaterial in the protected wrapper
Raw 64-byte sig passed to builder WITHOUT manual 0x00 prefix? rawSig handed to txnBuilder.addSignature directly; PR body explicitly documents the double-prefix finding
MPCv1 path uses decryptKeychainPrivateKey (not raw bitgo.decrypt)? decryptKeychainPrivateKey(this.bitgo, { encryptedPrv: backupKey }, walletPassphrase)
assert() guards for userKey/backupKey/walletPassphrase? Three assert(params.X, '...') calls replace the old if (!x) throw block
Unsigned sweep path untouched? else { serializedTx = unsignedTransaction.toBroadcastFormat(); } path is unchanged

Test Quality

Check Result Notes
v2 path test asserts getTSSSignature NOT called? mpcV2SandBox.assert.notCalled(getTSSSignatureSpy)
v1 path test asserts getTSSSignature WAS called? mpcV2SandBox.assert.calledOnce(getTSSSignatureSpy)
Crypto verification uses nacl.sign.detached.verify against actual signable payload and derived pubkey? Rebuilds tx from broadcast hex, derives accountId from commonKeyChain, verifies against tx.signablePayload
Mismatched bitgoKey vs commonKeyChain error test present? should.be.rejectedWith('EdDSA MPCv2 recovery: commonKeyChain from keycard does not match bitgoKey')
Missing-passphrase → unsigned sweep test present? The new describe('Recover Transactions (MPCv2):') block has no test that omits walletPassphrase and verifies the assert() throws (or that unsigned sweep is returned). The existing outer describe('Recover Transactions:') likely covers this for MPCv1, but the MPCv2 block leaves it untested. Low severity — assert() is standard — but worth noting.

Consistency

Check Result Notes
Describe block named 'Recover Transactions (MPCv2):'? Exact match
Sinon stubs use as unknown as { method: unknown } pattern (not as any)? Uses as unknown as { getAccountInfo: sinon.SinonStub } — typed stub, no as any

Summary

One ❌ finding:

  • Missing-passphrase test in the MPCv2 block — a test verifying that omitting walletPassphrase causes the assert() to throw (and does not silently fall through to an unsigned sweep) is absent from the new describe block. Not a blocker, but closes a small gap in the assertion-guard coverage.

Everything else is clean. The 0x00 discriminant handling, the decryptKeychainPrivateKey refactor, the assert() guards, the crypto-verification test, and the describe naming all pass. The type safety scan found no as any or unsafe casts.

Generated by [wallet-platform:review-pr] via Claude Code

Detect CBOR (MPCv2) vs JSON (MPCv1) keycards in Dot.recover() using
the shared getEddsaSigningMaterial/signDotMpcV2Recovery helpers from
sdk-core. Uses assert() for guards in newly introduced recovery code.

Ticket: WCI-1227
@vibhavgo
vibhavgo force-pushed the wci-1227-dot-mpcv2-recovery branch from 5b6afb7 to 8378a5a Compare August 14, 2026 07:14
@vibhavgo
vibhavgo requested a review from Marzooqa August 14, 2026 08:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant