├── .dockerignore ├── .env.example ├── .env.marketmakingexample ├── .eslintrc.json ├── .github └── workflows │ ├── deploy-on-sdk-update.yml │ ├── mainnet-beta.yml │ └── master.yml ├── .gitignore ├── .gitmodules ├── .husky ├── post-merge └── pre-commit ├── .prettierignore ├── .prettierrc.js ├── Dockerfile ├── LICENSE ├── README.md ├── build_all.sh ├── esbuild.config.js ├── example.config.yaml ├── jit-maker-config.yaml ├── jitMaker.config.yaml ├── lite.config.yaml ├── package.json ├── quickstart.md ├── src ├── bots │ ├── common │ │ ├── processUtils.ts │ │ ├── threads │ │ │ ├── txSender.ts │ │ │ ├── txThread.ts │ │ │ └── types.ts │ │ ├── txLogParse.ts │ │ └── txThreaded.ts │ ├── filler.ts │ ├── fillerLite.ts │ ├── floatingMaker.ts │ ├── fundingRateUpdater.ts │ ├── ifRevenueSettler.ts │ ├── jitMaker.ts │ ├── liquidator.ts │ ├── makerBidAskTwapCrank.ts │ ├── pythCranker.ts │ ├── pythLazerCranker.ts │ ├── spotFiller.ts │ ├── switchboardCranker.ts │ ├── trigger.ts │ ├── uncrossArbBot.ts │ ├── userIdleFlipper.ts │ ├── userLpSettler.ts │ └── userPnlSettler.ts ├── bundleSender.ts ├── config.ts ├── driftStateWatcher.ts ├── error.ts ├── experimental-bots │ ├── entrypoint.ts │ ├── filler-common │ │ ├── dlobBuilder.ts │ │ ├── orderSubscriberFiltered.ts │ │ ├── swiftOrderSubscriber.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── filler │ │ └── fillerMultithreaded.ts │ ├── spotFiller │ │ └── spotFillerMultithreaded.ts │ └── swift │ │ ├── makerExample.ts │ │ ├── placerExample.ts │ │ └── takerExample.ts ├── index.ts ├── logger.ts ├── makerSelection.ts ├── metrics.ts ├── pythLazerSubscriber.ts ├── pythPriceFeedSubscriber.ts ├── types.test.ts ├── types.ts ├── utils.test.ts ├── utils.ts └── webhook.ts ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | */**/node_modules 2 | */**/lib 3 | */**/dist 4 | .git/ 5 | .husky/ -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | KEEPER_PRIVATE_KEY=246,79,83,235,227,63,148,45,236,118,164,3,0,99,197,152,7,161,4,247,132,15,56,14,71,41,175,39,108,68,32,37,233,229,35,89,133,166,36,228,162,196,142,255,237,118,168,210,61,163,132,32,11,89,22,89,116,119,126,116,203,65,29,77 2 | ENDPOINT=https://api.devnet.solana.com 3 | ENV=devnet -------------------------------------------------------------------------------- /.env.marketmakingexample: -------------------------------------------------------------------------------- 1 | # create with solana-keygen pubkey /path/to/solana_private_key.json 2 | KEEPER_PRIVATE_KEY=/path/to/solana_private_key.json 3 | 4 | 5 | ENDPOINT=https://api.mainnet-beta.solana.com 6 | DRIFT_ENV=mainnet-beta 7 | DEBUG=false -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { 5 | "browser": true 6 | }, 7 | "ignorePatterns": ["**/lib", "**/node_modules", "migrations", "**/scratch", "**/drift-common"], 8 | "plugins": [], 9 | "extends": [ 10 | "eslint:recommended", 11 | "plugin:@typescript-eslint/eslint-recommended", 12 | "plugin:@typescript-eslint/recommended" 13 | ], 14 | "rules": { 15 | "@typescript-eslint/explicit-function-return-type": "off", 16 | "@typescript-eslint/ban-ts-ignore": "off", 17 | "@typescript-eslint/ban-ts-comment": "off", 18 | "@typescript-eslint/no-explicit-any": "off", 19 | "@typescript-eslint/no-unused-vars": [ 20 | 2, 21 | { 22 | "argsIgnorePattern": "^_", 23 | "varsIgnorePattern": "^_" 24 | } 25 | ], 26 | "@typescript-eslint/no-var-requires": 0, 27 | "@typescript-eslint/no-empty-function": 0, 28 | "no-mixed-spaces-and-tabs": [2, "smart-tabs"], 29 | "no-prototype-builtins": "off", 30 | "semi": 2 31 | }, 32 | "settings": { 33 | "react": { 34 | "version": "detect" 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/deploy-on-sdk-update.yml: -------------------------------------------------------------------------------- 1 | name: Deploy on sdk update 2 | on: 3 | repository_dispatch: 4 | types: [jit-sdk-update] 5 | 6 | jobs: 7 | update-sdk: 8 | runs-on: ubicloud 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v2 12 | with: 13 | submodules: 'recursive' 14 | persist-credentials: false 15 | 16 | - name: Setup node 17 | uses: actions/setup-node@v2 18 | with: 19 | node-version: "20.18.x" 20 | registry-url: "https://registry.npmjs.org" 21 | 22 | - name: Install dependencies 23 | run: | 24 | cd drift-common/protocol/sdk 25 | yarn install 26 | yarn build 27 | cd ../../common-ts 28 | yarn install 29 | yarn build 30 | cd ../../ 31 | yarn install 32 | yarn build 33 | 34 | - name: Add specific version of sdk 35 | run: yarn add @drift-labs/sdk@${{ github.event.client_payload.sdk-version }} 36 | 37 | - name: Add specific version of jit sdk 38 | run: yarn add @drift-labs/jit-proxy@${{ github.event.client_payload.jit-version }} 39 | 40 | - name: Build after new dependency 41 | run: yarn run build 42 | 43 | - name: Commit and push changes 44 | run: | 45 | git config user.name "GitHub Actions" 46 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 47 | git add -A 48 | git commit --allow-empty -m "Bumping sdk and jit dependencies to ${{ github.event.client_payload.sdk-version }} and ${{ github.event.client_payload.jit-version }}" 49 | 50 | - name: Push changes 51 | uses: ad-m/github-push-action@master 52 | with: 53 | github_token: ${{ secrets.GH_PAT }} 54 | -------------------------------------------------------------------------------- /.github/workflows/mainnet-beta.yml: -------------------------------------------------------------------------------- 1 | name: KeeperBots Build Image And Deploy 2 | 3 | on: 4 | push: 5 | branches: [mainnet-beta] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubicloud 11 | steps: 12 | - name: Checkout Code 13 | uses: actions/checkout@v3 14 | with: 15 | submodules: 'recursive' 16 | 17 | - name: Configure AWS credentials 18 | uses: aws-actions/configure-aws-credentials@master 19 | with: 20 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_PROD }} 21 | aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY_PROD }} 22 | aws-region: ${{ secrets.EKS_PROD_REGION }} 23 | 24 | - name: Log in to Amazon ECR 25 | id: login-ecr 26 | uses: aws-actions/amazon-ecr-login@v2 27 | 28 | - name: Build and push 29 | uses: docker/build-push-action@v6 30 | env: 31 | ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} 32 | ECR_REPOSITORY: keeper-bots-v2 33 | IMAGE_TAG: ${{ github.sha }} 34 | BRANCH_NAME: ${{ github.ref_name }} 35 | with: 36 | context: . 37 | push: true 38 | tags: | 39 | ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}-${{ env.BRANCH_NAME }}-amd64 40 | ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:latest-${{ env.BRANCH_NAME }}-amd64 41 | 42 | deploy: 43 | runs-on: ubicloud 44 | needs: [build] 45 | steps: 46 | - name: Configure AWS credentials 47 | uses: aws-actions/configure-aws-credentials@master 48 | with: 49 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_PROD }} 50 | aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY_PROD }} 51 | aws-region: ${{ secrets.EKS_PROD_REGION }} 52 | 53 | - name: Install kubectl 54 | uses: azure/setup-kubectl@v3 55 | with: 56 | version: 'v1.30.0' 57 | 58 | - name: Configure AWS EKS Credentials 59 | run: aws eks update-kubeconfig --name ${{ secrets.EKS_PROD_CLUSTER_NAME }} --region ${{ secrets.EKS_PROD_REGION }} --role-arn ${{ secrets.EKS_PROD_DEPLOY_ROLE }} 60 | 61 | - name: Restart deployment 62 | env: 63 | BRANCH_NAME: ${{ github.ref_name }} 64 | run: | 65 | kubectl get deployments -n $BRANCH_NAME -o name | grep filler | xargs -I {} kubectl rollout restart {} -n $BRANCH_NAME 66 | 67 | kubectl rollout restart -n $BRANCH_NAME deployment/funding-rate-updater-bot 68 | kubectl rollout restart -n $BRANCH_NAME deployment/liquidator-global-bot 69 | kubectl rollout restart -n $BRANCH_NAME deployment/liquidator-global-2-bot 70 | kubectl rollout restart -n $BRANCH_NAME deployment/user-lp-settler-bot 71 | kubectl rollout restart -n $BRANCH_NAME deployment/user-pnl-settler-bot 72 | kubectl rollout restart -n $BRANCH_NAME deployment/pyth-cranker-bot 73 | kubectl rollout restart -n $BRANCH_NAME deployment/pyth-lazer-cranker-bot 74 | kubectl rollout restart -n $BRANCH_NAME deployment/switchboard-cranker-bot 75 | kubectl rollout restart -n $BRANCH_NAME deployment/swift-placer-bot 76 | kubectl rollout restart -n $BRANCH_NAME deployment/switchboard-cranker-exponent-bot 77 | kubectl rollout restart -n $BRANCH_NAME deployment/switchboard-cranker-isolated-pool-bot 78 | 79 | -------------------------------------------------------------------------------- /.github/workflows/master.yml: -------------------------------------------------------------------------------- 1 | name: KeeperBots Build Image And Deploy 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubicloud 11 | steps: 12 | - name: Checkout Code 13 | uses: actions/checkout@v3 14 | with: 15 | submodules: recursive 16 | 17 | - name: Configure AWS credentials 18 | uses: aws-actions/configure-aws-credentials@master 19 | with: 20 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_NON_PROD }} 21 | aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY_NON_PROD }} 22 | aws-region: ${{ secrets.EKS_NON_PROD_REGION }} 23 | 24 | - name: Log in to Amazon ECR 25 | id: login-ecr 26 | uses: aws-actions/amazon-ecr-login@v2 27 | 28 | - name: Build and push 29 | uses: docker/build-push-action@v6 30 | env: 31 | ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} 32 | ECR_REPOSITORY: keeper-bots-v2 33 | IMAGE_TAG: ${{ github.sha }} 34 | BRANCH_NAME: ${{ github.ref_name }} 35 | with: 36 | context: . 37 | push: true 38 | tags: | 39 | ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}-${{ env.BRANCH_NAME }}-amd64 40 | ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:latest-${{ env.BRANCH_NAME }}-amd64 41 | 42 | deploy: 43 | runs-on: ubicloud 44 | needs: [build] 45 | steps: 46 | - name: Configure AWS credentials 47 | uses: aws-actions/configure-aws-credentials@master 48 | with: 49 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_NON_PROD }} 50 | aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY_NON_PROD }} 51 | aws-region: ${{ secrets.EKS_NON_PROD_REGION }} 52 | 53 | - name: Install kubectl 54 | uses: azure/setup-kubectl@v3 55 | with: 56 | version: 'v1.30.0' 57 | 58 | - name: Configure AWS EKS Credentials 59 | run: aws eks update-kubeconfig --name ${{ secrets.EKS_NON_PROD_CLUSTER_NAME }} --region ${{ secrets.EKS_NON_PROD_REGION }} --role-arn ${{ secrets.EKS_NON_PROD_DEPLOY_ROLE }} 60 | 61 | - name: Restart deployment 62 | env: 63 | BRANCH_NAME: ${{ github.ref_name }} 64 | run: | 65 | kubectl get deployments -n $BRANCH_NAME -o name | grep filler | xargs -I {} kubectl rollout restart {} -n $BRANCH_NAME 66 | kubectl rollout restart -n $BRANCH_NAME deployment/liquidator-bot 67 | kubectl rollout restart -n $BRANCH_NAME deployment/pyth-cranker-bot 68 | kubectl rollout restart -n $BRANCH_NAME deployment/pyth-lazer-cranker-bot 69 | kubectl rollout restart -n $BRANCH_NAME deployment/swift-taker-example-bot 70 | kubectl rollout restart -n $BRANCH_NAME deployment/swift-maker-example-bot 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/osx,node,linux,windows 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # npm tree 8 | package-lock.json 9 | 10 | # compiled files 11 | built/ 12 | 13 | # moved files 14 | src/id.json 15 | src/package.json 16 | 17 | # temporary files which can be created if a process still has a handle open of a deleted file 18 | .fuse_hidden* 19 | 20 | # KDE directory preferences 21 | .directory 22 | 23 | # Linux trash folder which might appear on any partition or disk 24 | .Trash-* 25 | 26 | # .nfs files are created when an open file is removed but is still being accessed 27 | .nfs* 28 | 29 | ### Node ### 30 | # Logs 31 | logs 32 | *.log 33 | npm-debug.log* 34 | yarn-debug.log* 35 | yarn-error.log* 36 | 37 | # Runtime data 38 | pids 39 | *.pid 40 | *.seed 41 | *.pid.lock 42 | 43 | # Directory for instrumented libs generated by jscoverage/JSCover 44 | lib-cov 45 | 46 | # Coverage directory used by tools like istanbul 47 | coverage 48 | 49 | # nyc test coverage 50 | .nyc_output 51 | 52 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 53 | .grunt 54 | 55 | # Bower dependency directory (https://bower.io/) 56 | bower_components 57 | 58 | # node-waf configuration 59 | .lock-wscript 60 | 61 | # Compiled binary addons (http://nodejs.org/api/addons.html) 62 | build/Release 63 | 64 | # Dependency directories 65 | node_modules/ 66 | jspm_packages/ 67 | 68 | # Typescript v1 declaration files 69 | typings/ 70 | 71 | # Optional npm cache directory 72 | .npm 73 | 74 | # Optional eslint cache 75 | .eslintcache 76 | 77 | # Optional REPL history 78 | .node_repl_history 79 | 80 | # Output of 'npm pack' 81 | *.tgz 82 | 83 | # Yarn Integrity file 84 | .yarn-integrity 85 | 86 | ### OSX ### 87 | *.DS_Store 88 | .AppleDouble 89 | .LSOverride 90 | 91 | # Icon must end with two \r 92 | Icon 93 | 94 | # Thumbnails 95 | ._* 96 | 97 | # Files that might appear in the root of a volume 98 | .DocumentRevisions-V100 99 | .fseventsd 100 | .Spotlight-V100 101 | .TemporaryItems 102 | .Trashes 103 | .VolumeIcon.icns 104 | .com.apple.timemachine.donotpresent 105 | 106 | # Directories potentially created on remote AFP share 107 | .AppleDB 108 | .AppleDesktop 109 | Network Trash Folder 110 | Temporary Items 111 | .apdisk 112 | 113 | ### Windows ### 114 | # Windows thumbnail cache files 115 | Thumbs.db 116 | ehthumbs.db 117 | ehthumbs_vista.db 118 | 119 | # Folder config file 120 | Desktop.ini 121 | 122 | # Recycle Bin used on file shares 123 | $RECYCLE.BIN/ 124 | 125 | # Windows Installer files 126 | *.cab 127 | *.msi 128 | *.msm 129 | *.msp 130 | 131 | # Windows shortcuts 132 | *.lnk 133 | 134 | 135 | # End of https://www.gitignore.io/api/osx,node,linux,windows 136 | 137 | src/**.js 138 | src/**.js.map 139 | .idea 140 | 141 | .env 142 | lib 143 | 144 | scratch 145 | 146 | test.yaml 147 | test.config.yaml 148 | devnet*.config.yaml 149 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "drift-common"] 2 | path = drift-common 3 | url = https://github.com/drift-labs/drift-common.git 4 | -------------------------------------------------------------------------------- /.husky/post-merge: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | RED='\033[0;31m'; 5 | NC='\033[0m'; 6 | 7 | BRANCH=$(git rev-parse --abbrev-ref HEAD) 8 | 9 | if [[ "$BRANCH" = "master" ]] 10 | then 11 | if [[ $(grep -ciE "drift-labs/sdk.*beta.*" ./package.json) -eq 0 ]] 12 | then 13 | echo "$RED warning: on '$BRANCH' branch but not using a beta release of @drift-labs/sdk $NC" 14 | fi 15 | elif [[ "$BRANCH" = "mainnet-beta" ]] 16 | then 17 | if [[ $(grep -ciE "drift-labs/sdk.*beta.*" ./package.json) -gt 0 ]] 18 | then 19 | echo "$RED warning: on '$BRANCH' branch but using a beta release of @drift-labs/sdk$NC" 20 | fi 21 | fi 22 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | RED='\033[0;31m'; 5 | NC='\033[0m'; 6 | 7 | BRANCH=$(git rev-parse --abbrev-ref HEAD) 8 | 9 | if [[ "$BRANCH" = "master" ]] 10 | then 11 | if [[ $(grep -ciE "drift-labs/sdk.*beta.*" ./package.json) -eq 0 ]] 12 | then 13 | echo "$RED warning: on '$BRANCH' branch but not using a beta release of @drift-labs/sdk $NC" 14 | fi 15 | elif [[ "$BRANCH" = "mainnet-beta" ]] 16 | then 17 | if [[ $(grep -ciE "drift-labs/sdk.*beta.*" ./package.json) -gt 0 ]] 18 | then 19 | echo "$RED warning: on '$BRANCH' branch but using a beta release of @drift-labs/sdk$NC" 20 | fi 21 | fi 22 | 23 | yarn prettify 24 | yarn lint 25 | yarn test 26 | yarn build 27 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/node_modules/** 2 | protocol -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'es5', 4 | singleQuote: true, 5 | printWidth: 80, 6 | tabWidth: 2, 7 | useTabs: true, 8 | bracketSameLine: false, 9 | }; 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM public.ecr.aws/docker/library/node:20 AS builder 2 | RUN npm install -g husky 3 | 4 | COPY package.json yarn.lock ./ 5 | 6 | WORKDIR /app 7 | 8 | COPY . . 9 | 10 | WORKDIR /app/drift-common/protocol/sdk 11 | RUN yarn install 12 | RUN yarn run build 13 | 14 | WORKDIR /app/drift-common/common-ts 15 | RUN yarn install 16 | RUN yarn run build 17 | 18 | WORKDIR /app 19 | RUN yarn install 20 | 21 | RUN node esbuild.config.js 22 | 23 | FROM public.ecr.aws/docker/library/node:20.18.1-alpine 24 | # 'bigint-buffer' native lib for performance 25 | RUN apk add python3 make g++ --virtual .build &&\ 26 | npm install -C /lib bigint-buffer @triton-one/yellowstone-grpc@1.3.0 &&\ 27 | apk del .build &&\ 28 | rm -rf /root/.cache/ /root/.npm /usr/local/lib/node_modules 29 | COPY --from=builder /app/lib/ ./lib/ 30 | 31 | EXPOSE 9464 32 | 33 | CMD ["node", "./lib/index.js"] 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2021 Drift Labs 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |

Keeper Bots for Drift Protocol v2

5 | 6 |

7 | Docs 8 | Discord Chat 9 | License 10 |

11 |
12 | 13 | # Setting up 14 | 15 | 16 | This repo has two main branches: 17 | 18 | * `master`: bleeding edge, may be unstable, currently running on the `devnet` cluster 19 | * `mainnet-beta`: stable, currently running on the `mainnet-beta` cluster 20 | 21 | ## Setup Environment 22 | 23 | ### yaml Config file: 24 | 25 | A `.yaml` file can be used to configure the bot setup now. See `example.config.yaml` for a commented example. 26 | 27 | Then you can run the bot by loading the config file: 28 | ```shell 29 | yarn run dev --config-file=example.config.yaml 30 | ``` 31 | 32 | Here is a table defining the various fields and their usage/defaults: 33 | 34 | | Field | Type | Description | Default | 35 | | ----------------- | ------ | --- | --- | 36 | | global | object | global configs to apply to all running bots | - | 37 | | global.endpoint | string | RPC endpoint to use | - | 38 | | global.wsEndpoint | string | (optional) Websocket endpoint to use | derived from `global.endpoint` | 39 | | global.keeperPrivateKey | string | (optional) The private key to use to pay/sign transactions | `KEEPER_PRIVATE_KEY` environment variable | 40 | | global.initUser | bool | Set `true` to init a fresh userAccount | `false` | 41 | | global.websocket | bool | Set `true` to run the selected bots in websocket mode if compatible| `false` | 42 | | global.runOnce | bool | Set `true` to run only one iteration of the selected bots | `false` | 43 | | global.debug | bool | Set `true` to enable debug logging | `false` | 44 | | global.subaccounts | list | (optional) Which subaccount IDs to load | `0` | 45 | | enabledBots | list | list of bots to enable, matching key must be present under `botConfigs` | - | 46 | | botConfigs | object | configs for associated bots | - | 47 | | botConfigs. | object | config for a specific | - | 48 | 49 | 50 | ### Install dependencies 51 | 52 | Run from repo root to install all npm dependencies for this repo + submodules: 53 | ```shell 54 | git submodule update --recursive --init 55 | ./build_all.sh 56 | ``` 57 | 58 | 59 | ## Initialize User 60 | 61 | A `ClearingHouseUser` must be created before interacting with the `ClearingHouse` program. 62 | 63 | ```shell 64 | yarn run dev --init-user 65 | ``` 66 | 67 | Alternatively, you can put the private key into a browser wallet and use the UI at https://app.drift.trade to initialize the user. 68 | 69 | ## Collateral 70 | 71 | Some bots (i.e. trading, liquidator and JIT makers) require collateral in order to keep positions open, a helper function is included to help with depositing collateral. 72 | A user must be initialized first before collateral may be deposited. 73 | 74 | ```shell 75 | # deposit 10,000 USDC 76 | yarn run dev --force-deposit 10000 77 | ``` 78 | 79 | Alternatively, you can put the private key into a browser wallet and use the UI at https://app.drift.trade to deposit collateral. 80 | 81 | Free collateral is what is determines the size of borrows and perp positions that an account can have. Free collateral = total collateral - initial margin requirement. Total collateral is the value of the spot assets in your account + unrealized perp pnl. The initial margin requirement is the total weighted value of the perp positions and spot liabilities in your account. The initial margin requirement weights are determined [here](https://docs.drift.trade/cross-collateral-deposits). In simple terms, free collateral is essentially the amount of total collateral that is not being used up by borrows and existing perp positions and open orders. 82 | 83 | 84 | # Run Bots 85 | 86 | After creating your `config.yaml` file as above, run with: 87 | 88 | ```shell 89 | yarn run dev --config-file=config.yaml 90 | ``` 91 | 92 | By default, some [Prometheus](https://prometheus.io/) metrics are exposed on `localhost:9464/metrics`. 93 | 94 | # Notes on some bots 95 | 96 | ## Filler Bot 97 | 98 | Include `filler` and/or `spotFiller` under `.enabledBots` in `config.yaml`. For a lightweight version 99 | of a filler bot for perp markets, include `fillerLite` rather than `filler` in `config.yaml`. The lighter 100 | version of the filler can be run on public RPCs for testing, but is not as stable. 101 | 102 | Read the docs: https://docs.drift.trade/keepers-and-decentralised-orderbook 103 | 104 | Fills (matches) crossing orders on the exchange for a small cut of the taker fees. Fillers maintain a copy of the DLOB to look 105 | for orders that cross. Fillers will also attempt to execute triggerable orders. 106 | 107 | ### Common errors 108 | 109 | When running the filler bots, you might see the following error codes in the transaction logs on a failed in pre-flight simulation: 110 | 111 | #### For perps 112 | 113 | | Error | Description | 114 | | ----------------- | ------ | 115 | | OrderDoesNotExist | Outcompeted: Order was already filled by someone else| 116 | | OrderNotTriggerable | Outcompeted: order was already triggered by someone else | 117 | | RevertFill | Outcompeted: order was already filled by someone else| 118 | 119 | 120 | #### Other messages 121 | 122 | | Message | Description | 123 | | --------|--------------| 124 | | filler last active slot != current slot | You might see this when outcompeted on a fill. The *filler last active slot* was the last slot that the filler had a successful fill in, so it may diverge *current slot* if the filler has not placed a successful order. 125 | 126 | 127 | ## Liquidator Bot 128 | 129 | The liquidator bot monitors spot and perp markets for bankrupt accounts, and attempts to liquidate positions according to the protocol's [liquidation process](https://docs.drift.trade/liquidators). 130 | 131 | ### Notes on derisking (`useJupiter`) 132 | 133 | This liquidator implementation includes an option to `useJupiter` to derisk (sell) spot assets into USDC. The derisk loop will use 134 | the more favorable of Drift spot or Jupiter before executing. Set `useJupiter` under the liquidator config to enable this behavior 135 | (see below). 136 | 137 | You may also set `disableAutoDerisking` to `true`, to disable the derisking loop. You may want to do this as part of a larger strategy 138 | where you are ok with taking on risk at a favorable to market price (liquidation fee applied). 139 | 140 | ### Notes on configuring subaccount 141 | 142 | By default the liquidator will attempt to liqudate (inherit the risk of) 143 | endangered positions in all markets. Set `botConfigs.liquidator.perpMarketIndicies` and/or `botConfigs.liquidator.spotMarketIndicies` 144 | in the config file to restrict which markets you want to liquidate. The 145 | account specified in `global.subaccounts` will be used as the active 146 | account. 147 | 148 | `perpSubaccountConfig` and `spotSubaccountConfig` can be used instead 149 | of `perpMarketIndicies` and `spotMarketIndicies` to specify a mapping 150 | from subaccount to list of market indicies. The value of these 2 fields 151 | are json strings: 152 | 153 | ### An example `config.yaml` 154 | ``` 155 | botConfigs: 156 | ... 157 | liquidator: 158 | ... 159 | useJupiter: true 160 | perpSubAccountConfig: 161 | 0: 162 | - 0 163 | - 1 164 | - 2 165 | 1: 166 | - 3 167 | - 4 168 | - 5 169 | - 6 170 | - 7 171 | - 8 172 | - 9 173 | - 10 174 | - 11 175 | - 12 176 | spotSubAccountConfig: 177 | 0: 178 | - 0 179 | - 1 180 | - 2 181 | ``` 182 | Means the liquidator will liquidate perp markets 0-2 using subaccount 0, perp markets 3-12 using subaccount 1, and spot markets 0-2 using subaccount 0. It will also use jupiter to derisk spot assets into USDC. Make sure that for all subaccounts specified in the botConfigs, that they are also listed in the global configs. So for the above example config: 183 | 184 | ``` 185 | global: 186 | ... 187 | subaccounts: [0, 1] 188 | ``` 189 | 190 | ### Common errors 191 | 192 | When running the liquidator, you might see the following error codes in the transaction logs on a failed in pre-flight simulation: 193 | 194 | | Error | Description | 195 | | ----------------- | ------ | 196 | | SufficientCollateral | The account you're trying to liquidate has sufficient collateral and can't be liquidated | 197 | | InvalidSpotPosition | Outcompeted: the liqudated account's spot position was already liquidated. | 198 | | InvalidPerpPosition | Outcompeted: the liqudated account's perp position was already liquidated. | 199 | 200 | ## Jit Maker 201 | 202 | The jit maker bot supplies liquidity to the protocol by participating in jit acutions for perp markets. Before running a jit maker bot, be sure to read the documentation below: 203 | 204 | Read the docs on jit auctions: https://docs.drift.trade/just-in-time-jit-auctions 205 | 206 | Read the docs on the jit proxy client: https://github.com/drift-labs/jit-proxy/blob/master/ts/sdk/Readme.md 207 | 208 | Be aware that running a jit maker means taking on positional risk, so be sure to manage your risk properly! 209 | 210 | ### Implementation 211 | 212 | This sample jit maker uses the jit proxy client, and updates ```JitParams``` for the markets specified in the config. The bot will update its bid and ask to match the current top level market in the DLOB, and specifies its maximum position size to keep leverage at 1. The jit maker will attempt to fill taker orders that cross its market that's specified in the ```JitParams```. If the current auction price does not cross the bid/ask the transaction will fail during pre-flight simulation, because for the purposes of the jit proxy program, the market is considered the market maker's worst acceptable price of execution. For order execution, the jit maker currently uses the ```JitterSniper``` -- read more on the jitters and different options in the jit proxy client documentation (link above). 213 | 214 | This bot is meant to serve as a starting off point for participating in jit auctions. To increase strategy complexity, consider different strategies for updating your markets. To change the amount of leverage, change the constant ```TARGET_LEVERAGE_PER_ACCOUNT``` before running. 215 | 216 | ### Common errors 217 | 218 | | Error | Description | 219 | | ----------------- | ------ | 220 | | BidNotCrossed/AskNotCrossed | The jit proxy program simulation fails if the auction price is not lower than the jit param bid or higher than jit param ask. Bot's market, oracle price, or auction price changed during execution. Can be a latency issue, either slow order submission or slow websocket/polling connection. | 221 | | OrderNotFound | Outcompeted: the taker order was already filled. | 222 | 223 | ### Running the bot and notes on configs 224 | 225 | ```jitMaker.config.yaml``` is supplied as an example, and a jit maker can be run with ```yarn run dev --config-file=jitMaker.config.yaml```. Jit maker bots require colleteral, so make sure to specify depositing collateral in the config file using ```forceDeposit```, or deposit collateral using the app or SDK before running the bot. 226 | 227 | To avoid errors being thrown during initialization, remember to enumerate in the global configs the subaccounts being used in the bot configs. An example below in a config.yaml file: 228 | 229 | ``` 230 | global: 231 | ... 232 | subaccounts: [0, 1] <----- bot configs specify subaccounts of [0, 1, 1], so make sure we load in [0, 1] in global configs to properly initialize driftClient! 233 | 234 | 235 | botConfigs: 236 | jitMaker: 237 | botId: "jitMaker" 238 | dryRun: false 239 | # below, ordering is important: match the subaccountIds to perpMarketindices. 240 | # e.g. to MM perp markets 0, 1 both on subaccount 0, then subaccounts=[0,0], perpMarketIndicies=[0,1] 241 | # to MM perp market 0 on subaccount 0 and perp market 1 on subaccount 1, then subaccounts=[0, 1], perpMarketIndicies=[0, 1] 242 | # also, make sure all subaccounts are loaded in the global config subaccounts above to avoid errors 243 | subaccounts: [0, 1, 1] <--------------- the subaccount set should be specified above too! 244 | perpMarketIndicies: [0, 1, 2] 245 | 246 | ``` 247 | -------------------------------------------------------------------------------- /build_all.sh: -------------------------------------------------------------------------------- 1 | cd drift-common/protocol/sdk && yarn && yarn build && cd ../../.. 2 | cd drift-common/common-ts && yarn && yarn build && cd ../.. 3 | yarn && yarn build 4 | -------------------------------------------------------------------------------- /esbuild.config.js: -------------------------------------------------------------------------------- 1 | const esbuild = require('esbuild'); 2 | const glob = require('tiny-glob'); 3 | 4 | const commonConfig = { 5 | bundle: true, 6 | platform: 'node', 7 | target: 'es2020', 8 | sourcemap: false, 9 | // minify: true, makes messy debug/error output 10 | treeShaking: true, 11 | legalComments: 'none', 12 | metafile: true, 13 | format: 'cjs', 14 | external: [ 15 | 'bigint-buffer', 16 | '@triton-one/yellowstone-grpc' 17 | ] 18 | }; 19 | 20 | (async () => { 21 | let entryPoints = await glob("./src/**/*.ts"); 22 | await esbuild.build({ 23 | ...commonConfig, 24 | entryPoints, 25 | outdir: 'lib', 26 | }); 27 | })().catch(() => process.exit(1)); -------------------------------------------------------------------------------- /example.config.yaml: -------------------------------------------------------------------------------- 1 | global: 2 | # devnet or mainnet-beta 3 | driftEnv: mainnet-beta 4 | 5 | # RPC endpoint to use 6 | endpoint: https://you-need-your-own-rpc.com 7 | 8 | # Custom websocket endpoint to use (if null will be determined from `endpoint``) 9 | # Note: the default wsEndpoint value simply replaces http(s) with ws(s), so if 10 | # your RPC provider requires a special path (i.e. /ws) for websocket connections 11 | # you must set this. 12 | wsEndpoint: 13 | 14 | # optional if you want to use helius' global priority fee method AND `endpoint` is not 15 | # already a helius url. 16 | heliusEndpoint: 17 | 18 | # optional endpoint to use for just confirming txs to check if they landed. 19 | # loop purposefully runs slow so should work with public RPC 20 | txConfirmationEndpoint: https://api.mainnet-beta.solana.com 21 | 22 | # `solana` or `helius`. If `helius` `endpoint` must be a helius RPC, or `heliusEndpoint` 23 | # must be set 24 | # solana: uses https://solana.com/docs/rpc/http/getrecentprioritizationfees 25 | # helius: uses https://docs.helius.dev/solana-rpc-nodes/alpha-priority-fee-api 26 | priorityFeeMethod: solana 27 | 28 | # skips preflight checks on sendTransaciton, default is false. 29 | # this will speed up tx sending, but may increase SOL paid due to failed txs landing 30 | # on chain 31 | #txSkipPreflight: true 32 | 33 | # max priority fee to use, in micro lamports 34 | # i.e. a fill that uses 500_000 CUs will spend: 35 | # 500_000 * 10_000 * 1e-6 * 1e-9 = 0.000005 SOL on priority fees 36 | # this is on top of the 0.000005 SOL base fee, so 0.000010 SOL total 37 | maxPriorityFeeMicroLamports: 10000 38 | 39 | # Private key to use to sign transactions. 40 | # will load from KEEPER_PRIVATE_KEY env var if null 41 | keeperPrivateKey: 42 | 43 | initUser: false # initialize user on startup 44 | testLiveness: false # test liveness, by failing liveness test after 1 min 45 | 46 | # Force deposit this amount of USDC to collateral account, the program will 47 | # end after the deposit transaction is sent 48 | #forceDeposit: 1000 49 | 50 | websocket: true # use websocket for account loading and events (limited support) 51 | eventSubscriber: false # disables event subscriber (heavy RPC demand), this is primary used for counting fills 52 | runOnce: false # Set true to run once and exit, useful for testing or one off bot runs 53 | debug: false # Enable debug logging 54 | 55 | # subaccountIDs to load, if null will load subaccount 0 (default). 56 | # Even if bot specific configs requires subaccountIDs, you should still 57 | # specify it here, since we load the subaccounts before loading individual 58 | # bots. 59 | # subaccounts: 60 | # - 0 61 | # - 1 62 | # - 2 63 | subaccounts: 64 | - 0 65 | 66 | eventSubscriberPollingInterval: 5000 67 | bulkAccountLoaderPollingInterval: 5000 68 | 69 | useJito: false 70 | # one of: ['non-jito-only', 'jito-only', 'hybrid']. 71 | # * non-jito-only: will only send txs to RPC when there is no active jito leader 72 | # * jito-only: will only send txs via bundle when there is an active jito leader 73 | # * hybrid: will attempt to send bundles when active jito leader, and use RPC when not 74 | # hybrid may not work well if using high throughput bots such as a filler depending on infra limitations. 75 | jitoStrategy: jito-only 76 | # the minimum tip to pay 77 | jitoMinBundleTip: 10000 78 | # the maximum tip to pay (will pay this once jitoMaxBundleFailCount is hit) 79 | jitoMaxBundleTip: 100000 80 | # the number of failed bundles (accepted but not landed) before tipping the max tip 81 | jitoMaxBundleFailCount: 200 82 | # the tip multiplier to use when tipping the max tip 83 | # controls superlinearity (1 = linear, 2 = slightly-superlinear, 3 = more-superlinear, ...) 84 | jitoTipMultiplier: 3 85 | jitoBlockEngineUrl: frankfurt.mainnet.block-engine.jito.wtf 86 | jitoAuthPrivateKey: /path/to/jito/bundle/signing/key/auth.json 87 | onlySendDuringJitoLeader: false 88 | 89 | # Which bots to run, be careful with this, running multiple bots in one instance 90 | # might use more resources than expected. 91 | # Bot specific configs are below 92 | enabledBots: 93 | # Perp order filler bot 94 | - filler 95 | 96 | # Spot order filler bot 97 | # - spotFiller 98 | 99 | # Trigger bot (triggers trigger orders) 100 | - trigger 101 | 102 | # Liquidator bot, liquidates unhealthy positions by taking over the risk (caution, you should manage risk here) 103 | # - liquidator 104 | 105 | # Example maker bot that participates in JIT auction (caution: you will probably lose money) 106 | # - jitMaker 107 | 108 | # Example maker bot that posts floating oracle orders 109 | # - floatingMaker 110 | 111 | # settles PnLs for the insurance fund (may want to run with runOnce: true) 112 | # - ifRevenueSettler 113 | 114 | # settles negative PnLs for users (may want to run with runOnce: true) 115 | # - userPnlSettler 116 | 117 | # - markTwapCrank 118 | 119 | # below are bot configs 120 | botConfigs: 121 | filler: 122 | botId: "filler" 123 | dryRun: false 124 | fillerPollingInterval: 6000 125 | metricsPort: 9464 126 | 127 | # will revert a transaction during simulation if a fill fails, this will save on tx fees, 128 | # and be friendlier for use with services like Jito. 129 | # Default is true 130 | revertOnFailure: true 131 | 132 | # calls simulateTransaction before sending to get an accurate CU estimate 133 | # as well as stop before sending the transaction (Default is true) 134 | simulateTxForCUEstimate: true 135 | 136 | spotFiller: 137 | botId: "spot-filler" 138 | dryRun: false 139 | fillerPollingInterval: 6000 140 | metricsPort: 9464 141 | revertOnFailure: true 142 | simulateTxForCUEstimate: true 143 | 144 | liquidator: 145 | botId: "liquidator" 146 | dryRun: false 147 | metricsPort: 9465 148 | # if true will NOT attempt to sell off any inherited positions 149 | disableAutoDerisking: false 150 | # if true will swap spot assets on jupiter if the price is better 151 | useJupiter: true 152 | # null will handle all markets 153 | perpMarketIndicies: 154 | spotMarketIndicies: 155 | 156 | # specify which subaccount is ok with inheriting risk in each specified 157 | # perp or spot market index. Leaving it null will watch 158 | # all markets on the default global.subaccounts.0 159 | perpSubAccountConfig: 160 | 0: # subaccount 0 will watch perp markets 0 and 1 161 | - 0 162 | - 1 163 | spotSubAccountConfig: # will watch all spot markets on the default global.subaccounts.0 164 | 165 | # deprecated (bad naming): use maxSlippageBps 166 | maxSlippagePct: 50 167 | 168 | # Max slippage to incur allow when derisking (in bps). 169 | # This is used to calculate the auction end price (worse price) when derisking 170 | # and also passed into jupiter when doing swaps. 171 | maxSlippageBps: 50 172 | 173 | # duration of jit auction for derisk orders 174 | deriskAuctionDurationSlots: 100 175 | 176 | # what algo to use for derisking. Options are "market" or "twap" 177 | deriskAlgo: "market" 178 | 179 | # if deriskAlgo == "twap", must supply these as well 180 | # twapDurationSec: 300 # overall duration of to run the twap algo. Aims to derisk the entire position over this duration 181 | 182 | # Minimum deposit amount to try to liquidiate, per spot market, in lamports. 183 | # If null, or a spot market isn't here, it will liquidate any amount 184 | # See perpMarkets.ts on the protocol codebase for the indices 185 | minDepositToLiq: 186 | 1: 10 187 | 2: 1000 188 | 189 | # Filter out un-liquidateable accounts that just create log noise 190 | excludedAccounts: 191 | - 9CJLgd5f9nmTp7KRV37RFcQrfEmJn6TU87N7VQAe2Pcq 192 | - Edh39zr8GnQFNYwyvxhPngTJHrr29H3vVup8e8ZD4Hwu 193 | 194 | # max % of collateral to spend when liquidating a user. In percentage terms (0.5 = 50%) 195 | maxPositionTakeoverPctOfCollateral: 0.5 196 | 197 | # sends a webhook notification (slack, discord, etc.) when a liquidation is attempted (can be noisy due to partial liquidations) 198 | notifyOnLiquidation: true 199 | 200 | # Will consider spot assets below this value to be "dust" and will be withdrawn to the authority wallet 201 | # in human USD terms: 10.0 for 10.0 USD worth of spot assets 202 | spotDustValueThreshold: 10 203 | 204 | trigger: 205 | botId: "trigger" 206 | dryRun: false 207 | metricsPort: 9465 208 | 209 | markTwapCrank: 210 | botId: "mark-twap-cranker" 211 | dryRun: false 212 | metricsPort: 9465 213 | crankIntervalToMarketIndicies: 214 | 15000: 215 | - 0 216 | - 1 217 | - 2 218 | 60000: 219 | - 3 220 | - 4 221 | - 5 222 | - 6 223 | - 7 224 | - 8 225 | - 9 226 | - 10 227 | - 11 228 | - 12 229 | - 13 230 | - 14 231 | - 15 232 | - 16 233 | -------------------------------------------------------------------------------- /jit-maker-config.yaml: -------------------------------------------------------------------------------- 1 | global: 2 | driftEnv: mainnet-beta 3 | endpoint: 4 | wsEndpoint: 5 | 6 | # Private key to use to sign transactions. 7 | # will load from KEEPER_PRIVATE_KEY env var if null 8 | keeperPrivateKey: 9 | 10 | initUser: false # set to true if you are starting with a fresh keypair and want to initialize a user 11 | testLiveness: false # test liveness, by failing liveness test after 1 min 12 | cancelOpenOrders: false # cancel open orders on startup 13 | closeOpenPositions: false # close all open positions 14 | 15 | websocket: true # use websocket for account loading and events (limited support) 16 | resubTimeoutMs: 30000 17 | debug: false # Enable debug logging 18 | 19 | # subaccountIDs to load, if null will load subaccount 0 (default). 20 | subaccounts: 21 | - 0 22 | 23 | maxPriorityFeeMicroLamports: 100000 24 | 25 | # Which bots to run, be careful with this, running multiple bots in one instance 26 | # might use more resources than expected. 27 | # Bot specific configs are below 28 | enabledBots: 29 | - jitMaker 30 | 31 | # below are bot configs 32 | botConfigs: 33 | jitMaker: 34 | botId: "jitMaker" 35 | dryRun: false # no effect for jit maker 36 | metricsPort: 9464 37 | 38 | # will jit make on perp market 20 (JTO-PERP) on subaccount 0 39 | marketType: "perp" 40 | marketIndexes: 41 | - 1 42 | subaccounts: 43 | - 2 44 | 45 | # bot will try to buy 30 bps above the best bid, and sell 30 bps below the best bid. 46 | aggressivenessBps: 100 47 | 48 | # CU limit to set for jit fill, you might need to increase it 49 | # if your account has many positions 50 | jitCULimit: 800000 51 | -------------------------------------------------------------------------------- /jitMaker.config.yaml: -------------------------------------------------------------------------------- 1 | global: 2 | # devnet or mainnet-beta 3 | driftEnv: mainnet-beta 4 | 5 | # RPC endpoint to use 6 | endpoint: 7 | # custom websocket endpoint to use (if null will be determined from rpc endpoint) 8 | wsEndpoint: 9 | resubTimeoutMs: 10000 # timeout for resubscribing to websocket 10 | 11 | # Private key to use to sign transactions. 12 | # will load from KEEPER_PRIVATE_KEY env var if null 13 | keeperPrivateKey: 14 | 15 | initUser: false # initialize user on startup 16 | testLiveness: false # test liveness, by failing liveness test after 1 min 17 | 18 | # Force deposit this amount of USDC to collateral account, the program will 19 | # end after the deposit transaction is sent 20 | #forceDeposit: 1000 21 | 22 | websocket: true # use websocket for account loading and events (limited support) 23 | runOnce: false # Set true to run once and exit, useful for testing or one off bot runs 24 | debug: false # Enable debug logging 25 | 26 | eventSubscriberPollingInterval: 0 27 | bulkAccountLoaderPollingInterval: 0 28 | 29 | useJito: false 30 | jitoBlockEngineUrl: frankfurt.mainnet.block-engine.jito.wtf 31 | jitoAuthPrivateKey: /path/to/jito/bundle/signing/key/auth.json 32 | 33 | # which subaccounts to load and get info for, if null will load subaccount 0 (default) 34 | subaccounts: [0] 35 | 36 | # Which bots to run, be careful with this, running multiple bots in one instance 37 | # might use more resources than expected. 38 | # Bot specific configs are below 39 | enabledBots: 40 | # Perp order filler bot 41 | # - filler 42 | 43 | # Spot order filler bot 44 | # - spotFiller 45 | 46 | # Trigger bot (triggers trigger orders) 47 | # - trigger 48 | 49 | # Liquidator bot, liquidates unhealthy positions by taking over the risk (caution, you should manage risk here) 50 | # - liquidator 51 | 52 | # Example maker bot that participates in JIT auction (caution: you will probably lose money) 53 | - jitMaker 54 | 55 | # Example maker bot that posts floating oracle orders 56 | # - floatingMaker 57 | 58 | # settles PnLs for the insurance fund (may want to run with runOnce: true) 59 | # - ifRevenueSettler 60 | 61 | # settles negative PnLs for users (may want to run with runOnce: true) 62 | # - userPnlSettler 63 | 64 | # uncross the book to capture an arb 65 | # - uncrossArb 66 | 67 | # below are bot configs 68 | botConfigs: 69 | jitMaker: 70 | botId: "jitMaker" 71 | dryRun: false 72 | # below, ordering is important: match the subaccountIds to perpMarketindices. 73 | # e.g. to MM perp markets 0, 1 both on subaccount 0, then subaccounts=[0,0], perpMarketIndicies=[0,1] 74 | # to MM perp market 0 on subaccount 0 and perp market 1 on subaccount 1, then subaccounts=[0, 1], perpMarketIndicies=[0, 1] 75 | # also, make sure all subaccounts are loaded in the global config subaccounts above to avoid errors 76 | subaccounts: [0] 77 | perpMarketIndicies: [0] 78 | marketType: PERP 79 | 80 | # uncrossArb: 81 | # botId: "uncrossArb" 82 | # dryRun: false 83 | # driftEnv: "mainnet-beta" 84 | 85 | -------------------------------------------------------------------------------- /lite.config.yaml: -------------------------------------------------------------------------------- 1 | global: 2 | # devnet or mainnet-beta 3 | driftEnv: mainnet-beta 4 | 5 | # RPC endpoint to use 6 | endpoint: https://api.mainnet-beta.solana.com 7 | wsEndpoint: 8 | 9 | # Private key to use to sign transactions. 10 | # will load from KEEPER_PRIVATE_KEY env var if null 11 | keeperPrivateKey: 12 | 13 | initUser: false # initialize user on startup 14 | testLiveness: false # test liveness, by failing liveness test after 1 min 15 | 16 | # Force deposit this amount of USDC to collateral account, the program will 17 | # end after the deposit transaction is sent 18 | #forceDeposit: 1000 19 | 20 | websocket: true # use websocket for account loading and events (limited support) 21 | runOnce: false # Set true to run once and exit, useful for testing or one off bot runs 22 | debug: false # Enable debug logging 23 | 24 | # subaccountIDs to load, if null will load subaccount 0 (default). 25 | # Even if bot specific configs requires subaccountIDs, you should still 26 | # specify it here, since we load the subaccounts before loading individual 27 | # bots. 28 | # subaccounts: 29 | # - 0 30 | # - 1 31 | # - 2 32 | 33 | eventSubscriberPollingInterval: 0 34 | bulkAccountLoaderPollingInterval: 0 35 | 36 | useJito: false 37 | 38 | # which subaccounts to load, if null will load subaccount 0 (default) 39 | subaccounts: 40 | 41 | # Which bots to run, be careful with this, running multiple bots in one instance 42 | # might use more resources than expected. 43 | # Bot specific configs are below 44 | enabledBots: 45 | # Perp order filler bot 46 | # - fillerLite 47 | 48 | # Spot order filler bot 49 | # - spotFiller 50 | 51 | # Trigger bot (triggers trigger orders) 52 | # - trigger 53 | 54 | # Liquidator bot, liquidates unhealthy positions by taking over the risk (caution, you should manage risk here) 55 | # - liquidator 56 | 57 | # Example maker bot that participates in JIT auction (caution: you will probably lose money) 58 | # - jitMaker 59 | 60 | # Example maker bot that posts floating oracle orders 61 | # - floatingMaker 62 | 63 | # settles PnLs for the insurance fund (may want to run with runOnce: true) 64 | # - ifRevenueSettler 65 | 66 | # settles negative PnLs for users (may want to run with runOnce: true) 67 | # - userPnlSettler 68 | 69 | - markTwapCrank 70 | 71 | # below are bot configs 72 | botConfigs: 73 | fillerLite: 74 | botId: "fillerLite" 75 | dryRun: false 76 | fillerPollingInterval: 500 77 | metricsPort: 9464 78 | 79 | # will revert a transaction during simulation if a fill fails, this will save on tx fees, 80 | # and be friendlier for use with services like Jito. 81 | # Default is true 82 | 83 | markTwapCrank: 84 | botId: "mark-twap-cranker" 85 | dryRun: false 86 | metricsPort: 9465 87 | crankIntervalToMarketIndicies: 88 | 15000: 89 | - 0 90 | - 1 91 | - 2 92 | 60000: 93 | - 3 94 | - 4 95 | - 5 96 | - 6 97 | - 7 98 | - 8 99 | - 9 100 | - 10 101 | - 11 102 | - 12 103 | - 13 104 | - 14 105 | - 15 106 | - 16 107 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@drift-labs/order-filler", 3 | "version": "0.1.0", 4 | "author": "crispheaney", 5 | "main": "lib/index.js", 6 | "license": "Apache-2.0", 7 | "dependencies": { 8 | "@drift-labs/jit-proxy": "0.17.43", 9 | "@drift-labs/sdk": "2.122.0-beta.5", 10 | "@drift/common": "file:./drift-common/common-ts", 11 | "@opentelemetry/api": "1.7.0", 12 | "@opentelemetry/auto-instrumentations-node": "0.31.2", 13 | "@opentelemetry/exporter-prometheus": "0.31.0", 14 | "@opentelemetry/sdk-node": "0.31.0", 15 | "@project-serum/anchor": "0.19.1-beta.1", 16 | "@project-serum/serum": "0.13.65", 17 | "@pythnetwork/price-service-client": "1.9.0", 18 | "@pythnetwork/pyth-lazer-sdk": "0.3.2", 19 | "@solana/spl-token": "0.3.7", 20 | "@solana/web3.js": "1.92.3", 21 | "@types/bn.js": "5.1.5", 22 | "@types/minimist": "1.2.5", 23 | "@types/mocha": "10.0.6", 24 | "@types/node": "20.12.7", 25 | "async": "3.2.5", 26 | "async-mutex": "0.3.2", 27 | "aws-sdk": "2.1511.0", 28 | "axios": "1.7.7", 29 | "commander": "9.5.0", 30 | "dotenv": "10.0.0", 31 | "jito-ts": "4.1.1", 32 | "lru-cache": "10.2.0", 33 | "minimist": "1.2.8", 34 | "rpc-websockets": "7.11.0", 35 | "tweetnacl": "1.0.3", 36 | "tweetnacl-util": "0.15.1", 37 | "typescript": "4.5.4", 38 | "undici": "6.19.2", 39 | "winston": "3.11.0", 40 | "ws": "8.18.0", 41 | "yaml": "2.3.4" 42 | }, 43 | "resolutions": { 44 | "jito-ts": "4.1.1" 45 | }, 46 | "devDependencies": { 47 | "@types/chai": "4.3.11", 48 | "@types/mocha": "10.0.6", 49 | "@typescript-eslint/eslint-plugin": "5.62.0", 50 | "@typescript-eslint/parser": "4.33.0", 51 | "chai": "4.3.10", 52 | "esbuild": "0.24.0", 53 | "eslint": "7.32.0", 54 | "eslint-config-prettier": "8.10.0", 55 | "eslint-plugin-prettier": "3.4.1", 56 | "husky": "7.0.4", 57 | "mocha": "10.2.0", 58 | "prettier": "3.0.1", 59 | "tiny-glob": "0.2.9", 60 | "ts-node": "10.9.1" 61 | }, 62 | "scripts": { 63 | "prepare": "husky install", 64 | "build": "yarn clean && node esbuild.config.js", 65 | "clean": "rm -rf lib", 66 | "start": "node lib/index.js", 67 | "dev": "NODE_OPTIONS=--max-old-space-size=8192 ts-node src/index.ts", 68 | "dev:inspect": "node --inspect --require ts-node/register src/index.ts", 69 | "dev:filler": "ts-node src/index.ts --filler", 70 | "dev:trigger": "ts-node src/index.ts --trigger", 71 | "dev:jitmaker": "ts-node src/index.ts --jit-maker", 72 | "dev:liquidator": "ts-node src/index.ts --liquidator", 73 | "dev:pnlsettler": "ts-node src/index.ts --user-pnl-settler", 74 | "dev-exp": "NODE_OPTIONS=--max-old-space-size=8192 ts-node src/experimental-bots/entrypoint.ts", 75 | "dev-exp:inspect": "NODE_OPTIONS=--max-old-space-size=8192 node --inspect --require ts-node/register src/experimental-bots/entrypoint.ts", 76 | "prettify": "prettier --check './src/**/*.ts'", 77 | "prettify:fix": "prettier --write './src/**/*.ts'", 78 | "lint": "eslint . --ext ts --quiet", 79 | "lint:fix": "eslint . --ext ts --fix", 80 | "example:restingLimitMaker": "ts-node market_making_examples/restingLimitMaker/index.ts", 81 | "example:jitMaker": "ts-node market_making_examples/jitMaker/index.ts", 82 | "test": "mocha -r ts-node/register ./src/**/*.test.ts" 83 | }, 84 | "engines": { 85 | "node": ">=20.18.0" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /quickstart.md: -------------------------------------------------------------------------------- 1 | ### Quick Start 2 | 3 | Drift Protocol Jit Market Making, offer fee rebates for providing liquidity. 4 | 5 | 1. create and fund an account on drift protocol 6 | 7 | - can experiment using devnet, for mainnet make sure you understand the risks 8 | - see [keeper-bots-v2 readme](https://github.com/drift-labs/keeper-bots-v2#initialize-user) 9 | - or see https://drift-labs.github.io/v2-teacher/#introduction 10 | 11 | 2. get a private RPC endpoint 12 | 13 | - RPC is node to method to send tx to solana validators 14 | - _Recommended_: [Helius Free Tier](https://dev.helius.xyz/dashboard/app) 15 | - more RPC colocation/resources can benefit competitiveness 16 | 17 | 3. examine `src/bots/jitMaker.ts` 18 | 19 | - see/modify the strategy for providing jit liquidity 20 | - default tries to intelligently provide within max leverage of 1x 21 | - optionally can hedge perpetual with spot if available 22 | - optionally set decision criteria on a per user basis 23 | 24 | 4. update parameters in `jitMaker.config.yaml` 25 | 26 | - set the markets willing to provide jit liquidity 27 | 28 | 5. run strategy 29 | 30 | - `yarn run dev --config-file=jitMaker.config.yaml` 31 | - track and monitor to make improvements 32 | 33 | 6. join discord / open PR 34 | 35 | - drift protocol promotes a open and helpful development community 36 | - get technical help, discuss ideas, and make internet friends :D 37 | -------------------------------------------------------------------------------- /src/bots/common/processUtils.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcess, fork, SendHandle, Serializable } from 'child_process'; 2 | 3 | export const spawnChildWithRetry = ( 4 | relativePath: string, 5 | childArgs: string[], 6 | processName: string, 7 | onMessage: (msg: Serializable, sendHandle: SendHandle) => void, 8 | logPrefix = '' 9 | ): ChildProcess => { 10 | const child = fork(relativePath, childArgs); 11 | 12 | child.on('message', onMessage); 13 | 14 | child.on('exit', (code: number | null, signal: NodeJS.Signals | null) => { 15 | console.log( 16 | `${logPrefix} Child process: ${processName} exited with code ${code}, signal: ${signal}` 17 | ); 18 | }); 19 | 20 | child.on('error', (err: Error) => { 21 | console.error( 22 | `${logPrefix} Child process: ${processName} had an error:\n`, 23 | err 24 | ); 25 | if (err.message.includes('Channel closed')) { 26 | console.error(`Exiting`); 27 | process.exit(2); 28 | } else { 29 | console.log(`${logPrefix} Restarting child process: ${processName}`); 30 | spawnChildWithRetry(relativePath, childArgs, processName, onMessage); 31 | } 32 | }); 33 | 34 | return child; 35 | }; 36 | -------------------------------------------------------------------------------- /src/bots/common/threads/txThread.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '../../../logger'; 2 | import { TxSender } from './txSender'; 3 | import { 4 | IpcMessage, 5 | IpcMessageMap, 6 | IpcMessageTypes, 7 | WrappedIpcMessage, 8 | deserializedIx, 9 | } from './types'; 10 | import { BlockhashSubscriber, SlotSubscriber } from '@drift-labs/sdk'; 11 | import { Connection } from '@solana/web3.js'; 12 | import dotenv from 'dotenv'; 13 | import parseArgs from 'minimist'; 14 | 15 | const logPrefix = 'TxThread'; 16 | 17 | function sendToParent(msgType: IpcMessageTypes, data: IpcMessage) { 18 | process.send!({ 19 | type: msgType, 20 | data, 21 | } as WrappedIpcMessage); 22 | } 23 | 24 | const main = async () => { 25 | // kill this process if the parent dies 26 | process.on('disconnect', () => process.exit()); 27 | dotenv.config(); 28 | 29 | const args = parseArgs(process.argv.slice(2)); 30 | logger.info(`[${logPrefix}] Started with args: ${JSON.stringify(args)}`); 31 | const rpcUrl = args['rpc']; 32 | const sendTxEnabled = args['send-tx'] === 'true'; 33 | 34 | if (process.send === undefined) { 35 | logger.error( 36 | `[${logPrefix}] Error spawning process: process.send is not a function` 37 | ); 38 | process.exit(1); 39 | } 40 | 41 | const blockhashSubscriber = new BlockhashSubscriber({ 42 | rpcUrl, 43 | commitment: 'finalized', 44 | updateIntervalMs: 3000, 45 | }); 46 | await blockhashSubscriber.subscribe(); 47 | 48 | const connection = new Connection(rpcUrl); 49 | const slotSubscriber = new SlotSubscriber(connection); 50 | await slotSubscriber.subscribe(); 51 | 52 | const txSender = new TxSender({ 53 | connection, 54 | blockhashSubscriber, 55 | slotSubscriber, 56 | resendInterval: 1000, 57 | sendTxEnabled, 58 | }); 59 | await txSender.subscribe(); 60 | 61 | const metricsSenderId = setInterval(async () => { 62 | sendToParent(IpcMessageTypes.METRICS, txSender.getMetrics()); 63 | }, 10_000); 64 | 65 | sendToParent(IpcMessageTypes.NOTIFICATION, { 66 | message: 'Started', 67 | }); 68 | 69 | const shutdown = async () => { 70 | clearInterval(metricsSenderId); 71 | 72 | blockhashSubscriber.unsubscribe(); 73 | await txSender.unsubscribe(); 74 | }; 75 | 76 | process.on('SIGINT', async () => { 77 | console.log(`TxSender Received SIGINT, shutting down...`); 78 | await shutdown(); 79 | process.exit(0); 80 | }); 81 | 82 | process.on('SIGTERM', async () => { 83 | console.log(`TxSender Received SIGTERM, shutting down...`); 84 | await shutdown(); 85 | process.exit(0); 86 | }); 87 | 88 | process.on('message', async (_msg: WrappedIpcMessage, _sendHandle: any) => { 89 | switch (_msg.type) { 90 | case IpcMessageTypes.NOTIFICATION: { 91 | const notification = 92 | _msg.data as IpcMessageMap[IpcMessageTypes.NOTIFICATION]; 93 | if (notification.message === 'close') { 94 | logger.info(`Parent close notification ${notification.message}`); 95 | process.exit(0); 96 | } 97 | logger.info(`Notification from parent: ${notification.message}`); 98 | break; 99 | } 100 | case IpcMessageTypes.NEW_SIGNER: { 101 | const signerPayload = 102 | _msg.data as IpcMessageMap[IpcMessageTypes.NEW_SIGNER]; 103 | txSender.addSigner(signerPayload.signerKey, signerPayload.signerInfo); 104 | break; 105 | } 106 | case IpcMessageTypes.NEW_ADDRESS_LOOKUP_TABLE: { 107 | const addressLookupTablePayload = 108 | _msg.data as IpcMessageMap[IpcMessageTypes.NEW_ADDRESS_LOOKUP_TABLE]; 109 | await txSender.addAddressLookupTable(addressLookupTablePayload.address); 110 | break; 111 | } 112 | case IpcMessageTypes.TRANSACTION: { 113 | const txPayload = 114 | _msg.data as IpcMessageMap[IpcMessageTypes.TRANSACTION]; 115 | 116 | if (txPayload.newSigners?.length > 0) { 117 | for (const signer of txPayload.newSigners) { 118 | txSender.addSigner(signer.key, signer.info); 119 | } 120 | } 121 | 122 | txPayload.ixs = txPayload.ixsSerialized.map((ix) => deserializedIx(ix)); 123 | await txSender.addTxPayload(txPayload); 124 | break; 125 | } 126 | case IpcMessageTypes.CONFIRM_TRANSACTION: { 127 | const confirmPayload = 128 | _msg.data as IpcMessageMap[IpcMessageTypes.CONFIRM_TRANSACTION]; 129 | txSender.addTxToConfirm(confirmPayload); 130 | break; 131 | } 132 | case IpcMessageTypes.SET_TRANSACTIONS_ENABLED: { 133 | const txPayload = 134 | _msg.data as IpcMessageMap[IpcMessageTypes.SET_TRANSACTIONS_ENABLED]; 135 | logger.info(`Parent set transactions enabled: ${txPayload.txEnabled}`); 136 | txSender.setTransactionsEnabled(txPayload.txEnabled); 137 | break; 138 | } 139 | default: { 140 | logger.info( 141 | `Unknown message type from parent: ${JSON.stringify(_msg)}` 142 | ); 143 | break; 144 | } 145 | } 146 | }); 147 | }; 148 | 149 | main().catch((err) => { 150 | logger.error(`[${logPrefix}] Error in TxThread: ${JSON.stringify(err)}`); 151 | process.exit(1); 152 | }); 153 | -------------------------------------------------------------------------------- /src/bots/common/threads/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AddressLookupTableAccount, 3 | PublicKey, 4 | TransactionInstruction, 5 | VersionedTransaction, 6 | } from '@solana/web3.js'; 7 | 8 | export type WrappedIpcMessage = { 9 | type: IpcMessageTypes; 10 | data: IpcMessage; 11 | }; 12 | 13 | export enum IpcMessageTypes { 14 | COMMAND = 'command', 15 | NOTIFICATION = 'notification', 16 | NEW_SIGNER = 'new_signer', 17 | NEW_ADDRESS_LOOKUP_TABLE = 'new_address_lookup_table', 18 | TRANSACTION = 'transaction', 19 | CONFIRM_TRANSACTION = 'confirm_transaction', 20 | SET_TRANSACTIONS_ENABLED = 'set_transactions_enabled', 21 | METRICS = 'metrics', 22 | } 23 | 24 | export type Serializable = 25 | | string 26 | | number 27 | | boolean 28 | | null 29 | | Serializable[] 30 | | { [key: string]: Serializable }; 31 | 32 | export type IpcMessageMap = { 33 | [IpcMessageTypes.COMMAND]: { 34 | command: string; 35 | }; 36 | [IpcMessageTypes.NOTIFICATION]: { 37 | message: string; 38 | }; 39 | [IpcMessageTypes.NEW_SIGNER]: { 40 | signerKey: string; 41 | signerInfo: string; 42 | }; 43 | [IpcMessageTypes.NEW_ADDRESS_LOOKUP_TABLE]: { 44 | address: string; 45 | }; 46 | [IpcMessageTypes.TRANSACTION]: { 47 | ixsSerialized: object[]; 48 | /// deserialized instructions 49 | ixs: TransactionInstruction[]; 50 | /// true to simulate the tx before retrying. 51 | simOnRetry: boolean; 52 | /// true to simulate the tx with CUs 53 | simulateCUs: boolean; 54 | /// instructs the TxThread to retry this tx with a new blockhash until it goes through 55 | retryUntilConfirmed: boolean; 56 | /// ID of signer(s) for the tx. Needs to be added via IpcMessageTypes.NEW_SIGNER before, otherwise will throw. 57 | signerKeys: string[]; 58 | /// Pubkey of address lookup tables for the tx. Needs to be added via IpcMessageTypes.NEW_ADDRESS_LOOKUP_TABLE before, otherwise will throw. 59 | addressLookupTables: string[]; 60 | /// New signers for the tx, use this instead of sending a IpcMessageTypes.NEW_SIGNER 61 | newSigners: { key: string; info: string }[]; 62 | }; 63 | [IpcMessageTypes.CONFIRM_TRANSACTION]: { 64 | /// transaction signature to confirm, signer for the tx must be previously registered with IpcMessageTypes.NEW_SIGNER, or passed in newSigners 65 | confirmTxSig: string; 66 | /// New signers for the tx, use this instead of sending a IpcMessageTypes.NEW_SIGNER 67 | newSigners: { key: string; info: string }[]; 68 | }; 69 | [IpcMessageTypes.SET_TRANSACTIONS_ENABLED]: { 70 | txEnabled: boolean; 71 | }; 72 | [IpcMessageTypes.METRICS]: TxSenderMetrics; 73 | }; 74 | 75 | export type IpcMessage = IpcMessageMap[keyof IpcMessageMap]; 76 | 77 | export type TxSenderMetrics = { 78 | txLanded: number; 79 | txAttempted: number; 80 | txDroppedTimeout: number; 81 | txDroppedBlockhashExpired: number; 82 | txFailedSimulation: number; 83 | txRetried: number; 84 | lruEvictedTxs: number; 85 | pendingQueueSize: number; 86 | confirmQueueSize: number; 87 | txConfirmRateLimited: number; 88 | txEnabled: number; 89 | txConfirmedFromWs: number; 90 | }; 91 | 92 | /// States for transactions going through the txThread 93 | export enum TxState { 94 | /// the tx is queued and has not been sent yet 95 | QUEUED = 'queued', 96 | /// the tx has been sent at least once 97 | RETRYING = 'retrying', 98 | /// the tx has failed (blockhash expired) 99 | FAILED = 'failed', 100 | /// the tx has landed (confirmed) 101 | LANDED = 'landed', 102 | } 103 | 104 | export type TransactionPayload = { 105 | instruction: IpcMessageMap[IpcMessageTypes.TRANSACTION]; 106 | retries?: number; 107 | blockhashUsed?: string; 108 | blockhashExpiryHeight?: number; 109 | lastSentTxSig?: string; 110 | lastSentTx?: VersionedTransaction; 111 | lastSentRawTx?: Buffer | Uint8Array; 112 | lastSendTs?: number; 113 | addressLookupTables?: AddressLookupTableAccount[]; 114 | }; 115 | 116 | export function deserializedIx(ix: any): TransactionInstruction { 117 | let keys = ix['keys'] as any[]; 118 | if (keys.length > 0) { 119 | keys = keys.map((k: any) => { 120 | return { 121 | pubkey: new PublicKey(k['pubkey'] as string), 122 | isSigner: k['isSigner'] as boolean, 123 | isWritable: k['isWritable'] as boolean, 124 | }; 125 | }); 126 | } 127 | return { 128 | keys, 129 | programId: new PublicKey(ix['programId']), 130 | data: Buffer.from(ix['data']), 131 | }; 132 | } 133 | -------------------------------------------------------------------------------- /src/bots/common/txLogParse.ts: -------------------------------------------------------------------------------- 1 | export function isIxLog(log: string): boolean { 2 | const match = log.match(new RegExp('Program log: Instruction:')); 3 | 4 | return match !== null; 5 | } 6 | 7 | export function isEndIxLog(programId: string, log: string): boolean { 8 | const match = log.match( 9 | new RegExp( 10 | `Program ${programId} consumed ([0-9]+) of ([0-9]+) compute units` 11 | ) 12 | ); 13 | 14 | return match !== null; 15 | } 16 | 17 | export function isFillIxLog(log: string): boolean { 18 | const match = log.match( 19 | new RegExp('Program log: Instruction: Fill(.*)Order') 20 | ); 21 | 22 | return match !== null; 23 | } 24 | 25 | export function isArbIxLog(log: string): boolean { 26 | const match = log.match(new RegExp('Program log: Instruction: ArbPerp')); 27 | 28 | return match !== null; 29 | } 30 | 31 | export function isOrderDoesNotExistLog(log: string): number | null { 32 | const match = log.match(new RegExp('.*Order does not exist ([0-9]+)')); 33 | 34 | if (!match) { 35 | return null; 36 | } 37 | 38 | return parseInt(match[1]); 39 | } 40 | 41 | export function isMakerOrderDoesNotExistLog(log: string): number | null { 42 | const match = log.match(new RegExp('.*Maker has no order id ([0-9]+)')); 43 | 44 | if (!match) { 45 | return null; 46 | } 47 | 48 | return parseInt(match[1]); 49 | } 50 | 51 | /** 52 | * parses a maker breached maintenance margin log, returns the maker's userAccount pubkey if it exists 53 | * @param log 54 | * @returns the maker's userAccount pubkey if it exists, null otherwise 55 | */ 56 | export function isMakerBreachedMaintenanceMarginLog( 57 | log: string 58 | ): string | null { 59 | const regex = 60 | /.*maker \(([1-9A-HJ-NP-Za-km-z]+)\) breached (maintenance|fill) requirements.*$/; 61 | const match = log.match(regex); 62 | 63 | return match ? match[1] : null; 64 | } 65 | 66 | export function isTakerBreachedMaintenanceMarginLog(log: string): boolean { 67 | const match = log.match( 68 | new RegExp('.*taker breached (maintenance|fill) requirements.*') 69 | ); 70 | 71 | return match !== null; 72 | } 73 | 74 | export function isErrFillingLog(log: string): [string, string] | null { 75 | const match = log.match( 76 | new RegExp('.*Err filling order id ([0-9]+) for user ([a-zA-Z0-9]+)') 77 | ); 78 | 79 | if (!match) { 80 | return null; 81 | } 82 | 83 | return [match[1], match[2]]; 84 | } 85 | 86 | export function isErrArb(log: string): boolean { 87 | const match = log.match(new RegExp('.*NoArbOpportunity*')); 88 | 89 | if (!match) { 90 | return false; 91 | } 92 | 93 | return true; 94 | } 95 | 96 | export function isErrArbNoBid(log: string): boolean { 97 | const match = log.match(new RegExp('.*NoBestBid*')); 98 | 99 | if (!match) { 100 | return false; 101 | } 102 | 103 | return true; 104 | } 105 | 106 | export function isErrArbNoAsk(log: string): boolean { 107 | const match = log.match(new RegExp('.*NoBestAsk*')); 108 | 109 | if (!match) { 110 | return false; 111 | } 112 | 113 | return true; 114 | } 115 | 116 | export function isErrStaleOracle(log: string): boolean { 117 | const match = log.match(new RegExp('.*Invalid Oracle: Stale.*')); 118 | 119 | if (!match) { 120 | return false; 121 | } 122 | 123 | return true; 124 | } 125 | -------------------------------------------------------------------------------- /src/bots/common/txThreaded.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '../../logger'; 2 | import { GaugeValue, Metrics } from '../../metrics'; 3 | import { spawnChildWithRetry } from './processUtils'; 4 | import { 5 | IpcMessageMap, 6 | IpcMessageTypes, 7 | WrappedIpcMessage, 8 | TxSenderMetrics, 9 | } from './threads/types'; 10 | import { TransactionInstruction } from '@solana/web3.js'; 11 | import { ChildProcess, SendHandle, Serializable } from 'child_process'; 12 | import path from 'path'; 13 | 14 | export class TxThreaded { 15 | protected txThreadProcess?: ChildProcess; 16 | protected txSendMetricsGauge?: GaugeValue; 17 | protected _lastTxMetrics?: TxSenderMetrics; 18 | protected _lastTxThreadMetricsReceived: number; 19 | private _botIdString: string; 20 | 21 | constructor(botIdString?: string) { 22 | this._botIdString = botIdString ?? ''; 23 | this._lastTxThreadMetricsReceived = 0; 24 | } 25 | get lastTxThreadMetricsReceived(): number { 26 | return this._lastTxThreadMetricsReceived; 27 | } 28 | 29 | get pendingQueueSize(): number { 30 | return this.pendingQueueSize; 31 | } 32 | 33 | public txThreadSetName(name: string) { 34 | this._botIdString = name; 35 | } 36 | 37 | /** 38 | * Sends a notification to the txThread to close and waits for it to exit 39 | */ 40 | protected async terminateTxThread() { 41 | if (!this.txThreadProcess) { 42 | logger.info(`[TxThreaded] No txThread process to terminate`); 43 | return; 44 | } 45 | 46 | this.txThreadProcess.send({ 47 | type: IpcMessageTypes.NOTIFICATION, 48 | data: { 49 | message: 'close', 50 | }, 51 | }); 52 | 53 | // terminate child txThread 54 | logger.info(`[TxThreaded] Waiting 5s for txThread to close...`); 55 | for (let i = 0; i < 5; i++) { 56 | if ( 57 | this.txThreadProcess.exitCode !== null || 58 | this.txThreadProcess.killed 59 | ) { 60 | break; 61 | } 62 | logger.info(`[TxThreaded] Child thread still alive ...`); 63 | await new Promise((resolve) => setTimeout(resolve, 1000)); 64 | } 65 | 66 | if (!this.txThreadProcess.killed) { 67 | logger.info(`[TxThreaded] Child thread still alive 🔪...`); 68 | this.txThreadProcess.kill(); 69 | } 70 | logger.info( 71 | `[TxThreaded] Child thread exit code: ${this.txThreadProcess.exitCode}` 72 | ); 73 | } 74 | 75 | protected initializeTxThreadMetrics(metrics: Metrics, meterName: string) { 76 | this.txSendMetricsGauge = metrics.addGauge( 77 | 'tx_sender_metrics', 78 | 'TxSender thread metrics', 79 | meterName 80 | ); 81 | } 82 | 83 | /** 84 | * Spawns the txThread process 85 | */ 86 | protected initTxThread(rpcUrl?: string) { 87 | // @ts-ignore - This is how to check for tsx unfortunately https://github.com/privatenumber/tsx/issues/49 88 | const isTsx: boolean = process._preload_modules.some((m: string) => 89 | m.includes('tsx') 90 | ); 91 | const isTsNode = process.argv.some((arg) => arg.includes('ts-node')); 92 | const isBun = process.versions.bun; 93 | const isTs = isTsNode || isTsx || isBun; 94 | const txThreadFileName = isTs ? 'txThread.ts' : 'txThread.js'; 95 | 96 | logger.info( 97 | `[TxThreaded] isTsNode or tsx: ${isTs}, txThreadFileName: ${txThreadFileName}, argv: ${JSON.stringify( 98 | process.argv 99 | )}, __dirname: ${__dirname}, __filename: ${__filename}` 100 | ); 101 | 102 | const rpcEndpoint = 103 | rpcUrl ?? process.env.ENDPOINT ?? process.env.RPC_HTTP_URL; 104 | 105 | if (!rpcEndpoint) { 106 | throw new Error( 107 | 'Must supply a Solana RPC endpoint through config file or env vars ENDPOINT or RPC_HTTP_URL' 108 | ); 109 | } 110 | 111 | // {@link ./threads/txThread.ts} 112 | this.txThreadProcess = spawnChildWithRetry( 113 | path.join( 114 | __dirname, 115 | isTs ? 'threads' : './bots/common/threads', 116 | txThreadFileName 117 | ), 118 | [`--rpc=${rpcEndpoint}`, `--send-tx=false`], // initially disable transactions 119 | 'txThread', 120 | (_msg: Serializable, _sendHandle: SendHandle) => { 121 | const msg = _msg as WrappedIpcMessage; 122 | switch (msg.type) { 123 | case IpcMessageTypes.NOTIFICATION: { 124 | const notification = 125 | msg.data as IpcMessageMap[IpcMessageTypes.NOTIFICATION]; 126 | logger.info(`Notification from child: ${notification.message}`); 127 | break; 128 | } 129 | case IpcMessageTypes.METRICS: { 130 | const txMetrics = 131 | msg.data as IpcMessageMap[IpcMessageTypes.METRICS]; 132 | const now = Date.now(); 133 | this._lastTxThreadMetricsReceived = now; 134 | this._lastTxMetrics = txMetrics; 135 | 136 | if (!this.txSendMetricsGauge) { 137 | break; 138 | } 139 | 140 | this.txSendMetricsGauge!.setLatestValue(now, { 141 | metric: 'txMetricsLastTs', 142 | }); 143 | this.txSendMetricsGauge!.setLatestValue(txMetrics.txLanded, { 144 | metric: 'txLanded', 145 | }); 146 | this.txSendMetricsGauge!.setLatestValue(txMetrics.txRetried, { 147 | metric: 'txRetried', 148 | }); 149 | this.txSendMetricsGauge!.setLatestValue(txMetrics.txAttempted, { 150 | metric: 'txAttempted', 151 | }); 152 | this.txSendMetricsGauge!.setLatestValue( 153 | txMetrics.txDroppedBlockhashExpired, 154 | { 155 | metric: 'txDroppedBlockhashExpired', 156 | } 157 | ); 158 | this.txSendMetricsGauge!.setLatestValue(txMetrics.txEnabled, { 159 | metric: 'txEnabled', 160 | }); 161 | this.txSendMetricsGauge!.setLatestValue(txMetrics.lruEvictedTxs, { 162 | metric: 'lruEvictedTxs', 163 | }); 164 | this.txSendMetricsGauge!.setLatestValue( 165 | txMetrics.pendingQueueSize, 166 | { 167 | metric: 'pendingQueueSize', 168 | } 169 | ); 170 | this.txSendMetricsGauge!.setLatestValue( 171 | txMetrics.confirmQueueSize, 172 | { 173 | metric: 'confirmQueueSize', 174 | } 175 | ); 176 | this.txSendMetricsGauge!.setLatestValue( 177 | txMetrics.txFailedSimulation, 178 | { 179 | metric: 'txFailedSimulation', 180 | } 181 | ); 182 | this.txSendMetricsGauge!.setLatestValue( 183 | txMetrics.txConfirmedFromWs, 184 | { 185 | metric: 'txConfirmedFromWs', 186 | } 187 | ); 188 | break; 189 | } 190 | default: { 191 | logger.info( 192 | `Unknown message type from child: ${JSON.stringify(_msg)}` 193 | ); 194 | break; 195 | } 196 | } 197 | }, 198 | `[${this._botIdString}]` 199 | ); 200 | } 201 | 202 | /** 203 | * Sends signer info for the tx thread to sign txs with. 204 | * @param signerKey - public key of the signer, used to id the signer 205 | * @param signerInfo - Raw private key or file to open, to be passed into `loadKeypair` 206 | */ 207 | protected async sendSignerToTxThread(signerKey: string, signerInfo: string) { 208 | if (!this.txThreadProcess) { 209 | logger.error(`[TxThreaded] No txThread process to send signer to`); 210 | return; 211 | } 212 | 213 | this.txThreadProcess.send({ 214 | type: IpcMessageTypes.NEW_SIGNER, 215 | data: { 216 | signerKey, 217 | signerInfo, 218 | }, 219 | }); 220 | } 221 | 222 | /** 223 | * Sends an address lookup table to the tx thread, needed when resigning txs for retry 224 | * @param address - address of the lookup table 225 | */ 226 | protected async sendAddressLutToTxThread(address: string) { 227 | if (!this.txThreadProcess) { 228 | logger.error( 229 | `[TxThreaded] No txThread process to send address lookup table to` 230 | ); 231 | return; 232 | } 233 | 234 | this.txThreadProcess.send({ 235 | type: IpcMessageTypes.NEW_ADDRESS_LOOKUP_TABLE, 236 | data: { 237 | address, 238 | }, 239 | }); 240 | } 241 | 242 | /** 243 | * Sends a transaction to the tx thread to be signed and sent 244 | * @param ixs - instructions to send 245 | * @param signerKeys - public keys of the signers (must previously be registerd with `sendSignerToTxThread`) 246 | * @param addressLookupTables - addresses of the address lookup tables (must previously be registerd with `sendAddressLutToTxThread`) 247 | * @param newSigners - new signers to add (identical to registering with `sendSignerToTxThread`) 248 | */ 249 | protected async sendIxsToTxthread({ 250 | ixs, 251 | signerKeys, 252 | doSimulation, 253 | addressLookupTables = [], 254 | newSigners = [], 255 | }: { 256 | ixs: TransactionInstruction[]; 257 | signerKeys: string[]; 258 | doSimulation: boolean; 259 | addressLookupTables?: string[]; 260 | newSigners?: { key: string; info: string }[]; 261 | }) { 262 | if (!this.txThreadProcess) { 263 | logger.error(`[TxThreaded] No txThread process to send instructions to`); 264 | return; 265 | } 266 | 267 | this.txThreadProcess.send({ 268 | type: IpcMessageTypes.TRANSACTION, 269 | data: { 270 | ixsSerialized: ixs, 271 | doSimulation, 272 | simulateCUs: true, 273 | retryUntilConfirmed: false, 274 | signerKeys, 275 | addressLookupTables, 276 | newSigners, 277 | }, 278 | }); 279 | } 280 | 281 | /** 282 | * Sends a transaction signature to the tx thread to be confirmed. The signer of the tx sig must be previously registered with `sendSignerToTxThread`. 283 | * @param txSig - transaction signature to confirm 284 | * @param signerKeys - public keys of the signers (must previously be registerd with `sendSignerToTxThread`) 285 | * @param addressLookupTables - addresses of the address lookup tables (must previously be registerd with `sendAddressLutToTxThread`) 286 | * @param newSigners - new signers to add (identical to registering with `sendSignerToTxThread`) 287 | */ 288 | protected async registerTxToConfirm({ 289 | txSig, 290 | newSigners = [], 291 | }: { 292 | txSig: string; 293 | newSigners?: { key: string; info: string }[]; 294 | }) { 295 | if (!this.txThreadProcess) { 296 | logger.error(`[TxThreaded] No txThread process to send instructions to`); 297 | return; 298 | } 299 | 300 | this.txThreadProcess.send({ 301 | type: IpcMessageTypes.CONFIRM_TRANSACTION, 302 | data: { 303 | confirmTxSig: txSig, 304 | newSigners, 305 | }, 306 | }); 307 | } 308 | 309 | /** 310 | * Enables or disables transactions for the tx thread 311 | * @param state - true to enable transactions, false to disable 312 | */ 313 | protected async setTxEnabledTxThread(state: boolean) { 314 | if (!this.txThreadProcess) { 315 | logger.error( 316 | `[TxThreaded] No txThread process to set transaction enabled state` 317 | ); 318 | return; 319 | } 320 | 321 | this.txThreadProcess.send({ 322 | type: IpcMessageTypes.SET_TRANSACTIONS_ENABLED, 323 | data: { 324 | txEnabled: state, 325 | }, 326 | }); 327 | } 328 | } 329 | -------------------------------------------------------------------------------- /src/bots/fillerLite.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DriftClient, 3 | BulkAccountLoader, 4 | SlotSubscriber, 5 | OrderSubscriber, 6 | UserAccount, 7 | User, 8 | PriorityFeeSubscriber, 9 | DataAndSlot, 10 | BlockhashSubscriber, 11 | } from '@drift-labs/sdk'; 12 | 13 | import { AddressLookupTableAccount, PublicKey } from '@solana/web3.js'; 14 | 15 | import { logger } from '../logger'; 16 | import { FillerConfig, GlobalConfig } from '../config'; 17 | import { RuntimeSpec } from '../metrics'; 18 | import { webhookMessage } from '../webhook'; 19 | import { FillerBot } from './filler'; 20 | 21 | import { sleepMs } from '../utils'; 22 | import { BundleSender } from '../bundleSender'; 23 | import { PythPriceFeedSubscriber } from '../pythPriceFeedSubscriber'; 24 | 25 | export class FillerLiteBot extends FillerBot { 26 | protected orderSubscriber: OrderSubscriber; 27 | 28 | constructor( 29 | slotSubscriber: SlotSubscriber, 30 | driftClient: DriftClient, 31 | runtimeSpec: RuntimeSpec, 32 | globalConfig: GlobalConfig, 33 | config: FillerConfig, 34 | priorityFeeSubscriber: PriorityFeeSubscriber, 35 | blockhashSubscriber: BlockhashSubscriber, 36 | bundleSender?: BundleSender, 37 | pythPriceFeedSubscriber?: PythPriceFeedSubscriber, 38 | lookupTableAccounts: AddressLookupTableAccount[] = [] 39 | ) { 40 | super( 41 | slotSubscriber, 42 | undefined, 43 | driftClient, 44 | undefined, 45 | runtimeSpec, 46 | globalConfig, 47 | config, 48 | priorityFeeSubscriber, 49 | blockhashSubscriber, 50 | bundleSender, 51 | pythPriceFeedSubscriber, 52 | lookupTableAccounts 53 | ); 54 | 55 | this.userStatsMapSubscriptionConfig = { 56 | type: 'polling', 57 | accountLoader: new BulkAccountLoader( 58 | this.driftClient.connection, 59 | 'processed', // No polling so value is irrelevant 60 | 0 // no polling, just for using mustGet 61 | ), 62 | }; 63 | 64 | this.orderSubscriber = new OrderSubscriber({ 65 | driftClient: this.driftClient, 66 | subscriptionConfig: { 67 | type: 'websocket', 68 | skipInitialLoad: false, 69 | resyncIntervalMs: 10_000, 70 | }, 71 | }); 72 | } 73 | 74 | public async init() { 75 | logger.info(`${this.name} initing`); 76 | await super.baseInit(); 77 | 78 | await this.orderSubscriber.subscribe(); 79 | await sleepMs(1200); // Wait a few slots to build up order book 80 | 81 | await webhookMessage(`[${this.name}]: started`); 82 | } 83 | 84 | public async startIntervalLoop(_intervalMs?: number) { 85 | super.startIntervalLoop(_intervalMs); 86 | } 87 | 88 | public async reset() { 89 | for (const intervalId of this.intervalIds) { 90 | clearInterval(intervalId as NodeJS.Timeout); 91 | } 92 | this.intervalIds = []; 93 | 94 | await this.orderSubscriber.unsubscribe(); 95 | } 96 | 97 | protected async getUserAccountAndSlotFromMap( 98 | key: string 99 | ): Promise> { 100 | if (!this.orderSubscriber.usersAccounts.has(key)) { 101 | const user = new User({ 102 | driftClient: this.driftClient, 103 | userAccountPublicKey: new PublicKey(key), 104 | accountSubscription: { 105 | type: 'polling', 106 | accountLoader: new BulkAccountLoader( 107 | this.driftClient.connection, 108 | 'processed', 109 | 0 110 | ), 111 | }, 112 | }); 113 | await user.subscribe(); 114 | return user.getUserAccountAndSlot()!; 115 | } else { 116 | const userAccount = this.orderSubscriber.usersAccounts.get(key)!; 117 | return { 118 | data: userAccount.userAccount, 119 | slot: userAccount.slot, 120 | }; 121 | } 122 | } 123 | 124 | protected async getDLOB() { 125 | const currentSlot = this.getMaxSlot(); 126 | return await this.orderSubscriber.getDLOB(currentSlot); 127 | } 128 | 129 | protected getMaxSlot(): number { 130 | return Math.max( 131 | this.slotSubscriber.getSlot(), 132 | this.orderSubscriber!.getSlot() 133 | ); 134 | } 135 | 136 | protected logSlots() { 137 | logger.info( 138 | `slotSubscriber slot: ${this.slotSubscriber.getSlot()}, orderSubscriber slot: ${this.orderSubscriber.getSlot()}` 139 | ); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/bots/floatingMaker.ts: -------------------------------------------------------------------------------- 1 | import { 2 | calculateAskPrice, 3 | calculateBidPrice, 4 | BN, 5 | isVariant, 6 | DriftClient, 7 | PerpMarketAccount, 8 | SlotSubscriber, 9 | PositionDirection, 10 | OrderType, 11 | BASE_PRECISION, 12 | Order, 13 | PerpPosition, 14 | } from '@drift-labs/sdk'; 15 | import { Mutex, tryAcquire, E_ALREADY_LOCKED } from 'async-mutex'; 16 | 17 | import { logger } from '../logger'; 18 | import { Bot } from '../types'; 19 | import { RuntimeSpec, metricAttrFromUserAccount } from '../metrics'; 20 | import { PrometheusExporter } from '@opentelemetry/exporter-prometheus'; 21 | import { Counter, Histogram, Meter, ObservableGauge } from '@opentelemetry/api'; 22 | import { 23 | ExplicitBucketHistogramAggregation, 24 | InstrumentType, 25 | MeterProvider, 26 | View, 27 | } from '@opentelemetry/sdk-metrics-base'; 28 | import { BaseBotConfig } from '../config'; 29 | 30 | type State = { 31 | marketPosition: Map; 32 | openOrders: Map>; 33 | }; 34 | 35 | const MARKET_UPDATE_COOLDOWN_SLOTS = 30; // wait slots before updating market position 36 | 37 | enum METRIC_TYPES { 38 | sdk_call_duration_histogram = 'sdk_call_duration_histogram', 39 | try_make_duration_histogram = 'try_make_duration_histogram', 40 | runtime_specs = 'runtime_specs', 41 | mutex_busy = 'mutex_busy', 42 | errors = 'errors', 43 | } 44 | 45 | /** 46 | * 47 | * This bot is responsible for placing limit orders that rest on the DLOB. 48 | * limit price offsets are used to automatically shift the orders with the 49 | * oracle price, making order updating automatic. 50 | * 51 | */ 52 | export class FloatingPerpMakerBot implements Bot { 53 | public readonly name: string; 54 | public readonly dryRun: boolean; 55 | public readonly defaultIntervalMs: number = 5000; 56 | 57 | private driftClient: DriftClient; 58 | private slotSubscriber: SlotSubscriber; 59 | private periodicTaskMutex = new Mutex(); 60 | private lastSlotMarketUpdated: Map = new Map(); 61 | 62 | private intervalIds: Array = []; 63 | 64 | // metrics 65 | private metricsInitialized = false; 66 | private metricsPort?: number; 67 | private exporter?: PrometheusExporter; 68 | private meter?: Meter; 69 | private bootTimeMs = Date.now(); 70 | private runtimeSpecsGauge?: ObservableGauge; 71 | private runtimeSpec?: RuntimeSpec; 72 | private mutexBusyCounter?: Counter; 73 | private tryMakeDurationHistogram?: Histogram; 74 | 75 | private agentState?: State; 76 | 77 | /** 78 | * Set true to enforce max position size 79 | */ 80 | private RESTRICT_POSITION_SIZE = false; 81 | 82 | /** 83 | * if a position's notional value passes this percentage of account 84 | * collateral, the position enters a CLOSING_* state. 85 | */ 86 | private MAX_POSITION_EXPOSURE = 0.1; 87 | 88 | /** 89 | * The max amount of quote to spend on each order. 90 | */ 91 | private MAX_TRADE_SIZE_QUOTE = 1000; 92 | 93 | private watchdogTimerMutex = new Mutex(); 94 | private watchdogTimerLastPatTime = Date.now(); 95 | 96 | constructor( 97 | clearingHouse: DriftClient, 98 | slotSubscriber: SlotSubscriber, 99 | runtimeSpec: RuntimeSpec, 100 | config: BaseBotConfig 101 | ) { 102 | this.name = config.botId; 103 | this.dryRun = config.dryRun; 104 | this.driftClient = clearingHouse; 105 | this.slotSubscriber = slotSubscriber; 106 | 107 | this.metricsPort = config.metricsPort; 108 | if (this.metricsPort) { 109 | this.initializeMetrics(); 110 | } 111 | } 112 | 113 | private initializeMetrics() { 114 | if (this.metricsInitialized) { 115 | logger.error('Tried to initilaize metrics multiple times'); 116 | return; 117 | } 118 | this.metricsInitialized = true; 119 | 120 | const { endpoint: defaultEndpoint } = PrometheusExporter.DEFAULT_OPTIONS; 121 | this.exporter = new PrometheusExporter( 122 | { 123 | port: this.metricsPort, 124 | endpoint: defaultEndpoint, 125 | }, 126 | () => { 127 | logger.info( 128 | `prometheus scrape endpoint started: http://localhost:${this.metricsPort}${defaultEndpoint}` 129 | ); 130 | } 131 | ); 132 | const meterName = this.name; 133 | const meterProvider = new MeterProvider({ 134 | views: [ 135 | new View({ 136 | instrumentName: METRIC_TYPES.try_make_duration_histogram, 137 | instrumentType: InstrumentType.HISTOGRAM, 138 | meterName: meterName, 139 | aggregation: new ExplicitBucketHistogramAggregation( 140 | Array.from(new Array(20), (_, i) => 0 + i * 5), 141 | true 142 | ), 143 | }), 144 | ], 145 | }); 146 | 147 | meterProvider.addMetricReader(this.exporter); 148 | this.meter = meterProvider.getMeter(meterName); 149 | 150 | this.bootTimeMs = Date.now(); 151 | 152 | this.runtimeSpecsGauge = this.meter.createObservableGauge( 153 | METRIC_TYPES.runtime_specs, 154 | { 155 | description: 'Runtime sepcification of this program', 156 | } 157 | ); 158 | this.runtimeSpecsGauge.addCallback((obs) => { 159 | obs.observe(this.bootTimeMs, this.runtimeSpec); 160 | }); 161 | this.mutexBusyCounter = this.meter.createCounter(METRIC_TYPES.mutex_busy, { 162 | description: 'Count of times the mutex was busy', 163 | }); 164 | this.tryMakeDurationHistogram = this.meter.createHistogram( 165 | METRIC_TYPES.try_make_duration_histogram, 166 | { 167 | description: 'Distribution of tryTrigger', 168 | unit: 'ms', 169 | } 170 | ); 171 | } 172 | 173 | public async init() { 174 | logger.info(`${this.name} initing`); 175 | this.agentState = { 176 | marketPosition: new Map(), 177 | openOrders: new Map>(), 178 | }; 179 | this.updateAgentState(); 180 | } 181 | 182 | public async reset() { 183 | for (const intervalId of this.intervalIds) { 184 | clearInterval(intervalId as NodeJS.Timeout); 185 | } 186 | this.intervalIds = []; 187 | } 188 | 189 | public async startIntervalLoop(intervalMs?: number) { 190 | await this.updateOpenOrders(); 191 | const intervalId = setInterval( 192 | this.updateOpenOrders.bind(this), 193 | intervalMs 194 | ); 195 | this.intervalIds.push(intervalId); 196 | 197 | logger.info(`${this.name} Bot started!`); 198 | } 199 | 200 | public async healthCheck(): Promise { 201 | let healthy = false; 202 | await this.watchdogTimerMutex.runExclusive(async () => { 203 | healthy = 204 | this.watchdogTimerLastPatTime > Date.now() - 2 * this.defaultIntervalMs; 205 | }); 206 | return healthy; 207 | } 208 | 209 | /** 210 | * Updates the agent state based on its current market positions. 211 | * 212 | * We want to maintain a two-sided market while being conscious of the positions 213 | * taken on by the account. 214 | * 215 | * As open positions approach MAX_POSITION_EXPOSURE, limit orders are skewed such 216 | * that the position that decreases risk will be closer to the oracle price, and the 217 | * position that increases risk will be further from the oracle price. 218 | * 219 | * @returns {Promise} 220 | */ 221 | private updateAgentState(): void { 222 | this.driftClient.getUserAccount()!.perpPositions.map((p) => { 223 | if (p.baseAssetAmount.isZero()) { 224 | return; 225 | } 226 | this.agentState!.marketPosition.set(p.marketIndex, p); 227 | }); 228 | 229 | // zero out the open orders 230 | for (const market of this.driftClient.getPerpMarketAccounts()) { 231 | this.agentState!.openOrders.set(market.marketIndex, []); 232 | } 233 | 234 | this.driftClient.getUserAccount()!.orders.map((o) => { 235 | if (isVariant(o.status, 'init')) { 236 | return; 237 | } 238 | const marketIndex = o.marketIndex; 239 | this.agentState!.openOrders.set(marketIndex, [ 240 | ...(this.agentState!.openOrders.get(marketIndex) ?? []), 241 | o, 242 | ]); 243 | }); 244 | } 245 | 246 | private async updateOpenOrdersForMarket(marketAccount: PerpMarketAccount) { 247 | const currSlot = this.slotSubscriber.currentSlot; 248 | const marketIndex = marketAccount.marketIndex; 249 | const nextUpdateSlot = 250 | this.lastSlotMarketUpdated.get(marketIndex) ?? 251 | 0 + MARKET_UPDATE_COOLDOWN_SLOTS; 252 | 253 | if (nextUpdateSlot > currSlot) { 254 | return; 255 | } 256 | 257 | const openOrders = this.agentState!.openOrders.get(marketIndex) || []; 258 | const oracle = this.driftClient.getOracleDataForPerpMarket(marketIndex); 259 | const vAsk = calculateAskPrice(marketAccount, oracle); 260 | const vBid = calculateBidPrice(marketAccount, oracle); 261 | 262 | // cancel orders if not quoting both sides of the market 263 | let placeNewOrders = openOrders.length === 0; 264 | 265 | if ( 266 | (openOrders.length > 0 && openOrders.length != 2) || 267 | marketIndex === 0 268 | ) { 269 | // cancel orders 270 | for (const o of openOrders) { 271 | const tx = await this.driftClient.cancelOrder(o.orderId); 272 | console.log( 273 | `${this.name} cancelling order ${this.driftClient 274 | .getUserAccount()! 275 | .authority.toBase58()}-${o.orderId}: ${tx}` 276 | ); 277 | } 278 | placeNewOrders = true; 279 | } 280 | 281 | if (placeNewOrders) { 282 | const biasNum = new BN(90); 283 | const biasDenom = new BN(100); 284 | 285 | const oracleBidSpread = oracle.price.sub(vBid); 286 | const tx0 = await this.driftClient.placePerpOrder({ 287 | marketIndex: marketIndex, 288 | orderType: OrderType.LIMIT, 289 | direction: PositionDirection.LONG, 290 | baseAssetAmount: BASE_PRECISION.mul(new BN(1)), 291 | oraclePriceOffset: oracleBidSpread 292 | .mul(biasNum) 293 | .div(biasDenom) 294 | .neg() 295 | .toNumber(), // limit bid below oracle 296 | }); 297 | console.log(`${this.name} placing long: ${tx0}`); 298 | 299 | const oracleAskSpread = vAsk.sub(oracle.price); 300 | const tx1 = await this.driftClient.placePerpOrder({ 301 | marketIndex: marketIndex, 302 | orderType: OrderType.LIMIT, 303 | direction: PositionDirection.SHORT, 304 | baseAssetAmount: BASE_PRECISION.mul(new BN(1)), 305 | oraclePriceOffset: oracleAskSpread 306 | .mul(biasNum) 307 | .div(biasDenom) 308 | .toNumber(), // limit ask above oracle 309 | }); 310 | console.log(`${this.name} placing short: ${tx1}`); 311 | } 312 | 313 | // enforce cooldown on market 314 | this.lastSlotMarketUpdated.set(marketIndex, currSlot); 315 | } 316 | 317 | private async updateOpenOrders() { 318 | const start = Date.now(); 319 | let ran = false; 320 | try { 321 | await tryAcquire(this.periodicTaskMutex).runExclusive(async () => { 322 | this.updateAgentState(); 323 | await Promise.all( 324 | this.driftClient.getPerpMarketAccounts().map((marketAccount) => { 325 | console.log( 326 | `${this.name} updating open orders for market ${marketAccount.marketIndex}` 327 | ); 328 | this.updateOpenOrdersForMarket(marketAccount); 329 | }) 330 | ); 331 | 332 | ran = true; 333 | }); 334 | } catch (e) { 335 | if (e === E_ALREADY_LOCKED) { 336 | const user = this.driftClient.getUser(); 337 | this.mutexBusyCounter!.add( 338 | 1, 339 | metricAttrFromUserAccount( 340 | user.getUserAccountPublicKey(), 341 | user.getUserAccount() 342 | ) 343 | ); 344 | } else { 345 | throw e; 346 | } 347 | } finally { 348 | if (ran) { 349 | const duration = Date.now() - start; 350 | const user = this.driftClient.getUser(); 351 | if (this.tryMakeDurationHistogram) { 352 | this.tryMakeDurationHistogram!.record( 353 | duration, 354 | metricAttrFromUserAccount( 355 | user.getUserAccountPublicKey(), 356 | user.getUserAccount() 357 | ) 358 | ); 359 | } 360 | logger.debug(`${this.name} Bot took ${Date.now() - start}ms to run`); 361 | 362 | await this.watchdogTimerMutex.runExclusive(async () => { 363 | this.watchdogTimerLastPatTime = Date.now(); 364 | }); 365 | } 366 | } 367 | } 368 | } 369 | -------------------------------------------------------------------------------- /src/bots/fundingRateUpdater.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DriftClient, 3 | ZERO, 4 | PerpMarketAccount, 5 | isOneOfVariant, 6 | getVariant, 7 | isOperationPaused, 8 | PerpOperation, 9 | decodeName, 10 | PublicKey, 11 | PriorityFeeSubscriberMap, 12 | DriftMarketInfo, 13 | isVariant, 14 | } from '@drift-labs/sdk'; 15 | import { Mutex } from 'async-mutex'; 16 | 17 | import { getErrorCode, getErrorCodeFromSimError } from '../error'; 18 | import { logger } from '../logger'; 19 | import { Bot } from '../types'; 20 | import { webhookMessage } from '../webhook'; 21 | import { BaseBotConfig } from '../config'; 22 | import { 23 | getDriftPriorityFeeEndpoint, 24 | simulateAndGetTxWithCUs, 25 | sleepMs, 26 | } from '../utils'; 27 | import { 28 | AddressLookupTableAccount, 29 | ComputeBudgetProgram, 30 | TransactionExpiredBlockheightExceededError, 31 | } from '@solana/web3.js'; 32 | 33 | const errorCodesToSuppress = [ 34 | 6040, 35 | 6251, // FundingWasNotUpdated 36 | 6096, // AMMNotUpdatedInSameSlot 37 | ]; 38 | const errorCodesCanRetry = [ 39 | 6096, // AMMNotUpdatedInSameSlot 40 | ]; 41 | const CU_EST_MULTIPLIER = 1.4; 42 | 43 | function onTheHourUpdate( 44 | now: number, 45 | lastUpdateTs: number, 46 | updatePeriod: number 47 | ): number | Error { 48 | const timeSinceLastUpdate = now - lastUpdateTs; 49 | 50 | if (timeSinceLastUpdate < 0) { 51 | return new Error('Invalid arguments'); 52 | } 53 | 54 | let nextUpdateWait = updatePeriod; 55 | if (updatePeriod > 1) { 56 | const lastUpdateDelay = lastUpdateTs % updatePeriod; 57 | if (lastUpdateDelay !== 0) { 58 | const maxDelayForNextPeriod = updatePeriod / 3; 59 | const twoFundingPeriods = updatePeriod * 2; 60 | 61 | if (lastUpdateDelay > maxDelayForNextPeriod) { 62 | // too late for on the hour next period, delay to following period 63 | nextUpdateWait = twoFundingPeriods - lastUpdateDelay; 64 | } else { 65 | // allow update on the hour 66 | nextUpdateWait = updatePeriod - lastUpdateDelay; 67 | } 68 | 69 | if (nextUpdateWait > twoFundingPeriods) { 70 | nextUpdateWait -= updatePeriod; 71 | } 72 | } 73 | } 74 | 75 | return Math.max(nextUpdateWait - timeSinceLastUpdate, 0); 76 | } 77 | 78 | export class FundingRateUpdaterBot implements Bot { 79 | public readonly name: string; 80 | public readonly dryRun: boolean; 81 | public readonly runOnce: boolean; 82 | public readonly defaultIntervalMs: number = 120000; // run every 2 min 83 | 84 | private driftClient: DriftClient; 85 | private intervalIds: Array = []; 86 | private priorityFeeSubscriberMap?: PriorityFeeSubscriberMap; 87 | private lookupTableAccounts?: AddressLookupTableAccount[]; 88 | 89 | private watchdogTimerMutex = new Mutex(); 90 | private watchdogTimerLastPatTime = Date.now(); 91 | private inProgress: boolean = false; 92 | 93 | constructor(driftClient: DriftClient, config: BaseBotConfig) { 94 | this.name = config.botId; 95 | this.dryRun = config.dryRun; 96 | this.driftClient = driftClient; 97 | this.runOnce = config.runOnce ?? false; 98 | } 99 | 100 | public async init() { 101 | const driftMarkets: DriftMarketInfo[] = []; 102 | for (const perpMarket of this.driftClient.getPerpMarketAccounts()) { 103 | driftMarkets.push({ 104 | marketType: 'perp', 105 | marketIndex: perpMarket.marketIndex, 106 | }); 107 | } 108 | this.priorityFeeSubscriberMap = new PriorityFeeSubscriberMap({ 109 | driftPriorityFeeEndpoint: getDriftPriorityFeeEndpoint('mainnet-beta'), 110 | driftMarkets, 111 | frequencyMs: 10_000, 112 | }); 113 | await this.priorityFeeSubscriberMap!.subscribe(); 114 | 115 | this.lookupTableAccounts = 116 | await this.driftClient.fetchAllLookupTableAccounts(); 117 | logger.info(`[${this.name}] inited`); 118 | } 119 | 120 | public async reset() { 121 | for (const intervalId of this.intervalIds) { 122 | clearInterval(intervalId as NodeJS.Timeout); 123 | } 124 | this.intervalIds = []; 125 | } 126 | 127 | public async startIntervalLoop(intervalMs?: number): Promise { 128 | logger.info(`[${this.name}] Bot started! runOnce ${this.runOnce}`); 129 | 130 | if (this.runOnce) { 131 | await this.tryUpdateFundingRate(); 132 | } else { 133 | await this.tryUpdateFundingRate(); 134 | const intervalId = setInterval( 135 | this.tryUpdateFundingRate.bind(this), 136 | intervalMs 137 | ); 138 | this.intervalIds.push(intervalId); 139 | } 140 | } 141 | 142 | public async healthCheck(): Promise { 143 | let healthy = false; 144 | await this.watchdogTimerMutex.runExclusive(async () => { 145 | healthy = 146 | this.watchdogTimerLastPatTime > 147 | Date.now() - 10 * this.defaultIntervalMs; 148 | }); 149 | return healthy; 150 | } 151 | 152 | private async tryUpdateFundingRate() { 153 | if (this.inProgress) { 154 | logger.info( 155 | `[${this.name}] UpdateFundingRate already in progress, skipping...` 156 | ); 157 | return; 158 | } 159 | const start = Date.now(); 160 | try { 161 | this.inProgress = true; 162 | 163 | const perpMarketAndOracleData: { 164 | [marketIndex: number]: { 165 | marketAccount: PerpMarketAccount; 166 | }; 167 | } = {}; 168 | 169 | for (const marketAccount of this.driftClient.getPerpMarketAccounts()) { 170 | perpMarketAndOracleData[marketAccount.marketIndex] = { 171 | marketAccount, 172 | }; 173 | } 174 | 175 | for ( 176 | let i = 0; 177 | i < this.driftClient.getPerpMarketAccounts().length; 178 | i++ 179 | ) { 180 | const perpMarket = perpMarketAndOracleData[i].marketAccount; 181 | isOneOfVariant; 182 | if (isOneOfVariant(perpMarket.status, ['initialized'])) { 183 | logger.info( 184 | `[${this.name}] Skipping perp market ${ 185 | perpMarket.marketIndex 186 | } because market status = ${getVariant(perpMarket.status)}` 187 | ); 188 | continue; 189 | } 190 | 191 | const fundingPaused = isOperationPaused( 192 | perpMarket.pausedOperations, 193 | PerpOperation.UPDATE_FUNDING 194 | ); 195 | if (fundingPaused) { 196 | const marketStr = decodeName(perpMarket.name); 197 | logger.warn( 198 | `[${this.name}] Update funding paused for market: ${perpMarket.marketIndex} ${marketStr}, skipping` 199 | ); 200 | continue; 201 | } 202 | 203 | if (perpMarket.amm.fundingPeriod.eq(ZERO)) { 204 | continue; 205 | } 206 | const currentTs = Date.now() / 1000; 207 | 208 | const timeRemainingTilUpdate = onTheHourUpdate( 209 | currentTs, 210 | perpMarket.amm.lastFundingRateTs.toNumber(), 211 | perpMarket.amm.fundingPeriod.toNumber() 212 | ); 213 | logger.info( 214 | `[${this.name}] Perp market ${perpMarket.marketIndex} timeRemainingTilUpdate=${timeRemainingTilUpdate}` 215 | ); 216 | if ((timeRemainingTilUpdate as number) <= 0) { 217 | logger.info( 218 | `[${this.name}] Perp market ${ 219 | perpMarket.marketIndex 220 | } lastFundingRateTs: ${perpMarket.amm.lastFundingRateTs.toString()}, fundingPeriod: ${perpMarket.amm.fundingPeriod.toString()}, lastFunding+Period: ${perpMarket.amm.lastFundingRateTs 221 | .add(perpMarket.amm.fundingPeriod) 222 | .toString()} vs. currTs: ${currentTs.toString()}` 223 | ); 224 | this.sendTxWithRetry(perpMarket.marketIndex, perpMarket.amm.oracle); 225 | } 226 | } 227 | } catch (e) { 228 | console.error(e); 229 | if (e instanceof Error) { 230 | await webhookMessage( 231 | `[${this.name}]: :x: uncaught error:\n${ 232 | e.stack ? e.stack : e.message 233 | }` 234 | ); 235 | } 236 | } finally { 237 | this.inProgress = false; 238 | logger.info( 239 | `[${this.name}] Update Funding Rates finished in ${ 240 | Date.now() - start 241 | }ms` 242 | ); 243 | await this.watchdogTimerMutex.runExclusive(async () => { 244 | this.watchdogTimerLastPatTime = Date.now(); 245 | }); 246 | } 247 | } 248 | 249 | private async sendTxs( 250 | microLamports: number, 251 | marketIndex: number, 252 | oracle: PublicKey 253 | ): Promise<{ success: boolean; canRetry: boolean }> { 254 | const ixs = [ 255 | ComputeBudgetProgram.setComputeUnitLimit({ 256 | units: 1_400_000, // simulateAndGetTxWithCUs will overwrite 257 | }), 258 | ComputeBudgetProgram.setComputeUnitPrice({ 259 | microLamports, 260 | }), 261 | ]; 262 | const perpMarket = this.driftClient.getPerpMarketAccount(marketIndex); 263 | if (isVariant(perpMarket?.amm.oracleSource, 'switchboardOnDemand')) { 264 | const crankIx = 265 | await this.driftClient.getPostSwitchboardOnDemandUpdateAtomicIx( 266 | perpMarket!.amm.oracle 267 | ); 268 | if (crankIx) { 269 | ixs.push(crankIx); 270 | } 271 | } 272 | ixs.push( 273 | await this.driftClient.getUpdateFundingRateIx(marketIndex, oracle) 274 | ); 275 | 276 | const recentBlockhash = 277 | await this.driftClient.connection.getLatestBlockhash('confirmed'); 278 | const simResult = await simulateAndGetTxWithCUs({ 279 | ixs, 280 | connection: this.driftClient.connection, 281 | payerPublicKey: this.driftClient.wallet.publicKey, 282 | lookupTableAccounts: this.lookupTableAccounts!, 283 | cuLimitMultiplier: CU_EST_MULTIPLIER, 284 | doSimulation: true, 285 | recentBlockhash: recentBlockhash.blockhash, 286 | }); 287 | logger.info( 288 | `[${this.name}] UpdateFundingRate estimated ${simResult.cuEstimate} CUs for market: ${marketIndex}` 289 | ); 290 | 291 | if (simResult.simError !== null) { 292 | const errorCode = getErrorCodeFromSimError(simResult.simError); 293 | if (errorCode && errorCodesToSuppress.includes(errorCode)) { 294 | logger.error( 295 | `[${ 296 | this.name 297 | }] Sim error (suppressed) on market: ${marketIndex}, code: ${errorCode} ${JSON.stringify( 298 | simResult.simError 299 | )}` 300 | ); 301 | } else { 302 | logger.error( 303 | `[${ 304 | this.name 305 | }] Sim error (not suppressed) on market: ${marketIndex}, code: ${errorCode}: ${JSON.stringify( 306 | simResult.simError 307 | )}\n${simResult.simTxLogs ? simResult.simTxLogs.join('\n') : ''}` 308 | ); 309 | } 310 | 311 | if (errorCode && errorCodesCanRetry.includes(errorCode)) { 312 | return { success: false, canRetry: true }; 313 | } else { 314 | return { success: false, canRetry: false }; 315 | } 316 | } 317 | 318 | const sendTxStart = Date.now(); 319 | const txSig = await this.driftClient.txSender.sendVersionedTransaction( 320 | simResult.tx, 321 | [], 322 | this.driftClient.opts 323 | ); 324 | logger.info( 325 | `[${ 326 | this.name 327 | }] UpdateFundingRate for market: ${marketIndex}, tx sent in ${ 328 | Date.now() - sendTxStart 329 | }ms: https://solana.fm/tx/${txSig.txSig}` 330 | ); 331 | 332 | return { success: true, canRetry: false }; 333 | } 334 | 335 | private async sendTxWithRetry(marketIndex: number, oracle: PublicKey) { 336 | const pfs = this.priorityFeeSubscriberMap!.getPriorityFees( 337 | 'perp', 338 | marketIndex 339 | ); 340 | let microLamports = 10_000; 341 | if (pfs) { 342 | microLamports = Math.floor(pfs.medium); 343 | } 344 | 345 | const maxRetries = 30; 346 | 347 | for (let i = 0; i < maxRetries; i++) { 348 | try { 349 | logger.info( 350 | `[${ 351 | this.name 352 | }] Funding rate update on market ${marketIndex}, attempt: ${ 353 | i + 1 354 | }/${maxRetries}` 355 | ); 356 | const result = await this.sendTxs(microLamports, marketIndex, oracle); 357 | if (result.success) { 358 | break; 359 | } 360 | if (result.canRetry) { 361 | logger.info(`[${this.name}] Retrying market ${marketIndex} in 1s...`); 362 | await sleepMs(1000); 363 | continue; 364 | } else { 365 | break; 366 | } 367 | } catch (e: any) { 368 | const err = e as Error; 369 | const errorCode = getErrorCode(err); 370 | logger.error( 371 | `[${this.name}] Error code: ${errorCode} while updating funding rates on perp marketIndex=${marketIndex}: ${err.message}` 372 | ); 373 | if (err instanceof TransactionExpiredBlockheightExceededError) { 374 | logger.info( 375 | `[${this.name}] Blockhash expired for market: ${marketIndex}, retrying in 1s...` 376 | ); 377 | await sleepMs(1000); 378 | continue; 379 | } else if (errorCode && !errorCodesToSuppress.includes(errorCode)) { 380 | logger.error( 381 | `[${this.name}] Unsuppressed error for market: ${marketIndex}, not retrying.` 382 | ); 383 | console.error(err); 384 | break; 385 | } 386 | } 387 | } 388 | } 389 | } 390 | -------------------------------------------------------------------------------- /src/bots/ifRevenueSettler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DriftClient, 3 | SpotMarketAccount, 4 | OraclePriceData, 5 | ZERO, 6 | PriorityFeeSubscriberMap, 7 | DriftMarketInfo, 8 | } from '@drift-labs/sdk'; 9 | import { Mutex } from 'async-mutex'; 10 | 11 | import { getErrorCode } from '../error'; 12 | import { logger } from '../logger'; 13 | import { Bot } from '../types'; 14 | import { webhookMessage } from '../webhook'; 15 | import { BaseBotConfig } from '../config'; 16 | import { 17 | getDriftPriorityFeeEndpoint, 18 | simulateAndGetTxWithCUs, 19 | sleepS, 20 | } from '../utils'; 21 | import { 22 | AddressLookupTableAccount, 23 | ComputeBudgetProgram, 24 | } from '@solana/web3.js'; 25 | 26 | const MAX_SETTLE_WAIT_TIME_S = 10 * 60; // 10 minutes 27 | 28 | const errorCodesToSuppress = [ 29 | 6177, // NoRevenueToSettleToIF 30 | 6176, // RevenueSettingsCannotSettleToIF 31 | ]; 32 | 33 | export class IFRevenueSettlerBot implements Bot { 34 | public readonly name: string; 35 | public readonly dryRun: boolean; 36 | public readonly runOnce: boolean; 37 | public readonly defaultIntervalMs: number = 600000; 38 | private priorityFeeSubscriberMap?: PriorityFeeSubscriberMap; 39 | 40 | private driftClient: DriftClient; 41 | private intervalIds: Array = []; 42 | 43 | private watchdogTimerMutex = new Mutex(); 44 | private watchdogTimerLastPatTime = Date.now(); 45 | private lookupTableAccounts?: AddressLookupTableAccount[]; 46 | 47 | constructor(driftClient: DriftClient, config: BaseBotConfig) { 48 | this.name = config.botId; 49 | this.dryRun = config.dryRun; 50 | this.runOnce = config.runOnce || false; 51 | this.driftClient = driftClient; 52 | } 53 | 54 | public async init() { 55 | logger.info(`${this.name} initing`); 56 | 57 | await this.driftClient.subscribe(); 58 | 59 | const driftMarkets: DriftMarketInfo[] = []; 60 | for (const spotMarket of this.driftClient.getSpotMarketAccounts()) { 61 | driftMarkets.push({ 62 | marketType: 'spot', 63 | marketIndex: spotMarket.marketIndex, 64 | }); 65 | } 66 | 67 | this.priorityFeeSubscriberMap = new PriorityFeeSubscriberMap({ 68 | driftPriorityFeeEndpoint: getDriftPriorityFeeEndpoint('mainnet-beta'), 69 | driftMarkets, 70 | frequencyMs: 10_000, 71 | }); 72 | await this.priorityFeeSubscriberMap!.subscribe(); 73 | 74 | if (!(await this.driftClient.getUser().exists())) { 75 | throw new Error( 76 | `User for ${this.driftClient.wallet.publicKey.toString()} does not exist` 77 | ); 78 | } 79 | 80 | this.lookupTableAccounts = 81 | await this.driftClient.fetchAllLookupTableAccounts(); 82 | } 83 | 84 | public async reset() { 85 | await this.priorityFeeSubscriberMap!.unsubscribe(); 86 | await this.driftClient.unsubscribe(); 87 | for (const intervalId of this.intervalIds) { 88 | clearInterval(intervalId as NodeJS.Timeout); 89 | } 90 | this.intervalIds = []; 91 | } 92 | 93 | public async startIntervalLoop(intervalMs?: number): Promise { 94 | logger.info(`${this.name} Bot started!`); 95 | if (this.runOnce) { 96 | await this.trySettleIFRevenue(); 97 | } else { 98 | const intervalId = setInterval( 99 | this.trySettleIFRevenue.bind(this), 100 | intervalMs 101 | ); 102 | this.intervalIds.push(intervalId); 103 | } 104 | } 105 | 106 | public async healthCheck(): Promise { 107 | let healthy = false; 108 | await this.watchdogTimerMutex.runExclusive(async () => { 109 | healthy = 110 | this.watchdogTimerLastPatTime > Date.now() - 2 * this.defaultIntervalMs; 111 | }); 112 | return healthy; 113 | } 114 | 115 | private async settleIFRevenue(spotMarketIndex: number) { 116 | try { 117 | const pfs = this.priorityFeeSubscriberMap!.getPriorityFees( 118 | 'spot', 119 | spotMarketIndex 120 | ); 121 | let microLamports = 10_000; 122 | if (pfs) { 123 | microLamports = Math.floor(pfs.medium); 124 | } 125 | const ixs = [ 126 | ComputeBudgetProgram.setComputeUnitLimit({ 127 | units: 1_400_000, // simulateAndGetTxWithCUs will overwrite 128 | }), 129 | ComputeBudgetProgram.setComputeUnitPrice({ 130 | microLamports, 131 | }), 132 | ]; 133 | ixs.push( 134 | await this.driftClient.getSettleRevenueToInsuranceFundIx( 135 | spotMarketIndex 136 | ) 137 | ); 138 | 139 | const recentBlockhash = 140 | await this.driftClient.connection.getLatestBlockhash('confirmed'); 141 | const simResult = await simulateAndGetTxWithCUs({ 142 | ixs, 143 | connection: this.driftClient.connection, 144 | payerPublicKey: this.driftClient.wallet.publicKey, 145 | lookupTableAccounts: this.lookupTableAccounts!, 146 | cuLimitMultiplier: 1.1, 147 | doSimulation: true, 148 | recentBlockhash: recentBlockhash.blockhash, 149 | }); 150 | logger.info( 151 | `settleRevenueToInsuranceFund on spot market ${spotMarketIndex} estimated to take ${simResult.cuEstimate} CUs.` 152 | ); 153 | if (simResult.simError !== null) { 154 | logger.error( 155 | `Sim error: ${JSON.stringify(simResult.simError)}\n${ 156 | simResult.simTxLogs ? simResult.simTxLogs.join('\n') : '' 157 | }` 158 | ); 159 | } else { 160 | const sendTxStart = Date.now(); 161 | const txSig = await this.driftClient.txSender.sendVersionedTransaction( 162 | simResult.tx, 163 | [], 164 | this.driftClient.opts 165 | ); 166 | logger.info( 167 | `Settle IF Revenue for spot market ${spotMarketIndex} tx sent in ${ 168 | Date.now() - sendTxStart 169 | }ms: https://solana.fm/tx/${txSig.txSig}` 170 | ); 171 | } 172 | } catch (e: any) { 173 | const err = e as Error; 174 | const errorCode = getErrorCode(err); 175 | logger.error( 176 | `Error code: ${errorCode} while settling revenue to IF for marketIndex=${spotMarketIndex}: ${err.message}` 177 | ); 178 | console.error(err); 179 | 180 | if (errorCode && !errorCodesToSuppress.includes(errorCode)) { 181 | await webhookMessage( 182 | `[${ 183 | this.name 184 | }]: :x: Error code: ${errorCode} while settling revenue to IF for marketIndex=${spotMarketIndex}:\n${ 185 | e.logs ? (e.logs as Array).join('\n') : '' 186 | }\n${err.stack ? err.stack : err.message}` 187 | ); 188 | } 189 | } 190 | } 191 | 192 | private async trySettleIFRevenue() { 193 | try { 194 | const spotMarketAndOracleData: { 195 | [marketIndex: number]: { 196 | marketAccount: SpotMarketAccount; 197 | oraclePriceData: OraclePriceData; 198 | }; 199 | } = {}; 200 | 201 | for (const marketAccount of this.driftClient.getSpotMarketAccounts()) { 202 | spotMarketAndOracleData[marketAccount.marketIndex] = { 203 | marketAccount, 204 | oraclePriceData: this.driftClient.getOracleDataForSpotMarket( 205 | marketAccount.marketIndex 206 | ), 207 | }; 208 | } 209 | 210 | const ifSettlePromises = []; 211 | for ( 212 | let i = 0; 213 | i < this.driftClient.getSpotMarketAccounts().length; 214 | i++ 215 | ) { 216 | const spotMarketAccount = spotMarketAndOracleData[i].marketAccount; 217 | const spotIf = spotMarketAccount.insuranceFund; 218 | if ( 219 | spotIf.revenueSettlePeriod.eq(ZERO) || 220 | spotMarketAccount.revenuePool.scaledBalance.eq(ZERO) 221 | ) { 222 | continue; 223 | } 224 | const currentTs = Date.now() / 1000; 225 | 226 | // add 1 sec buffer 227 | const timeUntilSettle = 228 | spotIf.lastRevenueSettleTs.toNumber() + 229 | spotIf.revenueSettlePeriod.toNumber() - 230 | currentTs + 231 | 1; 232 | 233 | if (timeUntilSettle <= MAX_SETTLE_WAIT_TIME_S) { 234 | ifSettlePromises.push( 235 | (async () => { 236 | logger.info( 237 | `IF revenue settling on market ${i} in ${timeUntilSettle} seconds` 238 | ); 239 | await sleepS(timeUntilSettle); 240 | await this.settleIFRevenue(i); 241 | })() 242 | ); 243 | } else { 244 | logger.info( 245 | `Too long to wait (${timeUntilSettle} seconds) to settle IF for marke market ${i}, skipping...` 246 | ); 247 | } 248 | } 249 | 250 | await Promise.all(ifSettlePromises); 251 | } catch (e: any) { 252 | console.error(e); 253 | const err = e as Error; 254 | if ( 255 | !err.message.includes('Transaction was not confirmed') && 256 | !err.message.includes('Blockhash not found') 257 | ) { 258 | const errorCode = getErrorCode(err); 259 | await webhookMessage( 260 | `[${ 261 | this.name 262 | }]: :x: IF Revenue Settler error: Error code: ${errorCode}:\n${ 263 | e.logs ? (e.logs as Array).join('\n') : '' 264 | }\n${err.stack ? err.stack : err.message}` 265 | ); 266 | } 267 | } finally { 268 | logger.info('Settle IF Revenues finished'); 269 | await this.watchdogTimerMutex.runExclusive(async () => { 270 | this.watchdogTimerLastPatTime = Date.now(); 271 | }); 272 | } 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /src/bots/pythLazerCranker.ts: -------------------------------------------------------------------------------- 1 | import { Bot } from '../types'; 2 | import { logger } from '../logger'; 3 | import { GlobalConfig, PythLazerCrankerBotConfig } from '../config'; 4 | import { PriceUpdateAccount } from '@pythnetwork/pyth-solana-receiver/lib/PythSolanaReceiver'; 5 | import { 6 | BlockhashSubscriber, 7 | DevnetPerpMarkets, 8 | DevnetSpotMarkets, 9 | DriftClient, 10 | getPythLazerOraclePublicKey, 11 | getVariant, 12 | MainnetPerpMarkets, 13 | MainnetSpotMarkets, 14 | PriorityFeeSubscriber, 15 | TxSigAndSlot, 16 | } from '@drift-labs/sdk'; 17 | import { 18 | AddressLookupTableAccount, 19 | ComputeBudgetProgram, 20 | } from '@solana/web3.js'; 21 | import { 22 | chunks, 23 | getVersionedTransaction, 24 | simulateAndGetTxWithCUs, 25 | sleepMs, 26 | } from '../utils'; 27 | import { Agent, setGlobalDispatcher } from 'undici'; 28 | import { PythLazerSubscriber } from '../pythLazerSubscriber'; 29 | 30 | setGlobalDispatcher( 31 | new Agent({ 32 | connections: 200, 33 | }) 34 | ); 35 | 36 | const SIM_CU_ESTIMATE_MULTIPLIER = 1.5; 37 | const DEFAULT_INTEVAL_MS = 30000; 38 | 39 | export class PythLazerCrankerBot implements Bot { 40 | private pythLazerClient: PythLazerSubscriber; 41 | readonly decodeFunc: (name: string, data: Buffer) => PriceUpdateAccount; 42 | 43 | public name: string; 44 | public dryRun: boolean; 45 | public defaultIntervalMs?; 46 | 47 | private blockhashSubscriber: BlockhashSubscriber; 48 | private health: boolean = true; 49 | 50 | constructor( 51 | private globalConfig: GlobalConfig, 52 | private crankConfigs: PythLazerCrankerBotConfig, 53 | private driftClient: DriftClient, 54 | private priorityFeeSubscriber?: PriorityFeeSubscriber, 55 | private lookupTableAccounts: AddressLookupTableAccount[] = [] 56 | ) { 57 | this.name = crankConfigs.botId; 58 | this.dryRun = crankConfigs.dryRun; 59 | this.defaultIntervalMs = crankConfigs.intervalMs ?? DEFAULT_INTEVAL_MS; 60 | 61 | if (this.globalConfig.useJito) { 62 | throw new Error('Jito is not supported for pyth lazer cranker'); 63 | } 64 | 65 | let feedIdChunks: number[][] = []; 66 | if (!this.crankConfigs.pythLazerIds) { 67 | const spotMarkets = 68 | this.globalConfig.driftEnv === 'mainnet-beta' 69 | ? MainnetSpotMarkets 70 | : DevnetSpotMarkets; 71 | const perpMarkets = 72 | this.globalConfig.driftEnv === 'mainnet-beta' 73 | ? MainnetPerpMarkets 74 | : DevnetPerpMarkets; 75 | 76 | const allFeedIds: number[] = []; 77 | for (const market of [...spotMarkets, ...perpMarkets]) { 78 | if ( 79 | (this.crankConfigs.onlyCrankUsedOracles && 80 | !getVariant(market.oracleSource).toLowerCase().includes('lazer')) || 81 | market.pythLazerId == undefined 82 | ) 83 | continue; 84 | allFeedIds.push(market.pythLazerId!); 85 | } 86 | const allFeedIdsSet = new Set(allFeedIds); 87 | feedIdChunks = chunks(Array.from(allFeedIdsSet), 11); 88 | } else { 89 | feedIdChunks = chunks(Array.from(this.crankConfigs.pythLazerIds), 11); 90 | } 91 | console.log(feedIdChunks); 92 | 93 | if (!this.globalConfig.lazerEndpoints || !this.globalConfig.lazerToken) { 94 | throw new Error('Missing lazerEndpoint or lazerToken in global config'); 95 | } 96 | 97 | console.log(this.crankConfigs.pythLazerChannel); 98 | this.pythLazerClient = new PythLazerSubscriber( 99 | this.globalConfig.lazerEndpoints, 100 | this.globalConfig.lazerToken, 101 | feedIdChunks, 102 | this.globalConfig.driftEnv, 103 | undefined, 104 | undefined, 105 | undefined, 106 | this.crankConfigs.pythLazerChannel ?? 'fixed_rate@200ms' 107 | ); 108 | this.decodeFunc = 109 | this.driftClient.program.account.pythLazerOracle.coder.accounts.decodeUnchecked.bind( 110 | this.driftClient.program.account.pythLazerOracle.coder.accounts 111 | ); 112 | 113 | this.blockhashSubscriber = new BlockhashSubscriber({ 114 | connection: driftClient.connection, 115 | }); 116 | } 117 | 118 | async init(): Promise { 119 | logger.info(`Initializing ${this.name} bot`); 120 | await this.blockhashSubscriber.subscribe(); 121 | this.lookupTableAccounts.push( 122 | ...(await this.driftClient.fetchAllLookupTableAccounts()) 123 | ); 124 | 125 | await this.pythLazerClient.subscribe(); 126 | 127 | this.priorityFeeSubscriber?.updateAddresses( 128 | this.pythLazerClient.allSubscribedIds.map((feedId) => 129 | getPythLazerOraclePublicKey( 130 | this.driftClient.program.programId, 131 | Number(feedId) 132 | ) 133 | ) 134 | ); 135 | } 136 | 137 | async reset(): Promise { 138 | logger.info(`Resetting ${this.name} bot`); 139 | this.blockhashSubscriber.unsubscribe(); 140 | await this.driftClient.unsubscribe(); 141 | this.pythLazerClient.unsubscribe(); 142 | } 143 | 144 | async startIntervalLoop(intervalMs = this.defaultIntervalMs): Promise { 145 | logger.info(`Starting ${this.name} bot with interval ${intervalMs} ms`); 146 | await sleepMs(5000); 147 | await this.runCrankLoop(); 148 | 149 | setInterval(async () => { 150 | await this.runCrankLoop(); 151 | }, intervalMs); 152 | } 153 | 154 | private async getBlockhashForTx(): Promise { 155 | const cachedBlockhash = this.blockhashSubscriber.getLatestBlockhash(10); 156 | if (cachedBlockhash) { 157 | return cachedBlockhash.blockhash as string; 158 | } 159 | 160 | const recentBlockhash = 161 | await this.driftClient.connection.getLatestBlockhash({ 162 | commitment: 'confirmed', 163 | }); 164 | 165 | return recentBlockhash.blockhash; 166 | } 167 | 168 | async runCrankLoop() { 169 | for (const [ 170 | feedIdsStr, 171 | priceMessage, 172 | ] of this.pythLazerClient.feedIdChunkToPriceMessage.entries()) { 173 | const feedIds = this.pythLazerClient.getPriceFeedIdsFromHash(feedIdsStr); 174 | const ixs = [ 175 | ComputeBudgetProgram.setComputeUnitLimit({ 176 | units: 30_000, 177 | }), 178 | ]; 179 | const priorityFees = Math.floor( 180 | (this.priorityFeeSubscriber?.getCustomStrategyResult() || 0) * 181 | this.driftClient.txSender.getSuggestedPriorityFeeMultiplier() 182 | ); 183 | logger.info( 184 | `Priority fees to use: ${priorityFees} with multiplier: ${this.driftClient.txSender.getSuggestedPriorityFeeMultiplier()}` 185 | ); 186 | ixs.push( 187 | ComputeBudgetProgram.setComputeUnitPrice({ 188 | microLamports: priorityFees, 189 | }) 190 | ); 191 | const pythLazerIxs = 192 | await this.driftClient.getPostPythLazerOracleUpdateIxs( 193 | feedIds, 194 | priceMessage, 195 | ixs 196 | ); 197 | ixs.push(...pythLazerIxs); 198 | 199 | if (!this.crankConfigs.skipSimulation) { 200 | ixs[0] = ComputeBudgetProgram.setComputeUnitLimit({ 201 | units: 1_400_000, 202 | }); 203 | const simResult = await simulateAndGetTxWithCUs({ 204 | ixs, 205 | connection: this.driftClient.connection, 206 | payerPublicKey: this.driftClient.wallet.publicKey, 207 | lookupTableAccounts: this.lookupTableAccounts, 208 | cuLimitMultiplier: SIM_CU_ESTIMATE_MULTIPLIER, 209 | doSimulation: true, 210 | recentBlockhash: await this.getBlockhashForTx(), 211 | }); 212 | if (simResult.simError) { 213 | logger.error( 214 | `Error simulating pyth lazer oracles for ${feedIds}: ${simResult.simTxLogs}` 215 | ); 216 | continue; 217 | } 218 | const startTime = Date.now(); 219 | this.driftClient 220 | .sendTransaction(simResult.tx) 221 | .then((txSigAndSlot: TxSigAndSlot) => { 222 | logger.info( 223 | `Posted pyth lazer oracles for ${feedIds} update atomic tx: ${ 224 | txSigAndSlot.txSig 225 | }, took ${Date.now() - startTime}ms` 226 | ); 227 | }) 228 | .catch((e) => { 229 | console.log(e); 230 | }); 231 | } else { 232 | const startTime = Date.now(); 233 | const tx = getVersionedTransaction( 234 | this.driftClient.wallet.publicKey, 235 | ixs, 236 | this.lookupTableAccounts, 237 | await this.getBlockhashForTx() 238 | ); 239 | this.driftClient 240 | .sendTransaction(tx) 241 | .then((txSigAndSlot: TxSigAndSlot) => { 242 | logger.info( 243 | `Posted pyth lazer oracles for ${feedIds} update atomic tx: ${ 244 | txSigAndSlot.txSig 245 | }, took ${Date.now() - startTime}ms` 246 | ); 247 | }) 248 | .catch((e) => { 249 | console.log(e); 250 | }); 251 | } 252 | } 253 | } 254 | 255 | async healthCheck(): Promise { 256 | return this.health; 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /src/bots/switchboardCranker.ts: -------------------------------------------------------------------------------- 1 | import { Bot } from '../types'; 2 | import { logger } from '../logger'; 3 | import { GlobalConfig, SwitchboardCrankerBotConfig } from '../config'; 4 | import { 5 | BlockhashSubscriber, 6 | DriftClient, 7 | PriorityFeeSubscriber, 8 | SlothashSubscriber, 9 | TxSigAndSlot, 10 | } from '@drift-labs/sdk'; 11 | import { BundleSender } from '../bundleSender'; 12 | import { 13 | AddressLookupTableAccount, 14 | ComputeBudgetProgram, 15 | PublicKey, 16 | TransactionInstruction, 17 | } from '@solana/web3.js'; 18 | import { chunks, getVersionedTransaction, shuffle, sleepMs } from '../utils'; 19 | import { Agent, setGlobalDispatcher } from 'undici'; 20 | 21 | setGlobalDispatcher( 22 | new Agent({ 23 | connections: 200, 24 | }) 25 | ); 26 | 27 | // ref: https://solscan.io/tx/Z5X334CFBmzbzxXHgfa49UVbMdLZf7nJdDCekjaZYinpykVqgTm47VZphazocMjYe1XJtEyeiL6QgrmvLeMesMA 28 | const MIN_CU_LIMIT = 50_000; 29 | 30 | export class SwitchboardCrankerBot implements Bot { 31 | public name: string; 32 | public dryRun: boolean; 33 | public defaultIntervalMs: number; 34 | 35 | private blockhashSubscriber: BlockhashSubscriber; 36 | private slothashSubscriber: SlothashSubscriber; 37 | 38 | constructor( 39 | private globalConfig: GlobalConfig, 40 | private crankConfigs: SwitchboardCrankerBotConfig, 41 | private driftClient: DriftClient, 42 | private priorityFeeSubscriber?: PriorityFeeSubscriber, 43 | private bundleSender?: BundleSender, 44 | private lookupTableAccounts: AddressLookupTableAccount[] = [] 45 | ) { 46 | this.name = crankConfigs.botId; 47 | this.dryRun = crankConfigs.dryRun; 48 | this.defaultIntervalMs = crankConfigs.intervalMs || 10_000; 49 | this.blockhashSubscriber = new BlockhashSubscriber({ 50 | connection: driftClient.connection, 51 | }); 52 | 53 | this.slothashSubscriber = new SlothashSubscriber( 54 | this.driftClient.connection, 55 | { 56 | commitment: 'confirmed', 57 | } 58 | ); 59 | } 60 | 61 | async init(): Promise { 62 | logger.info(`Initializing ${this.name} bot`); 63 | await this.blockhashSubscriber.subscribe(); 64 | this.lookupTableAccounts.push( 65 | ...(await this.driftClient.fetchAllLookupTableAccounts()) 66 | ); 67 | await this.slothashSubscriber.subscribe(); 68 | 69 | const writableAccounts = 70 | this.crankConfigs.writableAccounts && 71 | this.crankConfigs.writableAccounts.length > 0 72 | ? this.crankConfigs.writableAccounts.map((acc) => new PublicKey(acc)) 73 | : []; 74 | this.priorityFeeSubscriber?.updateAddresses([ 75 | ...Object.entries(this.crankConfigs.pullFeedConfigs).map( 76 | ([_alias, config]) => { 77 | return new PublicKey(config.pubkey); 78 | } 79 | ), 80 | ...writableAccounts, 81 | ]); 82 | } 83 | 84 | async reset(): Promise { 85 | logger.info(`Resetting ${this.name} bot`); 86 | this.blockhashSubscriber.unsubscribe(); 87 | await this.driftClient.unsubscribe(); 88 | } 89 | 90 | async startIntervalLoop(intervalMs = this.defaultIntervalMs): Promise { 91 | logger.info(`Starting ${this.name} bot with interval ${intervalMs} ms`); 92 | await sleepMs(5000); 93 | await this.runCrankLoop(); 94 | 95 | setInterval(async () => { 96 | await this.runCrankLoop(); 97 | }, intervalMs); 98 | } 99 | 100 | private async getBlockhashForTx(): Promise { 101 | const cachedBlockhash = this.blockhashSubscriber.getLatestBlockhash(10); 102 | if (cachedBlockhash) { 103 | return cachedBlockhash.blockhash as string; 104 | } 105 | 106 | const recentBlockhash = 107 | await this.driftClient.connection.getLatestBlockhash({ 108 | commitment: 'confirmed', 109 | }); 110 | 111 | return recentBlockhash.blockhash; 112 | } 113 | 114 | private shouldBuildForBundle(): boolean { 115 | if (!this.globalConfig.useJito) { 116 | return false; 117 | } 118 | if (!this.bundleSender?.connected()) { 119 | return false; 120 | } 121 | return true; 122 | } 123 | 124 | async runCrankLoop() { 125 | const pullFeedAliases = chunks( 126 | shuffle(Object.keys(this.crankConfigs.pullFeedConfigs)), 127 | 5 128 | ); 129 | for (const aliasChunk of pullFeedAliases) { 130 | try { 131 | console.log(aliasChunk); 132 | const switchboardIxs = 133 | await this.driftClient.getPostManySwitchboardOnDemandUpdatesAtomicIxs( 134 | aliasChunk.map( 135 | (alias) => 136 | new PublicKey(this.crankConfigs.pullFeedConfigs[alias].pubkey) 137 | ) 138 | ); 139 | if (!switchboardIxs) { 140 | logger.error(`No switchboardIxs for ${aliasChunk}`); 141 | continue; 142 | } 143 | const ixs = [ 144 | ...switchboardIxs, 145 | ComputeBudgetProgram.setComputeUnitLimit({ 146 | units: MIN_CU_LIMIT, 147 | }), 148 | ]; 149 | 150 | const shouldBuildForBundle = this.shouldBuildForBundle(); 151 | if (shouldBuildForBundle) { 152 | ixs.push(this.bundleSender!.getTipIx()); 153 | } else { 154 | const priorityFees = 155 | this.priorityFeeSubscriber?.getHeliusPriorityFeeLevel() || 0; 156 | ixs.push( 157 | ComputeBudgetProgram.setComputeUnitPrice({ 158 | microLamports: Math.floor(priorityFees), 159 | }) 160 | ); 161 | } 162 | 163 | const tx = getVersionedTransaction( 164 | this.driftClient.wallet.publicKey, 165 | ixs, 166 | this.lookupTableAccounts, 167 | await this.getBlockhashForTx() 168 | ); 169 | 170 | if (shouldBuildForBundle) { 171 | tx.sign([ 172 | // @ts-ignore; 173 | this.driftClient.wallet.payer, 174 | ]); 175 | this.bundleSender?.sendTransactions( 176 | [tx], 177 | undefined, 178 | undefined, 179 | false 180 | ); 181 | } else { 182 | this.driftClient 183 | .sendTransaction(tx) 184 | .then((txSigAndSlot: TxSigAndSlot) => { 185 | logger.info( 186 | `Posted update sb atomic tx for ${aliasChunk}: ${txSigAndSlot.txSig}` 187 | ); 188 | }) 189 | .catch((e) => { 190 | console.log(e); 191 | }); 192 | } 193 | } catch (e) { 194 | logger.error(`Error processing alias ${aliasChunk}: ${e}`); 195 | } 196 | } 197 | } 198 | 199 | async getPullIx(alias: string): Promise { 200 | const pubkey = new PublicKey( 201 | this.crankConfigs.pullFeedConfigs[alias].pubkey 202 | ); 203 | const pullIx = 204 | await this.driftClient.getPostSwitchboardOnDemandUpdateAtomicIx( 205 | pubkey, 206 | this.slothashSubscriber.currentSlothash 207 | ); 208 | if (!pullIx) { 209 | logger.error(`No pullIx for ${alias}`); 210 | return; 211 | } 212 | return pullIx; 213 | } 214 | 215 | async healthCheck(): Promise { 216 | return true; 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/bots/userIdleFlipper.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BN, 3 | DriftClient, 4 | UserAccount, 5 | PublicKey, 6 | UserMap, 7 | TxSigAndSlot, 8 | BlockhashSubscriber, 9 | } from '@drift-labs/sdk'; 10 | import { Mutex } from 'async-mutex'; 11 | 12 | import { logger } from '../logger'; 13 | import { Bot } from '../types'; 14 | import { BaseBotConfig } from '../config'; 15 | import { 16 | AddressLookupTableAccount, 17 | ComputeBudgetProgram, 18 | } from '@solana/web3.js'; 19 | import { simulateAndGetTxWithCUs, sleepMs } from '../utils'; 20 | 21 | const USER_IDLE_CHUNKS = 9; 22 | const SLEEP_MS = 1000; 23 | const CACHED_BLOCKHASH_OFFSET = 5; 24 | 25 | export class UserIdleFlipperBot implements Bot { 26 | public readonly name: string; 27 | public readonly dryRun: boolean; 28 | public readonly runOnce: boolean; 29 | public readonly defaultIntervalMs: number = 600000; 30 | 31 | private driftClient: DriftClient; 32 | private lookupTableAccounts?: AddressLookupTableAccount[]; 33 | private intervalIds: Array = []; 34 | private userMap: UserMap; 35 | private blockhashSubscriber: BlockhashSubscriber; 36 | 37 | private watchdogTimerMutex = new Mutex(); 38 | private watchdogTimerLastPatTime = Date.now(); 39 | 40 | constructor( 41 | driftClient: DriftClient, 42 | config: BaseBotConfig, 43 | blockhashSubscriber: BlockhashSubscriber 44 | ) { 45 | this.name = config.botId; 46 | this.dryRun = config.dryRun; 47 | this.runOnce = config.runOnce || false; 48 | this.driftClient = driftClient; 49 | this.userMap = new UserMap({ 50 | driftClient: this.driftClient, 51 | subscriptionConfig: { 52 | type: 'polling', 53 | frequency: 60_000, 54 | commitment: this.driftClient.opts?.commitment, 55 | }, 56 | skipInitialLoad: false, 57 | includeIdle: false, 58 | }); 59 | this.blockhashSubscriber = blockhashSubscriber; 60 | } 61 | 62 | public async init() { 63 | logger.info(`${this.name} initing`); 64 | 65 | await this.driftClient.subscribe(); 66 | if (!(await this.driftClient.getUser().exists())) { 67 | throw new Error( 68 | `User for ${this.driftClient.wallet.publicKey.toString()} does not exist` 69 | ); 70 | } 71 | await this.userMap.subscribe(); 72 | this.lookupTableAccounts = 73 | await this.driftClient.fetchAllLookupTableAccounts(); 74 | } 75 | 76 | public async reset() { 77 | for (const intervalId of this.intervalIds) { 78 | clearInterval(intervalId as NodeJS.Timeout); 79 | } 80 | this.intervalIds = []; 81 | 82 | await this.userMap?.unsubscribe(); 83 | } 84 | 85 | public async startIntervalLoop(intervalMs?: number): Promise { 86 | logger.info(`${this.name} Bot started!`); 87 | if (this.runOnce) { 88 | await this.tryIdleUsers(); 89 | } else { 90 | const intervalId = setInterval(this.tryIdleUsers.bind(this), intervalMs); 91 | this.intervalIds.push(intervalId); 92 | } 93 | } 94 | 95 | public async healthCheck(): Promise { 96 | let healthy = false; 97 | await this.watchdogTimerMutex.runExclusive(async () => { 98 | healthy = 99 | this.watchdogTimerLastPatTime > Date.now() - 2 * this.defaultIntervalMs; 100 | }); 101 | return healthy; 102 | } 103 | 104 | private async tryIdleUsers() { 105 | try { 106 | console.log('tryIdleUsers'); 107 | const currentSlot = await this.driftClient.connection.getSlot(); 108 | const usersToIdle: Array<[PublicKey, UserAccount]> = []; 109 | for (const user of this.userMap.values()) { 110 | // dont mark isolated pool users idle 111 | if (user.getUserAccount().poolId !== 0) { 112 | continue; 113 | } 114 | 115 | if (user.canMakeIdle(new BN(currentSlot))) { 116 | usersToIdle.push([ 117 | user.getUserAccountPublicKey(), 118 | user.getUserAccount(), 119 | ]); 120 | logger.info( 121 | `Can idle user ${user.getUserAccount().authority.toBase58()}` 122 | ); 123 | } 124 | } 125 | logger.info(`Found ${usersToIdle.length} users to idle`); 126 | 127 | for (let i = 0; i < usersToIdle.length; i += USER_IDLE_CHUNKS) { 128 | const usersChunk = usersToIdle.slice(i, i + USER_IDLE_CHUNKS); 129 | await this.trySendTxforChunk(usersChunk); 130 | } 131 | } catch (err) { 132 | console.error(err); 133 | if (!(err instanceof Error)) { 134 | return; 135 | } 136 | } finally { 137 | logger.info('UserIdleSettler finished'); 138 | await this.watchdogTimerMutex.runExclusive(async () => { 139 | this.watchdogTimerLastPatTime = Date.now(); 140 | }); 141 | } 142 | } 143 | 144 | private async trySendTxforChunk( 145 | usersChunk: Array<[PublicKey, UserAccount]> 146 | ): Promise { 147 | const success = await this.sendTxforChunk(usersChunk); 148 | if (!success) { 149 | const slice = usersChunk.length / 2; 150 | if (slice < 1) { 151 | return; 152 | } 153 | await sleepMs(SLEEP_MS); 154 | await this.trySendTxforChunk(usersChunk.slice(0, slice)); 155 | await sleepMs(SLEEP_MS); 156 | await this.trySendTxforChunk(usersChunk.slice(slice)); 157 | } 158 | await sleepMs(SLEEP_MS); 159 | } 160 | 161 | private async getBlockhashForTx(): Promise { 162 | const cachedBlockhash = this.blockhashSubscriber.getLatestBlockhash( 163 | CACHED_BLOCKHASH_OFFSET 164 | ); 165 | if (cachedBlockhash) { 166 | return cachedBlockhash.blockhash as string; 167 | } 168 | 169 | const recentBlockhash = 170 | await this.driftClient.connection.getLatestBlockhash({ 171 | commitment: 'finalized', 172 | }); 173 | if (!recentBlockhash) { 174 | throw new Error('No recent blockhash found??'); 175 | } 176 | 177 | return recentBlockhash.blockhash; 178 | } 179 | 180 | private async sendTxforChunk( 181 | usersChunk: Array<[PublicKey, UserAccount]> 182 | ): Promise { 183 | if (usersChunk.length === 0) { 184 | return true; 185 | } 186 | 187 | let success = false; 188 | try { 189 | const ixs = [ 190 | ComputeBudgetProgram.setComputeUnitLimit({ 191 | units: 1_400_000, // simulation will ovewrrite this 192 | }), 193 | ComputeBudgetProgram.setComputeUnitPrice({ 194 | microLamports: 50000, 195 | }), 196 | ]; 197 | for (const [userAccountPublicKey, userAccount] of usersChunk) { 198 | ixs.push( 199 | await this.driftClient.getUpdateUserIdleIx( 200 | userAccountPublicKey, 201 | userAccount 202 | ) 203 | ); 204 | } 205 | if (ixs.length === 2) { 206 | throw new Error( 207 | `Tried to send a tx with 0 users, chunkSize: ${usersChunk.length}` 208 | ); 209 | } 210 | 211 | const recentBlockhash = await this.getBlockhashForTx(); 212 | const simResult = await simulateAndGetTxWithCUs({ 213 | ixs, 214 | connection: this.driftClient.connection, 215 | payerPublicKey: this.driftClient.wallet.publicKey, 216 | lookupTableAccounts: this.lookupTableAccounts!, 217 | cuLimitMultiplier: 1.1, 218 | doSimulation: true, 219 | recentBlockhash, 220 | }); 221 | logger.info( 222 | `User idle flipper estimated ${simResult.cuEstimate} CUs for ${usersChunk.length} users.` 223 | ); 224 | 225 | if (simResult.simError !== null) { 226 | logger.error( 227 | `Sim error: ${JSON.stringify(simResult.simError)}\n${ 228 | simResult.simTxLogs ? simResult.simTxLogs.join('\n') : '' 229 | }` 230 | ); 231 | success = false; 232 | } else { 233 | const txSigAndSlot = 234 | await this.driftClient.txSender.sendVersionedTransaction( 235 | simResult.tx, 236 | [], 237 | this.driftClient.opts 238 | ); 239 | this.logTxAndSlotForUsers(txSigAndSlot, usersChunk); 240 | success = true; 241 | } 242 | } catch (e) { 243 | const userKeys = usersChunk 244 | .map(([userAccountPublicKey, _]) => userAccountPublicKey.toBase58()) 245 | .join(', '); 246 | logger.error(`Failed to idle users: ${userKeys}`); 247 | logger.error(e); 248 | } 249 | return success; 250 | } 251 | 252 | private logTxAndSlotForUsers( 253 | txSigAndSlot: TxSigAndSlot, 254 | usersChunk: Array<[PublicKey, UserAccount]> 255 | ) { 256 | const txSig = txSigAndSlot.txSig; 257 | for (const [userAccountPublicKey, _] of usersChunk) { 258 | logger.info( 259 | `Flipped user ${userAccountPublicKey.toBase58()} https://solscan.io/tx/${txSig}` 260 | ); 261 | } 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /src/driftStateWatcher.ts: -------------------------------------------------------------------------------- 1 | import { 2 | decodeName, 3 | DriftClient, 4 | getVariant, 5 | PerpMarketAccount, 6 | SpotMarketAccount, 7 | StateAccount, 8 | } from '@drift-labs/sdk'; 9 | 10 | export type StateChecks = { 11 | /// set true to check for new perp markets 12 | newPerpMarkets: boolean; 13 | /// set true to check for new spot markets 14 | newSpotMarkets: boolean; 15 | /// set true to check for changes in perp market statuses 16 | perpMarketStatus: boolean; 17 | /// set true to check for changes in spot market statuses 18 | spotMarketStatus: boolean; 19 | /// optional callback for if any of the state checks fail 20 | onStateChange?: (message: string, changes: StateChecks) => void; 21 | }; 22 | 23 | export type DriftStateWatcherConfig = { 24 | /// access drift program 25 | driftClient: DriftClient; 26 | /// interval to check for updates 27 | intervalMs: number; 28 | /// state checks to perform 29 | stateChecks: StateChecks; 30 | }; 31 | 32 | /** 33 | * Watches for updates on the DriftClient 34 | */ 35 | export class DriftStateWatcher { 36 | private lastStateAccount?: StateAccount; 37 | private lastSpotMarketAccounts: Map = new Map(); 38 | private lastPerpMarketAccounts: Map = new Map(); 39 | private interval?: NodeJS.Timeout; 40 | 41 | private _lastTriggered: boolean; 42 | private _lastTriggeredStates: StateChecks; 43 | 44 | constructor(private config: DriftStateWatcherConfig) { 45 | this._lastTriggeredStates = { 46 | newPerpMarkets: false, 47 | newSpotMarkets: false, 48 | perpMarketStatus: false, 49 | spotMarketStatus: false, 50 | }; 51 | this._lastTriggered = false; 52 | } 53 | 54 | get triggered() { 55 | return this._lastTriggered; 56 | } 57 | 58 | get triggeredStates(): StateChecks { 59 | return this._lastTriggeredStates; 60 | } 61 | 62 | public subscribe() { 63 | if (!this.config.driftClient.isSubscribed) { 64 | throw new Error( 65 | 'DriftClient must be subscribed before calling DriftStateWatcher.subscribe()' 66 | ); 67 | } 68 | this.lastStateAccount = this.config.driftClient.getStateAccount(); 69 | 70 | for (const perpMarket of this.config.driftClient.getPerpMarketAccounts()) { 71 | this.lastPerpMarketAccounts.set(perpMarket.marketIndex, perpMarket); 72 | } 73 | 74 | for (const spotMarket of this.config.driftClient.getSpotMarketAccounts()) { 75 | this.lastSpotMarketAccounts.set(spotMarket.marketIndex, spotMarket); 76 | } 77 | 78 | this.interval = setInterval( 79 | () => this.checkForUpdates(), 80 | this.config.intervalMs ?? 10_000 81 | ); 82 | } 83 | 84 | public unsubscribe() { 85 | if (this.interval) { 86 | clearInterval(this.interval); 87 | this.interval = undefined; 88 | } 89 | } 90 | 91 | private checkForUpdates() { 92 | let newPerpMarkets = false; 93 | let newSpotMarkets = false; 94 | let perpMarketStatus = false; 95 | let spotMarketStatus = false; 96 | let message = ''; 97 | 98 | const newStateAccount = this.config.driftClient.getStateAccount(); 99 | if (this.config.stateChecks.newPerpMarkets) { 100 | if ( 101 | newStateAccount.numberOfMarkets !== 102 | this.lastStateAccount!.numberOfMarkets 103 | ) { 104 | newPerpMarkets = true; 105 | message += `Number of perp markets changed: ${ 106 | this.lastStateAccount!.numberOfMarkets 107 | } -> ${newStateAccount.numberOfMarkets}\n`; 108 | } 109 | 110 | if (this.config.stateChecks.perpMarketStatus) { 111 | const perpMarkets = this.config.driftClient.getPerpMarketAccounts(); 112 | for (const perpMarket of perpMarkets) { 113 | const symbol = decodeName(perpMarket.name); 114 | const lastPerpMarket = this.lastPerpMarketAccounts.get( 115 | perpMarket.marketIndex 116 | ); 117 | if (!lastPerpMarket) { 118 | newPerpMarkets = true; 119 | message += `Found a new perp market: (marketIndex: ${perpMarket.marketIndex} ${symbol})\n`; 120 | break; 121 | } 122 | 123 | const newPerpStatus = getVariant(perpMarket.status); 124 | const lastPerpStatus = getVariant(lastPerpMarket.status); 125 | if (newPerpStatus !== lastPerpStatus) { 126 | perpMarketStatus = true; 127 | message += `Perp market status changed: (marketIndex: ${perpMarket.marketIndex} ${symbol}) ${lastPerpStatus} -> ${newPerpStatus}\n`; 128 | break; 129 | } 130 | 131 | const newPausedOps = perpMarket.pausedOperations; 132 | const lastPausedOps = lastPerpMarket.pausedOperations; 133 | if (newPausedOps !== lastPausedOps) { 134 | perpMarketStatus = true; 135 | message += `Perp market paused operations changed: (marketIndex: ${perpMarket.marketIndex} ${symbol}) ${lastPausedOps} -> ${newPausedOps}\n`; 136 | break; 137 | } 138 | 139 | const newPerpOracle = perpMarket.amm.oracle; 140 | const lastPerpOracle = lastPerpMarket.amm.oracle; 141 | if (!newPerpOracle.equals(lastPerpOracle)) { 142 | perpMarketStatus = true; 143 | message += `Perp oracle changed: (marketIndex: ${ 144 | perpMarket.marketIndex 145 | } ${symbol}) ${lastPerpOracle.toBase58()} -> ${newPerpOracle.toBase58()}\n`; 146 | break; 147 | } 148 | } 149 | } 150 | } 151 | 152 | if (this.config.stateChecks.newSpotMarkets) { 153 | if ( 154 | newStateAccount.numberOfSpotMarkets !== 155 | this.lastStateAccount!.numberOfSpotMarkets 156 | ) { 157 | newSpotMarkets = true; 158 | message += `Number of spot markets changed: ${ 159 | this.lastStateAccount!.numberOfSpotMarkets 160 | } -> ${newStateAccount.numberOfSpotMarkets}\n`; 161 | } 162 | 163 | if (this.config.stateChecks.spotMarketStatus) { 164 | const spotMarkets = this.config.driftClient.getSpotMarketAccounts(); 165 | for (const spotMarket of spotMarkets) { 166 | const symbol = decodeName(spotMarket.name); 167 | const lastSpotMarket = this.lastSpotMarketAccounts.get( 168 | spotMarket.marketIndex 169 | ); 170 | if (!lastSpotMarket) { 171 | newSpotMarkets = true; 172 | message += `Found a new spot market: (marketIndex: ${spotMarket.marketIndex} ${symbol})\n`; 173 | break; 174 | } 175 | 176 | const newSpotStatus = getVariant(spotMarket.status); 177 | const lastSpotStatus = getVariant(lastSpotMarket.status); 178 | if (newSpotStatus !== lastSpotStatus) { 179 | spotMarketStatus = true; 180 | message += `Spot market status changed: (marketIndex: ${spotMarket.marketIndex} ${symbol}) ${lastSpotStatus} -> ${newSpotStatus}\n`; 181 | break; 182 | } 183 | 184 | const newPausedOps = spotMarket.pausedOperations; 185 | const lastPausedOps = lastSpotMarket.pausedOperations; 186 | if (newPausedOps !== lastPausedOps) { 187 | spotMarketStatus = true; 188 | message += `Spot market paused operations changed: (marketIndex: ${spotMarket.marketIndex} ${symbol}) ${lastPausedOps} -> ${newPausedOps}\n`; 189 | break; 190 | } 191 | 192 | const newSpotOracle = spotMarket.oracle; 193 | const lastSpotOracle = lastSpotMarket.oracle; 194 | if (!newSpotOracle.equals(lastSpotOracle)) { 195 | spotMarketStatus = true; 196 | message += `Spot oracle changed: (marketIndex: ${ 197 | spotMarket.marketIndex 198 | } ${symbol}) ${lastSpotOracle.toBase58()} -> ${newSpotOracle.toBase58()}\n`; 199 | break; 200 | } 201 | } 202 | } 203 | } 204 | 205 | this._lastTriggeredStates = { 206 | newPerpMarkets, 207 | newSpotMarkets, 208 | perpMarketStatus, 209 | spotMarketStatus, 210 | }; 211 | if ( 212 | newPerpMarkets || 213 | newSpotMarkets || 214 | perpMarketStatus || 215 | spotMarketStatus 216 | ) { 217 | this._lastTriggered = true; 218 | if (this.config.stateChecks.onStateChange) { 219 | this.config.stateChecks.onStateChange(message, { 220 | newPerpMarkets, 221 | newSpotMarkets, 222 | perpMarketStatus, 223 | spotMarketStatus, 224 | }); 225 | } 226 | } else { 227 | this._lastTriggered = false; 228 | } 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/error.ts: -------------------------------------------------------------------------------- 1 | import { SendTransactionError, TransactionError } from '@solana/web3.js'; 2 | import { ExtendedTransactionError } from './utils'; 3 | 4 | export function getErrorCode(error: Error): number | undefined { 5 | // @ts-ignore 6 | let errorCode = error.code; 7 | 8 | if (!errorCode) { 9 | try { 10 | const matches = error.message.match( 11 | /custom program error: (0x[0-9,a-f]+)/ 12 | ); 13 | if (!matches) { 14 | return undefined; 15 | } 16 | 17 | const code = matches[1]; 18 | 19 | if (code) { 20 | errorCode = parseInt(code, 16); 21 | } 22 | } catch (e) { 23 | // no problem if couldn't match error code 24 | } 25 | } 26 | return errorCode; 27 | } 28 | 29 | export function getErrorMessage(error: SendTransactionError): string { 30 | let errorString = ''; 31 | error.logs?.forEach((logMsg) => { 32 | try { 33 | const matches = logMsg.match( 34 | /Program log: AnchorError occurred. Error Code: ([0-9,a-z,A-Z]+). Error Number/ 35 | ); 36 | if (!matches) { 37 | return; 38 | } 39 | const errorCode = matches[1]; 40 | 41 | if (errorCode) { 42 | errorString = errorCode; 43 | } 44 | } catch (e) { 45 | // no problem if couldn't match error code 46 | } 47 | }); 48 | 49 | return errorString; 50 | } 51 | 52 | type CustomError = { 53 | Custom: number; 54 | }; 55 | 56 | export function getErrorCodeFromSimError( 57 | error: TransactionError | string | null 58 | ): number | null { 59 | if ((error as ExtendedTransactionError).InstructionError === undefined) { 60 | return null; 61 | } 62 | const err = (error as ExtendedTransactionError).InstructionError; 63 | if (!err) { 64 | return null; 65 | } 66 | 67 | if (err.length < 2) { 68 | console.error(`sim error has no error code. ${JSON.stringify(error)}`); 69 | return null; 70 | } 71 | if (!err[1]) { 72 | return null; 73 | } 74 | 75 | if (typeof err[1] === 'object' && 'Custom' in err[1]) { 76 | return Number((err[1] as CustomError).Custom); 77 | } 78 | 79 | return null; 80 | } 81 | -------------------------------------------------------------------------------- /src/experimental-bots/filler-common/orderSubscriberFiltered.ts: -------------------------------------------------------------------------------- 1 | import { 2 | OrderSubscriber, 3 | OrderSubscriberConfig, 4 | loadKeypair, 5 | UserAccount, 6 | MarketType, 7 | isVariant, 8 | getVariant, 9 | getUserFilter, 10 | getUserWithOrderFilter, 11 | Wallet, 12 | BN, 13 | } from '@drift-labs/sdk'; 14 | import { Connection, PublicKey, RpcResponseAndContext } from '@solana/web3.js'; 15 | import dotenv from 'dotenv'; 16 | import { logger } from '../../logger'; 17 | import parseArgs from 'minimist'; 18 | import { getDriftClientFromArgs } from './utils'; 19 | 20 | const logPrefix = '[OrderSubscriberFiltered]'; 21 | 22 | export type UserAccountUpdate = { 23 | type: string; 24 | userAccount: string; 25 | pubkey: string; 26 | marketIndex: number; 27 | }; 28 | 29 | export interface UserMarkets { 30 | [key: string]: Set; 31 | } 32 | 33 | class OrderSubscriberFiltered extends OrderSubscriber { 34 | private readonly marketIndexes: number[]; 35 | private readonly marketTypeStr: string; 36 | 37 | // To keep track of where the user has open orders in the markets we care about 38 | private readonly userMarkets: UserMarkets = {}; 39 | 40 | constructor( 41 | config: OrderSubscriberConfig, 42 | marketIndexes: number[], 43 | marketType: MarketType 44 | ) { 45 | super(config); 46 | this.marketIndexes = marketIndexes; 47 | this.marketTypeStr = getVariant(marketType); 48 | } 49 | 50 | override tryUpdateUserAccount( 51 | key: string, 52 | dataType: 'raw' | 'decoded' | 'buffer', 53 | data: UserAccount | string[] | Buffer, 54 | slot: number 55 | ): void { 56 | if (!this.mostRecentSlot || slot > this.mostRecentSlot) { 57 | this.mostRecentSlot = slot; 58 | } 59 | 60 | const slotAndUserAccount = this.usersAccounts.get(key); 61 | if (slotAndUserAccount && slotAndUserAccount.slot > slot) { 62 | return; 63 | } 64 | 65 | let buffer: Buffer; 66 | if (dataType === 'raw') { 67 | // @ts-ignore 68 | buffer = Buffer.from(data[0], data[1]); 69 | } else if (dataType === 'buffer') { 70 | buffer = data as Buffer; 71 | } else { 72 | logger.warn('Received unexpected decoded data type for order subscriber'); 73 | return; 74 | } 75 | 76 | const lastActiveSlot = slotAndUserAccount?.userAccount.lastActiveSlot; 77 | const newLastActiveSlot = new BN( 78 | buffer.subarray(4328, 4328 + 8), 79 | undefined, 80 | 'le' 81 | ); 82 | 83 | if (lastActiveSlot && lastActiveSlot.gt(newLastActiveSlot)) { 84 | return; 85 | } 86 | 87 | const userAccount = this.decodeFn('User', buffer) as UserAccount; 88 | const userMarkets = new Set(); 89 | 90 | userAccount.orders.forEach((order) => { 91 | if ( 92 | this.marketIndexes.includes(order.marketIndex) && 93 | isVariant(order.marketType, this.marketTypeStr) 94 | ) { 95 | userMarkets.add(order.marketIndex); 96 | } 97 | }); 98 | 99 | this.updateUserMarkets(key, buffer, userMarkets); 100 | this.usersAccounts.set(key, { slot, userAccount }); 101 | } 102 | 103 | override async fetch(): Promise { 104 | if (this.fetchPromise) { 105 | return this.fetchPromise; 106 | } 107 | 108 | this.fetchPromise = new Promise((resolver) => { 109 | this.fetchPromiseResolver = resolver; 110 | }); 111 | 112 | try { 113 | const rpcRequestArgs = [ 114 | this.driftClient.program.programId.toBase58(), 115 | { 116 | commitment: this.commitment, 117 | filters: [getUserFilter(), getUserWithOrderFilter()], 118 | encoding: 'base64', 119 | withContext: true, 120 | }, 121 | ]; 122 | 123 | const rpcJSONResponse: any = 124 | // @ts-ignore 125 | await this.driftClient.connection._rpcRequest( 126 | 'getProgramAccounts', 127 | rpcRequestArgs 128 | ); 129 | 130 | const rpcResponseAndContext: RpcResponseAndContext< 131 | Array<{ 132 | pubkey: PublicKey; 133 | account: { 134 | data: [string, string]; 135 | }; 136 | }> 137 | > = rpcJSONResponse.result; 138 | 139 | const slot: number = rpcResponseAndContext.context.slot; 140 | 141 | const programAccountSet = new Set(); 142 | for (const programAccount of rpcResponseAndContext.value) { 143 | const key = programAccount.pubkey.toString(); 144 | programAccountSet.add(key); 145 | this.tryUpdateUserAccount( 146 | key, 147 | 'raw', 148 | programAccount.account.data, 149 | slot 150 | ); 151 | // give event loop a chance to breathe 152 | await new Promise((resolve) => setTimeout(resolve, 0)); 153 | } 154 | 155 | for (const key of this.usersAccounts.keys()) { 156 | if (!programAccountSet.has(key)) { 157 | this.usersAccounts.delete(key); 158 | this.userMarkets[key].forEach((marketIndex) => { 159 | this.sendUserAccountUpdateMessage( 160 | Buffer.from([]), 161 | key, 162 | 'delete', 163 | marketIndex 164 | ); 165 | }); 166 | delete this.userMarkets[key]; 167 | } 168 | // give event loop a chance to breathe 169 | await new Promise((resolve) => setTimeout(resolve, 0)); 170 | } 171 | } catch (e) { 172 | console.error(e); 173 | } finally { 174 | this.fetchPromiseResolver(); 175 | this.fetchPromise = undefined; 176 | } 177 | } 178 | 179 | sendUserAccountUpdateMessage( 180 | buffer: Buffer, 181 | key: string, 182 | msgType: 'update' | 'delete', 183 | marketIndex: number 184 | ) { 185 | const userAccountUpdate: UserAccountUpdate = { 186 | type: msgType, 187 | userAccount: buffer.toString('base64'), 188 | pubkey: key, 189 | marketIndex, 190 | }; 191 | if (typeof process.send === 'function') { 192 | process.send({ 193 | type: 'userAccountUpdate', 194 | data: userAccountUpdate, 195 | }); 196 | } 197 | } 198 | 199 | sendLivenessCheck(health: boolean) { 200 | if (typeof process.send === 'function') { 201 | process.send({ 202 | type: 'health', 203 | data: { 204 | healthy: health, 205 | }, 206 | }); 207 | } 208 | } 209 | 210 | private updateUserMarkets( 211 | userId: string, 212 | buffer: Buffer, 213 | currentMarkets: Set 214 | ): void { 215 | const previousMarkets = this.userMarkets[userId] || new Set(); 216 | 217 | const removedMarkets = new Set(); 218 | previousMarkets.forEach((marketIndex) => { 219 | if (!currentMarkets.has(marketIndex)) { 220 | removedMarkets.add(marketIndex); 221 | } 222 | }); 223 | 224 | this.userMarkets[userId] = currentMarkets; 225 | 226 | for (const marketIndex of currentMarkets) { 227 | this.sendUserAccountUpdateMessage(buffer, userId, 'update', marketIndex); 228 | } 229 | 230 | for (const marketIndex of removedMarkets) { 231 | this.sendUserAccountUpdateMessage(buffer, userId, 'delete', marketIndex); 232 | } 233 | } 234 | } 235 | 236 | const main = async () => { 237 | // kill this process if the parent dies 238 | process.on('disconnect', () => process.exit()); 239 | 240 | dotenv.config(); 241 | 242 | const args = parseArgs(process.argv.slice(2)); 243 | const driftEnv = args['drift-env'] ?? 'devnet'; 244 | const marketIndexesStr = String(args['market-indexes']); 245 | const marketIndexes = marketIndexesStr.split(',').map(Number); 246 | const marketTypeStr = args['market-type'] as string; 247 | if (marketTypeStr !== 'perp' && marketTypeStr !== 'spot') { 248 | throw new Error("market-type must be either 'perp' or 'spot'"); 249 | } 250 | 251 | let marketType: MarketType; 252 | switch (marketTypeStr) { 253 | case 'perp': 254 | marketType = MarketType.PERP; 255 | break; 256 | case 'spot': 257 | marketType = MarketType.SPOT; 258 | break; 259 | default: 260 | console.error('Error: Unsupported market type provided.'); 261 | process.exit(1); 262 | } 263 | 264 | const endpoint = process.env.ENDPOINT; 265 | const privateKey = process.env.KEEPER_PRIVATE_KEY; 266 | 267 | if (!endpoint || !privateKey) { 268 | throw new Error('ENDPOINT and KEEPER_PRIVATE_KEY must be provided'); 269 | } 270 | 271 | const wallet = new Wallet(loadKeypair(privateKey)); 272 | const connection = new Connection(endpoint, 'processed'); 273 | 274 | const driftClient = getDriftClientFromArgs({ 275 | connection, 276 | wallet, 277 | marketIndexes, 278 | marketTypeStr, 279 | env: driftEnv, 280 | }); 281 | await driftClient.subscribe(); 282 | 283 | const orderSubscriberConfig: OrderSubscriberConfig = { 284 | driftClient: driftClient, 285 | subscriptionConfig: { 286 | type: 'websocket', 287 | skipInitialLoad: false, 288 | resubTimeoutMs: 5000, 289 | resyncIntervalMs: 60000, 290 | commitment: 'processed', 291 | }, 292 | fastDecode: true, 293 | decodeData: false, 294 | }; 295 | 296 | const orderSubscriberFiltered = new OrderSubscriberFiltered( 297 | orderSubscriberConfig, 298 | marketIndexes, 299 | marketType 300 | ); 301 | 302 | await orderSubscriberFiltered.subscribe(); 303 | 304 | orderSubscriberFiltered.sendLivenessCheck(true); 305 | setInterval(() => { 306 | orderSubscriberFiltered.sendLivenessCheck(true); 307 | }, 10_000); 308 | 309 | logger.info(`${logPrefix} OrderSubscriberFiltered started`); 310 | }; 311 | 312 | main(); 313 | -------------------------------------------------------------------------------- /src/experimental-bots/filler-common/swiftOrderSubscriber.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DevnetPerpMarkets, 3 | DriftEnv, 4 | loadKeypair, 5 | MainnetPerpMarkets, 6 | } from '@drift-labs/sdk'; 7 | import { Keypair } from '@solana/web3.js'; 8 | import nacl from 'tweetnacl'; 9 | import { decodeUTF8 } from 'tweetnacl-util'; 10 | import WebSocket from 'ws'; 11 | import { sleepMs } from '../../utils'; 12 | import dotenv from 'dotenv'; 13 | import parseArgs from 'minimist'; 14 | 15 | export type SwiftOrderSubscriberConfig = { 16 | driftEnv: DriftEnv; 17 | endpoint: string; 18 | marketIndexes: number[]; 19 | keypair: Keypair; 20 | }; 21 | 22 | export class SwiftOrderSubscriber { 23 | private heartbeatTimeout: NodeJS.Timeout | null = null; 24 | private readonly heartbeatIntervalMs = 60000; 25 | private ws: WebSocket | null = null; 26 | subscribed: boolean = false; 27 | 28 | constructor(private config: SwiftOrderSubscriberConfig) {} 29 | 30 | getSymbolForMarketIndex(marketIndex: number) { 31 | const markets = 32 | this.config.driftEnv === 'devnet' 33 | ? DevnetPerpMarkets 34 | : MainnetPerpMarkets; 35 | return markets[marketIndex].symbol; 36 | } 37 | 38 | generateChallengeResponse(nonce: string) { 39 | const messageBytes = decodeUTF8(nonce); 40 | const signature = nacl.sign.detached( 41 | messageBytes, 42 | this.config.keypair.secretKey 43 | ); 44 | const signatureBase64 = Buffer.from(signature).toString('base64'); 45 | return signatureBase64; 46 | } 47 | 48 | handleAuthMessage(message: any) { 49 | if (message['channel'] === 'auth' && message['nonce'] != null) { 50 | const signatureBase64 = this.generateChallengeResponse(message['nonce']); 51 | this.ws?.send( 52 | JSON.stringify({ 53 | pubkey: this.config.keypair.publicKey.toBase58(), 54 | signature: signatureBase64, 55 | }) 56 | ); 57 | } 58 | 59 | if ( 60 | message['channel'] === 'auth' && 61 | message['message']?.toLowerCase() === 'authenticated' 62 | ) { 63 | this.subscribed = true; 64 | this.config.marketIndexes.forEach(async (marketIndex) => { 65 | this.ws?.send( 66 | JSON.stringify({ 67 | action: 'subscribe', 68 | market_type: 'perp', 69 | market_name: this.getSymbolForMarketIndex(marketIndex), 70 | }) 71 | ); 72 | await sleepMs(100); 73 | }); 74 | } 75 | } 76 | 77 | async subscribe() { 78 | const ws = new WebSocket( 79 | this.config.endpoint + 80 | '?pubkey=' + 81 | this.config.keypair.publicKey.toBase58() 82 | ); 83 | this.ws = ws; 84 | ws.on('open', async () => { 85 | console.log('Connected to the server'); 86 | 87 | ws.on('message', async (data: WebSocket.Data) => { 88 | const message = JSON.parse(data.toString()); 89 | this.startHeartbeatTimer(); 90 | 91 | if (message['channel'] === 'auth') { 92 | this.handleAuthMessage(message); 93 | } 94 | 95 | if (message['order']) { 96 | const order = message['order']; 97 | if (typeof process.send === 'function') { 98 | process.send({ 99 | type: 'signedMsgOrderParamsMessage', 100 | data: { 101 | type: 'signedMsgOrderParamsMessage', 102 | signedMsgOrder: order, 103 | marketIndex: order.market_index, 104 | uuid: this.convertUuidToNumber(order.uuid), 105 | }, 106 | }); 107 | } 108 | } 109 | }); 110 | 111 | ws.on('close', () => { 112 | console.log('Disconnected from the server'); 113 | this.reconnect(); 114 | }); 115 | 116 | ws.on('error', (error: Error) => { 117 | console.error('WebSocket error:', error); 118 | this.reconnect(); 119 | }); 120 | }); 121 | } 122 | 123 | private startHeartbeatTimer() { 124 | if (this.heartbeatTimeout) { 125 | clearTimeout(this.heartbeatTimeout); 126 | } 127 | this.heartbeatTimeout = setTimeout(() => { 128 | console.warn('No heartbeat received within 30 seconds, reconnecting...'); 129 | this.reconnect(); 130 | }, this.heartbeatIntervalMs); 131 | } 132 | 133 | private reconnect() { 134 | if (this.ws) { 135 | this.ws.removeAllListeners(); 136 | this.ws.terminate(); 137 | } 138 | 139 | console.log('Reconnecting to WebSocket...'); 140 | setTimeout(() => { 141 | this.subscribe(); 142 | }, 1000); 143 | } 144 | 145 | private convertUuidToNumber(uuid: string): number { 146 | return uuid 147 | .split('') 148 | .reduce( 149 | (n, c) => 150 | n * 64 + 151 | '_~0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.indexOf( 152 | c 153 | ), 154 | 0 155 | ); 156 | } 157 | } 158 | 159 | async function main() { 160 | process.on('disconnect', () => process.exit()); 161 | 162 | dotenv.config(); 163 | 164 | const args = parseArgs(process.argv.slice(2)); 165 | const driftEnv = args['drift-env'] ?? 'devnet'; 166 | const marketIndexesStr = String(args['market-indexes']); 167 | const marketIndexes = marketIndexesStr.split(',').map(Number); 168 | 169 | const endpoint = process.env.ENDPOINT; 170 | const privateKey = process.env.KEEPER_PRIVATE_KEY; 171 | 172 | if (!endpoint || !privateKey) { 173 | throw new Error('ENDPOINT and KEEPER_PRIVATE_KEY must be provided'); 174 | } 175 | 176 | const keypair = loadKeypair(privateKey); 177 | const swiftOrderSubscriberConfig: SwiftOrderSubscriberConfig = { 178 | driftEnv, 179 | endpoint: 180 | driftEnv === 'devnet' 181 | ? 'wss://master.swift.drift.trade/ws' 182 | : 'wss://swift.drift.trade/ws', 183 | marketIndexes, 184 | keypair, 185 | }; 186 | 187 | const swiftOrderSubscriber = new SwiftOrderSubscriber( 188 | swiftOrderSubscriberConfig 189 | ); 190 | await swiftOrderSubscriber.subscribe(); 191 | } 192 | 193 | main(); 194 | -------------------------------------------------------------------------------- /src/experimental-bots/filler-common/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | OrderStatus, 3 | OrderType, 4 | MarketType, 5 | PositionDirection, 6 | OrderTriggerCondition, 7 | SpotBalanceType, 8 | NodeToFill, 9 | DLOBNode, 10 | NodeToTrigger, 11 | PublicKey, 12 | } from '@drift-labs/sdk'; 13 | 14 | export type SerializedUserAccount = { 15 | authority: string; 16 | delegate: string; 17 | name: number[]; 18 | subAccountId: number; 19 | spotPositions: SerializedSpotPosition[]; 20 | perpPositions: SerializedPerpPosition[]; 21 | orders: SerializedOrder[]; 22 | status: number; 23 | nextLiquidationId: number; 24 | nextOrderId: number; 25 | maxMarginRatio: number; 26 | lastAddPerpLpSharesTs: string; 27 | settledPerpPnl: string; 28 | totalDeposits: string; 29 | totalWithdraws: string; 30 | totalSocialLoss: string; 31 | cumulativePerpFunding: string; 32 | cumulativeSpotFees: string; 33 | liquidationMarginFreed: string; 34 | lastActiveSlot: string; 35 | isMarginTradingEnabled: boolean; 36 | idle: boolean; 37 | openOrders: number; 38 | hasOpenOrder: boolean; 39 | openAuctions: number; 40 | hasOpenAuction: boolean; 41 | }; 42 | 43 | export type SerializedOrder = { 44 | status: OrderStatus; 45 | orderType: OrderType; 46 | marketType: MarketType; 47 | slot: string; 48 | orderId: number; 49 | userOrderId: number; 50 | marketIndex: number; 51 | price: string; 52 | baseAssetAmount: string; 53 | quoteAssetAmount: string; 54 | baseAssetAmountFilled: string; 55 | quoteAssetAmountFilled: string; 56 | direction: PositionDirection; 57 | reduceOnly: boolean; 58 | triggerPrice: string; 59 | triggerCondition: OrderTriggerCondition; 60 | existingPositionDirection: PositionDirection; 61 | postOnly: boolean; 62 | immediateOrCancel: boolean; 63 | oraclePriceOffset: number; 64 | auctionDuration: number; 65 | auctionStartPrice: string; 66 | auctionEndPrice: string; 67 | maxTs: string; 68 | bitFlags: number; 69 | postedSlotTail: number; 70 | }; 71 | 72 | export type SerializedSpotPosition = { 73 | marketIndex: number; 74 | balanceType: SpotBalanceType; 75 | scaledBalance: string; 76 | openOrders: number; 77 | openBids: string; 78 | openAsks: string; 79 | cumulativeDeposits: string; 80 | }; 81 | 82 | export type SerializedPerpPosition = { 83 | baseAssetAmount: string; 84 | lastCumulativeFundingRate: string; 85 | marketIndex: number; 86 | quoteAssetAmount: string; 87 | quoteEntryAmount: string; 88 | quoteBreakEvenAmount: string; 89 | openOrders: number; 90 | openBids: string; 91 | openAsks: string; 92 | settledPnl: string; 93 | lpShares: string; 94 | remainderBaseAssetAmount: number; 95 | lastBaseAssetAmountPerLp: string; 96 | lastQuoteAssetAmountPerLp: string; 97 | perLpBase: number; 98 | }; 99 | 100 | export type SerializedNodeToTrigger = { 101 | node: SerializedTriggerOrderNode; 102 | makers: string[]; 103 | }; 104 | 105 | export type SerializedTriggerOrderNode = { 106 | order: SerializedOrder; 107 | userAccountData: Buffer; 108 | userAccount: string; 109 | sortValue: string; 110 | haveFilled: boolean; 111 | haveTrigger: boolean; 112 | isSignedMsg: boolean; 113 | isProtectedMaker: boolean; 114 | }; 115 | 116 | export type SerializedNodeToFill = { 117 | fallbackAskSource?: FallbackLiquiditySource; 118 | fallbackBidSource?: FallbackLiquiditySource; 119 | node: SerializedDLOBNode; 120 | makerNodes: SerializedDLOBNode[]; 121 | authority?: string; 122 | }; 123 | 124 | export type SerializedDLOBNode = { 125 | type: string; 126 | order: SerializedOrder; 127 | userAccountData?: Buffer; 128 | userAccount: string; 129 | sortValue: string; 130 | haveFilled: boolean; 131 | haveTrigger?: boolean; 132 | fallbackAskSource?: FallbackLiquiditySource; 133 | fallbackBidSource?: FallbackLiquiditySource; 134 | isSignedMsg?: boolean; 135 | isUserProtectedMaker: boolean; 136 | }; 137 | 138 | export type FallbackLiquiditySource = 'phoenix' | 'openbook'; 139 | export type NodeToFillWithContext = NodeToFill & { 140 | fallbackAskSource?: FallbackLiquiditySource; 141 | fallbackBidSource?: FallbackLiquiditySource; 142 | }; 143 | 144 | export type NodeToFillWithBuffer = { 145 | userAccountData?: Buffer; 146 | makerAccountData: string; 147 | node: DLOBNode; 148 | fallbackAskSource?: FallbackLiquiditySource; 149 | fallbackBidSource?: FallbackLiquiditySource; 150 | makerNodes: DLOBNode[]; 151 | authority?: string; 152 | }; 153 | 154 | export type NodeToTriggerWithMakers = NodeToTrigger & { 155 | makers: PublicKey[]; 156 | }; 157 | -------------------------------------------------------------------------------- /src/experimental-bots/swift/makerExample.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DriftClient, 3 | getLimitOrderParams, 4 | getUserAccountPublicKey, 5 | getUserStatsAccountPublicKey, 6 | isVariant, 7 | MarketType, 8 | PositionDirection, 9 | PostOnlyParams, 10 | PriorityFeeSubscriberMap, 11 | PublicKey, 12 | SignedMsgOrderParamsDelegateMessage, 13 | SignedMsgOrderParamsMessage, 14 | UserMap, 15 | } from '@drift-labs/sdk'; 16 | import { RuntimeSpec } from 'src/metrics'; 17 | import WebSocket from 'ws'; 18 | import nacl from 'tweetnacl'; 19 | import { decodeUTF8 } from 'tweetnacl-util'; 20 | import { getWallet, simulateAndGetTxWithCUs } from '../../utils'; 21 | import { 22 | ComputeBudgetProgram, 23 | Keypair, 24 | TransactionInstruction, 25 | } from '@solana/web3.js'; 26 | import { getPriorityFeeInstruction } from '../filler-common/utils'; 27 | import { sha256 } from '@noble/hashes/sha256'; 28 | 29 | export class SwiftMaker { 30 | interval: NodeJS.Timeout | null = null; 31 | private ws: WebSocket | null = null; 32 | private signedMsgUrl: string; 33 | private heartbeatTimeout: NodeJS.Timeout | null = null; 34 | private priorityFeeSubscriber: PriorityFeeSubscriberMap; 35 | private readonly heartbeatIntervalMs = 80_000; 36 | constructor( 37 | private driftClient: DriftClient, 38 | private userMap: UserMap, 39 | runtimeSpec: RuntimeSpec, 40 | private dryRun?: boolean 41 | ) { 42 | this.signedMsgUrl = 43 | runtimeSpec.driftEnv === 'mainnet-beta' 44 | ? 'wss://swift.drift.trade/ws' 45 | : 'wss://master.swift.drift.trade/ws'; 46 | 47 | const perpMarketsToWatchForFees = [0, 1, 2, 3, 4, 5].map((x) => { 48 | return { marketType: 'perp', marketIndex: x }; 49 | }); 50 | 51 | this.priorityFeeSubscriber = new PriorityFeeSubscriberMap({ 52 | driftMarkets: perpMarketsToWatchForFees, 53 | driftPriorityFeeEndpoint: 'https://dlob.drift.trade', 54 | }); 55 | } 56 | 57 | async init() { 58 | await this.subscribeWs(); 59 | await this.priorityFeeSubscriber.subscribe(); 60 | } 61 | 62 | async subscribeWs() { 63 | /** 64 | Make sure that WS_DELEGATE_KEY referrs to a keypair for an empty wallet, and that it has been added to 65 | ws_delegates for an authority. see here: 66 | https://github.com/drift-labs/protocol-v2/blob/master/sdk/src/driftClient.ts#L1160-L1194 67 | */ 68 | const keypair = process.env.WS_DELEGATE_KEY 69 | ? getWallet(process.env.WS_DELEGATE_KEY)[0] 70 | : new Keypair(); 71 | const stakePrivateKey = process.env.STAKE_PRIVATE_KEY; 72 | let stakeKeypair: Keypair | undefined; 73 | if (stakePrivateKey) { 74 | stakeKeypair = getWallet(stakePrivateKey)[0]; 75 | } 76 | 77 | const ws = new WebSocket( 78 | this.signedMsgUrl + '?pubkey=' + keypair.publicKey.toBase58() 79 | ); 80 | 81 | ws.on('open', async () => { 82 | console.log('Connected to the server'); 83 | this.startHeartbeatTimer(); 84 | 85 | ws.on('message', async (data: WebSocket.Data) => { 86 | const message = JSON.parse(data.toString()); 87 | this.startHeartbeatTimer(); 88 | 89 | if (message['channel'] === 'auth' && message['nonce'] != null) { 90 | const messageBytes = decodeUTF8(message['nonce']); 91 | const signature = nacl.sign.detached(messageBytes, keypair.secretKey); 92 | const signatureBase64 = Buffer.from(signature).toString('base64'); 93 | console.log(stakeKeypair?.publicKey.toBase58()); 94 | ws.send( 95 | JSON.stringify({ 96 | pubkey: keypair.publicKey.toBase58(), 97 | signature: signatureBase64, 98 | stake_pubkey: stakeKeypair?.publicKey.toBase58(), 99 | }) 100 | ); 101 | } 102 | 103 | if ( 104 | message['channel'] === 'auth' && 105 | message['message'] === 'Authenticated' 106 | ) { 107 | ws.send( 108 | JSON.stringify({ 109 | action: 'subscribe', 110 | market_type: 'perp', 111 | market_name: 'SOL-PERP', 112 | }) 113 | ); 114 | ws.send( 115 | JSON.stringify({ 116 | action: 'subscribe', 117 | market_type: 'perp', 118 | market_name: 'BTC-PERP', 119 | }) 120 | ); 121 | ws.send( 122 | JSON.stringify({ 123 | action: 'subscribe', 124 | market_type: 'perp', 125 | market_name: 'ETH-PERP', 126 | }) 127 | ); 128 | ws.send( 129 | JSON.stringify({ 130 | action: 'subscribe', 131 | market_type: 'perp', 132 | market_name: 'APT-PERP', 133 | }) 134 | ); 135 | ws.send( 136 | JSON.stringify({ 137 | action: 'subscribe', 138 | market_type: 'perp', 139 | market_name: 'POL-PERP', 140 | }) 141 | ); 142 | ws.send( 143 | JSON.stringify({ 144 | action: 'subscribe', 145 | market_type: 'perp', 146 | market_name: 'ARB-PERP', 147 | }) 148 | ); 149 | } 150 | 151 | if (message['order'] && this.driftClient.isSubscribed) { 152 | const order = message['order']; 153 | console.info(`uuid: ${order['uuid']} at ${Date.now()}`); 154 | 155 | const signedMsgOrderParamsBufHex = Buffer.from( 156 | order['order_message'] 157 | ); 158 | const signedMsgOrderParamsBuf = Buffer.from( 159 | order['order_message'], 160 | 'hex' 161 | ); 162 | 163 | const isDelegateSigner = signedMsgOrderParamsBuf 164 | .slice(0, 8) 165 | .equals( 166 | Uint8Array.from( 167 | Buffer.from( 168 | sha256('global' + ':' + 'SignedMsgOrderParamsDelegateMessage') 169 | ).slice(0, 8) 170 | ) 171 | ); 172 | 173 | const signedMessage: 174 | | SignedMsgOrderParamsMessage 175 | | SignedMsgOrderParamsDelegateMessage = 176 | this.driftClient.decodeSignedMsgOrderParamsMessage( 177 | signedMsgOrderParamsBuf, 178 | isDelegateSigner 179 | ); 180 | 181 | const signedMsgOrderParams = signedMessage.signedMsgOrderParams; 182 | 183 | const signingAuthority = new PublicKey(order['signing_authority']); 184 | const takerAuthority = new PublicKey(order['taker_authority']); 185 | const takerUserPubkey = isDelegateSigner 186 | ? (signedMessage as SignedMsgOrderParamsDelegateMessage).takerPubkey 187 | : await getUserAccountPublicKey( 188 | this.driftClient.program.programId, 189 | takerAuthority, 190 | (signedMessage as SignedMsgOrderParamsMessage).subAccountId 191 | ); 192 | const takerUserAccount = ( 193 | await this.userMap.mustGet(takerUserPubkey.toString()) 194 | ).getUserAccount(); 195 | 196 | const isOrderLong = isVariant(signedMsgOrderParams.direction, 'long'); 197 | if (!signedMsgOrderParams.price) { 198 | console.error( 199 | `order has no price: ${JSON.stringify(signedMsgOrderParams)}` 200 | ); 201 | return; 202 | } 203 | const computeBudgetIxs: Array = [ 204 | ComputeBudgetProgram.setComputeUnitLimit({ 205 | units: 1_400_000, 206 | }), 207 | ]; 208 | computeBudgetIxs.push( 209 | getPriorityFeeInstruction( 210 | this.priorityFeeSubscriber.getPriorityFees( 211 | 'perp', 212 | signedMsgOrderParams.marketIndex 213 | )?.medium ?? 0 214 | ) 215 | ); 216 | 217 | const ixs = 218 | await this.driftClient.getPlaceAndMakeSignedMsgPerpOrderIxs( 219 | { 220 | orderParams: signedMsgOrderParamsBufHex, 221 | signature: Buffer.from(order['order_signature'], 'base64'), 222 | }, 223 | decodeUTF8(order['uuid']), 224 | { 225 | taker: takerUserPubkey, 226 | takerUserAccount, 227 | takerStats: getUserStatsAccountPublicKey( 228 | this.driftClient.program.programId, 229 | takerUserAccount.authority 230 | ), 231 | signingAuthority, 232 | }, 233 | getLimitOrderParams({ 234 | marketType: MarketType.PERP, 235 | marketIndex: signedMsgOrderParams.marketIndex, 236 | direction: isOrderLong 237 | ? PositionDirection.SHORT 238 | : PositionDirection.LONG, 239 | baseAssetAmount: signedMsgOrderParams.baseAssetAmount.divn(2), 240 | price: isOrderLong 241 | ? signedMsgOrderParams.auctionStartPrice!.muln(99).divn(100) 242 | : signedMsgOrderParams.auctionEndPrice!.muln(101).divn(100), 243 | postOnly: PostOnlyParams.MUST_POST_ONLY, 244 | bitFlags: signedMsgOrderParams.bitFlags, 245 | }), 246 | undefined, 247 | undefined, 248 | computeBudgetIxs 249 | ); 250 | 251 | if (this.dryRun) { 252 | console.log(Date.now() - order['ts']); 253 | return; 254 | } 255 | 256 | const resp = await simulateAndGetTxWithCUs({ 257 | connection: this.driftClient.connection, 258 | payerPublicKey: this.driftClient.wallet.payer!.publicKey, 259 | ixs: [...computeBudgetIxs, ...ixs], 260 | cuLimitMultiplier: 1.5, 261 | lookupTableAccounts: 262 | await this.driftClient.fetchAllLookupTableAccounts(), 263 | doSimulation: true, 264 | }); 265 | if (resp.simError) { 266 | console.log(resp.simTxLogs); 267 | return; 268 | } 269 | 270 | this.driftClient.txSender 271 | .sendVersionedTransaction(resp.tx) 272 | .then((response) => { 273 | console.log(response); 274 | }) 275 | .catch((error) => { 276 | console.log(error); 277 | }); 278 | } 279 | }); 280 | 281 | ws.on('close', () => { 282 | console.log('Disconnected from the server'); 283 | this.reconnect(); 284 | }); 285 | 286 | ws.on('error', (error: Error) => { 287 | console.error('WebSocket error:', error); 288 | this.reconnect(); 289 | }); 290 | }); 291 | 292 | this.ws = ws; 293 | } 294 | 295 | public async healthCheck() { 296 | return true; 297 | } 298 | 299 | private startHeartbeatTimer() { 300 | if (this.heartbeatTimeout) { 301 | clearTimeout(this.heartbeatTimeout); 302 | } 303 | this.heartbeatTimeout = setTimeout(() => { 304 | console.warn('No heartbeat received within 60 seconds, reconnecting...'); 305 | this.reconnect(); 306 | }, this.heartbeatIntervalMs); 307 | } 308 | 309 | private reconnect() { 310 | if (this.ws) { 311 | this.ws.removeAllListeners(); 312 | this.ws.terminate(); 313 | } 314 | 315 | console.log('Reconnecting to WebSocket...'); 316 | setTimeout(() => { 317 | this.subscribeWs(); 318 | }, 1000); 319 | } 320 | } 321 | -------------------------------------------------------------------------------- /src/experimental-bots/swift/takerExample.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DriftClient, 3 | getMarketOrderParams, 4 | isVariant, 5 | MarketType, 6 | PositionDirection, 7 | digestSignature, 8 | generateSignedMsgUuid, 9 | BN, 10 | OrderParams, 11 | } from '@drift-labs/sdk'; 12 | import { RuntimeSpec } from 'src/metrics'; 13 | import * as axios from 'axios'; 14 | import { sleepMs } from '../../utils'; 15 | 16 | const CONFIRM_TIMEOUT = 30_000; 17 | 18 | export class SwiftTaker { 19 | interval: NodeJS.Timeout | null = null; 20 | swiftUrl: string; 21 | 22 | constructor( 23 | private driftClient: DriftClient, 24 | runtimeSpec: RuntimeSpec, 25 | private intervalMs: number 26 | ) { 27 | this.swiftUrl = 28 | runtimeSpec.driftEnv === 'mainnet-beta' 29 | ? 'https://swift.drift.trade' 30 | : 'https://master.swift.drift.trade'; 31 | } 32 | 33 | async init() { 34 | await this.startInterval(); 35 | } 36 | 37 | public async healthCheck() { 38 | return true; 39 | } 40 | 41 | async startInterval() { 42 | const marketIndexes = [0, 1, 2, 3, 5, 6]; 43 | this.interval = setInterval(async () => { 44 | await sleepMs(Math.random() * 1000); // Randomize for different grafana metrics 45 | const slot = await this.driftClient.connection.getSlot(); 46 | const direction = 47 | Math.random() > 0.5 ? PositionDirection.LONG : PositionDirection.SHORT; 48 | 49 | const marketIndex = 50 | marketIndexes[Math.floor(Math.random() * marketIndexes.length)]; 51 | 52 | const oracleInfo = 53 | this.driftClient.getOracleDataForPerpMarket(marketIndex); 54 | const highPrice = oracleInfo.price.muln(101).divn(100); 55 | const lowPrice = oracleInfo.price; 56 | 57 | const marketOrderParams = getMarketOrderParams({ 58 | marketIndex, 59 | marketType: MarketType.PERP, 60 | direction, 61 | baseAssetAmount: this.driftClient 62 | .getPerpMarketAccount(marketIndex)! 63 | .amm.minOrderSize.muln(2), 64 | auctionStartPrice: isVariant(direction, 'long') ? lowPrice : highPrice, 65 | auctionEndPrice: isVariant(direction, 'long') ? highPrice : lowPrice, 66 | auctionDuration: 50, 67 | }); 68 | 69 | const orderMessage = { 70 | signedMsgOrderParams: marketOrderParams as OrderParams, 71 | subAccountId: this.driftClient.activeSubAccountId, 72 | slot: new BN(slot), 73 | uuid: generateSignedMsgUuid(), 74 | stopLossOrderParams: null, 75 | takeProfitOrderParams: null, 76 | }; 77 | const { orderParams: message, signature } = 78 | this.driftClient.signSignedMsgOrderParamsMessage(orderMessage); 79 | 80 | const hash = digestSignature(Uint8Array.from(signature)); 81 | console.log( 82 | `Sending order in slot: ${slot}, time: ${Date.now()}, hash: ${hash}` 83 | ); 84 | 85 | const response = await axios.default.post( 86 | this.swiftUrl + '/orders', 87 | { 88 | market_index: marketIndex, 89 | market_type: 'perp', 90 | message: message.toString(), 91 | signature: signature.toString('base64'), 92 | taker_pubkey: this.driftClient.wallet.publicKey.toBase58(), 93 | }, 94 | { 95 | headers: { 96 | 'Content-Type': 'application/json', 97 | }, 98 | } 99 | ); 100 | if (response.status !== 200) { 101 | console.error('Failed to send order', response.data); 102 | return; 103 | } 104 | 105 | const expireTime = Date.now() + CONFIRM_TIMEOUT; 106 | while (Date.now() < expireTime) { 107 | const response = await axios.default.get( 108 | this.swiftUrl + 109 | '/confirmation/hash-status?hash=' + 110 | encodeURIComponent(hash), 111 | { 112 | validateStatus: (_status) => true, 113 | } 114 | ); 115 | if (response.status === 200) { 116 | console.log('Confirmed hash ', hash); 117 | return; 118 | } else if (response.status >= 500) { 119 | break; 120 | } 121 | await new Promise((resolve) => setTimeout(resolve, 10000)); 122 | } 123 | console.error('Failed to confirm hash: ', hash); 124 | }, this.intervalMs); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import { createLogger, transports, format } from 'winston'; 2 | 3 | export const logger = createLogger({ 4 | transports: [new transports.Console()], 5 | format: format.combine( 6 | format.colorize(), 7 | format.timestamp(), 8 | format.printf(({ timestamp, level, message }) => { 9 | return `[${timestamp}] ${level}: ${message}`; 10 | }) 11 | ), 12 | }); 13 | 14 | export const setLogLevel = (logLevel: string) => { 15 | logger.level = logLevel; 16 | }; 17 | -------------------------------------------------------------------------------- /src/makerSelection.ts: -------------------------------------------------------------------------------- 1 | import { BN, convertToNumber, divCeil, DLOBNode, ZERO } from '@drift-labs/sdk'; 2 | import { MakerNodeMap, MAX_MAKERS_PER_FILL } from './bots/filler'; 3 | 4 | const PROBABILITY_PRECISION = new BN(1000); 5 | 6 | export function selectMakers(makerNodeMap: MakerNodeMap): MakerNodeMap { 7 | const selectedMakers: MakerNodeMap = new Map(); 8 | 9 | while (selectedMakers.size < MAX_MAKERS_PER_FILL && makerNodeMap.size > 0) { 10 | const maker = selectMaker(makerNodeMap); 11 | if (maker === undefined) { 12 | break; 13 | } 14 | const makerNodes = makerNodeMap.get(maker)!; 15 | selectedMakers.set(maker, makerNodes); 16 | makerNodeMap.delete(maker); 17 | } 18 | 19 | return selectedMakers; 20 | } 21 | 22 | function selectMaker(makerNodeMap: MakerNodeMap): string | undefined { 23 | if (makerNodeMap.size === 0) { 24 | return undefined; 25 | } 26 | 27 | let totalLiquidity = ZERO; 28 | for (const [_, dlobNodes] of makerNodeMap) { 29 | totalLiquidity = totalLiquidity.add(getMakerLiquidity(dlobNodes)); 30 | } 31 | 32 | const probabilities = []; 33 | for (const [_, dlobNodes] of makerNodeMap) { 34 | probabilities.push(getProbability(dlobNodes, totalLiquidity)); 35 | } 36 | 37 | let makerIndex = 0; 38 | const random = Math.random(); 39 | let sum = 0; 40 | for (let i = 0; i < probabilities.length; i++) { 41 | sum += probabilities[i]; 42 | if (random < sum) { 43 | makerIndex = i; 44 | break; 45 | } 46 | } 47 | 48 | return Array.from(makerNodeMap.keys())[makerIndex]; 49 | } 50 | 51 | function getProbability(dlobNodes: DLOBNode[], totalLiquidity: BN): number { 52 | const makerLiquidity = getMakerLiquidity(dlobNodes); 53 | return convertToNumber( 54 | divCeil(makerLiquidity.mul(PROBABILITY_PRECISION), totalLiquidity), 55 | PROBABILITY_PRECISION 56 | ); 57 | } 58 | 59 | function getMakerLiquidity(dlobNodes: DLOBNode[]): BN { 60 | return dlobNodes.reduce( 61 | (acc, dlobNode) => 62 | acc.add( 63 | dlobNode.order!.baseAssetAmount.sub( 64 | dlobNode.order!.baseAssetAmountFilled 65 | ) 66 | ), 67 | ZERO 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /src/metrics.ts: -------------------------------------------------------------------------------- 1 | import { Meter } from '@opentelemetry/api-metrics'; 2 | import { 3 | ExplicitBucketHistogramAggregation, 4 | MeterProvider, 5 | View, 6 | } from '@opentelemetry/sdk-metrics-base'; 7 | import { PrometheusExporter } from '@opentelemetry/exporter-prometheus'; 8 | import { logger } from './logger'; 9 | import { PublicKey } from '@solana/web3.js'; 10 | import { UserAccount } from '@drift-labs/sdk'; 11 | import { 12 | BatchObservableResult, 13 | Attributes, 14 | ObservableGauge, 15 | Histogram, 16 | Counter, 17 | } from '@opentelemetry/api'; 18 | 19 | /** 20 | * RuntimeSpec is the attributes of the runtime environment, used to 21 | * distinguish this metric set from others 22 | */ 23 | export type RuntimeSpec = { 24 | rpcEndpoint: string; 25 | driftEnv: string; 26 | commit: string; 27 | driftPid: string; 28 | walletAuthority: string; 29 | }; 30 | 31 | export function metricAttrFromUserAccount( 32 | userAccountKey: PublicKey, 33 | ua: UserAccount 34 | ): any { 35 | return { 36 | subaccount_id: ua.subAccountId, 37 | public_key: userAccountKey.toBase58(), 38 | authority: ua.authority.toBase58(), 39 | delegate: ua.delegate.toBase58(), 40 | }; 41 | } 42 | /** 43 | * Creates {count} buckets of size {increment} starting from {start}. Each bucket stores the count of values within its "size". 44 | * @param start 45 | * @param increment 46 | * @param count 47 | * @returns 48 | */ 49 | export function createHistogramBuckets( 50 | start: number, 51 | increment: number, 52 | count: number 53 | ) { 54 | return new ExplicitBucketHistogramAggregation( 55 | Array.from(new Array(count), (_, i) => start + i * increment) 56 | ); 57 | } 58 | 59 | export class GaugeValue { 60 | private latestGaugeValues: Map; 61 | private gauge: ObservableGauge; 62 | 63 | constructor(gauge: ObservableGauge) { 64 | this.gauge = gauge; 65 | this.latestGaugeValues = new Map(); 66 | } 67 | 68 | setLatestValue(value: number, attributes: Attributes) { 69 | const attributesStr = JSON.stringify(attributes); 70 | this.latestGaugeValues.set(attributesStr, value); 71 | } 72 | 73 | getLatestValue(attributes: Attributes): number | undefined { 74 | const attributesStr = JSON.stringify(attributes); 75 | return this.latestGaugeValues.get(attributesStr); 76 | } 77 | 78 | getGauge(): ObservableGauge { 79 | return this.gauge; 80 | } 81 | 82 | entries(): IterableIterator<[string, number]> { 83 | return this.latestGaugeValues.entries(); 84 | } 85 | } 86 | 87 | export class HistogramValue { 88 | private histogram: Histogram; 89 | constructor(histogram: Histogram) { 90 | this.histogram = histogram; 91 | } 92 | 93 | record(value: number, attributes: Attributes) { 94 | this.histogram.record(value, attributes); 95 | } 96 | } 97 | 98 | export class CounterValue { 99 | private counter: Counter; 100 | constructor(counter: Counter) { 101 | this.counter = counter; 102 | } 103 | 104 | add(value: number, attributes: Attributes) { 105 | this.counter.add(value, attributes); 106 | } 107 | } 108 | 109 | export class Metrics { 110 | private exporter: PrometheusExporter; 111 | private meterProvider: MeterProvider; 112 | private meters: Map; 113 | private gauges: Array; 114 | private defaultMeterName: string; 115 | 116 | constructor(meterName: string, views?: Array, metricsPort?: number) { 117 | const { endpoint: defaultEndpoint, port: defaultPort } = 118 | PrometheusExporter.DEFAULT_OPTIONS; 119 | const port = metricsPort || defaultPort; 120 | this.exporter = new PrometheusExporter( 121 | { 122 | port: port, 123 | endpoint: defaultEndpoint, 124 | }, 125 | () => { 126 | logger.info( 127 | `prometheus scrape endpoint started: http://localhost:${port}${defaultEndpoint}` 128 | ); 129 | } 130 | ); 131 | 132 | this.meterProvider = new MeterProvider({ views }); 133 | this.meterProvider.addMetricReader(this.exporter); 134 | this.gauges = new Array(); 135 | this.meters = new Map(); 136 | this.defaultMeterName = meterName; 137 | this.getMeter(this.defaultMeterName); 138 | } 139 | 140 | getMeter(name: string): Meter { 141 | if (this.meters.has(name)) { 142 | return this.meters.get(name) as Meter; 143 | } else { 144 | const meter = this.meterProvider.getMeter(name); 145 | this.meters.set(name, meter); 146 | return meter; 147 | } 148 | } 149 | 150 | addGauge( 151 | metricName: string, 152 | description: string, 153 | meterName?: string 154 | ): GaugeValue { 155 | const meter = this.getMeter(meterName ?? this.defaultMeterName); 156 | const newGauge = meter.createObservableGauge(metricName, { 157 | description: description, 158 | }); 159 | const gauge = new GaugeValue(newGauge); 160 | this.gauges.push(gauge); 161 | return gauge; 162 | } 163 | 164 | addHistogram( 165 | metricName: string, 166 | description: string, 167 | meterName?: string 168 | ): HistogramValue { 169 | const meter = this.getMeter(meterName ?? this.defaultMeterName); 170 | return new HistogramValue( 171 | meter.createHistogram(metricName, { 172 | description: description, 173 | }) 174 | ); 175 | } 176 | 177 | addCounter( 178 | metricName: string, 179 | description: string, 180 | meterName?: string 181 | ): CounterValue { 182 | const meter = this.getMeter(meterName ?? this.defaultMeterName); 183 | return new CounterValue( 184 | meter.createCounter(metricName, { 185 | description: description, 186 | }) 187 | ); 188 | } 189 | 190 | /** 191 | * Finalizes the observables by adding the batch observable callback to each meter. 192 | * Must call this before using this Metrics object 193 | */ 194 | finalizeObservables() { 195 | for (const meter of this.meters.values()) { 196 | meter.addBatchObservableCallback( 197 | (observerResult: BatchObservableResult) => { 198 | for (const gauge of this.gauges) { 199 | for (const [attributesStr, value] of gauge.entries()) { 200 | const attributes = JSON.parse(attributesStr); 201 | observerResult.observe(gauge.getGauge(), value, attributes); 202 | } 203 | } 204 | }, 205 | this.gauges.map((gauge) => gauge.getGauge()) 206 | ); 207 | } 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/pythLazerSubscriber.ts: -------------------------------------------------------------------------------- 1 | import { Channel, PythLazerClient } from '@pythnetwork/pyth-lazer-sdk'; 2 | import { DriftEnv, PerpMarkets } from '@drift-labs/sdk'; 3 | import { RedisClient } from '@drift/common/clients'; 4 | import * as axios from 'axios'; 5 | 6 | export class PythLazerSubscriber { 7 | private pythLazerClient?: PythLazerClient; 8 | feedIdChunkToPriceMessage: Map = new Map(); 9 | feedIdToPrice: Map = new Map(); 10 | feedIdHashToFeedIds: Map = new Map(); 11 | subscriptionIdsToFeedIdsHash: Map = new Map(); 12 | allSubscribedIds: number[] = []; 13 | 14 | timeoutId?: NodeJS.Timeout; 15 | receivingData = false; 16 | isUnsubscribing = false; 17 | 18 | marketIndextoPriceFeedIdChunk: Map = new Map(); 19 | marketIndextoPriceFeedId: Map = new Map(); 20 | useHttpRequests: boolean = false; 21 | 22 | constructor( 23 | private endpoints: string[], 24 | private token: string, 25 | private priceFeedIdsArrays: number[][], 26 | env: DriftEnv = 'devnet', 27 | private redisClient?: RedisClient, 28 | private httpEndpoints: string[] = [], 29 | private resubTimeoutMs: number = 2000, 30 | private subscribeChannel = 'fixed_rate@200ms' 31 | ) { 32 | const markets = PerpMarkets[env].filter( 33 | (market) => market.pythLazerId !== undefined 34 | ); 35 | 36 | this.allSubscribedIds = this.priceFeedIdsArrays.flat(); 37 | if ( 38 | priceFeedIdsArrays[0].length === 1 && 39 | this.allSubscribedIds.length > 3 && 40 | this.httpEndpoints.length > 0 41 | ) { 42 | this.useHttpRequests = true; 43 | } 44 | 45 | for (const priceFeedIds of priceFeedIdsArrays) { 46 | const filteredMarkets = markets.filter((market) => 47 | priceFeedIds.includes(market.pythLazerId!) 48 | ); 49 | for (const market of filteredMarkets) { 50 | this.marketIndextoPriceFeedIdChunk.set( 51 | market.marketIndex, 52 | priceFeedIds 53 | ); 54 | this.marketIndextoPriceFeedId.set( 55 | market.marketIndex, 56 | market.pythLazerId! 57 | ); 58 | } 59 | } 60 | } 61 | 62 | async subscribe() { 63 | // Will use http requests if chunk size is 1 and there are more than 3 ids 64 | if (this.useHttpRequests) { 65 | return; 66 | } 67 | 68 | this.pythLazerClient = await PythLazerClient.create( 69 | this.endpoints, 70 | this.token 71 | ); 72 | let subscriptionId = 1; 73 | for (const priceFeedIds of this.priceFeedIdsArrays) { 74 | const feedIdsHash = this.hash(priceFeedIds); 75 | this.feedIdHashToFeedIds.set(feedIdsHash, priceFeedIds); 76 | this.subscriptionIdsToFeedIdsHash.set(subscriptionId, feedIdsHash); 77 | this.pythLazerClient.addMessageListener((message) => { 78 | this.receivingData = true; 79 | clearTimeout(this.timeoutId); 80 | switch (message.type) { 81 | case 'json': { 82 | if (message.value.type == 'streamUpdated') { 83 | if (message.value.solana?.data) { 84 | this.feedIdChunkToPriceMessage.set( 85 | this.subscriptionIdsToFeedIdsHash.get( 86 | message.value.subscriptionId 87 | )!, 88 | message.value.solana.data 89 | ); 90 | } 91 | if (message.value.parsed?.priceFeeds) { 92 | for (const priceFeed of message.value.parsed.priceFeeds) { 93 | const price = 94 | Number(priceFeed.price!) * 95 | Math.pow(10, Number(priceFeed.exponent!)); 96 | this.feedIdToPrice.set(priceFeed.priceFeedId, price); 97 | } 98 | } 99 | } 100 | break; 101 | } 102 | default: { 103 | break; 104 | } 105 | } 106 | this.setTimeout(); 107 | }); 108 | this.pythLazerClient.send({ 109 | type: 'subscribe', 110 | subscriptionId, 111 | priceFeedIds, 112 | properties: ['price', 'bestAskPrice', 'bestBidPrice', 'exponent'], 113 | chains: ['solana'], 114 | deliveryFormat: 'json', 115 | channel: this.subscribeChannel as Channel, 116 | jsonBinaryEncoding: 'hex', 117 | }); 118 | subscriptionId++; 119 | } 120 | 121 | this.receivingData = true; 122 | this.setTimeout(); 123 | } 124 | 125 | protected setTimeout(): void { 126 | this.timeoutId = setTimeout(async () => { 127 | if (this.isUnsubscribing) { 128 | // If we are in the process of unsubscribing, do not attempt to resubscribe 129 | return; 130 | } 131 | 132 | if (this.receivingData) { 133 | console.log(`No ws data from pyth lazer client resubscribing`); 134 | await this.unsubscribe(); 135 | this.receivingData = false; 136 | await this.subscribe(); 137 | } 138 | }, this.resubTimeoutMs); 139 | } 140 | 141 | async unsubscribe() { 142 | this.isUnsubscribing = true; 143 | this.pythLazerClient?.shutdown(); 144 | this.pythLazerClient = undefined; 145 | clearTimeout(this.timeoutId); 146 | this.timeoutId = undefined; 147 | this.isUnsubscribing = false; 148 | } 149 | 150 | hash(arr: number[]): string { 151 | return 'h:' + arr.join('|'); 152 | } 153 | 154 | async getLatestPriceMessage(feedIds: number[]): Promise { 155 | if (this.useHttpRequests) { 156 | if (feedIds.length === 1 && this.redisClient) { 157 | const priceMessage = (await this.redisClient.get( 158 | `pythLazerData:${feedIds[0]}` 159 | )) as { data: string; ts: number } | undefined; 160 | if (priceMessage?.data && Date.now() - priceMessage.ts < 5000) { 161 | return priceMessage.data; 162 | } 163 | } 164 | for (const url of this.httpEndpoints) { 165 | const priceMessage = await this.fetchLatestPriceMessage(url, feedIds); 166 | if (priceMessage) { 167 | return priceMessage; 168 | } 169 | } 170 | return undefined; 171 | } 172 | return this.feedIdChunkToPriceMessage.get(this.hash(feedIds)); 173 | } 174 | 175 | async fetchLatestPriceMessage( 176 | url: string, 177 | feedIds: number[] 178 | ): Promise { 179 | try { 180 | const result = await axios.default.post( 181 | url, 182 | { 183 | priceFeedIds: feedIds, 184 | properties: ['price', 'bestAskPrice', 'bestBidPrice', 'exponent'], 185 | chains: ['solana'], 186 | channel: 'real_time', 187 | jsonBinaryEncoding: 'hex', 188 | }, 189 | { 190 | headers: { 191 | Authorization: `Bearer ${this.token}`, 192 | }, 193 | } 194 | ); 195 | if (result.data && result.status == 200) { 196 | return result.data['solana']['data']; 197 | } 198 | } catch (e) { 199 | console.error(e); 200 | return undefined; 201 | } 202 | } 203 | 204 | async getLatestPriceMessageForMarketIndex( 205 | marketIndex: number 206 | ): Promise { 207 | const feedIds = this.marketIndextoPriceFeedIdChunk.get(marketIndex); 208 | if (!feedIds) { 209 | return undefined; 210 | } 211 | return await this.getLatestPriceMessage(feedIds); 212 | } 213 | 214 | getPriceFeedIdsFromMarketIndex(marketIndex: number): number[] { 215 | return this.marketIndextoPriceFeedIdChunk.get(marketIndex) || []; 216 | } 217 | 218 | getPriceFeedIdsFromHash(hash: string): number[] { 219 | return this.feedIdHashToFeedIds.get(hash) || []; 220 | } 221 | 222 | getPriceFromMarketIndex(marketIndex: number): number | undefined { 223 | const feedId = this.marketIndextoPriceFeedId.get(marketIndex); 224 | if (feedId === undefined) { 225 | return undefined; 226 | } 227 | return this.feedIdToPrice.get(feedId); 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /src/pythPriceFeedSubscriber.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PriceFeed, 3 | PriceServiceConnection, 4 | PriceServiceConnectionConfig, 5 | } from '@pythnetwork/price-service-client'; 6 | 7 | export class PythPriceFeedSubscriber extends PriceServiceConnection { 8 | protected latestPythVaas: Map = new Map(); // priceFeedId -> vaa 9 | 10 | constructor(endpoint: string, config: PriceServiceConnectionConfig) { 11 | super(endpoint, config); 12 | } 13 | 14 | async subscribe(feedIds: string[]) { 15 | await super.subscribePriceFeedUpdates(feedIds, (priceFeed: PriceFeed) => { 16 | if (priceFeed.vaa) { 17 | const priceFeedId = '0x' + priceFeed.id; 18 | this.latestPythVaas.set(priceFeedId, priceFeed.vaa); 19 | } 20 | }); 21 | } 22 | 23 | getLatestCachedVaa(feedId: string): string | undefined { 24 | return this.latestPythVaas.get(feedId); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/types.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { BN, isVariant } from '@drift-labs/sdk'; 3 | import { TwapExecutionProgress } from './types'; 4 | import { selectMakers } from './makerSelection'; 5 | import { MAX_MAKERS_PER_FILL } from './bots/filler'; 6 | 7 | describe('TwapExecutionProgress', () => { 8 | const startTs = 1000; 9 | it('should calculate correct execution direction and progress', () => { 10 | let twap = new TwapExecutionProgress({ 11 | currentPosition: new BN(0), 12 | targetPosition: new BN(10), 13 | overallDurationSec: 10, 14 | startTimeSec: startTs, 15 | }); 16 | let remaining = twap.getAmountRemaining(); 17 | expect(remaining.toString()).to.be.equal(new BN(10).toString()); 18 | expect(isVariant(twap.getExecutionDirection(), 'long')).to.be.equal(true); 19 | 20 | twap = new TwapExecutionProgress({ 21 | currentPosition: new BN(10), 22 | targetPosition: new BN(0), 23 | overallDurationSec: 10, 24 | startTimeSec: startTs, 25 | }); 26 | remaining = twap.getAmountRemaining(); 27 | expect(remaining.toString()).to.be.equal(new BN(10).toString()); 28 | expect(isVariant(twap.getExecutionDirection(), 'short')).to.be.equal(true); 29 | 30 | twap = new TwapExecutionProgress({ 31 | currentPosition: new BN(-10), 32 | targetPosition: new BN(30), 33 | overallDurationSec: 10, 34 | startTimeSec: startTs, 35 | }); 36 | remaining = twap.getAmountRemaining(); 37 | expect(remaining.toString()).to.be.equal(new BN(40).toString()); 38 | expect(isVariant(twap.getExecutionDirection(), 'long')).to.be.equal(true); 39 | 40 | twap = new TwapExecutionProgress({ 41 | currentPosition: new BN(10), 42 | targetPosition: new BN(-30), 43 | overallDurationSec: 10, 44 | startTimeSec: startTs, 45 | }); 46 | remaining = twap.getAmountRemaining(); 47 | expect(remaining.toString()).to.be.equal(new BN(40).toString()); 48 | expect(isVariant(twap.getExecutionDirection(), 'short')).to.be.equal(true); 49 | }); 50 | 51 | it('should calculate execution slice correctly', () => { 52 | let twap = new TwapExecutionProgress({ 53 | currentPosition: new BN(0), 54 | targetPosition: new BN(10), 55 | overallDurationSec: 10, 56 | startTimeSec: startTs, 57 | }); 58 | let slice = twap.getExecutionSlice(startTs + 1); 59 | expect(slice.toString()).to.be.equal(new BN(1).toString()); 60 | 61 | twap = new TwapExecutionProgress({ 62 | currentPosition: new BN(-5000), 63 | targetPosition: new BN(10000), 64 | overallDurationSec: 300, 65 | startTimeSec: startTs, 66 | }); 67 | slice = twap.getExecutionSlice(startTs + 1); 68 | expect(slice.toString()).to.be.equal(new BN(50).toString()); 69 | expect(isVariant(twap.getExecutionDirection(), 'long')).to.be.equal(true); 70 | 71 | twap = new TwapExecutionProgress({ 72 | currentPosition: new BN(5000), 73 | targetPosition: new BN(-10000), 74 | overallDurationSec: 300, 75 | startTimeSec: startTs, 76 | }); 77 | slice = twap.getExecutionSlice(startTs + 1); 78 | expect(slice.toString()).to.be.equal(new BN(50).toString()); 79 | expect(isVariant(twap.getExecutionDirection(), 'short')).to.be.equal(true); 80 | }); 81 | 82 | it('should update progress and calculate execution slice correctly', () => { 83 | // no flip 84 | let twap = new TwapExecutionProgress({ 85 | currentPosition: new BN(0), 86 | targetPosition: new BN(10), 87 | overallDurationSec: 10, 88 | startTimeSec: startTs, 89 | }); 90 | let slice = twap.getExecutionSlice(startTs + 1); 91 | expect(slice.toString()).to.be.equal(new BN(1).toString()); 92 | let remaining = twap.getAmountRemaining(); 93 | expect(remaining.toString()).to.be.equal(new BN(10).toString()); 94 | expect(isVariant(twap.getExecutionDirection(), 'long')).to.be.equal(true); 95 | 96 | twap.updateProgress(new BN(1), startTs); 97 | slice = twap.getExecutionSlice(startTs + 1); 98 | twap.updateExecution(startTs + 1); 99 | expect(slice.toString()).to.be.equal(new BN(1).toString()); 100 | remaining = twap.getAmountRemaining(); 101 | expect(remaining.toString()).to.be.equal(new BN(9).toString()); 102 | expect(isVariant(twap.getExecutionDirection(), 'long')).to.be.equal(true); 103 | 104 | // flip short -> long 105 | twap = new TwapExecutionProgress({ 106 | currentPosition: new BN(-5000), 107 | targetPosition: new BN(10000), 108 | overallDurationSec: 300, 109 | startTimeSec: startTs, 110 | }); 111 | slice = twap.getExecutionSlice(startTs + 1); 112 | expect(slice.toString()).to.be.equal(new BN(50).toString()); 113 | remaining = twap.getAmountRemaining(); 114 | expect(remaining.toString()).to.be.equal(new BN(15000).toString()); 115 | expect(isVariant(twap.getExecutionDirection(), 'long')).to.be.equal(true); 116 | 117 | twap.updateProgress(new BN(-4950), startTs + 1); 118 | slice = twap.getExecutionSlice(startTs + 1); 119 | twap.updateExecution(startTs + 2); 120 | expect(slice.toString()).to.be.equal(new BN(50).toString()); 121 | remaining = twap.getAmountRemaining(); 122 | expect(remaining.toString()).to.be.equal(new BN(14950).toString()); 123 | expect(isVariant(twap.getExecutionDirection(), 'long')).to.be.equal(true); 124 | 125 | // flip long -> short 126 | twap = new TwapExecutionProgress({ 127 | currentPosition: new BN(5000), 128 | targetPosition: new BN(-10000), 129 | overallDurationSec: 300, 130 | startTimeSec: startTs, 131 | }); 132 | slice = twap.getExecutionSlice(startTs + 1); 133 | expect(slice.toString()).to.be.equal(new BN(50).toString()); 134 | remaining = twap.getAmountRemaining(); 135 | expect(remaining.toString()).to.be.equal(new BN(15000).toString()); 136 | expect(isVariant(twap.getExecutionDirection(), 'short')).to.be.equal(true); 137 | 138 | twap.updateProgress(new BN(4950), startTs + 1); 139 | slice = twap.getExecutionSlice(startTs + 1); 140 | twap.updateExecution(startTs + 1); 141 | expect(slice.toString()).to.be.equal(new BN(50).toString()); 142 | remaining = twap.getAmountRemaining(); 143 | expect(remaining.toString()).to.be.equal(new BN(14950).toString()); 144 | expect(isVariant(twap.getExecutionDirection(), 'short')).to.be.equal(true); 145 | 146 | // position increased while closing long 147 | twap = new TwapExecutionProgress({ 148 | currentPosition: new BN(5000), 149 | targetPosition: new BN(0), 150 | overallDurationSec: 300, 151 | startTimeSec: startTs, 152 | }); 153 | slice = twap.getExecutionSlice(startTs + 1); 154 | expect(slice.toString()).to.be.equal(new BN(16).toString()); 155 | remaining = twap.getAmountRemaining(); 156 | expect(remaining.toString()).to.be.equal(new BN(5000).toString()); 157 | expect(isVariant(twap.getExecutionDirection(), 'short')).to.be.equal(true); 158 | 159 | // filled a bit 160 | twap.updateProgress(new BN(4950), startTs + 1); 161 | slice = twap.getExecutionSlice(startTs + 1); 162 | twap.updateExecution(startTs + 1); 163 | expect(slice.toString()).to.be.equal(new BN(16).toString()); 164 | remaining = twap.getAmountRemaining(); 165 | expect(remaining.toString()).to.be.equal(new BN(4950).toString()); 166 | expect(isVariant(twap.getExecutionDirection(), 'short')).to.be.equal(true); 167 | 168 | // position got bigger 169 | twap.updateProgress(new BN(5500), startTs + 1); 170 | slice = twap.getExecutionSlice(startTs); 171 | twap.updateExecution(startTs + 2); 172 | expect(slice.toString()).to.be.equal(new BN(18).toString()); // should recalculate slice based on new larger position 173 | remaining = twap.getAmountRemaining(); 174 | expect(remaining.toString()).to.be.equal(new BN(5500).toString()); 175 | expect(isVariant(twap.getExecutionDirection(), 'short')).to.be.equal(true); 176 | }); 177 | 178 | it('should correctly size slice as time passes', () => { 179 | // want to trade out of 1000 over 300s (5 min) 180 | const twap = new TwapExecutionProgress({ 181 | currentPosition: new BN(1000), 182 | targetPosition: new BN(0), 183 | overallDurationSec: 300, 184 | startTimeSec: startTs, 185 | }); 186 | let slice = twap.getExecutionSlice(startTs + 0); 187 | expect(slice.toString()).to.be.equal(new BN(0).toString()); 188 | 189 | slice = twap.getExecutionSlice(startTs + 100); 190 | expect(slice.toString()).to.be.equal(new BN(333).toString()); 191 | 192 | slice = twap.getExecutionSlice(startTs + 150); 193 | expect(slice.toString()).to.be.equal(new BN(500).toString()); 194 | 195 | slice = twap.getExecutionSlice(startTs + 300); 196 | expect(slice.toString()).to.be.equal(new BN(1000).toString()); 197 | 198 | slice = twap.getExecutionSlice(startTs + 400); 199 | expect(slice.toString()).to.be.equal(new BN(1000).toString()); 200 | }); 201 | 202 | it('should calculate correct execution for first slice of new init', () => { 203 | const twap = new TwapExecutionProgress({ 204 | currentPosition: new BN(0), 205 | targetPosition: new BN(0), 206 | overallDurationSec: 3000, // 6 min 207 | startTimeSec: startTs, 208 | }); 209 | const remaining = twap.getAmountRemaining(); 210 | expect(remaining.toString()).to.be.equal(new BN(0).toString()); 211 | 212 | const startTs1 = startTs + 1000; 213 | twap.updateProgress(new BN(300_000), startTs1); // 300_000 over 3000s, is 100 per second 214 | let slice = twap.getExecutionSlice(startTs1); 215 | twap.updateExecution(startTs1); 216 | expect(slice.toString()).to.be.equal(new BN(100 * 1000).toString()); 217 | 218 | const startTs2 = startTs + 2000; 219 | twap.updateProgress(new BN(200_000), startTs2); 220 | slice = twap.getExecutionSlice(startTs2); 221 | twap.updateExecution(startTs2); 222 | expect(slice.toString()).to.be.equal(new BN(100 * 1000).toString()); 223 | 224 | const startTs3 = startTs + 3000; 225 | twap.updateProgress(new BN(100_000), startTs3); 226 | slice = twap.getExecutionSlice(startTs3); 227 | twap.updateExecution(startTs3); 228 | expect(slice.toString()).to.be.equal(new BN(100 * 1000).toString()); 229 | 230 | const startTs4 = startTs + 4000; 231 | twap.updateProgress(new BN(0), startTs4); 232 | slice = twap.getExecutionSlice(startTs4); 233 | twap.updateExecution(startTs4); 234 | expect(slice.toString()).to.be.equal(new BN(0).toString()); 235 | }); 236 | 237 | it('should correctly size slice as time passes with fills', () => { 238 | // want to trade out of 1000 over 300s (5 min) 239 | const twap = new TwapExecutionProgress({ 240 | currentPosition: new BN(1000), 241 | targetPosition: new BN(0), 242 | overallDurationSec: 300, 243 | startTimeSec: startTs, 244 | }); 245 | let slice = twap.getExecutionSlice(startTs + 0); 246 | expect(slice.toString()).to.be.equal(new BN(0).toString()); 247 | 248 | slice = twap.getExecutionSlice(startTs + 100); 249 | expect(slice.toString()).to.be.equal(new BN(333).toString()); 250 | 251 | slice = twap.getExecutionSlice(startTs + 150); 252 | expect(slice.toString()).to.be.equal(new BN(500).toString()); 253 | 254 | // fill half 255 | twap.updateProgress(new BN(500), startTs + 150); 256 | slice = twap.getExecutionSlice(startTs + 150); 257 | twap.updateExecution(startTs + 150); 258 | slice = twap.getExecutionSlice(startTs + 150); 259 | expect(slice.toString()).to.be.equal(new BN(0).toString()); 260 | 261 | slice = twap.getExecutionSlice(startTs + 300); 262 | expect(slice.toString()).to.be.equal(new BN(500).toString()); 263 | 264 | twap.updateProgress(new BN(0), startTs + 300); 265 | slice = twap.getExecutionSlice(startTs + 400); 266 | twap.updateExecution(startTs + 400); 267 | expect(slice.toString()).to.be.equal(new BN(0).toString()); 268 | }); 269 | }); 270 | 271 | describe('selectMakers', () => { 272 | let originalRandom: { (): number; (): number }; 273 | 274 | beforeEach(() => { 275 | // Mock Math.random 276 | let seed = 12345; 277 | originalRandom = Math.random; 278 | Math.random = () => { 279 | const x = Math.sin(seed++) * 10000; 280 | return x - Math.floor(x); 281 | }; 282 | }); 283 | 284 | afterEach(() => { 285 | // Restore original Math.random 286 | Math.random = originalRandom; 287 | }); 288 | 289 | it('more than 6', function () { 290 | // Mock DLOBNode and Order 291 | const mockOrder = (filledAmount: number, orderId: number) => ({ 292 | orderId, 293 | baseAssetAmount: new BN(100), 294 | baseAssetAmountFilled: new BN(filledAmount), 295 | }); 296 | 297 | const mockDLOBNode = (filledAmount: number, orderId: number) => ({ 298 | order: mockOrder(filledAmount, orderId), 299 | // Include other necessary properties of DLOBNode if needed 300 | }); 301 | 302 | const makerNodeMap = new Map([ 303 | ['0', [mockDLOBNode(10, 0)]], 304 | ['1', [mockDLOBNode(20, 1)]], 305 | ['2', [mockDLOBNode(30, 2)]], 306 | ['3', [mockDLOBNode(40, 3)]], 307 | ['4', [mockDLOBNode(50, 4)]], 308 | ['5', [mockDLOBNode(60, 5)]], 309 | ['6', [mockDLOBNode(70, 6)]], 310 | ['7', [mockDLOBNode(80, 7)]], 311 | ['8', [mockDLOBNode(90, 8)]], 312 | ]); 313 | 314 | // @ts-ignore 315 | const selectedMakers = selectMakers(makerNodeMap); 316 | 317 | expect(selectedMakers).to.not.be.undefined; 318 | expect(selectedMakers.size).to.be.equal(MAX_MAKERS_PER_FILL); 319 | 320 | expect(selectedMakers.get('0')).to.not.be.undefined; 321 | expect(selectedMakers.get('1')).to.not.be.undefined; 322 | expect(selectedMakers.get('2')).to.not.be.undefined; 323 | expect(selectedMakers.get('3')).to.not.be.undefined; 324 | expect(selectedMakers.get('4')).to.be.undefined; 325 | expect(selectedMakers.get('5')).to.not.be.undefined; 326 | expect(selectedMakers.get('6')).to.not.be.undefined; 327 | expect(selectedMakers.get('7')).to.be.undefined; 328 | expect(selectedMakers.get('8')).to.be.undefined; 329 | }); 330 | }); 331 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { BN, PositionDirection } from '@drift-labs/sdk'; 2 | import { PriceServiceConnection } from '@pythnetwork/price-service-client'; 3 | 4 | export const constants = { 5 | devnet: { 6 | USDCMint: '8zGuJQqwhZafTah7Uc7Z4tXRnguqkn5KLFAP8oV6PHe2', 7 | }, 8 | 'mainnet-beta': { 9 | USDCMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', 10 | }, 11 | }; 12 | 13 | export enum OrderExecutionAlgoType { 14 | Market = 'market', 15 | Twap = 'twap', 16 | } 17 | 18 | export type TwapExecutionConfig = { 19 | currentPosition: BN; 20 | targetPosition: BN; 21 | overallDurationSec: number; 22 | startTimeSec: number; 23 | }; 24 | 25 | export class TwapExecutionProgress { 26 | amountStart: BN; 27 | currentPosition: BN; 28 | amountTarget: BN; 29 | overallDurationSec: number; 30 | startTimeSec: number; 31 | lastUpdateSec: number; 32 | lastExecSec: number; 33 | firstExecDone = false; 34 | 35 | constructor(config: TwapExecutionConfig) { 36 | this.amountStart = config.currentPosition; 37 | this.currentPosition = config.currentPosition; 38 | this.amountTarget = config.targetPosition; 39 | this.overallDurationSec = config.overallDurationSec; 40 | this.startTimeSec = config.startTimeSec; 41 | this.lastUpdateSec = this.startTimeSec; 42 | this.lastExecSec = this.startTimeSec; 43 | } 44 | 45 | /** 46 | * 47 | * @returns the amount to execute in the current slice (absolute value) 48 | */ 49 | getExecutionSlice(nowSec: number): BN { 50 | // twap based on how much time has elapsed since last execution 51 | const orderSize = this.amountTarget.sub(this.amountStart); 52 | const secElapsed = new BN(nowSec - this.lastExecSec); 53 | const slice = orderSize 54 | .abs() 55 | .mul(secElapsed) 56 | .div(new BN(this.overallDurationSec)) 57 | .abs(); 58 | if (slice.gt(orderSize.abs())) { 59 | return orderSize.abs(); 60 | } 61 | const remaining = this.getAmountRemaining(); 62 | if (remaining.lt(slice)) { 63 | return remaining; 64 | } 65 | return slice; 66 | } 67 | 68 | /** 69 | * 70 | * @returns the execution direction (LONG or SHORT) to place orders 71 | */ 72 | getExecutionDirection(): PositionDirection { 73 | return this.amountTarget.gt(this.currentPosition) 74 | ? PositionDirection.LONG 75 | : PositionDirection.SHORT; 76 | } 77 | 78 | /** 79 | * 80 | * @returns the amount remaining to be executed (absolute value) 81 | */ 82 | getAmountRemaining(): BN { 83 | return this.amountTarget.sub(this.currentPosition).abs(); 84 | } 85 | 86 | /** 87 | * 88 | * @param currentPosition the current position base asset amount 89 | */ 90 | updateProgress(currentPosition: BN, nowSec: number): void { 91 | // if the new position is ultimately a larger order, reinit the twap so the slices reflect the new desired order 92 | const currExecutionSize = this.amountTarget.sub(this.amountStart).abs(); 93 | const newExecutionSize = this.amountTarget.sub(currentPosition).abs(); 94 | if (newExecutionSize.gt(currExecutionSize)) { 95 | this.amountStart = currentPosition; 96 | this.startTimeSec = nowSec; 97 | } 98 | 99 | this.currentPosition = currentPosition; 100 | this.lastUpdateSec = nowSec; 101 | } 102 | 103 | updateExecution(nowSec: number): void { 104 | this.lastExecSec = nowSec; 105 | this.firstExecDone = true; 106 | } 107 | 108 | updateTarget(newTarget: BN, nowSec: number): void { 109 | this.amountTarget = newTarget; 110 | this.startTimeSec = nowSec; 111 | this.lastUpdateSec = nowSec; 112 | } 113 | } 114 | 115 | export interface Bot { 116 | readonly name: string; 117 | readonly dryRun: boolean; 118 | readonly defaultIntervalMs?: number; 119 | readonly pythConnection?: PriceServiceConnection; 120 | 121 | /** 122 | * Initialize the bot 123 | */ 124 | init: () => Promise; 125 | 126 | /** 127 | * Reset the bot. This is called to reset the bot to a fresh state (pre-init). 128 | */ 129 | reset: () => Promise; 130 | 131 | /** 132 | * Start the bot loop. This is generally a polling loop. 133 | */ 134 | startIntervalLoop: (intervalMs?: number) => Promise; 135 | 136 | /** 137 | * Returns true if bot is healthy, else false. Typically used for monitoring liveness. 138 | */ 139 | healthCheck: () => Promise; 140 | } 141 | -------------------------------------------------------------------------------- /src/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { isSetComputeUnitsIx } from './utils'; 3 | import { ComputeBudgetProgram } from '@solana/web3.js'; 4 | 5 | describe('transaction simulation tests', () => { 6 | it('isSetComputeUnitsIx', () => { 7 | const cuLimitIx = ComputeBudgetProgram.setComputeUnitLimit({ 8 | units: 1_400_000, 9 | }); 10 | const cuPriceIx = ComputeBudgetProgram.setComputeUnitPrice({ 11 | microLamports: 10_000, 12 | }); 13 | 14 | expect(isSetComputeUnitsIx(cuLimitIx)).to.be.true; 15 | expect(isSetComputeUnitsIx(cuPriceIx)).to.be.false; 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/webhook.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | require('dotenv').config(); 3 | import { logger } from './logger'; 4 | 5 | // enum for webhook type (slack, telegram, ...) 6 | export enum WebhookType { 7 | Slack = 'slack', 8 | Discord = 'discord', 9 | Telegram = 'telegram', 10 | } 11 | 12 | export async function webhookMessage( 13 | message: string, 14 | webhookUrl?: string, 15 | prefix?: string, 16 | webhookType?: WebhookType 17 | ): Promise { 18 | if (process.env.WEBHOOK_TYPE) { 19 | webhookType = process.env.WEBHOOK_TYPE as WebhookType; 20 | } 21 | if (!webhookType) { 22 | webhookType = WebhookType.Discord; 23 | } 24 | if (webhookUrl || process.env.WEBHOOK_URL) { 25 | const webhook = webhookUrl || process.env.WEBHOOK_URL; 26 | const fullMessage = prefix ? prefix + message : message; 27 | if (fullMessage && webhook) { 28 | try { 29 | let data; 30 | switch (webhookType) { 31 | case WebhookType.Slack: 32 | data = { 33 | text: fullMessage, 34 | }; 35 | break; 36 | case WebhookType.Discord: 37 | data = { 38 | content: fullMessage, 39 | }; 40 | break; 41 | case WebhookType.Telegram: 42 | data = { 43 | text: fullMessage, 44 | }; 45 | break; 46 | default: 47 | logger.error(`webhookMessage: unknown webhookType: ${webhookType}`); 48 | return; 49 | } 50 | 51 | await axios.post(webhook, data); 52 | } catch (err) { 53 | logger.info('webhook error'); 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": [ 4 | "mocha", 5 | "node", 6 | ], 7 | "module": "commonjs", 8 | "target": "es2019", 9 | "esModuleInterop": true, 10 | "preserveConstEnums": true, 11 | "sourceMap": true, 12 | "resolveJsonModule": true, 13 | "skipLibCheck": true, 14 | "experimentalDecorators": true, 15 | "emitDecoratorMetadata": true, 16 | "baseUrl": ".", 17 | "rootDirs": [ 18 | "src" 19 | ], 20 | "outDir": "./lib", 21 | "strict": true, 22 | "paths": { 23 | "@drift/common/clients": ["./drift-common/common-ts/lib/clients"], 24 | } 25 | }, 26 | "include": [ 27 | "src/**/*" 28 | ], 29 | "exclude": [ 30 | "node_modules", 31 | "src/**/*.test.ts" 32 | ] 33 | } --------------------------------------------------------------------------------