Skip to content

fix(FLOW-16): reconcile cross-VM refund precision and harden worker recovery#37

Merged
liobrasil merged 16 commits into
mainfrom
fix/FLOW-16-precision-loss-documentation
Apr 7, 2026
Merged

fix(FLOW-16): reconcile cross-VM refund precision and harden worker recovery#37
liobrasil merged 16 commits into
mainfrom
fix/FLOW-16-precision-loss-documentation

Conversation

@liobrasil
Copy link
Copy Markdown
Contributor

@liobrasil liobrasil commented Jan 19, 2026

Summary

Fixes #31.

This PR fixes cross-VM refund precision for CREATE/DEPOSIT completion and hardens worker recovery for failed requests.

It preserves exact native FLOW refunds on failed completion, credits any CREATE/DEPOSIT precision residual that Cadence cannot represent back to claimableRefunds, and fixes failed non-escrowed recovery so requests are not orphaned in PROCESSING.

What Changed

  • Preserve exact native FLOW refund value when Cadence completes failed requests.
  • Validate an explicit refundAmount during Solidity completion.
  • Refund the full amount for failed CREATE/DEPOSIT requests.
  • Refund the precision residual for successful CREATE/DEPOSIT requests when the original amount cannot be represented exactly in Cadence.
  • Remove the legacy 4-arg completeProcessing path so completion uses a single explicit ABI.
  • Fix failed WITHDRAW recovery so worker cleanup does not drop tracked requests when finalization fails.
  • Add regression coverage for native dust refunds, ERC20 dust refunds, failed refund claims, and failed withdraw recovery.

Scope

  • Changes CREATE/DEPOSIT completion accounting.
  • Fixes worker recovery behavior for failed non-escrowed requests.
  • Adds end-to-end native and ERC20 refund regression coverage.

Test Plan

  • cd solidity && forge test
  • ./local/run_worker_tests.sh

EVM uses uint256 with 18 decimals while Cadence uses UFix64 with 8 decimals.
Converting between formats truncates precision beyond 8 decimal places.
This is not exploitable (truncation favors protocol) and minimum deposit
mitigates dust loss.

Added precision warnings to:
- Cadence contract header
- Cadence ufix64FromUInt256() function docstring
- Solidity contract header

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude Bot commented Jan 28, 2026

PR #37 Review — Precision Residual Refund

This PR introduces a well-motivated fix: the old completeProcessing path rounded the native-FLOW refund through UFix64 (losing sub-8-decimal precision), so precision residuals were silently swallowed instead of returned to the user. The new path sends the original attoflow value directly via EVM.Balance(attoflow: UInt(refundAmount)) and adds a symmetric refundAmount parameter with strict validation on both sides.

The design intent is sound. Three concrete issues are worth addressing before merging.


1. No escape hatch for a stuck PROCESSING request (medium)

_completeProcessing now strictly requires refundAmount == _expectedPrecisionResidual(...) for the success path. If the Cadence and Solidity residual computations ever diverge by even 1 wei — due to a decimal mismatch between the FlowEVMBridge registry and IERC20Metadata.decimals(), or a future contract upgrade — completeProcessing will revert for that specific request every time. The request stays in PROCESSING permanently: users cannot cancel it, dropRequests only touches PENDING, and the COA has no retry mechanism for the success path.

The failure-path check (refundAmount == request.amount) has the same structure but is less dangerous because a full refund is returned to escrow. The success-path check has no recovery path.

Suggested fixes: (a) add an admin rescueProcessingRequest that force-fails a stuck PROCESSING request with a full refund, or (b) loosen the success-path check to refundAmount <= expectedSuccessRefundAmount (the COA is already trusted; an under-refund by a few wei is far safer than permanent fund lock).

→ Inline comment at FlowYieldVaultsRequests.sol:1072


2. IERC20Metadata.decimals() called on the completeProcessing hot path (medium)

_expectedPrecisionResidual makes a new external call (IERC20Metadata(tokenAddress).decimals()) inside completeProcessing, which executes on every CREATE/DEPOSIT completion. Tokens that implement IERC20 but not IERC20Metadata.decimals() will cause this to revert, permanently locking that request in PROCESSING (same no-escape risk as above).

Recommended fix: cache decimals in TokenConfig at setTokenConfig time and read from storage here. This eliminates the external call on every completion and removes the dependency on runtime token behaviour.

→ Inline comment at FlowYieldVaultsRequests.sol:1772


3. Cadence and Solidity residual computations share a hidden invariant (low)

refundAmountForCompletion (Cadence) uses FlowEVMBridgeUtils.convertERC20AmountToCadenceAmount/convertCadenceAmountToERC20Amount to round-trip the amount; _expectedPrecisionResidual (Solidity) uses IERC20Metadata(token).decimals(). Both must agree on the token's decimal count for the strict equality to hold. This invariant is not documented and is not enforced by any test or assertion.

Recommend documenting it explicitly and adding an integration test that verifies residual agreement for every configured token.

→ Inline comment at FlowYieldVaultsEVM.cdc:2038


No issues found

  • Request-type enum values are in sync between Solidity and Cadence (0=CREATE, 1=DEPOSIT, 2=WITHDRAW, 3=CLOSE).
  • Refund accounting invariants (pendingUserBalances, claimableRefunds, totalAccountedBalance) are consistent across cancel/drop/complete paths.
  • The native-FLOW direct EVM.Balance(attoflow: UInt(refundAmount)) fix correctly avoids the UFix64 truncation that was the root cause.
  • yieldVaultOwnershipLookup / userOwnsYieldVault parallel tracking is consistent.
  • Sentinel values (NATIVE_FLOW, NO_YIELDVAULT_ID) are correctly guarded.
  • The depositToYieldVault intentional no-ownership-check design is confirmed by test test_DepositToYieldVault_AnyoneCanDeposit and Cadence inline comment.

@vishalchangrani vishalchangrani requested a review from a team February 18, 2026 19:09
@liobrasil liobrasil marked this pull request as draft February 21, 2026 00:13
@liobrasil liobrasil marked this pull request as ready for review February 23, 2026 23:22
/// EVM uses uint256 with 18 decimals (wei), while Cadence uses UFix64 with 8 decimals.
/// Converting between these formats truncates precision beyond 8 decimal places.
/// For example: 1.123456789012345678 FLOW (EVM) becomes 1.12345678 FLOW (Cadence).
/// This is not exploitable (users receive slightly less, not more) and the 1 FLOW
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Incomplete analysis — precision truncation IS a liveness bug in the refund path

The note correctly describes forward-direction truncation (deposit → Cadence), but misses the critical reverse case: when a native FLOW CREATE/DEPOSIT request fails, completeProcessing (lines 1056–1060) converts refundAmount (UInt256, 18 dec) → UFix64 (8 dec, truncated) → EVM.Balance via setFLOW. The resulting msg.value is smaller than the original request.amount.

Solidity then reverts with MsgValueMustEqualAmount (msg.value != request.amount, exact match). This causes completeProcessing to return falseprocessRequest panics → the WorkerHandler transaction reverts. Because the PROCESSING status was committed in a prior startProcessingBatch transaction, it is not reverted. When the SchedulerHandler detects the panic and calls markRequestAsFailed, it hits the same truncation, the same Solidity revert, and the same false return — after which it removes the request from scheduledRequests with no retry (line 674 of WorkerOps, comment: "errors are not considered transient"). The request is permanently stuck in PROCESSING with user funds in the COA.

The statement "users receive slightly less, not more" is true for withdrawals/close, but for the refund-on-failure path the protocol currently cannot return even the truncated amount because Solidity demands the exact original amount.

Affected amounts: any wei value where amount % 10^10 != 0 (e.g. any amount with non-zero attoflow below 10 gwei). Amounts that are exact multiples of 10^10 wei (1 FLOW, 1.5 FLOW, etc.) are unaffected.

* PRECISION NOTE: EVM uses uint256 with 18 decimals, while Cadence uses UFix64 with 8 decimals.
* Amounts are truncated beyond 8 decimal places during cross-VM conversion.
* Example: 1.123456789012345678 FLOW → 1.12345678 FLOW (loss of ~9e-9 FLOW).
* This is not exploitable (truncation favors the protocol) and the minimum deposit mitigates dust.
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 "not exploitable" claim is incomplete — see the refund path

The forward direction (deposit → Cadence) is indeed safe: users put in more than they get, so the protocol is not at risk. But completeProcessing in Solidity (line 1001) requires an exact match: if (msg.value != request.amount) revert MsgValueMustEqualAmount().

When a native FLOW request fails, the Cadence worker reconstructs the refund amount via UFix64, losing the same sub-8-decimal precision. The resulting msg.value is less than request.amount, so Solidity reverts. The Cadence transaction panics, the request stays permanently in PROCESSING on EVM, and there is no admin escape hatch (dropRequests only operates on PENDING). User funds in the COA have no path back.

Fix: Change line 1001 to if (msg.value < request.amount) and return the excess to the COA, or have the Cadence worker bypass the UFix64 conversion when building the refund balance and use the raw attoflow value directly.

@liobrasil liobrasil requested a review from m-Peter March 31, 2026 02:22
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.

assertEq(c.getUserPendingBalance(user, NATIVE_FLOW), 0);
assertEq(c.getClaimableRefund(user, NATIVE_FLOW), amount);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This test correctly verifies that the Solidity contract accepts and credits the full sub-8-decimal amount (1 ether + 123456789).

Note the coverage gap: these tests simulate the COA calling completeProcessing{value: amount} directly in Foundry. They validate the Solidity accounting but don't test the changed Cadence code — specifically that EVM.Balance(attoflow: UInt(refundAmount)) produces the right value and that the resulting EVM call carries the correct msg.value. The bug fix lives entirely in the Cadence contract, so a Cadence-level integration test (in error_handling_test.cdc) is the only way to close that gap.

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

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

Comment thread solidity/src/FlowYieldVaultsRequests.sol
Comment on lines +1055 to 1057
let needsRefund = refundAmount > 0
&& (requestType == FlowYieldVaultsEVM.RequestType.CREATE_YIELDVAULT.rawValue
|| requestType == FlowYieldVaultsEVM.RequestType.DEPOSIT_TO_YIELDVAULT.rawValue)
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

if (precisionLossDecimals > 77) return amount;

uint256 quantum = 10 ** precisionLossDecimals;
return amount % quantum;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

IERC20Metadata.decimals() called without revert guard — breaks success-path finalization for tokens without decimals()

decimals() is in the optional IERC20Metadata extension, not the base ERC-20 spec. If a token passes the allowlist but doesn't implement it, completeProcessing(success=true) will always revert for CREATE/DEPOSIT on that token. The failure is not permanent (crash recovery marks the request FAILED and issues a full refund), but users can never successfully complete a create/deposit for that token. _setTokenConfig has no guard for this.

Suggested fixes:

  1. Call IERC20Metadata(tokenAddress).decimals() in _setTokenConfig to reject tokens at registration time.
  2. Or wrap line 1698 in a try/catch and fall back to 18 decimals (the widely-used default).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It's very rare for a token not to conform to IERC20Metadata and not returning decimals() @claude

Copy link
Copy Markdown

@claude claude Bot Mar 31, 2026

Choose a reason for hiding this comment

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

Claude finished @liobrasil's task in 55s —— View job


  • Read relevant code and analyze the decimals() concern
  • Respond to pushback on rarity of tokens without decimals()

Fair point — tokens that implement ERC-20 but not decimals() are vanishingly rare in practice (virtually every token, including all OZ-based ones, includes it). The concern stands as a theoretical correctness issue rather than a practical one.

The reason I flagged it is that the failure mode is silent-until-too-late: if a non-conforming token ever were allowlisted via setTokenConfig, users could submit CREATE/DEPOSIT requests successfully, but those requests would be permanently uncompletable (because _expectedPrecisionResidual would revert on every completeProcessing call). The crash-recovery path via markRequestAsFailed would still work since it takes the success=false branch which skips _expectedPrecisionResidual.

So practically speaking the impact is bounded — requests would end up in a failed state and users would get refunds — but the user experience would be bad and the root cause non-obvious.

The minimal mitigation is a one-liner in setTokenConfig:

function setTokenConfig(address tokenAddress, bool isSupported, ...) external onlyOwner {
    if (isSupported && !isNative) {
        IERC20Metadata(tokenAddress).decimals(); // revert early if token doesn't implement it
    }
    ...
}

This adds a trivial read at registration time (owner-only, one-time) and makes the invariant explicit: every allowlisted ERC20 must implement decimals(). Given how rare the failure case is I understand if you consider this not worth the noise — just flagging the failure mode so it's a conscious tradeoff rather than a blind spot.


/**
* @dev Registers a new YieldVault with comprehensive ownership tracking.
* Updates multiple mappings to enable O(1) lookups for:
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

decimals() revert risk — requests could get permanently stuck in PROCESSING

IERC20Metadata(tokenAddress).decimals() (a few lines below, at the ERC20 branch) is a new external call added by this PR. If any configured ERC20 token does not implement decimals() — it is optional in the ERC20 spec — every completeProcessing() call for that token will revert here.

Since completeProcessing() is called from the Cadence worker, a revert causes the worker transaction to panic (keeping the request in PROCESSING state). Crash-recovery then calls markRequestAsFailed()completeProcessing() — same revert — effectively a permanently stuck request.

The practical risk is low because FlowEVMBridge-onboarded tokens almost certainly implement decimals(). The correct defence is to validate (or cache) the decimals at setTokenConfig time so a misconfigured token is rejected immediately rather than discovered mid-flight:

// In _setTokenConfig, for non-native tokens:
uint8 tokenDecimals = IERC20Metadata(tokenAddress).decimals(); // reverts early if missing
// store in TokenConfig for later use

Alternatively, add a try/catch (not available for arbitrary external calls in Solidity, so a static call wrapper is needed) or document that tokens without decimals() are unsupported and are rejected at the bridge onboarding step.

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.

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.

return amount % 1e10;
}

uint8 decimals = IERC20Metadata(tokenAddress).decimals();
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: two independent implementations must stay in sync

_expectedPrecisionResidual computes the residual using a live IERC20Metadata(tokenAddress).decimals() call and amount % 10^(decimals-8).

The Cadence side independently produces the same value via FlowEVMBridgeUtils.convertERC20AmountToCadenceAmountconvertCadenceAmountToERC20Amount round-trip (exactCadenceRepresentableAmount). If those utilities use a different decimal source or rounding mode, the InvalidRefundAmount check will always revert on ERC20 CREATE/DEPOSIT success completions, permanently stranding those requests in PROCESSING.

The new worker-test SCENARIO 4 covers only native FLOW dust; there is no end-to-end test that exercises an ERC20 token with sub-8-decimal-precision amounts through the full Cadence → EVM completion path to confirm the two residual calculations agree.

@liobrasil liobrasil requested review from jordanschalm and nvdtf March 31, 2026 05:39
requestId: scheduledRequestId,
workerTransactionId: request.workerTransactionId,
)
continue
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Good fix — continue was genuinely missing here

Before this PR, the StopAllMarkFailedSkipped path fell through and unconditionally executed scheduledRequests.remove(key: scheduledRequestId) on line 339. That meant a request could be dropped from tracking even when markRequestAsFailed returned false — leaving it stuck in PROCESSING with no recovery. The continue correctly keeps the request in scheduledRequests so the next scheduler run can retry finalisation.

FlowYieldVaultsEVMWorkerOps.scheduledRequests.remove(key: requestId)
// Keep the request tracked if EVM finalization fails so crash recovery can retry later.
if success {
FlowYieldVaultsEVMWorkerOps.scheduledRequests.remove(key: requestId)
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 retry limit — could leave requests stuck indefinitely

If markRequestAsFailed returns false (e.g., COA has insufficient native FLOW to send the refund, or the EVM call reverts for any persistent reason), the request stays in scheduledRequests and will be retried on every subsequent scheduler run with no back-off, counter, or escalation path beyond the WorkerHandlerPanicDetected event.

In practice the most plausible trigger is an underfunded COA: if the COA has zero balance it can never satisfy the msg.value == refundAmount check on the Solidity side for a native-FLOW failed CREATE/DEPOSIT, so the request is permanently unfinalisable.

Worth either: (a) documenting the invariant that the COA must maintain a reserve sufficient to cover refunds, or (b) adding a max-retry counter after which the request is dropped from scheduledRequests and an operator alert is emitted.

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.

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.

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

return amount % 1e10;
}

uint8 decimals = IERC20Metadata(tokenAddress).decimals();
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 strict InvalidRefundAmount validation creates a hard coupling between this live decimals() call and whatever decimal count Cadence's FlowEVMBridgeUtils.convertERC20AmountToCadenceAmount used when computing the residual. If the two sides ever disagree by even 1 token-unit — e.g., because the EVM bridge was configured with a decimal count different from what the token contract reports today — every successful completeProcessing call for that token will revert with InvalidRefundAmount.

The failure recovery path (success: false, refundAmount: request.amount) bypasses the precision computation entirely, so users still get a full refund; there is no permanent fund lock. However, every deposit/create attempt for the mismatched token would auto-fail, which would be a silent systematic outage for that token.

Worth adding a note in setTokenConfig or in admin docs that the token's on-chain decimals() must match the decimal count used in the Cadence bridge config for that token, or residual validation will always reject successful completions.

if (decimals <= 8) return 0;

uint8 precisionLossDecimals = decimals - 8;
if (precisionLossDecimals > 77) return amount;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Guard against overflow in 10 ** precisionLossDecimals is correct (10^77 < 2^256 < 10^78), but the behavior when precisionLossDecimals > 77 (i.e., decimals > 85) is to treat the entire amount as un-representable and return amount as the residual. That means the Cadence side must also compute exactProcessableAmount = 0 (bridging nothing), otherwise refundAmountForCompletion on the Cadence side returns amount and _expectedPrecisionResidual on the Solidity side returns amount — they agree, so the call proceeds.

This is an edge-case path that no real-world token would hit (no token has 85+ decimals), but it's worth a short comment explaining the invariant: "for decimals > 85, the entire amount is residual because no value is representable in UFix64."

FlowYieldVaultsRequests.InvalidRefundAmount.selector,
uint256(0),
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.

Two coverage gaps for the new InvalidRefundAmount validation that are worth adding here:

  1. Failed WITHDRAW/CLOSE with refundAmount != 0_completeProcessing requires refundAmount == 0 for non-escrowed failed requests; currently there is no test confirming InvalidRefundAmount is thrown when the COA accidentally passes a non-zero value.

  2. Successful CREATE/DEPOSIT with a ≤8-decimal token (e.g., 6-decimal USDC)_expectedPrecisionResidual returns 0 for such tokens, so completeProcessing must be called with refundAmount == 0. A test using TestERC20 with tokenDecimals_ = 6 would confirm the contract accepts refundAmount = 0 for a clean amount and rejects any non-zero refundAmount.

Copy link
Copy Markdown
Collaborator

@m-Peter m-Peter left a comment

Choose a reason for hiding this comment

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

Nice!!!

vm.prank(user);
c.claimRefund(address(token));
assertEq(token.balanceOf(user), balanceBefore + residual);
assertEq(c.totalAccountedBalance(address(token)), 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.

Missing test: ERC20 failed CREATE/DEPOSIT with a dusty amount.

test_CompleteProcessing_FailureRefundsExactCreateAmountWithDust and the DEPOSIT equivalent cover native FLOW, and test_CompleteProcessing_RefundERC20Tokens covers ERC20 failure but uses a clean 5 ether (no sub-8-decimal remainder). There is no test that exercises the branch where !success && isEscrowedRequest with an ERC20 token whose amount contains a low-precision residual — i.e., the COA must return the full amount + dust, not just the _expectedPrecisionResidual.

Suggested addition:

function test_CompleteProcessing_FailureRefundsExactERC20AmountWithDust() public {
    TestERC20 token = new TestERC20("Mock Token", "MOCK", 18);
    uint256 residual = 123456789;
    uint256 amount = 1 ether + residual;

    vm.prank(c.owner());
    c.setTokenConfig(address(token), true, 1 ether, false);

    token.mint(user, amount);
    vm.startPrank(user);
    token.approve(address(c), amount);
    uint256 reqId = c.createYieldVault(address(token), amount, VAULT_ID, STRATEGY_ID);
    vm.stopPrank();

    vm.startPrank(coa);
    _startProcessingBatch(reqId);
    token.approve(address(c), amount);
    c.completeProcessing(reqId, false, c.NO_YIELDVAULT_ID(), "Cadence error", amount);
    vm.stopPrank();

    assertEq(c.getClaimableRefund(user, address(token)), amount);
    assertEq(c.totalAccountedBalance(address(token)), amount);
}


/// @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
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 exist for exactCadenceRepresentableAmount or refundAmountForCompletion. The integration path in run_worker_tests.sh covers the happy path with a 18-decimal native FLOW and a MOET ERC20 token, but the following cases are not tested at the unit level:

  • Token with ≤ 8 decimals (e.g. USDC at 6 decimal) — should return 0 residual.
  • Amount that is already exactly quantum-aligned — should return 0 residual, and a success-path completeProcessing with refundAmount = 0 must not revert.
  • Token with decimals > 85 (the precisionLossDecimals > 77 guard on the Solidity side, where the entire amount is residual).
  • WITHDRAW_FROM_YIELDVAULT / CLOSE_YIELDVAULT requests — refundAmountForCompletion must always return 0 regardless of success/failure.

Adding a Cadence test file (e.g. cadence/tests/precision_residual_test.cdc) that covers these cases would protect against silent regressions when FlowEVMBridgeUtils conversion behaviour changes.

requestId: scheduledRequestId,
workerTransactionId: request.workerTransactionId,
)
continue
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 continue fix is correct, but the failure path it protects is not regression-tested. Scenario 7 only exercises the path where markRequestAsFailed returns true (EVM completeProcessing succeeds). There is no test that forces completeProcessing to revert inside stopAll() and then verifies:

  1. The StopAllMarkFailedSkipped event is emitted.
  2. The request remains in scheduledRequests (not dropped).
  3. A subsequent scheduler run retries finalization and succeeds.

Without this test, a future refactor that accidentally removes the continue would not be caught. An integration-level test that stubs or temporarily breaks the EVM contract during stopAll() and then restores it would be the most direct regression coverage.

Comment thread local/run_worker_tests.sh Outdated
assert_tx_success "$MOET_CONFIG_OUTPUT" "MOET token config applied"

log_test "Approve MOET residual refunds from COA"
MOET_COA_APPROVAL_OUTPUT=$(approve_erc20_from_coa "$MOET_EVM_ADDRESS" "$FLOW_VAULTS_REQUESTS_CONTRACT" "123456789")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This pre-approval is superfluous. When the Cadence worker processes a MOET request with an ERC20 residual, completeProcessing (Cadence) issues its own approve(evmContractAddress, refundAmount) call right before the EVM completeProcessing transaction — which replaces this allowance. The 123456789 set here has no effect on the actual test outcome.

Keeping it creates two problems:

  1. It misleads future contributors into thinking a global pre-approval is required for ERC20 residual refunds to work.
  2. When DEPOSIT_DUST_RESIDUAL = 987654321 is later used, the mismatch (987654321 > 123456789) looks like a bug even though it is harmless.

Remove this block, or add a comment explaining that it is intentionally a no-op (and why it is kept).

);
c.completeProcessing{value: refundAmount}(reqId, true, 100, "YieldVault created", refundAmount);
vm.stopPrank();
}
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 two InvalidRefundAmount revert tests only exercise CREATE requests. There's no test verifying that a non-zero refundAmount is rejected for WITHDRAW or CLOSE completions.

Both request types go through the same validation path (isEscrowedRequest = falseexpectedRefund = 0), but the branch is never exercised in the test suite. If the isEscrowedRequest check were accidentally widened, nothing would catch it.

Suggested additions:

function test_CompleteProcessing_RevertNonZeroRefundForWithdraw() public {
    vm.prank(user);
    uint256 reqId = c.withdrawFromYieldVault(42, 1 ether);

    vm.startPrank(coa);
    _startProcessingBatch(reqId);
    vm.expectRevert(
        abi.encodeWithSelector(
            FlowYieldVaultsRequests.InvalidRefundAmount.selector,
            uint256(0),
            uint256(1)
        )
    );
    c.completeProcessing(reqId, true, 42, "Withdrawn", 1);
    vm.stopPrank();
}

function test_CompleteProcessing_RevertNonZeroRefundForClose() public {
    vm.prank(user);
    uint256 reqId = c.closeYieldVault(42);

    vm.startPrank(coa);
    _startProcessingBatch(reqId);
    vm.expectRevert(
        abi.encodeWithSelector(
            FlowYieldVaultsRequests.InvalidRefundAmount.selector,
            uint256(0),
            uint256(1)
        )
    );
    c.completeProcessing(reqId, true, 42, "Closed", 1);
    vm.stopPrank();
}

prepare(signer: auth(IssueStorageCapabilityController) &Account) {
self.schedulerHandlerCap = signer.capabilities.storage
.issue<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>(
FlowYieldVaultsEVMWorkerOps.SchedulerHandlerStoragePath
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue creates a new unrevoked capability controller on every invocation. Running this transaction repeatedly (as happens across test scenarios) accumulates orphaned controllers on the account.

Consider borrowing the existing controller if one exists, or revoking it before issuing a new one. A common pattern is:

let existing = signer.capabilities.storage.getControllers(forPath: FlowYieldVaultsEVMWorkerOps.SchedulerHandlerStoragePath)
if existing.length > 0 {
    self.schedulerHandlerCap = existing[0].capability as! Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>
} else {
    self.schedulerHandlerCap = signer.capabilities.storage.issue<...>(...)
}

For a test-only helper this won't cause test failures, but it's an antipattern that will make debugging account state harder over time.

Comment thread local/run_worker_tests.sh Outdated
assert_tx_success "$MOET_CONFIG_OUTPUT" "MOET token config applied"

log_test "Approve MOET residual refunds from COA"
MOET_COA_APPROVAL_OUTPUT=$(approve_erc20_from_coa "$MOET_EVM_ADDRESS" "$FLOW_VAULTS_REQUESTS_CONTRACT" "123456789")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This static pre-approval ("123456789") is redundant. The Cadence worker already calls approve(refundAmount) inline inside completeProcessing (see FlowYieldVaultsEVM.cdc ~line 1075), so the COA approves the exact precision residual as part of the same atomic transaction.

The pre-approval happens to match MOET_CREATE_DUST_RESIDUAL, so the test passes, but it could silently mask a broken inline approval path and would fail for any residual amount other than 123456789.

Consider removing this pre-approval step so the test exercises the inline approval path end-to-end.

return amount % 1e10;
}

uint8 decimals = IERC20Metadata(tokenAddress).decimals();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

decimals() revert will permanently trap successful ERC20 completions

IERC20Metadata(tokenAddress).decimals() is called inside completeProcessing. If a configured ERC20 token doesn't implement IERC20Metadata (the method is optional per EIP-20), this call reverts and every successful CREATE/DEPOSIT completion for that token will fail permanently, trapping those requests in PROCESSING state.

Notably the failure path (!success branch) never calls decimals(), so failures can be finalized but successes cannot — a deeply asymmetric stuck state with no clear recovery path.

Fix: validate decimals() is callable in setTokenConfig for non-native tokens:

Suggested change
uint8 decimals = IERC20Metadata(tokenAddress).decimals();
uint8 decimals;
try IERC20Metadata(tokenAddress).decimals() returns (uint8 d) {
decimals = d;
} catch {
return 0; // treat as 0-residual (≤8 decimal behaviour)
}

Or, more robustly, gate setTokenConfig on a successful decimals() call so the contract never holds funds it cannot finalize.

request.amount
)
: 0;
if (refundAmount != expectedSuccessRefundAmount) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Strict equality validation creates an unrecoverable stuck state if Cadence/Solidity residuals diverge

Both sides independently compute the residual: Solidity uses amount % 10^(decimals-8) (via _expectedPrecisionResidual), while Cadence uses FlowEVMBridgeUtils.convertERC20AmountToCadenceAmount + inverse round-trip.

For native FLOW the math is provably identical. For ERC20 tokens, the Cadence bridge utility looks up decimals from the ERC20 on-chain at bridge time; if that lookup ever returns a value differing from IERC20Metadata(tokenAddress).decimals() called here (e.g., a proxy token whose decimals() returns different values depending on caller context, or a future upgrade), the two sides compute different residuals.

Because the check is != (exact match), any single-wei discrepancy causes completeProcessing to revert on every call, leaving the request permanently stuck in PROCESSING. There is currently no admin override for a PROCESSING request that can never complete.

Consider documenting the invariant explicitly and adding an admin forceFailProcessing escape hatch for the stuck-PROCESSING recovery scenario.

uint256(1)
)
);
c.completeProcessing(reqId, true, 42, "Withdrawn", 1);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Test gap: success: false, refundAmount > 0 on WITHDRAW/CLOSE is untested

This test only covers success: true. The !success branch for a non-escrowed request type follows different code (expectedFailedRefundAmount = 0) and is not covered by any test. Add a variant:

// should also revert: failed WITHDRAW with a non-zero refundAmount
c.completeProcessing(reqId, false, 42, "Failed", 1);

// Keep the request tracked if EVM finalization fails so crash recovery can retry later.
// Retries are currently unbounded until the underlying issue is resolved.
if success {
FlowYieldVaultsEVMWorkerOps.scheduledRequests.remove(key: requestId)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unbounded retry burns scheduler capacity on every run

When markRequestFailed returns false (EVM call fails), the request stays in scheduledRequests indefinitely. On every subsequent scheduler run the same entry is re-checked, the EVM call fails again, and the slot is consumed but nothing progresses. With multiple such entries the effective scheduler capacity shrinks permanently.

The comment acknowledges this is unbounded. Consider adding a retry counter to ScheduledRequest and after N failures emitting a distinct MarkFailedExhausted event (or switching to a separate "needs-admin-review" set), so monitoring can surface the stuck entry without indefinitely degrading throughput.

request.amount
)
: 0;
if (refundAmount != expectedSuccessRefundAmount) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Strict equality on precision residual can permanently lock funds

If the Cadence refundAmountForCompletion and the Solidity _expectedPrecisionResidual ever diverge by even 1 wei for a given request, this revert InvalidRefundAmount fires and there is no recovery path — the request remains stuck in PROCESSING forever (cannot be cancelled, cannot be retried by the COA, cannot be dropped by admin because dropRequests only touches PENDING). The user's escrowed funds would be permanently unrecoverable.

Divergence scenarios:

  • FlowEVMBridgeUtils.convertERC20AmountToCadenceAmount on the Cadence side uses the bridge's internal decimal registry; IERC20Metadata(token).decimals() on the Solidity side reads directly from the token. If the bridge registry and the token's own decimals() differ (e.g., after a token upgrade or mis-registration), the two residuals will not agree.
  • A bug introduced in either computation path during a contract upgrade.

Suggested mitigations:

  1. Add an admin rescueProcessingRequest(uint256 requestId) that can forcibly move a stuck PROCESSING request to FAILED with a full refund — a last-resort escape hatch.
  2. Or relax the success-path check to refundAmount <= expectedSuccessRefundAmount (the COA is already trusted; over-refunding by a few wei is safer than locking funds).

The failure-path strict check (refundAmount == request.amount) does not have the same lock-in risk because dropRequests + a re-deposited COA balance can handle failures, but the success-path check has no such escape.

return amount % 1e10;
}

uint8 decimals = IERC20Metadata(tokenAddress).decimals();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

IERC20Metadata.decimals() call on the hot path: reverts if token lacks it

_expectedPrecisionResidual is called inside the completeProcessing hot path (both success and failure branches for CREATE/DEPOSIT). For ERC20 tokens, it calls IERC20Metadata(tokenAddress).decimals() — a new external call not present before this PR.

If a supported token does not implement decimals() (e.g., certain non-standard ERC20s), this call reverts. The result: every completeProcessing for that token will revert, permanently locking the request in PROCESSING state (same no-escape scenario as the strict-equality issue above).

The fix is straightforward — cache the decimal value in TokenConfig when the token is registered (setTokenConfig):

struct TokenConfig {
    bool isSupported;
    uint256 minimumBalance;
    bool isNative;
    uint8 decimals;   // cached at registration time
}

Then read from storage here instead of making an external call. This also removes the gas cost of an external call on every completeProcessing invocation.

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.

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.

@liobrasil liobrasil merged commit 73406ae into main Apr 7, 2026
11 checks passed
@liobrasil liobrasil deleted the fix/FLOW-16-precision-loss-documentation branch April 7, 2026 22:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FLOW-16: Precision loss in cross-VM amount conversions

3 participants