├── .dockerignore ├── .eslintrc.js ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── Dockerfile ├── README.md ├── examples-d ├── config.json ├── docker-compose.yaml ├── grafana │ ├── dashboards │ │ └── dashboards.yml │ └── datasources │ │ └── datasources.yaml ├── package-lock.json ├── package.json ├── prometheus.yml └── remote-wallet-manager.ts ├── examples ├── README.md ├── docker-compose.yaml ├── package-lock.json ├── package.json ├── rebalancing.ts └── wallet-manager.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── balances │ ├── cosmos │ │ └── index.ts │ ├── evm │ │ ├── erc20-abi.json │ │ └── index.ts │ ├── index.ts │ ├── solana.ts │ └── sui.ts ├── chain-wallet-manager.ts ├── grpc │ ├── README.md │ ├── client.ts │ ├── out │ │ └── .gitignore │ ├── proto │ │ └── wallet-manager-grpc-service.proto │ ├── service-impl.ts │ └── service.ts ├── i-wallet-manager.ts ├── index.ts ├── price-assistant │ ├── helper.ts │ ├── ondemand-price-feed.ts │ ├── price-feed.ts │ ├── scheduled-price-feed.ts │ └── supported-tokens.config.ts ├── prometheus-exporter.ts ├── rebalance-strategies.ts ├── utils.ts ├── wallet-manager.ts └── wallets │ ├── base-wallet.ts │ ├── cosmos │ ├── cosmoshub.config.ts │ ├── evmos.config.ts │ ├── index.ts │ ├── kujira.config.ts │ ├── osmosis.config.ts │ └── wormchain.config.ts │ ├── evm │ ├── arbitrum.config.ts │ ├── avalanche.config.ts │ ├── base.config.ts │ ├── bsc.config.ts │ ├── celo.config.ts │ ├── ethereum.config.ts │ ├── fantom.config.ts │ ├── index.ts │ ├── klaytn.config.ts │ ├── moonbeam.config.ts │ ├── optimism.config.ts │ ├── polygon.config.ts │ └── sepolia.config.ts │ ├── index.ts │ ├── resource.ts │ ├── solana │ ├── index.ts │ ├── pythnet.config.ts │ └── solana.config.ts │ ├── sui │ ├── index.ts │ └── sui.config.ts │ └── wallet-pool.ts ├── test ├── balances │ └── sui.test.ts ├── integration │ └── wm-rebalancing.test.ts ├── utilities │ ├── common.ts │ └── wallet.ts ├── wallet-manager.test.ts └── wallets │ └── sui │ └── sui.test.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | src/grpc/out/* 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true 6 | }, 7 | extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 8 | overrides: [], 9 | parser: "@typescript-eslint/parser", 10 | parserOptions: { 11 | ecmaVersion: "latest", 12 | sourceType: "module", 13 | }, 14 | plugins: ["@typescript-eslint", "unused-imports"], 15 | rules: { 16 | "unused-imports/no-unused-imports-ts": "error", 17 | "@typescript-eslint/no-empty-function": "off", 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | on: 3 | push: 4 | branches: ["main"] 5 | pull_request: 6 | jobs: 7 | build-and-test: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout repo 11 | uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 18 15 | cache: "npm" 16 | cache-dependency-path: | 17 | ./package-lock.json 18 | - name: npm ci 19 | run: npm ci 20 | - name: typecheck 21 | run: npm run build 22 | - name: test 23 | run: npm test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | lib 4 | npm-debug.log 5 | .vscode 6 | .nyc 7 | .env 8 | .DS_Store 9 | examples/logs/* 10 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .idea 2 | npm-debug.log 3 | .vscode 4 | .nyc 5 | .env 6 | .DS_Store 7 | examples/logs/* 8 | examples/node_modules/* 9 | examples-d/logs/* 10 | examples-d/node_modules/* 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "trailingComma": "all", 4 | "arrowParens": "avoid", 5 | "printWidth": 80 6 | } 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine 2 | 3 | RUN apk --no-cache add build-base python3 4 | 5 | WORKDIR /app 6 | 7 | COPY package*.json ./ 8 | 9 | COPY tsconfig.json tsconfig.json 10 | 11 | COPY src src/ 12 | 13 | RUN npm ci 14 | 15 | RUN npm run build-grpc 16 | 17 | RUN npm run build 18 | 19 | CMD [ "node", "lib/grpc/service.js" ] 20 | -------------------------------------------------------------------------------- /examples-d/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "ethereum": { 4 | "rebalance": { 5 | "enabled": false, 6 | "strategy": "default", 7 | "interval": 10000, 8 | "minBalanceThreshold": 0.1 9 | }, 10 | "wallets": [ 11 | { 12 | "address": "0x80C67432656d59144cEFf962E8fAF8926599bCF8", 13 | "tokens": ["USDC", "DAI"] 14 | }, 15 | { 16 | "address": "0x8d0d970225597085A59ADCcd7032113226C0419d", 17 | "tokens": [] 18 | } 19 | ] 20 | }, 21 | "solana": { 22 | "wallets": [ 23 | { 24 | "address": "6VnfVsLdLwNuuCmooLTziQ99PFXZ5vc3yyqyb9tMDhhw", 25 | "tokens": ["usdc"] 26 | } 27 | ] 28 | } 29 | }, 30 | "options": { 31 | "logLevel": "debug", 32 | "balancePollInterval": 10000, 33 | "metrics": { 34 | "enabled": true, 35 | "serve": true, 36 | "port": 9091 37 | } 38 | }, 39 | "grpc": { 40 | "connectAddress": "localhost" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /examples-d/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | wallet-manager: 5 | image: wallet-manager:latest 6 | container_name: wallet-manager-d 7 | ports: 8 | - "50051:50051" 9 | volumes: 10 | - "./config.json:/etc/wallet-manager/config.json" 11 | restart: always 12 | 13 | grafana: 14 | image: grafana/grafana:latest 15 | container_name: wallet-manager-d_grafana 16 | ports: 17 | - '4000:3000' 18 | environment: 19 | GF_SECURITY_ADMIN_PASSWORD: 1234 20 | volumes: 21 | - ./grafana:/etc/grafana/provisioning 22 | 23 | prometheus: 24 | image: prom/prometheus 25 | container_name: wallet-monitor-d_prometheus 26 | ports: 27 | - '9090:9090' 28 | volumes: 29 | - ./prometheus.yml:/etc/prometheus/prometheus.yml 30 | command: "--config.file=/etc/prometheus/prometheus.yml" 31 | -------------------------------------------------------------------------------- /examples-d/grafana/dashboards/dashboards.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | providers: 3 | - name: 'default' 4 | orgId: 1 5 | folder: '' 6 | type: file 7 | disableDeletion: false 8 | updateIntervalSeconds: 10 9 | options: 10 | path: /etc/grafana/provisioning/dashboards -------------------------------------------------------------------------------- /examples-d/grafana/datasources/datasources.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | datasources: 3 | - name: Prometheus 4 | type: prometheus 5 | access: proxy 6 | url: http://prometheus:9090 7 | jsonData: 8 | timeInterval: "5s" 9 | httpMethod: "GET" 10 | editable: true -------------------------------------------------------------------------------- /examples-d/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "examples-d", 3 | "version": "0.0.1", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "examples-d", 9 | "version": "0.0.1", 10 | "license": "ISC", 11 | "dependencies": { 12 | "ts-node": "^10.9.1", 13 | "wallet-monitor": "file:../" 14 | } 15 | }, 16 | "..": { 17 | "name": "@xlabs-xyz/wallet-monitor", 18 | "version": "0.2.0", 19 | "license": "MIT", 20 | "dependencies": { 21 | "@ethersproject/bignumber": "^5.7.0", 22 | "@mysten/sui.js": "^0.32.2", 23 | "@solana/spl-token": "^0.3.7", 24 | "@solana/web3.js": "^1.74.0", 25 | "bluebird": "^3.7.2", 26 | "bs58": "^5.0.0", 27 | "ethers": "^5.7.0", 28 | "google-protobuf": "^3.21.2", 29 | "koa": "^2.14.1", 30 | "koa-router": "^12.0.0", 31 | "nice-grpc": "^2.1.4", 32 | "prom-client": "^14.2.0", 33 | "ts-node": "^10.9.1", 34 | "ts-proto": "^1.147.3", 35 | "winston": "^3.8.2", 36 | "zod": "^3.21.4" 37 | }, 38 | "devDependencies": { 39 | "@types/bluebird": "^3.5.38", 40 | "@types/jest": "^29.5.1", 41 | "@types/koa": "^2.13.6", 42 | "@types/koa-router": "^7.4.4", 43 | "@types/node": "^18.15.7", 44 | "@typescript-eslint/eslint-plugin": "^5.59.6", 45 | "@typescript-eslint/parser": "^5.59.6", 46 | "eslint": "^8.40.0", 47 | "eslint-plugin-unused-imports": "^2.0.0", 48 | "ganache": "^7.8.0", 49 | "ganache-cli": "^6.12.2", 50 | "grpc-tools": "^1.12.4", 51 | "jest": "^29.5.0", 52 | "prettier": "^2.8.7", 53 | "ts-jest": "^29.1.0", 54 | "typescript": "^5.0.2" 55 | } 56 | }, 57 | "../grpc-wrapper": { 58 | "name": "wallet-manager-grpc", 59 | "version": "1.0.0", 60 | "extraneous": true, 61 | "license": "ISC", 62 | "dependencies": { 63 | "@grpc/grpc-js": "^1.8.14", 64 | "google-protobuf": "^3.21.2", 65 | "wallet-monitor": "file:../" 66 | }, 67 | "devDependencies": { 68 | "grpc-tools": "^1.12.4" 69 | } 70 | }, 71 | "node_modules/@cspotcode/source-map-support": { 72 | "version": "0.8.1", 73 | "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", 74 | "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", 75 | "dependencies": { 76 | "@jridgewell/trace-mapping": "0.3.9" 77 | }, 78 | "engines": { 79 | "node": ">=12" 80 | } 81 | }, 82 | "node_modules/@jridgewell/resolve-uri": { 83 | "version": "3.1.1", 84 | "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", 85 | "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", 86 | "engines": { 87 | "node": ">=6.0.0" 88 | } 89 | }, 90 | "node_modules/@jridgewell/sourcemap-codec": { 91 | "version": "1.4.15", 92 | "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", 93 | "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" 94 | }, 95 | "node_modules/@jridgewell/trace-mapping": { 96 | "version": "0.3.9", 97 | "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", 98 | "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", 99 | "dependencies": { 100 | "@jridgewell/resolve-uri": "^3.0.3", 101 | "@jridgewell/sourcemap-codec": "^1.4.10" 102 | } 103 | }, 104 | "node_modules/@tsconfig/node10": { 105 | "version": "1.0.9", 106 | "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", 107 | "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==" 108 | }, 109 | "node_modules/@tsconfig/node12": { 110 | "version": "1.0.11", 111 | "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", 112 | "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==" 113 | }, 114 | "node_modules/@tsconfig/node14": { 115 | "version": "1.0.3", 116 | "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", 117 | "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==" 118 | }, 119 | "node_modules/@tsconfig/node16": { 120 | "version": "1.0.3", 121 | "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz", 122 | "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==" 123 | }, 124 | "node_modules/@types/node": { 125 | "version": "20.1.0", 126 | "resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.0.tgz", 127 | "integrity": "sha512-O+z53uwx64xY7D6roOi4+jApDGFg0qn6WHcxe5QeqjMaTezBO/mxdfFXIVAVVyNWKx84OmPB3L8kbVYOTeN34A==", 128 | "peer": true 129 | }, 130 | "node_modules/acorn": { 131 | "version": "8.8.2", 132 | "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", 133 | "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", 134 | "bin": { 135 | "acorn": "bin/acorn" 136 | }, 137 | "engines": { 138 | "node": ">=0.4.0" 139 | } 140 | }, 141 | "node_modules/acorn-walk": { 142 | "version": "8.2.0", 143 | "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", 144 | "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", 145 | "engines": { 146 | "node": ">=0.4.0" 147 | } 148 | }, 149 | "node_modules/arg": { 150 | "version": "4.1.3", 151 | "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", 152 | "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==" 153 | }, 154 | "node_modules/create-require": { 155 | "version": "1.1.1", 156 | "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", 157 | "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==" 158 | }, 159 | "node_modules/diff": { 160 | "version": "4.0.2", 161 | "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", 162 | "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", 163 | "engines": { 164 | "node": ">=0.3.1" 165 | } 166 | }, 167 | "node_modules/make-error": { 168 | "version": "1.3.6", 169 | "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", 170 | "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==" 171 | }, 172 | "node_modules/ts-node": { 173 | "version": "10.9.1", 174 | "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", 175 | "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", 176 | "dependencies": { 177 | "@cspotcode/source-map-support": "^0.8.0", 178 | "@tsconfig/node10": "^1.0.7", 179 | "@tsconfig/node12": "^1.0.7", 180 | "@tsconfig/node14": "^1.0.0", 181 | "@tsconfig/node16": "^1.0.2", 182 | "acorn": "^8.4.1", 183 | "acorn-walk": "^8.1.1", 184 | "arg": "^4.1.0", 185 | "create-require": "^1.1.0", 186 | "diff": "^4.0.1", 187 | "make-error": "^1.1.1", 188 | "v8-compile-cache-lib": "^3.0.1", 189 | "yn": "3.1.1" 190 | }, 191 | "bin": { 192 | "ts-node": "dist/bin.js", 193 | "ts-node-cwd": "dist/bin-cwd.js", 194 | "ts-node-esm": "dist/bin-esm.js", 195 | "ts-node-script": "dist/bin-script.js", 196 | "ts-node-transpile-only": "dist/bin-transpile.js", 197 | "ts-script": "dist/bin-script-deprecated.js" 198 | }, 199 | "peerDependencies": { 200 | "@swc/core": ">=1.2.50", 201 | "@swc/wasm": ">=1.2.50", 202 | "@types/node": "*", 203 | "typescript": ">=2.7" 204 | }, 205 | "peerDependenciesMeta": { 206 | "@swc/core": { 207 | "optional": true 208 | }, 209 | "@swc/wasm": { 210 | "optional": true 211 | } 212 | } 213 | }, 214 | "node_modules/typescript": { 215 | "version": "5.0.4", 216 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", 217 | "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", 218 | "peer": true, 219 | "bin": { 220 | "tsc": "bin/tsc", 221 | "tsserver": "bin/tsserver" 222 | }, 223 | "engines": { 224 | "node": ">=12.20" 225 | } 226 | }, 227 | "node_modules/v8-compile-cache-lib": { 228 | "version": "3.0.1", 229 | "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", 230 | "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==" 231 | }, 232 | "node_modules/wallet-monitor": { 233 | "resolved": "..", 234 | "link": true 235 | }, 236 | "node_modules/yn": { 237 | "version": "3.1.1", 238 | "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", 239 | "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", 240 | "engines": { 241 | "node": ">=6" 242 | } 243 | } 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /examples-d/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "examples-d", 3 | "version": "0.0.1", 4 | "scripts": { 5 | "example": "ts-node remote-wallet-manager.ts", 6 | "test": "echo \"Error: no test specified\" && exit 1" 7 | }, 8 | "author": "", 9 | "license": "ISC", 10 | "dependencies": { 11 | "ts-node": "^10.9.1", 12 | "wallet-monitor": "file:../" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples-d/prometheus.yml: -------------------------------------------------------------------------------- 1 | scrape_configs: 2 | - job_name: wallet-monitor-d 3 | scrape_interval: 10s 4 | static_configs: 5 | - targets: ['host.docker.internal:9091'] -------------------------------------------------------------------------------- /examples-d/remote-wallet-manager.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import { WalletInterface } from "wallet-monitor"; 3 | import {buildWalletManager} from "../src"; 4 | 5 | function readConfig() { 6 | const filePath = './config.json' 7 | const fileData = fs.readFileSync(filePath, 'utf-8') 8 | 9 | return JSON.parse(fileData) 10 | } 11 | 12 | const fileConfig = readConfig() 13 | 14 | const walletManager = buildWalletManager(fileConfig) 15 | 16 | // perform an action with any wallet available in the pool: 17 | const doSomethingWithWallet = async (wallet: WalletInterface) => { 18 | // do what you need with the wallet 19 | console.log( 20 | wallet.address, 21 | /** 22 | * You should never ever log your private keys. 23 | * This is just an example, to show what information you get from the interface provided. 24 | * Uncomment under your own risk ☠️ 25 | */ 26 | // wallet.privateKey, 27 | ); 28 | }; 29 | 30 | // perform an action with any wallet available in the pool: 31 | walletManager.withWallet('ethereum', doSomethingWithWallet); 32 | 33 | // perform an action with one particular wallet: 34 | walletManager.withWallet('ethereum', doSomethingWithWallet, { 35 | // address: '0x80C67432656d59144cEFf962E8fAF8926599bCF8', 36 | }); 37 | 38 | // configure the timeout for acquiring the wallet to use: 39 | walletManager.withWallet('solana', doSomethingWithWallet, { 40 | leaseTimeout: 10_000, 41 | }); 42 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Steps to test wallet-monitor metrics on prometheus and build grafana dashboard 2 | 3 | - First run `docker-compose` to run grafana and prometheus locally 4 | - Run network simulator from either ganache or hardhat 5 | - Run `npm run rebalancing` to test metrics with the required config 6 | -------------------------------------------------------------------------------- /examples/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | grafana: 5 | image: grafana/grafana:latest 6 | container_name: wallet-manager_grafana 7 | ports: 8 | - '4000:3000' 9 | environment: 10 | GF_SECURITY_ADMIN_PASSWORD: 1234 11 | volumes: 12 | - ../examples-d/grafana:/etc/grafana/provisioning 13 | 14 | prometheus: 15 | image: prom/prometheus 16 | container_name: wallet-monitor_prometheus 17 | ports: 18 | - '9090:9090' 19 | volumes: 20 | - ../examples-d/prometheus.yml:/etc/prometheus/prometheus.yml 21 | command: "--config.file=/etc/prometheus/prometheus.yml" 22 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "A set of examples for the wallet-monitor package", 3 | "scripts": { 4 | "wallet-manager": "ts-node ./wallet-manager.ts", 5 | "rebalancing": "ts-node ./rebalancing.ts" 6 | }, 7 | "dependencies": { 8 | "wallet-monitor": "file:../", 9 | "winston": "3.8.2" 10 | }, 11 | "devDependencies": { 12 | "ts-node": "^10.9.1" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/rebalancing.ts: -------------------------------------------------------------------------------- 1 | import { WalletManagerOptions, WalletManagerConfig } from "wallet-monitor"; 2 | import { buildWalletManager } from "../src"; 3 | import { wait } from "../test/utilities/common"; 4 | 5 | // npx ganache -i 5777 \ 6 | // --wallet.accounts=0xf9fdbcbcdb4c7c72642be9fe7c09ad5869a961a8ae3c3374841cb6ead5fd34b1,2000000000000000000 \ 7 | // --wallet.accounts=0x5da88a1c7df3490d040792fcca4676e709fdd3e6f6e0142accc96cb7e205e1e0,2000000000000000000 \ 8 | // --wallet.accounts=0xa2c984f6a752ee05af03dac0bb65f3ec93f1498d43d23ebe6c31cf988d771423,2000000000000000000 \ 9 | // --wallet.accounts=0xe0c1c809d0e80dcaaf200b3aec2a91cd00ed05134f10026113219d89d2f6a9b2,2000000000000000000 \ 10 | // --wallet.accounts=0x3500084e268b862df23a229f268510cdee92623102c4786b0ade6203fa59f421,2000000000000000000 \ 11 | // --wallet.accounts=0x49692cfecfb48cee7ce8a14273bda6996b832149ff7260bca09c2ea159e9147f,2000000000000000000 \ 12 | // --wallet.accounts=0xb2868bd9090dcfbc9d6f3012c98039ee20d778f8ef2d8cb721c56b69578934f3,2000000000000000000 \ 13 | // --wallet.accounts=0x50156cc51cb7ae4f5e6e2cb14a75fc177a1917fbab1a8675db25619567515ddd,2000000000000000000 \ 14 | // --wallet.accounts=0x6790f27fec85575792c7d1fab8de9955aff171b24329eacf2a279defa596c5d3,2000000000000000000 \ 15 | // --wallet.accounts=0xe94000d730b9655850afc8e39facb7058678f11e765075d4806d27ed619f258c,10000000000000000000 16 | 17 | const options: WalletManagerOptions = { 18 | logLevel: "debug", 19 | balancePollInterval: 5000, 20 | metrics: { 21 | enabled: true, 22 | serve: true, 23 | port: 9091, 24 | }, 25 | failOnInvalidChain: true, 26 | }; 27 | 28 | const allChainWallets: WalletManagerConfig = { 29 | ethereum: { 30 | chainConfig: { 31 | nodeUrl: "http://127.0.0.1:8545", 32 | }, 33 | rebalance: { 34 | enabled: true, 35 | strategy: "pourOver", 36 | interval: 10000, 37 | minBalanceThreshold: 3, 38 | maxGasPrice: 10000, 39 | gasLimit: 1000000, 40 | }, 41 | wallets: [ 42 | { privateKey: '0xf9fdbcbcdb4c7c72642be9fe7c09ad5869a961a8ae3c3374841cb6ead5fd34b1' }, 43 | { privateKey: '0x5da88a1c7df3490d040792fcca4676e709fdd3e6f6e0142accc96cb7e205e1e0' }, 44 | { privateKey: '0xa2c984f6a752ee05af03dac0bb65f3ec93f1498d43d23ebe6c31cf988d771423' }, 45 | { privateKey: '0xe0c1c809d0e80dcaaf200b3aec2a91cd00ed05134f10026113219d89d2f6a9b2' }, 46 | { privateKey: '0x3500084e268b862df23a229f268510cdee92623102c4786b0ade6203fa59f421' }, 47 | { privateKey: '0x49692cfecfb48cee7ce8a14273bda6996b832149ff7260bca09c2ea159e9147f' }, 48 | { privateKey: '0xb2868bd9090dcfbc9d6f3012c98039ee20d778f8ef2d8cb721c56b69578934f3' }, 49 | { privateKey: '0x50156cc51cb7ae4f5e6e2cb14a75fc177a1917fbab1a8675db25619567515ddd' }, 50 | { privateKey: '0x6790f27fec85575792c7d1fab8de9955aff171b24329eacf2a279defa596c5d3' }, 51 | { privateKey: '0xe94000d730b9655850afc8e39facb7058678f11e765075d4806d27ed619f258c' }, 52 | ], 53 | }, 54 | polygon: { 55 | chainConfig: { 56 | nodeUrl: "http://127.0.0.1:8545", 57 | }, 58 | rebalance: { 59 | enabled: true, 60 | strategy: "pourOver", 61 | interval: 10000, 62 | minBalanceThreshold: 3, 63 | maxGasPrice: 10000, 64 | gasLimit: 1000000, 65 | }, 66 | wallets: [ 67 | { privateKey: '0xf9fdbcbcdb4c7c72642be9fe7c09ad5869a961a8ae3c3374841cb6ead5fd34b1' }, 68 | { privateKey: '0x5da88a1c7df3490d040792fcca4676e709fdd3e6f6e0142accc96cb7e205e1e0' }, 69 | { privateKey: '0xa2c984f6a752ee05af03dac0bb65f3ec93f1498d43d23ebe6c31cf988d771423' }, 70 | { privateKey: '0xe0c1c809d0e80dcaaf200b3aec2a91cd00ed05134f10026113219d89d2f6a9b2' }, 71 | { privateKey: '0x3500084e268b862df23a229f268510cdee92623102c4786b0ade6203fa59f421' }, 72 | { privateKey: '0x49692cfecfb48cee7ce8a14273bda6996b832149ff7260bca09c2ea159e9147f' }, 73 | { privateKey: '0xb2868bd9090dcfbc9d6f3012c98039ee20d778f8ef2d8cb721c56b69578934f3' }, 74 | { privateKey: '0x50156cc51cb7ae4f5e6e2cb14a75fc177a1917fbab1a8675db25619567515ddd' }, 75 | { privateKey: '0x6790f27fec85575792c7d1fab8de9955aff171b24329eacf2a279defa596c5d3' }, 76 | { privateKey: '0xe94000d730b9655850afc8e39facb7058678f11e765075d4806d27ed619f258c' }, 77 | ], 78 | }, 79 | avalanche: { 80 | chainConfig: { 81 | nodeUrl: "http://127.0.0.1:8545", 82 | }, 83 | rebalance: { 84 | enabled: true, 85 | strategy: "pourOver", 86 | interval: 10000, 87 | minBalanceThreshold: 3, 88 | maxGasPrice: 10000, 89 | gasLimit: 1000000, 90 | }, 91 | wallets: [ 92 | { privateKey: '0xf9fdbcbcdb4c7c72642be9fe7c09ad5869a961a8ae3c3374841cb6ead5fd34b1' }, 93 | { privateKey: '0x5da88a1c7df3490d040792fcca4676e709fdd3e6f6e0142accc96cb7e205e1e0' }, 94 | { privateKey: '0xa2c984f6a752ee05af03dac0bb65f3ec93f1498d43d23ebe6c31cf988d771423' }, 95 | { privateKey: '0xe0c1c809d0e80dcaaf200b3aec2a91cd00ed05134f10026113219d89d2f6a9b2' }, 96 | { privateKey: '0x3500084e268b862df23a229f268510cdee92623102c4786b0ade6203fa59f421' }, 97 | { privateKey: '0x49692cfecfb48cee7ce8a14273bda6996b832149ff7260bca09c2ea159e9147f' }, 98 | { privateKey: '0xb2868bd9090dcfbc9d6f3012c98039ee20d778f8ef2d8cb721c56b69578934f3' }, 99 | { privateKey: '0x50156cc51cb7ae4f5e6e2cb14a75fc177a1917fbab1a8675db25619567515ddd' }, 100 | { privateKey: '0x6790f27fec85575792c7d1fab8de9955aff171b24329eacf2a279defa596c5d3' }, 101 | { privateKey: '0xe94000d730b9655850afc8e39facb7058678f11e765075d4806d27ed619f258c' }, 102 | ], 103 | }, 104 | }; 105 | 106 | export const manager = buildWalletManager({ config: allChainWallets, options }); 107 | 108 | 109 | for (let i = 0; i < 10; i++) { 110 | // perform an action with any wallet available in the pool: 111 | manager.withWallet("ethereum", async wallet => { 112 | // do what you need with the wallet 113 | console.log({ 114 | address: wallet.address, 115 | chainName: wallet.walletToolbox.chainName, 116 | }); 117 | await wait(3000); 118 | }); 119 | // perform an action with any wallet available in the pool: 120 | manager.withWallet("polygon", async wallet => { 121 | // do what you need with the wallet 122 | console.log({ 123 | address: wallet.address, 124 | chainName: wallet.walletToolbox.chainName, 125 | }); 126 | await wait(3000); 127 | }); 128 | // perform an action with any wallet available in the pool: 129 | manager.withWallet("avalanche", async wallet => { 130 | // do what you need with the wallet 131 | console.log({ 132 | address: wallet.address, 133 | chainName: wallet.walletToolbox.chainName, 134 | }); 135 | await wait(3000); 136 | }); 137 | } 138 | -------------------------------------------------------------------------------- /examples/wallet-manager.ts: -------------------------------------------------------------------------------- 1 | import { buildWalletManager, WalletManagerFullConfig } from 'wallet-monitor'; 2 | 3 | const allChainWallets: WalletManagerFullConfig['config'] = { 4 | ethereum: { 5 | rebalance: { 6 | enabled: false, 7 | strategy: 'default', 8 | interval: 10000, 9 | minBalanceThreshold: 0.1, 10 | }, 11 | wallets: [ 12 | { 13 | address: "0x80C67432656d59144cEFf962E8fAF8926599bCF8", 14 | tokens: ["USDC"] 15 | }, 16 | { 17 | address: "0x8d0d970225597085A59ADCcd7032113226C0419d", 18 | tokens: ["WBTC"] 19 | } 20 | ], 21 | walletBalanceConfig: { 22 | enabled: true, 23 | scheduled: { 24 | enabled: false, 25 | } 26 | }, 27 | priceFeedConfig: { 28 | supportedTokens: [{ 29 | chainId: 2, 30 | chainName: "ethereum", 31 | tokenContract: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", 32 | coingeckoId: "usd-coin", 33 | symbol: "USDC" 34 | }, 35 | { 36 | chainId: 2, 37 | chainName: "ethereum", 38 | tokenContract: "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599", 39 | coingeckoId: "wrapped-bitcoin", 40 | symbol: "WBTC" 41 | }], 42 | } 43 | }, 44 | solana: { 45 | wallets: [ 46 | { address: "6VnfVsLdLwNuuCmooLTziQ99PFXZ5vc3yyqyb9tMDhhw", tokens: ['usdc'] }, 47 | ], 48 | walletBalanceConfig: { 49 | enabled: true, 50 | scheduled: { 51 | enabled: false, 52 | } 53 | }, 54 | priceFeedConfig: { 55 | supportedTokens: [] 56 | } 57 | }, 58 | sui: { 59 | rebalance: { 60 | enabled: false, 61 | strategy: 'default', 62 | interval: 10000, 63 | minBalanceThreshold: 0.1, 64 | }, 65 | wallets: [ 66 | { privateKey: 'ODV9VYi3eSljEWWmpWh8s9m/P2BNNxU/Vp8jwADeNuw=' }, 67 | { address: '0x934042d46762fadf9f61ef07aa265fc14d28525c7051224a5f1cba2409aef307' }, 68 | { address: '0x341ab296bbe653b426e996b17af33718db62af3c250c04fe186c9124db5bd8b7' }, 69 | { address: '0x7d20dcdb2bca4f508ea9613994683eb4e76e9c4ed371169677c1be02aaf0b58e' }, 70 | { address: '0x18337b4c5b964b7645506542589c5ed496e794af82f98b3789fed96f61a94c96' }, 71 | { address: '0x9ae846f88db3476d7c9f2d8fc49722f7085a3b46aad998120dd11ebeab83e021' }, 72 | { address: '0xcb39d897bf0561af7531d37db9781e54528269fed4761275931ce32f20352977' }, 73 | { 74 | address: '0x8f11fe7121be742f46e2b3bc2eba081efdc3027697c317a917a2d16fd9b59ab1', 75 | tokens: ['USDC', 'USDT'] 76 | }, 77 | ], 78 | walletBalanceConfig: { 79 | enabled: true, 80 | scheduled: { 81 | enabled: false, 82 | } 83 | }, 84 | priceFeedConfig: { 85 | supportedTokens: [{ 86 | chainId: 21, 87 | chainName: "sui", 88 | tokenContract: "0x5d4b302506645c37ff133b98c4b50a5ae14841659738d6d733d59d0d217a93bf", 89 | coingeckoId: "usd-coin", 90 | symbol: "USDC" 91 | }], 92 | } 93 | }, 94 | klatyn: { 95 | rebalance: { 96 | enabled: false, 97 | strategy: 'default', 98 | interval: 10000, 99 | minBalanceThreshold: 0.1, 100 | }, 101 | wallets: [ 102 | { 103 | address: "0x80C67432656d59144cEFf962E8fAF8926599bCF8", 104 | tokens: ["USDC", "DAI"] 105 | }, 106 | { 107 | address: "0x8d0d970225597085A59ADCcd7032113226C0419d", 108 | tokens: [] 109 | } 110 | ], 111 | walletBalanceConfig: { 112 | enabled: true, 113 | scheduled: { 114 | enabled: false, 115 | } 116 | }, 117 | priceFeedConfig: { 118 | supportedTokens: [] 119 | } 120 | } 121 | } 122 | 123 | export const manager = buildWalletManager({ 124 | config: allChainWallets, 125 | options: { 126 | logLevel: 'debug', 127 | balancePollInterval: 10000, 128 | failOnInvalidChain: false, 129 | failOnInvalidTokens: false, 130 | metrics: { 131 | enabled: true, 132 | serve: true, 133 | port: 9091, 134 | }, 135 | priceFeedOptions: { 136 | enabled: true, 137 | scheduled: { 138 | enabled: false, 139 | } 140 | } 141 | } 142 | }); 143 | 144 | (async () => { 145 | try { 146 | console.time('balances') 147 | const balances = await manager.pullBalancesAtBlockHeight(); 148 | console.timeLog('balances', JSON.stringify(balances)) 149 | } catch (err) { 150 | console.error('Failed to pullBalancesAtBlockHeight', err); 151 | } 152 | })(); 153 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | testPathIgnorePatterns: ['/test/integration'] 6 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@xlabs-xyz/wallet-monitor", 3 | "version": "0.2.27", 4 | "description": "A set of utilities to monitor blockchain wallets and react to them", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "scripts": { 8 | "build-docker-image": "docker build -t wallet-manager-service:latest .", 9 | "start-service": "node lib/grpc/service.js", 10 | "build-grpc": "grpc_tools_node_protoc --plugin=protoc-gen-ts_proto=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=./src/grpc/out/ --ts_proto_opt=outputServices=nice-grpc,outputServices=generic-definitions,useExactTypes=false,esModuleInterop=true --proto_path=./src/grpc/proto/ src/grpc/proto/wallet-manager-grpc-service.proto", 11 | "build": "npm run build-grpc && tsc", 12 | "watch": "tsc -w", 13 | "lint": "eslint --ignore-path .gitignore --ext .js,.ts .", 14 | "prettier": "prettier --write $(git diff main --name-only --diff-filter u | grep '.ts$' | xargs)", 15 | "test": "jest --silent=false", 16 | "start-ganache": "npx ganache -i 5777 --wallet.accounts=0xf9fdbcbcdb4c7c72642be9fe7c09ad5869a961a8ae3c3374841cb6ead5fd34b1,200000000000000000 --wallet.accounts=0xe94000d730b9655850afc8e39facb7058678f11e765075d4806d27ed619f258c,10000000000000000000" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/xlabs/wallet-monitor.git" 21 | }, 22 | "keywords": [ 23 | "blockchain", 24 | "wallet", 25 | "wallet-balance", 26 | "monitoring", 27 | "wallet-monitoring", 28 | "wallet scraping", 29 | "wallet-scraping", 30 | "wallet exporter", 31 | "wallet-exporter", 32 | "wallet monitoring", 33 | "wallet balance exporter", 34 | "wallet balance export", 35 | "wallet balance scrape", 36 | "wallet balance monitor", 37 | "wallet balance monitoring" 38 | ], 39 | "author": "", 40 | "license": "MIT", 41 | "bugs": { 42 | "url": "https://github.com/xlabs/wallet-monitor/issues" 43 | }, 44 | "homepage": "https://github.com/xlabs/wallet-monitor#readme", 45 | "dependencies": { 46 | "@cosmjs/proto-signing": "^0.31.1", 47 | "@cosmjs/stargate": "^0.31.3", 48 | "@ethersproject/bignumber": "^5.7.0", 49 | "@mysten/sui.js": "^0.32.2", 50 | "@solana/spl-token": "^0.3.7", 51 | "@solana/web3.js": "^1.78.0", 52 | "axios": "^1.5.1", 53 | "bluebird": "^3.7.2", 54 | "bn.js": "^5.2.1", 55 | "bs58": "^5.0.0", 56 | "elliptic": "^6.6.1", 57 | "ethers": "^5.7.0", 58 | "koa": "^2.14.1", 59 | "koa-router": "^12.0.0", 60 | "nice-grpc": "^2.1.4", 61 | "prom-client": "^14.2.0", 62 | "winston": "^3.8.2", 63 | "zod": "^3.21.4" 64 | }, 65 | "devDependencies": { 66 | "@types/bluebird": "^3.5.38", 67 | "@types/elliptic": "^6.4.17", 68 | "@types/jest": "^29.5.1", 69 | "@types/koa": "^2.13.6", 70 | "@types/koa-router": "^7.4.4", 71 | "@types/node": "^18.15.7", 72 | "@typescript-eslint/eslint-plugin": "^5.59.6", 73 | "@typescript-eslint/parser": "^5.59.6", 74 | "eslint": "^8.40.0", 75 | "eslint-plugin-unused-imports": "^2.0.0", 76 | "google-protobuf": "^3.21.2", 77 | "grpc-tools": "^1.12.4", 78 | "jest": "^29.5.0", 79 | "prettier": "^2.8.7", 80 | "ts-jest": "^29.1.0", 81 | "ts-node": "^10.9.1", 82 | "ts-proto": "^1.147.3", 83 | "typescript": "^5.0.2" 84 | }, 85 | "overrides": { 86 | "elliptic": "^6.6.1" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/balances/cosmos/index.ts: -------------------------------------------------------------------------------- 1 | import { rawSecp256k1PubkeyToRawAddress } from "@cosmjs/amino"; 2 | import { Secp256k1 } from "@cosmjs/crypto"; 3 | import { fromHex, toBech32 } from "@cosmjs/encoding"; 4 | import { 5 | calculateFee, 6 | GasPrice, 7 | MsgSendEncodeObject, 8 | SigningStargateClient, 9 | } from "@cosmjs/stargate"; 10 | import { DirectSecp256k1Wallet } from "@cosmjs/proto-signing"; 11 | import { BigNumber, ethers } from "ethers"; 12 | import BN from "bn.js"; 13 | import elliptic from "elliptic"; 14 | import { CosmosProvider } from "../../wallets/cosmos"; 15 | import { Balance } from ".."; 16 | 17 | export async function pullCosmosNativeBalance( 18 | provider: CosmosProvider, 19 | address: string, 20 | denomination: string, 21 | ): Promise { 22 | const balance = await provider.getBalance(address, denomination); 23 | 24 | return { 25 | isNative: true, 26 | rawBalance: balance.amount, 27 | }; 28 | } 29 | 30 | export type CosmosTransferTransactionDetails = { 31 | targetAddress: string; 32 | amount: number; 33 | addressPrefix: string; 34 | defaultDecimals: number; 35 | nativeDenom: string; 36 | nodeUrl: string; 37 | defaultGasPrice: string; 38 | }; 39 | 40 | export async function transferCosmosNativeBalance( 41 | privateKey: string, 42 | txDetails: CosmosTransferTransactionDetails, 43 | ) { 44 | const { 45 | targetAddress, 46 | amount, 47 | addressPrefix, 48 | defaultDecimals, 49 | nativeDenom, 50 | nodeUrl, 51 | defaultGasPrice, 52 | } = txDetails; 53 | const signer = await createSigner(privateKey, addressPrefix); 54 | const wallet = await SigningStargateClient.connectWithSigner(nodeUrl, signer); 55 | 56 | const encodedAmount = { 57 | denom: nativeDenom, 58 | amount: ethers.utils 59 | .parseUnits(amount.toString(), defaultDecimals) 60 | .toString(), 61 | }; 62 | 63 | const accounts = await signer.getAccounts(); 64 | const fromAddress = accounts[0].address; 65 | 66 | const sendMsg: MsgSendEncodeObject = { 67 | typeUrl: "/cosmos.bank.v1beta1.MsgSend", 68 | value: { 69 | fromAddress, 70 | toAddress: targetAddress, 71 | amount: [encodedAmount], 72 | }, 73 | }; 74 | 75 | const gasPrice = GasPrice.fromString(defaultGasPrice); 76 | const gasEstimated = await wallet.simulate(fromAddress, [sendMsg], undefined); 77 | const gas = BigNumber.from(gasEstimated * 1.5).toString(); 78 | const fee = calculateFee(parseInt(gas), gasPrice); 79 | 80 | const receipt = await wallet.signAndBroadcast(fromAddress, [sendMsg], fee); 81 | const txCost = calculateFee(receipt.gasUsed, gasPrice); 82 | 83 | return { 84 | transactionHash: receipt.transactionHash, 85 | gasUsed: receipt.gasUsed.toString(), 86 | gasPrice: gasPrice.toString(), 87 | formattedCost: ethers.utils.formatUnits(txCost.amount[0].amount, defaultDecimals), 88 | }; 89 | } 90 | 91 | export interface Secp256k1Keypair { 92 | /** A 32 byte private key */ 93 | readonly pubkey: Uint8Array; 94 | /** 95 | * A raw secp256k1 public key. 96 | * 97 | * The type itself does not give you any guarantee if this is 98 | * compressed or uncompressed. If you are unsure where the data 99 | * is coming from, use `Secp256k1.compressPubkey` or 100 | * `Secp256k1.uncompressPubkey` (both idempotent) before processing it. 101 | */ 102 | readonly privkey: Uint8Array; 103 | } 104 | 105 | // Extracted from: https://github.com/cosmos/cosmjs/blob/33271bc51c/packages/crypto/src/secp256k1.ts#L31 106 | // and changed the return type to Secp256k1Keypair instead of a Promise 107 | // so we can make sync calls to this function. This is needed because the stack trace goes back to a 108 | // class constructor and we can't use async/await in a constructor. 109 | function makeKeypair(privkey: Uint8Array): Secp256k1Keypair { 110 | const secp256k1 = new elliptic.ec("secp256k1"); 111 | const secp256k1N = new BN( 112 | "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", 113 | "hex", 114 | ); 115 | if (privkey.length !== 32) { 116 | // is this check missing in secp256k1.validatePrivateKey? 117 | // https://github.com/bitjson/bitcoin-ts/issues/4 118 | throw new Error("input data is not a valid secp256k1 private key"); 119 | } 120 | 121 | const keypair = secp256k1.keyFromPrivate(privkey); 122 | if (keypair.validate().result !== true) { 123 | throw new Error("input data is not a valid secp256k1 private key"); 124 | } 125 | 126 | // range test that is not part of the elliptic implementation 127 | const privkeyAsBigInteger = new BN(privkey); 128 | if (privkeyAsBigInteger.gte(secp256k1N)) { 129 | // not strictly smaller than N 130 | throw new Error("input data is not a valid secp256k1 private key"); 131 | } 132 | 133 | const out: Secp256k1Keypair = { 134 | privkey: fromHex(keypair.getPrivate("hex")), 135 | // encodes uncompressed as 136 | // - 1-byte prefix "04" 137 | // - 32-byte x coordinate 138 | // - 32-byte y coordinate 139 | pubkey: Uint8Array.from(keypair.getPublic("array")), 140 | }; 141 | return out; 142 | } 143 | 144 | export async function createSigner(privateKey: string, addressPrefix: string) { 145 | const pk = new Uint8Array(fromHex(privateKey)); 146 | return await DirectSecp256k1Wallet.fromKey(pk, addressPrefix); 147 | } 148 | 149 | export function getCosmosAddressFromPrivateKey( 150 | privateKey: string, 151 | addressPrefix: string, 152 | ): string { 153 | const uncompressed = makeKeypair(fromHex(privateKey)).pubkey; 154 | const compressed = Secp256k1.compressPubkey(uncompressed); 155 | return toBech32(addressPrefix, rawSecp256k1PubkeyToRawAddress(compressed)); 156 | } 157 | -------------------------------------------------------------------------------- /src/balances/evm/erc20-abi.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "constant": true, 4 | "inputs": [], 5 | "name": "decimals", 6 | "outputs": [ 7 | { 8 | "name": "", 9 | "type": "uint8" 10 | } 11 | ], 12 | "payable": false, 13 | "stateMutability": "view", 14 | "type": "function" 15 | }, 16 | { 17 | "constant": true, 18 | "inputs": [ 19 | { 20 | "name": "_owner", 21 | "type": "address" 22 | } 23 | ], 24 | "name": "balanceOf", 25 | "outputs": [ 26 | { 27 | "name": "balance", 28 | "type": "uint256" 29 | } 30 | ], 31 | "payable": false, 32 | "stateMutability": "view", 33 | "type": "function" 34 | }, 35 | { 36 | "constant": true, 37 | "inputs": [], 38 | "name": "symbol", 39 | "outputs": [ 40 | { 41 | "name": "", 42 | "type": "string" 43 | } 44 | ], 45 | "payable": false, 46 | "stateMutability": "view", 47 | "type": "function" 48 | } 49 | ] 50 | -------------------------------------------------------------------------------- /src/balances/evm/index.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from 'ethers'; 2 | 3 | import { Balance } from '..'; 4 | 5 | import EVM_TOKEN_ABI from './erc20-abi.json'; 6 | 7 | function getTokenContract ( 8 | provider: ethers.providers.JsonRpcProvider, 9 | tokenAddress: string, 10 | ): ethers.Contract { 11 | return new ethers.Contract(tokenAddress, EVM_TOKEN_ABI, provider); 12 | } 13 | 14 | export async function pullEvmTokenData( 15 | provider: ethers.providers.JsonRpcProvider, 16 | tokenAddress: string, 17 | ): Promise { 18 | const contract = getTokenContract(provider, tokenAddress); 19 | 20 | const [symbol, decimals] = await Promise.all([ 21 | contract.symbol(), 22 | contract.decimals(), 23 | ]); 24 | 25 | return { symbol, decimals }; 26 | } 27 | 28 | export async function pullEvmTokenBalance( 29 | provider: ethers.providers.JsonRpcProvider, 30 | tokenAddress: string, 31 | address: string, 32 | ): Promise { 33 | const contract = getTokenContract(provider, tokenAddress); 34 | 35 | const balance = await contract.balanceOf(address); 36 | return { 37 | isNative: false, 38 | rawBalance: balance.toString(), 39 | } 40 | } 41 | 42 | export async function pullEvmNativeBalance( 43 | provider: ethers.providers.JsonRpcProvider, 44 | address: string, 45 | blockHeight?: number, 46 | ): Promise{ 47 | const weiAmount = await provider.getBalance(address, blockHeight); 48 | 49 | return { 50 | isNative: true, 51 | rawBalance: weiAmount.toString(), 52 | } 53 | } 54 | 55 | export type EvmTransferTransactionDetails = { 56 | targetAddress: string; 57 | amount: number; // amount in highest denomination (e.g. ETH, not wei) 58 | maxGasPrice?: number; 59 | gasLimit?: number; 60 | } 61 | 62 | export async function transferEvmNativeBalance( 63 | provider: ethers.providers.JsonRpcProvider, 64 | privateKey: string, 65 | txDetails: EvmTransferTransactionDetails 66 | ) { 67 | 68 | const { targetAddress, amount, maxGasPrice, gasLimit } = txDetails; 69 | 70 | const wallet = new ethers.Wallet(privateKey, provider); 71 | 72 | const amountInWei = ethers.utils.parseEther(amount.toString()); 73 | 74 | const transaction = { 75 | to: targetAddress, 76 | value: amountInWei, 77 | gasLimit: gasLimit, 78 | gasPrice: maxGasPrice ? ethers.utils.parseUnits(maxGasPrice!.toString(), 'gwei') : undefined, 79 | }; 80 | 81 | 82 | if (maxGasPrice) { 83 | transaction.gasPrice = ethers.utils.parseUnits(maxGasPrice.toString(), 'gwei'); 84 | } 85 | 86 | if (gasLimit) { 87 | transaction.gasLimit = gasLimit; 88 | } 89 | 90 | const txResponse = await wallet.sendTransaction(transaction); 91 | 92 | const txReceipt: ethers.providers.TransactionReceipt = await txResponse.wait(); 93 | 94 | return { 95 | transactionHash: txReceipt.transactionHash, 96 | gasUsed: txReceipt.gasUsed.toString(), 97 | gasPrice: txReceipt.effectiveGasPrice.toString(), 98 | formattedCost: ethers.utils.formatEther(txReceipt.gasUsed.mul(txReceipt.effectiveGasPrice)), 99 | } 100 | } 101 | 102 | export type EvmTokenData = { 103 | symbol: string; 104 | decimals: number; 105 | }; 106 | 107 | export function getEvmAddressFromPrivateKey(privateKey: string): string { 108 | let wallet; 109 | 110 | try { 111 | wallet = new ethers.Wallet(privateKey); 112 | } catch(e) { 113 | throw new Error(`Invalid private key: ${e}`); 114 | } 115 | 116 | return wallet.address; 117 | } 118 | 119 | -------------------------------------------------------------------------------- /src/balances/index.ts: -------------------------------------------------------------------------------- 1 | export type Balance = { 2 | isNative: boolean; 3 | rawBalance: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/balances/solana.ts: -------------------------------------------------------------------------------- 1 | import bs58 from 'bs58'; 2 | import { Connection, PublicKey, Keypair } from "@solana/web3.js"; 3 | import { Balance } from "./index"; 4 | import { SOLANA_DEFAULT_COMMITMENT } from '../wallets/solana/solana.config'; 5 | 6 | export async function pullSolanaNativeBalance( 7 | connection: Connection, 8 | address: string, 9 | ): Promise { 10 | // solana web3.js doesn't support passing exact slot(block number) only minSlot, while fetching balance 11 | const lamports = await connection.getBalance(new PublicKey(address), SOLANA_DEFAULT_COMMITMENT) 12 | 13 | return { 14 | isNative: true, 15 | rawBalance: lamports.toString() 16 | } 17 | } 18 | 19 | export function getSolanaAddressFromPrivateKey(privateKey: string): string { 20 | let secretKey; 21 | try { 22 | // when a Uint8Array has `toString` called, the string is not valid JSON since it lack `[` and `]` 23 | if (privateKey[0] == "[") { 24 | secretKey = new Uint8Array(JSON.parse(privateKey)); 25 | } else { 26 | secretKey = new Uint8Array(JSON.parse(`[${privateKey}]`)); 27 | } 28 | } catch (e) { 29 | secretKey = bs58.decode(privateKey); 30 | } 31 | return Keypair.fromSecretKey(secretKey).publicKey.toBase58() 32 | } 33 | -------------------------------------------------------------------------------- /src/balances/sui.ts: -------------------------------------------------------------------------------- 1 | import { Connection, JsonRpcProvider, Secp256k1Keypair, Ed25519Keypair, PRIVATE_KEY_SIZE, RawSigner, TransactionBlock, SuiTransactionBlockResponse } from '@mysten/sui.js'; 2 | import { WalletBalance } from '../wallets'; 3 | import { parseFixed } from '@ethersproject/bignumber'; 4 | 5 | export type SuiTokenData = { 6 | symbol: string; 7 | decimals: number; 8 | address: string; 9 | }; 10 | 11 | export interface SuiTransactionDetails { 12 | targetAddress: string; 13 | amount: number; 14 | maxGasPrice?: number; 15 | gasLimit?: number; 16 | } 17 | 18 | export async function pullSuiNativeBalance(conn: Connection, address: string): Promise { 19 | const provider = new JsonRpcProvider(conn); 20 | 21 | // mysten SDK doesn't support passing checkpoint (block number) to getBalance 22 | // https://github.com/MystenLabs/sui/issues/14137 23 | const rawBalance = await provider.getBalance({ owner: address }); 24 | 25 | return { 26 | isNative: true, 27 | rawBalance: rawBalance.totalBalance.toString(), 28 | } as WalletBalance; 29 | } 30 | 31 | // taken from: https://github.com/MystenLabs/sui/blob/818406c5abdf7de1b80915a0519071eec3a5b1c7/crates/sui-types/src/crypto.rs#L1650 32 | // see: https://github.com/MystenLabs/sui/blob/d1d6eba/sdk/typescript/src/cryptography/ed25519-keypair.ts#L65 33 | const keyPairsByHexPrefix = { 34 | '0x00': buildEd25519KeyPair, 35 | '0x01': buildSecp256k1KeyPair, 36 | } 37 | 38 | export async function pullSuiTokenData( 39 | conn: Connection, 40 | address: string 41 | ): Promise { 42 | const provider = new JsonRpcProvider(conn); 43 | const coinData = await provider.getCoinMetadata({coinType: address}); 44 | 45 | if (!coinData) { 46 | throw new Error(`Coin data not found for address: ${address}`); 47 | } 48 | 49 | return { 50 | symbol: coinData.symbol, 51 | decimals: coinData.decimals, 52 | address: address 53 | }; 54 | } 55 | 56 | export async function pullSuiTokenBalances( 57 | conn: Connection, 58 | address: string 59 | ): Promise<{ 60 | coinType: string; 61 | coinObjectCount: number; 62 | totalBalance: string; 63 | lockedBalance: { 64 | number?: number | undefined; 65 | epochId?: number | undefined; 66 | }; 67 | }[]> { 68 | const provider = new JsonRpcProvider(conn); 69 | 70 | return provider.getAllBalances({ owner: address }); 71 | } 72 | 73 | export async function transferSuiNativeBalance( 74 | conn: Connection, 75 | privateKey: string, 76 | txDetails: SuiTransactionDetails, 77 | ): Promise { 78 | const provider = new JsonRpcProvider(conn); 79 | const { targetAddress, amount } = txDetails; 80 | 81 | try { 82 | const keyPair = buildSuiKeyPairFromPrivateKey(privateKey); 83 | const signer = new RawSigner(keyPair, provider); 84 | const tx = new TransactionBlock(); 85 | const [coin] = tx.splitCoins(tx.gas, [tx.pure(amount)]); 86 | 87 | tx.transferObjects([coin], tx.pure(targetAddress)); 88 | const txBlock = await signer.signAndExecuteTransactionBlock({ 89 | transactionBlock: tx, 90 | }); 91 | 92 | return makeTxReceipt( 93 | await provider.getTransactionBlock({digest: txBlock.digest, options: { showEffects: true, showInput: true }}) 94 | ); 95 | } catch (error) { 96 | throw new Error(`Could not transfer native balance to address ${targetAddress}. Error: ${(error as Error)?.stack || error}`); 97 | } 98 | } 99 | 100 | function makeTxReceipt(txn: SuiTransactionBlockResponse) { 101 | const { digest, effects, transaction } = txn; 102 | if (!effects) { 103 | return { transactionHash: digest }; 104 | } 105 | 106 | const { gasUsed: gasConsumed } = effects; 107 | 108 | // from: https://docs.sui.io/build/sui-gas-charges#transaction-output-gascostsummary 109 | const totalGasUsed = parseFixed(gasConsumed.computationCost, 0) 110 | .add(parseFixed(gasConsumed.storageCost, 0)) 111 | .sub(parseFixed(gasConsumed.storageRebate, 0)); 112 | 113 | let gasUsed = '0'; 114 | let gasPrice = '0'; 115 | 116 | if (transaction) { 117 | gasPrice = transaction.data.gasData.price; 118 | gasUsed = totalGasUsed.div(gasPrice).toString(); 119 | } 120 | 121 | // Gas price and formattedCost are in mist 122 | return { 123 | transactionHash: digest, 124 | gasUsed, // gas price referenced from: https://docs.sui.io/build/sui-gas-charges#transaction-output-gascostsummary 125 | gasPrice, 126 | formattedCost: parseFixed(totalGasUsed.toString(), 0).toString(), 127 | } 128 | } 129 | 130 | function buildSecp256k1KeyPair(key: Buffer): Secp256k1Keypair { 131 | return Secp256k1Keypair.fromSecretKey(key); 132 | } 133 | 134 | function buildEd25519KeyPair(key: Buffer): Ed25519Keypair { 135 | return Ed25519Keypair.fromSecretKey(key); 136 | } 137 | 138 | function buildSuiKeyPairFromPrivateKey(privateKey: string): Secp256k1Keypair | Ed25519Keypair { 139 | const parsedKey = Buffer.from(privateKey, 'base64'); 140 | 141 | let key, buildKeyPair; 142 | if (parsedKey.length === PRIVATE_KEY_SIZE+1) { 143 | key = parsedKey.slice(1); 144 | const schemaPrefix =`0x${parsedKey.slice(0, 1).toString('hex')}` 145 | 146 | buildKeyPair = keyPairsByHexPrefix[ 147 | schemaPrefix as keyof typeof keyPairsByHexPrefix 148 | ]; 149 | 150 | if (!buildKeyPair) { 151 | throw new Error(`Invalid Keypair: prefix ${schemaPrefix}`); 152 | } 153 | } 154 | 155 | else if (parsedKey.length === PRIVATE_KEY_SIZE) { 156 | key = parsedKey; 157 | buildKeyPair = buildEd25519KeyPair; 158 | } 159 | 160 | else { 161 | throw new Error(`Invalid Sui private key. Expected length: 32 or 33 bytes, actual length: ${parsedKey.length}`); 162 | } 163 | 164 | return buildKeyPair(key); 165 | } 166 | 167 | export function getSuiAddressFromPrivateKey(privateKey: string) { 168 | let keyPair; 169 | try { 170 | keyPair = buildSuiKeyPairFromPrivateKey(privateKey); 171 | } catch (error) { 172 | throw new Error(`Invalid Sui private key. Error: ${(error as Error)?.stack || error}`); 173 | } 174 | 175 | return keyPair.getPublicKey().toSuiAddress(); 176 | } 177 | -------------------------------------------------------------------------------- /src/grpc/README.md: -------------------------------------------------------------------------------- 1 | # Wallet Monitor gRPC Service 2 | 3 | This is a gRPC wrapper for the `WalletMonitor`. 4 | The goal of this service is to enable moving to a distributed architecture for scalability or high availability reasons. 5 | 6 | ## Motivation 7 | With `WalletMonitor` implementing at least monitoring, locking and rebalancing, it was proving difficult for 8 | multiple application clients to all own a `WalletManager` in *library modality*. 9 | This is because while it is desirable for all clients to be able to monitor or trying locking the same wallet, it is not desirable 10 | for all clients to be able to rebalance the same set of wallets. 11 | 12 | This motivated us to move to develop the option of instantiating a `WalletManager` in *service modality* within a distributed architecture. 13 | This allows for centralized rebalancing while still maintaining the ability for each application instance to instantiate a client 14 | pointing to the service for monitoring and locking. 15 | 16 | ## Usage 17 | The same configuration previously used for the *library modality* can also be used for both the *service modality* and the client object. 18 | Once again, pointing to the relevant schema is the best way to explain the configuration options. 19 | ```typescript 20 | const schema = z.object({ 21 | config: WalletManagerConfigSchema, 22 | options: WalletManagerOptionsSchema.optional(), 23 | grpc: WalletManagerGRPCConfigSchema 24 | }) 25 | ``` 26 | It is sufficient to add the `grpc` section to the configuration file. 27 | 28 | ### Building the docker image 29 | ```bash 30 | npm run build-docker-image 31 | ``` 32 | ### Running the service 33 | ```bash 34 | docker run -p 50051:50051 -v <>/config.json:/etc/wallet-manager/config.json -d wallet-manager:latest 35 | ``` 36 | 37 | ### Client 38 | Use of the utility function `buildWalletManager` is highly recommended in this case because it will automatically 39 | infer from the configuration object/file whether to build an actual `WalletManager` or a `WalletManagerClient` object. 40 | In either case, the user will only see an interface that only allows locking since that's the only functionality that is 41 | supported when running in *service modality*. 42 | 43 | ### Example 44 | Please refer to [this example](../../examples-d/remote-wallet-manager.ts) for a more detailed usage of the service. 45 | -------------------------------------------------------------------------------- /src/grpc/client.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChainWalletManager, 3 | WalletBalancesByAddress, 4 | WalletExecuteOptions, 5 | WithWalletExecutor, 6 | } from "../chain-wallet-manager"; 7 | import { ChainName, isChain } from "../wallets"; 8 | import { 9 | getDefaultNetwork, 10 | WalletManagerConfig, 11 | WalletManagerOptions, 12 | } from "../wallet-manager"; 13 | import winston from "winston"; 14 | import { createLogger, mapConcurrent } from "../utils"; 15 | import { IClientWalletManager } from "../i-wallet-manager"; 16 | import { createChannel, createClient } from "nice-grpc"; 17 | import { WalletManagerGRPCServiceDefinition } from "./out/wallet-manager-grpc-service"; 18 | 19 | export class ClientWalletManager implements IClientWalletManager { 20 | private walletManagerGRPCChannel; 21 | private walletManagerGRPCClient; 22 | private managers; 23 | 24 | protected logger: winston.Logger; 25 | 26 | constructor( 27 | private host: string, 28 | private port: number, 29 | config: WalletManagerConfig, 30 | options?: WalletManagerOptions, 31 | ) { 32 | this.logger = createLogger(options?.logger, options?.logLevel, { 33 | label: "WalletManager", 34 | }); 35 | this.managers = {} as Record; 36 | 37 | this.walletManagerGRPCChannel = createChannel(`${host}:${port}`); 38 | this.walletManagerGRPCClient = createClient( 39 | WalletManagerGRPCServiceDefinition, 40 | this.walletManagerGRPCChannel, 41 | ); 42 | 43 | // Constructing a record of manager for the only purpose of extracting the appropriate provider and private key 44 | // to bundle together with the lock acquired from the grpc service. 45 | for (const [chainName, chainConfig] of Object.entries(config)) { 46 | if (!isChain(chainName)) 47 | throw new Error(`Invalid chain name: ${chainName}`); 48 | const network = chainConfig.network || getDefaultNetwork(chainName); 49 | 50 | const chainManagerConfig = { 51 | network, 52 | chainName, 53 | logger: this.logger, 54 | rebalance: { ...chainConfig.rebalance, enabled: false }, 55 | walletOptions: chainConfig.chainConfig, 56 | }; 57 | 58 | this.managers[chainName] = new ChainWalletManager( 59 | chainManagerConfig, 60 | chainConfig.wallets, 61 | ); 62 | } 63 | } 64 | 65 | public async withWallet( 66 | chainName: ChainName, 67 | fn: WithWalletExecutor, 68 | opts?: WalletExecuteOptions, 69 | ): Promise { 70 | const chainManager = this.managers[chainName]; 71 | if (!chainManager) 72 | throw new Error(`No wallets configured for chain: ${chainName}`); 73 | 74 | const { address: acquiredAddress } = 75 | await this.walletManagerGRPCClient.acquireLock({ 76 | chainName, 77 | address: opts?.address, 78 | leaseTimeout: opts?.leaseTimeout, 79 | acquireTimeout: opts?.waitToAcquireTimeout, 80 | }); 81 | 82 | // FIXME 83 | // Dirty solution. We are doing as little work as possible to get the same expected WalletInterface after 84 | // locking. 85 | // Unfortunately this is not only inefficient (we lock 2 times) but also nonsense because, if we successfully 86 | // locked a particular address in the wallet manager service, it's impossible that we have it locked here. 87 | // Nevertheless, this should allow us to just make it work right now. 88 | const acquiredWallet = await this.managers[chainName].acquireLock({ 89 | ...opts, 90 | address: acquiredAddress, 91 | }); 92 | 93 | try { 94 | return await fn(acquiredWallet); 95 | } catch (error) { 96 | this.logger.error( 97 | "The workflow function failed to run within the context of the acquired wallet.", 98 | error, 99 | ); 100 | throw error; 101 | } finally { 102 | await Promise.all([ 103 | this.walletManagerGRPCClient.releaseLock({ 104 | chainName, 105 | address: acquiredAddress, 106 | }), 107 | this.managers[chainName].releaseLock(acquiredAddress), 108 | ]); 109 | } 110 | } 111 | 112 | public getBlockHeight(chainName: ChainName): Promise { 113 | const manager = this.managers[chainName]; 114 | if (!manager) 115 | throw new Error(`No wallets configured for chain: ${chainName}`); 116 | 117 | return manager.getBlockHeight(); 118 | } 119 | 120 | private async balanceHandlerMapper(method: "getBalances" | "pullBalances") { 121 | const balances: Record = {}; 122 | 123 | await mapConcurrent( 124 | Object.entries(this.managers), 125 | async ([chainName, manager]) => { 126 | const balancesByChain = await manager[method](); 127 | balances[chainName] = balancesByChain; 128 | }, 129 | ); 130 | 131 | return balances; 132 | } 133 | 134 | // PullBalances doesn't need balances to be refreshed in the background 135 | public async pullBalances(): Promise< 136 | Record 137 | > { 138 | return await this.balanceHandlerMapper("pullBalances"); 139 | } 140 | 141 | private validateBlockHeightByChain( 142 | blockHeightByChain: Record, 143 | ) { 144 | for (const chain in blockHeightByChain) { 145 | const manager = this.managers[chain as ChainName]; 146 | if (!manager) 147 | throw new Error(`No wallets configured for chain: ${chain}`); 148 | } 149 | } 150 | 151 | public async getBlockHeightForAllSupportedChains(): Promise< 152 | Record 153 | > { 154 | // Required concurrency is the number of chains as we want to fetch the block height for all chains in parallel 155 | // to be precise about the block height at the time of fetching balances 156 | let blockHeightPerChain = {} as Record; 157 | const requiredConcurrency = Object.keys(this.managers).length; 158 | await mapConcurrent( 159 | Object.entries(this.managers), 160 | async ([chainName, manager]) => { 161 | try { 162 | const blockHeight = await manager.getBlockHeight(); 163 | blockHeightPerChain = { 164 | ...blockHeightPerChain, 165 | [chainName]: blockHeight, 166 | } as Record; 167 | } catch (err) { 168 | throw new Error(`No block height found for chain: ${chainName}, error: ${err}`); 169 | } 170 | }, 171 | requiredConcurrency, 172 | ); 173 | return blockHeightPerChain; 174 | } 175 | 176 | // pullBalancesAtBlockHeight doesn't need balances to be refreshed in the background 177 | public async pullBalancesAtBlockHeight( 178 | blockHeightByChain?: Record, 179 | ): Promise> { 180 | const balances: Record = {}; 181 | if (blockHeightByChain) { 182 | this.validateBlockHeightByChain(blockHeightByChain); 183 | } 184 | 185 | const blockHeightPerChain = blockHeightByChain ?? await this.getBlockHeightForAllSupportedChains(); 186 | 187 | await mapConcurrent( 188 | Object.entries(this.managers), 189 | async ([chainName, manager]) => { 190 | const blockHeight = blockHeightPerChain[chainName as ChainName]; 191 | const balancesByChain = await manager.pullBalancesAtBlockHeight( 192 | blockHeight, 193 | ); 194 | balances[chainName] = balancesByChain; 195 | }, 196 | ); 197 | 198 | return balances; 199 | } 200 | 201 | public async pullBalancesAtCurrentBlockHeight(): Promise> { 202 | throw new Error("Method not implemented."); 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/grpc/out/.gitignore: -------------------------------------------------------------------------------- 1 | # This file will tell git to ignore build artifacts but not the .gitignore itself. 2 | # It allows us to commit an empty directory for build artifacts without the build artifacts. 3 | * 4 | !.gitignore 5 | -------------------------------------------------------------------------------- /src/grpc/proto/wallet-manager-grpc-service.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "google/protobuf/empty.proto"; 4 | 5 | service WalletManagerGRPCService { 6 | rpc AcquireLock (AcquireLockRequest) returns (AcquireLockResponse); 7 | rpc ReleaseLock (ReleaseLockRequest) returns (google.protobuf.Empty); 8 | } 9 | 10 | message AcquireLockRequest { 11 | string chain_name = 1; 12 | optional string address = 2; 13 | optional uint64 lease_timeout = 3; 14 | optional uint64 acquire_timeout = 4; 15 | } 16 | message AcquireLockResponse { 17 | string address = 1; 18 | } 19 | 20 | message ReleaseLockRequest { 21 | string chain_name = 1; 22 | string address = 2; 23 | } 24 | -------------------------------------------------------------------------------- /src/grpc/service-impl.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AcquireLockRequest, AcquireLockResponse, 3 | ReleaseLockRequest, 4 | WalletManagerGRPCServiceImplementation 5 | } from "./out/wallet-manager-grpc-service"; 6 | import {CallContext} from "nice-grpc-common"; 7 | import {DeepPartial, Empty} from "./out/google/protobuf/empty"; 8 | import {IServiceWalletManager} from "../i-wallet-manager"; 9 | import { ChainName } from "../wallets"; 10 | 11 | export class WalletManagerGRPCService implements WalletManagerGRPCServiceImplementation { 12 | constructor(private underlyingWalletManager: IServiceWalletManager) {} 13 | 14 | async acquireLock(request: AcquireLockRequest, context: CallContext): Promise> { 15 | const acquiredWallet = await this.underlyingWalletManager.acquireLock( 16 | request.chainName as ChainName, 17 | { address: request.address, leaseTimeout: request.leaseTimeout, waitToAcquireTimeout: request.acquireTimeout } 18 | ); 19 | 20 | return {address: acquiredWallet.address}; 21 | } 22 | 23 | async releaseLock(request: ReleaseLockRequest, context: CallContext): Promise> { 24 | await this.underlyingWalletManager.releaseLock(request.chainName as ChainName, request.address); 25 | 26 | return {}; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/grpc/service.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import {WalletManagerGRPCService} from "./service-impl"; 3 | import {createServer} from "nice-grpc"; 4 | import {WalletManagerGRPCServiceDefinition} from "./out/wallet-manager-grpc-service"; 5 | import * as fs from "fs"; 6 | import { 7 | WalletManager, WalletManagerConfigSchema, 8 | WalletManagerGRPCConfigSchema, WalletManagerOptionsSchema, 9 | } from "../wallet-manager"; 10 | 11 | const grpcServerSchema = z.object({ 12 | listeningAddress: z.string().default('0.0.0.0'), 13 | listeningPort: z.number().default(50051), 14 | }) 15 | 16 | function readConfig() { 17 | const filePath = '/etc/wallet-manager/config.json' 18 | const fileData = fs.readFileSync(filePath, 'utf-8') 19 | const parsedData = JSON.parse(fileData) 20 | 21 | const schema = z.object({ 22 | config: WalletManagerConfigSchema, 23 | options: WalletManagerOptionsSchema.optional(), 24 | grpc: WalletManagerGRPCConfigSchema 25 | }) 26 | 27 | return schema.parse(parsedData) 28 | } 29 | 30 | const fileConfig = readConfig(); 31 | 32 | const walletManager = new WalletManager(fileConfig.config, fileConfig.options) 33 | 34 | const walletManagerGRPCService = new WalletManagerGRPCService(walletManager); 35 | 36 | const server = createServer(); // TODO: add observability for the GRPC server 37 | server.add(WalletManagerGRPCServiceDefinition, walletManagerGRPCService); 38 | server.listen(fileConfig.grpc.listenAddress + ':' + fileConfig.grpc.listenPort); 39 | 40 | // FIXME: This only handles the signal sent by docker. It does not handle keyboard interrupts. 41 | process.on('SIGTERM', function () { 42 | console.log('Shutting down service...') 43 | // Starting a graceful shutdown and a non-graceful shutdown with a timer. 44 | // tryShutdown and forceShutdown are idempotent between themselves and each other. 45 | // Therefore, it is correct if those function execute simultaneously. 46 | setTimeout(function () { 47 | server.forceShutdown() 48 | console.log('Shutting down timed out (5 seconds). Forcing shutdown.') 49 | process.exit(1) 50 | }, 5_000) 51 | 52 | server.shutdown().then(() => { 53 | console.log('Done shutting down gracefully.'); 54 | process.exit(); 55 | }).catch((error) => { 56 | console.log("Graceful shutdown was unsuccessful: ", error); 57 | process.exit(1); 58 | }); 59 | }) 60 | -------------------------------------------------------------------------------- /src/i-wallet-manager.ts: -------------------------------------------------------------------------------- 1 | import {Providers, WalletBalancesByAddress, WalletExecuteOptions, WalletInterface, Wallets, WithWalletExecutor} from "./chain-wallet-manager"; 2 | import { ChainName } from "./wallets"; 3 | 4 | /* 5 | This file defines interfaces to be used as a mask for the WalletManager class. 6 | The WalletManager class is currently doing a lot of things that require care and knowledge of the how to handle it. 7 | Instead of going into a refactor of the class itself, we use these interfaces to expose only the minimal set 8 | of methods that are needed depending on the context. 9 | As of today, these are the contexts: 10 | - Library: Users import WalletManager from the package and integrate it into their code. 11 | - Service: The class is used by the gRPC service to implement its endpoints through composition with WalletManager. 12 | - Client: The class is used by the gRPC client to call the service. 13 | 14 | Library and Client need to both expose the same convenience .withWallet() method, but they need to do it in different ways. 15 | Service needs to expose the bare .acquireLock() and .releaseLock() methods in order for the gRPC service to wrap them in its own methods. 16 | */ 17 | 18 | interface IWMContextManagedLocks { 19 | withWallet

(chainName: ChainName, fn: WithWalletExecutor, opts?: WalletExecuteOptions): Promise; 20 | pullBalances: () => Promise>; 21 | pullBalancesAtCurrentBlockHeight: () => Promise>; 22 | getBlockHeight: (chainName: ChainName) => Promise; 23 | getBlockHeightForAllSupportedChains: () => Promise>; 24 | } 25 | interface IWMBareLocks { 26 | acquireLock(chainName: ChainName, opts?: WalletExecuteOptions): Promise 27 | releaseLock(chainName: ChainName, address: string): Promise 28 | } 29 | 30 | export type ILibraryWalletManager = IWMContextManagedLocks 31 | export type IClientWalletManager = IWMContextManagedLocks 32 | export type IServiceWalletManager = IWMBareLocks 33 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | 2 | export type { ILibraryWalletManager, IServiceWalletManager, IClientWalletManager } from './i-wallet-manager' 3 | export type { WalletManager, WalletManagerConfig, WalletManagerOptions, WalletManagerFullConfig, WalletRebalancingConfig } from './wallet-manager' 4 | export { buildWalletManager } from './utils' 5 | export type {WalletExecuteOptions, WithWalletExecutor} from './chain-wallet-manager'; 6 | 7 | import { EVM_CHAIN_CONFIGS } from './wallets/evm'; 8 | // import { SOLANA_CHAINS } from './wallets/solana'; 9 | import { 10 | WalletInterface as WI, 11 | WalletBalancesByAddress as WBBA, 12 | } from './chain-wallet-manager'; 13 | 14 | export type WalletBalancesByAddress = WBBA; 15 | export type WalletInterface = WI; 16 | export type {ChainName, Environment} from "./wallets"; 17 | 18 | export {isEvmChain, isSolanaChain, isSuiChain} from './wallets'; 19 | 20 | /** 21 | * A map of chain names to their available networks. 22 | * It's exposed to the library user for convenience. 23 | * In most cases you can use the network name as you would expect it and it should work, 24 | * but this map contains available networks for each chain just in case you want to be 100% sure. 25 | */ 26 | export const NETWORKS = { 27 | [EVM_CHAIN_CONFIGS.ethereum.chainName]: EVM_CHAIN_CONFIGS.ethereum.networks, 28 | [EVM_CHAIN_CONFIGS.polygon.chainName]: EVM_CHAIN_CONFIGS.polygon.networks, 29 | [EVM_CHAIN_CONFIGS.avalanche.chainName]: EVM_CHAIN_CONFIGS.avalanche.networks, 30 | }; 31 | 32 | -------------------------------------------------------------------------------- /src/price-assistant/helper.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { inspect } from "util"; 3 | import { Logger } from "winston"; 4 | import { CoinGeckoIds, supportedNativeTokensByEnv } from "./supported-tokens.config"; 5 | import { Environment } from "../wallets"; 6 | import { TokenInfo, WalletManagerConfig } from "../wallet-manager"; 7 | 8 | export type CoinGeckoPriceDict = Partial<{ 9 | [k in CoinGeckoIds]: { 10 | usd: number; 11 | }; 12 | }>; 13 | 14 | /** 15 | * @param tokens - array of coingecko ids for tokens 16 | */ 17 | export async function getCoingeckoPrices( 18 | tokens: string[] | string, 19 | logger: Logger, 20 | ): Promise { 21 | const tokensToProcess = 22 | typeof tokens === "string" ? tokens : tokens.join(","); 23 | const response = await axios.get( 24 | `https://api.coingecko.com/api/v3/simple/price?ids=${tokensToProcess}&vs_currencies=usd`, 25 | { 26 | headers: { 27 | Accept: "application/json", 28 | }, 29 | }, 30 | ); 31 | 32 | if (response.status != 200) { 33 | logger.warn( 34 | `Failed to get CoinGecko Prices. Response: ${inspect(response)}`, 35 | ); 36 | throw new Error(`HTTP status != 200. Original Status: ${response.status}`); 37 | } 38 | 39 | return response.data; 40 | } 41 | 42 | export function preparePriceFeedConfig (walletConfig: WalletManagerConfig, network?: string) { 43 | const priceFeedSupportedTokens = Object.values(walletConfig).reduce((acc, chainConfig) => { 44 | if (!chainConfig.priceFeedConfig?.supportedTokens) return acc; 45 | return [...acc, ...chainConfig.priceFeedConfig.supportedTokens]; 46 | }, [] as TokenInfo[]); 47 | 48 | // Inject native token into price feed config, if enabled 49 | // Note: It is safe to use "mainnet" fallback here, because we are only using native token's coingeckoId 50 | const environment: Environment = network ? network as Environment : Environment.MAINNET; 51 | const nativeTokens = supportedNativeTokensByEnv[environment]; 52 | 53 | for (const nativeToken of nativeTokens) { 54 | const isNativeTokenDefined = priceFeedSupportedTokens.find(token => token.coingeckoId === nativeToken.coingeckoId); 55 | if (!isNativeTokenDefined) { 56 | priceFeedSupportedTokens.push(nativeToken); 57 | } 58 | } 59 | return priceFeedSupportedTokens; 60 | } -------------------------------------------------------------------------------- /src/price-assistant/ondemand-price-feed.ts: -------------------------------------------------------------------------------- 1 | import { Gauge, Registry } from "prom-client"; 2 | import { Logger } from "winston"; 3 | import { TimeLimitedCache } from "../utils"; 4 | import { TokenInfo, WalletPriceFeedConfig } from "../wallet-manager"; 5 | import { getCoingeckoPrices } from "./helper"; 6 | import { CoinGeckoIds } from "./supported-tokens.config"; 7 | import { PriceFeed, TokenPriceData } from "./price-feed"; 8 | 9 | const DEFAULT_TOKEN_PRICE_RETENSION_TIME = 5 * 1000; // 5 seconds 10 | /** 11 | * OnDemandPriceFeed is a price feed that fetches token prices from coingecko on-demand 12 | */ 13 | export class OnDemandPriceFeed extends PriceFeed { 14 | // here cache key is tokenContractAddress 15 | private cache = new TimeLimitedCache(); 16 | supportedTokens: TokenInfo[]; 17 | tokenPriceGauge?: Gauge; 18 | private tokenContractToCoingeckoId: Record = {}; 19 | 20 | constructor( 21 | priceAssistantConfig: WalletPriceFeedConfig, 22 | logger: Logger, 23 | registry?: Registry, 24 | ) { 25 | super("ONDEMAND_TOKEN_PRICE", logger, registry, undefined); 26 | this.supportedTokens = priceAssistantConfig.supportedTokens; 27 | 28 | this.tokenContractToCoingeckoId = this.supportedTokens.reduce( 29 | (acc, token) => { 30 | acc[token.tokenContract] = token.coingeckoId as CoinGeckoIds; 31 | return acc; 32 | }, 33 | {} as Record, 34 | ); 35 | 36 | if (registry) { 37 | this.tokenPriceGauge = new Gauge({ 38 | name: "token_usd_price", 39 | help: "Current price for a token", 40 | labelNames: ["symbol"], 41 | registers: [registry], 42 | }); 43 | } 44 | } 45 | 46 | start() { 47 | // no op 48 | } 49 | 50 | stop() { 51 | // no op 52 | } 53 | 54 | public getCoinGeckoId(tokenContract: string): CoinGeckoIds | undefined { 55 | return this.tokenContractToCoingeckoId[tokenContract]; 56 | } 57 | 58 | protected get(coingeckoId: string): number | undefined { 59 | return this.cache.get(coingeckoId as CoinGeckoIds); 60 | } 61 | 62 | async pullTokenPrices() { 63 | const coingekoTokens = []; 64 | const priceDict: TokenPriceData = {}; 65 | for (const token of this.supportedTokens) { 66 | const { coingeckoId } = token; 67 | 68 | // Check if we already have the price for this token 69 | const cachedPrice = this.cache.get(coingeckoId); 70 | if (cachedPrice) { 71 | priceDict[coingeckoId] = cachedPrice; 72 | continue; 73 | } 74 | coingekoTokens.push(token); 75 | } 76 | 77 | if (coingekoTokens.length === 0) { 78 | // All the cached tokens price are already available and valid 79 | return priceDict; 80 | } 81 | 82 | const coingekoTokenIds = coingekoTokens.map(token => token.coingeckoId); 83 | const coingeckoData = await getCoingeckoPrices( 84 | coingekoTokenIds, 85 | this.logger, 86 | ); 87 | for (const token of this.supportedTokens) { 88 | const { coingeckoId, symbol } = token; 89 | 90 | if (!(coingeckoId in coingeckoData)) { 91 | this.logger.warn( 92 | `Token ${symbol} (coingecko: ${coingeckoId}) not found in coingecko response data`, 93 | ); 94 | continue; 95 | } 96 | 97 | const tokenPrice = coingeckoData?.[coingeckoId]?.usd; 98 | if (tokenPrice) { 99 | this.cache.set( 100 | coingeckoId, 101 | tokenPrice, 102 | DEFAULT_TOKEN_PRICE_RETENSION_TIME, 103 | ); 104 | priceDict[coingeckoId] = tokenPrice; 105 | this.tokenPriceGauge?.labels({ symbol }).set(tokenPrice); 106 | } 107 | } 108 | return priceDict; 109 | } 110 | 111 | async update() { 112 | // no op 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/price-assistant/price-feed.ts: -------------------------------------------------------------------------------- 1 | import { Counter, Registry } from "prom-client"; 2 | import { Logger } from "winston"; 3 | import { printError } from "../utils"; 4 | 5 | const DEFAULT_FEED_INTERVAL = 10_000; 6 | 7 | export type TokenPriceData = Partial>; 8 | 9 | export abstract class PriceFeed { 10 | private name: string; 11 | private interval?: NodeJS.Timeout; 12 | private locked: boolean; 13 | private runIntervalMs: number; 14 | private metrics?: BasePriceFeedMetrics; 15 | protected logger: Logger; 16 | 17 | constructor(name: string, logger: Logger, registry?: Registry, runIntervalMs?: number) { 18 | this.name = name; 19 | this.logger = logger; 20 | this.locked = false; 21 | this.runIntervalMs = runIntervalMs || DEFAULT_FEED_INTERVAL; 22 | if (registry) this.metrics = this.initMetrics(registry); 23 | } 24 | 25 | protected abstract update(): Promise; 26 | 27 | protected abstract get(key: K): V; 28 | 29 | public abstract pullTokenPrices (): Promise; 30 | 31 | public start(): void { 32 | this.interval = setInterval(() => this.run(), this.runIntervalMs); 33 | this.run(); 34 | } 35 | 36 | public stop(): void { 37 | clearInterval(this.interval); 38 | } 39 | 40 | public getKey(key: K): V { 41 | const result = this.get(key); 42 | if (result === undefined || result === null) this.logger.error(`PriceFeed Key Not Found: ${key}`); 43 | return result; 44 | } 45 | 46 | private initMetrics(registry: Registry): BasePriceFeedMetrics { 47 | return { 48 | pricePollCounter: new Counter({ 49 | name: `${this.name.toLowerCase()}_base_price_feed_poll_operations_total`, 50 | help: `Total number of price poll operations for feed ${this.name}`, 51 | registers: [registry], 52 | labelNames: ["status"], 53 | }), 54 | }; 55 | } 56 | 57 | protected countPollOperations(status: "success" | "failure") { 58 | this.metrics?.pricePollCounter.labels({ status }).inc(); 59 | } 60 | 61 | private async run() { 62 | if (this.locked) { 63 | this.logger.warn(`Feed is locked, skipping poll operation`); 64 | return; 65 | } 66 | 67 | this.locked = true; 68 | 69 | try { 70 | await this.update(); 71 | this.countPollOperations("success"); 72 | } catch (error) { 73 | this.logger.error(`Error trying to update feed data: ${printError(error)}`); 74 | this.countPollOperations("failure"); 75 | } finally { 76 | this.locked = false; 77 | } 78 | } 79 | } 80 | 81 | type BasePriceFeedMetrics = { 82 | pricePollCounter: Counter; 83 | }; 84 | -------------------------------------------------------------------------------- /src/price-assistant/scheduled-price-feed.ts: -------------------------------------------------------------------------------- 1 | import { Gauge, Registry } from "prom-client"; 2 | import { Logger } from "winston"; 3 | import { PriceFeed, TokenPriceData } from "./price-feed"; 4 | import { TokenInfo, WalletPriceFeedConfig, WalletPriceFeedOptions } from "../wallet-manager"; 5 | import { getCoingeckoPrices } from "./helper"; 6 | import { inspect } from "util"; 7 | import { CoinGeckoIds } from "./supported-tokens.config"; 8 | 9 | /** 10 | * ScheduledPriceFeed is a price feed that periodically fetches token prices from coingecko 11 | */ 12 | export class ScheduledPriceFeed extends PriceFeed { 13 | private data = {} as TokenPriceData; 14 | supportedTokens: TokenInfo[]; 15 | tokenPriceGauge?: Gauge; 16 | private tokenContractToCoingeckoId: Record = {}; 17 | 18 | constructor(priceFeedConfig: WalletPriceFeedConfig & WalletPriceFeedOptions, logger: Logger, registry?: Registry) { 19 | const {scheduled, supportedTokens} = priceFeedConfig; 20 | super("SCHEDULED_TOKEN_PRICE", logger, registry, scheduled?.interval); 21 | this.supportedTokens = supportedTokens; 22 | 23 | this.tokenContractToCoingeckoId = supportedTokens.reduce((acc, token) => { 24 | acc[token.tokenContract] = token.coingeckoId as CoinGeckoIds; 25 | return acc; 26 | }, {} as Record); 27 | 28 | if (registry) { 29 | this.tokenPriceGauge = new Gauge({ 30 | name: "token_usd_price", 31 | help: "Current price for a token", 32 | labelNames: ["symbol"], 33 | registers: [registry], 34 | }); 35 | } 36 | } 37 | 38 | public getCoinGeckoId (tokenContract: string): CoinGeckoIds | undefined { 39 | return this.tokenContractToCoingeckoId[tokenContract]; 40 | } 41 | 42 | async pullTokenPrices (): Promise { 43 | const priceDict: TokenPriceData = {}; 44 | for (const token of this.supportedTokens) { 45 | const { coingeckoId } = token; 46 | const tokenPrice = this.get(coingeckoId); 47 | if (tokenPrice) { 48 | priceDict[coingeckoId] = tokenPrice; 49 | } 50 | } 51 | return priceDict 52 | } 53 | 54 | async update() { 55 | const coingekoTokenIds = this.supportedTokens.map((token) => token.coingeckoId); 56 | const coingeckoData = await getCoingeckoPrices(coingekoTokenIds, this.logger); 57 | for (const token of this.supportedTokens) { 58 | const { coingeckoId, symbol } = token; 59 | 60 | if (!(coingeckoId in coingeckoData)) { 61 | this.logger.warn(`Token ${symbol} (coingecko: ${coingeckoId}) not found in coingecko response data`); 62 | continue; 63 | } 64 | 65 | const tokenPrice = coingeckoData?.[coingeckoId]?.usd; 66 | if (tokenPrice) { 67 | this.data[coingeckoId] = tokenPrice; 68 | this.tokenPriceGauge?.labels({ symbol }).set(tokenPrice); 69 | } 70 | } 71 | this.logger.debug(`Updated price feed token prices: ${inspect(this.data)}`); 72 | } 73 | 74 | protected get(coingeckoId: string): number | undefined { 75 | return this.data[coingeckoId]; 76 | } 77 | } 78 | 79 | -------------------------------------------------------------------------------- /src/price-assistant/supported-tokens.config.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { ChainName, Environment } from "../wallets"; 3 | import { TokenInfo } from "../wallet-manager"; 4 | 5 | // get all coingeckoIds from here: https://api.coingecko.com/api/v3/coins/list 6 | export const CoinGeckoIdsSchema = z 7 | .union([ 8 | z.literal("solana"), 9 | z.literal("ethereum"), 10 | z.literal("binancecoin"), 11 | z.literal("matic-network"), 12 | z.literal("avalanche-2"), 13 | z.literal("fantom"), 14 | z.literal("celo"), 15 | z.literal("moonbeam"), 16 | z.literal("dai"), 17 | z.literal("usd-coin"), 18 | z.literal("tether"), 19 | z.literal("wrapped-bitcoin"), 20 | z.literal("sui"), 21 | z.literal("arbitrum"), 22 | z.literal("optimism"), 23 | z.literal("klay-token"), 24 | z.literal("base"), 25 | z.literal("pyth-network"), 26 | z.literal("sepolia"), 27 | z.literal("osmosis"), 28 | z.literal("cosmos"), 29 | z.literal("evmos"), 30 | z.literal("kujira"), 31 | z.literal("gateway"), 32 | ]); 33 | 34 | export type CoinGeckoIds = z.infer; 35 | 36 | export const coinGeckoIdByChainName = { 37 | "solana": "solana", 38 | "ethereum": "ethereum", 39 | "bsc": "binancecoin", 40 | "polygon": "matic-network", 41 | "avalanche": "avalanche-2", 42 | "fantom": "fantom", 43 | "celo": "celo", 44 | "moonbeam": "moonbeam", 45 | "sui": "sui", 46 | "arbitrum": "arbitrum", 47 | "optimism": "optimism", 48 | "base": "base", 49 | "klaytn": "klay-token", 50 | "pythnet": "pyth-network", 51 | "sepolia": "ethereum", 52 | "osmosis": "osmosis", 53 | "cosmoshub": "cosmos", 54 | "evmos": "evmos", 55 | "kujira": "kujira", 56 | "wormchain": "gateway" 57 | } as const satisfies Record; 58 | 59 | const mainnetNativeTokens = [ 60 | { 61 | chainId: 1, 62 | chainName: "solana", 63 | coingeckoId: "solana", 64 | symbol: "WSOL", 65 | tokenContract: "069b8857feab8184fb687f634618c035dac439dc1aeb3b5598a0f00000000001", 66 | }, 67 | { 68 | chainId: 2, 69 | chainName: "ethereum", 70 | coingeckoId: "ethereum", 71 | symbol: "WETH", 72 | tokenContract: "000000000000000000000000C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", 73 | }, 74 | { 75 | chainId: 4, 76 | chainName: "bsc", 77 | coingeckoId: "binancecoin", 78 | symbol: "WBNB", 79 | tokenContract: "000000000000000000000000bb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c", 80 | }, 81 | { 82 | chainId: 5, 83 | chainName: "polygon", 84 | coingeckoId: "matic-network", 85 | symbol: "WMATIC", 86 | tokenContract: "0000000000000000000000000d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270", 87 | }, 88 | { 89 | chainId: 6, 90 | chainName: "avalanche", 91 | coingeckoId: "avalanche-2", 92 | symbol: "WAVAX", 93 | tokenContract: "000000000000000000000000B31f66AA3C1e785363F0875A1B74E27b85FD66c7", 94 | }, 95 | { 96 | chainId: 10, 97 | chainName: "fantom", 98 | coingeckoId: "fantom", 99 | symbol: "WFTM", 100 | tokenContract: "00000000000000000000000021be370D5312f44cB42ce377BC9b8a0cEF1A4C83", 101 | }, 102 | { 103 | chainId: 13, 104 | chainName: "klaytn", 105 | coingeckoId: "klay-token", 106 | symbol: "WKLAY", 107 | tokenContract: "", 108 | }, 109 | { 110 | chainId: 14, 111 | chainName: "celo", 112 | coingeckoId: "celo", 113 | symbol: "WCELO", 114 | tokenContract: "000000000000000000000000471EcE3750Da237f93B8E339c536989b8978a438", 115 | }, 116 | { 117 | chainId: 16, 118 | chainName: "moonbeam", 119 | coingeckoId: "moonbeam", 120 | symbol: "WGLMR", 121 | tokenContract: "000000000000000000000000Acc15dC74880C9944775448304B263D191c6077F", 122 | }, 123 | { 124 | chainId: 21, 125 | chainName: "sui", 126 | coingeckoId: "sui", 127 | symbol: "WSUI", 128 | tokenContract: "9258181f5ceac8dbffb7030890243caed69a9599d2886d957a9cb7656af3bdb3", 129 | }, 130 | { 131 | chainId: 23, 132 | chainName: "arbitrum", 133 | coingeckoId: "arbitrum", 134 | symbol: "WARB", 135 | tokenContract: "0x912CE59144191C1204E64559FE8253a0e49E6548", 136 | }, 137 | { 138 | chainId: 24, 139 | chainName: "optimism", 140 | coingeckoId: "optimism", 141 | symbol: "WOP", 142 | tokenContract: "0x4200000000000000000000000000000000000042", 143 | }, 144 | { 145 | chainId: 26, 146 | chainName: "pythnet", 147 | coingeckoId: "pyth-network", 148 | symbol: "WPYTH", 149 | tokenContract: "", 150 | }, 151 | { 152 | chainId: 30, 153 | chainName: "base", 154 | coingeckoId: "base", 155 | symbol: "WBASE", 156 | tokenContract: "0x07150e919B4De5fD6a63DE1F9384828396f25fDC", 157 | }, 158 | ] satisfies TokenInfo[]; 159 | 160 | const testnetNativeTokens = [ 161 | { 162 | chainId: 1, 163 | chainName: "solana", 164 | coingeckoId: "solana", 165 | symbol: "SOL", 166 | tokenContract: "", 167 | }, 168 | { 169 | chainId: 2, 170 | chainName: "ethereum", 171 | coingeckoId: "ethereum", 172 | symbol: "ETH", 173 | tokenContract: "000000000000000000000000B4FBF271143F4FBf7B91A5ded31805e42b2208d6", 174 | }, 175 | { 176 | chainId: 4, 177 | chainName: "bsc", 178 | coingeckoId: "binancecoin", 179 | symbol: "BNB", 180 | tokenContract: "000000000000000000000000ae13d989daC2f0dEbFf460aC112a837C89BAa7cd", 181 | }, 182 | { 183 | chainId: 5, 184 | chainName: "polygon", 185 | coingeckoId: "matic-network", 186 | symbol: "MATIC", 187 | tokenContract: "0000000000000000000000009c3C9283D3e44854697Cd22D3Faa240Cfb032889", 188 | }, 189 | { 190 | chainId: 6, 191 | chainName: "avalanche", 192 | coingeckoId: "avalanche-2", 193 | symbol: "AVAX", 194 | tokenContract: "000000000000000000000000d00ae08403B9bbb9124bB305C09058E32C39A48c", 195 | }, 196 | { 197 | chainId: 10, 198 | chainName: "fantom", 199 | coingeckoId: "fantom", 200 | symbol: "FTM", 201 | tokenContract: "000000000000000000000000f1277d1Ed8AD466beddF92ef448A132661956621", 202 | }, 203 | { 204 | chainId: 13, 205 | chainName: "klaytn", 206 | coingeckoId: "klay-token", 207 | symbol: "KLAY", 208 | tokenContract: "", 209 | }, 210 | { 211 | chainId: 14, 212 | chainName: "celo", 213 | coingeckoId: "celo", 214 | symbol: "CELO", 215 | tokenContract: "000000000000000000000000F194afDf50B03e69Bd7D057c1Aa9e10c9954E4C9", 216 | }, 217 | { 218 | chainId: 16, 219 | chainName: "moonbeam", 220 | coingeckoId: "moonbeam", 221 | symbol: "GLMR", 222 | tokenContract: "000000000000000000000000D909178CC99d318e4D46e7E66a972955859670E1", 223 | }, 224 | { 225 | chainId: 21, 226 | chainName: "sui", 227 | coingeckoId: "sui", 228 | symbol: "SUI", 229 | tokenContract: "587c29de216efd4219573e08a1f6964d4fa7cb714518c2c8a0f29abfa264327d", 230 | }, 231 | { 232 | chainId: 23, 233 | chainName: "arbitrum", 234 | coingeckoId: "arbitrum", 235 | symbol: "ARB", 236 | tokenContract: "0xF861378B543525ae0C47d33C90C954Dc774Ac1F9", 237 | }, 238 | { 239 | chainId: 24, 240 | chainName: "optimism", 241 | coingeckoId: "optimism", 242 | symbol: "OP", 243 | tokenContract: "0x4200000000000000000000000000000000000042", 244 | }, 245 | { 246 | chainId: 26, 247 | chainName: "pythnet", 248 | coingeckoId: "pyth-network", 249 | symbol: "PYTH", 250 | tokenContract: "", 251 | }, 252 | { 253 | chainId: 30, 254 | chainName: "base", 255 | coingeckoId: "base", 256 | symbol: "BASE", 257 | tokenContract: "", 258 | }, 259 | ] satisfies TokenInfo[]; 260 | 261 | export const supportedNativeTokensByEnv: Record = { 262 | [Environment.MAINNET]: mainnetNativeTokens, 263 | [Environment.TESTNET]: testnetNativeTokens, 264 | [Environment.DEVNET]: [], 265 | }; -------------------------------------------------------------------------------- /src/prometheus-exporter.ts: -------------------------------------------------------------------------------- 1 | import Koa from 'koa'; 2 | import Router from 'koa-router'; 3 | import { Counter, Gauge, Registry } from 'prom-client'; 4 | 5 | import { WalletBalance, TokenBalance } from './wallets'; 6 | import { TransferReceipt } from './wallets/base-wallet'; 7 | 8 | function updateBalancesGauge(gauge: Gauge, chainName: string, network: string, balance: WalletBalance | TokenBalance) { 9 | const { symbol, address, isNative } = balance; 10 | const tokenAddress = (balance as TokenBalance).tokenAddress || ''; 11 | gauge 12 | .labels(chainName, network, symbol, isNative.toString(), tokenAddress, address) 13 | .set(parseFloat(balance.formattedBalance)); 14 | } 15 | 16 | function updateBalancesInUsdGauge(gauge: Gauge, chainName: string, network: string, balance: WalletBalance | TokenBalance) { 17 | const { symbol, address, isNative, balanceUsd } = balance; 18 | 19 | if (!balanceUsd) return; 20 | 21 | const tokenAddress = (balance as TokenBalance).tokenAddress || ''; 22 | gauge 23 | .labels(chainName, network, symbol, isNative.toString(), tokenAddress, address) 24 | .set(Number(balanceUsd.toString())); 25 | } 26 | 27 | function updateAvailableWalletsGauge(gauge: Gauge, chainName: string, network: string, count: number) { 28 | gauge 29 | .labels(chainName, network) 30 | .set(count); 31 | } 32 | 33 | function updateWalletsLockPeriodGauge(gauge: Gauge, chainName: string, network: string, walletAddress: string, lockTime: number) { 34 | gauge 35 | .labels(chainName, network, walletAddress) 36 | .set(lockTime); 37 | } 38 | 39 | function createRebalanceExpenditureCounter (registry: Registry, name: string) { 40 | return new Counter({ 41 | name, 42 | help: "Total native token cost of the rebalances performed", 43 | labelNames: ["chain_name", "strategy"], 44 | registers: [registry], 45 | }); 46 | } 47 | 48 | function createRebalanceInstructionsCounter (registry: Registry, name: string) { 49 | return new Counter({ 50 | name, 51 | help: "Total number of instructions executed during rebalances", 52 | labelNames: ["chain_name", "strategy", "status"], 53 | registers: [registry], 54 | }); 55 | } 56 | 57 | function createCounter(registry: Registry, name: string, help: string, labels: string[]) { 58 | return new Counter({ 59 | name, 60 | help, 61 | labelNames: labels, 62 | registers: [registry], 63 | }); 64 | } 65 | 66 | function createBalancesGauge(registry: Registry, gaugeName: string) { 67 | return new Gauge({ 68 | name: gaugeName, 69 | help: "Balances pulled for each configured wallet", 70 | labelNames: ["chain_name", "network", "symbol", "is_native", "token_address", "wallet_address"], 71 | registers: [registry], 72 | }); 73 | } 74 | 75 | function createBalancesInUsdGauge(registry: Registry, gaugeName: string) { 76 | return new Gauge({ 77 | name: gaugeName, 78 | help: "Balances pulled for each configured wallet in USD", 79 | labelNames: ["chain_name", "network", "symbol", "is_native", "token_address", "wallet_address"], 80 | registers: [registry], 81 | }); 82 | } 83 | 84 | function createAvailableWalletsGauge(registry: Registry, gaugeName: string) { 85 | return new Gauge({ 86 | name: gaugeName, 87 | help: "Number of available wallets per chain", 88 | labelNames: ["chain_name", "network"], 89 | registers: [registry], 90 | }); 91 | } 92 | 93 | function createWalletsLockPeriodGauge(registry: Registry, gaugeName: string) { 94 | return new Gauge({ 95 | name: gaugeName, 96 | help: "Amount of time a wallet was locked", 97 | labelNames: ["chain_name", "network", "wallet_address"], 98 | registers: [registry], 99 | }); 100 | } 101 | 102 | function startMetricsServer ( 103 | port: number, path: string, getMetrics: () => Promise 104 | ): Promise { 105 | const app = new Koa(); 106 | const router = new Router(); 107 | 108 | router.get(path, async (ctx: Koa.Context) => { 109 | ctx.body = await getMetrics(); 110 | }); 111 | 112 | app.use(router.routes()); 113 | app.use(router.allowedMethods()); 114 | 115 | return new Promise(resolve => { 116 | app.listen(port, () => { 117 | resolve(app); 118 | }) 119 | }); 120 | } 121 | 122 | export class PrometheusExporter { 123 | public app?: Koa; 124 | 125 | private balancesGauge: Gauge; 126 | private balancesUsdGauge: Gauge; 127 | private availableWalletsGauge: Gauge; 128 | private walletsLockPeriodGauge: Gauge; 129 | private rebalanceExpenditureCounter: Counter; 130 | private rebalanceInstructionsCounter: Counter; 131 | private locksAcquiredCounter: Counter; 132 | 133 | 134 | private prometheusPort: number; 135 | private prometheusPath: string; 136 | private registry: Registry; 137 | 138 | constructor(port?: number, path?: string, registry?: Registry) { 139 | this.registry = registry || new Registry(); 140 | this.prometheusPort = port || 9090; 141 | this.prometheusPath = path || '/metrics'; 142 | 143 | this.balancesGauge = createBalancesGauge(this.registry, 'wallet_monitor_balance'); 144 | this.balancesUsdGauge = createBalancesInUsdGauge(this.registry, 'wallet_monitor_balance_usd') 145 | this.availableWalletsGauge = createAvailableWalletsGauge(this.registry, 'wallet_monitor_available_wallets'); 146 | this.walletsLockPeriodGauge = createWalletsLockPeriodGauge(this.registry, 'wallet_monitor_wallets_lock_period') 147 | this.rebalanceExpenditureCounter = createRebalanceExpenditureCounter(this.registry, 'wallet_monitor_rebalance_expenditure'); 148 | this.rebalanceInstructionsCounter = createRebalanceInstructionsCounter(this.registry, 'wallet_monitor_rebalance_instruction'); 149 | this.locksAcquiredCounter = createCounter(this.registry, "locks_acquired_total", "Total number of acquired locks", ['chain_name', 'status']); 150 | } 151 | 152 | public getRegistry() { 153 | return this.registry; 154 | } 155 | 156 | public metrics() { 157 | return this.registry.metrics(); 158 | } 159 | 160 | public updateBalances(chainName: string, network: string, balances: WalletBalance[]) { 161 | balances.forEach(balance => { 162 | updateBalancesGauge(this.balancesGauge, chainName, network, balance); 163 | 164 | balance.tokens?.forEach((tokenBalance: TokenBalance) => { 165 | updateBalancesGauge(this.balancesGauge, chainName, network, tokenBalance); 166 | // Only added for token balances as of now 167 | updateBalancesInUsdGauge(this.balancesUsdGauge, chainName, network, tokenBalance) 168 | }); 169 | }); 170 | } 171 | 172 | public updateActiveWallets (chainName: string, network: string, count: number) { 173 | updateAvailableWalletsGauge(this.availableWalletsGauge, chainName, network, count); 174 | } 175 | 176 | public updateWalletsLockPeriod(chainName: string, network: string, walletAddress: string, lockTime: number) { 177 | updateWalletsLockPeriodGauge(this.walletsLockPeriodGauge, chainName, network, walletAddress, lockTime); 178 | } 179 | 180 | public updateRebalanceSuccess(chainName: string, strategy: string, receipts: TransferReceipt[]) { 181 | this.rebalanceInstructionsCounter.labels(chainName, strategy, "success").inc(receipts.length); 182 | const totalExpenditure = receipts.reduce((total, receipt) => { 183 | return total + parseFloat(receipt.formattedCost); 184 | }, 0); 185 | this.rebalanceExpenditureCounter.labels(chainName, strategy).inc(totalExpenditure); 186 | } 187 | 188 | public updateRebalanceFailure(chainName: string, strategy: string) { 189 | this.rebalanceInstructionsCounter.labels(chainName, strategy, "failed").inc(); 190 | } 191 | 192 | public increaseAcquiredLocks(chainName: string) { 193 | this.locksAcquiredCounter.labels(chainName, "success").inc(); 194 | } 195 | 196 | public increaseAcquireLockFailure(chainName: string) { 197 | this.locksAcquiredCounter.labels(chainName, "failed").inc(); 198 | } 199 | 200 | public async startMetricsServer(): Promise { 201 | this.app = await startMetricsServer(this.prometheusPort, this.prometheusPath, async () => { 202 | const metrics = await this.metrics() 203 | return metrics; 204 | }); 205 | } 206 | } -------------------------------------------------------------------------------- /src/rebalance-strategies.ts: -------------------------------------------------------------------------------- 1 | import { WalletBalancesByAddress } from "./chain-wallet-manager"; 2 | import { deepClone, omit } from "./utils"; 3 | 4 | export type RebalanceInstruction = { 5 | sourceAddress: string; 6 | targetAddress: string; 7 | amount: number; 8 | } 9 | 10 | export type RebalanceStrategy = (balances: WalletBalancesByAddress, minBalance: number) => RebalanceInstruction[]; 11 | 12 | export type RebalanceStrategyName = keyof typeof rebalanceStrategies; 13 | 14 | const getAddressWithMaxBalance = (balances: WalletBalancesByAddress) => { 15 | let max = 0; 16 | let maxAddress = ''; 17 | for (const balance of Object.values(balances)) { 18 | if (!max || +balance.formattedBalance > max) { 19 | max = +balance.formattedBalance; 20 | maxAddress = balance.address; 21 | } 22 | } 23 | 24 | return maxAddress; 25 | }; 26 | 27 | function pourOverRebalanceStrategy(balances: WalletBalancesByAddress, minBalance: number) { 28 | let sources = {} as WalletBalancesByAddress; 29 | const targets = {} as WalletBalancesByAddress; 30 | 31 | for (const [address, balance] of Object.entries(balances)) { 32 | if (+balance.formattedBalance < minBalance) { 33 | targets[address] = deepClone(balance); 34 | } 35 | 36 | else { 37 | sources[address] = deepClone(balance); 38 | } 39 | } 40 | 41 | if (!Object.keys(targets).length) return []; 42 | 43 | const instructions: RebalanceInstruction[] = []; 44 | 45 | for (const [targetAddress, target] of Object.entries(targets)) { 46 | if (!Object.keys(sources).length) throw new Error('No possible sources to rebalance from'); 47 | 48 | const sourceAddress = getAddressWithMaxBalance(sources); 49 | 50 | const difference = +sources[sourceAddress].formattedBalance - minBalance; 51 | 52 | const amount = difference / 2; 53 | 54 | instructions.push({ sourceAddress, targetAddress: targetAddress, amount }); 55 | 56 | const newSourceBalance = +sources[sourceAddress].formattedBalance - amount; 57 | 58 | if (newSourceBalance < (minBalance * 2)) { 59 | sources = omit(sources, sourceAddress); 60 | } 61 | 62 | else { 63 | sources[sourceAddress].formattedBalance = String(newSourceBalance); 64 | } 65 | 66 | const newTargetBalance = +targets[targetAddress].formattedBalance + amount; 67 | targets[targetAddress].formattedBalance = String(newTargetBalance); 68 | 69 | if (newTargetBalance > minBalance) { 70 | sources[targetAddress] = targets[target.address]; 71 | } 72 | } 73 | 74 | return instructions; 75 | } 76 | 77 | export const rebalanceStrategies: Record = { 78 | pourOver: pourOverRebalanceStrategy, 79 | }; -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { map } from 'bluebird'; 2 | import winston from 'winston'; 3 | import { inspect } from "util"; 4 | import { 5 | WalletManager, WalletManagerFullConfig, WalletManagerFullConfigSchema 6 | } from "./wallet-manager"; 7 | import {ClientWalletManager} from "./grpc/client"; 8 | import {IClientWalletManager, ILibraryWalletManager} from "./i-wallet-manager"; 9 | 10 | export function mapConcurrent(iterable: T[], mapper: (x: T, i: number, s: number) => any, concurrency = 1): Promise { 11 | return map(iterable, mapper, { concurrency }); 12 | } 13 | 14 | export function deepClone(obj: T): T { 15 | return JSON.parse(JSON.stringify(obj)); 16 | } 17 | 18 | export function omit(obj: any, key: string) { 19 | const { [key]: _, ...rest } = obj; 20 | return rest; 21 | } 22 | 23 | export function createLogger(logger?: winston.Logger, logLevel?: string, meta?: any) { 24 | if (logger) return logger; 25 | 26 | const silent = !logLevel || logLevel === 'silent'; 27 | 28 | const transport = silent 29 | ? new winston.transports.Console({ silent }) 30 | : new winston.transports.Console({ 31 | format: winston.format.combine( 32 | winston.format.label({ label: 'WalletManager' }), 33 | winston.format.colorize(), 34 | winston.format.splat(), 35 | winston.format.simple(), 36 | winston.format.timestamp({ 37 | format: "YYYY-MM-DD HH:mm:ss.SSS", 38 | }), 39 | winston.format.errors({ stack: true }) 40 | ) 41 | }); 42 | 43 | const _logger = winston.createLogger({ 44 | level: logLevel || 'error', 45 | transports: [transport], 46 | }); 47 | 48 | return _logger; 49 | } 50 | 51 | // This function will return the same object with the minimal set of methods required to work for the 52 | // requested context. 53 | export function buildWalletManager(rawWMFullConfig: WalletManagerFullConfig): IClientWalletManager | ILibraryWalletManager { 54 | const { config, options, grpc } = WalletManagerFullConfigSchema.parse(rawWMFullConfig) 55 | 56 | if (grpc) { 57 | return new ClientWalletManager(grpc.connectAddress, grpc.connectPort, config, options) as IClientWalletManager 58 | } else { 59 | return new WalletManager(config, options) as ILibraryWalletManager 60 | } 61 | } 62 | 63 | export type Accessor = (obj: T) => number; 64 | 65 | export function findMedian(arr: T[], accessor: Accessor): number | undefined { 66 | const sortedArr = arr.slice().sort((a, b) => accessor(a) - accessor(b)); 67 | const len = sortedArr.length; 68 | if (len === 0) { 69 | return undefined; 70 | } 71 | const mid = Math.floor(len / 2); 72 | return len % 2 === 0 ? (accessor(sortedArr[mid - 1]) + accessor(sortedArr[mid])) / 2 : accessor(sortedArr[mid]); 73 | } 74 | 75 | export function printError(error: unknown): string { 76 | if (error instanceof Error) { 77 | return `${error?.stack || error.message}`; 78 | } 79 | 80 | // Prints nested properties until a depth of 2 by default. 81 | return inspect(error); 82 | } 83 | 84 | interface TTL { 85 | value: T; 86 | timerId: NodeJS.Timeout; 87 | } 88 | 89 | export class TimeLimitedCache { 90 | private readonly cache: Map> 91 | constructor() { 92 | this.cache = new Map>(); 93 | } 94 | 95 | delete (key: K) { 96 | this.cache.delete(key); 97 | } 98 | 99 | set(key: K, value: V, duration: number): boolean { 100 | const doesKeyExists = this.cache.has(key); 101 | if (doesKeyExists) { 102 | clearTimeout(this.cache.get(key)?.timerId); 103 | } 104 | const timerId = setTimeout(() => this.delete(key), duration) 105 | this.cache.set(key, {value, timerId}) 106 | return !!doesKeyExists 107 | } 108 | 109 | get(key: K): V | undefined { 110 | return this.cache.get(key)?.value; 111 | } 112 | } -------------------------------------------------------------------------------- /src/wallets/base-wallet.ts: -------------------------------------------------------------------------------- 1 | import winston from "winston"; 2 | import { WalletBalance, TokenBalance, WalletOptions, WalletConfig } from "."; 3 | import { LocalWalletPool, WalletPool } from "./wallet-pool"; 4 | import { createLogger } from "../utils"; 5 | import { Wallets } from "../chain-wallet-manager"; 6 | 7 | export type BaseWalletOptions = { 8 | logger: winston.Logger; 9 | failOnInvalidTokens: boolean; 10 | }; 11 | 12 | export type TransferReceipt = { 13 | transactionHash: string; 14 | gasUsed: string; 15 | gasPrice: string; 16 | formattedCost: string; 17 | }; 18 | 19 | const DEFAULT_WALLET_ACQUIRE_TIMEOUT = 5000; 20 | 21 | export type WalletData = { 22 | address: string; 23 | privateKey?: string; 24 | tokens: string[]; 25 | }; 26 | 27 | export abstract class WalletToolbox { 28 | private warm = false; 29 | private walletPool: WalletPool; 30 | protected balancesByWalletAddress: Record = {}; 31 | protected wallets: Record; 32 | protected logger: winston.Logger; 33 | 34 | protected abstract validateNetwork(network: string): boolean; 35 | 36 | protected abstract validateChainName(chainName: string): boolean; 37 | 38 | protected abstract validateOptions(options: any): boolean; 39 | 40 | protected abstract validateTokenAddress(token: string): boolean; 41 | 42 | // Should parse tokens received from the user. 43 | // The tokens returned should be a list of token addresses used by the chain client 44 | // Example: ["DAI", "USDC"] => ["0x00000000", "0x00000001"]; 45 | protected abstract parseTokensConfig( 46 | tokens: string[], 47 | failOnInvalidTokens: boolean, 48 | ): string[]; 49 | 50 | // Should instantiate provider for the chain 51 | // calculate data which could be re-utilized (for example token's local addresses, symbol and decimals in evm chains) 52 | protected abstract warmup(): Promise; 53 | 54 | protected abstract getAddressFromPrivateKey( 55 | privateKey: string, 56 | options?: WalletOptions, 57 | ): string; 58 | 59 | // Should return balances for a native address in the chain 60 | abstract pullNativeBalance( 61 | address: string, 62 | blockHeight?: number, 63 | ): Promise; 64 | 65 | // Should return balances for tokens in the list for the address specified 66 | abstract pullTokenBalances( 67 | address: string, 68 | tokens: string[], 69 | ): Promise; 70 | 71 | protected abstract transferNativeBalance( 72 | privateKey: string, 73 | targetAddress: string, 74 | amount: number, 75 | maxGasPrice?: number, 76 | gasLimit?: number, 77 | ): Promise; 78 | 79 | protected abstract getRawWallet(privateKey: string): Promise; 80 | 81 | public abstract getGasPrice(): Promise; 82 | 83 | public abstract getBlockHeight(): Promise; 84 | 85 | constructor( 86 | protected network: string, 87 | protected chainName: string, 88 | protected rawConfig: WalletConfig[], 89 | options: WalletOptions, 90 | ) { 91 | this.logger = createLogger(options.logger); 92 | 93 | this.validateNetwork(network); 94 | this.validateChainName(chainName); 95 | this.validateOptions(options); 96 | 97 | const wallets = {} as Record; 98 | 99 | for (const raw of rawConfig) { 100 | const config = this.buildWalletConfig(raw, options); 101 | this.validateConfig(config, options.failOnInvalidTokens); 102 | wallets[config.address] = config; 103 | } 104 | 105 | this.wallets = wallets; 106 | 107 | this.walletPool = new LocalWalletPool(Object.keys(this.wallets)); // if HA: new DistributedWalletPool(); 108 | } 109 | 110 | public async pullBalances( 111 | isRebalancingEnabled = false, 112 | minBalanceThreshold?: number, 113 | blockHeight?: number, 114 | ): Promise { 115 | if (!this.warm) { 116 | this.logger.debug( 117 | `Warming up wallet toolbox for chain ${this.chainName}...`, 118 | ); 119 | try { 120 | await this.warmup(); 121 | } catch (error) { 122 | this.logger.error( 123 | `Error warming up wallet toolbox for chain (${this.chainName}): ${error}`, 124 | ); 125 | return []; 126 | } 127 | this.warm = true; 128 | } 129 | 130 | const balances: WalletBalance[] = []; 131 | 132 | for (const config of Object.values(this.wallets)) { 133 | const { address, tokens } = config; 134 | 135 | this.logger.verbose(`Pulling balances for ${address}...`); 136 | 137 | let nativeBalance: WalletBalance; 138 | 139 | try { 140 | nativeBalance = await this.pullNativeBalance(address, blockHeight); 141 | 142 | this.addOrDiscardWalletIfRequired( 143 | isRebalancingEnabled, 144 | address, 145 | nativeBalance, 146 | minBalanceThreshold ?? 0, 147 | ); 148 | 149 | balances.push(nativeBalance); 150 | 151 | this.logger.debug( 152 | `Balances for ${address} pulled: ${JSON.stringify(nativeBalance)}`, 153 | ); 154 | } catch (error) { 155 | this.logger.error( 156 | `Error pulling native balance for ${address}: ${error}`, 157 | ); 158 | continue; 159 | } 160 | 161 | if (!tokens || tokens.length === 0) { 162 | this.logger.verbose(`No token balances to pull for ${address}`); 163 | continue; 164 | } 165 | 166 | this.logger.verbose( 167 | `Pulling tokens (${tokens.join(", ")}) for ${address}...`, 168 | ); 169 | 170 | try { 171 | const tokenBalances = await this.pullTokenBalances(address, tokens); 172 | 173 | this.logger.debug( 174 | `Token balances for ${address} pulled: ${JSON.stringify( 175 | tokenBalances, 176 | )}`, 177 | ); 178 | 179 | nativeBalance.tokens.push(...tokenBalances); 180 | } catch (error) { 181 | this.logger.error( 182 | `Error pulling token balances for ${address}: ${error}`, 183 | ); 184 | } 185 | } 186 | 187 | return balances; 188 | } 189 | 190 | public async pullBalancesAtBlockHeight(blockHeight: number) { 191 | return this.pullBalances(false, undefined, blockHeight); 192 | } 193 | 194 | public async acquire(address?: string, acquireTimeout?: number) { 195 | const timeout = acquireTimeout || DEFAULT_WALLET_ACQUIRE_TIMEOUT; 196 | // this.grpcClient.acquireWallet(address); 197 | const walletAddress = await this.walletPool.blockAndAcquire( 198 | timeout, 199 | address, 200 | ); 201 | 202 | const privateKey = this.wallets[walletAddress].privateKey; 203 | 204 | return { 205 | address: walletAddress, 206 | rawWallet: await this.getRawWallet(privateKey!), 207 | }; 208 | } 209 | 210 | public async release(address: string) { 211 | await this.walletPool.release(address); 212 | } 213 | 214 | public async transferBalance( 215 | sourceAddress: string, 216 | targetAddress: string, 217 | amount: number, 218 | maxGasPrice?: number, 219 | gasLimit?: number, 220 | ) { 221 | const privateKey = this.wallets[sourceAddress].privateKey; 222 | 223 | if (!privateKey) { 224 | throw new Error(`Private key for ${sourceAddress} not found`); 225 | } 226 | 227 | await this.walletPool.blockAndAcquire( 228 | DEFAULT_WALLET_ACQUIRE_TIMEOUT, 229 | sourceAddress, 230 | ); 231 | 232 | let receipt; 233 | try { 234 | receipt = await this.transferNativeBalance( 235 | privateKey, 236 | targetAddress, 237 | amount, 238 | maxGasPrice, 239 | gasLimit, 240 | ); 241 | } catch (error) { 242 | await this.walletPool.release(sourceAddress); 243 | throw error; 244 | } 245 | 246 | await this.walletPool.release(sourceAddress); 247 | 248 | return receipt; 249 | } 250 | 251 | private validateConfig(rawConfig: any, failOnInvalidTokens: boolean) { 252 | if (!rawConfig.address && !rawConfig.privateKey) 253 | throw new Error( 254 | `Invalid config for chain: ${this.chainName}: Missing address`, 255 | ); 256 | 257 | if (failOnInvalidTokens && rawConfig.tokens?.length) { 258 | rawConfig.tokens.forEach((token: any) => { 259 | if (!this.validateTokenAddress(token)) { 260 | throw new Error( 261 | `Token not supported for ${this.chainName}[${this.network}]: ${token}`, 262 | ); 263 | } 264 | }); 265 | } 266 | 267 | return true; 268 | } 269 | 270 | private buildWalletConfig( 271 | rawConfig: any, 272 | options: WalletOptions, 273 | ): WalletData { 274 | const privateKey = rawConfig.privateKey; 275 | const { failOnInvalidTokens } = options; 276 | 277 | const address = 278 | rawConfig.address || this.getAddressFromPrivateKey(privateKey, options); 279 | 280 | const tokens = rawConfig.tokens 281 | ? this.parseTokensConfig(rawConfig.tokens, failOnInvalidTokens) 282 | : []; 283 | 284 | return { address, privateKey, tokens }; 285 | } 286 | 287 | /** 288 | * Removes the wallet from the pool or adds the wallet back in the 289 | * pool based on the min threshold specified in the rebalance config 290 | * 291 | * @param address wallet address 292 | * @param balance native balance in the wallet 293 | * @param minBalanceThreshold passed from rebalance config, defaults to zero 294 | * @returns true if there is enough balance on the wallet, false otherwise 295 | */ 296 | private addOrDiscardWalletIfRequired( 297 | isRebalancingEnabled: boolean, 298 | address: string, 299 | balance: WalletBalance, 300 | minBalanceThreshold: number, 301 | ): boolean { 302 | const isEnoughWalletBalance = 303 | Number(balance.formattedBalance) >= minBalanceThreshold; 304 | 305 | if (isRebalancingEnabled && balance.isNative) { 306 | if (isEnoughWalletBalance) { 307 | // set's the discarded flag on the wallet to false 308 | this.walletPool.addWalletBackToPoolIfRequired(address); 309 | } else { 310 | // set's the discarded flag on the wallet to true 311 | this.walletPool.discardWalletFromPool(address); 312 | } 313 | } 314 | 315 | return isEnoughWalletBalance; 316 | } 317 | } 318 | -------------------------------------------------------------------------------- /src/wallets/cosmos/cosmoshub.config.ts: -------------------------------------------------------------------------------- 1 | import { CosmosDefaultConfigs } from "."; 2 | 3 | const COSMOSHUB_MAINNET = "mainnet"; 4 | const COSMOSHUB_TESTNET = "testnet"; 5 | const COSMOSHUB_CURRENCY_SYMBOL = "ATOM"; 6 | const COSMOSHUB_NATIVE_DENOM = "uatom"; 7 | const COSMOSHUB_ADDRESS_PREFIX = "cosmos"; 8 | const COSMOSHUB_DEFAULT_DECIMALS = 6; 9 | 10 | // See: https://medium.com/@Pat_who/gas-fees-gas-price-on-cosmos-network-how-do-you-manage-them-bdf304b15a52 11 | const COSMOSHUB_MIN_GAS_PRICE = "0.025uatom"; 12 | 13 | export const COSMOSHUB = "cosmoshub"; 14 | export const COSMOSHUB_NETWORKS = { 15 | [COSMOSHUB_MAINNET]: 1, 16 | [COSMOSHUB_TESTNET]: 2, 17 | }; 18 | 19 | export const COSMOSHUB_KNOWN_TOKENS = { 20 | [COSMOSHUB_MAINNET]: {}, 21 | [COSMOSHUB_TESTNET]: {}, 22 | }; 23 | 24 | const COSMOSHUB_DEFAULT_TOKEN_POLL_CONCURRENCY = 10; 25 | 26 | export const COSMOSHUB_DEFAULT_CONFIGS: CosmosDefaultConfigs = { 27 | [COSMOSHUB_MAINNET]: { 28 | nodeUrl: "https://cosmos-rpc.publicnode.com:443", 29 | tokenPollConcurrency: COSMOSHUB_DEFAULT_TOKEN_POLL_CONCURRENCY, 30 | nativeDenom: COSMOSHUB_NATIVE_DENOM, 31 | addressPrefix: COSMOSHUB_ADDRESS_PREFIX, 32 | defaultDecimals: COSMOSHUB_DEFAULT_DECIMALS, 33 | minGasPrice: COSMOSHUB_MIN_GAS_PRICE, 34 | }, 35 | [COSMOSHUB_TESTNET]: { 36 | nodeUrl: "https://cosmos-testnet-rpc.polkachu.com", 37 | tokenPollConcurrency: COSMOSHUB_DEFAULT_TOKEN_POLL_CONCURRENCY, 38 | nativeDenom: COSMOSHUB_NATIVE_DENOM, 39 | addressPrefix: COSMOSHUB_ADDRESS_PREFIX, 40 | defaultDecimals: COSMOSHUB_DEFAULT_DECIMALS, 41 | minGasPrice: COSMOSHUB_MIN_GAS_PRICE, 42 | }, 43 | }; 44 | 45 | export const COSMOSHUB_CHAIN_CONFIG = { 46 | chainName: COSMOSHUB, 47 | networks: COSMOSHUB_NETWORKS, 48 | knownTokens: COSMOSHUB_KNOWN_TOKENS, 49 | defaultConfigs: COSMOSHUB_DEFAULT_CONFIGS, 50 | nativeCurrencySymbol: COSMOSHUB_CURRENCY_SYMBOL, 51 | defaultNetwork: COSMOSHUB_MAINNET, 52 | }; 53 | 54 | export type CosmoshubNetwork = keyof typeof COSMOSHUB_NETWORKS; 55 | -------------------------------------------------------------------------------- /src/wallets/cosmos/evmos.config.ts: -------------------------------------------------------------------------------- 1 | import { CosmosDefaultConfigs } from "."; 2 | 3 | const EVMOS_MAINNET = "mainnet"; 4 | const EVMOS_TESTNET = "testnet"; 5 | const EVMOS_CURRENCY_SYMBOL = "EVMOS"; 6 | const EVMOS_NATIVE_DENOM = "aevmos"; 7 | const EVMOS_ADDRESS_PREFIX = "evmos"; 8 | 9 | const EVMOS_CURRENCY_SYMBOL_TESTNET = "TEVMOS"; 10 | const EVMOS_NATIVE_DENOM_TESTNET = "atevmos"; 11 | const EVMOS_DEFAULT_DECIMALS = 18; 12 | 13 | // See: https://docs.evmos.org/protocol/modules/feemarket#parameters 14 | const EVMOS_MIN_GAS_PRICE_MAINNET = "0aevmos"; 15 | const EVMOS_MIN_GAS_PRICE_TESTNET = "0atevmos"; 16 | 17 | export const EVMOS = "evmos"; 18 | export const EVMOS_NETWORKS = { 19 | [EVMOS_MAINNET]: 1, 20 | [EVMOS_TESTNET]: 2, 21 | }; 22 | 23 | export const EVMOS_KNOWN_TOKENS = { 24 | [EVMOS_MAINNET]: {}, 25 | [EVMOS_TESTNET]: {}, 26 | }; 27 | 28 | const EVMOS_DEFAULT_TOKEN_POLL_CONCURRENCY = 10; 29 | 30 | export const EVMOS_DEFAULT_CONFIGS: CosmosDefaultConfigs = { 31 | [EVMOS_MAINNET]: { 32 | nodeUrl: "https://evmos-rpc.publicnode.com:443", 33 | tokenPollConcurrency: EVMOS_DEFAULT_TOKEN_POLL_CONCURRENCY, 34 | nativeDenom: EVMOS_NATIVE_DENOM, 35 | addressPrefix: EVMOS_ADDRESS_PREFIX, 36 | defaultDecimals: EVMOS_DEFAULT_DECIMALS, 37 | minGasPrice: EVMOS_MIN_GAS_PRICE_MAINNET, 38 | }, 39 | [EVMOS_TESTNET]: { 40 | nodeUrl: "https://evmos-testnet-rpc.publicnode.com:443", 41 | tokenPollConcurrency: EVMOS_DEFAULT_TOKEN_POLL_CONCURRENCY, 42 | nativeDenom: EVMOS_NATIVE_DENOM_TESTNET, 43 | addressPrefix: EVMOS_ADDRESS_PREFIX, 44 | defaultDecimals: EVMOS_DEFAULT_DECIMALS, 45 | minGasPrice: EVMOS_MIN_GAS_PRICE_TESTNET, 46 | }, 47 | }; 48 | 49 | export const EVMOS_CHAIN_CONFIG = { 50 | chainName: EVMOS, 51 | networks: EVMOS_NETWORKS, 52 | knownTokens: EVMOS_KNOWN_TOKENS, 53 | defaultConfigs: EVMOS_DEFAULT_CONFIGS, 54 | nativeCurrencySymbol: EVMOS_CURRENCY_SYMBOL, 55 | defaultNetwork: EVMOS_MAINNET, 56 | }; 57 | 58 | export type EvmosNetwork = keyof typeof EVMOS_NETWORKS; 59 | -------------------------------------------------------------------------------- /src/wallets/cosmos/index.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from "ethers"; 2 | import { SigningStargateClient, StargateClient } from "@cosmjs/stargate"; 3 | import { TokenBalance, WalletBalance, WalletConfig } from ".."; 4 | import { Wallets } from "../../chain-wallet-manager"; 5 | import { PriceFeed } from "../../wallet-manager"; 6 | import { 7 | BaseWalletOptions, 8 | TransferReceipt, 9 | WalletToolbox, 10 | } from "../base-wallet"; 11 | import { 12 | OSMOSIS, 13 | OSMOSIS_CHAIN_CONFIG, 14 | OsmosisNetwork, 15 | } from "./osmosis.config"; 16 | import { 17 | createSigner, 18 | getCosmosAddressFromPrivateKey, 19 | pullCosmosNativeBalance, 20 | transferCosmosNativeBalance, 21 | } from "../../balances/cosmos"; 22 | import { 23 | COSMOSHUB, 24 | COSMOSHUB_CHAIN_CONFIG, 25 | CosmoshubNetwork, 26 | } from "./cosmoshub.config"; 27 | import { EVMOS, EVMOS_CHAIN_CONFIG, EvmosNetwork } from "./evmos.config"; 28 | import { KUJIRA, KUJIRA_CHAIN_CONFIG, KujiraNetwork } from "./kujira.config"; 29 | import { 30 | WORMCHAIN, 31 | WORMCHAIN_CHAIN_CONFIG, 32 | WormchainNetwork, 33 | } from "./wormchain.config"; 34 | 35 | export type CosmosChainConfig = { 36 | chainName: string; 37 | networks: Record; 38 | knownTokens: Record>; 39 | defaultConfigs: Record; 40 | nativeCurrencySymbol: string; 41 | defaultNetwork: string; 42 | }; 43 | 44 | const COSMOS_CHAINS = { 45 | [OSMOSIS]: 1, 46 | [COSMOSHUB]: 2, 47 | [EVMOS]: 3, 48 | [KUJIRA]: 4, 49 | [WORMCHAIN]: 5, 50 | }; 51 | 52 | export type CosmosDefaultConfig = { 53 | nodeUrl: string; 54 | tokenPollConcurrency?: number; 55 | nativeDenom: string; 56 | addressPrefix: string; 57 | defaultDecimals: number; 58 | minGasPrice: string; 59 | }; 60 | 61 | export type CosmosDefaultConfigs = Record; 62 | export type CosmosChainName = keyof typeof COSMOS_CHAINS; 63 | export type CosmosWallet = SigningStargateClient; 64 | export type CosmosProvider = StargateClient; 65 | 66 | export const COSMOS_CHAIN_CONFIGS: Record = 67 | { 68 | [OSMOSIS]: OSMOSIS_CHAIN_CONFIG, 69 | [COSMOSHUB]: COSMOSHUB_CHAIN_CONFIG, 70 | [EVMOS]: EVMOS_CHAIN_CONFIG, 71 | [KUJIRA]: KUJIRA_CHAIN_CONFIG, 72 | [WORMCHAIN]: WORMCHAIN_CHAIN_CONFIG, 73 | }; 74 | 75 | export type CosmosWalletOptions = BaseWalletOptions & { 76 | nodeUrl?: string; 77 | tokenPollConcurrency?: number; 78 | nativeDenom: string; 79 | addressPrefix: string; 80 | defaultDecimals: number; 81 | }; 82 | 83 | export type CosmosNetworks = 84 | | OsmosisNetwork 85 | | CosmoshubNetwork 86 | | EvmosNetwork 87 | | KujiraNetwork 88 | | WormchainNetwork; 89 | 90 | export class CosmosWalletToolbox extends WalletToolbox { 91 | private provider: CosmosProvider | null; 92 | private chainConfig: CosmosChainConfig; 93 | 94 | constructor( 95 | public network: string, 96 | public chainName: string, 97 | public rawConfig: WalletConfig[], 98 | private options: CosmosWalletOptions, 99 | private priceFeed?: PriceFeed, 100 | ) { 101 | // Need to do all this before calling `super` because some options are needed 102 | // when the parent class calls `buildWalletConfig`. 103 | // The bad thing about all this is that now configuration is duplicated. In one 104 | // hand we will have the chainConfig which later is associated to `this.chainConfig` 105 | // but it is not available when the parent class is instantiated. And on the other 106 | // hand we are forced to pass the same options to the parent class, for some abstract 107 | // functions that are implemented in this class but are called from the parent class. 108 | // Specifically for the Cosmos ecosystem, the `addressPrefix` is needed to derive 109 | // and address based on a private key. Other options might be needed in the future. 110 | // So, for now, we will have to live with duplicated options. 111 | // TODO: Refactor wallet initialization 112 | const chainConfig = COSMOS_CHAIN_CONFIGS[chainName as CosmosChainName]; 113 | const defaultOptions = chainConfig.defaultConfigs[network]; 114 | options.addressPrefix = defaultOptions.addressPrefix; 115 | options.defaultDecimals = defaultOptions.defaultDecimals; 116 | options.nativeDenom = defaultOptions.nativeDenom; 117 | 118 | super(network, chainName, rawConfig, options); 119 | this.chainConfig = chainConfig; 120 | 121 | this.options = { ...defaultOptions, ...options } as CosmosWalletOptions; 122 | this.priceFeed = priceFeed; 123 | this.provider = null; 124 | } 125 | 126 | protected validateChainName(chainName: string): chainName is CosmosChainName { 127 | if (!(chainName in COSMOS_CHAIN_CONFIGS)) { 128 | throw new Error(`Invalid chain name "${chainName}" form Cosmos wallet`); 129 | } 130 | 131 | return true; 132 | } 133 | 134 | protected validateNetwork(network: string): network is CosmosNetworks { 135 | if ( 136 | !( 137 | network in 138 | COSMOS_CHAIN_CONFIGS[this.chainName as CosmosChainName].networks 139 | ) 140 | ) { 141 | throw new Error( 142 | `Invalid network "${network}" for chain: ${this.chainName}`, 143 | ); 144 | } 145 | 146 | return true; 147 | } 148 | 149 | protected validateOptions(options: any): options is CosmosWalletOptions { 150 | if (!options) return true; 151 | if (typeof options !== "object") { 152 | throw new Error(`Invalid options for chain "${this.chainName}"`); 153 | } 154 | 155 | return true; 156 | } 157 | 158 | protected validateTokenAddress(token: string): boolean { 159 | return true; 160 | } 161 | 162 | protected parseTokensConfig( 163 | tokens: string[], 164 | failOnInvalidTokens: boolean, 165 | ): string[] { 166 | return []; 167 | } 168 | 169 | protected async warmup() { 170 | this.provider = await StargateClient.connect(this.options.nodeUrl!); 171 | } 172 | 173 | // TODO: Implement getNativeBalance by blockHeight if possible 174 | public async pullNativeBalance(address: string): Promise { 175 | const { nativeDenom, defaultDecimals } = 176 | this.chainConfig.defaultConfigs[this.network]; 177 | const balance = await pullCosmosNativeBalance( 178 | this.provider!, 179 | address, 180 | nativeDenom, 181 | ); 182 | 183 | return { 184 | ...balance, 185 | symbol: this.chainConfig.nativeCurrencySymbol, 186 | address, 187 | formattedBalance: ethers.utils.formatUnits( 188 | balance.rawBalance, 189 | defaultDecimals, 190 | ), 191 | tokens: [], 192 | }; 193 | } 194 | 195 | public async pullTokenBalances( 196 | address: string, 197 | tokens: string[], 198 | ): Promise { 199 | return []; 200 | } 201 | 202 | protected async transferNativeBalance( 203 | privateKey: string, 204 | targetAddress: string, 205 | amount: number, 206 | maxGasPrice?: number | undefined, 207 | gasLimit?: number | undefined, 208 | ): Promise { 209 | const { addressPrefix, defaultDecimals, nativeDenom, minGasPrice } = 210 | this.chainConfig.defaultConfigs[this.network]; 211 | const nodeUrl = this.options.nodeUrl!; 212 | const txDetails = { 213 | targetAddress, 214 | amount, 215 | addressPrefix, 216 | defaultDecimals, 217 | nativeDenom, 218 | nodeUrl, 219 | defaultGasPrice: minGasPrice, 220 | }; 221 | const receipt = await transferCosmosNativeBalance(privateKey, txDetails); 222 | 223 | return receipt; 224 | } 225 | 226 | protected async getRawWallet(privateKey: string): Promise { 227 | const { addressPrefix } = this.chainConfig.defaultConfigs[this.network]; 228 | const signer = await createSigner(privateKey, addressPrefix); 229 | 230 | return await SigningStargateClient.connectWithSigner( 231 | this.options.nodeUrl!, 232 | signer, 233 | ); 234 | } 235 | 236 | public async getGasPrice(): Promise { 237 | return Promise.resolve(0n); 238 | } 239 | 240 | public async getBlockHeight(): Promise { 241 | return this.provider?.getHeight() || 0; 242 | } 243 | 244 | protected getAddressFromPrivateKey( 245 | privateKey: string, 246 | options?: CosmosWalletOptions, 247 | ): string { 248 | const { addressPrefix } = options!; 249 | return getCosmosAddressFromPrivateKey(privateKey, addressPrefix); 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /src/wallets/cosmos/kujira.config.ts: -------------------------------------------------------------------------------- 1 | import { CosmosDefaultConfigs } from "."; 2 | 3 | const KUJIRA_MAINNET = "mainnet"; 4 | const KUJIRA_TESTNET = "testnet"; 5 | const KUJIRA_CURRENCY_SYMBOL = "KUJI"; 6 | const KUJIRA_NATIVE_DENOM = "ukuji"; 7 | const KUJIRA_ADDRESS_PREFIX = "kujira"; 8 | const KUJIRA_DEFAULT_DECIMALS = 6; 9 | 10 | const KUJIRA_MIN_GAS_PRICE = "0.0075ukuji"; 11 | 12 | export const KUJIRA = "kujira"; 13 | export const KUJIRA_NETWORKS = { 14 | [KUJIRA_MAINNET]: 1, 15 | [KUJIRA_TESTNET]: 2, 16 | }; 17 | 18 | export const KUJIRA_KNOWN_TOKENS = { 19 | [KUJIRA_MAINNET]: {}, 20 | [KUJIRA_TESTNET]: {}, 21 | }; 22 | 23 | const KUJIRA_DEFAULT_TOKEN_POLL_CONCURRENCY = 10; 24 | 25 | export const KUJIRA_DEFAULT_CONFIGS: CosmosDefaultConfigs = { 26 | [KUJIRA_MAINNET]: { 27 | nodeUrl: "https://kujira-rpc.publicnode.com:443", 28 | tokenPollConcurrency: KUJIRA_DEFAULT_TOKEN_POLL_CONCURRENCY, 29 | nativeDenom: KUJIRA_NATIVE_DENOM, 30 | addressPrefix: KUJIRA_ADDRESS_PREFIX, 31 | defaultDecimals: KUJIRA_DEFAULT_DECIMALS, 32 | minGasPrice: KUJIRA_MIN_GAS_PRICE, 33 | }, 34 | [KUJIRA_TESTNET]: { 35 | nodeUrl: "https://kujira-testnet-rpc.polkachu.com", 36 | tokenPollConcurrency: KUJIRA_DEFAULT_TOKEN_POLL_CONCURRENCY, 37 | nativeDenom: KUJIRA_NATIVE_DENOM, 38 | addressPrefix: KUJIRA_ADDRESS_PREFIX, 39 | defaultDecimals: KUJIRA_DEFAULT_DECIMALS, 40 | minGasPrice: KUJIRA_MIN_GAS_PRICE, 41 | }, 42 | }; 43 | 44 | export const KUJIRA_CHAIN_CONFIG = { 45 | chainName: KUJIRA, 46 | networks: KUJIRA_NETWORKS, 47 | knownTokens: KUJIRA_KNOWN_TOKENS, 48 | defaultConfigs: KUJIRA_DEFAULT_CONFIGS, 49 | nativeCurrencySymbol: KUJIRA_CURRENCY_SYMBOL, 50 | defaultNetwork: KUJIRA_MAINNET, 51 | }; 52 | 53 | export type KujiraNetwork = keyof typeof KUJIRA_NETWORKS; 54 | -------------------------------------------------------------------------------- /src/wallets/cosmos/osmosis.config.ts: -------------------------------------------------------------------------------- 1 | import { CosmosDefaultConfigs } from "."; 2 | 3 | const OSMOSIS_MAINNET = "mainnet"; 4 | const OSMOSIS_TESTNET = "testnet"; 5 | const OSMOSIS_CURRENCY_SYMBOL = "OSMO"; 6 | const OSMOSIS_NATIVE_DENOM = "uosmo"; 7 | const OSMOSIS_ADDRESS_PREFIX = "osmo"; 8 | const OSMOSIS_DEFAULT_DECIMALS = 6; 9 | const OSMOSIS_MIN_GAS_PRICE = "0.0025uosmo"; 10 | 11 | export const OSMOSIS = "osmosis"; 12 | export const OSMOSIS_NETWORKS = { 13 | [OSMOSIS_MAINNET]: 1, 14 | [OSMOSIS_TESTNET]: 2, 15 | }; 16 | 17 | export const OSMOSIS_KNOWN_TOKENS = { 18 | [OSMOSIS_MAINNET]: {}, 19 | [OSMOSIS_TESTNET]: {}, 20 | }; 21 | 22 | const OSMOSIS_DEFAULT_TOKEN_POLL_CONCURRENCY = 10; 23 | 24 | export const OSMOSIS_DEFAULT_CONFIGS: CosmosDefaultConfigs = { 25 | [OSMOSIS_MAINNET]: { 26 | nodeUrl: "https://rpc.osmosis.zone", 27 | tokenPollConcurrency: OSMOSIS_DEFAULT_TOKEN_POLL_CONCURRENCY, 28 | nativeDenom: OSMOSIS_NATIVE_DENOM, 29 | addressPrefix: OSMOSIS_ADDRESS_PREFIX, 30 | defaultDecimals: OSMOSIS_DEFAULT_DECIMALS, 31 | minGasPrice: OSMOSIS_MIN_GAS_PRICE, 32 | }, 33 | [OSMOSIS_TESTNET]: { 34 | nodeUrl: "https://rpc.testnet.osmosis.zone", 35 | tokenPollConcurrency: OSMOSIS_DEFAULT_TOKEN_POLL_CONCURRENCY, 36 | nativeDenom: OSMOSIS_NATIVE_DENOM, 37 | addressPrefix: OSMOSIS_ADDRESS_PREFIX, 38 | defaultDecimals: OSMOSIS_DEFAULT_DECIMALS, 39 | minGasPrice: OSMOSIS_MIN_GAS_PRICE, 40 | }, 41 | }; 42 | 43 | export const OSMOSIS_CHAIN_CONFIG = { 44 | chainName: OSMOSIS, 45 | networks: OSMOSIS_NETWORKS, 46 | knownTokens: OSMOSIS_KNOWN_TOKENS, 47 | defaultConfigs: OSMOSIS_DEFAULT_CONFIGS, 48 | nativeCurrencySymbol: OSMOSIS_CURRENCY_SYMBOL, 49 | defaultNetwork: OSMOSIS_MAINNET, 50 | }; 51 | 52 | export type OsmosisNetwork = keyof typeof OSMOSIS_NETWORKS; 53 | -------------------------------------------------------------------------------- /src/wallets/cosmos/wormchain.config.ts: -------------------------------------------------------------------------------- 1 | import { CosmosDefaultConfigs } from "."; 2 | 3 | const WORMCHAIN_MAINNET = "wormchain-mainnet-0"; 4 | const WORMCHAIN_TESTNET = "wormchain-testnet-0"; 5 | const WORMCHAIN_CURRENCY_SYMBOL = "WORM"; 6 | const WORMCHAIN_NATIVE_DENOM = "uworm"; 7 | const WORMCHAIN_ADDRESS_PREFIX = "wormhole"; 8 | const WORMCHAIN_DEFAULT_DECIMALS = 6; 9 | 10 | const WORMCHAIN_MIN_GAS_PRICE = "0utest"; 11 | 12 | export const WORMCHAIN = "wormchain"; 13 | export const WORMCHAIN_NETWORKS = { 14 | [WORMCHAIN_MAINNET]: 1, 15 | [WORMCHAIN_TESTNET]: 2, 16 | }; 17 | 18 | export const WORMCHAIN_KNOWN_TOKENS = { 19 | [WORMCHAIN_MAINNET]: {}, 20 | [WORMCHAIN_TESTNET]: {}, 21 | }; 22 | 23 | const WORMCHAIN_DEFAULT_TOKEN_POLL_CONCURRENCY = 10; 24 | 25 | export const WORMCHAIN_DEFAULT_CONFIGS: CosmosDefaultConfigs = { 26 | [WORMCHAIN_MAINNET]: { 27 | nodeUrl: "https://wormchain-mainnet.jumpisolated.com:443", 28 | tokenPollConcurrency: WORMCHAIN_DEFAULT_TOKEN_POLL_CONCURRENCY, 29 | nativeDenom: WORMCHAIN_NATIVE_DENOM, 30 | addressPrefix: WORMCHAIN_ADDRESS_PREFIX, 31 | defaultDecimals: WORMCHAIN_DEFAULT_DECIMALS, 32 | minGasPrice: WORMCHAIN_MIN_GAS_PRICE, 33 | }, 34 | [WORMCHAIN_TESTNET]: { 35 | nodeUrl: "https://wormchain-testnet.jumpisolated.com:443", 36 | tokenPollConcurrency: WORMCHAIN_DEFAULT_TOKEN_POLL_CONCURRENCY, 37 | nativeDenom: WORMCHAIN_NATIVE_DENOM, 38 | addressPrefix: WORMCHAIN_ADDRESS_PREFIX, 39 | defaultDecimals: WORMCHAIN_DEFAULT_DECIMALS, 40 | minGasPrice: WORMCHAIN_MIN_GAS_PRICE, 41 | }, 42 | }; 43 | 44 | export const WORMCHAIN_CHAIN_CONFIG = { 45 | chainName: WORMCHAIN, 46 | networks: WORMCHAIN_NETWORKS, 47 | knownTokens: WORMCHAIN_KNOWN_TOKENS, 48 | defaultConfigs: WORMCHAIN_DEFAULT_CONFIGS, 49 | nativeCurrencySymbol: WORMCHAIN_CURRENCY_SYMBOL, 50 | defaultNetwork: WORMCHAIN_MAINNET, 51 | }; 52 | 53 | export type WormchainNetwork = keyof typeof WORMCHAIN_NETWORKS; 54 | -------------------------------------------------------------------------------- /src/wallets/evm/arbitrum.config.ts: -------------------------------------------------------------------------------- 1 | import { DEVNET } from '..'; 2 | import { EvmDefaultConfigs } from './index'; 3 | 4 | export const ARBITRUM = 'arbitrum'; 5 | 6 | export const ARBITRUM_MAINNET = 'Arbitrum'; 7 | export const ARBITRUM_TESTNET = 'Arbitrum Testnet'; 8 | 9 | export const ARBITRUM_NETWORKS = { 10 | [DEVNET]: 1, 11 | [ARBITRUM_MAINNET]: 2, 12 | [ARBITRUM_TESTNET]: 3, 13 | }; 14 | 15 | export const ARBITRUM_KNOWN_TOKENS = { 16 | [ARBITRUM_MAINNET]: { 17 | "ARB": "0x912CE59144191C1204E64559FE8253a0e49E6548", 18 | "WETH": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", 19 | "USDC": "0xff970a61a04b1ca14834a43f5de4533ebddb5cc8", 20 | "USDT": "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9", 21 | "WBTC": "0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f", 22 | "WBNB": "0x8579794b10f88ebbedd3605f2e5ff54f162d4788", 23 | "WMATIC": "0x3ab0e28c3f56616ad7061b4db38ae337e3809aea", 24 | "WAVAX": "0x565609faf65b92f7be02468acf86f8979423e514", 25 | "WGLMR": "0x944c5b67a03e6cb93ae1e4b70081f13b04cdb6bd", 26 | "WSUI": "0xfe7b5a32c93dc25184d475e3083ba30ed3c1bf8f", 27 | }, 28 | [ARBITRUM_TESTNET]: { 29 | }, 30 | [DEVNET]: {}, 31 | }; 32 | 33 | const ARBITRUM_DEFAULT_TOKEN_POLL_CONCURRENCY = 10; 34 | 35 | export const ARBITRUM_DEFAULT_CONFIGS: EvmDefaultConfigs = { 36 | [ARBITRUM_MAINNET]: { 37 | nodeUrl: 'https://arb1.arbitrum.io/rpc', 38 | tokenPollConcurrency: ARBITRUM_DEFAULT_TOKEN_POLL_CONCURRENCY, 39 | }, 40 | [ARBITRUM_TESTNET]: { 41 | nodeUrl: 'https://goerli-rollup.arbitrum.io/rpc', 42 | tokenPollConcurrency: ARBITRUM_DEFAULT_TOKEN_POLL_CONCURRENCY, 43 | }, 44 | 45 | [DEVNET]: { 46 | nodeUrl: 'http://localhost:8545', 47 | tokenPollConcurrency: ARBITRUM_DEFAULT_TOKEN_POLL_CONCURRENCY, 48 | }, 49 | }; 50 | 51 | export const ARBITRUM_CHAIN_CONFIG = { 52 | chainName: ARBITRUM, 53 | networks: ARBITRUM_NETWORKS, 54 | knownTokens: ARBITRUM_KNOWN_TOKENS, 55 | defaultConfigs: ARBITRUM_DEFAULT_CONFIGS, 56 | nativeCurrencySymbol: 'ETH', 57 | defaultNetwork: ARBITRUM_MAINNET, 58 | }; 59 | 60 | export type ArbitrumNetwork = keyof typeof ARBITRUM_NETWORKS; -------------------------------------------------------------------------------- /src/wallets/evm/avalanche.config.ts: -------------------------------------------------------------------------------- 1 | import { DEVNET } from ".."; 2 | export const AVALANCHE = "avalanche"; 3 | 4 | const AVALANCHE_MAINNET = "mainnet"; 5 | const AVALANCHE_TESTNET = "testnet"; 6 | 7 | export const AVALANCHE_NETWORKS = { 8 | [DEVNET]: 1, 9 | [AVALANCHE_MAINNET]: 2, 10 | // THIS should be called fuji 11 | [AVALANCHE_TESTNET]: 3, 12 | }; 13 | 14 | export const AVALANCHE_KNOWN_TOKENS = { 15 | [AVALANCHE_MAINNET]: { 16 | WETH: "0x8b82A291F83ca07Af22120ABa21632088fC92931", 17 | USDC: "0xB24CA28D4e2742907115fECda335b40dbda07a4C", 18 | USDT: "0x9d228444FC4B7E15A2C481b48E10247A03351FD8", 19 | WBTC: "0x1C0e79C5292c59bbC13C9F9f209D204cf4d65aD6", 20 | WBNB: "0x442F7f22b1EE2c842bEAFf52880d4573E9201158", 21 | WMATIC: "0xf2f13f0B7008ab2FA4A2418F4ccC3684E49D20Eb", 22 | WAVAX: "0xb31f66aa3c1e785363f0875a1b74e27b85fd66c7", 23 | WFTM: "0xd19abc09B7b36F7558929b97a866f499a26c2f83", 24 | WCELO: "0x494317B8521c5a5287a06DEE467dd6fe285dA4a8", 25 | WGLMR: "0x375aA6C67BF499fBf01804A9f92C03c0776F372d", 26 | WSUI: "0x1703CB0F762D2a435199B64Ea47E5349B7C17480", 27 | WARB: "0x2bb3Caf6b5c3a1FE9C4D9BE969B88FB007fE7FBd", 28 | }, 29 | [AVALANCHE_TESTNET]: {}, 30 | [DEVNET]: {}, 31 | }; 32 | 33 | export const AVALANCHE_DEFAULT_TOKEN_POLL_CONCURRENCY = 10; 34 | 35 | export const AVALANCHE_DEFAULT_CONFIGS = { 36 | [AVALANCHE_MAINNET]: { 37 | nodeUrl: "https://api.avax.network/ext/bc/C/rpc", 38 | tokenPollConcurrency: AVALANCHE_DEFAULT_TOKEN_POLL_CONCURRENCY, 39 | }, 40 | [AVALANCHE_TESTNET]: { 41 | nodeUrl: "https://api.avax-test.network/ext/bc/C/rpc", 42 | tokenPollConcurrency: AVALANCHE_DEFAULT_TOKEN_POLL_CONCURRENCY, 43 | }, 44 | [DEVNET]: { 45 | nodeUrl: "http://localhost:8545", 46 | tokenPollConcurrency: AVALANCHE_DEFAULT_TOKEN_POLL_CONCURRENCY, 47 | } 48 | }; 49 | 50 | export const AVALANCHE_CHAIN_CONFIG = { 51 | chainName: AVALANCHE, 52 | nativeCurrencySymbol: "AVAX", 53 | knownTokens: AVALANCHE_KNOWN_TOKENS, 54 | networks: AVALANCHE_NETWORKS, 55 | defaultConfigs: AVALANCHE_DEFAULT_CONFIGS, 56 | defaultNetwork: AVALANCHE_MAINNET, 57 | }; 58 | 59 | export type AvalancheNetwork = keyof typeof AVALANCHE_NETWORKS; 60 | -------------------------------------------------------------------------------- /src/wallets/evm/base.config.ts: -------------------------------------------------------------------------------- 1 | import { DEVNET } from "../index"; 2 | import { EvmDefaultConfigs } from "./index"; 3 | 4 | const BASE_MAINNET = "mainnet"; 5 | const BASE_GOERLI = "goerli"; 6 | const BASE_CURRENCY_SYMBOL = "ETH"; 7 | 8 | export const BASE = "base"; 9 | 10 | export const BASE_NETWORKS = { 11 | [DEVNET]: 1, 12 | [BASE_MAINNET]: 2, 13 | [BASE_GOERLI]: 3, 14 | }; 15 | 16 | export const BASE_KNOWN_TOKENS = { 17 | [BASE_MAINNET]: { 18 | // WETH: "0x4200000000000000000000000000000000000006", // no WETH on base mainnet 19 | // USDC: "0x7F5c764cBc14f9669B88837ca1490cCa17c31607", // no USDC on base mainnet 20 | // USDT: "0x94b008aA00579c1307B0EF2c499aD98a8ce58e58", // no USDT on base mainnet 21 | // WBTC: "0x68f180fcCe6836688e9084f035309E29Bf0A2095", // no WBTC on base mainnet 22 | // WBNB: "0x418D75f65a02b3D53B2418FB8E1fe493759c7605", // no WBNB on base mainnet 23 | // WMATIC: "0x7c9f4C87d911613Fe9ca58b579f737911AAD2D43", // no WMATIC on base mainnet 24 | // WAVAX: "0x85f138bfEE4ef8e540890CFb48F620571d67Eda3", // no WAVAX on base mainnet 25 | // WFTM: "0x4cD2690d86284e044cb63E60F1EB218a825a7e92", // no WFTM on base mainnet 26 | // WCELO: "0x3294395e62f4eb6af3f1fcf89f5602d90fb3ef69", // no WCELO on base mainnet 27 | // WGLMR: "0x93d3696A9F879b331f40CB5059e37015423A3Bd0", // no WGLMR on base mainnet 28 | // WSUI: "0x84074EA631dEc7a4edcD5303d164D5dEa4c653D6", // no WSUI on base mainnet 29 | // WARB: "0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1", // no WARB on base mainnet 30 | }, 31 | [BASE_GOERLI]: { 32 | // USDC: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 33 | // DAI: "0x6B175474E89094C44Da98b954EedeAC495271d0F", 34 | }, 35 | [DEVNET]: {}, 36 | }; 37 | 38 | const BASE_DEFAULT_TOKEN_POLL_CONCURRENCY = 10; 39 | 40 | export const BASE_DEFAULT_CONFIGS: EvmDefaultConfigs = { 41 | [BASE_MAINNET]: { 42 | nodeUrl: "https://developer-access-mainnet.base.org", 43 | tokenPollConcurrency: BASE_DEFAULT_TOKEN_POLL_CONCURRENCY, 44 | }, 45 | [BASE_GOERLI]: { 46 | nodeUrl: "https://goerli.base.org", 47 | tokenPollConcurrency: BASE_DEFAULT_TOKEN_POLL_CONCURRENCY, 48 | }, 49 | [DEVNET]: { 50 | nodeUrl: "http://localhost:8545", 51 | tokenPollConcurrency: BASE_DEFAULT_TOKEN_POLL_CONCURRENCY, 52 | }, 53 | }; 54 | 55 | export const BASE_CHAIN_CONFIG = { 56 | chainName: BASE, 57 | networks: BASE_NETWORKS, 58 | knownTokens: BASE_KNOWN_TOKENS, 59 | defaultConfigs: BASE_DEFAULT_CONFIGS, 60 | nativeCurrencySymbol: BASE_CURRENCY_SYMBOL, 61 | defaultNetwork: BASE_MAINNET, 62 | }; 63 | 64 | export type BaseNetwork = keyof typeof BASE_NETWORKS; 65 | // export type KnownBaseTokens = keyof typeof BASE_KNOWN_TOKENS; 66 | -------------------------------------------------------------------------------- /src/wallets/evm/bsc.config.ts: -------------------------------------------------------------------------------- 1 | import { DEVNET } from '..'; 2 | import { EvmDefaultConfigs } from "."; 3 | 4 | export const BSC = 'bsc'; 5 | 6 | export const BSC_MAINNET = 'mainnet'; 7 | 8 | export const BSC_TESTNET = 'testnet'; 9 | 10 | export const BSC_NETWORKS = { 11 | [DEVNET]: 1, 12 | [BSC_MAINNET]: 2, 13 | [BSC_TESTNET]: 3, 14 | }; 15 | 16 | export const BSC_KNOWN_TOKENS = { 17 | [BSC_MAINNET]: { 18 | "WETH": "0x4db5a66e937a9f4473fa95b1caf1d1e1d62e29ea", 19 | "USDC": "0xB04906e95AB5D797aDA81508115611fee694c2b3", 20 | "USDT": "0x524bC91Dc82d6b90EF29F76A3ECAaBAffFD490Bc", 21 | "WBTC": "0x43359676e1a3f9fbb5de095333f8e9c1b46dfa44", 22 | "WBNB": "0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c", 23 | "WMATIC": "0xc836d8dC361E44DbE64c4862D55BA041F88Ddd39", 24 | "WAVAX": "0x96412902aa9aFf61E13f085e70D3152C6ef2a817", 25 | "WFTM": "0xbF8413EE8612E0E4f66Aa63B5ebE27f3C5883d47", 26 | "WCELO": "0x2A335e327a55b177f5B40132fEC5D7298aa0D7e6", 27 | "WGLMR": "0x1C063db3c621BF901FC6C1D03328b08b2F9bbfba", 28 | "WSUI": "0x8314f6Bf1B4dd8604A0fC33C84F9AF2fc07AABC8", 29 | "WARB": "0x0c03a1d484b12c63bd499162fca0a403f8104939", 30 | }, 31 | [BSC_TESTNET]: {}, 32 | [DEVNET]: {}, 33 | }; 34 | 35 | const BSC_DEFAULT_TOKEN_POLL_CONCURRENCY = 10; 36 | 37 | export const BSC_DEFAULT_CONFIGS: EvmDefaultConfigs = { 38 | [BSC_MAINNET]: { 39 | nodeUrl: 'https://bsc-dataseed.binance.org', 40 | tokenPollConcurrency: BSC_DEFAULT_TOKEN_POLL_CONCURRENCY, 41 | }, 42 | [BSC_TESTNET]: { 43 | nodeUrl: 'https://data-seed-prebsc-1-s1.binance.org:8545', 44 | tokenPollConcurrency: BSC_DEFAULT_TOKEN_POLL_CONCURRENCY, 45 | }, 46 | [DEVNET]: { 47 | nodeUrl: 'http://localhost:8545', 48 | tokenPollConcurrency: BSC_DEFAULT_TOKEN_POLL_CONCURRENCY, 49 | }, 50 | }; 51 | 52 | export const BSC_CHAIN_CONFIG = { 53 | chainName: BSC, 54 | nativeCurrencySymbol: 'BNB', 55 | knownTokens: BSC_KNOWN_TOKENS, 56 | networks: BSC_NETWORKS, 57 | defaultConfigs: BSC_DEFAULT_CONFIGS, 58 | defaultNetwork: BSC_MAINNET, 59 | }; 60 | 61 | export type BscNetwork = keyof typeof BSC_NETWORKS; 62 | -------------------------------------------------------------------------------- /src/wallets/evm/celo.config.ts: -------------------------------------------------------------------------------- 1 | import { DEVNET } from '../index'; 2 | import { EvmDefaultConfigs } from "./index"; 3 | 4 | export const CELO = "celo"; 5 | 6 | const CELO_MAINNET = "mainnet"; 7 | const CELO_ALFAJORES = "alfajores"; 8 | 9 | const CELO_NETWORKS = { 10 | [DEVNET]: 1, 11 | [CELO_MAINNET]: 2, 12 | [CELO_ALFAJORES]: 3, 13 | }; 14 | 15 | export const CELO_KNOWN_TOKENS = { 16 | [CELO_MAINNET]: { 17 | "WETH": "0x66803FB87aBd4aaC3cbB3fAd7C3aa01f6F3FB207", 18 | "USDC": "0x37f750B7cC259A2f741AF45294f6a16572CF5cAd", 19 | "USDT": "0x617f3112bf5397D0467D315cC709EF968D9ba546", 20 | "WBTC": "0xd71Ffd0940c920786eC4DbB5A12306669b5b81EF", 21 | "WBNB": "0xBf2554ce8A4D1351AFeB1aC3E5545AaF7591042d", 22 | "WMATIC": "0x9C234706292b1144133ED509ccc5B3CD193BF712", 23 | "WAVAX": "0xFFdb274b4909fC2efE26C8e4Ddc9fe91963cAA4d", 24 | "WFTM": "0xd1A342eE2210238233a347FEd61EE7Faf9f251ce", 25 | "WCELO": "0x471ece3750da237f93b8e339c536989b8978a438", 26 | "WGLMR": "0x383A5513AbE4Fe36e0E00d484F710148E348Aa9D", 27 | "WSUI": "0x1Cb9859B1A16A67ef83A0c7b9A21eeC17d9a97Dc", 28 | }, 29 | [CELO_ALFAJORES]: {}, 30 | }; 31 | 32 | const CELO_DEFAULT_TOKEN_POLL_CONCURRENCY = 10; 33 | 34 | export const CELO_DEFAULT_CONFIGS: EvmDefaultConfigs = { 35 | [CELO_MAINNET]: { 36 | nodeUrl: "https://forno.celo.org", 37 | tokenPollConcurrency: CELO_DEFAULT_TOKEN_POLL_CONCURRENCY, 38 | }, 39 | [CELO_ALFAJORES]: { 40 | nodeUrl: "https://alfajores-forno.celo-testnet.org", 41 | tokenPollConcurrency: CELO_DEFAULT_TOKEN_POLL_CONCURRENCY, 42 | }, 43 | }; 44 | 45 | export const CELO_CHAIN_CONFIG = { 46 | chainName: CELO, 47 | networks: CELO_NETWORKS, 48 | knownTokens: CELO_KNOWN_TOKENS, 49 | defaultConfigs: CELO_DEFAULT_CONFIGS, 50 | nativeCurrencySymbol: "CELO", 51 | defaultNetwork: CELO_MAINNET, 52 | }; 53 | 54 | export type CeloNetwork = keyof typeof CELO_NETWORKS; 55 | -------------------------------------------------------------------------------- /src/wallets/evm/ethereum.config.ts: -------------------------------------------------------------------------------- 1 | import { DEVNET } from "../index"; 2 | import { EvmDefaultConfigs } from "./index"; 3 | 4 | const ETHEREUM_MAINNET = "mainnet"; 5 | const ETHEREUM_RINKEBY = "rinkeby"; 6 | const ETHEREUM_ROPSTEN = "ropsten"; 7 | const ETHEREUM_GOERLI = "goerli"; 8 | const ETHEREUM_SEPOLIA = "sepolia"; 9 | 10 | const ETHEREUM_CURRENCY_SYMBOL = "ETH"; 11 | 12 | export const ETHEREUM = "ethereum"; 13 | 14 | export const ETHEREUM_NETWORKS = { 15 | [DEVNET]: 1, 16 | [ETHEREUM_MAINNET]: 2, 17 | [ETHEREUM_RINKEBY]: 3, 18 | [ETHEREUM_ROPSTEN]: 4, 19 | [ETHEREUM_GOERLI]: 5, 20 | [ETHEREUM_SEPOLIA]: 6, 21 | }; 22 | 23 | export const ETHEREUM_KNOWN_TOKENS = { 24 | [ETHEREUM_MAINNET]: { 25 | WETH: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", 26 | USDC: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", 27 | USDT: "0xdac17f958d2ee523a2206206994597c13d831ec7", 28 | WBTC: "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599", 29 | WBNB: "0x418D75f65a02b3D53B2418FB8E1fe493759c7605", 30 | WMATIC: "0x7c9f4C87d911613Fe9ca58b579f737911AAD2D43", 31 | WAVAX: "0x85f138bfEE4ef8e540890CFb48F620571d67Eda3", 32 | WFTM: "0x4cD2690d86284e044cb63E60F1EB218a825a7e92", 33 | WCELO: "0x3294395e62f4eb6af3f1fcf89f5602d90fb3ef69", 34 | WGLMR: "0x93d3696A9F879b331f40CB5059e37015423A3Bd0", 35 | WSUI: "0x84074EA631dEc7a4edcD5303d164D5dEa4c653D6", 36 | WARB: "0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1", 37 | }, 38 | [ETHEREUM_RINKEBY]: { 39 | USDC: "0x4dbcdf9b62e891a7cec5a2568c3f4faf9e8abe2b", 40 | DAI: "0x5592ec0cfb4dbc12d3ab100b257153436a1f0fea", 41 | }, 42 | [ETHEREUM_ROPSTEN]: { 43 | USDC: "0x07865c6e87b9f70255377e024ace6630c1eaa37f", 44 | DAI: "0x5592ec0cfb4dbc12d3ab100b257153436a1f0fea", 45 | }, 46 | [ETHEREUM_GOERLI]: { 47 | USDC: "0x07865c6e87b9f70255377e024ace6630c1eaa37f", 48 | DAI: "0x5592ec0cfb4dbc12d3ab100b257153436a1f0fea", 49 | }, 50 | [ETHEREUM_SEPOLIA]: { 51 | // USDC: "0x07865c6e87b9f70255377e024ace6630c1eaa37f", // Disable Sepolia's tokens 52 | }, 53 | [DEVNET]: { 54 | ETH: "0x2170Ed0880ac9A755fd29B2688956BD959F933F8", 55 | }, 56 | }; 57 | 58 | const ETHEREUM_DEFAULT_TOKEN_POLL_CONCURRENCY = 10; 59 | 60 | export const ETHEREUM_DEFAULT_CONFIGS: EvmDefaultConfigs = { 61 | [ETHEREUM_MAINNET]: { 62 | nodeUrl: "https://mainnet.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161", 63 | tokenPollConcurrency: ETHEREUM_DEFAULT_TOKEN_POLL_CONCURRENCY, 64 | }, 65 | [ETHEREUM_RINKEBY]: { 66 | nodeUrl: "https://rinkeby.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161", 67 | tokenPollConcurrency: ETHEREUM_DEFAULT_TOKEN_POLL_CONCURRENCY, 68 | }, 69 | [ETHEREUM_ROPSTEN]: { 70 | nodeUrl: "https://ropsten.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161", 71 | tokenPollConcurrency: ETHEREUM_DEFAULT_TOKEN_POLL_CONCURRENCY, 72 | }, 73 | [ETHEREUM_GOERLI]: { 74 | nodeUrl: "https://goerli.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161", 75 | tokenPollConcurrency: ETHEREUM_DEFAULT_TOKEN_POLL_CONCURRENCY, 76 | }, 77 | [ETHEREUM_SEPOLIA]: { 78 | nodeUrl: "https://sepolia.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161", // https://endpoints.omniatech.io/v1/eth/sepolia/public 79 | tokenPollConcurrency: ETHEREUM_DEFAULT_TOKEN_POLL_CONCURRENCY, 80 | }, 81 | [DEVNET]: { 82 | nodeUrl: "http://127.0.0.1:8545", 83 | tokenPollConcurrency: ETHEREUM_DEFAULT_TOKEN_POLL_CONCURRENCY, 84 | }, 85 | }; 86 | 87 | export const ETHEREUM_CHAIN_CONFIG = { 88 | chainName: ETHEREUM, 89 | networks: ETHEREUM_NETWORKS, 90 | knownTokens: ETHEREUM_KNOWN_TOKENS, 91 | defaultConfigs: ETHEREUM_DEFAULT_CONFIGS, 92 | nativeCurrencySymbol: ETHEREUM_CURRENCY_SYMBOL, 93 | defaultNetwork: ETHEREUM_MAINNET, 94 | }; 95 | 96 | export type EthereumNetwork = keyof typeof ETHEREUM_NETWORKS; 97 | // export type KnownEthereumTokens = keyof typeof ETHEREUM_KNOWN_TOKENS; 98 | -------------------------------------------------------------------------------- /src/wallets/evm/fantom.config.ts: -------------------------------------------------------------------------------- 1 | import { DEVNET } from '..'; 2 | import { EvmDefaultConfigs } from './index'; 3 | 4 | export const FANTOM = 'fantom'; 5 | 6 | export const FANTOM_MAINNET = 'mainnet'; 7 | export const FANTOM_TESTNET = 'testnet'; 8 | 9 | export const FANTOM_NETWORKS = { 10 | [DEVNET]: 1, 11 | [FANTOM_MAINNET]: 2, 12 | [FANTOM_TESTNET]: 3, 13 | }; 14 | 15 | export const FANTOM_KNOWN_TOKENS = { 16 | [FANTOM_MAINNET]: { 17 | "WETH": "0x2A126f043BDEBe5A0A9841c51915E562D9B07289", 18 | "USDC": "0x2Ec752329c3EB419136ca5e4432Aa2CDb1eA23e6", 19 | "USDT": "0x14BCb86aEed6a74D3452550a25D37f1c30AA0A66", 20 | "WBTC": "0x87e9E225aD8a0755B9958fd95BE43DD6A91FF3A7", 21 | "WBNB": "0xc033551e05907Ddd643AE14b6D4a9CA72BfF509B", 22 | "WMATIC": "0xb88A6064B1F3FF5B9AE4A82fFD52560b0dF9FBD3", 23 | "WAVAX": "0x358CE030DC6116Cc296E8B9F002728e65459C146", 24 | "WFTM": "0x21be370d5312f44cb42ce377bc9b8a0cef1a4c83", 25 | "WCELO": "0xF432490C6c96C9d3bF523a499a1CEaFd8208A373", 26 | "WGLMR": "0xBF227E92D6754EB4BFE26C40cb299ff2809Da45f", 27 | "WSUI": "0xC277423a21F6e32D886BF85Ef6cCB945d5D28347", 28 | "WARB": "0x07d2bed56ffdab09bfad5911d70be2af26ed010c", 29 | }, 30 | [FANTOM_TESTNET]: { 31 | }, 32 | [DEVNET]: {}, 33 | }; 34 | 35 | const FANTOM_DEFAULT_TOKEN_POLL_CONCURRENCY = 10; 36 | 37 | export const FANTOM_DEFAULT_CONFIGS: EvmDefaultConfigs = { 38 | [FANTOM_MAINNET]: { 39 | nodeUrl: 'https://rpcapi.fantom.network', 40 | tokenPollConcurrency: FANTOM_DEFAULT_TOKEN_POLL_CONCURRENCY, 41 | }, 42 | [FANTOM_TESTNET]: { 43 | nodeUrl: 'https://rpc.testnet.fantom.network', 44 | tokenPollConcurrency: FANTOM_DEFAULT_TOKEN_POLL_CONCURRENCY, 45 | }, 46 | [DEVNET]: { 47 | nodeUrl: 'http://localhost:8545', 48 | tokenPollConcurrency: FANTOM_DEFAULT_TOKEN_POLL_CONCURRENCY, 49 | }, 50 | }; 51 | 52 | export const FANTOM_CHAIN_CONFIG = { 53 | chainName: FANTOM, 54 | networks: FANTOM_NETWORKS, 55 | knownTokens: FANTOM_KNOWN_TOKENS, 56 | defaultConfigs: FANTOM_DEFAULT_CONFIGS, 57 | nativeCurrencySymbol: 'FTM', 58 | defaultNetwork: FANTOM_MAINNET, 59 | }; 60 | 61 | export type FantomNetwork = keyof typeof FANTOM_NETWORKS; -------------------------------------------------------------------------------- /src/wallets/evm/index.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from "ethers"; 2 | 3 | import { WalletConfig, WalletBalance, TokenBalance } from "../"; 4 | import { mapConcurrent } from "../../utils"; 5 | import { WalletToolbox, BaseWalletOptions } from "../base-wallet"; 6 | import { 7 | pullEvmNativeBalance, 8 | EvmTokenData, 9 | pullEvmTokenData, 10 | pullEvmTokenBalance, 11 | transferEvmNativeBalance, 12 | getEvmAddressFromPrivateKey, 13 | } from "../../balances/evm"; 14 | 15 | import { 16 | EthereumNetwork, 17 | ETHEREUM_CHAIN_CONFIG, 18 | ETHEREUM, 19 | } from "./ethereum.config"; 20 | import { 21 | SepoliaNetwork, 22 | SEPOLIA, 23 | SEPOLIA_CHAIN_CONFIG, 24 | } from "./sepolia.config"; 25 | import { 26 | PolygonNetwork, 27 | POLYGON, 28 | POLYGON_CHAIN_CONFIG, 29 | } from "./polygon.config"; 30 | import { 31 | AvalancheNetwork, 32 | AVALANCHE, 33 | AVALANCHE_CHAIN_CONFIG, 34 | } from "./avalanche.config"; 35 | import { BscNetwork, BSC, BSC_CHAIN_CONFIG } from "./bsc.config"; 36 | import { FantomNetwork, FANTOM, FANTOM_CHAIN_CONFIG } from "./fantom.config"; 37 | import { CeloNetwork, CELO, CELO_CHAIN_CONFIG } from "./celo.config"; 38 | import { 39 | MoonbeamNetwork, 40 | MOONBEAM, 41 | MOONBEAM_CHAIN_CONFIG, 42 | } from "./moonbeam.config"; 43 | import { 44 | ArbitrumNetwork, 45 | ARBITRUM, 46 | ARBITRUM_CHAIN_CONFIG, 47 | } from "./arbitrum.config"; 48 | import { 49 | OptimismNetwork, 50 | OPTIMISM, 51 | OPTIMISM_CHAIN_CONFIG, 52 | } from "./optimism.config"; 53 | import { KlaytnNetwork, KLAYTN, KLAYTN_CHAIN_CONFIG } from "./klaytn.config"; 54 | import { BaseNetwork, BASE, BASE_CHAIN_CONFIG } from "./base.config"; 55 | import { PriceFeed } from "../../wallet-manager"; 56 | import { coinGeckoIdByChainName } from "../../price-assistant/supported-tokens.config"; 57 | 58 | const EVM_HEX_ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/; 59 | 60 | export type EvmChainConfig = { 61 | // TODO: most of this properties should actually be in a BaseChainConfig type 62 | chainName: string; 63 | nativeCurrencySymbol: string; 64 | knownTokens: Record>; 65 | defaultConfigs: Record; 66 | networks: Record; 67 | defaultNetwork: string; 68 | }; 69 | 70 | const EVM_CHAINS = { 71 | [ETHEREUM]: 1, 72 | [POLYGON]: 2, 73 | [AVALANCHE]: 3, 74 | [BSC]: 4, 75 | [FANTOM]: 5, 76 | [CELO]: 6, 77 | [MOONBEAM]: 7, 78 | [ARBITRUM]: 8, 79 | [OPTIMISM]: 9, 80 | [KLAYTN]: 10, 81 | [BASE]: 11, 82 | // We'll need to add sepolia as a different chain 83 | // because it's added as a diferent chain on the SDK 84 | // and we need to support monitoring sepolia and goerli 85 | // at the same time. 86 | // We'll need to do the same of L2 chains :( 87 | [SEPOLIA]: 12, 88 | }; 89 | 90 | export type EvmDefaultConfig = { 91 | nodeUrl: string; 92 | tokenPollConcurrency?: number; 93 | }; 94 | 95 | export type EvmDefaultConfigs = Record; 96 | 97 | export type EVMChainName = keyof typeof EVM_CHAINS; 98 | export type EVMWallet = ethers.Wallet; 99 | export type EVMProvider = ethers.providers.JsonRpcProvider; 100 | 101 | export const EVM_CHAIN_CONFIGS: Record = { 102 | [ETHEREUM]: ETHEREUM_CHAIN_CONFIG, 103 | [POLYGON]: POLYGON_CHAIN_CONFIG, 104 | [AVALANCHE]: AVALANCHE_CHAIN_CONFIG, 105 | [BSC]: BSC_CHAIN_CONFIG, 106 | [FANTOM]: FANTOM_CHAIN_CONFIG, 107 | [CELO]: CELO_CHAIN_CONFIG, 108 | [MOONBEAM]: MOONBEAM_CHAIN_CONFIG, 109 | [ARBITRUM]: ARBITRUM_CHAIN_CONFIG, 110 | [OPTIMISM]: OPTIMISM_CHAIN_CONFIG, 111 | [KLAYTN]: KLAYTN_CHAIN_CONFIG, 112 | [BASE]: BASE_CHAIN_CONFIG, 113 | [SEPOLIA]: SEPOLIA_CHAIN_CONFIG, 114 | }; 115 | 116 | export type EvmWalletOptions = BaseWalletOptions & { 117 | nodeUrl?: string; 118 | tokenPollConcurrency?: number; 119 | }; 120 | 121 | export type EvmNetworks = 122 | | EthereumNetwork 123 | | PolygonNetwork 124 | | BscNetwork 125 | | AvalancheNetwork 126 | | FantomNetwork 127 | | CeloNetwork 128 | | MoonbeamNetwork 129 | | ArbitrumNetwork 130 | | OptimismNetwork 131 | | KlaytnNetwork 132 | | BaseNetwork 133 | | SepoliaNetwork; 134 | 135 | function getUniqueTokens(wallets: WalletConfig[]): string[] { 136 | const tokens = wallets.reduce((acc, wallet) => { 137 | return wallet.tokens ? [...acc, ...wallet.tokens] : acc; 138 | }, [] as string[]); 139 | 140 | return [...new Set(tokens)]; 141 | } 142 | 143 | export class EvmWalletToolbox extends WalletToolbox { 144 | private provider: EVMProvider; 145 | private chainConfig: EvmChainConfig; 146 | private tokenData: Record = {}; 147 | private options: EvmWalletOptions; 148 | private priceFeed?: PriceFeed; 149 | 150 | constructor( 151 | public network: string, 152 | public chainName: EVMChainName, 153 | public rawConfig: WalletConfig[], 154 | options: EvmWalletOptions, 155 | priceFeed?: PriceFeed 156 | ) { 157 | super(network, chainName, rawConfig, options); 158 | this.chainConfig = EVM_CHAIN_CONFIGS[this.chainName]; 159 | 160 | const defaultOptions = this.chainConfig.defaultConfigs[this.network]; 161 | 162 | this.options = { ...defaultOptions, ...options } as EvmWalletOptions; 163 | this.priceFeed = priceFeed; 164 | 165 | const nodeUrlOrigin = this.options.nodeUrl && new URL(this.options.nodeUrl).origin 166 | this.logger.debug(`EVM rpc url: ${nodeUrlOrigin}`); 167 | this.provider = new ethers.providers.JsonRpcProvider(this.options.nodeUrl); 168 | } 169 | 170 | protected validateChainName(chainName: string): chainName is EVMChainName { 171 | if (!(chainName in EVM_CHAIN_CONFIGS)) 172 | throw new Error(`Invalid chain name "${chainName}" for EVM wallet`); 173 | return true; 174 | } 175 | 176 | protected validateNetwork(network: string): network is EvmNetworks { 177 | if (!(network in EVM_CHAIN_CONFIGS[this.chainName].networks)) 178 | throw new Error( 179 | `Invalid network "${network}" for chain: ${this.chainName}`, 180 | ); 181 | 182 | return true; 183 | } 184 | 185 | protected validateOptions(options: any): options is EvmWalletOptions { 186 | if (!options) return true; 187 | if (typeof options !== "object") 188 | throw new Error(`Invalid options for chain: ${this.chainName}`); 189 | return true; 190 | } 191 | 192 | protected validateTokenAddress(token: string): boolean { 193 | const knownTokens = 194 | EVM_CHAIN_CONFIGS[this.chainName].knownTokens[this.network]; 195 | 196 | return ( 197 | EVM_HEX_ADDRESS_REGEX.test(token) || token.toUpperCase() in knownTokens 198 | ); 199 | } 200 | 201 | protected parseTokensConfig( 202 | tokens: string[], 203 | failOnInvalidTokens: boolean, 204 | ): string[] { 205 | const knownTokens = 206 | EVM_CHAIN_CONFIGS[this.chainName].knownTokens[this.network]; 207 | 208 | const validTokens: string[] = []; 209 | for (const token of tokens) { 210 | if (EVM_HEX_ADDRESS_REGEX.test(token)) { 211 | validTokens.push(token); 212 | } else if (token.toUpperCase() in knownTokens) { 213 | validTokens.push(knownTokens[token.toUpperCase()]); 214 | } else { 215 | if (failOnInvalidTokens) { 216 | throw new Error(`Invalid token address or symbol: ${token}`); 217 | } else { 218 | this.logger.warn(`Invalid token address or symbol: ${token}`); 219 | } 220 | } 221 | } 222 | 223 | return validTokens; 224 | } 225 | 226 | protected async warmup() { 227 | const uniqueTokens = getUniqueTokens(Object.values(this.wallets)); 228 | 229 | await mapConcurrent( 230 | uniqueTokens, 231 | async tokenAddress => { 232 | this.tokenData[tokenAddress] = await pullEvmTokenData( 233 | this.provider, 234 | tokenAddress, 235 | ); 236 | }, 237 | this.options.tokenPollConcurrency, 238 | ); 239 | 240 | this.logger.debug(`EVM token data: ${JSON.stringify(this.tokenData)}`); 241 | } 242 | 243 | public async pullNativeBalance(address: string, blockHeight?: number): Promise { 244 | const balance = await pullEvmNativeBalance(this.provider, address, blockHeight); 245 | const formattedBalance = ethers.utils.formatEther(balance.rawBalance); 246 | 247 | // Pull prices in USD for all the native tokens in single network call 248 | await this.priceFeed?.pullTokenPrices(); 249 | const coingeckoId = coinGeckoIdByChainName[this.chainName]; 250 | const tokenUsdPrice = this.priceFeed?.getKey(coingeckoId); 251 | 252 | return { 253 | ...balance, 254 | address, 255 | formattedBalance, 256 | tokens: [], 257 | symbol: this.chainConfig.nativeCurrencySymbol, 258 | blockHeight, 259 | ...(tokenUsdPrice && { 260 | balanceUsd: Number(formattedBalance) * tokenUsdPrice, 261 | tokenUsdPrice 262 | }) 263 | }; 264 | } 265 | 266 | public async pullTokenBalances( 267 | address: string, 268 | tokens: string[], 269 | ): Promise { 270 | // Pull prices in USD for all the tokens in single network call 271 | const tokenPrices = await this.priceFeed?.pullTokenPrices(); 272 | return mapConcurrent( 273 | tokens, 274 | async tokenAddress => { 275 | const tokenData = this.tokenData[tokenAddress]; 276 | const balance = await pullEvmTokenBalance( 277 | this.provider, 278 | tokenAddress, 279 | address, 280 | ); 281 | const formattedBalance = ethers.utils.formatUnits( 282 | balance.rawBalance, 283 | tokenData.decimals, 284 | ); 285 | 286 | const coinGeckoId = this.priceFeed?.getCoinGeckoId(tokenAddress); 287 | const tokenUsdPrice = coinGeckoId && tokenPrices?.[coinGeckoId]; 288 | 289 | return { 290 | ...balance, 291 | address, 292 | tokenAddress, 293 | formattedBalance, 294 | 295 | symbol: tokenData.symbol, 296 | ...(tokenUsdPrice && { 297 | balanceUsd: Number(formattedBalance) * tokenUsdPrice, 298 | tokenUsdPrice 299 | }) 300 | }; 301 | }, 302 | this.options.tokenPollConcurrency, 303 | ); 304 | } 305 | 306 | protected async transferNativeBalance( 307 | privateKey: string, 308 | targetAddress: string, 309 | amount: number, 310 | maxGasPrice: number, 311 | gasLimit: number, 312 | ) { 313 | const txDetails = { targetAddress, amount, maxGasPrice, gasLimit }; 314 | const receipt = await transferEvmNativeBalance( 315 | this.provider, 316 | privateKey, 317 | txDetails, 318 | ); 319 | 320 | return receipt; 321 | } 322 | 323 | protected async getRawWallet (privateKey: string) { 324 | return new ethers.Wallet(privateKey, this.provider); 325 | } 326 | 327 | public async getGasPrice () { 328 | const gasPrice = await this.provider.getGasPrice(); 329 | return BigInt(gasPrice.toString()); 330 | } 331 | 332 | protected getAddressFromPrivateKey(privateKey: string): string { 333 | return getEvmAddressFromPrivateKey(privateKey); 334 | } 335 | 336 | public async getBlockHeight(): Promise { 337 | return this.provider.getBlockNumber(); 338 | } 339 | } 340 | -------------------------------------------------------------------------------- /src/wallets/evm/klaytn.config.ts: -------------------------------------------------------------------------------- 1 | import { DEVNET } from "../index"; 2 | import { EvmDefaultConfigs } from "./index"; 3 | 4 | const KLAYTN_MAINNET = "mainnet"; 5 | const KLAYTN_BAOBAB = "baobab"; 6 | const KLAYTN_CURRENCY_SYMBOL = "KLAY"; 7 | 8 | export const KLAYTN = "klaytn"; 9 | 10 | export const KLAYTN_NETWORKS = { 11 | [DEVNET]: 1, 12 | [KLAYTN_MAINNET]: 2, 13 | [KLAYTN_BAOBAB]: 3, 14 | }; 15 | 16 | export const KLAYTN_KNOWN_TOKENS = { 17 | [KLAYTN_MAINNET]: { 18 | // WETH: "0x4200000000000000000000000000000000000006", // no WETH on klaytn mainnet 19 | // USDC: "0x7F5c764cBc14f9669B88837ca1490cCa17c31607", // no USDC on klaytn mainnet 20 | // USDT: "0x94b008aA00579c1307B0EF2c499aD98a8ce58e58", // no USDT on klaytn mainnet 21 | // WBTC: "0x68f180fcCe6836688e9084f035309E29Bf0A2095", // no WBTC on klaytn mainnet 22 | // WBNB: "0x418D75f65a02b3D53B2418FB8E1fe493759c7605", // no WBNB on klaytn mainnet 23 | // WMATIC: "0x7c9f4C87d911613Fe9ca58b579f737911AAD2D43", // no WMATIC on klaytn mainnet 24 | // WAVAX: "0x85f138bfEE4ef8e540890CFb48F620571d67Eda3", // no WAVAX on klaytn mainnet 25 | // WFTM: "0x4cD2690d86284e044cb63E60F1EB218a825a7e92", // no WFTM on klaytn mainnet 26 | // WCELO: "0x3294395e62f4eb6af3f1fcf89f5602d90fb3ef69", // no WCELO on klaytn mainnet 27 | // WGLMR: "0x93d3696A9F879b331f40CB5059e37015423A3Bd0", // no WGLMR on klaytn mainnet 28 | // WSUI: "0x84074EA631dEc7a4edcD5303d164D5dEa4c653D6", // no WSUI on klaytn mainnet 29 | // WARB: "0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1", // no WARB on klaytn mainnet 30 | }, 31 | [KLAYTN_BAOBAB]: { 32 | // USDC: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 33 | // DAI: "0x6B175474E89094C44Da98b954EedeAC495271d0F", 34 | }, 35 | [DEVNET]: {}, 36 | }; 37 | 38 | const KLAYTN_DEFAULT_TOKEN_POLL_CONCURRENCY = 10; 39 | 40 | export const KLAYTN_DEFAULT_CONFIGS: EvmDefaultConfigs = { 41 | [KLAYTN_MAINNET]: { 42 | nodeUrl: "https://klaytn.blockpi.network/v1/rpc/public", 43 | tokenPollConcurrency: KLAYTN_DEFAULT_TOKEN_POLL_CONCURRENCY, 44 | }, 45 | [KLAYTN_BAOBAB]: { 46 | nodeUrl: "https://public-node-api.klaytnapi.com/v1/baobab ", 47 | tokenPollConcurrency: KLAYTN_DEFAULT_TOKEN_POLL_CONCURRENCY, 48 | }, 49 | [DEVNET]: { 50 | nodeUrl: "http://localhost:8545", 51 | tokenPollConcurrency: KLAYTN_DEFAULT_TOKEN_POLL_CONCURRENCY, 52 | }, 53 | }; 54 | 55 | export const KLAYTN_CHAIN_CONFIG = { 56 | chainName: KLAYTN, 57 | networks: KLAYTN_NETWORKS, 58 | knownTokens: KLAYTN_KNOWN_TOKENS, 59 | defaultConfigs: KLAYTN_DEFAULT_CONFIGS, 60 | nativeCurrencySymbol: KLAYTN_CURRENCY_SYMBOL, 61 | defaultNetwork: KLAYTN_MAINNET, 62 | }; 63 | 64 | export type KlaytnNetwork = keyof typeof KLAYTN_NETWORKS; 65 | // export type KnownKlaytnTokens = keyof typeof KLAYTN_KNOWN_TOKENS; 66 | -------------------------------------------------------------------------------- /src/wallets/evm/moonbeam.config.ts: -------------------------------------------------------------------------------- 1 | import { DEVNET } from '../'; 2 | import { EvmDefaultConfigs } from "./index"; 3 | 4 | export const MOONBEAM = "moonbeam"; 5 | 6 | const MOONBEAM_MAINNET = "moonbeam-mainnet"; 7 | const MOONBASE_ALPHA = "moonbase-alpha"; 8 | 9 | export const MOONBEAM_NETWORKS = { 10 | [DEVNET]: 1, 11 | [MOONBEAM_MAINNET]: 2, 12 | [MOONBASE_ALPHA]: 3, 13 | }; 14 | 15 | export const MOONBEAM_KNOWN_TOKENS = { 16 | [MOONBEAM_MAINNET]: { 17 | "WETH": "0xab3f0245B83feB11d15AAffeFD7AD465a59817eD", 18 | "USDC": "0x931715FEE2d06333043d11F658C8CE934aC61D0c", 19 | "USDT": "0xc30E9cA94CF52f3Bf5692aaCF81353a27052c46f", 20 | "WBTC": "0xE57eBd2d67B462E9926e04a8e33f01cD0D64346D", 21 | "WBNB": "0xE3b841C3f96e647E6dc01b468d6D0AD3562a9eeb", 22 | "WMATIC": "0x82DbDa803bb52434B1f4F41A6F0Acb1242A7dFa3", 23 | "WAVAX": "0xd4937A95BeC789CC1AE1640714C61c160279B22F", 24 | "WFTM": "0x609AedD990bf45926bca9E4eE988b4Fb98587D3A", 25 | "WCELO": "0xc1a792041985F65c17Eb65E66E254DC879CF380b", 26 | "WGLMR": "0xacc15dc74880c9944775448304b263d191c6077f", 27 | "WSUI": "0x484eCCE6775143D3335Ed2C7bCB22151C53B9F49", 28 | }, 29 | [MOONBASE_ALPHA]: {}, 30 | [DEVNET]: {}, 31 | }; 32 | 33 | const MOONBEAM_DEFAULT_TOKEN_POLL_CONCURRENCY = 10; 34 | 35 | export const MOONBEAM_DEFAULT_CONFIGS: EvmDefaultConfigs = { 36 | [MOONBEAM_MAINNET]: { 37 | nodeUrl: "https://rpc.ankr.com/moonbeam", 38 | tokenPollConcurrency: MOONBEAM_DEFAULT_TOKEN_POLL_CONCURRENCY, 39 | }, 40 | [MOONBASE_ALPHA]: { 41 | nodeUrl: "https://rpc.testnet.moonbeam.network", 42 | tokenPollConcurrency: MOONBEAM_DEFAULT_TOKEN_POLL_CONCURRENCY, 43 | }, 44 | [DEVNET]: { 45 | nodeUrl: "http://localhost:8545", 46 | tokenPollConcurrency: MOONBEAM_DEFAULT_TOKEN_POLL_CONCURRENCY, 47 | }, 48 | }; 49 | 50 | export const MOONBEAM_CHAIN_CONFIG = { 51 | chainName: MOONBEAM, 52 | networks: MOONBEAM_NETWORKS, 53 | knownTokens: MOONBEAM_KNOWN_TOKENS, 54 | defaultConfigs: MOONBEAM_DEFAULT_CONFIGS, 55 | nativeCurrencySymbol: "GLMR", 56 | defaultNetwork: MOONBEAM_MAINNET, 57 | }; 58 | 59 | export type MoonbeamNetwork = keyof typeof MOONBEAM_NETWORKS; 60 | -------------------------------------------------------------------------------- /src/wallets/evm/optimism.config.ts: -------------------------------------------------------------------------------- 1 | import { DEVNET } from "../index"; 2 | import { EvmDefaultConfigs } from "./index"; 3 | 4 | const OPTIMISM_MAINNET = "mainnet"; 5 | const OPTIMISM_GOERLI = "goerli"; 6 | const OPTIMISM_CURRENCY_SYMBOL = "ETH"; 7 | 8 | export const OPTIMISM = "optimism"; 9 | 10 | export const OPTIMISM_NETWORKS = { 11 | [DEVNET]: 1, 12 | [OPTIMISM_MAINNET]: 2, 13 | [OPTIMISM_GOERLI]: 3, 14 | }; 15 | 16 | export const OPTIMISM_KNOWN_TOKENS = { 17 | [OPTIMISM_MAINNET]: { 18 | // WETH: "0x4200000000000000000000000000000000000006", // no WETH on optimism mainnet 19 | // USDC: "0x7F5c764cBc14f9669B88837ca1490cCa17c31607", // no USDC on optimism mainnet 20 | // USDT: "0x94b008aA00579c1307B0EF2c499aD98a8ce58e58", // no USDT on optimism mainnet 21 | // WBTC: "0x68f180fcCe6836688e9084f035309E29Bf0A2095", // no WBTC on optimism mainnet 22 | // WBNB: "0x418D75f65a02b3D53B2418FB8E1fe493759c7605", // no WBNB on optimism mainnet 23 | // WMATIC: "0x7c9f4C87d911613Fe9ca58b579f737911AAD2D43", // no WMATIC on optimism mainnet 24 | // WAVAX: "0x85f138bfEE4ef8e540890CFb48F620571d67Eda3", // no WAVAX on optimism mainnet 25 | // WFTM: "0x4cD2690d86284e044cb63E60F1EB218a825a7e92", // no WFTM on optimism mainnet 26 | // WCELO: "0x3294395e62f4eb6af3f1fcf89f5602d90fb3ef69", // no WCELO on optimism mainnet 27 | // WGLMR: "0x93d3696A9F879b331f40CB5059e37015423A3Bd0", // no WGLMR on optimism mainnet 28 | // WSUI: "0x84074EA631dEc7a4edcD5303d164D5dEa4c653D6", // no WSUI on optimism mainnet 29 | // WARB: "0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1", // no WARB on optimism mainnet 30 | }, 31 | [OPTIMISM_GOERLI]: { 32 | // USDC: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 33 | // DAI: "0x6B175474E89094C44Da98b954EedeAC495271d0F", 34 | }, 35 | [DEVNET]: {}, 36 | }; 37 | 38 | const OPTIMISM_DEFAULT_TOKEN_POLL_CONCURRENCY = 10; 39 | 40 | export const OPTIMISM_DEFAULT_CONFIGS: EvmDefaultConfigs = { 41 | [OPTIMISM_MAINNET]: { 42 | nodeUrl: "https://optimism.publicnode.com", 43 | tokenPollConcurrency: OPTIMISM_DEFAULT_TOKEN_POLL_CONCURRENCY, 44 | }, 45 | [OPTIMISM_GOERLI]: { 46 | nodeUrl: "https://optimism-goerli.publicnode.com", 47 | tokenPollConcurrency: OPTIMISM_DEFAULT_TOKEN_POLL_CONCURRENCY, 48 | }, 49 | [DEVNET]: { 50 | nodeUrl: "http://localhost:8545", 51 | tokenPollConcurrency: OPTIMISM_DEFAULT_TOKEN_POLL_CONCURRENCY, 52 | }, 53 | }; 54 | 55 | export const OPTIMISM_CHAIN_CONFIG = { 56 | chainName: OPTIMISM, 57 | networks: OPTIMISM_NETWORKS, 58 | knownTokens: OPTIMISM_KNOWN_TOKENS, 59 | defaultConfigs: OPTIMISM_DEFAULT_CONFIGS, 60 | nativeCurrencySymbol: OPTIMISM_CURRENCY_SYMBOL, 61 | defaultNetwork: OPTIMISM_MAINNET, 62 | }; 63 | 64 | export type OptimismNetwork = keyof typeof OPTIMISM_NETWORKS; 65 | // export type KnownOptimismTokens = keyof typeof OPTIMISM_KNOWN_TOKENS; 66 | -------------------------------------------------------------------------------- /src/wallets/evm/polygon.config.ts: -------------------------------------------------------------------------------- 1 | import { EvmDefaultConfigs } from './index'; 2 | 3 | export const POLYGON = 'polygon'; 4 | 5 | export const POLYGON_MAINNET = 'mainnet'; 6 | 7 | export const POLYGON_MUMBAI = 'mumbai'; 8 | 9 | export const POLYGON_HEIMDALL = 'heimdall'; 10 | 11 | export const POLYGON_BOR = 'bor'; 12 | 13 | export const POLYGON_NETWORKS = { 14 | [POLYGON_MAINNET]: 1, 15 | [POLYGON_MUMBAI]: 2, 16 | }; 17 | 18 | export const POLYGON_KNOWN_TOKENS = { 19 | [POLYGON_MAINNET]: { 20 | "WETH": "0x11CD37bb86F65419713f30673A480EA33c826872", 21 | "USDC": "0x4318CB63A2b8edf2De971E2F17F77097e499459D", 22 | "USDT": "0x9417669fbf23357d2774e9d421307bd5ea1006d2", 23 | "WBTC": "0x5d49c278340655b56609fdf8976eb0612af3a0c3", 24 | "WBNB": "0xeCDCB5B88F8e3C15f95c720C51c71c9E2080525d", 25 | "WMATIC": "0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270", 26 | "WAVAX": "0x7Bb11E7f8b10E9e571E5d8Eace04735fDFB2358a", 27 | "WFTM": "0x3726831304D77f585f1Aca9d9841cc3Ef80dAa62", 28 | "WCELO": "0x922F49a9911effc034eE756196E59BE7b90D43b3", 29 | "WGLMR": "0xcC48d6CF842083fEc0E01d913fB964b585975F05", 30 | "WSUI": "0x34bE049fEbfc6C64Ffd82Da08a8931A9a45f2cc8", 31 | "WARB": "0x33c788e1191d18ccd0b6db8176ead234ed22321a", 32 | }, 33 | [POLYGON_MUMBAI]: { 34 | 35 | }, 36 | [POLYGON_HEIMDALL]: { 37 | 38 | }, 39 | [POLYGON_BOR]: { 40 | 41 | }, 42 | } 43 | 44 | const POLYGON_DEFAULT_TOKEN_POLL_CONCURRENCY = 10; 45 | 46 | export const POLYGON_DEFAULT_CONFIGS: EvmDefaultConfigs = { 47 | [POLYGON_MAINNET]: { 48 | nodeUrl: 'https://rpc-mainnet.maticvigil.com', 49 | tokenPollConcurrency: POLYGON_DEFAULT_TOKEN_POLL_CONCURRENCY, 50 | }, 51 | [POLYGON_MUMBAI]: { 52 | nodeUrl: 'https://rpc-mumbai.maticvigil.com', 53 | tokenPollConcurrency: POLYGON_DEFAULT_TOKEN_POLL_CONCURRENCY, 54 | }, 55 | [POLYGON_HEIMDALL]: { 56 | nodeUrl: 'https://rpc.heimdall.matic.today', 57 | tokenPollConcurrency: POLYGON_DEFAULT_TOKEN_POLL_CONCURRENCY, 58 | }, 59 | [POLYGON_BOR]: { 60 | nodeUrl: 'https://rpc.bor.matic.today', 61 | tokenPollConcurrency: POLYGON_DEFAULT_TOKEN_POLL_CONCURRENCY, 62 | }, 63 | } 64 | 65 | export const POLYGON_CHAIN_CONFIG = { 66 | chainName: POLYGON, 67 | nativeCurrencySymbol: 'MATIC', 68 | knownTokens: POLYGON_KNOWN_TOKENS, 69 | networks: POLYGON_NETWORKS, 70 | defaultConfigs: POLYGON_DEFAULT_CONFIGS, 71 | defaultNetwork: POLYGON_MAINNET, 72 | }; 73 | 74 | export type PolygonNetwork = keyof typeof POLYGON_NETWORKS; 75 | // export type KnownPolygonTokens = keyof typeof ETHEREUM_KNOWN_TOKENS; 76 | -------------------------------------------------------------------------------- /src/wallets/evm/sepolia.config.ts: -------------------------------------------------------------------------------- 1 | import { DEVNET } from "../index"; 2 | import { EvmDefaultConfigs } from "./index"; 3 | 4 | const ETHEREUM_SEPOLIA = "sepolia"; 5 | 6 | const ETHEREUM_CURRENCY_SYMBOL = "ETH"; 7 | 8 | export const SEPOLIA = "sepolia"; 9 | 10 | export const SEPOLIA_NETWORKS = { 11 | [ETHEREUM_SEPOLIA]: 1, 12 | }; 13 | 14 | export const SEPOLIA_KNOWN_TOKENS = { 15 | [ETHEREUM_SEPOLIA]: { 16 | }, 17 | }; 18 | 19 | const ETHEREUM_DEFAULT_TOKEN_POLL_CONCURRENCY = 10; 20 | 21 | export const SEPOLIA_DEFAULT_CONFIGS: EvmDefaultConfigs = { 22 | [ETHEREUM_SEPOLIA]: { 23 | nodeUrl: "https://sepolia.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161", // https://endpoints.omniatech.io/v1/eth/sepolia/public 24 | tokenPollConcurrency: ETHEREUM_DEFAULT_TOKEN_POLL_CONCURRENCY, 25 | }, 26 | }; 27 | 28 | export const SEPOLIA_CHAIN_CONFIG = { 29 | chainName: SEPOLIA, 30 | knownTokens: SEPOLIA_KNOWN_TOKENS, 31 | defaultConfigs: SEPOLIA_DEFAULT_CONFIGS, 32 | nativeCurrencySymbol: ETHEREUM_CURRENCY_SYMBOL, 33 | defaultNetwork: ETHEREUM_SEPOLIA, 34 | networks: SEPOLIA_NETWORKS, 35 | }; 36 | 37 | export type SepoliaNetwork = keyof typeof SEPOLIA_NETWORKS; 38 | // export type KnownEthereumTokens = keyof typeof ETHEREUM_KNOWN_TOKENS; 39 | -------------------------------------------------------------------------------- /src/wallets/index.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const DEVNET = "devnet"; 4 | 5 | export const enum Environment { 6 | MAINNET = "mainnet", 7 | TESTNET = "testnet", 8 | DEVNET = "devnet", 9 | } 10 | 11 | import { 12 | EvmWalletOptions, 13 | EvmWalletToolbox, 14 | EVM_CHAIN_CONFIGS, 15 | EVMChainName, 16 | EvmNetworks, 17 | } from "./evm"; 18 | import { 19 | SOLANA_CHAIN_CONFIGS, 20 | SOLANA_CHAINS, 21 | SolanaChainName, 22 | SolanaWalletOptions, 23 | SolanaWalletToolbox, 24 | } from "./solana"; 25 | import { 26 | SuiWalletToolbox, 27 | SUI_CHAINS, 28 | SuiChainName, 29 | SuiWalletOptions, 30 | SUI_CHAIN_CONFIGS, 31 | } from "./sui"; 32 | import { SolanaNetworks } from "./solana/solana.config"; 33 | import { EvmChainConfig } from "./evm"; 34 | import { SuiChainConfig } from "./sui"; 35 | import { SolanaChainConfig } from "./solana"; 36 | import { PriceFeed } from "../wallet-manager"; 37 | import { 38 | COSMOS_CHAIN_CONFIGS, 39 | CosmosChainConfig, 40 | CosmosChainName, 41 | CosmosNetworks, 42 | CosmosWalletOptions, 43 | CosmosWalletToolbox, 44 | } from "./cosmos"; 45 | 46 | export const KNOWN_CHAINS: Partial<{ 47 | [key in ChainName]: 48 | | EvmChainConfig 49 | | SuiChainConfig 50 | | SolanaChainConfig 51 | | CosmosChainConfig; 52 | }> = { 53 | ...SUI_CHAIN_CONFIGS, 54 | ...EVM_CHAIN_CONFIGS, 55 | ...SOLANA_CHAIN_CONFIGS, 56 | ...COSMOS_CHAIN_CONFIGS, 57 | }; 58 | 59 | export type ChainName = 60 | | EVMChainName 61 | | SolanaChainName 62 | | SuiChainName 63 | | CosmosChainName; 64 | export type Wallet = 65 | | EvmWalletToolbox 66 | | SolanaWalletToolbox 67 | | SuiWalletToolbox 68 | | CosmosWalletToolbox; 69 | export type WalletOptions = 70 | | EvmWalletOptions 71 | | SolanaWalletOptions 72 | | SuiWalletOptions 73 | | CosmosWalletOptions; 74 | 75 | // TODO: Consider writing a custom validator for an address? 76 | export const WalletConfigSchema = z.object({ 77 | address: z.string().optional(), 78 | tokens: z.array(z.string()).optional(), 79 | privateKey: z.string().optional(), 80 | }); 81 | export type WalletConfig = z.infer; 82 | 83 | export type Balance = { 84 | symbol: string; 85 | address: string; 86 | isNative: boolean; 87 | rawBalance: string; 88 | formattedBalance: string; 89 | blockHeight?: number; 90 | tokenUsdPrice?: number; 91 | balanceUsd?: number; 92 | }; 93 | 94 | export type TokenBalance = Balance & { 95 | // TODO: put Solana mint address here and make this not optional 96 | // Otherwise handle this as a tagged union if there are other types of tokens there. 97 | tokenAddress?: string; 98 | }; 99 | 100 | export type WalletBalance = Balance & { 101 | tokens: TokenBalance[]; 102 | }; 103 | 104 | export type AllNetworks = EvmNetworks | SolanaNetworks | CosmosNetworks; 105 | 106 | export function isChain(chainName: string): chainName is ChainName { 107 | return chainName in KNOWN_CHAINS; 108 | } 109 | 110 | export function isEvmChain(chainName: ChainName): chainName is EVMChainName { 111 | return chainName in EVM_CHAIN_CONFIGS; 112 | } 113 | 114 | export function isSolanaChain( 115 | chainName: ChainName, 116 | ): chainName is SolanaChainName { 117 | return chainName in SOLANA_CHAINS; 118 | } 119 | 120 | export function isSuiChain(chainName: ChainName): chainName is SuiChainName { 121 | return chainName in SUI_CHAINS; 122 | } 123 | 124 | export function isCosmosChain( 125 | chainName: ChainName, 126 | ): chainName is CosmosChainName { 127 | return chainName in COSMOS_CHAIN_CONFIGS; 128 | } 129 | 130 | export function createWalletToolbox( 131 | network: string, 132 | chainName: string, 133 | wallets: WalletConfig[], 134 | walletOptions: WalletOptions, 135 | priceFeed?: PriceFeed, 136 | ): Wallet { 137 | if (!isChain(chainName)) throw new Error("Unknown chain name " + chainName); 138 | 139 | switch (true) { 140 | case isEvmChain(chainName): 141 | return new EvmWalletToolbox( 142 | network, 143 | chainName as EVMChainName, 144 | wallets, 145 | walletOptions, 146 | priceFeed, 147 | ); 148 | 149 | case isSolanaChain(chainName): 150 | return new SolanaWalletToolbox( 151 | network, 152 | chainName as SolanaChainName, 153 | wallets, 154 | walletOptions, 155 | priceFeed, 156 | ); 157 | 158 | case isSuiChain(chainName): 159 | return new SuiWalletToolbox( 160 | network, 161 | chainName as SuiChainName, 162 | wallets, 163 | walletOptions as SuiWalletOptions, 164 | priceFeed, 165 | ); 166 | 167 | case isCosmosChain(chainName): 168 | return new CosmosWalletToolbox( 169 | network, 170 | chainName as CosmosChainName, 171 | wallets, 172 | walletOptions as CosmosWalletOptions, 173 | priceFeed, 174 | ); 175 | default: 176 | throw new Error(`Unknown chain name ${chainName}`); 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/wallets/resource.ts: -------------------------------------------------------------------------------- 1 | export class Resource { 2 | private id: string; 3 | private locked = false; 4 | private discarded = false; 5 | 6 | constructor (id: string) { 7 | this.id = id; 8 | } 9 | 10 | getId () { 11 | return this.id; 12 | } 13 | 14 | discard () { 15 | this.discarded = true; 16 | } 17 | 18 | retain () { 19 | this.discarded = false; 20 | } 21 | 22 | lock () { 23 | this.locked = true; 24 | } 25 | 26 | unlock () { 27 | this.locked = false; 28 | } 29 | 30 | isAvailable () { 31 | return !(this.locked || this.discarded); 32 | } 33 | 34 | isLocked () { 35 | return this.locked; 36 | } 37 | 38 | isDiscarded () { 39 | return this.discarded; 40 | } 41 | } -------------------------------------------------------------------------------- /src/wallets/solana/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Connection, 3 | LAMPORTS_PER_SOL, 4 | PublicKey, 5 | Keypair, 6 | RecentPrioritizationFees, 7 | } from "@solana/web3.js"; 8 | import { decode } from "bs58"; 9 | import { 10 | SOLANA, 11 | SOLANA_CHAIN_CONFIG, 12 | SOLANA_DEFAULT_COMMITMENT, 13 | SolanaNetworks, 14 | } from "./solana.config"; 15 | import { PYTHNET, PYTHNET_CHAIN_CONFIG } from "./pythnet.config"; 16 | import { 17 | BaseWalletOptions, 18 | TransferReceipt, 19 | WalletToolbox, 20 | } from "../base-wallet"; 21 | import { 22 | WalletBalance, 23 | TokenBalance, 24 | WalletConfig, 25 | WalletOptions, 26 | } from "../index"; 27 | import { 28 | pullSolanaNativeBalance, 29 | getSolanaAddressFromPrivateKey, 30 | } from "../../balances/solana"; 31 | import { getMint, Mint, TOKEN_PROGRAM_ID } from "@solana/spl-token"; 32 | import { findMedian, mapConcurrent } from "../../utils"; 33 | import { PriceFeed } from "../../wallet-manager"; 34 | import { coinGeckoIdByChainName } from "../../price-assistant/supported-tokens.config"; 35 | 36 | export type SolanaChainConfig = { 37 | chainName: string; 38 | nativeCurrencySymbol: string; 39 | knownTokens: Record>; 40 | defaultConfigs: Record; 41 | networks: Record; 42 | defaultNetwork: string; 43 | }; 44 | 45 | type SolanaWalletConfig = { 46 | address: string; 47 | tokens: string[]; 48 | }; 49 | 50 | export const SOLANA_CHAINS = { 51 | [SOLANA]: 1, 52 | [PYTHNET]: 2, 53 | }; 54 | 55 | export type SolanaChainName = keyof typeof SOLANA_CHAINS; 56 | export type SolanaWallet = { 57 | conn: Connection; 58 | payer: Keypair; 59 | }; 60 | export type SolanaProvider = Connection; 61 | 62 | export const SOLANA_CHAIN_CONFIGS: Record = 63 | { 64 | [SOLANA]: SOLANA_CHAIN_CONFIG, 65 | [PYTHNET]: PYTHNET_CHAIN_CONFIG, 66 | }; 67 | 68 | export type SolanaWalletOptions = BaseWalletOptions & { 69 | nodeUrl?: string; 70 | tokenPollConcurrency?: number; 71 | }; 72 | 73 | export class SolanaWalletToolbox extends WalletToolbox { 74 | private chainConfig: SolanaChainConfig; 75 | private options: SolanaWalletOptions; 76 | private tokenData: Record = {}; 77 | private connection: Connection; 78 | private priceFeed?: PriceFeed; 79 | 80 | constructor( 81 | public network: string, 82 | public chainName: SolanaChainName, 83 | public rawConfig: WalletConfig[], 84 | options: WalletOptions, 85 | priceFeed?: PriceFeed, 86 | ) { 87 | // TODO: purge useless wallet pool 88 | super(network, chainName, rawConfig, options); 89 | 90 | this.chainConfig = SOLANA_CHAIN_CONFIGS[this.chainName]; 91 | 92 | const defaultOptions = this.chainConfig.defaultConfigs[this.network]; 93 | this.options = { ...defaultOptions, ...options }; 94 | this.priceFeed = priceFeed; 95 | 96 | if (!this.options.nodeUrl) throw new Error(`No default node url provided.`); 97 | 98 | this.connection = new Connection(this.options.nodeUrl); 99 | } 100 | 101 | parseTokensConfig( 102 | tokens: SolanaWalletConfig["tokens"], 103 | failOnInvalidTokens: boolean, 104 | ): string[] { 105 | const knownTokens = 106 | SOLANA_CHAIN_CONFIGS[this.chainName].knownTokens[this.network]; 107 | const validTokens: string[] = []; 108 | for (const token of tokens) { 109 | if (token.toUpperCase() in knownTokens) 110 | validTokens.push(knownTokens[token.toUpperCase()]); 111 | else if (this.isValidTokenNativeAddress(token)) validTokens.push(token); 112 | else if (failOnInvalidTokens) 113 | throw new Error("Invalid Token Config for Solana: " + token); 114 | } 115 | 116 | return validTokens; 117 | } 118 | 119 | public async pullNativeBalance(address: string, blockHeight?: number): Promise { 120 | const balance = await pullSolanaNativeBalance(this.connection, address); 121 | const formattedBalance = ( 122 | Number(balance.rawBalance) / LAMPORTS_PER_SOL 123 | ).toString(); 124 | 125 | 126 | if (blockHeight) { 127 | this.logger.warn(`Solana does not support pulling balances by block height, ignoring blockHeight: ${blockHeight}`); 128 | } 129 | 130 | // Pull prices in USD for all the native tokens in single network call 131 | await this.priceFeed?.pullTokenPrices(); 132 | const coingeckoId = coinGeckoIdByChainName[this.chainName]; 133 | const tokenUsdPrice = this.priceFeed?.getKey(coingeckoId); 134 | 135 | return { 136 | ...balance, 137 | address, 138 | formattedBalance, 139 | tokens: [], 140 | symbol: this.chainConfig.nativeCurrencySymbol, 141 | blockHeight, 142 | ...(tokenUsdPrice && { 143 | balanceUsd: Number(formattedBalance) * tokenUsdPrice, 144 | tokenUsdPrice 145 | }) 146 | }; 147 | } 148 | 149 | public async pullTokenBalances( 150 | address: string, 151 | tokens: string[], 152 | ): Promise { 153 | // Because one user account could be the owner of multiple token accounts with the same mint account, 154 | // we need to aggregate data and sum over the distinct mint accounts. 155 | const tokenBalances = await this.connection.getParsedTokenAccountsByOwner( 156 | new PublicKey(address), 157 | { programId: TOKEN_PROGRAM_ID }, 158 | ); 159 | 160 | // decimals field of the map value doesn't change each time, but we still put it there for convenience. 161 | const tokenBalancesDistinct: Map = new Map(); 162 | tokenBalances.value.forEach(tokenAccount => { 163 | const mintAccount = tokenAccount.account.data.parsed.info.mint; 164 | const tokenAccountBalance = 165 | tokenAccount.account.data.parsed.info.tokenAmount.amount; 166 | tokenBalancesDistinct.set( 167 | mintAccount, 168 | (tokenBalancesDistinct.get(mintAccount) ?? 0) + tokenAccountBalance, 169 | ); 170 | }); 171 | 172 | // Pull prices in USD for all the tokens in single network call 173 | const tokenPrices = await this.priceFeed?.pullTokenPrices(); 174 | 175 | // Assuming that tokens[] is actually an array of mint account addresses. 176 | return tokens.map(token => { 177 | const tokenData = this.tokenData[token]; 178 | const tokenKnownInfo = Object.entries( 179 | this.chainConfig.knownTokens[this.network], 180 | ).find(([_, value]) => value === token); 181 | const tokenKnownSymbol = tokenKnownInfo ? tokenKnownInfo[0] : undefined; 182 | 183 | // We are choosing to show a balance of 0 for a token that is not owned by the address. 184 | const tokenBalance = tokenBalancesDistinct.get(token) ?? 0; 185 | const formattedBalance = tokenBalance / 10 ** tokenData.decimals; 186 | 187 | const coinGeckoId = this.priceFeed?.getCoinGeckoId(token); 188 | const tokenUsdPrice = coinGeckoId && tokenPrices?.[coinGeckoId]; 189 | 190 | return { 191 | isNative: false, 192 | rawBalance: tokenBalance.toString(), 193 | address, 194 | formattedBalance: formattedBalance.toString(), 195 | symbol: tokenKnownSymbol ?? "unknown", 196 | ...(tokenUsdPrice !== undefined && { 197 | balanceUsd: formattedBalance * tokenUsdPrice, 198 | tokenUsdPrice 199 | }) 200 | }; 201 | }); 202 | } 203 | 204 | protected validateChainName(chainName: string): chainName is SolanaChainName { 205 | if (!(chainName in SOLANA_CHAIN_CONFIGS)) 206 | throw new Error(`Invalid chain name "${chainName}" for Solana wallet`); 207 | return true; 208 | } 209 | 210 | protected validateTokenAddress(token: any) { 211 | const chainConfig = SOLANA_CHAIN_CONFIGS[this.chainName]; 212 | 213 | if (typeof token !== "string") 214 | throw new Error( 215 | `Invalid config for chain: ${this.chainName}: Invalid token`, 216 | ); 217 | 218 | if (token.toUpperCase() in chainConfig.knownTokens[this.network]) 219 | return true; 220 | 221 | if (!this.isValidTokenNativeAddress(token)) 222 | throw new Error( 223 | `Invalid token config for chain: ${this.chainName}: Invalid token "${token}"`, 224 | ); 225 | 226 | return true; 227 | } 228 | 229 | protected validateNetwork(network: string): network is SolanaNetworks { 230 | if (!(network in SOLANA_CHAIN_CONFIGS[this.chainName].networks)) 231 | throw new Error( 232 | `Invalid network "${network}" for chain: ${this.chainName}`, 233 | ); 234 | return true; 235 | } 236 | 237 | protected validateOptions(options: any): options is SolanaWalletOptions { 238 | return false; 239 | } 240 | 241 | protected async warmup() { 242 | const distinctTokens = [ 243 | ...new Set( 244 | Object.values(this.wallets).flatMap(({ address, tokens }) => { 245 | return tokens || []; 246 | }), 247 | ), 248 | ]; 249 | await mapConcurrent( 250 | distinctTokens, 251 | async token => { 252 | this.tokenData[token] = await getMint( 253 | this.connection, 254 | new PublicKey(token), 255 | ); 256 | }, 257 | this.options.tokenPollConcurrency, 258 | ); 259 | } 260 | 261 | protected async transferNativeBalance( 262 | sourceAddress: string, 263 | targetAddress: string, 264 | amount: number, 265 | ): Promise { 266 | // TODO: implement 267 | throw new Error( 268 | "SolanaWalletToolbox.transferNativeBalance not implemented.", 269 | ); 270 | } 271 | 272 | protected async getRawWallet(privateKey: string) { 273 | let secretKey; 274 | try { 275 | secretKey = decode(privateKey); 276 | } catch (e) { 277 | secretKey = new Uint8Array(JSON.parse(privateKey)); 278 | } 279 | return { 280 | conn: this.connection, 281 | payer: Keypair.fromSecretKey(secretKey), 282 | } as SolanaWallet; 283 | } 284 | 285 | public async getGasPrice() { 286 | // see more on why use getRecentPrioritizationFees: https://docs.solana.com/transaction_fees#get-recent-prioritization-fees 287 | const recentBlocks = await this.connection.getRecentPrioritizationFees(); 288 | const prioritizationFee = findMedian( 289 | recentBlocks, 290 | block => block.prioritizationFee, 291 | ); 292 | 293 | if (!prioritizationFee) 294 | throw new Error("Failed to get gas price for solana"); 295 | return BigInt(prioritizationFee); 296 | } 297 | 298 | protected getAddressFromPrivateKey(privateKey: string): string { 299 | return getSolanaAddressFromPrivateKey(privateKey); 300 | } 301 | 302 | protected isValidTokenNativeAddress(token: string): boolean { 303 | try { 304 | new PublicKey(token); 305 | } catch (e) { 306 | return false; 307 | } 308 | return true; 309 | } 310 | 311 | public async getBlockHeight(): Promise { 312 | return this.connection.getBlockHeight(SOLANA_DEFAULT_COMMITMENT); 313 | } 314 | 315 | public async acquire(address?: string, acquireTimeout?: number) { 316 | // We'll pick the first one only 317 | const wallets = Object.values(this.wallets).filter( 318 | ({ privateKey }) => privateKey !== undefined, 319 | ); 320 | if (wallets.length === 0) { 321 | throw new Error("No signer wallet found for Solana."); 322 | } 323 | 324 | const wallet = wallets[0]; 325 | const privateKey = wallet.privateKey; 326 | 327 | return { 328 | address: wallet.address, 329 | // We already checked that the private key is defined above 330 | rawWallet: await this.getRawWallet(privateKey!), 331 | }; 332 | } 333 | 334 | public async release(address: string): Promise {} 335 | } 336 | -------------------------------------------------------------------------------- /src/wallets/solana/pythnet.config.ts: -------------------------------------------------------------------------------- 1 | import { DEVNET } from '../index'; 2 | 3 | export const PYTHNET = 'pythnet'; 4 | 5 | // TODO: reference this page to add a full list of networks. 6 | export const PYTHNET_MAINNET = 'mainnet-beta' 7 | export const PYTHNET_NETWORKS = { 8 | [DEVNET]: 1, 9 | [PYTHNET_MAINNET]: 2, 10 | }; 11 | 12 | export const PYTHNET_KNOWN_TOKENS = { 13 | [PYTHNET_MAINNET]: {}, 14 | [DEVNET]: {}, 15 | }; 16 | 17 | export const PYTHNET_CHAIN_CONFIG = { 18 | chainName: PYTHNET, 19 | nativeCurrencySymbol: 'PGAS', 20 | knownTokens: PYTHNET_KNOWN_TOKENS, 21 | networks: PYTHNET_NETWORKS, 22 | defaultNetwork: PYTHNET_MAINNET, 23 | defaultConfigs: {}, 24 | }; 25 | -------------------------------------------------------------------------------- /src/wallets/solana/solana.config.ts: -------------------------------------------------------------------------------- 1 | import { DEVNET } from '../index'; 2 | const SOLANA_MAINNET = 'mainnet-beta'; 3 | const SOLANA_TESTNET = 'solana-devnet'; 4 | const SOLANA_CURRENCY_SYMBOL = 'SOL'; 5 | 6 | export const SOLANA = 'solana'; 7 | export const SOLANA_DEFAULT_COMMITMENT = 'finalized'; 8 | 9 | export const SOLANA_NETWORKS = { 10 | [SOLANA_MAINNET]: 1, 11 | [SOLANA_TESTNET]: 2, 12 | } 13 | 14 | export const SOLANA_KNOWN_TOKENS = { 15 | // TBD 16 | [SOLANA_MAINNET]: { 17 | "USDC": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" 18 | }, 19 | [SOLANA_TESTNET]: {}, 20 | [DEVNET]: {}, 21 | } 22 | 23 | const SOLANA_DEFAULT_TOKEN_POLL_CONCURRENCY = 10; 24 | 25 | export const SOLANA_DEFAULT_CONFIGS = { 26 | [SOLANA_MAINNET]: { 27 | nodeUrl: 'https://api.mainnet-beta.solana.com', 28 | tokenPollConcurrency: SOLANA_DEFAULT_TOKEN_POLL_CONCURRENCY, 29 | }, 30 | [SOLANA_TESTNET]: { 31 | nodeUrl: 'https://api.devnet.solana.com', 32 | tokenPollConcurrency: SOLANA_DEFAULT_TOKEN_POLL_CONCURRENCY, 33 | }, 34 | [DEVNET]: { 35 | nodeUrl: 'http://localhost:8899', 36 | tokenPollConcurrency: SOLANA_DEFAULT_TOKEN_POLL_CONCURRENCY, 37 | }, 38 | } 39 | 40 | export const SOLANA_CHAIN_CONFIG = { 41 | chainName: SOLANA, 42 | networks: SOLANA_NETWORKS, 43 | defaultNetwork: SOLANA_MAINNET, 44 | knownTokens: SOLANA_KNOWN_TOKENS, 45 | defaultConfigs: SOLANA_DEFAULT_CONFIGS, 46 | nativeCurrencySymbol: SOLANA_CURRENCY_SYMBOL, 47 | } 48 | 49 | export type SolanaNetworks = keyof typeof SOLANA_NETWORKS; 50 | export type KnownSolanaTokens = keyof typeof SOLANA_KNOWN_TOKENS; 51 | -------------------------------------------------------------------------------- /src/wallets/sui/index.ts: -------------------------------------------------------------------------------- 1 | import { Connection, Ed25519Keypair, RawSigner, JsonRpcProvider } from "@mysten/sui.js"; 2 | 3 | import { WalletConfig, WalletBalance, TokenBalance } from "../"; 4 | import { 5 | WalletToolbox, 6 | BaseWalletOptions, 7 | TransferReceipt, WalletData, 8 | } from "../base-wallet"; 9 | import { 10 | SuiTokenData, 11 | pullSuiNativeBalance, 12 | pullSuiTokenBalances, 13 | pullSuiTokenData, 14 | transferSuiNativeBalance 15 | } from "../../balances/sui"; 16 | 17 | import { 18 | SUI_CHAIN_CONFIG, 19 | SUI, 20 | SuiDefaultConfig, 21 | } from "./sui.config"; 22 | import { getSuiAddressFromPrivateKey } from "../../balances/sui"; 23 | import {mapConcurrent} from "../../utils"; 24 | import { formatFixed } from "@ethersproject/bignumber"; 25 | import { PriceFeed } from "../../wallet-manager"; 26 | import { coinGeckoIdByChainName } from "../../price-assistant/supported-tokens.config"; 27 | 28 | export const SUI_CHAINS = { 29 | [SUI]: 1, 30 | }; 31 | 32 | export const SUI_CHAIN_CONFIGS: Record = { 33 | [SUI]: SUI_CHAIN_CONFIG, 34 | }; 35 | 36 | export type SuiWalletOptions = BaseWalletOptions & { 37 | nodeUrl: string; 38 | faucetUrl?: string; 39 | tokenPollConcurrency?: number | undefined; 40 | }; 41 | 42 | export type SuiChainConfig = { 43 | chainName: string; 44 | nativeCurrencySymbol: string; 45 | knownTokens: Record>; 46 | defaultConfigs: Record; 47 | networks: Record; 48 | defaultNetwork: string; 49 | }; 50 | 51 | export type SuiDefaultConfigs = Record; 52 | 53 | export type SuiChainName = keyof typeof SUI_CHAINS; 54 | export type SuiWallet = RawSigner; 55 | export type SuiProvider = Connection; 56 | 57 | const SUI_HEX_ADDRESS_REGEX = /^0x[a-fA-F0-9]{64}::\w+::\w+$/; 58 | 59 | export class SuiWalletToolbox extends WalletToolbox { 60 | private connection: Connection; 61 | private chainConfig: SuiChainConfig; 62 | private tokenData: Record = {}; 63 | private options: SuiWalletOptions; 64 | private priceFeed?: PriceFeed; 65 | 66 | constructor( 67 | public network: string, 68 | public chainName: SuiChainName, 69 | public rawConfig: WalletConfig[], 70 | options: SuiWalletOptions, 71 | priceFeed?: PriceFeed 72 | ) { 73 | // TODO: purge useless wallet pool 74 | super(network, chainName, rawConfig, options); 75 | this.chainConfig = SUI_CHAIN_CONFIGS[this.chainName]; 76 | 77 | const defaultOptions = this.chainConfig.defaultConfigs[this.network]; 78 | 79 | this.options = { ...defaultOptions, ...options } as SuiWalletOptions; 80 | this.priceFeed = priceFeed; 81 | 82 | const nodeUrlOrigin = this.options.nodeUrl && new URL(this.options.nodeUrl).origin 83 | this.logger.debug(`SUI rpc url: ${nodeUrlOrigin}`); 84 | 85 | this.connection = new Connection({ 86 | fullnode: this.options.nodeUrl, 87 | }); 88 | } 89 | 90 | protected validateChainName(chainName: any): chainName is SuiChainName { 91 | if (chainName !== SUI) 92 | throw new Error(`Invalid chain name "${chainName}" for SUI wallet`); 93 | return true; 94 | } 95 | 96 | protected validateNetwork(network: string) { 97 | if (!(network in SUI_CHAIN_CONFIGS[this.chainName].networks)) 98 | throw new Error( 99 | `Invalid network "${network}" for chain: ${this.chainName}` 100 | ); 101 | return true; 102 | } 103 | 104 | protected validateOptions(options: any) { 105 | if (!options) return true; 106 | if (typeof options !== "object") 107 | throw new Error(`Invalid options for chain: ${this.chainName}`); 108 | return true; 109 | } 110 | 111 | protected validateTokenAddress(token: string): boolean { 112 | if (!this.isValidNativeTokenAddress(token)) { 113 | throw new Error(`Invalid token address: ${token}`); 114 | } 115 | 116 | if (!(this.isKnownToken(token))) { 117 | throw new Error(`Unknown token address: ${token}`); 118 | } 119 | return true; 120 | } 121 | 122 | protected parseTokensConfig(tokens: string[], failOnInvalidTokens: boolean): string[] { 123 | const validTokens: string[] = []; 124 | for (const token of tokens) { 125 | if (this.isValidNativeTokenAddress(token)) { 126 | validTokens.push(token); 127 | } else if (this.isKnownToken(token)) { 128 | validTokens.push(this.getKnownTokenAddress(token)); 129 | } else if (failOnInvalidTokens) { 130 | throw new Error(`Invalid token address: ${token}`); 131 | } 132 | } 133 | return validTokens; 134 | } 135 | 136 | private getKnownTokenAddress(token: string): string { 137 | return this.getKnownTokens()[token.toUpperCase()]; 138 | } 139 | 140 | private getKnownTokens(): Record { 141 | return SUI_CHAIN_CONFIGS[this.chainName].knownTokens[this.network]; 142 | } 143 | 144 | private isKnownToken(token: string): boolean { 145 | return token.toUpperCase() in this.getKnownTokens(); 146 | } 147 | 148 | protected async warmup() { 149 | const tokens = this.walletTokens(this.wallets); 150 | await mapConcurrent(tokens, async (tokenAddress: string): Promise => { 151 | // tokens has addresses, per this.parseTokensConfig() contract 152 | const tokenData = await pullSuiTokenData(this.connection, tokenAddress); 153 | this.tokenData[tokenAddress] = tokenData; 154 | }, 1); 155 | 156 | this.logger.debug(`Sui token data: ${JSON.stringify(this.tokenData)}`); 157 | } 158 | 159 | private walletTokens(wallets: Record): string[] { 160 | const walletData = Object.values(wallets); 161 | 162 | return walletData.reduce((tokens: string[], wallet: WalletData): string[] => { 163 | return wallet.tokens ? [...tokens, ...wallet.tokens] : [...tokens] 164 | }, [] as string[]); 165 | } 166 | 167 | public async pullNativeBalance(address: string, blockHeight?: number): Promise { 168 | const balance = await pullSuiNativeBalance(this.connection, address); 169 | const formattedBalance = String(+balance.rawBalance / 10 ** 9); 170 | 171 | if (blockHeight) { 172 | this.logger.warn(`Sui does not support pulling balances by block height, ignoring blockHeight: ${blockHeight}`); 173 | } 174 | 175 | // Pull prices in USD for all the native tokens in single network call 176 | await this.priceFeed?.pullTokenPrices(); 177 | const coingeckoId = coinGeckoIdByChainName[this.chainName]; 178 | const tokenUsdPrice = this.priceFeed?.getKey(coingeckoId); 179 | 180 | return { 181 | ...balance, 182 | address, 183 | formattedBalance, 184 | tokens: [], 185 | symbol: this.chainConfig.nativeCurrencySymbol, 186 | blockHeight, 187 | ...(tokenUsdPrice && { 188 | balanceUsd: Number(formattedBalance) * tokenUsdPrice, 189 | tokenUsdPrice 190 | }) 191 | }; 192 | } 193 | 194 | public async pullTokenBalances( 195 | address: string, 196 | tokens: string[] 197 | ): Promise { 198 | const uniqueTokens = [...new Set(tokens)]; 199 | const allBalances = await pullSuiTokenBalances(this.connection, address); 200 | // Pull prices in USD for all the tokens in single network call 201 | const tokenPrices = await this.priceFeed?.pullTokenPrices(); 202 | 203 | return uniqueTokens.map(tokenAddress => { 204 | const tokenData = this.tokenData[tokenAddress]; 205 | const symbol: string = tokenData?.symbol ? tokenData.symbol : ""; 206 | 207 | const balance = allBalances.find(balance => balance.coinType === tokenData.address); 208 | if (!balance) { 209 | return { 210 | tokenAddress, 211 | address, 212 | 213 | isNative: false, 214 | rawBalance: "0", 215 | formattedBalance: "0", 216 | symbol, 217 | } 218 | } 219 | 220 | const tokenDecimals = tokenData?.decimals ?? 9; 221 | const formattedBalance = formatFixed( 222 | balance.totalBalance, 223 | tokenDecimals 224 | ); 225 | 226 | const coinGeckoId = this.priceFeed?.getCoinGeckoId(tokenAddress); 227 | const tokenUsdPrice = coinGeckoId && tokenPrices?.[coinGeckoId]; 228 | 229 | return { 230 | tokenAddress, 231 | address, 232 | isNative: false, 233 | rawBalance: balance.totalBalance, 234 | formattedBalance, 235 | symbol, 236 | ...(tokenUsdPrice && { 237 | balanceUsd: Number(formattedBalance) * tokenUsdPrice, 238 | tokenUsdPrice 239 | }) 240 | }; 241 | }); 242 | } 243 | 244 | protected async transferNativeBalance( 245 | privateKey: string, 246 | targetAddress: string, 247 | amount: number, 248 | maxGasPrice?: number, 249 | gasLimit?: number 250 | ): Promise { 251 | const txDetails = { targetAddress, amount, maxGasPrice, gasLimit }; 252 | return transferSuiNativeBalance(this.connection, privateKey, txDetails); 253 | } 254 | 255 | protected async getRawWallet (privateKey: string) { 256 | const suiPrivateKeyAsBuffer = Buffer.from(privateKey, "base64"); 257 | const keyPair = Ed25519Keypair.fromSecretKey(suiPrivateKeyAsBuffer); 258 | const suiJsonProvider = new JsonRpcProvider(this.connection); 259 | return new RawSigner(keyPair, suiJsonProvider); 260 | } 261 | 262 | public async getGasPrice () { 263 | const suiJsonProvider = new JsonRpcProvider(this.connection); 264 | const gasPrice = await suiJsonProvider.getReferenceGasPrice(); 265 | return gasPrice; 266 | } 267 | 268 | protected getAddressFromPrivateKey(privateKey: string): string { 269 | return getSuiAddressFromPrivateKey(privateKey); 270 | } 271 | 272 | protected isValidNativeTokenAddress(token: string): boolean { 273 | return SUI_HEX_ADDRESS_REGEX.test(token) 274 | } 275 | 276 | public async getBlockHeight(): Promise { 277 | const suiJsonProvider = new JsonRpcProvider(this.connection); 278 | const sequenceNumber = await suiJsonProvider.getLatestCheckpointSequenceNumber(); 279 | return Number(sequenceNumber); 280 | } 281 | 282 | public async acquire(address?: string, acquireTimeout?: number) { 283 | // We'll pick the first one only 284 | const wallets = Object.values(this.wallets).filter( 285 | ({ privateKey }) => privateKey !== undefined, 286 | ); 287 | if (wallets.length === 0) { 288 | throw new Error("No signer wallet found for Sui."); 289 | } 290 | 291 | const wallet = wallets[0]; 292 | const privateKey = wallet.privateKey; 293 | 294 | return { 295 | address: wallet.address, 296 | // We already checked that the private key is defined above 297 | rawWallet: await this.getRawWallet(privateKey!), 298 | }; 299 | } 300 | 301 | public async release(address: string): Promise {} 302 | } 303 | -------------------------------------------------------------------------------- /src/wallets/sui/sui.config.ts: -------------------------------------------------------------------------------- 1 | import { DEVNET } from ".."; 2 | 3 | export const SUI = "sui"; 4 | 5 | const SUI_TESTNET = "testnet"; 6 | 7 | export const SUI_MAINNET = "mainnet"; 8 | 9 | export type SuiDefaultConfig = { 10 | nodeUrl: string; 11 | }; 12 | 13 | export const SUI_NETWORKS = { 14 | [DEVNET]: 1, 15 | [SUI_TESTNET]: 2, 16 | [SUI_MAINNET]: 3, 17 | }; 18 | 19 | export type SuiDefaultConfigs = Record; 20 | 21 | const SUI_DEFAULT_CONFIGS: SuiDefaultConfigs = { 22 | [DEVNET]: { 23 | nodeUrl: "http://localhost:8545", // ?? 24 | }, 25 | [SUI_TESTNET]: { 26 | nodeUrl: "https://fullnode.testnet.sui.io", 27 | }, 28 | [SUI_MAINNET]: { 29 | nodeUrl: "https://sui-mainnet-rpc.allthatnode.com", 30 | }, 31 | }; 32 | 33 | export const SUI_NATIVE_COIN_MODULE = "0x2::sui::SUI"; 34 | 35 | export const SUI_KNOWN_TOKENS = { 36 | [SUI_MAINNET]: { 37 | "WETH": "0xaf8cd5edc19c4512f4259f0bee101a40d41ebed738ade5874359610ef8eeced5::coin::COIN", 38 | "USDC": "0x5d4b302506645c37ff133b98c4b50a5ae14841659738d6d733d59d0d217a93bf::coin::COIN", 39 | "USDT": "0xc060006111016b8a020ad5b33834984a437aaa7d3c74c18e09a95d48aceab08c::coin::COIN", 40 | "WBTC": "0x027792d9fed7f9844eb4839566001bb6f6cb4804f66aa2da6fe1ee242d896881::coin::COIN", 41 | "WBNB": "0xb848cce11ef3a8f62eccea6eb5b35a12c4c2b1ee1af7755d02d7bd6218e8226f::coin::COIN", 42 | "WMATIC": "0xdbe380b13a6d0f5cdedd58de8f04625263f113b3f9db32b3e1983f49e2841676::coin::COIN", 43 | "WAVAX": "0x1e8b532cca6569cab9f9b9ebc73f8c13885012ade714729aa3b450e0339ac766::coin::COIN", 44 | "WFTM": "0x6081300950a4f1e2081580e919c210436a1bed49080502834950d31ee55a2396::coin::COIN", 45 | "WCELO": "0xa198f3be41cda8c07b3bf3fee02263526e535d682499806979a111e88a5a8d0f::coin::COIN", 46 | "WGLMR": "0x66f87084e49c38f76502d17f87d17f943f183bb94117561eb573e075fdc5ff75::coin::COIN", 47 | "SUI": "0x2::sui::SUI", 48 | }, 49 | [SUI_TESTNET]: {}, 50 | }; 51 | export const SUI_CHAIN_CONFIG = { 52 | chainName: SUI, 53 | nativeCurrencySymbol: "SUI", 54 | knownTokens: SUI_KNOWN_TOKENS, 55 | defaultConfigs: SUI_DEFAULT_CONFIGS, 56 | networks: SUI_NETWORKS, 57 | defaultNetwork: SUI_TESTNET, 58 | }; 59 | -------------------------------------------------------------------------------- /src/wallets/wallet-pool.ts: -------------------------------------------------------------------------------- 1 | import { printError } from "../utils"; 2 | import { Resource } from "./resource"; 3 | 4 | export interface WalletPool { 5 | blockAndAcquire(blockTimeout: number, resourceId?: string): Promise; 6 | release(wallet: string): Promise; 7 | discardWalletFromPool(wallet: string): void; 8 | addWalletBackToPoolIfRequired(wallet: string): void; 9 | } 10 | 11 | type ResourcePool = Record; 12 | 13 | export class LocalWalletPool implements WalletPool { 14 | private resources: ResourcePool = {}; 15 | 16 | constructor(walletAddresses: string[]) { 17 | for (const address of walletAddresses) { 18 | this.resources[address] = new Resource(address) 19 | } 20 | } 21 | 22 | private async acquire(resourceId?: string): Promise { 23 | const resource = resourceId 24 | ? this.resources[resourceId] 25 | : Object.values(this.resources).find( 26 | resource => resource.isAvailable(), 27 | ); 28 | 29 | if (!resource || !resource?.isAvailable()) 30 | throw new Error("Resource not available"); 31 | 32 | resource.lock(); 33 | 34 | return resource.getId(); 35 | } 36 | 37 | public discardWalletFromPool(resourceId: string): void { 38 | if (resourceId in this.resources) { 39 | this.resources[resourceId].discard(); 40 | } else { 41 | throw new Error(`Resource ${resourceId} not available for discarding`); 42 | } 43 | } 44 | 45 | public addWalletBackToPoolIfRequired(resourceId: string): void { 46 | if (resourceId in this.resources && this.resources[resourceId].isDiscarded()) { 47 | this.resources[resourceId].retain(); 48 | } 49 | } 50 | 51 | /** 52 | * @param blockTimeout Milliseconds until the operation is timed out. 53 | */ 54 | public blockAndAcquire(blockTimeout: number, resourceId?: string): Promise { 55 | return new Promise((resolve, reject) => { 56 | // `process.hrtime` provides the closest we can get to a monotonically increasing clock. 57 | const timeoutTimestamp = process.hrtime.bigint() + (BigInt(blockTimeout) * 10n ** 6n); 58 | // We create an error here to get the stack trace up until this point and preserve it into the asynchronous events. 59 | const errorWithCtx = new Error(); 60 | 61 | const acquire = async () => { 62 | try { 63 | const walletAddress = await this.acquire(resourceId); 64 | resolve(walletAddress); 65 | } catch (error) { 66 | if (process.hrtime.bigint() < timeoutTimestamp) { 67 | setTimeout(acquire, 5); 68 | } else { 69 | errorWithCtx.message = `Timed out waiting for resource. Wrapped error: ${printError(error)}`; 70 | reject(errorWithCtx); 71 | } 72 | } 73 | } 74 | 75 | acquire(); 76 | }); 77 | } 78 | 79 | public async release(walletAddress: string): Promise { 80 | const resource = this.resources[walletAddress]; 81 | if (!resource) throw new Error(`Resource "${walletAddress}" not found`); 82 | resource.unlock(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /test/balances/sui.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from '@jest/globals'; 2 | import { formatFixed } from '@ethersproject/bignumber'; 3 | 4 | describe("Test sui balance util", () => { 5 | test.each([ 6 | ["100000000", 6, "100.0"], // 100 usdc (usdc has 6 decimals) 7 | ["1000000000", 9, "1.0"], // 1 sui (sui has 9 decimals) 8 | ["10000000000", 9, "10.0"], // 10 sui 9 | ["99990000", 6, "99.99"], // 99.99 usdc 10 | ["123456789", 6, "123.456789"], // full precision usdc 11 | ["12345678901234", 6, "12345678.901234"], // big number with full precision 12 | ["1", 6, "0.000001"], // minimal precision for usdc 13 | ["1", 9, "0.000000001"], // minimal precision for sui 14 | ["-1", 9, "-0.000000001"], // negative minimal precision for sui 15 | ["-9950000", 6, "-9.95"], // negative usdc 16 | ["-1", 6, "-0.000001"], // negative minimal precision for usdc 17 | [(2n ** 64n - 1n).toString(), 8, "184467440737.09551615"], // max uint64 18 | ])("Test format units", (rawUnits: string, decimals: number, expectedFormattedValue: string) => { 19 | const result = formatFixed(rawUnits, decimals); 20 | expect(result).toBe(expectedFormattedValue); 21 | }); 22 | }); -------------------------------------------------------------------------------- /test/integration/wm-rebalancing.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "@jest/globals"; 2 | import { WalletManager, WalletRebalancingConfig } from "../../src/wallet-manager"; 3 | import { ChainName, DEVNET } from "../../src/wallets"; 4 | import { 5 | WalletExecuteOptions, 6 | WithWalletExecutor, 7 | } from "../../src/chain-wallet-manager"; 8 | import { ETHEREUM } from "../../src/wallets/evm/ethereum.config"; 9 | import { 10 | ETH_ADDR, 11 | ETH_ADDR_2, 12 | checkIfWalletIsReady, 13 | getWallets, 14 | } from "../utilities/wallet"; 15 | 16 | const WAIT_TO_ACQUIRE_TIMEOUT = 10; 17 | 18 | const rebalanceConfig: WalletRebalancingConfig = { 19 | enabled: true, 20 | strategy: "pourOver", 21 | interval: 10000, 22 | minBalanceThreshold: 0.1, 23 | maxGasPrice: 10000, 24 | gasLimit: 1000000, 25 | }; 26 | let walletManager: WalletManager; 27 | 28 | describe("wallet-manager", () => { 29 | afterEach(() => { 30 | walletManager.stop(); 31 | }); 32 | 33 | test("should not discard the wallets if native balance is above threshold", async () => { 34 | /** 35 | * Expected behaviour: Wallets are configured to have balances of 0.2 ETH and 10 ETH 36 | * and minBalanceThreshold is set to 0.1 ETH, so both wallets should be ready to be acquired 37 | */ 38 | await givenAWalletManager(rebalanceConfig); 39 | await whenWalletCalled( 40 | ETHEREUM, 41 | async () => { 42 | // no op 43 | }, 44 | { waitToAcquireTimeout: WAIT_TO_ACQUIRE_TIMEOUT }, 45 | ); 46 | const activeWalletsReadyToBeAquired = Object.values( 47 | walletManager.getChainBalances(ETHEREUM), 48 | ).length; 49 | 50 | expect(activeWalletsReadyToBeAquired).toEqual(2); 51 | }); 52 | 53 | test("should discard wallet if wallet native balance is below threshold", async () => { 54 | /** 55 | * Expected behaviour: Wallets are configured to have balances of 0.2 ETH and 10 ETH 56 | * and minBalanceThreshold is set to 10 ETH 57 | * so, ETH_ADDR is expected to be rejected as balance is below threshold 58 | * and ETH_ADDR_2 is expected to be fulfilled as balance is above threshold 59 | */ 60 | const minBalanceThreshold = 10; 61 | 62 | await givenAWalletManager({ ...rebalanceConfig, minBalanceThreshold }); 63 | 64 | const settledWithWalletCalls = await Promise.allSettled([ 65 | whenWalletCalled( 66 | ETHEREUM, 67 | async () => { 68 | // no op 69 | }, 70 | { waitToAcquireTimeout: WAIT_TO_ACQUIRE_TIMEOUT, address: ETH_ADDR }, 71 | ), 72 | whenWalletCalled( 73 | ETHEREUM, 74 | async () => { 75 | // no op 76 | }, 77 | { waitToAcquireTimeout: WAIT_TO_ACQUIRE_TIMEOUT, address: ETH_ADDR_2 }, 78 | ), 79 | ]); 80 | 81 | expect(settledWithWalletCalls[0].status).toEqual("rejected"); 82 | expect(settledWithWalletCalls[1].status).toEqual("fulfilled"); 83 | }); 84 | }); 85 | 86 | const givenAWalletManager = async ( 87 | rebalanceConfig: WalletRebalancingConfig = { enabled: false }, 88 | ) => { 89 | const cfg = { 90 | [ETHEREUM]: { 91 | rebalance: rebalanceConfig, 92 | network: DEVNET, 93 | wallets: getWallets(), 94 | }, 95 | }; 96 | 97 | walletManager = new WalletManager(cfg); 98 | await checkIfWalletIsReady(walletManager); 99 | }; 100 | 101 | const whenWalletCalled = async ( 102 | chain: ChainName, 103 | executor: WithWalletExecutor, 104 | walletOpts?: WalletExecuteOptions, 105 | ) => walletManager.withWallet(chain, executor, walletOpts); 106 | -------------------------------------------------------------------------------- /test/utilities/common.ts: -------------------------------------------------------------------------------- 1 | // Warning: In case of timeout, we are not cancelling the promise, we are just ignoring it. 2 | // Extend the timeout utility to accept cancellation callback 3 | export const timeout = (promise: Promise, timeoutMs: number) => { 4 | let timer: NodeJS.Timeout; 5 | const timeoutPromise = new Promise( 6 | (_, reject) => (timer = setTimeout(() => reject(new Error("timeout")), timeoutMs)) 7 | ); 8 | return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer)); 9 | }; 10 | 11 | export const wait = (ms: number) => 12 | new Promise((resolve, reject) => setTimeout(resolve, ms)); 13 | -------------------------------------------------------------------------------- /test/utilities/wallet.ts: -------------------------------------------------------------------------------- 1 | import { WalletManager } from "../../src/wallet-manager"; 2 | import { timeout } from "./common"; 3 | 4 | export const ETH_ADDR = "0xFa6E597ca1c7E72838c850d1268dDf618D444712"; 5 | export const ETH_ADDR_2 = "0x0EaC31cB932229D0Dcc628f89894012b7827481c"; 6 | 7 | export const checkIfWalletIsReady = async ( 8 | walletManager: WalletManager, 9 | ): Promise => { 10 | const isWalletReady = new Promise(resolve => { 11 | walletManager.on("balances", () => { 12 | resolve(true); 13 | }); 14 | }); 15 | 16 | await timeout(isWalletReady, 5000); 17 | }; 18 | 19 | export const getWallets = () => { 20 | return [ 21 | { 22 | address: ETH_ADDR, 23 | privateKey: 24 | "0xf9fdbcbcdb4c7c72642be9fe7c09ad5869a961a8ae3c3374841cb6ead5fd34b1", 25 | }, 26 | { 27 | address: ETH_ADDR_2, 28 | privateKey: 29 | "0xe94000d730b9655850afc8e39facb7058678f11e765075d4806d27ed619f258c", 30 | }, 31 | ]; 32 | }; 33 | -------------------------------------------------------------------------------- /test/wallet-manager.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "@jest/globals"; 2 | import { WalletManager, WalletRebalancingConfig } from "../src/wallet-manager"; 3 | import { ChainName, DEVNET } from "../src/wallets"; 4 | import { 5 | WalletExecuteOptions, 6 | WithWalletExecutor, 7 | } from "../src/chain-wallet-manager"; 8 | import { ETHEREUM } from "../src/wallets/evm/ethereum.config"; 9 | import { 10 | ETH_ADDR_2, 11 | checkIfWalletIsReady, 12 | getWallets, 13 | } from "./utilities/wallet"; 14 | import { wait } from "./utilities/common"; 15 | 16 | const WAIT_TO_ACQUIRE_TIMEOUT = 10; 17 | 18 | let walletManager: WalletManager; 19 | 20 | describe("wallet-manager", () => { 21 | afterEach(() => { 22 | walletManager.stop(); 23 | }); 24 | 25 | test("should execute sequentially per address", async () => { 26 | const markers: number[] = []; 27 | await givenAWalletManager(); 28 | 29 | await Promise.all([ 30 | whenWalletCalled(ETHEREUM, async () => { 31 | markers.push(0); 32 | }), 33 | whenWalletCalled(ETHEREUM, async () => { 34 | markers.push(1); 35 | }), 36 | ]); 37 | 38 | expect(markers).toEqual([0, 1]); 39 | }); 40 | 41 | test("should continue when something goes wrong", async () => { 42 | const markers: number[] = []; 43 | 44 | await givenAWalletManager(); 45 | 46 | await Promise.allSettled([ 47 | whenWalletCalled(ETHEREUM, async () => { 48 | throw new Error("oops"); 49 | }), 50 | whenWalletCalled( 51 | ETHEREUM, 52 | async () => { 53 | markers.push(1); 54 | }, 55 | { waitToAcquireTimeout: 50 }, 56 | ), 57 | ]); 58 | 59 | expect(markers).toEqual([1]); 60 | }); 61 | 62 | test("should fail if timeout is reached", async () => { 63 | /** 64 | * Expected behaviour: If one wallet A is already aquired and locked 65 | * Then, anyone else trying to acquire the same wallet A should fail 66 | */ 67 | const markers: number[] = []; 68 | 69 | await givenAWalletManager(); 70 | 71 | const settledWithWalletCalls = await Promise.allSettled([ 72 | whenWalletCalled( 73 | ETHEREUM, 74 | async () => { 75 | await wait(WAIT_TO_ACQUIRE_TIMEOUT * 2); 76 | markers.push(0); 77 | }, 78 | { waitToAcquireTimeout: WAIT_TO_ACQUIRE_TIMEOUT, address: ETH_ADDR_2 }, 79 | ), 80 | whenWalletCalled( 81 | ETHEREUM, 82 | async () => { 83 | markers.push(1); 84 | }, 85 | { waitToAcquireTimeout: WAIT_TO_ACQUIRE_TIMEOUT, address: ETH_ADDR_2 }, 86 | ), 87 | ]); 88 | 89 | expect(markers).toEqual([0]); 90 | expect(settledWithWalletCalls[1].status).toBe("rejected"); 91 | }, 20_000); 92 | }); 93 | 94 | const givenAWalletManager = async ( 95 | rebalanceConfig: WalletRebalancingConfig = { enabled: false }, 96 | ) => { 97 | const cfg = { 98 | [ETHEREUM]: { 99 | rebalance: rebalanceConfig, 100 | network: DEVNET, 101 | wallets: getWallets(), 102 | }, 103 | }; 104 | 105 | walletManager = new WalletManager(cfg); 106 | await checkIfWalletIsReady(walletManager); 107 | }; 108 | 109 | const whenWalletCalled = async ( 110 | chain: ChainName, 111 | executor: WithWalletExecutor, 112 | walletOpts?: WalletExecuteOptions, 113 | ) => walletManager.withWallet(chain, executor, walletOpts); 114 | -------------------------------------------------------------------------------- /test/wallets/sui/sui.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, jest, test } from '@jest/globals'; 2 | 3 | import { SuiWalletOptions } from "../../../src/wallets/sui"; 4 | 5 | import { createWalletToolbox, WalletConfig } from "../../../src/wallets"; 6 | 7 | import * as winston from "winston"; 8 | import { pullSuiTokenData } from "../../../src/balances/sui"; 9 | 10 | 11 | 12 | jest.mock("../../../src/balances/sui", () => ({ 13 | pullSuiTokenBalances: jest.fn(() => [ 14 | { 15 | coinType: "0x5d4b302506645c37ff133b98c4b50a5ae14841659738d6d733d59d0d217a93bf::coin::COIN", 16 | totalBalance: "100000000" 17 | }, 18 | { 19 | coinType: "0xfded065d61215141ea7afac2e5c1a7b23514a91cbb2cfd86a38ed97e2f7871ac::PEPE::PEPE", 20 | totalBalance: "500000000" 21 | } 22 | ]), 23 | formatUnits: jest.fn((units: string, decimals: number) => { 24 | return String(+units / 10 ** decimals); 25 | }), 26 | pullSuiTokenData: jest.fn((conn, address) => { 27 | switch(address) { 28 | case "0x5d4b302506645c37ff133b98c4b50a5ae14841659738d6d733d59d0d217a93bf::coin::COIN": 29 | return { 30 | symbol: "USDC", 31 | decimals: 6, 32 | address: "0x5d4b302506645c37ff133b98c4b50a5ae14841659738d6d733d59d0d217a93bf::coin::COIN" 33 | }; 34 | case "0xfded065d61215141ea7afac2e5c1a7b23514a91cbb2cfd86a38ed97e2f7871ac::PEPE::PEPE": 35 | return { 36 | symbol: "PEPE", 37 | decimals: 9, 38 | address: "0xfded065d61215141ea7afac2e5c1a7b23514a91cbb2cfd86a38ed97e2f7871ac::PEPE::PEPE" 39 | }; 40 | case "0xd01cebc27fe22868df462f33603646549e13a4b279f5e900b99b9843680445e1::SHIBA::SHIBA": 41 | return { 42 | symbol: "SHIBA", 43 | decimals: 9, 44 | address: "0xd01cebc27fe22868df462f33603646549e13a4b279f5e900b99b9843680445e1::SHIBA::SHIBA" 45 | }; 46 | } 47 | }) 48 | })); 49 | 50 | describe("sui wallet tests", () => { 51 | let logger: winston.Logger; 52 | let options: SuiWalletOptions; 53 | beforeAll(() => { 54 | const _logger = { 55 | debug: jest.fn(), 56 | }; 57 | 58 | jest.mock("winston", () => ({ 59 | createLogger: jest.fn().mockReturnValue(_logger), 60 | transports: { 61 | Console: jest.fn() 62 | } 63 | })); 64 | 65 | options = { 66 | logger, 67 | nodeUrl: "", 68 | failOnInvalidTokens: false 69 | }; 70 | }) 71 | test("Pull token balances", async () => { 72 | const wallets: WalletConfig[] = [ 73 | { 74 | address: "0x0000000000000000000000000000000000000000000000000000000000000000", 75 | tokens: ["USDC", "PEPE", "SHIBA", "0x5d1f47ea69bb0de31c313d7acf89b890dbb8991ea8e03c6c355171f84bb1ba4a::turbos::TURBOS"], 76 | }, 77 | ]; 78 | const wallet = createWalletToolbox( 79 | "mainnet", 80 | "sui", 81 | wallets, 82 | options 83 | ); 84 | 85 | await wallet.pullBalances(); 86 | expect(pullSuiTokenData).toHaveBeenCalledTimes(2); // KnownToken (USDC) and Valid Address (TURBOS) 87 | 88 | const tokenBalances = await wallet.pullTokenBalances( 89 | "0x0000000000000000000000000000000000000000000000000000000000000000", 90 | ["0x5d4b302506645c37ff133b98c4b50a5ae14841659738d6d733d59d0d217a93bf::coin::COIN"] 91 | ); 92 | 93 | expect(tokenBalances).toStrictEqual([ 94 | { 95 | "tokenAddress": "0x5d4b302506645c37ff133b98c4b50a5ae14841659738d6d733d59d0d217a93bf::coin::COIN", 96 | "address": "0x0000000000000000000000000000000000000000000000000000000000000000", 97 | "isNative": false, 98 | "rawBalance": "100000000", 99 | "formattedBalance": "100.0", 100 | "symbol": "USDC", 101 | } 102 | ]); 103 | }); 104 | }) -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "lib", 4 | "target": "esnext", 5 | "module": "CommonJS", 6 | "moduleResolution": "node", 7 | "lib": [ 8 | "es2022" 9 | ], 10 | "declaration": true, 11 | "skipLibCheck": true, 12 | "allowJs": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "isolatedModules": true, 17 | "esModuleInterop": true, 18 | "resolveJsonModule": true, 19 | "downlevelIteration": true, 20 | "sourceMap": true 21 | }, 22 | "include": [ 23 | "src" 24 | ], 25 | "exclude": [ 26 | "node_modules", 27 | "lib/**/*", 28 | "src/**/*.test.ts", 29 | "test" 30 | ] 31 | } 32 | --------------------------------------------------------------------------------