Skip to content

fix: cancel V2 arbitrary calls in zetaclient signer (backport v37)#4576

Merged
ws4charlie merged 8 commits intorelease/zetaclient/v37from
fix/disable-arbitrary-call-v37
Apr 28, 2026
Merged

fix: cancel V2 arbitrary calls in zetaclient signer (backport v37)#4576
ws4charlie merged 8 commits intorelease/zetaclient/v37from
fix/disable-arbitrary-call-v37

Conversation

@ws4charlie
Copy link
Copy Markdown
Contributor

@ws4charlie ws4charlie commented Apr 27, 2026

Summary

Backport of both #4575 and #4580 to release/zetaclient/v37.

V2 CCTXs with CallOptions.IsArbitraryCall=true whose outbound type is one of:

  • OutboundTypeCall / OutboundTypeGasWithdrawAndCallGatewayEVM.execute
  • OutboundTypeERC20WithdrawAndCallERC20Custody.withdrawAndCallGatewayEVM.executeWithERC20
  • OutboundTypeZetaWithdrawAndCallZetaConnector.withdrawAndCallGatewayEVM.executeWithERC20

…are short-circuited to a TSS self-transfer (SignCancel) in the signer, and the observer treats those receipts as failed outbounds to trigger the standard V2 revert flow rather than parsing gateway events. All four outbound types reach the same sender == address(0) branch (_executeArbitraryCall) on the destination gateway, so they are cancelled as one group.

Changes

Area Change
common/cctx.go New IsArbitraryCallCancellable helper enumerating the four cancellable types
signer/v2_signer.go Short-circuit to SignCancel for arbitrary calls on cancellable outbound types; warn log on cancellation
observer/outbound.go New isArbitraryCallCancellation predicate mirroring the signer; warn log on event-parsing bypass
cctx_test.go, outbound_test.go, v2_signer_test.go Unit coverage for all four cancellable types and the predicate
e2etests/test_eth_withdraw_and_arbitrary_call.go Expects Reverted with principal refund
e2etests/test_erc20_withdraw_and_arbitrary_call.go Expects Reverted with ZRC20 refund to RevertAddress
e2etests/test_zevm_to_evm_arbitrary_call.go Expects Reverted, asserts dApp not invoked

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

🤖 Generated with Claude Code

Backport of #4575 to release/zetaclient/v37.

V2 outbounds with CallOptions.IsArbitraryCall=true that route through
GatewayEVM.execute are no longer signed. The signer cancels them via a
TSS-to-TSS self-transfer, and the observer mirrors the cancellation by
voting the outbound failed so the CCTX reverts via the standard V2 flow,
refunding the principal to the revert address (or original sender).

Only OutboundTypeCall (no-asset call from GatewayZEVM.call) and
OutboundTypeGasWithdrawAndCall (gas withdraw + call from
GatewayZEVM.withdrawAndCall with the gas ZRC20) are cancelled. Other V2
paths are unchanged: ERC20-`withdrawAndCall` constrains the destination
to invoke typed `Callable.onCall`; plain V2 withdraws emit
`isArbitraryCall=true` per protocol-contracts semantics ("no
authenticated sender") but don't reach signGatewayExecute.

The observer predicate is also gated on `ProtocolContractVersion_V2` so
it stays symmetric with the signer's V2-only dispatch.

Tests:
- TestSigner_SignOutboundFromCCTXV2_NoAssetArbitraryCallCancels (the
  live-attack shape)
- TestSigner_SignOutboundFromCCTXV2_GasWithdrawAndArbitraryCallCancels
- TestSigner_SignOutboundFromCCTXV2_PlainWithdrawNotCancelled
  (regression guard for GatewayZEVM.withdraw() emitting isArbitraryCall=true)
- Test_isArbitraryCallCancellation predicate table covering all
  coin-type + call combinations plus a V1 cctx negative case
- TestIsGatewayExecuteOutbound enumerates every outbound type
- E2E test_eth_withdraw_and_arbitrary_call now expects Reverted, asserts
  TSS self-transfer (value=0), and verifies ZRC20 refund to RevertAddress
- E2E test_zevm_to_evm_arbitrary_call now expects Reverted

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

coderabbitai Bot commented Apr 27, 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: f1d3af5b-aaf6-45f2-8d17-9f1b8c219d1f

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/disable-arbitrary-call-v37

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
Copy link
Copy Markdown
Contributor Author

bugbot run

@ws4charlie ws4charlie added the no-changelog Skip changelog CI check label Apr 27, 2026
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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

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

Reviewed by Cursor Bugbot for commit 80b4d3e. Configure here.

Comment thread e2e/e2etests/test_eth_withdraw_and_arbitrary_call.go Outdated
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 27, 2026

Codecov Report

❌ Patch coverage is 93.10345% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
zetaclient/chains/evm/observer/outbound.go 87.50% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

ws4charlie and others added 3 commits April 27, 2026 16:10
Mirrors the same fix applied to the v38 PR (#4575): the exact-equality
form `revertBalanceAfter == revertBalanceBefore + amount` ignored the
gas-fee leftover refunded by RefundUnusedGasFee, making the assertion
fragile. Assert the robust invariant instead — the revert address
receives AT LEAST the principal back.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The new V2 arbitrary-call cancellation subtest asserts continueKeysign
is false, matching the cancellation path. Rename to match the assertion
(was copy-pasted from the pre-existing compliance subtest's misleading
'return true' wording).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the v38 fix: GatewayZEVM.withdrawAndCall charges the user
(amount + gasFee + protocolFlatFee) up-front in ETH-ZRC20, and the
cancellation refund covers principal + a fractional gas-fee leftover —
not the full upfront cost. Capturing the baseline before the withdraw
tx made 'actualAfter >= before + amount' fail by the protocol-fee +
stability-pool delta. Move the baseline to immediately after
MustWaitForTxReceipt so the upfront cost is already deducted and the
refund is purely additive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ws4charlie ws4charlie marked this pull request as ready for review April 27, 2026 21:47
@ws4charlie ws4charlie requested a review from a team as a code owner April 27, 2026 21:47
Comment thread e2e/e2etests/test_eth_withdraw_and_arbitrary_call.go Outdated
Comment thread zetaclient/chains/evm/observer/outbound.go
Addresses PR #4576 review comments:

- Cursor: EVMVerifyOutboundTransferAmount(hash, 0) is vacuous on a TSS
  self-transfer because the helper only iterates ERC20 Transfer logs and a
  payload-less self-transfer emits none. Replace with a direct fetch via
  TransactionByHash and assert tx.Value()==0, tx.Data() empty, tx.To()==TSS.
  This actually catches a regression that turned the cancelled outbound
  back into a non-zero GatewayEVM.execute.

- Greptile: cctx.OutboundParams[0] indexed without a bounds check; add
  require.NotEmpty before dereferencing so a missing-outbound failure
  reports cleanly instead of panicking.

- Greptile: add a deployment note on isArbitraryCallCancellation
  documenting that the predicate is metadata-only and the hotfix expects
  a coordinated zetaclient rollout to avoid split-vote inconsistency
  with old-signer / new-observer combos.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ws4charlie added a commit that referenced this pull request Apr 27, 2026
Same fixes applied to v37 (#4576) for parity:

- Replace EVMVerifyOutboundTransferAmount(hash, 0) (vacuous on a
  payload-less TSS self-transfer because the helper only iterates
  ERC20 Transfer logs) with TransactionByHash + asserts
  tx.Value()==0, tx.Data() empty, tx.To()==TSS.

- Guard cctx.OutboundParams[0] indexing with require.NotEmpty so a
  missing-outbound failure reports cleanly instead of panicking.

- Document that isArbitraryCallCancellation is metadata-only and the
  hotfix expects a coordinated zetaclient rollout (avoids split-vote
  inconsistency between old-signer / new-observer combos).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ws4charlie added a commit that referenced this pull request Apr 27, 2026
* fix: disable arbitrary call in signer

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: cancel arbitrary call CCTX in signer

Replace the prior MessageContext error gate with a SignCancel redirect
in SignOutboundFromCCTXV2. An arbitrary-call CCTX now produces a
TSS-to-TSS zero-value self-transfer that consumes the assigned nonce on
the destination chain instead of leaving the outbound perpetually
unsigned, which would head-of-line block subsequent outbounds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: vote outbound failed for cancelled arbitrary call CCTX

Mirror the existing compliance-restricted cancellation branch in the EVM
observer so that V2 arbitrary call CCTXs (which the signer cancels via
SignCancel) bypass Gateway event parsing and vote the outbound as failed.
This routes the CCTX through the standard V2 revert flow and produces a
clean terminal state of Reverted, freeing the TSS pending nonce.

Update the four direct-success arbitrary call e2e tests to expect
Reverted and the destination dApp to remain uncalled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: cover arbitrary call cancellation bypass in observer

Add a unit test for isArbitraryCallCancellation covering nil call options,
non-arbitrary, and arbitrary cases, plus an integration subtest in
Test_IsOutboundProcessed that mirrors the existing compliance-restricted
subtest to verify the observer votes failed and bypasses Gateway event
parsing for V2 arbitrary call CCTXs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: narrow arbitrary-call cancel to GatewayEVM.execute path only

The unconditional IsArbitraryCall guard cancelled every plain V2
withdraw because GatewayZEVM.withdraw() emits Withdrawn with
isArbitraryCall=true even when there is no message — the flag means
"no authenticated sender" in V2, not "execute arbitrary calldata."
This regressed test_zevm_rpc and would break all V2 plain withdraws
to EOAs in production.

Restrict the cancel to OutboundTypeCall and OutboundTypeGasWithdrawAndCall —
the only outbound types that reach signGatewayExecute. Mirror the same
narrowing in the observer's isArbitraryCallCancellation so the bypass
only fires for CCTXs the signer actually cancels.

ERC20Custody.withdrawAndCall and Connector.withdrawAndCall constrain
the destination to invoke typed Callable.onCall and remain enabled.
Revert the e2e expectation flips for ERC20- and ZETA-withdraw_and_arbitrary_call;
keep ETH- and ZEVM-to-EVM-arbitrary_call as Reverted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: assert ZRC20 refund on cancelled arbitrary-call CCTX

Strengthens TestETHWithdrawAndArbitraryCall: set an explicit
RevertAddress in RevertOptions, capture the address's ETH-ZRC20
balance before/after, and assert the principal-minus-protocol-fees
is refunded via getTotalRevertedAmount — the same pattern used by
TestEtherWithdrawRestricted, which exercises the existing compliance
SignCancel path. Also asserts the cancelled outbound is a TSS
self-transfer (value=0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: gate observer cancel predicate on V2, add no-asset-call signer test

I-1: isArbitraryCallCancellation now short-circuits when
ProtocolContractVersion != V2, mirroring the signer's V2-only dispatch
in SignOutboundFromCCTXV2. V1 CCTXs don't populate CallOptions today,
but the explicit gate prevents any future V1 path from accidentally
flipping a successful outbound to failed via this predicate.

I-2: TestSigner_SignOutboundFromCCTXV2_NoAssetArbitraryCallCancels
covers OutboundTypeCall (CoinType_NoAssetCall, amount=0) — the path
the live mainnet drain used. Existing tests covered
OutboundTypeGasWithdrawAndCall and the plain-withdraw regression but
left this dispatch implicit via the predicate table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: relax refund assertion to a lower bound

getTotalRevertedAmount over-estimates the actual refund by ~2.35M wei
on the arbitrary-call path (likely a rounding/fee discrepancy between
the helper and zetacore's RefundUnusedGasFee math), causing CI flake.

Assert the more robust invariant instead: the revert address receives
AT LEAST the principal back. Zetacore guarantees this — the principal
is refunded in full via ProcessRevert and gas-fee leftover is added on
top via RefundUnusedGasFee, so balance increase >= amount always holds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: fix misleading subtest name (return false, not true)

The new V2 arbitrary-call cancellation subtest asserts continueKeysign
is false, matching the cancellation path. Rename to match the assertion
(was copy-pasted from the pre-existing compliance subtest's misleading
'return true' wording). Greptile flagged this on PR #4575.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: capture revert-recipient balance after the withdraw tx mines

The previous baseline was captured before the withdraw, but
GatewayZEVM.withdrawAndCall charges the user (amount + gasFee +
protocolFlatFee) up-front in ETH-ZRC20. The cancellation refunds the
principal and a fractional gas-fee leftover, but not the protocol flat
fee nor 100% of gas — so measured against the pre-withdraw balance,
the net change can be negative (CI failure: -392050 wei).

Move the baseline to immediately after MustWaitForTxReceipt (matching
the pattern in test_eth_withdraw_restricted_address.go). The upfront
cost is then already deducted, so the refund is purely additive and the
'actualAfter >= before + amount' lower bound holds cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: replace vacuous self-transfer check, guard OutboundParams index

Same fixes applied to v37 (#4576) for parity:

- Replace EVMVerifyOutboundTransferAmount(hash, 0) (vacuous on a
  payload-less TSS self-transfer because the helper only iterates
  ERC20 Transfer logs) with TransactionByHash + asserts
  tx.Value()==0, tx.Data() empty, tx.To()==TSS.

- Guard cctx.OutboundParams[0] indexing with require.NotEmpty so a
  missing-outbound failure reports cleanly instead of panicking.

- Document that isArbitraryCallCancellation is metadata-only and the
  hotfix expects a coordinated zetaclient rollout (avoids split-vote
  inconsistency between old-signer / new-observer combos).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread zetaclient/chains/evm/signer/v2_signer.go Outdated
Comment thread zetaclient/chains/evm/observer/outbound_test.go
ws4charlie and others added 2 commits April 28, 2026 12:50
Adds Warn-level log lines on the signer cancel path and the observer
bypass path so security-critical cancellations are visible. Each line
includes cctx index, nonce/tx, destination, and outbound type.

Addresses kingpinXD's review on PR #4576.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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 (the helper now describes which outbound types
are cancelled, not which contract function they hit).

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.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ws4charlie ws4charlie changed the title fix: cancel V2 GatewayEVM.execute arbitrary calls (backport v37) fix: cancel V2 arbitrary calls in zetaclient signer (backport v37) Apr 28, 2026
@ws4charlie ws4charlie requested a review from kingpinXD April 28, 2026 19:24
Avoid evaluating the predicate twice in the same block. The function
parses ProtocolContractVersion, dereferences outbound params, and
dispatches into ParseOutboundTypeFromCCTX, so it does real work each
call. Mirrors the same fix applied on v38.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ws4charlie ws4charlie merged commit 890662c into release/zetaclient/v37 Apr 28, 2026
44 checks passed
@ws4charlie ws4charlie deleted the fix/disable-arbitrary-call-v37 branch April 28, 2026 22:15
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.

4 participants