diff --git a/.github/workflows/e2e_test.yml b/.github/workflows/e2e_test.yml index 6f9baf8..77e0bd6 100644 --- a/.github/workflows/e2e_test.yml +++ b/.github/workflows/e2e_test.yml @@ -18,16 +18,16 @@ jobs: with: token: ${{ secrets.GH_PAT }} submodules: recursive - + - name: Install Flow CLI run: sh -ci "$(curl -fsSL https://raw.githubusercontent.com/onflow/flow-cli/master/install.sh)" - + - name: Update PATH run: echo "$HOME/.local/bin" >> $GITHUB_PATH - + - name: Verify Flow CLI Installation run: flow version - + - name: Initialize submodules run: git submodule update --init --recursive @@ -44,12 +44,12 @@ jobs: run: | chmod +x ./local/setup_and_run_emulator.sh chmod +x ./local/deploy_full_stack.sh - + - name: Setup and Run Emulator run: | ./local/setup_and_run_emulator.sh & sleep 80 # Wait for the emulator to be fully up - + - name: Deploy Full Stack run: | set -o pipefail @@ -120,19 +120,19 @@ jobs: --legacy env: AMOUNT: 10000000000000000000 - + - name: Process Create Request run: | flow transactions send ./cadence/transactions/process_requests.cdc 0 10 --signer emulator-flow-yield-vaults --compute-limit 9999 - + - name: Verify YieldVault Creation run: | echo "=== Verifying YieldVault Creation ===" - + # Check yieldvault details using the account-level script YIELDVAULT_CHECK=$(flow scripts execute ./cadence/scripts/check_yieldvault_details.cdc 0x045a1763c93006ca) echo "$YIELDVAULT_CHECK" - + # Verify that we have at least one EVM address with yieldvaults if echo "$YIELDVAULT_CHECK" | grep -q '"totalEVMAddresses": 1'; then echo "✅ EVM address registered" @@ -140,7 +140,7 @@ jobs: echo "❌ No EVM addresses found" exit 1 fi - + # Verify that we have at least one yieldvault created if echo "$YIELDVAULT_CHECK" | grep -q '"totalMappedYieldVaults": 1'; then echo "✅ YieldVault created successfully" @@ -148,7 +148,7 @@ jobs: echo "❌ No yieldvaults found" exit 1 fi - + # Verify the specific EVM address has the yieldvault if echo "$YIELDVAULT_CHECK" | grep -q '6813eb9362372eef6200f3b1dbc3f819671cba69'; then echo "✅ YieldVault mapped to correct EVM address" @@ -156,9 +156,9 @@ jobs: echo "❌ EVM address mapping not found" exit 1 fi - + echo "✅ Test 1 Passed: Basic yieldvault creation verified" - + # === TEST 2: FULL YIELDVAULT LIFECYCLE === - name: Test 2 - Deposit Additional Funds (20 FLOW) run: | @@ -175,18 +175,18 @@ jobs: --legacy env: AMOUNT: 20000000000000000000 - + - name: Process Deposit Request run: | flow transactions send ./cadence/transactions/process_requests.cdc 0 10 --signer emulator-flow-yield-vaults --compute-limit 9999 - + - name: Verify Deposit run: | echo "Verifying deposit (should still have 1 yieldvault with more balance)..." - + YIELDVAULT_CHECK=$(flow scripts execute ./cadence/scripts/check_yieldvault_details.cdc 0x045a1763c93006ca) echo "$YIELDVAULT_CHECK" - + # Should still have 1 yieldvault if echo "$YIELDVAULT_CHECK" | grep -q '"totalMappedYieldVaults": 1'; then echo "✅ Still has 1 yieldvault after deposit" @@ -194,7 +194,7 @@ jobs: echo "❌ YieldVault count changed unexpectedly" exit 1 fi - + - name: Test 2 - Withdraw Half (15 FLOW) run: | echo "Step 2: Withdrawing 15 FLOW..." @@ -204,18 +204,18 @@ jobs: --rpc-url http://localhost:8545 \ --broadcast \ --legacy - + - name: Process Withdraw Request run: | flow transactions send ./cadence/transactions/process_requests.cdc 0 10 --signer emulator-flow-yield-vaults --compute-limit 9999 - + - name: Verify Withdrawal run: | echo "Verifying withdrawal (should still have 1 yieldvault with less balance)..." - + YIELDVAULT_CHECK=$(flow scripts execute ./cadence/scripts/check_yieldvault_details.cdc 0x045a1763c93006ca) echo "$YIELDVAULT_CHECK" - + # Should still have 1 yieldvault if echo "$YIELDVAULT_CHECK" | grep -q '"totalMappedYieldVaults": 1'; then echo "✅ Still has 1 yieldvault after withdrawal" @@ -223,7 +223,7 @@ jobs: echo "❌ YieldVault count changed unexpectedly" exit 1 fi - + - name: Test 2 - Close YieldVault run: | echo "Step 3: Closing yieldvault (withdrawing remaining funds)..." @@ -233,18 +233,18 @@ jobs: --rpc-url http://localhost:8545 \ --broadcast \ --legacy - + - name: Process Close Request run: | flow transactions send ./cadence/transactions/process_requests.cdc 0 10 --signer emulator-flow-yield-vaults --compute-limit 9999 - + - name: Verify YieldVault Closed run: | echo "Verifying yieldvault was closed..." - + YIELDVAULT_CHECK=$(flow scripts execute ./cadence/scripts/check_yieldvault_details.cdc 0x045a1763c93006ca) echo "$YIELDVAULT_CHECK" - + # After closing, should have 0 yieldvaults or the yieldvault should be marked as closed if echo "$YIELDVAULT_CHECK" | grep -q '"totalMappedYieldVaults": 0'; then echo "✅ YieldVault successfully closed and removed" @@ -254,9 +254,9 @@ jobs: echo "⚠️ YieldVault may still exist but should be in closed state" # Don't fail here as the close transaction succeeded fi - + echo "✅ Test 2 Passed: Full yieldvault lifecycle completed" - + # === FINAL SUMMARY === - name: Test Summary run: | diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index e67dee4..14bc0df 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -17,19 +17,19 @@ jobs: with: token: ${{ secrets.GH_PAT }} submodules: recursive - + - name: Install Flow CLI run: sh -ci "$(curl -fsSL https://raw.githubusercontent.com/onflow/flow-cli/master/install.sh)" - + - name: Update PATH run: echo "$HOME/.local/bin" >> $GITHUB_PATH - + - name: Verify Flow CLI Installation run: flow version - + - name: Make test script executable run: chmod +x ./local/run_cadence_tests.sh - + - name: Run Cadence Tests run: ./local/run_cadence_tests.sh diff --git a/.github/workflows/worker_tests.yml b/.github/workflows/worker_tests.yml new file mode 100644 index 0000000..00cb304 --- /dev/null +++ b/.github/workflows/worker_tests.yml @@ -0,0 +1,86 @@ +name: Worker Operations CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + worker-tests: + name: Worker Operations Tests + runs-on: ubuntu-latest + steps: + # === COMMON SETUP === + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GH_PAT }} + submodules: recursive + + - name: Install Flow CLI + run: sh -ci "$(curl -fsSL https://raw.githubusercontent.com/onflow/flow-cli/master/install.sh)" + + - name: Update PATH + run: echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Verify Flow CLI Installation + run: flow version + + - name: Initialize submodules + run: git submodule update --init --recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: stable + + - name: Install Solidity dependencies + working-directory: ./solidity + run: forge install --no-git + + - name: Make scripts executable + run: | + chmod +x ./local/setup_and_run_emulator.sh + chmod +x ./local/deploy_full_stack.sh + chmod +x ./local/run_worker_tests.sh + + # === INFRASTRUCTURE SETUP === + - name: Setup and Run Emulator + run: | + ./local/setup_and_run_emulator.sh & + sleep 80 # Wait for the emulator to be fully up + + - name: Deploy Full Stack + run: ./local/deploy_full_stack.sh + + - name: Detect Strategy Identifier + run: | + echo "Detecting supported strategy identifier..." + YIELDVAULT_CHECK=$(flow scripts execute ./cadence/scripts/check_yieldvault_details.cdc 0x045a1763c93006ca) + echo "$YIELDVAULT_CHECK" + + SUPPORTED_STRATEGIES=$(echo "$YIELDVAULT_CHECK" | grep -oE '"supportedStrategies": \[[^]]*\]' || true) + if [ -z "$SUPPORTED_STRATEGIES" ]; then + echo "❌ Could not parse supported strategy list" + exit 1 + fi + + STRATEGY_LIST=$(echo "$SUPPORTED_STRATEGIES" | sed -E 's/^"supportedStrategies": \[(.*)\]$/\1/' | tr -d '"' | tr ',' '\n' | sed 's/^ *//;s/ *$//' | sed '/^$/d') + STRATEGY_IDENTIFIER=$(echo "$STRATEGY_LIST" | grep 'TracerStrategy' | head -n 1 || true) + if [ -z "$STRATEGY_IDENTIFIER" ]; then + STRATEGY_IDENTIFIER=$(echo "$STRATEGY_LIST" | head -n 1) + fi + + if [ -z "$STRATEGY_IDENTIFIER" ]; then + echo "❌ No supported strategy identifier found" + exit 1 + fi + + echo "Using strategy identifier: $STRATEGY_IDENTIFIER" + echo "STRATEGY_IDENTIFIER=$STRATEGY_IDENTIFIER" >> $GITHUB_ENV + + # === RUN WORKER TESTS === + - name: Run Worker Tests + run: ./local/run_worker_tests.sh diff --git a/.gitignore b/.gitignore index f437f63..b0f07f3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.DS_Store + imports db diff --git a/.gitmodules b/.gitmodules index e105a72..eedcd70 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "lib/FlowYieldVaults"] path = lib/FlowYieldVaults - url = https://github.com/onflow/FlowYieldVaults.git + url = https://github.com/onflow/FlowYieldVaults [submodule "solidity/lib/forge-std"] path = solidity/lib/forge-std url = https://github.com/foundry-rs/forge-std diff --git a/CLAUDE.md b/CLAUDE.md index ae0f9b5..da59ae4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,9 +46,9 @@ flow deps install --skip-alias --skip-deployments # Install dependencies ### Cross-VM Request Flow 1. **EVM User** calls `FlowYieldVaultsRequests.sol` (creates request, escrows funds) -2. **FlowYieldVaultsTransactionHandler.cdc** triggers `Worker.processRequests()` on schedule +2. **FlowYieldVaultsEVMWorkerOps.cdc** SchedulerHandler schedules WorkerHandlers to process requests 3. **FlowYieldVaultsEVM.cdc** Worker fetches pending requests via `getPendingRequestsUnpacked()` -4. **Two-phase commit**: `startProcessing()` 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 (refunds credited to `claimableRefunds` on failure) ### Contract Components @@ -56,15 +56,16 @@ flow deps install --skip-alias --skip-deployments # Install dependencies | --------------------------------------- | -------------------- | ----------------------------------- | | `FlowYieldVaultsRequests.sol` | `solidity/src/` | EVM request queue + fund escrow | | `FlowYieldVaultsEVM.cdc` | `cadence/contracts/` | Cadence worker processing requests | -| `FlowYieldVaultsTransactionHandler.cdc` | `cadence/contracts/` | Auto-scheduler with adaptive delays | +| `FlowYieldVaultsEVMWorkerOps.cdc` | `cadence/contracts/` | SchedulerHandler + WorkerHandler orchestration | ### Key Design Patterns - **COA Bridge**: Cadence Owned Account bridges funds between EVM and Cadence via FlowEVMBridge - **Sentinel Values**: `NATIVE_FLOW = 0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF`, `NO_YIELDVAULT_ID = type(uint64).max` -- **Ownership Tracking**: Parallel mappings on both EVM (`userOwnsYieldVault`) and Cadence (`yieldVaultRegistry`) for O(1) lookups -- **Adaptive Scheduling**: TransactionHandler adjusts delay based on pending count (3s for >10, 5s for >=5, 7s for >=1, 30s idle) -- **Dynamic Execution Effort**: `baseEffortPerRequest * maxRequestsPerTx + baseOverhead` +- **Ownership Tracking**: Parallel mappings on both EVM (`userOwnsYieldVault`) and Cadence (`yieldVaultOwnershipLookup`) for O(1) lookups +- **Scheduler/Worker Split**: SchedulerHandler runs at fixed interval, schedules WorkerHandlers for individual requests +- **Batch Preprocessing**: SchedulerHandler validates requests before scheduling workers; invalid requests fail early +- **Crash Recovery**: SchedulerHandler monitors WorkerHandler transactions and marks panicked requests as FAILED ### Request Types (must stay synchronized between contracts) @@ -114,7 +115,42 @@ flow deps install --skip-alias --skip-deployments # Install dependencies | --------------------------------- | -------------------------------------------- | | FlowYieldVaultsRequests (EVM) | `0xF633C9dBf1a3964a895fCC4CA4404B6f8BA8141d` | | FlowYieldVaultsEVM (Cadence) | `df111ffc5064198a` | -| FlowYieldVaultsTransactionHandler | `df111ffc5064198a` | +| FlowYieldVaultsEVMWorkerOps | `df111ffc5064198a` | + +## Blockchain Execution Model (Critical for Code Review) + +When reviewing this codebase, keep these fundamental blockchain properties in mind: + +### Transaction Atomicity + +**All blockchain transactions are atomic.** If any operation within a transaction panics/reverts, ALL state changes made during that transaction are rolled back completely. There is no "partial completion" scenario. + +- In Cadence: `panic()` reverts all state changes in the transaction +- In Solidity: `revert()` or failed `require()` reverts all state changes +- This is **by design** and is the correct way to ensure data consistency + +Therefore, patterns like: +```cadence +// This is SAFE - if processRequest panics, the remove never happened +scheduledRequests.remove(key: requestId) +processResult = worker.processRequest(request) // if this panics, the line above reverts too +``` + +### Sequential Execution (No On-Chain Race Conditions) + +**Blockchain transactions execute one at a time in a deterministic order.** There is no parallel execution within the same blockchain execution environment. + +- Transactions are ordered within blocks and executed sequentially +- Two transactions cannot "race" against each other simultaneously +- What might look like a "race condition" is actually just transaction ordering, which is well-defined behavior + +This means scenarios like "Transaction A completes but Transaction B sees stale state" are **impossible** within the same execution context. By the time Transaction B executes, Transaction A has either fully committed or fully reverted. + +### Implications for This Codebase + +1. **WorkerHandler/SchedulerHandler coordination** is safe because they run in separate transactions that execute sequentially +2. **Panic-based error handling** in `processRequest()` is the correct pattern - it ensures atomicity across Cadence and EVM state +3. **State removal before vs after processing** doesn't create race conditions - if processing fails, the entire transaction (including removal) reverts ## Dependencies diff --git a/FLOW_YIELD_VAULTS_EVM_BRIDGE_DESIGN.md b/FLOW_YIELD_VAULTS_EVM_BRIDGE_DESIGN.md index f471d64..74bf117 100644 --- a/FLOW_YIELD_VAULTS_EVM_BRIDGE_DESIGN.md +++ b/FLOW_YIELD_VAULTS_EVM_BRIDGE_DESIGN.md @@ -28,7 +28,7 @@ EVM users deposit FLOW and submit requests to a Solidity contract. A Cadence wor │ │ │ └─────────────────────────────────────────┼───────────────────────────────────┘ │ COA calls: - │ - startProcessing() + │ - startProcessingBatch() │ - completeProcessing() ┌─────────────────────────────────────────┼───────────────────────────────────┐ │ Flow Cadence │ @@ -46,28 +46,31 @@ EVM users deposit FLOW and submit requests to a Solidity contract. A Cadence wor │ │ │ - feeProviderCap (FungibleToken.Withdraw, FungibleToken.Provider)│ │ │ │ │ │ │ │ │ │ │ │ Functions: │ │ │ -│ │ │ - processRequests() │ │ │ -│ │ │ - processCreateYieldVault() │ │ │ -│ │ │ - processDepositToYieldVault() │ │ │ -│ │ │ - processWithdrawFromYieldVault() │ │ │ -│ │ │ - processCloseYieldVault() │ │ │ +│ │ │ - processRequest() │ │ │ +│ │ │ - preprocessRequests() │ │ │ +│ │ │ - markRequestAsFailed() │ │ │ │ │ └─────────────────────────────────────────────────────────────────┘ │ │ │ │ │ │ │ │ State: │ │ │ │ - yieldVaultRegistry: {String: {UInt64: Bool}} │ │ │ │ - flowYieldVaultsRequestsAddress: EVM.EVMAddress? │ │ -│ │ - maxRequestsPerTx: Int (default: 1) │ │ │ └───────────────────────────────────────────────────────────────────────┘ │ │ ▲ │ │ │ triggers │ │ ┌───────────────────────────┴─────────────────────────────────────────┐ │ -│ │ FlowYieldVaultsTransactionHandler │ │ +│ │ FlowYieldVaultsEVMWorkerOps │ │ │ │ │ │ -│ │ - Implements FlowTransactionScheduler.TransactionHandler │ │ -│ │ - Auto-schedules next execution after each run │ │ -│ │ - Adaptive delay based on pending request count │ │ -│ │ - Single scheduled execution │ │ -│ │ - Pausable via Admin resource │ │ +│ │ ┌─────────────────────┐ ┌─────────────────────────────────────┐ │ │ +│ │ │ SchedulerHandler │───▶│ WorkerHandler │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ - Recurrent job │ │ - Processes single request │ │ │ +│ │ │ - Schedules workers│ │ - Finalizes status on EVM │ │ │ +│ │ │ - Crash recovery │ │ - Removes from scheduledRequests │ │ │ +│ │ │ - Preprocessing │ └─────────────────────────────────────┘ │ │ +│ │ └─────────────────────┘ │ │ +│ │ │ │ +│ │ State: scheduledRequests, isSchedulerPaused │ │ +│ │ Config: schedulerWakeupInterval (1s), maxProcessingRequests (3) │ │ │ └──────────────────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ @@ -123,7 +126,7 @@ Worker contract that processes EVM requests and manages YieldVault positions. **Responsibilities:** - Fetch pending requests from EVM via `getPendingRequestsUnpacked()` -- Execute two-phase commit (startProcessing → operation → completeProcessing) +- Execute two-phase commit (startProcessingBatch → operation → completeProcessing) - Create, deposit to, withdraw from, and close YieldVaults - Bridge funds between EVM and Cadence via COA - Track YieldVault ownership by EVM address @@ -135,36 +138,48 @@ access(all) let yieldVaultRegistry: {String: {UInt64: Bool}} // Configuration (stored as contract-only vars; exposed via getters) var flowYieldVaultsRequestsAddress: EVM.EVMAddress? -var maxRequestsPerTx: Int // Default: 1, max: 100 // Constants access(all) let nativeFlowEVMAddress: EVM.EVMAddress // 0xFFfF...FfFFFfF ``` -#### 3. FlowYieldVaultsTransactionHandler (Cadence) +#### 3. FlowYieldVaultsEVMWorkerOps (Cadence) + +Worker orchestration contract with auto-scheduling and crash recovery. -Scheduled transaction handler with auto-scheduling. +**Resources:** +- **WorkerHandler**: Processes individual requests. Scheduled by SchedulerHandler to handle one request at a time. +- **SchedulerHandler**: Recurrent job that checks for pending requests and schedules WorkerHandlers based on available capacity. **Responsibilities:** -- Implement `FlowTransactionScheduler.TransactionHandler` interface -- Trigger Worker's `processRequests()` on scheduled execution -- Auto-schedule next execution based on queue depth -- Dynamic execution effort calculation based on request count +- Implement `FlowTransactionScheduler.TransactionHandler` interface for both handlers +- SchedulerHandler checks for pending requests at fixed intervals +- SchedulerHandler calls `preprocessRequests()` which validates and transitions requests (PENDING → PROCESSING/FAILED) +- SchedulerHandler schedules WorkerHandlers for valid requests returned by `preprocessRequests()` +- SchedulerHandler identifies panicked WorkerHandlers and marks requests as FAILED +- WorkerHandler processes a single request and updates EVM state on completion +- Sequential scheduling for same-user requests to avoid block ordering issues - Pausable for maintenance **Key State:** ```cadence -// Delay configuration (pending count → delay in seconds) -access(contract) var thresholdToDelay: {Int: UFix64} // {11: 3.0, 5: 5.0, 1: 7.0, 0: 30.0} -access(all) let defaultDelay: UFix64 // 30.0 - -// Execution effort parameters -access(contract) var baseEffortPerRequest: UInt64 // Default: 2000 -access(contract) var baseOverhead: UInt64 // Default: 3000 -access(contract) var idleExecutionEffort: UInt64 // Default: 5000 (for Medium priority) +// In-flight request tracking +access(self) var scheduledRequests: {UInt256: ScheduledEVMRequest} // request id → tracking info +access(self) var isSchedulerPaused: Bool + +// Configuration +access(self) var schedulerWakeupInterval: UFix64 // Default: 1.0 seconds +access(self) var maxProcessingRequests: UInt8 // Default: 3 concurrent workers +access(all) let executionEffortConstants: {String: UInt64} // Configurable execution effort values +``` -// Control -access(contract) var isPaused: Bool +**ScheduledEVMRequest:** +```cadence +access(all) struct ScheduledEVMRequest { + access(all) let request: FlowYieldVaultsEVM.EVMRequest + access(all) let workerTransactionId: UInt64 + access(all) let workerScheduledTimestamp: UFix64 +} ``` #### 4. COA (Cadence Owned Account) @@ -277,7 +292,7 @@ access(all) struct ProcessResult { │ │ [EVMRequest] │ │ │ │───────────────────────▶│ │ │ │ │ │ - │ │ startProcessing(id) │ │ + │ │ startProcessingBatch([id], []) │ │ │ │◀───────────────────────│ │ │ │ Mark PROCESSING │ │ │ │ Deduct user balance │ │ @@ -314,7 +329,7 @@ access(all) struct ProcessResult { 3. Contract escrows funds, creates PENDING request 4. Worker fetches request via getPendingRequestsUnpacked() 5. Worker does not require ownership for deposits (permissionless) -6. Worker calls startProcessing() → PROCESSING, balance deducted +6. Worker calls startProcessingBatch() → PROCESSING, balance deducted 7. COA withdraws funds from its balance 8. Worker deposits to YieldVault via YieldVaultManager 9. Worker calls completeProcessing() → COMPLETED @@ -328,7 +343,7 @@ access(all) struct ProcessResult { 3. Contract creates PENDING request (no escrow needed) 4. Worker fetches request via getPendingRequestsUnpacked() 5. Worker validates YieldVault ownership -6. Worker calls startProcessing() → PROCESSING +6. Worker calls startProcessingBatch() → PROCESSING 7. Worker withdraws from YieldVault via YieldVaultManager 8. Worker bridges funds to EVM via COA.deposit() 9. COA transfers $FLOW directly to user's EVM address @@ -343,7 +358,7 @@ access(all) struct ProcessResult { 3. Contract creates PENDING request (amount = 0) 4. Worker fetches request via getPendingRequestsUnpacked() 5. Worker validates YieldVault ownership -6. Worker calls startProcessing() → PROCESSING +6. Worker calls startProcessingBatch() → PROCESSING 7. Worker closes YieldVault via YieldVaultManager, receives all funds 8. Worker bridges funds to EVM via COA.deposit() 9. COA transfers all $FLOW to user's EVM address @@ -369,7 +384,7 @@ All refund scenarios use a pull pattern - funds are credited to `claimableRefund | Scenario | What Happens | |----------|--------------| -| After `startProcessing()` (failed CREATE/DEPOSIT) | Funds credited to `claimableRefunds` | +| After `startProcessingBatch()` (failed CREATE/DEPOSIT) | Funds 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 | @@ -382,14 +397,15 @@ All refund scenarios use a pull pattern - funds are credited to `claimableRefund The bridge uses a two-phase commit pattern for atomic state management: -### Phase 1: startProcessing() +### Phase 1: startProcessingBatch() ```solidity -function startProcessing(uint256 requestId) external onlyAuthorizedCOA { - // 1. Validate request exists and is PENDING - // 2. Mark as PROCESSING - // 3. For CREATE_YIELDVAULT/DEPOSIT_TO_YIELDVAULT: Deduct user balance and transfer to COA - // 4. Emit RequestProcessed event +function startProcessingBatch(uint256[] calldata successfulRequestIds, uint256[] calldata rejectedRequestIds) external onlyAuthorizedCOA { + // 1. Mark rejectedRequestIds as FAILED + // 2. Validate each successful request exists and is PENDING + // 3. Mark successful requests as PROCESSING + // 4. For CREATE_YIELDVAULT/DEPOSIT_TO_YIELDVAULT: Deduct user balance and transfer to COA + // 5. Emit RequestProcessed events } ``` @@ -418,62 +434,87 @@ function completeProcessing( --- -## Adaptive Scheduling +## Scheduling & Request Processing + +### SchedulerHandler Workflow + +The SchedulerHandler runs at a fixed interval (`schedulerWakeupInterval`, default 1 second) and performs the following: + +1. **Check if paused** - Skip scheduling if `isSchedulerPaused` is true +2. **Crash recovery** - Identify WorkerHandlers that panicked and mark their requests as FAILED +3. **Check capacity** - Calculate available slots: `maxProcessingRequests - scheduledRequests.length` +4. **Fetch pending requests** - Get up to `capacity` pending requests from EVM +5. **Preprocess requests** - Call `preprocessRequests()` which validates each request, fails invalid ones, and transitions valid ones to PROCESSING +6. **Schedule workers** - Create WorkerHandler transactions for each valid request +7. **Auto-reschedule** - Schedule next SchedulerHandler execution + +### WorkerHandler Workflow -### Delay Thresholds +Each WorkerHandler is scheduled to process a single request: -| Pending Requests | Delay (seconds) | Description | -|------------------|-----------------|-------------| -| >= 11 | 3 | High load - rapid processing | -| >= 5 | 5 | Medium load | -| >= 1 | 7 | Low load | -| 0 | 30 | Idle - minimal overhead | +1. **Process request** - Call `worker.processRequest(request)` which handles the actual operation +2. **Remove from tracking** - Remove request from `scheduledRequests` dictionary +3. **Finalize on EVM** - The worker calls `completeProcessing()` to mark COMPLETED or FAILED -### Scheduling Logic +### Sequential Same-User Scheduling + +When multiple requests from the same EVM user are pending, they are scheduled with sequential delays to ensure ordering: ```cadence -access(all) fun getDelayForPendingCount(_ pendingCount: Int): UFix64 { - // Find highest threshold that pendingCount meets - var bestThreshold: Int? = nil - - for threshold in self.thresholdToDelay.keys { - if pendingCount >= threshold { - if bestThreshold == nil || threshold > bestThreshold! { - bestThreshold = threshold - } - } +// Track user request count for scheduling offset +let userScheduleOffset: {String: Int} = {} +for request in requests { + let key = request.user.toString() + if userScheduleOffset[key] == nil { + userScheduleOffset[key] = 0 } + userScheduleOffset[key] = userScheduleOffset[key]! + 1 + + // Offset delay by user request count + delay = delay + userScheduleOffset[key]! as! UFix64 - return self.thresholdToDelay[bestThreshold] ?? self.defaultDelay + // Schedule with computed delay + // ... } ``` -### Execution Effort Calculation +### Crash Recovery -The handler dynamically calculates execution effort based on the maximum requests per transaction: +The SchedulerHandler monitors scheduled WorkerHandlers for failures: ```cadence -access(all) fun calculateExecutionEffortAndPriority(_ requestCount: Int): {String: AnyStruct} { - let calculated = self.baseEffortPerRequest * UInt64(requestCount) + self.baseOverhead - - // If calculated > 7500, need High priority (max 9999) - // Otherwise use Medium priority (max 7500) - if calculated > 7500 { - let capped = calculated < 9999 ? calculated : 9999 - return { - "effort": capped, - "priority": 0 as UInt8 // High priority - } - } else { - return { - "effort": calculated, - "priority": 1 as UInt8 // Medium priority - } - } -} +// For each scheduled request: +// 1. Check if scheduled timestamp has passed +// 2. Get transaction status from manager +// 3. If status is nil (cleaned up) or not Scheduled, the worker panicked +// 4. Mark request as FAILED with error message +// 5. Remove from scheduledRequests ``` -When idle (no pending requests), the handler uses Medium priority to ensure sufficient computation budget. The execution effort is set to the computed value (based on `maxRequestsPerTx`) but capped at `idleExecutionEffort` (5000, suitable for Medium priority). This ensures efficient handling of burst arrivals while providing adequate computation resources. +### Configuration + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `schedulerWakeupInterval` | 1.0s | Fixed interval between SchedulerHandler executions | +| `maxProcessingRequests` | 3 | Maximum concurrent WorkerHandlers | + +### Execution Effort Constants + +Execution effort values are configurable via the `executionEffortConstants` dictionary and can be updated by the Admin using `setExecutionEffortConstants(key, value)`. + +| Key | Default | Description | +|-----|---------|-------------| +| `schedulerBaseEffort` | 700 | Base effort for SchedulerHandler execution | +| `schedulerPerRequestEffort` | 1000 | Additional effort per request preprocessed | +| `workerCreateYieldVaultRequestEffort` | 5000 | Effort for CREATE_YIELDVAULT requests | +| `workerDepositRequestEffort` | 2000 | Effort for DEPOSIT_TO_YIELDVAULT requests | +| `workerWithdrawRequestEffort` | 2000 | Effort for WITHDRAW_FROM_YIELDVAULT requests | +| `workerCloseYieldVaultRequestEffort` | 5000 | Effort for CLOSE_YIELDVAULT requests | + +Priority is dynamically determined based on execution effort: +- **Low**: effort ≤ 2500 +- **Medium**: 2500 < effort < 7500 +- **High**: effort ≥ 7500 --- @@ -537,9 +578,11 @@ access(all) fun getPendingRequestsForEVMAddress(_ evmAddressHex: String): Pendin // Total pending request count (public query) access(all) fun getPendingRequestCount(): Int -// Handler execution statistics (FlowYieldVaultsTransactionHandler) -access(all) view fun getStats(): {String: AnyStruct} -// Returns: {"executionCount": UInt64, "lastExecutionTime": UFix64?} +// Scheduler paused status (FlowYieldVaultsEVMWorkerOps) +access(all) view fun getIsSchedulerPaused(): Bool + +// Execution effort constants (FlowYieldVaultsEVMWorkerOps) +access(all) let executionEffortConstants: {String: UInt64} ``` --- @@ -556,7 +599,7 @@ access(all) view fun getStats(): {String: AnyStruct} | FlowYieldVaultsRequests | `notBlocklisted` | Optional blacklist for users | | FlowYieldVaultsRequests | `whenNotPaused` | New request creation blocked when paused | | FlowYieldVaultsEVM | Capability-based | Worker requires valid COA, YieldVaultManager, BetaBadge caps | -| FlowYieldVaultsTransactionHandler | Admin resource | Pause/unpause restricted to Admin holder | +| FlowYieldVaultsEVMWorkerOps | Admin resource | Pause/unpause scheduler restricted to Admin holder | ### YieldVault Ownership Verification @@ -641,7 +684,6 @@ pre { | `YieldVaultWithdrawnForEVMUser` | Withdrawal from YieldVault | | `YieldVaultClosedForEVMUser` | YieldVault closed | | `RequestFailed` | Request processing failed | -| `MaxRequestsPerTxUpdated` | Configuration changed | | `WithdrawFundsFromEVMFailed` | Failed to withdraw funds from EVM | | `EVMAllowlistStatusChanged` | Allowlist status changed on EVM | | `EVMAllowlistUpdated` | Addresses added/removed from allowlist on EVM | @@ -653,18 +695,17 @@ pre { | `EVMRequestsDropped` | Requests dropped on EVM | | `EVMRequestCancelled` | Request cancelled on EVM | -### FlowYieldVaultsTransactionHandler (Cadence) +### FlowYieldVaultsEVMWorkerOps (Cadence) | Event | Description | |-------|-------------| -| `HandlerPaused` | Processing paused | -| `HandlerUnpaused` | Processing resumed | -| `ScheduledExecutionTriggered` | Handler executed | -| `NextExecutionScheduled` | Next execution scheduled | -| `ExecutionSkipped` | Execution skipped (paused or error) | -| `AllExecutionsStopped` | All executions cancelled and fees refunded | -| `ThresholdToDelayUpdated` | Threshold config change | -| `ExecutionEffortParamsUpdated` | Execution effort parameters changed | +| `SchedulerPaused` | Scheduler paused - no new workers scheduled | +| `SchedulerUnpaused` | Scheduler resumed | +| `WorkerHandlerExecuted` | WorkerHandler processed a request (includes result) | +| `SchedulerHandlerExecuted` | SchedulerHandler completed execution cycle | +| `WorkerHandlerPanicDetected` | WorkerHandler panicked, request marked as FAILED | +| `WorkerHandlerScheduled` | WorkerHandler scheduled to process a request | +| `AllExecutionsStopped` | All scheduled executions cancelled and fees refunded | --- @@ -683,7 +724,7 @@ pre { | `RequestNotFound` | Invalid request ID | | `NotRequestOwner` | Cancelling another user's request | | `CanOnlyCancelPending` | Cancelling non-pending request | -| `RequestAlreadyFinalized` | Processing completed request | +| `InvalidRequestState` | Request is not in correct state | | `InsufficientBalance` | Not enough funds | | `BelowMinimumBalance` | Deposit below minimum | | `TooManyPendingRequests` | User at limit | @@ -731,18 +772,20 @@ function dropRequests(uint256[] calldata requestIds) external onlyOwner; // Admin resource functions access(all) fun setFlowYieldVaultsRequestsAddress(_ address: EVM.EVMAddress) access(all) fun updateFlowYieldVaultsRequestsAddress(_ address: EVM.EVMAddress) -access(all) fun updateMaxRequestsPerTx(_ newMax: Int) // 1-100 access(all) fun createWorker(...): @Worker ``` -#### FlowYieldVaultsTransactionHandler +#### FlowYieldVaultsEVMWorkerOps ```cadence // Admin resource functions -access(all) fun pause() -access(all) fun unpause() -access(all) fun setThresholdToDelay(newThresholds: {Int: UFix64}) -access(all) fun setExecutionEffortParams(baseEffortPerRequest: UInt64, baseOverhead: UInt64, idleExecutionEffort: UInt64) +access(all) fun pauseScheduler() // Stop scheduling new workers (in-flight workers continue) +access(all) fun unpauseScheduler() // Resume scheduling +access(all) fun setMaxProcessingRequests(maxProcessingRequests: UInt8) // Set max concurrent workers +access(all) fun setExecutionEffortConstants(key: String, value: UInt64) // Update execution effort +access(all) fun setSchedulerWakeupInterval(schedulerWakeupInterval: UFix64) // Set scheduler interval +access(all) fun createWorkerHandler(workerCap: ...) -> @WorkerHandler +access(all) fun createSchedulerHandler(workerCap: ...) -> @SchedulerHandler access(all) fun stopAll() // Emergency: pause + cancel all scheduled executions with refunds ``` @@ -778,12 +821,12 @@ access(all) fun stopAll() // Emergency: pause + cancel all scheduled executions 1. Deploy `FlowYieldVaultsRequests` on EVM with COA and WFLOW addresses 2. Deploy `FlowYieldVaultsEVM` on Cadence -3. Deploy `FlowYieldVaultsTransactionHandler` on Cadence +3. Deploy `FlowYieldVaultsEVMWorkerOps` on Cadence 4. Configure `FlowYieldVaultsEVM` with EVM contract address -5. Create Worker with required capabilities -6. Create Handler with Worker capability -7. Register Handler with FlowTransactionScheduler -8. Schedule initial execution +5. Create Worker with required capabilities (COA, YieldVaultManager, BetaBadge, FeeProvider) +6. Create WorkerHandler and SchedulerHandler via WorkerOps Admin +7. Register handlers with FlowTransactionScheduler Manager +8. Schedule initial WorkerHandler (for registration) and SchedulerHandler --- @@ -795,3 +838,4 @@ access(all) fun stopAll() // Emergency: pause + cancel all scheduled executions | 2.0 | - | Added two-phase commit | | 3.0 | Nov 2025 | Adaptive scheduling, O(1) ownership lookup | | 3.1 | Dec 2025 | Removed parallel processing, added dynamic execution effort calculation | +| 3.2 | Feb 2026 | Refactored preprocessing into `preprocessRequests()`, WorkerHandler fetches request by ID | diff --git a/FRONTEND_INTEGRATION.md b/FRONTEND_INTEGRATION.md index 312bc2a..97026e7 100644 --- a/FRONTEND_INTEGRATION.md +++ b/FRONTEND_INTEGRATION.md @@ -237,7 +237,7 @@ const userRequests = ids.filter( | Scenario | What Happens | User Action | | ------------------------- | --------------------------------------------- | -------------------------------- | | Request cancelled by user | CREATE/DEPOSIT funds → `claimableRefunds` | Call `claimRefund(tokenAddress)` | -| Request dropped by admin | CREATE/DEPOSIT funds → `claimableRefunds` | Call `claimRefund(tokenAddress)` | +| Request dropped | CREATE/DEPOSIT funds → `claimableRefunds` | Call `claimRefund(tokenAddress)` | | Cadence processing fails | CREATE/DEPOSIT funds → `claimableRefunds` | Call `claimRefund(tokenAddress)` | **Important:** `claimRefund()` only withdraws actual refunds. It does NOT touch funds escrowed for active pending requests. WITHDRAW/CLOSE requests never escrow funds and never generate refunds. @@ -315,7 +315,7 @@ contract.on("RefundClaimed", (user, tokenAddress, amount) => { ```typescript // BalanceUpdated fires when escrowed balance (pendingUserBalances) changes -// This happens on: request creation, startProcessing, cancelRequest, dropRequests +// This happens on: request creation, startProcessingBatch, cancelRequest, dropRequests contract.on("BalanceUpdated", (user, tokenAddress, newBalance) => { if (user.toLowerCase() === currentUser.toLowerCase()) { // Update UI with new escrowed balance for active pending requests @@ -350,7 +350,7 @@ fcl.config({ }); // Contract addresses (testnet) -const FLOW_YIELD_VAULTS_EVM_ADDRESS = "0xdf111ffc5064198a"; // FlowYieldVaultsEVM +const FLOW_YIELD_VAULTS_EVM_ADDRESS = "0x764bdff06a0ee77e"; // FlowYieldVaultsEVM const FLOW_YIELD_VAULTS_ADDRESS = "0xd2580caf2ef07c2f"; // FlowYieldVaults ``` @@ -358,7 +358,7 @@ const FLOW_YIELD_VAULTS_ADDRESS = "0xd2580caf2ef07c2f"; // FlowYieldVaults ```typescript const GET_USER_YIELDVAULTS = ` -import FlowYieldVaultsEVM from 0xdf111ffc5064198a +import FlowYieldVaultsEVM from 0x764bdff06a0ee77e access(all) fun main(evmAddress: String): [UInt64] { var normalizedAddress = evmAddress.toLower() @@ -498,7 +498,7 @@ const strategies = await fcl.query({ cadence: GET_SUPPORTED_STRATEGIES }); ```typescript const CHECK_SYSTEM_STATUS = ` -import FlowYieldVaultsEVM from 0xdf111ffc5064198a +import FlowYieldVaultsEVM from 0x764bdff06a0ee77e access(all) fun main(): {String: AnyStruct} { return { diff --git a/README.md b/README.md index 5e364a7..e7d081d 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,8 @@ This bridge allows EVM users to interact with Flow YieldVaults (yield-generating │ └───────────────────────────────────────────────────────────────────────┘ │ │ ▲ │ │ ┌───────────────────────────┴─────────────────────────────────────────┐ │ -│ │ FlowYieldVaultsTransactionHandler │ │ +│ │ FlowYieldVaultsEVMWorkerOps │ │ +│ │ SchedulerHandler ──schedules──▶ WorkerHandler (per request) │ │ │ │ (Auto-scheduling with FlowTransactionScheduler) │ │ │ └─────────────────────────────────────────────────────────────────────┘ │ │ │ @@ -44,7 +45,7 @@ This bridge allows EVM users to interact with Flow YieldVaults (yield-generating |-----------|-------------| | **FlowYieldVaultsRequests** (Solidity) | Request queue and fund escrow on EVM. Accepts user requests and holds deposited funds until processed. | | **FlowYieldVaultsEVM** (Cadence) | Worker contract that processes EVM requests, manages YieldVault positions, and bridges funds via COA. | -| **FlowYieldVaultsTransactionHandler** (Cadence) | Auto-scheduling handler that triggers request processing at adaptive intervals based on queue depth. | +| **FlowYieldVaultsEVMWorkerOps** (Cadence) | Orchestration contract with SchedulerHandler (checks queue, schedules workers) and WorkerHandler (processes individual requests). Includes crash recovery for panicked workers. | | **COA** (Cadence Owned Account) | Bridge account controlled by the Worker that moves funds between EVM and Cadence. | ## Supported Operations @@ -60,13 +61,11 @@ This bridge allows EVM users to interact with Flow YieldVaults (yield-generating 1. **User submits request** on EVM with optional fund deposit 2. **FlowYieldVaultsRequests** escrows funds and queues the request -3. **FlowYieldVaultsTransactionHandler** triggers `worker.processRequests()` at scheduled intervals -4. **Worker.processRequests()** fetches pending requests from EVM via `getPendingRequestsUnpacked()` -5. **For each request**, two-phase commit: - - `startProcessing()`: Marks request as PROCESSING, deducts user balance (for CREATE_YIELDVAULT/DEPOSIT_TO_YIELDVAULT) +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`) -6. **Funds bridged** to user on withdrawal/close operations +5. **Funds bridged** to user on withdrawal/close operations ## Quick Start @@ -100,6 +99,7 @@ Recommended sequence (run from repo root): 2. `./local/deploy_full_stack.sh` 3. `./local/run_e2e_tests.sh` 4. `./local/run_admin_e2e_tests.sh` +5. `./local/run_worker_tests.sh` Notes: - These scripts expect `flow`, `forge`, `cast`, `curl`, `bc`, `lsof`, and `git` on PATH. @@ -111,6 +111,7 @@ Local script reference: - `./local/deploy_full_stack.sh`: Funds local EVM EOAs, deploys `FlowYieldVaultsRequests` to the local EVM, deploys Cadence contracts, sets up the Worker, and writes `./local/.deployed_contract_address`. - `./local/run_e2e_tests.sh`: Runs end-to-end user flows (create/deposit/withdraw/close/cancel). Requires emulator/gateway running and a deployed contract address. - `./local/run_admin_e2e_tests.sh`: Runs end-to-end admin flows (allowlist/blocklist, token config, max requests, admin cancel/drop). Requires emulator/gateway running and a deployed contract address. +- `./local/run_worker_tests.sh`: Runs scheduled worker tests (SchedulerHandler, WorkerHandler, pause/unpause, crash recovery). Requires emulator/gateway running and a deployed contract address. - `./local/run_cadence_tests.sh`: Runs Cadence tests with `flow test`. Cleans `./db` and `./imports` first (stop emulator if you need to preserve state). - `./local/run_solidity_tests.sh`: Runs Solidity tests with `forge test`. - `./local/testnet-e2e.sh`: Testnet CLI for state checks and user/admin actions. Run `./local/testnet-e2e.sh --help` for commands. Uses `PRIVATE_KEY` and `TESTNET_RPC_URL` if set; admin commands require `testnet-account` in `flow.json`. Update the hardcoded `CONTRACT` address in the script when deploying a new version. @@ -188,7 +189,7 @@ forge script ./solidity/script/FlowYieldVaultsYieldVaultOperations.s.sol:FlowYie |---------|----------|---------| | Testnet | FlowYieldVaultsRequests | `0xF633C9dBf1a3964a895fCC4CA4404B6f8BA8141d` | | Testnet | FlowYieldVaultsEVM | Deployed on Cadence | -| Testnet | FlowYieldVaultsTransactionHandler | Deployed on Cadence | +| Testnet | FlowYieldVaultsEVMWorkerOps | Deployed on Cadence | Source of truth for published addresses: `deployments/contract-addresses.json`. @@ -249,27 +250,13 @@ Testnet E2E uses `deployments/contract-addresses.json` to auto-load addresses (s | `maxPendingRequestsPerUser` | 10 | Max pending requests per user (0 = unlimited) | | `minimumBalance` | 1 FLOW | Minimum deposit for native $FLOW | -### FlowYieldVaultsEVM (Cadence) +### FlowYieldVaultsEVMWorkerOps (Cadence) | Parameter | Default | Description | |-----------|---------|-------------| -| `maxRequestsPerTx` | 1 | Requests processed per transaction (1-100) | +| `schedulerWakeupInterval` | 1.0s | Fixed interval between scheduler executions | +| `maxProcessingRequests` | 3 | Maximum concurrent WorkerHandlers | -### FlowYieldVaultsTransactionHandler (Cadence) - -| Pending Requests | Delay | Description | -|------------------|-------|-------------| -| ≥11 | 3s | High load | -| ≥5 | 5s | Medium load | -| ≥1 | 7s | Low load | -| 0 | 30s | Idle | - -| Parameter | Default | Description | -|-----------|---------|-------------| -| `isPaused` | false | Pause/resume processing | -| `baseEffortPerRequest` | 2000 | Execution effort per request | -| `baseOverhead` | 3000 | Base overhead for transactions | -| `idleExecutionEffort` | 5000 | Max effort cap when idle (for Medium priority) | ## Security diff --git a/TESTING.md b/TESTING.md index be8fb50..daf3744 100644 --- a/TESTING.md +++ b/TESTING.md @@ -31,7 +31,7 @@ solidity/test/ **Test Categories**: - User request lifecycle - 7 tests - Claim refunds - 4 tests -- COA processing (startProcessing/completeProcessing) - 7 tests +- COA processing (startProcessingBatch/completeProcessing) - 7 tests - Admin functions - 6 tests - Ownership transfer - 4 tests - Access control (allowlist/blocklist) - 3 tests @@ -68,6 +68,7 @@ local/ ├── deploy_full_stack.sh # Funds local EOAs, deploys EVM contract, configures Cadence Worker ├── run_e2e_tests.sh # End-to-end user flows (create/deposit/withdraw/close/cancel) ├── run_admin_e2e_tests.sh # End-to-end admin flows (allowlist/blocklist/token config/max requests) +├── run_worker_tests.sh # Scheduled worker tests ├── run_cadence_tests.sh # Wrapper for flow test (cleans db/imports) ├── run_solidity_tests.sh # Wrapper for forge test ├── testnet-e2e.sh # Testnet CLI for state checks + user/admin actions @@ -116,6 +117,15 @@ for test in cadence/tests/*_test.cdc; do done ``` +### Scheduled Worker E2E (Emulator) + +```bash +# Full local sequence +./local/setup_and_run_emulator.sh +./local/deploy_full_stack.sh +./local/run_worker_tests.sh +``` + ### Local E2E (Emulator) ```bash @@ -149,7 +159,7 @@ Set `CONTRACT`/`CADENCE_CONTRACT` or update `deployments/contract-addresses.json |----------|-------|-----------| | User request lifecycle | 7 | CREATE/DEPOSIT/WITHDRAW/CLOSE, cancel | | Claim refunds | 4 | claimRefund flow, balances, events | -| COA processing | 7 | startProcessing/completeProcessing authorization and state | +| COA processing | 7 | startProcessingBatch/completeProcessing authorization and state | | Admin functions | 6 | COA, token config, max requests, dropRequests | | Ownership transfer | 4 | Two-step ownership, admin rights | | Access control | 3 | Allowlist/blocklist enforcement | @@ -168,7 +178,7 @@ Set `CONTRACT`/`CADENCE_CONTRACT` or update `deployments/contract-addresses.json **Key Validations**: - Request IDs increment, pending balances track escrow, refunds are claimable - Only authorized COA can start/complete processing -- Two-phase commit (startProcessing → completeProcessing) maintains consistency +- Two-phase commit (startProcessingBatch → completeProcessing) maintains consistency - Allowlist/blocklist and admin controls enforce access - FIFO order and per-user indexes remain consistent after removals diff --git a/cadence/contracts/FlowYieldVaultsEVM.cdc b/cadence/contracts/FlowYieldVaultsEVM.cdc index 17b7723..5611b57 100644 --- a/cadence/contracts/FlowYieldVaultsEVM.cdc +++ b/cadence/contracts/FlowYieldVaultsEVM.cdc @@ -16,14 +16,15 @@ import "FlowEVMBridgeConfig" /// /// Key architecture: /// - Worker resource: Holds COA capability and YieldVaultManager, processes requests -/// - Admin resource: Manages contract configuration (requests address, batch size) -/// - Two-phase processing: Uses startProcessing() and completeProcessing() to coordinate EVM and Cadence state +/// - Admin resource: Manages contract configuration (requests address) +/// - Two-phase processing: Uses preprocessRequests() and processRequests() to coordinate EVM and Cadence state /// -/// Request flow: -/// 1. Worker fetches pending requests from FlowYieldVaultsRequests (EVM) -/// 2. For each request, calls startProcessing() to mark as PROCESSING (deducts escrow for CREATE/DEPOSIT) -/// 3. Executes Cadence-side operation (create/deposit/withdraw/close YieldVault) -/// 4. Calls completeProcessing() to mark as COMPLETED or FAILED (refunds escrow for CREATE/DEPOSIT failures) +/// Request flow (two-phase): +/// 1. Preprocessing: preprocessRequests() validates requests and calls startProcessingBatch() to +/// 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) access(all) contract FlowYieldVaultsEVM { // ============================================ @@ -57,7 +58,7 @@ access(all) contract FlowYieldVaultsEVM { access(all) let status: UInt8 access(all) let tokenAddress: EVM.EVMAddress access(all) let amount: UInt256 - access(all) let yieldVaultId: UInt64 + access(all) let yieldVaultId: UInt64? access(all) let timestamp: UInt256 access(all) let message: String access(all) let vaultIdentifier: String @@ -70,7 +71,7 @@ access(all) contract FlowYieldVaultsEVM { status: UInt8, tokenAddress: EVM.EVMAddress, amount: UInt256, - yieldVaultId: UInt64, + yieldVaultId: UInt64?, timestamp: UInt256, message: String, vaultIdentifier: String, @@ -100,7 +101,12 @@ access(all) contract FlowYieldVaultsEVM { self.status = status self.tokenAddress = tokenAddress self.amount = amount - self.yieldVaultId = yieldVaultId + // EVM contract uses UInt64.max as sentinel for "no yieldvault" + if yieldVaultId == nil || yieldVaultId! == UInt64.max { + self.yieldVaultId = nil + } else { + self.yieldVaultId = yieldVaultId + } self.timestamp = timestamp self.message = message self.vaultIdentifier = vaultIdentifier @@ -108,18 +114,13 @@ access(all) contract FlowYieldVaultsEVM { } } - /// @notice Sentinel value for "no yieldvault" in ProcessResult - /// @dev Uses UInt64.max as sentinel since yieldVaultId can legitimately be 0 - access(all) let noYieldVaultId: UInt64 - /// @notice Result of processing a single request - /// @dev yieldVaultId uses UInt64.max as sentinel for "no yieldvault" since valid Ids can be 0 access(all) struct ProcessResult { access(all) let success: Bool - access(all) let yieldVaultId: UInt64 + access(all) let yieldVaultId: UInt64? access(all) let message: String - init(success: Bool, yieldVaultId: UInt64, message: String) { + init(success: Bool, yieldVaultId: UInt64?, message: String) { self.success = success self.yieldVaultId = yieldVaultId self.message = message @@ -152,11 +153,6 @@ access(all) contract FlowYieldVaultsEVM { /// @dev Uses recognizable pattern (all F's) matching FlowYieldVaultsRequests.sol NATIVE_FLOW constant access(all) let nativeFlowEVMAddress: EVM.EVMAddress - /// @notice Maximum requests to process per transaction - /// @dev Configurable by Admin for performance tuning. Higher values increase throughput - /// but risk hitting gas limits. Recommended range: 5-50. - access(contract) var maxRequestsPerTx: Int - /// @notice Storage path for Worker resource access(all) let WorkerStoragePath: StoragePath @@ -201,6 +197,12 @@ access(all) contract FlowYieldVaultsEVM { /// @param failed Number of failed requests access(all) event RequestsProcessed(count: Int, successful: Int, failed: Int) + /// @notice Emitted after preprocessing a batch of requests + /// @param count Total requests processed + /// @param successful Number of successful requests + /// @param rejected Number of rejected requests + access(all) event RequestsPreprocessed(count: Int, successful: Int, rejected: Int) + /// @notice Emitted when a new YieldVault is created for an EVM user /// @param requestId The EVM request ID that triggered this operation /// @param evmAddress The EVM address of the user @@ -263,13 +265,13 @@ access(all) contract FlowYieldVaultsEVM { tokenAddress: String ) - /// @notice Emitted when a request fails during processing + /// @notice Emitted when a request fails during processing or pre-processing /// @param requestId The failed request ID /// @param userAddress The EVM address of the user /// @param requestType The type of request that failed /// @param tokenAddress The token address involved in the request /// @param amount The amount involved in the request (in wei/smallest unit) - /// @param yieldVaultId The YieldVault ID if applicable (UInt64.max if not applicable) + /// @param yieldVaultId The YieldVault ID if applicable /// @param reason The failure reason access(all) event RequestFailed( requestId: UInt256, @@ -277,21 +279,13 @@ access(all) contract FlowYieldVaultsEVM { requestType: UInt8, tokenAddress: String, amount: UInt256, - yieldVaultId: UInt64, + yieldVaultId: UInt64?, reason: String ) - /// @notice Emitted when maxRequestsPerTx is updated - /// @param oldValue The previous value - /// @param newValue The new value - access(all) event MaxRequestsPerTxUpdated(oldValue: Int, newValue: Int) - - /// @notice Emitted when withdrawing funds from EVM fails - /// @param requestId The request ID - /// @param amount The amount that failed to withdraw - /// @param tokenAddress The token address - /// @param reason The failure reason - access(all) event WithdrawFundsFromEVMFailed(requestId: UInt256, amount: UFix64, tokenAddress: String, reason: String) + /// @notice Emitted when an error occurs + /// @param message The error message + access(all) event ErrorEncountered(message: String) /// @notice Emitted when allowlist status changes on EVM /// @param enabled The new allowlist status @@ -316,7 +310,12 @@ access(all) contract FlowYieldVaultsEVM { /// @param isSupported Whether the token is supported /// @param minimumBalance The minimum balance required /// @param isNative Whether the token is native FLOW - access(all) event EVMTokenConfigured(tokenAddress: String, isSupported: Bool, minimumBalance: UInt256, isNative: Bool) + access(all) event EVMTokenConfigured( + tokenAddress: String, + isSupported: Bool, + minimumBalance: UInt256, + isNative: Bool, + ) /// @notice Emitted when authorized COA changes on EVM /// @param newCOA The new authorized COA address @@ -360,20 +359,6 @@ access(all) contract FlowYieldVaultsEVM { emit FlowYieldVaultsRequestsAddressSet(address: address.toString()) } - /// @notice Updates the maximum requests processed per transaction - /// @param newMax The new maximum (must be 1-100) - access(all) fun updateMaxRequestsPerTx(_ newMax: Int) { - pre { - newMax > 0: "maxRequestsPerTx must be greater than 0 but got \(newMax)" - newMax <= 100: "maxRequestsPerTx must not exceed 100 for gas safety but got \(newMax)" - } - - let oldMax = FlowYieldVaultsEVM.maxRequestsPerTx - FlowYieldVaultsEVM.maxRequestsPerTx = newMax - - emit MaxRequestsPerTxUpdated(oldValue: oldMax, newValue: newMax) - } - /// @notice Creates a new Worker resource /// @param coaCap Capability to the COA with Call, Withdraw, and Bridge entitlements /// @param yieldVaultManagerCap Capability to the YieldVaultManager with Withdraw entitlement @@ -468,227 +453,230 @@ access(all) contract FlowYieldVaultsEVM { return self.getCOARef().address().toString() } - /// @notice Processes pending requests from the EVM contract - /// @dev Fetches up to count pending requests and processes each one. - /// Uses two-phase processing (startProcessing → completeProcessing) to sync request status. - /// @param startIndex The index to start fetching requests from - /// @param count The number of requests to fetch - access(all) fun processRequests(startIndex: Int, count: Int) { - pre { - FlowYieldVaultsEVM.flowYieldVaultsRequestsAddress != nil: - "FlowYieldVaultsRequests address not set - call Admin.setFlowYieldVaultsRequestsAddress() first" - } + // ============================================ + // Request Preprocessing + // ============================================ + + /// @notice Preprocesses a list of requests + /// @dev Flow: + /// - Validate status - should be PENDING + /// - Validate amount - should already be validated by Solidity, but check defensively + /// - Early validation for CREATE_YIELDVAULT requests - validate vaultIdentifier and strategyIdentifier + /// - Call startProcessingBatch to update the request statuses (PENDING -> PROCESSING/FAILED) + /// - Return successful requests for further processing + /// @param requests The list of EVM requests to preprocess + /// @return The list of successful requests for further processing, otherwise nil if the requests are invalid + access(all) fun preprocessRequests(_ requests: [EVMRequest]): [EVMRequest]? { + + var failedRequestIds: [UInt256] = [] + var successfulRequestIds: [UInt256] = [] + var successfulRequests: [FlowYieldVaultsEVM.EVMRequest] = [] + + for request in requests { + // Validate status - should be PENDING + if request.status != FlowYieldVaultsEVM.RequestStatus.PENDING.rawValue { + FlowYieldVaultsEVM.emitRequestFailed(request, + message: "Request must be in PENDING status but got \(request.status)") + failedRequestIds.append(request.id) + continue + } + + // Validate amount - should already be validated by Solidity but check defensively + if request.requestType != FlowYieldVaultsEVM.RequestType.CLOSE_YIELDVAULT.rawValue + && request.amount == 0 { + FlowYieldVaultsEVM.emitRequestFailed(request, + message: "Request amount must be greater than 0 for requestType \(request.requestType)") + failedRequestIds.append(request.id) + continue + } + + // Early validation for CREATE_YIELDVAULT requests + // Validate vaultIdentifier and strategyIdentifier + if request.requestType == FlowYieldVaultsEVM.RequestType.CREATE_YIELDVAULT.rawValue { + let validationResult = FlowYieldVaultsEVM.validateCreateYieldVaultParameters(request) + if !validationResult.success { + FlowYieldVaultsEVM.emitRequestFailed(request, + message: "Validation failed: \(validationResult.message)") + failedRequestIds.append(request.id) + continue + } + } - let requestsToProcess = self.getPendingRequestsFromEVM(startIndex: startIndex, count: count) - let batchSize = requestsToProcess.length + // All checks passed, add to successful lists (Update status to PROCESSING) + let newRequest = FlowYieldVaultsEVM.EVMRequest( + id: request.id, + user: request.user, + requestType: request.requestType, + // Update status to PROCESSING + status: FlowYieldVaultsEVM.RequestStatus.PROCESSING.rawValue, + tokenAddress: request.tokenAddress, + amount: request.amount, + yieldVaultId: request.yieldVaultId, + timestamp: request.timestamp, + message: request.message, + vaultIdentifier: request.vaultIdentifier, + strategyIdentifier: request.strategyIdentifier, + ) + successfulRequests.append(newRequest) + successfulRequestIds.append(request.id) + } - if batchSize == 0 { - emit RequestsProcessed(count: 0, successful: 0, failed: 0) - return + // Start processing requests (PENDING -> PROCESSING) + if let errorMessage = self.startProcessingBatch( + successfulRequestIds: successfulRequestIds, + rejectedRequestIds: failedRequestIds, + ) { + emit ErrorEncountered(message: "Failed to start processing requests: \(errorMessage)") + // This function doesn't have Cadence state side effects, so it's safe to return nil + // instead of panicking. + return nil } - var successCount = 0 - var failCount = 0 - var i = 0 + emit RequestsPreprocessed( + count: requests.length, + successful: successfulRequests.length, + rejected: failedRequestIds.length, + ) - while i < batchSize { - let request = requestsToProcess[i] + // Return successful requests + return successfulRequests + } - let success = self.processRequestSafely(request) - if success { - successCount = successCount + 1 - } else { + // ============================================ + // Request Processing + // ============================================ + + /// @notice Processes the given requests + /// @dev This function might panic if the request processing fails. + /// @param requests Requests to process. + access(all) fun processRequests(_ requests: [EVMRequest]) { + var successCount = 0 + var failCount = 0 + for request in requests { + let result = self.processRequest(request) + if !result.success { failCount = failCount + 1 + } else { + successCount = successCount + 1 } - i = i + 1 } - emit RequestsProcessed(count: batchSize, successful: successCount, failed: failCount) + emit RequestsProcessed( + count: requests.length, + successful: successCount, + failed: failCount, + ) } - /// @notice Safely processes a single request with error handling and status updates + /// @notice Processes a single request + /// @dev This function might panic if the request processing fails. /// @dev This is the main dispatcher that: - /// 1. Validates request preconditions (amount, status) - /// 2. For CREATE requests: validates vault/strategy parameters before fund withdrawal - /// 3. For WITHDRAW/CLOSE: calls startProcessing before the operation - /// 4. Dispatches to the appropriate process function based on request type - /// 5. Calls completeProcessing to update final status (with refund on failure for CREATE/DEPOSIT) + /// 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) /// @param request The EVM request to process - /// @return True if the request was processed successfully, false otherwise - access(self) fun processRequestSafely(_ request: EVMRequest): Bool { - // Validate amount - should already be validated by Solidity, but check defensively - // to prevent batch failure if edge case occurs - if request.requestType != FlowYieldVaultsEVM.RequestType.CLOSE_YIELDVAULT.rawValue && request.amount == 0 { - emit RequestFailed( - requestId: request.id, - userAddress: request.user.toString(), - requestType: request.requestType, - tokenAddress: request.tokenAddress.toString(), - amount: request.amount, - yieldVaultId: request.yieldVaultId, - reason: "Request amount must be greater than 0 for requestType \(request.requestType) (should have been caught by Solidity)" - ) - return false + /// @return ProcessResult with success status, the yieldVaultId, and status message + access(all) fun processRequest(_ request: EVMRequest): ProcessResult { + pre { + FlowYieldVaultsEVM.flowYieldVaultsRequestsAddress != nil: + "FlowYieldVaultsRequests address not set - call Admin.setFlowYieldVaultsRequestsAddress() first" } - // Validate status - should already be PENDING due to Solidity validation and startProcessing checks + // Validate status - should already be PROCESSING due to Solidity validation and startProcessingBatch checks // Check defensively to prevent batch failure if edge case occurs - if request.status != FlowYieldVaultsEVM.RequestStatus.PENDING.rawValue { - emit RequestFailed( - requestId: request.id, - userAddress: request.user.toString(), - requestType: request.requestType, - tokenAddress: request.tokenAddress.toString(), - amount: request.amount, - yieldVaultId: request.yieldVaultId, - reason: "Request must be in PENDING status but got \(request.status) (should have been caught by startProcessing)" + if request.status != FlowYieldVaultsEVM.RequestStatus.PROCESSING.rawValue { + return FlowYieldVaultsEVM.emitRequestFailedAndReturnProcessResult( + request, + message: "Request must be in PROCESSING status but got \(request.status)" ) - return false - } - - var success = false - var yieldVaultId: UInt64 = FlowYieldVaultsEVM.noYieldVaultId - var message = "" - - // Early validation for CREATE_YIELDVAULT requests - // Validate vaultIdentifier and strategyIdentifier before fund withdrawal to prevent panics - // Note: Must call startProcessing BEFORE completeProcessing because Solidity requires - // request status to be PROCESSING before it can be marked COMPLETED/FAILED - if request.requestType == FlowYieldVaultsEVM.RequestType.CREATE_YIELDVAULT.rawValue { - let validationResult = FlowYieldVaultsEVM.validateCreateYieldVaultParameters(request) - if !validationResult.success { - // Start processing first to transition request from PENDING to PROCESSING - // This is required because completeProcessing requires PROCESSING status - if !self.startProcessing(requestId: request.id) { - emit RequestFailed( - requestId: request.id, - userAddress: request.user.toString(), - requestType: request.requestType, - tokenAddress: request.tokenAddress.toString(), - amount: request.amount, - yieldVaultId: request.yieldVaultId, - reason: "Validation failed and could not start processing: \(validationResult.message)" - ) - return false - } - // Now we can mark as failed - request is in PROCESSING status - // Refund funds since startProcessing moved them to COA - if !self.completeProcessing( - requestId: request.id, - success: false, - yieldVaultId: FlowYieldVaultsEVM.noYieldVaultId, - message: validationResult.message, - refundAmount: request.amount, - tokenAddress: request.tokenAddress, - requestType: request.requestType - ) { - emit RequestFailed( - requestId: request.id, - userAddress: request.user.toString(), - requestType: request.requestType, - tokenAddress: request.tokenAddress.toString(), - amount: request.amount, - yieldVaultId: request.yieldVaultId, - reason: "Validation failed and could not complete processing: \(validationResult.message)" - ) - } - return false - } - } - - // WITHDRAW/CLOSE: Call startProcessing here before the switch statement. - // CREATE/DEPOSIT: startProcessing is called inside their respective process functions - // (processCreateYieldVault, processDepositToYieldVault) or in the validation block above, - // because they need to handle fund withdrawal from COA after startProcessing succeeds. - if (request.requestType == FlowYieldVaultsEVM.RequestType.WITHDRAW_FROM_YIELDVAULT.rawValue || request.requestType == FlowYieldVaultsEVM.RequestType.CLOSE_YIELDVAULT.rawValue) { - if !self.startProcessing(requestId: request.id) { - // WITHDRAW/CLOSE don't escrow deposits, so no refund needed on failure - if !self.completeProcessing( - requestId: request.id, - success: false, - yieldVaultId: request.yieldVaultId, - message: "Failed to start processing request \(request.id)", - refundAmount: 0, - tokenAddress: request.tokenAddress, - requestType: request.requestType - ) { - emit RequestFailed( - requestId: request.id, - userAddress: request.user.toString(), - requestType: request.requestType, - tokenAddress: request.tokenAddress.toString(), - amount: request.amount, - yieldVaultId: request.yieldVaultId, - reason: "Failed to start processing and complete processing for request \(request.id)" - ) - } - return false - } } + // Process request based on request type + var result: ProcessResult? = nil switch request.requestType { case FlowYieldVaultsEVM.RequestType.CREATE_YIELDVAULT.rawValue: - let result = self.processCreateYieldVault(request) - success = result.success - yieldVaultId = result.yieldVaultId - message = result.message + result = self.processCreateYieldVault(request) + case FlowYieldVaultsEVM.RequestType.DEPOSIT_TO_YIELDVAULT.rawValue: - let result = self.processDepositToYieldVault(request) - success = result.success - yieldVaultId = result.yieldVaultId != FlowYieldVaultsEVM.noYieldVaultId ? result.yieldVaultId : request.yieldVaultId - message = result.message + result = self.processDepositToYieldVault(request) + case FlowYieldVaultsEVM.RequestType.WITHDRAW_FROM_YIELDVAULT.rawValue: - let result = self.processWithdrawFromYieldVault(request) - success = result.success - yieldVaultId = result.yieldVaultId != FlowYieldVaultsEVM.noYieldVaultId ? result.yieldVaultId : request.yieldVaultId - message = result.message + result = self.processWithdrawFromYieldVault(request) + case FlowYieldVaultsEVM.RequestType.CLOSE_YIELDVAULT.rawValue: - let result = self.processCloseYieldVault(request) - success = result.success - yieldVaultId = result.yieldVaultId != FlowYieldVaultsEVM.noYieldVaultId ? result.yieldVaultId : request.yieldVaultId - message = result.message + result = self.processCloseYieldVault(request) + default: - success = false - message = "Unknown request type: \(request.requestType) for request ID \(request.id)" + return FlowYieldVaultsEVM.emitRequestFailedAndReturnProcessResult( + request, + message: "Unknown request type: \(request.requestType)" + ) + } + + if result == nil { + return FlowYieldVaultsEVM.emitRequestFailedAndReturnProcessResult( + request, + message: "Internal error: processResult is nil" + ) } // Pass refund info - completeProcessing will determine if refund is needed // based on success flag and request type if !self.completeProcessing( requestId: request.id, - success: success, - yieldVaultId: yieldVaultId, - message: message, + success: result!.success, + yieldVaultId: result!.yieldVaultId, + message: result!.message, refundAmount: request.amount, tokenAddress: request.tokenAddress, requestType: request.requestType ) { - emit RequestFailed( - requestId: request.id, - userAddress: request.user.toString(), - requestType: request.requestType, - tokenAddress: request.tokenAddress.toString(), - amount: request.amount, - yieldVaultId: yieldVaultId, - reason: "Processing completed but failed to update status: \(message)" - ) + let errorMessage = "Failed to complete processing for request \(request.id)" + // processRequest() performs Cadence-side state changes, such as creating vaults and transferring tokens. + // If the final EVM call fails, it panics to ensure that all Cadence state changes are reverted as well. + // In the future, this panic can be replaced by an explicit "reverse" function that handles all request types. + // This will enable the function to revert changes and return a failed result instead of panicking. + // Note: In the WorkerHandler context, panicking is safe and ensures atomicity for failed requests. + panic(errorMessage) } - if !success { - emit RequestFailed( - requestId: request.id, - userAddress: request.user.toString(), - requestType: request.requestType, - tokenAddress: request.tokenAddress.toString(), - amount: request.amount, - yieldVaultId: yieldVaultId, - reason: message + if !result!.success { + return FlowYieldVaultsEVM.emitRequestFailedAndReturnProcessResult( + request, + message: "Processing failed: \(result!.message)" ) } - return success + return result! } + /// @notice Marks a request as FAILED + /// @dev Calls completeProcessing to mark the request as failed with the given message + /// @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 + access(all) fun markRequestAsFailed( + _ request: EVMRequest, + message: String + ): Bool { + + FlowYieldVaultsEVM.emitRequestFailed(request, message: message) + + return self.completeProcessing( + requestId: request.id, + success: false, + yieldVaultId: request.yieldVaultId, + message: message, + refundAmount: request.amount, + tokenAddress: request.tokenAddress, + requestType: request.requestType, + ) + } + + // ============================================ + /// Internal Functions + // ============================================ + /// @notice Helper function to return funds to the COA and create a failure result /// @dev Used when an operation fails after funds have already been withdrawn from COA. /// Returns the vault contents to the COA so completeProcessing can refund via pull. @@ -715,7 +703,7 @@ access(all) contract FlowYieldVaultsEVM { return ProcessResult( success: false, - yieldVaultId: FlowYieldVaultsEVM.noYieldVaultId, + yieldVaultId: nil, message: "\(errorMessage). Funds returned to COA for refund." ) } @@ -723,11 +711,10 @@ access(all) contract FlowYieldVaultsEVM { /// @notice Processes a CREATE_YIELDVAULT request /// @dev Creates a new YieldVault for the EVM user with the specified vault type and strategy. /// Flow: - /// 1. Calls startProcessing to mark request as PROCESSING and transfer funds to COA - /// 2. Withdraws funds from COA (bridging ERC20 if needed) - /// 3. Validates vault type matches the requested vaultIdentifier - /// 4. Creates YieldVault via YieldVaultManager - /// 5. Records ownership in yieldVaultRegistry + /// 1. Withdraws funds from COA (bridging ERC20 if needed) + /// 2. Validates vault type matches the requested vaultIdentifier + /// 3. Creates YieldVault via YieldVaultManager + /// 4. Records ownership in yieldVaultRegistry /// @param request The CREATE_YIELDVAULT request containing vault/strategy identifiers and amount /// @return ProcessResult with success status, created yieldVaultId, and status message access(self) fun processCreateYieldVault(_ request: EVMRequest): ProcessResult { @@ -735,16 +722,7 @@ access(all) contract FlowYieldVaultsEVM { let strategyIdentifier = request.strategyIdentifier let amount = FlowYieldVaultsEVM.ufix64FromUInt256(request.amount, tokenAddress: request.tokenAddress) - // Phase 1: Mark request as PROCESSING and transfer escrowed funds to COA - if !self.startProcessing(requestId: request.id) { - return ProcessResult( - success: false, - yieldVaultId: FlowYieldVaultsEVM.noYieldVaultId, - message: "Failed to start processing request \(request.id) - request may already be processing or completed" - ) - } - - // Phase 2: Withdraw funds from COA (bridges ERC20 to Cadence vault if needed) + // Phase 1: Withdraw funds from COA (bridges ERC20 to Cadence vault if needed) let vaultOptional <- self.withdrawFundsFromCOA( amount: amount, tokenAddress: request.tokenAddress @@ -754,14 +732,14 @@ access(all) contract FlowYieldVaultsEVM { destroy vaultOptional return ProcessResult( success: false, - yieldVaultId: FlowYieldVaultsEVM.noYieldVaultId, + yieldVaultId: nil, message: "Failed to withdraw \(amount) from COA for request \(request.id) (token: \(request.tokenAddress.toString()))" ) } let vault <- vaultOptional! - // Phase 3: Validate vault type matches the requested identifier + // Phase 2: Validate vault type matches the requested identifier let vaultType = vault.getType() if vaultType.identifier != vaultIdentifier { return self.returnFundsToCOAAndFail( @@ -771,7 +749,7 @@ access(all) contract FlowYieldVaultsEVM { ) } - // Phase 4: Create the YieldVault with the specified strategy + // Phase 3: Create the YieldVault with the specified strategy // Note: strategyIdentifier already validated by validateCreateYieldVaultParameters let strategyType = CompositeType(strategyIdentifier)! @@ -784,7 +762,7 @@ access(all) contract FlowYieldVaultsEVM { withVault: <-vault ) - // Phase 5: Record ownership in contract state for O(1) lookups + // Phase 4: Record ownership in contract state for O(1) lookups let evmAddr = request.user.toString() // Initialize ownership map for this address if needed @@ -824,11 +802,11 @@ access(all) contract FlowYieldVaultsEVM { // Step 1: Validate user ownership of the YieldVault if let ownershipMap = FlowYieldVaultsEVM.yieldVaultRegistry[evmAddr] { - if !ownershipMap.containsKey(request.yieldVaultId) { + if !ownershipMap.containsKey(request.yieldVaultId!) { return ProcessResult( success: false, yieldVaultId: request.yieldVaultId, - message: "User \(evmAddr) does not own YieldVault Id \(request.yieldVaultId)" + message: "User \(evmAddr) does not own YieldVault Id \(request.yieldVaultId!)" ) } } else { @@ -840,14 +818,14 @@ access(all) contract FlowYieldVaultsEVM { } // Step 2: Close YieldVault and retrieve all funds - let vault <- self.getYieldVaultManagerRef().closeYieldVault(request.yieldVaultId) + let vault <- self.getYieldVaultManagerRef().closeYieldVault(request.yieldVaultId!) let amount = vault.balance // Step 3: Bridge funds back to user's EVM address self.bridgeFundsToEVMUser(vault: <-vault, recipient: request.user, tokenAddress: request.tokenAddress) // Step 4: Remove yieldVaultId from registry mapping - let _ = FlowYieldVaultsEVM.yieldVaultRegistry[evmAddr]!.remove(key: request.yieldVaultId) + let _ = FlowYieldVaultsEVM.yieldVaultRegistry[evmAddr]!.remove(key: request.yieldVaultId!) // Clean up empty dictionaries to optimize storage costs if FlowYieldVaultsEVM.yieldVaultRegistry[evmAddr]!.length == 0 { let _ = FlowYieldVaultsEVM.yieldVaultRegistry.remove(key: evmAddr) @@ -856,7 +834,7 @@ access(all) contract FlowYieldVaultsEVM { emit YieldVaultClosedForEVMUser( requestId: request.id, evmAddress: evmAddr, - yieldVaultId: request.yieldVaultId, + yieldVaultId: request.yieldVaultId!, amountReturned: amount, tokenAddress: request.tokenAddress.toString() ) @@ -864,7 +842,7 @@ access(all) contract FlowYieldVaultsEVM { return ProcessResult( success: true, yieldVaultId: request.yieldVaultId, - message: "YieldVault Id \(request.yieldVaultId) closed successfully, returned \(amount) FLOW" + message: "YieldVault Id \(request.yieldVaultId!) closed successfully, returned \(amount) FLOW" ) } @@ -872,26 +850,16 @@ access(all) contract FlowYieldVaultsEVM { /// @dev Deposits additional funds into an existing YieldVault. /// Note: Unlike CLOSE/WITHDRAW, anyone can deposit to any YieldVault (no ownership check). /// Flow: - /// 1. Calls startProcessing to mark request as PROCESSING and transfer funds to COA - /// 2. Withdraws funds from COA (bridging ERC20 if needed) - /// 3. Deposits to YieldVault via YieldVaultManager + /// 1. Withdraws funds from COA (bridging ERC20 if needed) + /// 2. Deposits to YieldVault via YieldVaultManager /// @param request The DEPOSIT_TO_YIELDVAULT request containing yieldVaultId and amount /// @return ProcessResult with success status, the yieldVaultId, and deposited amount access(self) fun processDepositToYieldVault(_ request: EVMRequest): ProcessResult { let evmAddr = request.user.toString() - // Step 1: Mark request as PROCESSING and transfer escrowed funds to COA - if !self.startProcessing(requestId: request.id) { - return ProcessResult( - success: false, - yieldVaultId: request.yieldVaultId, - message: "Failed to start processing request \(request.id) - request may already be processing or completed" - ) - } - let amount = FlowYieldVaultsEVM.ufix64FromUInt256(request.amount, tokenAddress: request.tokenAddress) - // Step 2: Withdraw funds from COA (bridges ERC20 to Cadence vault if needed) + // Step 1: Withdraw funds from COA (bridges ERC20 to Cadence vault if needed) let vaultOptional <- self.withdrawFundsFromCOA( amount: amount, tokenAddress: request.tokenAddress @@ -908,19 +876,19 @@ access(all) contract FlowYieldVaultsEVM { let vault <- vaultOptional! - // Step 3: Deposit to YieldVault via YieldVaultManager + // Step 2: Deposit to YieldVault via YieldVaultManager let betaRef = self.getBetaRef() - self.getYieldVaultManagerRef().depositToYieldVault(betaRef: betaRef, request.yieldVaultId, from: <-vault) + self.getYieldVaultManagerRef().depositToYieldVault(betaRef: betaRef, request.yieldVaultId!, from: <-vault) // Check if depositor is the yield vault owner for event emission var isYieldVaultOwner = false if let ownershipMap = FlowYieldVaultsEVM.yieldVaultRegistry[evmAddr] { - isYieldVaultOwner = ownershipMap.containsKey(request.yieldVaultId) + isYieldVaultOwner = ownershipMap.containsKey(request.yieldVaultId!) } emit YieldVaultDepositedForEVMUser( requestId: request.id, evmAddress: evmAddr, - yieldVaultId: request.yieldVaultId, + yieldVaultId: request.yieldVaultId!, amount: amount, tokenAddress: request.tokenAddress.toString(), isYieldVaultOwner: isYieldVaultOwner @@ -929,7 +897,7 @@ access(all) contract FlowYieldVaultsEVM { return ProcessResult( success: true, yieldVaultId: request.yieldVaultId, - message: "Deposited \(amount) FLOW to YieldVault Id \(request.yieldVaultId)" + message: "Deposited \(amount) FLOW to YieldVault Id \(request.yieldVaultId!)" ) } @@ -947,11 +915,11 @@ access(all) contract FlowYieldVaultsEVM { // Step 1: Validate user ownership of the YieldVault if let ownershipMap = FlowYieldVaultsEVM.yieldVaultRegistry[evmAddr] { - if !ownershipMap.containsKey(request.yieldVaultId) { + if !ownershipMap.containsKey(request.yieldVaultId!) { return ProcessResult( success: false, yieldVaultId: request.yieldVaultId, - message: "User \(evmAddr) does not own YieldVault Id \(request.yieldVaultId)" + message: "User \(evmAddr) does not own YieldVault Id \(request.yieldVaultId!)" ) } } else { @@ -965,12 +933,12 @@ access(all) contract FlowYieldVaultsEVM { let amount = FlowYieldVaultsEVM.ufix64FromUInt256(request.amount, tokenAddress: request.tokenAddress) // Step 2: Pre-validate YieldVault exists and has sufficient balance - let yieldVaultRef = self.getYieldVaultManagerRef().borrowYieldVault(id: request.yieldVaultId) + let yieldVaultRef = self.getYieldVaultManagerRef().borrowYieldVault(id: request.yieldVaultId!) if yieldVaultRef == nil { return ProcessResult( success: false, yieldVaultId: request.yieldVaultId, - message: "YieldVault Id \(request.yieldVaultId) not found in manager" + message: "YieldVault Id \(request.yieldVaultId!) not found in manager" ) } let availableBalance = yieldVaultRef!.getYieldVaultBalance() @@ -983,7 +951,7 @@ access(all) contract FlowYieldVaultsEVM { } // Step 3: Withdraw funds from YieldVault - let vault <- self.getYieldVaultManagerRef().withdrawFromYieldVault(request.yieldVaultId, amount: amount) + let vault <- self.getYieldVaultManagerRef().withdrawFromYieldVault(request.yieldVaultId!, amount: amount) // Step 4: Bridge funds back to user's EVM address let actualAmount = vault.balance @@ -992,7 +960,7 @@ access(all) contract FlowYieldVaultsEVM { emit YieldVaultWithdrawnForEVMUser( requestId: request.id, evmAddress: evmAddr, - yieldVaultId: request.yieldVaultId, + yieldVaultId: request.yieldVaultId!, amount: actualAmount, tokenAddress: request.tokenAddress.toString() ) @@ -1000,19 +968,22 @@ access(all) contract FlowYieldVaultsEVM { return ProcessResult( success: true, yieldVaultId: request.yieldVaultId, - message: "Withdrew \(actualAmount) FLOW from YieldVault Id \(request.yieldVaultId)" + message: "Withdrew \(actualAmount) FLOW from YieldVault Id \(request.yieldVaultId!)" ) } - /// @notice Marks a request as PROCESSING and transfers escrowed funds to COA - /// @dev For CREATE/DEPOSIT: deducts user balance and transfers funds to COA for bridging. - /// For WITHDRAW/CLOSE: only updates status (no balance change). - /// @param requestId The request ID to start processing - /// @return True if successful, false otherwise - access(self) fun startProcessing(requestId: UInt256): Bool { + /// @notice Starts processing a batch of requests + /// @dev Calls startProcessingBatch to update the request statuses + /// @param successfulRequestIds The request ids to start processing (PENDING -> PROCESSING) + /// @param rejectedRequestIds The request ids to reject (PENDING -> FAILED) + /// @return String error message if the requests failed to be started, otherwise nil + access(self) fun startProcessingBatch( + successfulRequestIds: [UInt256], + rejectedRequestIds: [UInt256], + ): String? { let calldata = EVM.encodeABIWithSignature( - "startProcessing(uint256)", - [requestId] + "startProcessingBatch(uint256[],uint256[])", + [successfulRequestIds, rejectedRequestIds] ) let result = self.getCOARef().call( @@ -1024,16 +995,14 @@ access(all) contract FlowYieldVaultsEVM { if result.status != EVM.Status.successful { let errorMsg = FlowYieldVaultsEVM.decodeEVMError(result.data) - emit WithdrawFundsFromEVMFailed( - requestId: requestId, - amount: 0.0, - tokenAddress: "", - reason: "startProcessing failed: \(errorMsg)" - ) - return false + return "startProcessingBatch failed: \(errorMsg)" } - return true + if rejectedRequestIds.length > 0 { + emit EVMRequestsDropped(requestIds: rejectedRequestIds) + } + + return nil // success } /// @notice Marks a request as COMPLETED or FAILED, returning escrowed funds on failure @@ -1050,15 +1019,19 @@ access(all) contract FlowYieldVaultsEVM { access(self) fun completeProcessing( requestId: UInt256, success: Bool, - yieldVaultId: UInt64, + yieldVaultId: UInt64?, message: String, refundAmount: UInt256, tokenAddress: EVM.EVMAddress, requestType: UInt8 ): Bool { + + // Don't use optional for EVM since valid Ids can be 0 + let evmYieldVaultId = yieldVaultId ?? UInt64.max + let calldata = EVM.encodeABIWithSignature( "completeProcessing(uint256,bool,uint64,string)", - [requestId, success, yieldVaultId, message] + [requestId, success, evmYieldVaultId, message] ) // Determine if refund is needed (failed CREATE or DEPOSIT) @@ -1251,9 +1224,13 @@ access(all) contract FlowYieldVaultsEVM { } } + // ============================================ + /// Public Functions + // ============================================ + /// @notice Gets the count of pending requests from the EVM contract /// @return The number of pending requests - access(all) fun getPendingRequestCountFromEVM(): Int { + access(all) fun getPendingRequestCountFromEVM(): Int? { let calldata = EVM.encodeABIWithSignature("getPendingRequestCount()", []) let callResult = self.getCOARef().dryCall( @@ -1265,7 +1242,10 @@ access(all) contract FlowYieldVaultsEVM { if callResult.status != EVM.Status.successful { let errorMsg = FlowYieldVaultsEVM.decodeEVMError(callResult.data) - panic("getPendingRequestCount call failed: \(errorMsg)") + emit ErrorEncountered( + message: "getPendingRequestCount call failed: \(errorMsg)" + ) + return nil } let decoded = EVM.decodeABI( @@ -1279,9 +1259,9 @@ access(all) contract FlowYieldVaultsEVM { /// @notice Fetches pending requests from the EVM contract /// @param startIndex The index to start fetching from - /// @param count The number of requests to fetch (use maxRequestsPerTx if not specified) + /// @param count The number of requests to fetch /// @return Array of pending EVMRequest structs - access(all) fun getPendingRequestsFromEVM(startIndex: Int, count: Int): [EVMRequest] { + access(all) fun getPendingRequestsFromEVM(startIndex: Int, count: Int): [EVMRequest]? { let startIdx = UInt256(startIndex) let cnt = UInt256(count) let calldata = EVM.encodeABIWithSignature("getPendingRequestsUnpacked(uint256,uint256)", [startIdx, cnt]) @@ -1295,7 +1275,8 @@ access(all) contract FlowYieldVaultsEVM { if callResult.status != EVM.Status.successful { let errorMsg = FlowYieldVaultsEVM.decodeEVMError(callResult.data) - panic("getPendingRequestsUnpacked call failed: \(errorMsg)") + emit ErrorEncountered(message: "getPendingRequestsUnpacked call failed: \(errorMsg)") + return nil } let decoded = EVM.decodeABI( @@ -1578,7 +1559,7 @@ access(all) contract FlowYieldVaultsEVM { /// @notice Drops pending requests on the EVM contract and refunds users /// @param requestIds The request IDs to drop - access(all) fun dropRequests(_ requestIds: [UInt256]) { + access(all) fun dropRequests(_ requestIds: [UInt256]): String? { let gasLimit: UInt64 = 500_000 + UInt64(requestIds.length) * 100_000 let calldata = EVM.encodeABIWithSignature( @@ -1595,10 +1576,11 @@ access(all) contract FlowYieldVaultsEVM { if result.status != EVM.Status.successful { let errorMsg = FlowYieldVaultsEVM.decodeEVMError(result.data) - panic("dropRequests failed: \(errorMsg)") + return "dropRequests failed: \(errorMsg)" } emit EVMRequestsDropped(requestIds: requestIds) + return nil } /// @notice Cancels a pending request on the EVM contract @@ -1658,12 +1640,6 @@ access(all) contract FlowYieldVaultsEVM { return self.flowYieldVaultsRequestsAddress } - /// @notice Gets the maximum requests processed per transaction - /// @return The current maxRequestsPerTx value - access(all) view fun getMaxRequestsPerTx(): Int { - return self.maxRequestsPerTx - } - /// @notice Gets pending requests for a specific EVM address (public query) /// @dev Uses the contract account's public COA capability at /public/evm for read-only EVM calls. /// @param evmAddressHex The EVM address as a hex string (e.g., "0x1234...") @@ -1759,6 +1735,87 @@ access(all) contract FlowYieldVaultsEVM { ) } + /// @notice Gets a specific request by ID in unpacked format (public query) + /// @dev Uses the contract account's public COA capability at /public/evm for read-only EVM calls. + /// @param requestId The request ID to fetch + /// @return EVMRequest containing request details + access(all) fun getRequestUnpacked(_ requestId: UInt256): EVMRequest? { + pre { + self.flowYieldVaultsRequestsAddress != nil: + "FlowYieldVaultsRequests address not set - call Admin.setFlowYieldVaultsRequestsAddress() first" + } + let coaRef = self.account.capabilities.borrow<&EVM.CadenceOwnedAccount>(/public/evm) + ?? panic("Could not borrow public COA capability from /public/evm for contract account \(self.account.address)") + + let calldata = EVM.encodeABIWithSignature( + "getRequestUnpacked(uint256)", + [requestId] + ) + + let callResult = coaRef.dryCall( + to: self.flowYieldVaultsRequestsAddress!, + data: calldata, + gasLimit: 15_000_000, + value: EVM.Balance(attoflow: 0) + ) + + if callResult.status != EVM.Status.successful { + let errorMsg = self.decodeEVMError(callResult.data) + emit ErrorEncountered(message: "getRequestUnpacked call failed: \(errorMsg)") + return nil + } + + let decoded = EVM.decodeABI( + types: [ + Type(), // id + Type(), // user + Type(), // requestType + Type(), // status + Type(), // tokenAddress + Type(), // amount + Type(), // yieldVaultId + Type(), // timestamp + Type(), // message + Type(), // vaultIdentifier + Type() // strategyIdentifier + ], + data: callResult.data + ) + + let id = decoded[0] as! UInt256 + let user = decoded[1] as! EVM.EVMAddress + let requestType = decoded[2] as! UInt8 + let status = decoded[3] as! UInt8 + let tokenAddress = decoded[4] as! EVM.EVMAddress + let amount = decoded[5] as! UInt256 + let yieldVaultId = decoded[6] as! UInt64 + let timestamp = decoded[7] as! UInt256 + let message = decoded[8] as! String + let vaultIdentifier = decoded[9] as! String + let strategyIdentifier = decoded[10] as! String + + // Request not found + if timestamp == 0 { + return nil + } + + // Build request array + let request = EVMRequest( + id: id, + user: user, + requestType: requestType, + status: status, + tokenAddress: tokenAddress, + amount: amount, + yieldVaultId: yieldVaultId, + timestamp: timestamp, + message: message, + vaultIdentifier: vaultIdentifier, + strategyIdentifier: strategyIdentifier + ) + return request + } + /// @notice Gets the total count of pending requests (public query) /// @dev Uses the contract account's public COA capability at /public/evm for read-only EVM calls. /// @return The number of pending requests @@ -1810,7 +1867,7 @@ access(all) contract FlowYieldVaultsEVM { if vaultType == nil { return ProcessResult( success: false, - yieldVaultId: FlowYieldVaultsEVM.noYieldVaultId, + yieldVaultId: nil, message: "Invalid vaultIdentifier: \(request.vaultIdentifier) is not a valid Cadence type" ) } @@ -1820,7 +1877,7 @@ access(all) contract FlowYieldVaultsEVM { if strategyType == nil { return ProcessResult( success: false, - yieldVaultId: FlowYieldVaultsEVM.noYieldVaultId, + yieldVaultId: nil, message: "Invalid strategyIdentifier: \(request.strategyIdentifier) is not a valid Cadence type" ) } @@ -1837,7 +1894,7 @@ access(all) contract FlowYieldVaultsEVM { if !isStrategySupported { return ProcessResult( success: false, - yieldVaultId: FlowYieldVaultsEVM.noYieldVaultId, + yieldVaultId: nil, message: "Unsupported strategy: \(request.strategyIdentifier) is not supported by FlowYieldVaults" ) } @@ -1847,7 +1904,7 @@ access(all) contract FlowYieldVaultsEVM { if supportedVaults[vaultType!] != true { return ProcessResult( success: false, - yieldVaultId: FlowYieldVaultsEVM.noYieldVaultId, + yieldVaultId: nil, message: "Unsupported vault type: \(request.vaultIdentifier) cannot be used to initialize strategy \(request.strategyIdentifier)" ) } @@ -1855,7 +1912,7 @@ access(all) contract FlowYieldVaultsEVM { // Validation passed return ProcessResult( success: true, - yieldVaultId: FlowYieldVaultsEVM.noYieldVaultId, + yieldVaultId: nil, message: "Validation passed" ) } @@ -1933,16 +1990,50 @@ access(all) contract FlowYieldVaultsEVM { return "EVM revert data: 0x\(String.encodeHex(data))" } + /// @notice Emits the RequestFailed event and returns a ProcessResult with success=false + /// @dev This is a helper function to emit the RequestFailed event and return a ProcessResult with success=false + /// @param request The EVM request that failed + /// @param message The error message to include in the result + /// @return ProcessResult with success=false and the yieldVaultId and message + access(self) fun emitRequestFailedAndReturnProcessResult( + _ request: EVMRequest, + message: String, + ): ProcessResult { + self.emitRequestFailed(request, message: message) + return ProcessResult( + success: false, + yieldVaultId: request.yieldVaultId, + message: "Request failed: \(message)", + ) + } + + /// @notice Emits the RequestFailed event + /// @dev This is a helper function to emit the RequestFailed event + /// @param request The EVM request that failed + /// @param message The error message to include in the result + access(self) fun emitRequestFailed( + _ request: EVMRequest, + message: String, + ) { + emit RequestFailed( + requestId: request.id, + userAddress: request.user.toString(), + requestType: request.requestType, + tokenAddress: request.tokenAddress.toString(), + amount: request.amount, + yieldVaultId: request.yieldVaultId, + reason: message, + ) + } + // ============================================ // Initialization // ============================================ init() { - self.noYieldVaultId = UInt64.max self.nativeFlowEVMAddress = EVM.addressFromString("0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF") self.WorkerStoragePath = /storage/flowYieldVaultsEVM self.AdminStoragePath = /storage/flowYieldVaultsEVMAdmin - self.maxRequestsPerTx = 1 self.yieldVaultRegistry = {} self.flowYieldVaultsRequestsAddress = nil diff --git a/cadence/contracts/FlowYieldVaultsEVMWorkerOps.cdc b/cadence/contracts/FlowYieldVaultsEVMWorkerOps.cdc new file mode 100644 index 0000000..f0acec9 --- /dev/null +++ b/cadence/contracts/FlowYieldVaultsEVMWorkerOps.cdc @@ -0,0 +1,985 @@ +import "FlowTransactionScheduler" +import "FlowTransactionSchedulerUtils" +import "FlowYieldVaultsEVM" +import "FlowToken" +import "FungibleToken" + +/// @title FlowYieldVaultsEVMWorkerOps +/// @author Flow YieldVaults Team +/// @notice Worker management contract for FlowYieldVaultsEVM requests processing and auto-scheduling. +/// @dev This contract provides two resources that implement the TransactionHandler interface for +/// auto-processing EVM requests: +/// - WorkerHandler: Processes each request individually. +/// - SchedulerHandler: Recurrent job that checks for pending requests and +/// schedules WorkerHandlers to process them based on available capacity. +/// +/// Design Overview: +/// - WorkerHandler is scheduled to process a specified request individually. Upon completion, it will finalize +/// the request status back on EVM side. +/// - SchedulerHandler is always scheduled to run at the configured interval. It checks if there are any +/// pending requests in the EVM contract. If there are, it will schedule multiple WorkerHandlers to process the +/// requests based on available capacity. +/// - SchedulerHandler uses a two-phase capacity strategy: +/// 1) Current run reads run capacity from scheduler data (nil means 0) and processes up to that limit. +/// 2) It computes next run capacity from remaining pending requests and schedules the next run with +/// matching execution effort. +/// - This keeps idle/empty scheduler runs cheap while automatically scaling effort when backlog appears. +/// - SchedulerHandler also identifies WorkerHandlers that panicked and handles the failure state changes accordingly. +/// - SchedulerHandler preprocesses requests before scheduling WorkerHandlers to identify and fail invalid requests. +/// - SchedulerHandler will schedule multiple WorkerHandlers for the same immediate height. If an EVM address has +/// multiple pending requests, they will be offset sequentially to avoid randomization in the same block. +/// - Contract provides shared state between WorkerHandler and SchedulerHandler (e.g. scheduledRequests dictionary). +/// +/// EVM State Overview: +/// - PENDING -> PROCESSING -> COMPLETED/FAILED +/// - PENDING -> FAILED (drop/cancel/preprocess failure) +/// +/// - PENDING: +/// - Request was created by an EVM user and is awaiting processing +/// - PROCESSING: +/// - Preprocessing was successful +/// - SchedulerHandler has scheduled a WorkerHandler to process the request +/// - COMPLETED: +/// - WorkerHandler has processed the request successfully and no failure occurred +/// - FAILED: +/// - WorkerHandler has processed the request successfully but it failed gracefully returning an error message +/// - WorkerHandler has panicked and SchedulerHandler has marked the request as FAILED +/// - Request was dropped or cancelled through the EVM contract +/// +access(all) contract FlowYieldVaultsEVMWorkerOps { + + // ============================================ + // State Variables + // ============================================ + + /// @notice Tracks current in-flight scheduled requests by the SchedulerHandler + /// @dev request id -> ScheduledEVMRequest + access(self) var scheduledRequests: {UInt256: ScheduledEVMRequest} + + /// @notice When true, the SchedulerHandler will not schedule new WorkerHandlers + /// @dev Note that this doesn't affect the in-flight requests (WorkerHandlers) + access(self) var isSchedulerPaused: Bool + + // ============================================ + // Configuration Variables + // ============================================ + + /// @notice Interval at which the SchedulerHandler will be executed recurrently + access(self) var schedulerWakeupInterval: UFix64 + + /// @notice Maximum number of WorkerHandlers to be scheduled simultaneously + access(self) var maxProcessingRequests: UInt8 + + // ============================================ + // Configuration Variables (Execution Effort) + // ============================================ + + /// @notice Configurable execution effort constants for scheduling transactions + /// @dev Keys are defined as public constants below. Values can be updated via Admin.setExecutionEffortConstants() + access(all) let executionEffortConstants: {String: UInt64} + + /// @notice Key constant for scheduler base execution effort + access(all) let SCHEDULER_BASE_EFFORT: String + /// @notice Key constant for scheduler per-request additional execution effort + access(all) let SCHEDULER_PER_REQUEST_EFFORT: String + /// @notice Key constant for worker CREATE_YIELDVAULT request execution effort + access(all) let WORKER_CREATE_YIELDVAULT_REQUEST_EFFORT: String + /// @notice Key constant for worker WITHDRAW_FROM_YIELDVAULT request execution effort + access(all) let WORKER_WITHDRAW_REQUEST_EFFORT: String + /// @notice Key constant for worker DEPOSIT_TO_YIELDVAULT request execution effort + access(all) let WORKER_DEPOSIT_REQUEST_EFFORT: String + /// @notice Key constant for worker CLOSE_YIELDVAULT request execution effort + access(all) let WORKER_CLOSE_YIELDVAULT_REQUEST_EFFORT: String + + // ============================================ + // Path Configuration Variables + // ============================================ + + /// @notice Storage path for WorkerHandler resource + access(all) let WorkerHandlerStoragePath: StoragePath + + /// @notice Storage path for SchedulerHandler resource + access(all) let SchedulerHandlerStoragePath: StoragePath + + /// @notice Storage path for Admin resource + access(all) let AdminStoragePath: StoragePath + + // ============================================ + // Events + // ============================================ + + /// @notice Emitted when the SchedulerHandler is paused + access(all) event SchedulerPaused() + + /// @notice Emitted when the SchedulerHandler is unpaused + access(all) event SchedulerUnpaused( + nextTransactionId: UInt64, + ) + + /// @notice Emitted when WorkerHandler has executed a request + /// @param transactionId The transaction ID that was executed + /// @param requestId The request ID that was processed + /// @param message The message from the WorkerHandler execution + access(all) event WorkerHandlerExecuted( + transactionId: UInt64, + requestId: UInt256?, + processResult: FlowYieldVaultsEVM.ProcessResult?, + message: String, + ) + + /// @notice Emitted when SchedulerHandler has executed a request + /// @param transactionId The transaction ID that was executed + /// @param nextTransactionId The transaction ID of the next SchedulerHandler execution + /// @param message The message from the SchedulerHandler execution + access(all) event SchedulerHandlerExecuted( + transactionId: UInt64, + nextTransactionId: UInt64?, + message: String, + pendingRequestCount: Int?, + fetchCount: Int?, + runCapacity: UInt8?, + nextRunCapacity: UInt8?, + ) + + /// @notice Emitted when a WorkerHandler has panicked and SchedulerHandler has marked the request as FAILED + /// @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 + access(all) event WorkerHandlerPanicDetected( + status: UInt8?, + markedAsFailed: Bool, + request: ScheduledEVMRequest, + ) + + /// @notice Emitted when a WorkerHandler has been scheduled to process a request + /// @param scheduledRequest The scheduled request + access(all) event WorkerHandlerScheduled( + scheduledRequest: ScheduledEVMRequest, + ) + + /// @notice Emitted when all scheduled executions are stopped and cancelled + /// @param cancelledIds Array of cancelled transaction IDs + /// @param totalRefunded Total amount of FLOW refunded + access(all) event AllExecutionsStopped( + cancelledIds: [UInt64], + totalRefunded: UFix64 + ) + + /// @notice Emitted when stopAll() cannot mark a cancelled request as FAILED + /// @param requestId EVM request ID that could not be marked as FAILED + /// @param workerTransactionId Cancelled WorkerHandler transaction ID + access(all) event StopAllMarkFailedSkipped( + requestId: UInt256, + workerTransactionId: UInt64, + ) + + // ============================================ + // Admin Resource + // ============================================ + + /// @notice Admin resource for handler configuration + /// @dev Only the contract deployer receives this resource + access(all) resource Admin { + + /// @notice Pauses the SchedulerHandler, stopping new scheduling + /// @dev This doesn't affect the in-flight requests (WorkerHandlers) + access(all) fun pauseScheduler() { + FlowYieldVaultsEVMWorkerOps.isSchedulerPaused = true + emit SchedulerPaused() + } + + /// @notice Unpauses the SchedulerHandler, resuming scheduling pending requests + access(all) fun unpauseScheduler() { + pre { + FlowYieldVaultsEVMWorkerOps._getManagerFromStorage() != nil: "Scheduler manager not found" + FlowYieldVaultsEVMWorkerOps._getSchedulerHandlerFromStorage() != nil: "SchedulerHandler resource not found" + } + if FlowYieldVaultsEVMWorkerOps.isSchedulerPaused { + FlowYieldVaultsEVMWorkerOps.isSchedulerPaused = false + let schedulerHandler = FlowYieldVaultsEVMWorkerOps._getSchedulerHandlerFromStorage()! + let manager = FlowYieldVaultsEVMWorkerOps._getManagerFromStorage()! + // First scheduler run will be scheduled without any requests to preprocess + // If there are pending requests, they will be preprocessed in the next scheduler execution + let txId = schedulerHandler.scheduleNextSchedulerExecution( + manager: manager, + forNumberOfRequests: 0, + ) + emit SchedulerUnpaused( + nextTransactionId: txId, + ) + } + } + + /// @notice Sets the maximum number of WorkerHandlers to be scheduled simultaneously + access(all) fun setMaxProcessingRequests(maxProcessingRequests: UInt8) { + pre { + maxProcessingRequests > 0: "Max processing requests must be greater than 0" + } + FlowYieldVaultsEVMWorkerOps.maxProcessingRequests = maxProcessingRequests + } + + /// @notice Sets the execution effort constants + /// @param key The key of the execution effort constant to set + /// @param value The value of the execution effort constant to set + access(all) fun setExecutionEffortConstants(key: String, value: UInt64) { + pre { + value > 0: "Execution effort must be greater than 0" + key == FlowYieldVaultsEVMWorkerOps.SCHEDULER_BASE_EFFORT || + key == FlowYieldVaultsEVMWorkerOps.SCHEDULER_PER_REQUEST_EFFORT || + key == FlowYieldVaultsEVMWorkerOps.WORKER_CREATE_YIELDVAULT_REQUEST_EFFORT || + key == FlowYieldVaultsEVMWorkerOps.WORKER_WITHDRAW_REQUEST_EFFORT || + key == FlowYieldVaultsEVMWorkerOps.WORKER_DEPOSIT_REQUEST_EFFORT || + key == FlowYieldVaultsEVMWorkerOps.WORKER_CLOSE_YIELDVAULT_REQUEST_EFFORT + : "Invalid key: \(key)" + value <= 9999: "Execution effort must be less than or equal to 9999" + } + FlowYieldVaultsEVMWorkerOps.executionEffortConstants[key] = value + } + + /// @notice Sets the interval at which the SchedulerHandler will be executed recurrently + access(all) fun setSchedulerWakeupInterval(schedulerWakeupInterval: UFix64) { + pre { + schedulerWakeupInterval > 0.0: "Scheduler wakeup interval must be greater than 0.0" + } + FlowYieldVaultsEVMWorkerOps.schedulerWakeupInterval = schedulerWakeupInterval + } + + /// @notice Creates a new WorkerHandler resource + /// @return The newly created WorkerHandler resource + access(all) fun createWorkerHandler( + workerCap: Capability<&FlowYieldVaultsEVM.Worker>, + ): @WorkerHandler { + pre { + workerCap.check(): "Worker capability is invalid (id: \(workerCap.id))" + } + return <- create WorkerHandler(workerCap: workerCap) + } + + /// @notice Creates a new SchedulerHandler resource + /// @return The newly created SchedulerHandler resource + access(all) fun createSchedulerHandler( + workerCap: Capability<&FlowYieldVaultsEVM.Worker>, + ): @SchedulerHandler { + pre { + workerCap.check(): "Worker capability is invalid (id: \(workerCap.id))" + } + return <- create SchedulerHandler(workerCap: workerCap) + } + + /// @notice Stores the scheduler transaction ID in SchedulerHandler state + /// @dev Used by bootstrap flows that schedule scheduler transactions outside SchedulerHandler.executeTransaction() + access(all) fun setNextSchedulerTransactionId(_ transactionId: UInt64) { + pre { + FlowYieldVaultsEVMWorkerOps._getSchedulerHandlerFromStorage() != nil: "SchedulerHandler resource not found" + } + let schedulerHandler = FlowYieldVaultsEVMWorkerOps._getSchedulerHandlerFromStorage()! + schedulerHandler.setNextSchedulerTransactionId(transactionId) + } + + /// @notice Pauses scheduler execution and cancels tracked in-flight WorkerHandler transactions + /// @dev This pauses new scheduling and cancels transactions tracked in scheduledRequests, refunding fees. + /// It also cancels the next scheduler transaction ID tracked by SchedulerHandler. + access(all) fun stopAll() { + pre { + FlowYieldVaultsEVMWorkerOps._getManagerFromStorage() != nil: "Scheduler manager not found" + FlowYieldVaultsEVMWorkerOps._getFlowTokenVaultFromStorage() != nil: "FlowToken vault not found" + FlowYieldVaultsEVMWorkerOps._getSchedulerHandlerFromStorage() != nil: "SchedulerHandler resource not found" + } + + // Step 1: Pause the SchedulerHandler to prevent any new scheduling during cancellation + self.pauseScheduler() + + // Borrow the scheduler Manager from storage + let manager = FlowYieldVaultsEVMWorkerOps._getManagerFromStorage()! + let workerHandler = FlowYieldVaultsEVMWorkerOps._getWorkerHandlerFromStorage()! + let worker = workerHandler.borrowWorker() + + let cancelledIds: [UInt64] = [] + + var totalRefunded = 0.0 + + // Borrow FlowToken vault to deposit refunded fees + let vaultRef = FlowYieldVaultsEVMWorkerOps._getFlowTokenVaultFromStorage()! + + // Step 2: Cancel each scheduled transaction, mark request as FAILED, and collect refunds + for scheduledRequestId in FlowYieldVaultsEVMWorkerOps.scheduledRequests.keys { + let request = FlowYieldVaultsEVMWorkerOps.scheduledRequests[scheduledRequestId]! + let refund <- manager.cancel(id: request.workerTransactionId) + totalRefunded = totalRefunded + refund.balance + vaultRef.deposit(from: <-refund) + cancelledIds.append(request.workerTransactionId) + + let markAsFailedResult = worker.markRequestAsFailed( + request.request, + message: "Worker transaction was cancelled by admin stopAll(). Transaction ID: \(request.workerTransactionId.toString())", + ) + if !markAsFailedResult { + emit StopAllMarkFailedSkipped( + requestId: scheduledRequestId, + workerTransactionId: request.workerTransactionId, + ) + } else { + FlowYieldVaultsEVMWorkerOps.scheduledRequests.remove(key: scheduledRequestId) + } + } + + // Step 3: Cancel scheduler execution + let schedulerHandler = FlowYieldVaultsEVMWorkerOps._getSchedulerHandlerFromStorage()! + if let schedulerTransactionId = schedulerHandler.nextSchedulerTransactionId { + let refund <- manager.cancel(id: schedulerTransactionId) + totalRefunded = totalRefunded + refund.balance + vaultRef.deposit(from: <-refund) + cancelledIds.append(schedulerTransactionId) + } + // Clear cached scheduler pointer to avoid stale transaction ID after cancellation. + schedulerHandler.clearNextSchedulerTransactionId() + + emit AllExecutionsStopped( + cancelledIds: cancelledIds, + totalRefunded: totalRefunded, + ) + } + } + + // ============================================ + // WorkerHandler Resource + // ============================================ + + /// @notice Handler that processes the given EVM requests + access(all) resource WorkerHandler: FlowTransactionScheduler.TransactionHandler { + + /// @notice Capability to the Worker resource for processing requests + access(self) let workerCap: Capability<&FlowYieldVaultsEVM.Worker> + + /// @notice Initializes the WorkerHandler + init( + workerCap: Capability<&FlowYieldVaultsEVM.Worker>, + ) { + pre { + workerCap.check(): "Worker capability is invalid (id: \(workerCap.id))" + } + self.workerCap = workerCap + } + + /// @notice Processes the assigned EVMRequest + /// @dev This is scheduled by the SchedulerHandler + /// @param id The transaction ID being executed + /// @param data - UInt256 - The request ID to process + access(FlowTransactionScheduler.Execute) fun executeTransaction(id: UInt64, data: AnyStruct?) { + + // Get the worker capability + let worker = self.workerCap.borrow()! + + var message = "" + var processResult: FlowYieldVaultsEVM.ProcessResult? = nil + + // Process assigned request + if let requestId = data as? UInt256 { + if let request = FlowYieldVaultsEVM.getRequestUnpacked(requestId) { + processResult = worker.processRequest(request) + message = "Request processed" + FlowYieldVaultsEVMWorkerOps.scheduledRequests.remove(key: requestId) + } else if let scheduledRequest = FlowYieldVaultsEVMWorkerOps.scheduledRequests[requestId] { + // Request lookup can fail transiently; attempt fail-marking with tracked payload first. + let markAsFailed = worker.markRequestAsFailed( + scheduledRequest.request, + message: "Request lookup failed in worker execution. Transaction ID: \(id.toString())", + ) + + if markAsFailed { + FlowYieldVaultsEVMWorkerOps.scheduledRequests.remove(key: requestId) + message = "Request not found and marked as failed: \(requestId.toString())" + } else { + // Keep tracking so scheduler recovery can retry failure handling later. + message = "Request not found and failed to mark as failed; retained for recovery: \(requestId.toString())" + } + } else { + message = "Request not found and not tracked: \(requestId.toString())" + } + } else { + message = "No valid request ID found" + } + + emit WorkerHandlerExecuted( + transactionId: id, + requestId: data as? UInt256, + processResult: processResult, + message: message, + ) + } + + /// @notice Borrows the Worker reference from the stored capability + /// @return The Worker reference + access(contract) view fun borrowWorker(): &FlowYieldVaultsEVM.Worker { + return self.workerCap.borrow()! + } + + /// @notice Returns the view types supported by the WorkerHandler + /// @return Array of supported view types + access(all) view fun getViews(): [Type] { + return [Type()] + } + + /// @notice Resolves a view for the WorkerHandler + /// @param view The view type to resolve + /// @return The resolved view value or nil + access(all) view fun resolveView(_ view: Type): AnyStruct? { + switch view { + case Type(): + return FlowYieldVaultsEVMWorkerOps.WorkerHandlerStoragePath + default: + return nil + } + } + + } + + // ============================================ + // SchedulerHandler Resource + // ============================================ + + /// @notice Recurrent handler that checks for pending requests and schedules WorkerHandlers to process them + /// @dev Also manages crash recovery for scheduled WorkerHandlers + access(all) resource SchedulerHandler: FlowTransactionScheduler.TransactionHandler { + + /// @notice Capability to the Worker resource for processing requests + access(self) let workerCap: Capability<&FlowYieldVaultsEVM.Worker> + + /// @notice Transaction ID of the next scheduled SchedulerHandler execution + access(all) var nextSchedulerTransactionId: UInt64? + + /// @notice Initializes the SchedulerHandler + init( + workerCap: Capability<&FlowYieldVaultsEVM.Worker>, + ) { + pre { + workerCap.check(): "Worker capability is invalid (id: \(workerCap.id))" + } + self.workerCap = workerCap + self.nextSchedulerTransactionId = nil + } + + /// @notice Executes the recurrent scheduler logic + /// @param id The transaction ID being executed + /// @param data Optional scheduler run capacity hint (UInt8). nil is treated as 0. + access(FlowTransactionScheduler.Execute) fun executeTransaction(id: UInt64, data: AnyStruct?) { + pre { + FlowYieldVaultsEVMWorkerOps._getManagerFromStorage() != nil: "Scheduler manager not found" + FlowYieldVaultsEVMWorkerOps._getWorkerHandlerFromStorage() != nil: "WorkerHandler resource not found" + FlowYieldVaultsEVMWorkerOps._getFlowTokenVaultFromStorage() != nil: "FlowToken vault not found" + } + + // Check if scheduler is paused + if FlowYieldVaultsEVMWorkerOps.isSchedulerPaused { + // Clear cached scheduler pointer since this execution won't schedule a next run. + self.clearNextSchedulerTransactionId() + emit SchedulerHandlerExecuted( + transactionId: id, + nextTransactionId: nil, + message: "Scheduler is paused", + pendingRequestCount: nil, + fetchCount: nil, + runCapacity: nil, + nextRunCapacity: nil, + ) + // Return without executing the main scheduler logic + // No further scheduler executions will be scheduled to save fees during paused state + return + } + + // Load scheduler manager and worker from storage + let manager = FlowYieldVaultsEVMWorkerOps._getManagerFromStorage()! + let worker = self.workerCap.borrow()! + + var message = "" + var nextRunCapacity: UInt8 = 0 + var pendingCount: Int? = nil + var fetchCount: Int? = nil + + // runCapacity: + // Scheduler budget passed in transaction data from previous scheduler run. + // nil means "no budget" (0). A value of N means this run can attempt up to N requests. + let runCapacity = data as? UInt8 ?? 0 + + // Calculate available capacity safely. + // Guard against underflow if maxProcessingRequests is reduced while requests are in flight. + let maxProcessingRequests = FlowYieldVaultsEVMWorkerOps.maxProcessingRequests + var currentInFlight = FlowYieldVaultsEVMWorkerOps.scheduledRequests.length + + // If capacity is saturated, run failed-worker recovery first to clear stale entries + // that would otherwise block pending-request processing. + if currentInFlight >= Int(maxProcessingRequests) { + self._checkForFailedWorkerRequests(manager: manager, worker: worker) + currentInFlight = FlowYieldVaultsEVMWorkerOps.scheduledRequests.length + } + + // capacityLimit: + // Remaining worker slots available right now, based on in-flight workers. + // capacityLimit = max(0, maxProcessingRequests - currentInFlight) + let capacityLimit: UInt8 = currentInFlight >= Int(maxProcessingRequests) + ? 0 + : maxProcessingRequests - UInt8(currentInFlight) + if capacityLimit > 0 { + // capacity: + // Effective per-run budget after applying both limits: + // - requested runCapacity + // - currently available worker slots (capacityLimit) + let capacity = runCapacity < capacityLimit ? runCapacity : capacityLimit + + // Check pending request count + if let pendingRequestCount = worker.getPendingRequestCountFromEVM() { + // pendingRequestCount: + // Total backlog currently pending on EVM at this moment. + pendingCount = pendingRequestCount + if pendingRequestCount > 0 { + + // fetchCount: + // Number of pending requests this run will actually fetch/process. + // fetchCount = min(pendingRequestCount, capacity) + fetchCount = pendingRequestCount > Int(capacity) ? Int(capacity) : pendingRequestCount + + // Run main scheduler logic + if let errorMessage = self._runScheduler( + manager: manager, + worker: worker, + fetchCount: fetchCount!, + ) { + message = "Scheduler failed with error: \(errorMessage)" + } else { + message = "Scheduler ran successfully" + } + + let stillPendingCount: Int = pendingRequestCount - fetchCount! + nextRunCapacity = stillPendingCount < Int(FlowYieldVaultsEVMWorkerOps.maxProcessingRequests) + ? UInt8(stillPendingCount) + : FlowYieldVaultsEVMWorkerOps.maxProcessingRequests + } + } else { + message = "ERROR fetching pending requests" + } + } + + // Schedule the next execution + let nextTransactionId = self.scheduleNextSchedulerExecution( + manager: manager, + forNumberOfRequests: nextRunCapacity, + ) + + emit SchedulerHandlerExecuted( + transactionId: id, + nextTransactionId: nextTransactionId, + message: message, + pendingRequestCount: pendingCount, + fetchCount: fetchCount, + runCapacity: runCapacity, + nextRunCapacity: nextRunCapacity, + ) + } + + /// @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 + /// 2. If fetchCount > 0, fetch pending requests from EVM + /// 3. Preprocess requests to drop invalid requests + /// 4. Start processing requests (PENDING -> PROCESSING) + /// 5. Schedule WorkerHandlers and assign request ids to them + /// @param manager The scheduler manager + /// @param worker The worker resource + /// @param fetchCount Number of pending requests to fetch in this run + /// @return Error message if any error occurred, nil otherwise + access(self) fun _runScheduler( + manager: auth(FlowTransactionSchedulerUtils.Owner) &{FlowTransactionSchedulerUtils.Manager}, + worker: &FlowYieldVaultsEVM.Worker, + fetchCount: Int, + ): String? { + // Check for failed worker requests + self._checkForFailedWorkerRequests(manager: manager, worker: worker) + + // Fetch pending requests from EVM + if fetchCount > 0 { + if let pendingRequests = worker.getPendingRequestsFromEVM( + startIndex: 0, + count: fetchCount, + ) { + // Preprocess requests (PENDING -> PROCESSING) + if let successfulRequests = worker.preprocessRequests(pendingRequests) { + // Schedule WorkerHandlers and assign request ids to them + self._scheduleWorkerHandlersForRequests( + requests: successfulRequests, + manager: manager, + ) + } else { + return "Failed to preprocess pending requests" + } + } else { + return "Failed to fetch pending requests" + } + } + + return nil // no error + } + + /// @notice Identifies failed WorkerHandlers (due to panic or revert) and marks the requests as FAILED + /// @dev Flow: + /// 1. Iterate over scheduledRequests + /// - scheduledRequests should only contain pending and reverted requests + /// 2. Check if the intended block height has been reached, continue if not + /// 3. Get transaction status for scheduled request from manager + /// - 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 from scheduledRequests only when fail-marking succeeds; otherwise retain for retry + /// @param manager The scheduler manager + /// @param worker The worker capability + access(self) fun _checkForFailedWorkerRequests( + manager: &{FlowTransactionSchedulerUtils.Manager}, + worker: &FlowYieldVaultsEVM.Worker, + ) { + for requestId in FlowYieldVaultsEVMWorkerOps.scheduledRequests.keys { + let request = FlowYieldVaultsEVMWorkerOps.scheduledRequests[requestId]! + + // Check block height + if getCurrentBlock().timestamp <= request.workerScheduledTimestamp { + // Expected timestamp is not reached yet, skip + continue + } + + // Check transaction status for scheduled requests to find reverts + let txId = request.workerTransactionId + let txStatus = manager.getTransactionStatus(id: txId) + + // Only acceptable status is Scheduled + // Handled requests by the worker should have been removed from scheduledRequests + // If manager cleaned up the transaction, the status will be nil + if txStatus == nil || txStatus != FlowTransactionScheduler.Status.Scheduled { + + // Fail request + let success = worker.markRequestAsFailed( + request.request, + message: "Worker transaction did not execute successfully. Transaction ID: \(txId.toString())", + ) + + // Remove from tracking only on successful fail-marking. + // If fail-marking fails, retain tracking so future recovery can retry. + if success { + FlowYieldVaultsEVMWorkerOps.scheduledRequests.remove(key: requestId) + } + + emit WorkerHandlerPanicDetected( + status: txStatus?.rawValue, + markedAsFailed: success, + request: request, + ) + } + } + } + + /// @notice Schedules WorkerHandlers for the given requests + /// @dev Flow: + /// 1. Iterate over given requests + /// 2. Decide delay + /// - Immediate execution is default + /// - If multiple requests from same user, offset delay by user request count to run them sequentially + /// 3. Schedule WorkerHandlers and pass request info + /// 4. Track scheduled request in contract state to be able to identify failed requests + /// @param requests The requests to schedule + /// @param manager The scheduler manager + access(self) fun _scheduleWorkerHandlersForRequests( + requests: [FlowYieldVaultsEVM.EVMRequest], + manager: auth(FlowTransactionSchedulerUtils.Owner) &{FlowTransactionSchedulerUtils.Manager}, + ) { + let workerHandler = FlowYieldVaultsEVMWorkerOps._getWorkerHandlerFromStorage()! + + // WorkerHandler scheduling parameters + let baseDelay = 1.0 + + // Borrow FlowToken vault to pay scheduling fees + let vaultRef = FlowYieldVaultsEVMWorkerOps._getFlowTokenVaultFromStorage()! + + // Track user request count for scheduling offset + let userScheduleOffset: {String: Int} = {} // user address -> request count - 1 + for request in requests { + + // Count user requests for scheduling + let key = request.user.toString() + if userScheduleOffset[key] == nil { + // first request for user is scheduled without additional delay + userScheduleOffset[key] = 0 + } else { + // subsequent requests are scheduled with an offset + userScheduleOffset[key] = userScheduleOffset[key]! + 1 + } + + // Offset delay by user request count + // We assume the original list is sorted by user action timestamp + // and no action changes order of requests + let delay = baseDelay + UFix64(userScheduleOffset[key]!) + var executionEffort: UInt64 = 0 + switch request.requestType { + case FlowYieldVaultsEVM.RequestType.CREATE_YIELDVAULT.rawValue: + executionEffort = FlowYieldVaultsEVMWorkerOps.executionEffortConstants[ + FlowYieldVaultsEVMWorkerOps.WORKER_CREATE_YIELDVAULT_REQUEST_EFFORT + ]! + case FlowYieldVaultsEVM.RequestType.WITHDRAW_FROM_YIELDVAULT.rawValue: + executionEffort = FlowYieldVaultsEVMWorkerOps.executionEffortConstants[ + FlowYieldVaultsEVMWorkerOps.WORKER_WITHDRAW_REQUEST_EFFORT + ]! + case FlowYieldVaultsEVM.RequestType.DEPOSIT_TO_YIELDVAULT.rawValue: + executionEffort = FlowYieldVaultsEVMWorkerOps.executionEffortConstants[ + FlowYieldVaultsEVMWorkerOps.WORKER_DEPOSIT_REQUEST_EFFORT + ]! + case FlowYieldVaultsEVM.RequestType.CLOSE_YIELDVAULT.rawValue: + executionEffort = FlowYieldVaultsEVMWorkerOps.executionEffortConstants[ + FlowYieldVaultsEVMWorkerOps.WORKER_CLOSE_YIELDVAULT_REQUEST_EFFORT + ]! + } + + // Schedule transaction + let transactionId = self._scheduleTransaction( + manager: manager, + handlerTypeIdentifier: workerHandler.getType().identifier, + data: request.id, + delay: delay, + executionEffort: executionEffort, + ) + + // Track scheduled request in contract state + let scheduledRequest = ScheduledEVMRequest( + request: request, + workerTransactionId: transactionId, + workerScheduledTimestamp: getCurrentBlock().timestamp + delay, + ) + + emit WorkerHandlerScheduled( + scheduledRequest: scheduledRequest + ) + + FlowYieldVaultsEVMWorkerOps.scheduledRequests.insert(key: request.id, scheduledRequest) + + } + } + + /// @notice Schedules the next recurrent execution for SchedulerHandler + /// @param manager The scheduler manager + access(contract) fun scheduleNextSchedulerExecution( + manager: auth(FlowTransactionSchedulerUtils.Owner) &{FlowTransactionSchedulerUtils.Manager}, + forNumberOfRequests: UInt8, + ): UInt64 { + // Scheduler parameters + let baseEffort = FlowYieldVaultsEVMWorkerOps.executionEffortConstants[ + FlowYieldVaultsEVMWorkerOps.SCHEDULER_BASE_EFFORT + ]! + let perRequestEffort = FlowYieldVaultsEVMWorkerOps.executionEffortConstants[ + FlowYieldVaultsEVMWorkerOps.SCHEDULER_PER_REQUEST_EFFORT + ]! + let executionEffort = baseEffort + UInt64(forNumberOfRequests) * perRequestEffort + + let transactionId = self._scheduleTransaction( + manager: manager, + handlerTypeIdentifier: self.getType().identifier, + data: forNumberOfRequests, + delay: FlowYieldVaultsEVMWorkerOps.schedulerWakeupInterval, + executionEffort: executionEffort, + ) + self.setNextSchedulerTransactionId(transactionId) + return transactionId + } + + /// @notice Sets the cached next scheduler transaction ID + access(contract) fun setNextSchedulerTransactionId(_ transactionId: UInt64?) { + self.nextSchedulerTransactionId = transactionId + } + + /// @notice Clears the cached next scheduler transaction ID + /// @dev Used by Admin.stopAll() after cancelling scheduler execution + access(contract) fun clearNextSchedulerTransactionId() { + self.setNextSchedulerTransactionId(nil) + } + + /// @notice Helper function to schedule a transaction for the SchedulerHandler + /// @dev This function is used for both recurrent scheduling and WorkerHandler scheduling + /// @param manager The scheduler manager + /// @param handlerTypeIdentifier The type identifier of the handler + /// @param data The data to pass to the handler + /// @param delay The delay in seconds + /// @return The transaction ID + access(self) fun _scheduleTransaction( + manager: auth(FlowTransactionSchedulerUtils.Owner) &{FlowTransactionSchedulerUtils.Manager}, + handlerTypeIdentifier: String, + data: AnyStruct?, + delay: UFix64, + executionEffort: UInt64, + ): UInt64 { + // Calculate the target execution timestamp + let future = getCurrentBlock().timestamp + delay + + // Determine priority based on execution effort + var priority = FlowTransactionScheduler.Priority.Low + if executionEffort > 2500 && executionEffort < 7500 { + priority = FlowTransactionScheduler.Priority.Medium + } else if executionEffort >= 7500 { + priority = FlowTransactionScheduler.Priority.High + } + + // Borrow FlowToken vault to pay scheduling fees + let vaultRef = FlowYieldVaultsEVMWorkerOps._getFlowTokenVaultFromStorage()! + + // Estimate fees and withdraw payment + // calculateFee() is not supported by Flow emulator. When emulator is updated, following code can be uncommented. + // let dataSizeMB = 0.0 + // let fee = FlowTransactionScheduler.calculateFee( + // executionEffort: executionEffort, + // priority: priority, + // dataSizeMB: dataSizeMB, + // ) + // let fees <- vaultRef.withdraw(amount: fee) as! @FlowToken.Vault + let estimate = FlowTransactionScheduler.estimate( + data: data, + timestamp: future, + priority: priority, + executionEffort: executionEffort + ) + let fees <- vaultRef.withdraw(amount: estimate.flowFee ?? 0.0) as! @FlowToken.Vault + + // Schedule the transaction + let transactionId = manager.scheduleByHandler( + handlerTypeIdentifier: handlerTypeIdentifier, + handlerUUID: nil, + data: data, + timestamp: future, + priority: priority, + executionEffort: executionEffort, + fees: <-fees + ) + + return transactionId + } + + /// @notice Returns the view types supported by this handler + /// @return Array of supported view types + access(all) view fun getViews(): [Type] { + return [Type()] + } + + /// @notice Resolves a view for this handler + /// @param view The view type to resolve + /// @return The resolved view value or nil + access(all) view fun resolveView(_ view: Type): AnyStruct? { + switch view { + case Type(): + return FlowYieldVaultsEVMWorkerOps.SchedulerHandlerStoragePath + default: + return nil + } + } + + } + + // ============================================ + // Internal Helper View Functions + // ============================================ + + /// @notice Gets the Manager from contract storage for managing scheduled transactions + /// @return The manager or nil if not found + access(self) view fun _getManagerFromStorage(): + auth(FlowTransactionSchedulerUtils.Owner) &{FlowTransactionSchedulerUtils.Manager}? { + return FlowYieldVaultsEVMWorkerOps.account.storage + .borrow + (from: FlowTransactionSchedulerUtils.managerStoragePath) + } + + /// @notice Gets the WorkerHandler from contract storage + /// @return The WorkerHandler or nil if not found + access(self) view fun _getWorkerHandlerFromStorage(): &WorkerHandler? { + return FlowYieldVaultsEVMWorkerOps.account.storage + .borrow<&WorkerHandler> + (from: FlowYieldVaultsEVMWorkerOps.WorkerHandlerStoragePath) + } + + /// @notice Gets the SchedulerHandler from contract storage + /// @return The SchedulerHandler or nil if not found + access(self) view fun _getSchedulerHandlerFromStorage(): &SchedulerHandler? { + return FlowYieldVaultsEVMWorkerOps.account.storage + .borrow<&SchedulerHandler> + (from: FlowYieldVaultsEVMWorkerOps.SchedulerHandlerStoragePath) + } + + /// @notice Gets the FlowToken vault from contract storage + /// @return The FlowToken vault or nil if not found + access(self) view fun _getFlowTokenVaultFromStorage(): + auth(FungibleToken.Withdraw) &FlowToken.Vault? { + return FlowYieldVaultsEVMWorkerOps.account.storage + .borrow + (from: /storage/flowTokenVault) + } + + // ============================================ + // Data Structures + // ============================================ + + /// @notice Data structure to track scheduled EVM requests + access(all) struct ScheduledEVMRequest { + /// @notice The EVM request to be processed + access(all) let request: FlowYieldVaultsEVM.EVMRequest + /// @notice The transaction ID of the scheduled WorkerHandler + access(all) let workerTransactionId: UInt64 + /// @notice The timestamp when the scheduled WorkerHandler is scheduled to execute + access(all) let workerScheduledTimestamp: UFix64 + + init( + request: FlowYieldVaultsEVM.EVMRequest, + workerTransactionId: UInt64, + workerScheduledTimestamp: UFix64, + ) { + self.request = request + self.workerTransactionId = workerTransactionId + self.workerScheduledTimestamp = workerScheduledTimestamp + } + } + + // ============================================ + // Public Functions + // ============================================ + + /// @notice Returns the current SchedulerHandler paused state + /// @return True if scheduler is paused, false otherwise + access(all) view fun getIsSchedulerPaused(): Bool { + return self.isSchedulerPaused + } + + // ============================================ + // Initialization + // ============================================ + + init() { + self.WorkerHandlerStoragePath = /storage/FlowYieldVaultsEVMWorkerOpsWorkerHandler + self.SchedulerHandlerStoragePath = /storage/FlowYieldVaultsEVMWorkerOpsSchedulerHandler + self.AdminStoragePath = /storage/FlowYieldVaultsEVMWorkerOpsAdmin + + self.SCHEDULER_BASE_EFFORT = "schedulerBaseEffort" + self.SCHEDULER_PER_REQUEST_EFFORT = "schedulerPerRequestEffort" + self.WORKER_CREATE_YIELDVAULT_REQUEST_EFFORT = "workerCreateYieldVaultRequestEffort" + self.WORKER_WITHDRAW_REQUEST_EFFORT = "workerWithdrawRequestEffort" + self.WORKER_DEPOSIT_REQUEST_EFFORT = "workerDepositRequestEffort" + self.WORKER_CLOSE_YIELDVAULT_REQUEST_EFFORT = "workerCloseYieldVaultRequestEffort" + + self.executionEffortConstants = { + self.SCHEDULER_BASE_EFFORT: 700, + self.SCHEDULER_PER_REQUEST_EFFORT: 1000, + self.WORKER_CREATE_YIELDVAULT_REQUEST_EFFORT: 5000, + self.WORKER_WITHDRAW_REQUEST_EFFORT: 2000, + self.WORKER_DEPOSIT_REQUEST_EFFORT: 2000, + self.WORKER_CLOSE_YIELDVAULT_REQUEST_EFFORT: 5000 + } + + self.scheduledRequests = {} + self.isSchedulerPaused = false + + self.schedulerWakeupInterval = 1.0 + self.maxProcessingRequests = 3 + + let admin <- create Admin() + self.account.storage.save(<-admin, to: self.AdminStoragePath) + } +} diff --git a/cadence/contracts/FlowYieldVaultsTransactionHandler.cdc b/cadence/contracts/FlowYieldVaultsTransactionHandler.cdc deleted file mode 100644 index 2917472..0000000 --- a/cadence/contracts/FlowYieldVaultsTransactionHandler.cdc +++ /dev/null @@ -1,560 +0,0 @@ -import "FlowTransactionScheduler" -import "FlowTransactionSchedulerUtils" -import "FlowYieldVaultsEVM" -import "FlowToken" -import "FungibleToken" - -/// @title FlowYieldVaultsTransactionHandler -/// @author Flow YieldVaults Team -/// @notice Handler contract for scheduled FlowYieldVaultsEVM request processing with auto-scheduling. -/// @dev This contract manages the automated execution of EVM request processing through the -/// FlowTransactionScheduler. After each execution, it automatically schedules the next -/// execution based on the current workload. -/// -/// Key features: -/// - Dynamic delay adjustment based on pending request count -/// - Cost-optimized idle polling (low effort when no pending requests) -/// - Pausable execution for maintenance -/// -/// Delay thresholds: -/// - >= 11 pending: 3s delay (high load) -/// - >= 5 pending: 5s delay (medium load) -/// - >= 1 pending: 7s delay (low load) -/// - 0 pending: 30s delay (idle) -access(all) contract FlowYieldVaultsTransactionHandler { - - // ============================================ - // State Variables - // ============================================ - - /// @notice Storage path for Handler resource - access(all) let HandlerStoragePath: StoragePath - - /// @notice Public path for Handler capability - access(all) let HandlerPublicPath: PublicPath - - /// @notice Storage path for Admin resource - access(all) let AdminStoragePath: StoragePath - - /// @notice Mapping of pending request thresholds to execution delays (in seconds) - /// @dev Higher pending counts result in shorter delays for faster processing - access(contract) var thresholdToDelay: {Int: UFix64} - - /// @notice Default delay when no threshold matches - access(all) let defaultDelay: UFix64 - - /// @notice When true, scheduled executions skip processing and don't schedule next execution - access(contract) var isPaused: Bool - - /// @notice Base execution effort per request processed - /// @dev Total executionEffort = baseEffortPerRequest * maxRequestsPerTx + baseOverhead - access(contract) var baseEffortPerRequest: UInt64 - - /// @notice Base overhead for transaction execution (independent of request count) - access(contract) var baseOverhead: UInt64 - - /// @notice Minimal execution effort used when idle (no pending requests) - /// @dev Keeps costs low for polling transactions that won't process anything - access(contract) var idleExecutionEffort: UInt64 - - // ============================================ - // Events - // ============================================ - - /// @notice Emitted when the handler is paused - access(all) event HandlerPaused() - - /// @notice Emitted when the handler is unpaused - access(all) event HandlerUnpaused() - - /// @notice Emitted when thresholdToDelay mapping is updated - /// @param oldThresholds The previous threshold to delay mapping - /// @param newThresholds The new threshold to delay mapping - access(all) event ThresholdToDelayUpdated(oldThresholds: {Int: UFix64}, newThresholds: {Int: UFix64}) - - /// @notice Emitted when execution effort parameters are updated - /// @param oldBaseEffortPerRequest Previous base effort per request - /// @param oldBaseOverhead Previous base overhead - /// @param oldIdleExecutionEffort Previous idle execution effort - /// @param newBaseEffortPerRequest New base effort per request - /// @param newBaseOverhead New base overhead - /// @param newIdleExecutionEffort New idle execution effort - access(all) event ExecutionEffortParamsUpdated( - oldBaseEffortPerRequest: UInt64, - oldBaseOverhead: UInt64, - oldIdleExecutionEffort: UInt64, - newBaseEffortPerRequest: UInt64, - newBaseOverhead: UInt64, - newIdleExecutionEffort: UInt64 - ) - - /// @notice Emitted when a scheduled execution is triggered - /// @param transactionId The transaction ID that was executed - /// @param maxRequestsPerTx The maximum number of requests that could be processed - /// @param executionEffort The execution effort used for this transaction - /// @param pendingRequests Number of pending requests after processing - /// @param nextExecutionDelaySeconds Delay until next execution - access(all) event ScheduledExecutionTriggered( - transactionId: UInt64, - maxRequestsPerTx: Int, - executionEffort: UInt64, - pendingRequests: Int, - nextExecutionDelaySeconds: UFix64 - ) - - /// @notice Emitted when next execution is scheduled (single transaction) - /// @param transactionId The scheduled transaction ID - /// @param scheduledFor Timestamp when execution is scheduled - /// @param delaySeconds Delay from current time - /// @param pendingRequests Current pending request count - access(all) event NextExecutionScheduled( - transactionId: UInt64, - scheduledFor: UFix64, - delaySeconds: UFix64, - pendingRequests: Int - ) - - /// @notice Emitted when execution is skipped - /// @param transactionId The transaction ID that was skipped - /// @param reason Why the execution was skipped - access(all) event ExecutionSkipped( - transactionId: UInt64, - reason: String - ) - - /// @notice Emitted when all scheduled executions are stopped and cancelled - /// @param cancelledIds Array of cancelled transaction IDs - /// @param totalRefunded Total amount of FLOW refunded - access(all) event AllExecutionsStopped( - cancelledIds: [UInt64], - totalRefunded: UFix64 - ) - - // ============================================ - // Resources - // ============================================ - - /// @notice Admin resource for handler configuration - /// @dev Only the contract deployer receives this resource - access(all) resource Admin { - - /// @notice Pauses the handler, stopping all scheduled executions - access(all) fun pause() { - FlowYieldVaultsTransactionHandler.isPaused = true - emit HandlerPaused() - } - - /// @notice Unpauses the handler, resuming scheduled executions - access(all) fun unpause() { - FlowYieldVaultsTransactionHandler.isPaused = false - emit HandlerUnpaused() - } - - /// @notice Updates the threshold to delay mapping - /// @param newThresholds The new mapping of pending request thresholds to delays - access(all) fun setThresholdToDelay(newThresholds: {Int: UFix64}) { - pre { - newThresholds.length > 0: "Thresholds mapping cannot be empty (got length: \(newThresholds.length))" - } - let oldThresholds = FlowYieldVaultsTransactionHandler.thresholdToDelay - FlowYieldVaultsTransactionHandler.thresholdToDelay = newThresholds - emit ThresholdToDelayUpdated(oldThresholds: oldThresholds, newThresholds: newThresholds) - } - - /// @notice Updates execution effort calculation parameters - /// @dev executionEffort = baseEffortPerRequest * maxRequestsPerTx + baseOverhead - /// @param baseEffortPerRequest Effort units per request (e.g., 2000 for EVM calls) - /// @param baseOverhead Fixed overhead regardless of request count (e.g., 3000) - /// @param idleExecutionEffort Minimal effort when no pending requests (e.g., 3000 to handle burst arrivals) - access(all) fun setExecutionEffortParams(baseEffortPerRequest: UInt64, baseOverhead: UInt64, idleExecutionEffort: UInt64) { - pre { - baseEffortPerRequest > 0: "baseEffortPerRequest must be greater than 0 but got \(baseEffortPerRequest)" - idleExecutionEffort > 0: "idleExecutionEffort must be greater than 0 but got \(idleExecutionEffort)" - } - let oldBaseEffortPerRequest = FlowYieldVaultsTransactionHandler.baseEffortPerRequest - let oldBaseOverhead = FlowYieldVaultsTransactionHandler.baseOverhead - let oldIdleExecutionEffort = FlowYieldVaultsTransactionHandler.idleExecutionEffort - - FlowYieldVaultsTransactionHandler.baseEffortPerRequest = baseEffortPerRequest - FlowYieldVaultsTransactionHandler.baseOverhead = baseOverhead - FlowYieldVaultsTransactionHandler.idleExecutionEffort = idleExecutionEffort - - emit ExecutionEffortParamsUpdated( - oldBaseEffortPerRequest: oldBaseEffortPerRequest, - oldBaseOverhead: oldBaseOverhead, - oldIdleExecutionEffort: oldIdleExecutionEffort, - newBaseEffortPerRequest: baseEffortPerRequest, - newBaseOverhead: baseOverhead, - newIdleExecutionEffort: idleExecutionEffort - ) - } - - /// @notice Stops all scheduled executions by pausing and cancelling all pending transactions - /// @dev This will pause the handler and cancel all scheduled transactions, refunding fees. - /// Flow: - /// 1. Pauses the handler to prevent new scheduling - /// 2. Borrows the scheduler Manager - /// 3. Cancels each pending transaction and collects refunds - /// 4. Returns summary of cancelled IDs and total refunded - /// @return Dictionary with cancelledIds array and totalRefunded amount - access(all) fun stopAll(): {String: AnyStruct} { - // Step 1: Pause to prevent any new scheduling during cancellation - FlowYieldVaultsTransactionHandler.isPaused = true - emit HandlerPaused() - - // Step 2: Borrow the scheduler Manager from storage - let manager = FlowYieldVaultsTransactionHandler.account.storage - .borrow( - from: FlowTransactionSchedulerUtils.managerStoragePath - ) - - let cancelledIds: [UInt64] = [] - - // Handle case where Manager doesn't exist yet - if manager == nil { - emit AllExecutionsStopped(cancelledIds: [], totalRefunded: 0.0) - return { - "cancelledIds": cancelledIds, - "totalRefunded": 0.0 - } - } - - // Step 3: Get all pending transaction IDs and prepare for refunds - let transactionIds = manager!.getTransactionIDs() - var totalRefunded: UFix64 = 0.0 - - // Borrow vault to deposit refunded fees - let vaultRef = FlowYieldVaultsTransactionHandler.account.storage - .borrow<&FlowToken.Vault>(from: /storage/flowTokenVault) - ?? panic("Could not borrow FlowToken vault from /storage/flowTokenVault for contract account") - - // Step 4: Cancel each scheduled transaction and collect refunds - for id in transactionIds { - let refund <- manager!.cancel(id: id) - totalRefunded = totalRefunded + refund.balance - vaultRef.deposit(from: <-refund) - cancelledIds.append(id) - } - - emit AllExecutionsStopped(cancelledIds: cancelledIds, totalRefunded: totalRefunded) - - return { - "cancelledIds": cancelledIds, - "totalRefunded": totalRefunded - } - } - } - - /// @notice Handler resource that implements FlowTransactionScheduler.TransactionHandler - /// @dev Processes EVM requests and auto-schedules next execution based on workload - access(all) resource Handler: FlowTransactionScheduler.TransactionHandler { - - /// @notice Capability to the Worker resource for processing requests - access(self) let workerCap: Capability<&FlowYieldVaultsEVM.Worker> - - /// @notice Counter tracking the total number of executions performed - access(self) var executionCount: UInt64 - - /// @notice Timestamp of the last execution, nil if never executed - access(self) var lastExecutionTime: UFix64? - - /// @notice Initializes the Handler with a Worker capability - /// @dev Validates that the Worker capability is valid on initialization. - /// Initializes execution tracking counters. - /// @param workerCap Capability to the FlowYieldVaultsEVM.Worker resource - init(workerCap: Capability<&FlowYieldVaultsEVM.Worker>) { - pre { - workerCap.check(): "Worker capability is invalid (id: \(workerCap.id))" - } - self.workerCap = workerCap - self.executionCount = 0 - self.lastExecutionTime = nil - } - - /// @notice Executes the scheduled transaction - /// @dev Called by FlowTransactionScheduler when the scheduled time arrives. - /// Processes requests and schedules the next execution. - /// Priority and execution effort are calculated dynamically based on maxRequestsPerTx. - /// @param id The transaction ID being executed - /// @param data Unused - priority and effort calculated dynamically from contract state - access(FlowTransactionScheduler.Execute) fun executeTransaction(id: UInt64, data: AnyStruct?) { - // Step 1: Check if handler is paused - if FlowYieldVaultsTransactionHandler.isPaused { - emit ExecutionSkipped(transactionId: id, reason: "Handler is paused") - return - } - - // Step 2: Borrow the Worker capability - let worker = self.workerCap.borrow() - if worker == nil { - emit ExecutionSkipped(transactionId: id, reason: "Could not borrow Worker capability (id: \(self.workerCap.id))") - return - } - - // Step 3: Process pending requests using the Worker - let maxRequestsPerTx = FlowYieldVaultsEVM.getMaxRequestsPerTx() - worker!.processRequests(startIndex: 0, count: maxRequestsPerTx) - - // Step 4: Calculate dynamic execution effort and priority for next execution - // Higher request counts require more effort; effort > 7500 triggers High priority - let effortAndPriority = FlowYieldVaultsTransactionHandler.calculateExecutionEffortAndPriority(maxRequestsPerTx) - let executionEffort = effortAndPriority["effort"]! as! UInt64 - let priorityRaw = effortAndPriority["priority"]! as! UInt8 - - let priority = priorityRaw == 0 - ? FlowTransactionScheduler.Priority.High - : FlowTransactionScheduler.Priority.Medium - - // Step 5: Update execution statistics - self.executionCount = self.executionCount + 1 - self.lastExecutionTime = getCurrentBlock().timestamp - - // Step 6: Determine next execution delay based on remaining pending requests - let pendingRequests = self.getPendingRequestCount(worker!) - let nextDelay = FlowYieldVaultsTransactionHandler.getDelayForPendingCount(pendingRequests) - - emit ScheduledExecutionTriggered( - transactionId: id, - maxRequestsPerTx: maxRequestsPerTx, - executionEffort: executionEffort, - pendingRequests: pendingRequests, - nextExecutionDelaySeconds: nextDelay - ) - - // Step 7: Schedule next execution with appropriate priority and effort - // When idle (no pending requests), use Medium priority with capped effort to reduce costs - if pendingRequests == 0 { - let cappedEffort = executionEffort < FlowYieldVaultsTransactionHandler.idleExecutionEffort - ? executionEffort - : FlowYieldVaultsTransactionHandler.idleExecutionEffort - self.scheduleNextExecution( - nextDelay: nextDelay, - priority: FlowTransactionScheduler.Priority.Medium, - executionEffort: cappedEffort, - pendingRequests: pendingRequests - ) - } else { - self.scheduleNextExecution(nextDelay: nextDelay, priority: priority, executionEffort: executionEffort, pendingRequests: pendingRequests) - } - } - - /// @notice Schedules the next execution with the FlowTransactionScheduler - /// @dev Calculates the future timestamp, estimates fees, withdraws from FlowToken vault, - /// and schedules via the Manager. Emits NextExecutionScheduled event on success. - /// @param nextDelay The delay in seconds until the next execution - /// @param priority The execution priority (High or Medium) - /// @param executionEffort The execution effort units to allocate - /// @param pendingRequests Current pending request count (for event emission) - access(self) fun scheduleNextExecution(nextDelay: UFix64, priority: FlowTransactionScheduler.Priority, executionEffort: UInt64, pendingRequests: Int) { - // Calculate the target execution timestamp - let future = getCurrentBlock().timestamp + nextDelay - - // Borrow the scheduler Manager from storage - let manager = FlowYieldVaultsTransactionHandler.account.storage - .borrow( - from: FlowTransactionSchedulerUtils.managerStoragePath - ) - ?? panic("Could not borrow Manager reference from \(FlowTransactionSchedulerUtils.managerStoragePath) for contract account") - - // Get the handler type identifier (should be this Handler's type) - let handlerTypeIdentifiers = manager.getHandlerTypeIdentifiers() - assert(handlerTypeIdentifiers.keys.length > 0, message: "No handler types found in manager (registered handlers count: \(handlerTypeIdentifiers.keys.length))") - let handlerTypeIdentifier = handlerTypeIdentifiers.keys[0] - - // Borrow FlowToken vault to pay scheduling fees - let vaultRef = FlowYieldVaultsTransactionHandler.account.storage - .borrow(from: /storage/flowTokenVault) - ?? panic("Could not borrow FlowToken vault from /storage/flowTokenVault for contract account") - - // Estimate fees and withdraw payment - let estimate = FlowTransactionScheduler.estimate( - data: [], - timestamp: future, - priority: priority, - executionEffort: executionEffort - ) - let fees <- vaultRef.withdraw(amount: estimate.flowFee ?? 0.0) as! @FlowToken.Vault - - // Schedule the next execution - let transactionId = manager.scheduleByHandler( - handlerTypeIdentifier: handlerTypeIdentifier, - handlerUUID: self.uuid, - data: [], - timestamp: future, - priority: priority, - executionEffort: executionEffort, - fees: <-fees - ) - - emit NextExecutionScheduled( - transactionId: transactionId, - scheduledFor: future, - delaySeconds: nextDelay, - pendingRequests: pendingRequests - ) - } - - /// @notice Returns the view types supported by this handler - /// @return Array of supported view types - access(all) view fun getViews(): [Type] { - return [Type(), Type()] - } - - /// @notice Resolves a view for this handler - /// @param view The view type to resolve - /// @return The resolved view value or nil - access(all) view fun resolveView(_ view: Type): AnyStruct? { - switch view { - case Type(): - return FlowYieldVaultsTransactionHandler.HandlerStoragePath - case Type(): - return FlowYieldVaultsTransactionHandler.HandlerPublicPath - default: - return nil - } - } - - /// @notice Gets the current count of pending requests from the EVM contract - /// @dev Delegates to the Worker's getPendingRequestCountFromEVM method - /// @param worker Reference to the Worker resource - /// @return The number of pending requests - access(self) fun getPendingRequestCount(_ worker: &FlowYieldVaultsEVM.Worker): Int { - return worker.getPendingRequestCountFromEVM() - } - - /// @notice Returns handler execution statistics - /// @return Dictionary with executionCount and lastExecutionTime - access(all) view fun getStats(): {String: AnyStruct} { - return { - "executionCount": self.executionCount, - "lastExecutionTime": self.lastExecutionTime - } - } - } - - // ============================================ - // Public Functions - // ============================================ - - /// @notice Creates a new Handler resource - /// @param workerCap Capability to the FlowYieldVaultsEVM.Worker - /// @return The newly created Handler resource - access(all) fun createHandler(workerCap: Capability<&FlowYieldVaultsEVM.Worker>): @Handler { - return <- create Handler(workerCap: workerCap) - } - - /// @notice Returns the current paused state - /// @return True if paused, false otherwise - access(all) view fun getIsPaused(): Bool { - return self.isPaused - } - - /// @notice Returns the current threshold to delay mapping - /// @return Dictionary mapping pending request thresholds to delays in seconds - access(all) view fun getThresholdToDelay(): {Int: UFix64} { - return self.thresholdToDelay - } - - /// @notice Returns the current execution effort parameters - /// @return Dictionary with baseEffortPerRequest, baseOverhead, and idleExecutionEffort - access(all) view fun getExecutionEffortParams(): {String: UInt64} { - return { - "baseEffortPerRequest": self.baseEffortPerRequest, - "baseOverhead": self.baseOverhead, - "idleExecutionEffort": self.idleExecutionEffort - } - } - - /// @notice Calculates the appropriate delay based on pending request count - /// @dev Finds the highest threshold that pendingCount meets or exceeds. - /// Example with default thresholds: {0: 30s, 1: 7s, 5: 5s, 11: 3s} - /// - pendingCount=15 matches thresholds 0, 1, 5, 11 -> uses 11 -> 3s delay - /// - pendingCount=7 matches thresholds 0, 1, 5 -> uses 5 -> 5s delay - /// - pendingCount=0 matches threshold 0 -> 30s delay (idle) - /// @param pendingCount The current number of pending requests - /// @return The delay in seconds for the next execution - access(all) view fun getDelayForPendingCount(_ pendingCount: Int): UFix64 { - // Find the highest threshold that pendingCount meets or exceeds - var bestThreshold: Int? = nil - - for threshold in self.thresholdToDelay.keys { - if pendingCount >= threshold { - // Take the highest matching threshold for the shortest delay - if bestThreshold == nil || threshold > bestThreshold! { - bestThreshold = threshold - } - } - } - - if let threshold = bestThreshold { - return self.thresholdToDelay[threshold] ?? self.defaultDelay - } - - return self.defaultDelay - } - - /// @notice Calculates execution effort and determines appropriate priority - /// @dev Formula: baseEffortPerRequest * requestCount + baseOverhead - /// Default values: 2000 * requestCount + 3000 - /// Examples: - /// - 1 request: 2000*1 + 3000 = 5000 (Medium priority) - /// - 2 requests: 2000*2 + 3000 = 7000 (Medium priority) - /// - 3 requests: 2000*3 + 3000 = 9000 (High priority, capped) - /// If calculated > 7500, uses High priority (max 9999) - /// Otherwise uses Medium priority (max 7500) - /// @param requestCount The number of requests to process (typically maxRequestsPerTx) - /// @return Dictionary with "effort" (UInt64) and "priority" (UInt8: 0=High, 1=Medium) - access(all) view fun calculateExecutionEffortAndPriority(_ requestCount: Int): {String: AnyStruct} { - // Calculate effort using formula: baseEffortPerRequest * requestCount + baseOverhead - let calculated = self.baseEffortPerRequest * UInt64(requestCount) + self.baseOverhead - - // Determine priority based on effort threshold - // High priority allows up to 9999 effort, Medium allows up to 7500 - if calculated > 7500 { - // Need High priority; cap effort at 9999 - let capped = calculated < 9999 ? calculated : 9999 - return { - "effort": capped, - "priority": 0 as UInt8 // 0 = High - } - } else { - // Medium priority is sufficient - return { - "effort": calculated, - "priority": 1 as UInt8 // 1 = Medium - } - } - } - - // ============================================ - // Initialization - // ============================================ - - init() { - self.HandlerStoragePath = /storage/FlowYieldVaultsTransactionHandler - self.HandlerPublicPath = /public/FlowYieldVaultsTransactionHandler - self.AdminStoragePath = /storage/FlowYieldVaultsTransactionHandlerAdmin - self.isPaused = false - self.defaultDelay = 30.0 - self.thresholdToDelay = { - 11: 3.0, - 5: 5.0, - 1: 7.0, - 0: 30.0 - } - - // Execution effort calculation parameters - // Formula: baseEffortPerRequest * maxRequestsPerTx + baseOverhead - // Default: 2000 * 1 + 3000 = 5000 for 1 request - // 2000 * 2 + 3000 = 7000 for 2 requests - self.baseEffortPerRequest = 2000 - self.baseOverhead = 3000 - - // Minimal execution effort for idle polling (no pending requests) - // Set to 5000 for Medium priority to handle burst arrivals after idle scheduling - self.idleExecutionEffort = 5000 - - let admin <- create Admin() - self.account.storage.save(<-admin, to: self.AdminStoragePath) - } -} diff --git a/cadence/scripts/check_pending_requests.cdc b/cadence/scripts/check_pending_requests.cdc index 317d159..8de085c 100644 --- a/cadence/scripts/check_pending_requests.cdc +++ b/cadence/scripts/check_pending_requests.cdc @@ -1,11 +1,11 @@ import "FlowYieldVaultsEVM" /// @title Check Pending Requests -/// @notice Returns the count of pending requests from FlowYieldVaultsRequests +/// @notice Returns the count of pending requests fetched for the requested page /// @param contractAddr The address where FlowYieldVaultsEVM Worker is stored /// @param startIndex The index to start fetching requests from /// @param count The number of requests to fetch -/// @return Number of pending requests +/// @return Number of pending requests fetched in this page /// access(all) fun main(contractAddr: Address, startIndex: Int, count: Int): Int { let account = getAuthAccount(contractAddr) @@ -14,7 +14,8 @@ access(all) fun main(contractAddr: Address, startIndex: Int, count: Int): Int { from: FlowYieldVaultsEVM.WorkerStoragePath ) ?? panic("No Worker found") - let requests = worker.getPendingRequestsFromEVM(startIndex: startIndex, count: count) - - return requests.length + if let requests = worker.getPendingRequestsFromEVM(startIndex: startIndex, count: count) { + return requests.length + } + panic("Failed to fetch pending requests from EVM") } diff --git a/cadence/scripts/get_contract_state.cdc b/cadence/scripts/get_contract_state.cdc index a10a9a5..f969e1e 100644 --- a/cadence/scripts/get_contract_state.cdc +++ b/cadence/scripts/get_contract_state.cdc @@ -2,14 +2,12 @@ import "FlowYieldVaultsEVM" /// @title Get Contract State /// @notice Returns the current state of the FlowYieldVaultsEVM contract -/// @param contractAddress The address where FlowYieldVaultsEVM is deployed (unused but kept for compatibility) /// @return Dictionary containing contract configuration and statistics /// -access(all) fun main(contractAddress: Address): {String: AnyStruct} { +access(all) fun main(): {String: AnyStruct} { let result: {String: AnyStruct} = {} result["flowYieldVaultsRequestsAddress"] = FlowYieldVaultsEVM.getFlowYieldVaultsRequestsAddress()?.toString() ?? "Not set" - result["maxRequestsPerTx"] = FlowYieldVaultsEVM.getMaxRequestsPerTx() result["yieldVaultRegistry"] = FlowYieldVaultsEVM.yieldVaultRegistry result["WorkerStoragePath"] = FlowYieldVaultsEVM.WorkerStoragePath.toString() diff --git a/cadence/scripts/get_execution_effort_constants.cdc b/cadence/scripts/get_execution_effort_constants.cdc new file mode 100644 index 0000000..52829b5 --- /dev/null +++ b/cadence/scripts/get_execution_effort_constants.cdc @@ -0,0 +1,21 @@ +import "FlowYieldVaultsEVMWorkerOps" + +/// @title Get Execution Effort Constants +/// @notice Returns the current execution effort constants from FlowYieldVaultsEVMWorkerOps +/// @dev Keys: +/// - schedulerBaseEffort: Base effort for SchedulerHandler execution +/// - schedulerPerRequestEffort: Additional effort per request preprocessed +/// - workerCreateYieldVaultRequestEffort: Effort for CREATE_YIELDVAULT requests +/// - workerDepositRequestEffort: Effort for DEPOSIT_TO_YIELDVAULT requests +/// - workerWithdrawRequestEffort: Effort for WITHDRAW_FROM_YIELDVAULT requests +/// - workerCloseYieldVaultRequestEffort: Effort for CLOSE_YIELDVAULT requests +/// @return Dictionary containing all execution effort constant key-value pairs +/// +access(all) fun main(): {String: UInt64} { + let constants = FlowYieldVaultsEVMWorkerOps.executionEffortConstants + let result: {String: UInt64} = {} + for key in constants.keys { + result[key] = constants[key]! + } + return result +} diff --git a/cadence/scripts/get_max_requests_config.cdc b/cadence/scripts/get_max_requests_config.cdc deleted file mode 100644 index 8a0fc89..0000000 --- a/cadence/scripts/get_max_requests_config.cdc +++ /dev/null @@ -1,54 +0,0 @@ -import "FlowYieldVaultsEVM" - -/// @title Get Max Requests Config -/// @notice Returns the current maxRequestsPerTx value and throughput estimates -/// @return Dictionary with current config and throughput calculations -/// -access(all) fun main(): {String: AnyStruct} { - let maxRequestsPerTx = FlowYieldVaultsEVM.getMaxRequestsPerTx() - - let executionsPerHourAt3s = 1200 // High load: >10 pending - let executionsPerHourAt30s = 120 // Idle: 0 pending - - let throughput: {String: Int} = { - "atHighLoad": maxRequestsPerTx * executionsPerHourAt3s, - "atIdle": maxRequestsPerTx * executionsPerHourAt30s - } - - let gasEstimate: {String: String} = { - "description": "Varies based on request complexity", - "rangePerRequest": "~100k-500k gas", - "totalRange": calculateGasRange(maxRequestsPerTx) - } - - return { - "currentMaxRequestsPerTx": maxRequestsPerTx, - "maxThroughputPerHour": throughput, - "estimatedGasPerExecution": gasEstimate, - "recommendations": getRecommendations(maxRequestsPerTx) - } -} - -access(all) fun calculateGasRange(_ batchSize: Int): String { - let lowGas = batchSize * 100_000 - let highGas = batchSize * 500_000 - return "\(lowGas) - \(highGas) gas" -} - -access(all) fun getRecommendations(_ current: Int): [String] { - let recommendations: [String] = [] - - if current < 5 { - recommendations.append("Very small batch size - consider increasing for efficiency") - } else if current < 10 { - recommendations.append("Conservative batch size - good for testing") - } else if current <= 30 { - recommendations.append("Optimal batch size range") - } else if current <= 50 { - recommendations.append("Large batch size - monitor for gas issues") - } else { - recommendations.append("Very large batch size - high risk of gas limits") - } - - return recommendations -} diff --git a/cadence/scripts/get_request_details.cdc b/cadence/scripts/get_request_details.cdc index 2588a6e..8e63a12 100644 --- a/cadence/scripts/get_request_details.cdc +++ b/cadence/scripts/get_request_details.cdc @@ -1,39 +1,28 @@ import "FlowYieldVaultsEVM" /// @title Get Request Details -/// @notice Returns details of the first pending request from FlowYieldVaultsRequests -/// @param contractAddr The address where FlowYieldVaultsEVM Worker is stored -/// @param startIndex The index to start fetching requests from -/// @param count The number of requests to fetch -/// @return Dictionary with request details or empty message if none pending +/// @notice Returns details for a specific request ID from FlowYieldVaultsRequests +/// @param requestId The request ID to fetch +/// @return Dictionary with request details /// -access(all) fun main(contractAddr: Address, startIndex: Int, count: Int): {String: AnyStruct} { - let account = getAuthAccount(contractAddr) - - let worker = account.storage.borrow<&FlowYieldVaultsEVM.Worker>( - from: FlowYieldVaultsEVM.WorkerStoragePath - ) ?? panic("No Worker found") - - let requests = worker.getPendingRequestsFromEVM(startIndex: startIndex, count: count) - - if requests.length == 0 { - return {"message": "No pending requests"} - } - - let request = requests[0] - - return { - "id": request.id.toString(), - "user": request.user.toString(), - "requestType": request.requestType, - "requestTypeName": getRequestTypeName(request.requestType), - "status": request.status, - "statusName": getStatusName(request.status), - "tokenAddress": request.tokenAddress.toString(), - "amount": request.amount.toString(), - "yieldVaultId": request.yieldVaultId.toString(), - "timestamp": request.timestamp.toString(), - "message": request.message +access(all) fun main(requestId: UInt256): {String: AnyStruct} { + + if let request = FlowYieldVaultsEVM.getRequestUnpacked(requestId) { + return { + "id": request.id.toString(), + "user": request.user.toString(), + "requestType": request.requestType, + "requestTypeName": getRequestTypeName(request.requestType), + "status": request.status, + "statusName": getStatusName(request.status), + "tokenAddress": request.tokenAddress.toString(), + "amount": request.amount.toString(), + "yieldVaultId": request.yieldVaultId?.toString() ?? "", + "timestamp": request.timestamp.toString(), + "message": request.message + } + } else { + panic("Request not found") } } diff --git a/cadence/scripts/scheduler/check_delay_for_pending_count.cdc b/cadence/scripts/scheduler/check_delay_for_pending_count.cdc deleted file mode 100644 index eb67537..0000000 --- a/cadence/scripts/scheduler/check_delay_for_pending_count.cdc +++ /dev/null @@ -1,31 +0,0 @@ -import "FlowYieldVaultsTransactionHandler" - -/// @title Check Delay for Pending Count -/// @notice Returns the scheduling delay for a given number of pending requests -/// @dev Useful for understanding the smart scheduling algorithm behavior -/// @param pendingRequests Number of pending requests to check -/// @return Dictionary with delay info and load category -/// -access(all) fun main(pendingRequests: Int): {String: AnyStruct} { - let delay = FlowYieldVaultsTransactionHandler.getDelayForPendingCount(pendingRequests) - let defaultDelay = FlowYieldVaultsTransactionHandler.defaultDelay - let thresholds = FlowYieldVaultsTransactionHandler.thresholdToDelay - - return { - "pendingRequests": pendingRequests, - "delaySeconds": delay, - "defaultDelay": defaultDelay, - "thresholds": thresholds, - "loadCategory": getLoadCategory(delay) - } -} - -access(all) fun getLoadCategory(_ delay: UFix64): String { - if delay <= 5.0 { - return "HIGH" - } else if delay <= 10.0 { - return "MEDIUM" - } else { - return "LOW" - } -} diff --git a/cadence/scripts/scheduler/check_handler_paused.cdc b/cadence/scripts/scheduler/check_handler_paused.cdc index db26f55..18db94b 100644 --- a/cadence/scripts/scheduler/check_handler_paused.cdc +++ b/cadence/scripts/scheduler/check_handler_paused.cdc @@ -1,9 +1,9 @@ -import "FlowYieldVaultsTransactionHandler" +import "FlowYieldVaultsEVMWorkerOps" -/// @title Check Handler Paused -/// @notice Returns whether the transaction handler is currently paused +/// @title Check Scheduler Handler Paused +/// @notice Returns whether the scheduler handler is currently paused /// @return True if paused, false otherwise /// access(all) fun main(): Bool { - return FlowYieldVaultsTransactionHandler.isPaused + return FlowYieldVaultsEVMWorkerOps.getIsSchedulerPaused() } diff --git a/cadence/scripts/scheduler/get_scheduled_transactions_info.cdc b/cadence/scripts/scheduler/get_scheduled_transactions_info.cdc new file mode 100644 index 0000000..ebaed72 --- /dev/null +++ b/cadence/scripts/scheduler/get_scheduled_transactions_info.cdc @@ -0,0 +1,38 @@ +import "FlowTransactionScheduler" +import "FlowTransactionSchedulerUtils" +import "FlowYieldVaultsEVMWorkerOps" + +/// @title Get Scheduled Transactions Info +/// @notice Returns the status of all scheduled transactions +/// @param accountAddress: The address of the account to get the manager from +/// +access(all) fun main(accountAddress: Address) { + let account = getAuthAccount(accountAddress) + let manager = account.storage + .borrow<&{FlowTransactionSchedulerUtils.Manager}> + (from: FlowTransactionSchedulerUtils.managerStoragePath) + + let transactionIDs = manager!.getTransactionIDs() + + for transactionID in transactionIDs { + let status = manager!.getTransactionStatus(id: transactionID) + let statusString = getStatusString(status: status) + log("\(transactionID): \(statusString)") + } + +} + +access(self) fun getStatusString(status: FlowTransactionScheduler.Status?): String { + if status == nil { + return "nil" + } + switch status! { + case FlowTransactionScheduler.Status.Scheduled: + return "Scheduled" + case FlowTransactionScheduler.Status.Executed: + return "Executed" + case FlowTransactionScheduler.Status.Canceled: + return "Canceled" + } + return "unknown" +} diff --git a/cadence/tests/access_control_test.cdc b/cadence/tests/access_control_test.cdc index adec22a..56c4b9e 100644 --- a/cadence/tests/access_control_test.cdc +++ b/cadence/tests/access_control_test.cdc @@ -30,11 +30,7 @@ fun setup() { access(all) fun testContractInitialState() { // Verify contract initializes with correct default values - - // maxRequestsPerTx should be initialized to a reasonable default (1 per original contract) - let maxRequests = getMaxRequestsConfig() - Test.assert(maxRequests == 1, message: "maxRequestsPerTx should be 1") - + // FlowYieldVaultsRequests address should be nil initially let requestsAddress = getRequestsAddress() Test.assert(requestsAddress == nil, message: "FlowYieldVaultsRequests address should be nil initially") @@ -46,40 +42,28 @@ fun testOnlyAdminCanupdateRequestsAddress() { let testAddress = EVM.addressFromString("0x1111111111111111111111111111111111111111") let actualAddress = FlowYieldVaultsEVM.getFlowYieldVaultsRequestsAddress() Test.expect(actualAddress == nil, Test.equal(true)) - + // --- act & assert ------------------------------------------------------ // Admin should be able to set/update the address let adminResult = updateRequestsAddress(admin, testAddress.toString()) Test.expect(adminResult, Test.beSucceeded()) } -access(all) -fun testOnlyAdminCanUpdateMaxRequests() { - // --- act & assert ------------------------------------------------------ - // Admin should be able to update maxRequestsPerTx - let adminResult = updateMaxRequests(admin, 16) - Test.expect(adminResult, Test.beSucceeded()) - - // Verify the update was applied by reading via script - let updatedMax = getMaxRequestsConfig() - Test.assert(updatedMax! == 16, message: "maxRequestsPerTx should be updated to 16") -} - access(all) fun testRequestsAddressCanBeUpdated() { // --- arrange ----------------------------------------------------------- let firstAddress = EVM.addressFromString("0x3333333333333333333333333333333333333333") let secondAddress = EVM.addressFromString("0x4444444444444444444444444444444444444444") - + // --- act & assert ------------------------------------------------------ // First set let firstResult = updateRequestsAddress(admin, firstAddress.toString()) Test.expect(firstResult, Test.beSucceeded()) - + // Second set - test that we can update multiple times let secondResult = updateRequestsAddress(admin, secondAddress.toString()) Test.expect(secondResult, Test.beSucceeded()) - + // Both transactions succeeded, which verifies: // 1. Admin has proper authorization to update the address // 2. The address can be updated multiple times @@ -93,11 +77,11 @@ fun testWorkerCreationRequiresCOA() { // Test that worker creation requires a valid COA capability // This is enforced by the precondition in Worker.init() Test.assert(getCOAAddress(admin.address) == nil, message: "Admin should not have COA initially") - + // Setup COA for admin first let coaResult = setupCOA(admin) Test.expect(coaResult, Test.beSucceeded()) - + // Verify COA was created let coaAddress = getCOAAddress(admin.address) Test.assert(coaAddress != nil, message: "COA should be created") @@ -107,11 +91,11 @@ access(all) fun testWorkerCreationRequiresBetaBadge() { // Test that worker creation requires a valid beta badge capability // This is enforced when creating the YieldVaultManager - + // Setup COA first let coaResult = setupCOA(admin) Test.expect(coaResult, Test.beSucceeded()) - + // Setup worker with badge (internally creates beta badge if admin doesn't have one) let workerResult = setupWorkerWithBadge(admin) Test.expect(workerResult, Test.beSucceeded()) @@ -122,7 +106,7 @@ fun testYieldVaultRegistryMapping() { // Verify the yieldVaultRegistry mapping is accessible let testAddress = "0x6666666666666666666666666666666666666666" let yieldVaultIds = FlowYieldVaultsEVM.getYieldVaultIdsForEVMAddress(testAddress) - + // Should return empty array for address with no yieldvaults Test.assertEqual(0, yieldVaultIds.length) } \ No newline at end of file diff --git a/cadence/tests/error_handling_test.cdc b/cadence/tests/error_handling_test.cdc index a029fb0..5ac3ad1 100644 --- a/cadence/tests/error_handling_test.cdc +++ b/cadence/tests/error_handling_test.cdc @@ -20,11 +20,11 @@ access(all) let testUserEVM = EVM.addressFromString("0x0000000000000000000000000 access(all) fun setup() { deployContracts() - + // Setup worker let coaResult = setupCOA(admin) Test.expect(coaResult, Test.beSucceeded()) - + let workerResult = setupWorkerWithBadge(admin) Test.expect(workerResult, Test.beSucceeded()) } @@ -38,7 +38,7 @@ fun testInvalidRequestType() { // --- arrange & act ----------------------------------------------------- // Attempting to create request with invalid type (99) should fail at precondition // This validates that the EVMRequest struct enforces valid request types - + // Test each valid request type let validTypes: [UInt8] = [ FlowYieldVaultsEVM.RequestType.CREATE_YIELDVAULT.rawValue, @@ -46,7 +46,7 @@ fun testInvalidRequestType() { FlowYieldVaultsEVM.RequestType.WITHDRAW_FROM_YIELDVAULT.rawValue, FlowYieldVaultsEVM.RequestType.CLOSE_YIELDVAULT.rawValue ] - + for requestType in validTypes { var amount = 1000000000000000000 as UInt256 if requestType == FlowYieldVaultsEVM.RequestType.CLOSE_YIELDVAULT.rawValue { @@ -60,21 +60,21 @@ fun testInvalidRequestType() { status: FlowYieldVaultsEVM.RequestStatus.PENDING.rawValue, tokenAddress: nativeFlowAddr, amount: amount, - yieldVaultId: FlowYieldVaultsEVM.noYieldVaultId, + yieldVaultId: UInt64.max, timestamp: 0, message: "", vaultIdentifier: mockVaultIdentifier, strategyIdentifier: mockStrategyIdentifier ) - + Test.assertEqual(requestType, validRequest.requestType) } - + // --- assert ------------------------------------------------------------ // Verify boundary values (0 and 3 are valid, values outside should fail) Test.assertEqual(0 as UInt8, FlowYieldVaultsEVM.RequestType.CREATE_YIELDVAULT.rawValue) Test.assertEqual(3 as UInt8, FlowYieldVaultsEVM.RequestType.CLOSE_YIELDVAULT.rawValue) - + Test.expectFailure(fun(): Void { let closeWithPositiveAmount = FlowYieldVaultsEVM.EVMRequest( id: 3, @@ -109,7 +109,7 @@ fun testZeroAmountWithdrawal() { vaultIdentifier: "", strategyIdentifier: "" ) - + // --- assert ------------------------------------------------------------ Test.assertEqual(0 as UInt256, closeWithZeroAmount.amount) Test.assertEqual(FlowYieldVaultsEVM.RequestType.CLOSE_YIELDVAULT.rawValue, closeWithZeroAmount.requestType) @@ -153,7 +153,7 @@ fun testZeroAmountWithdrawal() { ) }, errorMessageSubstring: "Amount must be greater than 0 for requestType \(requestType) but got amount 0") } - + // Note: Zero amounts for CREATE_YIELDVAULT, DEPOSIT_TO_YIELDVAULT, and WITHDRAW_FROM_YIELDVAULT // would fail at struct initialization with error: // "Amount must be greater than 0 for CREATE_YIELDVAULT, DEPOSIT_TO_YIELDVAULT, and WITHDRAW_FROM_YIELDVAULT operations" @@ -175,7 +175,7 @@ fun testRequestStatusCompletedStructure() { vaultIdentifier: mockVaultIdentifier, strategyIdentifier: mockStrategyIdentifier ) - + Test.assertEqual(FlowYieldVaultsEVM.RequestStatus.COMPLETED.rawValue, completedRequest.status) Test.assertEqual("Successfully created", completedRequest.message) } @@ -196,7 +196,7 @@ fun testRequestStatusFailedStructure() { vaultIdentifier: "", strategyIdentifier: "" ) - + Test.assertEqual(FlowYieldVaultsEVM.RequestStatus.FAILED.rawValue, failedRequest.status) Test.assertEqual("Insufficient balance", failedRequest.message) } diff --git a/cadence/tests/evm_bridge_lifecycle_test.cdc b/cadence/tests/evm_bridge_lifecycle_test.cdc index eba5e31..238b525 100644 --- a/cadence/tests/evm_bridge_lifecycle_test.cdc +++ b/cadence/tests/evm_bridge_lifecycle_test.cdc @@ -23,14 +23,14 @@ access(all) let userEVMAddr2 = EVM.addressFromString("0x000000000000000000000000 access(all) fun setup() { deployContracts() - + // Setup worker with COA and beta badge let coaResult = setupCOA(admin) Test.expect(coaResult, Test.beSucceeded()) - + let workerResult = setupWorkerWithBadge(admin) Test.expect(workerResult, Test.beSucceeded()) - + // Set mock FlowYieldVaultsRequests address let setAddrResult = updateRequestsAddress(admin, mockRequestsAddr.toString()) Test.expect(setAddrResult, Test.beSucceeded()) @@ -50,26 +50,26 @@ fun testCreateYieldVaultFromEVMRequest() { status: FlowYieldVaultsEVM.RequestStatus.PENDING.rawValue, tokenAddress: nativeFlowAddr, amount: 1000000000000000000, // 1 FLOW in wei (10^18) - yieldVaultId: FlowYieldVaultsEVM.noYieldVaultId, // Placeholder until Cadence assigns a real ID + yieldVaultId: nil, timestamp: 0, message: "", vaultIdentifier: mockVaultIdentifier, strategyIdentifier: mockStrategyIdentifier ) - + // Verify no yieldvaults exist for this user initially let yieldVaultsBefore = FlowYieldVaultsEVM.getYieldVaultIdsForEVMAddress(userEVMAddr1.toString()) Test.assertEqual(0, yieldVaultsBefore.length) - + // --- act --------------------------------------------------------------- // In real scenario, processRequests() would read from EVM contract // For testing, we validate the request structure and processing logic - + // Verify request created correctly Test.assertEqual(1 as UInt256, createRequest.id) Test.assertEqual(FlowYieldVaultsEVM.RequestType.CREATE_YIELDVAULT.rawValue, createRequest.requestType) Test.assertEqual(FlowYieldVaultsEVM.RequestStatus.PENDING.rawValue, createRequest.status) - + // --- assert ------------------------------------------------------------ // Verify the request structure is valid for processing Test.assert(createRequest.amount > 0, message: "Amount must be positive") @@ -94,11 +94,11 @@ fun testDepositToExistingYieldVault() { vaultIdentifier: "", // Not needed for DEPOSIT strategyIdentifier: "" ) - + // --- assert ------------------------------------------------------------ Test.assertEqual(2 as UInt256, depositRequest.id) Test.assertEqual(FlowYieldVaultsEVM.RequestType.DEPOSIT_TO_YIELDVAULT.rawValue, depositRequest.requestType) - Test.assertEqual(1 as UInt64, depositRequest.yieldVaultId) + Test.assertEqual(1 as UInt64?, depositRequest.yieldVaultId) Test.assert(depositRequest.amount > 0, message: "Deposit amount must be positive") } @@ -118,11 +118,11 @@ fun testWithdrawFromYieldVault() { vaultIdentifier: "", strategyIdentifier: "" ) - + // --- assert ------------------------------------------------------------ Test.assertEqual(3 as UInt256, withdrawRequest.id) Test.assertEqual(FlowYieldVaultsEVM.RequestType.WITHDRAW_FROM_YIELDVAULT.rawValue, withdrawRequest.requestType) - Test.assertEqual(1 as UInt64, withdrawRequest.yieldVaultId) + Test.assertEqual(1 as UInt64?, withdrawRequest.yieldVaultId) Test.assert(withdrawRequest.amount > 0, message: "Withdraw amount must be positive") } @@ -142,17 +142,17 @@ fun testCloseYieldVaultComplete() { vaultIdentifier: "", strategyIdentifier: "" ) - + // --- assert ------------------------------------------------------------ Test.assertEqual(4 as UInt256, closeRequest.id) Test.assertEqual(FlowYieldVaultsEVM.RequestType.CLOSE_YIELDVAULT.rawValue, closeRequest.requestType) - Test.assertEqual(1 as UInt64, closeRequest.yieldVaultId) + Test.assertEqual(1 as UInt64?, closeRequest.yieldVaultId) } access(all) fun testRequestStatusTransitions() { // --- Test valid status transitions --- - + // PENDING → COMPLETED let completedRequest = FlowYieldVaultsEVM.EVMRequest( id: 5, @@ -161,14 +161,14 @@ fun testRequestStatusTransitions() { status: FlowYieldVaultsEVM.RequestStatus.COMPLETED.rawValue, tokenAddress: nativeFlowAddr, amount: 1000000000000000000, - yieldVaultId: FlowYieldVaultsEVM.noYieldVaultId, + yieldVaultId: nil, timestamp: 0, message: "", vaultIdentifier: mockVaultIdentifier, strategyIdentifier: mockStrategyIdentifier ) Test.assertEqual(FlowYieldVaultsEVM.RequestStatus.COMPLETED.rawValue, completedRequest.status) - + // PENDING → FAILED let failedRequest = FlowYieldVaultsEVM.EVMRequest( id: 6, @@ -177,7 +177,7 @@ fun testRequestStatusTransitions() { status: FlowYieldVaultsEVM.RequestStatus.FAILED.rawValue, tokenAddress: nativeFlowAddr, amount: 1000000000000000000, - yieldVaultId: FlowYieldVaultsEVM.noYieldVaultId, + yieldVaultId: nil, timestamp: 0, message: "Insufficient balance", vaultIdentifier: mockVaultIdentifier, @@ -197,13 +197,13 @@ fun testMultipleUsersIndependentYieldVaults() { status: FlowYieldVaultsEVM.RequestStatus.PENDING.rawValue, tokenAddress: nativeFlowAddr, amount: 1000000000000000000, - yieldVaultId: FlowYieldVaultsEVM.noYieldVaultId, + yieldVaultId: nil, timestamp: 0, message: "", vaultIdentifier: mockVaultIdentifier, strategyIdentifier: mockStrategyIdentifier ) - + let user2Request = FlowYieldVaultsEVM.EVMRequest( id: 8, user: userEVMAddr2, @@ -211,20 +211,20 @@ fun testMultipleUsersIndependentYieldVaults() { status: FlowYieldVaultsEVM.RequestStatus.PENDING.rawValue, tokenAddress: nativeFlowAddr, amount: 2000000000000000000, - yieldVaultId: FlowYieldVaultsEVM.noYieldVaultId, + yieldVaultId: nil, timestamp: 0, message: "", vaultIdentifier: mockVaultIdentifier, strategyIdentifier: mockStrategyIdentifier ) - + // --- assert ------------------------------------------------------------ // Verify users are different Test.assert( user1Request.user.toString() != user2Request.user.toString(), message: "User addresses should be different" ) - + // Verify requests are independent Test.assert(user1Request.id != user2Request.id, message: "Request IDs should be unique") Test.assert(user1Request.amount != user2Request.amount, message: "Request amounts are different") @@ -240,18 +240,18 @@ fun testProcessResultStructure() { ) Test.assert(successResult.success) - Test.assertEqual(42 as UInt64, successResult.yieldVaultId) + Test.assertEqual(42 as UInt64?, successResult.yieldVaultId) Test.assertEqual("YieldVault created successfully", successResult.message) // Test failure result (NO_YIELDVAULT_ID sentinel for "no yieldvault") let failureResult = FlowYieldVaultsEVM.ProcessResult( success: false, - yieldVaultId: FlowYieldVaultsEVM.noYieldVaultId, + yieldVaultId: nil, message: "Insufficient COA balance" ) Test.assert(!failureResult.success) - Test.assertEqual(FlowYieldVaultsEVM.noYieldVaultId, failureResult.yieldVaultId) + Test.assertEqual(nil, failureResult.yieldVaultId) Test.assertEqual("Insufficient COA balance", failureResult.message) } @@ -260,7 +260,7 @@ fun testVaultAndStrategyIdentifiers() { // Test that vault and strategy identifiers are preserved correctly let customVaultId = "A.1234567890abcdef.CustomToken.Vault" let customStrategyId = "A.fedcba0987654321.CustomStrategy.Strategy" - + let request = FlowYieldVaultsEVM.EVMRequest( id: 9, user: userEVMAddr1, @@ -268,13 +268,13 @@ fun testVaultAndStrategyIdentifiers() { status: FlowYieldVaultsEVM.RequestStatus.PENDING.rawValue, tokenAddress: nativeFlowAddr, amount: 1000000000000000000, - yieldVaultId: FlowYieldVaultsEVM.noYieldVaultId, + yieldVaultId: nil, timestamp: 0, message: "", vaultIdentifier: customVaultId, strategyIdentifier: customStrategyId ) - + Test.assertEqual(customVaultId, request.vaultIdentifier) Test.assertEqual(customStrategyId, request.strategyIdentifier) } diff --git a/cadence/tests/test_helpers.cdc b/cadence/tests/test_helpers.cdc index a8f4518..fc9c5a7 100644 --- a/cadence/tests/test_helpers.cdc +++ b/cadence/tests/test_helpers.cdc @@ -31,14 +31,14 @@ access(all) fun deployContracts() { arguments: [] ) Test.expect(err, Test.beNil()) - + err = Test.deployContract( name: "Burner", path: "../../imports/f233dcee88fe0abe/Burner.cdc", arguments: [] ) Test.expect(err, Test.beNil()) - + // Deploy DeFiActions dependencies err = Test.deployContract( name: "DeFiActionsMathUtils", @@ -46,21 +46,21 @@ access(all) fun deployContracts() { arguments: [] ) Test.expect(err, Test.beNil()) - + err = Test.deployContract( name: "DeFiActionsUtils", path: "../../imports/92195d814edf9cb0/DeFiActionsUtils.cdc", arguments: [] ) Test.expect(err, Test.beNil()) - + err = Test.deployContract( name: "DeFiActions", path: "../../imports/92195d814edf9cb0/DeFiActions.cdc", arguments: [] ) Test.expect(err, Test.beNil()) - + // Deploy FlowYieldVaults dependencies err = Test.deployContract( name: "FlowYieldVaultsClosedBeta", @@ -68,14 +68,14 @@ access(all) fun deployContracts() { arguments: [] ) Test.expect(err, Test.beNil()) - + err = Test.deployContract( name: "FlowYieldVaults", path: "../../lib/FlowYieldVaults/cadence/contracts/FlowYieldVaults.cdc", arguments: [] ) Test.expect(err, Test.beNil()) - + // Deploy FlowEVMBridge dependencies for FlowEVMBridgeUtils // First deploy interfaces err = Test.deployContract( @@ -84,42 +84,42 @@ access(all) fun deployContracts() { arguments: [] ) Test.expect(err, Test.beNil()) - + err = Test.deployContract( name: "IBridgePermissions", path: "../../imports/1e4aa0b87d10b141/IBridgePermissions.cdc", arguments: [] ) Test.expect(err, Test.beNil()) - + err = Test.deployContract( name: "ICrossVM", path: "../../imports/1e4aa0b87d10b141/ICrossVM.cdc", arguments: [] ) Test.expect(err, Test.beNil()) - + err = Test.deployContract( name: "ICrossVMAsset", path: "../../imports/1e4aa0b87d10b141/ICrossVMAsset.cdc", arguments: [] ) Test.expect(err, Test.beNil()) - + err = Test.deployContract( name: "CrossVMMetadataViews", path: "../../imports/1d7e57aa55817448/CrossVMMetadataViews.cdc", arguments: [] ) Test.expect(err, Test.beNil()) - + err = Test.deployContract( name: "CrossVMNFT", path: "../../imports/1e4aa0b87d10b141/CrossVMNFT.cdc", arguments: [] ) Test.expect(err, Test.beNil()) - + // Deploy custom association types err = Test.deployContract( name: "FlowEVMBridgeCustomAssociationTypes", @@ -127,14 +127,14 @@ access(all) fun deployContracts() { arguments: [] ) Test.expect(err, Test.beNil()) - + err = Test.deployContract( name: "FlowEVMBridgeCustomAssociations", path: "../../imports/1e4aa0b87d10b141/FlowEVMBridgeCustomAssociations.cdc", arguments: [] ) Test.expect(err, Test.beNil()) - + // Deploy FlowEVMBridgeConfig err = Test.deployContract( name: "FlowEVMBridgeConfig", @@ -142,7 +142,7 @@ access(all) fun deployContracts() { arguments: [] ) Test.expect(err, Test.beNil()) - + // Deploy Serialize (dependency of SerializeMetadata) err = Test.deployContract( name: "Serialize", @@ -150,14 +150,14 @@ access(all) fun deployContracts() { arguments: [] ) Test.expect(err, Test.beNil()) - + err = Test.deployContract( name: "SerializeMetadata", path: "../../imports/1e4aa0b87d10b141/SerializeMetadata.cdc", arguments: [] ) Test.expect(err, Test.beNil()) - + // Deploy FlowEVMBridgeUtils (required by FlowYieldVaultsEVM) err = Test.deployContract( name: "FlowEVMBridgeUtils", @@ -165,7 +165,7 @@ access(all) fun deployContracts() { arguments: ["0x0000000000000000000000000000000000000000"] ) Test.expect(err, Test.beNil()) - + // Deploy FlowEVMBridge interface contracts err = Test.deployContract( name: "IEVMBridgeNFTMinter", @@ -173,28 +173,28 @@ access(all) fun deployContracts() { arguments: [] ) Test.expect(err, Test.beNil()) - + err = Test.deployContract( name: "IEVMBridgeTokenMinter", path: "../../imports/1e4aa0b87d10b141/IEVMBridgeTokenMinter.cdc", arguments: [] ) Test.expect(err, Test.beNil()) - + err = Test.deployContract( name: "IFlowEVMNFTBridge", path: "../../imports/1e4aa0b87d10b141/IFlowEVMNFTBridge.cdc", arguments: [] ) Test.expect(err, Test.beNil()) - + err = Test.deployContract( name: "IFlowEVMTokenBridge", path: "../../imports/1e4aa0b87d10b141/IFlowEVMTokenBridge.cdc", arguments: [] ) Test.expect(err, Test.beNil()) - + // Deploy CrossVMToken err = Test.deployContract( name: "CrossVMToken", @@ -202,11 +202,11 @@ access(all) fun deployContracts() { arguments: [] ) Test.expect(err, Test.beNil()) - + // Note: We skip deploying FlowEVMBridge, FlowEVMBridgeNFTEscrow, FlowEVMBridgeTokenEscrow, // and FlowEVMBridgeTemplates as they have access control issues and are not needed. // FlowYieldVaultsEVM only requires FlowEVMBridgeUtils and FlowEVMBridgeConfig which are already deployed. - + // Deploy FlowYieldVaultsEVM err = Test.deployContract( name: "FlowYieldVaultsEVM", @@ -245,15 +245,6 @@ fun updateRequestsAddress(_ signer: Test.TestAccount, _ address: String): Test.T ) } -access(all) -fun updateMaxRequests(_ signer: Test.TestAccount, _ maxRequests: Int): Test.TransactionResult { - return _executeTransaction( - "../transactions/update_max_requests.cdc", - [maxRequests], - signer - ) -} - access(all) fun setupWorkerWithBadge(_ admin: Test.TestAccount): Test.TransactionResult { return _executeTransaction( @@ -285,7 +276,7 @@ fun getYieldVaultIdsForEVMAddress(_ evmAddress: String): [UInt64]? { access(all) fun getRequestsAddress(): String? { - let res = _executeScript("../scripts/get_contract_state.cdc", [admin.address]) + let res = _executeScript("../scripts/get_contract_state.cdc", []) if res.status == Test.ResultStatus.succeeded { if let state = res.returnValue as? {String: AnyStruct} { let address = state["flowYieldVaultsRequestsAddress"] as! String? @@ -299,17 +290,6 @@ fun getRequestsAddress(): String? { return nil } -access(all) -fun getMaxRequestsConfig(): Int? { - let res = _executeScript("../scripts/get_max_requests_config.cdc", []) - if res.status == Test.ResultStatus.succeeded { - if let result = res.returnValue as? {String: AnyStruct} { - return result["currentMaxRequestsPerTx"] as! Int? - } - } - return nil -} - access(all) fun getCOAAddress(_ accountAddress: Address): String? { let res = _executeScript("../scripts/get_coa_address.cdc", [accountAddress]) diff --git a/cadence/tests/transactions/no_op.cdc b/cadence/tests/transactions/no_op.cdc new file mode 100644 index 0000000..2371a71 --- /dev/null +++ b/cadence/tests/transactions/no_op.cdc @@ -0,0 +1,6 @@ +// This transaction is used to trigger the FlowTransactionScheduler to execute pending scheduled transactions +// It is used in the worker tests to ensure the scheduler is working correctly. +// It is not used in the production code. +// The emulator block height needs to be advanced to trigger the scheduled transactions. + +transaction { execute {} } \ No newline at end of file diff --git a/cadence/transactions/admin/drop_requests.cdc b/cadence/transactions/admin/drop_requests.cdc index c3639fc..59d7574 100644 --- a/cadence/transactions/admin/drop_requests.cdc +++ b/cadence/transactions/admin/drop_requests.cdc @@ -13,6 +13,8 @@ transaction(requestIds: [UInt256]) { from: FlowYieldVaultsEVM.WorkerStoragePath ) ?? panic("Could not borrow FlowYieldVaultsEVM Worker resource") - worker.dropRequests(requestIds) + if let errorMsg = worker.dropRequests(requestIds) { + panic(errorMsg) + } } } diff --git a/cadence/transactions/admin/set_authorized_coa.cdc b/cadence/transactions/admin/set_authorized_coa.cdc index acdb961..1b28d77 100644 --- a/cadence/transactions/admin/set_authorized_coa.cdc +++ b/cadence/transactions/admin/set_authorized_coa.cdc @@ -4,7 +4,7 @@ import "EVM" /// @title Set Authorized COA /// @notice Sets the authorized COA address on the EVM FlowYieldVaultsRequests contract /// @dev Requires Worker resource. The Worker's COA must be the owner of the Solidity contract. -/// The new COA will be authorized to call startProcessing and completeProcessing. +/// The new COA will be authorized to call startProcessingBatch and completeProcessing. /// /// @param coa The EVM address of the new authorized COA /// diff --git a/cadence/transactions/deploy_evm_contract.cdc b/cadence/transactions/deploy_evm_contract.cdc index 0a5f901..cb58fb5 100644 --- a/cadence/transactions/deploy_evm_contract.cdc +++ b/cadence/transactions/deploy_evm_contract.cdc @@ -18,7 +18,7 @@ transaction(bytecode: String, gasLimit: UInt64) { execute { // Convert hex string to bytes let bytecodeBytes = bytecode.decodeHex() - + // Deploy the contract let result = self.coa.deploy( code: bytecodeBytes, @@ -27,6 +27,8 @@ transaction(bytecode: String, gasLimit: UInt64) { ) // Check if deployment was successful - assert(result.status == EVM.Status.successful, message: "EVM contract deployment failed") + if result.status != EVM.Status.successful { + panic("EVM contract deployment failed: \(result.errorMessage)") + } } } diff --git a/cadence/transactions/process_requests.cdc b/cadence/transactions/process_requests.cdc index 9d3eebc..8e18f0c 100644 --- a/cadence/transactions/process_requests.cdc +++ b/cadence/transactions/process_requests.cdc @@ -1,18 +1,36 @@ import "FlowYieldVaultsEVM" -/// @title Process Requests +/// @title Process Requests Manually (preprocess and process) (PENDING -> COMPLETED/FAILED) /// @notice Manually processes pending requests from FlowYieldVaultsRequests contract /// @dev Fetches and processes up to count pending requests starting from startIndex. /// Use for manual processing or debugging. Automated processing uses the transaction handler. +/// Performs both preprocess and processing steps. /// @param startIndex The index to start fetching requests from /// @param count The number of requests to fetch and process /// transaction(startIndex: Int, count: Int) { prepare(signer: auth(BorrowValue) &Account) { + let worker = signer.storage.borrow<&FlowYieldVaultsEVM.Worker>( from: FlowYieldVaultsEVM.WorkerStoragePath ) ?? panic("Could not borrow Worker from storage") - worker.processRequests(startIndex: startIndex, count: count) + if let requests = worker.getPendingRequestsFromEVM( + startIndex: startIndex, + count: count, + ) { + + // Preprocess requests (PENDING -> PROCESSING) + if let successfulRequests = worker.preprocessRequests(requests) { + + // Process requests + worker.processRequests(successfulRequests) + + } else { + panic("Failed to preprocess requests") + } + } else { + panic("Failed to fetch pending requests") + } } } diff --git a/cadence/transactions/scheduler/destroy_handler.cdc b/cadence/transactions/scheduler/destroy_handler.cdc index 75ae794..2656468 100644 --- a/cadence/transactions/scheduler/destroy_handler.cdc +++ b/cadence/transactions/scheduler/destroy_handler.cdc @@ -1,15 +1,20 @@ -import "FlowYieldVaultsTransactionHandler" +import "FlowYieldVaultsEVMWorkerOps" -/// @title Destroy FlowYieldVaults Transaction Handler +/// @title Destroy FlowYieldVaultsEVMWorkerOps Scheduler and Worker Handlers /// @notice Removes the Handler resource from storage transaction() { prepare(signer: auth(LoadValue, UnpublishCapability) &Account) { - // Unpublish the public capability first - signer.capabilities.unpublish(FlowYieldVaultsTransactionHandler.HandlerPublicPath) - // Load and destroy the handler resource - if let handler <- signer.storage.load<@FlowYieldVaultsTransactionHandler.Handler>( - from: FlowYieldVaultsTransactionHandler.HandlerStoragePath + // Load and destroy the SchedulerHandler resource + if let handler <- signer.storage.load<@FlowYieldVaultsEVMWorkerOps.SchedulerHandler>( + from: FlowYieldVaultsEVMWorkerOps.SchedulerHandlerStoragePath + ) { + destroy handler + } + + // Load and destroy the WorkerHandler resource + if let handler <- signer.storage.load<@FlowYieldVaultsEVMWorkerOps.WorkerHandler>( + from: FlowYieldVaultsEVMWorkerOps.WorkerHandlerStoragePath ) { destroy handler } diff --git a/cadence/transactions/scheduler/init_and_schedule.cdc b/cadence/transactions/scheduler/init_and_schedule.cdc index cfb9ce1..fd55400 100644 --- a/cadence/transactions/scheduler/init_and_schedule.cdc +++ b/cadence/transactions/scheduler/init_and_schedule.cdc @@ -2,37 +2,37 @@ import "FlowTransactionScheduler" import "FlowTransactionSchedulerUtils" import "FlowToken" import "FungibleToken" -import "FlowYieldVaultsTransactionHandler" +import "FlowYieldVaultsEVMWorkerOps" import "FlowYieldVaultsEVM" -/// @title Initialize Handler and Schedule First Execution -/// @notice Creates the transaction handler and schedules the first automated execution -/// @dev Combines init_flow_vaults_transaction_handler and schedule_initial_flow_vaults_execution. -/// Safe to run multiple times - will skip already-configured resources. -/// Execution effort and priority are calculated dynamically based on FlowYieldVaultsEVM.maxRequestsPerTx. +/// @title Initialize Handlers and Schedule First Execution +/// @notice Creates the WorkerHandler and SchedulerHandler and schedules the first executions +/// @dev Flow: +/// 1. Initialize the manager if it doesn't exist +/// 2. Initialize the WorkerHandler if it doesn't exist +/// 3. Initialize the SchedulerHandler if it doesn't exist +/// 4. Schedule the first dummy WorkerHandler transaction to register the WorkerHandler in the manager +/// 5. Schedule the scheduler /// -/// @param delaySeconds Initial delay before first execution (e.g., 5.0) -/// -transaction( - delaySeconds: UFix64 -) { - prepare(signer: auth(BorrowValue, IssueStorageCapabilityController, SaveValue, PublishCapability) &Account) { - if signer.storage.borrow<&FlowYieldVaultsEVM.Worker>(from: FlowYieldVaultsEVM.WorkerStoragePath) == nil { - panic("FlowYieldVaultsEVM Worker not found. Please initialize Worker first.") - } +transaction { - if signer.storage.borrow<&AnyResource>(from: FlowYieldVaultsTransactionHandler.HandlerStoragePath) == nil { - let workerCap = signer.capabilities.storage - .issue<&FlowYieldVaultsEVM.Worker>(FlowYieldVaultsEVM.WorkerStoragePath) - let handler <- FlowYieldVaultsTransactionHandler.createHandler(workerCap: workerCap) - signer.storage.save(<-handler, to: FlowYieldVaultsTransactionHandler.HandlerStoragePath) - } + let workerHandlerCap: Capability + let schedulerHandlerCap: Capability + let manager: auth(FlowTransactionSchedulerUtils.Owner) &{FlowTransactionSchedulerUtils.Manager} + let feeVaultRef: auth(FungibleToken.Withdraw) &FlowToken.Vault + let workerHandlerTypeIdentifier: String + let schedulerHandler: &FlowYieldVaultsEVMWorkerOps.SchedulerHandler + let opsAdmin: &FlowYieldVaultsEVMWorkerOps.Admin - let handlerCap = signer.capabilities.storage - .issue( - FlowYieldVaultsTransactionHandler.HandlerStoragePath - ) + prepare(signer: auth(BorrowValue, IssueStorageCapabilityController, SaveValue, PublishCapability) &Account) { + pre { + signer.storage.borrow<&FlowYieldVaultsEVM.Admin>(from: FlowYieldVaultsEVM.AdminStoragePath) != nil: + "FlowYieldVaultsEVM Admin not found." + signer.storage.borrow<&FlowYieldVaultsEVM.Worker>(from: FlowYieldVaultsEVM.WorkerStoragePath) != nil: + "FlowYieldVaultsEVM Worker not found." + } + // Initialize the manager if it doesn't exist if signer.storage.borrow<&AnyResource>(from: FlowTransactionSchedulerUtils.managerStoragePath) == nil { let manager <- FlowTransactionSchedulerUtils.createManager() signer.storage.save(<-manager, to: FlowTransactionSchedulerUtils.managerStoragePath) @@ -42,50 +42,157 @@ transaction( signer.capabilities.publish(managerCapPublic, at: FlowTransactionSchedulerUtils.managerPublicPath) } - let manager = signer.storage + // Load manager + self.manager = signer.storage .borrow( from: FlowTransactionSchedulerUtils.managerStoragePath ) ?? panic("Could not borrow Manager reference") - let future = getCurrentBlock().timestamp + delaySeconds - - // Calculate execution effort and priority dynamically based on maxRequestsPerTx - let maxRequestsPerTx = FlowYieldVaultsEVM.getMaxRequestsPerTx() - let effortAndPriority = FlowYieldVaultsTransactionHandler.calculateExecutionEffortAndPriority(maxRequestsPerTx) - let executionEffort = effortAndPriority["effort"]! as! UInt64 - let priorityRaw = effortAndPriority["priority"]! as! UInt8 - - let pr = priorityRaw == 0 - ? FlowTransactionScheduler.Priority.High - : FlowTransactionScheduler.Priority.Medium - - let est = FlowTransactionScheduler.estimate( - data: [], - timestamp: future, - priority: pr, - executionEffort: executionEffort - ) - - let estimatedFee = est.flowFee ?? 0.0 - - if est.timestamp == nil && pr != FlowTransactionScheduler.Priority.Low { - let errorMsg = est.error ?? "estimation failed" - panic("Fee estimation failed: \(errorMsg)") + // Load WorkerOps Admin + self.opsAdmin = signer.storage + .borrow<&FlowYieldVaultsEVMWorkerOps.Admin> + (from: FlowYieldVaultsEVMWorkerOps.AdminStoragePath) + ?? panic("Could not borrow FlowYieldVaultsEVMWorkerOps Admin") + + // Issue the worker capability for WorkerHandler resources + let workerCap = signer.capabilities.storage + .issue<&FlowYieldVaultsEVM.Worker>(FlowYieldVaultsEVM.WorkerStoragePath) + + // Initialize SchedulerHandler resource if it doesn't exist + if signer.storage.borrow<&AnyResource>(from: FlowYieldVaultsEVMWorkerOps.SchedulerHandlerStoragePath) == nil { + let handler <- self.opsAdmin.createSchedulerHandler(workerCap: workerCap) + signer.storage.save(<-handler, to: FlowYieldVaultsEVMWorkerOps.SchedulerHandlerStoragePath) + } + self.schedulerHandler = signer.storage + .borrow<&FlowYieldVaultsEVMWorkerOps.SchedulerHandler>(from: FlowYieldVaultsEVMWorkerOps.SchedulerHandlerStoragePath)! + + // Initialize WorkerHandler resource if it doesn't exist + if signer.storage.borrow<&AnyResource>(from: FlowYieldVaultsEVMWorkerOps.WorkerHandlerStoragePath) == nil { + let handler <- self.opsAdmin.createWorkerHandler(workerCap: workerCap) + self.workerHandlerTypeIdentifier = handler.getType().identifier + signer.storage.save(<-handler, to: FlowYieldVaultsEVMWorkerOps.WorkerHandlerStoragePath) + } else { + self.workerHandlerTypeIdentifier = signer.storage + .borrow<&FlowYieldVaultsEVMWorkerOps.WorkerHandler>(from: FlowYieldVaultsEVMWorkerOps.WorkerHandlerStoragePath)! + .getType().identifier } - let vaultRef = signer.storage + // Issue capability to SchedulerHandler for scheduling + self.schedulerHandlerCap = signer.capabilities.storage + .issue( + FlowYieldVaultsEVMWorkerOps.SchedulerHandlerStoragePath + ) + + // Issue capability to WorkerHandler for scheduling + self.workerHandlerCap = signer.capabilities.storage + .issue( + FlowYieldVaultsEVMWorkerOps.WorkerHandlerStoragePath + ) + + // Load FlowToken vault for fees + self.feeVaultRef = signer.storage .borrow(from: /storage/flowTokenVault) ?? panic("Missing FlowToken vault") - let fees <- vaultRef.withdraw(amount: estimatedFee) as! @FlowToken.Vault + } + + execute { + + // Make sure WorkerHandler is registered in the manager + if self.manager.getHandlerTypeIdentifiers()[self.workerHandlerTypeIdentifier] == nil { + // Schedule dummy (data=nil) WorkerHandler transaction to register the WorkerHandler in the manager + let workerHandlerPriority = FlowTransactionScheduler.Priority.Medium + let workerHandlerExecutionEffort = 5000 as UInt64 + let transactionId = _scheduleTransaction( + manager: self.manager, + handlerCap: self.workerHandlerCap, + feeVaultRef: self.feeVaultRef, + priority: workerHandlerPriority, + executionEffort: workerHandlerExecutionEffort, + data: nil + ) + log("\(self.workerHandlerTypeIdentifier) successfully registered in the manager") + } else { + log("\(self.workerHandlerTypeIdentifier) is already registered in the manager, skipped") + } + + // Check if scheduler is running + var schedulerRunning = false + if let nextTx = self.schedulerHandler.nextSchedulerTransactionId { + // Check nextTx status + let status = self.manager.getTransactionStatus(id: nextTx) + if status == FlowTransactionScheduler.Status.Scheduled { + schedulerRunning = true + log("Scheduler is already running: \(nextTx)") + } + } - let transactionId = manager.schedule( - handlerCap: handlerCap, - data: [], - timestamp: future, - priority: pr, - executionEffort: executionEffort, - fees: <-fees - ) + // Schedule scheduler + if !schedulerRunning { + let schedulerPriority = FlowTransactionScheduler.Priority.Medium + let schedulerExecutionEffort = 5000 as UInt64 + // First scheduler run will be scheduled without any requests to preprocess + // If there are pending requests, they will be preprocessed in the next scheduler execution + let schedulerTransactionId = _scheduleTransaction( + manager: self.manager, + handlerCap: self.schedulerHandlerCap, + feeVaultRef: self.feeVaultRef, + priority: schedulerPriority, + executionEffort: schedulerExecutionEffort, + data: nil + ) + self.opsAdmin.setNextSchedulerTransactionId(schedulerTransactionId) + log("Scheduler started: \(schedulerTransactionId)") + } } + +} + +/// @notice Helper function to schedule a transaction +/// @dev Flow: +/// 1. Calculate the target execution timestamp +/// 2. Estimate fees and withdraw payment +/// 3. Schedule the transaction +/// @param manager The manager +/// @param handlerCap The capability to the handler +/// @param feeVaultRef The vault to withdraw fees from +/// @return The transaction ID +access(self) fun _scheduleTransaction( + manager: auth(FlowTransactionSchedulerUtils.Owner) &{FlowTransactionSchedulerUtils.Manager}, + handlerCap: Capability, + feeVaultRef: auth(FungibleToken.Withdraw) &FlowToken.Vault, + priority: FlowTransactionScheduler.Priority, + executionEffort: UInt64, + data: AnyStruct?, +): UInt64 { + // Calculate the target execution timestamp + let future = getCurrentBlock().timestamp + 1.0 + + // Estimate fees and withdraw payment + // calculateFee() is not supported by Flow emulator. When emulator is updated, following code can be uncommented. + // let fee = FlowTransactionScheduler.calculateFee( + // executionEffort: executionEffort, + // priority: priority, + // dataSizeMB: 0.0, // nil + // ) + // let fees <- feeVaultRef.withdraw(amount: fee) as! @FlowToken.Vault + let estimate = FlowTransactionScheduler.estimate( + data: nil, + timestamp: future, + priority: priority, + executionEffort: executionEffort + ) + let fees <- feeVaultRef.withdraw(amount: estimate.flowFee ?? 0.0) as! @FlowToken.Vault + + // Schedule the next execution + let transactionId = manager.schedule( + handlerCap: handlerCap, + data: data, + timestamp: future, + priority: priority, + executionEffort: executionEffort, + fees: <-fees + ) + + return transactionId } diff --git a/cadence/transactions/scheduler/pause_transaction_handler.cdc b/cadence/transactions/scheduler/pause_transaction_handler.cdc index 0904135..f913e2f 100644 --- a/cadence/transactions/scheduler/pause_transaction_handler.cdc +++ b/cadence/transactions/scheduler/pause_transaction_handler.cdc @@ -1,16 +1,16 @@ -import "FlowYieldVaultsTransactionHandler" +import "FlowYieldVaultsEVMWorkerOps" -/// @title Pause Transaction Handler -/// @notice Pauses the automated transaction handler -/// @dev When paused, scheduled executions skip processing and do not reschedule. +/// @title Pause Scheduler Handler +/// @notice Pauses the scheduler handler +/// @dev When paused, no new requests will be scheduled. /// Requires Admin resource. /// transaction() { prepare(signer: auth(BorrowValue) &Account) { - let admin = signer.storage.borrow<&FlowYieldVaultsTransactionHandler.Admin>( - from: FlowYieldVaultsTransactionHandler.AdminStoragePath + let admin = signer.storage.borrow<&FlowYieldVaultsEVMWorkerOps.Admin>( + from: FlowYieldVaultsEVMWorkerOps.AdminStoragePath ) ?? panic("Could not borrow Admin resource") - admin.pause() + admin.pauseScheduler() } } diff --git a/cadence/transactions/scheduler/run_scheduler_manual.cdc b/cadence/transactions/scheduler/run_scheduler_manual.cdc new file mode 100644 index 0000000..a0bbf1a --- /dev/null +++ b/cadence/transactions/scheduler/run_scheduler_manual.cdc @@ -0,0 +1,26 @@ +import "FlowTransactionScheduler" +import "FlowYieldVaultsEVMWorkerOps" + +/// @title Run Scheduler Manually +/// @notice Runs the scheduler manually +/// @dev Flow: +/// 1. Issue a storage capability to the SchedulerHandler resource +/// 2. Borrow the SchedulerHandler resource and call executeTransaction +/// +transaction { + let schedulerHandlerCap: Capability + + prepare(signer: auth(IssueStorageCapabilityController) &Account) { + self.schedulerHandlerCap = signer.capabilities.storage + .issue( + FlowYieldVaultsEVMWorkerOps.SchedulerHandlerStoragePath + ) + } + + execute { + self.schedulerHandlerCap.borrow()!.executeTransaction( + id: 42, + data: nil + ) + } +} \ No newline at end of file diff --git a/cadence/transactions/scheduler/stop_all_scheduled_transactions.cdc b/cadence/transactions/scheduler/stop_all_scheduled_transactions.cdc index e489fbd..e0de6fb 100644 --- a/cadence/transactions/scheduler/stop_all_scheduled_transactions.cdc +++ b/cadence/transactions/scheduler/stop_all_scheduled_transactions.cdc @@ -1,17 +1,18 @@ -import "FlowYieldVaultsTransactionHandler" +import "FlowYieldVaultsEVMWorkerOps" /// @title Stop All Scheduled Transactions -/// @notice Stops and cancels all scheduled transactions, pausing the handler and refunding fees +/// @notice Pauses scheduler execution and cancels tracked in-flight WorkerHandler transactions /// @dev This will: /// 1. Pause the handler to prevent new scheduling -/// 2. Cancel all pending scheduled transactions -/// 3. Refund fees to the contract account +/// 2. Cancel WorkerHandler transactions tracked in FlowYieldVaultsEVMWorkerOps.scheduledRequests +/// 3. Cancel the next scheduler transaction ID stored on SchedulerHandler +/// 4. Refund fees to the contract account /// Requires Admin resource. /// transaction() { prepare(signer: auth(BorrowValue) &Account) { - let admin = signer.storage.borrow<&FlowYieldVaultsTransactionHandler.Admin>( - from: FlowYieldVaultsTransactionHandler.AdminStoragePath + let admin = signer.storage.borrow<&FlowYieldVaultsEVMWorkerOps.Admin>( + from: FlowYieldVaultsEVMWorkerOps.AdminStoragePath ) ?? panic("Could not borrow Admin resource") admin.stopAll() diff --git a/cadence/transactions/scheduler/unpause_transaction_handler.cdc b/cadence/transactions/scheduler/unpause_transaction_handler.cdc index 45afa38..d810e5d 100644 --- a/cadence/transactions/scheduler/unpause_transaction_handler.cdc +++ b/cadence/transactions/scheduler/unpause_transaction_handler.cdc @@ -1,16 +1,15 @@ -import "FlowYieldVaultsTransactionHandler" +import "FlowYieldVaultsEVMWorkerOps" -/// @title Unpause Transaction Handler -/// @notice Unpauses the automated transaction handler -/// @dev After unpausing, manually schedule a new execution using -/// init_and_schedule.cdc to restart the chain. +/// @title Unpause Scheduler Handler +/// @notice Unpauses the scheduler handler +/// @dev After unpausing, new requests will be scheduled. /// transaction() { prepare(signer: auth(BorrowValue) &Account) { - let admin = signer.storage.borrow<&FlowYieldVaultsTransactionHandler.Admin>( - from: FlowYieldVaultsTransactionHandler.AdminStoragePath + let admin = signer.storage.borrow<&FlowYieldVaultsEVMWorkerOps.Admin>( + from: FlowYieldVaultsEVMWorkerOps.AdminStoragePath ) ?? panic("Could not borrow Admin resource") - admin.unpause() + admin.unpauseScheduler() } } diff --git a/cadence/transactions/scheduler/update_execution_effort_params.cdc b/cadence/transactions/scheduler/update_execution_effort_params.cdc deleted file mode 100644 index ce22c6b..0000000 --- a/cadence/transactions/scheduler/update_execution_effort_params.cdc +++ /dev/null @@ -1,24 +0,0 @@ -import "FlowYieldVaultsTransactionHandler" - -/// @title Update Execution Effort Parameters -/// @notice Updates the parameters used to calculate execution effort dynamically -/// @dev Formula: executionEffort = baseEffortPerRequest * maxRequestsPerTx + baseOverhead -/// When idle (0 pending requests), uses idleExecutionEffort with Medium priority -/// -/// @param baseEffortPerRequest Effort units per request (e.g., 2000 for EVM calls) -/// @param baseOverhead Fixed overhead regardless of request count (e.g., 3000) -/// @param idleExecutionEffort Minimal effort when no pending requests (e.g., 5000 for Medium priority) -/// -transaction(baseEffortPerRequest: UInt64, baseOverhead: UInt64, idleExecutionEffort: UInt64) { - prepare(signer: auth(BorrowValue) &Account) { - let admin = signer.storage.borrow<&FlowYieldVaultsTransactionHandler.Admin>( - from: FlowYieldVaultsTransactionHandler.AdminStoragePath - ) ?? panic("Could not borrow Admin resource") - - admin.setExecutionEffortParams( - baseEffortPerRequest: baseEffortPerRequest, - baseOverhead: baseOverhead, - idleExecutionEffort: idleExecutionEffort - ) - } -} diff --git a/cadence/transactions/scheduler/update_threshold_to_delay.cdc b/cadence/transactions/scheduler/update_threshold_to_delay.cdc deleted file mode 100644 index 8577ebf..0000000 --- a/cadence/transactions/scheduler/update_threshold_to_delay.cdc +++ /dev/null @@ -1,18 +0,0 @@ -import "FlowYieldVaultsTransactionHandler" - -/// @title Update Threshold To Delay -/// @notice Updates the mapping of pending request thresholds to execution delays -/// @dev Requires Admin resource. Each threshold maps to a delay in seconds. -/// Higher pending counts should map to shorter delays for faster processing. -/// -/// @param newThresholds New mapping of thresholds to delays (e.g., {11: 3.0, 5: 5.0, 1: 7.0, 0: 30.0}) -/// -transaction(newThresholds: {Int: UFix64}) { - prepare(signer: auth(BorrowValue) &Account) { - let admin = signer.storage.borrow<&FlowYieldVaultsTransactionHandler.Admin>( - from: FlowYieldVaultsTransactionHandler.AdminStoragePath - ) ?? panic("Could not borrow Admin from storage") - - admin.setThresholdToDelay(newThresholds: newThresholds) - } -} diff --git a/cadence/transactions/set_execution_effort_constant.cdc b/cadence/transactions/set_execution_effort_constant.cdc new file mode 100644 index 0000000..ae944b7 --- /dev/null +++ b/cadence/transactions/set_execution_effort_constant.cdc @@ -0,0 +1,29 @@ +import "FlowYieldVaultsEVMWorkerOps" + +/// @title Set Execution Effort Constant +/// @notice Sets a value for a given key in executionEffortConstants via the Admin resource +/// @dev Only the account with the Admin resource stored can execute this transaction. +/// Valid keys: +/// - schedulerBaseEffort: Base effort for SchedulerHandler execution +/// - schedulerPerRequestEffort: Additional effort per request preprocessed +/// - workerCreateYieldVaultRequestEffort: Effort for CREATE_YIELDVAULT requests +/// - workerDepositRequestEffort: Effort for DEPOSIT_TO_YIELDVAULT requests +/// - workerWithdrawRequestEffort: Effort for WITHDRAW_FROM_YIELDVAULT requests +/// - workerCloseYieldVaultRequestEffort: Effort for CLOSE_YIELDVAULT requests +/// +/// @param key The execution effort constant key (must be one of the valid keys above) +/// @param value The execution effort value to set (must be greater than 0) +/// +transaction(key: String, value: UInt64) { + let admin: &FlowYieldVaultsEVMWorkerOps.Admin + + prepare(signer: auth(BorrowValue) &Account) { + self.admin = signer.storage.borrow<&FlowYieldVaultsEVMWorkerOps.Admin>( + from: FlowYieldVaultsEVMWorkerOps.AdminStoragePath + ) ?? panic("Could not borrow Admin resource") + } + + execute { + self.admin.setExecutionEffortConstants(key: key, value: value) + } +} diff --git a/cadence/transactions/update_max_requests.cdc b/cadence/transactions/update_max_requests.cdc deleted file mode 100644 index 89c2018..0000000 --- a/cadence/transactions/update_max_requests.cdc +++ /dev/null @@ -1,21 +0,0 @@ -import "FlowYieldVaultsEVM" - -/// @title Update Max Requests Per Transaction -/// @notice Updates the maximum number of requests processed per transaction -/// @dev Requires Admin resource. Recommended range: 5-50 based on gas testing. -/// -/// @param newMax The new maximum requests per transaction (1-100) -/// -transaction(newMax: Int) { - prepare(signer: auth(BorrowValue) &Account) { - let admin = signer.storage.borrow<&FlowYieldVaultsEVM.Admin>( - from: FlowYieldVaultsEVM.AdminStoragePath - ) ?? panic("Could not borrow FlowYieldVaultsEVM Admin resource") - - admin.updateMaxRequestsPerTx(newMax) - } - - post { - FlowYieldVaultsEVM.getMaxRequestsPerTx() == newMax: "maxRequestsPerTx was not updated correctly" - } -} diff --git a/deployments/artifacts/FlowYieldVaultsRequests.json b/deployments/artifacts/FlowYieldVaultsRequests.json index 1c49944..88f282c 100644 --- a/deployments/artifacts/FlowYieldVaultsRequests.json +++ b/deployments/artifacts/FlowYieldVaultsRequests.json @@ -694,6 +694,75 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "getRequestUnpacked", + "inputs": [ + { + "name": "requestId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "id", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "user", + "type": "address", + "internalType": "address" + }, + { + "name": "requestType", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "status", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "tokenAddress", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "yieldVaultId", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "timestamp", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "message", + "type": "string", + "internalType": "string" + }, + { + "name": "vaultIdentifier", + "type": "string", + "internalType": "string" + }, + { + "name": "strategyIdentifier", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "getUserPendingBalance", @@ -1078,12 +1147,17 @@ }, { "type": "function", - "name": "startProcessing", + "name": "startProcessingBatch", "inputs": [ { - "name": "requestId", - "type": "uint256", - "internalType": "uint256" + "name": "successfulRequestIds", + "type": "uint256[]", + "internalType": "uint256[]" + }, + { + "name": "rejectedRequestIds", + "type": "uint256[]", + "internalType": "uint256[]" } ], "outputs": [], @@ -1214,6 +1288,25 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "yieldVaultTokens", + "inputs": [ + { + "name": "", + "type": "uint64", + "internalType": "uint64" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "yieldVaultsByUser", @@ -1854,6 +1947,11 @@ "name": "CannotAllowlistZeroAddress", "inputs": [] }, + { + "type": "error", + "name": "CannotRegisterSentinelYieldVaultId", + "inputs": [] + }, { "type": "error", "name": "ContractPaused", @@ -1900,6 +1998,11 @@ "name": "InvalidCOAAddress", "inputs": [] }, + { + "type": "error", + "name": "InvalidRequestState", + "inputs": [] + }, { "type": "error", "name": "InvalidYieldVaultId", @@ -1991,11 +2094,6 @@ "name": "ReentrancyGuardReentrantCall", "inputs": [] }, - { - "type": "error", - "name": "RequestAlreadyFinalized", - "inputs": [] - }, { "type": "error", "name": "RequestNotFound", @@ -2032,5 +2130,64 @@ "type": "error", "name": "TransferFailed", "inputs": [] + }, + { + "type": "error", + "name": "YieldVaultIdAlreadyRegistered", + "inputs": [ + { + "name": "yieldVaultId", + "type": "uint64", + "internalType": "uint64" + } + ] + }, + { + "type": "error", + "name": "YieldVaultIdMismatch", + "inputs": [ + { + "name": "expectedId", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "providedId", + "type": "uint64", + "internalType": "uint64" + } + ] + }, + { + "type": "error", + "name": "YieldVaultTokenMismatch", + "inputs": [ + { + "name": "yieldVaultId", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "expected", + "type": "address", + "internalType": "address" + }, + { + "name": "provided", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "YieldVaultTokenNotSet", + "inputs": [ + { + "name": "yieldVaultId", + "type": "uint64", + "internalType": "uint64" + } + ] } ] diff --git a/deployments/contract-addresses.json b/deployments/contract-addresses.json index 87032d9..bcb924e 100644 --- a/deployments/contract-addresses.json +++ b/deployments/contract-addresses.json @@ -3,21 +3,21 @@ "FlowYieldVaultsRequests": { "abi": "./artifacts/FlowYieldVaultsRequests.json", "addresses": { - "testnet": "0xF633C9dBf1a3964a895fCC4CA4404B6f8BA8141d", + "testnet": "0x11ac56ad2019096cd1411cc65e521d60919910f7", "mainnet": "0x0000000000000000000000000000000000000000" } }, "FlowYieldVaultsEVM": { "network": "flow", "addresses": { - "testnet": "0xdf111ffc5064198a", + "testnet": "0x7235eeea4b4d2dc8", "mainnet": "0x0000000000000000" } } }, "metadata": { "version": "1.0.0", - "lastUpdated": "2026-01-10T01:08:24Z", + "lastUpdated": "2026-02-18T23:21:20Z", "networks": { "testnet": { "chainId": "545", diff --git a/flow.json b/flow.json index a30f12f..2db8b15 100644 --- a/flow.json +++ b/flow.json @@ -21,15 +21,15 @@ "aliases": { "emulator": "045a1763c93006ca", "testing": "0000000000000007", - "testnet": "df111ffc5064198a" + "testnet": "7235eeea4b4d2dc8" } }, - "FlowYieldVaultsTransactionHandler": { - "source": "./cadence/contracts/FlowYieldVaultsTransactionHandler.cdc", + "FlowYieldVaultsEVMWorkerOps": { + "source": "./cadence/contracts/FlowYieldVaultsEVMWorkerOps.cdc", "aliases": { "emulator": "045a1763c93006ca", "testing": "0000000000000007", - "testnet": "df111ffc5064198a" + "testnet": "7235eeea4b4d2dc8" } } }, @@ -528,7 +528,7 @@ } }, "testnet-account": { - "address": "df111ffc5064198a", + "address": "7235eeea4b4d2dc8", "key": { "type": "google-kms", "hashAlgorithm": "SHA2_256", @@ -547,13 +547,13 @@ "deployments": { "emulator": { "emulator-flow-yield-vaults": [ - "FlowYieldVaultsTransactionHandler", + "FlowYieldVaultsEVMWorkerOps", "FlowYieldVaultsEVM" ] }, "testnet": { "testnet-account": [ - "FlowYieldVaultsTransactionHandler", + "FlowYieldVaultsEVMWorkerOps", "FlowYieldVaultsEVM" ] } diff --git a/local/deploy_and_verify.sh b/local/deploy_and_verify.sh index 24217f9..4d9b1df 100755 --- a/local/deploy_and_verify.sh +++ b/local/deploy_and_verify.sh @@ -31,7 +31,7 @@ flow transactions send "$PROJECT_ROOT/cadence/transactions/setup_coa.cdc" \ --compute-limit 9999 # Get the COA address -COA_ADDRESS=$(flow scripts execute "$PROJECT_ROOT/cadence/scripts/get_coa_address.cdc" 0xdf111ffc5064198a --network testnet --output json | jq -r '.value') +COA_ADDRESS=$(flow scripts execute "$PROJECT_ROOT/cadence/scripts/get_coa_address.cdc" $TESTNET_ACCOUNT_ADDRESS --network testnet --output json | jq -r '.value') if [ -z "$COA_ADDRESS" ] || [ "$COA_ADDRESS" == "null" ]; then echo "❌ Error: Could not get COA address" @@ -89,7 +89,7 @@ echo " Bytecode length: ${#FULL_BYTECODE} characters" echo "" # Deploy via COA (signed with Google KMS through Cadence) -GAS_LIMIT=5000000 +GAS_LIMIT=10000000 echo " Deploying via COA (Google KMS signed)..." DEPLOY_RESULT=$(flow transactions send "$PROJECT_ROOT/cadence/transactions/deploy_evm_contract.cdc" \ @@ -103,10 +103,10 @@ DEPLOY_RESULT=$(flow transactions send "$PROJECT_ROOT/cadence/transactions/deplo # Extract the deployed address from the EVM.TransactionExecuted event # Structure: .events[].values.value.fields[] where name == "contractAddress" DEPLOYED_ADDRESS=$(echo "$DEPLOY_RESULT" | jq -r ' - .events[] | - select(.type | contains("EVM.TransactionExecuted")) | - .values.value.fields[] | - select(.name == "contractAddress") | + .events[] | + select(.type | contains("EVM.TransactionExecuted")) | + .values.value.fields[] | + select(.name == "contractAddress") | .value.value ' 2>/dev/null | head -1) @@ -181,15 +181,13 @@ echo "✅ Worker initialized and FlowYieldVaultsRequests address set" echo "" # ========================================== -# Step 7: Initialize Transaction Handler & Schedule +# Step 7: Initialize WorkerOps Handlers & Schedule # ========================================== -echo "🔧 Step 7: Initializing FlowYieldVaultsTransactionHandler and scheduling initial execution..." -echo " - Delay: 10 seconds" -echo " - Priority: Calculated dynamically based on execution effort" -echo " - Execution Effort: Calculated dynamically based on maxRequestsPerTx" +echo "🔧 Step 7: Initializing FlowYieldVaultsEVMWorkerOps handlers and scheduling initial execution..." +echo " - SchedulerHandler: Recurrent job at fixed interval" +echo " - WorkerHandler: Processes individual requests" flow transactions send "$PROJECT_ROOT/cadence/transactions/scheduler/init_and_schedule.cdc" \ - 10.0 \ --network testnet \ --signer testnet-account \ --compute-limit 9999 @@ -248,8 +246,8 @@ echo " https://evm-testnet.flowscan.io/address/$DEPLOYED_ADDRESS" echo "" echo "🔍 Useful Commands:" echo " - Check pending requests:" -echo " flow scripts execute cadence/scripts/check_pending_requests.cdc 0xdf111ffc5064198a --network testnet" +echo " flow scripts execute cadence/scripts/check_pending_requests.cdc $TESTNET_ACCOUNT_ADDRESS 0 10 --network testnet" echo "" echo " - Check handler status:" -echo " flow scripts execute cadence/scripts/check_yieldvaultmanager_status.cdc 0xdf111ffc5064198a --network testnet" +echo " flow scripts execute cadence/scripts/check_yieldvaultmanager_status.cdc $TESTNET_ACCOUNT_ADDRESS --network testnet" echo "" diff --git a/local/run_e2e_tests.sh b/local/run_e2e_tests.sh index 44bfa55..240eb5a 100755 --- a/local/run_e2e_tests.sh +++ b/local/run_e2e_tests.sh @@ -214,6 +214,35 @@ process_requests() { --compute-limit 9999 2>&1 } +# Extract transaction ID from flow output +extract_tx_id() { + local output=$1 + echo "$output" | grep -E "^ID" | head -1 | awk '{print $2}' +} + +# Get computation from transaction profile +get_tx_computation() { + local tx_id=$1 + flow transactions profile "$tx_id" --output /dev/null 2>/dev/null | grep -E "^Computation:" | awk '{print $2}' +} + +# Process requests and print transaction ID and computation +process_requests_verbose() { + local request_type=$1 + local start_index=${2:-0} + local count=${3:-10} + + local output=$(process_requests "$start_index" "$count") + local tx_id=$(extract_tx_id "$output") + + if [ -n "$tx_id" ]; then + local computation=$(get_tx_computation "$tx_id") + echo -e " ℹ️ Process TX ($request_type): $tx_id (computation: ${computation:-N/A})" >&2 + fi + + echo "$output" +} + # Wait for request to be processed wait_for_processing() { local request_id=$1 @@ -630,7 +659,7 @@ assert_balance_decreased "$USER_A_BALANCE_BEFORE" "$USER_A_BALANCE_AFTER_CREATE" # Process the request log_test "Process the pending request via Cadence" -PROCESS_OUTPUT=$(process_requests 0 10) +PROCESS_OUTPUT=$(process_requests_verbose "CREATE_YIELDVAULT" 0 10) if echo "$PROCESS_OUTPUT" | grep -q "SEALED"; then log_success "Request processing transaction sealed" else @@ -714,7 +743,7 @@ else # Process sleep 1 - process_requests 0 10 >/dev/null 2>&1 || true + process_requests_verbose "DEPOSIT_TO_YIELDVAULT" 0 10 >/dev/null || true sleep 2 # Verify escrow cleared after processing @@ -764,7 +793,7 @@ if [ -n "$YIELDVAULT_ID" ]; then # Process sleep 1 - process_requests 0 10 >/dev/null 2>&1 || true + process_requests_verbose "WITHDRAW_FROM_YIELDVAULT" 0 10 >/dev/null || true sleep 2 # Verify User A EVM balance increased by withdrawn amount @@ -805,7 +834,7 @@ assert_tx_success "$TX_OUTPUT" "User B create YieldVault transaction submitted" # Process sleep 1 -process_requests 0 10 >/dev/null 2>&1 || true +process_requests_verbose "CREATE_YIELDVAULT" 0 10 >/dev/null || true sleep 2 log_test "Verify User B has their own YieldVault" @@ -958,7 +987,7 @@ if [ -n "$YIELDVAULT_ID" ]; then # Process sleep 1 - process_requests 0 10 >/dev/null 2>&1 || true + process_requests_verbose "CLOSE_YIELDVAULT" 0 10 >/dev/null || true sleep 2 # Verify User A received all funds back @@ -1023,7 +1052,7 @@ if [ -n "$USER_B_VAULT_ID" ]; then # Process the deposit sleep 1 - process_requests 0 10 >/dev/null 2>&1 || true + process_requests_verbose "DEPOSIT_TO_YIELDVAULT (cross-user)" 0 10 >/dev/null || true sleep 2 # Verify User B's vault balance increased @@ -1053,7 +1082,7 @@ if [ -n "$USER_B_VAULT_ID" ]; then # Request submitted - will fail on Cadence processing log_info "Request submitted - verifying Cadence rejects it" sleep 1 - process_requests 0 10 >/dev/null 2>&1 || true + process_requests_verbose "CLOSE_YIELDVAULT (cross-user, should fail)" 0 10 >/dev/null || true sleep 2 # Verify User B's vault still exists and has balance @@ -1120,7 +1149,7 @@ log_info "Pending requests after rapid creation: $PENDING_COUNT" log_test "Process all pending requests" -PROCESS_OUTPUT=$(process_requests 0 20) +PROCESS_OUTPUT=$(process_requests_verbose "CREATE_YIELDVAULT (batch)" 0 20) if echo "$PROCESS_OUTPUT" | grep -q "SEALED"; then log_success "Batch processing completed" else @@ -1161,7 +1190,7 @@ TX_OUTPUT=$(cast_send "$USER_A_PK" \ assert_tx_success "$TX_OUTPUT" "Lifecycle: Create submitted" sleep 1 -process_requests 0 10 >/dev/null 2>&1 || true +process_requests_verbose "CREATE_YIELDVAULT (lifecycle)" 0 10 >/dev/null || true sleep 2 # Get the new vault ID @@ -1190,7 +1219,7 @@ if [ -n "$LIFECYCLE_VAULT_ID" ]; then # Process deposits sleep 1 - process_requests 0 10 >/dev/null 2>&1 || true + process_requests_verbose "DEPOSIT_TO_YIELDVAULT (lifecycle x2)" 0 10 >/dev/null || true sleep 2 # Withdraw 1 @@ -1209,7 +1238,7 @@ if [ -n "$LIFECYCLE_VAULT_ID" ]; then # Process withdrawals sleep 1 - process_requests 0 10 >/dev/null 2>&1 || true + process_requests_verbose "WITHDRAW_FROM_YIELDVAULT (lifecycle x2)" 0 10 >/dev/null || true sleep 2 # Close @@ -1220,7 +1249,7 @@ if [ -n "$LIFECYCLE_VAULT_ID" ]; then # Process close sleep 1 - process_requests 0 10 >/dev/null 2>&1 || true + process_requests_verbose "CLOSE_YIELDVAULT (lifecycle)" 0 10 >/dev/null || true sleep 2 # Verify lifecycle vault is closed and balance returned diff --git a/local/run_worker_tests.sh b/local/run_worker_tests.sh new file mode 100755 index 0000000..7d1fcc9 --- /dev/null +++ b/local/run_worker_tests.sh @@ -0,0 +1,1358 @@ +#!/bin/bash + +set -e # Exit on any error + +# ============================================ +# WORKER OPS TEST SCRIPT FOR FLOWYIELDVAULTSEVM +# ============================================ +# This script tests the FlowYieldVaultsEVMWorkerOps contract: +# - Scheduler initialization +# - Automated request processing via FlowTransactionScheduler +# - Pause/unpause scheduler +# - Stop all scheduled transactions +# - Multi-user automated processing +# +# The contract address is automatically loaded from ./local/.deployed_contract_address +# (created by deploy_full_stack.sh) +# +# Usage (run all three scripts chained): +# ./local/setup_and_run_emulator.sh && ./local/deploy_full_stack.sh && ./local/run_worker_tests.sh +# +# Or run individually after deployment: +# ./local/run_worker_tests.sh +# ============================================ + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Test counters +TESTS_PASSED=0 +TESTS_FAILED=0 +TOTAL_TESTS=0 + +# ============================================ +# CONFIGURATION +# ============================================ + +# Check if contract address is set, otherwise read from file +if [ -z "$FLOW_VAULTS_REQUESTS_CONTRACT" ]; then + if [ -f "./local/.deployed_contract_address" ]; then + FLOW_VAULTS_REQUESTS_CONTRACT=$(cat ./local/.deployed_contract_address) + echo "Loaded contract address from ./local/.deployed_contract_address" + else + echo -e "${RED}ERROR: FLOW_VAULTS_REQUESTS_CONTRACT not set${NC}" + echo "Please run ./local/deploy_full_stack.sh first" + exit 1 + fi +fi + +# Test accounts (from deploy_full_stack.sh) +# Private Key 0x3 -> User A +USER_A_EOA="0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69" +USER_A_PK="0x0000000000000000000000000000000000000000000000000000000000000003" + +# Private Key 0x4 -> User B +USER_B_EOA="0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718" +USER_B_PK="0x0000000000000000000000000000000000000000000000000000000000000004" + +# Private Key 0x5 -> User C +USER_C_EOA="0xe1AB8145F7E55DC933d51a18c793F901A3A0b276" +USER_C_PK="0x0000000000000000000000000000000000000000000000000000000000000005" + +RPC_URL="http://localhost:8545" + +# Contract constants +NATIVE_FLOW="0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF" +VAULT_IDENTIFIER="A.0ae53cb6e3f42a79.FlowToken.Vault" +STRATEGY_IDENTIFIER="${STRATEGY_IDENTIFIER:-A.045a1763c93006ca.FlowYieldVaultsStrategies.TracerStrategy}" +CADENCE_CONTRACT_ADDR="045a1763c93006ca" + +# Scheduler configuration +SCHEDULER_WAKEUP_INTERVAL=2 # Default scheduler wakeup interval in seconds +AUTO_PROCESS_TIMEOUT=10 # Timeout for waiting for automatic processing + +# ============================================ +# HELPER FUNCTIONS +# ============================================ + +log_section() { + echo "" + echo -e "${BLUE}============================================${NC}" + echo -e "${BLUE}$1${NC}" + echo -e "${BLUE}============================================${NC}" +} + +log_test() { + TOTAL_TESTS=$((TOTAL_TESTS + 1)) + echo -e "\n${YELLOW}TEST $TOTAL_TESTS: $1${NC}" +} + +log_success() { + TESTS_PASSED=$((TESTS_PASSED + 1)) + echo -e "${GREEN}PASSED: $1${NC}" +} + +log_fail() { + TESTS_FAILED=$((TESTS_FAILED + 1)) + echo -e "${RED}FAILED: $1${NC}" +} + +log_info() { + echo -e " INFO: $1" +} + +log_warn() { + echo -e "${YELLOW} WARN: $1${NC}" +} + +# Execute EVM transaction via cast +cast_send() { + local user_pk=$1 + local function_sig=$2 + shift 2 + + cast send "$FLOW_VAULTS_REQUESTS_CONTRACT" \ + "$function_sig" \ + "$@" \ + --rpc-url "$RPC_URL" \ + --private-key "$user_pk" \ + --legacy 2>&1 +} + +# Execute EVM call via cast +cast_call() { + local function_sig=$1 + shift + + cast call "$FLOW_VAULTS_REQUESTS_CONTRACT" \ + "$function_sig" \ + "$@" \ + --rpc-url "$RPC_URL" 2>&1 +} + +# Get pending request count +get_pending_count() { + cast_call "getPendingRequestCount()(uint256)" +} + +# Get request status (0=PENDING, 1=PROCESSING, 2=COMPLETED, 3=FAILED) +get_request_status() { + local request_id=$1 + # Use getRequestUnpacked which returns fields separately - status is the 4th return value (index 3) + local result=$(cast call "$FLOW_VAULTS_REQUESTS_CONTRACT" \ + "getRequestUnpacked(uint256)(uint256,address,uint8,uint8,address,uint256,uint64,uint256,string,string,string)" \ + "$request_id" \ + --rpc-url "$RPC_URL" 2>/dev/null) + # The output has each field on a separate line, status (uint8) is the 4th line + echo "$result" | sed -n '4p' | tr -d ' ' +} + +# Get user's YieldVault IDs from Cadence +get_user_yieldvaults() { + local evm_address=$1 + flow scripts execute ./cadence/scripts/check_user_yieldvaults.cdc "$evm_address" 2>/dev/null | \ + grep "Result:" | sed 's/Result: //' +} + +# Clean up wei values for numeric comparisons +clean_wei() { + echo "$1" | sed 's/ \[.*\]$//' | tr -d ' ' | sed 's/^0*$/0/' | sed 's/^0\+\([1-9]\)/\1/' +} + +# Convert wei to ether (using bc for arbitrary precision) +wei_to_ether() { + local wei=$1 + wei=$(echo "$wei" | sed 's/ \[.*\]$//' | tr -d ' ') + if [ -z "$wei" ] || [ "$wei" = "0" ]; then + echo "0" + return + fi + echo "scale=2; $wei / 1000000000000000000" | bc +} + +# Get user balance on EVM (in wei) +get_user_balance() { + local user_address=$1 + cast balance "$user_address" --rpc-url "$RPC_URL" 2>/dev/null | \ + sed 's/ \[.*\]$//' | tr -d ' ' +} + +# Get escrow balance from Solidity contract (in wei) +get_escrow_balance() { + local user_address=$1 + local token_address=${2:-$NATIVE_FLOW} + cast_call "getUserPendingBalance(address,address)(uint256)" "$user_address" "$token_address" | \ + sed 's/ \[.*\]$//' | tr -d ' ' +} + +# Get claimable refund balance from Solidity contract (in wei) +get_claimable_refund() { + local user_address=$1 + local token_address=${2:-$NATIVE_FLOW} + cast_call "getClaimableRefund(address,address)(uint256)" "$user_address" "$token_address" | \ + sed 's/ \[.*\]$//' | tr -d ' ' +} + +# Get request message (error message or status message) +get_request_message() { + local request_id=$1 + # Get the full request and extract the message field (9th field in the tuple) + local result=$(cast_call "getRequest(uint256)((uint256,address,uint8,uint8,address,uint256,uint64,uint256,string,string,string))" "$request_id" 2>&1) + # Extract the first quoted string which is the message field + echo "$result" | grep -oE '"[^"]*"' | head -1 | tr -d '"' +} + +# Get the next request ID (current counter value) +get_next_request_id() { + # The _requestIdCounter is private, but we can infer it from getPendingRequestCount + # or by checking the latest request. We'll use a simple approach: query total requests + # Actually, let's call the contract to get requestIdCounter via the last created request + # Since requests are 1-indexed and sequential, we can get the count + local result=$(cast call "$FLOW_VAULTS_REQUESTS_CONTRACT" "getPendingRequestCount()(uint256)" --rpc-url "$RPC_URL" 2>/dev/null) + result=$(clean_wei "$result") + echo "$result" +} + +# Compare two large numbers (wei values) using bc +# Usage: compare_wei $VALUE1 $OPERATOR $VALUE2 +# Returns 0 if comparison is true, 1 otherwise +# Operators: -gt, -lt, -ge, -le, -eq +compare_wei() { + local val1=$1 + local op=$2 + local val2=$3 + + # Handle empty values + val1=${val1:-0} + val2=${val2:-0} + + case "$op" in + -gt) [ "$(echo "$val1 > $val2" | bc)" -eq 1 ] ;; + -lt) [ "$(echo "$val1 < $val2" | bc)" -eq 1 ] ;; + -ge) [ "$(echo "$val1 >= $val2" | bc)" -eq 1 ] ;; + -le) [ "$(echo "$val1 <= $val2" | bc)" -eq 1 ] ;; + -eq) [ "$(echo "$val1 == $val2" | bc)" -eq 1 ] ;; + *) return 1 ;; + esac +} + +# Subtract two large numbers (wei values) using bc +# Usage: subtract_wei $VALUE1 $VALUE2 +subtract_wei() { + local val1=${1:-0} + local val2=${2:-0} + echo "$val1 - $val2" | bc +} + +# Wait for request to reach a specific status +# Usage: wait_for_request_status $REQUEST_ID $EXPECTED_STATUS [timeout] +# Returns 0 if status reached, 1 if timeout +wait_for_request_status() { + local request_id=$1 + local expected_status=$2 + local timeout=${3:-$AUTO_PROCESS_TIMEOUT} + local counter=0 + + log_info "Waiting for request $request_id to reach status $expected_status (timeout: ${timeout}s)..." + + while [ $counter -lt $timeout ]; do + tick_emulator + + local current_status=$(get_request_status "$request_id") + + if [ "$current_status" = "$expected_status" ]; then + log_info "Request $request_id reached status $expected_status after ${counter}s" + return 0 + fi + + sleep 1 + counter=$((counter + 1)) + + if [ $((counter % 5)) -eq 0 ]; then + log_info "Still waiting... (${counter}s elapsed, current status: $current_status)" + fi + done + + log_warn "Timeout waiting for request $request_id to reach status $expected_status" + return 1 +} + +# Extract request ID from transaction logs +# 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 + 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)) + else + echo "" + fi +} + +# ============================================ +# SCHEDULER-SPECIFIC HELPER FUNCTIONS +# ============================================ + +# Check if scheduler is paused +check_scheduler_paused() { + local result=$(flow scripts execute ./cadence/scripts/scheduler/check_handler_paused.cdc 2>/dev/null | \ + grep "Result:" | sed 's/Result: //') + echo "$result" +} + +# Pause the scheduler +pause_scheduler() { + flow transactions send ./cadence/transactions/scheduler/pause_transaction_handler.cdc \ + --signer emulator-flow-yield-vaults \ + --compute-limit 9999 2>&1 +} + +# Unpause the scheduler +unpause_scheduler() { + flow transactions send ./cadence/transactions/scheduler/unpause_transaction_handler.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 \ + --signer emulator-flow-yield-vaults \ + --compute-limit 9999 2>&1 +} + +# Send a no-op transaction to trigger emulator block processing +# This ensures FlowTransactionScheduler executes pending scheduled transactions +tick_emulator() { + flow transactions send ./cadence/tests/transactions/no_op.cdc --signer emulator-flow-yield-vaults >/dev/null 2>&1 || true +} + +# Count YieldVaults from the get_user_yieldvaults output +count_yieldvaults() { + local vaults_output="$1" + # Count numeric IDs in the output (handles "[]" as 0, "[1, 2, 3]" as 3) + if [ -z "$vaults_output" ] || [ "$vaults_output" = "[]" ]; then + echo "0" + else + echo "$vaults_output" | grep -Eo '[0-9]+' | wc -l | tr -d ' ' + 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 +wait_for_user_vault() { + local user_eoa=$1 + local vaults_before=$2 + local timeout=${3:-$AUTO_PROCESS_TIMEOUT} + local counter=0 + + local count_before=$(count_yieldvaults "$vaults_before") + log_info "Waiting for $user_eoa to receive new YieldVault (had $count_before, timeout: ${timeout}s)..." + + while [ $counter -lt $timeout ]; do + # Send no-op to trigger emulator processing of scheduled transactions + tick_emulator + + local current_vaults=$(get_user_yieldvaults "$user_eoa") + local count_current=$(count_yieldvaults "$current_vaults") + + if [ "$count_current" -gt "$count_before" ]; then + log_info "User received new YieldVault after ${counter}s (now has $count_current)" + return 0 + fi + + sleep 1 + counter=$((counter + 1)) + + # Progress indicator every 5 seconds + if [ $((counter % 5)) -eq 0 ]; then + log_info "Still waiting... (${counter}s elapsed, vaults: $count_current)" + fi + done + + log_warn "Timeout waiting for new YieldVault" + return 1 +} + +# Wait for multiple users to each have more YieldVaults than before +# Usage: wait_for_users_vaults "EOA1 EOA2 EOA3" "VAULTS1" "VAULTS2" "VAULTS3" [timeout] +# Returns 0 if all users received new vaults, 1 if timeout +wait_for_users_vaults() { + local user_eoas=$1 + local timeout=${5:-$AUTO_PROCESS_TIMEOUT} + local counter=0 + + # Store initial counts in arrays + local -a eoas=($user_eoas) + local -a initial_counts + initial_counts[0]=$(count_yieldvaults "$2") + initial_counts[1]=$(count_yieldvaults "$3") + initial_counts[2]=$(count_yieldvaults "$4") + + log_info "Waiting for ${#eoas[@]} users to receive new YieldVaults (timeout: ${timeout}s)..." + + while [ $counter -lt $timeout ]; do + tick_emulator + + local all_received=true + local status="" + + for i in "${!eoas[@]}"; do + local current_vaults=$(get_user_yieldvaults "${eoas[$i]}") + local count_current=$(count_yieldvaults "$current_vaults") + local count_initial=${initial_counts[$i]} + + if [ "$count_current" -le "$count_initial" ]; then + all_received=false + status="$status User$((i+1)):$count_current/$((count_initial+1))" + else + status="$status User$((i+1)):OK" + fi + done + + if [ "$all_received" = "true" ]; then + log_info "All users received new YieldVaults after ${counter}s" + return 0 + fi + + sleep 1 + counter=$((counter + 1)) + + if [ $((counter % 5)) -eq 0 ]; then + log_info "Still waiting... (${counter}s elapsed,$status)" + fi + done + + log_warn "Timeout waiting for all users to receive YieldVaults" + return 1 +} + +# Assert equals +assert_eq() { + local expected=$1 + local actual=$2 + local message=$3 + + if [ "$expected" = "$actual" ]; then + log_success "$message" + return 0 + else + log_fail "$message (expected: $expected, got: $actual)" + return 1 + fi +} + +# Assert not equals +assert_neq() { + local not_expected=$1 + local actual=$2 + local message=$3 + + if [ "$not_expected" != "$actual" ]; then + log_success "$message" + return 0 + else + log_fail "$message (should not be: $not_expected)" + return 1 + fi +} + +# Assert transaction success +assert_tx_success() { + local output=$1 + local message=$2 + + if echo "$output" | grep -q "SEALED"; then + log_success "$message" + return 0 + else + log_fail "$message" + echo "$output" + return 1 + fi +} + +# Assert EVM transaction success +assert_evm_tx_success() { + local output=$1 + local message=$2 + + if echo "$output" | grep -q "status.*1"; then + log_success "$message" + return 0 + else + log_fail "$message" + echo "$output" + return 1 + fi +} + +# ============================================ +# SETUP & VERIFICATION +# ============================================ + +log_section "SETUP & VERIFICATION" + +echo "Contract Address: $FLOW_VAULTS_REQUESTS_CONTRACT" +echo "User A: $USER_A_EOA" +echo "User B: $USER_B_EOA" +echo "User C: $USER_C_EOA" + +# Verify RPC connection +log_test "Verify EVM Gateway is responding" +RPC_CHECK=$(curl -s -X POST "$RPC_URL" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}' || echo "") + +if echo "$RPC_CHECK" | grep -q "0x"; then + log_success "EVM Gateway is responding" +else + log_fail "EVM Gateway not responding" + exit 1 +fi + +# Verify contract is deployed +log_test "Verify contract is deployed" +CODE=$(cast code "$FLOW_VAULTS_REQUESTS_CONTRACT" --rpc-url "$RPC_URL" 2>/dev/null || echo "0x") +if [ "$CODE" != "0x" ] && [ -n "$CODE" ]; then + log_success "Contract is deployed" +else + log_fail "Contract not found at $FLOW_VAULTS_REQUESTS_CONTRACT" + exit 1 +fi + +# ============================================ +# INITIAL BALANCES +# ============================================ + +log_section "Initial User Balances" + +USER_A_BALANCE_START=$(get_user_balance "$USER_A_EOA") +USER_B_BALANCE_START=$(get_user_balance "$USER_B_EOA") +USER_C_BALANCE_START=$(get_user_balance "$USER_C_EOA") + +echo "" +echo "User A ($USER_A_EOA): $(wei_to_ether $USER_A_BALANCE_START) FLOW" +echo "User B ($USER_B_EOA): $(wei_to_ether $USER_B_BALANCE_START) FLOW" +echo "User C ($USER_C_EOA): $(wei_to_ether $USER_C_BALANCE_START) FLOW" +echo "" + +# ============================================ +# SCENARIO 1: SCHEDULER INITIALIZATION +# ============================================ + +log_section "SCENARIO 1: Scheduler Initialization" + +log_test "Initialize scheduler handlers" + +# Check initial paused state (may fail if not initialized yet) +INITIAL_PAUSED=$(check_scheduler_paused 2>/dev/null || echo "unknown") +log_info "Initial scheduler paused state: $INITIAL_PAUSED" + +# Initialize scheduler +INIT_OUTPUT=$(init_scheduler 2>&1) + +if echo "$INIT_OUTPUT" | grep -q "SEALED"; then + log_success "Scheduler handlers initialized" +else + # May already be initialized, which is fine + if echo "$INIT_OUTPUT" | grep -q "already"; then + log_info "Scheduler handlers already initialized" + log_success "Scheduler handlers ready" + else + log_warn "Scheduler initialization output: $INIT_OUTPUT" + log_success "Proceeding with existing scheduler state" + fi +fi + +log_test "Verify scheduler is not paused after initialization" + +PAUSED_STATE=$(check_scheduler_paused) +log_info "Scheduler paused state: $PAUSED_STATE" + +if [ "$PAUSED_STATE" = "false" ]; then + log_success "Scheduler is not paused" +else + log_warn "Scheduler is paused, attempting to unpause..." + unpause_scheduler >/dev/null 2>&1 || true + sleep 1 + PAUSED_STATE=$(check_scheduler_paused) + if [ "$PAUSED_STATE" = "false" ]; then + log_success "Scheduler unpaused successfully" + else + log_fail "Could not unpause scheduler" + fi +fi + +# ============================================ +# SCENARIO 2: SINGLE REQUEST AUTOMATIC PROCESSING +# ============================================ + +log_section "SCENARIO 2: Single Request Automatic Processing" + +# Get initial state +USER_A_VAULTS_BEFORE=$(get_user_yieldvaults "$USER_A_EOA") + +log_test "Create single YieldVault request" + +TX_OUTPUT=$(cast_send "$USER_A_PK" \ + "createYieldVault(address,uint256,string,string)" \ + "$NATIVE_FLOW" \ + "1000000000000000000" \ + "$VAULT_IDENTIFIER" \ + "$STRATEGY_IDENTIFIER" \ + --value "1ether") + +assert_evm_tx_success "$TX_OUTPUT" "YieldVault creation request submitted" + +log_test "Wait for YieldVault to be created" + +if wait_for_user_vault "$USER_A_EOA" "$USER_A_VAULTS_BEFORE" "$AUTO_PROCESS_TIMEOUT"; then + USER_A_VAULTS_AFTER=$(get_user_yieldvaults "$USER_A_EOA") + log_info "User A YieldVaults before: $USER_A_VAULTS_BEFORE" + log_info "User A YieldVaults after: $USER_A_VAULTS_AFTER" + log_success "YieldVault created via automatic processing" +else + log_fail "YieldVault was not created within timeout" +fi + +# ============================================ +# SCENARIO 3: PAUSE WITH MULTI-USER REQUESTS +# ============================================ + +log_section "SCENARIO 3: Pause With Multi-User Requests" + +log_test "Pause the scheduler" + +PAUSE_OUTPUT=$(pause_scheduler) +assert_tx_success "$PAUSE_OUTPUT" "Pause transaction submitted" + +log_test "Verify scheduler is paused" + +sleep 1 +PAUSED_STATE=$(check_scheduler_paused) +assert_eq "true" "$PAUSED_STATE" "Scheduler reports paused state" + +# Record initial vault counts +USER_A_VAULTS_INITIAL=$(get_user_yieldvaults "$USER_A_EOA") +USER_B_VAULTS_INITIAL=$(get_user_yieldvaults "$USER_B_EOA") +USER_C_VAULTS_INITIAL=$(get_user_yieldvaults "$USER_C_EOA") + +USER_A_COUNT_BEFORE=$(count_yieldvaults "$USER_A_VAULTS_INITIAL") +USER_B_COUNT_BEFORE=$(count_yieldvaults "$USER_B_VAULTS_INITIAL") +USER_C_COUNT_BEFORE=$(count_yieldvaults "$USER_C_VAULTS_INITIAL") + +PENDING_BEFORE=$(get_pending_count) +PENDING_BEFORE=$(clean_wei "$PENDING_BEFORE") + +log_test "Create requests from multiple users while paused" + +# User A creates request +TX_A=$(cast_send "$USER_A_PK" \ + "createYieldVault(address,uint256,string,string)" \ + "$NATIVE_FLOW" \ + "1000000000000000000" \ + "$VAULT_IDENTIFIER" \ + "$STRATEGY_IDENTIFIER" \ + --value "1ether" 2>&1) + +# User B creates request +TX_B=$(cast_send "$USER_B_PK" \ + "createYieldVault(address,uint256,string,string)" \ + "$NATIVE_FLOW" \ + "1000000000000000000" \ + "$VAULT_IDENTIFIER" \ + "$STRATEGY_IDENTIFIER" \ + --value "1ether" 2>&1) + +# User C creates request +TX_C=$(cast_send "$USER_C_PK" \ + "createYieldVault(address,uint256,string,string)" \ + "$NATIVE_FLOW" \ + "1000000000000000000" \ + "$VAULT_IDENTIFIER" \ + "$STRATEGY_IDENTIFIER" \ + --value "1ether" 2>&1) + +USER_A_SUCCESS=$(echo "$TX_A" | grep -q "status.*1" && echo "true" || echo "false") +USER_B_SUCCESS=$(echo "$TX_B" | grep -q "status.*1" && echo "true" || echo "false") +USER_C_SUCCESS=$(echo "$TX_C" | grep -q "status.*1" && echo "true" || echo "false") + +log_info "User A request: $USER_A_SUCCESS" +log_info "User B request: $USER_B_SUCCESS" +log_info "User C request: $USER_C_SUCCESS" + +if [ "$USER_A_SUCCESS" = "true" ] && [ "$USER_B_SUCCESS" = "true" ] && [ "$USER_C_SUCCESS" = "true" ]; then + log_success "All multi-user requests submitted" +else + log_fail "Some requests failed to submit" +fi + +log_test "Verify requests stay PENDING while paused" + +# Wait longer than scheduler interval to ensure it would have processed if active +sleep $((SCHEDULER_WAKEUP_INTERVAL * 3)) + +PENDING_AFTER_PAUSE=$(get_pending_count) +PENDING_AFTER_PAUSE=$(clean_wei "$PENDING_AFTER_PAUSE") + +log_info "Pending requests: $PENDING_BEFORE -> $PENDING_AFTER_PAUSE" + +EXPECTED_PENDING=$((PENDING_BEFORE + 3)) +if [ "$PENDING_AFTER_PAUSE" -ge "$EXPECTED_PENDING" ]; then + log_success "Requests remain pending while scheduler is paused" +else + log_fail "Expected $EXPECTED_PENDING pending, got $PENDING_AFTER_PAUSE" +fi + +log_test "Unpause the scheduler" + +UNPAUSE_OUTPUT=$(unpause_scheduler) +assert_tx_success "$UNPAUSE_OUTPUT" "Unpause transaction submitted" + +log_test "Checking scheduler is unpaused" + +sleep 1 +PAUSED_STATE=$(check_scheduler_paused) +assert_eq "false" "$PAUSED_STATE" "Scheduler reports unpaused state" + +log_test "Wait for all users to receive YieldVaults" + +if wait_for_users_vaults "$USER_A_EOA $USER_B_EOA $USER_C_EOA" \ + "$USER_A_VAULTS_INITIAL" "$USER_B_VAULTS_INITIAL" "$USER_C_VAULTS_INITIAL" \ + "$AUTO_PROCESS_TIMEOUT"; then + + USER_A_VAULTS_FINAL=$(get_user_yieldvaults "$USER_A_EOA") + USER_B_VAULTS_FINAL=$(get_user_yieldvaults "$USER_B_EOA") + USER_C_VAULTS_FINAL=$(get_user_yieldvaults "$USER_C_EOA") + + USER_A_COUNT_AFTER=$(count_yieldvaults "$USER_A_VAULTS_FINAL") + USER_B_COUNT_AFTER=$(count_yieldvaults "$USER_B_VAULTS_FINAL") + USER_C_COUNT_AFTER=$(count_yieldvaults "$USER_C_VAULTS_FINAL") + + log_info "User A YieldVaults: $USER_A_COUNT_BEFORE -> $USER_A_COUNT_AFTER" + log_info "User B YieldVaults: $USER_B_COUNT_BEFORE -> $USER_B_COUNT_AFTER" + log_info "User C YieldVaults: $USER_C_COUNT_BEFORE -> $USER_C_COUNT_AFTER" + + log_success "All 3 users received new YieldVaults" +else + log_fail "Not all users received new YieldVaults within timeout" +fi + +# ============================================ +# SCENARIO 4: PANIC RECOVERY - INVALID STRATEGY +# ============================================ + +log_section "SCENARIO 4: 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 + +# Record initial state +USER_A_REFUND_BEFORE=$(get_claimable_refund "$USER_A_EOA") +USER_A_REFUND_BEFORE=$(clean_wei "$USER_A_REFUND_BEFORE") + +log_test "Create YieldVault request with invalid strategy identifier" + +# Use an invalid strategy identifier (not a valid Cadence type) +INVALID_STRATEGY="InvalidStrategy.NotReal" + +TX_OUTPUT=$(cast_send "$USER_A_PK" \ + "createYieldVault(address,uint256,string,string)" \ + "$NATIVE_FLOW" \ + "1000000000000000000" \ + "$VAULT_IDENTIFIER" \ + "$INVALID_STRATEGY" \ + --value "1ether" 2>&1) + +INVALID_REQUEST_ID="" + +if echo "$TX_OUTPUT" | grep -q "status.*1"; then + log_success "Invalid strategy request submitted" + + # Extract request ID from the logs in TX_OUTPUT + # The RequestCreated event has requestId as the second topic (topics[1]) + # Event signature: RequestCreated(uint256 indexed requestId, address indexed user, ...) + # Look for the RequestCreated event log (has 4 topics) and get topics[1] + # The pattern 0x000...000X where X is a small hex number is the requestId + INVALID_REQUEST_ID=$(echo "$TX_OUTPUT" | grep -oE '"0x0{60,62}[0-9a-fA-F]{1,4}"' | head -1 | tr -d '"' || true) + + if [ -n "$INVALID_REQUEST_ID" ]; then + # Convert hex to decimal + INVALID_REQUEST_ID=$(printf "%d" "$INVALID_REQUEST_ID" 2>/dev/null || echo "") + fi + + log_info "New request ID: $INVALID_REQUEST_ID" + + if [ -z "$INVALID_REQUEST_ID" ]; then + log_fail "Could not determine request ID from transaction logs" + fi +else + log_fail "Failed to submit invalid strategy request" + echo "$TX_OUTPUT" +fi + +log_test "Wait for request to be marked as FAILED" + +if [ -z "$INVALID_REQUEST_ID" ]; then + log_fail "Cannot check status - no request ID available" +else + # Wait for the scheduler to preprocess and fail the request + # Status 3 = FAILED + REQUEST_STATUS="" + WAIT_COUNTER=0 + MAX_WAIT=$((AUTO_PROCESS_TIMEOUT + 5)) + + while [ $WAIT_COUNTER -lt $MAX_WAIT ]; do + tick_emulator + + REQUEST_STATUS=$(get_request_status "$INVALID_REQUEST_ID") + # Status 3 = FAILED, Status 2 = COMPLETED + if [ "$REQUEST_STATUS" = "3" ]; then + log_info "Request $INVALID_REQUEST_ID reached FAILED status after ${WAIT_COUNTER}s" + break + elif [ "$REQUEST_STATUS" = "2" ]; then + log_warn "Request unexpectedly completed successfully" + break + fi + + sleep 1 + WAIT_COUNTER=$((WAIT_COUNTER + 1)) + + if [ $((WAIT_COUNTER % 5)) -eq 0 ]; then + log_info "Still waiting... (${WAIT_COUNTER}s, status: $REQUEST_STATUS)" + fi + done + + if [ "$REQUEST_STATUS" = "3" ]; then + log_success "Request correctly marked as FAILED (status: 3)" + + # Optionally check the error message + ERROR_MSG=$(get_request_message "$INVALID_REQUEST_ID") + if [ -n "$ERROR_MSG" ]; then + log_info "Error message: $ERROR_MSG" + fi + else + log_fail "Request not marked as FAILED (status: $REQUEST_STATUS)" + fi +fi + +log_test "Verify refund was credited for failed request" + +# Check that the user's claimable refund increased +USER_A_REFUND_AFTER=$(get_claimable_refund "$USER_A_EOA") +USER_A_REFUND_AFTER=$(clean_wei "$USER_A_REFUND_AFTER") + +log_info "User A claimable refund: $USER_A_REFUND_BEFORE -> $USER_A_REFUND_AFTER wei" + +# Expected refund is 1 ether = 1000000000000000000 wei +EXPECTED_REFUND_INCREASE="1000000000000000000" + +if compare_wei "$USER_A_REFUND_AFTER" -gt "$USER_A_REFUND_BEFORE"; then + REFUND_INCREASE=$(subtract_wei "$USER_A_REFUND_AFTER" "$USER_A_REFUND_BEFORE") + if compare_wei "$REFUND_INCREASE" -ge "$EXPECTED_REFUND_INCREASE"; then + log_success "Refund credited correctly ($(wei_to_ether $REFUND_INCREASE) FLOW)" + else + log_warn "Refund credited but amount differs: expected $EXPECTED_REFUND_INCREASE, got $REFUND_INCREASE" + log_success "Refund was credited" + fi +else + log_fail "No refund credited for failed request" +fi + +# ============================================ +# SCENARIO 5: PREPROCESSING VALIDATION TESTS +# ============================================ + +log_section "SCENARIO 5: Preprocessing Validation Tests" + +# This test verifies that the preprocessing logic correctly rejects +# various types of invalid requests + +# --- Test Case A: Invalid vaultIdentifier --- + +log_test "Test Case A: Create request with invalid vaultIdentifier" + +USER_B_REFUND_BEFORE=$(get_claimable_refund "$USER_B_EOA") +USER_B_REFUND_BEFORE=$(clean_wei "$USER_B_REFUND_BEFORE") + +# Use an invalid vault identifier (not a valid Cadence type) +INVALID_VAULT="InvalidVault.NotReal" + +TX_OUTPUT_A=$(cast_send "$USER_B_PK" \ + "createYieldVault(address,uint256,string,string)" \ + "$NATIVE_FLOW" \ + "1000000000000000000" \ + "$INVALID_VAULT" \ + "$STRATEGY_IDENTIFIER" \ + --value "1ether" 2>&1) + +if echo "$TX_OUTPUT_A" | grep -q "status.*1"; then + log_success "Invalid vault identifier request submitted" +else + log_fail "Failed to submit invalid vault identifier request" + echo "$TX_OUTPUT_A" +fi + +# --- Test Case B: Unsupported strategy type --- + +log_test "Test Case B: Create request with unsupported strategy type" + +USER_C_REFUND_BEFORE=$(get_claimable_refund "$USER_C_EOA") +USER_C_REFUND_BEFORE=$(clean_wei "$USER_C_REFUND_BEFORE") + +# Use a valid Cadence type that is not a supported strategy +# FlowToken.Vault is a valid type but not a strategy +UNSUPPORTED_STRATEGY="A.${CADENCE_CONTRACT_ADDR}.FlowToken.Vault" + +TX_OUTPUT_B=$(cast_send "$USER_C_PK" \ + "createYieldVault(address,uint256,string,string)" \ + "$NATIVE_FLOW" \ + "1000000000000000000" \ + "$VAULT_IDENTIFIER" \ + "$UNSUPPORTED_STRATEGY" \ + --value "1ether" 2>&1) + +if echo "$TX_OUTPUT_B" | grep -q "status.*1"; then + log_success "Unsupported strategy request submitted" +else + log_fail "Failed to submit unsupported strategy request" + echo "$TX_OUTPUT_B" +fi + +log_test "Wait for preprocessing to fail both invalid requests" + +# Get pending count before waiting +PENDING_BEFORE_PREPROCESS=$(get_pending_count) +PENDING_BEFORE_PREPROCESS=$(clean_wei "$PENDING_BEFORE_PREPROCESS") + +# Wait for scheduler to preprocess and fail both requests +log_info "Waiting for scheduler to process invalid requests (pending: $PENDING_BEFORE_PREPROCESS)..." +sleep $((SCHEDULER_WAKEUP_INTERVAL * 2)) + +# Trigger emulator processing multiple times +for i in $(seq 1 12); do + tick_emulator + sleep 1 + if [ $((i % 4)) -eq 0 ]; then + CURRENT_PENDING=$(get_pending_count) + CURRENT_PENDING=$(clean_wei "$CURRENT_PENDING") + log_info "Processing... (${i}s elapsed, pending: $CURRENT_PENDING)" + fi +done + +# Verify both requests were processed (removed from pending) +PENDING_AFTER_PREPROCESS=$(get_pending_count) +PENDING_AFTER_PREPROCESS=$(clean_wei "$PENDING_AFTER_PREPROCESS") +REQUESTS_PROCESSED=$((PENDING_BEFORE_PREPROCESS - PENDING_AFTER_PREPROCESS)) + +log_info "Pending: $PENDING_BEFORE_PREPROCESS -> $PENDING_AFTER_PREPROCESS" + +if [ "$PENDING_AFTER_PREPROCESS" -eq 0 ]; then + log_success "Both invalid requests were processed by scheduler" +else + log_fail "Expected all requests to be processed (pending: $PENDING_AFTER_PREPROCESS)" +fi + +log_test "Verify refund was credited for invalid vault identifier request" + +USER_B_REFUND_AFTER=$(get_claimable_refund "$USER_B_EOA") +USER_B_REFUND_AFTER=$(clean_wei "$USER_B_REFUND_AFTER") + +log_info "User B claimable refund: $USER_B_REFUND_BEFORE -> $USER_B_REFUND_AFTER wei" + +# Expected refund is 1 ether +EXPECTED_REFUND="1000000000000000000" + +if compare_wei "$USER_B_REFUND_AFTER" -gt "$USER_B_REFUND_BEFORE"; then + REFUND_INCREASE=$(subtract_wei "$USER_B_REFUND_AFTER" "$USER_B_REFUND_BEFORE") + log_info "User B refund increase: $(wei_to_ether $REFUND_INCREASE) FLOW" + if compare_wei "$REFUND_INCREASE" -ge "$EXPECTED_REFUND"; then + log_success "Invalid vaultIdentifier request failed and refund credited" + else + log_warn "Refund credited but amount differs from expected" + log_success "Refund was credited" + fi +else + log_fail "No refund credited for invalid vaultIdentifier request" +fi + +log_test "Verify refund was credited for unsupported strategy request" + +USER_C_REFUND_AFTER=$(get_claimable_refund "$USER_C_EOA") +USER_C_REFUND_AFTER=$(clean_wei "$USER_C_REFUND_AFTER") + +log_info "User C claimable refund: $USER_C_REFUND_BEFORE -> $USER_C_REFUND_AFTER wei" + +if compare_wei "$USER_C_REFUND_AFTER" -gt "$USER_C_REFUND_BEFORE"; then + REFUND_INCREASE=$(subtract_wei "$USER_C_REFUND_AFTER" "$USER_C_REFUND_BEFORE") + log_info "User C refund increase: $(wei_to_ether $REFUND_INCREASE) FLOW" + if compare_wei "$REFUND_INCREASE" -ge "$EXPECTED_REFUND"; then + log_success "Unsupported strategy request failed and refund credited" + else + log_warn "Refund credited but amount differs from expected" + log_success "Refund was credited" + fi +else + log_fail "No refund credited for unsupported strategy request" +fi + +# ============================================ +# SCENARIO 6: MAX PROCESSING CAPACITY TEST +# ============================================ + +log_section "SCENARIO 6: 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 +# until capacity becomes available + +# First, pause the scheduler to accumulate requests +log_test "Pause scheduler to accumulate requests" + +PAUSE_OUTPUT=$(pause_scheduler) +assert_tx_success "$PAUSE_OUTPUT" "Scheduler paused for capacity test" + +sleep 1 +PAUSED_STATE=$(check_scheduler_paused) +if [ "$PAUSED_STATE" != "true" ]; then + log_fail "Could not pause scheduler for capacity test" +fi + +# Record initial vault counts for all users +USER_A_VAULTS_START=$(get_user_yieldvaults "$USER_A_EOA") +USER_B_VAULTS_START=$(get_user_yieldvaults "$USER_B_EOA") +USER_C_VAULTS_START=$(get_user_yieldvaults "$USER_C_EOA") + +USER_A_COUNT_START=$(count_yieldvaults "$USER_A_VAULTS_START") +USER_B_COUNT_START=$(count_yieldvaults "$USER_B_VAULTS_START") +USER_C_COUNT_START=$(count_yieldvaults "$USER_C_VAULTS_START") + +PENDING_START=$(get_pending_count) +PENDING_START=$(clean_wei "$PENDING_START") + +log_test "Create 5 requests rapidly (exceeds maxProcessingRequests=3)" + +# Create 5 requests - 2 from User A, 2 from User B, 1 from User C +# This exceeds the default maxProcessingRequests of 3 +# Add small delays between requests from same user to avoid nonce conflicts + +# Request 1: User A +log_info "Submitting request 1 (User A)..." +TX_1=$(cast_send "$USER_A_PK" \ + "createYieldVault(address,uint256,string,string)" \ + "$NATIVE_FLOW" \ + "1000000000000000000" \ + "$VAULT_IDENTIFIER" \ + "$STRATEGY_IDENTIFIER" \ + --value "1ether" 2>&1) +sleep 1 + +# Request 2: User B +log_info "Submitting request 2 (User B)..." +TX_2=$(cast_send "$USER_B_PK" \ + "createYieldVault(address,uint256,string,string)" \ + "$NATIVE_FLOW" \ + "1000000000000000000" \ + "$VAULT_IDENTIFIER" \ + "$STRATEGY_IDENTIFIER" \ + --value "1ether" 2>&1) +sleep 1 + +# Request 3: User C +log_info "Submitting request 3 (User C)..." +TX_3=$(cast_send "$USER_C_PK" \ + "createYieldVault(address,uint256,string,string)" \ + "$NATIVE_FLOW" \ + "1000000000000000000" \ + "$VAULT_IDENTIFIER" \ + "$STRATEGY_IDENTIFIER" \ + --value "1ether" 2>&1) +sleep 1 + +# Request 4: User A (second request) - wait extra for nonce +log_info "Submitting request 4 (User A second)..." +TX_4=$(cast_send "$USER_A_PK" \ + "createYieldVault(address,uint256,string,string)" \ + "$NATIVE_FLOW" \ + "1000000000000000000" \ + "$VAULT_IDENTIFIER" \ + "$STRATEGY_IDENTIFIER" \ + --value "1ether" 2>&1) +sleep 1 + +# Request 5: User B (second request) - wait extra for nonce +log_info "Submitting request 5 (User B second)..." +TX_5=$(cast_send "$USER_B_PK" \ + "createYieldVault(address,uint256,string,string)" \ + "$NATIVE_FLOW" \ + "1000000000000000000" \ + "$VAULT_IDENTIFIER" \ + "$STRATEGY_IDENTIFIER" \ + --value "1ether" 2>&1) + +# Count successful submissions +SUCCESS_COUNT=0 +for tx in "$TX_1" "$TX_2" "$TX_3" "$TX_4" "$TX_5"; do + if echo "$tx" | grep -q "status.*1"; then + SUCCESS_COUNT=$((SUCCESS_COUNT + 1)) + fi +done + +log_info "Successfully submitted $SUCCESS_COUNT of 5 requests" + +if [ "$SUCCESS_COUNT" -eq 5 ]; then + log_success "All 5 requests submitted successfully" +else + log_fail "Only $SUCCESS_COUNT of 5 requests submitted" +fi + +log_test "Verify all 5 requests are PENDING" + +PENDING_AFTER_SUBMIT=$(get_pending_count) +PENDING_AFTER_SUBMIT=$(clean_wei "$PENDING_AFTER_SUBMIT") + +EXPECTED_PENDING=$((PENDING_START + 5)) +log_info "Pending requests: $PENDING_START -> $PENDING_AFTER_SUBMIT (expected: $EXPECTED_PENDING)" + +if [ "$PENDING_AFTER_SUBMIT" -ge "$EXPECTED_PENDING" ]; then + log_success "All 5 requests are PENDING" +else + log_fail "Expected at least $EXPECTED_PENDING pending requests, got $PENDING_AFTER_SUBMIT" +fi + +log_test "Unpause scheduler and verify capacity limits" + +UNPAUSE_OUTPUT=$(unpause_scheduler) +assert_tx_success "$UNPAUSE_OUTPUT" "Scheduler unpaused" + +# Wait for one scheduler cycle +sleep $((SCHEDULER_WAKEUP_INTERVAL + 1)) + +# Trigger emulator processing +for i in $(seq 1 3); do + tick_emulator + sleep 1 +done + +# Check pending count - some requests should still be pending due to capacity +PENDING_AFTER_FIRST_CYCLE=$(get_pending_count) +PENDING_AFTER_FIRST_CYCLE=$(clean_wei "$PENDING_AFTER_FIRST_CYCLE") + +log_info "Pending after first scheduler cycle: $PENDING_AFTER_FIRST_CYCLE" + +# With maxProcessingRequests=3, at most 3 can be processed in one cycle +# So we expect at least 2 to still be pending (5 - 3 = 2) +if [ "$PENDING_AFTER_FIRST_CYCLE" -ge 2 ] && [ "$PENDING_AFTER_FIRST_CYCLE" -lt "$PENDING_AFTER_SUBMIT" ]; then + log_success "Capacity limit respected - some requests still pending" +elif [ "$PENDING_AFTER_FIRST_CYCLE" -eq 0 ]; then + log_info "All requests processed quickly (scheduler may have run multiple cycles)" + log_success "Requests processed" +else + log_warn "Unexpected pending count: $PENDING_AFTER_FIRST_CYCLE" + log_success "Proceeding with test" +fi + +log_test "Wait for all requests to be processed" + +# Extended timeout for processing all 5 requests (need multiple scheduler cycles) +# With maxProcessingRequests=3, we need at least 2 cycles to process 5 requests +EXTENDED_TIMEOUT=$((AUTO_PROCESS_TIMEOUT * 4)) + +log_info "Waiting for pending requests to be processed (timeout: ${EXTENDED_TIMEOUT}s)..." + +# Wait for all pending requests to be processed +WAIT_COUNTER=0 +while [ $WAIT_COUNTER -lt $EXTENDED_TIMEOUT ]; do + # Tick emulator multiple times per iteration to ensure scheduler cycles complete + for t in $(seq 1 5); do + tick_emulator + done + + CURRENT_PENDING=$(get_pending_count) + CURRENT_PENDING=$(clean_wei "$CURRENT_PENDING") + + if [ "$CURRENT_PENDING" -le "$PENDING_START" ]; then + log_info "All batch requests processed after ${WAIT_COUNTER}s (pending: $CURRENT_PENDING)" + break + fi + + sleep 2 + WAIT_COUNTER=$((WAIT_COUNTER + 2)) + + log_info "Still processing... (${WAIT_COUNTER}s, pending: $CURRENT_PENDING)" +done + +# Extra ticks after loop to ensure everything settles +log_info "Extra processing time to ensure vaults are created..." +for t in $(seq 1 10); do + tick_emulator +done +sleep 2 + +log_test "Verify all users received their YieldVaults" + +# Wait specifically for all 5 YieldVaults to appear +VAULT_WAIT_TIMEOUT=30 +VAULT_WAIT_COUNTER=0 +TOTAL_NEW=0 + +while [ $VAULT_WAIT_COUNTER -lt $VAULT_WAIT_TIMEOUT ]; do + USER_A_VAULTS_END=$(get_user_yieldvaults "$USER_A_EOA") + USER_B_VAULTS_END=$(get_user_yieldvaults "$USER_B_EOA") + USER_C_VAULTS_END=$(get_user_yieldvaults "$USER_C_EOA") + + USER_A_COUNT_END=$(count_yieldvaults "$USER_A_VAULTS_END") + USER_B_COUNT_END=$(count_yieldvaults "$USER_B_VAULTS_END") + USER_C_COUNT_END=$(count_yieldvaults "$USER_C_VAULTS_END") + + USER_A_NEW=$((USER_A_COUNT_END - USER_A_COUNT_START)) + USER_B_NEW=$((USER_B_COUNT_END - USER_B_COUNT_START)) + USER_C_NEW=$((USER_C_COUNT_END - USER_C_COUNT_START)) + + TOTAL_NEW=$((USER_A_NEW + USER_B_NEW + USER_C_NEW)) + + if [ "$TOTAL_NEW" -ge 5 ]; then + log_info "All 5 YieldVaults detected after ${VAULT_WAIT_COUNTER}s" + break + fi + + # Keep ticking emulator and waiting + for t in $(seq 1 3); do + tick_emulator + done + sleep 2 + VAULT_WAIT_COUNTER=$((VAULT_WAIT_COUNTER + 2)) + + if [ $((VAULT_WAIT_COUNTER % 6)) -eq 0 ]; then + log_info "Waiting for vaults... (${VAULT_WAIT_COUNTER}s, found: $TOTAL_NEW/5)" + fi +done + +log_info "User A new vaults: $USER_A_NEW (expected: 2)" +log_info "User B new vaults: $USER_B_NEW (expected: 2)" +log_info "User C new vaults: $USER_C_NEW (expected: 1)" + +if [ "$TOTAL_NEW" -eq 5 ]; then + log_success "All 5 YieldVaults created successfully" +else + # Check if any requests failed by looking at refunds + USER_A_REFUND_END=$(get_claimable_refund "$USER_A_EOA" 2>/dev/null || echo "0") + USER_A_REFUND_END=$(clean_wei "$USER_A_REFUND_END") + USER_B_REFUND_END=$(get_claimable_refund "$USER_B_EOA" 2>/dev/null || echo "0") + USER_B_REFUND_END=$(clean_wei "$USER_B_REFUND_END") + USER_C_REFUND_END=$(get_claimable_refund "$USER_C_EOA" 2>/dev/null || echo "0") + USER_C_REFUND_END=$(clean_wei "$USER_C_REFUND_END") + + log_info "Debug - User A refund balance: $(wei_to_ether $USER_A_REFUND_END) FLOW" + log_info "Debug - User B refund balance: $(wei_to_ether $USER_B_REFUND_END) FLOW" + log_info "Debug - User C refund balance: $(wei_to_ether $USER_C_REFUND_END) FLOW" + + FINAL_PENDING=$(get_pending_count) + FINAL_PENDING=$(clean_wei "$FINAL_PENDING") + log_info "Debug - Final pending count: $FINAL_PENDING" + + if [ "$TOTAL_NEW" -ge 4 ]; then + log_warn "Only $TOTAL_NEW of 5 YieldVaults created - one request may have failed" + # This could be due to a race condition or actual failure + # Check if refund was credited (indicates failure) + if [ "$USER_A_REFUND_END" != "0" ] && [ "$USER_A_NEW" -lt 2 ]; then + log_info "User A has refund balance - one request likely failed" + fi + log_success "Capacity test completed (most requests processed)" + else + log_fail "Only $TOTAL_NEW of 5 YieldVaults created (expected 5)" + fi +fi + +# ============================================ +# CLEANUP & FINAL STATE +# ============================================ + +log_section "CLEANUP & FINAL STATE" + +# Ensure scheduler is running for future use +PAUSED_STATE=$(check_scheduler_paused) +if [ "$PAUSED_STATE" = "true" ]; then + log_info "Unpausing scheduler for cleanup..." + unpause_scheduler >/dev/null 2>&1 || true +fi + +# Give any remaining pending requests time to process +FINAL_PENDING=$(get_pending_count) +FINAL_PENDING=$(clean_wei "$FINAL_PENDING") + +if [ "$FINAL_PENDING" -gt 0 ]; then + log_info "Waiting for $FINAL_PENDING remaining pending requests to process..." + for i in $(seq 1 15); do + tick_emulator + sleep 1 + done +fi + +FINAL_PENDING=$(get_pending_count) +FINAL_PENDING=$(clean_wei "$FINAL_PENDING") +log_info "Final pending request count: $FINAL_PENDING" + +# Final scheduler state +FINAL_PAUSED=$(check_scheduler_paused) +log_info "Final scheduler paused state: $FINAL_PAUSED" + +# Summary of YieldVaults +log_section "YIELDVAULT SUMMARY" + +echo "" +echo "User A: $(get_user_yieldvaults "$USER_A_EOA")" +echo "User B: $(get_user_yieldvaults "$USER_B_EOA")" +echo "User C: $(get_user_yieldvaults "$USER_C_EOA")" +echo "" + +# ============================================ +# TEST SUMMARY +# ============================================ + +log_section "TEST SUMMARY" + +echo "" +echo "Scheduler state: $FINAL_PAUSED" +echo "Pending requests remaining: $FINAL_PENDING" +echo "" +echo -e "Tests Passed: ${GREEN}$TESTS_PASSED${NC}" +echo -e "Tests Failed: ${RED}$TESTS_FAILED${NC}" +echo -e "Total Tests: $TOTAL_TESTS" +echo "" + +if [ $TESTS_FAILED -eq 0 ]; then + echo -e "${GREEN}=========================================${NC}" + echo -e "${GREEN}ALL TESTS PASSED!${NC}" + echo -e "${GREEN}=========================================${NC}" + echo "" + echo "FlowTransactionScheduler automatic execution verified." + echo "All worker operations tests completed successfully." + exit 0 +else + echo -e "${RED}=========================================${NC}" + echo -e "${RED}SOME TESTS FAILED${NC}" + echo -e "${RED}=========================================${NC}" + echo "" + echo "Review failed tests above for details." + exit 1 +fi diff --git a/local/testnet-e2e.sh b/local/testnet-e2e.sh index bc0cf57..36a18a1 100755 --- a/local/testnet-e2e.sh +++ b/local/testnet-e2e.sh @@ -84,25 +84,24 @@ # # Expected behavior: # 1. Request created with status PENDING (EVM contract doesn't validate identifiers) -# 2. TransactionHandler picks up request -# 3. startProcessing() called - funds moved from Contract to COA -# 4. Worker attempts to parse identifiers on Cadence side +# 2. SchedulerHandler picks up request +# 4. Preprocessing: preprocessRequests() attempts to parse identifiers on Cadence side # 5. Validation fails: "Invalid vaultIdentifier/strategyIdentifier: X is not a valid Cadence type" -# 6. completeProcessing(FAILED) called - credits claimableRefunds +# 6. PENDING -> FAILED # 7. No YieldVault created, yieldVaultId set to NO_YIELDVAULT_ID (max uint64) # # Balance changes: # - User wallet: -amount (+ gas fees) - funds left wallet -# - Pending balance: 0 (escrow was deducted at startProcessing) +# - Pending balance: 0 (escrow was deducted at startProcessingBatch) # - Contract balance: +amount (funds returned by COA during completeProcessing) # - COA balance: unchanged (funds returned to contract) # - YieldVault: none created # # REFUND MECHANISM: # ----------------- -# When a CREATE/DEPOSIT request fails after startProcessing(): -# 1. startProcessing() transfers funds: Contract -> COA -# 2. Cadence worker detects validation failure +# When a CREATE/DEPOSIT request fails/panics after PROCESSING state: +# 1. PROCESSING state transfers funds: Contract -> COA +# 2. SchedulerHandler detects validation failure in case of panic # 3. completeProcessing(FAILED) is called with refund: # - Native FLOW: COA sends funds back via msg.value # - ERC20 (WFLOW): COA approves contract, then contract pulls via transferFrom @@ -147,7 +146,7 @@ CADENCE_CONTRACT="${CADENCE_CONTRACT:-}" WFLOW="0xd3bF53DAC106A0290B0483EcBC89d40FcC961f3e" NATIVE_FLOW="0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF" DEFAULT_CONTRACT="0xF633C9dBf1a3964a895fCC4CA4404B6f8BA8141d" -DEFAULT_CADENCE_CONTRACT="0xdf111ffc5064198a" +DEFAULT_CADENCE_CONTRACT="0x764bdff06a0ee77e" REFUND_CHECK_MAX_ATTEMPTS="${REFUND_CHECK_MAX_ATTEMPTS:-60}" REFUND_CHECK_DELAY_SECONDS="${REFUND_CHECK_DELAY_SECONDS:-5}" @@ -409,61 +408,133 @@ check_full_state() { create_yieldvault_flow() { local amount=$1 + shift + + # Parse remaining arguments, looking for multiplier (x100) pattern + local vault="$DEFAULT_VAULT" + local strategy="$DEFAULT_STRATEGY" + local count=1 + + for arg in "$@"; do + if [[ "$arg" =~ ^x([0-9]+)$ ]]; then + count="${BASH_REMATCH[1]}" + elif [ "$vault" = "$DEFAULT_VAULT" ] && [ -n "$arg" ]; then + vault="$arg" + elif [ "$strategy" = "$DEFAULT_STRATEGY" ] && [ -n "$arg" ]; then + strategy="$arg" + fi + done + validate_amount "$amount" - local vault="${2:-$DEFAULT_VAULT}" - local strategy="${3:-$DEFAULT_STRATEGY}" local amount_wei=$(ether_to_wei "$amount") - print_header "Creating YieldVault with $amount Native FLOW" + if [ "$count" -gt 1 ]; then + print_header "Creating $count YieldVaults with $amount Native FLOW each" + else + print_header "Creating YieldVault with $amount Native FLOW" + fi echo "Vault: $vault" echo "Strategy: $strategy" echo "" - cast send "$CONTRACT" "createYieldVault(address,uint256,string,string)" \ - "$NATIVE_FLOW" \ - "$amount_wei" \ - "$vault" \ - "$strategy" \ - --value "$amount_wei" \ - --private-key "$PRIVATE_KEY" \ - --rpc-url "$RPC_URL" + for ((i = 1; i <= count; i++)); do + if [ "$count" -gt 1 ]; then + echo -e "${YELLOW}[$i/$count]${NC} Sending transaction..." + fi - print_success "Transaction sent" + cast send "$CONTRACT" "createYieldVault(address,uint256,string,string)" \ + "$NATIVE_FLOW" \ + "$amount_wei" \ + "$vault" \ + "$strategy" \ + --value "$amount_wei" \ + --private-key "$PRIVATE_KEY" \ + --rpc-url "$RPC_URL" \ + --gas-limit 1000000 + + if [ "$count" -gt 1 ]; then + print_success "Transaction $i/$count sent" + else + print_success "Transaction sent" + fi + done + + if [ "$count" -gt 1 ]; then + echo "" + print_success "All $count transactions sent" + fi } create_yieldvault_wflow() { local amount=$1 + shift + + # Parse remaining arguments, looking for multiplier (x100) pattern + local vault="$DEFAULT_VAULT" + local strategy="$DEFAULT_STRATEGY" + local count=1 + + for arg in "$@"; do + if [[ "$arg" =~ ^x([0-9]+)$ ]]; then + count="${BASH_REMATCH[1]}" + elif [ "$vault" = "$DEFAULT_VAULT" ] && [ -n "$arg" ]; then + vault="$arg" + elif [ "$strategy" = "$DEFAULT_STRATEGY" ] && [ -n "$arg" ]; then + strategy="$arg" + fi + done + validate_amount "$amount" - local vault="${2:-$DEFAULT_VAULT}" - local strategy="${3:-$DEFAULT_STRATEGY}" local amount_wei=$(ether_to_wei "$amount") - print_header "Creating YieldVault with $amount WFLOW" + # Calculate total amount needed for approval + local total_wei=$(echo "$amount_wei * $count" | bc) + + if [ "$count" -gt 1 ]; then + print_header "Creating $count YieldVaults with $amount WFLOW each" + else + print_header "Creating YieldVault with $amount WFLOW" + fi echo "Vault: $vault" echo "Strategy: $strategy" echo "" - # First approve WFLOW - echo "Approving WFLOW..." + # Approve total WFLOW upfront + echo "Approving WFLOW (total: $(wei_to_ether $total_wei) WFLOW)..." cast send "$WFLOW" "approve(address,uint256)" \ "$CONTRACT" \ - "$amount_wei" \ + "$total_wei" \ --private-key "$PRIVATE_KEY" \ --rpc-url "$RPC_URL" > /dev/null print_success "WFLOW approved" - # Then create YieldVault - echo "Creating YieldVault..." - cast send "$CONTRACT" "createYieldVault(address,uint256,string,string)" \ - "$WFLOW" \ - "$amount_wei" \ - "$vault" \ - "$strategy" \ - --private-key "$PRIVATE_KEY" \ - --rpc-url "$RPC_URL" + for ((i = 1; i <= count; i++)); do + if [ "$count" -gt 1 ]; then + echo -e "${YELLOW}[$i/$count]${NC} Creating YieldVault..." + else + echo "Creating YieldVault..." + fi - print_success "Transaction sent" + cast send "$CONTRACT" "createYieldVault(address,uint256,string,string)" \ + "$WFLOW" \ + "$amount_wei" \ + "$vault" \ + "$strategy" \ + --private-key "$PRIVATE_KEY" \ + --rpc-url "$RPC_URL" + + if [ "$count" -gt 1 ]; then + print_success "Transaction $i/$count sent" + else + print_success "Transaction sent" + fi + done + + if [ "$count" -gt 1 ]; then + echo "" + print_success "All $count transactions sent" + fi } claim_refund() { @@ -821,10 +892,12 @@ show_help() { echo " cadence-state Check Cadence state only" echo "" echo "USER COMMANDS:" - echo " create-flow [vault] [strategy]" + echo " create-flow [vault] [strategy] [xN]" echo " Create YieldVault with Native FLOW" - echo " create-wflow [vault] [strategy]" + echo " Use xN to create N requests (e.g., x100)" + echo " create-wflow [vault] [strategy] [xN]" echo " Create YieldVault with WFLOW" + echo " Use xN to create N requests (e.g., x100)" echo " refund-check [vault] [strategy]" echo " Force failure, then claim refund (defaults: InvalidVault/InvalidStrategy)" echo " claim-refund [token]" @@ -866,7 +939,9 @@ show_help() { echo "EXAMPLES:" echo " $0 state" echo " $0 create-flow 1.2" + echo " $0 create-flow 1.2 x100 # Create 100 requests" echo " $0 create-flow 1.5 InvalidVault InvalidStrategy" + echo " $0 create-wflow 1.0 x50 # Create 50 WFLOW requests" echo " $0 refund-check 0.1" echo " $0 request 10" echo "" @@ -901,14 +976,14 @@ case "$1" in print_error "Amount required" exit 1 fi - create_yieldvault_flow "$2" "$3" "$4" + create_yieldvault_flow "$2" "$3" "$4" "$5" ;; create-wflow) if [ -z "$2" ]; then print_error "Amount required" exit 1 fi - create_yieldvault_wflow "$2" "$3" "$4" + create_yieldvault_wflow "$2" "$3" "$4" "$5" ;; refund-check) refund_check "$2" "$3" "$4" diff --git a/solidity/deployments/artifacts/FlowYieldVaultsRequests.json b/solidity/deployments/artifacts/FlowYieldVaultsRequests.json index 1c49944..88f282c 100644 --- a/solidity/deployments/artifacts/FlowYieldVaultsRequests.json +++ b/solidity/deployments/artifacts/FlowYieldVaultsRequests.json @@ -694,6 +694,75 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "getRequestUnpacked", + "inputs": [ + { + "name": "requestId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "id", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "user", + "type": "address", + "internalType": "address" + }, + { + "name": "requestType", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "status", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "tokenAddress", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "yieldVaultId", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "timestamp", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "message", + "type": "string", + "internalType": "string" + }, + { + "name": "vaultIdentifier", + "type": "string", + "internalType": "string" + }, + { + "name": "strategyIdentifier", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "getUserPendingBalance", @@ -1078,12 +1147,17 @@ }, { "type": "function", - "name": "startProcessing", + "name": "startProcessingBatch", "inputs": [ { - "name": "requestId", - "type": "uint256", - "internalType": "uint256" + "name": "successfulRequestIds", + "type": "uint256[]", + "internalType": "uint256[]" + }, + { + "name": "rejectedRequestIds", + "type": "uint256[]", + "internalType": "uint256[]" } ], "outputs": [], @@ -1214,6 +1288,25 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "yieldVaultTokens", + "inputs": [ + { + "name": "", + "type": "uint64", + "internalType": "uint64" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "yieldVaultsByUser", @@ -1854,6 +1947,11 @@ "name": "CannotAllowlistZeroAddress", "inputs": [] }, + { + "type": "error", + "name": "CannotRegisterSentinelYieldVaultId", + "inputs": [] + }, { "type": "error", "name": "ContractPaused", @@ -1900,6 +1998,11 @@ "name": "InvalidCOAAddress", "inputs": [] }, + { + "type": "error", + "name": "InvalidRequestState", + "inputs": [] + }, { "type": "error", "name": "InvalidYieldVaultId", @@ -1991,11 +2094,6 @@ "name": "ReentrancyGuardReentrantCall", "inputs": [] }, - { - "type": "error", - "name": "RequestAlreadyFinalized", - "inputs": [] - }, { "type": "error", "name": "RequestNotFound", @@ -2032,5 +2130,64 @@ "type": "error", "name": "TransferFailed", "inputs": [] + }, + { + "type": "error", + "name": "YieldVaultIdAlreadyRegistered", + "inputs": [ + { + "name": "yieldVaultId", + "type": "uint64", + "internalType": "uint64" + } + ] + }, + { + "type": "error", + "name": "YieldVaultIdMismatch", + "inputs": [ + { + "name": "expectedId", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "providedId", + "type": "uint64", + "internalType": "uint64" + } + ] + }, + { + "type": "error", + "name": "YieldVaultTokenMismatch", + "inputs": [ + { + "name": "yieldVaultId", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "expected", + "type": "address", + "internalType": "address" + }, + { + "name": "provided", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "YieldVaultTokenNotSet", + "inputs": [ + { + "name": "yieldVaultId", + "type": "uint64", + "internalType": "uint64" + } + ] } ] diff --git a/solidity/src/FlowYieldVaultsRequests.sol b/solidity/src/FlowYieldVaultsRequests.sol index 8cc0f08..7b4bef1 100644 --- a/solidity/src/FlowYieldVaultsRequests.sol +++ b/solidity/src/FlowYieldVaultsRequests.sol @@ -28,7 +28,7 @@ import { * 4. CLOSE_YIELDVAULT: User requests closure → COA closes YieldVault and bridges all funds back * * Processing uses atomic two-phase commit: - * - startProcessing(): Marks request as PROCESSING, deducts user balance + * - startProcessingBatch(): Marks requests as PROCESSING, deducts user balances * - completeProcessing(): Marks as COMPLETED/FAILED, credits claimable refunds on failure */ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { @@ -61,7 +61,7 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { /// @param status Current status of the request /// @param tokenAddress Token being deposited/withdrawn (NATIVE_FLOW for native $FLOW) /// @param amount Amount of tokens involved - /// @param yieldVaultId Associated YieldVault Id (NO_YIELDVAULT_ID for CREATE_YIELDVAULT until assigned by Cadence) + /// @param yieldVaultId Associated YieldVault Id /// @param timestamp Block timestamp when request was created /// @param message Status message or error reason /// @param vaultIdentifier Cadence vault type identifier for CREATE_YIELDVAULT @@ -103,8 +103,8 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { /// @dev On Cadence side, WFLOW is automatically unwrapped to native FlowToken by FlowEVMBridge address public immutable WFLOW; - /// @notice Sentinel value for "no yieldvault" (used when CREATE_YIELDVAULT fails before yieldvault is created) - /// @dev Uses type(uint64).max since valid yieldVaultIds can be 0. Matches FlowYieldVaultsEVM.noYieldVaultId + /// @notice Sentinel value for "no yieldvault" + /// @dev Uses type(uint64).max since valid yieldVaultIds can be 0 uint64 public constant NO_YIELDVAULT_ID = type(uint64).max; /// @dev Auto-incrementing counter for request IDs, starts at 1 @@ -221,7 +221,7 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { error CanOnlyCancelPending(); /// @notice Request is not in expected status for this operation - error RequestAlreadyFinalized(); + error InvalidRequestState(); /// @notice Insufficient balance for withdrawal error InsufficientBalance( @@ -283,7 +283,7 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { /// @param requestType Type of operation requested /// @param tokenAddress Token involved in the request /// @param amount Amount of tokens - /// @param yieldVaultId Associated YieldVault Id (NO_YIELDVAULT_ID for CREATE_YIELDVAULT until assigned by Cadence) + /// @param yieldVaultId Associated YieldVault Id /// @param timestamp Block timestamp when request was created /// @param vaultIdentifier Cadence vault type identifier (for CREATE_YIELDVAULT) /// @param strategyIdentifier Cadence strategy type identifier (for CREATE_YIELDVAULT) @@ -434,9 +434,9 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { /// @param requestId Request ID that closed this YieldVault event YieldVaultIdUnregistered(uint64 indexed yieldVaultId, address indexed owner, uint256 indexed requestId); - /// @notice Emitted when requests are dropped by admin + /// @notice Emitted when requests are dropped /// @param requestIds Dropped request IDs - /// @param droppedBy Admin who dropped the requests + /// @param droppedBy Admin/COA who dropped the requests event RequestsDropped(uint256[] requestIds, address indexed droppedBy); /// @notice Emitted when a user claims their refund @@ -676,92 +676,7 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { function dropRequests( uint256[] calldata requestIds ) external onlyOwner nonReentrant { - // Pre-allocate array for tracking successfully dropped request IDs - uint256[] memory droppedIds = new uint256[](requestIds.length); - uint256 droppedCount = 0; - - // Process each request ID in the input array - for (uint256 i = 0; i < requestIds.length; ) { - uint256 requestId = requestIds[i]; - Request storage request = requests[requestId]; - - // Only process valid requests that are still in PENDING status - // This check prevents double-processing and handles invalid IDs gracefully - if ( - request.id == requestId && - request.status == RequestStatus.PENDING - ) { - // Mark request as failed with admin message - request.status = RequestStatus.FAILED; - request.message = "Dropped by admin"; - - // For CREATE/DEPOSIT requests, move funds to claimableRefunds - // User must call claimRefund() to withdraw them (pull pattern) - // WITHDRAW/CLOSE requests don't escrow funds, so nothing to do - if ( - (request.requestType == RequestType.CREATE_YIELDVAULT || - request.requestType == RequestType.DEPOSIT_TO_YIELDVAULT) && - request.amount > 0 - ) { - uint256 newBalance = - pendingUserBalances[request.user][request.tokenAddress] - - request.amount; - pendingUserBalances[request.user][request.tokenAddress] = newBalance; - emit BalanceUpdated( - request.user, - request.tokenAddress, - newBalance - ); - claimableRefunds[request.user][request.tokenAddress] += request.amount; - emit RefundCredited( - request.user, - request.tokenAddress, - request.amount, - requestId - ); - } - - // Update user's pending request count - if (userPendingRequestCount[request.user] > 0) { - userPendingRequestCount[request.user]--; - } - - // Remove from pending queues (both global and user-specific) - _removePendingRequest(requestId); - - emit RequestProcessed( - requestId, - request.user, - request.requestType, - RequestStatus.FAILED, - request.yieldVaultId, - "Dropped by admin" - ); - - // Track this request as successfully dropped - droppedIds[droppedCount] = requestId; - unchecked { - ++droppedCount; - } - } - - unchecked { - ++i; - } - } - - // Emit batch event only if requests were actually dropped - if (droppedCount > 0) { - // Create properly-sized array for the event - uint256[] memory actualDroppedIds = new uint256[](droppedCount); - for (uint256 j = 0; j < droppedCount; ) { - actualDroppedIds[j] = droppedIds[j]; - unchecked { - ++j; - } - } - emit RequestsDropped(actualDroppedIds, msg.sender); - } + _dropRequestsInternal(requestIds); } // ============================================ @@ -994,89 +909,30 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { // ============================================ /** - * @notice Begins processing a request by transitioning it to PROCESSING status. - * @dev This is the first phase of the two-phase commit pattern. Must be called by the - * authorized COA before executing Cadence-side operations. - * - * For CREATE/DEPOSIT requests: - * - Validates sufficient escrowed balance exists - * - Atomically deducts user's escrowed balance - * - Transfers funds to the COA for bridging to Cadence - * - * For WITHDRAW/CLOSE requests: - * - Only transitions status (no fund movement on EVM side) - * - Funds will be bridged back from Cadence in completeProcessing - * - * The PROCESSING status prevents request cancellation and double-processing. - * @param requestId The unique identifier of the request to start processing. + * @notice Processes a batch of PENDING requests. + * @dev For successful requests, marks them as PROCESSING. + * For rejected requests, marks them as FAILED. + * Single-request processing is supported by passing one request id in + * successfulRequestIds and an empty rejectedRequestIds array. + * @param successfulRequestIds The request ids to start processing (PENDING -> PROCESSING) + * @param rejectedRequestIds The request ids to drop (PENDING -> FAILED) */ - function startProcessing(uint256 requestId) external onlyAuthorizedCOA nonReentrant { - Request storage request = requests[requestId]; - - // === VALIDATION === - if (request.id != requestId) revert RequestNotFound(); - if (request.status != RequestStatus.PENDING) - revert RequestAlreadyFinalized(); + function startProcessingBatch( + uint256[] calldata successfulRequestIds, + uint256[] calldata rejectedRequestIds + ) external onlyAuthorizedCOA nonReentrant { - // === TRANSITION TO PROCESSING === - // This prevents cancellation and ensures atomicity with completeProcessing - request.status = RequestStatus.PROCESSING; - - // === HANDLE FUND TRANSFER FOR CREATE/DEPOSIT === - // WITHDRAW/CLOSE don't have escrowed funds on EVM side - if ( - request.requestType == RequestType.CREATE_YIELDVAULT || - request.requestType == RequestType.DEPOSIT_TO_YIELDVAULT - ) { - // Verify sufficient escrowed balance - uint256 currentBalance = pendingUserBalances[request.user][ - request.tokenAddress - ]; - if (currentBalance < request.amount) { - revert InsufficientBalance( - request.tokenAddress, - request.amount, - currentBalance - ); - } + // === REJECTED REQUESTS === + _dropRequestsInternal(rejectedRequestIds); - // Deduct from user's escrowed balance - pendingUserBalances[request.user][request.tokenAddress] = - currentBalance - - request.amount; - emit BalanceUpdated( - request.user, - request.tokenAddress, - pendingUserBalances[request.user][request.tokenAddress] - ); + // === SUCCESSFUL REQUESTS === + for (uint256 i = 0; i < successfulRequestIds.length; ) { + _startProcessingInternal(successfulRequestIds[i]); - // Transfer escrowed funds to COA for bridging to Cadence - if (isNativeFlow(request.tokenAddress)) { - // Native FLOW: send via low-level call - (bool success, ) = authorizedCOA.call{value: request.amount}(""); - if (!success) revert TransferFailed(); - } else { - // ERC20: use SafeERC20 transfer - IERC20(request.tokenAddress).safeTransfer( - authorizedCOA, - request.amount - ); + unchecked { + ++i; } - emit FundsWithdrawn( - authorizedCOA, - request.tokenAddress, - request.amount - ); } - - emit RequestProcessed( - requestId, - request.user, - request.requestType, - RequestStatus.PROCESSING, - request.yieldVaultId, - "Processing started" - ); } /** @@ -1109,9 +965,9 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { // === VALIDATION === if (request.id != requestId) revert RequestNotFound(); - // Only PROCESSING requests can be completed (must call startProcessing first) + // Only PROCESSING requests can be completed (must call startProcessingBatch first) if (request.status != RequestStatus.PROCESSING) - revert RequestAlreadyFinalized(); + revert InvalidRequestState(); // === UPDATE REQUEST STATUS === RequestStatus newStatus = success @@ -1129,7 +985,7 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { } // === HANDLE REFUNDS FOR FAILED CREATE/DEPOSIT === - // COA must return the funds that were transferred in startProcessing + // COA must return the funds that were transferred in startProcessingBatch if ( !success && (request.requestType == RequestType.CREATE_YIELDVAULT || @@ -1176,12 +1032,6 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { _unregisterYieldVault(yieldVaultId, request.user, requestId); } - // === CLEANUP PENDING STATE === - if (userPendingRequestCount[request.user] > 0) { - userPendingRequestCount[request.user]--; - } - _removePendingRequest(requestId); - emit RequestProcessed(requestId, request.user, request.requestType, newStatus, yieldVaultId, message); } @@ -1327,6 +1177,52 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { return requests[requestId]; } + /// @notice Gets a specific request by ID in unpacked format (tuple) + /// @param requestId The request ID to fetch + /// @return id Request id + /// @return user User address + /// @return requestType Request type + /// @return status Request status + /// @return tokenAddress Token address + /// @return amount Amount + /// @return yieldVaultId YieldVault Id + /// @return timestamp Timestamp + /// @return message Status message + /// @return vaultIdentifier Vault identifier + /// @return strategyIdentifier Strategy identifier + function getRequestUnpacked( + uint256 requestId + ) + external + view + returns ( + uint256 id, + address user, + uint8 requestType, + uint8 status, + address tokenAddress, + uint256 amount, + uint64 yieldVaultId, + uint256 timestamp, + string memory message, + string memory vaultIdentifier, + string memory strategyIdentifier + ) + { + Request storage req = requests[requestId]; + id = req.id; + user = req.user; + requestType = uint8(req.requestType); + status = uint8(req.status); + tokenAddress = req.tokenAddress; + amount = req.amount; + yieldVaultId = req.yieldVaultId; + timestamp = req.timestamp; + message = req.message; + vaultIdentifier = req.vaultIdentifier; + strategyIdentifier = req.strategyIdentifier; + } + /// @notice Checks if a YieldVault Id is valid /// @param yieldVaultId YieldVault Id to check /// @return True if valid @@ -1443,6 +1339,181 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { // Internal Functions // ============================================ + /** + * @dev Internal implementation for dropping pending requests. + * Silently skips requests that don't exist or aren't in PENDING status. + * For CREATE/DEPOSIT requests, escrowed funds are moved to claimableRefunds. + * @param requestIds Array of request IDs to drop. Invalid/non-pending IDs are skipped. + */ + function _dropRequestsInternal(uint256[] calldata requestIds) internal { + // Pre-allocate array for tracking successfully dropped request IDs + uint256[] memory droppedIds = new uint256[](requestIds.length); + uint256 droppedCount = 0; + + // Process each request ID in the input array + for (uint256 i = 0; i < requestIds.length; ) { + uint256 requestId = requestIds[i]; + Request storage request = requests[requestId]; + + // Only process valid requests that are still in PENDING status + // This check prevents double-processing and handles invalid IDs gracefully + if ( + request.id == requestId && + request.status == RequestStatus.PENDING + ) { + // Mark request as failed with admin message + request.status = RequestStatus.FAILED; + request.message = "Dropped"; + + // For CREATE/DEPOSIT requests, move funds to claimableRefunds + // User must call claimRefund() to withdraw them (pull pattern) + // WITHDRAW/CLOSE requests don't escrow funds, so nothing to do + if ( + (request.requestType == RequestType.CREATE_YIELDVAULT || + request.requestType == RequestType.DEPOSIT_TO_YIELDVAULT) && + request.amount > 0 + ) { + uint256 newBalance = + pendingUserBalances[request.user][request.tokenAddress] - + request.amount; + pendingUserBalances[request.user][request.tokenAddress] = newBalance; + emit BalanceUpdated( + request.user, + request.tokenAddress, + newBalance + ); + claimableRefunds[request.user][request.tokenAddress] += request.amount; + emit RefundCredited( + request.user, + request.tokenAddress, + request.amount, + requestId + ); + } + + // Update user's pending request count + if (userPendingRequestCount[request.user] > 0) { + userPendingRequestCount[request.user]--; + } + + // Remove from pending queues (both global and user-specific) + _removePendingRequest(requestId); + + emit RequestProcessed( + requestId, + request.user, + request.requestType, + RequestStatus.FAILED, + request.yieldVaultId, + "Dropped" + ); + + // Track this request as successfully dropped + droppedIds[droppedCount] = requestId; + unchecked { + ++droppedCount; + } + } + + unchecked { + ++i; + } + } + + // Emit batch event only if requests were actually dropped + if (droppedCount > 0) { + // Create properly-sized array for the event + uint256[] memory actualDroppedIds = new uint256[](droppedCount); + for (uint256 j = 0; j < droppedCount; ) { + actualDroppedIds[j] = droppedIds[j]; + unchecked { + ++j; + } + } + emit RequestsDropped(actualDroppedIds, msg.sender); + } + } + + /** + * @dev Internal implementation for starting request processing. + * Transitions request to PROCESSING status and handles fund transfers. + * @param requestId The unique identifier of the request to start processing. + */ + function _startProcessingInternal(uint256 requestId) internal { + Request storage request = requests[requestId]; + + // === VALIDATION === + if (request.id != requestId) revert RequestNotFound(); + if (request.status != RequestStatus.PENDING) + revert InvalidRequestState(); + + // === TRANSITION TO PROCESSING === + // This prevents cancellation and ensures atomicity with completeProcessing + request.status = RequestStatus.PROCESSING; + + // === HANDLE FUND TRANSFER FOR CREATE/DEPOSIT === + // WITHDRAW/CLOSE don't have escrowed funds on EVM side + if ( + request.requestType == RequestType.CREATE_YIELDVAULT || + request.requestType == RequestType.DEPOSIT_TO_YIELDVAULT + ) { + // Verify sufficient escrowed balance + uint256 currentBalance = pendingUserBalances[request.user][ + request.tokenAddress + ]; + if (currentBalance < request.amount) { + revert InsufficientBalance( + request.tokenAddress, + request.amount, + currentBalance + ); + } + + // Deduct from user's escrowed balance + pendingUserBalances[request.user][request.tokenAddress] = + currentBalance - + request.amount; + emit BalanceUpdated( + request.user, + request.tokenAddress, + pendingUserBalances[request.user][request.tokenAddress] + ); + + // Transfer escrowed funds to COA for bridging to Cadence + if (isNativeFlow(request.tokenAddress)) { + // Native FLOW: send via low-level call + (bool success, ) = authorizedCOA.call{value: request.amount}(""); + if (!success) revert TransferFailed(); + } else { + // ERC20: use SafeERC20 transfer + IERC20(request.tokenAddress).safeTransfer( + authorizedCOA, + request.amount + ); + } + emit FundsWithdrawn( + authorizedCOA, + request.tokenAddress, + request.amount + ); + } + + // === CLEANUP PENDING STATE === + if (userPendingRequestCount[request.user] > 0) { + userPendingRequestCount[request.user]--; + } + _removePendingRequest(requestId); + + emit RequestProcessed( + requestId, + request.user, + request.requestType, + RequestStatus.PROCESSING, + request.yieldVaultId, + "Processing started" + ); + } + /** * @dev Validates deposit parameters and transfers tokens to this contract for escrow. * Performs comprehensive validation including amount, token support, and minimum balance checks. @@ -1640,7 +1711,7 @@ contract FlowYieldVaultsRequests is ReentrancyGuard, Ownable2Step { * @param requestType The type of request (CREATE, DEPOSIT, WITHDRAW, CLOSE). * @param tokenAddress The token involved in this request. * @param amount The amount of tokens involved (0 for CLOSE requests). - * @param yieldVaultId The YieldVault Id (NO_YIELDVAULT_ID for CREATE until assigned by Cadence). + * @param yieldVaultId The YieldVault Id * @param vaultIdentifier Cadence vault type identifier (only for CREATE requests). * @param strategyIdentifier Cadence strategy type identifier (only for CREATE requests). * @return The newly created request ID. diff --git a/solidity/test/FlowYieldVaultsRequests.t.sol b/solidity/test/FlowYieldVaultsRequests.t.sol index 4cb05d6..96a8976 100644 --- a/solidity/test/FlowYieldVaultsRequests.t.sol +++ b/solidity/test/FlowYieldVaultsRequests.t.sol @@ -48,6 +48,12 @@ contract FlowYieldVaultsRequestsTest is Test { c.testRegisterYieldVaultId(42, user, NATIVE_FLOW); } + function _startProcessingBatch(uint256 requestId) internal { + uint256[] memory successfulRequestIds = new uint256[](1); + successfulRequestIds[0] = requestId; + c.startProcessingBatch(successfulRequestIds, new uint256[](0)); + } + // ============================================ // USER REQUEST LIFECYCLE // ============================================ @@ -79,7 +85,7 @@ contract FlowYieldVaultsRequestsTest is Test { ); vm.startPrank(coa); - c.startProcessing(reqId); + _startProcessingBatch(reqId); vm.expectRevert( FlowYieldVaultsRequests.CannotRegisterSentinelYieldVaultId.selector ); @@ -97,7 +103,7 @@ contract FlowYieldVaultsRequestsTest is Test { uint256 reqId = c.createYieldVault{value: 1 ether}(NATIVE_FLOW, 1 ether, VAULT_ID, STRATEGY_ID); vm.startPrank(coa); - c.startProcessing(reqId); + _startProcessingBatch(reqId); c.completeProcessing(reqId, true, 0, "YieldVault 0 created"); vm.stopPrank(); @@ -224,7 +230,7 @@ contract FlowYieldVaultsRequestsTest is Test { // 2. COA starts processing (moves funds to COA) vm.prank(coa); - c.startProcessing(reqId); + _startProcessingBatch(reqId); assertEq(c.getUserPendingBalance(user, NATIVE_FLOW), 0); // 3. COA fails and returns funds @@ -261,7 +267,7 @@ contract FlowYieldVaultsRequestsTest is Test { // Process and fail vm.prank(coa); - c.startProcessing(reqId); + _startProcessingBatch(reqId); uint64 sentinelYieldVaultId = c.NO_YIELDVAULT_ID(); vm.prank(coa); c.completeProcessing{value: 2 ether}(reqId, false, sentinelYieldVaultId, "Failed"); @@ -280,7 +286,7 @@ contract FlowYieldVaultsRequestsTest is Test { uint256 reqId = c.createYieldVault{value: 1 ether}(NATIVE_FLOW, 1 ether, VAULT_ID, STRATEGY_ID); vm.prank(coa); - c.startProcessing(reqId); + _startProcessingBatch(reqId); uint64 sentinelYieldVaultId = c.NO_YIELDVAULT_ID(); vm.prank(coa); c.completeProcessing{value: 1 ether}(reqId, false, sentinelYieldVaultId, "Failed"); @@ -293,17 +299,17 @@ contract FlowYieldVaultsRequestsTest is Test { } // ============================================ - // COA PROCESSING - startProcessing & completeProcessing + // COA PROCESSING - startProcessingBatch & completeProcessing // ============================================ - function test_StartProcessing_Success() public { + function test_StartProcessingBatch_Success() public { vm.prank(user); uint256 reqId = c.createYieldVault{value: 1 ether}(NATIVE_FLOW, 1 ether, VAULT_ID, STRATEGY_ID); assertEq(c.getUserPendingBalance(user, NATIVE_FLOW), 1 ether); vm.prank(coa); - c.startProcessing(reqId); + _startProcessingBatch(reqId); // Balance deducted atomically assertEq(c.getUserPendingBalance(user, NATIVE_FLOW), 0); @@ -312,25 +318,25 @@ contract FlowYieldVaultsRequestsTest is Test { assertEq(uint8(req.status), uint8(FlowYieldVaultsRequests.RequestStatus.PROCESSING)); } - function test_StartProcessing_RevertNotPending() public { + function test_StartProcessingBatch_RevertNotPending() public { vm.prank(user); uint256 reqId = c.createYieldVault{value: 1 ether}(NATIVE_FLOW, 1 ether, VAULT_ID, STRATEGY_ID); vm.startPrank(coa); - c.startProcessing(reqId); + _startProcessingBatch(reqId); - vm.expectRevert(FlowYieldVaultsRequests.RequestAlreadyFinalized.selector); - c.startProcessing(reqId); + vm.expectRevert(FlowYieldVaultsRequests.InvalidRequestState.selector); + _startProcessingBatch(reqId); vm.stopPrank(); } - function test_StartProcessing_RevertUnauthorized() public { + function test_StartProcessingBatch_RevertUnauthorized() public { vm.prank(user); uint256 reqId = c.createYieldVault{value: 1 ether}(NATIVE_FLOW, 1 ether, VAULT_ID, STRATEGY_ID); vm.prank(user); vm.expectRevert(abi.encodeWithSelector(FlowYieldVaultsRequests.NotAuthorizedCOA.selector, user)); - c.startProcessing(reqId); + _startProcessingBatch(reqId); } function test_CompleteProcessing_Success() public { @@ -338,7 +344,7 @@ contract FlowYieldVaultsRequestsTest is Test { uint256 reqId = c.createYieldVault{value: 1 ether}(NATIVE_FLOW, 1 ether, VAULT_ID, STRATEGY_ID); vm.startPrank(coa); - c.startProcessing(reqId); + _startProcessingBatch(reqId); c.completeProcessing(reqId, true, 100, "YieldVault created"); vm.stopPrank(); @@ -357,7 +363,7 @@ contract FlowYieldVaultsRequestsTest is Test { uint256 reqId = c.createYieldVault{value: 1 ether}(NATIVE_FLOW, 1 ether, VAULT_ID, STRATEGY_ID); vm.startPrank(coa); - c.startProcessing(reqId); + _startProcessingBatch(reqId); // Escrowed balance is now 0 (funds sent to COA) assertEq(c.getUserPendingBalance(user, NATIVE_FLOW), 0); assertEq(c.getClaimableRefund(user, NATIVE_FLOW), 0); @@ -379,7 +385,7 @@ contract FlowYieldVaultsRequestsTest is Test { uint256 reqId = c.depositToYieldVault{value: 1 ether}(42, NATIVE_FLOW, 1 ether); vm.startPrank(coa); - c.startProcessing(reqId); + _startProcessingBatch(reqId); // Escrowed balance is now 0 (funds sent to COA) assertEq(c.getUserPendingBalance(user, NATIVE_FLOW), 0); assertEq(c.getClaimableRefund(user, NATIVE_FLOW), 0); @@ -403,7 +409,7 @@ contract FlowYieldVaultsRequestsTest is Test { uint256 reqId = c.closeYieldVault(42); vm.startPrank(coa); - c.startProcessing(reqId); + _startProcessingBatch(reqId); c.completeProcessing(reqId, true, 42, "Closed"); vm.stopPrank(); @@ -416,7 +422,7 @@ contract FlowYieldVaultsRequestsTest is Test { uint256 reqId = c.createYieldVault{value: 1 ether}(NATIVE_FLOW, 1 ether, VAULT_ID, STRATEGY_ID); vm.prank(coa); - vm.expectRevert(FlowYieldVaultsRequests.RequestAlreadyFinalized.selector); + vm.expectRevert(FlowYieldVaultsRequests.InvalidRequestState.selector); c.completeProcessing(reqId, true, 100, "Should fail"); } @@ -762,7 +768,7 @@ contract FlowYieldVaultsRequestsTest is Test { // 2. COA starts processing (deducts balance atomically) vm.prank(coa); - c.startProcessing(reqId); + _startProcessingBatch(reqId); assertEq(c.getUserPendingBalance(user, NATIVE_FLOW), 0); // 3. COA completes processing (funds are bridged via COA in Cadence) @@ -785,7 +791,7 @@ contract FlowYieldVaultsRequestsTest is Test { // COA processes vm.startPrank(coa); - c.startProcessing(reqId); + _startProcessingBatch(reqId); c.completeProcessing(reqId, true, 42, "Withdrawn"); vm.stopPrank(); @@ -846,7 +852,7 @@ contract FlowYieldVaultsRequestsTest is Test { // Process middle request (req3) vm.startPrank(coa); - c.startProcessing(req3); + _startProcessingBatch(req3); c.completeProcessing(req3, true, 200, "Created"); vm.stopPrank(); @@ -868,7 +874,7 @@ contract FlowYieldVaultsRequestsTest is Test { // Remove first element vm.startPrank(coa); - c.startProcessing(req1); + _startProcessingBatch(req1); c.completeProcessing(req1, true, 100, "Created"); vm.stopPrank(); @@ -887,7 +893,7 @@ contract FlowYieldVaultsRequestsTest is Test { // Remove last element vm.startPrank(coa); - c.startProcessing(req3); + _startProcessingBatch(req3); c.completeProcessing(req3, true, 100, "Created"); vm.stopPrank(); @@ -906,13 +912,13 @@ contract FlowYieldVaultsRequestsTest is Test { // Process in FIFO order vm.startPrank(coa); - c.startProcessing(req1); + _startProcessingBatch(req1); c.completeProcessing(req1, true, 100, "Created"); - c.startProcessing(req2); + _startProcessingBatch(req2); c.completeProcessing(req2, true, 101, "Created"); - c.startProcessing(req3); + _startProcessingBatch(req3); c.completeProcessing(req3, true, 102, "Created"); vm.stopPrank(); @@ -929,7 +935,7 @@ contract FlowYieldVaultsRequestsTest is Test { // Process out of order: req2, req4, req1, req3 vm.startPrank(coa); - c.startProcessing(req2); + _startProcessingBatch(req2); c.completeProcessing(req2, true, 100, "Created"); // After removing req2: [req1, req3, req4] @@ -938,7 +944,7 @@ contract FlowYieldVaultsRequestsTest is Test { assertEq(ids1[1], req3); assertEq(ids1[2], req4); - c.startProcessing(req4); + _startProcessingBatch(req4); c.completeProcessing(req4, true, 101, "Created"); // After removing req4: [req1, req3] @@ -946,7 +952,7 @@ contract FlowYieldVaultsRequestsTest is Test { assertEq(ids2[0], req1); assertEq(ids2[1], req3); - c.startProcessing(req1); + _startProcessingBatch(req1); c.completeProcessing(req1, true, 102, "Created"); // After removing req1: [req3] @@ -954,7 +960,7 @@ contract FlowYieldVaultsRequestsTest is Test { assertEq(ids3.length, 1); assertEq(ids3[0], req3); - c.startProcessing(req3); + _startProcessingBatch(req3); c.completeProcessing(req3, true, 103, "Created"); vm.stopPrank(); @@ -1034,7 +1040,7 @@ contract FlowYieldVaultsRequestsTest is Test { // Process req2 vm.startPrank(coa); - c.startProcessing(req2); + _startProcessingBatch(req2); c.completeProcessing(req2, true, 100, "Created"); vm.stopPrank(); @@ -1092,7 +1098,7 @@ contract FlowYieldVaultsRequestsTest is Test { // Remove user's middle request (u1r2) vm.startPrank(coa); - c.startProcessing(u1r2); + _startProcessingBatch(u1r2); c.completeProcessing(u1r2, true, 100, "Created"); vm.stopPrank(); @@ -1118,9 +1124,9 @@ contract FlowYieldVaultsRequestsTest is Test { vm.stopPrank(); vm.startPrank(coa); - c.startProcessing(req1); + _startProcessingBatch(req1); c.completeProcessing(req1, true, 100, "Created"); - c.startProcessing(req2); + _startProcessingBatch(req2); c.completeProcessing(req2, true, 101, "Created"); vm.stopPrank(); @@ -1139,7 +1145,7 @@ contract FlowYieldVaultsRequestsTest is Test { uint256 reqId = c.createYieldVault{value: 1 ether}(NATIVE_FLOW, 1 ether, VAULT_ID, STRATEGY_ID); vm.startPrank(coa); - c.startProcessing(reqId); + _startProcessingBatch(reqId); c.completeProcessing(reqId, true, 200, "Created"); vm.stopPrank(); @@ -1153,7 +1159,7 @@ contract FlowYieldVaultsRequestsTest is Test { uint256 closeReqId = c.closeYieldVault(200); vm.startPrank(coa); - c.startProcessing(closeReqId); + _startProcessingBatch(closeReqId); c.completeProcessing(closeReqId, true, 200, "Closed"); vm.stopPrank(); @@ -1173,11 +1179,11 @@ contract FlowYieldVaultsRequestsTest is Test { vm.stopPrank(); vm.startPrank(coa); - c.startProcessing(req1); + _startProcessingBatch(req1); c.completeProcessing(req1, true, 100, "Created"); - c.startProcessing(req2); + _startProcessingBatch(req2); c.completeProcessing(req2, true, 101, "Created"); - c.startProcessing(req3); + _startProcessingBatch(req3); c.completeProcessing(req3, true, 102, "Created"); vm.stopPrank(); @@ -1190,7 +1196,7 @@ contract FlowYieldVaultsRequestsTest is Test { uint256 closeReq = c.closeYieldVault(101); vm.startPrank(coa); - c.startProcessing(closeReq); + _startProcessingBatch(closeReq); c.completeProcessing(closeReq, true, 101, "Closed"); vm.stopPrank(); @@ -1211,7 +1217,7 @@ contract FlowYieldVaultsRequestsTest is Test { uint256 closeReq = c.closeYieldVault(42); vm.startPrank(coa); - c.startProcessing(closeReq); + _startProcessingBatch(closeReq); c.completeProcessing(closeReq, true, 42, "Closed"); vm.stopPrank(); @@ -1244,7 +1250,7 @@ contract FlowYieldVaultsRequestsTest is Test { // Process every other request (simulating out-of-order processing) vm.startPrank(coa); for (uint256 i = 1; i < numRequests; i += 2) { - c.startProcessing(requestIds[i]); + _startProcessingBatch(requestIds[i]); c.completeProcessing(requestIds[i], true, uint64(100 + i), "Created"); } vm.stopPrank(); @@ -1282,7 +1288,7 @@ contract FlowYieldVaultsRequestsTest is Test { // Process user[2]'s middle request vm.startPrank(coa); - c.startProcessing(userRequestIds[2][1]); + _startProcessingBatch(userRequestIds[2][1]); c.completeProcessing(userRequestIds[2][1], true, 300, "Created"); vm.stopPrank(); @@ -1306,7 +1312,7 @@ contract FlowYieldVaultsRequestsTest is Test { uint256 reqId = c.createYieldVault{value: 1 ether}(NATIVE_FLOW, 1 ether, VAULT_ID, STRATEGY_ID); vm.startPrank(coa); - c.startProcessing(reqId); + _startProcessingBatch(reqId); c.completeProcessing(reqId, true, 100, "Created"); vm.stopPrank(); @@ -1324,7 +1330,7 @@ contract FlowYieldVaultsRequestsTest is Test { // Start and fail processing req1 vm.startPrank(coa); - c.startProcessing(req1); + _startProcessingBatch(req1); // COA must return funds when completing with failure c.completeProcessing{value: 1 ether}(req1, false, c.NO_YIELDVAULT_ID(), "Failed"); vm.stopPrank(); @@ -1366,12 +1372,12 @@ contract FlowYieldVaultsRequestsTest is Test { uint256 reqId = c.createYieldVault{value: 1 ether}(NATIVE_FLOW, 1 ether, VAULT_ID, STRATEGY_ID); vm.startPrank(coa); - c.startProcessing(reqId); + _startProcessingBatch(reqId); c.completeProcessing(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); - c.startProcessing(reqId2); + _startProcessingBatch(reqId2); vm.expectRevert(abi.encodeWithSelector( FlowYieldVaultsRequests.YieldVaultIdAlreadyRegistered.selector, 100 @@ -1386,7 +1392,7 @@ contract FlowYieldVaultsRequestsTest is Test { uint256 reqId = c.createYieldVault{value: 1 ether}(NATIVE_FLOW, 1 ether, VAULT_ID, STRATEGY_ID); vm.startPrank(coa); - c.startProcessing(reqId); + _startProcessingBatch(reqId); c.completeProcessing(reqId, true, 100, "Created"); vm.stopPrank(); @@ -1395,7 +1401,7 @@ contract FlowYieldVaultsRequestsTest is Test { uint256 depositReq = c.depositToYieldVault{value: 1 ether}(100, NATIVE_FLOW, 1 ether); vm.startPrank(coa); - c.startProcessing(depositReq); + _startProcessingBatch(depositReq); vm.expectRevert(abi.encodeWithSelector( FlowYieldVaultsRequests.YieldVaultIdMismatch.selector, 100, // expected @@ -1411,7 +1417,7 @@ contract FlowYieldVaultsRequestsTest is Test { uint256 reqId = c.createYieldVault{value: 1 ether}(NATIVE_FLOW, 1 ether, VAULT_ID, STRATEGY_ID); vm.startPrank(coa); - c.startProcessing(reqId); + _startProcessingBatch(reqId); c.completeProcessing(reqId, true, 100, "Created"); vm.stopPrank();