Repository navigation
feat: accept Safe keycard params-EVM TSS recovery - #10106
pranishnepal wants to merge 2 commits into
Conversation
|
|
8cba36b to
1648ed5
Compare
Safe wallets derive one child key set from a 4-slot keycard, which buildSafeRecoverKeyParams maps into coin.recover(). Four coin families either rejected that parameter shape or skipped the error contracts the Safe recovery orchestrator relies on to decide whether to advance, retry or abort. abstract-eth: unsigned TSS sweeps accept a bitgoKey-only shape alongside the legacy userKey/backupKey pair, and verify the derived m/0 base address against walletContractAddress before any balance query. MPCV2 coins now dispatch to buildUnsignedSweepTxnMPCv2 from the base class, fixing bsc, arbeth, opeth and bera, which declare the feature but inherited the MPCv1 body and threw "Unsupported public key" on any common keychain. sdk-coin-stx: derive the 2-of-3 multisig address from the three child pubs and compare it to rootAddress, with zero network calls. sdk-coin-avaxc: assert the user and backup keys are signers on the wallet contract before spending gas, via a JSON-RPC queryIsWalletSigner override, since avax.network is not Etherscan-compatible. abstract-cosmos: derive senderAddress from bitgoKey in unsigned mode rather than trusting rootAddress, and compare the two in both modes, ignoring an optional ?memoId= suffix and bech32 casing. RecoverOptions.userKey and .backupKey become optional so the unsigned Safe shape type-checks. Every downstream consumer still classifies sweep and KRS recovery from the raw keys, as before, so that detection is unchanged. Two intended behaviour changes for existing callers: - Unsigned EVM TSS sweeps throw RecoveryKeyMismatchError when walletContractAddress is not the m/0 base address. The signed path already enforced this, and WRW labels the field "This is also the wallet's base address". Sweeps using a derivationSeed are exempt: they target a derived child no caller can predict. - AVAXC recovery throws ErrorNoInputToRecover on a zero balance instead of building a zero-value transaction, matching eth Adds end-to-end Safe recovery suites for eth, stx, avaxc and cosmos covering signed and unsigned happy paths, wrong passphrase, empty balance and provider failure, plus a bsc test pinning the MPCv2 dispatch TICKET: WCN-3946
1648ed5 to
b2832ac
Compare
|
@claude review always |
|
Claude finished @pranishnepal's task in 47s —— View job Review of #10106: Safe keycard params for EVM TSS / STX / AVAXC / Cosmos recoveryI read the source diff across abstract-eth, abstract-cosmos, stx, avaxc, evm, etc, tempo and the eth tokens. I did not read the new test files in detail and did not run the build or tests. The design is sound. The checks run before any network call, and the error contracts ( Worth addressing
Minor
Positive
|
There was a problem hiding this comment.
Findings marked 🟡 are optional suggestions and need no follow-up push.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🟡
modules/sdk-coin-eth/src/eth.ts— nit: for the same keys, ETH and the ERC20/ERC721/ERC7984 tokens can now disagree on whether a recovery is KRS or an unsigned sweep.Eth.recoverEthLikestrips whitespace before callinggetIsKrsRecovery/getIsUnsignedSweep. Every other consumer classifies from the raw keys, and the commit message says all of them do. Fix: classify the same way at every site, either all raw or all stripped.Why this was flagged
Take a backup xpub pasted with a leading newline. At eth.ts:640-644 the ETH coin strips whitespace, so
getIsKrsRecoveryandgetIsUnsignedSweepsee a key starting withxpub. That makes the recovery KRS or unsigned-sweep, andcheckKrsProviderruns. erc20Token.ts:540-545, erc721Token.ts, erc7984Token.ts, ethLikeToken.ts, avaxc.ts and etc.ts classify fromrawUserKey/rawBackupKey. For the same input they treat it as signed and then fail to decrypt the xpub. Before this change ETH also used the raw keys, so this divergence is new.Verification: Nit. The trigger is an ETH (non-TSS) recovery where a user or backup key is passed with leading whitespace.
Eth.recoverEthLikeat modules/sdk-coin-eth/src/eth.ts:209-214 strips whitespace first, then classifies from the stripped keys. The base code classified from the rawparams. erc20Token.ts:159-162 and the other token consumers classify from the raw keys. Both helpers usestartsWith('xpub'), so a leading '\n' really does flip the result.
f178769 to
e92ba01
Compare
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.
Tip: disable this comment in your organization's Code Review settings.
|
@claude review always |
|
Claude finished @pranishnepal's task in 19s —— View job Review of #10106 (re-review of current head)I re-read the Earlier findings that are now fixed in the branch
Still worth confirming (non-blocking)
I have no blocking issues. The earlier red items are resolved. |
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🟡
modules/abstract-eth/src/abstractEthLikeNewCoins.ts— TSS consolidation callers now get many identical sweeps of the m/0 base address back, where signed MPCv2 runs used to stop with RecoveryKeyMismatchError. Every scanned index and candidate address getswalletContractAddress: tssBaseAddress, so eachrecover()call passes the address check and rebuilds the same m/0 sweep. The m/i receive addresses being scanned are never swept. Fix: whenisTss, build and return the m/0 sweep at most once, or sweep each m/i (for example via a per-index derivation). Do not substitute the address being compared, because that turns the mismatch check into a no-op. [also at: modules/abstract-eth/src/abstractEthLikeNewCoins.ts:1577 - TSS consolidation recoveries with the wrong keychain will still sweep, with noRecoveryKeyMismatchError. The new wallet-address guard is never checked against what the caller supplied. recoverConsolidations derivestssBaseAddressfrom the keychain and passes it aswalletContractAddress.]Why this was flagged
Trigger:
recoverConsolidationscalled withisTss: trueand a common keychain in userKey, backupKey or bitgoKey (the bitgoKey-only shape is newly accepted byvalidateRecoveryParams).tssBaseAddressis loop-invariant (line 1587) and replaces every consolidation address.recoverTSS/buildUnsignedSweepTxnMPCv2always derive m/0 and callbuildTssRecoveryTxn(baseAddress, ...), which never reads walletContractAddress (line 2711). So each of the (endIdx - startIdx) x addresses iterations queries the same balance and nonce, and pushes an identical m/0 sweep intoconsolidatedTransactions. Only the first copy can confirm, since all share one nonce, and no receive address m/i is consolidated. On the base branch, the signed MPCv2 path compared m/0 with the per-index address and threwRecoveryKeyMismatchErroron the first iteration.assertTssBaseAddressMatchesWalletcannot catch this because it now compares m/0 with itself.Verification: Every iteration passes
walletContractAddress: tssBaseAddress ?? address(abstractEthLikeNewCoins.ts:1577).buildTssRecoveryTxn(baseAddress, ...)(line 2710-2738) never reads walletContractAddress. The m/i receive addresses are never queried or swept. Existing tests usestartingScanIndex: 1, endingScanIndex: 2(eth.ts ~3617), so they do not catch the duplication. -
🟣
modules/abstract-eth/src/abstractEthLikeNewCoins.ts— Pre-existing: Safe recoveries of an empty EVM TSS wallet still fail with a plain Error, notErrorNoInputToRecover, so the orchestrator cannot tell "nothing to recover" from a real failure.validateBalanceAndGetTxAmountthrowsBackup key address ... has balance 0 Gweiwhenever the balance is below gas, including zero. Fix: throwErrorNoInputToRecoverwhen the swept address balance is zero, before the gas check, on every TSS sweep path, while keeping the plain gas error for a non-zero balance below gas. Same pattern at 2 sites (abstractEthLikeNewCoins.ts:2749, tempo.ts:449).Why this was flagged
The trigger is a Safe ecdsaMpc recovery,
{ bitgoKey, isTss: true }or the signed shape, on an EVM TSS coin whose m/0 base address holds zero balance. It goes recover() -> recoverTSS -> buildUnsignedSweepTxnMPCv2, or the signed branch, -> buildTssRecoveryTxn (abstractEthLikeNewCoins.ts:2711). validateBalanceAndGetTxAmount then runsbaseAddressBalance.lt(totalGasNeeded)at :2749 and throws a plainErrorthat names a "Backup key address". The PR says it makes EVM TSS honour theErrorNoInputToRecovercontract the orchestrator uses to keep scanning. For EVM TSS and Tempo it did not, and ethSafeRecovery.ts:252 pins the plain/has balance 0 Gwei/error. Tempo's buildTempoSweepTx (tempo.ts:449) likewise throws a plainInsufficient balance in ...Error. The bitgoKey-only shape is newly accepted (validateRecoveryParams :1352), so Safe callers newly reach this path. They get a generic failure where the contract promisesErrorNoInputToRecover.Verification: At abstractEthLikeNewCoins.ts:2749
validateBalanceAndGetTxAmountdoesif (baseAddressBalance.lt(totalGasNeeded))and throws a plain Error. Nothing checks for zero balance first, so an empty wallet gets a plain Error, neverErrorNoInputToRecover. recoverConsolidations (:1595) only treats that type as "nothing here". The base commit has the same throw (base :2694), so this is not a regression.
e92ba01 to
af4c074
Compare
- eth.ts classified sweep/KRS recovery from whitespace-stripped keys while every sibling used the raw keys; extract classifyRecoveryKeys so the two cannot drift apart again - tempo: fail closed on a wrong keychain like the rest of the EVM TSS family - avaxc: forward apiKey to the signer check - cover the base-class missing commonKeyChain path, which also pins that validateUnsignedSweepTSSParams is awaited Aligning getConsolidationAddress onto the same keychain fallback was tried and reverted: recoverConsolidations is called with backupKey and no userKey, so it scanned an extra derived address and changed which addresses a consolidation sweeps. TICKET: WCN-3946
af4c074 to
1fdd01c
Compare
What
Makes
coin.recover()work with Safe wallet keycards across EVM TSS, STX, AVAXC and Cosmos.A Safe wallet derives one child key set from a 4-slot keycard, which
buildSafeRecoverKeyParamsmaps intorecover(). In unsigned mode that shape is{ bitgoKey, isTss, broadcast }— nouserKey, nobackupKey. Four coin families either rejected that shape outright or skipped theRecoveryKeyMismatchError/ErrorNoInputToRecover/RecoveryProviderErrorcontracts the recovery orchestrator uses to decide whether to try the next key slot, keep scanning, or stop and surface a network failure.WCN-3946
Don't panic at 29 files — here's the shape of it
package.jsonReview these five, skip the rest:
abstract-eth/src/abstractEthLikeNewCoins.ts— accept thebitgoKey-only shape; verify them/0address before any balance query; dispatch MPCV2 coins tobuildUnsignedSweepTxnMPCv2from the base classsdk-coin-stx/src/stx.ts— derive the 2-of-3 multisig address from the child pubs and compare it torootAddressbefore touching the nodesdk-coin-avaxc/src/avaxc.ts— check user/backup keys are wallet-contract signers before spending gas, via a JSON-RPCqueryIsWalletSigneroverride (avax.network isn't Etherscan-compatible)abstract-cosmos/src/cosmosCoin.ts— derivesenderAddressfrombitgoKeyin unsigned mode instead of trustingrootAddresssdk-core/src/bitgo/recovery/initiate.ts— newclassifyRecoveryKeyshelper (see below)Why the ripple exists
RecoverOptions.userKeyand.backupKeywere requiredstrings. The unsigned Safe shape has neither, so they had to become optional — otherwise every Safe caller needs a cast and the type keeps claiming two keys the runtime no longer requires.That one-line type change is the entire reason 6 unrelated files are touched: each reads
params.userKey.replace(...), which TypeScript then flags. Every one of those edits is the same shape:Testing
End-to-end suites for eth, stx, avaxc and cosmos driving the real chain (deriveSafeWalletKeys → buildSafeRecoverKeyParams → recover()) with only the network mocked. Each covers signed and unsigned happy paths, wrong passphrase, empty balance and provider failure.
Plus a bsc test pinning the MPCv2 dispatch, and a regression test for derivationSeed sweeps.