-
Notifications
You must be signed in to change notification settings - Fork 1
fix(FLOW-16): reconcile cross-VM refund precision and harden worker recovery #37
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
36216ee
c011746
753a2c8
8bbe2b4
b201c5c
39e46f2
b75642f
638b978
1f6654d
0780069
5c10765
554366d
4ba2ba4
3fd967c
26185f7
b033b3f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 { | ||||||||||
|
|
||||||||||
| // ============================================ | ||||||||||
|
|
@@ -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 { | ||||||||||
|
|
@@ -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 | ||||||||||
| ) { | ||||||||||
|
|
@@ -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 | ||||||||||
|
|
@@ -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, | ||||||||||
| ) | ||||||||||
|
|
@@ -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 | ||||||||||
|
|
@@ -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)", | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Breaking ABI change — needs coordinated testnet re-deployment The Solidity function signature changed from However, any previously deployed testnet instance of |
||||||||||
| [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 | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Redundant
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 |
||||||||||
| && (requestType == FlowYieldVaultsEVM.RequestType.CREATE_YIELDVAULT.rawValue | ||||||||||
| || requestType == FlowYieldVaultsEVM.RequestType.DEPOSIT_TO_YIELDVAULT.rawValue) | ||||||||||
|
Comment on lines
+1059
to
1061
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Redundant
Simplify to:
Suggested change
|
||||||||||
|
|
||||||||||
|
|
@@ -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)) | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The 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 amountBecause if (msg.value != request.amount) revert MsgValueMustEqualAmount();…any The new One gap: the Solidity tests added in this PR call |
||||||||||
| } else { | ||||||||||
| // ERC20: approve contract to pull funds | ||||||||||
| let approveCalldata = EVM.encodeABIWithSignature( | ||||||||||
|
|
@@ -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) | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Risk: divergence between Cadence and Solidity precision residual computations
// 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 Recommendations:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cross-chain residual invariant: implicit coupling between Cadence and Solidity arithmetic
For native FLOW both sides reduce to 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 { | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No Cadence unit tests for This function is the critical path that determines how much is sent to The new behaviour is covered by integration tests ( |
||||||||||
| 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( | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ERC20 residual: round-trip through
These two code paths must produce identical residuals or There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The round-trip These two sources of truth can diverge:
Consider adding an assertion here (or in tests) that the bridge-registry decimal count equals the ERC20 |
||||||||||
| 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. | ||||||||||
|
|
||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug (regression):
markRequestAsFailedsends the wrongrefundAmountfor WITHDRAW requestsSwitching from the 4-arg to the 5-arg ABI here is correct for
processRequest, butmarkRequestAsFailed(line 679) still passesrefundAmount: request.amountunconditionally and is not updated in this PR.Before this PR, the 4-arg Solidity overload silently ignored
refundAmountfor non-escrowed types. Now the 5-arg Solidity overload calls_completeProcessingwhich strictly validates:For a
WITHDRAW_FROM_YIELDVAULTrequest with a non-zeroamount:isEscrowedRequest = false→ expected = 0request.amount→ revertmarkRequestAsFailedreturnsfalse→ request permanently stuck inPROCESSINGThis 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: