diff --git a/.env.local b/.env.local new file mode 100644 index 0000000..2c990f5 --- /dev/null +++ b/.env.local @@ -0,0 +1,21 @@ +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PWD= +REDIS_DB=0 + +NODE_ENV=local +MONGODB_URI=mongodb://mongo:27017/onekey_swap?authSource=admin +MONGODB_USER=root +MONGODB_PASSWORD=root + +AWS_ACCESS_KEY_ID= +AWS_SECRET_KEY= +AWS_REGION= + +COOKIE_SIGN_KEY= + +LOKALISE_TOKEN= +LOKALISE_PROJECT_ID= + +PORT=7001 +GLOBAL_PREFIX=/api diff --git a/1key.postman_collection.json b/1key.postman_collection.json new file mode 100644 index 0000000..7a4d62f --- /dev/null +++ b/1key.postman_collection.json @@ -0,0 +1,584 @@ +{ + "info": { + "_postman_id": "879cb9ba-e305-401c-9f0d-86ace0b2e410", + "name": "1key", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "8417962" + }, + "item": [ + { + "name": "http://localhost:7001/api/health", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:7001/api/health", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7001", + "path": [ + "api", + "health" + ] + } + }, + "response": [] + }, + { + "name": "http://localhost:7001/api/swap/networks", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:7001/api/swap/networks", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7001", + "path": [ + "api", + "swap", + "networks" + ] + } + }, + "response": [] + }, + { + "name": "http://localhost:7001/api/swap/networks", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:7001/api/swap/networks?provider=openocean", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7001", + "path": [ + "api", + "swap", + "networks" + ], + "query": [ + { + "key": "provider", + "value": "openocean" + } + ] + } + }, + "response": [] + }, + { + "name": "http://localhost:7001/api/swap/networks", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:7001/api/swap/networks?provider=openocean&refresh=true", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7001", + "path": [ + "api", + "swap", + "networks" + ], + "query": [ + { + "key": "provider", + "value": "openocean" + }, + { + "key": "refresh", + "value": "true" + } + ] + } + }, + "response": [] + }, + { + "name": "http://localhost:7001/api/swap/tokens", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:7001/api/swap/tokens?chainCode=bsc", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7001", + "path": [ + "api", + "swap", + "tokens" + ], + "query": [ + { + "key": "chainCode", + "value": "bsc" + } + ] + } + }, + "response": [] + }, + { + "name": "http://localhost:7001/api/swap/tokens", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:7001/api/swap/tokens?chainCode=eth&provider=openocean&keyword=usdt&page=1&pageSize=20", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7001", + "path": [ + "api", + "swap", + "tokens" + ], + "query": [ + { + "key": "chainCode", + "value": "eth" + }, + { + "key": "provider", + "value": "openocean" + }, + { + "key": "keyword", + "value": "usdt" + }, + { + "key": "page", + "value": "1" + }, + { + "key": "pageSize", + "value": "20" + } + ] + } + }, + "response": [] + }, + { + "name": "http://localhost:7001/api/swap/tokens", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:7001/api/swap/tokens?chainCode=eth&provider=openocean&refresh=true&page=2&pageSize=20", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7001", + "path": [ + "api", + "swap", + "tokens" + ], + "query": [ + { + "key": "chainCode", + "value": "eth" + }, + { + "key": "provider", + "value": "openocean" + }, + { + "key": "refresh", + "value": "true" + }, + { + "key": "page", + "value": "2" + }, + { + "key": "pageSize", + "value": "20" + } + ] + } + }, + "response": [] + }, + { + "name": "http://localhost:7001/api/swap/quote", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:7001/api/swap/quote?provider=openocean&chainCode=bsc&inTokenAddress=0x55d398326f99059ff775485246999027b3197955&outTokenAddress=0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d&amountDecimals=5000000000000000000&gasPriceDecimals=1000000000&slippage=1&account=0x9116780aEf4B376499358fa7dEeC00cCF64fA801", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7001", + "path": [ + "api", + "swap", + "quote" + ], + "query": [ + { + "key": "provider", + "value": "openocean" + }, + { + "key": "chainCode", + "value": "bsc" + }, + { + "key": "inTokenAddress", + "value": "0x55d398326f99059ff775485246999027b3197955" + }, + { + "key": "outTokenAddress", + "value": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d" + }, + { + "key": "amountDecimals", + "value": "5000000000000000000" + }, + { + "key": "gasPriceDecimals", + "value": "1000000000" + }, + { + "key": "slippage", + "value": "1" + }, + { + "key": "account", + "value": "0x9116780aEf4B376499358fa7dEeC00cCF64fA801" + } + ] + } + }, + "response": [] + }, + { + "name": "http://localhost:7001/api/swap/quote", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:7001/api/swap/quote?provider=openocean&chainCode=bsc&inTokenAddress=0x55d398326f99059ff775485246999027b3197955&outTokenAddress=0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d&gasPriceDecimals=1&slippage=1&account=0x9116780aEf4B376499358fa7dEeC00cCF64fA801&amountDecimals=5000000000000000000", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7001", + "path": [ + "api", + "swap", + "quote" + ], + "query": [ + { + "key": "provider", + "value": "openocean" + }, + { + "key": "chainCode", + "value": "bsc" + }, + { + "key": "inTokenAddress", + "value": "0x55d398326f99059ff775485246999027b3197955" + }, + { + "key": "outTokenAddress", + "value": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d" + }, + { + "key": "gasPriceDecimals", + "value": "1" + }, + { + "key": "slippage", + "value": "1" + }, + { + "key": "account", + "value": "0x9116780aEf4B376499358fa7dEeC00cCF64fA801" + }, + { + "key": "amountDecimals", + "value": "5000000000000000000" + } + ] + } + }, + "response": [] + }, + { + "name": "http://localhost:7001/api/swap/quotes", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"providers\": [\"openocean\"],\n \"chainCode\": \"eth\",\n \"inTokenAddress\": \"0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\",\n \"outTokenAddress\": \"0xdac17f958d2ee523a2206206994597c13d831ec7\",\n \"amountDecimals\": \"1000000000000000000\",\n \"slippage\": 1,\n \"gasPriceDecimals\":\"12\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:7001/api/swap/quotes", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7001", + "path": [ + "api", + "swap", + "quotes" + ] + } + }, + "response": [] + }, + { + "name": "http://localhost:7001/api/swap/tx/build", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"provider\": \"openocean\",\n \"chainCode\": \"bsc\",\n \"inTokenAddress\": \"0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE\",\n \"outTokenAddress\": \"0x55d398326f99059fF775485246999027B3197955\",\n \"amountDecimals\": \"5\",\n \"gasPriceDecimals\": \"1\",\n \"account\": \"0x28816c4C4792467390C90e5B426F198570E29307\",\n \"referrer\": \"0xD4eb4cbB1ECbf96a1F0C67D958Ff6fBbB7B037BB\",\n \"slippage\": 1\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:7001/api/swap/tx/build", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7001", + "path": [ + "api", + "swap", + "tx", + "build" + ] + } + }, + "response": [] + }, + { + "name": "https://open-api.openocean.finance/v4/bsc/swap", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "https://open-api.openocean.finance/v4/bsc/swap?inTokenAddress=0x55d398326f99059ff775485246999027b3197955&outTokenAddress=0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d&amount=5&gasPrice=1&slippage=1&account=0x9116780aEf4B376499358fa7dEeC00cCF64fA801&referrer=0xD4eb4cbB1ECbf96a1F0C67D958Ff6fBbB7B037BB", + "protocol": "https", + "host": [ + "open-api", + "openocean", + "finance" + ], + "path": [ + "v4", + "bsc", + "swap" + ], + "query": [ + { + "key": "inTokenAddress", + "value": "0x55d398326f99059ff775485246999027b3197955" + }, + { + "key": "outTokenAddress", + "value": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d" + }, + { + "key": "amount", + "value": "5" + }, + { + "key": "gasPrice", + "value": "1" + }, + { + "key": "slippage", + "value": "1" + }, + { + "key": "account", + "value": "0x9116780aEf4B376499358fa7dEeC00cCF64fA801" + }, + { + "key": "referrer", + "value": "0xD4eb4cbB1ECbf96a1F0C67D958Ff6fBbB7B037BB" + } + ] + } + }, + "response": [] + }, + { + "name": "http://localhost:7001/api/swap/tx/build", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"provider\": \"openocean\",\n \"chainCode\": \"bsc\",\n \"inTokenAddress\": \"0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE\",\n \"outTokenAddress\": \"0x55d398326f99059fF775485246999027B3197955\",\n \"amountDecimals\": \"5\",\n \"gasPriceDecimals\": \"1000000000\",\n \"account\": \"0x28816c4C4792467390C90e5B426F198570E29307\",\n \"referrer\": \"0xD4eb4cbB1ECbf96a1F0C67D958Ff6fBbB7B037BB\",\n \"slippage\": 1\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:7001/api/swap/tx/build", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7001", + "path": [ + "api", + "swap", + "tx", + "build" + ] + } + }, + "response": [] + }, + { + "name": "http://localhost:7001/api/gas/estimate", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"chainCode\": \"eth\",\n \"from\": \"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045\",\n \"to\": \"0x000000000000000000000000000000000000dEaD\",\n \"value\": \"0\",\n \"data\": \"0x\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:7001/api/gas/estimate", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7001", + "path": [ + "api", + "gas", + "estimate" + ] + } + }, + "response": [] + }, + { + "name": "http://localhost:7001/api/gas/price", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:7001/api/gas/price?chainCode=eth", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7001", + "path": [ + "api", + "gas", + "price" + ], + "query": [ + { + "key": "chainCode", + "value": "eth" + } + ] + } + }, + "response": [] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "packages": {}, + "requests": {}, + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "packages": {}, + "requests": {}, + "exec": [ + "" + ] + } + } + ], + "variable": [ + { + "key": "BASE_URL", + "value": "", + "type": "default" + } + ] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 4420077..957e9c1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,16 +15,16 @@ FROM node:18-alpine WORKDIR /app -ARG NODE_ENV=production +ARG NODE_ENV=local ENV NODE_ENV $NODE_ENV RUN apk add --no-cache tzdata && rm -rf /var/cache/apk/* COPY --from=build /app /app -RUN yarn run prepare-env\ - && yarn workspaces focus --production\ - && yarn cache clean +# RUN yarn run prepare-env\ +# && yarn workspaces focus --production\ +RUN yarn cache clean ENV TZ="Asia/Shanghai" diff --git a/api_curls.md b/api_curls.md new file mode 100644 index 0000000..2e45163 --- /dev/null +++ b/api_curls.md @@ -0,0 +1,231 @@ +# OneKey Postman Collection 同步版 cURL + +以下请求已按 `1key.postman_collection.json` 对齐。 + +## 1) 健康检查 + +```bash +curl --location 'http://localhost:7001/api/health' +``` + +## 2) 网络列表 + +### 2.1 全部网络 + +```bash +curl --location 'http://localhost:7001/api/swap/networks' +``` + +### 2.2 指定 provider + +```bash +curl --location 'http://localhost:7001/api/swap/networks?provider=openocean' +``` + +### 2.3 指定 provider + refresh + +```bash +curl --location 'http://localhost:7001/api/swap/networks?provider=openocean&refresh=true' +``` + +## 3) Token 列表 + +### 3.1 按链查询 + +```bash +curl --location 'http://localhost:7001/api/swap/tokens?chainCode=bsc' +``` + +### 3.2 provider + keyword + 分页 + +```bash +curl --location 'http://localhost:7001/api/swap/tokens?chainCode=eth&provider=openocean&keyword=usdt&page=1&pageSize=20' +``` + +### 3.3 provider + refresh + 分页 + +```bash +curl --location 'http://localhost:7001/api/swap/tokens?chainCode=eth&provider=openocean&refresh=true&page=2&pageSize=20' +``` + +## 4) 单渠道询价 + +### 4.1 quote 示例 1 + +```bash +curl --location 'http://localhost:7001/api/swap/quote?provider=openocean&chainCode=bsc&inTokenAddress=0x55d398326f99059ff775485246999027b3197955&outTokenAddress=0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d&amountDecimals=5000000000000000000&gasPriceDecimals=1000000000&slippage=1&account=0x9116780aEf4B376499358fa7dEeC00cCF64fA801' +``` + +### 4.2 quote 示例 2 + +```bash +curl --location 'http://localhost:7001/api/swap/quote?provider=openocean&chainCode=bsc&inTokenAddress=0x55d398326f99059ff775485246999027b3197955&outTokenAddress=0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d&gasPriceDecimals=1&slippage=1&account=0x9116780aEf4B376499358fa7dEeC00cCF64fA801&amountDecimals=5000000000000000000' +``` + +## 5) 多渠道并发询价 + +```bash +curl --request POST \ + --url 'http://localhost:7001/api/swap/quotes' \ + --header 'Content-Type: application/json' \ + --data '{ + "providers": ["openocean"], + "chainCode": "eth", + "inTokenAddress": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "outTokenAddress": "0xdac17f958d2ee523a2206206994597c13d831ec7", + "amountDecimals": "1000000000000000000", + "slippage": 1, + "gasPriceDecimals": "12" +}' +``` + +## 6) 多渠道轮询询价(SSE) + +使用 POST + JSON Body,服务端通过 SSE 推送每轮询价结果。 +`Ctrl+C` 断开连接,服务端自动停止轮询。 + +### 6.1 默认间隔(5 秒) + +```bash +curl --no-buffer --request POST \ + --url 'http://localhost:7001/api/swap/quotes/poll' \ + --header 'Content-Type: application/json' \ + --data '{ + "providers": ["openocean"], + "chainCode": "eth", + "inTokenAddress": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "outTokenAddress": "0xdac17f958d2ee523a2206206994597c13d831ec7", + "amountDecimals": "1000000000000000000", + "gasPriceDecimals": "12", + "slippage": 1 +}' +``` + +### 6.2 自定义间隔(10 秒) + +```bash +curl --no-buffer --request POST \ + --url 'http://localhost:7001/api/swap/quotes/poll' \ + --header 'Content-Type: application/json' \ + --data '{ + "providers": ["openocean"], + "chainCode": "eth", + "inTokenAddress": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "outTokenAddress": "0xdac17f958d2ee523a2206206994597c13d831ec7", + "amountDecimals": "1000000000000000000", + "gasPriceDecimals": "12", + "slippage": 1, + "interval": 10 +}' +``` + +### 6.3 多渠道(interval 3 秒) + +```bash +curl --no-buffer --request POST \ + --url 'http://localhost:7001/api/swap/quotes/poll' \ + --header 'Content-Type: application/json' \ + --data '{ + "providers": ["openocean", "1inch"], + "chainCode": "bsc", + "inTokenAddress": "0x55d398326f99059ff775485246999027b3197955", + "outTokenAddress": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", + "amountDecimals": "5000000000000000000", + "gasPriceDecimals": "1000000000", + "slippage": 1, + "account": "0x9116780aEf4B376499358fa7dEeC00cCF64fA801", + "interval": 3 +}' +``` + +**响应事件格式:** + +``` +event: quote +data: {"round":1,"provider":"openocean","status":"success","quote":{...},"ts":1234567890} + +event: quote +data: {"round":1,"provider":"1inch","status":"error","error":{"code":"SWAP_PROVIDER_TIMEOUT","message":"..."},"ts":1234567891} + +event: round_done +data: {"round":1,"bestQuote":{...},"successCount":1,"failCount":1,"ts":1234567892} + +# 等待 interval 秒后第 2 轮继续... +``` + +**参数说明:** + +| 参数 | 必填 | 说明 | +|------|------|------| +| `providers[]` | 是 | 渠道列表,可传多个 | +| `chainCode` | 是 | 链编码,如 `eth`、`bsc` | +| `inTokenAddress` | 是 | 输入代币地址 | +| `outTokenAddress` | 是 | 输出代币地址 | +| `amountDecimals` | 是 | 输入数量(最小单位) | +| `gasPriceDecimals` | 是 | Gas Price(最小单位) | +| `slippage` | 否 | 滑点 | +| `account` | 否 | 钱包地址 | +| `interval` | 否 | 轮询间隔秒数,范围 `[3, 60]`,默认 `5` | + +## 7) 构建交易 + +### 6.1 buildTx 示例 1 + +```bash +curl --request POST \ + --url 'http://localhost:7001/api/swap/tx/build' \ + --header 'Content-Type: application/json' \ + --data '{ + "provider": "openocean", + "chainCode": "bsc", + "inTokenAddress": "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + "outTokenAddress": "0x55d398326f99059fF775485246999027B3197955", + "amountDecimals": "5", + "gasPriceDecimals": "1", + "account": "0x28816c4C4792467390C90e5B426F198570E29307", + "referrer": "0xD4eb4cbB1ECbf96a1F0C67D958Ff6fBbB7B037BB", + "slippage": 1 +}' +``` + +### 6.2 buildTx 示例 2 + +```bash +curl --request POST \ + --url 'http://localhost:7001/api/swap/tx/build' \ + --header 'Content-Type: application/json' \ + --data '{ + "provider": "openocean", + "chainCode": "bsc", + "inTokenAddress": "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + "outTokenAddress": "0x55d398326f99059fF775485246999027B3197955", + "amountDecimals": "5", + "gasPriceDecimals": "1000000000", + "account": "0x28816c4C4792467390C90e5B426F198570E29307", + "referrer": "0xD4eb4cbB1ECbf96a1F0C67D958Ff6fBbB7B037BB", + "slippage": 1 +}' +``` + +## 7) Gas 接口 + +### 7.1 估算 Gas + +```bash +curl --request POST \ + --url 'http://localhost:7001/api/gas/estimate' \ + --header 'Content-Type: application/json' \ + --data '{ + "chainCode": "eth", + "from": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + "to": "0x000000000000000000000000000000000000dEaD", + "value": "0", + "data": "0x" +}' +``` + +### 7.2 获取 Gas Price + +```bash +curl --location 'http://localhost:7001/api/gas/price?chainCode=eth' +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dca9861 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,86 @@ +services: + server: + build: + context: . + dockerfile: Dockerfile + args: + NODE_ENV: local + container_name: swap-server + environment: + NODE_ENV: local + PORT: 7001 + GLOBAL_PREFIX: /api + COOKIE_SIGN_KEY: local-cookie-sign-key + AWS_ACCESS_KEY_ID: local-access-key + AWS_SECRET_KEY: local-secret-key + AWS_REGION: us-east-1 + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_PWD: "" + REDIS_DB: 0 + MONGODB_URI: mongodb://mongo:27017/onekey_swap?authSource=admin + MONGODB_USER: root + MONGODB_PASSWORD: root + ports: + - "7001:7001" + depends_on: + redis: + condition: service_healthy + mongo: + condition: service_healthy + + redis: + image: redis:7-alpine + container_name: swap-redis + command: ["redis-server", "--appendonly", "yes"] + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + restart: unless-stopped + + mongo: + image: mongo:7 + container_name: swap-mongo + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: root + MONGO_INITDB_DATABASE: onekey_swap + ports: + - "27017:27017" + volumes: + - mongo_data:/data/db + healthcheck: + test: + [ + "CMD-SHELL", + "mongosh --quiet --username $$MONGO_INITDB_ROOT_USERNAME --password $$MONGO_INITDB_ROOT_PASSWORD --authenticationDatabase admin --eval 'db.runCommand({ ping: 1 }).ok' | grep 1", + ] + interval: 10s + timeout: 5s + retries: 10 + restart: unless-stopped + + mongo-express: + image: mongo-express:1-20-alpine3.19 + container_name: swap-mongo-express + environment: + ME_CONFIG_MONGODB_ADMINUSERNAME: root + ME_CONFIG_MONGODB_ADMINPASSWORD: root + ME_CONFIG_MONGODB_URL: mongodb://root:root@mongo:27017/?authSource=admin + ME_CONFIG_BASICAUTH: "false" + ports: + - "8081:8081" + depends_on: + mongo: + condition: service_healthy + restart: unless-stopped + +volumes: + redis_data: + mongo_data: diff --git a/docs/swap-current-implementation-simple-design.md b/docs/swap-current-implementation-simple-design.md new file mode 100644 index 0000000..4ed247f --- /dev/null +++ b/docs/swap-current-implementation-simple-design.md @@ -0,0 +1,152 @@ +# Swap(OpenOcean)当前实现技术设计方案 + +## 1. 文档目标 + +- 说明当前代码已落地的 Swap 后端技术方案(基于 `server-service-boilerplate`)。 +- 给出接口、数据、缓存、同步任务和错误处理的实际行为。 +- 标注与题目目标的对齐情况和后续优化方向。 + +## 2. 设计范围 + +- 渠道商:当前仅接入 `openocean`。 +- 业务流程:支持网络、Token 列表、单渠道询价、多渠道并发询价、构建交易(不广播)。 +- 存储与缓存:MongoDB(Network/Token)、Redis(查询缓存与对象缓存)。 +- 定时任务:OpenOcean Token 同步任务(Bull)。 + +## 3. 系统模块与职责 + +- `SwapController`:对外 API 聚合层,负责参数标准化、缓存刷新分支、结果聚合。 +- `GasController`:Gas 能力接口层,提供 gas 估算与 gas price 档位数据。 +- `ProviderManager`:渠道路由层,屏蔽具体 adapter 细节,提供统一 `getNetworks/getTokenList/quote/buildTx`。 +- `OpenOceanAdapter`:第三方 API 适配层,负责请求封装、超时重试、响应标准化、错误映射。 +- `TokenService`:Token 数据访问层,负责 DB 查询、分页、Redis Cache-Aside、缓存失效。 +- `NetworkService`:Network 数据访问层,负责 DB 查询与缓存读写。 +- `chain/evm + chain/indexer`:链级工具层,负责 RPC 费率获取、gas 估算、unsignedTx 构建与链类型判断。 +- `OpenOceanSchedule`:定时全链 Token 同步任务。 +- `DefaultErrorFilter + ResponseWrapperMiddleware`:统一响应与统一异常输出。 +- `base/reply`:统一响应格式。 +- `cache/redis`:Redis 缓存 key 与 payload 定义。 + +### 3.1 目录结构说明(核心) + +- `src/app` +- API 控制器入口(`swap.controller.ts`、`gas.controller.ts`)。 +- `src/thirdparty/swap` +- 渠道抽象与路由(`provider-manager.ts`、`provider.interface.ts`、`provider.errors.ts`)。 +- `src/thirdparty/swap/providers/openocean` +- OpenOcean 适配实现(network/token/quote/buildTx + 重试/错误映射)。 +- `src/entity/networks` +- Network 持久化模型与查询服务。 +- `src/entity/token` +- Token 持久化模型与分页查询服务。 +- `src/schedule` +- 定时任务(`openocean.schedule.ts`)。 +- `src/chain` +- 链级工具(gas/fee/unsignedTx)。 +- `src/cache` +- Redis 缓存读写封装与 key 约定。 +- `src/filter`、`src/middleware` +- 全局异常处理与统一响应包装。 + +## 4. 核心数据模型 + +### 4.1 Network(Mongo: `Network`) + +- 主字段:`provider`, `chainCode`, `chainName`, `chainId`, `nativeTokenAddress`, `isActive`, `metadata`, `syncedAt`。 +- 关键索引: +- unique(`provider`, `chainCode`) +- index(`provider`, `chainId`) +- index(`provider`, `syncedAt`) +- 说明:`chainId` 已支持空值(兼容 `sol/sui` 等非 EVM 链)。 + +### 4.2 Token(Mongo: `Token`) + +- 主字段:`provider`, `chainCode`, `address`, `symbol`, `name`, `decimals`, `logoURI`, `isActive`, `raw`, `syncedAt`。 +- 关键索引: +- unique(`provider`, `chainCode`, `address`) +- index(`provider`, `chainCode`, `symbol`) +- index(`provider`, `syncedAt`) +- 说明:`address` 统一小写,`raw` 保留上游原始字段。 + +## 5. API 设计与处理流程 + +### 5.1 `GET /swap/networks` + +- `refresh=false`:优先读取 Redis 网络缓存;命中直接返回。 +- 未命中或 `refresh=true`:拉取 provider 网络列表并同步到 DB,再回写 Redis。 +- provider 为空时会汇总所有已注册 provider 的网络。 + +### 5.2 `GET /swap/tokens` + +- `refresh=true`:直接向 provider 拉 Token 列表,仅返回,不写 DB(实时视图)。 +- `refresh=false`: +- 先判断本地 Token 缓存是否过期(按 `count + latestSyncedAt`)。 +- 需要时触发按链同步写入 DB。 +- 再从 DB 分页查询并组装 `providers` 覆盖关系。 + +### 5.3 `GET /swap/quote` + +- 单渠道询价。 +- 校验 `provider/amountDecimals/gasPriceDecimals` 后调用 adapter。 + +### 5.4 `POST /swap/quotes` + +- 多渠道并发询价(`Promise.allSettled`)。 +- 返回 `quotes + failedProviders + bestQuote`。 + +### 5.5 `POST /swap/tx/build` + +- 先调用 provider 构建 swap tx。 +- 再调用链工具构建 `unsignedTx`(EVM 参数补全,如 gas/nonce)。 + +### 5.6 `POST /gas/estimate` + +- 输入链与交易基础参数(`chainCode/to/from/data/value`)。 +- 通过 `chain/evm` 执行 RPC 估算,返回十进制和十六进制 gas limit。 + +### 5.7 `GET /gas/price` + +- 输入 `chainCode`。 +- 返回 `legacy` 与 `eip1559` 两组分档 gas 价格(`slow/standard/fast`)。 +- 当链不支持 EIP-1559 时,`eip1559` 返回 `null`。 + +## 6. 第三方接入(OpenOcean) + +- `getNetworks`:使用本地维护的支持链清单(静态映射)。 +- `getTokenList`:`/v4/:chain/tokenList`。 +- `quote`:`/v4/:chain/quote`。 +- `buildTx`:`/v4/:chain/swap`。 +- 请求能力: +- 默认超时 `10s`。 +- 超时类错误可重试(默认重试 1 次)。 +- 请求与响应有统一日志埋点(`[swap.thirdparty.*]`)。 + +## 7. 缓存与一致性策略 + +- 网络缓存:Redis `swap:networks:*`。 +- Token 查询缓存:Redis `swap:token:query:*`。 +- Token 对象缓存:Redis `swap:token:*`(按 token id)。 +- 采用 Cache-Aside:查缓存 -> 回源 DB -> 回填缓存;写 DB 后删除相关查询缓存。 +- 已补充“缓存自愈”:若读到字段不完整的旧 token 缓存,会回源 DB 重建。 + +## 8. 定时同步机制(openocean.schedule) + +- 任务定义:`@Processor('openoceanTokenSync', { repeat.cron })`。 +- 默认 cron:`*/30 * * * *`(每 30 分钟一次,可通过 `OPENOCEAN_TOKEN_SYNC_CRON` 覆盖)。 +- 启动时机:应用在 `local/production` 启用 Bull 组件后,自动注册该 repeat job。 +- 执行时机:由 cron 触发 `execute()`。 +- 执行逻辑:按链拉 token,逐条 upsert(存在更新,不存在创建,重复键回退更新)。 + +## 9. 错误处理与响应约定 + +- 成功响应:统一包装为 `{ code: 0, message: success, data }`。 +- 失败响应:由 `DefaultErrorFilter` 处理,返回 `{ code, message, data }`,HTTP 状态固定 200。 +- `ResParamsError` 当前返回 `MidwayHttpError(..., 404)`,用于参数错误提示。 + +## 10. 与 goal 的对齐情况 + +- 已覆盖: +- OpenOcean 基础对接(network/token/quote/swap-build)。 +- 网络和代币实体设计与持久化。 +- 多渠道并发询价能力(结构已支持,当前默认 provider 主要为 openocean)。 +- 自动同步方案(Bull cron 定时任务)。 diff --git a/package.json b/package.json index 11a5af3..f8f6f76 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@midwayjs/validate": "3", "@typegoose/typegoose": "^11.7.1", "dotenv": "^16.3.1", + "ethers": "^6.16.0", "glob": "^10.3.10", "lint-staged": "^15.2.2", "lodash": "^4.17.21", diff --git a/src/app/gas.controller.ts b/src/app/gas.controller.ts new file mode 100644 index 0000000..1e2eac3 --- /dev/null +++ b/src/app/gas.controller.ts @@ -0,0 +1,232 @@ +import { Controller, Get, Post, Query, Body } from '@midwayjs/core'; +import { Rule, RuleType } from '@midwayjs/validate'; +import { formatUnits, toQuantity } from 'ethers'; +import { ResParamsError, ResInternalServerError } from '../base/reply'; +import { getEvmRpcUrl, isEvmChainCode } from '../chain/indexer'; +import { estimateGasLimit, getFeeData } from '../chain/evm'; + +class GasEstimateRequest { + @Rule(RuleType.string().required()) + chainCode: string; + + @Rule(RuleType.string().required()) + to: string; + + @Rule(RuleType.string().required()) + from: string; + + @Rule(RuleType.string()) + data?: string; + + @Rule(RuleType.string()) + value?: string; + + @Rule(RuleType.number().min(10000)) + bufferBps?: number; +} + +class GasPriceQuery { + @Rule(RuleType.string().required()) + chainCode: string; +} + +interface GasEstimateResponse { + chainCode: string; + gasLimit: string; + gasLimitHex: string; + bufferBps: number; +} + +interface GasPriceTier { + gasPrice?: string; + gasPriceText?: string; + maxFeePerGas?: string; + maxFeePerGasText?: string; + maxPriorityFeePerGas?: string; + maxPriorityFeePerGasText?: string; +} + +interface LegacyGasPriceGroups { + slow: GasPriceTier; + standard: GasPriceTier; + fast: GasPriceTier; +} + +interface Eip1559GasPriceGroups { + slow: GasPriceTier; + standard: GasPriceTier; + fast: GasPriceTier; +} + +interface GasPriceResponse { + chainCode: string; + unit: 'wei'; + legacy: LegacyGasPriceGroups; + eip1559: Eip1559GasPriceGroups | null; +} + +const DEFAULT_GAS_BUFFER_BPS = 12000; +const SLOW_BPS = 9000; +const STANDARD_BPS = 10000; +const FAST_BPS = 11000; + +@Controller('/gas') +export class GasController { + @Post('/estimate') + async estimateGas( + @Body() body: GasEstimateRequest + ): Promise { + const chainCode = this.normalizeChainCode(body.chainCode); + this.ensureEvmChainCode(chainCode); + + try { + const gasLimit = await estimateGasLimit({ + rpcUrl: getEvmRpcUrl(chainCode), + to: body.to, + from: body.from, + data: body.data, + value: body.value, + bufferBps: body.bufferBps || DEFAULT_GAS_BUFFER_BPS, + }); + + return { + chainCode, + gasLimit: gasLimit.toString(), + gasLimitHex: toQuantity(gasLimit), + bufferBps: body.bufferBps || DEFAULT_GAS_BUFFER_BPS, + }; + } catch (error) { + throw ResInternalServerError( + `estimateGas failed on chain=${chainCode}: ${error}` + ); + } + } + + @Get('/price') + async getGasPrice(@Query() query: GasPriceQuery): Promise { + const chainCode = this.normalizeChainCode(query.chainCode); + this.ensureEvmChainCode(chainCode); + + try { + const feeData = await getFeeData({ + rpcUrl: getEvmRpcUrl(chainCode), + }); + + const legacyBaseGasPrice = feeData.gasPrice || feeData.maxFeePerGas; + if (!legacyBaseGasPrice) { + throw ResParamsError( + `provider returned empty gas price on chain=${chainCode}` + ); + } + + const legacy: LegacyGasPriceGroups = { + slow: { + gasPrice: this.applyBps(legacyBaseGasPrice, SLOW_BPS).toString(), + gasPriceText: this.toGweiText( + this.applyBps(legacyBaseGasPrice, SLOW_BPS) + ), + }, + standard: { + gasPrice: this.applyBps(legacyBaseGasPrice, STANDARD_BPS).toString(), + gasPriceText: this.toGweiText( + this.applyBps(legacyBaseGasPrice, STANDARD_BPS) + ), + }, + fast: { + gasPrice: this.applyBps(legacyBaseGasPrice, FAST_BPS).toString(), + gasPriceText: this.toGweiText( + this.applyBps(legacyBaseGasPrice, FAST_BPS) + ), + }, + }; + + const eip1559 = + feeData.supportsEip1559 && + feeData.maxFeePerGas && + feeData.maxPriorityFeePerGas + ? { + slow: { + maxFeePerGas: this.applyBps( + feeData.maxFeePerGas, + SLOW_BPS + ).toString(), + maxFeePerGasText: this.toGweiText( + this.applyBps(feeData.maxFeePerGas, SLOW_BPS) + ), + maxPriorityFeePerGas: this.applyBps( + feeData.maxPriorityFeePerGas, + SLOW_BPS + ).toString(), + maxPriorityFeePerGasText: this.toGweiText( + this.applyBps(feeData.maxPriorityFeePerGas, SLOW_BPS) + ), + }, + standard: { + maxFeePerGas: this.applyBps( + feeData.maxFeePerGas, + STANDARD_BPS + ).toString(), + maxFeePerGasText: this.toGweiText( + this.applyBps(feeData.maxFeePerGas, STANDARD_BPS) + ), + maxPriorityFeePerGas: this.applyBps( + feeData.maxPriorityFeePerGas, + STANDARD_BPS + ).toString(), + maxPriorityFeePerGasText: this.toGweiText( + this.applyBps(feeData.maxPriorityFeePerGas, STANDARD_BPS) + ), + }, + fast: { + maxFeePerGas: this.applyBps( + feeData.maxFeePerGas, + FAST_BPS + ).toString(), + maxFeePerGasText: this.toGweiText( + this.applyBps(feeData.maxFeePerGas, FAST_BPS) + ), + maxPriorityFeePerGas: this.applyBps( + feeData.maxPriorityFeePerGas, + FAST_BPS + ).toString(), + maxPriorityFeePerGasText: this.toGweiText( + this.applyBps(feeData.maxPriorityFeePerGas, FAST_BPS) + ), + }, + } + : null; + + return { + chainCode, + unit: 'wei', + legacy, + eip1559, + }; + } catch (error) { + throw ResInternalServerError( + `getGasPrice failed on chain=${chainCode}: ${error}` + ); + } + } + + private normalizeChainCode(chainCode?: string): string { + return String(chainCode || '') + .trim() + .toLowerCase(); + } + + private ensureEvmChainCode(chainCode: string) { + if (isEvmChainCode(chainCode)) { + return; + } + throw ResParamsError(`chainCode not supported yet: ${chainCode}`); + } + + private applyBps(value: bigint, bps: number): bigint { + return (value * BigInt(bps)) / BigInt(10000); + } + + private toGweiText(value: bigint): string { + return `${formatUnits(value, 'gwei')} gwei`; + } +} diff --git a/src/app/swap.controller.ts b/src/app/swap.controller.ts new file mode 100644 index 0000000..c0b9d2f --- /dev/null +++ b/src/app/swap.controller.ts @@ -0,0 +1,1160 @@ +import { PassThrough } from 'stream'; + +import { Body, Controller, Get, Inject, Post, Query } from '@midwayjs/core'; +import { Context } from '@midwayjs/koa'; +import { Rule, RuleType } from '@midwayjs/validate'; +import { NetworkService } from '../entity/networks/networks.service'; +import { buildUnsignedTx as buildChainUnsignedTx } from '../chain/indexer'; +import { TokenService } from '../entity/token/token.service'; +import { ProviderManager } from '../thirdparty/swap/provider-manager'; +import { UnsignedTx } from '../chain/indexer'; +import { ResParamsError, ResInternalServerError } from '../base/reply'; +import { + BuildTxResult, + QuoteAcrossProvidersResult, + QuoteRequest, + QuoteResult, + Network, + Token, +} from '../thirdparty/swap/provider.interface'; + +class SwapNetworksQuery { + // 渠道标识(可选);不传时返回所有支持渠道的网络 + @Rule(RuleType.string()) + provider?: string; + + // 是否强制刷新(可选,默认 false);true 时跳过 Redis 缓存读取 + @Rule(RuleType.boolean()) + refresh?: boolean; +} + +interface NetworkGroupItem { + chainCode: string; + chainId?: string; + nativeTokenAddress: string; + chainName?: string; + isActive: boolean; + syncedAt?: Date; +} + +interface NetworksGroupedResponse { + networksByProvider: Record; + meta: { + providerCount: number; + networkCount: number; + }; +} + +// 获取 Token 列表的查询参数 +class SwapTokensQuery { + // 渠道标识(可选);不传时查询该网络下所有渠道 + @Rule(RuleType.string()) + provider?: string; + + // 网络/链编码(必填),例如 eth、bsc、solana + @Rule(RuleType.string().required()) + chainCode: string; + + // 搜索关键词(可选),匹配 symbol/name/address + @Rule(RuleType.string()) + keyword?: string; + + // 页码(可选,默认 1) + @Rule(RuleType.number().integer().min(1)) + page?: number; + + // 每页数量(可选,默认 20,最大 200) + @Rule(RuleType.number().integer().min(1).max(200)) + pageSize?: number; + + // 是否强制刷新(可选,默认 false);true 时直接查 provider + @Rule(RuleType.boolean()) + refresh?: boolean; +} + +// 获取 Token 列表接口返回结构 +interface TokenListItem { + chainCode: string; + address: string; + symbol: string; + name?: string; + decimals?: number; + logoURI?: string; + isActive: boolean; + providers: string[]; + raw?: Record; + syncedAt?: Date; +} + +interface SwapTokensResponse { + list: TokenListItem[]; + pagination: { + page: number; + pageSize: number; + total: number; + }; +} + +// 多渠道询价请求参数(providers 为空时由服务端自动决策) +class SwapQuoteBaseRequest implements QuoteRequest { + // 网络/链编码(必填) + @Rule(RuleType.string().required()) + chainCode: string; + + // 输入代币地址(必填) + @Rule(RuleType.string().required()) + inTokenAddress: string; + + // 输出代币地址(必填) + @Rule(RuleType.string().required()) + outTokenAddress: string; + + // 输入数量(最小单位,必填) + @Rule(RuleType.string().required()) + amountDecimals: string; + + // 滑点(可选) + @Rule(RuleType.number().min(0)) + slippage?: number; + + // 用户钱包地址(可选) + @Rule(RuleType.string()) + account?: string; + + // gas price(最小单位,必填) + @Rule(RuleType.string().required()) + gasPriceDecimals: string; + + // 允许使用的 DEX ID 列表(可选,逗号分隔) + @Rule(RuleType.string()) + enabledDexIds?: string; + + // 禁止使用的 DEX ID 列表(可选,逗号分隔) + @Rule(RuleType.string()) + disabledDexIds?: string; +} + +class SwapQuotesRequest extends SwapQuoteBaseRequest { + // 渠道列表(必填,至少 1 个) + @Rule(RuleType.array().items(RuleType.string().required()).min(1).required()) + providers: string[]; +} + +// 多渠道询价返回结构(包含最佳报价与失败渠道) +interface SwapQuotesResponse extends QuoteAcrossProvidersResult { + bestQuote?: QuoteResult; + deadlineMs?: number; +} + +// 单渠道询价查询参数 +class SwapQuoteQuery extends SwapQuoteBaseRequest { + // 渠道标识(必填) + @Rule(RuleType.string().required()) + provider: string; +} + +// 构建交易请求参数 +class SwapBuildTxRequest { + // 网络/链编码(必填) + @Rule(RuleType.string().required()) + chainCode: string; + + // 输入代币地址(必填) + @Rule(RuleType.string().required()) + inTokenAddress: string; + + // 输出代币地址(必填) + @Rule(RuleType.string().required()) + outTokenAddress: string; + + // 输入数量(最小单位,必填) + @Rule(RuleType.string().required()) + amountDecimals: string; + + // gas price(最小单位,必填) + @Rule(RuleType.string().required()) + gasPriceDecimals: string; + + // 用户钱包地址(必填) + @Rule(RuleType.string().required()) + account: string; + + // 滑点(可选) + @Rule(RuleType.number().min(0)) + slippage?: number; + + // 允许使用的 DEX ID 列表(可选,逗号分隔) + @Rule(RuleType.string()) + enabledDexIds?: string; + + // 禁止使用的 DEX ID 列表(可选,逗号分隔) + @Rule(RuleType.string()) + disabledDexIds?: string; + + // 渠道标识(可选,不传时使用默认渠道) + @Rule(RuleType.string()) + provider?: string; + + // 接收地址(可选) + @Rule(RuleType.string()) + receiver?: string; + + // 返佣地址(可选) + @Rule(RuleType.string()) + referrer?: string; +} + +// 构建交易的完整响应 +interface SwapBuildTxResponse extends BuildTxResult { + /** + * 链级别的未签名交易对象,由 buildChainUnsignedTx 生成, + * 包含 nonce、gasLimit 等链上参数,客户端可直接用于签名。 + */ + unsignedTx: UnsignedTx; +} +class SwapQuotesPollQuery extends SwapQuoteBaseRequest { + // 渠道列表(至少 1 个) + @Rule(RuleType.array().items(RuleType.string().required()).min(1).required()) + providers: string[]; + + // 轮询间隔(秒,范围 3-60,默认 5) + @Rule(RuleType.number().integer().min(3).max(60)) + interval?: number; +} + +const POLL_INTERVAL_DEFAULT_S = 5; +const POLL_INTERVAL_MIN_S = 3; +const POLL_INTERVAL_MAX_S = 60; + +const SUPPORTED_PROVIDERS: string[] = ['openocean']; +const DEFAULT_PROVIDER = SUPPORTED_PROVIDERS[0] || 'openocean'; +const NETWORK_CACHE_LIMIT = 500; +const NETWORK_CACHE_TTL_MS = 6 * 60 * 60 * 1000; +const TOKEN_CACHE_TTL_MS = 6 * 60 * 60 * 1000; +const DEFAULT_TOKENS_PAGE = 1; +const DEFAULT_TOKENS_PAGE_SIZE = 20; +const MAX_TOKENS_PAGE_SIZE = 200; + +@Controller('/swap') +export class SwapController { + @Inject() + providerManager: ProviderManager; + + @Inject() + networkService: NetworkService; + + @Inject() + tokenService: TokenService; + + @Inject() + ctx: Context; + + // 获取支持网络列表 + @Get('/networks') + async getNetworks( + @Query() query: SwapNetworksQuery + ): Promise { + const provider = String(query.provider || '') + .trim() + .toLowerCase(); + const refresh = this.resolveRefresh(query.refresh); + if (provider) { + this.ensureSupportedProvider(provider); + } + + if (!refresh) { + const cachedNetworks = await this.networkService.getNetworksCache( + provider || undefined + ); + if (cachedNetworks && cachedNetworks.length > 0) { + return this.buildGroupedNetworksResponse(cachedNetworks, provider); + } + } + + let networks: Network[] = []; + if (provider) { + networks = await this.getNetworksByProvider(provider); + await this.networkService.setNetworksCache(provider, networks); + return this.buildGroupedNetworksResponse(networks, provider); + } + + const allProviders = this.resolveSupportedProviders(); + const networkGroups = await Promise.all( + allProviders.map(item => this.getNetworksByProvider(item)) + ); + networks = networkGroups.flat(); + await this.networkService.setNetworksCache(undefined, networks); + return this.buildGroupedNetworksResponse(networks); + } + + // 获取指定链的 Token 列表 + @Get('/tokens') + async getTokens( + @Query() query: SwapTokensQuery + ): Promise { + const chainCode = this.normalizeChainCode(query.chainCode); + const provider = String(query.provider || '') + .trim() + .toLowerCase(); + const keyword = this.normalizeKeyword(query.keyword); + const page = this.resolvePage(query.page); + const pageSize = this.resolvePageSize(query.pageSize); + const refresh = this.resolveRefresh(query.refresh); + + if (provider) { + this.ensureSupportedProvider(provider); + } + + if (refresh) { + const fetchedTokens = await this.fetchTokensDirectly(provider, chainCode); + const filteredTokens = this.filterTokensByKeyword(fetchedTokens, keyword); + const groupedTokens = this.groupTokensByAddress(filteredTokens); + const { list, total } = this.paginateItems(groupedTokens, page, pageSize); + return { + list, + pagination: { + page, + pageSize, + total, + }, + }; + } + + await this.ensureTokenCache(provider, chainCode); + + const tokenQuery = { + chainCode, + provider: provider || undefined, + keyword, + isActive: true, + }; + const tokenPage = await this.tokenService.findTokensByPage(tokenQuery, { + page, + pageSize, + }); + console.log('tokenPage', JSON.stringify(tokenPage, null, 2)); + const groupedTokens = this.groupTokensByAddress( + (tokenPage.result || []).map(token => this.mapTokenEntity(token)) + ); + if (!provider && groupedTokens.length > 0) { + const allProviderRecords = + await this.tokenService.findTokensByChainAndAddresses( + chainCode, + groupedTokens.map(token => token.address), + true + ); + this.applyProviderCoverage( + groupedTokens, + (allProviderRecords || []).map(item => this.mapTokenEntity(item)) + ); + } + + return { + list: groupedTokens, + pagination: { + page, + pageSize, + total: tokenPage.total || 0, + }, + }; + } + + // 单渠道询价 + @Get('/quote') + async quote(@Query() query: SwapQuoteQuery): Promise { + const provider = String(query.provider || '') + .trim() + .toLowerCase(); + if (!provider) { + throw ResParamsError('provider is required'); + } + this.ensureSupportedProvider(provider); + this.ensureRequiredQuoteFields( + query.amountDecimals, + query.gasPriceDecimals + ); + + return this.providerManager.quote(provider, { + chainCode: this.normalizeChainCode(query.chainCode), + inTokenAddress: query.inTokenAddress, + outTokenAddress: query.outTokenAddress, + amountDecimals: query.amountDecimals, + slippage: query.slippage, + account: query.account, + gasPriceDecimals: query.gasPriceDecimals, + enabledDexIds: query.enabledDexIds, + disabledDexIds: query.disabledDexIds, + }); + } + + // 多渠道并发询价 + @Post('/quotes') + async quoteAcrossProviders( + @Body() body: SwapQuotesRequest + ): Promise { + const providers = this.normalizeProviders(body.providers); + if (providers.length <= 0) { + throw ResParamsError('providers is required'); + } + for (const provider of providers) { + this.ensureSupportedProvider(provider); + } + this.ensureRequiredQuoteFields(body.amountDecimals, body.gasPriceDecimals); + + const result = await this.providerManager.quoteAcrossProviders(providers, { + chainCode: this.normalizeChainCode(body.chainCode), + inTokenAddress: body.inTokenAddress, + outTokenAddress: body.outTokenAddress, + amountDecimals: body.amountDecimals, + slippage: body.slippage, + account: body.account, + gasPriceDecimals: body.gasPriceDecimals, + enabledDexIds: body.enabledDexIds, + disabledDexIds: body.disabledDexIds, + }); + + return { + ...result, + bestQuote: this.pickBestQuote(result.quotes), + }; + } + + // 多渠道轮询询价(SSE) + // 连接后立即执行首轮,之后每隔 interval 秒重新询价,每个渠道结果到达时立刻推送 + // 客户端断开连接时自动停止轮询 + @Post('/quotes/poll') + async quotePoll(@Body() body: SwapQuotesPollQuery): Promise { + const providers = this.normalizeProviders(body.providers); + if (providers.length <= 0) { + throw ResParamsError('providers is required'); + } + for (const provider of providers) { + this.ensureSupportedProvider(provider); + } + this.ensureRequiredQuoteFields(body.amountDecimals, body.gasPriceDecimals); + + const intervalMs = + Math.max( + POLL_INTERVAL_MIN_S, + Math.min(POLL_INTERVAL_MAX_S, Number(body.interval || POLL_INTERVAL_DEFAULT_S)) + ) * 1000; + + const request: QuoteRequest = { + chainCode: this.normalizeChainCode(body.chainCode), + inTokenAddress: body.inTokenAddress, + outTokenAddress: body.outTokenAddress, + amountDecimals: body.amountDecimals, + slippage: body.slippage, + account: body.account, + gasPriceDecimals: body.gasPriceDecimals, + enabledDexIds: body.enabledDexIds, + disabledDexIds: body.disabledDexIds, + }; + + const ctx = this.ctx; + ctx.set('Content-Type', 'text/event-stream'); + ctx.set('Cache-Control', 'no-cache'); + ctx.set('Connection', 'keep-alive'); + ctx.status = 200; + + const stream = new PassThrough(); + ctx.body = stream; + + const write = (event: string, data: unknown) => { + if (!stream.destroyed) { + stream.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + } + }; + + let round = 0; + let isPolling = false; + + const runRound = async () => { + if (isPolling || stream.destroyed) return; + isPolling = true; + round += 1; + const currentRound = round; + const collectedQuotes: QuoteResult[] = []; + + await Promise.all( + providers.map(async provider => { + try { + const quote = await this.providerManager.quote(provider, request); + collectedQuotes.push(quote); + write('quote', { round: currentRound, provider, status: 'success', quote, ts: Date.now() }); + } catch (err) { + const providerErr = err as { code?: string; message?: string }; + write('quote', { + round: currentRound, + provider, + status: 'error', + error: { code: providerErr.code ?? 'UNKNOWN', message: String(providerErr.message ?? err) }, + ts: Date.now(), + }); + } + }) + ); + + write('round_done', { + round: currentRound, + bestQuote: this.pickBestQuote(collectedQuotes), + successCount: collectedQuotes.length, + failCount: providers.length - collectedQuotes.length, + ts: Date.now(), + }); + + isPolling = false; + }; + + // 立即执行第一轮 + runRound(); + const timer = setInterval(runRound, intervalMs); + + ctx.req.on('close', () => { + clearInterval(timer); + stream.destroy(); + }); + } + + // 构建兑换交易(不广播) + @Post('/tx/build') + async buildTx( + @Body() body: SwapBuildTxRequest + ): Promise { + const provider = this.normalizeProvider(body.provider); + this.ensureSupportedProvider(provider); + const chainCode = this.normalizeChainCode(body.chainCode); + this.ensureRequiredBuildTxFields( + body.amountDecimals, + body.gasPriceDecimals, + body.account + ); + + const providerResult = await this.providerManager.buildTx(provider, { + chainCode, + inTokenAddress: body.inTokenAddress, + outTokenAddress: body.outTokenAddress, + amountDecimals: body.amountDecimals, + slippage: body.slippage, + account: body.account, + gasPriceDecimals: body.gasPriceDecimals, + enabledDexIds: body.enabledDexIds, + disabledDexIds: body.disabledDexIds, + receiver: body.receiver, + referrer: body.referrer, + }); + + let unsignedTx: UnsignedTx; + try { + unsignedTx = await buildChainUnsignedTx( + chainCode, + providerResult.tx.to, + body.account, + providerResult.tx.data || '', + providerResult.tx.value || '0', + 12000 + ); + } catch (error) { + const reason = this.extractBuildTxErrorMessage(error); + throw ResInternalServerError(`build unsigned tx failed: ${reason}`); + } + + return { + ...providerResult, + unsignedTx: unsignedTx, + }; + } + + private normalizeProvider(provider?: string): string { + return String(provider || DEFAULT_PROVIDER) + .trim() + .toLowerCase(); + } + + private normalizeChainCode(chainCode?: string): string { + return String(chainCode || '') + .trim() + .toLowerCase(); + } + + private normalizeKeyword(keyword?: string): string | undefined { + const value = String(keyword || '').trim(); + return value || undefined; + } + + private resolveRefresh(refresh?: unknown): boolean { + if (typeof refresh === 'boolean') { + return refresh; + } + const normalized = String(refresh || '') + .trim() + .toLowerCase(); + return normalized === 'true' || normalized === '1'; + } + + private normalizeProviders(providers?: string[]): string[] { + const list = Array.isArray(providers) ? providers : []; + const normalized = list + .map(provider => + String(provider || '') + .trim() + .toLowerCase() + ) + .filter(Boolean); + return Array.from(new Set(normalized)); + } + + private resolvePage(page?: number): number { + const pageNum = Number(page); + if (Number.isFinite(pageNum) && pageNum >= 1) { + return Math.floor(pageNum); + } + return DEFAULT_TOKENS_PAGE; + } + + private resolvePageSize(pageSize?: number): number { + const pageSizeNum = Number(pageSize); + if (!Number.isFinite(pageSizeNum) || pageSizeNum < 1) { + return DEFAULT_TOKENS_PAGE_SIZE; + } + return Math.min(Math.floor(pageSizeNum), MAX_TOKENS_PAGE_SIZE); + } + + private ensureSupportedProvider(provider: string) { + const supported = this.resolveSupportedProviders(); + if (supported.includes(provider)) { + return; + } + + throw ResParamsError(`unsupported provider: ${provider}`); + } + + private extractBuildTxErrorMessage(error: unknown): string { + const maybeError = error as { + reason?: unknown; + shortMessage?: unknown; + message?: unknown; + }; + const reason = String(maybeError?.reason || '').trim(); + if (reason) { + return reason; + } + const shortMessage = String(maybeError?.shortMessage || '').trim(); + if (shortMessage) { + return shortMessage; + } + const message = String(maybeError?.message || '').trim(); + if (message) { + return message; + } + return 'unknown error'; + } + + private ensureRequiredQuoteFields( + amountDecimals?: string, + gasPriceDecimals?: string + ): void { + const normalizedAmountDecimals = String(amountDecimals || '').trim(); + const normalizedGasPriceDecimals = String(gasPriceDecimals || '').trim(); + if (!normalizedAmountDecimals || !normalizedGasPriceDecimals) { + throw ResParamsError('amountDecimals and gasPriceDecimals are required'); + } + } + + private ensureRequiredBuildTxFields( + amountDecimals?: string, + gasPriceDecimals?: string, + account?: string + ): void { + const normalizedAmountDecimals = String(amountDecimals || '').trim(); + const normalizedGasPriceDecimals = String(gasPriceDecimals || '').trim(); + const normalizedAccount = String(account || '').trim(); + if ( + !normalizedAmountDecimals || + !normalizedGasPriceDecimals || + !normalizedAccount + ) { + throw ResParamsError( + 'amountDecimals, gasPriceDecimals and account are required' + ); + } + } + + private resolveSupportedProviders(): string[] { + const dynamicSupportedProviders = + this.providerManager?.getSupportedProviders?.() || []; + const mergedProviders = Array.from( + new Set( + [...SUPPORTED_PROVIDERS, ...dynamicSupportedProviders] + .map(provider => this.normalizeProvider(provider)) + .filter(Boolean) + ) + ); + return mergedProviders.length > 0 ? mergedProviders : [DEFAULT_PROVIDER]; + } + + private isNetworkCacheExpired(networks: Network[]): boolean { + let latestSyncedAt = 0; + for (const network of networks) { + const syncedAtMs = new Date(network.syncedAt || 0).getTime(); + if (Number.isFinite(syncedAtMs) && syncedAtMs > latestSyncedAt) { + latestSyncedAt = syncedAtMs; + } + } + + if (!latestSyncedAt) { + return true; + } + return Date.now() - latestSyncedAt > NETWORK_CACHE_TTL_MS; + } + + private async syncNetworkCache(provider: string, networks: Network[]) { + const now = new Date(); + const pendingCreates: Array<{ + provider: string; + chainCode: string; + chainName: string; + chainId: string; + nativeTokenAddress: string; + isActive: boolean; + metadata?: Record; + syncedAt: Date; + }> = []; + + for (const network of networks || []) { + const chainCode = String(network.chainCode || '') + .trim() + .toLowerCase(); + if (!chainCode) { + continue; + } + + const upsertPayload = { + chainName: String(network.chainName || chainCode), + chainId: String(network.chainId || ''), + nativeTokenAddress: String(network.nativeTokenAddress || ''), + isActive: network.isActive !== false, + syncedAt: network.syncedAt || now, + }; + + const existingNetwork = await this.networkService.getNetworkDetail( + provider, + chainCode + ); + if (existingNetwork) { + await this.networkService.updateNetwork( + provider, + chainCode, + upsertPayload + ); + continue; + } + + pendingCreates.push({ + provider, + chainCode, + ...upsertPayload, + }); + } + + if (pendingCreates.length > 0) { + await this.networkService.createNetworksBatch(pendingCreates); + } + } + + private isDuplicateKeyError(error: unknown): boolean { + const code = Number((error as { code?: unknown } | undefined)?.code); + return code === 11000; + } + + private pickBestQuote(quotes: QuoteResult[]): QuoteResult | undefined { + let bestQuote: QuoteResult | undefined; + for (const quote of quotes || []) { + if (!bestQuote) { + bestQuote = quote; + continue; + } + if (this.compareAmount(quote.outAmount, bestQuote.outAmount) > 0) { + bestQuote = quote; + } + } + return bestQuote; + } + + private compareAmount(left: string, right: string): number { + try { + const leftAmount = BigInt(String(left || '0')); + const rightAmount = BigInt(String(right || '0')); + if (leftAmount > rightAmount) { + return 1; + } + if (leftAmount < rightAmount) { + return -1; + } + return 0; + } catch { + const leftNumber = Number(left); + const rightNumber = Number(right); + if (leftNumber > rightNumber) { + return 1; + } + if (leftNumber < rightNumber) { + return -1; + } + return 0; + } + } + + private async fetchTokensDirectly( + provider: string, + chainCode: string + ): Promise { + if (provider) { + const tokens = await this.providerManager.getTokenList( + provider, + chainCode + ); + return tokens || []; + } + + const providers = this.resolveSupportedProviders(); + const tokenGroups = await Promise.all( + providers.map(item => this.providerManager.getTokenList(item, chainCode)) + ); + return tokenGroups.flat().filter(Boolean); + } + + private filterTokensByKeyword(tokens: Token[], keyword?: string): Token[] { + if (!keyword) { + return tokens || []; + } + const normalizedKeyword = keyword.toLowerCase(); + return (tokens || []).filter(token => { + const symbol = String(token.symbol || '').toLowerCase(); + const name = String(token.name || '').toLowerCase(); + const address = String(token.address || '').toLowerCase(); + return ( + symbol.includes(normalizedKeyword) || + name.includes(normalizedKeyword) || + address.includes(normalizedKeyword) + ); + }); + } + + private paginateItems( + items: T[], + page: number, + pageSize: number + ): { list: T[]; total: number } { + const total = (items || []).length; + const start = (page - 1) * pageSize; + const end = start + pageSize; + return { + list: (items || []).slice(start, end), + total, + }; + } + + private groupTokensByAddress(tokens: Token[]): TokenListItem[] { + const grouped = new Map(); + const providerSets = new Map>(); + + for (const token of tokens || []) { + const chainCode = this.normalizeChainCode(token.chainCode); + const address = String(token.address || '') + .trim() + .toLowerCase(); + if (!chainCode || !address) { + continue; + } + + const key = `${chainCode}:${address}`; + const provider = this.normalizeProvider(token.provider); + const syncedAtTime = new Date(token.syncedAt || 0).getTime(); + const current = grouped.get(key); + + if (!current) { + grouped.set(key, { + chainCode, + address, + symbol: String(token.symbol || '').trim(), + name: token.name, + decimals: token.decimals, + logoURI: token.logoURI, + isActive: token.isActive !== false, + providers: [], + raw: this.normalizeMetadata(token.raw), + syncedAt: token.syncedAt, + }); + } else { + const currentSyncedAtTime = new Date(current.syncedAt || 0).getTime(); + if ( + Number.isFinite(syncedAtTime) && + syncedAtTime > 0 && + (!Number.isFinite(currentSyncedAtTime) || + syncedAtTime > currentSyncedAtTime) + ) { + current.syncedAt = token.syncedAt; + } + current.isActive = current.isActive || token.isActive !== false; + } + + if (!providerSets.has(key)) { + providerSets.set(key, new Set()); + } + if (provider) { + providerSets.get(key)!.add(provider); + } + } + + for (const [key, providers] of providerSets.entries()) { + const item = grouped.get(key); + if (!item) { + continue; + } + item.providers = Array.from(providers).sort(); + } + + return Array.from(grouped.values()); + } + + private applyProviderCoverage( + groupedTokens: TokenListItem[], + allProviderRecords: Token[] + ): void { + if (!groupedTokens.length || !allProviderRecords.length) { + return; + } + + const coverageMap = new Map(); + const groupedCoverage = this.groupTokensByAddress(allProviderRecords); + for (const token of groupedCoverage) { + coverageMap.set(`${token.chainCode}:${token.address}`, token.providers); + } + + for (const item of groupedTokens) { + const key = `${this.normalizeChainCode(item.chainCode)}:${String( + item.address || '' + ) + .trim() + .toLowerCase()}`; + const providers = coverageMap.get(key); + if (providers && providers.length > 0) { + item.providers = providers; + } + } + } + + private async ensureTokenCache(provider: string, chainCode: string) { + if (provider) { + const needSync = await this.shouldSyncTokenCache(provider, chainCode); + if (needSync) { + await this.syncTokenCache(provider, chainCode); + } + return; + } + + const providers = this.resolveSupportedProviders(); + await Promise.all( + providers.map(async item => { + const needSync = await this.shouldSyncTokenCache(item, chainCode); + if (needSync) { + await this.syncTokenCache(item, chainCode); + } + }) + ); + } + + private async shouldSyncTokenCache( + provider: string, + chainCode: string + ): Promise { + const tokenCount = await this.tokenService.countTokens({ + provider, + chainCode, + isActive: true, + }); + if (tokenCount <= 0) { + return true; + } + + const latestSyncedAt = await this.tokenService.getLatestSyncedAt({ + provider, + chainCode, + isActive: true, + }); + if (!latestSyncedAt) { + return true; + } + + const latestSyncedAtMs = new Date(latestSyncedAt).getTime(); + if (!Number.isFinite(latestSyncedAtMs) || latestSyncedAtMs <= 0) { + return true; + } + + return Date.now() - latestSyncedAtMs > TOKEN_CACHE_TTL_MS; + } + + private async syncTokenCache(provider: string, chainCode: string) { + const fetchedTokens = await this.providerManager.getTokenList( + provider, + chainCode + ); + const now = new Date(); + + for (const token of fetchedTokens || []) { + const address = String(token.address || '') + .trim() + .toLowerCase(); + if (!address) { + continue; + } + + const upsertPayload = { + symbol: String(token.symbol || '').trim(), + name: String(token.name || '').trim() || undefined, + decimals: token.decimals, + logoURI: String(token.logoURI || '').trim() || undefined, + isActive: token.isActive !== false, + raw: this.normalizeMetadata(token.raw), + syncedAt: token.syncedAt || now, + }; + if (!upsertPayload.symbol) { + continue; + } + + const existingToken = await this.tokenService.getTokenDetail( + provider, + chainCode, + address + ); + if (existingToken) { + await this.tokenService.updateToken( + provider, + chainCode, + address, + upsertPayload + ); + continue; + } + + try { + await this.tokenService.createToken({ + provider, + chainCode, + address, + ...upsertPayload, + }); + } catch (error) { + if (!this.isDuplicateKeyError(error)) { + throw error; + } + await this.tokenService.updateToken( + provider, + chainCode, + address, + upsertPayload + ); + } + } + } + + private async getNetworksByProvider(provider: string): Promise { + const cached = await this.networkService.findNetworks( + { + provider, + isActive: true, + }, + { + limit: NETWORK_CACHE_LIMIT, + } + ); + const cachedNetworks = (cached.result || []).map(network => + this.mapNetworkEntity(network) + ); + if ( + cachedNetworks.length > 0 && + !this.isNetworkCacheExpired(cachedNetworks) + ) { + return cachedNetworks; + } + + const fetchedNetworks = await this.providerManager.getNetworks(provider); + await this.syncNetworkCache(provider, fetchedNetworks); + return fetchedNetworks; + } + + private normalizeMetadata(raw?: Record) { + if (!raw || typeof raw !== 'object') { + return undefined; + } + return raw; + } + + private mapNetworkEntity(network: Network): Network { + return { + provider: String(network.provider || DEFAULT_PROVIDER), + chainCode: String(network.chainCode || ''), + chainId: String(network.chainId || ''), + nativeTokenAddress: String(network.nativeTokenAddress || ''), + chainName: String(network.chainName || ''), + isActive: Boolean(network.isActive), + syncedAt: network.syncedAt, + }; + } + + private buildGroupedNetworksResponse( + networks: Network[], + provider?: string + ): NetworksGroupedResponse { + const grouped: Record = {}; + for (const network of networks || []) { + const normalizedProvider = this.normalizeProvider(network.provider); + if (!grouped[normalizedProvider]) { + grouped[normalizedProvider] = []; + } + grouped[normalizedProvider].push({ + chainCode: String(network.chainCode || ''), + chainId: String(network.chainId || ''), + nativeTokenAddress: String(network.nativeTokenAddress || ''), + chainName: String(network.chainName || ''), + isActive: network.isActive !== false, + syncedAt: network.syncedAt, + }); + } + + const targetProvider = this.normalizeProvider(provider || ''); + if (provider && !grouped[targetProvider]) { + grouped[targetProvider] = []; + } + + const providerCount = Object.keys(grouped).length; + const networkCount = Object.values(grouped).reduce( + (count, list) => count + list.length, + 0 + ); + + return { + networksByProvider: grouped, + meta: { + providerCount, + networkCount, + }, + }; + } + + private mapTokenEntity(token: Token): Token { + return { + provider: String(token.provider || DEFAULT_PROVIDER), + chainCode: String(token.chainCode || ''), + address: String(token.address || ''), + symbol: String(token.symbol || ''), + name: token.name, + decimals: token.decimals, + logoURI: token.logoURI, + isActive: Boolean(token.isActive), + raw: this.normalizeMetadata(token.raw), + syncedAt: token.syncedAt, + }; + } +} diff --git a/src/base/reply.ts b/src/base/reply.ts new file mode 100644 index 0000000..7aa370f --- /dev/null +++ b/src/base/reply.ts @@ -0,0 +1,12 @@ +// 定义统一的响应格式 +import { MidwayHttpError } from '@midwayjs/core'; + +// reply error +export function ResParamsError(message: string): MidwayHttpError { + return new MidwayHttpError(message, 404); +} + +// internal server error +export function ResInternalServerError(message: string): MidwayHttpError { + return new MidwayHttpError(message, 500); +} diff --git a/src/cache/redis.ts b/src/cache/redis.ts new file mode 100644 index 0000000..4a3cf7b --- /dev/null +++ b/src/cache/redis.ts @@ -0,0 +1,235 @@ +import { createHash } from 'crypto'; + +import { Inject, Provide } from '@midwayjs/core'; +import { RedisService } from '@midwayjs/redis'; + +import { Network } from '../thirdparty/swap/provider.interface'; + +const CACHE_KEY_NS = 'swap'; + +function normalizeKeySegment(value: string | undefined): string { + return String(value || '') + .trim() + .toLowerCase(); +} + +function encodeKeySegment(value: string | undefined): string { + const normalized = normalizeKeySegment(value); + return encodeURIComponent(normalized || 'all'); +} + +function hashKeySegment(value: string | undefined): string { + const normalized = normalizeKeySegment(value); + if (!normalized) { + return 'none'; + } + return createHash('sha1').update(normalized).digest('hex'); +} + +export interface SwapTokenQueryCacheKeyParams { + chainCode: string; + provider?: string; + keyword?: string; + isActive?: boolean; + page: number; + pageSize: number; +} + +export interface SwapTokenQueryCachePayload { + ids: string[]; + total: number; + updatedAt: number; +} + +export const SwapRedisKey = { + networks(provider?: string) { + return `${CACHE_KEY_NS}:networks:${encodeKeySegment(provider)}`; + }, + tokenObject(id: string) { + return `${CACHE_KEY_NS}:token:${encodeURIComponent( + String(id || '').trim() + )}`; + }, + tokenQuery(params: SwapTokenQueryCacheKeyParams) { + const isActiveSegment = + typeof params.isActive === 'boolean' ? String(params.isActive) : 'all'; + const page = Number.isFinite(Number(params.page)) ? Number(params.page) : 1; + const pageSize = Number.isFinite(Number(params.pageSize)) + ? Number(params.pageSize) + : 20; + return `${CACHE_KEY_NS}:token:query:${encodeKeySegment( + params.chainCode + )}:${encodeKeySegment(params.provider)}:${encodeKeySegment( + isActiveSegment + )}:${page}:${pageSize}:${hashKeySegment(params.keyword)}`; + }, + tokenQueryPrefix(chainCode: string, provider?: string) { + return `${CACHE_KEY_NS}:token:query:${encodeKeySegment( + chainCode + )}:${encodeKeySegment(provider)}:`; + }, +}; + +export interface CacheEnvelope { + data: T; + updatedAt: number; +} + +@Provide() +export class RedisCacheClient { + @Inject() + redisService: RedisService; + + async setJSON(key: string, value: T, ttlSeconds?: number): Promise { + const payload = JSON.stringify(value); + if (ttlSeconds && ttlSeconds > 0) { + await this.redisService.set(key, payload, 'EX', ttlSeconds); + return; + } + await this.redisService.set(key, payload); + } + + async getJSON(key: string): Promise { + const payload = await this.redisService.get(key); + if (!payload) { + return null; + } + + try { + return JSON.parse(payload) as T; + } catch { + return null; + } + } + + async del(key: string): Promise { + return this.redisService.del(key); + } + + async setSwapNetworks( + provider: string | undefined, + networks: Network[], + ttlSeconds: number + ) { + const key = SwapRedisKey.networks(provider); + const payload: CacheEnvelope = { + data: networks, + updatedAt: Date.now(), + }; + await this.setJSON(key, payload, ttlSeconds); + return key; + } + + async getSwapNetworks( + provider?: string + ): Promise | null> { + return this.getJSON>( + SwapRedisKey.networks(provider) + ); + } + + async delSwapNetworks(provider?: string): Promise { + return this.del(SwapRedisKey.networks(provider)); + } + + async setSwapTokenObject(id: string, token: T, ttlSeconds: number) { + const key = SwapRedisKey.tokenObject(id); + await this.setJSON(key, token, ttlSeconds); + return key; + } + + async getSwapTokenObject(id: string): Promise { + return this.getJSON(SwapRedisKey.tokenObject(id)); + } + + async mgetSwapTokenObjects(ids: string[]): Promise> { + const map = new Map(); + const normalizedIDs = (ids || []) + .map(id => String(id || '').trim()) + .filter(Boolean); + if (normalizedIDs.length <= 0) { + return map; + } + + const keys = normalizedIDs.map(id => SwapRedisKey.tokenObject(id)); + const values = await (this.redisService as any).mget(...keys); + if (!Array.isArray(values)) { + return map; + } + + for (let i = 0; i < normalizedIDs.length; i += 1) { + const payload = values[i]; + if (!payload) { + continue; + } + try { + map.set(normalizedIDs[i], JSON.parse(payload) as T); + } catch { + continue; + } + } + + return map; + } + + async delSwapTokenObject(id: string): Promise { + return this.del(SwapRedisKey.tokenObject(id)); + } + + async setSwapTokenQueryResult( + params: SwapTokenQueryCacheKeyParams, + payload: { ids: string[]; total: number }, + ttlSeconds: number + ) { + const key = SwapRedisKey.tokenQuery(params); + const value: SwapTokenQueryCachePayload = { + ids: payload.ids || [], + total: Number(payload.total) || 0, + updatedAt: Date.now(), + }; + await this.setJSON(key, value, ttlSeconds); + return key; + } + + async getSwapTokenQueryResult( + params: SwapTokenQueryCacheKeyParams + ): Promise { + return this.getJSON( + SwapRedisKey.tokenQuery(params) + ); + } + + async delSwapTokenQueries( + chainCode: string, + provider?: string + ): Promise { + const pattern = `${SwapRedisKey.tokenQueryPrefix(chainCode, provider)}*`; + return this.delByPattern(pattern); + } + + private async delByPattern(pattern: string): Promise { + let cursor = '0'; + let deleted = 0; + + do { + const reply = await (this.redisService as any).scan( + cursor, + 'MATCH', + pattern, + 'COUNT', + 200 + ); + if (!Array.isArray(reply) || reply.length < 2) { + break; + } + + cursor = String(reply[0] || '0'); + const keys = Array.isArray(reply[1]) ? reply[1] : []; + if (keys.length > 0) { + deleted += await (this.redisService as any).del(...keys); + } + } while (cursor !== '0'); + + return deleted; + } +} diff --git a/src/chain/evm.ts b/src/chain/evm.ts new file mode 100644 index 0000000..56ca3b3 --- /dev/null +++ b/src/chain/evm.ts @@ -0,0 +1,233 @@ +import { + getAddress, + isHexString, + JsonRpcProvider, + toQuantity, + Transaction, + type Provider, +} from 'ethers'; + +// ═══════════════════════════════════════════════════════════════════ +// Types +// ═══════════════════════════════════════════════════════════════════ + +type ProviderFactory = (rpcUrl: string) => Provider; + +interface WithProvider { + rpcUrl: string; + providerFactory?: ProviderFactory; +} + +export interface EstimateGasInput extends WithProvider { + to: string; + from: string; + data?: string; + value?: string; + /** 10_000 = 100% (no buffer), 12_000 = 120%, etc. */ + bufferBps?: number; +} + +export interface FeeDataResult { + gasPrice: bigint | null; + maxFeePerGas: bigint | null; + maxPriorityFeePerGas: bigint | null; + supportsEip1559: boolean; +} + +export interface GetNonceInput extends WithProvider { + address: string; + blockTag?: 'latest' | 'pending'; +} + +export interface BuildUnsignedTxInput extends WithProvider { + to: string; + from: string; + data?: string; + /** Wei, decimal or hex string. Defaults to "0". */ + value?: string; + /** Gas-limit multiplier in basis points. 12_000 = 120%. */ + gasLimitBufferBps?: number; +} + +export interface EvmUnsignedTx { + /** 0 = legacy, 2 = EIP-1559 */ + type: number; + to: string; + from: string; + data: string; + /** Hex quantity (wei) */ + value: string; + nonce: number; + chainId: number; + /** Hex quantity */ + gasLimit: string; + /** Hex quantity — only on legacy (type 0) */ + gasPrice?: string; + /** Hex quantity — only on EIP-1559 (type 2) */ + maxFeePerGas?: string; + /** Hex quantity — only on EIP-1559 (type 2) */ + maxPriorityFeePerGas?: string; + /** RLP-encoded unsigned tx, ready to be signed */ + serialized: string; +} + +// ═══════════════════════════════════════════════════════════════════ +// 1. estimateGasLimit +// ═══════════════════════════════════════════════════════════════════ + +export async function estimateGasLimit( + input: EstimateGasInput +): Promise { + const provider = getProvider(input); + const estimated = await provider.estimateGas({ + to: checksumAddress(input.to, 'to'), + from: checksumAddress(input.from, 'from'), + data: hexData(input.data), + value: weiValue(input.value), + }); + return bpsBuffer(estimated, input.bufferBps); +} + +// ═══════════════════════════════════════════════════════════════════ +// 2. getFeeData +// ═══════════════════════════════════════════════════════════════════ + +export async function getFeeData(input: WithProvider): Promise { + const provider = getProvider(input); + const fd = await provider.getFeeData(); + return { + gasPrice: fd.gasPrice, + maxFeePerGas: fd.maxFeePerGas, + maxPriorityFeePerGas: fd.maxPriorityFeePerGas, + supportsEip1559: fd.maxFeePerGas != null, + }; +} + +// ═══════════════════════════════════════════════════════════════════ +// 3. getNonce +// ═══════════════════════════════════════════════════════════════════ + +export async function getNonce(input: GetNonceInput): Promise { + const provider = getProvider(input); + const addr = checksumAddress(input.address, 'address'); + return provider.getTransactionCount(addr, input.blockTag ?? 'pending'); +} + +// ═══════════════════════════════════════════════════════════════════ +// 4. buildUnsignedTx (核心) +// ═══════════════════════════════════════════════════════════════════ + +export async function buildUnsignedTx( + input: BuildUnsignedTxInput +): Promise { + const provider = getProvider(input); + + const to = checksumAddress(input.to, 'to'); + const from = checksumAddress(input.from, 'from'); + const data = hexData(input.data); + const value = weiValue(input.value); + + // ---- 4 次 RPC 并行 ---- + const [rawGasLimit, fd, nonce, network] = await Promise.all([ + provider.estimateGas({ to, from, data, value }), + provider.getFeeData(), + provider.getTransactionCount(from, 'pending'), + provider.getNetwork(), + ]); + + const gasLimit = bpsBuffer(rawGasLimit, input.gasLimitBufferBps); + const chainId = Number(network.chainId); + + // ---- 判断链是否支持 EIP-1559 ---- + const eip1559 = fd.maxFeePerGas != null && fd.maxPriorityFeePerGas != null; + + if (!eip1559 && fd.gasPrice == null) { + throw new Error( + 'Unable to determine gas price: provider returned no fee data' + ); + } + + // ---- 构造 ethers Transaction 对象 ---- + const shared = { to, data, value, nonce, chainId, gasLimit }; + + const tx = eip1559 + ? Transaction.from({ + ...shared, + type: 2, + maxFeePerGas: fd.maxFeePerGas!, + maxPriorityFeePerGas: fd.maxPriorityFeePerGas!, + }) + : Transaction.from({ + ...shared, + type: 0, + gasPrice: fd.gasPrice!, + }); + + // ---- 组装返回值 ---- + return { + type: tx.type, + to, + from, + data, + value: toQuantity(value), + nonce, + chainId, + gasLimit: toQuantity(gasLimit), + ...(eip1559 + ? { + maxFeePerGas: toQuantity(fd.maxFeePerGas!), + maxPriorityFeePerGas: toQuantity(fd.maxPriorityFeePerGas!), + } + : { + gasPrice: toQuantity(fd.gasPrice!), + }), + serialized: tx.unsignedSerialized, + }; +} + +// ═══════════════════════════════════════════════════════════════════ +// Internal helpers +// ═══════════════════════════════════════════════════════════════════ + +function getProvider(opts: WithProvider): Provider { + const url = opts.rpcUrl?.trim(); + if (!url) throw new Error('rpcUrl is required'); + return opts.providerFactory + ? opts.providerFactory(url) + : new JsonRpcProvider(url); +} + +function checksumAddress(raw: string, label: string): string { + const v = raw?.trim(); + if (!v) throw new Error(`${label} address is required`); + try { + return getAddress(v); + } catch { + throw new Error(`${label} is not a valid EVM address: ${raw}`); + } +} + +function hexData(raw?: string): string { + const v = raw?.trim(); + if (!v) return '0x'; + if (!isHexString(v)) throw new Error(`data must be a hex string`); + return v; +} + +function weiValue(raw?: string): bigint { + const v = raw?.trim(); + if (!v) return BigInt(0); + try { + return BigInt(v); + } catch { + throw new Error(`value is not a valid integer: ${raw}`); + } +} + +function bpsBuffer(amount: bigint, bps?: number): bigint { + if (bps == null) return amount; + if (!Number.isFinite(bps) || bps < 10_000) { + throw new Error('bufferBps must be >= 10_000 (100%)'); + } + return (amount * BigInt(Math.floor(bps))) / BigInt(10000); +} diff --git a/src/chain/indexer.ts b/src/chain/indexer.ts new file mode 100644 index 0000000..cb71ee7 --- /dev/null +++ b/src/chain/indexer.ts @@ -0,0 +1,133 @@ +import { + buildUnsignedTx as buildEvmUnsignedTx, + EvmUnsignedTx, + type BuildUnsignedTxInput as EvmBuildUnsignedTxInput, +} from './evm'; +import { SolanaUnsignedTx } from './sol'; + +const EVM_CHAIN_CODES = new Set([ + 'eth', + 'bsc', + 'base', + 'polygon', + 'arbitrum', + 'linea', + 'avax', + 'optimism', + 'zksync', + 'opbnb', + 'mantle', + 'scroll', + 'blast', + 'mode', + 'fantom', + 'celo', + 'xdai', + 'metis', + 'rootstock', + 'moonriver', + 'polygon_zkevm', + 'cronos', + 'harmony', + 'kava', + 'aurora', + 'manta', + 'gravity', + 'flare', + 'ape', + 'swell', + 'plume', + 'tac', + 'bera', + 'uni', + 'hyperevm', + 'sonic', + 'sei', + 'plasma', + 'monad', + 'telos', +]); + +export const EVM_CHAIN_RPC_MAP: Record = { + eth: 'https://eth-mainnet.public.blastapi.io', + bsc: 'https://bsc-dataseed.bnbchain.org', + base: 'https://mainnet.base.org', + polygon: 'https://polygon-rpc.com', + arbitrum: 'https://arb1.arbitrum.io/rpc', + linea: 'https://rpc.linea.build', + avax: 'https://api.avax.network/ext/bc/C/rpc', + optimism: 'https://mainnet.optimism.io', + zksync: 'https://mainnet.era.zksync.io', + opbnb: 'https://opbnb-mainnet-rpc.bnbchain.org', + mantle: 'https://rpc.mantle.xyz', + scroll: 'https://rpc.scroll.io', + fantom: 'https://rpc.fantom.network', + celo: 'https://forno.celo.org', + xdai: 'https://rpc.gnosischain.com', + metis: 'https://andromeda.metis.io/?owner=1088', + rootstock: 'https://public-node.rsk.co', + moonriver: 'https://rpc.api.moonriver.moonbeam.network', + cronos: 'https://evm.cronos.org', + harmony: 'https://api.harmony.one', + kava: 'https://evm.kava.io', + aurora: 'https://mainnet.aurora.dev', +}; + +export function isEvmChainCode(chainCode: string): boolean { + const normalized = normalizeChainCode(chainCode); + return EVM_CHAIN_CODES.has(normalized); +} + +// 联合类型 +export type UnsignedTx = EvmUnsignedTx | SolanaUnsignedTx; + +export async function buildUnsignedTx( + chainCode: string, + to: string, + from: string, + data?: string, + value?: string, + gasLimitBufferBps?: number, + options?: { + rpcUrl?: string; + providerFactory?: EvmBuildUnsignedTxInput['providerFactory']; + } +): Promise { + const normalizedChainCode = normalizeChainCode(chainCode); + if (isEvmChainCode(normalizedChainCode)) { + return await buildEvmUnsignedTx({ + to, + from, + data, + value, + gasLimitBufferBps, + rpcUrl: options?.rpcUrl || getEvmRpcUrl(normalizedChainCode), + providerFactory: options?.providerFactory, + }); + } + + throw new Error(`chainCode not supported yet: ${normalizedChainCode}`); +} + +export function getEvmRpcUrl(chainCode: string): string { + const normalized = normalizeChainCode(chainCode); + if (!isEvmChainCode(normalized)) { + throw new Error(`chainCode not supported yet: ${normalized}`); + } + + const rpcUrl = EVM_CHAIN_RPC_MAP[normalized]; + if (!rpcUrl) { + throw new Error(`rpc url not configured for chainCode: ${normalized}`); + } + return rpcUrl; +} + +function normalizeChainCode(chainCode: string): string { + const normalized = String(chainCode || '') + .trim() + .toLowerCase(); + if (!normalized) { + throw new Error('chainCode is required'); + } + return normalized; +} diff --git a/src/chain/sol.ts b/src/chain/sol.ts new file mode 100644 index 0000000..2c7042c --- /dev/null +++ b/src/chain/sol.ts @@ -0,0 +1,10 @@ +export interface SolanaUnsignedTx { + to: string; + from: string; + data: string; + value: string; + nonce: string; + chainId: string; + gasLimit: string; + gasPrice: string; +} diff --git a/src/config/config.default.ts b/src/config/config.default.ts index dbf5334..225be3b 100644 --- a/src/config/config.default.ts +++ b/src/config/config.default.ts @@ -10,6 +10,8 @@ const getRedisConfig = (db = 0, options: Record = {}) => ({ ...options, }); +const redisClientConfig = getRedisConfig(parseInt(process.env.REDIS_DB, 10)); + /* default中的配置项,会被config.xxxx.ts中相同配置项覆盖 */ @@ -24,7 +26,20 @@ export default { enableConsole: false, }, }, - redis: getRedisConfig(parseInt(process.env.REDIS_DB, 10)), + redis: { + ...redisClientConfig, + client: redisClientConfig, + defaultClientName: 'default', + }, + bull: { + defaultQueueOptions: { + redis: { + ...redisClientConfig, + // Prevent ioredis from throwing MaxRetriesPerRequestError under transient failures. + maxRetriesPerRequest: null, + }, + }, + }, mongoose: { dataSource: { default: { diff --git a/src/configuration.ts b/src/configuration.ts index 49748b8..7bd402c 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -17,6 +17,7 @@ import * as koa from '@midwayjs/koa'; import { IMidwayLogger } from '@midwayjs/logger'; import * as mongoose from '@midwayjs/mongoose'; import * as redis from '@midwayjs/redis'; +import * as bull from '@midwayjs/bull'; import * as validate from '@midwayjs/validate'; import { sync } from 'read-pkg'; @@ -34,6 +35,10 @@ import { registerModel } from './utils/register-model'; i18n, redis, mongoose, + { + component: bull, + enabledEnvironment: ['local', 'production'], + }, { component: crossDomain, enabledEnvironment: ['local'], diff --git a/src/constant/export-fields.ts b/src/constant/export-fields.ts index 937560b..9721ce5 100644 --- a/src/constant/export-fields.ts +++ b/src/constant/export-fields.ts @@ -1 +1,43 @@ export const UserMOdelProjection = ['userId', 'name', 'email']; + +export const SwapNetworkModelProjection = [ + 'id', + 'provider', + 'chainName', + 'chainCode', + 'chainId', + 'nativeTokenAddress', + 'isActive', + 'metadata', + 'syncedAt', + 'createdAt', + 'updatedAt', +]; + +export const SwapTokenModelProjection = [ + 'id', + 'provider', + 'chainCode', + 'address', + 'symbol', + 'name', + 'decimals', + 'logoURI', + 'isActive', + 'raw', + 'syncedAt', + 'createdAt', + 'updatedAt', +]; + +export const SwapProviderModelProjection = [ + 'id', + 'provider', + 'status', + 'priority', + 'timeoutMs', + 'rateLimitQps', + 'meta', + 'createdAt', + 'updatedAt', +]; diff --git a/src/entity/networks/networks.dto.ts b/src/entity/networks/networks.dto.ts new file mode 100644 index 0000000..b75a707 --- /dev/null +++ b/src/entity/networks/networks.dto.ts @@ -0,0 +1,81 @@ +import { Rule, RuleType } from '@midwayjs/validate'; + +import { PaginationDTO } from '../common/common.dto'; + +export class NetworkCreateDTO { + @Rule(RuleType.string().required()) + provider: string; + + @Rule(RuleType.string().required()) + chainName: string; + + @Rule(RuleType.string().required()) + chainCode: string; + + @Rule(RuleType.string()) + chainId?: string; + + @Rule(RuleType.string()) + nativeTokenAddress?: string; + + @Rule(RuleType.boolean()) + isActive?: boolean; + + @Rule(RuleType.object()) + metadata?: Record; + + @Rule(RuleType.date()) + syncedAt?: Date; +} + +export class NetworkUpdateDTO { + @Rule(RuleType.string()) + chainName?: string; + + @Rule(RuleType.string()) + chainId?: string; + + @Rule(RuleType.string()) + nativeTokenAddress?: string; + + @Rule(RuleType.boolean()) + isActive?: boolean; + + @Rule(RuleType.object()) + metadata?: Record; + + @Rule(RuleType.date()) + syncedAt?: Date; +} + +export class NetworkQueryDTO extends PaginationDTO { + @Rule(RuleType.string()) + provider?: string; + + @Rule(RuleType.string()) + chainCode?: string; + + @Rule(RuleType.string()) + chainId?: string; + + @Rule(RuleType.boolean()) + isActive?: boolean; +} + +export class NetworkItemDTO { + provider: string; + chainName: string; + chainCode: string; + chainId: string; + nativeTokenAddress: string; + isActive: boolean; + metadata?: Record; + syncedAt: Date; + createdAt: Date; + updatedAt: Date; +} + +export class NetworkListResponseDTO { + result: NetworkItemDTO[]; + next: string | null; +} diff --git a/src/entity/networks/networks.entity.ts b/src/entity/networks/networks.entity.ts new file mode 100644 index 0000000..674cfd8 --- /dev/null +++ b/src/entity/networks/networks.entity.ts @@ -0,0 +1,101 @@ +import { randomUUID } from 'crypto'; + +import { Schema } from 'mongoose'; + +interface Network { + id: string; + provider: string; + chainName: string; + chainCode: string; + chainId: string; + nativeTokenAddress: string; + isActive: boolean; + metadata?: Record; + syncedAt: Date; + createdAt: Date; + updatedAt: Date; +} + +const NetworkSchema = new Schema( + { + id: { + type: String, + required: true, + default: () => randomUUID(), + trim: true, + }, + provider: { + type: String, + required: true, + trim: true, + lowercase: true, + }, + chainName: { + type: String, + required: true, + trim: true, + }, + chainCode: { + type: String, + required: true, + trim: true, + lowercase: true, + }, + chainId: { + type: String, + required: false, + trim: true, + default: '', + }, + nativeTokenAddress: { + type: String, + required: false, + trim: true, + default: '', + }, + isActive: { + type: Boolean, + required: true, + default: true, + }, + metadata: { + type: Schema.Types.Mixed, + required: false, + default: undefined, + }, + syncedAt: { + type: Date, + required: true, + default: () => new Date(), + }, + }, + { + collection: 'Network', + timestamps: true, + } +); + +NetworkSchema.index( + { provider: 1, chainCode: 1 }, + { + unique: true, + name: 'network_provider_chain_code_unique', + } +); +NetworkSchema.index( + { provider: 1, chainId: 1 }, + { name: 'network_provider_chain_id_index' } +); +NetworkSchema.index( + { provider: 1, syncedAt: -1 }, + { name: 'network_provider_synced_at_index' } +); +NetworkSchema.index({ isActive: 1 }, { name: 'network_is_active_index' }); +NetworkSchema.index( + { id: 1 }, + { unique: true, sparse: true, name: 'network_id_unique' } +); + +export const NetworkSchemaName = 'NetworkSchema'; + +export { Network, NetworkSchema }; diff --git a/src/entity/networks/networks.service.ts b/src/entity/networks/networks.service.ts new file mode 100644 index 0000000..9e1e3e3 --- /dev/null +++ b/src/entity/networks/networks.service.ts @@ -0,0 +1,241 @@ +import { Inject, Provide } from '@midwayjs/core'; +import { FilterQuery, Model } from 'mongoose'; + +import { RedisCacheClient } from '../../cache/redis'; +import { SwapNetworkModelProjection } from '../../constant'; +import { Network as ProviderNetwork } from '../../thirdparty/swap/provider.interface'; +import { PaginationDTO } from '../common/common.dto'; +import { + NetworkCreateDTO, + NetworkQueryDTO, + NetworkUpdateDTO, +} from './networks.dto'; + +import { Network, NetworkSchemaName } from './networks.entity'; + +const NETWORK_REDIS_CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours + +@Provide() +export class NetworkService { + @Inject(NetworkSchemaName) + networkModel: Model; + + @Inject() + redisCacheClient: RedisCacheClient; + + // 批量创建网络: + // 1) 对 provider/chainCode 做标准化并按联合键去重 + // 2) 使用 bulkWrite + upsert 批量插入(仅插入不存在的数据) + // 3) 按 Cache-Aside 失效网络缓存(provider 维度 + all 维度) + async createNetworksBatch(networks: NetworkCreateDTO[]) { + const deduped = new Map(); + + for (const network of networks || []) { + const provider = this.normalizeLowercase(network.provider); + const chainCode = this.normalizeLowercase(network.chainCode); + if (!provider || !chainCode) { + continue; + } + + deduped.set(`${provider}:${chainCode}`, { + ...network, + provider, + chainCode, + }); + } + + const payloads = Array.from(deduped.values()); + if (payloads.length <= 0) { + return []; + } + + const operations = payloads.map(payload => ({ + updateOne: { + filter: { + provider: payload.provider, + chainCode: payload.chainCode, + }, + update: { + $setOnInsert: payload, + }, + upsert: true, + }, + })); + + await this.networkModel.bulkWrite(operations, { ordered: false }); + await this.invalidateNetworksCacheByProviders( + payloads.map(payload => payload.provider) + ); + + return this.networkModel.find( + { + $or: payloads.map(payload => ({ + provider: payload.provider, + chainCode: payload.chainCode, + })), + }, + SwapNetworkModelProjection + ); + } + + // 分页查询网络列表: + // 1) 按 query 构建过滤条件(provider/chainCode/chainId/isActive) + // 2) 按 createdAt 倒序返回当前页数据 + // 3) 当结果数量等于 limit 时返回 next 游标(最后一条记录 id) + async findNetworks(query: NetworkQueryDTO, pagination: PaginationDTO) { + const limitNum = Number(pagination.limit); + const limit = Number.isNaN(limitNum) ? 10 : limitNum; + const filters = this.buildListFilter(query, pagination); + + const result = await this.networkModel.find(filters, SwapNetworkModelProjection, { + limit, + sort: { createdAt: -1 }, + }); + + const latestItem = + result.length && result.length === limit + ? result[result.length - 1] + : null; + + return { + result, + next: latestItem?.id ?? null, + }; + } + + async getNetworkDetail(provider: string, chainCode: string) { + return this.networkModel.findOne( + { + provider: this.normalizeLowercase(provider), + chainCode: this.normalizeLowercase(chainCode), + }, + SwapNetworkModelProjection + ); + } + + async updateNetwork( + provider: string, + chainCode: string, + patch: NetworkUpdateDTO + ) { + const payload = { ...patch } as Partial; + delete payload.provider; + delete payload.chainCode; + delete payload.createdAt; + delete payload.updatedAt; + + await this.networkModel.updateOne( + { + provider: this.normalizeLowercase(provider), + chainCode: this.normalizeLowercase(chainCode), + }, + payload, + { + runValidators: true, + } + ); + + return true; + } + + async deleteNetwork(provider: string, chainCode: string) { + await this.networkModel.deleteOne({ + provider: this.normalizeLowercase(provider), + chainCode: this.normalizeLowercase(chainCode), + }); + return true; + } + + async getNetworksCache(provider?: string): Promise { + if (!this.redisCacheClient) { + return null; + } + + try { + const cached = await this.redisCacheClient.getSwapNetworks(provider); + if (!cached || !Array.isArray(cached.data)) { + return null; + } + return cached.data; + } catch (error) { + console.warn('[swap.network.service] get networks cache failed', error); + return null; + } + } + + async setNetworksCache( + provider: string | undefined, + networks: ProviderNetwork[] + ): Promise { + if (!this.redisCacheClient || !Array.isArray(networks)) { + return; + } + + try { + await this.redisCacheClient.setSwapNetworks( + provider, + networks, + NETWORK_REDIS_CACHE_TTL_SECONDS + ); + } catch (error) { + console.warn('[swap.network.service] set networks cache failed', error); + } + } + + private buildListFilter( + query: NetworkQueryDTO, + pagination: PaginationDTO + ): FilterQuery { + const filter: FilterQuery = {}; + + if (pagination.cursor) { + filter._id = { $lt: pagination.cursor }; + } + if (query.provider) { + filter.provider = this.normalizeLowercase(query.provider); + } + if (query.chainCode) { + filter.chainCode = this.normalizeLowercase(query.chainCode); + } + if (query.chainId) { + filter.chainId = String(query.chainId).trim(); + } + if (typeof query.isActive === 'boolean') { + filter.isActive = query.isActive; + } + + return filter; + } + + private normalizeLowercase(input?: string): string { + return String(input || '') + .trim() + .toLowerCase(); + } + + private async invalidateNetworksCacheByProviders(providers: string[]) { + if (!this.redisCacheClient) { + return; + } + + const normalizedProviders = Array.from( + new Set( + (providers || []).map(provider => this.normalizeLowercase(provider)) + ) + ).filter(Boolean); + const targetProviders = [undefined, ...normalizedProviders]; + + try { + await Promise.all( + targetProviders.map(provider => + this.redisCacheClient.delSwapNetworks(provider) + ) + ); + } catch (error) { + console.warn( + '[swap.network.service] invalidate networks cache failed', + error + ); + } + } +} diff --git a/src/entity/swap_provider/swap-provider.dto.ts b/src/entity/swap_provider/swap-provider.dto.ts new file mode 100644 index 0000000..07a46cd --- /dev/null +++ b/src/entity/swap_provider/swap-provider.dto.ts @@ -0,0 +1,64 @@ +import { Rule, RuleType } from '@midwayjs/validate'; + +import { PaginationDTO } from '../common/common.dto'; + +export class SwapProviderCreateDTO { + @Rule(RuleType.string().required()) + provider: string; + + @Rule(RuleType.string().valid('active', 'degraded', 'disabled')) + status?: 'active' | 'degraded' | 'disabled'; + + @Rule(RuleType.number()) + priority?: number; + + @Rule(RuleType.number()) + timeoutMs?: number; + + @Rule(RuleType.number()) + rateLimitQps?: number; + + @Rule(RuleType.object()) + meta?: Record; +} + +export class SwapProviderUpdateDTO { + @Rule(RuleType.string().valid('active', 'degraded', 'disabled')) + status?: 'active' | 'degraded' | 'disabled'; + + @Rule(RuleType.number()) + priority?: number; + + @Rule(RuleType.number()) + timeoutMs?: number; + + @Rule(RuleType.number()) + rateLimitQps?: number; + + @Rule(RuleType.object()) + meta?: Record; +} + +export class SwapProviderQueryDTO extends PaginationDTO { + @Rule(RuleType.string()) + provider?: string; + + @Rule(RuleType.string().valid('active', 'degraded', 'disabled')) + status?: 'active' | 'degraded' | 'disabled'; +} + +export class SwapProviderItemDTO { + provider: string; + status: 'active' | 'degraded' | 'disabled'; + priority: number; + timeoutMs: number; + rateLimitQps: number; + meta?: Record; + createdAt: Date; + updatedAt: Date; +} + +export class SwapProviderListResponseDTO { + result: SwapProviderItemDTO[]; + next: string | null; +} diff --git a/src/entity/swap_provider/swap-provider.entity.ts b/src/entity/swap_provider/swap-provider.entity.ts new file mode 100644 index 0000000..3851f0d --- /dev/null +++ b/src/entity/swap_provider/swap-provider.entity.ts @@ -0,0 +1,81 @@ +import { randomUUID } from 'crypto'; + +import { Schema } from 'mongoose'; + +type SwapProviderStatus = 'active' | 'degraded' | 'disabled'; + +interface SwapProvider { + id: string; + provider: string; + status: SwapProviderStatus; + priority: number; + timeoutMs: number; + rateLimitQps: number; + meta?: Record; + createdAt: Date; + updatedAt: Date; +} + +const SwapProviderSchema = new Schema( + { + id: { + type: String, + required: true, + default: () => randomUUID(), + trim: true, + }, + provider: { + type: String, + required: true, + unique: true, + trim: true, + lowercase: true, + }, + status: { + type: String, + required: true, + enum: ['active', 'degraded', 'disabled'], + default: 'active', + }, + priority: { + type: Number, + required: true, + default: 100, + }, + timeoutMs: { + type: Number, + required: true, + default: 1200, + min: 1, + }, + rateLimitQps: { + type: Number, + required: true, + default: 0, + min: 0, + }, + meta: { + type: Schema.Types.Mixed, + required: false, + default: undefined, + }, + }, + { + collection: 'SwapProvider', + timestamps: true, + } +); + +SwapProviderSchema.index( + { provider: 1 }, + { unique: true, name: 'swap_provider_provider_unique' } +); +SwapProviderSchema.index( + { status: 1, priority: 1 }, + { name: 'swap_provider_status_priority_index' } +); +SwapProviderSchema.index({ id: 1 }, { unique: true, sparse: true, name: 'swap_provider_id_unique' }); + +export const SwapProviderSchemaName = 'SwapProviderSchema'; + +export { SwapProvider, SwapProviderSchema, SwapProviderStatus }; diff --git a/src/entity/swap_provider/swap-provider.service.ts b/src/entity/swap_provider/swap-provider.service.ts new file mode 100644 index 0000000..cc2d004 --- /dev/null +++ b/src/entity/swap_provider/swap-provider.service.ts @@ -0,0 +1,119 @@ +import { Inject, Provide } from '@midwayjs/core'; +import { FilterQuery, Model } from 'mongoose'; + +import { SwapProviderModelProjection } from '../../constant'; +import { PaginationDTO } from '../common/common.dto'; +import { + SwapProviderCreateDTO, + SwapProviderQueryDTO, + SwapProviderUpdateDTO, +} from './swap-provider.dto'; + +import { + SwapProvider, + SwapProviderSchemaName, +} from './swap-provider.entity'; + +@Provide() +export class SwapProviderService { + @Inject(SwapProviderSchemaName) + swapProviderModel: Model; + + async createProvider(provider: SwapProviderCreateDTO) { + const record = await this.swapProviderModel.create({ + ...provider, + provider: this.normalizeLowercase(provider.provider), + }); + return this.findProviderById(record.id); + } + + async findProviders(query: SwapProviderQueryDTO, pagination: PaginationDTO) { + const limitNum = Number(pagination.limit); + const limit = Number.isNaN(limitNum) ? 10 : limitNum; + const filters = this.buildListFilter(query, pagination); + + const result = await this.swapProviderModel.find( + filters, + SwapProviderModelProjection, + { + limit, + sort: { createdAt: -1 }, + } + ); + + const latestItem = + result.length && result.length === limit + ? result[result.length - 1] + : null; + + return { + result, + next: latestItem?.id ?? null, + }; + } + + async getProviderDetail(provider: string) { + return this.swapProviderModel.findOne( + { + provider: this.normalizeLowercase(provider), + }, + SwapProviderModelProjection + ); + } + + async updateProvider(provider: string, patch: SwapProviderUpdateDTO) { + const payload = { ...patch } as Partial; + delete payload.provider; + delete payload.createdAt; + delete payload.updatedAt; + + await this.swapProviderModel.updateOne( + { + provider: this.normalizeLowercase(provider), + }, + payload, + { + runValidators: true, + } + ); + + return true; + } + + async deleteProvider(provider: string) { + await this.swapProviderModel.deleteOne({ + provider: this.normalizeLowercase(provider), + }); + return true; + } + + private async findProviderById(id: string) { + return this.swapProviderModel.findOne( + { _id: id }, + SwapProviderModelProjection + ); + } + + private buildListFilter( + query: SwapProviderQueryDTO, + pagination: PaginationDTO + ): FilterQuery { + const filter: FilterQuery = {}; + + if (pagination.cursor) { + filter._id = { $lt: pagination.cursor }; + } + if (query.provider) { + filter.provider = this.normalizeLowercase(query.provider); + } + if (query.status) { + filter.status = query.status; + } + + return filter; + } + + private normalizeLowercase(input?: string): string { + return String(input || '').trim().toLowerCase(); + } +} diff --git a/src/entity/token/token.controller.ts b/src/entity/token/token.controller.ts deleted file mode 100644 index a8df30c..0000000 --- a/src/entity/token/token.controller.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Controller, Get, Inject } from '@midwayjs/core'; -import { Context } from '@midwayjs/koa'; - -import { TokenService } from './token.service'; - -@Controller('/token') -export class UserController { - @Inject() - ctx: Context; - - @Inject() - service: TokenService; - - @Get('/') - async getList() { - return this.service.getNetworks(); - } - - @Get('/:name') - async get() { - // const { name } = this.ctx.params; - return this.service.getToken(); - } -} diff --git a/src/entity/token/token.dto.ts b/src/entity/token/token.dto.ts index e69de29..059b8fd 100644 --- a/src/entity/token/token.dto.ts +++ b/src/entity/token/token.dto.ts @@ -0,0 +1,95 @@ +import { Rule, RuleType } from '@midwayjs/validate'; + +import { PaginationDTO } from '../common/common.dto'; + +export class TokenCreateDTO { + @Rule(RuleType.string().required()) + provider: string; + + @Rule(RuleType.string().required()) + chainCode: string; + + @Rule(RuleType.string().required()) + address: string; + + @Rule(RuleType.string().required()) + symbol: string; + + @Rule(RuleType.string()) + name?: string; + + @Rule(RuleType.number().min(0)) + decimals?: number; + + @Rule(RuleType.string()) + logoURI?: string; + + @Rule(RuleType.boolean()) + isActive?: boolean; + + @Rule(RuleType.object()) + raw?: Record; + + @Rule(RuleType.date()) + syncedAt?: Date; +} + +export class TokenUpdateDTO { + @Rule(RuleType.string()) + symbol?: string; + + @Rule(RuleType.string()) + name?: string; + + @Rule(RuleType.number().min(0)) + decimals?: number; + + @Rule(RuleType.string()) + logoURI?: string; + + @Rule(RuleType.boolean()) + isActive?: boolean; + + @Rule(RuleType.object()) + raw?: Record; + + @Rule(RuleType.date()) + syncedAt?: Date; +} + +export class TokenQueryDTO extends PaginationDTO { + @Rule(RuleType.string()) + provider?: string; + + @Rule(RuleType.string()) + chainCode?: string; + + @Rule(RuleType.string()) + address?: string; + + @Rule(RuleType.string()) + keyword?: string; + + @Rule(RuleType.boolean()) + isActive?: boolean; +} + +export class TokenItemDTO { + provider: string; + chainCode: string; + address: string; + symbol: string; + name?: string; + decimals?: number; + logoURI?: string; + isActive: boolean; + raw?: Record; + syncedAt: Date; + createdAt: Date; + updatedAt: Date; +} + +export class TokenListResponseDTO { + result: TokenItemDTO[]; + next: string | null; +} diff --git a/src/entity/token/token.entity.ts b/src/entity/token/token.entity.ts new file mode 100644 index 0000000..6129a77 --- /dev/null +++ b/src/entity/token/token.entity.ts @@ -0,0 +1,112 @@ +import { randomUUID } from 'crypto'; + +import { Schema } from 'mongoose'; + +interface Token { + id: string; + provider: string; + chainCode: string; + address: string; + symbol: string; + name?: string; + decimals?: number; + logoURI?: string; + isActive: boolean; + raw?: Record; + syncedAt: Date; + createdAt: Date; + updatedAt: Date; +} + +const TokenSchema = new Schema( + { + id: { + type: String, + required: true, + default: () => randomUUID(), + trim: true, + }, + provider: { + type: String, + required: true, + trim: true, + lowercase: true, + }, + chainCode: { + type: String, + required: true, + trim: true, + lowercase: true, + }, + address: { + type: String, + required: true, + trim: true, + lowercase: true, + }, + symbol: { + type: String, + required: true, + trim: true, + }, + name: { + type: String, + required: false, + trim: true, + }, + decimals: { + type: Number, + required: false, + min: 0, + }, + logoURI: { + type: String, + required: false, + trim: true, + }, + isActive: { + type: Boolean, + required: true, + default: true, + }, + raw: { + type: Schema.Types.Mixed, + required: false, + default: undefined, + }, + syncedAt: { + type: Date, + required: true, + default: () => new Date(), + }, + }, + { + collection: 'Token', + timestamps: true, + } +); + +TokenSchema.index( + { provider: 1, chainCode: 1, address: 1 }, + { + unique: true, + name: 'token_provider_chain_code_address_unique', + } +); +TokenSchema.index( + { provider: 1, chainCode: 1, symbol: 1 }, + { name: 'token_provider_chain_code_symbol_index' } +); +TokenSchema.index( + { provider: 1, syncedAt: -1 }, + { name: 'token_provider_synced_at_index' } +); +TokenSchema.index({ updatedAt: -1 }, { name: 'swap_token_updated_at_index' }); +TokenSchema.index( + { id: 1 }, + { unique: true, sparse: true, name: 'token_id_unique' } +); + +export const TokenSchemaName = 'TokenSchema'; + +export { Token, TokenSchema }; diff --git a/src/entity/token/token.service.ts b/src/entity/token/token.service.ts index c324ecd..ec78c60 100644 --- a/src/entity/token/token.service.ts +++ b/src/entity/token/token.service.ts @@ -1,11 +1,612 @@ -import { Init, Provide } from '@midwayjs/core'; +import { Inject, Provide } from '@midwayjs/core'; +import { FilterQuery, Model } from 'mongoose'; + +import { + RedisCacheClient, + SwapTokenQueryCacheKeyParams, +} from '../../cache/redis'; +import { SwapTokenModelProjection } from '../../constant'; +import { PaginationDTO } from '../common/common.dto'; +import { TokenCreateDTO, TokenQueryDTO, TokenUpdateDTO } from './token.dto'; + +import { Token, TokenSchemaName } from './token.entity'; + +// token 查询缓存过期时间 +const TOKEN_QUERY_REDIS_CACHE_TTL_SECONDS = 10 * 60; +// token 对象缓存过期时间 +const TOKEN_OBJECT_REDIS_CACHE_TTL_SECONDS = 6 * 60 * 60; @Provide() export class TokenService { - @Init() - async init() {} + @Inject(TokenSchemaName) + tokenModel: Model; + + @Inject() + redisCacheClient: RedisCacheClient; + + async createToken(token: TokenCreateDTO) { + const provider = this.normalizeLowercase(token.provider); + const chainCode = this.normalizeLowercase(token.chainCode); + const address = this.normalizeLowercase(token.address); + + await this.tokenModel.create({ + ...token, + provider, + chainCode, + address, + }); + + const created = await this.getTokenDetail(provider, chainCode, address); + if (created) { + await this.setTokenObjectCache(created); + } + await this.invalidateTokensCacheByPairs([{ provider, chainCode }]); + return created; + } + + // 批量创建 Token: + // 1) 对 provider/chainCode/address 做标准化并按联合键去重 + // 2) 使用 bulkWrite + upsert 批量插入(仅插入不存在的数据) + // 3) 按 Cache-Aside 失效 Token 缓存(provider 维度 + all 维度) + async createTokenBatch(tokens: TokenCreateDTO[]) { + const deduped = new Map(); + + for (const token of tokens || []) { + const provider = this.normalizeLowercase(token.provider); + const chainCode = this.normalizeLowercase(token.chainCode); + const address = this.normalizeLowercase(token.address); + if (!provider || !chainCode || !address) { + continue; + } + + deduped.set(`${provider}:${chainCode}:${address}`, { + ...token, + provider, + chainCode, + address, + }); + } + + const payloads = Array.from(deduped.values()); + if (payloads.length <= 0) { + return []; + } + + const operations = payloads.map(payload => ({ + updateOne: { + filter: { + provider: payload.provider, + chainCode: payload.chainCode, + address: payload.address, + }, + update: { + $setOnInsert: payload, + }, + upsert: true, + }, + })); + + await this.tokenModel.bulkWrite(operations, { ordered: false }); + const result = await this.tokenModel.find( + { + $or: payloads.map(payload => ({ + provider: payload.provider, + chainCode: payload.chainCode, + address: payload.address, + })), + }, + SwapTokenModelProjection + ); + + await this.setTokenObjectCaches(result); + await this.invalidateTokensCacheByPairs( + payloads.map(payload => ({ + provider: payload.provider, + chainCode: payload.chainCode, + })) + ); + + return result; + } + + // 查询 Token: + // 1) 使用 buildListFilter 构建查询过滤条件 + // 2) 按 Cache-Aside 获取 Token 查询结果缓存 + // 3) 从 DB 查询 Token 列表并缓存 + // 4) 返回 Token 列表及下一页游标 + async findTokens(query: TokenQueryDTO, pagination: PaginationDTO) { + const limitNum = Number(pagination.limit); + const limit = Number.isNaN(limitNum) ? 10 : limitNum; + const filters = this.buildListFilter(query, pagination); + + const result = await this.tokenModel.find(filters, SwapTokenModelProjection, { + limit, + sort: { createdAt: -1 }, + }); + + const latestItem = + result.length && result.length === limit + ? result[result.length - 1] + : null; + const nextCursor = latestItem ? String((latestItem as any)._id || '') : ''; + + return { + result, + next: nextCursor || null, + }; + } + + // 分页查询 Token: + async findTokensByPage( + query: TokenQueryDTO, + pagination: { page: number; pageSize: number } + ) { + const pageNum = Number(pagination.page); + const page = Number.isNaN(pageNum) || pageNum < 1 ? 1 : pageNum; + const pageSizeNum = Number(pagination.pageSize); + const pageSize = + Number.isNaN(pageSizeNum) || pageSizeNum < 1 ? 20 : pageSizeNum; + const filters = this.buildListFilter(query, {}); + const cacheKeyParams = this.buildTokenQueryCacheParams( + query, + page, + pageSize + ); + + if (cacheKeyParams) { + const cachedResult = await this.getTokenQueryCache(cacheKeyParams); + if (cachedResult) { + const cachedTokens = await this.getTokensByIDs(cachedResult.ids); + if (cachedTokens.length === cachedResult.ids.length) { + return { + result: cachedTokens, + total: cachedResult.total, + }; + } + } + } + + const pageResult = await this.queryTokenPageFromDB(filters, page, pageSize); + await this.setTokenObjectCaches(pageResult.result); + if (cacheKeyParams && pageResult.ids.length === pageResult.result.length) { + await this.setTokenQueryCache( + cacheKeyParams, + pageResult.ids, + pageResult.total + ); + } + + return { + result: pageResult.result, + total: pageResult.total, + }; + } + + async countTokens(query: TokenQueryDTO): Promise { + const filters = this.buildListFilter(query, {}); + return this.tokenModel.countDocuments(filters); + } + + async getLatestSyncedAt(query: TokenQueryDTO): Promise { + const filters = this.buildListFilter(query, {}); + const latest = await this.tokenModel.findOne(filters, ['syncedAt'], { + sort: { syncedAt: -1 }, + }); + return latest?.syncedAt || null; + } + + async findTokensByChainAndAddresses( + chainCode: string, + addresses: string[], + isActive?: boolean + ): Promise { + const normalizedChainCode = this.normalizeLowercase(chainCode); + const normalizedAddresses = Array.from( + new Set( + (addresses || []) + .map(address => this.normalizeLowercase(address)) + .filter(Boolean) + ) + ); + if (!normalizedChainCode || normalizedAddresses.length <= 0) { + return []; + } + + const filters: FilterQuery = { + chainCode: normalizedChainCode, + address: { $in: normalizedAddresses }, + }; + if (typeof isActive === 'boolean') { + filters.isActive = isActive; + } + + return this.tokenModel.find(filters, SwapTokenModelProjection); + } + + async getTokenDetail(provider: string, chainCode: string, address: string) { + return this.tokenModel.findOne( + { + provider: this.normalizeLowercase(provider), + chainCode: this.normalizeLowercase(chainCode), + address: this.normalizeLowercase(address), + }, + SwapTokenModelProjection + ); + } + + async updateToken( + provider: string, + chainCode: string, + address: string, + patch: TokenUpdateDTO + ) { + const normalizedProvider = this.normalizeLowercase(provider); + const normalizedChainCode = this.normalizeLowercase(chainCode); + const normalizedAddress = this.normalizeLowercase(address); + const existing = await this.getTokenDetail( + normalizedProvider, + normalizedChainCode, + normalizedAddress + ); + + const payload = { ...patch } as Partial; + delete payload.provider; + delete payload.chainCode; + delete payload.address; + delete payload.createdAt; + delete payload.updatedAt; + + await this.tokenModel.updateOne( + { + provider: normalizedProvider, + chainCode: normalizedChainCode, + address: normalizedAddress, + }, + payload, + { + runValidators: true, + } + ); + + if (existing?.id) { + await this.delTokenObjectCache(existing.id); + } + const updated = await this.getTokenDetail( + normalizedProvider, + normalizedChainCode, + normalizedAddress + ); + if (updated) { + await this.setTokenObjectCache(updated); + } + await this.invalidateTokensCacheByPairs([ + { provider: normalizedProvider, chainCode: normalizedChainCode }, + ]); + + return true; + } + + async deleteToken(provider: string, chainCode: string, address: string) { + const normalizedProvider = this.normalizeLowercase(provider); + const normalizedChainCode = this.normalizeLowercase(chainCode); + const normalizedAddress = this.normalizeLowercase(address); + const existing = await this.getTokenDetail( + normalizedProvider, + normalizedChainCode, + normalizedAddress + ); + + await this.tokenModel.deleteOne({ + provider: normalizedProvider, + chainCode: normalizedChainCode, + address: normalizedAddress, + }); + + if (existing?.id) { + await this.delTokenObjectCache(existing.id); + } + await this.invalidateTokensCacheByPairs([ + { provider: normalizedProvider, chainCode: normalizedChainCode }, + ]); + + return true; + } + + private buildListFilter( + query: TokenQueryDTO, + pagination: PaginationDTO + ): FilterQuery { + const filter: FilterQuery = {}; + + if (pagination.cursor) { + filter._id = { $lt: pagination.cursor }; + } + if (query.provider) { + filter.provider = this.normalizeLowercase(query.provider); + } + if (query.chainCode) { + filter.chainCode = this.normalizeLowercase(query.chainCode); + } + if (query.address) { + filter.address = this.normalizeLowercase(query.address); + } + if (typeof query.isActive === 'boolean') { + filter.isActive = query.isActive; + } + if (query.keyword) { + const keywordRegex = new RegExp( + this.escapeRegex(query.keyword.trim()), + 'i' + ); + filter.$or = [ + { symbol: keywordRegex }, + { name: keywordRegex }, + { address: keywordRegex }, + ]; + } + + return filter; + } + + private normalizeLowercase(input?: string): string { + return String(input || '') + .trim() + .toLowerCase(); + } + + private escapeRegex(input: string): string { + return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + private buildTokenQueryCacheParams( + query: TokenQueryDTO, + page: number, + pageSize: number + ): SwapTokenQueryCacheKeyParams | null { + const chainCode = this.normalizeLowercase(query.chainCode); + if (!chainCode) { + return null; + } + + return { + chainCode, + provider: query.provider + ? this.normalizeLowercase(query.provider) + : undefined, + keyword: query.keyword ? String(query.keyword).trim() : undefined, + isActive: + typeof query.isActive === 'boolean' + ? Boolean(query.isActive) + : undefined, + page, + pageSize, + }; + } + + private async queryTokenPageFromDB( + filters: FilterQuery, + page: number, + pageSize: number + ): Promise<{ result: Token[]; total: number; ids: string[] }> { + const skip = (page - 1) * pageSize; + const [total, result] = await Promise.all([ + this.tokenModel.countDocuments(filters), + this.tokenModel.find(filters, SwapTokenModelProjection, { + skip, + limit: pageSize, + sort: { createdAt: -1 }, + }), + ]); + + const ids = (result || []) + .map(token => String(token?.id || '').trim()) + .filter(Boolean); + + return { result, total, ids }; + } + + private async setTokenObjectCaches(tokens: Token[]): Promise { + if (!Array.isArray(tokens) || tokens.length <= 0) { + return; + } + + await Promise.all(tokens.map(token => this.setTokenObjectCache(token))); + } + + private async setTokenObjectCache(token: Token): Promise { + if (!this.redisCacheClient || !token?.id) { + return; + } + + try { + // 随机过期时间 + 0到10 分钟左右 + const ttlSeconds = + TOKEN_OBJECT_REDIS_CACHE_TTL_SECONDS + Math.floor(Math.random() * 600); + await this.redisCacheClient.setSwapTokenObject( + token.id, + token, + ttlSeconds + ); + } catch (error) { + console.warn('[swap.token.service] set token object cache failed', error); + } + } + + private async delTokenObjectCache(tokenID?: string): Promise { + if (!this.redisCacheClient || !tokenID) { + return; + } + + try { + await this.redisCacheClient.delSwapTokenObject(tokenID); + } catch (error) { + console.warn( + '[swap.token.service] delete token object cache failed', + error + ); + } + } + + private async getTokenQueryCache( + params: SwapTokenQueryCacheKeyParams + ): Promise<{ ids: string[]; total: number } | null> { + if (!this.redisCacheClient) { + return null; + } + + try { + const cached = await this.redisCacheClient.getSwapTokenQueryResult( + params + ); + if (!cached || !Array.isArray(cached.ids)) { + return null; + } + return { + ids: cached.ids.map(id => String(id || '').trim()).filter(Boolean), + total: Number(cached.total) || 0, + }; + } catch (error) { + console.warn('[swap.token.service] get token query cache failed', error); + return null; + } + } + + private async setTokenQueryCache( + params: SwapTokenQueryCacheKeyParams, + ids: string[], + total: number + ): Promise { + if (!this.redisCacheClient) { + return; + } + + try { + await this.redisCacheClient.setSwapTokenQueryResult( + params, + { + ids, + total, + }, + TOKEN_QUERY_REDIS_CACHE_TTL_SECONDS + ); + } catch (error) { + console.warn('[swap.token.service] set token query cache failed', error); + } + } + + private async getTokensByIDs(ids: string[]): Promise { + const normalizedIDs = (ids || []) + .map(id => String(id || '').trim()) + .filter(Boolean); + if (normalizedIDs.length <= 0) { + return []; + } + + const tokenMap = new Map(); + const missedIDs: string[] = []; + + if (this.redisCacheClient) { + try { + const cached = await this.redisCacheClient.mgetSwapTokenObjects( + normalizedIDs + ); + for (const id of normalizedIDs) { + const item = cached.get(id); + if (item && this.isTokenRecordComplete(item)) { + tokenMap.set(id, item); + continue; + } + missedIDs.push(id); + } + } catch (error) { + console.warn( + '[swap.token.service] get token object cache failed', + error + ); + missedIDs.push(...normalizedIDs); + } + } else { + missedIDs.push(...normalizedIDs); + } + + if (missedIDs.length > 0) { + const dbRecords = await this.tokenModel.find( + { id: { $in: missedIDs } }, + SwapTokenModelProjection + ); + + for (const record of dbRecords || []) { + const tokenID = String(record.id || '').trim(); + if (!tokenID) { + continue; + } + tokenMap.set(tokenID, record); + } + + await this.setTokenObjectCaches(dbRecords); + } + + return normalizedIDs + .map(id => tokenMap.get(id)) + .filter((item): item is Token => Boolean(item)); + } + + private async invalidateTokensCacheByPairs( + pairs: Array<{ provider: string; chainCode: string }> + ) { + if (!this.redisCacheClient) { + return; + } + + const dedupedPairs = Array.from( + new Set( + (pairs || []).map( + pair => + `${this.normalizeLowercase( + pair.chainCode + )}:${this.normalizeLowercase(pair.provider)}` + ) + ) + ) + .map(pair => { + const [chainCode, provider] = pair.split(':'); + return { chainCode, provider }; + }) + .filter(pair => pair.chainCode && pair.provider); + + const targetPairs = Array.from( + new Set( + dedupedPairs.flatMap(pair => [ + `${pair.chainCode}:all`, + `${pair.chainCode}:${pair.provider}`, + ]) + ) + ); - async getNetworks() {} + try { + await Promise.all( + targetPairs.map(item => { + const [chainCode, provider] = item.split(':'); + return this.redisCacheClient.delSwapTokenQueries( + chainCode, + provider === 'all' ? undefined : provider + ); + }) + ); + } catch (error) { + console.warn( + '[swap.token.service] invalidate tokens cache failed', + error + ); + } + } - async getToken() {} + private isTokenRecordComplete(token: Token): boolean { + return Boolean( + String(token?.provider || '').trim() && + String(token?.chainCode || '').trim() && + String(token?.address || '').trim() && + String(token?.symbol || '').trim() + ); + } } diff --git a/src/filter/default.filter.ts b/src/filter/default.filter.ts index 3b0bff1..d9585e1 100644 --- a/src/filter/default.filter.ts +++ b/src/filter/default.filter.ts @@ -9,15 +9,20 @@ export class DefaultErrorFilter { NODE_ENV: string; async catch(err: MidwayHttpError, ctx: Context) { - const status = err.status; + const status = this.resolveStatus(err); + const message = err.message; /** handle HTTP Error */ const i18nService = await ctx.requestContext.getAsync( i18nServiceBasic.MidwayI18nService ); + const statusKey = `error__http_${status}`; + const translatedStatus = i18nService.translate(statusKey); + const fallback = i18nService.translate('error__common_unknown'); const statusMessage = - i18nService.translate(`error__http_${status}`) ?? - i18nService.translate('error__common_unknown'); + translatedStatus && translatedStatus !== statusKey + ? translatedStatus + : fallback; const shownMessage = `${status}: ${statusMessage}`; /** handle HTTP Error */ @@ -29,7 +34,12 @@ export class DefaultErrorFilter { return { code: status, message: shownMessage, - data: null, + data: message, }; } + + private resolveStatus(err: MidwayHttpError): number { + const status = Number((err as { status?: unknown })?.status); + return Number.isFinite(status) && status > 0 ? status : 500; + } } diff --git a/src/middleware/response-wrapper.middleware.ts b/src/middleware/response-wrapper.middleware.ts index d7abc38..8fdc325 100644 --- a/src/middleware/response-wrapper.middleware.ts +++ b/src/middleware/response-wrapper.middleware.ts @@ -9,6 +9,9 @@ export class ResponseWrapperMiddleware resolve() { return async (ctx: Context, next: NextFunction) => { const result = await next(); + if (ctx.response.get('Content-Type')?.includes('text/event-stream')) { + return; + } const i18nService = await ctx.requestContext.getAsync( i18nServiceBasic.MidwayI18nService ); diff --git a/src/schedule/openocean.schedule.ts b/src/schedule/openocean.schedule.ts new file mode 100644 index 0000000..b7f5dba --- /dev/null +++ b/src/schedule/openocean.schedule.ts @@ -0,0 +1,162 @@ +import { Inject } from '@midwayjs/core'; +import { IProcessor, Processor } from '@midwayjs/bull'; + +import { TokenService } from '../entity/token/token.service'; +import { ProviderManager } from '../thirdparty/swap/provider-manager'; +import { + OPENOCEAN_PROVIDER, + Token, +} from '../thirdparty/swap/provider.interface'; + +// OpenOcean 代币同步任务调度周期(默认每 30 分钟) +const OPENOCEAN_TOKEN_SYNC_CRON = + process.env.OPENOCEAN_TOKEN_SYNC_CRON || '*/30 * * * *'; + +// 定时拉取 OpenOcean 全链代币列表,并同步到本地 SwapToken 实体表 +@Processor('openoceanTokenSync', { + repeat: { + cron: OPENOCEAN_TOKEN_SYNC_CRON, + }, + removeOnComplete: true, + removeOnFail: 20, +}) +export class OpenOceanSchedule implements IProcessor { + @Inject() + providerManager: ProviderManager; + + @Inject() + tokenService: TokenService; + + // 调度入口:按链逐个拉取 token,失败链路记录后继续处理其他链 + async execute() { + const startedAt = Date.now(); + + const networks = await this.providerManager.getNetworks(OPENOCEAN_PROVIDER); + const chainCodes = Array.from( + new Set( + (networks || []) + .map(network => + String(network.chainCode || '') + .trim() + .toLowerCase() + ) + .filter(Boolean) + ) + ); + + let syncedChainCount = 0; + let failedChainCount = 0; + let upsertedTokenCount = 0; + + for (const chainCode of chainCodes) { + try { + const tokens = await this.providerManager.getTokenList( + OPENOCEAN_PROVIDER, + chainCode + ); + upsertedTokenCount += await this.syncChainTokens(chainCode, tokens); + syncedChainCount += 1; + } catch (error) { + failedChainCount += 1; + console.error( + `[schedule.openoceanTokenSync] sync chain failed: provider=${OPENOCEAN_PROVIDER}, chainCode=${chainCode}`, + error + ); + } + } + + console.info( + `[schedule.openoceanTokenSync] done: provider=${OPENOCEAN_PROVIDER}, chainTotal=${ + chainCodes.length + }, chainSuccess=${syncedChainCount}, chainFailed=${failedChainCount}, upsertedTokenCount=${upsertedTokenCount}, elapsedMs=${ + Date.now() - startedAt + }` + ); + } + + // 同步单条链的 token:存在则更新,不存在则创建(带重复键兜底) + private async syncChainTokens( + chainCode: string, + tokens: Token[] + ): Promise { + const now = new Date(); + let upserted = 0; + + for (const token of tokens || []) { + const address = String(token.address || '') + .trim() + .toLowerCase(); + const symbol = String(token.symbol || '').trim(); + if (!address || !symbol) { + continue; + } + + const upsertPayload = { + symbol, + name: String(token.name || '').trim() || undefined, + decimals: token.decimals, + logoURI: String(token.logoURI || '').trim() || undefined, + isActive: token.isActive !== false, + raw: this.normalizeRaw(token.raw), + syncedAt: token.syncedAt || now, + }; + + const existingToken = await this.tokenService.getTokenDetail( + OPENOCEAN_PROVIDER, + chainCode, + address + ); + + // 已存在记录:直接更新 + if (existingToken) { + await this.tokenService.updateToken( + OPENOCEAN_PROVIDER, + chainCode, + address, + upsertPayload + ); + upserted += 1; + continue; + } + + try { + // 不存在记录:先尝试创建 + await this.tokenService.createToken({ + provider: OPENOCEAN_PROVIDER, + chainCode, + address, + ...upsertPayload, + }); + } catch (error) { + if (!this.isDuplicateKeyError(error)) { + throw error; + } + + // 并发场景下可能命中唯一键冲突,回退为更新 + await this.tokenService.updateToken( + OPENOCEAN_PROVIDER, + chainCode, + address, + upsertPayload + ); + } + + upserted += 1; + } + + return upserted; + } + + private isDuplicateKeyError(error: unknown): boolean { + const code = Number((error as { code?: unknown } | undefined)?.code); + return code === 11000; + } + + // 统一处理 raw 字段,避免写入非对象值 + private normalizeRaw(raw?: Record) { + if (!raw || typeof raw !== 'object') { + return undefined; + } + return raw; + } +} diff --git a/src/thirdparty/swap/provider-manager.ts b/src/thirdparty/swap/provider-manager.ts new file mode 100644 index 0000000..f52a99f --- /dev/null +++ b/src/thirdparty/swap/provider-manager.ts @@ -0,0 +1,132 @@ +import { Init, Inject, Provide } from '@midwayjs/core'; +import { ResParamsError } from '../../base/reply'; +import { OpenOceanAdapter } from './providers/openocean/openocean'; +import { + BuildTxRequest, + BuildTxResult, + ISwapProvider, + OPENOCEAN_PROVIDER, + QuoteAcrossProvidersResult, + QuoteRequest, + QuoteResult, + Network, + Token, +} from './provider.interface'; + +@Provide() +export class ProviderManager { + @Inject() + openOceanAdapter: OpenOceanAdapter; + + private readonly providers = new Map(); + + @Init() + async init() { + if (this.openOceanAdapter) { + this.registerProvider(this.openOceanAdapter); + } + } + + registerProvider(provider: ISwapProvider) { + const providerKey = this.normalizeProvider(provider.provider); + this.providers.set(providerKey, provider); + } + + getSupportedProviders(): string[] { + return Array.from(this.providers.keys()); + } + + async getNetworks(provider: string): Promise { + const providerInstance = this.getProvider(provider); + return providerInstance.getNetworks(); + } + + async getTokenList(provider: string, chainCode: string): Promise { + const providerInstance = this.getProvider(provider); + return providerInstance.getTokenList(chainCode); + } + + async quote(provider: string, request: QuoteRequest): Promise { + const providerInstance = this.getProvider(provider); + return providerInstance.quote(request); + } + + async buildTx( + provider: string, + request: BuildTxRequest + ): Promise { + const providerInstance = this.getProvider(provider); + return providerInstance.buildTx(request); + } + + async quoteAcrossProviders( + providers: string[], + request: QuoteRequest + ): Promise { + const providerList = this.normalizeProviders(providers); + const settledResults = await Promise.allSettled( + providerList.map(async provider => { + const quote = await this.quote(provider, request); + return { + provider, + quote, + }; + }) + ); + + const quotes: QuoteResult[] = []; + const failedProviders: QuoteAcrossProvidersResult['failedProviders'] = []; + + for (const settledResult of settledResults) { + if (settledResult.status === 'fulfilled') { + quotes.push(settledResult.value.quote); + continue; + } + + const failedProvider = this.unwrapProviderName(settledResult.reason); + failedProviders.push({ + provider: failedProvider, + errorCode: 'SWAP_PROVIDER_UNAVAILABLE', + message: String(settledResult.reason || ''), + }); + } + + return { + quotes, + failedProviders, + }; + } + + private getProvider(provider: string): ISwapProvider { + const providerKey = this.normalizeProvider(provider); + const providerInstance = this.providers.get(providerKey); + + if (!providerInstance) { + throw ResParamsError(`provider not registered: ${providerKey}`); + } + + return providerInstance; + } + + private normalizeProvider(provider: string): string { + const providerKey = String(provider || '') + .trim() + .toLowerCase(); + return providerKey || OPENOCEAN_PROVIDER; + } + + private normalizeProviders(providers: string[]): string[] { + const normalized = (providers || []) + .map(provider => this.normalizeProvider(provider)) + .filter(Boolean); + const deduped = Array.from(new Set(normalized)); + return deduped.length > 0 ? deduped : [OPENOCEAN_PROVIDER]; + } + + private unwrapProviderName(error: unknown): string { + if (error instanceof Error) { + return String(error.message || OPENOCEAN_PROVIDER); + } + return OPENOCEAN_PROVIDER; + } +} diff --git a/src/thirdparty/swap/provider.errors.ts b/src/thirdparty/swap/provider.errors.ts new file mode 100644 index 0000000..b661ab2 --- /dev/null +++ b/src/thirdparty/swap/provider.errors.ts @@ -0,0 +1,19 @@ +export type SwapProviderErrorCode = + | 'SWAP_INVALID_PARAM' + | 'SWAP_PROVIDER_TIMEOUT' + | 'SWAP_PROVIDER_UNAVAILABLE' + | 'SWAP_QUOTE_EMPTY' + | 'SWAP_BUILD_TX_FAILED'; + +export class SwapProviderError extends Error { + name: string; + message: string; + code: SwapProviderErrorCode; + + constructor(code: SwapProviderErrorCode, message: string) { + super(); + this.name = 'SwapProviderError'; + this.message = message; + this.code = code; + } +} diff --git a/src/thirdparty/swap/provider.interface.ts b/src/thirdparty/swap/provider.interface.ts new file mode 100644 index 0000000..be9f47f --- /dev/null +++ b/src/thirdparty/swap/provider.interface.ts @@ -0,0 +1,86 @@ +import { SwapProviderErrorCode } from './provider.errors'; + +export const OPENOCEAN_PROVIDER = 'openocean'; + +export interface Network { + provider: string; + chainCode: string; + chainId: string; + nativeTokenAddress: string; + chainName?: string; + isActive?: boolean; + syncedAt?: Date; +} + +export interface Token { + provider: string; + chainCode: string; + address: string; + symbol: string; + name?: string; + decimals?: number; + logoURI?: string; + isActive?: boolean; + syncedAt?: Date; + raw?: Record; +} + +export interface QuoteRequest { + chainCode: string; + inTokenAddress: string; + outTokenAddress: string; + amountDecimals: string; + slippage?: string | number; + account?: string; + gasPriceDecimals: string; + enabledDexIds?: string; + disabledDexIds?: string; +} + +export interface QuoteResult { + provider: string; + chainCode: string; + inAmount: string; + outAmount: string; + minOutAmount?: string; + estimatedGas?: string; + gasPrice?: string; + route?: unknown; + raw?: Record; +} + +export interface BuildTxRequest extends QuoteRequest { + receiver?: string; + referrer?: string; +} + +export interface BuildTxResult { + provider: string; + chainCode: string; + tx: { + to: string; + data: string; + value: string; + gasLimit?: string; + gasPrice?: string; + chainId?: string; + nonce?: string; + }; +} + +export interface QuoteAcrossProvidersResult { + quotes: QuoteResult[]; + failedProviders: Array<{ + provider: string; + errorCode: SwapProviderErrorCode; + message: string; + }>; +} + +export interface ISwapProvider { + provider: string; + getNetworks(): Promise; + getTokenList(chainCode: string): Promise; + quote(request: QuoteRequest): Promise; + buildTx(request: BuildTxRequest): Promise; +} diff --git a/src/thirdparty/swap/providers/openocean/openocean.ts b/src/thirdparty/swap/providers/openocean/openocean.ts new file mode 100644 index 0000000..3c9c0e6 --- /dev/null +++ b/src/thirdparty/swap/providers/openocean/openocean.ts @@ -0,0 +1,893 @@ +import { Provide } from '@midwayjs/core'; + +import { + BuildTxRequest, + BuildTxResult, + ISwapProvider, + OPENOCEAN_PROVIDER, + QuoteRequest, + QuoteResult, + Network, + Token, +} from '../../provider.interface'; +import { SwapProviderError } from '../../provider.errors'; + +interface OpenOceanAdapterOptions { + baseUrl?: string; + timeoutMs?: number; + retryTimes?: number; + retryDelayMs?: number; + fetcher?: FetchLike; + log?: LogLike; + metric?: MetricLike; +} + +interface FetchRequestInitLike { + method?: string; + headers?: Record; + body?: string; +} + +interface FetchResponseLike { + ok: boolean; + status: number; + json: () => Promise; + text?: () => Promise; +} + +type FetchLike = ( + url: string, + init?: FetchRequestInitLike +) => Promise; + +type LogLike = Pick; + +type MetricLike = ( + name: string, + value: number, + tags?: Record +) => void; + +const DEFAULT_BASE_URL = 'https://open-api.openocean.finance'; +const DEFAULT_TIMEOUT_MS = 10000; +const DEFAULT_RETRY_TIMES = 1; +const DEFAULT_RETRY_DELAY_MS = 80; + +interface SupportedNetworkSeed { + chainCode: string; + chainId?: string; + nativeTokenAddress?: string; + chainName: string; +} + +// Source: OpenOcean supported chains docs. +// This list is intentionally hardcoded because OpenOcean does not provide +// a stable public API endpoint for chain discovery. +const OPENOCEAN_SUPPORTED_NETWORKS: SupportedNetworkSeed[] = [ + { + chainCode: 'eth', + chainId: '1', + nativeTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + chainName: 'Ethereum', + }, + { + chainCode: 'bsc', + chainId: '56', + nativeTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + chainName: 'BNB Chain', + }, + { + chainCode: 'base', + chainId: '8453', + nativeTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + chainName: 'Base', + }, + { + chainCode: 'polygon', + chainId: '137', + nativeTokenAddress: '0x0000000000000000000000000000000000001010', + chainName: 'Polygon', + }, + { + chainCode: 'arbitrum', + chainId: '42161', + nativeTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + chainName: 'Arbitrum', + }, + { + chainCode: 'linea', + chainId: '59144', + nativeTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + chainName: 'Linea', + }, + { + chainCode: 'hyperevm', + chainId: '999', + nativeTokenAddress: '0x0000000000000000000000000000000000000000', + chainName: 'HyperEVM', + }, + { + chainCode: 'avax', + chainId: '43114', + nativeTokenAddress: '0x0000000000000000000000000000000000000000', + chainName: 'Avalanche', + }, + { + chainCode: 'optimism', + chainId: '10', + nativeTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + chainName: 'Optimism', + }, + { + chainCode: 'sonic', + chainId: '146', + nativeTokenAddress: '0x0000000000000000000000000000000000000000', + chainName: 'Sonic', + }, + { + chainCode: 'uni', + chainId: '130', + nativeTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + chainName: 'UniChain', + }, + { + chainCode: 'bera', + chainId: '80094', + nativeTokenAddress: '0x0000000000000000000000000000000000000000', + chainName: 'Berachain', + }, + { + chainCode: 'sei', + chainId: '1329', + nativeTokenAddress: '0x0000000000000000000000000000000000000000', + chainName: 'Sei', + }, + { + chainCode: 'plasma', + chainId: '9745', + nativeTokenAddress: '0x0000000000000000000000000000000000000000', + chainName: 'Plasma', + }, + { + chainCode: 'monad', + chainId: '143', + nativeTokenAddress: '0x0000000000000000000000000000000000000000', + chainName: 'Monad', + }, + { + chainCode: 'injective', + chainId: '1776', + nativeTokenAddress: '0x0000000000000000000000000000000000000000', + chainName: 'Injective', + }, + { + chainCode: 'zksync', + chainId: '324', + nativeTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + chainName: 'zkSync Era', + }, + { + chainCode: 'aurora', + chainId: '1313161554', + nativeTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + chainName: 'Aurora', + }, + { + chainCode: 'cronos', + chainId: '25', + nativeTokenAddress: '0x0000000000000000000000000000000000000000', + chainName: 'Cronos', + }, + { + chainCode: 'harmony', + chainId: '1666600000', + nativeTokenAddress: '0x0000000000000000000000000000000000000000', + chainName: 'Harmony', + }, + { + chainCode: 'kava', + chainId: '2222', + nativeTokenAddress: '0x0000000000000000000000000000000000000000', + chainName: 'Kava', + }, + { + chainCode: 'metis', + chainId: '1088', + nativeTokenAddress: '0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000', + chainName: 'Metis', + }, + { + chainCode: 'celo', + chainId: '42220', + nativeTokenAddress: '0x471EcE3750Da237f93B8E339c536989b8978a438', + chainName: 'Celo', + }, + { + chainCode: 'telos', + chainId: '40', + nativeTokenAddress: '0x0000000000000000000000000000000000000000', + chainName: 'Telos', + }, + { + chainCode: 'polygon_zkevm', + chainId: '1101', + nativeTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + chainName: 'Polygon zkEVM', + }, + { + chainCode: 'xdai', + chainId: '100', + nativeTokenAddress: '0x0000000000000000000000000000000000000000', + chainName: 'Gnosis', + }, + { + chainCode: 'opbnb', + chainId: '204', + nativeTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + chainName: 'opBNB', + }, + { + chainCode: 'mantle', + chainId: '5000', + nativeTokenAddress: '0x0000000000000000000000000000000000000000', + chainName: 'Mantle', + }, + { + chainCode: 'manta', + chainId: '169', + nativeTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + chainName: 'Manta', + }, + { + chainCode: 'scroll', + chainId: '534352', + nativeTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + chainName: 'Scroll', + }, + { + chainCode: 'blast', + chainId: '81457', + nativeTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + chainName: 'Blast', + }, + { + chainCode: 'mode', + chainId: '34443', + nativeTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + chainName: 'Mode', + }, + { + chainCode: 'rootstock', + chainId: '30', + nativeTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + chainName: 'Rootstock', + }, + { + chainCode: 'gravity', + chainId: '1625', + nativeTokenAddress: '0x0000000000000000000000000000000000000000', + chainName: 'Gravity', + }, + { + chainCode: 'ape', + chainId: '33139', + nativeTokenAddress: '0x0000000000000000000000000000000000000000', + chainName: 'Apechain', + }, + { + chainCode: 'flare', + chainId: '14', + nativeTokenAddress: '0x0000000000000000000000000000000000000000', + chainName: 'Flare', + }, + { + chainCode: 'swell', + chainId: '1923', + nativeTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + chainName: 'Swell', + }, + { + chainCode: 'plume', + chainId: '98866', + nativeTokenAddress: '0x0000000000000000000000000000000000000000', + chainName: 'Plume', + }, + { + chainCode: 'tac', + chainId: '239', + nativeTokenAddress: '0x0000000000000000000000000000000000000000', + chainName: 'TAC', + }, + { + chainCode: 'moonriver', + chainId: '1285', + nativeTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + chainName: 'Moonriver', + }, + { + chainCode: 'fantom', + chainId: '250', + nativeTokenAddress: '0x0000000000000000000000000000000000000000', + chainName: 'Fantom', + }, + { + chainCode: 'solana', + chainId: '', + nativeTokenAddress: 'So11111111111111111111111111111111111111112', + chainName: 'Solana', + }, + { chainCode: 'sui', chainId: '', nativeTokenAddress: '', chainName: 'Sui' }, +]; + +@Provide() +export class OpenOceanAdapter implements ISwapProvider { + provider = OPENOCEAN_PROVIDER; + + private readonly baseUrl: string; + + private readonly timeoutMs: number; + + private readonly retryTimes: number; + + private readonly retryDelayMs: number; + + private readonly fetcher: FetchLike | undefined; + + private readonly log: LogLike; + + private readonly metric: MetricLike; + + constructor(options: OpenOceanAdapterOptions = {}) { + this.baseUrl = + options.baseUrl || process.env.OPENOCEAN_BASE_URL || DEFAULT_BASE_URL; + this.timeoutMs = Number( + options.timeoutMs ?? + process.env.OPENOCEAN_TIMEOUT_MS ?? + DEFAULT_TIMEOUT_MS + ); + this.retryTimes = Number( + options.retryTimes ?? + process.env.OPENOCEAN_RETRY_TIMES ?? + DEFAULT_RETRY_TIMES + ); + this.retryDelayMs = Number( + options.retryDelayMs ?? + process.env.OPENOCEAN_RETRY_DELAY_MS ?? + DEFAULT_RETRY_DELAY_MS + ); + this.fetcher = + options.fetcher ?? + ((globalThis as { fetch?: FetchLike }).fetch as FetchLike | undefined); + this.log = options.log || console; + this.metric = options.metric || (() => null); + } + + async getNetworks(): Promise { + const supportedNetworks = OPENOCEAN_SUPPORTED_NETWORKS; + const syncedAt = new Date(); + const result: Network[] = []; + for (const item of supportedNetworks) { + result.push({ + provider: this.provider, + chainCode: item.chainCode, + chainId: item.chainId || '', + nativeTokenAddress: item.nativeTokenAddress || '', + chainName: item.chainName, + isActive: true, + syncedAt, + }); + } + + return result; + } + + async getTokenList(chainCode: string): Promise { + const mappedChain = this.resolveChainCode(chainCode); + const responseData = await this.request({ + apiName: 'tokenList', + path: `/v4/${mappedChain}/tokenList`, + provider: this.provider, + chainCode: mappedChain, + }); + + const now = new Date(); + const result: Token[] = []; + for (const item of this.asArray(responseData)) { + const raw = this.asRecord(item); + const address = this.pickString(raw, ['address']); + const symbol = this.pickString(raw, ['symbol']); + if (!address || !symbol) { + continue; + } + + result.push({ + provider: this.provider, + chainCode: mappedChain, + address: address.toLowerCase(), + symbol, + name: this.pickString(raw, ['name']), + decimals: this.pickNumber(raw, ['decimals']), + logoURI: this.pickString(raw, ['icon', 'logoURI', 'logo']), + isActive: true, + raw, + syncedAt: now, + }); + } + + return result; + } + + async quote(request: QuoteRequest): Promise { + const mappedChain = this.resolveChainCode(request.chainCode); + this.validateQuoteRequest(request); + const amountDecimals = this.resolveAmountDecimals(request); + const gasPriceDecimals = this.resolveGasPriceDecimals(request); + + const responseData = await this.request>({ + apiName: 'quote', + path: `/v4/${mappedChain}/quote`, + query: { + inTokenAddress: request.inTokenAddress, + outTokenAddress: request.outTokenAddress, + amountDecimals, + slippage: request.slippage, + account: request.account, + gasPriceDecimals, + enabledDexIds: request.enabledDexIds, + disabledDexIds: request.disabledDexIds, + }, + provider: this.provider, + chainCode: mappedChain, + }); + + const outAmount = this.pickString(responseData, [ + 'outAmount', + 'outTokenAmount', + 'toTokenAmount', + 'out', + ]); + if (!outAmount) { + throw new SwapProviderError( + 'SWAP_QUOTE_EMPTY', + 'openocean quote response missing outAmount' + ); + } + + return { + provider: this.provider, + chainCode: mappedChain, + inAmount: + this.pickString(responseData, [ + 'inAmount', + 'inTokenAmount', + 'fromTokenAmount', + ]) || amountDecimals, + outAmount, + minOutAmount: this.pickString(responseData, [ + 'minOutAmount', + 'minAmountOut', + ]), + estimatedGas: this.pickString(responseData, [ + 'estimatedGas', + 'estimatedGasAmount', + 'gas', + ]), + gasPrice: this.pickString(responseData, ['gasPrice']), + route: responseData.path || responseData.route || responseData.dexes, + raw: responseData, + }; + } + + async buildTx(request: BuildTxRequest): Promise { + const mappedChain = this.resolveChainCode(request.chainCode); + this.validateQuoteRequest(request); + this.validateSwapRequest(request); + const amountDecimals = this.resolveAmountDecimals(request); + const gasPriceDecimals = this.resolveGasPriceDecimals(request); + + const responseData = await this.request>({ + apiName: 'swap', + path: `/v4/${mappedChain}/swap`, + query: { + inTokenAddress: request.inTokenAddress, + outTokenAddress: request.outTokenAddress, + amountDecimals, + slippage: request.slippage, + account: request.account, + receiver: request.receiver, + referrer: request.referrer, + gasPriceDecimals, + }, + provider: this.provider, + chainCode: mappedChain, + }); + + const txRecord = this.resolveTxPayload(responseData); + const to = this.pickString(txRecord, ['to']); + const data = this.pickString(txRecord, ['data']); + if (!to || !data) { + throw new SwapProviderError( + 'SWAP_BUILD_TX_FAILED', + 'openocean buildTx response missing tx.to or tx.data' + ); + } + + return { + provider: this.provider, + chainCode: mappedChain, + tx: { + to, + data, + value: this.pickString(txRecord, ['value']) || '0', + gasLimit: this.pickString(txRecord, ['gas', 'gasLimit']), + gasPrice: this.pickString(txRecord, ['gasPrice']), + chainId: this.pickString(txRecord, ['chainId']), + }, + }; + } + + private async request(options: { + apiName: string; + path: string; + method?: 'GET' | 'POST'; + query?: Record; + body?: Record; + provider?: string; + chainCode?: string; + }): Promise { + const url = this.buildUrl(options.path, options.query); + const method = options.method || 'GET'; + let lastError: unknown; + let attempt = 0; + + while (attempt <= this.retryTimes) { + attempt += 1; + const startedAt = Date.now(); + this.log.info?.( + `[swap.thirdparty.request] provider=${ + options.provider || this.provider + } api=${options.apiName} chainCode=${ + options.chainCode || '' + } attempt=${attempt} timeoutMs=${this.timeoutMs}` + ); + + try { + const response = await this.fetchWithTimeout(url, { + method, + headers: { + 'content-type': 'application/json', + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + + if (!response.ok) { + const responseBody = await this.readResponseBody(response); + const httpError = this.mapHttpError( + response.status, + responseBody, + options.provider + ); + throw httpError; + } + + const responseBody = await response.json(); + const data = this.unwrapData(responseBody) as T; + const payload = this.asRecord(data); + if (this.isProviderBusinessError(payload)) { + throw this.toProviderBusinessError(payload); + } + const latencyMs = Date.now() - startedAt; + this.metric('swap_provider_latency_ms', latencyMs, { + provider: options.provider || this.provider, + apiName: options.apiName, + status: 'success', + }); + this.log.info?.( + `[swap.thirdparty.response] provider=${ + options.provider || this.provider + } api=${options.apiName} latencyMs=${latencyMs} status=success` + ); + return data; + } catch (error) { + lastError = error; + const providerError = this.toProviderError(error, options.provider); + const shouldRetry = + providerError.code === 'SWAP_PROVIDER_TIMEOUT' && + attempt <= this.retryTimes; + const latencyMs = Date.now() - startedAt; + + this.metric('swap_provider_requests_total', 1, { + provider: options.provider || this.provider, + apiName: options.apiName, + status: 'error', + errorCode: providerError.code, + }); + this.log.warn?.( + `[swap.thirdparty.error] provider=${ + options.provider || this.provider + } api=${ + options.apiName + } latencyMs=${latencyMs} attempt=${attempt} retry=${shouldRetry} code=${ + providerError.code + } message=${providerError.message}` + ); + + if (!shouldRetry) { + throw providerError; + } + + await this.wait(this.retryDelayMs * attempt); + } + } + + throw this.toProviderError(lastError, options.provider); + } + + private async fetchWithTimeout( + url: string, + init: FetchRequestInitLike + ): Promise { + if (!this.fetcher) { + throw new SwapProviderError( + 'SWAP_PROVIDER_UNAVAILABLE', + 'fetch API is unavailable in current runtime' + ); + } + + const timeoutError = new SwapProviderError( + 'SWAP_PROVIDER_TIMEOUT', + `openocean request timeout after ${this.timeoutMs}ms` + ); + + let timeoutHandle: ReturnType | null = null; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => reject(timeoutError), this.timeoutMs); + }); + + try { + return await Promise.race([this.fetcher(url, init), timeoutPromise]); + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + } + } + + private buildUrl(path: string, query?: Record): string { + const url = new URL(path, this.baseUrl); + if (query) { + for (const [key, value] of Object.entries(query)) { + if (value === null || value === undefined || value === '') { + continue; + } + url.searchParams.set(key, String(value)); + } + } + return url.toString(); + } + + private validateQuoteRequest(request: QuoteRequest) { + const amountDecimals = this.pickString( + request as unknown as Record, + ['amountDecimals'] + ); + const gasPriceDecimals = this.pickString( + request as unknown as Record, + ['gasPriceDecimals'] + ); + if ( + !request.chainCode || + !request.inTokenAddress || + !request.outTokenAddress || + !amountDecimals || + !gasPriceDecimals + ) { + throw new SwapProviderError( + 'SWAP_INVALID_PARAM', + 'missing required fields for openocean request: chainCode, inTokenAddress, outTokenAddress, amountDecimals, gasPriceDecimals' + ); + } + } + + private resolveChainCode(chainCode: string): string { + const normalized = String(chainCode || '').trim(); + if (!normalized) { + throw new SwapProviderError( + 'SWAP_INVALID_PARAM', + 'chainCode is required' + ); + } + return normalized; + } + + private resolveAmountDecimals(request: QuoteRequest): string | undefined { + return this.pickString(request as unknown as Record, [ + 'amountDecimals', + ]); + } + + private resolveGasPriceDecimals(request: QuoteRequest): string | undefined { + return this.pickString(request as unknown as Record, [ + 'gasPriceDecimals', + ]); + } + + private validateSwapRequest(request: BuildTxRequest) { + const account = this.pickString( + request as unknown as Record, + ['account'] + ); + if (!account) { + throw new SwapProviderError( + 'SWAP_INVALID_PARAM', + 'missing required fields for openocean swap request: account' + ); + } + } + + private unwrapData(responseBody: unknown): unknown { + const raw = this.asRecord(responseBody); + if (raw?.data !== undefined) { + return raw.data; + } + return responseBody; + } + + private async readResponseBody(response: FetchResponseLike): Promise { + try { + if (response.text) { + return await response.text(); + } + return JSON.stringify(await response.json()); + } catch (error) { + return `failed_to_read_response_body: ${String(error)}`; + } + } + + private mapHttpError( + status: number, + body: string, + provider?: string + ): SwapProviderError { + if (status === 408 || status === 504) { + return new SwapProviderError( + 'SWAP_PROVIDER_TIMEOUT', + `provider timeout: ${body}` + ); + } + + if (status >= 500 || status === 429) { + return new SwapProviderError( + 'SWAP_PROVIDER_UNAVAILABLE', + `provider unavailable(${status}): ${body}` + ); + } + + return new SwapProviderError( + 'SWAP_INVALID_PARAM', + `provider bad request(${status}): ${body}` + ); + } + + private toProviderError( + error: unknown, + provider?: string + ): SwapProviderError { + if (error instanceof SwapProviderError) { + return error; + } + return new SwapProviderError( + 'SWAP_PROVIDER_UNAVAILABLE', + `provider request failed: ${String(error)}` + ); + } + + private isProviderBusinessError(payload: Record): boolean { + if (!payload || Object.keys(payload).length <= 0) { + return false; + } + + const code = this.pickNumber(payload, ['code', 'status']); + if (!Number.isFinite(code)) { + return false; + } + + return code !== 0 && code !== 200; + } + + private toProviderBusinessError( + payload: Record + ): SwapProviderError { + const code = this.pickNumber(payload, ['code', 'status']) || 500; + const message = + this.pickString(payload, ['error', 'message', 'msg']) || + `provider business error(${code})`; + + if (code === 408 || code === 504) { + return new SwapProviderError('SWAP_PROVIDER_TIMEOUT', message); + } + + if (code >= 500 || code === 429) { + return new SwapProviderError('SWAP_PROVIDER_UNAVAILABLE', message); + } + + return new SwapProviderError('SWAP_INVALID_PARAM', message); + } + + private asRecord(value: unknown): Record { + if (!value || typeof value !== 'object') { + return {}; + } + return value as Record; + } + + private asArray(value: unknown): unknown[] { + if (Array.isArray(value)) { + return value; + } + const raw = this.asRecord(value); + const tokenList = raw.tokenList; + if (Array.isArray(tokenList)) { + return tokenList; + } + const tokens = raw.tokens; + if (Array.isArray(tokens)) { + return tokens; + } + return []; + } + + private pickString( + source: Record, + keys: string[] + ): string | undefined { + for (const key of keys) { + const value = source[key]; + if (value === null || value === undefined) { + continue; + } + const result = String(value).trim(); + if (result) { + return result; + } + } + return undefined; + } + + private pickNumber( + source: Record, + keys: string[] + ): number | undefined { + for (const key of keys) { + const value = source[key]; + if (value === null || value === undefined) { + continue; + } + const parsed = Number(value); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + return undefined; + } + + private resolveTxPayload( + responseData: Record + ): Record { + const directTx = this.asRecord(responseData.tx); + if (Object.keys(directTx).length > 0) { + return directTx; + } + + const nestedData = this.asRecord(responseData.data); + if (Object.keys(nestedData).length > 0) { + return nestedData; + } + + return responseData; + } + + private async wait(ms: number): Promise { + await new Promise(resolve => setTimeout(resolve, ms)); + } +} diff --git a/test/chain/evm.test.ts b/test/chain/evm.test.ts new file mode 100644 index 0000000..19d5cd3 --- /dev/null +++ b/test/chain/evm.test.ts @@ -0,0 +1,85 @@ +import { + buildUnsignedTx, + estimateGasLimit, + getFeeData, + getNonce, +} from '../../src/chain/evm'; + +const providerFactory = () => + ({ + estimateGas: jest.fn(async () => BigInt(21000)), + getFeeData: jest.fn(async () => ({ + gasPrice: BigInt(1000000000), + maxFeePerGas: BigInt(2000000000), + maxPriorityFeePerGas: BigInt(100000000), + })), + getTransactionCount: jest.fn(async () => 7), + getNetwork: jest.fn(async () => ({ chainId: BigInt(56) })), + } as any); + +describe('evm chain helpers', () => { + it('estimateGasLimit returns buffered bigint', async () => { + const gas = await estimateGasLimit({ + rpcUrl: 'https://rpc.mock', + providerFactory, + to: '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d', + from: '0x9116780aEf4B376499358fa7dEeC00cCF64fA801', + bufferBps: 12000, + }); + expect(gas).toBe(BigInt(25200)); + }); + + it('getFeeData returns normalized fee data', async () => { + const data = await getFeeData({ + rpcUrl: 'https://rpc.mock', + providerFactory, + }); + expect(data).toMatchObject({ + supportsEip1559: true, + gasPrice: BigInt(1000000000), + }); + }); + + it('getNonce returns pending nonce', async () => { + const nonce = await getNonce({ + rpcUrl: 'https://rpc.mock', + providerFactory, + address: '0x9116780aEf4B376499358fa7dEeC00cCF64fA801', + }); + expect(nonce).toBe(7); + }); + + it('buildUnsignedTx builds EIP-1559 unsigned tx', async () => { + const tx = await buildUnsignedTx({ + rpcUrl: 'https://rpc.mock', + providerFactory, + to: '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d', + from: '0x9116780aEf4B376499358fa7dEeC00cCF64fA801', + data: '0x1234', + value: '0', + gasLimitBufferBps: 12000, + }); + + expect(tx).toMatchObject({ + type: 2, + chainId: 56, + nonce: 7, + gasLimit: '0x6270', + maxFeePerGas: '0x77359400', + maxPriorityFeePerGas: '0x5f5e100', + }); + expect(tx.serialized.startsWith('0x')).toBe(true); + }); + + it('throws readable error when data is not hex', async () => { + await expect( + buildUnsignedTx({ + rpcUrl: 'https://rpc.mock', + providerFactory, + to: '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d', + from: '0x9116780aEf4B376499358fa7dEeC00cCF64fA801', + data: 'not-hex', + }) + ).rejects.toThrow('data must be a hex string'); + }); +}); diff --git a/test/chain/indexer.test.ts b/test/chain/indexer.test.ts new file mode 100644 index 0000000..6c4a105 --- /dev/null +++ b/test/chain/indexer.test.ts @@ -0,0 +1,58 @@ +import { + buildUnsignedTx, + getEvmRpcUrl, + isEvmChainCode, +} from '../../src/chain/indexer'; + +describe('chain indexer', () => { + const providerFactory = () => + ({ + estimateGas: jest.fn(async () => BigInt(21000)), + getFeeData: jest.fn(async () => ({ + gasPrice: BigInt(1000000000), + maxFeePerGas: BigInt(2000000000), + maxPriorityFeePerGas: BigInt(100000000), + })), + getTransactionCount: jest.fn(async () => 8), + getNetwork: jest.fn(async () => ({ chainId: BigInt(56) })), + } as any); + + it('routes evm chainCode to evm builder', async () => { + const tx = await buildUnsignedTx( + 'bsc', + '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d', + '0x9116780aEf4B376499358fa7dEeC00cCF64fA801', + '0x', + '0', + 12000, + { providerFactory } + ); + + expect(tx.gasLimit).toBe('0x6270'); + expect(tx.nonce).toBe(8); + }); + + it('rejects unsupported non-evm chainCode', async () => { + await expect( + buildUnsignedTx( + 'solana', + '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d', + '0x9116780aEf4B376499358fa7dEeC00cCF64fA801' + ) + ).rejects.toThrow('chainCode not supported yet: solana'); + }); + + it('returns false for unsupported chainCode', () => { + expect(isEvmChainCode('sui')).toBe(false); + }); + + it('returns rpc url for configured evm chainCode', () => { + expect(getEvmRpcUrl('bsc')).toBe('https://bsc-dataseed.bnbchain.org'); + }); + + it('throws when evm chainCode rpc is not configured', () => { + expect(() => getEvmRpcUrl('monad')).toThrow( + 'rpc url not configured for chainCode: monad' + ); + }); +}); diff --git a/test/thirdparty/openocean-adapter.test.ts b/test/thirdparty/openocean-adapter.test.ts new file mode 100644 index 0000000..4b491c3 --- /dev/null +++ b/test/thirdparty/openocean-adapter.test.ts @@ -0,0 +1,201 @@ +import { OpenOceanAdapter } from '../../src/thirdparty/swap/providers/openocean/openocean'; +import { OPENOCEAN_PROVIDER } from '../../src/thirdparty/swap/provider.interface'; +import { SwapProviderError } from '../../src/thirdparty/swap/provider.errors'; + +const toJsonResponse = (payload: unknown, status = 200) => ({ + ok: status >= 200 && status < 300, + status, + json: async () => payload, + text: async () => JSON.stringify(payload), +}); + +describe('OpenOceanAdapter', () => { + describe('getNetworks', () => { + it('returns all supported networks with correct structure', async () => { + const adapter = new OpenOceanAdapter(); + const networks = await adapter.getNetworks(); + + expect(networks).toBeDefined(); + expect(Array.isArray(networks)).toBe(true); + expect(networks.length).toBeGreaterThan(0); + + for (const net of networks) { + expect(net).toMatchObject({ + provider: OPENOCEAN_PROVIDER, + chainCode: expect.any(String), + chainId: expect.any(String), + chainName: expect.any(String), + }); + expect(typeof net.nativeTokenAddress).toBe('string'); + expect(net.isActive).toBe(true); + expect(net.syncedAt).toBeInstanceOf(Date); + } + }); + + it('includes known EVM chains (eth, bsc, polygon)', async () => { + const adapter = new OpenOceanAdapter(); + const networks = await adapter.getNetworks(); + const chainCodes = networks.map(n => n.chainCode); + + expect(chainCodes).toContain('eth'); + expect(chainCodes).toContain('bsc'); + expect(chainCodes).toContain('polygon'); + + const eth = networks.find(n => n.chainCode === 'eth'); + expect(eth).toMatchObject({ + provider: OPENOCEAN_PROVIDER, + chainCode: 'eth', + chainId: '1', + chainName: 'Ethereum', + }); + expect(eth?.nativeTokenAddress).toBe( + '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' + ); + + const polygon = networks.find(n => n.chainCode === 'polygon'); + expect(polygon?.nativeTokenAddress).toBe( + '0x0000000000000000000000000000000000001010' + ); + }); + + it('includes Non-EVM chains (solana, sui)', async () => { + const adapter = new OpenOceanAdapter(); + const networks = await adapter.getNetworks(); + + const solana = networks.find(n => n.chainCode === 'solana'); + expect(solana).toBeDefined(); + expect(solana?.chainName).toBe('Solana'); + expect(solana?.nativeTokenAddress).toBe( + 'So11111111111111111111111111111111111111112' + ); + + const sui = networks.find(n => n.chainCode === 'sui'); + expect(sui).toBeDefined(); + expect(sui?.chainName).toBe('Sui'); + }); + }); + + describe('getTokenList', () => { + it('maps provider token list for BSC', async () => { + const adapter = new OpenOceanAdapter({ + fetcher: async url => { + expect(url).toContain('/v4/bsc/tokenList'); + return toJsonResponse({ + code: 200, + data: [ + { + address: '0xABCDEFabcdefABCDEFabcdefABCDEFabcdefABCD', + symbol: 'USDT', + name: 'Tether USD', + decimals: 18, + icon: 'https://example.com/usdt.png', + }, + ], + }); + }, + }); + + const tokens = await adapter.getTokenList('bsc'); + expect(tokens).toHaveLength(1); + expect(tokens[0]).toMatchObject({ + provider: OPENOCEAN_PROVIDER, + chainCode: 'bsc', + address: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + symbol: 'USDT', + name: 'Tether USD', + decimals: 18, + logoURI: 'https://example.com/usdt.png', + isActive: true, + }); + expect(tokens[0].syncedAt).toBeInstanceOf(Date); + expect(tokens[0].raw).toBeDefined(); + }); + + it('maps provider token list for Ethereum', async () => { + const adapter = new OpenOceanAdapter({ + fetcher: async url => { + expect(url).toContain('/v4/eth/tokenList'); + return toJsonResponse({ + code: 200, + data: { + tokenList: [ + { + address: '0x0000000000000000000000000000000000000001', + symbol: 'AAA', + }, + ], + }, + }); + }, + }); + + const tokens = await adapter.getTokenList('eth'); + expect(tokens).toHaveLength(1); + expect(tokens[0]).toMatchObject({ + provider: OPENOCEAN_PROVIDER, + chainCode: 'eth', + address: '0x0000000000000000000000000000000000000001', + symbol: 'AAA', + isActive: true, + }); + }); + }); + + describe('buildTx (swap)', () => { + const bscUsdt = '0x55d398326f99059ff775485246999027b3197955'; + const bscUsdc = '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d'; + const account = '0x9116780aEf4B376499358fa7dEeC00cCF64fA801'; + + it('returns tx with calldata when account is provided', async () => { + const adapter = new OpenOceanAdapter({ + fetcher: async url => { + expect(url).toContain('/v4/bsc/swap'); + return toJsonResponse({ + code: 200, + data: { + tx: { + to: '0x1111111111111111111111111111111111111111', + data: '0xabcdef', + value: '0', + }, + }, + }); + }, + }); + const result = await adapter.buildTx({ + chainCode: 'bsc', + inTokenAddress: bscUsdt, + outTokenAddress: bscUsdc, + amountDecimals: '5000000000000000000', + gasPriceDecimals: '1000000000', + slippage: 1, + account, + }); + + expect(result).toMatchObject({ + provider: OPENOCEAN_PROVIDER, + chainCode: 'bsc', + }); + expect(result.tx.to).toBe('0x1111111111111111111111111111111111111111'); + expect(result.tx.data).toBe('0xabcdef'); + expect(result.tx.value).toBe('0'); + }); + + it('throws invalid param when account is not provided', async () => { + const adapter = new OpenOceanAdapter(); + await expect( + adapter.buildTx({ + chainCode: 'bsc', + inTokenAddress: bscUsdt, + outTokenAddress: bscUsdc, + amountDecimals: '5000000000000000000', + gasPriceDecimals: '1000000000', + slippage: 1, + }) + ).rejects.toMatchObject>({ + name: 'SwapProviderError', + code: 'SWAP_INVALID_PARAM', + }); + }); + }); +}); diff --git a/test/thirdparty/provider-manager.test.ts b/test/thirdparty/provider-manager.test.ts new file mode 100644 index 0000000..ce8a587 --- /dev/null +++ b/test/thirdparty/provider-manager.test.ts @@ -0,0 +1,120 @@ +import { ProviderManager } from '../../src/thirdparty/swap/provider-manager'; +import { + BuildTxRequest, + BuildTxResult, + ISwapProvider, + Network, + QuoteRequest, + QuoteResult, + Token, +} from '../../src/thirdparty/swap/provider.interface'; +import { SwapProviderError } from '../../src/thirdparty/swap/provider.errors'; + +class MockProvider implements ISwapProvider { + provider: string; + + private readonly quoteImpl: (request: QuoteRequest) => Promise; + + constructor( + provider: string, + quoteImpl: (request: QuoteRequest) => Promise + ) { + this.provider = provider; + this.quoteImpl = quoteImpl; + } + + async getNetworks(): Promise { + return []; + } + + async getTokenList(_: string): Promise { + return []; + } + + async quote(request: QuoteRequest): Promise { + return this.quoteImpl(request); + } + + async buildTx(_: BuildTxRequest): Promise { + return { + provider: this.provider, + chainCode: 'eth', + tx: { + to: '0x1', + data: '0x2', + value: '0', + }, + }; + } +} + +describe('ProviderManager', () => { + it('routes quote requests to the matched provider', async () => { + const manager = new ProviderManager(); + manager.registerProvider( + new MockProvider('openocean', async () => ({ + provider: 'openocean', + chainCode: 'eth', + inAmount: '1', + outAmount: '2', + })) + ); + + const result = await manager.quote('openocean', { + chainCode: 'eth', + inTokenAddress: '0xaaa', + outTokenAddress: '0xbbb', + amountDecimals: '1', + gasPriceDecimals: '1', + }); + + expect(result.provider).toBe('openocean'); + expect(result.outAmount).toBe('2'); + }); + + it('throws 404 error when provider is not registered', async () => { + const manager = new ProviderManager(); + + await expect( + manager.quote('missing-provider', { + chainCode: 'eth', + inTokenAddress: '0xaaa', + outTokenAddress: '0xbbb', + amountDecimals: '1', + gasPriceDecimals: '1', + }) + ).rejects.toMatchObject({ status: 404 }); + }); + + it('returns partial success for quoteAcrossProviders', async () => { + const manager = new ProviderManager(); + manager.registerProvider( + new MockProvider('openocean', async () => ({ + provider: 'openocean', + chainCode: 'eth', + inAmount: '1', + outAmount: '2', + })) + ); + manager.registerProvider( + new MockProvider('broken', async () => { + throw new SwapProviderError('SWAP_PROVIDER_TIMEOUT', 'broken'); + }) + ); + + const result = await manager.quoteAcrossProviders(['openocean', 'broken'], { + chainCode: 'eth', + inTokenAddress: '0xaaa', + outTokenAddress: '0xbbb', + amountDecimals: '1', + gasPriceDecimals: '1', + }); + + expect(result.quotes).toHaveLength(1); + expect(result.failedProviders).toHaveLength(1); + expect(result.failedProviders[0]).toMatchObject({ + provider: 'broken', + errorCode: 'SWAP_PROVIDER_UNAVAILABLE', + }); + }); +}); diff --git a/test/user/user.controller.test.ts b/test/user/user.controller.test.ts deleted file mode 100644 index a1107ec..0000000 --- a/test/user/user.controller.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { faker } from '@faker-js/faker'; -import { Application, Framework } from '@midwayjs/koa'; -import { close, createApp, createHttpRequest } from '@midwayjs/mock'; - -describe('test UserController', () => { - - let app: Application; - - beforeAll(async () => { - app = await createApp(); - }); - - afterAll(async () => { - await close(app); - }); - - it('should create user successfully', async () => { - // make request - const result = await createHttpRequest(app).post('/user').send({ - name: faker.person.fullName(), - email: faker.internet.email() - }); - - expect(result.status).toBe(200); - }); -}); diff --git a/test/user/user.entity.test.ts b/test/user/user.entity.test.ts deleted file mode 100644 index c80dc8c..0000000 --- a/test/user/user.entity.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { faker } from '@faker-js/faker'; -import { Application, Framework } from '@midwayjs/koa'; -import { close, createApp } from '@midwayjs/mock'; - -import { UserService } from '../../src/entity/user/user.service'; - -describe('UserEntity', () => { - let app: Application; - let service: UserService; - - beforeAll(async () => { - app = await createApp(); - service = await app - .getApplicationContext() - .getAsync(UserService); - }); - - afterAll(async () => { - await service.userModel.deleteMany(); - await close(app); - }); - - it('should inject user entity into the app', async () => { - expect(service.userModel.find).toBeDefined(); - expect(typeof service.userModel.find).toEqual('function'); - }); - - it('should installed `mongoose-delete` plugin successfully', async () => { - const user = await service.userModel.create({ - name: faker.person.fullName(), - email: faker.internet.email(), - }); - const deleteResult = await service.userModel.deleteById(user.id); - expect(deleteResult.acknowledged).toEqual(true); - const resultWithoutDelete = await service.userModel.findOne({ - id: user.id, - }); - expect(resultWithoutDelete).toBeNull(); - const resultWithDelete = await service.userModel.findOneWithDeleted({ - name: user.name, - }); - expect(resultWithDelete).toBeTruthy(); - expect(resultWithDelete?.id).toEqual(user.id); - }); -}); diff --git a/yarn.lock b/yarn.lock index 1d35ff1..aa33315 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,6 +12,13 @@ __metadata: languageName: node linkType: hard +"@adraffy/ens-normalize@npm:1.10.1": + version: 1.10.1 + resolution: "@adraffy/ens-normalize@npm:1.10.1" + checksum: fdd647604e8fac6204921888aaf5a6bc65eabf0d2921bc5f93b64d01f4bc33ead167c1445f7de05468d05cd92ac31b74c68d2be840c62b79d73693308f885c06 + languageName: node + linkType: hard + "@ampproject/remapping@npm:^2.2.0": version: 2.2.1 resolution: "@ampproject/remapping@npm:2.2.1" @@ -1818,6 +1825,22 @@ __metadata: languageName: node linkType: hard +"@noble/curves@npm:1.2.0": + version: 1.2.0 + resolution: "@noble/curves@npm:1.2.0" + dependencies: + "@noble/hashes": "npm:1.3.2" + checksum: 0bac7d1bbfb3c2286910b02598addd33243cb97c3f36f987ecc927a4be8d7d88e0fcb12b0f0ef8a044e7307d1844dd5c49bb724bfa0a79c8ec50ba60768c97f6 + languageName: node + linkType: hard + +"@noble/hashes@npm:1.3.2": + version: 1.3.2 + resolution: "@noble/hashes@npm:1.3.2" + checksum: 2482cce3bce6a596626f94ca296e21378e7a5d4c09597cbc46e65ffacc3d64c8df73111f2265444e36a3168208628258bbbaccba2ef24f65f58b2417638a20e7 + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -1896,6 +1919,7 @@ __metadata: cross-env: "npm:^7.0.3" dotenv: "npm:^16.3.1" eslint-plugin-import: "npm:^2.29.0" + ethers: "npm:^6.16.0" glob: "npm:^10.3.10" husky: "npm:^9.0.11" jest: "npm:^29.7.0" @@ -2803,6 +2827,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:22.7.5": + version: 22.7.5 + resolution: "@types/node@npm:22.7.5" + dependencies: + undici-types: "npm:~6.19.2" + checksum: cf11f74f1a26053ec58066616e3a8685b6bcd7259bc569738b8f752009f9f0f7f85a1b2d24908e5b0f752482d1e8b6babdf1fbb25758711ec7bb9500bfcd6e60 + languageName: node + linkType: hard + "@types/normalize-package-data@npm:^2.4.0": version: 2.4.4 resolution: "@types/normalize-package-data@npm:2.4.4" @@ -3127,6 +3160,13 @@ __metadata: languageName: node linkType: hard +"aes-js@npm:4.0.0-beta.5": + version: 4.0.0-beta.5 + resolution: "aes-js@npm:4.0.0-beta.5" + checksum: 444f4eefa1e602cbc4f2a3c644bc990f93fd982b148425fee17634da510586fc09da940dcf8ace1b2d001453c07ff042e55f7a0482b3cc9372bf1ef75479090c + languageName: node + linkType: hard + "agent-base@npm:^7.0.2, agent-base@npm:^7.1.0": version: 7.1.0 resolution: "agent-base@npm:7.1.0" @@ -5100,6 +5140,21 @@ __metadata: languageName: node linkType: hard +"ethers@npm:^6.16.0": + version: 6.16.0 + resolution: "ethers@npm:6.16.0" + dependencies: + "@adraffy/ens-normalize": "npm:1.10.1" + "@noble/curves": "npm:1.2.0" + "@noble/hashes": "npm:1.3.2" + "@types/node": "npm:22.7.5" + aes-js: "npm:4.0.0-beta.5" + tslib: "npm:2.7.0" + ws: "npm:8.17.1" + checksum: 65c0ff7016b1592b1961c3b68508902b89fc75e1ad025bab3a722df1b5699fd077551875a7285e65b2d0cfea9a85b2459bb84010a0fa4e4494d231848aee3a73 + languageName: node + linkType: hard + "eventemitter3@npm:^5.0.1": version: 5.0.1 resolution: "eventemitter3@npm:5.0.1" @@ -10024,6 +10079,13 @@ __metadata: languageName: node linkType: hard +"tslib@npm:2.7.0": + version: 2.7.0 + resolution: "tslib@npm:2.7.0" + checksum: 469e1d5bf1af585742128827000711efa61010b699cb040ab1800bcd3ccdd37f63ec30642c9e07c4439c1db6e46345582614275daca3e0f4abae29b0083f04a6 + languageName: node + linkType: hard + "tslib@npm:^1.11.1, tslib@npm:^1.8.1, tslib@npm:^1.9.0": version: 1.14.1 resolution: "tslib@npm:1.14.1" @@ -10249,6 +10311,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~6.19.2": + version: 6.19.8 + resolution: "undici-types@npm:6.19.8" + checksum: 078afa5990fba110f6824823ace86073b4638f1d5112ee26e790155f481f2a868cc3e0615505b6f4282bdf74a3d8caad715fd809e870c2bb0704e3ea6082f344 + languageName: node + linkType: hard + "unique-filename@npm:^3.0.0": version: 3.0.0 resolution: "unique-filename@npm:3.0.0" @@ -10612,6 +10681,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:8.17.1": + version: 8.17.1 + resolution: "ws@npm:8.17.1" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: f4a49064afae4500be772abdc2211c8518f39e1c959640457dcee15d4488628620625c783902a52af2dd02f68558da2868fd06e6fd0e67ebcd09e6881b1b5bfe + languageName: node + linkType: hard + "ws@npm:^7.2.3": version: 7.5.9 resolution: "ws@npm:7.5.9"