diff --git a/CLAUDE.md b/CLAUDE.md index ce495a9..f2b1937 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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: diff --git a/FLOW_YIELD_VAULTS_EVM_BRIDGE_DESIGN.md b/FLOW_YIELD_VAULTS_EVM_BRIDGE_DESIGN.md index 3e8d251..3a71298 100644 --- a/FLOW_YIELD_VAULTS_EVM_BRIDGE_DESIGN.md +++ b/FLOW_YIELD_VAULTS_EVM_BRIDGE_DESIGN.md @@ -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; @@ -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 { @@ -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 | @@ -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. --- @@ -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 @@ -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. --- diff --git a/README.md b/README.md index ac6cca7..74dc316 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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) diff --git a/cadence/contracts/FlowYieldVaultsEVM.cdc b/cadence/contracts/FlowYieldVaultsEVM.cdc index bc6b009..fa245dc 100644 --- a/cadence/contracts/FlowYieldVaultsEVM.cdc +++ b/cadence/contracts/FlowYieldVaultsEVM.cdc @@ -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)", + [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 && (requestType == FlowYieldVaultsEVM.RequestType.CREATE_YIELDVAULT.rawValue || requestType == FlowYieldVaultsEVM.RequestType.DEPOSIT_TO_YIELDVAULT.rawValue) @@ -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)) } else { // ERC20: approve contract to pull funds let approveCalldata = EVM.encodeABIWithSignature( @@ -1978,11 +1994,11 @@ 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) @@ -1990,6 +2006,47 @@ access(all) contract FlowYieldVaultsEVM { 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) + } + + /// @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 { + 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( + 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. diff --git a/cadence/contracts/FlowYieldVaultsEVMWorkerOps.cdc b/cadence/contracts/FlowYieldVaultsEVMWorkerOps.cdc index 2ec49ef..97bfe1c 100644 --- a/cadence/contracts/FlowYieldVaultsEVMWorkerOps.cdc +++ b/cadence/contracts/FlowYieldVaultsEVMWorkerOps.cdc @@ -146,10 +146,10 @@ access(all) contract FlowYieldVaultsEVMWorkerOps { nextRunCapacity: UInt8?, ) - /// @notice Emitted when a WorkerHandler has panicked and SchedulerHandler has marked the request as FAILED + /// @notice Emitted when SchedulerHandler detects a failed WorkerHandler execution /// @param status The status of the transaction (Unknown, Scheduled, Executed, Canceled) - /// @param markedAsFailed Whether the request was marked as FAILED - /// @param request The request that was marked as FAILED + /// @param markedAsFailed Whether the request was successfully marked as FAILED on EVM + /// @param request The tracked request being recovered access(all) event WorkerHandlerPanicDetected( status: UInt8?, markedAsFailed: Bool, @@ -171,6 +171,8 @@ access(all) contract FlowYieldVaultsEVMWorkerOps { ) /// @notice Emitted when stopAll() cannot mark a cancelled request as FAILED + /// @dev The request remains tracked in scheduledRequests so later scheduler runs can retry finalization. + /// Retries are currently unbounded until the underlying EVM-side issue is resolved. /// @param requestId EVM request ID that could not be marked as FAILED /// @param workerTransactionId Cancelled WorkerHandler transaction ID access(all) event StopAllMarkFailedSkipped( @@ -332,6 +334,7 @@ access(all) contract FlowYieldVaultsEVMWorkerOps { requestId: scheduledRequestId, workerTransactionId: request.workerTransactionId, ) + continue } FlowYieldVaultsEVMWorkerOps.scheduledRequests.remove(key: scheduledRequestId) @@ -583,7 +586,8 @@ access(all) contract FlowYieldVaultsEVMWorkerOps { /// @notice Main scheduler logic /// @dev Flow: /// 1. Check for failed worker requests - /// - If a failure is identified, mark the request as failed and remove it from scheduledRequests + /// - If a failure is identified, attempt to mark the request as FAILED + /// - Remove it from scheduledRequests only after EVM finalization succeeds /// 2. If fetchCount > 0, fetch pending requests from EVM /// 3. Preprocess requests to drop invalid requests /// 4. Start processing requests (PENDING -> PROCESSING) @@ -631,7 +635,7 @@ access(all) contract FlowYieldVaultsEVMWorkerOps { /// - Only acceptable transaction status is Scheduled (pending execution) /// - No status is considered not acceptable because it means the manager cleaned up the request /// 4. If the transaction status is invalid, mark the request as FAILED providing the transaction ID - /// 5. Remove the request from scheduledRequests + /// 5. Remove the request from scheduledRequests only after it has been marked as FAILED on EVM /// @param manager The scheduler manager /// @param worker The worker capability access(self) fun _checkForFailedWorkerRequests( @@ -669,9 +673,11 @@ access(all) contract FlowYieldVaultsEVMWorkerOps { message: "Worker transaction did not execute successfully. Transaction ID: \(txId.toString())", ) - // Remove request from scheduledRequests - // Success is not checked because errors are not considered transient - FlowYieldVaultsEVMWorkerOps.scheduledRequests.remove(key: requestId) + // 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) + } emit WorkerHandlerPanicDetected( status: txStatus?.rawValue, diff --git a/cadence/tests/transactions/scheduler/run_scheduler_with_capacity.cdc b/cadence/tests/transactions/scheduler/run_scheduler_with_capacity.cdc new file mode 100644 index 0000000..5c8a397 --- /dev/null +++ b/cadence/tests/transactions/scheduler/run_scheduler_with_capacity.cdc @@ -0,0 +1,33 @@ +import "FlowTransactionScheduler" +import "FlowYieldVaultsEVMWorkerOps" + +/// @title Run Scheduler Manually With Capacity +/// @notice Runs the scheduler manually with an explicit run capacity +/// @dev Test-only helper for forcing preprocessing/scheduling without waiting for recurrent runs +/// @param runCapacity The number of pending requests this manual run is allowed to preprocess +transaction(runCapacity: UInt8) { + let schedulerHandlerCap: Capability + + prepare(signer: auth(Capabilities, IssueStorageCapabilityController) &Account) { + let existingControllers = signer.capabilities.storage.getControllers( + forPath: FlowYieldVaultsEVMWorkerOps.SchedulerHandlerStoragePath + ) + + if existingControllers.length > 0 { + self.schedulerHandlerCap = existingControllers[0].capability + as! Capability + } else { + self.schedulerHandlerCap = signer.capabilities.storage + .issue( + FlowYieldVaultsEVMWorkerOps.SchedulerHandlerStoragePath + ) + } + } + + execute { + self.schedulerHandlerCap.borrow()!.executeTransaction( + id: 42, + data: runCapacity + ) + } +} diff --git a/deployments/artifacts/FlowYieldVaultsRequests.json b/deployments/artifacts/FlowYieldVaultsRequests.json index 32fc49f..6dd99bc 100644 --- a/deployments/artifacts/FlowYieldVaultsRequests.json +++ b/deployments/artifacts/FlowYieldVaultsRequests.json @@ -328,6 +328,11 @@ "name": "message", "type": "string", "internalType": "string" + }, + { + "name": "refundAmount", + "type": "uint256", + "internalType": "uint256" } ], "outputs": [], @@ -2026,6 +2031,22 @@ "name": "InvalidMinimumBalance", "inputs": [] }, + { + "type": "error", + "name": "InvalidRefundAmount", + "inputs": [ + { + "name": "expectedOrMaximum", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "actual", + "type": "uint256", + "internalType": "uint256" + } + ] + }, { "type": "error", "name": "InvalidRequestState", diff --git a/lib/FlowYieldVaults b/lib/FlowYieldVaults index a89895a..db5b323 160000 --- a/lib/FlowYieldVaults +++ b/lib/FlowYieldVaults @@ -1 +1 @@ -Subproject commit a89895a6289c76402be1d1172c847a63f924629c +Subproject commit db5b323bd9ca31b5b68f1aeb5b11462b28c46139 diff --git a/local/run_worker_tests.sh b/local/run_worker_tests.sh index 3ff68a3..749e4de 100755 --- a/local/run_worker_tests.sh +++ b/local/run_worker_tests.sh @@ -71,6 +71,8 @@ NATIVE_FLOW="0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF" VAULT_IDENTIFIER="A.0ae53cb6e3f42a79.FlowToken.Vault" STRATEGY_IDENTIFIER="${STRATEGY_IDENTIFIER:-A.045a1763c93006ca.MockStrategies.TracerStrategy}" CADENCE_CONTRACT_ADDR="045a1763c93006ca" +MOET_VAULT_IDENTIFIER="A.${CADENCE_CONTRACT_ADDR}.MOET.Vault" +FLOWYIELDVAULTS_DIR="./lib/FlowYieldVaults" # Scheduler configuration SCHEDULER_WAKEUP_INTERVAL=1 # Default scheduler wakeup interval in seconds @@ -124,6 +126,21 @@ cast_send() { --legacy 2>&1 } +# Execute EVM transaction against an arbitrary contract +cast_send_to_contract() { + local user_pk=$1 + local contract_address=$2 + local function_sig=$3 + shift 3 + + cast send "$contract_address" \ + "$function_sig" \ + "$@" \ + --rpc-url "$RPC_URL" \ + --private-key "$user_pk" \ + --legacy 2>&1 +} + # Execute EVM call via cast cast_call() { local function_sig=$1 @@ -135,6 +152,18 @@ cast_call() { --rpc-url "$RPC_URL" 2>&1 } +# Execute EVM call against an arbitrary contract +cast_call_contract() { + local contract_address=$1 + local function_sig=$2 + shift 2 + + cast call "$contract_address" \ + "$function_sig" \ + "$@" \ + --rpc-url "$RPC_URL" 2>&1 +} + # Get pending request count get_pending_count() { cast_call "getPendingRequestCount()(uint256)" @@ -198,6 +227,80 @@ get_claimable_refund() { sed 's/ \[.*\]$//' | tr -d ' ' } +# Get ERC20 balance for a user +get_token_balance() { + local token_address=$1 + local user_address=$2 + cast_call_contract "$token_address" "balanceOf(address)(uint256)" "$user_address" | \ + sed 's/ \[.*\]$//' | tr -d ' ' +} + +# Claim refund from Solidity contract +claim_refund() { + local user_pk=$1 + local token_address=${2:-$NATIVE_FLOW} + cast_send "$user_pk" "claimRefund(address)" "$token_address" +} + +# Approve ERC20 spend +approve_token() { + local user_pk=$1 + local token_address=$2 + local spender_address=$3 + local amount=$4 + cast_send_to_contract "$user_pk" "$token_address" "approve(address,uint256)" "$spender_address" "$amount" +} + +# Transfer ERC20 tokens +transfer_token() { + local user_pk=$1 + local token_address=$2 + local recipient_address=$3 + local amount=$4 + cast_send_to_contract "$user_pk" "$token_address" "transfer(address,uint256)" "$recipient_address" "$amount" +} + +# Resolve the bridged MOET ERC20 address from Cadence bridge config +get_moet_evm_address() { + ( + cd "$FLOWYIELDVAULTS_DIR" || exit 1 + flow -f ./flow.json scripts execute ./cadence/tests/scripts/get_moet_evm_address.cdc 2>/dev/null + ) | \ + grep "Result:" | cut -d'"' -f2 | tr -d '[:space:]' +} + +# Bridge a Cadence token vault directly to an EVM address +bridge_token_to_evm_address() { + local vault_identifier=$1 + local amount=$2 + local recipient_address=$3 + ( + cd "$FLOWYIELDVAULTS_DIR" || exit 1 + flow -f ./flow.json transactions send \ + ./lib/flow-evm-bridge/cadence/transactions/bridge/tokens/bridge_tokens_to_any_evm_address.cdc \ + "$vault_identifier" \ + "$amount" \ + "$recipient_address" \ + --signer emulator-flow-yield-vaults \ + --compute-limit 9999 2>&1 + ) +} + +# Configure a supported token on FlowYieldVaultsRequests via the worker's COA +set_token_config() { + local token_address=$1 + local is_supported=$2 + local minimum_balance=$3 + local is_native=$4 + flow transactions send ./cadence/transactions/admin/set_token_config.cdc \ + "$token_address" \ + "$is_supported" \ + "$minimum_balance" \ + "$is_native" \ + --signer emulator-flow-yield-vaults \ + --compute-limit 9999 2>&1 +} + # Get request message (error message or status message) get_request_message() { local request_id=$1 @@ -286,21 +389,32 @@ wait_for_request_status() { # Usage: extract_request_id "$TX_OUTPUT" extract_request_id() { local tx_output="$1" - # Extract the transactionHash from cast send output - local tx_hash=$(echo "$tx_output" | grep "transactionHash" | awk '{print $2}') - if [ -z "$tx_hash" ]; then - echo "" - return 1 + local request_created_topic0="0x13e89e7f5f11ae17347a4657936695ef7097ee21e38f9250bd21466ccacccd5c" + # `cast send` already returns the logs array as JSON. Prefer parsing that directly so we + # do not depend on receipt formatting or accidentally match nested `transactionHash` fields. + local logs_json=$(printf '%s\n' "$tx_output" | awk '/^logs[[:space:]]+\[/ { sub(/^[^[]*/, ""); print; exit }') + local request_id_hex="" + + if [ -n "$logs_json" ]; then + request_id_hex=$(printf '%s\n' "$logs_json" | jq -r --arg topic0 "$request_created_topic0" \ + 'first(.[]? | select((.topics[0] // "" | ascii_downcase) == ($topic0 | ascii_downcase)) | .topics[1]) // empty' 2>/dev/null) fi - # Get transaction receipt and find RequestCreated event topic - # RequestCreated event: topic0 = keccak256("RequestCreated(uint256,address,uint8,address,uint256,uint64,uint256,string,string)") - # The requestId is indexed, so it's in topic1 - local receipt=$(cast receipt "$tx_hash" --rpc-url "$RPC_URL" 2>/dev/null) - # Extract the first topic after topic0 from the RequestCreated event log - local request_id=$(echo "$receipt" | grep -A 10 "logs" | grep -oE "0x[0-9a-fA-F]{64}" | head -2 | tail -1) - if [ -n "$request_id" ]; then - # Convert hex to decimal - echo $((request_id)) + + if [ -z "$request_id_hex" ]; then + local tx_hash=$(printf '%s\n' "$tx_output" | awk '/^transactionHash[[:space:]]+0x[0-9a-fA-F]{64}$/ { print $2; exit }') + if [ -z "$tx_hash" ]; then + echo "" + return 1 + fi + # Fall back to the JSON receipt when the logs line is not present. + # The requestId is indexed, so it is stored in topics[1]. + request_id_hex=$(cast receipt "$tx_hash" --json --rpc-url "$RPC_URL" 2>/dev/null | \ + jq -r --arg topic0 "$request_created_topic0" \ + 'first(.logs[]? | select((.topics[0] // "" | ascii_downcase) == ($topic0 | ascii_downcase)) | .topics[1]) // empty') + fi + + if [ -n "$request_id_hex" ]; then + cast to-dec "$request_id_hex" else echo "" fi @@ -331,6 +445,21 @@ unpause_scheduler() { --compute-limit 9999 2>&1 } +# Run scheduler manually with explicit capacity +run_scheduler_with_capacity() { + local run_capacity=$1 + flow transactions send ./cadence/tests/transactions/scheduler/run_scheduler_with_capacity.cdc "$run_capacity" \ + --signer emulator-flow-yield-vaults \ + --compute-limit 9999 2>&1 +} + +# Stop all scheduled worker and scheduler transactions +stop_all_scheduled_transactions() { + flow transactions send ./cadence/transactions/scheduler/stop_all_scheduled_transactions.cdc \ + --signer emulator-flow-yield-vaults \ + --compute-limit 9999 2>&1 +} + # Initialize scheduler handlers init_scheduler() { flow transactions send ./cadence/transactions/scheduler/init_and_schedule.cdc \ @@ -355,6 +484,16 @@ count_yieldvaults() { fi } +# Extract the latest YieldVault ID from the Cadence script output +get_latest_yieldvault_id() { + local vaults_output="$1" + if [ -z "$vaults_output" ] || [ "$vaults_output" = "[]" ]; then + echo "" + else + echo "$vaults_output" | grep -Eo '[0-9]+' | tail -1 + fi +} + # Wait for user to have more YieldVaults than before # Usage: wait_for_user_vault "$USER_EOA" "$VAULTS_BEFORE" [timeout] # Returns 0 if new vault detected, 1 if timeout @@ -460,6 +599,21 @@ assert_eq() { fi } +# Assert exact wei equality +assert_wei_eq() { + local expected=$(clean_wei "$1") + local actual=$(clean_wei "$2") + local message=$3 + + if compare_wei "$expected" -eq "$actual"; then + log_success "$message" + return 0 + else + log_fail "$message (expected: $expected wei, got: $actual wei)" + return 1 + fi +} + # Assert not equals assert_neq() { local not_expected=$1 @@ -539,6 +693,20 @@ else exit 1 fi +log_test "Resolve bridged MOET address" +MOET_EVM_ADDRESS=$(get_moet_evm_address) +if [ -n "$MOET_EVM_ADDRESS" ]; then + log_info "MOET EVM address: $MOET_EVM_ADDRESS" + log_success "Resolved bridged MOET token address" +else + log_fail "Could not resolve bridged MOET token address" + exit 1 +fi + +log_test "Configure MOET token support for worker tests" +MOET_CONFIG_OUTPUT=$(set_token_config "$MOET_EVM_ADDRESS" true "1000000000000000000" false) +assert_tx_success "$MOET_CONFIG_OUTPUT" "MOET token config applied" + # ============================================ # INITIAL BALANCES # ============================================ @@ -758,10 +926,231 @@ else fi # ============================================ -# SCENARIO 4: PANIC RECOVERY - INVALID STRATEGY +# SCENARIO 4: Successful Dust Refund Claims +# ============================================ + +log_section "SCENARIO 4: Successful Dust Refund Claims" + +DUST_TIMEOUT=$((AUTO_PROCESS_TIMEOUT * 2)) +CREATE_DUST_RESIDUAL="123456789" +CREATE_DUST_AMOUNT="1000000000123456789" +DEPOSIT_DUST_RESIDUAL="987654321" +DEPOSIT_DUST_AMOUNT="1000000000987654321" + +log_test "Successful CREATE with dust credits exact claimable refund" + +USER_B_CREATE_DUST_VAULTS_BEFORE=$(get_user_yieldvaults "$USER_B_EOA") +USER_B_CREATE_DUST_VAULTS_BEFORE_COUNT=$(count_yieldvaults "$USER_B_CREATE_DUST_VAULTS_BEFORE") +USER_B_CREATE_DUST_REFUND_BEFORE=$(get_claimable_refund "$USER_B_EOA") +USER_B_CREATE_DUST_REFUND_BEFORE=$(clean_wei "$USER_B_CREATE_DUST_REFUND_BEFORE") + +TX_CREATE_DUST=$(cast_send "$USER_B_PK" \ + "createYieldVault(address,uint256,string,string)" \ + "$NATIVE_FLOW" \ + "$CREATE_DUST_AMOUNT" \ + "$VAULT_IDENTIFIER" \ + "$STRATEGY_IDENTIFIER" \ + --value "$CREATE_DUST_AMOUNT" 2>&1) + +assert_evm_tx_success "$TX_CREATE_DUST" "Dusty CREATE request submitted" + +CREATE_DUST_REQUEST_ID=$(extract_request_id "$TX_CREATE_DUST") +if [ -n "$CREATE_DUST_REQUEST_ID" ]; then + log_success "Dusty CREATE request ID extracted ($CREATE_DUST_REQUEST_ID)" +else + log_fail "Could not extract request ID for dusty CREATE" +fi + +if [ -n "$CREATE_DUST_REQUEST_ID" ]; then + if wait_for_request_status "$CREATE_DUST_REQUEST_ID" "2" "$DUST_TIMEOUT"; then + log_success "Dusty CREATE request completed" + else + log_fail "Dusty CREATE request did not reach COMPLETED status" + fi + + if wait_for_user_vault "$USER_B_EOA" "$USER_B_CREATE_DUST_VAULTS_BEFORE" "$DUST_TIMEOUT"; then + USER_B_CREATE_DUST_VAULTS_AFTER=$(get_user_yieldvaults "$USER_B_EOA") + USER_B_CREATE_DUST_VAULTS_AFTER_COUNT=$(count_yieldvaults "$USER_B_CREATE_DUST_VAULTS_AFTER") + EXPECTED_USER_B_CREATE_DUST_VAULTS=$((USER_B_CREATE_DUST_VAULTS_BEFORE_COUNT + 1)) + + log_info "User B YieldVaults after dusty CREATE: $USER_B_CREATE_DUST_VAULTS_AFTER" + assert_eq "$EXPECTED_USER_B_CREATE_DUST_VAULTS" "$USER_B_CREATE_DUST_VAULTS_AFTER_COUNT" "Dusty CREATE created exactly one new YieldVault" + else + log_fail "Dusty CREATE did not create a new Cadence YieldVault" + fi + + USER_B_CREATE_DUST_REFUND_AFTER=$(get_claimable_refund "$USER_B_EOA") + USER_B_CREATE_DUST_REFUND_AFTER=$(clean_wei "$USER_B_CREATE_DUST_REFUND_AFTER") + USER_B_CREATE_DUST_REFUND_DELTA=$(subtract_wei "$USER_B_CREATE_DUST_REFUND_AFTER" "$USER_B_CREATE_DUST_REFUND_BEFORE") + + log_info "User B CREATE dust refund delta: $USER_B_CREATE_DUST_REFUND_DELTA wei" + assert_wei_eq "$CREATE_DUST_RESIDUAL" "$USER_B_CREATE_DUST_REFUND_DELTA" "CREATE dust credited exact residual" + + if compare_wei "$USER_B_CREATE_DUST_REFUND_AFTER" -gt "$USER_B_CREATE_DUST_REFUND_BEFORE"; then + CLAIM_CREATE_DUST_OUTPUT=$(claim_refund "$USER_B_PK" "$NATIVE_FLOW") + assert_evm_tx_success "$CLAIM_CREATE_DUST_OUTPUT" "CREATE dust refund claimed" + + USER_B_CREATE_DUST_REFUND_FINAL=$(get_claimable_refund "$USER_B_EOA") + USER_B_CREATE_DUST_REFUND_FINAL=$(clean_wei "$USER_B_CREATE_DUST_REFUND_FINAL") + assert_eq "0" "$USER_B_CREATE_DUST_REFUND_FINAL" "CREATE dust claimable refund cleared" + else + log_fail "No CREATE dust refund was available to claim" + fi +fi + +log_test "Successful DEPOSIT with dust credits exact claimable refund" + +USER_A_VAULTS_FOR_DUST_DEPOSIT=$(get_user_yieldvaults "$USER_A_EOA") +DUST_DEPOSIT_VAULT_ID=$(get_latest_yieldvault_id "$USER_A_VAULTS_FOR_DUST_DEPOSIT") + +if [ -n "$DUST_DEPOSIT_VAULT_ID" ]; then + log_info "Using YieldVault ID $DUST_DEPOSIT_VAULT_ID for dust deposit" + + USER_A_DEPOSIT_DUST_REFUND_BEFORE=$(get_claimable_refund "$USER_A_EOA") + USER_A_DEPOSIT_DUST_REFUND_BEFORE=$(clean_wei "$USER_A_DEPOSIT_DUST_REFUND_BEFORE") + + TX_DEPOSIT_DUST=$(cast_send "$USER_A_PK" \ + "depositToYieldVault(uint64,address,uint256)" \ + "$DUST_DEPOSIT_VAULT_ID" \ + "$NATIVE_FLOW" \ + "$DEPOSIT_DUST_AMOUNT" \ + --value "$DEPOSIT_DUST_AMOUNT" 2>&1) + + assert_evm_tx_success "$TX_DEPOSIT_DUST" "Dusty DEPOSIT request submitted" + + DEPOSIT_DUST_REQUEST_ID=$(extract_request_id "$TX_DEPOSIT_DUST") + if [ -n "$DEPOSIT_DUST_REQUEST_ID" ]; then + log_success "Dusty DEPOSIT request ID extracted ($DEPOSIT_DUST_REQUEST_ID)" + else + log_fail "Could not extract request ID for dusty DEPOSIT" + fi + + if [ -n "$DEPOSIT_DUST_REQUEST_ID" ]; then + if wait_for_request_status "$DEPOSIT_DUST_REQUEST_ID" "2" "$DUST_TIMEOUT"; then + log_success "Dusty DEPOSIT request completed" + else + log_fail "Dusty DEPOSIT request did not reach COMPLETED status" + fi + + USER_A_DEPOSIT_DUST_REFUND_AFTER=$(get_claimable_refund "$USER_A_EOA") + USER_A_DEPOSIT_DUST_REFUND_AFTER=$(clean_wei "$USER_A_DEPOSIT_DUST_REFUND_AFTER") + USER_A_DEPOSIT_DUST_REFUND_DELTA=$(subtract_wei "$USER_A_DEPOSIT_DUST_REFUND_AFTER" "$USER_A_DEPOSIT_DUST_REFUND_BEFORE") + + log_info "User A DEPOSIT dust refund delta: $USER_A_DEPOSIT_DUST_REFUND_DELTA wei" + assert_wei_eq "$DEPOSIT_DUST_RESIDUAL" "$USER_A_DEPOSIT_DUST_REFUND_DELTA" "DEPOSIT dust credited exact residual" + + if compare_wei "$USER_A_DEPOSIT_DUST_REFUND_AFTER" -gt "$USER_A_DEPOSIT_DUST_REFUND_BEFORE"; then + CLAIM_DEPOSIT_DUST_OUTPUT=$(claim_refund "$USER_A_PK" "$NATIVE_FLOW") + assert_evm_tx_success "$CLAIM_DEPOSIT_DUST_OUTPUT" "DEPOSIT dust refund claimed" + + USER_A_DEPOSIT_DUST_REFUND_FINAL=$(get_claimable_refund "$USER_A_EOA") + USER_A_DEPOSIT_DUST_REFUND_FINAL=$(clean_wei "$USER_A_DEPOSIT_DUST_REFUND_FINAL") + assert_eq "0" "$USER_A_DEPOSIT_DUST_REFUND_FINAL" "DEPOSIT dust claimable refund cleared" + else + log_fail "No DEPOSIT dust refund was available to claim" + fi + fi +else + log_fail "Could not determine a YieldVault ID for dusty DEPOSIT" +fi + +# ============================================ +# SCENARIO 5: Successful ERC20 Dust Refund Claims # ============================================ -log_section "SCENARIO 4: Panic Recovery - Invalid Strategy Identifier" +log_section "SCENARIO 5: Successful ERC20 Dust Refund Claims" + +MOET_BRIDGE_FUNDING_AMOUNT="10.0" +MOET_BRIDGE_FUNDING_WEI="10000000000000000000" +MOET_CREATE_DUST_RESIDUAL="123456789" +MOET_CREATE_DUST_AMOUNT="1000000000123456789" + +log_test "Successful MOET CREATE with dust credits exact claimable refund" + +USER_C_MOET_BALANCE_BEFORE=$(get_token_balance "$MOET_EVM_ADDRESS" "$USER_C_EOA") +USER_C_MOET_BALANCE_BEFORE=$(clean_wei "$USER_C_MOET_BALANCE_BEFORE") +USER_C_MOET_REFUND_BEFORE=$(get_claimable_refund "$USER_C_EOA" "$MOET_EVM_ADDRESS") +USER_C_MOET_REFUND_BEFORE=$(clean_wei "$USER_C_MOET_REFUND_BEFORE") +USER_C_MOET_VAULTS_BEFORE=$(get_user_yieldvaults "$USER_C_EOA") +USER_C_MOET_VAULTS_BEFORE_COUNT=$(count_yieldvaults "$USER_C_MOET_VAULTS_BEFORE") + +BRIDGE_MOET_TO_USER_OUTPUT=$(bridge_token_to_evm_address "$MOET_VAULT_IDENTIFIER" "$MOET_BRIDGE_FUNDING_AMOUNT" "$USER_C_EOA") +assert_tx_success "$BRIDGE_MOET_TO_USER_OUTPUT" "Bridged MOET funding amount to User C" + +USER_C_MOET_BALANCE_AFTER_FUNDING=$(get_token_balance "$MOET_EVM_ADDRESS" "$USER_C_EOA") +USER_C_MOET_BALANCE_AFTER_FUNDING=$(clean_wei "$USER_C_MOET_BALANCE_AFTER_FUNDING") +USER_C_MOET_FUNDING_DELTA=$(subtract_wei "$USER_C_MOET_BALANCE_AFTER_FUNDING" "$USER_C_MOET_BALANCE_BEFORE") +assert_wei_eq "$MOET_BRIDGE_FUNDING_WEI" "$USER_C_MOET_FUNDING_DELTA" "User C received exact bridged MOET funding amount" + +APPROVE_MOET_OUTPUT=$(approve_token "$USER_C_PK" "$MOET_EVM_ADDRESS" "$FLOW_VAULTS_REQUESTS_CONTRACT" "$MOET_CREATE_DUST_AMOUNT") +assert_evm_tx_success "$APPROVE_MOET_OUTPUT" "User C approved MOET dust amount" + +TX_MOET_CREATE_DUST=$(cast_send "$USER_C_PK" \ + "createYieldVault(address,uint256,string,string)" \ + "$MOET_EVM_ADDRESS" \ + "$MOET_CREATE_DUST_AMOUNT" \ + "$MOET_VAULT_IDENTIFIER" \ + "$STRATEGY_IDENTIFIER" 2>&1) + +assert_evm_tx_success "$TX_MOET_CREATE_DUST" "MOET dusty CREATE request submitted" + +MOET_CREATE_DUST_REQUEST_ID=$(extract_request_id "$TX_MOET_CREATE_DUST") +if [ -n "$MOET_CREATE_DUST_REQUEST_ID" ]; then + log_success "MOET dusty CREATE request ID extracted ($MOET_CREATE_DUST_REQUEST_ID)" +else + log_fail "Could not extract request ID for MOET dusty CREATE" +fi + +if [ -n "$MOET_CREATE_DUST_REQUEST_ID" ]; then + if wait_for_request_status "$MOET_CREATE_DUST_REQUEST_ID" "2" "$DUST_TIMEOUT"; then + log_success "MOET dusty CREATE request completed" + else + log_fail "MOET dusty CREATE request did not reach COMPLETED status" + fi + + if wait_for_user_vault "$USER_C_EOA" "$USER_C_MOET_VAULTS_BEFORE" "$DUST_TIMEOUT"; then + USER_C_MOET_VAULTS_AFTER=$(get_user_yieldvaults "$USER_C_EOA") + USER_C_MOET_VAULTS_AFTER_COUNT=$(count_yieldvaults "$USER_C_MOET_VAULTS_AFTER") + EXPECTED_USER_C_MOET_VAULTS=$((USER_C_MOET_VAULTS_BEFORE_COUNT + 1)) + + log_info "User C YieldVaults after MOET dusty CREATE: $USER_C_MOET_VAULTS_AFTER" + assert_eq "$EXPECTED_USER_C_MOET_VAULTS" "$USER_C_MOET_VAULTS_AFTER_COUNT" "MOET dusty CREATE created exactly one new YieldVault" + else + log_fail "MOET dusty CREATE did not create a new Cadence YieldVault" + fi + + USER_C_MOET_REFUND_AFTER=$(get_claimable_refund "$USER_C_EOA" "$MOET_EVM_ADDRESS") + USER_C_MOET_REFUND_AFTER=$(clean_wei "$USER_C_MOET_REFUND_AFTER") + USER_C_MOET_REFUND_DELTA=$(subtract_wei "$USER_C_MOET_REFUND_AFTER" "$USER_C_MOET_REFUND_BEFORE") + + log_info "User C MOET dust refund delta: $USER_C_MOET_REFUND_DELTA wei" + assert_wei_eq "$MOET_CREATE_DUST_RESIDUAL" "$USER_C_MOET_REFUND_DELTA" "MOET dusty CREATE credited exact residual" + + if compare_wei "$USER_C_MOET_REFUND_AFTER" -gt "$USER_C_MOET_REFUND_BEFORE"; then + USER_C_MOET_BALANCE_BEFORE_CLAIM=$(get_token_balance "$MOET_EVM_ADDRESS" "$USER_C_EOA") + USER_C_MOET_BALANCE_BEFORE_CLAIM=$(clean_wei "$USER_C_MOET_BALANCE_BEFORE_CLAIM") + + CLAIM_MOET_DUST_OUTPUT=$(claim_refund "$USER_C_PK" "$MOET_EVM_ADDRESS") + assert_evm_tx_success "$CLAIM_MOET_DUST_OUTPUT" "MOET dust refund claimed" + + USER_C_MOET_REFUND_FINAL=$(get_claimable_refund "$USER_C_EOA" "$MOET_EVM_ADDRESS") + USER_C_MOET_REFUND_FINAL=$(clean_wei "$USER_C_MOET_REFUND_FINAL") + assert_eq "0" "$USER_C_MOET_REFUND_FINAL" "MOET dust claimable refund cleared" + + USER_C_MOET_BALANCE_AFTER_CLAIM=$(get_token_balance "$MOET_EVM_ADDRESS" "$USER_C_EOA") + USER_C_MOET_BALANCE_AFTER_CLAIM=$(clean_wei "$USER_C_MOET_BALANCE_AFTER_CLAIM") + USER_C_MOET_CLAIM_DELTA=$(subtract_wei "$USER_C_MOET_BALANCE_AFTER_CLAIM" "$USER_C_MOET_BALANCE_BEFORE_CLAIM") + assert_wei_eq "$MOET_CREATE_DUST_RESIDUAL" "$USER_C_MOET_CLAIM_DELTA" "MOET dust claim transferred exact residual to user" + else + log_fail "No MOET dust refund was available to claim" + fi +fi + +# ============================================ +# SCENARIO 6: PANIC RECOVERY - INVALID STRATEGY +# ============================================ + +log_section "SCENARIO 6: Panic Recovery - Invalid Strategy Identifier" # This test verifies that requests with invalid strategy identifiers # are caught during preprocessing and marked as FAILED with proper error messages @@ -878,11 +1267,126 @@ else log_fail "No refund credited for failed request" fi +# Check exact refund amount and verify it can be claimed +USER_A_REFUND_INCREASE=$(subtract_wei "$USER_A_REFUND_AFTER" "$USER_A_REFUND_BEFORE") +assert_wei_eq "$EXPECTED_REFUND_INCREASE" "$USER_A_REFUND_INCREASE" "Failed request credited exact refund amount" + +if compare_wei "$USER_A_REFUND_AFTER" -gt "$USER_A_REFUND_BEFORE"; then + CLAIM_FAILED_REFUND_OUTPUT=$(claim_refund "$USER_A_PK" "$NATIVE_FLOW") + assert_evm_tx_success "$CLAIM_FAILED_REFUND_OUTPUT" "Failed request refund claimed" + + USER_A_REFUND_FINAL=$(get_claimable_refund "$USER_A_EOA") + USER_A_REFUND_FINAL=$(clean_wei "$USER_A_REFUND_FINAL") + assert_eq "0" "$USER_A_REFUND_FINAL" "Failed request claimable refund cleared" +else + log_fail "No failed-request refund was available to claim" +fi + +# ============================================ +# SCENARIO 7: FAILED WITHDRAW RECOVERY VIA STOPALL +# ============================================ + +log_section "SCENARIO 7: Failed Withdraw Recovery Via stopAll()" + +log_test "Pause scheduler before creating withdraw recovery requests" + +PAUSE_OUTPUT=$(pause_scheduler) +assert_tx_success "$PAUSE_OUTPUT" "Scheduler paused for withdraw recovery scenario" + +WITHDRAW_RECOVERY_VAULTS=$(get_user_yieldvaults "$USER_A_EOA") +WITHDRAW_RECOVERY_VAULT_ID=$(get_latest_yieldvault_id "$WITHDRAW_RECOVERY_VAULTS") +WITHDRAW_RECOVERY_AMOUNT_1="50000000000000000" +WITHDRAW_RECOVERY_AMOUNT_2="60000000000000000" +WITHDRAW_RECOVERY_AMOUNT_3="70000000000000000" + +if [ -n "$WITHDRAW_RECOVERY_VAULT_ID" ]; then + log_info "Using YieldVault ID $WITHDRAW_RECOVERY_VAULT_ID for withdraw recovery" + + USER_A_WITHDRAW_RECOVERY_REFUND_BEFORE=$(get_claimable_refund "$USER_A_EOA") + USER_A_WITHDRAW_RECOVERY_REFUND_BEFORE=$(clean_wei "$USER_A_WITHDRAW_RECOVERY_REFUND_BEFORE") + + log_test "Create three withdraw requests while scheduler is paused" + + # Same-user worker requests are scheduled with 1s offsets. + # We assert on the third request so stopAll() can cancel it before its delayed worker executes. + TX_WITHDRAW_RECOVERY_1=$(cast_send "$USER_A_PK" \ + "withdrawFromYieldVault(uint64,uint256)" \ + "$WITHDRAW_RECOVERY_VAULT_ID" \ + "$WITHDRAW_RECOVERY_AMOUNT_1" 2>&1) + assert_evm_tx_success "$TX_WITHDRAW_RECOVERY_1" "First withdraw recovery request submitted" + + sleep 1 + + TX_WITHDRAW_RECOVERY_2=$(cast_send "$USER_A_PK" \ + "withdrawFromYieldVault(uint64,uint256)" \ + "$WITHDRAW_RECOVERY_VAULT_ID" \ + "$WITHDRAW_RECOVERY_AMOUNT_2" 2>&1) + assert_evm_tx_success "$TX_WITHDRAW_RECOVERY_2" "Second withdraw recovery request submitted" + + sleep 1 + + TX_WITHDRAW_RECOVERY_3=$(cast_send "$USER_A_PK" \ + "withdrawFromYieldVault(uint64,uint256)" \ + "$WITHDRAW_RECOVERY_VAULT_ID" \ + "$WITHDRAW_RECOVERY_AMOUNT_3" 2>&1) + assert_evm_tx_success "$TX_WITHDRAW_RECOVERY_3" "Third withdraw recovery request submitted" + + WITHDRAW_RECOVERY_REQUEST_ID_1=$(extract_request_id "$TX_WITHDRAW_RECOVERY_1") + WITHDRAW_RECOVERY_REQUEST_ID_2=$(extract_request_id "$TX_WITHDRAW_RECOVERY_2") + WITHDRAW_RECOVERY_REQUEST_ID_3=$(extract_request_id "$TX_WITHDRAW_RECOVERY_3") + + if [ -n "$WITHDRAW_RECOVERY_REQUEST_ID_1" ] && [ -n "$WITHDRAW_RECOVERY_REQUEST_ID_2" ] && [ -n "$WITHDRAW_RECOVERY_REQUEST_ID_3" ]; then + log_success "Withdraw recovery request IDs extracted ($WITHDRAW_RECOVERY_REQUEST_ID_1, $WITHDRAW_RECOVERY_REQUEST_ID_2, $WITHDRAW_RECOVERY_REQUEST_ID_3)" + else + log_fail "Could not extract all withdraw recovery request IDs" + fi + + log_test "Preprocess withdraw requests into PROCESSING without waiting for automatic scheduler runs" + + UNPAUSE_OUTPUT=$(unpause_scheduler) + assert_tx_success "$UNPAUSE_OUTPUT" "Scheduler unpaused for manual preprocessing" + + MANUAL_SCHEDULER_OUTPUT=$(run_scheduler_with_capacity 3) + assert_tx_success "$MANUAL_SCHEDULER_OUTPUT" "Manual scheduler run with capacity 3 submitted" + + if [ -n "$WITHDRAW_RECOVERY_REQUEST_ID_3" ]; then + WITHDRAW_RECOVERY_STATUS_PROCESSING=$(get_request_status "$WITHDRAW_RECOVERY_REQUEST_ID_3") + assert_eq "1" "$WITHDRAW_RECOVERY_STATUS_PROCESSING" "Third withdraw recovery request reached PROCESSING" + fi + + log_test "Cancel scheduled worker transactions and verify failed withdraw finalization" + + STOP_ALL_OUTPUT=$(stop_all_scheduled_transactions) + assert_tx_success "$STOP_ALL_OUTPUT" "stopAll() transaction submitted" + + if [ -n "$WITHDRAW_RECOVERY_REQUEST_ID_3" ]; then + WITHDRAW_RECOVERY_STATUS_FINAL=$(get_request_status "$WITHDRAW_RECOVERY_REQUEST_ID_3") + assert_eq "3" "$WITHDRAW_RECOVERY_STATUS_FINAL" "Third withdraw recovery request marked FAILED after stopAll()" + + WITHDRAW_RECOVERY_MESSAGE=$(get_request_message "$WITHDRAW_RECOVERY_REQUEST_ID_3") + if echo "$WITHDRAW_RECOVERY_MESSAGE" | grep -q "cancelled by admin stopAll"; then + log_success "Failed withdraw request recorded stopAll() recovery message" + else + log_fail "Failed withdraw request message did not mention stopAll() cancellation" + fi + fi + + USER_A_WITHDRAW_RECOVERY_REFUND_AFTER=$(get_claimable_refund "$USER_A_EOA") + USER_A_WITHDRAW_RECOVERY_REFUND_AFTER=$(clean_wei "$USER_A_WITHDRAW_RECOVERY_REFUND_AFTER") + USER_A_WITHDRAW_RECOVERY_REFUND_DELTA=$(subtract_wei "$USER_A_WITHDRAW_RECOVERY_REFUND_AFTER" "$USER_A_WITHDRAW_RECOVERY_REFUND_BEFORE") + assert_wei_eq "0" "$USER_A_WITHDRAW_RECOVERY_REFUND_DELTA" "Failed withdraw recovery did not create a bogus refund" + + UNPAUSE_OUTPUT=$(unpause_scheduler) + assert_tx_success "$UNPAUSE_OUTPUT" "Scheduler resumed after stopAll() recovery" +else + log_fail "Could not determine a YieldVault ID for withdraw recovery scenario" +fi + # ============================================ -# SCENARIO 5: PREPROCESSING VALIDATION TESTS +# SCENARIO 8: PREPROCESSING VALIDATION TESTS # ============================================ -log_section "SCENARIO 5: Preprocessing Validation Tests" +log_section "SCENARIO 8: Preprocessing Validation Tests" # This test verifies that the preprocessing logic correctly rejects # various types of invalid requests @@ -1016,10 +1520,10 @@ else fi # ============================================ -# SCENARIO 6: MAX PROCESSING CAPACITY TEST +# SCENARIO 9: MAX PROCESSING CAPACITY TEST # ============================================ -log_section "SCENARIO 6: Max Processing Capacity Test" +log_section "SCENARIO 9: Max Processing Capacity Test" # This test verifies that the scheduler respects the maxProcessingRequests limit (default: 3) # When more requests are submitted than capacity allows, some should stay PENDING diff --git a/local/testnet-e2e.sh b/local/testnet-e2e.sh index ec144a3..32c467d 100755 --- a/local/testnet-e2e.sh +++ b/local/testnet-e2e.sh @@ -107,6 +107,8 @@ # - ERC20 (WFLOW): COA approves contract, then contract pulls via transferFrom # 4. Contract receives funds and credits claimableRefunds # 5. User can claim the refund via claimRefund() +# Successful CREATE/DEPOSIT can also credit a smaller claimable refund equal to the +# precision residual that Cadence cannot represent exactly. # # ============================================================================= # TYPICAL TEST FLOW @@ -147,6 +149,8 @@ WFLOW="0xd3bF53DAC106A0290B0483EcBC89d40FcC961f3e" PYUSD0="0xd7d43ab7b365f0d0789aE83F4385fA710FfdC98F" NATIVE_FLOW="0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF" DEFAULT_CONTRACT="0xF633C9dBf1a3964a895fCC4CA4404B6f8BA8141d" +# Override CONTRACT when testing branches that change the `completeProcessing` ABI. +# The worker and FlowYieldVaultsRequests deployment must stay in lockstep. DEFAULT_CADENCE_CONTRACT="0x764bdff06a0ee77e" REFUND_CHECK_MAX_ATTEMPTS="${REFUND_CHECK_MAX_ATTEMPTS:-60}" REFUND_CHECK_DELAY_SECONDS="${REFUND_CHECK_DELAY_SECONDS:-5}" diff --git a/solidity/deployments/artifacts/FlowYieldVaultsRequests.json b/solidity/deployments/artifacts/FlowYieldVaultsRequests.json index 32fc49f..6dd99bc 100644 --- a/solidity/deployments/artifacts/FlowYieldVaultsRequests.json +++ b/solidity/deployments/artifacts/FlowYieldVaultsRequests.json @@ -328,6 +328,11 @@ "name": "message", "type": "string", "internalType": "string" + }, + { + "name": "refundAmount", + "type": "uint256", + "internalType": "uint256" } ], "outputs": [], @@ -2026,6 +2031,22 @@ "name": "InvalidMinimumBalance", "inputs": [] }, + { + "type": "error", + "name": "InvalidRefundAmount", + "inputs": [ + { + "name": "expectedOrMaximum", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "actual", + "type": "uint256", + "internalType": "uint256" + } + ] + }, { "type": "error", "name": "InvalidRequestState", diff --git a/solidity/src/FlowYieldVaultsRequests.sol b/solidity/src/FlowYieldVaultsRequests.sol index 782ea9c..c62a5bb 100644 --- a/solidity/src/FlowYieldVaultsRequests.sol +++ b/solidity/src/FlowYieldVaultsRequests.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.20; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; @@ -29,7 +30,12 @@ import { * * Processing uses atomic two-phase commit: * - startProcessingBatch(): Marks requests as PROCESSING, deducts user balances - * - completeProcessing(): Marks as COMPLETED/FAILED, credits claimable refunds on failure + * - completeProcessing(): Marks as COMPLETED/FAILED and credits claimable refunds + * + * PRECISION NOTE: Native FLOW uses 18 decimals and ERC20 tokens use their own token decimals, + * while Cadence UFix64 supports 8 decimal places. When a CREATE/DEPOSIT amount carries more + * precision than Cadence can represent, the worker rounds the bridged amount down to the nearest + * Cadence-representable quantity and refunds the remainder to claimableRefunds during completion. */ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { using SafeERC20 for IERC20; @@ -220,7 +226,7 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { /// @notice Amount must be greater than zero error AmountMustBeGreaterThanZero(); - /// @notice msg.value must equal amount for native token deposits + /// @notice msg.value must equal the required native token amount for this operation error MsgValueMustEqualAmount(); /// @notice msg.value must be zero for ERC20 deposits @@ -291,6 +297,9 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { /// @notice No refund available for the specified token error NoRefundAvailable(address token); + /// @notice Refund amount is inconsistent with the request lifecycle + error InvalidRefundAmount(uint256 expectedOrMaximum, uint256 actual); + /// @notice Recovery user address cannot be zero error InvalidRecoveryUserAddress(); @@ -937,7 +946,8 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { } /// @notice Claims all refunded funds for a specific token - /// @dev Refunds accumulate in claimableRefunds when requests are cancelled, dropped, or fail. + /// @dev Refunds accumulate in claimableRefunds when requests are cancelled, dropped, + /// fail, or return precision residuals on successful CREATE/DEPOSIT completion. /// This function allows users to withdraw those funds. Does NOT touch funds escrowed /// for active pending requests (pendingUserBalances). /// @param tokenAddress Token to claim refund for (NATIVE_FLOW or ERC20) @@ -989,35 +999,84 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { * @dev This is the second phase of the two-phase commit pattern. Must be called by the * authorized COA after Cadence-side operations complete. * - * Refund handling (for failed CREATE/DEPOSIT requests only): - * - Native FLOW: COA must send exact amount via msg.value - * - ERC20: COA must approve this contract, funds are pulled via safeTransferFrom - * - Refunded funds are credited to user's claimableRefunds for later claim + * Refund handling: + * - Failed CREATE/DEPOSIT: refund the full request amount + * - Successful CREATE/DEPOSIT: refund only the precision residual, if any + * - WITHDRAW/CLOSE: refundAmount must be 0 * * YieldVault registration: * - Successful CREATE: Registers new YieldVault ownership * - Successful CLOSE: Unregisters YieldVault ownership * - * For all other cases (success, WITHDRAW/CLOSE), msg.value must be 0. + * The Cadence worker passes the explicit refund amount so Solidity can validate + * lifecycle accounting before finalizing the request. * @param requestId The unique identifier of the request to complete. * @param success True if the Cadence operation succeeded, false otherwise. * @param yieldVaultId The YieldVault Id from Cadence (for CREATE: newly assigned; for others: existing). * @param message Human-readable status message or error description. + * @param refundAmount Amount returned from the COA to claimableRefunds. */ function completeProcessing( uint256 requestId, bool success, uint64 yieldVaultId, - string calldata message + string calldata message, + uint256 refundAmount ) external payable onlyAuthorizedCOA nonReentrant { Request storage request = requests[requestId]; + _completeProcessing( + request, + requestId, + success, + yieldVaultId, + message, + refundAmount + ); + } + function _completeProcessing( + Request storage request, + uint256 requestId, + bool success, + uint64 yieldVaultId, + string calldata message, + uint256 refundAmount + ) internal { // === VALIDATION === if (request.id != requestId) revert RequestNotFound(); // Only PROCESSING requests can be completed (must call startProcessingBatch first) if (request.status != RequestStatus.PROCESSING) revert InvalidRequestState(); + bool isEscrowedRequest = + request.requestType == RequestType.CREATE_YIELDVAULT || + request.requestType == RequestType.DEPOSIT_TO_YIELDVAULT; + + if (!success) { + uint256 expectedFailedRefundAmount = isEscrowedRequest + ? request.amount + : 0; + if (refundAmount != expectedFailedRefundAmount) { + revert InvalidRefundAmount( + expectedFailedRefundAmount, + refundAmount + ); + } + } else { + uint256 expectedSuccessRefundAmount = isEscrowedRequest + ? _expectedPrecisionResidual( + request.tokenAddress, + request.amount + ) + : 0; + if (refundAmount != expectedSuccessRefundAmount) { + revert InvalidRefundAmount( + expectedSuccessRefundAmount, + refundAmount + ); + } + } + // === UPDATE REQUEST STATUS === RequestStatus newStatus = success ? RequestStatus.COMPLETED @@ -1033,37 +1092,32 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { revert YieldVaultIdMismatch(request.yieldVaultId, yieldVaultId); } - // === HANDLE REFUNDS FOR FAILED CREATE/DEPOSIT === - // COA must return the funds that were transferred in startProcessingBatch - if ( - !success && - (request.requestType == RequestType.CREATE_YIELDVAULT || - request.requestType == RequestType.DEPOSIT_TO_YIELDVAULT) - ) { + // === HANDLE REFUNDS FOR FAILED CREATE/DEPOSIT OR SUCCESS-PATH RESIDUALS === + // COA must return any amount that should remain claimable on the EVM side. + if (refundAmount > 0) { if (isNativeFlow(request.tokenAddress)) { - // Native FLOW: must be sent with this transaction - if (msg.value != request.amount) revert MsgValueMustEqualAmount(); + // Native FLOW: refund is sent with this transaction + if (msg.value != refundAmount) revert MsgValueMustEqualAmount(); } else { // ERC20: pull from COA (requires prior approval) if (msg.value != 0) revert MsgValueMustBeZero(); IERC20(request.tokenAddress).safeTransferFrom( msg.sender, address(this), - request.amount + refundAmount ); - totalAccountedBalance[request.tokenAddress] += request.amount; + totalAccountedBalance[request.tokenAddress] += refundAmount; } // Credit refunded funds to claimable refunds for later claim - claimableRefunds[request.user][request.tokenAddress] += request - .amount; + claimableRefunds[request.user][request.tokenAddress] += refundAmount; emit RefundCredited( request.user, request.tokenAddress, - request.amount, + refundAmount, requestId ); } else { - // No refund expected for: successful requests OR WITHDRAW/CLOSE requests + // No refund expected for this completion path if (msg.value != 0) revert MsgValueMustBeZero(); } @@ -1702,6 +1756,29 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { } } + /** + * @dev Computes the precision residual that cannot be represented in Cadence UFix64. + * Cadence preserves at most 8 decimal places, so amounts are rounded down to the + * nearest token quantum of 10^(decimals-8) smallest units when decimals exceed 8. + */ + function _expectedPrecisionResidual( + address tokenAddress, + uint256 amount + ) internal view returns (uint256) { + if (isNativeFlow(tokenAddress)) { + return amount % 1e10; + } + + uint8 decimals = IERC20Metadata(tokenAddress).decimals(); + if (decimals <= 8) return 0; + + uint8 precisionLossDecimals = decimals - 8; + if (precisionLossDecimals > 77) return amount; + + uint256 quantum = 10 ** precisionLossDecimals; + return amount % quantum; + } + /** * @dev Registers a new YieldVault with comprehensive ownership tracking. * Updates multiple mappings to enable O(1) lookups for: diff --git a/solidity/test/FlowYieldVaultsRequests.t.sol b/solidity/test/FlowYieldVaultsRequests.t.sol index 4fcd752..50eaff0 100644 --- a/solidity/test/FlowYieldVaultsRequests.t.sol +++ b/solidity/test/FlowYieldVaultsRequests.t.sol @@ -2,10 +2,26 @@ pragma solidity 0.8.20; import "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol"; -import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "../src/FlowYieldVaultsRequests.sol"; +contract TestERC20 is ERC20 { + uint8 private immutable _tokenDecimals; + + constructor(string memory name, string memory symbol, uint8 tokenDecimals_) ERC20(name, symbol) { + _tokenDecimals = tokenDecimals_; + } + + function mint(address account, uint256 amount) external { + _mint(account, amount); + } + + function decimals() public view override returns (uint8) { + return _tokenDecimals; + } +} + contract MockDAI is ERC20 { constructor() ERC20("Mock DAI", "DAI") {} @@ -81,6 +97,15 @@ contract FlowYieldVaultsRequestsTest is Test { c.startProcessingBatch(successfulRequestIds, new uint256[](0)); } + function _completeProcessingNoRefund( + uint256 requestId, + bool success, + uint64 yieldVaultId, + string memory message + ) internal { + c.completeProcessing(requestId, success, yieldVaultId, message, 0); + } + // ============================================ // USER REQUEST LIFECYCLE // ============================================ @@ -116,12 +141,7 @@ contract FlowYieldVaultsRequestsTest is Test { vm.expectRevert( FlowYieldVaultsRequests.CannotRegisterSentinelYieldVaultId.selector ); - c.completeProcessing( - reqId, - true, - sentinelYieldVaultId, - "Invalid yieldVaultId" - ); + _completeProcessingNoRefund(reqId, true, sentinelYieldVaultId, "Invalid yieldVaultId"); vm.stopPrank(); } @@ -131,7 +151,7 @@ contract FlowYieldVaultsRequestsTest is Test { vm.startPrank(coa); _startProcessingBatch(reqId); - c.completeProcessing(reqId, true, 0, "YieldVault 0 created"); + _completeProcessingNoRefund(reqId, true, 0, "YieldVault 0 created"); vm.stopPrank(); assertTrue(c.isYieldVaultIdValid(0), "YieldVault ID 0 should be valid"); @@ -263,7 +283,7 @@ contract FlowYieldVaultsRequestsTest is Test { // 3. COA fails and returns funds uint64 sentinelYieldVaultId = c.NO_YIELDVAULT_ID(); vm.prank(coa); - c.completeProcessing{value: 1 ether}(reqId, false, sentinelYieldVaultId, "Failed"); + c.completeProcessing{value: 1 ether}(reqId, false, sentinelYieldVaultId, "Failed", 1 ether); // 4. User has refund in claimableRefunds (not pendingUserBalances) assertEq(c.getUserPendingBalance(user, NATIVE_FLOW), 0); @@ -297,7 +317,7 @@ contract FlowYieldVaultsRequestsTest is Test { _startProcessingBatch(reqId); uint64 sentinelYieldVaultId = c.NO_YIELDVAULT_ID(); vm.prank(coa); - c.completeProcessing{value: 2 ether}(reqId, false, sentinelYieldVaultId, "Failed"); + c.completeProcessing{value: 2 ether}(reqId, false, sentinelYieldVaultId, "Failed", 2 ether); // Claim only NATIVE_FLOW uint256 balBefore = user.balance; @@ -316,7 +336,7 @@ contract FlowYieldVaultsRequestsTest is Test { _startProcessingBatch(reqId); uint64 sentinelYieldVaultId = c.NO_YIELDVAULT_ID(); vm.prank(coa); - c.completeProcessing{value: 1 ether}(reqId, false, sentinelYieldVaultId, "Failed"); + c.completeProcessing{value: 1 ether}(reqId, false, sentinelYieldVaultId, "Failed", 1 ether); // RefundClaimed is emitted on claim (no BalanceUpdated since we use separate claimableRefunds mapping) vm.prank(user); @@ -372,7 +392,7 @@ contract FlowYieldVaultsRequestsTest is Test { vm.startPrank(coa); _startProcessingBatch(reqId); - c.completeProcessing(reqId, true, 100, "YieldVault created"); + _completeProcessingNoRefund(reqId, true, 100, "YieldVault created"); vm.stopPrank(); FlowYieldVaultsRequests.Request memory req = c.getRequest(reqId); @@ -396,7 +416,7 @@ contract FlowYieldVaultsRequestsTest is Test { assertEq(c.getClaimableRefund(user, NATIVE_FLOW), 0); // COA must return funds when completing with failure - c.completeProcessing{value: 1 ether}(reqId, false, c.NO_YIELDVAULT_ID(), "Cadence error"); + c.completeProcessing{value: 1 ether}(reqId, false, c.NO_YIELDVAULT_ID(), "Cadence error", 1 ether); vm.stopPrank(); // Funds go to claimableRefunds (not pendingUserBalances) @@ -407,6 +427,68 @@ contract FlowYieldVaultsRequestsTest is Test { assertEq(uint8(req.status), uint8(FlowYieldVaultsRequests.RequestStatus.FAILED)); } + function test_CompleteProcessing_FailureRefundsExactCreateAmountWithDust() public { + uint256 amount = 1 ether + 123456789; + + vm.prank(user); + uint256 reqId = c.createYieldVault{value: amount}(NATIVE_FLOW, amount, VAULT_ID, STRATEGY_ID); + + vm.startPrank(coa); + _startProcessingBatch(reqId); + c.completeProcessing{value: amount}(reqId, false, c.NO_YIELDVAULT_ID(), "Cadence error", amount); + vm.stopPrank(); + + assertEq(c.getUserPendingBalance(user, NATIVE_FLOW), 0); + assertEq(c.getClaimableRefund(user, NATIVE_FLOW), amount); + } + + function test_CompleteProcessing_SuccessCreditsCreateResidualRefund() public { + // Cadence can only consume the 8-decimal portion. The sub-8-decimal wei stays on + // the EVM side and must be credited back as a refund even though the request succeeds. + uint256 residual = 123456789; + uint256 amount = 1 ether + residual; + + vm.prank(user); + uint256 reqId = c.createYieldVault{value: amount}(NATIVE_FLOW, amount, VAULT_ID, STRATEGY_ID); + + vm.startPrank(coa); + _startProcessingBatch(reqId); + c.completeProcessing{value: residual}(reqId, true, 100, "YieldVault created", residual); + vm.stopPrank(); + + // The YieldVault is created with the Cadence-representable amount and the leftover + // wei is made claimable instead of remaining stranded in the COA. + assertEq(c.getUserPendingBalance(user, NATIVE_FLOW), 0); + assertEq(c.getClaimableRefund(user, NATIVE_FLOW), residual); + + FlowYieldVaultsRequests.Request memory req = c.getRequest(reqId); + assertEq(uint8(req.status), uint8(FlowYieldVaultsRequests.RequestStatus.COMPLETED)); + assertEq(req.yieldVaultId, 100); + assertEq(c.doesUserOwnYieldVault(user, 100), true); + } + + function test_CompleteProcessing_RevertWrongCreateResidualRefund() public { + // A successful completion must return the exact precision residual; omitting it would + // strand funds in the COA, so the contract rejects the completion. + uint256 residual = 123456789; + uint256 amount = 1 ether + residual; + + vm.prank(user); + uint256 reqId = c.createYieldVault{value: amount}(NATIVE_FLOW, amount, VAULT_ID, STRATEGY_ID); + + vm.startPrank(coa); + _startProcessingBatch(reqId); + vm.expectRevert( + abi.encodeWithSelector( + FlowYieldVaultsRequests.InvalidRefundAmount.selector, + residual, + uint256(0) + ) + ); + _completeProcessingNoRefund(reqId, true, 100, "YieldVault created"); + vm.stopPrank(); + } + function test_CompleteProcessing_FailureRefundsBalance_DepositToYieldVault() public { vm.prank(user); uint256 reqId = c.depositToYieldVault{value: 1 ether}(42, NATIVE_FLOW, 1 ether); @@ -418,7 +500,7 @@ contract FlowYieldVaultsRequestsTest is Test { assertEq(c.getClaimableRefund(user, NATIVE_FLOW), 0); // COA must return funds when completing with failure - c.completeProcessing{value: 1 ether}(reqId, false, 42, "Cadence error"); + c.completeProcessing{value: 1 ether}(reqId, false, 42, "Cadence error", 1 ether); vm.stopPrank(); // Funds go to claimableRefunds (not pendingUserBalances) @@ -431,13 +513,183 @@ contract FlowYieldVaultsRequestsTest is Test { assertEq(uint8(req.status), uint8(FlowYieldVaultsRequests.RequestStatus.FAILED)); } + function test_CompleteProcessing_FailureRefundsExactDepositAmountWithDust() public { + uint256 amount = 1 ether + 987654321; + + vm.prank(user); + uint256 reqId = c.depositToYieldVault{value: amount}(42, NATIVE_FLOW, amount); + + vm.startPrank(coa); + _startProcessingBatch(reqId); + c.completeProcessing{value: amount}(reqId, false, 42, "Cadence error", amount); + vm.stopPrank(); + + assertEq(c.getUserPendingBalance(user, NATIVE_FLOW), 0); + assertEq(c.getClaimableRefund(user, NATIVE_FLOW), amount); + } + + function test_CompleteProcessing_SuccessCreditsDepositResidualRefund() public { + // Deposits follow the same rule as vault creation: only the 8-decimal Cadence amount + // is bridged, and the remaining wei is refunded on successful completion. + uint256 residual = 987654321; + uint256 amount = 1 ether + residual; + + vm.prank(user); + uint256 reqId = c.depositToYieldVault{value: amount}(42, NATIVE_FLOW, amount); + + vm.startPrank(coa); + _startProcessingBatch(reqId); + c.completeProcessing{value: residual}(reqId, true, 42, "Deposited", residual); + vm.stopPrank(); + + // Success should not consume the dust on the EVM side; it must become claimable. + assertEq(c.getUserPendingBalance(user, NATIVE_FLOW), 0); + assertEq(c.getClaimableRefund(user, NATIVE_FLOW), residual); + + FlowYieldVaultsRequests.Request memory req = c.getRequest(reqId); + assertEq(uint8(req.status), uint8(FlowYieldVaultsRequests.RequestStatus.COMPLETED)); + assertEq(req.yieldVaultId, 42); + } + + function test_CompleteProcessing_SuccessCreditsERC20ResidualRefund() public { + // ERC20 tokens with more than 8 decimals also leave a remainder after Cadence + // truncation, and that remainder must be refunded on success. + 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), residual); + c.completeProcessing(reqId, true, 101, "YieldVault created", residual); + vm.stopPrank(); + + // The ERC20 residual is tracked in claimableRefunds exactly like native FLOW dust. + assertEq(c.getUserPendingBalance(user, address(token)), 0); + assertEq(c.getClaimableRefund(user, address(token)), residual); + assertEq(c.totalAccountedBalance(address(token)), residual); + assertEq(c.doesUserOwnYieldVault(user, 101), true); + + uint256 balanceBefore = token.balanceOf(user); + vm.prank(user); + c.claimRefund(address(token)); + assertEq(token.balanceOf(user), balanceBefore + residual); + assertEq(c.totalAccountedBalance(address(token)), 0); + } + + function test_CompleteProcessing_RevertUnexpectedRefundForExactAmount() public { + // When the amount is already exactly representable in Cadence, a successful completion + // must not send any refund amount. + uint256 amount = 1 ether; + uint256 refundAmount = 1; + + vm.prank(user); + uint256 reqId = c.createYieldVault{value: amount}(NATIVE_FLOW, amount, VAULT_ID, STRATEGY_ID); + + vm.deal(coa, refundAmount); + + vm.startPrank(coa); + _startProcessingBatch(reqId); + vm.expectRevert( + abi.encodeWithSelector( + FlowYieldVaultsRequests.InvalidRefundAmount.selector, + uint256(0), + refundAmount + ) + ); + c.completeProcessing{value: refundAmount}(reqId, true, 100, "YieldVault created", refundAmount); + vm.stopPrank(); + } + + function test_CompleteProcessing_RevertNonZeroRefundForWithdraw() public { + // Non-escrowed requests must never credit refunds, even on success. + 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_RevertNonZeroRefundForFailedWithdraw() public { + // The same zero-refund invariant applies when a WITHDRAW fails. + 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, false, 42, "Failed", 1); + vm.stopPrank(); + } + + function test_CompleteProcessing_RevertNonZeroRefundForClose() public { + // CLOSE shares the same non-escrowed accounting rule as WITHDRAW. + 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(); + } + + function test_CompleteProcessing_RevertNonZeroRefundForFailedClose() public { + // A failed CLOSE must also reject any non-zero refundAmount. + 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, false, 42, "Failed", 1); + vm.stopPrank(); + } + function test_CompleteProcessing_CloseYieldVaultRemovesOwnership() public { vm.prank(user); uint256 reqId = c.closeYieldVault(42); vm.startPrank(coa); _startProcessingBatch(reqId); - c.completeProcessing(reqId, true, 42, "Closed"); + _completeProcessingNoRefund(reqId, true, 42, "Closed"); vm.stopPrank(); assertEq(c.doesUserOwnYieldVault(user, 42), false); @@ -450,7 +702,7 @@ contract FlowYieldVaultsRequestsTest is Test { vm.prank(coa); vm.expectRevert(FlowYieldVaultsRequests.InvalidRequestState.selector); - c.completeProcessing(reqId, true, 100, "Should fail"); + _completeProcessingNoRefund(reqId, true, 100, "Should fail"); } // ============================================ @@ -822,7 +1074,7 @@ contract FlowYieldVaultsRequestsTest is Test { // 3. COA completes processing (funds are bridged via COA in Cadence) vm.prank(coa); - c.completeProcessing(reqId, true, 100, "YieldVault created"); + _completeProcessingNoRefund(reqId, true, 100, "YieldVault created"); // Verify final state assertEq(c.getPendingRequestCount(), 0); @@ -841,7 +1093,7 @@ contract FlowYieldVaultsRequestsTest is Test { // COA processes vm.startPrank(coa); _startProcessingBatch(reqId); - c.completeProcessing(reqId, true, 42, "Withdrawn"); + _completeProcessingNoRefund(reqId, true, 42, "Withdrawn"); vm.stopPrank(); FlowYieldVaultsRequests.Request memory req = c.getRequest(reqId); @@ -902,7 +1154,7 @@ contract FlowYieldVaultsRequestsTest is Test { // Process middle request (req3) vm.startPrank(coa); _startProcessingBatch(req3); - c.completeProcessing(req3, true, 200, "Created"); + _completeProcessingNoRefund(req3, true, 200, "Created"); vm.stopPrank(); // Verify FIFO order is maintained: [req1, req2, req4, req5] @@ -924,7 +1176,7 @@ contract FlowYieldVaultsRequestsTest is Test { // Remove first element vm.startPrank(coa); _startProcessingBatch(req1); - c.completeProcessing(req1, true, 100, "Created"); + _completeProcessingNoRefund(req1, true, 100, "Created"); vm.stopPrank(); (uint256[] memory ids, , , , , , , , , , ) = c.getPendingRequestsUnpacked(0, 0); @@ -943,7 +1195,7 @@ contract FlowYieldVaultsRequestsTest is Test { // Remove last element vm.startPrank(coa); _startProcessingBatch(req3); - c.completeProcessing(req3, true, 100, "Created"); + _completeProcessingNoRefund(req3, true, 100, "Created"); vm.stopPrank(); (uint256[] memory ids, , , , , , , , , , ) = c.getPendingRequestsUnpacked(0, 0); @@ -962,13 +1214,13 @@ contract FlowYieldVaultsRequestsTest is Test { // Process in FIFO order vm.startPrank(coa); _startProcessingBatch(req1); - c.completeProcessing(req1, true, 100, "Created"); + _completeProcessingNoRefund(req1, true, 100, "Created"); _startProcessingBatch(req2); - c.completeProcessing(req2, true, 101, "Created"); + _completeProcessingNoRefund(req2, true, 101, "Created"); _startProcessingBatch(req3); - c.completeProcessing(req3, true, 102, "Created"); + _completeProcessingNoRefund(req3, true, 102, "Created"); vm.stopPrank(); assertEq(c.getPendingRequestCount(), 0, "All requests should be processed"); @@ -985,7 +1237,7 @@ contract FlowYieldVaultsRequestsTest is Test { // Process out of order: req2, req4, req1, req3 vm.startPrank(coa); _startProcessingBatch(req2); - c.completeProcessing(req2, true, 100, "Created"); + _completeProcessingNoRefund(req2, true, 100, "Created"); // After removing req2: [req1, req3, req4] (uint256[] memory ids1, , , , , , , , , , ) = c.getPendingRequestsUnpacked(0, 0); @@ -994,7 +1246,7 @@ contract FlowYieldVaultsRequestsTest is Test { assertEq(ids1[2], req4); _startProcessingBatch(req4); - c.completeProcessing(req4, true, 101, "Created"); + _completeProcessingNoRefund(req4, true, 101, "Created"); // After removing req4: [req1, req3] (uint256[] memory ids2, , , , , , , , , , ) = c.getPendingRequestsUnpacked(0, 0); @@ -1002,7 +1254,7 @@ contract FlowYieldVaultsRequestsTest is Test { assertEq(ids2[1], req3); _startProcessingBatch(req1); - c.completeProcessing(req1, true, 102, "Created"); + _completeProcessingNoRefund(req1, true, 102, "Created"); // After removing req1: [req3] (uint256[] memory ids3, , , , , , , , , , ) = c.getPendingRequestsUnpacked(0, 0); @@ -1010,7 +1262,7 @@ contract FlowYieldVaultsRequestsTest is Test { assertEq(ids3[0], req3); _startProcessingBatch(req3); - c.completeProcessing(req3, true, 103, "Created"); + _completeProcessingNoRefund(req3, true, 103, "Created"); vm.stopPrank(); assertEq(c.getPendingRequestCount(), 0); @@ -1260,7 +1512,7 @@ contract FlowYieldVaultsRequestsTest is Test { // Process req2 vm.startPrank(coa); _startProcessingBatch(req2); - c.completeProcessing(req2, true, 100, "Created"); + _completeProcessingNoRefund(req2, true, 100, "Created"); vm.stopPrank(); // User should now have req1 and req3 @@ -1318,7 +1570,7 @@ contract FlowYieldVaultsRequestsTest is Test { // Remove user's middle request (u1r2) vm.startPrank(coa); _startProcessingBatch(u1r2); - c.completeProcessing(u1r2, true, 100, "Created"); + _completeProcessingNoRefund(u1r2, true, 100, "Created"); vm.stopPrank(); // Verify user1's remaining requests @@ -1344,9 +1596,9 @@ contract FlowYieldVaultsRequestsTest is Test { vm.startPrank(coa); _startProcessingBatch(req1); - c.completeProcessing(req1, true, 100, "Created"); + _completeProcessingNoRefund(req1, true, 100, "Created"); _startProcessingBatch(req2); - c.completeProcessing(req2, true, 101, "Created"); + _completeProcessingNoRefund(req2, true, 101, "Created"); vm.stopPrank(); (uint256[] memory ids, , , , , , , , , , , uint256[] memory pendingBalances, ) = c.getPendingRequestsByUserUnpacked(user); @@ -1365,7 +1617,7 @@ contract FlowYieldVaultsRequestsTest is Test { vm.startPrank(coa); _startProcessingBatch(reqId); - c.completeProcessing(reqId, true, 200, "Created"); + _completeProcessingNoRefund(reqId, true, 200, "Created"); vm.stopPrank(); // Verify yieldvault is registered @@ -1379,7 +1631,7 @@ contract FlowYieldVaultsRequestsTest is Test { vm.startPrank(coa); _startProcessingBatch(closeReqId); - c.completeProcessing(closeReqId, true, 200, "Closed"); + _completeProcessingNoRefund(closeReqId, true, 200, "Closed"); vm.stopPrank(); // Verify yieldvault is unregistered @@ -1399,11 +1651,11 @@ contract FlowYieldVaultsRequestsTest is Test { vm.startPrank(coa); _startProcessingBatch(req1); - c.completeProcessing(req1, true, 100, "Created"); + _completeProcessingNoRefund(req1, true, 100, "Created"); _startProcessingBatch(req2); - c.completeProcessing(req2, true, 101, "Created"); + _completeProcessingNoRefund(req2, true, 101, "Created"); _startProcessingBatch(req3); - c.completeProcessing(req3, true, 102, "Created"); + _completeProcessingNoRefund(req3, true, 102, "Created"); vm.stopPrank(); // User now has yieldvaults: 42, 100, 101, 102 @@ -1416,7 +1668,7 @@ contract FlowYieldVaultsRequestsTest is Test { vm.startPrank(coa); _startProcessingBatch(closeReq); - c.completeProcessing(closeReq, true, 101, "Closed"); + _completeProcessingNoRefund(closeReq, true, 101, "Closed"); vm.stopPrank(); // Verify 101 is removed @@ -1437,7 +1689,7 @@ contract FlowYieldVaultsRequestsTest is Test { vm.startPrank(coa); _startProcessingBatch(closeReq); - c.completeProcessing(closeReq, true, 42, "Closed"); + _completeProcessingNoRefund(closeReq, true, 42, "Closed"); vm.stopPrank(); uint64[] memory userYieldVaults = c.getYieldVaultIdsForUser(user); @@ -1470,7 +1722,7 @@ contract FlowYieldVaultsRequestsTest is Test { vm.startPrank(coa); for (uint256 i = 1; i < numRequests; i += 2) { _startProcessingBatch(requestIds[i]); - c.completeProcessing(requestIds[i], true, uint64(100 + i), "Created"); + _completeProcessingNoRefund(requestIds[i], true, uint64(100 + i), "Created"); } vm.stopPrank(); @@ -1508,7 +1760,7 @@ contract FlowYieldVaultsRequestsTest is Test { // Process user[2]'s middle request vm.startPrank(coa); _startProcessingBatch(userRequestIds[2][1]); - c.completeProcessing(userRequestIds[2][1], true, 300, "Created"); + _completeProcessingNoRefund(userRequestIds[2][1], true, 300, "Created"); vm.stopPrank(); // Verify all other users still have 3 requests @@ -1532,7 +1784,7 @@ contract FlowYieldVaultsRequestsTest is Test { vm.startPrank(coa); _startProcessingBatch(reqId); - c.completeProcessing(reqId, true, 100, "Created"); + _completeProcessingNoRefund(reqId, true, 100, "Created"); vm.stopPrank(); assertEq(c.getPendingRequestCount(), 0); @@ -1551,7 +1803,7 @@ contract FlowYieldVaultsRequestsTest is Test { vm.startPrank(coa); _startProcessingBatch(req1); // COA must return funds when completing with failure - c.completeProcessing{value: 1 ether}(req1, false, c.NO_YIELDVAULT_ID(), "Failed"); + c.completeProcessing{value: 1 ether}(req1, false, c.NO_YIELDVAULT_ID(), "Failed", 1 ether); vm.stopPrank(); // req1 should still be removed from pending (it's marked FAILED) @@ -1592,7 +1844,7 @@ contract FlowYieldVaultsRequestsTest is Test { vm.startPrank(coa); _startProcessingBatch(reqId); - c.completeProcessing(reqId, true, 100, "Created"); + _completeProcessingNoRefund(reqId, true, 100, "Created"); // Try to register same ID again (simulate COA bug) uint256 reqId2 = c.createYieldVault{value: 1 ether}(NATIVE_FLOW, 1 ether, VAULT_ID, STRATEGY_ID); @@ -1601,7 +1853,7 @@ contract FlowYieldVaultsRequestsTest is Test { FlowYieldVaultsRequests.YieldVaultIdAlreadyRegistered.selector, 100 )); - c.completeProcessing(reqId2, true, 100, "Duplicate"); + _completeProcessingNoRefund(reqId2, true, 100, "Duplicate"); vm.stopPrank(); } @@ -1612,7 +1864,7 @@ contract FlowYieldVaultsRequestsTest is Test { vm.startPrank(coa); _startProcessingBatch(reqId); - c.completeProcessing(reqId, true, 100, "Created"); + _completeProcessingNoRefund(reqId, true, 100, "Created"); vm.stopPrank(); // Try to deposit but COA provides wrong ID @@ -1626,7 +1878,7 @@ contract FlowYieldVaultsRequestsTest is Test { 100, // expected 101 // provided (wrong) )); - c.completeProcessing(depositReq, true, 101, "Wrong ID"); + _completeProcessingNoRefund(depositReq, true, 101, "Wrong ID"); vm.stopPrank(); } @@ -1637,7 +1889,7 @@ contract FlowYieldVaultsRequestsTest is Test { vm.startPrank(coa); _startProcessingBatch(reqId); - c.completeProcessing(reqId, true, 100, "Created"); + _completeProcessingNoRefund(reqId, true, 100, "Created"); vm.stopPrank(); vm.prank(user2); @@ -1708,7 +1960,7 @@ contract FlowYieldVaultsRequestsTest is Test { uint64 sentinelYieldVaultId = c.NO_YIELDVAULT_ID(); vm.prank(coa); vm.expectRevert(FlowYieldVaultsRequests.MsgValueMustEqualAmount.selector); - c.completeProcessing{value: 3 ether}(req1, false, sentinelYieldVaultId, "Failed"); + c.completeProcessing{value: 3 ether}(req1, false, sentinelYieldVaultId, "Failed", 5 ether); c.testRegisterYieldVaultId(101, user2, address(dai)); c.setTokenConfig(address(dai), true, 0.5 ether, false); @@ -1724,12 +1976,12 @@ contract FlowYieldVaultsRequestsTest is Test { vm.prank(coa); vm.expectRevert(FlowYieldVaultsRequests.MsgValueMustBeZero.selector); - c.completeProcessing{value: 5 ether}(req2, false, 101, "Failed"); + c.completeProcessing{value: 5 ether}(req2, false, 101, "Failed", 5 ether); // Success case must also reject non-zero msg.value (the `else` branch) vm.prank(coa); vm.expectRevert(FlowYieldVaultsRequests.MsgValueMustBeZero.selector); - c.completeProcessing{value: 1 ether}(req2, true, 101, "Success"); + c.completeProcessing{value: 1 ether}(req2, true, 101, "Success", 0); } function test_CompleteProcessing_RefundNativeFunds() public { @@ -1748,7 +2000,7 @@ contract FlowYieldVaultsRequestsTest is Test { uint64 sentinelYieldVaultId = c.NO_YIELDVAULT_ID(); vm.prank(coa); - c.completeProcessing{value: 5 ether}(reqId, false, sentinelYieldVaultId, "Failed"); + c.completeProcessing{value: 5 ether}(reqId, false, sentinelYieldVaultId, "Failed", 5 ether); assertEq(coa.balance, 0 ether); assertEq(address(c).balance, 5 ether); @@ -1782,7 +2034,7 @@ contract FlowYieldVaultsRequestsTest is Test { vm.startPrank(coa); dai.approve(address(c), 5 ether); - c.completeProcessing(reqId, false, c.NO_YIELDVAULT_ID(), "Failed"); + c.completeProcessing(reqId, false, c.NO_YIELDVAULT_ID(), "Failed", 5 ether); assertEq(dai.balanceOf(coa), 0 ether); assertEq(dai.balanceOf(address(c)), 5 ether); vm.stopPrank(); @@ -1919,7 +2171,7 @@ contract FlowYieldVaultsRequestsTest is Test { vm.startPrank(coa); dai.approve(address(c), 5 ether); - c.completeProcessing(reqId, false, c.NO_YIELDVAULT_ID(), "Failed"); + c.completeProcessing(reqId, false, c.NO_YIELDVAULT_ID(), "Failed", 5 ether); assertEq(dai.balanceOf(coa), 0 ether); assertEq(dai.balanceOf(address(c)), 5 ether); vm.stopPrank();