├── .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 |
%20(1).png)
3 |
4 |
Keeper Bots for Drift Protocol v2
5 |
6 |
7 |
8 |
9 |
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 | }
--------------------------------------------------------------------------------