├── .circleci └── config.yml ├── .github └── workflows │ └── gitleaks.yml ├── .gitignore ├── .gitleaksignore ├── .gitmodules ├── .pre-commit-config.yaml ├── Dockerfile ├── LICENSE ├── Makefile ├── PRECOMMIT.md ├── README.md ├── benchmark ├── README.md ├── geyser-benchmark-no-file ├── internal │ ├── actor │ │ ├── jupiter_opts.go │ │ ├── jupiter_swap.go │ │ └── liquidity.go │ ├── csv │ │ └── writer.go │ ├── logger │ │ └── log.go │ ├── output │ │ └── slot.go │ ├── stream │ │ ├── jupiter_api.go │ │ ├── jupiter_opts.go │ │ ├── jupiter_price.go │ │ ├── jupiter_quote.go │ │ ├── quote.go │ │ ├── solanaws_orderbook.go │ │ ├── source.go │ │ ├── traderhttp_price.go │ │ ├── traderhttp_price_opts.go │ │ ├── traderws_orderbook.go │ │ ├── traderws_price.go │ │ ├── traderws_price_opts.go │ │ └── traderws_pumpfun.go │ ├── throughput │ │ ├── listener.go │ │ └── size.go │ ├── transaction │ │ ├── status.go │ │ ├── status_summary.go │ │ └── submit.go │ └── utils │ │ ├── concurrent.go │ │ └── flag.go ├── provider_compare │ └── main.go ├── pumpfun_newtoken_compare │ ├── README.md │ ├── block │ │ ├── block_subscribe.go │ │ └── types.go │ └── main.go ├── quotes │ ├── README.md │ ├── env.go │ ├── flag.go │ ├── main.go │ └── process.go ├── test │ ├── atulsriv@160.202.128.145 │ └── helloworld ├── traderapi │ ├── README.md │ ├── main.go │ └── output.go ├── txcompare │ ├── README.md │ ├── main.go │ └── output.go └── types.go ├── build.sh ├── connections ├── common.go ├── grpc.go ├── http.go ├── jsonrpc.go ├── ws.go └── wsrpc_trackers.go ├── examples ├── config │ └── env.go ├── grpcclient │ └── main.go ├── helpers.go ├── httpclient │ └── main.go └── wsclient │ └── main.go ├── go.mod ├── go.sum ├── package_info.go ├── provider ├── common.go ├── grpc.go ├── grpc_interfaces.go ├── http.go ├── http_interfaces.go ├── recent_hash_store.go ├── ws.go └── ws_interfaces.go ├── transaction ├── create.go ├── memo.go ├── memo_test.go ├── signing.go └── signing_test.go └── utils ├── bundle.go ├── locked_map.go ├── logger.go ├── proto.go ├── request_id.go └── timestamp.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | executors: 3 | bxgo: 4 | docker: 5 | - image: cimg/go:1.21 6 | environment: 7 | GOPATH: /home/circleci/go 8 | WORKSPACE: /home/circleci/go/src/github.com/bloXroute-Labs/solana-trader-client-go 9 | GOTRACEBACK: all 10 | RUN_TRADES: false 11 | RUN_SLOW_STREAM: false 12 | RUN_PERP_TRADES: true 13 | working_directory: /home/circleci/go/src/github.com/bloXroute-Labs/solana-trader-client-go 14 | jobs: 15 | init: 16 | executor: bxgo 17 | steps: 18 | - attach_workspace: 19 | at: /home/circleci/go 20 | - checkout 21 | - restore_cache: 22 | keys: 23 | - v1-go-mod-{{checksum "go.sum"}} 24 | - run: 25 | name: Update/install packages 26 | command: | 27 | sudo apt update 28 | sudo apt install awscli 29 | - run: 30 | name: Download golint 31 | command: go get -u golang.org/x/lint/golint 32 | - run: 33 | name: Download dependencies 34 | command: go mod tidy 35 | - save_cache: 36 | key: v1-go-mod-{{checksum "go.sum"}} 37 | paths: 38 | - "/home/circleci/go/pkg/mod" 39 | - persist_to_workspace: 40 | root: /home/circleci/go/ 41 | paths: 42 | - src 43 | - pkg 44 | - bin 45 | unit: 46 | executor: bxgo 47 | steps: 48 | - attach_workspace: 49 | at: /home/circleci/go 50 | - run: 51 | name: Unit test 52 | command: make unit 53 | workflows: 54 | version: 2 55 | test-branch: 56 | when: 57 | not: 58 | equal: [ scheduled_pipeline, << pipeline.trigger_source >> ] 59 | jobs: 60 | - init: 61 | context: circleci 62 | - unit: 63 | requires: 64 | - init -------------------------------------------------------------------------------- /.github/workflows/gitleaks.yml: -------------------------------------------------------------------------------- 1 | name: gitleaks 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - develop 7 | - main 8 | workflow_dispatch: 9 | schedule: 10 | - cron: "0 4 * * *" 11 | jobs: 12 | scan: 13 | name: gitleaks 14 | runs-on: ubuntu-latest 15 | environment: develop 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - uses: gitleaks/gitleaks-action@v2.3.7 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | GITLEAKS_LICENSE: "${{ secrets.GITLEAKS_LICENSE }}" 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .idea/ 3 | *.csv 4 | .vscode/launch.json 5 | .run 6 | vendor 7 | .vscode/settings.json 8 | examples/httpclient/__debug_bin* 9 | -------------------------------------------------------------------------------- /.gitleaksignore: -------------------------------------------------------------------------------- 1 | d243e75f5db384bd4e54fe4ca5e3c6e88ef9ab04:examples/grpcclient/main.go:generic-api-key:880 2 | d243e75f5db384bd4e54fe4ca5e3c6e88ef9ab04:examples/wsclient/main.go:generic-api-key:922 3 | d243e75f5db384bd4e54fe4ca5e3c6e88ef9ab04:examples/httpclient/main.go:generic-api-key:833 -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "solana-trader-proto"] 2 | path = solana-trader-proto 3 | url = git@github.com:bloXroute-Labs/solana-trader-proto.git 4 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/gitleaks/gitleaks 3 | rev: v8.21.1 4 | hooks: 5 | - id: gitleaks 6 | name: Detect hardcoded secrets 7 | args: ["detect", "--source=."] 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.21-bullseye as gobuilder 2 | 3 | # setup environment 4 | RUN mkdir -p /app/solana-trader-client-go 5 | WORKDIR /app/solana-trader-client-go 6 | 7 | # copy files and download dependencies 8 | COPY . . 9 | RUN go mod download 10 | 11 | # build 12 | RUN rm -rf bin 13 | RUN go build -o bin/ ./benchmark/traderapi 14 | 15 | FROM golang:1.21-bullseye 16 | 17 | RUN apt-get update 18 | RUN apt-get install -y net-tools 19 | RUN rm -rf /var/lib/apt/lists/* 20 | 21 | # setup user 22 | RUN useradd -ms /bin/bash solana-trader-client-go 23 | 24 | # setup environment 25 | RUN mkdir -p /app/solana-trader-client-go 26 | RUN chown -R solana-trader-client-go:solana-trader-client-go /app/solana-trader-client-go 27 | 28 | WORKDIR /app/solana-trader-client-go 29 | 30 | COPY --from=builder /app/solana-trader-client-go/bin /app/solana-trader-client-go/bin 31 | 32 | ENTRYPOINT ["/app/solana-trader-client-go/bin/traderapi"] 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 bloXroute Labs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | test: 3 | go test -v ./... 4 | 5 | .PHONY: unit 6 | unit: 7 | go test -v ./... 8 | 9 | .PHONY: grpc-examples 10 | grpc-examples: 11 | go run ./examples/grpcclient/main.go 12 | 13 | .PHONY: http-examples 14 | http-examples: 15 | go run ./examples/httpclient/main.go 16 | 17 | .PHONY: ws-examples 18 | ws-examples: 19 | go run ./examples/wsclient/main.go 20 | 21 | .PHONY: ssl-testnet ssl-mainnet cred-github environment-dev 22 | 23 | environment-dev: ssl-testnet cred-github 24 | 25 | ssl-testnet: 26 | mkdir -p $(CURDIR)/ssl/testnet/bloxroute_cloud_api/registration_only 27 | aws s3 cp s3://internal-credentials.bxrtest.com/bloxroute_cloud_api/registration_only/bloxroute_cloud_api_cert.pem $(CURDIR)/ssl/testnet/bloxroute_cloud_api/registration_only/ 28 | aws s3 cp s3://internal-credentials.bxrtest.com/bloxroute_cloud_api/registration_only/bloxroute_cloud_api_key.pem $(CURDIR)/ssl/testnet/bloxroute_cloud_api/registration_only/ 29 | 30 | ssl-mainnet: 31 | mkdir -p $(CURDIR)/ssl/bloxroute_cloud_api/registration_only 32 | aws s3 cp s3://internal-credentials.blxrbdn.com/bloxroute_cloud_api/registration_only/bloxroute_cloud_api_cert.pem $(CURDIR)/ssl/bloxroute_cloud_api/registration_only/ 33 | aws s3 cp s3://internal-credentials.blxrbdn.com/bloxroute_cloud_api/registration_only/bloxroute_cloud_api_key.pem $(CURDIR)/ssl/bloxroute_cloud_api/registration_only/ 34 | 35 | cred-github: 36 | aws s3 cp s3://files.bloxroute.com/trader-api/.netrc $(CURDIR)/.netrc -------------------------------------------------------------------------------- /PRECOMMIT.md: -------------------------------------------------------------------------------- 1 | # Pre-commit Setup Guide 2 | 3 | ## Overview 4 | 5 | This guide will help you set up `pre-commit` hooks for your project. Pre-commit hooks are useful for automatically running checks before committing code to ensure code quality, security, and consistency. 6 | TechOps has enabled general golang linters and gitleaks which should be enabled on each commit. 7 | 8 | ## Prerequisites 9 | 10 | - Python 3.6 or higher 11 | - `pip` (Python package installer) 12 | - `git` installed and configured 13 | 14 | ## Installation 15 | 16 | To install `pre-commit`, follow these steps: 17 | 18 | 1. **Install pre-commit** 19 | You can install `pre-commit` using `pip`: 20 | 21 | ``` 22 | pip install pre-commit 23 | ``` 24 | 25 | ## Sample Usage 26 | This is a sample run on a basic commit: 27 | ``` 28 | $ git commit -m "add precommit" 29 | Check Yaml...........................................(no files to check)Skipped 30 | Fix End of Files.........................................................Passed 31 | Trim Trailing Whitespace.................................................Passed 32 | Check for added large files..............................................Passed 33 | go fmt...............................................(no files to check)Skipped 34 | go imports...........................................(no files to check)Skipped 35 | golangci-lint........................................(no files to check)Skipped 36 | Detect hardcoded secrets.................................................Passed 37 | ``` 38 | 39 | You can also run `pre-commit` test on all files: 40 | ``` 41 | pre-commit run --all-files 42 | ``` 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Solana Trader Golang Client 2 | 3 | ## Objective 4 | This SDK is designed to make it easy for you to use the bloXroute Labs Solana Trader API 5 | in Go. 6 | 7 | ## Installation 8 | ``` 9 | go get github.com/bloXroute-Labs/solana-trader-client-go 10 | ``` 11 | 12 | ## Usage 13 | 14 | This library supports HTTP, websockets, and GRPC interfaces. You must use websockets or GRPC for any streaming methods, 15 | but any simple request/response calls are universally supported. 16 | 17 | For any methods involving transaction creation you will need to provide your Solana private key. You can provide this 18 | via the environment variable `PRIVATE_KEY`, or specify it via the provider configuration if you want to load it with 19 | some other mechanism. See samples for more information. As a general note on this: methods named `Post*` (e.g. 20 | `PostOrder`) typically do not sign/submit the transaction, only return the raw unsigned transaction. This isn't 21 | very useful to most users (unless you want to write a signer in a different language), and you'll typically want the 22 | similarly named `Submit*` methods (e.g. `SubmitOrder`). These methods generate, sign, and submit the 23 | transaction all at once. 24 | 25 | You will also need your bloXroute authorization header to use these endpoints. By default, this is loaded from the 26 | `AUTH_HEADER` environment variable. 27 | 28 | ## Quickstart 29 | 30 | ### Request sample: 31 | 32 | ```go 33 | package main 34 | 35 | import ( 36 | "context" 37 | "fmt" 38 | "github.com/bloXroute-Labs/solana-trader-client-go/provider" 39 | pb "github.com/bloXroute-Labs/solana-trader-proto/api" 40 | ) 41 | 42 | func main() { 43 | // GPRC 44 | g, err := provider.NewGRPCClient() 45 | if err != nil { 46 | panic(err) 47 | } 48 | 49 | orderbook, err := g.GetOrderbook(context.Background(), "ETH/USDT", 5, pb.Project_P_OPENBOOK) // in this case limit to 5 bids and asks. 0 for no limit 50 | if err != nil { 51 | panic(err) 52 | } 53 | fmt.Println(orderbook) 54 | 55 | // HTTP 56 | h := provider.NewHTTPClient() 57 | tickers, err := h.GetTickers(context.Background(), "ETHUSDT", pb.Project_P_OPENBOOK) 58 | if err != nil { 59 | panic(err) 60 | } 61 | fmt.Println(tickers) 62 | 63 | // WS 64 | w, err := provider.NewWSClient() 65 | if err != nil { 66 | panic(err) 67 | } 68 | // note that open orders is a slow function call 69 | openOrders, err := w.GetOpenOrders(context.Background(), "ETH/USDT", "4raJjCwLLqw8TciQXYruDEF4YhDkGwoEnwnAdwJSjcgv", "", pb.Project_P_OPENBOOK) 70 | if err != nil { 71 | panic(err) 72 | } 73 | fmt.Println(openOrders) 74 | } 75 | 76 | ``` 77 | #### Stream (only in GRPC/WS): 78 | 79 | ```go 80 | package main 81 | 82 | import ( 83 | "fmt" 84 | "github.com/bloXroute-Labs/solana-trader-client-go/provider" 85 | pb "github.com/bloXroute-Labs/solana-trader-proto/api" 86 | "context" 87 | ) 88 | 89 | func main() { 90 | ctx, cancel := context.WithCancel(context.Background()) 91 | defer cancel() 92 | 93 | g, err := provider.NewGRPCClient() // replace this with `NewWSClient()` to use WebSockets 94 | if err != nil { 95 | panic(err) 96 | } 97 | 98 | stream, err := g.GetOrderbookStream(ctx, []string{"SOL/USDT"}, 5, pb.Project_P_OPENBOOK) 99 | if err != nil { 100 | panic(err) 101 | } 102 | 103 | // wrap result in channel for easy of use 104 | orderbookCh := make(chan *pb.GetOrderbooksStreamResponse) 105 | stream.Into(orderbookCh) 106 | for i := 0; i < 3; i++ { 107 | orderbook := <-orderbookCh 108 | fmt.Println(orderbook) 109 | } 110 | } 111 | ``` 112 | 113 | More code samples are provided in the `examples/` directory. 114 | 115 | **A quick note on market names:** 116 | You can use a couple of different formats, with restrictions: 117 | 1. `A/B` (only for GRPC/WS clients) --> `ETH/USDT` 118 | 2. `A:B` --> `ETH:USDT` 119 | 3. `A-B` --> `ETH-USDT` 120 | 4. `AB` --> `ETHUSDT` 121 | 122 | 123 | ## Development 124 | 125 | Unit tests: 126 | 127 | ``` 128 | $ make unit 129 | ``` 130 | 131 | Integration tests per provider: 132 | ``` 133 | $ make grpc-examples 134 | 135 | $ make http-examples 136 | 137 | $ make ws-examples 138 | ``` -------------------------------------------------------------------------------- /benchmark/README.md: -------------------------------------------------------------------------------- 1 | # Solana Trader API Benchmark Scripts 2 | 3 | This folder contains a series of scripts that can be run for the purposes of benchmarking various aspects relevant 4 | to the functioning of Solana Trader API. 5 | 6 | ## Scripts List 7 | - `traderapi`: Compares performance of Solana Trader API against fetching data directly from a reference Solana node. 8 | - `txcompare`: Compares submitting competing transactions to multiple Solana nodes to determine if one is consistently faster. 9 | - `quotes`: Compares performance of Solana Trader API against Jupiter API for quote calculation. 10 | 11 | You can read through the `main.go` files for more information. -------------------------------------------------------------------------------- /benchmark/geyser-benchmark-no-file: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloXroute-Labs/solana-trader-client-go/f9734f2ff3be3347cf073324275d2b7479ba958a/benchmark/geyser-benchmark-no-file -------------------------------------------------------------------------------- /benchmark/internal/actor/jupiter_opts.go: -------------------------------------------------------------------------------- 1 | package actor 2 | 3 | import ( 4 | "github.com/bloXroute-Labs/solana-trader-client-go/provider" 5 | "time" 6 | ) 7 | 8 | type JupiterOpt func(s *jupiterSwap) 9 | 10 | func WithJupiterTokenPair(inputMint, outputMint string) JupiterOpt { 11 | return func(s *jupiterSwap) { 12 | s.inputMint = inputMint 13 | s.outputMint = outputMint 14 | } 15 | } 16 | 17 | func WithJupiterAmount(amount float64) JupiterOpt { 18 | return func(s *jupiterSwap) { 19 | s.amount = amount 20 | } 21 | } 22 | 23 | func WithJupiterInitialTimeout(initialTimeout time.Duration) JupiterOpt { 24 | return func(s *jupiterSwap) { 25 | s.initialTimeout = initialTimeout 26 | } 27 | } 28 | 29 | func WithJupiterAfterTimeout(timeout time.Duration) JupiterOpt { 30 | return func(s *jupiterSwap) { 31 | s.afterTimeout = timeout 32 | } 33 | } 34 | 35 | func WithJupiterInterval(interval time.Duration) JupiterOpt { 36 | return func(s *jupiterSwap) { 37 | s.interval = interval 38 | } 39 | } 40 | 41 | func WithJupiterClient(client provider.HTTPClientTraderAPI) JupiterOpt { 42 | return func(s *jupiterSwap) { 43 | s.client = client 44 | } 45 | } 46 | 47 | func WithJupiterPublicKey(pk string) JupiterOpt { 48 | return func(s *jupiterSwap) { 49 | s.publicKey = pk 50 | } 51 | } 52 | 53 | func WithJupiterAlternate(alternate bool) JupiterOpt { 54 | return func(s *jupiterSwap) { 55 | s.alternate = alternate 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /benchmark/internal/actor/jupiter_swap.go: -------------------------------------------------------------------------------- 1 | package actor 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/logger" 8 | "github.com/bloXroute-Labs/solana-trader-client-go/examples/config" 9 | "github.com/bloXroute-Labs/solana-trader-client-go/provider" 10 | pb "github.com/bloXroute-Labs/solana-trader-proto/api" 11 | "go.uber.org/zap" 12 | "sync" 13 | "time" 14 | ) 15 | 16 | const ( 17 | defaultInterval = time.Second 18 | defaultInitialTimeout = 2 * time.Second 19 | defaultAfterTimeout = 2 * time.Second 20 | defaultSlippage = 1 21 | defaultAmount = 0.1 22 | ) 23 | 24 | // places orders to affect liquidity and trigger price changes 25 | type jupiterSwap struct { 26 | interval time.Duration 27 | initialTimeout time.Duration 28 | afterTimeout time.Duration 29 | inputMint string 30 | outputMint string 31 | amount float64 32 | slippage float64 33 | publicKey string 34 | alternate bool 35 | 36 | client provider.HTTPClientTraderAPI 37 | } 38 | 39 | func NewJupiterSwap(opts ...JupiterOpt) (Liquidity, error) { 40 | j := &jupiterSwap{ 41 | amount: defaultAmount, 42 | interval: defaultInterval, 43 | initialTimeout: defaultInitialTimeout, 44 | afterTimeout: defaultAfterTimeout, 45 | slippage: defaultSlippage, 46 | client: provider.NewHTTPClient(), 47 | } 48 | 49 | for _, o := range opts { 50 | o(j) 51 | } 52 | 53 | if j.inputMint == "" || j.outputMint == "" { 54 | return nil, errors.New("input and output mints are mandatory") 55 | } 56 | 57 | if j.publicKey == "" { 58 | return nil, errors.New("public key is mandatory") 59 | } 60 | 61 | return j, nil 62 | } 63 | 64 | func (j *jupiterSwap) log() *zap.SugaredLogger { 65 | return logger.Log().With("source", "jupiterActor") 66 | } 67 | 68 | func (j *jupiterSwap) Swap(ctx context.Context, iterations int) ([]SwapEvent, error) { 69 | submitOpts := provider.SubmitOpts{ 70 | SubmitStrategy: pb.SubmitStrategy_P_SUBMIT_ALL, 71 | SkipPreFlight: config.BoolPtr(true), 72 | } 73 | 74 | time.Sleep(j.initialTimeout) 75 | 76 | ticker := time.NewTicker(j.interval) 77 | defer ticker.Stop() 78 | 79 | errCh := make(chan error, 1) 80 | resultCh := make(chan error, iterations) 81 | signatures := make([]SwapEvent, 0, iterations) 82 | signatureLock := &sync.Mutex{} 83 | lastOutAmount := 0. 84 | 85 | j.log().Infow("starting swap submission", "total", iterations) 86 | 87 | for i := 0; i < iterations; i++ { 88 | select { 89 | case <-ticker.C: 90 | go func(i int) { 91 | j.log().Infow("submitting swap", "count", i) 92 | 93 | var ( 94 | inputMint = j.inputMint 95 | outputMint = j.outputMint 96 | amount = j.amount 97 | ) 98 | 99 | if j.alternate && i%2 == 1 { 100 | inputMint, outputMint = outputMint, inputMint 101 | amount = lastOutAmount 102 | } 103 | 104 | info := fmt.Sprintf("%v => %v: %v", inputMint, outputMint, amount) 105 | 106 | postResponse, err := j.client.PostTradeSwap(ctx, j.publicKey, inputMint, outputMint, amount, j.slippage, pb.Project_P_JUPITER) 107 | if err != nil { 108 | errCh <- fmt.Errorf("error posting swap %v: %w", i, err) 109 | resultCh <- err 110 | return 111 | } 112 | 113 | // technically this can be a race condition, but shouldn't be a concern with the ticker times 114 | lastOutAmount = postResponse.OutAmount 115 | 116 | submitResponse, err := j.client.SignAndSubmitBatch(ctx, postResponse.Transactions, false, submitOpts) 117 | if err != nil { 118 | errCh <- fmt.Errorf("error submitting swap %v: %w", i, err) 119 | resultCh <- err 120 | return 121 | } 122 | 123 | j.log().Infow("completed swap", "transactions", submitResponse.Transactions) 124 | signatureLock.Lock() 125 | for _, transaction := range submitResponse.Transactions { 126 | signatures = append(signatures, SwapEvent{ 127 | Timestamp: time.Now(), 128 | Signature: transaction.Signature, 129 | Info: info, 130 | }) 131 | } 132 | signatureLock.Unlock() 133 | resultCh <- nil 134 | 135 | time.Sleep(j.interval) 136 | }(i) 137 | case err := <-errCh: 138 | return signatures, err 139 | case <-ctx.Done(): 140 | return signatures, errors.New("did not complete swaps before timeout") 141 | } 142 | } 143 | 144 | for i := 0; i < iterations; i++ { 145 | select { 146 | case err := <-resultCh: 147 | if err != nil { 148 | return signatures, err 149 | } 150 | case <-ctx.Done(): 151 | return signatures, errors.New("did not complete swaps before timeout") 152 | } 153 | } 154 | 155 | time.Sleep(j.afterTimeout) 156 | return signatures, nil 157 | } 158 | -------------------------------------------------------------------------------- /benchmark/internal/actor/liquidity.go: -------------------------------------------------------------------------------- 1 | package actor 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | type Liquidity interface { 9 | Swap(ctx context.Context, iterations int) ([]SwapEvent, error) 10 | } 11 | 12 | type SwapEvent struct { 13 | Timestamp time.Time 14 | Signature string 15 | Info string 16 | } 17 | -------------------------------------------------------------------------------- /benchmark/internal/csv/writer.go: -------------------------------------------------------------------------------- 1 | package csv 2 | 3 | import ( 4 | "encoding/csv" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | type LinesSegment interface { 10 | FormatCSV() [][]string 11 | } 12 | 13 | func Write[T LinesSegment](outputFile string, header []string, linesSegments []T, filter func(line []string) bool) error { 14 | f, err := os.Create(outputFile) 15 | if err != nil { 16 | return err 17 | } 18 | defer func(f *os.File) { 19 | _ = f.Close() 20 | }(f) 21 | 22 | w := csv.NewWriter(f) 23 | defer w.Flush() 24 | 25 | err = w.Write(header) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | for _, segment := range linesSegments { 31 | lines := segment.FormatCSV() 32 | 33 | LineWrite: 34 | for _, line := range lines { 35 | if filter(line) { 36 | continue LineWrite 37 | } 38 | 39 | if len(line) != len(header) { 40 | return fmt.Errorf("invalid CSV: line length (%v) differed from header (%v)", len(line), len(header)) 41 | } 42 | 43 | err := w.Write(line) 44 | if err != nil { 45 | return err 46 | } 47 | } 48 | } 49 | 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /benchmark/internal/logger/log.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import "go.uber.org/zap" 4 | 5 | var logger *zap.SugaredLogger 6 | 7 | func init() { 8 | baseLogger, err := zap.NewDevelopment() 9 | if err != nil { 10 | panic(err) 11 | } 12 | 13 | logger = baseLogger.Sugar() 14 | } 15 | 16 | func Log() *zap.SugaredLogger { 17 | return logger 18 | } 19 | -------------------------------------------------------------------------------- /benchmark/internal/output/slot.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "fmt" 5 | "golang.org/x/exp/maps" 6 | "sort" 7 | ) 8 | 9 | func SortRange[T any](slotRange map[int]T) []int { 10 | slots := maps.Keys(slotRange) 11 | sort.Ints(slots) 12 | return slots 13 | } 14 | 15 | func FormatSortRange[T any](slotRange map[int]T) string { 16 | if len(slotRange) == 0 { 17 | return "-" 18 | } 19 | sr := SortRange(slotRange) 20 | return fmt.Sprintf("%v-%v", sr[0], sr[len(sr)-1]) 21 | } 22 | -------------------------------------------------------------------------------- /benchmark/internal/stream/jupiter_api.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/logger" 9 | "go.uber.org/zap" 10 | "io" 11 | "math" 12 | "net/http" 13 | "strconv" 14 | "time" 15 | ) 16 | 17 | const ( 18 | quoteAPIEndpoint = "https://quote-api.jup.ag/v4/quote" 19 | priceAPIEndpoint = "https://price.jup.ag/v4/price" 20 | defaultAmount = 1 21 | defaultSlippage = 5 22 | defaultDecimals = 8 23 | defaultInterval = time.Second 24 | usdcMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" 25 | usdcDecimals = 6 26 | ) 27 | 28 | type JupiterAPIStream struct { 29 | client *http.Client 30 | mint string 31 | amount float64 // amount of USDC to provide for the swap (in $) 32 | adjustedAmount int // amount of USDC to provide for the swap (in units) 33 | decimals int // e.g. Jupiter requires 1000000 to indicate 1 USDC. This one's for the other mint. 34 | priceAdjustment float64 // number to multiply final price by to account for decimals. 35 | slippageBps int64 36 | ticker *time.Ticker 37 | interval time.Duration 38 | } 39 | 40 | func NewJupiterAPI(opts ...JupiterOpt) (Source[DurationUpdate[*JupiterPriceResponse], QuoteResult], error) { 41 | j := &JupiterAPIStream{ 42 | client: &http.Client{}, 43 | amount: defaultAmount, 44 | decimals: defaultDecimals, 45 | slippageBps: defaultSlippage, 46 | interval: defaultInterval, 47 | } 48 | 49 | for _, o := range opts { 50 | o(j) 51 | } 52 | 53 | if j.mint == "" { 54 | return nil, errors.New("mint token is mandatory") 55 | } 56 | 57 | j.adjustedAmount = int(j.amount * math.Pow(10, float64(usdcDecimals))) 58 | j.priceAdjustment = math.Pow(10, float64(j.decimals-usdcDecimals)) 59 | return j, nil 60 | } 61 | 62 | func (j *JupiterAPIStream) log() *zap.SugaredLogger { 63 | return logger.Log().With("source", "jupiterApi") 64 | } 65 | 66 | func (j *JupiterAPIStream) Name() string { 67 | return "jupiter" 68 | } 69 | 70 | func (j *JupiterAPIStream) Run(parent context.Context) ([]RawUpdate[DurationUpdate[*JupiterPriceResponse]], error) { 71 | ctx, cancel := context.WithCancel(parent) 72 | defer cancel() 73 | 74 | ticker := j.ticker 75 | if ticker == nil { 76 | ticker = time.NewTicker(j.interval) 77 | } 78 | 79 | return collectOrderedUpdates(ctx, ticker, func() (*JupiterPriceResponse, error) { 80 | res, err := j.FetchQuote(ctx) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | return &res, err 86 | }, nil, func(err error) { 87 | j.log().Errorw("could not fetch price", "err", err) 88 | }) 89 | } 90 | 91 | func (j *JupiterAPIStream) Process(updates []RawUpdate[DurationUpdate[*JupiterPriceResponse]], removeDuplicates bool) (results map[int][]ProcessedUpdate[QuoteResult], duplicates map[int][]ProcessedUpdate[QuoteResult], err error) { 92 | results = make(map[int][]ProcessedUpdate[QuoteResult]) 93 | duplicates = make(map[int][]ProcessedUpdate[QuoteResult]) 94 | 95 | lastPrice := -1. 96 | 97 | for _, update := range updates { 98 | slot := update.Data.Data.ContextSlot 99 | price := update.Data.Data.Price(j.mint) 100 | 101 | qr := QuoteResult{ 102 | Elapsed: update.Timestamp.Sub(update.Data.Start), 103 | BuyPrice: price, 104 | SellPrice: price, 105 | Source: "jupiter", 106 | } 107 | pu := ProcessedUpdate[QuoteResult]{ 108 | Timestamp: update.Data.Start, 109 | Slot: slot, 110 | Data: qr, 111 | } 112 | 113 | if price == lastPrice { 114 | duplicates[slot] = append(duplicates[slot], pu) 115 | if removeDuplicates { 116 | continue 117 | } 118 | } 119 | 120 | lastPrice = price 121 | results[slot] = append(results[slot], pu) 122 | } 123 | 124 | return 125 | } 126 | 127 | // FetchQuote is used to specify 1 USDC instead of 0.000001 128 | func (j *JupiterAPIStream) FetchQuote(ctx context.Context) (jr JupiterPriceResponse, err error) { 129 | url := fmt.Sprintf("%v?inputMint=%v&outputMint=%v&amount=%v&slippageBps=%v", quoteAPIEndpoint, usdcMint, j.mint, j.adjustedAmount, j.slippageBps) 130 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 131 | if err != nil { 132 | return 133 | } 134 | 135 | res, err := j.client.Do(req) 136 | if err != nil { 137 | return 138 | } 139 | 140 | defer func(Body io.ReadCloser) { 141 | _ = Body.Close() 142 | }(res.Body) 143 | 144 | b, err := io.ReadAll(res.Body) 145 | if err != nil { 146 | return 147 | } 148 | 149 | var quoteResponse JupiterQuoteResponse 150 | err = json.Unmarshal(b, "eResponse) 151 | if err != nil { 152 | return 153 | } 154 | 155 | if len(quoteResponse.Routes) == 0 { 156 | err = errors.New("no quotes found") 157 | return 158 | } 159 | 160 | bestRoute := quoteResponse.Routes[0] 161 | inAmount, err := strconv.ParseFloat(bestRoute.InAmount, 64) 162 | if err != nil { 163 | return 164 | } 165 | outAmount, err := strconv.ParseFloat(bestRoute.OutAmount, 64) 166 | if err != nil { 167 | return 168 | } 169 | price := inAmount / outAmount * j.priceAdjustment 170 | 171 | jr = JupiterPriceResponse{ 172 | PriceInfo: map[string]JupiterPriceInfo{ 173 | j.mint: { 174 | ID: j.mint, 175 | MintSymbol: "", 176 | VsToken: usdcMint, 177 | VsTokenSymbol: "USDC", 178 | Price: price, 179 | }, 180 | }, 181 | TimeTaken: quoteResponse.TimeTaken, 182 | ContextSlot: int(quoteResponse.ContextSlot), 183 | } 184 | return 185 | } 186 | 187 | // FetchPrice returns a price based off of swapping 0.0000001 USDC (the minimum possible unit). Trader API does 1 USDC. 188 | func (j *JupiterAPIStream) FetchPrice(ctx context.Context) (jr JupiterPriceResponse, err error) { 189 | url := fmt.Sprintf("%v?ids=%v", priceAPIEndpoint, j.mint) 190 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 191 | if err != nil { 192 | return 193 | } 194 | 195 | res, err := j.client.Do(req) 196 | if err != nil { 197 | return 198 | } 199 | 200 | defer func(Body io.ReadCloser) { 201 | _ = Body.Close() 202 | }(res.Body) 203 | 204 | b, err := io.ReadAll(res.Body) 205 | if err != nil { 206 | return 207 | } 208 | 209 | err = json.Unmarshal(b, &jr) 210 | if err != nil { 211 | return 212 | } 213 | 214 | return jr, nil 215 | } 216 | -------------------------------------------------------------------------------- /benchmark/internal/stream/jupiter_opts.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import "time" 4 | 5 | type JupiterOpt func(s *JupiterAPIStream) 6 | 7 | func WithJupiterToken(mint string, decimals int) JupiterOpt { 8 | return func(s *JupiterAPIStream) { 9 | s.mint = mint 10 | s.decimals = decimals 11 | } 12 | } 13 | 14 | func WithJupiterTicker(t *time.Ticker) JupiterOpt { 15 | return func(s *JupiterAPIStream) { 16 | s.ticker = t 17 | } 18 | } 19 | 20 | func WithJupiterAmount(amount float64) JupiterOpt { 21 | return func(s *JupiterAPIStream) { 22 | s.amount = amount 23 | } 24 | } 25 | 26 | func WithJupiterSlippage(slippage int64) JupiterOpt { 27 | return func(s *JupiterAPIStream) { 28 | s.slippageBps = slippage 29 | } 30 | } 31 | 32 | func WithJupiterInterval(interval time.Duration) JupiterOpt { 33 | return func(s *JupiterAPIStream) { 34 | s.interval = interval 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /benchmark/internal/stream/jupiter_price.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | type JupiterPriceResponse struct { 4 | PriceInfo map[string]JupiterPriceInfo `json:"data"` 5 | TimeTaken float64 `json:"timeTaken"` 6 | ContextSlot int `json:"contextSlot"` // might be removed from the API endpoint now 7 | } 8 | 9 | func (jr JupiterPriceResponse) Price(mint string) float64 { 10 | return jr.PriceInfo[mint].Price 11 | } 12 | 13 | type JupiterPriceInfo struct { 14 | ID string `json:"id"` 15 | MintSymbol string `json:"mintSymbol"` 16 | VsToken string `json:"vsToken"` 17 | VsTokenSymbol string `json:"vsTokenSymbol"` 18 | Price float64 `json:"price"` 19 | } 20 | -------------------------------------------------------------------------------- /benchmark/internal/stream/jupiter_quote.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | type JupiterQuoteResponse struct { 4 | Routes []JupiterRoute `json:"data"` 5 | TimeTaken float64 `json:"timeTaken"` 6 | ContextSlot uint64 `json:"contextSlot"` 7 | } 8 | 9 | type JupiterRoute struct { 10 | InAmount string `json:"inAmount"` 11 | OutAmount string `json:"outAmount"` 12 | PriceImpactPct float64 `json:"priceImpactPct"` 13 | MarketInfos []JupiterMarketInfo `json:"marketInfos"` 14 | Amount string `json:"amount"` 15 | SlippageBps int `json:"slippageBps"` 16 | OtherAmountThreshold string `json:"otherAmountThreshold"` 17 | SwapMode string `json:"swapMode"` 18 | } 19 | 20 | type JupiterMarketInfo struct { 21 | ID string `json:"id"` 22 | Label string `json:"label"` 23 | InputMint string `json:"inputMint"` 24 | OutputMint string `json:"outputMint"` 25 | NotEnoughLiquidity bool `json:"notEnoughLiquidity"` 26 | InAmount string `json:"inAmount"` 27 | OutAmount string `json:"outAmount"` 28 | PriceImpactPct float64 `json:"priceImpactPct"` 29 | LpFee JupiterFee `json:"lpFee"` 30 | PlatformFee JupiterFee `json:"platformFee"` 31 | } 32 | 33 | type JupiterFee struct { 34 | Amount string `json:"amount"` 35 | Mint string `json:"mint"` 36 | Pct float64 `json:"pct"` 37 | } 38 | -------------------------------------------------------------------------------- /benchmark/internal/stream/quote.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import "time" 4 | 5 | type QuoteResult struct { 6 | Elapsed time.Duration 7 | BuyPrice float64 8 | SellPrice float64 9 | Source string 10 | } 11 | -------------------------------------------------------------------------------- /benchmark/internal/stream/solanaws_orderbook.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/logger" 7 | pb "github.com/bloXroute-Labs/solana-trader-proto/api" 8 | bin "github.com/gagliardetto/binary" 9 | "github.com/gagliardetto/solana-go" 10 | gserum "github.com/gagliardetto/solana-go/programs/serum" 11 | solanarpc "github.com/gagliardetto/solana-go/rpc" 12 | solanaws "github.com/gagliardetto/solana-go/rpc/ws" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | type solanaOrderbookStream struct { 17 | rpcClient *solanarpc.Client 18 | wsClient *solanaws.Client 19 | 20 | wsAddress string 21 | marketPk solana.PublicKey 22 | market *gserum.MarketV2 23 | askPk solana.PublicKey 24 | bidPk solana.PublicKey 25 | } 26 | 27 | type SolanaRawUpdate struct { 28 | Data *solanaws.AccountResult 29 | Side gserum.Side 30 | } 31 | 32 | type SolanaUpdate struct { 33 | Side gserum.Side 34 | Orders []*pb.OrderbookItem 35 | previous *SolanaUpdate 36 | } 37 | 38 | func (s SolanaUpdate) IsRedundant() bool { 39 | if s.previous == nil { 40 | return false 41 | } 42 | return orderbookEqual(s.Orders, s.previous.Orders) 43 | } 44 | 45 | func orderbookEqual(o1, o2 []*pb.OrderbookItem) bool { 46 | if len(o1) != len(o2) { 47 | return false 48 | } 49 | 50 | for i, o := range o1 { 51 | if o.Size != o2[i].Size || o.Price != o2[i].Price { 52 | return false 53 | } 54 | } 55 | return true 56 | } 57 | 58 | func NewSolanaOrderbookStream(ctx context.Context, rpcAddress string, wsAddress, marketAddr string) (Source[SolanaRawUpdate, SolanaUpdate], error) { 59 | marketPk, err := solana.PublicKeyFromBase58(marketAddr) 60 | if err != nil { 61 | return nil, nil 62 | } 63 | 64 | s := &solanaOrderbookStream{ 65 | rpcClient: solanarpc.New(rpcAddress), 66 | wsAddress: wsAddress, 67 | marketPk: marketPk, 68 | } 69 | 70 | s.market, err = s.fetchMarket(ctx, marketPk) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | s.askPk = s.market.Asks 76 | s.bidPk = s.market.Bids 77 | 78 | s.wsClient, err = solanaws.Connect(ctx, s.wsAddress) 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | s.log().Debugw("connection established") 84 | return s, nil 85 | } 86 | 87 | func (s solanaOrderbookStream) log() *zap.SugaredLogger { 88 | return logger.Log().With("source", "solanaws", "address", s.wsAddress, "market", s.marketPk.String()) 89 | } 90 | 91 | func (s solanaOrderbookStream) Name() string { 92 | return fmt.Sprintf("solanaws[%v]", s.wsAddress) 93 | } 94 | 95 | // Run stops when parent ctx is canceled 96 | func (s solanaOrderbookStream) Run(parent context.Context) ([]RawUpdate[SolanaRawUpdate], error) { 97 | ctx, cancel := context.WithCancel(parent) 98 | defer cancel() 99 | 100 | asksSub, err := s.wsClient.AccountSubscribe(s.askPk, solanarpc.CommitmentProcessed) 101 | if err != nil { 102 | return nil, err 103 | } 104 | 105 | bidsSub, err := s.wsClient.AccountSubscribe(s.bidPk, solanarpc.CommitmentProcessed) 106 | if err != nil { 107 | return nil, err 108 | } 109 | 110 | s.log().Debug("subscription created") 111 | 112 | messageCh := make(chan RawUpdate[SolanaRawUpdate], 200) 113 | 114 | // dispatch ask/bid subs 115 | go func() { 116 | for { 117 | if ctx.Err() != nil { 118 | return 119 | } 120 | 121 | ar, err := asksSub.Recv(ctx) 122 | if err != nil { 123 | s.log().Debugw("closing Asks subscription", "err", err) 124 | cancel() 125 | return 126 | } 127 | 128 | messageCh <- NewRawUpdate(SolanaRawUpdate{ 129 | Data: ar, 130 | Side: gserum.SideAsk, 131 | }) 132 | } 133 | }() 134 | go func() { 135 | for { 136 | if ctx.Err() != nil { 137 | return 138 | } 139 | 140 | ar, err := bidsSub.Recv(ctx) 141 | if err != nil { 142 | s.log().Debugw("closing Bids subscription", "err", err) 143 | cancel() 144 | return 145 | } 146 | 147 | messageCh <- NewRawUpdate(SolanaRawUpdate{ 148 | Data: ar, 149 | Side: gserum.SideBid, 150 | }) 151 | } 152 | }() 153 | 154 | messages := make([]RawUpdate[SolanaRawUpdate], 0) 155 | for { 156 | select { 157 | case msg := <-messageCh: 158 | messages = append(messages, msg) 159 | case <-ctx.Done(): 160 | s.wsClient.Close() 161 | return messages, nil 162 | } 163 | } 164 | } 165 | 166 | func (s solanaOrderbookStream) Process(updates []RawUpdate[SolanaRawUpdate], removeDuplicates bool) (map[int][]ProcessedUpdate[SolanaUpdate], map[int][]ProcessedUpdate[SolanaUpdate], error) { 167 | results := make(map[int][]ProcessedUpdate[SolanaUpdate]) 168 | duplicates := make(map[int][]ProcessedUpdate[SolanaUpdate]) 169 | 170 | previous := make(map[gserum.Side]*SolanaUpdate) 171 | for _, update := range updates { 172 | var orderbook gserum.Orderbook 173 | err := bin.NewBinDecoder(update.Data.Data.Value.Data.GetBinary()).Decode(&orderbook) 174 | if err != nil { 175 | return nil, nil, err 176 | } 177 | 178 | slot := int(update.Data.Data.Context.Slot) 179 | orders := make([]*pb.OrderbookItem, 0) 180 | err = orderbook.Items(false, func(node *gserum.SlabLeafNode) error { 181 | // note: price/size are not properly converted into lot sizes 182 | orders = append(orders, &pb.OrderbookItem{ 183 | Price: float64(node.GetPrice().Int64()), 184 | Size: float64(node.Quantity), 185 | }) 186 | return nil 187 | }) 188 | if err != nil { 189 | return nil, nil, err 190 | } 191 | 192 | side := update.Data.Side 193 | su := SolanaUpdate{ 194 | Side: side, 195 | Orders: orders, 196 | previous: previous[side], 197 | } 198 | pu := ProcessedUpdate[SolanaUpdate]{ 199 | Timestamp: update.Timestamp, 200 | Slot: slot, 201 | Data: su, 202 | } 203 | 204 | redundant := su.IsRedundant() 205 | if redundant { 206 | duplicates[slot] = append(duplicates[slot], pu) 207 | } else { 208 | previous[side] = &su 209 | } 210 | 211 | if !(removeDuplicates && redundant) { 212 | results[slot] = append(results[slot], pu) 213 | _, ok := results[slot] 214 | if !ok { 215 | results[slot] = make([]ProcessedUpdate[SolanaUpdate], 0) 216 | } 217 | } 218 | } 219 | 220 | return results, duplicates, nil 221 | } 222 | 223 | func (s solanaOrderbookStream) fetchMarket(ctx context.Context, marketPk solana.PublicKey) (*gserum.MarketV2, error) { 224 | accountInfo, err := s.rpcClient.GetAccountInfo(ctx, marketPk) 225 | if err != nil { 226 | return nil, err 227 | } 228 | 229 | var market gserum.MarketV2 230 | err = bin.NewBinDecoder(accountInfo.Value.Data.GetBinary()).Decode(&market) 231 | return &market, err 232 | } 233 | -------------------------------------------------------------------------------- /benchmark/internal/stream/source.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | ) 8 | 9 | // Source represents any streaming interface that provides timestamped updates for comparison. 10 | type Source[T any, R any] interface { 11 | // Name returns an identifier for the source for printing 12 | Name() string 13 | 14 | // Run collects stream updates for the context duration. Run should avoid doing any other work besides collecting updates to have accurate timestamps. 15 | Run(context.Context) ([]RawUpdate[T], error) 16 | 17 | // Process deserializes the messages received by Run into useful formats for comparison. 18 | Process(updates []RawUpdate[T], removeDuplicates bool) (results map[int][]ProcessedUpdate[R], duplicates map[int][]ProcessedUpdate[R], err error) 19 | } 20 | 21 | type RawUpdate[T any] struct { 22 | Timestamp time.Time 23 | Data T 24 | } 25 | 26 | func NewRawUpdate[T any](data T) RawUpdate[T] { 27 | return RawUpdate[T]{ 28 | Timestamp: time.Now(), 29 | Data: data, 30 | } 31 | } 32 | 33 | type DurationUpdate[T any] struct { 34 | Start time.Time 35 | Data T 36 | } 37 | 38 | func NewDurationUpdate[T any](start time.Time, data T) RawUpdate[DurationUpdate[T]] { 39 | return NewRawUpdate(DurationUpdate[T]{ 40 | Start: start, 41 | Data: data, 42 | }) 43 | } 44 | 45 | type ProcessedUpdate[T any] struct { 46 | Timestamp time.Time 47 | Slot int 48 | Data T 49 | } 50 | 51 | func collectOrderedUpdates[T comparable](ctx context.Context, ticker *time.Ticker, requestFn func() (T, error), zero T, onError func(err error)) ([]RawUpdate[DurationUpdate[T]], error) { 52 | messages := make([]*RawUpdate[DurationUpdate[T]], 0) 53 | m := sync.Mutex{} 54 | for { 55 | select { 56 | case <-ticker.C: 57 | // to account for variances in response time, enforce an order 58 | go func() { 59 | m.Lock() 60 | 61 | start := time.Now() 62 | du := NewDurationUpdate[T](start, zero) 63 | duPtr := &du 64 | messages = append(messages, duPtr) 65 | 66 | m.Unlock() 67 | 68 | res, err := requestFn() 69 | if err != nil { 70 | onError(err) 71 | return 72 | } 73 | 74 | duPtr.Data.Data = res 75 | duPtr.Timestamp = time.Now() 76 | }() 77 | case <-ctx.Done(): 78 | returnMessages := make([]RawUpdate[DurationUpdate[T]], 0) 79 | for _, message := range messages { 80 | if message.Data.Data != zero { 81 | returnMessages = append(returnMessages, *message) 82 | } 83 | } 84 | return returnMessages, nil 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /benchmark/internal/stream/traderhttp_price.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/logger" 8 | "github.com/bloXroute-Labs/solana-trader-client-go/provider" 9 | pb "github.com/bloXroute-Labs/solana-trader-proto/api" 10 | "go.uber.org/zap" 11 | "time" 12 | ) 13 | 14 | type traderHTTPPriceStream struct { 15 | h provider.HTTPClientTraderAPI 16 | mint string 17 | ticker *time.Ticker 18 | interval time.Duration 19 | } 20 | 21 | func NewTraderHTTPPriceStream(opts ...TraderHTTPPriceOpt) (Source[DurationUpdate[*pb.GetPriceResponse], QuoteResult], error) { 22 | s := &traderHTTPPriceStream{ 23 | h: provider.NewHTTPClient(), 24 | interval: defaultInterval, 25 | } 26 | 27 | for _, o := range opts { 28 | o(s) 29 | } 30 | 31 | if s.mint == "" { 32 | return nil, errors.New("mint is mandatory") 33 | } 34 | 35 | return s, nil 36 | } 37 | 38 | func (s traderHTTPPriceStream) log() *zap.SugaredLogger { 39 | return logger.Log().With("source", "traderapi/http") 40 | } 41 | 42 | func (s traderHTTPPriceStream) Name() string { 43 | return fmt.Sprintf("traderapi") 44 | } 45 | 46 | // Run stops when parent ctx is canceled 47 | func (s traderHTTPPriceStream) Run(parent context.Context) ([]RawUpdate[DurationUpdate[*pb.GetPriceResponse]], error) { 48 | ctx, cancel := context.WithCancel(parent) 49 | defer cancel() 50 | 51 | ticker := s.ticker 52 | if ticker == nil { 53 | ticker = time.NewTicker(s.interval) 54 | } 55 | 56 | return collectOrderedUpdates(ctx, ticker, func() (*pb.GetPriceResponse, error) { 57 | res, err := s.h.GetPrice(ctx, []string{s.mint}) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | filteredRes := &pb.GetPriceResponse{TokenPrices: nil} 63 | for _, price := range res.TokenPrices { 64 | if price.Project == pb.Project_P_JUPITER { 65 | filteredRes.TokenPrices = append(filteredRes.TokenPrices, price) 66 | } 67 | } 68 | return filteredRes, nil 69 | }, &pb.GetPriceResponse{}, func(err error) { 70 | s.log().Errorw("could not fetch price", "err", err) 71 | }) 72 | } 73 | 74 | func (s traderHTTPPriceStream) Process(updates []RawUpdate[DurationUpdate[*pb.GetPriceResponse]], removeDuplicates bool) (results map[int][]ProcessedUpdate[QuoteResult], duplicates map[int][]ProcessedUpdate[QuoteResult], err error) { 75 | results = make(map[int][]ProcessedUpdate[QuoteResult]) 76 | duplicates = make(map[int][]ProcessedUpdate[QuoteResult]) 77 | 78 | lastBuyPrice := -1. 79 | lastSellPrice := -1. 80 | slot := -1 // no slot info is available 81 | for _, update := range updates { 82 | buyPrice := update.Data.Data.TokenPrices[0].Buy 83 | sellPrice := update.Data.Data.TokenPrices[0].Sell 84 | 85 | qr := QuoteResult{ 86 | Elapsed: update.Timestamp.Sub(update.Data.Start), 87 | BuyPrice: buyPrice, 88 | SellPrice: sellPrice, 89 | Source: "traderHTTP", 90 | } 91 | pu := ProcessedUpdate[QuoteResult]{ 92 | Timestamp: update.Timestamp, 93 | Slot: slot, 94 | Data: qr, 95 | } 96 | 97 | if buyPrice == lastBuyPrice && sellPrice == lastSellPrice { 98 | duplicates[slot] = append(duplicates[slot], pu) 99 | if removeDuplicates { 100 | continue 101 | } 102 | } 103 | 104 | lastBuyPrice = buyPrice 105 | lastSellPrice = sellPrice 106 | results[slot] = append(results[slot], pu) 107 | } 108 | 109 | return 110 | } 111 | -------------------------------------------------------------------------------- /benchmark/internal/stream/traderhttp_price_opts.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "github.com/bloXroute-Labs/solana-trader-client-go/provider" 5 | "time" 6 | ) 7 | 8 | type TraderHTTPPriceOpt func(s *traderHTTPPriceStream) 9 | 10 | func WithTraderHTTPClient(h provider.HTTPClientTraderAPI) TraderHTTPPriceOpt { 11 | return func(s *traderHTTPPriceStream) { 12 | s.h = h 13 | } 14 | } 15 | 16 | func WithTraderHTTPTicker(t *time.Ticker) TraderHTTPPriceOpt { 17 | return func(s *traderHTTPPriceStream) { 18 | s.ticker = t 19 | } 20 | } 21 | 22 | func WithTraderHTTPMint(m string) TraderHTTPPriceOpt { 23 | return func(s *traderHTTPPriceStream) { 24 | s.mint = m 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /benchmark/internal/stream/traderws_orderbook.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "encoding/json" 7 | "fmt" 8 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/logger" 9 | pb "github.com/bloXroute-Labs/solana-trader-proto/api" 10 | "github.com/gorilla/websocket" 11 | "github.com/pkg/errors" 12 | "github.com/sourcegraph/jsonrpc2" 13 | "go.uber.org/zap" 14 | "google.golang.org/protobuf/encoding/protojson" 15 | "io" 16 | "net/http" 17 | ) 18 | 19 | type TraderAPIUpdate struct { 20 | Asks []*pb.OrderbookItem 21 | Bids []*pb.OrderbookItem 22 | previous *TraderAPIUpdate 23 | } 24 | 25 | func (s TraderAPIUpdate) IsRedundant() bool { 26 | if s.previous == nil { 27 | return false 28 | } 29 | return orderbookEqual(s.previous.Bids, s.Bids) && orderbookEqual(s.previous.Asks, s.Asks) 30 | } 31 | 32 | type apiOrderbookStream struct { 33 | wsConn *websocket.Conn 34 | address string 35 | market string 36 | } 37 | 38 | func NewAPIOrderbookStream(address, market, authHeader string) (Source[[]byte, TraderAPIUpdate], error) { 39 | s := apiOrderbookStream{ 40 | address: address, 41 | market: market, 42 | } 43 | 44 | // ws provider not used to delay message deserialization until all complete 45 | dialer := websocket.Dialer{TLSClientConfig: &tls.Config{}} 46 | header := http.Header{} 47 | header.Set("Authorization", authHeader) 48 | wsConn, resp, err := dialer.Dial(s.address, header) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | s.log().Debugw("connection established") 54 | 55 | defer func(body io.ReadCloser) { 56 | _ = body.Close() 57 | }(resp.Body) 58 | s.wsConn = wsConn 59 | return s, nil 60 | } 61 | 62 | func (s apiOrderbookStream) log() *zap.SugaredLogger { 63 | return logger.Log().With("source", "traderapi", "address", s.address, "market", s.market) 64 | } 65 | 66 | func (s apiOrderbookStream) Name() string { 67 | return fmt.Sprintf("traderapi[%v]", s.address) 68 | } 69 | 70 | // Run stops when parent ctx is canceled 71 | func (s apiOrderbookStream) Run(parent context.Context) ([]RawUpdate[[]byte], error) { 72 | ctx, cancel := context.WithCancel(parent) 73 | defer cancel() 74 | 75 | subscribeRequest := fmt.Sprintf(`{"jsonrpc": "2.0", "id": 1, "method": "subscribe", "params": ["GetOrderbooksStream", {"markets": ["%v"]}]}`, s.market) 76 | err := s.wsConn.WriteMessage(websocket.TextMessage, []byte(subscribeRequest)) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | s.log().Debugw("subscription created") 82 | 83 | wsMessages := make(chan RawUpdate[[]byte], 100) 84 | go func() { 85 | for { 86 | if ctx.Err() != nil { 87 | return 88 | } 89 | 90 | _, b, err := s.wsConn.ReadMessage() 91 | if err != nil { 92 | s.log().Debugw("closing connection", "err", err) 93 | return 94 | } 95 | 96 | wsMessages <- NewRawUpdate(b) 97 | } 98 | }() 99 | 100 | messages := make([]RawUpdate[[]byte], 0) 101 | for { 102 | select { 103 | case msg := <-wsMessages: 104 | messages = append(messages, msg) 105 | case <-ctx.Done(): 106 | err = s.wsConn.Close() 107 | if err != nil { 108 | s.log().Errorw("could not close connection", "err", err) 109 | } 110 | return messages, nil 111 | } 112 | } 113 | } 114 | 115 | type subscriptionUpdate struct { 116 | SubscriptionID string `json:"subscriptionId"` 117 | Result json.RawMessage `json:"result"` 118 | } 119 | 120 | func (s apiOrderbookStream) Process(updates []RawUpdate[[]byte], removeDuplicates bool) (map[int][]ProcessedUpdate[TraderAPIUpdate], map[int][]ProcessedUpdate[TraderAPIUpdate], error) { 121 | var ( 122 | err error 123 | previous *TraderAPIUpdate 124 | ) 125 | 126 | results := make(map[int][]ProcessedUpdate[TraderAPIUpdate]) 127 | duplicates := make(map[int][]ProcessedUpdate[TraderAPIUpdate]) 128 | allowedFailures := 1 // allowed to skip processing of subscription confirmation message 129 | 130 | for _, update := range updates { 131 | var rpcUpdate jsonrpc2.Request 132 | err = json.Unmarshal(update.Data, &rpcUpdate) 133 | if err != nil || rpcUpdate.Params == nil { 134 | allowedFailures-- 135 | if allowedFailures < 0 { 136 | return nil, nil, errors.Wrap(err, "too many response errors") 137 | } 138 | 139 | var subscriptionResponse jsonrpc2.Response 140 | err := json.Unmarshal(update.Data, &subscriptionResponse) 141 | if err != nil { 142 | return nil, nil, fmt.Errorf("did not receive proper subscription response: %v", string(update.Data)) 143 | } 144 | 145 | if subscriptionResponse.Error != nil { 146 | return nil, nil, fmt.Errorf("did not receive proper subscription response: %v", string(update.Data)) 147 | } 148 | 149 | continue 150 | } 151 | 152 | var subU subscriptionUpdate 153 | err = json.Unmarshal(*rpcUpdate.Params, &subU) 154 | if err != nil { 155 | allowedFailures-- 156 | if allowedFailures < 0 { 157 | return nil, nil, errors.Wrap(err, "too many response errors") 158 | } 159 | continue 160 | } 161 | 162 | var orderbookInc pb.GetOrderbooksStreamResponse 163 | err = protojson.Unmarshal(subU.Result, &orderbookInc) 164 | if err != nil { 165 | return nil, nil, err 166 | } 167 | 168 | slot := int(orderbookInc.Slot) 169 | su := TraderAPIUpdate{ 170 | Asks: orderbookInc.Orderbook.Asks, 171 | Bids: orderbookInc.Orderbook.Bids, 172 | previous: previous, 173 | } 174 | pu := ProcessedUpdate[TraderAPIUpdate]{ 175 | Timestamp: update.Timestamp, 176 | Slot: slot, 177 | Data: su, 178 | } 179 | 180 | redundant := su.IsRedundant() 181 | if redundant { 182 | duplicates[slot] = append(results[slot], pu) 183 | } else { 184 | previous = &su 185 | } 186 | 187 | // skip redundant updates if duplicate updates flag is set 188 | if !(removeDuplicates && redundant) { 189 | _, ok := results[slot] 190 | if !ok { 191 | results[slot] = make([]ProcessedUpdate[TraderAPIUpdate], 0) 192 | } 193 | results[slot] = append(results[slot], pu) 194 | } 195 | } 196 | 197 | return results, duplicates, nil 198 | } 199 | -------------------------------------------------------------------------------- /benchmark/internal/stream/traderws_price.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/logger" 8 | "github.com/bloXroute-Labs/solana-trader-client-go/examples/config" 9 | "github.com/bloXroute-Labs/solana-trader-client-go/provider" 10 | pb "github.com/bloXroute-Labs/solana-trader-proto/api" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | type tradeWSPrice struct { 15 | w provider.WSClientTraderAPI 16 | mint string 17 | } 18 | 19 | func NewTraderWSPrice(opts ...TraderWSPriceOpt) (Source[*pb.GetPricesStreamResponse, QuoteResult], error) { 20 | s := &tradeWSPrice{} 21 | 22 | for _, o := range opts { 23 | o(s) 24 | } 25 | 26 | if s.mint == "" { 27 | return nil, errors.New("mint is mandatory") 28 | } 29 | 30 | if s.w == nil { 31 | w, err := provider.NewWSClientFullService(config.WSUrls["ny"]) 32 | if err != nil { 33 | return nil, err 34 | } 35 | s.w = w 36 | } 37 | 38 | return s, nil 39 | } 40 | 41 | func (s tradeWSPrice) log() *zap.SugaredLogger { 42 | return logger.Log().With("source", "traderapi") 43 | } 44 | 45 | func (s tradeWSPrice) Name() string { 46 | return fmt.Sprintf("traderapi") 47 | } 48 | 49 | // Run stops when parent ctx is canceled 50 | func (s tradeWSPrice) Run(parent context.Context) ([]RawUpdate[*pb.GetPricesStreamResponse], error) { 51 | ctx, cancel := context.WithCancel(parent) 52 | defer cancel() 53 | 54 | stream, err := s.w.GetPricesStream(ctx, []pb.Project{pb.Project_P_JUPITER}, []string{s.mint}) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | ch := stream.Channel(10) 60 | 61 | messages := make([]RawUpdate[*pb.GetPricesStreamResponse], 0) 62 | for { 63 | select { 64 | case msg := <-ch: 65 | messages = append(messages, NewRawUpdate(msg)) 66 | case <-ctx.Done(): 67 | err = s.w.Close() 68 | if err != nil { 69 | s.log().Errorw("could not close connection", "err", err) 70 | } 71 | return messages, nil 72 | } 73 | } 74 | } 75 | 76 | func (s tradeWSPrice) Process(updates []RawUpdate[*pb.GetPricesStreamResponse], removeDuplicates bool) (results map[int][]ProcessedUpdate[QuoteResult], duplicates map[int][]ProcessedUpdate[QuoteResult], err error) { 77 | results = make(map[int][]ProcessedUpdate[QuoteResult]) 78 | duplicates = make(map[int][]ProcessedUpdate[QuoteResult]) 79 | 80 | lastBuyPrice := -1. 81 | lastSellPrice := -1. 82 | for _, update := range updates { 83 | slot := int(update.Data.Slot) 84 | buyPrice := update.Data.Price.Buy 85 | sellPrice := update.Data.Price.Sell 86 | 87 | qr := QuoteResult{ 88 | Elapsed: 0, 89 | BuyPrice: buyPrice, 90 | SellPrice: sellPrice, 91 | Source: "traderWS", 92 | } 93 | pu := ProcessedUpdate[QuoteResult]{ 94 | Timestamp: update.Timestamp, 95 | Slot: slot, 96 | Data: qr, 97 | } 98 | 99 | if buyPrice == lastBuyPrice && sellPrice == lastSellPrice { 100 | duplicates[slot] = append(duplicates[slot], pu) 101 | if removeDuplicates { 102 | continue 103 | } 104 | } 105 | 106 | lastBuyPrice = buyPrice 107 | lastSellPrice = sellPrice 108 | results[slot] = append(results[slot], pu) 109 | } 110 | 111 | return 112 | } 113 | -------------------------------------------------------------------------------- /benchmark/internal/stream/traderws_price_opts.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import "github.com/bloXroute-Labs/solana-trader-client-go/provider" 4 | 5 | type TraderWSPriceOpt func(s *tradeWSPrice) 6 | 7 | func WithTraderWSClient(w provider.WSClientTraderAPI) TraderWSPriceOpt { 8 | return func(s *tradeWSPrice) { 9 | s.w = w 10 | } 11 | } 12 | 13 | func WithTraderWSMint(m string) TraderWSPriceOpt { 14 | return func(s *tradeWSPrice) { 15 | s.mint = m 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /benchmark/internal/stream/traderws_pumpfun.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark" 7 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/logger" 8 | "github.com/bloXroute-Labs/solana-trader-client-go/provider" 9 | "github.com/bloXroute-Labs/solana-trader-client-go/utils" 10 | pb "github.com/bloXroute-Labs/solana-trader-proto/api" 11 | "strings" 12 | 13 | "time" 14 | ) 15 | 16 | type traderWSPPumpFunNewToken struct { 17 | w *provider.WSClient 18 | pumpTxMap *utils.LockedMap[string, benchmark.PumpTxInfo] 19 | messageChan chan *benchmark.NewTokenResult 20 | authHeader string 21 | address string 22 | rpcHost string 23 | isFirstParty bool 24 | } 25 | 26 | func NewTraderWSPPumpFunNewToken(isFirstParty bool, messageChan chan *benchmark.NewTokenResult, pumpTxMap *utils.LockedMap[string, benchmark.PumpTxInfo], 27 | address, authHeader string) (Source[*benchmark.NewTokenResult, benchmark.NewTokenResult], error) { 28 | 29 | s := &traderWSPPumpFunNewToken{ 30 | pumpTxMap: pumpTxMap, 31 | messageChan: messageChan, 32 | isFirstParty: isFirstParty, 33 | } 34 | 35 | if s.w == nil { 36 | w, err := provider.NewWSClientWithOpts(provider.RPCOpts{ 37 | Endpoint: address, 38 | AuthHeader: authHeader, 39 | }) 40 | s.address = address 41 | s.authHeader = authHeader 42 | if err != nil { 43 | return nil, err 44 | } 45 | s.w = w 46 | } 47 | 48 | return s, nil 49 | } 50 | 51 | func (s traderWSPPumpFunNewToken) Name() string { 52 | return fmt.Sprintf("traderapi") 53 | } 54 | 55 | // Run stops when parent ctx is canceled 56 | func (s traderWSPPumpFunNewToken) Run(parent context.Context) ([]RawUpdate[*benchmark.NewTokenResult], error) { 57 | ctx, cancel := context.WithCancel(parent) 58 | defer cancel() 59 | 60 | stream, err := s.w.GetPumpFunNewTokensStream(ctx, &pb.GetPumpFunNewTokensStreamRequest{}) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | ch := make(chan *pb.GetPumpFunNewTokensStreamResponse, 10) 66 | go func() { 67 | for { 68 | v, err := stream() 69 | if err != nil { 70 | if strings.Contains(err.Error(), "shutdown requested") || 71 | strings.Contains(err.Error(), "stream context has been closed") { 72 | return 73 | } 74 | time.Sleep(time.Second) 75 | logger.Log().Errorf("resetting the stream, because of error %v \n", err) 76 | if s.w == nil { 77 | w, err := provider.NewWSClientWithOpts(provider.RPCOpts{ 78 | Endpoint: s.address, 79 | AuthHeader: s.authHeader, 80 | }) 81 | if err != nil { 82 | logger.Log().Errorw("err again", "err", err) 83 | continue 84 | } else { 85 | s.w = w 86 | stream, err = s.w.GetPumpFunNewTokensStream(ctx, &pb.GetPumpFunNewTokensStreamRequest{}) 87 | if err != nil { 88 | logger.Log().Errorw("err again", "err", err) 89 | continue 90 | } 91 | } 92 | } 93 | } else { 94 | ch <- v 95 | } 96 | } 97 | }() 98 | for { 99 | select { 100 | case msg := <-ch: 101 | if msg == nil { 102 | logger.Log().Infow("receiving nil in chann") 103 | continue 104 | } 105 | 106 | go func() { 107 | 108 | s.pumpTxMap.Update(msg.TxnHash, func(v benchmark.PumpTxInfo, exists bool) benchmark.PumpTxInfo { 109 | if exists { 110 | var firstPartyEventTime time.Time 111 | var thirdPartyEventTime time.Time 112 | if s.isFirstParty { 113 | // first party is late 114 | firstPartyEventTime = time.Now() 115 | thirdPartyEventTime = v.TimeSeen 116 | 117 | } else { 118 | // first party is first 119 | firstPartyEventTime = v.TimeSeen 120 | thirdPartyEventTime = time.Now() 121 | } 122 | 123 | res := &benchmark.NewTokenResult{ 124 | TraderAPIEventTime: firstPartyEventTime, 125 | ThirdPartyEventTime: thirdPartyEventTime, 126 | TxHash: msg.TxnHash, 127 | Slot: msg.Slot, 128 | Diff: firstPartyEventTime.Sub(thirdPartyEventTime), 129 | } 130 | logger.Log().Infow("trader-api setting event", "firstParty diff millis", 131 | res.Diff.Milliseconds(), "msg.TxnHash", msg.TxnHash, "firstPartyEventTime", firstPartyEventTime.UTC()) 132 | 133 | s.messageChan <- res 134 | 135 | } else { 136 | logger.Log().Debugw("trader api getting the event sooner", 137 | "isFirstParty", s.isFirstParty, 138 | "msg.TxnHash", msg.TxnHash) 139 | v = benchmark.PumpTxInfo{ 140 | TimeSeen: time.Now(), 141 | } 142 | } 143 | return v 144 | }) 145 | 146 | }() 147 | 148 | case <-ctx.Done(): 149 | err = s.w.Close() 150 | if err != nil { 151 | logger.Log().Errorw("could not close connection", "err", err) 152 | } 153 | //close(s.messageChan) 154 | 155 | logger.Log().Infow("end of ws") 156 | return nil, err 157 | } 158 | } 159 | 160 | } 161 | 162 | func (s traderWSPPumpFunNewToken) Process(_ []RawUpdate[*benchmark.NewTokenResult], _ bool) (results map[int][]ProcessedUpdate[benchmark.NewTokenResult], duplicates map[int][]ProcessedUpdate[benchmark.NewTokenResult], err error) { 163 | return 164 | } 165 | -------------------------------------------------------------------------------- /benchmark/internal/throughput/listener.go: -------------------------------------------------------------------------------- 1 | package throughput 2 | 3 | import ( 4 | "context" 5 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/logger" 6 | "time" 7 | ) 8 | 9 | const ( 10 | defaultTickInterval = 10 * time.Second 11 | defaultChannelBuffer = 100 12 | ) 13 | 14 | type StreamConfig struct { 15 | Name string 16 | TickInterval time.Duration 17 | ChannelBuffer int 18 | } 19 | 20 | func DefaultStreamConfig(name string) StreamConfig { 21 | return StreamConfig{ 22 | Name: name, 23 | TickInterval: defaultTickInterval, 24 | ChannelBuffer: defaultChannelBuffer, 25 | } 26 | } 27 | 28 | type StreamListener[T any] interface { 29 | // Connect indicates how the stream listener should connect and setup its primary stream 30 | Connect(context.Context) error 31 | 32 | // Produce indicates how to receive a series of messages on the connection 33 | Produce() ([]T, error) 34 | 35 | // Filter applies to produced items to determine whether they should be counted 36 | Filter(T) bool 37 | 38 | // Size applies to produced items to indicate its byte size 39 | Size(T) int 40 | 41 | // OnUpdate is a flexible trigger that can fire on each produced item 42 | OnUpdate(T) 43 | } 44 | 45 | func Listen[T any](parent context.Context, sl StreamListener[T], sc StreamConfig) error { 46 | ctx, cancel := context.WithCancel(parent) 47 | defer cancel() 48 | 49 | err := sl.Connect(ctx) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | logger.Log().Infow("connection made", "stream", sc.Name) 55 | 56 | ch := make(chan T, sc.ChannelBuffer) 57 | go func() { 58 | for { 59 | if ctx.Err() != nil { 60 | logger.Log().Infow("connection completed", "stream", sc.Name) 61 | return 62 | } 63 | 64 | updates, err := sl.Produce() 65 | if err != nil { 66 | cancel() 67 | logger.Log().Errorw("connection broken", "stream", sc.Name, "err", err) 68 | return 69 | } 70 | 71 | for _, update := range updates { 72 | if sl.Filter(update) { 73 | ch <- update 74 | } 75 | } 76 | } 77 | }() 78 | 79 | ticker := time.NewTicker(sc.TickInterval) 80 | 81 | count := 0 82 | size := 0 83 | startTime := time.Now() 84 | 85 | for { 86 | select { 87 | case msg := <-ch: 88 | count++ 89 | size += sl.Size(msg) 90 | sl.OnUpdate(msg) 91 | case <-ticker.C: 92 | elapsedSeconds := int(time.Since(startTime).Seconds()) 93 | throughput := size / elapsedSeconds 94 | 95 | logger.Log().Infow("ticker update", "stream", sc.Name, "count", count, "cps", count/elapsedSeconds, "total throughput", FormatSize(size), "throughput (/s)", FormatSize(throughput)) 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /benchmark/internal/throughput/size.go: -------------------------------------------------------------------------------- 1 | package throughput 2 | 3 | import "fmt" 4 | 5 | // FormatSize turns a byte count into a human readable string. 6 | func FormatSize(size int) string { 7 | if size > 1024*1024*1024 { 8 | return fmt.Sprintf("%v MB", size/1024/1024) 9 | } else if size > 1024*1024 { 10 | return fmt.Sprintf("%v kB", size/1024) 11 | } 12 | return fmt.Sprintf("%v B", size) 13 | } 14 | -------------------------------------------------------------------------------- /benchmark/internal/transaction/status.go: -------------------------------------------------------------------------------- 1 | package transaction 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/logger" 7 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/utils" 8 | "github.com/gagliardetto/solana-go" 9 | solanarpc "github.com/gagliardetto/solana-go/rpc" 10 | "time" 11 | ) 12 | 13 | const ( 14 | defaultFetchAttempts = 10 15 | defaultFetchInterval = 10 * time.Second 16 | ) 17 | 18 | type StatusQuerierOpts struct { 19 | FetchAttempts int 20 | FetchInterval time.Duration 21 | } 22 | 23 | var defaultStatusQuerierOpts = StatusQuerierOpts{ 24 | FetchAttempts: defaultFetchAttempts, 25 | FetchInterval: defaultFetchInterval, 26 | } 27 | 28 | type StatusQuerier struct { 29 | client *solanarpc.Client 30 | blocks map[uint64]*solanarpc.GetBlockResult 31 | opts StatusQuerierOpts 32 | } 33 | 34 | func NewStatusQuerier(endpoint string) *StatusQuerier { 35 | return NewStatusQuerierWithOpts(endpoint, defaultStatusQuerierOpts) 36 | } 37 | 38 | func NewStatusQuerierWithOpts(endpoint string, opts StatusQuerierOpts) *StatusQuerier { 39 | client := solanarpc.New(endpoint) 40 | tsq := &StatusQuerier{ 41 | client: client, 42 | blocks: make(map[uint64]*solanarpc.GetBlockResult), 43 | opts: opts, 44 | } 45 | return tsq 46 | } 47 | 48 | func (q *StatusQuerier) RecentBlockHash(ctx context.Context) (solana.Hash, error) { 49 | result, err := q.client.GetRecentBlockhash(ctx, "") 50 | if err != nil { 51 | return solana.Hash{}, err 52 | } 53 | 54 | return result.Value.Blockhash, nil 55 | } 56 | 57 | func (q *StatusQuerier) FetchBatch(ctx context.Context, signatures []solana.Signature) (BatchSummary, []BlockStatus, error) { 58 | statuses, err := utils.AsyncGather(ctx, signatures, func(i int, ctx context.Context, signature solana.Signature) (BlockStatus, error) { 59 | return q.Fetch(ctx, signature) 60 | }) 61 | 62 | if err != nil { 63 | return BatchSummary{}, nil, err 64 | } 65 | 66 | bestSlot := -1 67 | bestPosition := -1 68 | bestIndex := -1 69 | lostTransactions := make([]int, 0) 70 | 71 | for i, status := range statuses { 72 | if !status.Found { 73 | lostTransactions = append(lostTransactions, i) 74 | continue 75 | } 76 | 77 | replace := func() { 78 | bestSlot = int(status.Slot) 79 | bestPosition = status.Position 80 | bestIndex = i 81 | } 82 | 83 | // first found transaction: always best 84 | if bestSlot == -1 { 85 | replace() 86 | continue 87 | } 88 | 89 | // better slot: replace 90 | if int(status.Slot) < bestSlot { 91 | replace() 92 | continue 93 | } 94 | 95 | // same slot but better position: replace 96 | if int(status.Slot) == bestSlot && status.Position < bestPosition { 97 | replace() 98 | continue 99 | } 100 | } 101 | 102 | summary := BatchSummary{ 103 | Best: bestIndex, 104 | LostTransaction: lostTransactions, 105 | } 106 | return summary, statuses, err 107 | } 108 | 109 | // Fetch retrieve a transaction's slot in a block and its position within the block. This call blocks until timeout or success. 110 | func (q *StatusQuerier) Fetch(ctx context.Context, signature solana.Signature) (BlockStatus, error) { 111 | ts := BlockStatus{Position: -1} 112 | 113 | var ( 114 | tx *solanarpc.GetTransactionResult 115 | err error 116 | ) 117 | for i := 0; i < q.opts.FetchAttempts; i++ { 118 | tx, err = q.client.GetTransaction(ctx, signature, nil) 119 | if err == solanarpc.ErrNotFound { 120 | time.Sleep(q.opts.FetchInterval) 121 | continue 122 | } 123 | if err != nil { 124 | return ts, err 125 | } 126 | 127 | break 128 | } 129 | 130 | if tx == nil { 131 | logger.Log().Debugw("transaction failed execution", "signature", signature) 132 | return ts, nil 133 | } 134 | 135 | ts.Slot = tx.Slot 136 | ts.Found = true 137 | var ( 138 | ok bool 139 | block *solanarpc.GetBlockResult 140 | ) 141 | if block, ok = q.blocks[ts.Slot]; !ok { 142 | opts := &solanarpc.GetBlockOpts{TransactionDetails: solanarpc.TransactionDetailsSignatures} 143 | block, err = q.client.GetBlockWithOpts(ctx, tx.Slot, opts) 144 | if err != nil { 145 | return ts, nil 146 | } 147 | 148 | q.blocks[ts.Slot] = block 149 | } 150 | 151 | for i, blockSignature := range block.Signatures { 152 | if signature == blockSignature { 153 | ts.Position = i 154 | break 155 | } 156 | } 157 | 158 | if ts.Position == -1 { 159 | return ts, errors.New("transaction signature was not found in expected block") 160 | } 161 | 162 | ts.ExecutionTime = block.BlockTime.Time() 163 | return ts, nil 164 | } 165 | -------------------------------------------------------------------------------- /benchmark/internal/transaction/status_summary.go: -------------------------------------------------------------------------------- 1 | package transaction 2 | 3 | import "time" 4 | 5 | type BatchSummary struct { 6 | Best int 7 | LostTransaction []int 8 | } 9 | 10 | type BlockStatus struct { 11 | ExecutionTime time.Time 12 | Slot uint64 13 | Position int 14 | Found bool 15 | } 16 | -------------------------------------------------------------------------------- /benchmark/internal/transaction/submit.go: -------------------------------------------------------------------------------- 1 | package transaction 2 | 3 | import ( 4 | "context" 5 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/logger" 6 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/utils" 7 | "github.com/bloXroute-Labs/solana-trader-client-go/provider" 8 | "github.com/bloXroute-Labs/solana-trader-client-go/transaction" 9 | pb "github.com/bloXroute-Labs/solana-trader-proto/api" 10 | "github.com/gagliardetto/solana-go" 11 | solanarpc "github.com/gagliardetto/solana-go/rpc" 12 | "strconv" 13 | "sync" 14 | "time" 15 | ) 16 | 17 | const ( 18 | defaultSubmissionInterval = 2 * time.Second 19 | defaultSkipPreflight = true 20 | ) 21 | 22 | type Builder func() (string, error) 23 | 24 | type SubmitterOpts struct { 25 | SubmissionInterval time.Duration 26 | SkipPreflight bool 27 | } 28 | 29 | var defaultSubmitterOpts = SubmitterOpts{ 30 | SubmissionInterval: defaultSubmissionInterval, 31 | SkipPreflight: defaultSkipPreflight, 32 | } 33 | 34 | type Submitter struct { 35 | clients []*solanarpc.Client 36 | txBuilder Builder 37 | opts SubmitterOpts 38 | } 39 | 40 | func NewSubmitter(endpoints []string, txBuilder Builder) *Submitter { 41 | return NewSubmitterWithOpts(endpoints, txBuilder, defaultSubmitterOpts) 42 | } 43 | 44 | func NewSubmitterWithOpts(endpoints []string, txBuilder Builder, opts SubmitterOpts) *Submitter { 45 | clients := make([]*solanarpc.Client, 0, len(endpoints)) 46 | for _, endpoint := range endpoints { 47 | clients = append(clients, solanarpc.New(endpoint)) 48 | } 49 | 50 | ts := &Submitter{ 51 | clients: clients, 52 | txBuilder: txBuilder, 53 | opts: opts, 54 | } 55 | return ts 56 | } 57 | 58 | // SubmitIterations submits n iterations of transactions created by the builder to each of the endpoints and returns all signatures and creation times 59 | func (ts Submitter) SubmitIterations(ctx context.Context, iterations int) ([][]solana.Signature, []time.Time, error) { 60 | signatures := make([][]solana.Signature, 0, iterations) 61 | creationTimes := make([]time.Time, 0, iterations) 62 | for i := 0; i < iterations; i++ { 63 | iterationSignatures, creationTime, err := ts.SubmitIteration(ctx) 64 | if err != nil { 65 | return nil, nil, err 66 | } 67 | 68 | creationTimes = append(creationTimes, creationTime) 69 | signatures = append(signatures, iterationSignatures) 70 | logger.Log().Debugw("submitted iteration of transactions", "iteration", i, "count", len(iterationSignatures)) 71 | 72 | time.Sleep(ts.opts.SubmissionInterval) 73 | } 74 | 75 | return signatures, creationTimes, nil 76 | } 77 | 78 | // SubmitIteration uses the builder function to construct transactions for each endpoint, then sends all transactions concurrently (to be as fair as possible) 79 | func (ts Submitter) SubmitIteration(ctx context.Context) ([]solana.Signature, time.Time, error) { 80 | // assume that in order transaction building is ok 81 | txs := make([]string, 0, len(ts.clients)) 82 | for range ts.clients { 83 | tx, err := ts.txBuilder() 84 | if err != nil { 85 | return nil, time.Time{}, err 86 | } 87 | txs = append(txs, tx) 88 | } 89 | creationTime := time.Now() 90 | 91 | results, err := utils.AsyncGather(ctx, txs, func(i int, ctx context.Context, tx string) (solana.Signature, error) { 92 | return ts.submit(ctx, tx, i) 93 | }) 94 | if err != nil { 95 | return nil, creationTime, err 96 | } 97 | 98 | for _, result := range results { 99 | logger.Log().Debugw("submitted transaction", "signature", result) 100 | } 101 | return results, creationTime, nil 102 | } 103 | 104 | func (ts Submitter) submit(ctx context.Context, txBase64 string, index int) (solana.Signature, error) { 105 | txBytes, err := solanarpc.DataBytesOrJSONFromBase64(txBase64) 106 | if err != nil { 107 | return solana.Signature{}, err 108 | } 109 | 110 | twm := solanarpc.TransactionWithMeta{ 111 | Transaction: txBytes, 112 | } 113 | tx, err := twm.GetTransaction() 114 | if err != nil { 115 | return solana.Signature{}, err 116 | } 117 | opts := solanarpc.TransactionOpts{ 118 | SkipPreflight: ts.opts.SkipPreflight, 119 | PreflightCommitment: "", 120 | } 121 | signature, err := ts.clients[index].SendTransactionWithOpts(ctx, tx, opts) 122 | if err != nil { 123 | return solana.Signature{}, err 124 | } 125 | 126 | return signature, nil 127 | } 128 | 129 | const ( 130 | market = "8BnEgHoWFysVcuFFX7QztDmzuH8r5ZFvyP3sYwn1XTh6" 131 | ) 132 | 133 | var ( 134 | orderID = 1 135 | orderIDM = sync.Mutex{} 136 | ) 137 | 138 | // SerumBuilder builds a transaction that's expected to fail (canceling a not found order from Serum). Transactions are submitted with `skipPreflight` however, so it should still be "executed." 139 | func SerumBuilder(ctx context.Context, g *provider.GRPCClient, publicKey solana.PublicKey, ooAddress solana.PublicKey, privateKey solana.PrivateKey) Builder { 140 | return func() (string, error) { 141 | orderIDM.Lock() 142 | defer orderIDM.Unlock() 143 | 144 | response, err := g.PostCancelOrder(ctx, strconv.Itoa(orderID), pb.Side_S_ASK, publicKey.String(), market, ooAddress.String(), pb.Project_P_SERUM) 145 | if err != nil { 146 | return "", err 147 | } 148 | 149 | orderID++ 150 | 151 | signedTx, err := transaction.SignTxWithPrivateKey(response.Transaction.Content, privateKey) 152 | if err != nil { 153 | return "", err 154 | } 155 | 156 | return signedTx, nil 157 | } 158 | } 159 | 160 | var ( 161 | memoID = 0 162 | memoIDM = sync.Mutex{} 163 | ) 164 | 165 | // MemoBuilder builds a transaction with a simple memo 166 | func MemoBuilder(privateKey solana.PrivateKey, recentBlockHashFn func() (solana.Hash, error)) Builder { 167 | return func() (string, error) { 168 | memoIDM.Lock() 169 | memoID++ 170 | memoIDM.Unlock() 171 | 172 | publicKey := privateKey.PublicKey() 173 | 174 | builder := solana.NewTransactionBuilder() 175 | am := []*solana.AccountMeta{ 176 | solana.Meta(publicKey).WRITE().SIGNER(), 177 | } 178 | 179 | instruction := &solana.GenericInstruction{ 180 | AccountValues: am, 181 | ProgID: solana.MemoProgramID, 182 | DataBytes: []byte(strconv.Itoa(memoID)), 183 | } 184 | 185 | builder.AddInstruction(instruction) 186 | builder.SetFeePayer(publicKey) 187 | 188 | recentBlockHash, err := recentBlockHashFn() 189 | if err != nil { 190 | return "", err 191 | } 192 | builder.SetRecentBlockHash(recentBlockHash) 193 | 194 | tx, err := builder.Build() 195 | if err != nil { 196 | return "", err 197 | } 198 | 199 | _, err = tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { 200 | if key == publicKey { 201 | return &privateKey 202 | } 203 | return nil 204 | }) 205 | 206 | if err != nil { 207 | return "", nil 208 | } 209 | 210 | return tx.ToBase64() 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /benchmark/internal/utils/concurrent.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | "golang.org/x/sync/errgroup" 6 | ) 7 | 8 | type result[T any] struct { 9 | index int 10 | value T 11 | } 12 | 13 | func AsyncGather[T any, R any](ctx context.Context, inputs []T, apply func(int, context.Context, T) (R, error)) ([]R, error) { 14 | ch := make(chan result[R], len(inputs)) 15 | group, groupCtx := errgroup.WithContext(ctx) 16 | 17 | for i, input := range inputs { 18 | i := i 19 | input := input 20 | 21 | group.Go(func() error { 22 | v, err := apply(i, groupCtx, input) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | ch <- result[R]{ 28 | index: i, 29 | value: v, 30 | } 31 | return nil 32 | }) 33 | } 34 | 35 | err := group.Wait() 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | outputs := make([]R, len(inputs)) 41 | for range inputs { 42 | r := <-ch 43 | outputs[r.index] = r.value 44 | } 45 | return outputs, nil 46 | } 47 | -------------------------------------------------------------------------------- /benchmark/internal/utils/flag.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "github.com/urfave/cli/v2" 4 | 5 | var ( 6 | OutputFileFlag = &cli.StringFlag{ 7 | Name: "output", 8 | Usage: "file to output CSV results to", 9 | Required: true, 10 | } 11 | SolanaHTTPRPCEndpointFlag = &cli.StringFlag{ 12 | Name: "solana-http-endpoint", 13 | Usage: "HTTP RPC server endpoint to make blockchain queries against", 14 | } 15 | SolanaWSRPCEndpointFlag = &cli.StringFlag{ 16 | Name: "solana-ws-endpoint", 17 | Usage: "WS RPC server endpoint to make blockchain pub/sub queries against", 18 | } 19 | APIWSEndpoint = &cli.StringFlag{ 20 | Name: "solana-trader-ws-endpoint", 21 | Usage: "Solana Trader API API websocket connection endpoint", 22 | Value: "wss://virginia.solana.dex.blxrbdn.com/ws", 23 | } 24 | ) 25 | -------------------------------------------------------------------------------- /benchmark/provider_compare/main.go: -------------------------------------------------------------------------------- 1 | package provider_compare 2 | -------------------------------------------------------------------------------- /benchmark/pumpfun_newtoken_compare/README.md: -------------------------------------------------------------------------------- 1 | # benchmark/pumpfun_newtoken_compare 2 | 3 | Compares Solana Trader API pumpfun new token stream to another thirdparty blocksubscribe stream. 4 | 5 | ## Usage 6 | 7 | Go: 8 | ``` 9 | $ THIRD_PARTY_ENDPOINT=... AUTH_HEADER=... go run ./benchmark/pumpfun_newtoken_compare 10 | ``` 11 | 12 | ## Result 13 | 14 | ``` 15 | 16 | Mahmoud Taabodi 17 | 5:35 PM 18 | BlockTime TraderAPIEventTime ThirdPartyEventTime Diff(thirdParty) Diff(Blocktime) 19 | 1726263118000 1726263140243 1726263140243, -21.091899 sec, 1.151549 sec, 20 | 1726263118000 1726263140245 1726263140245, -21.044734 sec, 1.200886 sec, 21 | 1726263133000 1726263156966 1726263156966, -21.959157 sec, 2.006891 sec, 22 | 1726263156000 1726263171369 1726263171369, -12.996627 sec, 2.373115 sec, 23 | 1726263165000 1726263181012 1726263181012, -14.125343 sec, 1.887493 sec, 24 | 1726263171000 1726263187884 1726263187884, -15.026972 sec, 1.857471 sec, 25 | 1726263174000 1726263190033 1726263190033, -15.338945 sec, 0.694350 sec, 26 | 1726263215000 1726263235128 1726263235128, -18.032521 sec, 2.095713 sec, 27 | Run time: 3m0s 28 | 29 | Total events: 8 30 | 31 | Faster counts: 32 | traderAPIFaster 8 33 | thirdPartyFaster 0 34 | ``` 35 | -------------------------------------------------------------------------------- /benchmark/pumpfun_newtoken_compare/block/block_subscribe.go: -------------------------------------------------------------------------------- 1 | package block 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark" 8 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/logger" 9 | "github.com/bloXroute-Labs/solana-trader-client-go/utils" 10 | "github.com/gagliardetto/solana-go" 11 | solanarpc "github.com/gagliardetto/solana-go/rpc" 12 | "github.com/gorilla/websocket" 13 | "net/http" 14 | "strings" 15 | "time" 16 | ) 17 | 18 | const PumpFunProgramID = "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P" 19 | 20 | //const PumpFunMintAuthorityProgramID = "TSLvdd1pWpHVjahSpsvCXUbgwsL3JAcvokwaKt1eokM" 21 | 22 | func connect3PWs(header http.Header, rpcHost string) (*websocket.Conn, error) { 23 | requestBody := `{"jsonrpc": "2.0","id": "1","method": "blockSubscribe","params": ["all", {"maxSupportedTransactionVersion":0, "commitment": "finalized", "encoding": "base64", "showRewards": true, "transactionDetails": "full"}]}` 24 | if strings.Contains(rpcHost, "helius") { 25 | rpcHost = fmt.Sprintf("wss://%s", rpcHost) 26 | requestBody = `{"jsonrpc":"2.0","id":420,"method":"transactionSubscribe","params":[{"vote":false,"failed":false,"accountInclude":["6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P"],"accountRequired":["TSLvdd1pWpHVjahSpsvCXUbgwsL3JAcvokwaKt1eokM"]},{"commitment":"processed","encoding":"base64","transaction_details":"full","showRewards":true,"maxSupportedTransactionVersion":0}]}` 27 | } else if strings.Contains(rpcHost, "160.202.128.215") { 28 | rpcHost = fmt.Sprintf("ws://%s/ws", rpcHost) 29 | } 30 | logger.Log().Infow("connecting to third party", "rpcHost", rpcHost, "requestBody", requestBody) 31 | ws, _, err := websocket.DefaultDialer.Dial(rpcHost, header) 32 | if err != nil { 33 | logger.Log().Errorw("dial error ", "err", err, "rpcHost", rpcHost) 34 | return nil, err 35 | } 36 | 37 | err = ws.WriteMessage(websocket.TextMessage, []byte(requestBody)) 38 | if err != nil { 39 | logger.Log().Errorw("failed to write message", "err", err) 40 | return nil, err 41 | } 42 | 43 | return ws, nil 44 | } 45 | 46 | func TransactionFromBase64(txBase64 string) (*solana.Transaction, error) { 47 | txBytes, err := solanarpc.DataBytesOrJSONFromBase64(txBase64) 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | tx, err := solanarpc.TransactionWithMeta{Transaction: txBytes}.GetTransaction() 53 | if err != nil { 54 | return nil, err 55 | } 56 | return tx, nil 57 | } 58 | 59 | func StartThirdParty(ctx context.Context, pumpTxMap *utils.LockedMap[string, benchmark.PumpTxInfo], 60 | header http.Header, rpcHost string, messageChan chan *benchmark.NewTokenResult) error { 61 | isHelius := false 62 | if strings.Contains(rpcHost, "helius") { 63 | isHelius = true 64 | } 65 | ws, err := connect3PWs(header, rpcHost) 66 | if err != nil { 67 | return err 68 | } 69 | ch := make(chan []byte, 10) 70 | go func() { 71 | for { 72 | _, response, err := ws.ReadMessage() 73 | if err != nil { 74 | if strings.Contains(err.Error(), "use of closed network connection") { 75 | break 76 | } 77 | time.Sleep(time.Second) 78 | logger.Log().Errorw("ReadMessage", "error", err) 79 | err := ws.Close() 80 | if err != nil { 81 | logger.Log().Errorw("ws.Close()", "error", err) 82 | } 83 | ws, err = connect3PWs(header, rpcHost) 84 | if err != nil { 85 | logger.Log().Errorw("connect3PWs", "error", err) 86 | } else { 87 | logger.Log().Infow("reconnected to ws successfully") 88 | } 89 | continue 90 | } 91 | 92 | ch <- response 93 | } 94 | }() 95 | 96 | for { 97 | 98 | select { 99 | case response := <-ch: 100 | if isHelius { 101 | processHelius(pumpTxMap, response, messageChan) 102 | } else { 103 | process(pumpTxMap, response) 104 | } 105 | 106 | case <-ctx.Done(): 107 | logger.Log().Infow("end of third party processing") 108 | err := ws.Close() 109 | if err != nil { 110 | logger.Log().Errorw("ws.Close()", "error", err) 111 | } 112 | return nil 113 | } 114 | } 115 | } 116 | 117 | func processHelius(pumpTxMap *utils.LockedMap[string, benchmark.PumpTxInfo], response []byte, 118 | messageChan chan *benchmark.NewTokenResult) { 119 | var tx HeliusTx 120 | err := json.Unmarshal(response, &tx) 121 | if err != nil { 122 | logger.Log().Debugw("Unmarshal : ", "error", err) 123 | return 124 | } 125 | if tx.Params.Result.Transaction.Meta.Err != nil { 126 | return 127 | } 128 | for _, fulltx := range tx.Params.Result.Transaction.Transaction { 129 | if fulltx == "base64" { 130 | continue 131 | } 132 | txParsed, err := TransactionFromBase64(fulltx) 133 | if err != nil { 134 | logger.Log().Errorw("TransactionFromBase64 ", "error", err) 135 | continue 136 | } 137 | 138 | foundPump := false 139 | 140 | for _, key := range txParsed.Message.AccountKeys { 141 | if key.String() == PumpFunProgramID { 142 | logger.Log().Infow("helius found pump tx") 143 | foundPump = true 144 | break 145 | } 146 | } 147 | 148 | if !foundPump { 149 | continue 150 | } 151 | 152 | for _, sig := range txParsed.Signatures { 153 | sigStr := sig.String() 154 | 155 | pumpTxMap.Update(sigStr, func(v benchmark.PumpTxInfo, exists bool) benchmark.PumpTxInfo { 156 | if exists { 157 | // helius is getting the event later than trader-api 158 | firstPartyEventTime := v.TimeSeen 159 | thirdPartyEventTime := time.Now() 160 | 161 | res := &benchmark.NewTokenResult{ 162 | TraderAPIEventTime: firstPartyEventTime, 163 | ThirdPartyEventTime: thirdPartyEventTime, 164 | TxHash: sigStr, 165 | Slot: int64(tx.Params.Result.Slot), 166 | Diff: firstPartyEventTime.Sub(thirdPartyEventTime), 167 | } 168 | logger.Log().Infow("helius setting event", "firstParty diff millis", 169 | res.Diff.Milliseconds(), "msg.TxnHash", sigStr, "thirdPartyEventTime", thirdPartyEventTime.UTC()) 170 | 171 | messageChan <- res 172 | 173 | } else { 174 | logger.Log().Debugw("helius getting the event sooner", "msg.TxnHash", sigStr) 175 | v = benchmark.PumpTxInfo{ 176 | TimeSeen: time.Now(), 177 | } 178 | } 179 | return v 180 | }) 181 | } 182 | 183 | } 184 | } 185 | 186 | func process(pumpTxMap *utils.LockedMap[string, benchmark.PumpTxInfo], response []byte) { 187 | var block FullBlock 188 | err := json.Unmarshal(response, &block) 189 | if err != nil { 190 | logger.Log().Errorw("Unmarshal : ", "error", err) 191 | return 192 | } 193 | 194 | for _, fulltx := range block.Params.Result.Value.Block.Txs { 195 | for _, tx := range fulltx.Transaction { 196 | if fulltx.Meta.Err != nil { 197 | continue 198 | } 199 | if tx == "base64" { 200 | continue 201 | } 202 | txParsed, err := TransactionFromBase64(tx) 203 | if err != nil { 204 | logger.Log().Errorw("TransactionFromBase64 ", "error", err) 205 | continue 206 | } 207 | 208 | foundPump := false 209 | now := time.Now() 210 | for _, key := range txParsed.Message.AccountKeys { 211 | if key.String() == PumpFunProgramID { 212 | logger.Log().Infow("3p found pump tx") 213 | foundPump = true 214 | break 215 | } 216 | } 217 | if !foundPump { 218 | continue 219 | } 220 | 221 | for _, sig := range txParsed.Signatures { 222 | sigStr := sig.String() 223 | logger.Log().Infow("rpcNode signature incoming", "sig", sigStr) 224 | pumpTxMap.Set(sigStr, benchmark.PumpTxInfo{ 225 | TimeSeen: now, 226 | }) 227 | 228 | } 229 | 230 | } 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /benchmark/pumpfun_newtoken_compare/block/types.go: -------------------------------------------------------------------------------- 1 | package block 2 | 3 | type FullBlock struct { 4 | Jsonrpc string `json:"jsonrpc"` 5 | Method string `json:"method"` 6 | Params struct { 7 | Result struct { 8 | Context struct { 9 | Slot int `json:"slot"` 10 | } `json:"context"` 11 | Value struct { 12 | Slot int `json:"slot"` 13 | Block struct { 14 | PreviousBlockhash string `json:"previousBlockhash"` 15 | Blockhash string `json:"blockhash"` 16 | ParentSlot int `json:"parentSlot"` 17 | Txs []Tx `json:"transactions"` 18 | Rewards []struct { 19 | Pubkey string `json:"pubkey"` 20 | Lamports int `json:"lamports"` 21 | PostBalance int64 `json:"postBalance"` 22 | RewardType string `json:"rewardType"` 23 | Commission interface{} `json:"commission"` 24 | } `json:"rewards"` 25 | BlockTime int `json:"blockTime"` 26 | BlockHeight int `json:"blockHeight"` 27 | } `json:"block"` 28 | Err interface{} `json:"err"` 29 | } `json:"value"` 30 | } `json:"result"` 31 | Subscription int `json:"subscription"` 32 | } `json:"params"` 33 | } 34 | 35 | type Tx struct { 36 | Transaction []string `json:"transaction"` 37 | Meta struct { 38 | Err interface{} `json:"err"` 39 | Status struct { 40 | Ok interface{} `json:"Ok"` 41 | } `json:"status"` 42 | Fee int `json:"fee"` 43 | PreBalances []int `json:"preBalances"` 44 | PostBalances []int `json:"postBalances"` 45 | InnerInstructions []interface{} `json:"innerInstructions"` 46 | LogMessages []string `json:"logMessages"` 47 | PreTokenBalances []interface{} `json:"preTokenBalances"` 48 | PostTokenBalances []interface{} `json:"postTokenBalances"` 49 | Rewards []interface{} `json:"rewards"` 50 | LoadedAddresses struct { 51 | Writable []interface{} `json:"writable"` 52 | Readonly []interface{} `json:"readonly"` 53 | } `json:"loadedAddresses"` 54 | ComputeUnitsConsumed int `json:"computeUnitsConsumed"` 55 | } `json:"meta"` 56 | Version interface{} `json:"version"` 57 | } 58 | 59 | type HeliusTx struct { 60 | Jsonrpc string `json:"jsonrpc"` 61 | Method string `json:"method"` 62 | Params struct { 63 | Subscription int64 `json:"subscription"` 64 | Result struct { 65 | Transaction struct { 66 | Transaction []string `json:"transaction"` 67 | Meta struct { 68 | Err interface{} `json:"err"` 69 | Status struct { 70 | Ok interface{} `json:"Ok"` 71 | } `json:"status"` 72 | Fee int `json:"fee"` 73 | PreBalances []int64 `json:"preBalances"` 74 | PostBalances []int64 `json:"postBalances"` 75 | InnerInstructions []struct { 76 | Index int `json:"index"` 77 | Instructions []struct { 78 | ProgramIdIndex int `json:"programIdIndex"` 79 | Accounts []int `json:"accounts"` 80 | Data string `json:"data"` 81 | StackHeight int `json:"stackHeight"` 82 | } `json:"instructions"` 83 | } `json:"innerInstructions"` 84 | LogMessages []string `json:"logMessages"` 85 | PreTokenBalances []interface{} `json:"preTokenBalances"` 86 | PostTokenBalances []struct { 87 | AccountIndex int `json:"accountIndex"` 88 | Mint string `json:"mint"` 89 | UiTokenAmount struct { 90 | UiAmount float64 `json:"uiAmount"` 91 | Decimals int `json:"decimals"` 92 | Amount string `json:"amount"` 93 | UiAmountString string `json:"uiAmountString"` 94 | } `json:"uiTokenAmount"` 95 | Owner string `json:"owner"` 96 | ProgramId string `json:"programId"` 97 | } `json:"postTokenBalances"` 98 | Rewards []interface{} `json:"rewards"` 99 | LoadedAddresses struct { 100 | Writable []interface{} `json:"writable"` 101 | Readonly []interface{} `json:"readonly"` 102 | } `json:"loadedAddresses"` 103 | ComputeUnitsConsumed int `json:"computeUnitsConsumed"` 104 | } `json:"meta"` 105 | Version interface{} `json:"version"` 106 | } `json:"transaction"` 107 | Signature string `json:"signature"` 108 | Slot int `json:"slot"` 109 | } `json:"result"` 110 | } `json:"params"` 111 | } 112 | -------------------------------------------------------------------------------- /benchmark/pumpfun_newtoken_compare/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark" 6 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/logger" 7 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/stream" 8 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/utils" 9 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/pumpfun_newtoken_compare/block" 10 | utils2 "github.com/bloXroute-Labs/solana-trader-client-go/utils" 11 | "github.com/gagliardetto/solana-go/rpc" 12 | "github.com/joho/godotenv" 13 | "github.com/pkg/errors" 14 | log "github.com/sirupsen/logrus" 15 | "github.com/urfave/cli/v2" 16 | "net/http" 17 | "os" 18 | "os/signal" 19 | "strings" 20 | "syscall" 21 | "time" 22 | ) 23 | 24 | import ( 25 | "context" 26 | ) 27 | 28 | const updateInterval = 10 * time.Second 29 | 30 | var ( 31 | DurationFlag = &cli.DurationFlag{ 32 | Name: "run-time", 33 | Usage: "amount of time to run script for (seconds)", 34 | Required: false, 35 | //Value: time.Second * 3600, // 1 HOUR 36 | Value: time.Minute * 15, 37 | //Value: time.Second * 15, 38 | } 39 | ) 40 | 41 | func main() { 42 | 43 | err := godotenv.Load(".env") 44 | if err != nil { 45 | panic(err) 46 | } 47 | utils.OutputFileFlag = &cli.StringFlag{ 48 | Name: "output", 49 | Usage: "file to output CSV results to", 50 | Required: false, 51 | Value: "pump_fun_trader_api_comparison.csv", 52 | } 53 | 54 | app := &cli.App{ 55 | Name: "benchmark-traderapi-pumpfun-newtokens", 56 | Usage: "Compares Solana Trader API pumpfun new token stream", 57 | Flags: []cli.Flag{ 58 | DurationFlag, 59 | utils.OutputFileFlag, 60 | }, 61 | Action: run, 62 | } 63 | 64 | err = app.Run(os.Args) 65 | defer func() { 66 | if logger.Log() != nil { 67 | _ = logger.Log().Sync() 68 | } 69 | }() 70 | 71 | if err != nil { 72 | panic(err) 73 | } 74 | 75 | } 76 | 77 | func run(c *cli.Context) error { 78 | ctx, cancel := context.WithCancel(context.Background()) 79 | defer cancel() 80 | pumpTxMap := utils2.NewLockedMap[string, benchmark.PumpTxInfo]() 81 | 82 | startTime := time.Now() 83 | duration := c.Duration(DurationFlag.Name) 84 | runCtx, runCancel := context.WithTimeout(ctx, duration) 85 | defer runCancel() 86 | sigc := make(chan os.Signal, 1) 87 | signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM) 88 | go func() { 89 | for { 90 | select { 91 | case <-sigc: 92 | logger.Log().Info("shutdown requested") 93 | runCancel() 94 | return 95 | case <-ctx.Done(): 96 | runCancel() 97 | return 98 | } 99 | } 100 | }() 101 | thirdPartyEndpoint, ok := os.LookupEnv("THIRD_PARTY_ENDPOINT") 102 | if !ok { 103 | log.Infof("THIRD_PARTY_ENDPOINT environment variable not set: requests will be slower") 104 | } 105 | skip3rdParty := false 106 | messageChan := make(chan *benchmark.NewTokenResult, 100) 107 | 108 | authHeader, ok := os.LookupEnv("AUTH_HEADER") 109 | if !ok { 110 | return errors.New("AUTH_HEADER not set in environment") 111 | } 112 | getBlockEndpoint, ok := os.LookupEnv("GET_BLOCK_ENDPOINT") 113 | if !ok { 114 | log.Infof("GET_BLOCK_ENDPOINT environment variable not set: requests will be slower") 115 | } 116 | firstPartyEndpoint, ok := os.LookupEnv("FIRST_PARTY_ENDPOINT") 117 | if !ok { 118 | return errors.New("FIRST_PARTY_ENDPOINT not set in environment") 119 | } 120 | 121 | if strings.Contains(thirdPartyEndpoint, ":1809") { 122 | // the third party is another trader-api in this case 123 | skip3rdParty = true 124 | err := startTraderAPIStream(false, runCtx, messageChan, authHeader, thirdPartyEndpoint, pumpTxMap) 125 | if err != nil { 126 | panic(err) 127 | } 128 | } 129 | 130 | if !skip3rdParty { 131 | go func() { 132 | err := block.StartThirdParty( 133 | runCtx, 134 | pumpTxMap, 135 | http.Header{}, 136 | thirdPartyEndpoint, 137 | messageChan, 138 | ) 139 | if err != nil { 140 | logger.Log().Errorw("startDetecting", "error", err) 141 | } 142 | }() 143 | } 144 | 145 | err := startTraderAPIStream(true, runCtx, messageChan, authHeader, firstPartyEndpoint, pumpTxMap) 146 | if err != nil { 147 | panic(err) 148 | } 149 | ticker := time.NewTicker(updateInterval) 150 | var tradeUpdates []*benchmark.NewTokenResult 151 | solanaRpc := rpc.New(fmt.Sprintf("https://%s", getBlockEndpoint)) 152 | 153 | Loop: 154 | for { 155 | select { 156 | case msg, ok := <-messageChan: 157 | if ok { 158 | populateSlotInfos(msg, solanaRpc) 159 | tradeUpdates = append(tradeUpdates, msg) 160 | } 161 | case <-ticker.C: 162 | elapsedTime := time.Now().Sub(startTime).Round(time.Second) 163 | logger.Log().Infof("waited %v out of %v...", elapsedTime, duration) 164 | if elapsedTime >= duration { 165 | break Loop 166 | } 167 | case <-runCtx.Done(): 168 | break Loop 169 | 170 | } 171 | } 172 | time.Sleep(time.Second) 173 | 174 | logger.Log().Infow("finished collecting data points", "tradercount", len(tradeUpdates)) 175 | 176 | PrintSummary(duration, tradeUpdates) 177 | 178 | return nil 179 | } 180 | 181 | func startTraderAPIStream(isFirstParty bool, runCtx context.Context, messageChan chan *benchmark.NewTokenResult, authHeader, firstPartyEndpoint string, pumpTxMap *utils2.LockedMap[string, benchmark.PumpTxInfo]) error { 182 | traderOS, err := stream.NewTraderWSPPumpFunNewToken(isFirstParty, messageChan, pumpTxMap, firstPartyEndpoint, authHeader) 183 | if err != nil { 184 | return err 185 | } 186 | 187 | go func() { 188 | var err error 189 | 190 | _, err = traderOS.Run(runCtx) 191 | if err != nil { 192 | panic(err) 193 | return 194 | } 195 | }() 196 | 197 | return nil 198 | } 199 | 200 | func PrintSummary(runtime time.Duration, datapoints []*benchmark.NewTokenResult) { 201 | traderFaster := 0 202 | tpFaster := 0 203 | var sumDiff, total int64 204 | fmt.Println("BlockTime TraderAPIEventTime ThirdPartyEventTime Diff(thirdParty) Diff(Blocktime)") 205 | for _, vs := range datapoints { 206 | 207 | total++ 208 | fmt.Print(fmt.Sprintf("%d", vs.BlockTime.UnixMilli())) 209 | fmt.Print(fmt.Sprintf(" %d", vs.TraderAPIEventTime.UnixMilli())) 210 | fmt.Print(fmt.Sprintf(" %d", vs.ThirdPartyEventTime.UnixMilli())) 211 | fmt.Print(fmt.Sprintf(" %f sec", vs.Diff.Seconds())) 212 | fmt.Println(fmt.Sprintf(" %f sec", vs.TraderAPIEventTime.Sub(vs.BlockTime).Seconds())) 213 | diffMillis := vs.TraderAPIEventTime.UnixMilli() - vs.ThirdPartyEventTime.UnixMilli() 214 | sumDiff += diffMillis 215 | if vs.TraderAPIEventTime.Before(vs.ThirdPartyEventTime) { 216 | traderFaster++ 217 | } else if vs.ThirdPartyEventTime.Before(vs.TraderAPIEventTime) { 218 | tpFaster++ 219 | } 220 | } 221 | 222 | fmt.Println("Run time: ", runtime) 223 | fmt.Println() 224 | 225 | fmt.Println("Total events: ", total) 226 | fmt.Println() 227 | 228 | fmt.Println("Faster counts: ") 229 | fmt.Println(fmt.Sprintf(" traderAPIFaster %d", traderFaster)) 230 | fmt.Println(fmt.Sprintf(" thirdPartyFaster %d", tpFaster)) 231 | if total != 0 { 232 | fmt.Println(fmt.Sprintf(" Avg time Diff in millis %f", float64(sumDiff/total))) 233 | } 234 | } 235 | 236 | func populateSlotInfos(msg *benchmark.NewTokenResult, solanaRpc *rpc.Client) { 237 | tryCount := 0 238 | for { 239 | if tryCount > 3 { 240 | time.Sleep(10 * time.Second) 241 | } else { 242 | time.Sleep(time.Second) 243 | } 244 | tryCount++ 245 | if tryCount >= 10 { 246 | logger.Log().Infow("failed to find info for tx", 247 | "sig", msg.TxHash) 248 | break 249 | } 250 | 251 | mstv := uint64(0) 252 | slotInfo, err := solanaRpc.GetBlockWithOpts( 253 | context.Background(), 254 | uint64(msg.Slot), 255 | &rpc.GetBlockOpts{ 256 | TransactionDetails: rpc.TransactionDetailsNone, 257 | Commitment: rpc.CommitmentConfirmed, 258 | MaxSupportedTransactionVersion: &mstv, 259 | }, 260 | ) 261 | if err != nil { 262 | logger.Log().Errorw("error occurred when getting slot info", 263 | "tryCount", tryCount, "err", err) 264 | continue 265 | } 266 | msg.BlockTime = slotInfo.BlockTime.Time() 267 | return 268 | } 269 | 270 | } 271 | -------------------------------------------------------------------------------- /benchmark/quotes/README.md: -------------------------------------------------------------------------------- 1 | # benchmark/quotes 2 | 3 | Compares Solana Trader API prices stream to Jupiter's API. This comparison is most optimally done by choosing a low 4 | liquidity market (GOSU by default) then using this script to generate swaps for that market so you can identify when 5 | each source responds to the update in the chain. 6 | 7 | Note that this comparison is not entirely fair: Jupiter's API does not support streaming, so a poll-based approach 8 | must be taken. You can configure a poll timeout to your preference, but will have to deal with rate limits. To help 9 | with this somewhat, an HTTP polling based approach is also included for Trader API, though using websockets is 10 | expected to be significantly more helpful. 11 | 12 | You will need a bloXroute `AUTH_HEADER` to access Trader API, and a Solana `PRIVATE_KEY` to generate swaps. 13 | 14 | ## Usage 15 | 16 | Full test (10 swaps for 0.1 USDC => GOSU): 17 | ``` 18 | $ AUTH_HEADER=... PRIVATE_KEY=... go run ./benchmark/quotes --iterations 10 --runtime 5m --swap-amount 0.1 --swap-interval 30s --trigger-activity --output updates.csv 19 | ``` 20 | 21 | Single swap test (1 swap for 0.1 USDC => GOSU, useful for debugging): 22 | ``` 23 | $ AUTH_HEADER=... PRIVATE_KEY=... go run ./benchmark/quotes --iterations 1 --runtime 1m --swap-amount 0.1 --swap-interval 5s --trigger-activity --output updates.csv 24 | ``` 25 | 26 | No swaps (useful for debugging, though WS might not produce any results): 27 | ``` 28 | $ AUTH_HEADER=... go run ./benchmark/quotes --runtime 10s --output updates.csv 29 | ``` 30 | 31 | ## Result 32 | 33 | 34 | 35 | ``` 36 | 2023-06-02T13:10:29.125-0500 INFO quotes/main.go:99 trader API clients connected {"env": "mainnet"} 37 | 2023-06-02T13:10:29.125-0500 INFO quotes/main.go:133 starting all routines {"duration": "1m0s"} 38 | 2023-06-02T13:10:39.126-0500 INFO actor/jupiter_swap.go:82 starting swap submission {"source": "jupiterActor", "total": 1} 39 | 2023-06-02T13:10:44.127-0500 INFO actor/jupiter_swap.go:88 submitting swap {"source": "jupiterActor", "count": 0} 40 | 2023-06-02T13:10:45.613-0500 INFO actor/jupiter_swap.go:97 completed swap {"source": "jupiterActor", "transactions": [{"signature":"FkQABUjAXbqNroR5Y4xnnhS5RhtDe7abuyYusumJCpVpbrTGjvDtNdo4QdCWmTbhZVbiHFuHyDxkyfYqTzSupf6","submitted":true}]} 41 | 2023-06-02T13:10:55.618-0500 INFO quotes/main.go:195 ignoring jupiter duplicates {"count": 19} 42 | 2023-06-02T13:10:55.618-0500 INFO quotes/main.go:198 ignoring tradeWS duplicates {"count": 0} 43 | 2023-06-02T13:10:55.618-0500 INFO quotes/main.go:201 ignoring tradeWS duplicates {"count": 1} 44 | 45 | Trader API vs. Jupiter API Benchmark 46 | 47 | Swaps placed: 1 48 | Jun 2 13:10:45.613: FkQABUjAXbqNroR5Y4xnnhS5RhtDe7abuyYusumJCpVpbrTGjvDtNdo4QdCWmTbhZVbiHFuHyDxkyfYqTzSupf6 49 | 50 | Jupiter: 26 samples 51 | Start time: Jun 2 13:10:30.126 52 | End time: Jun 2 13:10:55.128 53 | Slot range: 197398959 => 197399010 54 | Price change: 0.00047147519075931205 => 0.0004718409190889233 55 | Distinct prices: 3 56 | 57 | Trader WS: 2 samples 58 | Start time: Jun 2 13:10:29.335 59 | End time: Jun 2 13:10:47.571 60 | Slot range: 197398958 => 197398999 61 | Buy change: 0.000467 => 0.000467 62 | Sell change: 0.0004714926053447434 => 0.00047185835217295884 63 | Distinct buy prices: 1 64 | Distinct sell prices: 2 65 | 66 | Trader HTTP: 26 samples 67 | Start time: Jun 2 13:10:29.626 68 | End time: Jun 2 13:10:54.626 69 | Buy change: 0.000467 => 0.000467 70 | Sell change: 0.0004714926053447434 => 0.00047185835217295884 71 | Distinct buy prices: 2 72 | Distinct sell prices: 3 73 | 74 | jupiter API 75 | [197398959] 2023-06-02 13:10:30.126294 -0500 CDT m=+1.282104543 [790ms]: B: 0.00047147519075931205 | S: 0.00047147519075931205 76 | [197398979] 2023-06-02 13:10:40.128294 -0500 CDT m=+11.284147626 [129ms]: B: 0.000471492615933144 | S: 0.000471492615933144 77 | [197398981] 2023-06-02 13:10:41.126732 -0500 CDT m=+12.282590376 [322ms]: B: 0.00047147519075931205 | S: 0.00047147519075931205 78 | [197398987] 2023-06-02 13:10:45.126687 -0500 CDT m=+16.282562418 [420ms]: B: 0.00047147519075931205 | S: 0.00047147519075931205 79 | [197398988] 2023-06-02 13:10:44.126723 -0500 CDT m=+15.282594084 [268ms]: B: 0.000471492615933144 | S: 0.000471492615933144 80 | [197398996] 2023-06-02 13:10:49.127456 -0500 CDT m=+20.283349126 [297ms]: B: 0.0004718409190889233 | S: 0.0004718409190889233 81 | 82 | traderWS 83 | [197398958] 2023-06-02 13:10:29.335606 -0500 CDT m=+0.491412793 [0ms]: B: 0.000467 | S: 0.0004714926053447434 84 | [197398999] 2023-06-02 13:10:47.571456 -0500 CDT m=+18.727342418 [0ms]: B: 0.000467 | S: 0.00047185835217295884 85 | 86 | traderHTTP 87 | [-1] 2023-06-02 13:10:29.860785 -0500 CDT m=+1.016593918 [233ms]: B: 0.000467 | S: 0.0004714926053447434 88 | [-1] 2023-06-02 13:10:45.471033 -0500 CDT m=+16.626910209 [844ms]: B: 0.000466 | S: 0.00047147894730418404 89 | [-1] 2023-06-02 13:10:46.486188 -0500 CDT m=+17.642068876 [859ms]: B: 0.000467 | S: 0.0004714926053447434 90 | [-1] 2023-06-02 13:10:47.683645 -0500 CDT m=+18.839531959 [56ms]: B: 0.000467 | S: 0.00047185835217295884 91 | ``` 92 | 93 | A CSV file will also be generated with a time-sorted event list. 94 | 95 | ``` 96 | timestamp,source,slot,processingTime,buy,sell 97 | 2023-06-02T13:10:29.335606-05:00,traderWS,197398958,0s,0.000467,0.0004714926053447434 98 | 2023-06-02T13:10:29.860785-05:00,traderHTTP,-1,233.970375ms,0.000467,0.0004714926053447434 99 | 2023-06-02T13:10:30.126294-05:00,jupiter,197398959,790.252125ms,0.00047147519075931205,0.00047147519075931205 100 | 2023-06-02T13:10:40.128294-05:00,jupiter,197398979,129.040042ms,0.000471492615933144,0.000471492615933144 101 | 2023-06-02T13:10:41.126732-05:00,jupiter,197398981,322.407708ms,0.00047147519075931205,0.00047147519075931205 102 | 2023-06-02T13:10:44.126723-05:00,jupiter,197398988,268.150959ms,0.000471492615933144,0.000471492615933144 103 | 2023-06-02T13:10:45.126687-05:00,jupiter,197398987,420.006041ms,0.00047147519075931205,0.00047147519075931205 104 | 2023-06-02T13:10:45.471033-05:00,traderHTTP,-1,844.338583ms,0.000466,0.00047147894730418404 105 | 2023-06-02T13:10:46-05:00,transaction-FkQABUjAXbqNroR5Y4xnnhS5RhtDe7abuyYusumJCpVpbrTGjvDtNdo4QdCWmTbhZVbiHFuHyDxkyfYqTzSupf6,197398996,386.508ms,0,0 106 | 2023-06-02T13:10:46.486188-05:00,traderHTTP,-1,859.4435ms,0.000467,0.0004714926053447434 107 | 2023-06-02T13:10:47.571456-05:00,traderWS,197398999,0s,0.000467,0.00047185835217295884 108 | 2023-06-02T13:10:47.683645-05:00,traderHTTP,-1,56.971583ms,0.000467,0.00047185835217295884 109 | 2023-06-02T13:10:49.127456-05:00,jupiter,197398996,297.499125ms,0.0004718409190889233,0.0004718409190889233 110 | ``` -------------------------------------------------------------------------------- /benchmark/quotes/env.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/bloXroute-Labs/solana-trader-client-go/examples/config" 6 | "github.com/bloXroute-Labs/solana-trader-client-go/provider" 7 | ) 8 | 9 | func traderClients(env string) (provider.HTTPClientTraderAPI, provider.WSClientTraderAPI, error) { 10 | var ( 11 | httpClient provider.HTTPClientTraderAPI 12 | wsClient provider.WSClientTraderAPI 13 | err error 14 | ) 15 | switch env { 16 | case "testnet": 17 | httpClient = provider.NewHTTPTestnet() 18 | wsClient, err = provider.NewWSClientTestnet() 19 | if err != nil { 20 | return nil, nil, err 21 | } 22 | case "devnet": 23 | httpClient = provider.NewHTTPDevnet() 24 | wsClient, err = provider.NewWSClientDevnet() 25 | if err != nil { 26 | return nil, nil, err 27 | } 28 | case "devnet1": 29 | httpClient = provider.NewHTTPClientWithOpts(nil, provider.DefaultRPCOpts("http://3.239.217.218:1809")) 30 | wsClient, err = provider.NewWSClientWithOpts(provider.DefaultRPCOpts("ws://3.239.217.218:1809/ws")) 31 | if err != nil { 32 | return nil, nil, err 33 | } 34 | case "mainnet": 35 | httpClient = provider.NewHTTPClient() 36 | wsClient, err = provider.NewWSClientFullService(config.WSUrls["ny"]) 37 | if err != nil { 38 | return nil, nil, err 39 | } 40 | case "mainnet1": 41 | httpClient = provider.NewHTTPClientWithOpts(nil, provider.DefaultRPCOpts("http://54.161.46.25:1809")) 42 | wsClient, err = provider.NewWSClientWithOpts(provider.DefaultRPCOpts("ws://54.161.46.25:1809/ws")) 43 | if err != nil { 44 | return nil, nil, err 45 | } 46 | default: 47 | return nil, nil, fmt.Errorf("unknown environment: %v", env) 48 | } 49 | 50 | return httpClient, wsClient, nil 51 | } 52 | -------------------------------------------------------------------------------- /benchmark/quotes/flag.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/urfave/cli/v2" 5 | "time" 6 | ) 7 | 8 | var ( 9 | MintFlag = &cli.StringFlag{ 10 | Name: "mint", 11 | Usage: "mint to fetch price for (inactive token is best)", 12 | Value: "6D7nXHAhsRbwj8KFZR2agB6GEjMLg4BM7MAqZzRT8F1j", // gosu 13 | } 14 | 15 | MintDecimalsFlag = &cli.IntFlag{ 16 | Name: "mint-decimals", 17 | Usage: "number of decimals for mint token", 18 | Value: 8, 19 | } 20 | 21 | TriggerActivityFlag = &cli.BoolFlag{ 22 | Name: "trigger-activity", 23 | Usage: "if true, send trigger transactions to force quote updates (requires PRIVATE_KEY environment variable_", 24 | } 25 | 26 | IterationsFlag = &cli.IntFlag{ 27 | Name: "iterations", 28 | Usage: "number of quotes to compare", 29 | Value: 5, 30 | } 31 | 32 | MaxRuntimeFlag = &cli.DurationFlag{ 33 | Name: "runtime", 34 | Usage: "max time to run benchmark for", 35 | Value: 10 * time.Minute, 36 | } 37 | 38 | SwapAmountFlag = &cli.Float64Flag{ 39 | Name: "swap-amount", 40 | Usage: "amount to swap for each trigger transaction (for unit, see --swap-mint)", 41 | Value: 0.1, 42 | } 43 | 44 | SwapMintFlag = &cli.StringFlag{ 45 | Name: "swap-mint", 46 | Usage: "corresponding token to swap from to --mint token for triggers", 47 | Value: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", 48 | } 49 | 50 | SwapIntervalFlag = &cli.DurationFlag{ 51 | Name: "swap-interval", 52 | Usage: "time to wait between each swap", 53 | Value: 30 * time.Second, 54 | } 55 | 56 | SwapInitialWaitFlag = &cli.DurationFlag{ 57 | Name: "swap-initial-wait", 58 | Usage: "initial wait before beginning swaps (note that swap timers will automatically finish the test before max-runtime)", 59 | Value: 10 * time.Second, 60 | } 61 | 62 | SwapAfterWaitFlag = &cli.DurationFlag{ 63 | Name: "swap-after-wait", 64 | Usage: "initial wait after finishing swaps", 65 | Value: 10 * time.Second, 66 | } 67 | 68 | SwapAlternateFlag = &cli.BoolFlag{ 69 | Name: "swap-alternate", 70 | Usage: "alternate direction of swaps", 71 | } 72 | 73 | QueryIntervalFlag = &cli.DurationFlag{ 74 | Name: "query-interval", 75 | Usage: "time to wait between each poll for poll-based streams", 76 | Value: 500 * time.Millisecond, 77 | } 78 | 79 | PublicKeyFlag = &cli.StringFlag{ 80 | Name: "public-key", 81 | Usage: "public key to place swaps over (requires PRIVATE_KEY environment variable)", 82 | Value: "AFT8VayE7qr8MoQsW3wHsDS83HhEvhGWdbNSHRKeUDfQ", 83 | } 84 | 85 | EnvFlag = &cli.StringFlag{ 86 | Name: "env", 87 | Usage: "trader API environment (options: mainnet, testnet, devnet)", 88 | Value: "mainnet", 89 | } 90 | ) 91 | -------------------------------------------------------------------------------- /benchmark/quotes/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/actor" 7 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/logger" 8 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/stream" 9 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/utils" 10 | pb "github.com/bloXroute-Labs/solana-trader-proto/api" 11 | "github.com/gagliardetto/solana-go" 12 | solanarpc "github.com/gagliardetto/solana-go/rpc" 13 | "github.com/pkg/errors" 14 | "github.com/urfave/cli/v2" 15 | "os" 16 | "time" 17 | ) 18 | 19 | // requires AUTH_HEADER and PRIVATE_KEY to work. 20 | 21 | func main() { 22 | utils.SolanaHTTPRPCEndpointFlag.Required = true 23 | utils.SolanaHTTPRPCEndpointFlag.Value = "https://api.mainnet-beta.solana.com" 24 | 25 | app := &cli.App{ 26 | Name: "benchmark-quotes", 27 | Usage: "Compares Solana Trader API AMM quotes with Jupiter API", 28 | Flags: []cli.Flag{ 29 | utils.APIWSEndpoint, 30 | utils.OutputFileFlag, 31 | utils.SolanaHTTPRPCEndpointFlag, 32 | MintFlag, 33 | MintDecimalsFlag, 34 | TriggerActivityFlag, 35 | IterationsFlag, 36 | PublicKeyFlag, 37 | MaxRuntimeFlag, 38 | SwapAmountFlag, 39 | SwapMintFlag, 40 | SwapIntervalFlag, 41 | SwapInitialWaitFlag, 42 | SwapAfterWaitFlag, 43 | SwapAlternateFlag, 44 | QueryIntervalFlag, 45 | EnvFlag, 46 | }, 47 | Action: run, 48 | } 49 | 50 | err := app.Run(os.Args) 51 | defer func() { 52 | if logger.Log() != nil { 53 | _ = logger.Log().Sync() 54 | } 55 | }() 56 | 57 | if err != nil { 58 | panic(err) 59 | } 60 | } 61 | 62 | func run(c *cli.Context) error { 63 | ctx, cancel := context.WithCancel(context.Background()) 64 | defer cancel() 65 | 66 | _, ok := os.LookupEnv("AUTH_HEADER") 67 | if !ok { 68 | return errors.New("AUTH_HEADER not set in environment") 69 | } 70 | 71 | var ( 72 | env = c.String(EnvFlag.Name) 73 | mint = c.String(MintFlag.Name) 74 | mintDecimals = c.Int(MintDecimalsFlag.Name) 75 | iterations = c.Int(IterationsFlag.Name) 76 | triggerActivity = c.Bool(TriggerActivityFlag.Name) 77 | publicKey = c.String(PublicKeyFlag.Name) 78 | 79 | maxRuntime = c.Duration(MaxRuntimeFlag.Name) 80 | swapAmount = c.Float64(SwapAmountFlag.Name) 81 | swapMint = c.String(SwapMintFlag.Name) 82 | swapInterval = c.Duration(SwapIntervalFlag.Name) 83 | swapInitialWait = c.Duration(SwapInitialWaitFlag.Name) 84 | swapAlternate = c.Bool(SwapAlternateFlag.Name) 85 | swapAfterWait = c.Duration(SwapAfterWaitFlag.Name) 86 | queryInterval = c.Duration(QueryIntervalFlag.Name) 87 | 88 | rpcEndpoint = c.String(utils.SolanaHTTPRPCEndpointFlag.Name) 89 | outputFile = c.String(utils.OutputFileFlag.Name) 90 | ) 91 | 92 | if triggerActivity { 93 | _, ok := os.LookupEnv("PRIVATE_KEY") 94 | if !ok { 95 | return errors.New("PRIVATE_KEY not set in environment when --trigger-activity set") 96 | } 97 | } 98 | 99 | httpClient, wsClient, err := traderClients(env) 100 | if err != nil { 101 | return err 102 | } 103 | 104 | logger.Log().Infow("trader API clients connected", "env", env) 105 | 106 | syncedTicker := time.NewTicker(queryInterval) 107 | defer syncedTicker.Stop() 108 | 109 | jupiterAPI, err := stream.NewJupiterAPI(stream.WithJupiterToken(mint, mintDecimals), stream.WithJupiterTicker(syncedTicker)) 110 | if err != nil { 111 | return err 112 | } 113 | 114 | traderAPIWS, err := stream.NewTraderWSPrice(stream.WithTraderWSMint(mint), stream.WithTraderWSClient(wsClient)) 115 | if err != nil { 116 | return err 117 | } 118 | 119 | traderAPIHTTP, err := stream.NewTraderHTTPPriceStream(stream.WithTraderHTTPMint(mint), stream.WithTraderHTTPTicker(syncedTicker), stream.WithTraderHTTPClient(httpClient)) 120 | if err != nil { 121 | return err 122 | } 123 | 124 | jupiterActor, err := actor.NewJupiterSwap(actor.WithJupiterTokenPair(swapMint, mint), actor.WithJupiterPublicKey(publicKey), actor.WithJupiterInitialTimeout(swapInitialWait), actor.WithJupiterAfterTimeout(swapAfterWait), actor.WithJupiterInterval(swapInterval), actor.WithJupiterAmount(swapAmount), actor.WithJupiterClient(httpClient), actor.WithJupiterAlternate(swapAlternate)) 125 | if err != nil { 126 | return err 127 | } 128 | 129 | var ( 130 | tradeWSUpdates []stream.RawUpdate[*pb.GetPricesStreamResponse] 131 | tradeHTTPUpdates []stream.RawUpdate[stream.DurationUpdate[*pb.GetPriceResponse]] 132 | jupiterUpdates []stream.RawUpdate[stream.DurationUpdate[*stream.JupiterPriceResponse]] 133 | errCh = make(chan error, 2) 134 | runCtx, runCancel = context.WithTimeout(ctx, maxRuntime) 135 | ) 136 | defer runCancel() 137 | 138 | logger.Log().Infow("starting all routines", "duration", maxRuntime) 139 | 140 | go func() { 141 | var err error 142 | 143 | jupiterUpdates, err = jupiterAPI.Run(runCtx) 144 | if err != nil { 145 | errCh <- errors.Wrap(err, "could not collect results from Solana") 146 | return 147 | } 148 | 149 | errCh <- nil 150 | }() 151 | 152 | go func() { 153 | var err error 154 | 155 | tradeWSUpdates, err = traderAPIWS.Run(runCtx) 156 | if err != nil { 157 | errCh <- errors.Wrap(err, "could not collect results from Trader API") 158 | return 159 | } 160 | 161 | errCh <- nil 162 | }() 163 | 164 | go func() { 165 | var err error 166 | 167 | tradeHTTPUpdates, err = traderAPIHTTP.Run(runCtx) 168 | if err != nil { 169 | errCh <- errors.Wrap(err, "could not collect results from Trader API") 170 | return 171 | } 172 | 173 | errCh <- nil 174 | }() 175 | 176 | var swaps []actor.SwapEvent 177 | if triggerActivity { 178 | swaps, err = jupiterActor.Swap(runCtx, iterations) 179 | if err != nil { 180 | return err 181 | } 182 | 183 | runCancel() 184 | } 185 | 186 | // wait for routines to exit 187 | completionCount := 0 188 | for completionCount < 3 { 189 | select { 190 | case runErr := <-errCh: 191 | completionCount++ 192 | if runErr != nil { 193 | logger.Log().Errorw("fatal error during runtime: exiting", "err", err) 194 | return runErr 195 | } 196 | } 197 | } 198 | 199 | jupiterProcessedUpdate, jupiterDuplicates, _ := jupiterAPI.Process(jupiterUpdates, true) 200 | logger.Log().Infow("ignoring jupiter duplicates", "count", len(jupiterDuplicates)) 201 | 202 | tradeWSProcessedUpdate, tradeWSDuplicates, _ := traderAPIWS.Process(tradeWSUpdates, true) 203 | logger.Log().Infow("ignoring tradeWS duplicates", "count", len(tradeWSDuplicates)) 204 | 205 | tradeHTTPProcessedUpdate, tradeHTTPDuplicates, _ := traderAPIHTTP.Process(tradeHTTPUpdates, true) 206 | logger.Log().Infow("ignoring tradeWS duplicates", "count", len(tradeHTTPDuplicates)) 207 | 208 | swapUpdates, err := fetchTransactionInfo(ctx, swaps, rpcEndpoint) 209 | if err != nil { 210 | return err 211 | } 212 | 213 | fmt.Println() 214 | result := benchmarkResult{ 215 | mint: mint, 216 | swaps: swaps, 217 | jupiterRawUpdates: jupiterUpdates, 218 | tradeWSRawUpdates: tradeWSUpdates, 219 | tradeHTTPRawUpdates: tradeHTTPUpdates, 220 | jupiterProcessedUpdates: jupiterProcessedUpdate, 221 | tradeWSProcessedUpdates: tradeWSProcessedUpdate, 222 | tradeHTTPProcessedUpdates: tradeHTTPProcessedUpdate, 223 | swapProcessedUpdates: swapUpdates, 224 | } 225 | 226 | result.PrintSummary() 227 | result.PrintRaw() 228 | return result.WriteCSV(outputFile) 229 | } 230 | 231 | func fetchTransactionInfo(ctx context.Context, swaps []actor.SwapEvent, rpcEndpoint string) (map[int][]stream.ProcessedUpdate[stream.QuoteResult], error) { 232 | rpc := solanarpc.New(rpcEndpoint) 233 | transactionEvents, err := utils.AsyncGather(ctx, swaps, func(i int, ctx context.Context, t actor.SwapEvent) (r stream.ProcessedUpdate[stream.QuoteResult], err error) { 234 | maxVersion := uint64(0) 235 | swap := swaps[i] 236 | result, err := rpc.GetTransaction(ctx, solana.MustSignatureFromBase58(swap.Signature), &solanarpc.GetTransactionOpts{ 237 | Encoding: solana.EncodingBase64, 238 | Commitment: solanarpc.CommitmentConfirmed, 239 | MaxSupportedTransactionVersion: &maxVersion, 240 | }) 241 | if err != nil { 242 | return 243 | } 244 | 245 | r = stream.ProcessedUpdate[stream.QuoteResult]{ 246 | Timestamp: result.BlockTime.Time(), 247 | Slot: int(result.Slot), 248 | Data: stream.QuoteResult{ 249 | Elapsed: result.BlockTime.Time().Sub(swap.Timestamp), 250 | BuyPrice: 0, 251 | SellPrice: 0, 252 | Source: fmt.Sprintf("transaction-%v-%v", swap.Signature, swap.Info), 253 | }, 254 | } 255 | return 256 | }) 257 | 258 | if err != nil { 259 | return nil, err 260 | } 261 | 262 | result := make(map[int][]stream.ProcessedUpdate[stream.QuoteResult]) 263 | for _, event := range transactionEvents { 264 | result[event.Slot] = append(result[event.Slot], event) 265 | } 266 | return result, nil 267 | } 268 | -------------------------------------------------------------------------------- /benchmark/quotes/process.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/csv" 5 | "fmt" 6 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/actor" 7 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/output" 8 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/stream" 9 | pb "github.com/bloXroute-Labs/solana-trader-proto/api" 10 | "os" 11 | "sort" 12 | "strconv" 13 | "time" 14 | ) 15 | 16 | type benchmarkResult struct { 17 | mint string 18 | swaps []actor.SwapEvent 19 | jupiterRawUpdates []stream.RawUpdate[stream.DurationUpdate[*stream.JupiterPriceResponse]] 20 | tradeWSRawUpdates []stream.RawUpdate[*pb.GetPricesStreamResponse] 21 | tradeHTTPRawUpdates []stream.RawUpdate[stream.DurationUpdate[*pb.GetPriceResponse]] 22 | 23 | jupiterProcessedUpdates map[int][]stream.ProcessedUpdate[stream.QuoteResult] 24 | tradeWSProcessedUpdates map[int][]stream.ProcessedUpdate[stream.QuoteResult] 25 | tradeHTTPProcessedUpdates map[int][]stream.ProcessedUpdate[stream.QuoteResult] 26 | swapProcessedUpdates map[int][]stream.ProcessedUpdate[stream.QuoteResult] // not actually a quote, but to fit the data 27 | } 28 | 29 | func (br benchmarkResult) PrintSummary() { 30 | fmt.Println("Trader API vs. Jupiter API Benchmark") 31 | fmt.Println() 32 | 33 | fmt.Println("Swaps placed: ", len(br.swaps)) 34 | for _, swap := range br.swaps { 35 | fmt.Printf("%v: %v\n", swap.Timestamp.Format(time.StampMilli), swap.Signature) 36 | } 37 | fmt.Println() 38 | 39 | fmt.Println("Jupiter: ", len(br.jupiterRawUpdates), " samples") 40 | if len(br.jupiterRawUpdates) > 0 { 41 | startTime := br.firstJupiter().Start 42 | endTime := br.lastJupiter().Start 43 | fmt.Printf("Start time: %v\n", startTime.Format(time.StampMilli)) 44 | fmt.Printf("End time: %v\n", endTime.Format(time.StampMilli)) 45 | fmt.Printf("Slot range: %v => %v\n", br.firstJupiter().Data.ContextSlot, br.lastJupiter().Data.ContextSlot) 46 | fmt.Printf("Price change: %v => %v \n", br.firstJupiter().Data.Price(br.mint), br.lastJupiter().Data.Price(br.mint)) 47 | fmt.Printf("Distinct prices: %v\n", br.distinctJupiter()) 48 | } 49 | fmt.Println() 50 | 51 | fmt.Println("Trader WS: ", len(br.tradeWSRawUpdates), " samples") 52 | if len(br.tradeWSRawUpdates) > 0 { 53 | startTime := br.firstWS().Timestamp 54 | endTime := br.lastWS().Timestamp 55 | fmt.Printf("Start time: %v\n", startTime.Format(time.StampMilli)) 56 | fmt.Printf("End time: %v\n", endTime.Format(time.StampMilli)) 57 | fmt.Printf("Slot range: %v => %v\n", br.firstWS().Data.Slot, br.lastWS().Data.Slot) 58 | fmt.Printf("Buy change: %v => %v\n", br.firstWS().Data.Price.Buy, br.lastWS().Data.Price.Buy) 59 | fmt.Printf("Sell change: %v => %v\n", br.firstWS().Data.Price.Sell, br.lastWS().Data.Price.Sell) 60 | fmt.Printf("Distinct buy prices: %v\n", br.distinctWSBuy()) 61 | fmt.Printf("Distinct sell prices: %v\n", br.distinctWSSell()) 62 | } 63 | fmt.Println() 64 | 65 | fmt.Println("Trader HTTP: ", len(br.tradeHTTPRawUpdates), " samples") 66 | if len(br.tradeHTTPRawUpdates) > 0 { 67 | startTime := br.firstHTTP().Start 68 | endTime := br.lastHTTP().Start 69 | fmt.Printf("Start time: %v\n", startTime.Format(time.StampMilli)) 70 | fmt.Printf("End time: %v\n", endTime.Format(time.StampMilli)) 71 | fmt.Printf("Buy change: %v => %v\n", br.firstHTTP().Data.TokenPrices[0].Buy, br.lastHTTP().Data.TokenPrices[0].Buy) 72 | fmt.Printf("Sell change: %v => %v\n", br.firstHTTP().Data.TokenPrices[0].Sell, br.lastHTTP().Data.TokenPrices[0].Sell) 73 | fmt.Printf("Distinct buy prices: %v\n", br.distinctHTTPBuy()) 74 | fmt.Printf("Distinct sell prices: %v\n", br.distinctHTTPSell()) 75 | } 76 | fmt.Println() 77 | } 78 | 79 | func (br benchmarkResult) PrintRaw() { 80 | fmt.Println("jupiter API") 81 | printProcessedUpdate(br.jupiterProcessedUpdates) 82 | fmt.Println() 83 | 84 | fmt.Println("traderWS") 85 | printProcessedUpdate(br.tradeWSProcessedUpdates) 86 | fmt.Println() 87 | 88 | fmt.Println("traderHTTP") 89 | printProcessedUpdate(br.tradeHTTPProcessedUpdates) 90 | } 91 | 92 | func (br benchmarkResult) WriteCSV(fileName string) error { 93 | f, err := os.Create(fileName) 94 | if err != nil { 95 | return err 96 | } 97 | defer func(f *os.File) { 98 | _ = f.Close() 99 | }(f) 100 | 101 | w := csv.NewWriter(f) 102 | defer w.Flush() 103 | 104 | err = w.Write([]string{ 105 | "timestamp", 106 | "source", 107 | "slot", 108 | "processingTime", 109 | "buy", 110 | "sell", 111 | }) 112 | if err != nil { 113 | return err 114 | } 115 | 116 | allUpdates := br.allUpdates() 117 | 118 | for _, update := range allUpdates { 119 | err = w.Write([]string{ 120 | update.Timestamp.Format(time.RFC3339Nano), 121 | update.Data.Source, 122 | strconv.Itoa(update.Slot), 123 | update.Data.Elapsed.String(), 124 | strconv.FormatFloat(update.Data.BuyPrice, 'f', -1, 64), 125 | strconv.FormatFloat(update.Data.SellPrice, 'f', -1, 64), 126 | }) 127 | if err != nil { 128 | return err 129 | } 130 | } 131 | 132 | return nil 133 | } 134 | 135 | func (br benchmarkResult) firstJupiter() stream.DurationUpdate[*stream.JupiterPriceResponse] { 136 | return br.jupiterRawUpdates[0].Data 137 | } 138 | 139 | func (br benchmarkResult) lastJupiter() stream.DurationUpdate[*stream.JupiterPriceResponse] { 140 | return br.jupiterRawUpdates[len(br.jupiterRawUpdates)-1].Data 141 | } 142 | 143 | func (br benchmarkResult) distinctJupiter() int { 144 | return distinctPrices(br.jupiterRawUpdates, func(v stream.RawUpdate[stream.DurationUpdate[*stream.JupiterPriceResponse]]) float64 { 145 | return v.Data.Data.Price(br.mint) 146 | }) 147 | } 148 | 149 | func (br benchmarkResult) firstWS() stream.RawUpdate[*pb.GetPricesStreamResponse] { 150 | return br.tradeWSRawUpdates[0] 151 | } 152 | 153 | func (br benchmarkResult) lastWS() stream.RawUpdate[*pb.GetPricesStreamResponse] { 154 | return br.tradeWSRawUpdates[len(br.tradeWSRawUpdates)-1] 155 | } 156 | 157 | func (br benchmarkResult) distinctWSBuy() int { 158 | return distinctPrices(br.tradeWSRawUpdates, func(v stream.RawUpdate[*pb.GetPricesStreamResponse]) float64 { 159 | return v.Data.Price.Buy 160 | }) 161 | } 162 | 163 | func (br benchmarkResult) distinctWSSell() int { 164 | return distinctPrices(br.tradeWSRawUpdates, func(v stream.RawUpdate[*pb.GetPricesStreamResponse]) float64 { 165 | return v.Data.Price.Sell 166 | }) 167 | } 168 | 169 | func (br benchmarkResult) firstHTTP() stream.DurationUpdate[*pb.GetPriceResponse] { 170 | return br.tradeHTTPRawUpdates[0].Data 171 | } 172 | 173 | func (br benchmarkResult) lastHTTP() stream.DurationUpdate[*pb.GetPriceResponse] { 174 | return br.tradeHTTPRawUpdates[len(br.tradeHTTPRawUpdates)-1].Data 175 | } 176 | 177 | func (br benchmarkResult) distinctHTTPBuy() int { 178 | return distinctPrices(br.tradeHTTPRawUpdates, func(v stream.RawUpdate[stream.DurationUpdate[*pb.GetPriceResponse]]) float64 { 179 | return v.Data.Data.TokenPrices[0].Buy 180 | }) 181 | } 182 | 183 | func (br benchmarkResult) distinctHTTPSell() int { 184 | return distinctPrices(br.tradeHTTPRawUpdates, func(v stream.RawUpdate[stream.DurationUpdate[*pb.GetPriceResponse]]) float64 { 185 | return v.Data.Data.TokenPrices[0].Sell 186 | }) 187 | } 188 | 189 | func (br benchmarkResult) allUpdates() []stream.ProcessedUpdate[stream.QuoteResult] { 190 | allEvents := make([]stream.ProcessedUpdate[stream.QuoteResult], 0) 191 | 192 | for _, updates := range br.jupiterProcessedUpdates { 193 | for _, update := range updates { 194 | allEvents = append(allEvents, update) 195 | } 196 | } 197 | for _, updates := range br.tradeWSProcessedUpdates { 198 | for _, update := range updates { 199 | allEvents = append(allEvents, update) 200 | } 201 | } 202 | for _, updates := range br.tradeHTTPProcessedUpdates { 203 | for _, update := range updates { 204 | allEvents = append(allEvents, update) 205 | } 206 | } 207 | for _, updates := range br.swapProcessedUpdates { 208 | for _, update := range updates { 209 | allEvents = append(allEvents, update) 210 | } 211 | } 212 | 213 | sort.Slice(allEvents, func(i, j int) bool { 214 | return allEvents[i].Timestamp.Before(allEvents[j].Timestamp) 215 | }) 216 | 217 | return allEvents 218 | } 219 | 220 | func distinctPrices[T any](s []T, getter func(T) float64) int { 221 | prices := make(map[float64]bool) 222 | 223 | count := 0 224 | for _, v := range s { 225 | price := getter(v) 226 | _, ok := prices[price] 227 | if ok { 228 | continue 229 | } 230 | 231 | prices[price] = true 232 | count++ 233 | } 234 | 235 | return count 236 | } 237 | 238 | func printProcessedUpdate(pu map[int][]stream.ProcessedUpdate[stream.QuoteResult]) { 239 | slots := output.SortRange(pu) 240 | for _, slot := range slots { 241 | for _, update := range pu[slot] { 242 | printLine(update) 243 | } 244 | } 245 | } 246 | 247 | func printLine(update stream.ProcessedUpdate[stream.QuoteResult]) { 248 | fmt.Printf("[%v] %v [%vms]: B: %v | S: %v\n", update.Slot, update.Timestamp, update.Data.Elapsed.Milliseconds(), update.Data.BuyPrice, update.Data.SellPrice) 249 | } 250 | -------------------------------------------------------------------------------- /benchmark/test/atulsriv@160.202.128.145: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloXroute-Labs/solana-trader-client-go/f9734f2ff3be3347cf073324275d2b7479ba958a/benchmark/test/atulsriv@160.202.128.145 -------------------------------------------------------------------------------- /benchmark/test/helloworld: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloXroute-Labs/solana-trader-client-go/f9734f2ff3be3347cf073324275d2b7479ba958a/benchmark/test/helloworld -------------------------------------------------------------------------------- /benchmark/traderapi/README.md: -------------------------------------------------------------------------------- 1 | # benchmark/traderapi 2 | 3 | Compares Solana Trader API orderbook stream to a direct connection with Solana. Identifies updates with the same slot number, 4 | and indicates how fast updates on each stream were relative to each other. Note that all raw data is collected 5 | immediately, and all processing and deserializing happens at the end to avoid noise from deserialization overhead. 6 | Returns some unused data about messages that were seen only one connection vs. the other for future debugging of 7 | reliability. 8 | 9 | ## Usage 10 | 11 | Go: 12 | ``` 13 | $ AUTH_HEADER=... go run ./benchmark/traderapi --run-time 10s --output result.csv 14 | ``` 15 | 16 | Docker: 17 | ``` 18 | $ docker run -e AUTH_HEADER=... --name cmp --rm bloxroute/solana-trader-client-go:bm-traderapi-v1.0.0 19 | ``` 20 | 21 | ## Result 22 | 23 | ``` 24 | 2022-09-13T14:23:55.672-0500 DEBUG arrival/traderws.go:53 connection established {"source": "traderapi", "address": "ws://54.163.206.248:1809/ws", "market": "8BnEgHoWFysVcuFFX7QztDmzuH8r5ZFvyP3sYwn1XTh6"} 25 | 2022-09-13T14:23:55.873-0500 DEBUG arrival/solanaws.go:83 connection established {"source": "solanaws", "address": "ws://185.209.178.55", "market": "8BnEgHoWFysVcuFFX7QztDmzuH8r5ZFvyP3sYwn1XTh6"} 26 | 2022-09-13T14:23:55.874-0500 DEBUG arrival/traderws.go:81 subscription created {"source": "traderapi", "address": "ws://54.163.206.248:1809/ws", "market": "8BnEgHoWFysVcuFFX7QztDmzuH8r5ZFvyP3sYwn1XTh6"} 27 | 2022-09-13T14:23:55.878-0500 DEBUG arrival/solanaws.go:110 subscription created {"source": "solanaws", "address": "ws://185.209.178.55", "market": "8BnEgHoWFysVcuFFX7QztDmzuH8r5ZFvyP3sYwn1XTh6"} 28 | 2022-09-13T14:24:05.875-0500 INFO traderapi/main.go:120 waited 10s out of 10s... 29 | 2022-09-13T14:24:05.876-0500 DEBUG arrival/traderws.go:92 closing connection {"source": "traderapi", "address": "ws://54.163.206.248:1809/ws", "market": "8BnEgHoWFysVcuFFX7QztDmzuH8r5ZFvyP3sYwn1XTh6", "err": "read tcp 192.168.1.214:60182->54.163.206.248:1809: use of closed network connection"} 30 | 2022-09-13T14:24:05.876-0500 INFO traderapi/main.go:125 finished collecting data points {"tradercount": 44, "solanacount": 26} 31 | 2022-09-13T14:24:05.886-0500 DEBUG arrival/solanaws.go:123 closing Asks subscription {"source": "solanaws", "address": "ws://185.209.178.55", "market": "8BnEgHoWFysVcuFFX7QztDmzuH8r5ZFvyP3sYwn1XTh6", "err": "read tcp 192.168.1.214:60184->185.209.178.55:80: use of closed network connection"} 32 | 2022-09-13T14:24:05.886-0500 DEBUG arrival/solanaws.go:142 closing Bids subscription {"source": "solanaws", "address": "ws://185.209.178.55", "market": "8BnEgHoWFysVcuFFX7QztDmzuH8r5ZFvyP3sYwn1XTh6", "err": "read tcp 192.168.1.214:60184->185.209.178.55:80: use of closed network connection"} 33 | 2022-09-13T14:24:05.936-0500 DEBUG traderapi/main.go:132 processed trader API results {"range": "150523364-150523376", "count": 13, "duplicaterange": "150523366-150523372", "duplicatecount": 2} 34 | 2022-09-13T14:24:06.033-0500 DEBUG traderapi/main.go:139 processed solana results {"range": "150523364-150523376", "count": 12, "duplicaterange": "150523365-150523377", "duplicatecount": 6} 35 | 2022-09-13T14:24:06.033-0500 DEBUG traderapi/main.go:142 finished processing data points {"startSlot": 150523364, "endSlot": 150523376, "count": 13} 36 | 2022-09-13T14:24:06.033-0500 INFO traderapi/main.go:149 completed merging: outputting data... 37 | Run time: 10s 38 | Endpoints: 39 | ws://54.163.206.248:1809/ws [traderapi] 40 | ws://185.209.178.55 [solana] 41 | 42 | Total updates: 43 43 | 44 | Faster counts: 45 | 19 ws://54.163.206.248:1809/ws 46 | 0 ws://185.209.178.55 47 | Average difference( ms): 48 | 352ms ws://54.163.206.248:1809/ws 49 | 0ms ws://185.209.178.55 50 | Unmatched updates: 51 | (updates from each stream without a corresponding result on the other) 52 | 20 ws://54.163.206.248:1809/ws 53 | 0 ws://185.209.178.55 54 | ``` 55 | -------------------------------------------------------------------------------- /benchmark/traderapi/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/csv" 5 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/logger" 6 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/output" 7 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/stream" 8 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/utils" 9 | "github.com/pkg/errors" 10 | "github.com/urfave/cli/v2" 11 | "os" 12 | "time" 13 | ) 14 | 15 | import ( 16 | "context" 17 | ) 18 | 19 | const updateInterval = 10 * time.Second 20 | 21 | func main() { 22 | app := &cli.App{ 23 | Name: "benchmark-traderapi", 24 | Usage: "Compares Solana Trader API orderbook stream with a direct connection to a Solana node to determine the efficacy of using the Solana Trader API stream", 25 | Flags: []cli.Flag{ 26 | utils.SolanaHTTPRPCEndpointFlag, 27 | utils.SolanaWSRPCEndpointFlag, 28 | utils.APIWSEndpoint, 29 | MarketAddrFlag, 30 | DurationFlag, 31 | utils.OutputFileFlag, 32 | RemoveUnmatchedFlag, 33 | RemoveDuplicatesFlag, 34 | }, 35 | Action: run, 36 | } 37 | 38 | err := app.Run(os.Args) 39 | defer func() { 40 | if logger.Log() != nil { 41 | _ = logger.Log().Sync() 42 | } 43 | }() 44 | 45 | if err != nil { 46 | panic(err) 47 | } 48 | } 49 | 50 | func run(c *cli.Context) error { 51 | ctx, cancel := context.WithCancel(context.Background()) 52 | defer cancel() 53 | 54 | marketAddr := c.String(MarketAddrFlag.Name) 55 | traderAPIEndpoint := c.String(utils.APIWSEndpoint.Name) 56 | solanaRPCEndpoint := c.String(utils.SolanaHTTPRPCEndpointFlag.Name) 57 | solanaWSEndpoint := c.String(utils.SolanaWSRPCEndpointFlag.Name) 58 | 59 | authHeader, ok := os.LookupEnv("AUTH_HEADER") 60 | if !ok { 61 | return errors.New("AUTH_HEADER not set in environment") 62 | } 63 | 64 | connectCtx, connectCancel := context.WithTimeout(ctx, 5*time.Second) 65 | defer connectCancel() 66 | 67 | traderOS, err := stream.NewAPIOrderbookStream(traderAPIEndpoint, marketAddr, authHeader) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | solanaOS, err := stream.NewSolanaOrderbookStream(connectCtx, solanaRPCEndpoint, solanaWSEndpoint, marketAddr) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | startTime := time.Now() 78 | duration := c.Duration(DurationFlag.Name) 79 | runCtx, runCancel := context.WithTimeout(ctx, duration) 80 | defer runCancel() 81 | 82 | var ( 83 | tradeUpdates []stream.RawUpdate[[]byte] 84 | solanaUpdates []stream.RawUpdate[stream.SolanaRawUpdate] 85 | ) 86 | errCh := make(chan error, 2) 87 | 88 | go func() { 89 | var err error 90 | 91 | tradeUpdates, err = traderOS.Run(runCtx) 92 | if err != nil { 93 | errCh <- errors.Wrap(err, "could not collect results from trader API") 94 | return 95 | } 96 | 97 | errCh <- nil 98 | }() 99 | 100 | go func() { 101 | var err error 102 | 103 | solanaUpdates, err = solanaOS.Run(runCtx) 104 | if err != nil { 105 | errCh <- errors.Wrap(err, "could not collect results from Solana") 106 | return 107 | } 108 | 109 | errCh <- nil 110 | }() 111 | 112 | completionCount := 0 113 | ticker := time.NewTicker(updateInterval) 114 | 115 | for completionCount < 2 { 116 | select { 117 | case runErr := <-errCh: 118 | completionCount++ 119 | if runErr != nil { 120 | logger.Log().Errorw("fatal error during runtime: exiting", "err", err) 121 | return runErr 122 | } 123 | case <-ticker.C: 124 | elapsedTime := time.Now().Sub(startTime).Round(time.Second) 125 | logger.Log().Infof("waited %v out of %v...", elapsedTime, duration) 126 | } 127 | 128 | } 129 | 130 | logger.Log().Infow("finished collecting data points", "tradercount", len(tradeUpdates), "solanacount", len(solanaUpdates)) 131 | 132 | removeDuplicates := c.Bool(RemoveDuplicatesFlag.Name) 133 | traderResults, traderDuplicates, err := traderOS.Process(tradeUpdates, removeDuplicates) 134 | if err != nil { 135 | return errors.Wrap(err, "could not process trader API updates") 136 | } 137 | logger.Log().Debugw("processed trader API results", "range", output.FormatSortRange(traderResults), "count", len(traderResults), "duplicaterange", output.FormatSortRange(traderDuplicates), "duplicatecount", len(traderDuplicates)) 138 | 139 | solanaResults, solanaDuplicates, err := solanaOS.Process(solanaUpdates, removeDuplicates) 140 | if err != nil { 141 | return errors.Wrap(err, "could not process solana results") 142 | } 143 | 144 | logger.Log().Debugw("processed solana results", "range", output.FormatSortRange(solanaResults), "count", len(solanaResults), "duplicaterange", output.FormatSortRange(solanaDuplicates), "duplicatecount", len(solanaDuplicates)) 145 | 146 | slots := SlotRange(traderResults, solanaResults) 147 | logger.Log().Debugw("finished processing data points", "startSlot", slots[0], "endSlot", slots[len(slots)-1], "count", len(slots)) 148 | 149 | datapoints, _, _, err := Merge(slots, traderResults, solanaResults) 150 | if err != nil { 151 | return err 152 | } 153 | 154 | logger.Log().Infow("completed merging: outputting data...") 155 | 156 | // dump results to stdout 157 | removeUnmatched := c.Bool(RemoveUnmatchedFlag.Name) 158 | PrintSummary(duration, traderAPIEndpoint, solanaWSEndpoint, datapoints) 159 | 160 | // write results to csv 161 | outputFile := c.String(utils.OutputFileFlag.Name) 162 | header := []string{"slot", "diff", "seq", "trader-api-time", "solana-side", "solana-time"} 163 | err = csv.Write(outputFile, header, datapoints, func(line []string) bool { 164 | if removeUnmatched { 165 | for _, col := range line { 166 | return col == "n/a" 167 | } 168 | } 169 | return false 170 | }) 171 | if err != nil { 172 | return err 173 | } 174 | 175 | return nil 176 | } 177 | 178 | var ( 179 | MarketAddrFlag = &cli.StringFlag{ 180 | Name: "market", 181 | Usage: "market to run analysis for", 182 | Value: "8BnEgHoWFysVcuFFX7QztDmzuH8r5ZFvyP3sYwn1XTh6", // SOL/USDC market 183 | } 184 | DurationFlag = &cli.DurationFlag{ 185 | Name: "run-time", 186 | Usage: "amount of time to run script for (seconds)", 187 | Required: true, 188 | } 189 | RemoveUnmatchedFlag = &cli.BoolFlag{ 190 | Name: "remove-unmatched", 191 | Usage: "skip events without a match from other source", 192 | } 193 | RemoveDuplicatesFlag = &cli.BoolFlag{ 194 | Name: "remove-duplicates", 195 | Usage: "skip events that are identical to the previous", 196 | } 197 | ) 198 | -------------------------------------------------------------------------------- /benchmark/traderapi/output.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/output" 6 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/stream" 7 | gserum "github.com/gagliardetto/solana-go/programs/serum" 8 | "strconv" 9 | "time" 10 | ) 11 | 12 | const tsFormat = "15:04:05.999999" 13 | 14 | type Datapoint struct { 15 | Slot int 16 | TraderTimestamps []time.Time 17 | SolanaAsk time.Time 18 | SolanaBid time.Time 19 | } 20 | 21 | // OrderedTimestamps returns timestamps pairs of [serumTS, solanaTS] within the slot. For example, the first TS from Solana Trader API is matched with the sooner of SolanaAsk and SolanaBid, the second with the later, and the rest with zero values. 22 | func (d Datapoint) OrderedTimestamps() ([][]time.Time, gserum.Side) { 23 | var firstSide gserum.Side = gserum.SideAsk 24 | 25 | tsList := [][]time.Time{ 26 | {time.Time{}, time.Time{}}, 27 | {time.Time{}, time.Time{}}, 28 | } 29 | 30 | setBidFirst := func() { 31 | tsList[1][1] = d.SolanaAsk 32 | tsList[0][1] = d.SolanaBid 33 | firstSide = gserum.SideBid 34 | } 35 | 36 | setAskFirst := func() { 37 | tsList[0][1] = d.SolanaAsk 38 | tsList[1][1] = d.SolanaBid 39 | } 40 | 41 | // zeros should come at end 42 | if d.SolanaAsk.IsZero() && !d.SolanaBid.IsZero() { 43 | setBidFirst() 44 | } else if !d.SolanaAsk.IsZero() && d.SolanaBid.IsZero() { 45 | setAskFirst() 46 | } else if d.SolanaBid.Before(d.SolanaAsk) { 47 | setBidFirst() 48 | } else { 49 | setAskFirst() 50 | } 51 | 52 | for i, ts := range d.TraderTimestamps { 53 | if i < 2 { 54 | tsList[i][0] = ts 55 | } else { 56 | tsList = append(tsList, []time.Time{ts, {}}) 57 | } 58 | } 59 | 60 | return tsList, firstSide 61 | } 62 | 63 | func (d Datapoint) FormatPrint() []string { 64 | ots, firstSide := d.OrderedTimestamps() 65 | sides := []string{"ask", "bid"} 66 | if firstSide == gserum.SideBid { 67 | sides = []string{"bid", "ask"} 68 | } 69 | 70 | lines := make([]string, 0) 71 | for i, timestamps := range ots { 72 | side := "n/a" 73 | if i < len(sides) { 74 | side = sides[i] 75 | } 76 | 77 | line := fmt.Sprintf("slot %v (%v): solana trader %v, solana %v, diff %v", d.Slot, side, formatTS(timestamps[0]), formatTS(timestamps[1]), formatDiff(timestamps[0], timestamps[1])) 78 | lines = append(lines, line) 79 | } 80 | return lines 81 | } 82 | 83 | func (d Datapoint) FormatCSV() [][]string { 84 | ots, firstSide := d.OrderedTimestamps() 85 | sides := []string{"ask", "bid"} 86 | if firstSide == gserum.SideBid { 87 | sides = []string{"bid", "ask"} 88 | } 89 | 90 | lines := make([][]string, 0) 91 | for i, timestamps := range ots { 92 | side := "n/a" 93 | if i < len(sides) { 94 | side = sides[i] 95 | } 96 | 97 | line := []string{strconv.Itoa(d.Slot), formatDiff(timestamps[0], timestamps[1]), strconv.Itoa(i + 1), formatTS(timestamps[0]), side, formatTS(timestamps[1])} 98 | lines = append(lines, line) 99 | } 100 | 101 | return lines 102 | } 103 | 104 | // SlotRange enumerate the superset range of slots used in Trader API and Solana updates 105 | func SlotRange(traderResults map[int][]stream.ProcessedUpdate[stream.TraderAPIUpdate], solanaResults map[int][]stream.ProcessedUpdate[stream.SolanaUpdate]) []int { 106 | traderSlots := output.SortRange(traderResults) 107 | solanaSlots := output.SortRange(solanaResults) 108 | 109 | slots := make([]int, 0, len(traderResults)) 110 | solanaIndex := 0 111 | for i := 0; i < len(traderSlots); i++ { 112 | traderCandidate := traderSlots[i] 113 | 114 | for j := solanaIndex; j < len(solanaSlots); j++ { 115 | solanaCandidate := solanaSlots[j] 116 | if solanaCandidate < traderCandidate { 117 | slots = append(slots, solanaCandidate) 118 | solanaIndex++ 119 | } else if solanaCandidate == traderCandidate { 120 | solanaIndex++ 121 | } else { 122 | break 123 | } 124 | } 125 | 126 | slots = append(slots, traderCandidate) 127 | } 128 | 129 | for j := solanaIndex; j < len(solanaSlots); j++ { 130 | slots = append(slots, solanaSlots[j]) 131 | } 132 | 133 | return slots 134 | } 135 | 136 | // Merge combines Trader API and Solana updates over the specified slots, indicating the difference in slot times and any updates that were not included in the other. 137 | func Merge(slots []int, traderResults map[int][]stream.ProcessedUpdate[stream.TraderAPIUpdate], solanaResults map[int][]stream.ProcessedUpdate[stream.SolanaUpdate]) ([]Datapoint, map[int][]stream.ProcessedUpdate[stream.TraderAPIUpdate], map[int][]stream.ProcessedUpdate[stream.SolanaUpdate], error) { 138 | datapoints := make([]Datapoint, 0) 139 | leftoverTrader := make(map[int][]stream.ProcessedUpdate[stream.TraderAPIUpdate]) 140 | leftoverSolana := make(map[int][]stream.ProcessedUpdate[stream.SolanaUpdate]) 141 | 142 | for _, slot := range slots { 143 | traderData, traderOK := traderResults[slot] 144 | solanaData, solanaOK := solanaResults[slot] 145 | 146 | if !traderOK && !solanaOK { 147 | return nil, nil, nil, fmt.Errorf("(slot %v) improper slot set provided", slot) 148 | } 149 | 150 | if !traderOK { 151 | leftoverSolana[slot] = solanaData 152 | } 153 | 154 | if !solanaOK { 155 | leftoverTrader[slot] = traderData 156 | } 157 | 158 | dp := Datapoint{ 159 | Slot: slot, 160 | TraderTimestamps: make([]time.Time, 0, len(traderData)), 161 | } 162 | if len(solanaData) > 2 { 163 | return nil, nil, nil, fmt.Errorf("(slot %v) solana data unexpectedly had more than 2 entries: %v", slot, solanaData) 164 | } 165 | for _, su := range solanaData { 166 | if su.Data.Side == gserum.SideAsk { 167 | dp.SolanaAsk = su.Timestamp 168 | } else if su.Data.Side == gserum.SideBid { 169 | dp.SolanaBid = su.Timestamp 170 | } else { 171 | return nil, nil, nil, fmt.Errorf("(slot %v) solana data unknown side: %v", slot, solanaData) 172 | } 173 | } 174 | 175 | for _, su := range traderData { 176 | dp.TraderTimestamps = append(dp.TraderTimestamps, su.Timestamp) 177 | } 178 | 179 | datapoints = append(datapoints, dp) 180 | } 181 | 182 | return datapoints, leftoverTrader, leftoverSolana, nil 183 | } 184 | 185 | func PrintSummary(runtime time.Duration, traderEndpoint string, solanaEndpoint string, datapoints []Datapoint) { 186 | traderFaster := 0 187 | solanaFaster := 0 188 | totalTraderLead := 0 189 | totalSolanaLead := 0 190 | traderUnmatched := 0 191 | solanaUnmatched := 0 192 | total := 0 193 | 194 | for _, dp := range datapoints { 195 | timestamps, _ := dp.OrderedTimestamps() 196 | 197 | for _, matchedTs := range timestamps { 198 | total++ 199 | 200 | if len(matchedTs) < 2 { 201 | traderUnmatched++ 202 | continue 203 | } 204 | 205 | traderTs := matchedTs[0] 206 | solanaTs := matchedTs[1] 207 | 208 | // sometimes only 1 of asks and bids are matched 209 | if traderTs.IsZero() && solanaTs.IsZero() { 210 | continue 211 | } 212 | 213 | // skip cases where one or other timestamp is zero 214 | if traderTs.IsZero() { 215 | solanaUnmatched++ 216 | continue 217 | } 218 | 219 | if solanaTs.IsZero() { 220 | traderUnmatched++ 221 | continue 222 | } 223 | 224 | if traderTs.Before(solanaTs) { 225 | traderFaster++ 226 | totalTraderLead += int(solanaTs.Sub(traderTs).Milliseconds()) 227 | continue 228 | } 229 | 230 | if solanaTs.Before(traderTs) { 231 | solanaFaster++ 232 | totalSolanaLead += int(traderTs.Sub(solanaTs).Milliseconds()) 233 | continue 234 | } 235 | } 236 | } 237 | 238 | averageTraderLead := 0 239 | if traderFaster > 0 { 240 | averageTraderLead = totalTraderLead / traderFaster 241 | } 242 | averageSolanaLead := 0 243 | if solanaFaster > 0 { 244 | averageSolanaLead = totalSolanaLead / solanaFaster 245 | } 246 | 247 | fmt.Println("Run time: ", runtime) 248 | fmt.Println("Endpoints:") 249 | fmt.Println(" ", traderEndpoint, " [traderapi]") 250 | fmt.Println(" ", solanaEndpoint, " [solana]") 251 | fmt.Println() 252 | 253 | fmt.Println("Total updates: ", total) 254 | fmt.Println() 255 | 256 | fmt.Println("Faster counts: ") 257 | fmt.Println(fmt.Sprintf(" %-6d %v", traderFaster, traderEndpoint)) 258 | fmt.Println(fmt.Sprintf(" %-6d %v", solanaFaster, solanaEndpoint)) 259 | 260 | fmt.Println("Average difference( ms): ") 261 | fmt.Println(fmt.Sprintf(" %-6s %v", fmt.Sprintf("%vms", averageTraderLead), traderEndpoint)) 262 | fmt.Println(fmt.Sprintf(" %-6s %v", fmt.Sprintf("%vms", averageSolanaLead), solanaEndpoint)) 263 | 264 | fmt.Println("Unmatched updates: ") 265 | fmt.Println("(updates from each stream without a corresponding result on the other)") 266 | fmt.Println(fmt.Sprintf(" %-6d %v", traderUnmatched, traderEndpoint)) 267 | fmt.Println(fmt.Sprintf(" %-6d %v", solanaUnmatched, solanaEndpoint)) 268 | } 269 | 270 | func formatTS(ts time.Time) string { 271 | if ts.IsZero() { 272 | return "n/a" 273 | } else { 274 | return ts.Format(tsFormat) 275 | } 276 | } 277 | 278 | func formatDiff(traderTS time.Time, solanaTS time.Time) string { 279 | if traderTS.IsZero() || solanaTS.IsZero() { 280 | return "n/a" 281 | } else { 282 | return traderTS.Sub(solanaTS).String() 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /benchmark/txcompare/README.md: -------------------------------------------------------------------------------- 1 | # benchmark/txcompare 2 | 3 | Simultaneously submits transactions to each of the provided endpoints, then determines which transactions were accepted 4 | into blocks first and which were lost. Judges speed based on block number and position within block. Note that there is 5 | no correction for RPC endpoint distance, so you may find it informative to ping each endpoint beforehand to determine 6 | the fairness of this analysis 7 | 8 | ## Usage 9 | 10 | Go: 11 | ``` 12 | $ PRIVATE_KEY=... go run ./benchmark/txcompare --iterations 4 --output result.csv --endpoint [rpc-endpoint-1] --endpoint [rpc-endpoint-2] --query-endpoint [rpc-endpoint-with-tx-indexing] 13 | ``` 14 | 15 | You can specify as many `--endpoint` arguments as you would like. `PRIVATE_KEY` must be provided to sign transactions. 16 | By default, this is a simple transaction with a [Memo Program](https://spl.solana.com/memo) instruction. 17 | 18 | For `--query-endpoint` you need an RPC node with transaction 19 | history enabled, as this script calls the `getBlock` and `getTransaction` endpoints. You can test this yourself: 20 | 21 | ``` 22 | $ curl https://api.mainnet-beta.solana.com -X POST -H "Content-Type: application/json" -d ' 23 | { 24 | "jsonrpc": "2.0", 25 | "id": 1, 26 | "method": "getTransaction", 27 | "params": [ 28 | "daMbnUbiMDFFGCwsLmLTrDRn2Jpx5YnFdnoxynR4ob2jax7M9TiAtnCpBTXbYdCfBVTg8FzpJU3hwcBJgDs8heB", 29 | "json" 30 | ] 31 | } 32 | ' 33 | {"jsonrpc":"2.0","result":{...},"id":1} 34 | ``` 35 | 36 | A node without this indexing would look like this: 37 | 38 | ``` 39 | $ curl [endpoint] -X POST -H "Content-Type: application/json" -d '...' 40 | {"jsonrpc":"2.0","error":{"code":-32011,"message":"Transaction history is not available from this node"},"id":1} 41 | ``` 42 | 43 | ## Result 44 | 45 | Logs should look this: 46 | 47 | ``` 48 | 2022-07-28T11:29:28.154-0500 DEBUG transaction/submit.go:94 submitted transaction {"signature": "2UiE9gyTdYH4nFWP1ir92WXqqkX7WSRSAfN6NyCYgVfR29jmsqs1K1ZkK8KAMwWYp4mMR5tj5sfGQN9UgQxiQkKe"} 49 | 2022-07-28T11:29:28.154-0500 DEBUG transaction/submit.go:94 submitted transaction {"signature": "5SPCcCYgdsiXSdQeTmibc265YeiiQeeGe6WbdGPDNjo1gjVEf6D9DSmBesV1py6ipPoRtMMUUeah7pK38q9e7w8Y"} 50 | 2022-07-28T11:29:28.154-0500 DEBUG transaction/submit.go:65 submitted iteration of transactions {"iteration": 0, "count": 2} 51 | 2022-07-28T11:29:30.251-0500 DEBUG transaction/submit.go:94 submitted transaction {"signature": "2Y4hVUejyNM9mymrseuFrvmmj1RFwpV59uxRXgn1LCB9VYnVjn2sFoGnjSmKA4vRxBT13ZkNe6h6SHhGd2jfgUnQ"} 52 | 2022-07-28T11:29:30.252-0500 DEBUG transaction/submit.go:94 submitted transaction {"signature": "2BLCkuL2U6iKTpGCxNZUCn8zyBYx8254kJibu39mtve43UstBkAQYZXYRjtiwMVybihDaKwq4MJUUy3K18YeqEUi"} 53 | 2022-07-28T11:29:30.252-0500 DEBUG transaction/submit.go:65 submitted iteration of transactions {"iteration": 1, "count": 2} 54 | 2022-07-28T11:29:32.346-0500 DEBUG transaction/submit.go:94 submitted transaction {"signature": "5eyRvD6KtQ7qLs28aafUS3DXZzhnpLEhzByQVJscYFd6QuUFakvEnKLUVmUVJfHD5aJvCTbjpqSaM2rYQjninje7"} 55 | 2022-07-28T11:29:32.347-0500 DEBUG transaction/submit.go:94 submitted transaction {"signature": "3PJdb5STnCnJ4aBBYgxhq1RaayTaF8P1Dvub8GnDPYC8kd7Dui94VBKqDuAznwy4LJLKBgwuWg3S5vNuPsjv9yHt"} 56 | 2022-07-28T11:29:32.347-0500 DEBUG transaction/submit.go:65 submitted iteration of transactions {"iteration": 2, "count": 2} 57 | 2022-07-28T11:29:34.439-0500 DEBUG transaction/submit.go:94 submitted transaction {"signature": "5ofkDQsaEm4PrpG1v9u8t7JhDcXx2pAPtZscPrGLUfUdCDymSGNCfyrc85tAzjhM6KkSRh8kiXucP79A8HZSzMYu"} 58 | 2022-07-28T11:29:34.440-0500 DEBUG transaction/submit.go:94 submitted transaction {"signature": "2gej52a1hEF715UyqDD5Q8CSuvQ8tBhY3twNoV7jCqoJXqxLtCiayVVZAMgW8iSF2DcA2b3TMztysEXnKnigiNFq"} 59 | 2022-07-28T11:29:34.440-0500 DEBUG transaction/submit.go:65 submitted iteration of transactions {"iteration": 3, "count": 2} 60 | 2022-07-28T11:30:47.107-0500 DEBUG txcompare/main.go:76 iteration results found {"iteration": 0, "winner": "https://nd-223-967-158.p2pify.com/92b9f51421b09d9b68ce6e8cd8d50ebf"} 61 | 2022-07-28T11:30:47.107-0500 DEBUG txcompare/main.go:98 iteration transaction result {"iteration": 0, "endpoint": "https://api.mainnet-beta.solana.com", "slot": 143533719, "position": 894, "signature": "2UiE9gyTdYH4nFWP1ir92WXqqkX7WSRSAfN6NyCYgVfR29jmsqs1K1ZkK8KAMwWYp4mMR5tj5sfGQN9UgQxiQkKe"} 62 | 2022-07-28T11:30:47.107-0500 DEBUG txcompare/main.go:98 iteration transaction result {"iteration": 0, "endpoint": "https://nd-223-967-158.p2pify.com/92b9f51421b09d9b68ce6e8cd8d50ebf", "slot": 143533718, "position": 877, "signature": "5SPCcCYgdsiXSdQeTmibc265YeiiQeeGe6WbdGPDNjo1gjVEf6D9DSmBesV1py6ipPoRtMMUUeah7pK38q9e7w8Y"} 63 | 2022-07-28T11:30:47.151-0500 DEBUG txcompare/main.go:76 iteration results found {"iteration": 1, "winner": "https://api.mainnet-beta.solana.com"} 64 | 2022-07-28T11:30:47.151-0500 DEBUG txcompare/main.go:98 iteration transaction result {"iteration": 1, "endpoint": "https://api.mainnet-beta.solana.com", "slot": 143533718, "position": 1057, "signature": "2Y4hVUejyNM9mymrseuFrvmmj1RFwpV59uxRXgn1LCB9VYnVjn2sFoGnjSmKA4vRxBT13ZkNe6h6SHhGd2jfgUnQ"} 65 | 2022-07-28T11:30:47.151-0500 DEBUG txcompare/main.go:98 iteration transaction result {"iteration": 1, "endpoint": "https://nd-223-967-158.p2pify.com/92b9f51421b09d9b68ce6e8cd8d50ebf", "slot": 143533718, "position": 1354, "signature": "2BLCkuL2U6iKTpGCxNZUCn8zyBYx8254kJibu39mtve43UstBkAQYZXYRjtiwMVybihDaKwq4MJUUy3K18YeqEUi"} 66 | 2022-07-28T11:30:47.392-0500 DEBUG txcompare/main.go:76 iteration results found {"iteration": 2, "winner": "https://api.mainnet-beta.solana.com"} 67 | 2022-07-28T11:30:47.392-0500 DEBUG txcompare/main.go:98 iteration transaction result {"iteration": 2, "endpoint": "https://api.mainnet-beta.solana.com", "slot": 143533720, "position": 286, "signature": "5eyRvD6KtQ7qLs28aafUS3DXZzhnpLEhzByQVJscYFd6QuUFakvEnKLUVmUVJfHD5aJvCTbjpqSaM2rYQjninje7"} 68 | 2022-07-28T11:30:47.393-0500 DEBUG txcompare/main.go:98 iteration transaction result {"iteration": 2, "endpoint": "https://nd-223-967-158.p2pify.com/92b9f51421b09d9b68ce6e8cd8d50ebf", "slot": 143533720, "position": 569, "signature": "3PJdb5STnCnJ4aBBYgxhq1RaayTaF8P1Dvub8GnDPYC8kd7Dui94VBKqDuAznwy4LJLKBgwuWg3S5vNuPsjv9yHt"} 69 | 2022-07-28T11:30:57.717-0500 DEBUG txcompare/main.go:76 iteration results found {"iteration": 3, "winner": "https://nd-223-967-158.p2pify.com/92b9f51421b09d9b68ce6e8cd8d50ebf"} 70 | 2022-07-28T11:30:57.718-0500 DEBUG txcompare/main.go:98 iteration transaction result {"iteration": 3, "endpoint": "https://api.mainnet-beta.solana.com", "slot": 143533759, "position": 127, "signature": "5ofkDQsaEm4PrpG1v9u8t7JhDcXx2pAPtZscPrGLUfUdCDymSGNCfyrc85tAzjhM6KkSRh8kiXucP79A8HZSzMYu"} 71 | 2022-07-28T11:30:57.718-0500 DEBUG txcompare/main.go:98 iteration transaction result {"iteration": 3, "endpoint": "https://nd-223-967-158.p2pify.com/92b9f51421b09d9b68ce6e8cd8d50ebf", "slot": 143533733, "position": 2085, "signature": "2gej52a1hEF715UyqDD5Q8CSuvQ8tBhY3twNoV7jCqoJXqxLtCiayVVZAMgW8iSF2DcA2b3TMztysEXnKnigiNFq"} 72 | Iterations: 4 73 | Endpoints: 74 | https://api.mainnet-beta.solana.com 75 | https://nd-223-967-158.p2pify.com/92b9f51421b09d9b68ce6e8cd8d50ebf 76 | 77 | Win counts: 78 | 2 https://api.mainnet-beta.solana.com 79 | 2 https://nd-223-967-158.p2pify.com/92b9f51421b09d9b68ce6e8cd8d50ebf 80 | 81 | Lost transactions: 82 | 0 https://api.mainnet-beta.solana.com 83 | 0 https://nd-223-967-158.p2pify.com/92b9f51421b09d9b68ce6e8cd8d50ebf 84 | ``` 85 | 86 | A CSV file will also be generated at the `--output` location, with details of each transaction. -------------------------------------------------------------------------------- /benchmark/txcompare/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/csv" 7 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/logger" 8 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/transaction" 9 | "github.com/bloXroute-Labs/solana-trader-client-go/benchmark/internal/utils" 10 | "github.com/bloXroute-Labs/solana-trader-client-go/provider" 11 | "github.com/gagliardetto/solana-go" 12 | "github.com/urfave/cli/v2" 13 | "os" 14 | ) 15 | 16 | func main() { 17 | app := &cli.App{ 18 | Name: "benchmark-txcompare", 19 | Usage: "Compares submitting transactions to multiple Solana nodes to determine if one is consistently faster", 20 | Flags: []cli.Flag{ 21 | IterationCountFlag, 22 | SolanaHTTPEndpointsFlag, 23 | SolanaQueryEndpointsFlag, 24 | utils.OutputFileFlag, 25 | }, 26 | Action: run, 27 | } 28 | 29 | err := app.Run(os.Args) 30 | defer func() { 31 | if logger.Log() != nil { 32 | _ = logger.Log().Sync() 33 | } 34 | }() 35 | 36 | if err != nil { 37 | panic(err) 38 | } 39 | } 40 | 41 | func run(c *cli.Context) error { 42 | ctx, cancel := context.WithCancel(context.Background()) 43 | defer cancel() 44 | 45 | opts := provider.DefaultRPCOpts(provider.MainnetNYGRPC) 46 | if opts.PrivateKey == nil { 47 | return errors.New("PRIVATE_KEY environment variable must be set") 48 | } 49 | 50 | iterations := c.Int(IterationCountFlag.Name) 51 | endpoints := c.StringSlice(SolanaHTTPEndpointsFlag.Name) 52 | queryEndpoint := c.String(SolanaQueryEndpointsFlag.Name) 53 | 54 | querier := transaction.NewStatusQuerier(queryEndpoint) 55 | 56 | recentBlockHashFn := func() (solana.Hash, error) { 57 | return querier.RecentBlockHash(ctx) 58 | } 59 | submitter := transaction.NewSubmitter(endpoints, transaction.MemoBuilder(*opts.PrivateKey, recentBlockHashFn)) 60 | 61 | signatures, creationTimes, err := submitter.SubmitIterations(ctx, iterations) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | datapoints := make([]Datapoint, 0) 67 | best := make([]int, len(endpoints)) 68 | lost := make([]int, len(endpoints)) 69 | for i, iterationSignatures := range signatures { 70 | summary, statuses, err := querier.FetchBatch(ctx, iterationSignatures) 71 | if err != nil { 72 | return err 73 | } 74 | 75 | if summary.Best >= 0 { 76 | logger.Log().Debugw("iteration results found", "iteration", i, "winner", endpoints[summary.Best]) 77 | best[summary.Best]++ 78 | } else { 79 | logger.Log().Debugw("iteration no transactions confirmed", "iteration", i) 80 | } 81 | for j, status := range statuses { 82 | dp := Datapoint{ 83 | Iteration: i, 84 | CreationTime: creationTimes[i], 85 | Signature: iterationSignatures[j].String(), 86 | Endpoint: endpoints[j], 87 | Executed: status.Found, 88 | ExecutionTime: status.ExecutionTime, 89 | Slot: status.Slot, 90 | Position: status.Position, 91 | } 92 | datapoints = append(datapoints, dp) 93 | 94 | if !status.Found { 95 | lost[j]++ 96 | } 97 | 98 | logger.Log().Debugw("iteration transaction result", "iteration", i, "endpoint", dp.Endpoint, "slot", dp.Slot, "position", dp.Position, "signature", dp.Signature) 99 | } 100 | } 101 | 102 | Print(iterations, endpoints, best, lost) 103 | 104 | outputFile := c.String(utils.OutputFileFlag.Name) 105 | header := []string{"iteration", "creation-time", "signature", "endpoint", "executed", "execution-time", "slot", "position"} 106 | err = csv.Write(outputFile, header, datapoints, func(line []string) bool { 107 | return false 108 | }) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | return nil 114 | } 115 | 116 | var ( 117 | IterationCountFlag = &cli.IntFlag{ 118 | Name: "iterations", 119 | Aliases: []string{"n"}, 120 | Usage: "number of transaction pairs to submit", 121 | Required: true, 122 | } 123 | SolanaHTTPEndpointsFlag = &cli.StringSliceFlag{ 124 | Name: "endpoint", 125 | Aliases: []string{"e"}, 126 | Usage: "solana endpoints to submit transactions to (multiple allowed)", 127 | Required: true, 128 | } 129 | SolanaQueryEndpointsFlag = &cli.StringFlag{ 130 | Name: "query-endpoint", 131 | Usage: "solana endpoints to query for transaction inclusion (useful when submission endpoint doesn't index transactions)", 132 | Required: true, 133 | } 134 | ) 135 | -------------------------------------------------------------------------------- /benchmark/txcompare/output.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "time" 7 | ) 8 | 9 | const tsFormat = "15:04:05.999999" 10 | 11 | type Datapoint struct { 12 | Iteration int 13 | CreationTime time.Time 14 | Signature string 15 | Endpoint string 16 | 17 | Executed bool 18 | ExecutionTime time.Time 19 | Slot uint64 20 | Position int 21 | } 22 | 23 | func (d Datapoint) FormatCSV() [][]string { 24 | return [][]string{{ 25 | strconv.Itoa(d.Iteration), 26 | d.CreationTime.Format(tsFormat), 27 | d.Signature, 28 | d.Endpoint, 29 | strconv.FormatBool(d.Executed), 30 | d.ExecutionTime.Format(tsFormat), 31 | strconv.Itoa(int(d.Slot)), 32 | strconv.Itoa(d.Position), 33 | }} 34 | } 35 | 36 | func Print(iterations int, endpoints []string, bests []int, lost []int) { 37 | fmt.Println("Iterations: ", iterations) 38 | fmt.Println("Endpoints:") 39 | 40 | for _, endpoint := range endpoints { 41 | fmt.Println(" ", endpoint) 42 | } 43 | 44 | fmt.Println() 45 | fmt.Println("Win counts: ") 46 | 47 | for i, endpoint := range endpoints { 48 | fmt.Println(fmt.Sprintf(" %-3d %v", bests[i], endpoint)) 49 | } 50 | 51 | fmt.Println() 52 | fmt.Println("Lost transactions: ") 53 | 54 | for i, endpoint := range endpoints { 55 | fmt.Println(fmt.Sprintf(" %-3d %v", lost[i], endpoint)) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /benchmark/types.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import "time" 4 | 5 | type PumpTxInfo struct { 6 | TimeSeen time.Time 7 | } 8 | 9 | type NewTokenResult struct { 10 | TraderAPIEventTime time.Time 11 | ThirdPartyEventTime time.Time 12 | BlockTime time.Time 13 | Diff time.Duration 14 | TxHash string 15 | Slot int64 16 | } 17 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | IMAGE=bloxroute/solana-trader-client-go:${1:-latest} 4 | echo "building container... $IMAGE" 5 | echo "" 6 | 7 | docker build . -f Dockerfile --rm=true -t $IMAGE --platform linux/amd64 8 | -------------------------------------------------------------------------------- /connections/common.go: -------------------------------------------------------------------------------- 1 | package connections 2 | 3 | type Streamer[T any] func() (T, error) 4 | 5 | func (s Streamer[T]) Channel(size int) chan T { 6 | ch := make(chan T, size) 7 | s.Into(ch) 8 | return ch 9 | } 10 | 11 | func (s Streamer[T]) Into(ch chan T) { 12 | go func() { 13 | for { 14 | v, err := s() 15 | if err != nil { 16 | close(ch) 17 | return 18 | } 19 | ch <- v 20 | } 21 | }() 22 | } 23 | -------------------------------------------------------------------------------- /connections/grpc.go: -------------------------------------------------------------------------------- 1 | package connections 2 | 3 | import ( 4 | "fmt" 5 | "google.golang.org/grpc" 6 | "io" 7 | ) 8 | 9 | func GRPCStream[T any](stream grpc.ClientStream, input string) Streamer[*T] { 10 | var generator Streamer[*T] = func() (*T, error) { 11 | m := new(T) 12 | err := stream.RecvMsg(m) 13 | if err == io.EOF { 14 | return nil, fmt.Errorf("stream for input %s ended successfully", input) 15 | } else if err != nil { 16 | return nil, err 17 | } 18 | return m, nil 19 | } 20 | 21 | return generator 22 | } 23 | -------------------------------------------------------------------------------- /connections/http.go: -------------------------------------------------------------------------------- 1 | package connections 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io/ioutil" 9 | "net/http" 10 | 11 | package_info "github.com/bloXroute-Labs/solana-trader-client-go" 12 | "google.golang.org/protobuf/encoding/protojson" 13 | "google.golang.org/protobuf/proto" 14 | "google.golang.org/protobuf/reflect/protoreflect" 15 | ) 16 | 17 | const contentType = "application/json" 18 | 19 | var httpResponseNil = fmt.Errorf("HTTP response is nil") 20 | 21 | type HTTPError struct { 22 | Code int `json:"code"` 23 | Details interface{} `json:"details"` 24 | Message string `json:"message"` 25 | } 26 | 27 | func (h HTTPError) Error() string { 28 | return h.Message 29 | } 30 | 31 | func HTTPGetWithClient[T protoreflect.ProtoMessage](ctx context.Context, url string, client *http.Client, val T, authHeader string) error { 32 | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 33 | req.Header.Set("Authorization", authHeader) 34 | req.Header.Set("x-sdk", package_info.Name) 35 | req.Header.Set("x-sdk-version", package_info.Version) 36 | httpResp, err := client.Do(req) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | if httpResp.StatusCode != http.StatusOK { 42 | return httpUnmarshalError(httpResp) 43 | } 44 | 45 | if err := httpUnmarshal[T](httpResp, val); err != nil { 46 | return err 47 | } 48 | 49 | return nil 50 | } 51 | 52 | func HTTPPostWithClient[T protoreflect.ProtoMessage](ctx context.Context, url string, client *http.Client, body interface{}, val T, authHeader string) error { 53 | protoMsg := body.(proto.Message) 54 | 55 | // Use protojson marshaler 56 | marshaler := protojson.MarshalOptions{ 57 | UseProtoNames: true, 58 | } 59 | b, err := marshaler.Marshal(protoMsg) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(b)) 65 | if err != nil { 66 | return err 67 | } 68 | req.Header.Set("Authorization", authHeader) 69 | req.Header.Set("Content-Type", contentType) 70 | req.Header.Set("x-sdk", package_info.Name) 71 | req.Header.Set("x-sdk-version", package_info.Version) 72 | httpResp, err := client.Do(req) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | if httpResp.StatusCode != http.StatusOK { 78 | return httpUnmarshalError(httpResp) 79 | } 80 | 81 | if err := httpUnmarshal[T](httpResp, val); err != nil { 82 | return err 83 | } 84 | 85 | return nil 86 | } 87 | 88 | func httpUnmarshalError(httpResp *http.Response) error { 89 | if httpResp == nil { 90 | return httpResponseNil 91 | } 92 | 93 | body, err := ioutil.ReadAll(httpResp.Body) 94 | if err != nil { 95 | return err 96 | } 97 | 98 | return errors.New(string(body)) 99 | } 100 | 101 | func httpUnmarshal[T protoreflect.ProtoMessage](httpResp *http.Response, val T) error { 102 | if httpResp == nil { 103 | return httpResponseNil 104 | } 105 | 106 | b, err := ioutil.ReadAll(httpResp.Body) 107 | if err != nil { 108 | return err 109 | } 110 | 111 | if err := protojson.Unmarshal(b, val); err != nil { 112 | return err 113 | } 114 | 115 | return nil 116 | } 117 | -------------------------------------------------------------------------------- /connections/jsonrpc.go: -------------------------------------------------------------------------------- 1 | package connections 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | const ( 9 | subscribeMethod = "subscribe" 10 | unsubscribeMethod = "unsubscribe" 11 | ) 12 | 13 | // FeedUpdate wraps the result from any particular stream with the subscription ID it's associated with 14 | type FeedUpdate struct { 15 | SubscriptionID string `json:"subscription"` 16 | Result json.RawMessage `json:"result"` 17 | } 18 | 19 | // SubscribeParams exist because subscribe arguments usually look like ["streamName", {"some": "opts"}], which doesn't map elegantly to Go structs 20 | type SubscribeParams struct { 21 | StreamName string 22 | StreamOpts json.RawMessage 23 | } 24 | 25 | func (s SubscribeParams) MarshalJSON() ([]byte, error) { 26 | nameB, err := json.Marshal(s.StreamName) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | var params []json.RawMessage 32 | if s.StreamOpts == nil { 33 | params = []json.RawMessage{nameB} 34 | } else { 35 | params = []json.RawMessage{nameB, s.StreamOpts} 36 | } 37 | 38 | return json.Marshal(params) 39 | } 40 | 41 | func (s *SubscribeParams) UnmarshalJSON(b []byte) error { 42 | var result []json.RawMessage 43 | err := json.Unmarshal(b, &result) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | if len(result) != 2 { 49 | return fmt.Errorf("invalid argument count: expected 2, got %v", len(result)) 50 | } 51 | 52 | err = json.Unmarshal(result[0], &s.StreamName) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | s.StreamOpts = result[1] 58 | return nil 59 | } 60 | 61 | // UnsubscribeParams exist because unsubscribe arguments usually look like ["subscriptionID"], which doesn't map elegantly to Go structs 62 | type UnsubscribeParams struct { 63 | SubscriptionID string 64 | } 65 | 66 | func (s UnsubscribeParams) MarshalJSON() ([]byte, error) { 67 | params := []string{s.SubscriptionID} 68 | return json.Marshal(params) 69 | } 70 | 71 | func (s *UnsubscribeParams) UnmarshalJSON(b []byte) error { 72 | var result []json.RawMessage 73 | err := json.Unmarshal(b, &result) 74 | if err != nil { 75 | return err 76 | } 77 | 78 | if len(result) != 1 { 79 | return fmt.Errorf("invalid argument count: expected 1, got %v", len(result)) 80 | } 81 | 82 | err = json.Unmarshal(result[0], &s.SubscriptionID) 83 | if err != nil { 84 | return err 85 | } 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /connections/ws.go: -------------------------------------------------------------------------------- 1 | package connections 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "sync" 10 | "time" 11 | 12 | package_info "github.com/bloXroute-Labs/solana-trader-client-go" 13 | "github.com/bloXroute-Labs/solana-trader-client-go/utils" 14 | "github.com/gorilla/websocket" 15 | "github.com/sourcegraph/jsonrpc2" 16 | "google.golang.org/protobuf/encoding/protojson" 17 | "google.golang.org/protobuf/proto" 18 | ) 19 | 20 | const ( 21 | handshakeTimeout = 5 * time.Second 22 | connectionRetryTimeout = 15 * time.Second 23 | connectionRetryInterval = 100 * time.Millisecond 24 | subscriptionBuffer = 1000 25 | unsubscribeGracePeriod = 3 * time.Second 26 | pingInterval = 30 * time.Second 27 | pingWriteWait = 10 * time.Second 28 | ) 29 | 30 | type WS struct { 31 | messageM sync.Mutex 32 | subscriptionM sync.RWMutex 33 | requestID *utils.RequestID 34 | conn *websocket.Conn 35 | ctx context.Context 36 | cancel context.CancelFunc 37 | err error 38 | writeCh chan []byte 39 | 40 | requestMap map[uint64]requestTracker 41 | requestM sync.RWMutex 42 | 43 | subscriptionMap map[string]subscriptionEntry 44 | 45 | // public to allow overriding of (un)subscribe method name 46 | SubscribeMethodName string 47 | UnsubscribeMethodName string 48 | 49 | endpoint string 50 | authHeader string 51 | } 52 | 53 | func NewWS(endpoint string, authHeader string, disablePingLoop bool) (*WS, error) { 54 | conn, err := connect(endpoint, authHeader) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | ctx, cancel := context.WithCancel(context.Background()) 60 | ws := &WS{ 61 | requestID: utils.NewRequestID(), 62 | endpoint: endpoint, 63 | authHeader: authHeader, 64 | conn: conn, 65 | ctx: ctx, 66 | cancel: cancel, 67 | writeCh: make(chan []byte, 100), 68 | requestMap: make(map[uint64]requestTracker), 69 | subscriptionMap: make(map[string]subscriptionEntry), 70 | SubscribeMethodName: subscribeMethod, 71 | UnsubscribeMethodName: unsubscribeMethod, 72 | } 73 | go ws.readLoop() 74 | go ws.writeLoop() 75 | if !disablePingLoop { 76 | go ws.pingLoop() 77 | } 78 | return ws, nil 79 | } 80 | 81 | func connect(endpoint string, auth string) (*websocket.Conn, error) { 82 | dialer := websocket.Dialer{HandshakeTimeout: handshakeTimeout} 83 | header := http.Header{} 84 | header.Set("Authorization", auth) 85 | header.Set("x-sdk", package_info.Name) 86 | header.Set("x-sdk-version", package_info.Version) 87 | 88 | conn, _, err := dialer.Dial(endpoint, header) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | return conn, err 94 | } 95 | 96 | func (w *WS) readLoop() { 97 | defer w.cancel() 98 | 99 | for { 100 | outerLoop: 101 | // all message reading is done on a single goroutine, while message processing is dispatched on independent 102 | // goroutines in parallel. in most cases this is fine, but if not message processors can request to hold the lock 103 | // (see lockHeld attribute usage) to force sequential processing 104 | // 105 | // main scenario for this is for subscription messages: we want to process the initial response to register 106 | // a subscription ID before processing any potential updates which would otherwise be discarded 107 | w.messageM.Lock() 108 | w.messageM.Unlock() 109 | 110 | var msg []byte 111 | var err error 112 | if w.conn != nil { 113 | _, msg, err = w.conn.ReadMessage() 114 | } 115 | if err != nil || w.conn == nil { 116 | // reconnect the websocket connection if connection read message fails 117 | 118 | // IMPORTANT: 119 | // Don't do this for the timeout case for this sceniaro: 120 | // case <-time.After(connectionRetryTimeout): 121 | // The timer will be re-created every time the select is called, so it will never fire. 122 | 123 | connectionRetyTimer := time.After(connectionRetryTimeout) 124 | 125 | for { 126 | select { 127 | case <-connectionRetyTimer: 128 | _ = w.Close(err) 129 | return 130 | default: 131 | w.conn, err = connect(w.endpoint, w.authHeader) 132 | if err != nil { 133 | time.Sleep(connectionRetryInterval) 134 | } else { 135 | // websocket connection re-established 136 | goto outerLoop 137 | } 138 | } 139 | } 140 | } 141 | 142 | // try response format first 143 | var response jsonrpc2.Response 144 | err = json.Unmarshal(msg, &response) 145 | if err == nil && (response.Result != nil || response.Error != nil) { 146 | w.processRPCResponse(response) 147 | continue 148 | } 149 | 150 | // if not, try subscription format 151 | var update jsonrpc2.Request 152 | err = json.Unmarshal(msg, &update) 153 | if err == nil && update.Params != nil { 154 | w.processSubscriptionUpdate(update) 155 | continue 156 | } 157 | 158 | // no message works: exit loop and cancel connection 159 | _ = w.Close(fmt.Errorf("unknown jsonrpc message format: %v", string(msg))) 160 | return 161 | } 162 | } 163 | 164 | func (w *WS) writeLoop() { 165 | for { 166 | m := <-w.writeCh 167 | if w.conn != nil { 168 | err := w.conn.WriteMessage(websocket.TextMessage, m) 169 | if err != nil { 170 | _ = w.Close(fmt.Errorf("error sending message: %w", err)) 171 | return 172 | } 173 | } 174 | } 175 | } 176 | 177 | func (w *WS) pingLoop() { 178 | ticker := time.NewTicker(pingInterval) 179 | defer ticker.Stop() 180 | for { 181 | select { 182 | case <-ticker.C: 183 | if w.conn != nil { 184 | err := w.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(pingWriteWait)) 185 | if err != nil { 186 | _ = w.Close(fmt.Errorf("ping failed: %w", err)) 187 | return 188 | } 189 | } 190 | case <-w.ctx.Done(): 191 | return 192 | } 193 | } 194 | } 195 | 196 | func (w *WS) processRPCResponse(response jsonrpc2.Response) { 197 | requestID := response.ID.Num 198 | w.requestM.RLock() 199 | rt, ok := w.requestMap[requestID] 200 | w.requestM.RUnlock() 201 | if !ok { 202 | _ = w.Close(fmt.Errorf("unknown request ID: got %v, most recent %v", requestID, w.requestID.Current())) 203 | return 204 | } 205 | 206 | ru := responseUpdate{ 207 | v: response, 208 | lockHeld: false, 209 | } 210 | 211 | // hold lock: now it's the responsibility of the listening channel to release the lock for the next loop 212 | if rt.lockRequired { 213 | w.messageM.Lock() 214 | ru.lockHeld = true 215 | } 216 | 217 | rt.ch <- ru 218 | } 219 | 220 | func (w *WS) processSubscriptionUpdate(update jsonrpc2.Request) { 221 | var f FeedUpdate 222 | err := json.Unmarshal(*update.Params, &f) 223 | if err != nil { 224 | _ = w.Close(fmt.Errorf("could not deserialize feed update: %w", err)) 225 | return 226 | } 227 | 228 | w.subscriptionM.RLock() 229 | defer w.subscriptionM.RUnlock() 230 | sub, ok := w.subscriptionMap[f.SubscriptionID] 231 | if !ok { 232 | _ = w.Close(fmt.Errorf("unknown subscription ID: %v", f.SubscriptionID)) 233 | return 234 | } 235 | // skip message for inactive subscription: will be closed soon 236 | if !sub.active { 237 | return 238 | } 239 | 240 | sub.ch <- f.Result 241 | } 242 | 243 | func (w *WS) Request(ctx context.Context, method string, request proto.Message, response proto.Message) error { 244 | requestID := w.requestID.Next() 245 | rpcRequest := jsonrpc2.Request{ 246 | Method: method, 247 | ID: jsonrpc2.ID{Num: requestID}, 248 | } 249 | params, err := protojson.Marshal(request) 250 | if err != nil { 251 | return err 252 | } 253 | rawParams := json.RawMessage(params) 254 | rpcRequest.Params = &rawParams 255 | 256 | rpcResponse, err := w.request(ctx, rpcRequest, false) 257 | if err != nil { 258 | return err 259 | } 260 | 261 | if err = protojson.Unmarshal(*rpcResponse.Result, response); err != nil { 262 | return fmt.Errorf("error unmarshalling message of type %T: %w", response, err) 263 | } 264 | return nil 265 | } 266 | 267 | func (w *WS) request(ctx context.Context, request jsonrpc2.Request, lockRequired bool) (jsonrpc2.Response, error) { 268 | b, err := json.Marshal(request) 269 | if err != nil { 270 | return jsonrpc2.Response{}, err 271 | } 272 | 273 | // setup listener for next request ID that matches response 274 | responseCh := make(chan responseUpdate) 275 | w.requestM.Lock() 276 | w.requestMap[request.ID.Num] = requestTracker{ 277 | ch: responseCh, 278 | lockRequired: lockRequired, 279 | } 280 | w.requestM.Unlock() 281 | 282 | defer func() { 283 | w.requestM.Lock() 284 | defer w.requestM.Unlock() 285 | 286 | delete(w.requestMap, request.ID.Num) 287 | }() 288 | 289 | w.writeCh <- b 290 | 291 | select { 292 | case response := <-responseCh: 293 | rpcResponse := response.v 294 | if rpcResponse.Error != nil { 295 | var rpcErr string 296 | err = json.Unmarshal(*rpcResponse.Error.Data, &rpcErr) 297 | if err != nil { 298 | return jsonrpc2.Response{}, err 299 | } 300 | return rpcResponse, errors.New(rpcErr) 301 | } 302 | return response.v, nil 303 | case <-ctx.Done(): 304 | return jsonrpc2.Response{}, ctx.Err() 305 | case <-w.ctx.Done(): 306 | // connection closed 307 | return jsonrpc2.Response{}, fmt.Errorf("websocket connection was closed: %w", w.err) 308 | } 309 | } 310 | 311 | func WSStreamAny[T any](w *WS, ctx context.Context, streamName string, streamParams interface{}) (Streamer[T], error) { 312 | var ( 313 | err error 314 | streamParamsB []byte 315 | ) 316 | 317 | if streamParams == nil { 318 | streamParamsB = nil 319 | } else { 320 | streamParamsB, err = json.Marshal(streamParams) 321 | if err != nil { 322 | return nil, err 323 | } 324 | } 325 | 326 | return wsStream(w, ctx, streamName, streamParamsB, func(b []byte) (T, error) { 327 | var v T 328 | err := json.Unmarshal(b, &v) 329 | return v, err 330 | }) 331 | } 332 | 333 | func WSStreamProto[T proto.Message](w *WS, ctx context.Context, streamName string, streamParams proto.Message, resultInitFn func() T) (Streamer[T], error) { 334 | streamParamsB, err := protojson.Marshal(streamParams) 335 | if err != nil { 336 | return nil, err 337 | } 338 | return wsStream(w, ctx, streamName, streamParamsB, func(b []byte) (T, error) { 339 | v := resultInitFn() 340 | err := protojson.Unmarshal(b, v) 341 | return v, err 342 | }) 343 | } 344 | 345 | func wsStream[T any](w *WS, ctx context.Context, streamName string, streamParams json.RawMessage, unmarshal func(b []byte) (T, error)) (Streamer[T], error) { 346 | params := SubscribeParams{ 347 | StreamName: streamName, 348 | StreamOpts: streamParams, 349 | } 350 | 351 | paramsB, err := json.Marshal(params) 352 | if err != nil { 353 | return nil, err 354 | } 355 | 356 | rawParams := json.RawMessage(paramsB) 357 | rpcRequest := jsonrpc2.Request{ 358 | Method: w.SubscribeMethodName, 359 | ID: jsonrpc2.ID{Num: w.requestID.Next()}, 360 | Params: &rawParams, 361 | } 362 | 363 | // requires lock held on subscription mutex, otherwise a subscription message could be processed before the map entry is created 364 | rpcResponse, err := w.request(ctx, rpcRequest, true) 365 | if err != nil { 366 | return nil, err 367 | } 368 | defer w.messageM.Unlock() 369 | 370 | var subscriptionID string 371 | err = json.Unmarshal(*rpcResponse.Result, &subscriptionID) 372 | if err != nil { 373 | return nil, err 374 | } 375 | 376 | ch := make(chan json.RawMessage, subscriptionBuffer) 377 | streamCtx, streamCancel := context.WithCancel(ctx) 378 | 379 | w.subscriptionM.Lock() 380 | w.subscriptionMap[subscriptionID] = subscriptionEntry{ 381 | active: true, 382 | ch: ch, 383 | cancel: streamCancel, 384 | } 385 | w.subscriptionM.Unlock() 386 | 387 | // set goroutine to unsubscribe when ctx is canceled 388 | go func() { 389 | <-streamCtx.Done() 390 | 391 | // immediately mark as inactive 392 | w.subscriptionM.Lock() 393 | w.subscriptionMap[subscriptionID] = subscriptionEntry{active: false} 394 | w.subscriptionM.Unlock() 395 | 396 | up := UnsubscribeParams{SubscriptionID: subscriptionID} 397 | b, _ := json.Marshal(up) 398 | rm := json.RawMessage(b) 399 | 400 | unsubscribeMessage := jsonrpc2.Request{ 401 | ID: jsonrpc2.ID{Num: w.requestID.Next()}, 402 | Method: w.UnsubscribeMethodName, 403 | Params: &rm, 404 | } 405 | 406 | _, err = w.request(w.ctx, unsubscribeMessage, false) 407 | if err != nil { 408 | _ = w.Close(fmt.Errorf("unsubscribe requested rejected: %w", err)) 409 | } 410 | 411 | // wait for server to process message before forcing errors from unknown subscription IDs 412 | time.Sleep(unsubscribeGracePeriod) 413 | w.subscriptionM.Lock() 414 | delete(w.subscriptionMap, subscriptionID) 415 | w.subscriptionM.Unlock() 416 | }() 417 | 418 | return func() (T, error) { 419 | var zero T 420 | select { 421 | case b := <-ch: 422 | v, err := unmarshal(b) 423 | if err != nil { 424 | return zero, err 425 | } 426 | return v, nil 427 | case <-w.ctx.Done(): 428 | return zero, fmt.Errorf("connection has been closed: %w", w.err) 429 | case <-streamCtx.Done(): 430 | return zero, errors.New("stream context has been closed") 431 | } 432 | }, nil 433 | } 434 | 435 | func (w *WS) Close(reason error) error { 436 | w.messageM.Lock() 437 | defer w.messageM.Unlock() 438 | w.subscriptionM.Lock() 439 | defer w.subscriptionM.Unlock() 440 | 441 | if w.ctx.Err() != nil { 442 | return nil 443 | } 444 | 445 | w.err = reason 446 | 447 | // cancel main connection ctx 448 | w.cancel() 449 | 450 | // cancel all subscriptions 451 | for _, sub := range w.subscriptionMap { 452 | if sub.active { 453 | sub.close() 454 | } 455 | } 456 | 457 | // close underlying connection 458 | if w.conn != nil { 459 | return w.conn.Close() 460 | } 461 | 462 | return nil 463 | } 464 | -------------------------------------------------------------------------------- /connections/wsrpc_trackers.go: -------------------------------------------------------------------------------- 1 | package connections 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "github.com/sourcegraph/jsonrpc2" 7 | ) 8 | 9 | // entry to track an active subscription on connection: channel to send updates on and reference to cancel the subscription 10 | type subscriptionEntry struct { 11 | active bool 12 | ch chan json.RawMessage 13 | cancel context.CancelFunc 14 | } 15 | 16 | func (s subscriptionEntry) close() { 17 | close(s.ch) 18 | s.cancel() 19 | } 20 | 21 | type responseUpdate struct { 22 | v jsonrpc2.Response 23 | lockHeld bool 24 | } 25 | 26 | type requestTracker struct { 27 | ch chan responseUpdate 28 | // can be set to hold message processing lock to ensure processing completes before next message (particularly useful for registering subscription before processing any potential updates on the connection) 29 | lockRequired bool 30 | } 31 | -------------------------------------------------------------------------------- /examples/config/env.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "github.com/bloXroute-Labs/solana-trader-client-go/provider" 6 | "os" 7 | "strings" 8 | 9 | "github.com/joho/godotenv" 10 | log "github.com/sirupsen/logrus" 11 | ) 12 | 13 | type Example struct { 14 | Env Env 15 | RunSlowStream bool 16 | RunTrades bool 17 | } 18 | 19 | func BoolPtr(val bool) *bool { 20 | return &val 21 | } 22 | 23 | func Load() (Example, error) { 24 | env, err := loadEnv() 25 | if err != nil { 26 | return Example{}, err 27 | } 28 | 29 | runSlowStream := true 30 | rtsV := os.Getenv("RUN_SLOW_STREAM") 31 | if rtsV == "false" { 32 | runSlowStream = false 33 | } 34 | 35 | runTrades := true 36 | rtV := os.Getenv("RUN_TRADES") 37 | if rtV == "false" { 38 | runTrades = false 39 | } 40 | 41 | return Example{ 42 | Env: env, 43 | RunTrades: runTrades, 44 | RunSlowStream: runSlowStream, 45 | }, nil 46 | } 47 | 48 | type Env string 49 | 50 | const ( 51 | EnvMainnet Env = "mainnet" 52 | EnvTestnet Env = "testnet" 53 | EnvLocal Env = "local" 54 | ) 55 | 56 | type Region string 57 | 58 | const ( 59 | NY Region = "ny" 60 | UK Region = "uk" 61 | ) 62 | 63 | var ( 64 | GRPCUrls = map[Region]string{ 65 | NY: provider.MainnetNYGRPC, 66 | UK: provider.MainnetUKGRPC, 67 | } 68 | 69 | HTTPUrls = map[Region]string{ 70 | NY: provider.MainnetNYHTTP, 71 | UK: provider.MainnetUKHTTP, 72 | } 73 | 74 | WSUrls = map[Region]string{ 75 | NY: provider.MainnetNYWS, 76 | UK: provider.MainnetUKWS, 77 | } 78 | ) 79 | 80 | func loadEnv() (Env, error) { 81 | err := godotenv.Load(".env") 82 | if err != nil { 83 | fmt.Println("Error loading .env file") 84 | } 85 | v, ok := os.LookupEnv("API_ENV") 86 | if !ok { 87 | return EnvMainnet, nil 88 | } 89 | 90 | switch Env(strings.ToLower(v)) { 91 | case EnvLocal: 92 | return EnvLocal, nil 93 | case EnvTestnet: 94 | return EnvTestnet, nil 95 | case EnvMainnet: 96 | return EnvMainnet, nil 97 | default: 98 | return EnvMainnet, fmt.Errorf("API_ENV %v not supported", v) 99 | } 100 | } 101 | 102 | type EnvironmentVariables struct { 103 | PrivateKey string 104 | PublicKey string 105 | OpenOrdersAddress string 106 | Payer string 107 | } 108 | 109 | func InitializeEnvironmentVariables() EnvironmentVariables { 110 | // Load .env file if it exists 111 | godotenv.Load() 112 | 113 | // Check required AUTH_HEADER 114 | if os.Getenv("AUTH_HEADER") == "" { 115 | log.Fatal("must specify bloXroute authorization header!") 116 | } 117 | 118 | // Get private key 119 | privateKey := os.Getenv("PRIVATE_KEY") 120 | if privateKey == "" { 121 | log.Error("PRIVATE_KEY environment variable not set, cannot run any examples that require tx submission") 122 | } 123 | 124 | // Get public key 125 | publicKey := os.Getenv("PUBLIC_KEY") 126 | if publicKey == "" { 127 | log.Warn("PUBLIC_KEY environment variable not set: will skip place/cancel/settle examples") 128 | } 129 | 130 | // Get open orders 131 | openOrders := os.Getenv("OPEN_ORDERS") 132 | if openOrders == "" { 133 | log.Error("OPEN_ORDERS environment variable not set: requests will be slower") 134 | } 135 | 136 | // Get payer or default to public key 137 | payer := os.Getenv("PAYER") 138 | if payer == "" { 139 | log.Warn("PAYER environment variable not set: will be set to owner address") 140 | payer = publicKey 141 | } 142 | 143 | return EnvironmentVariables{ 144 | PrivateKey: privateKey, 145 | PublicKey: publicKey, 146 | OpenOrdersAddress: openOrders, 147 | Payer: payer, 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /examples/helpers.go: -------------------------------------------------------------------------------- 1 | package examples 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/bloXroute-Labs/solana-trader-client-go/provider" 8 | pb "github.com/bloXroute-Labs/solana-trader-proto/api" 9 | log "github.com/sirupsen/logrus" 10 | ) 11 | 12 | func GetPumpFunNewTokenHelper() (*pb.GetPumpFunNewTokensStreamResponse, error) { 13 | grpcClient, err := provider.NewGRPCClientPumpNY() 14 | if err != nil { 15 | panic(err) 16 | } 17 | log.Info("starting GetPumpFunNewTokens stream") 18 | ctx, cancel := context.WithCancel(context.Background()) 19 | defer cancel() 20 | 21 | stream, err := grpcClient.GetPumpFunNewTokensStream(ctx, &pb.GetPumpFunNewTokensStreamRequest{}) 22 | if err != nil { 23 | log.Errorf("error with GetPumpFunNewTokens stream request: %v", err) 24 | return nil, err 25 | } 26 | 27 | ch := stream.Channel(0) 28 | 29 | // Wait for a single response 30 | v, ok := <-ch 31 | if !ok { 32 | return nil, errors.New("Token searcher failed") 33 | } 34 | 35 | log.Infof("response %v received", v) 36 | return v, nil 37 | } 38 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/bloXroute-Labs/solana-trader-client-go 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.3 6 | 7 | require ( 8 | github.com/bloXroute-Labs/solana-trader-proto v1.9.3-0.20250527192455-e5e04812e048 9 | github.com/gagliardetto/binary v0.8.0 10 | github.com/gagliardetto/solana-go v1.12.0 11 | github.com/gorilla/websocket v1.5.3 12 | github.com/joho/godotenv v1.5.1 13 | github.com/manifoldco/promptui v0.9.0 14 | github.com/mhmtszr/concurrent-swiss-map v1.0.8 15 | github.com/pkg/errors v0.9.1 16 | github.com/sirupsen/logrus v1.9.3 17 | github.com/sourcegraph/jsonrpc2 v0.2.0 18 | github.com/stretchr/testify v1.8.1 19 | github.com/urfave/cli/v2 v2.27.5 20 | go.uber.org/zap v1.27.0 21 | golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 22 | golang.org/x/net v0.35.0 23 | golang.org/x/sync v0.11.0 24 | google.golang.org/grpc v1.70.0 25 | google.golang.org/protobuf v1.36.5 26 | ) 27 | 28 | require ( 29 | filippo.io/edwards25519 v1.1.0 // indirect 30 | github.com/GeertJohan/go.rice v1.0.3 // indirect 31 | github.com/benbjohnson/clock v1.3.5 // indirect 32 | github.com/blendle/zapdriver v1.3.1 // indirect 33 | github.com/buger/jsonparser v1.1.1 // indirect 34 | github.com/chzyer/readline v1.5.1 // indirect 35 | github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect 36 | github.com/daaku/go.zipexe v1.0.2 // indirect 37 | github.com/davecgh/go-spew v1.1.1 // indirect 38 | github.com/fatih/color v1.18.0 // indirect 39 | github.com/gagliardetto/treeout v0.1.4 // indirect 40 | github.com/golang/protobuf v1.5.4 // indirect 41 | github.com/google/uuid v1.6.0 // indirect 42 | github.com/gorilla/rpc v1.2.1 // indirect 43 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect 44 | github.com/json-iterator/go v1.1.12 // indirect 45 | github.com/klauspost/compress v1.17.11 // indirect 46 | github.com/logrusorgru/aurora v2.0.3+incompatible // indirect 47 | github.com/mattn/go-colorable v0.1.14 // indirect 48 | github.com/mattn/go-isatty v0.0.20 // indirect 49 | github.com/mitchellh/go-testing-interface v1.14.1 // indirect 50 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 51 | github.com/modern-go/reflect2 v1.0.2 // indirect 52 | github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect 53 | github.com/mr-tron/base58 v1.2.0 // indirect 54 | github.com/pmezard/go-difflib v1.0.0 // indirect 55 | github.com/rogpeppe/go-internal v1.13.1 // indirect 56 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 57 | github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 // indirect 58 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 59 | go.mongodb.org/mongo-driver v1.17.2 // indirect 60 | go.uber.org/atomic v1.11.0 // indirect 61 | go.uber.org/multierr v1.11.0 // indirect 62 | go.uber.org/ratelimit v0.3.1 // indirect 63 | golang.org/x/crypto v0.33.0 // indirect 64 | golang.org/x/sys v0.30.0 // indirect 65 | golang.org/x/term v0.29.0 // indirect 66 | golang.org/x/text v0.22.0 // indirect 67 | golang.org/x/time v0.9.0 // indirect 68 | google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect 69 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect 70 | gopkg.in/yaml.v3 v3.0.1 // indirect 71 | ) 72 | -------------------------------------------------------------------------------- /package_info.go: -------------------------------------------------------------------------------- 1 | package package_info 2 | 3 | var Version = "v2.1.1" 4 | var Name = "solana-trader-client-go" 5 | -------------------------------------------------------------------------------- /provider/common.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "github.com/gagliardetto/solana-go" 11 | 12 | "github.com/bloXroute-Labs/solana-trader-client-go/transaction" 13 | "github.com/bloXroute-Labs/solana-trader-client-go/utils" 14 | pb "github.com/bloXroute-Labs/solana-trader-proto/api" 15 | ) 16 | 17 | const ( 18 | mainnetNY = "ny.solana.dex.blxrbdn.com" 19 | mainnetPumpNY = "pump-ny.solana.dex.blxrbdn.com" 20 | mainnetUK = "uk.solana.dex.blxrbdn.com" 21 | // see https://docs.bloxroute.com/solana/trader-api/introduction/regions for all regions of traderAPI. There are 22 | // some regions below that are only available for transaction submission related endpoints, not full API service. 23 | mainnetFrankfurt = "germany.solana.dex.blxrbdn.com" 24 | mainnetLA = "la.solana.dex.blxrbdn.com" 25 | mainnetAmsterdam = "amsterdam.solana.dex.blxrbdn.com" 26 | mainnetTokyo = "tokyo.solana.dex.blxrbdn.com" 27 | testnet = "solana.dex.bxrtest.com" 28 | devnet = "solana-trader-api-nlb-6b0f765f2fc759e1.elb.us-east-1.amazonaws.com" 29 | ) 30 | 31 | // for information about submit only regions, see documentation: https://docs.bloxroute.com/solana/trader-api/introduction/regions 32 | 33 | var ( 34 | MainnetNYHTTP = httpEndpoint(mainnetNY, true) 35 | MainnetPumpNYHTTP = httpEndpoint(mainnetPumpNY, true) 36 | MainnetUKHTTP = httpEndpoint(mainnetUK, true) 37 | 38 | // submit only http 39 | MainnetFrankfurtHTTP = httpEndpoint(mainnetFrankfurt, true) 40 | MainnetLAHTTP = httpEndpoint(mainnetLA, true) 41 | MainnetAmsterdamHTTP = httpEndpoint(mainnetAmsterdam, true) 42 | MainnetTokyoHTTP = httpEndpoint(mainnetTokyo, true) 43 | 44 | MainnetNYWS = wsEndpoint(mainnetNY, true) 45 | MainnetPumpNYWS = wsEndpoint(mainnetPumpNY, true) 46 | MainnetUKWS = wsEndpoint(mainnetUK, true) 47 | 48 | // submit only ws 49 | MainnetFrankfurtWS = wsEndpoint(mainnetFrankfurt, true) 50 | MainnetLAWS = wsEndpoint(mainnetLA, true) 51 | MainnetAmsterdamWS = wsEndpoint(mainnetAmsterdam, true) 52 | MainnetTokyoWS = wsEndpoint(mainnetTokyo, true) 53 | 54 | MainnetNYGRPC = grpcEndpoint(mainnetNY, true) 55 | MainnetPumpNYGRPC = grpcEndpoint(mainnetPumpNY, true) 56 | MainnetUKGRPC = grpcEndpoint(mainnetUK, true) 57 | 58 | // submit only grpc 59 | MainnetFrankfurtGRPC = grpcEndpoint(mainnetFrankfurt, true) 60 | MainnetLAGRPC = grpcEndpoint(mainnetLA, true) 61 | MainnetAmsterdamGRPC = grpcEndpoint(mainnetAmsterdam, true) 62 | MainnetTokyoGRPC = grpcEndpoint(mainnetTokyo, true) 63 | 64 | TestnetHTTP = httpEndpoint(testnet, true) 65 | TestnetWS = wsEndpoint(testnet, true) 66 | TestnetGRPC = grpcEndpoint(testnet, true) 67 | 68 | DevnetHTTP = httpEndpoint(devnet, false) 69 | DevnetWS = wsEndpoint(devnet, false) 70 | DevnetGRPC = grpcEndpoint(devnet, false) 71 | 72 | LocalHTTP = "http://localhost:9000" 73 | LocalWS = "ws://localhost:9000/ws" 74 | LocalGRPC = "localhost:9000" 75 | ) 76 | 77 | func httpEndpoint(baseUrl string, secure bool) string { 78 | prefix := "http" 79 | if secure { 80 | prefix = "https" 81 | } 82 | return fmt.Sprintf("%v://%v", prefix, baseUrl) 83 | } 84 | 85 | func wsEndpoint(baseUrl string, secure bool) string { 86 | prefix := "ws" 87 | if secure { 88 | prefix = "wss" 89 | } 90 | return fmt.Sprintf("%v://%v/ws", prefix, baseUrl) 91 | } 92 | 93 | func grpcEndpoint(baseUrl string, secure bool) string { 94 | port := "80" 95 | if secure { 96 | port = "443" 97 | } 98 | return fmt.Sprintf("%v:%v", baseUrl, port) 99 | } 100 | 101 | var ErrPrivateKeyNotFound = errors.New("private key not provided for signing transaction") 102 | 103 | type PostOrderOpts struct { 104 | OpenOrdersAddress string 105 | ClientOrderID uint64 106 | SkipPreFlight *bool 107 | } 108 | 109 | type SubmitOpts struct { 110 | SubmitStrategy pb.SubmitStrategy 111 | SkipPreFlight *bool 112 | } 113 | 114 | type PostSubmitOpts struct { 115 | SkipPreFlight bool 116 | FrontRunningProtection bool 117 | UseStakedRPCs bool 118 | AllowBackRun bool 119 | RevenueAddress string 120 | Sniping bool 121 | AllowRevert bool 122 | } 123 | 124 | type RPCOpts struct { 125 | Endpoint string 126 | DisableAuth bool 127 | UseTLS bool 128 | PrivateKey *solana.PrivateKey 129 | AuthHeader string 130 | DisablePingLoop bool 131 | CacheBlockHash bool 132 | BlockHashTtl time.Duration 133 | } 134 | 135 | func DefaultRPCOpts(endpoint string) RPCOpts { 136 | var spk *solana.PrivateKey 137 | privateKey, err := transaction.LoadPrivateKeyFromEnv() 138 | if err == nil { 139 | spk = &privateKey 140 | } 141 | return RPCOpts{ 142 | Endpoint: endpoint, 143 | PrivateKey: spk, 144 | AuthHeader: os.Getenv("AUTH_HEADER"), 145 | } 146 | } 147 | 148 | var stringToAmm = map[string]pb.Project{ 149 | "unknown": pb.Project_P_UNKNOWN, 150 | "jupiter": pb.Project_P_JUPITER, 151 | "raydium": pb.Project_P_RAYDIUM, 152 | "all": pb.Project_P_ALL, 153 | } 154 | 155 | func ProjectFromString(project string) (pb.Project, error) { 156 | if apiProject, ok := stringToAmm[strings.ToLower(project)]; ok { 157 | return apiProject, nil 158 | } 159 | 160 | return pb.Project_P_UNKNOWN, fmt.Errorf("could not find project %s", project) 161 | } 162 | 163 | func buildBatchRequest(transactions []*pb.TransactionMessage, privateKey solana.PrivateKey, useBundle bool, opts SubmitOpts) (*pb.PostSubmitBatchRequest, error) { 164 | batchRequest := pb.PostSubmitBatchRequest{} 165 | batchRequest.SubmitStrategy = opts.SubmitStrategy 166 | 167 | for _, tx := range transactions { 168 | request, err := createBatchRequestEntry(opts, tx.Content, privateKey) 169 | if err != nil { 170 | return nil, err 171 | } 172 | 173 | batchRequest.Entries = append(batchRequest.Entries, request) 174 | 175 | } 176 | 177 | batchRequest.UseBundle = &useBundle 178 | batchRequest.Timestamp = utils.GetTimestamp() 179 | 180 | return &batchRequest, nil 181 | } 182 | 183 | func createBatchRequestEntry(opts SubmitOpts, txBase64 string, privateKey solana.PrivateKey) (*pb.PostSubmitRequestEntry, error) { 184 | oneRequest := pb.PostSubmitRequestEntry{} 185 | if opts.SkipPreFlight == nil { 186 | oneRequest.SkipPreFlight = true 187 | } else { 188 | oneRequest.SkipPreFlight = *opts.SkipPreFlight 189 | } 190 | 191 | signedTxBase64, err := transaction.SignTxWithPrivateKey(txBase64, privateKey) 192 | if err != nil { 193 | return nil, err 194 | } 195 | oneRequest.Transaction = &pb.TransactionMessage{ 196 | Content: signedTxBase64, 197 | } 198 | 199 | return &oneRequest, nil 200 | } 201 | -------------------------------------------------------------------------------- /provider/recent_hash_store.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "github.com/bloXroute-Labs/solana-trader-client-go/connections" 6 | pb "github.com/bloXroute-Labs/solana-trader-proto/api" 7 | log "github.com/sirupsen/logrus" 8 | "sync" 9 | "time" 10 | ) 11 | 12 | type blockHashProvider func(ctx context.Context) (*pb.GetRecentBlockHashResponse, error) 13 | type blockHashStreamProvider func(ctx context.Context) (connections.Streamer[*pb.GetRecentBlockHashResponse], error) 14 | 15 | type recentBlockHashStore struct { 16 | mutex sync.RWMutex 17 | hashProvider blockHashProvider 18 | hashStreamProvider blockHashStreamProvider 19 | hash string 20 | hashTime time.Time 21 | hashExpiryDuration time.Duration 22 | } 23 | 24 | func newRecentBlockHashStore( 25 | hashProvider blockHashProvider, 26 | streamProvider blockHashStreamProvider, 27 | opts RPCOpts, 28 | ) *recentBlockHashStore { 29 | return &recentBlockHashStore{ 30 | mutex: sync.RWMutex{}, 31 | hashProvider: hashProvider, 32 | hashStreamProvider: streamProvider, 33 | hash: "", 34 | hashTime: time.Time{}, 35 | hashExpiryDuration: opts.BlockHashTtl, 36 | } 37 | } 38 | 39 | func (s *recentBlockHashStore) run(ctx context.Context) { 40 | stream, err := s.hashStreamProvider(ctx) 41 | if err != nil { 42 | log.Error("can't open recent block hash stream") 43 | return 44 | } 45 | ch := stream.Channel(1) 46 | for { 47 | select { 48 | case hash := <-ch: 49 | s.update(hash) 50 | case <-ctx.Done(): 51 | return 52 | } 53 | } 54 | } 55 | 56 | func (s *recentBlockHashStore) update(hash *pb.GetRecentBlockHashResponse) { 57 | s.mutex.Lock() 58 | defer s.mutex.Unlock() 59 | s.hash = hash.BlockHash 60 | now := time.Now() 61 | s.hashTime = now 62 | } 63 | 64 | func (s *recentBlockHashStore) get(ctx context.Context) (*pb.GetRecentBlockHashResponse, error) { 65 | response := s.cached() 66 | if response != nil { 67 | return response, nil 68 | } 69 | 70 | s.mutex.Lock() 71 | defer s.mutex.Unlock() 72 | 73 | now := time.Now() 74 | hash, err := s.hashProvider(ctx) 75 | if err != nil { 76 | return nil, err 77 | } 78 | s.hash = hash.BlockHash 79 | s.hashTime = now 80 | return &pb.GetRecentBlockHashResponse{ 81 | BlockHash: s.hash, 82 | }, nil 83 | } 84 | 85 | func (s *recentBlockHashStore) cached() *pb.GetRecentBlockHashResponse { 86 | now := time.Now() 87 | s.mutex.RLock() 88 | defer s.mutex.RUnlock() 89 | if s.hash != "" && s.hashTime.Before(now.Add(s.hashExpiryDuration)) { 90 | return &pb.GetRecentBlockHashResponse{ 91 | BlockHash: s.hash, 92 | } 93 | } 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /transaction/create.go: -------------------------------------------------------------------------------- 1 | package transaction 2 | 3 | import ( 4 | "github.com/gagliardetto/solana-go" 5 | "github.com/gagliardetto/solana-go/programs/system" 6 | ) 7 | 8 | const ReceipientAddress = "5wiGAqf4BX23XU6jc3MDDZAFoNV5pz61thsUuSgpsAxS" 9 | 10 | func CreateSampleTx(privateKey solana.PrivateKey, recentBlockHash solana.Hash, lamports uint64) (*solana.Transaction, error) { 11 | 12 | recipient := solana.MustPublicKeyFromBase58(ReceipientAddress) 13 | 14 | tx, err := solana.NewTransaction([]solana.Instruction{ 15 | system.NewTransferInstruction(lamports, privateKey.PublicKey(), recipient).Build(), 16 | }, recentBlockHash) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | signatures, err := tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { 22 | if key.Equals(privateKey.PublicKey()) { 23 | return &privateKey 24 | } 25 | return nil 26 | }) 27 | if err != nil { 28 | return nil, err 29 | } 30 | tx.Signatures = signatures 31 | 32 | return tx, nil 33 | } 34 | -------------------------------------------------------------------------------- /transaction/memo.go: -------------------------------------------------------------------------------- 1 | package transaction 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "fmt" 7 | "github.com/gagliardetto/solana-go" 8 | solanarpc "github.com/gagliardetto/solana-go/rpc" 9 | ) 10 | 11 | const BxMemoMarkerMsg = "Powered by bloXroute Trader Api" 12 | 13 | var TraderAPIMemoProgram = solana.MustPublicKeyFromBase58("HQ2UUt18uJqKaQFJhgV9zaTdQxUZjNrsKFgoEDquBkcx") 14 | 15 | // CreateTraderAPIMemoInstruction generates a transaction instruction that places a memo in the transaction log 16 | // Having a memo instruction with signals Trader-API usage is required 17 | func CreateTraderAPIMemoInstruction(msg string) solana.Instruction { 18 | if msg == "" { 19 | msg = BxMemoMarkerMsg 20 | } 21 | buf := new(bytes.Buffer) 22 | buf.Write([]byte(msg)) 23 | 24 | instruction := &solana.GenericInstruction{ 25 | AccountValues: nil, 26 | ProgID: TraderAPIMemoProgram, 27 | DataBytes: buf.Bytes(), 28 | } 29 | 30 | return instruction 31 | } 32 | 33 | func addMemo(tx *solana.Transaction) error { 34 | memoInstruction := CreateTraderAPIMemoInstruction("") 35 | memoData, err := memoInstruction.Data() 36 | if err != nil { 37 | return err 38 | } 39 | 40 | cutoff := uint16(len(tx.Message.AccountKeys)) 41 | for _, instruction := range tx.Message.Instructions { 42 | for i, accountIdx := range instruction.Accounts { 43 | if accountIdx >= cutoff { 44 | instruction.Accounts[i] = accountIdx + 1 45 | } 46 | } 47 | } 48 | 49 | tx.Message.AccountKeys = append(tx.Message.AccountKeys, memoInstruction.ProgramID()) 50 | tx.Message.Instructions = append(tx.Message.Instructions, solana.CompiledInstruction{ 51 | ProgramIDIndex: cutoff, 52 | Accounts: nil, 53 | Data: memoData, 54 | }) 55 | 56 | return nil 57 | } 58 | 59 | // AddMemoAndSign adds memo instruction to a serialized transaction, it's primarily used if the user 60 | // doesn't want to interact with Trader-API directly 61 | func AddMemoAndSign(txBase64 string, privateKey solana.PrivateKey) (string, error) { 62 | signedTxBytes, err := solanarpc.DataBytesOrJSONFromBase64(txBase64) 63 | if err != nil { 64 | return "", err 65 | } 66 | unsignedTx := solanarpc.TransactionWithMeta{Transaction: signedTxBytes} 67 | solanaTx, err := unsignedTx.GetTransaction() 68 | if err != nil { 69 | return "", err 70 | } 71 | 72 | if len(solanaTx.Message.AccountKeys) >= 32 { 73 | return "", fmt.Errorf("transaction has too many account keys") 74 | } 75 | 76 | for _, key := range solanaTx.Message.AccountKeys { 77 | if key == TraderAPIMemoProgram { 78 | return "", fmt.Errorf("transaction already has bloXroute memo instruction") 79 | } 80 | } 81 | 82 | err = addMemo(solanaTx) 83 | if err != nil { 84 | return "", err 85 | } 86 | 87 | err = signTx(solanaTx, privateKey) 88 | if err != nil { 89 | return "", err 90 | } 91 | 92 | txnBytes, err := solanaTx.MarshalBinary() 93 | if err != nil { 94 | return "", err 95 | } 96 | 97 | return base64.StdEncoding.EncodeToString(txnBytes), nil 98 | 99 | } 100 | -------------------------------------------------------------------------------- /transaction/memo_test.go: -------------------------------------------------------------------------------- 1 | package transaction 2 | 3 | import ( 4 | "github.com/gagliardetto/solana-go" 5 | solanarpc "github.com/gagliardetto/solana-go/rpc" 6 | log "github.com/sirupsen/logrus" 7 | "github.com/stretchr/testify/require" 8 | "testing" 9 | ) 10 | 11 | func TestAddMemoToSerializedTxn(t *testing.T) { 12 | privateKey, err := solana.NewRandomPrivateKey() 13 | if err != nil { 14 | log.Fatal(err) 15 | } 16 | privateKeys := make(map[solana.PublicKey]solana.PrivateKey) 17 | privateKeys[privateKey.PublicKey()] = privateKey 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | txbuilder := solana.NewTransactionBuilder() 22 | txbuilder.AddInstruction(&solana.GenericInstruction{ 23 | AccountValues: nil, 24 | ProgID: solana.PublicKey{}, 25 | DataBytes: nil, 26 | }) 27 | txbuilder.SetRecentBlockHash(solana.MustHashFromBase58("A1xapHMk7Y9tj2NuVKw1ddKASsCce2M5EyD1xXo3RWr1")) 28 | txbuilder.SetFeePayer(privateKey.PublicKey()) 29 | tx, err := txbuilder.Build() 30 | if err != nil { 31 | log.Fatal(err) 32 | } 33 | 34 | encodedTxn := tx.MustToBase64() 35 | require.NoError(t, err) 36 | require.NotEmpty(t, encodedTxn) 37 | if err != nil { 38 | log.Fatal(err) 39 | } 40 | encodedTxn2, err := AddMemoAndSign(encodedTxn, privateKey) 41 | require.NoError(t, err) 42 | require.NotEmpty(t, encodedTxn2) 43 | 44 | // validate 45 | signedTxBytes, err := solanarpc.DataBytesOrJSONFromBase64(encodedTxn2) 46 | if err != nil { 47 | log.Fatal(err) 48 | } 49 | unsignedTx := solanarpc.TransactionWithMeta{Transaction: signedTxBytes} 50 | solanaTx, err := unsignedTx.GetTransaction() 51 | 52 | require.Equal(t, 2, len(solanaTx.Message.Instructions)) 53 | program, err := solanaTx.Message.Program(solanaTx.Message.Instructions[1].ProgramIDIndex) 54 | if err != nil { 55 | log.Fatal(err) 56 | } 57 | require.Equal(t, TraderAPIMemoProgram, program) 58 | 59 | } 60 | -------------------------------------------------------------------------------- /transaction/signing.go: -------------------------------------------------------------------------------- 1 | package transaction 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/gagliardetto/solana-go" 7 | solanarpc "github.com/gagliardetto/solana-go/rpc" 8 | "os" 9 | ) 10 | 11 | // LoadPrivateKeyFromEnv looks up private key from the `PRIVATE_KEY` environment variable 12 | func LoadPrivateKeyFromEnv() (solana.PrivateKey, error) { 13 | privateKeyBase58, ok := os.LookupEnv("PRIVATE_KEY") 14 | if !ok { 15 | return solana.PrivateKey{}, fmt.Errorf("env variable `PRIVATE_KEY` not set") 16 | } 17 | 18 | return solana.PrivateKeyFromBase58(privateKeyBase58) 19 | } 20 | 21 | // SignTx uses the environment variable for `PRIVATE_KEY` to sign the message content and replace the zero signature 22 | func SignTx(unsignedTxBase64 string) (string, error) { 23 | privateKey, err := LoadPrivateKeyFromEnv() 24 | if err != nil { 25 | return "", err 26 | } 27 | 28 | return SignTxWithPrivateKey(unsignedTxBase64, privateKey) 29 | } 30 | 31 | // SignTxWithPrivateKey uses the provided private key to sign the message content and replace the zero signature 32 | func SignTxWithPrivateKey(unsignedTxBase64 string, privateKey solana.PrivateKey) (string, error) { 33 | unsignedTxBytes, err := solanarpc.DataBytesOrJSONFromBase64(unsignedTxBase64) 34 | if err != nil { 35 | return "", err 36 | } 37 | 38 | unsignedTx := solanarpc.TransactionWithMeta{Transaction: unsignedTxBytes} 39 | solanaTx, err := unsignedTx.GetTransaction() 40 | if err != nil { 41 | return "", err 42 | } 43 | 44 | err = signTx(solanaTx, privateKey) 45 | if err != nil { 46 | return "", err 47 | } 48 | 49 | return solanaTx.ToBase64() 50 | } 51 | 52 | func signTx(solanaTx *solana.Transaction, privateKey solana.PrivateKey) error { 53 | signaturesRequired := int(solanaTx.Message.Header.NumRequiredSignatures) 54 | signaturesPresent := len(solanaTx.Signatures) 55 | if signaturesPresent != signaturesRequired { 56 | if signaturesRequired-signaturesPresent == 1 { 57 | return appendSignature(solanaTx, privateKey) 58 | } 59 | return fmt.Errorf("transaction requires %v signatures and has %v signatures", signaturesRequired, signaturesPresent) 60 | } 61 | 62 | return replaceZeroSignature(solanaTx, privateKey) 63 | } 64 | 65 | func appendSignature(solanaTx *solana.Transaction, privateKey solana.PrivateKey) error { 66 | messageContent, err := solanaTx.Message.MarshalBinary() 67 | if err != nil { 68 | return fmt.Errorf("unable to encode message for signing: %w", err) 69 | } 70 | 71 | signedMessageContent, err := privateKey.Sign(messageContent) 72 | if err != nil { 73 | return fmt.Errorf("unable to sign message: %v", err) 74 | } 75 | 76 | solanaTx.Signatures = append(solanaTx.Signatures, signedMessageContent) 77 | return nil 78 | } 79 | 80 | func replaceZeroSignature(tx *solana.Transaction, privateKey solana.PrivateKey) error { 81 | zeroSigIndex := -1 82 | for i, sig := range tx.Signatures { 83 | if sig.IsZero() { 84 | if zeroSigIndex != -1 { 85 | return errors.New("more than one zero signature provided in transaction") 86 | } 87 | zeroSigIndex = i 88 | } 89 | } 90 | 91 | if zeroSigIndex == -1 { 92 | return nil 93 | } 94 | 95 | messageContent, err := tx.Message.MarshalBinary() 96 | if err != nil { 97 | return fmt.Errorf("unable to encode message for signing: %w", err) 98 | } 99 | 100 | signedMessageContent, err := privateKey.Sign(messageContent) 101 | if err != nil { 102 | return fmt.Errorf("unable to sign message: %v", err) 103 | } 104 | 105 | tx.Signatures[zeroSigIndex] = signedMessageContent 106 | return nil 107 | } 108 | 109 | // PartialSign heavily derived from `solana-go/transaction.go`. Signs the transaction with all available private keys, except 110 | // the main Solana address's 111 | func PartialSign(tx *solana.Transaction, ownerPk solana.PublicKey, privateKeys map[solana.PublicKey]solana.PrivateKey) error { 112 | requiredSignatures := tx.Message.Header.NumRequiredSignatures 113 | if uint8(len(privateKeys)) != requiredSignatures-1 { 114 | // one signature is reserved for the end user to sign the transaction 115 | return fmt.Errorf("unexpected error: could not generate enough signatures : # of privateKeys : %d vs requiredSignatures: %d", len(privateKeys), requiredSignatures) 116 | } 117 | 118 | messageBytes, err := tx.Message.MarshalBinary() 119 | if err != nil { 120 | return err 121 | } 122 | 123 | signatures := make([]solana.Signature, 0, requiredSignatures) 124 | for _, key := range tx.Message.AccountKeys[0:requiredSignatures] { 125 | if key == ownerPk { 126 | // if belongs to owner: add empty signature 127 | signatures = append(signatures, solana.Signature{}) 128 | } else { 129 | // otherwise, sign 130 | privateKey, ok := privateKeys[key] 131 | if !ok { 132 | return errors.New("private key not found") 133 | } 134 | s, err := privateKey.Sign(messageBytes) 135 | if err != nil { 136 | return err // TODO: wrap error 137 | } 138 | signatures = append(signatures, s) 139 | } 140 | } 141 | 142 | tx.Signatures = signatures 143 | return nil 144 | } 145 | -------------------------------------------------------------------------------- /transaction/signing_test.go: -------------------------------------------------------------------------------- 1 | package transaction 2 | 3 | import ( 4 | "github.com/gagliardetto/solana-go" 5 | "github.com/stretchr/testify/assert" 6 | "github.com/stretchr/testify/require" 7 | "testing" 8 | ) 9 | 10 | const ( 11 | // randomly generated private key 12 | testPrivateKey = "2RTpGMbfK2F4VNxMwTUdoaxgqh837NQg7sBnvv6C6bUmswJfG4eZ6gZb7qtsnaLAGftJW3XjmXYwDX91kJGEtkkh" 13 | 14 | // new order transaction, which requires multiple signatures 15 | testPartiallySignedTx = "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABbqMO1naqyX2pXt5r/M/lbVG4AJo25JAZOPdxzTR20O9wBPcSo/haHeK/y5d9quE823ycX2P04cfrUFjwhKnkDAgAGEIls26fgpAnCYufUzDrXMMpDjMYkf2Y2FHuxqKE+2+IrqlXf7Qtg+iNNWHLp5EkyLgE6Zs6D0t2ssRfXrXHWFO5qxMPO+p8Zv1TI3A9eTRzu5TJ9JkgrKdKxPLqkNEchjeQTBcZ30N4UFbquVSNklrtTZGoyzqrep6uSS5UUAYeQqUNpofphjJYDGcmbFeqKg3p+Y1sXgGjP6XzrYw7WDPRrEDIxyXUFDOyNpt5ANXybymDvno8zFlolVmVlKoJTO0ZSeUngp6ZZ+KrchrxTzHxCRpoXdlqbrWKxsFvIaLXuyb65sW0YqCc5du+Jt/3oSuybqsoNsXPbj9pK4N5HijSoS7ZGYkZ4HXqa2rhYi6hrKszlE1jIRPVETkBkCHX9WkydmX0uxDvcDSNiac+w0IORr9ED/Y+9Y0U/9YtuI6kgBpuIV/6rgYT7aH9jRhjANdrEOdwa6ztVmKDwAAAAAAEGp9UXGSxcUSGMyUw9SvF/WNruCJuh/UTj29mKAAAAAAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANB1GoKC2mEwX+KZw3uZjlhHHbETUDcxD4vhBFpgr27vOhBA/YJb+FjB3SXIbz9mWbwz0Kuj54mmKlLK7JmfeBeyG9xPJsofTYjtYaYK9Q6qToB65V7TW9JbOETcYleqwFDQIAATQAAAAAgFiEDAAAAAClAAAAAAAAAAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpDAQBCgALAQEODAIDBAUGBwEACAkMCzMACgAAAAEAAAAAwusLAAAAAGQAAAAAAAAAAMgXqAQAAAAAAAAAAAAAAAAAAAAAAAAA//8PAB9Qb3dlcmVkIGJ5IGJsb1hyb3V0ZSBUcmFkZXIgQXBpDAMBAAABCQ==" 16 | testSignedTx = "AqXuZ3cHVI4WE6Kus6iXzBIptXPsqOk5h5umwzWEGQS+iAOgwlGGVPHDsQaVXquB6ddpeIBN9hJhDbeMtCOxKQtbqMO1naqyX2pXt5r/M/lbVG4AJo25JAZOPdxzTR20O9wBPcSo/haHeK/y5d9quE823ycX2P04cfrUFjwhKnkDAgAGEIls26fgpAnCYufUzDrXMMpDjMYkf2Y2FHuxqKE+2+IrqlXf7Qtg+iNNWHLp5EkyLgE6Zs6D0t2ssRfXrXHWFO5qxMPO+p8Zv1TI3A9eTRzu5TJ9JkgrKdKxPLqkNEchjeQTBcZ30N4UFbquVSNklrtTZGoyzqrep6uSS5UUAYeQqUNpofphjJYDGcmbFeqKg3p+Y1sXgGjP6XzrYw7WDPRrEDIxyXUFDOyNpt5ANXybymDvno8zFlolVmVlKoJTO0ZSeUngp6ZZ+KrchrxTzHxCRpoXdlqbrWKxsFvIaLXuyb65sW0YqCc5du+Jt/3oSuybqsoNsXPbj9pK4N5HijSoS7ZGYkZ4HXqa2rhYi6hrKszlE1jIRPVETkBkCHX9WkydmX0uxDvcDSNiac+w0IORr9ED/Y+9Y0U/9YtuI6kgBpuIV/6rgYT7aH9jRhjANdrEOdwa6ztVmKDwAAAAAAEGp9UXGSxcUSGMyUw9SvF/WNruCJuh/UTj29mKAAAAAAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANB1GoKC2mEwX+KZw3uZjlhHHbETUDcxD4vhBFpgr27vOhBA/YJb+FjB3SXIbz9mWbwz0Kuj54mmKlLK7JmfeBeyG9xPJsofTYjtYaYK9Q6qToB65V7TW9JbOETcYleqwFDQIAATQAAAAAgFiEDAAAAAClAAAAAAAAAAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpDAQBCgALAQEODAIDBAUGBwEACAkMCzMACgAAAAEAAAAAwusLAAAAAGQAAAAAAAAAAMgXqAQAAAAAAAAAAAAAAAAAAAAAAAAA//8PAB9Qb3dlcmVkIGJ5IGJsb1hyb3V0ZSBUcmFkZXIgQXBpDAMBAAABCQ==" 17 | ) 18 | 19 | func TestSignTxWithPrivateKey(t *testing.T) { 20 | privateKey, err := solana.PrivateKeyFromBase58(testPrivateKey) 21 | require.Nil(t, err) 22 | 23 | signed, err := SignTxWithPrivateKey(testPartiallySignedTx, privateKey) 24 | require.Nil(t, err) 25 | 26 | assert.Equal(t, testSignedTx, signed) 27 | } 28 | -------------------------------------------------------------------------------- /utils/bundle.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/gagliardetto/solana-go" 5 | "github.com/gagliardetto/solana-go/programs/system" 6 | ) 7 | 8 | const ( 9 | // BloxrouteTipAddress is from here and may fall out of date from time to time. Check our docs: 10 | // https://docs.bloxroute.com/solana/trader-api-v2/front-running-protection-and-transaction-bundle 11 | BloxrouteTipAddress = "HWEoBxYs7ssKuudEjzjmpfJVX7Dvi7wescFsVx2L5yoY" 12 | ) 13 | 14 | // CreateBloxrouteTipTransactionToUseBundles creates a transaction you can use to when using PostSubmitBundle endpoints. 15 | // This transaction should be the LAST transaction in your submission bundle 16 | func CreateBloxrouteTipTransactionToUseBundles(privateKey solana.PrivateKey, tipAmount uint64, recentBlockHash solana.Hash) (*solana.Transaction, error) { 17 | recipient := solana.MustPublicKeyFromBase58(BloxrouteTipAddress) 18 | 19 | tx, err := solana.NewTransaction([]solana.Instruction{ 20 | system.NewTransferInstruction(tipAmount, privateKey.PublicKey(), recipient).Build()}, recentBlockHash) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | signatures, err := tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { 26 | if key.Equals(privateKey.PublicKey()) { 27 | return &privateKey 28 | } 29 | return nil 30 | }) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | tx.Signatures = signatures 36 | 37 | return tx, nil 38 | } 39 | -------------------------------------------------------------------------------- /utils/locked_map.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | cmap "github.com/mhmtszr/concurrent-swiss-map" 5 | ) 6 | 7 | type LockedMap[T comparable, V any] struct { 8 | data *cmap.CsMap[T, V] 9 | } 10 | 11 | func NewLockedMap[T comparable, V any]() *LockedMap[T, V] { 12 | return &LockedMap[T, V]{ 13 | data: cmap.Create[T, V](), 14 | } 15 | } 16 | 17 | func (fs *LockedMap[T, V]) Get(key T) (V, bool) { 18 | return fs.data.Load(key) 19 | } 20 | 21 | // GetOrInsert inserts the entry if the key doesn't exist 22 | func (fs *LockedMap[T, V]) GetOrInsert(key T, f func() V) (out V) { 23 | 24 | fs.data.SetIf(key, func(value V, found bool) (val V, set bool) { 25 | if found { 26 | out = value 27 | set = false 28 | val = value 29 | return 30 | } 31 | 32 | val = f() 33 | out = val 34 | set = true 35 | return 36 | }) 37 | return 38 | } 39 | 40 | func (fs *LockedMap[T, V]) Set(key T, v V) { 41 | fs.data.Store(key, v) 42 | } 43 | 44 | func (fs *LockedMap[T, V]) Delete(key T) { 45 | fs.data.Delete(key) 46 | } 47 | 48 | func (fs *LockedMap[T, V]) DeleteAll() { 49 | fs.data.Clear() 50 | } 51 | 52 | func (fs *LockedMap[T, V]) DeleteWithCondition(cond func(V) bool) { 53 | fs.data.Range(func(key T, value V) (stop bool) { 54 | if cond(value) { 55 | fs.data.Delete(key) 56 | } 57 | return false 58 | }) 59 | } 60 | 61 | func (fs *LockedMap[T, V]) ExistOrAdd(key T, value V) (exists bool) { 62 | fs.data.SetIf(key, func(_ V, found bool) (val V, set bool) { 63 | exists = found 64 | set = !found 65 | val = value 66 | return 67 | }) 68 | return 69 | } 70 | 71 | func (fs *LockedMap[T, V]) Update(key T, update func(value V, exists bool) V) (val V) { 72 | fs.data.SetIf(key, func(valueInMap V, found bool) (valueOut V, set bool) { 73 | val = update(valueInMap, found) 74 | return val, true 75 | }) 76 | return 77 | } 78 | 79 | func (fs *LockedMap[T, V]) Len() int { 80 | return fs.data.Count() 81 | } 82 | 83 | func (fs *LockedMap[T, V]) Keys() []T { 84 | var keys []T 85 | fs.data.Range(func(key T, _ V) (stop bool) { 86 | keys = append(keys, key) 87 | return false 88 | }) 89 | 90 | return keys 91 | } 92 | 93 | func (fs *LockedMap[T, V]) Values() []V { 94 | var values []V 95 | fs.data.Range(func(_ T, value V) (stop bool) { 96 | values = append(values, value) 97 | return false 98 | }) 99 | 100 | return values 101 | } 102 | 103 | func (fs *LockedMap[T, V]) Copy() map[T]V { 104 | m := make(map[T]V) 105 | fs.data.Range(func(key T, value V) (stop bool) { 106 | m[key] = value 107 | return false 108 | }) 109 | 110 | return m 111 | } 112 | -------------------------------------------------------------------------------- /utils/logger.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "github.com/sirupsen/logrus" 4 | 5 | func InitLogger() { 6 | customFormatter := new(logrus.TextFormatter) 7 | customFormatter.TimestampFormat = "2006-01-02 15:04:05" 8 | customFormatter.FullTimestamp = true 9 | logrus.SetFormatter(customFormatter) 10 | } 11 | -------------------------------------------------------------------------------- /utils/proto.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | pb "github.com/bloXroute-Labs/solana-trader-proto/api" 5 | "github.com/gagliardetto/solana-go" 6 | ) 7 | 8 | func ConvertProtoAddressLookupTable(addressLookupTableProto map[string]*pb.PublicKeys) (map[solana.PublicKey]solana.PublicKeySlice, error) { 9 | addressLookupTable := make(map[solana.PublicKey]solana.PublicKeySlice) 10 | 11 | for pk, accounts := range addressLookupTableProto { 12 | solanaPk, err := solana.PublicKeyFromBase58(pk) 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | var solanaPkSlice solana.PublicKeySlice 18 | 19 | for _, acc := range accounts.Pks { 20 | accPk, err := solana.PublicKeyFromBase58(acc) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | solanaPkSlice = append(solanaPkSlice, accPk) 26 | } 27 | 28 | addressLookupTable[solanaPk] = solanaPkSlice 29 | } 30 | 31 | return addressLookupTable, nil 32 | } 33 | 34 | func ConvertJupiterInstructions(instructions []*pb.InstructionJupiter) ([]solana.Instruction, error) { 35 | var solanaInstructions []solana.Instruction 36 | 37 | for _, inst := range instructions { 38 | programID, err := solana.PublicKeyFromBase58(inst.ProgramID) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | var accountMetaSlice solana.AccountMetaSlice 44 | 45 | for _, acc := range inst.Accounts { 46 | programID, err := solana.PublicKeyFromBase58(acc.ProgramID) 47 | if err != nil { 48 | return nil, err 49 | } 50 | accountMetaSlice = append(accountMetaSlice, solana.NewAccountMeta( 51 | programID, acc.IsWritable, acc.IsSigner)) 52 | } 53 | 54 | solanaInstructions = append(solanaInstructions, solana.NewInstruction(programID, accountMetaSlice, inst.Data)) 55 | } 56 | 57 | return solanaInstructions, nil 58 | } 59 | 60 | func ConvertRaydiumInstructions(instructions []*pb.InstructionRaydium) ([]solana.Instruction, error) { 61 | var solanaInstructions []solana.Instruction 62 | 63 | for _, inst := range instructions { 64 | programID, err := solana.PublicKeyFromBase58(inst.ProgramID) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | var accountMetaSlice solana.AccountMetaSlice 70 | 71 | for _, acc := range inst.Accounts { 72 | programID, err := solana.PublicKeyFromBase58(acc.ProgramID) 73 | if err != nil { 74 | return nil, err 75 | } 76 | accountMetaSlice = append(accountMetaSlice, solana.NewAccountMeta( 77 | programID, acc.IsWritable, acc.IsSigner)) 78 | } 79 | 80 | solanaInstructions = append(solanaInstructions, solana.NewInstruction(programID, accountMetaSlice, inst.Data)) 81 | } 82 | 83 | return solanaInstructions, nil 84 | } 85 | -------------------------------------------------------------------------------- /utils/request_id.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | type RequestID struct { 8 | id uint64 9 | lock *sync.Mutex 10 | } 11 | 12 | func NewRequestID() *RequestID { 13 | return &RequestID{ 14 | id: 1, 15 | lock: &sync.Mutex{}, 16 | } 17 | } 18 | 19 | func (r *RequestID) Current() uint64 { 20 | return r.id 21 | } 22 | 23 | func (r *RequestID) Next() uint64 { 24 | r.lock.Lock() 25 | defer r.lock.Unlock() 26 | 27 | val := r.id 28 | r.id++ 29 | return val 30 | } 31 | -------------------------------------------------------------------------------- /utils/timestamp.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "time" 5 | 6 | "google.golang.org/protobuf/types/known/timestamppb" 7 | ) 8 | 9 | // GetTimestamp returns a Protocol Buffer Timestamp object based on the current time. 10 | func GetTimestamp() *timestamppb.Timestamp { 11 | now := time.Now() 12 | ts := timestamppb.New(now) 13 | return ts 14 | } 15 | 16 | // GetRFC3339Timestamp returns the current time as an RFC 3339 formatted string suitable for 17 | // Protocol Buffer Timestamp JSON serialization. 18 | func GetRFC3339Timestamp() string { 19 | now := time.Now().UTC() 20 | formatted := now.Format(time.RFC3339Nano) 21 | return formatted 22 | } 23 | --------------------------------------------------------------------------------