-
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
Changes from all commits
8501c60
0d008b4
b845e7f
904723e
7d197cf
c92bbf4
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.
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
liobrasil marked this conversation as resolved.
Collaborator
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 we had some |
||||||||||||||||
| 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 |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| import Test | ||
| import BlockchainHelpers | ||
| import "EVM" | ||
| import "FlowToken" | ||
| import "FlowYieldVaults" | ||
|
|
@@ -29,6 +30,24 @@ fun setup() { | |
| Test.expect(workerResult, Test.beSucceeded()) | ||
| } | ||
|
|
||
| access(self) | ||
| fun makeERC20BoolReturnData(length: Int, lastByte: UInt8, poisonIndex: Int?): [UInt8] { | ||
| var data: [UInt8] = [] | ||
| var i = 0 | ||
| while i < length { | ||
| var value: UInt8 = 0 | ||
| if i == length - 1 { | ||
| value = lastByte | ||
| } | ||
| if poisonIndex != nil && i == poisonIndex! { | ||
| value = 7 | ||
| } | ||
| data.append(value) | ||
| i = i + 1 | ||
| } | ||
| return data | ||
| } | ||
|
|
||
| // ----------------------------------------------------------------------------- | ||
| // TEST CASES | ||
| // ----------------------------------------------------------------------------- | ||
|
|
@@ -200,3 +219,98 @@ fun testRequestStatusFailedStructure() { | |
| Test.assertEqual(FlowYieldVaultsEVM.RequestStatus.FAILED.rawValue, failedRequest.status) | ||
| Test.assertEqual("Insufficient balance", failedRequest.message) | ||
| } | ||
|
|
||
| access(all) | ||
| fun testIsERC20BoolReturnSuccessUnitCases() { | ||
| Test.assert( | ||
| FlowYieldVaultsEVM.isERC20BoolReturnSuccess([]), | ||
| message: "Empty return data should be accepted for compatibility" | ||
| ) | ||
|
|
||
| let canonicalTrue = makeERC20BoolReturnData(length: 32, lastByte: 1, poisonIndex: nil) | ||
| Test.assert( | ||
| FlowYieldVaultsEVM.isERC20BoolReturnSuccess(canonicalTrue), | ||
| message: "Canonical ABI-encoded true should be accepted" | ||
| ) | ||
|
|
||
| let canonicalFalse = makeERC20BoolReturnData(length: 32, lastByte: 0, poisonIndex: nil) | ||
| Test.assert( | ||
| !FlowYieldVaultsEVM.isERC20BoolReturnSuccess(canonicalFalse), | ||
| message: "Canonical ABI-encoded false should be rejected" | ||
| ) | ||
|
|
||
| let nonCanonicalTrue = makeERC20BoolReturnData(length: 32, lastByte: 2, poisonIndex: nil) | ||
| Test.assert( | ||
| !FlowYieldVaultsEVM.isERC20BoolReturnSuccess(nonCanonicalTrue), | ||
| message: "Non-canonical bool values should be rejected" | ||
| ) | ||
|
|
||
| let poisonedMiddleByte = makeERC20BoolReturnData(length: 32, lastByte: 1, poisonIndex: 5) | ||
| Test.assert( | ||
| !FlowYieldVaultsEVM.isERC20BoolReturnSuccess(poisonedMiddleByte), | ||
| message: "Non-zero bytes before the final bool byte should be rejected" | ||
| ) | ||
|
|
||
| let shortReturn = makeERC20BoolReturnData(length: 31, lastByte: 1, poisonIndex: nil) | ||
| Test.assert( | ||
| !FlowYieldVaultsEVM.isERC20BoolReturnSuccess(shortReturn), | ||
| message: "Non-empty short return data should be rejected" | ||
| ) | ||
|
|
||
| let longReturn = makeERC20BoolReturnData(length: 33, lastByte: 1, poisonIndex: nil) | ||
| Test.assert( | ||
| !FlowYieldVaultsEVM.isERC20BoolReturnSuccess(longReturn), | ||
| message: "Non-empty long return data should be rejected" | ||
| ) | ||
| } | ||
|
|
||
| access(all) | ||
| fun testMarkRequestAsFailedRejectsFalseApproveRefunds() { | ||
| // completeProcessing only needs the configured requests contract address as the | ||
| // spender for approve(address,uint256). The call should fail before any request | ||
| // contract interaction, so a dummy address is enough for this regression path. | ||
| let requestsResult = updateRequestsAddress(admin, mockRequestsAddr.toString()) | ||
| Test.expect(requestsResult, Test.beSucceeded()) | ||
|
|
||
| let requestFailedCountBefore = Test.eventsOfType(Type<FlowYieldVaultsEVM.RequestFailed>()).length | ||
| // Deploy a minimal ERC20-shaped contract that returns false from approve(...) | ||
| // without reverting, matching the bug class fixed by this PR. | ||
| let falseApproveTokenAddress = deployFalseApproveToken(admin) | ||
|
|
||
| // The transaction itself asserts that markRequestAsFailed(...) returns false after | ||
| // the refund approval check, rather than silently succeeding. | ||
| let markFailedResult = _executeTransaction( | ||
| "transactions/mark_request_as_failed_false_approve.cdc", | ||
| [falseApproveTokenAddress], | ||
| admin | ||
| ) | ||
| Test.expect(markFailedResult, Test.beSucceeded()) | ||
|
|
||
| let requestFailedEvents = Test.eventsOfType(Type<FlowYieldVaultsEVM.RequestFailed>()) | ||
| Test.assert( | ||
| requestFailedEvents.length > requestFailedCountBefore, | ||
| message: "Expected RequestFailed events for the approve(false) refund path" | ||
| ) | ||
|
|
||
| // markRequestAsFailed emits one RequestFailed immediately. The last event should | ||
| // be the follow-up failure emitted by completeProcessing after approve(false). | ||
| let lastEvent = requestFailedEvents[requestFailedEvents.length - 1] as! FlowYieldVaultsEVM.RequestFailed | ||
| let expectedTokenAddress = EVM.addressFromString(falseApproveTokenAddress).toString() | ||
| Test.assertEqual(4242 as UInt256, lastEvent.requestId) | ||
| Test.assertEqual(expectedTokenAddress, lastEvent.tokenAddress) | ||
| Test.assertEqual( | ||
| "ERC20 approve for refund returned false or invalid bool data", | ||
| lastEvent.reason | ||
| ) | ||
| } | ||
|
|
||
| access(all) | ||
| fun testERC20TransferFalseReturnIsRejected() { | ||
| let falseTransferTokenAddress = deployFalseApproveToken(admin) | ||
| let transferCheckResult = _executeTransaction( | ||
| "transactions/check_false_transfer_return.cdc", | ||
| [falseTransferTokenAddress], | ||
| admin | ||
| ) | ||
| Test.expect(transferCheckResult, Test.beSucceeded()) | ||
| } | ||
|
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 test name implies the transfer-returns-false case is rejected during a withdrawal, but the test only verifies Consider adding a test that drives a full |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,12 @@ 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. Regenerate it | ||
| // with the repo's Foundry defaults from solidity/foundry.toml so the embedded | ||
| // deployment bytecode stays in sync with the compiled mock. The mock returns | ||
| // false for both approve(...) and transfer(...), and exposes decimals() so the | ||
| // withdraw-path regression test can exercise ERC20 amount conversion as well. | ||
| access(all) let falseApproveTokenBytecode = "608080604052346100155760bd908161001a8239f35b5f80fdfe608060405260043610156010575f80fd5b5f803560e01c908163095ea7b3146059578163313ce56714603d575063a9059cbb146039575f80fd5b6059565b3460565780600319360112605657602060405160128152f35b80fd5b3460835760403660031901126083576004356001600160a01b0381160360835760206040515f8152f35b5f80fdfea2646970667358221220f2d42f93a8dedb61e5106e91a3ecbe33acc335d24cd8bfa64d97be09fa49cf7064736f6c63430008140033" | ||
|
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 comment says to regenerate this manually from One lightweight guard: add a comment with the compiler version and a short content hash of |
||
|
|
||
| /* --- Mock Vault and Strategy Identifiers --- */ | ||
|
|
||
|
|
@@ -263,6 +269,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,34 @@ | ||
| import "EVM" | ||
| import "FlowYieldVaultsEVM" | ||
|
|
||
| transaction(tokenAddress: String) { | ||
| prepare(signer: auth(BorrowValue) &Account) { | ||
| let coaRef = signer.storage.borrow<auth(EVM.Call) &EVM.CadenceOwnedAccount>( | ||
| from: /storage/evm | ||
| ) ?? panic("Could not borrow COA reference") | ||
|
|
||
| let transferCalldata = EVM.encodeABIWithSignature( | ||
| "transfer(address,uint256)", | ||
| [ | ||
| EVM.addressFromString("0x0000000000000000000000000000000000000099"), | ||
| 1 as UInt256 | ||
| ] | ||
| ) | ||
|
|
||
| let transferResult = coaRef.call( | ||
| to: EVM.addressFromString(tokenAddress), | ||
| data: transferCalldata, | ||
| gasLimit: 100_000, | ||
| value: EVM.Balance(attoflow: 0) | ||
| ) | ||
|
|
||
| assert( | ||
| transferResult.status == EVM.Status.successful, | ||
| message: "Expected transfer call to succeed at the EVM level" | ||
| ) | ||
| assert( | ||
| !FlowYieldVaultsEVM.isERC20BoolReturnSuccess(transferResult.data), | ||
| message: "Expected transfer(false) return data to be rejected" | ||
| ) | ||
| } | ||
| } |
| 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,18 @@ | ||
| // 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 tests need. | ||
| function approve(address, uint256) external pure returns (bool) { | ||
| return false; | ||
| } | ||
|
|
||
| function transfer(address, uint256) external pure returns (bool) { | ||
| return false; | ||
| } | ||
|
|
||
| function decimals() external pure returns (uint8) { | ||
| return 18; | ||
| } | ||
| } |
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.