Skip to content

fix(abstract-eth): verify EVM TSS bytes against intent before signing - #10121

Closed
dpkjnr wants to merge 4 commits into
masterfrom
CECHO-2460-evm-tss-fail-closed
Closed

dpkjnr wants to merge 4 commits into
masterfrom
CECHO-2460-evm-tss-fail-closed

Conversation

@dpkjnr

@dpkjnr dpkjnr commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

What

Makes EVM TSS signing fail closed. Before the user key share signs, verifyTssTransaction decodes the transaction bytes once and compares to / value / data with the user intent, on both signing paths: direct send (the caller's txParams) and pending approval (the stored txRequest.intent).

  • Wiring (sdk-core): the ECDSA and MPCv2 signing paths pass the stored intent, the caller's untouched txParams (callerTxParams) and the chain name into verifyTransaction. Stored kebab-case DeFi intents (defi-approve, defi-deposit, defi-withdraw) pass the missing-recipients guard so their calldata gets checked. defi-redeem has no handler and stays rejected, as on master.
  • Source of truth: when the caller declares a type or recipients, only its own params count. The stored intent is used only when the caller declared nothing, so server data never fills gaps in a caller's intent. The asset is resolved from local identity only.
  • One flow (abstract-eth): the bytes are decoded once, and the intent type picks small checks that each accept a closed set of shapes: native send, ERC-20 / ERC-7984 transfer, exact dApp call, batcher batch / batchTransferFrom, tokenApproval to the batcher for MAX, fillNonce, consolidation to the base address, and the DeFi calldata checks. contractCall / customTx throw.
  • One policy step: checks return findings. Fatal findings (the checks that threw before) still throw, with the same messages. Every other finding goes to console.warn and the new onVerificationWarning callback, or throws when the client sets the new client-side strictTssVerification option on BitGoAPI.
  • Consolidation gap: an ERC-20-shaped consolidation must carry no native value. Before, transfer(baseAddress, 0) sent to any contract passed the recipient check and could move the native balance.
  • Legacy chains: EvmCoin.verifyLegacyTssTransaction, which returned true without decoding, is removed, so kavaevm and prividiumeth run the parent verifier.
  • The verifier helpers stay internal to @bitgo/abstract-eth.

Why

TOB-BITGOEDMPC-2 / INFOSEC-671: verifyTssTransaction trusted the server-declared txParams.type. A compromised server could label approve(attacker, MAX) or any contract call as a payment, or redirect a payment on the pending-approval path, and the key share would sign it.

Case master this PR (default / strictTssVerification)
approve(attacker, MAX) under a native payment intent signs warns / throws
ERC-20 transfer on a different token contract than declared signs warns / throws
contractCall / customTx intent signs throws / throws
Native redirect or inflated value on pending approval signs throws / throws
Token consolidation that carries native value signs warns / throws (always throws with consolidationToBaseAddress)
Stored defi-redeem intent rejected rejected
Legitimate native send or ERC-20 transfer signs signs

Test plan

  • Unit suites on Node 24.22.1: sdk-core 1064, sdk-coin-eth 517, abstract-eth 188, sdk-coin-evm 134, sdk-coin-polygon 71, sdk-coin-xdc 65, sdk-coin-flr 61, sdk-coin-bsc 56, sdk-coin-arbeth 56, sdk-coin-opeth 56, sdk-coin-coredao 9.
  • End to end: the real verifier inside EcdsaUtils.signTxRequest (kavaevm). Rejected bytes never reach the signing round.
  • Prettier and ESLint clean on all changed files (0 errors).
  • BitGo SDK CI on this PR.
  • Release gate: prod backtest over ~563k EVM TSS payments from the last 90 days, covering direct sends and pending approvals, the legacy chains, every intent type, and tokens missing from the shipped statics. Expect no unexplained rejection of legitimate payments (tracked on CECHO-2460).

Open items

  • Rollout: the default warns and strictTssVerification opts into throwing, as agreed on CECHO-2460. Whether that closes TOB-BITGOEDMPC-2 / INFOSEC-671 is still open with security (INFOSEC-691).
  • Unresolved tokens are findings, so they warn by default; security asked for a failure. A lookup through the coin factory, which also knows AMS tokens, is not in this PR.
  • BitGo Express can't pass strictTssVerification yet; it builds BitGo from a fixed option list.
  • Interface: adds an optional callerTxParams to VerifyTransactionOptions. Out-of-repo verifyTransaction implementations won't get the caller / stored split until they read it.

Supersedes #9972, #9973, #9975, #10114.

Ticket: CECHO-2460

🤖 Generated with Claude Code

@linear-code

linear-code Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

CECHO-2460

@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

⚠️ Compatibility checks are failing on Node.js 27-nightly (experimental, non-blocking). This does not block merge, but it signals an incompatibility to fix before this release becomes supported.

View run

verifyTssTransaction trusted the server-declared txParams.type and only decoded two calldata
shapes under transfer. A compromised server could label approve(attacker, MAX) or any contract
call as a payment, or redirect a payment on the pending-approval path, and the user key share
would sign it (TOB-BITGOEDMPC-2 / INFOSEC-671).

The ecdsa and ecdsaMPCv2 signing paths now pass the stored txRequest.intent, the caller's
untouched txParams (callerTxParams) and the chain name into verifyTransaction. When the caller
declares a type or recipients, only its own params count. The stored intent is used only when
the caller declared nothing (pending approval), so server data never fills gaps in a caller's
intent. The asset comes from local identity only; unresolvable or conflicting identity is a
finding.

The verifier decodes the bytes once and runs small checks picked by the intent type, each
accepting a closed set of shapes: native send, ERC-20 or ERC-7984 transfer, exact dApp call,
batcher batch / batchTransferFrom, tokenApproval to the batcher for MAX, fillNonce,
consolidation to the base address, and the DeFi calldata checks. contractCall and customTx
throw. A token consolidation must carry no native value, which closes a path where
transfer(baseAddress, 0) sent to any contract moved the native balance there.

Checks return findings and one policy step decides warn or throw. Fatal findings, the checks
that threw before, still throw with the same messages. Every other finding goes to
console.warn and the new onVerificationWarning callback, or throws when the client sets the
new client-side strictTssVerification BitGoAPI option, which is never read from server data.

Stored kebab-case DeFi intents (defi-approve, defi-deposit, defi-withdraw) now pass the
missing-recipients guard so their calldata is checked. defi-redeem has no handler and stays
rejected. EvmCoin.verifyLegacyTssTransaction, which returned true without decoding, is removed
so kavaevm and prividiumeth run the parent verifier. The verifier helpers stay internal to
abstract-eth.

Tests cover the audit cases on kavaevm, hbarevm and tbsc (batcher), and an end-to-end case
runs the real verifier inside EcdsaUtils.signTxRequest to show rejected bytes never reach the
signing round.

Ticket: CECHO-2460
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dpkjnr
dpkjnr force-pushed the CECHO-2460-evm-tss-fail-closed branch from fd8d18d to 90aceaa Compare November 4, 2026 06:25
@dpkjnr
dpkjnr marked this pull request as ready for review November 4, 2026 07:34
@dpkjnr
dpkjnr requested review from a team as code owners November 4, 2026 07:34

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

@dpkjnr

dpkjnr commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

@claude review

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Review of #10121: fail-closed EVM TSS verification

I read tssVerification.ts in full and the abstractEthLikeNewCoins.ts and sdk-core wiring diff. I did not run the tests or build, and I did not read the new test files or the sdk-core/sdk-api diffs closely. The design looks sound: decoding once, closed shape sets per intent type, and caller-vs-stored intent selection. Findings below, most important first.

Findings

  1. The default mode doesn't close TOB-BITGOEDMPC-2 (abstractEthLikeNewCoins.ts, applyTssVerificationPolicy). The PR already says this is open with security (INFOSEC-691). In default mode, approve(attacker, MAX) under a native payment intent, a wrong token contract, and an unresolved token all end in console.warn and sign. Only contractCall / customTx and the historic checks are fatal. Until strictTssVerification defaults on, a compromised server still gets a signature. Please make the rollout plan explicit: a dated flip of the default, or a release gate tied to the backtest. Also note BitGo Express can't set the flag yet, so a server-side deployment can't opt in at all.

  2. Policy step: warnings are partly emitted before an exception. In strict mode only findings[0] is raised, so the error shows one reason and the rest are dropped. Callers see one reason at a time. In non-strict mode, onVerificationWarning runs inside the loop. If the callback throws, the remaining findings are never warned, and the throw aborts signing even though the mode is meant to be non-blocking. I suggest wrapping the callback in try/catch, and logging all findings before raising in strict mode.

  3. A decode failure is only a warning for non-single-recipient paths. Undecodable txHex becomes a new finding, but a missing or garbage txHex is the strongest "do not sign" signal there is. For transfer with 0 or 2+ recipients, bytes that can't be decoded sign under the default policy. Consider making a decode failure fatal on every path. The signer can't sign bytes that the coin's own builder can't parse anyway, so there should be no legitimate case this blocks. The same code also runs evaluateEvmTssIntent against a synthetic {to:'', value:'0', data:'0x'}, which produces noise findings next to the decode finding.

  4. Consolidation with caller-declared recipients. isConsolidation is true whenever verification.consolidationToBaseAddress is set, even when canonicalType resolves to transfer because the caller supplied recipients. Both the payment handler and the consolidation check then run, and the payment handler compares to the recipient addresses rather than the base address. That is stricter than master, so it is safe. It would still be worth a test that a legitimate consolidation with recipients present doesn't start failing.

  5. verifyConsolidation under non-fatal severity. getRawDecoded can throw on truncated transfer calldata. The caller catches it, so it is safe, but it turns into a "could not be verified" finding that warns. For consolidationToBaseAddress it rethrows, which is correct.

  6. MAX_UINT256 and erc20TransferMethodId are exported from tssVerification.ts. The PR says the helpers stay internal to @bitgo/abstract-eth. Confirm that src/index.ts doesn't export * from this file. Several other functions are exported too, for tests.

  7. Typing. bitgo?.strictTssVerification and onVerificationWarning are read off this.bitgo. Please confirm they are declared on the BitGoBase interface (the bitgoBase.ts change suggests so) and not accessed through casts. Per CLAUDE.md, avoid any there.

Smaller points

  • console.warn logs full calldata via JSON.stringify({expected, decoded}). That's fine for local SDK use, but in Express or server contexts it could land in shared logs. Consider truncating data.
  • The pending-approval wrap-approve stored intent still needs txParams.wrapParams. That behaves the same as master (it was rejected before and still is), but it is worth a one-line note in the PR if it is intentional.

Not verified

  • The CI results, and the prod backtest release gate (which is still unchecked).
  • Behaviour for kavaevm / prividiumeth after removing verifyLegacyTssTransaction. This is the highest regression risk, and it depends on the backtest covering those chains.

View job run | branch CECHO-2460-evm-tss-fail-closed

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread modules/sdk-coin-evm/src/evmCoin.ts Resolved
Comment on lines +3871 to +3873
if (this.bitgo?.strictTssVerification) {
await raise(findings[0]);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 (optional) Clients that turn on strictTssVerification can no longer sign accelerations, staking (delegate, stake, claim, …), wrap/unwrap, wrap-native, enableToken or bridgeFunds on EVM TSS. Every intent type without a handler gets an unhandled finding (tssVerification.ts:1107). The strict policy throws on the first finding of any severity. Fix: in strict mode, throw only for new findings on types that have a handler, and keep unhandled as a warning until those types get handlers. Or add handlers first so strict mode does not block legitimate operations such as unsticking a tx.

Why this was flagged

A client builds BitGoAPI with strictTssVerification: true and accelerates a stuck EVM TSS transaction (type acceleration), or signs a BSC/CELO staking intent through the same signTxRequest path. canonicalizeEvmTssIntentType returns acceleration or delegate. evaluateEvmTssIntent falls through to default and returns unhandledTypeViolation with severity unhandled (tssVerification.ts:1106-1107). applyTssVerificationPolicy at abstractEthLikeNewCoins.ts:3871 calls raise(findings[0]) whenever strict mode is on, whatever the severity, so signing fails with "no fail-closed verifier for intent type 'acceleration'". On the base branch these intents verified and signed. The option is opt-in, but its stated meaning is that mismatches become failures. A client who turns it on gets every unhandled operation type refused, even when the bytes are correct.

Verification: Intent types without a handler hit default: return [unhandledTypeViolation(ctx, tx)] (tssVerification.ts:1106-1107). applyTssVerificationPolicy (abstractEthLikeNewCoins.ts:3871-3872) does if (this.bitgo?.strictTssVerification) { await raise(findings[0]); }, which throws for any severity. Strict mode is new and opt-in, so default clients only get a console.warn and nothing breaks compared with base.

pranishnepal
pranishnepal previously approved these changes Nov 4, 2026

@pranishnepal pranishnepal left a comment

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.

wallets own changes look fine to me

Verify legacy transactions using serialized bytes and bind them to the
signing payload. Reject caller-declared payments without recipients
rather than falling back to server-provided intent.

Add regression coverage and update package changelogs.

Ticket: CECHO-2460
Co-authored-by: Ralph CLI <ralph-cli@bitgo.com>
Ralph: 18.13.2-bitgo.6 openrouter/deepseek/deepseek-v4.1-flash-1017@default:high+smol:high+task:high openrouter/anthropic/claude-opus-5.5@slow:high+plan:xhigh openrouter/openai/gpt-6.1-terra@vision:low+advisor:medium openrouter/z-ai/glm-5.3-flash@commit:high+memory:low openrouter/inclusionai/ling-3.1-flash@tiny:low openrouter/moonshotai/kimi-k3.1-pro@designer:high openrouter/google/gemini-3-pro-image@image web/exa@web openrouter/microsoft/mai-transcribe-2@dictation openrouter/typesafe/jev-1.14@judge
@dpkjnr

dpkjnr commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

@claude review again

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @dpkjnr's task in 38s —— View job


Re-review of #10121 (head e47c0e4)

I read the new commit and the policy code in abstractEthLikeNewCoins.ts. I did not build or run tests, and I did not re-read the test files in full.

Fixed since the last review

  • Legacy chains (kavaevm, prividiumeth). The four coins now have TSS_VERIFY_USE_SERIALIZED_TX_HEX, so the verifier decodes serializedTxHex. The ECDSA path also calls assertSignableConsistency, which ties the verified bytes to the signableHex that gets signed. assertSignableConsistency now builds its common from the coin's own network, not from the chain id. This is the right fix.
  • Caller declares transfer with no recipients. This now throws before the warn/strict policy runs, and it reads only callerParams. The fix is correct.

Still open

  1. Unfixed 🔴: caller-supplied recipients[].tokenData. buildEvmTssIntentContext (tssVerification.ts:374) reads tokenData.tokenContractAddress only from storedIntent. A direct send that names its token through tokenData is resolved as native. An ERC-20 send then gets a "native asset but token calldata" finding. That is a warning by default and a throw under strictTssVerification. Please also read the caller recipient's own tokenData fields.
  2. Unfixed 🔴: fillNonce for a receive address. verifyFillNonce requires to === baseAddress, but Wallet.prebuildTransactionTxRequests forwards receiveAddress. Nothing in tssVerification.ts reads receiveAddress. A legitimate send warns every time and fails in strict mode. Please take the expected address from receiveAddress when it is set, and confirm the hard-coded 1-wei value.
  3. Same gap as the fixed transfer case, for transferToken. The new guard covers only transfer. A caller that declares transferToken with no recipients gets a single new finding ("must declare exactly one token recipient"). That is a warning by default, so the bytes sign. Consider extending the guard to every type that needs recipients: transferToken, and a consolidate with caller recipients if that applies.
  4. Strict mode and unhandled types (abstractEthLikeNewCoins.ts:3889). raise(findings[0]) throws on any severity. A client that turns on strict mode can no longer sign acceleration, staking, wrap/unwrap or enableToken. I suggest strict throws only on new findings and keeps unhandled as a warning until handlers exist. If failing on those is intended, state it in the README.
  5. Policy step ordering.
    • In strict mode only the first finding is reported.
    • In default mode a throwing onVerificationWarning callback stops the loop partway. It also aborts signing, although this mode is meant to be non-blocking. Wrap the callback in try/catch.
  6. Decode failure on non-single-recipient paths is only a new finding. That means batch and zero-recipient payments with undecodable bytes sign in default mode. I suggest making a decode failure fatal on every path, and skipping evaluateEvmTssIntent on the synthetic {to:'', ...} placeholder. That placeholder produces noise findings.
  7. Deleted test with no replacement. modules/statics/test/unit/coins.ts loses the test that bsc/tbsc/xdc/txdc/icp/ticp have TSS_VERIFY_USE_SERIALIZED_TX_HEX. That guard protects the same regression this commit fixes. Please restore it, and add kavaevm, tkavaevm, prividiumeth and tprividiumeth to its list.
  8. console.warn logs full calldata through JSON.stringify({expected, decoded}). Truncate data for server/Express log hygiene.

Release gate and rollout

The default mode still signs approve(attacker, MAX) under a native payment intent, and the prod backtest is still unchecked. I'd keep this unmerged until the backtest results are in. Also give INFOSEC-691 a dated plan for flipping the default.

Not verified: CI results, the backtest, and a real kavaevm EIP-155 preimage run through assertSignableConsistency.
| branch CECHO-2460-evm-tss-fail-closed

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings marked 🟡 are optional suggestions and need no follow-up push.

Still open from earlier reviews (3):

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

@@ -1439,14 +1439,6 @@ describe('ERC20 Bulk Transaction Feature', () => {
});

describe('TSS Verify Use Serialized Tx Hex Feature', () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 nit (optional): Nothing now checks the TSS_VERIFY_USE_SERIALIZED_TX_HEX feature on base coins, so dropping it from a chain would break that chain's TSS signing without any test failing. The deleted test covered bsc, tbsc, xdc, txdc, icp and ticp. Nothing replaced it for the four chains this change relies on. Fix: restore the base-coin assertion and add kavaevm, tkavaevm, prividiumeth and tprividiumeth to its list.

Why this was flagged

The diff deletes the test 'should have TSS_VERIFY_USE_SERIALIZED_TX_HEX for coins whose signableHex is not parseable' (coins.ts:1442-1448 on the base). The only test left checks tokens: bsc:busd, xdc:usdc and txdc:tmt. With the EvmCoin.verifyLegacyTssTransaction bypass removed, the kavaevm/prividiumeth entries at allCoinsAndTokens.ts:2173, 2193, 2883 and 2901 now carry this feature, and signing on those chains depends on it. Without it, decodeTssTransaction parses the EIP-155 signableHex and throws on every payment. If a later statics edit drops the feature from any of these base coins, or from bsc/xdc/icp, CI stays green while TSS sends on that chain fail. On the base branch the deleted test would have caught this for bsc/xdc/icp.

Verification: Severity: nit. Trigger: a later change drops TSS_VERIFY_USE_SERIALIZED_TX_HEX from bsc, tbsc, xdc, txdc, icp or ticp. The diff deletes the test that checked those six coins from modules/statics/test/unit/coins.ts. All that remains (coins.ts:1442-1445) is the token test. If the feature were dropped, ecdsa.ts:834 would verify the unparseable signableHex.

Honor caller token metadata and receive-address nonce fills, and normalize
calldata before comparing intent. Reject undecodable payloads, missing token
recipients, and stored DeFi intents without a trusted vault binding.

Keep warn-by-default rollout and strict opt-in enforcement. Isolate warning
callback failures and bound console diagnostics.

Ticket: CECHO-2460
Co-authored-by: Ralph CLI <ralph-cli@bitgo.com>
Ralph: 18.13.2-bitgo.6 openrouter/deepseek/deepseek-v4.1-flash-1017@default:high+smol:high+task:high openrouter/anthropic/claude-opus-5.5@slow:high+plan:xhigh openrouter/openai/gpt-6.1-terra@vision:low+advisor:medium openrouter/z-ai/glm-5.3-flash@commit:high+memory:low openrouter/inclusionai/ling-3.1-flash@tiny:low openrouter/moonshotai/kimi-k3.1-pro@designer:high openrouter/google/gemini-3-pro-image@image web/exa@web openrouter/microsoft/mai-transcribe-2@dictation openrouter/typesafe/jev-1.14@judge
@dpkjnr

dpkjnr commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

@claude review

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @dpkjnr's task in 27s —— View job


Review of #10121 (head 6e9bbe9)

I read the diff since e47c0e4 and spot-checked tssVerification.ts and the policy code in abstractEthLikeNewCoins.ts. I did not build or run tests, and I did not re-read the test files.

Fixed since the last review

  • Caller tokenData. buildEvmTssIntentContext now reads the caller recipient's tokenData.tokenContractAddress and tokenData.tokenName alongside the flat fields.
  • fillNonce for a receive address. The context now carries receiveAddress (declared, or from the stored intent), and verifyFillNonce expects receiveAddress ?? baseAddress.
  • Calldata normalization. normalizeCalldata treats '', '0x' and undefined alike and restores a missing 0x prefix. The selector gates and isEmptyCalldata use the same form.
  • onVerificationWarning. A sync throw is dropped, and an async rejection is swallowed.
  • Log hygiene. console.warn redacts calldata to the selector and caps the reason text. The callback still gets the full finding.
  • Stored defi-* intents. The evaluateEvmTssIntent comment now says they are rejected before the calldata checks run. That closes the earlier "signs with wrong to / value" concern.
  • Unfixed items in the earlier reviews that I did not re-check: the transferToken guard, the restored statics TSS_VERIFY_USE_SERIALIZED_TX_HEX test for the legacy chains, and the decodeFindings handling. Please confirm these.

Still open

  1. Default mode still signs approve(attacker, MAX) under a native payment intent. The same holds for a wrong token contract and an unresolved token. This is the known INFOSEC-691 question. Before merge I'd like a dated plan for flipping the default, tied to the backtest results. Express also can't set strictTssVerification, so server-side deployments can't opt in at all.
  2. Strict mode and unhandled types (applyTssVerificationPolicy). The code now documents that strict throws on findings[0] of any severity, so a strict client cannot sign acceleration, staking, wrap/unwrap, enableToken or bridgeFunds. If that is intended, the README should list those types explicitly, since it blocks real operations.
  3. Strict mode reports only the first finding. Consider logging all findings before raising, so the error carries the full picture.
  4. Decode failure on non-single-recipient paths is still only a new finding. In default mode, batch and zero-recipient payments with undecodable bytes sign. Consider making it fatal on every path.

Release gate

The prod backtest (~563k payments) is still unchecked, and it is the main remaining risk. The chains most likely to regress are kavaevm and prividiumeth, because verifyLegacyTssTransaction was removed. Please also run a real EIP-155 preimage through assertSignableConsistency, which no test has done yet. I'd keep this unmerged until the backtest results are in.
| branch CECHO-2460-evm-tss-fail-closed

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Still open from earlier reviews (2):

  • Unresolved: 2 minor or pre-existing.

Verify FLR C-to-P atomic exports without routing through the EVM
decoder, checking chain direction, recipient and amount bounds.

Validate ERC-721 and ERC-1155 transfers against declared token
metadata on direct-send and pending-approval paths.

Add regression coverage and document verification boundaries.

Ticket: CECHO-2460
Co-authored-by: Ralph CLI <ralph-cli@bitgo.com>
Ralph: 18.13.2-bitgo.6 openrouter/deepseek/deepseek-v4.1-flash-1017@default:high+smol:high+task:high openrouter/anthropic/claude-opus-5.5@slow:high+plan:xhigh openrouter/openai/gpt-6.1-terra@vision:low+advisor:medium openrouter/z-ai/glm-5.3-flash@commit:high+memory:low openrouter/inclusionai/ling-3.1-flash@tiny:low openrouter/moonshotai/kimi-k3.1-pro@designer:high openrouter/google/gemini-3-pro-image@image web/exa@web openrouter/microsoft/mai-transcribe-2@dictation openrouter/typesafe/jev-1.14@judge
@dpkjnr

dpkjnr commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

@claude review

@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @dpkjnr's task in 43s —— View job


Review of #10121 (head 0476c52)

I read the diff of 0476c52 (FLR export verifier, NFT handler, applyTssVerificationPolicy, notifyVerificationWarning). I did not build or run tests, and I did not read the new test files.

Fixed since the last review

  • FLR C→P export. Flr.verifyTssTransaction verifies atomic exports before the EVM decode. It checks the encoded chains (C → P), exactly one output, the recipient as a normalized P-chain address set, and an amount range of [amount/1e9 + minImportToPFee, amount/1e9 + maxImportFee]. A declared export whose bytes are not atomic is rejected. This is the right shape.
  • NFT transfertoken. ERC-721 and ERC-1155 are now checked against tokenData: contract, zero value, destination, tokenId and quantity. Batch calldata must carry exactly one id and one quantity. The tokenId is decoded from the arguments after the selector. That is correct, because the earlier getBufferedByteCode split on the selector string could misparse an id that contains the selector bytes.
  • Warning sink and decode failure. notifyVerificationWarning isolates sync throws and async rejections. Undecodable bytes now fail before any policy step.

Still open

  1. Default mode still signs approve(attacker, MAX) under a native payment intent (INFOSEC-691). The same is true for a wrong token contract and an unresolved token. I would not merge without a dated plan to flip the default, tied to the backtest. Express also cannot set strictTssVerification.

  2. Other atomic types may now fail. In flr.ts:355, an atomic tx whose declared intent is not export (for example import / importtoc) is passed to super.verifyTssTransaction. The comment says this "keeps the parent baseline", but the parent now throws "transaction bytes could not be decoded" on any non-EVM bytes. On master those types passed. If FLR TSS wallets sign atomic imports through this path, they regress in every mode. Either handle those types in Flr or add a test that shows they never reach this verifier.

  3. Strict mode and unhandled types (abstractEthLikeNewCoins.ts:3953). raise(findings[0]) throws on any severity, so strict clients cannot sign acceleration, staking, wrap/unwrap, enableToken or bridgeFunds. The code comment now states this. The README should list the blocked types too, since this stops real operations.

  4. Strict mode reports only the first finding. Log all findings before raising.

  5. NFT _from is not compared (verifyNftTokenTransfer). The comment says the intent never names the sender. A server could still point from at another holder that has approved the wallet or a forwarder. This needs an approval, so I rate it low. Pinning from to the base or receive address would still close it.

  6. Smaller points

    • isDisplayOnlyNftName drops a terc721:/erc721: placeholder name from identity. That is safe, because a real contract address must be declared. Add a negative test where the placeholder comes with no tokenContractAddress. It should stay unresolved.
    • resolveDeclaredTssIntent in flr.ts reads recipient.address.address and recipient.amount.value from the stored intent without guards. A malformed stored intent gives a raw TypeError instead of a clear rejection.
    • Buffer.from(data.slice(10), 'hex') silently truncates odd-length hex. getRawDecoded will probably fail on the length, but check.
    • I did not re-check the restored statics TSS_VERIFY_USE_SERIALIZED_TX_HEX test for kavaevm and prividiumeth.

Release gate

The prod backtest (~563k payments) is still unchecked. kavaevm and prividiumeth are the chains most likely to regress. Please also run a real EIP-155 preimage through assertSignableConsistency, and an FLR export built from a real atomic tx, not a mock.
| branch CECHO-2460-evm-tss-fail-closed

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Still open from earlier reviews (2):

  • Unresolved: 2 minor or pre-existing.

Comment on lines +516 to +526
if (!amount.isFinite() || amount.isNegative() || !output.isFinite() || output.isNegative()) {
throw new Error(`atomic export amount ${declaredAmount} or output amount ${outputAmount} is not a valid amount`);
}
const expectedOutput = amount.div(1e9);
const minOutput = expectedOutput.plus(flrpNetwork.minImportToPFee);
const maxOutput = expectedOutput.plus(flrpNetwork.maxImportFee);
if (output.isLessThan(minOutput) || output.isGreaterThan(maxOutput)) {
throw new Error(
`atomic export output amount ${outputAmount} is outside the expected range [${minOutput.toFixed()}, ${maxOutput.toFixed()}] for the declared amount ${declaredAmount}`
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 FLR TSS exports whose declared wei amount is not a whole number of nanoFLR will be rejected in every mode; on the base branch they signed. assertAtomicExportAmount divides by 1e9 without rounding, so minOutput keeps a fraction. The atomic output is a whole nanoFLR count, so unless WP rounds up, output.isLessThan(minOutput) throws. Fix: round expectedOutput to whole nanoFLR before adding the fees, using the same rounding as the export builder (for example integerValue(BigNumber.ROUND_FLOOR)). The hop check validateHopPrebuild already rounds with toFixed(0).

Why this was flagged

An FLR TSS wallet calls prebuildTransaction({ type: 'Export', recipients: [{ address: pAddr, amount: '1234567890123456789' }] }). That amount is in wei and is not a multiple of 1e9. MPCv2 signing reaches Flr.verifyTssTransaction, then verifyAtomicExportTssTransaction, then assertAtomicExportAmount (flr.ts:516-526). expectedOutput = amount.div(1e9) is 1234567890.123456789. minOutput adds minImportToPFee and keeps the .123456789 fraction. The atomic export output (explained.outputAmount) is an integer nanoFLR value, because Avalanche atomic amounts cannot hold sub-gwei units. So WP must round, and with floor, half-up (which validateHopPrebuild assumes at flr.ts:611 with toFixed(0)) or half-even, a legitimate output lands below minOutput. Then 'atomic export output amount ... is outside the expected range' is thrown. This check is a plain throw, not a finding, so the default warn mode does not soften it. On the base branch the export signed with no amount check. The new tests only use a declared amount that is an exact multiple of 1e9.

Verification: In modules/sdk-coin-flr/src/flr.ts, assertAtomicExportAmount computes const expectedOutput = amount.div(1e9); (line 519) and then minOutput without rounding. explained.outputAmount is always a whole number of nanoFLR, so output.isLessThan(minOutput) throws in every mode. On the base commit, Flr has no verifyTssTransaction override, so these exports signed.

Comment on lines +383 to +426
let tx: BaseTransaction;
let explained: AtomicTransactionExplanation;
try {
tx = await this.getAtomicTransaction(txPrebuild.txHex);
explained = tx.explainTransaction() as AtomicTransactionExplanation;
} catch (e) {
throw new Error(`transaction bytes could not be decoded to verify intent: ${(e as Error).message}`);
}

if (explained.type !== TransactionType.Export) {
throw new Error(`atomic transaction type ${String(explained.type)} does not match the declared export intent`);
}
// Explanations map every owner onto this coin's P-chain alias, so matching the output address
// does not prove the export targets the P-chain. Read the chains encoded in the bytes.
const { sourceChain, destinationChain } = (tx as FlrPLib.Transaction).toJson();
if (sourceChain !== 'C' || destinationChain !== 'P') {
throw new Error(
`atomic export must be a C-chain to P-chain export but encodes ${sourceChain} -> ${destinationChain}`
);
}
const outputs = explained.outputs;
if (outputs.length !== 1) {
throw new Error(`atomic export transaction must have exactly 1 output but ${outputs.length} found`);
}
if (intent.recipients.length !== 1) {
throw new Error(`atomic export requires exactly 1 recipient but ${intent.recipients.length} found`);
}

const declaredRecipient = intent.recipients[0];
const declaredDestination = Flr.normalizePChainAddressSet(declaredRecipient.address);
if (!declaredDestination) {
throw new Error(`atomic export recipient ${declaredRecipient.address} is not a valid P-chain address`);
}
const decodedDestination = Flr.normalizePChainAddressSet(outputs[0].address);
if (!decodedDestination) {
throw new Error(`atomic export destination ${outputs[0].address} is not a valid P-chain address`);
}
if (declaredDestination !== decodedDestination) {
throw new Error(
`atomic export destination ${outputs[0].address} does not match the declared recipient ${declaredRecipient.address}`
);
}

this.assertAtomicExportAmount(declaredRecipient.amount, explained.outputAmount);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟣 pre-existing, not blocking: Pre-existing, security-relevant: on FLR MPCv2 exports a compromised server can still get any digest signed. The new export verifier only checks bytes the key share never signs. verifyAtomicExportTssTransaction decodes txPrebuild.txHex (serializedTxHex), but the signing round signs the server's signableHex directly as the hash. Nothing checks that signableHex equals SHA-256 of those bytes. Fix: before returning true, require txPrebuild.txInfo to equal tx.signablePayload (hex), or add the same binding to the prehashed branch at ecdsaMPCv2.ts:1001.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

An FLR TSS wallet signs an export intent on the MPCv2 path. shouldUsePreHashedSignable is true for 0000-prefixed bytes, so ecdsaMPCv2.ts:992-999 passes txPrebuild: { txHex: unsignedTx.serializedTxHex, txInfo: unsignedTx.signableHex }. assertSignableConsistency runs only when shouldVerifyWithSerializedTxHex is true (ecdsaMPCv2.ts:1001), so nothing binds the digest on this branch. getMpcv2HashBuffer (ecdsaMPCv2.ts:1220-1230) only checks that the buffer is 32 bytes before using signableHex as the message hash. The new Flr.verifyAtomicExportTssTransaction checks the type, the C->P chains, the destination and the amount of serializedTxHex, then returns true without reading txPrebuild.txInfo. A server can send a legitimate export as serializedTxHex together with any 32-byte digest. The verifier passes and the user share signs that digest. The gap exists on base too, but this PR presents FLR exports as verified against intent (the TOB-BITGOEDMPC-2 threat model), and they are not.

Verification: The new verifyAtomicExportTssTransaction (flr.ts) decodes only txPrebuild.txHex and never compares txInfo with tx.signablePayload. assertSignableConsistency at ecdsaMPCv2.ts:1001-1002 skips the pre-hashed branch, and getMpcv2HashBuffer (ecdsaMPCv2.ts:1220-1230) only checks bufferContent.length === 32. Base branch: the code at ecdsaMPCv2.ts 991-1008 is the same.

Comment on lines +403 to +424
const outputs = explained.outputs;
if (outputs.length !== 1) {
throw new Error(`atomic export transaction must have exactly 1 output but ${outputs.length} found`);
}
if (intent.recipients.length !== 1) {
throw new Error(`atomic export requires exactly 1 recipient but ${intent.recipients.length} found`);
}

const declaredRecipient = intent.recipients[0];
const declaredDestination = Flr.normalizePChainAddressSet(declaredRecipient.address);
if (!declaredDestination) {
throw new Error(`atomic export recipient ${declaredRecipient.address} is not a valid P-chain address`);
}
const decodedDestination = Flr.normalizePChainAddressSet(outputs[0].address);
if (!decodedDestination) {
throw new Error(`atomic export destination ${outputs[0].address} is not a valid P-chain address`);
}
if (declaredDestination !== decodedDestination) {
throw new Error(
`atomic export destination ${outputs[0].address} does not match the declared recipient ${declaredRecipient.address}`
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟣 pre-existing, not blocking: A compromised server can get FLR TSS wallets to sign C-to-P exports that freeze the funds or let one key spend them, and the new export verifier still passes them. verifyAtomicExportTssTransaction compares only the output owner set and amount. It never checks the exported output's locktime or threshold. Fix: require locktime 0 and a threshold that matches the declared P-chain address set, read from exportedOutputs[0].output. Do not use toJson().threshold, which ExportInCTxBuilder.initBuilder never parses. [also at: modules/sdk-coin-flr/src/flr.ts:426 - FLR TSS wallets still sign an atomic export that debits far more C-chain FLR than the declared amount plus fee. The excess is burned as fee.]
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

An FLR TSS wallet signs a declared Export (direct send or pending approval). ecdsaMPCv2.ts:992-999 passes the atomic serializedTxHex, and Flr.verifyTssTransaction (flr.ts:343-360) sends it to verifyAtomicExportTssTransaction. The bytes carry a secp256k1 TransferOutput with owners, threshold and locktime: ExportInCTxBuilder.ts:89-98 reads getLocktime(), and newExportTxFromBaseFee takes both at lines 172-186. The verifier only checks the chain direction, the output count, the owner set (mapOutputToEntry drops threshold and locktime, utils.ts:265-273) and the amount. A server-built export to the right owners P-a~P-b~P-c with threshold: 1 lets the BitGo key spend the P-chain UTXO alone. A far-future locktime freezes the funds. Either way return true and the key share signs. The base branch did no export check at all. This PR presents the check as the safeguard, and it misses these fields. It is separate from the signableHex binding finding: binding the digest would not add these checks.

Verification: Pre-existing and security-relevant. Nothing reads the output's locktime or threshold. ExportInCTxBuilder.initBuilder (modules/sdk-coin-flrp/src/lib/ExportInCTxBuilder.ts:89-98) sets _locktime but never sets _threshold from the parsed output. A far-future locktime freezes the exported funds, and threshold=1 lets any single owner key spend them. The base branch had no export verification at all.

@dpkjnr

dpkjnr commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

Closing in favour of the narrower first release in #10136, which limits the change to verifyTssTransaction.

The remaining work in this PR is now tracked as separate tickets: CECHO-2680 (contractCall / customTx) and the sub-tickets under CECHO-2679 (legacy routing, intent plumbing, Katana, FLR, remaining handlers).

The branch is kept for reference.

@dpkjnr dpkjnr closed this Nov 5, 2026
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.

2 participants