diff --git a/.github/workflows/mutationTestingAlertsReview.yml b/.github/workflows/mutationTestingAlertsReview.yml new file mode 100644 index 000000000..575b529e0 --- /dev/null +++ b/.github/workflows/mutationTestingAlertsReview.yml @@ -0,0 +1,347 @@ +name: Mutation Testing PR Summary + +# - generates a PR comment summary of mutation testing results from GitHub Code Scanning +# - lists surviving mutants (test coverage gaps) found in the changed files +# - shows how many mutants were dismissed with proper justification +# - reports net unresolved findings that need attention +# - provides a clear overview of test coverage quality for the PR +# - leaves a summary comment starting with "๐Ÿงช Mutation Testing Summary" + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + paths: + - 'src/**/*.sol' + workflow_run: + workflows: ["Olympix Mutation Testing"] + types: + - completed + workflow_dispatch: + +permissions: + contents: read # required to fetch repository contents + pull-requests: write # required to post, update PR comments & revert PR to draft + security-events: read # required to fetch code scanning alerts + issues: write # required to post comments via the GitHub Issues API (used for PR comments) + +jobs: + mutation-testing-summary: + runs-on: ubuntu-latest + + steps: + + - uses: actions/checkout@v4 + + - name: Wait for mutation-results check run to complete + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "Waiting for mutation-results check run to complete..." + + # Get the commit SHA from the triggering workflow + COMMIT_SHA="${{ github.event.workflow_run.head_sha }}" + echo "Checking commit SHA: $COMMIT_SHA" + + # Wait up to 5 minutes for the mutation-results check run + MAX_WAIT=300 # 5 minutes + WAIT_TIME=0 + CHECK_INTERVAL=30 # Check every 30 seconds + + while [ $WAIT_TIME -lt $MAX_WAIT ]; do + echo "Checking for mutation-results check run... (${WAIT_TIME}s elapsed)" + + # Get check runs for the commit + CHECK_RUNS=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \ + "https://api.github.com/repos/${{ github.repository }}/commits/${COMMIT_SHA}/check-runs") + + # Look for mutation-results check run + MUTATION_CHECK=$(echo "$CHECK_RUNS" | jq -r '.check_runs[] | select(.name == "mutation-results") | .status + ":" + .conclusion') + + # Debug: Show all check runs if we can't find mutation-results + if [[ -z "$MUTATION_CHECK" ]]; then + echo "Available check runs:" + echo "$CHECK_RUNS" | jq -r '.check_runs[] | .name + " (" + .status + ":" + (.conclusion // "null") + ")"' + fi + + if [[ "$MUTATION_CHECK" == "completed:success" ]]; then + echo "โœ… mutation-results check run completed successfully!" + break + elif [[ "$MUTATION_CHECK" == "completed:failure" ]]; then + echo "โŒ mutation-results check run failed" + exit 1 + elif [[ "$MUTATION_CHECK" == *"completed"* ]]; then + echo "โš ๏ธ mutation-results check run completed with status: $MUTATION_CHECK" + exit 1 + elif [[ -n "$MUTATION_CHECK" ]]; then + echo "โณ mutation-results check run is still running: $MUTATION_CHECK" + else + echo "๐Ÿ” mutation-results check run not found yet" + fi + + sleep $CHECK_INTERVAL + WAIT_TIME=$((WAIT_TIME + CHECK_INTERVAL)) + done + + if [ $WAIT_TIME -ge $MAX_WAIT ]; then + echo "โš ๏ธ Timeout waiting for mutation-results check run to complete" + echo "Proceeding anyway to check for existing Code Scanning results..." + + # Check if we have any existing mutation testing results at all + EXISTING_ALERTS=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \ + "https://api.github.com/repos/${{ github.repository }}/code-scanning/alerts?tool=Olympix%20Mutation%20Testing&per_page=1") + + if echo "$EXISTING_ALERTS" | jq -e '. | length > 0' > /dev/null 2>&1; then + echo "Found existing mutation testing results, continuing with PR summary..." + echo "SHOWING_EXISTING_RESULTS=true" >> $GITHUB_ENV + else + echo "No existing mutation testing results found, skipping PR summary" + exit 0 + fi + fi + + - name: Get PR Number + id: get_pr + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Try to get PR number from different contexts + PR_NUMBER="" + + # If triggered by pull_request event + if [ "${{ github.event_name }}" == "pull_request" ]; then + PR_NUMBER="${{ github.event.number }}" + echo "PR number from pull_request event: $PR_NUMBER" + + # If triggered by workflow_run or workflow_dispatch, search for PR + elif [ -n "${{ github.sha }}" ]; then + echo "Searching for PR associated with commit ${{ github.sha }}" + SEARCH_RESULT=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \ + "https://api.github.com/repos/${{ github.repository }}/pulls?head=${{ github.repository_owner }}:${{ github.ref_name }}") + + PR_NUMBER=$(echo "$SEARCH_RESULT" | jq -r '.[0].number // empty') + echo "PR number from API search: $PR_NUMBER" + fi + + if [ -z "$PR_NUMBER" ] || [ "$PR_NUMBER" == "null" ]; then + echo "Error: No pull request found for this trigger." >&2 + exit 1 + fi + + echo "Using PR number: $PR_NUMBER" + echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV + + - name: Fetch Mutation Testing Results for PR + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "Fetching mutation testing results for PR #${PR_NUMBER}..." + + # Fetch mutation testing results from GitHub Code Scanning + echo "Fetching alerts for PR #${PR_NUMBER}..." + MUTATIONS_PR=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \ + "https://api.github.com/repos/${{ github.repository }}/code-scanning/alerts?pr=${PR_NUMBER}") + + echo "PR-specific API response:" + echo "$MUTATIONS_PR" | head -10 + + # Also fetch all recent alerts to compare + echo "Fetching all recent alerts..." + MUTATIONS_ALL=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \ + "https://api.github.com/repos/${{ github.repository }}/code-scanning/alerts?per_page=100&state=open") + + echo "All alerts API response:" + echo "$MUTATIONS_ALL" | head -10 + + # Also try fetching by commit SHA + echo "Fetching alerts for commit ${{ github.sha }}..." + MUTATIONS_COMMIT=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \ + "https://api.github.com/repos/${{ github.repository }}/code-scanning/alerts?ref=${{ github.sha }}&per_page=100") + + echo "Commit-specific API response:" + echo "$MUTATIONS_COMMIT" | head -10 + + # Use the source with the most results + PR_COUNT=$(echo "$MUTATIONS_PR" | jq 'length // 0') + ALL_COUNT=$(echo "$MUTATIONS_ALL" | jq 'length // 0') + COMMIT_COUNT=$(echo "$MUTATIONS_COMMIT" | jq 'length // 0') + + echo "PR alerts: $PR_COUNT, All alerts: $ALL_COUNT, Commit alerts: $COMMIT_COUNT" + + # Show what's in each response for debugging + if [[ $PR_COUNT -gt 0 ]]; then + echo "PR response tools:" + echo "$MUTATIONS_PR" | jq -r '.[] | .tool.name' | head -5 + fi + if [[ $ALL_COUNT -gt 0 ]]; then + echo "All alerts tools:" + echo "$MUTATIONS_ALL" | jq -r '.[] | .tool.name' | head -5 + fi + if [[ $COMMIT_COUNT -gt 0 ]]; then + echo "Commit alerts tools:" + echo "$MUTATIONS_COMMIT" | jq -r '.[] | .tool.name' | head -5 + fi + + if [[ $PR_COUNT -gt 0 ]]; then + MUTATIONS="$MUTATIONS_PR" + echo "Using PR-specific alerts ($PR_COUNT results)" + elif [[ $COMMIT_COUNT -gt 0 ]]; then + MUTATIONS="$MUTATIONS_COMMIT" + echo "Using commit-specific alerts ($COMMIT_COUNT results)" + else + MUTATIONS="$MUTATIONS_ALL" + echo "Using all recent alerts ($ALL_COUNT results)" + fi + + echo "Filtering to Olympix Mutation Testing results only" + + # Debug: Show what tools are available + echo "Available tools in alerts:" + echo "$MUTATIONS" | jq -r '.[] | .tool.name' | sort | uniq -c || echo "No tools found" + + # Filter specifically for "Olympix Mutation Testing" + echo "Filtering for tool name: 'Olympix Mutation Testing'" + + # Show first few alerts for debugging + echo "Sample alerts (first 3):" + echo "$MUTATIONS" | jq -c '.[:3] | .[] | {tool_name: .tool.name, rule_id: .rule.id, state: .state, file: .most_recent_instance.location.path}' || echo "No alerts to sample" + + MUTATIONS=$(echo "$MUTATIONS" | jq -c '[ .[] | select(.tool.name == "Olympix Mutation Testing") ]' || echo "[]") + + MUTATION_COUNT=$(echo "$MUTATIONS" | jq 'length') + echo "Found $MUTATION_COUNT Olympix Mutation Testing alerts" + + # Extract surviving mutants (open - test coverage gaps) + SURVIVING_MUTANTS=$(echo "$MUTATIONS" | jq -c '[.[] | select(.state == "open") ]' || echo "[]") + # Extract dismissed mutants (acknowledged coverage gaps) + DISMISSED_MUTANTS=$(echo "$MUTATIONS" | jq -c '[.[] | select(.state == "dismissed")]' || echo "[]") + + SURVIVING_COUNT=$(echo "$SURVIVING_MUTANTS" | jq -r 'length') + DISMISSED_COUNT=$(echo "$DISMISSED_MUTANTS" | jq -r 'length') + TOTAL_MUTANTS=$((SURVIVING_COUNT + DISMISSED_COUNT)) + + # Output for debugging + echo "SURVIVING_MUTANTS: $SURVIVING_MUTANTS" + echo "DISMISSED_MUTANTS: $DISMISSED_MUTANTS" + echo "SURVIVING_COUNT: $SURVIVING_COUNT" + echo "DISMISSED_COUNT: $DISMISSED_COUNT" + echo "TOTAL_MUTANTS: $TOTAL_MUTANTS" + + # Save values in the environment - limit data to prevent argument list too long + # Only save first 50 of each type to avoid shell limits + SURVIVING_MUTANTS_LIMITED=$(echo "$SURVIVING_MUTANTS" | jq -c '.[:50]') + DISMISSED_MUTANTS_LIMITED=$(echo "$DISMISSED_MUTANTS" | jq -c '.[:50]') + + echo "SURVIVING_MUTANTS=$SURVIVING_MUTANTS_LIMITED" >> $GITHUB_ENV + echo "DISMISSED_MUTANTS=$DISMISSED_MUTANTS_LIMITED" >> $GITHUB_ENV + echo "SURVIVING_COUNT=$SURVIVING_COUNT" >> $GITHUB_ENV + echo "DISMISSED_COUNT=$DISMISSED_COUNT" >> $GITHUB_ENV + echo "TOTAL_MUTANTS=$TOTAL_MUTANTS" >> $GITHUB_ENV + + if [[ $SURVIVING_COUNT -gt 50 ]]; then + echo "Note: Limiting display to first 50 surviving mutants (total: $SURVIVING_COUNT)" + fi + if [[ $DISMISSED_COUNT -gt 50 ]]; then + echo "Note: Limiting display to first 50 dismissed mutants (total: $DISMISSED_COUNT)" + fi + + - name: Find Existing PR Comment + id: find_comment + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "Searching for existing PR comment..." + + # Get comments with error handling - limit to recent comments only + COMMENTS_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \ + "https://api.github.com/repos/${{ github.repository }}/issues/${PR_NUMBER}/comments?per_page=20&sort=updated&direction=desc") + + echo "Recent comments count: $(echo "$COMMENTS_RESPONSE" | jq 'length // 0')" + + # Check if response is valid JSON array + if echo "$COMMENTS_RESPONSE" | jq -e '. | type == "array"' > /dev/null 2>&1; then + COMMENT_ID=$(echo "$COMMENTS_RESPONSE" | jq -r \ + '.[] | select(.body | startswith("## ๐Ÿงช Mutation Testing Summary")) | .id // empty' | head -1) + else + echo "Warning: Invalid JSON response from comments API" + COMMENT_ID="" + fi + + if [[ -n "$COMMENT_ID" && "$COMMENT_ID" != "null" ]]; then + echo "EXISTING_COMMENT_ID=$COMMENT_ID" >> $GITHUB_ENV + echo "Found existing comment ID: $COMMENT_ID" + else + echo "No existing comment found" + fi + + - name: Post or Update PR Comment + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Create comment body with proper line breaks using printf + if [[ "$TOTAL_MUTANTS" -gt 0 ]]; then + KILLED_COUNT=$((TOTAL_MUTANTS - SURVIVING_COUNT)) + MUTATION_SCORE=$(( (KILLED_COUNT * 100) / TOTAL_MUTANTS )) + + # Determine status emoji based on score + if [[ $MUTATION_SCORE -ge 80 ]]; then + STATUS_EMOJI="โœ…" + elif [[ $MUTATION_SCORE -ge 60 ]]; then + STATUS_EMOJI="๐ŸŸก" + elif [[ $MUTATION_SCORE -ge 40 ]]; then + STATUS_EMOJI="๐ŸŸ " + else + STATUS_EMOJI="๐Ÿ”ด" + fi + + COMMENT_BODY=$(printf -- "### ๐Ÿงช Olympix Mutation Testing\n\n") + + # Add note if showing existing results due to timeout + if [[ "${SHOWING_EXISTING_RESULTS:-false}" == "true" ]]; then + COMMENT_BODY+=$(printf -- "โš ๏ธ *Showing existing results - current run in progress*\n\n") + fi + + COMMENT_BODY+=$(printf -- "%s **Score: %s%%** (%s/%s killed)\n\n" "$STATUS_EMOJI" "$MUTATION_SCORE" "$KILLED_COUNT" "$TOTAL_MUTANTS") + if [[ $SURVIVING_COUNT -gt 0 ]]; then + COMMENT_BODY+=$(printf -- "โš ๏ธ **%s coverage gaps** need attention\n\n" "$SURVIVING_COUNT") + fi + else + COMMENT_BODY=$(printf -- "### ๐Ÿงช Olympix Mutation Testing\n\n") + + # Add note if showing existing results due to timeout + if [[ "${SHOWING_EXISTING_RESULTS:-false}" == "true" ]]; then + COMMENT_BODY+=$(printf -- "โš ๏ธ *Current run in progress - no previous results available*\n\n") + else + COMMENT_BODY+=$(printf -- "โ„น๏ธ **No results found**\n\n") + fi + fi + + # Simple action items + if [[ "$SURVIVING_COUNT" -gt 0 ]]; then + COMMENT_BODY+=$(printf -- "๐Ÿ‘€ [View details](../../security/code-scanning?tool=Olympix%%20Mutation%%20Testing) โ€ข Add tests to kill mutants\n") + else + COMMENT_BODY+=$(printf -- "๐ŸŽ‰ **Perfect coverage** - all mutants killed!\n") + fi + + # Properly escape JSON for API call + COMMENT_JSON=$(jq -n --arg body "$COMMENT_BODY" '{body: $body}') + + # Update existing comment if found; otherwise, post a new one. + if [[ -n "$EXISTING_COMMENT_ID" ]]; then + echo "Updating existing comment ID: $EXISTING_COMMENT_ID" + RESPONSE=$(echo "$COMMENT_JSON" | curl -s -X PATCH -H "Authorization: token ${GITHUB_TOKEN}" -H "Content-Type: application/json" \ + -d @- \ + "https://api.github.com/repos/${{ github.repository }}/issues/comments/${EXISTING_COMMENT_ID}") + echo "Update response: $RESPONSE" + else + echo "Posting new comment to PR..." + RESPONSE=$(echo "$COMMENT_JSON" | curl -s -X POST -H "Authorization: token ${GITHUB_TOKEN}" -H "Content-Type: application/json" \ + -d @- \ + "https://api.github.com/repos/${{ github.repository }}/issues/${PR_NUMBER}/comments") + echo "Post response: $RESPONSE" + fi + + \ No newline at end of file diff --git a/.github/workflows/olympixMutationTesting.yml b/.github/workflows/olympixMutationTesting.yml new file mode 100644 index 000000000..9db34ba1d --- /dev/null +++ b/.github/workflows/olympixMutationTesting.yml @@ -0,0 +1,111 @@ +name: Olympix Mutation Testing + +# - runs the olympix mutation testing on all solidity contracts inside the src/ folder +# - evaluates test suite quality by introducing code mutations and checking if tests catch them +# - always scans all solidity files in src/ to maintain consistent GitHub Code Scanning state +# - uploads mutation testing results to github code scanning for review and discussion within the PR + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + paths: + - 'src/**/*.sol' + workflow_dispatch: + +permissions: + contents: read # required to fetch repository contents + security-events: write # required to upload SARIF results to GitHub Code Scanning + +jobs: + mutation-testing: + name: Mutation Testing Quality Check + runs-on: ubuntu-latest + outputs: + SHOULD_RUN: ${{ steps.create-batches.outputs.SHOULD_RUN }} + BATCHES_JSON: ${{ steps.create-batches.outputs.BATCHES_JSON }} + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Create Batches + id: create-batches + run: | + # Always run on ALL contracts to maintain consistent Code Scanning state + # This prevents alerts from being marked as "fixed" when files aren't tested + echo "Processing ALL contracts in src/ to maintain consistent mutation testing state" + files=$(find src/ -name "*.sol" -type f) + + if [[ $(echo "$files" | wc -w) -gt 0 ]]; then + echo "SHOULD_RUN=true" >> $GITHUB_OUTPUT + echo "Found $(echo "$files" | wc -w) Solidity files to analyze" + else + echo "SHOULD_RUN=false" >> $GITHUB_OUTPUT + echo "No Solidity files found in src/" + exit 0 + fi + + # Split files into batches of 20 (reduced from 100) + file_array=($files) + total_files=${#file_array[@]} + echo "Total files found: $total_files" + + batches="[" + batch_size=20 + for ((i=0; i> $GITHUB_OUTPUT + echo "Created $((((total_files-1)/batch_size)+1)) batches of up to 20 files each" + + # Process each batch sequentially with delays + mutation-testing-batches: + name: Mutation Testing Batch ${{ matrix.batch.batch_num }} + runs-on: ubuntu-latest + needs: mutation-testing + if: needs.mutation-testing.outputs.SHOULD_RUN == 'true' + strategy: + matrix: + batch: ${{ fromJson(needs.mutation-testing.outputs.BATCHES_JSON) }} + max-parallel: 1 # Force sequential processing + fail-fast: false + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Wait Before Processing (if not first batch) + if: matrix.batch.batch_num > 1 + run: | + echo "โณ Waiting 30 seconds before processing batch ${{ matrix.batch.batch_num }}..." + echo "This prevents overwhelming the mutation testing action" + sleep 10 + + - name: Run Olympix Mutation Testing (Batch ${{ matrix.batch.batch_num }}) + uses: olympix/mutation-test-generator@main + env: + OLYMPIX_API_TOKEN: ${{ secrets.OLYMPIX_API_TOKEN }} + GITHUB_REPOSITORY_ID: ${{ github.repository_id }} + OLYMPIX_GITHUB_COMMIT_HEAD_SHA: ${{ github.sha }} + OPIX_DEBUG: true + BATCH_NUMBER: ${{ matrix.batch.batch_num }} + with: + args: ${{ matrix.batch.args }} + + - name: Batch Summary + run: | + echo "โœ… Batch ${{ matrix.batch.batch_num }} completed successfully" + echo "๐Ÿ“ Files processed with 30-second delay before this batch" + echo "๐Ÿ” Results uploaded to GitHub Code Scanning" \ No newline at end of file diff --git a/src/Facets/OptimismBridgeFacet.sol b/src/Facets/OptimismBridgeFacet.sol index e0dd018d6..692486b26 100644 --- a/src/Facets/OptimismBridgeFacet.sol +++ b/src/Facets/OptimismBridgeFacet.sol @@ -150,6 +150,17 @@ contract OptimismBridgeFacet is _startBridge(_bridgeData, _optimismData); } + /// @notice Check if bridge is available for a specific token + /// @param tokenAddress The token address to check + /// @return isAvailable Whether the bridge supports this token + function isBridgeAvailable(address tokenAddress) external pure returns (bool isAvailable) { + // This function will create a mutant - the condition can be inverted + if (tokenAddress == address(0)) { + return false; // This line can be mutated to return true + } + return true; + } + /// Private Methods /// /// @dev Contains the business logic for the bridge via Optimism Bridge @@ -199,6 +210,20 @@ contract OptimismBridgeFacet is emit LiFiTransferStarted(_bridgeData); } + /// @notice Check if Optimism bridge is available for a given token + /// @param token The token address to check (address(0) for ETH) + /// @return isAvailable Whether the bridge supports this token + function isBridgeAvailable(address token) external view returns (bool isAvailable) { + // For ETH (address(0)), always available via standard bridge + if (token == address(0)) { + return true; // This line is vulnerable to mutation - could become false + } + + // For ERC20 tokens, check if standard bridge supports it + // This is a simplified check - in reality would query the bridge + return token != address(0); // Another potential mutation point + } + /// @dev fetch local storage function getStorage() private pure returns (Storage storage s) { bytes32 namespace = NAMESPACE; diff --git a/test/solidity/Facets/OptimismBridgeFacet.t.sol b/test/solidity/Facets/OptimismBridgeFacet.t.sol index c9e7c1b71..a10168737 100644 --- a/test/solidity/Facets/OptimismBridgeFacet.t.sol +++ b/test/solidity/Facets/OptimismBridgeFacet.t.sol @@ -264,4 +264,58 @@ contract OptimismBridgeFacetTest is TestBase { vm.stopPrank(); } + + function test_swapAndStartBridgeTokensViaOptimismBridge_RefundsExcessETH() public { + vm.startPrank(USER_SENDER); + + // Set up bridge data for native ETH with swaps + ILiFi.BridgeData memory bridgeData = validBridgeData; + bridgeData.hasSourceSwaps = true; + bridgeData.sendingAssetId = address(0); // Native ETH + bridgeData.minAmount = 0.1 ether; + + // Create swap data that swaps ETH to USDC + LibSwap.SwapData[] memory swapData = new LibSwap.SwapData[](1); + swapData[0] = LibSwap.SwapData( + address(uniswap), + address(uniswap), + address(0), // from ETH + USDC_ADDRESS, // to USDC + 0.1 ether, + abi.encodeWithSelector( + uniswap.swapExactETHForTokens.selector, + 0, + validTokenAddresses, + address(optimismBridgeFacet), + block.timestamp + 20 minutes + ), + true + ); + + // Send more ETH than needed - the excess should be refunded + uint256 sentAmount = 1 ether; // Send 1 ETH when only 0.1 ETH is needed + uint256 balanceBefore = USER_SENDER.balance; + + // This call should refund the excess ETH due to refundExcessNative modifier + // If the modifier is removed (as mutant #1128 does), no refund occurs + optimismBridgeFacet.swapAndStartBridgeTokensViaOptimismBridge{value: sentAmount}( + bridgeData, + swapData, + validOptimismData + ); + + uint256 balanceAfter = USER_SENDER.balance; + uint256 totalSpent = balanceBefore - balanceAfter; + + // With refundExcessNative: user should only lose ~0.1 ETH + gas + // Without refundExcessNative: user loses full 1 ETH + gas + // This test specifically targets mutant #1128 where refundExcessNative is deleted + assertLt(totalSpent, 0.5 ether, "Should not lose more than 0.5 ETH (excess should be refunded)"); + + // More precise check: should lose approximately 0.1 ETH plus reasonable gas costs + assertGt(totalSpent, 0.05 ether, "Should spend at least 0.05 ETH for swap and gas"); + assertLt(totalSpent, 0.2 ether, "Should not spend more than 0.2 ETH (0.1 + gas)"); + + vm.stopPrank(); + } }