diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000000..d8e6d36302a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,88 @@ +# Git +.git +.gitignore +.github + +# Documentation +*.md +!README.md +docs/ + +# Dependencies +node_modules +.pnp +.pnp.js + +# Testing +coverage +.nyc_output +*.test.ts +*.test.tsx +*.spec.ts +*.spec.tsx +**/__tests__/** +**/__test__/** +**/*.test.* +**/*.spec.* +**/test-utils/** +**/e2e/** +**/playwright/** +**/*.stories.ts +**/*.stories.tsx +**/*.stories.js +**/*.stories.jsx + +# Build outputs +dist +build +.next +out +.nx + +# Environment files +.env +.env.local +.env.development +.env.test +.env.production +.env*.local + +# IDE +.vscode +.idea +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Misc +.cache +.temp +tmp +*.tmp + +# Docker +Dockerfile* +docker-compose*.yml +.dockerignore + +# CI/CD +.github/workflows + +# Mobile specific (not needed for web build) +apps/mobile/ +apps/extension/ + +# Scripts +scripts/ diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml new file mode 100644 index 00000000000..dd7bb46c315 --- /dev/null +++ b/.github/workflows/docker-build-push.yml @@ -0,0 +1,92 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - main + tags: + - 'v*' + pull_request: + branches: + - main + workflow_dispatch: + inputs: + tag: + description: 'Docker image tag' + required: false + default: 'latest' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: 🔒 Checkout code + uses: actions/checkout@v4 + + - name: 🏗️ Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver-opts: | + image=moby/buildkit:latest + network=host + + - name: 🔐 Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 🏷️ Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha,prefix={{branch}}- + type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=${{ github.event.inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag != '' }} + + - name: 🧷 Get version + id: version + uses: juliangruber/read-file-action@v1 + with: + path: VERSION + default: 'dev' + + - name: 🏗️ Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + NODE_ENV=production + BUILD_NUM=${{ github.run_number }} + CLOUDFLARE_ENV=production + platforms: linux/amd64,linux/arm64 + + - name: 📝 Output image info + if: github.event_name != 'pull_request' + run: | + echo "✅ Docker image built and pushed successfully!" + echo "📦 Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" + echo "🏷️ Tags: ${{ steps.meta.outputs.tags }}" + echo "📌 Version: ${{ steps.version.outputs.content }}" diff --git a/.github/workflows/docker-deploy.yml b/.github/workflows/docker-deploy.yml new file mode 100644 index 00000000000..5f330081e88 --- /dev/null +++ b/.github/workflows/docker-deploy.yml @@ -0,0 +1,74 @@ +name: Deploy Docker Image + +on: + workflow_run: + workflows: ["Build and Push Docker Image"] + types: + - completed + workflow_dispatch: + inputs: + image_tag: + description: 'Docker image tag to deploy' + required: true + default: 'latest' + environment: + description: 'Deployment environment' + required: true + type: choice + options: + - staging + - production + default: 'staging' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + deploy: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} + environment: ${{ github.event.inputs.environment || 'staging' }} + permissions: + contents: read + packages: read + + steps: + - name: 🔒 Checkout code + uses: actions/checkout@v4 + + - name: 🔐 Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 🏷️ Set image tag + id: image + run: | + if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + echo "tag=${{ github.event.inputs.image_tag }}" >> $GITHUB_OUTPUT + else + echo "tag=latest" >> $GITHUB_OUTPUT + fi + + - name: 🚀 Deploy to server + run: | + echo "🚀 Deploying ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.image.outputs.tag }}" + echo "📦 Environment: ${{ github.event.inputs.environment || 'staging' }}" + # TODO: 添加实际的部署脚本 + # 例如: + # - SSH 到服务器 + # - 拉取新镜像 + # - 重启容器 + # + # 示例命令: + # ssh user@server "docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.image.outputs.tag }}" + # ssh user@server "docker-compose -f /path/to/docker-compose.yml up -d" + + - name: ✅ Deployment notification + run: | + echo "✅ Deployment completed successfully!" + echo "📦 Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.image.outputs.tag }}" + echo "🌍 Environment: ${{ github.event.inputs.environment || 'staging' }}" diff --git a/DEPLOYMENT_CHECKLIST.md b/DEPLOYMENT_CHECKLIST.md new file mode 100644 index 00000000000..63159e8dd17 --- /dev/null +++ b/DEPLOYMENT_CHECKLIST.md @@ -0,0 +1,231 @@ +# CI/CD 部署检查清单 + +本文档列出了完成线上 CI/CD 部署所需的所有文件和配置。 + +## ✅ 已创建的文件 + +### 核心文件 +- ✅ `Dockerfile` - Docker 镜像构建文件 +- ✅ `.dockerignore` - Docker 构建忽略文件 +- ✅ `docker-compose.yml` - Docker Compose 配置(用于本地开发) + +### CI/CD Workflows +- ✅ `.github/workflows/docker-build-push.yml` - 自动构建和推送 Docker 镜像 +- ✅ `.github/workflows/docker-deploy.yml` - 自动部署工作流 + +### 部署脚本 +- ✅ `scripts/deploy.sh` - 部署脚本 +- ✅ `scripts/rollback.sh` - 回滚脚本 +- ✅ `scripts/health-check.sh` - 健康检查脚本 + +### 文档 +- ✅ `DOCKER_BUILD.md` - Docker 构建详细文档 + +## 📋 需要配置的项目 + +### 1. GitHub Secrets(必需) + +在 GitHub 仓库设置中添加以下 Secrets: + +**如果使用 GitHub Container Registry (ghcr.io):** +- 不需要额外配置,使用默认的 `GITHUB_TOKEN` 即可 + +**如果使用其他 Registry (如 Docker Hub):** +- `DOCKER_USERNAME` - Docker Hub 用户名 +- `DOCKER_PASSWORD` - Docker Hub 密码或访问令牌 + +**如果使用私有服务器部署:** +- `DEPLOY_SSH_KEY` - SSH 私钥(用于连接到部署服务器) +- `DEPLOY_HOST` - 部署服务器地址 +- `DEPLOY_USER` - 部署服务器用户名 + +### 2. 环境变量文件(必需) + +创建以下文件(不要提交到 Git): + +```bash +# 生产环境 +.env.production + +# 测试环境 +.env.staging +``` + +参考 `.env.production.example` 和 `.env.staging.example` 的格式。 + +### 3. 服务器配置(如果使用服务器部署) + +#### 在部署服务器上安装 Docker + +```bash +# Ubuntu/Debian +curl -fsSL https://get.docker.com -o get-docker.sh +sudo sh get-docker.sh + +# 启动 Docker +sudo systemctl start docker +sudo systemctl enable docker +``` + +#### 配置 SSH 访问 + +```bash +# 在本地生成 SSH 密钥对(如果还没有) +ssh-keygen -t ed25519 -C "deploy@hskswap" + +# 将公钥添加到服务器 +ssh-copy-id user@your-server-ip +``` + +### 4. 更新部署脚本(可选) + +如果使用自定义部署方式,需要修改 `.github/workflows/docker-deploy.yml` 中的部署步骤。 + +## 🚀 部署流程 + +### 自动部署(推荐) + +1. **推送到 main 分支** + ```bash + git push origin main + ``` + - 自动触发 `docker-build-push.yml` + - 构建并推送镜像到 GitHub Container Registry + +2. **手动触发部署** + - 在 GitHub Actions 中运行 `docker-deploy.yml` + - 选择环境和镜像标签 + +### 手动部署 + +```bash +# 1. 设置环境变量 +export DOCKER_REGISTRY=ghcr.io +export DOCKER_IMAGE_NAME=hashkeychain/hskswap +export PORT=3000 + +# 2. 部署到测试环境 +./scripts/deploy.sh staging latest + +# 3. 部署到生产环境 +./scripts/deploy.sh production latest +``` + +## 🔍 验证部署 + +### 检查容器状态 + +```bash +# 查看运行中的容器 +docker ps + +# 查看容器日志 +docker logs hskswap -f + +# 健康检查 +./scripts/health-check.sh +``` + +### 访问应用 + +- 测试环境: http://your-server-ip:3000 +- 生产环境: http://your-domain.com + +## 🔄 回滚 + +如果部署出现问题,可以快速回滚: + +```bash +# 回滚到指定版本 +./scripts/rollback.sh v1.0.0 + +# 或回滚到上一个标签 +./scripts/rollback.sh previous +``` + +## 📊 监控和日志 + +### 查看日志 + +```bash +# 实时日志 +docker logs -f hskswap + +# 最近 100 行日志 +docker logs --tail 100 hskswap + +# 带时间戳的日志 +docker logs -f -t hskswap +``` + +### 资源监控 + +```bash +# 查看容器资源使用 +docker stats hskswap + +# 查看容器详细信息 +docker inspect hskswap +``` + +## 🛡️ 安全建议 + +1. **不要提交敏感信息** + - `.env.production` 和 `.env.staging` 应在 `.gitignore` 中 + - 使用 GitHub Secrets 存储敏感配置 + +2. **使用 HTTPS** + - 配置 Nginx 反向代理和 SSL 证书 + - 使用 Let's Encrypt 免费证书 + +3. **定期更新** + - 定期更新基础镜像 + - 扫描镜像漏洞 + +4. **访问控制** + - 限制容器网络访问 + - 使用防火墙规则 + +## 📝 下一步 + +1. ✅ 配置 GitHub Secrets +2. ✅ 创建环境变量文件 +3. ✅ 设置部署服务器(如果需要) +4. ✅ 配置域名和 SSL(生产环境) + +## ❓ 常见问题 + +### Q: 镜像构建失败怎么办? + +A: +- 检查 GitHub Actions 日志 +- 确认 Dockerfile 语法正确 +- 检查依赖是否完整 + +### Q: 部署后无法访问? + +A: +- 检查容器是否运行: `docker ps` +- 检查端口是否正确映射 +- 查看容器日志: `docker logs hskswap` +- 检查防火墙设置 + +### Q: 如何更新应用? + +A: +- 推送新代码到 main 分支 +- 等待 CI/CD 自动构建 +- 手动触发部署或等待自动部署 + +## 📚 相关文档 + +- [DOCKER_BUILD.md](./DOCKER_BUILD.md) - Docker 构建详细说明 +- [GitHub Actions 文档](https://docs.github.com/en/actions) +- [Docker 文档](https://docs.docker.com/) + +## 🆘 获取帮助 + +如有问题,请: +1. 查看 GitHub Actions 日志 +2. 检查容器日志 +3. 提交 Issue 到仓库 diff --git a/DOCKER_BUILD.md b/DOCKER_BUILD.md new file mode 100644 index 00000000000..ff2e0ae8941 --- /dev/null +++ b/DOCKER_BUILD.md @@ -0,0 +1,332 @@ +# Docker 镜像构建文档 + +本文档说明如何为 HSKswap 项目构建和运行 Docker 镜像。 + +## 前置要求 + +- Docker 20.10+ +- Docker Compose 2.0+ (可选,用于本地开发) +- 至少 8GB 可用磁盘空间 + +## 项目结构 + +HSKswap 是一个基于 Bun 和 NX 的 monorepo 项目,包含以下主要应用: + +- **Web** (`apps/web/`) - 主要的 Web 应用界面 +- **Mobile** (`apps/mobile/`) - React Native 移动应用 +- **Extension** (`apps/extension/`) - 浏览器扩展 + +## Dockerfile + +项目根目录包含 `Dockerfile`,用于构建生产环境的 Web 应用镜像。 + +### 构建阶段 + +Dockerfile 采用多阶段构建,包含以下阶段: + +1. **依赖安装阶段** - 安装所有依赖 +2. **构建阶段** - 编译生产版本 +3. **运行阶段** - 使用 Nginx 提供静态文件服务 + +## 构建镜像 + +### 方法 1: 使用 Docker 命令 + +```bash +# 构建镜像 +docker build -t hskswap:latest . + +# 或者指定标签 +docker build -t hskswap:v1.0.0 -t hskswap:latest . +``` + +### 方法 2: 使用构建参数 + +```bash +# 构建时指定环境变量 +docker build \ + --build-arg NODE_ENV=production \ + --build-arg BUILD_NUM=1 \ + -t hskswap:latest . +``` + +### 方法 3: 使用 Docker Compose + +```bash +# 使用 docker-compose.yml 构建 +docker-compose build + +# 构建并启动 +docker-compose up -d +``` + +## 运行容器 + +### 基本运行 + +```bash +# 运行容器 +docker run -d \ + --name hskswap \ + -p 3000:80 \ + hskswap:latest +``` + +### 使用环境变量 + +```bash +# 运行容器并传递环境变量 +docker run -d \ + --name hskswap \ + -p 3000:80 \ + -e REACT_APP_INFURA_KEY=your_key \ + -e REACT_APP_ALCHEMY_KEY=your_key \ + hskswap:latest +``` + +### 使用环境变量文件 + +```bash +# 使用 .env 文件 +docker run -d \ + --name hskswap \ + -p 3000:80 \ + --env-file .env.production \ + hskswap:latest +``` + +## 镜像标签和版本 + +### 标签规范 + +- `hskswap:latest` - 最新版本 +- `hskswap:v1.0.0` - 特定版本 +- `hskswap:main` - main 分支构建 +- `hskswap:dev` - 开发版本 + +### 构建特定版本 + +```bash +# 从特定 Git 标签构建 +git checkout v1.0.0 +docker build -t hskswap:v1.0.0 . +``` + +## 推送镜像到 Registry + +### 推送到 Docker Hub + +```bash +# 登录 Docker Hub +docker login + +# 标记镜像 +docker tag hskswap:latest yourusername/hskswap:latest + +# 推送镜像 +docker push yourusername/hskswap:latest +``` + +### 推送到私有 Registry + +```bash +# 标记镜像 +docker tag hskswap:latest registry.example.com/hskswap:latest + +# 推送镜像 +docker push registry.example.com/hskswap:latest +``` + +### 推送到 GitHub Container Registry + +```bash +# 标记镜像 +docker tag hskswap:latest ghcr.io/hashkeychain/hskswap:latest + +# 推送镜像 +docker push ghcr.io/hashkeychain/hskswap:latest +``` + +## 优化建议 + +### 1. 使用 BuildKit + +```bash +# 启用 BuildKit 加速构建 +DOCKER_BUILDKIT=1 docker build -t hskswap:latest . +``` + +### 2. 使用缓存挂载 + +```bash +# 使用缓存挂载加速依赖安装 +docker build \ + --build-arg BUILDKIT_INLINE_CACHE=1 \ + --cache-from hskswap:latest \ + -t hskswap:latest . +``` + +### 3. 多平台构建 + +```bash +# 构建多平台镜像 (需要 buildx) +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + -t hskswap:latest \ + --push . +``` + +## 环境变量配置 + +### 必需的环境变量 + +- `REACT_APP_INFURA_KEY` - Infura API Key +- `REACT_APP_ALCHEMY_KEY` - Alchemy API Key +- `REACT_APP_QUICKNODE_ENDPOINT_NAME` - QuickNode 端点名称 +- `REACT_APP_QUICKNODE_ENDPOINT_TOKEN` - QuickNode 端点令牌 + +### 可选的环境变量 + +- `NODE_ENV` - 环境模式 (production/staging/development) +- `BUILD_NUM` - 构建编号 +- `REACT_APP_CHAIN_ID` - 默认链 ID + +## 健康检查 + +容器包含健康检查配置: + +```bash +# 检查容器健康状态 +docker ps + +# 查看健康检查日志 +docker inspect --format='{{json .State.Health}}' hskswap +``` + +## 故障排查 + +### 构建失败 + +1. 检查 Docker 版本是否满足要求 +2. 确保有足够的磁盘空间 +3. 检查网络连接(下载依赖需要) + +```bash +# 查看构建日志 +docker build --progress=plain -t hskswap:latest . +``` + +### 容器无法启动 + +1. 检查端口是否被占用 +2. 查看容器日志 + +```bash +# 查看容器日志 +docker logs hskswap + +# 实时查看日志 +docker logs -f hskswap +``` + +### 性能问题 + +1. 使用多阶段构建减少镜像大小 +2. 启用 BuildKit 加速构建 +3. 使用缓存挂载 + +## CI/CD 集成 + +### GitHub Actions + +```yaml +name: Build and Push Docker Image + +on: + push: + branches: [main] + tags: ['v*'] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + push: true + tags: | + ghcr.io/hashkeychain/hskswap:latest + ghcr.io/hashkeychain/hskswap:${{ github.ref_name }} +``` + +## 安全建议 + +1. **不要将敏感信息硬编码到镜像中** + - 使用环境变量或密钥管理服务 + - 使用 Docker secrets 或 Kubernetes secrets + +2. **定期更新基础镜像** + - 使用最新的官方基础镜像 + - 定期扫描镜像漏洞 + +3. **最小权限原则** + - 使用非 root 用户运行容器 + - 限制容器权限 + +4. **镜像扫描** + ```bash + # 使用 Trivy 扫描镜像 + trivy image hskswap:latest + ``` + +## 常见问题 + +### Q: 镜像太大怎么办? + +A: 使用多阶段构建,只保留必要的文件,移除开发依赖。 + +### Q: 构建时间太长? + +A: +- 使用 BuildKit 和缓存 +- 优化 Dockerfile 层顺序 +- 使用 .dockerignore 排除不必要的文件 + +### Q: 如何更新镜像? + +A: +```bash +# 拉取最新代码 +git pull + +# 重新构建 +docker build -t hskswap:latest . + +# 重启容器 +docker restart hskswap +``` + +## 相关资源 + +- [Docker 官方文档](https://docs.docker.com/) +- [Dockerfile 最佳实践](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/) +- [Bun 文档](https://bun.sh/docs) +- [NX 文档](https://nx.dev/) + +## 支持 + +如有问题,请提交 Issue 到 [GitHub Repository](https://github.com/HashKeyChain/HSKswap)。 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000000..13a5ec22a9f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,92 @@ +# 多阶段构建 Dockerfile for HSKswap Web Application +# 使用 Bun 作为运行时和包管理器 + +# ============================================ +# Stage 1: 依赖安装阶段 +# ============================================ +FROM oven/bun:1.3.1 AS deps + +WORKDIR /app + +# 复制包管理文件 +COPY package.json bun.lockb ./ +COPY apps/web/package.json ./apps/web/ +COPY packages/*/package.json ./packages/*/ + +# 安装依赖 +RUN bun install --frozen-lockfile + +# ============================================ +# Stage 2: 构建阶段 +# ============================================ +FROM oven/bun:1.3.1 AS builder + +WORKDIR /app + +# 从依赖阶段复制 node_modules +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules +COPY --from=deps /app/packages ./packages + +# 复制源代码 +COPY . . + +# 设置构建参数 +ARG NODE_ENV=production +ARG BUILD_NUM=0 +ARG CLOUDFLARE_ENV=production + +ENV NODE_ENV=${NODE_ENV} +ENV BUILD_NUM=${BUILD_NUM} +ENV CLOUDFLARE_ENV=${CLOUDFLARE_ENV} + +# 构建生产版本 +RUN cd apps/web && bun run build:production + +# ============================================ +# Stage 3: 运行阶段 (Nginx) +# ============================================ +FROM nginx:alpine AS runner + +WORKDIR /usr/share/nginx/html + +# 复制构建产物 +COPY --from=builder /app/apps/web/build . + +# 复制 Nginx 配置(如果存在) +# 如果没有 nginx.conf 文件,使用默认配置 +RUN echo 'server { \ + listen 80; \ + server_name _; \ + root /usr/share/nginx/html; \ + index index.html; \ + gzip on; \ + gzip_vary on; \ + gzip_min_length 1024; \ + gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json; \ + add_header X-Frame-Options "SAMEORIGIN" always; \ + add_header X-Content-Type-Options "nosniff" always; \ + add_header X-XSS-Protection "1; mode=block" always; \ + location / { \ + try_files $uri $uri/ /index.html; \ + } \ + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { \ + expires 1y; \ + add_header Cache-Control "public, immutable"; \ + } \ + location /health { \ + access_log off; \ + return 200 "healthy\n"; \ + add_header Content-Type text/plain; \ + } \ +}' > /etc/nginx/conf.d/default.conf + +# 暴露端口 +EXPOSE 80 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --quiet --tries=1 --spider http://localhost/health || exit 1 + +# 启动 Nginx +CMD ["nginx", "-g", "daemon off;"] diff --git a/apps/extension/package.json b/apps/extension/package.json index afe0d102858..31401636bb2 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -18,7 +18,7 @@ "@uniswap/analytics-events": "2.43.0", "@uniswap/client-embeddedwallet": "0.0.16", "@uniswap/sdk-core": "7.9.0", - "@uniswap/universal-router-sdk": "4.19.5", + "@hkdex-tmp/universal_router_sdk": "1.0.3", "@uniswap/v3-sdk": "3.25.2", "@uniswap/v4-sdk": "1.21.2", "@universe/api": "workspace:^", diff --git a/apps/extension/src/app/features/dappRequests/types/UniversalRouterTypes.ts b/apps/extension/src/app/features/dappRequests/types/UniversalRouterTypes.ts index 5990962ebb1..1090ed99fff 100644 --- a/apps/extension/src/app/features/dappRequests/types/UniversalRouterTypes.ts +++ b/apps/extension/src/app/features/dappRequests/types/UniversalRouterTypes.ts @@ -1,4 +1,4 @@ -import { CommandType } from '@uniswap/universal-router-sdk' +import { CommandType } from '@hkdex-tmp/universal_router_sdk' import { FeeAmount as FeeAmountV3 } from '@uniswap/v3-sdk' import { BigNumberSchema } from 'src/app/features/dappRequests/types/EthersTypes' import { z } from 'zod' diff --git a/apps/extension/src/background/utils/getCalldataInfoFromTransaction.ts b/apps/extension/src/background/utils/getCalldataInfoFromTransaction.ts index adff8212d30..924a5bc22b3 100644 --- a/apps/extension/src/background/utils/getCalldataInfoFromTransaction.ts +++ b/apps/extension/src/background/utils/getCalldataInfoFromTransaction.ts @@ -1,4 +1,4 @@ -import { CommandParser, UniversalRouterCall } from '@uniswap/universal-router-sdk' +import { CommandParser, UniversalRouterCall } from '@hkdex-tmp/universal_router_sdk' import { V4BaseActionsParser, V4RouterCall } from '@uniswap/v4-sdk' import { EthSendTransactionRPCActions } from 'src/app/features/dappRequests/types/DappRequestTypes' import { parseCalldata as parseNfPMCalldata } from 'src/app/features/dappRequests/types/NonfungiblePositionManager' diff --git a/apps/extension/wxt.config.ts b/apps/extension/wxt.config.ts index 6fa77e81623..45c8b956a46 100644 --- a/apps/extension/wxt.config.ts +++ b/apps/extension/wxt.config.ts @@ -170,7 +170,7 @@ export default defineConfig({ '@uniswap/v3-sdk', '@uniswap/v4-sdk', '@uniswap/router-sdk', - '@uniswap/universal-router-sdk', + '@hkdex-tmp/universal_router_sdk', '@uniswap/uniswapx-sdk', '@uniswap/permit2-sdk', 'jsbi', @@ -286,7 +286,7 @@ export default defineConfig({ '@uniswap/v3-sdk', '@uniswap/v4-sdk', '@uniswap/router-sdk', - '@uniswap/universal-router-sdk', + '@hkdex-tmp/universal_router_sdk', '@uniswap/uniswapx-sdk', '@uniswap/permit2-sdk', 'jsbi', diff --git a/apps/mobile/src/components/RestoreWalletModal/__snapshots__/PrivateKeySpeedBumpModal.test.tsx.snap b/apps/mobile/src/components/RestoreWalletModal/__snapshots__/PrivateKeySpeedBumpModal.test.tsx.snap index a7a02717069..6a097d8735c 100644 --- a/apps/mobile/src/components/RestoreWalletModal/__snapshots__/PrivateKeySpeedBumpModal.test.tsx.snap +++ b/apps/mobile/src/components/RestoreWalletModal/__snapshots__/PrivateKeySpeedBumpModal.test.tsx.snap @@ -437,7 +437,7 @@ exports[`PrivateKeySpeedBumpModal renders correctly 1`] = ` disabled={false} focusVisibleStyle={ { - "backgroundColor": "#E500A5", + "backgroundColor": "#3d7fff", } } forwardedRef={[Function]} @@ -462,7 +462,7 @@ exports[`PrivateKeySpeedBumpModal renders correctly 1`] = ` { "alignItems": "center", "alignSelf": "stretch", - "backgroundColor": "#FF37C7", + "backgroundColor": "#4177e2", "borderBottomColor": "transparent", "borderBottomLeftRadius": 16, "borderBottomRightRadius": 16, @@ -504,7 +504,7 @@ exports[`PrivateKeySpeedBumpModal renders correctly 1`] = ` { "alignItems": "center", "alignSelf": "stretch", - "backgroundColor": "#FF37C7", + "backgroundColor": "#4177e2", "borderBottomColor": "transparent", "borderBottomLeftRadius": 16, "borderBottomRightRadius": 16, diff --git a/apps/mobile/src/components/explore/__snapshots__/FavoriteHeaderRow.test.tsx.snap b/apps/mobile/src/components/explore/__snapshots__/FavoriteHeaderRow.test.tsx.snap index da2b061869e..d18613f43ac 100644 --- a/apps/mobile/src/components/explore/__snapshots__/FavoriteHeaderRow.test.tsx.snap +++ b/apps/mobile/src/components/explore/__snapshots__/FavoriteHeaderRow.test.tsx.snap @@ -100,7 +100,7 @@ exports[`FavoriteHeaderRow when editing renders without error 1`] = ` maxFontSizeMultiplier={1.2} style={ { - "color": "#FF37C7", + "color": "#4177e2", "fontFamily": "Basel Grotesk", "fontSize": 17, "fontWeight": "500", diff --git a/apps/mobile/src/components/input/__snapshots__/SelectionCircle.test.tsx.snap b/apps/mobile/src/components/input/__snapshots__/SelectionCircle.test.tsx.snap index b29ce1681af..20dedf53a4a 100644 --- a/apps/mobile/src/components/input/__snapshots__/SelectionCircle.test.tsx.snap +++ b/apps/mobile/src/components/input/__snapshots__/SelectionCircle.test.tsx.snap @@ -5,16 +5,16 @@ exports[`renders selection circle 1`] = ` style={ { "alignItems": "center", - "borderBottomColor": "#FF37C7", + "borderBottomColor": "#4177e2", "borderBottomLeftRadius": 999999, "borderBottomRightRadius": 999999, "borderBottomWidth": 1, - "borderLeftColor": "#FF37C7", + "borderLeftColor": "#4177e2", "borderLeftWidth": 1, - "borderRightColor": "#FF37C7", + "borderRightColor": "#4177e2", "borderRightWidth": 1, "borderStyle": "solid", - "borderTopColor": "#FF37C7", + "borderTopColor": "#4177e2", "borderTopLeftRadius": 999999, "borderTopRightRadius": 999999, "borderTopWidth": 1, @@ -28,7 +28,7 @@ exports[`renders selection circle 1`] = ` { ' { expect(responseText).toContain(' - Uniswap Interface + HSKSwap | Trade Crypto on DeFi's Leading Exchange + + + - - + + + @@ -18,44 +22,21 @@ + + - - + - {extensionEligible && } + {/* Get App banner is hidden - only English is supported */} + {/* {extensionEligible && } */} {renderUkBanner && } {renderUniswapWrapped2025Banner} diff --git a/apps/web/src/pages/App/utils/UserPropertyUpdater.tsx b/apps/web/src/pages/App/utils/UserPropertyUpdater.tsx index 0e0df7da91a..9dc3fca5460 100644 --- a/apps/web/src/pages/App/utils/UserPropertyUpdater.tsx +++ b/apps/web/src/pages/App/utils/UserPropertyUpdater.tsx @@ -10,7 +10,8 @@ import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledCh import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { InterfaceUserPropertyName, setUserProperty } from 'uniswap/src/features/telemetry/user' import { ReactQueryCacheKey } from 'utilities/src/reactQuery/cache' -import { getCLS, getFCP, getFID, getLCP, Metric } from 'web-vitals' +// Web Vitals imports disabled - removed to reduce log noise +// import { getCLS, getFCP, getFID, getLCP, Metric } from 'web-vitals' /** * Query options for fetching the Uniswap identifier @@ -71,14 +72,15 @@ export function UserPropertyUpdater() { const pageLoadProperties = { service_worker: serviceWorkerProperty, cache } sendAnalyticsEvent(SharedEventName.APP_LOADED, pageLoadProperties) - const sendWebVital = - (metric: string) => - ({ delta }: Metric) => - sendAnalyticsEvent(SharedEventName.WEB_VITALS, { ...pageLoadProperties, [metric]: delta }) - getCLS(sendWebVital('cumulative_layout_shift')) - getFCP(sendWebVital('first_contentful_paint_ms')) - getFID(sendWebVital('first_input_delay_ms')) - getLCP(sendWebVital('largest_contentful_paint_ms')) + // Web Vitals tracking disabled - removed to reduce log noise + // const sendWebVital = + // (metric: string) => + // ({ delta }: Metric) => + // sendAnalyticsEvent(SharedEventName.WEB_VITALS, { ...pageLoadProperties, [metric]: delta }) + // getCLS(sendWebVital('cumulative_layout_shift')) + // getFCP(sendWebVital('first_contentful_paint_ms')) + // getFID(sendWebVital('first_input_delay_ms')) + // getLCP(sendWebVital('largest_contentful_paint_ms')) }, []) useEffect(() => { diff --git a/apps/web/src/pages/CreatePosition/CreatePosition.tsx b/apps/web/src/pages/CreatePosition/CreatePosition.tsx index e90d11fd5a3..e6d1e0370ec 100644 --- a/apps/web/src/pages/CreatePosition/CreatePosition.tsx +++ b/apps/web/src/pages/CreatePosition/CreatePosition.tsx @@ -112,7 +112,8 @@ const Toolbar = () => { } = useCreateLiquidityContext() const { protocolVersion } = positionState const customSlippageTolerance = useTransactionSettingsStore((s) => s.customSlippageTolerance) - const [versionDropdownOpen, setVersionDropdownOpen] = useState(false) + // 注释掉版本选择器 - 本期只做 V3 基础添加流动性 + // const [versionDropdownOpen, setVersionDropdownOpen] = useState(false) const [showResetModal, setShowResetModal] = useState(false) @@ -134,38 +135,39 @@ const Toolbar = () => { } }, [handleReset, isTestnetModeEnabled, prevIsTestnetModeEnabled]) - const handleVersionChange = useCallback( - (version: ProtocolVersion) => { - const versionUrl = getProtocolVersionLabel(version) - if (versionUrl) { - // Ensure useLiquidityUrlState is synced - setTimeout(() => navigate(`/positions/create/${versionUrl}`), 1) - } + // 注释掉版本切换功能 - 本期只做 V3 基础添加流动性 + // const handleVersionChange = useCallback( + // (version: ProtocolVersion) => { + // const versionUrl = getProtocolVersionLabel(version) + // if (versionUrl) { + // // Ensure useLiquidityUrlState is synced + // setTimeout(() => navigate(`/positions/create/${versionUrl}`), 1) + // } - setPositionState({ - ...DEFAULT_POSITION_STATE, - protocolVersion: version, - }) - setPriceRangeState(DEFAULT_PRICE_RANGE_STATE) - setStep(PositionFlowStep.SELECT_TOKENS_AND_FEE_TIER) - setVersionDropdownOpen(false) - }, - [setPositionState, setPriceRangeState, setStep, navigate], - ) + // setPositionState({ + // ...DEFAULT_POSITION_STATE, + // protocolVersion: version, + // }) + // setPriceRangeState(DEFAULT_PRICE_RANGE_STATE) + // setStep(PositionFlowStep.SELECT_TOKENS_AND_FEE_TIER) + // setVersionDropdownOpen(false) + // }, + // [setPositionState, setPriceRangeState, setStep, navigate], + // ) - const versionOptions = useMemo( - () => - [ProtocolVersion.V4, ProtocolVersion.V3, ProtocolVersion.V2] - .filter((version) => version !== protocolVersion) - .map((version) => ( - handleVersionChange(version)}> - - {t('position.new.protocol', { protocol: getProtocolVersionLabel(version) })} - - - )), - [handleVersionChange, protocolVersion, t], - ) + // const versionOptions = useMemo( + // () => + // [ProtocolVersion.V4, ProtocolVersion.V3, ProtocolVersion.V2] + // .filter((version) => version !== protocolVersion) + // .map((version) => ( + // handleVersionChange(version)}> + // + // {t('position.new.protocol', { protocol: getProtocolVersionLabel(version) })} + // + // + // )), + // [handleVersionChange, protocolVersion, t], + // ) return ( @@ -177,7 +179,8 @@ const Toolbar = () => { setShowResetModal(true)} isDisabled={isNativeTokenAOnly} /> - { alignRight > {versionOptions} - + */} ; tokenB: Maybe }>({ - tokenA: initialInputs.tokenA, + tokenA: initialInputs.tokenA ?? initialInputs.defaultInitialToken, tokenB: initialInputs.tokenB, }) + // Update currencyInputs when initialInputs change (e.g., when URL params are auto-initialized) + useEffect(() => { + setCurrencyInputs({ + tokenA: initialInputs.tokenA ?? initialInputs.defaultInitialToken, + tokenB: initialInputs.tokenB, + }) + }, [initialInputs.tokenA, initialInputs.tokenB, initialInputs.defaultInitialToken]) + return ( @@ -248,7 +261,7 @@ function CreatePositionContent({ currencyInputs={currencyInputs} setCurrencyInputs={setCurrencyInputs} initialPositionState={{ - fee: initialInputs.fee ?? undefined, + fee: initialInputs.fee, // fee is already guaranteed to have default value from useLiquidityUrlState hook: initialInputs.hook ?? undefined, protocolVersion: initialProtocolVersion, }} diff --git a/apps/web/src/pages/CreatePosition/CreatePositionModal.tsx b/apps/web/src/pages/CreatePosition/CreatePositionModal.tsx index 7fbbc4e6f56..b046d956448 100644 --- a/apps/web/src/pages/CreatePosition/CreatePositionModal.tsx +++ b/apps/web/src/pages/CreatePosition/CreatePositionModal.tsx @@ -77,6 +77,7 @@ export function CreatePositionModal({ setTransactionError(false) const isValidTx = isValidLiquidityTxContext(txInfo) + if ( !account || !isSignerMnemonicAccountDetails(account) || diff --git a/apps/web/src/pages/CreatePosition/CreatePositionTxContext.tsx b/apps/web/src/pages/CreatePosition/CreatePositionTxContext.tsx index ca0e94c378f..b1444a1197c 100644 --- a/apps/web/src/pages/CreatePosition/CreatePositionTxContext.tsx +++ b/apps/web/src/pages/CreatePosition/CreatePositionTxContext.tsx @@ -1,11 +1,12 @@ /* eslint-disable max-lines */ import { ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' -import { Currency, CurrencyAmount } from '@uniswap/sdk-core' +import { Currency, CurrencyAmount, MaxUint256, Token } from '@uniswap/sdk-core' import { Pair } from '@uniswap/v2-sdk' import { Pool as V3Pool } from '@uniswap/v3-sdk' -import { Pool as V4Pool } from '@uniswap/v4-sdk' +import { Interface } from '@ethersproject/abi' import { TradingApi } from '@universe/api' import { useDepositInfo } from 'components/Liquidity/Create/hooks/useDepositInfo' +import { useOnChainLpApproval } from 'components/Liquidity/Create/hooks/useOnChainLpApproval' import { DYNAMIC_FEE_DATA, PositionState } from 'components/Liquidity/Create/types' import { useCreatePositionDependentAmountFallback } from 'components/Liquidity/hooks/useDependentAmountFallback' import { getTokenOrZeroAddress, validateCurrencyInput } from 'components/Liquidity/utils/currency' @@ -43,6 +44,13 @@ import { AccountDetails } from 'uniswap/src/features/wallet/types/AccountDetails import { logger } from 'utilities/src/logger/logger' import { ONE_SECOND_MS } from 'utilities/src/time/time' +/** + * Check if a chain ID is a HashKey chain + */ +function isHashKeyChain(chainId: number | undefined): boolean { + return chainId === UniverseChainId.HashKey || chainId === UniverseChainId.HashKeyTestnet +} + /** * @internal - exported for testing */ @@ -80,7 +88,7 @@ export function generateAddLiquidityApprovalParams({ token1: getTokenOrZeroAddress(displayCurrencies.TOKEN1), amount0: currencyAmounts.TOKEN0.quotient.toString(), amount1: currencyAmounts.TOKEN1.quotient.toString(), - generatePermitAsTransaction: protocolVersion === ProtocolVersion.V4 ? canBatchTransactions : undefined, + generatePermitAsTransaction: undefined, // HashKey Chain only supports V3, no V4 permit support } satisfies TradingApi.CheckApprovalLPRequest } @@ -106,7 +114,7 @@ export function generateCreateCalldataQueryParams({ approvalCalldata?: TradingApi.CheckApprovalLPResponse positionState: PositionState ticks: [Maybe, Maybe] - poolOrPair: V3Pool | V4Pool | Pair | undefined + poolOrPair: V3Pool | Pair | undefined displayCurrencies: { [field in PositionField]: Maybe } currencyAmounts?: { [field in PositionField]?: Maybe> } independentField: PositionField @@ -119,7 +127,8 @@ export function generateCreateCalldataQueryParams({ !apiProtocolItems || !currencyAmounts?.TOKEN0 || !currencyAmounts.TOKEN1 || - !validateCurrencyInput(displayCurrencies) + !validateCurrencyInput(displayCurrencies) || + !positionState.fee // Ensure fee is defined ) { return undefined } @@ -181,7 +190,7 @@ export function generateCreateCalldataQueryParams({ return undefined } - const pool = poolOrPair as V4Pool | V3Pool | undefined + const pool = poolOrPair as V3Pool | undefined if (!pool || !displayCurrencies.TOKEN0 || !displayCurrencies.TOKEN1) { return undefined } @@ -204,6 +213,19 @@ export function generateCreateCalldataQueryParams({ const independentAmount = currencyAmounts[independentField] const dependentAmount = currencyAmounts[dependentField] + // Ensure fee is defined + if (!positionState.fee?.feeAmount) { + return undefined + } + + // V3 pool configuration (HashKey Chain only supports V3) + const poolConfig: any = { + tickSpacing, + token0: getTokenOrZeroAddress(displayCurrencies.TOKEN0), + token1: getTokenOrZeroAddress(displayCurrencies.TOKEN1), + fee: positionState.fee.isDynamic ? DYNAMIC_FEE_DATA.feeAmount : positionState.fee.feeAmount, + } + return { simulateTransaction: !( permitData || @@ -224,13 +246,7 @@ export function generateCreateCalldataQueryParams({ position: { tickLower: tickLower ?? undefined, tickUpper: tickUpper ?? undefined, - pool: { - tickSpacing, - token0: getTokenOrZeroAddress(displayCurrencies.TOKEN0), - token1: getTokenOrZeroAddress(displayCurrencies.TOKEN1), - fee: positionState.fee?.isDynamic ? DYNAMIC_FEE_DATA.feeAmount : positionState.fee?.feeAmount, - hooks: positionState.hook, - }, + pool: poolConfig, }, } satisfies TradingApi.CreateLPPositionRequest } @@ -252,10 +268,20 @@ export function generateCreatePositionTxRequest({ createCalldata?: TradingApi.CreateLPPositionResponse createCalldataQueryParams?: TradingApi.CreateLPPositionRequest currencyAmounts?: { [field in PositionField]?: Maybe> } - poolOrPair: Pair | undefined + poolOrPair: V3Pool | Pair | undefined canBatchTransactions: boolean }): CreatePositionTxAndGasInfo | undefined { - if (!createCalldata || !currencyAmounts?.TOKEN0 || !currencyAmounts.TOKEN1) { + if (!currencyAmounts?.TOKEN0 || !currencyAmounts.TOKEN1) { + return undefined + } + + // For HashKey chains, Trading API doesn't support creating LP positions + // So createCalldata will be undefined, and we'll use async step instead + const chainId = currencyAmounts.TOKEN0.currency.chainId + const isHashKey = isHashKeyChain(chainId) + + // For non-HashKey chains, createCalldata is required + if (!isHashKey && !createCalldata) { return undefined } @@ -287,16 +313,31 @@ export function generateCreatePositionTxRequest({ const validatedToken0PermitTransaction = validateTransactionRequest(approvalCalldata?.token0PermitTransaction) const validatedToken1PermitTransaction = validateTransactionRequest(approvalCalldata?.token1PermitTransaction) - const txRequest = validateTransactionRequest(createCalldata.create) - if (!txRequest && !(validatedToken0PermitTransaction || validatedToken1PermitTransaction)) { + // For HashKey chains, we don't have createCalldata from Trading API + // So txRequest will be undefined, and we'll use async step with createPositionRequestArgs + const txRequest = createCalldata?.create ? validateTransactionRequest(createCalldata.create) : undefined + + // For HashKey chains, allow missing txRequest (will use async step) + // For other chains, require txRequest unless using permit transactions + if (!isHashKey && !txRequest && !(validatedToken0PermitTransaction || validatedToken1PermitTransaction)) { // Allow missing txRequest if mismatched (unsigned flow using token0PermitTransaction/2) return undefined } - const queryParams: TradingApi.CreateLPPositionRequest | undefined = - protocolVersion === ProtocolVersion.V4 - ? { ...createCalldataQueryParams, batchPermitData: validatedPermitRequest } - : createCalldataQueryParams + // HashKey Chain only supports V3, so no need for V4-specific batchPermitData handling + const queryParams: TradingApi.CreateLPPositionRequest | undefined = createCalldataQueryParams + + // For HashKey chains, get sqrtRatioX96 from poolOrPair if available (V3 pools only) + // For other chains, get it from createCalldata + let sqrtRatioX96: string | undefined + if (isHashKey && poolOrPair && protocolVersion !== ProtocolVersion.V2) { + // For V3, poolOrPair is a V3Pool, not a Pair + const pool = poolOrPair as V3Pool + sqrtRatioX96 = pool.sqrtRatioX96.toString() + } + if (!sqrtRatioX96) { + sqrtRatioX96 = createCalldata?.sqrtRatioX96 + } return { type: LiquidityTransactionType.Create, @@ -307,7 +348,9 @@ export function generateCreatePositionTxRequest({ type: LiquidityTransactionType.Create, currency0Amount: currencyAmounts.TOKEN0, currency1Amount: currencyAmounts.TOKEN1, - liquidityToken: protocolVersion === ProtocolVersion.V2 ? poolOrPair?.liquidityToken : undefined, + liquidityToken: protocolVersion === ProtocolVersion.V2 && poolOrPair && 'liquidityToken' in poolOrPair + ? (poolOrPair as Pair).liquidityToken + : undefined, }, approveToken0Request: validatedApprove0Request, approveToken1Request: validatedApprove1Request, @@ -319,7 +362,7 @@ export function generateCreatePositionTxRequest({ token0PermitTransaction: validatedToken0PermitTransaction, token1PermitTransaction: validatedToken1PermitTransaction, positionTokenPermitTransaction: undefined, - sqrtRatioX96: createCalldata.sqrtRatioX96, + sqrtRatioX96, } satisfies CreatePositionTxAndGasInfo } @@ -343,37 +386,89 @@ export function CreatePositionTxContextProvider({ children }: PropsWithChildren) protocolVersion, currencies, ticks, - poolOrPair, + poolOrPair: rawPoolOrPair, depositState, creatingPoolOrPair, currentTransactionStep, positionState, setRefetch, } = useCreateLiquidityContext() + + // Filter out V4 pools - HashKey Chain only supports V3 + const poolOrPair = useMemo(() => { + if (!rawPoolOrPair) return undefined + // If it's a Pair (V2), return as is + if ('liquidityToken' in rawPoolOrPair) { + return rawPoolOrPair + } + // If it's a V3Pool (has 'fee' and 'tickSpacing' properties), return as is + if ('fee' in rawPoolOrPair && 'tickSpacing' in rawPoolOrPair && 'token0' in rawPoolOrPair) { + const token0 = rawPoolOrPair.token0 + // V3Pool has Token, V4Pool has Currency - check if token0 is Token + if ('address' in token0) { + return rawPoolOrPair as V3Pool + } + } + // Otherwise, it's V4Pool, return undefined for HashKey chains + return undefined + }, [rawPoolOrPair]) + const account = useWallet().evmAccount + + if (!currencies?.display) { + throw new TypeError('currencies.display is undefined in CreatePositionTxContext') + } + const { TOKEN0, TOKEN1 } = currencies.display + + if (!depositState) { + throw new TypeError('depositState is undefined in CreatePositionTxContext') + } + const { exactField } = depositState - const invalidRange = protocolVersion !== ProtocolVersion.V2 && isInvalidRange(ticks[0], ticks[1]) + let invalidRange: boolean + try { + invalidRange = protocolVersion !== ProtocolVersion.V2 && isInvalidRange(ticks?.[0], ticks?.[1]) + } catch (error) { + invalidRange = false // Default to false on error + } + const depositInfoProps = useMemo(() => { - const [tickLower, tickUpper] = ticks - const outOfRange = isOutOfRange({ - poolOrPair, - lowerTick: tickLower, - upperTick: tickUpper, - }) + try { + const [tickLower, tickUpper] = ticks || [undefined, undefined] + const outOfRange = isOutOfRange({ + poolOrPair, + lowerTick: tickLower, + upperTick: tickUpper, + }) - return { - protocolVersion, - poolOrPair, - address: account?.address, - token0: TOKEN0, - token1: TOKEN1, - tickLower: protocolVersion !== ProtocolVersion.V2 ? (tickLower ?? undefined) : undefined, - tickUpper: protocolVersion !== ProtocolVersion.V2 ? (tickUpper ?? undefined) : undefined, - exactField, - exactAmounts: depositState.exactAmounts, - skipDependentAmount: protocolVersion === ProtocolVersion.V2 ? false : outOfRange || invalidRange, + return { + protocolVersion, + poolOrPair, + address: account?.address, + token0: TOKEN0, + token1: TOKEN1, + tickLower: protocolVersion !== ProtocolVersion.V2 ? (tickLower ?? undefined) : undefined, + tickUpper: protocolVersion !== ProtocolVersion.V2 ? (tickUpper ?? undefined) : undefined, + exactField, + exactAmounts: depositState?.exactAmounts, + skipDependentAmount: protocolVersion === ProtocolVersion.V2 ? false : outOfRange || invalidRange, + } + } catch (error) { + // Return minimal props on error + return { + protocolVersion, + poolOrPair, + address: account?.address, + token0: TOKEN0, + token1: TOKEN1, + tickLower: undefined, + tickUpper: undefined, + exactField, + exactAmounts: depositState?.exactAmounts, + skipDependentAmount: true, // Skip dependent amount on error + } } }, [TOKEN0, TOKEN1, exactField, ticks, poolOrPair, depositState, account?.address, protocolVersion, invalidRange]) @@ -395,7 +490,113 @@ export function CreatePositionTxContextProvider({ children }: PropsWithChildren) const [transactionError, setTransactionError] = useState(false) + // Check if this is a HashKey chain (Trading API doesn't support HashKey chains) + const isHashKey = isHashKeyChain(poolOrPair?.chainId) + + // Use on-chain approval check for HashKey chains + const token0Amount = useMemo(() => { + const amount = currencyAmounts?.TOKEN0 + if (!amount || !(amount.currency instanceof Token)) return undefined + return amount as CurrencyAmount + }, [currencyAmounts?.TOKEN0]) + + const token1Amount = useMemo(() => { + const amount = currencyAmounts?.TOKEN1 + if (!amount || !(amount.currency instanceof Token)) return undefined + return amount as CurrencyAmount + }, [currencyAmounts?.TOKEN1]) + + const onChainApproval = useOnChainLpApproval({ + token0: TOKEN0 instanceof Token ? TOKEN0 : undefined, + token1: TOKEN1 instanceof Token ? TOKEN1 : undefined, + amount0: token0Amount, + amount1: token1Amount, + owner: account?.address, + chainId: poolOrPair?.chainId, + }) + + // Build approval transaction requests for HashKey chains based on on-chain check + // Supports both traditional ERC20 approve and Permit2 authorization + const hashKeyApprovalCalldata = useMemo(() => { + try { + if (!isHashKey || !onChainApproval.positionManagerAddress || !poolOrPair?.chainId) { + return undefined + } + + const positionManagerAddress = onChainApproval.positionManagerAddress + const approveInterface = new Interface(['function approve(address spender,uint256 value)']) + + // Only build approval transactions if needed (considering Permit2 authorization) + // If Permit2 authorization is valid, we don't need traditional approve + const token0NeedsApproval = onChainApproval.token0NeedsApproval && TOKEN0 instanceof Token && currencyAmounts?.TOKEN0 + const token1NeedsApproval = onChainApproval.token1NeedsApproval && TOKEN1 instanceof Token && currencyAmounts?.TOKEN1 + + const token0ApprovalTx = token0NeedsApproval && TOKEN0 instanceof Token && positionManagerAddress + ? { + to: TOKEN0.address, + data: approveInterface.encodeFunctionData('approve', [ + positionManagerAddress, + MaxUint256.toString(), + ]), + value: '0x0', + chainId: poolOrPair.chainId, + } + : undefined + + const token1ApprovalTx = token1NeedsApproval && TOKEN1 instanceof Token && positionManagerAddress + ? { + to: TOKEN1.address, + data: approveInterface.encodeFunctionData('approve', [ + positionManagerAddress, + MaxUint256.toString(), + ]), + value: '0x0', + chainId: poolOrPair.chainId, + } + : undefined + + // Return in Trading API format for compatibility + // Only include approvals if they are needed + const token0Approval = token0ApprovalTx ? validateTransactionRequest(token0ApprovalTx) : undefined + const token1Approval = token1ApprovalTx ? validateTransactionRequest(token1ApprovalTx) : undefined + + // Note: Permit2 permit transactions (token0PermitTransaction/token1PermitTransaction) + // are typically generated by Trading API. For HashKey Chain, we currently only support + // traditional approve transactions. If Permit2 authorization exists and is valid, + // no approval transactions are needed. + + // Return undefined if no approvals needed (similar to Trading API behavior) + if (!token0Approval && !token1Approval) { + return undefined + } + + return { + token0Approval, + token1Approval, + // TODO: Add Permit2 permit transaction support for HashKey Chain if needed + // token0PermitTransaction: ..., + // token1PermitTransaction: ..., + } as TradingApi.CheckApprovalLPResponse | undefined + } catch (error) { + return undefined + } + }, [ + isHashKey, + onChainApproval.positionManagerAddress, + onChainApproval.token0NeedsApproval, + onChainApproval.token1NeedsApproval, + onChainApproval.token0NeedsPermit2Approval, + onChainApproval.token1NeedsPermit2Approval, + TOKEN0, + TOKEN1, + currencyAmounts, + poolOrPair?.chainId, + ]) + const addLiquidityApprovalParams = useMemo(() => { + if (!currencies?.display) { + return undefined + } return generateAddLiquidityApprovalParams({ address: account?.address, protocolVersion, @@ -403,10 +604,14 @@ export function CreatePositionTxContextProvider({ children }: PropsWithChildren) currencyAmounts, canBatchTransactions, }) - }, [account?.address, protocolVersion, currencies.display, currencyAmounts, canBatchTransactions]) + }, [account?.address, protocolVersion, currencies?.display, currencyAmounts, canBatchTransactions]) + + // For HashKey chains, skip Trading API and use on-chain check + const shouldEnableTradingApiApprovalQuery = + !!addLiquidityApprovalParams && !inputError && !transactionError && !invalidRange && !isHashKey const { - data: approvalCalldata, + data: tradingApiApprovalCalldata, error: approvalError, isLoading: approvalLoading, refetch: approvalRefetch, @@ -414,10 +619,13 @@ export function CreatePositionTxContextProvider({ children }: PropsWithChildren) params: addLiquidityApprovalParams, staleTime: 5 * ONE_SECOND_MS, retry: false, - enabled: !!addLiquidityApprovalParams && !inputError && !transactionError && !invalidRange, + enabled: shouldEnableTradingApiApprovalQuery, }) - if (approvalError) { + // Use on-chain approval data for HashKey chains, Trading API data for others + const approvalCalldata = isHashKey ? hashKeyApprovalCalldata : tradingApiApprovalCalldata + + if (approvalError && !isHashKey) { const message = parseErrorMessageTitle(approvalError, { defaultTitle: 'unknown CheckLpApprovalQuery' }) logger.error(message, { tags: { file: 'CreatePositionTxContext', function: 'useEffect' }, @@ -430,6 +638,9 @@ export function CreatePositionTxContextProvider({ children }: PropsWithChildren) const gasFeeToken1PermitUSD = useUSDCurrencyAmountOfGasFee(poolOrPair?.chainId, approvalCalldata?.gasFeeToken1Permit) const createCalldataQueryParams = useMemo(() => { + if (!currencies?.display || !depositState) { + return undefined + } return generateCreateCalldataQueryParams({ account, approvalCalldata, @@ -438,7 +649,7 @@ export function CreatePositionTxContextProvider({ children }: PropsWithChildren) creatingPoolOrPair, displayCurrencies: currencies.display, ticks, - poolOrPair, + poolOrPair: poolOrPair as V3Pool | Pair | undefined, // Filter out V4Pool - HashKey Chain only supports V3 currencyAmounts, independentField: depositState.exactField, slippageTolerance: customSlippageTolerance, @@ -451,9 +662,9 @@ export function CreatePositionTxContextProvider({ children }: PropsWithChildren) ticks, poolOrPair, positionState, - depositState.exactField, + depositState?.exactField, customSlippageTolerance, - currencies.display, + currencies?.display, protocolVersion, ]) @@ -461,14 +672,21 @@ export function CreatePositionTxContextProvider({ children }: PropsWithChildren) currentTransactionStep?.step.type === TransactionStepType.IncreasePositionTransaction || currentTransactionStep?.step.type === TransactionStepType.IncreasePositionTransactionAsync + // For HashKey chains, we don't require approvalCalldata from Trading API + // For other chains, approvalCalldata is required + const requiresApprovalCalldata = !isHashKey + + // For HashKey chains, Trading API doesn't support creating LP positions + // So we disable the query entirely for HashKey chains const isQueryEnabled = + !isHashKey && // Disable Trading API query for HashKey chains !isUserCommittedToCreate && !inputError && !transactionError && !approvalLoading && !approvalError && !invalidRange && - Boolean(approvalCalldata) && + (requiresApprovalCalldata ? Boolean(approvalCalldata) : true) && Boolean(createCalldataQueryParams) const { @@ -555,7 +773,7 @@ export function CreatePositionTxContextProvider({ children }: PropsWithChildren) createCalldata, createCalldataQueryParams, currencyAmounts, - poolOrPair: protocolVersion === ProtocolVersion.V2 ? poolOrPair : undefined, + poolOrPair: protocolVersion === ProtocolVersion.V2 && poolOrPair instanceof Pair ? poolOrPair : undefined, canBatchTransactions, }) }, [ @@ -568,6 +786,7 @@ export function CreatePositionTxContextProvider({ children }: PropsWithChildren) canBatchTransactions, ]) + const value = useMemo( (): CreatePositionTxContextType => ({ txInfo, diff --git a/apps/web/src/pages/Explore/index.tsx b/apps/web/src/pages/Explore/index.tsx index 9f192e158cb..cd4aa079b56 100644 --- a/apps/web/src/pages/Explore/index.tsx +++ b/apps/web/src/pages/Explore/index.tsx @@ -252,7 +252,7 @@ const Explore = ({ initialTab }: { initialTab?: ExploreTab }) => { {currentKey === ExploreTab.Pools && ( - diff --git a/apps/web/src/pages/MigrateV2/index.tsx b/apps/web/src/pages/MigrateV2/index.tsx index 739809cd173..cbefdc76310 100644 --- a/apps/web/src/pages/MigrateV2/index.tsx +++ b/apps/web/src/pages/MigrateV2/index.tsx @@ -6,7 +6,6 @@ import { LightCard } from 'components/Card/cards' import { AutoColumn } from 'components/deprecated/Column' import MigrateSushiPositionCard from 'components/PositionCard/Sushi' import MigrateV2PositionCard from 'components/PositionCard/V2' -import { SwitchLocaleLink } from 'components/SwitchLocaleLink' import { Dots } from 'components/swap/styled' import { V2Unsupported } from 'components/V2Unsupported' import { useAccount } from 'hooks/useAccount' @@ -207,7 +206,7 @@ export default function MigrateV2() { - + {/* */} ) } diff --git a/apps/web/src/pages/Portfolio/Overview/hooks/useIsPortfolioZero.ts b/apps/web/src/pages/Portfolio/Overview/hooks/useIsPortfolioZero.ts index c4f16c0ed74..83a1c629089 100644 --- a/apps/web/src/pages/Portfolio/Overview/hooks/useIsPortfolioZero.ts +++ b/apps/web/src/pages/Portfolio/Overview/hooks/useIsPortfolioZero.ts @@ -1,5 +1,10 @@ +import { useCurrencyBalances } from 'lib/hooks/useCurrencyBalance' import { usePortfolioAddresses } from 'pages/Portfolio/hooks/usePortfolioAddresses' import { useMemo } from 'react' +import { useAccount } from 'hooks/useAccount' +import { getStablecoinsForChain } from 'uniswap/src/features/chains/utils' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { nativeOnChain } from 'uniswap/src/constants/tokens' import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' import { usePortfolioTotalValue } from 'uniswap/src/features/dataApi/balances/balancesRest' @@ -10,6 +15,25 @@ import { usePortfolioTotalValue } from 'uniswap/src/features/dataApi/balances/ba export function useIsPortfolioZero(): boolean { const portfolioAddresses = usePortfolioAddresses() const { isTestnetModeEnabled } = useEnabledChains() + const { chainId } = useAccount() + const isHashKeyChain = + chainId === UniverseChainId.HashKey || chainId === UniverseChainId.HashKeyTestnet + + const hskCurrencies = useMemo(() => { + if (!isHashKeyChain || !chainId) { + return undefined + } + + const nativeCurrency = nativeOnChain(chainId) + const stablecoins = getStablecoinsForChain(chainId) + return [nativeCurrency, ...stablecoins] + }, [chainId, isHashKeyChain]) + + const hskBalances = useCurrencyBalances(portfolioAddresses.evmAddress, hskCurrencies) + const hasNonZeroHskBalance = useMemo( + () => hskBalances.some((balance) => balance?.greaterThan(0)), + [hskBalances], + ) // Fetch portfolio total value to determine if portfolio is zero const { data: portfolioData } = usePortfolioTotalValue({ @@ -20,5 +44,11 @@ export function useIsPortfolioZero(): boolean { const { balanceUSD } = portfolioData || {} // Calculate isPortfolioZero - denominated portfolio balance on testnet is always 0 - return useMemo(() => !isTestnetModeEnabled && balanceUSD === 0, [isTestnetModeEnabled, balanceUSD]) + const defaultIsPortfolioZero = useMemo( + () => !isTestnetModeEnabled && balanceUSD === 0, + [isTestnetModeEnabled, balanceUSD], + ) + + // TODO(HSK): Replace on-chain fallback with a dedicated portfolio/price service for HashKey. + return isHashKeyChain ? !hasNonZeroHskBalance : defaultIsPortfolioZero } diff --git a/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemA.tsx b/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemA.tsx index b82a3bb3a1f..2be2ffa02f8 100644 --- a/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemA.tsx +++ b/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemA.tsx @@ -1,7 +1,7 @@ import { EmblemProps } from 'pages/Portfolio/components/AnimatedStyledBanner/Emblems/types' import { useSporeColors } from 'ui/src' -export function EmblemA({ fill = '#FF37C7', opacity = 1, ...props }: EmblemProps): JSX.Element { +export function EmblemA({ fill = '#4177e2', opacity = 1, ...props }: EmblemProps): JSX.Element { const colors = useSporeColors() return ( diff --git a/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemB.tsx b/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemB.tsx index 97a95e05515..174dd57da35 100644 --- a/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemB.tsx +++ b/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemB.tsx @@ -1,7 +1,7 @@ import { EmblemProps } from 'pages/Portfolio/components/AnimatedStyledBanner/Emblems/types' import { useSporeColors } from 'ui/src' -export function EmblemB({ fill = '#FF37C7', opacity = 1, ...props }: EmblemProps): JSX.Element { +export function EmblemB({ fill = '#4177e2', opacity = 1, ...props }: EmblemProps): JSX.Element { const colors = useSporeColors() return ( diff --git a/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemC.tsx b/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemC.tsx index 43409765e33..8e262158413 100644 --- a/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemC.tsx +++ b/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemC.tsx @@ -2,7 +2,7 @@ import { EmblemProps } from 'pages/Portfolio/components/AnimatedStyledBanner/Emb import { useId } from 'react' import { useSporeColors } from 'ui/src' -export function EmblemC({ fill = '#FF37C7', opacity = 1, ...props }: EmblemProps): JSX.Element { +export function EmblemC({ fill = '#4177e2', opacity = 1, ...props }: EmblemProps): JSX.Element { const colors = useSporeColors() const clipPathId = useId() diff --git a/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemD.tsx b/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemD.tsx index 6d2dce5ec4b..6494f47899c 100644 --- a/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemD.tsx +++ b/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemD.tsx @@ -1,7 +1,7 @@ import { EmblemProps } from 'pages/Portfolio/components/AnimatedStyledBanner/Emblems/types' import { useSporeColors } from 'ui/src' -export function EmblemD({ fill = '#FF37C7', opacity = 1, ...props }: EmblemProps): JSX.Element { +export function EmblemD({ fill = '#4177e2', opacity = 1, ...props }: EmblemProps): JSX.Element { const colors = useSporeColors() return ( diff --git a/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemE.tsx b/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemE.tsx index d07e8cff91b..a19e9e39be1 100644 --- a/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemE.tsx +++ b/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemE.tsx @@ -1,7 +1,7 @@ import { EmblemProps } from 'pages/Portfolio/components/AnimatedStyledBanner/Emblems/types' import { useSporeColors } from 'ui/src' -export function EmblemE({ fill = '#FF37C7', opacity = 1, ...props }: EmblemProps): JSX.Element { +export function EmblemE({ fill = '#4177e2', opacity = 1, ...props }: EmblemProps): JSX.Element { const colors = useSporeColors() return ( diff --git a/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemF.tsx b/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemF.tsx index f39c3cf8cb7..048afbf43d9 100644 --- a/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemF.tsx +++ b/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemF.tsx @@ -1,7 +1,7 @@ import { EmblemProps } from 'pages/Portfolio/components/AnimatedStyledBanner/Emblems/types' import { useSporeColors } from 'ui/src' -export function EmblemF({ fill = '#FF37C7', opacity = 1, ...props }: EmblemProps): JSX.Element { +export function EmblemF({ fill = '#4177e2', opacity = 1, ...props }: EmblemProps): JSX.Element { const colors = useSporeColors() return ( diff --git a/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemG.tsx b/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemG.tsx index d0e5263fc54..22d31f8b672 100644 --- a/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemG.tsx +++ b/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemG.tsx @@ -1,7 +1,7 @@ import { EmblemProps } from 'pages/Portfolio/components/AnimatedStyledBanner/Emblems/types' import { useSporeColors } from 'ui/src' -export function EmblemG({ fill = '#FF37C7', opacity = 1, ...props }: EmblemProps): JSX.Element { +export function EmblemG({ fill = '#4177e2', opacity = 1, ...props }: EmblemProps): JSX.Element { const colors = useSporeColors() return ( diff --git a/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemH.tsx b/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemH.tsx index 2568174c990..2a470109700 100644 --- a/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemH.tsx +++ b/apps/web/src/pages/Portfolio/components/AnimatedStyledBanner/Emblems/EmblemH.tsx @@ -1,7 +1,7 @@ import { EmblemProps } from 'pages/Portfolio/components/AnimatedStyledBanner/Emblems/types' import { useSporeColors } from 'ui/src' -export function EmblemH({ fill = '#FF37C7', opacity = 1, ...props }: EmblemProps): JSX.Element { +export function EmblemH({ fill = '#4177e2', opacity = 1, ...props }: EmblemProps): JSX.Element { const colors = useSporeColors() return ( diff --git a/apps/web/src/pages/Positions/TopPools.tsx b/apps/web/src/pages/Positions/TopPools.tsx index 92b14c1ebe2..789f9293745 100644 --- a/apps/web/src/pages/Positions/TopPools.tsx +++ b/apps/web/src/pages/Positions/TopPools.tsx @@ -5,12 +5,14 @@ import { ALL_NETWORKS_ARG } from '@universe/api' import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { ExternalArrowLink } from 'components/Liquidity/ExternalArrowLink' import { useAccount } from 'hooks/useAccount' +import { useHSKSubgraphPools } from 'hooks/useHSKSubgraphPools' import { TopPoolsSection } from 'pages/Positions/TopPoolsSection' import { useTranslation } from 'react-i18next' import { useTopPools } from 'state/explore/topPools' import { Flex, useMedia } from 'ui/src' import { useExploreStatsQuery } from 'uniswap/src/data/rest/exploreStats' import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { useMemo } from 'react' const MAX_BOOSTED_POOLS = 3 @@ -21,47 +23,84 @@ export function TopPools({ chainId }: { chainId: UniverseChainId | null }) { const media = useMedia() const isBelowXlScreen = !media.xl + // 使用 HSK Subgraph 获取所有 pools 数据(获取足够多的数量以确保获取全部) + // 注意:GraphQL 查询可能有限制,如果 pools 数量超过限制,可能需要分页获取 const { - data: exploreStatsData, - isLoading: exploreStatsLoading, - error: exploreStatsError, - } = useExploreStatsQuery({ - input: { chainId: chainId ? chainId.toString() : ALL_NETWORKS_ARG }, - }) + data: hskPools, + isLoading: hskPoolsLoading, + error: hskPoolsError, + } = useHSKSubgraphPools(1000) // 获取足够多的 pools(1000 应该足够覆盖所有 pools) - const { topPools, topBoostedPools } = useTopPools({ - topPoolData: { data: exploreStatsData, isLoading: exploreStatsLoading, isError: !!exploreStatsError }, - sortState: { sortDirection: OrderDirection.Desc, sortBy: PoolSortFields.TVL }, - }) + // 按 TVL 排序 pools + const sortedHSKPools = useMemo(() => { + if (!hskPools) { + return [] + } + const sorted = [...hskPools].sort((a, b) => { + // totalLiquidity.value 是 number 类型 + const tvlA = typeof a.totalLiquidity?.value === 'number' + ? a.totalLiquidity.value + : parseFloat(String(a.totalLiquidity?.value || '0')) + const tvlB = typeof b.totalLiquidity?.value === 'number' + ? b.totalLiquidity.value + : parseFloat(String(b.totalLiquidity?.value || '0')) + return tvlB - tvlA + }) + return sorted + }, [hskPools]) - const displayBoostedPools = - topBoostedPools && topBoostedPools.length > 0 && Boolean(account.address) && isLPIncentivesEnabled + // 屏蔽原有的 explore stats 查询 + // const { + // data: exploreStatsData, + // isLoading: exploreStatsLoading, + // error: exploreStatsError, + // } = useExploreStatsQuery({ + // input: { chainId: chainId ? chainId.toString() : ALL_NETWORKS_ARG }, + // }) + + // const { topPools: exploreTopPools, topBoostedPools } = useTopPools({ + // topPoolData: { data: exploreStatsData, isLoading: exploreStatsLoading, isError: !!exploreStatsError }, + // sortState: { sortDirection: OrderDirection.Desc, sortBy: PoolSortFields.TVL }, + // }) + + // 只使用 HSK Subgraph 数据(所有 pools 都是 V3) + // 按 TVL 排序后,只取前 6 个 + const topPools = useMemo(() => { + return sortedHSKPools.slice(0, 6) + }, [sortedHSKPools]) + const isLoading = hskPoolsLoading + const hasError = !!hskPoolsError + + // 屏蔽 boosted pools(因为我们已经屏蔽了原有的 explore stats) + const displayBoostedPools = false // HSK Subgraph 数据不包含 boosted pools const displayTopPools = topPools && topPools.length > 0 - if (!isBelowXlScreen) { - return null - } + // 临时:即使屏幕很大也显示,用于调试 + // if (!isBelowXlScreen) { + // return null + // } return ( - {displayBoostedPools && ( + {/* {displayBoostedPools && ( {t('explore.more.unichain')} - )} + )} */} {displayTopPools && ( - - + + {/* 隐藏 explore more pools 链接 */} + {/* {t('explore.more.pools')} - + */} )} diff --git a/apps/web/src/pages/Positions/TopPoolsCard.tsx b/apps/web/src/pages/Positions/TopPoolsCard.tsx index 6e0411f48bb..87b6efe2a68 100644 --- a/apps/web/src/pages/Positions/TopPoolsCard.tsx +++ b/apps/web/src/pages/Positions/TopPoolsCard.tsx @@ -1,4 +1,5 @@ import { gqlToCurrency, supportedChainIdFromGQLChain, unwrapToken } from 'appGraphql/data/util' +import { Percent } from '@uniswap/sdk-core' import { GraphQLApi } from '@universe/api' import { LiquidityPositionInfoBadges } from 'components/Liquidity/LiquidityPositionInfoBadges' import { LPIncentiveRewardsBadge } from 'components/Liquidity/LPIncentives/LPIncentiveRewardsBadge' @@ -7,7 +8,6 @@ import { Trans } from 'react-i18next' import { PoolStat } from 'state/explore/types' import { Flex, Text } from 'ui/src' import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' -import { toGraphQLChain } from 'uniswap/src/features/chains/utils' import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' export function TopPoolsCard({ pool }: { pool: PoolStat }) { @@ -15,11 +15,66 @@ export function TopPoolsCard({ pool }: { pool: PoolStat }) { const { formatPercent } = useLocalizationContext() const chainId = supportedChainIdFromGQLChain(pool.chain as GraphQLApi.Chain) ?? defaultChainId + + // 尝试使用 gqlToCurrency,如果失败则直接使用 pool 中的 symbol const token0 = pool.token0 ? gqlToCurrency(unwrapToken(chainId, pool.token0)) : undefined const token1 = pool.token1 ? gqlToCurrency(unwrapToken(chainId, pool.token1)) : undefined + // 如果 gqlToCurrency 返回 undefined(比如数据来自 HSK subgraph),直接使用 pool 中的 symbol + const token0Symbol = token0?.symbol || pool.token0?.symbol || '--' + const token1Symbol = token1?.symbol || pool.token1?.symbol || '--' + const formattedApr = pool.boostedApr ? formatPercent(pool.boostedApr) : null + // 安全地格式化 APR:检查 pool.apr 是否是 Percent 对象,如果是则使用 toFixed,否则转换为数字 + const formatApr = (apr: Percent | any): string => { + if (!apr) { + return formatPercent(0) + } + // 检查是否有 toFixed 方法(Percent 对象应该有) + if (typeof apr.toFixed === 'function') { + return formatPercent(apr.toFixed(3)) + } + // 如果没有 toFixed 方法,尝试从 numerator 和 denominator 计算 + // Percent 对象有 numerator 和 denominator 属性 + // 注意:protobuf 序列化后,numerator 和 denominator 可能是数组(JSBI 序列化) + if ('numerator' in apr && 'denominator' in apr) { + let numValue: number + let denValue: number + + // 处理数组格式(protobuf 序列化后的 JSBI) + if (Array.isArray(apr.numerator)) { + // JSBI 序列化为数组,需要转换为数字 + // 空数组表示 0 + if (apr.numerator.length === 0) { + numValue = 0 + } else { + // JSBI 数组格式:[sign, ...digits],sign 是 1 或 -1 + // 简化处理:如果是空数组或只有一个元素,直接使用 + numValue = apr.numerator.length > 0 ? Number(apr.numerator[0] || 0) : 0 + } + } else { + numValue = Number(apr.numerator) || 0 + } + + if (Array.isArray(apr.denominator)) { + if (apr.denominator.length === 0) { + denValue = 1 + } else { + denValue = Number(apr.denominator[0] || 1) + } + } else { + denValue = Number(apr.denominator) || 1 + } + + // 计算百分比:Percent 是 numerator/denominator,转换为百分比需要 * 100 + const percentValue = denValue > 0 ? (numValue / denValue) * 100 : 0 + return formatPercent(percentValue.toFixed(3)) + } + // 最后的回退:尝试直接转换为数字 + return formatPercent(Number(apr) || 0) + } + return ( - {token0?.symbol} / {token1?.symbol} + {token0Symbol} / {token1Symbol} @@ -49,7 +106,7 @@ export function TopPoolsCard({ pool }: { pool: PoolStat }) { - {formatPercent(pool.apr.toFixed(3))} + {formatApr(pool.apr)} {formattedApr && } diff --git a/apps/web/src/pages/Positions/TopPoolsSection.tsx b/apps/web/src/pages/Positions/TopPoolsSection.tsx index 629349ad0be..ceb7b3e529e 100644 --- a/apps/web/src/pages/Positions/TopPoolsSection.tsx +++ b/apps/web/src/pages/Positions/TopPoolsSection.tsx @@ -18,6 +18,10 @@ export function TopPoolsSection({ pools, title, isLoading }: { pools: PoolStat[] ) } + if (!pools || pools.length === 0) { + return null + } + return ( {title} diff --git a/apps/web/src/pages/Positions/index.tsx b/apps/web/src/pages/Positions/index.tsx index 795d43a7e94..1b8d67fd5a0 100644 --- a/apps/web/src/pages/Positions/index.tsx +++ b/apps/web/src/pages/Positions/index.tsx @@ -1,20 +1,16 @@ /* eslint-disable max-lines */ import { PositionStatus, ProtocolVersion } from '@uniswap/client-data-api/dist/data/v1/poolTypes_pb' import { FeatureFlags, useFeatureFlag } from '@universe/gating' -import PROVIDE_LIQUIDITY from 'assets/images/provideLiquidity.png' import tokenLogo from 'assets/images/token-logo.png' -import V4_HOOK from 'assets/images/v4Hooks.png' import { ExpandoRow } from 'components/AccountDrawer/MiniPortfolio/ExpandoRow' -import { useAccountDrawer } from 'components/AccountDrawer/MiniPortfolio/hooks' import { MenuStateVariant, useSetMenu } from 'components/AccountDrawer/menuState' -import { ExternalArrowLink } from 'components/Liquidity/ExternalArrowLink' import { LiquidityPositionCard, LiquidityPositionCardLoader } from 'components/Liquidity/LiquidityPositionCard' import { LpIncentiveClaimModal } from 'components/Liquidity/LPIncentives/LpIncentiveClaimModal' -import LpIncentiveRewardsCard from 'components/Liquidity/LPIncentives/LpIncentiveRewardsCard' import { PositionsHeader } from 'components/Liquidity/PositionsHeader' import { PositionInfo } from 'components/Liquidity/types' import { getPositionUrl } from 'components/Liquidity/utils/getPositionUrl' import { parseRestPosition } from 'components/Liquidity/utils/parseFromRest' +import { useAppKit } from 'components/Web3Provider/reownConfig' import { useAccount } from 'hooks/useAccount' import { useLpIncentives } from 'hooks/useLpIncentives' import { atom, useAtom } from 'jotai' @@ -27,11 +23,8 @@ import { usePendingLPTransactionsChangeListener } from 'state/transactions/hooks import { useRequestPositionsForSavedPairs } from 'state/user/hooks' import { ClickableTamaguiStyle } from 'theme/components/styles' import { Anchor, Button, Flex, Text, useMedia } from 'ui/src' -import { CloseIconWithHover } from 'ui/src/components/icons/CloseIconWithHover' -import { InfoCircleFilled } from 'ui/src/components/icons/InfoCircleFilled' import { Pools } from 'ui/src/components/icons/Pools' import { Wallet } from 'ui/src/components/icons/Wallet' -import { uniswapUrls } from 'uniswap/src/constants/urls' import { useGetPositionsInfiniteQuery } from 'uniswap/src/data/rest/getPositions' import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' import { UniverseChainId } from 'uniswap/src/features/chains/types' @@ -50,7 +43,7 @@ const PAGE_SIZE = 25 function DisconnectedWalletView() { const { t } = useTranslation() - const accountDrawer = useAccountDrawer() + const { open } = useAppKit() const setMenu = useSetMenu() const connectedWithoutEVM = useIsMissingPlatformWallet(Platform.EVM) @@ -58,7 +51,7 @@ function DisconnectedWalletView() { if (connectedWithoutEVM) { setMenu({ variant: MenuStateVariant.CONNECT_PLATFORM, platform: Platform.EVM }) } - accountDrawer.open() + open({ view: 'Connect' }) } return ( @@ -90,7 +83,7 @@ function DisconnectedWalletView() { size="small" emphasis="secondary" tag="a" - href="/positions/create/v4" + href="/positions/create/v3" $platform-web={{ textDecoration: 'none', }} @@ -103,7 +96,8 @@ function DisconnectedWalletView() { - + {/* 未连接钱包时隐藏 "providing liquidity" 和 "hooks on v4" 内容 */} + {/* - + */} ) } @@ -163,7 +157,7 @@ function EmptyPositionsView() { variant="default" size="small" tag="a" - href="/positions/create/v4" + href="/positions/create/v3" $platform-web={{ textDecoration: 'none', }} @@ -214,8 +208,9 @@ function LearnMoreTile({ ) } -const chainFilterAtom = atom(null) -const versionFilterAtom = atom([ProtocolVersion.V4, ProtocolVersion.V3, ProtocolVersion.V2]) +// 本期只做 V3 基础添加流动性 - 默认筛选 HashKey Chain + V3 +const chainFilterAtom = atom(UniverseChainId.HashKey) +const versionFilterAtom = atom([ProtocolVersion.V3]) const statusFilterAtom = atom([PositionStatus.IN_RANGE, PositionStatus.OUT_OF_RANGE]) function VirtualizedPositionsList({ @@ -263,7 +258,7 @@ function VirtualizedPositionsList({ @@ -402,7 +397,7 @@ export default function Pool() { $lg={{ px: '$spacing20' }} > - {isLPIncentivesEnabled && ( + {/* {isLPIncentivesEnabled && ( { @@ -412,8 +407,8 @@ export default function Pool() { setTokenRewards={setTokenRewards} initialHasCollectedRewards={hasCollectedRewards} /> - )} - + )} */} + )} - {!statusFilter.includes(PositionStatus.CLOSED) && !closedCTADismissed && account.address && ( + {/* {!statusFilter.includes(PositionStatus.CLOSED) && !closedCTADismissed && account.address && ( setClosedCTADismissed(true)} size="$icon.20" /> - )} - {isConnected && ( + )} */} + {/* 注释掉 Pool Finder 入口 - 本期只做基础添加流动性 */} + {/* {isConnected && ( {t('pool.import.link.description')} @@ -505,11 +501,11 @@ export default function Pool() { - )} + )} */} - {isConnected && ( + {/* {isConnected && ( {t('liquidity.learnMoreLabel')} @@ -528,7 +524,7 @@ export default function Pool() { {t('common.button.learn')} - )} + )} */} {isLPIncentivesEnabled && ( diff --git a/apps/web/src/pages/RouteDefinitions.tsx b/apps/web/src/pages/RouteDefinitions.tsx index 48a0eca12b2..8c189b1a0e8 100644 --- a/apps/web/src/pages/RouteDefinitions.tsx +++ b/apps/web/src/pages/RouteDefinitions.tsx @@ -3,7 +3,6 @@ import { getExploreDescription, getExploreTitle } from 'pages/getExploreTitle' import { getPortfolioDescription, getPortfolioTitle } from 'pages/getPortfolioTitle' import { getAddLiquidityPageTitle, getPositionPageDescription, getPositionPageTitle } from 'pages/getPositionPageTitle' // High-traffic pages (index and /swap) should not be lazy-loaded. -import Landing from 'pages/Landing' import Swap from 'pages/Swap' import { lazy, ReactNode, Suspense, useMemo } from 'react' import { matchPath, Navigate, Route, Routes, useLocation } from 'react-router' @@ -127,15 +126,17 @@ export const routes: RouteDefinition[] = [ getTitle: () => StaticTitlesAndDescriptions.UniswapTitle, getDescription: () => StaticTitlesAndDescriptions.SwapDescription, getElement: (args) => { - return args.browserRouterEnabled && args.hash ? : + return args.browserRouterEnabled && args.hash ? : }, }), + // HKSWAP: Disabled explore routes createRouteDefinition({ path: '/explore', getTitle: getExploreTitle, getDescription: getExploreDescription, nestedPaths: [':tab', ':chainName', ':tab/:chainName'], getElement: () => , + enabled: () => false, // HKSWAP: Disable explore page }), // Special case: redirect WSOL to SOL TDP, as directly trading WSOL is not supported currently. createRouteDefinition({ @@ -143,6 +144,7 @@ export const routes: RouteDefinition[] = [ getTitle: () => i18n.t('common.buyAndSell'), getDescription: () => StaticTitlesAndDescriptions.TDPDescription, getElement: () => , + enabled: () => false, // HKSWAP: Disable explore routes }), createRouteDefinition({ path: '/explore/tokens/:chainName/:tokenAddress', @@ -153,24 +155,28 @@ export const routes: RouteDefinition[] = [ ), + enabled: () => false, // HKSWAP: Disable explore routes }), createRouteDefinition({ path: '/tokens', getTitle: getExploreTitle, getDescription: getExploreDescription, getElement: () => , + enabled: () => false, // HKSWAP: Disable tokens routes (redirect to explore) }), createRouteDefinition({ path: '/tokens/:chainName', getTitle: getExploreTitle, getDescription: getExploreDescription, getElement: () => , + enabled: () => false, // HKSWAP: Disable tokens routes (redirect to explore) }), createRouteDefinition({ path: '/tokens/:chainName/:tokenAddress', getTitle: () => StaticTitlesAndDescriptions.DetailsPageBaseTitle, getDescription: () => StaticTitlesAndDescriptions.TDPDescription, getElement: () => , + enabled: () => false, // HKSWAP: Disable tokens routes (redirect to explore) }), createRouteDefinition({ path: '/explore/pools/:chainName/:poolAddress', @@ -181,6 +187,7 @@ export const routes: RouteDefinition[] = [ ), + enabled: () => false, // HKSWAP: Disable explore pools routes }), createRouteDefinition({ path: '/explore/auctions/:chainName/:id', @@ -217,40 +224,41 @@ export const routes: RouteDefinition[] = [ getDescription: () => i18n.t('title.createGovernanceTo'), getElement: () => , }), - createRouteDefinition({ - path: '/buy', - getElement: () => , - getTitle: () => StaticTitlesAndDescriptions.SwapTitle, - }), - createRouteDefinition({ - path: '/sell', - getElement: () => , - getTitle: () => StaticTitlesAndDescriptions.SwapTitle, - }), + // createRouteDefinition({ + // path: '/buy', + // getElement: () => , + // getTitle: () => StaticTitlesAndDescriptions.SwapTitle, + // }), + // createRouteDefinition({ + // path: '/sell', + // getElement: () => , + // getTitle: () => StaticTitlesAndDescriptions.SwapTitle, + // }), createRouteDefinition({ path: '/send', getElement: () => , getTitle: () => i18n.t('title.sendTokens'), }), - createRouteDefinition({ - path: '/limits', - getElement: () => , - getTitle: () => i18n.t('title.placeLimit'), - }), - createRouteDefinition({ - path: '/limit', - getElement: () => , - getTitle: () => i18n.t('title.placeLimit'), - }), - createRouteDefinition({ - path: '/buy', - getElement: () => , - getTitle: () => StaticTitlesAndDescriptions.SwapTitle, - }), + // createRouteDefinition({ + // path: '/limits', + // getElement: () => , + // getTitle: () => i18n.t('title.placeLimit'), + // }), + // createRouteDefinition({ + // path: '/limit', + // getElement: () => , + // getTitle: () => i18n.t('title.placeLimit'), + // }), + // createRouteDefinition({ + // path: '/buy', + // getElement: () => , + // getTitle: () => StaticTitlesAndDescriptions.SwapTitle, + // }), createRouteDefinition({ path: '/swap', getElement: () => , getTitle: () => StaticTitlesAndDescriptions.SwapTitle, + getDescription: () => StaticTitlesAndDescriptions.SwapDescription, }), // Refreshed pool routes createRouteDefinition({ @@ -284,18 +292,19 @@ export const routes: RouteDefinition[] = [ getTitle: getPositionPageTitle, getDescription: getPositionPageDescription, }), - createRouteDefinition({ - path: '/migrate/v2/:chainName/:pairAddress', - getElement: () => , - getTitle: () => StaticTitlesAndDescriptions.MigrateTitle, - getDescription: () => StaticTitlesAndDescriptions.MigrateDescription, - }), - createRouteDefinition({ - path: '/migrate/v3/:chainName/:tokenId', - getElement: () => , - getTitle: () => StaticTitlesAndDescriptions.MigrateTitleV3, - getDescription: () => StaticTitlesAndDescriptions.MigrateDescriptionV4, - }), + // 注释掉迁移路由 - 本期不做迁移功能 + // createRouteDefinition({ + // path: '/migrate/v2/:chainName/:pairAddress', + // getElement: () => , + // getTitle: () => StaticTitlesAndDescriptions.MigrateTitle, + // getDescription: () => StaticTitlesAndDescriptions.MigrateDescription, + // }), + // createRouteDefinition({ + // path: '/migrate/v3/:chainName/:tokenId', + // getElement: () => , + // getTitle: () => StaticTitlesAndDescriptions.MigrateTitleV3, + // getDescription: () => StaticTitlesAndDescriptions.MigrateDescriptionV4, + // }), // Legacy pool routes createRouteDefinition({ path: '/pool', @@ -303,12 +312,13 @@ export const routes: RouteDefinition[] = [ getTitle: getPositionPageTitle, getDescription: getPositionPageDescription, }), - createRouteDefinition({ - path: '/pool/v2/find', - getElement: () => , - getTitle: getPositionPageDescription, - getDescription: getPositionPageDescription, - }), + // 注释掉 Pool Finder 路由 - 本期只做基础添加流动性 + // createRouteDefinition({ + // path: '/pool/v2/find', + // getElement: () => , + // getTitle: getPositionPageDescription, + // getDescription: getPositionPageDescription, + // }), createRouteDefinition({ path: '/pool/v2', getElement: () => , @@ -321,12 +331,13 @@ export const routes: RouteDefinition[] = [ getTitle: getPositionPageTitle, getDescription: getPositionPageDescription, }), - createRouteDefinition({ - path: '/pools/v2/find', - getElement: () => , - getTitle: getPositionPageTitle, - getDescription: getPositionPageDescription, - }), + // 注释掉 Pool Finder 路由 - 本期只做基础添加流动性 + // createRouteDefinition({ + // path: '/pools/v2/find', + // getElement: () => , + // getTitle: getPositionPageTitle, + // getDescription: getPositionPageDescription, + // }), createRouteDefinition({ path: '/pools', getElement: () => , @@ -339,13 +350,14 @@ export const routes: RouteDefinition[] = [ getTitle: getPositionPageTitle, getDescription: getPositionPageDescription, }), - createRouteDefinition({ - path: '/add/v2', - nestedPaths: [':currencyIdA', ':currencyIdA/:currencyIdB'], - getElement: () => , - getTitle: getAddLiquidityPageTitle, - getDescription: () => StaticTitlesAndDescriptions.AddLiquidityDescription, - }), + // 注释掉 V2 添加流动性路由 - 本期只做 V3 基础添加流动性 + // createRouteDefinition({ + // path: '/add/v2', + // nestedPaths: [':currencyIdA', ':currencyIdA/:currencyIdB'], + // getElement: () => , + // getTitle: getAddLiquidityPageTitle, + // getDescription: () => StaticTitlesAndDescriptions.AddLiquidityDescription, + // }), createRouteDefinition({ path: '/add', nestedPaths: [ @@ -370,18 +382,19 @@ export const routes: RouteDefinition[] = [ getTitle: () => i18n.t('title.removePoolLiquidity'), getDescription: () => i18n.t('title.removev3Liquidity'), }), - createRouteDefinition({ - path: '/migrate/v2', - getElement: () => , - getTitle: () => StaticTitlesAndDescriptions.MigrateTitle, - getDescription: () => StaticTitlesAndDescriptions.MigrateDescription, - }), - createRouteDefinition({ - path: '/migrate/v2/:address', - getElement: () => , - getTitle: () => StaticTitlesAndDescriptions.MigrateTitle, - getDescription: () => StaticTitlesAndDescriptions.MigrateDescription, - }), + // 注释掉 V2 迁移路由 - 本期只做 V3 基础添加流动性 + // createRouteDefinition({ + // path: '/migrate/v2', + // getElement: () => , + // getTitle: () => StaticTitlesAndDescriptions.MigrateTitle, + // getDescription: () => StaticTitlesAndDescriptions.MigrateDescription, + // }), + // createRouteDefinition({ + // path: '/migrate/v2/:address', + // getElement: () => , + // getTitle: () => StaticTitlesAndDescriptions.MigrateTitle, + // getDescription: () => StaticTitlesAndDescriptions.MigrateDescription, + // }), createRouteDefinition({ path: EXTENSION_PASSKEY_AUTH_PATH, getElement: () => , diff --git a/apps/web/src/pages/Swap/Buy/BuyFormButton.tsx b/apps/web/src/pages/Swap/Buy/BuyFormButton.tsx index 0484223e100..16032c51fd3 100644 --- a/apps/web/src/pages/Swap/Buy/BuyFormButton.tsx +++ b/apps/web/src/pages/Swap/Buy/BuyFormButton.tsx @@ -1,4 +1,4 @@ -import { useAccountDrawer } from 'components/AccountDrawer/MiniPortfolio/hooks' +import { useAppKit } from 'components/Web3Provider/reownConfig' import { useConnectionStatus } from 'features/accounts/store/hooks' import { useBuyFormContext } from 'pages/Swap/Buy/BuyFormContext' import { useTranslation } from 'react-i18next' @@ -14,7 +14,7 @@ interface BuyFormButtonProps { export function BuyFormButton({ forceDisabled }: BuyFormButtonProps) { const isDisconnected = useConnectionStatus('aggregate').isDisconnected - const accountDrawer = useAccountDrawer() + const { open } = useAppKit() const { t } = useTranslation() const isShortMobileDevice = useIsShortMobileDevice() @@ -29,7 +29,7 @@ export function BuyFormButton({ forceDisabled }: BuyFormButtonProps) { if (isDisconnected || isMissingPlatformWallet) { return ( - - - - + <> + + + + + + + + + + + ) } diff --git a/packages/uniswap/src/features/transactions/swap/components/SwapFormButton/hooks/useIsPortfolioZero.ts b/packages/uniswap/src/features/transactions/swap/components/SwapFormButton/hooks/useIsPortfolioZero.ts index 8d4d60fa9ed..7e4c493c6bc 100644 --- a/packages/uniswap/src/features/transactions/swap/components/SwapFormButton/hooks/useIsPortfolioZero.ts +++ b/packages/uniswap/src/features/transactions/swap/components/SwapFormButton/hooks/useIsPortfolioZero.ts @@ -1,14 +1,15 @@ import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' -import { usePortfolioTotalValue } from 'uniswap/src/features/dataApi/balances/balancesRest' import { useWallet } from 'uniswap/src/features/wallet/hooks/useWallet' +import { useOnChainPortfolioTotalValue } from 'uniswap/src/features/transactions/swap/components/SwapFormButton/hooks/useOnChainPortfolioTotalValue' export function useIsPortfolioZero(): boolean { const wallet = useWallet() const { isTestnetModeEnabled } = useEnabledChains() - const { data } = usePortfolioTotalValue({ + // 使用链上查询替代 Uniswap GetPortfolio API + const { balanceUSD, loading, error } = useOnChainPortfolioTotalValue({ evmAddress: wallet.evmAccount?.address, svmAddress: wallet.svmAccount?.address, }) - return !isTestnetModeEnabled && data?.balanceUSD === 0 + return !isTestnetModeEnabled && balanceUSD === 0 } diff --git a/packages/uniswap/src/features/transactions/swap/components/SwapFormButton/hooks/useOnChainPortfolioTotalValue.ts b/packages/uniswap/src/features/transactions/swap/components/SwapFormButton/hooks/useOnChainPortfolioTotalValue.ts new file mode 100644 index 00000000000..d4f01fdf264 --- /dev/null +++ b/packages/uniswap/src/features/transactions/swap/components/SwapFormButton/hooks/useOnChainPortfolioTotalValue.ts @@ -0,0 +1,132 @@ +import { useQuery } from '@tanstack/react-query' +import { useMemo } from 'react' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { filterChainIdsByPlatform } from 'uniswap/src/features/chains/utils' +import { Platform } from 'uniswap/src/features/platforms/types/Platform' +import { createEthersProvider } from 'uniswap/src/features/providers/createEthersProvider' +import { getSolanaConnection } from 'uniswap/src/features/providers/getSolanaConnection' +import { SOLANA_ONCHAIN_BALANCE_COMMITMENT } from 'uniswap/src/data/solanaConnection/getSolanaParsedTokenAccountsByOwnerQueryOptions' +import { PublicKey } from '@solana/web3.js' +import { ReactQueryCacheKey } from 'utilities/src/reactQuery/cache' +import { logger } from 'utilities/src/logger/logger' +import type { Address } from 'utilities/src/addresses/types' + +interface OnChainPortfolioTotalValueResult { + balanceUSD: number + loading: boolean + error: Error | undefined +} + +/** + * 使用链上查询获取投资组合总价值(USD) + * 替代 Uniswap GetPortfolio API,避免 CORS 问题和外部依赖 + * + * 当前实现:查询所有启用链的原生代币余额 + * 如果所有链的原生代币余额都为 0,则认为投资组合为 0 + */ +export function useOnChainPortfolioTotalValue({ + evmAddress, + svmAddress, +}: { + evmAddress?: Address + svmAddress?: Address +}): OnChainPortfolioTotalValueResult { + const { chains: enabledChainIds, isTestnetModeEnabled } = useEnabledChains() + + // 过滤出 EVM 和 SVM 链 + const evmChainIds = useMemo( + () => filterChainIdsByPlatform(enabledChainIds, Platform.EVM), + [enabledChainIds] + ) + const svmChainIds = useMemo( + () => filterChainIdsByPlatform(enabledChainIds, Platform.SVM), + [enabledChainIds] + ) + + // 查询所有链的原生代币余额 + const { data, isLoading, error } = useQuery({ + queryKey: [ + ReactQueryCacheKey.OnchainPortfolioTotalValue, + evmAddress, + svmAddress, + evmChainIds, + svmChainIds, + isTestnetModeEnabled, + ], + queryFn: async (): Promise<{ balanceUSD: number; hasAnyBalance: boolean }> => { + if (!evmAddress && !svmAddress) { + return { balanceUSD: 0, hasAnyBalance: false } + } + + // 并行查询所有 EVM 链的原生代币余额 + const evmBalancePromises = evmChainIds.map(async (chainId): Promise => { + if (!evmAddress) return BigInt(0) + + try { + const provider = createEthersProvider({ chainId }) + if (!provider) { + return BigInt(0) + } + const balance = await provider.getBalance(evmAddress) + return BigInt(balance.toString()) + } catch (error) { + logger.warn('useOnChainPortfolioTotalValue', 'queryFn', `Failed to fetch balance for chain ${chainId}`, { + error, + chainId, + address: evmAddress, + }) + return BigInt(0) + } + }) + + // 并行查询所有 SVM 链的原生代币余额 + const svmBalancePromises = svmChainIds.map(async (chainId): Promise => { + if (!svmAddress) return BigInt(0) + + try { + const connection = getSolanaConnection() + if (!connection) { + return BigInt(0) + } + const balance = await connection.getBalance(new PublicKey(svmAddress), SOLANA_ONCHAIN_BALANCE_COMMITMENT) + return BigInt(balance.toString()) + } catch (error) { + logger.warn('useOnChainPortfolioTotalValue', 'queryFn', `Failed to fetch balance for chain ${chainId}`, { + error, + chainId, + address: svmAddress, + }) + return BigInt(0) + } + }) + + // 等待所有查询完成 + const [evmBalances, svmBalances] = await Promise.all([ + Promise.all(evmBalancePromises), + Promise.all(svmBalancePromises), + ]) + + // 检查是否有任何非零余额 + const allBalances = [...evmBalances, ...svmBalances] + const hasAnyBalance = allBalances.some((balance) => balance > BigInt(0)) + + // 如果测试网模式启用,或者有任何余额,返回非零值 + // 否则返回 0(表示投资组合为空) + const balanceUSD = isTestnetModeEnabled || hasAnyBalance ? 1 : 0 + + return { balanceUSD, hasAnyBalance } + }, + enabled: !!(evmAddress || svmAddress) && (evmChainIds.length > 0 || svmChainIds.length > 0), + staleTime: 30 * 1000, // 30 秒 + refetchInterval: 60 * 1000, // 60 秒刷新一次 + }) + + return useMemo( + () => ({ + balanceUSD: data?.balanceUSD ?? 0, + loading: isLoading, + error: error instanceof Error ? error : error ? new Error(String(error)) : undefined, + }), + [data?.balanceUSD, isLoading, error] + ) +} diff --git a/packages/uniswap/src/features/transactions/swap/components/SwapFormButton/hooks/useSwapFormButtonText.ts b/packages/uniswap/src/features/transactions/swap/components/SwapFormButton/hooks/useSwapFormButtonText.ts index be565d133cc..9d10dce2ffa 100644 --- a/packages/uniswap/src/features/transactions/swap/components/SwapFormButton/hooks/useSwapFormButtonText.ts +++ b/packages/uniswap/src/features/transactions/swap/components/SwapFormButton/hooks/useSwapFormButtonText.ts @@ -42,49 +42,29 @@ export const useSwapFormButtonText = (): string => { if (swapRedirectCallback) { return t('common.getStarted') - } - - if (isWebFORNudgeEnabled) { + } else if (isWebFORNudgeEnabled) { return t('empty.swap.button.text') - } - - if (isIndicative) { + } else if (isIndicative) { return t('swap.finalizingQuote') - } - - if (isDisconnected) { + } else if (isDisconnected) { return isLogIn ? t('nav.logIn.button') : t('common.connectWallet.button') - } - - if (isMissingPlatformWallet) { + } else if (isMissingPlatformWallet) { return t('common.connectTo', { platform: isSVMChain(chainId) ? 'Solana' : 'Ethereum' }) - } - - if (blockingWarning?.buttonText) { + } else if (blockingWarning?.buttonText) { return blockingWarning.buttonText - } - - if (isTokenSelectionInvalid) { + } else if (isTokenSelectionInvalid) { return t('common.selectToken.label') - } - - if (isAmountSelectionInvalid) { + } else if (isAmountSelectionInvalid) { return t('common.noAmount.error') - } - - if (insufficientBalanceWarning) { + } else if (insufficientBalanceWarning) { return t('common.insufficientTokenBalance.error.simple', { tokenSymbol: currencies[CurrencyField.INPUT]?.currency.symbol ?? '', }) - } - - if (insufficientGasFundsWarning) { + } else if (insufficientGasFundsWarning) { return t('common.insufficientTokenBalance.error.simple', { tokenSymbol: nativeCurrency.symbol ?? '' }) - } - - if (isWrap) { + } else if (isWrap) { return getActionText({ t, wrapType }) } - return t('swap.button.review') + return t('empty.swap.button.text') } diff --git a/packages/uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/constants.ts b/packages/uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/constants.ts index 7d397ff058a..29547fef9eb 100644 --- a/packages/uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/constants.ts +++ b/packages/uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/constants.ts @@ -18,6 +18,14 @@ export const CHAIN_TO_UNIVERSAL_ROUTER_ADDRESS: Partial - + {!isWebApp && /* Interface renders its own header with multiple tabs */} - {!hideSettings && } + {/* Original Swap+Settings button moved to swapblock position below */} + {/* {!hideSettings && } */} {!hideContent && ( - + )} @@ -77,18 +83,48 @@ export function SwapFormScreen({ ) } -function SwapFormContent(): JSX.Element { +function SwapFormContent({ + hideSettings, + filteredSettings, + isBridgeTrade, +}: { + hideSettings: boolean + filteredSettings: TransactionSettingConfig[] + isBridgeTrade: boolean +}): JSX.Element { const { trade, isCrossChain } = useSwapFormScreenStore((state) => ({ trade: state.trade, isCrossChain: state.isCrossChain, })) const priceUXEnabled = usePriceUXEnabled() + const { autoSlippageTolerance } = useSlippageSettings() return ( - + {/* Original Swap+Settings button moved here to occupy space within the border */} + {!hideSettings && ( + <> + + {/* Swap text */} + + Swap + + {/* Settings button */} + + + {/* Border below Swap+Settings with 8px left/right extension */} + + + )} + diff --git a/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen/SwapFormScreenDetails/SwapFormScreenDetails.web.tsx b/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen/SwapFormScreenDetails/SwapFormScreenDetails.web.tsx index 2b812afd7ea..9f29e18a0f6 100644 --- a/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen/SwapFormScreenDetails/SwapFormScreenDetails.web.tsx +++ b/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen/SwapFormScreenDetails/SwapFormScreenDetails.web.tsx @@ -1,17 +1,17 @@ import { Accordion, Flex } from 'ui/src' import { SwapFormButton } from 'uniswap/src/features/transactions/swap/components/SwapFormButton/SwapFormButton' -import { ExpandableRows } from 'uniswap/src/features/transactions/swap/form/SwapFormScreen/SwapFormScreenDetails/ExpandableRows' -import { SwapFormScreenFooter } from 'uniswap/src/features/transactions/swap/form/SwapFormScreen/SwapFormScreenDetails/SwapFormScreenFooter/SwapFormScreenFooter' +// import { ExpandableRows } from 'uniswap/src/features/transactions/swap/form/SwapFormScreen/SwapFormScreenDetails/ExpandableRows' +// import { SwapFormScreenFooter } from 'uniswap/src/features/transactions/swap/form/SwapFormScreen/SwapFormScreenDetails/SwapFormScreenFooter/SwapFormScreenFooter' import { SwapFormWarningModals } from 'uniswap/src/features/transactions/swap/form/SwapFormScreen/SwapFormWarningModals/SwapFormWarningModals' import { useSwapFormScreenStore } from 'uniswap/src/features/transactions/swap/form/stores/swapFormScreenStore/useSwapFormScreenStore' import { SwapFormWarningStoreContextProvider } from 'uniswap/src/features/transactions/swap/form/stores/swapFormWarningStore/SwapFormWarningStoreContextProvider' -import { usePriceUXEnabled } from 'uniswap/src/features/transactions/swap/hooks/usePriceUXEnabled' +// import { usePriceUXEnabled } from 'uniswap/src/features/transactions/swap/hooks/usePriceUXEnabled' export function SwapFormScreenDetails(): JSX.Element { - const isPriceUXEnabled = usePriceUXEnabled() - const { tokenColor, showFooter } = useSwapFormScreenStore((state) => ({ + // const isPriceUXEnabled = usePriceUXEnabled() + const { tokenColor } = useSwapFormScreenStore((state) => ({ tokenColor: state.tokenColor, - showFooter: state.showFooter, + // showFooter: state.showFooter, })) return ( @@ -24,15 +24,17 @@ export function SwapFormScreenDetails(): JSX.Element { } `} - + - + {/* SwapFormScreenFooter removed - footer content (gas info, warnings) are hidden to prevent space changes */} + {/* */} - {showFooter && !isPriceUXEnabled ? : null} + {/* ExpandableRows removed - fee, network cost, etc. details are hidden before modal opens */} + {/* {showFooter && !isPriceUXEnabled ? : null} */} ) diff --git a/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen/SwapFormScreenDetails/SwapFormScreenFooter/GasAndWarningRows/GasAndWarningRows.web.tsx b/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen/SwapFormScreenDetails/SwapFormScreenFooter/GasAndWarningRows/GasAndWarningRows.web.tsx index e75566903af..bdd03334e19 100644 --- a/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen/SwapFormScreenDetails/SwapFormScreenFooter/GasAndWarningRows/GasAndWarningRows.web.tsx +++ b/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen/SwapFormScreenDetails/SwapFormScreenFooter/GasAndWarningRows/GasAndWarningRows.web.tsx @@ -3,7 +3,7 @@ import { WarningLabel } from 'uniswap/src/components/modals/WarningModal/types' import { isSVMChain } from 'uniswap/src/features/platforms/utils/chains' import { InsufficientNativeTokenWarning } from 'uniswap/src/features/transactions/components/InsufficientNativeTokenWarning/InsufficientNativeTokenWarning' import { BlockedAddressWarning } from 'uniswap/src/features/transactions/modals/BlockedAddressWarning' -import { TradeInfoRow } from 'uniswap/src/features/transactions/swap/form/SwapFormScreen/SwapFormScreenDetails/SwapFormScreenFooter/GasAndWarningRows/TradeInfoRow/TradeInfoRow' +// import { TradeInfoRow } from 'uniswap/src/features/transactions/swap/form/SwapFormScreen/SwapFormScreenDetails/SwapFormScreenFooter/GasAndWarningRows/TradeInfoRow/TradeInfoRow' import { useDebouncedGasInfo } from 'uniswap/src/features/transactions/swap/form/SwapFormScreen/SwapFormScreenDetails/SwapFormScreenFooter/GasAndWarningRows/useDebouncedGasInfo' import { useParsedSwapWarnings } from 'uniswap/src/features/transactions/swap/hooks/useSwapWarnings/useSwapWarnings' import { useSwapFormStoreDerivedSwapInfo } from 'uniswap/src/features/transactions/swap/stores/swapFormStore/useSwapFormStore' @@ -49,11 +49,12 @@ export function GasAndWarningRows(): JSX.Element { /> )} - {!insufficientGasFundsWarning && ( + {/* TradeInfoRow removed - rate display and accordion trigger are hidden */} + {/* {!insufficientGasFundsWarning && ( - )} + )} */} diff --git a/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen/SwapFormScreenDetails/SwapFormScreenFooter/GasAndWarningRows/TradeInfoRow/TradeInfoRow.tsx b/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen/SwapFormScreenDetails/SwapFormScreenFooter/GasAndWarningRows/TradeInfoRow/TradeInfoRow.tsx index 2938946f4f3..31246eda617 100644 --- a/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen/SwapFormScreenDetails/SwapFormScreenFooter/GasAndWarningRows/TradeInfoRow/TradeInfoRow.tsx +++ b/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen/SwapFormScreenDetails/SwapFormScreenFooter/GasAndWarningRows/TradeInfoRow/TradeInfoRow.tsx @@ -43,55 +43,7 @@ export function TradeInfoRow({ gasInfo, warning }: { gasInfo: GasInfo; warning?: const outputChainId = currencies.output?.currency.chainId const showCanonicalBridge = isWebApp && warning?.type === WarningLabel.NoQuotesFound && inputChainId !== outputChainId - return ( - - - {debouncedTrade && !warning && ( - - )} - - {warning && ( - - - - - {warning.title} - - - - )} - - - {showCanonicalBridge ? ( - - ) : debouncedTrade ? ( - - {({ open }: { open: boolean }) => ( - - - )} - - ) : ( - - )} - - ) + // TradeInfoRow removed - rate display and accordion trigger are hidden + // Only show gas info without rate and accordion + return } diff --git a/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen/SwitchCurrenciesButton.tsx b/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen/SwitchCurrenciesButton.tsx index a92aa260da7..418be436225 100644 --- a/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen/SwitchCurrenciesButton.tsx +++ b/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen/SwitchCurrenciesButton.tsx @@ -16,8 +16,8 @@ const SWAP_DIRECTION_BUTTON_SIZE = { small: spacing.spacing8, }, borderWidth: { - regular: spacing.spacing4, - small: spacing.spacing1, + regular: 1, + small: 1, }, } as const diff --git a/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen/hooks/useCurrencyInputFocusedStyle.ts b/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen/hooks/useCurrencyInputFocusedStyle.ts index b23b700f4e8..3dc3f22b62f 100644 --- a/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen/hooks/useCurrencyInputFocusedStyle.ts +++ b/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen/hooks/useCurrencyInputFocusedStyle.ts @@ -3,10 +3,10 @@ import type { FlexProps } from 'ui/src' function getCurrencyInputFocusedStyle(isFocused: boolean): FlexProps { return { borderColor: isFocused ? '$surface3' : '$transparent', - backgroundColor: isFocused ? '$surface1' : '$surface2', + backgroundColor: '$surface1', // Always use surface1 for consistent background hoverStyle: { borderColor: isFocused ? '$surface3Hovered' : '$transparent', - backgroundColor: isFocused ? '$surface1' : '$surface2Hovered', + backgroundColor: '$surface1', // Always use surface1 for consistent background }, } } diff --git a/packages/uniswap/src/features/transactions/swap/hooks/useTrade.ts b/packages/uniswap/src/features/transactions/swap/hooks/useTrade.ts index 7b95c661fb2..28ba3dc3637 100644 --- a/packages/uniswap/src/features/transactions/swap/hooks/useTrade.ts +++ b/packages/uniswap/src/features/transactions/swap/hooks/useTrade.ts @@ -1,10 +1,10 @@ import { Currency } from '@uniswap/sdk-core' import { useMemo } from 'react' import { parseQuoteCurrencies } from 'uniswap/src/features/transactions/swap/hooks/useTrade/parseQuoteCurrencies' -import { useIndicativeTradeQuery } from 'uniswap/src/features/transactions/swap/hooks/useTrade/useIndicativeTradeQuery' import { useTradeQuery } from 'uniswap/src/features/transactions/swap/hooks/useTrade/useTradeQuery' import type { TradeWithGasEstimates } from 'uniswap/src/features/transactions/swap/services/tradeService/tradeService' import { TradeWithStatus, UseTradeArgs } from 'uniswap/src/features/transactions/swap/types/trade' +import type { IndicativeTrade } from 'uniswap/src/features/transactions/swap/types/trade' // error strings hardcoded in @uniswap/unified-routing-api // https://github.com/Uniswap/unified-routing-api/blob/020ea371a00d4cc25ce9f9906479b00a43c65f2c/lib/util/errors.ts#L4 @@ -15,8 +15,16 @@ export const API_RATE_LIMIT_ERROR = 'TOO_MANY_REQUESTS' export function useTrade(params: UseTradeArgs): TradeWithStatus { const { error, data, isLoading: queryIsLoading, isFetching } = useTradeQuery(params) const isLoading = (params.amountSpecified && params.isDebouncing) || queryIsLoading - const indicative = useIndicativeTradeQuery(params) - const { currencyIn, currencyOut } = parseQuoteCurrencies(params) + // Disable indicative quote to ensure single source of quote requests + // This prevents duplicate requests and ensures consistency + const indicative = { trade: undefined, isLoading: false } + const { currencyIn, currencyOut } = parseQuoteCurrencies({ + tradeType: params.tradeType, + amountSpecified: params.amountSpecified, + otherCurrency: params.otherCurrency, + sellToken: params.sellToken, + buyToken: params.buyToken, + }) return useMemo(() => { return parseTradeResult({ @@ -38,7 +46,7 @@ function parseTradeResult(input: { currencyOut?: Currency isLoading: boolean isFetching: boolean - indicative: ReturnType + indicative: { trade: IndicativeTrade | undefined; isLoading: boolean } error: Error | null isDebouncing?: boolean }): TradeWithStatus { diff --git a/packages/uniswap/src/features/transactions/swap/hooks/useTrade/parseQuoteCurrencies.ts b/packages/uniswap/src/features/transactions/swap/hooks/useTrade/parseQuoteCurrencies.ts index 713087857cd..fe325cd2b51 100644 --- a/packages/uniswap/src/features/transactions/swap/hooks/useTrade/parseQuoteCurrencies.ts +++ b/packages/uniswap/src/features/transactions/swap/hooks/useTrade/parseQuoteCurrencies.ts @@ -6,6 +6,9 @@ interface ParseQuoteCurrenciesInput { tradeType: SdkTradeType amountSpecified?: CurrencyAmount | null otherCurrency?: Currency | null + // Explicit sell and buy tokens from UI to ensure consistency + sellToken?: Currency + buyToken?: Currency } export interface QuoteCurrencyData { @@ -15,17 +18,31 @@ export interface QuoteCurrencyData { } export function parseQuoteCurrencies(input: ParseQuoteCurrenciesInput): QuoteCurrencyData { - const { tradeType, amountSpecified, otherCurrency } = input - - const currencyIn = tradeType === SdkTradeType.EXACT_INPUT ? amountSpecified?.currency : otherCurrency - const currencyOut = tradeType === SdkTradeType.EXACT_OUTPUT ? amountSpecified?.currency : otherCurrency + const { tradeType, amountSpecified, otherCurrency, sellToken, buyToken } = input + + // CRITICAL: If explicit sellToken and buyToken are provided, use them directly + // This ensures tokenIn/tokenOut always match UI's sell/buy tokens + // If either is missing, we should NOT use fallback logic as it may use stale/incorrect values + if (sellToken && buyToken) { + const requestTradeType = + tradeType === SdkTradeType.EXACT_INPUT ? TradingApi.TradeType.EXACT_INPUT : TradingApi.TradeType.EXACT_OUTPUT + + return { + currencyIn: sellToken, + currencyOut: buyToken, + requestTradeType, + } + } + // If sellToken or buyToken is missing, return undefined to prevent incorrect quote requests + // This ensures we only send requests when we have explicit UI state, not inferred state + // The skip flag in useDerivedSwapInfo should prevent this from being called, but this is a safety check const requestTradeType = tradeType === SdkTradeType.EXACT_INPUT ? TradingApi.TradeType.EXACT_INPUT : TradingApi.TradeType.EXACT_OUTPUT return { - currencyIn: currencyIn ?? undefined, - currencyOut: currencyOut ?? undefined, + currencyIn: undefined, + currencyOut: undefined, requestTradeType, } } diff --git a/packages/uniswap/src/features/transactions/swap/hooks/useTrade/useTradeQuery.ts b/packages/uniswap/src/features/transactions/swap/hooks/useTrade/useTradeQuery.ts index aa17f35c9f1..fdff03f9a36 100644 --- a/packages/uniswap/src/features/transactions/swap/hooks/useTrade/useTradeQuery.ts +++ b/packages/uniswap/src/features/transactions/swap/hooks/useTrade/useTradeQuery.ts @@ -11,15 +11,32 @@ import { useEvent } from 'utilities/src/react/hooks' import { ONE_SECOND_MS } from 'utilities/src/time/time' export function useTradeQuery(params: UseTradeArgs): UseQueryResult { - const quoteCurrencyData = parseQuoteCurrencies(params) + const quoteCurrencyData = parseQuoteCurrencies({ + tradeType: params.tradeType, + amountSpecified: params.amountSpecified, + otherCurrency: params.otherCurrency, + sellToken: params.sellToken, + buyToken: params.buyToken, + }) + + // CRITICAL: If currencyIn or currencyOut is undefined, the query should be disabled + // This prevents sending quote requests with missing or incorrect tokens + const isQueryEnabled = !params.skip && !!quoteCurrencyData.currencyIn && !!quoteCurrencyData.currencyOut + const pollingIntervalForChain = usePollingIntervalByChain(quoteCurrencyData.currencyIn?.chainId) const internalPollInterval = params.pollInterval ?? pollingIntervalForChain const tradeService = useTradeService() const getTradeQueryOptions = useEvent(createTradeServiceQueryOptions({ tradeService })) + const baseQueryOptions = getTradeQueryOptions(params) const response = useQueryWithImmediateGarbageCollection({ - ...getTradeQueryOptions(params), + ...baseQueryOptions, + // Override enabled flag to ensure query is disabled if currencies are missing + enabled: isQueryEnabled && baseQueryOptions.enabled, refetchInterval: internalPollInterval, + // Set staleTime to prevent duplicate requests within the polling interval + // This ensures we don't refetch if data is still fresh, even if query key changes + staleTime: internalPollInterval, // We set the `gcTime` to 15 seconds longer than the refetch interval so that there's more than enough time for a refetch to complete before we clear the stale data. // If the user loses internet connection (or leaves the app and comes back) for longer than this, // then we clear stale data and show a big loading spinner in the swap review screen. diff --git a/packages/uniswap/src/features/transactions/swap/hooks/useTrade/useTradeServiceQueryOptions.ts b/packages/uniswap/src/features/transactions/swap/hooks/useTrade/useTradeServiceQueryOptions.ts index 9610082f01a..532aa05e2a5 100644 --- a/packages/uniswap/src/features/transactions/swap/hooks/useTrade/useTradeServiceQueryOptions.ts +++ b/packages/uniswap/src/features/transactions/swap/hooks/useTrade/useTradeServiceQueryOptions.ts @@ -15,18 +15,63 @@ export type TradeServiceQueryOptions = UseQueryOptions< [ ReactQueryCacheKey.TradeService, 'getTrade', - ValidatedTradeInput | JupiterOrderUrlParams | null, // TODO(SWAP-383): Remove JupiterOrderUrlParams from union once Solana trade repo is implemented + string, // Stable query key based on values instead of object reference ] > +/** + * Creates a stable query key based on validated input values instead of object reference. + * This prevents unnecessary refetches when object references change but values remain the same. + */ +function createStableQueryKey( + validatedInput: ValidatedTradeInput | JupiterOrderUrlParams | null, +): string { + if (!validatedInput) { + return 'null' + } + + // Handle JupiterOrderUrlParams (Solana trades) + if ('inputMint' in validatedInput && 'outputMint' in validatedInput) { + const jupiterParams = validatedInput as JupiterOrderUrlParams + const keyParts = [ + 'jupiter', + jupiterParams.inputMint, + jupiterParams.outputMint, + jupiterParams.amount, + jupiterParams.swapMode, + jupiterParams.slippageBps ?? 'default', + ] + return keyParts.join('|') + } + + // Handle ValidatedTradeInput (EVM trades) + const evmInput = validatedInput as ValidatedTradeInput + // Create a stable key based on all relevant values + // This ensures the query key only changes when actual values change, not object references + const keyParts = [ + evmInput.tokenInChainId, + evmInput.tokenInAddress, + evmInput.tokenOutChainId, + evmInput.tokenOutAddress, + evmInput.amount.quotient.toString(), + evmInput.requestTradeType, + evmInput.activeAccountAddress ?? 'unconnected', + evmInput.isUSDQuote ?? false, + evmInput.generatePermitAsTransaction ?? false, + ] + + return keyParts.join('|') +} + export function createTradeServiceQueryOptions(ctx: { tradeService: TradeService }): (params?: UseTradeArgs) => TradeServiceQueryOptions { return (params?: UseTradeArgs) => { const validatedInput = params ? ctx.tradeService.prepareTradeInput(params) : null + const stableKey = createStableQueryKey(validatedInput) return queryOptions({ - queryKey: [ReactQueryCacheKey.TradeService, 'getTrade', validatedInput], + queryKey: [ReactQueryCacheKey.TradeService, 'getTrade', stableKey], queryFn: async (): Promise => { if (!params) { return { trade: null } diff --git a/packages/uniswap/src/features/transactions/swap/plan/utils.ts b/packages/uniswap/src/features/transactions/swap/plan/utils.ts index 66c5de47c01..1446a107999 100644 --- a/packages/uniswap/src/features/transactions/swap/plan/utils.ts +++ b/packages/uniswap/src/features/transactions/swap/plan/utils.ts @@ -21,13 +21,18 @@ export async function handleSwitchChains(params: { const swapChainId = swapTxContext.trade.inputAmount.currency.chainId - if (isJupiter(swapTxContext) || swapChainId === startChainId) { + // If startChainId is undefined, we assume we're already on the correct chain (or don't need to switch) + // This is common when the account hasn't been initialized yet or we're testing a single chain + if (isJupiter(swapTxContext) || swapChainId === startChainId || startChainId === undefined) { return { chainSwitchFailed: false } } + try { const chainSwitched = await selectChain(swapChainId) - return { chainSwitchFailed: !chainSwitched } + } catch (error) { + return { chainSwitchFailed: true } + } } export function stepHasFinalized(step: TradingApi.PlanStep): boolean { diff --git a/packages/uniswap/src/features/transactions/swap/review/SwapReviewScreen/SwapReviewFooter/SwapReviewFooter.tsx b/packages/uniswap/src/features/transactions/swap/review/SwapReviewScreen/SwapReviewFooter/SwapReviewFooter.tsx index f9e0bc461d1..a292b403fdc 100644 --- a/packages/uniswap/src/features/transactions/swap/review/SwapReviewScreen/SwapReviewFooter/SwapReviewFooter.tsx +++ b/packages/uniswap/src/features/transactions/swap/review/SwapReviewScreen/SwapReviewFooter/SwapReviewFooter.tsx @@ -2,7 +2,6 @@ import { memo, useMemo } from 'react' import { Flex, IconButton, useIsShortMobileDevice } from 'ui/src' import { BackArrow } from 'ui/src/components/icons/BackArrow' import type { Warning } from 'uniswap/src/components/modals/WarningModal/types' -import { UniverseChainId } from 'uniswap/src/features/chains/types' import { TransactionModalFooterContainer } from 'uniswap/src/features/transactions/components/TransactionModal/TransactionModal' import { useSwapOnPrevious } from 'uniswap/src/features/transactions/swap/review/hooks/useSwapOnPrevious' import { SubmitSwapButton } from 'uniswap/src/features/transactions/swap/review/SwapReviewScreen/SwapReviewFooter/SubmitSwapButton' diff --git a/packages/uniswap/src/features/transactions/swap/review/hooks/useTokenApprovalInfo.test.ts b/packages/uniswap/src/features/transactions/swap/review/hooks/useTokenApprovalInfo.test.ts index d92ed919f55..3b67b85da76 100644 --- a/packages/uniswap/src/features/transactions/swap/review/hooks/useTokenApprovalInfo.test.ts +++ b/packages/uniswap/src/features/transactions/swap/review/hooks/useTokenApprovalInfo.test.ts @@ -1,12 +1,14 @@ -import { renderHook } from '@testing-library/react' +import { renderHook, waitFor } from '@testing-library/react' import { CurrencyAmount, Token } from '@uniswap/sdk-core' import { FeeType, GasEstimate, TradingApi } from '@universe/api' +import { Contract } from 'ethers/lib/ethers' import { DAI, USDC } from 'uniswap/src/constants/tokens' import { useCheckApprovalQuery } from 'uniswap/src/data/apiClients/tradingApi/useCheckApprovalQuery' import { AccountType } from 'uniswap/src/features/accounts/types' import { UniverseChainId } from 'uniswap/src/features/chains/types' import { DEFAULT_GAS_STRATEGY } from 'uniswap/src/features/gas/utils' import { Platform } from 'uniswap/src/features/platforms/types/Platform' +import { createEthersProvider } from 'uniswap/src/features/providers/createEthersProvider' import type { TokenApprovalInfoParams } from 'uniswap/src/features/transactions/swap/review/hooks/useTokenApprovalInfo' import { useTokenApprovalInfo } from 'uniswap/src/features/transactions/swap/review/hooks/useTokenApprovalInfo' import { ApprovalAction } from 'uniswap/src/features/transactions/swap/types/trade' @@ -15,12 +17,20 @@ import { SignerMnemonicAccountDetails } from 'uniswap/src/features/wallet/types/ import { logger } from 'utilities/src/logger/logger' jest.mock('uniswap/src/data/apiClients/tradingApi/useCheckApprovalQuery') +jest.mock('uniswap/src/features/providers/createEthersProvider', () => ({ + createEthersProvider: jest.fn(), +})) +jest.mock('ethers/lib/ethers', () => ({ + Contract: jest.fn(), +})) jest.mock('utilities/src/logger/logger', () => ({ logger: { error: jest.fn(), }, })) const mockUseCheckApprovalQuery = useCheckApprovalQuery as jest.Mock +const mockCreateEthersProvider = createEthersProvider as jest.Mock +const mockContract = Contract as jest.Mock describe('useTokenApprovalInfo', () => { const mockAccount: SignerMnemonicAccountDetails = { @@ -179,4 +189,91 @@ describe('useTokenApprovalInfo', () => { }, }) }) + + it('uses on-chain allowance for HashKey chains and skips approval when allowance is sufficient', async () => { + const hashKeyTokenIn = new Token( + UniverseChainId.HashKeyTestnet, + DAI.address, + DAI.decimals, + DAI.symbol, + DAI.name, + ) + const hashKeyTokenOut = new Token( + UniverseChainId.HashKeyTestnet, + USDC.address, + USDC.decimals, + USDC.symbol, + USDC.name, + ) + const hashKeyParams: TokenApprovalInfoParams = { + ...mockParams, + chainId: UniverseChainId.HashKeyTestnet, + currencyInAmount: CurrencyAmount.fromRawAmount(hashKeyTokenIn, mockCurrencyInAmount.quotient), + currencyOutAmount: CurrencyAmount.fromRawAmount(hashKeyTokenOut, mockCurrencyOutAmount.quotient), + } + + mockUseCheckApprovalQuery.mockReturnValue({ + data: undefined, + isLoading: false, + error: null, + }) + mockCreateEthersProvider.mockReturnValue({}) + mockContract.mockImplementation(() => ({ + allowance: jest.fn().mockResolvedValue({ + toString: () => mockCurrencyInAmount.quotient.toString(), + }), + })) + + const { result } = renderHook(() => useTokenApprovalInfo(hashKeyParams)) + + await waitFor(() => { + expect(result.current.tokenApprovalInfo.action).toBe(ApprovalAction.None) + }) + }) + + it('uses on-chain allowance for HashKey chains and returns approval tx when allowance is insufficient', async () => { + const hashKeyTokenIn = new Token( + UniverseChainId.HashKeyTestnet, + DAI.address, + DAI.decimals, + DAI.symbol, + DAI.name, + ) + const hashKeyParams: TokenApprovalInfoParams = { + ...mockParams, + chainId: UniverseChainId.HashKeyTestnet, + currencyInAmount: CurrencyAmount.fromRawAmount(hashKeyTokenIn, mockCurrencyInAmount.quotient), + currencyOutAmount: undefined, + } + + mockUseCheckApprovalQuery.mockReturnValue({ + data: undefined, + isLoading: false, + error: null, + }) + mockCreateEthersProvider.mockReturnValue({}) + mockContract.mockImplementation(() => ({ + allowance: jest.fn().mockResolvedValue({ + toString: () => '0', + }), + })) + + const { result } = renderHook(() => useTokenApprovalInfo(hashKeyParams)) + + await waitFor(() => { + expect(result.current.tokenApprovalInfo.action).toBe(ApprovalAction.Permit2Approve) + }) + + expect(result.current.tokenApprovalInfo).toMatchObject({ + action: ApprovalAction.Permit2Approve, + txRequest: { + to: hashKeyTokenIn.address, + chainId: UniverseChainId.HashKeyTestnet, + value: '0x0', + }, + cancelTxRequest: null, + }) + const txData = result.current.tokenApprovalInfo.txRequest?.data?.toString() ?? '' + expect(txData.startsWith('0x095ea7b3')).toBe(true) + }) }) diff --git a/packages/uniswap/src/features/transactions/swap/review/hooks/useTokenApprovalInfo.ts b/packages/uniswap/src/features/transactions/swap/review/hooks/useTokenApprovalInfo.ts index 973bf95a107..b5f0cc31a8c 100644 --- a/packages/uniswap/src/features/transactions/swap/review/hooks/useTokenApprovalInfo.ts +++ b/packages/uniswap/src/features/transactions/swap/review/hooks/useTokenApprovalInfo.ts @@ -1,13 +1,18 @@ import { Currency, CurrencyAmount } from '@uniswap/sdk-core' import { TradingApi } from '@universe/api' -import { useMemo } from 'react' +import { Interface } from '@ethersproject/abi' +import { Contract } from 'ethers/lib/ethers' +import { useEffect, useMemo, useState } from 'react' +import ERC20_ABI from 'uniswap/src/abis/erc20.json' import { useUniswapContextSelector } from 'uniswap/src/contexts/UniswapContext' import { useCheckApprovalQuery } from 'uniswap/src/data/apiClients/tradingApi/useCheckApprovalQuery' import { UniverseChainId } from 'uniswap/src/features/chains/types' import { convertGasFeeToDisplayValue, useActiveGasStrategy } from 'uniswap/src/features/gas/hooks' import { GasFeeResult } from 'uniswap/src/features/gas/types' +import { createEthersProvider } from 'uniswap/src/features/providers/createEthersProvider' import { ApprovalAction, TokenApprovalInfo } from 'uniswap/src/features/transactions/swap/types/trade' import { isUniswapX } from 'uniswap/src/features/transactions/swap/utils/routing' +import { CHAIN_TO_UNIVERSAL_ROUTER_ADDRESS } from 'uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/constants' import { getTokenAddressForApi, toTradingApiSupportedChainId, @@ -37,6 +42,12 @@ function useApprovalWillBeBatchedWithSwap(chainId: UniverseChainId, routing: Tra const swapDelegationInfo = useUniswapContextSelector((ctx) => ctx.getSwapDelegationInfo?.(chainId)) const isBatchableFlow = Boolean(routing && !isUniswapX({ routing })) + const isHashKeyChain = chainId === UniverseChainId.HashKey || chainId === UniverseChainId.HashKeyTestnet + + if (isHashKeyChain) { + // HashKey swaps use direct router calldata and are not atomic-batched with approvals. + return false + } return Boolean((canBatchTransactions || swapDelegationInfo?.delegationAddress) && isBatchableFlow) } @@ -47,6 +58,7 @@ export function useTokenApprovalInfo(params: TokenApprovalInfoParams): ApprovalT const isWrap = wrapType !== WrapType.NotApplicable /** Approval is included elsewhere for Chained Actions so it can be skipped */ const isChained = routing === TradingApi.Routing.CHAINED + const isHashKeyChain = chainId === UniverseChainId.HashKey || chainId === UniverseChainId.HashKeyTestnet const address = account?.address const inputWillBeWrapped = routing && isUniswapX({ routing }) @@ -54,6 +66,7 @@ export function useTokenApprovalInfo(params: TokenApprovalInfoParams): ApprovalT const currencyIn = inputWillBeWrapped ? currencyInAmount?.currency.wrapped : currencyInAmount?.currency const amount = currencyInAmount?.quotient.toString() + const tokenInAllowanceAddress = currencyIn?.isNative ? undefined : currencyIn?.wrapped.address const tokenInAddress = getTokenAddressForApi(currencyIn) // Only used for bridging @@ -62,6 +75,85 @@ export function useTokenApprovalInfo(params: TokenApprovalInfoParams): ApprovalT const tokenOutAddress = getTokenAddressForApi(currencyOut) const gasStrategy = useActiveGasStrategy(chainId, 'general') + const erc20Interface = useMemo(() => new Interface(ERC20_ABI), []) + + const approvalWillBeBatchedWithSwap = useApprovalWillBeBatchedWithSwap(chainId, routing) + + const hskRouterAddress = useMemo(() => { + if (!isHashKeyChain) { + return undefined + } + return CHAIN_TO_UNIVERSAL_ROUTER_ADDRESS[chainId]?.[0] + }, [chainId, isHashKeyChain]) + + const [allowanceState, setAllowanceState] = useState<{ + value: string | undefined + isLoading: boolean + error: Error | null + }>({ + value: undefined, + isLoading: false, + error: null, + }) + + const shouldCheckOnchainAllowance = Boolean( + isHashKeyChain && + !isWrap && + !approvalWillBeBatchedWithSwap && + !isChained && + address && + amount && + tokenInAllowanceAddress && + hskRouterAddress, + ) + + useEffect(() => { + if (!shouldCheckOnchainAllowance) { + setAllowanceState((prev) => + prev.value || prev.isLoading || prev.error ? { value: undefined, isLoading: false, error: null } : prev, + ) + return + } + + const provider = createEthersProvider({ chainId }) + if (!provider) { + setAllowanceState({ + value: undefined, + isLoading: false, + error: new Error('No RPC provider available for HashKey allowance check'), + }) + return + } + + let cancelled = false + setAllowanceState((prev) => ({ ...prev, isLoading: true, error: null })) + + ;(async () => { + try { + const erc20Contract = new Contract(tokenInAllowanceAddress as string, ERC20_ABI, provider) + const allowance = await erc20Contract.allowance(address, hskRouterAddress as string) + if (!cancelled) { + setAllowanceState({ value: allowance.toString(), isLoading: false, error: null }) + } + } catch (err) { + if (!cancelled) { + const error = err instanceof Error ? err : new Error('Allowance check failed') + setAllowanceState({ value: undefined, isLoading: false, error }) + } + } + })() + + return () => { + cancelled = true + } + }, [ + address, + amount, + chainId, + hskRouterAddress, + shouldCheckOnchainAllowance, + tokenInAllowanceAddress, + ]) const approvalRequestArgs: TradingApi.ApprovalRequest | undefined = useMemo(() => { const tokenInChainId = toTradingApiSupportedChainId(chainId) @@ -96,8 +188,8 @@ export function useTokenApprovalInfo(params: TokenApprovalInfoParams): ApprovalT tokenOutAddress, ]) - const approvalWillBeBatchedWithSwap = useApprovalWillBeBatchedWithSwap(chainId, routing) - const shouldSkip = !approvalRequestArgs || isWrap || !address || approvalWillBeBatchedWithSwap || isChained + const shouldSkip = + !approvalRequestArgs || isWrap || !address || approvalWillBeBatchedWithSwap || isChained || isHashKeyChain const { data, isLoading, error } = useCheckApprovalQuery({ params: shouldSkip ? undefined : approvalRequestArgs, @@ -105,6 +197,7 @@ export function useTokenApprovalInfo(params: TokenApprovalInfoParams): ApprovalT immediateGcTime: ONE_MINUTE_MS, }) + const tokenApprovalInfo: TokenApprovalInfo = useMemo(() => { if (error) { logger.error(error, { @@ -115,6 +208,75 @@ export function useTokenApprovalInfo(params: TokenApprovalInfoParams): ApprovalT }) } + if (isHashKeyChain) { + if (allowanceState.error) { + logger.error(allowanceState.error, { + tags: { file: 'useTokenApprovalInfo', function: 'useTokenApprovalInfo' }, + extra: { + address, + amount, + chainId, + hskRouterAddress, + tokenInAddress: tokenInAllowanceAddress, + }, + }) + } + + if (isWrap || !address || approvalWillBeBatchedWithSwap || isChained) { + return { + action: ApprovalAction.None, + txRequest: null, + cancelTxRequest: null, + } + } + + if (!tokenInAllowanceAddress || currencyIn?.isNative) { + return { + action: ApprovalAction.None, + txRequest: null, + cancelTxRequest: null, + } + } + + if (!shouldCheckOnchainAllowance || allowanceState.isLoading || !allowanceState.value || !amount) { + return { + action: ApprovalAction.Unknown, + txRequest: null, + cancelTxRequest: null, + } + } + + try { + const hasAllowance = BigInt(allowanceState.value) >= BigInt(amount) + if (hasAllowance) { + return { + action: ApprovalAction.None, + txRequest: null, + cancelTxRequest: null, + } + } + } catch { + return { + action: ApprovalAction.Unknown, + txRequest: null, + cancelTxRequest: null, + } + } + + const txRequest = { + to: tokenInAllowanceAddress, + data: erc20Interface.encodeFunctionData('approve', [hskRouterAddress as string, amount]), + value: '0x0', + chainId, + } + + return { + action: ApprovalAction.Permit2Approve, + txRequest, + cancelTxRequest: null, + } + } + // Approval is N/A for wrap transactions or unconnected state. if (isWrap || !address || approvalWillBeBatchedWithSwap || isChained) { return { @@ -125,6 +287,7 @@ export function useTokenApprovalInfo(params: TokenApprovalInfoParams): ApprovalT } if (data && !error) { + // API returns null if no approval is required // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition @@ -161,19 +324,40 @@ export function useTokenApprovalInfo(params: TokenApprovalInfoParams): ApprovalT txRequest: null, cancelTxRequest: null, } - }, [address, approvalRequestArgs, approvalWillBeBatchedWithSwap, data, error, isWrap, isChained]) + }, [ + address, + allowanceState.error, + allowanceState.isLoading, + allowanceState.value, + amount, + approvalRequestArgs, + approvalWillBeBatchedWithSwap, + chainId, + currencyIn?.isNative, + data, + error, + erc20Interface, + hskRouterAddress, + isChained, + isHashKeyChain, + isWrap, + shouldCheckOnchainAllowance, + tokenInAllowanceAddress, + ]) return useMemo(() => { - const gasEstimate = data?.gasEstimates?.[0] + const gasEstimate = isHashKeyChain ? undefined : data?.gasEstimates?.[0] const noApprovalNeeded = tokenApprovalInfo.action === ApprovalAction.None const noRevokeNeeded = tokenApprovalInfo.action === ApprovalAction.Permit2Approve || tokenApprovalInfo.action === ApprovalAction.None - const approvalFee = noApprovalNeeded ? '0' : data?.gasFee - const revokeFee = noRevokeNeeded ? '0' : data?.cancelGasFee + const approvalFee = noApprovalNeeded ? '0' : isHashKeyChain ? undefined : data?.gasFee + const revokeFee = noRevokeNeeded ? '0' : isHashKeyChain ? undefined : data?.cancelGasFee const unknownApproval = tokenApprovalInfo.action === ApprovalAction.Unknown - const isGasLoading = unknownApproval && isLoading - const approvalGasError = unknownApproval && !isLoading ? new Error('Approval action unknown') : null + const isApprovalLoading = isHashKeyChain ? allowanceState.isLoading : isLoading + const approvalGasError = + allowanceState.error ?? (unknownApproval && !isApprovalLoading ? new Error('Approval action unknown') : null) + const isGasLoading = unknownApproval && isApprovalLoading return { tokenApprovalInfo, @@ -191,5 +375,15 @@ export function useTokenApprovalInfo(params: TokenApprovalInfoParams): ApprovalT error: approvalGasError, }, } - }, [gasStrategy, data?.cancelGasFee, data?.gasEstimates, data?.gasFee, isLoading, tokenApprovalInfo]) + }, [ + allowanceState.error, + allowanceState.isLoading, + data?.cancelGasFee, + data?.gasEstimates, + data?.gasFee, + gasStrategy, + isHashKeyChain, + isLoading, + tokenApprovalInfo, + ]) } diff --git a/packages/uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/classic/classicSwapTxAndGasInfoService.ts b/packages/uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/classic/classicSwapTxAndGasInfoService.ts index ade68068025..1c1e375d5f1 100644 --- a/packages/uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/classic/classicSwapTxAndGasInfoService.ts +++ b/packages/uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/classic/classicSwapTxAndGasInfoService.ts @@ -20,6 +20,7 @@ export function createClassicSwapTxAndGasInfoService(ctx: { const service: SwapTxAndGasInfoService = { async getSwapTxAndGasInfo(params) { const swapTxInfo = await getEVMSwapTransactionRequestInfo(params) + const permitTxInfo = getPermitTxInfo(params.trade) return getClassicSwapTxAndGasInfo({ ...params, swapTxInfo, permitTxInfo }) diff --git a/packages/uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/evm/evmSwapInstructionsService.ts b/packages/uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/evm/evmSwapInstructionsService.ts index 3c896cb72e5..751bcb65721 100644 --- a/packages/uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/evm/evmSwapInstructionsService.ts +++ b/packages/uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/evm/evmSwapInstructionsService.ts @@ -24,7 +24,7 @@ import { ApprovalAction } from 'uniswap/src/features/transactions/swap/types/tra import { tradingApiToUniverseChainId } from 'uniswap/src/features/transactions/swap/utils/tradingApi' type SwapInstructions = - | { response: SwapData; unsignedPermit: null; swapRequestParams: null } + | { response: SwapData; unsignedPermit: null; swapRequestParams: TradingApi.CreateSwapRequest | null } | { response: null; unsignedPermit: TradingApi.Permit; swapRequestParams: TradingApi.CreateSwapRequest } /** A service utility capable of fetching swap instructions or returning unsigned permit data when instructions cannot yet be fetched. */ @@ -74,7 +74,8 @@ function createLegacyEVMSwapInstructionsService( } const response = await swapRepository.fetchSwapData(swapRequestParams) - return { response, unsignedPermit: null, swapRequestParams: null } + // Keep swapRequestParams even when we have a response, so deadline is preserved in swapTxContext + return { response, unsignedPermit: null, swapRequestParams } }, } @@ -100,8 +101,10 @@ function createBatchedEVMSwapInstructionsService( overrideSimulation: true, // always simulate for batched transactions }) + const response = await swapRepository.fetchSwapData(swapRequestParams) - return { response, unsignedPermit: null, swapRequestParams: null } + // Keep swapRequestParams even when we have a response, so deadline is preserved in swapTxContext + return { response, unsignedPermit: null, swapRequestParams } }, } diff --git a/packages/uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/evm/evmSwapRepository.ts b/packages/uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/evm/evmSwapRepository.ts index efc674be5a0..985f4dacc67 100644 --- a/packages/uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/evm/evmSwapRepository.ts +++ b/packages/uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/evm/evmSwapRepository.ts @@ -1,9 +1,7 @@ import { TransactionRequest } from '@ethersproject/providers' import { GasEstimate, TradingApi } from '@universe/api' -import { TradingApiClient } from 'uniswap/src/data/apiClients/tradingApi/TradingApiClient' import { UniverseChainId } from 'uniswap/src/features/chains/types' import { SwapDelegationInfo } from 'uniswap/src/features/smartWallet/delegation/types' -import { tradingApiToUniverseChainId } from 'uniswap/src/features/transactions/swap/utils/tradingApi' export type SwapData = { requestId: string @@ -16,63 +14,41 @@ export interface EVMSwapRepository { fetchSwapData: (params: TradingApi.CreateSwapRequest) => Promise } -export function convertSwapResponseToSwapData(response: TradingApi.CreateSwapResponse): SwapData { - return { - requestId: response.requestId, - transactions: [response.swap], - gasFee: response.gasFee, - gasEstimate: response.gasEstimates?.[0], - } -} - +/** + * HashKey chains (133, 177) do not use swap API. + * Transactions are built directly from quote methodParameters using buildTxRequestFromTrade. + * This repository should never be called for HashKey chains. + */ export function createLegacyEVMSwapRepository(): EVMSwapRepository { return { - fetchSwapData: async (params: TradingApi.CreateSwapRequest) => - convertSwapResponseToSwapData(await TradingApiClient.fetchSwap(params)), - } -} - -export function convertSwap7702ResponseToSwapData( - response: TradingApi.CreateSwap7702Response, - includesDelegation?: boolean, -): SwapData { - return { - requestId: response.requestId, - transactions: [response.swap], - gasFee: response.gasFee, - includesDelegation, + fetchSwapData: async () => { + throw new Error('Swap API is not used for HashKey chains. Transactions should be built from quote methodParameters.') + }, } } +/** + * HashKey chains (133, 177) do not use swap API. + * This repository should never be called for HashKey chains. + */ export function create7702EVMSwapRepository(ctx: { getSwapDelegationInfo: (chainId?: UniverseChainId) => SwapDelegationInfo }): EVMSwapRepository { - const { getSwapDelegationInfo } = ctx - async function fetchSwapData(params: TradingApi.CreateSwapRequest): Promise { - const chainId = tradingApiToUniverseChainId(params.quote.chainId) - const smartContractDelegationInfo = getSwapDelegationInfo(chainId) - const response = await TradingApiClient.fetchSwap7702({ - ...params, - smartContractDelegationAddress: smartContractDelegationInfo.delegationAddress, - }) - - return convertSwap7702ResponseToSwapData(response, smartContractDelegationInfo.delegationInclusion) - } - - return { fetchSwapData } -} - -export function convertSwap5792ResponseToSwapData(response: TradingApi.CreateSwap5792Response): SwapData { return { - requestId: response.requestId, - transactions: response.calls.map((c) => ({ ...c, chainId: response.chainId })), - gasFee: response.gasFee, + fetchSwapData: async () => { + throw new Error('Swap API is not used for HashKey chains. Transactions should be built from quote methodParameters.') + }, } } +/** + * HashKey chains (133, 177) do not use swap API. + * This repository should never be called for HashKey chains. + */ export function create5792EVMSwapRepository(): EVMSwapRepository { return { - fetchSwapData: async (params: TradingApi.CreateSwapRequest) => - convertSwap5792ResponseToSwapData(await TradingApiClient.fetchSwap5792(params)), + fetchSwapData: async () => { + throw new Error('Swap API is not used for HashKey chains. Transactions should be built from quote methodParameters.') + }, } } diff --git a/packages/uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/evm/utils.ts b/packages/uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/evm/utils.ts index 3baef326bbf..bc5c03d057c 100644 --- a/packages/uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/evm/utils.ts +++ b/packages/uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/evm/utils.ts @@ -4,6 +4,7 @@ import type { ApprovalTxInfo } from 'uniswap/src/features/transactions/swap/revi import type { EVMSwapInstructionsService } from 'uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/evm/evmSwapInstructionsService' import type { TransactionRequestInfo } from 'uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/utils' import { + createPrepareSwapRequestParams, createProcessSwapResponse, getSwapInputExceedsBalance, } from 'uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/utils' @@ -31,6 +32,7 @@ export function createGetEVMSwapTransactionRequestInfo(ctx: { const { gasStrategy, transactionSettings, instructionService } = ctx const processSwapResponse = createProcessSwapResponse({ gasStrategy }) + const prepareSwapRequestParams = createPrepareSwapRequestParams({ gasStrategy }) const getEVMSwapTransactionRequestInfo: GetEVMSwapTransactionRequestInfoFn = async ({ trade, @@ -46,6 +48,16 @@ export function createGetEVMSwapTransactionRequestInfo(ctx: { const approvalUnknown = approvalAction === ApprovalAction.Unknown const skip = getSwapInputExceedsBalance({ derivedSwapInfo }) || approvalUnknown + + // Always prepare swapRequestParams, even if skip is true, so deadline is preserved + const alreadyApproved = approvalAction === ApprovalAction.None && !swapQuoteResponse.permitTransaction + const swapRequestParams = prepareSwapRequestParams({ + swapQuoteResponse, + signature: undefined, + transactionSettings, + alreadyApproved, + }) + const { data, error } = await tryCatch( skip ? Promise.resolve(undefined) @@ -53,14 +65,19 @@ export function createGetEVMSwapTransactionRequestInfo(ctx: { ) const isRevokeNeeded = tokenApprovalInfo.action === ApprovalAction.RevokeAndPermit2Approve + + // Use swapRequestParams from data if available, otherwise use the one we prepared + const finalSwapRequestParams = data?.swapRequestParams ?? swapRequestParams + const swapTxInfo = processSwapResponse({ response: data?.response ?? undefined, error, permitData: data?.unsignedPermit, swapQuote, + trade, isSwapLoading: false, isRevokeNeeded, - swapRequestParams: data?.swapRequestParams ?? undefined, + swapRequestParams: finalSwapRequestParams, }) return swapTxInfo diff --git a/packages/uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/utils.ts b/packages/uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/utils.ts index 01a1403a058..d172628fbbf 100644 --- a/packages/uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/utils.ts +++ b/packages/uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/utils.ts @@ -16,6 +16,7 @@ import { getTradeSettingsDeadline } from 'uniswap/src/data/apiClients/tradingApi import { getChainLabel } from 'uniswap/src/features/chains/utils' import { convertGasFeeToDisplayValue, useActiveGasStrategy } from 'uniswap/src/features/gas/hooks' import type { GasFeeResult } from 'uniswap/src/features/gas/types' +import { SwapRouter as V3SwapRouter } from '@uniswap/router-sdk' import { SwapEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import type { TransactionSettings } from 'uniswap/src/features/transactions/components/settings/types' @@ -46,6 +47,7 @@ import type { import { ApprovalAction } from 'uniswap/src/features/transactions/swap/types/trade' import { mergeGasFeeResults } from 'uniswap/src/features/transactions/swap/utils/gas' import { isClassic } from 'uniswap/src/features/transactions/swap/utils/routing' +import { slippageToleranceToPercent } from 'uniswap/src/features/transactions/swap/utils/format' import { validatePermit, validateTransactionRequest, @@ -54,6 +56,7 @@ import { import { SWAP_GAS_URGENCY_OVERRIDE } from 'uniswap/src/features/transactions/swap/utils/tradingApi' import type { ValidatedTransactionRequest } from 'uniswap/src/features/transactions/types/transactionRequests' import { CurrencyField } from 'uniswap/src/types/currency' +import { UniverseChainId } from 'uniswap/src/features/chains/types' import { logger } from 'utilities/src/logger/logger' import { isExtensionApp, isMobileApp, isWebApp } from 'utilities/src/platform' import type { ITraceContext } from 'utilities/src/telemetry/trace/TraceContext' @@ -99,12 +102,14 @@ export function createPrepareSwapRequestParams({ gasStrategy }: { gasStrategy: G transactionSettings, alreadyApproved, overrideSimulation, + blockTimestamp, }: { swapQuoteResponse: ClassicQuoteResponse | BridgeQuoteResponse | WrapQuoteResponse | UnwrapQuoteResponse signature: string | undefined transactionSettings: TransactionSettings alreadyApproved: boolean overrideSimulation?: boolean + blockTimestamp?: bigint | number }): TradingApi.CreateSwapRequest { const isBridgeTrade = swapQuoteResponse.routing === TradingApi.Routing.BRIDGE const permitData = swapQuoteResponse.permitData @@ -118,7 +123,7 @@ export function createPrepareSwapRequestParams({ gasStrategy }: { gasStrategy: G */ const shouldSimulateTxn = overrideSimulation ?? (isBridgeTrade ? false : alreadyApproved) - const deadline = getTradeSettingsDeadline(transactionSettings.customDeadline) + const deadline = getTradeSettingsDeadline(transactionSettings.customDeadline, blockTimestamp) return { quote: swapQuoteResponse.quote, @@ -191,51 +196,350 @@ export function getSimulationError({ return null } +/** + * Calculate gasFee from quote response + * If gasFee is directly available, use it. Otherwise, calculate from gasPriceWei * gasUseEstimate + */ +function getGasFeeFromQuote( + swapQuote: TradingApi.ClassicQuote | TradingApi.BridgeQuote | undefined, + gasStrategy: GasStrategy, +): { value: string | undefined; displayValue: string | undefined } { + if (!swapQuote) { + return { value: undefined, displayValue: undefined } + } + + // Try to use gasFee directly if available + if ('gasFee' in swapQuote && swapQuote.gasFee) { + return { + value: swapQuote.gasFee, + displayValue: convertGasFeeToDisplayValue(swapQuote.gasFee, gasStrategy), + } + } + + // Calculate from gasPriceWei * gasUseEstimate if available + if ('gasPriceWei' in swapQuote && 'gasUseEstimate' in swapQuote) { + const gasPriceWei = swapQuote.gasPriceWei + const gasUseEstimate = swapQuote.gasUseEstimate + + if (gasPriceWei && gasUseEstimate && typeof gasPriceWei === 'string' && typeof gasUseEstimate === 'string') { + try { + // Calculate: gasFee = gasPriceWei * gasUseEstimate + const gasFeeValue = (BigInt(gasPriceWei) * BigInt(gasUseEstimate)).toString() + return { + value: gasFeeValue, + displayValue: convertGasFeeToDisplayValue(gasFeeValue, gasStrategy), + } + } catch (error) { + // If calculation fails, return undefined + return { value: undefined, displayValue: undefined } + } + } + } + + return { value: undefined, displayValue: undefined } +} + +/** + * Build transaction request from quote methodParameters when swap API response is not available + */ +function getRouterAddressForChain(chainId: number): { routerAddress?: string; routerSource?: string } { + // Get router address from chainId + // For HashKey chains, use the first address from CHAIN_TO_UNIVERSAL_ROUTER_ADDRESS if available + // Otherwise, try to get from UNIVERSAL_ROUTER_ADDRESS function + let routerAddress: string | undefined + let routerSource: string | undefined + + // Try to get from CHAIN_TO_UNIVERSAL_ROUTER_ADDRESS first (for HashKey chains) + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { CHAIN_TO_UNIVERSAL_ROUTER_ADDRESS } = require('uniswap/src/features/transactions/swap/components/UnichainInstantBalanceModal/constants') + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { UniverseChainId: UniverseChainIdEnum } = require('uniswap/src/features/chains/types') + + // Try both numeric key and enum key lookup + const chainIdAsEnum = chainId as UniverseChainId + let addresses = CHAIN_TO_UNIVERSAL_ROUTER_ADDRESS[chainIdAsEnum] + + // If not found, try looking up by numeric value (for HashKey chains: 133, 177) + if (!addresses && (chainId === 133 || chainId === 177)) { + // Try direct numeric lookup + addresses = CHAIN_TO_UNIVERSAL_ROUTER_ADDRESS[chainId as keyof typeof CHAIN_TO_UNIVERSAL_ROUTER_ADDRESS] + // Also try enum lookup + if (!addresses) { + const hashKeyEnumValue = chainId === 133 ? UniverseChainIdEnum.HashKeyTestnet : UniverseChainIdEnum.HashKey + addresses = CHAIN_TO_UNIVERSAL_ROUTER_ADDRESS[hashKeyEnumValue] + } + } + if (addresses && addresses.length > 0) { + routerAddress = addresses[0] + routerSource = 'CHAIN_TO_UNIVERSAL_ROUTER_ADDRESS' + } + } catch (error) { + // Ignore error + } + + // Fallback to UNIVERSAL_ROUTER_ADDRESS if available + if (!routerAddress) { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { UNIVERSAL_ROUTER_ADDRESS, UniversalRouterVersion } = require('@hkdex-tmp/universal_router_sdk') + routerAddress = UNIVERSAL_ROUTER_ADDRESS(UniversalRouterVersion.V1_2, chainId) + routerSource = 'UNIVERSAL_ROUTER_ADDRESS' + } catch (error) { + // Ignore error + } + } + + return { routerAddress, routerSource } +} + +function getGasLimitWithBuffer( + gasUseEstimate?: string | number, + { multiplier = 1.2 }: { multiplier?: number } = {}, +): string | undefined { + if (!gasUseEstimate) { + return undefined + } + try { + const gasUseEstimateStr = String(gasUseEstimate) + const factor = Math.floor(multiplier * 100) + const gasLimitWithBuffer = (BigInt(gasUseEstimateStr) * BigInt(factor)) / BigInt(100) + return gasLimitWithBuffer.toString() + } catch { + return undefined + } +} + +function buildTxRequestFromQuote( + swapQuote: TradingApi.ClassicQuote | TradingApi.BridgeQuote | undefined, + chainId: number, +): providers.TransactionRequest[] | undefined { + // Type assertion: methodParameters exists in ClassicQuote but may not be in type definition + const quoteWithMethodParams = swapQuote as (TradingApi.ClassicQuote | TradingApi.BridgeQuote) & { + methodParameters?: { calldata: string; value: string } + } + + if (!quoteWithMethodParams?.methodParameters) { + return undefined + } + + const { calldata, value } = quoteWithMethodParams.methodParameters + + if (!calldata) { + return undefined + } + + const { routerAddress } = getRouterAddressForChain(chainId) + + if (!routerAddress) { + // For HashKey chains (133, 177), they may not use Universal Router + if (chainId === 133 || chainId === 177) { + logger.error('HashKey chain does not have Universal Router configured', { + tags: { file: 'utils.ts', function: 'buildTxRequestFromQuote' }, + extra: { chainId }, + }) + return undefined + } + logger.error('Could not determine Universal Router address', { + tags: { file: 'utils.ts', function: 'buildTxRequestFromQuote' }, + extra: { chainId }, + }) + return undefined + } + + // Build transaction request from quote methodParameters + // Note: chainId is required for validateTransactionRequest to pass validation + const txRequest: providers.TransactionRequest = { + to: routerAddress, + data: calldata, + value: value && value !== '0x00' ? value : undefined, + chainId, // Required for validation + } + + // Add gas limit from quote if available + // This is critical - quote's gasUseEstimate is more accurate than provider's estimate + // Add 20% buffer to gas limit for safety (to account for price changes, etc.) + const quoteWithGasEstimate = quoteWithMethodParams as (TradingApi.ClassicQuote | TradingApi.BridgeQuote) & { + gasUseEstimate?: string | number + } + const isHashKey = chainId === UniverseChainId.HashKey || chainId === UniverseChainId.HashKeyTestnet + // HKSWAP: Increased gas limit multiplier for HashKey chains from 2.0 to 2.5 to prevent gas insufficient errors + // Increased multiplier for all chains from 1.5 to 2.0 to handle complex multi-hop swaps with nested calls + const gasLimitBuffered = getGasLimitWithBuffer(quoteWithGasEstimate.gasUseEstimate, { + multiplier: isHashKey ? 2.5 : 2.0, + }) + if (gasLimitBuffered) { + txRequest.gasLimit = gasLimitBuffered + } + + return [txRequest] +} + +function buildTxRequestFromTrade( + trade: ClassicTrade, + chainId: number, + deadline?: number, +): providers.TransactionRequest[] | undefined { + const { routerAddress } = getRouterAddressForChain(chainId) + if (!routerAddress) { + return undefined + } + + const slippageTolerance = slippageToleranceToPercent(trade.slippageTolerance) + const deadlineOrPreviousBlockhash = String(deadline ?? trade.deadline ?? 0) + const feeOptions = + trade.swapFee?.recipient && trade.swapFee.feeField === CurrencyField.OUTPUT + ? { fee: trade.swapFee.percent, recipient: trade.swapFee.recipient } + : undefined + + // HKSWAP: Increased gas limit multiplier for HashKey chains from 2.0 to 2.5 to prevent gas insufficient errors + // Increased multiplier for all chains from 1.2 to 2.0 to handle complex multi-hop swaps with nested calls + const gasLimitBuffered = getGasLimitWithBuffer((trade as any)?.quote?.quote?.gasUseEstimate, { + multiplier: chainId === UniverseChainId.HashKey || chainId === UniverseChainId.HashKeyTestnet ? 2.5 : 2.0, + }) + + const { calldata, value } = V3SwapRouter.swapCallParameters(trade, { + slippageTolerance, + deadlineOrPreviousBlockhash, + fee: feeOptions, + }) + + const txRequest: providers.TransactionRequest = { + to: routerAddress, + data: calldata, + value: value && value !== '0x00' ? value : undefined, + chainId, + ...(gasLimitBuffered ? { gasLimit: gasLimitBuffered } : {}), + } + + return [txRequest] +} + export function createProcessSwapResponse({ gasStrategy }: { gasStrategy: GasStrategy }) { return function processSwapResponse({ response, error, swapQuote, + trade, isSwapLoading, permitData, swapRequestParams, isRevokeNeeded, permitsDontNeedSignature, + chainId, }: { response: SwapData | undefined error: Error | null swapQuote: TradingApi.ClassicQuote | TradingApi.BridgeQuote | undefined + trade?: ClassicTrade isSwapLoading: boolean permitData: TradingApi.NullablePermit | undefined swapRequestParams: TradingApi.CreateSwapRequest | undefined isRevokeNeeded: boolean permitsDontNeedSignature?: boolean + chainId?: number }): TransactionRequestInfo { - // We use the gasFee estimate from quote, as its more accurate - const swapGasFee = { - value: swapQuote?.gasFee, - displayValue: convertGasFeeToDisplayValue(swapQuote?.gasFee, gasStrategy), + // Try to get chainId from swapQuote if not provided + let finalChainId = chainId + if (!finalChainId && swapQuote) { + // Try to get chainId from quote's token chain IDs + const quoteWithChainIds = swapQuote as (TradingApi.ClassicQuote | TradingApi.BridgeQuote) & { + tokenInChainId?: number | string + tokenOutChainId?: number | string } + const tokenInChainId = 'tokenInChainId' in quoteWithChainIds ? quoteWithChainIds.tokenInChainId : undefined + const tokenOutChainId = 'tokenOutChainId' in quoteWithChainIds ? quoteWithChainIds.tokenOutChainId : undefined + + if (tokenInChainId && typeof tokenInChainId === 'number') { + finalChainId = tokenInChainId + } else if (tokenOutChainId && typeof tokenOutChainId === 'number') { + finalChainId = tokenOutChainId + } + } + + // Final fallback: use HashKeyTestnet (133) since we only support HSK chains + if (!finalChainId) { + finalChainId = 133 // UniverseChainId.HashKeyTestnet + } + + // We use the gasFee estimate from quote, as its more accurate + // Calculate gasFee from quote response (either directly from gasFee or from gasPriceWei * gasUseEstimate) + const swapGasFee = getGasFeeFromQuote(swapQuote, gasStrategy) // This is a case where simulation fails on backend, meaning txn is expected to fail const simulationError = getSimulationError({ swapQuote, isRevokeNeeded }) const gasEstimateError = simulationError ?? error + // Only set error if there's actually an error (not just an empty object) + // Check if error is a valid Error instance or has meaningful content + let finalError: Error | null = null + if (gasEstimateError) { + if (gasEstimateError instanceof Error) { + finalError = gasEstimateError + } else if (typeof gasEstimateError === 'object' && gasEstimateError !== null) { + // Check if it's not just an empty object + const errorKeys = Object.keys(gasEstimateError) + if (errorKeys.length > 0) { + // Convert to Error if it has content + finalError = new Error(JSON.stringify(gasEstimateError)) + } + } + } + const gasFeeResult = { value: swapGasFee.value, displayValue: swapGasFee.displayValue, isLoading: isSwapLoading, - error: gasEstimateError, + error: finalError, } const gasEstimate: SwapGasFeeEstimation = { swapEstimate: response?.gasEstimate, } + const isHashKeyChain = finalChainId === UniverseChainId.HashKey || finalChainId === UniverseChainId.HashKeyTestnet + + // Use swap API transactions if available, otherwise build from quote methodParameters + let txRequests: providers.TransactionRequest[] | undefined + + if (isHashKeyChain && trade) { + txRequests = buildTxRequestFromTrade(trade, finalChainId, swapRequestParams?.deadline) + if (!txRequests) { + logger.error('HashKey chain failed to build SwapRouter02 txRequest from trade', { + tags: { file: 'utils.ts', function: 'processSwapResponse' }, + extra: { chainId: finalChainId }, + }) + } + } else if (response?.transactions) { + txRequests = response.transactions + } else if (finalChainId && swapQuote) { + txRequests = buildTxRequestFromQuote(swapQuote, finalChainId) + if (!txRequests) { + // This error means swap cannot proceed - txRequests is required + } + } else { + // This should not happen if finalChainId fallback is working correctly + // But if it does, we should still try to build with finalChainId + if (finalChainId && swapQuote) { + txRequests = buildTxRequestFromQuote(swapQuote, finalChainId) + } + } + + // HashKey does not provide reliable gas simulation via backend; allow swap flow to proceed. + if (isHashKeyChain) { + if (gasFeeResult.value === undefined) { + gasFeeResult.value = '0' + gasFeeResult.displayValue = '0' + } + if (gasFeeResult.error) { + gasFeeResult.error = null + } + } + return { gasFeeResult, - txRequests: response?.transactions, + txRequests, permitData: permitsDontNeedSignature ? undefined : permitData, gasEstimate, includesDelegation: response?.includesDelegation, @@ -332,6 +636,7 @@ export function createGasFields({ permitTxInfo.gasFeeResult, ) + const gasFeeEstimation: SwapGasFeeEstimation = { ...swapTxInfo.gasEstimate, approvalEstimate: approvalGasFeeResult.gasEstimate, diff --git a/packages/uniswap/src/features/transactions/swap/services/executeSwapService.ts b/packages/uniswap/src/features/transactions/swap/services/executeSwapService.ts index f817d1aae13..a8b9951857b 100644 --- a/packages/uniswap/src/features/transactions/swap/services/executeSwapService.ts +++ b/packages/uniswap/src/features/transactions/swap/services/executeSwapService.ts @@ -55,24 +55,21 @@ export function createExecuteSwapService(ctx: { !isSignerMnemonicAccountDetails(account) || !isValidSwapTxContext(swapTxContext) ) { - ctx.onFailure( - new Error( - !account + const errorMessage = !account ? 'No account available' : !swapTxContext ? 'Missing swap transaction context' : !isSignerMnemonicAccountDetails(account) ? 'Invalid account type - must be signer mnemonic account' - : 'Invalid swap transaction context', - ), - ) + : 'Invalid swap transaction context' + + ctx.onFailure(new Error(errorMessage)) return } const { presetPercentage, preselectAsset } = ctx.getPresetInfo() - ctx - .onExecuteSwap({ + const executeParams = { account, swapTxContext, currencyInAmountUSD: currencyAmountsUSDValue[CurrencyField.INPUT] ?? undefined, @@ -89,8 +86,9 @@ export function createExecuteSwapService(ctx: { isFiatInputMode: ctx.getIsFiatMode?.(), wrapType, inputCurrencyAmount: currencyAmounts.input ?? undefined, - }) - .catch(ctx.onFailure) + } + + ctx.onExecuteSwap(executeParams) }, } } diff --git a/packages/uniswap/src/features/transactions/swap/services/hooks/usePrepareSwap.ts b/packages/uniswap/src/features/transactions/swap/services/hooks/usePrepareSwap.ts index ec134e49a94..8e6ac78cde5 100644 --- a/packages/uniswap/src/features/transactions/swap/services/hooks/usePrepareSwap.ts +++ b/packages/uniswap/src/features/transactions/swap/services/hooks/usePrepareSwap.ts @@ -19,7 +19,7 @@ const getIsViewOnlyWallet = (activeAccount?: AccountDetails): boolean => { return activeAccount?.accountType === AccountType.Readonly } -export function usePrepareSwap(ctx: { warningService: WarningService }): () => void { +export function usePrepareSwap(ctx: { warningService: WarningService; onExecuteSwapDirectly?: () => void }): () => void { const { handleShowTokenWarningModal, handleShowBridgingWarningModal, @@ -79,6 +79,7 @@ export function usePrepareSwap(ctx: { warningService: WarningService }): () => v // ctx warningService: ctx.warningService, logger, + onExecuteSwapDirectly: ctx.onExecuteSwapDirectly, }), ) } diff --git a/packages/uniswap/src/features/transactions/swap/services/prepareSwapService.ts b/packages/uniswap/src/features/transactions/swap/services/prepareSwapService.ts index a9b9c9cc0a1..a0749e585ca 100644 --- a/packages/uniswap/src/features/transactions/swap/services/prepareSwapService.ts +++ b/packages/uniswap/src/features/transactions/swap/services/prepareSwapService.ts @@ -20,12 +20,14 @@ export function createPrepareSwap( const getAction = createGetAction(ctx) const handleEventAction = createHandleEventAction(ctx) - const action = getAction({ + const skipWarnings = { skipBridgingWarning: ctx.warningService.getSkipBridgingWarning(), skipMaxTransferWarning: ctx.warningService.getSkipMaxTransferWarning(), skipTokenProtectionWarning: ctx.warningService.getSkipTokenProtectionWarning(), skipBridgedAssetWarning: ctx.warningService.getSkipBridgedAssetWarning(), - }) + } + + const action = getAction(skipWarnings) handleEventAction(action) } catch (error) { @@ -154,6 +156,7 @@ interface HandleEventActionContext { onConnectWallet?: (platform?: Platform) => void updateSwapForm: (newState: Partial) => void setScreen: (screen: TransactionScreen) => void + onExecuteSwapDirectly?: () => void } function createHandleEventAction(ctx: HandleEventActionContext): (action: ReviewAction) => void { @@ -167,6 +170,7 @@ function createHandleEventAction(ctx: HandleEventActionContext): (action: Review onConnectWallet, updateSwapForm, setScreen, + onExecuteSwapDirectly, } = ctx function handleEventAction(action: ReviewAction): void { switch (action.type) { @@ -193,8 +197,14 @@ function createHandleEventAction(ctx: HandleEventActionContext): (action: Review handleShowBridgedAssetModal() break case ReviewActionType.PROCEED_TO_REVIEW: - updateSwapForm({ txId: createTransactionId() }) - setScreen(TransactionScreen.Review) + const txId = createTransactionId() + updateSwapForm({ txId }) + // If onExecuteSwapDirectly is provided, execute swap directly instead of showing review screen + if (onExecuteSwapDirectly) { + onExecuteSwapDirectly() + } else { + setScreen(TransactionScreen.Review) + } break } } diff --git a/packages/uniswap/src/features/transactions/swap/services/tradeService/evmTradeService.ts b/packages/uniswap/src/features/transactions/swap/services/tradeService/evmTradeService.ts index 5a0cb0be4b9..6469529891a 100644 --- a/packages/uniswap/src/features/transactions/swap/services/tradeService/evmTradeService.ts +++ b/packages/uniswap/src/features/transactions/swap/services/tradeService/evmTradeService.ts @@ -103,6 +103,7 @@ export function createEVMTradeService(ctx: EVMTradeServiceContext): TradeService const quoteHash = getIdentifierForQuote(quoteRequestArgs) // Step 5: Transform quote to trade + // Pass customSlippageTolerance to ensure trade uses user configuration instead of API response const result = transformQuoteToTrade({ quote: quoteResponse, amountSpecified: validatedInput.amount, @@ -111,6 +112,7 @@ export function createEVMTradeService(ctx: EVMTradeServiceContext): TradeService currencyOut: validatedInput.currencyOut, requestTradeType: validatedInput.requestTradeType, }, + customSlippageTolerance: input.customSlippageTolerance, }) // Return trade with gas estimates return { diff --git a/packages/uniswap/src/features/transactions/swap/services/tradeService/transformations/buildQuoteRequest.ts b/packages/uniswap/src/features/transactions/swap/services/tradeService/transformations/buildQuoteRequest.ts index d93bbd29d59..b4de5b8d371 100644 --- a/packages/uniswap/src/features/transactions/swap/services/tradeService/transformations/buildQuoteRequest.ts +++ b/packages/uniswap/src/features/transactions/swap/services/tradeService/transformations/buildQuoteRequest.ts @@ -97,10 +97,14 @@ export type FlattenedQuoteRequestResult = Omit> quoteCurrencyData: QuoteCurrencyData + customSlippageTolerance?: number }): QuoteWithTradeAndGasEstimate { if (!input.quote) { return null @@ -35,6 +36,18 @@ export function transformQuoteToTrade(input: { requestTradeType === TradingApi.TradeType.EXACT_INPUT ? SdkTradeType.EXACT_INPUT : SdkTradeType.EXACT_OUTPUT const gasEstimate = getGasEstimate(input.quote) + // Override quote.slippage with customSlippageTolerance if provided + // This ensures trade.slippageTolerance uses user configuration instead of API response + const quoteWithCustomSlippage = input.customSlippageTolerance !== undefined && input.quote.routing === TradingApi.Routing.CLASSIC + ? { + ...input.quote, + quote: { + ...input.quote.quote, + slippage: input.customSlippageTolerance, + }, + } + : input.quote + const formattedTrade = currencyIn && currencyOut ? transformTradingApiResponseToTrade({ @@ -42,7 +55,7 @@ export function transformQuoteToTrade(input: { currencyOut, tradeType, deadline: inXMinutesUnix(DEFAULT_SWAP_VALIDITY_TIME_MINS), // TODO(MOB-3050): set deadline as `quoteRequestArgs.deadline` - data: input.quote, + data: quoteWithCustomSlippage, }) : null @@ -57,7 +70,7 @@ export function transformQuoteToTrade(input: { : null return { - ...input.quote, + ...quoteWithCustomSlippage, gasEstimate, trade, } diff --git a/packages/uniswap/src/features/transactions/swap/steps/swap.ts b/packages/uniswap/src/features/transactions/swap/steps/swap.ts index c59bc330d8a..7101d11d850 100644 --- a/packages/uniswap/src/features/transactions/swap/steps/swap.ts +++ b/packages/uniswap/src/features/transactions/swap/steps/swap.ts @@ -1,6 +1,5 @@ import { TradingApi } from '@universe/api' import invariant from 'tiny-invariant' -import { TradingApiClient } from 'uniswap/src/data/apiClients/tradingApi/TradingApiClient' import { UnexpectedTransactionStateError } from 'uniswap/src/features/transactions/errors' import { OnChainTransactionFields, @@ -28,24 +27,20 @@ export function createSwapTransactionStep(txRequest: ValidatedTransactionRequest return { type: TransactionStepType.SwapTransaction, txRequest } } +/** + * HashKey chains (133, 177) do not use swap API. + * Transactions are built directly from quote methodParameters. + * This function should never be called for HashKey chains. + */ export function createSwapTransactionAsyncStep( swapRequestArgs: TradingApi.CreateSwapRequest | undefined, ): SwapTransactionStepAsync { return { type: TransactionStepType.SwapTransactionAsync, - getTxRequest: async (signature: string): Promise => { - if (!swapRequestArgs) { - return undefined - } - - const { swap } = await TradingApiClient.fetchSwap({ - ...swapRequestArgs, - signature, - /* simulating transaction provides a more accurate gas limit, and the simulation will succeed because async swap step will only occur after approval has been confirmed. */ - simulateTransaction: true, - }) - - return validateTransactionRequest(swap) + getTxRequest: async (): Promise => { + throw new Error( + 'Swap API is not used for HashKey chains. Transactions should be built from quote methodParameters using buildTxRequestFromTrade.', + ) }, } } diff --git a/packages/uniswap/src/features/transactions/swap/stores/swapFormStore/hooks/useDefaultSwapFormState.ts b/packages/uniswap/src/features/transactions/swap/stores/swapFormStore/hooks/useDefaultSwapFormState.ts index 4705b5cebcf..a894685f264 100644 --- a/packages/uniswap/src/features/transactions/swap/stores/swapFormStore/hooks/useDefaultSwapFormState.ts +++ b/packages/uniswap/src/features/transactions/swap/stores/swapFormStore/hooks/useDefaultSwapFormState.ts @@ -19,7 +19,7 @@ export const getDefaultState = (defaultChainId: UniverseChainId): Readonly bigint | undefined) | undefined +if (isWebApp) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const useCurrentBlockTimestampModule = require('apps/web/src/hooks/useCurrentBlockTimestamp') + useCurrentBlockTimestamp = useCurrentBlockTimestampModule.default || useCurrentBlockTimestampModule +} function useSwapTransactionRequestInfo({ derivedSwapInfo, @@ -31,26 +38,70 @@ function useSwapTransactionRequestInfo({ tokenApprovalInfo: TokenApprovalInfo | undefined }): TransactionRequestInfo { const trace = useTrace() - const gasStrategy = useActiveGasStrategy(derivedSwapInfo.chainId, 'general') const transactionSettings = useAllTransactionSettings() const permitData = derivedSwapInfo.trade.trade?.quote.permitData // On interface, we do not fetch signature until after swap is clicked, as it requires user interaction. const { data: signature } = usePermit2SignatureWithData({ permitData, skip: isWebApp }) + // Keep track of the last successful quote to use if current quote fails + const lastSuccessfulQuoteRef = useRef( + undefined, + ) + const swapQuoteResponse = useMemo(() => { const quote = derivedSwapInfo.trade.trade?.quote if (quote && (isClassic(quote) || isBridge(quote) || isWrap(quote))) { + // Update the last successful quote + lastSuccessfulQuoteRef.current = quote return quote } + // If current quote is not available, use the last successful quote + if (lastSuccessfulQuoteRef.current) { + return lastSuccessfulQuoteRef.current + } return undefined }, [derivedSwapInfo.trade.trade?.quote]) const swapQuote = swapQuoteResponse?.quote - const swapDelegationInfo = useUniswapContextSelector((ctx) => ctx.getSwapDelegationInfo?.(derivedSwapInfo.chainId)) + // Get current chainId from active account if derivedSwapInfo.chainId is not available + // We only support HSK chains (HashKey = 177, HashKeyTestnet = 133) + const activeAccount = useActiveAccount(Platform.EVM) + const currentChainId = (activeAccount as { chainId?: UniverseChainId } | undefined)?.chainId ?? derivedSwapInfo.chainId + + // Resolve chainId with multiple fallbacks + // Priority: 1. currentChainId (from account or derivedSwapInfo), 2. swapQuote tokenInChainId, 3. swapQuote tokenOutChainId, 4. default HashKeyTestnet + const resolvedChainId = useMemo(() => { + // First try: use currentChainId if available + if (currentChainId) { + return currentChainId + } + + // Second try: get from swapQuote tokenInChainId + if (swapQuote && 'tokenInChainId' in swapQuote && swapQuote.tokenInChainId) { + return swapQuote.tokenInChainId as UniverseChainId + } + + // Third try: get from swapQuote tokenOutChainId + if (swapQuote && 'tokenOutChainId' in swapQuote && swapQuote.tokenOutChainId) { + return swapQuote.tokenOutChainId as UniverseChainId + } + + // Final fallback: use HashKeyTestnet (133) since we only support HSK chains + return UniverseChainId.HashKeyTestnet + }, [currentChainId, swapQuote]) + + const gasStrategy = useActiveGasStrategy(resolvedChainId, 'general') + + const swapDelegationInfo = useUniswapContextSelector((ctx) => ctx.getSwapDelegationInfo?.(resolvedChainId)) const overrideSimulation = !!swapDelegationInfo?.delegationAddress + // Get block timestamp for accurate deadline calculation + // Only fetch on web app (where useCurrentBlockTimestamp is available) + const blockTimestamp = isWebApp && useCurrentBlockTimestamp ? useCurrentBlockTimestamp() : undefined + + const prepareSwapRequestParams = useMemo(() => createPrepareSwapRequestParams({ gasStrategy }), [gasStrategy]) const swapRequestParams = useMemo(() => { @@ -60,13 +111,16 @@ function useSwapTransactionRequestInfo({ const alreadyApproved = tokenApprovalInfo?.action === ApprovalAction.None && !swapQuoteResponse.permitTransaction - return prepareSwapRequestParams({ + const requestParams = prepareSwapRequestParams({ swapQuoteResponse, signature: signature ?? undefined, transactionSettings, alreadyApproved, overrideSimulation, + blockTimestamp, }) + + return requestParams }, [ swapQuoteResponse, tokenApprovalInfo?.action, @@ -74,60 +128,42 @@ function useSwapTransactionRequestInfo({ signature, transactionSettings, overrideSimulation, + blockTimestamp, ]) const canBatchTransactions = useUniswapContextSelector((ctx) => - ctx.getCanBatchTransactions?.(derivedSwapInfo.chainId), + ctx.getCanBatchTransactions?.(resolvedChainId), ) const permitsDontNeedSignature = !!canBatchTransactions - const shouldSkipSwapRequest = getShouldSkipSwapRequest({ - derivedSwapInfo, - tokenApprovalInfo, - signature: signature ?? undefined, - permitsDontNeedSignature, - }) - - const tradingApiSwapRequestMs = useDynamicConfigValue({ - config: DynamicConfigs.Swap, - key: SwapConfigKey.TradingApiSwapRequestMs, - defaultValue: FALLBACK_SWAP_REQUEST_POLL_INTERVAL_MS, - }) - - const { - data, - error, - isLoading: isSwapLoading, - } = useTradingApiSwapQuery( - { - params: shouldSkipSwapRequest ? undefined : swapRequestParams, - refetchInterval: tradingApiSwapRequestMs, - staleTime: tradingApiSwapRequestMs, - // We add a small buffer in case connection is too slow - immediateGcTime: tradingApiSwapRequestMs + ONE_SECOND_MS * 5, - }, - { - canBatchTransactions, - swapDelegationAddress: swapDelegationInfo?.delegationAddress, - includesDelegation: swapDelegationInfo?.delegationInclusion, - }, - ) + + // Skip swap API call - use quote methodParameters directly instead + // Don't call swap API - we'll use quote methodParameters directly + const data = undefined + const error = null + const isSwapLoading = false const processSwapResponse = useMemo(() => createProcessSwapResponse({ gasStrategy }), [gasStrategy]) - const result = useMemo( - () => - processSwapResponse({ + const result = useMemo(() => { + // Ensure resolvedChainId is always defined + const chainIdToUse = resolvedChainId ?? UniverseChainId.HashKeyTestnet + + const processResult = processSwapResponse({ response: data, error, swapQuote, + trade: derivedSwapInfo.trade.trade ?? undefined, isSwapLoading, permitData, swapRequestParams, isRevokeNeeded: tokenApprovalInfo?.action === ApprovalAction.RevokeAndPermit2Approve, permitsDontNeedSignature, - }), - [ + chainId: chainIdToUse, + }) + + return processResult + }, [ data, error, isSwapLoading, @@ -137,8 +173,12 @@ function useSwapTransactionRequestInfo({ processSwapResponse, tokenApprovalInfo?.action, permitsDontNeedSignature, - ], - ) + resolvedChainId, + derivedSwapInfo, + swapQuoteResponse, + activeAccount, + currentChainId, + ]) // Only log analytics events once per request const previousRequestIdRef = useRef(swapQuoteResponse?.requestId) diff --git a/packages/uniswap/src/features/transactions/swap/types/trade.ts b/packages/uniswap/src/features/transactions/swap/types/trade.ts index 00356c49085..55adc351827 100644 --- a/packages/uniswap/src/features/transactions/swap/types/trade.ts +++ b/packages/uniswap/src/features/transactions/swap/types/trade.ts @@ -445,6 +445,11 @@ export interface UseTradeArgs { amountSpecified: Maybe> otherCurrency: Maybe tradeType: TradeType + // Explicit sell and buy tokens from UI to ensure consistency + // sellToken should always correspond to CurrencyField.INPUT (what user is selling) + // buyToken should always correspond to CurrencyField.OUTPUT (what user is buying) + sellToken?: Currency + buyToken?: Currency pollInterval?: PollingInterval customSlippageTolerance?: number isUSDQuote?: boolean diff --git a/packages/uniswap/src/features/transactions/swap/utils/gas.ts b/packages/uniswap/src/features/transactions/swap/utils/gas.ts index e31dd89ca12..8be515f6e6d 100644 --- a/packages/uniswap/src/features/transactions/swap/utils/gas.ts +++ b/packages/uniswap/src/features/transactions/swap/utils/gas.ts @@ -23,14 +23,38 @@ export function sumGasFees(gasFees: (string | undefined)[]): string | undefined * - displayValue: Sum of all display values (undefined if any result has error or missing value) */ export function mergeGasFeeResults(...gasFeeResults: GasFeeResult[]): GasFeeResult { - const error = gasFeeResults.map((g) => g.error).find((e) => !!e) ?? null + // Filter out "Approval action unknown" errors - these are informational and shouldn't block swap + // Also filter out empty objects and empty Error instances + const meaningfulErrors = gasFeeResults + .map((g) => g.error) + .filter((e) => { + if (!e) return false + if (e instanceof Error) { + // Filter out empty Error objects and "Approval action unknown" errors + if (!e.message || e.message.length === 0) return false + if (e.message === 'Approval action unknown') return false + return true + } + if (typeof e === 'object' && Object.keys(e).length > 0) return true + // Empty object {} should be filtered out + return false + }) + + // Use the first meaningful error, or null if none + const error = meaningfulErrors[0] ?? null + const isLoading = gasFeeResults.some((r) => r.isLoading) - const expectedValueMissing = gasFeeResults.some((r) => r.value === undefined) + // Only consider value missing if it's missing AND there's a meaningful error + // If value is missing but error is null (or filtered out), we can still proceed if other results have values + const resultsWithValues = gasFeeResults.filter((r) => r.value !== undefined) + const expectedValueMissing = resultsWithValues.length === 0 && error !== null + if (expectedValueMissing || error) { return { value: undefined, displayValue: undefined, error, isLoading } } + // Sum only the values that are defined const value = sumGasFees(gasFeeResults.map((r) => r.value)) const displayValue = sumGasFees(gasFeeResults.map((r) => r.displayValue)) return { value, displayValue, error, isLoading } diff --git a/packages/uniswap/src/features/transactions/swap/utils/generateSwapTransactionSteps.ts b/packages/uniswap/src/features/transactions/swap/utils/generateSwapTransactionSteps.ts index 1f6da056acf..dd3cd81a159 100644 --- a/packages/uniswap/src/features/transactions/swap/utils/generateSwapTransactionSteps.ts +++ b/packages/uniswap/src/features/transactions/swap/utils/generateSwapTransactionSteps.ts @@ -1,8 +1,10 @@ +import { Interface } from '@ethersproject/abi' import { createApprovalTransactionStep } from 'uniswap/src/features/transactions/steps/approve' import { createPermit2SignatureStep } from 'uniswap/src/features/transactions/steps/permit2Signature' import { createPermit2TransactionStep } from 'uniswap/src/features/transactions/steps/permit2Transaction' import { createRevocationTransactionStep } from 'uniswap/src/features/transactions/steps/revoke' import { TransactionStep } from 'uniswap/src/features/transactions/steps/types' +import { UniverseChainId } from 'uniswap/src/features/chains/types' import { orderClassicSwapSteps } from 'uniswap/src/features/transactions/swap/steps/classicSteps' import { createSignUniswapXOrderStep } from 'uniswap/src/features/transactions/swap/steps/signOrder' import { @@ -12,7 +14,37 @@ import { } from 'uniswap/src/features/transactions/swap/steps/swap' import { orderUniswapXSteps } from 'uniswap/src/features/transactions/swap/steps/uniswapxSteps' import { isValidSwapTxContext, SwapTxAndGasInfo } from 'uniswap/src/features/transactions/swap/types/swapTxAndGasInfo' +import type { BridgeTrade, ClassicTrade } from 'uniswap/src/features/transactions/swap/types/trade' import { isBridge, isClassic, isUniswapX } from 'uniswap/src/features/transactions/swap/utils/routing' +import type { ValidatedTransactionRequest } from 'uniswap/src/features/transactions/types/transactionRequests' + +function buildHashKeyApprovalTxRequest({ + trade, + swapTxRequest, +}: { + trade: ClassicTrade | BridgeTrade + swapTxRequest: ValidatedTransactionRequest | undefined +}): ValidatedTransactionRequest | undefined { + const chainId = trade.inputAmount.currency.chainId + const isHashKeyChain = chainId === UniverseChainId.HashKey || chainId === UniverseChainId.HashKeyTestnet + if (!isHashKeyChain) { + return undefined + } + + const spender = swapTxRequest?.to?.toString() + if (!spender) { + return undefined + } + + const approveInterface = new Interface(['function approve(address spender,uint256 value)']) + + return { + to: trade.inputAmount.currency.wrapped.address, + data: approveInterface.encodeFunctionData('approve', [spender, trade.inputAmount.quotient.toString()]), + value: '0x0', + chainId, + } +} export function generateSwapTransactionSteps(txContext: SwapTxAndGasInfo): TransactionStep[] { const isValidSwap = isValidSwapTxContext(txContext) @@ -30,7 +62,7 @@ export function generateSwapTransactionSteps(txContext: SwapTxAndGasInfo): Trans }) const approval = createApprovalTransactionStep({ ...requestFields, - txRequest: approveTxRequest, + txRequest: approveTxRequest ?? buildHashKeyApprovalTxRequest({ trade, swapTxRequest: txContext.txRequests?.[0] }), amount: trade.inputAmount.quotient.toString(), }) @@ -77,13 +109,14 @@ export function generateSwapTransactionSteps(txContext: SwapTxAndGasInfo): Trans permit: undefined, swap: createSwapTransactionStepBatched(txContext.txRequests), }) + } else { + return orderClassicSwapSteps({ + revocation, + approval, + permit: undefined, + swap: createSwapTransactionStep(txContext.txRequests[0]), + }) } - return orderClassicSwapSteps({ - revocation, - approval, - permit: undefined, - swap: createSwapTransactionStep(txContext.txRequests[0]), - }) } } diff --git a/packages/uniswap/src/features/transactions/swap/utils/getIsWebForNudgeEnabled.ts b/packages/uniswap/src/features/transactions/swap/utils/getIsWebForNudgeEnabled.ts index 7cb7c6fe964..8e61c350ecc 100644 --- a/packages/uniswap/src/features/transactions/swap/utils/getIsWebForNudgeEnabled.ts +++ b/packages/uniswap/src/features/transactions/swap/utils/getIsWebForNudgeEnabled.ts @@ -6,9 +6,11 @@ export function getIsWebFORNudgeEnabled(): boolean { return false } - return getExperimentValue({ + const result = getExperimentValue({ experiment: Experiments.WebFORNudges, param: WebFORNudgesProperties.NudgeEnabled, defaultValue: false, }) + + return result } diff --git a/packages/uniswap/src/features/transactions/swap/utils/getPriceImpact.ts b/packages/uniswap/src/features/transactions/swap/utils/getPriceImpact.ts index e95248050ea..c3a30757c20 100644 --- a/packages/uniswap/src/features/transactions/swap/utils/getPriceImpact.ts +++ b/packages/uniswap/src/features/transactions/swap/utils/getPriceImpact.ts @@ -1,60 +1,12 @@ -import { type Currency, CurrencyAmount, Percent } from '@uniswap/sdk-core' -import { getCurrencyAmount, ValueType } from 'uniswap/src/features/tokens/getCurrencyAmount' import type { DerivedSwapInfo } from 'uniswap/src/features/transactions/swap/types/derivedSwapInfo' -import { getSwapFeeUsdFromDerivedSwapInfo } from 'uniswap/src/features/transactions/swap/utils/getSwapFeeUsd' -import { isClassic, isJupiter, isUniswapX } from 'uniswap/src/features/transactions/swap/utils/routing' - -function stringToUSDAmount(value: string | number | undefined, USDCurrency: Currency): Maybe> { - if (!value) { - return undefined - } - - return getCurrencyAmount({ - value: value.toString().slice(0, USDCurrency.decimals), - valueType: ValueType.Exact, - currency: USDCurrency, - }) -} - -/** Returns the price impact of the current trade, including UniswapX trades. UniswapX trades do not have typical pool-based price impact; we use a frontend-calculated metric. */ -function getUniswapXPriceImpact({ derivedSwapInfo }: { derivedSwapInfo: DerivedSwapInfo }): Percent | undefined { - const trade = derivedSwapInfo.trade.trade - const { input: inputUSD, output: outputUSD } = derivedSwapInfo.currencyAmountsUSDValue - - if (!trade || !isUniswapX(trade) || !trade.quote.quote.classicGasUseEstimateUSD || !inputUSD || !outputUSD) { - return undefined - } - - const classicGasEstimateUSD = stringToUSDAmount(trade.quote.quote.classicGasUseEstimateUSD, inputUSD.currency) - const swapFeeUSDString = getSwapFeeUsdFromDerivedSwapInfo(derivedSwapInfo) - const swapFeeUSD = - stringToUSDAmount(swapFeeUSDString, inputUSD.currency) ?? CurrencyAmount.fromRawAmount(inputUSD.currency, '0') - - if (!classicGasEstimateUSD) { - return undefined - } - - const result = outputUSD - .add(classicGasEstimateUSD) - .add(swapFeeUSD) - .divide(inputUSD) - .asFraction.subtract(1) - .multiply(-1) - - return new Percent(result.numerator, result.denominator) -} +import { isClassic } from 'uniswap/src/features/transactions/swap/utils/routing' +// HKSWAP: Simplified - HashKey chains only support Classic trades (no UniswapX, no Jupiter/Solana) export function getPriceImpact(derivedSwapInfo: DerivedSwapInfo): Percent | undefined { const trade = derivedSwapInfo.trade.trade - if (!trade) { + if (!trade || !isClassic(trade)) { return undefined } - if (isUniswapX(trade)) { - return getUniswapXPriceImpact({ derivedSwapInfo }) - } else if (isClassic(trade) || isJupiter(trade)) { - return trade.priceImpact - } else { - return undefined - } + return trade.priceImpact } diff --git a/packages/uniswap/src/features/transactions/swap/utils/protocols.ts b/packages/uniswap/src/features/transactions/swap/utils/protocols.ts index 8f0fa23647f..c34d197ddbf 100644 --- a/packages/uniswap/src/features/transactions/swap/utils/protocols.ts +++ b/packages/uniswap/src/features/transactions/swap/utils/protocols.ts @@ -6,14 +6,10 @@ import { createGetSupportedChainId } from 'uniswap/src/features/chains/hooks/use import { UniverseChainId } from 'uniswap/src/features/chains/types' import { createGetV4SwapEnabled, useV4SwapEnabled } from 'uniswap/src/features/transactions/swap/hooks/useV4SwapEnabled' +// HSKSwap only supports V3 export const DEFAULT_PROTOCOL_OPTIONS = [ // `as const` allows us to derive a type narrower than ProtocolItems, and the `...` spread removes readonly, allowing DEFAULT_PROTOCOL_OPTIONS to be passed around as an argument without `readonly` - ...([ - TradingApi.ProtocolItems.UNISWAPX_V2, - TradingApi.ProtocolItems.V4, - TradingApi.ProtocolItems.V3, - TradingApi.ProtocolItems.V2, - ] as const), + ...([TradingApi.ProtocolItems.V3] as const), ] export type FrontendSupportedProtocol = (typeof DEFAULT_PROTOCOL_OPTIONS)[number] diff --git a/packages/uniswap/src/features/transactions/swap/utils/tradingApi.ts b/packages/uniswap/src/features/transactions/swap/utils/tradingApi.ts index 42cbd5c9e96..d4e98b08884 100644 --- a/packages/uniswap/src/features/transactions/swap/utils/tradingApi.ts +++ b/packages/uniswap/src/features/transactions/swap/utils/tradingApi.ts @@ -373,7 +373,17 @@ function isTradingApiSupportedChainId(chainId?: number): chainId is TradingApi.C } export function toTradingApiSupportedChainId(chainId: Maybe): TradingApi.ChainId | undefined { - if (!chainId || !isTradingApiSupportedChainId(chainId)) { + if (!chainId) { + return undefined + } + + // HashKey chains are supported by the Trading API (even if not in TradingApi.ChainId enum) + // The backend will handle the conversion to internal format ("hsk" or "hsktest") + if (chainId === 177 || chainId === 133) { + return chainId as TradingApi.ChainId + } + + if (!isTradingApiSupportedChainId(chainId)) { return undefined } return chainId @@ -482,29 +492,23 @@ export function createGetQuoteRoutingParams(ctx: { const { isUSDQuote } = input // for USD quotes, we avoid routing through UniswapX // hooksOptions should not be sent for USD quotes + // HSKSwap only supports V3 if (isUSDQuote) { return { - protocols: [TradingApi.ProtocolItems.V2, TradingApi.ProtocolItems.V3, TradingApi.ProtocolItems.V4], + protocols: [TradingApi.ProtocolItems.V3], } } const protocols = ctx.getProtocols() - let finalProtocols = [...protocols] - let hooksOptions: TradingApi.HooksOptions + // HSKSwap only supports V3, filter out V2, V4, and UniswapX + const v3OnlyProtocols = protocols.filter((p) => p === TradingApi.ProtocolItems.V3) - const isV4HookPoolsEnabled = ctx.getIsV4HookPoolsEnabled() + // If no V3 in protocols, force V3 (shouldn't happen with DEFAULT_PROTOCOL_OPTIONS, but safety check) + const finalProtocols = v3OnlyProtocols.length > 0 ? v3OnlyProtocols : [TradingApi.ProtocolItems.V3] - if (isV4HookPoolsEnabled) { - if (!protocols.includes(TradingApi.ProtocolItems.V4)) { - finalProtocols = [...protocols, TradingApi.ProtocolItems.V4] // we need to re-add v4 to protocols if v4 hooks is toggled on - hooksOptions = TradingApi.HooksOptions.V4_HOOKS_ONLY - } else { - hooksOptions = TradingApi.HooksOptions.V4_HOOKS_INCLUSIVE - } - } else { - hooksOptions = TradingApi.HooksOptions.V4_NO_HOOKS - } + // HSKSwap doesn't support V4 hooks + const hooksOptions = TradingApi.HooksOptions.V4_NO_HOOKS return { protocols: finalProtocols, hooksOptions } } diff --git a/packages/uniswap/src/i18n/i18n-setup-interface.tsx b/packages/uniswap/src/i18n/i18n-setup-interface.tsx index 8e8858fe8af..9904b9ffb33 100644 --- a/packages/uniswap/src/i18n/i18n-setup-interface.tsx +++ b/packages/uniswap/src/i18n/i18n-setup-interface.tsx @@ -29,10 +29,12 @@ export function setupi18n(): undefined { return enUsLocale } - const fileName = getLocaleTranslationKey(locale) + // Only English is supported - return undefined for other languages + return undefined - // eslint-disable-next-line no-unsanitized/method - return import(`./locales/translations/${fileName}.json`) + // const fileName = getLocaleTranslationKey(locale) + // // eslint-disable-next-line no-unsanitized/method + // return import(`./locales/translations/${fileName}.json`) }), ) // eslint-disable-next-line max-params diff --git a/packages/uniswap/src/i18n/i18n-setup.tsx b/packages/uniswap/src/i18n/i18n-setup.tsx index 61cfdfc348a..ad0f73ea034 100644 --- a/packages/uniswap/src/i18n/i18n-setup.tsx +++ b/packages/uniswap/src/i18n/i18n-setup.tsx @@ -3,58 +3,60 @@ import 'uniswap/src/i18n/locales/@types/i18next' import i18n from 'i18next' import { initReactI18next } from 'react-i18next' import enUS from 'uniswap/src/i18n/locales/source/en-US.json' -import esES from 'uniswap/src/i18n/locales/translations/es-ES.json' -import frFR from 'uniswap/src/i18n/locales/translations/fr-FR.json' -import idID from 'uniswap/src/i18n/locales/translations/id-ID.json' -import jaJP from 'uniswap/src/i18n/locales/translations/ja-JP.json' -import nlNL from 'uniswap/src/i18n/locales/translations/nl-NL.json' -import ptPT from 'uniswap/src/i18n/locales/translations/pt-PT.json' -import ruRU from 'uniswap/src/i18n/locales/translations/ru-RU.json' -import trTR from 'uniswap/src/i18n/locales/translations/tr-TR.json' -import viVN from 'uniswap/src/i18n/locales/translations/vi-VN.json' -import zhCN from 'uniswap/src/i18n/locales/translations/zh-CN.json' -import zhTW from 'uniswap/src/i18n/locales/translations/zh-TW.json' +// Only English is supported - other languages are disabled +// import esES from 'uniswap/src/i18n/locales/translations/es-ES.json' +// import frFR from 'uniswap/src/i18n/locales/translations/fr-FR.json' +// import idID from 'uniswap/src/i18n/locales/translations/id-ID.json' +// import jaJP from 'uniswap/src/i18n/locales/translations/ja-JP.json' +// import nlNL from 'uniswap/src/i18n/locales/translations/nl-NL.json' +// import ptPT from 'uniswap/src/i18n/locales/translations/pt-PT.json' +// import ruRU from 'uniswap/src/i18n/locales/translations/ru-RU.json' +// import trTR from 'uniswap/src/i18n/locales/translations/tr-TR.json' +// import viVN from 'uniswap/src/i18n/locales/translations/vi-VN.json' +// import zhCN from 'uniswap/src/i18n/locales/translations/zh-CN.json' +// import zhTW from 'uniswap/src/i18n/locales/translations/zh-TW.json' import { MissingI18nInterpolationError } from 'uniswap/src/i18n/shared' import { getWalletDeviceLocale } from 'uniswap/src/i18n/utils' import { logger } from 'utilities/src/logger/logger' +// Only English is supported - other languages are disabled const resources = { - 'zh-Hans': { translation: zhCN, statsigKey: 'zh-CN' }, - 'zh-Hant': { translation: zhTW, statsigKey: 'zh-TW' }, - 'nl-NL': { translation: nlNL, statsigKey: 'nl-NL' }, 'en-US': { translation: enUS, statsigKey: 'en-US' }, - 'fr-FR': { translation: frFR, statsigKey: 'fr-FR' }, - 'id-ID': { translation: idID, statsigKey: 'id-ID' }, - 'ja-JP': { translation: jaJP, statsigKey: 'ja-JP' }, - 'pt-PT': { translation: ptPT, statsigKey: 'pt-PT' }, - 'ru-RU': { translation: ruRU, statsigKey: 'ru-RU' }, + // 'zh-Hans': { translation: zhCN, statsigKey: 'zh-CN' }, + // 'zh-Hant': { translation: zhTW, statsigKey: 'zh-TW' }, + // 'nl-NL': { translation: nlNL, statsigKey: 'nl-NL' }, + // 'fr-FR': { translation: frFR, statsigKey: 'fr-FR' }, + // 'id-ID': { translation: idID, statsigKey: 'id-ID' }, + // 'ja-JP': { translation: jaJP, statsigKey: 'ja-JP' }, + // 'pt-PT': { translation: ptPT, statsigKey: 'pt-PT' }, + // 'ru-RU': { translation: ruRU, statsigKey: 'ru-RU' }, // Spanish locales that use `,` as the decimal separator - 'es-419': { translation: esES, statsigKey: 'es-ES' }, - 'es-BZ': { translation: esES, statsigKey: 'es-ES' }, - 'es-CU': { translation: esES, statsigKey: 'es-ES' }, - 'es-DO': { translation: esES, statsigKey: 'es-ES' }, - 'es-GT': { translation: esES, statsigKey: 'es-ES' }, - 'es-HN': { translation: esES, statsigKey: 'es-ES' }, - 'es-MX': { translation: esES, statsigKey: 'es-ES' }, - 'es-NI': { translation: esES, statsigKey: 'es-ES' }, - 'es-PA': { translation: esES, statsigKey: 'es-ES' }, - 'es-PE': { translation: esES, statsigKey: 'es-ES' }, - 'es-PR': { translation: esES, statsigKey: 'es-ES' }, - 'es-SV': { translation: esES, statsigKey: 'es-ES' }, - 'es-US': { translation: esES, statsigKey: 'es-ES' }, + // 'es-419': { translation: esES, statsigKey: 'es-ES' }, + // 'es-BZ': { translation: esES, statsigKey: 'es-ES' }, + // 'es-CU': { translation: esES, statsigKey: 'es-ES' }, + // 'es-DO': { translation: esES, statsigKey: 'es-ES' }, + // 'es-GT': { translation: esES, statsigKey: 'es-ES' }, + // 'es-HN': { translation: esES, statsigKey: 'es-ES' }, + // 'es-MX': { translation: esES, statsigKey: 'es-ES' }, + // 'es-NI': { translation: esES, statsigKey: 'es-ES' }, + // 'es-PA': { translation: esES, statsigKey: 'es-ES' }, + // 'es-PE': { translation: esES, statsigKey: 'es-ES' }, + // 'es-PR': { translation: esES, statsigKey: 'es-ES' }, + // 'es-SV': { translation: esES, statsigKey: 'es-ES' }, + // 'es-US': { translation: esES, statsigKey: 'es-ES' }, // Spanish locales that use `.` as the decimal separator - 'es-AR': { translation: esES, statsigKey: 'es-ES' }, - 'es-BO': { translation: esES, statsigKey: 'es-ES' }, - 'es-CL': { translation: esES, statsigKey: 'es-ES' }, - 'es-CO': { translation: esES, statsigKey: 'es-ES' }, - 'es-CR': { translation: esES, statsigKey: 'es-ES' }, - 'es-EC': { translation: esES, statsigKey: 'es-ES' }, - 'es-ES': { translation: esES, statsigKey: 'es-ES' }, - 'es-PY': { translation: esES, statsigKey: 'es-ES' }, - 'es-UY': { translation: esES, statsigKey: 'es-ES' }, - 'es-VE': { translation: esES, statsigKey: 'es-ES' }, - 'tr-TR': { translation: trTR, statsigKey: 'tr-TR' }, - 'vi-VN': { translation: viVN, statsigKey: 'vi-VN' }, + // 'es-AR': { translation: esES, statsigKey: 'es-ES' }, + // 'es-BO': { translation: esES, statsigKey: 'es-ES' }, + // 'es-CL': { translation: esES, statsigKey: 'es-ES' }, + // 'es-CO': { translation: esES, statsigKey: 'es-ES' }, + // 'es-CR': { translation: esES, statsigKey: 'es-ES' }, + // 'es-EC': { translation: esES, statsigKey: 'es-ES' }, + // 'es-ES': { translation: esES, statsigKey: 'es-ES' }, + // 'es-PY': { translation: esES, statsigKey: 'es-ES' }, + // 'es-UY': { translation: esES, statsigKey: 'es-ES' }, + // 'es-VE': { translation: esES, statsigKey: 'es-ES' }, + // 'tr-TR': { translation: trTR, statsigKey: 'tr-TR' }, + // 'vi-VN': { translation: viVN, statsigKey: 'vi-VN' }, } const defaultNS = 'translation' @@ -63,7 +65,8 @@ i18n .use(initReactI18next) .init({ defaultNS, - lng: getWalletDeviceLocale(), + lng: 'en-US', // Force English only + // lng: getWalletDeviceLocale(), fallbackLng: 'en-US', resources, interpolation: { diff --git a/packages/uniswap/src/i18n/locales/source/en-US.json b/packages/uniswap/src/i18n/locales/source/en-US.json index 7ac4662ed3d..ad7133c7475 100644 --- a/packages/uniswap/src/i18n/locales/source/en-US.json +++ b/packages/uniswap/src/i18n/locales/source/en-US.json @@ -301,6 +301,7 @@ "common.connectAWallet.button.switch": "Switch wallet", "common.connectTo": "Connect to {{platform}}", "common.connectWallet.button": "Connect wallet", + "common.connecting": "Connecting", "common.contactUs.button": "Contact us", "common.copied": "Copied", "common.copy.address": "Copy address", @@ -716,7 +717,7 @@ "downloadApp.modal.getStarted.title": "Start swapping in seconds", "downloadApp.modal.getTheApp.title": "Get started with Uniswap", "downloadApp.modal.uniswapProducts.subtitle": "Uniswap products work seamlessly together to create the best onchain experience.", - "empty.swap.button.text": "Add funds to swap", + "empty.swap.button.text": "Swap Tokens", "error.dataUnavailable": "Data is unavailable at the moment; we’re working on a fix", "error.id": "Error ID: {{eventId}}", "error.jupiterApi.execute.default.title": "Something went wrong with Jupiter API. Please try again.", @@ -964,7 +965,7 @@ "home.upsell.receive.title": "Receive crypto", "home.warning.viewOnly": "This is a view-only wallet", "interface.metatags.description": "Swap crypto on Ethereum, Base, Arbitrum, Polygon, Unichain and more. The DeFi platform trusted by millions.", - "interface.metatags.title": "Uniswap Interface", + "interface.metatags.title": "HSKSwap", "landing.api": "API", "landing.appsOverview": "Built for all the ways you swap", "landing.blog.description": "Catch up on the latest company news, product features and more", @@ -2207,7 +2208,7 @@ "testnet.modal.swapDeepLink.title.toTestnetMode": "Enable testnet mode", "testnet.unsupported": "This functionality is not supported in testnet mode.", "themeToggle.theme": "Theme", - "title.buySellTradeEthereum": "Buy, sell & trade Ethereum and other top tokens on Uniswap", + "title.buySellTradeEthereum": "Buy, sell & trade Ethereum and other top tokens on HSKSwap", "title.createGovernanceOn": "Create a new governance proposal on Uniswap", "title.createGovernanceTo": "Create a new governance proposal to be voted on by UNI holders. UNI tokens represent voting shares in Uniswap governance.", "title.earnFees": "Earn fees when others swap on Uniswap by adding tokens to liquidity pools.", @@ -2226,9 +2227,9 @@ "title.removev3Liquidity": "Remove your tokens from v3 liquidity pools.", "title.sendCrypto": "Send crypto", "title.sendTokens": "Send tokens on Uniswap", - "title.swappingMadeSimple": "Instantly buy and sell crypto on Ethereum, Base, Arbitrum, Polygon, Unichain and more. The DeFi platform trusted by millions.", + "title.swappingMadeSimple": "Instantly buy and sell crypto on Ethereum, Base, Arbitrum, Polygon, HashKey Chain and more. The DeFi platform trusted by millions.", "title.tradeTokens": "Trade tokens and provide liquidity. Real-time prices, charts, transaction data, and more.", - "title.uniswapTradeCrypto": "Uniswap | Trade Crypto on DeFi’s Leading Exchange ", + "title.uniswapTradeCrypto": "HSKSwap | Trade Crypto on DeFi's Leading Exchange", "title.uniToken": "UNI tokens represent voting shares in Uniswap governance. You can vote on each proposal yourself or delegate your votes to a third party.", "title.voteOnGov": "Vote on governance proposals on Uniswap", "token.balances.main": "Your balance", @@ -2367,6 +2368,7 @@ "tokens.selector.section.favorite": "Favorites", "tokens.selector.section.otherNetworksSearchResults": "Tokens found on other networks", "tokens.selector.section.otherSearchResults": "Other tokens on {{network}}", + "tokens.selector.section.poolTokens": "Pool tokens", "tokens.selector.section.recent": "Recent searches", "tokens.selector.section.search": "Search results", "tokens.selector.section.trending": "Tokens by 24H volume", diff --git a/packages/uniswap/src/i18n/locales/translations/es-ES.json b/packages/uniswap/src/i18n/locales/translations/es-ES.json index 74954ee2c95..cb29377436c 100644 --- a/packages/uniswap/src/i18n/locales/translations/es-ES.json +++ b/packages/uniswap/src/i18n/locales/translations/es-ES.json @@ -301,6 +301,7 @@ "common.connectAWallet.button.switch": "Cambiar billetera", "common.connectTo": "Conectar a {{platform}}", "common.connectWallet.button": "Conectar billetera", + "common.connecting": "Conectando", "common.contactUs.button": "Contáctanos", "common.copied": "Copiado", "common.copy.address": "Copiar dirección", @@ -717,7 +718,7 @@ "downloadApp.modal.getStarted.title": "Comienza a hacer intercambios en segundos", "downloadApp.modal.getTheApp.title": "Comienza con Uniswap", "downloadApp.modal.uniswapProducts.subtitle": "Los productos de Uniswap funcionan perfectamente juntos para crear la mejor experiencia en la cadena.", - "empty.swap.button.text": "Agregar fondos para hacer intercambios", + "empty.swap.button.text": "Intercambiar tokens", "error.dataUnavailable": "Los datos no están disponibles en este momento; estamos trabajando para solucionarlo", "error.id": "ID del error: {{eventId}}", "error.jupiterApi.execute.default.title": "Ocurrió un error con la API de Jupiter. Inténtalo de nuevo.", diff --git a/packages/uniswap/src/i18n/locales/translations/fil-PH.json b/packages/uniswap/src/i18n/locales/translations/fil-PH.json index 6aa457d12e3..7d33bdb162f 100644 --- a/packages/uniswap/src/i18n/locales/translations/fil-PH.json +++ b/packages/uniswap/src/i18n/locales/translations/fil-PH.json @@ -301,6 +301,7 @@ "common.connectAWallet.button.switch": "I-switch ang wallet", "common.connectTo": "Kumonekta sa {{platform}}", "common.connectWallet.button": "Ikonekta ang wallet", + "common.connecting": "Kumokonekta", "common.contactUs.button": "Makipag-ugnayan sa amin", "common.copied": "Nakopya na", "common.copy.address": "Kopyahin ang address", @@ -717,7 +718,7 @@ "downloadApp.modal.getStarted.title": "Simulang mag-swap sa loob lang ng ilang segundo", "downloadApp.modal.getTheApp.title": "Magsimula sa Uniswap", "downloadApp.modal.uniswapProducts.subtitle": "Maayos na gumagana nang magkakasama ang mga produkto ng Uniswap para magawa ang pinakamagandang onchain na experience.", - "empty.swap.button.text": "Magdagdag ng mga pondo para mag-swap", + "empty.swap.button.text": "Mag-swap ng Tokens", "error.dataUnavailable": "Hindi available ang data sa ngayon; inaayos na namin ito", "error.id": "Error ID: {{eventId}}", "error.jupiterApi.execute.default.title": "Nagkaproblema sa Jupiter API. Pakisubukan ulit.", diff --git a/packages/uniswap/src/i18n/locales/translations/fr-FR.json b/packages/uniswap/src/i18n/locales/translations/fr-FR.json index 8b07ccf4c4b..2a6af3f218a 100644 --- a/packages/uniswap/src/i18n/locales/translations/fr-FR.json +++ b/packages/uniswap/src/i18n/locales/translations/fr-FR.json @@ -301,6 +301,7 @@ "common.connectAWallet.button.switch": "Changer de wallet", "common.connectTo": "Se connecter à {{platform}}", "common.connectWallet.button": "Connecter le wallet", + "common.connecting": "Connexion en cours", "common.contactUs.button": "Nous contacter", "common.copied": "Copié", "common.copy.address": "Copier l’adresse", @@ -717,7 +718,7 @@ "downloadApp.modal.getStarted.title": "Commencez à échanger en quelques secondes", "downloadApp.modal.getTheApp.title": "Commencer avec Uniswap", "downloadApp.modal.uniswapProducts.subtitle": "Les produits Uniswap fonctionnent parfaitement ensemble pour créer la meilleure expérience en chaîne.", - "empty.swap.button.text": "Ajouter des fonds à échanger", + "empty.swap.button.text": "Échanger des jetons", "error.dataUnavailable": "Les données ne sont pas disponibles pour le moment ; nous travaillons à corriger ceci", "error.id": "ID de l'erreur : {{eventId}}", "error.jupiterApi.execute.default.title": "Une erreur est survenue avec l’API Jupiter, veuillez réessayer.", diff --git a/packages/uniswap/src/i18n/locales/translations/id-ID.json b/packages/uniswap/src/i18n/locales/translations/id-ID.json index 6c0f6952db9..66a6b42bffd 100644 --- a/packages/uniswap/src/i18n/locales/translations/id-ID.json +++ b/packages/uniswap/src/i18n/locales/translations/id-ID.json @@ -301,6 +301,7 @@ "common.connectAWallet.button.switch": "Ganti dompet", "common.connectTo": "Hubungkan ke {{platform}}", "common.connectWallet.button": "Hubungkan dompet", + "common.connecting": "Menghubungkan", "common.contactUs.button": "Hubungi kami", "common.copied": "Disalin", "common.copy.address": "Salin alamat", @@ -717,7 +718,7 @@ "downloadApp.modal.getStarted.title": "Tukarkan sekarang dengan cepat", "downloadApp.modal.getTheApp.title": "Mulai menggunakan Uniswap", "downloadApp.modal.uniswapProducts.subtitle": "Semua produk Uniswap bekerja sama dengan lancar demi menciptakan pengalaman onchain terbaik.", - "empty.swap.button.text": "Tambah dana ke pertukaran", + "empty.swap.button.text": "Tukar Token", "error.dataUnavailable": "Data tidak tersedia saat ini; kami sedang mengatasinya", "error.id": "ID Kesalahan: {{eventId}}", "error.jupiterApi.execute.default.title": "Terjadi kesalahan dengan Jupiter API. Silakan coba lagi.", diff --git a/packages/uniswap/src/i18n/locales/translations/ja-JP.json b/packages/uniswap/src/i18n/locales/translations/ja-JP.json index 442c1009306..d9da5a16036 100644 --- a/packages/uniswap/src/i18n/locales/translations/ja-JP.json +++ b/packages/uniswap/src/i18n/locales/translations/ja-JP.json @@ -301,6 +301,7 @@ "common.connectAWallet.button.switch": "ウォレットを切り替え", "common.connectTo": "{{platform}} に接続", "common.connectWallet.button": "ウォレットを接続", + "common.connecting": "接続中", "common.contactUs.button": "お問い合わせ", "common.copied": "コピーしました", "common.copy.address": "アドレスをコピー", @@ -717,7 +718,7 @@ "downloadApp.modal.getStarted.title": "すぐにスワップを始められます", "downloadApp.modal.getTheApp.title": "Uniswap を始めましょう", "downloadApp.modal.uniswapProducts.subtitle": "Uniswap 製品はシームレスに連携して、最高のオンチェーンエクスペリエンスを実現します。", - "empty.swap.button.text": "資金を追加してスワップする", + "empty.swap.button.text": "トークンをスワップ", "error.dataUnavailable": "現在、データをご利用いただけません。問題を解決中です", "error.id": "エラーID:{{eventId}}", "error.jupiterApi.execute.default.title": "Jupiter API でエラーが発生しました。もう一度お試しください。", diff --git a/packages/uniswap/src/i18n/locales/translations/ko-KR.json b/packages/uniswap/src/i18n/locales/translations/ko-KR.json index fbfce56b879..6e6e06aa6a2 100644 --- a/packages/uniswap/src/i18n/locales/translations/ko-KR.json +++ b/packages/uniswap/src/i18n/locales/translations/ko-KR.json @@ -301,6 +301,7 @@ "common.connectAWallet.button.switch": "지갑 전환", "common.connectTo": "{{platform}}에 연결", "common.connectWallet.button": "지갑 연결", + "common.connecting": "연결 중", "common.contactUs.button": "문의하기", "common.copied": "복사됨", "common.copy.address": "주소 복사", @@ -717,7 +718,7 @@ "downloadApp.modal.getStarted.title": "빠르게 스왑 시작", "downloadApp.modal.getTheApp.title": "Uniswap 시작하기", "downloadApp.modal.uniswapProducts.subtitle": "Uniswap 제품은 서로 원활하게 작동하여 최고의 온체인 경험을 만들어냅니다.", - "empty.swap.button.text": "스왑을 위해 자금 추가", + "empty.swap.button.text": "토큰 교환", "error.dataUnavailable": "지금은 데이터를 사용할 수 없습니다. 우리는 문제를 해결하기 위해 노력하고 있습니다", "error.id": "오류 ID: {{eventId}}", "error.jupiterApi.execute.default.title": "Jupiter API에 문제가 발생했습니다. 다시 시도해 주세요.", diff --git a/packages/uniswap/src/i18n/locales/translations/nl-NL.json b/packages/uniswap/src/i18n/locales/translations/nl-NL.json index 33255752911..eab1cd5098a 100644 --- a/packages/uniswap/src/i18n/locales/translations/nl-NL.json +++ b/packages/uniswap/src/i18n/locales/translations/nl-NL.json @@ -301,6 +301,7 @@ "common.connectAWallet.button.switch": "Van wallet wisselen", "common.connectTo": "Verbinden met {{platform}}", "common.connectWallet.button": "Wallet koppelen", + "common.connecting": "Bezig met verbinden", "common.contactUs.button": "Neem contact met ons op", "common.copied": "Gekopieerd", "common.copy.address": "Adres kopiëren", @@ -717,7 +718,7 @@ "downloadApp.modal.getStarted.title": "Begin in een paar seconden met swappen", "downloadApp.modal.getTheApp.title": "Aan de slag met Uniswap", "downloadApp.modal.uniswapProducts.subtitle": "Uniswap-producten werken naadloos samen om de beste onchain-ervaring te creëren.", - "empty.swap.button.text": "Geld toevoegen aan swap", + "empty.swap.button.text": "Tokens Wisselen", "error.dataUnavailable": "Gegevens zijn momenteel niet beschikbaar; we werken aan een oplossing", "error.id": "Fout-ID: {{eventId}}", "error.jupiterApi.execute.default.title": "Er is iets misgegaan met de Jupiter-API. Probeer het opnieuw.", diff --git a/packages/uniswap/src/i18n/locales/translations/pt-BR.json b/packages/uniswap/src/i18n/locales/translations/pt-BR.json index 1ac5f3bf105..0db6c35465b 100644 --- a/packages/uniswap/src/i18n/locales/translations/pt-BR.json +++ b/packages/uniswap/src/i18n/locales/translations/pt-BR.json @@ -288,6 +288,7 @@ "common.connectAWallet.button": "Conectar uma carteira", "common.connectingWallet": "Conectando carteira...", "common.connectWallet.button": "Conectar carteira", + "common.connecting": "Conectando", "common.contactUs.button": "Fale conosco", "common.contractInteraction": "Interação contratual", "common.copied": "Copiado", diff --git a/packages/uniswap/src/i18n/locales/translations/pt-PT.json b/packages/uniswap/src/i18n/locales/translations/pt-PT.json index 724c01edb8e..14061154213 100644 --- a/packages/uniswap/src/i18n/locales/translations/pt-PT.json +++ b/packages/uniswap/src/i18n/locales/translations/pt-PT.json @@ -301,6 +301,7 @@ "common.connectAWallet.button.switch": "Alterar carteira", "common.connectTo": "Conectar à {{platform}}", "common.connectWallet.button": "Conectar carteira", + "common.connecting": "Conectando", "common.contactUs.button": "Fale conosco", "common.copied": "Copiado", "common.copy.address": "Copiar endereço", @@ -717,7 +718,7 @@ "downloadApp.modal.getStarted.title": "Comece a fazer swap em segundos", "downloadApp.modal.getTheApp.title": "Como começar a usar a Uniswap", "downloadApp.modal.uniswapProducts.subtitle": "Os produtos Uniswap funcionam muito bem juntos para criar a melhor experiência de on-chain.", - "empty.swap.button.text": "Adicionar fundos para fazer swap", + "empty.swap.button.text": "Trocar Tokens", "error.dataUnavailable": "Dados indisponíveis no momento; estamos trabalhando para resolver", "error.id": "ID do erro: {{eventId}}", "error.jupiterApi.execute.default.title": "Ocorreu um problema com a API Jupiter. Tente novamente.", diff --git a/packages/uniswap/src/i18n/locales/translations/ru-RU.json b/packages/uniswap/src/i18n/locales/translations/ru-RU.json index 554749095a5..4d78a21e839 100644 --- a/packages/uniswap/src/i18n/locales/translations/ru-RU.json +++ b/packages/uniswap/src/i18n/locales/translations/ru-RU.json @@ -301,6 +301,7 @@ "common.connectAWallet.button.switch": "Переключить кошелек", "common.connectTo": "Подключиться к {{platform}}", "common.connectWallet.button": "Подключить кошелек", + "common.connecting": "Подключение", "common.contactUs.button": "Свяжитесь с нами", "common.copied": "Скопировано", "common.copy.address": "Копировать адрес", @@ -717,7 +718,7 @@ "downloadApp.modal.getStarted.title": "Начните выполнять своп токенов за считанные секунды", "downloadApp.modal.getTheApp.title": "Начало работы с Uniswap", "downloadApp.modal.uniswapProducts.subtitle": "Продукты Uniswap прекрасно взаимодействуют друг с другом, обеспечивая наилучший опыт работы ончейн.", - "empty.swap.button.text": "Добавить средства для свопа", + "empty.swap.button.text": "Обменять токены", "error.dataUnavailable": "Данные сейчас недоступны. Мы работаем над исправлением.", "error.id": "Идентификатор ошибки: {{eventId}}", "error.jupiterApi.execute.default.title": "Возникла проблема с Jupiter API. Попробуйте еще раз.", diff --git a/packages/uniswap/src/i18n/locales/translations/tr-TR.json b/packages/uniswap/src/i18n/locales/translations/tr-TR.json index eb506ef35a4..e2b7be115c8 100644 --- a/packages/uniswap/src/i18n/locales/translations/tr-TR.json +++ b/packages/uniswap/src/i18n/locales/translations/tr-TR.json @@ -301,6 +301,7 @@ "common.connectAWallet.button.switch": "Cüzdanı değiştir", "common.connectTo": "{{platform}} platformuna bağlan", "common.connectWallet.button": "Cüzdanı bağla", + "common.connecting": "Bağlanıyor", "common.contactUs.button": "Bize ulaş", "common.copied": "Kopyalandı", "common.copy.address": "Adresi kopyala", @@ -717,7 +718,7 @@ "downloadApp.modal.getStarted.title": "Saniyeler içinde swap işlemlerine başla", "downloadApp.modal.getTheApp.title": "Uniswap'ı kullanmaya başla", "downloadApp.modal.uniswapProducts.subtitle": "Uniswap ürünleri, en iyi zincir içi deneyimi sunmak için kusursuz bir şekilde birlikte çalışır.", - "empty.swap.button.text": "Swap'a fon ekle", + "empty.swap.button.text": "Token Takasla", "error.dataUnavailable": "Veriler şu anda kullanılamıyor; bir düzeltme üzerinde çalışıyoruz", "error.id": "Hata Kimliği: {{eventId}}", "error.jupiterApi.execute.default.title": "Jupiter API ile ilgili bir sorun oluştu. Lütfen tekrar dene.", diff --git a/packages/uniswap/src/i18n/locales/translations/vi-VN.json b/packages/uniswap/src/i18n/locales/translations/vi-VN.json index 2e1c12e3ad0..4f148ff4aed 100644 --- a/packages/uniswap/src/i18n/locales/translations/vi-VN.json +++ b/packages/uniswap/src/i18n/locales/translations/vi-VN.json @@ -301,6 +301,7 @@ "common.connectAWallet.button.switch": "Chuyển ví", "common.connectTo": "Kết nối với {{platform}}", "common.connectWallet.button": "Kết nối ví", + "common.connecting": "Đang kết nối", "common.contactUs.button": "Liên hệ", "common.copied": "Đã sao chép", "common.copy.address": "Sao chép địa chỉ", @@ -717,7 +718,7 @@ "downloadApp.modal.getStarted.title": "Bắt đầu hoán đổi trong giây lát", "downloadApp.modal.getTheApp.title": "Bắt đầu với Uniswap", "downloadApp.modal.uniswapProducts.subtitle": "Các sản phẩm Uniswap hoạt động liền mạch cùng nhau để tạo trải nghiệm onchain tốt nhất.", - "empty.swap.button.text": "Nạp quỹ để hoán đổi", + "empty.swap.button.text": "Hoán đổi Token", "error.dataUnavailable": "Dữ liệu hiện không khả dụng; chúng tôi đang khắc phục", "error.id": "ID Lỗi: {{eventId}}", "error.jupiterApi.execute.default.title": "Đã xảy ra lỗi với API Jupiter. Vui lòng thử lại.", diff --git a/packages/uniswap/src/i18n/locales/translations/zh-CN.json b/packages/uniswap/src/i18n/locales/translations/zh-CN.json index 883b448d931..47bfad80127 100644 --- a/packages/uniswap/src/i18n/locales/translations/zh-CN.json +++ b/packages/uniswap/src/i18n/locales/translations/zh-CN.json @@ -301,6 +301,7 @@ "common.connectAWallet.button.switch": "切换钱包", "common.connectTo": "连接到 {{platform}}", "common.connectWallet.button": "连接钱包", + "common.connecting": "连接中", "common.contactUs.button": "联系我们", "common.copied": "已复制", "common.copy.address": "复制地址", @@ -717,7 +718,7 @@ "downloadApp.modal.getStarted.title": "数秒内即可开始交换代币", "downloadApp.modal.getTheApp.title": "开始使用 Uniswap", "downloadApp.modal.uniswapProducts.subtitle": "Uniswap 产品无缝协作,创造最佳的链上体验。", - "empty.swap.button.text": "添加资金以进行交换", + "empty.swap.button.text": "交换代币", "error.dataUnavailable": "目前无法提供数据;我们正在努力修复", "error.id": "错误 ID:{{eventId}}", "error.jupiterApi.execute.default.title": "Jupiter API 出错了,请重试。", @@ -966,7 +967,7 @@ "home.upsell.receive.title": "接收加密货币", "home.warning.viewOnly": "这是仅供查看的钱包", "interface.metatags.description": "支持在以太坊、Base、Arbitrum、Polygon、Unichain 等链上交换加密货币。数百万用户信赖的 DeFi 平台。", - "interface.metatags.title": "Uniswap 界面", + "interface.metatags.title": "HSKSwap", "landing.api": "API", "landing.appsOverview": "专为各种交换方式而设计", "landing.blog.description": "了解最新的公司新闻、产品功能等", @@ -2222,7 +2223,7 @@ "testnet.modal.swapDeepLink.title.toTestnetMode": "启用测试网模式", "testnet.unsupported": "测试网模式不支持此功能。", "themeToggle.theme": "主题", - "title.buySellTradeEthereum": "在 Uniswap 上购买、出售和交易以太坊及其他顶级代币", + "title.buySellTradeEthereum": "在 HSKSwap 上购买、出售和交易以太坊及其他顶级代币", "title.createGovernanceOn": "在 Uniswap 上创建新的治理提案", "title.createGovernanceTo": "创建一个新的治理提案,供 UNI 持有者投票。UNI 代币代表 Uniswap 治理中的投票权份额。", "title.earnFees": "在他人通过向流动性池添加代币以在 Uniswap 上进行交换时赚取费用。", @@ -2241,9 +2242,9 @@ "title.removev3Liquidity": "从 v3 流动性资金池中移除你的代币。", "title.sendCrypto": "发送加密货币", "title.sendTokens": "在 Uniswap 上发送代币", - "title.swappingMadeSimple": "支持在以太坊、Base、Arbitrum、Polygon、Unichain 等链上即时买卖加密货币。数百万用户信赖的 DeFi 平台。", + "title.swappingMadeSimple": "支持在以太坊、Base、Arbitrum、Polygon、HashKey Chain 等链上即时买卖加密货币。数百万用户信赖的 DeFi 平台。", "title.tradeTokens": "交易代币并提供流动性。实时价格、图表、交易数据等。", - "title.uniswapTradeCrypto": "Uniswap | 在 DeFi 领先交易所交易加密货币 ", + "title.uniswapTradeCrypto": "HSKSwap | 在 DeFi 领先交易所交易加密货币", "title.uniToken": "UNI 代币代表 Uniswap 治理中的投票权份额。你可以自己对每个提案进行投票,也可以将你的投票委托给第三方。", "title.voteOnGov": "对 Uniswap 上的治理提案进行投票", "token.balances.main": "你的余额", diff --git a/packages/uniswap/src/i18n/locales/translations/zh-TW.json b/packages/uniswap/src/i18n/locales/translations/zh-TW.json index a802ae873ca..c209a1e16e8 100644 --- a/packages/uniswap/src/i18n/locales/translations/zh-TW.json +++ b/packages/uniswap/src/i18n/locales/translations/zh-TW.json @@ -301,6 +301,7 @@ "common.connectAWallet.button.switch": "切換錢包", "common.connectTo": "連接至 {{platform}}", "common.connectWallet.button": "連線錢包", + "common.connecting": "連線中", "common.contactUs.button": "聯絡我們", "common.copied": "已複製", "common.copy.address": "複製地址", @@ -717,7 +718,7 @@ "downloadApp.modal.getStarted.title": "幾秒內即可開始交換", "downloadApp.modal.getTheApp.title": "開始使用 Uniswap", "downloadApp.modal.uniswapProducts.subtitle": "Uniswap 產品無縫協作,創造最佳的鏈上體驗。", - "empty.swap.button.text": "新增資金以進行交換", + "empty.swap.button.text": "交換代幣", "error.dataUnavailable": "目前無法取得資料;我們正在努力修復問題", "error.id": "錯誤 ID:{{eventId}}", "error.jupiterApi.execute.default.title": "Jupiter API 發生錯誤,請重試。", @@ -2222,7 +2223,7 @@ "testnet.modal.swapDeepLink.title.toTestnetMode": "啟用測試網模式", "testnet.unsupported": "測試網模式不支援此功能。", "themeToggle.theme": "主題", - "title.buySellTradeEthereum": "在 Uniswap 上購買、出售和交易以太幣和其他頂級代幣", + "title.buySellTradeEthereum": "在 HSKSwap 上購買、出售和交易以太幣和其他頂級代幣", "title.createGovernanceOn": "在 Uniswap 上建立新的治理提案", "title.createGovernanceTo": "建立一個新的治理提案,供 UNI 持有者投票。UNI 代幣代表 Uniswap 治理中的投票份額。", "title.earnFees": "你可以在其他人將代幣新增到流動資產池,以在 Uniswap 上進行交換時賺取交易費用。", @@ -2241,9 +2242,9 @@ "title.removev3Liquidity": "從 v3 流動性資產池中移除你的代幣。", "title.sendCrypto": "傳送加密貨幣", "title.sendTokens": "在 Uniswap 上傳送代幣", - "title.swappingMadeSimple": "支援在以太坊、Base、Arbitrum、Polygon、Unichain 等鏈上即時買賣加密貨幣。深受數百萬用戶信賴的 DeFi 平台。", + "title.swappingMadeSimple": "支援在以太坊、Base、Arbitrum、Polygon、HashKey Chain 等鏈上即時買賣加密貨幣。深受數百萬用戶信賴的 DeFi 平台。", "title.tradeTokens": "交易代幣並提供流動資產。即時價格、圖表、交易資料和其他。", - "title.uniswapTradeCrypto": "Uniswap | 在 DeFi 領先交易所交易加密貨幣 ", + "title.uniswapTradeCrypto": "HSKSwap | 在 DeFi 領先交易所交易加密貨幣", "title.uniToken": "UNI 代幣代表 Uniswap 治理中的投票份額。你可以自己對每個提案進行投票,也可以將你的投票委託給第三方。", "title.voteOnGov": "對 Uniswap 治理提案進行投票", "token.balances.main": "你的餘額", diff --git a/packages/uniswap/src/utils/routingDiagram/routingProviders/uniswapRoutingProvider.ts b/packages/uniswap/src/utils/routingDiagram/routingProviders/uniswapRoutingProvider.ts index 27a97ab84e3..ebe7dd11910 100644 --- a/packages/uniswap/src/utils/routingDiagram/routingProviders/uniswapRoutingProvider.ts +++ b/packages/uniswap/src/utils/routingDiagram/routingProviders/uniswapRoutingProvider.ts @@ -49,7 +49,7 @@ function getProtocolLabel(route: { protocol: Protocol; pools: UniswapPool[] }): } export const uniswapRoutingProvider: RoutingProvider = { - name: 'Uniswap API', + name: 'Hskswap API', // HKSWAP: Changed from 'Uniswap API' to 'Hskswap API' icon: undefined, iconColor: '$neutral1', @@ -109,7 +109,7 @@ export const uniswapRoutingProvider: RoutingProvider = { } export const uniswapChainedRoutingProvider: RoutingProvider = { - name: 'Uniswap API', + name: 'Hskswap API', // HKSWAP: Changed from 'Uniswap API' to 'Hskswap API' icon: undefined, iconColor: '$neutral1', diff --git a/packages/utilities/src/logger/datadog/Datadog.web.ts b/packages/utilities/src/logger/datadog/Datadog.web.ts index 5fc36802a72..688e4637260 100644 --- a/packages/utilities/src/logger/datadog/Datadog.web.ts +++ b/packages/utilities/src/logger/datadog/Datadog.web.ts @@ -102,7 +102,8 @@ export function createDatadogReduxEnhancer({ } /* Log action to Datadog */ - if (isAction) { + // Skip logging redux-persist actions to reduce console noise + if (isAction && !action.type.startsWith('persist/')) { datadogRum.addAction(`Redux Action: ${action.type}`, action) } diff --git a/packages/utilities/src/logger/logger.ts b/packages/utilities/src/logger/logger.ts index db7a1f44819..7e8d95cd330 100644 --- a/packages/utilities/src/logger/logger.ts +++ b/packages/utilities/src/logger/logger.ts @@ -77,8 +77,9 @@ function logMessage( message: string, ...args: unknown[] // arbitrary extra data - ideally formatted as key value pairs ): void { - // Log to console directly for dev builds or interface for debugging - if (__DEV__ || isWebApp) { + // Disabled info and debug level console logging to reduce console noise + // Only log warnings and errors to console + if ((__DEV__ || isWebApp) && (level === 'warn' || level === 'error')) { if (isMobileApp && ['log', 'debug', 'warn'].includes(level)) { // `log`, `debug`, and `warn` are all logged with `console.log` on mobile // because `console.debug` and `console.warn` only support one single argument in Reactotron. diff --git a/packages/utilities/src/reactQuery/cache.ts b/packages/utilities/src/reactQuery/cache.ts index 1d36c92437e..0807ecf2862 100644 --- a/packages/utilities/src/reactQuery/cache.ts +++ b/packages/utilities/src/reactQuery/cache.ts @@ -35,6 +35,7 @@ export enum ReactQueryCacheKey { OnboardingRedirect = 'OnboardingRedirect', OnchainBalances = 'OnchainBalances', OnchainENS = 'OnchainENS', + OnchainPortfolioTotalValue = 'OnchainPortfolioTotalValue', OnRampAuth = 'OnRampAuth', PasskeyAuthStatus = 'PasskeyAuthStatus', Permit2SignatureWithData = 'Permit2SignatureWithData', diff --git a/packages/utilities/src/telemetry/analytics/logging.ts b/packages/utilities/src/telemetry/analytics/logging.ts index 4883d29c9c9..3f3e1d5f170 100644 --- a/packages/utilities/src/telemetry/analytics/logging.ts +++ b/packages/utilities/src/telemetry/analytics/logging.ts @@ -17,9 +17,10 @@ export function generateAnalyticsLoggers(fileName: string): ErrorLoggers { logger.error(error, { tags: { file: fileName, function: 'init' } }) }, sendEvent(eventName: string, eventProperties?: Record): void { - if (isNonTestDev) { - logger.info('analytics', 'sendEvent', `[Event: ${eventName}]`, eventProperties ?? {}) - } + // Disabled logging to reduce console noise + // if (isNonTestDev) { + // logger.info('analytics', 'sendEvent', `[Event: ${eventName}]`, eventProperties ?? {}) + // } }, setAllowAnalytics(allow: boolean): void { if (isNonTestDev) { @@ -32,9 +33,10 @@ export function generateAnalyticsLoggers(fileName: string): ErrorLoggers { } }, setUserProperty(property: string, value: UserPropertyValue): void { - if (isNonTestDev) { - logger.info('analytics', 'setUserProperty', `[Property: ${property}]: ${value}`) - } + // Disabled logging to reduce console noise + // if (isNonTestDev) { + // logger.info('analytics', 'setUserProperty', `[Property: ${property}]: ${value}`) + // } }, } } diff --git a/packages/wallet/src/components/WalletPreviewCard/__snapshots__/WalletPreviewCard.test.tsx.snap b/packages/wallet/src/components/WalletPreviewCard/__snapshots__/WalletPreviewCard.test.tsx.snap index 0b525bc03cb..0c5c1a632cf 100644 --- a/packages/wallet/src/components/WalletPreviewCard/__snapshots__/WalletPreviewCard.test.tsx.snap +++ b/packages/wallet/src/components/WalletPreviewCard/__snapshots__/WalletPreviewCard.test.tsx.snap @@ -341,7 +341,7 @@ exports[`renders wallet preview card 1`] = ` "borderWidth": 0, }, { - "color": "#FF37C7", + "color": "#4177e2", "height": 20, "width": 20, }, @@ -356,7 +356,7 @@ exports[`renders wallet preview card 1`] = ` vbWidth={48} > { + // Wait for the transaction to be submitted before returning + // This ensures onSuccess is only called after the transaction is actually sent to the wallet + try { + await submitPromise + if (process.env.NODE_ENV === 'development') { + logger.debug('TransactionService', 'submitTransaction', 'Transaction submitted successfully:', transactionHash) + } + } catch (error) { logger.error(error, { tags: { file: 'TransactionService', function: 'submitTransaction' }, - extra: { context: 'Background submission failed' }, + extra: { context: 'Transaction submission failed' }, }) - }) + throw error + } - // Return the hash immediately + // Return the hash after transaction is submitted return { transactionHash } } diff --git a/packages/wallet/src/features/transactions/swap/executeSwapSaga.ts b/packages/wallet/src/features/transactions/swap/executeSwapSaga.ts index d1b6273433d..ba38273db59 100644 --- a/packages/wallet/src/features/transactions/swap/executeSwapSaga.ts +++ b/packages/wallet/src/features/transactions/swap/executeSwapSaga.ts @@ -172,8 +172,14 @@ function* executeTransactionStep(params: { // Execute async (either because sync is not enabled or sync failed) const asyncResult = yield* executor.executeStep(step) if (!asyncResult.success) { + const error = asyncResult.error instanceof Error + ? asyncResult.error + : new Error(asyncResult.error ? String(asyncResult.error) : 'Transaction failed') yield* call(onFailure) - throw new Error('Transaction failed') + throw error + } + chainId, + }) } return undefined // Async execution doesn't return a sync result @@ -326,9 +332,8 @@ export function createExecuteSwapSaga( if (isUniswapXPreSignedSwapTransaction(preSignedTransaction) || swapTxHasDelayedSubmission) { yield* call(onPending) - } else { - yield* call(onSuccess) } + // Note: onSuccess will be called after transaction is successfully submitted const gasFeeEstimation = swapTxContext.gasFeeEstimation @@ -460,15 +465,17 @@ export function createExecuteSwapSaga( } } - // Call onSuccess now if it wasn't called earlier due to transaction spacing - if (swapTxHasDelayedSubmission) { + // Call onSuccess after transaction is successfully submitted + // For delayed submission, onSuccess will be called after all transactions are submitted yield* call(onSuccess) - } } catch (error) { dependencies.logger.error(error, { tags: { file: 'executeSwapSaga', function: 'executeSwap' }, extra: { analytics: params.analytics }, }) + // Call onFailure with the error so it can be displayed to the user + const swapError = error instanceof Error ? error : new Error(String(error)) + yield* call(params.onFailure, swapError) } } } diff --git a/prd.md b/prd.md new file mode 100644 index 00000000000..acff4734d9c --- /dev/null +++ b/prd.md @@ -0,0 +1,769 @@ +Uniswap V3 前端配池流程与调试指南 (全范围模式) +文档版本: 2.1 +适用场景: HashKey Chain (Mainnet & Testnet) V3 流动性添加,强制用户进行全范围 (Full Range) 流动性添加 +核心合约: NonfungiblePositionManager +实施状态: ✅ 已完成 + +## 概述 +本文档描述 HashKey Chain 上 Uniswap V3 流动性添加的特殊实现。为简化用户体验和降低风险,HashKey Chain 上的 V3 流动性添加**强制使用全范围模式**,隐藏价格区间选择功能。 + uniswap sdk 不支持 hashkey chain,不要使用 isBackendSupportedChainId 这类型错误!!! +### 适用链 +- **HashKey Chain Mainnet** (Chain ID: 177) +- **HashKey Chain Testnet** (Chain ID: 133) + + +Trading API 授权检查 本项目不支持, +1. useTokenAllowance - 基础链上授权检查 +位置:apps/web/src/hooks/useTokenAllowance.ts +功能: +使用 useReadContract 直接查询链上 ERC20 合约的 allowance 方法 +不依赖任何 API,纯链上查询 +支持自动刷新(当授权交易确认后) +export function useTokenAllowance({ token, owner, spender }: { token?: Token owner?: string // 用户地址 spender?: string // 授权给谁(比如 Position Manager)}): { tokenAllowance?: CurrencyAmount isSyncing: boolean} +2. usePermit2Allowance - Permit2 授权检查 +位置:apps/web/src/hooks/usePermit2Allowance.ts +功能: +检查 Permit2 合约的授权 +内部使用 useTokenAllowance 检查基础 ERC20 授权 +3. getApproveInfo - Gas 估算中的授权检查 +位置:apps/web/src/state/routing/gas.ts +功能: +使用合约的 callStatic.allowance 方法检查授权 +用于估算授权交易的 gas 费用 + +### 核心特性 +1. **仅支持 Uniswap V3**(不支持 V4) + - HashKey Chain 上的流动性添加功能**仅支持 V3 协议** + - V4 协议相关代码已从 HashKey Chain 支持中移除 + - 所有 V4 相关的 hooks、配置和逻辑都不适用于 HashKey Chain +2. 自动强制全范围流动性模式 +3. 隐藏价格区间选择 UI +4. 新建池子时需要用户输入初始价格 +5. 支持所有 V3 费率等级 (0.01%, 0.05%, 0.3%, 1%) +6. **默认费率等级:0.3%(最常用,适合主流代币对)** +7. **链上交易构建**:对于 HashKey Chain,不使用 Trading API,直接在链上构建交易 + - 使用 `NonfungiblePositionManager.multicall` 方法 + - 包含 `createAndInitializePoolIfNecessary` 和 `mint` 两个步骤 + +### 关键技术说明 + +#### 1. SDK 使用情况(重要 - 必读) + +⚠️ **关键信息**:本项目**正在迁移**到 HashKey 自定义 SDK + +**当前状态**: +- **目标 SDK**:`@hkdex-tmp/universal_router_sdk` (1.0.3) - HashKey 团队维护的自定义 SDK +- **当前状态**:部分功能还在使用官方 SDK,**正在逐步替换中** +- **原因**:官方 SDK 不支持 HashKey Chain,需要使用自定义版本 + +**已安装的 SDK 包**: + +**🔴 HashKey 自定义 SDK(核心 - 必须使用):** +- **@hkdex-tmp/universal_router_sdk**: 1.0.3 +- **用途**:应该用于**所有功能**(Swap、流动性添加、路由、价格计算等) +- **优先级**:⭐⭐⭐⭐⭐ **最高优先级** +- **原因**: + - HashKey 团队专门为 HashKey Chain 定制和维护 + - 包含 HashKey Chain 的所有合约地址 + - 已修复官方 SDK 在 HashKey Chain 上的兼容性问题 + - 针对 HashKey Chain 的特殊需求优化 + +**官方 Uniswap SDK(临时使用 - 计划替换):** +- **@uniswap/sdk-core**: 7.9.0 - 核心类型(Token、Currency) +- **@uniswap/v3-sdk**: 3.25.2 - V3 逻辑(Pool、Position、Tick) +- **@uniswap/v2-sdk**: 4.15.2 +- **@uniswap/v4-sdk**: 1.21.2 +- **@uniswap/router-sdk**: 2.0.2 - V3SwapRouter +- **状态**:部分功能还在使用,**正在逐步替换为 @hkdex-tmp/universal_router_sdk** + +**当前迁移状态**: +| 功能 | 当前使用的 SDK | 目标 SDK | 状态 | 说明 | +|------|--------------|---------|------|------| +| Swap 交易 | `@hkdex-tmp/universal_router_sdk` | `@hkdex-tmp/universal_router_sdk` | ✅ 已完成 | - | +| V3 流动性添加 | `@uniswap/v3-sdk` | `@hkdex-tmp/universal_router_sdk` | ⏳ 待迁移 | 还没来得及更换 | +| Pool 计算 | `@uniswap/v3-sdk` | `@hkdex-tmp/universal_router_sdk` | ⏳ 待迁移 | 还没来得及更换 | +| 价格计算 | `@uniswap/v3-sdk` | `@hkdex-tmp/universal_router_sdk` | ⏳ 待迁移 | 还没来得及更换 | +| 合约地址 | 手动配置 `v3Addresses.ts` | `@hkdex-tmp/universal_router_sdk` | ⏳ 待迁移 | 还没来得及更换 | + +**🔴 核心开发原则(必须遵守)**: + +**规则 1:遇到问题时,第一反应是检查 `@hkdex-tmp/universal_router_sdk`** +- 官方 SDK 报错?→ 检查 `@hkdex-tmp/universal_router_sdk` +- 缺少合约地址?→ 检查 `@hkdex-tmp/universal_router_sdk` +- 功能不支持?→ 检查 `@hkdex-tmp/universal_router_sdk` +- 计算结果异常?→ 检查 `@hkdex-tmp/universal_router_sdk` + +**规则 2:SDK 选择优先级** +``` +1️⃣ @hkdex-tmp/universal_router_sdk (1.0.3) ⭐ 最高优先级 + ↓ 如果确认该 SDK 没有所需功能 +2️⃣ 官方 @uniswap/*-sdk(临时方案) + ↓ 如果都不行 +3️⃣ 自行实现 +``` + +**规则 3:不要假设官方 SDK 可用** +- ❌ 错误:直接使用 `@uniswap/v3-sdk` 认为它支持 HashKey Chain +- ✅ 正确:先检查 `@hkdex-tmp/universal_router_sdk` 是否有对应功能 + +**当前项目状态**: +- ✅ `@hkdex-tmp/universal_router_sdk` 已安装在项目中 +- ⏳ 正在逐步迁移,还有很多功能使用官方 SDK +- 📝 本次流动性添加实现使用了官方 SDK(临时方案,后续需迁移) + +#### 2. HashKey Chain V3 合约部署 + +HashKey Chain 上部署了**自己的 Uniswap V3 合约克隆**,合约地址与官方 Ethereum 部署不同: + +**Testnet (Chain ID: 133) 和 Mainnet (Chain ID: 177) 合约地址:** +- **V3 Factory**: `0x2dC2c21D1049F786C535bF9d45F999dB5474f3A0` +- **NonfungiblePositionManager**: `0x3c8816a838966b8b0927546A1630113F612B1553` ⭐ **核心合约** +- **SwapRouter02**: `0x46cBccE3c74E95d1761435d52B0b9Abc9e2FEAC0` +- **QuoterV2**: `0x9576241e23629cF8ad3d8ad7b12993935b24fA9d` +- **Multicall2**: `0x47F625Ec29637445AA1570d7008Cf78692CdA096` +- **TickLens**: `0x73942976823088508a2C6c8055DF71107DB1d8db` +- **V3Migrator**: `0x0bb37eD33c163c46DEef0F6D14d262D0bc57B130` +- **V3Staker**: `0xF5A3fD7A48c574cB07fE79f679bb4DcC6EcA1205` +- **NFT Descriptor Library**: `0x04618B09C4bfa69768D07bA7479c19F40Aed06Ac` +- **NFT Descriptor**: `0x6EF5d83eC912C12F1b1c5ACBD6C565120aB6EC5c` +- **Descriptor Proxy**: `0x47438E3ee7B305fC7fd0e2cC3633002e65fFeaec` + +**说明**: +- 这些合约是 Uniswap V3 的标准部署克隆,但地址不同于官方 Ethereum 部署 +- 使用官方 SDK (@uniswap/v3-sdk) 可以与这些合约交互 +- **需要在代码中手动配置这些地址**(官方 SDK 默认不包含 HashKey Chain) +- 配置位置:`packages/uniswap/src/constants/v3Addresses.ts` +- 如果官方 SDK 不支持某些功能,检查 `@hkdex-tmp/universal_router_sdk` 是否提供 + +#### 3. 后端 API 支持情况 + +**关键问题**:Uniswap 官方后端不支持 HashKey Chain + +**表现**: +- `backendSupported: false` (在 chainInfo 配置中) +- REST API 查询池子信息返回 **404 错误** +- GraphQL API 不认识 HashKey Chain +- Trading API 不支持 HashKey Chain 的报价 + +**影响范围**: +1. **池子查询**:`useGetPoolsByTokens` 返回 404 +2. **价格数据**:无法获取历史价格和图表数据 +3. **TVL 数据**:无法显示池子的总锁仓量 +4. **交易路由**:Trading API 无法提供最优路由 + +**解决方案**: +- ✅ 使用本地 SDK 直接计算(不依赖后端) +- ✅ 检测 `backendSupported: false` 时,自动启用"创建新池子"模式 +- ✅ 使用链上 RPC 调用代替后端 API +- ⚠️ 缺少图表和历史数据(可接受的降级体验) + +**🔴 重要:自定义网关地址配置** + +本项目使用**自定义的 Uniswap Gateway DNS 地址**,而非官方默认地址: + +**环境变量配置**: +- **变量名**:`REACT_APP_UNISWAP_GATEWAY_DNS` +- **自定义地址**:`https://zy95c64c3c.execute-api.ap-southeast-1.amazonaws.com/prod/v2` +- **配置文件位置**:`apps/web/.env` + +**⚠️ 关键说明**: +- 这是**HashKey 团队自定义部署的网关服务**,专门为 HashKey Chain 优化 +- 与官方 Uniswap Gateway 不同,这是独立的 AWS API Gateway 部署 +- 该地址用于前端与后端服务的通信,包括池子查询、价格数据等 +- **不要使用官方默认地址**,必须使用此自定义地址 +- 如果修改此地址,需要确保新的网关服务支持 HashKey Chain 的相关功能 + +**配置示例**: +```bash +# apps/web/.env +REACT_APP_UNISWAP_GATEWAY_DNS=https://zy95c64c3c.execute-api.ap-southeast-1.amazonaws.com/prod/v2 +``` + +#### 4. 初始价格设置的关键问题 + +**问题现象**: +- 初始价格输入框没有显示 +- 用户无法设置新池子的初始价格 +- 导致数量计算异常(如 100 TT1 = 0.000000000000004799 WHSK) + +**根本原因**: +```typescript +// useDerivedPositionInfo.tsx +const creatingPoolOrPair = poolDataIsFetched && !poolOrPair +``` + +**问题分析**: +- `poolDataIsFetched`: 依赖后端 API 查询完成 +- 当后端返回 404 时,React Query 可能永远不会将 `isFetched` 设为 true +- 或者查询被禁用(`enabled: false`),导致 `poolDataIsFetched = false` +- 最终 `creatingPoolOrPair = false`,导致 `` 不显示 + +**问题定位**: +- 文件:`apps/web/src/components/Liquidity/Create/hooks/useDerivedPositionInfo.tsx` +- 第 299 行:`const creatingPoolOrPair = poolDataIsFetched && !poolOrPair` +- 当后端返回 404 时,`poolDataIsFetched` 可能为 `false`,导致 `creatingPoolOrPair = false` +- 结果:`` 组件不渲染 + +**需要修复**: +- ⚠️ **待确认正确的修复方案** +- 需要处理 HashKey Chain 后端不支持的情况 +- 确保初始价格输入框能正确显示 +- 修复时需要考虑: + 1. 如何检测后端不支持的情况 + 2. 如何正确设置 `creatingPoolOrPair` 标志 + 3. 不要破坏现有逻辑 + +#### 5. 当前实现的技术债务与后续优化 + +**⚠️ 重要提醒**:本次流动性添加功能使用了**临时技术方案** + +**临时方案详情**: +- 使用官方 `@uniswap/v3-sdk` 进行 Pool 计算、价格计算、Tick 处理 +- 使用官方 `@uniswap/sdk-core` 提供基础类型 +- 手动配置 HashKey Chain 的 V3 合约地址(`v3Addresses.ts`) +- 手动处理后端不支持的情况(`backendSupported: false`) + +**为什么使用临时方案**: +- ⏰ 时间紧急,还没来得及完全迁移到 `@hkdex-tmp/universal_router_sdk` +- ✅ 官方 SDK 的核心计算逻辑是通用的,可以工作 +- ⚠️ 但需要手动配置很多 HashKey Chain 特定的参数 + +**技术债务清单**: +1. [ ] **初始价格输入框不显示**:需要修复 `creatingPoolOrPair` 逻辑 +2. [ ] **合约地址配置**:应该从 `@hkdex-tmp/universal_router_sdk` 获取,而非手动配置 +3. [ ] **Pool 计算逻辑**:检查自定义 SDK 是否有优化版本 +4. [ ] **价格计算**:检查是否有 HashKey Chain 特定的处理 +5. [ ] **后端 fallback**:自定义 SDK 可能已经处理了后端不支持的情况 + +**后续优化步骤**: +1. 检查 `@hkdex-tmp/universal_router_sdk` 的完整 API 和类型定义 +2. 确认是否包含流动性相关的功能和合约地址 +3. 逐步替换官方 SDK 的使用 +4. 移除手动配置(如果 SDK 已包含) +5. 全面测试确保兼容性 + +**开发检查清单(每次实现新功能时)**: +- [ ] ⭐ 第一步:搜索 `@hkdex-tmp/universal_router_sdk` 的源码 +- [ ] 检查该 SDK 的 TypeScript 类型定义和导出 +- [ ] 如果没有所需功能,再考虑官方 SDK +- [ ] 记录选择的 SDK 和原因 +- [ ] 标记是否为技术债务(需要后续优化) + +## 实施细节 + +### 1. 代码修改文件 + +#### 1.1 `/apps/web/src/state/mint/v3/utils.ts` +添加全范围模式相关工具函数: +- `FULL_RANGE_TICKS`: 各费率等级的全范围 Tick 常量 +- `getFullRangeConfig(feeTier)`: 获取特定费率的全范围配置 +- `sortTokens(tokenA, tokenB)`: Token 地址排序 +- `isFullRangeModeChain(chainId)`: 判断链是否需要强制全范围模式 + +#### 1.2 `/apps/web/src/components/Liquidity/Create/RangeSelectionStep.tsx` +修改价格区间选择组件: +- 检测 HashKey Chain,自动启用全范围模式 +- 隐藏全范围/自定义范围切换控件 +- 隐藏价格区间图表和输入框 +- 保留初始价格输入(新建池子时) + +### 2. 核心流程图解 +在开始写代码前,请确保逻辑遵循以下数据流。这一步最容易出问题的就是 Token 排序 导致的 价格倒置。 + +```mermaid +graph TD + Start[用户输入: Token A, Token B, 费率 Fee, 初始价格 P] --> Sort{地址排序 check}; + + Sort -- Token A < Token B --> Normal[顺序正常: token0=A, token1=B]; + Sort -- Token A > Token B --> Flip[顺序颠倒: token0=B, token1=A]; + + Normal --> CalcPrice[使用价格 P 计算 sqrtPriceX96]; + Flip --> CalcPriceInvert[使用 1/P 计算 sqrtPriceX96]; + + CalcPrice --> Ticks[读取全范围 Ticks 常量]; + CalcPriceInvert --> Ticks; + + Ticks --> CalcAmount[根据 P 和 输入数量A, 自动计算数量B]; + + CalcAmount --> Slippage[计算滑点 amountMin (例如 95%)]; + + Slippage --> Construct[构造 Multicall 数据]; + Construct --> Tx[发送交易 -> PositionManager]; +``` +2. 关键数据准备 (Step-by-Step) +2.1 Token 排序 (最重要) +Uniswap V3 强制要求 token0 地址必须小于 token1。 + +TypeScript +const isTokenA0 = tokenA.address.toLowerCase() < tokenB.address.toLowerCase(); +const token0 = isTokenA0 ? tokenA : tokenB; +const token1 = isTokenA0 ? tokenB : tokenA; + +// 价格处理 +const realPrice = isTokenA0 ? userInputPrice : (1 / userInputPrice); +2.2 获取全范围 Ticks (Hardcoded) +不要在运行时动态计算,直接使用根据 tickSpacing 预计算好的“最大整数倍对齐值”,防止 Revert。 + +费率 (Fee Tier) Spacing Min Tick (tickLower) Max Tick (tickUpper) +0.01% (100) 1 -887272 887272 +0.05% (500) 10 -887270 887270 +0.3% (3000) 60 -887220 887220 +1% (10000) 200 -887200 887200 +2.3 初始价格编码 +使用 SDK 将人类可读的价格转换为链上格式。 + +TypeScript +import { encodeSqrtRatioX96 } from '@uniswap/v3-sdk'; + +// 注意:这里需要处理 Decimals 精度差 +// 建议使用 SDK 的 Price 对象或 JSBI 进行预处理 +const sqrtPriceX96 = encodeSqrtRatioX96(amount1, amount0); +3. 合约交互参数构建 +我们需要向 NonfungiblePositionManager 发送一个 multicall 交易,包含两步:初始化池子 和 添加流动性。 + +步骤 A: createAndInitializePoolIfNecessary +如果池子已存在,此步骤会自动跳过(不消耗 Gas),但这保证了你的交易总是安全的。 + +token0: token0.address + +token1: token1.address + +fee: 3000 (对应 0.3%) + +sqrtPriceX96: (上一步计算的值) + +步骤 B: mint (添加流动性) +token0: token0.address + +token1: token1.address + +fee: 3000 + +tickLower: (从 2.2 表格中获取的常量) + +tickUpper: (从 2.2 表格中获取的常量) + +amount0Desired: 用户输入的 token0 数量 + +amount1Desired: 用户输入的 token1 数量 (全范围模式下,必须两边都存) + +amount0Min: amount0Desired * 0.95 (5% 滑点保护,新建池建议放宽一点) + +amount1Min: amount1Desired * 0.95 + +recipient: 用户钱包地址 + +deadline: Math.floor(Date.now() / 1000) + 60 * 20 + +4. 调试与排错清单 (Debugging Checklist) +如果你的交易失败 (Revert) 或模拟执行报错,请按以下顺序检查: + +🔴 错误 1: Transaction reverted: T / Tick +现象: 提示 Tick 无效或越界。 + +原因: 传入的 tickLower 或 tickUpper 不是 tickSpacing 的整数倍。 + +检查: 确认你是否正确读取了表格中的值。例如 0.3% 的池子,千万不要传 -887272,必须传 -887220。 + +🔴 错误 2: STF / TransferHelper: TRANSFER_FROM_FAILED +现象: 经典的转账失败。 + +原因: 用户没有授权 (Approve) 代币给 NonfungiblePositionManager。 + +检查: + +检查 Allowance 是否足够。 + +如果是原生代币 (ETH/BNB),需检查是否正确转换为了 WETH/WBNB (V3 Manager 只收 ERC20)。 + +检查用户钱包余额是否足够支付 amountDesired。 + +🔴 错误 3: 价格极其离谱 (如 1 ETH = 0.0005 USDC) +现象: 池子建成了,但价格是倒过来的。 + +原因: Token 没有排序。 + +检查: 打印 token0 和 token1 的地址。如果 token0 是 USDC (地址小) 而 token1 是 ETH (地址大),你的价格计算公式必须是 1 / 2000 而不是 2000。 + +🔴 错误 4: Gas Estimation Failed (Gas 预估失败) +原因 A: 池子虽然没显示,但在链上可能已经被别人建了(且价格和你设定的偏差巨大)。 + +原因 B: amountMin 设置得太高。对于新建池,如果计算精度有微小误差,过高的 min 会导致交易失败。调试时可先设为 0 试试。 + +🔴 错误 5: Trading API does not support creating LP positions on HashKey Chain +现象: 提示 Trading API 不支持 HashKey Chain。 + +原因: HashKey Chain 不支持 Trading API,需要使用链上交易构建。 + +解决方案: +- 代码已自动处理:对于 HashKey Chain,系统会自动在链上构建交易 +- 使用 `NonfungiblePositionManager.multicall` 方法 +- 包含 `createAndInitializePoolIfNecessary` 和 `mint` 两个步骤 +- 确保协议版本是 V3(不是 V4) + +🔴 错误 6: HashKey Chain only supports V3 protocol +现象: 提示 HashKey Chain 只支持 V3 协议。 + +原因: 尝试使用 V4 协议创建流动性,但 HashKey Chain 不支持 V4。 + +解决方案: +- 确保 `protocolVersion` 是 `ProtocolVersion.V3` +- 检查 `positionState.protocolVersion` 是否正确设置为 V3 +- 移除所有 V4 相关的配置和代码 + +--- + +## 7. HashKey Chain 链上交易构建实现 + +### 7.1 概述 + +对于 HashKey Chain,由于 Trading API 不支持,我们直接在链上构建交易,而不是调用 Trading API。 + +### 7.2 实现位置 + +**核心文件:** +- `/packages/uniswap/src/features/transactions/liquidity/steps/increasePosition.ts` + - `createCreatePositionAsyncStep` 函数 + - 检测 HashKey Chain + - 构建链上交易 + +**调用位置:** +- `/apps/web/src/pages/CreatePosition/CreatePositionTxContext.tsx` + - `generateCreatePositionTxRequest` 函数 + - 禁用 Trading API 查询 + - 传递 `createPositionRequestArgs` 给异步步骤 + +### 7.3 交易构建流程 + +1. **检测 HashKey Chain** + ```typescript + const chainId = createPositionRequestArgs.chainId as number + const isHashKeyChain = chainId === UniverseChainId.HashKey || chainId === UniverseChainId.HashKeyTestnet + ``` + +2. **验证协议版本** + ```typescript + const protocol = createPositionRequestArgs.protocol + if (protocol !== TradingApi.ProtocolItems.V3) { + throw new Error(`HashKey Chain only supports V3 protocol, got ${protocol}`) + } + ``` + +3. **获取 Position Manager 地址** + ```typescript + const positionManagerAddress = getV3PositionManagerAddress(chainId) + ``` + +4. **构建 multicall 数据** + ```typescript + const multicallData: string[] = [] + + // 步骤 1: 创建并初始化池子(如果需要) + if (initialPrice) { + multicallData.push( + NFPMInterface.encodeFunctionData('createAndInitializePoolIfNecessary', [ + token0, + token1, + fee, + initialPrice, // sqrtPriceX96 + ]) + ) + } + + // 步骤 2: 添加流动性 + multicallData.push( + NFPMInterface.encodeFunctionData('mint', [ + { + token0, + token1, + fee, + tickLower, + tickUpper, + amount0Desired, + amount1Desired, + amount0Min, + amount1Min, + recipient: walletAddress, + deadline, + }, + ]) + ) + ``` + +5. **构建交易请求** + ```typescript + const txRequest: ValidatedTransactionRequest = { + to: positionManagerAddress, + data: NFPMInterface.encodeFunctionData('multicall', [multicallData]), + value: '0x0', + chainId, + } + ``` + +### 7.4 关键参数说明 + +- **token0, token1**: 代币地址(已排序,token0 < token1) +- **fee**: 费率等级(如 500 表示 0.05%,3000 表示 0.3%) +- **initialPrice**: 初始价格(sqrtPriceX96 格式),仅在创建新池子时需要 +- **tickLower, tickUpper**: 价格区间(全范围模式下使用预定义的常量值) +- **amount0Desired, amount1Desired**: 期望的代币数量 +- **amount0Min, amount1Min**: 最小代币数量(考虑滑点保护) +- **recipient**: 接收 NFT 的地址(用户钱包地址) +- **deadline**: 交易截止时间(Unix 时间戳,通常设置为当前时间 + 20 分钟) + +### 7.5 与 Trading API 的区别 + +| 特性 | Trading API | HashKey Chain 链上构建 | +|------|------------|----------------------| +| 协议支持 | V2, V3, V4 | 仅 V3 | +| 交易构建 | 后端 API | 前端链上构建 | +| 依赖 | Trading API 服务 | 仅需链上合约 | +| 授权检查 | Trading API | 链上检查(`useOnChainLpApproval`)| +| 错误处理 | API 错误消息 | 链上交易错误 | + +5. 工具函数 (Utils) +复制此代码块到你的项目中: + +TypeScript +import { FeeAmount } from '@uniswap/v3-sdk' + +// 全范围 Tick 常量表 +export const FULL_RANGE_TICKS = { + [FeeAmount.LOWEST]: { min: -887272, max: 887272 }, // 0.01% + [FeeAmount.LOW]: { min: -887270, max: 887270 }, // 0.05% + [FeeAmount.MEDIUM]: { min: -887220, max: 887220 }, // 0.3% + [FeeAmount.HIGH]: { min: -887200, max: 887200 }, // 1% +} + +/** + * 获取全范围配置 + * @param feeTier 费率枚举值 (e.g. 3000) + */ +export function getFullRangeConfig(feeTier: FeeAmount) { + const config = FULL_RANGE_TICKS[feeTier]; + if (!config) { + throw new Error(`Unsupported fee tier: ${feeTier}`); + } + return config; +} + +/** + * 简单的 Token 排序检查 + */ +export function sortTokens(tokenA: string, tokenB: string) { + return tokenA.toLowerCase() < tokenB.toLowerCase() + ? [tokenA, tokenB] + : [tokenB, tokenA]; +} + +6. 实施完成说明 + +本 PRD 已完成代码实施,具体修改如下: + +6.1 修改的文件 + +**核心功能文件:** + +1. `/apps/web/src/state/mint/v3/utils.ts` + - ✅ 添加 FULL_RANGE_TICKS 常量(支持所有费率等级) + - ✅ 添加 getFullRangeConfig() 工具函数 + - ✅ 添加 sortTokens() Token 地址排序函数 + - ✅ 添加 isFullRangeModeChain() 检测 HashKey Chain 的函数 + +2. `/apps/web/src/components/Liquidity/Create/RangeSelectionStep.tsx` + - ✅ 检测当前链是否为 HashKey Chain (ID: 133 或 177) + - ✅ 自动强制启用全范围模式(设置 fullRange: true) + - ✅ 隐藏"Set Range"标题和说明 + - ✅ 隐藏全范围/自定义范围切换控件(SegmentedControl) + - ✅ 隐藏价格区间图表(LiquidityRangeInput / D3LiquidityRangeInput) + - ✅ 隐藏价格区间输入框(RangeAmountInput) + - ✅ 保留初始价格输入(新建池子时必需) + +3. `/apps/web/src/components/Liquidity/Create/hooks/useLiquidityUrlState.ts` + - ✅ 修改 `currencyA` parser 的默认值 + - ✅ 从空字符串 `''` 改为 `NATIVE_CHAIN_ID` + - ✅ 当用户访问 `/positions/create/v3` 时 + - ✅ URL 自动添加 `?currencyA=NATIVE` + - ✅ HSK 自动被选中为 Token A + +4. `/apps/web/src/pages/CreatePosition/CreatePosition.tsx` + - ✅ 添加 fallback 逻辑确保 tokenA 有值 + - ✅ 使用 `initialInputs.tokenA ?? initialInputs.defaultInitialToken` + - ✅ 监听 initialInputs 变化并更新 currencyInputs + - ✅ 确保 HSK 始终作为默认 Token A 显示 + +**默认链配置文件:** + +5. `/packages/uniswap/src/features/chains/utils.ts` + - ✅ 修改 `getDefaultChainId()` 函数 + - ✅ 测试模式默认链:HashKeyTestnet (133) + - ✅ 正式模式默认链:HashKey (177) + - ✅ 不再使用 Ethereum 或 Sepolia 作为默认链 + +**Token 配置文件:** + +6. `/packages/uniswap/src/constants/tokens.ts` + - ✅ 添加 HashKey Chain 和 HashKey Testnet 的导入 + - ✅ 在 `WRAPPED_NATIVE_CURRENCY` 中添加 WHSK 配置 + - ✅ HashKey Mainnet (177): WHSK at `0xCA8aAceEC5Db1e91B9Ed3a344bA026c4a2B3ebF6` + - ✅ HashKey Testnet (133): WHSK at `0xCA8aAceEC5Db1e91B9Ed3a344bA026c4a2B3ebF6` + - ✅ 解决 "Unsupported chain ID" 错误 + +7. `/apps/web/src/components/Liquidity/Create/types.ts` & `useLiquidityUrlState.ts` + - ✅ **设置默认费率等级为 0.3%(MEDIUM)** + - ✅ 修改 `DEFAULT_POSITION_STATE.fee` 从 `undefined` 为 `DEFAULT_FEE_DATA` + - ✅ 在 `useLiquidityUrlState` 中返回 `fee ?? DEFAULT_FEE_DATA` + - ✅ 提升用户体验:用户无需手动选择费率即可继续 + - ✅ 0.3% 是 Uniswap V3 最常用的费率,适合大多数代币对 + +8. `/packages/uniswap/src/features/chains/evm/info/hashkey.ts` + - ✅ **禁用 V4 支持**:设置 `supportsV4: false` + - ✅ HashKey Chain 仅支持 V3,不支持 V4 + - ✅ Mainnet 和 Testnet 都已更新 + +9. `/apps/web/src/components/Liquidity/DepositInputForm.tsx` + - ✅ **修复自定义代币显示问题** + - ✅ 手动构造 `CurrencyInfo` 对象,不依赖后端 API + - ✅ 使用 `currencyId()` 函数正确处理代币地址 + - ✅ 解决 "Select token" 按钮问题 + +10. `/apps/web/src/components/Liquidity/utils/getPoolIdOrAddressFromCreatePositionInfo.ts` + - ✅ **添加防御性检查** + - ✅ 当 Factory 地址未配置时返回 undefined + - ✅ 避免创建新池子时的地址错误 + - ✅ 使用 `getV3FactoryAddress()` 支持自定义链 + +11. `/packages/uniswap/src/constants/v3Addresses.ts` **(新文件)** + - ✅ **配置 HashKey Chain 的 V3 合约地址** + - ✅ V3 Factory: `0x2dC2c21D1049F786C535bF9d45F999dB5474f3A0` + - ✅ NonfungiblePositionManager: `0x3c8816a838966b8b0927546A1630113F612B1553` + - ✅ SwapRouter02: `0x46cBccE3c74E95d1761435d52B0b9Abc9e2FEAC0` + - ✅ QuoterV2: `0x9576241e23629cF8ad3d8ad7b12993935b24fA9d` + - ✅ Multicall2: `0x47F625Ec29637445AA1570d7008Cf78692CdA096` + - ✅ 支持 Mainnet (177) 和 Testnet (133) + +12. `/apps/web/src/pages/CreatePosition/CreatePositionTxContext.tsx` + - ✅ **修复 V3/V4 hooks 字段问题** + - ✅ 仅在 V4 时添加 hooks 字段 + - ✅ V3 不支持 hooks,移除该字段避免 API 错误 + - ✅ 添加 fee 必填验证,确保不会传递 undefined + - ✅ **移除 V4 支持**:HashKey Chain 仅支持 V3,所有 V4 相关代码已移除 + - ✅ **过滤 V4Pool**:从 `poolOrPair` 中过滤掉 V4Pool,只保留 V3Pool 或 Pair + - ✅ **禁用 Trading API 查询**:对于 HashKey Chain,禁用 `useCreateLpPositionCalldataQuery` + - ✅ **支持链上交易构建**:当 `txRequest` 为 undefined 时,使用异步步骤在链上构建交易 + +13. `/packages/uniswap/src/features/transactions/liquidity/steps/increasePosition.ts` + - ✅ **添加 HashKey Chain 链上交易构建支持** + - ✅ 检测 HashKey Chain,如果检测到则构建链上交易而非调用 Trading API + - ✅ **仅支持 V3 协议**:如果协议不是 V3,抛出错误 + - ✅ 使用 `NonfungiblePositionManager.multicall` 构建交易 + - ✅ 包含 `createAndInitializePoolIfNecessary`(如果需要创建池子) + - ✅ 包含 `mint`(添加流动性) + - ✅ 正确处理 `amount0Desired`、`amount1Desired`、`amount0Min`、`amount1Min` + - ✅ 计算 deadline(20 分钟) + +14. `/apps/web/src/components/Liquidity/Create/types.ts` + - ✅ **修改默认协议版本**:从 V4 改为 V3 + - ✅ 确保 HashKey Chain 默认使用 V3 + - ✅ 与链配置保持一致(HashKey Chain 不支持 V4) + +15. `/packages/uniswap/src/features/transactions/liquidity/utils.ts` + - ✅ **修复错误消息显示问题** + - ✅ 修复 "id: undefined" 错误消息 + - ✅ 只有当 `requestId` 存在时才在错误消息中包含 id + +**实现方式说明:** + +本实现采用**修改默认链配置**的方式,而非修改各个页面的链接。这样做的好处: +- ✅ 保持原有的链接形式(`/positions/create/v3`) +- ✅ 所有入口点自动生效,无需逐一修改 +- ✅ URL 参数自动带上 HashKey Chain 相关信息 +- ✅ 符合系统架构设计,集中管理默认配置 + +6.2 用户体验 + +在 HashKey Chain 上添加 V3 流动性时: +1. ✅ 用户选择 Token A 和 Token B(默认 Token A 为 HSK 原生代币) +2. ✅ 用户选择费率等级(**默认为 0.3%**,也可选择 0.01%, 0.05%, 1%) +3. ✅ 如果是新建池子,用户需要输入初始价格 +4. ✅ 系统自动使用全范围模式,无需用户选择价格区间 +5. ✅ 用户输入存款数量 +6. ✅ 确认并提交交易 + +6.3 技术要点 + +- 全范围 Tick 值已预先计算并硬编码,避免运行时计算错误 +- Token 自动按地址排序,确保 token0 < token1 +- 初始价格会根据 Token 排序自动调整(必要时取倒数) +- **HashKey Chain 仅支持 V3 协议**,不支持 V4 +- **链上交易构建**:对于 HashKey Chain,不使用 Trading API,直接在链上构建交易 + - 使用 `NonfungiblePositionManager.multicall` 方法 + - 包含 `createAndInitializePoolIfNecessary`(如果需要创建池子)和 `mint`(添加流动性)两个步骤 + - 正确处理滑点保护(slippage tolerance) + - 自动计算 deadline(20 分钟) +- **默认费率等级为 0.3%**,这是 Uniswap V3 中最常用且最平衡的费率选择 +- 用户仍可手动选择其他费率等级(0.01%, 0.05%, 1%),保留灵活性 +- **链上授权检查**:使用 `useOnChainLpApproval` hook 进行链上授权检查,不依赖 Trading API + +6.4 环境配置与默认链设置 + +**测试/开发环境:** +- 默认链:HashKey Testnet (Chain ID: 133) +- Testnet Mode 开启 + +**生产环境:** +- 默认链:HashKey Mainnet (Chain ID: 177) +- Testnet Mode 关闭 + +**其他链:** +- 不受影响,保持原有的价格区间选择功能 +- 用户可以手动切换到其他链 + +--- + +6.5 默认链配置实现 + +**核心修改:** + +在 `/packages/uniswap/src/features/chains/utils.ts` 中修改 `getDefaultChainId()` 函数: + +```typescript +function getDefaultChainId({ + platform, + isTestnetModeEnabled, +}: { + platform?: Platform + isTestnetModeEnabled: boolean +}): UniverseChainId { + if (platform === Platform.SVM) { + return UniverseChainId.Solana + } + + // 默认使用 HashKey Chain + // 开发/测试环境:HashKey Testnet (133) + // 生产环境:HashKey Mainnet (177) + return isTestnetModeEnabled ? UniverseChainId.HashKeyTestnet : UniverseChainId.HashKey +} +``` + +**生效范围:** + +所有使用 `useEnabledChains()` hook 的地方都会自动使用 HashKey Chain 作为默认链: +1. ✅ 导航栏 "Pool > Create Position" (`/positions/create/v3`) +2. ✅ Positions 页面的 "New" 按钮 +3. ✅ 空状态页面的 "New Position" 按钮 +4. ✅ 所有其他创建流动性的入口 +5. ✅ URL 自动生成正确的 chain 参数 +6. ✅ 默认选择 HSK 原生代币 + +**URL 效果:** + +用户访问 `/positions/create/v3` 时: +- 测试环境自动应用:`chain=hashkey_testnet`, `currencyA=NATIVE` +- 生产环境自动应用:`chain=hashkey`, `currencyA=NATIVE` + +**环境切换方式:** + +通过应用的 Testnet Mode 开关控制: +- Testnet Mode ON → HashKey Testnet (133) +- Testnet Mode OFF → HashKey Mainnet (177) \ No newline at end of file diff --git a/scripts/clean.sh b/scripts/clean.sh old mode 100644 new mode 100755 diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 00000000000..3a4b3ca2d25 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,82 @@ +#!/bin/bash + +# HSKswap Docker 部署脚本 +# 用法: ./scripts/deploy.sh [environment] [image_tag] + +set -e + +# 颜色输出 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# 配置 +REGISTRY="${DOCKER_REGISTRY:-ghcr.io}" +IMAGE_NAME="${DOCKER_IMAGE_NAME:-hashkeychain/hskswap}" +ENVIRONMENT="${1:-staging}" +IMAGE_TAG="${2:-latest}" +CONTAINER_NAME="hskswap" +PORT="${PORT:-3000}" + +# 完整镜像名称 +FULL_IMAGE_NAME="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}" + +echo -e "${GREEN}🚀 开始部署 HSKswap${NC}" +echo -e "${YELLOW}环境: ${ENVIRONMENT}${NC}" +echo -e "${YELLOW}镜像: ${FULL_IMAGE_NAME}${NC}" +echo "" + +# 检查 Docker 是否运行 +if ! docker info > /dev/null 2>&1; then + echo -e "${RED}❌ Docker 未运行,请先启动 Docker${NC}" + exit 1 +fi + +# 拉取最新镜像 +echo -e "${GREEN}📥 拉取镜像...${NC}" +docker pull "${FULL_IMAGE_NAME}" + +# 停止并删除旧容器 +if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + echo -e "${YELLOW}🛑 停止旧容器...${NC}" + docker stop "${CONTAINER_NAME}" || true + docker rm "${CONTAINER_NAME}" || true +fi + +# 运行新容器 +echo -e "${GREEN}▶️ 启动新容器...${NC}" +docker run -d \ + --name "${CONTAINER_NAME}" \ + --restart unless-stopped \ + -p "${PORT}:80" \ + --env-file .env.${ENVIRONMENT} \ + "${FULL_IMAGE_NAME}" + +# 等待容器启动 +echo -e "${YELLOW}⏳ 等待容器启动...${NC}" +sleep 5 + +# 健康检查 +echo -e "${GREEN}🏥 检查容器健康状态...${NC}" +for i in {1..30}; do + if docker exec "${CONTAINER_NAME}" wget --quiet --tries=1 --spider http://localhost/health > /dev/null 2>&1; then + echo -e "${GREEN}✅ 容器健康检查通过!${NC}" + break + fi + if [ $i -eq 30 ]; then + echo -e "${RED}❌ 容器健康检查失败${NC}" + docker logs "${CONTAINER_NAME}" + exit 1 + fi + sleep 2 +done + +# 显示容器信息 +echo "" +echo -e "${GREEN}✅ 部署完成!${NC}" +echo -e "${YELLOW}容器名称: ${CONTAINER_NAME}${NC}" +echo -e "${YELLOW}访问地址: http://localhost:${PORT}${NC}" +echo "" +echo "查看日志: docker logs -f ${CONTAINER_NAME}" +echo "停止容器: docker stop ${CONTAINER_NAME}" diff --git a/scripts/health-check.sh b/scripts/health-check.sh new file mode 100755 index 00000000000..f49129190c7 --- /dev/null +++ b/scripts/health-check.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# HSKswap 健康检查脚本 +# 用法: ./scripts/health-check.sh + +set -e + +CONTAINER_NAME="hskswap" +HEALTH_URL="http://localhost/health" +MAX_RETRIES=5 +RETRY_INTERVAL=2 + +echo "🏥 检查容器健康状态..." + +# 检查容器是否运行 +if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + echo "❌ 容器未运行" + exit 1 +fi + +# 健康检查 +for i in $(seq 1 $MAX_RETRIES); do + if docker exec "${CONTAINER_NAME}" wget --quiet --tries=1 --spider "${HEALTH_URL}" > /dev/null 2>&1; then + echo "✅ 容器健康检查通过" + exit 0 + fi + echo "⏳ 等待中... ($i/$MAX_RETRIES)" + sleep $RETRY_INTERVAL +done + +echo "❌ 容器健康检查失败" +docker logs --tail 50 "${CONTAINER_NAME}" +exit 1 diff --git a/scripts/rollback.sh b/scripts/rollback.sh new file mode 100755 index 00000000000..2f7385a4a2d --- /dev/null +++ b/scripts/rollback.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +# HSKswap 回滚脚本 +# 用法: ./scripts/rollback.sh [previous_tag] + +set -e + +# 颜色输出 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +CONTAINER_NAME="hskswap" +REGISTRY="${DOCKER_REGISTRY:-ghcr.io}" +IMAGE_NAME="${DOCKER_IMAGE_NAME:-hashkeychain/hskswap}" +PREVIOUS_TAG="${1:-previous}" + +if [ "$PREVIOUS_TAG" == "previous" ]; then + echo -e "${YELLOW}⚠️ 请指定要回滚的镜像标签${NC}" + echo "用法: ./scripts/rollback.sh " + echo "示例: ./scripts/rollback.sh v1.0.0" + exit 1 +fi + +FULL_IMAGE_NAME="${REGISTRY}/${IMAGE_NAME}:${PREVIOUS_TAG}" + +echo -e "${YELLOW}🔄 开始回滚到 ${PREVIOUS_TAG}${NC}" + +# 检查镜像是否存在 +if ! docker image inspect "${FULL_IMAGE_NAME}" > /dev/null 2>&1; then + echo -e "${YELLOW}📥 镜像不存在,正在拉取...${NC}" + docker pull "${FULL_IMAGE_NAME}" +fi + +# 停止当前容器 +if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + echo -e "${YELLOW}🛑 停止当前容器...${NC}" + docker stop "${CONTAINER_NAME}" +fi + +# 删除当前容器 +if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + docker rm "${CONTAINER_NAME}" +fi + +# 使用回滚脚本重新部署 +./scripts/deploy.sh staging "${PREVIOUS_TAG}" + +echo -e "${GREEN}✅ 回滚完成!${NC}"