fix(FLOW-13): validate ERC20 call return semantics#60
fix(FLOW-13): validate ERC20 call return semantics#60
Conversation
Review SummaryThe fix is correct and necessary — 1. Missing test for the
|
657ba2d to
10ba22c
Compare
| } | ||
|
|
||
| return data[31] == 1 | ||
| } |
There was a problem hiding this comment.
No Cadence tests cover the two new failure paths added in this PR:
completeProcessing()— ERC20approvereturns false (semantic failure) on the refund pathbridgeERC20ToEVM()— ERC20transferreturns false (semantic failure)
Both paths are reachable in production and the fix is security-relevant, so they deserve explicit test coverage. Suggested additions to error_handling_test.cdc (or a new erc20_return_semantics_test.cdc):
- A mock ERC20 whose
approvereturns ABI-encodedfalse→ assertcompleteProcessingreturnsfalseand emitsRequestFailed - A mock ERC20 whose
transferreturns ABI-encodedfalse→ assert the WorkerHandler transaction panics/reverts - Unit cases for
isERC20BoolReturnSuccessdirectly: empty data, 32-bytetrue, 32-bytefalse, <32 non-empty bytes, >32 bytes, last byte = 2
The function itself (isERC20BoolReturnSuccess) is access(self), so unit tests must go through the integration path or the function visibility should be temporarily widened for testing (e.g., access(contract) + test helper, or a test-only view script).
10ba22c to
8501c60
Compare
| if !FlowYieldVaultsEVM.isERC20BoolReturnSuccess(approveResult.data) { | ||
| emit RequestFailed( | ||
| requestId: requestId, | ||
| userAddress: "", | ||
| requestType: requestType, | ||
| tokenAddress: tokenAddress.toString(), | ||
| amount: refundAmount, | ||
| yieldVaultId: yieldVaultId, | ||
| reason: "ERC20 approve for refund returned false or invalid bool data" | ||
| ) | ||
| return false | ||
| } |
There was a problem hiding this comment.
Double RequestFailed emission in the crash-recovery path.
completeProcessing is called from two places:
-
processRequest– if this block fires and returnsfalse,processRequestimmediatelypanics (line ~640), which rolls back the entire transaction including thisRequestFailedevent. The emission is effectively dead code in that path (same pre-existing pattern on theapproveResult.statusbranch above). -
markRequestAsFailed(crash-recovery) – here there is no downstream panic.markRequestAsFailedalready emitsRequestFailedbefore callingcompleteProcessing. Ifapprovereturnsfalseand this block fires, a secondRequestFailedevent for the samerequestIdis written on-chain and persists. Combined with the event already emitted bymarkRequestAsFailed, observers see two failure events for one request.
The pre-existing approveResult.status != EVM.Status.successful branch has the identical problem; this PR extends the pattern. The cleanest fix is to suppress the emit inside completeProcessing and let the single emission from markRequestAsFailed stand, or to add a guard that skips the emit when the refund-approval path is entered from crash-recovery.
| /// @notice Validates ERC20 bool return data semantics | ||
| /// @dev ERC20 methods may return no data (accepted for compatibility) or ABI-encoded bool. | ||
| /// When return data is present, it must be exactly 32 bytes with value `1` (true). | ||
| access(all) view fun isERC20BoolReturnSuccess(_ data: [UInt8]): Bool { |
There was a problem hiding this comment.
access(all) on an internal helper
isERC20BoolReturnSuccess is only called internally by this contract. Exposing it as access(all) unnecessarily widens the public surface. Consider access(contract) (or at most access(account)).
| access(all) view fun isERC20BoolReturnSuccess(_ data: [UInt8]): Bool { | |
| access(contract) view fun isERC20BoolReturnSuccess(_ data: [UInt8]): Bool { |
| return true | ||
| } | ||
|
|
||
| if data.length != 32 { |
There was a problem hiding this comment.
Compatibility: any return data not exactly 32 bytes is treated as failure
The guard correctly handles the common non-standard-token pattern (empty return = success) and the standard ABI-encoded bool (32 bytes). However, a small class of tokens returns extra data beyond the canonical 32 bytes (e.g. some proxy wrappers or multi-return-value variants). Those would hit this branch and be treated as a semantic failure, causing either a stuck request (approve path) or a panic + full revert (transfer path).
If the intent is to be strict, this is fine as documented. But it should at least be called out in the doc comment so operators know which tokens are compatible. Consider mentioning it in the @dev block above:
Tokens that return non-empty data other than a 32-byte ABI-encoded bool are unsupported and treated as failure.
| i = i + 1 | ||
| } | ||
|
|
||
| return data[31] == 1 |
There was a problem hiding this comment.
No unit tests for this pure function
isERC20BoolReturnSuccess is pure and has several discrete edge cases, all of which can be exercised without an EVM or full stack:
| input | expected |
|---|---|
[] (empty) |
true |
32 bytes, last = 1, rest = 0 |
true |
32 bytes, last = 0, rest = 0 |
false (approve returned false) |
| 32 bytes, a middle byte is non-zero | false (malformed) |
| 31 bytes | false |
| 33 bytes | false |
None of these are covered in error_handling_test.cdc or validation_test.cdc. A dedicated unit test (or additions to an existing test file) would make regressions here immediately visible. The function being access(all) (see other comment) makes direct invocation from tests straightforward.
| yieldVaultId: yieldVaultId, | ||
| reason: "ERC20 approve for refund returned false or invalid bool data" | ||
| ) | ||
| return false |
There was a problem hiding this comment.
Approve false-return leaves request in PROCESSING (same as pre-existing status-failure path)
When this branch returns false, completeProcessing() on the EVM contract is never called, so the request remains in PROCESSING status indefinitely. This behaviour already existed for the approveResult.status != successful path above, so this is not a regression introduced here.
Worth noting: the SchedulerHandler crash-recovery path (which marks panicked transactions as FAILED) does not trigger on a clean return false. If the approve consistently returns false for a given token, the request will be permanently stuck unless manually recovered. A follow-up to propagate a failure status update to the EVM contract even in this path may be worth tracking.
| if !FlowYieldVaultsEVM.isERC20BoolReturnSuccess(approveResult.data) { | ||
| emit RequestFailed( | ||
| requestId: requestId, | ||
| userAddress: "", | ||
| requestType: requestType, | ||
| tokenAddress: tokenAddress.toString(), | ||
| amount: refundAmount, | ||
| yieldVaultId: yieldVaultId, | ||
| reason: "ERC20 approve for refund returned false or invalid bool data" | ||
| ) | ||
| return false | ||
| } |
There was a problem hiding this comment.
Duplicate RequestFailed event when called from markRequestAsFailed
markRequestAsFailed calls emitRequestFailed(request, message) on line 663 before calling completeProcessing. When the false-approve branch is hit, this block emits a second RequestFailed for the same requestId — with userAddress: "" instead of the real user address.
Off-chain indexers listening for RequestFailed will see two events for the same request; the second one clobbers the correct user address with an empty string.
In the processRequest path this doesn't matter because the subsequent panic on line 640 causes the transaction to revert, discarding all emitted events. But in the markRequestAsFailed path the transaction succeeds, so both events are persisted.
The existing test (testMarkRequestAsFailedRejectsFalseApproveRefunds) validates only requestFailedEvents.length > requestFailedCountBefore and checks the last event, so it actually asserts against the duplicate — masking this bug.
Fix: guard the emit (and the early return false) with a flag indicating that the caller already emitted the event, or restructure so completeProcessing never emits RequestFailed directly and always delegates event emission to its callers.
| /// @notice Validates ERC20 bool return data semantics | ||
| /// @dev ERC20 methods may return no data (accepted for compatibility) or ABI-encoded bool. | ||
| /// When return data is present, it must be exactly 32 bytes with value `1` (true). | ||
| access(all) view fun isERC20BoolReturnSuccess(_ data: [UInt8]): Bool { |
There was a problem hiding this comment.
access(all) should be access(contract) (or access(self))
Every other contract-level helper in this file (decodeEVMError, uint256FromUFix64, balanceFromUFix64, ufix64FromUInt256, emitRequestFailed, …) is access(self). Marking this one access(all) unnecessarily widens the public API surface and is inconsistent.
| access(all) view fun isERC20BoolReturnSuccess(_ data: [UInt8]): Bool { | |
| access(contract) view fun isERC20BoolReturnSuccess(_ data: [UInt8]): Bool { |
| if !FlowYieldVaultsEVM.isERC20BoolReturnSuccess(transferResult.data) { | ||
| panic("ERC20 transfer to recipient returned false or invalid bool data") | ||
| } |
There was a problem hiding this comment.
No test coverage for the transfer-false panic path
The approve-false refund path gets a dedicated test (testMarkRequestAsFailedRejectsFalseApproveRefunds), but this new branch — which panics and reverts the entire withdrawal transaction — has no test. It is the higher-severity failure mode: it leaves funds in the COA (deposited by depositTokens above) until the transaction is rolled back, and it would trigger crash recovery in SchedulerHandler.
A minimal test would deploy an analogous FalseTransferToken (same pattern as FalseApproveToken but with a transfer that returns false) and verify that a WITHDRAW_FROM_YIELDVAULT request using that token causes the worker transaction to panic.
| panic("ERC20 transfer to recipient failed: \(errorMsg)") | ||
| } | ||
|
|
||
| if !FlowYieldVaultsEVM.isERC20BoolReturnSuccess(transferResult.data) { |
There was a problem hiding this comment.
Missing test for the transfer false-return path.
The PR adds this panic branch for false-returning transfer(), but the test suite only exercises the approve false-return path (via markRequestAsFailed → completeProcessing). FalseApproveToken.sol only implements approve, so there is currently no coverage for this new code path.
A false-returning transfer() occurs in bridgeFundsToEVMUser → bridgeERC20ToEVM, which is the withdrawal path (WITHDRAW_FROM_YIELDVAULT and CLOSE_YIELDVAULT). The panic here causes the whole WorkerHandler transaction to revert, leaving the request in PROCESSING state until crash recovery marks it FAILED. That behaviour is intentional, but a regression test with a FalseTransferToken fixture would confirm the check fires and doesn't silently succeed.
cadence/tests/test_helpers.cdc
Outdated
| access(all) let nativeFlowAddr = EVM.addressFromString("0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF") | ||
| // Creation bytecode for solidity/src/test/FalseApproveToken.sol. It is embedded so | ||
| // the Cadence test suite can deploy the mock without depending on a Forge build step. | ||
| access(all) let falseApproveTokenBytecode = "60808060405234601457608690816100198239f35b5f80fdfe60808060405260043610156011575f80fd5b5f90813560e01c63095ea7b3146025575f80fd5b34604c576040366003190112604c576004356001600160a01b03811603604c576020918152f35b5080fdfea2646970667358221220c7ff278b8e5279cc5adf7df58cc234302dfea0dbc7ef9d6bbe613015e424a98064736f6c63430008140033" |
There was a problem hiding this comment.
Hardcoded bytecode is not validated against the Solidity source.
falseApproveTokenBytecode was compiled from solidity/src/test/FalseApproveToken.sol at a specific point in time, but there is no CI step that re-compiles the contract and checks the two stay in sync. If FalseApproveToken.sol is changed (e.g. compiler version bump, optimizer setting, or adding a transfer stub for the missing-test case above), this hex string will silently use stale bytecode and the test will continue to pass against the wrong contract.
Consider adding a note in FalseApproveToken.sol linking to this constant and/or a CI assertion that the compiled creation bytecode matches the embedded string.
|
|
||
| if !FlowYieldVaultsEVM.isERC20BoolReturnSuccess(transferResult.data) { | ||
| panic("ERC20 transfer to recipient returned false or invalid bool data") | ||
| } |
There was a problem hiding this comment.
No regression test covers this new branch. The approve-false path gets a dedicated testMarkRequestAsFailedRejectsFalseApproveRefunds test, but the symmetric case here — a token whose transfer() returns false — has no corresponding test. Because bridgeERC20ToEVM is reached on the happy path of WITHDRAW requests (not the refund path), a FalseTransferToken fixture and a matching test that triggers a WITHDRAW against it would be needed to give this guard the same confidence as the approve check.
| /// @notice Validates ERC20 bool return data semantics | ||
| /// @dev ERC20 methods may return no data (accepted for compatibility) or ABI-encoded bool. | ||
| /// When return data is present, it must be exactly 32 bytes with value `1` (true). | ||
| access(all) view fun isERC20BoolReturnSuccess(_ data: [UInt8]): Bool { |
There was a problem hiding this comment.
access(all) makes this callable by any script or external transaction. It's a pure validation helper with no side effects, so there's no security risk, but it unnecessarily widens the contract's public API. Compare with decodeEVMError which is access(self). Consider access(self) or access(contract) here; if it needs to remain callable from scripts for test assertions, access(all) view is acceptable but worth documenting as intentional.
|
|
||
| access(all) let mockRequestsAddr = EVM.addressFromString("0x0000000000000000000000000000000000000002") | ||
| access(all) let nativeFlowAddr = EVM.addressFromString("0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF") | ||
| // Creation bytecode for solidity/src/test/FalseApproveToken.sol. It is embedded so |
There was a problem hiding this comment.
The creation bytecode is hand-embedded and will silently drift if FalseApproveToken.sol is ever edited (e.g. to add transfer for the symmetric test case). Consider adding a comment that pins the expected solc version and a checksum, or a CI step that rebuilds and compares. As-is, a source change won't surface until a test is run against the live emulator.
Summary
Fixes #28
Validates ERC20 call return semantics in the Cadence worker so EVM-level call success is not treated as token-operation success when ERC20 methods explicitly return
false.Key points:
completeProcessing()now validates ERC20approve(address,uint256)return data when presentbridgeERC20ToEVM()now validates ERC20transfer(address,uint256)return data when presentChanges
isERC20BoolReturnSuccess(_ data: [UInt8]): Boolapprovethat returnsfalse(or invalid bool data) now emitsRequestFailedand aborts completionTest plan
./local/run_solidity_tests.sh./local/run_cadence_tests.sh