[
  {
    "path": ".claude/skills/claude-review/SKILL.md",
    "content": "---\nname: claude-review\ndescription: Review code changes using Hyperlane Warp UI coding standards. Use when reviewing PRs, checking your own changes, or doing self-review before committing.\n---\n\n# Code Review Skill\n\nUse this skill to review code changes against Hyperlane Warp UI standards.\n\n## When to Use\n\n- Before committing changes (self-review)\n- When asked to review a PR or diff\n- To check if changes follow project patterns\n\n## Instructions\n\nRead and apply the guidelines from `REVIEW.md` to review the code changes.\n\n### For PR Reviews\n\nWhen reviewing a PR, deliver feedback as a **single consolidated GitHub review** using `/inline-pr-comments`. Each run produces a separate review — nothing is overwritten.\n\n1. **Fetch prior reviews first** — the `/inline-pr-comments` skill fetches existing reviews/comments so you can avoid duplicating feedback and stay aware of ongoing discussions\n2. **Review body** — Overall assessment, architecture concerns, and issues found outside the diff\n3. **Inline comments** — Specific issues on changed lines (attached to the same review)\n\n### For Self-Review\n\nWhen reviewing your own changes before committing:\n\n1. Run `git diff` to see changes\n2. Apply the code review guidelines\n3. Fix issues directly rather than commenting\n"
  },
  {
    "path": ".claude/skills/claude-security-review/SKILL.md",
    "content": "---\nname: claude-security-review\ndescription: Security-focused review for frontend/Web3 code. Use for XSS, wallet security, CSP, and dependency checks.\n---\n\n# Security Review Skill\n\nUse this skill for security-focused code review of frontend Web3 code.\n\n## When to Use\n\n- Reviewing wallet integration code\n- Checking for XSS vulnerabilities\n- CSP header changes\n- Dependency updates\n\n## Instructions\n\nRead and apply the security guidelines from `.github/prompts/security-scan.md` to review the code changes.\n\nReport findings with severity ratings (Critical/High/Medium/Low/Informational) and suggested fixes.\n\n### For PR Reviews\n\nWhen reviewing a PR, deliver feedback using `/inline-pr-comments` to post inline comments on specific lines.\n"
  },
  {
    "path": ".claude/skills/commit/SKILL.md",
    "content": "---\nname: commit\ndescription: Commit changes following project quality gates and best practices. Run before creating any git commit.\n---\n\n# Commit Skill\n\nUse this skill when committing changes to ensure quality and correctness.\n\n## Pre-Commit Checklist\n\nRun these in order. **Do not commit if any fail.**\n\n1. **`pnpm format`** — Format all source files\n2. **`pnpm lint`** — Check for ESLint errors\n3. **`pnpm typecheck`** — Verify TypeScript compiles\n4. **`pnpm build`** — Ensure production build succeeds (optional for small changes, required before PR)\n\n## Staging Rules\n\n- **Only stage files related to the current task.** Review `git status` carefully.\n- **Never stage unrelated files** — markdown notes, scratch files, `.monorepo-tarballs/`, `agent/`, etc. should not be committed unless explicitly requested.\n- **Use specific file paths** with `git add`, not `git add .` or `git add -A`.\n- **Review `git diff --staged`** before committing to verify only intended changes are included.\n\n## Commit Message Format\n\n- Use conventional commit prefixes: `feat:`, `fix:`, `style:`, `chore:`, `refactor:`, `docs:`, `test:`\n- Keep the first line under 72 characters\n- Add a blank line then bullet points for multi-change commits\n- End with `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>`\n- Use a HEREDOC to pass the message to avoid shell escaping issues\n\n## Things to Watch For\n\n- **Secrets**: Never commit `.env`, credentials, or API keys\n- **Large files**: Don't commit binaries, build artifacts, or font files (check `.gitignore`)\n- **Formatting drift**: If oxfmt changed files you didn't touch, stage them separately or skip them\n\n## Example Flow\n\n```bash\npnpm format\npnpm lint\npnpm typecheck\ngit status                    # review what changed\ngit diff                      # verify changes are correct\ngit add <specific-files>      # only related files\ngit diff --staged             # double-check staged changes\ngit commit -m \"$(cat <<'EOF'\nfeat: description of change\n\n- Detail 1\n- Detail 2\n\nEOF\n)\"\n```\n"
  },
  {
    "path": ".claude/skills/inline-pr-comments/SKILL.md",
    "content": "---\nname: inline-pr-comments\ndescription: Post a single consolidated PR review with summary and inline comments. Use this skill to deliver code review feedback as one unified review per run.\n---\n\n# Consolidated PR Review Skill\n\nUse this skill to submit code review feedback as a **single consolidated GitHub review** containing both the summary body and all inline comments.\n\n## When to Use\n\n- After completing a code review (use with /claude-review)\n- When you have specific line-by-line feedback to deliver\n\n## Instructions\n\nSubmit one consolidated review per run using the GitHub API. **Do NOT post inline comments individually.** Each run produces a new review — nothing is overwritten.\n\n### Step 1: Fetch Prior Review Context\n\nBefore reviewing, read existing reviews and comments on the PR for context:\n\n```bash\n# Fetch existing reviews (summaries)\ngh api --paginate \"repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews\" --jq '.[] | {user: .user.login, state: .state, body: .body}'\n\n# Fetch inline review comments\ngh api --paginate \"repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments\" --jq '.[] | {user: .user.login, path: .path, line: .line, body: .body}'\n\n# Fetch general PR discussion comments\ngh api --paginate \"repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments\" --jq '.[] | {user: .user.login, body: .body}'\n```\n\nUse this context to:\n\n- **Skip issues already raised** — don't re-flag something a prior review already pointed out\n- **Reference prior discussions** — e.g. \"As noted in the previous review, ...\"\n- **Flag unresolved issues** — if a prior review raised something that still isn't fixed, note it briefly (e.g. \"Still unresolved from prior review: ...\")\n- **Avoid contradictions** — don't suggest the opposite of what a human reviewer requested\n\n### Step 2: Collect All Findings\n\nComplete the full review. Collect:\n\n- **Summary** — Overall assessment, architecture concerns, non-diff observations\n- **Inline comments** — Specific issues on changed lines (path, line, body)\n\n### Step 3: Build Review JSON\n\nWrite a JSON file to `/tmp/review.json`:\n\n```json\n{\n  \"body\": \"## Review Summary\\n\\nOverall assessment here.\\n\\n## Observations Outside This PR\\n- `file:line`: description\",\n  \"event\": \"COMMENT\",\n  \"comments\": [\n    {\n      \"path\": \"src/file.ts\",\n      \"line\": 42,\n      \"body\": \"Issue description here\"\n    },\n    {\n      \"path\": \"src/other.ts\",\n      \"start_line\": 10,\n      \"line\": 15,\n      \"body\": \"Multi-line comment\"\n    }\n  ]\n}\n```\n\n**Fields:**\n\n- `body` — Markdown review summary (required)\n- `event` — Always `\"COMMENT\"` (never APPROVE or REQUEST_CHANGES)\n- `comments` — Array of inline comment objects (can be empty if no inline findings)\n  - `path` — File path relative to repo root\n  - `line` — Line number in the NEW version of the file\n  - `start_line` + `line` — For multi-line comments\n  - `body` — Markdown-formatted feedback\n\n### Step 4: Submit the Review\n\n```bash\ngh api \"repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews\" --input /tmp/review.json\n```\n\nThe `GITHUB_REPOSITORY` and `PR_NUMBER` environment variables are set by the CI workflow.\n\n### Limitations\n\n- Inline comments can only target lines in the diff (changed/added lines)\n- Comments targeting unchanged lines will cause the API call to fail\n- If unsure whether a line is in the diff, put the finding in the summary body instead\n\n### Handling Non-Diff Findings\n\nIssues in code NOT changed by the PR go in the review `body` under a dedicated section:\n\n```markdown\n## Observations Outside This PR\n\n- `src/utils/foo.ts:142`: Pre-existing null check missing\n- `src/core/bar.ts:78-82`: Similar pattern to line 45 issue\n```\n\n### Feedback Guidelines\n\n| Feedback Type                 | In Diff? | Where to Put It                                      |\n| ----------------------------- | -------- | ---------------------------------------------------- |\n| Specific code issue           | Yes      | `comments` array entry for that line                 |\n| Pattern repeated across files | Yes      | First occurrence in `comments` + note others in body |\n| Related issue found           | No       | `body` under \"Observations Outside This PR\"          |\n| Pre-existing bug discovered   | No       | `body` (consider separate issue if critical)         |\n| Overall architecture concern  | N/A      | `body`                                               |\n\nBe concise. Group minor style issues together. Never use APPROVE or REQUEST_CHANGES.\n"
  },
  {
    "path": ".claude/skills/resolve-pr-reviews/SKILL.md",
    "content": "---\nname: resolve-pr-reviews\ndescription: Review and resolve PR review comments interactively. Fetches unresolved comments, proposes fixes or explains why to skip, and replies on GitHub.\n---\n\n# Resolve PR Reviews\n\nUse this skill to process review comments on a PR. Pass the PR number as an argument (e.g. `/resolve-pr-reviews 1041`).\n\n## Workflow\n\n### Step 1: Fetch unresolved review comments\n\nUse the GitHub API to get all review comments that haven't been resolved:\n\n```bash\n# Get inline review comments (pull request review comments)\ngh api repos/{owner}/{repo}/pulls/{pr}/comments --jq '.[] | select(.position != null or .line != null)'\n\n# Get issue-level comments (general PR comments from reviewers, not bots)\ngh api repos/{owner}/{repo}/issues/{pr}/comments\n```\n\nFilter out:\n- Deployment/CI bots (Vercel deploy previews, CI status checks)\n- Already-resolved threads\n- Your own previous replies\n\nKeep:\n- AI review comments (Claude review bot, CodeRabbit inline suggestions) — these are actionable review feedback\n- Human reviewer comments\n\n### Step 2: Analyze each comment\n\nFor each unresolved comment:\n1. Read the relevant code being commented on\n2. Understand the reviewer's concern\n3. Propose a concrete fix OR explain why it should be skipped\n4. Categorize severity: **must fix**, **good idea**, or **skip** (with reasoning)\n\n### Step 3: Present to user\n\n**IMPORTANT: You MUST ask the user this question BEFORE showing any analysis. Do NOT skip this step or present comments prematurely.**\n\nAfter fetching and analyzing comments internally, use the `AskUserQuestion` tool to present a selection prompt:\n\n```\nQuestion: \"Found N review comments on PR #XXXX. How would you like to go through them?\"\nOptions:\n  - \"One by one\" — Present each comment individually, user decides fix/skip before next\n  - \"All at once\" — Present all comments together, user reviews full list\n```\n\nWait for the user's selection before proceeding. Then:\n\n- **\"one by one\"** (default): Present each comment individually with your analysis and proposed solution. Wait for user to decide \"fix\" or \"skip\" before moving to the next one.\n- **\"all at once\"**: Present all comments together with your analysis. User reviews the full list, then says which to fix and which to skip.\n\nFor each comment, show:\n- The reviewer's comment (abbreviated)\n- **Your own independent analysis** — don't just parrot the reviewer. Verify if the concern is actually valid, check the relevant code/dependencies, and explain what's really happening. If the reviewer is wrong or partially wrong, say so.\n- Your proposed fix (code diff) or skip reasoning\n- Your recommendation\n\n### Step 4: Apply fixes\n\nApply all approved fixes to the codebase.\n\n### Step 5: Commit and push\n\nRun the `/commit` skill to format, lint, typecheck, stage, and commit. Then push:\n\n```bash\ngit push\n```\n\n### Step 6: Reply to comments on GitHub\n\nReply to each comment using the correct GitHub API endpoints:\n\n**For inline review comments** (pull request review comments):\n```bash\n# Reply to an inline review comment (creates a reply in the same thread)\ngh api repos/{owner}/{repo}/pulls/{pr}/comments/{comment_id}/replies \\\n  --method POST \\\n  -f body=\"<reply text>\"\n```\n\n**For issue-level comments** (general PR comments):\n```bash\ngh api repos/{owner}/{repo}/issues/{pr}/comments \\\n  --method POST \\\n  -f body=\"<reply text>\"\n```\n\nReply content:\n- If fixed: \"Fixed in <commit_sha>.\" (keep it short)\n- If skipped: Brief explanation of why (1-2 sentences)\n- Tag the reviewer with `@username` when replying to top-level comments\n\n## Important Notes\n\n- **Never guess comment IDs** — always fetch them from the API first\n- **Test the reply endpoint** — `pulls/{pr}/comments/{id}/replies` is for inline review comment threads. `issues/{pr}/comments` is for general PR comments.\n- **Don't reply to deploy/CI bots** — skip Vercel deploy previews, CI status comments. DO reply to AI review bots (Claude, CodeRabbit inline suggestions).\n- **Keep replies concise** — reviewers don't want essays\n"
  },
  {
    "path": ".env.example",
    "content": "NEXT_PUBLIC_WALLET_CONNECT_ID=12345678901234567890123456789012\nNEXT_PUBLIC_RPC_OVERRIDES='{\"chain1\":{\"http\":\"https://...\"}}'\n\n# Offchain fee quoting (optional)\nNEXT_PUBLIC_FEE_QUOTING_URL=       # Fee quoting service base URL\nFEE_QUOTING_API_KEY=               # Server-side only API key for fee quoting service\n\n# Localhost only; production deployments must use https://\nNEXT_PUBLIC_RELAY_API_URL=http://localhost:8900\n\n# Predicate Attestation Support (server-side only, optional)\n# Required for routes with PredicateRouterWrapper\nPREDICATE_API_KEY=your_predicate_api_key_here\n# Optional: custom Predicate API URL (defaults to https://api.predicate.io/v2/attestation)\n# SECURITY: This value is server-side only and must point to a trusted endpoint.\n# Do not set this to an arbitrary or user-controlled host — the server-side proxy\n# enforces HTTPS and an allowlist (api.predicate.io / predicate.io) to prevent SSRF.\nPREDICATE_API_URL=https://api.predicate.io/v2/attestation\n\n# AWS S3 Font Storage (server-side only, used by prebuild script)\nAWS_ACCESS_KEY_ID=your_access_key_id\nAWS_SECRET_ACCESS_KEY=your_secret_access_key\nAWS_S3_BUCKET=your_bucket_name\nAWS_REGION=us-east-1\n"
  },
  {
    "path": ".github/CODEOWNERS",
    "content": "* @xaroz @paulbalaji @xeno097 @troykessler\n"
  },
  {
    "path": ".github/prompts/security-scan.md",
    "content": "## Frontend Security Focus Areas\n\nThis is a Web3 frontend application. Pay special attention to:\n\n### XSS & Content Security\n- Input sanitization before rendering user data\n- Dangerous patterns: dangerouslySetInnerHTML, eval(), innerHTML\n- URL validation (javascript: protocol, data: URLs)\n- CSP headers and inline script risks\n\n### Web3 Wallet Security\n- Blind signature attacks (signing data without user understanding)\n- Transaction simulation before signing\n- Clear message display before signature requests\n- Proper origin/domain verification for wallet connections\n- **Chain-aware address validation** - EVM hex can lowercase; Solana base58/Cosmos bech32 are case-sensitive\n- **Don't collapse addresses** - Normalizing non-EVM addresses can create security issues\n\n### Dependency & Supply Chain\n- Known vulnerabilities in dependencies\n- Malicious packages, typosquatting\n- Outdated critical security packages\n\n### API & Token Security\n- CORS configuration\n- Token storage (avoid localStorage for sensitive tokens)\n- API key exposure in client-side code\n\n### Private Key Handling\n- NEVER expose private keys client-side\n- Check for hardcoded keys or mnemonics\n- Wallet connection patterns should not request keys\n\n### Content Security Policy\n- New external resources (scripts, styles, frames) need CSP header updates\n- Check `next.config.js` for script-src, style-src, connect-src, frame-src\n- Third-party integrations (Intercom, analytics, wallets) need explicit allowlisting\n- Test with CSP enabled in production mode\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: ci\n\non:\n  push:\n    branches: [main, nautilus, nexus, injective, trump, ousdt]\n  pull_request:\n    branches: [main, nautilus, nexus, injective, trump, ousdt]\n  merge_group:\n  workflow_dispatch:\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - uses: pnpm/action-setup@v4\n      - uses: actions/setup-node@v6\n        with:\n          node-version-file: '.nvmrc'\n\n      - name: Get pnpm store directory\n        id: pnpm-store\n        shell: bash\n        run: echo \"path=$(pnpm store path)\" >> $GITHUB_OUTPUT\n\n      - name: Cache pnpm store\n        uses: actions/cache@v4\n        with:\n          path: ${{ steps.pnpm-store.outputs.path }}\n          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-pnpm-store-\n\n      - name: Install dependencies\n        run: |\n          pnpm install --frozen-lockfile\n          CHANGES=$(git status -s)\n          if [[ ! -z $CHANGES ]]; then\n            echo \"Changes found: $CHANGES\"\n            git diff\n            exit 1\n          fi\n\n      - name: Cache build output\n        id: build-cache\n        uses: actions/cache@v4\n        with:\n          path: .next/\n          key: ${{ runner.os }}-nextjs-build-${{ hashFiles('src/**', 'pnpm-lock.yaml', 'next.config.js', 'tsconfig.json') }}\n\n      - name: build\n        if: steps.build-cache.outputs.cache-hit != 'true'\n        run: pnpm run build\n        env:\n          NEXT_PUBLIC_WALLET_CONNECT_ID: ${{ secrets.NEXT_PUBLIC_WALLET_CONNECT_ID }}\n\n  typecheck:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - uses: pnpm/action-setup@v4\n      - uses: actions/setup-node@v6\n        with:\n          node-version-file: '.nvmrc'\n\n      - name: Get pnpm store directory\n        id: pnpm-store\n        shell: bash\n        run: echo \"path=$(pnpm store path)\" >> $GITHUB_OUTPUT\n\n      - name: Cache pnpm store\n        uses: actions/cache@v4\n        with:\n          path: ${{ steps.pnpm-store.outputs.path }}\n          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-pnpm-store-\n\n      - name: Install dependencies\n        run: pnpm install --frozen-lockfile\n\n      - name: typecheck\n        run: pnpm run typecheck\n\n  format:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - uses: pnpm/action-setup@v4\n      - uses: actions/setup-node@v6\n        with:\n          node-version-file: '.nvmrc'\n\n      - name: Get pnpm store directory\n        id: pnpm-store\n        shell: bash\n        run: echo \"path=$(pnpm store path)\" >> $GITHUB_OUTPUT\n\n      - name: Cache pnpm store\n        uses: actions/cache@v4\n        with:\n          path: ${{ steps.pnpm-store.outputs.path }}\n          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-pnpm-store-\n\n      - name: Install dependencies\n        run: pnpm install --frozen-lockfile\n\n      - name: format\n        run: |\n          pnpm run format\n          CHANGES=$(git status -s)\n          if [[ ! -z $CHANGES ]]; then\n            echo \"Changes found: $CHANGES\"\n            exit 1\n          fi\n\n  lint:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - uses: pnpm/action-setup@v4\n      - uses: actions/setup-node@v6\n        with:\n          node-version-file: '.nvmrc'\n\n      - name: Get pnpm store directory\n        id: pnpm-store\n        shell: bash\n        run: echo \"path=$(pnpm store path)\" >> $GITHUB_OUTPUT\n\n      - name: Cache pnpm store\n        uses: actions/cache@v4\n        with:\n          path: ${{ steps.pnpm-store.outputs.path }}\n          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-pnpm-store-\n\n      - name: Install dependencies\n        run: pnpm install --frozen-lockfile\n\n      - name: lint\n        run: pnpm run lint\n\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - uses: pnpm/action-setup@v4\n      - uses: actions/setup-node@v6\n        with:\n          node-version-file: '.nvmrc'\n\n      - name: Get pnpm store directory\n        id: pnpm-store\n        shell: bash\n        run: echo \"path=$(pnpm store path)\" >> $GITHUB_OUTPUT\n\n      - name: Cache pnpm store\n        uses: actions/cache@v4\n        with:\n          path: ${{ steps.pnpm-store.outputs.path }}\n          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-pnpm-store-\n\n      - name: Install dependencies\n        run: pnpm install --frozen-lockfile\n\n      - name: test\n        run: pnpm run test\n\n  e2e:\n    needs: [build]\n    if: >-\n      github.base_ref == 'main' ||\n      (github.event_name == 'push' && github.ref == 'refs/heads/main') ||\n      github.event_name == 'merge_group' ||\n      github.event_name == 'workflow_dispatch'\n    uses: ./.github/workflows/e2e.yml\n    secrets: inherit\n\n  # Fast wallet-connected E2E subset, gates every PR.\n  e2e-smoke:\n    needs: [build]\n    uses: ./.github/workflows/e2e-smoke.yml\n    secrets: inherit\n\n  # Full wallet-connected E2E matrix (chromium/firefox/webkit). Main-only so\n  # PRs stay fast; merge_group runs it as a final gate before landing.\n  e2e-wallet-full:\n    needs: [build]\n    if: >-\n      (github.event_name == 'push' && github.ref == 'refs/heads/main') ||\n      github.event_name == 'merge_group' ||\n      github.event_name == 'workflow_dispatch'\n    uses: ./.github/workflows/e2e-wallet-full.yml\n    secrets: inherit\n"
  },
  {
    "path": ".github/workflows/claude-code-review.yml",
    "content": "name: Claude Code Review\n\non:\n  pull_request:\n    types: [opened, synchronize, reopened, ready_for_review]\n  pull_request_review_comment:\n    types: [created]\n  issue_comment:\n    types: [created]\n\nenv:\n  CLAUDE_OPUS_MODEL: claude-opus-4-7\n  CLAUDE_SONNET_MODEL: claude-sonnet-4-6\n\nconcurrency:\n  group: claude-review-${{ github.event.pull_request.number || github.event.issue.number }}\n  cancel-in-progress: false\n\njobs:\n  code-review:\n    if: |\n      (\n        github.event_name == 'issue_comment' &&\n        github.event.issue.pull_request &&\n        contains(github.event.comment.body, '@claude review') &&\n        (\n          github.event.comment.author_association == 'MEMBER' ||\n          github.event.comment.author_association == 'OWNER' ||\n          github.event.comment.author_association == 'COLLABORATOR'\n        )\n      ) ||\n      (\n        github.event_name == 'pull_request' &&\n        contains(join(github.event.pull_request.labels.*.name, ','), 'claude-review') &&\n        github.event.pull_request.head.repo.full_name == github.repository\n      )\n    runs-on: ubuntu-latest\n    timeout-minutes: 15\n    permissions:\n      contents: read\n      pull-requests: write\n      issues: write\n      id-token: write\n    steps:\n      - name: Get PR SHA\n        id: pr-sha\n        uses: actions/github-script@v7\n        with:\n          script: |\n            if (context.eventName === 'issue_comment') {\n              const { data: pr } = await github.rest.pulls.get({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                pull_number: context.issue.number\n              });\n              core.setOutput('head_sha', pr.head.sha);\n            } else {\n              core.setOutput('head_sha', context.payload.pull_request.head.sha);\n            }\n\n      - name: Checkout repository\n        uses: actions/checkout@v6\n        with:\n          ref: ${{ steps.pr-sha.outputs.head_sha }}\n          fetch-depth: 0\n\n      - name: Run Claude Code Review\n        uses: anthropics/claude-code-action@v1\n        with:\n          anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}\n          prompt: \"Use agent teams to run /claude-review, then use /inline-pr-comments to post findings as a consolidated PR review with inline comments\"\n          track_progress: true\n          use_sticky_comment: false\n          claude_args: |\n            --model ${{ env.CLAUDE_OPUS_MODEL }}\n            --allowedTools \"Bash(gh api:*)\"\n\n  security-review:\n    if: |\n      (\n        github.event_name == 'pull_request' &&\n        !github.event.pull_request.draft &&\n        github.event.pull_request.head.repo.full_name == github.repository\n      ) ||\n      (\n        github.event_name == 'issue_comment' &&\n        github.event.issue.pull_request &&\n        contains(github.event.comment.body, '@claude security') &&\n        (\n          github.event.comment.author_association == 'MEMBER' ||\n          github.event.comment.author_association == 'OWNER' ||\n          github.event.comment.author_association == 'COLLABORATOR'\n        )\n      )\n    runs-on: ubuntu-latest\n    timeout-minutes: 20\n    permissions:\n      contents: read\n      pull-requests: write\n      issues: write\n      id-token: write\n    steps:\n      - name: Get PR SHA\n        id: pr-sha\n        uses: actions/github-script@v7\n        with:\n          script: |\n            if (context.eventName === 'issue_comment') {\n              const { data: pr } = await github.rest.pulls.get({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                pull_number: context.issue.number\n              });\n              core.setOutput('head_sha', pr.head.sha);\n            } else {\n              core.setOutput('head_sha', context.payload.pull_request.head.sha);\n            }\n\n      - name: Checkout repository\n        uses: actions/checkout@v6\n        with:\n          ref: ${{ steps.pr-sha.outputs.head_sha }}\n          fetch-depth: 2\n\n      - name: Run Claude Security Review\n        uses: anthropics/claude-code-security-review@25e460eb0a12077f0c6a1934d5dbae2f50785dda\n        with:\n          claude-api-key: ${{ secrets.ANTHROPIC_API_KEY }}\n          comment-pr: true\n          upload-results: true\n          exclude-directories: 'node_modules,dist,.next,coverage,cache'\n          claudecode-timeout: '15'\n          claude-model: ${{ env.CLAUDE_OPUS_MODEL }}\n          custom-security-scan-instructions: '.github/prompts/security-scan.md'\n\n  interactive:\n    if: |\n      (\n        github.event_name == 'issue_comment' &&\n        github.event.issue.pull_request &&\n        contains(github.event.comment.body, '@claude') &&\n        !contains(github.event.comment.body, '@claude review') &&\n        !contains(github.event.comment.body, '@claude security') &&\n        (\n          github.event.comment.author_association == 'MEMBER' ||\n          github.event.comment.author_association == 'OWNER' ||\n          github.event.comment.author_association == 'COLLABORATOR'\n        )\n      ) ||\n      (\n        github.event_name == 'pull_request_review_comment' &&\n        github.event.pull_request.head.repo.full_name == github.repository &&\n        contains(github.event.comment.body, '@claude') &&\n        !contains(github.event.comment.body, '@claude security') &&\n        (\n          github.event.comment.author_association == 'MEMBER' ||\n          github.event.comment.author_association == 'OWNER' ||\n          github.event.comment.author_association == 'COLLABORATOR'\n        )\n      )\n    runs-on: ubuntu-latest\n    timeout-minutes: 15\n    permissions:\n      contents: read\n      pull-requests: write\n      issues: write\n      id-token: write\n    steps:\n      - name: Get PR SHA\n        id: pr-sha\n        uses: actions/github-script@v7\n        with:\n          script: |\n            let prNumber;\n            if (context.eventName === 'issue_comment') {\n              prNumber = context.issue.number;\n            } else if (context.eventName === 'pull_request_review_comment') {\n              prNumber = context.payload.pull_request.number;\n            }\n            if (prNumber) {\n              const { data: pr } = await github.rest.pulls.get({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                pull_number: prNumber\n              });\n              core.setOutput('head_sha', pr.head.sha);\n            }\n\n      - name: Checkout repository\n        uses: actions/checkout@v6\n        with:\n          ref: ${{ steps.pr-sha.outputs.head_sha || github.sha }}\n          fetch-depth: 0\n\n      - name: Run Claude Code Assistant\n        uses: anthropics/claude-code-action@v1\n        with:\n          anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}\n          track_progress: true\n          use_sticky_comment: false\n          claude_args: \"--model ${{ env.CLAUDE_SONNET_MODEL }}\"\n"
  },
  {
    "path": ".github/workflows/create-merge-prs.yaml",
    "content": "name: Merge Main to Other Branches\n\non:\n  push:\n    branches:\n      - main\n  workflow_dispatch:\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: true\n\njobs:\n  create-merge-prs:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n      pull-requests: write\n    strategy:\n      fail-fast: false\n      matrix:\n        branch: [injective, nexus, trump, ousdt]\n    steps:\n      - name: Generate GitHub App Token\n        id: generate-token\n        uses: actions/create-github-app-token@v2\n        with:\n          app-id: ${{ secrets.HYPER_GONK_APP_ID }}\n          private-key: ${{ secrets.HYPER_GONK_PRIVATE_KEY }}\n\n      - name: Checkout repository\n        uses: actions/checkout@v5\n        with:\n          fetch-depth: 0\n          token: ${{ steps.generate-token.outputs.token }}\n\n      - name: Get GitHub App User ID\n        id: get-user-id\n        run: echo \"user-id=$(gh api /users/${{ steps.generate-token.outputs.app-slug }}[bot] --jq .id)\" >> \"$GITHUB_OUTPUT\"\n        env:\n          GH_TOKEN: ${{ steps.generate-token.outputs.token }}\n\n      - name: Configure Git for Hyper Gonk\n        run: |\n          git config user.name \"${{ steps.generate-token.outputs.app-slug }}[bot]\"\n          git config user.email \"${{ steps.get-user-id.outputs.user-id }}+${{ steps.generate-token.outputs.app-slug }}[bot]@users.noreply.github.com\"\n\n      - name: Check if merge is needed\n        id: check-merge\n        run: |\n          git fetch origin ${{ matrix.branch }}\n          if git merge-base --is-ancestor origin/main origin/${{ matrix.branch }}; then\n            echo \"Branch ${{ matrix.branch }} is already up-to-date with main\"\n            echo \"needs_merge=false\" >> $GITHUB_OUTPUT\n          else\n            echo \"Branch ${{ matrix.branch }} needs updates from main\"\n            echo \"needs_merge=true\" >> $GITHUB_OUTPUT\n          fi\n\n      - name: Create and push merge branch\n        if: steps.check-merge.outputs.needs_merge == 'true'\n        id: merge\n        run: |\n          git checkout ${{ matrix.branch }}\n\n          # Attempt merge, capturing if there are conflicts\n          if git merge origin/main --no-edit; then\n            echo \"has_conflicts=false\" >> $GITHUB_OUTPUT\n          else\n            echo \"::warning::Merge conflict detected for ${{ matrix.branch }}\"\n            echo \"has_conflicts=true\" >> $GITHUB_OUTPUT\n            # Stage all files including conflicts to allow branch creation\n            git add -A\n            git commit --no-edit || true\n          fi\n\n          git branch -M main-to-${{ matrix.branch }}\n          git push -fu origin main-to-${{ matrix.branch }}\n        env:\n          GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}\n\n      - name: Create or update PR\n        if: steps.check-merge.outputs.needs_merge == 'true'\n        run: |\n          PR_EXISTS=$(gh pr list --base ${{ matrix.branch }} --head main-to-${{ matrix.branch }} --json number --jq '.[0].number')\n\n          if [ \"${{ steps.merge.outputs.has_conflicts }}\" == \"true\" ]; then\n            BODY=\"⚠️ **This PR has merge conflicts that need to be resolved manually.**\n\n          This PR was automatically created to merge changes from \\`main\\` into \\`${{ matrix.branch }}\\`.\"\n          else\n            BODY=\"This PR was automatically created to merge changes from \\`main\\` into \\`${{ matrix.branch }}\\`.\"\n          fi\n\n          if [ -z \"$PR_EXISTS\" ]; then\n            gh pr create \\\n              --base ${{ matrix.branch }} \\\n              --head main-to-${{ matrix.branch }} \\\n              --title \"chore: merge main into ${{ matrix.branch }}\" \\\n              --body \"$BODY\" \\\n              --draft\n            echo \"Created new PR for ${{ matrix.branch }}\"\n          else\n            echo \"PR #$PR_EXISTS already exists for ${{ matrix.branch }}\"\n          fi\n        env:\n          GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}\n"
  },
  {
    "path": ".github/workflows/e2e-smoke.yml",
    "content": "name: e2e-smoke\n\n# Fast subset of the wallet-connected E2E suite. Runs on every PR as a gate.\n# The full wallet E2E matrix runs from .github/workflows/e2e-wallet-full.yml.\n\non:\n  workflow_call:\n  workflow_dispatch:\n\njobs:\n  e2e-smoke:\n    timeout-minutes: 10\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - uses: pnpm/action-setup@v4\n      - uses: actions/setup-node@v6\n        with:\n          node-version-file: '.nvmrc'\n\n      - name: Get pnpm store directory\n        id: pnpm-store\n        shell: bash\n        run: echo \"path=$(pnpm store path)\" >> $GITHUB_OUTPUT\n\n      - name: Cache pnpm store\n        uses: actions/cache@v4\n        with:\n          path: ${{ steps.pnpm-store.outputs.path }}\n          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-pnpm-store-\n\n      - name: Install dependencies\n        run: pnpm install --frozen-lockfile\n\n      - name: Restore build cache\n        uses: actions/cache/restore@v4\n        with:\n          path: .next/\n          key: ${{ runner.os }}-nextjs-build-${{ hashFiles('src/**', 'pnpm-lock.yaml', 'next.config.js', 'tsconfig.json') }}\n\n      - name: Get Playwright version\n        id: pw-version\n        run: echo \"version=$(pnpm exec playwright --version)\" >> $GITHUB_OUTPUT\n\n      - name: Cache Playwright browsers\n        id: pw-cache\n        uses: actions/cache@v4\n        with:\n          path: ~/.cache/ms-playwright\n          key: ${{ runner.os }}-playwright-${{ steps.pw-version.outputs.version }}\n\n      - name: Install Playwright browsers\n        if: steps.pw-cache.outputs.cache-hit != 'true'\n        run: pnpm exec playwright install chromium\n\n      - name: Install Playwright system dependencies\n        run: pnpm exec playwright install-deps chromium\n\n      - name: Run E2E wallet smoke\n        run: pnpm run test:e2e:wallet:smoke\n        env:\n          NEXT_PUBLIC_WALLET_CONNECT_ID: ${{ secrets.NEXT_PUBLIC_WALLET_CONNECT_ID }}\n\n      - name: Upload report on failure\n        if: failure()\n        uses: actions/upload-artifact@v4\n        with:\n          name: e2e-smoke-report-${{ github.run_attempt }}\n          path: playwright-report\n          retention-days: 7\n"
  },
  {
    "path": ".github/workflows/e2e-wallet-full.yml",
    "content": "name: e2e-wallet-full\n\n# Full wallet-connected E2E suite across chromium/firefox/webkit.\n# Called from ci.yml on push-to-main/merge_group/workflow_dispatch.\n# On PR, only the smoke subset runs (see e2e-smoke.yml).\n\non:\n  workflow_call:\n  workflow_dispatch:\n\njobs:\n  e2e-wallet:\n    timeout-minutes: 25\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        # webkit disabled: prod build serves CSP `upgrade-insecure-requests`,\n        # which webkit strictly honors on localhost (chromium/firefox special-\n        # case it). All chunks get upgraded to https://localhost and fail SSL,\n        # so the app never hydrates. Re-enable once we have a build path that\n        # drops the upgrade directive for e2e without weakening prod CSP.\n        browser: [chromium, firefox]\n    steps:\n      - uses: actions/checkout@v6\n      - uses: pnpm/action-setup@v4\n      - uses: actions/setup-node@v6\n        with:\n          node-version-file: '.nvmrc'\n\n      - name: Get pnpm store directory\n        id: pnpm-store\n        shell: bash\n        run: echo \"path=$(pnpm store path)\" >> $GITHUB_OUTPUT\n\n      - name: Cache pnpm store\n        uses: actions/cache@v4\n        with:\n          path: ${{ steps.pnpm-store.outputs.path }}\n          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-pnpm-store-\n\n      - name: Install dependencies\n        run: pnpm install --frozen-lockfile\n\n      - name: Restore build cache\n        uses: actions/cache/restore@v4\n        with:\n          path: .next/\n          key: ${{ runner.os }}-nextjs-build-${{ hashFiles('src/**', 'pnpm-lock.yaml', 'next.config.js', 'tsconfig.json') }}\n\n      - name: Get Playwright version\n        id: pw-version\n        run: echo \"version=$(pnpm exec playwright --version)\" >> $GITHUB_OUTPUT\n\n      - name: Cache Playwright browsers\n        id: pw-cache\n        uses: actions/cache@v4\n        with:\n          path: ~/.cache/ms-playwright\n          key: ${{ runner.os }}-playwright-${{ steps.pw-version.outputs.version }}-${{ matrix.browser }}\n\n      - name: Install Playwright browsers\n        if: steps.pw-cache.outputs.cache-hit != 'true'\n        run: pnpm exec playwright install ${{ matrix.browser }}\n\n      - name: Install Playwright system dependencies\n        run: pnpm exec playwright install-deps ${{ matrix.browser }}\n\n      - name: Run E2E wallet tests\n        run: pnpm exec playwright test tests/e2e-wallet --project=${{ matrix.browser }}\n        env:\n          NEXT_PUBLIC_WALLET_CONNECT_ID: ${{ secrets.NEXT_PUBLIC_WALLET_CONNECT_ID }}\n\n      - name: Upload report\n        if: ${{ !cancelled() }}\n        uses: actions/upload-artifact@v4\n        with:\n          name: e2e-wallet-report-${{ matrix.browser }}-${{ github.run_attempt }}\n          path: playwright-report\n          retention-days: 14\n\n  notify-failure:\n    if: ${{ always() && needs.e2e-wallet.result == 'failure' }}\n    needs: [e2e-wallet]\n    runs-on: ubuntu-latest\n    steps:\n      - name: Notify Slack on e2e-wallet failure\n        uses: slackapi/slack-github-action@v3\n        with:\n          webhook: ${{ secrets.E2E_SLACK_WEBHOOK }}\n          webhook-type: incoming-webhook\n          payload: |\n            text: \":alert: Warp UI full e2e-wallet matrix failed — see workflow run for details\"\n            blocks:\n              - type: \"section\"\n                text:\n                  type: \"mrkdwn\"\n                  text: \":alert: *Warp UI e2e-wallet full matrix failed*\\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View workflow run #${{ github.run_number }}> on `${{ github.ref_name }}` @ ${{ github.sha }}\"\n"
  },
  {
    "path": ".github/workflows/e2e.yml",
    "content": "name: e2e\n\non:\n  workflow_call:\n  workflow_dispatch:\n\njobs:\n  build:\n    # Only build when triggered standalone (workflow_dispatch).\n    # When called from ci.yml (workflow_call), the build cache is already populated.\n    if: ${{ github.event_name == 'workflow_dispatch' }}\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - uses: pnpm/action-setup@v4\n      - uses: actions/setup-node@v6\n        with:\n          node-version-file: '.nvmrc'\n\n      - name: Get pnpm store directory\n        id: pnpm-store\n        shell: bash\n        run: echo \"path=$(pnpm store path)\" >> $GITHUB_OUTPUT\n\n      - name: Cache pnpm store\n        uses: actions/cache@v4\n        with:\n          path: ${{ steps.pnpm-store.outputs.path }}\n          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-pnpm-store-\n\n      - name: Install dependencies\n        run: pnpm install --frozen-lockfile\n\n      - name: Cache build output\n        id: build-cache\n        uses: actions/cache@v4\n        with:\n          path: .next/\n          key: ${{ runner.os }}-nextjs-build-${{ hashFiles('src/**', 'pnpm-lock.yaml', 'next.config.js', 'tsconfig.json') }}\n\n      - name: build\n        if: steps.build-cache.outputs.cache-hit != 'true'\n        run: pnpm run build\n        env:\n          NEXT_PUBLIC_WALLET_CONNECT_ID: ${{ secrets.NEXT_PUBLIC_WALLET_CONNECT_ID }}\n\n  e2e:\n    timeout-minutes: 10\n    needs: [build]\n    if: ${{ !cancelled() && (needs.build.result == 'success' || needs.build.result == 'skipped') }}\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        shardIndex: [1, 2]\n        shardTotal: [2]\n    steps:\n      - uses: actions/checkout@v6\n      - uses: pnpm/action-setup@v4\n      - uses: actions/setup-node@v6\n        with:\n          node-version-file: '.nvmrc'\n\n      - name: Get pnpm store directory\n        id: pnpm-store\n        shell: bash\n        run: echo \"path=$(pnpm store path)\" >> $GITHUB_OUTPUT\n\n      - name: Cache pnpm store\n        uses: actions/cache@v4\n        with:\n          path: ${{ steps.pnpm-store.outputs.path }}\n          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-pnpm-store-\n\n      - name: Install dependencies\n        run: pnpm install --frozen-lockfile\n\n      - name: Restore build cache\n        uses: actions/cache/restore@v4\n        with:\n          path: .next/\n          key: ${{ runner.os }}-nextjs-build-${{ hashFiles('src/**', 'pnpm-lock.yaml', 'next.config.js', 'tsconfig.json') }}\n\n      - name: Get Playwright version\n        id: pw-version\n        run: echo \"version=$(pnpm exec playwright --version)\" >> $GITHUB_OUTPUT\n\n      - name: Cache Playwright browsers\n        id: pw-cache\n        uses: actions/cache@v4\n        with:\n          path: ~/.cache/ms-playwright\n          key: ${{ runner.os }}-playwright-${{ steps.pw-version.outputs.version }}\n\n      - name: Install Playwright browsers\n        if: steps.pw-cache.outputs.cache-hit != 'true'\n        run: pnpm exec playwright install chromium\n\n      - name: Install Playwright system dependencies\n        run: pnpm exec playwright install-deps chromium\n\n      - name: Run Playwright tests\n        run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}\n\n      - name: Upload blob report\n        if: ${{ !cancelled() }}\n        uses: actions/upload-artifact@v4\n        with:\n          name: blob-report-${{ matrix.shardIndex }}\n          path: blob-report\n          retention-days: 1\n\n  merge-reports:\n    if: ${{ !cancelled() }}\n    needs: [e2e]\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - uses: pnpm/action-setup@v4\n      - uses: actions/setup-node@v6\n        with:\n          node-version-file: '.nvmrc'\n\n      - name: Install dependencies\n        run: pnpm install --frozen-lockfile\n\n      - name: Download blob reports\n        uses: actions/download-artifact@v4\n        with:\n          path: all-blob-reports\n          pattern: blob-report-*\n          merge-multiple: true\n\n      - name: Merge into HTML report\n        run: pnpm exec playwright merge-reports --reporter html ./all-blob-reports\n\n      - name: Upload HTML report\n        uses: actions/upload-artifact@v4\n        with:\n          name: playwright-report-attempt-${{ github.run_attempt }}\n          path: playwright-report\n          retention-days: 14\n"
  },
  {
    "path": ".github/workflows/update-hyperlane-deps.yml",
    "content": "name: Update Hyperlane Dependencies\n\non:\n  schedule:\n    # Run weekly on Mondays at 9 AM UTC\n    - cron: '0 9 * * 1'\n  workflow_dispatch: # Allow manual triggering\n\njobs:\n  update-dependencies:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n      pull-requests: write\n\n    steps:\n      - name: Generate GitHub App Token\n        id: generate-token\n        uses: actions/create-github-app-token@v2\n        with:\n          app-id: ${{ secrets.HYPER_GONK_APP_ID }}\n          private-key: ${{ secrets.HYPER_GONK_PRIVATE_KEY }}\n\n      - name: Checkout repository\n        uses: actions/checkout@v6\n        with:\n          ref: main\n          token: ${{ steps.generate-token.outputs.token }}\n\n      - name: Get GitHub App User ID\n        id: get-user-id\n        run: echo \"user-id=$(gh api /users/${{ steps.generate-token.outputs.app-slug }}[bot] --jq .id)\" >> \"$GITHUB_OUTPUT\"\n        env:\n          GH_TOKEN: ${{ steps.generate-token.outputs.token }}\n\n      - name: Configure Git\n        run: |\n          git config user.name \"${{ steps.generate-token.outputs.app-slug }}[bot]\"\n          git config user.email \"${{ steps.get-user-id.outputs.user-id }}+${{ steps.generate-token.outputs.app-slug }}[bot]@users.noreply.github.com\"\n\n      - name: Setup pnpm\n        uses: pnpm/action-setup@v4\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version: '22'\n\n      - name: Get latest SDK, Utils, Registry, and Widgets versions\n        id: get-versions\n        run: |\n          LATEST_SDK=$(pnpm view @hyperlane-xyz/sdk version)\n          LATEST_UTILS=$(pnpm view @hyperlane-xyz/utils version)\n          LATEST_REGISTRY=$(pnpm view @hyperlane-xyz/registry version)\n          LATEST_WIDGETS=$(pnpm view @hyperlane-xyz/widgets version)\n          echo \"sdk=$LATEST_SDK\" >> $GITHUB_OUTPUT\n          echo \"utils=$LATEST_UTILS\" >> $GITHUB_OUTPUT\n          echo \"registry=$LATEST_REGISTRY\" >> $GITHUB_OUTPUT\n          echo \"widgets=$LATEST_WIDGETS\" >> $GITHUB_OUTPUT\n          echo \"Latest SDK: $LATEST_SDK\"\n          echo \"Latest Utils: $LATEST_UTILS\"\n          echo \"Latest Registry: $LATEST_REGISTRY\"\n          echo \"Latest Widgets: $LATEST_WIDGETS\"\n\n      - name: Update package.json with latest versions\n        run: |\n          npm pkg set dependencies.@hyperlane-xyz/sdk=${{ steps.get-versions.outputs.sdk }}\n          npm pkg set dependencies.@hyperlane-xyz/utils=${{ steps.get-versions.outputs.utils }}\n          npm pkg set dependencies.@hyperlane-xyz/registry=${{ steps.get-versions.outputs.registry }}\n          npm pkg set dependencies.@hyperlane-xyz/widgets=${{ steps.get-versions.outputs.widgets }}\n\n      - name: Install dependencies\n        run: pnpm install --no-frozen-lockfile\n\n      - name: Check for changes and create PR\n        env:\n          GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}\n        run: |\n          if ! git diff --quiet; then\n            git checkout -b ci/update-hl-deps\n            git add -A\n            git commit -m \"chore: update Hyperlane deps to SDK ${{ steps.get-versions.outputs.sdk }} and Registry ${{ steps.get-versions.outputs.registry }}\n\n          - Update @hyperlane-xyz/sdk to ${{ steps.get-versions.outputs.sdk }}\n          - Update @hyperlane-xyz/utils to ${{ steps.get-versions.outputs.utils }}\n          - Update @hyperlane-xyz/registry to ${{ steps.get-versions.outputs.registry }}\n          - Update @hyperlane-xyz/widgets to ${{ steps.get-versions.outputs.widgets }}\"\n\n            git push -fu origin ci/update-hl-deps\n\n            PR_TITLE=\"chore: update Hyperlane deps\"\n            PR_BODY=\"## Automated Dependency Update\n\n          This PR updates the Hyperlane dependencies to their latest versions.\n\n          **Updated versions:**\n          - \\`@hyperlane-xyz/sdk\\`: \\`${{ steps.get-versions.outputs.sdk }}\\`\n          - \\`@hyperlane-xyz/utils\\`: \\`${{ steps.get-versions.outputs.utils }}\\`\n          - \\`@hyperlane-xyz/registry\\`: \\`${{ steps.get-versions.outputs.registry }}\\`\n          - \\`@hyperlane-xyz/widgets\\`: \\`${{ steps.get-versions.outputs.widgets }}\\`\n\n          **Changes include:**\n          - Updated \\`package.json\\` with latest Hyperlane package versions\n          - Updated \\`pnpm-lock.yaml\\` via \\`pnpm install\\`\n\n          ---\n          🤖 This PR was automatically generated by the [update-hyperlane-deps workflow](.github/workflows/update-hyperlane-deps.yml)\"\n\n            PR_EXISTS=$(gh pr list --base main --head ci/update-hl-deps --json number --jq length)\n            if [ \"$PR_EXISTS\" -eq \"0\" ]; then\n              gh pr create \\\n                --base main \\\n                --head ci/update-hl-deps \\\n                --title \"$PR_TITLE\" \\\n                --body \"$PR_BODY\"\n            else\n              echo \"Pull request already exists. Updating title and description...\"\n              gh pr edit ci/update-hl-deps \\\n                --title \"$PR_TITLE\" \\\n                --body \"$PR_BODY\"\n            fi\n          else\n            echo \"No changes detected. Skipping PR creation.\"\n          fi\n"
  },
  {
    "path": ".gitignore",
    "content": "# dependencies\n/node_modules\ncache/\n.pnpm-store/\n\n# testing\n/coverage\ncoverage.json\n/test/outputs\n\n# next.js\n/.next/\n/out/\n\n# production\n/src/context/*.json\n/artifacts\n/build\n/dist\n\n# misc\n.DS_Store\n*.pem\n\n# debug\nnpm-debug.log*\n.pnpm-debug.log*\n\n# local env files\n.env*\n!.env.example\n\n# vercel\n.vercel\n\n# typescript\n*.tsbuildinfo\n\n.idea\n\n.monorepo-tarballs\n\n.opencode\n.sisyphus\n\n# fonts (downloaded via scripts/fetch-fonts.mjs)\npublic/fonts/\n\n# Playwright\nnode_modules/\n/test-results/\n/playwright-report/\n/blob-report/\n/playwright/.cache/\n/playwright/.auth/\n/.playwright-mcp/\n"
  },
  {
    "path": ".nvmrc",
    "content": "v24\n"
  },
  {
    "path": ".oxfmtrc.json",
    "content": "{\n  \"singleQuote\": true,\n  \"sortImports\": {},\n  \"sortTailwindcss\": {\n    \"functions\": [\"clsx\"]\n  },\n  \"ignorePatterns\": [\"test/outputs\", \"public\", \"LICENSE.md\"]\n}\n"
  },
  {
    "path": ".oxlintrc.json",
    "content": "{\n  \"plugins\": [\"typescript\", \"import\", \"react\", \"nextjs\", \"jsx-a11y\"],\n  \"rules\": {\n    \"camelcase\": \"error\",\n    \"guard-for-in\": \"error\",\n    \"import/no-cycle\": \"error\",\n    \"import/no-self-import\": \"error\",\n    \"no-console\": \"warn\",\n    \"no-eval\": \"error\",\n    \"no-ex-assign\": \"error\",\n    \"no-extra-boolean-cast\": \"error\",\n    \"no-constant-condition\": \"off\",\n    \"typescript/ban-ts-comment\": \"off\",\n    \"typescript/explicit-module-boundary-types\": \"off\",\n    \"typescript/no-explicit-any\": \"off\",\n    \"typescript/no-non-null-assertion\": \"off\",\n    \"typescript/no-require-imports\": \"warn\",\n    \"typescript/no-unused-vars\": [\n      \"error\",\n      {\n        \"argsIgnorePattern\": \"^_\",\n        \"varsIgnorePattern\": \"^_\",\n        \"caughtErrorsIgnorePattern\": \"^_\"\n      }\n    ],\n    \"no-unused-vars\": \"off\",\n    \"nextjs/no-img-element\": \"off\",\n    \"jsx-a11y/alt-text\": \"off\",\n    \"jsx-a11y/label-has-associated-control\": \"off\"\n  },\n  \"ignorePatterns\": [\n    \"node_modules\",\n    \"dist\",\n    \"build\",\n    \"coverage\",\n    \".next\",\n    \"postcss.config.js\",\n    \"next.config.js\",\n    \"tailwind.config.js\",\n    \"sentry.*\"\n  ]\n}\n"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\"oxc.oxc-vscode\"],\n  \"unwantedRecommendations\": []\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"search.exclude\": {\n    \"**/node_modules/**\": true\n  },\n  \"files.exclude\": {\n    \"**/*.js.map\": true,\n    \"**/*.js\": { \"when\": \"$(basename).ts\" },\n    \"**/*.map\": { \"when\": \"$(basename).map\" }\n  },\n  \"files.watcherExclude\": {\n    \"**/.git/objects/**\": true,\n    \"**/.git/subtree-cache/**\": true,\n    \"**/node_modules/*/**\": true\n  },\n  \"editor.formatOnSave\": true,\n  \"editor.tabSize\": 2,\n  \"editor.detectIndentation\": false,\n  \"typescript.updateImportsOnFileMove.enabled\": \"always\",\n  \"typescript.tsdk\": \"node_modules/typescript/lib\",\n  \"[typescript]\": {\n    \"editor.defaultFormatter\": \"oxc.oxc-vscode\"\n  },\n  \"[typescriptreact]\": {\n    \"editor.defaultFormatter\": \"oxc.oxc-vscode\"\n  },\n  \"[javascript]\": {\n    \"editor.defaultFormatter\": \"oxc.oxc-vscode\"\n  },\n  \"[json]\": {\n    \"editor.defaultFormatter\": \"oxc.oxc-vscode\"\n  },\n  \"[jsonc]\": {\n    \"editor.defaultFormatter\": \"oxc.oxc-vscode\"\n  },\n  \"[html]\": {\n    \"editor.defaultFormatter\": \"oxc.oxc-vscode\"\n  },\n  \"[css]\": {\n    \"editor.defaultFormatter\": \"oxc.oxc-vscode\"\n  },\n  \"tailwindCSS.experimental.classRegex\": [[\"clsx\\\\(([^)]*)\\\\)\", \"(?:'|\\\"|`)([^']*)(?:'|\\\"|`)\"]]\n}\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# AGENTS.md\n\n**Be extremely concise. Sacrifice grammar for concision. Terse responses preferred. No fluff.**\n\nThis file provides guidance to AI coding assistants when working with code in this repository.\n\n## Project Overview\n\nHyperlane Warp UI Template is a Next.js web application for cross-chain token transfers using [Hyperlane Warp Routes](https://docs.hyperlane.xyz/docs/reference/applications/warp-routes). It enables permissionless bridging of tokens between any supported blockchain.\n\n## Plan Mode\n\n- Make the plan extremely concise. Sacrifice grammar for the sake of concision.\n- At the end of each plan, give me a list of unresolved questions to answer, if any.\n\n## Development Commands\n\n```bash\npnpm install          # Install dependencies\npnpm dev              # Start development server\npnpm build            # Production build\npnpm test             # Run tests (vitest)\npnpm lint             # ESLint check\npnpm typecheck        # TypeScript type checking\npnpm prettier         # Format code with Prettier\npnpm clean            # Remove build artifacts (dist, cache, .next)\n```\n\n## Architecture\n\n### Stack\n- **Framework**: Next.js 15 with React 18\n- **Styling**: Tailwind CSS + Chakra UI\n- **State**: Zustand with persist middleware (`src/features/store.ts`)\n- **Queries**: TanStack Query\n- **Wallets**: Each blockchain uses distinct, composable wallet providers (EVM/RainbowKit, Solana, Cosmos, Starknet, Radix)\n- **Core Libraries**: `@hyperlane-xyz/sdk`, `@hyperlane-xyz/registry`, `@hyperlane-xyz/widgets`, `@hyperlane-xyz/utils`\n\n### Key Directories\n\n- `src/features/` - Core domain logic organized by feature:\n  - `transfer/` - Token transfer flow (form, validation, execution via `useTokenTransfer`)\n  - `tokens/` - Token selection, balances, approvals\n  - `chains/` - Chain metadata, selection UI\n  - `wallet/` - Multi-protocol wallet context providers\n  - `warpCore/` - WarpCore configuration assembly\n  - `store.ts` - Global Zustand store managing WarpContext, transfers, UI state\n\n- `src/consts/` - Configuration files:\n  - `config.ts` - App configuration (feature flags, registry settings)\n  - `warpRoutes.yaml` - Warp route token definitions\n  - `chains.yaml` / `chains.ts` - Custom chain metadata\n  - `app.ts` - App branding (name, colors, fonts)\n\n- `src/components/` - Reusable UI components\n- `src/pages/` - Next.js pages (main UI at `index.tsx`)\n\n### Data Flow\n\n1. **Initialization**: `WarpContextInitGate` loads registry and assembles `WarpCore` from warp route configs\n2. **State Hydration**: Zustand store rehydrates persisted state (chain overrides, transfer history)\n3. **Transfer Flow**: `TransferTokenForm` → `useTokenTransfer` → `WarpCore.getTransferRemoteTxs()` → wallet transaction\n\n### Configuration\n\nEnvironment variables (see `.env.example`):\n- `NEXT_PUBLIC_WALLET_CONNECT_ID` - **Required** for wallet connections\n- `NEXT_PUBLIC_REGISTRY_URL` - **Optional** custom Hyperlane registry URL\n- `NEXT_PUBLIC_RPC_OVERRIDES` - **Optional** JSON map of chain RPC overrides\n\n## Customization\n\nSee `CUSTOMIZE.md` for detailed customization instructions:\n- **Warp Routes**: `src/consts/warpRoutes.yaml` + `warpRouteWhitelist.ts`\n- **Chains**: `src/consts/chains.yaml` or `chains.ts`\n- **Branding**: `src/consts/app.ts`, `tailwind.config.js`, logo files in `src/images/logos/`\n- **Feature Flags**: `src/consts/config.ts` (showTipBox, showAddRouteButton, etc.)\n\n## Testing\n\nTests use Vitest and are co-located with source files using the `*.test.ts` naming convention. Vitest automatically discovers and runs all matching test files.\n\n```bash\n# Run all tests\npnpm test\n\n# Run a single test file\npnpm vitest src/features/transfer/fees.test.ts\n\n# Run tests in watch mode\npnpm vitest --watch\n```\n\n## Engineering Philosophy\n\n### Keep It Simple\nWe handle ONLY the most important cases. Don't add functionality unless it's small or absolutely necessary.\n\n### Error Handling\n- **Expected issues** (external systems, user input): Use explicit error handling, try/catch at boundaries\n- **Unexpected issues** (invalid state, broken invariants): Fail loudly with `throw` or `console.error`\n- **NEVER** add silent fallbacks for unexpected issues - they mask bugs\n\n### Backwards-Compatibility\n| Change Location | Backwards-Compat? | Rationale |\n|-----------------|-------------------|-----------|\n| Local/uncommitted | No | Iteration speed; no external impact |\n| In main unreleased | Preferred | Minimize friction for other developers |\n| Released | Required | Prevent breaking downstream integrations |\n\n## Code Review\n\nFor code review guidelines, see `REVIEW.md`.\n\n### PR Review Comment Format\n\n**Use inline comments** for specific feedback on code changes. Use the GitHub API to post reviews:\n\n```bash\ngh api repos/{owner}/{repo}/pulls/{pr}/reviews --input - << 'EOF'\n{\n  \"event\": \"COMMENT\",\n  \"body\": \"Overall summary (optional)\",\n  \"comments\": [\n    {\"path\": \"file.ts\", \"line\": 42, \"body\": \"Specific issue here\"},\n    {\"path\": \"file.ts\", \"start_line\": 10, \"line\": 15, \"body\": \"Multi-line comment\"}\n  ]\n}\nEOF\n```\n\n| Feedback Type        | Where                                   |\n| -------------------- | --------------------------------------- |\n| Specific code issue  | Inline comment on that line             |\n| Repeated pattern     | Inline on first, mention others in body |\n| Architecture concern | Summary body                            |\n\n**Limitation**: Can only comment on lines in the diff (changed lines). Comments on unchanged code fail.\n\n## Tips for AI Coding Sessions\n\n1. **Run tests incrementally** - `pnpm vitest <file>` for specific test files\n2. **Check existing patterns** - Search codebase for similar implementations\n3. **Use SDK types** - Import from `@hyperlane-xyz/sdk`, don't redefine\n4. **Zustand for state** - Global state in `src/features/store.ts`\n5. **Keep changes minimal** - Only modify what's necessary; avoid scope creep\n6. **Feature folders** - Domain logic in `src/features/`, not scattered\n7. **Chain-aware addresses** - Only lowercase EVM addresses; Solana/Cosmos are case-sensitive\n8. **Check src/utils/** - Functions like `normalizeAddress`, `isNullish` already exist\n9. **CSP updates** - New external scripts need `next.config.js` CSP header updates\n10. **useQuery patterns** - Use built-in `refetch`, don't create custom refresh state\n11. **Flatten conditionals** - Use early returns instead of nested if/else in JSX\n\n## Verify Before Acting\n\n**Always search the codebase before assuming.** Don't hallucinate file paths, function names, or patterns.\n\n- `grep` or search before claiming \"X doesn't exist\"\n- Read the actual file before suggesting changes to it\n- Check `git log` or blame before assuming why code exists\n- Verify imports exist in `package.json` before using them\n\n## When the AI Gets It Wrong\n\nIf output seems wrong, check:\n\n1. **Did I read the actual file?** Or did I assume its contents?\n2. **Did I search for existing patterns?** The codebase likely has examples\n3. **Am I using stale context?** Re-read files that may have changed\n4. **Did I verify the error message?** Run the command and read actual output\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "# CLAUDE.md\n\n**Be extremely concise. Sacrifice grammar for concision. Terse responses preferred. No fluff.**\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Project Overview\n\nHyperlane Warp UI Template is a Next.js web application for cross-chain token transfers using [Hyperlane Warp Routes](https://docs.hyperlane.xyz/docs/reference/applications/warp-routes). It enables permissionless bridging of tokens between any supported blockchain.\n\n## Plan Mode\n\n- Make the plan extremely concise. Sacrifice grammar for the sake of concision.\n- At the end of each plan, give me a list of unresolved questions to answer, if any.\n\n## Development Commands\n\n```bash\npnpm install          # Install dependencies\npnpm dev              # Start development server\npnpm build            # Production build\npnpm test             # Run tests (vitest)\npnpm lint             # ESLint check\npnpm typecheck        # TypeScript type checking\npnpm prettier         # Format code with Prettier\npnpm clean            # Remove build artifacts (dist, cache, .next)\n```\n\n## Architecture\n\n### Stack\n- **Framework**: Next.js 15 with React 18\n- **Styling**: Tailwind CSS + Chakra UI\n- **State**: Zustand with persist middleware (`src/features/store.ts`)\n- **Queries**: TanStack Query\n- **Wallets**: Each blockchain uses distinct, composable wallet providers (EVM/RainbowKit, Solana, Cosmos, Starknet, Radix)\n- **Core Libraries**: `@hyperlane-xyz/sdk`, `@hyperlane-xyz/registry`, `@hyperlane-xyz/widgets`, `@hyperlane-xyz/utils`\n\n### Key Directories\n\n- `src/features/` - Core domain logic organized by feature:\n  - `transfer/` - Token transfer flow (form, validation, execution via `useTokenTransfer`)\n  - `tokens/` - Token selection, balances, approvals\n  - `chains/` - Chain metadata, selection UI\n  - `wallet/` - Multi-protocol wallet context providers\n  - `warpCore/` - WarpCore configuration assembly\n  - `store.ts` - Global Zustand store managing WarpContext, transfers, UI state\n\n- `src/consts/` - Configuration files:\n  - `config.ts` - App configuration (feature flags, registry settings)\n  - `warpRoutes.yaml` - Warp route token definitions\n  - `chains.yaml` / `chains.ts` - Custom chain metadata\n  - `app.ts` - App branding (name, colors, fonts)\n\n- `src/components/` - Reusable UI components\n- `src/pages/` - Next.js pages (main UI at `index.tsx`)\n\n### Data Flow\n\n1. **Initialization**: `WarpContextInitGate` loads registry and assembles `WarpCore` from warp route configs\n2. **State Hydration**: Zustand store rehydrates persisted state (chain overrides, transfer history)\n3. **Transfer Flow**: `TransferTokenForm` → `useTokenTransfer` → `WarpCore.getTransferRemoteTxs()` → wallet transaction\n\n### Configuration\n\nEnvironment variables (see `.env.example`):\n- `NEXT_PUBLIC_WALLET_CONNECT_ID` - **Required** for wallet connections\n- `NEXT_PUBLIC_REGISTRY_URL` - **Optional** custom Hyperlane registry URL\n- `NEXT_PUBLIC_RPC_OVERRIDES` - **Optional** JSON map of chain RPC overrides\n\n## Customization\n\nSee `CUSTOMIZE.md` for detailed customization instructions:\n- **Warp Routes**: `src/consts/warpRoutes.yaml` + `warpRouteWhitelist.ts`\n- **Chains**: `src/consts/chains.yaml` or `chains.ts`\n- **Branding**: `src/consts/app.ts`, `tailwind.config.js`, logo files in `src/images/logos/`\n- **Feature Flags**: `src/consts/config.ts` (showTipBox, showAddRouteButton, etc.)\n\n## Testing\n\nTests use Vitest and are co-located with source files using the `*.test.ts` naming convention. Vitest automatically discovers and runs all matching test files.\n\n```bash\n# Run all tests\npnpm test\n\n# Run a single test file\npnpm vitest src/features/transfer/fees.test.ts\n\n# Run tests in watch mode\npnpm vitest --watch\n```\n\n## Engineering Philosophy\n\n### Keep It Simple\nWe handle ONLY the most important cases. Don't add functionality unless it's small or absolutely necessary.\n\n### Error Handling\n- **Expected issues** (external systems, user input): Use explicit error handling, try/catch at boundaries\n- **Unexpected issues** (invalid state, broken invariants): Fail loudly with `throw` or `console.error`\n- **NEVER** add silent fallbacks for unexpected issues - they mask bugs\n\n### Backwards-Compatibility\n| Change Location | Backwards-Compat? | Rationale |\n|-----------------|-------------------|-----------|\n| Local/uncommitted | No | Iteration speed; no external impact |\n| In main unreleased | Preferred | Minimize friction for other developers |\n| Released | Required | Prevent breaking downstream integrations |\n\n## Tips for Claude Code Sessions\n\n1. **Run tests incrementally** - `pnpm vitest <file>` for specific test files\n2. **Check existing patterns** - Search codebase for similar implementations\n3. **Use SDK types** - Import from `@hyperlane-xyz/sdk`, don't redefine\n4. **Zustand for state** - Global state in `src/features/store.ts`\n5. **Keep changes minimal** - Only modify what's necessary; avoid scope creep\n6. **Feature folders** - Domain logic in `src/features/`, not scattered\n7. **Chain-aware addresses** - Only lowercase EVM addresses; Solana/Cosmos are case-sensitive\n8. **Check src/utils/** - Functions like `normalizeAddress`, `isNullish` already exist\n9. **CSP updates** - New external scripts need `next.config.js` CSP header updates\n10. **useQuery patterns** - Use built-in `refetch`, don't create custom refresh state\n11. **Flatten conditionals** - Use early returns instead of nested if/else in JSX\n\n## Verify Before Acting\n\n**Always search the codebase before assuming.** Don't hallucinate file paths, function names, or patterns.\n\n- `grep` or search before claiming \"X doesn't exist\"\n- Read the actual file before suggesting changes to it\n- Check `git log` or blame before assuming why code exists\n- Verify imports exist in `package.json` before using them\n\n## When Claude Gets It Wrong\n\nIf output seems wrong, check:\n\n1. **Did I read the actual file?** Or did I assume its contents?\n2. **Did I search for existing patterns?** The codebase likely has examples\n3. **Am I using stale context?** Re-read files that may have changed\n4. **Did I verify the error message?** Run the command and read actual output\n"
  },
  {
    "path": "CUSTOMIZE.md",
    "content": "# Customizing tokens and branding\n\nFind below instructions for customizing the token list and branding assets of this app.\n\n## Registry\n\nBy default, the app will use the canonical Hyperlane registry published on NPM. See `package.json` for the precise version.\n\nTo use custom chains or custom warp routes, you can either configure a different registry using the `NEXT_PUBLIC_REGISTRY_URL` and `NEXT_PUBLIC_REGISTRY_BRANCH` environment variables or define them manually (see the next two sections).\n\n## Custom Warp Route Configs\n\nThis app requires a set of warp route configs to function. The configs are located in `./src/consts/warpRoutes.yaml` and `./src/consts/warpRoutes.ts`. The output artifacts of a warp route deployment using the [Hyperlane CLI](https://www.npmjs.com/package/@hyperlane-xyz/cli) can be used here.\n\nIn addition to defining your warp route configs, you can control which routes display in the UI via the `warpRouteWhitelist.ts` file.\n\n## Custom Chain Configs\n\nBy default, the app will use only the chains that are included in the configured registry and included in your warp routes.\n\nTo add support for additional chains, or to override a chain's properties (such as RPC URLs), add chain metadata to either `./src/consts/chains.ts` or `./src/consts/chains.yaml`. The same chain configs used in the [Hyperlane CLI](https://www.npmjs.com/package/@hyperlane-xyz/cli) will work here. You may also add an optional `logoURI` field to a chain config to show a custom logo image in the app.\n\n## Default Multi-Collateral Warp Route\n\nBy default, if there are multiples multi-collateral routes surfacing the same asset, the application will pick the token with the lowest fee and the highest collateral in the destination.\n\nYou can override this behavior by updating the file `./src/consts/defaultMultiCollateralRoutes.ts` with an object that includes the `chainName`, `collateralAddressOrDenom` (or just `native` as key) and the default `addressOrDenom`. If there is a matching `origin` and `destination`, `getTransferToken` will pick this route as a priority.\n\n## Tip Card\n\nThe content of the tip card above the form can be customized in `./src/components/tip/TipCard.tsx`\nOr it can be hidden entirely with the `showTipBox` setting in `./src/consts/config.ts`\n\n## Branding\n\n## App name and description\n\nThe values to describe the app itself (e.g. to WalletConnect) are in `./src/consts/app.ts`\n\n### Color Scheme\n\nTo update the color scheme, make changes in the Tailwind config file at `./tailwind.config.js`\nTo modify just the background color, that can be changed in `./src/consts/app.ts`\n\n### Metadata\n\nThe HTML metadata tags are located in `./src/pages/_document.tsx`\n\n### Title / Name Images\n\nThe logo images you should change are:\n\n- `./src/images/logos/app-logo.svg`\n- `./src/images/logos/app-name.svg`\n- `./src/images/logos/app-title.svg`\n\nThese images are primarily used in the header and footer files:\n\n- `./src/components/nav/Header.tsx`\n- `./src/components/nav/Footer.tsx`\n\n### Social links\n\nThe links used in the footer can be found here: `./src/consts/links.ts`\n\n### Public assets / Favicons\n\nThe images and manifest files under `./public` should also be updated.\n"
  },
  {
    "path": "LICENSE.md",
    "content": "Apache License\n==============\n\n_Version 2.0, January 2004_  \n_&lt;<http://www.apache.org/licenses/>&gt;_\n\n### Terms and Conditions for use, reproduction, and distribution\n\n#### 1. Definitions\n\n“License” shall mean the terms and conditions for use, reproduction, and\ndistribution as defined by Sections 1 through 9 of this document.\n\n“Licensor” shall mean the copyright owner or entity authorized by the copyright\nowner that is granting the License.\n\n“Legal Entity” shall mean the union of the acting entity and all other entities\nthat control, are controlled by, or are under common control with that entity.\nFor the purposes of this definition, “control” means **(i)** the power, direct or\nindirect, to cause the direction or management of such entity, whether by\ncontract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the\noutstanding shares, or **(iii)** beneficial ownership of such entity.\n\n“You” (or “Your”) shall mean an individual or Legal Entity exercising\npermissions granted by this License.\n\n“Source” form shall mean the preferred form for making modifications, including\nbut not limited to software source code, documentation source, and configuration\nfiles.\n\n“Object” form shall mean any form resulting from mechanical transformation or\ntranslation of a Source form, including but not limited to compiled object code,\ngenerated documentation, and conversions to other media types.\n\n“Work” shall mean the work of authorship, whether in Source or Object form, made\navailable under the License, as indicated by a copyright notice that is included\nin or attached to the work (an example is provided in the Appendix below).\n\n“Derivative Works” shall mean any work, whether in Source or Object form, that\nis based on (or derived from) the Work and for which the editorial revisions,\nannotations, elaborations, or other modifications represent, as a whole, an\noriginal work of authorship. For the purposes of this License, Derivative Works\nshall not include works that remain separable from, or merely link (or bind by\nname) to the interfaces of, the Work and Derivative Works thereof.\n\n“Contribution” shall mean any work of authorship, including the original version\nof the Work and any modifications or additions to that Work or Derivative Works\nthereof, that is intentionally submitted to Licensor for inclusion in the Work\nby the copyright owner or by an individual or Legal Entity authorized to submit\non behalf of the copyright owner. For the purposes of this definition,\n“submitted” means any form of electronic, verbal, or written communication sent\nto the Licensor or its representatives, including but not limited to\ncommunication on electronic mailing lists, source code control systems, and\nissue tracking systems that are managed by, or on behalf of, the Licensor for\nthe purpose of discussing and improving the Work, but excluding communication\nthat is conspicuously marked or otherwise designated in writing by the copyright\nowner as “Not a Contribution.”\n\n“Contributor” shall mean Licensor and any individual or Legal Entity on behalf\nof whom a Contribution has been received by Licensor and subsequently\nincorporated within the Work.\n\n#### 2. Grant of Copyright License\n\nSubject to the terms and conditions of this License, each Contributor hereby\ngrants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,\nirrevocable copyright license to reproduce, prepare Derivative Works of,\npublicly display, publicly perform, sublicense, and distribute the Work and such\nDerivative Works in Source or Object form.\n\n#### 3. Grant of Patent License\n\nSubject to the terms and conditions of this License, each Contributor hereby\ngrants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,\nirrevocable (except as stated in this section) patent license to make, have\nmade, use, offer to sell, sell, import, and otherwise transfer the Work, where\nsuch license applies only to those patent claims licensable by such Contributor\nthat are necessarily infringed by their Contribution(s) alone or by combination\nof their Contribution(s) with the Work to which such Contribution(s) was\nsubmitted. If You institute patent litigation against any entity (including a\ncross-claim or counterclaim in a lawsuit) alleging that the Work or a\nContribution incorporated within the Work constitutes direct or contributory\npatent infringement, then any patent licenses granted to You under this License\nfor that Work shall terminate as of the date such litigation is filed.\n\n#### 4. Redistribution\n\nYou may reproduce and distribute copies of the Work or Derivative Works thereof\nin any medium, with or without modifications, and in Source or Object form,\nprovided that You meet the following conditions:\n\n* **(a)** You must give any other recipients of the Work or Derivative Works a copy of\nthis License; and\n* **(b)** You must cause any modified files to carry prominent notices stating that You\nchanged the files; and\n* **(c)** You must retain, in the Source form of any Derivative Works that You distribute,\nall copyright, patent, trademark, and attribution notices from the Source form\nof the Work, excluding those notices that do not pertain to any part of the\nDerivative Works; and\n* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any\nDerivative Works that You distribute must include a readable copy of the\nattribution notices contained within such NOTICE file, excluding those notices\nthat do not pertain to any part of the Derivative Works, in at least one of the\nfollowing places: within a NOTICE text file distributed as part of the\nDerivative Works; within the Source form or documentation, if provided along\nwith the Derivative Works; or, within a display generated by the Derivative\nWorks, if and wherever such third-party notices normally appear. The contents of\nthe NOTICE file are for informational purposes only and do not modify the\nLicense. You may add Your own attribution notices within Derivative Works that\nYou distribute, alongside or as an addendum to the NOTICE text from the Work,\nprovided that such additional attribution notices cannot be construed as\nmodifying the License.\n\nYou may add Your own copyright statement to Your modifications and may provide\nadditional or different license terms and conditions for use, reproduction, or\ndistribution of Your modifications, or for any such Derivative Works as a whole,\nprovided Your use, reproduction, and distribution of the Work otherwise complies\nwith the conditions stated in this License.\n\n#### 5. Submission of Contributions\n\nUnless You explicitly state otherwise, any Contribution intentionally submitted\nfor inclusion in the Work by You to the Licensor shall be under the terms and\nconditions of this License, without any additional terms or conditions.\nNotwithstanding the above, nothing herein shall supersede or modify the terms of\nany separate license agreement you may have executed with Licensor regarding\nsuch Contributions.\n\n#### 6. Trademarks\n\nThis License does not grant permission to use the trade names, trademarks,\nservice marks, or product names of the Licensor, except as required for\nreasonable and customary use in describing the origin of the Work and\nreproducing the content of the NOTICE file.\n\n#### 7. Disclaimer of Warranty\n\nUnless required by applicable law or agreed to in writing, Licensor provides the\nWork (and each Contributor provides its Contributions) on an “AS IS” BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,\nincluding, without limitation, any warranties or conditions of TITLE,\nNON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are\nsolely responsible for determining the appropriateness of using or\nredistributing the Work and assume any risks associated with Your exercise of\npermissions under this License.\n\n#### 8. Limitation of Liability\n\nIn no event and under no legal theory, whether in tort (including negligence),\ncontract, or otherwise, unless required by applicable law (such as deliberate\nand grossly negligent acts) or agreed to in writing, shall any Contributor be\nliable to You for damages, including any direct, indirect, special, incidental,\nor consequential damages of any character arising as a result of this License or\nout of the use or inability to use the Work (including but not limited to\ndamages for loss of goodwill, work stoppage, computer failure or malfunction, or\nany and all other commercial damages or losses), even if such Contributor has\nbeen advised of the possibility of such damages.\n\n#### 9. Accepting Warranty or Additional Liability\n\nWhile redistributing the Work or Derivative Works thereof, You may choose to\noffer, and charge a fee for, acceptance of support, warranty, indemnity, or\nother liability obligations and/or rights consistent with this License. However,\nin accepting such obligations, You may act only on Your own behalf and on Your\nsole responsibility, not on behalf of any other Contributor, and only if You\nagree to indemnify, defend, and hold each Contributor harmless for any liability\nincurred by, or claims asserted against, such Contributor by reason of your\naccepting any such warranty or additional liability.\n\n_END OF TERMS AND CONDITIONS_\n\n### APPENDIX: How to apply the Apache License to your work\n\nTo apply the Apache License to your work, attach the following boilerplate\nnotice, with the fields enclosed by brackets `[]` replaced with your own\nidentifying information. (Don't include the brackets!) The text should be\nenclosed in the appropriate comment syntax for the file format. We also\nrecommend that a file or class name and description of purpose be included on\nthe same “printed page” as the copyright notice for easier identification within\nthird-party archives.\n\n    Copyright [yyyy] [name of copyright owner]\n    \n    Licensed under the Apache License, Version 2.0 (the \"License\");\n    you may not use this file except in compliance with the License.\n    You may obtain a copy of the License at\n    \n      http://www.apache.org/licenses/LICENSE-2.0\n    \n    Unless required by applicable law or agreed to in writing, software\n    distributed under the License is distributed on an \"AS IS\" BASIS,\n    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n    See the License for the specific language governing permissions and\n    limitations under the License.\n\n"
  },
  {
    "path": "README.md",
    "content": "# Hyperlane Warp Route UI Template\n\nThis repo contains an example web interface for interchain tokens built with [Hyperlane Warp Route](https://docs.hyperlane.xyz/docs/reference/applications/warp-routes). Warp is a framework to permissionlessly bridge tokens to any chain.\n\n## Architecture\n\nThis app is built with Next & React, Wagmi, RainbowKit, and the Hyperlane SDK.\n\n- Constants that you may want to change are in `./src/consts/`, see the following Customization section for details.\n- The index page is located at `./src/pages/index.tsx`\n- The primary features are implemented in `./src/features/`\n\n## Customization\n\nSee [CUSTOMIZE.md](./CUSTOMIZE.md) for details about adjusting the tokens and branding of this app.\n\n## Development\n\n### Setup\n\n#### Configure\n\nYou need a `projectId` from the WalletConnect Cloud to run the Hyperlane Warp Route UI. Sign up to [WalletConnect Cloud](https://cloud.walletconnect.com) to create a new project.\n\n#### Build\n\n```sh\n# Install dependencies\npnpm install\n\n# Build Next project\npnpm run build\n```\n\n### Run\n\nYou can add `.env.local` file next to `.env.example` where you set `projectId` copied from WalletConnect Cloud.\n\n```sh\n# Start the Next dev server\npnpm run dev\n# Or with a custom projectId\nNEXT_PUBLIC_WALLET_CONNECT_ID=<projectId> pnpm run dev\n```\n\n### Test\n\n```sh\n# Run unit tests\npnpm test\n\n# Run E2E tests (reuses running dev server, or starts one via Playwright)\npnpm test:e2e\n\n# Run E2E tests with headed browser and slow motion for debugging\nSLOW_MO=2000 pnpm test:e2e --headed --workers=1\n\n# Run a single E2E test file with headed browser\nSLOW_MO=2000 pnpm test:e2e --headed --workers=1 tests/wallet-connect/protocol-wallet-modals.spec.ts\n\n# Lint check code\npnpm run lint\n\n# Check code types\npnpm run typecheck\n```\n\n### Format\n\n```sh\n# Format code using Prettier\npnpm run prettier\n```\n\n### Clean / Reset\n\n```sh\n# Delete build artifacts to start fresh\npnpm run clean\n```\n\n### Local package linking to hyperlane-monorepo\n\nIf you have to make changes to the widgets package to edit e.g. the Connect Button or other components linking\nthe widgets package locally to test it is necessary. To do that you can run the following commands\n\n```sh\n# Link monorepo packages with the warp-ui\npnpm link:monorepo\n# Unlink packages again after testing\npnpm unlink:monorepo\n```\n\n## Embed Widget\n\nThe Warp UI can be embedded as an iframe on any website, giving your users a bridge experience directly in your app.\n\n### Setup\n\nOnce deployed (e.g., to Vercel), the embed is available at `/embed`:\n\n```html\n<iframe\n  src=\"https://your-domain.com/embed\"\n  width=\"420\"\n  height=\"600\"\n  style=\"border: none; border-radius: 12px;\"\n/>\n```\n\nYou can pre-select transfer routes using query params:\n\n```text\n/embed?origin=ethereum&destination=arbitrum&originToken=USDC&destinationToken=USDC\n```\n\n### Theme Customization\n\nCustomize colors via URL params (hex values without `#`):\n\n| Param        | Description                                    | Default (light) |\n| ------------ | ---------------------------------------------- | --------------- |\n| `accent`     | Primary/accent color (buttons, headers, links) | `9A0DFF`        |\n| `bg`         | Page background                                | transparent     |\n| `card`       | Card/surface background                        | `ffffff`        |\n| `text`       | Text color                                     | `010101`        |\n| `buttonText` | Button text color                              | `ffffff`        |\n| `border`     | Border color                                   | `BFBFBF40`      |\n| `error`      | Error state color                              | `dc2626`        |\n| `mode`       | `dark` or `light` — applies preset defaults    | `light`         |\n\n**Examples:**\n\n```html\n<!-- Blue accent -->\n<iframe src=\"https://your-domain.com/embed?accent=3b82f6\" ... />\n\n<!-- Dark mode with green accent -->\n<iframe src=\"https://your-domain.com/embed?mode=dark&accent=22c55e\" ... />\n\n<!-- Fully custom theme -->\n<iframe\n  src=\"https://your-domain.com/embed?bg=0f172a&card=1e293b&text=e2e8f0&accent=8b5cf6&buttonText=ffffff&border=334155\"\n  ...\n/>\n```\n\n### PostMessage Events\n\nThe embed page sends events to the parent window via `postMessage`:\n\n```js\nwindow.addEventListener('message', (event) => {\n  if (event.data?.type !== 'hyperlane-warp-widget') return;\n\n  const { type, payload } = event.data.event;\n  if (type === 'ready') {\n    console.log('Widget ready at', payload.timestamp);\n  }\n});\n```\n\n### Solving CSP Issues\n\nIf your site has a Content Security Policy that blocks iframes, you'll need to allow the Warp UI origin in your CSP `frame-src` directive:\n\n```text\nContent-Security-Policy: frame-src https://your-warp-ui-domain.com;\n```\n\nFor sites where you can't modify CSP headers (e.g., WordPress, Shopify), check if the platform has an iframe allowlist setting, or use a reverse proxy to serve the embed from your own domain.\n\nIf you self-host the Warp UI and want to restrict which sites can embed it, set the `NEXT_PUBLIC_EMBED_ALLOWED_ORIGINS` environment variable:\n\n```text\nNEXT_PUBLIC_EMBED_ALLOWED_ORIGINS=https://app-a.com https://app-b.com\n```\n\nIf not set, any site can embed the widget (default: `*`).\n\n## Deployment\n\nThe easiest hosting solution for this Next.JS app is to create a project on Vercel.\n\n## Learn more\n\nFor more information, see the [Hyperlane documentation](https://docs.hyperlane.xyz/docs/protocol/warp-routes/warp-routes-overview).\n"
  },
  {
    "path": "REVIEW.md",
    "content": "# Code Review Guidelines\n\n## Code Quality\n\n- Logic errors and potential bugs\n- Error handling and edge cases\n- Code clarity and maintainability\n- Adherence to existing patterns in the codebase\n- **Use existing utilities** - Search codebase before adding new helpers\n- **Prefer `??` over `||`** - Preserves zero/empty string as valid values\n\n## Architecture\n\n- Consistency with existing architecture patterns\n- Breaking changes or backward compatibility issues\n- API contract changes\n- **Deduplicate** - Move repeated code/types to shared files\n- **Extract utilities** - Shared functions belong in utils packages\n\n## Testing\n\n- Test coverage for new/modified code\n- Edge cases that should be tested\n- **New utility functions need unit tests**\n\n## Performance\n\n- Unnecessary re-renders or computations\n- Bundle size impact of new dependencies\n\n## Frontend-Specific\n\n- **Use existing utilities** - Check `src/utils/` before adding (normalizeAddress, etc.)\n- **Chain-aware addresses** - Only lowercase EVM hex; Solana/Cosmos are case-sensitive\n- **CSP updates required** - New external scripts/styles need `next.config.js` CSP updates\n- **Avoid floating promises** - In useEffect, use IIFE or separate async function\n- **Use useQuery refetch** - Don't reinvent; use built-in refetch from TanStack Query\n- **Flatten rendering logic** - Avoid nested if; use early returns instead\n- **Zustand patterns** - Follow existing store patterns in `src/features/store.ts`\n- **Constants outside functions** - Move config/constants outside component functions\n"
  },
  {
    "path": "next-env.d.ts",
    "content": "/// <reference types=\"next\" />\n/// <reference types=\"next/image-types/global\" />\nimport \"./.next/dev/types/routes.d.ts\";\n\n// NOTE: This file should not be edited\n// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.\n"
  },
  {
    "path": "next.config.js",
    "content": "/** @type {import('next').NextConfig} */\n\nconst { version } = require('./package.json');\nconst withBundleAnalyzer = require('@next/bundle-analyzer')({\n  enabled: process.env.ANALYZE === 'true',\n});\nconst isDev = process.env.NODE_ENV !== 'production';\n\n// Sometimes useful to disable this during development\nconst ENABLE_CSP_HEADER = true;\nconst FRAME_SRC_HOSTS = [\n  'https://*.walletconnect.com',\n  'https://*.walletconnect.org',\n  'https://cdn.solflare.com',\n  'https://js.refiner.io',\n  'https://intercom-sheets.com',\n  'https://intercom-reporting.com',\n];\nconst STYLE_SRC_HOSTS = ['https://js.refiner.io', 'https://storage.refiner.io'];\nconst IMG_SRC_HOSTS = [\n  'https://*.walletconnect.com',\n  'https://*.githubusercontent.com',\n  'https://cdn.jsdelivr.net/gh/hyperlane-xyz/hyperlane-registry@main/',\n  'https://js.refiner.io',\n  'https://storage.refiner.io',\n  'https://js.intercomcdn.com',\n  'https://static.intercomassets.com',\n  'https://downloads.intercomcdn.com',\n  'https://uploads.intercomusercontent.com',\n  'https://gifs.intercomcdn.com',\n];\nconst SCRIPT_SRC_HOSTS = [\n  'https://snaps.consensys.io',\n  'https://js.refiner.io',\n  'https://app.intercom.io',\n  'https://widget.intercom.io',\n  'https://js.intercomcdn.com',\n];\nconst MEDIA_SRC_HOSTS = [\n  'https://js.refiner.io',\n  'https://storage.refiner.io',\n  'https://js.intercomcdn.com',\n  'https://downloads.intercomcdn.com',\n];\nconst cspHeader = `\n  default-src 'self';\n  script-src 'self' 'wasm-unsafe-eval'${isDev ? \" 'unsafe-eval'\" : ''} ${SCRIPT_SRC_HOSTS.join(' ')};\n  style-src 'self' 'unsafe-inline' ${STYLE_SRC_HOSTS.join(' ')};\n  connect-src *;\n  img-src 'self' blob: data: ${IMG_SRC_HOSTS.join(' ')};\n  font-src 'self' data: https://js.intercomcdn.com https://fonts.intercomcdn.com;\n  object-src 'none';\n  base-uri 'self';\n  form-action 'self';\n  frame-src 'self' ${FRAME_SRC_HOSTS.join(' ')};\n  frame-ancestors 'none';\n  media-src 'self' ${MEDIA_SRC_HOSTS.join(' ')};\n  ${!isDev ? 'block-all-mixed-content;' : ''}\n  ${!isDev ? 'upgrade-insecure-requests;' : ''}\n`\n  .replace(/\\s{2,}/g, ' ')\n  .trim();\n\nconst securityHeaders = [\n  {\n    key: 'X-XSS-Protection',\n    value: '1; mode=block',\n  },\n  {\n    key: 'X-Frame-Options',\n    value: 'DENY',\n  },\n  {\n    key: 'X-Content-Type-Options',\n    value: 'nosniff',\n  },\n  {\n    key: 'Referrer-Policy',\n    value: 'strict-origin-when-cross-origin',\n  },\n  // Note, causes a problem for firefox: https://github.com/MetaMask/metamask-extension/issues/3133\n  ...(ENABLE_CSP_HEADER\n    ? [\n        {\n          key: 'Content-Security-Policy',\n          value: cspHeader,\n        },\n      ]\n    : []),\n];\n\n// Embed page headers: allow framing from specified origins (default: any)\n// Accepts space or comma-separated origins, e.g. \"https://a.com https://b.com\" or \"https://a.com,https://b.com\"\nconst rawEmbedAllowedOrigins = process.env.NEXT_PUBLIC_EMBED_ALLOWED_ORIGINS ?? '*';\nconst embedAllowedOrigins = rawEmbedAllowedOrigins\n  .split(/[,\\s]+/)\n  .map((origin) => origin.trim())\n  .filter(Boolean);\n\nif (embedAllowedOrigins.length === 0) {\n  throw new Error('Invalid NEXT_PUBLIC_EMBED_ALLOWED_ORIGINS: no valid origins provided');\n}\n\nif (embedAllowedOrigins.some((origin) => /[;\\r\\n]/.test(origin))) {\n  throw new Error('Invalid NEXT_PUBLIC_EMBED_ALLOWED_ORIGINS: contains forbidden characters');\n}\n\nconst embedCspHeader = cspHeader.replace(\n  \"frame-ancestors 'none'\",\n  `frame-ancestors ${embedAllowedOrigins.join(' ')}`,\n);\nconst embedSecurityHeaders = [\n  {\n    key: 'X-XSS-Protection',\n    value: '1; mode=block',\n  },\n  // No X-Frame-Options — allow embedding\n  {\n    key: 'X-Content-Type-Options',\n    value: 'nosniff',\n  },\n  {\n    key: 'Referrer-Policy',\n    value: 'strict-origin-when-cross-origin',\n  },\n  ...(ENABLE_CSP_HEADER\n    ? [\n        {\n          key: 'Content-Security-Policy',\n          value: embedCspHeader,\n        },\n      ]\n    : []),\n];\n\nconst nextConfig = {\n  // Disable the dev-tools indicator/portal in dev when running under the\n  // e2e harness — its <nextjs-portal> shadow DOM intermittently intercepts\n  // pointer events during picker clicks (observed flake on\n  // `token-select-destination` in full-suite runs). Scope to an explicit\n  // env var so local dev UX is unchanged.\n  ...(process.env.DISABLE_NEXT_DEV_INDICATORS === '1' ? { devIndicators: false } : {}),\n  turbopack: {\n    rules: {\n      '*.yaml': {\n        loaders: ['yaml-loader'],\n        as: '*.js',\n      },\n      '*.yml': {\n        loaders: ['yaml-loader'],\n        as: '*.js',\n      },\n    },\n    resolveAlias: {\n      // Only shim pino on SSR (Node) where its transport/worker resolution breaks\n      // under Turbopack. In the browser use the real pino browser build, which\n      // exports `levels` that @walletconnect/logger depends on.\n      pino: {\n        browser: 'pino/browser.js',\n        default: './src/utils/pino-noop.js',\n      },\n    },\n  },\n\n  async headers() {\n    return [\n      {\n        source: '/embed',\n        headers: embedSecurityHeaders,\n      },\n      {\n        source: '/((?!embed$).*)',\n        headers: securityHeaders,\n      },\n    ];\n  },\n\n  env: {\n    NEXT_PUBLIC_VERSION: version,\n    NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN || '',\n  },\n\n  reactStrictMode: true,\n\n  serverExternalPackages: ['@sentry/nextjs'],\n\n  // Exclude heavy client-only chain SDKs from serverless function file tracing.\n  // These packages are only used client-side and not needed in serverless functions.\n  // Note: @sentry and @opentelemetry are kept for server-side instrumentation (see instrumentation.ts).\n  outputFileTracingExcludes: {\n    '*': [\n      './node_modules/@provablehq/**',\n      './node_modules/@radixdlt/**',\n      './node_modules/@solana/**',\n      './node_modules/@cosmjs/**',\n      './node_modules/@starknet-io/**',\n      './node_modules/ethers/**',\n    ],\n  },\n\n  experimental: {\n    turbopackFileSystemCacheForBuild: true,\n    parallelServerCompiles: true,\n    parallelServerBuildTraces: true,\n    optimizePackageImports: [\n      '@hyperlane-xyz/registry',\n      '@hyperlane-xyz/sdk',\n      '@hyperlane-xyz/utils',\n      '@hyperlane-xyz/widgets',\n    ],\n  },\n\n  // Skip type checking during builds — CI runs these separately\n  typescript: { ignoreBuildErrors: true },\n};\n\nmodule.exports = withBundleAnalyzer(nextConfig);\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"@hyperlane-xyz/warp-ui-template\",\n  \"version\": \"13.0.0\",\n  \"private\": true,\n  \"description\": \"A web app template for building Hyperlane Warp Route UIs\",\n  \"homepage\": \"https://www.hyperlane.xyz\",\n  \"license\": \"Apache-2.0\",\n  \"author\": \"J M Rossy\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/hyperlane-xyz/hyperlane-warp-ui-template\"\n  },\n  \"main\": \"dist/src/index.js\",\n  \"types\": \"dist/src/index.d.ts\",\n  \"scripts\": {\n    \"clean\": \"rm -rf dist cache .next\",\n    \"predev\": \"node scripts/fetch-fonts.mjs\",\n    \"dev\": \"next dev\",\n    \"prebuild\": \"node scripts/fetch-fonts.mjs\",\n    \"fetch-fonts\": \"node --env-file-if-exists=.env.local scripts/fetch-fonts.mjs\",\n    \"build\": \"next build\",\n    \"typecheck\": \"tsc\",\n    \"lint\": \"oxlint ./src\",\n    \"start\": \"next start\",\n    \"test\": \"vitest --watch false\",\n    \"test:e2e\": \"playwright test --project=chromium\",\n    \"test:e2e:wallet\": \"playwright test tests/e2e-wallet --project=chromium\",\n    \"test:e2e:wallet:smoke\": \"playwright test tests/e2e-wallet/smoke/gate.spec.ts tests/e2e-wallet/autoconnect/evm.spec.ts tests/e2e-wallet/balance-display/evm.spec.ts tests/e2e-wallet/autoconnect/solana.spec.ts --project=chromium\",\n    \"format\": \"oxfmt ./src\",\n    \"link:monorepo\": \"node scripts/link-monorepo.js\",\n    \"unlink:monorepo\": \"node scripts/unlink-monorepo.js\"\n  },\n  \"dependencies\": {\n    \"@chakra-ui/next-js\": \"^2.4.2\",\n    \"@chakra-ui/provider\": \"^2.4.2\",\n    \"@chakra-ui/react\": \"^2.8.2\",\n    \"@chakra-ui/system\": \"^2.6.2\",\n    \"@chakra-ui/theme-utils\": \"^2.0.21\",\n    \"@cosmjs/cosmwasm-stargate\": \"^0.32.4\",\n    \"@cosmjs/proto-signing\": \"^0.32.4\",\n    \"@cosmjs/stargate\": \"^0.32.4\",\n    \"@cosmos-kit/core\": \"^2.13.1\",\n    \"@cosmos-kit/cosmostation\": \"^2.11.2\",\n    \"@cosmos-kit/keplr\": \"^2.12.2\",\n    \"@cosmos-kit/leap\": \"^2.12.2\",\n    \"@cosmos-kit/react\": \"2.18.0\",\n    \"@drift-labs/snap-wallet-adapter\": \"^0.3.0\",\n    \"@emotion/react\": \"^11.13.3\",\n    \"@emotion/styled\": \"^11.13.0\",\n    \"@headlessui/react\": \"^2.2.0\",\n    \"@hyperlane-xyz/registry\": \"24.3.0\",\n    \"@hyperlane-xyz/sdk\": \"33.1.0\",\n    \"@hyperlane-xyz/utils\": \"33.1.0\",\n    \"@hyperlane-xyz/widgets\": \"33.1.0\",\n    \"@interchain-ui/react\": \"^1.23.28\",\n    \"@intercom/messenger-js-sdk\": \"^0.0.18\",\n    \"@metamask/post-message-stream\": \"6.1.2\",\n    \"@metamask/providers\": \"10.2.1\",\n    \"@provablehq/aleo-wallet-adaptor-react\": \"0.3.0-alpha.1\",\n    \"@provablehq/aleo-wallet-adaptor-shield\": \"0.3.0-alpha.1\",\n    \"@radixdlt/babylon-gateway-api-sdk\": \"^1.10.1\",\n    \"@radixdlt/radix-dapp-toolkit\": \"^2.2.1\",\n    \"@rainbow-me/rainbowkit\": \"2.2.10\",\n    \"@sentry/browser\": \"8.38.0\",\n    \"@sentry/core\": \"8.38.0\",\n    \"@sentry/nextjs\": \"^8.38.0\",\n    \"@sentry/react\": \"8.38.0\",\n    \"@solana/spl-token\": \"^0.4.9\",\n    \"@solana/wallet-adapter-backpack\": \"^0.1.14\",\n    \"@solana/wallet-adapter-base\": \"^0.9.22\",\n    \"@solana/wallet-adapter-ledger\": \"^0.9.29\",\n    \"@solana/wallet-adapter-phantom\": \"^0.9.28\",\n    \"@solana/wallet-adapter-react\": \"^0.15.32\",\n    \"@solana/wallet-adapter-react-ui\": \"^0.9.31\",\n    \"@solana/wallet-adapter-salmon\": \"^0.1.18\",\n    \"@solana/wallet-adapter-solflare\": \"^0.6.32\",\n    \"@solana/wallet-adapter-trust\": \"^0.1.17\",\n    \"@solana/wallet-adapter-wallets\": \"0.19.16\",\n    \"@solana/web3.js\": \"^1.95.4\",\n    \"@starknet-react/chains\": \"^3.1.2\",\n    \"@starknet-react/core\": \"^3.7.2\",\n    \"@tanstack/query-core\": \"^5.90.12\",\n    \"@tanstack/react-query\": \"^5.59.20\",\n    \"@tronweb3/tronwallet-abstract-adapter\": \"^1.1.12\",\n    \"@tronweb3/tronwallet-adapter-react-hooks\": \"^1.1.11\",\n    \"@tronweb3/tronwallet-adapter-tronlink\": \"^1.1.15\",\n    \"@vercel/analytics\": \"^1.4.0\",\n    \"@vercel/functions\": \"^1.5.0\",\n    \"axios\": \"^1.7.9\",\n    \"bignumber.js\": \"^9.1.2\",\n    \"buffer\": \"^6.0.3\",\n    \"clsx\": \"^2.1.1\",\n    \"cosmjs-types\": \"^0.9.0\",\n    \"formik\": \"^2.4.6\",\n    \"framer-motion\": \"^12.38.0\",\n    \"next\": \"^16.2.1\",\n    \"react\": \"^19.2.4\",\n    \"react-dom\": \"^19.2.4\",\n    \"react-toastify\": \"^10.0.6\",\n    \"refiner-js\": \"1.2.4\",\n    \"starknetkit\": \"2.6.1\",\n    \"viem\": \"^2.21.41\",\n    \"wagmi\": \"^2.12.26\",\n    \"zod\": \"3.21.4\",\n    \"zustand\": \"^4.4.7\"\n  },\n  \"devDependencies\": {\n    \"@aws-sdk/client-s3\": \"^3.967.0\",\n    \"@next/bundle-analyzer\": \"^16.2.1\",\n    \"@playwright/test\": \"^1.58.2\",\n    \"@types/node\": \"^24.10.9\",\n    \"@types/react\": \"^19.2.14\",\n    \"@types/react-dom\": \"^19.2.3\",\n    \"autoprefixer\": \"^10.4.20\",\n    \"oxfmt\": \"0.42.0\",\n    \"oxlint\": \"1.57.0\",\n    \"postcss\": \"^8.4.47\",\n    \"tailwindcss\": \"^3.4.15\",\n    \"ts-node\": \"^10.9.2\",\n    \"typescript\": \"6.0.2\",\n    \"vite-tsconfig-paths\": \"^5.1.4\",\n    \"vitest\": \"3.0.5\",\n    \"yaml\": \"^2.6.0\",\n    \"yaml-loader\": \"^0.8.1\"\n  },\n  \"engines\": {\n    \"node\": \">=24\"\n  },\n  \"packageManager\": \"pnpm@10.25.0\",\n  \"pnpm\": {\n    \"overrides\": {\n      \"tronweb>ethers\": \"^6.13.5\",\n      \"@solana/web3.js\": \"^1.95.4\",\n      \"axios\": \"^1.7.9\",\n      \"bignumber\": \"9.1.2\",\n      \"bn.js\": \"^5.2\",\n      \"cosmjs-types\": \"0.9\",\n      \"ethers\": \"^5.8.0\",\n      \"globals\": \"^14.0.0\",\n      \"lit-html\": \"2.8.0\",\n      \"react-fast-compare\": \"^3.2\",\n      \"viem\": \"^2.21.41\",\n      \"zustand\": \"^4.4\",\n      \"sha.js\": \"2.4.12\",\n      \"cipher-base\": \"1.0.5\",\n      \"elliptic\": \"6.6.1\",\n      \"pbkdf2\": \"3.1.3\",\n      \"form-data\": \"4.0.4\",\n      \"@ledgerhq/errors\": \"6.31.0\"\n    },\n    \"patchedDependencies\": {\n      \"starknetkit@2.6.1\": \"patches/starknetkit@2.6.1.patch\",\n      \"@provablehq/wasm@0.9.18\": \"patches/@provablehq__wasm@0.9.18.patch\",\n      \"@provablehq/sdk@0.9.15\": \"patches/@provablehq__sdk@0.9.15.patch\"\n    },\n    \"onlyBuiltDependencies\": []\n  }\n}\n"
  },
  {
    "path": "patches/@provablehq__sdk@0.9.15.patch",
    "content": "diff --git a/dist/mainnet/browser.js b/dist/mainnet/browser.js\nindex 166fc0b9e40569f8b33ecb3be7b170d3010a2d2e..8ab95f815dbee39886dd042f055adb552d0d6c26 100644\n--- a/dist/mainnet/browser.js\n+++ b/dist/mainnet/browser.js\n@@ -466,7 +466,29 @@ async function retryWithBackoff(fn, { maxAttempts = 5, baseDelay = 100, jitter,\n     throw new Error(\"retryWithBackoff: unreachable\");\n }\n \n-const KEY_STORE = Metadata.baseUrl();\n+const IS_BROWSER_RUNTIME = (typeof window !== \"undefined\" && typeof document !== \"undefined\") || (typeof WorkerGlobalScope !== \"undefined\" && typeof self !== \"undefined\" && self instanceof WorkerGlobalScope);\n+const SSR_CREDITS_PROGRAM_KEY = {\n+    name: \"\",\n+    locator: \"\",\n+    prover: \"\",\n+    verifier: \"\",\n+    verifyingKey() {\n+        throw new Error(\"Aleo browser runtime is unavailable during SSR\");\n+    },\n+};\n+const SSR_CREDITS_PROGRAM_KEYS = new Proxy({\n+    getKey() {\n+        return SSR_CREDITS_PROGRAM_KEY;\n+    },\n+}, {\n+    get(target, prop, receiver) {\n+        if (prop in target) {\n+            return Reflect.get(target, prop, receiver);\n+        }\n+        return SSR_CREDITS_PROGRAM_KEY;\n+    },\n+});\n+const KEY_STORE = IS_BROWSER_RUNTIME ? Metadata.baseUrl() : \"\";\n function convert(metadata) {\n     // This looks up the method name in VerifyingKey\n     const verifyingKey = VerifyingKey[metadata.verifyingKey];\n@@ -481,7 +503,7 @@ function convert(metadata) {\n         verifyingKey,\n     };\n }\n-const CREDITS_PROGRAM_KEYS = {\n+const CREDITS_PROGRAM_KEYS = IS_BROWSER_RUNTIME ? {\n     bond_public: convert(Metadata.bond_public()),\n     bond_validator: convert(Metadata.bond_validator()),\n     claim_unbond_public: convert(Metadata.claim_unbond_public()),\n@@ -505,7 +527,7 @@ const CREDITS_PROGRAM_KEYS = {\n             throw new Error(`Key \"${key}\" not found.`);\n         }\n     }\n-};\n+} : SSR_CREDITS_PROGRAM_KEYS;\n const PRIVATE_TRANSFER_TYPES = new Set([\n     \"transfer_private\",\n     \"private\",\n@@ -3666,7 +3688,13 @@ class RecordScanner {\n  * ```\n  */\n class SealanceMerkleTree {\n-    static hasher = new Poseidon4();\n+    static _hasher;\n+    static get hasher() {\n+        if (!this._hasher) {\n+            this._hasher = new Poseidon4();\n+        }\n+        return this._hasher;\n+    }\n     /**\n     * Converts an Aleo blockchain address to a field element.\n     *\ndiff --git a/dist/testnet/browser.js b/dist/testnet/browser.js\nindex df2a22058c16e27b3a5878727d0e80e0f724c86c..029b7542d9a8014ff1bd04e3f2abb5b8d0801a79 100644\n--- a/dist/testnet/browser.js\n+++ b/dist/testnet/browser.js\n@@ -466,7 +466,29 @@ async function retryWithBackoff(fn, { maxAttempts = 5, baseDelay = 100, jitter,\n     throw new Error(\"retryWithBackoff: unreachable\");\n }\n \n-const KEY_STORE = Metadata.baseUrl();\n+const IS_BROWSER_RUNTIME = (typeof window !== \"undefined\" && typeof document !== \"undefined\") || (typeof WorkerGlobalScope !== \"undefined\" && typeof self !== \"undefined\" && self instanceof WorkerGlobalScope);\n+const SSR_CREDITS_PROGRAM_KEY = {\n+    name: \"\",\n+    locator: \"\",\n+    prover: \"\",\n+    verifier: \"\",\n+    verifyingKey() {\n+        throw new Error(\"Aleo browser runtime is unavailable during SSR\");\n+    },\n+};\n+const SSR_CREDITS_PROGRAM_KEYS = new Proxy({\n+    getKey() {\n+        return SSR_CREDITS_PROGRAM_KEY;\n+    },\n+}, {\n+    get(target, prop, receiver) {\n+        if (prop in target) {\n+            return Reflect.get(target, prop, receiver);\n+        }\n+        return SSR_CREDITS_PROGRAM_KEY;\n+    },\n+});\n+const KEY_STORE = IS_BROWSER_RUNTIME ? Metadata.baseUrl() : \"\";\n function convert(metadata) {\n     // This looks up the method name in VerifyingKey\n     const verifyingKey = VerifyingKey[metadata.verifyingKey];\n@@ -481,7 +503,7 @@ function convert(metadata) {\n         verifyingKey,\n     };\n }\n-const CREDITS_PROGRAM_KEYS = {\n+const CREDITS_PROGRAM_KEYS = IS_BROWSER_RUNTIME ? {\n     bond_public: convert(Metadata.bond_public()),\n     bond_validator: convert(Metadata.bond_validator()),\n     claim_unbond_public: convert(Metadata.claim_unbond_public()),\n@@ -505,7 +527,7 @@ const CREDITS_PROGRAM_KEYS = {\n             throw new Error(`Key \"${key}\" not found.`);\n         }\n     }\n-};\n+} : SSR_CREDITS_PROGRAM_KEYS;\n const PRIVATE_TRANSFER_TYPES = new Set([\n     \"transfer_private\",\n     \"private\",\n@@ -3666,7 +3688,13 @@ class RecordScanner {\n  * ```\n  */\n class SealanceMerkleTree {\n-    static hasher = new Poseidon4();\n+    static _hasher;\n+    static get hasher() {\n+        if (!this._hasher) {\n+            this._hasher = new Poseidon4();\n+        }\n+        return this._hasher;\n+    }\n     /**\n     * Converts an Aleo blockchain address to a field element.\n     *\n"
  },
  {
    "path": "patches/@provablehq__wasm@0.9.18.patch",
    "content": "diff --git a/dist/mainnet/index.js b/dist/mainnet/index.js\nindex 1988ed1acaabfdd5238dd9729678033d1978aacd..3ff88a8fc24bcae8425ce764dc44814497e5734c 100644\n--- a/dist/mainnet/index.js\n+++ b/dist/mainnet/index.js\n@@ -14222,8 +14222,11 @@ async function __wbg_init(module_or_path, memory) {\n }\n \n const module$1 = new URL(\"aleo_wasm.wasm\", import.meta.url);\n-                \n-                    await __wbg_init({ module_or_path: module$1 });\n+const shouldAutoInit = (typeof window !== \"undefined\" && typeof document !== \"undefined\") || (typeof WorkerGlobalScope !== \"undefined\" && typeof self !== \"undefined\" && self instanceof WorkerGlobalScope);\n+\n+if (shouldAutoInit) {\n+    await __wbg_init({ module_or_path: module$1 });\n+}\n \n async function initThreadPool(threads) {\n     if (threads == null) {\ndiff --git a/dist/testnet/index.js b/dist/testnet/index.js\nindex 180e9b86e0c8c2b579504bac26fa57e04e93275a..a3fc1b0dbb13a31872810cf2fa1d6bbfdc5881ac 100644\n--- a/dist/testnet/index.js\n+++ b/dist/testnet/index.js\n@@ -14222,8 +14222,11 @@ async function __wbg_init(module_or_path, memory) {\n }\n \n const module$1 = new URL(\"aleo_wasm.wasm\", import.meta.url);\n-                \n-                    await __wbg_init({ module_or_path: module$1 });\n+const shouldAutoInit = (typeof window !== \"undefined\" && typeof document !== \"undefined\") || (typeof WorkerGlobalScope !== \"undefined\" && typeof self !== \"undefined\" && self instanceof WorkerGlobalScope);\n+\n+if (shouldAutoInit) {\n+    await __wbg_init({ module_or_path: module$1 });\n+}\n \n async function initThreadPool(threads) {\n     if (threads == null) {\n"
  },
  {
    "path": "patches/starknetkit@2.6.1.patch",
    "content": "diff --git a/dist/starknetkit.js b/dist/starknetkit.js\nindex 9fee9301ba99263b32be70b492787671eb9a2d26..6cfeb4756cdb7e2ddcd39f3b292d0b123fc56a2b 100644\n--- a/dist/starknetkit.js\n+++ b/dist/starknetkit.js\n@@ -4563,7 +4563,7 @@ const mapModalWallets = ({\n     if (d) {\n       const g = d.id === \"argentX\" ? { light: ARGENT_X_ICON, dark: ARGENT_X_ICON } : isString(d.icon) ? { light: d.icon, dark: d.icon } : d.icon;\n       return {\n-        name: d.name,\n+        name: d.id === \"argentX\" ? \"Ready Wallet (formerly Argent)\" : d.name,\n         id: d.id,\n         icon: g,\n         connector: c\n@@ -4575,7 +4575,7 @@ const mapModalWallets = ({\n     if (m) {\n       const { downloads: g } = m, p = m.id === \"argentX\" ? ARGENT_X_ICON : m.icon;\n       return {\n-        name: m.name,\n+        name: m.id === \"argentX\" ? \"Ready Wallet (formerly Argent)\" : m.name,\n         id: m.id,\n         icon: { light: p, dark: p },\n         connector: c,\n"
  },
  {
    "path": "playwright.config.ts",
    "content": "import { defineConfig, devices } from '@playwright/test';\n\n/**\n * Read environment variables from file.\n * https://github.com/motdotla/dotenv\n */\n// import dotenv from 'dotenv';\n// import path from 'path';\n// dotenv.config({ path: path.resolve(__dirname, '.env') });\n\n/**\n * See https://playwright.dev/docs/test-configuration.\n */\nexport default defineConfig({\n  testDir: './tests',\n  /* Run tests in files in parallel */\n  fullyParallel: true,\n  /* Fail the build on CI if you accidentally left test.only in the source code. */\n  forbidOnly: !!process.env.CI,\n  /* Retry on CI only */\n  retries: process.env.CI ? 2 : 0,\n  /* Opt out of parallel tests on CI. */\n  workers: process.env.CI ? 1 : undefined,\n  /* Reporter to use. See https://playwright.dev/docs/test-reporters */\n  reporter: process.env.CI ? [['blob'], ['list']] : 'html',\n  /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */\n  use: {\n    /* Base URL to use in actions like `await page.goto('')`. */\n    // baseURL: 'http://localhost:3000',\n\n    /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */\n    trace: 'on-first-retry',\n\n    /* Slow down actions for visual debugging. Set SLOW_MO=2000 to add 2s delay. */\n    launchOptions: {\n      slowMo: parseInt(process.env.SLOW_MO || '0'),\n    },\n  },\n\n  /* Configure projects for major browsers */\n  projects: [\n    {\n      name: 'chromium',\n      use: { ...devices['Desktop Chrome'] },\n    },\n\n    {\n      name: 'firefox',\n      use: { ...devices['Desktop Firefox'] },\n    },\n\n    {\n      name: 'webkit',\n      use: { ...devices['Desktop Safari'] },\n    },\n\n    /* Test against mobile viewports. */\n    // {\n    //   name: 'Mobile Chrome',\n    //   use: { ...devices['Pixel 5'] },\n    // },\n    // {\n    //   name: 'Mobile Safari',\n    //   use: { ...devices['iPhone 12'] },\n    // },\n\n    /* Test against branded browsers. */\n    // {\n    //   name: 'Microsoft Edge',\n    //   use: { ...devices['Desktop Edge'], channel: 'msedge' },\n    // },\n    // {\n    //   name: 'Google Chrome',\n    //   use: { ...devices['Desktop Chrome'], channel: 'chrome' },\n    // },\n  ],\n\n  /* Run your local dev server before starting the tests */\n  webServer: {\n    // `pnpm dev` is fast to iterate against but renders Next's <nextjs-portal>\n    // dev-tools web component, which intermittently intercepts picker clicks.\n    // CI always uses the prod build via `pnpm start`; set E2E_USE_PROD=1\n    // locally to match CI if you're chasing dev-only flakes.\n    command: process.env.CI || process.env.E2E_USE_PROD === '1' ? 'pnpm start' : 'pnpm dev',\n    url: 'http://localhost:3000',\n    timeout: 180_000,\n    reuseExistingServer: !process.env.CI,\n    // Kill the Next dev-tools <nextjs-portal> so picker clicks aren't\n    // intermittently intercepted in dev. CI uses the prod build where the\n    // portal doesn't render, so this only matters locally.\n    env: { DISABLE_NEXT_DEV_INDICATORS: '1' },\n  },\n});\n"
  },
  {
    "path": "pnpm-workspace.yaml",
    "content": "# Reject packages published less than 7 days ago (supply chain protection)\nminimumReleaseAge: 10080\nminimumReleaseAgeExclude:\n  - '@hyperlane-xyz/*'\n"
  },
  {
    "path": "postcss.config.js",
    "content": "module.exports = {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n}\n"
  },
  {
    "path": "public/.well-known/radix.json",
    "content": "{\n  \"dApps\": [\n    {\n      \"dAppDefinitionAddress\": \"account_rdx12ycz0wsuygqa5slye9du6e7wz7fr4pzx39l5r5cznqc6yudpks20cw\"\n    }\n  ]\n}"
  },
  {
    "path": "public/browserconfig.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<browserconfig>\n    <msapplication>\n        <tile>\n            <square150x150logo src=\"/mstile-150x150.png\"/>\n            <TileColor>#ffffff</TileColor>\n        </tile>\n    </msapplication>\n</browserconfig>\n"
  },
  {
    "path": "public/site.webmanifest",
    "content": "{\n    \"name\": \"\",\n    \"short_name\": \"\",\n    \"icons\": [\n        {\n            \"src\": \"/android-chrome-192x192.png\",\n            \"sizes\": \"192x192\",\n            \"type\": \"image/png\"\n        },\n        {\n            \"src\": \"/android-chrome-512x512.png\",\n            \"sizes\": \"512x512\",\n            \"type\": \"image/png\"\n        }\n    ],\n    \"theme_color\": \"#ffffff\",\n    \"background_color\": \"#ffffff\",\n    \"display\": \"standalone\"\n}\n"
  },
  {
    "path": "public/theme-init.js",
    "content": "(() => {\n  const getSystemTheme = () => {\n    try {\n      if (typeof window.matchMedia !== 'function') return 'light';\n      return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';\n    } catch {\n      return 'light';\n    }\n  };\n\n  let storedTheme = null;\n  try {\n    // Must match UI_THEME_STORAGE_KEY in src/consts/app.ts.\n    // This script runs before TS modules load, so keep this literal in sync manually.\n    storedTheme = window.localStorage.getItem('warp-ui-theme');\n  } catch {\n    // Ignore read errors (e.g. privacy mode) and fallback to system theme.\n  }\n\n  const themeMode = storedTheme === 'dark' || storedTheme === 'light' ? storedTheme : getSystemTheme();\n  document.documentElement.dataset.themeMode = themeMode;\n})();\n"
  },
  {
    "path": "scripts/README.md",
    "content": "# Development Scripts\n\n## link-monorepo.js\n\nLinks local Hyperlane monorepo packages for development using `pnpm pack` (creates tarball archives).\n\n### Usage\n\n```bash\n# Link specific packages\npnpm link:monorepo sdk utils widgets\n\n# Or use node directly\nnode scripts/link-monorepo.js sdk utils widgets tron-sdk\n```\n\n### What it does\n\n1. **Checks monorepo setup**: Verifies the monorepo exists at `../hyperlane-monorepo`\n2. **Builds entire monorepo**: Runs `pnpm build` from the monorepo root to ensure all packages and dependencies are built in the correct order\n3. **Packs each package**: Runs `pnpm pack` in each specified package to create a `.tgz` tarball\n4. **Updates package.json**: Changes dependency references to `file:../hyperlane-monorepo/typescript/<package>/<tarball>.tgz`\n5. **Adds pnpm overrides**: Ensures all references (including transitive dependencies) use the packed versions\n6. **Cleans and reinstalls**: Removes `node_modules` and `pnpm-lock.yaml`, then runs `pnpm install`\n\n### Why pnpm pack?\n\n**pnpm pack** creates a tarball (`.tgz` file) that:\n- **Contains only published files**: Respects the `files` field in `package.json`, just like npm publish\n- **Resolves workspace dependencies**: All `workspace:*` and `catalog:` references are resolved to actual versions\n- **No symlinks**: Avoids issues with multiple React instances or module resolution problems\n- **Works with any package manager**: The tarball can be used with pnpm, npm, or yarn\n- **Fast iteration**: Just `pnpm pack` and `pnpm install` to update\n\nThis approach is simpler and more reliable than yalc for monorepos with pnpm catalogs.\n\n### Requirements\n\n- The Hyperlane monorepo must be located at `../hyperlane-monorepo`\n- Packages must be in `../hyperlane-monorepo/typescript/<package-name>/`\n- No additional global tools required (uses built-in pnpm commands)\n\n### Common packages to link\n\n- `sdk` - @hyperlane-xyz/sdk\n- `utils` - @hyperlane-xyz/utils\n- `widgets` - @hyperlane-xyz/widgets\n- `registry` - @hyperlane-xyz/registry\n- `deploy-sdk` - @hyperlane-xyz/deploy-sdk\n- `tron-sdk` - @hyperlane-xyz/tron-sdk (unpublished)\n- `provider-sdk` - @hyperlane-xyz/provider-sdk\n\n### Development workflow\n\n```bash\n# 1. Link packages you want to develop\npnpm link:monorepo sdk utils widgets tron-sdk\n\n# 2. Make changes in ../hyperlane-monorepo/typescript/sdk\n\n# 3. Rebuild the monorepo (or just the package)\ncd ../hyperlane-monorepo\npnpm build\n# OR just rebuild the specific package:\n# cd typescript/sdk && pnpm build\n\n# 4. Re-pack the changed package\ncd typescript/sdk\npnpm pack\n\n# 5. Reinstall in your React app\ncd ../../../hyperlane-warp-ui-template\npnpm install\n\n# 6. Your React app is now using the updated package!\n```\n\n### Quick update workflow\n\nFor faster iteration after the initial link:\n\n```bash\n# In monorepo package directory\ncd ../hyperlane-monorepo/typescript/sdk\npnpm build && pnpm pack && cd - && pnpm install\n```\n\n### Unlinking\n\nTo clean up packed packages and restore published versions:\n\n```bash\npnpm unlink:monorepo\n```\n\nThis will:\n1. Find all dependencies pointing to packed tarballs\n2. Automatically restore dependencies to their latest published versions from npm (using `npm view`)\n3. Remove pnpm overrides for packed packages\n4. Clean `node_modules`, lockfile, and the `.monorepo-tarballs/` directory\n5. Run `pnpm install`\n\nThe script automatically fetches the latest version for each linked package from the npm registry and updates `package.json` accordingly.\n\n## unlink-monorepo.js\n\nRemoves packed packages and cleans up overrides.\n\n### Usage\n\n```bash\npnpm unlink:monorepo\n```\n\n### Troubleshooting\n\nIf you encounter issues:\n\n1. **Check monorepo location**: Ensure `../hyperlane-monorepo` exists\n2. **Build errors**: Make sure the monorepo builds successfully with `cd ../hyperlane-monorepo && pnpm build`\n3. **Tarball not found**: The package might not have packed correctly - check for `.tgz` files in the package directory\n4. **Install fails**: Try manually deleting `node_modules` and `pnpm-lock.yaml`, then run `pnpm install`\n5. **Workspace errors**: Make sure you're running `pnpm build` from the monorepo root first\n\n### Manual commands\n\n```bash\n# Pack a single package\ncd ../hyperlane-monorepo/typescript/sdk\npnpm pack\n\n# Check what tarballs exist\nls ../hyperlane-monorepo/typescript/sdk/*.tgz\n\n# Manually update package.json to use a packed version\n# \"dependencies\": {\n#   \"@hyperlane-xyz/sdk\": \"file:../hyperlane-monorepo/typescript/sdk/hyperlane-xyz-sdk-21.1.0.tgz\"\n# }\n\n# Force reinstall\nrm -rf node_modules pnpm-lock.yaml && pnpm install\n```\n\n### Notes\n\n- Tarballs are created in the package directory (e.g., `typescript/sdk/hyperlane-xyz-sdk-21.1.0.tgz`)\n- The script automatically cleans old tarballs before packing\n- After linking, your `package.json` will have `file:../hyperlane-monorepo/...` references\n- Tarballs contain only files listed in the package's `files` field (typically just `/dist`)\n- This approach works great for development but remember to test with published versions before releasing\n\n### Comparison: pnpm pack vs yalc\n\n| Feature | pnpm pack | yalc |\n|---------|-----------|------|\n| Setup | No global tools | Requires global install |\n| Catalog support | ✅ Native | ⚠️ Needs @jimsheen/yalc fork |\n| Speed | Fast | Fast |\n| Workflow | pack + install | publish + push |\n| Cleanup | Delete tarballs | yalc remove |\n| Portability | Works anywhere | Requires yalc on system |\n\nFor this monorepo with pnpm catalogs, `pnpm pack` is the recommended approach.\n"
  },
  {
    "path": "scripts/fetch-fonts.mjs",
    "content": "import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';\nimport { createWriteStream, existsSync, mkdirSync } from 'fs';\nimport { dirname, join } from 'path';\nimport { pipeline } from 'stream/promises';\nimport { fileURLToPath } from 'url';\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\nconst FONTS_DIR = join(__dirname, '..', 'public', 'fonts');\n\n// Font files to download from S3\nconst FONTS = ['PPValve-PlainVariable.woff2', 'PPFraktionMono-Variable.woff2'];\n\nasync function fetchFonts() {\n  const { AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_S3_BUCKET, AWS_REGION } = process.env;\n\n  // Gracefully skip if environment variables are not configured\n  if (!AWS_ACCESS_KEY_ID || !AWS_SECRET_ACCESS_KEY || !AWS_S3_BUCKET) {\n    console.warn('AWS environment variables not configured - skipping font download');\n    console.warn(\n      'To enable font fetching, set: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_S3_BUCKET',\n    );\n    return;\n  }\n\n  const s3 = new S3Client({\n    region: AWS_REGION || 'us-east-1',\n    credentials: {\n      accessKeyId: AWS_ACCESS_KEY_ID,\n      secretAccessKey: AWS_SECRET_ACCESS_KEY,\n    },\n  });\n\n  // Ensure fonts directory exists\n  if (!existsSync(FONTS_DIR)) {\n    mkdirSync(FONTS_DIR, { recursive: true });\n    console.log(`Created directory: ${FONTS_DIR}`);\n  }\n\n  const results = { success: [], failed: [] };\n\n  // Download each font, continuing on failure\n  for (const fontFile of FONTS) {\n    const outputPath = join(FONTS_DIR, fontFile);\n\n    try {\n      console.log(`Downloading ${fontFile}...`);\n\n      const command = new GetObjectCommand({\n        Bucket: AWS_S3_BUCKET,\n        Key: fontFile,\n      });\n\n      const response = await s3.send(command);\n      const writeStream = createWriteStream(outputPath);\n\n      await pipeline(response.Body, writeStream);\n\n      console.log(`Downloaded ${fontFile}`);\n      results.success.push(fontFile);\n    } catch (error) {\n      console.warn(`Failed to download ${fontFile}: ${error.message}`);\n      results.failed.push(fontFile);\n    }\n  }\n\n  // Summary\n  console.log(\n    `\\nFont download complete: ${results.success.length} succeeded, ${results.failed.length} failed`,\n  );\n\n  if (results.failed.length > 0) {\n    console.warn('Failed fonts:', results.failed.join(', '));\n  }\n}\n\nfetchFonts().catch((error) => {\n  console.error('Font fetch script encountered an unexpected error:', error.message);\n  // Don't fail the build, but log as error for visibility\n});\n"
  },
  {
    "path": "scripts/link-monorepo.js",
    "content": "const { execSync } = require('child_process');\nconst fs = require('fs');\nconst path = require('path');\n\n/** --- Configuration --- */\nconst MONOREPO_NAME = 'hyperlane-monorepo';\nconst REACT_APP_DIR = process.cwd();\nconst MONOREPO_PATH = path.resolve(REACT_APP_DIR, '..', MONOREPO_NAME);\nconst TYPESCRIPT_DIR = path.join(MONOREPO_PATH, 'typescript');\nconst SOLIDITY_DIR = path.join(MONOREPO_PATH, 'solidity');\nconst LOCAL_TARBALLS_DIR = path.join(REACT_APP_DIR, '.monorepo-tarballs');\n\n// Default packages to link. Add new entries here as needed.\nconst DEFAULT_PACKAGES = [\n  'aleo-sdk',\n  'cosmos-sdk',\n  'deploy-sdk',\n  'provider-sdk',\n  'radix-sdk',\n  'sdk',\n  'starknet-sdk',\n  'svm-sdk',\n  'tron-sdk',\n  'utils',\n  'widgets',\n];\n\n// Allow overriding via CLI args, e.g.: node link-monorepo.js sdk utils\nconst args = process.argv.slice(2).length > 0 ? process.argv.slice(2) : DEFAULT_PACKAGES;\n\n/**\n * Helper to run commands\n */\nfunction run(command, cwd = REACT_APP_DIR) {\n  try {\n    execSync(command, { stdio: 'inherit', cwd });\n    return true;\n  } catch (err) {\n    return false;\n  }\n}\n\n/**\n * Validates that a package path stays within the typescript directory\n * Prevents path traversal attacks (e.g., ../foo)\n */\nfunction validatePackagePath(folder) {\n  const pkgPath = path.join(TYPESCRIPT_DIR, folder);\n  const resolvedPath = path.resolve(pkgPath);\n  const relativePath = path.relative(TYPESCRIPT_DIR, resolvedPath);\n\n  // Check if the path escapes the typescript directory\n  if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {\n    console.error(`❌ Invalid package path: \"${folder}\"`);\n    console.error(`   Package paths must be within ${TYPESCRIPT_DIR}`);\n    process.exit(1);\n  }\n\n  return pkgPath;\n}\n\nconsole.log('🚀 Starting pnpm pack link workflow...\\n');\n\n/**\n * 1. Check monorepo setup\n */\nconsole.log('------------------------------------------');\nconsole.log('📋 Checking monorepo setup...');\nif (!fs.existsSync(MONOREPO_PATH)) {\n  console.error(`❌ Monorepo not found at: ${MONOREPO_PATH}`);\n  process.exit(1);\n}\nconsole.log(`✅ Found monorepo at: ${MONOREPO_PATH}\\n`);\n\n/**\n * 2. Build the entire monorepo first\n */\nconsole.log('------------------------------------------');\nconsole.log('🏗️  Building entire monorepo...');\nconsole.log('   This ensures all dependencies are built in the correct order\\n');\nif (!run('pnpm build', MONOREPO_PATH)) {\n  console.error('\\n❌ Monorepo build failed. Please fix errors and try again.');\n  process.exit(1);\n}\nconsole.log('✅ Monorepo build complete\\n');\n\n/**\n * 3. Prepare local tarballs directory\n */\nconsole.log('------------------------------------------');\nconsole.log('📁 Preparing local tarballs directory...\\n');\n\nif (!fs.existsSync(LOCAL_TARBALLS_DIR)) {\n  fs.mkdirSync(LOCAL_TARBALLS_DIR, { recursive: true });\n  console.log(`   ✅ Created: ${LOCAL_TARBALLS_DIR}\\n`);\n} else {\n  console.log(`   ✅ Using: ${LOCAL_TARBALLS_DIR}\\n`);\n}\n\n/**\n * Packs the package at pkgPath and moves the tarball to LOCAL_TARBALLS_DIR.\n * Returns the packed package info, or null on failure.\n */\nfunction packPackage(pkgPath) {\n  if (!fs.existsSync(pkgPath)) {\n    console.warn(`⚠️  Directory not found: ${pkgPath}`);\n    return null;\n  }\n\n  const pkgJsonPath = path.join(pkgPath, 'package.json');\n  if (!fs.existsSync(pkgJsonPath)) {\n    console.warn(`⚠️  package.json not found in ${pkgPath}`);\n    return null;\n  }\n\n  const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));\n  const packageName = pkgJson.name;\n  const packageVersion = pkgJson.version;\n\n  console.log(`📦 Packing: ${packageName}@${packageVersion}`);\n\n  // Remove old tarballs first\n  fs.readdirSync(pkgPath).filter(f => f.endsWith('.tgz')).forEach(tarball => {\n    fs.unlinkSync(path.join(pkgPath, tarball));\n  });\n\n  if (!run('pnpm pack', pkgPath)) {\n    console.error(`❌ Failed to pack ${packageName}`);\n    return null;\n  }\n\n  const tarballs = fs.readdirSync(pkgPath).filter(f => f.endsWith('.tgz'));\n  if (tarballs.length === 0) {\n    console.error(`❌ No tarball found after packing ${packageName}`);\n    return null;\n  }\n\n  const tarballName = tarballs[0];\n  const sourceTarballPath = path.join(pkgPath, tarballName);\n  const destTarballPath = path.join(LOCAL_TARBALLS_DIR, tarballName);\n\n  fs.copyFileSync(sourceTarballPath, destTarballPath);\n  fs.unlinkSync(sourceTarballPath);\n\n  const relativePath = path.relative(REACT_APP_DIR, destTarballPath);\n  console.log(`   ✅ Created and moved: ${tarballName}`);\n  console.log(`      Location: ${relativePath}\\n`);\n\n  return { name: packageName, version: packageVersion, tarballPath: relativePath };\n}\n\n/**\n * 4. Pack each specified package\n */\nconst packedPackages = [];\n\nconsole.log('------------------------------------------');\nconsole.log('📦 Packing packages...\\n');\n\nargs.forEach((folder) => {\n  const result = packPackage(validatePackagePath(folder));\n  if (result) packedPackages.push(result);\n});\n\nconsole.log('📦 Packing hardcoded package: @hyperlane-xyz/core (from solidity/)');\nconst coreResult = packPackage(SOLIDITY_DIR);\nif (coreResult) packedPackages.push(coreResult);\n\nif (packedPackages.length === 0) {\n  console.error('❌ No packages were packed successfully.');\n  process.exit(1);\n}\n\n/**\n * 5. Update package.json with file: references\n */\nconsole.log('------------------------------------------');\nconsole.log('🔧 Updating package.json with packed dependencies...\\n');\n\nconst packageJsonPath = path.join(REACT_APP_DIR, 'package.json');\nconst packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));\n\n// Ensure pnpm.overrides exists\nif (!packageJson.pnpm) {\n  packageJson.pnpm = {};\n}\nif (!packageJson.pnpm.overrides) {\n  packageJson.pnpm.overrides = {};\n}\n\npackedPackages.forEach(({ name, tarballPath }) => {\n  // Update dependencies\n  if (packageJson.dependencies && packageJson.dependencies[name]) {\n    packageJson.dependencies[name] = `file:${tarballPath}`;\n    console.log(`   ${name} -> file:${tarballPath}`);\n  }\n\n  // Add to overrides to ensure sub-dependencies use packed version\n  packageJson.pnpm.overrides[name] = `file:${tarballPath}`;\n});\n\n// Write updated package.json\nfs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\\n');\nconsole.log('\\n✅ Updated package.json\\n');\n\n/**\n * 6. Clean and reinstall\n */\nconsole.log('------------------------------------------');\nconsole.log('🧹 Cleaning node_modules and lockfile...\\n');\n\nconst nodeModulesPath = path.join(REACT_APP_DIR, 'node_modules');\nconst lockfilePath = path.join(REACT_APP_DIR, 'pnpm-lock.yaml');\n\nif (fs.existsSync(nodeModulesPath)) {\n  fs.rmSync(nodeModulesPath, { recursive: true, force: true });\n}\nif (fs.existsSync(lockfilePath)) {\n  fs.unlinkSync(lockfilePath);\n}\n\nconsole.log('✅ Cleaned\\n');\n\nconsole.log('------------------------------------------');\nconsole.log('📥 Installing dependencies...\\n');\n\nif (!run('pnpm install')) {\n  console.error('\\n❌ pnpm install failed.');\n  process.exit(1);\n}\n\n/**\n * 7. Success!\n */\nconsole.log('\\n------------------------------------------');\nconsole.log('✨ Done! Packages are linked.\\n');\nconsole.log('📦 Linked packages:');\npackedPackages.forEach(({ name, version }) => {\n  console.log(`   - ${name}@${version}`);\n});\nconsole.log('\\n💡 To update after making changes:');\nconsole.log('   1. Run this script again: pnpm link:monorepo <packages>');\nconsole.log('   OR manually:');\nconsole.log('   1. cd ../hyperlane-monorepo && pnpm build');\nconsole.log('   2. cd typescript/<package> && pnpm pack');\nconsole.log('   3. Move the .tgz file to .monorepo-tarballs/ here');\nconsole.log('   4. cd back here && pnpm install\\n');\nconsole.log(`📁 Tarballs location: ${path.relative(REACT_APP_DIR, LOCAL_TARBALLS_DIR)}\\n`);\n"
  },
  {
    "path": "scripts/unlink-monorepo.js",
    "content": "const { execSync, spawnSync } = require('child_process');\nconst fs = require('fs');\nconst path = require('path');\n\n/**\n * Unlink monorepo packages and restore published versions\n */\n\nconst REACT_APP_DIR = process.cwd();\nconst packageJsonPath = path.join(REACT_APP_DIR, 'package.json');\nconst LOCAL_TARBALLS_DIR = path.join(REACT_APP_DIR, '.monorepo-tarballs');\n\nconsole.log('🔗 Unlinking monorepo packages...\\n');\n\ntry {\n  // Read package.json to find file: references to monorepo\n  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));\n  const packOverrides = [];\n\n  // Find dependencies pointing to packed tarballs (old or new location)\n  if (packageJson.dependencies) {\n    Object.entries(packageJson.dependencies).forEach(([name, value]) => {\n      if (typeof value === 'string' &&\n          (value.startsWith('file:../hyperlane-monorepo/') ||\n           value.startsWith('file:.monorepo-tarballs/'))) {\n        packOverrides.push(name);\n      }\n    });\n  }\n\n  if (packOverrides.length === 0) {\n    console.log('ℹ️  No packed monorepo packages found in dependencies.');\n  } else {\n    console.log('🔧 Found packed packages in dependencies:');\n    packOverrides.forEach((name) => {\n      console.log(`   - ${name}`);\n    });\n  }\n\n  // Remove overrides for packed packages (old or new location)\n  if (packageJson.pnpm && packageJson.pnpm.overrides) {\n    let removedCount = 0;\n    Object.keys(packageJson.pnpm.overrides).forEach((name) => {\n      const value = packageJson.pnpm.overrides[name];\n      if (typeof value === 'string' &&\n          (value.startsWith('file:../hyperlane-monorepo/') ||\n           value.startsWith('file:.monorepo-tarballs/'))) {\n        delete packageJson.pnpm.overrides[name];\n        removedCount++;\n      }\n    });\n\n    if (removedCount > 0) {\n      console.log(`\\n🔧 Removed ${removedCount} override(s) from package.json`);\n    }\n  }\n\n  // Restore dependencies to published versions\n  const failedToRestore = [];\n  if (packOverrides.length > 0) {\n    console.log('\\n🔧 Restoring dependencies to published versions...');\n\n    packOverrides.forEach((name) => {\n      if (packageJson.dependencies[name]) {\n        const currentValue = packageJson.dependencies[name];\n        try {\n          // Fetch the latest version from npm registry\n          // Use spawnSync with array args to prevent command injection\n          const result = spawnSync('npm', ['view', name, 'version'], { encoding: 'utf8' });\n          if (result.status === 0 && result.stdout) {\n            const versionOutput = result.stdout.trim();\n            packageJson.dependencies[name] = versionOutput;\n            console.log(`   ${name} -> ${versionOutput}`);\n          } else {\n            console.warn(`   ⚠️  Failed to fetch version for ${name}`);\n            failedToRestore.push({ name, currentValue });\n          }\n        } catch (err) {\n          console.warn(`   ⚠️  Failed to fetch version for ${name}`);\n          failedToRestore.push({ name, currentValue });\n        }\n      }\n    });\n  }\n\n  // Check if any dependencies couldn't be restored\n  if (failedToRestore.length > 0) {\n    console.error('\\n❌ Cannot proceed: Some dependencies could not be restored to published versions.');\n    console.error('   This typically happens with unpublished packages (e.g., @hyperlane-xyz/tron-sdk)');\n    console.error('   or when you are offline.\\n');\n    console.error('   Failed packages:');\n    failedToRestore.forEach(({ name, currentValue }) => {\n      console.error(`   - ${name} (currently: ${currentValue})`);\n    });\n    console.error('\\n💡 Options:');\n    console.error('   1. Manually update these dependencies in package.json to published versions');\n    console.error('   2. Remove these dependencies from package.json if not needed');\n    console.error('   3. Keep using the linked versions (do not run this script)\\n');\n    process.exit(1);\n  }\n\n  // Write updated package.json\n  fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\\n');\n\n  console.log('\\n------------------------------------------');\n  console.log('🧹 Cleaning node_modules, lockfile, and tarballs...\\n');\n\n  const nodeModulesPath = path.join(REACT_APP_DIR, 'node_modules');\n  const lockfilePath = path.join(REACT_APP_DIR, 'pnpm-lock.yaml');\n\n  if (fs.existsSync(nodeModulesPath)) {\n    fs.rmSync(nodeModulesPath, { recursive: true, force: true });\n  }\n  if (fs.existsSync(lockfilePath)) {\n    fs.unlinkSync(lockfilePath);\n  }\n\n  // Clean up local tarballs directory\n  if (fs.existsSync(LOCAL_TARBALLS_DIR)) {\n    console.log(`   Removing ${path.relative(REACT_APP_DIR, LOCAL_TARBALLS_DIR)}/`);\n    fs.rmSync(LOCAL_TARBALLS_DIR, { recursive: true, force: true });\n  }\n\n  console.log('✅ Cleaned\\n');\n\n  console.log('------------------------------------------');\n  console.log('📥 Running pnpm install...\\n');\n\n  execSync('pnpm install', {\n    stdio: 'inherit'\n  });\n\n  console.log('\\n✅ Successfully unlinked packages!');\n  console.log('   All dependencies have been restored to their published versions from npm.\\n');\n} catch (err) {\n  console.error('\\n❌ Unlink failed. See error above.\\n');\n  process.exit(1);\n}\n"
  },
  {
    "path": "sentry.client.config.js",
    "content": "import { sentryDefaultConfig } from './sentry.default.config';\nimport * as Sentry from '@sentry/nextjs';\n\nif (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_SENTRY_DSN) {\n  Sentry.init({\n    ...sentryDefaultConfig,\n    integrations: [\n      Sentry.breadcrumbsIntegration({\n        console: false,\n        dom: false,\n        fetch: false,\n        history: false,\n        sentry: false,\n        xhr: false,\n      }),\n      Sentry.dedupeIntegration(),\n      Sentry.functionToStringIntegration(),\n      Sentry.globalHandlersIntegration(),\n      Sentry.httpContextIntegration(),\n    ],\n  });\n}\n"
  },
  {
    "path": "sentry.default.config.js",
    "content": "const filters = [\n  // Hyperlane custom set\n  \"trap returned falsish for property\", // Error from cosmos wallet lib\n  \"not established yet\", // Same, bug with their WC integration ^\n  \"Refused to create a WebAssembly object\", // CSP blocking wasm\n  \"call to WebAssembly.instantiate\", // Same ^\n  \"Request rejected\", // Unknown noise during Next.js init\n  \"WebSocket connection failed for host\", // WalletConnect flakiness\n  \"Socket stalled when trying to connect\", // Same ^\n  \"Request expired. Please try again.\", // Same^\n  \"Failed to publish payload\", // Same ^\n  // Some recommendations from https://docs.sentry.io/platforms/javascript/configuration/filtering\n  \"top.GLOBALS\",\n  \"originalCreateNotification\",\n  \"canvas.contentDocument\",\n  \"MyApp_RemoveAllHighlights\",\n  \"atomicFindClose\",\n  \"Wallet is not initialized\",\n  \"region has been blocked from accessing this service\"\n]\n\nexport const sentryDefaultConfig = {\n  dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,\n  tracesSampleRate: 0.01,\n  maxBreadcrumbs: 1,\n  sendClientReports: false,\n  attachStacktrace: false,\n  defaultIntegrations: false,\n  integrations: [],\n  beforeSend(event, hint) {\n    if (event && event.message && \n      filters.find((f) => event.message.match(f))) \n    {\n      return null;\n    }\n\n    const error = hint.originalException;\n    if (error && error.message && \n      filters.find((f) => error.message.match(f))) \n    {\n      return null;\n    } \n\n    delete event.user;\n    return event;\n  },\n  ignoreErrors: filters,\n  denyUrls: [\n    // Chrome extensions\n    /extensions\\//i,\n    /^chrome:\\/\\//i,\n    /^chrome-extension:\\/\\//i,\n  ],\n};\n"
  },
  {
    "path": "src/components/banner/FormWarningBanner.tsx",
    "content": "import clsx from 'clsx';\nimport { ComponentProps } from 'react';\n\nimport { WarningBanner } from '../../components/banner/WarningBanner';\n\nexport function FormWarningBanner({\n  className,\n  isVisible,\n  ...props\n}: ComponentProps<typeof WarningBanner>) {\n  return (\n    <div>\n      <WarningBanner\n        className={clsx('absolute -top-4 left-0 right-0 z-10', className)}\n        isVisible={isVisible}\n        {...props}\n      />\n      <div\n        className={clsx('transition-all duration-500', isVisible ? 'pb-12 sm:pb-10' : 'pb-0')}\n      ></div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/banner/RecipientWarningBanner.tsx",
    "content": "import { WarningIcon } from '@hyperlane-xyz/widgets';\n\nexport function RecipientWarningBanner({\n  destinationChain,\n  confirmRecipientHandler,\n}: {\n  destinationChain: string;\n  confirmRecipientHandler: (checked: boolean) => void;\n}) {\n  return (\n    <div className=\"flex items-center gap-3\">\n      <WarningIcon width={40} height={40} />\n      <div>\n        <p className=\"my-2\">\n          The recipient address is the same as the currently connected smart contract wallet,{' '}\n          <strong>but it does not exist as a smart contract on {destinationChain}</strong>.\n        </p>\n        <p className=\"my-2\">This may result in losing access to your bridged tokens.</p>\n        <p className=\"my-2\">\n          <strong>\n            Only proceed if you are certain you have control over this address on {destinationChain}\n          </strong>\n        </p>\n        <div className=\"justify-left flex w-max gap-2 rounded bg-white/30 px-2.5 py-1 text-center hover:bg-white/50 active:bg-white/60\">\n          <input\n            onChange={({ target: { checked } }) => confirmRecipientHandler(checked)}\n            type=\"checkbox\"\n            id=\"confirm-address\"\n            name=\"confirm-recipient\"\n          />\n          <label htmlFor=\"confirm-address\">I have control and want to bridge to this address</label>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/banner/WarningBanner.tsx",
    "content": "import { WarningIcon } from '@hyperlane-xyz/widgets';\nimport { PropsWithChildren, ReactNode } from 'react';\n\nexport function WarningBanner({\n  isVisible,\n  cta,\n  onClick,\n  className,\n  children,\n}: PropsWithChildren<{\n  isVisible: boolean;\n  cta?: ReactNode;\n  onClick?: () => void;\n  className?: string;\n}>) {\n  return (\n    <div\n      className={`flex items-center justify-between gap-2 rounded bg-amber-400 px-4 text-sm ${\n        isVisible ? 'max-h-28 py-2' : 'max-h-0'\n      } overflow-hidden transition-all duration-500 ${className}`}\n    >\n      <div className=\"flex items-center gap-2\">\n        <WarningIcon width={20} height={20} />\n        {children}\n      </div>\n      {cta && onClick && (\n        <button\n          type=\"button\"\n          onClick={onClick}\n          className=\"rounded-full bg-white/30 px-2.5 py-1 text-center hover:bg-white/50 active:bg-white/60\"\n        >\n          {cta}\n        </button>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/buttons/ConnectAwareSubmitButton.tsx",
    "content": "import { ProtocolType } from '@hyperlane-xyz/utils';\nimport { useTimeout } from '@hyperlane-xyz/widgets';\nimport {\n  useAccountForChain,\n  useConnectFns,\n} from '@hyperlane-xyz/widgets/walletIntegrations/multiProtocol';\nimport { useFormikContext } from 'formik';\nimport { useCallback } from 'react';\n\nimport { EVENT_NAME } from '../../features/analytics/types';\nimport { trackEvent } from '../../features/analytics/utils';\nimport { useChainProtocol, useMultiProvider } from '../../features/chains/hooks';\nimport { SolidButton } from './SolidButton';\n\ninterface Props {\n  chainName: ChainName;\n  text: string;\n  classes?: string;\n  disabled?: boolean;\n}\n\nexport function ConnectAwareSubmitButton<FormValues = any>({\n  chainName,\n  text,\n  classes,\n  disabled,\n}: Props) {\n  const protocol = useChainProtocol(chainName) || ProtocolType.Ethereum;\n  const connectFns = useConnectFns();\n  const connectFn = connectFns[protocol];\n\n  const multiProvider = useMultiProvider();\n  const account = useAccountForChain(multiProvider, chainName);\n  const isAccountReady = account?.isReady;\n\n  const { errors, setErrors, touched, setTouched } = useFormikContext<FormValues>();\n\n  const hasError = Object.keys(errors).length > 0;\n  const firstError = `${Object.values(errors)[0]}` || 'Unknown error';\n\n  const color = hasError ? 'red' : 'accent';\n  const content = hasError ? firstError : isAccountReady ? text : 'Connect wallet';\n  const type =\n    disabled || !isAccountReady\n      ? 'button' // never submits when deliberately disabled\n      : 'submit';\n\n  const onClick = () => {\n    if (isAccountReady) return undefined;\n\n    trackEvent(EVENT_NAME.WALLET_CONNECTION_INITIATED, { protocol });\n    connectFn();\n  };\n\n  // Automatically clear error state after a timeout\n  const clearErrors = useCallback(() => {\n    setErrors({});\n    setTouched({});\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [setErrors, setTouched, errors, touched]);\n\n  useTimeout(clearErrors, 3500);\n\n  return (\n    <SolidButton\n      disabled={disabled && isAccountReady}\n      type={type}\n      color={color}\n      onClick={onClick}\n      className={classes}\n    >\n      {content}\n    </SolidButton>\n  );\n}\n"
  },
  {
    "path": "src/components/buttons/SolidButton.tsx",
    "content": "import { PropsWithChildren, ReactElement } from 'react';\n\ninterface ButtonProps {\n  type?: 'submit' | 'reset' | 'button';\n  color?: 'white' | 'primary' | 'accent' | 'green' | 'red' | 'gray'; // defaults to primary\n  bold?: boolean;\n  className?: string;\n  icon?: ReactElement;\n}\n\nexport function SolidButton(\n  props: PropsWithChildren<ButtonProps & React.HTMLProps<HTMLButtonElement>>,\n) {\n  const {\n    type,\n    onClick,\n    color: _color,\n    className,\n    bold,\n    icon,\n    disabled,\n    title,\n    ...passThruProps\n  } = props;\n  const color = _color ?? 'primary';\n\n  const base =\n    'flex items-center justify-center rounded transition-all duration-500 active:scale-95';\n  let baseColors, onHover;\n  if (color === 'primary') {\n    baseColors = 'bg-primary-500 text-white';\n    onHover = 'hover:bg-primary-600';\n  } else if (color === 'accent') {\n    baseColors = 'bg-accent-gradient text-white shadow-accent-glow';\n    onHover = 'hover:opacity-90';\n  } else if (color === 'green') {\n    baseColors = 'bg-green-500 text-white';\n    onHover = 'hover:bg-green-600';\n  } else if (color === 'red') {\n    baseColors = 'bg-error-gradient text-white shadow-error-glow';\n    onHover = 'hover:opacity-90';\n  } else if (color === 'white') {\n    baseColors = 'bg-white text-black';\n    onHover = 'hover:bg-primary-100';\n  } else if (color === 'gray') {\n    baseColors = 'bg-gray-100 text-primary-500';\n    onHover = 'hover:bg-gray-200';\n  }\n  const onDisabled =\n    'disabled:bg-gray-300 disabled:text-gray-500 disabled:shadow-none disabled:bg-none';\n  const weight = bold ? 'font-semibold' : '';\n  const allClasses = `${base} ${baseColors} ${onHover} ${onDisabled} ${weight} ${className}`;\n\n  return (\n    <button\n      onClick={onClick}\n      type={type ?? 'button'}\n      disabled={disabled ?? false}\n      title={title}\n      className={allClasses}\n      {...passThruProps}\n    >\n      {icon ? (\n        <div className=\"flex items-center justify-center space-x-1\">\n          {props.icon}\n          {props.children}\n        </div>\n      ) : (\n        <>{props.children}</>\n      )}\n    </button>\n  );\n}\n"
  },
  {
    "path": "src/components/errors/ErrorBoundary.tsx",
    "content": "import { ErrorBoundary as ErrorBoundaryInner } from '@hyperlane-xyz/widgets';\nimport { PropsWithChildren } from 'react';\n\nimport { links } from '../../consts/links';\n\nexport function ErrorBoundary({ children }: PropsWithChildren<unknown>) {\n  return <ErrorBoundaryInner supportLink={<SupportLink />}>{children}</ErrorBoundaryInner>;\n}\n\nfunction SupportLink() {\n  return (\n    <a href={links.discord} target=\"_blank\" rel=\"noopener noreferrer\" className=\"mt-5 text-sm\">\n      For support, join the{' '}\n      <span className=\"underline underline-offset-2\">Hyperlane Discord</span>{' '}\n    </a>\n  );\n}\n"
  },
  {
    "path": "src/components/icons/BookIcon.tsx",
    "content": "import { DefaultIconProps } from '@hyperlane-xyz/widgets';\nimport { memo } from 'react';\n\nimport { Color } from '../../styles/Color';\n\nfunction _BookIcon({ color, ...props }: DefaultIconProps) {\n  return (\n    <svg viewBox=\"0 0 23 16\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <path\n        d=\"M21.688 6.90571C20.9608 6.90571 20.3759 7.53466 20.3759 8.3166C20.3759 8.38459 20.4076 8.45259 20.4076 8.52059L11.2708 13.5692C11.0337 13.3312 10.7175 13.1612 10.3698 13.1612C9.99038 13.1612 9.65842 13.3312 9.42131 13.6202L1.86529 9.96547L1.61237 9.84648C1.13814 9.6085 0.837801 9.13254 0.837801 8.57158C0.837801 8.07862 1.02749 7.65365 1.39107 7.38167C1.4543 7.33068 1.51753 7.29668 1.58076 7.26268L1.69141 7.21169L2.00756 7.36468L10.2117 11.3424C10.2117 11.3424 10.3223 11.3764 10.3856 11.3764C10.4488 11.3764 10.512 11.3764 10.5753 11.3254L21.8935 5.06985C22.0357 4.98486 22.1306 4.83187 22.1148 4.66189C22.1148 4.4919 22.0199 4.33891 21.8619 4.25392L13.1835 0.038247C13.0729 -0.012749 12.9306 -0.012749 12.8199 0.038247L1.50172 6.31076L1.37526 6.36175L1.20137 6.44675C0.442612 6.85471 0 7.67065 0 8.58858C0 9.48951 0.505842 10.2884 1.2646 10.6794L9.08935 14.4701C9.08935 14.4701 9.05773 14.5551 9.05773 14.5891C9.05773 15.371 9.64261 16 10.3698 16C11.0969 16 11.6818 15.371 11.6818 14.5891C11.6818 14.5211 11.6502 14.4531 11.6502 14.3851L20.7869 9.33652C21.0241 9.5745 21.3402 9.72749 21.688 9.72749C22.4151 9.72749 23 9.09854 23 8.3166C23 7.53466 22.4151 6.90571 21.688 6.90571ZM10.3698 15.0821C10.2275 15.0821 10.101 15.0141 10.022 14.8951C9.95876 14.8101 9.91134 14.6911 9.91134 14.5721C9.91134 14.3001 10.1168 14.0792 10.3698 14.0622C10.6227 14.0622 10.844 14.2831 10.844 14.5721C10.844 14.6911 10.7966 14.7931 10.7333 14.8781C10.6543 14.9971 10.5278 15.0821 10.3698 15.0821ZM21.7196 8.82656C21.7196 8.82656 21.7196 8.82656 21.7038 8.82656C21.4509 8.82656 21.2296 8.60558 21.2296 8.3166C21.2296 8.19761 21.277 8.09562 21.3402 8.01062C21.4192 7.89163 21.5615 7.80664 21.7038 7.80664C21.9567 7.80664 22.178 8.02762 22.178 8.3166C22.178 8.60558 21.9725 8.80956 21.7354 8.80956L21.7196 8.82656Z\"\n        fill={color || Color.primary[500]}\n      />\n    </svg>\n  );\n}\n\nexport const BookIcon = memo(_BookIcon);\n"
  },
  {
    "path": "src/components/icons/ChainLogo.tsx",
    "content": "import { ChainLogo as ChainLogoInner } from '@hyperlane-xyz/widgets';\nimport { useEffect, useRef } from 'react';\n\nimport { useChainMetadata } from '../../features/chains/hooks';\nimport { useStore } from '../../features/store';\nimport { observeDarkLogosInContainer } from '../../utils/imageBrightness';\n\nexport function ChainLogo({\n  chainName,\n  background,\n  size,\n}: {\n  chainName?: string;\n  background?: boolean;\n  size?: number;\n}) {\n  const registry = useStore((s) => s.registry);\n  const chainMetadata = useChainMetadata(chainName);\n  const wrapperRef = useRef<HTMLSpanElement>(null);\n  const name = chainMetadata?.name || '';\n  const logoUri = chainMetadata?.logoURI;\n\n  // Process immediately; keep a short-lived observer until the logo image first appears.\n  useEffect(() => {\n    const el = wrapperRef.current;\n    if (!el) return;\n    const logoObserver = observeDarkLogosInContainer(el, { disconnectOnFirstImage: true });\n    return () => logoObserver?.disconnect();\n  }, [chainName, logoUri]);\n\n  return (\n    <span ref={wrapperRef} className=\"inline-flex\">\n      <ChainLogoInner\n        chainName={name}\n        logoUri={logoUri}\n        registry={registry}\n        size={size}\n        background={background}\n      />\n    </span>\n  );\n}\n"
  },
  {
    "path": "src/components/icons/ChevronLargeIcon.tsx",
    "content": "import { DefaultIconProps } from '@hyperlane-xyz/widgets';\nimport { memo } from 'react';\n\nimport { Color } from '../../styles/Color';\n\nfunction _ChevronLargeIcon({ color, ...props }: DefaultIconProps) {\n  return (\n    <svg viewBox=\"0 0 16 20\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <path\n        d=\"M16 11.2601L2.31817e-07 20L1.95149e-07 16.8365L11.2573 10.992C11.8306 10.6702 12.2997 10.563 12.9251 10.563H14.5928V9.49062H12.9251C12.2997 9.49062 11.8306 9.38338 11.2573 9.06166L3.6048e-08 3.10992L0 0L16 8.68633V11.2601Z\"\n        fill={color || Color.gray[900]}\n      />\n    </svg>\n  );\n}\n\nexport const ChevronLargeIcon = memo(_ChevronLargeIcon);\n"
  },
  {
    "path": "src/components/icons/HamburgerIcon.tsx",
    "content": "import { DefaultIconProps } from '@hyperlane-xyz/widgets';\nimport { memo } from 'react';\n\nfunction _HamburgerIcon({ color, ...props }: DefaultIconProps) {\n  return (\n    <svg viewBox=\"0 0 20 19\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <path\n        d=\"M10 18H1M19 9.5H1M19 1H1\"\n        stroke={color || 'currentColor'}\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nexport const HamburgerIcon = memo(_HamburgerIcon);\n"
  },
  {
    "path": "src/components/icons/HyperlaneGradientLogo.tsx",
    "content": "import { DefaultIconProps } from '@hyperlane-xyz/widgets';\nimport { memo } from 'react';\n\nfunction _HyperlaneGradientLogo({ ...props }: DefaultIconProps) {\n  return (\n    <svg viewBox=\"0 0 219 18\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <path\n        d=\"M18.6184 1.34497L21.4768 8.5871C21.5798 8.84574 21.5798 9.13026 21.4768 9.3889L18.6184 16.631C18.3094 17.4328 17.5111 17.976 16.6613 17.976H9.9401V17.1742H11.3049C12.5668 17.1742 13.6741 16.3983 14.1376 15.2343L16.4552 9.3889H11.5367L8.67828 16.631C8.36926 17.4328 7.57096 17.976 6.72116 17.976H0V17.1742H1.36483C2.62666 17.1742 3.73398 16.3983 4.1975 15.2343L6.51515 9.3889C6.61815 9.13026 6.61815 8.84574 6.51515 8.5871L4.1975 2.74166C3.73398 1.57775 2.60091 0.801807 1.36483 0.801807H0V0H6.72116C7.59671 0 8.36926 0.54316 8.67828 1.34497L11.5367 8.5871H16.4552L14.1376 2.74166C13.6741 1.57775 12.541 0.801807 11.3049 0.801807H9.9401V0H16.6613C17.5368 0 18.3094 0.54316 18.6184 1.34497ZM34.4298 14.1222C35.0994 14.1222 35.6659 13.9928 36.1552 13.7342C36.6445 13.4755 37.005 13.0876 37.2883 12.5703C37.5458 12.053 37.7003 11.3805 37.7003 10.5787H36.5157C36.5157 11.4839 36.3612 12.1047 36.0264 12.4668C35.6917 12.8289 35.1766 13.01 34.4556 13.01H34.4041C33.683 13.01 33.168 12.8289 32.8332 12.4668C32.4985 12.1047 32.344 11.4581 32.344 10.5787V7.52664C32.344 6.64724 32.4985 6.02649 32.8332 5.63852C33.168 5.27641 33.683 5.09536 34.4041 5.09536H34.4556C35.1766 5.09536 35.6917 5.27641 36.0264 5.63852C36.3612 6.00062 36.5157 6.62138 36.5157 7.52664H37.7003C37.7003 6.72484 37.5715 6.07822 37.2883 5.53506C37.0307 5.01776 36.6445 4.62979 36.1552 4.37114C35.6659 4.1125 35.0994 3.98317 34.4298 3.98317H34.3783C33.7088 3.98317 33.1423 4.1125 32.653 4.37114C32.1637 4.62979 31.8032 5.01776 31.5199 5.53506C31.2624 6.05235 31.1079 6.72484 31.1079 7.52664V10.5787C31.1079 11.3805 31.2366 12.053 31.5199 12.5703C31.7774 13.0876 32.1637 13.5014 32.653 13.7342C33.1423 13.9928 33.7088 14.1222 34.3783 14.1222H34.4298ZM43.0566 14.1222C43.7262 14.1222 44.2927 13.9928 44.782 13.7342C45.2712 13.4755 45.6318 13.0876 45.915 12.5703C46.1726 12.053 46.3271 11.3805 46.3271 10.5787V7.52664C46.3271 6.72484 46.1983 6.07822 45.915 5.53506C45.6575 5.01776 45.2712 4.62979 44.782 4.37114C44.2927 4.1125 43.7262 3.98317 43.0566 3.98317H43.0051C42.3356 3.98317 41.769 4.1125 41.2798 4.37114C40.7905 4.62979 40.43 5.01776 40.1467 5.53506C39.8892 6.05235 39.7347 6.72484 39.7347 7.52664V10.5787C39.7347 11.3805 39.8634 12.053 40.1467 12.5703C40.4042 13.0876 40.7905 13.5014 41.2798 13.7342C41.769 13.9928 42.3356 14.1222 43.0051 14.1222H43.0566ZM43.0051 13.0358C42.2841 13.0358 41.769 12.8548 41.4343 12.4927C41.0995 12.1306 40.945 11.4839 40.945 10.6045V7.55251C40.945 6.67311 41.0995 6.05235 41.4343 5.66438C41.769 5.30227 42.2841 5.12122 43.0051 5.12122H43.0566C43.7777 5.12122 44.2927 5.30227 44.6275 5.66438C44.9622 6.02649 45.1167 6.64724 45.1167 7.55251V10.6045C45.1167 11.5098 44.9622 12.1306 44.6275 12.4927C44.2927 12.8548 43.7777 13.0358 43.0566 13.0358H43.0051ZM49.752 13.9928V5.92303H49.8808L52.9452 13.9928H54.6963V4.13836H53.5375V12.2082H53.4087L50.3443 4.13836H48.5932V13.9928H49.752ZM58.3788 13.9928V5.92303H58.5076L61.572 13.9928H63.3231V4.13836H62.1643V12.2082H62.0355L58.9711 4.13836H57.22V13.9928H58.3788ZM72.1301 13.9928V12.8806H66.8253L67.2888 13.3203V9.23372L66.8253 9.62169H71.0228V8.5095H66.8253L67.2888 8.89747V4.83671L66.8253 5.27641H72.1301V4.16422H66.1043V14.0187H72.1301V13.9928ZM77.538 14.1222C78.2075 14.1222 78.774 13.9928 79.2633 13.7342C79.7526 13.4755 80.1131 13.0876 80.3964 12.5703C80.6539 12.053 80.8084 11.3805 80.8084 10.5787H79.6238C79.6238 11.4839 79.4693 12.1047 79.1346 12.4668C78.7998 12.8289 78.2848 13.01 77.5637 13.01H77.5122C76.7912 13.01 76.2761 12.8289 75.9414 12.4668C75.6066 12.1047 75.4521 11.4581 75.4521 10.5787V7.52664C75.4521 6.64724 75.6066 6.02649 75.9414 5.63852C76.2761 5.27641 76.7912 5.09536 77.5122 5.09536H77.5637C78.2848 5.09536 78.7998 5.27641 79.1346 5.63852C79.4693 6.00062 79.6238 6.62138 79.6238 7.52664H80.8084C80.8084 6.72484 80.6797 6.07822 80.3964 5.53506C80.1389 5.01776 79.7526 4.62979 79.2633 4.37114C78.774 4.1125 78.2075 3.98317 77.538 3.98317H77.4865C76.8169 3.98317 76.2504 4.1125 75.7611 4.37114C75.2718 4.62979 74.9113 5.01776 74.628 5.53506C74.3705 6.05235 74.216 6.72484 74.216 7.52664V10.5787C74.216 11.3805 74.3448 12.053 74.628 12.5703C74.8855 13.0876 75.2718 13.5014 75.7611 13.7342C76.2504 13.9928 76.8169 14.1222 77.4865 14.1222H77.538ZM86.7313 13.9928V4.83671L86.2677 5.27641H89.6927V4.16422H82.5853V5.27641H86.0102L85.5467 4.83671V13.9928H86.7313ZM98.0105 13.9928V12.8806H92.7056L93.1692 13.3203V9.23372L92.7056 9.62169H96.9031V8.5095H92.7056L93.1692 8.89747V4.83671L92.7056 5.27641H98.0105V4.16422H91.9846V14.0187H98.0105V13.9928ZM103.393 13.9928C104.062 13.9928 104.629 13.8635 105.118 13.6049C105.607 13.3462 105.968 12.9582 106.251 12.4409C106.508 11.9236 106.663 11.2512 106.663 10.4494V7.68183C106.663 6.88002 106.534 6.2334 106.251 5.71611C105.993 5.19881 105.607 4.78498 105.118 4.5522C104.629 4.29355 104.062 4.16422 103.393 4.16422H100.225V14.0187H103.393V13.9928ZM101.435 4.75911L100.946 5.25054H103.393C103.856 5.25054 104.242 5.32814 104.551 5.48333C104.86 5.63852 105.066 5.89716 105.221 6.25927C105.375 6.62138 105.453 7.08694 105.453 7.68183V10.4494C105.453 11.0442 105.375 11.5357 105.221 11.8978C105.066 12.2599 104.835 12.4927 104.551 12.6737C104.242 12.8289 103.856 12.9065 103.393 12.9065H100.946L101.435 13.3979V4.75911ZM121.213 13.9928C122.14 13.9928 122.835 13.76 123.324 13.2945C123.814 12.8289 124.045 12.1823 124.045 11.3288V11.2253C124.045 10.6045 123.891 10.1131 123.608 9.75101C123.324 9.3889 122.886 9.13026 122.32 8.97507V8.81988C123.221 8.5095 123.659 7.83702 123.659 6.80243V6.64724C123.659 5.84543 123.427 5.25054 122.938 4.81084C122.449 4.37114 121.753 4.13836 120.826 4.13836H117.685V13.9928H121.213ZM118.895 4.83671L118.431 5.25054H120.852C121.444 5.25054 121.882 5.37987 122.14 5.61265C122.397 5.84543 122.526 6.2334 122.526 6.7507V6.85416C122.526 7.39732 122.397 7.81115 122.14 8.04394C121.882 8.30258 121.444 8.43191 120.852 8.43191H118.431L118.895 8.84574V4.83671ZM118.895 9.13026L118.431 9.54409H121.213C121.805 9.54409 122.217 9.67342 122.474 9.93206C122.732 10.1907 122.861 10.5787 122.861 11.1477V11.2253C122.861 11.7943 122.732 12.234 122.474 12.4927C122.217 12.7513 121.779 12.8806 121.187 12.8806H118.406L118.869 13.2686V9.13026H118.895ZM129.839 13.9928V9.93206L133.007 4.16422H131.719L129.299 8.69056H129.144L126.749 4.16422H125.462L128.629 9.93206V14.0187H129.814L129.839 13.9928ZM144.544 13.9928V9.31131L144.054 9.67342H148.921L148.432 9.31131V13.9928H149.617V4.13836H148.432V8.89747L148.921 8.53537H144.054L144.544 8.89747V4.13836H143.359V13.9928H144.544ZM155.694 13.9928V9.93206L158.861 4.16422H157.574L155.153 8.69056H154.999L152.604 4.16422H151.316L154.484 9.93206V14.0187H155.668L155.694 13.9928ZM161.977 13.9928V9.72515L161.514 10.1648H164.269C165.248 10.1648 165.969 9.93206 166.484 9.44063C166.999 8.9492 167.256 8.19913 167.256 7.1904V7.13867C167.256 6.12995 166.999 5.37987 166.484 4.88844C165.969 4.39701 165.222 4.16422 164.269 4.16422H160.793V14.0187H161.977V13.9928ZM161.977 4.81084L161.514 5.25054H164.269C164.707 5.25054 165.068 5.30227 165.325 5.4316C165.583 5.53506 165.789 5.74197 165.917 6.00062C166.046 6.25927 166.098 6.64724 166.098 7.13867V7.1904C166.098 7.68183 166.046 8.04394 165.917 8.32845C165.789 8.5871 165.608 8.79402 165.351 8.89747C165.093 9.00093 164.733 9.07853 164.295 9.07853H161.54L162.003 9.51823V4.86257L161.977 4.81084ZM175.6 13.9928V12.8806H170.295L170.759 13.3203V9.23372L170.295 9.62169H174.493V8.5095H170.295L170.759 8.89747V4.83671L170.295 5.27641H175.6V4.16422H169.574V14.0187H175.6V13.9928ZM179.282 13.9928V9.72515L178.793 10.1648H181.343C182.321 10.1648 183.042 9.93206 183.557 9.44063C184.072 8.9492 184.33 8.19913 184.33 7.1904V7.13867C184.33 6.12995 184.072 5.37987 183.557 4.88844C183.042 4.39701 182.295 4.16422 181.343 4.16422H178.072V14.0187H179.257L179.282 13.9928ZM179.282 4.81084L178.793 5.25054H181.343C181.78 5.25054 182.141 5.30227 182.424 5.4316C182.682 5.53506 182.888 5.74197 182.991 6.00062C183.119 6.25927 183.171 6.64724 183.171 7.13867V7.1904C183.171 7.68183 183.119 8.04394 182.991 8.32845C182.862 8.5871 182.682 8.79402 182.424 8.89747C182.167 9.00093 181.806 9.07853 181.343 9.07853H178.793L179.282 9.51823V4.81084ZM184.252 13.9928L181.935 10.2166V9.3889L180.158 9.4665L182.913 13.967H184.252V13.9928ZM192.853 13.9928V12.8806H187.703L188.167 13.3203V4.16422H186.982V14.0187H192.828L192.853 13.9928ZM194.527 13.9928H195.815L198.184 5.27641H198.313L200.682 13.9928H201.944L199.188 4.13836H197.36L194.553 13.9928H194.527ZM200.321 10.9925L200.038 9.88033H196.407L196.124 10.9925H200.321ZM204.931 13.9928V5.92303H205.06L208.124 13.9928H209.875V4.13836H208.716V12.2082H208.588L205.523 4.13836H203.772V13.9928H204.931ZM218.708 13.9928V12.8806H213.403L213.867 13.3203V9.23372L213.403 9.62169H217.601V8.5095H213.403L213.867 8.89747V4.83671L213.403 5.27641H218.708V4.16422H212.682V14.0187H218.708V13.9928Z\"\n        fill=\"url(#paint0_linear_279_1224)\"\n      />\n      <defs>\n        <linearGradient\n          id=\"paint0_linear_279_1224\"\n          x1=\"0\"\n          y1=\"8.988\"\n          x2=\"218.708\"\n          y2=\"8.988\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#4C52FF\" />\n          <stop offset=\"0.3\" stopColor=\"#9A0DFF\" />\n          <stop offset=\"0.6\" stopColor=\"#9A0DFF\" />\n          <stop offset=\"1\" stopColor=\"#FF4FE9\" />\n        </linearGradient>\n      </defs>\n    </svg>\n  );\n}\n\nexport const HyperlaneGradientLogo = memo(_HyperlaneGradientLogo);\n"
  },
  {
    "path": "src/components/icons/HyperlaneTransparentLogo.tsx",
    "content": "import { memo } from 'react';\n\nfunction _HyperlaneTransparentLogo() {\n  return (\n    <svg\n      width=\"146\"\n      height=\"127\"\n      viewBox=\"0 0 146 127\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <path\n        d=\"M111.997 0.5C118.021 0.500205 123.353 4.29477 125.572 10.0957L125.784 10.3896L125.822 10.4424L125.846 10.5029L144.975 60.709L144.978 60.7158C145.675 62.6255 145.675 64.7173 144.978 66.627L144.975 66.6338L125.846 116.837L125.847 116.838C123.643 122.677 118.291 126.5 112.243 126.5H66.999V119.931H76.6943C84.8493 119.931 92.1424 114.758 95.1924 106.801L110.287 66.9561H78.5166L59.5107 116.754L59.5098 116.753C57.3054 122.591 51.9546 126.414 45.9072 126.414H0.582031V119.846H10.1953C18.35 119.846 25.6424 114.673 28.6924 106.716L44.124 66.1133C44.7404 64.4248 44.7404 62.5752 44.124 60.8867V60.8857L28.6924 20.2832L28.6914 20.2803C25.7242 12.3299 18.3532 7.15444 10.1953 7.1543H0.5V0.585938H45.7432C51.7903 0.585938 57.1411 4.40815 59.3457 10.2461H59.3467L78.3525 60.0439H110.122L94.9453 20.1982V20.1973C91.895 12.2411 84.6027 7.06939 76.4482 7.06934H66.7529V0.5H111.997Z\"\n        stroke=\"white\"\n      />\n    </svg>\n  );\n}\n\nexport const HyperlaneTransparentLogo = memo(_HyperlaneTransparentLogo);\n"
  },
  {
    "path": "src/components/icons/QuestionMarkIcon.tsx",
    "content": "import { DefaultIconProps } from '@hyperlane-xyz/widgets';\nimport { memo } from 'react';\n\nimport { Color } from '../../styles/Color';\n\nfunction _QuestionMarkIcon({ color, width = 20, height = 20, ...rest }: DefaultIconProps) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 90 90\"\n      width={width}\n      height={height}\n      fill=\"none\"\n      {...rest}\n    >\n      <path\n        d=\"M45 0C20.147 0 0 20.147 0 45c0 24.853 20.147 45 45 45s45-20.147 45-45C90 20.147 69.853 0 45 0zM49.083 68.404C47.884 69.469 46.389 70 44.597 70c-1.792 0-3.288-.531-4.486-1.596-1.199-1.063-1.798-2.424-1.798-4.082 0-1.657.599-3.018 1.798-4.082 1.198-1.064 2.693-1.596 4.486-1.596 1.792 0 3.287.532 4.486 1.596 1.199 1.064 1.799 2.425 1.799 4.082 0 1.658-.6 3.019-1.799 4.082zM59.718 38.381c-.739 1.524-1.928 3.081-3.562 4.671l-3.864 3.595c-1.099 1.053-1.861 2.133-2.285 3.242-.425 1.109-.66 2.515-.706 4.217h-9.61c0-3.271.369-5.852 1.109-7.746.739-1.893 1.937-3.533 3.595-4.923 1.658-1.388 2.918-2.66 3.781-3.813.862-1.154 1.293-2.425 1.293-3.814 0-3.382-1.456-5.074-4.368-5.074-1.344 0-2.431.493-3.259 1.479-.829.986-1.266 2.318-1.311 3.999H29.174c.044-4.48 1.456-7.969 4.234-10.467C36.185 21.249 40.083 20 45.101 20c4.996 0 8.865 1.154 11.609 3.461 2.745 2.308 4.116 5.59 4.116 9.846 0 1.859-.369 3.551-1.108 5.074z\"\n        fill={color || Color.primary[500]}\n      />\n    </svg>\n  );\n}\n\nexport const QuestionMarkIcon = memo(_QuestionMarkIcon);\n"
  },
  {
    "path": "src/components/icons/StakeIcon.tsx",
    "content": "import { DefaultIconProps } from '@hyperlane-xyz/widgets';\nimport { memo } from 'react';\n\nfunction _StakeIcon({ ...props }: DefaultIconProps) {\n  return (\n    <svg viewBox=\"0 0 20 20\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <circle cx=\"10\" cy=\"10\" r=\"10\" fill=\"#9A0DFF\" />\n      <path\n        d=\"M6.91687 9.90641L4.87399 4.56765C4.80806 4.39535 4.93528 4.21045 5.11977 4.21045H7.65818C7.7658 4.21045 7.86258 4.27598 7.90253 4.37591L9.65836 8.76722H12.5701L10.8771 4.57209C10.8073 4.39913 10.9346 4.21045 11.1211 4.21045H13.7572C13.8633 4.21045 13.959 4.27415 14 4.37201L16.3163 9.90641L14.0157 15.6245C13.9756 15.7241 13.879 15.7894 13.7716 15.7894H11.2942C11.1116 15.7894 10.9845 15.6079 11.0469 15.4363L12.5701 11.2496H9.65836L7.90265 15.6243C7.86262 15.724 7.76593 15.7894 7.65843 15.7894H5.11544C4.93214 15.7894 4.80499 15.6067 4.86867 15.4348L6.91687 9.90641Z\"\n        fill=\"#EBD3FF\"\n      />\n      <path d=\"M0 10H20\" stroke=\"#EBD3FF\" strokeWidth=\"1.25\" />\n    </svg>\n  );\n}\n\nexport const StakeIcon = memo(_StakeIcon);\n"
  },
  {
    "path": "src/components/icons/SwapIcon.tsx",
    "content": "import { DefaultIconProps } from '@hyperlane-xyz/widgets';\nimport { memo } from 'react';\n\nimport { Color } from '../../styles/Color';\n\nfunction _SwapIcon({ color, ...props }: DefaultIconProps) {\n  return (\n    <svg viewBox=\"0 0 15 21\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <path\n        d=\"M9.40694 4.5704L14.0221 9.09747V6.86643L7 0L0 6.86643V9.09747L4.61514 4.5704C5.32177 3.87726 5.58675 3.18412 5.58675 2.20939V1.99278H6.09464V19.0072H5.58675V18.7906C5.58675 17.7942 5.32177 17.1227 4.61514 16.4296L0 11.9025V14.1336L7.02208 21L14.0221 14.1336V11.9025L9.40694 16.4296C8.70032 17.1227 8.43533 17.8159 8.43533 18.7906V19.0072H7.92744V1.99278H8.43533V2.20939C8.43533 3.20578 8.70032 3.87726 9.40694 4.5704Z\"\n        fill={color || Color.gray[400]}\n      />\n    </svg>\n  );\n}\n\nexport const SwapIcon = memo(_SwapIcon);\n"
  },
  {
    "path": "src/components/icons/TokenIcon.tsx",
    "content": "import { IToken } from '@hyperlane-xyz/sdk';\nimport { isHttpsUrl, isRelativeUrl } from '@hyperlane-xyz/utils';\nimport { Circle } from '@hyperlane-xyz/widgets';\nimport type { SyntheticEvent } from 'react';\nimport { useState } from 'react';\n\nimport { links } from '../../consts/links';\nimport {\n  markDarkLogoMissing,\n  processDarkLogoImage,\n  toOriginalVariantSrc,\n} from '../../utils/imageBrightness';\n\ninterface Props {\n  token?: IToken | null;\n  size?: number;\n}\n\nexport function TokenIcon({ token, size = 32 }: Props) {\n  const title = token?.symbol || '';\n  const character = title ? title.charAt(0).toUpperCase() : '';\n  const fontSize = Math.floor(size / 2);\n\n  const [fallbackToText, setFallbackToText] = useState(false);\n  const imageSrc = getImageSrc(token);\n  const bgColorSeed =\n    token && (!imageSrc || fallbackToText)\n      ? (Buffer.from(token.addressOrDenom).at(0) || 0) % 5\n      : undefined;\n\n  function handleImageLoad(event: SyntheticEvent<HTMLImageElement>) {\n    processDarkLogoImage(event.currentTarget);\n  }\n\n  function handleImageError(event: SyntheticEvent<HTMLImageElement>) {\n    const img = event.currentTarget;\n    const original = img.dataset.logoOriginalSrc;\n    const attemptedDark = img.dataset.logoDarkSrc;\n    const current = img.getAttribute('src') || img.src;\n    const originalLoadFailed =\n      !!original && current === original && img.complete && img.naturalWidth === 0;\n    const darkFallbackAlreadyHandled =\n      img.dataset.logoDarkFailed === 'true' &&\n      !!original &&\n      !!attemptedDark &&\n      current === original;\n    if (darkFallbackAlreadyHandled && !originalLoadFailed) return;\n\n    const isDarkFallbackError = !!original && !!attemptedDark && current === attemptedDark;\n    const fallbackSrc = toOriginalVariantSrc(current);\n    const isDarkVariantSrc = fallbackSrc !== null;\n\n    // Dark-variant misses should fall back to original logo, not text.\n    if (isDarkFallbackError || isDarkVariantSrc) {\n      markDarkLogoMissing(current);\n      const nextSrc = fallbackSrc || original;\n      if (nextSrc) {\n        img.src = nextSrc;\n        return;\n      }\n    }\n    setFallbackToText(true);\n  }\n\n  return (\n    <span className=\"inline-flex\">\n      <Circle size={size} bgColorSeed={bgColorSeed} title={title}>\n        {imageSrc && !fallbackToText ? (\n          <img\n            src={imageSrc}\n            className=\"h-full w-full p-0.5\"\n            onLoad={handleImageLoad}\n            onError={handleImageError}\n            loading=\"lazy\"\n          />\n        ) : (\n          <div className={`text-[${fontSize}px]`}>{character}</div>\n        )}\n      </Circle>\n    </span>\n  );\n}\n\nfunction getImageSrc(token?: IToken | null) {\n  if (!token?.logoURI) return null;\n  // If it's a valid, direct URL, return it\n  if (isHttpsUrl(token.logoURI)) return token.logoURI;\n  // Otherwise assume it's a relative URL to the registry base\n  if (isRelativeUrl(token.logoURI)) return `${links.imgPath}${token.logoURI}`;\n  return null;\n}\n"
  },
  {
    "path": "src/components/icons/WebSimpleIcon.tsx",
    "content": "import { DefaultIconProps } from '@hyperlane-xyz/widgets';\nimport { memo } from 'react';\n\nimport { Color } from '../../styles/Color';\n\nfunction _WebSimpleIcon({ color, ...props }: DefaultIconProps) {\n  return (\n    <svg viewBox=\"0 0 20 20\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <path\n        d=\"M0.75 9.75H5.75M0.75 9.75C0.75 14.7206 4.77944 18.75 9.75 18.75M0.75 9.75C0.75 4.77944 4.77944 0.75 9.75 0.75M5.75 9.75H13.75M5.75 9.75C5.75 14.7206 7.54086 18.75 9.75 18.75M5.75 9.75C5.75 4.77944 7.54086 0.75 9.75 0.75M13.75 9.75H18.75M13.75 9.75C13.75 4.77944 11.9591 0.75 9.75 0.75M13.75 9.75C13.75 14.7206 11.9591 18.75 9.75 18.75M18.75 9.75C18.75 4.77944 14.7206 0.75 9.75 0.75M18.75 9.75C18.75 14.7206 14.7206 18.75 9.75 18.75\"\n        stroke={color || Color.primary[500]}\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nexport const WebSimpleIcon = memo(_WebSimpleIcon);\n"
  },
  {
    "path": "src/components/icons/XIcon.tsx",
    "content": "import { DefaultIconProps } from '@hyperlane-xyz/widgets';\nimport { memo } from 'react';\n\nimport { Color } from '../../styles/Color';\n\nfunction _XIcon({ color, ...props }: DefaultIconProps) {\n  return (\n    <svg viewBox=\"0 0 19 17\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" {...props}>\n      <path\n        d=\"M14.4386 0H17.2498L11.1081 7.01958L18.3333 16.5716H12.676L8.24503 10.7784L3.17496 16.5716H0.362027L6.9312 9.06341L0 0H5.80092L9.80616 5.29528L14.4386 0ZM13.4519 14.889H15.0097L4.9545 1.59428H3.28288L13.4519 14.889Z\"\n        fill={color || Color.primary[500]}\n      />\n    </svg>\n  );\n}\n\nexport const XIcon = memo(_XIcon);\n"
  },
  {
    "path": "src/components/input/SearchInput.tsx",
    "content": "import { SearchIcon, XIcon } from '@hyperlane-xyz/widgets';\nimport { Ref } from 'react';\n\nimport { TextInput } from './TextField';\n\nexport function SearchInput({\n  inputRef,\n  value,\n  onChange,\n  placeholder,\n  'aria-label': ariaLabel,\n}: {\n  inputRef?: Ref<HTMLInputElement>;\n  value: string;\n  onChange: (s: string) => void;\n  placeholder: string;\n  'aria-label'?: string;\n}) {\n  return (\n    <div className=\"relative w-full\">\n      <SearchIcon\n        width={16}\n        height={16}\n        className=\"token-picker-search-icon absolute left-3 top-1/2 -translate-y-1/2 opacity-50\"\n      />\n      <TextInput\n        ref={inputRef}\n        value={value}\n        onChange={onChange}\n        placeholder={placeholder}\n        aria-label={ariaLabel}\n        name=\"search\"\n        className=\"token-picker-search-input !mt-0 w-full pl-9 pr-8 all:border-gray-300 all:py-2 all:text-sm all:focus:border-blue-400\"\n        autoComplete=\"off\"\n      />\n      {value && (\n        <button\n          type=\"button\"\n          aria-label=\"Clear search\"\n          onClick={() => onChange('')}\n          className=\"absolute right-2.5 top-1/2 -translate-y-1/2 rounded-full p-0.5 text-gray-400 hover:text-gray-600\"\n        >\n          <XIcon width={12} height={12} />\n        </button>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/input/TextField.tsx",
    "content": "import clsx from 'clsx';\nimport { Field, FieldAttributes } from 'formik';\nimport { ChangeEvent, InputHTMLAttributes, Ref, forwardRef } from 'react';\n\nexport function TextField({ className, ...props }: FieldAttributes<unknown>) {\n  return <Field className={clsx(defaultClassName, className)} {...props} />;\n}\n\ntype InputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {\n  onChange: (v: string) => void;\n};\n\nexport const TextInput = forwardRef(function _TextInput(\n  { onChange, className, ...props }: InputProps,\n  ref: Ref<HTMLInputElement>,\n) {\n  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {\n    onChange(e?.target?.value || '');\n  };\n  return (\n    <input\n      ref={ref}\n      type=\"text\"\n      autoComplete=\"off\"\n      onChange={handleChange}\n      className={clsx(defaultClassName, className)}\n      {...props}\n    />\n  );\n});\n\nconst defaultClassName =\n  'rounded-lg border border-primary-300 focus:border-primary-500 disabled:bg-gray-150 outline-none transition-all duration-300';\n"
  },
  {
    "path": "src/components/layout/AppLayout.tsx",
    "content": "import Head from 'next/head';\nimport { PropsWithChildren, useEffect } from 'react';\n\nimport { APP_NAME } from '../../consts/app';\nimport { config } from '../../consts/config';\nimport { initIntercom } from '../../features/analytics/intercom';\nimport { initRefiner } from '../../features/analytics/refiner';\nimport { EVENT_NAME } from '../../features/analytics/types';\nimport { useWalletConnectionTracking } from '../../features/analytics/useWalletConnectionTracking';\nimport { trackEvent } from '../../features/analytics/utils';\nimport { useStore } from '../../features/store';\nimport { SideBarMenu } from '../../features/wallet/SideBarMenu';\nimport { WalletProtocolModal } from '../../features/wallet/WalletProtocolModal';\nimport { Footer } from '../nav/Footer';\nimport { Header } from '../nav/Header';\n\nexport function AppLayout({ children }: PropsWithChildren) {\n  const { showEnvSelectModal, setShowEnvSelectModal, isSideBarOpen, setIsSideBarOpen } = useStore(\n    (s) => ({\n      showEnvSelectModal: s.showEnvSelectModal,\n      setShowEnvSelectModal: s.setShowEnvSelectModal,\n      isSideBarOpen: s.isSideBarOpen,\n      setIsSideBarOpen: s.setIsSideBarOpen,\n    }),\n  );\n\n  useWalletConnectionTracking();\n\n  useEffect(() => {\n    initIntercom();\n    initRefiner();\n    trackEvent(EVENT_NAME.PAGE_VIEWED, {});\n  }, []);\n\n  return (\n    <>\n      <Head>\n        {/* https://nextjs.org/docs/messages/no-document-viewport-meta */}\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n        <title>{APP_NAME}</title>\n      </Head>\n      <div\n        id=\"app-content\"\n        className=\"min-w-screen relative flex h-full min-h-screen w-full flex-col justify-between\"\n      >\n        <Header />\n        <div className=\"mx-auto flex max-w-screen-xl grow items-center sm:px-4\">\n          <main className=\"my-4 flex w-full flex-1 items-center justify-center\">{children}</main>\n        </div>\n        <Footer />\n      </div>\n\n      <WalletProtocolModal\n        isOpen={showEnvSelectModal}\n        close={() => setShowEnvSelectModal(false)}\n        protocols={config.walletProtocols}\n        onProtocolSelected={(protocol) =>\n          trackEvent(EVENT_NAME.WALLET_CONNECTION_INITIATED, { protocol })\n        }\n      />\n      <SideBarMenu\n        onClose={() => setIsSideBarOpen(false)}\n        isOpen={isSideBarOpen}\n        onClickConnectWallet={() => setShowEnvSelectModal(true)}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/layout/Card.tsx",
    "content": "import { PropsWithChildren } from 'react';\n\ninterface Props {\n  className?: string;\n}\n\nexport function Card({ className = '', children }: PropsWithChildren<Props>) {\n  return (\n    <div\n      className={`relative overflow-auto rounded-2xl bg-white p-1.5 xs:p-2 sm:p-3 md:p-4 ${className}`}\n    >\n      {children}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/layout/ModalHeader.tsx",
    "content": "import clsx from 'clsx';\nimport { ReactNode } from 'react';\n\nexport function ModalHeader({ children, className }: { children?: ReactNode; className?: string }) {\n  return (\n    <div className={clsx('flex items-center gap-2 bg-accent-gradient px-4 py-1', className)}>\n      {children && (\n        <>\n          <div className=\"h-2 w-2 rounded-full bg-white\" />\n          <span className=\"font-secondary text-xs text-white\">{children}</span>\n        </>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/nav/Footer.tsx",
    "content": "import { HyperlaneGradientLogo } from '../icons/HyperlaneGradientLogo';\nimport { NavItem, navLinks } from './Nav';\n\nexport function Footer() {\n  return (\n    <footer className=\"footer-root relative text-white\">\n      <div className=\"footer-inner relative px-8 pb-5 pt-2 sm:pt-0\">\n        <div className=\"flex flex-col items-center justify-between gap-4\">\n          <FooterLogo />\n          <FooterNav />\n        </div>\n      </div>\n    </footer>\n  );\n}\n\nfunction FooterLogo() {\n  return (\n    <div className=\"flex items-center justify-center rounded-full border border-transparent bg-transparent px-[0.8rem] py-[0.35rem] dark:border-primary-300/40 dark:bg-white/[0.08] dark:shadow-[0_0_22px_rgba(154,13,255,0.24)]\">\n      <HyperlaneGradientLogo\n        className=\"dark:[filter:saturate(1.2)_brightness(1.2)_drop-shadow(0_0_10px_rgba(185,89,255,0.45))]\"\n        width={219}\n        height={18}\n      />\n    </div>\n  );\n}\n\nfunction FooterNav() {\n  return (\n    <nav className=\"hidden text-md font-medium lg:block\">\n      <ul className=\"flex gap-9\">\n        {navLinks.map((item) => (\n          <li key={item.title}>\n            <NavItem item={item} className=\"dark:text-primary-50 dark:hover:text-white\" />\n          </li>\n        ))}\n      </ul>\n    </nav>\n  );\n}\n"
  },
  {
    "path": "src/components/nav/Header.tsx",
    "content": "import { DropdownMenu } from '@hyperlane-xyz/widgets';\nimport Image from 'next/image';\nimport Link from 'next/link';\n\nimport { useTheme } from '../../features/theme/ThemeContext';\nimport { ConnectWalletButton } from '../../features/wallet/ConnectWalletButton';\nimport Logo from '../../images/logos/app-logo.svg';\nimport Name from '../../images/logos/app-name.svg';\nimport Title from '../../images/logos/app-title.svg';\nimport { HamburgerIcon } from '../icons/HamburgerIcon';\nimport { NavItem, navLinks } from './Nav';\n\nexport function Header() {\n  const { themeMode, toggleThemeMode } = useTheme();\n  const nextThemeMode = themeMode === 'dark' ? 'light' : 'dark';\n  const nextThemeLabel = nextThemeMode === 'dark' ? 'Lights out' : 'Lights on';\n\n  return (\n    <header className=\"relative flex w-full items-center justify-between bg-primary-25 px-4 py-3 shadow-app-header lg:justify-center lg:bg-transparent lg:px-6 lg:pb-2 lg:pt-3 lg:shadow-none dark:border-b dark:border-primary-300/[0.24] dark:bg-background/[0.88] dark:shadow-app-header-dark lg:dark:border-b-0 lg:dark:bg-transparent lg:dark:shadow-none\">\n      {/* Mobile/Tablet: Logo + Hamburger Menu */}\n      <div className=\"flex items-center gap-3 lg:hidden\">\n        <Link href=\"/\" aria-label=\"Homepage\">\n          <Image src={Logo} width={36} alt=\"\" className=\"h-auto\" />\n        </Link>\n        <DropdownMenu\n          button={<HamburgerIcon width={20} height={19} />}\n          buttonClassname=\"rounded p-2 text-primary-500 data-[open]:bg-primary-25 data-[open]:shadow-[inset_4px_4px_4px_rgba(154,13,255,0.1)] data-[open]:text-white dark:bg-white/[0.08] dark:text-foreground-primary dark:data-[open]:bg-primary-300/25 dark:data-[open]:ring-1 dark:data-[open]:ring-primary-300/45 dark:data-[open]:text-white\"\n          menuClassname=\"py-4 dark:border dark:border-primary-300/35 dark:bg-surface dark:shadow-menu-dark\"\n          menuItems={navLinks.map((item) => (\n            <NavItem\n              key={item.title}\n              item={item}\n              className=\"w-full gap-3 px-6 py-2 hover:bg-primary-50/30 dark:text-foreground-primary dark:hover:bg-primary-300/[0.16] dark:[&_path]:fill-current dark:[&_path]:stroke-current\"\n            />\n          ))}\n        />\n      </div>\n\n      {/* Desktop: Centered Logo */}\n      <Link href=\"/\" aria-label=\"Homepage\" className=\"hidden flex-col py-2 lg:flex\">\n        <div className=\"flex items-end\">\n          <Image src={Logo} width={46} alt=\"\" className=\"h-auto\" />\n          <Image src={Name} width={150} alt=\"\" className=\"ml-1.5\" />\n        </div>\n        <Image src={Title} width={43} alt=\"\" className=\"self-end\" />\n      </Link>\n\n      <div className=\"flex items-center gap-2 lg:absolute lg:right-12\">\n        <button\n          type=\"button\"\n          className={`${styles.themeToggle} theme-toggle`}\n          onClick={toggleThemeMode}\n          aria-label={`Switch to ${nextThemeMode} mode`}\n          title={`Switch to ${nextThemeMode} mode`}\n        >\n          {nextThemeLabel}\n        </button>\n        <ConnectWalletButton />\n      </div>\n    </header>\n  );\n}\n\nconst styles = {\n  themeToggle:\n    'rounded-md border border-primary-500/35 bg-white/85 px-2.5 py-1 text-xs font-medium capitalize text-primary-900 transition-[background-color,border-color,color,box-shadow] duration-200 hover:border-primary-300/70 hover:bg-gray-950/90 hover:text-primary-25 focus-visible:outline-none focus-visible:shadow-[0_0_0_2px_rgba(154,13,255,0.25)] dark:border-primary-300/45 dark:bg-black/75 dark:text-primary-50 dark:hover:border-primary-500/55 dark:hover:bg-white/95 dark:hover:text-primary-900 dark:focus-visible:shadow-[0_0_0_2px_rgba(185,89,255,0.35)]',\n};\n"
  },
  {
    "path": "src/components/nav/Nav.tsx",
    "content": "import { GithubIcon } from '@hyperlane-xyz/widgets';\nimport clsx from 'clsx';\nimport Link from 'next/link';\nimport { forwardRef, ReactNode } from 'react';\n\nimport { links } from '../../consts/links';\nimport { Color } from '../../styles/Color';\nimport { BookIcon } from '../icons/BookIcon';\nimport { QuestionMarkIcon } from '../icons/QuestionMarkIcon';\nimport { StakeIcon } from '../icons/StakeIcon';\nimport { WebSimpleIcon } from '../icons/WebSimpleIcon';\nimport { XIcon } from '../icons/XIcon';\n\ninterface NavLinkItem {\n  title: string;\n  url: string;\n  icon: ReactNode;\n}\n\nexport const navLinks: NavLinkItem[] = [\n  { title: 'Stake', url: links.stake, icon: <StakeIcon width={20} height={20} /> },\n  { title: 'X.com', url: links.twitter, icon: <XIcon width={19} height={17} /> },\n  { title: 'Hyperlane', url: links.home, icon: <WebSimpleIcon width={20} height={20} /> },\n  {\n    title: 'Support',\n    url: links.support,\n    icon: <QuestionMarkIcon width={20} height={20} color={Color.primary[500]} />,\n  },\n  {\n    title: 'Docs',\n    url: links.docs,\n    icon: <BookIcon color={Color.primary[500]} width={23} height={16} />,\n  },\n  {\n    title: 'Github',\n    url: links.github,\n    icon: <GithubIcon width={20} height={20} color={Color.primary[500]} />,\n  },\n];\n\ninterface NavItemProps {\n  item: NavLinkItem;\n  className?: string;\n}\n\nexport const NavItem = forwardRef<HTMLAnchorElement, NavItemProps>(function NavItem(\n  { item, className },\n  ref,\n) {\n  return (\n    <Link\n      ref={ref}\n      className={clsx(\n        'flex items-center gap-2 text-primary-500 decoration-primary-500 underline-offset-2 hover:underline',\n        className,\n      )}\n      target=\"_blank\"\n      rel=\"noopener noreferrer\"\n      href={item.url}\n    >\n      <div className=\"w-5\">{item.icon}</div>\n      <span>{item.title}</span>\n    </Link>\n  );\n});\n"
  },
  {
    "path": "src/components/tip/TipCard.tsx",
    "content": "import { IconButton, XCircleIcon } from '@hyperlane-xyz/widgets';\nimport Image from 'next/image';\nimport { useState } from 'react';\n\nimport { config } from '../../consts/config';\nimport { links } from '../../consts/links';\nimport InfoCircle from '../../images/icons/info-circle.svg';\nimport { HyperlaneTransparentLogo } from '../icons/HyperlaneTransparentLogo';\n\nexport function TipCard() {\n  const [show, setShow] = useState(config.showTipBox);\n  if (!show) return null;\n  return (\n    <div\n      data-testid=\"tip-card\"\n      className=\"tip-card relative w-full overflow-hidden rounded bg-tip-card-gradient px-4 pb-4 pt-4 shadow-card xl:w-72 xl:pb-24 dark:bg-gradient-to-t dark:from-primary-500/30 dark:to-[#111]/95 dark:shadow-lg dark:ring-1 dark:ring-inset dark:ring-primary-500/50\"\n    >\n      <div className=\"absolute right-2 top-2\">\n        <IconButton\n          onClick={() => setShow(false)}\n          title=\"Hide tip\"\n          className=\"text-gray-400 hover:text-gray-600 dark:text-foreground-secondary dark:hover:text-foreground-primary dark:[&_path]:fill-current\"\n        >\n          <XCircleIcon width={14} height={14} />\n        </IconButton>\n      </div>\n\n      <h2 className=\"pr-6 font-secondary text-lg font-normal text-gray-900 dark:text-white\">\n        Bridge Tokens with Hyperlane Warp Routes!\n      </h2>\n      <p className=\"mt-2 text-sm text-gray-600 dark:text-foreground-muted\">\n        Warp Routes make it easy to permissionlessly take your tokens interchain. Fork this template\n        to get started!\n      </p>\n\n      <a\n        href={links.github}\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n        className=\"mt-3 inline-flex items-center gap-1.5 rounded-md border border-gray-300 bg-white px-3 py-1.5 font-secondary text-sm text-gray-700 transition-colors hover:bg-gray-50 dark:border-primary-500/80 dark:bg-primary-500/20 dark:text-white dark:hover:bg-primary-500/30\"\n      >\n        <Image src={InfoCircle} width={12} alt=\"\" className=\"dark:invert\" />\n        <span>More</span>\n      </a>\n\n      <div className=\"tip-card-logo pointer-events-none absolute bottom-2 left-1/2 -translate-x-1/2\">\n        <HyperlaneTransparentLogo />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/toast/IgpDetailsToast.tsx",
    "content": "import { toast } from 'react-toastify';\n\nimport { links } from '../../consts/links';\n\nexport function toastIgpDetails(igpFee: string, tokenName = 'native token') {\n  toast.error(<IgpDetailsToast tokenName={tokenName} igpFee={igpFee} />, {\n    autoClose: 5000,\n  });\n}\n\nexport function IgpDetailsToast({ tokenName, igpFee }: { tokenName: string; igpFee: string }) {\n  return (\n    <div>\n      Cross-chain transfers require a fee of {igpFee} {tokenName} to fund delivery transaction\n      costs. Your {tokenName} balance is insufficient.{' '}\n      <a className=\"underline\" href={links.gasDocs} target=\"_blank\" rel=\"noopener noreferrer\">\n        Learn More\n      </a>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/toast/TxSuccessToast.tsx",
    "content": "import { toast } from 'react-toastify';\n\nimport { useMultiProvider } from '../../features/chains/hooks';\n\nexport function toastTxSuccess(msg: string, txHash: string, chain: ChainName) {\n  toast.success(<TxSuccessToast msg={msg} txHash={txHash} chain={chain} />, {\n    autoClose: 12000,\n  });\n}\n\nexport function TxSuccessToast({\n  msg,\n  txHash,\n  chain,\n}: {\n  msg: string;\n  txHash: string;\n  chain: ChainName;\n}) {\n  const multiProvider = useMultiProvider();\n  const url = multiProvider.tryGetExplorerTxUrl(chain, { hash: txHash });\n\n  return (\n    <div>\n      {msg + ' '}\n      {url && (\n        <a className=\"underline\" href={url} target=\"_blank\" rel=\"noopener noreferrer\">\n          Open in Explorer\n        </a>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/toast/useToastError.tsx",
    "content": "import { errorToString } from '@hyperlane-xyz/utils';\nimport { useEffect } from 'react';\nimport { toast } from 'react-toastify';\n\nimport { logger } from '../../utils/logger';\n\nexport function useToastError(error: any, errorMsg?: string) {\n  useEffect(() => {\n    if (!error) return;\n    const message = errorMsg || errorToString(error, 500);\n    logger.error(message, error);\n    toast.error(errorMsg);\n  }, [error, errorMsg]);\n}\n"
  },
  {
    "path": "src/consts/app.ts",
    "content": "import { Color } from '../styles/Color';\n\nexport type UiThemeMode = 'light' | 'dark';\n\nexport const APP_NAME = 'Hyperlane Warp UI Template';\nexport const APP_DESCRIPTION = 'A DApp for Hyperlane Warp Route transfers';\nexport const APP_URL = 'hyperlane-warp-template.vercel.app';\nexport const BRAND_COLOR = Color.primary['500'];\n\nexport const UI_THEME_STORAGE_KEY = 'warp-ui-theme';\nexport const DEFAULT_UI_THEME_MODE: UiThemeMode = 'light';\n"
  },
  {
    "path": "src/consts/args.ts",
    "content": "import { ProtocolType } from '@hyperlane-xyz/utils';\n\nexport enum WARP_QUERY_PARAMS {\n  ORIGIN = 'origin',\n  DESTINATION = 'destination',\n  ORIGIN_TOKEN = 'originToken',\n  DESTINATION_TOKEN = 'destinationToken',\n}\n\nexport const ADD_ASSET_SUPPORTED_PROTOCOLS: ProtocolType[] = [ProtocolType.Ethereum];\n"
  },
  {
    "path": "src/consts/blacklist.ts",
    "content": "// A list of addresses that are cannot be used in the app\n// If a wallet with this address is connected, the app will show an error\nexport const ADDRESS_BLACKLIST: string[] = [];\n"
  },
  {
    "path": "src/consts/chainAddresses.ts",
    "content": "import { ChainAddresses } from '@hyperlane-xyz/registry';\nimport { ChainMap } from '@hyperlane-xyz/sdk';\n\n// Per-chain contract addresses to merge with the configured registry's\n// addresses. Entries here override registry entries per key. Useful when\n// you need TypeScript-side imports (e.g. importing pre-built `*Addresses`\n// constants from `@hyperlane-xyz/registry`).\n//\n// For YAML-friendly definitions, use `chainAddresses.yaml` instead.\n// Schema: any contract addresses you'd find in a registry chain's addresses.yaml\n// (e.g. mailbox, quotedCalls, validatorAnnounce, ...)\nexport const addresses: ChainMap<ChainAddresses> = {\n  // mychain: {\n  //   mailbox: '0x...',\n  //   quotedCalls: '0x...',\n  // },\n};\n"
  },
  {
    "path": "src/consts/chainAddresses.yaml",
    "content": "# A map of chain name -> contract addresses\n# Merges with addresses from the configured registry. Filesystem entries\n# override registry entries per key, mirroring how chains.yaml extends chains.\n# Schema: any contract addresses you'd find in a registry chain's addresses.yaml\n# (e.g. mailbox, quotedCalls, validatorAnnounce, ...)\n# Full set of valid keys: https://github.com/hyperlane-xyz/hyperlane-registry/blob/main/chains/ethereum/addresses.yaml\n# (or the ChainAddresses type from @hyperlane-xyz/registry)\n#\n# Note: schema validation runs at app init — a typo here will throw and\n# block the whole warp context init, not just balance fetching.\n---\n# Example using local anvil chain:\n# anvil1:\n#   mailbox: '0x610178dA211FEF7D417bC0e6FeD39F05609AD788'\n#   quotedCalls: '0x9A9f2CCfdE556A7E9Ff0848998Aa4a0CFD8863AE'\n# anvil2:\n#   mailbox: '0x610178dA211FEF7D417bC0e6FeD39F05609AD788'\n#   quotedCalls: '0x9A9f2CCfdE556A7E9Ff0848998Aa4a0CFD8863AE'\n"
  },
  {
    "path": "src/consts/chains.ts",
    "content": "import {\n  eclipsemainnet,\n  eclipsemainnetAddresses,\n  solanamainnet,\n  solanamainnetAddresses,\n  solaxy,\n  solaxyAddresses,\n  sonicsvm,\n  sonicsvmAddresses,\n  soon,\n  soonAddresses,\n} from '@hyperlane-xyz/registry';\nimport { ChainMap, ChainMetadata } from '@hyperlane-xyz/sdk';\n\n// A map of chain names to ChainMetadata\n// Chains can be defined here, in chains.json, or in chains.yaml\n// Chains already in the SDK need not be included here unless you want to override some fields\n// Schema here: https://github.com/hyperlane-xyz/hyperlane-monorepo/blob/main/typescript/sdk/src/metadata/chainMetadataTypes.ts\nexport const chains: ChainMap<ChainMetadata & { mailbox?: Address }> = {\n  solanamainnet: {\n    ...solanamainnet,\n    // SVM chains require mailbox addresses for the token adapters\n    mailbox: solanamainnetAddresses.mailbox,\n  },\n  eclipsemainnet: {\n    ...eclipsemainnet,\n    mailbox: eclipsemainnetAddresses.mailbox,\n  },\n  soon: {\n    ...soon,\n    mailbox: soonAddresses.mailbox,\n  },\n  sonicsvm: {\n    ...sonicsvm,\n    mailbox: sonicsvmAddresses.mailbox,\n  },\n  solaxy: {\n    ...solaxy,\n    mailbox: solaxyAddresses.mailbox,\n  },\n  // mycustomchain: {\n  //   protocol: ProtocolType.Ethereum,\n  //   chainId: 123123,\n  //   domainId: 123123,\n  //   name: 'mycustomchain',\n  //   displayName: 'My Chain',\n  //   nativeToken: { name: 'Ether', symbol: 'ETH', decimals: 18 },\n  //   rpcUrls: [{ http: 'https://mycustomchain-rpc.com' }],\n  //   blockExplorers: [\n  //     {\n  //       name: 'MyCustomScan',\n  //       url: 'https://mycustomchain-scan.com',\n  //       apiUrl: 'https://api.mycustomchain-scan.com/api',\n  //       family: ExplorerFamily.Etherscan,\n  //     },\n  //   ],\n  //   blocks: {\n  //     confirmations: 1,\n  //     reorgPeriod: 1,\n  //     estimateBlockTime: 10,\n  //   },\n  //   logoURI: '/logo.svg',\n  // },\n};\n\n// rent account payment for (mostly for) SVM chains added on top of IGP,\n// not exact but should be pretty close to actual payment\nexport const chainsRentEstimate: ChainMap<bigint> = {\n  eclipsemainnet: BigInt(Math.round(0.00004019 * 10 ** 9)),\n  solanamainnet: BigInt(Math.round(0.00411336 * 10 ** 9)),\n  sonicsvm: BigInt(Math.round(0.00411336 * 10 ** 9)),\n  soon: BigInt(Math.round(0.00000355 * 10 ** 9)),\n};\n"
  },
  {
    "path": "src/consts/chains.yaml",
    "content": "# A map of chain names to ChainMetadata\n# Chains can be defined here, in chains.json, or in chains.ts\n# Chains already in the SDK need not be included here unless you want to override some fields\n# Schema here: https://github.com/hyperlane-xyz/hyperlane-monorepo/blob/main/typescript/sdk/src/metadata/chainMetadataTypes.ts\n---\n# Example using local anvil chain:\n# anvil1:\n#   chainId: 31337\n#   domainId: 31337\n#   name: anvil1\n#   protocol: ethereum\n#   rpcUrls:\n#     - http: http://127.0.0.1:8545\n# anvil2:\n#   chainId: 31338\n#   domainId: 31338\n#   name: anvil2\n#   protocol: ethereum\n#   rpcUrls:\n#     - http: http://127.0.0.1:8555\n"
  },
  {
    "path": "src/consts/config.ts",
    "content": "import { ChainMap } from '@hyperlane-xyz/sdk';\nimport { ProtocolType } from '@hyperlane-xyz/utils';\n\nimport { ADDRESS_BLACKLIST } from './blacklist';\n\nconst isDevMode = process.env.NODE_ENV === 'development';\nconst version = process.env.NEXT_PUBLIC_VERSION || '2.0.0';\nconst registryUrl = process.env.NEXT_PUBLIC_REGISTRY_URL || undefined;\nconst registryBranch = process.env.NEXT_PUBLIC_REGISTRY_BRANCH || undefined;\nconst registryProxyUrl = process.env.NEXT_PUBLIC_GITHUB_PROXY || 'https://proxy.hyperlane.xyz';\nconst walletConnectProjectId = process.env.NEXT_PUBLIC_WALLET_CONNECT_ID || '';\nconst transferBlacklist = process.env.NEXT_PUBLIC_TRANSFER_BLACKLIST || '';\nconst chainWalletWhitelists = JSON.parse(process.env.NEXT_PUBLIC_CHAIN_WALLET_WHITELISTS || '{}');\nconst rpcOverrides = process.env.NEXT_PUBLIC_RPC_OVERRIDES || '';\nconst explorerApiUrl =\n  process.env.NEXT_PUBLIC_EXPLORER_API_URL || 'https://explorer4.hasura.app/v1/graphql';\nconst feeQuotingUrl = process.env.NEXT_PUBLIC_FEE_QUOTING_URL || undefined;\nconst relayApiUrl = process.env.NEXT_PUBLIC_RELAY_API_URL || undefined;\n\ninterface Config {\n  addressBlacklist: string[]; // A list of addresses that are blacklisted and cannot be used in the app\n  chainWalletWhitelists: ChainMap<string[]>; // A map of chain names to a list of wallet names that work for it\n  defaultOriginToken: string | undefined; // The initial origin token to show when app first loads (format: chainName-symbol, e.g. \"ethereum-hyper\")\n  defaultDestinationToken: string | undefined; // The initial destination token to show when app first loads (format: chainName-symbol, e.g. \"bsc-hyper\")\n  enableExplorerLink: boolean; // Include a link to the hyperlane explorer in the transfer modal\n  explorerApiUrl: string; // URL for the Hyperlane Explorer GraphQL API\n  relayApiUrl: string | undefined; // Optional URL for the Hyperlane Relayer API\n  isDevMode: boolean; // Enables some debug features in the app\n  registryUrl: string | undefined; // Optional URL to use a custom registry instead of the published canonical version\n  registryBranch?: string | undefined; // Optional customization of the registry branch instead of main\n  registryProxyUrl?: string; // Optional URL to use a custom proxy for the GithubRegistry\n  showTipBox: boolean; // Show/Hide the blue tip box above the transfer form\n  shouldDisableChains: boolean; // Enable chain disabling for ChainSearchMenu. When true it will deactivate chains that have disabled status\n  transferBlacklist: string; // comma-separated list of routes between which transfers are disabled. Expects Caip2Id-Caip2Id (e.g. ethereum:1-sealevel:1399811149)\n  version: string; // Matches version number in package.json\n  walletConnectProjectId: string; // Project ID provided by walletconnect\n  walletProtocols: ProtocolType[] | undefined; // Wallet Protocols to show in the wallet connect modal. Leave undefined to include all of them\n  rpcOverrides: string; // JSON string containing a map of chain names to an object with an URL for RPC overrides (For an example check the .env.example file)\n  enableTrackingEvents: boolean; // Allow tracking events to happen on some actions;\n  featuredTokens: string[]; // List of featured tokens to prioritize in token picker (format: \"chainName-symbol\")\n  feeQuotingUrl: string | undefined; // Offchain fee quoting service base URL\n}\n\nexport const config: Config = Object.freeze({\n  addressBlacklist: ADDRESS_BLACKLIST.map((address) => address.toLowerCase()),\n  chainWalletWhitelists,\n  enableExplorerLink: false,\n  explorerApiUrl,\n  relayApiUrl,\n  defaultOriginToken: 'ethereum-USDC',\n  defaultDestinationToken: 'base-USDC',\n  isDevMode,\n  registryUrl,\n  registryBranch,\n  registryProxyUrl,\n  showTipBox: true,\n  version,\n  transferBlacklist,\n  walletConnectProjectId,\n  walletProtocols: [\n    ProtocolType.Ethereum,\n    ProtocolType.Sealevel,\n    ProtocolType.Cosmos,\n    ProtocolType.Starknet,\n    ProtocolType.Radix,\n    ProtocolType.Tron,\n    ProtocolType.Aleo,\n  ],\n  shouldDisableChains: false,\n  rpcOverrides,\n  enableTrackingEvents: false,\n  feeQuotingUrl,\n  featuredTokens: [\n    // USDC\n    'arbitrum-USDC',\n    'avalanche-USDC',\n    'base-USDC',\n    'eclipsemainnet-USDC',\n    'ethereum-USDC',\n    'hyperevm-USDC',\n    'ink-USDC',\n    'linea-USDC',\n    'monad-USDC',\n    'optimism-USDC',\n    'polygon-USDC',\n    'solanamainnet-USDC',\n    'unichain-USDC',\n    'worldchain-USDC',\n\n    // ETH\n    'arbitrum-ETH',\n    'base-ETH',\n    'ethereum-ETH',\n    'optimism-ETH',\n    'hyperevm-ETH',\n\n    // USDT\n    'eclipsemainnet-USDT',\n    'ethereum-USDT',\n    'solanamainnet-USDT',\n    'hyperevm-USDT',\n    'aleo-USDT',\n    'bsc-USDT',\n    'matchain-USDT',\n\n    // SOL\n    'eclipsemainnet-SOL',\n    'solanamainnet-SOL',\n    'aleo-SOL',\n    'hyperevm-SOL',\n    'radix-hSOL',\n    'sonicsvm-SOL',\n    'starknet-SOL',\n\n    // WBTC\n    'eclipsemainnet-WBTC',\n    'ethereum-WBTC',\n    'hyperevm-WBTC',\n    'radix-hWBTC',\n    'aleo-WBTC',\n\n    // HYPER\n    'arbitrum-HYPER',\n    'base-HYPER',\n    'bsc-HYPER',\n    'ethereum-HYPER',\n    'optimism-HYPER',\n\n    // stHYPER\n    'bsc-stHYPER',\n    'ethereum-stHYPER',\n  ],\n});\n"
  },
  {
    "path": "src/consts/defaultMultiCollateralRoutes.ts",
    "content": "import { DefaultMultiCollateralRoutes } from '../features/tokens/types';\n\n// Default multi-collateral warp route configuration\n// Maps: chainName -> collateralAddress -> warpRouteAddressOrDenom\n//\n// For ERC20/collateralized tokens:\n//   { \"ethereum\": { \"0xUSDC...\": \"0xWarpRoute...\" } }\n//\n// For native tokens (HypNative), use 'native' as the key:\n//   { \"ethereum\": { \"native\": \"0xWarpRoute...\" } }\nexport const defaultMultiCollateralRoutes: DefaultMultiCollateralRoutes | undefined = undefined;\n"
  },
  {
    "path": "src/consts/links.ts",
    "content": "export const links = {\n  home: 'https://www.hyperlane.xyz',\n  explorer: 'https://explorer.hyperlane.xyz',\n  discord: 'https://discord.gg/VK9ZUy3aTV',\n  github: 'https://github.com/hyperlane-xyz/hyperlane-warp-ui-template',\n  docs: 'https://docs.hyperlane.xyz',\n  warpDocs: 'https://docs.hyperlane.xyz/docs/reference/applications/warp-routes',\n  gasDocs: 'https://docs.hyperlane.xyz/docs/reference/hooks/interchain-gas',\n  chains: 'https://docs.hyperlane.xyz/docs/resources/domains',\n  twitter: 'https://x.com/hyperlane',\n  blog: 'https://medium.com/hyperlane',\n  tos: 'https://hyperlane.xyz/terms-of-service',\n  privacyPolicy: 'https://hyperlane.xyz/privacy-policy',\n  bounty:\n    'https://github.com/search?q=org:hyperlane-xyz+label:bounty+is:open+is:issue&type=issues&s=&o=desc',\n  imgPath: 'https://cdn.jsdelivr.net/gh/hyperlane-xyz/hyperlane-registry@main',\n  transferFees: 'https://docs.hyperlane.xyz/docs/protocol/core/fees#fee-estimation',\n  stake: 'https://app.symbiotic.fi/vault/0xE1F23869776c82f691d9Cb34597Ab1830Fb0De58',\n  support: 'https://help.hyperlane.xyz/',\n};\n"
  },
  {
    "path": "src/consts/warpRouteWhitelist.test.ts",
    "content": "import {\n  GithubRegistry,\n  warpRouteConfigs as publishedWarpRouteConfigs,\n} from '@hyperlane-xyz/registry';\nimport { WarpCoreConfig } from '@hyperlane-xyz/sdk';\nimport { objKeys } from '@hyperlane-xyz/utils';\nimport { assert, test } from 'vitest';\n\nimport { config } from './config';\nimport { warpRouteWhitelist } from './warpRouteWhitelist';\n\ntest('warpRouteWhitelist', async () => {\n  if (!warpRouteWhitelist) return;\n\n  const registry = new GithubRegistry({\n    uri: config.registryUrl,\n    branch: config.registryBranch,\n    proxyUrl: config.registryProxyUrl,\n  });\n  let warpRouteConfigs: Record<string, WarpCoreConfig>;\n\n  try {\n    warpRouteConfigs = await registry.getWarpRoutes();\n  } catch {\n    warpRouteConfigs = publishedWarpRouteConfigs;\n  }\n\n  const uppercaseConfigKeys = new Set(objKeys(warpRouteConfigs).map((key) => key.toUpperCase()));\n  for (const id of warpRouteWhitelist) {\n    assert(uppercaseConfigKeys.has(id.toUpperCase()), `No route with id ${id} found in registry.`);\n  }\n});\n"
  },
  {
    "path": "src/consts/warpRouteWhitelist.ts",
    "content": "// A list of warp route config IDs to be included in the app\n// Warp Route IDs use format `SYMBOL/chainname1-chainname2...` where chains are ordered alphabetically\n// If left null, all warp routes in the configured registry will be included\n// If set to a list (including an empty list), only the specified routes will be included\nexport const warpRouteWhitelist: Array<string> | null = null;\n// Example:\n// [\n//   // 'ETH/ethereum-viction'\n// ];\n\n/**\n * Returns the effective warp route whitelist.\n * On the /embed route, the `routes` URL param can filter routes.\n * If a static whitelist exists, URL routes are intersected with it\n * (only routes in BOTH are shown). If static whitelist is null,\n * URL routes are used as-is.\n */\nexport function getWarpRouteWhitelist(): Array<string> | null {\n  if (typeof window !== 'undefined' && window.location.pathname === '/embed') {\n    const params = new URLSearchParams(window.location.search);\n    const routes = params.get('routes');\n    if (routes) {\n      const urlRoutes = routes\n        .split(',')\n        .map((id) => id.trim())\n        .filter(Boolean);\n      // Intersect with static whitelist if it exists\n      if (warpRouteWhitelist) {\n        return urlRoutes.filter((r) => warpRouteWhitelist.includes(r));\n      }\n      return urlRoutes;\n    }\n  }\n  return warpRouteWhitelist;\n}\n"
  },
  {
    "path": "src/consts/warpRoutes.ts",
    "content": "import { WarpCoreConfig } from '@hyperlane-xyz/sdk';\n\n// A list of Warp Route token configs\n// These configs will be merged with the warp routes in the configured registry\n// The input here is typically the output of the Hyperlane CLI warp deploy command\nexport const warpRouteConfigs: WarpCoreConfig = {\n  tokens: [],\n  options: {},\n};\n"
  },
  {
    "path": "src/consts/warpRoutes.yaml",
    "content": "# A list of Warp Route token configs\n# These configs will be merged with the warp routes in the configured registry\n# The input here is typically the output of the Hyperlane CLI warp deploy command\n---\ntokens: []\noptions: {}\n"
  },
  {
    "path": "src/features/WarpContextInitGate.tsx",
    "content": "import { SpinnerIcon, useTimeout } from '@hyperlane-xyz/widgets';\nimport { PropsWithChildren, useState } from 'react';\n\nimport { Color } from '../styles/Color';\nimport { useReadyMultiProvider } from './chains/hooks';\n\nconst INIT_TIMEOUT = 10_000; // 10 seconds\n\n// A wrapper app to delay rendering children until the warp context is ready\nexport function WarpContextInitGate({ children }: PropsWithChildren<unknown>) {\n  const isWarpContextReady = !!useReadyMultiProvider();\n\n  const [isTimedOut, setIsTimedOut] = useState(false);\n  useTimeout(() => setIsTimedOut(true), INIT_TIMEOUT);\n\n  if (!isWarpContextReady) {\n    if (isTimedOut) {\n      // Fallback to outer error boundary\n      throw new Error(\n        'Failed to initialize warp context. Please check your registry URL and connection status.',\n      );\n    } else {\n      return (\n        <div className=\"warp-init-gate flex h-screen items-center justify-center\">\n          <SpinnerIcon width={80} height={80} color={Color.primary['500']} />\n        </div>\n      );\n    }\n  }\n\n  return <>{children}</>;\n}\n"
  },
  {
    "path": "src/features/analytics/intercom.ts",
    "content": "import Intercom from '@intercom/messenger-js-sdk';\n\nimport { logger } from '../../utils/logger';\n\nexport const INTERCOM_APP_ID = process.env.NEXT_PUBLIC_INTERCOM_APP_ID || '';\n\nlet isInitialized = false;\n\n/**\n * Initialize Intercom messenger widget\n * Should be called once when the app loads\n */\nexport function initIntercom(): void {\n  if (typeof window === 'undefined' || !INTERCOM_APP_ID || isInitialized) return;\n\n  try {\n    Intercom({\n      // eslint-disable-next-line camelcase\n      app_id: INTERCOM_APP_ID,\n      alignment: 'right',\n    });\n    isInitialized = true;\n  } catch (error) {\n    logger.warn('Failed to initialize Intercom:', error);\n  }\n}\n"
  },
  {
    "path": "src/features/analytics/refiner.ts",
    "content": "import { config } from '../../consts/config';\nimport { logger } from '../../utils/logger';\n\nconst REFINER_PROJECT_ID = process.env.NEXT_PUBLIC_REFINER_PROJECT_ID || '';\nconst REFINER_TRANSFER_FORM_ID = process.env.NEXT_PUBLIC_REFINER_TRANSFER_FORM_ID || '';\n\ntype RefinerFn = (method: string, ...args: unknown[]) => void;\n\nlet refiner: RefinerFn | null = null;\n\n/**\n * Initialize Refiner SDK\n * Should be called once when the app loads\n */\nexport async function initRefiner(): Promise<void> {\n  if (typeof window === 'undefined' || refiner || !REFINER_PROJECT_ID) return;\n  try {\n    const refinerModule = await import('refiner-js');\n    refiner = refinerModule.default;\n    refiner('setProject', REFINER_PROJECT_ID);\n  } catch (error) {\n    logger.warn('Failed to initialize Refiner:', error);\n  }\n}\n\n/**\n * Identify user and show the transfer survey form\n */\nexport function refinerIdentifyAndShowTransferForm(params: {\n  walletAddress: string;\n  protocol: string;\n  chain: string;\n}): void {\n  if (!config.enableTrackingEvents || !refiner || !REFINER_TRANSFER_FORM_ID) return;\n\n  try {\n    refiner('identifyUser', {\n      id: params.walletAddress,\n      // eslint-disable-next-line camelcase\n      wallet_address: params.walletAddress,\n      protocol: params.protocol,\n      chain: params.chain,\n    });\n    refiner('showForm', REFINER_TRANSFER_FORM_ID);\n  } catch (error) {\n    logger.warn('Failed to show Refiner form:', error);\n  }\n}\n"
  },
  {
    "path": "src/features/analytics/types.ts",
    "content": "import { ProtocolType } from '@hyperlane-xyz/utils';\n\nexport enum EVENT_NAME {\n  PAGE_VIEWED = 'Page Viewed',\n  CHAIN_SELECTED = 'Chain Selected',\n  TOKEN_SELECTED = 'Token Selected',\n  TRANSACTION_SUBMITTED = 'Transaction Submitted',\n  TRANSACTION_SUBMISSION_FAILED = 'Transaction Submission Failed',\n  WALLET_CONNECTION_INITIATED = 'Wallet Connection Initiated',\n  WALLET_CONNECTED = 'Wallet Connected',\n  UNSUPPORTED_ROUTE_SELECTED = 'Unsupported Route Selected',\n}\n\nexport type AllowedPropertyValues = string | number | boolean | null;\n\n// Define specific properties for each event (max 7 custom properties due to Vercel's 8 property limit, sessionId takes one slot)\nexport type EventProperties = {\n  [EVENT_NAME.PAGE_VIEWED]: Record<string, never>;\n  [EVENT_NAME.CHAIN_SELECTED]: {\n    chainType: string;\n    chainId: ChainId | null;\n    chainName: string | null;\n    previousChainId: ChainId | null;\n    previousChainName: string | null;\n  };\n  [EVENT_NAME.TOKEN_SELECTED]: {\n    tokenType: string;\n    originToken: string;\n    destinationToken: string;\n    origin: string;\n    originChainId: ChainId;\n    destination: string;\n    destinationChainId: ChainId;\n  };\n  [EVENT_NAME.TRANSACTION_SUBMITTED]: {\n    chains: string;\n    tokenAddress: string;\n    tokenSymbol: string;\n    amount: string;\n    walletAddress: string;\n    transactionHash: string;\n    recipient: string;\n  };\n  [EVENT_NAME.WALLET_CONNECTION_INITIATED]: {\n    protocol: ProtocolType;\n  };\n  [EVENT_NAME.WALLET_CONNECTED]: {\n    protocol: ProtocolType;\n    walletAddress: string;\n    walletName: string;\n  };\n  [EVENT_NAME.TRANSACTION_SUBMISSION_FAILED]: {\n    chains: string;\n    tokenAddress: string;\n    tokenSymbol: string;\n    amount: string;\n    walletAddress: string | null;\n    recipient: string;\n    error: string;\n  };\n  [EVENT_NAME.UNSUPPORTED_ROUTE_SELECTED]: {\n    originToken: string;\n    destinationToken: string;\n    origin: string;\n    destination: string;\n    originChainId: ChainId;\n    destinationChainId: ChainId;\n  };\n};\n"
  },
  {
    "path": "src/features/analytics/useWalletConnectionTracking.tsx",
    "content": "import { ProtocolType } from '@hyperlane-xyz/utils';\nimport {\n  useAccounts,\n  useWalletDetails,\n} from '@hyperlane-xyz/widgets/walletIntegrations/multiProtocol';\nimport { useEffect, useRef } from 'react';\n\nimport { config } from '../../consts/config';\nimport { useMultiProvider } from '../chains/hooks';\nimport { EVENT_NAME } from './types';\nimport { trackEvent } from './utils';\n\n/**\n * Custom hook to track wallet connections and fire analytics events\n * Handles both new connections without duplicating events\n */\nexport function useWalletConnectionTracking() {\n  const multiProvider = useMultiProvider();\n  const { accounts } = useAccounts(multiProvider, config.addressBlacklist);\n  const walletDetails = useWalletDetails();\n  // Use a ref to track which wallets we've already fired events for in this session\n  // This prevents infinite loops and duplicate events\n  const trackedWalletsRef = useRef<Set<string>>(new Set());\n\n  useEffect(() => {\n    // Iterate through all protocol types\n    Object.entries(accounts).forEach(([protocol, accountInfo]) => {\n      const { addresses } = accountInfo;\n\n      // not tracking cosmos protocols chain\n      if (protocol === ProtocolType.Cosmos) return;\n\n      // Only track if account has addresses (meaning wallet is connected)\n      if (addresses && addresses.length > 0) {\n        const address = addresses[0].address;\n        const walletName = walletDetails[protocol as ProtocolType]?.name;\n\n        // if protocol is cosmosnative, track only cosmos addresses\n        if (protocol === ProtocolType.CosmosNative && !address.includes('cosmos')) return;\n\n        // Create a unique identifier for this wallet connection (protocol + address)\n        const walletId = `${protocol}:${address}`;\n\n        // Check if we've already tracked this wallet in this session\n        if (!trackedWalletsRef.current.has(walletId)) {\n          // Add to tracked set to prevent duplicate events\n          trackedWalletsRef.current.add(walletId);\n\n          // Fire the analytics event\n          trackEvent(EVENT_NAME.WALLET_CONNECTED, {\n            protocol: protocol as ProtocolType,\n            walletAddress: address,\n            walletName: walletName || 'Unknown',\n          });\n        }\n      }\n    });\n  }, [accounts, walletDetails]);\n}\n"
  },
  {
    "path": "src/features/analytics/utils.ts",
    "content": "import { MultiProtocolProvider, Token, WarpCore } from '@hyperlane-xyz/sdk';\nimport { KnownProtocolType, objLength } from '@hyperlane-xyz/utils';\nimport { getAccountAddressAndPubKey } from '@hyperlane-xyz/widgets/walletIntegrations/multiProtocol';\nimport { type AccountInfo } from '@hyperlane-xyz/widgets/walletIntegrations/types';\nimport { track } from '@vercel/analytics';\n\nimport { config } from '../../consts/config';\nimport { getTokenKey } from '../tokens/utils';\nimport { TransferFormValues } from '../transfer/types';\nimport { EVENT_NAME, EventProperties } from './types';\n\nconst sessionId =\n  crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;\n\nexport function trackEvent<T extends EVENT_NAME>(eventName: T, properties: EventProperties[T]) {\n  if (!config.enableTrackingEvents) return;\n\n  // take into consideration vercel only allows up to 8 properties\n  track(eventName, {\n    sessionId,\n    ...properties,\n  });\n}\n\nexport function trackTokenSelectionEvent(\n  tokenType: string,\n  originToken: Token | undefined,\n  destinationToken: Token | undefined,\n  multiProvider: MultiProtocolProvider,\n) {\n  if (!originToken || !destinationToken) return;\n\n  const origin = originToken.chainName;\n  const destination = destinationToken.chainName;\n  const originChainId = multiProvider.getChainId(origin);\n  const destinationChainId = multiProvider.getChainId(destination);\n\n  trackEvent(EVENT_NAME.TOKEN_SELECTED, {\n    tokenType,\n    originToken: originToken.symbol,\n    destinationToken: destinationToken.symbol,\n    origin,\n    destination,\n    originChainId,\n    destinationChainId,\n  });\n}\n\nexport function trackChainSelectionEvent(\n  chainType: string,\n  chain: { name: string; chainId: ChainId } | null,\n  previousChain: { name: string; chainId: ChainId } | null,\n) {\n  trackEvent(EVENT_NAME.CHAIN_SELECTED, {\n    chainType,\n    chainId: chain?.chainId ?? null,\n    chainName: chain?.name ?? null,\n    previousChainId: previousChain?.chainId ?? null,\n    previousChainName: previousChain?.name ?? null,\n  });\n}\n\n// errors that happen because of form not being filled correctly\nconst SKIPPED_ERRORS = [\n  'Token is required',\n  'Origin token is required',\n  'Destination token is required',\n  'Invalid amount',\n];\n\nexport function trackTransactionFailedEvent(\n  errors: Record<string, string> | null,\n  warpCore: WarpCore,\n  { originTokenKey, destinationTokenKey, amount, recipient: formRecipient }: TransferFormValues,\n  accounts: Record<KnownProtocolType, AccountInfo>,\n  overrideToken: Token | null,\n) {\n  if (!errors || objLength(errors) < 1) return;\n\n  const firstError = `${Object.values(errors)[0]}` || 'Unknown error';\n\n  if (SKIPPED_ERRORS.includes(firstError)) return;\n\n  // Find token from warpCore tokens by key\n  const token = overrideToken || warpCore.tokens.find((t) => getTokenKey(t) === originTokenKey);\n  if (!token) return;\n\n  const origin = token.chainName;\n  const { address } = getAccountAddressAndPubKey(warpCore.multiProvider, origin, accounts);\n\n  // Find destination token to get destination chain\n  const destToken = warpCore.tokens.find((t) => getTokenKey(t) === destinationTokenKey);\n  if (!destToken) return;\n  const destination = destToken.chainName;\n\n  // Get recipient (form value or fallback to connected wallet for destination)\n  const { address: connectedDestAddress } = getAccountAddressAndPubKey(\n    warpCore.multiProvider,\n    destination,\n    accounts,\n  );\n  const recipient = formRecipient || connectedDestAddress || '';\n\n  const originChainId = warpCore.multiProvider.tryGetChainId(origin);\n  const destinationChainId = destination ? warpCore.multiProvider.tryGetChainId(destination) : null;\n  return trackEvent(EVENT_NAME.TRANSACTION_SUBMISSION_FAILED, {\n    amount,\n    chains: `${origin}|${originChainId}|${destination}|${destinationChainId}`,\n    walletAddress: address || null,\n    tokenAddress: token.addressOrDenom,\n    tokenSymbol: token.symbol,\n    recipient,\n    error: firstError,\n  });\n}\n\nexport function trackUnsupportedRouteEvent(\n  originToken: Token | undefined,\n  destinationToken: Token | undefined,\n  multiProvider: MultiProtocolProvider,\n) {\n  if (!originToken || !destinationToken) return;\n\n  const origin = originToken.chainName;\n  const destination = destinationToken.chainName;\n  const originChainId = multiProvider.getChainId(origin);\n  const destinationChainId = multiProvider.getChainId(destination);\n\n  trackEvent(EVENT_NAME.UNSUPPORTED_ROUTE_SELECTED, {\n    originToken: originToken.symbol,\n    destinationToken: destinationToken.symbol,\n    origin,\n    destination,\n    originChainId,\n    destinationChainId,\n  });\n}\n"
  },
  {
    "path": "src/features/balances/UsdLabel.tsx",
    "content": "import { TokenAmount } from '@hyperlane-xyz/sdk';\n\nimport { getUsdDisplayForFee } from './feeUsdDisplay';\nimport type { FeePrices } from './useFeePrices';\n\nexport function UsdLabel({\n  tokenAmount,\n  feePrices,\n}: {\n  tokenAmount?: TokenAmount;\n  feePrices: FeePrices;\n}) {\n  const usd = getUsdDisplayForFee(tokenAmount, feePrices);\n  if (!usd) return null;\n  return <span className=\"ml-1 text-gray-500\">{usd}</span>;\n}\n"
  },
  {
    "path": "src/features/balances/cosmos.ts",
    "content": "import { StargateClient } from '@cosmjs/stargate';\nimport { Token, TokenStandard } from '@hyperlane-xyz/sdk';\nimport { ProtocolType } from '@hyperlane-xyz/utils';\n\nimport { logger } from '../../utils/logger';\nimport { getTokenKey } from '../tokens/utils';\nimport { TokenEntry } from './tokens';\n\nexport interface CosmosChainGroup {\n  chainName: string;\n  bankTokens: { key: string; denom: string }[];\n  fallbackTokens: TokenEntry[];\n}\n\n/** Standards where the bank denom is `addressOrDenom`. */\nconst BANK_DENOM_FROM_ADDRESS: TokenStandard[] = [\n  TokenStandard.CosmosNative,\n  TokenStandard.CosmosIbc,\n  TokenStandard.CosmosIcs20,\n  TokenStandard.CWNative,\n  TokenStandard.CW20,\n];\n\n/** Standards where the bank denom is `collateralAddressOrDenom`. */\nconst BANK_DENOM_FROM_COLLATERAL: TokenStandard[] = [\n  TokenStandard.CwHypCollateral,\n  TokenStandard.CwHypSynthetic,\n  TokenStandard.CosmNativeHypCollateral,\n];\n\nfunction classifyCosmosToken(token: Token): { type: 'bank' | 'unknown'; denom?: string } {\n  if (BANK_DENOM_FROM_ADDRESS.includes(token.standard)) {\n    return { type: 'bank', denom: token.addressOrDenom };\n  }\n  if (BANK_DENOM_FROM_COLLATERAL.includes(token.standard)) {\n    return token.collateralAddressOrDenom\n      ? { type: 'bank', denom: token.collateralAddressOrDenom }\n      : { type: 'unknown' };\n  }\n  if (token.standard === TokenStandard.CosmNativeHypSynthetic) {\n    return { type: 'bank', denom: `hyperlane/${token.addressOrDenom}` };\n  }\n  // CwHypNative requires dynamic denom resolution via contract — SDK fallback\n  return { type: 'unknown' };\n}\n\n/** Group Cosmos/CosmosNative tokens by chain, split into bank-batchable vs SDK fallback. */\nexport function groupCosmosTokensByChain(tokens: Token[]): Map<string, CosmosChainGroup> {\n  const groups = new Map<string, CosmosChainGroup>();\n\n  for (const token of tokens) {\n    if (token.protocol !== ProtocolType.Cosmos && token.protocol !== ProtocolType.CosmosNative)\n      continue;\n\n    const key = getTokenKey(token);\n    const { type, denom } = classifyCosmosToken(token);\n\n    if (!groups.has(token.chainName)) {\n      groups.set(token.chainName, {\n        chainName: token.chainName,\n        bankTokens: [],\n        fallbackTokens: [],\n      });\n    }\n    const group = groups.get(token.chainName)!;\n\n    if (type === 'bank' && denom) {\n      group.bankTokens.push({ key, denom });\n    } else {\n      group.fallbackTokens.push({ token, key });\n    }\n  }\n\n  return groups;\n}\n\n/**\n * Fetch bank-module token balances for a single cosmos chain via getAllBalances.\n * Fallback tokens (e.g. CwHypNative) are handled by the caller via fetchSdkBalance.\n */\nexport async function fetchCosmosChainBalances(\n  group: CosmosChainGroup,\n  rpcUrl: string,\n  address: string,\n): Promise<Record<string, bigint>> {\n  if (group.bankTokens.length === 0) return {};\n\n  const client = await StargateClient.connect(rpcUrl);\n  try {\n    const allCoins = await client.getAllBalances(address);\n    const coinMap = new Map(allCoins.map((c) => [c.denom, BigInt(c.amount)]));\n\n    const out: Record<string, bigint> = {};\n    for (const { key, denom } of group.bankTokens) {\n      const balance = coinMap.get(denom);\n      if (balance !== undefined) {\n        out[key] = balance;\n      }\n    }\n    return out;\n  } catch (err) {\n    logger.warn(`Bank allBalances failed on ${group.chainName}`, err);\n    return {};\n  } finally {\n    client.disconnect();\n  }\n}\n"
  },
  {
    "path": "src/features/balances/evm.ts",
    "content": "import { ChainAddresses } from '@hyperlane-xyz/registry';\nimport { ChainMap, MultiProtocolProvider, Token } from '@hyperlane-xyz/sdk';\nimport { ProtocolType, normalizeAddress } from '@hyperlane-xyz/utils';\nimport {\n  Hex,\n  createPublicClient,\n  decodeFunctionResult,\n  encodeFunctionData,\n  erc20Abi,\n  http,\n  multicall3Abi,\n} from 'viem';\n\nimport { logger } from '../../utils/logger';\nimport { getTokenKey } from '../tokens/utils';\nimport { TokenEntry, fetchSdkBalance } from './tokens';\n\nconst MULTICALL3_ADDRESS = '0xca11bde05977b3631167028862be2a173976ca11' as Hex;\n\n// Minimal ABI for HypCollateral.wrappedToken()\nconst wrappedTokenAbi = [\n  {\n    inputs: [],\n    name: 'wrappedToken',\n    outputs: [{ internalType: 'address', name: '', type: 'address' }],\n    stateMutability: 'view',\n    type: 'function',\n  },\n] as const;\n\ntype TokenClassification = 'erc20' | 'lockbox' | 'native' | 'unknown';\n\nexport interface ChainGroup {\n  chainName: string;\n  tokens: TokenEntry[];\n  erc20: { address: Hex; key: string }[];\n  lockbox: { routerAddress: Hex; key: string }[];\n}\n\ninterface CallInfo {\n  target: Hex;\n  callData: Hex;\n  tokenKey: string;\n}\n\ntype Aggregate3Result = Array<{ success: boolean; returnData: Hex }>;\n\n/** Assumes EVM tokens only — callers must filter by ProtocolType.Ethereum first. */\nfunction classifyToken(token: Token): { type: TokenClassification; erc20Address?: Hex } {\n  if (token.isHypNative()) return { type: 'native' };\n\n  const standard = token.standard;\n  if (standard.includes('Lockbox')) return { type: 'lockbox' };\n  if (token.collateralAddressOrDenom)\n    return { type: 'erc20', erc20Address: normalizeAddress(token.collateralAddressOrDenom) as Hex };\n  if (standard.includes('Synthetic'))\n    return { type: 'erc20', erc20Address: normalizeAddress(token.addressOrDenom) as Hex };\n  return { type: 'unknown' };\n}\n\n/**\n * Prefer the registry's batchContractAddress when available — some chains\n * (e.g. ancient8, viction) have the standard multicall3 address compromised.\n */\nfunction getBatchAddress(chainName: string, chainAddresses: ChainMap<ChainAddresses>): Hex {\n  const addresses = chainAddresses[chainName];\n  if (addresses?.batchContractAddress) {\n    return addresses.batchContractAddress.toLowerCase() as Hex;\n  }\n  return MULTICALL3_ADDRESS;\n}\n\n/** Group EVM tokens by chainId. Returns batched chain groups + SDK fallback for native/unknown. */\nexport function groupEvmTokensByChain(\n  tokens: Token[],\n  multiProvider: MultiProtocolProvider,\n): {\n  chainGroups: Map<number, ChainGroup>;\n  fallbackTokens: TokenEntry[];\n} {\n  const chainGroups = new Map<number, ChainGroup>();\n  const fallbackTokens: TokenEntry[] = [];\n\n  for (const token of tokens) {\n    if (token.protocol !== ProtocolType.Ethereum) continue;\n\n    const key = getTokenKey(token);\n    const chainMeta = multiProvider.tryGetChainMetadata(token.chainName);\n    if (!chainMeta?.chainId) continue;\n    const chainId = Number(chainMeta.chainId);\n    const classification = classifyToken(token);\n\n    if (classification.type === 'native' || classification.type === 'unknown') {\n      fallbackTokens.push({ token, key });\n      continue;\n    }\n\n    if (!chainGroups.has(chainId)) {\n      chainGroups.set(chainId, { chainName: token.chainName, tokens: [], erc20: [], lockbox: [] });\n    }\n    const group = chainGroups.get(chainId)!;\n    group.tokens.push({ token, key });\n\n    if (classification.type === 'erc20' && classification.erc20Address) {\n      group.erc20.push({ address: classification.erc20Address, key });\n    } else {\n      group.lockbox.push({ routerAddress: normalizeAddress(token.addressOrDenom) as Hex, key });\n    }\n  }\n\n  return { chainGroups, fallbackTokens };\n}\n\n/** Send a multicall3 aggregate3 batch via a single eth_call. */\nasync function callAggregate3(\n  client: any, // ReturnType<typeof createPublicClient> triggers TS2589\n  batchAddress: Hex,\n  calls: Array<{ target: Hex; callData: Hex }>,\n): Promise<Aggregate3Result> {\n  const raw = await client.request({\n    method: 'eth_call',\n    params: [\n      {\n        to: batchAddress,\n        data: encodeFunctionData({\n          abi: multicall3Abi,\n          functionName: 'aggregate3',\n          args: [\n            calls.map((c) => ({\n              target: c.target,\n              allowFailure: true as const,\n              callData: c.callData,\n            })),\n          ],\n        }),\n      },\n      'latest',\n    ],\n  });\n  return decodeFunctionResult({\n    abi: multicall3Abi,\n    functionName: 'aggregate3',\n    data: raw,\n  });\n}\n\ninterface LockboxResolution {\n  resolved: CallInfo[];\n  unresolvedKeys: string[];\n}\n\n/**\n * Resolve lockbox underlying ERC20 addresses via wrappedToken() multicall.\n * Returns resolved CallInfo[] for Phase 2 + keys of any unresolved lockboxes for SDK fallback.\n */\nasync function resolveLockboxTokens(\n  client: any,\n  batchAddress: Hex,\n  lockboxTokens: ChainGroup['lockbox'],\n  balanceOfCallData: Hex,\n  chainId: number,\n): Promise<LockboxResolution> {\n  if (lockboxTokens.length === 0) return { resolved: [], unresolvedKeys: [] };\n\n  const resolveCallData = encodeFunctionData({\n    abi: wrappedTokenAbi,\n    functionName: 'wrappedToken',\n  });\n  try {\n    const decoded = await callAggregate3(\n      client,\n      batchAddress,\n      lockboxTokens.map((lb) => ({ target: lb.routerAddress, callData: resolveCallData })),\n    );\n    const resolved: CallInfo[] = [];\n    const unresolvedKeys: string[] = [];\n    for (let i = 0; i < decoded.length; i++) {\n      if (decoded[i].success && decoded[i].returnData !== '0x') {\n        const underlying = decodeFunctionResult({\n          abi: wrappedTokenAbi,\n          functionName: 'wrappedToken',\n          data: decoded[i].returnData,\n        });\n        resolved.push({\n          target: underlying,\n          callData: balanceOfCallData,\n          tokenKey: lockboxTokens[i].key,\n        });\n      } else {\n        unresolvedKeys.push(lockboxTokens[i].key);\n      }\n    }\n    return { resolved, unresolvedKeys };\n  } catch (err) {\n    logger.warn(`Lockbox resolution failed on chain ${chainId}`, err);\n    return { resolved: [], unresolvedKeys: lockboxTokens.map((lb) => lb.key) };\n  }\n}\n\nfunction decodeBalanceResults(\n  decoded: Aggregate3Result,\n  calls: CallInfo[],\n  out: Record<string, bigint>,\n) {\n  for (let i = 0; i < decoded.length; i++) {\n    if (decoded[i].success && decoded[i].returnData !== '0x') {\n      out[calls[i].tokenKey] = decodeFunctionResult({\n        abi: erc20Abi,\n        functionName: 'balanceOf',\n        data: decoded[i].returnData,\n      });\n    }\n  }\n}\n\n/**\n * Fetch all ERC20 balances for a single chain via multicall3.\n * Phase 1: resolve lockbox underlying ERC20 addresses.\n * Phase 2: batch all balanceOf calls into one aggregate3.\n * Falls back to SDK getBalance() if the batch contract is unavailable.\n */\nexport async function fetchChainBalances(\n  chainId: number,\n  group: ChainGroup,\n  multiProvider: MultiProtocolProvider,\n  evmAddress: Hex,\n  chainAddresses: ChainMap<ChainAddresses>,\n): Promise<Record<string, bigint>> {\n  const rpcUrl = multiProvider.tryGetChainMetadata(group.chainName)?.rpcUrls?.[0]?.http;\n  if (!rpcUrl) {\n    logger.warn(`No RPC URL for chain ${group.chainName}, skipping balance fetch`);\n    return {};\n  }\n\n  const client = createPublicClient({ transport: http(rpcUrl) });\n  const batchAddress = getBatchAddress(group.chainName, chainAddresses);\n  const balanceOfCallData = encodeFunctionData({\n    abi: erc20Abi,\n    functionName: 'balanceOf',\n    args: [evmAddress],\n  });\n\n  const { resolved: lockboxResolved, unresolvedKeys } = await resolveLockboxTokens(\n    client,\n    batchAddress,\n    group.lockbox,\n    balanceOfCallData,\n    chainId,\n  );\n\n  const allCalls: CallInfo[] = [\n    ...group.erc20.map((t) => ({\n      target: t.address,\n      callData: balanceOfCallData,\n      tokenKey: t.key,\n    })),\n    ...lockboxResolved,\n  ];\n\n  // SDK fallback for lockbox tokens whose wrappedToken() couldn't be resolved\n  const unresolvedFallbacks =\n    unresolvedKeys.length > 0\n      ? group.tokens\n          .filter(({ key }) => unresolvedKeys.includes(key))\n          .map(({ token, key }) => fetchSdkBalance(token, multiProvider, evmAddress, key))\n      : [];\n\n  if (allCalls.length === 0) {\n    const partials = await Promise.all(unresolvedFallbacks);\n    return Object.assign({}, ...partials);\n  }\n\n  try {\n    const out: Record<string, bigint> = {};\n    const decoded = await callAggregate3(client, batchAddress, allCalls);\n    decodeBalanceResults(decoded, allCalls, out);\n    const fallbackPartials = await Promise.all(unresolvedFallbacks);\n    return Object.assign(out, ...fallbackPartials);\n  } catch (err) {\n    logger.warn(`Batch call failed on chain ${chainId}, falling back to SDK getBalance`, err);\n    const partials = await Promise.all(\n      group.tokens.map(({ token, key }) => fetchSdkBalance(token, multiProvider, evmAddress, key)),\n    );\n    return Object.assign({}, ...partials);\n  }\n}\n"
  },
  {
    "path": "src/features/balances/feeUsdDisplay.test.ts",
    "content": "import { TokenAmount } from '@hyperlane-xyz/sdk';\nimport { describe, expect, test } from 'vitest';\n\nimport { createMockToken } from '../../utils/test';\nimport {\n  getFeePercentage,\n  getTotalFeesUsd,\n  getTotalFeesUsdRaw,\n  getUsdDisplayForFee,\n} from './feeUsdDisplay';\nimport { FeePrices } from './useFeePrices';\n\nconst ethToken = createMockToken({ symbol: 'ETH', decimals: 18 });\n\nfunction makeAmount(token: typeof ethToken, wei: bigint) {\n  return new TokenAmount(wei, token);\n}\n\nconst prices: FeePrices = { ETH: 2000, SOL: 100 };\n\ndescribe('getUsdDisplayForFee', () => {\n  test('returns null for undefined tokenAmount', () => {\n    expect(getUsdDisplayForFee(undefined, prices)).toBeNull();\n  });\n\n  test('returns null for zero amount', () => {\n    expect(getUsdDisplayForFee(makeAmount(ethToken, 0n), prices)).toBeNull();\n  });\n\n  test('returns null when no price available', () => {\n    const unknown = createMockToken({ symbol: 'UNKNOWN', decimals: 18 });\n    expect(getUsdDisplayForFee(makeAmount(unknown, 10n ** 18n), prices)).toBeNull();\n  });\n\n  test('returns formatted USD for valid amount', () => {\n    // 1 ETH at $2000 = $2000.00\n    expect(getUsdDisplayForFee(makeAmount(ethToken, 10n ** 18n), prices)).toBe('≈$2,000.00');\n  });\n\n  test('returns <$0.01 for very small amounts', () => {\n    // 1 wei ETH is effectively $0\n    expect(getUsdDisplayForFee(makeAmount(ethToken, 1n), prices)).toBe('<$0.01');\n  });\n});\n\ndescribe('getTotalFeesUsdRaw', () => {\n  test('sums all priced fees', () => {\n    const fees = {\n      localQuote: makeAmount(ethToken, 10n ** 16n), // 0.01 ETH = $20\n      interchainQuote: makeAmount(ethToken, 5n * 10n ** 16n), // 0.05 ETH = $100\n    };\n    expect(getTotalFeesUsdRaw(fees, prices)).toBeCloseTo(120);\n  });\n\n  test('skips fees without price', () => {\n    const unknown = createMockToken({ symbol: 'UNKNOWN', decimals: 18 });\n    const fees = {\n      localQuote: makeAmount(unknown, 10n ** 18n),\n      interchainQuote: makeAmount(ethToken, 10n ** 16n), // 0.01 ETH = $20\n    };\n    expect(getTotalFeesUsdRaw(fees, prices)).toBeCloseTo(20);\n  });\n\n  test('returns 0 when no fees have prices', () => {\n    const unknown = createMockToken({ symbol: 'UNKNOWN', decimals: 18 });\n    const fees = {\n      localQuote: makeAmount(unknown, 10n ** 18n),\n      interchainQuote: makeAmount(unknown, 10n ** 18n),\n    };\n    expect(getTotalFeesUsdRaw(fees, prices)).toBe(0);\n  });\n});\n\ndescribe('getTotalFeesUsd', () => {\n  test('returns formatted total', () => {\n    const fees = {\n      localQuote: makeAmount(ethToken, 10n ** 16n),\n      interchainQuote: makeAmount(ethToken, 10n ** 16n),\n    };\n    expect(getTotalFeesUsd(fees, prices)).toBe('≈$40.00');\n  });\n\n  test('returns null when total is 0', () => {\n    const fees = {\n      localQuote: makeAmount(ethToken, 0n),\n      interchainQuote: makeAmount(ethToken, 0n),\n    };\n    expect(getTotalFeesUsd(fees, prices)).toBeNull();\n  });\n});\n\ndescribe('getFeePercentage', () => {\n  test('returns null when totalFeesUsd is 0', () => {\n    expect(getFeePercentage(0, 1000)).toBeNull();\n  });\n\n  test('returns null when transferUsd is 0', () => {\n    expect(getFeePercentage(10, 0)).toBeNull();\n  });\n\n  test('returns formatted percentage', () => {\n    expect(getFeePercentage(1, 1000)).toBe('0.10%');\n  });\n\n  test('returns <0.01% for tiny percentages', () => {\n    expect(getFeePercentage(0.001, 1000)).toBe('<0.01%');\n  });\n\n  test('returns ≥100% when fees exceed transfer', () => {\n    expect(getFeePercentage(1500, 1000)).toBe('≥100%');\n  });\n\n  test('returns ≥100% when fees equal transfer', () => {\n    expect(getFeePercentage(1000, 1000)).toBe('≥100%');\n  });\n});\n"
  },
  {
    "path": "src/features/balances/feeUsdDisplay.ts",
    "content": "import { TokenAmount } from '@hyperlane-xyz/sdk';\nimport { isNullish } from '@hyperlane-xyz/utils';\n\nimport type { FeePrices } from './useFeePrices';\nimport { formatUsd } from './utils';\n\nexport function getUsdDisplayForFee(\n  tokenAmount: TokenAmount | undefined,\n  feePrices: FeePrices,\n): string | null {\n  if (!tokenAmount || tokenAmount.amount === 0n) return null;\n  const price = feePrices[tokenAmount.token.symbol];\n  if (isNullish(price)) return null;\n  const value = tokenAmount.getDecimalFormattedAmount() * price;\n  if (value <= 0) return null;\n  return formatUsd(value, true);\n}\n\nexport function getTotalFeesUsdRaw(\n  fees: { localQuote: TokenAmount; interchainQuote: TokenAmount; tokenFeeQuote?: TokenAmount },\n  feePrices: FeePrices,\n): number {\n  let total = 0;\n  for (const quote of [fees.localQuote, fees.interchainQuote, fees.tokenFeeQuote]) {\n    if (!quote || quote.amount === 0n) continue;\n    const price = feePrices[quote.token.symbol];\n    if (!price) continue;\n    total += quote.getDecimalFormattedAmount() * price;\n  }\n  return total;\n}\n\nexport function getTotalFeesUsd(\n  fees: { localQuote: TokenAmount; interchainQuote: TokenAmount; tokenFeeQuote?: TokenAmount },\n  feePrices: FeePrices,\n): string | null {\n  const total = getTotalFeesUsdRaw(fees, feePrices);\n  if (total <= 0) return null;\n  return formatUsd(total, true);\n}\n\nexport function getFeePercentage(totalFeesUsd: number, transferUsd: number): string | null {\n  if (totalFeesUsd <= 0 || transferUsd <= 0) return null;\n  const pct = (totalFeesUsd / transferUsd) * 100;\n  if (pct < 0.01) return '<0.01%';\n  if (pct >= 100) return '≥100%';\n  return `${pct.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%`;\n}\n"
  },
  {
    "path": "src/features/balances/hooks.ts",
    "content": "import { IToken, MultiProtocolProvider, Token } from '@hyperlane-xyz/sdk';\nimport { ProtocolType, getAddressProtocolType, isValidAddress } from '@hyperlane-xyz/utils';\nimport { useCosmosAccount } from '@hyperlane-xyz/widgets/walletIntegrations/cosmos';\nimport { useEthereumAccount } from '@hyperlane-xyz/widgets/walletIntegrations/ethereum';\nimport { useAccountAddressForChain } from '@hyperlane-xyz/widgets/walletIntegrations/multiProtocol';\nimport { useSolanaAccount } from '@hyperlane-xyz/widgets/walletIntegrations/solana';\nimport { useQuery } from '@tanstack/react-query';\nimport { useMemo } from 'react';\nimport { toast } from 'react-toastify';\nimport { Hex } from 'viem';\nimport { useBalance as useWagmiBalance } from 'wagmi';\n\nimport { useToastError } from '../../components/toast/useToastError';\nimport { logger } from '../../utils/logger';\nimport { useMultiProvider } from '../chains/hooks';\nimport { getChainDisplayName } from '../chains/utils';\nimport { useStore } from '../store';\nimport { getTokenKey } from '../tokens/utils';\nimport { fetchCosmosChainBalances, groupCosmosTokensByChain } from './cosmos';\nimport { fetchChainBalances, groupEvmTokensByChain } from './evm';\nimport { fetchSealevelChainBalances, groupSealevelTokensByChain } from './svm';\nimport { fetchSdkBalance } from './tokens';\n\nexport function useBalance(chain?: ChainName, token?: IToken, address?: Address) {\n  const multiProvider = useMultiProvider();\n  const { isLoading, isError, error, data } = useQuery({\n    // The Token and Multiprovider classes are not serializable, so we can't use it as a key\n    // eslint-disable-next-line @tanstack/query/exhaustive-deps\n    queryKey: [\n      'useBalance',\n      chain,\n      address,\n      token?.addressOrDenom,\n      token?.collateralAddressOrDenom,\n    ],\n    queryFn: () => {\n      if (!chain || !token || !address || !isValidAddress(address, token.protocol)) return null;\n      return token.getBalance(multiProvider, address);\n    },\n    staleTime: 30_000,\n    refetchInterval: 30_000,\n  });\n\n  useToastError(error, 'Error fetching balance');\n\n  return {\n    isLoading,\n    isError,\n    balance: data ?? undefined,\n  };\n}\n\nexport function useOriginBalance(originToken?: Token) {\n  const multiProvider = useMultiProvider();\n  const origin = originToken?.chainName;\n  const address = useAccountAddressForChain(multiProvider, origin);\n  return useBalance(origin, originToken, address);\n}\n\nexport function useDestinationBalance(recipient?: string, destinationToken?: Token) {\n  const destination = destinationToken?.chainName;\n  return useBalance(destination, destinationToken, recipient);\n}\n\nexport async function getDestinationNativeBalance(\n  multiProvider: MultiProtocolProvider,\n  { destination, recipient }: { destination: string; recipient: string },\n) {\n  try {\n    const chainMetadata = multiProvider.getChainMetadata(destination);\n    const token = Token.FromChainMetadataNativeToken(chainMetadata);\n    const balance = await token.getBalance(multiProvider, recipient);\n    return balance.amount;\n  } catch (error) {\n    const msg = `Error checking recipient balance on ${getChainDisplayName(multiProvider, destination)}`;\n    logger.error(msg, error);\n    toast.error(msg);\n    return undefined;\n  }\n}\n\nexport function useEvmWalletBalance(\n  chainName: string,\n  chainId: number,\n  token: string,\n  refetchEnabled: boolean,\n) {\n  const multiProvider = useMultiProvider();\n  const address = useAccountAddressForChain(multiProvider, chainName);\n  const allowRefetch = Boolean(address) && refetchEnabled;\n\n  const { data, isError, isLoading } = useWagmiBalance({\n    address: address ? (address as Hex) : undefined,\n    token: token ? (token as Hex) : undefined,\n    chainId: chainId,\n    query: {\n      refetchInterval: allowRefetch ? 5000 : false,\n      enabled: allowRefetch,\n    },\n  });\n\n  return { balance: data, isError, isLoading };\n}\n\n/** Returns a Map<ProtocolType, string> of all connected wallet addresses. */\nfunction useWalletAddresses(multiProvider: MultiProtocolProvider): Map<ProtocolType, string> {\n  const evmAddress = useEthereumAccount(multiProvider).addresses[0]?.address;\n  const solanaAddress = useSolanaAccount(multiProvider).addresses[0]?.address;\n\n  return useMemo(() => {\n    const map = new Map<ProtocolType, string>();\n    if (evmAddress) map.set(ProtocolType.Ethereum, evmAddress);\n    if (solanaAddress) map.set(ProtocolType.Sealevel, solanaAddress);\n    return map;\n  }, [evmAddress, solanaAddress]);\n}\n\n/**\n * Batch-fetch token balances across protocols.\n * - EVM: multicall3 — one eth_call per chain\n * - Sealevel: getParsedTokenAccountsByOwner per chain\n * - Cosmos: bank.allBalances per chain (with SDK fallback for CwHypNative)\n */\nexport function useTokenBalances(tokens: Token[], scope: string, addressOverride?: string) {\n  const multiProvider = useMultiProvider();\n  const chainAddresses = useStore((s) => s.chainAddresses);\n  const walletAddresses = useWalletAddresses(multiProvider);\n  const cosmosAddresses = useCosmosAccount(multiProvider).addresses;\n  const tokenKeys = useMemo(() => tokens.map((t) => getTokenKey(t)), [tokens]);\n\n  // When an address override is provided (e.g. pasted recipient),\n  // detect its protocol and use it instead of the connected wallet address\n  const effectiveAddresses = useMemo(() => {\n    if (!addressOverride) return walletAddresses;\n    const protocol = getAddressProtocolType(addressOverride);\n    if (!protocol) return walletAddresses;\n    const map = new Map(walletAddresses);\n    map.set(protocol, addressOverride);\n    return map;\n  }, [walletAddresses, addressOverride]);\n\n  const addressEntries = useMemo(\n    () => Array.from(effectiveAddresses.entries()).sort(([a], [b]) => a.localeCompare(b)),\n    [effectiveAddresses],\n  );\n\n  const cosmosAddressKey = useMemo(\n    () => cosmosAddresses.map((a) => `${a.chainName}:${a.address}`).sort(),\n    [cosmosAddresses],\n  );\n\n  // fetchChainBalances only reads batchContractAddress per chain. If that\n  // expands, widen this digest accordingly.\n  const chainAddressesKey = useMemo(\n    () =>\n      Object.entries(chainAddresses)\n        .map(([chain, addrs]) => `${chain}:${addrs.batchContractAddress ?? ''}`)\n        .sort()\n        .join('|'),\n    [chainAddresses],\n  );\n\n  const hasAnyAddress = effectiveAddresses.size > 0 || cosmosAddresses.length > 0;\n\n  const { data: balances = {}, isLoading } = useQuery({\n    // eslint-disable-next-line @tanstack/query/exhaustive-deps -- effectiveAddresses derived from addressEntries; tokens covered by tokenKeys; multiProvider is not serializable\n    queryKey: [\n      'tokenBalances',\n      addressEntries,\n      cosmosAddressKey,\n      scope,\n      tokenKeys,\n      chainAddressesKey,\n    ],\n    queryFn: async (): Promise<Record<string, bigint>> => {\n      const promises: Promise<Record<string, bigint>>[] = [];\n\n      // EVM\n      const evmAddr = effectiveAddresses.get(ProtocolType.Ethereum);\n      if (evmAddr) {\n        const { chainGroups, fallbackTokens } = groupEvmTokensByChain(tokens, multiProvider);\n        for (const [chainId, group] of chainGroups) {\n          promises.push(\n            fetchChainBalances(chainId, group, multiProvider, evmAddr as Hex, chainAddresses),\n          );\n        }\n        for (const { token, key } of fallbackTokens) {\n          promises.push(fetchSdkBalance(token, multiProvider, evmAddr, key));\n        }\n      }\n\n      // Sealevel\n      const solAddr = effectiveAddresses.get(ProtocolType.Sealevel);\n      if (solAddr) {\n        const sealevelGroups = groupSealevelTokensByChain(tokens);\n        for (const [, group] of sealevelGroups) {\n          const rpcUrl = multiProvider.tryGetChainMetadata(group.chainName)?.rpcUrls?.[0]?.http;\n          if (rpcUrl) {\n            promises.push(fetchSealevelChainBalances(group, rpcUrl, solAddr));\n          }\n        }\n      }\n\n      // Cosmos — per-chain bech32 address lookup\n      // addressOverride: match bech32 prefix to chain metadata to find the right chain\n      const cosmosOverride = effectiveAddresses.get(ProtocolType.Cosmos);\n      if (cosmosAddresses.length > 0 || cosmosOverride) {\n        const cosmosGroups = groupCosmosTokensByChain(tokens);\n        for (const [, group] of cosmosGroups) {\n          let addr = cosmosAddresses.find((a) => a.chainName === group.chainName)?.address;\n          if (!addr && cosmosOverride) {\n            const chainPrefix = multiProvider.tryGetChainMetadata(group.chainName)?.bech32Prefix;\n            if (chainPrefix && cosmosOverride.startsWith(chainPrefix)) addr = cosmosOverride;\n          }\n          if (!addr) continue;\n          const rpcUrl = multiProvider.tryGetChainMetadata(group.chainName)?.rpcUrls?.[0]?.http;\n          if (!rpcUrl) continue;\n          promises.push(fetchCosmosChainBalances(group, rpcUrl, addr));\n          for (const { token, key } of group.fallbackTokens) {\n            promises.push(fetchSdkBalance(token, multiProvider, addr, key));\n          }\n        }\n      }\n\n      const partials = await Promise.all(promises);\n      return Object.assign({}, ...partials);\n    },\n    enabled: tokens.length > 0 && hasAnyAddress,\n    staleTime: 30_000,\n    refetchInterval: 30_000,\n  });\n\n  return { balances, isLoading, hasAnyAddress };\n}\n"
  },
  {
    "path": "src/features/balances/svm.ts",
    "content": "import { Token, TokenStandard } from '@hyperlane-xyz/sdk';\nimport { ProtocolType } from '@hyperlane-xyz/utils';\nimport { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token';\nimport { Connection, PublicKey } from '@solana/web3.js';\n\nimport { logger } from '../../utils/logger';\nimport { getTokenKey } from '../tokens/utils';\nimport { TokenEntry } from './tokens';\n\ntype SealevelTokenClassification = 'spl' | 'spl2022' | 'native' | 'unknown';\n\ninterface SealevelTokenEntry extends TokenEntry {\n  mintAddress: string;\n}\n\nexport interface SealevelChainGroup {\n  chainName: string;\n  splTokens: SealevelTokenEntry[];\n  spl2022Tokens: SealevelTokenEntry[];\n  nativeTokens: TokenEntry[];\n}\n\nfunction classifySealevelToken(token: Token): {\n  type: SealevelTokenClassification;\n  mintAddress?: string;\n} {\n  switch (token.standard) {\n    case TokenStandard.SealevelNative:\n    case TokenStandard.SealevelHypNative:\n      return { type: 'native' };\n    case TokenStandard.SealevelSpl:\n      return { type: 'spl', mintAddress: token.addressOrDenom };\n    case TokenStandard.SealevelSpl2022:\n      return { type: 'spl2022', mintAddress: token.addressOrDenom };\n    case TokenStandard.SealevelHypCollateral:\n      return { type: 'spl', mintAddress: token.collateralAddressOrDenom };\n    case TokenStandard.SealevelHypSynthetic:\n      if (!token.collateralAddressOrDenom) return { type: 'unknown' };\n      return { type: 'spl2022', mintAddress: token.collateralAddressOrDenom };\n    default:\n      return { type: 'unknown' };\n  }\n}\n\n/** Group Sealevel tokens by chain. Unknown standards are skipped. */\nexport function groupSealevelTokensByChain(tokens: Token[]): Map<string, SealevelChainGroup> {\n  const chainGroups = new Map<string, SealevelChainGroup>();\n\n  for (const token of tokens) {\n    if (token.protocol !== ProtocolType.Sealevel) continue;\n\n    const key = getTokenKey(token);\n    const classification = classifySealevelToken(token);\n    if (classification.type === 'unknown') continue;\n\n    if (!chainGroups.has(token.chainName)) {\n      chainGroups.set(token.chainName, {\n        chainName: token.chainName,\n        splTokens: [],\n        spl2022Tokens: [],\n        nativeTokens: [],\n      });\n    }\n    const group = chainGroups.get(token.chainName)!;\n\n    if (classification.type === 'native') {\n      group.nativeTokens.push({ token, key });\n    } else if (classification.type === 'spl' && classification.mintAddress) {\n      group.splTokens.push({ token, key, mintAddress: classification.mintAddress });\n    } else if (classification.type === 'spl2022' && classification.mintAddress) {\n      group.spl2022Tokens.push({ token, key, mintAddress: classification.mintAddress });\n    }\n  }\n\n  return chainGroups;\n}\n\n/**\n * Build a map from mint address -> array of token keys.\n * Multiple tokens can map to the same mint (e.g. SealevelSpl + SealevelHypCollateral wrapping same token).\n */\nfunction buildMintToKeysMap(entries: SealevelTokenEntry[]): Map<string, string[]> {\n  const map = new Map<string, string[]>();\n  for (const entry of entries) {\n    const existing = map.get(entry.mintAddress);\n    if (existing) {\n      existing.push(entry.key);\n    } else {\n      map.set(entry.mintAddress, [entry.key]);\n    }\n  }\n  return map;\n}\n\ninterface ParsedTokenAccount {\n  account: { data: { parsed: { info: { mint: string; tokenAmount: { amount: string } } } } };\n}\n\nfunction parseSplTokenAccounts(\n  accounts: ParsedTokenAccount[],\n  mintToKeys: Map<string, string[]>,\n): Record<string, bigint> {\n  const out: Record<string, bigint> = {};\n  for (const { account } of accounts) {\n    const info = account.data.parsed.info;\n    const keys = mintToKeys.get(info.mint);\n    if (!keys) continue;\n    const amount = BigInt(info.tokenAmount.amount);\n    for (const key of keys) {\n      out[key] = (out[key] ?? 0n) + amount;\n    }\n  }\n  return out;\n}\n\n/** Fetch all SPL token balances for a single Sealevel chain via getParsedTokenAccountsByOwner. */\nexport async function fetchSealevelChainBalances(\n  group: SealevelChainGroup,\n  rpcUrl: string,\n  ownerAddress: string,\n): Promise<Record<string, bigint>> {\n  let owner: PublicKey;\n  try {\n    owner = new PublicKey(ownerAddress);\n  } catch {\n    logger.warn(`Invalid Solana address: ${ownerAddress}`);\n    return {};\n  }\n  const connection = new Connection(rpcUrl, 'confirmed');\n  const promises: Promise<Record<string, bigint>>[] = [];\n\n  // Fetch SPL and Token-2022 accounts\n  const programFetches: [Map<string, string[]>, PublicKey, string][] = [\n    [buildMintToKeysMap(group.splTokens), TOKEN_PROGRAM_ID, 'SPL'],\n    [buildMintToKeysMap(group.spl2022Tokens), TOKEN_2022_PROGRAM_ID, 'Token-2022'],\n  ];\n  for (const [mintToKeys, programId, label] of programFetches) {\n    if (mintToKeys.size === 0) continue;\n    promises.push(\n      connection\n        .getParsedTokenAccountsByOwner(owner, { programId })\n        .then((result) => parseSplTokenAccounts(result.value, mintToKeys))\n        .catch((err) => {\n          logger.warn(`${label} fetch failed on ${group.chainName}`, err);\n          return {};\n        }),\n    );\n  }\n\n  // Fetch native SOL balance once and assign to all native token keys\n  if (group.nativeTokens.length > 0) {\n    promises.push(\n      connection\n        .getBalance(owner)\n        .then((lamports) => {\n          const balance = BigInt(lamports);\n          const out: Record<string, bigint> = {};\n          for (const { key } of group.nativeTokens) {\n            out[key] = balance;\n          }\n          return out;\n        })\n        .catch((err) => {\n          logger.warn(`Native SOL balance failed on ${group.chainName}`, err);\n          return {};\n        }),\n    );\n  }\n\n  const partials = await Promise.all(promises);\n  return Object.assign({}, ...partials);\n}\n"
  },
  {
    "path": "src/features/balances/tokens.ts",
    "content": "import { MultiProtocolProvider, Token } from '@hyperlane-xyz/sdk';\n\nimport { logger } from '../../utils/logger';\n\nexport interface TokenEntry {\n  token: Token;\n  key: string;\n}\n\n/** Fetch a single token balance via SDK fallback. */\nexport async function fetchSdkBalance(\n  token: Token,\n  multiProvider: MultiProtocolProvider,\n  address: string,\n  key: string,\n): Promise<Record<string, bigint>> {\n  try {\n    const balance = await token.getBalance(multiProvider, address);\n    return { [key]: balance.amount };\n  } catch (err) {\n    logger.warn(`Failed to fetch balance for ${key}`, err);\n    return {};\n  }\n}\n"
  },
  {
    "path": "src/features/balances/useFeePrices.ts",
    "content": "import { Token, WarpCoreFeeEstimate } from '@hyperlane-xyz/sdk';\nimport { isNullish } from '@hyperlane-xyz/utils';\nimport { useQuery } from '@tanstack/react-query';\nimport { useMemo } from 'react';\n\nimport { fetchPrices } from '../tokens/useTokenPrice';\n\nconst FEE_PRICE_REFRESH_INTERVAL = 300_000; // 5 min, matches PRICE_STALE_TIME\n\n// Maps fee token symbol to its CoinGecko USD price\nexport type FeePrices = Record<string, number>;\n\n// SDK fee tokens lack coinGeckoId. Resolve it by matching against warp core\n// tokens which carry coinGeckoId from config.\nfunction resolveCoinGeckoId(\n  feeToken: WarpCoreFeeEstimate['localQuote']['token'],\n  knownTokens: Token[],\n  resolvedSymbols: Record<string, string>,\n): string | undefined {\n  if (feeToken.coinGeckoId) return feeToken.coinGeckoId;\n  if (resolvedSymbols[feeToken.symbol]) return resolvedSymbols[feeToken.symbol];\n  for (const token of knownTokens) {\n    if (!token.coinGeckoId) continue;\n    if (token.chainName === feeToken.chainName && token.symbol === feeToken.symbol)\n      return token.coinGeckoId;\n  }\n  // Fall back to symbol-only match (native tokens like ETH across chains share a CoinGecko ID)\n  for (const token of knownTokens) {\n    if (token.symbol === feeToken.symbol && token.coinGeckoId) return token.coinGeckoId;\n  }\n  return undefined;\n}\n\n/**\n * Resolves USD prices for fee tokens. First checks batch prices from useTokenPrices(),\n * then fetches only the missing ones from CoinGecko.\n */\nexport function useFeePrices(\n  fees: WarpCoreFeeEstimate | null,\n  knownTokens: Token[],\n  batchPrices: Record<string, number>,\n): FeePrices {\n  // Fee tokens are native gas tokens whose symbol (e.g. \"ETH\") maps 1:1 to a\n  // CoinGecko ID regardless of chain, so keying by symbol is safe here.\n  const { symbolToId, missingIds } = useMemo(() => {\n    const map: Record<string, string> = {};\n    if (fees) {\n      for (const quote of [fees.localQuote, fees.interchainQuote, fees.tokenFeeQuote]) {\n        if (!quote || quote.amount === 0n) continue;\n        const id = resolveCoinGeckoId(quote.token, knownTokens, map);\n        if (id) map[quote.token.symbol] = id;\n      }\n    }\n    // IDs not already covered by the batch useTokenPrices() fetch\n    const missing = [...new Set(Object.values(map))].filter((id) => !(id in batchPrices));\n    return { symbolToId: map, missingIds: missing };\n  }, [fees, knownTokens, batchPrices]);\n\n  const { data } = useQuery({\n    queryKey: ['useFeePrices', missingIds],\n    queryFn: () => fetchPrices(missingIds),\n    enabled: missingIds.length > 0,\n    staleTime: FEE_PRICE_REFRESH_INTERVAL,\n    refetchInterval: FEE_PRICE_REFRESH_INTERVAL,\n    refetchOnWindowFocus: false,\n    refetchOnMount: false,\n  });\n\n  return useMemo(() => {\n    const result: FeePrices = {};\n    for (const [symbol, id] of Object.entries(symbolToId)) {\n      // Prefer batch price, fall back to separately fetched price\n      const price = batchPrices[id] ?? data?.[id];\n      if (!isNullish(price)) result[symbol] = price;\n    }\n    return result;\n  }, [symbolToId, batchPrices, data]);\n}\n"
  },
  {
    "path": "src/features/balances/utils.ts",
    "content": "import { Token } from '@hyperlane-xyz/sdk';\nimport { fromWeiRounded } from '@hyperlane-xyz/utils';\n\nimport { getTokenKey } from '../tokens/utils';\n\nexport function formatBalance(balance: bigint, decimals: number): string {\n  return fromWeiRounded(balance.toString(), decimals, 4);\n}\n\nexport function formatUsd(value: number, approximate = false): string {\n  if (value < 0.01) return '<$0.01';\n  const prefix = approximate ? '≈$' : '$';\n  return `${prefix}${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;\n}\n\nexport function getUsdValue(\n  token: Token,\n  balances: Record<string, bigint>,\n  prices: Record<string, number>,\n): number | null {\n  const key = getTokenKey(token);\n  const bal = balances[key];\n  if (bal == null || !token.coinGeckoId) return null;\n  const price = prices[token.coinGeckoId];\n  if (price == null) return null;\n  return (Number(bal) / 10 ** token.decimals) * price;\n}\n"
  },
  {
    "path": "src/features/chains/ChainConnectionWarning.test.ts",
    "content": "import { ChainMetadata, isRpcHealthy } from '@hyperlane-xyz/sdk';\nimport { ProtocolType } from '@hyperlane-xyz/utils';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport { checkRpcHealth } from './ChainConnectionWarning';\n\nvi.mock('@hyperlane-xyz/sdk', async (importOriginal) => {\n  const actual = (await importOriginal()) as { MultiProtocolProvider: any };\n  return {\n    ...actual,\n    MultiProtocolProvider: vi.fn().mockImplementation(() => ({\n      getProvider: vi.fn(),\n    })),\n    isRpcHealthy: vi.fn(),\n  };\n});\n\nconst mockRpcUrl = 'http://mock.test.rpc.com';\n\nconst mockEvmChainMetadata: ChainMetadata = {\n  name: 'TestChain',\n  protocol: ProtocolType.Ethereum,\n  rpcUrls: [{ http: mockRpcUrl }, { http: mockRpcUrl }],\n  chainId: 10000000,\n  domainId: 10000000,\n};\nconst mockSvmChainMetadata = {\n  name: 'TestChain',\n  protocol: ProtocolType.Sealevel,\n  rpcUrls: [{ http: mockRpcUrl }, { http: mockRpcUrl }],\n  chainId: 10000000,\n  domainId: 10000000,\n};\n\ndescribe('checkRpcHealth', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  test('should call isRpcHealthy as many times as rpcUrls length when chain protocol is Ethereum', async () => {\n    (isRpcHealthy as ReturnType<typeof vi.fn>).mockImplementation(() => Promise.resolve(true));\n    await checkRpcHealth(mockEvmChainMetadata);\n    expect(isRpcHealthy).toHaveBeenCalledTimes(mockEvmChainMetadata.rpcUrls.length);\n  });\n\n  test('should call isRpcHealthy only once for non Ethereum chains', async () => {\n    (isRpcHealthy as ReturnType<typeof vi.fn>).mockImplementation(() => Promise.resolve(true));\n    await checkRpcHealth(mockSvmChainMetadata);\n    expect(isRpcHealthy).toHaveBeenCalledTimes(1);\n  });\n\n  test('should return true if at least one Ethereum RPC is healthy', async () => {\n    (isRpcHealthy as ReturnType<typeof vi.fn>).mockImplementation((_, i) =>\n      i === 1 ? Promise.resolve(true) : Promise.reject(),\n    );\n    const result = await checkRpcHealth(mockEvmChainMetadata);\n    expect(result).toBe(true);\n  });\n\n  test('should return false if no RPCs are healthy', async () => {\n    (isRpcHealthy as ReturnType<typeof vi.fn>).mockImplementation(() => Promise.resolve(false));\n    const result = await checkRpcHealth(mockEvmChainMetadata as any);\n    expect(result).toBe(false);\n  });\n});\n"
  },
  {
    "path": "src/features/chains/ChainConnectionWarning.tsx",
    "content": "import { ChainMetadata, isRpcHealthy } from '@hyperlane-xyz/sdk';\nimport { ProtocolType } from '@hyperlane-xyz/utils';\nimport { useQuery } from '@tanstack/react-query';\nimport { useState } from 'react';\n\nimport { FormWarningBanner } from '../../components/banner/FormWarningBanner';\nimport { logger } from '../../utils/logger';\nimport { ChainEditModal } from './ChainEditModal';\nimport { useMultiProvider } from './hooks';\nimport { getChainDisplayName } from './utils';\n\nexport function ChainConnectionWarning({\n  origin,\n  destination,\n}: {\n  origin: ChainName;\n  destination: ChainName;\n}) {\n  const multiProvider = useMultiProvider();\n  const [editChainName, setEditChainName] = useState<string | null>(null);\n  const originMetadata = multiProvider.getChainMetadata(origin);\n  const destinationMetadata = multiProvider.getChainMetadata(destination);\n\n  const { data } = useQuery({\n    queryKey: ['ChainConnectionWarning', originMetadata, destinationMetadata],\n    queryFn: async () => {\n      const isOriginHealthy = await checkRpcHealth(originMetadata);\n      const isDestinationHealthy = await checkRpcHealth(destinationMetadata);\n      return { isOriginHealthy, isDestinationHealthy };\n    },\n    refetchInterval: 300000, // 5 minutes\n  });\n\n  const unhealthyChain =\n    data &&\n    ((!data.isOriginHealthy && originMetadata) ||\n      (!data.isDestinationHealthy && destinationMetadata) ||\n      undefined);\n\n  const displayName = getChainDisplayName(\n    multiProvider,\n    unhealthyChain?.name ?? originMetadata.name,\n    true,\n  );\n\n  const onClickEdit = () => {\n    if (!unhealthyChain) return;\n    setEditChainName(unhealthyChain.name);\n  };\n\n  return (\n    <>\n      <FormWarningBanner isVisible={!!unhealthyChain} cta=\"Edit\" onClick={onClickEdit}>\n        {`Connection to ${displayName} is unstable. Consider adding a more reliable RPC URL.`}\n      </FormWarningBanner>\n      {editChainName && (\n        <ChainEditModal\n          isOpen={!!editChainName}\n          close={() => setEditChainName(null)}\n          chainName={editChainName}\n        />\n      )}\n    </>\n  );\n}\n\nexport async function checkRpcHealth(chainMetadata: ChainMetadata) {\n  try {\n    // Note: this currently checks the health of only the first RPC for non EVM chains,\n    // which is what wallets and wallet libs will use\n    // for EVM chains it will use a fallback RPC, that is why we need to check if any RPC are healthy instead\n    if (chainMetadata.protocol === ProtocolType.Ethereum) {\n      const healthChecks = chainMetadata.rpcUrls.map((_, i) =>\n        isRpcHealthy(chainMetadata, i).then((result) => (result ? true : Promise.reject())),\n      );\n      return await Promise.any(healthChecks);\n    } else return await isRpcHealthy(chainMetadata, 0);\n  } catch (error) {\n    if (error instanceof AggregateError)\n      logger.warn(`No healthy RPCs found for ${chainMetadata.name}`);\n    else logger.warn('Error checking RPC health', error);\n    return false;\n  }\n}\n"
  },
  {
    "path": "src/features/chains/ChainEditModal.tsx",
    "content": "import { ChainMetadata } from '@hyperlane-xyz/sdk';\nimport { ChainDetailsMenu, Modal } from '@hyperlane-xyz/widgets';\nimport { useCallback, useEffect, useRef } from 'react';\n\nimport { ModalHeader } from '../../components/layout/ModalHeader';\nimport { observeDarkLogosInContainer } from '../../utils/imageBrightness';\nimport { useStore } from '../store';\n\ninterface Props {\n  isOpen: boolean;\n  close: () => void;\n  chainName: ChainName;\n  /** Called when user clicks the Back button inside ChainDetailsMenu */\n  onClickBack?: () => void;\n}\n\nexport function ChainEditModal({ isOpen, close, chainName, onClickBack }: Props) {\n  const chainMetadata = useStore((s) => s.chainMetadata);\n  const overrides = useStore((s) => s.chainMetadataOverrides[chainName]);\n  const setChainMetadataOverrides = useStore((s) => s.setChainMetadataOverrides);\n\n  const metadata = chainMetadata[chainName];\n\n  const onChangeOverrideMetadata = useCallback(\n    (chainOverrides?: Partial<ChainMetadata>) => {\n      const current = useStore.getState().chainMetadataOverrides;\n      setChainMetadataOverrides({\n        ...current,\n        [chainName]: chainOverrides,\n      });\n    },\n    [chainName, setChainMetadataOverrides],\n  );\n\n  const displayName = metadata?.displayName ?? metadata?.name ?? chainName;\n  const containerRef = useRef<HTMLDivElement>(null);\n  const hasMetadata = !!metadata;\n\n  useEffect(() => {\n    if (!isOpen || !containerRef.current) return;\n    const observer = observeDarkLogosInContainer(containerRef.current);\n    return () => observer?.disconnect();\n  }, [isOpen, chainName, hasMetadata]);\n\n  if (!metadata) return null;\n\n  return (\n    <Modal\n      isOpen={isOpen}\n      close={close}\n      panelClassname=\"chain-picker-modal p-0 max-w-lg overflow-hidden\"\n    >\n      <ModalHeader>{`Edit ${displayName}`}</ModalHeader>\n      <div ref={containerRef} className=\"chain-edit-container max-h-[80vh] overflow-auto p-4\">\n        <ChainDetailsMenu\n          chainMetadata={metadata}\n          overrideChainMetadata={overrides}\n          onChangeOverrideMetadata={onChangeOverrideMetadata}\n          onClickBack={onClickBack ?? close}\n        />\n      </div>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "src/features/chains/ChainFilterPanel.tsx",
    "content": "import { ChainName } from '@hyperlane-xyz/sdk';\nimport { ProtocolType, toTitleCase } from '@hyperlane-xyz/utils';\nimport {\n  ArrowIcon,\n  ChevronIcon,\n  FunnelIcon,\n  PencilIcon,\n  UpDownArrowsIcon,\n  XIcon,\n} from '@hyperlane-xyz/widgets';\nimport { useCallback, useEffect, useRef, useState } from 'react';\n\nimport { SearchInput } from '../../components/input/SearchInput';\nimport { Color } from '../../styles/Color';\nimport {\n  ChainFilterState,\n  FilterTestnet,\n  SortOrder,\n  SortState,\n  defaultFilterState,\n  defaultSortState,\n  isFilterActive,\n  sortOptions,\n} from './chainFilterSort';\nimport { ChainList } from './ChainList';\nimport { ChainInfo } from './hooks';\n\ninterface ChainFilterPanelProps {\n  searchQuery: string;\n  onSearchChange: (s: string) => void;\n  selectedChain: ChainName | null;\n  onSelectChain: (chain: ChainInfo | null) => void;\n  onEditChain?: (chainName: string) => void;\n  showBackButton?: boolean;\n  onBack?: () => void;\n}\n\nexport function ChainFilterPanel({\n  searchQuery,\n  onSearchChange,\n  selectedChain,\n  onSelectChain,\n  onEditChain,\n  showBackButton,\n  onBack,\n}: ChainFilterPanelProps) {\n  const [isEditMode, setIsEditMode] = useState(false);\n  const [filterState, setFilterState] = useState<ChainFilterState>(defaultFilterState);\n  const [sortState, setSortState] = useState<SortState>(defaultSortState);\n\n  const handleChainClick = (chain: ChainInfo | null) => {\n    if (isEditMode && chain && onEditChain) {\n      onEditChain(chain.name);\n    } else {\n      onSelectChain(chain);\n    }\n  };\n\n  const hasActiveFilter = isFilterActive(filterState);\n  const isNonDefaultSort =\n    sortState.sortBy !== defaultSortState.sortBy ||\n    sortState.sortOrder !== defaultSortState.sortOrder;\n\n  return (\n    <div className=\"chain-picker-modal flex w-full flex-col rounded-md bg-gray-100 md:w-[282px]\">\n      <div className=\"relative shrink-0 px-4 py-4\">\n        {showBackButton && (\n          <button\n            type=\"button\"\n            onClick={onBack}\n            className=\"absolute left-2 top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-200 hover:text-gray-700 md:hidden\"\n          >\n            <ChevronIcon direction=\"w\" width={14} height={14} />\n          </button>\n        )}\n        <SearchInput\n          value={searchQuery}\n          onChange={onSearchChange}\n          placeholder=\"Search Chains\"\n          aria-label=\"Search chains\"\n        />\n      </div>\n\n      {/* Toolbar: label + filter/sort/edit icons */}\n      <div className=\"flex items-center justify-between px-4 pb-2\">\n        <h3 className=\"font-secondary text-sm font-normal text-black\">Chain Selection</h3>\n        <div className=\"chain-picker-toolbar flex items-center gap-1\">\n          <FilterButton\n            filterState={filterState}\n            onChange={setFilterState}\n            isActive={hasActiveFilter}\n          />\n          <SortButton sortState={sortState} onChange={setSortState} isActive={isNonDefaultSort} />\n          <button\n            type=\"button\"\n            onClick={() => setIsEditMode((prev) => !prev)}\n            title={isEditMode ? 'Exit edit mode' : 'Edit chain metadata'}\n            className=\"flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-gray-200\"\n          >\n            <PencilIcon\n              width={14}\n              height={14}\n              color={isEditMode ? Color.primary['500'] : Color.gray['500']}\n            />\n          </button>\n        </div>\n      </div>\n\n      <ChainList\n        searchQuery={searchQuery}\n        selectedChain={selectedChain}\n        onSelectChain={handleChainClick}\n        isEditMode={isEditMode}\n        filterState={filterState}\n        sortState={sortState}\n      />\n    </div>\n  );\n}\n\n// ── Filter dropdown ─────────────────────────────────────────────────\nfunction FilterButton({\n  filterState,\n  onChange,\n  isActive,\n}: {\n  filterState: ChainFilterState;\n  onChange: (s: ChainFilterState) => void;\n  isActive: boolean;\n}) {\n  const [isOpen, setIsOpen] = useState(false);\n  const ref = useRef<HTMLDivElement>(null);\n\n  useClickOutside(ref, () => setIsOpen(false));\n\n  return (\n    <div className=\"relative\" ref={ref}>\n      <button\n        type=\"button\"\n        onClick={() => setIsOpen((p) => !p)}\n        title=\"Filter chains\"\n        className=\"flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-gray-200\"\n      >\n        <FunnelIcon\n          width={14}\n          height={14}\n          color={isActive ? Color.primary['500'] : Color.gray['500']}\n        />\n      </button>\n      {isOpen && (\n        <div className=\"absolute right-0 top-full z-20 mt-1 w-56 max-w-[calc(100vw-2rem)] rounded-lg border border-gray-200 bg-white p-3 shadow-md md:left-0 md:right-auto\">\n          <div className=\"mb-3 flex items-center justify-between\">\n            <span className=\"text-xs font-medium text-gray-500\">Filters</span>\n            {isActive && (\n              <button\n                type=\"button\"\n                onClick={() => onChange(defaultFilterState)}\n                className=\"flex items-center gap-1 text-xs text-gray-400 hover:text-gray-600\"\n              >\n                <XIcon width={8} height={8} />\n                Clear\n              </button>\n            )}\n          </div>\n\n          {/* Type filter */}\n          <div className=\"mb-3\">\n            <label className=\"mb-1.5 block text-xs text-gray-500\">Type</label>\n            <div className=\"flex gap-1\">\n              {Object.values(FilterTestnet).map((opt) => (\n                <button\n                  key={opt}\n                  type=\"button\"\n                  onClick={() =>\n                    onChange({ ...filterState, type: filterState.type === opt ? undefined : opt })\n                  }\n                  className={`rounded px-2.5 py-1 text-xs transition-colors ${\n                    filterState.type === opt\n                      ? 'bg-primary-500 text-white'\n                      : 'bg-gray-100 text-gray-600 hover:bg-gray-200'\n                  }`}\n                >\n                  {toTitleCase(opt)}\n                </button>\n              ))}\n            </div>\n          </div>\n\n          {/* Protocol filter */}\n          <div>\n            <label className=\"mb-1.5 block text-xs text-gray-500\">Protocol</label>\n            <div className=\"flex flex-wrap gap-1\">\n              {Object.values(ProtocolType).map((opt) => (\n                <button\n                  key={opt}\n                  type=\"button\"\n                  onClick={() =>\n                    onChange({\n                      ...filterState,\n                      protocol: filterState.protocol === opt ? undefined : opt,\n                    })\n                  }\n                  className={`rounded px-2.5 py-1 text-xs transition-colors ${\n                    filterState.protocol === opt\n                      ? 'bg-primary-500 text-white'\n                      : 'bg-gray-100 text-gray-600 hover:bg-gray-200'\n                  }`}\n                >\n                  {toTitleCase(opt)}\n                </button>\n              ))}\n            </div>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n\n// ── Sort dropdown ───────────────────────────────────────────────────\nfunction SortButton({\n  sortState,\n  onChange,\n  isActive,\n}: {\n  sortState: SortState;\n  onChange: (s: SortState) => void;\n  isActive: boolean;\n}) {\n  const [isOpen, setIsOpen] = useState(false);\n  const ref = useRef<HTMLDivElement>(null);\n\n  useClickOutside(ref, () => setIsOpen(false));\n\n  const toggleOrder = () => {\n    onChange({\n      ...sortState,\n      sortOrder: sortState.sortOrder === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc,\n    });\n  };\n\n  return (\n    <div className=\"relative\" ref={ref}>\n      <button\n        type=\"button\"\n        onClick={() => setIsOpen((p) => !p)}\n        title={`Sort: ${toTitleCase(sortState.sortBy)} (${sortState.sortOrder})`}\n        className=\"flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-gray-200\"\n      >\n        <UpDownArrowsIcon\n          width={14}\n          height={14}\n          color={isActive ? Color.primary['500'] : Color.gray['500']}\n        />\n      </button>\n      {isOpen && (\n        <div className=\"absolute right-0 top-full z-20 mt-1 w-40 rounded-lg border border-gray-200 bg-white py-1 shadow-md\">\n          <div className=\"flex items-center justify-between border-b border-gray-100 px-3 py-1.5\">\n            <span className=\"text-xs font-medium text-gray-500\">Sort by</span>\n            <button\n              type=\"button\"\n              onClick={toggleOrder}\n              title=\"Toggle sort order\"\n              className=\"rounded p-0.5 hover:bg-gray-100\"\n            >\n              <ArrowIcon\n                direction={sortState.sortOrder === SortOrder.Asc ? 'n' : 's'}\n                width={12}\n                height={12}\n              />\n            </button>\n          </div>\n          {sortOptions.map((opt) => (\n            <button\n              key={opt}\n              type=\"button\"\n              onClick={() => {\n                onChange({ sortBy: opt, sortOrder: sortState.sortOrder });\n                setIsOpen(false);\n              }}\n              className={`w-full px-3 py-1.5 text-left text-xs transition-colors hover:bg-gray-100 ${\n                sortState.sortBy === opt ? 'font-medium text-primary-500' : 'text-gray-700'\n              }`}\n            >\n              {toTitleCase(opt)}\n            </button>\n          ))}\n        </div>\n      )}\n    </div>\n  );\n}\n\n// ── Hook: close on outside click ────────────────────────────────────\nfunction useClickOutside(ref: React.RefObject<HTMLElement | null>, handler: () => void) {\n  const handlerRef = useRef(handler);\n  useEffect(() => {\n    handlerRef.current = handler;\n  });\n\n  const onPointerDown = useCallback(\n    (e: PointerEvent) => {\n      if (ref.current && !ref.current.contains(e.target as Node)) {\n        handlerRef.current();\n      }\n    },\n    [ref],\n  );\n\n  useEffect(() => {\n    document.addEventListener('pointerdown', onPointerDown);\n    return () => document.removeEventListener('pointerdown', onPointerDown);\n  }, [onPointerDown]);\n}\n"
  },
  {
    "path": "src/features/chains/ChainList.tsx",
    "content": "import { ChainName } from '@hyperlane-xyz/sdk';\nimport { PencilIcon } from '@hyperlane-xyz/widgets';\nimport { useMemo } from 'react';\n\nimport { ChainLogo } from '../../components/icons/ChainLogo';\nimport { Color } from '../../styles/Color';\nimport {\n  ChainFilterState,\n  SortState,\n  chainSearch,\n  defaultFilterState,\n  defaultSortState,\n} from './chainFilterSort';\nimport { ChainInfo, useChainInfos } from './hooks';\n\ninterface ChainListProps {\n  searchQuery: string;\n  selectedChain: ChainName | null;\n  onSelectChain: (chain: ChainInfo | null) => void;\n  isEditMode?: boolean;\n  filterState?: ChainFilterState;\n  sortState?: SortState;\n}\n\nexport function ChainList({\n  searchQuery,\n  selectedChain,\n  onSelectChain,\n  isEditMode,\n  filterState = defaultFilterState,\n  sortState = defaultSortState,\n}: ChainListProps) {\n  const allChains = useChainInfos();\n\n  const chains = useMemo(\n    () =>\n      chainSearch({ data: allChains, query: searchQuery, sort: sortState, filter: filterState }),\n    [searchQuery, allChains, filterState, sortState],\n  );\n\n  return (\n    <div className=\"relative flex-1 overflow-hidden\">\n      <div className=\"h-full overflow-auto\">\n        {/* All Chains option - hidden in edit mode */}\n        {!isEditMode && (\n          <ChainButton\n            isSelected={selectedChain === null}\n            onClick={() => onSelectChain(null)}\n            icon={\n              <div className=\"flex h-7 w-7 items-center justify-center rounded-full bg-gradient-to-br from-blue-400 to-purple-500 text-[10px] font-bold text-white\">\n                ALL\n              </div>\n            }\n            label=\"All Chains\"\n          />\n        )}\n\n        {/* Individual chains */}\n        {chains.map((chain) => (\n          <ChainButton\n            key={chain.name}\n            chainName={chain.name}\n            isTestnet={chain.isTestnet}\n            isSelected={!isEditMode && selectedChain === chain.name}\n            onClick={() => onSelectChain(chain)}\n            icon={<ChainLogo chainName={chain.name} size={28} />}\n            label={chain.displayName}\n            showEditIcon={isEditMode}\n            disabled={chain.disabled}\n          />\n        ))}\n\n        {chains.length === 0 && (\n          <div className=\"px-4 py-8 text-center text-sm text-gray-500\">No chains found</div>\n        )}\n        {/* Spacer for fade effect */}\n        <div className=\"h-10\" />\n      </div>\n      {/* Bottom fade effect */}\n      <div className=\"chain-picker-fade pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-b from-transparent to-gray-100\" />\n    </div>\n  );\n}\n\nfunction ChainButton({\n  chainName,\n  isTestnet,\n  isSelected,\n  onClick,\n  icon,\n  label,\n  showEditIcon,\n  disabled,\n}: {\n  chainName?: string;\n  isTestnet?: boolean;\n  isSelected: boolean;\n  onClick: () => void;\n  icon: React.ReactNode;\n  label: string;\n  showEditIcon?: boolean;\n  disabled?: boolean;\n}) {\n  return (\n    <button\n      type=\"button\"\n      disabled={disabled}\n      data-chain={chainName}\n      data-is-testnet={chainName ? String(!!isTestnet) : undefined}\n      className={`token-picker-chain-row ${styles.label} flex w-full items-center gap-3 border-l-2 px-4 py-2.5 transition-colors ${\n        disabled\n          ? 'border-transparent opacity-50'\n          : isSelected\n            ? 'border-primary-500 bg-primary-500/10 text-primary-700'\n            : 'border-transparent text-black hover:bg-gray-200'\n      }`}\n      onClick={onClick}\n    >\n      {icon}\n      <span className=\"min-w-0 flex-1 truncate text-sm font-medium\">{label}</span>\n      {showEditIcon && (\n        <span className=\"chain-picker-edit-icon\">\n          <PencilIcon width={14} height={14} color={Color.gray['500']} />\n        </span>\n      )}\n    </button>\n  );\n}\n\nconst styles = {\n  label: 'font-secondary text-sm font-normal',\n};\n"
  },
  {
    "path": "src/features/chains/ChainWalletWarning.tsx",
    "content": "import { toTitleCase } from '@hyperlane-xyz/utils';\nimport {\n  useConnectFns,\n  useDisconnectFns,\n  useWalletDetails,\n} from '@hyperlane-xyz/widgets/walletIntegrations/multiProtocol';\nimport { useMemo } from 'react';\n\nimport { FormWarningBanner } from '../../components/banner/FormWarningBanner';\nimport { config } from '../../consts/config';\nimport { logger } from '../../utils/logger';\nimport { useMultiProvider } from './hooks';\nimport { getChainDisplayName } from './utils';\n\nexport function ChainWalletWarning({ origin }: { origin: ChainName }) {\n  const multiProvider = useMultiProvider();\n\n  const wallets = useWalletDetails();\n  const connectFns = useConnectFns();\n  const disconnectFns = useDisconnectFns();\n\n  const { isVisible, chainDisplayName, walletWhitelist, connectFn, disconnectFn } = useMemo(() => {\n    const protocol = multiProvider.tryGetProtocol(origin);\n    const walletWhitelist = config.chainWalletWhitelists[origin]?.map((w) =>\n      w.trim().toLowerCase(),\n    );\n    if (!protocol || !walletWhitelist?.length)\n      return { isVisible: false, chainDisplayName: '', walletWhitelist: [] };\n\n    const chainDisplayName = getChainDisplayName(multiProvider, origin, true);\n    const walletName = wallets[protocol]?.name?.trim()?.toLowerCase();\n    const connectFn = connectFns[protocol];\n    const disconnectFn = disconnectFns[protocol];\n    const isVisible = !!walletName && !walletWhitelist.includes(walletName);\n\n    return { isVisible, chainDisplayName, walletWhitelist, connectFn, disconnectFn };\n  }, [multiProvider, origin, wallets, connectFns, disconnectFns]);\n\n  const onClickChange = () => {\n    if (!connectFn || !disconnectFn) return;\n    disconnectFn()\n      .then(() => connectFn())\n      .catch((err) => logger.error('Error changing wallet connection', err));\n  };\n\n  return (\n    <FormWarningBanner isVisible={isVisible} cta=\"Change\" onClick={onClickChange}>\n      {`${chainDisplayName} requires one of the following wallets: ${walletWhitelist\n        .map((w) => toTitleCase(w))\n        .join(', ')}`}\n    </FormWarningBanner>\n  );\n}\n"
  },
  {
    "path": "src/features/chains/MobileChainQuickSelect.tsx",
    "content": "import { ChainName } from '@hyperlane-xyz/sdk';\nimport { useMemo } from 'react';\n\nimport { ChainLogo } from '../../components/icons/ChainLogo';\nimport { ChainInfo, useChainInfos } from './hooks';\n\nconst DEFAULT_MAX_VISIBLE_CHAINS = 4;\n\ninterface MobileChainQuickSelectProps {\n  selectedChain: ChainName | null;\n  onSelectChain: (chain: ChainInfo | null) => void;\n  onMoreClick: () => void;\n  /** Optional list of preferred chain names to display first. Remaining slots filled with other chains. */\n  preferredChains?: ChainName[];\n}\n\nexport function MobileChainQuickSelect({\n  selectedChain,\n  onSelectChain,\n  onMoreClick,\n  preferredChains,\n}: MobileChainQuickSelectProps) {\n  const allChains = useChainInfos();\n\n  // Compute visible chains - only recalculates when preferredChains changes\n  const { visibleChains, hasMore } = useMemo(() => {\n    if (preferredChains && preferredChains.length > 0) {\n      const chainNameSet = new Set(allChains.map((c) => c.name));\n      const preferredSet = new Set(preferredChains);\n\n      // Get preferred chains that exist and are enabled, maintaining preferred order\n      const preferred = preferredChains\n        .filter((name) => chainNameSet.has(name))\n        .map((name) => allChains.find((c) => c.name === name))\n        .filter((c): c is ChainInfo => !!c && !c.disabled);\n\n      // Fill remaining slots with non-preferred, non-disabled chains\n      const remaining = allChains.filter((c) => !preferredSet.has(c.name) && !c.disabled);\n      const slotsToFill = DEFAULT_MAX_VISIBLE_CHAINS - preferred.length;\n      const fillers = remaining.slice(0, Math.max(0, slotsToFill));\n\n      const visible = [...preferred, ...fillers];\n\n      return {\n        visibleChains: visible,\n        hasMore: allChains.length > visible.length,\n      };\n    }\n\n    // Otherwise use first N enabled chains\n    const enabledChains = allChains.filter((c) => !c.disabled);\n    return {\n      visibleChains: enabledChains.slice(0, DEFAULT_MAX_VISIBLE_CHAINS),\n      hasMore: enabledChains.length > DEFAULT_MAX_VISIBLE_CHAINS,\n    };\n  }, [allChains, preferredChains]);\n\n  return (\n    <div className=\"flex items-center gap-2\">\n      {/* All Chains pill */}\n      <button\n        type=\"button\"\n        onClick={() => onSelectChain(null)}\n        className={`shrink-0 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${\n          selectedChain === null\n            ? 'bg-blue-100 text-blue-700'\n            : 'bg-gray-100 text-gray-600 hover:bg-gray-200'\n        }`}\n      >\n        All\n      </button>\n\n      {/* Chain icon buttons */}\n      {visibleChains.map((chain) => (\n        <button\n          key={chain.name}\n          type=\"button\"\n          onClick={() => onSelectChain(chain)}\n          className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg transition-colors ${\n            selectedChain === chain.name ? 'bg-blue-100' : 'bg-gray-100 hover:bg-gray-200'\n          }`}\n          title={chain.displayName}\n        >\n          <ChainLogo chainName={chain.name} size={24} />\n        </button>\n      ))}\n\n      {/* More button */}\n      {hasMore && (\n        <button\n          type=\"button\"\n          onClick={onMoreClick}\n          className=\"shrink-0 rounded-lg bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-200\"\n        >\n          More &gt;\n        </button>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/features/chains/addresses.ts",
    "content": "import { ChainAddresses, ChainAddressesSchema, IRegistry } from '@hyperlane-xyz/registry';\nimport { ChainMap, ChainName } from '@hyperlane-xyz/sdk';\nimport { objFilter, objMerge } from '@hyperlane-xyz/utils';\nimport { z } from 'zod';\n\nimport { addresses as ChainAddressesTS } from '../../consts/chainAddresses.ts';\nimport ChainAddressesYaml from '../../consts/chainAddresses.yaml';\nimport { config } from '../../consts/config';\nimport { logger } from '../../utils/logger';\n\nexport async function assembleChainAddresses(\n  chainsInTokens: ChainName[],\n  registry: IRegistry,\n): Promise<ChainMap<ChainAddresses>> {\n  const result = z.record(ChainAddressesSchema).safeParse({\n    ...ChainAddressesYaml,\n    ...ChainAddressesTS,\n  });\n  if (!result.success) {\n    logger.warn('Invalid chain addresses', result.error);\n    throw new Error(`Invalid chain addresses: ${result.error.toString()}`);\n  }\n  const filesystemAddresses = result.data;\n\n  let registryChainAddresses: ChainMap<ChainAddresses> | undefined;\n  if (config.registryUrl) {\n    try {\n      logger.debug('Using custom registry chain addresses from:', config.registryUrl);\n      registryChainAddresses = await registry.getAddresses();\n    } catch (error) {\n      logger.warn(\n        'Failed fetching chain addresses from GH registry, using published addresses',\n        config.registryUrl,\n        error,\n      );\n    }\n  } else {\n    logger.debug('Using default published registry for chain addresses');\n  }\n  if (!registryChainAddresses) {\n    registryChainAddresses = (await import('@hyperlane-xyz/registry')).chainAddresses;\n  }\n\n  // Filter to only chains referenced by the configured warp routes\n  registryChainAddresses = objFilter(registryChainAddresses, (c, _a): _a is ChainAddresses =>\n    chainsInTokens.includes(c),\n  );\n\n  // Filesystem entries override registry entries per key\n  return objMerge<ChainMap<ChainAddresses>>(registryChainAddresses, filesystemAddresses);\n}\n"
  },
  {
    "path": "src/features/chains/chainFilterSort.test.ts",
    "content": "import { ProtocolType } from '@hyperlane-xyz/utils';\nimport { describe, expect, it } from 'vitest';\n\nimport {\n  ChainSortBy,\n  FilterTestnet,\n  SortOrder,\n  chainSearch,\n  defaultFilterState,\n  defaultSortState,\n} from './chainFilterSort';\nimport { ChainInfo } from './hooks';\n\nconst makeChain = (overrides: Partial<ChainInfo> & { name: string }): ChainInfo => ({\n  displayName: overrides.name,\n  chainId: 1,\n  protocol: ProtocolType.Ethereum,\n  isTestnet: false,\n  disabled: false,\n  ...overrides,\n});\n\nconst chains: ChainInfo[] = [\n  makeChain({ name: 'ethereum', displayName: 'Ethereum', chainId: 1 }),\n  makeChain({ name: 'polygon', displayName: 'Polygon', chainId: 137 }),\n  makeChain({ name: 'arbitrum', displayName: 'Arbitrum', chainId: 42161 }),\n  makeChain({\n    name: 'solanamainnet',\n    displayName: 'Solana',\n    chainId: 1399811149,\n    protocol: ProtocolType.Sealevel,\n  }),\n  makeChain({\n    name: 'cosmoshub',\n    displayName: 'Cosmos Hub',\n    chainId: 'cosmoshub-4',\n    protocol: ProtocolType.Cosmos,\n  }),\n  makeChain({ name: 'sepolia', displayName: 'Sepolia', chainId: 11155111, isTestnet: true }),\n  makeChain({ name: 'disabled-chain', displayName: 'Disabled', chainId: 999, disabled: true }),\n];\n\nconst search = (overrides: {\n  query?: string;\n  sort?: { sortBy: ChainSortBy; sortOrder: SortOrder };\n  filter?: { type?: FilterTestnet; protocol?: ProtocolType };\n}) =>\n  chainSearch({\n    data: chains,\n    query: overrides.query ?? '',\n    sort: overrides.sort ?? defaultSortState,\n    filter: overrides.filter ?? defaultFilterState,\n  });\n\ndescribe('chainSearch', () => {\n  describe('query', () => {\n    it('returns all chains with empty query', () => {\n      expect(search({})).toHaveLength(chains.length);\n    });\n\n    it('matches by name', () => {\n      const result = search({ query: 'polygon' });\n      expect(result).toHaveLength(1);\n      expect(result[0].name).toBe('polygon');\n    });\n\n    it('matches by displayName case-insensitively', () => {\n      const result = search({ query: 'SOLANA' });\n      expect(result).toHaveLength(1);\n      expect(result[0].name).toBe('solanamainnet');\n    });\n\n    it('matches by chainId', () => {\n      const result = search({ query: '137' });\n      expect(result).toHaveLength(1);\n      expect(result[0].name).toBe('polygon');\n    });\n\n    it('matches broadly by chainId substring', () => {\n      // '1' appears in chainIds: 1, 137, 42161, 1399811149, 11155111, 999 → all except 999\n      const result = search({ query: '1' });\n      expect(result.map((c) => c.name)).not.toContain('disabled-chain');\n      expect(result.length).toBeGreaterThan(1);\n    });\n  });\n\n  describe('filter', () => {\n    it('filters by testnet', () => {\n      const result = search({ filter: { type: FilterTestnet.Testnet } });\n      expect(result.every((c) => c.isTestnet)).toBe(true);\n    });\n\n    it('filters by mainnet', () => {\n      const result = search({ filter: { type: FilterTestnet.Mainnet } });\n      expect(result.every((c) => !c.isTestnet)).toBe(true);\n    });\n\n    it('filters by protocol', () => {\n      const result = search({ filter: { protocol: ProtocolType.Sealevel } });\n      expect(result).toHaveLength(1);\n      expect(result[0].name).toBe('solanamainnet');\n    });\n\n    it('combines type and protocol filters', () => {\n      const result = search({\n        filter: { type: FilterTestnet.Mainnet, protocol: ProtocolType.Ethereum },\n      });\n      expect(result.every((c) => !c.isTestnet && c.protocol === ProtocolType.Ethereum)).toBe(true);\n    });\n  });\n\n  describe('sort', () => {\n    it('sorts by name ascending (default)', () => {\n      const result = search({});\n      const names = result.filter((c) => !c.disabled).map((c) => c.name);\n      expect(names).toEqual([\n        'arbitrum',\n        'cosmoshub',\n        'ethereum',\n        'polygon',\n        'sepolia',\n        'solanamainnet',\n      ]);\n    });\n\n    it('sorts by name descending', () => {\n      const result = search({\n        sort: { sortBy: ChainSortBy.Name, sortOrder: SortOrder.Desc },\n      });\n      const names = result.filter((c) => !c.disabled).map((c) => c.name);\n      expect(names).toEqual([\n        'solanamainnet',\n        'sepolia',\n        'polygon',\n        'ethereum',\n        'cosmoshub',\n        'arbitrum',\n      ]);\n    });\n\n    it('sorts by chainId ascending', () => {\n      const result = search({\n        sort: { sortBy: ChainSortBy.ChainId, sortOrder: SortOrder.Asc },\n      });\n      // Numeric sort: 1 < 137 < 42161 < ... then string IDs like \"cosmoshub-4\"\n      const ids = result.filter((c) => !c.disabled).map((c) => c.chainId.toString());\n      expect(ids).toEqual(['1', '137', '42161', '11155111', '1399811149', 'cosmoshub-4']);\n    });\n\n    it('sorts by protocol', () => {\n      const result = search({\n        sort: { sortBy: ChainSortBy.Protocol, sortOrder: SortOrder.Asc },\n      });\n      const protocols = result.filter((c) => !c.disabled).map((c) => c.protocol);\n      expect(protocols).toEqual([\n        ProtocolType.Cosmos,\n        ProtocolType.Ethereum,\n        ProtocolType.Ethereum,\n        ProtocolType.Ethereum,\n        ProtocolType.Ethereum,\n        ProtocolType.Sealevel,\n      ]);\n    });\n\n    it('sorts disabled chains to the bottom', () => {\n      const result = search({});\n      const lastChain = result[result.length - 1];\n      expect(lastChain.disabled).toBe(true);\n      expect(lastChain.name).toBe('disabled-chain');\n    });\n  });\n});\n"
  },
  {
    "path": "src/features/chains/chainFilterSort.ts",
    "content": "import { ProtocolType } from '@hyperlane-xyz/utils';\n\nimport { ChainInfo } from './hooks';\n\n// ── Sort ────────────────────────────────────────────────────────────\nexport enum ChainSortBy {\n  Name = 'name',\n  ChainId = 'chain id',\n  Protocol = 'protocol',\n}\n\nexport enum SortOrder {\n  Asc = 'asc',\n  Desc = 'desc',\n}\n\nexport interface SortState {\n  sortBy: ChainSortBy;\n  sortOrder: SortOrder;\n}\n\nexport const defaultSortState: SortState = {\n  sortBy: ChainSortBy.Name,\n  sortOrder: SortOrder.Asc,\n};\n\n// ── Filter ──────────────────────────────────────────────────────────\nexport enum FilterTestnet {\n  Testnet = 'testnet',\n  Mainnet = 'mainnet',\n}\n\nexport interface ChainFilterState {\n  type?: FilterTestnet;\n  protocol?: ProtocolType;\n}\n\nexport const defaultFilterState: ChainFilterState = {\n  type: undefined,\n  protocol: undefined,\n};\n\nexport function isFilterActive(filter: ChainFilterState): boolean {\n  return filter.type !== undefined || filter.protocol !== undefined;\n}\n\nexport const sortOptions = [ChainSortBy.Name, ChainSortBy.ChainId, ChainSortBy.Protocol];\n\n// ── Combined search + filter + sort (mirrors widgets' chainSearch) ──\nexport function chainSearch({\n  data,\n  query,\n  sort,\n  filter,\n}: {\n  data: ChainInfo[];\n  query: string;\n  sort: SortState;\n  filter: ChainFilterState;\n}): ChainInfo[] {\n  const q = query.trim().toLowerCase();\n  return (\n    data\n      // Query search\n      .filter(\n        (chain) =>\n          !q ||\n          chain.name.toLowerCase().includes(q) ||\n          chain.displayName.toLowerCase().includes(q) ||\n          chain.chainId.toString().toLowerCase().includes(q),\n      )\n      // Filter options\n      .filter((chain) => {\n        let included = true;\n        if (filter.type) {\n          included &&= chain.isTestnet === (filter.type === FilterTestnet.Testnet);\n        }\n        if (filter.protocol) {\n          included &&= chain.protocol === filter.protocol;\n        }\n        return included;\n      })\n      // Sort options\n      .sort((c1, c2) => {\n        // Disabled chains always at the bottom\n        if (c1.disabled && !c2.disabled) return 1;\n        if (!c1.disabled && c2.disabled) return -1;\n\n        if (sort.sortBy === ChainSortBy.ChainId) {\n          const result = c1.chainId\n            .toString()\n            .localeCompare(c2.chainId.toString(), undefined, { numeric: true });\n          return sort.sortOrder === SortOrder.Asc ? result : -result;\n        }\n\n        let v1 = c1.name;\n        let v2 = c2.name;\n        if (sort.sortBy === ChainSortBy.Protocol) {\n          v1 = c1.protocol;\n          v2 = c2.protocol;\n        }\n        return sort.sortOrder === SortOrder.Asc ? v1.localeCompare(v2) : v2.localeCompare(v1);\n      })\n  );\n}\n"
  },
  {
    "path": "src/features/chains/hooks.ts",
    "content": "import { ChainName, ChainStatus } from '@hyperlane-xyz/sdk';\nimport { ProtocolType } from '@hyperlane-xyz/utils';\nimport { useMemo } from 'react';\n\nimport { config } from '../../consts/config';\nimport { useStore } from '../store';\nimport { getChainDisplayName } from './utils';\n\nexport function useMultiProvider() {\n  return useStore((s) => s.multiProvider);\n}\n\n// Ensures that the multiProvider has been populated during the onRehydrateStorage hook above,\n// otherwise returns undefined\nexport function useReadyMultiProvider() {\n  const multiProvider = useMultiProvider();\n  if (!multiProvider.getKnownChainNames().length) return undefined;\n  return multiProvider;\n}\n\nexport function useChainMetadata(chainName?: ChainName) {\n  const multiProvider = useMultiProvider();\n  if (!chainName) return undefined;\n  return multiProvider.tryGetChainMetadata(chainName);\n}\n\nexport function useChainProtocol(chainName?: ChainName) {\n  const metadata = useChainMetadata(chainName);\n  return metadata?.protocol;\n}\n\nexport function useChainDisplayName(chainName: ChainName, shortName = false) {\n  const multiProvider = useMultiProvider();\n  return getChainDisplayName(multiProvider, chainName, shortName);\n}\n\nexport interface ChainInfo {\n  name: string;\n  displayName: string;\n  chainId: ChainId;\n  protocol: ProtocolType;\n  isTestnet: boolean;\n  disabled: boolean;\n}\n\nexport function useChainInfos(): ChainInfo[] {\n  const chainMetadata = useStore((s) => s.chainMetadata);\n\n  return useMemo(() => {\n    const chainInfos = Object.values(chainMetadata).map((chain) => ({\n      name: chain.name,\n      displayName: chain.displayName || chain.name,\n      chainId: chain.chainId,\n      protocol: chain.protocol,\n      isTestnet: !!chain.isTestnet,\n      disabled: config.shouldDisableChains && chain.availability?.status === ChainStatus.Disabled,\n    }));\n    return chainInfos;\n  }, [chainMetadata]);\n}\n\nexport function useDisabledChains(): Set<string> {\n  const chainInfos = useChainInfos();\n  return useMemo(\n    () => new Set(chainInfos.filter((c) => c.disabled).map((c) => c.name)),\n    [chainInfos],\n  );\n}\n"
  },
  {
    "path": "src/features/chains/metadata.ts",
    "content": "import { IRegistry } from '@hyperlane-xyz/registry';\nimport {\n  ChainMap,\n  ChainMetadata,\n  ChainMetadataSchema,\n  mergeChainMetadataMap,\n  RpcUrlSchema,\n} from '@hyperlane-xyz/sdk';\nimport {\n  objFilter,\n  objMap,\n  promiseObjAll,\n  ProtocolType,\n  tryParseJsonOrYaml,\n} from '@hyperlane-xyz/utils';\nimport { z } from 'zod';\n\nimport { chains as ChainsTS } from '../../consts/chains.ts';\nimport ChainsYaml from '../../consts/chains.yaml';\nimport { config } from '../../consts/config.ts';\nimport { links } from '../../consts/links.ts';\nimport { logger } from '../../utils/logger.ts';\n\nexport async function assembleChainMetadata(\n  chainsInTokens: ChainName[],\n  registry: IRegistry,\n  storeMetadataOverrides?: ChainMap<Partial<ChainMetadata | undefined>>,\n) {\n  // Chains must include a cosmos chain or CosmosKit throws errors\n  const result = z.record(ChainMetadataSchema).safeParse({\n    ...ChainsYaml,\n    ...ChainsTS,\n  });\n  if (!result.success) {\n    logger.warn('Invalid chain metadata', result.error);\n    throw new Error(`Invalid chain metadata: ${result.error.toString()}`);\n  }\n  const filesystemMetadata = result.data as ChainMap<ChainMetadata>;\n\n  let registryChainMetadata: ChainMap<ChainMetadata> | undefined;\n  if (config.registryUrl) {\n    try {\n      logger.debug('Using custom registry chain metadata from:', config.registryUrl);\n      registryChainMetadata = await registry.getMetadata();\n    } catch (error) {\n      logger.warn(\n        'Failed fetching chain metadata from GH registry, using published registry',\n        config.registryUrl,\n        error,\n      );\n    }\n  } else {\n    logger.debug('Using default published registry for chain metadata');\n  }\n  if (!registryChainMetadata) {\n    registryChainMetadata = (await import('@hyperlane-xyz/registry')).chainMetadata;\n  }\n\n  // Filter out chains that are not in the tokens config\n  registryChainMetadata = objFilter(registryChainMetadata, (c, m): m is ChainMetadata =>\n    chainsInTokens.includes(c),\n  );\n\n  // TODO have the registry do this automatically\n  registryChainMetadata = await promiseObjAll(\n    objMap(\n      registryChainMetadata,\n      async (chainName, metadata): Promise<ChainMetadata> => ({\n        ...metadata,\n        logoURI: `${links.imgPath}/chains/${chainName}/logo.svg`,\n      }),\n    ),\n  );\n  const mergedChainMetadata = mergeChainMetadataMap(registryChainMetadata, filesystemMetadata);\n\n  const parsedRpcOverridesResult = tryParseJsonOrYaml(config.rpcOverrides);\n  const rpcOverrides = z\n    .record(RpcUrlSchema)\n    .safeParse(parsedRpcOverridesResult.success && parsedRpcOverridesResult.data);\n  if (config.rpcOverrides && !rpcOverrides.success) {\n    logger.warn('Invalid RPC overrides config', rpcOverrides.error);\n  }\n\n  const chainMetadata = objMap(mergedChainMetadata, (chainName, metadata) => {\n    const overridesUrl =\n      rpcOverrides.success && rpcOverrides.data[chainName]\n        ? rpcOverrides.data[chainName]\n        : undefined;\n\n    if (!overridesUrl) return metadata;\n\n    // Only EVM supports fallback transport, so we are putting the override at the end\n    const rpcUrls =\n      metadata.protocol === ProtocolType.Ethereum\n        ? [...metadata.rpcUrls, overridesUrl]\n        : [overridesUrl, ...metadata.rpcUrls];\n\n    return { ...metadata, rpcUrls };\n  });\n\n  const chainMetadataWithOverrides = mergeChainMetadataMap(chainMetadata, storeMetadataOverrides);\n  return { chainMetadata, chainMetadataWithOverrides };\n}\n"
  },
  {
    "path": "src/features/chains/utils.ts",
    "content": "import { isAbacusWorksChain } from '@hyperlane-xyz/registry';\nimport { ChainMap, ChainMetadata, ChainStatus, WarpCore } from '@hyperlane-xyz/sdk';\nimport { toTitleCase, trimToLength } from '@hyperlane-xyz/utils';\nimport { ChainSearchMenuProps } from '@hyperlane-xyz/widgets';\n\nimport { config } from '../../consts/config';\n\ntype ChainMetadataProvider = Pick<\n  WarpCore['multiProvider'],\n  'metadata' | 'tryGetChainMetadata' | 'tryGetChainName'\n>;\n\nexport function getChainDisplayName(\n  multiProvider: ChainMetadataProvider,\n  chain: ChainName,\n  shortName = false,\n) {\n  if (!chain) return 'Unknown';\n  const metadata = multiProvider.tryGetChainMetadata(chain);\n  if (!metadata) return 'Unknown';\n  const displayName = shortName ? metadata.displayNameShort : metadata.displayName;\n  return displayName || metadata.displayName || toTitleCase(metadata.name);\n}\n\nexport function isPermissionlessChain(multiProvider: ChainMetadataProvider, chain: ChainName) {\n  if (!chain) return true;\n  const metadata = multiProvider.tryGetChainMetadata(chain);\n  return !metadata || !isAbacusWorksChain(metadata);\n}\n\nexport function hasPermissionlessChain(multiProvider: ChainMetadataProvider, ids: ChainName[]) {\n  return !ids.every((c) => !isPermissionlessChain(multiProvider, c));\n}\n\n/**\n * Returns an object that contains the amount of\n * routes from a single chain to every other chain\n */\nexport function getNumRoutesWithSelectedChain(\n  warpCore: WarpCore,\n  selectedChain: ChainName,\n  isSelectedChainOrigin: boolean,\n): ChainSearchMenuProps['customListItemField'] {\n  const multiProvider = warpCore.multiProvider;\n  const chains = multiProvider.metadata;\n  const selectedChainDisplayName = trimToLength(\n    getChainDisplayName(multiProvider, selectedChain, true),\n    10,\n  );\n\n  const data = Object.keys(chains).reduce<ChainMap<{ display: string; sortValue: number }>>(\n    (result, otherChain) => {\n      const origin = isSelectedChainOrigin ? selectedChain : otherChain;\n      const destination = isSelectedChainOrigin ? otherChain : selectedChain;\n      const tokens = warpCore.getTokensForRoute(origin, destination).length;\n      result[otherChain] = {\n        display: `${tokens} route${tokens > 1 ? 's' : ''}`,\n        sortValue: tokens,\n      };\n\n      return result;\n    },\n    {},\n  );\n\n  const preposition = isSelectedChainOrigin ? 'from' : 'to';\n  return {\n    header: `Routes ${preposition} ${selectedChainDisplayName}`,\n    data,\n  };\n}\n\nexport function isChainDisabled(chainMetadata: ChainMetadata | null) {\n  if (!config.shouldDisableChains || !chainMetadata) return false;\n\n  return chainMetadata.availability?.status === ChainStatus.Disabled;\n}\n\n/**\n * Return given chainName if it is valid, otherwise return undefined\n */\nexport function tryGetValidChainName(\n  chainName: string | null,\n  multiProvider: ChainMetadataProvider,\n): string | undefined {\n  const validChainName = chainName && multiProvider.tryGetChainName(chainName);\n  const chainMetadata = validChainName ? multiProvider.tryGetChainMetadata(chainName) : null;\n  const chainDisabled = isChainDisabled(chainMetadata);\n\n  if (chainDisabled) return undefined;\n\n  return validChainName ? chainName : undefined;\n}\n"
  },
  {
    "path": "src/features/limits/const.ts",
    "content": "import { RouteLimit } from './types';\n\nexport const multiCollateralTokenLimits: RouteLimit[] = [];\n"
  },
  {
    "path": "src/features/limits/types.ts",
    "content": "export type RouteLimit = {\n  symbol: string;\n  amountWei: bigint;\n  chains: ChainName[];\n};\n"
  },
  {
    "path": "src/features/limits/utils.test.ts",
    "content": "import { TestChainName, TokenStandard } from '@hyperlane-xyz/sdk';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport { createMockToken, createTokenConnectionMock } from '../../utils/test';\nimport { RouteLimit } from './types';\nimport { getMultiCollateralTokenLimit, isMultiCollateralLimitExceeded } from './utils';\n\nconst mockLimits: RouteLimit[] = [\n  {\n    amountWei: 100000n,\n    chains: [TestChainName.test1, TestChainName.test2],\n    symbol: 'FAKE',\n  },\n];\n\nbeforeEach(() => {\n  vi.restoreAllMocks();\n});\n\ndescribe('getMultiCollateralTokenLimit', () => {\n  test('returns null if destinationToken is not collateralized', () => {\n    const token = createMockToken({ connections: [createTokenConnectionMock()] });\n    const destToken = createMockToken({\n      chainName: TestChainName.test3,\n      standard: TokenStandard.CosmosIbc,\n    });\n    expect(getMultiCollateralTokenLimit(token, destToken, mockLimits)).toBeNull();\n  });\n\n  test('should return null if tokens are not multi-collateral', () => {\n    const token = createMockToken({\n      connections: [createTokenConnectionMock()],\n      standard: TokenStandard.CosmosIbc,\n    });\n    const destToken = createMockToken({ chainName: TestChainName.test2 });\n    expect(getMultiCollateralTokenLimit(token, destToken, mockLimits)).toBeNull();\n  });\n\n  test('should return null if no matching limit in routeLimits', () => {\n    const token = createMockToken({\n      symbol: 'NOMATCH',\n      connections: [createTokenConnectionMock()],\n    });\n    const destToken = createMockToken({ chainName: TestChainName.test2 });\n    expect(getMultiCollateralTokenLimit(token, destToken, mockLimits)).toBeNull();\n  });\n\n  test('should return the correct limit if token pair matches', () => {\n    const token = createMockToken({\n      connections: [createTokenConnectionMock()],\n    });\n    const destToken = createMockToken({ chainName: TestChainName.test2 });\n    expect(getMultiCollateralTokenLimit(token, destToken, mockLimits)).toEqual(mockLimits[0]);\n  });\n});\n\ndescribe('isMultiCollateralLimitExceeded', () => {\n  test('should return null if limit is not exceeded', () => {\n    const token = createMockToken({\n      connections: [createTokenConnectionMock()],\n    });\n    const destToken = createMockToken({ chainName: TestChainName.test2 });\n    expect(isMultiCollateralLimitExceeded(token, destToken, '1000', mockLimits)).toBeNull();\n  });\n\n  test('should return the limit if exceeded', () => {\n    const token = createMockToken({\n      connections: [createTokenConnectionMock()],\n    });\n    const destToken = createMockToken({ chainName: TestChainName.test2 });\n    expect(isMultiCollateralLimitExceeded(token, destToken, '10000000', mockLimits)).toEqual(\n      BigInt(mockLimits[0].amountWei),\n    );\n  });\n});\n"
  },
  {
    "path": "src/features/limits/utils.ts",
    "content": "import { IToken, Token } from '@hyperlane-xyz/sdk';\n\nimport { isValidMultiCollateralToken } from '../tokens/utils';\nimport { multiCollateralTokenLimits } from './const';\nimport { RouteLimit } from './types';\n\nexport function getMultiCollateralTokenLimit(\n  originToken: Token | IToken,\n  destinationToken: Token | IToken,\n  routeLimits: RouteLimit[] = multiCollateralTokenLimits,\n) {\n  if (!isValidMultiCollateralToken(originToken, destinationToken)) return null;\n\n  const limitExists = routeLimits.find((limit) => {\n    if (limit.symbol !== originToken.symbol || limit.symbol !== destinationToken.symbol)\n      return false;\n\n    return (\n      limit.chains.includes(originToken.chainName) &&\n      limit.chains.includes(destinationToken.chainName)\n    );\n  });\n\n  return limitExists || null;\n}\n\nexport function isMultiCollateralLimitExceeded(\n  originToken: Token | IToken,\n  destinationToken: Token | IToken,\n  amountWei: string,\n  routeLimits: RouteLimit[] = multiCollateralTokenLimits,\n): bigint | null {\n  const limitExists = getMultiCollateralTokenLimit(originToken, destinationToken, routeLimits);\n\n  if (!limitExists) return null;\n\n  return BigInt(amountWei) > limitExists.amountWei ? limitExists.amountWei : null;\n}\n"
  },
  {
    "path": "src/features/messages/graphqlClient.ts",
    "content": "import { config } from '../../consts/config';\n\nexport type GraphQLResult<T> = { type: 'success'; data: T } | { type: 'error'; error: Error };\n\nexport async function executeGraphQLQuery<T = unknown>(\n  query: string,\n  variables: Record<string, unknown>,\n): Promise<GraphQLResult<T>> {\n  try {\n    const response = await fetch(config.explorerApiUrl, {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n      },\n      body: JSON.stringify({ query, variables }),\n    });\n\n    if (!response.ok) {\n      return { type: 'error', error: new Error(`HTTP error: ${response.status}`) };\n    }\n\n    const result = await response.json();\n\n    const [err] = result.errors ?? [];\n    if (err) {\n      return { type: 'error', error: new Error(err.message) };\n    }\n\n    return { type: 'success', data: result.data };\n  } catch (err) {\n    return { type: 'error', error: err instanceof Error ? err : new Error('Unknown error') };\n  }\n}\n"
  },
  {
    "path": "src/features/messages/queries/build.ts",
    "content": "import { addressToPostgresBytea, stringToPostgresBytea } from './encoding';\nimport { messageDetailFragment, messageStubFragment } from './fragments';\n\n// Query defined at module level to avoid recreation on each call\nconst MESSAGE_HISTORY_QUERY = `\n  query MessageHistory($wallets: [bytea!]!, $warpRoutes: [bytea!]!, $limit: Int!, $offset: Int!) @cached(ttl: 5) {\n    message_view(\n      limit: $limit,\n      offset: $offset,\n      order_by: {send_occurred_at: desc},\n      where: {\n        _and: [\n          {origin_tx_sender: {_in: $wallets}},\n          {_or: [\n            {sender: {_in: $warpRoutes}},\n            {recipient: {_in: $warpRoutes}}\n          ]}\n        ]\n      }\n    ) {\n      ${messageStubFragment}\n    }\n  }\n`;\n\n/**\n * Build variables for the message history query\n * Returns null if addresses are invalid or empty\n */\nexport function buildMessageHistoryQuery(\n  walletAddresses: string[],\n  warpRouteAddresses: string[],\n  limit: number,\n  offset: number,\n): { query: string; variables: Record<string, unknown> } | null {\n  if (!walletAddresses.length || !warpRouteAddresses.length) return null;\n\n  // Convert wallet addresses to bytea format\n  const walletBytea = walletAddresses\n    .map((addr) => {\n      try {\n        return addressToPostgresBytea(addr);\n      } catch {\n        return null;\n      }\n    })\n    .filter((addr): addr is string => !!addr);\n\n  // Convert warp route addresses to bytea format\n  const warpRouteBytea = warpRouteAddresses\n    .map((addr) => {\n      try {\n        return addressToPostgresBytea(addr);\n      } catch {\n        return null;\n      }\n    })\n    .filter((addr): addr is string => !!addr);\n\n  if (!walletBytea.length || !warpRouteBytea.length) return null;\n\n  return {\n    query: MESSAGE_HISTORY_QUERY,\n    variables: {\n      wallets: walletBytea,\n      warpRoutes: warpRouteBytea,\n      limit,\n      offset,\n    },\n  };\n}\n\nconst MESSAGE_BY_ID_QUERY = `\n  query MessageById($msgId: bytea!) @cached(ttl: 5) {\n    message_view(\n      where: {msg_id: {_eq: $msgId}},\n      limit: 1\n    ) {\n      ${messageDetailFragment}\n    }\n  }\n`;\n\n/**\n * Build a query to fetch a single message by its Hyperlane message ID\n */\nexport function buildMessageByIdQuery(msgId: string): {\n  query: string;\n  variables: { msgId: string };\n} {\n  return {\n    query: MESSAGE_BY_ID_QUERY,\n    variables: {\n      msgId: stringToPostgresBytea(msgId),\n    },\n  };\n}\n"
  },
  {
    "path": "src/features/messages/queries/encoding.ts",
    "content": "import type { ChainMetadata } from '@hyperlane-xyz/sdk';\nimport {\n  addressToByteHexString,\n  bufferToBase58,\n  bytesToProtocolAddress,\n  ensure0x,\n  isAddressEvm,\n  ProtocolType,\n  strip0x,\n} from '@hyperlane-xyz/utils';\n\nexport function stringToPostgresBytea(hexString: string): string {\n  const trimmed = strip0x(hexString).toLowerCase();\n  return `\\\\x${trimmed}`;\n}\n\nexport function postgresByteaToString(byteString: string): string {\n  if (!byteString || byteString.length < 4) throw new Error('Invalid byte string');\n  return ensure0x(byteString.substring(2));\n}\n\nexport function addressToPostgresBytea(address: string): string {\n  const hexString = isAddressEvm(address) ? address : addressToByteHexString(address);\n  return stringToPostgresBytea(hexString);\n}\n\nexport function postgresByteaToAddress(\n  byteString: string,\n  chainMetadata: ChainMetadata | null | undefined,\n): string {\n  const hexString = postgresByteaToString(byteString);\n  if (!chainMetadata) return hexString;\n  const addressBytes = Buffer.from(strip0x(hexString), 'hex');\n  return bytesToProtocolAddress(addressBytes, chainMetadata.protocol, chainMetadata.bech32Prefix);\n}\n\nexport function postgresByteaToTxHash(\n  byteString: string,\n  chainMetadata: ChainMetadata | null | undefined,\n): string {\n  const hexString = postgresByteaToString(byteString);\n  if (chainMetadata?.protocol !== ProtocolType.Sealevel) return hexString;\n  const bytes = Buffer.from(strip0x(hexString), 'hex');\n  return bufferToBase58(bytes);\n}\n\nexport function parseTimestamp(t: string): number {\n  const asUtc = t.at(-1) === 'Z' ? t : t + 'Z';\n  return new Date(asUtc).getTime();\n}\n"
  },
  {
    "path": "src/features/messages/queries/fragments.ts",
    "content": "/**\n * GraphQL fragments for message queries\n */\nexport const messageStubFragment = `\n  id\n  msg_id\n  nonce\n  sender\n  recipient\n  is_delivered\n  send_occurred_at\n  delivery_occurred_at\n  origin_chain_id\n  origin_domain_id\n  origin_tx_hash\n  origin_tx_sender\n  origin_tx_recipient\n  destination_chain_id\n  destination_domain_id\n  destination_tx_hash\n  destination_tx_sender\n  destination_tx_recipient\n  message_body\n`;\n\n/**\n * Extended fragment for single message detail queries (includes block height for stage tracking)\n */\nexport const messageDetailFragment = `\n  ${messageStubFragment}\n  origin_block_height\n`;\n\n/**\n * Raw message entry from GraphQL\n */\nexport interface MessageStubEntry {\n  id: number;\n  msg_id: string; // bytea e.g. \\\\x123\n  nonce: number;\n  sender: string; // bytea\n  recipient: string; // bytea\n  is_delivered: boolean;\n  send_occurred_at: string; // e.g. \"2022-08-28T17:30:15\"\n  delivery_occurred_at: string | null;\n  origin_chain_id: number;\n  origin_domain_id: number;\n  origin_tx_hash: string; // bytea\n  origin_tx_sender: string; // bytea\n  origin_tx_recipient: string; // bytea\n  destination_chain_id: number;\n  destination_domain_id: number;\n  destination_tx_hash: string | null; // bytea\n  destination_tx_sender: string | null; // bytea\n  destination_tx_recipient: string | null; // bytea\n  message_body: string | null; // bytea - contains recipient and amount for warp transfers\n  origin_block_height?: number; // Only present in detail queries\n}\n"
  },
  {
    "path": "src/features/messages/types.ts",
    "content": "import type { Address, ChainId } from '@hyperlane-xyz/utils';\n\nexport enum MessageStatus {\n  Unknown = 'unknown',\n  Pending = 'pending',\n  Delivered = 'delivered',\n  Failing = 'failing',\n}\n\nexport interface MessageTxStub {\n  timestamp: number;\n  hash: string;\n  from: Address;\n  to: Address;\n}\n\nexport interface WarpTransferInfo {\n  recipient: Address; // Actual recipient from message body\n  amount: string; // Raw amount from message body (needs decimals for display)\n}\n\nexport interface MessageStub {\n  status: MessageStatus;\n  id: string; // Database id\n  msgId: string; // Message hash\n  nonce: number;\n  sender: Address; // Warp route address (contract)\n  recipient: Address; // Warp route address (contract)\n  originChainId: ChainId;\n  originDomainId: number;\n  destinationChainId: ChainId;\n  destinationDomainId: number;\n  origin: MessageTxStub;\n  destination?: MessageTxStub;\n  warpTransfer?: WarpTransferInfo; // Parsed from message body\n}\n"
  },
  {
    "path": "src/features/messages/useMergedTransferHistory.test.ts",
    "content": "import { MultiProtocolProvider, WarpCore } from '@hyperlane-xyz/sdk';\nimport type { ChainMetadata } from '@hyperlane-xyz/sdk/metadata/chainMetadataTypes';\nimport type { ChainMap } from '@hyperlane-xyz/sdk/types';\nimport { ProtocolType } from '@hyperlane-xyz/utils';\nimport { describe, expect, test } from 'vitest';\n\nimport { createMockToken } from '../../utils/test';\nimport { MessageStatus, type MessageStub } from './types';\nimport { messageToTransferContext } from './useMergedTransferHistory';\n\nconst TEST_CHAIN_METADATA: ChainMap<ChainMetadata<{ mailbox?: string }>> = {\n  ethereum: {\n    name: 'ethereum',\n    chainId: 1,\n    domainId: 1,\n    protocol: ProtocolType.Ethereum,\n    rpcUrls: [{ http: 'http://localhost:8545' }],\n    nativeToken: { name: 'Ether', symbol: 'ETH', decimals: 18 },\n  },\n  arbitrum: {\n    name: 'arbitrum',\n    chainId: 42161,\n    domainId: 42161,\n    protocol: ProtocolType.Ethereum,\n    rpcUrls: [{ http: 'http://localhost:8546' }],\n    nativeToken: { name: 'Ether', symbol: 'ETH', decimals: 18 },\n  },\n};\n\ndescribe('messageToTransferContext', () => {\n  test('uses the concrete route token when same-symbol tokens share a chain', () => {\n    const matchingRouteToken = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDC',\n      decimals: 6,\n      addressOrDenom: '0x00000000000000000000000000000000000000a1',\n    });\n    const wrongRouteToken = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDC',\n      decimals: 18,\n      addressOrDenom: '0x00000000000000000000000000000000000000b2',\n    });\n    const multiProvider = new MultiProtocolProvider(TEST_CHAIN_METADATA);\n    const warpCore = new WarpCore(multiProvider, [wrongRouteToken, matchingRouteToken]);\n    const msg: MessageStub = {\n      status: MessageStatus.Delivered,\n      id: 'db-id',\n      msgId: 'msg-id',\n      nonce: 1,\n      sender: matchingRouteToken.addressOrDenom,\n      recipient: '0x00000000000000000000000000000000000000c3',\n      originChainId: 1,\n      originDomainId: 1,\n      destinationChainId: 42161,\n      destinationDomainId: 42161,\n      origin: {\n        timestamp: 123,\n        hash: '0x0000000000000000000000000000000000000000000000000000000000000123',\n        from: '0x0000000000000000000000000000000000000f01',\n        to: '0x0000000000000000000000000000000000000f02',\n      },\n      warpTransfer: {\n        recipient: '0x0000000000000000000000000000000000000f03',\n        amount: '1000000',\n      },\n    };\n    const result = messageToTransferContext(msg, multiProvider, warpCore);\n\n    expect(result.origin).toBe('ethereum');\n    expect(result.destination).toBe('arbitrum');\n    // 1_000_000 wire units formatted with token.decimals=6 → \"1\"\n    expect(result.amount).toBe('1');\n    expect(result.originTokenAddressOrDenom).toBe(matchingRouteToken.addressOrDenom);\n    expect(result.destTokenAddressOrDenom).toBe(msg.recipient);\n  });\n});\n"
  },
  {
    "path": "src/features/messages/useMergedTransferHistory.ts",
    "content": "import type { MultiProtocolProvider, WarpCore } from '@hyperlane-xyz/sdk';\nimport { useMemo } from 'react';\n\nimport { logger } from '../../utils/logger';\nimport { tryFindToken } from '../tokens/hooks';\nimport { formatMessageAmount } from '../transfer/scaleUtils';\nimport { TransferContext, TransferStatus } from '../transfer/types';\nimport { MessageStatus, MessageStub } from './types';\n\nexport const TransferItemType = {\n  Local: 'local',\n  Api: 'api',\n} as const;\n\nexport type TransferItem =\n  | { type: typeof TransferItemType.Local; data: TransferContext }\n  | { type: typeof TransferItemType.Api; data: MessageStub };\n\n/**\n * Convert an API MessageStub to a TransferContext for display\n */\nexport function messageToTransferContext(\n  msg: MessageStub,\n  multiProvider: MultiProtocolProvider,\n  warpCore: WarpCore,\n): TransferContext {\n  const originChain = multiProvider.tryGetChainName(msg.originDomainId) || '';\n  const destChain = multiProvider.tryGetChainName(msg.destinationDomainId) || '';\n\n  // Use actual sender (tx sender) and recipient (from warp message body)\n  const actualSender = msg.origin.from;\n  const actualRecipient = msg.warpTransfer?.recipient || msg.recipient;\n\n  // Format amount: message-body amount → local units via scale, then human-readable\n  let formattedAmount = '';\n  const token = tryFindToken(warpCore, originChain, msg.sender);\n  if (msg.warpTransfer?.amount && token) {\n    try {\n      formattedAmount = formatMessageAmount(msg.warpTransfer.amount, token);\n    } catch (err) {\n      logger.error('Failed to format warp transfer amount', err);\n    }\n  }\n\n  return {\n    status:\n      msg.status === MessageStatus.Delivered\n        ? TransferStatus.Delivered\n        : TransferStatus.ConfirmedTransfer,\n    origin: originChain,\n    destination: destChain,\n    amount: formattedAmount,\n    sender: actualSender,\n    recipient: actualRecipient,\n    originTxHash: msg.origin.hash,\n    msgId: msg.msgId,\n    timestamp: msg.origin.timestamp,\n    originTokenAddressOrDenom: msg.sender,\n    destTokenAddressOrDenom: msg.recipient,\n  };\n}\n\n/**\n * Hook to merge local transfers with API messages\n * Local transfers are shown until they appear in the API results\n */\nexport function useMergedTransferHistory(\n  localTransfers: TransferContext[],\n  apiMessages: MessageStub[],\n): TransferItem[] {\n  return useMemo(() => {\n    const apiMsgIds = new Set(apiMessages.map((m) => m.msgId));\n\n    // Local transfers that aren't in API yet\n    const localItems: TransferItem[] = localTransfers\n      .filter((t) => !t.msgId || !apiMsgIds.has(t.msgId))\n      .map((t) => ({ type: TransferItemType.Local, data: t }));\n\n    // API messages\n    const apiItems: TransferItem[] = apiMessages.map((m) => ({\n      type: TransferItemType.Api,\n      data: m,\n    }));\n\n    // Sort by timestamp descending\n    return [...localItems, ...apiItems].sort((a, b) => {\n      const tsA = a.type === TransferItemType.Local ? a.data.timestamp : a.data.origin.timestamp;\n      const tsB = b.type === TransferItemType.Local ? b.data.timestamp : b.data.origin.timestamp;\n      return tsB - tsA;\n    });\n  }, [localTransfers, apiMessages]);\n}\n"
  },
  {
    "path": "src/features/messages/useMessageDeliveryStatus.ts",
    "content": "import type { MultiProtocolProvider } from '@hyperlane-xyz/sdk';\nimport { useQuery } from '@tanstack/react-query';\n\nimport { logger } from '../../utils/logger';\nimport { executeGraphQLQuery } from './graphqlClient';\nimport { buildMessageByIdQuery } from './queries/build';\nimport { parseTimestamp, postgresByteaToTxHash } from './queries/encoding';\nimport type { MessageStubEntry } from './queries/fragments';\n\nconst POLL_INTERVAL_MS = 10_000;\n\nexport interface MessageDeliveryResult {\n  /** Whether the message has been delivered on destination */\n  isDelivered: boolean;\n  /** Destination transaction hash (only when delivered) */\n  destinationTxHash?: string;\n  /** Origin timestamp in ms */\n  originTimestamp?: number;\n  /** Origin block height from GraphQL (fallback for page refresh) */\n  originBlockHeight?: number;\n  /** Loading state */\n  isLoading: boolean;\n}\n\n/**\n * Queries GraphQL for a single message by msgId.\n * Polls while the modal is open and message is not yet delivered.\n */\nexport function useMessageDeliveryStatus(\n  msgId: string | undefined,\n  isOpen: boolean,\n  multiProvider: MultiProtocolProvider,\n): MessageDeliveryResult {\n  const { data, isLoading } = useQuery({\n    // eslint-disable-next-line @tanstack/query/exhaustive-deps -- multiProvider is stable, adding it causes unnecessary refetches\n    queryKey: ['messageDelivery', msgId],\n    queryFn: async () => {\n      if (!msgId) return null;\n      const queryData = buildMessageByIdQuery(msgId);\n      const result = await executeGraphQLQuery<{ message_view: MessageStubEntry[] }>(\n        queryData.query,\n        queryData.variables,\n      );\n      if (result.type === 'error') {\n        logger.error('Failed to query message delivery status', result.error);\n        return null;\n      }\n      const entry = result.data.message_view?.[0];\n      if (!entry) return null;\n      return parseDeliveryResult(entry, multiProvider);\n    },\n    enabled: !!msgId && isOpen,\n    staleTime: 30_000,\n    refetchInterval: (query) => {\n      // Stop polling once delivered\n      if (query.state.data?.isDelivered) return false;\n      return POLL_INTERVAL_MS;\n    },\n    refetchOnWindowFocus: false,\n  });\n\n  return {\n    isDelivered: data?.isDelivered ?? false,\n    destinationTxHash: data?.destinationTxHash,\n    originTimestamp: data?.originTimestamp,\n    originBlockHeight: data?.originBlockHeight,\n    isLoading,\n  };\n}\n\nfunction parseDeliveryResult(\n  entry: MessageStubEntry,\n  multiProvider: MultiProtocolProvider,\n): Omit<MessageDeliveryResult, 'isLoading'> {\n  const destMetadata = multiProvider.tryGetChainMetadata(entry.destination_domain_id);\n\n  return {\n    isDelivered: entry.is_delivered,\n    destinationTxHash:\n      entry.is_delivered && entry.destination_tx_hash\n        ? postgresByteaToTxHash(entry.destination_tx_hash, destMetadata)\n        : undefined,\n    originTimestamp: parseTimestamp(entry.send_occurred_at),\n    originBlockHeight: entry.origin_block_height,\n  };\n}\n"
  },
  {
    "path": "src/features/messages/useMessageHistory.ts",
    "content": "import type { MultiProtocolProvider } from '@hyperlane-xyz/sdk';\nimport {\n  assert,\n  bytesToProtocolAddress,\n  fromHexString,\n  parseWarpRouteMessage,\n} from '@hyperlane-xyz/utils';\nimport { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';\nimport { useCallback, useMemo, useState } from 'react';\n\nimport { logger } from '../../utils/logger';\nimport { executeGraphQLQuery } from './graphqlClient';\nimport { buildMessageHistoryQuery } from './queries/build';\nimport {\n  parseTimestamp,\n  postgresByteaToAddress,\n  postgresByteaToString,\n  postgresByteaToTxHash,\n} from './queries/encoding';\nimport { MessageStubEntry } from './queries/fragments';\nimport { MessageStatus, MessageStub, WarpTransferInfo } from './types';\n\nconst PAGE_LIMIT = 15;\nconst REFRESH_INTERVAL_MS = 60_000;\n\ninterface UseMessageHistoryResult {\n  messages: MessageStub[];\n  isLoading: boolean;\n  isRefreshing: boolean;\n  error: Error | null;\n  hasMore: boolean;\n  loadMore: () => void;\n  refresh: () => void;\n}\n\ninterface PageResult {\n  messages: MessageStub[];\n  rawCount: number; // Raw DB rows fetched (before parsing/filtering)\n}\n\nexport function useMessageHistory(\n  walletAddresses: string[],\n  warpRouteAddresses: string[],\n  multiProvider: MultiProtocolProvider,\n): UseMessageHistoryResult {\n  const walletKey = useMemo(() => JSON.stringify([...walletAddresses].sort()), [walletAddresses]);\n  const warpRouteKey = useMemo(\n    () => JSON.stringify([...warpRouteAddresses].sort()),\n    [warpRouteAddresses],\n  );\n\n  const queryClient = useQueryClient();\n  const queryKey = useMemo(\n    () => ['messageHistory', walletKey, warpRouteKey] as const,\n    [walletKey, warpRouteKey],\n  );\n  const [isRefreshing, setIsRefreshing] = useState(false);\n\n  const { data, isLoading, isFetchingNextPage, error, hasNextPage, fetchNextPage } =\n    useInfiniteQuery({\n      // eslint-disable-next-line @tanstack/query/exhaustive-deps -- multiProvider is not serializable\n      queryKey,\n      queryFn: async ({ pageParam }): Promise<PageResult> => {\n        const wallets = JSON.parse(walletKey);\n        const warpRoutes = JSON.parse(warpRouteKey);\n        assert(Array.isArray(wallets), 'wallets must be an array');\n        assert(Array.isArray(warpRoutes), 'warpRoutes must be an array');\n\n        if (!wallets.length || !warpRoutes.length) return { messages: [], rawCount: 0 };\n\n        const queryData = buildMessageHistoryQuery(wallets, warpRoutes, PAGE_LIMIT, pageParam);\n        if (!queryData) return { messages: [], rawCount: 0 };\n\n        const result = await executeGraphQLQuery<{ message_view: MessageStubEntry[] }>(\n          queryData.query,\n          queryData.variables,\n        );\n\n        if (result.type === 'error') throw result.error;\n\n        const entries = result.data.message_view;\n        if (!entries?.length) return { messages: [], rawCount: 0 };\n\n        const messages = entries\n          .map((entry) => parseMessageEntry(entry, multiProvider))\n          .filter((m): m is MessageStub => m !== null);\n\n        return { messages, rawCount: entries.length };\n      },\n      initialPageParam: 0,\n      getNextPageParam: (lastPage, allPages) => {\n        if (lastPage.rawCount < PAGE_LIMIT) return undefined;\n        // Use raw DB row count for offset to avoid drift when parseMessageEntry filters entries\n        return allPages.reduce((acc, page) => acc + page.rawCount, 0);\n      },\n      enabled: walletAddresses.length > 0 && warpRouteAddresses.length > 0,\n      refetchInterval: REFRESH_INTERVAL_MS,\n      refetchOnWindowFocus: false,\n    });\n\n  const messages = useMemo(() => {\n    if (!data?.pages) return [];\n    const seen = new Set<string>();\n    const result: MessageStub[] = [];\n    for (const page of data.pages) {\n      for (const msg of page.messages) {\n        if (!seen.has(msg.msgId)) {\n          seen.add(msg.msgId);\n          result.push(msg);\n        }\n      }\n    }\n    return result;\n  }, [data]);\n\n  const refresh = useCallback(async () => {\n    setIsRefreshing(true);\n    await queryClient.resetQueries({ queryKey });\n    setIsRefreshing(false);\n  }, [queryClient, queryKey]);\n\n  return {\n    messages,\n    isLoading: isLoading || isFetchingNextPage,\n    isRefreshing,\n    error: error as Error | null,\n    hasMore: !!hasNextPage,\n    loadMore: () => fetchNextPage(),\n    refresh,\n  };\n}\n\nfunction parseMessageEntry(\n  entry: MessageStubEntry,\n  multiProvider: MultiProtocolProvider,\n): MessageStub | null {\n  try {\n    const originMetadata = multiProvider.tryGetChainMetadata(entry.origin_domain_id);\n    const destinationMetadata = multiProvider.tryGetChainMetadata(entry.destination_domain_id);\n\n    let warpTransfer: WarpTransferInfo | undefined;\n    if (entry.message_body && destinationMetadata) {\n      try {\n        const body = postgresByteaToString(entry.message_body);\n        const parsed = parseWarpRouteMessage(body);\n        // Convert recipient from bytes32 to proper address format for destination chain\n        const recipientBytes = fromHexString(parsed.recipient);\n        const recipientAddress = bytesToProtocolAddress(\n          recipientBytes,\n          destinationMetadata.protocol,\n          destinationMetadata.bech32Prefix,\n        );\n        warpTransfer = {\n          recipient: recipientAddress,\n          amount: parsed.amount.toString(),\n        };\n      } catch {\n        // Not a warp transfer message or parsing failed\n      }\n    }\n\n    let destination: MessageStub['destination'];\n    if (\n      entry.is_delivered &&\n      entry.delivery_occurred_at &&\n      entry.destination_tx_hash &&\n      entry.destination_tx_sender &&\n      entry.destination_tx_recipient\n    ) {\n      destination = {\n        timestamp: parseTimestamp(entry.delivery_occurred_at),\n        hash: postgresByteaToTxHash(entry.destination_tx_hash, destinationMetadata),\n        from: postgresByteaToAddress(entry.destination_tx_sender, destinationMetadata),\n        to: postgresByteaToAddress(entry.destination_tx_recipient, destinationMetadata),\n      };\n    }\n\n    return {\n      status: entry.is_delivered ? MessageStatus.Delivered : MessageStatus.Pending,\n      id: entry.id.toString(),\n      msgId: postgresByteaToString(entry.msg_id),\n      nonce: entry.nonce,\n      sender: postgresByteaToAddress(entry.sender, originMetadata),\n      recipient: postgresByteaToAddress(entry.recipient, destinationMetadata),\n      originChainId: entry.origin_chain_id,\n      originDomainId: entry.origin_domain_id,\n      destinationChainId: entry.destination_chain_id,\n      destinationDomainId: entry.destination_domain_id,\n      origin: {\n        timestamp: parseTimestamp(entry.send_occurred_at),\n        hash: postgresByteaToTxHash(entry.origin_tx_hash, originMetadata),\n        from: postgresByteaToAddress(entry.origin_tx_sender, originMetadata),\n        to: postgresByteaToAddress(entry.origin_tx_recipient, originMetadata),\n      },\n      destination,\n      warpTransfer,\n    };\n  } catch (err) {\n    logger.error('Failed to parse message entry', entry.id, err);\n    return null;\n  }\n}\n"
  },
  {
    "path": "src/features/messages/useOriginFinality.ts",
    "content": "import { ProtocolType } from '@hyperlane-xyz/utils';\nimport { useQuery } from '@tanstack/react-query';\n\nimport { useMultiProvider } from '../chains/hooks';\nimport { DEFAULT_FINALITY_BLOCKS } from '../transfer/utils';\n\nconst POLL_INTERVAL_MS = 10_000;\n\n/**\n * Checks if the origin tx is finalized by comparing latest block to origin block + finality blocks.\n * Only works for EVM chains; non-EVM returns false.\n */\nexport function useOriginFinality(\n  origin: string | undefined,\n  originBlockNumber: number | undefined,\n  enabled: boolean,\n): boolean {\n  const multiProvider = useMultiProvider();\n\n  const { data } = useQuery({\n    // eslint-disable-next-line @tanstack/query/exhaustive-deps -- multiProvider is not serializable\n    queryKey: ['originFinality', origin, originBlockNumber],\n    queryFn: async () => {\n      if (!origin || !originBlockNumber) return false;\n      const protocol = multiProvider.tryGetProtocol(origin);\n      if (protocol !== ProtocolType.Ethereum) return false;\n      try {\n        const metadata = multiProvider.tryGetChainMetadata(origin);\n        const finalityBlocks = metadata?.blocks?.confirmations ?? DEFAULT_FINALITY_BLOCKS;\n        const provider = multiProvider.getEthersV5Provider(origin);\n        const latestBlock = await provider.getBlockNumber();\n        return latestBlock > originBlockNumber + finalityBlocks;\n      } catch {\n        return false;\n      }\n    },\n    enabled,\n    refetchInterval: (query) => {\n      if (query.state.data === true) return false;\n      return POLL_INTERVAL_MS;\n    },\n    refetchOnWindowFocus: false,\n  });\n\n  return data ?? false;\n}\n"
  },
  {
    "path": "src/features/routerAddresses.test.ts",
    "content": "import { TokenStandard } from '@hyperlane-xyz/sdk/token/TokenStandard';\nimport { normalizeAddress } from '@hyperlane-xyz/utils';\nimport { describe, expect, test } from 'vitest';\n\nimport { createMockToken } from '../utils/test';\nimport { getRouterAddressesByChain } from './store';\n\nconst VALID_EVM_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';\n\ndescribe('getRouterAddressesByChain', () => {\n  test('normalizes EVM addresses but preserves case-sensitive non-EVM addresses', () => {\n    const evmToken = createMockToken({\n      chainName: 'ethereum',\n      standard: TokenStandard.EvmHypCollateral,\n      addressOrDenom: VALID_EVM_ADDRESS,\n      decimals: 6,\n    });\n    const sealevelToken = createMockToken({\n      chainName: 'solanamainnet',\n      standard: TokenStandard.SealevelHypCollateral,\n      addressOrDenom: 'So11111111111111111111111111111111111111112',\n      decimals: 9,\n    });\n    const cosmosToken = createMockToken({\n      chainName: 'cosmoshub',\n      standard: TokenStandard.CosmosIbc,\n      addressOrDenom: 'ibc/27394FB092D2A2A821B4B8C3670D8E4C0A9D1C6A1BDAA1F4B1C7E7DA4D5E6F70',\n      decimals: 6,\n    });\n\n    const result = getRouterAddressesByChain([evmToken, sealevelToken, cosmosToken]);\n    const normalizedEvmAddress = normalizeAddress(VALID_EVM_ADDRESS);\n\n    expect(result.ethereum?.has(normalizedEvmAddress)).toBe(true);\n    expect(result.ethereum?.has('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48')).toBe(false);\n    expect(result.solanamainnet?.has(sealevelToken.addressOrDenom)).toBe(true);\n    expect(result.cosmoshub?.has(cosmosToken.addressOrDenom)).toBe(true);\n  });\n});\n"
  },
  {
    "path": "src/features/sanctions/hooks/useIsAccountChainalysisSanctioned.ts",
    "content": "import { useEthereumAccount } from '@hyperlane-xyz/widgets/walletIntegrations/ethereum';\nimport { isAddress } from 'viem';\nimport { useReadContract } from 'wagmi';\n\nimport { useMultiProvider } from '../../chains/hooks';\n\n// https://go.chainalysis.com/chainalysis-oracle-docs.html\nconst ORACLE_ABI = [\n  {\n    inputs: [\n      {\n        internalType: 'address',\n        name: 'addr',\n        type: 'address',\n      },\n    ],\n    name: 'isSanctioned',\n    outputs: [\n      {\n        internalType: 'bool',\n        name: '',\n        type: 'bool',\n      },\n    ],\n    stateMutability: 'view',\n    type: 'function',\n  },\n] as const;\nconst ORACLE_ADDRESS = '0x40C57923924B5c5c5455c48D93317139ADDaC8fb';\n\nexport function useIsAccountChainalysisSanctioned() {\n  const multiProvider = useMultiProvider();\n  const evmAddress = useEthereumAccount(multiProvider).addresses[0]?.address;\n\n  const sanctioned = useReadContract({\n    abi: ORACLE_ABI,\n    functionName: 'isSanctioned',\n    args: [isAddress(evmAddress) ? evmAddress : '0x'],\n    chainId: 1,\n    address: ORACLE_ADDRESS,\n    query: { enabled: !!evmAddress },\n  });\n\n  return !!sanctioned.data;\n}\n"
  },
  {
    "path": "src/features/sanctions/hooks/useIsAccountOfacSanctioned.ts",
    "content": "import { eqAddress } from '@hyperlane-xyz/utils';\nimport { useEthereumAccount } from '@hyperlane-xyz/widgets/walletIntegrations/ethereum';\nimport { useQuery } from '@tanstack/react-query';\n\nimport { useMultiProvider } from '../../chains/hooks';\n\nconst OFAC_SANCTIONED_ADDRESSES_ENDPOINT =\n  'https://raw.githubusercontent.com/0xB10C/ofac-sanctioned-digital-currency-addresses/lists/sanctioned_addresses_ETH.json';\n\nexport function useIsAccountOfacSanctioned() {\n  const multiProvider = useMultiProvider();\n  const evmAddress = useEthereumAccount(multiProvider).addresses[0]?.address;\n\n  const sanctionedAddresses = useQuery<string[]>({\n    queryKey: ['useIsAccountOfacSanctioned', evmAddress],\n    queryFn: () => fetch(OFAC_SANCTIONED_ADDRESSES_ENDPOINT).then((x) => x.json()),\n    enabled: !!evmAddress,\n  });\n\n  return (\n    !!sanctionedAddresses.data &&\n    !!evmAddress &&\n    !!sanctionedAddresses.data.find((x) => eqAddress(x, evmAddress))\n  );\n}\n"
  },
  {
    "path": "src/features/sanctions/hooks/useIsAccountSanctioned.ts",
    "content": "import { useIsAccountChainalysisSanctioned } from './useIsAccountChainalysisSanctioned';\nimport { useIsAccountOfacSanctioned } from './useIsAccountOfacSanctioned';\n\nexport function useIsAccountSanctioned() {\n  const isAccountOfacSanctioned = useIsAccountOfacSanctioned();\n  const isAccountChainalysisSanctioned = useIsAccountChainalysisSanctioned();\n\n  return isAccountOfacSanctioned || isAccountChainalysisSanctioned;\n}\n"
  },
  {
    "path": "src/features/store.ts",
    "content": "import {\n  ChainAddresses,\n  GithubRegistry,\n  IRegistry,\n  PartialRegistry,\n} from '@hyperlane-xyz/registry';\nimport {\n  ChainMap,\n  ChainMetadata,\n  ChainName,\n  MultiProtocolProvider,\n  Token,\n  WarpCore,\n  WarpCoreConfig,\n} from '@hyperlane-xyz/sdk';\nimport { normalizeAddress, objFilter } from '@hyperlane-xyz/utils';\nimport { toast } from 'react-toastify';\nimport { create } from 'zustand';\nimport { persist } from 'zustand/middleware';\n\nimport { config } from '../consts/config';\nimport { logger } from '../utils/logger';\nimport { assembleChainAddresses } from './chains/addresses';\nimport { assembleChainMetadata } from './chains/metadata';\nimport {\n  buildTokensArray,\n  getTokenKey,\n  groupTokensByCollateral,\n  setResolvedUnderlyingMap,\n} from './tokens/utils';\nimport { resolveWrappedCollateralTokens } from './tokens/wrappedTokenResolver';\nimport { FinalTransferStatuses, TransferContext, TransferStatus } from './transfer/types';\nimport {\n  type E2ETokenSnapshot,\n  initE2EStateIfEnabled,\n  markE2ERuntimeReady,\n} from './wallet/_e2e/windowState';\nimport { assembleWarpCoreConfig } from './warpCore/warpCoreConfig';\n\n// Increment this when persist state has breaking changes\nconst PERSIST_STATE_VERSION = 2;\n\ninterface WarpContext {\n  registry: IRegistry;\n  chainMetadata: ChainMap<ChainMetadata>;\n  chainAddresses: ChainMap<ChainAddresses>;\n  multiProvider: MultiProtocolProvider;\n  warpCore: WarpCore;\n  /** Unified tokens array (deduplicated, can be origin or destination) */\n  tokens: Token[];\n  /** Pre-computed collateral groups for fast route checking */\n  collateralGroups: Map<string, Token[]>;\n  /** Pre-computed token key to Token map for O(1) lookups */\n  tokenByKeyMap: Map<string, Token>;\n  // Set of router addresses per chain\n  routerAddressesByChainMap: Record<ChainName, Set<string>>;\n  // Deduplicated, sorted CoinGecko IDs for all tokens\n  coinGeckoIds: string[];\n}\n\nfunction buildE2ETokenSnapshot(tokens: Token[] | undefined): E2ETokenSnapshot[] | undefined {\n  if (!tokens?.length) return undefined;\n  return tokens.map((t) => ({\n    key: getTokenKey(t),\n    chain: t.chainName,\n    symbol: t.symbol,\n    standard: t.standard,\n    addressOrDenom: t.addressOrDenom,\n    collateralAddressOrDenom: t.collateralAddressOrDenom,\n    connectionKeys: (t.connections ?? []).map((c) => getTokenKey(c.token as Token)),\n  }));\n}\n// Keeping everything here for now as state is simple\n// Will refactor into slices as necessary\nexport interface AppState {\n  // Chains and providers\n  chainMetadata: ChainMap<ChainMetadata>;\n  // Per-chain contract addresses, merged from registry + filesystem (addresses.yaml)\n  chainAddresses: ChainMap<ChainAddresses>;\n  // Overrides to chain metadata set by user via the chain picker\n  chainMetadataOverrides: ChainMap<Partial<ChainMetadata>>;\n  setChainMetadataOverrides: (overrides?: ChainMap<Partial<ChainMetadata> | undefined>) => void;\n  // Overrides to warp core configs added by user\n  warpCoreConfigOverrides: WarpCoreConfig[];\n  setWarpCoreConfigOverrides: (overrides?: WarpCoreConfig[] | undefined) => void;\n  multiProvider: MultiProtocolProvider;\n  registry: IRegistry;\n  warpCore: WarpCore;\n  setWarpContext: (context: WarpContext) => void;\n\n  // User history\n  transfers: TransferContext[];\n  addTransfer: (t: TransferContext) => void;\n  resetTransfers: () => void;\n  updateTransferStatus: (\n    i: number,\n    s: TransferStatus,\n    options?: {\n      msgId?: string;\n      originTxHash?: string;\n      originBlockNumber?: number;\n      destinationTxHash?: string;\n    },\n  ) => void;\n  failUnconfirmedTransfers: () => void;\n\n  // Shared component state\n  transferLoading: boolean;\n  setTransferLoading: (isLoading: boolean) => void;\n  isSideBarOpen: boolean;\n  setIsSideBarOpen: (isOpen: boolean) => void;\n  showEnvSelectModal: boolean;\n  setShowEnvSelectModal: (show: boolean) => void;\n\n  originChainName: ChainName;\n  setOriginChainName: (originChainName: ChainName) => void;\n  // instead of moving the TipCard component inside the formik and an useEffect can be set to watch for it\n  isTipCardActionTriggered: boolean;\n  setIsTipCardActionTriggered: (isTipCardActionTriggered: boolean) => void;\n  /** Unified tokens array (deduplicated, can be origin or destination) */\n  tokens: Token[];\n  /** Pre-computed collateral groups for fast route checking */\n  collateralGroups: Map<string, Token[]>;\n  /** Pre-computed token key to Token map for O(1) lookups */\n  tokenByKeyMap: Map<string, Token>;\n  // Set of router addresses per chain — used to prevent sending to warp route\n  // addresses and to filter message API results\n  routerAddressesByChainMap: Record<ChainName, Set<string>>;\n  // Deduplicated, sorted CoinGecko IDs for all tokens (used by useTokenPrices)\n  coinGeckoIds: string[];\n}\n\nexport const useStore = create<AppState>()(\n  persist(\n    // Store reducers\n    (set, get) => ({\n      // Chains and providers\n      chainMetadata: {},\n      chainAddresses: {},\n      chainMetadataOverrides: {},\n      setChainMetadataOverrides: async (\n        overrides: ChainMap<Partial<ChainMetadata> | undefined> = {},\n      ) => {\n        logger.debug('Setting chain overrides in store');\n        const filtered = objFilter(overrides, (_, metadata) => !!metadata);\n        const {\n          registry,\n          chainMetadata,\n          chainAddresses,\n          multiProvider,\n          warpCore,\n          routerAddressesByChainMap,\n          tokens,\n          collateralGroups,\n          tokenByKeyMap,\n          coinGeckoIds,\n        } = await initWarpContext({\n          ...get(),\n          chainMetadataOverrides: filtered,\n        });\n        set({\n          chainMetadataOverrides: filtered,\n          registry,\n          chainMetadata,\n          chainAddresses,\n          multiProvider,\n          warpCore,\n          routerAddressesByChainMap,\n          tokens,\n          collateralGroups,\n          tokenByKeyMap,\n          coinGeckoIds,\n        });\n      },\n      warpCoreConfigOverrides: [],\n      setWarpCoreConfigOverrides: async (overrides: WarpCoreConfig[] | undefined = []) => {\n        logger.debug('Setting warp core config overrides in store');\n        const {\n          registry,\n          chainMetadata,\n          chainAddresses,\n          multiProvider,\n          warpCore,\n          routerAddressesByChainMap,\n          tokens,\n          collateralGroups,\n          tokenByKeyMap,\n          coinGeckoIds,\n        } = await initWarpContext({\n          ...get(),\n          warpCoreConfigOverrides: overrides,\n        });\n        set({\n          warpCoreConfigOverrides: overrides,\n          registry,\n          chainMetadata,\n          chainAddresses,\n          multiProvider,\n          warpCore,\n          routerAddressesByChainMap,\n          tokens,\n          collateralGroups,\n          tokenByKeyMap,\n          coinGeckoIds,\n        });\n      },\n      multiProvider: new MultiProtocolProvider({}),\n      registry: new GithubRegistry({\n        uri: config.registryUrl,\n        branch: config.registryBranch,\n        proxyUrl: config.registryProxyUrl,\n      }),\n      warpCore: new WarpCore(new MultiProtocolProvider({}), []),\n      setWarpContext: (context) => {\n        logger.debug('Setting warp context in store');\n        set(context);\n      },\n\n      // User history\n      transfers: [],\n      addTransfer: (t) => {\n        set((state) => ({ transfers: [...state.transfers, t] }));\n      },\n      resetTransfers: () => {\n        set(() => ({ transfers: [] }));\n      },\n      updateTransferStatus: (i, s, options) => {\n        set((state) => {\n          if (i >= state.transfers.length) return state;\n          const txs = [...state.transfers];\n          txs[i].status = s;\n          txs[i].msgId ||= options?.msgId;\n          txs[i].originTxHash ||= options?.originTxHash;\n          txs[i].originBlockNumber ||= options?.originBlockNumber;\n          txs[i].destinationTxHash ||= options?.destinationTxHash;\n          return {\n            transfers: txs,\n          };\n        });\n      },\n      failUnconfirmedTransfers: () => {\n        set((state) => ({\n          transfers: state.transfers.map((t) =>\n            FinalTransferStatuses.includes(t.status) ? t : { ...t, status: TransferStatus.Failed },\n          ),\n        }));\n      },\n\n      // Shared component state\n      transferLoading: false,\n      setTransferLoading: (isLoading) => {\n        set(() => ({ transferLoading: isLoading }));\n      },\n      isSideBarOpen: false,\n      setIsSideBarOpen: (isSideBarOpen) => {\n        set(() => ({ isSideBarOpen }));\n      },\n      showEnvSelectModal: false,\n      setShowEnvSelectModal: (showEnvSelectModal) => {\n        set(() => ({ showEnvSelectModal }));\n      },\n      originChainName: '',\n      setOriginChainName: (originChainName: ChainName) => {\n        set(() => ({ originChainName }));\n      },\n      routerAddressesByChainMap: {},\n      isTipCardActionTriggered: false,\n      setIsTipCardActionTriggered: (isTipCardActionTriggered: boolean) => {\n        set(() => ({ isTipCardActionTriggered }));\n      },\n      tokens: [],\n      collateralGroups: new Map(),\n      tokenByKeyMap: new Map(),\n      coinGeckoIds: [],\n    }),\n\n    // Store config\n    {\n      name: 'app-state', // name in storage\n      partialize: (state) => ({\n        // fields to persist\n        chainMetadataOverrides: state.chainMetadataOverrides,\n        transfers: state.transfers, // Keep for transfers through non-indexed routes\n      }),\n      version: PERSIST_STATE_VERSION,\n      onRehydrateStorage: () => {\n        logger.debug('Rehydrating state');\n        return (state, error) => {\n          state?.failUnconfirmedTransfers();\n          if (error || !state) {\n            logger.error('Error during hydration', error);\n            return;\n          }\n          initWarpContext(state).then((context) => {\n            state.setWarpContext(context);\n            logger.debug('Rehydration complete');\n          });\n        };\n      },\n    },\n  ),\n);\n\nasync function initWarpContext({\n  registry,\n  chainMetadataOverrides,\n  warpCoreConfigOverrides,\n}: {\n  registry: IRegistry;\n  chainMetadataOverrides: ChainMap<Partial<ChainMetadata> | undefined>;\n  warpCoreConfigOverrides: WarpCoreConfig[];\n}): Promise<WarpContext> {\n  let currentRegistry = registry;\n  try {\n    // Pre-load registry content to avoid repeated requests\n    await currentRegistry.listRegistryContent();\n  } catch (error) {\n    // Lazy-load the published constants so they stay out of the initial bundle\n    const { chainAddresses, chainMetadata } = await import('@hyperlane-xyz/registry');\n    currentRegistry = new PartialRegistry({\n      chainAddresses,\n      chainMetadata,\n    });\n    logger.warn(\n      'Failed to list registry content using GithubRegistry, will continue with PartialRegistry.',\n      error,\n    );\n  }\n\n  try {\n    const { config: coreConfig } = await assembleWarpCoreConfig(\n      warpCoreConfigOverrides,\n      currentRegistry,\n    );\n\n    const chainsInTokens = Array.from(new Set(coreConfig.tokens.map((t) => t.chainName)));\n    const [{ chainMetadata, chainMetadataWithOverrides }, chainAddresses] = await Promise.all([\n      assembleChainMetadata(chainsInTokens, currentRegistry, chainMetadataOverrides),\n      assembleChainAddresses(chainsInTokens, currentRegistry),\n    ]);\n    const multiProvider = new MultiProtocolProvider(chainMetadataWithOverrides);\n    const warpCore = WarpCore.FromConfig(multiProvider, coreConfig);\n\n    // Resolve underlying addresses for lockbox/vault tokens so they group\n    // with their non-wrapper counterparts (e.g., lockbox USDT = regular USDT)\n    const resolvedMap = await resolveWrappedCollateralTokens(warpCore.tokens, multiProvider);\n    setResolvedUnderlyingMap(resolvedMap);\n\n    // Build unified tokens array (deduplicated by collateral at startup)\n    const tokens = buildTokensArray(warpCore.tokens);\n    // Build collateral groups for fast route checking\n    const collateralGroups = groupTokensByCollateral(warpCore.tokens);\n    // Build token by key map for O(1) lookups\n    const tokenByKeyMap = new Map<string, Token>();\n    for (const token of tokens) {\n      tokenByKeyMap.set(getTokenKey(token), token);\n    }\n\n    const routerAddressesByChainMap = getRouterAddressesByChain(warpCore.tokens);\n    const coinGeckoIds = Array.from(\n      new Set(coreConfig.tokens.map((t) => t.coinGeckoId).filter(Boolean)),\n    ).sort() as string[];\n    initE2EStateIfEnabled();\n    markE2ERuntimeReady(() => buildE2ETokenSnapshot(warpCore.tokens));\n    return {\n      registry: currentRegistry,\n      chainMetadata,\n      chainAddresses,\n      multiProvider,\n      warpCore,\n      routerAddressesByChainMap,\n      tokens,\n      collateralGroups,\n      tokenByKeyMap,\n      coinGeckoIds,\n    };\n  } catch (error) {\n    toast.error('Error initializing warp context. Please check connection status and configs.');\n    logger.error('Error initializing warp context', error);\n    return {\n      registry,\n      chainMetadata: {},\n      chainAddresses: {},\n      multiProvider: new MultiProtocolProvider({}),\n      warpCore: new WarpCore(new MultiProtocolProvider({}), []),\n      routerAddressesByChainMap: {},\n      tokens: [],\n      collateralGroups: new Map(),\n      tokenByKeyMap: new Map(),\n      coinGeckoIds: [],\n    };\n  }\n}\n\n// Build map of chain -> set of router addresses\nexport function getRouterAddressesByChain(\n  tokens: WarpCore['tokens'],\n): Record<ChainName, Set<string>> {\n  return tokens.reduce<Record<ChainName, Set<string>>>((acc, token) => {\n    if (!token.addressOrDenom) return acc;\n    acc[token.chainName] ||= new Set<string>();\n    acc[token.chainName].add(normalizeAddress(token.addressOrDenom));\n    return acc;\n  }, {});\n}\n"
  },
  {
    "path": "src/features/theme/ThemeContext.tsx",
    "content": "import {\n  PropsWithChildren,\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useLayoutEffect,\n  useState,\n} from 'react';\n\nimport { DEFAULT_UI_THEME_MODE, UI_THEME_STORAGE_KEY, UiThemeMode } from '../../consts/app';\nimport { processDarkLogoImage } from '../../utils/imageBrightness';\nimport { getSystemUiThemeMode, parseUiThemeMode } from '../../utils/theme';\n\ninterface ThemeContextValue {\n  themeMode: UiThemeMode;\n  toggleThemeMode: () => void;\n}\n\nconst ThemeContext = createContext<ThemeContextValue | null>(null);\n\nfunction getStoredThemeMode(): UiThemeMode | null {\n  if (typeof window === 'undefined') return null;\n  try {\n    return parseUiThemeMode(window.localStorage.getItem(UI_THEME_STORAGE_KEY));\n  } catch {\n    return null;\n  }\n}\n\nfunction persistThemeMode(mode: UiThemeMode) {\n  if (typeof window === 'undefined') return;\n  try {\n    window.localStorage.setItem(UI_THEME_STORAGE_KEY, mode);\n  } catch {\n    // Keep theme toggle working for this session when storage is unavailable.\n  }\n}\n\nexport function ThemeProvider({ children }: PropsWithChildren) {\n  const [themeMode, setThemeMode] = useState<UiThemeMode>(() => {\n    if (typeof window === 'undefined') return DEFAULT_UI_THEME_MODE;\n    const docTheme = parseUiThemeMode(document.documentElement.dataset.themeMode);\n    if (docTheme) return docTheme;\n    const storedTheme = getStoredThemeMode();\n    return storedTheme ?? getSystemUiThemeMode();\n  });\n  const [hasExplicitThemePreference, setHasExplicitThemePreference] = useState(() => {\n    return getStoredThemeMode() !== null;\n  });\n\n  const toggleThemeMode = useCallback(() => {\n    if (typeof window === 'undefined') return;\n    document.documentElement.dataset.themeSwitching = 'true';\n    setThemeMode((prevThemeMode) => (prevThemeMode === 'dark' ? 'light' : 'dark'));\n    setHasExplicitThemePreference(true);\n  }, []);\n\n  useLayoutEffect(() => {\n    if (typeof window === 'undefined') return;\n    document.documentElement.dataset.themeMode = themeMode;\n    if (hasExplicitThemePreference) persistThemeMode(themeMode);\n    const knownLogoImages = document.querySelectorAll(\n      'img[data-logo-handlers-bound=\"true\"], img[data-logo-original-src], img[data-logo-dark-src]',\n    );\n    knownLogoImages.forEach((img) => processDarkLogoImage(img as HTMLImageElement));\n\n    const frame = window.requestAnimationFrame(() => {\n      delete document.documentElement.dataset.themeSwitching;\n    });\n    return () => window.cancelAnimationFrame(frame);\n  }, [themeMode, hasExplicitThemePreference]);\n\n  useEffect(() => {\n    if (typeof window === 'undefined') return;\n    if (hasExplicitThemePreference) return;\n    if (typeof window.matchMedia !== 'function') return;\n    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n    const handleChange = (event: MediaQueryListEvent) => {\n      setThemeMode(event.matches ? 'dark' : 'light');\n    };\n\n    mediaQuery.addEventListener('change', handleChange);\n    return () => mediaQuery.removeEventListener('change', handleChange);\n  }, [hasExplicitThemePreference]);\n\n  return (\n    <ThemeContext.Provider value={{ themeMode, toggleThemeMode }}>{children}</ThemeContext.Provider>\n  );\n}\n\nexport function useTheme() {\n  const context = useContext(ThemeContext);\n  if (!context) throw new Error('useTheme must be used within ThemeProvider');\n  return context;\n}\n"
  },
  {
    "path": "src/features/tokens/ImportTokenButton.tsx",
    "content": "import { Token } from '@hyperlane-xyz/sdk';\nimport { PlusIcon } from '@hyperlane-xyz/widgets';\nimport { useCallback } from 'react';\nimport { toast } from 'react-toastify';\n\nimport { logger } from '../../utils/logger';\nimport { useAddToken } from './hooks';\n\nconst USER_REJECTED_ERROR = 'User rejected';\n\ninterface ImportTokenButtonProps {\n  token?: Token;\n}\n\nexport function ImportTokenButton({ token }: ImportTokenButtonProps) {\n  const { addToken, canAddAsset, isLoading } = useAddToken(token);\n\n  const handleAddToken = useCallback(async () => {\n    try {\n      await addToken();\n    } catch (error: any) {\n      const errorDetails = error.message || error.toString();\n      if (!errorDetails.includes(USER_REJECTED_ERROR)) toast.error(errorDetails);\n      logger.debug(error);\n    }\n  }, [addToken]);\n\n  if (!canAddAsset) return null;\n\n  return (\n    <button\n      type=\"button\"\n      className=\"flex items-center text-sm text-primary-500 hover:text-primary-700 disabled:opacity-50 dark:text-foreground-secondary dark:hover:text-foreground-primary [&_path]:fill-primary-500 [&_path]:hover:fill-primary-700 dark:[&_path]:fill-current dark:hover:[&_path]:fill-current\"\n      onClick={handleAddToken}\n      disabled={isLoading}\n    >\n      <PlusIcon width={18} height={18} className=\"-mr-0.5\" />\n      <span>Add token to Wallet</span>\n    </button>\n  );\n}\n"
  },
  {
    "path": "src/features/tokens/SelectOrInputTokenIds.tsx",
    "content": "import { TextField } from '../../components/input/TextField';\nimport { SelectTokenIdField } from './SelectTokenIdField';\n\n// import { useContractSupportsTokenByOwner, useIsSenderNftOwner } from './balances';\n\nexport function SelectOrInputTokenIds({ disabled }: { disabled: boolean }) {\n  // const accountAddress = useAccountAddressForChain(origin);\n  // const { isContractAllowToGetTokenIds } = useContractSupportsTokenByOwner(\n  //   activeToken,\n  //   accountAddress,\n  // );\n  const isContractAllowToGetTokenIds = true;\n\n  return isContractAllowToGetTokenIds ? (\n    <SelectTokenIdField name=\"amount\" disabled={disabled} />\n  ) : (\n    <InputTokenId disabled={disabled} />\n  );\n}\n\nfunction InputTokenId({ disabled }: { disabled: boolean }) {\n  // const {\n  //   values: { amount },\n  // } = useFormikContext<TransferFormValues>();\n  // useIsSenderNftOwner(token, amount);\n\n  return (\n    <div className=\"relative w-full\">\n      <TextField\n        name=\"amount\"\n        placeholder=\"Input Token Id\"\n        className=\"w-full\"\n        type=\"number\"\n        step=\"any\"\n        disabled={disabled}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/features/tokens/SelectTokenIdField.tsx",
    "content": "import { ChevronIcon, Modal, SpinnerIcon } from '@hyperlane-xyz/widgets';\nimport { useField } from 'formik';\nimport { useState } from 'react';\n\ntype Props = {\n  name: string;\n  disabled?: boolean;\n};\n\n// TODO: fix or remove NFT token path\nexport function SelectTokenIdField({ name, disabled }: Props) {\n  const [, , helpers] = useField<number>(name);\n  const [tokenId, setTokenId] = useState<string | undefined>(undefined);\n  const handleChange = (newTokenId: string) => {\n    helpers.setValue(parseInt(newTokenId));\n    setTokenId(newTokenId);\n  };\n\n  const isLoading = false;\n  const tokenIds = [];\n\n  const [isModalOpen, setIsModalOpen] = useState(false);\n\n  const onClick = () => {\n    if (!disabled) setIsModalOpen(true);\n  };\n\n  return (\n    <div className=\"flex flex-col items-center\">\n      <button type=\"button\" className={styles.base} onClick={onClick}>\n        <div className=\"flex items-center\">\n          <span className={`ml-2 ${!tokenId && 'text-slate-400'}`}>\n            {tokenId ? tokenId : 'Select Token Id'}\n          </span>\n        </div>\n        <ChevronIcon width={12} height={8} direction=\"s\" />\n      </button>\n      <SelectTokenIdModal\n        isOpen={isModalOpen}\n        tokenIds={tokenIds}\n        isLoading={isLoading}\n        close={() => setIsModalOpen(false)}\n        onSelect={handleChange}\n      />\n    </div>\n  );\n}\n\nexport function SelectTokenIdModal({\n  isOpen,\n  tokenIds,\n  isLoading,\n  close,\n  onSelect,\n}: {\n  isOpen: boolean;\n  tokenIds: string[] | null | undefined;\n  isLoading: boolean;\n  close: () => void;\n  onSelect: (tokenId: string) => void;\n}) {\n  const onSelectTokenId = (tokenId: string) => {\n    return () => {\n      onSelect(tokenId);\n      close();\n    };\n  };\n\n  return (\n    <Modal\n      isOpen={isOpen}\n      title=\"Select Token Id\"\n      close={close}\n      showCloseButton\n      panelClassname=\"p-4\"\n    >\n      <div className=\"mt-2 flex flex-col space-y-1\">\n        {isLoading ? (\n          <div className=\"my-24 flex flex-col items-center\">\n            <SpinnerIcon width={80} height={80} />\n            <h3 className=\"mt-5 text-sm text-gray-500\">Finding token IDs</h3>\n          </div>\n        ) : tokenIds && tokenIds.length !== 0 ? (\n          tokenIds.map((id) => (\n            <button\n              key={id}\n              className=\"flex items-center rounded px-2 py-1.5 text-sm transition-all duration-200 hover:bg-gray-100 active:bg-gray-200\"\n              onClick={onSelectTokenId(id)}\n            >\n              <span className=\"ml-2\">{id}</span>\n            </button>\n          ))\n        ) : (\n          <div className=\"px-2 py-1.5 text-sm text-gray-500 transition-all duration-200\">\n            No token ids found\n          </div>\n        )}\n      </div>\n    </Modal>\n  );\n}\n\nconst styles = {\n  base: 'mt-1.5 w-full px-2.5 py-2 flex items-center justify-between text-sm bg-white rounded border border-gray-400 outline-none transition-colors duration-500',\n  enabled: 'hover:bg-gray-50 active:bg-gray-100 focus:border-primary-500',\n  disabled: 'bg-gray-150 cursor-default',\n};\n"
  },
  {
    "path": "src/features/tokens/TokenChainIcon.tsx",
    "content": "import { IToken } from '@hyperlane-xyz/sdk';\n\nimport { ChainLogo } from '../../components/icons/ChainLogo';\nimport { TokenIcon } from '../../components/icons/TokenIcon';\n\ninterface Props {\n  token: IToken;\n  size?: number;\n}\n\nexport function TokenChainIcon({ token, size = 32 }: Props) {\n  // Chain logo is 45% of token size, with minimum of 12px\n  const chainLogoSize = Math.max(Math.floor(size * 0.45), 12);\n  // Add 2px padding around chain logo for the white border/background\n  const chainLogoContainerSize = chainLogoSize + 2;\n\n  return (\n    <div className=\"relative inline-block\" style={{ width: size, height: size }}>\n      <TokenIcon token={token} size={size} />\n      <div\n        className=\"absolute -bottom-0.5 -right-0.5 rounded-full border border-white bg-white dark:border-white/[0.22] dark:bg-surface\"\n        style={{\n          width: chainLogoContainerSize,\n          height: chainLogoContainerSize,\n        }}\n      >\n        <ChainLogo chainName={token.chainName} size={chainLogoSize} />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/features/tokens/TokenList.tsx",
    "content": "import { ChainName, Token } from '@hyperlane-xyz/sdk';\nimport { Tooltip, useDebounce } from '@hyperlane-xyz/widgets';\nimport React, { useEffect, useMemo, useRef, useState, useTransition } from 'react';\n\nimport { config } from '../../consts/config';\nimport { useTokenBalances } from '../balances/hooks';\nimport { formatBalance, formatUsd, getUsdValue } from '../balances/utils';\nimport { useDisabledChains, useMultiProvider } from '../chains/hooks';\nimport { getChainDisplayName } from '../chains/utils';\nimport { useCollateralGroups, useTokens } from './hooks';\nimport { TokenChainIcon } from './TokenChainIcon';\nimport { TokenSelectionMode } from './types';\nimport { useTokenPrices } from './useTokenPrice';\nimport { checkTokenHasRoute, getTokenKey } from './utils';\n\nconst featuredSet = new Set(config.featuredTokens.map((t) => t.toLowerCase()));\n\nfunction isFeaturedToken(token: Token): boolean {\n  return featuredSet.has(`${token.chainName}-${token.symbol}`.toLowerCase());\n}\n\nfunction matchesSearch(\n  token: Token,\n  query: string,\n  multiProvider: ReturnType<typeof useMultiProvider>,\n): boolean {\n  return (\n    token.name.toLowerCase().includes(query) ||\n    token.symbol.toLowerCase().includes(query) ||\n    token.addressOrDenom.toLowerCase().includes(query) ||\n    token.collateralAddressOrDenom?.toLowerCase().includes(query) ||\n    getChainDisplayName(multiProvider, token.chainName).toLowerCase().includes(query)\n  );\n}\n\ninterface TokenListProps {\n  selectionMode: TokenSelectionMode;\n  searchQuery: string;\n  chainFilter: ChainName | null;\n  onSelect: (token: Token) => void;\n  counterpartToken?: Token;\n  /** Recipient address for destination balance lookups */\n  recipient?: string;\n}\n\nexport function TokenList({\n  selectionMode,\n  searchQuery,\n  chainFilter,\n  onSelect,\n  counterpartToken,\n  recipient,\n}: TokenListProps) {\n  const multiProvider = useMultiProvider();\n  const disabledChains = useDisabledChains();\n  const _allTokens = useTokens();\n  const allTokens = useMemo(\n    () =>\n      disabledChains.size > 0\n        ? _allTokens.filter((t) => !disabledChains.has(t.chainName))\n        : _allTokens,\n    [_allTokens, disabledChains],\n  );\n  const collateralGroups = useCollateralGroups();\n  const debouncedSearch = useDebounce(searchQuery, 300);\n  const scrollRef = useRef<HTMLDivElement>(null);\n\n  // Deferred state for route map - allows UI to render immediately\n  const [tokenRouteMap, setTokenRouteMap] = useState<Map<string, boolean> | null>(null);\n  const [, startTransition] = useTransition();\n\n  // Default token set: featured+routable when featured defined, all tokens otherwise\n  const defaultTokens = useMemo(() => {\n    if (featuredSet.size === 0) return allTokens;\n    return allTokens.filter((t) => {\n      if (isFeaturedToken(t)) return true;\n      if (tokenRouteMap) return tokenRouteMap.get(getTokenKey(t)) ?? false;\n      return false;\n    });\n  }, [allTokens, tokenRouteMap]);\n\n  // Tokens to fetch balances for:\n  // Filter active → all matching tokens (no cap)\n  // No filter     → defaultTokens, capped at 50 with routable prioritized\n  const balanceTokens = useMemo(() => {\n    const q = debouncedSearch?.trim().toLowerCase();\n\n    if (q || chainFilter) {\n      return allTokens.filter((t) => {\n        const chainMatch = chainFilter && t.chainName === chainFilter;\n        const searchMatch = q && matchesSearch(t, q, multiProvider);\n        return chainMatch || searchMatch;\n      });\n    }\n\n    // No filter: routable first, then featured tokens (no cap)\n    // Without featured tokens: routable first, capped at 50\n    const routable: Token[] = [];\n    const rest: Token[] = [];\n    for (const t of defaultTokens) {\n      const hasRoute = tokenRouteMap ? (tokenRouteMap.get(getTokenKey(t)) ?? true) : true;\n      (hasRoute ? routable : rest).push(t);\n    }\n\n    if (featuredSet.size > 0) return [...routable, ...rest];\n\n    const maxDefault = 50;\n    const combined = [...routable, ...rest];\n    return combined.length <= maxDefault ? combined : combined.slice(0, maxDefault);\n  }, [debouncedSearch, chainFilter, allTokens, defaultTokens, tokenRouteMap, multiProvider]);\n\n  // Fetch balances — use recipient address override only in destination mode\n  const addressOverride = selectionMode === 'destination' ? recipient : undefined;\n  const {\n    balances,\n    isLoading: isBalanceLoading,\n    hasAnyAddress,\n  } = useTokenBalances(balanceTokens, chainFilter ?? 'all', addressOverride);\n  const { prices } = useTokenPrices();\n\n  // Build lookup maps: getTokenKey → balance/usdValue\n  const { balanceMap, usdMap } = useMemo(() => {\n    const bMap = new Map<string, bigint>();\n    const uMap = new Map<string, number>();\n    for (const token of balanceTokens) {\n      const key = getTokenKey(token);\n      const bal = balances[key];\n      if (bal != null && bal > 0n) {\n        bMap.set(key, bal);\n        const usd = getUsdValue(token, balances, prices);\n        if (usd != null && usd > 0) uMap.set(key, usd);\n      }\n    }\n    return { balanceMap: bMap, usdMap: uMap };\n  }, [balanceTokens, balances, prices]);\n\n  const { tokens, isLimited } = useMemo(() => {\n    const q = debouncedSearch?.trim().toLowerCase();\n    const hasFilter = !!q || !!chainFilter;\n\n    // Default view: use defaultTokens; filter active: search all tokens\n    const baseTokens = hasFilter ? allTokens : defaultTokens;\n\n    // Filter by chain\n    const chainFiltered = chainFilter\n      ? baseTokens.filter((t) => t.chainName === chainFilter)\n      : baseTokens;\n\n    // Filter by search query\n    const filtered = chainFiltered.filter((t) => {\n      if (!q) return true;\n      return matchesSearch(t, q, multiProvider);\n    });\n\n    // Sort: routable → USD value → balance → no balance → alphabetical\n    const sorted = [...filtered].sort((a, b) => {\n      const aKey = getTokenKey(a);\n      const bKey = getTokenKey(b);\n\n      // 1. Routable tokens always first\n      if (tokenRouteMap) {\n        const aHasRoute = tokenRouteMap.get(aKey) ?? true;\n        const bHasRoute = tokenRouteMap.get(bKey) ?? true;\n        if (aHasRoute && !bHasRoute) return -1;\n        if (!aHasRoute && bHasRoute) return 1;\n      }\n\n      // 2. USD value descending\n      const aUsd = usdMap.get(aKey) ?? 0;\n      const bUsd = usdMap.get(bKey) ?? 0;\n      if (aUsd > 0 || bUsd > 0) {\n        if (aUsd !== bUsd) return bUsd - aUsd;\n      }\n\n      // 3. Balance without USD descending\n      const aBal = balanceMap.get(aKey) ?? 0n;\n      const bBal = balanceMap.get(bKey) ?? 0n;\n      if (aBal > 0n || bBal > 0n) {\n        if (aBal > bBal) return -1;\n        if (aBal < bBal) return 1;\n      }\n\n      // 4. Symbol alphabetical\n      const symbolCompare = a.symbol.localeCompare(b.symbol);\n      if (symbolCompare !== 0) return symbolCompare;\n\n      // 5. Chain name alphabetical\n      return a.chainName.localeCompare(b.chainName);\n    });\n\n    // No filter: cap display at 50 only when no featured tokens\n    const maxDisplay = 50;\n    const shouldCap = !hasFilter && featuredSet.size === 0;\n    const isLimited = shouldCap && sorted.length > maxDisplay;\n    const displayTokens = isLimited ? sorted.slice(0, maxDisplay) : sorted;\n\n    return { tokens: displayTokens, isLimited };\n  }, [\n    debouncedSearch,\n    chainFilter,\n    allTokens,\n    defaultTokens,\n    multiProvider,\n    tokenRouteMap,\n    usdMap,\n    balanceMap,\n  ]);\n\n  // Compute route map in a transition (non-blocking)\n  useEffect(() => {\n    startTransition(() => {\n      if (!counterpartToken) {\n        setTokenRouteMap(null);\n        return;\n      }\n\n      const routeMap = new Map<string, boolean>();\n\n      for (const token of allTokens) {\n        const key = getTokenKey(token);\n        const originToken = selectionMode === 'origin' ? token : counterpartToken;\n        const destToken = selectionMode === 'origin' ? counterpartToken : token;\n        const hasRoute = checkTokenHasRoute(originToken, destToken, collateralGroups);\n        routeMap.set(key, hasRoute);\n      }\n\n      setTokenRouteMap(routeMap);\n    });\n  }, [allTokens, counterpartToken, selectionMode, collateralGroups]);\n\n  // Reset scroll when user changes search or chain filter\n  useEffect(() => {\n    scrollRef.current?.scrollTo(0, 0);\n  }, [searchQuery, chainFilter]);\n\n  if (tokens.length === 0) {\n    return (\n      <div className=\"token-picker-empty flex flex-1 flex-col items-center justify-center px-4 py-12 text-gray-500\">\n        <div className=\"text-base font-medium\">No tokens found</div>\n        <div className=\"mt-2 text-sm\">Try a different search or chain filter</div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"relative flex-1 overflow-hidden\">\n      <div ref={scrollRef} className=\"h-full overflow-auto\">\n        <div className=\"token-picker-header sticky top-0 z-10 border-b border-primary-50 bg-white px-4 pb-2 pt-2\">\n          <h3 className={`${styles.base} text-sm text-black`}>Token Selection</h3>\n        </div>\n        <div className=\"py-2 md:px-3\">\n          {tokens.map((token) => {\n            const key = getTokenKey(token);\n            const hasRoute = tokenRouteMap ? (tokenRouteMap.get(key) ?? true) : true;\n            const balance = balanceMap.get(key);\n            const usdValue = usdMap.get(key) ?? null;\n\n            return (\n              <TokenButton\n                key={key}\n                token={token}\n                onSelect={onSelect}\n                hasRoute={hasRoute}\n                counterpartToken={counterpartToken}\n                selectionMode={selectionMode}\n                balance={balance}\n                usdValue={usdValue}\n                isBalanceLoading={isBalanceLoading && hasAnyAddress}\n              />\n            );\n          })}\n\n          {isLimited && (\n            <div className=\"token-picker-hint mx-1 mb-3 mt-2 rounded-lg bg-blue-50 px-3 py-4 text-center\">\n              <p className=\"text-sm text-blue-600\">Search or select a chain to see more tokens</p>\n            </div>\n          )}\n          {/* Spacer for fade effect */}\n          <div className=\"h-10\" />\n        </div>\n      </div>\n      {/* Bottom fade effect */}\n      <div className=\"token-picker-fade pointer-events-none absolute bottom-0 left-0 right-0 hidden h-12 bg-gradient-to-b from-transparent to-cream-200 md:block\" />\n    </div>\n  );\n}\n\nconst TokenButton = React.memo(function TokenButton({\n  token,\n  onSelect,\n  hasRoute,\n  counterpartToken,\n  selectionMode,\n  balance,\n  usdValue,\n  isBalanceLoading,\n}: {\n  token: Token;\n  onSelect: (token: Token) => void;\n  hasRoute: boolean;\n  counterpartToken?: Token;\n  selectionMode: TokenSelectionMode;\n  balance?: bigint;\n  usdValue?: number | null;\n  isBalanceLoading: boolean;\n}) {\n  const multiProvider = useMultiProvider();\n  const chainDisplayName = getChainDisplayName(multiProvider, token.chainName);\n  const counterpartChainName = counterpartToken\n    ? getChainDisplayName(multiProvider, counterpartToken.chainName)\n    : '';\n\n  const routeDirection = selectionMode === 'destination' ? 'from' : 'to';\n  const routeTooltipMessage = counterpartToken\n    ? `No route ${routeDirection} ${counterpartToken.symbol} on ${counterpartChainName}`\n    : '';\n\n  const formattedBalance = balance != null ? formatBalance(balance, token.decimals) : null;\n  const formattedUsd = usdValue != null && usdValue > 0 ? formatUsd(usdValue) : null;\n  const showRouteUnavailable = !hasRoute && counterpartToken;\n\n  // Primary = USD if available, else balance. Secondary = balance when USD is primary.\n  const primaryValue = formattedUsd ?? formattedBalance;\n  const secondaryValue = formattedUsd ? formattedBalance : null;\n\n  return (\n    <button\n      type=\"button\"\n      className=\"token-picker-row group mb-2 flex h-[60px] w-full items-center rounded-md px-3 transition-colors hover:bg-gray-100\"\n      onClick={() => onSelect(token)}\n    >\n      <TokenChainIcon token={token} size={36} />\n\n      <div className=\"ml-3 min-w-0 flex-1 text-left\">\n        <div className=\"flex items-center gap-2\">\n          <span className={`token-picker-symbol ${styles.base} text-base text-black`}>\n            {token.symbol || 'Unknown'}\n          </span>\n          <span className=\"token-picker-chain-name text-xs text-gray-500\">{chainDisplayName}</span>\n        </div>\n        <div className={`token-picker-name ${styles.base} mt-0.5 truncate text-xs text-gray-500`}>\n          {token.name || 'Unknown Token'}\n        </div>\n      </div>\n\n      <div className=\"ml-2 shrink-0 text-right\">\n        {isBalanceLoading && !primaryValue ? (\n          <div className=\"token-picker-shimmer mb-1 ml-auto h-4 w-14 animate-pulse rounded bg-gray-100\" />\n        ) : primaryValue ? (\n          <>\n            <div className={`token-picker-usd ${styles.base} text-sm font-medium text-black`}>\n              {primaryValue}\n            </div>\n            {secondaryValue && (\n              <div className={`token-picker-meta ${styles.base} text-xs text-gray-400`}>\n                {secondaryValue}\n              </div>\n            )}\n          </>\n        ) : null}\n        {showRouteUnavailable && (\n          <div className=\"flex items-center justify-end gap-1 whitespace-nowrap text-[10px] text-gray-400\">\n            <span>Route unavailable</span>\n            <Tooltip\n              content={routeTooltipMessage}\n              id={`route-tooltip-${getTokenKey(token)}`}\n              tooltipClassName=\"token-picker-info-icon max-w-[280px]\"\n              onClick={(e) => e.stopPropagation()}\n            />\n          </div>\n        )}\n      </div>\n    </button>\n  );\n});\n\nconst styles = {\n  base: 'font-secondary font-normal',\n};\n"
  },
  {
    "path": "src/features/tokens/TokenListPanel.tsx",
    "content": "import { ChainName, Token } from '@hyperlane-xyz/sdk';\nimport { useEffect, useRef } from 'react';\n\nimport { SearchInput } from '../../components/input/SearchInput';\nimport { ChainInfo } from '../chains/hooks';\nimport { MobileChainQuickSelect } from '../chains/MobileChainQuickSelect';\nimport { TokenList } from './TokenList';\nimport { TokenSelectionMode } from './types';\n\nconst preferredChains = ['ethereum', 'solanamainnet', 'arbitrum', 'base'];\n\ninterface TokenListPanelProps {\n  selectionMode: TokenSelectionMode;\n  searchQuery: string;\n  onSearchChange: (s: string) => void;\n  chainFilter: ChainName | null;\n  onSelect: (token: Token) => void;\n  counterpartToken?: Token;\n  /** Recipient address for destination balance lookups */\n  recipient?: string;\n  /** Mobile chain selection props */\n  selectedChain: ChainName | null;\n  onSelectChain: (chain: ChainInfo | null) => void;\n  onMoreChainsClick: () => void;\n}\n\nexport function TokenListPanel({\n  selectionMode,\n  searchQuery,\n  onSearchChange,\n  chainFilter,\n  onSelect,\n  counterpartToken,\n  recipient,\n  selectedChain,\n  onSelectChain,\n  onMoreChainsClick,\n}: TokenListPanelProps) {\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  useEffect(() => {\n    // Auto-focus token search on mount\n    inputRef.current?.focus();\n  }, []);\n\n  return (\n    <div className=\"token-picker-modal flex min-w-0 flex-1 flex-col bg-white\">\n      <div className=\"shrink-0 md:p-4\">\n        <SearchInput\n          inputRef={inputRef}\n          value={searchQuery}\n          onChange={onSearchChange}\n          placeholder=\"Search Name, Symbol, or Contract Address\"\n          aria-label=\"Search tokens\"\n        />\n        {/* Mobile chain quick select (hidden on desktop) */}\n        <div className=\"mt-3 md:hidden\">\n          <MobileChainQuickSelect\n            selectedChain={selectedChain}\n            onSelectChain={onSelectChain}\n            onMoreClick={onMoreChainsClick}\n            preferredChains={preferredChains}\n          />\n        </div>\n      </div>\n      <TokenList\n        selectionMode={selectionMode}\n        searchQuery={searchQuery}\n        chainFilter={chainFilter}\n        onSelect={onSelect}\n        counterpartToken={counterpartToken}\n        recipient={recipient}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/features/tokens/TokenSelectField.tsx",
    "content": "import { Token } from '@hyperlane-xyz/sdk';\nimport { useField, useFormikContext } from 'formik';\nimport { useState } from 'react';\n\nimport { ChevronLargeIcon } from '../../components/icons/ChevronLargeIcon';\nimport { WARP_QUERY_PARAMS } from '../../consts/args';\nimport { updateQueryParams } from '../../utils/queryParams';\nimport { trackTokenSelectionEvent, trackUnsupportedRouteEvent } from '../analytics/utils';\nimport { ChainEditModal } from '../chains/ChainEditModal';\nimport { useMultiProvider } from '../chains/hooks';\nimport { getChainDisplayName } from '../chains/utils';\nimport { TransferFormValues } from '../transfer/types';\nimport { shouldClearAddress } from '../transfer/utils';\nimport { getTokenByKeyFromMap, useCollateralGroups, useTokenByKeyMap, useTokens } from './hooks';\nimport { TokenChainIcon } from './TokenChainIcon';\nimport { TokenSelectionMode } from './types';\nimport { UnifiedTokenChainModal } from './UnifiedTokenChainModal';\nimport { checkTokenHasRoute, getTokenKey } from './utils';\n\ntype Props = {\n  name: string;\n  label?: string;\n  selectionMode: TokenSelectionMode;\n  disabled?: boolean;\n  setIsNft?: (value: boolean) => void;\n  showLabel?: boolean;\n};\n\nexport function TokenSelectField({\n  name,\n  label,\n  selectionMode,\n  disabled,\n  setIsNft,\n  showLabel = true,\n}: Props) {\n  const { values, setFieldValue } = useFormikContext<TransferFormValues>();\n  const [{ value: tokenKey }, , { setValue: setTokenKey }] = useField<string | undefined>(name);\n  const [isModalOpen, setIsModalOpen] = useState(false);\n  const [editingChain, setEditingChain] = useState<string | null>(null);\n  const collateralGroups = useCollateralGroups();\n  const tokens = useTokens();\n\n  const handleEditBack = () => {\n    setEditingChain(null);\n    setIsModalOpen(true);\n  };\n\n  const multiProvider = useMultiProvider();\n  const tokenMap = useTokenByKeyMap();\n\n  // Get the current token\n  const selectedToken = getTokenByKeyFromMap(tokenMap, tokenKey);\n\n  // Get the counterpart token (destination when selecting origin, origin when selecting destination)\n  const counterpartToken =\n    selectionMode === 'origin'\n      ? getTokenByKeyFromMap(tokenMap, values.destinationTokenKey)\n      : getTokenByKeyFromMap(tokenMap, values.originTokenKey);\n\n  const handleSelectToken = (newToken: Token) => {\n    const newTokenKey = getTokenKey(newToken);\n    setTokenKey(newTokenKey);\n\n    // Track analytics - derive origin and destination from current tokens\n    const originToken =\n      selectionMode === 'origin' ? newToken : getTokenByKeyFromMap(tokenMap, values.originTokenKey);\n    const destToken =\n      selectionMode === 'destination'\n        ? newToken\n        : getTokenByKeyFromMap(tokenMap, values.destinationTokenKey);\n\n    trackTokenSelectionEvent(selectionMode, originToken, destToken, multiProvider);\n\n    // Update URL query params based on selection mode\n    if (selectionMode === 'origin') {\n      setFieldValue('amount', '');\n\n      // Auto-select destination if current one has no route from new origin\n      const hasValidRoute = destToken && checkTokenHasRoute(newToken, destToken, collateralGroups);\n      const queryParams: Record<string, string> = {\n        [WARP_QUERY_PARAMS.ORIGIN]: newToken.chainName,\n        [WARP_QUERY_PARAMS.ORIGIN_TOKEN]: newToken.symbol,\n      };\n\n      if (!hasValidRoute) {\n        const firstDest = tokens.find(\n          (t) =>\n            t.chainName !== newToken.chainName && checkTokenHasRoute(newToken, t, collateralGroups),\n        );\n        if (firstDest) {\n          setFieldValue('destinationTokenKey', getTokenKey(firstDest));\n          queryParams[WARP_QUERY_PARAMS.DESTINATION] = firstDest.chainName;\n          queryParams[WARP_QUERY_PARAMS.DESTINATION_TOKEN] = firstDest.symbol;\n          // Clear recipient if new destination protocol doesn't match\n          if (shouldClearAddress(multiProvider, values.recipient, firstDest.chainName)) {\n            setFieldValue('recipient', '');\n          }\n        }\n      }\n\n      updateQueryParams(queryParams);\n    } else {\n      // When destination changes, validate and clear custom recipient if protocol changed\n      const shouldClearRecipient = shouldClearAddress(\n        multiProvider,\n        values.recipient,\n        newToken.chainName,\n      );\n      if (shouldClearRecipient) setFieldValue('recipient', '');\n\n      // fire an event for unsupported route\n      // this will only happen for destination selection\n      // the origin selection will always pick a routable token pair by default\n      if (originToken) {\n        const tokenHasRoute = checkTokenHasRoute(originToken, newToken, collateralGroups);\n        if (!tokenHasRoute) trackUnsupportedRouteEvent(originToken, newToken, multiProvider);\n      }\n\n      updateQueryParams({\n        [WARP_QUERY_PARAMS.DESTINATION]: newToken.chainName,\n        [WARP_QUERY_PARAMS.DESTINATION_TOKEN]: newToken.symbol,\n      });\n    }\n\n    // Update NFT state if callback provided\n    if (setIsNft) {\n      setIsNft(newToken.isNft());\n    }\n  };\n\n  const openTokenSelectModal = () => {\n    if (!disabled) setIsModalOpen(true);\n  };\n\n  return (\n    <>\n      <div className=\"flex flex-col\">\n        {showLabel && label && (\n          <span className=\"mb-1 pl-0.5 text-sm text-gray-600 dark:text-foreground-secondary\">\n            {label}\n          </span>\n        )}\n        <TokenButton\n          token={selectedToken}\n          disabled={disabled}\n          onClick={openTokenSelectModal}\n          multiProvider={multiProvider}\n          testId={`token-select-${selectionMode}`}\n        />\n      </div>\n\n      <UnifiedTokenChainModal\n        isOpen={isModalOpen}\n        close={() => setIsModalOpen(false)}\n        onSelect={handleSelectToken}\n        selectionMode={selectionMode}\n        counterpartToken={counterpartToken}\n        recipient={values.recipient}\n        onEditChain={setEditingChain}\n      />\n      {editingChain && (\n        <ChainEditModal\n          isOpen={!!editingChain}\n          close={() => setEditingChain(null)}\n          onClickBack={handleEditBack}\n          chainName={editingChain}\n        />\n      )}\n    </>\n  );\n}\n\nfunction TokenButton({\n  token,\n  disabled,\n  onClick,\n  multiProvider,\n  testId,\n}: {\n  token?: Token;\n  disabled?: boolean;\n  onClick: () => void;\n  multiProvider: ReturnType<typeof useMultiProvider>;\n  testId?: string;\n}) {\n  const chainDisplayName = token ? getChainDisplayName(multiProvider, token.chainName) : '';\n  const chainMetadata = token ? multiProvider.tryGetChainMetadata(token.chainName) : null;\n\n  return (\n    <button\n      type=\"button\"\n      className={`${styles.base} ${disabled ? styles.disabled : styles.enabled}`}\n      onClick={onClick}\n      disabled={disabled}\n      data-testid={testId}\n      data-chain={token?.chainName}\n      data-is-testnet={token ? String(!!chainMetadata?.isTestnet) : undefined}\n    >\n      {token ? (\n        <div className=\"flex min-w-0 flex-1 items-center gap-2.5\">\n          <TokenChainIcon token={token} size={36} />\n          <div className=\"flex min-w-0 flex-col items-start\">\n            <span className=\"font-secondary text-lg font-normal text-gray-900 dark:text-foreground-primary\">\n              {token.symbol}\n            </span>\n            <span className=\"text-sm text-gray-900 dark:text-foreground-primary\">\n              {chainDisplayName}\n            </span>\n          </div>\n        </div>\n      ) : (\n        <span className=\"text-sm text-gray-400 dark:text-foreground-secondary\">Select token</span>\n      )}\n      <div className=\"transfer-token-chevron flex h-10 w-10 items-center justify-center rounded-full border border-gray-400 bg-white drop-shadow-button transition-colors duration-150 group-hover:bg-gray-50 dark:border-primary-300/35 dark:bg-primary-300/15 dark:text-foreground-primary dark:group-hover:bg-primary-300/[0.28] dark:[&_path]:fill-current\">\n        <ChevronLargeIcon width={14} height={18} />\n      </div>\n    </button>\n  );\n}\n\nconst styles = {\n  base: 'transfer-token-field group flex w-full items-center justify-between rounded-[7px] border border-gray-400/25 px-1.5 py-2 shadow-sm transition-all duration-150 dark:border-primary-300/25 dark:bg-transparent dark:text-foreground-primary',\n  enabled:\n    'cursor-pointer hover:bg-gray-50 dark:hover:border-primary-300/50 dark:hover:bg-primary-300/[0.08]',\n  disabled: 'cursor-not-allowed opacity-60',\n};\n"
  },
  {
    "path": "src/features/tokens/UnifiedTokenChainModal.tsx",
    "content": "import { Token } from '@hyperlane-xyz/sdk';\nimport { Modal } from '@hyperlane-xyz/widgets';\nimport { useCallback, useState } from 'react';\n\nimport { ModalHeader } from '../../components/layout/ModalHeader';\nimport { trackChainSelectionEvent } from '../analytics/utils';\nimport { ChainFilterPanel } from '../chains/ChainFilterPanel';\nimport { ChainInfo } from '../chains/hooks';\nimport { TokenListPanel } from './TokenListPanel';\nimport { TokenSelectionMode } from './types';\n\ninterface Props {\n  isOpen: boolean;\n  close: () => void;\n  onSelect: (token: Token) => void;\n  selectionMode: TokenSelectionMode;\n  /** The currently selected token on the counterpart side (destination when selecting origin, origin when selecting destination) */\n  counterpartToken?: Token;\n  /** Recipient address for destination balance lookups */\n  recipient?: string;\n  /** Called when user clicks a chain in edit mode - closes this modal first */\n  onEditChain?: (chainName: string) => void;\n}\n\nexport function UnifiedTokenChainModal({\n  isOpen,\n  close,\n  onSelect,\n  selectionMode,\n  counterpartToken,\n  recipient,\n  onEditChain,\n}: Props) {\n  const [chainSearch, setChainSearch] = useState('');\n  const [tokenSearch, setTokenSearch] = useState('');\n  const [selectedChain, setSelectedChain] = useState<ChainInfo | null>(null);\n  // Mobile-only state: whether to show the full chain list\n  const [showMobileChainList, setShowMobileChainList] = useState(false);\n\n  const onClose = useCallback(() => {\n    close();\n    setChainSearch('');\n    setTokenSearch('');\n    setSelectedChain(null);\n    setShowMobileChainList(false);\n  }, [close]);\n\n  const handleSelectToken = useCallback(\n    (token: Token) => {\n      onSelect(token);\n      onClose();\n    },\n    [onSelect, onClose],\n  );\n\n  const handleSelectChain = (chain: ChainInfo | null) => {\n    if (selectedChain?.name === chain?.name) return;\n    trackChainSelectionEvent(selectionMode, chain, selectedChain);\n    setSelectedChain(chain);\n  };\n\n  // Mobile: when selecting a chain from the full list, go back to tokens\n  const handleSelectChainMobile = (chain: ChainInfo | null) => {\n    handleSelectChain(chain);\n    setShowMobileChainList(false);\n  };\n\n  const handleEditChain = (chainName: string) => {\n    close();\n    onEditChain?.(chainName);\n  };\n\n  return (\n    <Modal\n      isOpen={isOpen}\n      close={onClose}\n      panelClassname=\"token-picker-modal p-0 max-w-sm md:max-w-[800px] overflow-hidden\"\n    >\n      <ModalHeader>Select Token</ModalHeader>\n      <div className=\"flex h-[80vh] gap-4 p-4 md:h-[582px]\">\n        {/* Chain filter panel: always visible on desktop, conditionally visible on mobile */}\n        <div className={`${showMobileChainList ? 'flex flex-1' : 'hidden'} md:flex md:flex-none`}>\n          <ChainFilterPanel\n            searchQuery={chainSearch}\n            onSearchChange={setChainSearch}\n            selectedChain={selectedChain?.name ?? null}\n            onSelectChain={handleSelectChainMobile}\n            onEditChain={handleEditChain}\n            showBackButton={showMobileChainList}\n            onBack={() => setShowMobileChainList(false)}\n          />\n        </div>\n\n        {/* Token list panel: hidden on mobile when showing chain list */}\n        <div className={`min-w-0 flex-1 ${showMobileChainList ? 'hidden md:flex' : 'flex'}`}>\n          <TokenListPanel\n            selectionMode={selectionMode}\n            searchQuery={tokenSearch}\n            onSearchChange={setTokenSearch}\n            chainFilter={selectedChain?.name ?? null}\n            onSelect={handleSelectToken}\n            counterpartToken={counterpartToken}\n            recipient={recipient}\n            selectedChain={selectedChain?.name ?? null}\n            onSelectChain={handleSelectChain}\n            onMoreChainsClick={() => setShowMobileChainList(true)}\n          />\n        </div>\n      </div>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "src/features/tokens/approval.ts",
    "content": "import { IToken, QuotedCallsParams } from '@hyperlane-xyz/sdk';\nimport { useAccountAddressForChain } from '@hyperlane-xyz/widgets/walletIntegrations/multiProtocol';\nimport { useQuery } from '@tanstack/react-query';\n\nimport { useToastError } from '../../components/toast/useToastError';\nimport { useMultiProvider } from '../chains/hooks';\nimport { useWarpCore } from './hooks';\n\nexport function useIsApproveRequired(\n  token?: IToken,\n  amount?: string,\n  enabled = true,\n  quotedCallsParams?: QuotedCallsParams | null,\n) {\n  const multiProvider = useMultiProvider();\n  const warpCore = useWarpCore();\n\n  const owner = useAccountAddressForChain(multiProvider, token?.chainName);\n\n  const { isLoading, isError, error, data } = useQuery({\n    // The Token class is not serializable, so we can't use it as a key\n    // eslint-disable-next-line @tanstack/query/exhaustive-deps\n    queryKey: [\n      'useIsApproveRequired',\n      owner,\n      amount,\n      token?.addressOrDenom,\n      quotedCallsParams?.address,\n    ],\n    queryFn: async () => {\n      if (!token || !owner || !amount) return false;\n      // QuotedCalls: approval target is the quotedCalls contract, not the router\n      // TODO: upstream a `spender` override to WarpCore.isApproveRequired so this\n      // parallel adapter call can be removed.\n      if (quotedCallsParams?.address) {\n        const adapter = token.getAdapter(warpCore.multiProvider);\n        return adapter.isApproveRequired(owner, quotedCallsParams.address, amount);\n      }\n      return warpCore.isApproveRequired({ originTokenAmount: token.amount(amount), owner });\n    },\n    enabled,\n  });\n\n  useToastError(error, 'Error fetching approval status');\n\n  return { isLoading, isError, isApproveRequired: !!data };\n}\n"
  },
  {
    "path": "src/features/tokens/hooks.ts",
    "content": "import { IToken, Token, WarpCore } from '@hyperlane-xyz/sdk';\nimport {\n  useAccountForChain,\n  useActiveChains,\n  useWatchAsset,\n} from '@hyperlane-xyz/widgets/walletIntegrations/multiProtocol';\nimport { useMutation } from '@tanstack/react-query';\n\nimport { ADD_ASSET_SUPPORTED_PROTOCOLS, WARP_QUERY_PARAMS } from '../../consts/args';\nimport { config } from '../../consts/config';\nimport { getQueryParams } from '../../utils/queryParams';\nimport { useMultiProvider } from '../chains/hooks';\nimport { tryGetValidChainName } from '../chains/utils';\nimport { useStore } from '../store';\nimport { getTokenKey } from './utils';\n\nexport function useWarpCore() {\n  return useStore((s) => s.warpCore);\n}\n/**\n * Find a token by its key from a WarpCore or Token array\n */\nexport function getTokenByKey(tokens: Token[], key: string | undefined): Token | undefined {\n  if (!key) return undefined;\n  return tokens.find((token) => getTokenKey(token) === key);\n}\n\n// Helper to find token by chainName-symbol format\nfunction findTokenByChainSymbol(tokens: Token[], chainSymbol: string): Token | undefined {\n  const [chainName, symbol] = chainSymbol.split('-');\n  if (!chainName || !symbol) return undefined;\n  return tokens.find(\n    (t) => t.chainName === chainName && t.symbol.toLowerCase() === symbol.toLowerCase(),\n  );\n}\n\n/**\n * Get initial origin and destination token keys from URL params\n * Returns { originTokenKey, destinationTokenKey } for form initialization\n */\nexport function getInitialTokenKeys(\n  warpCore: WarpCore,\n  tokens: Token[],\n): { originTokenKey: string | undefined; destinationTokenKey: string | undefined } {\n  // Early return if no tokens\n  if (tokens.length === 0) {\n    return { originTokenKey: undefined, destinationTokenKey: undefined };\n  }\n\n  // 1. First priority: URL params\n  const params = getQueryParams();\n  const originChainQuery = tryGetValidChainName(\n    params.get(WARP_QUERY_PARAMS.ORIGIN),\n    warpCore.multiProvider,\n  );\n  const destinationChainQuery = tryGetValidChainName(\n    params.get(WARP_QUERY_PARAMS.DESTINATION),\n    warpCore.multiProvider,\n  );\n  const originTokenSymbol = params.get(WARP_QUERY_PARAMS.ORIGIN_TOKEN);\n  const destinationTokenSymbol = params.get(WARP_QUERY_PARAMS.DESTINATION_TOKEN);\n\n  // Try to find origin token from URL params (chain + symbol)\n  let originToken: Token | undefined;\n  if (originChainQuery && originTokenSymbol) {\n    originToken = tokens.find(\n      (t) =>\n        t.chainName === originChainQuery &&\n        t.symbol.toLowerCase() === originTokenSymbol.toLowerCase(),\n    );\n  }\n\n  // 2. Second priority: Config default token (format: chainName-symbol)\n  if (!originToken && config.defaultOriginToken) {\n    originToken = findTokenByChainSymbol(tokens, config.defaultOriginToken);\n  }\n\n  // 3. Third priority: First featured token with connections\n  if (!originToken && config.featuredTokens.length > 0) {\n    for (const ft of config.featuredTokens) {\n      const candidate = findTokenByChainSymbol(tokens, ft);\n      if (candidate?.connections?.length) {\n        originToken = candidate;\n        break;\n      }\n    }\n  }\n\n  // 4. Last resort: First available token with connections\n  if (!originToken) {\n    originToken = tokens.find((t) => t.connections && t.connections.length > 0);\n  }\n\n  // Try to find destination token from URL params (chain + symbol)\n  let destinationToken: Token | undefined;\n  if (destinationChainQuery && destinationTokenSymbol) {\n    destinationToken = tokens.find(\n      (t) =>\n        t.chainName === destinationChainQuery &&\n        t.symbol.toLowerCase() === destinationTokenSymbol.toLowerCase(),\n    );\n  }\n\n  // Fallback: use config default token (format: chainName-symbol)\n  if (!destinationToken && config.defaultDestinationToken) {\n    destinationToken = findTokenByChainSymbol(tokens, config.defaultDestinationToken);\n  }\n\n  // Last resort: first connection from origin token\n  if (!destinationToken && originToken) {\n    const firstConnection = originToken.connections?.[0];\n    const connectedChain = firstConnection?.token?.chainName;\n    const connectedSymbol = firstConnection?.token?.symbol;\n    destinationToken = connectedChain\n      ? tokens.find(\n          (t) =>\n            t.chainName === connectedChain &&\n            t.symbol.toLowerCase() === connectedSymbol?.toLowerCase(),\n        )\n      : undefined;\n  }\n\n  return {\n    originTokenKey: originToken ? getTokenKey(originToken) : undefined,\n    destinationTokenKey: destinationToken ? getTokenKey(destinationToken) : undefined,\n  };\n}\n\n/** Raw tokens from WarpCore (not deduplicated) */\nexport function useWarpCoreTokens() {\n  return useWarpCore().tokens;\n}\n\n/** Unified tokens array (deduplicated, can be origin or destination) */\nexport function useTokens() {\n  return useStore((s) => s.tokens);\n}\n\nexport function useCollateralGroups() {\n  return useStore((s) => s.collateralGroups);\n}\n\n/** Pre-computed token key to Token map for O(1) lookups */\nexport function useTokenByKeyMap() {\n  return useStore((s) => s.tokenByKeyMap);\n}\n\n/**\n * O(1) token lookup by key using the pre-computed map.\n * Use this instead of getTokenByKey() for better performance.\n */\nexport function getTokenByKeyFromMap(\n  tokenByKeyMap: Map<string, Token>,\n  key: string | undefined,\n): Token | undefined {\n  if (!key) return undefined;\n  return tokenByKeyMap.get(key);\n}\n\nexport function tryFindToken(\n  warpCore: WarpCore,\n  chain: ChainName,\n  addressOrDenom?: string,\n): IToken | null {\n  try {\n    return warpCore.findToken(chain, addressOrDenom);\n  } catch {\n    return null;\n  }\n}\n\nexport function tryFindTokenConnection(token: Token, chainName: string) {\n  const connectedToken = token.connections?.find(\n    (connection) => connection.token.chainName === chainName,\n  );\n\n  return connectedToken ? connectedToken.token : null;\n}\n\nexport function useAddToken(token?: IToken) {\n  const multiProvider = useMultiProvider();\n  const activeChains = useActiveChains(multiProvider);\n  const watchAsset = useWatchAsset(multiProvider);\n  const account = useAccountForChain(multiProvider, token?.chainName);\n  const isAccountReady = account?.isReady;\n  const isSupportedProtocol = token\n    ? ADD_ASSET_SUPPORTED_PROTOCOLS.includes(token?.protocol)\n    : false;\n\n  const canAddAsset = token && isAccountReady && isSupportedProtocol;\n\n  const { isPending, mutateAsync } = useMutation({\n    mutationFn: () => {\n      if (!canAddAsset)\n        throw new Error('Cannot import this asset, please check the token imported');\n\n      const { addAsset } = watchAsset[token.protocol];\n      const activeChain = activeChains.chains[token.protocol];\n\n      if (!activeChain.chainName)\n        throw new Error('Not active chain found, please check if your wallet is connected ');\n\n      return addAsset(token, activeChain.chainName);\n    },\n  });\n\n  return { addToken: mutateAsync, isLoading: isPending, canAddAsset };\n}\n"
  },
  {
    "path": "src/features/tokens/types.ts",
    "content": "import { Token, TokenAmount } from '@hyperlane-xyz/sdk';\n\nexport interface TokensWithDestinationBalance {\n  originToken: Token;\n  destinationToken: Token;\n  balance: bigint;\n}\n\nexport interface TokenWithFee {\n  token: Token;\n  tokenFee?: TokenAmount;\n  balance: bigint;\n}\n\nexport type TokenSelectionMode = 'origin' | 'destination';\nexport type DefaultMultiCollateralRoutes = Record<ChainName, Record<Address, Address>>;\n"
  },
  {
    "path": "src/features/tokens/useTokenPrice.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\n\nimport { logger } from '../../utils/logger';\nimport { useStore } from '../store';\n\nconst PRICE_STALE_TIME = 300_000; // 5 min\n\ntype CoinGeckoResponse = Record<string, { usd: number }>;\n\n/** Fetch USD prices from CoinGecko for one or more coinGeckoIds. */\nexport async function fetchPrices(ids: string[]): Promise<Record<string, number>> {\n  if (ids.length === 0) return {};\n  try {\n    const res = await fetch(\n      `https://api.coingecko.com/api/v3/simple/price?ids=${ids.join(',')}&vs_currencies=usd`,\n    );\n    if (!res.ok) {\n      logger.warn(`CoinGecko API error: ${res.status} ${res.statusText}`);\n      return {};\n    }\n    const data: CoinGeckoResponse = await res.json();\n    const result: Record<string, number> = {};\n    for (const [id, priceData] of Object.entries(data)) {\n      if (priceData?.usd != null) result[id] = priceData.usd;\n    }\n    return result;\n  } catch (error) {\n    logger.warn('Failed to fetch token prices', error);\n    return {};\n  }\n}\n\n/**\n * Batch-fetch USD prices for all tokens via CoinGecko.\n * Uses deduplicated coinGeckoIds from the store (computed during init).\n * Single query shared across all consumers via TanStack Query deduplication.\n */\nexport function useTokenPrices() {\n  const coinGeckoIds = useStore((s) => s.coinGeckoIds);\n\n  const { data, isLoading } = useQuery({\n    queryKey: ['tokenPrices', coinGeckoIds],\n    queryFn: () => fetchPrices(coinGeckoIds),\n    enabled: coinGeckoIds.length > 0,\n    staleTime: PRICE_STALE_TIME,\n    refetchInterval: PRICE_STALE_TIME,\n    refetchOnWindowFocus: false,\n    refetchOnMount: false,\n  });\n\n  return { prices: data ?? {}, isLoading };\n}\n"
  },
  {
    "path": "src/features/tokens/utils.test.ts",
    "content": "import { TestChainName, TokenStandard, WarpCore } from '@hyperlane-xyz/sdk';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport { createMockToken, createTokenConnectionMock } from '../../utils/test';\nimport {\n  buildTokensArray,\n  checkTokenHasRoute,\n  dedupeTokensByCollateral,\n  findConnectedDestinationToken,\n  findRouteToken,\n  getTokenKey,\n  groupTokensByCollateral,\n  isValidMultiCollateralToken,\n  setResolvedUnderlyingMap,\n  tryGetDefaultOriginToken,\n} from './utils';\n\nbeforeEach(() => {\n  vi.restoreAllMocks();\n  // Reset resolved underlying map between tests\n  setResolvedUnderlyingMap(new Map());\n});\n\ndescribe('isValidMultiCollateralToken', () => {\n  test('should return false if originToken is not collateralized', () => {\n    const token = createMockToken({ standard: TokenStandard.CosmosIbc });\n    const destToken = createMockToken({ chainName: TestChainName.test2 });\n    expect(isValidMultiCollateralToken(token, destToken)).toBe(false);\n  });\n\n  test('should return false if destinationToken is not collateralized', () => {\n    const token = createMockToken({\n      connections: [\n        createTokenConnectionMock(undefined, {\n          standard: TokenStandard.CosmosIbc,\n          collateralAddressOrDenom: undefined,\n        }),\n      ],\n    });\n    const destToken = createMockToken({\n      chainName: TestChainName.test2,\n      standard: TokenStandard.CosmosIbc,\n      collateralAddressOrDenom: undefined,\n    });\n    expect(isValidMultiCollateralToken(token, destToken)).toBe(false);\n  });\n\n  test('should return true if originToken is HypNative even without collateralAddressOrDenom', () => {\n    const token = createMockToken({\n      collateralAddressOrDenom: undefined,\n      standard: TokenStandard.EvmHypNative,\n    });\n    const destToken = createMockToken({\n      chainName: TestChainName.test2,\n      standard: TokenStandard.EvmHypNative,\n      collateralAddressOrDenom: undefined,\n    });\n    expect(isValidMultiCollateralToken(token, destToken)).toBe(true);\n  });\n\n  test('should return true if destinationToken is HypNative even without collateralAddressOrDenom', () => {\n    const token = createMockToken({\n      standard: TokenStandard.EvmHypNative,\n      collateralAddressOrDenom: undefined,\n    });\n    const destToken = createMockToken({\n      chainName: TestChainName.test2,\n      standard: TokenStandard.EvmHypNative,\n      collateralAddressOrDenom: undefined,\n    });\n    expect(isValidMultiCollateralToken(token, destToken)).toBe(true);\n  });\n\n  test('should return true if destinationToken standard is in TOKEN_COLLATERALIZED_STANDARDS even without collateralAddressOrDenom', () => {\n    const token = createMockToken();\n    const destToken = createMockToken({\n      chainName: TestChainName.test2,\n      collateralAddressOrDenom: undefined,\n      standard: TokenStandard.EvmHypCollateral,\n    });\n    expect(isValidMultiCollateralToken(token, destToken)).toBe(true);\n  });\n\n  test('should return true when both tokens are collateralized', () => {\n    const token = createMockToken({\n      connections: [createTokenConnectionMock()],\n    });\n    const destinationToken = token.getConnectionForChain(TestChainName.test2)!.token;\n    expect(isValidMultiCollateralToken(token, destinationToken)).toBe(true);\n  });\n});\n\ndescribe('tryGetDefaultOriginToken', () => {\n  test('should return null when not a valid multi-collateral token', () => {\n    const originToken = createMockToken({\n      collateralAddressOrDenom: undefined,\n      standard: TokenStandard.EvmHypSynthetic,\n    });\n    const destinationToken = createMockToken();\n\n    const result = tryGetDefaultOriginToken(originToken, destinationToken, {}, []);\n\n    expect(result).toBeNull();\n  });\n\n  test('should return null when defaultMultiCollateralRoutes is undefined', () => {\n    const originToken = createMockToken({\n      chainName: 'ethereum',\n      collateralAddressOrDenom: '0xUSDC',\n      connections: [createTokenConnectionMock()],\n    });\n    const destinationToken = originToken.getConnectionForChain(TestChainName.test2)!.token;\n\n    const result = tryGetDefaultOriginToken(originToken, destinationToken, undefined, []);\n\n    expect(result).toBeNull();\n  });\n\n  test('should return null when origin chain not in config', () => {\n    const originToken = createMockToken({\n      chainName: 'unknownchain',\n      collateralAddressOrDenom: '0xUSDC',\n      connections: [createTokenConnectionMock()],\n    });\n    const destinationToken = originToken.getConnectionForChain(TestChainName.test2)!.token;\n\n    const defaultRoutes = {\n      ethereum: { '0xUSDC': '0xWarpRoute' },\n      arbitrum: { '0xUSDC': '0xWarpRoute' },\n    };\n\n    const result = tryGetDefaultOriginToken(originToken, destinationToken, defaultRoutes, []);\n\n    expect(result).toBeNull();\n  });\n\n  test('should return null when destination chain not in config', () => {\n    const originToken = createMockToken({\n      chainName: 'ethereum',\n      collateralAddressOrDenom: '0xUSDC',\n      connections: [createTokenConnectionMock(undefined, { chainName: 'unknownchain' })],\n    });\n    const destinationToken = originToken.getConnectionForChain('unknownchain')!.token;\n\n    const defaultRoutes = {\n      ethereum: { '0xUSDC': '0xWarpRoute' },\n      arbitrum: { '0xUSDC': '0xWarpRoute' },\n    };\n\n    const result = tryGetDefaultOriginToken(originToken, destinationToken, defaultRoutes, []);\n\n    expect(result).toBeNull();\n  });\n\n  test('should return null when collateral address not found in config', () => {\n    const originToken = createMockToken({\n      chainName: 'ethereum',\n      collateralAddressOrDenom: '0xUnknownCollateral',\n      connections: [createTokenConnectionMock(undefined, { chainName: 'arbitrum' })],\n    });\n    const destinationToken = originToken.getConnectionForChain('arbitrum')!.token;\n\n    const defaultRoutes = {\n      ethereum: { '0xUSDC': '0xWarpRoute' },\n      arbitrum: { '0xUSDC': '0xWarpRoute' },\n    };\n\n    const result = tryGetDefaultOriginToken(originToken, destinationToken, defaultRoutes, []);\n\n    expect(result).toBeNull();\n  });\n\n  test('should return null when matching token not found in tokensWithSameCollateralAddresses', () => {\n    const originToken = createMockToken({\n      chainName: 'ethereum',\n      collateralAddressOrDenom: '0xUSDC',\n      connections: [\n        createTokenConnectionMock(undefined, {\n          chainName: 'arbitrum',\n          collateralAddressOrDenom: '0xUSDC',\n        }),\n      ],\n    });\n    const destinationToken = originToken.getConnectionForChain('arbitrum')!.token;\n\n    const defaultRoutes = {\n      ethereum: { '0xUSDC': '0xNonExistentWarpRoute' },\n      arbitrum: { '0xUSDC': '0xNonExistentDestWarpRoute' },\n    };\n\n    // Empty array - no tokens to match\n    const result = tryGetDefaultOriginToken(originToken, destinationToken, defaultRoutes, []);\n\n    expect(result).toBeNull();\n  });\n\n  test('should return default token when found in tokensWithSameCollateralAddresses', () => {\n    // Use proper hex addresses for eqAddress comparison\n    const ORIGIN_COLLATERAL = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';\n    const DEST_COLLATERAL = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831';\n    const DEFAULT_ORIGIN_WARP = '0xe1De9910fe71cC216490AC7FCF019e13a34481D7';\n    const DEFAULT_DEST_WARP = '0xAd4350Ee0f9f5b85BaB115425426086Ae8384ebb';\n    const OTHER_ORIGIN_WARP = '0x3333333333333333333333333333333333333333';\n    const OTHER_DEST_WARP = '0x4444444444444444444444444444444444444444';\n\n    const originToken = createMockToken({\n      chainName: 'ethereum',\n      collateralAddressOrDenom: ORIGIN_COLLATERAL,\n      connections: [\n        createTokenConnectionMock(undefined, {\n          chainName: 'arbitrum',\n          collateralAddressOrDenom: DEST_COLLATERAL,\n        }),\n      ],\n    });\n    const destinationToken = originToken.getConnectionForChain('arbitrum')!.token;\n\n    // Non-default token (should not be selected)\n    const otherOriginToken = createMockToken({\n      addressOrDenom: OTHER_ORIGIN_WARP,\n      chainName: 'ethereum',\n      collateralAddressOrDenom: ORIGIN_COLLATERAL,\n    });\n    const otherDestToken = createMockToken({\n      addressOrDenom: OTHER_DEST_WARP,\n      chainName: 'arbitrum',\n      collateralAddressOrDenom: DEST_COLLATERAL,\n    });\n\n    // Default token (should be selected)\n    const defaultOriginToken = createMockToken({\n      addressOrDenom: DEFAULT_ORIGIN_WARP,\n      chainName: 'ethereum',\n      collateralAddressOrDenom: ORIGIN_COLLATERAL,\n    });\n    const defaultDestToken = createMockToken({\n      addressOrDenom: DEFAULT_DEST_WARP,\n      chainName: 'arbitrum',\n      collateralAddressOrDenom: DEST_COLLATERAL,\n    });\n\n    const defaultRoutes = {\n      ethereum: { [ORIGIN_COLLATERAL]: DEFAULT_ORIGIN_WARP },\n      arbitrum: { [DEST_COLLATERAL]: DEFAULT_DEST_WARP },\n    };\n\n    // Multiple tokens with same collateral - should find the default one\n    const tokensWithSameCollateral = [\n      { originToken: otherOriginToken, destinationToken: otherDestToken },\n      { originToken: defaultOriginToken, destinationToken: defaultDestToken },\n    ];\n\n    const result = tryGetDefaultOriginToken(\n      originToken,\n      destinationToken,\n      defaultRoutes,\n      tokensWithSameCollateral,\n    );\n\n    expect(result).toBe(defaultOriginToken);\n    expect(result).not.toBe(otherOriginToken);\n  });\n\n  test('should use native key for HypNative tokens', () => {\n    // Use proper hex addresses for eqAddress comparison\n    const NATIVE_ORIGIN_WARP = '0x1111111111111111111111111111111111111111';\n    const NATIVE_DEST_WARP = '0x2222222222222222222222222222222222222222';\n    const OTHER_NATIVE_ORIGIN_WARP = '0x5555555555555555555555555555555555555555';\n    const OTHER_NATIVE_DEST_WARP = '0x6666666666666666666666666666666666666666';\n\n    const originToken = createMockToken({\n      chainName: 'ethereum',\n      collateralAddressOrDenom: undefined,\n      standard: TokenStandard.EvmHypNative,\n      connections: [\n        createTokenConnectionMock(undefined, {\n          chainName: 'arbitrum',\n          collateralAddressOrDenom: undefined,\n          standard: TokenStandard.EvmHypNative,\n        }),\n      ],\n    });\n    const destinationToken = originToken.getConnectionForChain('arbitrum')!.token;\n\n    // Non-default native token (should not be selected)\n    const otherOriginToken = createMockToken({\n      addressOrDenom: OTHER_NATIVE_ORIGIN_WARP,\n      chainName: 'ethereum',\n      collateralAddressOrDenom: undefined,\n      standard: TokenStandard.EvmHypNative,\n    });\n    const otherDestToken = createMockToken({\n      addressOrDenom: OTHER_NATIVE_DEST_WARP,\n      chainName: 'arbitrum',\n      collateralAddressOrDenom: undefined,\n      standard: TokenStandard.EvmHypNative,\n    });\n\n    // Default native token (should be selected)\n    const defaultOriginToken = createMockToken({\n      addressOrDenom: NATIVE_ORIGIN_WARP,\n      chainName: 'ethereum',\n      collateralAddressOrDenom: undefined,\n      standard: TokenStandard.EvmHypNative,\n    });\n    const defaultDestToken = createMockToken({\n      addressOrDenom: NATIVE_DEST_WARP,\n      chainName: 'arbitrum',\n      collateralAddressOrDenom: undefined,\n      standard: TokenStandard.EvmHypNative,\n    });\n\n    const defaultRoutes = {\n      ethereum: { native: NATIVE_ORIGIN_WARP },\n      arbitrum: { native: NATIVE_DEST_WARP },\n    };\n\n    // Multiple native tokens - should find the default one\n    const tokensWithSameCollateral = [\n      { originToken: otherOriginToken, destinationToken: otherDestToken },\n      { originToken: defaultOriginToken, destinationToken: defaultDestToken },\n    ];\n\n    const result = tryGetDefaultOriginToken(\n      originToken,\n      destinationToken,\n      defaultRoutes,\n      tokensWithSameCollateral,\n    );\n\n    expect(result).toBe(defaultOriginToken);\n    expect(result).not.toBe(otherOriginToken);\n  });\n});\n\ndescribe('dedupeTokensByCollateral', () => {\n  test('should return empty array for empty input', () => {\n    expect(dedupeTokensByCollateral([])).toEqual([]);\n  });\n\n  test('should keep all non-collateralized tokens without deduplication', () => {\n    const token1 = createMockToken({\n      standard: TokenStandard.EvmHypSynthetic,\n      addressOrDenom: '0x1111111111111111111111111111111111111111',\n    });\n    const token2 = createMockToken({\n      standard: TokenStandard.EvmHypSynthetic,\n      addressOrDenom: '0x2222222222222222222222222222222222222222',\n    });\n\n    const result = dedupeTokensByCollateral([token1, token2]);\n\n    expect(result).toHaveLength(2);\n    expect(result).toContain(token1);\n    expect(result).toContain(token2);\n  });\n\n  test('should keep collateralized tokens with different collateral addresses', () => {\n    const token1 = createMockToken({\n      standard: TokenStandard.EvmHypCollateral,\n      addressOrDenom: '0x1111111111111111111111111111111111111111',\n      collateralAddressOrDenom: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',\n    });\n    const token2 = createMockToken({\n      standard: TokenStandard.EvmHypCollateral,\n      addressOrDenom: '0x2222222222222222222222222222222222222222',\n      collateralAddressOrDenom: '0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB',\n    });\n\n    const result = dedupeTokensByCollateral([token1, token2]);\n\n    expect(result).toHaveLength(2);\n    expect(result).toContain(token1);\n    expect(result).toContain(token2);\n  });\n\n  test('should dedupe collateralized tokens with same collateral on same chain', () => {\n    const collateralAddress = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';\n    const token1 = createMockToken({\n      chainName: 'ethereum',\n      standard: TokenStandard.EvmHypCollateral,\n      addressOrDenom: '0x1111111111111111111111111111111111111111',\n      collateralAddressOrDenom: collateralAddress,\n    });\n    const token2 = createMockToken({\n      chainName: 'ethereum',\n      standard: TokenStandard.EvmHypCollateral,\n      addressOrDenom: '0x2222222222222222222222222222222222222222',\n      collateralAddressOrDenom: collateralAddress,\n    });\n\n    const result = dedupeTokensByCollateral([token1, token2]);\n\n    expect(result).toHaveLength(1);\n    expect(result[0]).toBe(token1);\n  });\n\n  test('should keep tokens with same collateral on different chains', () => {\n    const collateralAddress = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';\n    const token1 = createMockToken({\n      chainName: 'ethereum',\n      standard: TokenStandard.EvmHypCollateral,\n      addressOrDenom: '0x1111111111111111111111111111111111111111',\n      collateralAddressOrDenom: collateralAddress,\n    });\n    const token2 = createMockToken({\n      chainName: 'arbitrum',\n      standard: TokenStandard.EvmHypCollateral,\n      addressOrDenom: '0x2222222222222222222222222222222222222222',\n      collateralAddressOrDenom: collateralAddress,\n    });\n\n    const result = dedupeTokensByCollateral([token1, token2]);\n\n    expect(result).toHaveLength(2);\n    expect(result).toContain(token1);\n    expect(result).toContain(token2);\n  });\n\n  test('should handle mixed collateralized and non-collateralized tokens', () => {\n    const collateralAddress = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';\n    const syntheticToken = createMockToken({\n      standard: TokenStandard.EvmHypSynthetic,\n      addressOrDenom: '0x1111111111111111111111111111111111111111',\n    });\n    const collateralToken1 = createMockToken({\n      chainName: 'ethereum',\n      standard: TokenStandard.EvmHypCollateral,\n      addressOrDenom: '0x2222222222222222222222222222222222222222',\n      collateralAddressOrDenom: collateralAddress,\n    });\n    const collateralToken2 = createMockToken({\n      chainName: 'ethereum',\n      standard: TokenStandard.EvmHypCollateral,\n      addressOrDenom: '0x3333333333333333333333333333333333333333',\n      collateralAddressOrDenom: collateralAddress,\n    });\n\n    const result = dedupeTokensByCollateral([syntheticToken, collateralToken1, collateralToken2]);\n\n    expect(result).toHaveLength(2);\n    expect(result).toContain(syntheticToken);\n    expect(result).toContain(collateralToken1);\n    expect(result).not.toContain(collateralToken2);\n  });\n\n  test('should dedupe HypNative tokens on same chain', () => {\n    const token1 = createMockToken({\n      chainName: 'ethereum',\n      standard: TokenStandard.EvmHypNative,\n      addressOrDenom: '0x1111111111111111111111111111111111111111',\n      collateralAddressOrDenom: undefined,\n    });\n    const token2 = createMockToken({\n      chainName: 'ethereum',\n      standard: TokenStandard.EvmHypNative,\n      addressOrDenom: '0x2222222222222222222222222222222222222222',\n      collateralAddressOrDenom: undefined,\n    });\n\n    const result = dedupeTokensByCollateral([token1, token2]);\n\n    expect(result).toHaveLength(1);\n    expect(result[0]).toBe(token1);\n  });\n\n  test('should keep HypNative tokens on different chains', () => {\n    const token1 = createMockToken({\n      chainName: 'ethereum',\n      standard: TokenStandard.EvmHypNative,\n      addressOrDenom: '0x1111111111111111111111111111111111111111',\n      collateralAddressOrDenom: undefined,\n    });\n    const token2 = createMockToken({\n      chainName: 'arbitrum',\n      standard: TokenStandard.EvmHypNative,\n      addressOrDenom: '0x2222222222222222222222222222222222222222',\n      collateralAddressOrDenom: undefined,\n    });\n\n    const result = dedupeTokensByCollateral([token1, token2]);\n\n    expect(result).toHaveLength(2);\n    expect(result).toContain(token1);\n    expect(result).toContain(token2);\n  });\n\n  test('should preserve order keeping first token seen', () => {\n    const collateralAddress = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';\n    const token1 = createMockToken({\n      chainName: 'ethereum',\n      standard: TokenStandard.EvmHypCollateral,\n      addressOrDenom: '0x1111111111111111111111111111111111111111',\n      collateralAddressOrDenom: collateralAddress,\n      name: 'First Token',\n    });\n    const token2 = createMockToken({\n      chainName: 'ethereum',\n      standard: TokenStandard.EvmHypCollateral,\n      addressOrDenom: '0x2222222222222222222222222222222222222222',\n      collateralAddressOrDenom: collateralAddress,\n      name: 'Second Token',\n    });\n    const token3 = createMockToken({\n      chainName: 'ethereum',\n      standard: TokenStandard.EvmHypCollateral,\n      addressOrDenom: '0x3333333333333333333333333333333333333333',\n      collateralAddressOrDenom: collateralAddress,\n      name: 'Third Token',\n    });\n\n    const result = dedupeTokensByCollateral([token1, token2, token3]);\n\n    expect(result).toHaveLength(1);\n    expect(result[0].name).toBe('First Token');\n  });\n\n  test('should dedupe by symbol as well as collateral address', () => {\n    const collateralAddress = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';\n    const usdcToken = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDC',\n      standard: TokenStandard.EvmHypCollateral,\n      addressOrDenom: '0x1111111111111111111111111111111111111111',\n      collateralAddressOrDenom: collateralAddress,\n    });\n    const usdtToken = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDT',\n      standard: TokenStandard.EvmHypCollateral,\n      addressOrDenom: '0x2222222222222222222222222222222222222222',\n      collateralAddressOrDenom: collateralAddress,\n    });\n\n    const result = dedupeTokensByCollateral([usdcToken, usdtToken]);\n\n    expect(result).toHaveLength(2);\n    expect(result).toContain(usdcToken);\n    expect(result).toContain(usdtToken);\n  });\n});\n\ndescribe('buildTokensArray', () => {\n  const ADDR_1 = '0x1111111111111111111111111111111111111111';\n  const ADDR_2 = '0x2222222222222222222222222222222222222222';\n  const ADDR_3 = '0x3333333333333333333333333333333333333333';\n  const COLLATERAL_USDC = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';\n\n  test('should return empty array for empty input', () => {\n    expect(buildTokensArray([])).toEqual([]);\n  });\n\n  test('should exclude tokens without connections', () => {\n    const token = createMockToken({ addressOrDenom: ADDR_1, connections: [] });\n    expect(buildTokensArray([token])).toHaveLength(0);\n  });\n\n  test('should include origin and destination tokens', () => {\n    const origin = createMockToken({\n      chainName: 'ethereum',\n      addressOrDenom: ADDR_1,\n      connections: [\n        createTokenConnectionMock(undefined, { chainName: 'arbitrum', addressOrDenom: ADDR_2 }),\n      ],\n    });\n\n    const result = buildTokensArray([origin]);\n\n    expect(result).toHaveLength(2);\n    expect(result.some((t) => t.addressOrDenom === ADDR_1)).toBe(true);\n    expect(result.some((t) => t.addressOrDenom === ADDR_2)).toBe(true);\n  });\n\n  test('should dedupe by token key when same token appears multiple times', () => {\n    const token1 = createMockToken({\n      chainName: 'ethereum',\n      addressOrDenom: ADDR_1,\n      name: 'First',\n      connections: [\n        createTokenConnectionMock(undefined, { chainName: 'arbitrum', addressOrDenom: ADDR_2 }),\n      ],\n    });\n    const token2 = createMockToken({\n      chainName: 'ethereum',\n      addressOrDenom: ADDR_1,\n      name: 'Second',\n      connections: [\n        createTokenConnectionMock(undefined, { chainName: 'arbitrum', addressOrDenom: ADDR_2 }),\n      ],\n    });\n\n    const result = buildTokensArray([token1, token2]);\n\n    // 1 deduped origin + 1 destination\n    expect(result).toHaveLength(2);\n    const originToken = result.find((t) => t.addressOrDenom === ADDR_1);\n    expect(originToken).toBeDefined();\n    expect(originToken!.name).toBe('First');\n  });\n\n  test('should dedupe when two origins share the same destination', () => {\n    const token1 = createMockToken({\n      chainName: 'ethereum',\n      addressOrDenom: ADDR_1,\n      connections: [\n        createTokenConnectionMock(undefined, { chainName: 'arbitrum', addressOrDenom: ADDR_3 }),\n      ],\n    });\n    const token2 = createMockToken({\n      chainName: 'optimism',\n      addressOrDenom: ADDR_2,\n      connections: [\n        createTokenConnectionMock(undefined, { chainName: 'arbitrum', addressOrDenom: ADDR_3 }),\n      ],\n    });\n\n    const result = buildTokensArray([token1, token2]);\n\n    // 2 origins + 1 shared destination\n    expect(result).toHaveLength(3);\n    expect(result.filter((t) => t.addressOrDenom === ADDR_3)).toHaveLength(1);\n  });\n\n  test('should dedupe when a token is both origin and destination', () => {\n    const tokenA = createMockToken({\n      chainName: 'ethereum',\n      addressOrDenom: ADDR_1,\n      connections: [\n        createTokenConnectionMock(undefined, { chainName: 'arbitrum', addressOrDenom: ADDR_2 }),\n      ],\n    });\n    const tokenB = createMockToken({\n      chainName: 'arbitrum',\n      addressOrDenom: ADDR_2,\n      connections: [\n        createTokenConnectionMock(undefined, { chainName: 'ethereum', addressOrDenom: ADDR_1 }),\n      ],\n    });\n\n    const result = buildTokensArray([tokenA, tokenB]);\n\n    expect(result).toHaveLength(2);\n    expect(result.filter((t) => t.addressOrDenom === ADDR_1)).toHaveLength(1);\n    expect(result.filter((t) => t.addressOrDenom === ADDR_2)).toHaveLength(1);\n  });\n\n  test('should dedupe tokens with different addresses but same collateral on same chain', () => {\n    const token1 = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDC',\n      standard: TokenStandard.EvmHypCollateral,\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: COLLATERAL_USDC,\n      connections: [\n        createTokenConnectionMock(undefined, { chainName: 'arbitrum', addressOrDenom: ADDR_3 }),\n      ],\n    });\n    const token2 = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDC',\n      standard: TokenStandard.EvmHypCollateral,\n      addressOrDenom: ADDR_2,\n      collateralAddressOrDenom: COLLATERAL_USDC,\n      connections: [\n        createTokenConnectionMock(undefined, { chainName: 'optimism', addressOrDenom: ADDR_3 }),\n      ],\n    });\n\n    const result = buildTokensArray([token1, token2]);\n\n    // Only 1 origin (deduped by collateral) + destinations\n    const originTokens = result.filter((t) => t.chainName === 'ethereum');\n    expect(originTokens).toHaveLength(1);\n    expect(originTokens[0].addressOrDenom).toBe(ADDR_1);\n  });\n});\n\ndescribe('checkTokenHasRoute', () => {\n  const COLLATERAL_A = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';\n  const COLLATERAL_B = '0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB';\n  const ADDR_1 = '0x1111111111111111111111111111111111111111';\n  const ADDR_2 = '0x2222222222222222222222222222222222222222';\n\n  test('should return true when origin group has connection to dest collateral', () => {\n    const origin = createMockToken({\n      chainName: 'ethereum',\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: COLLATERAL_A,\n      connections: [\n        createTokenConnectionMock(undefined, {\n          chainName: 'arbitrum',\n          addressOrDenom: ADDR_2,\n          collateralAddressOrDenom: COLLATERAL_B,\n        }),\n      ],\n    });\n    const dest = createMockToken({\n      chainName: 'arbitrum',\n      addressOrDenom: ADDR_2,\n      collateralAddressOrDenom: COLLATERAL_B,\n    });\n\n    const groups = groupTokensByCollateral([origin, dest]);\n    expect(checkTokenHasRoute(origin, dest, groups)).toBe(true);\n  });\n\n  test('should return true when a later same-chain connection matches dest collateral', () => {\n    const origin = createMockToken({\n      chainName: 'ethereum',\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: COLLATERAL_A,\n      connections: [\n        createTokenConnectionMock(undefined, {\n          chainName: 'arbitrum',\n          addressOrDenom: '0x3333333333333333333333333333333333333333',\n          // First same-chain connection points to a different collateral\n          collateralAddressOrDenom: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',\n        }),\n        createTokenConnectionMock(undefined, {\n          chainName: 'arbitrum',\n          addressOrDenom: ADDR_2,\n          // Second same-chain connection is the intended collateral\n          collateralAddressOrDenom: COLLATERAL_B,\n        }),\n      ],\n    });\n    const dest = createMockToken({\n      chainName: 'arbitrum',\n      addressOrDenom: ADDR_2,\n      collateralAddressOrDenom: COLLATERAL_B,\n    });\n\n    const groups = groupTokensByCollateral([origin, dest]);\n    expect(checkTokenHasRoute(origin, dest, groups)).toBe(true);\n  });\n\n  test('should return false when no connection to dest chain', () => {\n    const origin = createMockToken({\n      chainName: 'ethereum',\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: COLLATERAL_A,\n      connections: [],\n    });\n    const dest = createMockToken({\n      chainName: 'arbitrum',\n      addressOrDenom: ADDR_2,\n      collateralAddressOrDenom: COLLATERAL_B,\n    });\n\n    const groups = groupTokensByCollateral([origin, dest]);\n    expect(checkTokenHasRoute(origin, dest, groups)).toBe(false);\n  });\n\n  test('should return true when connection exists with same address but different collateral keys', () => {\n    // Even though collateral keys differ, the connection exists (same addressOrDenom),\n    // so findConnectedDestinationToken matches via address fallback — route is valid.\n    const origin = createMockToken({\n      chainName: 'ethereum',\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: COLLATERAL_A,\n      connections: [\n        createTokenConnectionMock(undefined, {\n          chainName: 'arbitrum',\n          addressOrDenom: ADDR_2,\n          collateralAddressOrDenom: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',\n        }),\n      ],\n    });\n    const dest = createMockToken({\n      chainName: 'arbitrum',\n      addressOrDenom: ADDR_2,\n      collateralAddressOrDenom: COLLATERAL_B,\n    });\n\n    const groups = groupTokensByCollateral([origin, dest]);\n    expect(checkTokenHasRoute(origin, dest, groups)).toBe(true);\n  });\n\n  test('should return false when origin token not in any collateral group', () => {\n    const origin = createMockToken({\n      chainName: 'ethereum',\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: COLLATERAL_A,\n    });\n    const dest = createMockToken({\n      chainName: 'arbitrum',\n      addressOrDenom: ADDR_2,\n      collateralAddressOrDenom: COLLATERAL_B,\n    });\n\n    // Empty groups — origin not present\n    const groups = new Map();\n    expect(checkTokenHasRoute(origin, dest, groups)).toBe(false);\n  });\n\n  test('should find route through another token in the same collateral group', () => {\n    // originDeduped has no connections (it was deduplicated)\n    const originDeduped = createMockToken({\n      chainName: 'ethereum',\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: COLLATERAL_A,\n      connections: [],\n    });\n    // originWithRoute shares collateral and has the actual connection\n    const originWithRoute = createMockToken({\n      chainName: 'ethereum',\n      addressOrDenom: '0x4444444444444444444444444444444444444444',\n      collateralAddressOrDenom: COLLATERAL_A,\n      connections: [\n        createTokenConnectionMock(undefined, {\n          chainName: 'arbitrum',\n          addressOrDenom: ADDR_2,\n          collateralAddressOrDenom: COLLATERAL_B,\n        }),\n      ],\n    });\n    const dest = createMockToken({\n      chainName: 'arbitrum',\n      addressOrDenom: ADDR_2,\n      collateralAddressOrDenom: COLLATERAL_B,\n    });\n\n    const groups = groupTokensByCollateral([originDeduped, originWithRoute, dest]);\n    expect(checkTokenHasRoute(originDeduped, dest, groups)).toBe(true);\n  });\n});\n\ndescribe('findRouteToken', () => {\n  const ADDR_1 = '0x1111111111111111111111111111111111111111';\n  const ADDR_2 = '0x2222222222222222222222222222222222222222';\n  const ADDR_3 = '0x3333333333333333333333333333333333333333';\n  const ADDR_4 = '0x4444444444444444444444444444444444444444';\n  const ADDR_5 = '0x5555555555555555555555555555555555555555';\n  const ADDR_6 = '0x6666666666666666666666666666666666666666';\n  const COLLATERAL = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';\n  const COLLATERAL_B = '0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB';\n\n  const createMockWarpCore = (routeTokens: ReturnType<typeof createMockToken>[]) =>\n    ({\n      getTokensForRoute: vi.fn().mockReturnValue(routeTokens),\n    }) as unknown as WarpCore;\n\n  test('should return originToken if it already has the matching connection', () => {\n    const destToken = createMockToken({\n      chainName: 'arbitrum',\n      addressOrDenom: ADDR_2,\n      collateralAddressOrDenom: COLLATERAL,\n    });\n    const origin = createMockToken({\n      chainName: 'ethereum',\n      addressOrDenom: ADDR_1,\n      connections: [\n        createTokenConnectionMock(undefined, {\n          chainName: 'arbitrum',\n          addressOrDenom: ADDR_2,\n          collateralAddressOrDenom: COLLATERAL,\n        }),\n      ],\n    });\n    const warpCore = createMockWarpCore([]);\n\n    const result = findRouteToken(warpCore, origin, destToken);\n\n    expect(result).toBe(origin);\n    expect(warpCore.getTokensForRoute).not.toHaveBeenCalled();\n  });\n\n  test('should prefer route token that matches specific destination token on same chain', () => {\n    const destinationCollateralA = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';\n    const destinationCollateralB = '0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB';\n    const origin = createMockToken({\n      chainName: 'ethereum',\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: COLLATERAL,\n      connections: [\n        createTokenConnectionMock(undefined, {\n          chainName: 'arbitrum',\n          addressOrDenom: ADDR_2,\n          collateralAddressOrDenom: destinationCollateralA,\n        }),\n      ],\n    });\n    const selectedDestination = createMockToken({\n      chainName: 'arbitrum',\n      addressOrDenom: ADDR_3,\n      collateralAddressOrDenom: destinationCollateralB,\n    });\n    const routeToken = createMockToken({\n      chainName: 'ethereum',\n      addressOrDenom: '0x4444444444444444444444444444444444444444',\n      collateralAddressOrDenom: COLLATERAL,\n      connections: [\n        createTokenConnectionMock(undefined, {\n          chainName: 'arbitrum',\n          addressOrDenom: ADDR_3,\n          collateralAddressOrDenom: destinationCollateralB,\n        }),\n      ],\n    });\n    const warpCore = createMockWarpCore([routeToken]);\n\n    const result = findRouteToken(warpCore, origin, selectedDestination);\n\n    expect(result).toBe(routeToken);\n  });\n\n  test('should return undefined when no routes exist', () => {\n    const destToken = createMockToken({\n      chainName: 'arbitrum',\n      addressOrDenom: ADDR_2,\n    });\n    const origin = createMockToken({\n      chainName: 'ethereum',\n      addressOrDenom: ADDR_1,\n      connections: [],\n    });\n    const warpCore = createMockWarpCore([]);\n\n    const result = findRouteToken(warpCore, origin, destToken);\n\n    expect(result).toBeUndefined();\n  });\n\n  test('should match by collateral address', () => {\n    const destToken = createMockToken({\n      chainName: 'arbitrum',\n      addressOrDenom: ADDR_3,\n    });\n    const origin = createMockToken({\n      chainName: 'ethereum',\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: COLLATERAL,\n      connections: [],\n    });\n    const routeToken = createMockToken({\n      chainName: 'ethereum',\n      addressOrDenom: ADDR_2,\n      collateralAddressOrDenom: COLLATERAL,\n      connections: [\n        createTokenConnectionMock(undefined, { chainName: 'arbitrum', addressOrDenom: ADDR_3 }),\n      ],\n    });\n    const warpCore = createMockWarpCore([routeToken]);\n\n    const result = findRouteToken(warpCore, origin, destToken);\n\n    expect(result).toBe(routeToken);\n  });\n\n  test('should match by symbol when no collateral address', () => {\n    const destToken = createMockToken({\n      chainName: 'arbitrum',\n      symbol: 'ETH',\n      standard: TokenStandard.EvmHypNative,\n      addressOrDenom: ADDR_3,\n      collateralAddressOrDenom: undefined,\n    });\n    const origin = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'ETH',\n      standard: TokenStandard.EvmHypNative,\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: undefined,\n      connections: [],\n    });\n    const routeToken = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'ETH',\n      standard: TokenStandard.EvmHypNative,\n      addressOrDenom: ADDR_2,\n      collateralAddressOrDenom: undefined,\n      connections: [\n        createTokenConnectionMock(undefined, { chainName: 'arbitrum', addressOrDenom: ADDR_3 }),\n      ],\n    });\n    const warpCore = createMockWarpCore([routeToken]);\n\n    const result = findRouteToken(warpCore, origin, destToken);\n\n    expect(result).toBe(routeToken);\n  });\n\n  test('should return undefined when no collateral or symbol match', () => {\n    const destToken = createMockToken({\n      chainName: 'arbitrum',\n      addressOrDenom: ADDR_3,\n    });\n    const origin = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDC',\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: COLLATERAL,\n      connections: [],\n    });\n    const routeToken = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'WETH',\n      addressOrDenom: ADDR_2,\n      collateralAddressOrDenom: '0xDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD',\n      connections: [\n        createTokenConnectionMock(undefined, { chainName: 'arbitrum', addressOrDenom: ADDR_3 }),\n      ],\n    });\n    const warpCore = createMockWarpCore([routeToken]);\n\n    const result = findRouteToken(warpCore, origin, destToken);\n\n    // No collateral or symbol match — should not blindly pick first token\n    expect(result).toBeUndefined();\n  });\n\n  test('should match lockbox token via resolved collateral key, not symbol fallback', () => {\n    const UNDERLYING_USDT = '0xdAC17F958D2ee523a2206206994597C13D831ec7';\n    const LOCKBOX_WRAPPER = '0x6D265C7dD8d76F25155F1a7687C693FDC1220D12';\n    const WRONG_COLLATERAL = '0xDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD';\n\n    const destToken = createMockToken({\n      chainName: 'optimism',\n      symbol: 'USDT',\n      addressOrDenom: ADDR_3,\n      collateralAddressOrDenom: UNDERLYING_USDT,\n    });\n    // Regular USDT (displayed, no connection to optimism)\n    const origin = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDT',\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: UNDERLYING_USDT,\n      connections: [],\n    });\n    // Lockbox USDT — resolved collateral matches origin\n    const lockboxToken = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDT',\n      standard: TokenStandard.EvmHypXERC20Lockbox,\n      addressOrDenom: ADDR_2,\n      collateralAddressOrDenom: LOCKBOX_WRAPPER,\n      connections: [\n        createTokenConnectionMock(undefined, { chainName: 'optimism', addressOrDenom: ADDR_3 }),\n      ],\n    });\n    // Decoy: same symbol but wrong collateral — symbol fallback would pick this\n    const decoyToken = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDT',\n      addressOrDenom: ADDR_5,\n      collateralAddressOrDenom: WRONG_COLLATERAL,\n      connections: [\n        createTokenConnectionMock(undefined, {\n          chainName: 'optimism',\n          addressOrDenom: ADDR_6,\n        }),\n      ],\n    });\n\n    // Set resolved map: lockbox wrapper resolves to real USDT\n    const resolvedMap = new Map([[getTokenKey(lockboxToken), UNDERLYING_USDT.toLowerCase()]]);\n    setResolvedUnderlyingMap(resolvedMap);\n\n    // Decoy listed first — if symbol fallback ran first, it would pick decoy\n    const warpCore = createMockWarpCore([decoyToken, lockboxToken]);\n    const result = findRouteToken(warpCore, origin, destToken);\n\n    // Must pick lockbox (collateral key match), not decoy (symbol match)\n    expect(result).toBe(lockboxToken);\n  });\n\n  test('should match OwnerCollateral token via resolved collateral key, not symbol fallback', () => {\n    const UNDERLYING_USDT = '0xdAC17F958D2ee523a2206206994597C13D831ec7';\n    const VAULT_ADDRESS = '0x04DA4b99FFc82f0e44DEd14c3539A6fDaD08E2fE';\n    const WRONG_COLLATERAL = '0xDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD';\n\n    const destToken = createMockToken({\n      chainName: 'incentiv',\n      symbol: 'USDT',\n      addressOrDenom: ADDR_3,\n      collateralAddressOrDenom: UNDERLYING_USDT,\n    });\n    const origin = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDT',\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: UNDERLYING_USDT,\n      connections: [],\n    });\n    // Vault token — resolved collateral matches origin\n    const vaultToken = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDT',\n      standard: TokenStandard.EvmHypOwnerCollateral,\n      addressOrDenom: ADDR_2,\n      collateralAddressOrDenom: VAULT_ADDRESS,\n      connections: [\n        createTokenConnectionMock(undefined, { chainName: 'incentiv', addressOrDenom: ADDR_3 }),\n      ],\n    });\n    // Decoy: same symbol but wrong collateral\n    const decoyToken = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDT',\n      addressOrDenom: ADDR_5,\n      collateralAddressOrDenom: WRONG_COLLATERAL,\n      connections: [\n        createTokenConnectionMock(undefined, {\n          chainName: 'incentiv',\n          addressOrDenom: ADDR_6,\n        }),\n      ],\n    });\n\n    const resolvedMap = new Map([[getTokenKey(vaultToken), UNDERLYING_USDT.toLowerCase()]]);\n    setResolvedUnderlyingMap(resolvedMap);\n\n    // Decoy listed first — symbol fallback would pick it\n    const warpCore = createMockWarpCore([decoyToken, vaultToken]);\n    const result = findRouteToken(warpCore, origin, destToken);\n\n    // Must pick vault (collateral key match), not decoy\n    expect(result).toBe(vaultToken);\n  });\n\n  // --- Multi-collateral disambiguation tests (USDC->USDC vs USDC->XO bug) ---\n\n  test('should not return origin when it has a connection to the chain but wrong destination collateral', () => {\n    // Origin (deduped USDC) has a connection to solanamainnet -> standard USDC\n    // But user selected XO Cash (different collateral) as destination\n    const xoCashDest = createMockToken({\n      chainName: 'solanamainnet',\n      symbol: 'XO',\n      name: 'XO Cash',\n      addressOrDenom: ADDR_4,\n      collateralAddressOrDenom: COLLATERAL_B, // XO collateral\n    });\n    const origin = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDC',\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: COLLATERAL,\n      connections: [\n        // This connection goes to standard USDC on solanamainnet, NOT XO Cash\n        createTokenConnectionMock(undefined, {\n          chainName: 'solanamainnet',\n          symbol: 'USDC',\n          addressOrDenom: ADDR_3,\n          collateralAddressOrDenom: COLLATERAL, // standard USDC collateral\n        }),\n      ],\n    });\n    // The correct route token connects to XO Cash\n    const xoRouteToken = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDC',\n      addressOrDenom: ADDR_2,\n      collateralAddressOrDenom: COLLATERAL,\n      connections: [\n        createTokenConnectionMock(undefined, {\n          chainName: 'solanamainnet',\n          symbol: 'XO',\n          addressOrDenom: ADDR_4,\n          collateralAddressOrDenom: COLLATERAL_B,\n        }),\n      ],\n    });\n    const warpCore = createMockWarpCore([origin, xoRouteToken]);\n\n    const result = findRouteToken(warpCore, origin, xoCashDest);\n\n    // Must NOT return origin (wrong destination), must find xoRouteToken\n    expect(result).toBe(xoRouteToken);\n    expect(result).not.toBe(origin);\n  });\n\n  test('should pick correct route among multiple collateral matches by destination token', () => {\n    // Two route tokens on ethereum with same USDC collateral, each connecting\n    // to different tokens on solanamainnet\n    const standardUsdcDest = createMockToken({\n      chainName: 'solanamainnet',\n      symbol: 'USDC',\n      addressOrDenom: ADDR_3,\n      collateralAddressOrDenom: COLLATERAL,\n    });\n    const xoCashDest = createMockToken({\n      chainName: 'solanamainnet',\n      symbol: 'XO',\n      addressOrDenom: ADDR_4,\n      collateralAddressOrDenom: COLLATERAL_B,\n    });\n    const origin = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDC',\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: COLLATERAL,\n      connections: [],\n    });\n    const usdcRoute = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDC',\n      addressOrDenom: ADDR_2,\n      collateralAddressOrDenom: COLLATERAL,\n      connections: [\n        createTokenConnectionMock(undefined, {\n          chainName: 'solanamainnet',\n          symbol: 'USDC',\n          addressOrDenom: ADDR_3,\n          collateralAddressOrDenom: COLLATERAL,\n        }),\n      ],\n    });\n    const xoRoute = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDC',\n      addressOrDenom: ADDR_5,\n      collateralAddressOrDenom: COLLATERAL,\n      connections: [\n        createTokenConnectionMock(undefined, {\n          chainName: 'solanamainnet',\n          symbol: 'XO',\n          addressOrDenom: ADDR_4,\n          collateralAddressOrDenom: COLLATERAL_B,\n        }),\n      ],\n    });\n\n    // USDC route listed first — without fix, .find() would always pick it\n    const warpCore = createMockWarpCore([usdcRoute, xoRoute]);\n\n    // Selecting XO Cash as destination should pick xoRoute\n    const resultXo = findRouteToken(warpCore, origin, xoCashDest);\n    expect(resultXo).toBe(xoRoute);\n\n    // Selecting standard USDC as destination should pick usdcRoute\n    const resultUsdc = findRouteToken(warpCore, origin, standardUsdcDest);\n    expect(resultUsdc).toBe(usdcRoute);\n  });\n\n  test('should work with synthetic (non-collateral) destination tokens', () => {\n    // Origin is collateral on ethereum, destination is synthetic on arbitrum\n    const syntheticDest = createMockToken({\n      chainName: 'arbitrum',\n      symbol: 'USDC',\n      standard: TokenStandard.EvmHypSynthetic,\n      addressOrDenom: ADDR_3,\n      collateralAddressOrDenom: undefined,\n    });\n    const origin = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDC',\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: COLLATERAL,\n      connections: [],\n    });\n    const routeToken = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDC',\n      addressOrDenom: ADDR_2,\n      collateralAddressOrDenom: COLLATERAL,\n      connections: [\n        createTokenConnectionMock(undefined, {\n          chainName: 'arbitrum',\n          symbol: 'USDC',\n          standard: TokenStandard.EvmHypSynthetic,\n          addressOrDenom: ADDR_3,\n          collateralAddressOrDenom: undefined,\n        }),\n      ],\n    });\n    const warpCore = createMockWarpCore([routeToken]);\n\n    const result = findRouteToken(warpCore, origin, syntheticDest);\n\n    expect(result).toBe(routeToken);\n  });\n\n  test('should disambiguate when origin has synthetic connection but user wants collateral dest', () => {\n    // Origin has connection to synthetic USDC on arbitrum\n    // But user selected a collateral USDC on arbitrum (different route)\n    const collateralDest = createMockToken({\n      chainName: 'arbitrum',\n      symbol: 'USDC',\n      addressOrDenom: ADDR_4,\n      collateralAddressOrDenom: COLLATERAL_B,\n    });\n    const origin = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDC',\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: COLLATERAL,\n      connections: [\n        // Existing connection goes to synthetic\n        createTokenConnectionMock(undefined, {\n          chainName: 'arbitrum',\n          symbol: 'USDC',\n          standard: TokenStandard.EvmHypSynthetic,\n          addressOrDenom: ADDR_3,\n          collateralAddressOrDenom: undefined,\n        }),\n      ],\n    });\n    // Route token that connects to the collateral destination\n    const collateralRoute = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDC',\n      addressOrDenom: ADDR_2,\n      collateralAddressOrDenom: COLLATERAL,\n      connections: [\n        createTokenConnectionMock(undefined, {\n          chainName: 'arbitrum',\n          symbol: 'USDC',\n          addressOrDenom: ADDR_4,\n          collateralAddressOrDenom: COLLATERAL_B,\n        }),\n      ],\n    });\n    const warpCore = createMockWarpCore([origin, collateralRoute]);\n\n    const result = findRouteToken(warpCore, origin, collateralDest);\n\n    // Must not return origin (its connection is synthetic, not the collateral dest)\n    expect(result).toBe(collateralRoute);\n  });\n\n  test('should return origin when its connection matches the destination collateral', () => {\n    // Origin already has the right connection — should short-circuit\n    const dest = createMockToken({\n      chainName: 'arbitrum',\n      symbol: 'USDC',\n      addressOrDenom: ADDR_3,\n      collateralAddressOrDenom: COLLATERAL_B,\n    });\n    const origin = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDC',\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: COLLATERAL,\n      connections: [\n        createTokenConnectionMock(undefined, {\n          chainName: 'arbitrum',\n          symbol: 'USDC',\n          addressOrDenom: ADDR_3,\n          collateralAddressOrDenom: COLLATERAL_B,\n        }),\n      ],\n    });\n    const warpCore = createMockWarpCore([]);\n\n    const result = findRouteToken(warpCore, origin, dest);\n\n    expect(result).toBe(origin);\n    expect(warpCore.getTokensForRoute).not.toHaveBeenCalled();\n  });\n\n  test('should handle single collateral match connecting to correct synthetic dest', () => {\n    // Only one route token exists, connects to synthetic — should return it\n    const syntheticDest = createMockToken({\n      chainName: 'arbitrum',\n      symbol: 'USDC',\n      standard: TokenStandard.EvmHypSynthetic,\n      addressOrDenom: ADDR_3,\n      collateralAddressOrDenom: undefined,\n    });\n    const origin = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDC',\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: COLLATERAL,\n      connections: [],\n    });\n    const routeToken = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDC',\n      addressOrDenom: ADDR_2,\n      collateralAddressOrDenom: COLLATERAL,\n      connections: [\n        createTokenConnectionMock(undefined, {\n          chainName: 'arbitrum',\n          symbol: 'USDC',\n          standard: TokenStandard.EvmHypSynthetic,\n          addressOrDenom: ADDR_3,\n          collateralAddressOrDenom: undefined,\n        }),\n      ],\n    });\n    const warpCore = createMockWarpCore([routeToken]);\n\n    const result = findRouteToken(warpCore, origin, syntheticDest);\n\n    expect(result).toBe(routeToken);\n  });\n});\n\ndescribe('resolved underlying map integration', () => {\n  const UNDERLYING = '0xdAC17F958D2ee523a2206206994597C13D831ec7';\n  const WRAPPER = '0x6D265C7dD8d76F25155F1a7687C693FDC1220D12';\n  const ADDR_1 = '0x1111111111111111111111111111111111111111';\n  const ADDR_2 = '0x2222222222222222222222222222222222222222';\n  const ADDR_3 = '0x3333333333333333333333333333333333333333';\n\n  test('dedupeTokensByCollateral should dedup lockbox against regular collateral', () => {\n    const regularUsdt = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDT',\n      standard: TokenStandard.EvmHypCollateral,\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: UNDERLYING,\n    });\n    const lockboxUsdt = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDT',\n      standard: TokenStandard.EvmHypXERC20Lockbox,\n      addressOrDenom: ADDR_2,\n      collateralAddressOrDenom: WRAPPER,\n    });\n\n    // Without resolved map: both survive (different collateral addresses)\n    expect(dedupeTokensByCollateral([regularUsdt, lockboxUsdt])).toHaveLength(2);\n\n    // With resolved map: lockbox resolves to same underlying, gets deduped\n    const resolvedMap = new Map([[getTokenKey(lockboxUsdt), UNDERLYING.toLowerCase()]]);\n    setResolvedUnderlyingMap(resolvedMap);\n\n    const result = dedupeTokensByCollateral([regularUsdt, lockboxUsdt]);\n    expect(result).toHaveLength(1);\n    expect(result[0]).toBe(regularUsdt);\n  });\n\n  test('dedupeTokensByCollateral should dedup VSXERC20Lockbox against regular collateral', () => {\n    const regularUsdt = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDT',\n      standard: TokenStandard.EvmHypCollateral,\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: UNDERLYING,\n    });\n    const vsLockboxUsdt = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDT',\n      standard: TokenStandard.EvmHypVSXERC20Lockbox,\n      addressOrDenom: ADDR_2,\n      collateralAddressOrDenom: WRAPPER,\n    });\n\n    const resolvedMap = new Map([[getTokenKey(vsLockboxUsdt), UNDERLYING.toLowerCase()]]);\n    setResolvedUnderlyingMap(resolvedMap);\n\n    const result = dedupeTokensByCollateral([regularUsdt, vsLockboxUsdt]);\n    expect(result).toHaveLength(1);\n    expect(result[0]).toBe(regularUsdt);\n  });\n\n  test('dedupeTokensByCollateral should dedup OwnerCollateral against regular collateral', () => {\n    const regularUsdt = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDT',\n      standard: TokenStandard.EvmHypCollateral,\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: UNDERLYING,\n    });\n    const vaultUsdt = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDT',\n      standard: TokenStandard.EvmHypOwnerCollateral,\n      addressOrDenom: ADDR_2,\n      collateralAddressOrDenom: WRAPPER,\n    });\n\n    const resolvedMap = new Map([[getTokenKey(vaultUsdt), UNDERLYING.toLowerCase()]]);\n    setResolvedUnderlyingMap(resolvedMap);\n\n    const result = dedupeTokensByCollateral([regularUsdt, vaultUsdt]);\n    expect(result).toHaveLength(1);\n    expect(result[0]).toBe(regularUsdt);\n  });\n\n  test('dedupeTokensByCollateral should keep lockbox if no regular counterpart exists', () => {\n    const lockboxUsdt = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDT',\n      standard: TokenStandard.EvmHypXERC20Lockbox,\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: WRAPPER,\n    });\n\n    const resolvedMap = new Map([[getTokenKey(lockboxUsdt), UNDERLYING.toLowerCase()]]);\n    setResolvedUnderlyingMap(resolvedMap);\n\n    const result = dedupeTokensByCollateral([lockboxUsdt]);\n    expect(result).toHaveLength(1);\n    expect(result[0]).toBe(lockboxUsdt);\n  });\n\n  test('groupTokensByCollateral should group lockbox with regular collateral', () => {\n    const regularUsdt = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDT',\n      standard: TokenStandard.EvmHypCollateral,\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: UNDERLYING,\n    });\n    const lockboxUsdt = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDT',\n      standard: TokenStandard.EvmHypXERC20Lockbox,\n      addressOrDenom: ADDR_2,\n      collateralAddressOrDenom: WRAPPER,\n    });\n\n    // Without resolved map: separate groups\n    const groupsBefore = groupTokensByCollateral([regularUsdt, lockboxUsdt]);\n    expect(groupsBefore.size).toBe(2);\n\n    // With resolved map: same group\n    const resolvedMap = new Map([[getTokenKey(lockboxUsdt), UNDERLYING.toLowerCase()]]);\n    setResolvedUnderlyingMap(resolvedMap);\n\n    const groupsAfter = groupTokensByCollateral([regularUsdt, lockboxUsdt]);\n    expect(groupsAfter.size).toBe(1);\n    const group = Array.from(groupsAfter.values())[0];\n    expect(group).toHaveLength(2);\n    expect(group).toContain(regularUsdt);\n    expect(group).toContain(lockboxUsdt);\n  });\n\n  test('checkTokenHasRoute should find route via lockbox in same collateral group', () => {\n    const regularUsdt = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDT',\n      standard: TokenStandard.EvmHypCollateral,\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: UNDERLYING,\n      connections: [], // no direct connection to optimism\n    });\n    const lockboxUsdt = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDT',\n      standard: TokenStandard.EvmHypXERC20Lockbox,\n      addressOrDenom: ADDR_2,\n      collateralAddressOrDenom: WRAPPER,\n      connections: [\n        createTokenConnectionMock(undefined, {\n          chainName: 'optimism',\n          symbol: 'USDT',\n          addressOrDenom: ADDR_3,\n          collateralAddressOrDenom: UNDERLYING,\n        }),\n      ],\n    });\n    const destToken = createMockToken({\n      chainName: 'optimism',\n      symbol: 'USDT',\n      standard: TokenStandard.EvmHypCollateral,\n      addressOrDenom: ADDR_3,\n      collateralAddressOrDenom: UNDERLYING,\n    });\n\n    const resolvedMap = new Map([[getTokenKey(lockboxUsdt), UNDERLYING.toLowerCase()]]);\n    setResolvedUnderlyingMap(resolvedMap);\n\n    const groups = groupTokensByCollateral([regularUsdt, lockboxUsdt, destToken]);\n    // regularUsdt has no connection, but lockboxUsdt in same group does\n    expect(checkTokenHasRoute(regularUsdt, destToken, groups)).toBe(true);\n  });\n\n  test('checkTokenHasRoute should return false when no resolved route exists', () => {\n    const regularUsdt = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDT',\n      standard: TokenStandard.EvmHypCollateral,\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: UNDERLYING,\n      connections: [],\n    });\n    const destToken = createMockToken({\n      chainName: 'optimism',\n      symbol: 'USDT',\n      standard: TokenStandard.EvmHypCollateral,\n      addressOrDenom: ADDR_3,\n      collateralAddressOrDenom: UNDERLYING,\n    });\n\n    // No lockbox token, no connection — should be false\n    const groups = groupTokensByCollateral([regularUsdt, destToken]);\n    expect(checkTokenHasRoute(regularUsdt, destToken, groups)).toBe(false);\n  });\n});\n\ndescribe('findConnectedDestinationToken', () => {\n  test('should match later same-chain connection by collateral key', () => {\n    const COLLATERAL_A = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';\n    const COLLATERAL_B = '0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB';\n    const origin = createMockToken({\n      chainName: 'ethereum',\n      collateralAddressOrDenom: COLLATERAL_A,\n      connections: [\n        createTokenConnectionMock(undefined, {\n          chainName: 'arbitrum',\n          addressOrDenom: '0x1111111111111111111111111111111111111111',\n          collateralAddressOrDenom: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',\n        }),\n        createTokenConnectionMock(undefined, {\n          chainName: 'arbitrum',\n          addressOrDenom: '0x2222222222222222222222222222222222222222',\n          collateralAddressOrDenom: COLLATERAL_B,\n        }),\n      ],\n    });\n    const selectedDestination = createMockToken({\n      chainName: 'arbitrum',\n      addressOrDenom: '0x3333333333333333333333333333333333333333',\n      collateralAddressOrDenom: COLLATERAL_B,\n    });\n\n    const matched = findConnectedDestinationToken(origin, selectedDestination);\n    expect(matched?.addressOrDenom).toBe('0x2222222222222222222222222222222222222222');\n  });\n\n  test('should return undefined when there is no destination-chain connection', () => {\n    const origin = createMockToken({\n      chainName: 'ethereum',\n      connections: [\n        createTokenConnectionMock(undefined, {\n          chainName: 'optimism',\n        }),\n      ],\n    });\n    const selectedDestination = createMockToken({ chainName: 'arbitrum' });\n\n    const matched = findConnectedDestinationToken(origin, selectedDestination);\n    expect(matched).toBeUndefined();\n  });\n\n  // --- M0 Portal case: multiple synthetic tokens share the same addressOrDenom ---\n  // wM, USDSC, USDnr on ethereum all use portal contract 0xD925... but wrap\n  // different collaterals and have different symbols. Address alone cannot\n  // identify a token — symbol must match too.\n  test('should NOT match via address fallback when symbol differs (M0Portal case)', () => {\n    const M0_PORTAL_ADDR = '0xD925C84b55E4e44a53749fF5F2a5A13F63D128fd';\n    const WM_COLLATERAL = '0x437cc33344a0B27A429f795ff6B469C72698B291';\n    const USDSC_COLLATERAL = '0x3f99231dD03a9F0E7e3421c92B7b90fbe012985a';\n\n    // Origin is wM on ethereum, connected to wM on soneium (same portal addr)\n    const wmOrigin = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'wM',\n      standard: TokenStandard.EvmM0Portal,\n      addressOrDenom: M0_PORTAL_ADDR,\n      collateralAddressOrDenom: WM_COLLATERAL,\n      connections: [\n        createTokenConnectionMock(undefined, {\n          chainName: 'soneium',\n          symbol: 'wM',\n          standard: TokenStandard.EvmM0Portal,\n          addressOrDenom: M0_PORTAL_ADDR,\n          collateralAddressOrDenom: WM_COLLATERAL,\n        }),\n      ],\n    });\n    // User selects USDSC on soneium — same portal addr, different symbol/collateral\n    const usdscDestination = createMockToken({\n      chainName: 'soneium',\n      symbol: 'USDSC',\n      standard: TokenStandard.EvmM0Portal,\n      addressOrDenom: M0_PORTAL_ADDR,\n      collateralAddressOrDenom: USDSC_COLLATERAL,\n    });\n\n    // Must not impersonate USDSC via shared addressOrDenom\n    expect(findConnectedDestinationToken(wmOrigin, usdscDestination)).toBeUndefined();\n  });\n\n  test('should still match via address fallback when symbol matches', () => {\n    // Control case: when symbols match, address fallback is still valid\n    // (e.g. a single route between two chains with same addressOrDenom).\n    const ADDR = '0x1111111111111111111111111111111111111111';\n    const CONN_COLLATERAL = '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC';\n    const DEST_COLLATERAL = '0xDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD';\n\n    const origin = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDC',\n      connections: [\n        createTokenConnectionMock(undefined, {\n          chainName: 'arbitrum',\n          symbol: 'USDC',\n          addressOrDenom: ADDR,\n          collateralAddressOrDenom: CONN_COLLATERAL,\n        }),\n      ],\n    });\n    // Same address + same symbol, but different collateral key — address fallback should match\n    const destination = createMockToken({\n      chainName: 'arbitrum',\n      symbol: 'USDC',\n      addressOrDenom: ADDR,\n      collateralAddressOrDenom: DEST_COLLATERAL,\n    });\n\n    expect(findConnectedDestinationToken(origin, destination)?.addressOrDenom).toBe(ADDR);\n  });\n});\n\ndescribe('M0Portal integration (multi-synthetic same addressOrDenom)', () => {\n  const M0_HUB = '0xD925C84b55E4e44a53749fF5F2a5A13F63D128fd';\n  const M0_LITE = '0x36f586A30502AE3afb555b8aA4dCc05d233c2ecE';\n  const WM_COLLATERAL = '0x437cc33344a0B27A429f795ff6B469C72698B291';\n  const USDSC_COLLATERAL = '0x3f99231dD03a9F0E7e3421c92B7b90fbe012985a';\n  const USDNR_COLLATERAL = '0xD48e565561416dE59DA1050ED70b8d75e8eF28f9';\n\n  test('dedupeTokensByCollateral should collapse M0Portal tokens sharing collateral on same chain', () => {\n    // Two wM ethereum definitions (one EvmM0Portal, one EvmM0PortalLite) with different\n    // addresses but SAME collateral — these are the real wM dupes that should collapse.\n    const wmHub = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'wM',\n      standard: TokenStandard.EvmM0Portal,\n      addressOrDenom: M0_HUB,\n      collateralAddressOrDenom: WM_COLLATERAL,\n    });\n    const wmLite = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'wM',\n      standard: TokenStandard.EvmM0PortalLite,\n      addressOrDenom: M0_LITE,\n      collateralAddressOrDenom: WM_COLLATERAL,\n    });\n\n    // Which variant survives doesn't matter — findRouteToken resolves the correct\n    // variant at transfer time. Ordering is covered by the dedicated test above.\n    const result = dedupeTokensByCollateral([wmHub, wmLite]);\n    expect(result).toHaveLength(1);\n  });\n\n  test('dedupeTokensByCollateral should NOT collapse M0Portal tokens with different symbols/collaterals', () => {\n    // wM, USDSC, USDnr all share the SAME addressOrDenom on ethereum but wrap\n    // different collaterals. They must remain distinct.\n    const wm = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'wM',\n      standard: TokenStandard.EvmM0Portal,\n      addressOrDenom: M0_HUB,\n      collateralAddressOrDenom: WM_COLLATERAL,\n    });\n    const usdsc = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDSC',\n      standard: TokenStandard.EvmM0Portal,\n      addressOrDenom: M0_HUB,\n      collateralAddressOrDenom: USDSC_COLLATERAL,\n    });\n    const usdnr = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDnr',\n      standard: TokenStandard.EvmM0Portal,\n      addressOrDenom: M0_HUB,\n      collateralAddressOrDenom: USDNR_COLLATERAL,\n    });\n\n    const result = dedupeTokensByCollateral([wm, usdsc, usdnr]);\n    expect(result).toHaveLength(3);\n    expect(result).toContain(wm);\n    expect(result).toContain(usdsc);\n    expect(result).toContain(usdnr);\n  });\n\n  test('findRouteToken should pick correct M0Portal token by destination chain connectivity', () => {\n    // wM on ethereum routes to mantra (via EvmM0Portal) and bsc (via EvmM0PortalLite).\n    // When user picks origin=wM ethereum + destination=wM bsc, findRouteToken must\n    // return the Lite variant (only one with a bsc connection).\n    const wmMantraConn = {\n      chainName: 'mantra',\n      symbol: 'wM',\n      standard: TokenStandard.EvmM0Portal,\n      addressOrDenom: M0_HUB,\n      collateralAddressOrDenom: WM_COLLATERAL,\n    };\n    const wmBscConn = {\n      chainName: 'bsc',\n      symbol: 'wM',\n      standard: TokenStandard.EvmM0PortalLite,\n      addressOrDenom: M0_LITE,\n      collateralAddressOrDenom: WM_COLLATERAL,\n    };\n\n    // Origin displayed in UI is the Hub variant (survives dedup)\n    const wmHubEth = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'wM',\n      standard: TokenStandard.EvmM0Portal,\n      addressOrDenom: M0_HUB,\n      collateralAddressOrDenom: WM_COLLATERAL,\n      connections: [createTokenConnectionMock(undefined, wmMantraConn)],\n    });\n    // Lite variant exists in WarpCore with the bsc connection\n    const wmLiteEth = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'wM',\n      standard: TokenStandard.EvmM0PortalLite,\n      addressOrDenom: M0_LITE,\n      collateralAddressOrDenom: WM_COLLATERAL,\n      connections: [createTokenConnectionMock(undefined, wmBscConn)],\n    });\n\n    const wmBscDest = createMockToken(wmBscConn);\n\n    const warpCore = {\n      getTokensForRoute: vi.fn().mockReturnValue([wmLiteEth]),\n    } as unknown as WarpCore;\n\n    const result = findRouteToken(warpCore, wmHubEth, wmBscDest);\n    expect(result).toBe(wmLiteEth);\n  });\n\n  test('checkTokenHasRoute should reject wM origin -> USDSC dest (different symbols, shared portal addr)', () => {\n    // Origin wM ethereum connected to wM soneium (same addressOrDenom as USDSC soneium!).\n    // User selects USDSC soneium as dest — the symbol mismatch must block the route.\n    const wmSoneiumConn = {\n      chainName: 'soneium',\n      symbol: 'wM',\n      standard: TokenStandard.EvmM0Portal,\n      addressOrDenom: M0_HUB,\n      collateralAddressOrDenom: WM_COLLATERAL,\n    };\n    const wmEth = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'wM',\n      standard: TokenStandard.EvmM0Portal,\n      addressOrDenom: M0_HUB,\n      collateralAddressOrDenom: WM_COLLATERAL,\n      connections: [createTokenConnectionMock(undefined, wmSoneiumConn)],\n    });\n\n    const usdscSoneium = createMockToken({\n      chainName: 'soneium',\n      symbol: 'USDSC',\n      standard: TokenStandard.EvmM0Portal,\n      addressOrDenom: M0_HUB,\n      collateralAddressOrDenom: USDSC_COLLATERAL,\n    });\n\n    // Collateral groups built from full warpCore.tokens (not UI-deduped)\n    const groups = groupTokensByCollateral([wmEth, usdscSoneium]);\n\n    // wmEth's only connection is wM soneium (same address as USDSC soneium but\n    // different symbol/collateral). Pre-fix this would falsely match via address.\n    expect(checkTokenHasRoute(wmEth, usdscSoneium, groups)).toBe(false);\n  });\n});\n"
  },
  {
    "path": "src/features/tokens/utils.ts",
    "content": "import {\n  IToken,\n  Token,\n  TOKEN_COLLATERALIZED_STANDARDS,\n  TokenStandard,\n  WarpCore,\n} from '@hyperlane-xyz/sdk';\nimport { eqAddress, isNullish, normalizeAddress, objKeys } from '@hyperlane-xyz/utils';\n\nimport { DefaultMultiCollateralRoutes } from './types';\n\n// Module-level caches for expensive key computations\n// WeakMap allows automatic garbage collection when token objects are no longer referenced\nconst tokenKeyCache = new WeakMap<IToken, string>();\nlet collateralKeyCache = new WeakMap<IToken, string>();\n\n// Resolved underlying addresses for lockbox/vault tokens.\n// Set once during initWarpContext via setResolvedUnderlyingMap().\n// getCollateralKey() uses this to group lockbox/vault tokens with their\n// non-wrapper counterparts (e.g., lockbox USDT grouped with regular USDT).\nlet resolvedUnderlyingMap: Map<string, string> = new Map();\n// Standards the UI treats as collateralized on top of the SDK's\n// TOKEN_COLLATERALIZED_STANDARDS. Kept local since we don't necessarily\n// want these promoted upstream in the SDK.\nconst EXTRA_COLLATERALIZED_STANDARDS = new Set([\n  TokenStandard.EvmHypCollateralFiat,\n  TokenStandard.EvmM0Portal,\n  TokenStandard.EvmM0PortalLite,\n]);\n\n/**\n * Set the resolved underlying address map for lockbox/vault tokens.\n * Must be called before buildTokensArray/groupTokensByCollateral.\n * Clears the collateral key cache since keys may change.\n */\nexport function setResolvedUnderlyingMap(map: Map<string, string>) {\n  resolvedUnderlyingMap = map;\n  collateralKeyCache = new WeakMap();\n}\n\nfunction isCollateralizedToken(token: IToken): boolean {\n  return (\n    TOKEN_COLLATERALIZED_STANDARDS.includes(token.standard) ||\n    EXTRA_COLLATERALIZED_STANDARDS.has(token.standard) ||\n    token.isHypNative()\n  );\n}\n\nexport function isValidMultiCollateralToken(\n  originToken: Token | IToken,\n  destinationToken: Token | IToken,\n) {\n  if (!isCollateralizedToken(originToken)) return false;\n  if (!isCollateralizedToken(destinationToken)) return false;\n  return true;\n}\n\n/**\n * Resolve the connected destination token from originToken that matches the selected destination token.\n * For multi-collateral routes, there can be multiple connections for the same destination chain.\n * In that case we prioritize collateral-key matching, then address matching.\n */\nexport function findConnectedDestinationToken(\n  originToken: Token | IToken,\n  destinationToken: Token | IToken,\n): Token | undefined {\n  const destinationCandidates = originToken\n    .getConnections()\n    .filter((connection) => connection.token.chainName === destinationToken.chainName)\n    .map((connection) => connection.token as Token);\n\n  if (!destinationCandidates.length) return undefined;\n\n  const destinationCollateralKey = getCollateralKey(destinationToken);\n  return (\n    destinationCandidates.find(\n      (candidate) => getCollateralKey(candidate) === destinationCollateralKey,\n    ) ||\n    // Address fallback also requires matching symbol: some standards (e.g. EvmM0Portal\n    // wM/USDSC/USDnr) share the same addressOrDenom across different synthetic tokens,\n    // so address alone is not a reliable identity check.\n    destinationCandidates.find(\n      (candidate) =>\n        candidate.symbol.toLowerCase() === destinationToken.symbol.toLowerCase() &&\n        eqAddress(candidate.addressOrDenom, destinationToken.addressOrDenom),\n    )\n  );\n}\n\n// Match collateral addresses, or fall back to symbol matching for HypNative tokens\n// (which have null collateral addresses) to avoid treating different native\n// deployments (e.g. ETH vs ETHSTAGE) as interchangeable.\nfunction matchesCollateral(\n  referenceAddress: string | null,\n  candidateAddress: string | null,\n  referenceSymbol: string,\n  candidateSymbol: string,\n): boolean {\n  if (isNullish(referenceAddress) && isNullish(candidateAddress)) {\n    return candidateSymbol === referenceSymbol;\n  }\n  if (referenceAddress && candidateAddress) {\n    return eqAddress(referenceAddress, candidateAddress);\n  }\n  return false;\n}\n\nexport function getTokensWithSameCollateralAddresses(\n  warpCore: WarpCore,\n  origin: Token,\n  destination: IToken,\n) {\n  if (!isCollateralizedToken(origin) || !isCollateralizedToken(destination)) return [];\n\n  // For HypNative tokens, use null as identifier since they don't have collateralAddressOrDenom\n  const originCollateralAddress = origin.collateralAddressOrDenom\n    ? normalizeAddress(origin.collateralAddressOrDenom, origin.protocol)\n    : null;\n  const destinationCollateralAddress = destination.collateralAddressOrDenom\n    ? normalizeAddress(destination.collateralAddressOrDenom, destination.protocol)\n    : null;\n\n  return warpCore\n    .getTokensForRoute(origin.chainName, destination.chainName)\n    .map((originToken) => {\n      const destinationToken = findConnectedDestinationToken(originToken, destination);\n      return { originToken, destinationToken };\n    })\n    .filter((tokens): tokens is { originToken: Token; destinationToken: Token } => {\n      // doing this because annoying Typescript will have destinationToken\n      // as undefined even if it is filtered out\n      const { originToken, destinationToken } = tokens;\n\n      if (!destinationToken) return false;\n      const isMultiCollateralToken = isValidMultiCollateralToken(originToken, destinationToken);\n      if (!isMultiCollateralToken) return false;\n\n      const currentOriginCollateralAddress = originToken.collateralAddressOrDenom\n        ? normalizeAddress(originToken.collateralAddressOrDenom, originToken.protocol)\n        : null;\n      const currentDestinationCollateralAddress = destinationToken.collateralAddressOrDenom\n        ? normalizeAddress(destinationToken.collateralAddressOrDenom, destinationToken.protocol)\n        : null;\n\n      const originMatches = matchesCollateral(\n        originCollateralAddress,\n        currentOriginCollateralAddress,\n        origin.symbol,\n        originToken.symbol,\n      );\n      const destinationMatches = matchesCollateral(\n        destinationCollateralAddress,\n        currentDestinationCollateralAddress,\n        destination.symbol,\n        destinationToken.symbol,\n      );\n\n      return originMatches && destinationMatches;\n    });\n}\n\n/**\n * Generate a stable token key from a token object\n * Uses chainName + lowercase symbol + normalized address\n * Format: \"chainName-symbol-addressOrDenom\" (stable identifier)\n * Example: \"ethereum-usdc-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48\"\n *\n * Results are cached by token object reference for O(1) subsequent lookups.\n */\nexport function getTokenKey(token: IToken): string {\n  const cached = tokenKeyCache.get(token);\n  if (!isNullish(cached)) return cached;\n\n  const normalizedAddress = normalizeAddress(token.addressOrDenom, token.protocol);\n  const key = `${token.chainName.toLowerCase()}-${token.symbol.toLowerCase()}-${normalizedAddress}`;\n\n  tokenKeyCache.set(token, key);\n  return key;\n}\n\n/**\n * De-duplicate tokens by collateral address on the same chain\n * Returns only one token per unique collateral address per chain\n * Used for both origin and destination token arrays at startup\n */\nexport function dedupeTokensByCollateral(tokens: Token[]): Token[] {\n  const seenCollaterals = new Map<string, Token>();\n\n  return tokens.filter((token) => {\n    // If not a collateralized token, include it\n    if (!isCollateralizedToken(token)) {\n      return true;\n    }\n\n    const collateralKey = getCollateralKey(token);\n\n    // If we haven't seen this collateral on this chain, include it\n    if (!seenCollaterals.has(collateralKey)) {\n      seenCollaterals.set(collateralKey, token);\n      return true;\n    }\n\n    // Already seen this collateral, skip it\n    return false;\n  });\n}\n\n/**\n * Build a unified tokens array containing all tokens that can participate in transfers\n * (either as origin or destination). Deduplicates by address and by collateral.\n */\nexport function buildTokensArray(warpCoreTokens: Token[]): Token[] {\n  const tokenMap = new Map<string, Token>();\n\n  // Add all tokens that have connections (can be origins)\n  for (const token of warpCoreTokens) {\n    if (token.connections && token.connections.length > 0) {\n      const key = getTokenKey(token);\n      if (!tokenMap.has(key)) {\n        tokenMap.set(key, token);\n      }\n    }\n  }\n\n  // Add all destination tokens (reachable via connections)\n  for (const token of warpCoreTokens) {\n    token.connections?.forEach((conn) => {\n      const destToken = conn.token as Token;\n      const key = getTokenKey(destToken);\n      if (!tokenMap.has(key)) {\n        tokenMap.set(key, destToken);\n      }\n    });\n  }\n\n  // Deduplicate tokens that have same collateral address on the same chain\n  return dedupeTokensByCollateral(Array.from(tokenMap.values()));\n}\n\n/**\n * Build collateral groups - groups tokens by their collateral key for O(1) lookup\n * Used for fast route checking in the token selection modal\n */\nexport function groupTokensByCollateral(tokens: Token[]): Map<string, Token[]> {\n  const groups = new Map<string, Token[]>();\n  for (const token of tokens) {\n    const key = getCollateralKey(token);\n    const existing = groups.get(key) || [];\n    existing.push(token);\n    groups.set(key, existing);\n  }\n  return groups;\n}\n\n/**\n * Get a unique collateral identifier for a token\n * Used to determine if two tokens share the same underlying collateral\n *\n * For lockbox/vault tokens whose collateralAddressOrDenom points to a wrapper,\n * uses the resolved underlying address (from resolvedUnderlyingMap) so they\n * group with their non-wrapper counterparts.\n *\n * Results are cached by token object reference for O(1) subsequent lookups.\n */\nexport function getCollateralKey(token: IToken): string {\n  const cached = collateralKeyCache.get(token);\n  if (!isNullish(cached)) return cached;\n\n  const chainName = token.chainName.toLowerCase();\n  const symbol = token.symbol.toLowerCase();\n  const protocol = token.protocol;\n\n  let key: string;\n\n  // For collateralized tokens, use the collateral address\n  if (isCollateralizedToken(token)) {\n    if (token.collateralAddressOrDenom) {\n      // Check if this token has a resolved underlying address (lockbox/vault)\n      const tokenId = getTokenKey(token);\n      const resolvedUnderlying = resolvedUnderlyingMap.get(tokenId);\n      const collateralAddress = resolvedUnderlying ?? token.collateralAddressOrDenom;\n      key = `${chainName}-${symbol}-${normalizeAddress(collateralAddress, protocol)}`;\n    } else {\n      // For HypNative tokens without collateralAddressOrDenom\n      key = `${chainName}-${symbol}-hypnative-${protocol}`;\n    }\n  } else {\n    // For non-collateralized tokens, use the token's own address\n    key = `${chainName}-${symbol}-${normalizeAddress(token.addressOrDenom, protocol)}`;\n  }\n\n  collateralKeyCache.set(token, key);\n  return key;\n}\n\n/**\n * Check if a route exists between origin and destination tokens\n * Uses pre-computed collateral groups for fast O(1) lookups\n *\n * @param originToken - The origin token (what the user is sending)\n * @param destToken - The destination token (what the user will receive)\n * @param collateralGroups - Pre-computed map of collateral key → tokens\n * @returns true if a valid route exists between the tokens\n */\nexport function checkTokenHasRoute(\n  originToken: Token,\n  destToken: Token,\n  collateralGroups: Map<string, Token[]>,\n): boolean {\n  const originCollateralKey = getCollateralKey(originToken);\n  const originGroup = collateralGroups.get(originCollateralKey) || [];\n\n  // Check if any token in origin's collateral group has a matching connection\n  // to the specific destination token. Uses findConnectedDestinationToken to stay\n  // consistent with the transfer flow's matching logic (collateral key + address fallback).\n  return originGroup.some((token) => Boolean(findConnectedDestinationToken(token, destToken)));\n}\n\n/**\n * Find the actual warpCore token that has a route to the destination.\n * The passed originToken may be from a deduplicated array and may not have\n * the connection, but another token with the same collateral in the warpCore might\n * have this token.\n */\nexport function findRouteToken(\n  warpCore: WarpCore,\n  originToken: Token,\n  destinationToken: IToken,\n): Token | undefined {\n  const destinationChain = destinationToken.chainName;\n\n  // First check if the passed token already has the right connection.\n  // Must verify the connected token matches the intended destination — not just any\n  // connection to that chain — because the same origin can connect to different\n  // destination tokens on the same chain (e.g. USDC->USDC vs USDC->XO on Solana)\n  const existingConnection = findConnectedDestinationToken(originToken, destinationToken);\n  if (existingConnection) return originToken;\n\n  // Otherwise, find all the tokens from warpCore that has a route with the origin and destination\n  const routeTokens = warpCore.getTokensForRoute(originToken.chainName, destinationChain);\n  if (routeTokens.length === 0) return undefined;\n\n  const originCollateralKey = getCollateralKey(originToken);\n  const collateralMatches = routeTokens.filter((t) => getCollateralKey(t) === originCollateralKey);\n\n  // When multiple routes share the same origin collateral but connect to different\n  // destination tokens (e.g. USDC->USDC vs USDC->XO), use the destination token\n  // to pick the correct route\n  if (collateralMatches.length > 1) {\n    const exactMatch = collateralMatches.find((t) => {\n      const connectedToken = findConnectedDestinationToken(t, destinationToken);\n      return connectedToken;\n    });\n    if (exactMatch) return exactMatch;\n  }\n\n  return collateralMatches[0];\n}\n\n// Returns the default origin token from tokensWithSameCollateralAddresses if:\n// - It is a valid multi-collateral token\n// - Both origin and destination chains are configured in defaultMultiCollateralRoutes\n// - A matching token is found in tokensWithSameCollateralAddresses\n// Returns null if no default is found (caller should fall back to fee-based selection)\nexport function tryGetDefaultOriginToken(\n  originToken: IToken,\n  destinationToken: IToken,\n  defaultMultiCollateralRoutes: DefaultMultiCollateralRoutes | undefined,\n  tokensWithSameCollateralAddresses: { originToken: Token; destinationToken: Token }[],\n): Token | null {\n  // this call might be repeated with getTransferToken but it ensures we are only dealing with valid\n  // multi-collateral tokens here\n  if (!isValidMultiCollateralToken(originToken, destinationToken)) return null;\n  if (!defaultMultiCollateralRoutes) return null;\n\n  const originChainName = originToken.chainName;\n  const destChainName = destinationToken.chainName;\n\n  // Check both chains are in config\n  if (\n    !objKeys(defaultMultiCollateralRoutes).includes(originChainName) ||\n    !objKeys(defaultMultiCollateralRoutes).includes(destChainName)\n  )\n    return null;\n\n  // Get lookup key - 'native' for HypNative, collateralAddressOrDenom otherwise\n  const originKey = originToken.isHypNative() ? 'native' : originToken.collateralAddressOrDenom;\n  const destKey = destinationToken.isHypNative()\n    ? 'native'\n    : destinationToken.collateralAddressOrDenom;\n\n  if (!originKey || !destKey) return null;\n\n  const defaultOriginAddressOrDenom = defaultMultiCollateralRoutes[originChainName][originKey];\n  const defaultDestAddressOrDenom = defaultMultiCollateralRoutes[destChainName][destKey];\n\n  if (!defaultOriginAddressOrDenom || !defaultDestAddressOrDenom) return null;\n\n  // Find matching token from tokensWithSameCollateralAddresses\n  const match = tokensWithSameCollateralAddresses.find(\n    ({ originToken: ot, destinationToken: dt }) =>\n      eqAddress(ot.addressOrDenom, defaultOriginAddressOrDenom) &&\n      eqAddress(dt.addressOrDenom, defaultDestAddressOrDenom),\n  );\n\n  return match?.originToken ?? null;\n}\n"
  },
  {
    "path": "src/features/tokens/wrappedTokenResolver.test.ts",
    "content": "import { MultiProtocolProvider, TokenStandard } from '@hyperlane-xyz/sdk';\nimport { ProtocolType } from '@hyperlane-xyz/utils';\nimport { describe, expect, test, vi } from 'vitest';\n\nimport { createMockToken } from '../../utils/test';\nimport { resolveWrappedCollateralTokens } from './wrappedTokenResolver';\n\nconst ADDR_1 = '0x1111111111111111111111111111111111111111';\nconst ADDR_2 = '0x2222222222222222222222222222222222222222';\nconst UNDERLYING = '0xdAC17F958D2ee523a2206206994597C13D831ec7';\n\nconst mockMultiProvider = {} as MultiProtocolProvider;\n\nconst createMockAdapter = (wrappedAddress?: string, shouldThrow?: boolean) => ({\n  ...(wrappedAddress !== undefined && {\n    getWrappedTokenAddress: shouldThrow\n      ? vi.fn().mockRejectedValue(new Error('RPC error'))\n      : vi.fn().mockResolvedValue(wrappedAddress),\n  }),\n});\n\ndescribe('resolveWrappedCollateralTokens', () => {\n  test('should return empty map for no eligible tokens', async () => {\n    const syntheticToken = createMockToken({\n      standard: TokenStandard.EvmHypSynthetic,\n      addressOrDenom: ADDR_1,\n    });\n    // Override protocol to Ethereum (createMockToken uses test chain defaults)\n    vi.spyOn(syntheticToken, 'protocol', 'get').mockReturnValue(ProtocolType.Ethereum);\n\n    const result = await resolveWrappedCollateralTokens([syntheticToken], mockMultiProvider);\n    expect(result.size).toBe(0);\n  });\n\n  test('should resolve lockbox token underlying address', async () => {\n    const lockboxToken = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDT',\n      standard: TokenStandard.EvmHypXERC20Lockbox,\n      addressOrDenom: ADDR_1,\n      collateralAddressOrDenom: '0xWRAPPER',\n    });\n    vi.spyOn(lockboxToken, 'protocol', 'get').mockReturnValue(ProtocolType.Ethereum);\n    vi.spyOn(lockboxToken, 'getHypAdapter').mockReturnValue(\n      createMockAdapter(UNDERLYING) as ReturnType<typeof lockboxToken.getHypAdapter>,\n    );\n\n    const result = await resolveWrappedCollateralTokens([lockboxToken], mockMultiProvider);\n\n    expect(result.size).toBe(1);\n    const resolved = Array.from(result.values())[0];\n    // normalizeAddress produces checksum-cased EVM addresses\n    expect(resolved).toBe(UNDERLYING);\n  });\n\n  test('should skip adapter without getWrappedTokenAddress', async () => {\n    const lockboxToken = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDT',\n      standard: TokenStandard.EvmHypXERC20Lockbox,\n      addressOrDenom: ADDR_1,\n    });\n    vi.spyOn(lockboxToken, 'protocol', 'get').mockReturnValue(ProtocolType.Ethereum);\n    // Adapter without getWrappedTokenAddress\n    vi.spyOn(lockboxToken, 'getHypAdapter').mockReturnValue(\n      createMockAdapter() as ReturnType<typeof lockboxToken.getHypAdapter>,\n    );\n\n    const result = await resolveWrappedCollateralTokens([lockboxToken], mockMultiProvider);\n    expect(result.size).toBe(0);\n  });\n\n  test('should handle RPC error gracefully and resolve other tokens', async () => {\n    const failingToken = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'USDT',\n      standard: TokenStandard.EvmHypXERC20Lockbox,\n      addressOrDenom: ADDR_1,\n    });\n    const succeedingToken = createMockToken({\n      chainName: 'ethereum',\n      symbol: 'WETH',\n      standard: TokenStandard.EvmHypOwnerCollateral,\n      addressOrDenom: ADDR_2,\n      collateralAddressOrDenom: '0xVAULT',\n    });\n\n    vi.spyOn(failingToken, 'protocol', 'get').mockReturnValue(ProtocolType.Ethereum);\n    vi.spyOn(succeedingToken, 'protocol', 'get').mockReturnValue(ProtocolType.Ethereum);\n    vi.spyOn(failingToken, 'getHypAdapter').mockReturnValue(\n      createMockAdapter(UNDERLYING, true) as ReturnType<typeof failingToken.getHypAdapter>,\n    );\n    vi.spyOn(succeedingToken, 'getHypAdapter').mockReturnValue(\n      createMockAdapter(UNDERLYING) as ReturnType<typeof succeedingToken.getHypAdapter>,\n    );\n\n    const result = await resolveWrappedCollateralTokens(\n      [failingToken, succeedingToken],\n      mockMultiProvider,\n    );\n\n    // Only the succeeding token should be in the map\n    expect(result.size).toBe(1);\n  });\n\n  test('should filter out non-Ethereum tokens', async () => {\n    const solanaToken = createMockToken({\n      chainName: 'solana',\n      symbol: 'USDT',\n      standard: TokenStandard.EvmHypXERC20Lockbox,\n      addressOrDenom: ADDR_1,\n    });\n    vi.spyOn(solanaToken, 'protocol', 'get').mockReturnValue(ProtocolType.Sealevel);\n\n    const result = await resolveWrappedCollateralTokens([solanaToken], mockMultiProvider);\n    expect(result.size).toBe(0);\n  });\n});\n"
  },
  {
    "path": "src/features/tokens/wrappedTokenResolver.ts",
    "content": "import {\n  IHypTokenAdapter,\n  LOCKBOX_STANDARDS,\n  MultiProtocolProvider,\n  Token,\n  TokenStandard,\n} from '@hyperlane-xyz/sdk';\nimport { ProtocolType, normalizeAddress } from '@hyperlane-xyz/utils';\n\nimport { logger } from '../../utils/logger';\nimport { getTokenKey } from './utils';\n\n// Standards whose collateralAddressOrDenom points to a wrapper, not the actual underlying ERC20.\n// getWrappedTokenAddress() on the adapter resolves the real underlying address.\nconst WRAPPED_COLLATERAL_STANDARDS: string[] = [\n  ...LOCKBOX_STANDARDS,\n  TokenStandard.EvmHypOwnerCollateral,\n];\n\n/**\n * Resolve the actual underlying ERC20 address for lockbox/vault tokens\n * via token.getHypAdapter().getWrappedTokenAddress().\n *\n * Returns Map<tokenKey, resolvedUnderlyingAddress> (normalized, lowercase).\n * Tokens that fail resolution are silently omitted (current behavior preserved).\n */\nexport async function resolveWrappedCollateralTokens(\n  allTokens: Token[],\n  multiProvider: MultiProtocolProvider,\n): Promise<Map<string, string>> {\n  const result = new Map<string, string>();\n\n  const eligibleTokens = allTokens.filter(\n    (t) =>\n      t.protocol === ProtocolType.Ethereum && WRAPPED_COLLATERAL_STANDARDS.includes(t.standard),\n  );\n\n  if (eligibleTokens.length === 0) return result;\n\n  const resolvePromises = eligibleTokens.map(async (token) => {\n    try {\n      const adapter = token.getHypAdapter(multiProvider) as IHypTokenAdapter<unknown> & {\n        getWrappedTokenAddress?: () => Promise<string>;\n      };\n      if (!adapter.getWrappedTokenAddress) return;\n      const underlying = await adapter.getWrappedTokenAddress();\n      result.set(getTokenKey(token), normalizeAddress(underlying));\n    } catch (err) {\n      logger.warn(`wrappedToken resolution failed for ${token.symbol} on ${token.chainName}`, err);\n    }\n  });\n\n  await Promise.all(resolvePromises);\n  return result;\n}\n"
  },
  {
    "path": "src/features/transfer/FeeSectionButton.tsx",
    "content": "import { WarpCoreFeeEstimate } from '@hyperlane-xyz/sdk';\nimport { ChevronIcon, FuelPumpIcon, useModal } from '@hyperlane-xyz/widgets';\nimport { useEffect, useState } from 'react';\n\nimport { getFeePercentage, getTotalFeesUsdRaw } from '../balances/feeUsdDisplay';\nimport { FeePrices } from '../balances/useFeePrices';\nimport { formatUsd } from '../balances/utils';\nimport { TransferFeeModal } from './TransferFeeModal';\n\nfunction useLoadingDots(isLoading: boolean, intervalMs = 1000) {\n  const [dotCount, setDotCount] = useState(1);\n\n  useEffect(() => {\n    if (!isLoading) return;\n\n    let count = 0;\n    const interval = setInterval(() => {\n      count = (count % 3) + 1;\n      setDotCount(count);\n    }, intervalMs);\n\n    return () => clearInterval(interval);\n  }, [isLoading, intervalMs]);\n\n  return 'Loading' + '.'.repeat(dotCount);\n}\n\nexport function FeeSectionButton({\n  isLoading,\n  fees,\n  feePrices,\n  transferUsd,\n}: {\n  isLoading: boolean;\n  fees: (WarpCoreFeeEstimate & { totalFees: string }) | null;\n  feePrices: FeePrices;\n  transferUsd: number;\n}) {\n  const { close, isOpen, open } = useModal();\n  const loadingText = useLoadingDots(isLoading);\n\n  // Determine display text and whether button is clickable\n  const hasFees = fees !== null;\n  const isClickable = hasFees && !isLoading;\n  const feeText = isLoading ? loadingText : hasFees ? fees.totalFees : '-';\n  const totalUsdRaw = hasFees ? getTotalFeesUsdRaw(fees, feePrices) : 0;\n  const totalUsd = totalUsdRaw > 0 ? formatUsd(totalUsdRaw, true) : null;\n  const pct = hasFees ? getFeePercentage(totalUsdRaw, transferUsd) : null;\n\n  return (\n    <>\n      <div className=\"mb-2 mt-2 h-2\">\n        <button\n          className={`fee-section-btn flex w-fit items-center font-secondary text-xxs text-gray-700 dark:text-foreground-secondary [&_path]:fill-gray-700 dark:[&_path]:fill-current ${isClickable ? 'hover:text-gray-900 dark:hover:text-foreground-primary [&_path]:hover:fill-gray-900 dark:hover:[&_path]:fill-current' : 'pointer-events-none cursor-default'}`}\n          type=\"button\"\n          onClick={isClickable ? open : undefined}\n          disabled={!isClickable}\n        >\n          <FuelPumpIcon width={14} height={14} className=\"mr-1\" />\n          Fees: {feeText}\n          {isClickable && totalUsd && (\n            <span className=\"ml-1 text-gray-500 dark:text-foreground-secondary\">\n              {totalUsd}\n              {pct ? ` (${pct})` : ''}\n            </span>\n          )}\n          {isClickable && <ChevronIcon direction=\"e\" width=\"0.6rem\" height=\"0.6rem\" />}\n        </button>\n      </div>\n      <TransferFeeModal\n        close={close}\n        isOpen={isOpen}\n        isLoading={isLoading}\n        fees={fees}\n        feePrices={feePrices}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "src/features/transfer/RecipientConfirmationModal.tsx",
    "content": "import { Modal } from '@hyperlane-xyz/widgets';\nimport {\n  getAccountAddressAndPubKey,\n  useAccounts,\n} from '@hyperlane-xyz/widgets/walletIntegrations/multiProtocol';\nimport { useFormikContext } from 'formik';\n\nimport { SolidButton } from '../../components/buttons/SolidButton';\nimport { useMultiProvider } from '../chains/hooks';\nimport { getTokenByKeyFromMap, useTokenByKeyMap } from '../tokens/hooks';\nimport { TransferFormValues } from './types';\n\nexport function RecipientConfirmationModal({\n  isOpen,\n  close,\n  onConfirm,\n}: {\n  isOpen: boolean;\n  close: () => void;\n  onConfirm: () => void;\n}) {\n  const { values } = useFormikContext<TransferFormValues>();\n  const multiProvider = useMultiProvider();\n  const tokenMap = useTokenByKeyMap();\n  const { accounts } = useAccounts(multiProvider);\n  const destinationToken = getTokenByKeyFromMap(tokenMap, values.destinationTokenKey);\n\n  // Get recipient (form value or fallback to connected wallet for destination)\n  const { address: connectedDestAddress } = getAccountAddressAndPubKey(\n    multiProvider,\n    destinationToken?.chainName,\n    accounts,\n  );\n  const recipient = values.recipient || connectedDestAddress || '';\n\n  return (\n    <Modal\n      isOpen={isOpen}\n      close={close}\n      title=\"Confirm Recipient Address\"\n      panelClassname=\"flex flex-col items-center p-4 gap-5\"\n    >\n      <p className=\"text-center text-sm\">\n        The recipient address has no funds on the destination chain. Is this address correct?\n      </p>\n      <p className=\"rounded-lg bg-primary-500/5 p-2 text-center text-sm\">{recipient}</p>\n      <div className=\"flex items-center justify-center gap-12\">\n        <SolidButton onClick={close} color=\"gray\" className=\"min-w-24 px-4 py-1\">\n          Cancel\n        </SolidButton>\n        <SolidButton\n          onClick={() => {\n            close();\n            onConfirm();\n          }}\n          color=\"primary\"\n          className=\"min-w-24 px-4 py-1\"\n        >\n          Continue\n        </SolidButton>\n      </div>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "src/features/transfer/TransferFeeModal.tsx",
    "content": "import { WarpCoreFeeEstimate } from '@hyperlane-xyz/sdk';\nimport { Modal, Skeleton, Tooltip } from '@hyperlane-xyz/widgets';\nimport Link from 'next/link';\n\nimport { links } from '../../consts/links';\nimport { UsdLabel } from '../balances/UsdLabel';\nimport { FeePrices } from '../balances/useFeePrices';\n\nexport function TransferFeeModal({\n  isOpen,\n  close,\n  fees,\n  isLoading,\n  feePrices,\n}: {\n  isOpen: boolean;\n  close: () => void;\n  fees: WarpCoreFeeEstimate | null;\n  isLoading: boolean;\n  feePrices: FeePrices;\n}) {\n  return (\n    <Modal\n      isOpen={isOpen}\n      close={close}\n      panelClassname=\"transfer-fee-modal max-w-sm overflow-hidden p-0 dark:border dark:border-primary-300/40 dark:bg-surface dark:text-foreground-primary dark:shadow-[0_16px_40px_rgba(0,0,0,0.45)] md:max-w-128\"\n    >\n      <div className=\"w-full bg-accent-gradient px-4 py-2.5 font-secondary text-base font-normal tracking-wider text-white shadow-accent-glow\">\n        Fee Details\n      </div>\n      <div className=\"transfer-fee-modal-content flex w-full flex-col items-start gap-2 p-4 text-sm dark:text-foreground-primary\">\n        {fees?.localQuote && fees.localQuote.amount > 0n && (\n          <div className=\"flex gap-4\">\n            <span className=\"flex min-w-[7.5rem] items-center gap-1\">\n              Local Gas (est.)\n              <Tooltip\n                content=\"Gas to submit the transaction on the origin chain\"\n                id=\"local-gas-tooltip\"\n                tooltipClassName=\"max-w-[300px]\"\n              />\n            </span>\n            {isLoading ? (\n              <Skeleton className=\"h-4 w-40 sm:w-72\" />\n            ) : (\n              <span>\n                {`${fees.localQuote.getDecimalFormattedAmount().toFixed(8) || '0'} ${fees.localQuote.token.symbol || ''}`}\n                <UsdLabel tokenAmount={fees.localQuote} feePrices={feePrices} />\n              </span>\n            )}\n          </div>\n        )}\n        {fees?.interchainQuote && fees.interchainQuote.amount > 0n && (\n          <div className=\"flex gap-4\">\n            <span className=\"flex min-w-[7.5rem] items-center gap-1\">\n              Interchain Gas\n              <Tooltip\n                content=\"Gas to deliver and execute the message on the destination chain, including the relayer fee\"\n                id=\"igp-tooltip\"\n                tooltipClassName=\"max-w-[300px]\"\n              />\n            </span>\n            {isLoading ? (\n              <Skeleton className=\"h-4 w-40 sm:w-72\" />\n            ) : (\n              <span>\n                {`${fees.interchainQuote.getDecimalFormattedAmount().toFixed(8) || '0'} ${fees.interchainQuote.token.symbol || ''}`}\n                <UsdLabel tokenAmount={fees.interchainQuote} feePrices={feePrices} />\n              </span>\n            )}\n          </div>\n        )}\n        {fees?.tokenFeeQuote && fees.tokenFeeQuote.amount > 0n && (\n          <div className=\"flex gap-4\">\n            <span className=\"flex min-w-[7.5rem] items-center gap-1\">\n              Token Fee <Tooltip content=\"Variable fee based on amount\" id=\"token-fee-tooltip\" />\n            </span>\n            {isLoading ? (\n              <Skeleton className=\"h-4 w-40 sm:w-72\" />\n            ) : (\n              <span>\n                {`${fees.tokenFeeQuote.getDecimalFormattedAmount().toFixed(8) || '0'} ${fees.tokenFeeQuote.token.symbol || ''}`}\n                <UsdLabel tokenAmount={fees.tokenFeeQuote} feePrices={feePrices} />\n              </span>\n            )}\n          </div>\n        )}\n        <span className=\"mt-2\">\n          Read more about{' '}\n          <Link\n            href={links.transferFees}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"text-primary-500 underline dark:text-primary-50\"\n          >\n            transfer fees.\n          </Link>\n        </span>\n      </div>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "src/features/transfer/TransferSection.tsx",
    "content": "import { ReactNode } from 'react';\n\ntype TransferSectionProps = {\n  label: string;\n  children: ReactNode;\n};\n\nexport function TransferSection({ label, children }: TransferSectionProps) {\n  return (\n    <div className=\"overflow-hidden rounded bg-card-gradient shadow-card dark:bg-background/65 dark:bg-none dark:shadow-[0_16px_32px_rgba(0,0,0,0.25)] dark:ring-1 dark:ring-inset dark:ring-primary-300/20\">\n      {/* Gradient Header */}\n      <div className=\"bg-accent-gradient px-3 py-1 shadow-accent-glow\">\n        <div className=\"flex items-center gap-2\">\n          <div className=\"h-2 w-2 rounded-full bg-cream-300\" />\n          <span className=\"text-sm font-medium text-white\">{label}</span>\n        </div>\n      </div>\n      {/* Content */}\n      <div className=\"p-3\">{children}</div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/features/transfer/TransferTokenCard.tsx",
    "content": "import { TransferTokenForm } from './TransferTokenForm';\n\nexport function TransferTokenCard() {\n  return (\n    <div className=\"relative w-100 sm:w-[31rem]\">\n      <TransferTokenForm />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/features/transfer/TransferTokenForm.tsx",
    "content": "import { IToken, QuotedCallsParams, Token, TokenAmount, WarpCore } from '@hyperlane-xyz/sdk';\nimport {\n  KnownProtocolType,\n  ProtocolType,\n  eqAddress,\n  errorToString,\n  fromWei,\n  isNullish,\n  isValidAddressEvm,\n  normalizeAddress,\n  toWei,\n} from '@hyperlane-xyz/utils';\nimport { ChevronIcon, SpinnerIcon, useModal } from '@hyperlane-xyz/widgets';\nimport {\n  getAccountAddressAndPubKey,\n  useAccountAddressForChain,\n  useAccounts,\n} from '@hyperlane-xyz/widgets/walletIntegrations/multiProtocol';\nimport { type AccountInfo } from '@hyperlane-xyz/widgets/walletIntegrations/types';\nimport BigNumber from 'bignumber.js';\nimport { Form, Formik, useFormikContext } from 'formik';\nimport { useCallback, useEffect, useMemo, useState } from 'react';\nimport { toast } from 'react-toastify';\n\nimport { RecipientWarningBanner } from '../../components/banner/RecipientWarningBanner';\nimport { ConnectAwareSubmitButton } from '../../components/buttons/ConnectAwareSubmitButton';\nimport { SolidButton } from '../../components/buttons/SolidButton';\nimport { SwapIcon } from '../../components/icons/SwapIcon';\nimport { TextField } from '../../components/input/TextField';\nimport { WARP_QUERY_PARAMS } from '../../consts/args';\nimport { config } from '../../consts/config';\nimport { defaultMultiCollateralRoutes } from '../../consts/defaultMultiCollateralRoutes';\nimport { Color } from '../../styles/Color';\nimport { logger } from '../../utils/logger';\nimport { updateQueryParams } from '../../utils/queryParams';\nimport { trackTransactionFailedEvent } from '../analytics/utils';\nimport {\n  getDestinationNativeBalance,\n  useDestinationBalance,\n  useOriginBalance,\n} from '../balances/hooks';\nimport { UsdLabel } from '../balances/UsdLabel';\nimport { useFeePrices } from '../balances/useFeePrices';\nimport { ChainConnectionWarning } from '../chains/ChainConnectionWarning';\nimport { ChainWalletWarning } from '../chains/ChainWalletWarning';\nimport { useChainDisplayName, useMultiProvider } from '../chains/hooks';\nimport { isMultiCollateralLimitExceeded } from '../limits/utils';\nimport { useIsAccountSanctioned } from '../sanctions/hooks/useIsAccountSanctioned';\nimport { useStore } from '../store';\nimport { useIsApproveRequired } from '../tokens/approval';\nimport {\n  getInitialTokenKeys,\n  getTokenByKeyFromMap,\n  useCollateralGroups,\n  useTokenByKeyMap,\n  useTokens,\n  useWarpCore,\n} from '../tokens/hooks';\nimport { ImportTokenButton } from '../tokens/ImportTokenButton';\nimport { TokenSelectField } from '../tokens/TokenSelectField';\nimport { useTokenPrices } from '../tokens/useTokenPrice';\nimport { checkTokenHasRoute, findConnectedDestinationToken } from '../tokens/utils';\nimport { WalletConnectionWarning } from '../wallet/WalletConnectionWarning';\nimport { WalletDropdown } from '../wallet/WalletDropdown';\nimport { getInterchainQuote, getTotalFee, getTransferToken } from './fees';\nimport { FeeSectionButton } from './FeeSectionButton';\nimport { useFetchMaxAmount } from './maxAmount';\nimport { RecipientConfirmationModal } from './RecipientConfirmationModal';\nimport { computeDestAmount } from './scaleUtils';\nimport { TransferSection } from './TransferSection';\nimport { TransferFormValues } from './types';\nimport { useRecipientBalanceWatcher } from './useBalanceWatcher';\nimport { useFeeQuotes } from './useFeeQuotes';\nimport { type QuotedCallsFeeQuotesResult, useQuotedCallsFeeQuotes } from './useQuotedCalls';\nimport { useTokenTransfer } from './useTokenTransfer';\nimport { isSmartContract, shouldClearAddress } from './utils';\n\nexport function TransferTokenForm() {\n  const multiProvider = useMultiProvider();\n  const warpCore = useWarpCore();\n  const tokenMap = useTokenByKeyMap();\n  const collateralGroups = useCollateralGroups();\n\n  const { setOriginChainName, routerAddressesByChainMap } = useStore((s) => ({\n    setOriginChainName: s.setOriginChainName,\n    routerAddressesByChainMap: s.routerAddressesByChainMap,\n  }));\n\n  const initialValues = useFormInitialValues();\n  const { accounts } = useAccounts(multiProvider, config.addressBlacklist);\n\n  // Flag for if form is in input vs review mode\n  const [isReview, setIsReview] = useState(false);\n  // Flag for check current type of token (setter used by TokenSelectField)\n  const [, setIsNft] = useState(false);\n  // This state is used for when the formik token is different from\n  // the token with highest collateral in a multi-collateral token setup\n  const [routeOverrideToken, setRouteTokenOverride] = useState<Token | null>(null);\n  // Modal for confirming address\n  const {\n    open: openConfirmationModal,\n    close: closeConfirmationModal,\n    isOpen: isConfirmationModalOpen,\n  } = useModal();\n\n  const validate = async (values: TransferFormValues) => {\n    const [result, overrideToken] = await validateForm(\n      warpCore,\n      tokenMap,\n      collateralGroups,\n      values,\n      accounts,\n      routerAddressesByChainMap,\n    );\n\n    trackTransactionFailedEvent(result, warpCore, values, accounts, overrideToken);\n\n    // Unless this is done, the review and the transfer would contain\n    // the selected token rather than collateral with highest balance\n    setRouteTokenOverride(overrideToken);\n    return result;\n  };\n\n  const onSubmitForm = async (values: TransferFormValues) => {\n    const originToken = getTokenByKeyFromMap(tokenMap, values.originTokenKey);\n    const destinationToken = getTokenByKeyFromMap(tokenMap, values.destinationTokenKey);\n    if (!originToken || !destinationToken) return;\n\n    // Get recipient (form value or fallback to connected wallet)\n    const { address: connectedDestAddress } = getAccountAddressAndPubKey(\n      multiProvider,\n      destinationToken.chainName,\n      accounts,\n    );\n    const recipient = values.recipient || connectedDestAddress || '';\n    if (!recipient) return;\n\n    logger.debug('Checking destination native balance for:', destinationToken.chainName, recipient);\n    const balance = await getDestinationNativeBalance(multiProvider, {\n      destination: destinationToken.chainName,\n      recipient,\n    });\n    if (isNullish(balance)) return;\n    else if (balance > 0n) {\n      logger.debug('Reviewing transfer form values');\n      setIsReview(true);\n    } else {\n      logger.debug('Recipient has no balance on destination. Confirming address.');\n      openConfirmationModal();\n    }\n  };\n\n  // Update origin chain name in store when origin token changes\n  useEffect(() => {\n    const originToken = getTokenByKeyFromMap(tokenMap, initialValues.originTokenKey);\n    if (originToken) {\n      setOriginChainName(originToken.chainName);\n    }\n  }, [initialValues.originTokenKey, tokenMap, setOriginChainName]);\n\n  return (\n    <Formik<TransferFormValues>\n      initialValues={initialValues}\n      onSubmit={onSubmitForm}\n      validate={validate}\n      validateOnChange={false}\n      validateOnBlur={false}\n    >\n      {({ isValidating }) => (\n        <Form className=\"transfer-form flex w-full flex-col items-stretch gap-1.5\">\n          <WarningBanners />\n\n          <TransferSection label=\"Send\">\n            <OriginTokenCard isReview={isReview} setIsNft={setIsNft} />\n          </TransferSection>\n          <SwapTokensButton disabled={isReview} />\n          <TransferSection label=\"Receive\">\n            <DestinationTokenCard isReview={isReview} />\n          </TransferSection>\n\n          <TransferCheckout\n            isReview={isReview}\n            isValidating={isValidating}\n            setIsReview={setIsReview}\n            routeOverrideToken={routeOverrideToken}\n            cleanOverrideToken={() => setRouteTokenOverride(null)}\n          />\n          <RecipientConfirmationModal\n            isOpen={isConfirmationModalOpen}\n            close={closeConfirmationModal}\n            onConfirm={() => setIsReview(true)}\n          />\n        </Form>\n      )}\n    </Formik>\n  );\n}\n\nfunction SwapTokensButton({ disabled }: { disabled?: boolean }) {\n  const { values, setValues } = useFormikContext<TransferFormValues>();\n  const tokenMap = useTokenByKeyMap();\n  const multiProvider = useMultiProvider();\n\n  const onSwap = useCallback(() => {\n    if (disabled) return;\n\n    const { originTokenKey, destinationTokenKey, recipient } = values;\n    const originToken = getTokenByKeyFromMap(tokenMap, originTokenKey);\n    const destToken = getTokenByKeyFromMap(tokenMap, destinationTokenKey);\n\n    if (!originToken || !destToken) return;\n\n    // After swap, origin becomes the new destination - validate recipient for new destination protocol\n    const shouldClearRecipient = shouldClearAddress(\n      multiProvider,\n      recipient,\n      originToken.chainName,\n    );\n\n    setValues((prevValues) => ({\n      ...prevValues,\n      amount: '',\n      originTokenKey: destinationTokenKey,\n      destinationTokenKey: originTokenKey,\n      recipient: shouldClearRecipient ? '' : prevValues.recipient,\n    }));\n\n    // Update URL params\n    if (originToken && destToken) {\n      updateQueryParams({\n        [WARP_QUERY_PARAMS.ORIGIN]: destToken.chainName,\n        [WARP_QUERY_PARAMS.ORIGIN_TOKEN]: destToken.symbol,\n        [WARP_QUERY_PARAMS.DESTINATION]: originToken.chainName,\n        [WARP_QUERY_PARAMS.DESTINATION_TOKEN]: originToken.symbol,\n      });\n    }\n  }, [disabled, values, tokenMap, setValues, multiProvider]);\n\n  return (\n    <div className=\"relative z-10 -my-3 flex justify-center\">\n      <button\n        type=\"button\"\n        onClick={onSwap}\n        disabled={disabled}\n        className=\"swap-chains-button group flex h-8 w-8 items-center justify-center rounded border border-gray-400/50 bg-white shadow-button transition-all hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-primary-300/35 dark:bg-background/90 dark:shadow-none dark:hover:bg-primary-300/[0.18]\"\n      >\n        <SwapIcon\n          width={18}\n          height={24}\n          className=\"swap-chains-icon transition-transform duration-300 group-hover:rotate-180 group-disabled:rotate-0 dark:drop-shadow-[0_0_8px_rgba(255,255,255,0.55)] dark:[&_path]:fill-white\"\n        />\n      </button>\n    </div>\n  );\n}\n\nfunction OriginTokenCard({\n  isReview,\n  setIsNft,\n}: {\n  isReview: boolean;\n  setIsNft?: (b: boolean) => void;\n}) {\n  const { values } = useFormikContext<TransferFormValues>();\n  const tokenMap = useTokenByKeyMap();\n  const collateralGroups = useCollateralGroups();\n\n  const originToken = getTokenByKeyFromMap(tokenMap, values.originTokenKey);\n  const destinationToken = getTokenByKeyFromMap(tokenMap, values.destinationTokenKey);\n  const { balance } = useOriginBalance(originToken);\n  const { prices, isLoading: isPriceLoading } = useTokenPrices();\n  const tokenPrice = originToken?.coinGeckoId ? prices[originToken.coinGeckoId] : undefined;\n\n  const isRouteSupported = useMemo(() => {\n    if (!originToken || !destinationToken) return true;\n    return checkTokenHasRoute(originToken, destinationToken, collateralGroups);\n  }, [originToken, destinationToken, collateralGroups]);\n\n  const amount = parseFloat(values.amount);\n  const totalTokenPrice = !isNullish(tokenPrice) && !isNaN(amount) ? amount * tokenPrice : 0;\n  const shouldShowPrice = totalTokenPrice >= 0.01;\n\n  return (\n    <div>\n      <div className=\"mb-2 flex items-center justify-between\">\n        <WalletDropdown\n          chainName={originToken?.chainName}\n          selectionMode=\"origin\"\n          disabled={isReview}\n        />\n        <ImportTokenButton token={originToken} />\n      </div>\n\n      <div className=\"transfer-chain-field rounded-[7px] border border-gray-400/25 bg-white p-3 shadow-input dark:border-primary-300/[0.18] dark:bg-transparent dark:shadow-none\">\n        <TokenSelectField\n          name=\"originTokenKey\"\n          selectionMode=\"origin\"\n          disabled={isReview}\n          setIsNft={setIsNft}\n          showLabel={false}\n        />\n\n        <div className=\"transfer-divider my-2.5 h-px bg-primary-50 dark:bg-primary-300/[0.22]\" />\n\n        <div className=\"flex items-center justify-between gap-2\">\n          <TextField\n            name=\"amount\"\n            placeholder=\"0\"\n            className=\"transfer-text-input w-full flex-1 border-none bg-transparent font-secondary text-xl font-normal text-gray-900 outline-none placeholder:text-gray-900 dark:text-foreground-primary dark:placeholder:text-foreground-secondary\"\n            type=\"number\"\n            step=\"any\"\n            disabled={isReview}\n            min=\"0\"\n            onWheel={(e: React.WheelEvent<HTMLInputElement>) => e.currentTarget.blur()}\n            onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {\n              if (e.key === '-' || e.key === 'e') e.preventDefault();\n            }}\n          />\n          <MaxButton balance={balance} disabled={isReview} isRouteSupported={isRouteSupported} />\n        </div>\n        <div className=\"transfer-balance mt-1 flex items-center justify-between text-xs leading-[18px] text-gray-450 dark:text-foreground-secondary\">\n          <span>\n            {shouldShowPrice && !isPriceLoading ? (\n              <>\n                $\n                {totalTokenPrice.toLocaleString('en-US', {\n                  minimumFractionDigits: 2,\n                  maximumFractionDigits: 2,\n                })}\n              </>\n            ) : (\n              '$0.00'\n            )}\n          </span>\n          <TokenBalance label=\"Balance\" balance={balance} />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction DestinationTokenCard({ isReview }: { isReview: boolean }) {\n  const { values, setFieldValue } = useFormikContext<TransferFormValues>();\n  const tokenMap = useTokenByKeyMap();\n  const multiProvider = useMultiProvider();\n\n  const destinationToken = getTokenByKeyFromMap(tokenMap, values.destinationTokenKey);\n\n  const connectedDestAddress = useAccountAddressForChain(\n    multiProvider,\n    destinationToken?.chainName,\n  );\n  const recipient = values.recipient || connectedDestAddress;\n\n  const { balance } = useDestinationBalance(recipient, destinationToken);\n\n  useRecipientBalanceWatcher(recipient, balance);\n\n  return (\n    <div>\n      <div className=\"mb-2 flex items-center justify-between\">\n        <WalletDropdown\n          chainName={destinationToken?.chainName}\n          selectionMode=\"destination\"\n          recipient={values.recipient}\n          onRecipientChange={(addr: string) => setFieldValue('recipient', addr)}\n          disabled={isReview}\n        />\n        <ImportTokenButton token={destinationToken} />\n      </div>\n\n      <div className=\"transfer-chain-field rounded-[7px] border border-gray-400/25 bg-white p-3 shadow-input dark:border-primary-300/[0.18] dark:bg-transparent dark:shadow-none\">\n        <TokenSelectField\n          name=\"destinationTokenKey\"\n          selectionMode=\"destination\"\n          disabled={isReview}\n          showLabel={false}\n        />\n\n        <div className=\"transfer-divider my-2.5 h-px bg-primary-50 dark:bg-primary-300/[0.22]\" />\n\n        <TokenBalance label=\"Remote Balance\" balance={balance} />\n      </div>\n    </div>\n  );\n}\n\nfunction MaxButton({\n  balance,\n  disabled,\n  isRouteSupported,\n}: {\n  balance?: TokenAmount;\n  disabled?: boolean;\n  isRouteSupported: boolean;\n}) {\n  const { values, setFieldValue } = useFormikContext<TransferFormValues>();\n  const { originTokenKey, destinationTokenKey } = values;\n  const tokenMap = useTokenByKeyMap();\n  const originToken = getTokenByKeyFromMap(tokenMap, originTokenKey);\n  const destinationToken = getTokenByKeyFromMap(tokenMap, destinationTokenKey);\n  const multiProvider = useMultiProvider();\n  const { accounts } = useAccounts(multiProvider);\n  const { fetchMaxAmount, isLoading } = useFetchMaxAmount();\n\n  const isDisabled =\n    disabled || !isRouteSupported || isLoading || !balance || !originToken || !destinationToken;\n\n  const onClick = async () => {\n    if (isDisabled) return;\n    const maxAmount = await fetchMaxAmount({\n      balance,\n      origin: originToken.chainName,\n      destinationToken,\n      accounts,\n      recipient: values.recipient,\n    });\n    if (isNullish(maxAmount)) return;\n    const decimalsAmount = maxAmount.getDecimalFormattedAmount();\n    const roundedAmount = new BigNumber(decimalsAmount).toFixed(4, BigNumber.ROUND_FLOOR);\n    setFieldValue('amount', roundedAmount);\n  };\n\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      disabled={isDisabled}\n      className=\"transfer-max-btn rounded border border-gray-300 px-2 py-0.5 font-secondary text-sm text-gray-450 transition-colors hover:border-gray-400 hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-primary-300/40 dark:text-foreground-secondary dark:hover:border-primary-300/65 dark:hover:text-foreground-primary\"\n    >\n      {isLoading ? <SpinnerIcon className=\"h-4 w-4\" /> : 'Max'}\n    </button>\n  );\n}\n\nfunction TokenBalance({\n  label,\n  balance,\n}: {\n  label: string;\n  balance: TokenAmount | null | undefined;\n}) {\n  return (\n    <span className=\"text-xs leading-[18px] text-gray-450 dark:text-foreground-secondary\">\n      {balance ? (\n        <>\n          {label}: {balance.getDecimalFormattedAmount().toFixed(4)} {balance.token.symbol}\n        </>\n      ) : (\n        <>{label}: 0.00</>\n      )}\n    </span>\n  );\n}\n\nfunction TransferCheckout({\n  isReview,\n  isValidating,\n  setIsReview,\n  routeOverrideToken,\n  cleanOverrideToken,\n}: {\n  isReview: boolean;\n  isValidating: boolean;\n  setIsReview: (b: boolean) => void;\n  routeOverrideToken: Token | null;\n  cleanOverrideToken: () => void;\n}) {\n  const { values } = useFormikContext<TransferFormValues>();\n  const tokenMap = useTokenByKeyMap();\n  const isRouteSupported = useIsRouteSupported();\n\n  // Use the same origin-token resolution as ButtonSection / executeTransfer so\n  // the offchain quote is bound to the same router the transfer will actually\n  // call. validateForm has already produced the multi-collateral / cross-asset\n  // optimal token and stored it in routeOverrideToken — no extra findRouteToken\n  // pass here.\n  const originToken = routeOverrideToken || getTokenByKeyFromMap(tokenMap, values.originTokenKey);\n  const destinationTokenByKey = getTokenByKeyFromMap(tokenMap, values.destinationTokenKey);\n  const destinationToken =\n    originToken && destinationTokenByKey\n      ? findConnectedDestinationToken(originToken, destinationTokenByKey)\n      : undefined;\n\n  const quotedCalls = useQuotedCallsFeeQuotes(\n    values,\n    isRouteSupported,\n    originToken,\n    destinationToken,\n  );\n\n  return (\n    <>\n      <ReviewDetails\n        isReview={isReview}\n        originToken={originToken}\n        destinationToken={destinationToken}\n        isRouteSupported={isRouteSupported}\n        quotedCalls={quotedCalls}\n      />\n      <ButtonSection\n        isReview={isReview}\n        isValidating={isValidating}\n        setIsReview={setIsReview}\n        cleanOverrideToken={cleanOverrideToken}\n        routeOverrideToken={routeOverrideToken}\n        getQuotedCallsParams={quotedCalls.getQuotedCallsParams}\n      />\n    </>\n  );\n}\n\nfunction ButtonSection({\n  isReview,\n  isValidating,\n  setIsReview,\n  cleanOverrideToken,\n  routeOverrideToken,\n  getQuotedCallsParams,\n}: {\n  isReview: boolean;\n  isValidating: boolean;\n  setIsReview: (b: boolean) => void;\n  cleanOverrideToken: () => void;\n  routeOverrideToken: Token | null;\n  getQuotedCallsParams: () => Promise<QuotedCallsParams | null>;\n}) {\n  const { values } = useFormikContext<TransferFormValues>();\n  const multiProvider = useMultiProvider();\n  const tokenMap = useTokenByKeyMap();\n  const originToken = routeOverrideToken || getTokenByKeyFromMap(tokenMap, values.originTokenKey);\n  const destinationToken = getTokenByKeyFromMap(tokenMap, values.destinationTokenKey);\n  const chainDisplayName = useChainDisplayName(destinationToken?.chainName || '');\n  const isRouteSupported = useIsRouteSupported();\n\n  const { accounts } = useAccounts(multiProvider, config.addressBlacklist);\n  const { address: connectedWallet } = getAccountAddressAndPubKey(\n    multiProvider,\n    originToken?.chainName,\n    accounts,\n  );\n\n  // Get recipient (form value or fallback to connected wallet for destination)\n  const { address: connectedDestAddress } = getAccountAddressAndPubKey(\n    multiProvider,\n    destinationToken?.chainName,\n    accounts,\n  );\n  const recipient = values.recipient || connectedDestAddress || '';\n\n  // Confirming recipient address\n  const [{ addressConfirmed, showWarning }, setRecipientInfos] = useState({\n    showWarning: false,\n    addressConfirmed: true,\n  });\n\n  useEffect(() => {\n    let isMounted = true;\n\n    const checkSameEVMRecipient = async (recipient: string) => {\n      if (!connectedWallet || !originToken || !destinationToken) {\n        setRecipientInfos({ showWarning: false, addressConfirmed: true });\n        return;\n      }\n\n      const { protocol: destinationProtocol } = multiProvider.getChainMetadata(\n        destinationToken.chainName,\n      );\n      const { protocol: sourceProtocol } = multiProvider.getChainMetadata(originToken.chainName);\n\n      if (\n        sourceProtocol !== ProtocolType.Ethereum ||\n        destinationProtocol !== ProtocolType.Ethereum\n      ) {\n        setRecipientInfos({ showWarning: false, addressConfirmed: true });\n        return;\n      }\n\n      if (!isValidAddressEvm(recipient)) {\n        setRecipientInfos({ showWarning: false, addressConfirmed: true });\n        return;\n      }\n\n      const { isContract: isSenderSmartContract, error: senderCheckError } = await isSmartContract(\n        multiProvider,\n        originToken.chainName,\n        connectedWallet,\n      );\n      if (!isMounted) return;\n\n      const { isContract: isRecipientSmartContract, error: recipientCheckError } =\n        await isSmartContract(multiProvider, destinationToken.chainName, recipient);\n      if (!isMounted) return;\n\n      const isSelfRecipient = eqAddress(recipient, connectedWallet);\n\n      if (senderCheckError || recipientCheckError) {\n        toast.error(senderCheckError || recipientCheckError);\n        setRecipientInfos({ addressConfirmed: true, showWarning: false });\n        return;\n      }\n\n      if (isSelfRecipient && isSenderSmartContract && !isRecipientSmartContract) {\n        const msg = `The recipient address is the same as the connected wallet, but it does not exist as a smart contract on ${chainDisplayName}.`;\n        logger.warn(msg);\n        setRecipientInfos({ showWarning: true, addressConfirmed: false });\n      } else {\n        setRecipientInfos({ showWarning: false, addressConfirmed: true });\n      }\n    };\n    checkSameEVMRecipient(recipient);\n\n    return () => {\n      isMounted = false;\n    };\n  }, [recipient, connectedWallet, multiProvider, originToken, destinationToken, chainDisplayName]);\n\n  const isSanctioned = useIsAccountSanctioned();\n\n  const { setTransferLoading } = useStore((s) => ({\n    setTransferLoading: s.setTransferLoading,\n  }));\n\n  const onDoneTransactions = () => {\n    setIsReview(false);\n    cleanOverrideToken();\n  };\n  const { triggerTransactions } = useTokenTransfer(onDoneTransactions);\n\n  const triggerTransactionsHandler = async () => {\n    if (isSanctioned || !originToken || !destinationToken) return;\n    setIsReview(false);\n    setTransferLoading(true);\n\n    // Wait for any in-flight offchain quote to settle so a quick Send-click\n    // during the first-load / refetch window doesn't fall through to the\n    // plain transferRemote path.\n    const quotedCallsParams = await getQuotedCallsParams();\n    await triggerTransactions(values, routeOverrideToken, quotedCallsParams);\n    setTransferLoading(false);\n  };\n\n  const onEdit = () => {\n    setIsReview(false);\n    cleanOverrideToken();\n  };\n\n  const text = !isRouteSupported\n    ? 'Route is not supported'\n    : isValidating\n      ? 'Validating...'\n      : 'Continue';\n\n  if (!isReview) {\n    return (\n      <>\n        <div\n          className={`gap-2 bg-amber-400 px-4 text-sm ${\n            showWarning ? 'max-h-38 py-2' : 'max-h-0'\n          } overflow-hidden transition-all duration-500`}\n        >\n          <RecipientWarningBanner\n            destinationChain={chainDisplayName}\n            confirmRecipientHandler={(checked) =>\n              setRecipientInfos((state) => ({ ...state, addressConfirmed: checked }))\n            }\n          />\n        </div>\n\n        <ConnectAwareSubmitButton\n          disabled={!addressConfirmed || !isRouteSupported}\n          chainName={originToken?.chainName || ''}\n          text={text}\n          classes=\"w-full mb-4 px-3 py-2.5 font-secondary text-xl text-cream-100\"\n        />\n      </>\n    );\n  }\n\n  return (\n    <>\n      <div\n        className={`gap-2 bg-amber-400 px-4 text-sm ${\n          showWarning ? 'max-h-38 py-2' : 'max-h-0'\n        } overflow-hidden transition-all duration-500`}\n      >\n        <RecipientWarningBanner\n          destinationChain={chainDisplayName}\n          confirmRecipientHandler={(checked) =>\n            setRecipientInfos((state) => ({ ...state, addressConfirmed: checked }))\n          }\n        />\n      </div>\n      <div className=\"mb-4 mt-4 flex items-center justify-between space-x-4\">\n        <SolidButton\n          type=\"button\"\n          color=\"primary\"\n          onClick={onEdit}\n          className=\"px-6 py-1.5 font-secondary\"\n          icon={<ChevronIcon direction=\"w\" width={10} height={6} color={Color.white} />}\n        >\n          <span>Edit</span>\n        </SolidButton>\n        <SolidButton\n          disabled={!addressConfirmed || isSanctioned}\n          type=\"button\"\n          color=\"accent\"\n          onClick={triggerTransactionsHandler}\n          className=\"flex-1 px-3 py-1.5 font-secondary text-white\"\n        >\n          {`Send to ${chainDisplayName}`}\n        </SolidButton>\n      </div>\n    </>\n  );\n}\n\nfunction ReviewDetails({\n  isReview,\n  originToken,\n  destinationToken,\n  isRouteSupported,\n  quotedCalls,\n}: {\n  isReview: boolean;\n  originToken: Token | undefined;\n  destinationToken: IToken | undefined;\n  isRouteSupported: boolean;\n  quotedCalls: QuotedCallsFeeQuotesResult;\n}) {\n  const { values } = useFormikContext<TransferFormValues>();\n  const warpCore = useWarpCore();\n  const { amount } = values;\n  const originTokenSymbol = originToken?.symbol || '';\n  const isNft = originToken?.isNft();\n\n  const destAmount = useMemo(() => {\n    if (!isReview) return null;\n    return computeDestAmount(amount, originToken, destinationToken);\n  }, [amount, originToken, destinationToken, isReview]);\n\n  const amountWei = isNft ? amount.toString() : toWei(amount, originToken?.decimals);\n\n  // Offchain fee quoting (when configured) — owned by TransferCheckout\n  const {\n    isLoading: isOffchainQuoteLoading,\n    fees: offchainFeeQuotes,\n    quotedCallsParams,\n  } = quotedCalls;\n\n  // Onchain fee quoting: used as fallback when offchain isn't available for this route\n  const offchainSettled = !isOffchainQuoteLoading;\n  const offchainUnavailable = !config.feeQuotingUrl || (offchainSettled && !offchainFeeQuotes);\n  const { isLoading: isOnchainQuoteLoading, fees: onchainFeeQuotes } = useFeeQuotes(\n    values,\n    isRouteSupported && offchainUnavailable,\n    originToken,\n    destinationToken,\n    !isReview,\n  );\n\n  const feeQuotes = offchainFeeQuotes ?? onchainFeeQuotes;\n  const isQuoteLoading = offchainUnavailable ? isOnchainQuoteLoading : isOffchainQuoteLoading;\n\n  // QuotedCalls pulls amount + tokenFeeQuote when the fee is denominated in\n  // the transferred token. Inflate the approval check so a user with allowance\n  // == amount doesn't revert on the token pull.\n  const sameTokenFeeAmount =\n    quotedCallsParams && feeQuotes?.tokenFeeQuote?.token.equals(originToken)\n      ? feeQuotes.tokenFeeQuote.amount\n      : 0n;\n  const approvalAmountWei = isNft\n    ? amountWei\n    : (BigInt(amountWei || '0') + sameTokenFeeAmount).toString();\n\n  // Approval check: uses quotedCalls address as spender when offchain quoting is active\n  const { isLoading: isApproveLoading, isApproveRequired } = useIsApproveRequired(\n    originToken,\n    approvalAmountWei,\n    isReview,\n    quotedCallsParams,\n  );\n\n  const { prices } = useTokenPrices();\n  const feePrices = useFeePrices(feeQuotes ?? null, warpCore.tokens, prices);\n  const tokenPrice = originToken?.coinGeckoId ? prices[originToken.coinGeckoId] : undefined;\n  const parsedAmount = parseFloat(amount);\n  const transferUsd = tokenPrice && !isNaN(parsedAmount) ? parsedAmount * tokenPrice : 0;\n  const isLoading = isApproveLoading || isQuoteLoading;\n\n  const fees = useMemo(() => {\n    if (!feeQuotes) return null;\n\n    const interchainQuote = getInterchainQuote(originToken, feeQuotes.interchainQuote);\n    const fees = {\n      ...feeQuotes,\n      interchainQuote: interchainQuote || feeQuotes.interchainQuote,\n    };\n    const totalFees = getTotalFee({\n      ...fees,\n      interchainQuote: interchainQuote || fees.interchainQuote,\n    })\n      .map((fee) => `${fee.getDecimalFormattedAmount().toFixed(8)} ${fee.token.symbol}`)\n      .join(', ');\n\n    return {\n      ...fees,\n      totalFees,\n    };\n  }, [feeQuotes, originToken]);\n\n  return (\n    <>\n      {!isReview && (\n        <FeeSectionButton\n          fees={fees}\n          isLoading={isLoading}\n          feePrices={feePrices}\n          transferUsd={transferUsd}\n        />\n      )}\n\n      <div\n        className={`${\n          isReview ? 'max-h-screen duration-1000 ease-in' : 'max-h-0 duration-500'\n        } overflow-hidden transition-all`}\n      >\n        <label className=\"transfer-field-label mt-4 block pl-0.5 text-sm text-gray-600 dark:text-foreground-secondary\">\n          Transactions\n        </label>\n        <div className=\"transfer-review-panel mt-1.5 space-y-2 break-all rounded border border-gray-400 bg-gray-150 px-2.5 py-2 text-sm dark:border-primary-300/25 dark:bg-background/40 dark:text-foreground-primary\">\n          {isLoading ? (\n            <div className=\"flex items-center justify-center py-6\">\n              <SpinnerIcon className=\"h-5 w-5\" />\n            </div>\n          ) : (\n            <>\n              {isApproveRequired && (\n                <div>\n                  <h4>Transaction 1: Approve Transfer</h4>\n                  <div className=\"ml-1.5 mt-1.5 space-y-1.5 border-l border-gray-300 pl-2 text-xs\">\n                    <p>{`Spender: ${quotedCallsParams?.address ?? originToken?.addressOrDenom}`}</p>\n                    {originToken?.collateralAddressOrDenom && (\n                      <p>{`Collateral Address: ${originToken.collateralAddressOrDenom}`}</p>\n                    )}\n                  </div>\n                </div>\n              )}\n              <div>\n                <h4>{`Transaction${isApproveRequired ? ' 2' : ''}: Transfer Remote`}</h4>\n                <div className=\"ml-1.5 mt-1.5 space-y-1.5 border-l border-gray-300 pl-2 text-xs dark:border-primary-300/25\">\n                  {destinationToken?.addressOrDenom && (\n                    <p className=\"flex\">\n                      <span className=\"min-w-[7.5rem]\">Remote Token</span>\n                      <span>{destinationToken.addressOrDenom}</span>\n                    </p>\n                  )}\n\n                  <p className=\"flex\">\n                    <span className=\"min-w-[7.5rem]\">{isNft ? 'Token ID' : 'Amount'}</span>\n                    <span>{`${amount} ${originTokenSymbol}`}</span>\n                  </p>\n                  {destAmount && (\n                    <p className=\"flex\">\n                      <span className=\"min-w-[7.5rem]\">Received Amount</span>\n                      <span>{`${destAmount} ${destinationToken?.symbol || ''}`}</span>\n                    </p>\n                  )}\n                  {fees?.localQuote && fees.localQuote.amount > 0n && (\n                    <p className=\"flex\">\n                      <span className=\"min-w-[7.5rem]\">Local Gas (est.)</span>\n                      <span>\n                        {`${fees.localQuote.getDecimalFormattedAmount().toFixed(8) || '0'} ${fees.localQuote.token.symbol || ''}`}\n                        <UsdLabel tokenAmount={fees.localQuote} feePrices={feePrices} />\n                      </span>\n                    </p>\n                  )}\n                  {fees?.interchainQuote && fees.interchainQuote.amount > 0n && (\n                    <p className=\"flex\">\n                      <span className=\"min-w-[7.5rem]\">Interchain Gas</span>\n                      <span>\n                        {`${fees.interchainQuote.getDecimalFormattedAmount().toFixed(8) || '0'} ${fees.interchainQuote.token.symbol || ''}`}\n                        <UsdLabel tokenAmount={fees.interchainQuote} feePrices={feePrices} />\n                      </span>\n                    </p>\n                  )}\n                  {fees?.tokenFeeQuote && fees.tokenFeeQuote.amount > 0n && (\n                    <p className=\"flex\">\n                      <span className=\"min-w-[7.5rem]\">Token Fee</span>\n                      <span>\n                        {`${fees.tokenFeeQuote.getDecimalFormattedAmount().toFixed(8) || '0'} ${fees.tokenFeeQuote.token.symbol || ''}`}\n                        <UsdLabel tokenAmount={fees.tokenFeeQuote} feePrices={feePrices} />\n                      </span>\n                    </p>\n                  )}\n                </div>\n              </div>\n            </>\n          )}\n        </div>\n      </div>\n    </>\n  );\n}\n\nfunction WarningBanners() {\n  const { values } = useFormikContext<TransferFormValues>();\n  const tokenMap = useTokenByKeyMap();\n  const originToken = getTokenByKeyFromMap(tokenMap, values.originTokenKey);\n  const destinationToken = getTokenByKeyFromMap(tokenMap, values.destinationTokenKey);\n\n  return (\n    // Cap space to one visible banner since warning layers are absolutely positioned at the top.\n    <div className=\"max-h-12 overflow-hidden sm:max-h-10\">\n      <ChainWalletWarning origin={originToken?.chainName || ''} />\n      <ChainConnectionWarning\n        origin={originToken?.chainName || ''}\n        destination={destinationToken?.chainName || ''}\n      />\n      <WalletConnectionWarning origin={originToken?.chainName || ''} />\n    </div>\n  );\n}\n\nfunction useFormInitialValues(): TransferFormValues {\n  const warpCore = useWarpCore();\n  const tokens = useTokens();\n\n  const { originTokenKey, destinationTokenKey } = getInitialTokenKeys(warpCore, tokens);\n\n  return useMemo(\n    () => ({\n      originTokenKey,\n      destinationTokenKey,\n      amount: '',\n      recipient: '',\n    }),\n    [originTokenKey, destinationTokenKey],\n  );\n}\n\nfunction useIsRouteSupported(): boolean {\n  const { values } = useFormikContext<TransferFormValues>();\n  const tokenMap = useTokenByKeyMap();\n  const collateralGroups = useCollateralGroups();\n  const originToken = getTokenByKeyFromMap(tokenMap, values.originTokenKey);\n  const destinationToken = getTokenByKeyFromMap(tokenMap, values.destinationTokenKey);\n\n  return useMemo(() => {\n    if (!originToken || !destinationToken) return true;\n    return checkTokenHasRoute(originToken, destinationToken, collateralGroups);\n  }, [originToken, destinationToken, collateralGroups]);\n}\n\nconst insufficientFundsErrMsg = /insufficient.(funds|lamports)/i;\nconst emptyAccountErrMsg = /AccountNotFound/i;\n\nasync function validateForm(\n  warpCore: WarpCore,\n  tokenMap: Map<string, Token>,\n  collateralGroups: Map<string, Token[]>,\n  values: TransferFormValues,\n  accounts: Record<KnownProtocolType, AccountInfo>,\n  routerAddressesByChainMap: Record<ChainName, Set<string>>,\n): Promise<[Record<string, string> | null, Token | null]> {\n  // returns a tuple, where first value is validation result\n  // and second value is token override\n  try {\n    const { originTokenKey, destinationTokenKey, amount, recipient: formRecipient } = values;\n\n    // Look up tokens from the pre-computed map\n    const token = getTokenByKeyFromMap(tokenMap, originTokenKey);\n    const destinationToken = getTokenByKeyFromMap(tokenMap, destinationTokenKey);\n\n    if (!amount) return [{ amount: 'Invalid amount' }, null];\n    if (!token) return [{ originTokenKey: 'Origin token is required' }, null];\n    if (!destinationToken) return [{ destinationTokenKey: 'Destination token is required' }, null];\n\n    // Use form recipient if set, otherwise fallback to connected wallet for destination chain\n    const { address: connectedDestAddress } = getAccountAddressAndPubKey(\n      warpCore.multiProvider,\n      destinationToken.chainName,\n      accounts,\n    );\n    const recipient = formRecipient || connectedDestAddress || '';\n\n    if (!recipient) return [{ amount: 'Invalid recipient' }, null];\n\n    // Early route check using collateral groups - validates origin token can reach destination token\n    if (!checkTokenHasRoute(token, destinationToken, collateralGroups)) {\n      return [{ destinationTokenKey: 'Route is not supported' }, null];\n    }\n\n    const destination = destinationToken.chainName;\n\n    if (routerAddressesByChainMap[destination]?.has(normalizeAddress(recipient))) {\n      return [{ recipient: 'Warp Route address is not valid as recipient' }, null];\n    }\n\n    const { address: sender, publicKey: senderPubKey } = getAccountAddressAndPubKey(\n      warpCore.multiProvider,\n      token.chainName,\n      accounts,\n    );\n\n    const amountWei = toWei(amount, token.decimals);\n    const transferToken = await getTransferToken(\n      warpCore,\n      token,\n      destinationToken,\n      amountWei,\n      recipient,\n      sender,\n      defaultMultiCollateralRoutes,\n    );\n\n    // This should not happen since we already checked the route above, but keep as safety check\n    const connectedDestinationToken = findConnectedDestinationToken(\n      transferToken,\n      destinationToken,\n    );\n    if (!connectedDestinationToken) {\n      return [{ destinationTokenKey: 'Route is not supported' }, null];\n    }\n\n    const multiCollateralLimit = isMultiCollateralLimitExceeded(\n      token,\n      connectedDestinationToken,\n      amountWei,\n    );\n\n    if (multiCollateralLimit) {\n      return [\n        {\n          amount: `Transfer limit is ${fromWei(multiCollateralLimit.toString(), token.decimals)} ${token.symbol}`,\n        },\n        null,\n      ];\n    }\n\n    const originTokenAmount = transferToken.amount(amountWei);\n\n    // Don't fetch a Predicate attestation here — that doubles API spend on every debounced\n    // form change. Gas simulation inside validateTransfer may fail without one, so we catch\n    // and swallow simulation errors only for Predicate routes; balance/collateral/recipient\n    // checks have already run by the time gas sim is reached.\n    let result;\n    try {\n      result = await warpCore.validateTransfer({\n        originTokenAmount,\n        destination,\n        recipient,\n        sender: sender || '',\n        senderPubKey: await senderPubKey,\n        destinationToken: connectedDestinationToken,\n      });\n    } catch (error) {\n      const isPredicateRoute = await warpCore.isPredicateSupported(transferToken, destination);\n      if (!isPredicateRoute) throw error;\n      // Only swallow EVM execution reverts (predicate wrapper rejecting without attestation).\n      // Rethrow provider/RPC/network errors so they surface rather than silently\n      // appearing as \"validation passed\" and failing at submit-time.\n      const causeCode = (error as any)?.cause?.code;\n      if (causeCode !== 'CALL_EXCEPTION' && causeCode !== 'UNPREDICTABLE_GAS_LIMIT') throw error;\n      result = null;\n    }\n\n    if (!isNullish(result)) {\n      const enriched = await enrichBalanceError(\n        warpCore,\n        result,\n        originTokenAmount,\n        destination,\n        sender || '',\n        recipient,\n        connectedDestinationToken,\n      );\n      return [enriched, null];\n    }\n\n    if (transferToken.addressOrDenom === token.addressOrDenom) return [null, null];\n\n    return [null, transferToken];\n  } catch (error: any) {\n    logger.error('Error validating form', error);\n    let errorMsg = errorToString(error, 40);\n    const fullError = `${errorMsg} ${error.message}`;\n    if (insufficientFundsErrMsg.test(fullError) || emptyAccountErrMsg.test(fullError)) {\n      const originToken = getTokenByKeyFromMap(tokenMap, values.originTokenKey);\n      const chainMetadata = originToken\n        ? warpCore.multiProvider.tryGetChainMetadata(originToken.chainName)\n        : null;\n      const symbol = chainMetadata?.nativeToken?.symbol || 'funds';\n      errorMsg = `Insufficient ${symbol} for gas fees`;\n    }\n    return [{ form: errorMsg }, null];\n  }\n}\n\nconst igpErrorPattern = /^Insufficient (\\S+) for interchain gas$/;\n\nasync function enrichBalanceError(\n  warpCore: WarpCore,\n  result: Record<string, string>,\n  originTokenAmount: TokenAmount<Token>,\n  destination: string,\n  sender: string,\n  recipient: string,\n  destinationToken: Token,\n): Promise<Record<string, string>> {\n  if (!result.amount) return result;\n  const igpErrorMatch = igpErrorPattern.exec(result.amount);\n  if (!igpErrorMatch) return result;\n\n  try {\n    const { igpQuote } = await warpCore.getInterchainTransferFee({\n      originTokenAmount,\n      destination,\n      sender,\n      recipient,\n      destinationToken,\n    });\n\n    // Symbol in validateTransfer message is sourced from igpQuote.token.symbol.\n    if (igpErrorMatch[1] !== igpQuote.token.symbol) return result;\n\n    const balance = originTokenAmount.token.isFungibleWith(igpQuote.token)\n      ? await originTokenAmount.token.getBalance(warpCore.multiProvider, sender)\n      : await igpQuote.token.getBalance(warpCore.multiProvider, sender);\n    const deficit = igpQuote.amount - balance.amount;\n    if (deficit > 0n) {\n      const deficitAmount = new TokenAmount(deficit, igpQuote.token);\n      return {\n        ...result,\n        amount: `Insufficient ${igpQuote.token.symbol} for interchain gas (need ${deficitAmount.getDecimalFormattedAmount().toFixed(4)} more ${igpQuote.token.symbol})`,\n      };\n    }\n  } catch (e) {\n    logger.warn('Failed to enrich balance error', e);\n  }\n  return result;\n}\n"
  },
  {
    "path": "src/features/transfer/TransfersDetailsModal.tsx",
    "content": "import { ProtocolType } from '@hyperlane-xyz/utils';\nimport {\n  CopyButton,\n  MessageStage,\n  MessageStatus,\n  MessageTimeline,\n  Modal,\n  SpinnerIcon,\n  type StageTimings,\n  useTimeout,\n  WideChevronIcon,\n} from '@hyperlane-xyz/widgets';\nimport {\n  useAccountForChain,\n  useWalletDetails,\n} from '@hyperlane-xyz/widgets/walletIntegrations/multiProtocol';\nimport Image from 'next/image';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\n\nimport { ChainLogo } from '../../components/icons/ChainLogo';\nimport { ModalHeader } from '../../components/layout/ModalHeader';\nimport ArrowRightIcon from '../../images/icons/arrow-right.svg';\nimport LinkIcon from '../../images/icons/external-link-icon.svg';\nimport { Color } from '../../styles/Color';\nimport { formatTimestamp } from '../../utils/date';\nimport { getHypExplorerLink } from '../../utils/links';\nimport { logger } from '../../utils/logger';\nimport { useMultiProvider } from '../chains/hooks';\nimport { getChainDisplayName, hasPermissionlessChain } from '../chains/utils';\nimport { useMessageDeliveryStatus } from '../messages/useMessageDeliveryStatus';\nimport { useOriginFinality } from '../messages/useOriginFinality';\nimport { useStore } from '../store';\nimport { tryFindToken, useWarpCore } from '../tokens/hooks';\nimport { TokenChainIcon } from '../tokens/TokenChainIcon';\nimport { computeDestAmount } from './scaleUtils';\nimport { TransferContext, TransferStatus } from './types';\nimport {\n  estimateDeliverySeconds,\n  formatEta,\n  getIconByTransferStatus,\n  getTransferStatusLabel,\n  isTransferFailed,\n  isTransferSent,\n} from './utils';\n\nconst DEFAULT_TIMINGS: StageTimings = {\n  [MessageStage.Finalized]: null,\n  [MessageStage.Validated]: null,\n  [MessageStage.Relayed]: null,\n};\n\nexport function TransfersDetailsModal({\n  isOpen,\n  onClose,\n  transfer,\n}: {\n  isOpen: boolean;\n  onClose: () => void;\n  transfer: TransferContext;\n}) {\n  const [fromUrl, setFromUrl] = useState<string>('');\n  const [toUrl, setToUrl] = useState<string>('');\n  const [originTxUrl, setOriginTxUrl] = useState<string>('');\n  const [destTxUrl, setDestTxUrl] = useState<string>('');\n\n  const {\n    status,\n    origin,\n    destination,\n    amount,\n    sender,\n    recipient,\n    originTokenAddressOrDenom,\n    originTxHash,\n    destTokenAddressOrDenom,\n    msgId,\n    timestamp,\n    destinationTxHash: storedDestTxHash,\n  } = transfer || {};\n\n  const multiProvider = useMultiProvider();\n  const warpCore = useWarpCore();\n  const transfers = useStore((s) => s.transfers);\n  const updateTransferStatus = useStore((s) => s.updateTransferStatus);\n\n  // Find the index of this transfer in the store (for updating status)\n  const transferIndex = useMemo(\n    () => transfers.findIndex((t) => t === transfer || (t.msgId && t.msgId === transfer?.msgId)),\n    [transfers, transfer],\n  );\n\n  const isChainKnown = multiProvider.hasChain(origin);\n  const account = useAccountForChain(multiProvider, isChainKnown ? origin : undefined);\n  const walletDetails = useWalletDetails()[account?.protocol || ProtocolType.Ethereum];\n\n  // Query delivery status from GraphQL when modal is open for sent transfers\n  const isSent = isTransferSent(transfer?.status);\n  const isFailed = isTransferFailed(transfer?.status);\n  const shouldTrackDelivery = isSent && !isFailed && !!msgId;\n\n  const delivery = useMessageDeliveryStatus(\n    shouldTrackDelivery ? msgId : undefined,\n    isOpen,\n    multiProvider,\n  );\n\n  // Combine store + live query to avoid flicker when reopening modal\n  const isDelivered = status === TransferStatus.Delivered || delivery.isDelivered;\n\n  // Origin block number: prefer store (hot path), fall back to GraphQL (cold path after refresh)\n  const originBlockNumber = transfer?.originBlockNumber ?? delivery.originBlockHeight;\n\n  const isFinalized = useOriginFinality(\n    origin,\n    originBlockNumber,\n    isSent && !isFailed && !isDelivered && !!originBlockNumber && isOpen,\n  );\n\n  const stage = useMemo((): MessageStage => {\n    if (isDelivered) return MessageStage.Relayed;\n    if (isFinalized) return MessageStage.Finalized;\n    if (isTransferSent(transfer?.status) && transfer?.originTxHash) return MessageStage.Sent;\n    return MessageStage.Preparing;\n  }, [isDelivered, isFinalized, transfer]);\n\n  // Resolve the destination tx hash from either store or live query\n  const destinationTxHash = storedDestTxHash || delivery.destinationTxHash;\n\n  const isAccountReady = !!account?.isReady;\n  const connectorName = walletDetails.name || 'wallet';\n  const token = tryFindToken(warpCore, origin, originTokenAddressOrDenom);\n  const destToken = tryFindToken(warpCore, destination, destTokenAddressOrDenom);\n  const destAmount = computeDestAmount(amount || '', token, destToken);\n  const isPermissionlessRoute = hasPermissionlessChain(multiProvider, [destination, origin]);\n  const isFinal = isSent || isFailed;\n  const currentStatus = isDelivered ? TransferStatus.Delivered : status;\n  const statusDescription = getTransferStatusLabel(\n    currentStatus,\n    connectorName,\n    isPermissionlessRoute,\n    isAccountReady,\n  );\n  const showSignWarning = useSignIssueWarning(status);\n\n  const date = useMemo(\n    () => (timestamp ? formatTimestamp(timestamp) : formatTimestamp(new Date().getTime())),\n    [timestamp],\n  );\n\n  const explorerLink = getHypExplorerLink(multiProvider, origin, msgId);\n\n  // ETA: only show when confirmed on origin but not yet delivered\n  const showEta = currentStatus === TransferStatus.ConfirmedTransfer && !isDelivered && !isFailed;\n  const etaSeconds = useMemo(\n    () => (showEta ? estimateDeliverySeconds(origin, destination, multiProvider) : null),\n    [showEta, origin, destination, multiProvider],\n  );\n\n  // Show timeline for sent (non-failed) transfers that have an origin tx hash\n  const showTimeline = isSent && !isFailed && !!originTxHash;\n  const messageStatus = isDelivered\n    ? MessageStatus.Delivered\n    : isFailed\n      ? MessageStatus.Failing\n      : MessageStatus.Pending;\n\n  // Reset delivery tracking when viewing a different transfer\n  const hasUpdatedDelivery = useRef(false);\n  useEffect(() => {\n    hasUpdatedDelivery.current = false;\n  }, [msgId]);\n\n  // Update store when delivery is confirmed\n  useEffect(() => {\n    if (\n      delivery.isDelivered &&\n      !hasUpdatedDelivery.current &&\n      status !== TransferStatus.Delivered &&\n      transferIndex >= 0\n    ) {\n      hasUpdatedDelivery.current = true;\n      updateTransferStatus(transferIndex, TransferStatus.Delivered, {\n        destinationTxHash: delivery.destinationTxHash,\n      });\n    }\n  }, [\n    delivery.isDelivered,\n    delivery.destinationTxHash,\n    transferIndex,\n    status,\n    updateTransferStatus,\n  ]);\n\n  // Fetch explorer URLs for addresses and transactions\n  useEffect(() => {\n    if (!transfer) return;\n    let cancelled = false;\n\n    const fetchUrls = async () => {\n      try {\n        setFromUrl('');\n        setToUrl('');\n        setOriginTxUrl('');\n        setDestTxUrl('');\n        if (originTxHash) {\n          const txUrl = multiProvider.tryGetExplorerTxUrl(origin, { hash: originTxHash });\n          if (txUrl && !cancelled) setOriginTxUrl(fixDoubleSlash(txUrl));\n        }\n        if (destinationTxHash) {\n          const txUrl = multiProvider.tryGetExplorerTxUrl(destination, {\n            hash: destinationTxHash,\n          });\n          if (txUrl && !cancelled) setDestTxUrl(fixDoubleSlash(txUrl));\n        }\n        const [fetchedFromUrl, fetchedToUrl] = await Promise.all([\n          multiProvider.tryGetExplorerAddressUrl(origin, sender),\n          multiProvider.tryGetExplorerAddressUrl(destination, recipient),\n        ]);\n        if (cancelled) return;\n        if (fetchedFromUrl) setFromUrl(fixDoubleSlash(fetchedFromUrl));\n        if (fetchedToUrl) setToUrl(fixDoubleSlash(fetchedToUrl));\n      } catch (error) {\n        logger.error('Error fetching URLs:', error);\n      }\n    };\n\n    fetchUrls();\n    return () => {\n      cancelled = true;\n    };\n  }, [\n    transfer,\n    multiProvider,\n    origin,\n    destination,\n    originTxHash,\n    destinationTxHash,\n    sender,\n    recipient,\n  ]);\n\n  return (\n    <Modal isOpen={isOpen} close={onClose} panelClassname=\"transfer-details-modal max-w-sm\">\n      <ModalHeader className=\"h-8 shadow-accent-glow\" />\n      <div className=\"p-4\">\n        {isFinal && (\n          <div className=\"flex justify-between\">\n            <h2 className=\"text-xs font-normal text-gray-900\">{date}</h2>\n            <div className=\"flex items-center text-xs font-normal\">\n              {isSent ? (\n                <h3 className=\"text-green-50\">{isDelivered ? 'Delivered' : 'Sent'}</h3>\n              ) : (\n                <h3 className=\"text-red-500\">Failed</h3>\n              )}\n              <Image\n                src={getIconByTransferStatus(currentStatus)}\n                width={16}\n                height={16}\n                alt=\"\"\n                className=\"ml-2\"\n              />\n            </div>\n          </div>\n        )}\n\n        <div>\n          <div className=\"mt-4 flex w-full items-center justify-center rounded-sm border border-gray-400/25 bg-card-gradient py-2 shadow-card\">\n            <div className=\"flex items-center font-secondary text-sm font-normal\">\n              <span>{amount}</span>\n              <span className=\"ml-1\">{token?.symbol || ''}</span>\n              {destToken && (\n                <>\n                  <Image className=\"mx-2\" src={ArrowRightIcon} width={10} height={10} alt=\"\" />\n                  <span>{destAmount || amount}</span>\n                  <span className=\"ml-1\">{destToken.symbol}</span>\n                </>\n              )}\n            </div>\n          </div>\n\n          <div className=\"-mt-2 grid grid-cols-[1fr_auto_1fr] items-center rounded-sm border border-gray-400/25 bg-card-gradient py-5 shadow-card\">\n            <div className=\"flex flex-col items-center\">\n              {token ? (\n                <TokenChainIcon token={token} size={36} />\n              ) : (\n                <ChainLogo chainName={origin} size={36} />\n              )}\n              <span className=\"mt-1 text-xs font-medium tracking-wider\">{token?.symbol || ''}</span>\n              <span className=\"text-xxs font-normal tracking-wider text-gray-500\">\n                {getChainDisplayName(multiProvider, origin, true)}\n              </span>\n            </div>\n            <div className=\"mb-6 flex justify-center sm:space-x-1.5\">\n              <WideChevron />\n              <WideChevron />\n            </div>\n            <div className=\"flex flex-col items-center\">\n              {destToken ? (\n                <TokenChainIcon token={destToken} size={36} />\n              ) : (\n                <ChainLogo chainName={destination} size={36} />\n              )}\n              <span className=\"mt-1 text-xs font-medium tracking-wider\">\n                {destToken?.symbol || ''}\n              </span>\n              <span className=\"text-xxs font-normal tracking-wider text-gray-500\">\n                {getChainDisplayName(multiProvider, destination, true)}\n              </span>\n            </div>\n          </div>\n        </div>\n\n        {showTimeline && (\n          <div className=\"mt-4 rounded border border-gray-400/25 bg-card-gradient p-3 shadow-card\">\n            <h4 className=\"mb-1 font-secondary text-sm text-gray-900\">Status</h4>\n            <div className=\"flex w-full flex-col items-center justify-center [&_h4]:text-[clamp(0.625rem,0.7rem,0.75rem)]\">\n              <MessageTimeline\n                status={messageStatus}\n                stage={stage}\n                timings={DEFAULT_TIMINGS}\n                timestampSent={delivery.originTimestamp}\n                hideDescriptions={true}\n                iconPosition=\"inline\"\n                barClassName=\"bg-accent-gradient\"\n              />\n            </div>\n            {showEta && etaSeconds && (\n              <p className=\"mt-2 text-center text-xs text-gray-500\">\n                Est. delivery: {formatEta(etaSeconds)}\n              </p>\n            )}\n          </div>\n        )}\n\n        {isFinal ? (\n          <div className=\"mt-5 flex flex-col space-y-4\">\n            <TransferProperty name=\"Sender Address\" value={sender} url={fromUrl} />\n            <TransferProperty name=\"Recipient Address\" value={recipient} url={toUrl} />\n            {originTxHash && (\n              <TransferProperty\n                name=\"Origin Transaction Hash\"\n                value={originTxHash}\n                url={originTxUrl}\n              />\n            )}\n            {destinationTxHash && (\n              <TransferProperty\n                name=\"Destination Transaction Hash\"\n                value={destinationTxHash}\n                url={destTxUrl}\n              />\n            )}\n            {msgId && <TransferProperty name=\"Message ID\" value={msgId} />}\n            {explorerLink && (\n              <div className=\"flex justify-center\">\n                <span className=\"text-xxs leading-normal tracking-wider text-primary-500\">\n                  <a\n                    className=\"text-xs leading-normal tracking-wider text-primary-500 underline-offset-2 hover:opacity-80 active:opacity-70\"\n                    href={explorerLink}\n                    target=\"_blank\"\n                    rel=\"noopener noreferrer\"\n                  >\n                    View in Explorer\n                  </a>\n                </span>\n              </div>\n            )}\n          </div>\n        ) : (\n          <div className=\"flex flex-col items-center justify-center py-4\">\n            <SpinnerIcon width={60} height={60} className=\"transfer-details-spinner mt-3\" />\n            <div className=\"mt-5 text-center text-sm text-gray-600\">{statusDescription}</div>\n            {showSignWarning && (\n              <div className=\"mt-3 text-center text-sm text-gray-600\">\n                If your wallet does not show a transaction request or never confirms, please try the\n                transfer again.\n              </div>\n            )}\n          </div>\n        )}\n      </div>\n    </Modal>\n  );\n}\n\nfunction TransferProperty({ name, value, url }: { name: string; value: string; url?: string }) {\n  return (\n    <div>\n      <div className=\"flex items-center justify-between\">\n        <label className=\"text-xs leading-normal tracking-wider text-gray-350\">{name}</label>\n        <div className=\"flex items-center space-x-2\">\n          {url && (\n            <a href={url} target=\"_blank\" rel=\"noopener noreferrer\">\n              <Image src={LinkIcon} width={14} height={14} alt=\"\" />\n            </a>\n          )}\n          <CopyButton copyValue={value} width={14} height={14} className=\"opacity-40\" />\n        </div>\n      </div>\n      <div className=\"mt-1 truncate text-xs leading-normal tracking-wider text-gray-900\">\n        {value}\n      </div>\n    </div>\n  );\n}\n\nfunction WideChevron() {\n  return (\n    <WideChevronIcon\n      width=\"16\"\n      height=\"100%\"\n      direction=\"e\"\n      color={Color.gray['300']}\n      rounded={true}\n    />\n  );\n}\n\n// https://github.com/wagmi-dev/wagmi/discussions/2928\nfunction useSignIssueWarning(status: TransferStatus) {\n  const [showWarning, setShowWarning] = useState(false);\n  const warningCallback = useCallback(() => {\n    if (status === TransferStatus.SigningTransfer || status === TransferStatus.ConfirmingTransfer)\n      setShowWarning(true);\n  }, [status, setShowWarning]);\n  useTimeout(warningCallback, 20_000);\n  return showWarning;\n}\n\n// TODO cosmos fix double slash problem in ChainMetadataManager\n// Occurs when baseUrl has not other path (e.g. for manta explorer)\nfunction fixDoubleSlash(url: string) {\n  return url.replace(/([^:]\\/)\\/+/g, '$1');\n}\n"
  },
  {
    "path": "src/features/transfer/fees.test.ts",
    "content": "import { Token, TokenAmount, WarpCore } from '@hyperlane-xyz/sdk';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport { createMockToken } from '../../utils/test';\nimport { TokensWithDestinationBalance, TokenWithFee } from '../tokens/types';\nimport * as tokenUtils from '../tokens/utils';\nimport {\n  compareByBalanceDesc,\n  filterAndSortTokensByBalance,\n  getTotalFee,\n  getTransferToken,\n  sortTokensByFee,\n} from './fees';\n\n// Common test constants\nconst MOCK_RECIPIENT = '0xrecipient';\nconst MOCK_SENDER = '0xsender';\nconst TRANSFER_AMOUNT = '500000';\nconst LARGE_TRANSFER_AMOUNT = '1000000';\n\n// Balance constants (collateral/token balances)\nconst BALANCE_TINY = BigInt(100);\nconst BALANCE_SMALL = BigInt(500);\nconst BALANCE_MEDIUM = BigInt(1000);\nconst BALANCE_LARGE = BigInt(1000000);\nconst BALANCE_XLARGE = BigInt(2000000);\nconst BALANCE_XXLARGE = BigInt(5000000);\n\n// Fee constants\nconst FEE_LOW = BigInt(1000);\nconst FEE_MEDIUM = BigInt(3000);\nconst FEE_HIGH = BigInt(5000);\n\nbeforeEach(() => {\n  vi.restoreAllMocks();\n});\n\ndescribe('getTotalFee', () => {\n  test('should group fungible tokens and sum their values', () => {\n    const token1 = createMockToken({ symbol: 'ETH', decimals: 18 });\n    const token2 = createMockToken({ symbol: 'ETH', decimals: 18 });\n\n    // Mock isFungibleWith to return true for same tokens\n    vi.spyOn(token1, 'isFungibleWith').mockReturnValue(true);\n\n    const interchainQuote = token1.amount('1000000000000000000');\n    const localQuote = token2.amount('500000000000000000');\n\n    const result = getTotalFee({ interchainQuote, localQuote });\n\n    expect(result).toHaveLength(1);\n    expect(result[0].token).toEqual(token1);\n    expect(result[0].amount).toEqual(BigInt('1500000000000000000'));\n  });\n\n  test('should separate non-fungible tokens with same symbol', () => {\n    const token1 = createMockToken({ symbol: 'ETH', decimals: 18, chainName: 'ethereum' });\n    const token2 = createMockToken({ symbol: 'ETH', decimals: 18, chainName: 'polygon' });\n\n    // Mock isFungibleWith to return false for different chain tokens\n    vi.spyOn(token1, 'isFungibleWith').mockReturnValue(false);\n\n    const interchainQuote = token1.amount('1000000000000000000');\n    const localQuote = token2.amount('500000000000000000');\n\n    const result = getTotalFee({ interchainQuote, localQuote });\n\n    // Now we can properly handle same symbols but non-fungible tokens\n    expect(result).toHaveLength(2);\n    expect(result[0].token).toEqual(token1);\n    expect(result[0].amount).toEqual(BigInt('1000000000000000000'));\n    expect(result[1].token).toEqual(token2);\n    expect(result[1].amount).toEqual(BigInt('500000000000000000'));\n  });\n\n  test('should handle three different tokens separately', () => {\n    const ethToken = createMockToken({ symbol: 'ETH', decimals: 18 });\n    const usdcToken = createMockToken({ symbol: 'USDC', decimals: 6 });\n    const wethToken = createMockToken({ symbol: 'WETH', decimals: 18 });\n\n    // Mock isFungibleWith to return false for all combinations\n    vi.spyOn(ethToken, 'isFungibleWith').mockReturnValue(false);\n    vi.spyOn(usdcToken, 'isFungibleWith').mockReturnValue(false);\n    vi.spyOn(wethToken, 'isFungibleWith').mockReturnValue(false);\n\n    const interchainQuote = ethToken.amount('1000000000000000000');\n    const localQuote = usdcToken.amount('1000000');\n    const tokenFeeQuote = wethToken.amount('2000000000000000000');\n\n    const result = getTotalFee({ interchainQuote, localQuote, tokenFeeQuote });\n\n    expect(result).toHaveLength(3);\n    expect(result[0].token).toEqual(ethToken);\n    expect(result[0].amount).toEqual(BigInt('1000000000000000000'));\n    expect(result[1].token).toEqual(usdcToken);\n    expect(result[1].amount).toEqual(BigInt('1000000'));\n    expect(result[2].token).toEqual(wethToken);\n    expect(result[2].amount).toEqual(BigInt('2000000000000000000'));\n  });\n\n  test('should handle partial fungibility - two fungible, one separate', () => {\n    const ethToken1 = createMockToken({ symbol: 'ETH', decimals: 18 });\n    const ethToken2 = createMockToken({ symbol: 'ETH', decimals: 18 });\n    const usdcToken = createMockToken({ symbol: 'USDC', decimals: 6 });\n\n    // Mock ETH tokens to be fungible with each other but not with USDC\n    vi.spyOn(ethToken1, 'isFungibleWith').mockImplementation(\n      (token) => token === ethToken2 || token === ethToken1,\n    );\n    vi.spyOn(ethToken2, 'isFungibleWith').mockImplementation(\n      (token) => token === ethToken1 || token === ethToken2,\n    );\n    vi.spyOn(usdcToken, 'isFungibleWith').mockReturnValue(false);\n\n    const interchainQuote = ethToken1.amount('1000000000000000000');\n    const localQuote = ethToken2.amount('500000000000000000');\n    const tokenFeeQuote = usdcToken.amount('1000000');\n\n    const result = getTotalFee({ interchainQuote, localQuote, tokenFeeQuote });\n\n    expect(result).toHaveLength(2);\n    expect(result[0].token).toEqual(ethToken1);\n    expect(result[0].amount).toEqual(BigInt('1500000000000000000'));\n    expect(result[1].token).toEqual(usdcToken);\n    expect(result[1].amount).toEqual(BigInt('1000000'));\n  });\n\n  test('should handle optional tokenFeeQuote being undefined', () => {\n    const ethToken = createMockToken({ symbol: 'ETH', decimals: 18 });\n    const usdcToken = createMockToken({ symbol: 'USDC', decimals: 6 });\n\n    vi.spyOn(ethToken, 'isFungibleWith').mockReturnValue(false);\n\n    const interchainQuote = ethToken.amount('1000000000000000000');\n    const localQuote = usdcToken.amount('1000000');\n\n    const result = getTotalFee({ interchainQuote, localQuote, tokenFeeQuote: undefined });\n\n    expect(result).toHaveLength(2);\n    expect(result[0].token).toEqual(ethToken);\n    expect(result[0].amount).toEqual(BigInt('1000000000000000000'));\n    expect(result[1].token).toEqual(usdcToken);\n    expect(result[1].amount).toEqual(BigInt('1000000'));\n  });\n\n  test('should handle zero amounts', () => {\n    const ethToken1 = createMockToken({ symbol: 'ETH', decimals: 18 });\n    const ethToken2 = createMockToken({ symbol: 'ETH', decimals: 18 });\n\n    vi.spyOn(ethToken1, 'isFungibleWith').mockReturnValue(true);\n\n    const interchainQuote = ethToken1.amount('0');\n    const localQuote = ethToken2.amount('1000000000000000000');\n\n    const result = getTotalFee({ interchainQuote, localQuote });\n\n    expect(result).toHaveLength(1);\n    expect(result[0].token).toEqual(ethToken1);\n    expect(result[0].amount).toEqual(BigInt('1000000000000000000'));\n  });\n\n  test('should handle large numbers correctly', () => {\n    const ethToken1 = createMockToken({ symbol: 'ETH', decimals: 18 });\n    const ethToken2 = createMockToken({ symbol: 'ETH', decimals: 18 });\n\n    vi.spyOn(ethToken1, 'isFungibleWith').mockReturnValue(true);\n\n    const largeAmount1 = '999999999999999999999999999';\n    const largeAmount2 = '1000000000000000000000000000';\n\n    const interchainQuote = ethToken1.amount(largeAmount1);\n    const localQuote = ethToken2.amount(largeAmount2);\n\n    const result = getTotalFee({ interchainQuote, localQuote });\n\n    expect(result).toHaveLength(1);\n    expect(result[0].token).toEqual(ethToken1);\n    expect(result[0].amount).toEqual(BigInt(largeAmount1) + BigInt(largeAmount2));\n  });\n\n  test('should handle tokenFeeQuote fungible with interchainQuote only', () => {\n    const ethToken = createMockToken({ symbol: 'ETH', decimals: 18 });\n    const usdcToken = createMockToken({ symbol: 'USDC', decimals: 6 });\n    const ethToken2 = createMockToken({ symbol: 'ETH', decimals: 18 });\n\n    vi.spyOn(ethToken, 'isFungibleWith').mockReturnValue(false);\n    vi.spyOn(usdcToken, 'isFungibleWith').mockReturnValue(false);\n    vi.spyOn(ethToken2, 'isFungibleWith').mockImplementation((token) => token === ethToken);\n\n    const interchainQuote = ethToken.amount('1000000000000000000');\n    const localQuote = usdcToken.amount('1000000');\n    const tokenFeeQuote = ethToken2.amount('2000000000000000000');\n\n    const result = getTotalFee({ interchainQuote, localQuote, tokenFeeQuote });\n\n    expect(result).toHaveLength(2);\n    expect(result[0].token).toEqual(ethToken);\n    expect(result[0].amount).toEqual(BigInt('3000000000000000000'));\n    expect(result[1].token).toEqual(usdcToken);\n    expect(result[1].amount).toEqual(BigInt('1000000'));\n  });\n\n  test('should handle tokenFeeQuote fungible with localQuote only', () => {\n    const ethToken = createMockToken({ symbol: 'ETH', decimals: 18 });\n    const usdcToken = createMockToken({ symbol: 'USDC', decimals: 6 });\n    const usdceToken = createMockToken({ symbol: 'USDC', decimals: 6 });\n\n    vi.spyOn(ethToken, 'isFungibleWith').mockReturnValue(false);\n    vi.spyOn(usdcToken, 'isFungibleWith').mockReturnValue(false);\n    vi.spyOn(usdceToken, 'isFungibleWith').mockImplementation((token) => token === usdcToken);\n\n    const interchainQuote = ethToken.amount('1000000000000000000');\n    const localQuote = usdcToken.amount('1000000');\n    const tokenFeeQuote = usdceToken.amount('2000000');\n\n    const result = getTotalFee({ interchainQuote, localQuote, tokenFeeQuote });\n\n    expect(result).toHaveLength(2);\n    expect(result[0].token).toEqual(ethToken);\n    expect(result[0].amount).toEqual(BigInt('1000000000000000000'));\n    expect(result[1].token).toEqual(usdcToken);\n    expect(result[1].amount).toEqual(BigInt('3000000'));\n  });\n\n  test('should handle tokenFeeQuote fungible with all other tokens', () => {\n    const token1 = createMockToken({ symbol: 'USDC', decimals: 6 });\n    const token2 = createMockToken({ symbol: 'USDC', decimals: 6 });\n    const token3 = createMockToken({ symbol: 'USDC', decimals: 6 });\n\n    vi.spyOn(token1, 'isFungibleWith').mockImplementation(\n      (token) => token === token2 || token === token3,\n    );\n    vi.spyOn(token2, 'isFungibleWith').mockImplementation(\n      (token) => token === token1 || token === token3,\n    );\n    vi.spyOn(token3, 'isFungibleWith').mockImplementation(\n      (token) => token === token1 || token === token2,\n    );\n\n    const interchainQuote = token1.amount('1000000');\n    const localQuote = token2.amount('2000000');\n    const tokenFeeQuote = token3.amount('3000000');\n\n    const result = getTotalFee({ interchainQuote, localQuote, tokenFeeQuote });\n\n    expect(result).toHaveLength(1);\n    expect(result[0].token).toEqual(token1);\n    expect(result[0].amount).toEqual(BigInt('6000000'));\n  });\n});\n\ndescribe('compareByBalanceDesc', () => {\n  test('should return -1 when first balance is greater', () => {\n    expect(compareByBalanceDesc({ balance: BALANCE_TINY }, { balance: BigInt(50) })).toBe(-1);\n  });\n\n  test('should return 1 when first balance is smaller', () => {\n    expect(compareByBalanceDesc({ balance: BigInt(50) }, { balance: BALANCE_TINY })).toBe(1);\n  });\n\n  test('should return 0 when balances are equal', () => {\n    expect(compareByBalanceDesc({ balance: BALANCE_TINY }, { balance: BALANCE_TINY })).toBe(0);\n  });\n\n  test('should handle very large bigints', () => {\n    const large1 = BigInt('999999999999999999999999999');\n    const large2 = BigInt('999999999999999999999999998');\n    expect(compareByBalanceDesc({ balance: large1 }, { balance: large2 })).toBe(-1);\n  });\n});\n\ndescribe('filterAndSortTokensByBalance', () => {\n  test('should filter out tokens with balance below minimum', () => {\n    const token1 = createMockToken({ symbol: 'TOKEN1' });\n    const token2 = createMockToken({ symbol: 'TOKEN2' });\n    const destToken1 = createMockToken({ symbol: 'TOKEN1', chainName: 'chain1' });\n    const destToken2 = createMockToken({ symbol: 'TOKEN2', chainName: 'chain2' });\n\n    const tokens: TokensWithDestinationBalance[] = [\n      { originToken: token1, destinationToken: destToken1, balance: BALANCE_TINY },\n      { originToken: token2, destinationToken: destToken2, balance: BALANCE_MEDIUM },\n    ];\n\n    const result = filterAndSortTokensByBalance(tokens, BALANCE_SMALL);\n\n    expect(result).toHaveLength(1);\n    expect(result[0].originToken).toBe(token2);\n  });\n\n  test('should sort tokens by balance in descending order', () => {\n    const token1 = createMockToken({ symbol: 'TOKEN1' });\n    const token2 = createMockToken({ symbol: 'TOKEN2' });\n    const token3 = createMockToken({ symbol: 'TOKEN3' });\n    const destToken1 = createMockToken({ symbol: 'TOKEN1', chainName: 'chain1' });\n    const destToken2 = createMockToken({ symbol: 'TOKEN2', chainName: 'chain2' });\n    const destToken3 = createMockToken({ symbol: 'TOKEN3', chainName: 'chain3' });\n\n    const tokens: TokensWithDestinationBalance[] = [\n      { originToken: token1, destinationToken: destToken1, balance: BALANCE_TINY },\n      { originToken: token2, destinationToken: destToken2, balance: BALANCE_SMALL },\n      { originToken: token3, destinationToken: destToken3, balance: BigInt(300) },\n    ];\n\n    const result = filterAndSortTokensByBalance(tokens, BigInt(50));\n\n    expect(result).toHaveLength(3);\n    // Should be sorted: token2 (500) > token3 (300) > token1 (100)\n    expect(result[0].originToken).toBe(token2);\n    expect(result[0].balance).toBe(BALANCE_SMALL);\n    expect(result[1].originToken).toBe(token3);\n    expect(result[1].balance).toBe(BigInt(300));\n    expect(result[2].originToken).toBe(token1);\n    expect(result[2].balance).toBe(BALANCE_TINY);\n  });\n\n  test('should return empty array when no tokens meet minimum balance', () => {\n    const token1 = createMockToken({ symbol: 'TOKEN1' });\n    const destToken1 = createMockToken({ symbol: 'TOKEN1', chainName: 'chain1' });\n\n    const tokens: TokensWithDestinationBalance[] = [\n      { originToken: token1, destinationToken: destToken1, balance: BALANCE_TINY },\n    ];\n\n    const result = filterAndSortTokensByBalance(tokens, BALANCE_SMALL);\n\n    expect(result).toHaveLength(0);\n  });\n});\n\ndescribe('sortTokensByFee', () => {\n  test('should return tokens with no fee before tokens with fee', () => {\n    const token1 = createMockToken({ symbol: 'TOKEN1' });\n    const token2 = createMockToken({ symbol: 'TOKEN2' });\n    const feeToken = createMockToken({ symbol: 'FEE' });\n\n    const tokenFees: TokenWithFee[] = [\n      { token: token1, tokenFee: new TokenAmount(FEE_LOW, feeToken), balance: BALANCE_TINY },\n      { token: token2, tokenFee: undefined, balance: BALANCE_TINY },\n    ];\n\n    const result = sortTokensByFee(tokenFees);\n\n    expect(result[0].token).toBe(token2);\n    expect(result[1].token).toBe(token1);\n  });\n\n  test('should sort tokens by fee amount (lowest first)', () => {\n    const token1 = createMockToken({ symbol: 'TOKEN1' });\n    const token2 = createMockToken({ symbol: 'TOKEN2' });\n    const token3 = createMockToken({ symbol: 'TOKEN3' });\n    const feeToken = createMockToken({ symbol: 'FEE' });\n\n    const tokenFees: TokenWithFee[] = [\n      { token: token1, tokenFee: new TokenAmount(FEE_HIGH, feeToken), balance: BALANCE_TINY },\n      { token: token2, tokenFee: new TokenAmount(FEE_LOW, feeToken), balance: BALANCE_TINY },\n      { token: token3, tokenFee: new TokenAmount(FEE_MEDIUM, feeToken), balance: BALANCE_TINY },\n    ];\n\n    const result = sortTokensByFee(tokenFees);\n\n    expect(result[0].token).toBe(token2);\n    expect(result[1].token).toBe(token3);\n    expect(result[2].token).toBe(token1);\n  });\n\n  test('should use balance as tiebreaker when fees are equal', () => {\n    const token1 = createMockToken({ symbol: 'TOKEN1' });\n    const token2 = createMockToken({ symbol: 'TOKEN2' });\n    const feeToken = createMockToken({ symbol: 'FEE' });\n\n    const tokenFees: TokenWithFee[] = [\n      { token: token1, tokenFee: new TokenAmount(FEE_LOW, feeToken), balance: BALANCE_TINY },\n      { token: token2, tokenFee: new TokenAmount(FEE_LOW, feeToken), balance: BALANCE_SMALL },\n    ];\n\n    const result = sortTokensByFee(tokenFees);\n\n    // Same fee, so token2 should come first (higher balance)\n    expect(result[0].token).toBe(token2);\n    expect(result[1].token).toBe(token1);\n  });\n\n  test('should use balance as tiebreaker when both have no fee', () => {\n    const token1 = createMockToken({ symbol: 'TOKEN1' });\n    const token2 = createMockToken({ symbol: 'TOKEN2' });\n\n    const tokenFees: TokenWithFee[] = [\n      { token: token1, tokenFee: undefined, balance: BALANCE_TINY },\n      { token: token2, tokenFee: undefined, balance: BALANCE_SMALL },\n    ];\n\n    const result = sortTokensByFee(tokenFees);\n\n    // Both no fee, so token2 should come first (higher balance)\n    expect(result[0].token).toBe(token2);\n    expect(result[1].token).toBe(token1);\n  });\n\n  test('should handle complex sorting with mixed fees and balances', () => {\n    const token1 = createMockToken({ symbol: 'TOKEN1' });\n    const token2 = createMockToken({ symbol: 'TOKEN2' });\n    const token3 = createMockToken({ symbol: 'TOKEN3' });\n    const token4 = createMockToken({ symbol: 'TOKEN4' });\n    const feeToken = createMockToken({ symbol: 'FEE' });\n\n    const tokenFees: TokenWithFee[] = [\n      { token: token1, tokenFee: new TokenAmount(FEE_LOW, feeToken), balance: BALANCE_TINY }, // low fee, low balance\n      { token: token2, tokenFee: undefined, balance: BigInt(200) }, // no fee, low balance\n      { token: token3, tokenFee: new TokenAmount(FEE_LOW, feeToken), balance: BALANCE_SMALL }, // low fee, high balance\n      { token: token4, tokenFee: undefined, balance: BigInt(800) }, // no fee, high balance\n    ];\n\n    const result = sortTokensByFee(tokenFees);\n\n    // Expected order:\n    // 1. token4 (no fee, high balance 800)\n    // 2. token2 (no fee, low balance 200)\n    // 3. token3 (fee 1000, high balance 500)\n    // 4. token1 (fee 1000, low balance 100)\n    expect(result[0].token).toBe(token4);\n    expect(result[1].token).toBe(token2);\n    expect(result[2].token).toBe(token3);\n    expect(result[3].token).toBe(token1);\n  });\n});\n\ndescribe('getTransferToken', () => {\n  const createMockWarpCore = (\n    overrides?: Partial<WarpCore>,\n    originToken?: ReturnType<typeof createMockToken>,\n  ) =>\n    ({\n      getTokenCollateral: vi.fn(),\n      getInterchainTransferFee: vi.fn(),\n      // Return the origin token from getTokensForRoute so findRouteToken can find a match\n      getTokensForRoute: vi.fn().mockReturnValue(originToken ? [originToken] : []),\n      ...overrides,\n    }) as unknown as WarpCore;\n\n  test('should return originToken if not a valid multi-collateral token', async () => {\n    const originToken = createMockToken();\n    const destinationToken = createMockToken();\n\n    vi.spyOn(tokenUtils, 'isValidMultiCollateralToken').mockReturnValue(false);\n\n    const result = await getTransferToken(\n      createMockWarpCore({}, originToken),\n      originToken,\n      destinationToken,\n      LARGE_TRANSFER_AMOUNT,\n      MOCK_RECIPIENT,\n      MOCK_SENDER,\n    );\n\n    expect(result).toBe(originToken);\n  });\n\n  test('should return originToken if only one token exists with same collateral', async () => {\n    const originToken = createMockToken();\n    const destinationToken = createMockToken();\n\n    vi.spyOn(tokenUtils, 'isValidMultiCollateralToken').mockReturnValue(true);\n    vi.spyOn(tokenUtils, 'getTokensWithSameCollateralAddresses').mockReturnValue([\n      { originToken, destinationToken },\n    ]);\n\n    const result = await getTransferToken(\n      createMockWarpCore({}, originToken),\n      originToken,\n      destinationToken,\n      LARGE_TRANSFER_AMOUNT,\n      MOCK_RECIPIENT,\n      MOCK_SENDER,\n    );\n\n    expect(result).toBe(originToken);\n  });\n\n  test('should return the matched collateral route token when only one candidate exists', async () => {\n    const originToken = createMockToken({ symbol: 'PYUSD', chainName: 'arbitrum' });\n    const routeToken = createMockToken({ symbol: 'PYUSD', chainName: 'arbitrum' });\n    const destinationToken = createMockToken({ symbol: 'USDC', chainName: 'base' });\n    const matchedRouteToken = createMockToken({ symbol: 'PYUSD', chainName: 'arbitrum' });\n\n    vi.spyOn(tokenUtils, 'findRouteToken').mockReturnValue(routeToken);\n    vi.spyOn(tokenUtils, 'isValidMultiCollateralToken').mockReturnValue(true);\n    vi.spyOn(tokenUtils, 'getTokensWithSameCollateralAddresses').mockReturnValue([\n      { originToken: matchedRouteToken, destinationToken },\n    ]);\n\n    const result = await getTransferToken(\n      createMockWarpCore({}, originToken),\n      originToken,\n      destinationToken,\n      LARGE_TRANSFER_AMOUNT,\n      MOCK_RECIPIENT,\n      MOCK_SENDER,\n    );\n\n    expect(result).toBe(matchedRouteToken);\n  });\n\n  test('should return originToken if no tokens have sufficient collateral balance', async () => {\n    const originToken = createMockToken({ symbol: 'TOKEN1' });\n    const destinationToken = createMockToken({ symbol: 'TOKEN1' });\n    const originToken2 = createMockToken({ symbol: 'TOKEN2' });\n    const destinationToken2 = createMockToken({ symbol: 'TOKEN2' });\n\n    vi.spyOn(tokenUtils, 'isValidMultiCollateralToken').mockReturnValue(true);\n    vi.spyOn(tokenUtils, 'getTokensWithSameCollateralAddresses').mockReturnValue([\n      { originToken, destinationToken },\n      { originToken: originToken2, destinationToken: destinationToken2 },\n    ]);\n\n    const warpCore = createMockWarpCore(\n      {\n        getTokenCollateral: vi.fn().mockResolvedValue(BALANCE_TINY),\n      },\n      originToken,\n    );\n\n    const result = await getTransferToken(\n      warpCore,\n      originToken,\n      destinationToken,\n      LARGE_TRANSFER_AMOUNT,\n      MOCK_RECIPIENT,\n      MOCK_SENDER,\n    );\n\n    expect(result).toBe(originToken);\n  });\n\n  test('should return first token with enough collateral if fee fetching fails for all', async () => {\n    const originToken = createMockToken({ symbol: 'TOKEN1' });\n    const destinationToken = createMockToken({ symbol: 'TOKEN1', chainName: 'chain1' });\n    const originToken2 = createMockToken({ symbol: 'TOKEN2' });\n    const destinationToken2 = createMockToken({ symbol: 'TOKEN2', chainName: 'chain2' });\n\n    vi.spyOn(tokenUtils, 'isValidMultiCollateralToken').mockReturnValue(true);\n    vi.spyOn(tokenUtils, 'getTokensWithSameCollateralAddresses').mockReturnValue([\n      { originToken, destinationToken },\n      { originToken: originToken2, destinationToken: destinationToken2 },\n    ]);\n\n    const warpCore = createMockWarpCore(\n      {\n        getTokenCollateral: vi\n          .fn()\n          .mockResolvedValueOnce(BALANCE_LARGE)\n          .mockResolvedValueOnce(BALANCE_XLARGE),\n        getInterchainTransferFee: vi.fn().mockRejectedValue(new Error('Fee fetch failed')),\n      },\n      originToken,\n    );\n\n    const result = await getTransferToken(\n      warpCore,\n      originToken,\n      destinationToken,\n      TRANSFER_AMOUNT,\n      MOCK_RECIPIENT,\n      MOCK_SENDER,\n    );\n\n    // Should return the token with highest collateral balance\n    expect(result).toBe(originToken2);\n  });\n\n  test('should return token with lowest fee when multiple routes available', async () => {\n    const originToken = createMockToken({ symbol: 'TOKEN1' });\n    const destinationToken = createMockToken({ symbol: 'TOKEN1', chainName: 'chain1' });\n    const originToken2 = createMockToken({ symbol: 'TOKEN2' });\n    const destinationToken2 = createMockToken({ symbol: 'TOKEN2', chainName: 'chain2' });\n\n    vi.spyOn(tokenUtils, 'isValidMultiCollateralToken').mockReturnValue(true);\n    vi.spyOn(tokenUtils, 'getTokensWithSameCollateralAddresses').mockReturnValue([\n      { originToken, destinationToken },\n      { originToken: originToken2, destinationToken: destinationToken2 },\n    ]);\n\n    const feeToken = createMockToken({ symbol: 'FEE' });\n\n    const warpCore = createMockWarpCore(\n      {\n        getTokenCollateral: vi.fn().mockResolvedValue(BALANCE_XLARGE),\n        getInterchainTransferFee: vi\n          .fn()\n          .mockResolvedValueOnce({ tokenFeeQuote: new TokenAmount(FEE_HIGH, feeToken) })\n          .mockResolvedValueOnce({ tokenFeeQuote: new TokenAmount(FEE_LOW, feeToken) }),\n      },\n      originToken,\n    );\n\n    const result = await getTransferToken(\n      warpCore,\n      originToken,\n      destinationToken,\n      TRANSFER_AMOUNT,\n      MOCK_RECIPIENT,\n      MOCK_SENDER,\n    );\n\n    expect(result).toBe(originToken2);\n  });\n\n  test('should prefer route with no fee over route with fee', async () => {\n    const originToken = createMockToken({ symbol: 'TOKEN1' });\n    const destinationToken = createMockToken({ symbol: 'TOKEN1', chainName: 'chain1' });\n    const originToken2 = createMockToken({ symbol: 'TOKEN2' });\n    const destinationToken2 = createMockToken({ symbol: 'TOKEN2', chainName: 'chain2' });\n\n    vi.spyOn(tokenUtils, 'isValidMultiCollateralToken').mockReturnValue(true);\n    vi.spyOn(tokenUtils, 'getTokensWithSameCollateralAddresses').mockReturnValue([\n      { originToken, destinationToken },\n      { originToken: originToken2, destinationToken: destinationToken2 },\n    ]);\n\n    const feeToken = createMockToken({ symbol: 'FEE' });\n\n    const warpCore = createMockWarpCore(\n      {\n        getTokenCollateral: vi.fn().mockResolvedValue(BALANCE_XLARGE),\n        getInterchainTransferFee: vi\n          .fn()\n          .mockResolvedValueOnce({ tokenFeeQuote: new TokenAmount(FEE_LOW, feeToken) })\n          .mockResolvedValueOnce({ tokenFeeQuote: undefined }),\n      },\n      originToken,\n    );\n\n    const result = await getTransferToken(\n      warpCore,\n      originToken,\n      destinationToken,\n      TRANSFER_AMOUNT,\n      MOCK_RECIPIENT,\n      MOCK_SENDER,\n    );\n\n    expect(result).toBe(originToken2);\n  });\n\n  test('should handle collateral fetch failure gracefully', async () => {\n    const originToken = createMockToken({ symbol: 'TOKEN1' });\n    const destinationToken = createMockToken({ symbol: 'TOKEN1', chainName: 'chain1' });\n    const originToken2 = createMockToken({ symbol: 'TOKEN2' });\n    const destinationToken2 = createMockToken({ symbol: 'TOKEN2', chainName: 'chain2' });\n\n    vi.spyOn(tokenUtils, 'isValidMultiCollateralToken').mockReturnValue(true);\n    vi.spyOn(tokenUtils, 'getTokensWithSameCollateralAddresses').mockReturnValue([\n      { originToken, destinationToken },\n      { originToken: originToken2, destinationToken: destinationToken2 },\n    ]);\n\n    const feeToken = createMockToken({ symbol: 'FEE' });\n\n    const warpCore = createMockWarpCore(\n      {\n        getTokenCollateral: vi\n          .fn()\n          .mockRejectedValueOnce(new Error('Failed to fetch collateral'))\n          .mockResolvedValueOnce(BALANCE_XXLARGE),\n        getInterchainTransferFee: vi.fn().mockResolvedValue({\n          tokenFeeQuote: new TokenAmount(FEE_LOW, feeToken),\n        }),\n      },\n      originToken,\n    );\n\n    const result = await getTransferToken(\n      warpCore,\n      originToken,\n      destinationToken,\n      TRANSFER_AMOUNT,\n      MOCK_RECIPIENT,\n      MOCK_SENDER,\n    );\n\n    expect(result).toBe(originToken2);\n  });\n\n  test('should handle fee fetch failure for some routes gracefully', async () => {\n    const originToken = createMockToken({ symbol: 'TOKEN1' });\n    const destinationToken = createMockToken({ symbol: 'TOKEN1', chainName: 'chain1' });\n    const originToken2 = createMockToken({ symbol: 'TOKEN2' });\n    const destinationToken2 = createMockToken({ symbol: 'TOKEN2', chainName: 'chain2' });\n\n    vi.spyOn(tokenUtils, 'isValidMultiCollateralToken').mockReturnValue(true);\n    vi.spyOn(tokenUtils, 'getTokensWithSameCollateralAddresses').mockReturnValue([\n      { originToken, destinationToken },\n      { originToken: originToken2, destinationToken: destinationToken2 },\n    ]);\n\n    const feeToken = createMockToken({ symbol: 'FEE' });\n\n    const warpCore = createMockWarpCore(\n      {\n        getTokenCollateral: vi.fn().mockResolvedValue(BALANCE_XXLARGE),\n        getInterchainTransferFee: vi\n          .fn()\n          .mockRejectedValueOnce(new Error('Fee fetch failed'))\n          .mockResolvedValueOnce({ tokenFeeQuote: new TokenAmount(FEE_LOW, feeToken) }),\n      },\n      originToken,\n    );\n\n    const result = await getTransferToken(\n      warpCore,\n      originToken,\n      destinationToken,\n      TRANSFER_AMOUNT,\n      MOCK_RECIPIENT,\n      MOCK_SENDER,\n    );\n\n    expect(result).toBe(originToken2);\n  });\n\n  test('should return default token when configured in defaultMultiCollateralRoutes', async () => {\n    const originToken = createMockToken({\n      symbol: 'USDC',\n      chainName: 'ethereum',\n      collateralAddressOrDenom: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',\n    });\n    const destinationToken = createMockToken({\n      symbol: 'USDC',\n      chainName: 'arbitrum',\n      collateralAddressOrDenom: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831',\n    });\n    const defaultOriginToken = createMockToken({\n      symbol: 'USDC',\n      addressOrDenom: '0xe1De9910fe71cC216490AC7FCF019e13a34481D7',\n      chainName: 'ethereum',\n      collateralAddressOrDenom: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',\n    });\n    const defaultDestToken = createMockToken({\n      symbol: 'USDC',\n      addressOrDenom: '0xAd4350Ee0f9f5b85BaB115425426086Ae8384ebb',\n      chainName: 'arbitrum',\n      collateralAddressOrDenom: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831',\n    });\n\n    vi.spyOn(tokenUtils, 'isValidMultiCollateralToken').mockReturnValue(true);\n    vi.spyOn(tokenUtils, 'getTokensWithSameCollateralAddresses').mockReturnValue([\n      { originToken, destinationToken },\n      { originToken: defaultOriginToken, destinationToken: defaultDestToken },\n    ]);\n\n    const defaultRoutes = {\n      ethereum: {\n        '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': '0xe1De9910fe71cC216490AC7FCF019e13a34481D7',\n      },\n      arbitrum: {\n        '0xaf88d065e77c8cC2239327C5EDb3A432268e5831': '0xAd4350Ee0f9f5b85BaB115425426086Ae8384ebb',\n      },\n    };\n\n    const warpCore = createMockWarpCore(\n      {\n        // These should NOT be called because default route bypasses fee lookup\n        getTokenCollateral: vi.fn(),\n        getInterchainTransferFee: vi.fn(),\n      },\n      originToken,\n    );\n\n    const result = await getTransferToken(\n      warpCore,\n      originToken,\n      destinationToken,\n      TRANSFER_AMOUNT,\n      MOCK_RECIPIENT,\n      MOCK_SENDER,\n      defaultRoutes,\n    );\n\n    expect(result).toBe(defaultOriginToken);\n    // Verify fee lookup was not called (bypassed)\n    expect(warpCore.getTokenCollateral).not.toHaveBeenCalled();\n    expect(warpCore.getInterchainTransferFee).not.toHaveBeenCalled();\n  });\n\n  // --- Multi-candidate (3+ tokens) branch coverage ---\n\n  test('should pick lowest fee among 4 candidates with enough balance', async () => {\n    const [t1, t2, t3, t4] = [1, 2, 3, 4].map((n) =>\n      createMockToken({ symbol: `T${n}`, chainName: `chain${n}` }),\n    );\n    const [d1, d2, d3, d4] = [1, 2, 3, 4].map((n) =>\n      createMockToken({ symbol: `T${n}`, chainName: `dest${n}` }),\n    );\n\n    vi.spyOn(tokenUtils, 'isValidMultiCollateralToken').mockReturnValue(true);\n    vi.spyOn(tokenUtils, 'getTokensWithSameCollateralAddresses').mockReturnValue([\n      { originToken: t1, destinationToken: d1 },\n      { originToken: t2, destinationToken: d2 },\n      { originToken: t3, destinationToken: d3 },\n      { originToken: t4, destinationToken: d4 },\n    ]);\n\n    const feeToken = createMockToken({ symbol: 'FEE' });\n    // Key mocks by token identity so tests are resilient to production-side iteration order changes\n    const feeByOrigin = new Map<Token, bigint>([\n      [t1, FEE_HIGH],\n      [t2, FEE_MEDIUM],\n      [t3, FEE_LOW],\n      [t4, FEE_MEDIUM],\n    ]);\n    const warpCore = createMockWarpCore(\n      {\n        getTokenCollateral: vi.fn().mockResolvedValue(BALANCE_XXLARGE),\n        getInterchainTransferFee: vi.fn(async ({ originTokenAmount }) => ({\n          tokenFeeQuote: new TokenAmount(feeByOrigin.get(originTokenAmount.token)!, feeToken),\n        })),\n      },\n      t1,\n    );\n\n    const result = await getTransferToken(\n      warpCore,\n      t1,\n      d1,\n      TRANSFER_AMOUNT,\n      MOCK_RECIPIENT,\n      MOCK_SENDER,\n    );\n\n    expect(result).toBe(t3); // lowest fee\n  });\n\n  test('should exclude candidates with insufficient balance before fee comparison (3 tokens)', async () => {\n    // t1: not enough balance (filtered out)\n    // t2: enough balance, medium fee\n    // t3: enough balance, low fee (winner)\n    const [t1, t2, t3] = [1, 2, 3].map((n) =>\n      createMockToken({ symbol: `T${n}`, chainName: `chain${n}` }),\n    );\n    const [d1, d2, d3] = [1, 2, 3].map((n) =>\n      createMockToken({ symbol: `T${n}`, chainName: `dest${n}` }),\n    );\n\n    vi.spyOn(tokenUtils, 'isValidMultiCollateralToken').mockReturnValue(true);\n    vi.spyOn(tokenUtils, 'getTokensWithSameCollateralAddresses').mockReturnValue([\n      { originToken: t1, destinationToken: d1 },\n      { originToken: t2, destinationToken: d2 },\n      { originToken: t3, destinationToken: d3 },\n    ]);\n\n    const feeToken = createMockToken({ symbol: 'FEE' });\n    const balanceByDest = new Map<Token, bigint>([\n      [d1, BALANCE_TINY],\n      [d2, BALANCE_XLARGE],\n      [d3, BALANCE_XLARGE],\n    ]);\n    const feeByOrigin = new Map<Token, bigint>([\n      [t2, FEE_MEDIUM],\n      [t3, FEE_LOW],\n    ]);\n    const warpCore = createMockWarpCore(\n      {\n        getTokenCollateral: vi.fn(async (token) => balanceByDest.get(token) ?? 0n),\n        // Only t2 + t3 reach fee fetch (t1 filtered by balance)\n        getInterchainTransferFee: vi.fn(async ({ originTokenAmount }) => ({\n          tokenFeeQuote: new TokenAmount(feeByOrigin.get(originTokenAmount.token)!, feeToken),\n        })),\n      },\n      t1,\n    );\n\n    const result = await getTransferToken(\n      warpCore,\n      t1,\n      d1,\n      TRANSFER_AMOUNT,\n      MOCK_RECIPIENT,\n      MOCK_SENDER,\n    );\n\n    expect(result).toBe(t3);\n    // getInterchainTransferFee should only be called for the 2 candidates that passed balance\n    expect(warpCore.getInterchainTransferFee).toHaveBeenCalledTimes(2);\n  });\n\n  test('should return originRouteToken when NO candidates have enough balance (3 tokens)', async () => {\n    const [t1, t2, t3] = [1, 2, 3].map((n) =>\n      createMockToken({ symbol: `T${n}`, chainName: `chain${n}` }),\n    );\n    const [d1, d2, d3] = [1, 2, 3].map((n) =>\n      createMockToken({ symbol: `T${n}`, chainName: `dest${n}` }),\n    );\n\n    vi.spyOn(tokenUtils, 'isValidMultiCollateralToken').mockReturnValue(true);\n    vi.spyOn(tokenUtils, 'getTokensWithSameCollateralAddresses').mockReturnValue([\n      { originToken: t1, destinationToken: d1 },\n      { originToken: t2, destinationToken: d2 },\n      { originToken: t3, destinationToken: d3 },\n    ]);\n\n    const warpCore = createMockWarpCore(\n      {\n        getTokenCollateral: vi.fn().mockResolvedValue(BALANCE_TINY), // all below LARGE_TRANSFER_AMOUNT\n        getInterchainTransferFee: vi.fn(),\n      },\n      t1,\n    );\n\n    const result = await getTransferToken(\n      warpCore,\n      t1,\n      d1,\n      LARGE_TRANSFER_AMOUNT,\n      MOCK_RECIPIENT,\n      MOCK_SENDER,\n    );\n\n    expect(result).toBe(t1); // falls back to originRouteToken\n    expect(warpCore.getInterchainTransferFee).not.toHaveBeenCalled();\n  });\n\n  test('should return highest-balance candidate when ALL fee fetches fail (3 tokens)', async () => {\n    const [t1, t2, t3] = [1, 2, 3].map((n) =>\n      createMockToken({ symbol: `T${n}`, chainName: `chain${n}` }),\n    );\n    const [d1, d2, d3] = [1, 2, 3].map((n) =>\n      createMockToken({ symbol: `T${n}`, chainName: `dest${n}` }),\n    );\n\n    vi.spyOn(tokenUtils, 'isValidMultiCollateralToken').mockReturnValue(true);\n    vi.spyOn(tokenUtils, 'getTokensWithSameCollateralAddresses').mockReturnValue([\n      { originToken: t1, destinationToken: d1 },\n      { originToken: t2, destinationToken: d2 },\n      { originToken: t3, destinationToken: d3 },\n    ]);\n\n    const balanceByDest = new Map<Token, bigint>([\n      [d1, BALANCE_LARGE],\n      [d2, BALANCE_XLARGE],\n      [d3, BALANCE_XXLARGE], // highest\n    ]);\n    const warpCore = createMockWarpCore(\n      {\n        getTokenCollateral: vi.fn(async (token) => balanceByDest.get(token) ?? 0n),\n        getInterchainTransferFee: vi.fn().mockRejectedValue(new Error('fee fetch failed')),\n      },\n      t1,\n    );\n\n    const result = await getTransferToken(\n      warpCore,\n      t1,\n      d1,\n      TRANSFER_AMOUNT,\n      MOCK_RECIPIENT,\n      MOCK_SENDER,\n    );\n\n    // With tokenFees empty, returns tokenBalances[0].originToken (highest balance)\n    expect(result).toBe(t3);\n  });\n\n  test('should prefer zero-fee route over cheap route among 4 candidates', async () => {\n    const [t1, t2, t3, t4] = [1, 2, 3, 4].map((n) =>\n      createMockToken({ symbol: `T${n}`, chainName: `chain${n}` }),\n    );\n    const [d1, d2, d3, d4] = [1, 2, 3, 4].map((n) =>\n      createMockToken({ symbol: `T${n}`, chainName: `dest${n}` }),\n    );\n\n    vi.spyOn(tokenUtils, 'isValidMultiCollateralToken').mockReturnValue(true);\n    vi.spyOn(tokenUtils, 'getTokensWithSameCollateralAddresses').mockReturnValue([\n      { originToken: t1, destinationToken: d1 },\n      { originToken: t2, destinationToken: d2 },\n      { originToken: t3, destinationToken: d3 },\n      { originToken: t4, destinationToken: d4 },\n    ]);\n\n    const feeToken = createMockToken({ symbol: 'FEE' });\n    // t3 → undefined fee (should win); others → concrete fees\n    const feeByOrigin = new Map<Token, bigint | undefined>([\n      [t1, FEE_HIGH],\n      [t2, FEE_LOW],\n      [t3, undefined],\n      [t4, FEE_MEDIUM],\n    ]);\n    const warpCore = createMockWarpCore(\n      {\n        getTokenCollateral: vi.fn().mockResolvedValue(BALANCE_XXLARGE),\n        getInterchainTransferFee: vi.fn(async ({ originTokenAmount }) => {\n          const fee = feeByOrigin.get(originTokenAmount.token);\n          return {\n            tokenFeeQuote: fee != null ? new TokenAmount(fee, feeToken) : undefined,\n          };\n        }),\n      },\n      t1,\n    );\n\n    const result = await getTransferToken(\n      warpCore,\n      t1,\n      d1,\n      TRANSFER_AMOUNT,\n      MOCK_RECIPIENT,\n      MOCK_SENDER,\n    );\n\n    expect(result).toBe(t3);\n  });\n\n  test('should pick default route among 4 candidates (bypasses fee lookup)', async () => {\n    // Common collateral, 4 routes on ethereum → arbitrum, default is t3\n    const ORIGIN_COLLATERAL = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';\n    const DEST_COLLATERAL = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831';\n    const [WARP_1, WARP_2, WARP_3, WARP_4] = [1, 2, 3, 4].map((n) =>\n      `0x${String(n).repeat(40)}`.slice(0, 42),\n    );\n    const [DEST_1, DEST_2, DEST_3, DEST_4] = ['a', 'b', 'c', 'd'].map((c) =>\n      `0x${c.repeat(40)}`.slice(0, 42),\n    );\n\n    const make = (address: string, chainName: string, collateral: string) =>\n      createMockToken({\n        symbol: 'USDC',\n        addressOrDenom: address,\n        chainName,\n        collateralAddressOrDenom: collateral,\n      });\n\n    const t1 = make(WARP_1, 'ethereum', ORIGIN_COLLATERAL);\n    const t2 = make(WARP_2, 'ethereum', ORIGIN_COLLATERAL);\n    const t3 = make(WARP_3, 'ethereum', ORIGIN_COLLATERAL); // default\n    const t4 = make(WARP_4, 'ethereum', ORIGIN_COLLATERAL);\n    const d1 = make(DEST_1, 'arbitrum', DEST_COLLATERAL);\n    const d2 = make(DEST_2, 'arbitrum', DEST_COLLATERAL);\n    const d3 = make(DEST_3, 'arbitrum', DEST_COLLATERAL);\n    const d4 = make(DEST_4, 'arbitrum', DEST_COLLATERAL);\n\n    vi.spyOn(tokenUtils, 'isValidMultiCollateralToken').mockReturnValue(true);\n    vi.spyOn(tokenUtils, 'getTokensWithSameCollateralAddresses').mockReturnValue([\n      { originToken: t1, destinationToken: d1 },\n      { originToken: t2, destinationToken: d2 },\n      { originToken: t3, destinationToken: d3 },\n      { originToken: t4, destinationToken: d4 },\n    ]);\n\n    const defaultRoutes = {\n      ethereum: { [ORIGIN_COLLATERAL]: WARP_3 },\n      arbitrum: { [DEST_COLLATERAL]: DEST_3 },\n    };\n\n    const warpCore = createMockWarpCore(\n      {\n        getTokenCollateral: vi.fn(),\n        getInterchainTransferFee: vi.fn(),\n      },\n      t1,\n    );\n\n    const result = await getTransferToken(\n      warpCore,\n      t1,\n      d1,\n      TRANSFER_AMOUNT,\n      MOCK_RECIPIENT,\n      MOCK_SENDER,\n      defaultRoutes,\n    );\n\n    expect(result).toBe(t3);\n    // Default bypasses both fee + balance lookup\n    expect(warpCore.getTokenCollateral).not.toHaveBeenCalled();\n    expect(warpCore.getInterchainTransferFee).not.toHaveBeenCalled();\n  });\n\n  test('should fall back to fee-based winner among 3 candidates when default not matched', async () => {\n    // Default configured but points to an address not in tokensWithSameCollateralAddresses\n    const ORIGIN_COLLATERAL = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';\n    const DEST_COLLATERAL = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831';\n\n    const make = (address: string, chainName: string) =>\n      createMockToken({\n        symbol: 'USDC',\n        addressOrDenom: address,\n        chainName,\n        collateralAddressOrDenom: chainName === 'ethereum' ? ORIGIN_COLLATERAL : DEST_COLLATERAL,\n      });\n\n    const t1 = make('0x1111111111111111111111111111111111111111', 'ethereum');\n    const t2 = make('0x2222222222222222222222222222222222222222', 'ethereum');\n    const t3 = make('0x3333333333333333333333333333333333333333', 'ethereum');\n    const d1 = make('0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'arbitrum');\n    const d2 = make('0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'arbitrum');\n    const d3 = make('0xcccccccccccccccccccccccccccccccccccccccc', 'arbitrum');\n\n    vi.spyOn(tokenUtils, 'isValidMultiCollateralToken').mockReturnValue(true);\n    vi.spyOn(tokenUtils, 'getTokensWithSameCollateralAddresses').mockReturnValue([\n      { originToken: t1, destinationToken: d1 },\n      { originToken: t2, destinationToken: d2 },\n      { originToken: t3, destinationToken: d3 },\n    ]);\n\n    const defaultRoutes = {\n      ethereum: { [ORIGIN_COLLATERAL]: '0xNonExistent' },\n      arbitrum: { [DEST_COLLATERAL]: '0xNonExistentDest' },\n    };\n\n    const feeToken = createMockToken({ symbol: 'FEE' });\n    const feeByOrigin = new Map<Token, bigint>([\n      [t1, FEE_HIGH],\n      [t2, FEE_LOW], // winner\n      [t3, FEE_MEDIUM],\n    ]);\n    const warpCore = createMockWarpCore(\n      {\n        getTokenCollateral: vi.fn().mockResolvedValue(BALANCE_XXLARGE),\n        getInterchainTransferFee: vi.fn(async ({ originTokenAmount }) => ({\n          tokenFeeQuote: new TokenAmount(feeByOrigin.get(originTokenAmount.token)!, feeToken),\n        })),\n      },\n      t1,\n    );\n\n    const result = await getTransferToken(\n      warpCore,\n      t1,\n      d1,\n      TRANSFER_AMOUNT,\n      MOCK_RECIPIENT,\n      MOCK_SENDER,\n      defaultRoutes,\n    );\n\n    expect(result).toBe(t2);\n  });\n\n  test('should fall back to fee-based selection when default token not found in same collateral addresses', async () => {\n    const originToken = createMockToken({\n      symbol: 'USDC',\n      chainName: 'ethereum',\n      collateralAddressOrDenom: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',\n    });\n    const destinationToken = createMockToken({\n      symbol: 'USDC',\n      chainName: 'arbitrum',\n      collateralAddressOrDenom: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831',\n    });\n    const originToken2 = createMockToken({\n      symbol: 'USDC',\n      addressOrDenom: '0xDifferentWarpRoute',\n      chainName: 'ethereum',\n      collateralAddressOrDenom: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',\n    });\n    const destinationToken2 = createMockToken({\n      symbol: 'USDC',\n      addressOrDenom: '0xDifferentDestWarpRoute',\n      chainName: 'arbitrum',\n      collateralAddressOrDenom: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831',\n    });\n\n    vi.spyOn(tokenUtils, 'isValidMultiCollateralToken').mockReturnValue(true);\n    // tokensWithSameCollateralAddresses does NOT include the default token addressOrDenom\n    vi.spyOn(tokenUtils, 'getTokensWithSameCollateralAddresses').mockReturnValue([\n      { originToken, destinationToken },\n      { originToken: originToken2, destinationToken: destinationToken2 },\n    ]);\n\n    // Default routes are configured but point to addresses not in tokensWithSameCollateralAddresses\n    const defaultRoutes = {\n      ethereum: {\n        '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': '0xNonExistentWarpRoute',\n      },\n      arbitrum: {\n        '0xaf88d065e77c8cC2239327C5EDb3A432268e5831': '0xNonExistentDestWarpRoute',\n      },\n    };\n\n    const feeToken = createMockToken({ symbol: 'FEE' });\n\n    const warpCore = createMockWarpCore(\n      {\n        getTokenCollateral: vi.fn().mockResolvedValue(BALANCE_XLARGE),\n        getInterchainTransferFee: vi\n          .fn()\n          .mockResolvedValueOnce({ tokenFeeQuote: new TokenAmount(FEE_HIGH, feeToken) })\n          .mockResolvedValueOnce({ tokenFeeQuote: new TokenAmount(FEE_LOW, feeToken) }),\n      },\n      originToken,\n    );\n\n    const result = await getTransferToken(\n      warpCore,\n      originToken,\n      destinationToken,\n      TRANSFER_AMOUNT,\n      MOCK_RECIPIENT,\n      MOCK_SENDER,\n      defaultRoutes,\n    );\n\n    // Should fall back to fee-based selection and return token2 (lowest fee)\n    expect(result).toBe(originToken2);\n    // Verify fee lookup WAS called (fallback behavior)\n    expect(warpCore.getTokenCollateral).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "src/features/transfer/fees.ts",
    "content": "import { IToken, Token, TokenAmount, WarpCore } from '@hyperlane-xyz/sdk';\nimport { objKeys } from '@hyperlane-xyz/utils';\n\nimport { chainsRentEstimate } from '../../consts/chains';\nimport { logger } from '../../utils/logger';\nimport { getPromisesFulfilledValues } from '../../utils/promises';\nimport {\n  DefaultMultiCollateralRoutes,\n  TokensWithDestinationBalance,\n  TokenWithFee,\n} from '../tokens/types';\nimport {\n  findRouteToken,\n  getTokensWithSameCollateralAddresses,\n  isValidMultiCollateralToken,\n  tryGetDefaultOriginToken,\n} from '../tokens/utils';\n\n// Compare two objects with balance field in descending order (highest first)\nexport function compareByBalanceDesc(a: { balance: bigint }, b: { balance: bigint }) {\n  if (a.balance > b.balance) return -1;\n  if (a.balance < b.balance) return 1;\n  return 0;\n}\n\n// Filter tokens by minimum balance and sort by descending balance\nexport function filterAndSortTokensByBalance(\n  tokens: TokensWithDestinationBalance[],\n  minAmount: bigint,\n): TokensWithDestinationBalance[] {\n  return tokens.filter((t) => t.balance >= minAmount).sort(compareByBalanceDesc);\n}\n\n// Sort tokens by fee (no fee first, then lowest fee), using balance as tiebreaker\nexport function sortTokensByFee(tokenFees: TokenWithFee[]): TokenWithFee[] {\n  return [...tokenFees].sort((a, b) => {\n    const aFee = a.tokenFee?.amount;\n    const bFee = b.tokenFee?.amount;\n\n    if (aFee === undefined && bFee !== undefined) return -1;\n    if (aFee !== undefined && bFee === undefined) return 1;\n    if (aFee === undefined && bFee === undefined) return compareByBalanceDesc(a, b);\n\n    if (aFee! < bFee!) return -1;\n    if (aFee! > bFee!) return 1;\n    return compareByBalanceDesc(a, b);\n  });\n}\n\n// get the total amount combined of all the fees\nexport function getTotalFee({\n  interchainQuote,\n  localQuote,\n  tokenFeeQuote,\n}: {\n  interchainQuote: TokenAmount;\n  localQuote: TokenAmount;\n  tokenFeeQuote?: TokenAmount;\n}) {\n  const feeGroups: TokenAmount[] = [];\n  const tokenAmounts = [interchainQuote, localQuote];\n\n  if (tokenFeeQuote) {\n    tokenAmounts.push(tokenFeeQuote);\n  }\n\n  for (const tokenAmount of tokenAmounts) {\n    let foundFungibleGroup = false;\n\n    // Check if the current tokenAmount is fungible (same asset) as any token\n    // in the feeGroups array, if so add the amount to that asset group\n    for (let i = 0; i < feeGroups.length; i++) {\n      if (tokenAmount.token.isFungibleWith(feeGroups[i].token)) {\n        feeGroups[i] = feeGroups[i].plus(tokenAmount.amount);\n        foundFungibleGroup = true;\n        break;\n      }\n    }\n\n    // If no fungible group found, create a new one\n    if (!foundFungibleGroup) {\n      feeGroups.push(new TokenAmount(tokenAmount.amount, tokenAmount.token));\n    }\n  }\n\n  return feeGroups;\n}\n\nexport function getInterchainQuote(\n  originToken: IToken | undefined,\n  interchainQuote: TokenAmount | undefined,\n) {\n  if (!interchainQuote) return undefined;\n\n  return originToken && objKeys(chainsRentEstimate).includes(originToken.chainName)\n    ? interchainQuote.plus(chainsRentEstimate[originToken.chainName])\n    : interchainQuote;\n}\n\n// Checks if a token is a multi-collateral token and returns:\n// 1. The default token if configured in defaultMultiCollateralRoutes (bypasses fee lookup)\n// 2. Otherwise, the token with the lowest fee from tokens with same collateral\nexport async function getTransferToken(\n  warpCore: WarpCore,\n  originToken: Token,\n  destinationToken: IToken,\n  amountWei: string,\n  recipient: string,\n  sender: string | undefined,\n  defaultMultiCollateralRoutes?: DefaultMultiCollateralRoutes,\n) {\n  // Find the actual warpCore token that has the route\n  // Because we deduplicated tokens with the same collateral, the current token pair\n  // might not be correct, so it is necessary to get the correct token pair.\n  // Make sure to have used `checkTokenHasRoute` before calling getTransferToken\n  // as that will validate that the token pair actually refer to the same asset\n\n  // originRouteToken may differ from originToken due to collateral dedup —\n  // it's the actual warpCore token with the connection to destinationChain\n  const originRouteToken = findRouteToken(warpCore, originToken, destinationToken);\n  if (!originRouteToken) {\n    // No route exists, return original token (validation will catch this)\n    return originToken;\n  }\n\n  if (!isValidMultiCollateralToken(originRouteToken, destinationToken)) {\n    return originRouteToken;\n  }\n\n  const tokensWithSameCollateralAddresses = getTokensWithSameCollateralAddresses(\n    warpCore,\n    originRouteToken,\n    destinationToken,\n  );\n\n  if (!tokensWithSameCollateralAddresses.length) return originRouteToken;\n\n  // if only one token exists then return that exact matched route token\n  if (tokensWithSameCollateralAddresses.length === 1) {\n    return tokensWithSameCollateralAddresses[0].originToken;\n  }\n\n  logger.debug(\n    'Multiple multi-collateral tokens found for same collateral address, retrieving routes with collateral balance...',\n  );\n\n  // Check for default multi-collateral route first (bypasses fee lookup)\n  const defaultToken = tryGetDefaultOriginToken(\n    originToken,\n    destinationToken,\n    defaultMultiCollateralRoutes,\n    tokensWithSameCollateralAddresses,\n  );\n  if (defaultToken) {\n    logger.debug('Using default multi-collateral route');\n    return defaultToken;\n  }\n  // fetch each destination token balance\n  const balanceResults = await Promise.allSettled(\n    tokensWithSameCollateralAddresses.map(async ({ originToken, destinationToken }) => {\n      try {\n        const balance = await warpCore.getTokenCollateral(destinationToken);\n        return { originToken, destinationToken, balance };\n      } catch {\n        return null;\n      }\n    }),\n  );\n\n  const validBalanceResults = getPromisesFulfilledValues(balanceResults);\n\n  const tokenBalances = filterAndSortTokensByBalance(validBalanceResults, BigInt(amountWei));\n  if (!tokenBalances.length) return originRouteToken;\n\n  logger.debug('Retrieving fees for multi-collateral routes...');\n  // fetch each route fees\n  const feeResults = await Promise.allSettled(\n    tokenBalances.map(async ({ originToken, destinationToken, balance }) => {\n      try {\n        const originTokenAmount = new TokenAmount(amountWei, originToken);\n        const fees = await warpCore.getInterchainTransferFee({\n          originTokenAmount,\n          destination: destinationToken.chainName,\n          recipient,\n          sender,\n          destinationToken,\n        });\n        return { token: originToken, fees, balance };\n      } catch {\n        return null;\n      }\n    }),\n  );\n\n  const tokenFees = getPromisesFulfilledValues(feeResults).map(({ token, fees, balance }) => ({\n    token,\n    tokenFee: fees.tokenFeeQuote,\n    balance,\n  }));\n  // if no token was found with fees, just return the first token with enough collateral\n  if (!tokenFees.length) return tokenBalances[0].originToken;\n\n  const sortedTokensByFees = sortTokensByFee(tokenFees);\n\n  logger.debug('Found route with lower fee, switching route...');\n  return sortedTokensByFees[0].token;\n}\n"
  },
  {
    "path": "src/features/transfer/maxAmount.ts",
    "content": "import { MultiProtocolProvider, Token, TokenAmount, WarpCore } from '@hyperlane-xyz/sdk';\nimport { KnownProtocolType } from '@hyperlane-xyz/utils';\nimport { getAccountAddressAndPubKey } from '@hyperlane-xyz/widgets/walletIntegrations/multiProtocol';\nimport { type AccountInfo } from '@hyperlane-xyz/widgets/walletIntegrations/types';\nimport { useMutation } from '@tanstack/react-query';\nimport { toast } from 'react-toastify';\n\nimport { defaultMultiCollateralRoutes } from '../../consts/defaultMultiCollateralRoutes';\nimport { logger } from '../../utils/logger';\nimport { useMultiProvider } from '../chains/hooks';\nimport { isMultiCollateralLimitExceeded } from '../limits/utils';\nimport { useWarpCore } from '../tokens/hooks';\nimport { findConnectedDestinationToken } from '../tokens/utils';\nimport { getTransferToken } from './fees';\n\ninterface FetchMaxParams {\n  accounts: Record<KnownProtocolType, AccountInfo>;\n  balance: TokenAmount;\n  origin: ChainName;\n  destinationToken: Token;\n  recipient?: string;\n}\n\nexport function useFetchMaxAmount() {\n  const multiProvider = useMultiProvider();\n  const warpCore = useWarpCore();\n\n  const mutation = useMutation({\n    mutationFn: (params: FetchMaxParams) => fetchMaxAmount(multiProvider, warpCore, params),\n  });\n\n  return { fetchMaxAmount: mutation.mutateAsync, isLoading: mutation.isPending };\n}\n\nasync function fetchMaxAmount(\n  multiProvider: MultiProtocolProvider,\n  warpCore: WarpCore,\n  {\n    accounts,\n    balance,\n    destinationToken: destToken,\n    origin,\n    recipient: formRecipient,\n  }: FetchMaxParams,\n) {\n  try {\n    const destination = destToken.chainName;\n    const { address, publicKey } = getAccountAddressAndPubKey(multiProvider, origin, accounts);\n    if (!address) return balance;\n    const originToken = new Token(balance.token);\n\n    // Get recipient (form value or fallback to connected wallet for destination)\n    const { address: connectedDestAddress } = getAccountAddressAndPubKey(\n      multiProvider,\n      destination,\n      accounts,\n    );\n    const recipient = formRecipient || connectedDestAddress || address;\n\n    const transferToken = await getTransferToken(\n      warpCore,\n      originToken,\n      destToken,\n      balance.amount.toString(),\n      recipient,\n      address,\n      defaultMultiCollateralRoutes,\n    );\n    const transferDestinationToken = findConnectedDestinationToken(transferToken, destToken);\n    if (!transferDestinationToken) return undefined;\n    const tokenAmount = new TokenAmount(balance.amount, transferToken);\n    const maxAmount = await warpCore.getMaxTransferAmount({\n      balance: tokenAmount,\n      destination,\n      sender: address,\n      senderPubKey: await publicKey,\n      recipient,\n      destinationToken: transferDestinationToken,\n    });\n\n    const multiCollateralLimit = isMultiCollateralLimitExceeded(\n      maxAmount.token,\n      transferDestinationToken,\n      maxAmount.amount.toString(),\n    );\n    if (multiCollateralLimit) return new TokenAmount(multiCollateralLimit, maxAmount.token);\n\n    return maxAmount;\n  } catch (error) {\n    logger.warn('Error fetching fee quotes for max amount', error);\n    const chainName = multiProvider.tryGetChainMetadata(origin)?.displayName;\n    toast.warn(`Cannot simulate transfer, ${chainName} native balance may be insufficient.`);\n    return undefined;\n  }\n}\n"
  },
  {
    "path": "src/features/transfer/predicate.ts",
    "content": "import type { IToken, Token, WarpCore } from '@hyperlane-xyz/sdk';\nimport {\n  PredicateAttestation,\n  TokenAmount,\n  WarpTxCategory,\n  WarpTypedTransaction,\n  isPredicateCapableAdapter,\n} from '@hyperlane-xyz/sdk';\nimport { ProtocolType, assert } from '@hyperlane-xyz/utils';\n\nimport { fetchAttestation as fetchAttestationFromProxy } from '../../lib/predicateClient';\nimport { logger } from '../../utils/logger';\n\ninterface FetchAttestationParams {\n  warpCore: WarpCore;\n  token: Token;\n  destination: string;\n  sender: string;\n  recipient: string;\n  amount: TokenAmount<IToken>;\n  destinationToken?: IToken;\n}\n\nexport interface PredicateAttestationResult {\n  attestation: PredicateAttestation;\n  // Pinned at attestation-build time so callers can pass the same value\n  // to getTransferRemoteTxs / estimateTransferRemoteFees and avoid IGP drift.\n  interchainFee: TokenAmount<IToken>;\n  tokenFeeQuote: TokenAmount<IToken> | undefined;\n}\n\n/**\n * Fetches Predicate attestation for a token transfer if required.\n * Returns undefined if attestation is not needed or fails.\n *\n * @throws Error if attestation is required but fetch fails\n */\nexport async function fetchPredicateAttestation({\n  warpCore,\n  token,\n  destination,\n  sender,\n  recipient,\n  amount,\n  destinationToken,\n}: FetchAttestationParams): Promise<PredicateAttestationResult | undefined> {\n  if (token.protocol !== ProtocolType.Ethereum) {\n    return undefined;\n  }\n\n  const needsAttestation = await warpCore.isPredicateSupported(token, destination);\n\n  if (!needsAttestation) {\n    return undefined;\n  }\n\n  logger.debug('Route requires Predicate attestation, fetching...');\n\n  const adapter = token.getHypAdapter(warpCore.multiProvider);\n\n  if (!isPredicateCapableAdapter(adapter)) {\n    throw new Error(`Token adapter for ${token.chainName} does not support Predicate`);\n  }\n\n  const wrapperAddress = await adapter.getPredicateWrapperAddress();\n  assert(wrapperAddress, 'Predicate wrapper address not found');\n\n  // Quote IGP once here so the same value is pinned into the calldata AND returned to callers.\n  // Both getTransferRemoteTxs (calldata build) and the downstream submit/fee-estimate calls\n  // must use the identical msg_value; the Predicate wrapper hashes it into the Statement\n  // preimage, so any IGP drift between the two calls causes _authorizeTransaction to revert.\n  const { igpQuote, tokenFeeQuote } = await warpCore.getInterchainTransferFee({\n    originTokenAmount: amount,\n    destination,\n    sender,\n    recipient,\n    destinationToken,\n  });\n\n  // Build the transfer tx WITHOUT an attestation to obtain the inner transferRemote calldata.\n  // The on-chain PredicateWrapper.transferRemoteWithAttestation() reconstructs that same\n  // inner calldata and passes it to Predicate.validateAttestation() — it cannot include the\n  // attestation bytes in what it verifies (that would be circular). Empirically, a dummy-\n  // attestation approach produces different calldata that the verifier rejects at estimateGas.\n  const tempTxs = await warpCore.getTransferRemoteTxs({\n    originTokenAmount: amount,\n    destination,\n    sender,\n    recipient,\n    interchainFee: igpQuote,\n    tokenFeeQuote,\n    destinationToken,\n  });\n\n  assert(tempTxs.length > 0, 'No transactions returned for transfer');\n\n  // Use last tx: preTransferRemoteTxs (approval/revoke) are prepended before the transfer tx\n  const tempTx =\n    tempTxs.find((tx) => tx.category === WarpTxCategory.Transfer) ?? tempTxs[tempTxs.length - 1];\n  assert(tempTx && typeof tempTx === 'object', 'Invalid transaction object');\n\n  const transaction = (tempTx as WarpTypedTransaction).transaction;\n  assert(transaction, 'Transfer transaction missing');\n\n  const txData = transaction.data?.toString();\n  assert(txData, 'Transfer tx missing calldata');\n\n  let chainName = token.chainName;\n\n  // Only necessary for this one testnet\n  if (chainName === 'basesepolia') {\n    chainName = 'base-sepolia';\n  }\n\n  logger.debug('Fetching attestation for tx:', {\n    to: wrapperAddress,\n    from: sender,\n    chain: chainName,\n  });\n\n  // Fetch attestation from Predicate API via proxy\n  const response = await fetchAttestationFromProxy({\n    to: wrapperAddress,\n    from: sender,\n    data: txData,\n    msg_value: transaction.value?.toString() || '0',\n    chain: chainName,\n  });\n\n  logger.debug('Predicate attestation received:', response.attestation.uuid);\n  return { attestation: response.attestation, interchainFee: igpQuote, tokenFeeQuote };\n}\n"
  },
  {
    "path": "src/features/transfer/relayApi.ts",
    "content": "import { ProviderType, TypedTransactionReceipt } from '@hyperlane-xyz/sdk';\nimport { ProtocolType, isNullish } from '@hyperlane-xyz/utils';\n\nimport { config } from '../../consts/config';\nimport { logger } from '../../utils/logger';\n\n// keccak256(\"MessageSent(bytes)\") — emitted by MessageTransmitter V2 for both native USDC\n// transfers (depositForBurn) and GMP-only messages. Covers CCTP V2 GMP that lacks DepositForBurn.\nconst MESSAGE_SENT_TOPIC = '0x8c5261668696ce22758910d05bab8f186d6eb247ceac2af2e82c7dc17669b036';\n\n// Circle's MessageTransmitter V2 addresses (lowercase for comparison).\n// Source: https://developers.circle.com/cctp/references/contract-addresses#messagetransmitterv2\nconst MESSAGE_TRANSMITTER_V2_ADDRESSES = new Set([\n  '0x81d40f21f12a8f0e3252bccb954d722d4c464b64', // mainnet (all chains except EDGE)\n  '0x5b61381fc9e58e70efc13a4a97516997019198ee', // mainnet EDGE\n  '0xe737e5cebeeba77efe34d4aa090756590b1ce275', // testnet (all chains)\n]);\n\ninterface RelayResponse {\n  messages: Array<{\n    message_id: string;\n    origin: number;\n    destination: number;\n    nonce: number;\n  }>;\n}\n\n/**\n * Submits an origin transaction hash to the relayer API for fast CCTP processing.\n * Fire-and-forget: errors are logged but never surfaced to the user.\n */\nexport async function submitToRelayApi(\n  originChain: string,\n  txHash: string,\n  originProtocol: ProtocolType,\n  txReceipt: TypedTransactionReceipt | null | undefined,\n): Promise<void> {\n  if (!config.relayApiUrl) return;\n\n  try {\n    const isCctp =\n      originProtocol === ProtocolType.Ethereum &&\n      !isNullish(txReceipt) &&\n      (txReceipt.type === ProviderType.EthersV5 || txReceipt.type === ProviderType.Viem) &&\n      txReceipt.receipt.logs.some(\n        (log) =>\n          log.topics[0] === MESSAGE_SENT_TOPIC &&\n          MESSAGE_TRANSMITTER_V2_ADDRESSES.has(log.address.toLowerCase()),\n      );\n    if (!isCctp) return;\n\n    const baseUrl = config.relayApiUrl.replace(/\\/$/, '');\n    const payload = { origin_chain: originChain, tx_hash: txHash };\n    logger.debug('[RelayAPI] Requesting relay', { url: `${baseUrl}/relay`, payload });\n    const response = await fetch(`${baseUrl}/relay`, {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify(payload),\n    });\n\n    if (!response.ok) {\n      const text = await response.text();\n      logger.warn(`[RelayAPI] ${response.status} for ${txHash} on ${originChain}: ${text}`);\n      return;\n    }\n\n    const data: RelayResponse = await response.json();\n    logger.debug(\n      `[RelayAPI] Accepted ${data.messages.length} message(s) from ${txHash} on ${originChain}`,\n      data.messages.map((m) => m.message_id),\n    );\n  } catch (error) {\n    logger.warn(`[RelayAPI] Failed to submit ${txHash} on ${originChain}`, error);\n  }\n}\n"
  },
  {
    "path": "src/features/transfer/scaleUtils.test.ts",
    "content": "import { describe, expect, test } from 'vitest';\n\nimport { computeDestAmount, formatMessageAmount } from './scaleUtils';\n\ndescribe('computeDestAmount', () => {\n  test('returns null when either token is null or undefined', () => {\n    const token = { decimals: 18, scale: 10 };\n    expect(computeDestAmount('1', null, token)).toBeNull();\n    expect(computeDestAmount('1', token, null)).toBeNull();\n    expect(computeDestAmount('1', undefined, token)).toBeNull();\n    expect(computeDestAmount('1', token, undefined)).toBeNull();\n  });\n\n  test('returns null when both tokens have no scale', () => {\n    const origin = { decimals: 18 };\n    const dest = { decimals: 6 };\n    expect(computeDestAmount('1', origin, dest)).toBeNull();\n  });\n\n  test('returns null when scales are equal', () => {\n    const origin = { decimals: 18, scale: 10 };\n    const dest = { decimals: 18, scale: 10 };\n    expect(computeDestAmount('1', origin, dest)).toBeNull();\n  });\n\n  test('returns null when scales are equivalent fractions', () => {\n    const origin = { decimals: 18, scale: { numerator: 2, denominator: 4 } };\n    const dest = { decimals: 18, scale: { numerator: 1, denominator: 2 } };\n    expect(computeDestAmount('1', origin, dest)).toBeNull();\n  });\n\n  test('computes dest amount for same-decimal different-scale (VRA-style)', () => {\n    const origin = { decimals: 18, scale: 10 };\n    const dest = { decimals: 18, scale: 1 };\n    expect(computeDestAmount('1', origin, dest)).toBe('10');\n  });\n\n  test('computes dest amount for scale-down route (BSC USDT style)', () => {\n    const origin = { decimals: 18, scale: { numerator: 1, denominator: 1_000_000_000_000 } };\n    const dest = { decimals: 6 };\n    expect(computeDestAmount('1', origin, dest)).toBe('1');\n  });\n\n  test('computes dest amount for scale-up route', () => {\n    const origin = { decimals: 6, scale: 1_000_000_000_000 };\n    const dest = { decimals: 18 };\n    expect(computeDestAmount('1', origin, dest)).toBe('1');\n  });\n\n  test('computes dest amount when only dest has scale', () => {\n    const origin = { decimals: 6 };\n    const dest = { decimals: 18, scale: { numerator: 1, denominator: 1_000_000_000_000 } };\n    expect(computeDestAmount('1', origin, dest)).toBe('1');\n  });\n\n  test('computes dest amount with bigint scale values', () => {\n    const origin = { decimals: 18, scale: { numerator: 1n, denominator: 1_000_000_000_000n } };\n    const dest = { decimals: 6 };\n    expect(computeDestAmount('1', origin, dest)).toBe('1');\n  });\n\n  test('handles fractional amounts', () => {\n    const origin = { decimals: 18, scale: 10 };\n    const dest = { decimals: 18, scale: 1 };\n    expect(computeDestAmount('0.5', origin, dest)).toBe('5');\n  });\n});\n\ndescribe('formatMessageAmount', () => {\n  test('uses localAmountFromMessage for tokens with scale', () => {\n    // BSC USDT: 18 decimals, scale {1, 1e12}\n    // Message amount 1000000 (1 USDT in 6-dec message space)\n    // Local = 1000000 * 1e12 / 1 = 1e18 → \"1\" in 18 decimals\n    const token = {\n      decimals: 18,\n      scale: { numerator: 1, denominator: 1_000_000_000_000 },\n    };\n    expect(formatMessageAmount('1000000', token)).toBe('1');\n  });\n\n  test('converts message amount using numeric scale (VRA-style)', () => {\n    // VRA: ETH scale=1, BSC scale=10\n    // Message amount 10e18 (10 VRA in message space with scale=10)\n    // Local = 10e18 / 10 = 1e18 → \"1\" in 18 decimals\n    const token = { decimals: 18, scale: 10 };\n    expect(formatMessageAmount('10000000000000000000', token)).toBe('1');\n  });\n\n  test('uses token.decimals when token has no scale', () => {\n    const token = { decimals: 6 };\n    expect(formatMessageAmount('1000000', token)).toBe('1');\n  });\n});\n"
  },
  {
    "path": "src/features/transfer/scaleUtils.ts",
    "content": "import type { ScaleInput } from '@hyperlane-xyz/sdk';\nimport { localAmountFromMessage, messageAmountFromLocal, scalesEqual } from '@hyperlane-xyz/sdk';\nimport { fromWei, toWei } from '@hyperlane-xyz/utils';\n\nimport { logger } from '../../utils/logger';\n\nexport interface ScaledToken {\n  decimals: number;\n  scale?: ScaleInput;\n}\n\n/**\n * Computes the destination amount for a transfer when origin and destination\n * tokens have different scales. Returns null when scales are equal (no conversion needed).\n *\n * Converts: human amount → origin wei → message-space → dest wei → human amount\n */\nexport function computeDestAmount(\n  amount: string,\n  originToken: ScaledToken | null | undefined,\n  destToken: ScaledToken | null | undefined,\n): string | null {\n  if (!originToken || !destToken) return null;\n  if (!originToken.scale && !destToken.scale) return null;\n  if (scalesEqual(originToken.scale, destToken.scale)) return null;\n\n  try {\n    const originWei = BigInt(toWei(amount, originToken.decimals));\n    const messageAmount = messageAmountFromLocal(originWei, originToken.scale);\n    const destWei = localAmountFromMessage(messageAmount, destToken.scale);\n    return fromWei(destWei.toString(), destToken.decimals);\n  } catch (e) {\n    logger.error('Failed to compute dest amount', e);\n    return null;\n  }\n}\n\n/**\n * Formats a raw message-body amount into a human-readable local amount string.\n * For tokens with scale, converts from message-space to local units first.\n */\nexport function formatMessageAmount(rawAmount: string, token: ScaledToken): string {\n  if (token.scale) {\n    const messageAmount = BigInt(rawAmount);\n    const localAmount = localAmountFromMessage(messageAmount, token.scale);\n    return fromWei(localAmount.toString(), token.decimals);\n  }\n  return fromWei(rawAmount, token.decimals);\n}\n"
  },
  {
    "path": "src/features/transfer/types.ts",
    "content": "export interface TransferFormValues {\n  originTokenKey: string | undefined;\n  destinationTokenKey: string | undefined;\n  amount: string;\n  recipient: Address;\n}\n\nexport enum TransferStatus {\n  Preparing = 'preparing',\n  CreatingTxs = 'creating-txs',\n  FetchingAttestation = 'fetching-attestation',\n  SigningApprove = 'signing-approve',\n  SigningRevoke = 'signing-revoke',\n  ConfirmingRevoke = 'confirming-revoke',\n  ConfirmingApprove = 'confirming-approve',\n  SigningTransfer = 'signing-transfer',\n  ConfirmingTransfer = 'confirming-transfer',\n  ConfirmedTransfer = 'confirmed-transfer',\n  Delivered = 'delivered',\n  Failed = 'failed',\n}\n\nexport const SentTransferStatuses = [TransferStatus.ConfirmedTransfer, TransferStatus.Delivered];\n\n// Statuses considered not pending\nexport const FinalTransferStatuses = [...SentTransferStatuses, TransferStatus.Failed];\n\nexport interface TransferContext {\n  status: TransferStatus;\n  origin: ChainName;\n  destination: ChainName;\n  originTokenAddressOrDenom?: string;\n  destTokenAddressOrDenom?: string;\n  amount: string;\n  sender: Address;\n  recipient: Address;\n  originTxHash?: string;\n  originBlockNumber?: number;\n  msgId?: string;\n  destinationTxHash?: string;\n  timestamp: number;\n}\n"
  },
  {
    "path": "src/features/transfer/useBalanceWatcher.ts",
    "content": "import { TokenAmount } from '@hyperlane-xyz/sdk';\nimport { useEffect, useRef } from 'react';\nimport { toast } from 'react-toastify';\n\nexport function useRecipientBalanceWatcher(recipient?: Address, balance?: TokenAmount) {\n  // A crude way to detect transfer completions by triggering\n  // toast on recipient balance increase. This is not ideal because it\n  // could confuse unrelated balance changes for message delivery\n  // TODO replace with a polling worker that queries the hyperlane explorer\n  const prevRecipientBalance = useRef<{ balance?: TokenAmount; recipient?: string }>({\n    recipient: '',\n  });\n  useEffect(() => {\n    if (\n      recipient &&\n      balance &&\n      prevRecipientBalance.current.balance &&\n      prevRecipientBalance.current.recipient === recipient &&\n      balance.token.equals(prevRecipientBalance.current.balance.token) &&\n      balance.amount > prevRecipientBalance.current.balance.amount\n    ) {\n      toast.success('Recipient has received funds, transfer complete!');\n    }\n    prevRecipientBalance.current = { balance, recipient: recipient };\n  }, [balance, recipient, prevRecipientBalance]);\n}\n"
  },
  {
    "path": "src/features/transfer/useFeeQuotes.test.ts",
    "content": "import type { IToken, Token, TokenAmount, WarpCore, WarpCoreFeeEstimate } from '@hyperlane-xyz/sdk';\nimport { ProtocolType } from '@hyperlane-xyz/utils';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { findConnectedDestinationToken } from '../tokens/utils';\nimport { fetchPredicateAttestation } from './predicate';\nimport { fetchFeeQuotes } from './useFeeQuotes';\n\nvi.mock('../tokens/utils', () => ({\n  findConnectedDestinationToken: vi.fn(),\n}));\n\nvi.mock('./predicate', () => ({\n  fetchPredicateAttestation: vi.fn(),\n}));\n\nconst mockFindConnectedDestinationToken = vi.mocked(findConnectedDestinationToken);\nconst mockFetchPredicateAttestation = vi.mocked(fetchPredicateAttestation);\n\nfunction mockOriginToken(protocol: ProtocolType): Token {\n  return {\n    protocol,\n    decimals: 6,\n    amount: vi.fn((amount) => ({ amount: BigInt(amount), token: {} }) as unknown as TokenAmount),\n  } as unknown as Token;\n}\n\nfunction mockDestinationToken(protocol: ProtocolType): IToken {\n  return {\n    protocol,\n    chainName: 'base',\n  } as unknown as IToken;\n}\n\ndescribe('fetchFeeQuotes', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it('retries with fallback sender when connected sender quote fails on evm->evm', async () => {\n    const originToken = mockOriginToken(ProtocolType.Ethereum);\n    const destinationToken = mockDestinationToken(ProtocolType.Ethereum);\n    mockFindConnectedDestinationToken.mockReturnValue(destinationToken as unknown as Token);\n\n    const expectedFees = { localQuote: { amount: 1n } } as unknown as WarpCoreFeeEstimate;\n    const estimateTransferRemoteFees = vi\n      .fn()\n      .mockRejectedValueOnce(new Error('insufficient funds for gas * price + value'))\n      .mockResolvedValueOnce(expectedFees);\n    const warpCore = {\n      estimateTransferRemoteFees,\n      isPredicateSupported: vi.fn().mockResolvedValue(false),\n    } as unknown as WarpCore;\n\n    const result = await fetchFeeQuotes(\n      warpCore,\n      originToken,\n      destinationToken,\n      'base',\n      '0x1111111111111111111111111111111111111111',\n      undefined,\n      '0.01',\n      '0x2222222222222222222222222222222222222222',\n      false,\n    );\n\n    expect(result).toBe(expectedFees);\n    expect(estimateTransferRemoteFees).toHaveBeenCalledTimes(2);\n    expect(estimateTransferRemoteFees.mock.calls[0][0].sender).toBe(\n      '0x1111111111111111111111111111111111111111',\n    );\n    expect(estimateTransferRemoteFees.mock.calls[1][0].sender).toBe(\n      '0x000000000000000000000000000000000000dEaD',\n    );\n  });\n\n  it('does not retry for non-evm routes', async () => {\n    const originToken = mockOriginToken(ProtocolType.Sealevel);\n    const destinationToken = mockDestinationToken(ProtocolType.Ethereum);\n    mockFindConnectedDestinationToken.mockReturnValue(destinationToken as unknown as Token);\n\n    const estimateTransferRemoteFees = vi.fn().mockRejectedValueOnce(new Error('quote failed'));\n    const warpCore = {\n      estimateTransferRemoteFees,\n      isPredicateSupported: vi.fn().mockResolvedValue(false),\n    } as unknown as WarpCore;\n\n    await expect(\n      fetchFeeQuotes(\n        warpCore,\n        originToken,\n        destinationToken,\n        'base',\n        '0x1111111111111111111111111111111111111111',\n        undefined,\n        '0.01',\n        '0x2222222222222222222222222222222222222222',\n        false,\n      ),\n    ).rejects.toThrow('quote failed');\n\n    expect(estimateTransferRemoteFees).toHaveBeenCalledTimes(1);\n  });\n\n  it('does not retry when already using fallback sender', async () => {\n    const originToken = mockOriginToken(ProtocolType.Ethereum);\n    const destinationToken = mockDestinationToken(ProtocolType.Ethereum);\n    mockFindConnectedDestinationToken.mockReturnValue(destinationToken as unknown as Token);\n\n    const estimateTransferRemoteFees = vi.fn().mockRejectedValueOnce(new Error('quote failed'));\n    const warpCore = {\n      estimateTransferRemoteFees,\n      isPredicateSupported: vi.fn().mockResolvedValue(false),\n    } as unknown as WarpCore;\n\n    await expect(\n      fetchFeeQuotes(\n        warpCore,\n        originToken,\n        destinationToken,\n        'base',\n        '0x000000000000000000000000000000000000dEaD',\n        undefined,\n        '0.01',\n        '0x2222222222222222222222222222222222222222',\n        false,\n      ),\n    ).rejects.toThrow('quote failed');\n\n    expect(estimateTransferRemoteFees).toHaveBeenCalledTimes(1);\n  });\n\n  it('returns null for Predicate route when only fallback sender is available', async () => {\n    const originToken = mockOriginToken(ProtocolType.Ethereum);\n    const destinationToken = mockDestinationToken(ProtocolType.Ethereum);\n    mockFindConnectedDestinationToken.mockReturnValue(destinationToken as unknown as Token);\n\n    const estimateTransferRemoteFees = vi.fn();\n    const warpCore = {\n      estimateTransferRemoteFees,\n      isPredicateSupported: vi.fn().mockResolvedValue(true),\n    } as unknown as WarpCore;\n\n    const result = await fetchFeeQuotes(\n      warpCore,\n      originToken,\n      destinationToken,\n      'base',\n      '0x000000000000000000000000000000000000dEaD',\n      undefined,\n      '0.01',\n      '0x2222222222222222222222222222222222222222',\n      false,\n    );\n\n    expect(result).toBeNull();\n    expect(mockFetchPredicateAttestation).not.toHaveBeenCalled();\n    expect(estimateTransferRemoteFees).not.toHaveBeenCalled();\n  });\n\n  it('returns null when Predicate attestation fetch fails', async () => {\n    const originToken = mockOriginToken(ProtocolType.Ethereum);\n    const destinationToken = mockDestinationToken(ProtocolType.Ethereum);\n    mockFindConnectedDestinationToken.mockReturnValue(destinationToken as unknown as Token);\n\n    mockFetchPredicateAttestation.mockRejectedValue(new Error('attestation API unavailable'));\n\n    const estimateTransferRemoteFees = vi.fn();\n    const warpCore = {\n      estimateTransferRemoteFees,\n      isPredicateSupported: vi.fn().mockResolvedValue(true),\n    } as unknown as WarpCore;\n\n    const result = await fetchFeeQuotes(\n      warpCore,\n      originToken,\n      destinationToken,\n      'base',\n      '0x1111111111111111111111111111111111111111',\n      undefined,\n      '0.01',\n      '0x2222222222222222222222222222222222222222',\n      false,\n    );\n\n    expect(result).toBeNull();\n    expect(estimateTransferRemoteFees).not.toHaveBeenCalled();\n  });\n\n  it('pins interchainFee from attestation and calls getLocalTransferFeeAmount with attestation', async () => {\n    const originToken = mockOriginToken(ProtocolType.Ethereum);\n    const destinationToken = mockDestinationToken(ProtocolType.Ethereum);\n    mockFindConnectedDestinationToken.mockReturnValue(destinationToken as unknown as Token);\n\n    const pinnedInterchainFee = { amount: 100n } as unknown as TokenAmount;\n    const mockAttestation = {\n      uuid: 'test-uuid',\n    } as unknown as import('@hyperlane-xyz/sdk').PredicateAttestation;\n    mockFetchPredicateAttestation.mockResolvedValue({\n      attestation: mockAttestation,\n      interchainFee: pinnedInterchainFee,\n      tokenFeeQuote: undefined,\n    });\n\n    const localQuote = { amount: 5n } as unknown as TokenAmount;\n    const estimateTransferRemoteFees = vi.fn();\n    const getLocalTransferFeeAmount = vi.fn().mockResolvedValue(localQuote);\n    const warpCore = {\n      estimateTransferRemoteFees,\n      getLocalTransferFeeAmount,\n      isPredicateSupported: vi.fn().mockResolvedValue(true),\n    } as unknown as WarpCore;\n\n    const result = await fetchFeeQuotes(\n      warpCore,\n      originToken,\n      destinationToken,\n      'base',\n      '0x1111111111111111111111111111111111111111',\n      undefined,\n      '0.01',\n      '0x2222222222222222222222222222222222222222',\n      false,\n    );\n\n    // estimateTransferRemoteFees re-quotes IGP and would drift from attested msg_value;\n    // predicate path must call getLocalTransferFeeAmount directly with pinned values.\n    expect(estimateTransferRemoteFees).not.toHaveBeenCalled();\n    expect(getLocalTransferFeeAmount).toHaveBeenCalledWith(\n      expect.objectContaining({\n        attestation: mockAttestation,\n        interchainFee: pinnedInterchainFee,\n      }),\n    );\n    expect(result?.interchainQuote).toBe(pinnedInterchainFee);\n    expect(result?.localQuote).toBe(localQuote);\n  });\n});\n"
  },
  {
    "path": "src/features/transfer/useFeeQuotes.ts",
    "content": "import {\n  IToken,\n  PredicateAttestation,\n  Token,\n  TokenAmount,\n  WarpCore,\n  WarpCoreFeeEstimate,\n} from '@hyperlane-xyz/sdk';\nimport { HexString, ProtocolType, toWei } from '@hyperlane-xyz/utils';\nimport { useDebounce } from '@hyperlane-xyz/widgets';\nimport {\n  getAccountAddressAndPubKey,\n  useAccounts,\n} from '@hyperlane-xyz/widgets/walletIntegrations/multiProtocol';\nimport { useQuery } from '@tanstack/react-query';\n\nimport { defaultMultiCollateralRoutes } from '../../consts/defaultMultiCollateralRoutes';\nimport { logger } from '../../utils/logger';\nimport { useMultiProvider } from '../chains/hooks';\nimport { useWarpCore } from '../tokens/hooks';\nimport { findConnectedDestinationToken } from '../tokens/utils';\nimport { getTransferToken } from './fees';\nimport { fetchPredicateAttestation } from './predicate';\nimport { TransferFormValues } from './types';\n\nconst FEE_QUOTE_REFRESH_INTERVAL = 30_000; // 30s\nconst EVM_FEE_QUOTE_FALLBACK_ADDRESS = '0x000000000000000000000000000000000000dEaD';\n\nexport function useFeeQuotes(\n  { originTokenKey, destinationTokenKey, amount, recipient: formRecipient }: TransferFormValues,\n  enabled: boolean,\n  originToken: Token | undefined,\n  destinationToken: IToken | undefined,\n  searchForLowestFee: boolean = false,\n) {\n  const multiProvider = useMultiProvider();\n  const warpCore = useWarpCore();\n  const debouncedAmount = useDebounce(amount, 500);\n  const destination = destinationToken?.chainName;\n\n  const { accounts } = useAccounts(multiProvider);\n  const { address: sender, publicKey: senderPubKey } = getAccountAddressAndPubKey(\n    multiProvider,\n    originToken?.chainName,\n    accounts,\n  );\n\n  const isEvmToEvmRoute =\n    originToken?.protocol === ProtocolType.Ethereum &&\n    destinationToken?.protocol === ProtocolType.Ethereum;\n  const effectiveSender = sender || (isEvmToEvmRoute ? EVM_FEE_QUOTE_FALLBACK_ADDRESS : undefined);\n\n  // Get effective recipient (form value or fallback to connected wallet for destination)\n  const { address: connectedDestAddress } = getAccountAddressAndPubKey(\n    multiProvider,\n    destinationToken?.chainName,\n    accounts,\n  );\n  const recipient =\n    formRecipient ||\n    connectedDestAddress ||\n    (isEvmToEvmRoute ? EVM_FEE_QUOTE_FALLBACK_ADDRESS : '');\n\n  const isFormValid = !!(\n    originToken &&\n    destination &&\n    debouncedAmount &&\n    recipient &&\n    effectiveSender\n  );\n  const shouldFetch = enabled && isFormValid;\n\n  const { isLoading, isError, data } = useQuery({\n    // The WarpCore class is not serializable, so we can't use it as a key\n    // eslint-disable-next-line @tanstack/query/exhaustive-deps\n    queryKey: [\n      'useFeeQuotes',\n      originTokenKey,\n      destinationTokenKey,\n      effectiveSender,\n      senderPubKey,\n      debouncedAmount,\n      recipient,\n    ],\n    queryFn: () =>\n      fetchFeeQuotes(\n        warpCore,\n        originToken,\n        destinationToken,\n        destination,\n        effectiveSender,\n        senderPubKey,\n        debouncedAmount,\n        recipient,\n        searchForLowestFee,\n      ),\n    enabled: shouldFetch,\n    refetchInterval: FEE_QUOTE_REFRESH_INTERVAL,\n  });\n\n  return { isLoading, isError, fees: data };\n}\n\nexport async function fetchFeeQuotes(\n  warpCore: WarpCore,\n  originToken: Token | undefined,\n  destinationToken: IToken | undefined,\n  destination?: ChainName,\n  sender?: Address,\n  senderPubKey?: Promise<HexString | undefined>,\n  amount?: string,\n  recipient?: string,\n  searchForLowestFee: boolean = false,\n): Promise<WarpCoreFeeEstimate | null> {\n  if (!originToken || !destinationToken || !destination || !sender || !amount || !recipient)\n    return null;\n\n  let transferToken = originToken;\n  const amountWei = toWei(amount, transferToken.decimals);\n\n  // when true attempt to get route with lowest fee (or use default if configured)\n  if (searchForLowestFee) {\n    transferToken = await getTransferToken(\n      warpCore,\n      originToken,\n      destinationToken,\n      amountWei,\n      recipient,\n      sender,\n      defaultMultiCollateralRoutes,\n    );\n  }\n\n  const originTokenAmount = transferToken.amount(amountWei);\n\n  const connectedDestinationToken = findConnectedDestinationToken(transferToken, destinationToken);\n  if (!connectedDestinationToken) return null;\n  const isEvmToEvmRoute =\n    originToken.protocol === ProtocolType.Ethereum &&\n    destinationToken.protocol === ProtocolType.Ethereum;\n  const senderPubKeyValue = await senderPubKey;\n\n  // Predicate routes require the wrapper path (transferRemoteWithAttestation) — estimating\n  // fees without a valid attestation reverts on-chain. But attestation needs a real sender\n  // address; skip fee display when only the fallback address is available.\n  let pinnedInterchainFee: WarpCoreFeeEstimate['interchainQuote'] | undefined;\n  let pinnedTokenFeeQuote: WarpCoreFeeEstimate['tokenFeeQuote'] | undefined;\n  let attestation: PredicateAttestation | undefined;\n  const isPredicateRoute = await warpCore.isPredicateSupported(transferToken, destination);\n  if (isPredicateRoute) {\n    if (sender === EVM_FEE_QUOTE_FALLBACK_ADDRESS) return null;\n    try {\n      const result = await fetchPredicateAttestation({\n        warpCore,\n        token: transferToken,\n        destination,\n        sender,\n        recipient,\n        amount: originTokenAmount,\n        destinationToken: connectedDestinationToken,\n      });\n      attestation = result?.attestation;\n      pinnedInterchainFee = result?.interchainFee;\n      pinnedTokenFeeQuote = result?.tokenFeeQuote;\n    } catch (error: any) {\n      logger.error('Predicate attestation failed during fee estimation', error);\n      return null;\n    }\n  }\n\n  logger.debug('Fetching fee quotes');\n  try {\n    // Predicate routes: estimateTransferRemoteFees re-quotes IGP internally; a drifted\n    // fresh quote diverges from the attested msg_value and reverts in _authorizeTransaction.\n    // Call getLocalTransferFeeAmount directly with pinned values to avoid the re-quote.\n    if (pinnedInterchainFee) {\n      const localQuote = await warpCore.getLocalTransferFeeAmount({\n        originToken: originTokenAmount.token,\n        destination,\n        sender,\n        senderPubKey: senderPubKeyValue,\n        interchainFee: pinnedInterchainFee as TokenAmount<IToken>,\n        tokenFeeQuote: pinnedTokenFeeQuote as TokenAmount<IToken> | undefined,\n        attestation,\n        amount: originTokenAmount.amount,\n        destinationToken: connectedDestinationToken,\n      });\n      return {\n        interchainQuote: pinnedInterchainFee,\n        localQuote,\n        tokenFeeQuote: pinnedTokenFeeQuote,\n      };\n    }\n    const feeEstimate = await warpCore.estimateTransferRemoteFees({\n      originTokenAmount,\n      destination,\n      sender,\n      senderPubKey: senderPubKeyValue,\n      recipient: recipient,\n      attestation,\n      destinationToken: connectedDestinationToken,\n    });\n    return feeEstimate;\n  } catch (error) {\n    // Connected wallets switch fee simulation from a neutral fallback sender to the user's real\n    // account. Some RPC/wallet combinations intermittently fail estimateGas in that mode (e.g.\n    // sender-specific state checks), which makes fees disappear in the UI.\n    // Retry with the pre-connect fallback sender so fee display remains stable.\n    // This only affects quote estimation; transfer submission still uses the real connected account.\n    if (!isEvmToEvmRoute || sender === EVM_FEE_QUOTE_FALLBACK_ADDRESS) throw error;\n    logger.warn('Fee quote failed with connected sender, retrying with fallback sender', error);\n    return warpCore.estimateTransferRemoteFees({\n      originTokenAmount,\n      destination,\n      sender: EVM_FEE_QUOTE_FALLBACK_ADDRESS,\n      recipient: recipient,\n      attestation: undefined,\n      destinationToken: connectedDestinationToken,\n    });\n  }\n}\n"
  },
  {
    "path": "src/features/transfer/useQuotedCalls.test.ts",
    "content": "import { computeScopedSalt } from '@hyperlane-xyz/sdk';\nimport { encodePacked, keccak256 } from 'viem';\nimport { describe, expect, test } from 'vitest';\n\ndescribe('computeScopedSalt', () => {\n  test('matches QuotedCalls._scopeSalt (abi.encodePacked)', () => {\n    const sender = '0x1234567890abcdef1234567890abcdef12345678' as const;\n    const clientSalt =\n      '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as const;\n\n    const result = computeScopedSalt(sender, clientSalt);\n\n    const expected = keccak256(encodePacked(['address', 'bytes32'], [sender, clientSalt]));\n    expect(result).toBe(expected);\n  });\n\n  test('produces 52-byte packed encoding, not 64-byte abi.encode', () => {\n    const sender = '0x1234567890abcdef1234567890abcdef12345678' as const;\n    const clientSalt =\n      '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as const;\n\n    const packed = encodePacked(['address', 'bytes32'], [sender, clientSalt]);\n    // address = 20 bytes, bytes32 = 32 bytes => 52 bytes = 104 hex chars + 0x prefix\n    expect(packed.length).toBe(2 + 104);\n  });\n\n  test('different senders produce different salts', () => {\n    const clientSalt =\n      '0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc' as const;\n    const salt1 = computeScopedSalt('0x1111111111111111111111111111111111111111', clientSalt);\n    const salt2 = computeScopedSalt('0x2222222222222222222222222222222222222222', clientSalt);\n    expect(salt1).not.toBe(salt2);\n  });\n});\n"
  },
  {
    "path": "src/features/transfer/useQuotedCalls.ts",
    "content": "import {\n  IToken,\n  QuotedCallsParams,\n  SubmitQuoteCommand,\n  Token,\n  TokenAmount,\n  TokenPullMode,\n  WarpCore,\n  computeScopedSalt,\n} from '@hyperlane-xyz/sdk';\nimport { ProtocolType, addressToBytes32, toWei } from '@hyperlane-xyz/utils';\nimport { useDebounce } from '@hyperlane-xyz/widgets';\nimport { useAccounts } from '@hyperlane-xyz/widgets/walletIntegrations/accounts';\nimport { getAccountAddressAndPubKey } from '@hyperlane-xyz/widgets/walletIntegrations/accountUtils';\nimport { useQuery } from '@tanstack/react-query';\nimport { type Address, type Hex, toHex } from 'viem';\n\nimport { config } from '../../consts/config';\nimport { logger } from '../../utils/logger';\nimport { useMultiProvider } from '../chains/hooks';\nimport { useStore } from '../store';\nimport { useWarpCore } from '../tokens/hooks';\nimport { TransferFormValues } from './types';\n\nconst FEE_QUOTE_REFRESH_INTERVAL = 30_000;\n// Upstream quote expires at ~5 min; cap consumption at 4 min so a tab that was\n// backgrounded past the refetchInterval can't submit on a stale quote between\n// returning to focus and the focus-refetch completing.\nconst MAX_QUOTE_AGE_MS = 4 * 60_000;\n\ninterface QuotedCallsFetchResult {\n  interchainQuote: TokenAmount;\n  localQuote: TokenAmount;\n  tokenFeeQuote?: TokenAmount;\n  quotedCallsParams: QuotedCallsParams;\n  issuedAt: number;\n}\n\nexport interface QuotedCallsFeeQuotesResult {\n  isLoading: boolean;\n  fees: {\n    interchainQuote: TokenAmount;\n    localQuote: TokenAmount;\n    tokenFeeQuote?: TokenAmount;\n  } | null;\n  quotedCallsParams: QuotedCallsParams | null;\n  // Submit handlers call this to retrieve quotedCallsParams that may still be\n  // in flight. Returns the cached value when settled, or awaits the active\n  // fetch otherwise — prevents a click on Send during the first-load /\n  // stale-refetch window from silently falling through to plain transferRemote.\n  getQuotedCallsParams: () => Promise<QuotedCallsParams | null>;\n}\n\nexport function useQuotedCallsFeeQuotes(\n  { originTokenKey, destinationTokenKey, amount, recipient: formRecipient }: TransferFormValues,\n  enabled: boolean,\n  originToken: Token | undefined,\n  destinationToken: IToken | undefined,\n): QuotedCallsFeeQuotesResult {\n  const multiProvider = useMultiProvider();\n  const warpCore = useWarpCore();\n  const chainAddresses = useStore((s) => s.chainAddresses);\n  const debouncedAmount = useDebounce(amount, 500);\n  const destination = destinationToken?.chainName;\n\n  const { accounts } = useAccounts(multiProvider);\n  const { address: sender } = getAccountAddressAndPubKey(\n    multiProvider,\n    originToken?.chainName,\n    accounts,\n  );\n\n  const { address: connectedDestAddress } = getAccountAddressAndPubKey(\n    multiProvider,\n    destinationToken?.chainName,\n    accounts,\n  );\n  const recipient = formRecipient || connectedDestAddress || '';\n\n  const quotedCallsAddress = originToken\n    ? chainAddresses[originToken.chainName]?.quotedCalls\n    : undefined;\n\n  const isEvm = originToken?.protocol === ProtocolType.Ethereum;\n  // TODO: cross-collateral routes need command='transferRemoteTo' + targetRouter\n  // wired through to /api/quote. Short-circuit until that's done so they fall\n  // through to the onchain quoting path instead of getting an incorrect quote.\n  const isCrossCollateral =\n    !!originToken &&\n    !!destinationToken &&\n    warpCore.isCrossCollateralTransfer(originToken, destinationToken);\n  const isFormValid = !!(originToken && destination && debouncedAmount && recipient && sender);\n  const shouldFetch =\n    enabled &&\n    isFormValid &&\n    isEvm &&\n    !isCrossCollateral &&\n    !!config.feeQuotingUrl &&\n    !!quotedCallsAddress;\n\n  const { isLoading, isFetching, data, refetch } = useQuery({\n    // eslint-disable-next-line @tanstack/query/exhaustive-deps -- queryFn also\n    // closes over (warpCore, originToken, destinationToken, destination) which\n    // are class instances and can't be safely stringified into a query key.\n    // (originToken, destinationToken, destination) identity is covered by the\n    // *Key proxies (originTokenKey, destinationTokenKey) already in the key;\n    // warpCore is a store singleton that's stable for the session.\n    queryKey: [\n      'useQuotedCallsFeeQuotes',\n      originTokenKey,\n      destinationTokenKey,\n      // addressOrDenom is the actual router used in the API call; include it so\n      // a token swap that keeps the *Key proxy stable still busts the cache.\n      originToken?.addressOrDenom,\n      destinationToken?.addressOrDenom,\n      sender,\n      debouncedAmount,\n      recipient,\n      quotedCallsAddress,\n    ],\n    queryFn: () =>\n      fetchQuotedCallsFees(\n        warpCore,\n        quotedCallsAddress,\n        originToken,\n        destinationToken,\n        destination,\n        sender,\n        debouncedAmount,\n        recipient,\n      ),\n    enabled: shouldFetch,\n    refetchInterval: FEE_QUOTE_REFRESH_INTERVAL,\n  });\n\n  // useQuery keeps `data` after `enabled` flips false (stale form) and after\n  // failed refetches (stale issuedAt). Suppress the cached value in both cases\n  // so the consumer doesn't replay an expired quote. When stale and a fresh\n  // fetch is in flight, report isLoading: isFetching so the form stays in\n  // offchain mode; once retries are exhausted (or interval is paused),\n  // isFetching → false and the consumer falls back to onchain quoting.\n  const isStale = !!data && Date.now() - data.issuedAt > MAX_QUOTE_AGE_MS;\n  const effectiveLoading = !shouldFetch || !data || isStale ? isFetching : isLoading;\n\n  const getQuotedCallsParams = async (): Promise<QuotedCallsParams | null> => {\n    if (!effectiveLoading) {\n      return !shouldFetch || !data || isStale ? null : data.quotedCallsParams;\n    }\n    const result = await refetch();\n    return result.data?.quotedCallsParams ?? null;\n  };\n\n  if (!shouldFetch || !data || isStale) {\n    return {\n      isLoading: effectiveLoading,\n      fees: null,\n      quotedCallsParams: null,\n      getQuotedCallsParams,\n    };\n  }\n  return {\n    isLoading,\n    fees: {\n      interchainQuote: data.interchainQuote,\n      localQuote: data.localQuote,\n      tokenFeeQuote: data.tokenFeeQuote,\n    },\n    quotedCallsParams: data.quotedCallsParams,\n    getQuotedCallsParams,\n  };\n}\n\nfunction generateClientSalt(): Hex {\n  const bytes = new Uint8Array(32);\n  crypto.getRandomValues(bytes);\n  return toHex(bytes);\n}\n\nasync function fetchQuotedCallsFees(\n  warpCore: WarpCore,\n  quotedCallsAddress: Address | undefined,\n  originToken: Token | undefined,\n  destinationToken: IToken | undefined,\n  destination: string | undefined,\n  sender: string | undefined,\n  amount: string | undefined,\n  recipient: string | undefined,\n): Promise<QuotedCallsFetchResult | null> {\n  if (\n    !originToken ||\n    !destinationToken ||\n    !destination ||\n    !sender ||\n    !amount ||\n    !recipient ||\n    !quotedCallsAddress\n  )\n    return null;\n\n  // Predicate routes take the wrapper path at submit time (see useTokenTransfer\n  // where quotedCalls is dropped when an attestation is present). Skip the\n  // offchain quote here so ReviewDetails falls back to onchain quoting and\n  // previews the same path the user will actually sign. The adapter memoizes\n  // the wrapper lookup, so this is one RPC on first quote and free thereafter.\n  if (await warpCore.isPredicateSupported(originToken, destination)) return null;\n\n  const amountWei = toWei(amount, originToken.decimals);\n  const originTokenAmount = originToken.amount(amountWei);\n\n  // Generate per-attempt salt and scope it to the sender so the on-chain\n  // QuotedCalls contract can verify keccak256(msg.sender, clientSalt) == quote.salt.\n  const clientSalt = generateClientSalt();\n  const salt = computeScopedSalt(sender as Address, clientSalt);\n\n  // Get destination domain ID\n  const destinationDomainId = warpCore.multiProvider.getDomainId(destination);\n\n  // Fetch quotes from API proxy\n  const recipientBytes32 = addressToBytes32(recipient) as Hex;\n  const params = new URLSearchParams({\n    command: 'transferRemote',\n    origin: originToken.chainName,\n    router: originToken.addressOrDenom,\n    destination: String(destinationDomainId),\n    salt,\n    recipient: recipientBytes32,\n  });\n\n  logger.debug('Fetching offchain fee quotes');\n  const res = await fetch(`/api/quote?${params}`);\n  if (!res.ok) {\n    const body = await res.json().catch(() => ({}));\n    throw new Error(\n      `Fee quote proxy failed (${res.status}): ${(body as any).message ?? res.statusText}`,\n    );\n  }\n\n  const { quotes } = (await res.json()) as { quotes: SubmitQuoteCommand[] };\n\n  // Build QuotedCallsParams (without feeQuotes — they come from the quoteExecute below)\n  const baseQuotedCallsParams: QuotedCallsParams = {\n    address: quotedCallsAddress,\n    quotes: quotes as SubmitQuoteCommand[],\n    clientSalt,\n    tokenPullMode: TokenPullMode.TransferFrom,\n  };\n\n  // Get fee estimates via quoteExecute eth_call\n  const { igpQuote, tokenFeeQuote, feeQuotes } = await warpCore.getQuotedTransferFee({\n    originTokenAmount,\n    destination,\n    sender,\n    recipient,\n    quotedCalls: baseQuotedCallsParams,\n  });\n\n  // Attach feeQuotes for later use in getTransferRemoteTxs (avoids re-quoting).\n  const quotedCallsParams: QuotedCallsParams = { ...baseQuotedCallsParams, feeQuotes };\n\n  // Estimate local gas for the actual QuotedCalls.execute() tx so the UI\n  // pre-shows the gas cost the user will see in MetaMask. No silent fallback —\n  // a throw here drops the whole offchain result (fees + quotedCallsParams),\n  // so the consumer falls through to the plain transferRemote path with its\n  // own matching local-gas estimate.\n  const localQuote = await warpCore.getLocalTransferFeeAmount({\n    originToken,\n    destination,\n    sender,\n    interchainFee: igpQuote,\n    tokenFeeQuote,\n    amount: originTokenAmount.amount,\n    destinationToken,\n    quotedCalls: quotedCallsParams,\n  });\n\n  return {\n    interchainQuote: igpQuote,\n    localQuote,\n    tokenFeeQuote,\n    quotedCallsParams,\n    issuedAt: Date.now(),\n  };\n}\n"
  },
  {
    "path": "src/features/transfer/useTokenTransfer.ts",
    "content": "import {\n  ProviderType,\n  QuotedCallsParams,\n  Token,\n  TypedTransactionReceipt,\n  WarpCore,\n  WarpTxCategory,\n} from '@hyperlane-xyz/sdk';\nimport { toTitleCase, toWei } from '@hyperlane-xyz/utils';\nimport {\n  getAccountAddressForChain,\n  useAccounts,\n  useActiveChains,\n  useTransactionFns,\n} from '@hyperlane-xyz/widgets/walletIntegrations/multiProtocol';\nimport { useCallback, useState } from 'react';\nimport { toast } from 'react-toastify';\n\nimport { toastTxSuccess } from '../../components/toast/TxSuccessToast';\nimport { logger } from '../../utils/logger';\nimport { refinerIdentifyAndShowTransferForm } from '../analytics/refiner';\nimport { EVENT_NAME } from '../analytics/types';\nimport { trackEvent } from '../analytics/utils';\nimport { useMultiProvider } from '../chains/hooks';\nimport { getChainDisplayName } from '../chains/utils';\nimport { AppState, useStore } from '../store';\nimport { getTokenByKey, useWarpCore } from '../tokens/hooks';\nimport { findConnectedDestinationToken } from '../tokens/utils';\nimport { fetchPredicateAttestation, PredicateAttestationResult } from './predicate';\nimport { submitToRelayApi } from './relayApi';\nimport { TransferContext, TransferFormValues, TransferStatus } from './types';\nimport { tryGetMsgIdFromTransferReceipt } from './utils';\n\nconst CHAIN_MISMATCH_ERROR = 'ChainMismatchError';\nconst TRANSFER_TIMEOUT_ERROR1 = 'block height exceeded';\nconst TRANSFER_TIMEOUT_ERROR2 = 'timeout';\n\nexport function useTokenTransfer(onDone?: () => void) {\n  const { transfers, addTransfer, updateTransferStatus } = useStore((s) => ({\n    transfers: s.transfers,\n    addTransfer: s.addTransfer,\n    updateTransferStatus: s.updateTransferStatus,\n  }));\n  const transferIndex = transfers.length;\n\n  const multiProvider = useMultiProvider();\n  const warpCore = useWarpCore();\n\n  const activeAccounts = useAccounts(multiProvider);\n  const activeChains = useActiveChains(multiProvider);\n  const transactionFns = useTransactionFns(multiProvider);\n\n  const [isLoading, setIsLoading] = useState(false);\n\n  // TODO implement cancel callback for when modal is closed?\n  const triggerTransactions = useCallback(\n    (\n      values: TransferFormValues,\n      routeOverrideToken: Token | null,\n      quotedCallsParams?: QuotedCallsParams | null,\n    ) =>\n      executeTransfer({\n        warpCore,\n        values,\n        transferIndex,\n        activeAccounts,\n        activeChains,\n        transactionFns,\n        addTransfer,\n        updateTransferStatus,\n        setIsLoading,\n        onDone,\n        routeOverrideToken,\n        quotedCallsParams: quotedCallsParams ?? undefined,\n      }),\n    [\n      warpCore,\n      transferIndex,\n      activeAccounts,\n      activeChains,\n      transactionFns,\n      setIsLoading,\n      addTransfer,\n      updateTransferStatus,\n      onDone,\n    ],\n  );\n\n  return {\n    isLoading,\n    triggerTransactions,\n  };\n}\n\nasync function executeTransfer({\n  warpCore,\n  values,\n  transferIndex,\n  activeAccounts,\n  activeChains,\n  transactionFns,\n  addTransfer,\n  updateTransferStatus,\n  setIsLoading,\n  onDone,\n  routeOverrideToken,\n  quotedCallsParams,\n}: {\n  warpCore: WarpCore;\n  values: TransferFormValues;\n  transferIndex: number;\n  activeAccounts: ReturnType<typeof useAccounts>;\n  activeChains: ReturnType<typeof useActiveChains>;\n  transactionFns: ReturnType<typeof useTransactionFns>;\n  addTransfer: (t: TransferContext) => void;\n  updateTransferStatus: AppState['updateTransferStatus'];\n  setIsLoading: (b: boolean) => void;\n  onDone?: () => void;\n  routeOverrideToken: Token | null;\n  quotedCallsParams?: QuotedCallsParams;\n}) {\n  logger.debug('Preparing transfer transaction(s)');\n  setIsLoading(true);\n  let transferStatus: TransferStatus = TransferStatus.Preparing;\n  updateTransferStatus(transferIndex, transferStatus);\n\n  const { originTokenKey, destinationTokenKey, amount, recipient: formRecipient } = values;\n  const multiProvider = warpCore.multiProvider;\n\n  try {\n    const originToken = routeOverrideToken || getTokenByKey(warpCore.tokens, originTokenKey);\n    const destinationToken = getTokenByKey(warpCore.tokens, destinationTokenKey);\n    if (!originToken || !destinationToken) throw new Error('No token route found between chains');\n\n    // Get effective recipient (form value or fallback to connected wallet for destination)\n    const connectedDestAddress = getAccountAddressForChain(\n      multiProvider,\n      destinationToken.chainName,\n      activeAccounts.accounts,\n    );\n    const recipient = formRecipient || connectedDestAddress || '';\n    if (!recipient) throw new Error('No recipient address available');\n    // Resolve the connected destination token that matches the selected destination token.\n    const connectedDestinationToken = findConnectedDestinationToken(originToken, destinationToken);\n    if (!connectedDestinationToken) throw new Error('No token connection found between chains');\n    const origin = originToken.chainName;\n    const destination = connectedDestinationToken.chainName;\n\n    const originProtocol = originToken.protocol;\n    const isNft = originToken.isNft();\n    const weiAmountOrId = isNft ? amount : toWei(amount, originToken.decimals);\n    const originTokenAmount = originToken.amount(weiAmountOrId);\n\n    const sendTransaction = transactionFns[originProtocol].sendTransaction;\n    const sendMultiTransaction = transactionFns[originProtocol].sendMultiTransaction;\n    const activeChain = activeChains.chains[originProtocol];\n    const sender = getAccountAddressForChain(multiProvider, origin, activeAccounts.accounts);\n    if (!sender) throw new Error('No active account found for origin chain');\n\n    const isCollateralSufficient = await warpCore.isDestinationCollateralSufficient({\n      originTokenAmount,\n      destination,\n      destinationToken: connectedDestinationToken,\n    });\n    if (!isCollateralSufficient) {\n      toast.error('Insufficient collateral on destination for transfer');\n      throw new Error('Insufficient destination collateral');\n    }\n\n    addTransfer({\n      timestamp: new Date().getTime(),\n      status: TransferStatus.Preparing,\n      origin,\n      destination,\n      originTokenAddressOrDenom: originToken.addressOrDenom,\n      destTokenAddressOrDenom: connectedDestinationToken.addressOrDenom,\n      sender,\n      recipient,\n      amount,\n    });\n\n    updateTransferStatus(transferIndex, (transferStatus = TransferStatus.CreatingTxs));\n\n    // Check if Predicate attestation is needed\n    let attestationResult: PredicateAttestationResult | undefined;\n\n    try {\n      updateTransferStatus(transferIndex, (transferStatus = TransferStatus.FetchingAttestation));\n      attestationResult = await fetchPredicateAttestation({\n        warpCore,\n        token: originToken,\n        destination,\n        sender,\n        recipient,\n        amount: originTokenAmount,\n        destinationToken: connectedDestinationToken,\n      });\n    } catch (error) {\n      const message = error instanceof Error ? error.message : 'Unknown error';\n      logger.error('Predicate attestation error:', error);\n      throw new Error(`Predicate attestation failed: ${message}`);\n    }\n\n    updateTransferStatus(transferIndex, (transferStatus = TransferStatus.CreatingTxs));\n\n    const txs = await warpCore.getTransferRemoteTxs({\n      originTokenAmount,\n      destination,\n      sender,\n      recipient,\n      // quotedCalls and attestation are mutually exclusive in the SDK; predicate\n      // routes take the wrapper path, plain routes can use offchain quoting.\n      quotedCalls: attestationResult ? undefined : quotedCallsParams,\n      attestation: attestationResult?.attestation,\n      // Pin the IGP quote captured at attestation time so msg_value matches the\n      // attested Statement preimage — prevents _authorizeTransaction revert on drift.\n      interchainFee: attestationResult?.interchainFee,\n      tokenFeeQuote: attestationResult?.tokenFeeQuote,\n      destinationToken: connectedDestinationToken,\n    });\n\n    const hashes: string[] = [];\n    let txReceipt: TypedTransactionReceipt | undefined = undefined;\n\n    if (txs.length > 1 && txs.every((tx) => tx.type === ProviderType.Starknet)) {\n      updateTransferStatus(\n        transferIndex,\n        (transferStatus = txCategoryToStatuses[WarpTxCategory.Transfer][0]),\n      );\n      const { hash, confirm } = await sendMultiTransaction({\n        txs,\n        chainName: origin,\n        activeChainName: activeChain.chainName,\n      });\n      updateTransferStatus(\n        transferIndex,\n        (transferStatus = txCategoryToStatuses[WarpTxCategory.Transfer][1]),\n      );\n      txReceipt = await confirm();\n      const description = toTitleCase(WarpTxCategory.Transfer);\n      logger.debug(`${description} transaction confirmed, hash:`, hash);\n      toastTxSuccess(`${description} transaction sent!`, hash, origin);\n\n      hashes.push(hash);\n    } else {\n      for (const tx of txs) {\n        updateTransferStatus(\n          transferIndex,\n          (transferStatus = txCategoryToStatuses[tx.category][0]),\n        );\n        const { hash, confirm } = await sendTransaction({\n          tx,\n          chainName: origin,\n          activeChainName: activeChain.chainName,\n        });\n        updateTransferStatus(\n          transferIndex,\n          (transferStatus = txCategoryToStatuses[tx.category][1]),\n        );\n        txReceipt = await confirm();\n        const description = toTitleCase(tx.category);\n        logger.debug(`${description} transaction confirmed, hash:`, hash);\n        toastTxSuccess(`${description} transaction sent!`, hash, origin);\n\n        hashes.push(hash);\n      }\n    }\n\n    const msgId = txReceipt\n      ? tryGetMsgIdFromTransferReceipt(multiProvider, origin, txReceipt)\n      : undefined;\n\n    const originTxHash = hashes.at(-1);\n    const originBlockNumber =\n      txReceipt?.receipt && 'blockNumber' in txReceipt.receipt\n        ? Number(txReceipt.receipt.blockNumber)\n        : undefined;\n    updateTransferStatus(transferIndex, (transferStatus = TransferStatus.ConfirmedTransfer), {\n      originTxHash,\n      originBlockNumber,\n      msgId,\n    });\n\n    if (originTxHash) submitToRelayApi(origin, originTxHash, originProtocol, txReceipt);\n\n    // track event after tx submission\n    const originChainId = warpCore.multiProvider.getChainId(origin);\n    const destinationChainId = warpCore.multiProvider.getChainId(destination);\n    trackEvent(EVENT_NAME.TRANSACTION_SUBMITTED, {\n      amount,\n      recipient,\n      chains: `${origin}|${originChainId}|${destination}|${destinationChainId}`,\n      tokenAddress: originToken.addressOrDenom,\n      tokenSymbol: originToken.symbol,\n      walletAddress: sender,\n      transactionHash: originTxHash || '',\n    });\n\n    // Identify user and show Refiner survey form after successful transfer\n    refinerIdentifyAndShowTransferForm({\n      walletAddress: sender,\n      protocol: originProtocol,\n      chain: origin,\n    });\n  } catch (error: any) {\n    logger.error(`Error at stage ${transferStatus}`, error);\n    const errorDetails = error.message || error.toString();\n    updateTransferStatus(transferIndex, TransferStatus.Failed);\n    if (errorDetails.includes(CHAIN_MISMATCH_ERROR)) {\n      // Wagmi switchNetwork call helps prevent this but isn't foolproof\n      toast.error('Wallet must be connected to origin chain');\n    } else if (\n      errorDetails.includes(TRANSFER_TIMEOUT_ERROR1) ||\n      errorDetails.includes(TRANSFER_TIMEOUT_ERROR2)\n    ) {\n      toast.error(\n        `Transaction timed out, ${getChainDisplayName(multiProvider, origin)} may be busy. Please try again.`,\n      );\n    } else {\n      toast.error(errorMessages[transferStatus] || 'Unable to transfer tokens.');\n    }\n  }\n\n  setIsLoading(false);\n  if (onDone) onDone();\n}\n\nconst errorMessages: Partial<Record<TransferStatus, string>> = {\n  [TransferStatus.Preparing]: 'Error while preparing the transactions.',\n  [TransferStatus.CreatingTxs]: 'Error while creating the transactions.',\n  [TransferStatus.FetchingAttestation]: 'Error while fetching compliance attestation.',\n  [TransferStatus.SigningApprove]: 'Error while signing the approve transaction.',\n  [TransferStatus.ConfirmingApprove]: 'Error while confirming the approve transaction.',\n  [TransferStatus.SigningTransfer]: 'Error while signing the transfer transaction.',\n  [TransferStatus.ConfirmingTransfer]: 'Error while confirming the transfer transaction.',\n};\n\nconst txCategoryToStatuses: Record<WarpTxCategory, [TransferStatus, TransferStatus]> = {\n  [WarpTxCategory.Approval]: [TransferStatus.SigningApprove, TransferStatus.ConfirmingApprove],\n  [WarpTxCategory.Revoke]: [TransferStatus.SigningRevoke, TransferStatus.ConfirmingRevoke],\n  [WarpTxCategory.Transfer]: [TransferStatus.SigningTransfer, TransferStatus.ConfirmingTransfer],\n};\n"
  },
  {
    "path": "src/features/transfer/utils.test.ts",
    "content": "import { describe, expect, test, vi } from 'vitest';\n\nimport { estimateDeliverySeconds, formatEta } from './utils';\n\ndescribe('formatEta', () => {\n  test('returns seconds for values under 60', () => {\n    expect(formatEta(30)).toBe('~30s');\n    expect(formatEta(1)).toBe('~1s');\n    expect(formatEta(59)).toBe('~59s');\n  });\n\n  test('returns minutes at 60s boundary', () => {\n    expect(formatEta(60)).toBe('~1 min');\n  });\n\n  test('rounds up to nearest minute', () => {\n    expect(formatEta(61)).toBe('~2 min');\n    expect(formatEta(120)).toBe('~2 min');\n    expect(formatEta(150)).toBe('~3 min');\n  });\n});\n\ndescribe('estimateDeliverySeconds', () => {\n  const mockMultiProvider = {\n    tryGetChainMetadata: vi.fn(),\n  } as any;\n\n  test('returns null when origin metadata is missing', () => {\n    mockMultiProvider.tryGetChainMetadata.mockReturnValue(null);\n    expect(estimateDeliverySeconds('origin', 'dest', mockMultiProvider)).toBeNull();\n  });\n\n  test('returns null when destination metadata is missing', () => {\n    mockMultiProvider.tryGetChainMetadata\n      .mockReturnValueOnce({ blocks: {} })\n      .mockReturnValueOnce(null);\n    expect(estimateDeliverySeconds('origin', 'dest', mockMultiProvider)).toBeNull();\n  });\n\n  test('uses defaults when block metadata is sparse', () => {\n    mockMultiProvider.tryGetChainMetadata.mockReturnValue({ blocks: {} });\n    const result = estimateDeliverySeconds('origin', 'dest', mockMultiProvider);\n    // defaults: blockTime=3, confirmations=3, reorgBlocks=0\n    // finalityTime = (3 + 0) * 3 = 9, validation = 5, relay = 3 * 1.5 = 4.5\n    // total = ceil(9 + 5 + 4.5) = 19\n    expect(result).toBe(19);\n  });\n\n  test('uses numeric reorgPeriod', () => {\n    mockMultiProvider.tryGetChainMetadata.mockReturnValue({\n      blocks: { estimateBlockTime: 2, confirmations: 5, reorgPeriod: 10 },\n    });\n    // finalityTime = (5 + 10) * 2 = 30, validation = 5, relay = 2 * 1.5 = 3\n    // total = ceil(30 + 5 + 3) = 38\n    expect(estimateDeliverySeconds('origin', 'dest', mockMultiProvider)).toBe(38);\n  });\n\n  test('ignores string reorgPeriod like \"finalized\"', () => {\n    mockMultiProvider.tryGetChainMetadata.mockReturnValue({\n      blocks: { estimateBlockTime: 2, confirmations: 5, reorgPeriod: 'finalized' },\n    });\n    // reorgBlocks = 0 (string ignored), finalityTime = (5 + 0) * 2 = 10\n    // validation = 5, relay = 2 * 1.5 = 3, total = ceil(10 + 5 + 3) = 18\n    expect(estimateDeliverySeconds('origin', 'dest', mockMultiProvider)).toBe(18);\n  });\n});\n"
  },
  {
    "path": "src/features/transfer/utils.ts",
    "content": "import {\n  ChainMap,\n  CoreAddresses,\n  MultiProtocolCore,\n  ProviderType,\n  TypedTransactionReceipt,\n  ViemProvider,\n} from '@hyperlane-xyz/sdk';\nimport { isValidAddress, isValidAddressEvm } from '@hyperlane-xyz/utils';\nimport { getAddress } from 'viem';\n\nimport ConfirmedIcon from '../../images/icons/confirmed-icon.svg';\nimport ErrorCircleIcon from '../../images/icons/error-circle.svg';\nimport { logger } from '../../utils/logger';\nimport { getChainDisplayName } from '../chains/utils';\nimport { FinalTransferStatuses, SentTransferStatuses, TransferStatus } from './types';\n\ntype MultiProvider = MultiProtocolCore['multiProvider'];\n\nexport function getTransferStatusLabel(\n  status: TransferStatus,\n  connectorName: string,\n  isPermissionlessRoute: boolean,\n  isAccountReady: boolean,\n) {\n  let statusDescription = '...';\n  if (!isAccountReady && !FinalTransferStatuses.includes(status))\n    statusDescription = 'Please connect wallet to continue';\n  else if (status === TransferStatus.Preparing)\n    statusDescription = 'Preparing for token transfer...';\n  else if (status === TransferStatus.CreatingTxs) statusDescription = 'Creating transactions...';\n  else if (status === TransferStatus.FetchingAttestation)\n    statusDescription = 'Verifying compliance attestation...';\n  else if (status === TransferStatus.SigningApprove)\n    statusDescription = `Sign approve transaction in ${connectorName} to continue.`;\n  else if (status === TransferStatus.ConfirmingApprove)\n    statusDescription = 'Confirming approve transaction...';\n  else if (status === TransferStatus.SigningRevoke)\n    statusDescription = `Sign revoke transaction in ${connectorName} to continue.`;\n  else if (status === TransferStatus.ConfirmingRevoke)\n    statusDescription = 'Confirming revoke transaction...';\n  else if (status === TransferStatus.SigningTransfer)\n    statusDescription = `Sign transfer transaction in ${connectorName} to continue.`;\n  else if (status === TransferStatus.ConfirmingTransfer)\n    statusDescription = 'Confirming transfer transaction...';\n  else if (status === TransferStatus.ConfirmedTransfer)\n    if (!isPermissionlessRoute)\n      statusDescription = 'Transfer transaction confirmed, delivering message...';\n    else\n      statusDescription =\n        'Transfer confirmed, the funds will arrive when the message is delivered.';\n  else if (status === TransferStatus.Delivered)\n    statusDescription = 'Delivery complete, transfer successful!';\n  else if (status === TransferStatus.Failed)\n    statusDescription = 'Transfer failed, please try again.';\n\n  return statusDescription;\n}\n\nexport function isTransferSent(status: TransferStatus) {\n  return SentTransferStatuses.includes(status);\n}\n\nexport function isTransferFailed(status: TransferStatus) {\n  return status === TransferStatus.Failed;\n}\n\nexport const STATUSES_WITH_ICON = [\n  TransferStatus.Delivered,\n  TransferStatus.ConfirmedTransfer,\n  TransferStatus.Failed,\n];\n\nexport function getIconByTransferStatus(status: TransferStatus) {\n  switch (status) {\n    case TransferStatus.Delivered:\n    case TransferStatus.ConfirmedTransfer:\n      return ConfirmedIcon;\n    case TransferStatus.Failed:\n      return ErrorCircleIcon;\n    default:\n      return ErrorCircleIcon;\n  }\n}\n\nexport function tryGetMsgIdFromTransferReceipt(\n  multiProvider: MultiProvider,\n  origin: ChainName,\n  receipt: TypedTransactionReceipt,\n) {\n  try {\n    // IBC transfers have no message IDs\n    if (receipt.type === ProviderType.CosmJs) return undefined;\n\n    if (receipt.type === ProviderType.Starknet) {\n      receipt = {\n        type: ProviderType.Starknet,\n        receipt: receipt.receipt as any,\n      };\n    }\n\n    if (receipt.type === ProviderType.Viem) {\n      // Massage viem type into ethers type because that's still what the\n      // SDK expects. In this case they're compatible.\n      receipt = {\n        type: ProviderType.EthersV5,\n        receipt: receipt.receipt as any,\n      };\n    }\n\n    const addressStubs = multiProvider\n      .getKnownChainNames()\n      .reduce<ChainMap<CoreAddresses>>((acc, chainName) => {\n        // Actual core addresses not required for the id extraction\n        acc[chainName] = {\n          validatorAnnounce: '',\n          proxyAdmin: '',\n          mailbox: '',\n          quotedCalls: '',\n        };\n        return acc;\n      }, {});\n    const core = new MultiProtocolCore(multiProvider, addressStubs);\n    const messages = core.extractMessageIds(origin, receipt);\n    if (messages.length) {\n      const msgId = messages[0].messageId;\n      logger.debug('Message id found in logs', msgId);\n      return msgId;\n    } else {\n      logger.warn('No messages found in logs');\n      return undefined;\n    }\n  } catch (error) {\n    logger.error('Could not get msgId from transfer receipt', error);\n    return undefined;\n  }\n}\n\nexport async function isEvmContractAddress(\n  viemProvider: ViemProvider['provider'],\n  address: string,\n): Promise<\n  { isContractAddress: false; code: undefined } | { isContractAddress: true; code: string }\n> {\n  const code = await viemProvider.getCode({ address: getAddress(address) });\n  if (!code || code === '0x') {\n    return { isContractAddress: false, code: undefined };\n  }\n  return { isContractAddress: true, code };\n}\n\nconst eip7702AccountSelector = '0xef0100';\nexport async function isSmartContract(\n  multiProvider: MultiProvider,\n  chain: string,\n  address: string,\n): Promise<{ isContract: boolean; error?: string }> {\n  if (!isValidAddressEvm(address)) {\n    return { isContract: false };\n  }\n\n  try {\n    const provider = multiProvider.getViemProvider(chain);\n\n    if (!provider) {\n      throw new Error(`No viem provider for chain ${chain}`);\n    }\n\n    const { isContractAddress, code } = await isEvmContractAddress(provider, address);\n\n    if (!isContractAddress && !code) return { isContract: false };\n\n    // Checks if an address is also an EIP-7702 which is a smart account but not an smart contract\n    // It would technically be correct to check if the delegated contract address is also a valid\n    // contract address, but for our use case which is showing a banner to warn users\n    // if the address is a Smart Contract, this wouldn't be necessary since `0xef0100`\n    // is only reserved for Smart Accounts\n    if (code.startsWith(eip7702AccountSelector)) return { isContract: false };\n\n    return { isContract: true };\n  } catch (error) {\n    const msg = `Error checking if ${address} is a smart contract on ${getChainDisplayName(multiProvider, chain)}`;\n    logger.error(msg, error);\n    return { isContract: false, error: msg };\n  }\n}\n\nconst VALIDATION_TIME_EST = 5; // seconds\nconst DEFAULT_BLOCK_TIME_EST = 3; // seconds\nexport const DEFAULT_FINALITY_BLOCKS = 3;\n\n/**\n * Estimate total delivery time in seconds using chain metadata.\n * Returns null if metadata is unavailable.\n */\nexport function estimateDeliverySeconds(\n  origin: ChainName,\n  destination: ChainName,\n  multiProvider: MultiProvider,\n): number | null {\n  try {\n    const originMeta = multiProvider.tryGetChainMetadata(origin);\n    const destMeta = multiProvider.tryGetChainMetadata(destination);\n    if (!originMeta || !destMeta) return null;\n\n    const originBlockTime = originMeta.blocks?.estimateBlockTime ?? DEFAULT_BLOCK_TIME_EST;\n    const destBlockTime = destMeta.blocks?.estimateBlockTime ?? DEFAULT_BLOCK_TIME_EST;\n    const confirmations = originMeta.blocks?.confirmations ?? DEFAULT_FINALITY_BLOCKS;\n\n    // reorgPeriod can be a number or string block tag like \"finalized\"\n    let reorgBlocks = 0;\n    const reorgPeriod = originMeta.blocks?.reorgPeriod;\n    if (typeof reorgPeriod === 'number') reorgBlocks = reorgPeriod;\n\n    const finalityTime = (confirmations + reorgBlocks) * originBlockTime;\n    const relayTime = destBlockTime * 1.5;\n\n    return Math.ceil(finalityTime + VALIDATION_TIME_EST + relayTime);\n  } catch (error) {\n    logger.error('Failed to estimate delivery ETA', error);\n    return null;\n  }\n}\n\n/**\n * Format seconds into a human-readable ETA string.\n */\nexport function formatEta(seconds: number): string {\n  if (seconds < 60) return `~${seconds}s`;\n  const minutes = Math.ceil(seconds / 60);\n  return `~${minutes} min`;\n}\n\n// Returns if the recipient should be cleared by checking if it is valid address from the current chain protocol\nexport function shouldClearAddress(\n  multiProvider: MultiProvider,\n  recipient: string,\n  chainName: string,\n) {\n  const protocol = multiProvider.tryGetProtocol(chainName);\n  if (recipient && protocol && !isValidAddress(recipient, protocol)) return true;\n  return false;\n}\n"
  },
  {
    "path": "src/features/wallet/ConnectWalletButton.tsx",
    "content": "import { ConnectWalletButton as ConnectWalletButtonInner } from '@hyperlane-xyz/widgets/walletIntegrations/ConnectWalletButton';\n\nimport { useMultiProvider } from '../chains/hooks';\nimport { useStore } from '../store';\n\nexport function ConnectWalletButton() {\n  const multiProvider = useMultiProvider();\n  const { originChainName } = useStore((s) => ({\n    originChainName: s.originChainName,\n  }));\n\n  const { setShowEnvSelectModal, setIsSideBarOpen } = useStore((s) => ({\n    setShowEnvSelectModal: s.setShowEnvSelectModal,\n    setIsSideBarOpen: s.setIsSideBarOpen,\n  }));\n\n  return (\n    <ConnectWalletButtonInner\n      multiProvider={multiProvider}\n      onClickWhenUnconnected={() => setShowEnvSelectModal(true)}\n      onClickWhenConnected={() => setIsSideBarOpen(true)}\n      className=\"rounded-lg bg-accent-gradient shadow-accent-glow [&_*]:text-white [&_path]:fill-white\"\n      countClassName=\"bg-white/20\"\n      chainName={originChainName}\n    />\n  );\n}\n"
  },
  {
    "path": "src/features/wallet/RecipientAddressModal.tsx",
    "content": "import { isValidAddress, ProtocolType } from '@hyperlane-xyz/utils';\nimport { Modal, XIcon } from '@hyperlane-xyz/widgets';\nimport { useState } from 'react';\n\nimport { SolidButton } from '../../components/buttons/SolidButton';\n\ninterface RecipientAddressModalProps {\n  isOpen: boolean;\n  close: () => void;\n  onSave: (address: string) => void;\n  initialValue?: string;\n  protocol?: ProtocolType;\n}\n\nexport function RecipientAddressModal({\n  isOpen,\n  close,\n  onSave,\n  initialValue = '',\n  protocol = ProtocolType.Ethereum,\n}: RecipientAddressModalProps) {\n  const [address, setAddress] = useState(initialValue);\n  const [error, setError] = useState('');\n\n  const handleSave = () => {\n    const trimmedAddress = address.trim();\n    if (!trimmedAddress) return;\n\n    if (!isValidAddress(trimmedAddress, protocol)) {\n      setError('Invalid address');\n      return;\n    }\n\n    setError('');\n    onSave(trimmedAddress);\n    close();\n  };\n\n  const handleClose = () => {\n    setAddress(initialValue);\n    setError('');\n    close();\n  };\n\n  const handleAddressChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    setAddress(e.target.value);\n    if (error) setError('');\n  };\n\n  return (\n    <Modal isOpen={isOpen} close={handleClose} panelClassname=\"max-w-sm p-0 overflow-hidden\">\n      <div className=\"flex items-center justify-between px-4 py-3\">\n        <h2 className=\"text-lg font-medium text-gray-900\">Receive Address</h2>\n        <button\n          type=\"button\"\n          aria-label=\"Close\"\n          onClick={handleClose}\n          className=\"text-gray-400 transition-colors hover:text-gray-600\"\n        >\n          <XIcon width={12} height={12} />\n        </button>\n      </div>\n      <div className=\"px-4 pb-4\">\n        <input\n          type=\"text\"\n          value={address}\n          onChange={handleAddressChange}\n          placeholder=\"Paste Wallet Address\"\n          className={`w-full rounded-lg border px-4 py-3 text-sm text-gray-700 placeholder:text-gray-400 focus:outline-none ${\n            error\n              ? 'border-red-500 focus:border-red-500'\n              : 'border-gray-300 focus:border-primary-500'\n          }`}\n        />\n        {error && <p className=\"mt-1 text-sm text-red-500\">{error}</p>}\n        <SolidButton\n          type=\"button\"\n          color=\"primary\"\n          onClick={handleSave}\n          className=\"mt-4 w-full py-3 text-base\"\n          disabled={!address.trim()}\n        >\n          Save\n        </SolidButton>\n      </div>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "src/features/wallet/SideBarMenu.tsx",
    "content": "import { normalizeAddress } from '@hyperlane-xyz/utils';\nimport { RefreshIcon, SpinnerIcon } from '@hyperlane-xyz/widgets';\nimport { AccountList } from '@hyperlane-xyz/widgets/walletIntegrations/AccountList';\nimport { useAccounts } from '@hyperlane-xyz/widgets/walletIntegrations/multiProtocol';\nimport Image from 'next/image';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { toast } from 'react-toastify';\n\nimport { ChainLogo } from '../../components/icons/ChainLogo';\nimport { config } from '../../consts/config';\nimport ArrowRightIcon from '../../images/icons/arrow-right.svg';\nimport CollapseIcon from '../../images/icons/collapse-icon.svg';\nimport { formatTransferHistoryTimestamp } from '../../utils/date';\nimport { logger } from '../../utils/logger';\nimport { useMultiProvider } from '../chains/hooks';\nimport { getChainDisplayName } from '../chains/utils';\nimport { MessageStatus } from '../messages/types';\nimport {\n  messageToTransferContext,\n  TransferItem,\n  TransferItemType,\n  useMergedTransferHistory,\n} from '../messages/useMergedTransferHistory';\nimport { useMessageHistory } from '../messages/useMessageHistory';\nimport { useStore } from '../store';\nimport { tryFindToken, useWarpCore } from '../tokens/hooks';\nimport { TokenChainIcon } from '../tokens/TokenChainIcon';\nimport { computeDestAmount, formatMessageAmount } from '../transfer/scaleUtils';\nimport { TransfersDetailsModal } from '../transfer/TransfersDetailsModal';\nimport { TransferContext, TransferStatus } from '../transfer/types';\nimport { getIconByTransferStatus, STATUSES_WITH_ICON } from '../transfer/utils';\nimport { startRelativeTimeTicker } from './relativeTimeTicker';\n\nexport function SideBarMenu({\n  onClickConnectWallet,\n  isOpen,\n  onClose,\n}: {\n  onClickConnectWallet: () => void;\n  isOpen: boolean;\n  onClose: () => void;\n}) {\n  const didMountRef = useRef(false);\n  const scrollContainerRef = useRef<HTMLDivElement>(null);\n  const [isMenuOpen, setIsMenuOpen] = useState(false);\n  const [isModalOpen, setIsModalOpen] = useState(false);\n  const [selectedTransfer, setSelectedTransfer] = useState<TransferContext | null>(null);\n  const [nowMs, setNowMs] = useState(() => Date.now());\n\n  const multiProvider = useMultiProvider();\n\n  const { transfers, transferLoading, originChainName, routerAddressesByChainMap } = useStore(\n    (s) => ({\n      transfers: s.transfers,\n      transferLoading: s.transferLoading,\n      originChainName: s.originChainName,\n      routerAddressesByChainMap: s.routerAddressesByChainMap,\n    }),\n  );\n\n  // Get all connected wallet addresses (normalized for consistent matching)\n  const { accounts } = useAccounts(multiProvider, config.addressBlacklist);\n  const walletAddresses = useMemo(() => {\n    const addresses: string[] = [];\n    for (const accountInfo of Object.values(accounts)) {\n      if (accountInfo.addresses) {\n        for (const addrInfo of accountInfo.addresses) {\n          if (addrInfo.address) {\n            addresses.push(normalizeAddress(addrInfo.address));\n          }\n        }\n      }\n    }\n    return addresses;\n  }, [accounts]);\n\n  // Get all warp route addresses from configured routes (already normalized)\n  const warpRouteAddresses = useMemo(() => {\n    const addresses: string[] = [];\n    for (const addressSet of Object.values(routerAddressesByChainMap)) {\n      for (const addr of addressSet) {\n        addresses.push(addr);\n      }\n    }\n    return addresses;\n  }, [routerAddressesByChainMap]);\n\n  // Fetch message history from API\n  const { messages, isLoading, isRefreshing, hasMore, loadMore, refresh } = useMessageHistory(\n    walletAddresses,\n    warpRouteAddresses,\n    multiProvider,\n  );\n\n  // Merge local transfers with API messages\n  const warpCore = useWarpCore();\n  const allMergedTransfers = useMergedTransferHistory(transfers, messages);\n\n  // Filter out API messages with unknown tokens\n  const mergedTransfers = useMemo(\n    () =>\n      allMergedTransfers.filter((item) => {\n        if (item.type === TransferItemType.Local) return true;\n        const originChain = multiProvider.tryGetChainName(item.data.originDomainId);\n        if (!originChain) return false;\n        return !!tryFindToken(warpCore, originChain, item.data.sender);\n      }),\n    [allMergedTransfers, multiProvider, warpCore],\n  );\n\n  // Infinite scroll handler\n  const handleScroll = useCallback(() => {\n    const container = scrollContainerRef.current;\n    if (!container || isLoading || !hasMore) return;\n\n    const { scrollTop, scrollHeight, clientHeight } = container;\n    if (scrollHeight - scrollTop - clientHeight < 100) {\n      loadMore();\n    }\n  }, [isLoading, hasMore, loadMore]);\n\n  const onCopySuccess = () => {\n    toast.success('Address copied to clipboard', { autoClose: 2000 });\n  };\n\n  const handleItemClick = (item: TransferItem) => {\n    if (item.type === TransferItemType.Local) {\n      setSelectedTransfer(item.data);\n    } else {\n      setSelectedTransfer(messageToTransferContext(item.data, multiProvider, warpCore));\n    }\n    setIsModalOpen(true);\n  };\n\n  // Open modal for new transfer\n  useEffect(() => {\n    if (!didMountRef.current) {\n      didMountRef.current = true;\n    } else if (transferLoading) {\n      setSelectedTransfer(transfers[transfers.length - 1]);\n      setIsModalOpen(true);\n    }\n  }, [transfers, transferLoading]);\n\n  useEffect(() => {\n    setIsMenuOpen(isOpen);\n  }, [isOpen]);\n\n  useEffect(() => {\n    if (!isMenuOpen) return;\n    return startRelativeTimeTicker({\n      onTick: () => setNowMs(Date.now()),\n    });\n  }, [isMenuOpen]);\n\n  return (\n    <>\n      <div\n        className={`sidebar-menu fixed right-0 top-0 h-full w-88 transform bg-white/95 shadow-lg transition-transform duration-100 ease-in dark:border-l dark:border-primary-300/35 dark:bg-surface/95 ${\n          isMenuOpen\n            ? 'z-10 translate-x-0 dark:shadow-[-8px_0_32px_rgba(0,0,0,0.45)]'\n            : 'z-0 translate-x-full'\n        }`}\n      >\n        {isMenuOpen && (\n          <button\n            className=\"sidebar-menu-collapse absolute left-0 top-0 flex h-full w-9 -translate-x-full items-center justify-center rounded-l bg-accent-50/30 backdrop-blur-[1.5px] transition-all dark:border-r dark:border-primary-300/25 dark:bg-surface/70\"\n            onClick={() => onClose()}\n          >\n            <Image\n              src={CollapseIcon}\n              width={15}\n              height={24}\n              alt=\"\"\n              className=\"dark:opacity-85 dark:brightness-0 dark:invert\"\n            />\n          </button>\n        )}\n        <div\n          ref={scrollContainerRef}\n          onScroll={handleScroll}\n          className=\"flex h-full w-full flex-col overflow-y-auto\"\n        >\n          <div className=\"sidebar-menu-header w-full bg-accent-gradient px-3.5 py-2 text-base font-normal tracking-wider text-white shadow-accent-glow dark:!shadow-none\">\n            Connected Wallets\n          </div>\n          <AccountList\n            multiProvider={multiProvider}\n            onClickConnectWallet={onClickConnectWallet}\n            onCopySuccess={onCopySuccess}\n            className=\"\"\n            chainName={originChainName}\n          />\n          <div className=\"sidebar-menu-header flex w-full items-center justify-between bg-accent-gradient px-3.5 py-2 shadow-accent-glow dark:!shadow-none\">\n            <span className=\"text-base font-normal tracking-wider text-white\">\n              Transfer History\n            </span>\n            <button\n              onClick={refresh}\n              disabled={isLoading}\n              className=\"sidebar-menu-refresh rounded p-1 hover:bg-accent-500/50 disabled:opacity-50\"\n              title=\"Refresh\"\n            >\n              <RefreshIcon\n                width={20}\n                height={20}\n                color=\"white\"\n                className={isLoading ? 'animate-spin' : ''}\n              />\n            </button>\n          </div>\n          <div className=\"flex grow flex-col pb-4\">\n            {isRefreshing ? (\n              <div className=\"flex justify-center px-3.5 py-6\">\n                <SpinnerIcon className=\"h-5 w-5\" />\n              </div>\n            ) : (\n              <>\n                <div className=\"sidebar-menu-list flex w-full grow flex-col divide-y\">\n                  {mergedTransfers.length === 0 && !isLoading && (\n                    <div className=\"sidebar-menu-empty px-3.5 py-6 text-center text-sm text-gray-500 dark:text-foreground-primary\">\n                      No transfers yet\n                    </div>\n                  )}\n                  {mergedTransfers.map((item) => (\n                    <TransferSummary\n                      key={\n                        item.type === TransferItemType.Local\n                          ? `local-${item.data.timestamp}-${item.data.originTxHash || item.data.msgId || ''}`\n                          : `api-${item.data.msgId}`\n                      }\n                      item={item}\n                      onClick={() => handleItemClick(item)}\n                      multiProvider={multiProvider}\n                      warpCore={warpCore}\n                      nowMs={nowMs}\n                    />\n                  ))}\n                </div>\n                {isLoading && (\n                  <div className=\"flex justify-center px-3.5 py-4\">\n                    <SpinnerIcon className=\"h-5 w-5\" />\n                  </div>\n                )}\n                {!hasMore && mergedTransfers.length > 0 && (\n                  <div className=\"sidebar-menu-end px-3.5 py-3 text-center text-xs text-gray-400 dark:text-foreground-primary\">\n                    No more transfers\n                  </div>\n                )}\n              </>\n            )}\n          </div>\n        </div>\n      </div>\n      {selectedTransfer && (\n        <TransfersDetailsModal\n          isOpen={isModalOpen}\n          onClose={() => {\n            setIsModalOpen(false);\n            setSelectedTransfer(null);\n          }}\n          transfer={selectedTransfer}\n        />\n      )}\n    </>\n  );\n}\n\nfunction TransferSummary({\n  item,\n  onClick,\n  multiProvider,\n  warpCore,\n  nowMs,\n}: {\n  item: TransferItem;\n  onClick: () => void;\n  multiProvider: ReturnType<typeof useMultiProvider>;\n  warpCore: ReturnType<typeof useWarpCore>;\n  nowMs: number;\n}) {\n  const { originChain, destChain, amount, destAmount, status, token, destToken, timestamp } =\n    useMemo(() => {\n      if (item.type === TransferItemType.Local) {\n        const t = item.data;\n        const originToken = tryFindToken(warpCore, t.origin, t.originTokenAddressOrDenom);\n        const destinationToken = tryFindToken(warpCore, t.destination, t.destTokenAddressOrDenom);\n        return {\n          originChain: t.origin,\n          destChain: t.destination,\n          amount: t.amount,\n          destAmount: computeDestAmount(t.amount, originToken, destinationToken),\n          status: t.status,\n          token: originToken,\n          destToken: destinationToken,\n          timestamp: t.timestamp,\n        };\n      }\n      const msg = item.data;\n      const originChain = multiProvider.tryGetChainName(msg.originDomainId) || '';\n      const destChain = multiProvider.tryGetChainName(msg.destinationDomainId) || '';\n      const token = tryFindToken(warpCore, originChain, msg.sender);\n\n      let amount = '';\n      if (msg.warpTransfer?.amount && token) {\n        try {\n          amount = formatMessageAmount(msg.warpTransfer.amount, token);\n        } catch (err) {\n          logger.error('Failed to format warp transfer amount', err);\n        }\n      }\n\n      const destToken = tryFindToken(warpCore, destChain, msg.recipient);\n\n      return {\n        originChain,\n        destChain,\n        amount,\n        destAmount: computeDestAmount(amount, token, destToken),\n        status:\n          msg.status === MessageStatus.Delivered\n            ? TransferStatus.Delivered\n            : TransferStatus.ConfirmedTransfer,\n        token,\n        destToken,\n        timestamp: msg.origin.timestamp,\n      };\n    }, [item.type, item.data, multiProvider, warpCore]);\n\n  return (\n    <button onClick={onClick} className={`${styles.btn} justify-between py-3`}>\n      <div className=\"flex gap-2.5\">\n        <div className=\"flex h-[2.25rem] w-[2.25rem] items-center justify-center\">\n          {token ? (\n            <TokenChainIcon token={token} size={32} />\n          ) : (\n            <ChainLogo chainName={originChain} size={32} />\n          )}\n        </div>\n        <div className=\"flex flex-col\">\n          <div className=\"flex items-baseline\">\n            {amount && (\n              <span className=\"sidebar-menu-token-text text-sm font-normal text-gray-800 dark:text-foreground-primary\">\n                {amount}\n              </span>\n            )}\n            <span\n              className={`sidebar-menu-token-text text-sm font-normal text-gray-800 dark:text-foreground-primary ${amount ? 'ml-1' : ''}`}\n            >\n              {token?.symbol || 'Unknown token'}\n            </span>\n            {destToken && (\n              <>\n                <Image\n                  className=\"sidebar-menu-arrow mx-1 dark:opacity-85 dark:brightness-0 dark:invert\"\n                  src={ArrowRightIcon}\n                  width={10}\n                  height={10}\n                  alt=\"\"\n                />\n                {(destAmount || amount) && (\n                  <span className=\"sidebar-menu-token-text text-sm font-normal text-gray-800 dark:text-foreground-primary\">\n                    {destAmount || amount}\n                  </span>\n                )}\n                <span className=\"sidebar-menu-token-text ml-1 text-sm font-normal text-gray-800 dark:text-foreground-primary\">\n                  {destToken.symbol}\n                </span>\n              </>\n            )}\n          </div>\n          <div className=\"mt-1 flex items-center\">\n            <span className=\"sidebar-menu-route-text text-xxs font-normal tracking-wide text-gray-900 dark:text-foreground-primary\">\n              {getChainDisplayName(multiProvider, originChain, true)}\n            </span>\n            <Image\n              className=\"sidebar-menu-arrow mx-1 dark:opacity-85 dark:brightness-0 dark:invert\"\n              src={ArrowRightIcon}\n              width={10}\n              height={10}\n              alt=\"\"\n            />\n            <span className=\"sidebar-menu-route-text text-xxs font-normal tracking-wide text-gray-900 dark:text-foreground-primary\">\n              {getChainDisplayName(multiProvider, destChain, true)}\n            </span>\n          </div>\n          <div className=\"sidebar-menu-time mt-1 w-full text-left text-xxs font-normal text-gray-500 dark:text-foreground-primary\">\n            {formatTransferHistoryTimestamp(timestamp, nowMs)}\n          </div>\n        </div>\n      </div>\n      <div className=\"flex h-5 w-5\">\n        {STATUSES_WITH_ICON.includes(status) ? (\n          <Image src={getIconByTransferStatus(status)} width={25} height={25} alt=\"\" />\n        ) : (\n          <SpinnerIcon className=\"-ml-1 mr-3 h-5 w-5\" />\n        )}\n      </div>\n    </button>\n  );\n}\n\nconst styles = {\n  btn: 'sidebar-menu-item flex w-full cursor-pointer items-center px-3.5 py-2 text-sm transition-all duration-500 hover:bg-gray-200 active:scale-95 dark:hover:bg-primary-300/10',\n};\n"
  },
  {
    "path": "src/features/wallet/WalletConnectionWarning.tsx",
    "content": "import { ProtocolType } from '@hyperlane-xyz/utils';\nimport { useWalletDetails } from '@hyperlane-xyz/widgets/walletIntegrations/multiProtocol';\nimport { useMemo } from 'react';\n\nimport { FormWarningBanner } from '../../components/banner/FormWarningBanner';\nimport { useMultiProvider } from '../chains/hooks';\n\nexport function WalletConnectionWarning({ origin }: { origin: ChainName }) {\n  const multiProvider = useMultiProvider();\n  const walletDetails = useWalletDetails();\n\n  const message = useMemo(() => {\n    const protocol = multiProvider.tryGetProtocol(origin);\n\n    if (protocol && walletDetails[protocol] && walletWarnings[protocol]) {\n      const protocolWalletDetail = walletDetails[protocol];\n      const walletWarning = walletWarnings[protocol];\n\n      if (protocolWalletDetail.name && walletWarning[protocolWalletDetail.name])\n        return walletWarning[protocolWalletDetail.name];\n    }\n\n    return null;\n  }, [multiProvider, origin, walletDetails]);\n\n  return <FormWarningBanner isVisible={!!message}>{message}</FormWarningBanner>;\n}\n\ntype WalletWarning = Partial<Record<ProtocolType, Record<string, string>>>;\n\nconst walletWarnings: WalletWarning = {\n  [ProtocolType.Starknet]: {\n    metamask:\n      'You might need to switch to a funded token in the Metamask Popup when confirming the transaction',\n  },\n};\n"
  },
  {
    "path": "src/features/wallet/WalletDropdown.tsx",
    "content": "import { ProtocolType, shortenAddress } from '@hyperlane-xyz/utils';\nimport { ChevronIcon, DropdownMenu, useModal, XIcon } from '@hyperlane-xyz/widgets';\nimport {\n  useAccountAddressForChain,\n  useAccountForChain,\n  useConnectFns,\n  useDisconnectFns,\n} from '@hyperlane-xyz/widgets/walletIntegrations/multiProtocol';\nimport React, { useCallback, useMemo } from 'react';\n\nimport { Color } from '../../styles/Color';\nimport { logger } from '../../utils/logger';\nimport { useChainProtocol, useMultiProvider } from '../chains/hooks';\nimport { RecipientAddressModal } from './RecipientAddressModal';\n\ninterface WalletDropdownProps {\n  chainName: string | undefined;\n  selectionMode: 'origin' | 'destination';\n  recipient?: string;\n  onRecipientChange?: (address: string) => void;\n  disabled?: boolean;\n}\n\nexport function WalletDropdown({\n  chainName,\n  selectionMode,\n  recipient,\n  onRecipientChange,\n  disabled,\n}: WalletDropdownProps) {\n  const multiProvider = useMultiProvider();\n  const protocol = useChainProtocol(chainName || '') || ProtocolType.Ethereum;\n\n  const account = useAccountForChain(multiProvider, chainName);\n  const isConnected = account?.isReady;\n  const connectedAddress = useAccountAddressForChain(multiProvider, chainName);\n\n  const disconnectFns = useDisconnectFns();\n  const disconnectFn = disconnectFns[protocol];\n\n  const { isOpen: isModalOpen, open: openModal, close: closeModal } = useModal();\n\n  const onDisconnect = useCallback(async () => {\n    try {\n      await disconnectFn?.();\n    } catch (err) {\n      logger.error('Failed to disconnect wallet', err);\n    }\n  }, [disconnectFn]);\n\n  const onSaveRecipient = useCallback(\n    (address: string) => {\n      onRecipientChange?.(address);\n    },\n    [onRecipientChange],\n  );\n\n  const onUseConnectedWallet = useCallback(() => {\n    onRecipientChange?.('');\n  }, [onRecipientChange]);\n\n  const isDestination = selectionMode === 'destination';\n  const hasCustomRecipient = isDestination && !!recipient && recipient !== connectedAddress;\n  const displayAddress = hasCustomRecipient ? recipient : connectedAddress;\n  const truncatedAddress = displayAddress ? shortenAddress(displayAddress) : '';\n\n  // Build menu items based on state\n  const menuItems = useMemo(() => {\n    const items: React.ReactNode[] = [];\n\n    // when there is not a wallet connected, show the current chain wallet connect modal\n    if (!isConnected) {\n      items.push(<ConnectMenuItem key=\"connect\" protocol={protocol} />);\n    }\n\n    if (isDestination) {\n      if (items.length > 0) items.push(<MenuSeparator key=\"sep-1\" />);\n      items.push(\n        <MenuItemButton key=\"paste\" onClick={openModal}>\n          Paste wallet address\n        </MenuItemButton>,\n      );\n    }\n\n    if (hasCustomRecipient && isConnected) {\n      items.push(<MenuSeparator key=\"sep-2\" />);\n      items.push(\n        <MenuItemButton key=\"use-connected\" onClick={onUseConnectedWallet}>\n          Use connected wallet\n        </MenuItemButton>,\n      );\n    }\n\n    // Only show disconnect if actually connected\n    if (isConnected) {\n      if (items.length > 0) items.push(<MenuSeparator key=\"sep-3\" />);\n      items.push(\n        <MenuItemButton key=\"disconnect\" onClick={onDisconnect}>\n          Disconnect wallet\n        </MenuItemButton>,\n      );\n    }\n\n    return items;\n  }, [\n    isDestination,\n    hasCustomRecipient,\n    isConnected,\n    protocol,\n    onDisconnect,\n    onUseConnectedWallet,\n    openModal,\n  ]);\n\n  // Origin mode, not connected - simple button without dropdown\n  if (!isConnected && !isDestination) {\n    return <ConnectWalletButton chainName={chainName} />;\n  }\n\n  // All other cases - use dropdown\n  return (\n    <>\n      <DropdownMenu\n        button={<DropdownWalletButton address={truncatedAddress} />}\n        buttonClassname=\"flex items-center\"\n        menuClassname=\"wallet-dropdown-menu mt-2 min-w-[200px] rounded-lg border border-gray-200 bg-white py-1 shadow-md dark:border-primary-300/40 dark:bg-surface dark:shadow-[0_12px_32px_rgba(0,0,0,0.45)]\"\n        menuItems={menuItems}\n        buttonProps={{ disabled }}\n      />\n      <RecipientAddressModal\n        isOpen={isModalOpen}\n        close={closeModal}\n        onSave={onSaveRecipient}\n        initialValue={recipient}\n        protocol={protocol}\n      />\n    </>\n  );\n}\n\n// Self-contained connect button with its own hooks\nfunction ConnectWalletButton({ chainName }: { chainName?: string }) {\n  const protocol = useChainProtocol(chainName || '') || ProtocolType.Ethereum;\n  const connectFns = useConnectFns();\n  const connectFn = connectFns[protocol];\n\n  const onConnect = useCallback(() => {\n    connectFn?.();\n  }, [connectFn]);\n\n  return (\n    <button\n      type=\"button\"\n      onClick={onConnect}\n      className=\"wallet-connect-link flex items-center gap-1.5 text-sm text-primary-500 underline decoration-current transition-colors hover:text-primary-600 dark:text-foreground-secondary dark:hover:text-foreground-primary dark:[&_path]:fill-current dark:[&_path]:stroke-current\"\n    >\n      <XIcon width={8} height={8} color={Color.red[500]} />\n      <span>Connect Wallet</span>\n      <ChevronIcon width={10} height={8} direction=\"s\" color={Color.primary[500]} />\n    </button>\n  );\n}\n\n// Self-contained connect menu item with its own hooks\nfunction ConnectMenuItem({ protocol }: { protocol: ProtocolType }) {\n  const connectFns = useConnectFns();\n  const connectFn = connectFns[protocol];\n\n  const onConnect = useCallback(() => {\n    connectFn?.();\n  }, [connectFn]);\n\n  return (\n    <button type=\"button\" onClick={onConnect} className={menuItemClass}>\n      Connect wallet\n    </button>\n  );\n}\n\nfunction DropdownWalletButton({ address }: { address: string }) {\n  return (\n    <div className=\"wallet-connect-link flex items-center gap-2 text-sm underline decoration-current dark:text-foreground-secondary dark:hover:text-foreground-primary dark:[&_path]:fill-current dark:[&_path]:stroke-current\">\n      {address ? (\n        <div className=\"h-2 w-2 rounded-full bg-green-50\" />\n      ) : (\n        <XIcon width={8} height={8} color={Color.red[500]} />\n      )}\n      <div className=\"flex items-center gap-2 text-primary-500 transition-colors duration-150 hover:text-primary-700 dark:text-foreground-secondary dark:hover:text-foreground-primary [&_path]:fill-primary-500 hover:[&_path]:fill-primary-700 dark:[&_path]:fill-current dark:[&_path]:stroke-current dark:hover:[&_path]:fill-current dark:hover:[&_path]:stroke-current\">\n        <span>{address || 'Connect Wallet'}</span>\n        <ChevronIcon width={10} height={6} direction=\"s\" />\n      </div>\n    </div>\n  );\n}\n\nconst menuItemClass =\n  'wallet-dropdown-item w-full px-4 py-2.5 text-left text-sm text-gray-900 hover:bg-gray-100 dark:text-foreground-primary dark:hover:bg-primary-300/[0.16]';\n\nfunction MenuItemButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {\n  return (\n    <button type=\"button\" onClick={onClick} className={menuItemClass}>\n      {children}\n    </button>\n  );\n}\n\nfunction MenuSeparator() {\n  return (\n    <div className=\"wallet-dropdown-separator mx-2 my-1 h-px bg-primary-50 dark:bg-primary-300/20\" />\n  );\n}\n"
  },
  {
    "path": "src/features/wallet/WalletProtocolModal.test.ts",
    "content": "import { ProtocolType } from '@hyperlane-xyz/utils';\nimport { describe, expect, it } from 'vitest';\n\nimport { config } from '../../consts/config';\nimport { PROTOCOL_OPTIONS } from './WalletProtocolModal';\n\ndescribe('WalletProtocolModal', () => {\n  it('includes tron in the modal options when tron is enabled in config', () => {\n    expect(config.walletProtocols).toContain(ProtocolType.Tron);\n    expect(PROTOCOL_OPTIONS.map((option) => option.protocol)).toContain(ProtocolType.Tron);\n  });\n});\n"
  },
  {
    "path": "src/features/wallet/WalletProtocolModal.tsx",
    "content": "import { ProtocolType } from '@hyperlane-xyz/utils';\nimport { Modal, PROTOCOL_TO_LOGO } from '@hyperlane-xyz/widgets';\nimport { useConnectFns } from '@hyperlane-xyz/widgets/walletIntegrations/multiProtocol';\nimport clsx from 'clsx';\n\nimport { logger } from '../../utils/logger';\n\ninterface WalletProtocolModalProps {\n  isOpen: boolean;\n  close: () => void;\n  protocols?: ProtocolType[];\n  onProtocolSelected?: (protocol: ProtocolType) => void;\n}\n\nexport const PROTOCOL_OPTIONS = [\n  { protocol: ProtocolType.Ethereum, title: 'EVM', subtitle: 'an EVM' },\n  { protocol: ProtocolType.Sealevel, title: 'Solana', subtitle: 'a Solana' },\n  { protocol: ProtocolType.Cosmos, title: 'Cosmos', subtitle: 'a Cosmos' },\n  { protocol: ProtocolType.Starknet, title: 'Starknet', subtitle: 'a Starknet' },\n  { protocol: ProtocolType.Radix, title: 'Radix', subtitle: 'a Radix' },\n  { protocol: ProtocolType.Tron, title: 'Tron', subtitle: 'a Tron' },\n  {\n    protocol: ProtocolType.Aleo,\n    title: 'Aleo',\n    subtitle: 'an Aleo',\n    logoClassName: 'wallet-protocol-aleo-logo',\n  },\n];\n\nexport function WalletProtocolModal({\n  isOpen,\n  close,\n  protocols,\n  onProtocolSelected,\n}: WalletProtocolModalProps) {\n  const connectFns = useConnectFns();\n\n  const onClickProtocol = (protocol: ProtocolType) => {\n    const connectFn = connectFns[protocol];\n    if (!connectFn) {\n      // eslint-disable-next-line no-console\n      console.error(`No wallet connect function configured for protocol: ${protocol}`);\n      return;\n    }\n    close();\n    onProtocolSelected?.(protocol);\n    connectFn();\n  };\n\n  const includesProtocol = (protocol: ProtocolType) => !protocols || protocols.includes(protocol);\n\n  return (\n    <Modal\n      isOpen={isOpen}\n      close={close}\n      dialogClassname=\"wallet-protocol-dialog\"\n      panelClassname=\"wallet-protocol-modal max-w-[44rem] p-4 dark:border dark:border-edge/60 dark:bg-surface dark:text-foreground-primary dark:shadow-[0_16px_40px_rgba(0,0,0,0.45)]\"\n    >\n      <div className=\"wallet-protocol-grid flex flex-wrap justify-center gap-2.5 py-2\">\n        {PROTOCOL_OPTIONS.filter((option) => includesProtocol(option.protocol)).map((option) => {\n          const Logo = PROTOCOL_TO_LOGO[option.protocol];\n          if (!Logo) {\n            logger.error('Missing protocol logo mapping for', option.protocol);\n            return null;\n          }\n          return (\n            <button\n              type=\"button\"\n              key={option.protocol}\n              onClick={() => onClickProtocol(option.protocol)}\n              className=\"wallet-protocol-card flex basis-[calc(50%-0.5rem)] flex-col items-center space-y-2.5 rounded-lg border border-gray-200 py-3.5 transition-all hover:bg-gray-100 active:scale-95 sm:basis-[calc(33.333%-0.5rem)] dark:border-edge/60 dark:bg-background/80 dark:hover:bg-surface/85\"\n            >\n              <Logo\n                width={34}\n                height={34}\n                className={clsx(\n                  option.logoClassName,\n                  option.protocol === ProtocolType.Aleo &&\n                    'dark:text-foreground-primary dark:[&_polygon]:fill-current',\n                )}\n              />\n              <div className=\"wallet-protocol-title tracking-wide text-gray-800 dark:text-foreground-primary\">\n                {option.title}\n              </div>\n              <div className=\"wallet-protocol-subtitle text-sm text-gray-500 dark:text-foreground-secondary\">\n                {`Connect to ${option.subtitle}-compatible wallet`}\n              </div>\n            </button>\n          );\n        })}\n      </div>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "src/features/wallet/_e2e/E2EAutoConnectCosmos.tsx",
    "content": "import { useWallet } from '@cosmos-kit/react';\nimport { useEffect } from 'react';\n\nconst MOCK_WALLET_NAME = 'warp-e2e-mock-cosmos';\n\n// Cosmos-kit's programmatic connect requires a selected wallet + chain. Unlike\n// wagmi / @solana/wallet-adapter, there is no single \"top-level connect\" call\n// because wallets are per-chain. `mainWallet.connect()` calls connectAll but\n// defaults to activeOnly — chains only become active when the app actually\n// renders them via useChain/useChains, so at mount that list is usually empty\n// or limited to cosmoshub. Explicitly connect every registered chain-wallet so\n// the UI sees a valid address no matter which cosmos chain the user picks.\nexport function E2EAutoConnectCosmos() {\n  const { mainWallet } = useWallet(MOCK_WALLET_NAME);\n\n  useEffect(() => {\n    if (!mainWallet) return;\n    const chainWallets = mainWallet.getChainWalletList(false);\n    for (const cw of chainWallets) {\n      if (cw.isWalletConnected) continue;\n      cw.connect().catch(() => {\n        /* Auto-connect is best-effort. */\n      });\n    }\n  }, [mainWallet]);\n\n  return null;\n}\n"
  },
  {
    "path": "src/features/wallet/_e2e/E2EAutoConnectEvm.tsx",
    "content": "import { useEffect } from 'react';\nimport { useAccount, useConnect } from 'wagmi';\n\nimport { MOCK_EVM_CONNECTOR_ID } from './constants';\n\nexport function E2EAutoConnectEvm() {\n  const { connect, connectors } = useConnect();\n  const { isConnected, status } = useAccount();\n\n  useEffect(() => {\n    if (isConnected || status === 'connecting' || status === 'reconnecting') return;\n    const mockConnector = connectors.find((c) => c.id === MOCK_EVM_CONNECTOR_ID);\n    if (!mockConnector) return;\n    connect({ connector: mockConnector });\n  }, [connect, connectors, isConnected, status]);\n\n  return null;\n}\n"
  },
  {
    "path": "src/features/wallet/_e2e/E2EAutoConnectRadix.tsx",
    "content": "import { useAccount } from '@hyperlane-xyz/widgets/walletIntegrations/radix/AccountContext';\nimport { useEffect } from 'react';\n\n// Radix account address for E2E. Deterministic, recognisable in traces.\nexport const MOCK_RADIX_ADDRESS =\n  'account_rdx12e2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee';\n\n// The @hyperlane-xyz/widgets Radix AccountContext tracks the connected\n// account purely in React state (no adapter/client class). Seeding that state\n// at mount is enough for downstream hooks (`useAccount`) to observe a\n// connected wallet on the Radix chain.\nexport function E2EAutoConnectRadix() {\n  const { setAccounts, setSelectedAccount, selectedAccount } = useAccount();\n\n  useEffect(() => {\n    if (selectedAccount === MOCK_RADIX_ADDRESS) return;\n    const mockAccount = {\n      address: MOCK_RADIX_ADDRESS,\n      label: 'Warp E2E Mock (Radix)',\n      appearanceId: 0,\n    };\n    // The WalletAccount type in widgets expects additional fields but all\n    // downstream code paths only read `address` — cast through unknown.\n    setAccounts([mockAccount as unknown as Parameters<typeof setAccounts>[0][number]]);\n    setSelectedAccount(MOCK_RADIX_ADDRESS);\n  }, [selectedAccount, setAccounts, setSelectedAccount]);\n\n  return null;\n}\n"
  },
  {
    "path": "src/features/wallet/_e2e/E2EAutoConnectSolana.tsx",
    "content": "import { useWallet } from '@solana/wallet-adapter-react';\nimport { useEffect } from 'react';\n\nconst MOCK_WALLET_NAME = 'WarpE2EMock';\n\nexport function E2EAutoConnectSolana() {\n  const { wallets, select, wallet } = useWallet();\n\n  // Select the mock adapter once it's registered. After selection, the\n  // WalletProvider's autoConnect={true} prop takes over and fires\n  // adapter.connect() for us — which ensures WalletProviderBase's 'connect'\n  // event handler is subscribed before the emit (the effect order is\n  // parent-subscribes → parent-autoConnect; a manual connect() from this\n  // child effect would race ahead of the subscribe and the resulting event\n  // would be dropped on the floor, leaving publicKey null).\n  useEffect(() => {\n    const mock = wallets.find((w) => w.adapter.name === MOCK_WALLET_NAME);\n    if (!mock) return;\n    if (!wallet || wallet.adapter.name !== MOCK_WALLET_NAME) {\n      select(mock.adapter.name);\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [wallets]);\n\n  return null;\n}\n"
  },
  {
    "path": "src/features/wallet/_e2e/E2EAutoConnectStarknet.tsx",
    "content": "import { useAccount, useConnect } from '@starknet-react/core';\nimport { useEffect } from 'react';\n\nconst MOCK_CONNECTOR_ID = 'warp-e2e-mock-starknet';\n\nexport function E2EAutoConnectStarknet() {\n  const { connect, connectors, status } = useConnect();\n  const { isConnected } = useAccount();\n\n  useEffect(() => {\n    if (isConnected || status === 'pending') return;\n    const mock = connectors.find((c) => c.id === MOCK_CONNECTOR_ID);\n    if (!mock) return;\n    connect({ connector: mock });\n  }, [connect, connectors, isConnected, status]);\n\n  return null;\n}\n"
  },
  {
    "path": "src/features/wallet/_e2e/E2EAutoConnectTron.tsx",
    "content": "import type { AdapterName } from '@tronweb3/tronwallet-abstract-adapter';\nimport { useWallet } from '@tronweb3/tronwallet-adapter-react-hooks';\nimport { useEffect } from 'react';\n\nconst MOCK_ADAPTER_NAME = 'WarpE2EMockTron' as AdapterName;\n\n// tronwallet-adapter-react's WalletProvider has its own `autoConnect`, but it\n// relies on a localStorage key left behind by a prior manual connection. In a\n// clean E2E session that key isn't set, so we explicitly select + connect the\n// mock adapter once it's present.\nexport function E2EAutoConnectTron() {\n  const { wallets, wallet, select, connect, connected, connecting } = useWallet();\n\n  useEffect(() => {\n    if (connected || connecting) return;\n    if (!wallets?.some((w) => w.adapter.name === MOCK_ADAPTER_NAME)) return;\n    if (wallet?.adapter.name !== MOCK_ADAPTER_NAME) {\n      select(MOCK_ADAPTER_NAME);\n      return;\n    }\n    connect().catch(() => {\n      /* Auto-connect is best-effort. */\n    });\n  }, [wallets, wallet, select, connect, connected, connecting]);\n\n  return null;\n}\n"
  },
  {
    "path": "src/features/wallet/_e2e/MockCosmosWallet.test.fixtures.ts",
    "content": "// Deterministic addresses the MockCosmosWallet mnemonic resolves to under\n// various bech32 prefixes. Any change to the mnemonic or derivation path in\n// MockCosmosWallet.ts must regenerate these — the unit tests pin the values.\nexport const MOCK_COSMOS_MNEMONIC_ADDRESSES = {\n  cosmos: 'cosmos19rl4cm2hmr8afy4kldpxz3fka4jguq0auqdal4',\n  neutron: 'neutron19rl4cm2hmr8afy4kldpxz3fka4jguq0aclyl9j',\n} as const;\n"
  },
  {
    "path": "src/features/wallet/_e2e/MockCosmosWallet.test.ts",
    "content": "import { beforeEach, describe, expect, test } from 'vitest';\n\nimport { MockCosmosWallet, mockCosmosWalletInfo } from './MockCosmosWallet';\nimport { MOCK_COSMOS_MNEMONIC_ADDRESSES } from './MockCosmosWallet.test.fixtures';\n\n// The mock cosmos wallet needs to work as a cosmos-kit MainWalletBase: the\n// initClient handshake must succeed, the client must honour the per-chain\n// bech32 prefix recorded via addChain, and the offline direct signer must\n// produce valid accounts + signatures deterministically.\ndescribe('MockCosmosWallet', () => {\n  let wallet: MockCosmosWallet;\n\n  beforeEach(() => {\n    wallet = new MockCosmosWallet(mockCosmosWalletInfo);\n  });\n\n  test('initClient resolves the mock WalletClient', async () => {\n    await wallet.initClient();\n    expect(wallet.client).toBeDefined();\n  });\n\n  test('getAccount returns the expected deterministic cosmos-prefixed address', async () => {\n    await wallet.initClient();\n    const client = wallet.client!;\n    // Seed the prefix table the way cosmos-kit does via addChain.\n    await client.addChain!({\n      name: 'cosmoshub',\n      chain: { chain_id: 'cosmoshub-4', bech32_prefix: 'cosmos' } as never,\n      assetList: { chain_name: 'cosmoshub', assets: [] } as never,\n    } as never);\n    const account = (await client.getAccount!('cosmoshub-4')) as unknown as {\n      address: string;\n      algo: string;\n    };\n    expect(account.address).toBe(MOCK_COSMOS_MNEMONIC_ADDRESSES.cosmos);\n    expect(account.algo).toBe('secp256k1');\n  });\n\n  test('getAccount honours per-chain bech32 prefix (same key, different encoding)', async () => {\n    await wallet.initClient();\n    const client = wallet.client!;\n    await client.addChain!({\n      name: 'cosmoshub',\n      chain: { chain_id: 'cosmoshub-4', bech32_prefix: 'cosmos' } as never,\n      assetList: { chain_name: 'cosmoshub', assets: [] } as never,\n    } as never);\n    await client.addChain!({\n      name: 'neutron',\n      chain: { chain_id: 'neutron-1', bech32_prefix: 'neutron' } as never,\n      assetList: { chain_name: 'neutron', assets: [] } as never,\n    } as never);\n\n    const cosmosAccount = (await client.getAccount!('cosmoshub-4')) as unknown as {\n      address: string;\n      pubkey: Uint8Array;\n    };\n    const neutronAccount = (await client.getAccount!('neutron-1')) as unknown as {\n      address: string;\n      pubkey: Uint8Array;\n    };\n    expect(cosmosAccount.address).toBe(MOCK_COSMOS_MNEMONIC_ADDRESSES.cosmos);\n    expect(neutronAccount.address).toBe(MOCK_COSMOS_MNEMONIC_ADDRESSES.neutron);\n    // Same underlying pubkey across prefixes.\n    expect(Buffer.from(cosmosAccount.pubkey).toString('hex')).toBe(\n      Buffer.from(neutronAccount.pubkey).toString('hex'),\n    );\n  });\n\n  test('getOfflineSignerDirect exposes getAccounts and signDirect on the same key', async () => {\n    await wallet.initClient();\n    const client = wallet.client!;\n    await client.addChain!({\n      name: 'cosmoshub',\n      chain: { chain_id: 'cosmoshub-4', bech32_prefix: 'cosmos' } as never,\n      assetList: { chain_name: 'cosmoshub', assets: [] } as never,\n    } as never);\n    const signer = client.getOfflineSignerDirect!('cosmoshub-4');\n    const accounts = await signer.getAccounts();\n    expect(accounts).toHaveLength(1);\n    expect(accounts[0].address).toBe(MOCK_COSMOS_MNEMONIC_ADDRESSES.cosmos);\n  });\n});\n"
  },
  {
    "path": "src/features/wallet/_e2e/MockCosmosWallet.ts",
    "content": "import { DirectSecp256k1HdWallet, type OfflineDirectSigner } from '@cosmjs/proto-signing';\nimport {\n  ChainWalletBase,\n  MainWalletBase,\n  type ChainRecord,\n  type SimpleAccount,\n  type Wallet,\n  type WalletAccount,\n  type WalletClient,\n} from '@cosmos-kit/core';\n\nimport { pushCosmosTx } from './windowState';\n\n// Well-known BIP39 test vector. Not a production key, no funds on it — safe to\n// hard-code. Any fixture change here will shift the derived address, so update\n// MOCK_COSMOS_ADDRESS in ../constants.ts alongside it.\nconst FIXED_MNEMONIC =\n  'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';\n\n// Minimal 1x1 transparent PNG for cosmos-kit's wallet-list icon slot.\nconst TRANSPARENT_ICON =\n  'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAAACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII=';\n\nexport const mockCosmosWalletInfo: Wallet = {\n  name: 'warp-e2e-mock-cosmos',\n  prettyName: 'Warp E2E Mock (Cosmos)',\n  logo: TRANSPARENT_ICON,\n  mode: 'extension',\n  mobileDisabled: true,\n  rejectMessage: { source: 'Request rejected' },\n  connectEventNamesOnWindow: [],\n  downloads: [],\n};\n\nclass MockCosmosClient implements WalletClient {\n  // chainId → bech32 prefix, populated as cosmos-kit discovers chains.\n  private prefixByChainId = new Map<string, string>();\n  // Per-prefix signer cache. Instance-scoped so multiple MockCosmosWallet\n  // instances (e.g. across unit tests) stay isolated.\n  private signerCache = new Map<string, Promise<DirectSecp256k1HdWallet>>();\n\n  private getSignerFor(prefix: string): Promise<DirectSecp256k1HdWallet> {\n    const cached = this.signerCache.get(prefix);\n    if (cached) return cached;\n    const fresh = DirectSecp256k1HdWallet.fromMnemonic(FIXED_MNEMONIC, { prefix });\n    this.signerCache.set(prefix, fresh);\n    return fresh;\n  }\n\n  async enable(_chainIds: string | string[]): Promise<void> {\n    // No-op — the mock is always \"connected\".\n  }\n\n  async disconnect(): Promise<void> {\n    // No-op.\n  }\n\n  async addChain(chainInfo: ChainRecord): Promise<void> {\n    const chain = chainInfo.chain;\n    if (chain?.chain_id && chain?.bech32_prefix) {\n      this.prefixByChainId.set(chain.chain_id, chain.bech32_prefix);\n    }\n  }\n\n  async getSimpleAccount(chainId: string): Promise<SimpleAccount> {\n    const account = await this.getAccount(chainId);\n    // Cast through unknown — cosmos-kit's WalletAccount references an\n    // AccountData declared from a different @cosmjs/amino version than the one\n    // `signer.getAccounts()` resolves against, so TS sees the shapes as\n    // distinct even though they're structurally compatible at runtime.\n    return {\n      namespace: 'cosmos',\n      chainId,\n      address: (account as unknown as { address: string }).address,\n    };\n  }\n\n  async getAccount(chainId: string): Promise<WalletAccount> {\n    const prefix = this.prefixByChainId.get(chainId);\n    // Throwing when the prefix isn't yet recorded forces cosmos-kit's\n    // chain-wallet update() loop into its addChain fallback — otherwise it\n    // happily hands back a cosmos-prefixed address for every chainId.\n    if (!prefix) throw new Error(`No bech32 prefix recorded for ${chainId}`);\n    const signer = await this.getSignerFor(prefix);\n    const [first] = await signer.getAccounts();\n    return {\n      address: first.address,\n      pubkey: first.pubkey,\n      algo: 'secp256k1',\n      username: 'warp-e2e-mock',\n    } as unknown as WalletAccount;\n  }\n\n  getOfflineSigner(chainId: string): OfflineDirectSigner {\n    return this.getOfflineSignerDirect(chainId);\n  }\n\n  getOfflineSignerDirect(chainId: string): OfflineDirectSigner {\n    const prefix = this.prefixByChainId.get(chainId);\n    if (!prefix) throw new Error(`No bech32 prefix recorded for ${chainId}`);\n    // Build the signer lazily inside each method call so the top-level API\n    // stays synchronous (cosmos-kit's WalletClient type requires it), while\n    // still letting us await the async DirectSecp256k1HdWallet.fromMnemonic.\n    return {\n      getAccounts: async () => {\n        const underlying = await this.getSignerFor(prefix);\n        return underlying.getAccounts();\n      },\n      signDirect: async (signerAddress, signDoc) => {\n        try {\n          pushCosmosTx({\n            chainId,\n            signerAddress,\n            typeUrls: [],\n            messagesJson: JSON.stringify({\n              accountNumber: signDoc.accountNumber?.toString(),\n              chainId: signDoc.chainId,\n            }),\n          });\n        } catch (error) {\n          console.error('Failed to capture mock Cosmos tx', error);\n        }\n        const underlying = await this.getSignerFor(prefix);\n        return underlying.signDirect(signerAddress, signDoc);\n      },\n    };\n  }\n}\n\nclass MockCosmosChainWallet extends ChainWalletBase {\n  constructor(walletInfo: Wallet, chainInfo: ChainRecord) {\n    super(walletInfo, chainInfo);\n  }\n}\n\nexport class MockCosmosWallet extends MainWalletBase {\n  constructor(walletInfo: Wallet = mockCosmosWalletInfo) {\n    super(walletInfo, MockCosmosChainWallet);\n  }\n\n  async initClient(): Promise<void> {\n    this.initingClient();\n    try {\n      this.initClientDone(new MockCosmosClient());\n    } catch (err) {\n      this.initClientError(err as Error);\n    }\n  }\n}\n"
  },
  {
    "path": "src/features/wallet/_e2e/MockSolanaAdapter.ts",
    "content": "import {\n  BaseMessageSignerWalletAdapter,\n  WalletReadyState,\n  type SendTransactionOptions,\n  type WalletName,\n} from '@solana/wallet-adapter-base';\nimport type {\n  Connection,\n  PublicKey,\n  Transaction,\n  TransactionSignature,\n  VersionedTransaction,\n} from '@solana/web3.js';\nimport { Keypair } from '@solana/web3.js';\n\nimport { pushSolanaTx } from './windowState';\n\n// Fixed-seed keypair. Derived lazily inside the class constructor so that\n// merely importing this module (e.g. during tree-shake analysis or from\n// bundles that include cosmos-kit chain plumbing) doesn't run ed25519 key\n// derivation on prod page loads.\nconst FIXED_SEED = new Uint8Array(32).fill(0xe2);\n\nconst MOCK_SIGNATURE = 'e2e' + '1'.repeat(85);\n\nexport class MockSolanaAdapter extends BaseMessageSignerWalletAdapter {\n  name = 'WarpE2EMock' as WalletName<'WarpE2EMock'>;\n  url = 'https://hyperlane.xyz';\n  // Minimal 1x1 transparent PNG data URI.\n  icon =\n    'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAAACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII=';\n  readyState = WalletReadyState.Installed;\n  publicKey: PublicKey | null = null;\n  connecting = false;\n  supportedTransactionVersions = new Set([0, 'legacy'] as const);\n\n  async connect(): Promise<void> {\n    // BaseWalletAdapter.connected is derived from !!this.publicKey, so we\n    // must leave publicKey null until connect() runs. Otherwise every\n    // subsequent connect() short-circuits via `if (this.connected) return;`\n    // without ever firing the 'connect' event, so wallet-adapter-react's\n    // internal publicKey state stays null and useSolanaAccount hands back\n    // an empty addresses array.\n    if (this.connected) return;\n    this.connecting = true;\n    this.publicKey = Keypair.fromSeed(FIXED_SEED).publicKey;\n    (this as unknown as { emit: (event: string, payload?: unknown) => void }).emit(\n      'connect',\n      this.publicKey,\n    );\n    this.connecting = false;\n  }\n\n  async disconnect(): Promise<void> {\n    this.publicKey = null;\n    (this as unknown as { emit: (event: string, payload?: unknown) => void }).emit('disconnect');\n  }\n\n  async signTransaction<T extends Transaction | VersionedTransaction>(transaction: T): Promise<T> {\n    captureTx(transaction);\n    return transaction;\n  }\n\n  async signMessage(message: Uint8Array): Promise<Uint8Array> {\n    // Return a deterministic 64-byte \"signature\".\n    const out = new Uint8Array(64);\n    for (let i = 0; i < 64; i++) out[i] = (message[i % message.length] ^ 0xe2) & 0xff;\n    return out;\n  }\n\n  async sendTransaction(\n    transaction: Transaction | VersionedTransaction,\n    _connection: Connection,\n    _options?: SendTransactionOptions,\n  ): Promise<TransactionSignature> {\n    captureTx(transaction);\n    return MOCK_SIGNATURE;\n  }\n}\n\nfunction captureTx(transaction: Transaction | VersionedTransaction): void {\n  try {\n    const bytes =\n      'serialize' in transaction\n        ? transaction.serialize({ requireAllSignatures: false, verifySignatures: false })\n        : new Uint8Array();\n    const serializedBase64 = bufferToBase64(bytes);\n    let programIds: string[] = [];\n    let feePayer: string | undefined;\n    if ('message' in transaction && transaction.message) {\n      const msg = transaction.message;\n      feePayer = msg.staticAccountKeys?.[0]?.toBase58();\n      programIds = msg.compiledInstructions.map((ix) =>\n        msg.staticAccountKeys[ix.programIdIndex].toBase58(),\n      );\n    } else if ('instructions' in transaction) {\n      feePayer = transaction.feePayer?.toBase58();\n      programIds = transaction.instructions.map((ix) => ix.programId.toBase58());\n    }\n    pushSolanaTx({ feePayer, serializedBase64, programIds });\n  } catch (error) {\n    console.error('Failed to capture mock Solana tx', error);\n  }\n}\n\nfunction bufferToBase64(buf: Uint8Array): string {\n  if (typeof window !== 'undefined' && typeof window.btoa === 'function') {\n    let bin = '';\n    for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]);\n    return window.btoa(bin);\n  }\n  return '';\n}\n"
  },
  {
    "path": "src/features/wallet/_e2e/MockStarknetConnector.test.ts",
    "content": "import { describe, expect, test } from 'vitest';\n\nimport { MOCK_STARKNET_ADDRESS, createMockStarknetConnector } from './MockStarknetConnector';\n\n// Adapter-smoke coverage for the Starknet mock. UI-level autoconnect is wired\n// (see E2EAutoConnectStarknet) but not covered by a Playwright spec in this\n// push — matrix entry is labeled \"adapter smoke\" accordingly.\ndescribe('MockStarknetConnector', () => {\n  test('connect resolves to the fixed mock address on each chain', async () => {\n    const connector = createMockStarknetConnector();\n    expect(connector.id).toBe('warp-e2e-mock-starknet');\n    expect(connector.available()).toBe(true);\n\n    const result = await connector.connect();\n    expect(result.account).toBe(MOCK_STARKNET_ADDRESS);\n  });\n});\n"
  },
  {
    "path": "src/features/wallet/_e2e/MockStarknetConnector.ts",
    "content": "import { MockConnector, type MockConnectorAccounts } from '@starknet-react/core';\n\n// Fixed address for deterministic E2E tests. Not a real account on any\n// network; the mock connector never broadcasts.\nexport const MOCK_STARKNET_ADDRESS =\n  '0x07e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2';\n\n// Minimal duck-typed AccountInterface. We cast through `unknown` because the\n// starknet `AccountInterface` has ~50 methods, none of which the autoconnect\n// code path actually calls. Tests that exercise signing would need to flesh\n// this out.\nfunction buildFakeAccount() {\n  return {\n    address: MOCK_STARKNET_ADDRESS,\n    cairoVersion: '1',\n    transactionVersion: '0x3',\n    deploySelf: async () => ({ transaction_hash: '0x0', contract_address: MOCK_STARKNET_ADDRESS }),\n    estimateAccountDeployFee: async () => ({\n      suggestedMaxFee: 0n,\n      resourceBounds: {},\n      gas_consumed: 0n,\n      gas_price: 0n,\n      overall_fee: 0n,\n      unit: 'WEI',\n    }),\n    execute: async () => ({ transaction_hash: '0x0' }),\n    signMessage: async () => ['0x0', '0x0'],\n  };\n}\n\nexport function createMockStarknetConnector(): MockConnector {\n  // MockConnectorAccounts expects a starknet.js AccountInterface[]. We only\n  // care about the `address` accessor for autoconnect, so cast through unknown\n  // to sidestep the full 50-method interface.\n  const account = buildFakeAccount() as unknown as MockConnectorAccounts['mainnet'][number];\n  return new MockConnector({\n    accounts: { mainnet: [account], sepolia: [account] },\n    options: {\n      id: 'warp-e2e-mock-starknet',\n      name: 'Warp E2E Mock (Starknet)',\n      available: true,\n    },\n  });\n}\n"
  },
  {
    "path": "src/features/wallet/_e2e/MockTronAdapter.test.ts",
    "content": "import { AdapterState } from '@tronweb3/tronwallet-abstract-adapter';\nimport { describe, expect, test } from 'vitest';\n\nimport { MOCK_TRON_ADDRESS, MockTronAdapter } from './MockTronAdapter';\n\n// Adapter-smoke coverage for the Tron mock. UI-level autoconnect is not\n// wired in this push — matrix entry is labeled \"adapter smoke\" accordingly.\ndescribe('MockTronAdapter', () => {\n  test('connect() sets the fixed address and emits connect', async () => {\n    const adapter = new MockTronAdapter();\n    expect(adapter.connected).toBe(false);\n    expect(adapter.state).toBe(AdapterState.Disconnect);\n    const connectPromise = new Promise<string>((resolve) => {\n      (adapter as unknown as { on: (event: string, fn: (v: string) => void) => void }).on(\n        'connect',\n        (addr) => resolve(addr),\n      );\n    });\n    await adapter.connect();\n    expect(adapter.connected).toBe(true);\n    expect(adapter.address).toBe(MOCK_TRON_ADDRESS);\n    expect(await connectPromise).toBe(MOCK_TRON_ADDRESS);\n  });\n});\n"
  },
  {
    "path": "src/features/wallet/_e2e/MockTronAdapter.ts",
    "content": "import {\n  Adapter,\n  AdapterState,\n  WalletReadyState,\n  type AdapterName,\n  type SignedTransaction,\n  type Transaction,\n} from '@tronweb3/tronwallet-abstract-adapter';\n\n// Fixed base58 address for deterministic E2E. Not a real address; the adapter\n// never broadcasts. Tron base58 addresses start with \"T\" and are 34 chars.\nexport const MOCK_TRON_ADDRESS = 'TE2EE2EE2EE2EE2EE2EE2EE2EE2EE2EE2E';\n\nexport class MockTronAdapter extends Adapter<'WarpE2EMockTron'> {\n  name = 'WarpE2EMockTron' as AdapterName<'WarpE2EMockTron'>;\n  url = 'https://hyperlane.xyz';\n  icon = '';\n  readyState = WalletReadyState.Found;\n  state = AdapterState.Disconnect;\n  address: string | null = null;\n  connecting = false;\n\n  get connected(): boolean {\n    return this.state === AdapterState.Connected;\n  }\n\n  async connect(): Promise<void> {\n    if (this.connected) return;\n    this.connecting = true;\n    this.address = MOCK_TRON_ADDRESS;\n    this.state = AdapterState.Connected;\n    this.connecting = false;\n    (this as unknown as { emit: (event: string, ...args: unknown[]) => void }).emit(\n      'connect',\n      MOCK_TRON_ADDRESS,\n    );\n    (this as unknown as { emit: (event: string, ...args: unknown[]) => void }).emit(\n      'stateChanged',\n      AdapterState.Connected,\n    );\n  }\n\n  async disconnect(): Promise<void> {\n    this.address = null;\n    this.state = AdapterState.Disconnect;\n    (this as unknown as { emit: (event: string, ...args: unknown[]) => void }).emit('disconnect');\n    (this as unknown as { emit: (event: string, ...args: unknown[]) => void }).emit(\n      'stateChanged',\n      AdapterState.Disconnect,\n    );\n  }\n\n  async signMessage(message: string): Promise<string> {\n    // Deterministic hex-like stub; not a valid signature, but tests that\n    // reach this path would need to assert captured message, not validate sig.\n    return '0x' + Buffer.from(message).toString('hex').padEnd(130, '0').slice(0, 130);\n  }\n\n  async signTransaction(transaction: Transaction): Promise<SignedTransaction> {\n    // Echo back with a fake signature array.\n    return {\n      ...(transaction as SignedTransaction),\n      signature: ['0x' + 'e2'.repeat(32)],\n    };\n  }\n\n  async switchChain(_chainId: string): Promise<void> {\n    // No-op for mock.\n  }\n}\n"
  },
  {
    "path": "src/features/wallet/_e2e/constants.ts",
    "content": "// Fixed accounts the E2E mocks connect as.\n//\n// EVM: chosen so it stands out in traces (0xE2eE… pattern).\n// Solana: derived from `Keypair.fromSeed(new Uint8Array(32).fill(0xe2))`.\n// Cosmos: derived from DirectSecp256k1HdWallet.fromMnemonic(<BIP39 test vector>,\n//   { prefix: 'cosmos' }). Pinned in MockCosmosWallet.test.fixtures.ts; keep\n//   all three in sync with each other.\nexport const MOCK_EVM_ADDRESS = '0xE2eE2eE2eE2eE2eE2eE2eE2eE2eE2eE2eE2eE2eE' as const;\nexport const MOCK_SOLANA_ADDRESS = 'EY4LF4gq73QHyff6McmgPKU6UuPtErVU7vVAYcv2nwGi' as const;\nexport const MOCK_COSMOS_ADDRESS = 'cosmos19rl4cm2hmr8afy4kldpxz3fka4jguq0auqdal4' as const;\n\nexport const MOCK_EVM_CONNECTOR_ID = 'mock';\n"
  },
  {
    "path": "src/features/wallet/_e2e/isE2E.ts",
    "content": "// E2E test gate. Only active on local test hosts plus `?_e2e=1`, so deployed\n// prod/preview URLs cannot switch wallet contexts over to mocks.\nexport function isE2EMode(): boolean {\n  if (typeof window === 'undefined') return false;\n  const isLocalHost =\n    window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';\n  return isLocalHost && new URLSearchParams(window.location.search).has('_e2e');\n}\n"
  },
  {
    "path": "src/features/wallet/_e2e/windowState.ts",
    "content": "import { isE2EMode } from './isE2E';\n\nexport interface CapturedEvmTx {\n  chainId: number;\n  to?: `0x${string}`;\n  data?: `0x${string}`;\n  value?: string;\n  from?: `0x${string}`;\n}\n\nexport interface CapturedSolanaTx {\n  feePayer?: string;\n  serializedBase64: string;\n  programIds: string[];\n}\n\nexport interface CapturedCosmosTx {\n  chainId: string;\n  signerAddress: string;\n  typeUrls: string[];\n  messagesJson: string;\n}\n\nexport interface E2ETokenSnapshot {\n  key: string;\n  chain: string;\n  symbol: string;\n  standard: string;\n  addressOrDenom: string;\n  collateralAddressOrDenom?: string;\n  // Keys of tokens this origin routes to directly. Distinct from\n  // `collateralGroups` (which dedupe by collateral), so a test can prove a\n  // real direct route rather than a same-symbol coincidence.\n  connectionKeys: string[];\n}\n\nexport interface WarpE2EState {\n  readyAt: number;\n  evmTxs: CapturedEvmTx[];\n  solanaTxs: CapturedSolanaTx[];\n  cosmosTxs: CapturedCosmosTx[];\n  // Flips true once the async WarpCore runtime has replaced the synchronous\n  // TokenMetadata entries in the store. Reads that depend on real Token\n  // instances (e.g. useBalance calling token.getBalance) can gate on this.\n  isRuntimeReady?: boolean;\n  // Lazily-populated snapshot of the runtime Token map (key → identity +\n  // direct connections). Populated by markE2ERuntimeReady so tests can\n  // assert on per-route router/mint identity without needing to reach the\n  // review panel (which requires full validation-call mocking).\n  tokens?: E2ETokenSnapshot[];\n}\n\ndeclare global {\n  interface Window {\n    __WARP_E2E__?: WarpE2EState;\n  }\n}\n\nexport function initE2EStateIfEnabled(): void {\n  if (!isE2EMode()) return;\n  if (typeof window === 'undefined') return;\n  if (window.__WARP_E2E__) return;\n  window.__WARP_E2E__ = {\n    readyAt: Date.now(),\n    evmTxs: [],\n    solanaTxs: [],\n    cosmosTxs: [],\n  };\n}\n\n// Note: there is intentionally no `pushEvmTx` helper — EVM tx payloads are\n// captured Node-side via the page.route intercept in tests/e2e-wallet/helpers/\n// evmRpc.ts. The `evmTxs` array on the window state is reserved for a future\n// connector-level capture path.\n\nexport function pushSolanaTx(tx: CapturedSolanaTx): void {\n  if (typeof window === 'undefined') return;\n  if (window.__WARP_E2E__) window.__WARP_E2E__.solanaTxs.push(tx);\n}\n\nexport function pushCosmosTx(tx: CapturedCosmosTx): void {\n  if (typeof window === 'undefined') return;\n  if (window.__WARP_E2E__) window.__WARP_E2E__.cosmosTxs.push(tx);\n}\n\nexport function markE2ERuntimeReady(buildTokens?: () => E2ETokenSnapshot[] | undefined): void {\n  if (typeof window === 'undefined') return;\n  if (!window.__WARP_E2E__) return;\n  window.__WARP_E2E__.isRuntimeReady = true;\n  // Snapshot producer is lazy so the (non-trivial) iteration over route\n  // tokens + their connections only runs when E2E mode is actually active.\n  // In prod the window hook is absent and we bail above before invoking.\n  const snap = buildTokens?.();\n  if (snap) window.__WARP_E2E__.tokens = snap;\n}\n"
  },
  {
    "path": "src/features/wallet/context/AleoWalletContext.tsx",
    "content": "import { AleoPopupProvider } from '@hyperlane-xyz/widgets/walletIntegrations/aleo/AleoProviders';\nimport { AleoWalletProvider } from '@provablehq/aleo-wallet-adaptor-react';\nimport { ShieldWalletAdapter } from '@provablehq/aleo-wallet-adaptor-shield';\nimport { PropsWithChildren } from 'react';\n\nexport function AleoWalletContext({ children }: PropsWithChildren<unknown>) {\n  const wallets = [new ShieldWalletAdapter()];\n\n  return (\n    <AleoWalletProvider wallets={wallets}>\n      <AleoPopupProvider>{children}</AleoPopupProvider>\n    </AleoWalletProvider>\n  );\n}\n"
  },
  {
    "path": "src/features/wallet/context/CosmosWalletContext.tsx",
    "content": "import { ChakraProvider, extendTheme } from '@chakra-ui/react';\nimport { GasPrice } from '@cosmjs/stargate';\nimport { wallets as cosmostationWallets } from '@cosmos-kit/cosmostation';\nimport { wallets as keplrWallets } from '@cosmos-kit/keplr';\nimport { wallets as leapWallets } from '@cosmos-kit/leap';\nimport { ChainProvider } from '@cosmos-kit/react';\nimport { cosmoshub } from '@hyperlane-xyz/registry';\nimport { MultiProtocolProvider } from '@hyperlane-xyz/sdk';\nimport { getCosmosKitChainConfigs } from '@hyperlane-xyz/widgets/walletIntegrations/cosmos';\nimport '@interchain-ui/react/styles';\nimport { PropsWithChildren, useMemo } from 'react';\n\nimport { APP_DESCRIPTION, APP_NAME, APP_URL } from '../../../consts/app';\nimport { config } from '../../../consts/config';\nimport { useMultiProvider } from '../../chains/hooks';\nimport { E2EAutoConnectCosmos } from '../_e2e/E2EAutoConnectCosmos';\nimport { isE2EMode } from '../_e2e/isE2E';\nimport { MockCosmosWallet } from '../_e2e/MockCosmosWallet';\n\nconst theme = extendTheme({\n  fonts: {\n    heading: `'Neue Haas Grotesk', 'Helvetica', 'sans-serif'`,\n    body: `'Neue Haas Grotesk', 'Helvetica', 'sans-serif'`,\n  },\n});\n\nexport function CosmosWalletContext({ children }: PropsWithChildren<unknown>) {\n  const chainMetadata = useMultiProvider().metadata;\n  const { chains, assets } = useMemo(() => {\n    const multiProvider = new MultiProtocolProvider({ ...chainMetadata, cosmoshub });\n    return getCosmosKitChainConfigs(multiProvider);\n  }, [chainMetadata]);\n  const leapWithoutSnap = leapWallets.filter((wallet) => !wallet.walletName.includes('snap'));\n  const e2e = isE2EMode();\n  // In E2E mode use mock-only — the real Keplr/Cosmostation/Leap adapters\n  // each poll for their extension at mount and spam console errors when not\n  // installed, polluting test traces. They are not needed when the mock is\n  // driving the flow.\n  const walletsList = useMemo(() => {\n    if (e2e) return [new MockCosmosWallet()];\n    return [...keplrWallets, ...cosmostationWallets, ...leapWithoutSnap];\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [e2e]);\n  // TODO replace Chakra here with a custom modal for ChainProvider\n  // Using Chakra + @cosmos-kit/react instead of @cosmos-kit/react-lite adds about 600Kb to the bundle\n  return (\n    <ChakraProvider theme={theme}>\n      <ChainProvider\n        chains={chains}\n        assetLists={assets}\n        wallets={walletsList}\n        walletConnectOptions={{\n          signClient: {\n            projectId: config.walletConnectProjectId,\n            metadata: {\n              name: APP_NAME,\n              description: APP_DESCRIPTION,\n              url: APP_URL,\n              icons: [],\n            },\n          },\n        }}\n        signerOptions={{\n          signingCosmwasm: () => {\n            return {\n              // TODO cosmos get gas price from registry or RPC\n              gasPrice: GasPrice.fromString('0.03token'),\n            };\n          },\n          signingStargate: () => {\n            return {\n              // TODO cosmos get gas price from registry or RPC\n              gasPrice: GasPrice.fromString('0.2tia'),\n            };\n          },\n        }}\n        modalTheme={{ defaultTheme: 'light' }}\n      >\n        {e2e && <E2EAutoConnectCosmos />}\n        {children}\n      </ChainProvider>\n    </ChakraProvider>\n  );\n}\n"
  },
  {
    "path": "src/features/wallet/context/EvmWalletContext.tsx",
    "content": "import { MultiProtocolProvider } from '@hyperlane-xyz/sdk';\nimport { getWagmiChainConfigs } from '@hyperlane-xyz/widgets/walletIntegrations/ethereum';\nimport { RainbowKitProvider, connectorsForWallets, lightTheme } from '@rainbow-me/rainbowkit';\n\nimport '@rainbow-me/rainbowkit/styles.css';\nimport {\n  argentWallet,\n  binanceWallet,\n  coinbaseWallet,\n  injectedWallet,\n  ledgerWallet,\n  metaMaskWallet,\n  rainbowWallet,\n  trustWallet,\n  walletConnectWallet,\n} from '@rainbow-me/rainbowkit/wallets';\nimport { PropsWithChildren, useMemo } from 'react';\nimport { createClient, fallback, http } from 'viem';\nimport { WagmiProvider, createConfig, mock } from 'wagmi';\n\nimport { APP_NAME } from '../../../consts/app';\nimport { config } from '../../../consts/config';\nimport { Color } from '../../../styles/Color';\nimport { useMultiProvider } from '../../chains/hooks';\nimport { MOCK_EVM_ADDRESS } from '../_e2e/constants';\nimport { E2EAutoConnectEvm } from '../_e2e/E2EAutoConnectEvm';\nimport { isE2EMode } from '../_e2e/isE2E';\n\nfunction initWagmi(multiProvider: MultiProtocolProvider, e2e: boolean) {\n  const chains = getWagmiChainConfigs(multiProvider);\n\n  const baseConnectors = connectorsForWallets(\n    [\n      {\n        groupName: 'Recommended',\n        wallets: [metaMaskWallet, injectedWallet, walletConnectWallet, ledgerWallet],\n      },\n      {\n        groupName: 'More',\n        wallets: [binanceWallet, coinbaseWallet, rainbowWallet, trustWallet, argentWallet],\n      },\n    ],\n    { appName: APP_NAME, projectId: config.walletConnectProjectId },\n  );\n\n  const connectors = e2e\n    ? [mock({ accounts: [MOCK_EVM_ADDRESS], features: { reconnect: true } })]\n    : baseConnectors;\n\n  const wagmiConfig = createConfig({\n    // Splice to make annoying wagmi type happy\n    chains: [chains[0], ...chains.splice(1)],\n    connectors,\n    client({ chain }) {\n      const transport = fallback(chain.rpcUrls.default.http.map((chainHttp) => http(chainHttp)));\n      return createClient({ chain, transport });\n    },\n  });\n\n  return { wagmiConfig, chains };\n}\n\nexport function EvmWalletContext({ children }: PropsWithChildren<unknown>) {\n  const multiProvider = useMultiProvider();\n  const e2e = isE2EMode();\n  const { wagmiConfig } = useMemo(() => initWagmi(multiProvider, e2e), [multiProvider, e2e]);\n\n  return (\n    <WagmiProvider config={wagmiConfig}>\n      <RainbowKitProvider\n        theme={lightTheme({\n          accentColor: Color.primary['500'],\n          borderRadius: 'small',\n          fontStack: 'system',\n        })}\n      >\n        {e2e && <E2EAutoConnectEvm />}\n        {children}\n      </RainbowKitProvider>\n    </WagmiProvider>\n  );\n}\n"
  },
  {
    "path": "src/features/wallet/context/RadixWalletContext.tsx",
    "content": "import { AccountProvider } from '@hyperlane-xyz/widgets/walletIntegrations/radix/AccountContext';\nimport '@interchain-ui/react/styles';\nimport {\n  GatewayApiProvider,\n  PopupProvider,\n  RdtProvider,\n} from '@hyperlane-xyz/widgets/walletIntegrations/radix/RadixProviders';\nimport { GatewayApiClient } from '@radixdlt/babylon-gateway-api-sdk';\nimport { RadixDappToolkit, RadixNetwork } from '@radixdlt/radix-dapp-toolkit';\nimport { PropsWithChildren } from 'react';\n\nimport { APP_NAME } from '../../../consts/app';\nimport { E2EAutoConnectRadix } from '../_e2e/E2EAutoConnectRadix';\nimport { isE2EMode } from '../_e2e/isE2E';\n\nexport function RadixWalletContext({ children }: PropsWithChildren<unknown>) {\n  const rdt = RadixDappToolkit({\n    networkId: RadixNetwork.Mainnet,\n    applicationVersion: '1.0.0',\n    applicationName: APP_NAME,\n    dAppDefinitionAddress: 'account_rdx12ycz0wsuygqa5slye9du6e7wz7fr4pzx39l5r5cznqc6yudpks20cw',\n    useCache: false,\n  });\n\n  const gatewayApi = GatewayApiClient.initialize(rdt.gatewayApi.clientConfig);\n\n  return (\n    <RdtProvider value={rdt}>\n      <GatewayApiProvider value={gatewayApi}>\n        <AccountProvider>\n          <PopupProvider>\n            {isE2EMode() && <E2EAutoConnectRadix />}\n            {children}\n          </PopupProvider>\n        </AccountProvider>\n      </GatewayApiProvider>\n    </RdtProvider>\n  );\n}\n"
  },
  {
    "path": "src/features/wallet/context/SolanaWalletContext.tsx",
    "content": "import { SnapWalletAdapter } from '@drift-labs/snap-wallet-adapter';\nimport { WalletAdapterNetwork, WalletError } from '@solana/wallet-adapter-base';\nimport { ConnectionProvider, WalletProvider } from '@solana/wallet-adapter-react';\nimport { WalletModalProvider } from '@solana/wallet-adapter-react-ui';\n\nimport '@solana/wallet-adapter-react-ui/styles.css';\nimport {\n  LedgerWalletAdapter,\n  SalmonWalletAdapter,\n  SolflareWalletAdapter,\n  TrustWalletAdapter,\n  PhantomWalletAdapter,\n  BackpackWalletAdapter,\n} from '@solana/wallet-adapter-wallets';\nimport { clusterApiUrl } from '@solana/web3.js';\nimport { PropsWithChildren, useCallback, useMemo } from 'react';\nimport { toast } from 'react-toastify';\n\nimport { logger } from '../../../utils/logger';\nimport { E2EAutoConnectSolana } from '../_e2e/E2EAutoConnectSolana';\nimport { isE2EMode } from '../_e2e/isE2E';\nimport { MockSolanaAdapter } from '../_e2e/MockSolanaAdapter';\n\nexport function SolanaWalletContext({ children }: PropsWithChildren<unknown>) {\n  // TODO support multiple networks\n  const network = WalletAdapterNetwork.Mainnet;\n  const endpoint = useMemo(() => clusterApiUrl(network), [network]);\n  const e2e = isE2EMode();\n  const wallets = useMemo(\n    () => {\n      const real = [\n        new PhantomWalletAdapter(),\n        new BackpackWalletAdapter(),\n        new SolflareWalletAdapter(),\n        new SalmonWalletAdapter(),\n        new SnapWalletAdapter(),\n        new TrustWalletAdapter(),\n        new LedgerWalletAdapter(),\n      ];\n      return e2e ? [new MockSolanaAdapter()] : real;\n    },\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [network, e2e],\n  );\n\n  const onError = useCallback((error: WalletError) => {\n    logger.error('Error initializing Solana wallet provider', error);\n    toast.error('Error preparing Solana wallet');\n  }, []);\n\n  return (\n    <ConnectionProvider endpoint={endpoint}>\n      <WalletProvider wallets={wallets} onError={onError} autoConnect>\n        <WalletModalProvider>\n          {e2e && <E2EAutoConnectSolana />}\n          {children}\n        </WalletModalProvider>\n      </WalletProvider>\n    </ConnectionProvider>\n  );\n}\n"
  },
  {
    "path": "src/features/wallet/context/StarknetWalletContext.tsx",
    "content": "import { starknetsepolia } from '@hyperlane-xyz/registry';\nimport { chainMetadataToStarknetChain } from '@hyperlane-xyz/sdk';\nimport { getStarknetChains } from '@hyperlane-xyz/widgets/walletIntegrations/starknet';\nimport { Chain } from '@starknet-react/chains';\nimport { StarknetConfig, publicProvider, voyager } from '@starknet-react/core';\nimport { PropsWithChildren, useMemo } from 'react';\nimport { InjectedConnector } from 'starknetkit/injected';\n\nimport { useMultiProvider } from '../../chains/hooks';\nimport { E2EAutoConnectStarknet } from '../_e2e/E2EAutoConnectStarknet';\nimport { isE2EMode } from '../_e2e/isE2E';\nimport { createMockStarknetConnector } from '../_e2e/MockStarknetConnector';\n\nconst initialChain = chainMetadataToStarknetChain(starknetsepolia);\nconst READY_WALLET_ICON = `data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjMyIiBoZWlnaHQ9IjMyIiByeD0iOCIgZmlsbD0iYmxhY2siLz4KPHBhdGggZD0iTTE4LjQwMTggNy41NTU1NkgxMy41OTgyQzEzLjQzNzcgNy41NTU1NiAxMy4zMDkxIDcuNjg3NDcgMTMuMzA1NiA3Ljg1MTQzQzEzLjIwODUgMTIuNDYwMyAxMC44NDg0IDE2LjgzNDcgNi43ODYwOCAxOS45MzMxQzYuNjU3MTEgMjAuMDMxNCA2LjYyNzczIDIwLjIxNjIgNi43MjIwMiAyMC4zNDkzTDkuNTMyNTMgMjQuMzE5NkM5LjYyODE1IDI0LjQ1NDggOS44MTQ0NCAyNC40ODUzIDkuOTQ1NTggMjQuMzg2QzEyLjQ4NTYgMjIuNDYxMyAxNC41Mjg3IDIwLjEzOTUgMTYgMTcuNTY2QzE3LjQ3MTMgMjAuMTM5NSAxOS41MTQ1IDIyLjQ2MTMgMjIuMDU0NSAyNC4zODZDMjIuMTg1NiAyNC40ODUzIDIyLjM3MTkgMjQuNDU0OCAyMi40Njc2IDI0LjMxOTZMMjUuMjc4MSAyMC4zNDkzQzI1LjM3MjMgMjAuMjE2MiAyNS4zNDI5IDIwLjAzMTQgMjUuMjE0IDE5LjkzMzFDMjEuMTUxNiAxNi44MzQ3IDE4Ljc5MTUgMTIuNDYwMyAxOC42OTQ2IDcuODUxNDNDMTguNjkxMSA3LjY4NzQ3IDE4LjU2MjMgNy41NTU1NiAxOC40MDE4IDcuNTU1NTZaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMjQuNzIzNiAxMC40OTJMMjQuMjIzMSA4LjkyNDM5QzI0LjEyMTMgOC42MDYxNCAyMy44NzM0IDguMzU4MjQgMjMuNTU3NyA4LjI2MDIzTDIyLjAwMzkgNy43NzU5NUMyMS43ODk1IDcuNzA5MDYgMjEuNzg3MyA3LjQwMTc3IDIyLjAwMTEgNy4zMzIwMUwyMy41NDY5IDYuODI0NjZDMjMuODYwOSA2LjcyMTQ2IDI0LjEwNiA2LjQ2OTUyIDI0LjIwMjcgNi4xNTAxMUwyNC42Nzk4IDQuNTc1MDJDMjQuNzQ1OCA0LjM1NzA5IDI1LjA0ODkgNC4zNTQ3NyAyNS4xMTgzIDQuNTcxNTZMMjUuNjE4OCA2LjEzOTE1QzI1LjcyMDYgNi40NTc0IDI1Ljk2ODYgNi43MDUzMSAyNi4yODQyIDYuODAzOUwyNy44MzggNy4yODc2MUMyOC4wNTI0IDcuMzU0NSAyOC4wNTQ3IDcuNjYxNzkgMjcuODQwOCA3LjczMjEzTDI2LjI5NSA4LjIzOTQ4QzI1Ljk4MTEgOC4zNDIxIDI1LjczNiA4LjU5NDA0IDI1LjYzOTMgOC45MTQwMkwyNS4xNjIxIDEwLjQ4ODVDMjUuMDk2MSAxMC43MDY1IDI0Ljc5MyAxMC43MDg4IDI0LjcyMzYgMTAuNDkyWiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==`;\n\nexport function StarknetWalletContext({ children }: PropsWithChildren<unknown>) {\n  const multiProvider = useMultiProvider();\n  const chainsFromRegistry = getStarknetChains(multiProvider);\n  const e2e = isE2EMode();\n  const connectors = useMemo(() => {\n    const real = [\n      new InjectedConnector({ options: { id: 'braavos', name: 'Braavos' } }),\n      new InjectedConnector({\n        options: { id: 'argentX', name: 'Ready Wallet (formerly Argent)', icon: READY_WALLET_ICON },\n      }),\n      new InjectedConnector({ options: { id: 'keplr', name: 'Keplr' } }),\n      new InjectedConnector({ options: { id: 'metamask', name: 'MetaMask Snap' } }),\n      new InjectedConnector({ options: { id: 'okxwallet', name: 'OKX' } }),\n    ];\n    return e2e ? [createMockStarknetConnector()] : real;\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [e2e]);\n\n  // Because at least one chain is required, we need an initial chain here,\n  // because chains are built based on MultiProvider and existing chains from warp routes\n  const uniqueChains = useMemo(() => {\n    //\n    const combinedChains = [...chainsFromRegistry, initialChain];\n    const chainMap = combinedChains.reduce((map, chain) => {\n      if (!map.has(chain.id)) {\n        map.set(chain.id, chain);\n      }\n      return map;\n    }, new Map<bigint, Chain>());\n    return Array.from(chainMap.values());\n  }, [chainsFromRegistry]);\n\n  return (\n    <StarknetConfig\n      chains={uniqueChains}\n      provider={publicProvider()}\n      connectors={connectors}\n      explorer={voyager}\n      autoConnect\n    >\n      {e2e && <E2EAutoConnectStarknet />}\n      {children}\n    </StarknetConfig>\n  );\n}\n"
  },
  {
    "path": "src/features/wallet/context/TronWalletContext.tsx",
    "content": "import { WalletProvider } from '@tronweb3/tronwallet-adapter-react-hooks';\nimport { TronLinkAdapter } from '@tronweb3/tronwallet-adapter-tronlink';\nimport { PropsWithChildren, useMemo } from 'react';\n\nimport { E2EAutoConnectTron } from '../_e2e/E2EAutoConnectTron';\nimport { isE2EMode } from '../_e2e/isE2E';\nimport { MockTronAdapter } from '../_e2e/MockTronAdapter';\n\nexport function TronWalletContext({ children }: PropsWithChildren<unknown>) {\n  const e2e = isE2EMode();\n  const adapters = useMemo(() => {\n    const real = [new TronLinkAdapter()];\n    return e2e ? [new MockTronAdapter()] : real;\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [e2e]);\n  return (\n    <WalletProvider adapters={adapters}>\n      {e2e && <E2EAutoConnectTron />}\n      {children}\n    </WalletProvider>\n  );\n}\n"
  },
  {
    "path": "src/features/wallet/relativeTimeTicker.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { startRelativeTimeTicker } from './relativeTimeTicker';\n\nfunction createFakeDocument(initialVisibilityState: DocumentVisibilityState = 'visible') {\n  let visibilityState = initialVisibilityState;\n  const listeners = new Set<() => void>();\n\n  return {\n    get visibilityState() {\n      return visibilityState;\n    },\n    setVisibilityState(nextVisibilityState: DocumentVisibilityState) {\n      visibilityState = nextVisibilityState;\n    },\n    addEventListener(type: 'visibilitychange', listener: () => void) {\n      if (type === 'visibilitychange') listeners.add(listener);\n    },\n    removeEventListener(type: 'visibilitychange', listener: () => void) {\n      if (type === 'visibilitychange') listeners.delete(listener);\n    },\n    dispatchVisibilityChange() {\n      listeners.forEach((listener) => listener());\n    },\n  };\n}\n\ndescribe('startRelativeTimeTicker', () => {\n  beforeEach(() => {\n    vi.useFakeTimers();\n  });\n\n  afterEach(() => {\n    vi.useRealTimers();\n  });\n\n  it('ticks immediately and on the configured interval', () => {\n    const onTick = vi.fn();\n    const documentObject = createFakeDocument();\n    const stop = startRelativeTimeTicker({\n      onTick,\n      intervalMs: 30000,\n      documentObject,\n      timerApi: globalThis,\n    });\n\n    expect(onTick).toHaveBeenCalledTimes(1);\n\n    vi.advanceTimersByTime(30000);\n    expect(onTick).toHaveBeenCalledTimes(2);\n\n    stop();\n  });\n\n  it('ticks when the tab becomes visible again', () => {\n    const onTick = vi.fn();\n    const documentObject = createFakeDocument('hidden');\n    const stop = startRelativeTimeTicker({\n      onTick,\n      intervalMs: 30000,\n      documentObject,\n      timerApi: globalThis,\n    });\n\n    expect(onTick).toHaveBeenCalledTimes(1);\n\n    documentObject.setVisibilityState('visible');\n    documentObject.dispatchVisibilityChange();\n\n    expect(onTick).toHaveBeenCalledTimes(2);\n\n    stop();\n  });\n});\n"
  },
  {
    "path": "src/features/wallet/relativeTimeTicker.ts",
    "content": "interface VisibilityDocument {\n  visibilityState: DocumentVisibilityState;\n  addEventListener: (\n    type: 'visibilitychange',\n    listener: () => void,\n    options?: boolean | AddEventListenerOptions,\n  ) => void;\n  removeEventListener: (\n    type: 'visibilitychange',\n    listener: () => void,\n    options?: boolean | EventListenerOptions,\n  ) => void;\n}\n\ninterface StartRelativeTimeTickerOptions {\n  onTick: () => void;\n  intervalMs?: number;\n  documentObject?: VisibilityDocument;\n  timerApi?: Pick<typeof globalThis, 'setInterval' | 'clearInterval'>;\n}\n\nexport function startRelativeTimeTicker({\n  onTick,\n  intervalMs = 30000,\n  documentObject = document,\n  timerApi = globalThis,\n}: StartRelativeTimeTickerOptions) {\n  onTick();\n\n  const intervalId = timerApi.setInterval(onTick, intervalMs);\n  const handleVisibilityChange = () => {\n    if (documentObject.visibilityState === 'visible') onTick();\n  };\n\n  documentObject.addEventListener('visibilitychange', handleVisibilityChange);\n\n  return () => {\n    timerApi.clearInterval(intervalId);\n    documentObject.removeEventListener('visibilitychange', handleVisibilityChange);\n  };\n}\n"
  },
  {
    "path": "src/features/warpCore/AddWarpConfigModal.tsx",
    "content": "import { BaseRegistry } from '@hyperlane-xyz/registry';\nimport { MultiProtocolProvider, WarpCoreConfig, WarpCoreConfigSchema } from '@hyperlane-xyz/sdk';\nimport { failure, Result, success, tryParseJsonOrYaml } from '@hyperlane-xyz/utils';\nimport { Button, CopyButton, IconButton, Modal, PlusIcon, XIcon } from '@hyperlane-xyz/widgets';\nimport clsx from 'clsx';\nimport { useState } from 'react';\nimport { toast } from 'react-toastify';\n\nimport { Color } from '../../styles/Color';\nimport { logger } from '../../utils/logger';\nimport { useMultiProvider } from '../chains/hooks';\nimport { useStore } from '../store';\n\nexport function AddWarpConfigModal({ isOpen, close }: { isOpen: boolean; close: () => void }) {\n  const { warpCoreConfigOverrides, setWarpCoreConfigOverrides } = useStore(\n    ({ warpCoreConfigOverrides, setWarpCoreConfigOverrides }) => ({\n      warpCoreConfigOverrides,\n      setWarpCoreConfigOverrides,\n    }),\n  );\n\n  const onAddConfig = (warpCoreConfig: WarpCoreConfig) => {\n    setWarpCoreConfigOverrides([...warpCoreConfigOverrides, warpCoreConfig]);\n    toast.success('Warp config added!');\n    close();\n  };\n\n  const onRemoveConfig = (index: number) => {\n    setWarpCoreConfigOverrides(warpCoreConfigOverrides.filter((_, i) => i !== index));\n    toast.success('Warp config removed');\n  };\n\n  return (\n    <Modal\n      isOpen={isOpen}\n      close={close}\n      panelClassname=\"px-4 py-3 max-w-lg flex flex-col items-center gap-2\"\n    >\n      <h2 className=\"text-center text-primary-500\">Add Warp Route Configs</h2>\n      <p className=\"text-xs\">\n        Add warp route configs, like those from the Hyperlane CLI. Note, these routes will be\n        available only in your own browser.\n      </p>\n      <Form onAdd={onAddConfig} />\n      <ConfigList warpCoreConfigOverrides={warpCoreConfigOverrides} onRemove={onRemoveConfig} />\n    </Modal>\n  );\n}\n\n// TODO de-dupe with Form in ChainAddMenu in widgets lib\nfunction Form({ onAdd }: { onAdd: (warpCoreConfig: WarpCoreConfig) => void }) {\n  const multiProvider = useMultiProvider();\n  const [textInput, setTextInput] = useState('');\n  const [error, setError] = useState<any>(null);\n\n  const onChangeInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {\n    setTextInput(e.target.value);\n    setError(null);\n  };\n\n  const onClickAdd = () => {\n    const result = tryParseConfigInput(textInput, multiProvider);\n    if (result.success) {\n      onAdd(result.data);\n    } else {\n      setError(`Invalid config: ${result.error}`);\n    }\n  };\n\n  return (\n    <>\n      <div className=\"relative w-full\">\n        <textarea\n          className={clsx(\n            'min-h-72 w-full resize rounded-sm border border-gray-200 p-2 text-xs outline-none focus:border-gray-400',\n            error && 'border-red-500',\n          )}\n          placeholder={placeholderText}\n          value={textInput}\n          onChange={onChangeInput}\n        ></textarea>\n        {error && <div className=\"text-xs text-red-600\">{error}</div>}\n        <CopyButton\n          copyValue={textInput || placeholderText}\n          width={14}\n          height={14}\n          className=\"absolute right-5 top-3\"\n        />\n      </div>\n      <Button\n        onClick={onClickAdd}\n        className=\"w-full gap-1 bg-accent-500 px-3 py-1.5 text-sm text-white\"\n      >\n        <PlusIcon width={20} height={20} color={Color.white} />\n        <span>Add Config</span>\n      </Button>\n    </>\n  );\n}\n\nfunction ConfigList({\n  warpCoreConfigOverrides,\n  onRemove,\n}: {\n  warpCoreConfigOverrides: WarpCoreConfig[];\n  onRemove: (index: number) => void;\n}) {\n  if (!warpCoreConfigOverrides.length) return null;\n\n  return (\n    <div className=\"mt-2 flex w-full flex-col gap-2 border-t pt-3\">\n      {warpCoreConfigOverrides.map((config, i) => (\n        <div key={i} className=\"flex items-center justify-between gap-1\">\n          <span className=\"truncate text-xs\">{BaseRegistry.warpRouteConfigToId(config)}</span>\n          <IconButton onClick={() => onRemove(i)} title=\"Remove config\">\n            <XIcon width={10} height={10} color={Color.gray['800']} />\n          </IconButton>\n        </div>\n      ))}\n    </div>\n  );\n}\n\nfunction tryParseConfigInput(\n  input: string,\n  multiProvider: MultiProtocolProvider,\n): Result<WarpCoreConfig> {\n  const parsed = tryParseJsonOrYaml(input);\n  if (!parsed.success) return parsed;\n\n  const result = WarpCoreConfigSchema.safeParse(parsed.data);\n\n  if (!result.success) {\n    logger.warn('Error validating warp config', result.error);\n    const firstIssue = result.error.issues[0];\n    return failure(`${firstIssue.path} => ${firstIssue.message}`);\n  }\n\n  const warpConfig = result.data;\n  const warpChains = warpConfig.tokens.map((t) => t.chainName);\n  const unknownChain = warpChains.find((c) => !multiProvider.hasChain(c));\n\n  if (unknownChain) {\n    return failure(`Unknown chain: ${unknownChain}`);\n  }\n\n  return success(result.data);\n}\n\nconst placeholderText = `# YAML config data\n---\ntokens:\n  - addressOrDenom: \"0x123...\"\n    chainName: ethereum\n    collateralAddressOrDenom: \"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48\"\n    connections:\n      - token: ethereum|mycoolchain|0x345...\n    decimals: 6\n    name: USDC\n    standard: EvmHypCollateral\n    symbol: USDC\n  - addressOrDenom: \"0x345...\"\n    chainName: mycoolchain\n    connections:\n      - token: ethereum|ethereum|0x123...\n    decimals: 6\n    name: USDC\n    standard: EvmHypSynthetic\n    symbol: USDC\noptions: {}\n`;\n"
  },
  {
    "path": "src/features/warpCore/warpCoreConfig.test.ts",
    "content": "import { TokenStandard } from '@hyperlane-xyz/sdk';\nimport { describe, expect, test } from 'vitest';\n\nimport { dedupeTokens, NullableAddressWarpCoreToken } from './warpCoreConfig';\n\nconst makeToken = (\n  overrides: Partial<NullableAddressWarpCoreToken>,\n): NullableAddressWarpCoreToken =>\n  ({\n    decimals: 6,\n    name: 'Mock',\n    ...overrides,\n  }) as NullableAddressWarpCoreToken;\n\ndescribe('dedupeTokens', () => {\n  test('should dedupe non-M0 tokens by chainName|addressOrDenom', () => {\n    // Two identical IBC token definitions (same address, same chain) — merge into one.\n    const t1 = makeToken({\n      chainName: 'neutron',\n      symbol: 'USDC',\n      standard: TokenStandard.CosmosIbc,\n      addressOrDenom: 'ibc/ABC',\n    });\n    const t2 = makeToken({\n      chainName: 'neutron',\n      symbol: 'USDC',\n      standard: TokenStandard.CosmosIbc,\n      addressOrDenom: 'ibc/ABC',\n    });\n\n    expect(dedupeTokens([t1, t2])).toHaveLength(1);\n  });\n\n  test('should keep non-M0 tokens with different addressOrDenom distinct', () => {\n    const t1 = makeToken({\n      chainName: 'ethereum',\n      symbol: 'USDC',\n      standard: TokenStandard.EvmHypCollateral,\n      addressOrDenom: '0x1111111111111111111111111111111111111111',\n    });\n    const t2 = makeToken({\n      chainName: 'ethereum',\n      symbol: 'USDC',\n      standard: TokenStandard.EvmHypCollateral,\n      addressOrDenom: '0x2222222222222222222222222222222222222222',\n    });\n\n    expect(dedupeTokens([t1, t2])).toHaveLength(2);\n  });\n\n  test('should NOT merge EvmM0Portal tokens sharing addressOrDenom but different symbols', () => {\n    // wM, USDSC, USDnr all use the same ethereum portal contract but wrap different collaterals\n    const M0_HUB = '0xD925C84b55E4e44a53749fF5F2a5A13F63D128fd';\n    const wm = makeToken({\n      chainName: 'ethereum',\n      symbol: 'wM',\n      standard: TokenStandard.EvmM0Portal,\n      addressOrDenom: M0_HUB,\n      collateralAddressOrDenom: '0x437cc33344a0B27A429f795ff6B469C72698B291',\n      warpRouteId: 'wM/wrapped-m',\n    });\n    const usdsc = makeToken({\n      chainName: 'ethereum',\n      symbol: 'USDSC',\n      standard: TokenStandard.EvmM0Portal,\n      addressOrDenom: M0_HUB,\n      collateralAddressOrDenom: '0x3f99231dD03a9F0E7e3421c92B7b90fbe012985a',\n      warpRouteId: 'USDSC/usdsc',\n    });\n    const usdnr = makeToken({\n      chainName: 'ethereum',\n      symbol: 'USDnr',\n      standard: TokenStandard.EvmM0Portal,\n      addressOrDenom: M0_HUB,\n      collateralAddressOrDenom: '0xD48e565561416dE59DA1050ED70b8d75e8eF28f9',\n      warpRouteId: 'USDnr/usdnr',\n    });\n\n    const result = dedupeTokens([wm, usdsc, usdnr]);\n    expect(result).toHaveLength(3);\n    expect(result.map((t) => t.symbol).sort()).toEqual(['USDSC', 'USDnr', 'wM']);\n  });\n\n  test('should NOT merge EvmM0PortalLite tokens sharing addressOrDenom but different symbols', () => {\n    // wM Lite and mUSD share the same bsc portal-lite contract\n    const M0_LITE = '0x36f586A30502AE3afb555b8aA4dCc05d233c2ecE';\n    const wmLite = makeToken({\n      chainName: 'bsc',\n      symbol: 'wM',\n      standard: TokenStandard.EvmM0PortalLite,\n      addressOrDenom: M0_LITE,\n      collateralAddressOrDenom: '0x437cc33344a0B27A429f795ff6B469C72698B291',\n      warpRouteId: 'wM/wrapped-m-portal-lite',\n    });\n    const musd = makeToken({\n      chainName: 'bsc',\n      symbol: 'mUSD',\n      standard: TokenStandard.EvmM0PortalLite,\n      addressOrDenom: M0_LITE,\n      collateralAddressOrDenom: '0xaca92e438df0b2401ff60da7e4337b687a2435da',\n      warpRouteId: 'mUSD/musd',\n    });\n\n    const result = dedupeTokens([wmLite, musd]);\n    expect(result).toHaveLength(2);\n  });\n\n  test('should still merge M0Portal duplicates with same chainName+symbol+addressOrDenom', () => {\n    // Identical wM entries (e.g. fetched from registry and also from yaml) should merge\n    const wm1 = makeToken({\n      chainName: 'ethereum',\n      symbol: 'wM',\n      standard: TokenStandard.EvmM0Portal,\n      addressOrDenom: '0xD925C84b55E4e44a53749fF5F2a5A13F63D128fd',\n      collateralAddressOrDenom: '0x437cc33344a0B27A429f795ff6B469C72698B291',\n    });\n    const wm2 = makeToken({\n      chainName: 'ethereum',\n      symbol: 'wM',\n      standard: TokenStandard.EvmM0Portal,\n      addressOrDenom: '0xD925C84b55E4e44a53749fF5F2a5A13F63D128fd',\n      collateralAddressOrDenom: '0x437cc33344a0B27A429f795ff6B469C72698B291',\n    });\n\n    expect(dedupeTokens([wm1, wm2])).toHaveLength(1);\n  });\n\n  test('should keep M0Portal Hub and PortalLite variants distinct on same chain (different addresses)', () => {\n    // wM on ethereum exists in both wrapped-m (Hub) and wrapped-m-portal-lite (Lite) configs\n    const wmHub = makeToken({\n      chainName: 'ethereum',\n      symbol: 'wM',\n      standard: TokenStandard.EvmM0Portal,\n      addressOrDenom: '0xD925C84b55E4e44a53749fF5F2a5A13F63D128fd',\n      collateralAddressOrDenom: '0x437cc33344a0B27A429f795ff6B469C72698B291',\n    });\n    const wmLite = makeToken({\n      chainName: 'ethereum',\n      symbol: 'wM',\n      standard: TokenStandard.EvmM0PortalLite,\n      addressOrDenom: '0x36f586A30502AE3afb555b8aA4dCc05d233c2ecE',\n      collateralAddressOrDenom: '0x437cc33344a0B27A429f795ff6B469C72698B291',\n    });\n\n    const result = dedupeTokens([wmHub, wmLite]);\n    expect(result).toHaveLength(2);\n  });\n\n  test('should handle mix of M0 and non-M0 tokens', () => {\n    const ibc = makeToken({\n      chainName: 'neutron',\n      symbol: 'USDC',\n      standard: TokenStandard.CosmosIbc,\n      addressOrDenom: 'ibc/ABC',\n    });\n    const ibcDup = makeToken({\n      chainName: 'neutron',\n      symbol: 'USDC',\n      standard: TokenStandard.CosmosIbc,\n      addressOrDenom: 'ibc/ABC',\n    });\n    const wm = makeToken({\n      chainName: 'ethereum',\n      symbol: 'wM',\n      standard: TokenStandard.EvmM0Portal,\n      addressOrDenom: '0xD925C84b55E4e44a53749fF5F2a5A13F63D128fd',\n    });\n    const usdsc = makeToken({\n      chainName: 'ethereum',\n      symbol: 'USDSC',\n      standard: TokenStandard.EvmM0Portal,\n      addressOrDenom: '0xD925C84b55E4e44a53749fF5F2a5A13F63D128fd',\n    });\n\n    const result = dedupeTokens([ibc, ibcDup, wm, usdsc]);\n    // IBC merged to 1, wM + USDSC stay as 2 → 3 total\n    expect(result).toHaveLength(3);\n  });\n});\n"
  },
  {
    "path": "src/features/warpCore/warpCoreConfig.ts",
    "content": "import {\n  IRegistry,\n  warpRouteConfigs as publishedRegistryWarpRoutes,\n} from '@hyperlane-xyz/registry';\nimport {\n  TOKEN_STANDARD_TO_PROTOCOL,\n  TokenStandard,\n  WarpCoreConfig,\n  WarpCoreConfigSchema,\n  getTokenConnectionId,\n  validateZodResult,\n} from '@hyperlane-xyz/sdk';\nimport { isObjEmpty, objFilter, objMerge } from '@hyperlane-xyz/utils';\n\nimport { config } from '../../consts/config.ts';\nimport { warpRouteConfigs as tsWarpRoutes } from '../../consts/warpRoutes.ts';\nimport yamlWarpRoutes from '../../consts/warpRoutes.yaml';\nimport { getWarpRouteWhitelist, warpRouteWhitelist } from '../../consts/warpRouteWhitelist.ts';\nimport { logger } from '../../utils/logger.ts';\n\ntype WarpCoreToken = WarpCoreConfig['tokens'][number];\nexport type NullableAddressWarpCoreToken = Omit<WarpCoreToken, 'addressOrDenom'> & {\n  addressOrDenom: string | null;\n};\n\nexport async function assembleWarpCoreConfig(\n  storeOverrides: WarpCoreConfig[],\n  registry: IRegistry,\n): Promise<{ config: WarpCoreConfig }> {\n  const yamlResult = WarpCoreConfigSchema.safeParse(yamlWarpRoutes);\n  const yamlConfig = validateZodResult(yamlResult, 'warp core yaml config');\n  const tsResult = WarpCoreConfigSchema.safeParse(tsWarpRoutes);\n  const tsConfig = validateZodResult(tsResult, 'warp core typescript config');\n\n  let registryWarpRoutes: Record<string, WarpCoreConfig>;\n\n  try {\n    if (config.registryUrl) {\n      logger.debug('Using custom registry warp routes from:', config.registryUrl);\n      registryWarpRoutes = await registry.getWarpRoutes();\n\n      // Safety fallback for whitelisted routes that may exist as per-route files\n      // before they are generated into warpRouteConfigs.yaml.\n      if (warpRouteWhitelist?.length) {\n        const uppercaseRouteIds = new Set(\n          Object.keys(registryWarpRoutes).map((routeId) => routeId.toUpperCase()),\n        );\n        const missingRouteIds = warpRouteWhitelist.filter(\n          (routeId) => !uppercaseRouteIds.has(routeId.toUpperCase()),\n        );\n\n        if (missingRouteIds.length) {\n          const routeEntries = await Promise.all(\n            missingRouteIds.map(\n              async (routeId): Promise<[string, WarpCoreConfig | null]> => [\n                routeId,\n                await registry.getWarpRoute(routeId),\n              ],\n            ),\n          );\n          for (const [routeId, routeConfig] of routeEntries) {\n            if (routeConfig) registryWarpRoutes[routeId] = routeConfig;\n          }\n        }\n      }\n      if (isObjEmpty(registryWarpRoutes)) throw new Error('Warp routes empty');\n    } else {\n      throw new Error('No custom registry URL provided');\n    }\n  } catch (error) {\n    // Browser/runtime environments can occasionally fail on getWarpRoutes() due to large payloads,\n    // rate limits, or transport issues. For whitelisted flows, try fetching routes one-by-one.\n    if (config.registryUrl && warpRouteWhitelist?.length) {\n      try {\n        logger.debug(\n          'getWarpRoutes() failed; attempting per-route registry fallback for whitelist IDs',\n          error,\n        );\n        const routeEntries = await Promise.all(\n          warpRouteWhitelist.map(\n            async (routeId): Promise<[string, WarpCoreConfig | null]> => [\n              routeId,\n              await registry.getWarpRoute(routeId),\n            ],\n          ),\n        );\n        const fallbackRoutes = routeEntries.reduce<Record<string, WarpCoreConfig>>(\n          (acc, [routeId, routeConfig]) => {\n            if (routeConfig) acc[routeId] = routeConfig;\n            return acc;\n          },\n          {},\n        );\n        if (!isObjEmpty(fallbackRoutes)) {\n          logger.debug('Using per-route whitelist fallback from registry.getWarpRoute');\n          registryWarpRoutes = fallbackRoutes;\n        } else {\n          throw new Error('Per-route fallback returned no warp routes');\n        }\n      } catch (routeError) {\n        logger.debug(\n          'Per-route whitelist fallback failed; using published registry routes',\n          routeError,\n        );\n        registryWarpRoutes = publishedRegistryWarpRoutes;\n      }\n    } else {\n      logger.debug('Using default published registry for warp routes');\n      registryWarpRoutes = publishedRegistryWarpRoutes;\n    }\n  }\n\n  const effectiveWhitelist = getWarpRouteWhitelist();\n  let filteredRegistryConfigMap = effectiveWhitelist\n    ? filterToIds(registryWarpRoutes, effectiveWhitelist)\n    : registryWarpRoutes;\n  filteredRegistryConfigMap = fillMissingCoinGeckoIds(filteredRegistryConfigMap);\n\n  const filteredRegistryConfigValues = Object.values(filteredRegistryConfigMap);\n  const filteredRegistryTokens = filteredRegistryConfigValues.map((c) => c.tokens).flat();\n  const filteredRegistryOptions = filteredRegistryConfigValues.map((c) => c.options).flat();\n\n  const storeOverrideTokens = storeOverrides.map((c) => c.tokens).flat();\n  const storeOverrideOptions = storeOverrides.map((c) => c.options).flat();\n\n  // Type assertion needed: Zod's z.infer widens {numerator: bigint} to\n  // {numerator: string | bigint} when tokens are spread across arrays.\n  const combinedTokens = [\n    ...filteredRegistryTokens,\n    ...tsConfig.tokens,\n    ...yamlConfig.tokens,\n    ...storeOverrideTokens,\n  ] as NullableAddressWarpCoreToken[];\n  const tokens = filterUnconnectedToken(dedupeTokens(combinedTokens));\n\n  const combinedOptions = [\n    ...filteredRegistryOptions,\n    tsConfig.options,\n    yamlConfig.options,\n    ...storeOverrideOptions,\n  ];\n  const options = reduceOptions(combinedOptions);\n\n  if (!tokens.length)\n    throw new Error(\n      'No warp route configs provided. Please check your registry, warp route whitelist, and custom route configs for issues.',\n    );\n\n  return { config: { tokens, options } };\n}\n\n// Fill missing coinGeckoIds within each warp route\n// For each route, if any token has a coinGeckoId, apply it to tokens without one\nfunction fillMissingCoinGeckoIds(\n  routes: Record<string, WarpCoreConfig>,\n): Record<string, WarpCoreConfig> {\n  return Object.entries(routes).reduce<Record<string, WarpCoreConfig>>((acc, [routeId, config]) => {\n    // Find first coinGeckoId in this route's tokens\n    const coinGeckoId = config.tokens.find((token) => token.coinGeckoId)?.coinGeckoId;\n\n    if (coinGeckoId) {\n      // Apply coinGeckoId to all tokens in this route that don't have one\n      const updatedTokens = config.tokens.map((token) => ({\n        ...token,\n        coinGeckoId: token.coinGeckoId || coinGeckoId,\n      }));\n      acc[routeId] = {\n        ...config,\n        tokens: updatedTokens,\n      };\n    } else {\n      // No coinGeckoId found, keep route as is\n      acc[routeId] = config;\n    }\n    return acc;\n  }, {});\n}\n\nfunction filterToIds(\n  config: Record<string, WarpCoreConfig>,\n  idWhitelist: string[],\n): Record<string, WarpCoreConfig> {\n  return objFilter(config, (id, c): c is WarpCoreConfig =>\n    idWhitelist.map((id) => id.toUpperCase()).includes(id.toUpperCase()),\n  );\n}\n\n// Separate warp configs may contain duplicate definitions of the same token.\n// E.g. an IBC token that gets used for interchain gas in many different routes.\nexport function dedupeTokens(\n  tokens: NullableAddressWarpCoreToken[],\n): NullableAddressWarpCoreToken[] {\n  const idToToken: Record<string, NullableAddressWarpCoreToken> = {};\n  for (const token of tokens) {\n    let id = '';\n    // Temporary fix issue for M0 routes where addressOrDenom can be the same\n    if (\n      token.standard === TokenStandard.EvmM0PortalLite ||\n      token.standard === TokenStandard.EvmM0Portal\n    ) {\n      id = `${token.chainName}|${token.symbol}|${token.addressOrDenom?.toLowerCase()}`;\n    } else {\n      id = `${token.chainName}|${token.addressOrDenom?.toLowerCase()}`;\n    }\n    idToToken[id] = objMerge(idToToken[id] || {}, token);\n  }\n  return Object.values(idToToken);\n}\n\n// Combine a list of WarpCore option objects into one single options object\nfunction reduceOptions(optionsList: Array<WarpCoreConfig['options']>): WarpCoreConfig['options'] {\n  return optionsList.reduce<WarpCoreConfig['options']>((acc, o) => {\n    if (!o || !acc) return acc;\n    for (const key of Object.keys(o)) {\n      acc[key] = (acc[key] || []).concat(o[key] || []);\n    }\n    return acc;\n  }, {});\n}\n\n// Remove tokens that have no connections from the token list, but preserve tokens that are destinations\nfunction filterUnconnectedToken(tokens: NullableAddressWarpCoreToken[]): WarpCoreToken[] {\n  const destinationTokenIds = new Set<string>();\n\n  tokens.forEach((token) => {\n    if (token.connections?.length) {\n      token.connections.forEach((conn) => {\n        destinationTokenIds.add(conn.token);\n      });\n    }\n  });\n\n  // Keep tokens with connections OR tokens that are destinations\n  return tokens.filter((token): token is WarpCoreToken => {\n    if (!token.addressOrDenom) return false;\n    // Has connections - keep it\n    if (token.connections?.length) return true;\n\n    const protocol = TOKEN_STANDARD_TO_PROTOCOL[token.standard];\n\n    // Is a destination token - keep it\n    const tokenId = getTokenConnectionId(protocol, token.chainName, token.addressOrDenom);\n    return destinationTokenIds.has(tokenId);\n  });\n}\n"
  },
  {
    "path": "src/global.d.ts",
    "content": "declare type Address = string;\ndeclare type ChainName = string;\ndeclare type ChainId = number | string;\ndeclare type DomainId = number;\n\ndeclare module '*.css' {\n  const content: string;\n  export default content;\n}\n\ndeclare module '@interchain-ui/react/styles';\n\ndeclare module '*.yaml' {\n  const data: any;\n  export default data;\n}\n"
  },
  {
    "path": "src/instrumentation.ts",
    "content": "import { sentryDefaultConfig } from '../sentry.default.config';\n\nexport async function register() {\n  if (!process.env.NEXT_PUBLIC_SENTRY_DSN) return;\n  try {\n    const Sentry = await import('@sentry/nextjs');\n    if (process.env.NEXT_RUNTIME === 'nodejs' || process.env.NEXT_RUNTIME === 'edge') {\n      Sentry.init({ ...sentryDefaultConfig, defaultIntegrations: false });\n    }\n  } catch (error) {\n    // eslint-disable-next-line no-console\n    console.error('Failed to load Sentry instrumentation:', error);\n  }\n}\n"
  },
  {
    "path": "src/lib/predicateClient.ts",
    "content": "import { PredicateAttestationRequest, PredicateAttestationResponse } from '@hyperlane-xyz/sdk';\n\nexport type AttestationRequest = PredicateAttestationRequest;\n\nconst PREDICATE_PROXY_URL = '/api/predicate/attestation';\n\n/**\n * Fetches Predicate attestation via Next.js API proxy route\n * Routes through backend to avoid CORS and keep API key server-side\n */\nexport async function fetchAttestation(\n  request: AttestationRequest,\n): Promise<PredicateAttestationResponse> {\n  const response = await fetch(PREDICATE_PROXY_URL, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n    },\n    body: JSON.stringify(request),\n  });\n\n  if (!response.ok) {\n    const error = await response.json();\n    throw new Error(error.error || 'Failed to fetch attestation');\n  }\n\n  return response.json();\n}\n"
  },
  {
    "path": "src/pages/_app.tsx",
    "content": "import { useIsSsr } from '@hyperlane-xyz/widgets';\n\nimport '@hyperlane-xyz/widgets/styles.css';\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query';\nimport { Analytics } from '@vercel/analytics/react';\nimport type { AppProps } from 'next/app';\nimport { useRouter } from 'next/router';\nimport { useEffect } from 'react';\nimport { ToastContainer, Zoom } from 'react-toastify';\n\nimport 'react-toastify/dist/ReactToastify.css';\nimport '../../sentry.client.config';\nimport { ErrorBoundary } from '../components/errors/ErrorBoundary';\nimport { AppLayout } from '../components/layout/AppLayout';\nimport { ThemeProvider } from '../features/theme/ThemeContext';\nimport { initE2EStateIfEnabled } from '../features/wallet/_e2e/windowState';\nimport { AleoWalletContext } from '../features/wallet/context/AleoWalletContext';\nimport { CosmosWalletContext } from '../features/wallet/context/CosmosWalletContext';\nimport { EvmWalletContext } from '../features/wallet/context/EvmWalletContext';\nimport { RadixWalletContext } from '../features/wallet/context/RadixWalletContext';\nimport { SolanaWalletContext } from '../features/wallet/context/SolanaWalletContext';\nimport { StarknetWalletContext } from '../features/wallet/context/StarknetWalletContext';\nimport { TronWalletContext } from '../features/wallet/context/TronWalletContext';\nimport { WarpContextInitGate } from '../features/WarpContextInitGate';\n\nimport '../styles/embed-theme.css';\nimport { parseEmbedTheme } from '../styles/embedTheme';\n\nimport '../styles/globals.css';\nimport '../vendor/inpage-metamask';\nimport '../vendor/polyfill';\n\nconst reactQueryClient = new QueryClient({\n  defaultOptions: {\n    queries: {\n      refetchOnWindowFocus: false,\n    },\n  },\n});\n\n/**\n * Sets embed-mode class + CSS variables on <body> early so the loading\n * screen (WarpContextInitGate) is also themed.\n */\nfunction useEarlyEmbedMode(isEmbed: boolean) {\n  useEffect(() => {\n    if (!isEmbed) return;\n    document.body.classList.add('embed-mode');\n    const theme = parseEmbedTheme();\n    const { style } = document.body;\n    for (const [key, value] of Object.entries(theme)) {\n      const varName = `--embed-${key.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())}`;\n      style.setProperty(varName, value);\n    }\n    return () => document.body.classList.remove('embed-mode');\n  }, [isEmbed]);\n}\n\nexport default function App({ Component, pageProps }: AppProps) {\n  const router = useRouter();\n  const isEmbed = router.pathname === '/embed';\n\n  useEarlyEmbedMode(isEmbed);\n\n  // Init once on mount. Tests gate on page.waitForFunction(() =>\n  // Boolean(window.__WARP_E2E__)) so the post-commit effect timing is fine;\n  // the previous render-time call violated React's purity expectation even\n  // though it was idempotent in practice.\n  useEffect(() => {\n    initE2EStateIfEnabled();\n  }, []);\n\n  // Disable app SSR for now as it's not needed and\n  // complicates wallet and graphql integrations\n  const isSsr = useIsSsr();\n  if (isSsr) {\n    return <div></div>;\n  }\n\n  const content = isEmbed ? (\n    <Component {...pageProps} />\n  ) : (\n    <AppLayout>\n      <Component {...pageProps} />\n      <Analytics />\n    </AppLayout>\n  );\n\n  return (\n    <div className=\"font-primary text-black\">\n      <ErrorBoundary>\n        <QueryClientProvider client={reactQueryClient}>\n          <WarpContextInitGate>\n            <EvmWalletContext>\n              <SolanaWalletContext>\n                <CosmosWalletContext>\n                  <StarknetWalletContext>\n                    <RadixWalletContext>\n                      <AleoWalletContext>\n                        <TronWalletContext>\n                          <ThemeProvider>{content}</ThemeProvider>\n                        </TronWalletContext>\n                      </AleoWalletContext>\n                    </RadixWalletContext>\n                  </StarknetWalletContext>\n                </CosmosWalletContext>\n              </SolanaWalletContext>\n            </EvmWalletContext>\n          </WarpContextInitGate>\n        </QueryClientProvider>\n        <ToastContainer transition={Zoom} position=\"bottom-right\" limit={2} />\n      </ErrorBoundary>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/pages/_document.tsx",
    "content": "import { Head, Html, Main, NextScript } from 'next/document';\n\nimport { APP_DESCRIPTION, APP_NAME, APP_URL, BRAND_COLOR } from '../consts/app';\n\nexport default function Document() {\n  return (\n    <Html>\n      <Head>\n        <meta charSet=\"utf-8\" />\n\n        <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/apple-touch-icon.png\" />\n        <link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/favicon-32x32.png\" />\n        <link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/favicon-16x16.png\" />\n        <link rel=\"manifest\" href=\"/site.webmanifest\" />\n        <link rel=\"mask-icon\" href=\"/safari-pinned-tab.svg\" color={BRAND_COLOR} />\n        <link rel=\"shortcut icon\" href=\"/favicon.png\" />\n        <meta name=\"msapplication-TileColor\" content=\"#ffffff\" />\n        <meta name=\"theme-color\" content=\"#ffffff\" />\n\n        <meta name=\"application-name\" content={APP_NAME} />\n        <meta name=\"keywords\" content={APP_NAME + ' Hyperlane Token Bridge Interchain App'} />\n        <meta name=\"description\" content={APP_DESCRIPTION} />\n\n        <meta name=\"HandheldFriendly\" content=\"true\" />\n        <meta name=\"apple-mobile-web-app-title\" content={APP_NAME} />\n        <meta name=\"mobile-web-app-capable\" content=\"yes\" />\n\n        <meta property=\"og:url\" content={APP_URL} />\n        <meta property=\"og:title\" content={APP_NAME} />\n        <meta property=\"og:type\" content=\"website\" />\n        <meta property=\"og:image\" content={`${APP_URL}/logo.png`} />\n        <meta property=\"og:description\" content={APP_DESCRIPTION} />\n        {/* Synchronous same-origin script — blocks rendering to set theme before first paint.\n            Inline version would be blocked by CSP (no unsafe-inline in script-src). */}\n        {/* eslint-disable-next-line @next/next/no-sync-scripts */}\n        <script src=\"/theme-init.js\" />\n      </Head>\n      <body className=\"font-primary text-black\">\n        <Main />\n        <NextScript />\n      </body>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "src/pages/api/predicate/attestation.ts",
    "content": "import { PredicateApiClient } from '@hyperlane-xyz/sdk';\nimport type { NextApiRequest, NextApiResponse } from 'next';\n\nimport { logger } from '../../../utils/logger';\n\nconst PREDICATE_API_KEY = process.env.PREDICATE_API_KEY;\nconst PREDICATE_API_URL = process.env.PREDICATE_API_URL;\nconst ALLOWED_PREDICATE_DOMAINS = ['api.predicate.io', 'predicate.io'];\n\n// Validate PREDICATE_API_URL override to prevent SSRF\nfunction validateApiUrl(url: string): boolean {\n  try {\n    const parsed = new URL(url);\n    return (\n      parsed.protocol === 'https:' &&\n      ALLOWED_PREDICATE_DOMAINS.some(\n        (domain) => parsed.hostname === domain || parsed.hostname.endsWith('.' + domain),\n      )\n    );\n  } catch {\n    return false;\n  }\n}\n\n// Rate limiting is handled by Vercel firewall rules at the edge layer.\n// Do not add in-memory rate limiting here — serverless instances are ephemeral\n// and load-balanced, so module-level state is not shared across invocations.\n\nexport default async function handler(req: NextApiRequest, res: NextApiResponse) {\n  if (req.method !== 'POST') {\n    return res.status(405).json({ error: 'Method not allowed' });\n  }\n\n  if (!PREDICATE_API_KEY) {\n    return res.status(500).json({ error: 'Predicate API key not configured' });\n  }\n\n  if (PREDICATE_API_URL && !validateApiUrl(PREDICATE_API_URL)) {\n    logger.error('Invalid PREDICATE_API_URL', new Error(PREDICATE_API_URL));\n    return res.status(500).json({ error: 'Invalid API configuration' });\n  }\n\n  // Input validation\n  const { to, from, data, msg_value, chain } = req.body || {};\n  if (!to || !from || !data || !msg_value || !chain) {\n    return res.status(400).json({\n      error: 'Missing required fields: to, from, data, msg_value, chain',\n    });\n  }\n\n  if (\n    typeof to !== 'string' ||\n    typeof from !== 'string' ||\n    typeof data !== 'string' ||\n    typeof msg_value !== 'string' ||\n    typeof chain !== 'string'\n  ) {\n    return res.status(400).json({ error: 'Invalid field types' });\n  }\n\n  try {\n    const client = new PredicateApiClient(PREDICATE_API_KEY, PREDICATE_API_URL);\n    const result = await client.fetchAttestation({ to, from, data, msg_value, chain });\n    return res.status(200).json(result);\n  } catch (error) {\n    const message = error instanceof Error ? error.message : 'Failed to fetch attestation';\n    logger.error('Predicate API error', error);\n    return res.status(502).json({ error: message });\n  }\n}\n"
  },
  {
    "path": "src/pages/api/quote.test.ts",
    "content": "import { beforeEach, describe, expect, test, vi } from 'vitest';\n\nconst mockGetQuote = vi.fn();\n\nvi.mock('@hyperlane-xyz/sdk', async (importOriginal) => ({\n  ...(await importOriginal<typeof import('@hyperlane-xyz/sdk')>()),\n  FeeQuotingClient: class {\n    getQuote = mockGetQuote;\n  },\n}));\n\nvi.stubEnv('FEE_QUOTING_API_KEY', 'test-api-key');\nvi.stubEnv('NEXT_PUBLIC_FEE_QUOTING_URL', 'https://quoting.test');\n\nconst { default: handler } = await import('./quote');\n\nfunction mockReqRes(method: string, query: Record<string, string> = {}) {\n  const req = { method, query } as any;\n  const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() } as any;\n  return { req, res };\n}\n\nbeforeEach(() => vi.clearAllMocks());\n\ndescribe('quote API handler', () => {\n  const ROUTER = '0x1234567890123456789012345678901234567890';\n  const SALT = '0x' + 'a'.repeat(64);\n  const RECIPIENT = '0x' + 'b'.repeat(64);\n  const validQuery = {\n    command: 'transferRemote',\n    origin: 'ethereum',\n    router: ROUTER,\n    destination: '1',\n    salt: SALT,\n    recipient: RECIPIENT,\n  };\n\n  test('rejects non-GET methods', async () => {\n    const { req, res } = mockReqRes('POST', validQuery);\n    await handler(req, res);\n    expect(res.status).toHaveBeenCalledWith(405);\n  });\n\n  test('returns 400 for missing required params', async () => {\n    const { req, res } = mockReqRes('GET', { command: 'transferRemote' });\n    await handler(req, res);\n    expect(res.status).toHaveBeenCalledWith(400);\n  });\n\n  test('proxies successful quote response', async () => {\n    const mockResponse = { quotes: [{ token: '0x1', amount: '100' }] };\n    mockGetQuote.mockResolvedValue(mockResponse);\n\n    const { req, res } = mockReqRes('GET', validQuery);\n    await handler(req, res);\n\n    expect(res.status).toHaveBeenCalledWith(200);\n    expect(res.json).toHaveBeenCalledWith(mockResponse);\n    expect(mockGetQuote).toHaveBeenCalledWith({\n      command: 'transferRemote',\n      origin: 'ethereum',\n      router: ROUTER,\n      destination: 1,\n      salt: SALT,\n      recipient: RECIPIENT,\n    });\n  });\n\n  test('returns 400 when command is not in the allowlist', async () => {\n    const { req, res } = mockReqRes('GET', { ...validQuery, command: 'foo/../admin' });\n    await handler(req, res);\n    expect(res.status).toHaveBeenCalledWith(400);\n    expect(res.json).toHaveBeenCalledWith({ message: 'Invalid command' });\n    expect(mockGetQuote).not.toHaveBeenCalled();\n  });\n\n  test('returns 400 when destination is not a positive integer', async () => {\n    const { req, res } = mockReqRes('GET', { ...validQuery, destination: 'abc' });\n    await handler(req, res);\n    expect(res.status).toHaveBeenCalledWith(400);\n    expect(res.json).toHaveBeenCalledWith({\n      message: 'destination must be a positive integer domain id',\n    });\n    expect(mockGetQuote).not.toHaveBeenCalled();\n  });\n\n  test('returns 400 when warp command is missing recipient', async () => {\n    const { recipient: _omitted, ...noRecipient } = validQuery;\n    const { req, res } = mockReqRes('GET', noRecipient);\n    await handler(req, res);\n    expect(res.status).toHaveBeenCalledWith(400);\n    expect(res.json).toHaveBeenCalledWith({ message: 'recipient required for warp commands' });\n    expect(mockGetQuote).not.toHaveBeenCalled();\n  });\n\n  test('returns 400 when router is not a valid EVM address', async () => {\n    const { req, res } = mockReqRes('GET', { ...validQuery, router: '0x1234' });\n    await handler(req, res);\n    expect(res.status).toHaveBeenCalledWith(400);\n    expect(res.json).toHaveBeenCalledWith({ message: 'router must be a valid EVM address' });\n    expect(mockGetQuote).not.toHaveBeenCalled();\n  });\n\n  test('returns 400 when salt is not 32-byte hex', async () => {\n    const { req, res } = mockReqRes('GET', { ...validQuery, salt: '0xabcd' });\n    await handler(req, res);\n    expect(res.status).toHaveBeenCalledWith(400);\n    expect(res.json).toHaveBeenCalledWith({ message: 'salt must be 32-byte hex' });\n    expect(mockGetQuote).not.toHaveBeenCalled();\n  });\n\n  test('returns 400 when recipient is not 32-byte hex', async () => {\n    const { req, res } = mockReqRes('GET', { ...validQuery, recipient: '0x5678' });\n    await handler(req, res);\n    expect(res.status).toHaveBeenCalledWith(400);\n    expect(res.json).toHaveBeenCalledWith({ message: 'recipient must be 32-byte hex' });\n    expect(mockGetQuote).not.toHaveBeenCalled();\n  });\n\n  test('returns 502 with a generic message on upstream error (no leak)', async () => {\n    mockGetQuote.mockRejectedValue(new Error('Invalid API key'));\n\n    const { req, res } = mockReqRes('GET', validQuery);\n    await handler(req, res);\n\n    expect(res.status).toHaveBeenCalledWith(502);\n    expect(res.json).toHaveBeenCalledWith({ message: 'Fee quoting request failed' });\n  });\n});\n"
  },
  {
    "path": "src/pages/api/quote.ts",
    "content": "import { FeeQuotingClient, FeeQuotingCommand, WARP_FEE_COMMANDS } from '@hyperlane-xyz/sdk';\nimport { isValidAddressEvm } from '@hyperlane-xyz/utils';\nimport type { NextApiRequest, NextApiResponse } from 'next';\n\nconst apiKey = process.env.FEE_QUOTING_API_KEY;\nconst baseUrl = process.env.NEXT_PUBLIC_FEE_QUOTING_URL || undefined;\n// Strict allowlist — `command` is interpolated raw into the upstream URL path\n// by FeeQuotingClient (`${baseUrl}/quote/${command}?...`), so a bare type-cast\n// would be a path-injection surface.\nconst ALLOWED_COMMANDS = new Set<FeeQuotingCommand>(Object.values(FeeQuotingCommand));\n\n// salt, recipient and targetRouter are bytes32 wire encodings (keccak hash /\n// addressToBytes32 padding), not canonical addresses — validate as 32-byte hex.\nconst HEX_BYTES32 = /^0x[0-9a-fA-F]{64}$/;\n\n// Rate limiting: this proxy is browser-reachable and FEE_QUOTING_API_KEY is\n// only held server-side, so abuse protection lives in front of this handler\n// (Vercel rate-limiting) and at the upstream Hyperlane fee quoting service.\n// Don't add an app-layer limiter here — it would double-count against the\n// same per-IP / per-key budget that the platform layer already enforces.\n\nexport default async function handler(req: NextApiRequest, res: NextApiResponse) {\n  if (req.method !== 'GET') return res.status(405).json({ message: 'Method not allowed' });\n  if (!apiKey || !baseUrl) return res.status(503).json({ message: 'Fee quoting not configured' });\n\n  const command = firstString(req.query.command);\n  const origin = firstString(req.query.origin);\n  const router = firstString(req.query.router);\n  const destination = firstString(req.query.destination);\n  const salt = firstString(req.query.salt);\n  const recipient = firstString(req.query.recipient);\n  const targetRouter = firstString(req.query.targetRouter);\n\n  if (!command || !origin || !router || !destination || !salt) {\n    return res.status(400).json({ message: 'Missing required query parameters' });\n  }\n  if (!ALLOWED_COMMANDS.has(command as FeeQuotingCommand)) {\n    return res.status(400).json({ message: 'Invalid command' });\n  }\n  const destinationDomainId = Number(destination);\n  if (!Number.isInteger(destinationDomainId) || destinationDomainId <= 0) {\n    return res.status(400).json({ message: 'destination must be a positive integer domain id' });\n  }\n  // Warp commands (transferRemote / transferRemoteTo) require a recipient.\n  if (WARP_FEE_COMMANDS.has(command as FeeQuotingCommand) && !recipient) {\n    return res.status(400).json({ message: 'recipient required for warp commands' });\n  }\n  if (!isValidAddressEvm(router)) {\n    return res.status(400).json({ message: 'router must be a valid EVM address' });\n  }\n  if (!HEX_BYTES32.test(salt)) {\n    return res.status(400).json({ message: 'salt must be 32-byte hex' });\n  }\n  if (recipient && !HEX_BYTES32.test(recipient)) {\n    return res.status(400).json({ message: 'recipient must be 32-byte hex' });\n  }\n  if (targetRouter && !HEX_BYTES32.test(targetRouter)) {\n    return res.status(400).json({ message: 'targetRouter must be 32-byte hex' });\n  }\n\n  const client = new FeeQuotingClient({ baseUrl, apiKey });\n\n  try {\n    const response = await client.getQuote({\n      command: command as FeeQuotingCommand,\n      origin,\n      router: router as `0x${string}`,\n      destination: destinationDomainId,\n      salt: salt as `0x${string}`,\n      recipient: recipient as `0x${string}` | undefined,\n      targetRouter: targetRouter as `0x${string}` | undefined,\n    });\n    return res.status(200).json(response);\n  } catch (error) {\n    // Log full error server-side; return generic message to avoid leaking\n    // upstream URLs, status text, or auth-related hints (e.g. \"Invalid API key\")\n    // to the browser.\n    console.error('Fee quoting request failed', error);\n    return res.status(502).json({ message: 'Fee quoting request failed' });\n  }\n}\n\nfunction firstString(v: string | string[] | undefined): string | undefined {\n  return typeof v === 'string' ? v : undefined;\n}\n"
  },
  {
    "path": "src/pages/blocked.tsx",
    "content": "import { ErrorBoundary } from '../components/errors/ErrorBoundary';\n\nexport default function Page() {\n  return (\n    <ErrorBoundary>\n      {(() => {\n        throw new Error('Your region has been blocked from accessing this service');\n      })()}\n    </ErrorBoundary>\n  );\n}\n"
  },
  {
    "path": "src/pages/embed.tsx",
    "content": "import { HyperlaneLogo } from '@hyperlane-xyz/widgets';\nimport type { NextPage } from 'next';\nimport Head from 'next/head';\nimport { type CSSProperties, useEffect, useMemo, useRef, useState } from 'react';\n\nimport { APP_NAME } from '../consts/app';\nimport { useStore } from '../features/store';\nimport { TransfersDetailsModal } from '../features/transfer/TransfersDetailsModal';\nimport { TransferTokenCard } from '../features/transfer/TransferTokenCard';\nimport { TransferContext } from '../features/transfer/types';\nimport { parseEmbedTheme, themeToCssVars } from '../styles/embedTheme';\nimport { logger } from '../utils/logger';\n\n/**\n * Embeddable widget page — renders the transfer form in a minimal, chrome-less\n * layout suitable for iframe embedding. Accepts theme overrides via URL params.\n *\n * Usage:\n *   <iframe src=\"https://your-warp-ui.com/embed?accent=3b82f6&bg=ffffff&mode=dark\" />\n *\n * Supported URL params:\n *   - accent, bg, card, text, buttonText, border, error (hex without #)\n *   - mode: \"dark\" or \"light\"\n *   - origin, destination, originToken, destinationToken (transfer defaults)\n */\n\nconst WIDGET_MESSAGE_TYPE = 'hyperlane-warp-widget';\n\nfunction emitWidgetEvent(eventType: string, payload?: Record<string, unknown>) {\n  if (typeof window === 'undefined' || window.parent === window) return;\n  window.parent.postMessage(\n    { type: WIDGET_MESSAGE_TYPE, event: { type: eventType, payload } },\n    '*',\n  );\n}\n\nfunction usePostMessageBridge() {\n  useEffect(() => {\n    if (typeof window === 'undefined' || window.parent === window) return;\n\n    const send = () => emitWidgetEvent('ready', { timestamp: Date.now() });\n    send();\n    const timers = [500, 1500, 3000].map((ms) => setTimeout(send, ms));\n    return () => timers.forEach(clearTimeout);\n  }, []);\n}\n\n/** Auto-opens TransfersDetailsModal when a new transfer starts. */\nfunction useAutoTransferModal() {\n  const transfers = useStore((s) => s.transfers);\n  const transferLoading = useStore((s) => s.transferLoading);\n  const [selectedTransfer, setSelectedTransfer] = useState<TransferContext | null>(null);\n  const [isOpen, setIsOpen] = useState(false);\n  const didMountRef = useRef(false);\n\n  useEffect(() => {\n    if (!didMountRef.current) {\n      didMountRef.current = true;\n      return;\n    } else if (transferLoading) {\n      const latestTransfer = transfers[transfers.length - 1];\n      if (!latestTransfer) {\n        logger.error('Expected latest transfer while transferLoading is true', transfers);\n        return;\n      }\n      setSelectedTransfer(latestTransfer);\n      setIsOpen(true);\n    }\n    // Same pattern as SideBarMenu — open modal when new transfer detected\n  }, [transfers, transferLoading]);\n\n  const close = () => {\n    setIsOpen(false);\n    setSelectedTransfer(null);\n  };\n\n  return { selectedTransfer, isOpen, close };\n}\n\nconst EmbedPage: NextPage = () => {\n  usePostMessageBridge();\n  const cssVars = useMemo(() => themeToCssVars(parseEmbedTheme()), []);\n  const { selectedTransfer, isOpen: isModalOpen, close: closeModal } = useAutoTransferModal();\n\n  return (\n    <>\n      <Head>\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n        <title>{APP_NAME}</title>\n        <meta name=\"robots\" content=\"noindex, nofollow\" />\n      </Head>\n      <div className=\"embed-container\" style={cssVars as CSSProperties}>\n        <div className=\"flex min-h-screen items-center justify-center p-2\">\n          <div>\n            <TransferTokenCard />\n            <div className=\"mt-2 flex items-center justify-end gap-1 pr-1 opacity-50\">\n              <span className=\"text-xxs tracking-wide\">Powered by</span>\n              <HyperlaneLogo width={12} height={12} color=\"currentColor\" className=\"-mt-[2px]\" />\n              <span className=\"text-xxs font-medium tracking-wide\">Hyperlane</span>\n            </div>\n          </div>\n        </div>\n      </div>\n      {selectedTransfer && (\n        <TransfersDetailsModal\n          isOpen={isModalOpen}\n          onClose={closeModal}\n          transfer={selectedTransfer}\n        />\n      )}\n    </>\n  );\n};\n\nexport default EmbedPage;\n"
  },
  {
    "path": "src/pages/index.tsx",
    "content": "import type { NextPage } from 'next';\n\nimport { TipCard } from '../components/tip/TipCard';\nimport { TransferTokenCard } from '../features/transfer/TransferTokenCard';\n\nconst Home: NextPage = () => {\n  return (\n    <div className=\"relative flex w-100 flex-col gap-8 sm:w-[31rem] xl:block\">\n      <div className=\"xl:absolute xl:right-[calc(100%+1rem)] xl:top-1 xl:w-72\">\n        <TipCard />\n      </div>\n      <TransferTokenCard />\n    </div>\n  );\n};\n\nexport default Home;\n"
  },
  {
    "path": "src/proxy.ts",
    "content": "import { geolocation } from '@vercel/functions';\nimport { NextRequest, NextResponse } from 'next/server';\n\nexport const config = {\n  // only run on the index\n  matcher: '/',\n};\n\nconst BLOCKED_COUNTRIES = [\n  'CU', // Cuba\n  'KP', // North Korea\n  'RU', // Russia\n  'AF', // Afghanistan\n  'BY', // Belarus\n  'BA', // Bosnia & Herzegovina\n  'CF', // Central African Republic\n  'CD', // Democratic Republic of the Congo\n  'GN', // Guinea\n  'GW', // Guinea-Bissau\n  'HT', // Haiti\n  'IQ', // Iraq\n  'LB', // Lebanon\n  'LY', // Libya\n  'ML', // Mali\n  'NI', // Nicaragua\n  'SO', // Somalia\n  'SS', // South Sudan\n  'SD', // Sudan\n  'VE', // Venezuela\n  'YE', // Yemen\n  'ZW', // Zimbabwe\n  'MM', // Myanmar\n  'SY', // Syria\n];\n\nconst BLOCKED_REGIONS = [\n  {\n    country: 'UA', // Ukraine\n    regions: [\n      '43', // Crimea\n      '14', // Donetsk\n      '09', // Luhansk\n    ],\n  },\n];\n\nexport function proxy(req: NextRequest) {\n  const { country, region } = geolocation(req);\n\n  if (country && BLOCKED_COUNTRIES.includes(country)) {\n    return NextResponse.redirect(new URL('/blocked', req.url));\n  }\n\n  if (\n    country &&\n    region &&\n    BLOCKED_REGIONS.find((x) => x.country === country)?.regions.includes(region)\n  ) {\n    return NextResponse.redirect(new URL('/blocked', req.url));\n  }\n\n  return NextResponse.next();\n}\n"
  },
  {
    "path": "src/styles/Color.ts",
    "content": "// @ts-ignore\nimport { theme } from '../../tailwind.config';\n\nconst themeColors = theme.extend.colors as unknown as Record<string, string>;\n\nexport const Color = {\n  black: themeColors.black,\n  white: themeColors.white,\n  gray: themeColors.gray,\n  primary: themeColors.primary,\n  accent: themeColors.accent,\n  red: themeColors.red,\n  cream: themeColors.cream,\n} as const;\n"
  },
  {
    "path": "src/styles/embed-theme.css",
    "content": "/*\n * Embed Theme Overrides\n * =====================\n * Applied when the embed page is active (body.embed-mode).\n * Overrides Tailwind utility classes with CSS custom properties\n * so integrators can theme the widget via URL params.\n *\n * CSS variables are set on both .embed-container (inline styles)\n * and body (via useEmbedBodyClass) so portaled modals are also themed.\n */\n\n/* --- Base container --- */\n.embed-container {\n  background-color: var(--embed-bg);\n  color: var(--embed-text);\n}\n\n/* ===================\n   Accent / Primary\n   =================== */\n\n/* Gradient buttons → solid accent */\nbody.embed-mode .bg-accent-gradient,\nbody.embed-mode .bg-primary-500 {\n  background-image: none !important;\n  background-color: var(--embed-accent) !important;\n  color: var(--embed-button-text) !important;\n  box-shadow: none !important;\n}\n/* Hover only on interactive elements — not section headers or timeline bars */\nbody.embed-mode button.bg-accent-gradient:hover,\nbody.embed-mode button.bg-primary-500:hover,\nbody.embed-mode .hover\\:bg-primary-600:hover {\n  background-color: var(--embed-accent-dark) !important;\n}\n\n/* Accent glow (section headers) */\nbody.embed-mode .shadow-accent-glow {\n  box-shadow: none !important;\n}\n\n/* Primary-colored text (links, labels, active states) */\nbody.embed-mode .text-primary-500 {\n  color: var(--embed-accent) !important;\n}\nbody.embed-mode .text-primary-600,\nbody.embed-mode .text-primary-700,\nbody.embed-mode .hover\\:text-primary-600:hover,\nbody.embed-mode .hover\\:text-primary-700:hover {\n  color: var(--embed-accent-dark) !important;\n}\n\n/* SVG fills via Tailwind arbitrary [&_path] selectors */\nbody.embed-mode .\\[\\&_path\\]\\:fill-primary-500 path {\n  fill: var(--embed-accent) !important;\n}\nbody.embed-mode .\\[\\&_path\\]\\:hover\\:fill-primary-700:hover path {\n  fill: var(--embed-accent-dark) !important;\n}\n\n/* SVG fills via inline color={Color.primary[500]} props */\nbody.embed-mode .text-primary-500 svg path,\nbody.embed-mode .text-primary-500 path {\n  fill: var(--embed-accent) !important;\n}\nbody.embed-mode .hover\\:text-primary-700:hover svg path,\nbody.embed-mode .hover\\:text-primary-700:hover path {\n  fill: var(--embed-accent-dark) !important;\n}\n\n/* Hardcoded primary/accent hex in SVG attributes */\nbody.embed-mode svg path[fill='#9A0DFF'],\nbody.embed-mode svg path[fill='#9a0dff'],\nbody.embed-mode svg path[fill='#A62AFF'],\nbody.embed-mode svg path[fill='#a62aff'] {\n  fill: var(--embed-accent) !important;\n}\n\n/* Primary borders */\nbody.embed-mode .border-primary-500 {\n  border-color: var(--embed-accent) !important;\n}\nbody.embed-mode .border-primary-50 {\n  border-color: var(--embed-accent) !important;\n}\n\n/* Primary backgrounds (dividers, selected chain highlight) */\nbody.embed-mode .bg-primary-50 {\n  background-color: var(--embed-border) !important;\n}\nbody.embed-mode [class*='bg-primary-500/10'],\nbody.embed-mode [class*='bg-primary-500\\\\/10'] {\n  background-color: color-mix(in srgb, var(--embed-accent) 10%, transparent) !important;\n}\n\n/* \"All Chains\" badge gradient */\nbody.embed-mode .from-blue-400 {\n  --tw-gradient-from: var(--embed-accent-light) !important;\n}\nbody.embed-mode .to-purple-500 {\n  --tw-gradient-to: var(--embed-accent) !important;\n}\n\n/* ===================\n   Error\n   =================== */\nbody.embed-mode .bg-error-gradient {\n  background-image: none !important;\n  background-color: var(--embed-error) !important;\n  color: #ffffff !important;\n  box-shadow: none !important;\n}\nbody.embed-mode .bg-error-gradient svg path {\n  fill: #ffffff !important;\n}\n\n/* ===================\n   Surfaces & Cards\n   =================== */\n\n/* Loading screen (WarpContextInitGate) */\nbody.embed-mode .bg-app-gradient {\n  background-image: none !important;\n  background-color: var(--embed-bg, transparent) !important;\n}\n/* Spinner: circle = light ring (stroke only), path = spinning arc (fill only) */\nbody.embed-mode .bg-app-gradient svg circle {\n  stroke: var(--embed-accent) !important;\n  fill: none !important;\n}\nbody.embed-mode .bg-app-gradient svg path {\n  fill: var(--embed-accent) !important;\n  stroke: none !important;\n}\n\nbody.embed-mode .bg-card-gradient {\n  background-image: none !important;\n  background-color: var(--embed-card) !important;\n}\nbody.embed-mode .bg-white,\nbody.embed-mode .htw-bg-white {\n  background-color: var(--embed-card) !important;\n}\nbody.embed-mode .bg-gray-100 {\n  background-color: color-mix(in srgb, var(--embed-card) 95%, var(--embed-text) 5%) !important;\n}\nbody.embed-mode .bg-gray-150 {\n  background-color: color-mix(in srgb, var(--embed-card) 90%, var(--embed-text) 10%) !important;\n}\nbody.embed-mode .to-gray-100 {\n  --tw-gradient-to: color-mix(in srgb, var(--embed-card) 95%, var(--embed-text) 5%) !important;\n}\n\n/* Disabled buttons */\nbody.embed-mode button:disabled {\n  background-image: none !important;\n}\n\n/* ===================\n   Shadows\n   =================== */\nbody.embed-mode .shadow-card {\n  box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1) !important;\n}\nbody.embed-mode .shadow-input {\n  box-shadow: 0 0 4px rgba(0, 0, 0, 0.08) !important;\n}\nbody.embed-mode .shadow-button {\n  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important;\n}\n\n/* ===================\n   Text Colors\n   =================== */\nbody.embed-mode .text-black {\n  color: var(--embed-text) !important;\n}\nbody.embed-mode .text-gray-900 {\n  color: var(--embed-text) !important;\n}\nbody.embed-mode .text-gray-700 {\n  color: color-mix(in srgb, var(--embed-text) 80%, transparent) !important;\n}\nbody.embed-mode .text-gray-600 {\n  color: color-mix(in srgb, var(--embed-text) 70%, transparent) !important;\n}\nbody.embed-mode .text-gray-500 {\n  color: color-mix(in srgb, var(--embed-text) 55%, transparent) !important;\n}\nbody.embed-mode .text-gray-450 {\n  color: color-mix(in srgb, var(--embed-text) 60%, transparent) !important;\n}\nbody.embed-mode .text-gray-400 {\n  color: color-mix(in srgb, var(--embed-text) 45%, transparent) !important;\n}\nbody.embed-mode .text-cream-100 {\n  color: var(--embed-button-text) !important;\n}\n\n/* Token select chevron — inline fill={Color.gray[900]} */\nbody.embed-mode .drop-shadow-button svg path {\n  fill: var(--embed-text) !important;\n}\n\n/* ===================\n   Borders\n   =================== */\nbody.embed-mode .border-gray-400\\/25,\nbody.embed-mode .border-gray-400\\/50,\nbody.embed-mode .border-gray-400 {\n  border-color: var(--embed-border) !important;\n}\nbody.embed-mode .border-gray-200,\nbody.embed-mode .border-gray-300 {\n  border-color: var(--embed-border) !important;\n}\nbody.embed-mode .border-gray-100 {\n  border-color: color-mix(in srgb, var(--embed-border) 50%, transparent) !important;\n}\n\n/* ===================\n   Hover States\n   =================== */\nbody.embed-mode .hover\\:bg-gray-50:hover {\n  background-color: color-mix(in srgb, var(--embed-card) 95%, var(--embed-text) 5%) !important;\n}\nbody.embed-mode .hover\\:bg-gray-100:hover {\n  background-color: color-mix(in srgb, var(--embed-card) 90%, var(--embed-text) 10%) !important;\n}\nbody.embed-mode .hover\\:bg-gray-200:hover {\n  background-color: color-mix(in srgb, var(--embed-card) 85%, var(--embed-text) 15%) !important;\n}\n\n/* ===================\n   Inputs\n   =================== */\nbody.embed-mode input:focus {\n  border-color: var(--embed-accent) !important;\n}\n\n/* ===================\n   Chain Edit Modal\n   =================== */\nbody.embed-mode .chain-edit-container a {\n  color: var(--embed-accent) !important;\n}\nbody.embed-mode .chain-edit-container a:hover {\n  color: var(--embed-accent-dark) !important;\n}\n\n/* ===================\n   Scrollbar\n   =================== */\nbody.embed-mode {\n  scrollbar-color: var(--embed-accent-light) transparent;\n}\nbody.embed-mode ::-webkit-scrollbar-thumb {\n  background: var(--embed-accent-light) !important;\n}\nbody.embed-mode ::-webkit-scrollbar-track {\n  background: transparent !important;\n}\n"
  },
  {
    "path": "src/styles/embedTheme.test.ts",
    "content": "import { describe, expect, test } from 'vitest';\n\nimport { normalizeHex, shiftColor } from './embedTheme';\n\ndescribe('normalizeHex', () => {\n  test('expands 3-digit shorthand to 6-digit', () => {\n    expect(normalizeHex('#abc')).toBe('#aabbcc');\n    expect(normalizeHex('f00')).toBe('#ff0000');\n    expect(normalizeHex('#fff')).toBe('#ffffff');\n  });\n\n  test('expands 4-digit shorthand (strips alpha) to 6-digit', () => {\n    expect(normalizeHex('#abcd')).toBe('#aabbcc');\n    expect(normalizeHex('f00f')).toBe('#ff0000');\n  });\n\n  test('passes through 6-digit hex unchanged', () => {\n    expect(normalizeHex('#3b82f6')).toBe('#3b82f6');\n    expect(normalizeHex('9a0dff')).toBe('#9a0dff');\n  });\n\n  test('strips alpha from 8-digit hex', () => {\n    expect(normalizeHex('#3b82f6ff')).toBe('#3b82f6');\n    expect(normalizeHex('9a0dff80')).toBe('#9a0dff');\n  });\n});\n\ndescribe('shiftColor', () => {\n  test('lightens a color', () => {\n    const result = shiftColor('#000000', 60);\n    expect(result).toBe('#3c3c3c');\n  });\n\n  test('darkens a color', () => {\n    const result = shiftColor('#ffffff', -60);\n    expect(result).toBe('#c3c3c3');\n  });\n\n  test('clamps to 0 (no negative channels)', () => {\n    const result = shiftColor('#101010', -100);\n    expect(result).toBe('#000000');\n  });\n\n  test('clamps to 255 (no overflow)', () => {\n    const result = shiftColor('#f0f0f0', 100);\n    expect(result).toBe('#ffffff');\n  });\n\n  test('handles 3-digit shorthand input', () => {\n    // #abc → #aabbcc, then shift\n    const result = shiftColor('#abc', 0);\n    expect(result).toBe('#aabbcc');\n  });\n\n  test('handles 8-digit hex input (strips alpha)', () => {\n    const result = shiftColor('#3b82f6ff', 0);\n    expect(result).toBe('#3b82f6');\n  });\n});\n\ndescribe('HEX_COLOR_RE (via parseHexParam behavior)', () => {\n  // Test the regex indirectly by checking which values would match\n  const HEX_COLOR_RE = /^([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;\n\n  test('accepts valid 3-digit hex', () => {\n    expect(HEX_COLOR_RE.test('abc')).toBe(true);\n    expect(HEX_COLOR_RE.test('FFF')).toBe(true);\n  });\n\n  test('accepts valid 4-digit hex', () => {\n    expect(HEX_COLOR_RE.test('abcd')).toBe(true);\n    expect(HEX_COLOR_RE.test('F00F')).toBe(true);\n  });\n\n  test('accepts valid 6-digit hex', () => {\n    expect(HEX_COLOR_RE.test('3b82f6')).toBe(true);\n    expect(HEX_COLOR_RE.test('9A0DFF')).toBe(true);\n  });\n\n  test('accepts valid 8-digit hex', () => {\n    expect(HEX_COLOR_RE.test('3b82f6ff')).toBe(true);\n    expect(HEX_COLOR_RE.test('9A0DFF80')).toBe(true);\n  });\n\n  test('rejects 5-digit hex', () => {\n    expect(HEX_COLOR_RE.test('12345')).toBe(false);\n  });\n\n  test('rejects 7-digit hex', () => {\n    expect(HEX_COLOR_RE.test('1234567')).toBe(false);\n  });\n\n  test('rejects 1-2 digit hex', () => {\n    expect(HEX_COLOR_RE.test('ab')).toBe(false);\n    expect(HEX_COLOR_RE.test('a')).toBe(false);\n  });\n\n  test('rejects non-hex characters', () => {\n    expect(HEX_COLOR_RE.test('gggggg')).toBe(false);\n    expect(HEX_COLOR_RE.test('xyz')).toBe(false);\n  });\n\n  test('rejects empty string', () => {\n    expect(HEX_COLOR_RE.test('')).toBe(false);\n  });\n});\n"
  },
  {
    "path": "src/styles/embedTheme.ts",
    "content": "import { getQueryParams } from '../utils/queryParams';\n\nexport interface EmbedTheme {\n  accent: string;\n  accentLight: string;\n  accentDark: string;\n  bg: string;\n  card: string;\n  text: string;\n  buttonText: string;\n  border: string;\n  error: string;\n}\n\n/** Known URL params recognized by the embed page. Anything else is ignored. */\nconst KNOWN_URL_PARAMS = new Set([\n  'accent',\n  'bg',\n  'card',\n  'text',\n  'buttonText',\n  'border',\n  'error',\n  'mode',\n  'routes',\n]);\n\n/** Strict hex color validation: 3, 4, 6, or 8 hex chars only. */\nconst HEX_COLOR_RE = /^([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;\n\n/**\n * Safely parse a hex color from a URL param.\n * Returns null if the param is missing, not in the allowlist, or fails validation.\n */\nfunction parseHexParam(params: URLSearchParams, name: string): string | null {\n  if (!KNOWN_URL_PARAMS.has(name)) return null;\n  const value = params.get(name);\n  if (!value || !HEX_COLOR_RE.test(value)) return null;\n  // Normalize to lowercase to prevent case-based injection tricks\n  return `#${value.toLowerCase()}`;\n}\n\n/** Normalize hex to 6-digit format. Expands 3-digit shorthand, strips alpha from 8-digit. */\nexport function normalizeHex(hex: string): string {\n  let cleaned = hex.replace('#', '').toLowerCase();\n  if (cleaned.length === 3) {\n    cleaned = cleaned\n      .split('')\n      .map((c) => c + c)\n      .join('');\n  }\n  if (cleaned.length === 4) {\n    cleaned = cleaned\n      .slice(0, 3)\n      .split('')\n      .map((c) => c + c)\n      .join('');\n  }\n  if (cleaned.length === 8) {\n    cleaned = cleaned.slice(0, 6);\n  }\n  return `#${cleaned}`;\n}\n\nexport function shiftColor(hex: string, amount: number): string {\n  const normalized = normalizeHex(hex);\n  const num = parseInt(normalized.replace('#', ''), 16);\n  const r = Math.min(255, Math.max(0, ((num >> 16) & 0xff) + amount));\n  const g = Math.min(255, Math.max(0, ((num >> 8) & 0xff) + amount));\n  const b = Math.min(255, Math.max(0, (num & 0xff) + amount));\n  return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;\n}\n\nconst LIGHT_DEFAULTS: EmbedTheme = {\n  accent: '#9a0dff',\n  accentLight: '#c97eff',\n  accentDark: '#7211b9',\n  bg: 'transparent',\n  card: '#ffffff',\n  text: '#010101',\n  buttonText: '#ffffff',\n  border: '#bfbfbf40',\n  error: '#dc2626',\n};\n\nconst DARK_DEFAULTS: EmbedTheme = {\n  accent: '#9a0dff',\n  accentLight: '#c97eff',\n  accentDark: '#7211b9',\n  bg: '#1a1a2e',\n  card: '#16213e',\n  text: '#e0e0e0',\n  buttonText: '#ffffff',\n  border: '#ffffff20',\n  error: '#ef4444',\n};\n\n/** Allowed values for the mode param. */\nconst ALLOWED_MODES = new Set(['dark', 'light']);\n\n/** Parse embed theme from current URL query params. Only allowlisted params are read. */\nexport function parseEmbedTheme(): EmbedTheme {\n  if (typeof window === 'undefined') return LIGHT_DEFAULTS;\n\n  const params = getQueryParams();\n  const mode = params.get('mode');\n  const defaults =\n    mode && ALLOWED_MODES.has(mode) && mode === 'dark' ? DARK_DEFAULTS : LIGHT_DEFAULTS;\n  const accent = parseHexParam(params, 'accent') || defaults.accent;\n\n  return {\n    accent,\n    accentLight: shiftColor(accent, 60),\n    accentDark: shiftColor(accent, -40),\n    bg: parseHexParam(params, 'bg') || defaults.bg,\n    card: parseHexParam(params, 'card') || defaults.card,\n    text: parseHexParam(params, 'text') || defaults.text,\n    buttonText: parseHexParam(params, 'buttonText') || defaults.buttonText,\n    border: parseHexParam(params, 'border') || defaults.border,\n    error: parseHexParam(params, 'error') || defaults.error,\n  };\n}\n\n/** Convert theme object to CSS variable inline styles. */\nexport function themeToCssVars(theme: EmbedTheme): Record<string, string> {\n  return Object.fromEntries(\n    Object.entries(theme).map(([key, value]) => [\n      `--embed-${key.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())}`,\n      value,\n    ]),\n  );\n}\n"
  },
  {
    "path": "src/styles/globals.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n/*\nCustom Fonts (optional - falls back to system fonts if not available)\nPlace font files in public/fonts/ directory\n=====================================================================\n*/\n\n/* PP Valve - Secondary font */\n@font-face {\n  font-family: 'PP Valve';\n  src: url('/fonts/PPValve-PlainVariable.woff2') format('woff2');\n  font-weight: 100 900;\n  font-style: normal;\n  font-display: swap;\n}\n\n/* Custom fonts — fetched via `pnpm run fetch-fonts` (requires S3 creds). Falls back to system-ui if missing. */\n/* PP Fraktion Mono - Primary font */\n@font-face {\n  font-family: 'PP Fraktion Mono';\n  src: url('/fonts/PPFraktionMono-Variable.woff2') format('woff2');\n  font-weight: 100 900;\n  font-style: normal;\n  font-display: swap;\n}\n\n/*\nNormalization\n=============\n*/\n:root {\n  --app-bg-color: #f8f8ff;\n  --app-bg-image:\n    url('/backgrounds/main.svg'),\n    radial-gradient(120% 80% at 50% 100%, #e8caff 0%, #f2e4ff 60%, #f8f2ff 100%);\n  --color-background: #f8f8ff;\n  --color-background-rgb: 248 248 255;\n  --color-surface: #ffffff;\n  --color-surface-rgb: 255 255 255;\n  --color-text-primary: #010101;\n  --color-text-primary-rgb: 1 1 1;\n  --color-text-secondary: #6b6b6b;\n  --color-text-secondary-rgb: 107 107 107;\n  --color-text-muted: #6b6b6b;\n  --color-text-muted-rgb: 107 107 107;\n  --color-border: #d9d9d9;\n  --color-border-rgb: 217 217 217;\n}\n\nhtml[data-theme-mode='dark'] {\n  --app-bg-color: #0d0612;\n  --app-bg-image:\n    url('/backgrounds/main-dark.svg'),\n    radial-gradient(ellipse 200% 150% at 50% 100%, #5e1396 0%, #1a0a28 40%, #0d0612 100%);\n  --color-background: #0d0612;\n  --color-background-rgb: 13 6 18;\n  --color-surface: #140a1e;\n  --color-surface-rgb: 20 10 30;\n  --color-text-primary: #f2e4ff;\n  --color-text-primary-rgb: 242 228 255;\n  --color-text-secondary: #c9b5df;\n  --color-text-secondary-rgb: 201 181 223;\n  --color-text-muted: #b494d6;\n  --color-text-muted-rgb: 180 148 214;\n  --color-border: #404040;\n  --color-border-rgb: 64 64 64;\n\n  /* Compatibility aliases for remaining selector-level/widget overrides */\n  --dark-text-primary: var(--color-text-primary);\n  --dark-text-secondary: var(--color-text-secondary);\n  --dark-text-muted: var(--color-text-muted);\n  --dark-surface: rgb(var(--color-surface-rgb) / 0.96);\n  --dark-surface-card: rgb(var(--color-background-rgb) / 0.65);\n  --dark-border: rgba(185, 89, 255, 0.25);\n  --dark-border-hover: rgba(185, 89, 255, 0.5);\n  --dark-border-subtle: rgba(185, 89, 255, 0.18);\n  --dark-hover: rgba(185, 89, 255, 0.16);\n  --dark-accent-rgb: 185, 89, 255;\n}\n\nhtml,\nbody {\n  padding: 0;\n  margin: 0;\n  background-color: var(--app-bg-color);\n}\n\n.warp-init-gate,\n#app-content {\n  background-color: var(--app-bg-color);\n  background-image: var(--app-bg-image);\n  background-size: cover;\n  background-repeat: no-repeat;\n  background-position: center;\n}\n\na {\n  outline: none;\n  color: inherit;\n  text-decoration: none;\n}\n\nselect {\n  -webkit-appearance: none;\n  -moz-appearance: none;\n  appearance: none;\n  background: url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' fill='%23010101'><polygon points='0,0 100,0 50,50'/></svg>\")\n    no-repeat;\n  background-size: 8px;\n  background-position: 92% 60%;\n  background-repeat: no-repeat;\n  cursor: pointer;\n}\n\nselect:focus {\n  outline: none;\n}\n\n/*\nText and shadows\n================\n*/\n\n.black-shadow {\n  text-shadow: 0 0 #010101;\n}\n\n/*\nScrollbar Overrides\n===================\n*/\n\nbody {\n  scroll-behavior: smooth;\n  scrollbar-width: thin;\n  scrollbar-color: #e8caff transparent;\n}\n\n::-webkit-scrollbar {\n  width: 8px;\n}\n\n::-webkit-scrollbar-track {\n  background: transparent;\n}\n\n::-webkit-scrollbar-thumb {\n  background: #e8caff;\n  border-radius: 4px;\n}\n\n::-webkit-scrollbar-thumb:hover {\n  background: #d4a3ff;\n}\n\n/* phones */\n@media only screen and (max-width: 767px) {\n  ::-webkit-scrollbar {\n    width: 0;\n  }\n}\n\n/*\nInput Overrides\n===============\n*/\n\n/* Chrome, Safari, Edge, Opera */\ninput::-webkit-outer-spin-button,\ninput::-webkit-inner-spin-button {\n  -webkit-appearance: none;\n  margin: 0;\n}\n\n/* clears the 'X' from Internet Explorer */\ninput[type='search']::-ms-clear {\n  display: none;\n  width: 0;\n  height: 0;\n}\ninput[type='search']::-ms-reveal {\n  display: none;\n  width: 0;\n  height: 0;\n}\n\n/* clears the 'X' from Chrome */\ninput[type='search']::-webkit-search-decoration,\ninput[type='search']::-webkit-search-cancel-button,\ninput[type='search']::-webkit-search-results-button,\ninput[type='search']::-webkit-search-results-decoration {\n  display: none;\n}\n\n/* Firefox */\ninput[type='number'] {\n  -moz-appearance: textfield;\n}\n\ninput[type='date'],\ninput[type='datetime'],\ninput[type='datetime-local'] {\n  font-size: 0.95em;\n}\n\n/*\nResponsiveness\n==============\n*/\n@media only screen and (max-width: 479px) {\n  /* phones */\n  html {\n    font-size: 0.9rem;\n  }\n}\n@media only screen and (max-width: 359px) {\n  /* small phones */\n  html {\n    font-size: 0.8rem;\n  }\n}\n\n/*\nCommon animations\n=================\n*/\n\n@keyframes fadeOut {\n  0% {\n    opacity: 1;\n  }\n  100% {\n    opacity: 0;\n  }\n}\n@keyframes fadeIn {\n  0% {\n    opacity: 0;\n  }\n  100% {\n    opacity: 1;\n  }\n}\n\n/*\nToasts\n======\n*/\n:root {\n  --toastify-color-light: #ffffff;\n  --toastify-color-dark: #2e3338;\n  --toastify-color-info: #3498db;\n  --toastify-color-success: #35d07f;\n  --toastify-color-warning: #fcd34d;\n  --toastify-color-error: #dc2626;\n  --toastify-color-transparent: rgba(255, 255, 255, 0.7);\n\n  --toastify-icon-color-info: var(--toastify-color-info);\n  --toastify-icon-color-success: var(--toastify-color-success);\n  --toastify-icon-color-warning: var(--toastify-color-warning);\n  --toastify-icon-color-error: var(--toastify-color-error);\n\n  --toastify-toast-width: 300px;\n  --toastify-toast-background: #fff;\n  --toastify-toast-min-height: 64px;\n  --toastify-toast-max-height: 800px;\n  --toastify-font-family: Roboto, sans-serif;\n  --toastify-z-index: 9999;\n\n  --toastify-text-color-light: #2e3338;\n  --toastify-text-color-dark: #fff;\n\n  --toastify-text-color-info: #fff;\n  --toastify-text-color-success: #fff;\n  --toastify-text-color-warning: #fff;\n  --toastify-text-color-error: #fff;\n\n  --toastify-spinner-color: #616161;\n  --toastify-spinner-color-empty-area: #e0e0e0;\n\n  --toastify-color-progress-light: linear-gradient(\n    to right,\n    #4cd964,\n    #5ac8fa,\n    #007aff,\n    #34aadc,\n    #5856d6,\n    #ff2d55\n  );\n  --toastify-color-progress-dark: #bb86fc;\n  --toastify-color-progress-info: var(--toastify-color-info);\n  --toastify-color-progress-success: var(--toastify-color-success);\n  --toastify-color-progress-warning: var(--toastify-color-warning);\n  --toastify-color-progress-error: var(--toastify-color-error);\n}\n\n/*\nFixes for cosmos chain selection modal\n====================================\n*/\ndiv[data-floating-ui-portal] > div {\n  z-index: 10000;\n}\n\n/*\nRefiner widget z-index override\n===============================\nNeeds to be higher than Intercom chatbox (z-index: 2147483000)\n*/\n#refiner-widget-wrapper {\n  z-index: 2147483001 !important;\n}\n\n/*\nTheme mode\n==========\nChain Edit Modal widget style overrides.\nMany selectors below target widget internals (.htw-*); revisit during @hyperlane-xyz/widgets upgrades.\n*/\n.chain-edit-container {\n  font-family: var(--font-secondary, 'PP Valve', system-ui, sans-serif);\n}\n\n/* Section headers */\n.chain-edit-container h2 {\n  font-family: var(--font-secondary, 'PP Valve', system-ui, sans-serif);\n  font-size: 1rem;\n}\n\n.chain-edit-container h3 {\n  font-family: var(--font-secondary, 'PP Valve', system-ui, sans-serif);\n  color: var(--color-gray-500, #6b7280);\n}\n\n/* Text inputs */\n.chain-edit-container input[type='text'],\n.chain-edit-container textarea {\n  font-family: var(--font-primary, 'PP Fraktion Mono', monospace);\n  border-color: #d1d5db;\n  border-radius: 4px;\n  font-size: 0.8125rem;\n}\n\n.chain-edit-container input[type='text']:focus,\n.chain-edit-container textarea:focus {\n  border-color: var(--color-primary-500, #9a0dff);\n  outline: none;\n}\n\n/* Buttons inside the widget */\n.chain-edit-container button {\n  font-family: var(--font-secondary, 'PP Valve', system-ui, sans-serif);\n}\n\n/* Links */\n.chain-edit-container a {\n  color: var(--color-primary-500, #9a0dff);\n}\n\n.chain-edit-container a:hover {\n  text-decoration: underline;\n}\n\n/* Chain edit modal dark mode overrides */\n\n/* Tooltip ? icons — brighten the background circle for visibility */\nhtml[data-theme-mode='dark'] .chain-edit-container [data-tooltip-id] .htw-rounded-full {\n  background: rgba(var(--dark-accent-rgb), 0.3) !important;\n  border: 1px solid rgba(var(--dark-accent-rgb), 0.45);\n}\n\nhtml[data-theme-mode='dark'] .chain-edit-container [data-tooltip-id] svg {\n  opacity: 0.9 !important;\n  filter: brightness(0) invert(1);\n}\n\n/* Status circles (green/red/gray) — brighter with glow */\nhtml[data-theme-mode='dark'] .chain-edit-container .htw-bg-green-500 {\n  background-color: #4ade80 !important;\n  box-shadow: 0 0 6px rgba(74, 222, 128, 0.5);\n}\n\nhtml[data-theme-mode='dark'] .chain-edit-container .htw-bg-red-500 {\n  background-color: #f87171 !important;\n  box-shadow: 0 0 6px rgba(248, 113, 113, 0.5);\n}\n\nhtml[data-theme-mode='dark'] .chain-edit-container .htw-bg-gray-400 {\n  background-color: #9ca3af !important;\n  box-shadow: 0 0 4px rgba(156, 163, 175, 0.3);\n}\n\n/* \"Add new rpc/explorer\" — tone down the plus icon */\nhtml[data-theme-mode='dark'] .chain-edit-container .htw-gap-3 > svg path {\n  fill: var(--dark-text-secondary) !important;\n}\n\nhtml[data-theme-mode='dark'] .chain-edit-container .htw-gap-3:hover > svg path {\n  fill: var(--dark-text-primary) !important;\n}\n\n/* \"Add new\" text — muted to match icon */\nhtml[data-theme-mode='dark'] .chain-edit-container .htw-gap-3 .htw-text-sm {\n  color: var(--dark-text-secondary) !important;\n}\n\nhtml[data-theme-mode='dark'] .chain-edit-container .htw-gap-3:hover .htw-text-sm {\n  color: var(--dark-text-primary) !important;\n}\n\nhtml[data-theme-switching='true'] *,\nhtml[data-theme-switching='true'] *::before,\nhtml[data-theme-switching='true'] *::after {\n  transition: none !important;\n  animation: none !important;\n}\n\nhtml[data-theme-mode='dark'] {\n  color-scheme: dark;\n}\n\nhtml[data-theme-mode='dark'] .shadow-accent-glow {\n  box-shadow: inset 0 0 20px 0 rgba(154, 13, 255, 0.35) !important;\n}\n\nhtml[data-theme-mode='dark'] .shadow-error-glow {\n  box-shadow: inset 0 0 20px 0 rgba(255, 13, 126, 0.28) !important;\n}\n\nhtml[data-theme-mode='dark'] .sidebar-menu .bg-gray-100 {\n  background-color: rgba(255, 255, 255, 0.1) !important;\n}\n\nhtml[data-theme-mode='dark'] .sidebar-menu .text-gray-900,\nhtml[data-theme-mode='dark'] .sidebar-menu .text-gray-800 {\n  color: var(--dark-text-primary) !important;\n}\n\nhtml[data-theme-mode='dark'] .sidebar-menu .text-gray-600,\nhtml[data-theme-mode='dark'] .sidebar-menu .text-gray-500,\nhtml[data-theme-mode='dark'] .sidebar-menu .text-gray-400 {\n  color: var(--dark-text-secondary) !important;\n}\n\nhtml[data-theme-mode='dark'] .sidebar-menu .sidebar-menu-empty.text-gray-500,\nhtml[data-theme-mode='dark'] .sidebar-menu .sidebar-menu-end.text-gray-400,\nhtml[data-theme-mode='dark'] .sidebar-menu .sidebar-menu-time.text-gray-500 {\n  color: var(--dark-text-primary) !important;\n}\n\n.tip-card-logo {\n  opacity: 0.38;\n  clip-path: inset(0 0 1px 0);\n  -webkit-mask-image: linear-gradient(\n    to top,\n    rgba(0, 0, 0, 0.98) 22%,\n    rgba(0, 0, 0, 0.52) 58%,\n    rgba(0, 0, 0, 0.1) 86%,\n    transparent 100%\n  );\n  mask-image: linear-gradient(\n    to top,\n    rgba(0, 0, 0, 0.98) 22%,\n    rgba(0, 0, 0, 0.52) 58%,\n    rgba(0, 0, 0, 0.1) 86%,\n    transparent 100%\n  );\n}\n\n.tip-card-logo svg {\n  display: block;\n}\n\nhtml[data-theme-mode='dark'] .tip-card-logo {\n  opacity: 0.16;\n  -webkit-mask-image: linear-gradient(\n    to top,\n    transparent 0%,\n    rgba(0, 0, 0, 0.6) 12%,\n    rgba(0, 0, 0, 1) 24%,\n    rgba(0, 0, 0, 0.58) 56%,\n    rgba(0, 0, 0, 0.12) 86%,\n    transparent 100%\n  );\n  mask-image: linear-gradient(\n    to top,\n    transparent 0%,\n    rgba(0, 0, 0, 0.6) 12%,\n    rgba(0, 0, 0, 1) 24%,\n    rgba(0, 0, 0, 0.58) 56%,\n    rgba(0, 0, 0, 0.12) 86%,\n    transparent 100%\n  );\n}\n\n/* Footer nav icons — filter-based recoloring (SVGs have hardcoded fills) */\nhtml[data-theme-mode='dark'] .footer-root nav a svg {\n  filter: saturate(0.82) brightness(1.22);\n  opacity: 0.95;\n}\n\n/* Horizontal dividers inside transfer cards */\nhtml[data-theme-mode='dark'] .transfer-divider {\n  background: rgba(185, 89, 255, 0.22) !important;\n}\n\nhtml[data-theme-mode='dark'] .transfer-form .text-gray-900,\nhtml[data-theme-mode='dark'] .transfer-form .text-gray-800 {\n  color: var(--dark-text-primary) !important;\n}\n\nhtml[data-theme-mode='dark'] .transfer-form .text-gray-700,\nhtml[data-theme-mode='dark'] .transfer-form .text-gray-600,\nhtml[data-theme-mode='dark'] .transfer-form .text-gray-500 {\n  color: var(--dark-text-secondary) !important;\n}\n\nhtml[data-theme-mode='dark'] .transfer-form .fee-section-btn.text-gray-700:hover {\n  color: var(--dark-text-primary) !important;\n}\n\nhtml[data-theme-mode='dark'] .chain-picker-modal:not(.bg-gray-100),\nhtml[data-theme-mode='dark'] .token-picker-modal {\n  border: 1px solid rgba(185, 89, 255, 0.38) !important;\n  background: var(--dark-surface) !important;\n  box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);\n}\n\nhtml[data-theme-mode='dark'] .chain-picker-modal input,\nhtml[data-theme-mode='dark'] .token-picker-search-input {\n  border-color: rgba(185, 89, 255, 0.45) !important;\n  background: rgba(255, 255, 255, 0.08) !important;\n  color: var(--dark-text-primary) !important;\n}\n\nhtml[data-theme-mode='dark'] .chain-picker-modal input::placeholder,\nhtml[data-theme-mode='dark'] .token-picker-search-input::placeholder {\n  color: var(--dark-text-secondary) !important;\n}\n\nhtml[data-theme-mode='dark'] .chain-picker-modal button {\n  color: var(--dark-text-primary);\n}\n\nhtml[data-theme-mode='dark'] .chain-picker-modal .htw-h-7 {\n  border-color: rgba(185, 89, 255, 0.4) !important;\n  background: rgba(255, 255, 255, 0.05);\n}\n\nhtml[data-theme-mode='dark'] .chain-picker-modal .htw-h-7 span {\n  color: #e9d8ff !important;\n}\n\nhtml[data-theme-mode='dark'] .chain-picker-modal .htw-relative > svg,\nhtml[data-theme-mode='dark'] .chain-picker-modal .htw-h-7 svg,\nhtml[data-theme-mode='dark'] .chain-picker-modal .htw-mr-0\\.5 svg,\nhtml[data-theme-mode='dark'] .token-picker-search-icon {\n  filter: brightness(0) invert(1) opacity(0.82);\n}\n\nhtml[data-theme-mode='dark'] .chain-picker-modal button:hover,\nhtml[data-theme-mode='dark'] .token-picker-row:hover,\nhtml[data-theme-mode='dark'] .token-picker-chain-row:hover {\n  background: rgba(185, 89, 255, 0.16) !important;\n}\n\n/* Token list panel inner backgrounds */\nhtml[data-theme-mode='dark'] .token-picker-header {\n  background: var(--dark-surface) !important;\n  border-color: rgba(185, 89, 255, 0.22) !important;\n}\n\nhtml[data-theme-mode='dark'] .token-picker-header h3 {\n  color: var(--dark-text-secondary) !important;\n}\n\nhtml[data-theme-mode='dark'] .token-picker-row .text-black {\n  color: var(--dark-text-primary) !important;\n}\n\nhtml[data-theme-mode='dark'] .token-picker-fade {\n  background: linear-gradient(to bottom, transparent, rgba(20, 10, 30, 0.96)) !important;\n}\n\nhtml[data-theme-mode='dark'] .chain-picker-fade {\n  background: linear-gradient(to bottom, transparent, rgba(20, 10, 30, 0.96)) !important;\n}\n\nhtml[data-theme-mode='dark'] .token-picker-shimmer {\n  background: rgba(255, 255, 255, 0.1) !important;\n}\n\nhtml[data-theme-mode='dark'] .token-picker-hint {\n  background: transparent !important;\n  border: 1px solid rgba(185, 89, 255, 0.25);\n}\n\nhtml[data-theme-mode='dark'] .token-picker-hint p {\n  color: var(--dark-text-secondary) !important;\n}\n\n/* Chain list selected state */\nhtml[data-theme-mode='dark'] .token-picker-chain-row .text-black {\n  color: var(--dark-text-primary) !important;\n}\n\n/* Token row hover override (replaces hover:bg-gray-100) */\nhtml[data-theme-mode='dark'] .token-picker-row.hover\\:bg-gray-100:hover {\n  background: rgba(185, 89, 255, 0.16) !important;\n}\n\nhtml[data-theme-mode='dark'] .chain-picker-modal .htw-border-gray-200,\nhtml[data-theme-mode='dark'] .chain-picker-modal .htw-border-gray-100,\nhtml[data-theme-mode='dark'] .token-picker-modal .border-gray-200,\nhtml[data-theme-mode='dark'] .token-picker-modal .border-gray-100 {\n  border-color: rgba(185, 89, 255, 0.3) !important;\n}\n\nhtml[data-theme-mode='dark'] .chain-picker-modal .htw-bg-gray-100,\nhtml[data-theme-mode='dark'] .chain-picker-modal .bg-gray-100,\nhtml[data-theme-mode='dark'] .chain-picker-modal.bg-gray-100 {\n  background: rgba(255, 255, 255, 0.06) !important;\n}\n\n/* Chain filter/sort dropdown panels */\nhtml[data-theme-mode='dark'] .chain-picker-modal .bg-white {\n  background: rgba(20, 10, 30, 0.98) !important;\n}\n\nhtml[data-theme-mode='dark'] .chain-picker-modal .border-gray-200 {\n  border-color: rgba(185, 89, 255, 0.3) !important;\n}\n\nhtml[data-theme-mode='dark'] .chain-picker-modal .text-gray-700,\nhtml[data-theme-mode='dark'] .chain-picker-modal .text-gray-600,\nhtml[data-theme-mode='dark'] .chain-picker-modal .text-gray-500 {\n  color: var(--dark-text-secondary) !important;\n}\n\nhtml[data-theme-mode='dark'] .chain-picker-modal .hover\\:bg-gray-200:hover {\n  background: rgba(185, 89, 255, 0.16) !important;\n}\n\nhtml[data-theme-mode='dark'] .chain-picker-modal .hover\\:bg-gray-100:hover {\n  background: rgba(185, 89, 255, 0.12) !important;\n}\n\nhtml[data-theme-mode='dark'] .chain-picker-modal .text-black {\n  color: var(--dark-text-primary) !important;\n}\n\n/* Toolbar + edit icons: brighten gray-500 SVG fills for dark bg */\nhtml[data-theme-mode='dark'] .chain-picker-toolbar svg path[fill='#6b7280' i],\nhtml[data-theme-mode='dark'] .chain-picker-edit-icon path[fill='#6b7280' i] {\n  fill: var(--dark-text-secondary) !important;\n}\n\nhtml[data-theme-mode='dark'] .chain-picker-toolbar svg path[fill='#9A0DFF' i] {\n  fill: #c9a0ff !important;\n}\n\nhtml[data-theme-mode='dark']\n  .chain-picker-modal\n  .htw-divide-gray-100\n  > :not([hidden])\n  ~ :not([hidden]) {\n  border-color: rgba(185, 89, 255, 0.2) !important;\n}\n\nhtml[data-theme-mode='dark'] .chain-picker-modal .htw-text-gray-700,\nhtml[data-theme-mode='dark'] .chain-picker-modal .htw-text-gray-600,\nhtml[data-theme-mode='dark'] .chain-picker-modal .htw-text-gray-500,\nhtml[data-theme-mode='dark'] .chain-picker-modal .htw-text-gray-400,\nhtml[data-theme-mode='dark'] .token-picker-modal .text-gray-500,\nhtml[data-theme-mode='dark'] .token-picker-modal .text-gray-400 {\n  color: var(--dark-text-secondary) !important;\n}\n\nhtml[data-theme-mode='dark'] .chain-picker-modal h2 {\n  color: var(--dark-text-primary) !important;\n}\n\nhtml[data-theme-mode='dark'] .chain-picker-modal a {\n  color: #e9d8ff !important;\n}\n\nhtml[data-theme-mode='dark'] .chain-picker-modal .htw-space-y-4 {\n  color: #e9d8ff;\n}\n\nhtml[data-theme-mode='dark'] .chain-picker-modal textarea {\n  border-color: rgba(185, 89, 255, 0.45) !important;\n  background: rgba(255, 255, 255, 0.08) !important;\n  color: var(--dark-text-primary) !important;\n}\n\nhtml[data-theme-mode='dark'] .chain-picker-modal textarea::placeholder {\n  color: var(--dark-text-secondary) !important;\n}\n\nhtml[data-theme-mode='dark'] .chain-picker-modal .htw-space-y-4 svg {\n  filter: brightness(0) invert(1) opacity(0.82);\n}\n\nhtml[data-theme-mode='dark'] .token-picker-symbol,\nhtml[data-theme-mode='dark'] .token-picker-usd,\nhtml[data-theme-mode='dark'] .token-picker-address,\nhtml[data-theme-mode='dark'] .token-picker-chain-name,\nhtml[data-theme-mode='dark'] .token-picker-empty {\n  color: var(--dark-text-primary) !important;\n}\n\nhtml[data-theme-mode='dark'] .token-picker-name,\nhtml[data-theme-mode='dark'] .token-picker-meta {\n  color: var(--dark-text-secondary) !important;\n}\n\nhtml[data-theme-mode='dark'] .token-picker-info-icon {\n  filter: brightness(0) invert(1) opacity(0.7);\n}\n\nhtml[data-theme-mode='dark'] .token-picker-chain-row {\n  border-color: rgba(185, 89, 255, 0.2) !important;\n}\n\nhtml[data-theme-mode='dark'] .sidebar-menu-list > :not([hidden]) ~ :not([hidden]) {\n  border-color: rgba(185, 89, 255, 0.22);\n}\n\nhtml[data-theme-mode='dark'] .sidebar-menu-chain-badge {\n  background: rgba(255, 255, 255, 0.12) !important;\n}\n\nhtml[data-theme-mode='dark'] .transfer-details-modal {\n  border: 1px solid rgba(185, 89, 255, 0.42) !important;\n  background: var(--dark-surface) !important;\n  color: var(--dark-text-primary) !important;\n  box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);\n}\n\nhtml[data-theme-mode='dark'] .transfer-details-modal .bg-primary-200 {\n  background: rgba(185, 89, 255, 0.2) !important;\n}\n\nhtml[data-theme-mode='dark'] .transfer-details-modal .text-gray-900,\nhtml[data-theme-mode='dark'] .transfer-details-modal .text-gray-800 {\n  color: var(--dark-text-primary) !important;\n}\n\nhtml[data-theme-mode='dark'] .transfer-details-modal .text-gray-600,\nhtml[data-theme-mode='dark'] .transfer-details-modal .text-gray-500,\nhtml[data-theme-mode='dark'] .transfer-details-modal .text-gray-350 {\n  color: var(--dark-text-secondary) !important;\n}\n\n/* Card gradient and shadow — dark surface variant */\nhtml[data-theme-mode='dark'] .transfer-details-modal .bg-card-gradient {\n  background: linear-gradient(\n    180deg,\n    var(--dark-surface-card) 0%,\n    rgba(20, 10, 30, 0.8) 100%\n  ) !important;\n}\n\nhtml[data-theme-mode='dark'] .transfer-details-modal .shadow-card {\n  box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.3) !important;\n}\n\nhtml[data-theme-mode='dark'] .transfer-details-modal .border-gray-400\\/25 {\n  border-color: var(--dark-border) !important;\n}\n\n/* Timeline section — text and icons */\nhtml[data-theme-mode='dark'] .transfer-details-modal h4 {\n  color: var(--dark-text-primary) !important;\n}\n\n/* Timeline stage labels (Sent, Finalized, Validated, Relayed) */\nhtml[data-theme-mode='dark'] .transfer-details-modal .htw-text-gray-700 {\n  color: var(--dark-text-primary) !important;\n}\n\n/* Timeline bar and icon circles — accent purple instead of blue */\nhtml[data-theme-mode='dark'] .transfer-details-modal .htw-bg-blue-500 {\n  background-color: rgb(var(--dark-accent-rgb)) !important;\n}\n\n/* Timeline — arrow-shaped segments via clip-path [=> >=> >=> >=] */\n/* Middle segments: V-notch left, point right */\n.transfer-details-modal .htw-bg-blue-500 {\n  clip-path: polygon(\n    0 0,\n    calc(100% - 10px) 0,\n    100% 50%,\n    calc(100% - 10px) 100%,\n    0 100%,\n    10px 50%\n  ) !important;\n}\n\n/* First segment: flat left, point right */\n.transfer-details-modal .htw-flex-1:first-child .htw-bg-blue-500 {\n  clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 50%, calc(100% - 10px) 100%, 0 100%) !important;\n}\n\n/* Last segment: V-notch left, flat right */\n.transfer-details-modal .htw-flex-1:last-child .htw-bg-blue-500 {\n  clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%, 10px 50%) !important;\n}\n\n/* Hide widget chevrons — clip-path handles the shape */\n.transfer-details-modal .htw-bg-blue-500 > :not(:first-child) {\n  opacity: 0 !important;\n}\n\n/* Center icons within clip-path chevron shapes */\n/* First segment (flat left, arrow right): visual center is ~5px left of CSS center */\n.transfer-details-modal .htw-flex-1:first-child .htw-bg-blue-500 > :first-child {\n  transform: translateX(-5px);\n}\n\n/* Middle segments: cancel widget's htw-pl-2 that pushes icons right */\n.transfer-details-modal\n  .htw-flex-1:not(:first-child):not(:last-child)\n  .htw-bg-blue-500\n  > :first-child {\n  padding-left: 0 !important;\n}\n\n/* Last segment (V-notch left, flat right): visual center is ~5px right of CSS center */\n.transfer-details-modal .htw-flex-1:last-child .htw-bg-blue-500 > :first-child {\n  transform: translateX(5px);\n}\n\n/* Close gaps between arrow segments */\n.transfer-details-modal .htw-flex-0 {\n  width: 0 !important;\n}\n\n/* Arrow between amount/ticker pairs (e.g. \"1 USDC → 1 USDC\") */\nhtml[data-theme-mode='dark'] .transfer-details-modal .font-secondary img {\n  filter: brightness(0) invert(1) opacity(0.85);\n}\n\n/* Wide chevrons between origin/dest token icons */\nhtml[data-theme-mode='dark'] .transfer-details-modal .mb-6 svg path {\n  fill: rgba(185, 89, 255, 0.6) !important;\n}\n\n/* Link icons */\nhtml[data-theme-mode='dark'] .transfer-details-modal a img {\n  filter: brightness(0) invert(1) opacity(0.82);\n}\n\nhtml[data-theme-mode='dark'] .transfer-details-modal .opacity-40 {\n  opacity: 0.7 !important;\n  filter: brightness(0) invert(1);\n}\n\nhtml[data-theme-mode='dark'] .transfer-details-modal a {\n  color: var(--dark-text-primary) !important;\n}\n\nhtml[data-theme-mode='dark'] .transfer-details-spinner {\n  color: var(--dark-text-primary) !important;\n}\n\nhtml[data-theme-mode='dark'] .transfer-details-spinner circle {\n  stroke: rgba(185, 89, 255, 0.35) !important;\n}\n\nhtml[data-theme-mode='dark'] .transfer-details-spinner path {\n  fill: rgb(var(--dark-accent-rgb)) !important;\n}\n\n/* Fix wallet icons being cropped to circles by widget's htw-rounded-full + htw-overflow-hidden */\n.sidebar-menu .htw-rounded-full.htw-overflow-hidden {\n  border-radius: 0.375rem;\n}\n\n/* Wallet rows — full width, flat, dividers between items to match transfer history */\n.sidebar-menu .htw-space-y-2 {\n  gap: 0 !important;\n}\n\n.sidebar-menu .htw-space-y-2 > * + * {\n  margin-top: 0 !important;\n  border-top: 1px solid rgba(0, 0, 0, 0.1);\n}\n\n.sidebar-menu .htw-space-y-2 > *:last-child {\n  border-bottom: 1px solid rgba(0, 0, 0, 0.1);\n}\n\n.sidebar-menu .htw-rounded-sm {\n  border-radius: 0 !important;\n  padding-left: 0.875rem !important; /* px-3.5 */\n  padding-right: 0.875rem !important;\n}\n\nhtml[data-theme-mode='dark'] .sidebar-menu .htw-space-y-2 > * + * {\n  border-color: rgba(185, 89, 255, 0.18);\n}\n\nhtml[data-theme-mode='dark'] .sidebar-menu .htw-space-y-2 > *:last-child {\n  border-color: rgba(185, 89, 255, 0.18);\n}\n\nhtml[data-theme-mode='dark'] .sidebar-menu .htw-rounded-sm {\n  color: var(--dark-text-primary) !important;\n  background: transparent;\n}\n\nhtml[data-theme-mode='dark'] .sidebar-menu .htw-rounded-sm:hover {\n  background: rgba(185, 89, 255, 0.12) !important;\n}\n\nhtml[data-theme-mode='dark'] .sidebar-menu .htw-rounded-sm .htw-text-gray-800 {\n  color: var(--dark-text-primary) !important;\n}\n\nhtml[data-theme-mode='dark'] .sidebar-menu .htw-rounded-sm .htw-text-gray-500,\nhtml[data-theme-mode='dark'] .sidebar-menu .htw-rounded-sm .htw-text-xs {\n  color: var(--dark-text-secondary) !important;\n}\n\nhtml[data-theme-mode='dark'] .sidebar-menu .htw-rounded-sm svg path {\n  fill: currentcolor !important;\n}\n\nhtml[data-theme-mode='dark'] .sidebar-menu .htw-relative > .htw-absolute svg path {\n  fill: var(--dark-text-secondary) !important;\n}\n\nhtml[data-theme-mode='dark'] .sidebar-menu .htw-bg-gray-600 {\n  background: rgba(255, 255, 255, 0.18) !important;\n  color: var(--dark-text-primary) !important;\n}\n\n.wallet-protocol-grid {\n  align-items: stretch;\n}\n\n.wallet-protocol-card {\n  min-height: 9.75rem;\n  justify-content: center;\n}\n\n.wallet-protocol-title {\n  font-weight: 500;\n}\n\n.wallet-protocol-subtitle {\n  display: flex;\n  min-height: 2.5rem;\n  align-items: center;\n  justify-content: center;\n  line-height: 1.2;\n  text-align: center;\n}\n\n.wallet-protocol-dialog .htw-flex.htw-min-h-full {\n  align-items: center;\n}\n\nhtml[data-theme-mode='dark'] .wallet-protocol-dialog .htw-bg-black\\/25 {\n  background: radial-gradient(\n    circle at 50% 45%,\n    rgba(46, 16, 77, 0.28) 0%,\n    rgba(10, 4, 16, 0.78) 55%,\n    rgba(4, 2, 8, 0.9) 100%\n  ) !important;\n}\n"
  },
  {
    "path": "src/styles/mediaQueries.ts",
    "content": "import { useEffect, useState } from 'react';\n\ninterface WindowSize {\n  width?: number;\n  height?: number;\n}\n\n// From https://usehooks.com/useWindowSize/\nexport function useWindowSize() {\n  const [windowSize, setWindowSize] = useState<WindowSize>({\n    width: window.innerWidth,\n    height: window.innerHeight,\n  });\n\n  useEffect(() => {\n    // Handler to call on window resize\n    const handleResize = () => {\n      // Set window width/height to state\n      setWindowSize({\n        width: window.innerWidth,\n        height: window.innerHeight,\n      });\n    };\n\n    // Add event listener\n    window.addEventListener('resize', handleResize);\n    // Remove event listener on cleanup\n    return () => window.removeEventListener('resize', handleResize);\n  }, []); // Empty array ensures that effect is only run on mount\n\n  return windowSize;\n}\n\nexport function isWindowSizeMobile(windowWidth: number | undefined) {\n  return !!(windowWidth && windowWidth < 768);\n}\n\nexport function isWindowSizeSmallMobile(windowWidth: number | undefined) {\n  return !!(windowWidth && windowWidth < 360);\n}\n\nexport function useIsMobile() {\n  const windowSize = useWindowSize();\n  return isWindowSizeMobile(windowSize.width);\n}\n"
  },
  {
    "path": "src/utils/date.test.ts",
    "content": "import { describe, expect, test } from 'vitest';\n\nimport { formatTimestamp, formatTransferHistoryTimestamp } from './date';\n\ndescribe('formatTransferHistoryTimestamp', () => {\n  const now = 1_700_000_000_000;\n\n  test('formats future timestamps as absolute time', () => {\n    const timestamp = now + 5_000;\n    expect(formatTransferHistoryTimestamp(timestamp, now)).toBe(formatTimestamp(timestamp));\n  });\n\n  test('uses seconds for values under a minute', () => {\n    const timestamp = now - 59_000;\n    expect(formatTransferHistoryTimestamp(timestamp, now)).toBe('59s ago');\n  });\n\n  test('returns 0s ago for zero elapsed time', () => {\n    expect(formatTransferHistoryTimestamp(now, now)).toBe('0s ago');\n  });\n\n  test('rolls over to minutes at 60 seconds', () => {\n    const timestamp = now - 60_000;\n    expect(formatTransferHistoryTimestamp(timestamp, now)).toBe('1m ago');\n  });\n\n  test('uses minutes for values under an hour', () => {\n    const timestamp = now - (59 * 60 + 59) * 1000;\n    expect(formatTransferHistoryTimestamp(timestamp, now)).toBe('59m ago');\n  });\n\n  test('rolls over to hours at 60 minutes', () => {\n    const timestamp = now - 60 * 60 * 1000;\n    expect(formatTransferHistoryTimestamp(timestamp, now)).toBe('1h ago');\n  });\n\n  test('uses hours for values under 24 hours', () => {\n    const timestamp = now - (23 * 60 * 60 + 59 * 60) * 1000;\n    expect(formatTransferHistoryTimestamp(timestamp, now)).toBe('23h ago');\n  });\n\n  test('falls back to absolute time at 24 hours', () => {\n    const timestamp = now - 24 * 60 * 60 * 1000;\n    expect(formatTransferHistoryTimestamp(timestamp, now)).toBe(formatTimestamp(timestamp));\n  });\n});\n"
  },
  {
    "path": "src/utils/date.ts",
    "content": "export function formatTimestamp(timestamp: number): string {\n  const date = new Date(timestamp);\n  return `${date.toLocaleTimeString()} ${date.toLocaleDateString()}`;\n}\n\nfunction toMs(timestamp: number): number {\n  // Timestamps below 1e12 are likely epoch seconds (ms didn't reach 1e12 until 2001)\n  return timestamp < 1e12 ? timestamp * 1000 : timestamp;\n}\n\nexport function formatTransferHistoryTimestamp(timestamp: number, now = Date.now()): string {\n  const tsMs = toMs(timestamp);\n  const elapsedMs = now - tsMs;\n  if (elapsedMs < 0) return formatTimestamp(tsMs);\n  const elapsedSec = Math.floor(elapsedMs / 1000);\n  if (elapsedSec < 60) return `${elapsedSec}s ago`;\n  const elapsedMin = Math.floor(elapsedSec / 60);\n  if (elapsedMin < 60) return `${elapsedMin}m ago`;\n  const elapsedHours = Math.floor(elapsedMin / 60);\n  if (elapsedHours < 24) return `${elapsedHours}h ago`;\n  return formatTimestamp(tsMs);\n}\n"
  },
  {
    "path": "src/utils/imageBrightness.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, test } from 'vitest';\n\nimport {\n  markDarkLogoMissing,\n  processDarkLogoImage,\n  processDarkLogosInContainer,\n  resetDarkLogoCache,\n  toOriginalVariantSrc,\n} from './imageBrightness';\n\nclass FakeImage {\n  dataset: Record<string, string> = {};\n  private attrs = new Map<string, string>();\n  private listeners = new Map<string, Array<() => void>>();\n\n  constructor(src: string) {\n    this.src = src;\n  }\n\n  get src() {\n    return this.attrs.get('src') || '';\n  }\n\n  set src(value: string) {\n    this.attrs.set('src', value);\n  }\n\n  getAttribute(name: string): string | null {\n    return this.attrs.get(name) || null;\n  }\n\n  addEventListener(name: string, callback: () => void) {\n    const existing = this.listeners.get(name) || [];\n    this.listeners.set(name, [...existing, callback]);\n  }\n\n  dispatch(name: string) {\n    const existing = this.listeners.get(name) || [];\n    existing.forEach((callback) => callback());\n  }\n}\n\nconst originalDocument = (globalThis as { document?: unknown }).document;\nconst originalWindow = (globalThis as { window?: unknown }).window;\n\nfunction setThemeMode(themeMode: 'light' | 'dark') {\n  (globalThis as { document: unknown }).document = {\n    documentElement: { dataset: { themeMode } },\n    getElementById: (id: string) =>\n      id === 'app-content'\n        ? {\n            getAttribute: (name: string) => (name === 'data-theme-mode' ? themeMode : null),\n          }\n        : null,\n  };\n\n  (globalThis as { window: unknown }).window = {\n    location: { href: 'https://app.example/' },\n    matchMedia: () => ({ matches: themeMode === 'dark' }),\n  };\n}\n\nfunction restoreDomGlobals() {\n  if (originalDocument === undefined) {\n    delete (globalThis as { document?: unknown }).document;\n  } else {\n    (globalThis as { document: unknown }).document = originalDocument;\n  }\n\n  if (originalWindow === undefined) {\n    delete (globalThis as { window?: unknown }).window;\n  } else {\n    (globalThis as { window: unknown }).window = originalWindow;\n  }\n}\n\ndescribe('imageBrightness', () => {\n  beforeEach(() => {\n    resetDarkLogoCache();\n    setThemeMode('dark');\n  });\n\n  afterEach(() => {\n    restoreDomGlobals();\n  });\n\n  test('uses dark variant in dark mode', () => {\n    const img = new FakeImage('https://cdn.example/icons/logo-a.svg');\n    processDarkLogoImage(img as unknown as HTMLImageElement);\n    expect(img.src).toBe('https://cdn.example/icons/darkmode-logo-a.svg');\n  });\n\n  test('falls back to original when dark variant fails to load', () => {\n    const img = new FakeImage('https://cdn.example/icons/logo-b.svg');\n    processDarkLogoImage(img as unknown as HTMLImageElement);\n    expect(img.src).toBe('https://cdn.example/icons/darkmode-logo-b.svg');\n\n    img.dispatch('error');\n    expect(img.src).toBe('https://cdn.example/icons/logo-b.svg');\n\n    processDarkLogoImage(img as unknown as HTMLImageElement);\n    expect(img.src).toBe('https://cdn.example/icons/logo-b.svg');\n  });\n\n  test('respects known-missing dark variants', () => {\n    markDarkLogoMissing('https://cdn.example/icons/darkmode-logo-c.svg');\n    const img = new FakeImage('https://cdn.example/icons/logo-c.svg');\n    processDarkLogoImage(img as unknown as HTMLImageElement);\n    expect(img.src).toBe('https://cdn.example/icons/logo-c.svg');\n  });\n\n  test('keeps original source in light mode', () => {\n    setThemeMode('light');\n    const img = new FakeImage('https://cdn.example/icons/logo-d.svg');\n    processDarkLogoImage(img as unknown as HTMLImageElement);\n    expect(img.src).toBe('https://cdn.example/icons/logo-d.svg');\n  });\n\n  test('processes all images in a container', () => {\n    const first = new FakeImage('https://cdn.example/icons/logo-e.svg');\n    const second = new FakeImage('https://cdn.example/icons/logo-f.svg');\n    const container = {\n      querySelectorAll: () => [first, second],\n    };\n\n    processDarkLogosInContainer(container as unknown as Element);\n\n    expect(first.src).toBe('https://cdn.example/icons/darkmode-logo-e.svg');\n    expect(second.src).toBe('https://cdn.example/icons/darkmode-logo-f.svg');\n  });\n\n  test('maps dark variant src back to original src', () => {\n    expect(toOriginalVariantSrc('https://cdn.example/icons/darkmode-logo-g.svg?v=1#logo')).toBe(\n      'https://cdn.example/icons/logo-g.svg?v=1#logo',\n    );\n  });\n\n  test('returns null when src is not a dark variant', () => {\n    expect(toOriginalVariantSrc('https://cdn.example/icons/logo-g.svg')).toBeNull();\n  });\n});\n"
  },
  {
    "path": "src/utils/imageBrightness.ts",
    "content": "function getImgSrc(img: HTMLImageElement): string {\n  return img.getAttribute('src') || '';\n}\n\nconst darkLogoAvailabilityCache = new Map<string, 'ok' | 'missing'>();\nconst DARK_LOGO_CACHE_MAX_ENTRIES = 500;\n\nfunction setDarkLogoAvailability(key: string, value: 'ok' | 'missing') {\n  if (!key) return;\n  if (darkLogoAvailabilityCache.has(key)) {\n    darkLogoAvailabilityCache.delete(key);\n  }\n  darkLogoAvailabilityCache.set(key, value);\n  if (darkLogoAvailabilityCache.size <= DARK_LOGO_CACHE_MAX_ENTRIES) return;\n  const oldestKey = darkLogoAvailabilityCache.keys().next().value as string | undefined;\n  if (oldestKey) darkLogoAvailabilityCache.delete(oldestKey);\n}\n\n/** @internal visibleForTesting */\nexport function resetDarkLogoCache() {\n  darkLogoAvailabilityCache.clear();\n}\n\nfunction isDarkModeEnabled(): boolean {\n  if (typeof document === 'undefined') return false;\n  const htmlTheme = document.documentElement.dataset.themeMode;\n  if (htmlTheme === 'dark') return true;\n  if (htmlTheme === 'light') return false;\n\n  return (\n    typeof window !== 'undefined' &&\n    typeof window.matchMedia === 'function' &&\n    window.matchMedia('(prefers-color-scheme: dark)').matches\n  );\n}\n\nfunction toDarkVariantSrc(src: string): string | null {\n  try {\n    const url = new URL(src, window.location.href);\n    const lastSlash = url.pathname.lastIndexOf('/');\n    const filename = url.pathname.substring(lastSlash + 1);\n    if (!filename || filename.toLowerCase().startsWith('darkmode-')) return null;\n    url.pathname = url.pathname.substring(0, lastSlash + 1) + 'darkmode-' + filename;\n    return url.toString();\n  } catch {\n    return null;\n  }\n}\n\nexport function toOriginalVariantSrc(src: string): string | null {\n  try {\n    const url = new URL(src, window.location.href);\n    const lastSlash = url.pathname.lastIndexOf('/');\n    const filename = url.pathname.substring(lastSlash + 1);\n    if (!filename.toLowerCase().startsWith('darkmode-')) return null;\n    url.pathname =\n      url.pathname.substring(0, lastSlash + 1) + filename.substring('darkmode-'.length);\n    return url.toString();\n  } catch {\n    return null;\n  }\n}\n\nexport function markDarkLogoMissing(darkSrc: string) {\n  setDarkLogoAvailability(darkSrc, 'missing');\n}\n\nfunction bindFallbackHandlers(img: HTMLImageElement) {\n  if (img.dataset.logoHandlersBound === 'true') return;\n\n  img.addEventListener('error', () => {\n    const original = img.dataset.logoOriginalSrc;\n    const attemptedDark = img.dataset.logoDarkSrc;\n    const current = getImgSrc(img);\n\n    if (!original || !attemptedDark) return;\n    if (current === original) return;\n\n    img.dataset.logoDarkFailed = 'true';\n    img.dataset.logoDarkFailedSrc = attemptedDark;\n    setDarkLogoAvailability(attemptedDark, 'missing');\n    img.src = original;\n  });\n\n  img.addEventListener('load', () => {\n    const attemptedDark = img.dataset.logoDarkSrc;\n    if (!attemptedDark) return;\n    if (getImgSrc(img) !== attemptedDark) return;\n    img.dataset.logoDarkFailed = 'false';\n    img.dataset.logoDarkFailedSrc = '';\n    setDarkLogoAvailability(attemptedDark, 'ok');\n  });\n\n  img.dataset.logoHandlersBound = 'true';\n}\n\nfunction syncOriginalSrc(img: HTMLImageElement): string {\n  const current = getImgSrc(img);\n  const previousOriginal = img.dataset.logoOriginalSrc;\n  const currentDark = img.dataset.logoDarkSrc;\n  const isCurrentDark = !!currentDark && current === currentDark;\n\n  // If the image changed to a new non-dark source, treat it as a new base logo.\n  if (!previousOriginal || (!isCurrentDark && current && current !== previousOriginal)) {\n    img.dataset.logoOriginalSrc = current;\n    img.dataset.logoDarkFailed = 'false';\n    img.dataset.logoDarkFailedSrc = '';\n  }\n\n  return img.dataset.logoOriginalSrc || current;\n}\n\nfunction hasReadyImg(container: Element): boolean {\n  return Array.from(container.querySelectorAll('img')).some((img) =>\n    Boolean((img as HTMLImageElement).getAttribute('src')),\n  );\n}\n\nfunction processNodeImages(node: Node) {\n  if (!(node instanceof Element)) return;\n  if (node instanceof HTMLImageElement) {\n    processDarkLogoImage(node);\n    return;\n  }\n  node.querySelectorAll('img').forEach((img) => processDarkLogoImage(img as HTMLImageElement));\n}\n\n/**\n * For one image:\n * - light mode: always use base file name\n * - dark mode: try \"darkmode-*.<ext>\" first, fallback to base on load error\n */\nexport function processDarkLogoImage(img: HTMLImageElement) {\n  const current = getImgSrc(img);\n  if (!current) return;\n\n  bindFallbackHandlers(img);\n  const original = syncOriginalSrc(img);\n  if (!original) return;\n\n  if (!isDarkModeEnabled()) {\n    if (getImgSrc(img) !== original) img.src = original;\n    return;\n  }\n\n  const darkSrc = toDarkVariantSrc(original);\n  if (!darkSrc) return;\n\n  // Don't keep retrying a dark logo we already know is missing.\n  if (\n    darkLogoAvailabilityCache.get(darkSrc) === 'missing' ||\n    (img.dataset.logoDarkFailed === 'true' && img.dataset.logoDarkFailedSrc === darkSrc)\n  ) {\n    if (getImgSrc(img) !== original) img.src = original;\n    return;\n  }\n\n  img.dataset.logoDarkSrc = darkSrc;\n  if (getImgSrc(img) !== darkSrc) img.src = darkSrc;\n}\n\n/**\n * Process all images currently in a container.\n */\nexport function processDarkLogosInContainer(container: Element) {\n  container.querySelectorAll('img').forEach((imgEl) => {\n    processDarkLogoImage(imgEl as HTMLImageElement);\n  });\n}\n\nexport function observeDarkLogosInContainer(\n  container: Element,\n  { disconnectOnFirstImage = false }: { disconnectOnFirstImage?: boolean } = {},\n): MutationObserver | null {\n  if (typeof MutationObserver === 'undefined') return null;\n\n  processDarkLogosInContainer(container);\n  if (disconnectOnFirstImage && hasReadyImg(container)) return null;\n\n  const logoObserver = new MutationObserver((mutations) => {\n    mutations.forEach((mutation) => {\n      if (mutation.type === 'childList') {\n        mutation.addedNodes.forEach(processNodeImages);\n        return;\n      }\n      if (mutation.type === 'attributes' && mutation.target instanceof HTMLImageElement) {\n        processDarkLogoImage(mutation.target);\n      }\n    });\n    if (disconnectOnFirstImage && hasReadyImg(container)) logoObserver.disconnect();\n  });\n\n  logoObserver.observe(container, {\n    childList: true,\n    subtree: true,\n    attributes: true,\n    attributeFilter: ['src'],\n  });\n  return logoObserver;\n}\n"
  },
  {
    "path": "src/utils/links.ts",
    "content": "import { MultiProtocolProvider } from '@hyperlane-xyz/sdk';\nimport { toBase64 } from '@hyperlane-xyz/utils';\n\nimport { config } from '../consts/config';\nimport { links } from '../consts/links';\nimport { isPermissionlessChain } from '../features/chains/utils';\n\nexport function getHypExplorerLink(\n  multiProvider: MultiProtocolProvider,\n  chain: ChainName,\n  msgId?: string,\n) {\n  if (!config.enableExplorerLink || !chain || !msgId) return null;\n  const baseLink = `${links.explorer}/message/${msgId}`;\n\n  if (!isPermissionlessChain(multiProvider, chain)) return baseLink;\n\n  const chainMetadata = multiProvider.tryGetChainMetadata(chain);\n  if (!chainMetadata) return baseLink;\n\n  const serializedConfig = toBase64([chainMetadata]);\n  if (!serializedConfig) return baseLink;\n\n  const params = new URLSearchParams({ chains: serializedConfig });\n  return `${baseLink}?${params.toString()}`;\n}\n"
  },
  {
    "path": "src/utils/logger.ts",
    "content": "/* eslint-disable no-console */\nimport { captureException } from '@sentry/nextjs';\n\nimport { config } from '../consts/config';\n\nexport const logger = {\n  debug: (...args: any[]) => console.debug(...args),\n  info: (...args: any[]) => console.info(...args),\n  warn: (...args: any[]) => console.warn(...args),\n  error: (message: string, err: any, ...args: any[]) => {\n    console.error(message, err, ...args);\n    if (!config.isDevMode) {\n      const filteredArgs = args.filter(isSafeSentryArg);\n      const extra = filteredArgs.reduce((acc, arg, i) => ({ ...acc, [`arg${i}`]: arg }), {});\n      extra['message'] = message;\n      captureException(err, { extra });\n    }\n  },\n};\n\n// First line of defense. Scrubbing is also configured in sentry.config.* files\nfunction isSafeSentryArg(arg: any) {\n  if (typeof arg == 'number') return true;\n  if (typeof arg == 'string') return arg.length < 1000;\n  return false;\n}\n"
  },
  {
    "path": "src/utils/pino-noop.js",
    "content": "// Mock pino during SSR to avoid transport resolution issues under Turbopack.\nconst noop = () => {};\n\nconst mockLogger = {\n  trace: noop,\n  debug: noop,\n  info: noop,\n  warn: noop,\n  error: noop,\n  fatal: noop,\n  child: () => mockLogger,\n  level: 'silent',\n};\n\nfunction pino() {\n  return mockLogger;\n}\n\npino.destination = () => process.stdout;\npino.transport = () => process.stdout;\n\nmodule.exports = pino;\nmodule.exports.default = pino;\nmodule.exports.pino = pino;\n"
  },
  {
    "path": "src/utils/promises.ts",
    "content": "/**\n * Extracts fulfilled values from Promise.allSettled results, filtering out rejected and null values.\n */\nexport function getPromisesFulfilledValues<T>(results: PromiseSettledResult<T | null>[]): T[] {\n  return results\n    .filter((r) => r.status === 'fulfilled')\n    .map((r) => r.value)\n    .filter((v) => v != null);\n}\n"
  },
  {
    "path": "src/utils/queryParams.ts",
    "content": "export function getQueryParams() {\n  return new URLSearchParams(window.location.search);\n}\n\nexport function updateQueryParam(key: string, value?: string | number) {\n  const params = getQueryParams(); // Get current query parameters\n\n  if (value === undefined || value === null) {\n    // Remove the parameter if the value is undefined or null\n    params.delete(key);\n  } else {\n    // Add or update the parameter\n    params.set(key, value.toString());\n  }\n\n  // Update the browser's URL without reloading the page\n  const newUrl = `${window.location.pathname}?${params.toString()}`;\n  window.history.replaceState({}, '', newUrl);\n}\n\nexport function updateQueryParams(params: Record<string, string | number>) {\n  for (const [key, value] of Object.entries(params)) {\n    updateQueryParam(key, value);\n  }\n}\n"
  },
  {
    "path": "src/utils/test.ts",
    "content": "import {\n  TestChainName,\n  Token,\n  TokenArgs,\n  TokenConnection,\n  TokenConnectionType,\n  TokenStandard,\n} from '@hyperlane-xyz/sdk';\n\nexport const mockCollateralAddress = '0xabc';\nexport const addressZero = '0x0000000000000000000000000000000000000000';\n\nexport const defaultTokenArgs: TokenArgs = {\n  chainName: TestChainName.test1,\n  standard: TokenStandard.EvmHypCollateral,\n  addressOrDenom: addressZero,\n  decimals: 6,\n  symbol: 'FAKE',\n  name: 'Fake Token',\n  collateralAddressOrDenom: mockCollateralAddress,\n};\n\nexport const defaultTokenArgs2: TokenArgs = {\n  ...defaultTokenArgs,\n  chainName: TestChainName.test2,\n};\n\nexport const createMockToken = (args?: Partial<TokenArgs>) => {\n  return new Token({ ...defaultTokenArgs, ...args });\n};\n\nexport const createTokenConnectionMock = (\n  args?: Partial<TokenConnection>,\n  tokenArgs?: Partial<TokenArgs>,\n): TokenConnection => {\n  return {\n    type: TokenConnectionType.Hyperlane,\n    token: createMockToken({ ...defaultTokenArgs2, ...tokenArgs }),\n    ...args,\n  } as TokenConnection;\n};\n"
  },
  {
    "path": "src/utils/theme.ts",
    "content": "import { DEFAULT_UI_THEME_MODE, UiThemeMode } from '../consts/app';\n\nexport function parseUiThemeMode(value: string | null | undefined): UiThemeMode | null {\n  if (value === 'light' || value === 'dark') return value;\n  return null;\n}\n\nexport function getSystemUiThemeMode(): UiThemeMode {\n  if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {\n    return DEFAULT_UI_THEME_MODE;\n  }\n  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';\n}\n"
  },
  {
    "path": "src/vendor/inpage-metamask.js",
    "content": "// Copied from https://github.com/WalletConnect/web3modal/pull/614/files\n// But updated to use newer packages\nimport { WindowPostMessageStream } from '@metamask/post-message-stream';\nimport { initializeProvider } from '@metamask/providers';\n\n// Firefox Metamask Hack\n// Due to https://github.com/MetaMask/metamask-extension/issues/3133\n(() => {\n  if (\n    typeof window !== 'undefined' &&\n    !window.ethereum &&\n    !window.web3 &&\n    navigator.userAgent.includes('Firefox')\n  ) {\n    // setup background connection\n    const metamaskStream = new WindowPostMessageStream({\n      name: 'metamask-inpage',\n      target: 'metamask-contentscript',\n    });\n\n    // this will initialize the provider and set it as window.ethereum\n    initializeProvider({\n      connectionStream: metamaskStream,\n      shouldShimWeb3: true,\n    });\n  }\n})();\n"
  },
  {
    "path": "src/vendor/polyfill.js",
    "content": "BigInt.prototype.toJSON = function () {\n  return this.toString();\n};\n"
  },
  {
    "path": "tailwind.config.js",
    "content": "/** @type {import('tailwindcss').Config} */\n\nconst defaultTheme = require('tailwindcss/defaultTheme');\nconst defaultColors = require('tailwindcss/colors');\n\nmodule.exports = {\n  content: ['./src/**/*.{js,ts,jsx,tsx}'],\n  darkMode: ['selector', '[data-theme-mode=\"dark\"]'],\n  theme: {\n    fontFamily: {\n      primary: ['PP Fraktion Mono', 'system-ui', 'sans-serif'],\n      secondary: ['PP Valve', 'system-ui', 'sans-serif'],\n      serif: ['Garamond', 'serif'],\n    },\n    screens: {\n      all: '1px',\n      xs: '480px',\n      ...defaultTheme.screens,\n    },\n    extend: {\n      colors: {\n        black: '#010101',\n        white: '#ffffff',\n        cream: {\n          100: '#FDFBFF',\n          200: '#FCF9FE',\n          300: '#F8F8FF',\n        },\n        gray: {\n          ...defaultColors.gray,\n          150: '#EBEDF0',\n          250: '#404040',\n          300: '#D9D9D9',\n          350: '#6B6B6B',\n          400: '#BFBFBF',\n          450: '#B6B6B6',\n          900: '#332840',\n          950: '#221A2D',\n        },\n        primary: {\n          25: '#E2C4FC',\n          50: '#E8CAFF',\n          100: '#D9A4FF',\n          200: '#C97EFF',\n          300: '#B959FF',\n          400: '#AA33FF',\n          500: '#9A0DFF',\n          600: '#860FDC',\n          700: '#7211B9',\n          800: '#5E1396',\n          900: '#4A1673',\n        },\n        accent: {\n          25: '#F8F0FF',\n          50: '#F9D5FB',\n          100: '#FABAF8',\n          200: '#FCA0F4',\n          300: '#FD85F0',\n          400: '#FE6AED',\n          450: '#FF0D7E',\n          500: '#D631B9',\n          600: '#DA46CA',\n          700: '#B53DAA',\n          800: '#91358B',\n          900: '#6C2C6C',\n        },\n        red: {\n          100: '#EBBAB8',\n          200: '#DF8D8A',\n          300: '#D25F5B',\n          400: '#C5312C',\n          500: '#BF1B15',\n          600: '#AB1812',\n          700: '#85120E',\n          800: '#5F0D0A',\n          900: '#390806',\n        },\n        green: {\n          50: '#00C467',\n          100: '#BED5C9',\n          200: '#93BAA6',\n          300: '#679F82',\n          400: '#3C835E',\n          500: '#27764d',\n          600: '#236A45',\n          700: '#1F5E3D',\n          800: '#17462E',\n          900: '#0F2F1E',\n        },\n        background: 'rgb(var(--color-background-rgb) / <alpha-value>)',\n        surface: 'rgb(var(--color-surface-rgb) / <alpha-value>)',\n        'foreground-primary': 'rgb(var(--color-text-primary-rgb) / <alpha-value>)',\n        'foreground-secondary': 'rgb(var(--color-text-secondary-rgb) / <alpha-value>)',\n        'foreground-muted': 'rgb(var(--color-text-muted-rgb) / <alpha-value>)',\n        edge: 'rgb(var(--color-border-rgb) / <alpha-value>)',\n      },\n      fontSize: {\n        xxs: '0.7rem',\n        xs: '0.775rem',\n        sm: '0.85rem',\n        md: '0.95rem',\n      },\n      spacing: {\n        88: '22rem',\n        100: '26rem',\n        112: '28rem',\n        128: '32rem',\n        144: '36rem',\n      },\n      borderRadius: {\n        none: '0',\n        sm: '0.20rem',\n        DEFAULT: '0.30rem',\n        md: '0.40rem',\n        lg: '0.50rem',\n        full: '9999px',\n      },\n      blur: {\n        xs: '3px',\n      },\n      animation: {\n        'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;',\n      },\n      backgroundImage: ({ theme }) => ({\n        'app-gradient': `radial-gradient(81.94% 51.02% at 50% 100%, ${theme('colors.primary.50')} 0%, ${theme('colors.cream.300')} 100%)`,\n        'accent-gradient': `radial-gradient(61.48% 118.8% at 50.08% 92%, ${theme('colors.primary.200')} 0%, ${theme('colors.primary.500')} 100%)`,\n        'error-gradient': `radial-gradient(61.48% 118.8% at 50.08% 92%, ${theme('colors.accent.300')} 0%, ${theme('colors.accent.450')} 100%)`,\n        'card-gradient': `linear-gradient(180deg, ${theme('colors.white')} 0%, ${theme('colors.cream.200')} 100%)`,\n        'tip-card-gradient': `radial-gradient(74.42% 40.45% at 50% 100%, ${theme('colors.primary.50')} 0%, ${theme('colors.cream.300')} 100%)`,\n      }),\n      boxShadow: ({ theme }) => ({\n        'accent-glow': `inset 2px 2px 13px 2px ${theme('colors.accent.100')}`,\n        // Intentionally identical to accent-glow — error is differentiated via error-gradient background, not the glow\n        'error-glow': `inset 2px 2px 13px 2px ${theme('colors.accent.100')}`,\n        card: `0px 4px 6px ${theme('colors.gray.950')}1A`,\n        button: `0 4px 6px ${theme('colors.gray.950')}1A`,\n        'app-header': '0px 4px 7px rgba(0,0,0,0.05)',\n        'app-header-dark': '0 8px 24px rgba(0,0,0,0.35)',\n        input: `0 0 4px ${theme('colors.gray.400')}4D`,\n        'menu-dark': '0 16px 36px rgba(0,0,0,0.45)',\n      }),\n      dropShadow: ({ theme }) => ({\n        button: `0 4px 6px ${theme('colors.gray.950')}0D`,\n      }),\n      transitionProperty: {\n        height: 'height, max-height',\n        spacing: 'margin, padding',\n      },\n      maxWidth: {\n        'xl-1': '39.5rem',\n      },\n    },\n  },\n  plugins: [],\n};\n"
  },
  {
    "path": "tests/chain-selection/edit-chain.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport { getOriginTokenButton } from '../helpers/locators';\n\ntest.describe('Chain Selection - Edit Chain', () => {\n  test('should enter edit mode and open chain edit modal', async ({ page }) => {\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // Open token selector\n    await getOriginTokenButton(page).click();\n    await expect(page.getByText('Select Token')).toBeVisible();\n\n    // Click edit mode pencil button\n    await page.getByRole('button', { name: 'Edit chain metadata' }).click();\n\n    // Button should now say \"Exit edit mode\"\n    await expect(page.getByRole('button', { name: 'Exit edit mode' })).toBeVisible();\n\n    // Click on Ethereum chain in edit mode\n    await page.getByRole('button', { name: 'ethereum Ethereum', exact: true }).click();\n\n    // Chain edit modal should open\n    await expect(page.getByText('Edit Ethereum')).toBeVisible();\n    await expect(page.getByRole('heading', { name: 'Ethereum Metadata' })).toBeVisible();\n\n    // Should show chain details\n    await expect(page.getByRole('heading', { name: 'Connections' })).toBeVisible();\n    await expect(page.getByRole('heading', { name: 'Block Explorers' })).toBeVisible();\n    await expect(page.getByRole('heading', { name: 'Chain Information' })).toBeVisible();\n\n    // Chain info details\n    await expect(page.getByRole('heading', { name: 'Chain ID' })).toBeVisible();\n    await expect(page.getByRole('heading', { name: 'Domain ID' })).toBeVisible();\n    await expect(page.getByText('Mainnet', { exact: true })).toBeVisible();\n\n    // Should have action buttons\n    await expect(page.getByRole('button', { name: 'Back' })).toBeVisible();\n    await expect(page.getByRole('button', { name: 'Copy Metadata' })).toBeVisible();\n    await expect(page.getByRole('link', { name: 'View in registry' })).toBeVisible();\n  });\n\n  test('should close edit modal with Back button', async ({ page }) => {\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    await getOriginTokenButton(page).click();\n    await page.getByRole('button', { name: 'Edit chain metadata' }).click();\n    await page.getByRole('button', { name: 'ethereum Ethereum', exact: true }).click();\n\n    // Verify modal is open\n    await expect(page.getByText('Edit Ethereum')).toBeVisible();\n\n    // Click Back\n    await page.getByRole('button', { name: 'Back' }).click();\n\n    // Modal should close, back to token selector\n    await expect(page.getByText('Edit Ethereum')).not.toBeVisible();\n    await expect(page.getByText('Select Token')).toBeVisible();\n  });\n\n  test('should exit edit mode', async ({ page }) => {\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    await getOriginTokenButton(page).click();\n\n    // Enter edit mode\n    await page.getByRole('button', { name: 'Edit chain metadata' }).click();\n    await expect(page.getByRole('button', { name: 'Exit edit mode' })).toBeVisible();\n\n    // Exit edit mode\n    await page.getByRole('button', { name: 'Exit edit mode' }).click();\n\n    // Should show \"Edit chain metadata\" again\n    await expect(page.getByRole('button', { name: 'Edit chain metadata' })).toBeVisible();\n  });\n});\n"
  },
  {
    "path": "tests/chain-selection/filter-by-protocol.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport { getOriginTokenButton } from '../helpers/locators';\n\ntest.describe('Chain Selection - Filter by Protocol', () => {\n  test('should filter chains by Sealevel protocol', async ({ page }) => {\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // Open token selector\n    await getOriginTokenButton(page).click();\n    await expect(page.getByText('Select Token')).toBeVisible();\n\n    // Open filter dropdown\n    await page.getByRole('button', { name: 'Filter chains' }).click();\n\n    // Click Sealevel protocol filter\n    await page.getByRole('button', { name: 'Sealevel' }).click();\n\n    // Should show Solana chains\n    await expect(page.getByRole('button', { name: /Solana/i }).first()).toBeVisible();\n\n    // EVM chains should not be visible\n    await expect(page.getByRole('button', { name: 'ethereum Ethereum', exact: true })).not.toBeVisible();\n  });\n\n  test('should filter chains by Cosmos protocol', async ({ page }) => {\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    await getOriginTokenButton(page).click();\n    await page.getByRole('button', { name: 'Filter chains' }).click();\n\n    // Click Cosmos protocol filter\n    await page.getByRole('button', { name: 'Cosmos', exact: true }).click();\n\n    // Should show Cosmos chains (e.g., Neutron, Osmosis, Stride)\n    await expect(page.getByRole('button', { name: /neutron Neutron/i })).toBeVisible();\n  });\n\n  test('should clear filters', async ({ page }) => {\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    await getOriginTokenButton(page).click();\n    await page.getByRole('button', { name: 'Filter chains' }).click();\n\n    // Apply a filter\n    await page.getByRole('button', { name: 'Testnet', exact: true }).click();\n\n    // Clear button should appear\n    await expect(page.getByText('Clear')).toBeVisible();\n\n    // Click Clear\n    await page.getByText('Clear').click();\n\n    // All chains should be visible again (including mainnet)\n    await expect(page.getByRole('button', { name: 'ethereum Ethereum', exact: true })).toBeVisible();\n  });\n});\n"
  },
  {
    "path": "tests/chain-selection/filter-by-type.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport { config } from '../../src/consts/config';\nimport { getOriginTokenButton } from '../helpers/locators';\n\n// Match chain rows by the `data-chain` slug (what ChainList renders) instead of\n// reconstructing a displayName at test time.\nconst defaultOriginChain = config.defaultOriginToken?.split('-')[0];\n\ntest.describe('Chain Selection - Filter by Type', () => {\n  test('should filter chains by Testnet type', async ({ page }) => {\n    test.skip(!defaultOriginChain, 'No defaultOriginToken configured');\n\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // Read default origin's testnet status directly from the token select button's data attribute\n    const originButton = getOriginTokenButton(page);\n    await expect(originButton).toBeVisible();\n    test.skip(\n      (await originButton.getAttribute('data-is-testnet')) === 'true',\n      'Default origin is a testnet chain — test assumes mainnet default',\n    );\n\n    // Open token selector\n    await originButton.click();\n    await expect(page.getByText('Select Token')).toBeVisible();\n\n    const defaultChainRow = page.locator(\n      `.token-picker-chain-row[data-chain=\"${defaultOriginChain}\"]`,\n    );\n\n    // Baseline: default origin (mainnet) chain row is visible before filtering\n    await expect(defaultChainRow).toBeVisible();\n\n    // Open filter dropdown\n    await page.getByRole('button', { name: 'Filter chains' }).click();\n\n    // Verify filter panel shows\n    await expect(page.getByText('Filters')).toBeVisible();\n    await expect(page.getByText('Type')).toBeVisible();\n    await expect(page.getByText('Protocol', { exact: true })).toBeVisible();\n\n    // Click Testnet filter\n    await page.getByRole('button', { name: 'Testnet', exact: true }).click();\n\n    // Clear button appears only when a filter is active — confirms click took effect\n    await expect(page.getByRole('button', { name: 'Clear' })).toBeVisible();\n\n    // No mainnet chain rows should remain visible — covers more than just the default\n    await expect(\n      page.locator('.token-picker-chain-row[data-is-testnet=\"false\"]'),\n    ).toHaveCount(0);\n  });\n\n  test('should filter chains by Mainnet type', async ({ page }) => {\n    test.skip(!defaultOriginChain, 'No defaultOriginToken configured');\n\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    const originButton = getOriginTokenButton(page);\n    await expect(originButton).toBeVisible();\n    test.skip(\n      (await originButton.getAttribute('data-is-testnet')) === 'true',\n      'Default origin is a testnet chain — test assumes mainnet default',\n    );\n\n    // Open token selector and filter dropdown\n    await originButton.click();\n    await page.getByRole('button', { name: 'Filter chains' }).click();\n\n    // Click Mainnet filter\n    await page.getByRole('button', { name: 'Mainnet', exact: true }).click();\n\n    // Clear button confirms the filter is active (not a silent no-op)\n    await expect(page.getByRole('button', { name: 'Clear' })).toBeVisible();\n\n    // Default origin chain row (mainnet) remains visible after Mainnet filter\n    await expect(\n      page.locator(`.token-picker-chain-row[data-chain=\"${defaultOriginChain}\"]`),\n    ).toBeVisible();\n\n    // Negative check: no testnet chain rows should remain\n    await expect(\n      page.locator('.token-picker-chain-row[data-is-testnet=\"true\"]'),\n    ).toHaveCount(0);\n  });\n});\n"
  },
  {
    "path": "tests/chain-selection/sort-chains.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport { getOriginTokenButton } from '../helpers/locators';\n\ntest.describe('Chain Selection - Sort Chains', () => {\n  test('should open sort dropdown and show sort options', async ({ page }) => {\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    await getOriginTokenButton(page).click();\n    await expect(page.getByText('Select Token')).toBeVisible();\n\n    // Open sort dropdown\n    await page.getByRole('button', { name: 'Sort: Name (asc)' }).click();\n\n    // Should show sort options\n    await expect(page.getByText('Sort by')).toBeVisible();\n    await expect(page.getByRole('button', { name: 'Name', exact: true })).toBeVisible();\n    await expect(page.getByRole('button', { name: 'Chain Id', exact: true })).toBeVisible();\n    await expect(page.getByRole('button', { name: 'Protocol', exact: true })).toBeVisible();\n    await expect(page.getByRole('button', { name: 'Toggle sort order' })).toBeVisible();\n  });\n\n  test('should sort chains by Chain Id', async ({ page }) => {\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    await getOriginTokenButton(page).click();\n\n    // Get first chain in default (Name asc) sort\n    const chainButtons = page.locator('button[class*=\"border-l-2\"]');\n    const firstChainDefault = await chainButtons.nth(1).textContent();\n\n    // Switch to Chain Id sort\n    await page.getByRole('button', { name: 'Sort: Name (asc)' }).click();\n    await page.getByRole('button', { name: 'Chain Id', exact: true }).click();\n\n    // First chain should be different after sorting by Chain Id\n    const firstChainById = await chainButtons.nth(1).textContent();\n    expect(firstChainById).not.toBe(firstChainDefault);\n  });\n\n  test('should toggle sort order', async ({ page }) => {\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    await getOriginTokenButton(page).click();\n\n    // Default sort is Name (asc) - first chain should start with 'A'\n    const chainButtons = page.locator('button[class*=\"border-l-2\"]');\n    const firstChainBefore = chainButtons.nth(1); // nth(0) is \"All Chains\"\n    await expect(firstChainBefore).toContainText(/^[A-B]/);\n\n    // Open sort and toggle order to desc\n    await page.getByRole('button', { name: 'Sort: Name (asc)' }).click();\n    await page.getByRole('button', { name: 'Toggle sort order' }).click();\n\n    // First chain should now start with Z\n    await expect(chainButtons.nth(1)).toContainText(/^[Z]/);\n  });\n});\n"
  },
  {
    "path": "tests/e2e-wallet/approval/evm.spec.ts",
    "content": "import { expect, test } from '@playwright/test';\nimport { MOCK_EVM_ADDRESS } from '../helpers/constants';\nimport { installEvmRpcMock } from '../helpers/evmRpc';\nimport { enterAmount } from '../helpers/formFlow';\nimport { openE2EApp } from '../helpers/page-setup';\n\nconst USDC_ETHEREUM = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48';\nconst APPROVE_SELECTOR = '0x095ea7b3';\n\n// transferRemote overloads on Hyperlane TokenRouter / HypERC20 — any of these\n// firing BEFORE approval would indicate the approval gate regressed.\nconst TRANSFER_REMOTE_SELECTORS = [\n  '0x81b4e8b4',\n  '0x51debffc',\n  '0xb96da154',\n];\n\ntest.describe('EVM approval flow', () => {\n  test.setTimeout(180_000);\n  test('low allowance forces approve() as the first captured tx', async ({ page }) => {\n    const { txs } = await installEvmRpcMock(page, {\n      chainUrlMap: [\n        { chainId: 8453, urlMatch: /base\\.drpc|base\\.org|base-mainnet|base\\.publicnode|base\\.llamarpc|basescan|base\\.blockpi|base\\.meowrpc/i },\n        { chainId: 1, urlMatch: /ethereum\\.|eth\\.drpc|eth-mainnet|llamarpc|cloudflare-eth|ankr.*eth|eth\\.publicnode/i },\n      ],\n      erc20: {\n        [`1:${USDC_ETHEREUM}`]: {\n          decimals: 6,\n          balances: { [MOCK_EVM_ADDRESS.toLowerCase()]: '0x3b9aca00' },\n          // Zero allowance forces the approve() branch before transferRemote.\n          allowance: '0x0',\n        },\n        [`8453:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913`]: {\n          decimals: 6,\n          defaultBalance: '0xffffffffffff',\n        },\n      },\n      wrappedTokenByChainId: {\n        1: USDC_ETHEREUM,\n        8453: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913',\n      },\n    });\n\n    await openE2EApp(page);\n    await expect(page.getByText('0xe2e...e2ee').first()).toBeVisible({ timeout: 20_000 });\n\n    await enterAmount(page, '1');\n    await page.getByRole('button', { name: /^Continue$/ }).click();\n\n    await expect(page.locator('.transfer-review-panel').first()).toContainText(\n      /Transfer Remote/i,\n      { timeout: 45_000 },\n    );\n    const sendButton = page.getByRole('button', { name: /^Send to/i });\n    await sendButton.waitFor({ state: 'visible', timeout: 30_000 });\n    await sendButton.click({ timeout: 30_000 });\n\n    // First eth_sendTransaction must be the approval call. The SDK only dispatches\n    // transferRemote after the approval receipt confirms.\n    await expect\n      .poll(() => txs.length, { timeout: 60_000, intervals: [500] })\n      .toBeGreaterThan(0);\n\n    const firstSelector = txs[0].data!.slice(0, 10).toLowerCase();\n    expect(firstSelector).toBe(APPROVE_SELECTOR);\n    // And the approve tx targets the ERC20 itself on the origin chain.\n    expect(txs[0].chainId).toBe(1);\n    expect(txs[0].to?.toLowerCase()).toBe(USDC_ETHEREUM);\n\n    // A follow-up transferRemote should eventually arrive on the same chain.\n    // If it doesn't (e.g. approve receipt polling stalls under the mock),\n    // the non-approval path below guards the regression we actually care about\n    // — that the first tx is approve, not transferRemote.\n    if (txs.length > 1) {\n      const secondSelector = txs[1].data!.slice(0, 10).toLowerCase();\n      expect(TRANSFER_REMOTE_SELECTORS).toContain(secondSelector);\n    }\n  });\n});\n"
  },
  {
    "path": "tests/e2e-wallet/autoconnect/cosmos.spec.ts",
    "content": "import { expect, test } from '@playwright/test';\nimport { openE2EApp } from '../helpers/page-setup';\n\ntest.describe('Cosmos mock wallet: auto-connect', () => {\n  test('connects to MockCosmosWallet and renders a shortened celestia bech32', async ({ page }) => {\n    await openE2EApp(page);\n\n    // Switch origin to a Cosmos-origin warp route (TIA on celestia). TIA is\n    // not in the featured-tokens list, so the default picker view hides it\n    // behind the search input — type the chain name to surface it.\n    await page.getByTestId('token-select-origin').click();\n    await page.getByText('Select Token').waitFor({ state: 'visible', timeout: 30_000 });\n    await page.getByLabel('Search tokens').fill('celestia');\n    await page\n      .getByRole('button', { name: /celestia TIA/i })\n      .first()\n      .click({ timeout: 30_000 });\n    await page.getByText('Select Token').waitFor({ state: 'hidden', timeout: 30_000 });\n\n    // Pinned from the fixed mnemonic re-encoded with the `celestia` prefix.\n    const shortened = page.getByText('celes...ud9c').first();\n    await expect(shortened).toBeVisible({ timeout: 20_000 });\n  });\n});\n"
  },
  {
    "path": "tests/e2e-wallet/autoconnect/evm.spec.ts",
    "content": "import { expect, test } from '@playwright/test';\nimport { openE2EApp } from '../helpers/page-setup';\n\ntest.describe('EVM mock connector: auto-connect', () => {\n  test('connects to mock EVM wallet on page load', async ({ page }) => {\n    await openE2EApp(page);\n    // shortenAddress lowercases (first 5 + '...' + last 4), giving 0xe2e...e2ee.\n    await expect(page.getByText('0xe2e...e2ee').first()).toBeVisible({ timeout: 20_000 });\n  });\n});\n"
  },
  {
    "path": "tests/e2e-wallet/autoconnect/radix.spec.ts",
    "content": "import { expect, test } from '@playwright/test';\nimport { openE2EApp } from '../helpers/page-setup';\n\n// MOCK_RADIX_ADDRESS from src/features/wallet/_e2e/E2EAutoConnectRadix.tsx.\n// Deterministic, never broadcasted.\nconst MOCK_RADIX_ADDRESS =\n  'account_rdx12e2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee';\n\ntest.describe('Radix mock wallet: auto-connect', () => {\n  test('seeded Radix account surfaces in the Connected Wallets sidebar', async ({ page }) => {\n    await openE2EApp(page);\n\n    // E2EAutoConnectRadix seeds the widgets-managed Radix AccountContext on\n    // mount. There is no dedicated Radix UI in the transfer form until a\n    // Radix-origin token is selected, so validate against the global\n    // Connected Wallets dropdown — a browser-level proof that the seeded\n    // state reaches the app surface.\n    await page.getByRole('button', { name: /Wallets .*Connected/i }).click();\n    await expect(page.getByText(MOCK_RADIX_ADDRESS)).toBeVisible({ timeout: 20_000 });\n  });\n\n  test('selecting a Radix-origin token flips origin WalletDropdown off \"Connect Wallet\"', async ({\n    page,\n  }) => {\n    await openE2EApp(page);\n\n    await page.getByTestId('token-select-origin').click();\n    await page.getByText('Select Token').waitFor({ state: 'visible', timeout: 30_000 });\n    await page.getByLabel('Search tokens').fill('radix');\n    await page\n      .getByRole('button', { name: /radix hSOL/i })\n      .first()\n      .click({ timeout: 30_000 });\n    await page.getByText('Select Token').waitFor({ state: 'hidden', timeout: 30_000 });\n\n    const sendSection = page.getByText('Send').first().locator('../..');\n    await expect(sendSection.getByText('Connect Wallet')).toBeHidden({ timeout: 20_000 });\n  });\n});\n"
  },
  {
    "path": "tests/e2e-wallet/autoconnect/solana.spec.ts",
    "content": "import { expect, test } from '@playwright/test';\nimport { selectOriginToken } from '../helpers/formFlow';\nimport { openE2EApp } from '../helpers/page-setup';\n\n// Deterministic Keypair.fromSeed(0xe2... ×32).publicKey shortened via\n// shortenAddress() → \"EY4LF...nwGi\". Hard-coded so the assertion fails loudly\n// if the seed or shortening scheme changes upstream.\nconst MOCK_SOLANA_SHORT = 'EY4LF...nwGi';\n\ntest.describe('Solana mock adapter: auto-connect', () => {\n  test('connects to MockSolanaAdapter and renders the shortened pubkey', async ({ page }) => {\n    await openE2EApp(page);\n\n    // Switch origin to a Solana chain so the Solana wallet dropdown renders.\n    await selectOriginToken(page, /solanamainnet USDC/i);\n\n    // Assert the Solana mock's specific shortened pubkey is visible — the\n    // previous generic `[1-9A-HJ-NP-Za-km-z]{3,}\\.\\.\\.` matcher also matched\n    // the EVM address (0xe2E...e2Ee) and silently passed even when Solana\n    // never auto-connected.\n    await expect(page.getByText(MOCK_SOLANA_SHORT).first()).toBeVisible({ timeout: 20_000 });\n  });\n});\n"
  },
  {
    "path": "tests/e2e-wallet/autoconnect/starknet.spec.ts",
    "content": "import { expect, test } from '@playwright/test';\nimport { openE2EApp } from '../helpers/page-setup';\n\ntest.describe('Starknet mock connector: auto-connect', () => {\n  test('connects to MockStarknetConnector and renders a shortened address', async ({ page }) => {\n    await openE2EApp(page, {\n      extraQuery: {\n        origin: 'starknet',\n        originToken: 'SOL',\n      },\n    });\n\n    // Origin-card WalletDropdown flips from \"Connect Wallet\" to the shortened\n    // address once MockStarknetConnector auto-connects. No manual click.\n    const sendSection = page.getByText('Send').first().locator('../..');\n    await expect(sendSection.getByText('Connect Wallet')).toBeHidden({ timeout: 20_000 });\n\n    const shortened = page.getByText('0x07e...E2E2').first();\n    await expect(shortened).toBeVisible({ timeout: 5_000 });\n  });\n});\n"
  },
  {
    "path": "tests/e2e-wallet/autoconnect/tron.spec.ts",
    "content": "import { expect, test } from '@playwright/test';\nimport { openE2EApp } from '../helpers/page-setup';\n\n// MOCK_TRON_ADDRESS from src/features/wallet/_e2e/MockTronAdapter.ts. Not a\n// real address; the adapter never broadcasts.\nconst MOCK_TRON_ADDRESS = 'TE2EE2EE2EE2EE2EE2EE2EE2EE2EE2EE2E';\n\ntest.describe('Tron mock adapter: auto-connect', () => {\n  test('MockTronAdapter auto-connects and surfaces its address in the wallet dropdown', async ({\n    page,\n  }) => {\n    await openE2EApp(page);\n\n    // No Tron-origin warp routes ship in the published registry today, so the\n    // origin picker can't surface a Tron token — we instead assert against\n    // the \"Connected Wallets\" dropdown in the header, which lists every\n    // auto-connected mock wallet once its adapter emits 'connect'.\n    await page.getByRole('button', { name: /Wallets .*Connected/i }).click();\n\n    // The button's accessible name concatenates the adapter's display name\n    // with the connected address. Match the full MOCK_TRON_ADDRESS against\n    // the expanded Connected Wallets listing.\n    await expect(page.getByText(MOCK_TRON_ADDRESS)).toBeVisible({ timeout: 20_000 });\n  });\n});\n"
  },
  {
    "path": "tests/e2e-wallet/balance-display/evm.spec.ts",
    "content": "import { expect, test } from '@playwright/test';\nimport { MOCK_EVM_ADDRESS } from '../helpers/constants';\nimport { installEvmRpcMock } from '../helpers/evmRpc';\nimport { selectOriginToken } from '../helpers/formFlow';\nimport { openE2EApp } from '../helpers/page-setup';\n\ntest.describe('EVM balance display', () => {\n  test('renders mocked ERC20 balance in transfer form', async ({ page }) => {\n    await installEvmRpcMock(page, {\n      chainUrlMap: [\n        { chainId: 1, urlMatch: /ethereum\\.|eth\\.drpc|eth-mainnet|cloudflare-eth/i },\n        { chainId: 8453, urlMatch: /base\\.drpc|base\\.org|base-mainnet/i },\n      ],\n      erc20: {\n        // Use a wildcard fallback here because current main can surface\n        // different Ethereum USDC route-token contracts while still routing to\n        // the same origin-chain UI slot we care about.\n        '*': {\n          decimals: 6,\n          // 1234.567890 USDC = 1_234_567_890 raw (6 decimals) → 0x499602d2\n          balances: {\n            [MOCK_EVM_ADDRESS.toLowerCase()]: '0x499602d2',\n          },\n        },\n      },\n    });\n\n    await openE2EApp(page);\n    await selectOriginToken(page, /ethereum USDC/i);\n\n    // The .transfer-balance element should eventually show the mocked balance.\n    const balance = page.locator('.transfer-balance').first();\n    await expect(balance).toBeVisible({ timeout: 20_000 });\n    await expect(balance).toContainText('1234.5679 USDC', { timeout: 20_000 });\n  });\n});\n"
  },
  {
    "path": "tests/e2e-wallet/balance-display/solana.spec.ts",
    "content": "import { expect, test } from '@playwright/test';\nimport { selectOriginToken } from '../helpers/formFlow';\nimport { openE2EApp, waitForWarpRuntime } from '../helpers/page-setup';\nimport { installSolanaRpcMock } from '../helpers/solanaRpc';\n\nconst USDC_MINT_SOLANAMAINNET = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';\n\ntest.describe('Solana balance display', () => {\n  test('renders mocked SPL balance for solanamainnet USDC in origin card', async ({ page }) => {\n    await installSolanaRpcMock(page, {\n      spl: {\n        balancesByMint: {\n          [USDC_MINT_SOLANAMAINNET]: '4321560000',\n        },\n      },\n    });\n\n    await openE2EApp(page);\n    // The synchronous storeInit path seeds `tokens` as TokenMetadata — useBalance\n    // short-circuits on those. Gate on the async WarpCore runtime upgrade so the\n    // token we pick is a real Token instance with a getBalance method.\n    await waitForWarpRuntime(page);\n    await expect(page.getByText('0xe2e...e2ee').first()).toBeVisible({ timeout: 20_000 });\n\n    await selectOriginToken(page, /solanamainnet USDC/i);\n\n    const balance = page.locator('.transfer-balance').first();\n    await expect(balance).toBeVisible({ timeout: 20_000 });\n    // Balance is rendered with 4 decimal places (see TokenBalance) — use a\n    // prefix match so an upstream display-format change surfaces as a clear\n    // diff rather than a cryptic substring miss.\n    await expect(balance).toContainText(/4321\\.56\\d* USDC/, { timeout: 20_000 });\n  });\n});\n"
  },
  {
    "path": "tests/e2e-wallet/destination-router/evm.spec.ts",
    "content": "import { expect, test, type Page } from '@playwright/test';\nimport { MOCK_EVM_ADDRESS } from '../helpers/constants';\nimport { installEvmRpcMock } from '../helpers/evmRpc';\nimport { clickContinue, enterAmount, selectDestinationToken } from '../helpers/formFlow';\nimport { openE2EApp, waitForWarpRuntime } from '../helpers/page-setup';\n\nconst USDC_ETHEREUM = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48';\nconst REMOTE_ADDRESS_RE = /0x[0-9a-fA-F]{40}/;\n\ntest.describe('EVM destination router selection', () => {\n  async function captureRemoteAddress(page: Page, destPattern: RegExp) {\n    await waitForWarpRuntime(page);\n    await selectDestinationToken(page, destPattern);\n    await enterAmount(page, '1');\n    await clickContinue(page);\n    const reviewPanel = page.locator('.transfer-review-panel').first();\n    await expect(reviewPanel).toContainText(/Transfer Remote/i, { timeout: 30_000 });\n    const text = await reviewPanel.innerText();\n    return text.split('Transfer Remote')[1]?.match(REMOTE_ADDRESS_RE)?.[0];\n  }\n\n  const rpcConfig = {\n    chainUrlMap: [\n      { chainId: 1, urlMatch: /ethereum\\.|eth\\.drpc/i },\n      { chainId: 8453, urlMatch: /base\\.drpc|base\\.org/i },\n      { chainId: 42161, urlMatch: /arb1\\.arbitrum|arbitrum\\.rpc/i },\n    ],\n    erc20: {\n      [`1:${USDC_ETHEREUM}`]: {\n        decimals: 6,\n        balances: { [MOCK_EVM_ADDRESS.toLowerCase()]: '0x3b9aca00' },\n      },\n    },\n  };\n\n  test('Base and Arbitrum destinations resolve distinct non-empty remote-token addresses', async ({\n    page,\n  }) => {\n    test.setTimeout(180_000);\n    await installEvmRpcMock(page, rpcConfig);\n    await openE2EApp(page);\n    await expect(page.getByText('0xe2e...e2ee').first()).toBeVisible({ timeout: 15_000 });\n    const baseAddr = await captureRemoteAddress(page, /base USDC/i);\n    expect(baseAddr).toMatch(REMOTE_ADDRESS_RE);\n    expect(baseAddr).not.toMatch(/^0x0+$/);\n\n    await openE2EApp(page);\n    await expect(page.getByText('0xe2e...e2ee').first()).toBeVisible({ timeout: 15_000 });\n    const arbAddr = await captureRemoteAddress(page, /arbitrum USDC/i);\n    expect(arbAddr).toMatch(REMOTE_ADDRESS_RE);\n    expect(arbAddr).not.toMatch(/^0x0+$/);\n    expect(arbAddr).not.toBe(baseAddr);\n  });\n});\n"
  },
  {
    "path": "tests/e2e-wallet/destination-router/solana.spec.ts",
    "content": "import { expect, test } from '@playwright/test';\nimport { selectDestinationToken, selectOriginToken } from '../helpers/formFlow';\nimport { openE2EApp, waitForWarpRuntime } from '../helpers/page-setup';\n\n// Registry-defined warp route endpoints for solanamainnet USDC. Hard-coded\n// so a drift in upstream addresses fails the test loudly rather than\n// silently passing on dedup'd collateral-group noise. The handoff's prior\n// attempt at a UI-only assertion was fooled by `checkTokenHasRoute`'s\n// collateralGroup dedup; asserting against Token.connections (which is not\n// dedup'd) is the actual ground truth.\nconst SOLANAMAINNET_USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';\nconst EXPECTED_ROUTE_ENDPOINTS: Array<{ chain: string; addressOrDenom: string }> = [\n  // Eclipse synthetic mint, owned by the Solana warp router on 3EpVCPU...\n  { chain: 'eclipsemainnet', addressOrDenom: 'EqRSt9aUDMKYKhzd1DGMderr3KNp29VZH3x5P7LFTC8m' },\n  // Base HypSynthetic router for the solanamainnet ↔ base USDC warp route\n  // on EiUymjh... (a different Solana router than the Eclipse one).\n  { chain: 'base', addressOrDenom: '0xb46930ca998587a95d9ee000fa73a071add56b64' },\n];\n\ntest.describe('Solana destination router identity', () => {\n  test('solanamainnet USDC Token.connections resolve to the expected chain-scoped routers', async ({\n    page,\n  }) => {\n    await openE2EApp(page);\n    await waitForWarpRuntime(page);\n\n    const info = await page.evaluate(() => {\n      const tokens = window.__WARP_E2E__?.tokens ?? [];\n      const byKey = new Map(tokens.map((t) => [t.key, t]));\n      const solanaUsdc = tokens.filter((t) => t.chain === 'solanamainnet' && t.symbol === 'USDC');\n      // For each solanamainnet USDC entry, resolve its connection keys to\n      // {chain, addressOrDenom} tuples. Flatten across all solanamainnet\n      // USDC routers so the test can assert on route endpoints without\n      // depending on which specific origin router the picker landed on.\n      const endpoints = solanaUsdc.flatMap((t) =>\n        t.connectionKeys\n          .map((k) => byKey.get(k))\n          .filter((c): c is NonNullable<typeof c> => Boolean(c))\n          .map((c) => ({ chain: c.chain, addressOrDenom: c.addressOrDenom })),\n      );\n      const collaterals = new Set(solanaUsdc.map((t) => t.collateralAddressOrDenom));\n      return {\n        solanaUsdcCount: solanaUsdc.length,\n        collaterals: [...collaterals],\n        endpoints,\n      };\n    });\n    const normalizedEndpoints = info.endpoints.map((endpoint) => ({\n      ...endpoint,\n      addressOrDenom:\n        endpoint.chain === 'base' ? endpoint.addressOrDenom.toLowerCase() : endpoint.addressOrDenom,\n    }));\n\n    // All solanamainnet USDC warp-route tokens must pin the same underlying\n    // SPL mint (the canonical USDC). If this drifts, an unrelated token\n    // snuck in and the rest of the assertion is meaningless.\n    expect(info.collaterals).toEqual([SOLANAMAINNET_USDC_MINT]);\n\n    for (const expected of EXPECTED_ROUTE_ENDPOINTS) {\n      expect(normalizedEndpoints, `expected endpoint for ${expected.chain}`).toContainEqual(\n        expected,\n      );\n    }\n  });\n\n  test('picking solanamainnet USDC → eclipsemainnet USDC leaves both chain labels pinned', async ({\n    page,\n  }) => {\n    await openE2EApp(page);\n    await waitForWarpRuntime(page);\n\n    await selectOriginToken(page, /solanamainnet USDC/i);\n    await selectDestinationToken(page, /eclipsemainnet USDC/i);\n\n    await expect(page.getByTestId('token-select-origin')).toContainText(/Solana/i);\n    await expect(page.getByTestId('token-select-destination')).toContainText(/Eclipse/i);\n    // Negative checks: the labels must be chain-scoped, not a dedup'd\n    // fallback to some other USDC.\n    await expect(page.getByTestId('token-select-origin')).not.toContainText(/Eclipse/i);\n    await expect(page.getByTestId('token-select-destination')).not.toContainText(/^Solana/i);\n  });\n});\n"
  },
  {
    "path": "tests/e2e-wallet/helpers/captured.ts",
    "content": "import type { Page } from '@playwright/test';\nimport type { CapturedCosmosTx, CapturedEvmTx, CapturedSolanaTx, WarpE2EState } from './types';\n\ndeclare global {\n  interface Window {\n    __WARP_E2E__?: WarpE2EState;\n  }\n}\n\nexport async function getE2EState(page: Page): Promise<WarpE2EState> {\n  const state = await page.evaluate(() => window.__WARP_E2E__);\n  if (!state) throw new Error('__WARP_E2E__ not initialized — did you call openE2EApp?');\n  return state;\n}\n\nexport async function getCapturedEvmTxs(page: Page): Promise<CapturedEvmTx[]> {\n  return (await getE2EState(page)).evmTxs;\n}\n\nexport async function getCapturedSolanaTxs(page: Page): Promise<CapturedSolanaTx[]> {\n  return (await getE2EState(page)).solanaTxs;\n}\n\nexport async function getCapturedCosmosTxs(page: Page): Promise<CapturedCosmosTx[]> {\n  return (await getE2EState(page)).cosmosTxs;\n}\n\nexport async function waitForCapturedEvmTx(page: Page, timeoutMs = 10_000): Promise<CapturedEvmTx> {\n  await page.waitForFunction(() => (window.__WARP_E2E__?.evmTxs.length ?? 0) > 0, {\n    timeout: timeoutMs,\n  });\n  const txs = await getCapturedEvmTxs(page);\n  return txs[txs.length - 1];\n}\n\nexport async function waitForCapturedSolanaTx(\n  page: Page,\n  timeoutMs = 10_000,\n): Promise<CapturedSolanaTx> {\n  await page.waitForFunction(() => (window.__WARP_E2E__?.solanaTxs.length ?? 0) > 0, {\n    timeout: timeoutMs,\n  });\n  const txs = await getCapturedSolanaTxs(page);\n  return txs[txs.length - 1];\n}\n\nexport async function waitForCapturedCosmosTx(\n  page: Page,\n  timeoutMs = 10_000,\n): Promise<CapturedCosmosTx> {\n  await page.waitForFunction(() => (window.__WARP_E2E__?.cosmosTxs.length ?? 0) > 0, {\n    timeout: timeoutMs,\n  });\n  const txs = await getCapturedCosmosTxs(page);\n  return txs[txs.length - 1];\n}\n"
  },
  {
    "path": "tests/e2e-wallet/helpers/constants.ts",
    "content": "export {\n  MOCK_COSMOS_ADDRESS,\n  MOCK_EVM_ADDRESS,\n  MOCK_SOLANA_ADDRESS,\n} from '../../../src/features/wallet/_e2e/constants';\n"
  },
  {
    "path": "tests/e2e-wallet/helpers/evmRpc.ts",
    "content": "import type { Page, Route } from '@playwright/test';\nimport { MOCK_EVM_ADDRESS } from './constants';\nimport type { CapturedEvmTx } from './types';\n\nconst FAKE_TX_HASH = ('0x' + 'ee'.repeat(32)) as `0x${string}`;\nconst ONE_ETH_HEX = '0xde0b6b3a7640000';\nconst DEFAULT_GAS_PRICE = '0x3b9aca00'; // 1 gwei\nconst DEFAULT_BLOCK_HEX = '0x12345';\nconst MAX_UINT256 = '0x' + 'f'.repeat(64);\n\nexport interface ChainUrlMatcher {\n  chainId: number;\n  urlMatch: RegExp;\n}\n\nexport interface Erc20Fixture {\n  decimals?: number;\n  symbol?: string;\n  // Owner address → hex balance (padded or not).\n  balances?: Record<string, string>;\n  // Fallback balance for any owner not in `balances`. Lets tests seed a\n  // blanket \"router holds enough collateral\" without needing to know each\n  // warp-route's router address.\n  defaultBalance?: string;\n  // Max by default; override to force approval flow.\n  allowance?: string;\n}\n\nexport interface InstallEvmRpcMockOptions {\n  // Maps chain RPC URL patterns → chainId. First match wins.\n  chainUrlMap?: ChainUrlMatcher[];\n  // ERC20 fixtures by contract address (lowercased, no 0x stripped).\n  // chainId-aware key: `${chainId}:${contractLower}`.\n  // `*` is a wildcard fallback for any otherwise-unmatched ERC20 contract.\n  erc20?: Record<string, Erc20Fixture>;\n  // Native balance per owner (hex). Defaults to 1 ETH for any address.\n  nativeBalances?: Record<string, string>;\n  // Hyperlane HypCollateral routers call `wrappedToken()` to discover the\n  // underlying ERC20. Mock returns this address per chainId.\n  wrappedTokenByChainId?: Record<number, string>;\n  // routers(uint32) result keyed by remote domain. Falls back to a\n  // deterministic address derived from the requested domain.\n  routerByDomain?: Record<number, string>;\n}\n\nexport interface EvmRpcMockHandle {\n  txs: CapturedEvmTx[];\n  resolveChainId: (url: string) => number | undefined;\n}\n\nexport async function installEvmRpcMock(\n  page: Page,\n  opts: InstallEvmRpcMockOptions = {},\n): Promise<EvmRpcMockHandle> {\n  const txs: CapturedEvmTx[] = [];\n  const erc20 = opts.erc20 ?? {};\n  const chainUrlMap = opts.chainUrlMap ?? [];\n\n  const resolveChainId = (url: string): number | undefined =>\n    chainUrlMap.find((m) => m.urlMatch.test(url))?.chainId;\n\n  await page.route('**/*', async (route: Route) => {\n    const req = route.request();\n    if (req.method() !== 'POST') return route.continue();\n    let body: unknown;\n    try {\n      body = req.postDataJSON();\n    } catch {\n      return route.continue();\n    }\n    if (!body || typeof body !== 'object') return route.continue();\n    const isBatch = Array.isArray(body);\n    const items = isBatch ? (body as unknown[]) : [body];\n    const firstItem = items[0] as { jsonrpc?: string; method?: string };\n    if (!firstItem?.jsonrpc) return route.continue();\n    // Only handle EVM-shaped JSON-RPC. Solana RPC also uses JSON-RPC over\n    // POST (getBalance/getTokenAccountsByOwner/…), so without this guard\n    // we'd return eth_* shaped responses to Solana calls.\n    const method = firstItem.method ?? '';\n    const isEvmMethod =\n      method.startsWith('eth_') ||\n      method.startsWith('net_') ||\n      method.startsWith('wallet_') ||\n      method.startsWith('web3_');\n    if (!isEvmMethod) return route.continue();\n\n    const url = req.url();\n    const chainId = resolveChainId(url);\n    const responses = items.map((item) => handleOne(item, { url, chainId, erc20, opts, txs }));\n    return route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify(isBatch ? responses : responses[0]),\n    });\n  });\n\n  return { txs, resolveChainId };\n}\n\ninterface HandleCtx {\n  url: string;\n  chainId: number | undefined;\n  erc20: Record<string, Erc20Fixture>;\n  opts: InstallEvmRpcMockOptions;\n  txs: CapturedEvmTx[];\n}\n\nfunction handleOne(itemUnknown: unknown, ctx: HandleCtx): unknown {\n  const item = itemUnknown as { id?: unknown; method?: string; params?: unknown[] };\n  const ok = (result: unknown) => ({ jsonrpc: '2.0', id: item.id ?? null, result });\n  const fail = (message: string) => ({\n    jsonrpc: '2.0',\n    id: item.id ?? null,\n    error: { code: -32601, message },\n  });\n\n  const { method, params = [] } = item;\n  const chainIdHex = ctx.chainId !== undefined ? `0x${ctx.chainId.toString(16)}` : '0x1';\n\n  switch (method) {\n    case 'eth_chainId':\n      return ok(chainIdHex);\n    case 'net_version':\n      return ok(String(ctx.chainId ?? 1));\n    case 'eth_blockNumber':\n      return ok(DEFAULT_BLOCK_HEX);\n    case 'eth_getBlockByNumber':\n    case 'eth_getBlockByHash':\n      // Ethers v5 parses every hash field via isHexString(value, 32) and throws\n      // \"invalid hash\" on undefined — include every hash-shaped field so fee\n      // resolution in the Hyperlane SDK path works end-to-end.\n      return ok({\n        number: DEFAULT_BLOCK_HEX,\n        hash: '0x' + '1'.repeat(64),\n        parentHash: '0x' + '2'.repeat(64),\n        sha3Uncles: '0x' + '3'.repeat(64),\n        stateRoot: '0x' + '4'.repeat(64),\n        transactionsRoot: '0x' + '5'.repeat(64),\n        receiptsRoot: '0x' + '6'.repeat(64),\n        mixHash: '0x' + '7'.repeat(64),\n        logsBloom: '0x' + '0'.repeat(512),\n        difficulty: '0x0',\n        totalDifficulty: '0x0',\n        extraData: '0x',\n        size: '0x1000',\n        gasLimit: '0x1c9c380',\n        gasUsed: '0x0',\n        timestamp: '0x65000000',\n        uncles: [],\n        nonce: '0x0000000000000000',\n        miner: '0x' + '0'.repeat(40),\n        baseFeePerGas: DEFAULT_GAS_PRICE,\n        transactions: [],\n      });\n    case 'eth_gasPrice':\n    case 'eth_maxPriorityFeePerGas':\n      return ok(DEFAULT_GAS_PRICE);\n    case 'eth_feeHistory':\n      return ok({\n        oldestBlock: DEFAULT_BLOCK_HEX,\n        baseFeePerGas: [DEFAULT_GAS_PRICE, DEFAULT_GAS_PRICE],\n        gasUsedRatio: [0.5],\n        reward: [['0x0']],\n      });\n    case 'eth_estimateGas':\n      return ok('0x186a0');\n    case 'eth_getBalance': {\n      const addr = String(params[0] ?? '').toLowerCase();\n      return ok(ctx.opts.nativeBalances?.[addr] ?? ONE_ETH_HEX);\n    }\n    case 'eth_call':\n      return ok(handleEthCall(params[0] as { to?: string; data?: string }, ctx));\n    case 'eth_sendTransaction':\n    case 'eth_sendRawTransaction': {\n      if (method === 'eth_sendTransaction') {\n        const tx = params[0] as {\n          to?: `0x${string}`;\n          data?: `0x${string}`;\n          value?: string;\n          from?: `0x${string}`;\n        };\n        ctx.txs.push({\n          chainId: ctx.chainId ?? 0,\n          to: tx?.to,\n          data: tx?.data,\n          value: tx?.value,\n          from: tx?.from,\n        });\n      }\n      return ok(FAKE_TX_HASH);\n    }\n    case 'eth_getTransactionReceipt':\n      return ok({\n        status: '0x1',\n        transactionHash: params[0],\n        blockNumber: DEFAULT_BLOCK_HEX,\n        blockHash: '0x' + '1'.repeat(64),\n        from: MOCK_EVM_ADDRESS.toLowerCase(),\n        to: '0x' + '0'.repeat(40),\n        gasUsed: '0x186a0',\n        cumulativeGasUsed: '0x186a0',\n        effectiveGasPrice: DEFAULT_GAS_PRICE,\n        logs: [],\n        logsBloom: '0x' + '0'.repeat(512),\n        type: '0x2',\n        contractAddress: null,\n      });\n    case 'eth_getTransactionByHash':\n      return ok({\n        hash: params[0],\n        blockNumber: DEFAULT_BLOCK_HEX,\n        blockHash: '0x' + '1'.repeat(64),\n        transactionIndex: '0x0',\n        from: MOCK_EVM_ADDRESS.toLowerCase(),\n        to: '0x' + '0'.repeat(40),\n        value: '0x0',\n        gas: '0x186a0',\n        gasPrice: DEFAULT_GAS_PRICE,\n        nonce: '0x0',\n        input: '0x',\n        type: '0x2',\n      });\n    case 'eth_getCode':\n      return ok('0x');\n    case 'eth_getTransactionCount':\n      return ok('0x0');\n    case 'web3_clientVersion':\n      return ok('warp-e2e-mock/1.0');\n    default:\n      return fail(`Method not mocked: ${method}`);\n  }\n}\n\nfunction handleEthCall(call: { to?: string; data?: string }, ctx: HandleCtx): string {\n  const to = (call.to ?? '').toLowerCase();\n  const data = call.data ?? '0x';\n  const erc20Key = `${ctx.chainId ?? '?'}:${to}`;\n  const erc20Fixture = ctx.erc20[erc20Key] ?? ctx.erc20[to] ?? ctx.erc20['*'];\n\n  // ERC20 balanceOf(address)\n  if (data.startsWith('0x70a08231')) {\n    const owner = ('0x' + data.slice(34, 74)).toLowerCase();\n    const direct = erc20Fixture?.balances?.[owner];\n    if (direct !== undefined) return padHex(direct);\n    if (erc20Fixture?.defaultBalance !== undefined) return padHex(erc20Fixture.defaultBalance);\n    return padHex('0x0');\n  }\n  // ERC20 allowance(address,address)\n  if (data.startsWith('0xdd62ed3e')) {\n    return padHex(erc20Fixture?.allowance ?? MAX_UINT256);\n  }\n  // decimals()\n  if (data.startsWith('0x313ce567')) {\n    return padHex('0x' + (erc20Fixture?.decimals ?? 18).toString(16));\n  }\n  // symbol()\n  if (data.startsWith('0x95d89b41')) {\n    return encodeString(erc20Fixture?.symbol ?? 'TEST');\n  }\n  // Hyperlane router probes — without these the SDK throws during\n  // warpCore.getTransferRemoteTxs / estimateTransferRemoteFees (semver parse).\n\n  // PACKAGE_VERSION() — string; \"6.0.0\" passes all compareVersions gates.\n  if (data.startsWith('0x93c44847')) return encodeString('6.0.0');\n  // wrappedToken() — Hyperlane HypCollateral queries this to discover the\n  // underlying ERC20 before calling balanceOf. Must return a real address,\n  // otherwise the SDK reads balanceOf on 0x0 and the collateral check fails.\n  if (data.startsWith('0x996c6cc3')) {\n    const perChain = ctx.opts.wrappedTokenByChainId?.[ctx.chainId ?? -1];\n    if (perChain) return padHex(perChain);\n    return padHex('0x0');\n  }\n  // Other address-returning probes (mailbox, owner, feeRecipient, hook, ISM).\n  if (\n    data.startsWith('0xd5438eae') ||\n    data.startsWith('0x8da5cb5b') ||\n    data.startsWith('0x46904840') ||\n    data.startsWith('0x7f5a7c7b') ||\n    data.startsWith('0xde523cf3')\n  ) {\n    return padHex('0x0');\n  }\n  // quoteGasPayment(uint32) / destinationGas(uint32) — uint256, 0 is a fine default.\n  if (data.startsWith('0xf2ed8c53') || data.startsWith('0x775313a1')) return padHex('0x0');\n  // routers(uint32) — bytes32 router address on remote domain.\n  if (data.startsWith('0x2ead72f6')) {\n    const domain = readUint32Arg(data);\n    const router =\n      domain == null\n        ? '0x' + 'a'.repeat(40)\n        : (ctx.opts.routerByDomain?.[domain] ??\n          `0x${domain.toString(16).padStart(40, '0')}`);\n    return padHex(router);\n  }\n  // domains() — uint32[]; empty array works (SDK has .catch fallback).\n  if (data.startsWith('0x440df4f4')) return '0x' + '20'.padStart(64, '0') + '0'.repeat(64);\n  // Multicall3 aggregate3 — empty Result[] forces SDK to use individual getBalance calls.\n  if (data.startsWith('0x82ad56cb')) return '0x' + '20'.padStart(64, '0') + '0'.repeat(64);\n\n  return '0x' + '0'.repeat(64);\n}\n\nfunction padHex(hex: string): string {\n  const stripped = hex.startsWith('0x') ? hex.slice(2) : hex;\n  return '0x' + stripped.padStart(64, '0');\n}\n\nfunction encodeString(s: string): string {\n  const bytes = Array.from(new TextEncoder().encode(s))\n    .map((b) => b.toString(16).padStart(2, '0'))\n    .join('');\n  const len = s.length.toString(16).padStart(64, '0');\n  const offset = '20'.padStart(64, '0');\n  const padded = (bytes + '0'.repeat(64)).slice(0, Math.ceil(bytes.length / 64) * 64 || 64);\n  return '0x' + offset + len + padded;\n}\n\nfunction readUint32Arg(data: string): number | undefined {\n  const word = data.slice(10, 74);\n  if (word.length !== 64) return undefined;\n  return Number.parseInt(word.slice(56), 16);\n}\n"
  },
  {
    "path": "tests/e2e-wallet/helpers/formFlow.ts",
    "content": "import type { Page } from '@playwright/test';\n\n// Extra patience for token-picker flows — under full-suite load (cosmos-kit\n// module init, multicall fallbacks, etc.) the default 5s locator timeouts\n// occasionally aren't enough.\nconst MODAL_TIMEOUT = 30_000;\n\nfunction tokenPickerModal(page: Page) {\n  return page\n    .locator('div.token-picker-modal[data-headlessui-state=\"open\"]')\n    .filter({ hasText: 'Select Token' });\n}\n\n// In dev builds (`pnpm dev`) Next.js renders a <nextjs-portal> web\n// component that intermittently shows up as the topmost element at\n// picker button click points. Playwright's pointer-based click hit-tests\n// report \"<nextjs-portal></nextjs-portal> intercepts pointer events\" and\n// retry until timeout; a plain `force: true` click clears the\n// intercept check but sometimes lands the pointer-up on the portal, so\n// the React onClick never fires. Use `dispatchEvent('click')` instead —\n// it synthesizes the click event directly on the target element via DOM\n// API, bypassing hit-testing entirely. We always wait for the modal to\n// be visible before the inner click, so the target is mounted and the\n// click reaches the correct React handler. CI runs prod builds where\n// the portal doesn't exist, so this is a no-op there.\nexport async function selectOriginToken(page: Page, buttonName: RegExp): Promise<void> {\n  await page.getByTestId('token-select-origin').dispatchEvent('click');\n  const modal = tokenPickerModal(page);\n  await modal.waitFor({ state: 'visible', timeout: MODAL_TIMEOUT });\n  await modal\n    .getByRole('button', { name: buttonName })\n    .first()\n    .dispatchEvent('click', undefined, { timeout: MODAL_TIMEOUT });\n  await modal.waitFor({ state: 'hidden', timeout: MODAL_TIMEOUT });\n}\n\nexport async function selectDestinationToken(page: Page, buttonName: RegExp): Promise<void> {\n  await page.getByTestId('token-select-destination').dispatchEvent('click');\n  const modal = tokenPickerModal(page);\n  await modal.waitFor({ state: 'visible', timeout: MODAL_TIMEOUT });\n  await modal\n    .getByRole('button', { name: buttonName })\n    .first()\n    .dispatchEvent('click', undefined, { timeout: MODAL_TIMEOUT });\n  await modal.waitFor({ state: 'hidden', timeout: MODAL_TIMEOUT });\n}\n\nexport async function enterAmount(page: Page, amount: string): Promise<void> {\n  const input = page.getByRole('spinbutton');\n  await input.click();\n  await input.fill(amount);\n}\n\nexport async function clickContinue(page: Page): Promise<void> {\n  // ButtonSection renders Continue in input mode, Send in review mode.\n  await page.getByRole('button', { name: /^Continue$/ }).dispatchEvent('click');\n}\n\nexport async function clickSendInReview(page: Page): Promise<void> {\n  await page.getByRole('button', { name: /Send to /i }).dispatchEvent('click');\n}\n"
  },
  {
    "path": "tests/e2e-wallet/helpers/page-setup.ts",
    "content": "import type { Page } from '@playwright/test';\n\nconst DEFAULT_BASE = 'http://localhost:3000';\n\nexport interface OpenE2EAppOptions {\n  path?: string;\n  extraQuery?: Record<string, string>;\n}\n\nexport async function openE2EApp(page: Page, opts: OpenE2EAppOptions = {}): Promise<void> {\n  const path = opts.path ?? '/';\n  const params = new URLSearchParams({ _e2e: '1', ...(opts.extraQuery ?? {}) });\n  await page.goto(`${DEFAULT_BASE}${path}?${params.toString()}`);\n  await page.waitForFunction(() => Boolean(window.__WARP_E2E__));\n}\n\n// Blocks until the async WarpCore runtime has replaced the synchronous\n// TokenMetadata entries in the store. Gate on this before interacting with\n// anything that calls token.getBalance (origin/destination balance cards,\n// review-panel route lookups).\nexport async function waitForWarpRuntime(page: Page, timeoutMs = 20_000): Promise<void> {\n  await page.waitForFunction(() => Boolean(window.__WARP_E2E__?.isRuntimeReady), null, {\n    timeout: timeoutMs,\n  });\n}\n"
  },
  {
    "path": "tests/e2e-wallet/helpers/solanaRpc.ts",
    "content": "import type { Page, Route } from '@playwright/test';\n\n// Solana RPC methods the warp UI and SDK balance path actually call.\n//   - getParsedTokenAccountsByOwner  → balance listing for SPL/Token-2022 holders\n//   - getBalance                     → native SOL lamports\n//   - getAccountInfo                 → mint info lookups (isSpl2022 probe, etc.)\n// The goal here is enough surface to make the origin-card balance render a\n// known number; transaction signing is not exercised.\n\nexport interface SolanaSplFixture {\n  // Mint base58 → owner balance in raw atoms (u64 as string to dodge Number limits).\n  // One mint may be held by multiple token accounts in reality; we collapse to\n  // a single synthetic account per mint/owner pair.\n  balancesByMint: Record<string, string>;\n  // Mark select mints as Token-2022 so the adapter takes the right program-id\n  // code path. All others default to the classic SPL program.\n  token2022Mints?: string[];\n}\n\nexport interface InstallSolanaRpcMockOptions {\n  // Optional endpoint guard. When omitted we intercept any Solana JSON-RPC\n  // method regardless of host, which is safer on current main because the app\n  // fans out across many provider URLs per chain.\n  urlMatch?: RegExp;\n  // Lamports → owner base58 → hex/decimal string. Defaults to 1 SOL for any\n  // owner not listed.\n  nativeLamports?: Record<string, number>;\n  spl?: SolanaSplFixture;\n}\n\nconst TOKEN_PROGRAM_ID = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA';\nconst TOKEN_2022_PROGRAM_ID = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb';\nconst SOL_LAMPORTS = 1_000_000_000;\n\nexport async function installSolanaRpcMock(\n  page: Page,\n  opts: InstallSolanaRpcMockOptions = {},\n): Promise<void> {\n  const urlMatch = opts.urlMatch;\n  const balancesByMint = opts.spl?.balancesByMint ?? {};\n  const token2022 = new Set(opts.spl?.token2022Mints ?? []);\n  const nativeLamports = opts.nativeLamports ?? {};\n\n  await page.route('**/*', async (route: Route) => {\n    const req = route.request();\n    if (req.method() !== 'POST') return route.continue();\n    const url = req.url();\n    let body: unknown;\n    try {\n      body = req.postDataJSON();\n    } catch {\n      return route.continue();\n    }\n    if (!body || typeof body !== 'object') return route.continue();\n    const isBatch = Array.isArray(body);\n    const items = isBatch ? (body as unknown[]) : [body];\n    const first = items[0] as { jsonrpc?: string; method?: string };\n    if (urlMatch && !urlMatch.test(url)) return route.continue();\n    if (first?.jsonrpc !== '2.0') return route.continue();\n    // Only claim Solana JSON-RPC methods; everything else (eth_*, cosmos REST)\n    // stays on the wire or is handled by another matcher.\n    if (!isSolanaMethod(first.method)) return route.continue();\n\n    const responses = items.map((item) =>\n      handleOne(item, { balancesByMint, token2022, nativeLamports }),\n    );\n    return route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify(isBatch ? responses : responses[0]),\n    });\n  });\n}\n\nfunction isSolanaMethod(method?: string): boolean {\n  if (!method) return false;\n  // The Solana RPC method namespace is flat (no common prefix), so enumerate.\n  return [\n    'getParsedTokenAccountsByOwner',\n    'getTokenAccountsByOwner',\n    'getBalance',\n    'getAccountInfo',\n    'getMultipleAccounts',\n    'getTokenAccountBalance',\n    'getTokenSupply',\n    'getLatestBlockhash',\n    'getMinimumBalanceForRentExemption',\n    'getSignatureStatuses',\n    'sendTransaction',\n    'getGenesisHash',\n    'getHealth',\n    'getEpochInfo',\n    'getBlockHeight',\n    'getSlot',\n  ].includes(method);\n}\n\ninterface HandleCtx {\n  balancesByMint: Record<string, string>;\n  token2022: Set<string>;\n  nativeLamports: Record<string, number>;\n}\n\nfunction handleOne(itemUnknown: unknown, ctx: HandleCtx): unknown {\n  const item = itemUnknown as { id?: unknown; method?: string; params?: unknown[] };\n  const ok = (result: unknown) => ({ jsonrpc: '2.0', id: item.id ?? null, result });\n  const { method, params = [] } = item;\n\n  switch (method) {\n    case 'getBalance': {\n      const owner = String(params[0] ?? '');\n      const value = ctx.nativeLamports[owner] ?? SOL_LAMPORTS;\n      return ok({ context: { slot: 1 }, value });\n    }\n    case 'getParsedTokenAccountsByOwner': {\n      const owner = String(params[0] ?? '');\n      const filter = params[1] as { programId?: string } | undefined;\n      const programId = filter?.programId ?? TOKEN_PROGRAM_ID;\n      // Return one synthetic parsed token account per mint whose program matches.\n      const accounts = Object.entries(ctx.balancesByMint)\n        .filter(([mint]) => {\n          const isToken2022 = ctx.token2022.has(mint);\n          return programId === TOKEN_2022_PROGRAM_ID ? isToken2022 : !isToken2022;\n        })\n        .map(([mint, amount]) => buildParsedAccount({ owner, mint, amount }));\n      return ok({ context: { slot: 1 }, value: accounts });\n    }\n    case 'getAccountInfo': {\n      // Minimal account shape — the mint-info probe in\n      // SealevelTokenAdapter.isSpl2022 only checks owner program.\n      const pubkey = String(params[0] ?? '');\n      const owner = ctx.token2022.has(pubkey) ? TOKEN_2022_PROGRAM_ID : TOKEN_PROGRAM_ID;\n      return ok({\n        context: { slot: 1 },\n        value: {\n          lamports: 1_000_000,\n          owner,\n          data: ['', 'base64'],\n          executable: false,\n          rentEpoch: 0,\n        },\n      });\n    }\n    case 'getTokenAccountBalance': {\n      // SealevelTokenAdapter.getBalance derives an ATA and calls this —\n      // we don't know which mint corresponds to that ATA without replicating\n      // the derivation, so return the sum of all seeded mint balances. Tests\n      // stage exactly one mint at a time in practice.\n      const total = Object.values(ctx.balancesByMint).reduce(\n        (acc, v) => acc + BigInt(v),\n        0n,\n      );\n      const formatted = formatUiAmount(total, 6);\n      return ok({\n        context: { slot: 1 },\n        value: {\n          amount: total.toString(),\n          decimals: 6,\n          uiAmount: formatted.uiAmount,\n          uiAmountString: formatted.uiAmountString,\n        },\n      });\n    }\n    case 'getHealth':\n      return ok('ok');\n    case 'getBlockHeight':\n    case 'getSlot':\n      // Any positive integer passes the SDK's Solana health probe.\n      return ok(123_456);\n    case 'getGenesisHash':\n      return ok('5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d');\n    case 'getLatestBlockhash':\n      return ok({\n        context: { slot: 1 },\n        value: {\n          blockhash: '11111111111111111111111111111111',\n          lastValidBlockHeight: 1,\n        },\n      });\n    default:\n      return {\n        jsonrpc: '2.0',\n        id: item.id ?? null,\n        error: { code: -32601, message: `Method not found: ${method}` },\n      };\n  }\n}\n\nfunction buildParsedAccount({\n  owner,\n  mint,\n  amount,\n}: {\n  owner: string;\n  mint: string;\n  amount: string;\n}): unknown {\n  const formatted = formatUiAmount(BigInt(amount), 6);\n  // Synthesize the getParsedTokenAccountsByOwner shape the SDK/app consume.\n  // `pubkey` is usually the derived ATA; the reader only cares that the entry\n  // is associated with this mint + has the right parsed amount, so a\n  // deterministic string is fine.\n  return {\n    pubkey: deriveSyntheticAta(owner, mint),\n    account: {\n      data: {\n        parsed: {\n          info: {\n            mint,\n            owner,\n            tokenAmount: {\n              amount,\n              decimals: 6,\n              uiAmount: formatted.uiAmount,\n              uiAmountString: formatted.uiAmountString,\n            },\n          },\n          type: 'account',\n        },\n        program: 'spl-token',\n        space: 165,\n      },\n      executable: false,\n      lamports: 1_000_000,\n      owner: TOKEN_PROGRAM_ID,\n      rentEpoch: 0,\n    },\n  };\n}\n\nfunction deriveSyntheticAta(owner: string, mint: string): string {\n  // Deterministic-but-fake; distinct per (owner, mint) pair, and always valid\n  // base58 of reasonable length.\n  const seed = (owner + mint).slice(0, 32).padEnd(32, 'x');\n  return 'Mock' + seed;\n}\n\nfunction formatUiAmount(\n  amount: bigint,\n  decimals: number,\n): { uiAmount: number | null; uiAmountString: string } {\n  const divisor = 10n ** BigInt(decimals);\n  const whole = amount / divisor;\n  const fraction = (amount % divisor).toString().padStart(decimals, '0');\n  const uiAmountString = `${whole}.${fraction}`.replace(/\\.?0+$/, '') || '0';\n  const uiAmountNumber = Number(uiAmountString);\n  return {\n    uiAmount: Number.isFinite(uiAmountNumber) ? uiAmountNumber : null,\n    uiAmountString,\n  };\n}\n"
  },
  {
    "path": "tests/e2e-wallet/helpers/types.ts",
    "content": "// Mirror of src/features/wallet/_e2e/windowState.ts types, kept in /tests so\n// the test suite can type-check window.__WARP_E2E__ reads without importing\n// from src (keeps test/src dep graphs clean).\nexport interface CapturedEvmTx {\n  chainId: number;\n  to?: `0x${string}`;\n  data?: `0x${string}`;\n  value?: string;\n  from?: `0x${string}`;\n}\n\nexport interface CapturedSolanaTx {\n  feePayer?: string;\n  serializedBase64: string;\n  programIds: string[];\n}\n\nexport interface CapturedCosmosTx {\n  chainId: string;\n  signerAddress: string;\n  typeUrls: string[];\n  messagesJson: string;\n}\n\nexport interface E2ETokenSnapshot {\n  key: string;\n  chain: string;\n  symbol: string;\n  standard: string;\n  addressOrDenom: string;\n  collateralAddressOrDenom?: string;\n  connectionKeys: string[];\n}\n\nexport interface WarpE2EState {\n  readyAt: number;\n  evmTxs: CapturedEvmTx[];\n  solanaTxs: CapturedSolanaTx[];\n  cosmosTxs: CapturedCosmosTx[];\n  isRuntimeReady?: boolean;\n  tokens?: E2ETokenSnapshot[];\n}\n"
  },
  {
    "path": "tests/e2e-wallet/invalid-route/evm.spec.ts",
    "content": "import { expect, test } from '@playwright/test';\nimport { installEvmRpcMock } from '../helpers/evmRpc';\nimport { getCapturedEvmTxs } from '../helpers/captured';\nimport { openE2EApp } from '../helpers/page-setup';\n\ntest.describe('EVM invalid route / amount', () => {\n  test('submit is blocked when amount is 0 (no tx captured)', async ({ page }) => {\n    const { txs } = await installEvmRpcMock(page, {\n      chainUrlMap: [\n        { chainId: 1, urlMatch: /ethereum\\.|eth\\.drpc/i },\n        { chainId: 8453, urlMatch: /base\\.drpc|base\\.org/i },\n      ],\n    });\n    await openE2EApp(page);\n\n    // Wait for auto-connect to settle so submit button is live.\n    await expect(page.getByText('0xe2e...e2ee').first()).toBeVisible({ timeout: 15_000 });\n\n    // Click Continue with the empty default amount.\n    await page.getByRole('button', { name: /^Continue$/ }).click();\n\n    // ConnectAwareSubmitButton swaps its content to the first form error.\n    await expect(\n      page.getByRole('button', { name: /Invalid amount/i }),\n    ).toBeVisible({ timeout: 5_000 });\n\n    // No RPC-side eth_sendTransaction should have fired, and the window capture\n    // must be empty.\n    expect(txs).toHaveLength(0);\n    expect(await getCapturedEvmTxs(page)).toHaveLength(0);\n  });\n});\n"
  },
  {
    "path": "tests/e2e-wallet/same-symbol-dedup/cosmos.spec.ts",
    "content": "import { expect, test } from '@playwright/test';\nimport { openE2EApp } from '../helpers/page-setup';\n\nasync function openOriginAndSearch(page: import('@playwright/test').Page, query: string) {\n  await page.getByTestId('token-select-origin').click();\n  await page.getByText('Select Token').waitFor({ state: 'visible', timeout: 30_000 });\n  await page.getByLabel('Search tokens').fill(query);\n}\n\ntest.describe('Cosmos same-symbol dedup', () => {\n  test('celestia TIA vs stride TIA resolve to distinct destination routes', async ({ page }) => {\n    await openE2EApp(page);\n\n    // Origin = TIA on celestia. The published registry exposes\n    // TIA/celestia-{ethereum,eclipsemainnet,solanamainnet,abstract,base}, so\n    // the auto-resolved destination lives on one of those EVM/SVM chains.\n    await openOriginAndSearch(page, 'celestia');\n    await page\n      .getByRole('button', { name: /celestia TIA/i })\n      .first()\n      .click({ timeout: 30_000 });\n    await page.getByText('Select Token').waitFor({ state: 'hidden', timeout: 30_000 });\n\n    const origin = page.getByTestId('token-select-origin');\n    const destination = page.getByTestId('token-select-destination');\n    await expect(origin).toContainText(/Celestia/i);\n    // Destination must not be a cosmos chain — celestia's connections are all\n    // EVM/Sealevel/Abstract. In particular it must not be stride (Stride TIA\n    // would be a different warp route entirely).\n    await expect(destination).not.toContainText(/Stride/i);\n    await expect(destination).not.toContainText(/Celestia/i);\n    const celestiaDestText = await destination.innerText();\n\n    // Origin = TIA on stride. stride-originated TIA routes connect to\n    // eclipsemainnet/forma/celestia — a disjoint destination set from the\n    // celestia-originated side.\n    await openOriginAndSearch(page, 'stride');\n    await page\n      .getByRole('button', { name: /stride TIA/i })\n      .first()\n      .click({ timeout: 30_000 });\n    await page.getByText('Select Token').waitFor({ state: 'hidden', timeout: 30_000 });\n\n    await expect(origin).toContainText(/Stride/i);\n    await expect(origin).not.toContainText(/Celestia/i);\n    await expect\n      .poll(() => destination.innerText(), { timeout: 10_000, intervals: [250] })\n      .not.toBe(celestiaDestText);\n    const strideDestText = await destination.innerText();\n\n    // Same-symbol correctness: the destination flipped because the underlying\n    // warp route differs — NOT because the chain label happened to re-render.\n    // If dedup were broken and stride TIA fell back to the celestia route,\n    // both invocations would leave the destination button identical.\n    expect(strideDestText).not.toBe(celestiaDestText);\n  });\n});\n"
  },
  {
    "path": "tests/e2e-wallet/same-symbol-dedup/evm.spec.ts",
    "content": "import { expect, test } from '@playwright/test';\nimport { MOCK_EVM_ADDRESS } from '../helpers/constants';\nimport { installEvmRpcMock } from '../helpers/evmRpc';\nimport { enterAmount, selectOriginToken } from '../helpers/formFlow';\nimport { openE2EApp } from '../helpers/page-setup';\n\nconst USDC_ARBITRUM = '0xaf88d065e77c8cc2239327c5edb3a432268e5831';\n\ntest.describe('EVM same-symbol dedup', () => {\n  test('selecting Arbitrum USDC resolves the Arbitrum-scoped route (not Ethereum)', async ({\n    page,\n  }) => {\n    await installEvmRpcMock(page, {\n      chainUrlMap: [\n        { chainId: 1, urlMatch: /ethereum\\.|eth\\.drpc|eth-mainnet/i },\n        { chainId: 8453, urlMatch: /base\\.drpc|base\\.org|base-mainnet/i },\n        { chainId: 42161, urlMatch: /arb1\\.arbitrum|arbitrum\\.rpc|arbitrum-mainnet/i },\n      ],\n      erc20: {\n        [`42161:${USDC_ARBITRUM}`]: {\n          decimals: 6,\n          balances: { [MOCK_EVM_ADDRESS.toLowerCase()]: '0x3b9aca00' }, // 1000 USDC\n        },\n      },\n    });\n\n    await openE2EApp(page);\n    await expect(page.getByText('0xe2e...e2ee').first()).toBeVisible({ timeout: 15_000 });\n\n    await selectOriginToken(page, /arbitrum USDC/i);\n    // The origin token field's accessible label includes the chain — must be\n    // Arbitrum, not Ethereum.\n    await expect(page.getByTestId('token-select-origin')).toContainText(/Arbitrum/i);\n    await expect(page.getByTestId('token-select-origin')).not.toContainText(/Ethereum/i);\n\n    await enterAmount(page, '1');\n    await page.getByRole('button', { name: /^Continue$/ }).click();\n\n    // Review panel populating with the Transfer Remote section proves the\n    // route resolved against the Arbitrum-scoped USDC (a failed dedup would\n    // surface a validation error or a different remote token address here).\n    const reviewPanel = page.locator('.transfer-review-panel').first();\n    await expect(reviewPanel).toContainText(/Transfer Remote/i, { timeout: 30_000 });\n    await expect(reviewPanel).toContainText(/1 USDC/);\n    // Remote token must render as a 0x-address (non-empty, not a fallback string).\n    await expect(reviewPanel).toContainText(/0x[0-9a-fA-F]{40}/);\n  });\n});\n"
  },
  {
    "path": "tests/e2e-wallet/same-symbol-dedup/solana.spec.ts",
    "content": "import { expect, test } from '@playwright/test';\nimport { selectDestinationToken, selectOriginToken } from '../helpers/formFlow';\nimport { openE2EApp } from '../helpers/page-setup';\n\ntest.describe('Solana same-symbol dedup', () => {\n  test('Solana USDC vs Eclipse USDC render distinct chain labels in origin field', async ({\n    page,\n  }) => {\n    await openE2EApp(page);\n\n    await selectOriginToken(page, /solanamainnet USDC/i);\n    const origin = page.getByTestId('token-select-origin');\n    await expect(origin).toContainText(/Solana/i);\n    await expect(origin).not.toContainText(/Eclipse/i);\n\n    // Switch origin to Eclipse USDC (same symbol) — origin must flip chain label.\n    await selectOriginToken(page, /eclipsemainnet USDC/i);\n    await expect(origin).toContainText(/Eclipse/i);\n    await expect(origin).not.toContainText(/^Solana/i);\n  });\n\n  test('destination USDC selection on a Solana route is chain-scoped', async ({ page }) => {\n    await openE2EApp(page);\n    await selectOriginToken(page, /solanamainnet USDC/i);\n    await selectDestinationToken(page, /eclipsemainnet USDC/i);\n    await expect(page.getByTestId('token-select-destination')).toContainText(/Eclipse/i);\n  });\n});\n"
  },
  {
    "path": "tests/e2e-wallet/smoke/gate.spec.ts",
    "content": "import { expect, test } from '@playwright/test';\nimport { getE2EState } from '../helpers/captured';\nimport { openE2EApp } from '../helpers/page-setup';\n\ntest.describe('E2E wallet scaffolding smoke', () => {\n  test('initializes __WARP_E2E__ global when ?_e2e=1 is set', async ({ page }) => {\n    await openE2EApp(page);\n    const state = await getE2EState(page);\n    expect(state.readyAt).toBeGreaterThan(0);\n    expect(state.evmTxs).toEqual([]);\n    expect(state.solanaTxs).toEqual([]);\n    expect(state.cosmosTxs).toEqual([]);\n  });\n\n  test('does not initialize the global without the gate', async ({ page }) => {\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n    const state = await page.evaluate(() => window.__WARP_E2E__);\n    expect(state).toBeUndefined();\n  });\n});\n"
  },
  {
    "path": "tests/e2e-wallet/tx-payload/evm.spec.ts",
    "content": "import { expect, test } from '@playwright/test';\nimport { MOCK_EVM_ADDRESS } from '../helpers/constants';\nimport { installEvmRpcMock } from '../helpers/evmRpc';\nimport { enterAmount } from '../helpers/formFlow';\nimport { openE2EApp } from '../helpers/page-setup';\n\nconst USDC_ETHEREUM = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48';\n\n// transferRemote overloads on Hyperlane TokenRouter / HypERC20.\nconst TRANSFER_REMOTE_SELECTORS = [\n  '0x81b4e8b4', // transferRemote(uint32,bytes32,uint256)\n  '0x51debffc', // transferRemote(uint32,bytes32,uint256,bytes,address)\n  '0xb96da154', // transferRemote(uint32,bytes32,uint256,uint256)\n];\ntest.describe('EVM tx payload capture', () => {\n  // Full review + send flow needs extra time for fee resolution and tx confirmation.\n  test.setTimeout(180_000);\n  test('default Ethereum→Base USDC Send emits an on-chain tx with correct chain + selector', async ({\n    page,\n  }) => {\n    const { txs } = await installEvmRpcMock(page, {\n      chainUrlMap: [\n        { chainId: 8453, urlMatch: /base\\.drpc|base\\.org|base-mainnet|base\\.publicnode|base\\.llamarpc|basescan|base\\.blockpi|base\\.meowrpc/i },\n        { chainId: 1, urlMatch: /ethereum\\.|eth\\.drpc|eth-mainnet|llamarpc|cloudflare-eth|ankr.*eth|eth\\.publicnode/i },\n      ],\n      erc20: {\n        [`1:${USDC_ETHEREUM}`]: {\n          decimals: 6,\n          balances: { [MOCK_EVM_ADDRESS.toLowerCase()]: '0x3b9aca00' }, // 1000 USDC\n        },\n        [`8453:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913`]: {\n          decimals: 6,\n          defaultBalance: '0xffffffffffff', // plenty of collateral\n        },\n      },\n      // HypCollateral.wrappedToken() — returns the underlying USDC mint so\n      // the adapter's getBalance goes through our USDC fixture.\n      wrappedTokenByChainId: {\n        1: USDC_ETHEREUM,\n        8453: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913',\n      },\n    });\n\n    await openE2EApp(page);\n    await expect(page.getByText('0xe2e...e2ee').first()).toBeVisible({ timeout: 20_000 });\n\n    await enterAmount(page, '1');\n    await page.getByRole('button', { name: /^Continue$/ }).click();\n\n    // Review panel ready.\n    await expect(page.locator('.transfer-review-panel').first()).toContainText(\n      /Transfer Remote/i,\n      { timeout: 45_000 },\n    );\n\n    const sendButton = page.getByRole('button', { name: /^Send to/i });\n    await sendButton.waitFor({ state: 'visible', timeout: 30_000 });\n    await sendButton.click({ timeout: 30_000 });\n\n    // eth_sendTransaction reaches the RPC mock.\n    await expect\n      .poll(() => txs.length, { timeout: 60_000, intervals: [500] })\n      .toBeGreaterThan(0);\n\n    const captured = txs[txs.length - 1];\n    expect(captured.chainId).toBe(1);\n    expect(captured.to).toMatch(/^0x[0-9a-fA-F]{40}$/);\n    expect(captured.data).toBeDefined();\n    const selector = captured.data!.slice(0, 10).toLowerCase();\n    expect(TRANSFER_REMOTE_SELECTORS).toContain(selector);\n  });\n});\n"
  },
  {
    "path": "tests/embed/basic-rendering.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\n\ntest.describe('Basic Rendering', () => {\n  test('should render transfer form with Send and Receive sections', async ({ page }) => {\n    await page.goto('http://localhost:3000/embed');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    await expect(page.getByText('Send').first()).toBeVisible();\n    await expect(page.getByText('Receive').first()).toBeVisible();\n    await expect(page.locator('input[type=\"number\"]')).toBeVisible();\n    await expect(\n      page.getByRole('button', { name: 'Connect wallet', exact: true }),\n    ).toBeVisible();\n  });\n\n  test('should add embed-mode class to body', async ({ page }) => {\n    await page.goto('http://localhost:3000/embed');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    await expect(page.locator('body')).toHaveClass(/embed-mode/);\n  });\n\n  test('should set embed-mode class before warp context loads', async ({ page }) => {\n    // Navigate and check body class immediately (before transfer form renders)\n    await page.goto('http://localhost:3000/embed', { waitUntil: 'domcontentloaded' });\n    // Poll for embed-mode class — should appear before the transfer form\n    await expect(page.locator('body')).toHaveClass(/embed-mode/, { timeout: 10000 });\n  });\n});\n"
  },
  {
    "path": "tests/embed/csp-headers.spec.ts",
    "content": "// spec: CSP and Security Headers\n// seed: tests/seed.spec.ts\n\nimport { test, expect } from '@playwright/test';\n\ntest.describe('CSP and Security Headers', () => {\n  test('embed route should not have X-Frame-Options header', async ({ page }) => {\n    // Navigate to http://localhost:3000/embed and capture the response\n    const response = await page.goto('http://localhost:3000/embed');\n\n    // Get the response headers and verify X-Frame-Options is NOT present\n    const headers = response!.headers();\n    expect(headers['x-frame-options']).toBeUndefined();\n  });\n\n  test('embed route CSP should have frame-ancestors *', async ({ page }) => {\n    // Navigate to http://localhost:3000/embed and capture the response\n    const response = await page.goto('http://localhost:3000/embed');\n\n    // Get the Content-Security-Policy header value\n    const headers = response!.headers();\n    const csp = headers['content-security-policy'];\n\n    // Verify it contains 'frame-ancestors *'\n    expect(csp).toContain('frame-ancestors *');\n\n    // Verify it does NOT contain \"frame-ancestors 'none'\"\n    expect(csp).not.toContain(\"frame-ancestors 'none'\");\n  });\n\n  test('main route should have X-Frame-Options DENY', async ({ page }) => {\n    // Navigate to http://localhost:3000/ and capture the response\n    const response = await page.goto('http://localhost:3000/');\n\n    // Get the X-Frame-Options header and verify it equals 'DENY'\n    const headers = response!.headers();\n    expect(headers['x-frame-options']).toBe('DENY');\n  });\n\n  test('main route CSP should have frame-ancestors none', async ({ page }) => {\n    // Navigate to http://localhost:3000/ and capture the response\n    const response = await page.goto('http://localhost:3000/');\n\n    // Get Content-Security-Policy header and verify it contains \"frame-ancestors 'none'\"\n    const headers = response!.headers();\n    const csp = headers['content-security-policy'];\n    expect(csp).toContain(\"frame-ancestors 'none'\");\n  });\n});\n"
  },
  {
    "path": "tests/embed/no-chrome.spec.ts",
    "content": "// spec: No Chrome\n// seed: tests/seed.spec.ts\n\nimport { test, expect } from '@playwright/test';\n\ntest.describe('No Chrome', () => {\n  test('should not show header navigation on embed page', async ({ page }) => {\n    // Navigate to the embed page\n    await page.goto('http://localhost:3000/embed');\n\n    // Wait for Send text to confirm page loaded\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // Verify no header element exists on the embed page\n    await expect(page.locator('header')).toHaveCount(0);\n  });\n\n  test('should not show footer on embed page', async ({ page }) => {\n    // Navigate to the embed page\n    await page.goto('http://localhost:3000/embed');\n\n    // Wait for Send text to confirm page loaded\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // Verify no footer element exists on the embed page\n    await expect(page.locator('footer')).toHaveCount(0);\n  });\n\n  test('main app still shows header and footer (contrast)', async ({ page }) => {\n    // Navigate to the main app page\n    await page.goto('http://localhost:3000/');\n\n    // Wait for page to load\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // Verify the main app has a header element\n    await expect(page.locator('header')).toBeVisible();\n\n    // Verify the main app has a footer element\n    await expect(page.locator('footer')).toBeVisible();\n  });\n});\n"
  },
  {
    "path": "tests/embed/routes-param.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport { resolveTestRoutes } from '../helpers/constants';\n\nconst { primary, secondary, skip } = resolveTestRoutes();\n\ntest.describe('Routes Parameter', () => {\n  test('should load embed page with a valid routes param', async ({ page }) => {\n    test.skip(skip, 'warpRouteWhitelist is empty — no valid routes to test');\n\n    await page.goto(`http://localhost:3000/embed?routes=${primary}`);\n    await page.getByText('Send').first().waitFor({ state: 'visible', timeout: 15000 });\n    await expect(page.getByText('Send').first()).toBeVisible();\n  });\n\n  test('should load embed page with empty routes param gracefully', async ({ page }) => {\n    await page.goto('http://localhost:3000/embed?routes=');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n    await expect(page.getByText('Send').first()).toBeVisible();\n  });\n\n  test('should load embed page with multiple routes param', async ({ page }) => {\n    test.skip(skip, 'warpRouteWhitelist is empty — no valid routes to test');\n\n    await page.goto(`http://localhost:3000/embed?routes=${primary},${secondary}`);\n    await page.getByText('Send').first().waitFor({ state: 'visible', timeout: 15000 });\n    await expect(page.getByText('Send').first()).toBeVisible();\n  });\n\n  test('should fail to load with fake/nonexistent route', async ({ page }) => {\n    await page.goto('http://localhost:3000/embed?routes=FAKE/nonexistent-route');\n    // Nonexistent routes cause the app to error — \"Send\" should never become visible\n    await expect(page.getByText('Send').first()).toBeHidden({ timeout: 10000 });\n  });\n\n  test('should handle mix of real and fake routes', async ({ page }) => {\n    test.skip(skip, 'warpRouteWhitelist is empty — no valid routes to test');\n\n    await page.goto(`http://localhost:3000/embed?routes=${primary},FAKE/does-not-exist`);\n    await page.getByText('Send').first().waitFor({ state: 'visible', timeout: 15000 });\n    await expect(page.getByText('Send').first()).toBeVisible();\n  });\n});\n"
  },
  {
    "path": "tests/embed/theme.spec.ts",
    "content": "// spec: Theme Tests\n// seed: tests/seed.spec.ts\n\nimport { test, expect } from '@playwright/test';\n\ntest.describe('Theme Tests', () => {\n  test('should apply default light theme CSS variables', async ({ page }) => {\n    // Navigate to http://localhost:3000/embed\n    await page.goto('http://localhost:3000/embed');\n\n    // Wait for text \"Send\" to be visible\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // Evaluate JS: getComputedStyle(document.body).getPropertyValue('--embed-accent').trim()\n    const accent = await page.evaluate(() =>\n      getComputedStyle(document.body).getPropertyValue('--embed-accent').trim(),\n    );\n\n    // Should equal '#9A0DFF' (case-insensitive)\n    expect(accent.toLowerCase()).toBe('#9a0dff');\n  });\n\n  test('should apply custom accent color from URL param', async ({ page }) => {\n    // Navigate to http://localhost:3000/embed?accent=3b82f6\n    await page.goto('http://localhost:3000/embed?accent=3b82f6');\n\n    // Wait for text \"Send\" to be visible\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // Evaluate: getComputedStyle(document.body).getPropertyValue('--embed-accent').trim()\n    const accent = await page.evaluate(() =>\n      getComputedStyle(document.body).getPropertyValue('--embed-accent').trim(),\n    );\n\n    // Should equal '#3b82f6'\n    expect(accent).toBe('#3b82f6');\n  });\n\n  test('should apply dark mode defaults', async ({ page }) => {\n    // Navigate to http://localhost:3000/embed?mode=dark\n    await page.goto('http://localhost:3000/embed?mode=dark');\n\n    // Wait for text \"Send\" to be visible\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // Evaluate: getComputedStyle(document.body).getPropertyValue('--embed-bg').trim()\n    const bg = await page.evaluate(() =>\n      getComputedStyle(document.body).getPropertyValue('--embed-bg').trim(),\n    );\n\n    // Should equal '#1a1a2e'\n    expect(bg).toBe('#1a1a2e');\n\n    // Evaluate: getComputedStyle(document.body).getPropertyValue('--embed-text').trim()\n    const text = await page.evaluate(() =>\n      getComputedStyle(document.body).getPropertyValue('--embed-text').trim(),\n    );\n\n    // Should equal '#e0e0e0'\n    expect(text).toBe('#e0e0e0');\n  });\n\n  test('should fall back to default for invalid hex param', async ({ page }) => {\n    // Navigate to http://localhost:3000/embed?accent=not-a-color\n    await page.goto('http://localhost:3000/embed?accent=not-a-color');\n\n    // Wait for text \"Send\" to be visible\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // Evaluate: getComputedStyle(document.body).getPropertyValue('--embed-accent').trim()\n    const accent = await page.evaluate(() =>\n      getComputedStyle(document.body).getPropertyValue('--embed-accent').trim(),\n    );\n\n    // Should equal '#9A0DFF' (the default, since 'not-a-color' fails hex validation)\n    expect(accent.toLowerCase()).toBe('#9a0dff');\n  });\n\n  test('should apply custom error color', async ({ page }) => {\n    // Navigate to http://localhost:3000/embed?error=ff6600\n    await page.goto('http://localhost:3000/embed?error=ff6600');\n\n    // Wait for text \"Send\" to be visible\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // Evaluate: getComputedStyle(document.body).getPropertyValue('--embed-error').trim()\n    const error = await page.evaluate(() =>\n      getComputedStyle(document.body).getPropertyValue('--embed-error').trim(),\n    );\n\n    // Should equal '#ff6600'\n    expect(error).toBe('#ff6600');\n  });\n});\n"
  },
  {
    "path": "tests/helpers/constants.ts",
    "content": "import { warpRouteWhitelist } from '../../src/consts/warpRouteWhitelist';\n\n// Picks known-good warp route IDs for embed ?routes= tests.\n// Prefers whitelist entries on prod branches; falls back to registry routes on main.\nexport function resolveTestRoutes(): { primary: string; secondary: string; skip: boolean } {\n  if (warpRouteWhitelist === null) {\n    return { primary: 'USDC/aleo', secondary: 'ETH/aleo', skip: false };\n  }\n  if (warpRouteWhitelist.length === 0) {\n    return { primary: '', secondary: '', skip: true };\n  }\n  return {\n    primary: warpRouteWhitelist[0],\n    secondary: warpRouteWhitelist[1] ?? warpRouteWhitelist[0],\n    skip: false,\n  };\n}\n"
  },
  {
    "path": "tests/helpers/locators.ts",
    "content": "import type { Page, Locator } from '@playwright/test';\n\nexport function getOriginTokenButton(page: Page): Locator {\n  return page.getByTestId('token-select-origin');\n}\n\nexport function getDestinationTokenButton(page: Page): Locator {\n  return page.getByTestId('token-select-destination');\n}\n\nexport function getTipCard(page: Page): Locator {\n  return page.getByTestId('tip-card');\n}\n"
  },
  {
    "path": "tests/page-load/default-tokens.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport { config } from '../../src/consts/config';\nimport { getOriginTokenButton, getDestinationTokenButton } from '../helpers/locators';\n\ntest.describe('Page Load - Default Tokens', () => {\n  test('should show config default origin and destination tokens if defined', async ({ page }) => {\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    const originButton = getOriginTokenButton(page);\n    await expect(originButton).toBeVisible();\n    if (config.defaultOriginToken) {\n      const [originChain, originSymbol] = config.defaultOriginToken.split('-');\n      await expect(originButton).toHaveAttribute('data-chain', originChain);\n      await expect(originButton).toContainText(originSymbol);\n    }\n\n    const destButton = getDestinationTokenButton(page);\n    await expect(destButton).toBeVisible();\n    if (config.defaultDestinationToken) {\n      const [destChain, destSymbol] = config.defaultDestinationToken.split('-');\n      await expect(destButton).toHaveAttribute('data-chain', destChain);\n      await expect(destButton).toContainText(destSymbol);\n    }\n  });\n});\n"
  },
  {
    "path": "tests/page-load/header-footer.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\n\ntest.describe('Page Load - Header and Footer', () => {\n  test('should display header and footer', async ({ page }) => {\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // Header\n    await expect(page.getByRole('link', { name: 'Homepage' })).toBeVisible();\n    await expect(page.getByRole('banner').getByRole('button', { name: 'Connect wallet' })).toBeVisible();\n\n    // Footer links\n    const footer = page.getByRole('contentinfo');\n    await expect(footer.getByRole('link', { name: 'Stake' })).toBeVisible();\n    await expect(footer.getByRole('link', { name: 'X.com' })).toBeVisible();\n    await expect(footer.getByRole('link', { name: 'Hyperlane' })).toBeVisible();\n    await expect(footer.getByRole('link', { name: 'Support' })).toBeVisible();\n    await expect(footer.getByRole('link', { name: 'Docs' })).toBeVisible();\n    await expect(footer.getByRole('link', { name: 'Github' })).toBeVisible();\n  });\n});\n"
  },
  {
    "path": "tests/page-load/query-param-override.spec.ts",
    "content": "// spec: specs/plan.md\n// seed: tests/page-load/transfer-form-visible.spec.ts\n\nimport { test, expect } from '@playwright/test';\nimport { config } from '../../src/consts/config';\nimport { getOriginTokenButton, getDestinationTokenButton } from '../helpers/locators';\n\ntest.describe('Page Load - Query Param Token Override', () => {\n  test('should use query params to set origin and destination tokens', async ({ page }) => {\n    // 1. Navigate to app with query params to override origin and destination tokens to ETH\n    await page.goto(\n      'http://localhost:3000?origin=base&originToken=ETH&destination=ethereum&destinationToken=ETH',\n    );\n\n    // 2. Wait for 'Send' text visible\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // 3. Verify origin: page.getByRole('button', { name: 'base ETH Base' })\n    await expect(page.getByRole('button', { name: 'base ETH Base' })).toBeVisible();\n\n    // 4. Verify destination: page.getByRole('button', { name: 'ethereum ETH Ethereum' })\n    await expect(page.getByRole('button', { name: 'ethereum ETH Ethereum' })).toBeVisible();\n\n    // 5. Verify page.url() includes 'origin=base'\n    await expect(page).toHaveURL(/origin=base/);\n  });\n\n  test('should fall back to config defaults with invalid query params', async ({ page }) => {\n    await page.goto(\n      'http://localhost:3000?origin=nonexistent&originToken=FAKE&destination=nonexistent&destinationToken=FAKE',\n    );\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // Should fall back to config defaults\n    const originButton = getOriginTokenButton(page);\n    await expect(originButton).toBeVisible();\n    if (config.defaultOriginToken) {\n      const [originChain, originSymbol] = config.defaultOriginToken.split('-');\n      await expect(originButton).toHaveAttribute('data-chain', originChain);\n      await expect(originButton).toContainText(originSymbol);\n    }\n\n    const destButton = getDestinationTokenButton(page);\n    await expect(destButton).toBeVisible();\n    if (config.defaultDestinationToken) {\n      const [destChain, destSymbol] = config.defaultDestinationToken.split('-');\n      await expect(destButton).toHaveAttribute('data-chain', destChain);\n      await expect(destButton).toContainText(destSymbol);\n    }\n  });\n\n  test('should handle partial query params (origin only)', async ({ page }) => {\n    // 1. Navigate with partial query params (origin only)\n    await page.goto('http://localhost:3000?origin=base&originToken=ETH');\n\n    // 2. Wait for 'Send' text visible\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // 3. Verify origin: page.getByRole('button', { name: 'base ETH Base' })\n    await expect(page.getByRole('button', { name: 'base ETH Base' })).toBeVisible();\n\n    // 4. Verify destination is visible: page.getByTestId('token-select-destination')\n    await expect(page.getByTestId('token-select-destination')).toBeVisible();\n  });\n});\n"
  },
  {
    "path": "tests/page-load/tip-card.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport { config } from '../../src/consts/config';\nimport { getTipCard } from '../helpers/locators';\n\ntest.describe('Page Load - Tip Card', () => {\n  test('should display tip card', async ({ page }) => {\n    test.skip(!config.showTipBox, 'Tip card disabled in config');\n\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // Tip card container visible — content/copy varies per branch, so assert structure only\n    const tipCard = getTipCard(page);\n    await expect(tipCard).toBeVisible();\n\n    // Close tip card\n    await page.getByRole('button', { name: 'Hide tip' }).click();\n\n    // Tip card should disappear\n    await expect(tipCard).not.toBeVisible();\n  });\n});\n"
  },
  {
    "path": "tests/page-load/transfer-form-visible.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport { APP_NAME } from '../../src/consts/app';\nimport { config } from '../../src/consts/config';\nimport { getDestinationTokenButton, getOriginTokenButton } from '../helpers/locators';\n\ntest.describe('Page Load - Transfer Form', () => {\n  test('should display the transfer form on page load', async ({ page }) => {\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // Page title\n    await expect(page).toHaveTitle(APP_NAME);\n\n    // Send and Receive sections visible\n    await expect(page.getByText('Send').first()).toBeVisible();\n    await expect(page.getByText('Receive').first()).toBeVisible();\n\n    // Connect wallet button visible\n    await expect(page.getByRole('button', { name: 'Connect wallet' }).first()).toBeVisible();\n\n    // Send section: default origin token (only assert when configured; otherwise the app\n    // falls back to featuredTokens / first routable token — covered elsewhere)\n    const originButton = getOriginTokenButton(page);\n    await expect(originButton).toBeVisible();\n    if (config.defaultOriginToken) {\n      const [originChain, originSymbol] = config.defaultOriginToken.split('-');\n      await expect(originButton).toHaveAttribute('data-chain', originChain);\n      await expect(originButton).toContainText(originSymbol);\n    }\n\n    // Amount input visible\n    const amountInput = page.getByRole('spinbutton');\n    await expect(amountInput).toBeVisible();\n\n    // Max button visible but disabled\n    const maxButton = page.getByRole('button', { name: 'Max' });\n    await expect(maxButton).toBeVisible();\n    await expect(maxButton).toBeDisabled();\n\n    // USD price and balance\n    await expect(page.getByText('$0.00')).toBeVisible();\n    await expect(page.getByText('Balance: 0.00', { exact: true })).toBeVisible();\n\n    // Receive section: default destination token\n    const destButton = getDestinationTokenButton(page);\n    await expect(destButton).toBeVisible();\n    if (config.defaultDestinationToken) {\n      const [destChain, destSymbol] = config.defaultDestinationToken.split('-');\n      await expect(destButton).toHaveAttribute('data-chain', destChain);\n      await expect(destButton).toContainText(destSymbol);\n    }\n    await expect(page.getByText('Remote Balance: 0.00')).toBeVisible();\n  });\n});\n"
  },
  {
    "path": "tests/sidebar/sidebar-content.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\n\ntest.describe('Sidebar', () => {\n  test('should show sidebar with wallet and history sections', async ({ page }) => {\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // Connected Wallets section\n    await expect(page.getByText('Connected Wallets')).toBeVisible();\n\n    // Transfer History section\n    await expect(page.getByText('Transfer History')).toBeVisible();\n    await expect(page.getByText('No transfers yet')).toBeVisible();\n\n    // Sidebar buttons\n    await expect(page.getByRole('button', { name: 'Connect wallet' }).nth(1)).toBeVisible();\n    await expect(page.getByRole('button', { name: 'Disconnect all wallets' })).toBeVisible();\n  });\n});\n"
  },
  {
    "path": "tests/token-selection/filter-chains.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport { getOriginTokenButton } from '../helpers/locators';\n\ntest.describe('Token Selection - Filter Chains', () => {\n  test('should filter chains by search', async ({ page }) => {\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // Open origin token selector\n    await getOriginTokenButton(page).click();\n    await expect(page.getByText('Select Token')).toBeVisible();\n\n    // Type in chain search\n    await page.getByPlaceholder('Search Chains').fill('Base');\n\n    // Should show Base in the chain list\n    await expect(page.getByRole('button', { name: 'base Base', exact: true })).toBeVisible();\n\n    // Click on Base chain\n    await page.getByRole('button', { name: 'base Base', exact: true }).click();\n\n    // Token list should filter to show Base tokens\n    await expect(page.getByText('Base').first()).toBeVisible();\n  });\n});\n"
  },
  {
    "path": "tests/token-selection/open-close-modal.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport { getOriginTokenButton } from '../helpers/locators';\n\ntest.describe('Token Selection - Open and Close Modal', () => {\n  test('should open and close token selection modal', async ({ page }) => {\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // Open origin token selector\n    await getOriginTokenButton(page).click();\n\n    // Modal should open\n    await expect(page.getByText('Select Token')).toBeVisible();\n    await expect(page.getByText('Chain Selection')).toBeVisible();\n    await expect(page.getByText('All Chains')).toBeVisible();\n    await expect(page.getByText('Token Selection')).toBeVisible();\n    await expect(page.getByPlaceholder('Search Chains')).toBeVisible();\n    await expect(page.getByPlaceholder('Search Name, Symbol, or Contract Address')).toBeVisible();\n\n    // Close with Escape\n    await page.keyboard.press('Escape');\n\n    // Modal should close, transfer form visible again\n    await expect(page.getByText('Select Token')).not.toBeVisible();\n    await expect(page.getByText('Send').first()).toBeVisible();\n  });\n});\n"
  },
  {
    "path": "tests/token-selection/search-tokens.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport { getOriginTokenButton } from '../helpers/locators';\n\ntest.describe('Token Selection - Search Tokens', () => {\n  test('should search tokens by name', async ({ page }) => {\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // Open origin token selector\n    await getOriginTokenButton(page).click();\n    await expect(page.getByText('Select Token')).toBeVisible();\n\n    // Type in token search\n    await page.getByPlaceholder('Search Name, Symbol, or Contract Address').fill('ETH');\n\n    // Should show ETH tokens in the list\n    await expect(page.getByText('ETH').first()).toBeVisible();\n  });\n});\n"
  },
  {
    "path": "tests/token-selection/select-destination-token.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport { getDestinationTokenButton } from '../helpers/locators';\n\ntest.describe('Token Selection - Select Destination Token', () => {\n  test('should select a different destination token', async ({ page }) => {\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // Open destination token selector\n    await getDestinationTokenButton(page).click();\n    await expect(page.getByText('Select Token')).toBeVisible();\n\n    // Click on USDC Arbitrum token\n    await page.getByRole('button', { name: 'arbitrum USDC Arbitrum USD Coin' }).first().click();\n\n    // Modal should close\n    await expect(page.getByText('Select Token')).not.toBeVisible();\n\n    // Destination token should now show USDC on Arbitrum\n    await expect(page.getByRole('button', { name: /USDC Arbitrum/i })).toBeVisible();\n  });\n});\n"
  },
  {
    "path": "tests/token-selection/select-origin-token.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport { getOriginTokenButton } from '../helpers/locators';\n\ntest.describe('Token Selection - Select Origin Token', () => {\n  test('should select a different origin token', async ({ page }) => {\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // Open origin token selector\n    await getOriginTokenButton(page).click();\n    await expect(page.getByText('Select Token')).toBeVisible();\n\n    // Click on USDC Arbitrum token\n    await page.getByRole('button', { name: 'arbitrum USDC Arbitrum USD Coin' }).first().click();\n\n    // Modal should close\n    await expect(page.getByText('Select Token')).not.toBeVisible();\n\n    // Origin token should now show USDC on Arbitrum\n    await expect(page.getByRole('button', { name: /USDC Arbitrum/i })).toBeVisible();\n  });\n});\n"
  },
  {
    "path": "tests/transfer-form/connect-wallet-prompt.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\n\ntest.describe('Transfer Form - Connect Wallet Prompt', () => {\n  test('should show connect wallet button when not connected', async ({ page }) => {\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // Main connect wallet submit button at bottom of form\n    const connectButton = page.getByRole('main').getByRole('button', { name: 'Connect wallet', exact: true });\n    await expect(connectButton).toBeVisible();\n    await expect(connectButton).toBeEnabled();\n  });\n});\n"
  },
  {
    "path": "tests/transfer-form/enter-amount.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\n\ntest.describe('Transfer Form - Enter Amount', () => {\n  test('should enter transfer amount', async ({ page }) => {\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // Click and type in amount input\n    const amountInput = page.getByRole('spinbutton');\n    await amountInput.click();\n    await expect(amountInput).toBeFocused();\n\n    await amountInput.fill('100');\n    await expect(amountInput).toHaveValue('100');\n  });\n});\n"
  },
  {
    "path": "tests/transfer-form/swap-tokens.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport { getOriginTokenButton, getDestinationTokenButton } from '../helpers/locators';\n\ntest.describe('Transfer Form - Swap Tokens', () => {\n  test('should swap origin and destination tokens', async ({ page }) => {\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // Record the initial origin and destination token labels before swap\n    const originBtn = getOriginTokenButton(page);\n    const destBtn = getDestinationTokenButton(page);\n\n    const initialOriginName = await originBtn.textContent();\n    const initialDestName = await destBtn.textContent();\n\n    // Ensure both buttons are visible\n    await expect(originBtn).toBeVisible();\n    await expect(destBtn).toBeVisible();\n\n    // Click swap button (between Send and Receive sections)\n    await page.locator('div.-my-3 > button').click();\n\n    // After swap: origin and destination should have exchanged their tokens\n    await expect(originBtn).toHaveText(initialDestName!);\n    await expect(destBtn).toHaveText(initialOriginName!);\n  });\n});\n"
  },
  {
    "path": "tests/wallet-connect/evm-wallet-modal.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\n\ntest.describe('Wallet Connect - EVM', () => {\n  test('should show RainbowKit modal when connecting wallet for EVM chain', async ({ page }) => {\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // Default origin is Ethereum (EVM) - click Connect Wallet in Send section\n    await page.getByRole('button', { name: 'Connect Wallet' }).nth(1).click();\n\n    // RainbowKit modal should appear\n    const dialog = page.getByRole('dialog', { name: 'Connect a Wallet' });\n    await expect(dialog).toBeVisible();\n    await expect(dialog.getByRole('heading', { name: 'Connect a Wallet' })).toBeVisible();\n\n    // Should show EVM wallet options\n    await expect(dialog.getByRole('button', { name: 'MetaMask' })).toBeVisible();\n    await expect(dialog.getByRole('button', { name: 'WalletConnect' })).toBeVisible();\n    await expect(dialog.getByRole('button', { name: 'Coinbase Wallet' })).toBeVisible();\n\n    // Close the modal\n    await dialog.getByRole('button', { name: 'Close' }).click();\n    await expect(dialog).not.toBeVisible();\n  });\n\n  test('should show RainbowKit modal for BSC destination chain', async ({ page }) => {\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n\n    // Destination is BSC (EVM) - click Connect Wallet dropdown in Receive section\n    // The destination has a dropdown menu, click it to show connect option\n    const receiveSection = page.getByText('Receive').first().locator('../../..');\n    await receiveSection.getByText('Connect Wallet').click();\n\n    // Dropdown menu should appear with \"Connect wallet\" option\n    await expect(page.getByRole('button', { name: 'Connect wallet' }).last()).toBeVisible();\n  });\n});\n"
  },
  {
    "path": "tests/wallet-connect/protocol-wallet-modals.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport { getOriginTokenButton } from '../helpers/locators';\n\n// Helper to select a token from a given chain via the token selector\nasync function selectToken(\n  page: import('@playwright/test').Page,\n  tokenSearch: string,\n  tokenButtonName: RegExp,\n) {\n  // Open origin token selector\n  await getOriginTokenButton(page).click();\n  await page.getByText('Select Token').waitFor({ state: 'visible' });\n\n  // Search and select the token\n  const searchBox = page.getByPlaceholder(\n    'Search Name, Symbol, or Contract Address',\n  );\n  await searchBox.fill(tokenSearch);\n  await page\n    .getByRole('button', { name: tokenButtonName })\n    .first()\n    .waitFor({ state: 'visible' });\n  await page.getByRole('button', { name: tokenButtonName }).first().click();\n  await expect(page.getByText('Select Token')).not.toBeVisible();\n}\n\ntest.describe('Wallet Connect - Protocol Modals', () => {\n  test.beforeEach(async ({ page }) => {\n    await page.goto('http://localhost:3000');\n    await page.getByText('Send').first().waitFor({ state: 'visible' });\n  });\n\n  test('EVM: should show RainbowKit modal for Ethereum', async ({ page }) => {\n    // Default origin is Ethereum (EVM) - click the main Connect wallet button\n    await page\n      .getByRole('main')\n      .getByRole('button', { name: 'Connect wallet', exact: true })\n      .click();\n\n    const dialog = page.getByRole('dialog', { name: 'Connect a Wallet' });\n    await expect(dialog).toBeVisible();\n    await expect(\n      dialog.getByRole('heading', { name: 'Connect a Wallet' }),\n    ).toBeVisible();\n    await expect(\n      dialog.getByRole('button', { name: 'MetaMask' }),\n    ).toBeVisible();\n    await expect(\n      dialog.getByRole('button', { name: 'WalletConnect' }),\n    ).toBeVisible();\n    await expect(\n      dialog.getByRole('button', { name: 'Coinbase Wallet' }),\n    ).toBeVisible();\n\n    // Close\n    await dialog.getByRole('button', { name: 'Close' }).click();\n    await expect(dialog).not.toBeVisible();\n  });\n\n  test('Sealevel: should show Solana wallet modal', async ({ page }) => {\n    await selectToken(page, 'solana', /solanamainnet SOL Solana/);\n\n    await page\n      .getByRole('main')\n      .getByRole('button', { name: 'Connect wallet', exact: true })\n      .click();\n\n    // Solana wallet modal\n    const dialog = page.getByRole('dialog');\n    await expect(dialog).toBeVisible();\n    await expect(\n      dialog.getByText('Connect a wallet on Solana to continue'),\n    ).toBeVisible();\n    await expect(\n      dialog.getByRole('button', { name: 'Solflare' }),\n    ).toBeVisible();\n\n    await page.keyboard.press('Escape');\n    await expect(dialog).not.toBeVisible();\n  });\n\n  test('Cosmos: should show Cosmos wallet modal', async ({\n    page,\n  }) => {\n    await selectToken(page, 'TIA', /celestia TIA Celestia/);\n\n    await page\n      .getByRole('main')\n      .getByRole('button', { name: 'Connect wallet', exact: true })\n      .click();\n\n    // Cosmos wallet modal - \"Select your wallet\"\n    const dialog = page.getByRole('dialog', { name: 'Select your wallet' });\n    await expect(dialog).toBeVisible();\n    await expect(dialog.getByText('Select your wallet')).toBeVisible();\n    await expect(\n      dialog.getByRole('button', { name: 'Keplr Keplr' }),\n    ).toBeVisible();\n    await expect(\n      dialog.getByRole('button', { name: 'Cosmostation Cosmostation' }),\n    ).toBeVisible();\n    await expect(\n      dialog.getByRole('button', { name: 'Leap Leap' }),\n    ).toBeVisible();\n\n    await page.keyboard.press('Escape');\n    await expect(dialog).not.toBeVisible();\n  });\n\n  test('Aleo: should show Aleo wallet modal', async ({ page }) => {\n    await selectToken(page, 'aleo', /aleo ALEO Aleo Aleo/);\n\n    await page\n      .getByRole('main')\n      .getByRole('button', { name: 'Connect wallet', exact: true })\n      .click();\n\n    // Aleo wallet modal - \"Connect with an Aleo Wallet\"\n    await expect(\n      page.getByRole('heading', { name: 'Connect with an Aleo Wallet' }),\n    ).toBeVisible();\n    await expect(\n      page.getByRole('button', { name: 'Shield Wallet Shield Wallet' }),\n    ).toBeVisible();\n\n    // Close\n    await page.getByRole('button', { name: 'Close' }).click();\n    await expect(\n      page.getByRole('heading', { name: 'Connect with an Aleo Wallet' }),\n    ).not.toBeVisible();\n  });\n\n  test('Radix: should show Radix wallet overlay', async ({ page }) => {\n    await selectToken(page, 'radix', /radix hSOL Radix/);\n\n    await page\n      .getByRole('main')\n      .getByRole('button', { name: 'Connect wallet', exact: true })\n      .click();\n\n    // Radix uses a custom overlay, not a dialog role\n    await expect(page.getByText('Login Request Pending')).toBeVisible();\n    await expect(\n      page.getByText('Open Your Radix Wallet App to complete the request'),\n    ).toBeVisible();\n\n    // Cancel the request\n    await page.getByRole('button', { name: 'Cancel' }).click();\n    await expect(page.getByText('Login Request Pending')).not.toBeVisible();\n  });\n\n  test('Starknet: should show Starknet wallet modal', async ({ page }) => {\n    await selectToken(page, 'starknet', /starknet SOL Starknet/);\n\n    await page\n      .getByRole('main')\n      .getByRole('button', { name: 'Connect wallet', exact: true })\n      .click();\n\n    // Starknet modal\n    const dialog = page.getByRole('dialog');\n    await expect(dialog).toBeVisible();\n    await expect(\n      dialog.getByRole('heading', { name: 'Connect to' }),\n    ).toBeVisible();\n\n    // Wallet options shown as install links\n    await expect(dialog.getByText('Install Braavos')).toBeVisible();\n    await expect(\n      dialog.getByText('Install Ready Wallet (formerly Argent)'),\n    ).toBeVisible();\n\n    // Close\n    await dialog.getByRole('button', { name: 'Close' }).click();\n    await expect(dialog).not.toBeVisible();\n  });\n});\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"allowJs\": true,\n    \"allowImportingTsExtensions\": true,\n    \"declaration\": true,\n    \"declarationMap\": true,\n    \"esModuleInterop\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"incremental\": true,\n    \"isolatedModules\": true,\n    \"jsx\": \"react-jsx\",\n    \"lib\": [\n      \"dom\",\n      \"dom.iterable\",\n      \"esnext\"\n    ],\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"bundler\",\n    \"noEmit\": true,\n    \"noEmitOnError\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noImplicitAny\": false,\n    \"noImplicitReturns\": true,\n    \"noUnusedLocals\": true,\n    \"preserveSymlinks\": true,\n    \"preserveWatchOutput\": true,\n    \"resolveJsonModule\": true,\n    \"skipLibCheck\": true,\n    \"sourceMap\": true,\n    \"strict\": true,\n    \"target\": \"ES2022\"\n  },\n  \"exclude\": [\n    \"node_modules\",\n    \"dist\"\n  ],\n  \"include\": [\n    \"next-env.d.ts\",\n    \"./src/\"\n  ],\n  \"files\": [\n    \"./src/global.d.ts\"\n  ],\n  \"ts-node\": {\n    \"files\": true,\n    \"compilerOptions\": {\n      \"module\": \"commonjs\"\n    }\n  }\n}\n"
  },
  {
    "path": "vitest.config.mts",
    "content": "import tsconfigPaths from 'vite-tsconfig-paths';\nimport { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n  plugins: [tsconfigPaths()],\n  assetsInclude: ['**/*.yaml'],\n  test: {\n    exclude: ['tests/**', 'node_modules/**'],\n  },\n});\n"
  }
]