Skip to content
Merged
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
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ flow deps install --skip-alias --skip-deployments # Install dependencies
1. **EVM User** calls `FlowYieldVaultsRequests.sol` (creates request, escrows funds)
2. **FlowYieldVaultsEVMWorkerOps.cdc** SchedulerHandler schedules WorkerHandlers to process requests
3. **FlowYieldVaultsEVM.cdc** Worker fetches pending requests via `getPendingRequestsUnpacked()`
4. **Two-phase commit**: `startProcessingBatch()` marks PROCESSING and deducts balance, `completeProcessing()` marks COMPLETED/FAILED (refunds credited to `claimableRefunds` on failure)
4. **Two-phase commit**: `startProcessingBatch()` marks PROCESSING and deducts balance, `completeProcessing()` marks COMPLETED/FAILED and credits any EVM-side refund due to `claimableRefunds` (failed CREATE/DEPOSIT or successful CREATE/DEPOSIT precision residuals)

### Contract Components

Expand Down Expand Up @@ -117,6 +117,8 @@ flow deps install --skip-alias --skip-deployments # Install dependencies
| FlowYieldVaultsEVM (Cadence) | `df111ffc5064198a` |
| FlowYieldVaultsEVMWorkerOps | `df111ffc5064198a` |

The current worker code expects the 5-argument `completeProcessing(uint256,bool,uint64,string,uint256)` ABI. Keep Cadence and EVM deployments in sync when reviewing or updating testnet addresses.

## Blockchain Execution Model (Critical for Code Review)

When reviewing this codebase, keep these fundamental blockchain properties in mind:
Expand Down
29 changes: 16 additions & 13 deletions FLOW_YIELD_VAULTS_EVM_BRIDGE_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ mapping(address => uint256[]) public pendingRequestIdsByUser;

// Balance tracking
mapping(address => mapping(address => uint256)) public pendingUserBalances; // Escrowed for active requests
mapping(address => mapping(address => uint256)) public claimableRefunds; // Claimable from cancelled/failed
mapping(address => mapping(address => uint256)) public claimableRefunds; // Claimable from cancelled, dropped, failed, or success-path residual refunds

// Token configuration
mapping(address => TokenConfig) public allowedTokens;
Expand Down Expand Up @@ -226,7 +226,7 @@ enum RequestStatus {
PENDING, // 0 - Awaiting processing
PROCESSING, // 1 - Being processed (balance deducted)
COMPLETED, // 2 - Successfully processed
FAILED // 3 - Failed (refund credited; user must claim)
FAILED // 3 - Failed (escrowed CREATE/DEPOSIT may credit a claimable refund)
}

struct TokenConfig {
Expand Down Expand Up @@ -385,6 +385,7 @@ All refund scenarios use a pull pattern - funds are credited to `claimableRefund
| Scenario | What Happens |
|----------|--------------|
| After `startProcessingBatch()` (failed CREATE/DEPOSIT) | Funds credited to `claimableRefunds` |
| Successful CREATE/DEPOSIT with precision loss | Precision residual credited to `claimableRefunds` |
| User cancels request | Funds moved from `pendingUserBalances` to `claimableRefunds` |
| Admin drops request | Funds moved from `pendingUserBalances` to `claimableRefunds` |
| WITHDRAW/CLOSE | No escrowed funds on EVM side, so refunds are not applicable |
Expand Down Expand Up @@ -418,19 +419,21 @@ function completeProcessing(
uint256 requestId,
bool success,
uint64 yieldVaultId,
string calldata message
string calldata message,
uint256 refundAmount
) external onlyAuthorizedCOA {
// 1. Validate request is PROCESSING
// 2. Mark as COMPLETED or FAILED
// 3. On failure (CREATE/DEPOSIT): Credit claimableRefunds (user must call claimRefund)
// 4. On CREATE_YIELDVAULT success: Register YieldVault ownership
// 5. On CLOSE_YIELDVAULT success: Unregister YieldVault ownership
// 6. Remove from pending queue
// 7. Emit RequestProcessed event
// 2. Validate refundAmount against the request lifecycle
// 3. Mark as COMPLETED or FAILED
// 4. Credit claimableRefunds when a failed CREATE/DEPOSIT or success-path precision residual must stay on EVM
// 5. On CREATE_YIELDVAULT success: Register YieldVault ownership
// 6. On CLOSE_YIELDVAULT success: Unregister YieldVault ownership
// 7. Remove from pending queue
// 8. Emit RequestProcessed event
}
```

**Purpose:** Finalizes the operation with proper cleanup. On failure, refunds are credited for later claim; cross-VM flow is not atomic.
**Purpose:** Finalizes the operation with proper cleanup. Refunds are credited for later claim when a failed CREATE/DEPOSIT must be returned on EVM or when a successful CREATE/DEPOSIT leaves a precision residual on EVM; cross-VM flow is not atomic.

---

Expand Down Expand Up @@ -620,8 +623,8 @@ Ownership is verified for WITHDRAW/CLOSE on both EVM and Cadence. Deposits are p

### Fund Safety

1. **Escrow Model:** Funds held in contract until processing begins; refunds are claimable on failure
2. **Two-Phase Commit:** Balance deducted before operation, credited back on failure
1. **Escrow Model:** Funds held in contract until processing begins; failed CREATE/DEPOSIT and success-path precision residuals become claimable refunds
2. **Two-Phase Commit:** Balance deducted before operation, then reconciled on completion via success/failure finalization plus any explicit refund amount
3. **Cross-VM Non-Atomicity:** Funds can be in transit between EVM and Cadence; stuck PROCESSING is possible without admin recovery
4. **ReentrancyGuard:** Solidity contract protected against reentrancy

Expand Down Expand Up @@ -742,7 +745,7 @@ pre {

### Cadence Error Handling

Failed operations return `ProcessResult` with `success: false` and descriptive message. The Worker emits `RequestFailed` and calls `completeProcessing(success: false)` to credit refunds for later `claimRefund`.
Failed operations return `ProcessResult` with `success: false` and descriptive message. The Worker emits `RequestFailed` and calls `completeProcessing(..., refundAmount)` to credit any failed escrowed amount for later `claimRefund`. Successful CREATE/DEPOSIT requests may also credit a smaller precision-residual refund during completion.

---

Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ This bridge allows EVM users to interact with Flow YieldVaults (yield-generating
3. **SchedulerHandler** fetches pending requests, calls `preprocessRequests()` to validate and transition (PENDING → PROCESSING), then schedules WorkerHandlers
4. **WorkerHandler** processes individual requests via `processRequest()`:
- Execute Cadence operation (create/deposit/withdraw/close YieldVault)
- `completeProcessing()`: Marks as COMPLETED or FAILED (on failure, credits `claimableRefunds`; user claims via `claimRefund`)
- `completeProcessing()`: Marks as COMPLETED or FAILED and credits any EVM-side refund due to `claimableRefunds` (failed CREATE/DEPOSIT or successful CREATE/DEPOSIT precision residuals; user claims via `claimRefund`)
5. **Funds bridged** to user on withdrawal/close operations

## Quick Start
Expand Down Expand Up @@ -193,6 +193,8 @@ forge script ./solidity/script/FlowYieldVaultsYieldVaultOperations.s.sol:FlowYie

Source of truth for published addresses: `deployments/contract-addresses.json`.

The current contracts must be deployed in lockstep: the worker now calls the 5-argument `completeProcessing(uint256,bool,uint64,string,uint256)` ABI, so any environment still pointing at an older `FlowYieldVaultsRequests` deployment must be updated before using the current Cadence worker.

## Versioning and Branching

This repo follows the contract versioning approach discussed in
Expand Down Expand Up @@ -281,7 +283,7 @@ Testnet E2E uses `deployments/contract-addresses.json` to auto-load addresses (s

### Fund Safety

- Funds are escrowed until processing begins; failed CREATE/DEPOSIT credit refunds to `claimableRefunds` (user calls `claimRefund`)
- Funds are escrowed until processing begins; failed CREATE/DEPOSIT and successful CREATE/DEPOSIT precision residuals credit `claimableRefunds` (user calls `claimRefund`)
- Two-phase commit keeps EVM-side balance updates consistent; cross-VM flow is not atomic
- Request cancellation and admin drop move escrowed funds to `claimableRefunds` (pull pattern)

Expand Down
105 changes: 81 additions & 24 deletions cadence/contracts/FlowYieldVaultsEVM.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@ import "FlowEVMBridgeConfig"
/// batch-update statuses (PENDING -> PROCESSING for valid, PENDING -> FAILED for invalid)
/// 2. Processing: For each PROCESSING request, executes Cadence-side operation
/// (create/deposit/withdraw/close YieldVault), then calls completeProcessing() to mark
/// as COMPLETED or FAILED (with refund to EVM contract on CREATE/DEPOSIT failure)
/// as COMPLETED or FAILED while reconciling any refund owed on the EVM side
/// PRECISION NOTE:
/// Native FLOW uses 18 decimals and ERC20 tokens use their own token decimals,
/// while Cadence UFix64 supports 8 decimal places.
/// CREATE/DEPOSIT requests therefore round the bridged amount down to the nearest
/// Cadence-representable quantity and refund any remainder back to EVM claimable refunds.

access(all) contract FlowYieldVaultsEVM {

// ============================================
Expand Down Expand Up @@ -575,7 +581,7 @@ access(all) contract FlowYieldVaultsEVM {
/// @dev This is the main dispatcher that:
/// 1. Validates request status - should be PROCESSING
/// 2. Dispatches to the appropriate process function based on request type
/// 3. Calls completeProcessing to update final status (with refund on failure for CREATE/DEPOSIT)
/// 3. Calls completeProcessing to update final status and return any refund due on EVM
/// @param request The EVM request to process
/// @return ProcessResult with success status, the yieldVaultId, and status message
access(all) fun processRequest(_ request: EVMRequest): ProcessResult {
Expand Down Expand Up @@ -622,14 +628,17 @@ access(all) contract FlowYieldVaultsEVM {
)
}

// Pass refund info - completeProcessing will determine if refund is needed
// based on success flag and request type
let refundAmount = FlowYieldVaultsEVM.refundAmountForCompletion(
request: request,
success: result!.success
)

if !self.completeProcessing(
requestId: request.id,
success: result!.success,
yieldVaultId: result!.yieldVaultId,
message: result!.message,
refundAmount: request.amount,
refundAmount: refundAmount,
tokenAddress: request.tokenAddress,
requestType: request.requestType
) {
Expand All @@ -653,7 +662,9 @@ access(all) contract FlowYieldVaultsEVM {
}

/// @notice Marks a request as FAILED
/// @dev Calls completeProcessing to mark the request as failed with the given message
/// @dev Calls completeProcessing to mark the request as failed with the given message.
/// Uses the same refund computation as the normal completion path to keep
/// failed CREATE/DEPOSIT and failed WITHDRAW/CLOSE behavior consistent.
/// @param request The EVM request to mark as failed
/// @param message The error message to include in the result
/// @return True if the request was marked as failed on EVM, false otherwise
Expand All @@ -664,12 +675,17 @@ access(all) contract FlowYieldVaultsEVM {

FlowYieldVaultsEVM.emitRequestFailed(request, message: message)

let refundAmount = FlowYieldVaultsEVM.refundAmountForCompletion(
request: request,
success: false,
)

return self.completeProcessing(
requestId: request.id,
success: false,
yieldVaultId: request.yieldVaultId,
message: message,
refundAmount: request.amount,
refundAmount: refundAmount,
tokenAddress: request.tokenAddress,
requestType: request.requestType,
)
Expand Down Expand Up @@ -1007,15 +1023,17 @@ access(all) contract FlowYieldVaultsEVM {
return nil // success
}

/// @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). ERC20 approve return data is validated when present.
/// For WITHDRAW/CLOSE or success: no refund sent.
/// @notice Marks a request as COMPLETED or FAILED, returning any refund owed on EVM
/// @dev Failed CREATE/DEPOSIT requests return the full escrowed amount to the EVM requests
/// contract, where it is credited to claimable refunds. Successful CREATE/DEPOSIT
/// requests may return only a precision residual that Cadence could not represent
/// exactly. WITHDRAW/CLOSE requests never send a refund. ERC20 approve return data
/// is validated when present.
/// @param requestId The request ID to complete
/// @param success Whether the operation succeeded
/// @param yieldVaultId The associated YieldVault Id
/// @param message Status message or error reason
/// @param refundAmount Amount to refund (0 if no refund needed)
/// @param refundAmount Amount to credit to claimable refunds (0 if no refund needed)
/// @param tokenAddress Token address for refund (used to determine native vs ERC20)
/// @param requestType The type of request (needed to determine if refund applies)
/// @return True if the EVM call succeeded, false otherwise
Expand All @@ -1033,13 +1051,12 @@ access(all) contract FlowYieldVaultsEVM {
let evmYieldVaultId = yieldVaultId ?? UInt64.max

let calldata = EVM.encodeABIWithSignature(
"completeProcessing(uint256,bool,uint64,string)",
[requestId, success, evmYieldVaultId, message]
"completeProcessing(uint256,bool,uint64,string,uint256)",
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug (regression): markRequestAsFailed sends the wrong refundAmount for WITHDRAW requests

Switching from the 4-arg to the 5-arg ABI here is correct for processRequest, but markRequestAsFailed (line 679) still passes refundAmount: request.amount unconditionally and is not updated in this PR.

Before this PR, the 4-arg Solidity overload silently ignored refundAmount for non-escrowed types. Now the 5-arg Solidity overload calls _completeProcessing which strictly validates:

uint256 expectedFailedRefundAmount = isEscrowedRequest ? request.amount : 0;
if (refundAmount != expectedFailedRefundAmount) revert InvalidRefundAmount(...);

For a WITHDRAW_FROM_YIELDVAULT request with a non-zero amount:

  • isEscrowedRequest = false → expected = 0
  • Cadence passes request.amount → revert
  • markRequestAsFailed returns false → request permanently stuck in PROCESSING

This breaks both crash-recovery paths in FlowYieldVaultsEVMWorkerOps.cdc (lines 326 and 667).

The fix is to use the helper introduced in this PR in markRequestAsFailed:

refundAmount: FlowYieldVaultsEVM.refundAmountForCompletion(request: request, success: false),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Breaking ABI change — needs coordinated testnet re-deployment

The Solidity function signature changed from completeProcessing(uint256,bool,uint64,string) to completeProcessing(uint256,bool,uint64,string,uint256). The Cadence calldata here is consistent with the new Solidity signature and the artifact JSON is updated, so the code is internally coherent.

However, any previously deployed testnet instance of FlowYieldVaultsRequests (address 0xF633C9... in CLAUDE.md) must be re-deployed before this Cadence contract is upgraded, otherwise every call to completeProcessing will revert because the 4-arg selector no longer exists. A phased or atomic upgrade is needed.

[requestId, success, evmYieldVaultId, message, refundAmount]
)

// Determine if refund is needed (failed CREATE or DEPOSIT)
let needsRefund = !success
&& refundAmount > 0
// Determine if refund is needed for CREATE/DEPOSIT lifecycle accounting
let needsRefund = refundAmount > 0
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant requestType guard obscures invariant ownership

refundAmountForCompletion() already guarantees refundAmount == 0 for WITHDRAW/CLOSE requests, so the requestType check here is unreachable dead code. This is fine for defence-in-depth, but if the check is ever wrong it masks the real bug: Solidity would still reject the call via InvalidRefundAmount, so a caller cannot use this guard to bypass the accounting check.

Worth documenting explicitly:

// refundAmountForCompletion() already guarantees refundAmount == 0 for WITHDRAW/CLOSE,
// so the requestType guard below is a secondary safety net only.
let needsRefund = refundAmount > 0
    && (requestType == FlowYieldVaultsEVM.RequestType.CREATE_YIELDVAULT.rawValue
        || requestType == FlowYieldVaultsEVM.RequestType.DEPOSIT_TO_YIELDVAULT.rawValue)

Not a bug, but the comment should clarify that refundAmountForCompletion is the primary invariant so future readers don't assume the guard here is load-bearing.

&& (requestType == FlowYieldVaultsEVM.RequestType.CREATE_YIELDVAULT.rawValue
|| requestType == FlowYieldVaultsEVM.RequestType.DEPOSIT_TO_YIELDVAULT.rawValue)
Comment on lines +1059 to 1061
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant requestType guard — refundAmountForCompletion already returns 0 for WITHDRAW/CLOSE

refundAmountForCompletion returns 0 for any non-escrowed request type (WITHDRAW/CLOSE), so refundAmount > 0 can only ever be true for CREATE/DEPOSIT requests. The requestType == CREATE_YIELDVAULT || ... check here is therefore dead code and can cause a reader to believe there is a code path where refundAmount > 0 for WITHDRAW/CLOSE.

Simplify to:

Suggested change
let needsRefund = refundAmount > 0
&& (requestType == FlowYieldVaultsEVM.RequestType.CREATE_YIELDVAULT.rawValue
|| requestType == FlowYieldVaultsEVM.RequestType.DEPOSIT_TO_YIELDVAULT.rawValue)
let needsRefund = refundAmount > 0


Expand All @@ -1050,10 +1067,9 @@ access(all) contract FlowYieldVaultsEVM {
let isNativeFlow = tokenAddress.toString() == FlowYieldVaultsEVM.nativeFlowEVMAddress.toString()

if isNativeFlow {
// Native FLOW: send with the call
// Convert UInt256 to UFix64 then to EVM.Balance
let refundUFix64 = FlowYieldVaultsEVM.ufix64FromUInt256(refundAmount, tokenAddress: tokenAddress)
refundValue = FlowYieldVaultsEVM.balanceFromUFix64(refundUFix64, tokenAddress: tokenAddress)
// Native FLOW: send the original attoflow amount back to EVM without
// round-tripping through UFix64, which would truncate sub-8-decimal precision.
refundValue = EVM.Balance(attoflow: UInt(refundAmount))
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The fix is correct. The old code round-tripped through UFix64 truncation:

// OLD — BROKEN for sub-8-decimal amounts
let refundUFix64 = ufix64FromUInt256(refundAmount, ...)  // truncates to 8 decimals
refundValue = balanceFromUFix64(refundUFix64, ...)        // encodes truncated amount

Because FlowYieldVaultsRequests.sol enforces msg.value == request.amount exactly:

if (msg.value != request.amount) revert MsgValueMustEqualAmount();

…any request.amount with sub-8-decimal precision would cause the EVM call to revert. completeProcessing returning false triggers a panic in processRequest, and the request would be permanently stuck in PROCESSING.

The new EVM.Balance(attoflow: UInt(refundAmount)) sends the original wei amount without precision loss, which is the right fix. UInt in Cadence is arbitrary-precision, so the cast is safe for any deposit size.

One gap: the Solidity tests added in this PR call completeProcessing directly as the COA and verify the Solidity-side accounting. They don't exercise the Cadence code path (completeProcessingEVM.Balance(attoflow: UInt(refundAmount))). Consider adding a Cadence error_handling_test scenario with a sub-8-decimal amount (e.g. 1_000_000_000_123_456_789 attoflow) that triggers a failure, and asserting the claimable refund matches the exact input amount.

} else {
// ERC20: approve contract to pull funds
let approveCalldata = EVM.encodeABIWithSignature(
Expand Down Expand Up @@ -1978,18 +1994,59 @@ access(all) contract FlowYieldVaultsEVM {
/// @dev For native FLOW: Uses 18 decimals (attoflow to FLOW conversion)
/// For ERC20: Uses FlowEVMBridgeUtils to look up token decimals.
/// Cadence UFix64 preserves 8 decimal places, so tokens with more than 8
/// decimals are truncated toward zero when entering Cadence. Any remainder
/// smaller than 0.00000001 token is lost and cannot be recovered later.
/// decimals are truncated toward zero when entering Cadence. For CREATE and
/// DEPOSIT flows, any discarded remainder must be reconciled on the EVM side.
/// @param value The amount in wei/smallest unit (UInt256)
/// @param tokenAddress The token address to determine decimal conversion
/// @return The converted amount in UFix64 format
/// @return The converted amount in UFix64 format (truncated to 8 decimals)
access(self) fun ufix64FromUInt256(_ value: UInt256, tokenAddress: EVM.EVMAddress): UFix64 {
if tokenAddress.toString() == FlowYieldVaultsEVM.nativeFlowEVMAddress.toString() {
return FlowEVMBridgeUtils.uint256ToUFix64(value: value, decimals: 18)
}
return FlowEVMBridgeUtils.convertERC20AmountToCadenceAmount(value, erc20Address: tokenAddress)
}

/// @notice Computes the exact EVM-side amount that survives a round trip through Cadence precision
/// @dev This floors the amount to the nearest quantity representable by Cadence UFix64.
/// @param value The requested amount in wei/smallest unit
/// @param tokenAddress The token address to determine decimal conversion
/// @return The EVM-side amount that can be processed in Cadence without losing precision
access(self) fun exactCadenceRepresentableAmount(_ value: UInt256, tokenAddress: EVM.EVMAddress): UInt256 {
let cadenceAmount = FlowYieldVaultsEVM.ufix64FromUInt256(value, tokenAddress: tokenAddress)
return FlowYieldVaultsEVM.uint256FromUFix64(cadenceAmount, tokenAddress: tokenAddress)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Risk: divergence between Cadence and Solidity precision residual computations

exactCadenceRepresentableAmount uses FlowEVMBridgeUtils (Cadence on-chain library) to round-trip the value. The Solidity mirror, _expectedPrecisionResidual, uses hardcoded arithmetic:

// native FLOW
return amount % 1e10;
// ERC20
uint8 decimals = IERC20Metadata(tokenAddress).decimals();
uint256 quantum = 10 ** (decimals - 8);
return amount % quantum;

If these two code paths ever return different values for the same input — e.g. because FlowEVMBridgeUtils.convertERC20AmountToCadenceAmount uses a different decimal source than IERC20Metadata.decimals() for some token — then completeProcessing will always revert with InvalidRefundAmount, permanently bricking every request for that token.

Recommendations:

  1. Add a test that cross-checks the two computations for at least one ERC20 with 18 decimals and one with 6 decimals.
  2. Consider adding a view function to the Solidity contract that exposes the expected residual so the Cadence worker can read it on-chain rather than recomputing it independently.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cross-chain residual invariant: implicit coupling between Cadence and Solidity arithmetic

exactCadenceRepresentableAmount computes the residual via a FlowEVMBridgeUtils round-trip, while Solidity's _expectedPrecisionResidual computes it independently with amount % (10 ** (decimals - 8)). completeProcessing() now rejects any call where these two values differ by even 1 wei with InvalidRefundAmount, permanently bricking the request in PROCESSING state.

For native FLOW both sides reduce to amount % 1e10, which is provably identical. For ERC20, the equivalence depends on FlowEVMBridgeUtils.convertERC20AmountToCadenceAmount / convertCadenceAmountToERC20Amount implementing exact floor division by 10^(decimals-8) — the same arithmetic Solidity uses. If those utils ever round instead of truncate, or use a different decimal lookup, the two computations diverge and all ERC20 requests with a non-zero residual are permanently stuck.

The integration test only exercises MOET (18 decimals, same math as native FLOW). Consider adding a test case with an ERC20 of non-18 decimals (e.g. 6 or 17) to confirm the round-trip produces the same residual as the Solidity formula, or add an explicit comment cross-referencing the bridge utils contract version this was validated against.

}

/// @notice Computes the refund amount that must be returned on EVM completion
/// @dev Failed CREATE/DEPOSIT requests refund the full amount. Successful CREATE/DEPOSIT
/// requests refund only the precision residual that could not be represented in Cadence.
/// @param request The request being completed
/// @param success Whether the Cadence operation succeeded
/// @return Amount to credit to claimable refunds on the Solidity side
access(self) fun refundAmountForCompletion(request: EVMRequest, success: Bool): UInt256 {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No Cadence unit tests for refundAmountForCompletion

This function is the critical path that determines how much is sent to completeProcessing for every request completion. It has three distinct branches (non-escrowed, failed escrowed, successful escrowed with residual) and the ERC20 residual branch adds a round-trip through exactCadenceRepresentableAmount.

The new behaviour is covered by integration tests (run_worker_tests.sh) and Solidity unit tests, but there are no Cadence-level unit tests in cadence/tests/. Given that a wrong return value here either strands funds in the COA (residual not refunded) or bricks requests (wrong refund causes InvalidRefundAmount revert on the Solidity side), at minimum error_handling_test.cdc and evm_bridge_lifecycle_test.cdc should exercise the residual paths.

let isEscrowedRequest =
request.requestType == FlowYieldVaultsEVM.RequestType.CREATE_YIELDVAULT.rawValue
|| request.requestType == FlowYieldVaultsEVM.RequestType.DEPOSIT_TO_YIELDVAULT.rawValue

if !isEscrowedRequest {
return 0
}

if !success {
return request.amount
}

let exactProcessableAmount = FlowYieldVaultsEVM.exactCadenceRepresentableAmount(
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ERC20 residual: round-trip through FlowEVMBridgeUtils must match Solidity's amount % 10^(decimals-8)

exactCadenceRepresentableAmount performs convertERC20AmountToCadenceAmountconvertCadenceAmountToERC20Amount, which is the Cadence-side equivalent of amount - (amount % 10^(decimals-8)). The Solidity side independently reimplements the same formula in _expectedPrecisionResidual using a live call to IERC20Metadata.decimals().

These two code paths must produce identical residuals or completeProcessing will revert with InvalidRefundAmount for every ERC20 CREATE/DEPOSIT success completion. The test suite verifies the Solidity half in isolation (test_CompleteProcessing_SuccessCreditsERC20ResidualRefund) but there is no Cadence-level or worker-level integration test for ERC20 dust amounts. An integration test (Cadence or shell worker test) that sends amount = N * 10^(decimals-8) + dust through a full lifecycle would pin this cross-system invariant.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

exactCadenceRepresentableAmount double-converts through FlowEVMBridgeUtils — decimal mismatch risk

The round-trip ufix64FromUInt256 → uint256FromUFix64 relies on FlowEVMBridgeUtils.convertERC20AmountToCadenceAmount / convertCadenceAmountToERC20Amount to determine how many decimal places the token has. The Solidity side uses IERC20Metadata(tokenAddress).decimals() for the same purpose.

These two sources of truth can diverge:

  • If the FlowEVMBridge registry stores a decimal count that differs from what the ERC20 token's decimals() returns (e.g., after a token upgrade or initial mis-registration), the residuals computed by refundAmountForCompletion (Cadence) and _expectedPrecisionResidual (Solidity) will differ, causing completeProcessing to revert on Solidity.

Consider adding an assertion here (or in tests) that the bridge-registry decimal count equals the ERC20 decimals() result for every configured token, and document that this invariant must hold for the precision accounting to work correctly.

request.amount,
tokenAddress: request.tokenAddress
)

if request.amount > exactProcessableAmount {
return request.amount - exactProcessableAmount
}

return 0
}

/// @notice Converts a UFix64 amount from Cadence to UInt256 for EVM
/// @dev For native FLOW: Uses 18 decimals (FLOW to attoflow conversion)
/// For ERC20: Uses FlowEVMBridgeUtils to look up token decimals.
Expand Down
Loading
Loading