Skip to content

fix(zetaclient/v38): also cancel arbitrary calls reaching executeWithERC20#4580

Merged
ws4charlie merged 2 commits intorelease/zetaclient/v38from
fix/cancel-arbitrary-call-erc20-v38
Apr 28, 2026
Merged

fix(zetaclient/v38): also cancel arbitrary calls reaching executeWithERC20#4580
ws4charlie merged 2 commits intorelease/zetaclient/v38from
fix/cancel-arbitrary-call-erc20-v38

Conversation

@ws4charlie
Copy link
Copy Markdown
Contributor

@ws4charlie ws4charlie commented Apr 28, 2026

Summary

Extends the V2 arbitrary-call cancel guard (merged in #4575) to cover the ERC20 and ZETA withdrawAndCall paths, which forward to GatewayEVM.executeWithERC20 and reach the same sender==0 arbitrary-call branch as GatewayEVM.execute. Also adds warn logs around the cancellation so it's visible in production.

Why this matters

Before this change, only GatewayEVM.execute arbitrary calls were cancelled. ERC20Custody.withdrawAndCall and ZetaConnector.withdrawAndCall were still signed normally — but those paths forward MessageContext straight into GatewayEVM.executeWithERC20, which has the same _executeArbitraryCall branch that execute does. Without this extension, an attacker who triggers an IsArbitraryCall=true CCTX with CoinType=ERC20 (or Zeta) could still reach the arbitrary-call branch on the destination gateway.

Changes

Area Change
common/cctx.go Rename IsGatewayExecuteOutboundIsArbitraryCallCancellable; expand to cover OutboundTypeERC20WithdrawAndCall and OutboundTypeZetaWithdrawAndCall
signer/v2_signer.go Use new helper; emit warn log on cancellation
observer/outbound.go Use new helper; emit warn log on event-parsing bypass
cctx_test.go TestIsArbitraryCallCancellable enumerates all four cancellable types
outbound_test.go Predicate table flips ERC20 + Zeta arbitrary withdrawAndCall cases to true
e2etests/test_erc20_withdraw_and_arbitrary_call.go Now expects Reverted with ZRC20 refund to RevertAddress
e2etests/test_zeta_withdraw_and_arbitrary_call.go V2-enabled branch flipped to Reverted (forward compat — V2 ZETA flows are disabled at the contract level on mainnet today)

Test plan

  • go build ./zetaclient/... ./e2e/...
  • go test ./zetaclient/chains/evm/{common,observer,signer}/...
  • go vet ./zetaclient/chains/evm/... ./e2e/e2etests/...
  • CI E2E suite green on this branch
  • Bugbot review

🤖 Generated with Claude Code


Note

High Risk
Changes signer and observer behavior for V2 cross-chain outbounds by cancelling additional arbitrary-call routes, which is security- and funds-flow critical and could affect outbound processing if misclassified.

Overview
Extends the V2 arbitrary-call cancellation guard so CCTXs with CallOptions.IsArbitraryCall=true are also signer-cancelled for ERC20 and ZETA withdrawAndCall outbounds that reach GatewayEVM.executeWithERC20 (in addition to the existing GatewayEVM.execute paths).

Renames/expands the outbound-type predicate to IsArbitraryCallCancellable, wires it into both the V2 signer and observer (including new warn logs when voting failed due to signer-cancel), and updates unit/e2e tests to expect Reverted CCTXs with refund to the configured RevertAddress and no destination dApp invocation.

Reviewed by Cursor Bugbot for commit 38856c9. Configure here.

Greptile Summary

Extends the V2 arbitrary-call cancel guard (from #4575) to cover ERC20Custody.withdrawAndCall and ZetaConnector.withdrawAndCall, which both forward into GatewayEVM.executeWithERC20's _executeArbitraryCall branch with MessageContext.Sender == address(0) — the same drain shape as GatewayEVM.execute. The changes rename IsGatewayExecuteOutboundIsArbitraryCallCancellable, wire the updated predicate into both the V2 signer and observer, add warn logs on cancellation, and update unit + e2e tests to expect Reverted CCTXs with refund to the configured RevertAddress.

Confidence Score: 4/5

Safe to merge; all findings are P2 style/test-coverage gaps with no correctness impact on the core cancellation logic.

The predicate expansion, signer guard, observer handler, and unit tests are all consistent and correct. Both P2 findings (double function call in observer, missing refund assertion in Zeta e2e test) are quality issues rather than logic defects. Deployment note about coordinated rollout is important and already documented.

e2e/e2etests/test_zeta_withdraw_and_arbitrary_call.go — missing principal-refund balance assertion parallel to the ERC20 test.

Important Files Changed

Filename Overview
zetaclient/chains/evm/common/cctx.go Renames IsGatewayExecuteOutboundIsArbitraryCallCancellable and expands it to include OutboundTypeERC20WithdrawAndCall and OutboundTypeZetaWithdrawAndCall; logic and docs are correct and exhaustive.
zetaclient/chains/evm/signer/v2_signer.go Uses updated IsArbitraryCallCancellable predicate and adds a warn log; the early-return guard before the switch is correctly positioned so ERC20/Zeta non-arbitrary calls still fall through to normal signing paths.
zetaclient/chains/evm/observer/outbound.go Updated to use IsArbitraryCallCancellable; adds warn log but calls isArbitraryCallCancellation twice in the same block — minor style issue, no correctness impact.
e2e/e2etests/test_erc20_withdraw_and_arbitrary_call.go Correctly adds revert-address setup, balance capture before CCTX processing, Reverted status assertion, dApp-not-called check, and exact principal refund assertion.
e2e/e2etests/test_zeta_withdraw_and_arbitrary_call.go Flips V2-enabled branch to expect Reverted and dApp not called, but omits the revert-address balance check present in the ERC20 counterpart.
zetaclient/chains/evm/common/cctx_test.go Renames test function and extends the truth table with the two new cancellable types; all allTypes entries are exercised.
zetaclient/chains/evm/observer/outbound_test.go Flips the ERC20 and Zeta arbitrary withdrawAndCall test cases from false to true; table is consistent with the new predicate.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[CCTX PendingOutbound\nIsArbitraryCall=true] --> B{ParseOutboundTypeFromCCTX}

    B --> C[OutboundTypeCall\nOutboundTypeGasWithdrawAndCall]
    B --> D[OutboundTypeERC20WithdrawAndCall\n🆕 now cancellable]
    B --> E[OutboundTypeZetaWithdrawAndCall\n🆕 now cancellable]
    B --> F[Plain withdraw types\nIsArbitraryCall=true on wire\nbut NOT cancellable]

    C --> G{IsArbitraryCallCancellable?}
    D --> G
    E --> G
    F --> H[Normal signing path\nEvent parsing in observer]

    G -- true --> I[Signer: SignCancel\nTSS self-transfer\nWarn log emitted]
    I --> J[Observer: vote failed\nWarn log emitted]
    J --> K[ZetaCore: CCTX → Reverted\nPrincipal refunded to RevertAddress]

    G -- false --> H
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: zetaclient/chains/evm/observer/outbound.go
Line: 197-203

Comment:
**Double call to `isArbitraryCallCancellation`**

`isArbitraryCallCancellation(cctx)` is evaluated twice in the same code block — once in the outer `if` condition and again to gate the warn log. The function parses `ProtocolContractVersion`, dereferences outbound params, and dispatches into `ParseOutboundTypeFromCCTX`, so it does real work each time. Extracting it to a local variable eliminates the redundant evaluation and makes the intent clearer.

```suggestion
	isArbitraryCancel := isArbitraryCallCancellation(cctx)
	if compliance.IsCCTXRestricted(cctx) || isArbitraryCancel {
		if isArbitraryCancel {
			logger.Warn().
				Str(logs.FieldCctxIndex, cctx.Index).
				Stringer(logs.FieldTx, receipt.TxHash).
				Msg("voting V2 arbitrary-call CCTX as failed (signer-cancelled)")
		}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: e2e/e2etests/test_zeta_withdraw_and_arbitrary_call.go
Line: 37-47

Comment:
**No revert-refund balance check in Zeta path**

The ERC20 test was extended to capture `revertBalanceBefore` and assert `revertBalanceBefore + amount == revertBalanceAfter`, ensuring the principal is actually returned to the revert address. The Zeta test flips the same CCTX expectation to `Reverted` but does not include a corresponding balance check. Without it, a regression where the ZETA principal is silently lost would not be caught by this test.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix: also cancel arbitrary calls reachin..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

The ERC20Custody.withdrawAndCall and ZetaConnector.withdrawAndCall paths
forward MessageContext straight through to GatewayEVM.executeWithERC20,
which has the same sender==0 arbitrary-call branch (_executeArbitraryCall)
as GatewayEVM.execute. Until the contracts patch ships, a CCTX with
IsArbitraryCall=true and CoinType=ERC20/Zeta would be signed normally,
TSS would broadcast ERC20Custody.withdrawAndCall({sender:0x0}, ...) which
calls gateway.executeWithERC20({sender:0x0}, ...), and the gateway would
route to _executeArbitraryCall — same drain primitive as the original
attack via GatewayZEVM.call.

Extend the cancel guard to cover OutboundTypeERC20WithdrawAndCall and
OutboundTypeZetaWithdrawAndCall in addition to the GatewayEVM.execute
paths. Rename IsGatewayExecuteOutbound → IsArbitraryCallCancellable to
reflect the broader scope. Add warn logs on both signer-side cancellation
and observer-side bypass so cancellations are visible in production logs.

Updated tests:
- TestIsArbitraryCallCancellable enumerates all four cancellable types.
- Test_isArbitraryCallCancellation table covers the two new positive
  cases (ERC20 and Zeta withdrawAndCall).
- E2E test_erc20_withdraw_and_arbitrary_call now expects Reverted with
  ZRC20 refund to RevertAddress, matching the eth and zevm-to-evm
  arbitrary-call e2e patterns.
- E2E test_zeta_withdraw_and_arbitrary_call's V2-enabled branch flipped
  to Reverted for forward compatibility (V2 ZETA flows are currently
  disabled at the contract level on mainnet).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ws4charlie
Copy link
Copy Markdown
Contributor Author

bugbot run

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 28, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • develop

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: bce4a031-aadc-43da-8ec0-5331603881a8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/cancel-arbitrary-call-erc20-v38

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@ws4charlie ws4charlie added the no-changelog Skip changelog CI check label Apr 28, 2026
@ws4charlie ws4charlie marked this pull request as ready for review April 28, 2026 19:19
@ws4charlie ws4charlie requested a review from a team as a code owner April 28, 2026 19:19
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 38856c9. Configure here.

Comment thread zetaclient/chains/evm/observer/outbound.go Outdated
Comment thread e2e/e2etests/test_zeta_withdraw_and_arbitrary_call.go
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 28, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

- outbound.go: extract isArbitraryCallCancellation(cctx) to a local
  variable to avoid double evaluation (the predicate parses
  ProtocolContractVersion, dereferences outbound params, and dispatches
  into ParseOutboundTypeFromCCTX).
- test_zeta_withdraw_and_arbitrary_call.go: add a revert-refund balance
  check on the V2-enabled branch, mirroring the ERC20 test. Uses
  TestDAppV2ZEVMAddr as the revert recipient so the assertion isn't
  perturbed by gas (revert tx is signed by TSS).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ws4charlie ws4charlie merged commit 10cd8b4 into release/zetaclient/v38 Apr 28, 2026
46 checks passed
@ws4charlie ws4charlie deleted the fix/cancel-arbitrary-call-erc20-v38 branch April 28, 2026 20:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-changelog Skip changelog CI check

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants