diff --git a/.claude/skills/qabot-fixture/SKILL.md b/.claude/skills/qabot-fixture/SKILL.md new file mode 100644 index 00000000000..7982c67aeac --- /dev/null +++ b/.claude/skills/qabot-fixture/SKILL.md @@ -0,0 +1,81 @@ +--- +name: qabot-fixture +description: Create a new qabot E2E test fixture interactively. Guides the user through defining test steps, expected outcomes, and saves the YAML fixture file. Use when user says "create fixture", "new test", "qabot fixture", or "/qabot-fixture". +allowed-tools: Bash, Read, Write, Edit, Glob, Grep, AskUserQuestion +--- + +# qabot Fixture Builder + +You are helping the user create a new E2E test fixture for qabot. Fixtures are YAML files that define test steps for agent-browser to execute. + +## Process + +### 1. Gather context + +Ask the user what they want to test. If they give a vague answer like "test the earn page", ask clarifying questions: +- What specific functionality? (page loads, specific interaction, full flow) +- What route? (e.g., /earn, /trade, /dashboard) - note: the base URL is a run-level arg, not per-fixture +- Does it need a connected wallet? (most tests do - use `depends_on: wallet-health.yaml`) + +### 2. Read existing fixtures for reference + +```bash +ls e2e/fixtures/*.yaml +``` + +Read them to understand the established patterns before building a new one. + +### 3. Build steps interactively + +For each step, ask the user: +- What should the agent do? (this becomes the `instruction`) +- What should be true after? (this becomes the `expected`) + +Write instructions in natural language - agent-browser interprets them. No CSS selectors needed. + +### 4. Name, describe, and save + +Ask for: +- A fixture name (kebab-case for the filename, human-readable for the `name` field) +- A short description of what this fixture tests (one line) + +Save to `e2e/fixtures/.yaml`. + +## Fixture YAML Format + +```yaml +name: Human Readable Name +description: One-line description of what this fixture tests +route: / +depends_on: + - wallet-health.yaml # runs BEFORE main steps (wallet unlock, onboarding dismiss) +post_depends_on: + - evm-chains-regression.yaml # runs AFTER main steps (optional) +steps: + - name: Step name + instruction: > + Natural language instruction for agent-browser. + Be specific about what to click, fill, or verify. + Reference UI elements by their visible text, not selectors. + expected: What should be true after this step completes + screenshot: true +``` + +### Composability + +Fixtures can declare dependencies that run before or after the main steps: + +- **`depends_on`** - fixtures that run BEFORE the main steps. Most fixtures should include `wallet-health.yaml` which handles onboarding dismiss + wallet unlock. +- **`post_depends_on`** - fixtures that run AFTER all main steps (e.g. regression suites, cleanup). +- Dependencies are resolved recursively and deduplicated (each fixture runs at most once). +- All fixtures run in one browser session - page state carries over. +- Step indices are continuous across all fixtures. + +## Guidelines + +- Instructions should be specific enough for an AI agent to follow +- Reference UI elements by visible text (e.g., "click the ETH selector", not "click #asset-select") +- For inputs that use React controlled components, note that `press` (char by char) works but `fill` may not trigger onChange +- Every step should have `screenshot: true` so test runs capture visual evidence +- Keep steps atomic - one action per step where possible +- The `expected` field is what the agent checks in the accessibility snapshot to determine pass/fail diff --git a/.claude/skills/qabot/SKILL.md b/.claude/skills/qabot/SKILL.md new file mode 100644 index 00000000000..694255b762c --- /dev/null +++ b/.claude/skills/qabot/SKILL.md @@ -0,0 +1,453 @@ +--- +name: qabot +description: Run QA tests using agent-browser and post results to the qabot dashboard. Interactive mode helps craft fixtures. With a fixture provided (or for automated runs like clawdbot releases), executes tests and reports results. Use when user says "qa test", "run qabot", "/qabot", or when running automated QA. +allowed-tools: Bash, Read, Write, Edit, Glob, Grep, WebFetch, AskUserQuestion +--- + +# qabot - QA Testing Skill + +You are running QA tests and reporting results to the qabot dashboard. qabot is a platform for QA reports - operators (humans with Claude, or clawdbot for automated runs) authenticate via a shared API key and push test results. + +## Environment + +Secrets are stored in `~/.secrets` (sourced by `~/.zshrc`). Required env vars: + +- `QABOT_API_KEY` - shared API key for write access to qabot +- `QABOT_OPERATOR` - your operator name (e.g. "gomes", "clawdbot") - labels who ran what +- `NATIVE_WALLET_PASSWORD` - native wallet password for agent-browser wallet unlock + +```bash +# Verify env is set - all three MUST be present +echo "QABOT_API_KEY: ${QABOT_API_KEY:+set}" +echo "QABOT_OPERATOR: ${QABOT_OPERATOR:+set}" +echo "NATIVE_WALLET_PASSWORD: ${NATIVE_WALLET_PASSWORD:+set}" +``` + +If any of these are missing, tell the user to add them to `~/.secrets`. The API key is shared among trusted operators. + +## Ports + +- **Web dev server** (ShapeShift app): `localhost:3000` +- **qabot API/dashboard**: `localhost:8080` (dev) or deployed URL + +## Modes + +### Interactive Mode (no fixture provided) + +When the user says something like "qa test the trade page" or "run qabot" without a specific fixture: + +1. **Ask what to test** - use AskUserQuestion to clarify what functionality to test +2. **Help craft a fixture** - build a YAML fixture collaboratively with the user +3. **Save the fixture** - write to `e2e/fixtures/.yaml` +4. **Execute the fixture** - run the tests via agent-browser +5. **Report results** - push to qabot + +### Fixture Mode (fixture provided or automated) + +When a specific fixture file is provided, or running as part of an automated flow (clawdbot release, CI trigger): + +1. **Load the fixture** - read the YAML file from `e2e/fixtures/` +2. **Resolve dependencies** - if the fixture has `depends_on`, load those fixtures first (recursively) +3. **Execute all steps** - run dependency fixtures first, then the main fixture, all in one browser session +4. **Capture results** - screenshots + pass/fail per step (step indices are continuous across all fixtures) +5. **Report to qabot** - create run, upload screenshots, push results, post PR comment if applicable + +## Fixture Format + +```yaml +name: Test Name +description: What this tests +route: /trade +depends_on: + - wallet-health.yaml # runs this fixture first, wallet unlock etc. +steps: + - name: Step name + instruction: Natural language instruction for agent-browser + expected: What should be true after this step + screenshot: true # optional, take screenshot after step +``` + +### Composability + +Fixtures can declare `depends_on` - a list of other fixture filenames that must run first. + +- Dependencies are resolved recursively and deduplicated (each fixture runs at most once) +- All fixtures run sequentially in one browser session - the page state carries over +- Step indices are continuous across all fixtures (dep fixture steps come first) +- All steps from all fixtures go into a single qabot run +- The `fixtureFile` for the run is the top-level fixture name + +Example: `eth-to-fox-swap.yaml` depends on `wallet-health.yaml`. When you run eth-to-fox-swap: +1. wallet-health runs first (7 steps: dismiss onboarding, unlock wallet, verify page) +2. eth-to-fox-swap steps run next (5 steps: select assets, enter amount, verify quote) +3. Total: 12 steps in one run, indices 0-11 + +### Onboarding Dialog + +On first visit to any origin (gome.shapeshift.com, release.shapeshift.com, etc.), ShapeShift shows an onboarding splash dialog ("Self-Custody", "You own your keys") with "Skip" and "Next" buttons. The wallet-health fixture handles dismissing this. Always run wallet-health as a dependency for other fixtures. + +## agent-browser Session + +**IMPORTANT**: Always use the `qabot` profile. The native wallet is stored in this profile's IndexedDB per-origin. + +```bash +agent-browser --session qabot --profile ~/.agent-browser/profiles/qabot open +``` + +The profile at `~/.agent-browser/profiles/qabot` stores the native wallet (IndexedDB, localStorage, cookies) per origin. Import the wallet once per origin in headed mode, then reuse. + +First time setup per origin (headed, import wallet manually): +```bash +agent-browser --session qabot --profile ~/.agent-browser/profiles/qabot --headed open +# Import the native wallet, set password to $NATIVE_WALLET_PASSWORD, close. +# Subsequent runs reuse the persisted profile. +``` + +Origins where the wallet has been imported: +- `http://localhost:3000` (local dev) +- `https://gome.shapeshift.com` (gome staging) +- `https://release.shapeshift.com` (release staging) + +### Wallet Unlock + +The native wallet requires a password on each session start. The wallet-health fixture handles this. If running without wallet-health, handle it manually: + +1. Check for onboarding dialog first - click "Skip" if present +2. The "Enter Your Password" dialog may or may not appear automatically depending on the page: + - On some pages, the wallet modal opens automatically with the unlock prompt + - On other pages (e.g. /trade), you'll see "Connect Wallet" in the top-right nav instead + - If you see "Connect Wallet", click it, then **wait 5 seconds** for native wallets to load in the modal + - If the "test" wallet appears in the wallet list on the left side of the modal, click it to get the password prompt + - Note: the nav button may still say "Connect Wallet" even while the wallet is loading/connecting - this is normal +3. Click the wallet name button (e.g. "test", "teest") if wallet selection is shown +4. Focus the password input via JS eval (click --ref often times out on external origins): + `eval "document.querySelector('input[type=password], input[placeholder*=Password]')?.focus()"` +5. Type `$NATIVE_WALLET_PASSWORD` character by character using `press` (NOT `fill` - React controlled inputs need keypress events) +6. Click "Next" via JS eval: + `eval "$(cat /tmp/click-next.js)"` +7. Wait 8+ seconds for external origins to fully hydrate + +### Tips + +#### JS Eval & Smart Quotes (CRITICAL) + +- **Smart quotes kill JS eval**: Claude's output produces unicode smart quotes (`"` `"` `'` `'`) which cause `SyntaxError: Invalid or unexpected token` in agent-browser eval. **NEVER write JS inline in eval commands.** Always write JS to a temp file first with `printf`, then `cat` it: + ```bash + printf 'var btns=document.querySelectorAll("button"); for(var i=0;i /tmp/click-preview.js + agent-browser --session qabot eval "$(cat /tmp/click-preview.js)" + ``` +- **Pre-write common click helpers at session start**: Before executing any steps, create these reusable JS files in `/tmp/`. This avoids smart quote issues and speeds up execution: + ```bash + # Write all click helpers upfront + printf 'var btns=document.querySelectorAll("button"); for(var i=0;i /tmp/click-close.js + printf 'var btns=document.querySelectorAll("button"); for(var i=0;i /tmp/click-later.js + printf 'var btns=document.querySelectorAll("button"); for(var i=0;i /tmp/click-switch.js + printf 'var btns=document.querySelectorAll("button"); for(var i=0;i /tmp/click-sign.js + printf 'var btns=document.querySelectorAll("button"); for(var i=0;i /tmp/click-confirm.js + printf 'var btns=document.querySelectorAll("button"); for(var i=0;i /tmp/click-understand.js + printf 'var btns=document.querySelectorAll("button"); for(var i=0;i /tmp/click-preview.js + printf 'var btns=document.querySelectorAll("button"); for(var i=0;i /tmp/click-gotit.js + printf 'var btns=document.querySelectorAll("button"); for(var i=0;i /tmp/click-skip.js + printf 'var btns=document.querySelectorAll("button"); for(var i=0;i /tmp/click-next.js + ``` + Then use them: `agent-browser --session qabot eval "$(cat /tmp/click-close.js)"` + +#### Clicking on External Origins + +- **Clicking on external origins**: `click --ref` and `click --text` frequently time out on gome/release.shapeshift.com (elements blocked by overlays or slow hydration). **Always prefer JS eval for clicking on external origins**. +- **`.click()` vs `dispatchEvent`**: Some buttons on external origins don't respond to `.click()` (e.g. asset picker avatars). Use `dispatchEvent(new MouseEvent("click",{bubbles:true,cancelable:true}))` as a more reliable fallback. Example for asset avatar buttons: + ```js + var btn=document.querySelector("button[class*=avatar]"); if(btn) btn.dispatchEvent(new MouseEvent("click",{bubbles:true,cancelable:true})); + ``` +- **Cookie/tracking banner**: On external origins, a cookie banner ("Our dApp uses anonymized click tracking...") appears with a "Got It" button. **Dismiss this immediately after page load** - it can block interactions. The wallet-health fixture should handle this. + +#### Asset Picker + +- **Asset picker multi-chain**: Assets like FOX exist on multiple chains. Clicking the asset button once expands to show chain variants - click the specific chain variant (e.g. "Ethereum (FOX)") from the expanded list. Primary assets like SOL, BTC, RUNE don't need expansion. +- **Asset picker interaction**: Open sell asset picker by JS-clicking the sell asset avatar button. Search by focusing the search input via JS eval then pressing characters. Select results by JS-clicking the matching button. +- **Switch Assets is unreliable after swaps**: After completing a swap, the "Switch Assets" button may not reverse assets. Always verify via snapshot after clicking. If it didn't work, fall back to manually selecting assets via the pickers. + +#### Screenshots + +- **Always use absolute paths for screenshots**: Use `/tmp/step-N-name.png`, NOT relative paths. Relative paths cause resolution issues between agent-browser's cwd and the shell's cwd. `curl -F file@...` will fail with exit code 26 if the path is wrong. + ```bash + agent-browser --session qabot screenshot "/tmp/step-0-dismiss-onboarding.png" + ``` +- **Screenshots are temporary**: Screenshots are saved to `/tmp/` only as a temp step before uploading to Vercel Blob. After uploading, `rm` the local file. Do NOT accumulate local screenshots. +- **Delete after successful push**: The `step-complete` endpoint handles screenshot upload server-side. After a successful curl (HTTP 201), delete the local file with `rm -f`. +- **Screenshots timing**: Always take screenshots AFTER verifying the expected state via snapshot, not before. Early screenshots capture intermediate states. + +#### Agent Thought / Action Logging + +- **Agent thoughts must be user-facing**: `agentThought` and `actionTaken` fields in results should read like a QA engineer's notes, NOT implementation details. Write "Focused password input, typed password" not "JS eval to focus input, press chars one by one". Describe what happened from a user's perspective, not the automation method used. +- **No developer jargon**: Never use terms like "React controlled input", "nativeInputValueSetter", "dispatchEvent", "HStack", "Chakra modal". Write like a human QA tester: "Entered amount in the input field", "Clicked the account button", "Toggled to dollar input mode". +- **Shell expansion in curl fields**: Dollar signs in `-F` field values get shell-expanded (e.g. `$0.10` becomes `/bin/zsh.10`). **Always use single quotes** for `-F` values containing dollar signs: `-F 'agentThought=Entered 10 cents'`. Or avoid dollar signs entirely - write "10 cents" or "0.10 USD" instead of "$0.10". + +#### Swap Flow Gotchas + +- **Check balances before setting amounts**: Always snapshot and read the balance display before entering a swap amount. If the wallet balance is less than the intended amount + gas, reduce the amount. Example: SOL balance was $0.989, $1 + gas failed. Reduced to $0.50. +- **Fiat toggle persists between swaps**: After the first swap, fiat mode may already be active. Always check the input placeholder before toggling - if it already shows "$0", skip the toggle. +- **Warning dialogs during swap**: Two common warnings appear after clicking Preview Trade or Confirm and Trade: + 1. "Below recommended minimum" - for small amounts. Click "I understand" to proceed. + 2. "Price impact" - for high slippage (common with small THORChain trades). Click "I understand" to proceed. +- **Preview Trade loading loop**: On gome/release origins, clicking "Preview Trade" sometimes shows "Loading... Preview Trade" indefinitely then bounces back to enabled. The fix: click Preview Trade, then immediately (within 2-3s) check for and click "I understand" on the below-minimum warning. The warning appearing while loading causes the loop. Sequence: + ```bash + agent-browser --session qabot eval "$(cat /tmp/click-preview.js)" + sleep 2 + agent-browser --session qabot eval "$(cat /tmp/click-understand.js)" + sleep 3 + # Then verify Confirm Details screen appeared via snapshot + ``` +- **THORChain swap timing**: SOL->RUNE completes in ~10s, RUNE->SOL can take ~90s. Always poll with 120s timeout. +- **Feedback dialog after swap**: A "How was your trade experience?" dialog appears after swaps complete. Dismiss with "Maybe Later" button. + +#### Shell & Environment + +- **zsh gotchas**: `$VAR` as command doesn't work in zsh. `!` negation in inline scripts causes "command not found: !". Use `grep -v` or numeric comparison instead. macOS `date` doesn't support `%3N` for milliseconds - use `python3 -c 'import time; print(int(time.time()*1000))'`. `status` is a read-only variable in zsh - use `result_status` instead. +- Use `snapshot` after every action to verify state +- Close the session when done: `agent-browser --session qabot close` +- External origins (gome, release) are slower than localhost - use longer waits (8-10s after wallet unlock) + +## Execution Flow + +### 1. Set up auth + +```bash +source ~/.secrets +QABOT="${QABOT_URL:-http://localhost:8080}" +BASE_URL="${BASE_URL:-http://localhost:3000}" +``` + +All write requests use: +- `Authorization: Bearer $QABOT_API_KEY` +- `X-Qabot-Operator: $QABOT_OPERATOR` + +### 2. Detect enabled chains (for multi-chain fixtures) + +Some fixtures (e.g. `send-receive.yaml`) test multiple chains. Before executing, detect +which chains are actually enabled in the target environment. Use **read-only** operations only. + +**First-class chains** (always enabled, no feature flag): +Ethereum, Bitcoin, Bitcoin Cash, Dogecoin, Litecoin, Cosmos Hub, THORChain, Avalanche + +**Feature-flagged chains** need `VITE_FEATURE_=true` in the effective env config. +Vite precedence: `.env.production` overrides `.env` (base). Check both files: + +```bash +# WEB_REPO should already be set from section 4 (branch detection). +# If not, detect it from the port 3000 process or set it manually. + +# One-liner: merge .env + .env.production (later overrides), extract enabled chain flags +ENABLED_FLAGS=$(cat "$WEB_REPO/.env" "$WEB_REPO/.env.production" 2>/dev/null | \ + grep '^VITE_FEATURE_' | \ + awk -F= '{flags[$1]=$2} END{for(f in flags) if(flags[f]=="true") print f}' | \ + sed 's/VITE_FEATURE_//' | sort) + +# $ENABLED_FLAGS now contains flag names like: ARBITRUM, BASE, BNBSMARTCHAIN, ... +# Cross-reference with the fixture's chain list to determine which chains to test. +``` + +Flag name → chain mapping (from `src/config.ts` and `src/constants/chains.ts`): +OPTIMISM, BNBSMARTCHAIN, POLYGON, GNOSIS, ARBITRUM, SOLANA, STARKNET, TRON, SUI, NEAR, +TON, BASE, MONAD, HYPEREVM, PLASMA, MANTLE, INK, MEGAETH, BERACHAIN, CRONOS, KATANA, +FLOWEVM, CELO, PLUME, STORY, ZK_SYNC_ERA, BLAST, ETHEREAL, WORLDCHAIN, HEMI, SEI, +LINEA, SCROLL, SONIC, UNICHAIN, BOB, MODE, SONEIUM, MAYACHAIN, ZCASH + +Note: `.env.production` can explicitly disable chains that `.env` enables (e.g. `FLOWEVM=false`). + +### 3. Resolve fixture dependencies + +```bash +# Read the fixture YAML +# If depends_on is present, load each dependency recursively +# Deduplicate (each fixture runs once even if referenced multiple times) +# Build ordered list: [dep1_steps, dep2_steps, ..., main_fixture_steps] +# Step indices are continuous: 0, 1, 2, ... across all fixtures +``` + +### 4. Detect branch and commit + +Branch and commit must reflect the **web app being tested**, NOT the qabot repo. +Use **read-only git operations only** (fetch, rev-parse) - NEVER switch branches. + +```bash +GITHUB_REPO="shapeshift/web" + +# Origin-to-branch mapping (CloudFlare Pages deployments): +# localhost:3000 → local branch (detected from dev server process) +# gome.shapeshift.com → gome +# release.shapeshift.com → release +# develop.shapeshift.com → develop +# app.shapeshift.com → main +# neo.shapeshift.com → neo + +if [[ "$BASE_URL" == *"localhost"* ]]; then + # Local dev: detect web repo from the process actually serving port 3000 + # This handles worktrees correctly (main repo vs .worktrees/qabot etc.) + DEV_PID=$(lsof -i :3000 -sTCP:LISTEN -n -P -t 2>/dev/null | head -1) + if [ -n "$DEV_PID" ]; then + WEB_REPO=$(lsof -p "$DEV_PID" 2>/dev/null | awk '/cwd/{print $NF}') + fi + # Fallback: infer from context (check WEB_REPO env var, or ask the user) + if [ -z "$WEB_REPO" ]; then + echo "ERROR: Could not detect web repo from port 3000. Set WEB_REPO env var." >&2 + exit 1 + fi + BRANCH=$(git -C "$WEB_REPO" rev-parse --abbrev-ref HEAD) + COMMIT=$(git -C "$WEB_REPO" rev-parse HEAD) +else + # Remote origin: infer WEB_REPO from context for git fetch + # (any local clone of shapeshift/web works - agent should find it) + # Remote origin: map URL to branch, fetch latest upstream commit + case "$BASE_URL" in + *gome.*) BRANCH="gome" ;; + *release.*) BRANCH="release" ;; + *develop.*) BRANCH="develop" ;; + *neo.*) BRANCH="neo" ;; + *) BRANCH="main" ;; # app.shapeshift.com or unknown + esac + git -C "$WEB_REPO" fetch origin "$BRANCH" --quiet 2>/dev/null + COMMIT=$(git -C "$WEB_REPO" rev-parse "origin/$BRANCH" 2>/dev/null || echo "unknown") +fi + +COMMIT_SHORT="${COMMIT:0:7}" +BRANCH_URL="https://github.com/$GITHUB_REPO/tree/$BRANCH" +COMMIT_URL="https://github.com/$GITHUB_REPO/commit/$COMMIT" +``` + +The dashboard auto-generates GitHub permalinks from `prBranch` and `commitSha`: +- Branch → `https://github.com/shapeshift/web/tree/` +- Commit → `https://github.com/shapeshift/web/commit/` + +### 5. Create a run + +**IMPORTANT**: Always pass the full (not short) commit SHA so the dashboard permalink works. + +```bash +RUN_ID=$(curl -s -X POST "$QABOT/api/runs" \ + -H "Authorization: Bearer $QABOT_API_KEY" \ + -H "X-Qabot-Operator: $QABOT_OPERATOR" \ + -H "Content-Type: application/json" \ + -d '{"triggerType":"manual","fixtureFile":".yaml","url":"'"$BASE_URL"'","prBranch":"'"$BRANCH"'","commitSha":"'"$COMMIT"'"}' \ + | jq -r '.id') + +# URL is a run-level arg, NOT per-fixture. Fixtures define a `route` (e.g. /trade). +# The full URL = $BASE_URL + fixture route. +# For local dev: BASE_URL=http://localhost:3000 +# For staging: BASE_URL=https://gome.shapeshift.com or https://release.shapeshift.com +# +# For PR runs, also add: prNumber, prTitle, triggerType: "pr" +# For release runs, add: releaseTag, triggerType: "release" +# For cron/clawdbot runs, use: triggerType: "cron" +``` + +### 6. Mark run as running + +Before executing any steps, transition the run from `pending` to `running`: + +```bash +curl -s -X PATCH "$QABOT/api/runs/$RUN_ID" \ + -H "Authorization: Bearer $QABOT_API_KEY" -H "X-Qabot-Operator: $QABOT_OPERATOR" \ + -H "Content-Type: application/json" \ + -d '{"status":"running"}' +``` + +Run lifecycle: `pending` (created) -> `running` (agent-browser starts) -> `passed`/`failed` (all steps done) + +### 7. Execute fixture steps (one at a time) + +**CRITICAL**: Process each step individually. After each step: take a screenshot and push the result immediately via the batch endpoint. Do NOT batch all results at the end. + +```text +# Pre-write all click helpers to /tmp/ (see Tips > JS Eval section above) +# Record the run start time ONCE before the loop: +RUN_START_MS=$(python3 -c 'import time; print(int(time.time()*1000))') + +For EACH step across all fixtures (index 0, 1, 2, ...): + + 1. Execute the step's instruction via agent-browser commands + 2. Take a snapshot and evaluate the `expected` condition + 3. Determine status: "passed" if expected state is visible, "failed" if not + 4. Calculate ELAPSED time since run start (NOT per-step duration): + ELAPSED_MS=$(($(python3 -c 'import time; print(int(time.time()*1000))') - RUN_START_MS)) + 5. ALWAYS take a screenshot using ABSOLUTE path in /tmp/: + agent-browser --session qabot screenshot "/tmp/step-$INDEX-.png" + 6. Push screenshot + result in ONE call via the batch endpoint: + curl -s -X POST "$QABOT/api/runs/$RUN_ID/step-complete" \ + -H "Authorization: Bearer $QABOT_API_KEY" -H "X-Qabot-Operator: $QABOT_OPERATOR" \ + -F "stepIndex=$INDEX" \ + -F "name= > " \ + -F "status=" \ + -F "durationMs=$ELAPSED_MS" \ + -F "agentThought=" \ + -F "actionTaken=" \ + -F "file=@/tmp/step-$INDEX-.png" \ + -F "label=" + The server uploads the screenshot to Vercel Blob, inserts the result, + and recalculates run counters - all in one request. + On success, delete the local file: rm -f "/tmp/step-$INDEX-.png" + If the step has no screenshot, omit the "file" and "label" fields. + 7. For failed steps, also add: -F "errorMessage=" + and optionally: -F "errorStack=" + 8. If step failed and it's critical, you may stop early +``` + +**IMPORTANT**: `durationMs` for each step is the **total elapsed wall-clock time since the run started**, NOT the duration of that individual step. This captures agent thinking time between steps (which is significant). The dashboard shows these as cumulative timestamps so the last step's duration = total run duration. + +This way the dashboard updates live as each step completes. + +#### Step Naming Convention (Grouping) + +The dashboard groups steps into collapsible sections using ` > ` as the separator. Use this convention in ALL step names: + +- **Dependency fixture steps**: ` > ` + - Example: `Wallet Health > Dismiss onboarding` + - Example: `Wallet Health > Unlock wallet` +- **Template fixture steps** (multi-chain): ` > ` + - Example: `Ethereum > Navigate to asset page` + - Example: `Bitcoin > Enter amount and confirm send` +- **Regular fixture steps**: ` > ` + - Example: `ETH to FOX Swap > Select sell asset` + +Multi-level nesting is supported by chaining separators: +- `Send Receive > Ethereum > Navigate to asset page` (3 levels) + +Steps without ` > ` render flat (no grouping) for backwards compatibility. + +**CRITICAL**: Always use ` > ` (space-arrow-space), never `: ` or ` - ` as group separators. The dashboard only recognizes ` > `. + +### 8. Complete the run + +```bash +STATUS="passed" # or "failed" if any step failed +TOTAL_MS=$(($(python3 -c 'import time; print(int(time.time()*1000))') - RUN_START_MS)) +curl -s -X PATCH "$QABOT/api/runs/$RUN_ID" \ + -H "Authorization: Bearer $QABOT_API_KEY" -H "X-Qabot-Operator: $QABOT_OPERATOR" \ + -H "Content-Type: application/json" \ + -d '{"status":"'"$STATUS"'","completedAt":"'$(date -u +%Y-%m-%dT%H:%M:%S.000Z)'","durationMs":'"$TOTAL_MS"'}' +``` + +### 9. Post PR comment (if PR run) + +```bash +curl -s -X POST "$QABOT/api/github/comment" \ + -H "Authorization: Bearer $QABOT_API_KEY" -H "X-Qabot-Operator: $QABOT_OPERATOR" \ + -H "Content-Type: application/json" \ + -d '{"runId":"'"$RUN_ID"'"}' +``` + +## Available Fixtures + +```bash +ls e2e/fixtures/*.yaml +``` + +## Agent Thought / Action Logging + +For each step, capture: +- **agentThought**: Your reasoning about what you see and whether the expected condition is met +- **actionTaken**: The actual agent-browser commands you ran +- **errorMessage**: If the step failed, what went wrong +- **errorStack**: Any error output from agent-browser + +This context shows up in the qabot dashboard and PR comments. diff --git a/CLAUDE.md b/CLAUDE.md index f50440a7b2d..4233ebf3dfc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,6 +93,7 @@ - Main branch is `develop` - use this for PRs - Branch naming: Use descriptive names (e.g., `feat_gridplus`, `fix_wallet_connect`) - When opening PRs (via `gh`, Aviator `av`, or any CLI tool), ALWAYS use the `.github/PULL_REQUEST_TEMPLATE.md` template as the base for the PR body +- **Editing PR descriptions**: `gh pr edit --body` fails on this repo due to a deprecated Projects Classic GraphQL error. Use the REST API instead: `gh api repos/shapeshift/web/pulls/ -X PATCH -F "body=@/path/to/body.md"` (write the body to a temp file first) ### UI/UX Standards - Account for light/dark mode using `useColorModeValue` hook diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000000..0d0da8bab49 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,140 @@ +# qabot - E2E QA Testing + +qabot is an AI-powered QA testing platform for ShapeShift Web. Operators (humans using Claude Code) run E2E test fixtures via `agent-browser` and post results to the qabot dashboard at https://qabot-kappa.vercel.app/. + +The `/qabot` skill in Claude Code handles everything - executing fixtures, capturing screenshots, and uploading results. + +## Setup + +### 1. Environment Variables + +You need three secrets and two config vars. We recommend keeping secrets in `~/.secrets` and sourcing that from your shell rc - keeps secrets out of dotfiles you might commit: + +```bash +# ~/.secrets +export QABOT_API_KEY="" +export NATIVE_WALLET_PASSWORD="" +``` + +Then source it from `~/.zshrc` (or `~/.bashrc`): + +```bash +# ~/.zshrc +[ -f ~/.secrets ] && source ~/.secrets +``` + +These two don't need to be in secrets - put them wherever you source env vars: + +```bash +export QABOT_URL="https://qabot-kappa.vercel.app" +export QABOT_OPERATOR="" # e.g. "john", "clawdbot" +``` + +Summary: + +| Variable | Required | Secret? | Description | +|---|---|---|---| +| `QABOT_API_KEY` | Yes | Yes | Shared API key for dashboard write access | +| `NATIVE_WALLET_PASSWORD` | Yes | Yes | Password for the native test wallet in agent-browser | +| `QABOT_URL` | Yes | No | Dashboard URL (default: `https://qabot-kappa.vercel.app`) | +| `QABOT_OPERATOR` | Yes | No | Your operator name - labels who ran the test | + +### 2. agent-browser + +[agent-browser](https://github.com/anthropics/agent-browser) is the headless browser automation tool that executes test steps. + +```bash +npm install -g agent-browser +``` + +Requires v0.14.0+. + +### 3. Wallet Profile Setup (one-time) + +agent-browser stores wallet state per profile per origin. You need to import the native test wallet once for each origin you'll test against: + +```bash +# Open a headed browser session +agent-browser --session qabot --profile ~/.agent-browser/profiles/qabot --headed open http://localhost:3000 + +# In the browser: +# 1. Create or import a native wallet +# 2. Set password to $NATIVE_WALLET_PASSWORD +# 3. Close the browser +``` + +Repeat for any other origin you want to test: + +```bash +agent-browser --session qabot --profile ~/.agent-browser/profiles/qabot --headed open https://gome.shapeshift.com +agent-browser --session qabot --profile ~/.agent-browser/profiles/qabot --headed open https://release.shapeshift.com +``` + +After setup, the profile persists at `~/.agent-browser/profiles/qabot/` and subsequent runs reuse it. + +### 4. ShapeShift Web Running + +For local testing, start the dev server: + +```bash +yarn dev +``` + +Or point `BASE_URL` at a staging environment: + +```bash +BASE_URL=https://gome.shapeshift.com +``` + +## Running Tests + +### Via Claude Code Skill + +``` +/qabot smoke-test.yaml +/qabot eth-to-fox-swap.yaml +/qabot evm-chains-regression.yaml +``` + +Or just `/qabot` for interactive mode - Claude will help you pick or craft a fixture. + +### Available Fixtures + +| Fixture | Description | Dependencies | +|---|---|---| +| `smoke-test.yaml` | Onboarding dismiss, wallet unlock, trade page verify | None | +| `eth-to-fox-swap.yaml` | Full ETH to FOX swap end-to-end | smoke-test | +| `thorchain-solana-swapper.yaml` | SOL/RUNE cross-chain swaps via THORChain | smoke-test | +| `evm-chains-regression.yaml` | $1 same-chain swaps across 7 EVM chains | smoke-test | + +Fixtures live in `e2e/fixtures/`. Create new ones with `/qabot-fixture`. + +## How It Works + +1. The `/qabot` skill reads a YAML fixture and resolves dependencies +2. agent-browser opens a Chrome session with the test wallet profile +3. Each step is executed, verified via accessibility snapshots, and screenshotted +4. Screenshots are uploaded to the qabot dashboard (Vercel Blob storage) +5. Step results (pass/fail, agent observations, screenshots) are pushed per-step +6. The dashboard at https://qabot-kappa.vercel.app/ shows live progress + +## Fixture Format + +```yaml +name: My Test +description: What this tests +route: /trade +depends_on: + - smoke-test.yaml # runs BEFORE main steps +post_depends_on: + - cleanup.yaml # runs AFTER main steps +steps: + - name: Do something + instruction: > + Natural language instruction for agent-browser. + Click the swap button, type 1 in the amount field, etc. + expected: What should be visible/true after this step + screenshot: true +``` + +Dependencies are resolved recursively and deduplicated. All steps run in one browser session with continuous step indices. diff --git a/e2e/fixtures/asset-data-regression.yaml b/e2e/fixtures/asset-data-regression.yaml new file mode 100644 index 00000000000..3e6b15dc3a3 --- /dev/null +++ b/e2e/fixtures/asset-data-regression.yaml @@ -0,0 +1,270 @@ +name: Asset Data Regression +description: > + Validates asset search, related asset grouping, and markets page after asset data regeneration. + Tests USDC, USDT, and FOX search with grouped multi-chain variants via global search, + plus markets recently added section. + Does NOT require wallet connection - no depends_on smoke-test. +route: /trade + +# ============================================================ +# IMPLEMENTATION NOTES (for agent-browser execution): +# +# Global Search Modal Interaction Pattern: +# 1. Click [data-testid=global-search-button] via JS eval (.click()) +# 2. Wait 1s for modal to mount +# 3. Set search value via nativeInputValueSetter (NOT press commands - those steal focus and close modal) +# 4. All subsequent reads/clicks must happen via JS eval targeting [data-testid=global-search-modal] +# 5. Close modal with Escape key between searches +# +# The modal closes when focus moves outside it. agent-browser `press` commands +# move focus away. Use JS eval exclusively for modal interactions. +# +# Data-testid patterns: +# - global-search-button: opens the search modal +# - global-search-modal: the modal container +# - global-search-input: the search text input +# - grouped-asset-row-{SYMBOL}-{assetId}: expandable multi-chain grouped row +# - asset-row-{ChainName}-{SYMBOL}-{assetId}: individual chain variant inside grouped row +# - asset-row-{SYMBOL}-{assetId}: standalone (non-grouped) asset row +# - markets-row-{category}: market section (e.g. markets-row-recentlyAdded) +# - asset-card-{SYMBOL}: clickable card in markets grid +# +# JS eval helper for typing in React controlled inputs: +# var input = document.querySelector("[data-testid=global-search-input]"); +# var setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set; +# setter.call(input, "usdc"); +# input.dispatchEvent(new Event("input", {bubbles: true})); +# input.dispatchEvent(new Event("change", {bubbles: true})); +# ============================================================ + +steps: + # ============================================================ + # STEP 0: DISMISS ONBOARDING / BANNERS + # ============================================================ + - name: Dismiss onboarding and banners + instruction: > + After opening the page, dismiss onboarding splash if present (click "Skip" button via JS eval). + Dismiss cookie/tracking banner if present (click "Got It" button via JS eval). + Wait 2 seconds for page to settle. + expected: No onboarding dialog or cookie banner visible, trade page is loading + screenshot: true + + # ============================================================ + # STEP 1: USDC SEARCH + # ============================================================ + - name: "USDC: Open global search and search" + instruction: > + Click [data-testid=global-search-button] via JS eval to open the search modal. + Wait 1 second for modal to mount. + Set search input value to "usdc" using nativeInputValueSetter + input/change events. + Wait 2 seconds for results. + Verify [data-testid=global-search-modal] contains [data-testid^=grouped-asset-row-USDC]. + expected: > + Global search modal is open with "usdc" in search input. + Results show grouped-asset-row-USDC (expandable with chevron). + Also note: there may be 2 spam USDC tokens (0x115b...aba605, 0x7177...638f9c) + without proper icons - these are NOT in the generated asset data and come from + a dynamic runtime source. Flag as soft_fail if present. + screenshot: true + + # ============================================================ + # STEP 2: USDC EXPAND + VERIFY CHAIN VARIANTS + # ============================================================ + - name: "USDC: Expand grouped row and verify chain variants" + instruction: > + Click [data-testid^=grouped-asset-row-USDC] via JS eval to expand. + Wait 1 second for Collapse animation. + Count child elements matching [data-testid^=asset-row-] inside the modal. + Expected at minimum 8 chain variants: Ethereum, Arbitrum, Base, Optimism, + Polygon, Avalanche, BSC, Solana. + Scroll to a mid-list variant for the screenshot. + expected: > + Grouped USDC row is expanded (chevron pointing up). + JS eval confirms 8+ chain variants present. + Key chains: Ethereum, Arbitrum One, Base, Solana, Avalanche, BSC, Sui, Tron. + screenshot: true + + # ============================================================ + # STEP 3: CLOSE MODAL + # ============================================================ + - name: "USDC: Close global search" + instruction: > + Press Escape to close the global search modal. + Wait 1 second. + expected: Global search modal is closed, trade page is visible + screenshot: false + + # ============================================================ + # STEP 4: USDT SEARCH + # ============================================================ + - name: "USDT: Open global search and search" + instruction: > + Click [data-testid=global-search-button] via JS eval to re-open modal. + Wait 1 second. Set value to "usdt" via nativeInputValueSetter. + Wait 2 seconds for results. + Verify [data-testid^=grouped-asset-row-USDT] exists in modal. + expected: > + Global search modal shows USDT results with grouped-asset-row-USDT visible. + screenshot: true + + # ============================================================ + # STEP 5: USDT EXPAND + VERIFY + # ============================================================ + - name: "USDT: Expand grouped row and verify chain variants" + instruction: > + Click [data-testid^=grouped-asset-row-USDT] via JS eval to expand. + Wait 1 second. Count [data-testid^=asset-row-] children. + Expected at minimum 6 chains: Ethereum, Arbitrum, Optimism, Polygon, Avalanche, BSC. + expected: > + Grouped USDT row expanded. JS eval confirms 6+ chain variants. + Key chains: Ethereum, Arbitrum, Optimism, Polygon, Avalanche, BSC. + screenshot: true + + # ============================================================ + # STEP 6: CLOSE MODAL + # ============================================================ + - name: "USDT: Close global search" + instruction: > + Press Escape to close modal. Wait 1 second. + expected: Global search modal is closed + screenshot: false + + # ============================================================ + # STEP 7: FOX SEARCH + # ============================================================ + - name: "FOX: Open global search and search" + instruction: > + Click [data-testid=global-search-button] via JS eval to re-open modal. + Wait 1 second. Set value to "fox" via nativeInputValueSetter. + Wait 2 seconds for results. + Verify [data-testid^=grouped-asset-row-FOX] exists in modal. + expected: > + Global search modal shows FOX results with grouped-asset-row-FOX visible. + screenshot: true + + # ============================================================ + # STEP 8: FOX EXPAND + VERIFY + # ============================================================ + - name: "FOX: Expand grouped row and verify chain variants" + instruction: > + Click [data-testid^=grouped-asset-row-FOX] via JS eval to expand. + Wait 1 second. Count [data-testid^=asset-row-] children. + Expected at minimum 2 chains: Ethereum, Arbitrum. + expected: > + Grouped FOX row expanded. JS eval confirms 2+ chain variants. + Key chains: Ethereum, Arbitrum. + screenshot: true + + # ============================================================ + # STEP 9: CLOSE MODAL + # ============================================================ + - name: "FOX: Close global search" + instruction: > + Press Escape to close modal. Wait 1 second. + expected: Global search modal is closed + screenshot: false + + # ============================================================ + # STEP 10: MARKETS PAGE + # ============================================================ + - name: "Markets: Navigate to markets page" + instruction: > + Navigate to http://localhost:3000/#/markets/recommended via agent-browser open. + Wait 8 seconds for all market data sections to fully load + (CoinGecko data, categories, asset cards). + expected: Markets page is visible with category sections (Trending, Top Movers, Recently Added, etc.) + screenshot: true + + # ============================================================ + # STEP 11: RECENTLY ADDED - CLICK FIRST ASSET + # ============================================================ + - name: "Markets: Click first recently added asset" + instruction: > + Find [data-testid=markets-row-recentlyAdded] via JS eval. + Find first [data-testid^=asset-card-] inside that section. Note its symbol. + Click it via JS eval. + Wait 5 seconds for asset detail page to fully load (price chart, market data). + expected: > + Asset detail page loads. URL hash contains /assets/. + [data-testid=asset-chart-price] exists and shows a price (not N/A, not empty skeleton). + [data-testid=asset-market-data] exists with market data rows. + screenshot: true + + # ============================================================ + # STEP 12: ASSET PAGE - VERIFY MARKET DATA + # ============================================================ + - name: "Asset page: Verify market data loaded" + instruction: > + Check [data-testid=asset-market-data] via JS eval. + Verify it contains text content (price, market cap, volume values). + Check [data-testid=asset-chart-price] has non-empty text. + expected: > + Market data card is visible with price, market cap, and volume values. + Price heading shows a dollar amount (not "N/A", not empty). + screenshot: true + + # ============================================================ + # STEP 13: ASSET PAGE - CLICK 1H TIMEFRAME + # ============================================================ + - name: "Asset page: Click 1H timeframe button" + instruction: > + Click [data-testid=radio-1H] via JS eval. + Wait 3 seconds for chart data to reload. + Verify the button appears selected (check aria-checked or data-checked attribute). + expected: > + 1H timeframe button is selected/active. + Chart area is visible (PriceChart component rendered). + No error states or empty chart. + screenshot: true + + # ============================================================ + # STEP 14: ASSET PAGE - CLICK 1W TIMEFRAME + # ============================================================ + - name: "Asset page: Click 1W timeframe button" + instruction: > + Click [data-testid=radio-1W] via JS eval. + Wait 3 seconds for chart data to reload. + Verify the button appears selected. + expected: > + 1W timeframe button is selected/active. + Chart area is visible. No errors. + screenshot: true + + # ============================================================ + # STEP 15: NAVIGATE BACK TO MARKETS + # ============================================================ + - name: "Markets: Navigate back to markets page" + instruction: > + Navigate to http://localhost:3000/#/markets/recommended via agent-browser open. + Wait 5 seconds for market data sections to load. + expected: Markets page visible with category sections including Recently Added. + screenshot: false + + # ============================================================ + # STEP 16: RECENTLY ADDED - CLICK SECOND ASSET + # ============================================================ + - name: "Markets: Click second recently added asset" + instruction: > + Find [data-testid=markets-row-recentlyAdded] via JS eval. + Find ALL [data-testid^=asset-card-] inside that section. + Click the SECOND one (index 1) via JS eval. Note its symbol. + Wait 5 seconds for asset detail page to load. + expected: > + Second asset's detail page loads. URL hash contains /assets/. + [data-testid=asset-chart-price] shows a price. + [data-testid=asset-market-data] exists. + screenshot: true + + # ============================================================ + # STEP 17: SECOND ASSET - VERIFY MARKET DATA + TIMEFRAMES + # ============================================================ + - name: "Asset page: Verify second asset market data and timeframes" + instruction: > + Check [data-testid=asset-market-data] has text content (price, market cap, volume). + Click [data-testid=radio-1H] via JS eval, wait 2 seconds. + Click [data-testid=radio-1W] via JS eval, wait 2 seconds. + Verify no errors after timeframe switches. + expected: > + Market data is present for second asset. + Timeframe buttons (1H, 1W) are clickable and chart updates without errors. + screenshot: true diff --git a/e2e/fixtures/eth-to-fox-swap.yaml b/e2e/fixtures/eth-to-fox-swap.yaml new file mode 100644 index 00000000000..92a37a04ce3 --- /dev/null +++ b/e2e/fixtures/eth-to-fox-swap.yaml @@ -0,0 +1,90 @@ +name: ETH to FOX Swap +description: Full ETH to FOX swap - select assets, enter amount, preview, sign, wait for completion +route: /trade +depends_on: + - wallet-health.yaml +steps: + - name: Dismiss stale notifications + instruction: > + Before starting the swap, dismiss any lingering notifications from previous swaps. + Close any toast notifications, feedback dialogs ("How was your trade experience?"), + or pending transaction banners by clicking their Close/X buttons. + This prevents stale UI state from interfering with the current swap's completion detection. + expected: No stale notifications visible + screenshot: true + - name: Select ETH as sell asset + instruction: > + Check if ETH is already the sell asset. If not, click the sell asset selector, + search for ETH, and select "Ethereum (ETH)". + expected: ETH is selected as the sell asset + screenshot: true + - name: Select FOX as buy asset + instruction: > + Click the buy/receive asset selector button (the one showing the current buy asset like BTC). + In the asset picker dialog, type "FOX" in the search field. + Click the FOX result - if it shows multiple chain avatars, click it once to expand, + then click "Ethereum (FOX)" from the expanded list. + Wait for the dialog to close and FOX to appear as the buy asset. + expected: FOX is selected as the buy asset with a FOX balance shown + screenshot: true + - name: Toggle to fiat input + instruction: > + Click the "≈ $0.00" button below the sell amount to toggle to fiat/USD input mode. + If already in fiat mode (placeholder shows "$0"), skip this step. + expected: The input field is in fiat mode (placeholder shows $0, balance shows $ amount) + screenshot: true + - name: Enter swap amount + instruction: > + Click the sell amount textbox and type 0.10 character by character using press + (NOT fill - React controlled inputs need keypress events). + Wait a moment for the value to register. + expected: $0.10 is entered in the sell field + screenshot: true + - name: Wait for quote + instruction: > + Wait up to 10 seconds for a swap quote to appear. + The "You Get" field should show a FOX amount (not 0), + and a rate like "1 ETH = X FOX" should appear. + The "Preview Trade" button should become enabled (not disabled). + expected: Quote is displayed with FOX receive amount, rate shown, Preview Trade button enabled + screenshot: true + - name: Preview trade + instruction: > + Click the "Preview Trade" button. + Wait for the "Confirm Details" screen to appear showing swap details + (sell amount, receive amount, swapper, fees). + expected: Confirm Details screen visible with swap summary and "Confirm and Trade" button + screenshot: true + - name: Confirm and Trade + instruction: > + Click the "Confirm and Trade" button. + This transitions to the Trade execution screen. + A "Sign & Swap" button will appear, initially in a loading/disabled state. + Wait up to 30 seconds for "Sign & Swap" to become enabled (no longer loading/disabled). + expected: Sign & Swap button is visible and enabled (not loading, not disabled) + screenshot: true + - name: Sign and Swap + instruction: > + Click the "Sign & Swap" button to sign and submit the transaction. + The native wallet will sign the transaction automatically (no password prompt for native ETH sends). + After clicking, the transaction is submitted on-chain. + expected: Transaction submitted, swap execution in progress + screenshot: true + - name: Wait for completion + instruction: > + Wait for the swap to complete. The page will show "Awaiting swap" with a progress bar. + When complete, it transitions back to the main trade page. + A feedback dialog ("How was your trade experience?") may appear - dismiss it by clicking "Maybe Later" or "Close". + Check that the buy asset balance has increased. + Wait up to 120 seconds, checking every 5 seconds. + Look for: the trade page reappearing, a feedback dialog, success notification, or the + "Awaiting swap" text disappearing. + expected: Swap completed - back on trade page, FOX balance increased, no more "Awaiting swap" + screenshot: true + - name: Clean up notifications + instruction: > + After confirming the swap completed, dismiss any remaining notifications or dialogs + (feedback dialog, success toast, etc.) by clicking Close/X/Maybe Later buttons. + This leaves a clean state for any subsequent fixture that depends on this one. + expected: Trade page is clean with no overlaying notifications or dialogs + screenshot: true diff --git a/e2e/fixtures/evm-chains-regression.yaml b/e2e/fixtures/evm-chains-regression.yaml new file mode 100644 index 00000000000..2f707a8040f --- /dev/null +++ b/e2e/fixtures/evm-chains-regression.yaml @@ -0,0 +1,197 @@ +name: First-Class EVM Chains Swap Regression +description: > + Regression test for first-class EVM chains. Executes a $1 same-chain swap + on each chain (native -> any token) and verifies the swap completes. + Detects node failures, RPC issues, and swapper regressions. + Chains: Ethereum, Base, Gnosis, Avalanche, Arbitrum, BSC, Optimism. +route: /trade +depends_on: + - wallet-health.yaml +steps: + # ============================================================ + # ETHEREUM - ETH -> any token ($1) + # ============================================================ + - name: "Ethereum: Same-chain swap ETH -> token ($1)" + instruction: > + Ensure sell is ETH on Ethereum (check [data-testid="sell-asset-button"]). + Click [data-testid="buy-asset-button"] to open TO picker. + Focus [data-testid="asset-search-input"] and search, or use + [data-testid="chain-filter-button"] then [data-testid="chain-menu-item-Ethereum"] to filter. + Select any available token (e.g. [data-testid="asset-row-USDC"] or similar). + Ensure fiat mode. Enter "1" in sell amount. Wait for quote. + Click [data-testid="trade-form-preview-button-trade.previewTrade"]. + Handle warnings (click "I understand" if shown). + Click [data-testid="trade-confirm-button-trade.confirmAndTrade"]. + Wait for [data-testid="trade-confirm-button-trade.signAndSwap"] to become enabled, then click it. + Wait for completion (max 120s). Dismiss notifications. + expected: Swap completed, notification says complete + screenshot: true + + # ============================================================ + # BASE - ETH -> any token ($1) + # ============================================================ + - name: "Base: Switch sell to ETH on Base" + instruction: > + Click [data-testid="sell-asset-button"] to open sell picker. + Focus [data-testid="asset-search-input"] and search "ETH", or use + [data-testid="chain-filter-button"] then [data-testid="chain-menu-item-Base"] to filter. + Click [data-testid="asset-row-ETH"] to select ETH on Base. + Verify sell shows ETH on Base. + expected: Sell asset is ETH on Base + screenshot: false + + - name: "Base: Same-chain swap ETH -> token ($1)" + instruction: > + Click [data-testid="buy-asset-button"] to open TO picker. + Focus [data-testid="asset-search-input"] and search, or use + [data-testid="chain-filter-button"] then [data-testid="chain-menu-item-Base"] to filter. + Select any token (e.g. [data-testid="asset-row-USDC"]). + Ensure fiat mode. Enter "1". Wait for quote. + Click [data-testid="trade-form-preview-button-trade.previewTrade"]. + Handle warnings (click "I understand" if shown). + Click [data-testid="trade-confirm-button-trade.confirmAndTrade"]. + Wait for [data-testid="trade-confirm-button-trade.signAndSwap"] to become enabled, then click it. + Wait for completion (max 120s). Dismiss notifications. + expected: Swap completed, notification says complete + screenshot: true + + # ============================================================ + # GNOSIS - xDAI -> any token ($1) + # ============================================================ + - name: "Gnosis: Switch sell to xDAI on Gnosis" + instruction: > + Click [data-testid="sell-asset-button"] to open sell picker. + Focus [data-testid="asset-search-input"] and search "xDAI", or use + [data-testid="chain-filter-button"] then [data-testid="chain-menu-item-Gnosis"] to filter. + Click [data-testid="asset-row-XDAI"] to select xDAI on Gnosis. + If no xDAI balance, mark step as failed (precondition: xDAI balance required). + expected: Sell asset is xDAI on Gnosis with balance + soft_fail: true + screenshot: false + + - name: "Gnosis: Same-chain swap xDAI -> token ($1)" + instruction: > + Click [data-testid="buy-asset-button"] to open TO picker. + Focus [data-testid="asset-search-input"] and search, or use + [data-testid="chain-filter-button"] then [data-testid="chain-menu-item-Gnosis"] to filter. + Select any token (e.g. [data-testid="asset-row-USDC"]). + Ensure fiat mode. Enter "1". Wait for quote. + Click [data-testid="trade-form-preview-button-trade.previewTrade"]. + Handle warnings (click "I understand" if shown). + Click [data-testid="trade-confirm-button-trade.confirmAndTrade"]. + Wait for [data-testid="trade-confirm-button-trade.signAndSwap"] to become enabled, then click it. + Wait for completion (max 120s). Dismiss notifications. + expected: Swap completed, notification says complete + screenshot: true + + # ============================================================ + # AVALANCHE - AVAX -> any token ($1) + # ============================================================ + - name: "Avalanche: Switch sell to AVAX" + instruction: > + Click [data-testid="sell-asset-button"] to open sell picker. + Focus [data-testid="asset-search-input"] and search "AVAX", or use + [data-testid="chain-filter-button"] then [data-testid="chain-menu-item-Avalanche C-Chain"] to filter. + Click [data-testid="asset-row-AVAX"] to select AVAX. + If no AVAX balance, mark step as failed (precondition: AVAX balance required). + expected: Sell asset is AVAX with balance + soft_fail: true + screenshot: false + + - name: "Avalanche: Same-chain swap AVAX -> token ($1)" + instruction: > + Click [data-testid="buy-asset-button"] to open TO picker. + Focus [data-testid="asset-search-input"] and search, or use + [data-testid="chain-filter-button"] then [data-testid="chain-menu-item-Avalanche C-Chain"] to filter. + Select any token (e.g. [data-testid="asset-row-USDC"]). + Ensure fiat mode. Enter "1". Wait for quote. + Click [data-testid="trade-form-preview-button-trade.previewTrade"]. + Handle warnings (click "I understand" if shown). + Click [data-testid="trade-confirm-button-trade.confirmAndTrade"]. + Wait for [data-testid="trade-confirm-button-trade.signAndSwap"] to become enabled, then click it. + Wait for completion (max 120s). Dismiss notifications. + expected: Swap completed, notification says complete + screenshot: true + + # ============================================================ + # ARBITRUM - ETH -> any token ($1) + # ============================================================ + - name: "Arbitrum: Switch sell to ETH on Arbitrum" + instruction: > + Click [data-testid="sell-asset-button"] to open sell picker. + Focus [data-testid="asset-search-input"] and search "ETH", or use + [data-testid="chain-filter-button"] then [data-testid="chain-menu-item-Arbitrum One"] to filter. + Click [data-testid="asset-row-ETH"] to select ETH on Arbitrum. + expected: Sell asset is ETH on Arbitrum + screenshot: false + + - name: "Arbitrum: Same-chain swap ETH -> token ($1)" + instruction: > + Click [data-testid="buy-asset-button"] to open TO picker. + Focus [data-testid="asset-search-input"] and search, or use + [data-testid="chain-filter-button"] then [data-testid="chain-menu-item-Arbitrum One"] to filter. + Select any token (e.g. [data-testid="asset-row-USDC"]). + Ensure fiat mode. Enter "1". Wait for quote. + Click [data-testid="trade-form-preview-button-trade.previewTrade"]. + Handle warnings (click "I understand" if shown). + Click [data-testid="trade-confirm-button-trade.confirmAndTrade"]. + Wait for [data-testid="trade-confirm-button-trade.signAndSwap"] to become enabled, then click it. + Wait for completion (max 120s). Dismiss notifications. + expected: Swap completed, notification says complete + screenshot: true + + # ============================================================ + # BSC - BNB -> any token ($1) + # ============================================================ + - name: "BSC: Switch sell to BNB" + instruction: > + Click [data-testid="sell-asset-button"] to open sell picker. + Focus [data-testid="asset-search-input"] and search "BNB", or use + [data-testid="chain-filter-button"] then [data-testid="chain-menu-item-BNB Smart Chain"] to filter. + Click [data-testid="asset-row-BNB"] to select BNB. + If no BNB balance, mark step as failed (precondition: BNB balance required). + expected: Sell asset is BNB with balance + soft_fail: true + screenshot: false + + - name: "BSC: Same-chain swap BNB -> token ($1)" + instruction: > + Click [data-testid="buy-asset-button"] to open TO picker. + Focus [data-testid="asset-search-input"] and search, or use + [data-testid="chain-filter-button"] then [data-testid="chain-menu-item-BNB Smart Chain"] to filter. + Select any token (e.g. [data-testid="asset-row-USDT"]). + Ensure fiat mode. Enter "1". Wait for quote. + Click [data-testid="trade-form-preview-button-trade.previewTrade"]. + Handle warnings (click "I understand" if shown). + Click [data-testid="trade-confirm-button-trade.confirmAndTrade"]. + Wait for [data-testid="trade-confirm-button-trade.signAndSwap"] to become enabled, then click it. + Wait for completion (max 120s). Dismiss notifications. + expected: Swap completed, notification says complete + screenshot: true + + # ============================================================ + # OPTIMISM - ETH -> any token ($1) + # ============================================================ + - name: "Optimism: Switch sell to ETH on Optimism" + instruction: > + Click [data-testid="sell-asset-button"] to open sell picker. + Focus [data-testid="asset-search-input"] and search "ETH", or use + [data-testid="chain-filter-button"] then [data-testid="chain-menu-item-Optimism"] to filter. + Click [data-testid="asset-row-ETH"] to select ETH on Optimism. + expected: Sell asset is ETH on Optimism + screenshot: false + + - name: "Optimism: Same-chain swap ETH -> token ($1)" + instruction: > + Click [data-testid="buy-asset-button"] to open TO picker. + Focus [data-testid="asset-search-input"] and search, or use + [data-testid="chain-filter-button"] then [data-testid="chain-menu-item-Optimism"] to filter. + Select any token (e.g. [data-testid="asset-row-USDC"]). + Ensure fiat mode. Enter "1". Wait for quote. + Click [data-testid="trade-form-preview-button-trade.previewTrade"]. + Handle warnings (click "I understand" if shown). + Click [data-testid="trade-confirm-button-trade.confirmAndTrade"]. + Wait for [data-testid="trade-confirm-button-trade.signAndSwap"] to become enabled, then click it. + Wait for completion (max 120s). Dismiss notifications. + expected: Swap completed, notification says complete + screenshot: true diff --git a/e2e/fixtures/fox-ecosystem.yaml b/e2e/fixtures/fox-ecosystem.yaml new file mode 100644 index 00000000000..2b63b05ce83 --- /dev/null +++ b/e2e/fixtures/fox-ecosystem.yaml @@ -0,0 +1,164 @@ +name: fox-ecosystem +description: Fox Ecosystem page - rFOX staking/unstaking/claiming, simulator, FOX token, farming, governance, action center notifications +route: /fox-ecosystem +depends_on: + - wallet-health.yaml + +steps: + - name: Navigate to Fox Ecosystem + instruction: Navigate to the fox-ecosystem route (use BASE_URL from environment). Wait for the page to fully load with rFOX section visible. + expected: Page shows "FOX Token Dashboard" heading, rFOX Staking section with APY badge, navigation links (rFOX Staking, FOX Token, FOX Farming+, Governance). "Program Ended" banner about WETH/FOX sunset visible. + screenshot: true + + - name: Verify rFOX section balances + instruction: Check [data-testid="rfox-section"]. Read the staking balance, pending rewards, lifetime rewards, and time in pool values. + expected: Staking balance shows FOX amount with USD equivalent. Pending rewards shows USDC amount. Lifetime rewards shows USD value. Time in pool shows duration. APY percentage displayed in badge. FOX and WETH/FOX filter tabs visible. + screenshot: true + + - name: Stake FOX - open modal and enter amount + instruction: Click [data-testid="rfox-stake-button"] via JS dispatchEvent. Focus the amount input, type "0.5" via keyboard type. Wait for gas estimation to load. + expected: > + Stake dialog opens showing Balance X FOX on Arbitrum One. After typing 0.5, + Staking Details appear with Stake Amount, Lockup Period, Share of Pool, + and Approval Fee or Network Fee. Stake button becomes enabled. + screenshot: true + + - name: Stake FOX - submit and approve + instruction: Click the "Stake" button via JS dispatchEvent. An "Attention!" warning appears about the lock-up period. Click "Yes" to proceed. The confirm screen shows amount, fee, and "Approve" button (if first stake) or "Confirm & Stake". Click "Approve" if shown, wait for approval tx to confirm, then click "Confirm & Stake". + expected: Warning modal mentions 1 month lock-up period. After confirming, approval toast "Approving RFOX to use X FOX" appears in bottom-right. After approval confirms, toast updates to "Approved RFOX to use X FOX" with green checkmark. Button changes to "Confirm & Stake". After stake confirms, toast shows "You have successfully staked X FOX" with green checkmark. Modal returns to input screen. + screenshot: true + soft_fail: true + + - name: Verify staking balance increased + instruction: Close the Stake modal. Read the staking balance in [data-testid="rfox-section"]. + expected: Staking balance has increased by the staked amount (0.5 FOX). The balance update is visible on the main page. + screenshot: true + + - name: Unstake FOX - open modal and select 25% + instruction: Click [data-testid="rfox-unstake-button"] via JS dispatchEvent. Click the "25%" preset button. + expected: Unstake dialog opens at 100%, then changes to 25.00% after clicking preset. Amount shows approximately 25% of staked balance. Staking Details show Unstake Amount, Lockup Period (a month), Share of Pool, Gas Fee. Unstake button enabled. + screenshot: true + + - name: Unstake FOX - submit and confirm + instruction: Click the "Unstake" button. An "Attention!" warning appears about cooldown period. Click "I understand". On the Confirm screen, click "Confirm & Unstake". Wait for tx to process. + expected: Warning mentions cooldown period of a month. After confirmation, toast "Requesting withdraw of X FOX" appears (pending). After tx confirms, toast updates to "Once a month has elapsed you will be able to claim your X FOX" with green checkmark. Modal returns to input screen. Staking balance decreases. + screenshot: true + soft_fail: true + + - name: Check Action Center notifications + instruction: Click the notification bell button (aria-label="Pending Transactions") in the header. Read the notifications list. + expected: Action Center drawer opens showing "Notifications" header. Recent transactions listed with status badges - Confirmed for stake/unstake, Claim Available for ready unstaking requests, Claimed for completed claims. Each shows rFOX icon, description, and time ago. + screenshot: true + + - name: Close Action Center + instruction: Click the close button (aria-label="Close") on the Action Center drawer. + expected: Drawer closes, back to Fox Ecosystem page. + screenshot: false + + - name: Open Claim modal and verify requests + instruction: Click [data-testid="rfox-claim-button"] via JS dispatchEvent. + expected: Claim dialog opens with a list of unstaking requests. Each shows Claim FOX, cooldown status, and FOX amount. Cooling-down requests show "Claim available in X days" (yellow text). Ready requests show "Cooldown finished X ago" (green text) and are clickable. + screenshot: true + + - name: Claim a ready unstaking request + instruction: > + Check if any enabled claim request exists (one with "Cooldown finished" text and not disabled). + If no ready requests exist (all are still cooling down), skip this step. + If a ready request exists, find the smallest one and click it via JS dispatchEvent. + expected: > + Either no ready requests exist (all cooling down, step is skipped) OR + confirmation screen shows claim amount in FOX with USD value, receive address, + network fee in USD, and "Confirm & Claim" button. + screenshot: true + soft_fail: true + + - name: Confirm the claim transaction + instruction: > + If the previous step was skipped (no ready requests), skip this step too. + Otherwise click the "Confirm & Claim" button via JS dispatchEvent. Wait 5 seconds for transaction to process. + expected: > + Either skipped (no ready claim) OR transaction signs and submits. Returns to + claim list. The claimed request should no longer appear in the list. + NOTE - no success toast is shown for claims, this is a known UX inconsistency. + screenshot: true + soft_fail: true + + - name: Verify balance increased after claim + instruction: Close the Claim modal. Open the Stake modal via [data-testid="rfox-stake-button"] and read the available balance. + expected: Available FOX balance has increased by the claimed amount. + screenshot: true + + - name: Close Stake modal after balance check + instruction: Close the dialog via JS dispatchEvent. + expected: Dialog closes. + screenshot: false + + - name: Switch to WETH/FOX filter + instruction: Click [data-testid="fox-chain-filter-WETH/FOX"] via JS dispatchEvent. Scroll [data-testid="rfox-section"] into view. + expected: rFOX section switches to WETH/FOX view. Staking balance shows WETH/FOX amount. Stake button is hidden (LP sunset). Only Unstake and Claim buttons visible. + screenshot: true + + - name: Switch back to FOX filter + instruction: Click [data-testid="fox-chain-filter-FOX"] via JS dispatchEvent. + expected: rFOX section switches back to FOX view with all three buttons (Stake, Unstake, Claim). + screenshot: false + + - name: Test simulator deposit input + instruction: Focus [data-testid="rfox-deposit-input"] via JS focus() + select(), then type "50000" via keyboard type. + expected: Deposit amount shows 50,000 FOX. Share of Pool percentage increases from the default value. + screenshot: true + + - name: Test simulator revenue input + instruction: Focus [data-testid="rfox-revenue-input"] via JS focus() + select(), then type "500000" via keyboard type. + expected: Revenue shows $500,000. Estimated Rewards, Total Burn values update proportionally. + screenshot: true + + - name: Scroll to FOX Token section + instruction: Scroll [data-testid="fox-token-section"] into view using JS scrollIntoView({behavior:"smooth",block:"start"}). + expected: FOX Token section visible with current price, 24h change (green/red), market cap, 24hr volume. Buy [data-testid="fox-buy-button"] and Trade [data-testid="fox-trade-button"] buttons visible. Chain filter buttons (All, Ethereum, Arbitrum One, Optimism, Polygon, Gnosis) visible. + screenshot: true + + - name: Test Ethereum chain filter + instruction: Click [data-testid="fox-chain-filter-Ethereum"] via JS dispatchEvent. + expected: Token balance list filters to show only Ethereum FOX holdings. + screenshot: true + + - name: Test Arbitrum chain filter + instruction: Click [data-testid="fox-chain-filter-Arbitrum One"] via JS dispatchEvent. + expected: Token balance list filters to show only Arbitrum One FOX holdings. + screenshot: true + + - name: Reset chain filter to All + instruction: Click [data-testid="fox-chain-filter-All"] via JS dispatchEvent. + expected: All FOX balances across all chains shown in the list. + screenshot: false + + - name: Scroll to FOX Farming section + instruction: Scroll to the FOX Farming+ section using document.getElementById("farming").scrollIntoView(). Scroll up 200px to see full section. + expected: Farming section shows APY percentage, Total Claimable Rewards in FOX with Claim button [data-testid="fox-farming-claim-button"], Total Staking Value in WETH/FOX with Manage button [data-testid="fox-farming-manage-button"], Next Epoch countdown. + screenshot: true + + - name: Scroll to Governance section + instruction: Scroll to Governance using document.getElementById("governance").scrollIntoView(). + expected: Governance section shows total voting power in FOX, Active tab [data-testid="governance-tab-active"] selected by default, at least one proposal with title, description snippet, and vote counts (For/Against with progress bars). + screenshot: true + + - name: Test Governance Closed tab + instruction: Click [data-testid="governance-tab-closed"] via JS dispatchEvent. + expected: Closed tab shows completed proposals with final vote tallies, For/Against percentages and progress bars. + screenshot: true + + - name: Reset Governance to Active tab + instruction: Click [data-testid="governance-tab-active"] via JS dispatchEvent. + expected: Active tab selected again showing current proposals. + screenshot: false + + - name: Re-verify Action Center after all transactions + instruction: Click the notification bell button (aria-label="Pending Transactions"). Scroll through the notifications list. + expected: Action Center shows complete history - stake confirmation, unstake with cooldown info, approval confirmation, claim completions, and any "Claim Available" items for ready unstaking requests. All entries have correct status badges and timestamps. + screenshot: true + + - name: Close Action Center final + instruction: Click the close button on the Action Center drawer. + expected: Drawer closes cleanly. + screenshot: false diff --git a/e2e/fixtures/send-receive.yaml b/e2e/fixtures/send-receive.yaml new file mode 100644 index 00000000000..b8776f57d9a --- /dev/null +++ b/e2e/fixtures/send-receive.yaml @@ -0,0 +1,473 @@ +name: Send & Receive +description: > + Test send (self-send) and receive flows across all production-enabled chains. + Verifies address display, QR code, send form, broadcast, and tx history. + + CONSTRAINTS (CRITICAL - READ BEFORE EXECUTING): + - NEVER spend more than $2 USD per chain, absolute maximum + - EVM chains: $0.10 self-send + - UTXO chains (BTC, BCH, DOGE, LTC, ZEC): up to $2 self-send + - Other chains: $0.10-$1 depending on minimums + - ALWAYS use fiat input mode (toggle to $ before entering amount) + - ALWAYS self-send using "Your Wallets" account picker in the send modal address screen + - NEVER type or paste an address manually into the send address input + - NEVER send to any address other than your own + - If insufficient funds for a chain, DO NOT SKIP. Instead, swap $1 (ceiling) + from the highest-balance chain into the target chain's native asset first. + Use the /trade page to execute the swap, then return to the send flow. + Only skip if the swap itself fails or there's no chain with enough balance. + - Exclude Tron (too expensive) + + FUNDING VIA SWAP (when balance is insufficient): + - Navigate to /trade via hash route + - Set sell asset to the chain with highest USD balance (check balances first) + - Set buy asset to the target chain's native asset + - Toggle to fiat mode, enter $1 (ceiling - never swap more than $1 to fund) + - Preview and confirm the swap + - Wait for swap completion (up to 120s for cross-chain) + - Navigate back to the target chain's asset page and continue the send flow + - The swap step does NOT count as a fixture step - it's infrastructure. + Log it in the agentThought of the navigate step for the chain. + - For UTXO chains, broadcast confirmation = success (don't wait for on-chain confirm) + - Only test native asset per chain (ETH for Ethereum, BTC for Bitcoin, etc.) + + CHAIN DETECTION: + - Before running, detect which chains are enabled using the chain detection + procedure in SKILL.md (section 2). Only test chains that are enabled in the + target environment. Skip disabled chains. + - First-class chains (always enabled, no flag): Ethereum, Bitcoin, Bitcoin Cash, + Dogecoin, Litecoin, Cosmos Hub, THORChain, Avalanche + - Feature-flagged chains: check VITE_FEATURE_ in .env + .env.production + + NAVIGATION: + - NEVER use agent-browser `navigate` for in-app page changes - it causes full page reload + and disconnects the wallet, requiring re-authentication + - Use JS eval to change hash route: eval "window.location.hash = '/assets/'" + - This preserves wallet connection and app state + + SEND MODAL INTERACTIONS: + - The send modal is a Chakra UI modal with focus trap + - "Your Wallets" account rows are HStack divs with onClick, NOT buttons + - To click Account #0, find the chakra-stack inside the modal body that contains + "Account #0" text but NOT "Account #1", and dispatchEvent on it: + var modal = document.querySelector("[class*=modal__body]"); + var stacks = modal.querySelectorAll("[class*=chakra-stack]"); + for (var i=0; i ` separator for dashboard grouping: + "{chain_name} > {step_template_name}" (e.g. "Ethereum > Open send modal") + - Total steps = wallet-health deps + (chains * steps_per_chain) + - If a chain fails, log the failure and CONTINUE to the next chain. + +route: /trade +depends_on: + - wallet-health.yaml + +# ============================================================================ +# CHAIN DEFINITIONS +# ============================================================================ +# Each chain entry: name, symbol, assetId, type (evm|utxo|cosmos|other), +# sendAmountUsd, flag (feature flag name, omit for always-enabled chains) +# +# The agent should: +# 1. Run chain detection (SKILL.md section 2) to get ENABLED_FLAGS +# 2. Include chains with no `flag` field (always enabled) +# 3. Include chains whose `flag` is in ENABLED_FLAGS +# 4. Skip chains whose `flag` is NOT in ENABLED_FLAGS +# 5. Always skip Tron regardless of flag status + +chains: + # --- First-class (always enabled, no feature flag) --- + - name: Ethereum + symbol: ETH + assetId: eip155:1/slip44:60 + type: evm + sendAmountUsd: "0.1" + + - name: Bitcoin + symbol: BTC + assetId: bip122:000000000019d6689c085ae165831e93/slip44:0 + type: utxo + sendAmountUsd: "2" + + - name: Bitcoin Cash + symbol: BCH + assetId: bip122:000000000000000000651ef99cb9fcbe/slip44:145 + type: utxo + sendAmountUsd: "2" + + - name: Dogecoin + symbol: DOGE + assetId: bip122:00000000001a91e3dace36e2be3bf030/slip44:3 + type: utxo + sendAmountUsd: "2" + + - name: Litecoin + symbol: LTC + assetId: bip122:12a765e31ffd4059bada1e25190f6e98/slip44:2 + type: utxo + sendAmountUsd: "2" + + - name: Cosmos Hub + symbol: ATOM + assetId: cosmos:cosmoshub-4/slip44:118 + type: cosmos + sendAmountUsd: "0.1" + + - name: THORChain + symbol: RUNE + assetId: cosmos:thorchain-1/slip44:931 + type: cosmos + sendAmountUsd: "0.1" + + - name: Avalanche + symbol: AVAX + assetId: eip155:43114/slip44:60 + type: evm + sendAmountUsd: "0.1" + + # --- Feature-flagged (check VITE_FEATURE_ in env) --- + - name: Optimism + symbol: ETH + assetId: eip155:10/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: OPTIMISM + + - name: BNB Smart Chain + symbol: BNB + assetId: eip155:56/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: BNBSMARTCHAIN + + - name: Polygon + symbol: POL + assetId: eip155:137/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: POLYGON + + - name: Gnosis + symbol: xDAI + assetId: eip155:100/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: GNOSIS + + - name: Arbitrum + symbol: ETH + assetId: eip155:42161/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: ARBITRUM + + - name: Base + symbol: ETH + assetId: eip155:8453/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: BASE + + - name: Solana + symbol: SOL + assetId: solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501 + type: other + sendAmountUsd: "0.1" + flag: SOLANA + + - name: Mayachain + symbol: CACAO + assetId: cosmos:mayachain-mainnet-v1/slip44:931 + type: cosmos + sendAmountUsd: "0.1" + flag: MAYACHAIN + + - name: Zcash + symbol: ZEC + assetId: bip122:00040fe8ec8471911baa1db1266ea15d/slip44:133 + type: utxo + sendAmountUsd: "2" + flag: ZCASH + + - name: SUI + symbol: SUI + assetId: sui:35834a8a/slip44:784 + type: other + sendAmountUsd: "0.1" + flag: SUI + + - name: TON + symbol: TON + assetId: ton:mainnet/slip44:607 + type: other + sendAmountUsd: "0.1" + flag: TON + + - name: NEAR + symbol: NEAR + assetId: near:mainnet/slip44:397 + type: other + sendAmountUsd: "0.1" + flag: NEAR + + - name: Starknet + symbol: STRK + assetId: starknet:SN_MAIN/slip44:9004 + type: other + sendAmountUsd: "0.1" + flag: STARKNET + + - name: Monad + symbol: MON + assetId: eip155:143/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: MONAD + + - name: HyperEVM + symbol: HYPE + assetId: eip155:999/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: HYPEREVM + + - name: Plasma + symbol: pETH + assetId: eip155:9745/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: PLASMA + + - name: Katana + symbol: KAN + assetId: eip155:747474/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: KATANA + + - name: Mantle + symbol: MNT + assetId: eip155:5000/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: MANTLE + + - name: Ink + symbol: ETH + assetId: eip155:57073/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: INK + + - name: MegaETH + symbol: ETH + assetId: eip155:4326/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: MEGAETH + + - name: Berachain + symbol: BERA + assetId: eip155:80094/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: BERACHAIN + + - name: Cronos + symbol: CRO + assetId: eip155:25/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: CRONOS + + - name: Sonic + symbol: S + assetId: eip155:146/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: SONIC + + - name: Linea + symbol: ETH + assetId: eip155:59144/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: LINEA + + - name: Scroll + symbol: ETH + assetId: eip155:534352/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: SCROLL + + - name: Blast + symbol: ETH + assetId: eip155:81457/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: BLAST + + - name: ZkSync Era + symbol: ETH + assetId: eip155:324/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: ZK_SYNC_ERA + + - name: Hemi + symbol: ETH + assetId: eip155:43111/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: HEMI + + - name: Sei + symbol: SEI + assetId: eip155:1329/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: SEI + + - name: Story + symbol: IP + assetId: eip155:1514/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: STORY + + - name: WorldChain + symbol: WLD + assetId: eip155:480/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: WORLDCHAIN + + - name: Plume + symbol: ETH + assetId: eip155:98866/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: PLUME + + - name: Unichain + symbol: ETH + assetId: eip155:130/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: UNICHAIN + + - name: BOB + symbol: ETH + assetId: eip155:60808/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: BOB + + - name: Mode + symbol: ETH + assetId: eip155:34443/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: MODE + + - name: Soneium + symbol: ETH + assetId: eip155:1868/slip44:60 + type: evm + sendAmountUsd: "0.1" + flag: SONEIUM + + # EXCLUDED: Tron (too expensive for self-send testing) + # EXCLUDED: Ethereal, FlowEvm, Celo (.env.production disables them) + +# ============================================================================ +# STEP TEMPLATES +# ============================================================================ +# The agent repeats these steps for EACH enabled chain. +# Replace {name}, {symbol}, {assetId}, {sendAmountUsd} with chain values. +# For UTXO chains, broadcast = success (skip "verify transaction confirmed" wait). + +step_template: + - name: "{name} > Navigate to asset page" + instruction: > + Use JS eval to navigate to the {name} ({symbol}) asset page: + eval "window.location.hash = '/assets/{assetId}'" + Wait 3 seconds for page to load. + Verify the asset page shows the chain name, balance, and action buttons + (Trade, Send, Receive). If the asset page doesn't load or shows + "Asset not found", mark as failed. + CHECK BALANCE: If the balance shows $0.00 or "No balance", skip ALL remaining + steps for this chain (mark as skipped with "insufficient funds"). + expected: Asset page loaded with balance > $0, Send and Receive buttons visible + screenshot: true + + - name: "{name} > Test receive" + instruction: > + Click "Receive" button via JS eval. Wait 2 seconds for modal. + Verify the receive modal shows: QR code, address, Copy button. + Record the address format (0x for EVM, bc1/1/3 for BTC, etc.). + Close the modal by pressing Escape. + expected: Receive modal shows address and QR code. Modal closed. + screenshot: true + + - name: "{name} > Open send modal" + instruction: > + Click "Send" button via JS eval. Wait 2 seconds for the send modal. + The modal should show address input and "Your Wallets" section + listing your accounts. + expected: Send modal open with "Your Wallets" showing Account #0 + screenshot: true + + - name: "{name} > Select own account" + instruction: > + Click Account #0 in "Your Wallets" using the JS pattern from the + fixture description (querySelectorAll chakra-stack, match "Account #0" + excluding "Account #1", dispatchEvent MouseEvent click). + CRITICAL: Do NOT type any address. Do NOT use .closest("button"). + After clicking, modal should advance to amount entry. + expected: Amount entry screen showing destination "Account #0" + screenshot: true + + - name: "{name} > Enter amount and confirm" + instruction: > + 1. Toggle to fiat mode if not already (click the "$0.00" button) + 2. Enter {sendAmountUsd} using nativeInputValueSetter pattern + 3. Wait for Preview button to become enabled (not "Loading...") + 4. Click Preview. Wait for confirm screen. + 5. Verify amount, destination (Account #0), chain name. + 6. Click Confirm. Wait for broadcast toast ("Sending..."). + UTXO note: For UTXO chains, broadcast = success. Don't wait for confirm. + CONSTRAINT: Never exceed the sendAmountUsd for this chain. + expected: Transaction broadcast confirmed via toast notification + screenshot: true + + - name: "{name} > Verify send result" + instruction: > + Wait up to 60s for completion toast ("successfully sent"). + For UTXO chains, broadcast toast is sufficient - don't wait for on-chain. + Dismiss any feedback dialog ("Maybe Later"). + Navigate back to asset page and check Transaction History for the send. + expected: Send transaction visible in history or confirmed via toast + screenshot: true diff --git a/e2e/fixtures/thorchain-solana-swapper.yaml b/e2e/fixtures/thorchain-solana-swapper.yaml new file mode 100644 index 00000000000..27b6b5a9b13 --- /dev/null +++ b/e2e/fixtures/thorchain-solana-swapper.yaml @@ -0,0 +1,187 @@ +name: THORChain Solana Swapper +description: Test THORChain Solana swapper integration - SOL to RUNE and RUNE to SOL swaps, specifically selecting THORChain quotes +route: /trade +depends_on: + - wallet-health.yaml +steps: + - name: Dismiss stale notifications + instruction: > + Before starting, dismiss any lingering notifications from previous swaps. + Close any toast notifications, feedback dialogs ("How was your trade experience?"), + or pending transaction banners by clicking their Close/X buttons. + expected: No stale notifications visible + screenshot: true + + - name: Select SOL as sell asset + instruction: > + Click the sell asset selector button. On external origins, use JS eval to click + the sell asset avatar button (find button with text matching current sell asset + img child). + In the asset picker dialog, focus the search input via JS eval, then type "SOL" char by char. + Click "Solana (SOL)" from the results (SOL is a primary asset, no multi-chain expansion needed). + Wait for the dialog to close and SOL to appear as the sell asset. + expected: SOL is selected as the sell asset + screenshot: true + + - name: Select RUNE as buy asset + instruction: > + Click the buy/receive asset selector button (using JS eval on external origins). + In the asset picker dialog, focus the search input via JS eval, then type "RUNE" char by char. + Click "THORChain (RUNE)" from the results (RUNE is a primary asset, no multi-chain expansion needed). + Wait for the dialog to close and RUNE to appear as the buy asset. + expected: RUNE is selected as the buy asset + screenshot: true + + - name: Toggle to fiat input + instruction: > + Click the "≈ $0.00" button below the sell amount to toggle to fiat/USD input mode. + If already in fiat mode (placeholder shows "$0"), skip this step. + expected: The input field is in fiat mode (placeholder shows $0, balance shows $ amount) + screenshot: true + + - name: Enter $1 swap amount + instruction: > + Click the sell amount textbox and type 1 character by character using press + (NOT fill - React controlled inputs need keypress events). + Wait a moment for the value to register. + expected: $1 is entered in the sell field + screenshot: true + + - name: Wait for quotes and select THORChain + instruction: > + Wait up to 15 seconds for swap quotes to appear. + Look for a quotes list or rate display. There may be multiple swapper options. + Specifically look for the THORChain quote - it will have the THORChain logo + (a green/teal runic symbol). Click on the THORChain quote to select it. + If no quote appears at $1, clear the input, type 2 (for $2), and wait again. + If still no quote at $2, try 3, then 5, then 10. Stop at $10 if no quote. + The "Preview Trade" button should become enabled (not disabled). + expected: THORChain quote is selected, Preview Trade button enabled + screenshot: true + + - name: Preview trade (SOL to RUNE) + instruction: > + Click the "Preview Trade" button. + A warning dialog may appear saying "This amount is below the recommended minimum". + If so, click "I understand" to proceed. + Wait for the "Confirm Details" screen to appear showing swap details + (sell amount, receive amount, swapper, fees). + Verify the swapper shown is THORChain. + expected: Confirm Details screen visible with THORChain as swapper and "Confirm and Trade" button + screenshot: true + + - name: Confirm and Trade (SOL to RUNE) + instruction: > + Click the "Confirm and Trade" button. + A price impact warning may appear ("This trade is impacted by price movement X%"). + If so, click "I understand" to proceed. + This transitions to the Trade execution screen. + A "Sign & Swap" button will appear, initially in a loading/disabled state. + Wait up to 30 seconds for "Sign & Swap" to become enabled (no longer loading/disabled). + expected: Sign & Swap button is visible and enabled (not loading, not disabled) + screenshot: true + + - name: Sign and Swap (SOL to RUNE) + instruction: > + Click the "Sign & Swap" button to sign and submit the transaction. + The native wallet will sign the transaction automatically. + After clicking, the transaction is submitted on-chain. + expected: Transaction submitted, swap execution in progress + screenshot: true + + - name: Wait for SOL to RUNE completion + instruction: > + Wait for the swap to complete. The page will show "Awaiting swap" with a progress bar. + When complete, it transitions back to the main trade page. + A feedback dialog ("How was your trade experience?") may appear - dismiss it by clicking "Maybe Later" or "Close". + Wait up to 120 seconds, checking every 5 seconds. + Look for: the trade page reappearing, a feedback dialog, success notification, or the + "Awaiting swap" text disappearing. + expected: SOL to RUNE swap completed - back on trade page, no more "Awaiting swap" + screenshot: true + + - name: Clean up after SOL to RUNE + instruction: > + Dismiss any remaining notifications or dialogs (feedback dialog, success toast, etc.) + by clicking Close/X/Maybe Later buttons. This leaves a clean state for the next swap. + expected: Trade page is clean with no overlaying notifications or dialogs + screenshot: true + + - name: Switch to RUNE sell / SOL buy + instruction: > + The page should still have RUNE as buy and SOL as sell from the first swap. + Click the "Switch Assets" button (between sell and buy sections) to reverse them. + Use JS eval on external origins: find the button with text "Switch Assets" and click it. + After switching, RUNE should be the sell asset and SOL should be the buy asset. + If Switch Assets doesn't work, manually select RUNE as sell and SOL as buy via the asset pickers. + expected: RUNE is the sell asset, SOL is the buy asset + screenshot: true + + - name: Toggle to fiat input for RUNE + instruction: > + Click the "≈ $0.00" button below the sell amount to toggle to fiat/USD input mode. + If already in fiat mode (placeholder shows "$0"), skip this step. + expected: The input field is in fiat mode + screenshot: true + + - name: Enter $1 RUNE swap amount + instruction: > + Click the sell amount textbox and type 1 character by character using press + (NOT fill - React controlled inputs need keypress events). + Wait a moment for the value to register. + expected: $1 is entered in the sell field + screenshot: true + + - name: Wait for quotes and select THORChain (RUNE to SOL) + instruction: > + Wait up to 15 seconds for swap quotes to appear. + Look for a quotes list or rate display. There may be multiple swapper options. + Specifically look for the THORChain quote - it will have the THORChain logo + (a green/teal runic symbol). Click on the THORChain quote to select it. + If no quote appears at $1, clear the input, type 2 (for $2), and wait again. + If still no quote at $2, try 3, then 5, then 10. Stop at $10 if no quote. + The "Preview Trade" button should become enabled (not disabled). + expected: THORChain quote is selected, Preview Trade button enabled + screenshot: true + + - name: Preview trade (RUNE to SOL) + instruction: > + Click the "Preview Trade" button. + A warning dialog may appear about below-minimum amount. Click "I understand" if so. + Wait for the "Confirm Details" screen to appear showing swap details. + Verify the swapper shown is THORChain. + expected: Confirm Details screen visible with THORChain as swapper and "Confirm and Trade" button + screenshot: true + + - name: Confirm and Trade (RUNE to SOL) + instruction: > + Click the "Confirm and Trade" button. + A price impact warning may appear. Click "I understand" if so. + This transitions to the Trade execution screen. + A "Sign & Swap" button will appear, initially in a loading/disabled state. + Wait up to 30 seconds for "Sign & Swap" to become enabled. + expected: Sign & Swap button is visible and enabled + screenshot: true + + - name: Sign and Swap (RUNE to SOL) + instruction: > + Click the "Sign & Swap" button to sign and submit the transaction. + The native wallet will sign the transaction automatically. + After clicking, the transaction is submitted on-chain. + expected: Transaction submitted, swap execution in progress + screenshot: true + + - name: Wait for RUNE to SOL completion + instruction: > + Wait for the swap to complete. The page will show "Awaiting swap" with a progress bar. + When complete, it transitions back to the main trade page. + A feedback dialog may appear - dismiss it by clicking "Maybe Later" or "Close". + Wait up to 120 seconds, checking every 5 seconds. + expected: RUNE to SOL swap completed - back on trade page, no more "Awaiting swap" + screenshot: true + + - name: Clean up notifications + instruction: > + After confirming both swaps completed, dismiss any remaining notifications or dialogs + by clicking Close/X/Maybe Later buttons. + expected: Trade page is clean with no overlaying notifications or dialogs + screenshot: true diff --git a/e2e/fixtures/wallet-health.yaml b/e2e/fixtures/wallet-health.yaml new file mode 100644 index 00000000000..31b3d8eb41c --- /dev/null +++ b/e2e/fixtures/wallet-health.yaml @@ -0,0 +1,51 @@ +name: Wallet Health +description: Dismiss onboarding, unlock native wallet, verify trade page loads with swap inputs and button +route: /trade +steps: + - name: Dismiss onboarding and banners + instruction: > + After opening the page, check if an onboarding/splash dialog appears + (e.g. "ShapeShift Wallet" with "Skip" and "Next" buttons, mentioning "Self-Custody"). + If it appears, click the "Skip" button to dismiss it. + If no onboarding dialog appears (you see the password prompt or the trade page directly), skip this step. + Also check for a cookie/tracking banner at the bottom of the page ("Our dApp uses + anonymized click tracking...") with a "Got It" button. If present, click "Got It" to dismiss it. + This banner can block interactions with other elements if left open. + expected: The onboarding dialog is dismissed (or was never shown), no cookie banner visible + screenshot: true + - name: Fill wallet password + instruction: > + Wait for the "Enter Your Password" dialog to appear. + Click on the native wallet button (e.g. "teest" or "test") to select it if not already selected. + Once the password input and "Next" button appear, focus the password input using JS eval + (click --ref often times out on external origins): + eval "document.querySelector('input[type=password], input[placeholder*=Password]')?.focus()" + Then type $NATIVE_WALLET_PASSWORD character by character using press + (agent-browser fill does not trigger React onChange - use press for each char). + expected: Password field is filled (shows masked dots), Next button is enabled and clickable + screenshot: true + - name: Unlock wallet + instruction: > + Click the "Next" button to submit the password. On external origins where click --ref + may time out, use JS eval: + eval "Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Next')?.click()" + Wait for the wallet to fully unlock and the app to load (no more password dialog). + Wait at least 8 seconds for external origins to fully hydrate. + expected: The wallet is unlocked and the app shows the main interface (no password prompt visible) + screenshot: true + - name: Page loads + instruction: Verify the trade page has fully loaded with the swap interface visible + expected: The trade page is visible with a swap interface (Swap heading, asset selectors) + screenshot: true + - name: Sell input visible + instruction: Look for the sell/from asset input field + expected: A sell asset input field is visible with an asset selector + screenshot: true + - name: Buy input visible + instruction: Look for the buy/to asset input field + expected: A buy asset input field is visible with an asset selector + screenshot: true + - name: Swap button exists + instruction: Look for the swap/trade action button + expected: A swap or trade button is present on the page + screenshot: true diff --git a/e2e/screenshots/.gitignore b/e2e/screenshots/.gitignore new file mode 100644 index 00000000000..d6b7ef32c84 --- /dev/null +++ b/e2e/screenshots/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/e2e/screenshots/evm_chains_batch/blast.png b/e2e/screenshots/evm_chains_batch/blast.png deleted file mode 100644 index 0d25a987b14..00000000000 Binary files a/e2e/screenshots/evm_chains_batch/blast.png and /dev/null differ diff --git a/e2e/screenshots/evm_chains_batch/bob.png b/e2e/screenshots/evm_chains_batch/bob.png deleted file mode 100644 index 176e6ccf33a..00000000000 Binary files a/e2e/screenshots/evm_chains_batch/bob.png and /dev/null differ diff --git a/e2e/screenshots/evm_chains_batch/celo.png b/e2e/screenshots/evm_chains_batch/celo.png deleted file mode 100644 index b12851a167f..00000000000 Binary files a/e2e/screenshots/evm_chains_batch/celo.png and /dev/null differ diff --git a/e2e/screenshots/evm_chains_batch/cronos.png b/e2e/screenshots/evm_chains_batch/cronos.png deleted file mode 100644 index 830e09262ac..00000000000 Binary files a/e2e/screenshots/evm_chains_batch/cronos.png and /dev/null differ diff --git a/e2e/screenshots/evm_chains_batch/ethereal.png b/e2e/screenshots/evm_chains_batch/ethereal.png deleted file mode 100644 index 354b2a02d3f..00000000000 Binary files a/e2e/screenshots/evm_chains_batch/ethereal.png and /dev/null differ diff --git a/e2e/screenshots/evm_chains_batch/flowevm.png b/e2e/screenshots/evm_chains_batch/flowevm.png deleted file mode 100644 index be6ac6cafc5..00000000000 Binary files a/e2e/screenshots/evm_chains_batch/flowevm.png and /dev/null differ diff --git a/e2e/screenshots/evm_chains_batch/hemi.png b/e2e/screenshots/evm_chains_batch/hemi.png deleted file mode 100644 index 3e59d674efa..00000000000 Binary files a/e2e/screenshots/evm_chains_batch/hemi.png and /dev/null differ diff --git a/e2e/screenshots/evm_chains_batch/mantle.png b/e2e/screenshots/evm_chains_batch/mantle.png deleted file mode 100644 index 7581e1dd236..00000000000 Binary files a/e2e/screenshots/evm_chains_batch/mantle.png and /dev/null differ diff --git a/e2e/screenshots/evm_chains_batch/mode.png b/e2e/screenshots/evm_chains_batch/mode.png deleted file mode 100644 index af17ec26685..00000000000 Binary files a/e2e/screenshots/evm_chains_batch/mode.png and /dev/null differ diff --git a/e2e/screenshots/evm_chains_batch/plume.png b/e2e/screenshots/evm_chains_batch/plume.png deleted file mode 100644 index ed110a8d32f..00000000000 Binary files a/e2e/screenshots/evm_chains_batch/plume.png and /dev/null differ diff --git a/e2e/screenshots/evm_chains_batch/soneium.png b/e2e/screenshots/evm_chains_batch/soneium.png deleted file mode 100644 index f736fdda2e6..00000000000 Binary files a/e2e/screenshots/evm_chains_batch/soneium.png and /dev/null differ diff --git a/e2e/screenshots/evm_chains_batch/sonic.png b/e2e/screenshots/evm_chains_batch/sonic.png deleted file mode 100644 index f5d581e3660..00000000000 Binary files a/e2e/screenshots/evm_chains_batch/sonic.png and /dev/null differ diff --git a/e2e/screenshots/evm_chains_batch/story.png b/e2e/screenshots/evm_chains_batch/story.png deleted file mode 100644 index 507e129cab6..00000000000 Binary files a/e2e/screenshots/evm_chains_batch/story.png and /dev/null differ diff --git a/e2e/screenshots/evm_chains_batch/unichain.png b/e2e/screenshots/evm_chains_batch/unichain.png deleted file mode 100644 index aeb8149fd21..00000000000 Binary files a/e2e/screenshots/evm_chains_batch/unichain.png and /dev/null differ diff --git a/e2e/screenshots/evm_chains_batch/worldchain.png b/e2e/screenshots/evm_chains_batch/worldchain.png deleted file mode 100644 index 035e3771862..00000000000 Binary files a/e2e/screenshots/evm_chains_batch/worldchain.png and /dev/null differ diff --git a/e2e/screenshots/evm_chains_batch/zksync.png b/e2e/screenshots/evm_chains_batch/zksync.png deleted file mode 100644 index 4abdf95c41f..00000000000 Binary files a/e2e/screenshots/evm_chains_batch/zksync.png and /dev/null differ diff --git a/packages/hdwallet-core/src/cosmos.ts b/packages/hdwallet-core/src/cosmos.ts index eb9ea4bd0d6..61ac7259d88 100644 --- a/packages/hdwallet-core/src/cosmos.ts +++ b/packages/hdwallet-core/src/cosmos.ts @@ -69,6 +69,29 @@ export interface CosmosSignedTx { signatures: string[] } +export interface CosmosSignAminoDoc { + addressNList: BIP32Path + signDoc: { + chain_id: string + account_number: string + sequence: string + fee: Cosmos.StdFee + msgs: Cosmos.Msg[] + memo: string + } +} + +export interface CosmosSignedAmino { + signed: CosmosSignAminoDoc['signDoc'] + signature: { + pub_key: { + type: string + value: string + } + signature: string + } +} + export interface CosmosGetAccountPaths { accountIdx: number } @@ -97,6 +120,7 @@ export interface CosmosWallet extends CosmosWalletInfo, HDWallet { cosmosGetAddress(msg: CosmosGetAddress): Promise cosmosSignTx(msg: CosmosSignTx): Promise + cosmosSignAmino?(msg: CosmosSignAminoDoc): Promise } export function cosmosDescribePath(path: BIP32Path): PathDescription { diff --git a/packages/hdwallet-native/src/cosmos.ts b/packages/hdwallet-native/src/cosmos.ts index 2fdabd0397b..280b78a18e4 100644 --- a/packages/hdwallet-native/src/cosmos.ts +++ b/packages/hdwallet-native/src/cosmos.ts @@ -1,4 +1,5 @@ -import type { StdTx } from '@cosmjs/amino' +import type { StdSignDoc, StdTx } from '@cosmjs/amino' +import { serializeSignDoc } from '@cosmjs/amino' import type { SignerData } from '@cosmjs/stargate' import * as core from '@shapeshiftoss/hdwallet-core' import * as bech32 from 'bech32' @@ -106,5 +107,26 @@ export function MixinNativeCosmosWallet { + return this.needsMnemonic(!!this.#masterKey, async () => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const keyPair = await util.getKeyPair(this.#masterKey!, msg.addressNList, 'cosmos') + const pubkey = await keyPair.node.getPublicKey() + const signBytes = serializeSignDoc(msg.signDoc as StdSignDoc) + const signatureBytes = await keyPair.node.ecdsaSign('sha256', signBytes) + + return { + signed: msg.signDoc, + signature: { + pub_key: { + type: 'tendermint/PubKeySecp256k1', + value: Buffer.from(pubkey).toString('base64'), + }, + signature: Buffer.from(signatureBytes).toString('base64'), + }, + } + }) + } } } diff --git a/packages/public-api/.env.example b/packages/public-api/.env.example index d800eaf6015..53231f87eca 100644 --- a/packages/public-api/.env.example +++ b/packages/public-api/.env.example @@ -2,6 +2,7 @@ PORT=3001 HOST=0.0.0.0 NODE_ENV=production +TRUST_PROXY=1 # Asset Data Path (optional - will auto-detect if not set) # ASSET_DATA_PATH=/app/public/generated/generatedAssetData.json @@ -16,6 +17,12 @@ NODE_ENV=production # UNCHAINED_ETHEREUM_HTTP_URL=https://api.ethereum.shapeshift.com # UNCHAINED_THORCHAIN_HTTP_URL=https://api.thorchain.shapeshift.com +# Rate Limiting (requests per minute per IP, defaults shown) +# RATE_LIMIT_GLOBAL_MAX=300 +# RATE_LIMIT_DATA_MAX=120 +# RATE_LIMIT_SWAP_RATES_MAX=60 +# RATE_LIMIT_SWAP_QUOTE_MAX=45 + # Partner API Keys (add your partner keys here) # Format: API_KEY_=:: # Example: API_KEY_PARTNER1=abc123:MyPartner:50 diff --git a/packages/public-api/package.json b/packages/public-api/package.json index ac2a8915431..e290bfbeb35 100644 --- a/packages/public-api/package.json +++ b/packages/public-api/package.json @@ -31,6 +31,7 @@ "@types/swagger-ui-express": "^4.1.8", "cors": "^2.8.5", "express": "^4.21.0", + "express-rate-limit": "^7.5.0", "uuid": "^9.0.0", "yaml": "^2.8.2", "zod": "3.23.8" diff --git a/packages/public-api/src/assets.ts b/packages/public-api/src/assets.ts index 643f8b1711b..25deac0368c 100644 --- a/packages/public-api/src/assets.ts +++ b/packages/public-api/src/assets.ts @@ -47,13 +47,22 @@ export const initAssets = (): Promise => { for (const assetId of sortedAssetIds) { const asset = localAssetData[assetId] if (asset) { - const baseAsset = getBaseAsset(asset.chainId) - enrichedAssetsById[assetId] = { - ...asset, - networkName: baseAsset?.networkName, - explorer: baseAsset?.explorer, - explorerAddressLink: baseAsset?.explorerAddressLink, - explorerTxLink: baseAsset?.explorerTxLink, + try { + const baseAsset = getBaseAsset(asset.chainId) + enrichedAssetsById[assetId] = { + ...asset, + networkName: baseAsset?.networkName, + explorer: baseAsset?.explorer, + explorerAddressLink: baseAsset?.explorerAddressLink, + explorerTxLink: baseAsset?.explorerTxLink, + } + } catch (error) { + console.warn('Failed to enrich asset with base chain data', { + assetId, + chainId: asset.chainId, + error, + }) + enrichedAssetsById[assetId] = asset } } } diff --git a/packages/public-api/src/docs/openapi.ts b/packages/public-api/src/docs/openapi.ts index 330f9f5d6be..22aa369ac05 100644 --- a/packages/public-api/src/docs/openapi.ts +++ b/packages/public-api/src/docs/openapi.ts @@ -3,6 +3,7 @@ import '../setupZod' import { OpenApiGeneratorV3, OpenAPIRegistry } from '@asteasolutions/zod-to-openapi' import { z } from 'zod' +import { RateLimitErrorCode } from '../middleware/rateLimit' import { AssetRequestSchema, AssetsListRequestSchema } from '../routes/assets' import { QuoteRequestSchema } from '../routes/quote' import { RatesRequestSchema } from '../routes/rates' @@ -206,6 +207,43 @@ const RateResponseSchema = registry.register( }), ) +const RateLimitErrorSchema = registry.register( + 'RateLimitError', + z.object({ + error: z.string().openapi({ example: 'Too many requests, please try again later' }), + code: z + .nativeEnum(RateLimitErrorCode) + .openapi({ example: RateLimitErrorCode.RateLimitExceeded }), + }), +) + +const rateLimitResponse = { + description: 'Rate limit exceeded. Includes Retry-After header with seconds until reset.', + content: { + 'application/json': { + schema: RateLimitErrorSchema, + }, + }, + headers: { + 'Retry-After': { + description: 'Seconds until the rate limit window resets', + schema: { type: 'integer' as const, example: 30 }, + }, + 'RateLimit-Limit': { + description: 'Maximum requests allowed per window', + schema: { type: 'integer' as const, example: 60 }, + }, + 'RateLimit-Remaining': { + description: 'Requests remaining in the current window', + schema: { type: 'integer' as const, example: 0 }, + }, + 'RateLimit-Reset': { + description: 'Seconds until the rate limit window resets', + schema: { type: 'integer' as const, example: 30 }, + }, + }, +} + // --- Paths --- registry.registerPath({ @@ -226,6 +264,7 @@ registry.registerPath({ }, }, }, + 429: rateLimitResponse, }, }) @@ -247,6 +286,7 @@ registry.registerPath({ }, }, }, + 429: rateLimitResponse, }, }) @@ -272,6 +312,7 @@ registry.registerPath({ }, }, }, + 429: rateLimitResponse, }, }) @@ -297,6 +338,7 @@ registry.registerPath({ 404: { description: 'Asset not found', }, + 429: rateLimitResponse, }, }) @@ -337,6 +379,7 @@ registry.registerPath({ 400: { description: 'Invalid request', }, + 429: rateLimitResponse, }, }) @@ -370,6 +413,7 @@ registry.registerPath({ 400: { description: 'Invalid request or unavailable swapper', }, + 429: rateLimitResponse, }, }) diff --git a/packages/public-api/src/index.ts b/packages/public-api/src/index.ts index a60a3c0fdea..7735b063a84 100644 --- a/packages/public-api/src/index.ts +++ b/packages/public-api/src/index.ts @@ -6,6 +6,12 @@ import express from 'express' import { initAssets } from './assets' import { API_HOST, API_PORT } from './config' import { affiliateAddress } from './middleware/auth' +import { + dataLimiter, + globalLimiter, + swapQuoteLimiter, + swapRatesLimiter, +} from './middleware/rateLimit' import { getAssetById, getAssetCount, getAssets } from './routes/assets' import { getChainCount, getChains } from './routes/chains' import { docsRouter } from './routes/docs' @@ -14,9 +20,12 @@ import { getRates } from './routes/rates' const app = express() +app.set('trust proxy', process.env.TRUST_PROXY === '1' ? 1 : false) + // Middleware app.use(cors()) app.use(express.json()) +app.use(globalLimiter) // Root endpoint - API info app.get('/', (_req, res) => { @@ -47,17 +56,17 @@ app.get('/health', (_req, res) => { const v1Router = express.Router() // Swap endpoints (optional affiliate address tracking) -v1Router.get('/swap/rates', affiliateAddress, getRates) -v1Router.post('/swap/quote', affiliateAddress, getQuote) +v1Router.get('/swap/rates', swapRatesLimiter, affiliateAddress, getRates) +v1Router.post('/swap/quote', swapQuoteLimiter, affiliateAddress, getQuote) // Chain endpoints -v1Router.get('/chains', getChains) -v1Router.get('/chains/count', getChainCount) +v1Router.get('/chains', dataLimiter, getChains) +v1Router.get('/chains/count', dataLimiter, getChainCount) // Asset endpoints -v1Router.get('/assets', getAssets) -v1Router.get('/assets/count', getAssetCount) -v1Router.get('/assets/:assetId(*)', getAssetById) +v1Router.get('/assets', dataLimiter, getAssets) +v1Router.get('/assets/count', dataLimiter, getAssetCount) +v1Router.get('/assets/:assetId(*)', dataLimiter, getAssetById) app.use('/v1', v1Router) app.use('/docs', docsRouter) diff --git a/packages/public-api/src/middleware/rateLimit.ts b/packages/public-api/src/middleware/rateLimit.ts new file mode 100644 index 00000000000..55fd88ca761 --- /dev/null +++ b/packages/public-api/src/middleware/rateLimit.ts @@ -0,0 +1,36 @@ +import type { Options, RateLimitRequestHandler } from 'express-rate-limit' +import rateLimit from 'express-rate-limit' + +const WINDOW_MS = 60 * 1000 + +export enum RateLimitErrorCode { + RateLimitExceeded = 'RATE_LIMIT_EXCEEDED', +} + +const parseEnvInt = (key: string, defaultValue: number): number => { + const value = process.env[key] + if (!value) return defaultValue + const parsed = parseInt(value, 10) + return isNaN(parsed) ? defaultValue : parsed +} + +const rateLimitHandler: Options['handler'] = (_req, res) => { + res.status(429).json({ + error: 'Too many requests, please try again later', + code: RateLimitErrorCode.RateLimitExceeded, + }) +} + +const createLimiter = (envKey: string, defaultMax: number): RateLimitRequestHandler => + rateLimit({ + windowMs: WINDOW_MS, + max: parseEnvInt(envKey, defaultMax), + standardHeaders: 'draft-7', + legacyHeaders: false, + handler: rateLimitHandler, + }) + +export const globalLimiter = createLimiter('RATE_LIMIT_GLOBAL_MAX', 300) +export const dataLimiter = createLimiter('RATE_LIMIT_DATA_MAX', 120) +export const swapRatesLimiter = createLimiter('RATE_LIMIT_SWAP_RATES_MAX', 60) +export const swapQuoteLimiter = createLimiter('RATE_LIMIT_SWAP_QUOTE_MAX', 45) diff --git a/src/components/AssetHeader/AssetChart.tsx b/src/components/AssetHeader/AssetChart.tsx index 808db8df3f9..41123329569 100644 --- a/src/components/AssetHeader/AssetChart.tsx +++ b/src/components/AssetHeader/AssetChart.tsx @@ -121,7 +121,7 @@ export const AssetChart = ({ accountId, assetId, isLoaded }: AssetChartProps) => {asset?.symbol} {translate('assets.assetDetails.assetHeader.price')} - + {priceContent} diff --git a/src/components/AssetHeader/AssetMarketData.tsx b/src/components/AssetHeader/AssetMarketData.tsx index 593dff17690..0baa2dd9c0c 100644 --- a/src/components/AssetHeader/AssetMarketData.tsx +++ b/src/components/AssetHeader/AssetMarketData.tsx @@ -67,7 +67,7 @@ export const AssetMarketData: React.FC = ({ assetId }) => const isLoaded = !!marketData return ( - + {translate('assets.assetDetails.assetHeader.marketData')} diff --git a/src/components/AssetSearch/components/AssetRow.tsx b/src/components/AssetSearch/components/AssetRow.tsx index ef7d5572ee9..9073ec44aed 100644 --- a/src/components/AssetSearch/components/AssetRow.tsx +++ b/src/components/AssetSearch/components/AssetRow.tsx @@ -291,7 +291,9 @@ export const AssetRow: FC = memo( width='100%' height='auto' p={4} - data-testid={`asset-row-${showChainName ? `${chainName}-${asset.symbol}` : asset.symbol}`} + data-testid={`asset-row-${showChainName ? `${chainName}-${asset.symbol}` : asset.symbol}-${ + asset.assetId + }`} {...props} {...longPressHandlers(asset)} > diff --git a/src/components/AssetSearch/components/GroupedAssetRow.tsx b/src/components/AssetSearch/components/GroupedAssetRow.tsx index d07331abb6d..4bff0bea372 100644 --- a/src/components/AssetSearch/components/GroupedAssetRow.tsx +++ b/src/components/AssetSearch/components/GroupedAssetRow.tsx @@ -171,7 +171,7 @@ export const GroupedAssetRow: FC = ({ p={4} borderBottomRadius={isOpen ? 0 : 'lg'} bg={isOpen ? 'background.surface.raised.base' : 'transparent'} - data-testid={`grouped-asset-row-${asset.symbol}`} + data-testid={`grouped-asset-row-${asset.symbol}-${asset.assetId}`} > diff --git a/src/components/Layout/Header/GlobalSearch/GlobalSearchButton.tsx b/src/components/Layout/Header/GlobalSearch/GlobalSearchButton.tsx index f9f33c329ae..37e905c944e 100644 --- a/src/components/Layout/Header/GlobalSearch/GlobalSearchButton.tsx +++ b/src/components/Layout/Header/GlobalSearch/GlobalSearchButton.tsx @@ -47,6 +47,7 @@ export const GlobalSearchButton = memo(({ isIconButton = false }: GlobalSearchBu variant='ghost' aria-label={translate('common.search')} onClick={onOpen} + data-testid='global-search-button' /> ) : ( @@ -55,6 +56,7 @@ export const GlobalSearchButton = memo(({ isIconButton = false }: GlobalSearchBu icon={searchIcon} aria-label={translate('common.search')} onClick={onOpen} + data-testid='global-search-button' /> ) : null} @@ -372,7 +376,11 @@ export const FoxFarming = () => { /> - diff --git a/src/pages/Fox/components/FoxGovernance.tsx b/src/pages/Fox/components/FoxGovernance.tsx index 1e210071b1d..dc19c341d51 100644 --- a/src/pages/Fox/components/FoxGovernance.tsx +++ b/src/pages/Fox/components/FoxGovernance.tsx @@ -166,10 +166,12 @@ export const FoxGovernance = () => { - + {translate('common.active')} - {translate('common.closed')} + + {translate('common.closed')} + diff --git a/src/pages/Fox/components/FoxHeader.tsx b/src/pages/Fox/components/FoxHeader.tsx index eaf736cbbd6..32e15394f83 100644 --- a/src/pages/Fox/components/FoxHeader.tsx +++ b/src/pages/Fox/components/FoxHeader.tsx @@ -127,16 +127,36 @@ export const FoxHeader = () => { - + {translate('RFOX.staking')} - + {translate('foxPage.foxToken')} - + {translate('foxPage.foxFarming.title')} - + {translate('foxPage.governance.title')} diff --git a/src/pages/Fox/components/FoxToken.tsx b/src/pages/Fox/components/FoxToken.tsx index 553ce540485..8a5180b3553 100644 --- a/src/pages/Fox/components/FoxToken.tsx +++ b/src/pages/Fox/components/FoxToken.tsx @@ -20,7 +20,7 @@ export const FoxToken = () => { return ( <> - + {translate('foxPage.foxToken')} diff --git a/src/pages/Fox/components/FoxTokenFilterButton.tsx b/src/pages/Fox/components/FoxTokenFilterButton.tsx index 267a63b73e6..b57d4f0d85e 100644 --- a/src/pages/Fox/components/FoxTokenFilterButton.tsx +++ b/src/pages/Fox/components/FoxTokenFilterButton.tsx @@ -51,6 +51,7 @@ export const FoxTokenFilterButton = ({ return ( - diff --git a/src/pages/Fox/components/RFOXSection.tsx b/src/pages/Fox/components/RFOXSection.tsx index da752a481ef..55717f3faaf 100644 --- a/src/pages/Fox/components/RFOXSection.tsx +++ b/src/pages/Fox/components/RFOXSection.tsx @@ -297,6 +297,7 @@ export const RFOXSection = () => { {stakingAssetId === foxOnArbitrumOneAssetId && ( )} - @@ -340,7 +347,7 @@ export const RFOXSection = () => { - + diff --git a/src/pages/Fox/components/RFOXSimulator.tsx b/src/pages/Fox/components/RFOXSimulator.tsx index 6aefb44c0ec..c3bceb787f0 100644 --- a/src/pages/Fox/components/RFOXSimulator.tsx +++ b/src/pages/Fox/components/RFOXSimulator.tsx @@ -95,7 +95,13 @@ export const RFOXSimulator = ({ stakingAssetId }: RFOXSimulatorProps) => { borderRadius='2xl' overflow='hidden' > - + {translate('foxPage.rfox.simulateTitle')} diff --git a/src/pages/Fox/components/RFOXSliders.tsx b/src/pages/Fox/components/RFOXSliders.tsx index 92f89eda2dc..6126631fc0f 100644 --- a/src/pages/Fox/components/RFOXSliders.tsx +++ b/src/pages/Fox/components/RFOXSliders.tsx @@ -91,6 +91,7 @@ export const RFOXSliders: React.FC = ({ = ({ = ({ borderRadius='2xl' p={0} onClick={handleClick} + data-testid={`asset-card-${assetId}`} > = ({ }, [selectedOrder, selectedSort, showOrderFilter, showSortFilter]) return ( - + = ({ [accountId, accountMetadata, chainId, handleClose, requestEvent, topic, wallet, web3wallet], ) - const handleConfirmCosmosRequest = useCallback( - async (customTransactionData?: CustomTransactionData) => { - if (!requestEvent || !chainId || !wallet || !web3wallet || !topic) { - return - } - - const chainAdapter = assertGetCosmosSdkChainAdapter(chainId) + const handleConfirmCosmosRequest = useCallback(async () => { + if (!requestEvent || !wallet || !web3wallet || !topic) { + return + } + try { const response = await approveCosmosRequest({ wallet, requestEvent, - chainAdapter, accountMetadata, - customTransactionData, - accountId, }) await web3wallet.respondSessionRequest({ topic, response, }) - handleClose() - }, - [accountId, accountMetadata, chainId, handleClose, requestEvent, topic, wallet, web3wallet], - ) + } catch (e) { + console.error('[WC Cosmos] request failed:', e) + await web3wallet.respondSessionRequest({ + topic, + response: formatJsonRpcError(requestEvent.id, (e as Error).message ?? 'Unknown error'), + }) + } + handleClose() + }, [accountMetadata, handleClose, requestEvent, topic, wallet, web3wallet]) const handleRejectRequest = useCallback(async () => { if (!requestEvent || !web3wallet || !topic) return @@ -316,12 +314,7 @@ export const WalletConnectModalManager: FC = ({ - > - } + state={state as Required>} topic={topic} /> ) diff --git a/src/plugins/walletConnectToDapps/components/WalletConnectSigningModal/content/CosmosSignAminoContent.tsx b/src/plugins/walletConnectToDapps/components/WalletConnectSigningModal/content/CosmosSignAminoContent.tsx new file mode 100644 index 00000000000..20fe6ab2cc6 --- /dev/null +++ b/src/plugins/walletConnectToDapps/components/WalletConnectSigningModal/content/CosmosSignAminoContent.tsx @@ -0,0 +1,84 @@ +import { Box, Card, VStack } from '@chakra-ui/react' +import type { FC } from 'react' +import { useTranslate } from 'react-polyglot' + +import { RawText, Text } from '@/components/Text' +import { ModalSection } from '@/plugins/walletConnectToDapps/components/modals/ModalSection' +import type { CosmosSignAminoCallRequestParams } from '@/plugins/walletConnectToDapps/types' + +type CosmosSignAminoContentProps = { + signDoc: CosmosSignAminoCallRequestParams['signDoc'] +} + +export const CosmosSignAminoContent: FC = ({ signDoc }) => { + const translate = useTranslate() + + const { + memo, + sequence, + msgs: messages, + account_number: accountNumber, + chain_id: chainId, + } = signDoc + + return ( + + + + + + + {chainId} + + + + + + {memo || '-'} + + + + + + {messages.length > 0 + ? JSON.stringify(messages, null, 2) + : translate('plugins.walletConnectToDapps.modal.signMessage.noMessages')} + + + + + + {sequence} + + + + + + {accountNumber} + + + + + + ) +} diff --git a/src/plugins/walletConnectToDapps/components/modals/CosmosSignMessageConfirmation.tsx b/src/plugins/walletConnectToDapps/components/modals/CosmosSignMessageConfirmation.tsx index d347cc886a9..534c6546a43 100644 --- a/src/plugins/walletConnectToDapps/components/modals/CosmosSignMessageConfirmation.tsx +++ b/src/plugins/walletConnectToDapps/components/modals/CosmosSignMessageConfirmation.tsx @@ -1,193 +1,30 @@ -import { Box, Button, Card, Divider, HStack, Image, VStack } from '@chakra-ui/react' -import type { FC, JSX } from 'react' -import { useCallback, useMemo, useState } from 'react' -import { useTranslate } from 'react-polyglot' +import type { FC } from 'react' +import { useCallback } from 'react' -import { FoxIcon } from '@/components/Icons/FoxIcon' -import { RawText, Text } from '@/components/Text' -import { useWallet } from '@/hooks/useWallet/useWallet' -import { AddressSummaryCard } from '@/plugins/walletConnectToDapps/components/modals/AddressSummaryCard' -import { ExternalLinkButton } from '@/plugins/walletConnectToDapps/components/modals/ExternalLinkButtons' -import { ModalSection } from '@/plugins/walletConnectToDapps/components/modals/ModalSection' -import { useWalletConnectState } from '@/plugins/walletConnectToDapps/hooks/useWalletConnectState' -import type { - CosmosSignAminoCallRequest, - CosmosSignDirectCallRequest, -} from '@/plugins/walletConnectToDapps/types' -import { CosmosSigningMethod } from '@/plugins/walletConnectToDapps/types' +import { CosmosSignAminoContent } from '@/plugins/walletConnectToDapps/components/WalletConnectSigningModal/content/CosmosSignAminoContent' +import { WalletConnectSigningModal } from '@/plugins/walletConnectToDapps/components/WalletConnectSigningModal/WalletConnectSigningModal' +import type { CosmosSignAminoCallRequest } from '@/plugins/walletConnectToDapps/types' import type { WalletConnectRequestModalProps } from '@/plugins/walletConnectToDapps/WalletConnectModalManager' -import { selectFeeAssetByChainId } from '@/state/slices/selectors' -import { useAppSelector } from '@/state/store' - -const disabledProp = { opacity: 0.5, cursor: 'not-allowed', userSelect: 'none' } export const CosmosSignMessageConfirmationModal: FC< - WalletConnectRequestModalProps + WalletConnectRequestModalProps > = ({ onConfirm, onReject, state, topic }) => { - const [isLoading, setIsLoading] = useState(false) - const { address, chainId } = useWalletConnectState(state) - const peerMetadata = state.sessionsByTopic[topic]?.peer.metadata - - const connectedAccountFeeAsset = useAppSelector(state => - selectFeeAssetByChainId(state, chainId ?? ''), - ) - - const translate = useTranslate() - const walletInfo = useWallet().state.walletInfo - const WalletIcon = walletInfo?.icon ?? FoxIcon - const walletIcon = useMemo( - () => (typeof WalletIcon === 'string' ? null : ), - [WalletIcon], - ) const request = state.modalData.requestEvent?.params.request - const handleConfirm = useCallback(async () => { - setIsLoading(true) + const handleFormSubmit = useCallback(async () => { await onConfirm() - setIsLoading(false) }, [onConfirm]) - const handleReject = useCallback(async () => { - setIsLoading(true) - await onReject() - setIsLoading(false) - }, [onReject]) - - const methodSpecificContent: JSX.Element | null = useMemo(() => { - if (request?.method === CosmosSigningMethod.COSMOS_SIGN_AMINO) { - const { - memo, - sequence, - msgs: messages, - account_number: accountNumber, - chain_id: chainId, - } = request.params.signDoc - - return ( - - - - {memo} - - - - {messages.length > 0 - ? messages - : translate('plugins.walletConnectToDapps.modal.signMessage.noMessages')} - - - - {sequence} - - - - {accountNumber} - - - - {chainId} - - - ) - } else if (request?.method === CosmosSigningMethod.COSMOS_SIGN_DIRECT) { - const authInfo = request.params.signDoc.authInfoBytes - const body = request.params.signDoc.bodyBytes - - return ( - - - - {authInfo} - - - - {body} - - - ) - } else return null - }, [request?.method, request?.params.signDoc, translate]) - - if (!peerMetadata) return null + if (!request) return null return ( - <> - - - - - - - - - {peerMetadata.name} - - - - - {methodSpecificContent} - - - - - - - - + + + ) } diff --git a/src/plugins/walletConnectToDapps/components/modals/NetworkSelection.tsx b/src/plugins/walletConnectToDapps/components/modals/NetworkSelection.tsx index faa5d5abbcb..b483f39fed4 100644 --- a/src/plugins/walletConnectToDapps/components/modals/NetworkSelection.tsx +++ b/src/plugins/walletConnectToDapps/components/modals/NetworkSelection.tsx @@ -9,7 +9,6 @@ import { VStack, } from '@chakra-ui/react' import type { ChainId } from '@shapeshiftoss/caip' -import { isEvmChainId } from '@shapeshiftoss/chain-adapters' import type { ProposalTypes } from '@walletconnect/types' import { partition, uniq } from 'lodash' import type { FC } from 'react' @@ -27,6 +26,7 @@ import { import { DialogTitle } from '@/components/Modal/components/DialogTitle' import { RawText } from '@/components/Text' import { getChainAdapterManager } from '@/context/PluginProvider/chainAdapterSingleton' +import { isWcSupportedChainId } from '@/plugins/walletConnectToDapps/utils/createApprovalNamespaces' import { selectAccountIdsByAccountNumberAndChainId, selectWalletConnectedChainIdsSorted, @@ -103,20 +103,15 @@ export const NetworkSelection: FC = ({ const chainIdsSortedByBalance = useAppSelector(selectWalletConnectedChainIdsSorted) const availableChainIds = useMemo(() => { - // Use all EVM chains available for the selected account number as a source of truth - // Do *not* honor wc optional namespaces, the app is the source of truth, and the app may or may not handle additional one at their discretion - // This is to keep things simple for users and not display less chains than they have accounts for, for a given account number const accountNumberChainIds = Object.entries( accountIdsByAccountNumberAndChainId[selectedAccountNumber] ?? {}, ) - .filter(([chainId]) => isEvmChainId(chainId)) + .filter(([chainId]) => isWcSupportedChainId(chainId)) .map(([chainId]) => chainId) - // Add any required chains from the dApp even if user doesn't have account/s at the current accountNumber for it/them - we'll handle that state ourselves - // Rationale being, they should definitely be able to see the required chains when going to network selection regardless of whether or not they have an account for it const requiredFromNamespaces = Object.values(requiredNamespaces) .flatMap(namespace => namespace.chains ?? []) - .filter(isEvmChainId) + .filter(isWcSupportedChainId) const allChainIds = uniq([...accountNumberChainIds, ...requiredFromNamespaces]) @@ -148,7 +143,7 @@ export const NetworkSelection: FC = ({ const optionalChainIds = useMemo(() => { const userChainIds = Object.keys( accountIdsByAccountNumberAndChainId[selectedAccountNumber] ?? {}, - ).filter(isEvmChainId) + ).filter(isWcSupportedChainId) return userChainIds.filter(chainId => !requiredChainIds.includes(chainId as ChainId)) }, [selectedAccountNumber, accountIdsByAccountNumberAndChainId, requiredChainIds]) @@ -167,7 +162,7 @@ export const NetworkSelection: FC = ({ } else { const userChainIds = Object.keys( accountIdsByAccountNumberAndChainId[selectedAccountNumber] ?? {}, - ).filter(isEvmChainId) + ).filter(isWcSupportedChainId) onSelectedChainIdsChange(userChainIds as ChainId[]) } }, [ diff --git a/src/plugins/walletConnectToDapps/components/modals/SessionProposal.tsx b/src/plugins/walletConnectToDapps/components/modals/SessionProposal.tsx index 8c69f2648e4..be8ac1c7305 100644 --- a/src/plugins/walletConnectToDapps/components/modals/SessionProposal.tsx +++ b/src/plugins/walletConnectToDapps/components/modals/SessionProposal.tsx @@ -1,6 +1,5 @@ import type { AccountId, ChainId } from '@shapeshiftoss/caip' import { fromAccountId, fromChainId } from '@shapeshiftoss/caip' -import { isEvmChainId } from '@shapeshiftoss/chain-adapters' import type { SessionTypes } from '@walletconnect/types' import { getSdkError } from '@walletconnect/utils' import { uniq } from 'lodash' @@ -16,7 +15,10 @@ import { SessionProposalRoutes } from '@/plugins/walletConnectToDapps/components import { PeerMeta } from '@/plugins/walletConnectToDapps/components/PeerMeta' import type { SessionProposalRef } from '@/plugins/walletConnectToDapps/types' import { WalletConnectActionType } from '@/plugins/walletConnectToDapps/types' -import { createApprovalNamespaces } from '@/plugins/walletConnectToDapps/utils/createApprovalNamespaces' +import { + createApprovalNamespaces, + isWcSupportedChainId, +} from '@/plugins/walletConnectToDapps/utils/createApprovalNamespaces' import type { WalletConnectSessionModalProps } from '@/plugins/walletConnectToDapps/WalletConnectModalManager' import { selectAccountIdsByAccountNumberAndChainId, @@ -104,9 +106,11 @@ const SessionProposal = forwardRef { if (!selectedAccountNumberAccountIdsByChainId) return - const evmChainIds = chainIds.filter(isEvmChainId) + const supportedChainIds = chainIds.filter(isWcSupportedChainId) const orderedAccountIds = orderAccountIdsByBalance( - evmChainIds, + supportedChainIds, selectedAccountNumberAccountIdsByChainId, ) setSelectedAccountIds(orderedAccountIds) diff --git a/src/plugins/walletConnectToDapps/eventsManager/useWalletConnectEventsHandler.ts b/src/plugins/walletConnectToDapps/eventsManager/useWalletConnectEventsHandler.ts index a424e90b674..2cc53bb23a2 100644 --- a/src/plugins/walletConnectToDapps/eventsManager/useWalletConnectEventsHandler.ts +++ b/src/plugins/walletConnectToDapps/eventsManager/useWalletConnectEventsHandler.ts @@ -1,4 +1,4 @@ -import { formatJsonRpcResult } from '@json-rpc-tools/utils' +import { formatJsonRpcError, formatJsonRpcResult } from '@json-rpc-tools/utils' import type { WalletKitTypes } from '@reown/walletkit' import type { ChainReference } from '@shapeshiftoss/caip' import { CHAIN_NAMESPACE, fromAccountId, toChainId } from '@shapeshiftoss/caip' @@ -27,7 +27,10 @@ export const useWalletConnectEventsHandler = ( (proposal: WalletKitTypes.EventArguments['session_proposal']) => { dispatch({ type: WalletConnectActionType.SET_MODAL, - payload: { modal: WalletConnectModal.SessionProposal, data: { proposal } }, + payload: { + modal: WalletConnectModal.SessionProposal, + data: { proposal }, + }, }) }, [dispatch], @@ -148,7 +151,28 @@ export const useWalletConnectEventsHandler = ( }) return } + case CosmosSigningMethod.COSMOS_GET_ACCOUNTS: { + const cosmosAccounts = session?.namespaces?.cosmos?.accounts ?? [] + // Best-effort: pubkey should be a base64-encoded secp256k1 public key, but we + // only have the bech32 address here. Falls back to address which is semantically + // wrong but allows dApps that don't strictly validate the pubkey field to work. + const accounts = cosmosAccounts.map(caip10 => { + const { account } = fromAccountId(caip10) + return { address: account, algo: 'secp256k1', pubkey: account } + }) + return web3wallet?.respondSessionRequest({ + topic, + response: formatJsonRpcResult(requestEvent.id, accounts), + }) + } case CosmosSigningMethod.COSMOS_SIGN_DIRECT: + return web3wallet?.respondSessionRequest({ + topic, + response: formatJsonRpcError( + requestEvent.id, + 'cosmos_signDirect is not supported - use cosmos_signAmino instead', + ), + }) case CosmosSigningMethod.COSMOS_SIGN_AMINO: return dispatch({ type: WalletConnectActionType.SET_MODAL, @@ -165,5 +189,9 @@ export const useWalletConnectEventsHandler = ( [dispatch, web3wallet], ) - return { handleSessionProposal, handleSessionAuthRequest, handleSessionRequest } + return { + handleSessionProposal, + handleSessionAuthRequest, + handleSessionRequest, + } } diff --git a/src/plugins/walletConnectToDapps/types.ts b/src/plugins/walletConnectToDapps/types.ts index e900f0e1d2e..60261839faf 100644 --- a/src/plugins/walletConnectToDapps/types.ts +++ b/src/plugins/walletConnectToDapps/types.ts @@ -24,6 +24,7 @@ export enum EIP155_SigningMethod { } export enum CosmosSigningMethod { + COSMOS_GET_ACCOUNTS = 'cosmos_getAccounts', COSMOS_SIGN_DIRECT = 'cosmos_signDirect', COSMOS_SIGN_AMINO = 'cosmos_signAmino', } @@ -186,6 +187,11 @@ export type EthPersonalSignCallRequest = { params: EthPersonalSignCallRequestParams } +export type CosmosGetAccountsCallRequest = { + method: CosmosSigningMethod.COSMOS_GET_ACCOUNTS + params: undefined +} + export type CosmosSignDirectCallRequestParams = { signerAddress: string signDoc: { @@ -241,6 +247,7 @@ export type WalletConnectRequest = | EthPersonalSignCallRequest | EthSignTypedDataCallRequest | EthSendTransactionCallRequest + | CosmosGetAccountsCallRequest | CosmosSignDirectCallRequest | CosmosSignAminoCallRequest diff --git a/src/plugins/walletConnectToDapps/utils/CosmosRequestHandlerUtil.ts b/src/plugins/walletConnectToDapps/utils/CosmosRequestHandlerUtil.ts index dccc4864e67..6742d63e986 100644 --- a/src/plugins/walletConnectToDapps/utils/CosmosRequestHandlerUtil.ts +++ b/src/plugins/walletConnectToDapps/utils/CosmosRequestHandlerUtil.ts @@ -1,15 +1,15 @@ import type { JsonRpcResult } from '@json-rpc-tools/utils' import { formatJsonRpcResult } from '@json-rpc-tools/utils' -import type { AccountId } from '@shapeshiftoss/caip' -import type { ChainAdapter } from '@shapeshiftoss/chain-adapters' import { toAddressNList } from '@shapeshiftoss/chain-adapters' -import type { Cosmos, CosmosSignTx, HDWallet } from '@shapeshiftoss/hdwallet-core' -import type { AccountMetadata, CosmosSdkChainId } from '@shapeshiftoss/types' +import type { Cosmos, HDWallet } from '@shapeshiftoss/hdwallet-core' +import { supportsCosmos } from '@shapeshiftoss/hdwallet-core' +import type { AccountMetadata } from '@shapeshiftoss/types' import { getSdkError } from '@walletconnect/utils' import { assertIsDefined } from '@/lib/utils' +import { assertGetCosmosSdkChainAdapter } from '@/lib/utils/cosmosSdk' import type { - CustomTransactionData, + CosmosSignAminoCallRequestParams, SupportedSessionRequest, } from '@/plugins/walletConnectToDapps/types' import { CosmosSigningMethod } from '@/plugins/walletConnectToDapps/types' @@ -17,55 +17,54 @@ import { CosmosSigningMethod } from '@/plugins/walletConnectToDapps/types' type ApproveCosmosRequestArgs = { requestEvent: SupportedSessionRequest wallet: HDWallet - chainAdapter: ChainAdapter accountMetadata?: AccountMetadata - customTransactionData?: CustomTransactionData - accountId?: AccountId } export const approveCosmosRequest = async ({ requestEvent, wallet, - chainAdapter, accountMetadata, - customTransactionData, -}: ApproveCosmosRequestArgs): Promise> => { +}: ApproveCosmosRequestArgs): Promise> => { const { params, id } = requestEvent const { request } = params - assertIsDefined(customTransactionData) - assertIsDefined(accountMetadata) - - const { bip44Params } = accountMetadata - const { accountNumber } = bip44Params - const addressNList = toAddressNList(chainAdapter.getBip44Params(bip44Params)) + if (!supportsCosmos(wallet)) { + throw new Error('Wallet does not support Cosmos') + } switch (request.method) { - case CosmosSigningMethod.COSMOS_SIGN_AMINO: - // TODO: Implement - const txToSign: CosmosSignTx = { + case CosmosSigningMethod.COSMOS_SIGN_AMINO: { + assertIsDefined(accountMetadata) + + const { bip44Params } = accountMetadata + const chainAdapter = assertGetCosmosSdkChainAdapter(params.chainId) + const addressNList = toAddressNList(chainAdapter.getBip44Params(bip44Params)) + + const { signDoc } = request.params as CosmosSignAminoCallRequestParams + + if (!wallet.cosmosSignAmino) { + throw new Error('Wallet does not support cosmosSignAmino') + } + + const result = await wallet.cosmosSignAmino({ addressNList, - tx: { - // FIXME: proto-tx-builder requires a message length of 1, but sign messages have 0 - msg: request.params.signDoc.msgs as unknown as Cosmos.Msg[], - fee: request.params.signDoc.fee, - signatures: [], - memo: request.params.signDoc.memo, + signDoc: { + chain_id: signDoc.chain_id, + account_number: signDoc.account_number, + sequence: signDoc.sequence, + fee: signDoc.fee as unknown as Cosmos.StdFee, + msgs: signDoc.msgs as unknown as Cosmos.Msg[], + memo: signDoc.memo, }, - chain_id: request.params.signDoc.chain_id, - account_number: accountNumber.toString(), - sequence: request.params.signDoc.sequence, - fee: 0, // fixme - } - const signedMessage = await chainAdapter.signTransaction({ - txToSign, - wallet, }) - return formatJsonRpcResult(id, signedMessage) + + if (!result) throw new Error('Failed to sign Cosmos amino transaction') + + return formatJsonRpcResult(id, result) + } case CosmosSigningMethod.COSMOS_SIGN_DIRECT: { - // TODO: Implement - return formatJsonRpcResult(1, 'signedMessage') + throw new Error('cosmos_signDirect is not yet supported - use cosmos_signAmino instead') } default: diff --git a/src/plugins/walletConnectToDapps/utils/createApprovalNamespaces.ts b/src/plugins/walletConnectToDapps/utils/createApprovalNamespaces.ts index 95ff86bf716..dcecf72f7e3 100644 --- a/src/plugins/walletConnectToDapps/utils/createApprovalNamespaces.ts +++ b/src/plugins/walletConnectToDapps/utils/createApprovalNamespaces.ts @@ -1,10 +1,24 @@ import type { AccountId, ChainId } from '@shapeshiftoss/caip' -import { fromAccountId } from '@shapeshiftoss/caip' +import { CHAIN_NAMESPACE, fromAccountId } from '@shapeshiftoss/caip' import { isEvmChainId } from '@shapeshiftoss/chain-adapters' import type { ProposalTypes, SessionTypes } from '@walletconnect/types' import { uniq } from 'lodash' -import { EIP155_SigningMethod } from '@/plugins/walletConnectToDapps/types' +import { CosmosSigningMethod, EIP155_SigningMethod } from '@/plugins/walletConnectToDapps/types' + +const DEFAULT_EIP155_METHODS = Object.values(EIP155_SigningMethod).filter( + method => method !== EIP155_SigningMethod.GET_CAPABILITIES, +) + +const DEFAULT_COSMOS_METHODS = Object.values(CosmosSigningMethod) + +const DEFAULT_COSMOS_EVENTS: string[] = [] + +const isCosmosSdkChainId = (chainId: string): boolean => + chainId.startsWith(`${CHAIN_NAMESPACE.CosmosSdk}:`) + +export const isWcSupportedChainId = (chainId: string): boolean => + isEvmChainId(chainId) || isCosmosSdkChainId(chainId) export const createApprovalNamespaces = ( requiredNamespaces: ProposalTypes.RequiredNamespaces, @@ -14,18 +28,24 @@ export const createApprovalNamespaces = ( ): SessionTypes.Namespaces => { const approvedNamespaces: SessionTypes.Namespaces = {} - const DEFAULT_EIP155_METHODS = Object.values(EIP155_SigningMethod).filter( - method => method !== EIP155_SigningMethod.GET_CAPABILITIES, - ) + const getDefaultMethods = (key: string): string[] => { + switch (key) { + case CHAIN_NAMESPACE.Evm: + return DEFAULT_EIP155_METHODS + case CHAIN_NAMESPACE.CosmosSdk: + return DEFAULT_COSMOS_METHODS + default: + return [] + } + } const createNamespaceEntry = ( key: string, proposalNamespace: ProposalTypes.RequiredNamespace, accounts: string[], ) => { - // That condition seems useless at runtime since we *currently* only handle eip155 - // but technically, we *do* support Cosmos SDK - const methods = key === 'eip155' ? DEFAULT_EIP155_METHODS : proposalNamespace.methods + const defaultMethods = getDefaultMethods(key) + const methods = defaultMethods.length > 0 ? defaultMethods : proposalNamespace.methods return { accounts, @@ -34,7 +54,6 @@ export const createApprovalNamespaces = ( } } - // Handle required namespaces first Object.entries(requiredNamespaces).forEach(([key, proposalNamespace]) => { const selectedAccountsForKey = selectedAccountIds.filter(accountId => { const { chainNamespace } = fromAccountId(accountId) @@ -46,20 +65,20 @@ export const createApprovalNamespaces = ( } }) - // Handle optional namespaces for chains user selected but aren't required - const requiredChainIds = Object.values(requiredNamespaces) - .flatMap(namespace => namespace.chains ?? []) - .filter(isEvmChainId) + const requiredChainIds = Object.values(requiredNamespaces).flatMap( + namespace => namespace.chains ?? [], + ) - const additionalChainIds = selectedChainIds.filter( + // Handle optional EVM namespaces + const additionalEvmChainIds = selectedChainIds.filter( chainId => isEvmChainId(chainId) && !requiredChainIds.includes(chainId), ) - if (additionalChainIds.length > 0) { + if (additionalEvmChainIds.length > 0) { const eip155AccountIds = selectedAccountIds.filter( accountId => fromAccountId(accountId).chainNamespace === 'eip155' && - additionalChainIds.includes(fromAccountId(accountId).chainId), + additionalEvmChainIds.includes(fromAccountId(accountId).chainId), ) if (eip155AccountIds.length > 0) { @@ -78,5 +97,38 @@ export const createApprovalNamespaces = ( } } + // Handle optional Cosmos namespaces + const cosmosNamespaceKey = CHAIN_NAMESPACE.CosmosSdk + const additionalCosmosChainIds = selectedChainIds.filter( + chainId => isCosmosSdkChainId(chainId) && !requiredChainIds.includes(chainId), + ) + + if (additionalCosmosChainIds.length > 0) { + const cosmosAccountIds = selectedAccountIds.filter( + accountId => + fromAccountId(accountId).chainNamespace === cosmosNamespaceKey && + additionalCosmosChainIds.includes(fromAccountId(accountId).chainId), + ) + + if (cosmosAccountIds.length > 0) { + const existing = approvedNamespaces[cosmosNamespaceKey] + approvedNamespaces[cosmosNamespaceKey] = { + ...(existing ?? {}), + accounts: uniq([...(existing?.accounts ?? []), ...cosmosAccountIds]), + methods: uniq([ + ...(existing?.methods ?? DEFAULT_COSMOS_METHODS), + ...(optionalNamespaces?.[cosmosNamespaceKey]?.methods && + optionalNamespaces[cosmosNamespaceKey].methods.length > 0 + ? optionalNamespaces[cosmosNamespaceKey].methods + : DEFAULT_COSMOS_METHODS), + ]), + events: uniq([ + ...(existing?.events ?? DEFAULT_COSMOS_EVENTS), + ...(optionalNamespaces?.[cosmosNamespaceKey]?.events ?? DEFAULT_COSMOS_EVENTS), + ]), + } + } + } + return approvedNamespaces } diff --git a/yarn.lock b/yarn.lock index 9fe7e6b3269..9168af7e828 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13885,6 +13885,7 @@ __metadata: "@types/uuid": ^9.0.5 cors: ^2.8.5 express: ^4.21.0 + express-rate-limit: ^7.5.0 tsx: ^4.19.2 typescript: ~5.2.2 uuid: ^9.0.0 @@ -29255,6 +29256,15 @@ __metadata: languageName: node linkType: hard +"express-rate-limit@npm:^7.5.0": + version: 7.5.1 + resolution: "express-rate-limit@npm:7.5.1" + peerDependencies: + express: ">= 4.11" + checksum: 544fdd576a846a7d3ed0203a011cec6d65ad8e5eab58dbe045a0bdefd119060c140c38b327e32713b56342cc901a3ca5c99f13cce4ceee08408e775ec4742c16 + languageName: node + linkType: hard + "express@npm:^4.21.0": version: 4.22.1 resolution: "express@npm:4.22.1"