Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions cadence/contracts/FlowYieldVaultsEVM.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Copy link

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 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.

}
Comment on lines +1081 to +1092
Copy link

Choose a reason for hiding this comment

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

Double RequestFailed emission in the crash-recovery path.

completeProcessing is called from two places:

  1. processRequest – if this block fires and returns false, processRequest immediately panics (line ~640), which rolls back the entire transaction including this RequestFailed event. The emission is effectively dead code in that path (same pre-existing pattern on the approveResult.status branch above).

  2. markRequestAsFailed (crash-recovery) – here there is no downstream panic. markRequestAsFailed already emits RequestFailed before calling completeProcessing. If approve returns false and this block fires, a second RequestFailed event for the same requestId is written on-chain and persists. Combined with the event already emitted by markRequestAsFailed, 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.

Comment on lines +1081 to +1092
Copy link

Choose a reason for hiding this comment

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

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.

}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Copy link

Choose a reason for hiding this comment

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

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 markRequestAsFailedcompleteProcessing). FalseApproveToken.sol only implements approve, so there is currently no coverage for this new code path.

A false-returning transfer() occurs in bridgeFundsToEVMUserbridgeERC20ToEVM, 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.

panic("ERC20 transfer to recipient returned false or invalid bool data")
}
Comment on lines +1241 to +1243
Copy link

Choose a reason for hiding this comment

The 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 (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.

Copy link

Choose a reason for hiding this comment

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

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.

}

// ============================================
Expand Down Expand Up @@ -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 {
Copy link

Choose a reason for hiding this comment

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

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)).

Suggested change
access(all) view fun isERC20BoolReturnSuccess(_ data: [UInt8]): Bool {
access(contract) view fun isERC20BoolReturnSuccess(_ data: [UInt8]): Bool {

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
access(all) view fun isERC20BoolReturnSuccess(_ data: [UInt8]): Bool {
access(contract) view fun isERC20BoolReturnSuccess(_ data: [UInt8]): Bool {

Copy link

Choose a reason for hiding this comment

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

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.

if data.length == 0 {
return true
}

if data.length != 32 {
Copy link

Choose a reason for hiding this comment

The 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 @dev block above:

Tokens that return non-empty data other than a 32-byte ABI-encoded bool are unsupported and treated as failure.

return false
}

var i = 0
while i < 31 {
if data[i] != 0 {
return false
}
i = i + 1
}

return data[31] == 1
Copy link

Choose a reason for hiding this comment

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

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.

}
Copy link

Choose a reason for hiding this comment

The 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:

  1. completeProcessing() — ERC20 approve returns false (semantic failure) on the refund path
  2. bridgeERC20ToEVM() — ERC20 transfer returns 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 approve returns ABI-encoded false → assert completeProcessing returns false and emits RequestFailed
  • A mock ERC20 whose transfer returns ABI-encoded false → assert the WorkerHandler transaction panics/reverts
  • Unit cases for isERC20BoolReturnSuccess directly: empty data, 32-byte true, 32-byte false, <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).


/// @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
Expand Down
40 changes: 40 additions & 0 deletions cadence/tests/error_handling_test.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,43 @@ fun testRequestStatusFailedStructure() {
Test.assertEqual(FlowYieldVaultsEVM.RequestStatus.FAILED.rawValue, failedRequest.status)
Test.assertEqual("Insufficient balance", failedRequest.message)
}

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
)
}
32 changes: 32 additions & 0 deletions cadence/tests/test_helpers.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Choose a reason for hiding this comment

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

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.

// 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 --- */

Expand Down Expand Up @@ -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)
Expand Down
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")
}
}
10 changes: 10 additions & 0 deletions solidity/src/test/FalseApproveToken.sol
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;
}
}
Loading