-
Notifications
You must be signed in to change notification settings - Fork 1
fix(FLOW-13): validate ERC20 call return semantics #60
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
8501c60
0d008b4
b845e7f
904723e
7d197cf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -1007,7 +1007,8 @@ access(all) contract FlowYieldVaultsEVM { | |||||||||||||||
|
|
||||||||||||||||
| /// @notice Marks a request as COMPLETED or FAILED, returning escrowed funds on failure | ||||||||||||||||
| /// @dev For failed CREATE/DEPOSIT: returns funds from COA to EVM contract via msg.value (native) | ||||||||||||||||
| /// or approve+transferFrom (ERC20). For WITHDRAW/CLOSE or success: no refund sent. | ||||||||||||||||
| /// or approve+transferFrom (ERC20). ERC20 approve return data is validated when present. | ||||||||||||||||
| /// For WITHDRAW/CLOSE or success: no refund sent. | ||||||||||||||||
| /// @param requestId The request ID to complete | ||||||||||||||||
| /// @param success Whether the operation succeeded | ||||||||||||||||
| /// @param yieldVaultId The associated YieldVault Id | ||||||||||||||||
|
|
@@ -1076,6 +1077,19 @@ access(all) contract FlowYieldVaultsEVM { | |||||||||||||||
| ) | ||||||||||||||||
| return false | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| 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 | ||||||||||||||||
| } | ||||||||||||||||
liobrasil marked this conversation as resolved.
Show resolved
Hide resolved
Comment on lines
+1081
to
+1092
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Double
The pre-existing
Comment on lines
+1081
to
+1092
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Duplicate
Off-chain indexers listening for In the The existing test ( Fix: guard the emit (and the early |
||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
|
|
@@ -1186,7 +1200,8 @@ access(all) contract FlowYieldVaultsEVM { | |||||||||||||||
|
|
||||||||||||||||
| /// @notice Bridges a Cadence vault to ERC20 tokens on EVM and transfers to recipient | ||||||||||||||||
| /// @dev Uses COA.depositTokens() to bridge tokens to EVM, then calls ERC20.transfer() | ||||||||||||||||
| /// to send the tokens to the recipient address. | ||||||||||||||||
| /// to send the tokens to the recipient address. ERC20 transfer return data is validated | ||||||||||||||||
| /// when present to ensure semantic success. | ||||||||||||||||
| /// @param vault The Cadence vault containing tokens to bridge (will be consumed) | ||||||||||||||||
| /// @param recipient The EVM address to receive the tokens | ||||||||||||||||
| /// @param tokenAddress The ERC20 token address on EVM | ||||||||||||||||
|
|
@@ -1222,6 +1237,10 @@ access(all) contract FlowYieldVaultsEVM { | |||||||||||||||
| let errorMsg = FlowYieldVaultsEVM.decodeEVMError(transferResult.data) | ||||||||||||||||
| panic("ERC20 transfer to recipient failed: \(errorMsg)") | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| if !FlowYieldVaultsEVM.isERC20BoolReturnSuccess(transferResult.data) { | ||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing test for the The PR adds this panic branch for false-returning A false-returning |
||||||||||||||||
| panic("ERC20 transfer to recipient returned false or invalid bool data") | ||||||||||||||||
| } | ||||||||||||||||
|
Comment on lines
+1241
to
+1243
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No test coverage for the transfer-false panic path The approve-false refund path gets a dedicated test ( A minimal test would deploy an analogous There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No regression test covers this new branch. The |
||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| // ============================================ | ||||||||||||||||
|
|
@@ -1990,6 +2009,29 @@ access(all) contract FlowYieldVaultsEVM { | |||||||||||||||
| return "EVM revert data: 0x\(String.encodeHex(data))" | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| /// @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. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Every other contract-level helper in this file (
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||
| if data.length == 0 { | ||||||||||||||||
| return true | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| if data.length != 32 { | ||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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
|
||||||||||||||||
| return false | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| var i = 0 | ||||||||||||||||
| while i < 31 { | ||||||||||||||||
| if data[i] != 0 { | ||||||||||||||||
| return false | ||||||||||||||||
| } | ||||||||||||||||
| i = i + 1 | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| return data[31] == 1 | ||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No unit tests for this pure function
None of these are covered in |
||||||||||||||||
| } | ||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No Cadence tests cover the two new failure paths added in this PR:
Both paths are reachable in production and the fix is security-relevant, so they deserve explicit test coverage. Suggested additions to
The function itself ( |
||||||||||||||||
|
|
||||||||||||||||
| /// @notice Emits the RequestFailed event and returns a ProcessResult with success=false | ||||||||||||||||
| /// @dev This is a helper function to emit the RequestFailed event and return a ProcessResult with success=false | ||||||||||||||||
| /// @param request The EVM request that failed | ||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,9 @@ access(all) let admin = Test.getAccount(0x0000000000000007) // testing alias | |
|
|
||
| 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. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The creation bytecode is hand-embedded and will silently drift if |
||
| // the Cadence test suite can deploy the mock without depending on a Forge build step. | ||
| access(all) let falseApproveTokenBytecode = "60808060405234601457608690816100198239f35b5f80fdfe60808060405260043610156011575f80fd5b5f90813560e01c63095ea7b3146025575f80fd5b34604c576040366003190112604c576004356001600160a01b03811603604c576020918152f35b5080fdfea2646970667358221220c128b17595d998b699a78b40cad92d7f4abc61b14795de2abd078778dd30397164736f6c63430008140033" | ||
|
|
||
| /* --- Mock Vault and Strategy Identifiers --- */ | ||
|
|
||
|
|
@@ -263,6 +266,35 @@ fun setupCOA(_ signer: Test.TestAccount): Test.TransactionResult { | |
| ) | ||
| } | ||
|
|
||
| access(all) | ||
| fun deployEVMContract(_ signer: Test.TestAccount, _ bytecode: String, _ gasLimit: UInt64): String { | ||
| let deployResult = _executeTransaction( | ||
| "../transactions/deploy_evm_contract.cdc", | ||
| [bytecode, gasLimit], | ||
| signer | ||
| ) | ||
| Test.expect(deployResult, Test.beSucceeded()) | ||
|
|
||
| // The deployment transaction itself has no return value, so the deployed address | ||
| // is recovered from the latest EVM.TransactionExecuted event. | ||
| let txnEvents = Test.eventsOfType(Type<EVM.TransactionExecuted>()) | ||
| Test.assert(txnEvents.length > 0, message: "Expected an EVM.TransactionExecuted event after deployment") | ||
|
|
||
| let evt = txnEvents[txnEvents.length - 1] as? EVM.TransactionExecuted | ||
| ?? panic("Latest event is not EVM.TransactionExecuted") | ||
| let contractAddress = evt.contractAddress | ||
| Test.assert(contractAddress.length > 0, message: "Deployed contract address should not be empty") | ||
|
|
||
| return contractAddress | ||
| } | ||
|
|
||
| access(all) | ||
| fun deployFalseApproveToken(_ signer: Test.TestAccount): String { | ||
| // Reuse the generic deploy helper so the regression test exercises the same COA | ||
| // deployment path we use for other EVM fixtures. | ||
| return deployEVMContract(signer, falseApproveTokenBytecode, UInt64(15_000_000)) | ||
| } | ||
|
|
||
| /* --- FlowYieldVaultsEVM specific script helpers --- */ | ||
|
|
||
| access(all) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import "EVM" | ||
| import "FlowYieldVaultsEVM" | ||
|
|
||
| transaction(tokenAddress: String) { | ||
| prepare(signer: auth(BorrowValue) &Account) { | ||
| let worker = signer.storage.borrow<&FlowYieldVaultsEVM.Worker>( | ||
| from: FlowYieldVaultsEVM.WorkerStoragePath | ||
| ) ?? panic("Could not borrow FlowYieldVaultsEVM worker") | ||
|
|
||
| // Any failed CREATE or DEPOSIT request with a non-native token and a positive | ||
| // amount enters the refund approval path inside completeProcessing(...). | ||
| let request = FlowYieldVaultsEVM.EVMRequest( | ||
| id: 4242, | ||
| user: EVM.addressFromString("0x0000000000000000000000000000000000000099"), | ||
| requestType: FlowYieldVaultsEVM.RequestType.CREATE_YIELDVAULT.rawValue, | ||
| status: FlowYieldVaultsEVM.RequestStatus.PROCESSING.rawValue, | ||
| tokenAddress: EVM.addressFromString(tokenAddress), | ||
| amount: 1, | ||
| yieldVaultId: nil, | ||
| timestamp: 0, | ||
| message: "", | ||
| vaultIdentifier: "", | ||
| strategyIdentifier: "" | ||
| ) | ||
|
|
||
| // The regression is fixed only if this returns false after approve(false) | ||
| // instead of letting the request look successfully completed. | ||
| let result = worker.markRequestAsFailed( | ||
| request, | ||
| message: "Synthetic failure before refund approval" | ||
| ) | ||
|
|
||
| assert(!result, message: "Expected approve(false) refund path to return false") | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| // SPDX-License-Identifier: MIT | ||
| pragma solidity ^0.8.20; | ||
|
|
||
| contract FalseApproveToken { | ||
| // Minimal weird-token fixture: the call succeeds at the EVM level but returns | ||
| // `false`, which is the exact behavior the Cadence regression test needs. | ||
| function approve(address, uint256) external pure returns (bool) { | ||
| return false; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 inPROCESSINGstatus indefinitely. This behaviour already existed for theapproveResult.status != successfulpath 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 returnsfalsefor 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.