├── tonmetrics ├── oapi-bridge.yaml ├── README.md ├── Makefile └── analytics.go ├── internal ├── v1 │ ├── storage │ │ ├── migrations │ │ │ ├── 0001_create_tables.down.sql │ │ │ ├── 0002_create_tables.down.sql │ │ │ ├── 0001_create_tables.up.sql │ │ │ └── 0002_create_tables.up.sql │ │ ├── storage.go │ │ ├── mem.go │ │ ├── pg_test.go │ │ └── mem_test.go │ └── handler │ │ ├── session_test.go │ │ └── session.go ├── ntp │ ├── interfaces.go │ ├── local.go │ └── client.go ├── version.go ├── handler │ ├── trace.go │ ├── webhook_test.go │ ├── webhook.go │ ├── params.go │ └── params_test.go ├── models │ └── models.go ├── middleware │ └── limiter.go ├── app │ ├── middleware.go │ ├── metrics.go │ └── health.go ├── v3 │ ├── handler │ │ ├── session.go │ │ └── events.go │ └── storage │ │ └── storage.go ├── utils │ ├── http.go │ ├── tls.go │ └── http_test.go ├── storage │ ├── message_cache.go │ └── message_cache_test.go ├── config │ └── config.go └── analytics │ └── collector.go ├── test ├── clustered │ ├── results │ │ ├── cluster-3-nodes │ │ │ ├── test-metadata-write-heavy.txt │ │ │ └── summary-write-heavy.json │ │ ├── cluster-6-nodes │ │ │ ├── test-metadata-write-heavy.txt │ │ │ └── summary-write-heavy.json │ │ └── README.md │ ├── README.md │ ├── Makefile │ ├── run-cluster-3-nodes.sh │ ├── run-cluster-6-nodes.sh │ └── k6 │ │ └── bridge_test.js ├── replica-dead │ ├── Makefile │ ├── README.md │ ├── docker-compose.yml │ └── results │ │ └── summary.json ├── gointegration │ ├── README.md │ ├── analytics_mock.go │ └── bridge_stress_test.go └── unsub-listeners │ ├── docker-compose.yml │ ├── README.md │ └── run-test.sh ├── .github ├── dependabot.yaml └── workflows │ ├── basic.yaml │ ├── codeql.yaml │ ├── lint.yaml │ ├── deploy-image.yml │ └── tests.yaml ├── Dockerfile ├── docker ├── Dockerfile.bridge3 ├── Dockerfile.analytics-mock ├── Dockerfile.benchmark ├── Dockerfile.integration ├── Dockerfile.gointegration ├── README.md ├── dnsmasq.conf ├── nginx.conf ├── docker-compose.memory.yml └── docker-compose.postgres.yml ├── scripts ├── gofmtcheck.sh ├── test-bridge-sdk.sh ├── verify-sharding.sh └── pr-metrics.sh ├── .gitignore ├── docs ├── API.md ├── KNOWN_ISSUES.md ├── DEPLOYMENT.md ├── ARCHITECTURE.md ├── MONITORING.md └── CONFIGURATION.md ├── benchmark ├── README.md └── bridge_test.js ├── go.mod ├── actions └── local │ └── action.yaml ├── README.md ├── cmd ├── analytics-mock │ └── main.go └── bridge │ ├── README.md │ └── main.go └── Makefile /tonmetrics/oapi-bridge.yaml: -------------------------------------------------------------------------------- 1 | package: tonmetrics 2 | generate: 3 | models: true 4 | output: bridge_events.gen.go 5 | -------------------------------------------------------------------------------- /internal/v1/storage/migrations/0001_create_tables.down.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | drop schema if exists bridge; 3 | drop table public.schema_migrations; 4 | COMMIT; -------------------------------------------------------------------------------- /internal/v1/storage/migrations/0002_create_tables.down.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | drop schema if exists bridge cascade; 3 | drop table public.schema_migrations; 4 | COMMIT; -------------------------------------------------------------------------------- /test/clustered/results/cluster-3-nodes/test-metadata-write-heavy.txt: -------------------------------------------------------------------------------- 1 | Test Scenario: write-heavy 2 | Date: Fri Oct 17 11:33:08 CEST 2025 3 | SSE_VUS: 250 4 | SEND_RATE: 7500 5 | TEST_DURATION: 2m 6 | Cluster: 3-node (0.45 CPU total) 7 | -------------------------------------------------------------------------------- /test/clustered/results/cluster-6-nodes/test-metadata-write-heavy.txt: -------------------------------------------------------------------------------- 1 | Test Scenario: write-heavy 2 | Date: Fri Oct 17 11:38:37 CEST 2025 3 | SSE_VUS: 250 4 | SEND_RATE: 7500 5 | TEST_DURATION: 2m 6 | Cluster: 6-node (0.90 CPU total) 7 | -------------------------------------------------------------------------------- /internal/ntp/interfaces.go: -------------------------------------------------------------------------------- 1 | package ntp 2 | 3 | // TimeProvider provides the current time in milliseconds. 4 | // This interface allows using either local time or NTP-synchronized time. 5 | type TimeProvider interface { 6 | NowUnixMilli() int64 7 | } 8 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: / 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: github-actions 8 | directory: / 9 | schedule: 10 | interval: daily -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-alpine AS gobuild 2 | RUN apk add --no-cache make git 3 | WORKDIR /build-dir 4 | COPY go.mod . 5 | COPY go.sum . 6 | RUN go mod download 7 | COPY . . 8 | RUN make build && cp bridge /tmp/bridge 9 | 10 | 11 | FROM alpine:latest AS bridge 12 | COPY --from=gobuild /tmp/bridge /app/bridge 13 | CMD ["/app/bridge"] 14 | -------------------------------------------------------------------------------- /docker/Dockerfile.bridge3: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-alpine AS gobuild 2 | RUN apk add --no-cache make git 3 | WORKDIR /build-dir 4 | COPY go.mod . 5 | COPY go.sum . 6 | RUN go mod download 7 | COPY . . 8 | RUN make build3 && cp bridge3 /tmp/bridge3 9 | 10 | 11 | FROM alpine:latest AS bridge 12 | COPY --from=gobuild /tmp/bridge3 /app/bridge3 13 | CMD ["/app/bridge3"] 14 | -------------------------------------------------------------------------------- /docker/Dockerfile.analytics-mock: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-alpine AS builder 2 | 3 | WORKDIR /app 4 | 5 | # Copy the standalone mock server 6 | COPY cmd/analytics-mock/main.go ./main.go 7 | 8 | # Build the server 9 | RUN go build -o analytics-mock main.go 10 | 11 | FROM alpine:latest 12 | RUN apk --no-cache add ca-certificates curl 13 | WORKDIR /root/ 14 | COPY --from=builder /app/analytics-mock . 15 | EXPOSE 9090 16 | CMD ["./analytics-mock"] 17 | -------------------------------------------------------------------------------- /.github/workflows/basic.yaml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | name: build 12 | runs-on: ubuntu-latest 13 | steps: 14 | 15 | - name: Check out code 16 | uses: actions/checkout@v6 17 | 18 | - name: test buildV1 19 | run: make build 20 | 21 | - name: test buildV3 22 | run: make build3 23 | -------------------------------------------------------------------------------- /internal/v1/storage/migrations/0001_create_tables.up.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | create schema if not exists bridge; 4 | drop table if exists bridge.messages; 5 | create table bridge.messages 6 | ( 7 | client_id text not null, 8 | event_id bigint not null, 9 | end_time timestamp not null, 10 | bridge_message bytea not null 11 | ); 12 | 13 | COMMIT; 14 | -------------------------------------------------------------------------------- /scripts/gofmtcheck.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Check gofmt 4 | echo "==> Checking that code complies with gofmt requirements..." 5 | gofmt_files=$(gofmt -l `find . -name '*.go' | grep -v vendor | grep -v yacc | grep -v .git`) 6 | if [[ -n ${gofmt_files} ]]; then 7 | echo 'gofmt needs running on the following files:' 8 | echo "${gofmt_files}" 9 | echo "You can use the command: \`make fmt\` to reformat code." 10 | exit 1 11 | fi 12 | echo "OK" 13 | 14 | 15 | exit 0 -------------------------------------------------------------------------------- /test/replica-dead/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: up down test status clean 2 | 3 | up: 4 | docker-compose up -d 5 | 6 | test: 7 | docker-compose up benchmark 8 | 9 | down: 10 | docker-compose down 11 | 12 | clean: 13 | docker-compose down -v 14 | 15 | status: 16 | @docker exec valkey-primary valkey-cli INFO replication | grep -E "role|connected_slaves" 17 | @docker exec valkey-replica valkey-cli INFO replication | grep -E "role|master_link_status" 18 | 19 | logs: 20 | docker logs -f bridge 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | /bridge 8 | .vscode/ 9 | vendor/ 10 | .DS_Store 11 | 12 | # Test binary, built with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | 18 | # Dependency directories (remove the comment below to include it) 19 | # vendor/ 20 | 21 | .idea 22 | env.yaml 23 | /bridge 24 | /bridge3 25 | benchmark/k6 26 | bridge-sdk/ 27 | 28 | .env 29 | -------------------------------------------------------------------------------- /docker/Dockerfile.benchmark: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-alpine AS build 2 | RUN apk add --no-cache git build-base \ 3 | && go install go.k6.io/xk6/cmd/xk6@latest \ 4 | && /go/bin/xk6 build --with github.com/phymbert/xk6-sse@latest -o /k6 \ 5 | && go install github.com/google/pprof@latest 6 | 7 | FROM alpine:3.20 8 | RUN apk add --no-cache ca-certificates wget 9 | COPY --from=build /k6 /usr/local/bin/k6 10 | COPY --from=build /go/bin/pprof /usr/local/bin/pprof 11 | WORKDIR /scripts 12 | ENTRYPOINT ["/bin/sh","-lc"] -------------------------------------------------------------------------------- /internal/ntp/local.go: -------------------------------------------------------------------------------- 1 | package ntp 2 | 3 | import "time" 4 | 5 | // LocalTimeProvider provides time based on the local system clock. 6 | type LocalTimeProvider struct{} 7 | 8 | // NewLocalTimeProvider creates a new local time provider. 9 | func NewLocalTimeProvider() *LocalTimeProvider { 10 | return &LocalTimeProvider{} 11 | } 12 | 13 | // NowUnixMilli returns the current local system time in Unix milliseconds. 14 | func (l *LocalTimeProvider) NowUnixMilli() int64 { 15 | return time.Now().UnixMilli() 16 | } 17 | -------------------------------------------------------------------------------- /internal/v1/storage/migrations/0002_create_tables.up.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | create schema if not exists bridge; 4 | drop table if exists bridge.messages; 5 | create table bridge.messages 6 | ( 7 | client_id text not null, 8 | event_id bigint not null, 9 | end_time timestamp not null, 10 | bridge_message bytea not null 11 | ); 12 | 13 | create index messages_client_id_index 14 | on bridge.messages (client_id); 15 | 16 | COMMIT; 17 | -------------------------------------------------------------------------------- /docker/Dockerfile.integration: -------------------------------------------------------------------------------- 1 | # Create a new file: Dockerfile.integration 2 | FROM node:24-alpine 3 | 4 | # Install necessary tools including make 5 | RUN apk add --no-cache \ 6 | make \ 7 | curl \ 8 | wget \ 9 | bash \ 10 | ca-certificates \ 11 | bind-tools \ 12 | go \ 13 | git 14 | 15 | # Set working directory 16 | WORKDIR /app 17 | 18 | # Copy your integration files 19 | COPY . . 20 | 21 | # Make sure any scripts are executable 22 | RUN chmod +x /app/scripts/* || true 23 | 24 | # Default command 25 | CMD ["make", "test-bridge-sdk"] -------------------------------------------------------------------------------- /docker/Dockerfile.gointegration: -------------------------------------------------------------------------------- 1 | # Create a new file: Dockerfile.integration 2 | FROM golang:1.24-alpine 3 | 4 | # Install necessary packages including make and git 5 | RUN apk add --no-cache \ 6 | make \ 7 | bash \ 8 | ca-certificates \ 9 | curl \ 10 | bind-tools 11 | 12 | # Create app directory 13 | WORKDIR /app 14 | 15 | # # Copy only module files and the test folder to keep the image small 16 | # COPY Makefile Makefile 17 | # COPY go.mod go.mod 18 | # COPY go.sum go.sum 19 | # COPY test/ ./test/ 20 | 21 | COPY Makefile go.mod go.sum . 22 | COPY test/ ./test/ 23 | 24 | # Default command - run the Makefile integration target which runs go tests 25 | CMD ["make", "test-gointegration"] -------------------------------------------------------------------------------- /internal/version.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import "fmt" 4 | 5 | const ( 6 | bridgeVersionDefault = "devel" 7 | gitRevisionDefault = "devel" 8 | ) 9 | 10 | var ( 11 | // These variables are here only to show current version. They are set in makefile during build process 12 | BridgeVersion = bridgeVersionDefault 13 | GitRevision = gitRevisionDefault 14 | BridgeVersionRevision = func() string { 15 | version := bridgeVersionDefault 16 | revision := gitRevisionDefault 17 | if BridgeVersion != "" { 18 | version = BridgeVersion 19 | } 20 | if GitRevision != "" { 21 | revision = GitRevision 22 | } 23 | return fmt.Sprintf("%s-%s", version, revision) 24 | }() 25 | ) 26 | -------------------------------------------------------------------------------- /tonmetrics/README.md: -------------------------------------------------------------------------------- 1 | # TON Metrics Code Generation 2 | 3 | This directory owns the Go models used to send TON analytics bridge events. 4 | 5 | ## Prerequisites 6 | - Go 1.24 or newer (for installing `oapi-codegen`) 7 | - `make`, `jq` 8 | 9 | ## Usage 10 | Run `make generate` from this folder to regenerate `bridge_events.gen.go`. The recipe: 11 | 1. Downloads the latest swagger specification from `analytics.ton.org`. 12 | 2. Reduces it to a minimal OpenAPI 3 document that only keeps `Bridge*` schemas via `jq`. 13 | 3. Invokes a pinned version of `oapi-codegen` to emit Go structs into `bridge_events.gen.go`. 14 | 4. Formats the result with `gofmt`. 15 | 16 | Temporary swagger artifacts live under `.tmp/`. 17 | -------------------------------------------------------------------------------- /docs/API.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | This bridge implements the [TON Connect HTTP Bridge Specification](https://github.com/ton-blockchain/ton-connect/blob/main/bridge.md). 4 | 5 | ## Bridge Endpoints 6 | 7 | **Port:** `8081` (default, configurable via `PORT`) 8 | 9 | - `POST /bridge/message` - Send a message to a client 10 | - `GET /bridge/events` - Subscribe to SSE stream for real-time messages 11 | 12 | ## Health & Monitoring Endpoints 13 | 14 | **Port:** `9103` (default, configurable via `METRICS_PORT`) 15 | 16 | - `GET /health` - Basic health check 17 | - `GET /ready` - Readiness check (includes storage connectivity) 18 | - `GET /version` - Bridge version and build information 19 | - `GET /metrics` - Prometheus metrics endpoint 20 | -------------------------------------------------------------------------------- /docs/KNOWN_ISSUES.md: -------------------------------------------------------------------------------- 1 | # Known issues 2 | 3 | ## Buffering 4 | 5 | Some proxies may have default buffering settings that can be overridden, meaning the proxy may also modify headers. 6 | 7 | - [Cloudflare](https://community.cloudflare.com/t/using-server-sent-events-sse-with-cloudflare-proxy/656279) and other CDNs may also utilize this header to optimize performance. 8 | 9 | - Some proxies, like [Nginx](https://nginx.org/en/docs/http/ngx_http_proxy_module.html), may have default buffering settings that can be overridden with this header. 10 | 11 | 12 | To resolve this, ensure you're passing these headers: 13 | ``` 14 | Content-Type: text/event-stream 15 | Cache-Control: private, no-cache, no-transform 16 | X-Accel-Buffering: no 17 | ``` 18 | -------------------------------------------------------------------------------- /internal/handler/trace.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | "github.com/sirupsen/logrus" 6 | ) 7 | 8 | func ParseOrGenerateTraceID(traceIdParam string, ok bool) string { 9 | log := logrus.WithField("prefix", "CreateSession") 10 | traceId := "unknown" 11 | if ok { 12 | uuids, err := uuid.Parse(traceIdParam) 13 | if err != nil { 14 | log.WithFields(logrus.Fields{ 15 | "error": err, 16 | "invalid_trace_id": traceIdParam[0], 17 | }).Warn("generating a new trace_id") 18 | } else { 19 | traceId = uuids.String() 20 | } 21 | } 22 | if traceId == "unknown" { 23 | uuids, err := uuid.NewV7() 24 | if err != nil { 25 | log.Error(err) 26 | } else { 27 | traceId = uuids.String() 28 | } 29 | } 30 | return traceId 31 | } 32 | -------------------------------------------------------------------------------- /benchmark/README.md: -------------------------------------------------------------------------------- 1 | # bridge benchmarks 2 | 3 | This directory contains performance testing tools for the Ton Connect Bridge Server using K6 with SSE support. 4 | 5 | ## 🚀 Quick Start 6 | 7 | 1. **Install K6 with SSE extension:** 8 | ```bash 9 | go install go.k6.io/xk6/cmd/xk6@latest 10 | xk6 build --with github.com/phymbert/xk6-sse@latest 11 | ``` 12 | 13 | 2. **Start the bridge server:** 14 | ```bash 15 | make build 16 | POSTGRES_URI="postgres://bridge_user:bridge_password@localhost:5432/bridge?sslmode=disable" \ 17 | PORT=8081 CORS_ENABLE=true RPS_LIMIT=100 CONNECTIONS_LIMIT=8000 \ 18 | LOG_LEVEL=error ./bridge 19 | ``` 20 | 21 | 3. **Run benchmark:** 22 | ```bash 23 | cd benchmark 24 | ./k6 run bridge_test.js 25 | ``` 26 | 27 | 28 | Happy benchmarking! 🚀 29 | -------------------------------------------------------------------------------- /internal/models/models.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type SseMessage struct { 4 | EventId int64 5 | Message []byte 6 | To string 7 | } 8 | 9 | type BridgeMessage struct { 10 | From string `json:"from"` 11 | Message string `json:"message"` 12 | TraceId string `json:"trace_id"` 13 | BridgeRequestSource string `json:"request_source,omitempty"` // encrypted 14 | BridgeConnectSource BridgeConnectSource `json:"connect_source,omitempty"` 15 | } 16 | 17 | type BridgeRequestSource struct { 18 | Origin string `json:"origin"` 19 | IP string `json:"ip"` 20 | Time string `json:"time"` 21 | UserAgent string `json:"user_agent"` 22 | } 23 | 24 | type BridgeConnectSource struct { 25 | IP string `json:"ip"` 26 | } 27 | -------------------------------------------------------------------------------- /test/gointegration/README.md: -------------------------------------------------------------------------------- 1 | # Go Integration Tests 2 | 3 | End-to-end integration tests for the TON Connect Bridge, written in Go. These tests validate core bridge functionality including SSE connections, message delivery, reconnection scenarios, and stress testing. 4 | 5 | > **Note**: These tests are adapted from the [bridge-sdk test suite](https://github.com/ton-connect/bridge-sdk/tree/main/test), ported from TypeScript to Go to enable native integration testing without external dependencies. 6 | 7 | ## Running Tests 8 | 9 | ```bash 10 | # Run all integration tests 11 | make test-gointegration 12 | 13 | # Run specific test 14 | go test -v -run TestBridge_ConnectAndClose ./test/gointegration/ 15 | 16 | # Run with custom bridge URL 17 | BRIDGE_URL=https://walletbot.me/tonconnect-bridge/bridge go test -v ./test/gointegration/ 18 | ``` 19 | -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | # Docker Configuration Files 2 | 3 | All Docker-related configuration files for development and testing. 4 | 5 | ## Development Environments 6 | 7 | Use these compose files to run the bridge locally with different storage backends: 8 | 9 | - **docker-compose.memory.yml** - In-memory storage (default, no persistence) 10 | - **docker-compose.postgres.yml** - PostgreSQL storage (persistent) 11 | - **docker-compose.valkey.yml** - Valkey (Redis fork) storage 12 | - **docker-compose.sharded-valkey.yml** - Sharded Valkey cluster 13 | - **docker-compose.nginx.yml** - Nginx reverse proxy setup 14 | - **docker-compose.dnsmasq.yml** - DNS configuration for testing 15 | 16 | ### Quick Start 17 | 18 | ```bash 19 | # From project root 20 | make run # Uses memory storage 21 | make STORAGE=postgres run # Uses PostgreSQL storage 22 | 23 | # Or directly with docker-compose 24 | docker-compose -f docker/docker-compose.memory.yml up 25 | ``` 26 | -------------------------------------------------------------------------------- /test/replica-dead/README.md: -------------------------------------------------------------------------------- 1 | # Valkey Failover Testing 2 | 3 | Test Bridge behavior when Valkey primary/replica fail. 4 | 5 | > **Note**: These tests are not running in CI. 6 | 7 | ## Start 8 | 9 | ```bash 10 | cd test/failover 11 | docker-compose up -d 12 | docker-compose up benchmark # runs 5min load test 13 | ``` 14 | 15 | ## Test Primary Failure 16 | 17 | ```bash 18 | # Terminal 1: watch logs 19 | docker logs -f bridge 20 | 21 | # Terminal 2: kill primary during load test 22 | docker stop valkey-primary 23 | ``` 24 | 25 | **Expected**: Bridge will have connection errors until you manually reconnect. 26 | 27 | ## Test Replica Failure 28 | 29 | ```bash 30 | docker stop valkey-replica 31 | ``` 32 | 33 | **Expected**: Primary keeps working, no issues. 34 | 35 | ## Check Status 36 | 37 | ```bash 38 | docker exec valkey-primary valkey-cli INFO replication 39 | docker exec valkey-replica valkey-cli INFO replication 40 | ``` 41 | 42 | ## Cleanup 43 | 44 | ```bash 45 | docker-compose down 46 | ``` 47 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yaml: -------------------------------------------------------------------------------- 1 | name: codeql 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | schedule: 9 | - cron: '30 06 * * 6' 10 | 11 | env: 12 | GO_VERSION: 1.24.6 13 | 14 | jobs: 15 | analyze: 16 | runs-on: ubuntu-latest 17 | permissions: 18 | security-events: write 19 | steps: 20 | - name: Check out code into the Go module directory 21 | uses: actions/checkout@v6 22 | - name: Set up Go 1.x 23 | uses: actions/setup-go@v6 24 | with: 25 | go-version: ${{ env.GO_VERSION }} 26 | - name: Initialize CodeQL 27 | uses: github/codeql-action/init@v4 28 | with: 29 | languages: go 30 | build-mode: manual 31 | - name: BuildV1 32 | run: make build 33 | - name: BuildV3 34 | run: make build3 35 | - name: Perform CodeQL Analysis 36 | uses: github/codeql-action/analyze@v4 37 | with: 38 | category: "/language:go" -------------------------------------------------------------------------------- /internal/v1/storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/ton-connect/bridge/internal/analytics" 8 | "github.com/ton-connect/bridge/internal/config" 9 | "github.com/ton-connect/bridge/internal/models" 10 | common_storage "github.com/ton-connect/bridge/internal/storage" 11 | ) 12 | 13 | var ( 14 | ExpiredCache = common_storage.NewMessageCache(config.Config.EnableExpiredCache, time.Hour) 15 | TransferedCache = common_storage.NewMessageCache(config.Config.EnableTransferedCache, time.Minute) 16 | ) 17 | 18 | type Storage interface { 19 | GetMessages(ctx context.Context, keys []string, lastEventId int64) ([]models.SseMessage, error) 20 | Add(ctx context.Context, mes models.SseMessage, ttl int64) error 21 | HealthCheck() error 22 | } 23 | 24 | func NewStorage(dbURI string, collector analytics.EventCollector, builder analytics.EventBuilder) (Storage, error) { 25 | if dbURI != "" { 26 | return NewPgStorage(dbURI, collector, builder) 27 | } 28 | return NewMemStorage(collector, builder), nil 29 | } 30 | -------------------------------------------------------------------------------- /docker/dnsmasq.conf: -------------------------------------------------------------------------------- 1 | # DNSMasq configuration file 2 | # Set up upstream DNS servers 3 | server=8.8.8.8 4 | server=8.8.4.4 5 | server=1.1.1.1 6 | 7 | # Enable DNS caching 8 | cache-size=1000 9 | 10 | # Set short TTL for local domains (in seconds) 11 | # In production, you might want to set a longer TTL 12 | # THIS VALUES IS FOR TESTING PURPOSES ONLY 13 | local-ttl=1 14 | 15 | # Log queries for debugging 16 | log-queries 17 | 18 | # Expand hostnames from /etc/hosts 19 | expand-hosts 20 | 21 | # Set domain name 22 | domain=local 23 | 24 | # Local DNS entries for your bridge services (using static IPs) 25 | address=/bridge1.local/172.20.0.30 26 | address=/bridge2.local/172.20.0.31 27 | address=/bridge3.local/172.20.0.32 28 | address=/valkey.local/172.20.0.20 29 | address=/dnsmasq.local/172.20.0.10 30 | 31 | # Round-robin DNS for bridge-magic.local using host-record 32 | host-record=bridge-magic.local,172.20.0.30 33 | host-record=bridge-magic.local,172.20.0.31 34 | host-record=bridge-magic.local,172.20.0.32 35 | 36 | # Wildcard entries (optional) 37 | # address=/.test/127.0.0.1 -------------------------------------------------------------------------------- /test/unsub-listeners/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | valkey: 3 | image: valkey/valkey:7.2-alpine 4 | ports: 5 | - "16379:6379" 6 | volumes: 7 | - valkey_data:/data 8 | healthcheck: 9 | test: ["CMD", "valkey-cli", "ping"] 10 | interval: 5s 11 | timeout: 3s 12 | retries: 5 13 | networks: 14 | - test_network 15 | restart: unless-stopped 16 | 17 | bridge: 18 | container_name: bridge-unsub-test 19 | build: 20 | context: ../.. 21 | dockerfile: docker/Dockerfile.bridge3 22 | ports: 23 | - "8081:8081" 24 | environment: 25 | PORT: 8081 26 | STORAGE: "valkey" 27 | VALKEY_URI: "redis://valkey:6379" 28 | CORS_ENABLE: "true" 29 | HEARTBEAT_INTERVAL: 10 30 | RPS_LIMIT: 1000 31 | CONNECTIONS_LIMIT: 200 32 | depends_on: 33 | valkey: 34 | condition: service_healthy 35 | networks: 36 | - test_network 37 | restart: unless-stopped 38 | 39 | 40 | volumes: 41 | valkey_data: 42 | 43 | networks: 44 | test_network: 45 | driver: bridge 46 | -------------------------------------------------------------------------------- /docker/nginx.conf: -------------------------------------------------------------------------------- 1 | upstream bridge_backend { 2 | ip_hash; # This ensures same client goes to same backend 3 | server bridge1:8081; 4 | server bridge2:8081; 5 | server bridge3:8081; 6 | } 7 | 8 | server { 9 | listen 80; 10 | server_name localhost; 11 | 12 | location / { 13 | proxy_pass http://bridge_backend; 14 | proxy_set_header Host $host; 15 | proxy_set_header X-Real-IP $remote_addr; 16 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 17 | proxy_set_header X-Forwarded-Proto $scheme; 18 | 19 | # WebSocket support 20 | proxy_http_version 1.1; 21 | proxy_set_header Upgrade $http_upgrade; 22 | proxy_set_header Connection "upgrade"; 23 | 24 | # Timeout settings 25 | proxy_connect_timeout 60s; 26 | proxy_send_timeout 60s; 27 | proxy_read_timeout 60s; 28 | } 29 | 30 | # Health check endpoint 31 | location /health { 32 | access_log off; 33 | add_header Content-Type text/plain; 34 | return 200 "healthy\n"; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /test/clustered/results/README.md: -------------------------------------------------------------------------------- 1 | # Test Results Directory 2 | 3 | This directory contains all test results from Valkey cluster scalability tests. 4 | 5 | ## Structure 6 | 7 | - `cluster-3-nodes/` - Results from 2-node cluster tests 8 | - `summary-write-heavy.json` - k6 summary for write-heavy scenario 9 | - `summary-read-heavy.json` - k6 summary for read-heavy scenario 10 | - `summary-mixed.json` - k6 summary for mixed scenario 11 | - `test-metadata-*.txt` - Test configuration metadata 12 | 13 | - `cluster-6-nodes/` - Results from 4-node cluster tests 14 | - `summary-write-heavy.json` - k6 summary for write-heavy scenario 15 | - `summary-read-heavy.json` - k6 summary for read-heavy scenario 16 | - `summary-mixed.json` - k6 summary for mixed scenario 17 | - `test-metadata-*.txt` - Test configuration metadata 18 | 19 | - `charts/` - Generated comparison charts (PNG format) 20 | - `p95_latency_comparison.png` 21 | - `error_rate_comparison.png` 22 | - `delivery_latency_comparison.png` 23 | 24 | - `bridge-stats/` - Bridge resource usage data 25 | - Docker stats snapshots 26 | - CPU/memory usage logs 27 | 28 | ## Usage 29 | 30 | Results are automatically saved here when running tests via: 31 | - `make test-3node-*` 32 | - `make test-6node-*` 33 | 34 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | fmtcheck: 14 | name: go fmt check 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v6 18 | - uses: actions/setup-go@v6 19 | with: 20 | go-version: '1.24.6' 21 | cache: false 22 | - name: go fmt check 23 | run: make fmtcheck 24 | 25 | golangci: 26 | name: golangci-lint 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v6 30 | - uses: actions/setup-go@v6 31 | with: 32 | go-version: '1.24.6' 33 | cache: false 34 | - name: golangci-lint 35 | uses: golangci/golangci-lint-action@v9 36 | with: 37 | version: latest 38 | args: --timeout=10m --color=always 39 | 40 | goimports: 41 | runs-on: ubuntu-latest 42 | name: goimports check 43 | steps: 44 | - name: checkout 45 | uses: actions/checkout@v6 46 | - name: goimports check 47 | uses: DarthBenro008/goimports-check-action@v0.2.0 48 | with: 49 | root-path: './' # The relative root path to run goimports 50 | excludes: "./yacc/" -------------------------------------------------------------------------------- /test/clustered/README.md: -------------------------------------------------------------------------------- 1 | # Valkey Cluster Scalability Tests 2 | 3 | Find the breaking point where the 3-node cluster experiences performance degradation (high latency, errors, or dropped messages) while the 6-node cluster continues to handle the same workload successfully. This demonstrates the practical value of horizontal scaling. 4 | 5 | > **Note**: These tests are not running in CI. 6 | 7 | ## Architecture 8 | 9 | Both clusters use Valkey 7.2 in cluster mode: 10 | 11 | ### 3-Node Cluster 12 | - **3 shards** × 0.05 CPU = **0.45 CPU total** 13 | - No replicas (master-only configuration) 14 | - 256MB memory per shard 15 | - Network: `172.21.0.0/16` 16 | 17 | ### 6-Node Cluster 18 | - **6 shards** × 0.05 CPU = **0.90 CPU total** 19 | - No replicas (master-only configuration) 20 | - 256MB memory per shard 21 | - Network: `172.22.0.0/16` with automatic slot distribution. 22 | 23 | 24 | ## Start 25 | 26 | ```bash 27 | # Run a specific scenario on 3-node cluster 28 | make test-3node-write-heavy 29 | make test-3node-read-heavy 30 | make test-3node-mixed 31 | 32 | # Run a specific scenario on 6-node cluster 33 | make test-6node-write-heavy 34 | make test-6node-read-heavy 35 | make test-6node-mixed 36 | ``` 37 | 38 | Or use the shell scripts directly: 39 | 40 | ```bash 41 | ./run-cluster-3-nodes.sh mixed 42 | ./run-cluster-6-nodes.sh write-heavy 43 | ``` 44 | -------------------------------------------------------------------------------- /test/clustered/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help test-3node-write-heavy test-3node-read-heavy test-3node-mixed \ 2 | test-6node-write-heavy test-6node-read-heavy test-6node-mixed 3 | 4 | help: 5 | @echo "Valkey Cluster Scalability Tests" 6 | @echo "" 7 | @echo "Available targets:" 8 | @echo "" 9 | @echo "3-Node Cluster Tests (0.45 CPU total):" 10 | @echo " test-3node-write-heavy - Run write-heavy test on 3-node cluster" 11 | @echo " test-3node-read-heavy - Run read-heavy test on 3-node cluster" 12 | @echo " test-3node-mixed - Run mixed test on 3-node cluster" 13 | @echo "" 14 | @echo "6-Node Cluster Tests (0.90 CPU total):" 15 | @echo " test-6node-write-heavy - Run write-heavy test on 6-node cluster" 16 | @echo " test-6node-read-heavy - Run read-heavy test on 6-node cluster" 17 | @echo " test-6node-mixed - Run mixed test on 6-node cluster" 18 | @echo "" 19 | 20 | # 3-Node Cluster Tests 21 | test-3node-write-heavy: 22 | @./run-cluster-3-nodes.sh write-heavy 23 | 24 | test-3node-read-heavy: 25 | @./run-cluster-3-nodes.sh read-heavy 26 | 27 | test-3node-mixed: 28 | @./run-cluster-3-nodes.sh mixed 29 | 30 | # 6-Node Cluster Tests 31 | test-6node-write-heavy: 32 | @./run-cluster-6-nodes.sh write-heavy 33 | 34 | test-6node-read-heavy: 35 | @./run-cluster-6-nodes.sh read-heavy 36 | 37 | test-6node-mixed: 38 | @./run-cluster-6-nodes.sh mixed 39 | -------------------------------------------------------------------------------- /tonmetrics/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: generate clean tools FORCE 2 | 3 | TOOLS_DIR := $(shell go env GOPATH)/bin 4 | OAPI := $(TOOLS_DIR)/oapi-codegen 5 | OAPI_PKG := github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.4.1 6 | SPEC_URL := https://analytics.ton.org/swagger/doc.json 7 | SPEC_RAW := $(CURDIR)/swagger-tonconnect.json 8 | SPEC_BRIDGE := $(CURDIR)/swagger-tonconnect-bridge.json 9 | CONFIG := $(CURDIR)/oapi-bridge.yaml 10 | GEN_FILE := $(CURDIR)/bridge_events.gen.go 11 | 12 | $(CURDIR): 13 | mkdir -p $@ 14 | 15 | $(SPEC_RAW): | $(CURDIR) 16 | curl -sS $(SPEC_URL) -o $@ 17 | 18 | FORCE: 19 | 20 | $(SPEC_BRIDGE): $(SPEC_RAW) FORCE 21 | @command -v jq >/dev/null || (echo "jq is required" >&2 && exit 1) 22 | jq 'def bridge_defs: (.definitions | with_entries(select(.key | startswith("Bridge")))); \ 23 | def dummy_paths($$defs): (reduce ($$defs | keys[]) as $$name ({}; .["/dummy/\($$name)"] = {post: {requestBody: {content: {"application/json": {schema: {"$$ref": "#/components/schemas/\($$name)"}}}}, responses: {"204": {description: "stub"}}}})); \ 24 | bridge_defs as $$defs | \ 25 | {openapi: "3.0.0", info: {title: (.info.title // "TON Analytics"), version: (.info.version // "1.0.0")}, components: {schemas: $$defs}, paths: dummy_paths($$defs)}' $< > $@ 26 | 27 | $(OAPI): 28 | GO111MODULE=on go install $(OAPI_PKG) 29 | 30 | tools: $(OAPI) 31 | 32 | generate: tools $(SPEC_BRIDGE) 33 | $(OAPI) -generate models -config $(CONFIG) $(SPEC_BRIDGE) 34 | gofmt -w $(GEN_FILE) 35 | -------------------------------------------------------------------------------- /internal/handler/webhook_test.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "reflect" 9 | "sync" 10 | "testing" 11 | 12 | "github.com/ton-connect/bridge/internal/config" 13 | ) 14 | 15 | func TestSendWebhook(t *testing.T) { 16 | var wg sync.WaitGroup 17 | wg.Add(2) 18 | 19 | urls := make(chan string, 2) 20 | 21 | handlerFunc := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 22 | defer wg.Done() 23 | body, err := io.ReadAll(r.Body) 24 | if err != nil { 25 | t.Fatal(err) 26 | } 27 | urls <- r.URL.String() 28 | if string(body) != `{"topic":"test","hash":"test-hash"}` { 29 | t.Fatalf("bad body: %s", body) 30 | } 31 | w.WriteHeader(http.StatusOK) 32 | }) 33 | hook1 := httptest.NewServer(handlerFunc) 34 | hook2 := httptest.NewServer(handlerFunc) 35 | defer hook1.Close() 36 | defer hook2.Close() 37 | 38 | data := WebhookData{ 39 | Topic: "test", 40 | Hash: "test-hash", 41 | } 42 | config.Config.WebhookURL = fmt.Sprintf("%s/webhook,%s/callback", hook1.URL, hook2.URL) 43 | 44 | SendWebhook("SOME-CLIENT-ID", data) 45 | wg.Wait() 46 | close(urls) 47 | 48 | calledUrls := make(map[string]struct{}) 49 | for url := range urls { 50 | calledUrls[url] = struct{}{} 51 | } 52 | expected := map[string]struct{}{ 53 | "/webhook/SOME-CLIENT-ID": {}, 54 | "/callback/SOME-CLIENT-ID": {}, 55 | } 56 | if !reflect.DeepEqual(calledUrls, expected) { 57 | t.Fatalf("bad urls: %v", calledUrls) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /internal/middleware/limiter.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "fmt" 5 | "github.com/ton-connect/bridge/internal/utils" 6 | "net/http" 7 | "sync" 8 | ) 9 | 10 | // ConnectionsLimiter is a middleware that limits the number of simultaneous connections per IP. 11 | type ConnectionsLimiter struct { 12 | mu sync.Mutex 13 | connections map[string]int 14 | max int 15 | realIP *utils.RealIPExtractor 16 | } 17 | 18 | func NewConnectionLimiter(i int, extractor *utils.RealIPExtractor) *ConnectionsLimiter { 19 | return &ConnectionsLimiter{ 20 | connections: map[string]int{}, 21 | max: i, 22 | realIP: extractor, 23 | } 24 | } 25 | 26 | // leaseConnection increases a number of connections per given token and 27 | // returns a release function to be called once a request is finished. 28 | // If the token reaches the limit of max simultaneous connections, leaseConnection returns an error. 29 | func (auth *ConnectionsLimiter) LeaseConnection(request *http.Request) (release func(), err error) { 30 | key := fmt.Sprintf("ip-%v", auth.realIP.Extract(request)) 31 | auth.mu.Lock() 32 | defer auth.mu.Unlock() 33 | 34 | if auth.connections[key] >= auth.max { 35 | return nil, fmt.Errorf("you have reached the limit of streaming connections: %v max", auth.max) 36 | } 37 | auth.connections[key] += 1 38 | 39 | return func() { 40 | auth.mu.Lock() 41 | defer auth.mu.Unlock() 42 | auth.connections[key] -= 1 43 | if auth.connections[key] == 0 { 44 | delete(auth.connections, key) 45 | } 46 | }, nil 47 | } 48 | -------------------------------------------------------------------------------- /internal/app/middleware.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "github.com/labstack/echo/v4" 8 | "github.com/ton-connect/bridge/internal/config" 9 | bridge_middleware "github.com/ton-connect/bridge/internal/middleware" 10 | "github.com/ton-connect/bridge/internal/utils" 11 | "golang.org/x/exp/slices" 12 | ) 13 | 14 | // SkipRateLimitsByToken checks if the request should bypass rate limits based on bearer token 15 | func SkipRateLimitsByToken(request *http.Request) bool { 16 | if request == nil { 17 | return false 18 | } 19 | authorization := request.Header.Get("Authorization") 20 | if authorization == "" { 21 | return false 22 | } 23 | token := strings.TrimPrefix(authorization, "Bearer ") 24 | exist := slices.Contains(config.Config.RateLimitsByPassToken, token) 25 | if exist { 26 | TokenUsageMetric.WithLabelValues(token).Inc() 27 | return true 28 | } 29 | return false 30 | } 31 | 32 | // ConnectionsLimitMiddleware creates middleware for limiting concurrent connections 33 | func ConnectionsLimitMiddleware(counter *bridge_middleware.ConnectionsLimiter, skipper func(c echo.Context) bool) echo.MiddlewareFunc { 34 | return func(next echo.HandlerFunc) echo.HandlerFunc { 35 | return func(c echo.Context) error { 36 | if skipper(c) { 37 | return next(c) 38 | } 39 | release, err := counter.LeaseConnection(c.Request()) 40 | if err != nil { 41 | return c.JSON(utils.HttpResError(err.Error(), http.StatusTooManyRequests)) 42 | } 43 | defer release() 44 | return next(c) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /internal/app/metrics.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | client_prometheus "github.com/prometheus/client_golang/prometheus" 5 | "github.com/prometheus/client_golang/prometheus/promauto" 6 | "github.com/ton-connect/bridge/internal" 7 | ) 8 | 9 | var ( 10 | TokenUsageMetric = promauto.NewCounterVec(client_prometheus.CounterOpts{ 11 | Name: "bridge_token_usage", 12 | }, []string{"token"}) 13 | 14 | HealthMetric = client_prometheus.NewGauge(client_prometheus.GaugeOpts{ 15 | Name: "bridge_health_status", 16 | Help: "Health status of the bridge (1 = healthy, 0 = unhealthy)", 17 | }) 18 | 19 | ReadyMetric = client_prometheus.NewGauge(client_prometheus.GaugeOpts{ 20 | Name: "bridge_ready_status", 21 | Help: "Ready status of the bridge (1 = ready, 0 = not ready)", 22 | }) 23 | 24 | BridgeInfoMetric = client_prometheus.NewGaugeVec(client_prometheus.GaugeOpts{ 25 | Name: "bridge_info", 26 | Help: "Bridge information with engine type, version, and storage backend", 27 | }, []string{"engine", "version", "storage"}) 28 | ) 29 | 30 | // InitMetrics registers all Prometheus metrics and sets version info 31 | func InitMetrics() { 32 | client_prometheus.MustRegister(HealthMetric) 33 | client_prometheus.MustRegister(ReadyMetric) 34 | client_prometheus.MustRegister(BridgeInfoMetric) 35 | } 36 | 37 | // SetBridgeInfo sets the bridge_info metric with engine, version, and storage labels 38 | func SetBridgeInfo(engine, storage string) { 39 | BridgeInfoMetric.With(client_prometheus.Labels{ 40 | "engine": engine, 41 | "version": internal.BridgeVersionRevision, 42 | "storage": storage, 43 | }).Set(1) 44 | } 45 | -------------------------------------------------------------------------------- /internal/v1/handler/session_test.go: -------------------------------------------------------------------------------- 1 | package handlerv1 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "sync" 7 | "testing" 8 | "time" 9 | 10 | "github.com/ton-connect/bridge/internal/models" 11 | ) 12 | 13 | // mockDB implements the db interface for testing 14 | type mockDB struct{} 15 | 16 | func (m *mockDB) GetMessages(ctx context.Context, clientIds []string, lastEventId int64) ([]models.SseMessage, error) { 17 | // Return empty slice for testing 18 | return []models.SseMessage{}, nil 19 | } 20 | 21 | func (m *mockDB) Add(ctx context.Context, mes models.SseMessage, ttl int64) error { 22 | // Mock implementation - just return nil 23 | return nil 24 | } 25 | 26 | func (m *mockDB) HealthCheck() error { 27 | return nil // Always healthy 28 | } 29 | 30 | // TestMultipleRuns runs the panic test multiple times to increase chances of hitting the race condition 31 | func TestMultipleRuns(t *testing.T) { 32 | runs := 10 // Reduced runs for faster testing 33 | 34 | var wg sync.WaitGroup 35 | 36 | for i := 0; i < runs; i++ { 37 | log.Print("TestMultipleRuns run", i) 38 | 39 | wg.Add(1) 40 | go func(runNum int) { 41 | defer wg.Done() 42 | 43 | mockDb := &mockDB{} 44 | session := NewSession(mockDb, []string{"client1"}, 0) 45 | 46 | heartbeatInterval := 1 * time.Microsecond 47 | 48 | session.Start("heartbeat", false, heartbeatInterval) 49 | 50 | // Random small delay to vary timing 51 | time.Sleep(5 * time.Microsecond) 52 | 53 | close(session.Closer) 54 | time.Sleep(200 * time.Microsecond) 55 | }(i) 56 | } 57 | 58 | // Wait for all goroutines to complete 59 | wg.Wait() 60 | } 61 | -------------------------------------------------------------------------------- /internal/handler/webhook.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "strings" 9 | 10 | log "github.com/sirupsen/logrus" 11 | "github.com/ton-connect/bridge/internal/config" 12 | ) 13 | 14 | type WebhookData struct { 15 | Topic string `json:"topic"` 16 | Hash string `json:"hash"` 17 | } 18 | 19 | func SendWebhook(clientID string, body WebhookData) { 20 | if config.Config.WebhookURL == "" { 21 | return 22 | } 23 | webhooks := strings.Split(config.Config.WebhookURL, ",") 24 | for _, webhook := range webhooks { 25 | go func(webhook string) { 26 | err := sendWebhook(clientID, body, webhook) 27 | if err != nil { 28 | log.Errorf("failed to trigger webhook '%s': %v", webhook, err) 29 | } 30 | }(webhook) 31 | } 32 | } 33 | 34 | func sendWebhook(clientID string, body WebhookData, webhook string) error { 35 | postBody, err := json.Marshal(body) 36 | if err != nil { 37 | return fmt.Errorf("failed to marshal body: %w", err) 38 | } 39 | req, err := http.NewRequest(http.MethodPost, webhook+"/"+clientID, bytes.NewReader(postBody)) 40 | if err != nil { 41 | return fmt.Errorf("failed to init request: %w", err) 42 | } 43 | req.Header.Set("Content-Type", "application/json") 44 | res, err := http.DefaultClient.Do(req) 45 | if err != nil { 46 | return fmt.Errorf("failed send request: %w", err) 47 | } 48 | defer func() { 49 | if closeErr := res.Body.Close(); closeErr != nil { 50 | log.Errorf("failed to close response body: %v", closeErr) 51 | } 52 | }() 53 | if res.StatusCode != http.StatusOK { 54 | return fmt.Errorf("bad status code: %v", res.StatusCode) 55 | } 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /internal/v3/handler/session.go: -------------------------------------------------------------------------------- 1 | package handlerv3 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | log "github.com/sirupsen/logrus" 8 | "github.com/ton-connect/bridge/internal/models" 9 | "github.com/ton-connect/bridge/internal/v3/storage" 10 | ) 11 | 12 | type Session struct { 13 | mux sync.RWMutex 14 | ClientIds []string 15 | storage storagev3.Storage 16 | messageCh chan models.SseMessage 17 | Closer chan interface{} 18 | lastEventId int64 19 | } 20 | 21 | func NewSession(s storagev3.Storage, clientIds []string, lastEventId int64) *Session { 22 | session := Session{ 23 | mux: sync.RWMutex{}, 24 | ClientIds: clientIds, 25 | storage: s, 26 | messageCh: make(chan models.SseMessage, 100), 27 | Closer: make(chan interface{}), 28 | lastEventId: lastEventId, 29 | } 30 | return &session 31 | } 32 | 33 | // GetMessages returns the read-only channel for receiving messages 34 | func (s *Session) GetMessages() <-chan models.SseMessage { 35 | return s.messageCh 36 | } 37 | 38 | // Close stops the session and cleans up resources 39 | func (s *Session) Close() { 40 | log := log.WithField("prefix", "Session.Close") 41 | s.mux.Lock() 42 | defer s.mux.Unlock() 43 | 44 | err := s.storage.Unsub(context.Background(), s.ClientIds, s.messageCh) 45 | if err != nil { 46 | log.Errorf("failed to unsubscribe from storage: %v", err) 47 | } 48 | 49 | close(s.Closer) 50 | close(s.messageCh) 51 | } 52 | 53 | // Start begins the session by subscribing to storage 54 | func (s *Session) Start() { 55 | s.mux.Lock() 56 | defer s.mux.Unlock() 57 | 58 | err := s.storage.Sub(context.Background(), s.ClientIds, s.lastEventId, s.messageCh) 59 | if err != nil { 60 | close(s.messageCh) 61 | return 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /internal/v3/storage/storage.go: -------------------------------------------------------------------------------- 1 | package storagev3 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/ton-connect/bridge/internal/analytics" 9 | "github.com/ton-connect/bridge/internal/config" 10 | "github.com/ton-connect/bridge/internal/models" 11 | common_storage "github.com/ton-connect/bridge/internal/storage" 12 | ) 13 | 14 | var ( 15 | ExpiredCache = common_storage.NewMessageCache(config.Config.EnableExpiredCache, time.Hour) 16 | // TransferedCache = common_storage.NewMessageCache(config.Config.EnableTransferedCache, time.Minute) 17 | ) 18 | 19 | // ConnectionInfo represents connection metadata for verification 20 | type ConnectionInfo struct { 21 | ClientID string 22 | IP string 23 | Origin string 24 | UserAgent string 25 | } 26 | 27 | type Storage interface { 28 | Pub(ctx context.Context, message models.SseMessage, ttl int64) error 29 | Sub(ctx context.Context, keys []string, lastEventId int64, messageCh chan<- models.SseMessage) error 30 | Unsub(ctx context.Context, keys []string, messageCh chan<- models.SseMessage) error 31 | 32 | // Connection verification methods 33 | AddConnection(ctx context.Context, conn ConnectionInfo, ttl time.Duration) error 34 | VerifyConnection(ctx context.Context, conn ConnectionInfo) (string, error) 35 | 36 | HealthCheck() error 37 | } 38 | 39 | func NewStorage(storageType string, uri string, collector analytics.EventCollector, builder analytics.EventBuilder) (Storage, error) { 40 | switch storageType { 41 | case "valkey", "redis": 42 | return NewValkeyStorage(uri) 43 | case "postgres": 44 | return nil, fmt.Errorf("postgres storage does not support pub-sub functionality yet") 45 | case "memory": 46 | return NewMemStorage(collector, builder), nil 47 | default: 48 | return nil, fmt.Errorf("unsupported storage type: %s", storageType) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /scripts/test-bridge-sdk.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Script to test the bridge against ton-connect/bridge-sdk 4 | set -e 5 | 6 | BRIDGE_URL=${BRIDGE_URL:-"http://localhost:8080/bridge"} 7 | BRIDGE_SDK_DIR="bridge-sdk" 8 | BRIDGE_SDK_REPO="https://github.com/ton-connect/bridge-sdk.git" 9 | 10 | echo "🚀 Starting bridge-sdk tests..." 11 | echo "Bridge URL: $BRIDGE_URL" 12 | 13 | # Check if Node.js and npm are available 14 | if ! command -v node &> /dev/null; then 15 | echo "❌ Node.js is not installed. Please install Node.js to run bridge-sdk tests." 16 | exit 1 17 | fi 18 | 19 | if ! command -v npm &> /dev/null; then 20 | echo "❌ npm is not installed. Please install npm to run bridge-sdk tests." 21 | exit 1 22 | fi 23 | 24 | # Clone or update bridge-sdk repository 25 | if [ ! -d "$BRIDGE_SDK_DIR" ]; then 26 | echo "📦 Cloning bridge-sdk repository..." 27 | git clone "$BRIDGE_SDK_REPO" "$BRIDGE_SDK_DIR" 28 | else 29 | echo "📦 Updating bridge-sdk repository..." 30 | cd "$BRIDGE_SDK_DIR" 31 | git pull origin main || git pull origin master 32 | cd .. 33 | fi 34 | 35 | # Install dependencies 36 | echo "📋 Installing bridge-sdk dependencies..." 37 | cd "$BRIDGE_SDK_DIR" 38 | 39 | # Workaround for Rollup optional dependencies issue in Docker environments 40 | # Remove package-lock.json and node_modules as suggested by the error message 41 | echo "🧹 Cleaning npm artifacts to resolve optional dependencies issue..." 42 | rm -rf package-lock.json node_modules 43 | 44 | echo "📦 Installing dependencies with clean state..." 45 | npm install 46 | 47 | # Force Rollup to use JavaScript fallback instead of native binaries 48 | export ROLLUP_NO_BUNDLER_WORKER=1 49 | 50 | # Run tests 51 | echo "🧪 Running bridge-sdk tests..." 52 | BRIDGE_URL="$BRIDGE_URL" npx vitest run gateway provider 53 | 54 | echo "✅ Bridge-sdk tests completed successfully!" 55 | -------------------------------------------------------------------------------- /test/unsub-listeners/README.md: -------------------------------------------------------------------------------- 1 | # Unsub Listeners Bug Test 2 | 3 | > **Note**: These tests are not running in CI. 4 | 5 | ## Bug 6 | 7 | When multiple SSE connections are open for the same `client_id`, closing one connection unsubscribes ALL listeners. This causes remaining open connections to stop receiving messages. 8 | 9 | **Expected:** Only the closed connection should be unsubscribed. Other connections for the same `client_id` should continue receiving messages. 10 | 11 | ## Reproduction Steps 12 | 13 | 1. Open 2 SSE connections for the same client_id: 14 | 15 | ``` 16 | # Terminal 1curl -s "http://localhost:8081/bridge/events?client_id=0000000000000000000000000000000000000000000000000000000000000001"# Terminal 2  curl -s "http://localhost:8081/bridge/events?client_id=0000000000000000000000000000000000000000000000000000000000000001" 17 | ``` 18 | 19 | 2. Send a message: 20 | 21 | ``` 22 | curl -X POST "http://localhost:8081/bridge/message?client_id=sender&to=0000000000000000000000000000000000000000000000000000000000000001&ttl=300&topic=test" -d "message $(date +%s)" 23 | ``` 24 | 25 | Both terminals receive the message 26 | 27 | 3. Close Terminal 1 (triggers Unsub) 28 | 4. Send another message: 29 | 30 | ``` 31 | curl -X POST "http://localhost:8081/bridge/message?client_id=sender&to=0000000000000000000000000000000000000000000000000000000000000001&ttl=300&topic=test" -d "message $(date +%s)" 32 | ``` 33 | 34 | How it was before: Terminal 2 does NOT receive the message. 35 | How is it now: Terminal 2 receives the message. 36 | 37 | ## How to Run Test 38 | 39 | **Prerequisites:** Docker, Docker Compose, ports 8081 and 16379 available. 40 | 41 | 1. Start the services: 42 | ```bash 43 | cd test/unsub-listeners 44 | docker-compose up -d 45 | ``` 46 | 47 | 2. Wait for Bridge to be ready (check logs or visit http://localhost:8081/health) 48 | 49 | 3. Run the test: 50 | ```bash 51 | ./run-test.sh 52 | ``` 53 | 54 | 4. Clean up when done: 55 | ```bash 56 | docker-compose down -v 57 | ``` 58 | -------------------------------------------------------------------------------- /scripts/verify-sharding.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Verification script for Valkey sharding setup 3 | 4 | echo "=== Verifying Data Distribution ===" 5 | 6 | # Check cluster status 7 | echo "Cluster Info:" 8 | docker exec bridge3-valkey-shard1-1 valkey-cli -c cluster info 9 | 10 | echo -e "\nCluster Nodes:" 11 | docker exec bridge3-valkey-shard1-1 valkey-cli -c cluster nodes 12 | 13 | # Check key distribution 14 | echo -e "\n=== Key Distribution ===" 15 | echo "Shard 1 keys:" 16 | docker exec bridge3-valkey-shard1-1 valkey-cli dbsize 17 | 18 | echo "Shard 2 keys:" 19 | docker exec bridge3-valkey-shard2-1 valkey-cli -p 6380 dbsize 20 | 21 | echo "Shard 3 keys:" 22 | docker exec bridge3-valkey-shard3-1 valkey-cli -p 6381 dbsize 23 | 24 | # Sample some keys to see which shard they're on 25 | echo -e "\n=== Sample Key Locations ===" 26 | for i in {1..10}; do 27 | key="test:key:$i" 28 | slot=$(docker exec bridge3-valkey-shard1-1 valkey-cli -c cluster keyslot $key) 29 | node=$(docker exec bridge3-valkey-shard1-1 valkey-cli -c cluster nodes | grep $slot | head -1) 30 | echo "Key '$key' -> Slot $slot" 31 | done 32 | 33 | # Check memory usage on each shard 34 | echo -e "\n=== Memory Usage ===" 35 | echo "Shard 1 memory:" 36 | docker exec bridge3-valkey-shard1-1 valkey-cli info memory | grep used_memory_human 37 | 38 | echo "Shard 2 memory:" 39 | docker exec bridge3-valkey-shard2-1 valkey-cli -p 6380 info memory | grep used_memory_human 40 | 41 | echo "Shard 3 memory:" 42 | docker exec bridge3-valkey-shard3-1 valkey-cli -p 6381 info memory | grep used_memory_human 43 | 44 | echo -e "\n=== Hash Slot Distribution ===" 45 | echo "Shard 1 slots:" 46 | docker exec bridge3-valkey-shard1-1 valkey-cli -c cluster nodes | grep "172.20.0.20:6379" | awk '{print $9}' 47 | 48 | echo "Shard 2 slots:" 49 | docker exec bridge3-valkey-shard1-1 valkey-cli -c cluster nodes | grep "172.20.0.21:6380" | awk '{print $9}' 50 | 51 | echo "Shard 3 slots:" 52 | docker exec bridge3-valkey-shard1-1 valkey-cli -c cluster nodes | grep "172.20.0.22:6381" | awk '{print $9}' 53 | -------------------------------------------------------------------------------- /test/unsub-listeners/run-test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | CLIENT_ID="0000000000000000000000000000000000000000000000000000000000000001" 5 | BRIDGE_URL="http://localhost:8081" 6 | 7 | CONN1_LOG="/tmp/sse_conn1_$$.log" 8 | CONN2_LOG="/tmp/sse_conn2_$$.log" 9 | 10 | cleanup() { 11 | echo "Cleaning up..." 12 | [ -n "${PID1:-}" ] && kill "$PID1" 2>/dev/null || true 13 | [ -n "${PID2:-}" ] && kill "$PID2" 2>/dev/null || true 14 | rm -f "$CONN1_LOG" "$CONN2_LOG" 15 | } 16 | 17 | trap cleanup EXIT INT TERM 18 | 19 | echo "Step 1: Opening SSE connection 1..." 20 | curl -sN "$BRIDGE_URL/bridge/events?client_id=$CLIENT_ID" > "$CONN1_LOG" 2>&1 & 21 | PID1=$! 22 | sleep 1 23 | 24 | echo "Step 2: Opening SSE connection 2..." 25 | curl -sN "$BRIDGE_URL/bridge/events?client_id=$CLIENT_ID" > "$CONN2_LOG" 2>&1 & 26 | PID2=$! 27 | sleep 2 28 | 29 | echo "Step 3: Sending first message..." 30 | MSG1="message1_$(date +%s)" 31 | curl -sX POST "$BRIDGE_URL/bridge/message?client_id=sender&to=$CLIENT_ID&ttl=300&topic=test" -d "$MSG1" 32 | sleep 2 33 | 34 | echo "Step 4: Checking both connections received message 1..." 35 | if ! grep -q "$MSG1" "$CONN1_LOG"; then 36 | echo "FAIL: Connection 1 did not receive message 1" 37 | exit 1 38 | fi 39 | echo "Connection 1 received message 1" 40 | 41 | if ! grep -q "$MSG1" "$CONN2_LOG"; then 42 | echo "FAIL: Connection 2 did not receive message 1" 43 | exit 1 44 | fi 45 | echo "Connection 2 received message 1" 46 | 47 | echo "Step 5: Closing connection 1..." 48 | kill "$PID1" 49 | wait "$PID1" 2>/dev/null || true 50 | sleep 2 51 | 52 | echo "Step 6: Sending second message..." 53 | MSG2="message2_$(date +%s)" 54 | curl -sX POST "$BRIDGE_URL/bridge/message?client_id=sender&to=$CLIENT_ID&ttl=300&topic=test" -d "$MSG2" 55 | sleep 2 56 | 57 | echo "Step 7: Checking connection 2 still receives messages..." 58 | if ! grep -q "$MSG2" "$CONN2_LOG"; then 59 | echo "FAIL: Connection 2 did not receive message 2" 60 | echo "Bug still exists: closing one connection unsubscribed all listeners" 61 | exit 1 62 | fi 63 | 64 | echo "Connection 2 received message 2" 65 | echo "PASS: Bug is fixed" 66 | exit 0 67 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ton-connect/bridge 2 | 3 | go 1.24.6 4 | 5 | require ( 6 | github.com/beevik/ntp v1.5.0 7 | github.com/caarlos0/env/v6 v6.10.1 8 | github.com/golang-migrate/migrate/v4 v4.19.0 9 | github.com/google/uuid v1.6.0 10 | github.com/jackc/pgx/v4 v4.18.3 11 | github.com/labstack/echo-contrib v0.17.4 12 | github.com/labstack/echo/v4 v4.13.4 13 | github.com/prometheus/client_golang v1.23.2 14 | github.com/realclientip/realclientip-go v1.0.0 15 | github.com/redis/go-redis/v9 v9.16.0 16 | github.com/sirupsen/logrus v1.9.3 17 | golang.org/x/crypto v0.45.0 18 | golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df 19 | golang.org/x/time v0.14.0 20 | ) 21 | 22 | require ( 23 | github.com/beorn7/perks v1.0.1 // indirect 24 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 25 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 26 | github.com/hashicorp/errwrap v1.1.0 // indirect 27 | github.com/hashicorp/go-multierror v1.1.1 // indirect 28 | github.com/jackc/chunkreader/v2 v2.0.1 // indirect 29 | github.com/jackc/pgconn v1.14.3 // indirect 30 | github.com/jackc/pgio v1.0.0 // indirect 31 | github.com/jackc/pgpassfile v1.0.0 // indirect 32 | github.com/jackc/pgproto3/v2 v2.3.3 // indirect 33 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 34 | github.com/jackc/pgtype v1.14.0 // indirect 35 | github.com/jackc/puddle v1.3.0 // indirect 36 | github.com/labstack/gommon v0.4.2 // indirect 37 | github.com/lib/pq v1.10.9 // indirect 38 | github.com/mattn/go-colorable v0.1.14 // indirect 39 | github.com/mattn/go-isatty v0.0.20 // indirect 40 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 41 | github.com/prometheus/client_model v0.6.2 // indirect 42 | github.com/prometheus/common v0.66.1 // indirect 43 | github.com/prometheus/procfs v0.16.1 // indirect 44 | github.com/valyala/bytebufferpool v1.0.0 // indirect 45 | github.com/valyala/fasttemplate v1.2.2 // indirect 46 | go.yaml.in/yaml/v2 v2.4.2 // indirect 47 | golang.org/x/net v0.47.0 // indirect 48 | golang.org/x/sys v0.38.0 // indirect 49 | golang.org/x/text v0.31.0 // indirect 50 | google.golang.org/protobuf v1.36.8 // indirect 51 | ) 52 | -------------------------------------------------------------------------------- /internal/handler/params.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "strings" 9 | 10 | "github.com/labstack/echo/v4" 11 | ) 12 | 13 | type ParamsStorage struct { 14 | params map[string]string 15 | } 16 | 17 | func NewParamsStorage(c echo.Context, maxBodySize int64) (*ParamsStorage, error) { 18 | ps := &ParamsStorage{ 19 | params: make(map[string]string), 20 | } 21 | 22 | bodyContent := []byte{} 23 | var contentType string 24 | if c.Request().Body != nil { 25 | limitedReader := io.LimitReader(c.Request().Body, maxBodySize+1) 26 | bodyBytes, err := io.ReadAll(limitedReader) 27 | if err != nil { 28 | return nil, err 29 | } 30 | if int64(len(bodyBytes)) > maxBodySize { 31 | return nil, fmt.Errorf("request body too large") 32 | } 33 | bodyContent = bodyBytes 34 | c.Request().Body = io.NopCloser(bytes.NewReader(bodyBytes)) 35 | contentType = c.Request().Header.Get("Content-Type") 36 | } 37 | 38 | bodyParams := ps.parseBodyParams(bodyContent, contentType) 39 | if len(bodyParams) > 0 { 40 | ps.params = bodyParams 41 | } else { 42 | ps.params = ps.parseURLParams(c) 43 | } 44 | 45 | return ps, nil 46 | } 47 | 48 | func (p *ParamsStorage) Get(key string) (string, bool) { 49 | val, ok := p.params[key] 50 | return val, ok && len(val) > 0 51 | } 52 | 53 | func (p *ParamsStorage) parseBodyParams(bodyContent []byte, contentType string) map[string]string { 54 | bodyParams := make(map[string]string) 55 | 56 | if len(bodyContent) > 0 && strings.Contains(contentType, "application/json") { 57 | var parsed map[string]interface{} 58 | if json.Unmarshal(bodyContent, &parsed) == nil { 59 | for key, val := range parsed { 60 | if str, ok := val.(string); ok { 61 | bodyParams[key] = str 62 | } 63 | } 64 | } 65 | } 66 | 67 | return bodyParams 68 | } 69 | 70 | // TODO if you need to retrieve list of values for a key, you have to support it 71 | func (p *ParamsStorage) parseURLParams(c echo.Context) map[string]string { 72 | urlParams := make(map[string]string) 73 | for key, values := range c.QueryParams() { 74 | if len(values) > 0 && values[0] != "" { 75 | urlParams[key] = values[0] 76 | } 77 | } 78 | return urlParams 79 | } 80 | -------------------------------------------------------------------------------- /internal/utils/http.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "strings" 7 | 8 | "github.com/realclientip/realclientip-go" 9 | ) 10 | 11 | type HttpRes struct { 12 | Message string `json:"message,omitempty" example:"status ok"` 13 | StatusCode int `json:"statusCode,omitempty" example:"200"` 14 | } 15 | 16 | func HttpResOk() HttpRes { 17 | return HttpRes{ 18 | Message: "OK", 19 | StatusCode: http.StatusOK, 20 | } 21 | } 22 | 23 | func HttpResError(errMsg string, statusCode int) (int, HttpRes) { 24 | return statusCode, HttpRes{ 25 | Message: errMsg, 26 | StatusCode: statusCode, 27 | } 28 | } 29 | 30 | func ExtractOrigin(rawURL string) string { 31 | if rawURL == "" { 32 | return "" 33 | } 34 | u, err := url.Parse(rawURL) 35 | if err != nil { 36 | return rawURL 37 | } 38 | if u.Scheme == "" || u.Host == "" { 39 | return rawURL 40 | } 41 | return u.Scheme + "://" + u.Host 42 | } 43 | 44 | type RealIPExtractor struct { 45 | strategy realclientip.RightmostTrustedRangeStrategy 46 | } 47 | 48 | // NewRealIPExtractor creates a new realIPExtractor with the given trusted ranges. 49 | func NewRealIPExtractor(trustedRanges []string) (*RealIPExtractor, error) { 50 | ipNets, err := realclientip.AddressesAndRangesToIPNets(trustedRanges...) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | strategy, err := realclientip.NewRightmostTrustedRangeStrategy("X-Forwarded-For", ipNets) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | return &RealIPExtractor{ 61 | strategy: strategy, 62 | }, nil 63 | } 64 | 65 | var remoteAddrStrategy = realclientip.RemoteAddrStrategy{} 66 | 67 | func (e *RealIPExtractor) Extract(request *http.Request) string { 68 | headers := request.Header.Clone() 69 | 70 | newXForwardedFor := []string{} 71 | oldXForwardedFor := headers.Get("X-Forwarded-For") 72 | 73 | if oldXForwardedFor != "" { 74 | newXForwardedFor = append(newXForwardedFor, oldXForwardedFor) 75 | } 76 | 77 | remoteAddr := remoteAddrStrategy.ClientIP(nil, request.RemoteAddr) 78 | if remoteAddr == "" || len(newXForwardedFor) == 0 { 79 | return remoteAddr 80 | } 81 | 82 | newXForwardedFor = append(newXForwardedFor, remoteAddr) 83 | headers.Set("X-Forwarded-For", strings.Join(newXForwardedFor, ", ")) 84 | 85 | // RightmostTrustedRangeStrategy ignore the second parameter 86 | rightmostTrusted := e.strategy.ClientIP(headers, "") 87 | if rightmostTrusted == "" { 88 | return remoteAddr 89 | } 90 | return rightmostTrusted 91 | } 92 | -------------------------------------------------------------------------------- /test/replica-dead/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | valkey-primary: 3 | image: valkey/valkey:7.2-alpine 4 | ports: 5 | - "6379:6379" 6 | command: valkey-server --appendonly yes 7 | healthcheck: 8 | test: ["CMD", "valkey-cli", "ping"] 9 | interval: 5s 10 | timeout: 3s 11 | retries: 5 12 | networks: 13 | - failover_network 14 | 15 | valkey-replica: 16 | image: valkey/valkey:7.2-alpine 17 | ports: 18 | - "6380:6379" 19 | command: valkey-server --appendonly yes --replicaof valkey-primary 6379 20 | healthcheck: 21 | test: ["CMD", "valkey-cli", "ping"] 22 | interval: 5s 23 | timeout: 3s 24 | retries: 5 25 | depends_on: 26 | valkey-primary: 27 | condition: service_healthy 28 | networks: 29 | - failover_network 30 | 31 | bridge: 32 | build: 33 | context: ../.. 34 | dockerfile: docker/Dockerfile.bridge3 35 | environment: 36 | PORT: 8081 37 | STORAGE: "valkey" 38 | VALKEY_URI: "redis://valkey-primary:6379" 39 | CORS_ENABLE: "true" 40 | RPS_LIMIT: 100000 41 | CONNECTIONS_LIMIT: 20000 42 | depends_on: 43 | valkey-primary: 44 | condition: service_healthy 45 | valkey-replica: 46 | condition: service_healthy 47 | networks: 48 | - failover_network 49 | 50 | benchmark: 51 | build: 52 | context: ../.. 53 | dockerfile: docker/Dockerfile.benchmark 54 | working_dir: /scripts 55 | environment: 56 | BRIDGE_URL: "http://bridge:8081/bridge" 57 | RAMP_UP: "1m" 58 | HOLD: "3m" 59 | RAMP_DOWN: "1m" 60 | SSE_VUS: "100" 61 | SEND_RATE: "1000" 62 | depends_on: 63 | bridge: 64 | condition: service_started 65 | networks: 66 | - failover_network 67 | volumes: 68 | - ../../benchmark:/scripts:ro 69 | - ./results:/results 70 | entrypoint: [ "/bin/sh", "-lc" ] 71 | command: 72 | - > 73 | printf '\n\n====== Waiting for bridge to start ======\n\n'; 74 | sleep 30; 75 | printf 'Running load test for %s with:\n' "$$TEST_DURATION"; 76 | printf ' - SSE Virtual Users: %s\n' "$$SSE_VUS"; 77 | printf ' - Send Rate: %s msg/s\n' "$$SEND_RATE"; 78 | printf ' - Target: %s\n\n' "$$BRIDGE_URL"; 79 | printf '\n\nPlease wait...\n\n'; 80 | /usr/local/bin/k6 run --quiet --summary-export=/results/summary.json /scripts/bridge_test.js; 81 | printf '\n\n============================\n\n'; 82 | 83 | networks: 84 | failover_network: 85 | -------------------------------------------------------------------------------- /actions/local/action.yaml: -------------------------------------------------------------------------------- 1 | name: "TON Connect Bridge" 2 | description: "Builds and starts the Bridge service via docker compose and exposes it at http://localhost:8081/bridge" 3 | author: "TON Tech" 4 | 5 | inputs: 6 | repository: 7 | description: "TON Connect Bridge repository" 8 | default: "ton-connect/bridge" 9 | branch: 10 | description: "Branch of repository" 11 | default: "master" 12 | 13 | runs: 14 | using: "composite" 15 | steps: 16 | - name: Checkout TON Connect Bridge repository 17 | uses: actions/checkout@v5 18 | with: 19 | repository: ${{ inputs.repository }} 20 | ref: ${{ inputs.branch }} 21 | path: ton-connect-bridge 22 | 23 | - name: Ensure dependencies 24 | shell: bash 25 | # language=bash 26 | run: | 27 | set -euo pipefail 28 | docker --version 29 | docker compose version 30 | 31 | - name: Start TON Connect Bridge service 32 | shell: bash 33 | # language=bash 34 | run: | 35 | set -euo pipefail 36 | cd ton-connect-bridge 37 | echo "Starting TON Connect Bridge service with docker-compose.memory.yml..." 38 | docker compose -f docker/docker-compose.memory.yml up --build -d 39 | 40 | echo "Waiting for services to be ready..." 41 | sleep 10 42 | 43 | echo "Checking if TON Connect Bridge service is accessible..." 44 | max_attempts=30 45 | attempt=1 46 | 47 | while [ $attempt -le $max_attempts ]; do 48 | echo "Attempt $attempt/$max_attempts: Checking http://localhost:8081/bridge" 49 | if curl -I -f -s -o /dev/null -w "%{http_code}\n" http://localhost:9103/metrics; then 50 | echo "✅ TON Connect Bridge service is accessible at http://localhost:8081/bridge" 51 | break 52 | else 53 | echo "❌ TON Connect Bridge service not ready yet, waiting 5 seconds..." 54 | sleep 5 55 | attempt=$((attempt + 1)) 56 | fi 57 | done 58 | 59 | if [ $attempt -gt $max_attempts ]; then 60 | echo "❌ TON Connect Bridge service failed to start within expected time" 61 | echo "Docker containers status:" 62 | docker compose -f docker/docker-compose.memory.yml ps 63 | echo "TON Connect Bridge container logs:" 64 | docker compose -f docker/docker-compose.memory.yml logs bridge 65 | exit 1 66 | fi 67 | 68 | echo "🚀 TON Connect Bridge service is now running and accessible at http://localhost:8081/bridge" 69 | 70 | -------------------------------------------------------------------------------- /internal/app/health.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "sync/atomic" 7 | "time" 8 | 9 | log "github.com/sirupsen/logrus" 10 | "github.com/ton-connect/bridge/internal" 11 | ) 12 | 13 | // HealthChecker interface for storage health checking 14 | type HealthChecker interface { 15 | HealthCheck() error 16 | } 17 | 18 | // HealthManager manages the health status of the bridge 19 | type HealthManager struct { 20 | healthy int64 // Use atomic for thread-safe access 21 | } 22 | 23 | // NewHealthManager creates a new health manager 24 | func NewHealthManager() *HealthManager { 25 | return &HealthManager{healthy: 0} 26 | } 27 | 28 | // UpdateHealthStatus checks storage health and updates metrics 29 | func (h *HealthManager) UpdateHealthStatus(storage HealthChecker) { 30 | var healthStatus int64 = 1 31 | if err := storage.HealthCheck(); err != nil { 32 | healthStatus = 0 33 | } 34 | 35 | atomic.StoreInt64(&h.healthy, healthStatus) 36 | HealthMetric.Set(float64(healthStatus)) 37 | ReadyMetric.Set(float64(healthStatus)) 38 | } 39 | 40 | // StartHealthMonitoring starts a background goroutine to monitor health 41 | func (h *HealthManager) StartHealthMonitoring(storage HealthChecker) { 42 | // Initial health check 43 | h.UpdateHealthStatus(storage) 44 | 45 | ticker := time.NewTicker(5 * time.Second) 46 | defer ticker.Stop() 47 | 48 | for range ticker.C { 49 | h.UpdateHealthStatus(storage) 50 | } 51 | } 52 | 53 | // HealthHandler returns HTTP handler for health endpoints 54 | func (h *HealthManager) HealthHandler(w http.ResponseWriter, r *http.Request) { 55 | w.Header().Set("Content-Type", "application/json") 56 | w.Header().Set("X-Build-Commit", internal.BridgeVersionRevision) 57 | 58 | healthy := atomic.LoadInt64(&h.healthy) 59 | if healthy == 0 { 60 | w.WriteHeader(http.StatusServiceUnavailable) 61 | _, err := fmt.Fprintf(w, `{"status":"unhealthy"}`+"\n") 62 | if err != nil { 63 | log.Errorf("health response write error: %v", err) 64 | } 65 | return 66 | } 67 | 68 | w.WriteHeader(http.StatusOK) 69 | _, err := fmt.Fprintf(w, `{"status":"ok"}`+"\n") 70 | if err != nil { 71 | log.Errorf("health response write error: %v", err) 72 | } 73 | } 74 | 75 | // VersionHandler returns HTTP handler for version endpoint 76 | func VersionHandler(w http.ResponseWriter, r *http.Request) { 77 | w.Header().Set("Content-Type", "application/json") 78 | w.Header().Set("X-Build-Commit", internal.BridgeVersionRevision) 79 | 80 | w.WriteHeader(http.StatusOK) 81 | response := fmt.Sprintf(`{"version":"%s"}`, internal.BridgeVersionRevision) 82 | _, err := fmt.Fprintf(w, "%s", response+"\n") 83 | if err != nil { 84 | log.Errorf("version response write error: %v", err) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TON Connect Bridge 2 | 3 | [HTTP bridge](https://github.com/ton-connect/docs/blob/main/bridge.md) implementation for TON Connect 2.0. 4 | 5 | ## 🚀 Quick Start 6 | 7 | ```bash 8 | git clone https://github.com/ton-connect/bridge 9 | cd bridge 10 | make build3 11 | ./bridge3 12 | ``` 13 | 14 | For production deployments, use Redis/Valkey storage. See [Configuration](docs/CONFIGURATION.md) for details. 15 | 16 | Use `make help` to see all available commands. 17 | 18 | **Note:** For common issues and troubleshooting, see [KNOWN_ISSUES.md](docs/KNOWN_ISSUES.md) 19 | 20 | ## 📋 Requirements 21 | 22 | - Go 1.24+ 23 | - Redis/Valkey 7.0+ (or any Redis-compatible storage) for production 24 | - Node.js & npm (for testing) 25 | 26 | ## 📚 Documentation 27 | 28 | - [Architecture](docs/ARCHITECTURE.md) - Bridge architecture, pub/sub design, and scaling 29 | - [Configuration](docs/CONFIGURATION.md) - Complete environment variables reference 30 | - [Deployment](docs/DEPLOYMENT.md) - Production deployment patterns and best practices 31 | - [Known Issues](docs/KNOWN_ISSUES.md) - Common issues and troubleshooting 32 | - [Monitoring](docs/MONITORING.md) - Metrics, health checks, and observability 33 | 34 |
35 | Looking for PostgreSQL-based setup? 36 | 37 | > For existing PostgreSQL-based installations, see [Bridge v1](cmd/bridge/README.md). 38 | > 39 | > We recommend migrating to the current version — it scales with your user base and receives active development. 40 | > 41 | > The current setup uses Redis/Valkey for pub/sub, enabling horizontal scaling across multiple instances. The PostgreSQL-based version is limited to a single instance and cannot scale horizontally. 42 | 43 |
44 | 45 | ## Use local TON Connect Bridge 46 | 47 | Default url: `http://localhost:8081/bridge` 48 | 49 | ### Docker 50 | 51 | ```bash 52 | git clone https://github.com/ton-connect/bridge.git 53 | cd bridge 54 | docker compose -f docker/docker-compose.valkey.yml up --build -d 55 | curl -I -f -s -o /dev/null -w "%{http_code}\n" http://localhost:9103/metrics 56 | ``` 57 | 58 | ### GitHub Action 59 | 60 | Example usage from another repository: 61 | 62 | ```yaml 63 | name: e2e 64 | on: [push, pull_request] 65 | 66 | jobs: 67 | test: 68 | runs-on: ubuntu-latest 69 | steps: 70 | - uses: actions/checkout@v4 71 | 72 | - name: Start Bridge 73 | uses: ton-connect/bridge/actions/local@master 74 | with: 75 | repository: "ton-connect/bridge" 76 | branch: "master" 77 | 78 | - name: Run E2E tests 79 | env: 80 | BRIDGE_URL: http://localhost:8081/bridge 81 | run: | 82 | npm run e2e 83 | ``` 84 | 85 | Made with ❤️ for the TON ecosystem -------------------------------------------------------------------------------- /internal/v3/handler/events.go: -------------------------------------------------------------------------------- 1 | package handlerv3 2 | 3 | import ( 4 | "math/rand" 5 | "sync/atomic" 6 | 7 | "github.com/ton-connect/bridge/internal/ntp" 8 | ) 9 | 10 | // EventIDGenerator generates monotonically increasing event IDs across multiple bridge instances. 11 | // Uses time-based ID generation with local sequence counter to ensure uniqueness and ordering. 12 | // Format: (timestamp_ms << 11) | local_counter (53 bits total for JavaScript compatibility) 13 | // This provides ~2K events per millisecond per bridge instance. 14 | // 15 | // Note: Due to concurrent generation and potential clock skew between instances, 16 | // up to 5% of events may not be in strict monotonic sequence, which is acceptable 17 | // for the bridge's event ordering requirements. 18 | type EventIDGenerator struct { 19 | counter int64 // Local sequence counter, incremented atomically 20 | offset int64 // Random offset per instance to avoid collisions (11 bits) 21 | timeProvider ntp.TimeProvider // Time source (local or NTP-synchronized) 22 | } 23 | 24 | // NewEventIDGenerator creates a new event ID generator with counter starting from 0. 25 | // The timeProvider parameter determines the time source: 26 | // - Use ntp.Client for NTP-synchronized time (better consistency across bridge instances) 27 | // - Use ntp.LocalTimeProvider for local system time (fallback when NTP is unavailable) 28 | func NewEventIDGenerator(timeProvider ntp.TimeProvider) *EventIDGenerator { 29 | return &EventIDGenerator{ 30 | counter: 0, 31 | offset: rand.Int63() & 0x7FF, // Random offset (11 bits) to avoid collisions between instances 32 | timeProvider: timeProvider, 33 | } 34 | } 35 | 36 | // NextID generates the next monotonic event ID. 37 | // 38 | // The ID format combines (53 bits total for JavaScript Number.MAX_SAFE_INTEGER compatibility): 39 | // - Upper 42 bits: Unix timestamp in milliseconds (supports dates up to year 2100) 40 | // - Lower 11 bits: Local counter masked to 11 bits (handles multiple events per millisecond) 41 | // 42 | // This approach ensures: 43 | // - IDs are mostly monotonic if bridge instances have synchronized clocks (NTP) 44 | // - No central coordination needed → scalable across multiple bridge instances 45 | // - Unique IDs even with high event rates (~2K events/ms per instance) 46 | // - Works well with SSE last_event_id for client reconnection 47 | // - Safe integer representation in JavaScript (< 2^53) 48 | func (g *EventIDGenerator) NextID() int64 { 49 | timestamp := g.timeProvider.NowUnixMilli() 50 | counter := atomic.AddInt64(&g.counter, 1) 51 | return getIdFromParams(timestamp, counter+g.offset) 52 | } 53 | 54 | func getIdFromParams(timestamp int64, nonce int64) int64 { 55 | return ((timestamp << 11) | (nonce & 0x7FF)) & 0x1FFFFFFFFFFFFF 56 | } 57 | -------------------------------------------------------------------------------- /internal/utils/tls.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "crypto/elliptic" 6 | "crypto/rand" 7 | "crypto/x509" 8 | "crypto/x509/pkix" 9 | "encoding/base64" 10 | "encoding/hex" 11 | "encoding/json" 12 | "encoding/pem" 13 | "fmt" 14 | "math/big" 15 | "time" 16 | 17 | "github.com/ton-connect/bridge/internal/models" 18 | "golang.org/x/crypto/nacl/box" 19 | ) 20 | 21 | // GenerateSelfSignedCertificate generates a self-signed X.509 certificate and private key 22 | func GenerateSelfSignedCertificate() ([]byte, []byte, error) { 23 | privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 24 | if err != nil { 25 | return nil, nil, err 26 | } 27 | 28 | serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) 29 | serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) 30 | if err != nil { 31 | return nil, nil, err 32 | } 33 | 34 | certTemplate := x509.Certificate{ 35 | SerialNumber: serialNumber, 36 | Subject: pkix.Name{ 37 | Organization: []string{"TON"}, 38 | }, 39 | NotBefore: time.Now().Add(-time.Hour), 40 | NotAfter: time.Now().Add(1000 * 24 * time.Hour), 41 | 42 | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, 43 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 44 | BasicConstraintsValid: true, 45 | } 46 | 47 | derBytes, err := x509.CreateCertificate(rand.Reader, &certTemplate, &certTemplate, &privateKey.PublicKey, privateKey) 48 | if err != nil { 49 | return nil, nil, err 50 | } 51 | 52 | certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) 53 | key, err := x509.MarshalECPrivateKey(privateKey) 54 | if err != nil { 55 | return nil, nil, err 56 | } 57 | keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: key}) 58 | 59 | return certPEM, keyPEM, nil 60 | } 61 | 62 | // EncryptRequestSourceWithWalletID encrypts the request source metadata using the wallet's Curve25519 public key 63 | func EncryptRequestSourceWithWalletID(requestSource models.BridgeRequestSource, walletID string) (string, error) { 64 | data, err := json.Marshal(requestSource) 65 | if err != nil { 66 | return "", fmt.Errorf("failed to marshal request source: %w", err) 67 | } 68 | 69 | publicKeyBytes, err := hex.DecodeString(walletID) 70 | if err != nil { 71 | return "", fmt.Errorf("failed to decode wallet ID: %w", err) 72 | } 73 | 74 | if len(publicKeyBytes) != 32 { 75 | return "", fmt.Errorf("invalid public key length: expected 32 bytes, got %d", len(publicKeyBytes)) 76 | } 77 | 78 | // Convert to Curve25519 public key format 79 | var recipientPublicKey [32]byte 80 | copy(recipientPublicKey[:], publicKeyBytes) 81 | 82 | encrypted, err := box.SealAnonymous(nil, data, &recipientPublicKey, rand.Reader) 83 | if err != nil { 84 | return "", fmt.Errorf("failed to encrypt data: %w", err) 85 | } 86 | 87 | return base64.StdEncoding.EncodeToString(encrypted), nil 88 | } 89 | -------------------------------------------------------------------------------- /internal/v1/handler/session.go: -------------------------------------------------------------------------------- 1 | package handlerv1 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | 8 | "github.com/sirupsen/logrus" 9 | "github.com/ton-connect/bridge/internal/models" 10 | "github.com/ton-connect/bridge/internal/v1/storage" 11 | ) 12 | 13 | type Session struct { 14 | mux sync.RWMutex 15 | ClientIds []string 16 | MessageCh chan models.SseMessage 17 | storage storage.Storage 18 | Closer chan interface{} 19 | lastEventId int64 20 | } 21 | 22 | func NewSession(s storage.Storage, clientIds []string, lastEventId int64) *Session { 23 | session := Session{ 24 | mux: sync.RWMutex{}, 25 | ClientIds: clientIds, 26 | storage: s, 27 | MessageCh: make(chan models.SseMessage, 10), 28 | Closer: make(chan interface{}), 29 | lastEventId: lastEventId, 30 | } 31 | return &session 32 | } 33 | 34 | func (s *Session) worker(heartbeatMessage string, enableQueueDoneEvent bool, heartbeatInterval time.Duration) { 35 | log := logrus.WithField("prefix", "Session.worker") 36 | 37 | wg := sync.WaitGroup{} 38 | s.runHeartbeat(&wg, log, heartbeatMessage, heartbeatInterval) 39 | 40 | s.retrieveHistoricMessages(&wg, log, enableQueueDoneEvent) 41 | 42 | // Wait for closer to be closed from the outside 43 | // Happens when connection is closed 44 | <-s.Closer 45 | 46 | // Wait for channel producers to finish before closing the channel 47 | wg.Wait() 48 | close(s.MessageCh) 49 | } 50 | 51 | func (s *Session) runHeartbeat(wg *sync.WaitGroup, log *logrus.Entry, heartbeatMessage string, heartbeatInterval time.Duration) { 52 | wg.Add(1) 53 | go func() { 54 | defer wg.Done() 55 | 56 | ticker := time.NewTicker(heartbeatInterval) 57 | defer ticker.Stop() 58 | 59 | for { 60 | select { 61 | case <-s.Closer: 62 | return 63 | case <-ticker.C: 64 | s.MessageCh <- models.SseMessage{EventId: -1, Message: []byte(heartbeatMessage)} 65 | } 66 | } 67 | }() 68 | } 69 | 70 | func (s *Session) retrieveHistoricMessages(wg *sync.WaitGroup, log *logrus.Entry, doneEvent bool) { 71 | wg.Add(1) 72 | defer wg.Done() 73 | 74 | messages, err := s.storage.GetMessages(context.TODO(), s.ClientIds, s.lastEventId) 75 | if err != nil { 76 | log.Info("get queue error: ", err) 77 | } 78 | 79 | for _, m := range messages { 80 | select { 81 | case <-s.Closer: 82 | return 83 | default: 84 | s.MessageCh <- m 85 | } 86 | } 87 | 88 | if doneEvent { 89 | s.MessageCh <- models.SseMessage{EventId: -1, Message: []byte("event: message\r\ndata: queue_done\r\n\r\n")} 90 | } 91 | } 92 | 93 | func (s *Session) AddMessageToQueue(ctx context.Context, mes models.SseMessage) { 94 | select { 95 | case <-s.Closer: 96 | default: 97 | s.MessageCh <- mes 98 | } 99 | } 100 | 101 | func (s *Session) Start(heartbeatMessage string, enableQueueDoneEvent bool, heartbeatInterval time.Duration) { 102 | go s.worker(heartbeatMessage, enableQueueDoneEvent, heartbeatInterval) 103 | } 104 | -------------------------------------------------------------------------------- /test/gointegration/analytics_mock.go: -------------------------------------------------------------------------------- 1 | package bridge_test 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "sync" 9 | 10 | "github.com/sirupsen/logrus" 11 | ) 12 | 13 | // AnalyticsMock is a mock analytics server for testing 14 | type AnalyticsMock struct { 15 | Server *httptest.Server 16 | mu sync.RWMutex 17 | receivedEvents []map[string]interface{} 18 | totalEvents int 19 | } 20 | 21 | // NewAnalyticsMock creates a new mock analytics server 22 | func NewAnalyticsMock() *AnalyticsMock { 23 | mock := &AnalyticsMock{ 24 | receivedEvents: make([]map[string]interface{}, 0), 25 | } 26 | 27 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 28 | if r.Method != http.MethodPost { 29 | w.WriteHeader(http.StatusMethodNotAllowed) 30 | return 31 | } 32 | 33 | if r.URL.Path != "/events" { 34 | w.WriteHeader(http.StatusNotFound) 35 | return 36 | } 37 | 38 | body, err := io.ReadAll(r.Body) 39 | if err != nil { 40 | logrus.Errorf("analytics mock: failed to read body: %v", err) 41 | w.WriteHeader(http.StatusBadRequest) 42 | return 43 | } 44 | 45 | var events []map[string]interface{} 46 | if err := json.Unmarshal(body, &events); err != nil { 47 | logrus.Errorf("analytics mock: failed to unmarshal events: %v", err) 48 | w.WriteHeader(http.StatusBadRequest) 49 | return 50 | } 51 | 52 | mock.mu.Lock() 53 | mock.receivedEvents = append(mock.receivedEvents, events...) 54 | mock.totalEvents += len(events) 55 | mock.mu.Unlock() 56 | 57 | logrus.Infof("analytics mock: received batch of %d events (total: %d)", len(events), mock.totalEvents) 58 | 59 | // Return 202 Accepted like the real analytics server 60 | w.WriteHeader(http.StatusAccepted) 61 | }) 62 | 63 | mock.Server = httptest.NewServer(handler) 64 | return mock 65 | } 66 | 67 | // Close shuts down the mock server 68 | func (m *AnalyticsMock) Close() { 69 | m.Server.Close() 70 | } 71 | 72 | // GetEvents returns all received events 73 | func (m *AnalyticsMock) GetEvents() []map[string]interface{} { 74 | m.mu.RLock() 75 | defer m.mu.RUnlock() 76 | 77 | events := make([]map[string]interface{}, len(m.receivedEvents)) 78 | copy(events, m.receivedEvents) 79 | return events 80 | } 81 | 82 | // GetEventCount returns the total number of events received 83 | func (m *AnalyticsMock) GetEventCount() int { 84 | m.mu.RLock() 85 | defer m.mu.RUnlock() 86 | return m.totalEvents 87 | } 88 | 89 | // GetEventsByName returns events filtered by event_name 90 | func (m *AnalyticsMock) GetEventsByName(eventName string) []map[string]interface{} { 91 | m.mu.RLock() 92 | defer m.mu.RUnlock() 93 | 94 | filtered := make([]map[string]interface{}, 0) 95 | for _, event := range m.receivedEvents { 96 | if name, ok := event["event_name"].(string); ok && name == eventName { 97 | filtered = append(filtered, event) 98 | } 99 | } 100 | return filtered 101 | } 102 | 103 | // Reset clears all received events 104 | func (m *AnalyticsMock) Reset() { 105 | m.mu.Lock() 106 | defer m.mu.Unlock() 107 | m.receivedEvents = make([]map[string]interface{}, 0) 108 | m.totalEvents = 0 109 | } 110 | -------------------------------------------------------------------------------- /cmd/analytics-mock/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "log" 7 | "net/http" 8 | "sync" 9 | ) 10 | 11 | type AnalyticsMock struct { 12 | mu sync.RWMutex 13 | receivedEvents []map[string]interface{} 14 | totalEvents int 15 | } 16 | 17 | func NewAnalyticsMock() *AnalyticsMock { 18 | return &AnalyticsMock{ 19 | receivedEvents: make([]map[string]interface{}, 0), 20 | } 21 | } 22 | 23 | func (m *AnalyticsMock) ServeHTTP(w http.ResponseWriter, r *http.Request) { 24 | if r.URL.Path == "/health" { 25 | w.WriteHeader(http.StatusOK) 26 | if _, err := w.Write([]byte("OK")); err != nil { 27 | log.Printf("Failed to write health response: %v", err) 28 | } 29 | return 30 | } 31 | 32 | if r.Method != http.MethodPost { 33 | w.WriteHeader(http.StatusMethodNotAllowed) 34 | return 35 | } 36 | 37 | if r.URL.Path != "/events" { 38 | w.WriteHeader(http.StatusNotFound) 39 | return 40 | } 41 | 42 | body, err := io.ReadAll(r.Body) 43 | if err != nil { 44 | log.Printf("Failed to read body: %v", err) 45 | w.WriteHeader(http.StatusBadRequest) 46 | return 47 | } 48 | 49 | var events []map[string]interface{} 50 | if err := json.Unmarshal(body, &events); err != nil { 51 | log.Printf("Failed to unmarshal events: %v", err) 52 | w.WriteHeader(http.StatusBadRequest) 53 | return 54 | } 55 | 56 | m.mu.Lock() 57 | m.receivedEvents = append(m.receivedEvents, events...) 58 | m.totalEvents += len(events) 59 | total := m.totalEvents 60 | m.mu.Unlock() 61 | 62 | log.Printf("✅ Received batch of %d events (total: %d)", len(events), total) 63 | for _, event := range events { 64 | if eventName, ok := event["event_name"].(string); ok { 65 | log.Printf(" - %s", eventName) 66 | } 67 | } 68 | 69 | // Return 202 Accepted like the real analytics server 70 | w.WriteHeader(http.StatusAccepted) 71 | } 72 | 73 | func (m *AnalyticsMock) GetStats(w http.ResponseWriter, r *http.Request) { 74 | m.mu.RLock() 75 | defer m.mu.RUnlock() 76 | 77 | eventTypes := make(map[string]int) 78 | for _, event := range m.receivedEvents { 79 | if eventName, ok := event["event_name"].(string); ok { 80 | eventTypes[eventName]++ 81 | } 82 | } 83 | 84 | stats := map[string]interface{}{ 85 | "total_events": m.totalEvents, 86 | "event_types": eventTypes, 87 | } 88 | 89 | w.Header().Set("Content-Type", "application/json") 90 | if err := json.NewEncoder(w).Encode(stats); err != nil { 91 | log.Printf("Failed to encode stats: %v", err) 92 | w.WriteHeader(http.StatusInternalServerError) 93 | return 94 | } 95 | } 96 | 97 | func main() { 98 | mock := NewAnalyticsMock() 99 | 100 | http.HandleFunc("/events", mock.ServeHTTP) 101 | http.HandleFunc("/health", mock.ServeHTTP) 102 | http.HandleFunc("/stats", mock.GetStats) 103 | 104 | port := ":9090" 105 | log.Printf("🚀 Analytics Mock Server starting on %s", port) 106 | log.Printf("📊 Endpoints:") 107 | log.Printf(" POST /events - Receive analytics events") 108 | log.Printf(" GET /health - Health check") 109 | log.Printf(" GET /stats - Get statistics") 110 | log.Printf("") 111 | if err := http.ListenAndServe(port, nil); err != nil { 112 | log.Fatal(err) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /internal/storage/message_cache.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | ) 7 | 8 | // MessageCache interface defines methods for caching messages 9 | type MessageCache interface { 10 | Mark(eventID int64) 11 | MarkIfNotExists(eventID int64) bool 12 | IsMarked(eventID int64) bool 13 | Cleanup() int 14 | Len() int 15 | } 16 | 17 | // InMemoryMessageCache tracks marked messages to avoid logging them as expired 18 | type InMemoryMessageCache struct { 19 | markedMessages map[int64]time.Time // event_id -> timestamp 20 | mutex sync.RWMutex 21 | ttl time.Duration 22 | } 23 | 24 | // NewMessageCache creates a new message cache instance 25 | func NewMessageCache(enable bool, ttl time.Duration) MessageCache { 26 | if !enable { 27 | return &NoopMessageCache{} 28 | } 29 | return &InMemoryMessageCache{ 30 | markedMessages: make(map[int64]time.Time), 31 | ttl: ttl, 32 | } 33 | } 34 | 35 | // Mark message 36 | func (mc *InMemoryMessageCache) Mark(eventID int64) { 37 | mc.mutex.Lock() 38 | mc.markedMessages[eventID] = time.Now() 39 | mc.mutex.Unlock() 40 | } 41 | 42 | // Mark message 43 | func (mc *InMemoryMessageCache) MarkIfNotExists(eventID int64) bool { 44 | mc.mutex.Lock() 45 | defer mc.mutex.Unlock() 46 | if _, exists := mc.markedMessages[eventID]; !exists { 47 | mc.markedMessages[eventID] = time.Now() 48 | return true 49 | } 50 | return false 51 | } 52 | 53 | // IsMarked checks if a message was marked 54 | func (mc *InMemoryMessageCache) IsMarked(eventID int64) bool { 55 | mc.mutex.RLock() 56 | _, marked := mc.markedMessages[eventID] 57 | mc.mutex.RUnlock() 58 | return marked 59 | } 60 | 61 | // Cleanup removes old marked message entries 62 | func (mc *InMemoryMessageCache) Cleanup() int { 63 | counter := 0 64 | cutoff := time.Now().Add(-mc.ttl) 65 | mc.mutex.Lock() 66 | for eventID, deliveryTime := range mc.markedMessages { 67 | if deliveryTime.Before(cutoff) { 68 | delete(mc.markedMessages, eventID) 69 | counter++ 70 | } 71 | } 72 | mc.mutex.Unlock() 73 | return counter 74 | } 75 | 76 | func (mc *InMemoryMessageCache) Len() int { 77 | mc.mutex.RLock() 78 | size := len(mc.markedMessages) 79 | mc.mutex.RUnlock() 80 | return size 81 | } 82 | 83 | // NoopMessageCache is a no-operation implementation of MessageCache 84 | type NoopMessageCache struct{} 85 | 86 | // NewNoopMessageCache creates a new no-operation message cache 87 | func NewNoopMessageCache() MessageCache { 88 | return &NoopMessageCache{} 89 | } 90 | 91 | // Mark does nothing in the noop implementation 92 | func (nc *NoopMessageCache) Mark(eventID int64) { 93 | // no-op 94 | } 95 | 96 | // MarkIfNotExists always returns true in the noop implementation 97 | func (nc *NoopMessageCache) MarkIfNotExists(eventID int64) bool { 98 | return true 99 | } 100 | 101 | // IsMarked always returns false in the noop implementation 102 | func (nc *NoopMessageCache) IsMarked(eventID int64) bool { 103 | return false 104 | } 105 | 106 | // Cleanup always returns 0 in the noop implementation 107 | func (nc *NoopMessageCache) Cleanup() int { 108 | return 0 109 | } 110 | 111 | // Len always returns 0 in the noop implementation 112 | func (nc *NoopMessageCache) Len() int { 113 | return 0 114 | } 115 | -------------------------------------------------------------------------------- /docs/DEPLOYMENT.md: -------------------------------------------------------------------------------- 1 | # Deployment Guide 2 | 3 | Production-ready deployment patterns and best practices for TON Connect Bridge v3. 4 | 5 | ## Quick Deployment Checklist 6 | 7 | - [ ] Set up Redis/Valkey 7.0+ **cluster** (Bridge v3 requires cluster mode) 8 | - [ ] Configure environment variables (see [CONFIGURATION.md](CONFIGURATION.md)) 9 | - [ ] Deploy multiple Bridge instances for high availability 10 | - [ ] Set up load balancer with health checks 11 | - [ ] Configure TLS termination 12 | - [ ] Enable monitoring and alerts (see [MONITORING.md](MONITORING.md)) 13 | - [ ] Set up rate limiting 14 | - [ ] Run load tests with built-in [`benchmark/`](../benchmark/) tools 15 | 16 | ## Architecture Patterns 17 | 18 | > **Note:** Bridge v3 requires Redis/Valkey cluster mode. Single-node deployments are not supported. 19 | 20 | ### Pattern 1: Multi-Instance with Load Balancer (Recommended) 21 | 22 | **Best for:** Production, >10,000 concurrent connections, high availability 23 | 24 | ``` 25 | Internet 26 | │ 27 | ├── TLS Termination & Load Balancer 28 | │ (nginx, HAProxy, or cloud LB) 29 | │ 30 | ├─────────────┬───────────┬────────────┐ 31 | │ │ │ │ 32 | ▼ ▼ ▼ ▼ 33 | ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ 34 | │ Bridge │ │ Bridge │ │ Bridge │ │ Bridge │ 35 | │Instance│ │Instance│ │Instance│ │Instance│ 36 | └────────┘ └────────┘ └────────┘ └────────┘ 37 | │ │ │ │ 38 | └──────────┴──────────┴──────────┘ 39 | │ 40 | ▼ 41 | ┌───────────────┐ 42 | │ Redis Cluster │ 43 | └───────────────┘ 44 | ``` 45 | 46 | **Pros:** 47 | - High availability 48 | - Horizontal scaling 49 | - Load distribution 50 | - Zero-downtime deployments 51 | 52 | **Cons:** 53 | - More complex setup 54 | - Higher cost 55 | 56 | **Key Components:** 57 | - Bridge v3 Instances: 3+ instances for redundancy 58 | - Redis Cluster: 3-6 nodes with replication (required for Bridge v3) 59 | - Load Balancer: Distributes traffic, health checks 60 | - TLS Termination: SSL/TLS at load balancer level 61 | 62 | ### Pattern 2: Multi-Region (Global) 63 | 64 | **Best for:** Global audience, ultra-low latency requirements 65 | 66 | ``` 67 | Region 1 Region 2 Region 3 68 | ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ 69 | │ Load Balancer │ │ Load Balancer │ │ Load Balancer │ 70 | └───────┬───────┘ └───────┬───────┘ └───────┬────────┘ 71 | │ │ │ 72 | ┌─────┴──────┐ ┌─────┴──────┐ ┌─────┴──────┐ 73 | │ Bridge │ │ Bridge │ │ Bridge │ 74 | └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ 75 | │ │ │ 76 | └──────────────────────┴──────────────────────┘ 77 | │ 78 | ┌──────────────────────┐ 79 | │ Global Redis Cluster │ 80 | │ (with replication) │ 81 | └──────────────────────┘ 82 | ``` 83 | 84 | **Requirements:** 85 | - Bridge v3 with Redis cluster 86 | - Multi-region Redis cluster with cross-region replication 87 | - Geographic load balancing (GeoDNS/CDN) 88 | - Low-latency network between regions 89 | 90 | -------------------------------------------------------------------------------- /.github/workflows/deploy-image.yml: -------------------------------------------------------------------------------- 1 | # 2 | name: Create and publish a Docker image 3 | 4 | # Configures this workflow to run every time a change is pushed to the branch called `release`. 5 | on: 6 | push: 7 | branches: ['master'] 8 | 9 | # Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds. 10 | env: 11 | REGISTRY: ghcr.io 12 | IMAGE_NAME: ${{ github.repository }} 13 | 14 | # There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu. 15 | jobs: 16 | build-and-push-image: 17 | runs-on: ubuntu-latest 18 | # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. 19 | permissions: 20 | contents: read 21 | packages: write 22 | attestations: write 23 | id-token: write 24 | # 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v6 28 | # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. 29 | - name: Log in to the Container registry 30 | uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef 31 | with: 32 | registry: ${{ env.REGISTRY }} 33 | username: ${{ github.actor }} 34 | password: ${{ secrets.GITHUB_TOKEN }} 35 | # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels. 36 | - name: Extract metadata (tags, labels) for Docker 37 | id: meta 38 | uses: docker/metadata-action@8d8c7c12f7b958582a5cb82ba16d5903cb27976a 39 | with: 40 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 41 | tags: | 42 | type=ref,event=branch 43 | # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. 44 | # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see [Usage](https://github.com/docker/build-push-action#usage) in the README of the `docker/build-push-action` repository. 45 | # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. 46 | - name: Build and push Docker image 47 | id: push 48 | uses: docker/build-push-action@9e436ba9f2d7bcd1d038c8e55d039d37896ddc5d 49 | with: 50 | context: . 51 | push: true 52 | tags: ${{ steps.meta.outputs.tags }} 53 | labels: ${{ steps.meta.outputs.labels }} 54 | 55 | # This step generates an artifact attestation for the image, which is an unforgeable statement about where and how it was built. It increases supply chain security for people who consume the image. For more information, see [Using artifact attestations to establish provenance for builds](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds). 56 | - name: Generate artifact attestation 57 | uses: actions/attest-build-provenance@v3 58 | with: 59 | subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} 60 | subject-digest: ${{ steps.push.outputs.digest }} 61 | push-to-registry: true 62 | 63 | -------------------------------------------------------------------------------- /internal/v1/storage/mem.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | "encoding/json" 8 | "sync" 9 | "time" 10 | 11 | log "github.com/sirupsen/logrus" 12 | "github.com/ton-connect/bridge/internal/analytics" 13 | "github.com/ton-connect/bridge/internal/models" 14 | ) 15 | 16 | type MemStorage struct { 17 | db map[string][]message 18 | lock sync.Mutex 19 | analytics analytics.EventCollector 20 | eventBuilder analytics.EventBuilder 21 | } 22 | 23 | type message struct { 24 | models.SseMessage 25 | expireAt time.Time 26 | } 27 | 28 | func (m message) IsExpired(now time.Time) bool { 29 | return m.expireAt.Before(now) 30 | } 31 | 32 | func NewMemStorage(collector analytics.EventCollector, builder analytics.EventBuilder) *MemStorage { 33 | s := MemStorage{ 34 | db: map[string][]message{}, 35 | analytics: collector, 36 | eventBuilder: builder, 37 | } 38 | go s.watcher() 39 | return &s 40 | } 41 | 42 | func removeExpiredMessages(ms []message, now time.Time) ([]message, []message) { 43 | results := make([]message, 0) 44 | expired := make([]message, 0) 45 | for _, m := range ms { 46 | if m.IsExpired(now) { 47 | if !ExpiredCache.IsMarked(m.EventId) { 48 | expired = append(expired, m) 49 | } 50 | } else { 51 | results = append(results, m) 52 | } 53 | } 54 | return results, expired 55 | } 56 | 57 | func (s *MemStorage) watcher() { 58 | for { 59 | s.lock.Lock() 60 | for key, msgs := range s.db { 61 | actual, expired := removeExpiredMessages(msgs, time.Now()) 62 | s.db[key] = actual 63 | 64 | for _, m := range expired { 65 | var bridgeMsg models.BridgeMessage 66 | fromID := "unknown" 67 | hash := sha256.Sum256(m.Message) 68 | messageHash := hex.EncodeToString(hash[:]) 69 | 70 | if err := json.Unmarshal(m.Message, &bridgeMsg); err == nil { 71 | fromID = bridgeMsg.From 72 | contentHash := sha256.Sum256([]byte(bridgeMsg.Message)) 73 | messageHash = hex.EncodeToString(contentHash[:]) 74 | } 75 | log.WithFields(map[string]interface{}{ 76 | "hash": messageHash, 77 | "from": fromID, 78 | "to": key, 79 | "event_id": m.EventId, 80 | "trace_id": bridgeMsg.TraceId, 81 | }).Debug("message expired") 82 | 83 | _ = s.analytics.TryAdd(s.eventBuilder.NewBridgeMessageExpiredEvent( 84 | key, 85 | bridgeMsg.TraceId, 86 | m.EventId, 87 | messageHash, 88 | )) 89 | } 90 | } 91 | s.lock.Unlock() 92 | 93 | _ = ExpiredCache.Cleanup() 94 | _ = TransferedCache.Cleanup() 95 | 96 | time.Sleep(time.Second) 97 | } 98 | } 99 | 100 | func (s *MemStorage) GetMessages(ctx context.Context, keys []string, lastEventId int64) ([]models.SseMessage, error) { 101 | s.lock.Lock() 102 | defer s.lock.Unlock() 103 | 104 | now := time.Now() 105 | results := make([]models.SseMessage, 0) 106 | for _, key := range keys { 107 | messages, ok := s.db[key] 108 | if !ok { 109 | continue 110 | } 111 | for _, m := range messages { 112 | if m.IsExpired(now) { 113 | continue 114 | } 115 | if m.EventId <= lastEventId { 116 | continue 117 | } 118 | results = append(results, m.SseMessage) 119 | } 120 | } 121 | return results, nil 122 | } 123 | 124 | func (s *MemStorage) Add(ctx context.Context, mes models.SseMessage, ttl int64) error { 125 | s.lock.Lock() 126 | defer s.lock.Unlock() 127 | 128 | s.db[mes.To] = append(s.db[mes.To], message{SseMessage: mes, expireAt: time.Now().Add(time.Duration(ttl) * time.Second)}) 129 | return nil 130 | } 131 | 132 | func (s *MemStorage) HealthCheck() error { 133 | return nil // Always healthy 134 | } 135 | -------------------------------------------------------------------------------- /docker/docker-compose.memory.yml: -------------------------------------------------------------------------------- 1 | services: 2 | bridge: 3 | container_name: bridge 4 | build: .. 5 | environment: 6 | PORT: 8081 7 | CORS_ENABLE: "true" 8 | HEARTBEAT_INTERVAL: 10 9 | RPS_LIMIT: 1000 10 | CONNECTIONS_LIMIT: 200 11 | TON_ANALYTICS_ENABLED: "true" 12 | TON_ANALYTICS_URL: "http://analytics-mock:9090/events" 13 | TON_ANALYTICS_NETWORK_ID: "-239" 14 | TON_ANALYTICS_BRIDGE_VERSION: "test-1.0.0" 15 | TON_ANALYTICS_BRIDGE_URL: "http://bridge-instance1:8080" 16 | ports: 17 | - "8081:8081" # bridge 18 | - "9103:9103" # pprof and metrics 19 | networks: 20 | - bridge_network 21 | restart: unless-stopped 22 | 23 | integration: 24 | build: 25 | context: .. 26 | dockerfile: docker/Dockerfile.integration 27 | container_name: bridge-integration 28 | environment: 29 | BRIDGE_URL: "http://bridge:8081/bridge" 30 | depends_on: 31 | bridge: 32 | condition: service_started 33 | networks: 34 | - bridge_network 35 | command: ["/bin/bash", "-c", "make test-bridge-sdk"] 36 | # command: ["/bin/bash", "-c", "tail -f /dev/null"] 37 | 38 | gointegration: 39 | build: 40 | context: .. 41 | dockerfile: docker/Dockerfile.gointegration 42 | container_name: bridge-gointegration 43 | environment: 44 | BRIDGE_URL: "http://bridge:8081/bridge" 45 | depends_on: 46 | bridge: 47 | condition: service_started 48 | networks: 49 | - bridge_network 50 | command: ["/bin/bash", "-c", "make test-gointegration"] 51 | # command: ["/bin/bash", "-c", "tail -f /dev/null"] 52 | 53 | 54 | benchmark: 55 | build: 56 | context: .. 57 | dockerfile: docker/Dockerfile.benchmark 58 | container_name: benchmark 59 | working_dir: /scripts 60 | environment: 61 | BRIDGE_URL: "http://bridge:8081/bridge" 62 | RAMP_UP: "1m" 63 | HOLD: "3m" 64 | RAMP_DOWN: "1m" 65 | SSE_VUS: "500" 66 | SEND_RATE: "10000" 67 | depends_on: 68 | bridge: 69 | condition: service_started 70 | networks: 71 | - bridge_network 72 | volumes: 73 | - ../benchmark:/scripts:ro 74 | - ../benchmark/results:/results 75 | entrypoint: [ "/bin/sh", "-lc" ] 76 | command: 77 | - > 78 | printf '\n\n====== pprof BEFORE ======\n\n'; 79 | pprof -top -sample_index=alloc_space -unit=GB -nodecount=5 http://bridge:9103/debug/pprof/allocs 2>/dev/null || true; 80 | pprof -proto http://bridge:9103/debug/pprof/allocs > /tmp/allocs-before.pb.gz 2>/dev/null || true; 81 | printf '\n\n====== starting BENCH ======\n\n'; 82 | printf 'Running load test for %s with:\n' "$$TEST_DURATION"; 83 | printf ' - SSE Virtual Users: %s\n' "$$SSE_VUS"; 84 | printf ' - Send Rate: %s msg/s\n' "$$SEND_RATE"; 85 | printf ' - Target: %s\n\n' "$$BRIDGE_URL"; 86 | printf '\n\nPlease wait...\n\n'; 87 | /usr/local/bin/k6 run --quiet --summary-export=/results/summary.json /scripts/bridge_test.js; 88 | printf '\n\n====== pprof AFTER ======\n\n'; 89 | pprof -top -sample_index=alloc_space -unit=GB -nodecount=5 http://bridge:9103/debug/pprof/allocs 2>/dev/null || true; 90 | pprof -proto http://bridge:9103/debug/pprof/allocs > /tmp/allocs-after.pb.gz 2>/dev/null || true; 91 | printf '\n\n====== pprof DIFF ======\n\n'; 92 | pprof -top -sample_index=alloc_space -unit=GB -nodecount=5 -base=/tmp/allocs-before.pb.gz /tmp/allocs-after.pb.gz || true; 93 | rm -f /tmp/allocs-before.pb.gz /tmp/allocs-after.pb.gz 2>/dev/null || true; 94 | printf '\n\n============================\n\n'; 95 | 96 | networks: 97 | bridge_network: 98 | driver: bridge -------------------------------------------------------------------------------- /internal/ntp/client.go: -------------------------------------------------------------------------------- 1 | package ntp 2 | 3 | import ( 4 | "context" 5 | "sync/atomic" 6 | "time" 7 | 8 | "github.com/beevik/ntp" 9 | "github.com/sirupsen/logrus" 10 | ) 11 | 12 | type Client struct { 13 | servers []string 14 | syncInterval time.Duration 15 | queryTimeout time.Duration 16 | offset atomic.Int64 // stored as nanoseconds (time.Duration) 17 | lastSync atomic.Int64 18 | stopCh chan struct{} 19 | stopped atomic.Bool 20 | } 21 | 22 | type Options struct { 23 | Servers []string 24 | SyncInterval time.Duration 25 | QueryTimeout time.Duration 26 | } 27 | 28 | func NewClient(opts Options) *Client { 29 | if len(opts.Servers) == 0 { 30 | opts.Servers = []string{ 31 | "time.google.com", 32 | "time.cloudflare.com", 33 | "pool.ntp.org", 34 | } 35 | } 36 | 37 | if opts.SyncInterval == 0 { 38 | opts.SyncInterval = 5 * time.Minute 39 | } 40 | 41 | if opts.QueryTimeout == 0 { 42 | opts.QueryTimeout = 5 * time.Second 43 | } 44 | 45 | client := &Client{ 46 | servers: opts.Servers, 47 | syncInterval: opts.SyncInterval, 48 | queryTimeout: opts.QueryTimeout, 49 | stopCh: make(chan struct{}), 50 | } 51 | 52 | return client 53 | } 54 | 55 | func (c *Client) Start(ctx context.Context) { 56 | if !c.stopped.CompareAndSwap(true, false) { 57 | logrus.Warn("NTP client already started") 58 | return 59 | } 60 | 61 | logrus.WithFields(logrus.Fields{ 62 | "servers": c.servers, 63 | "sync_interval": c.syncInterval, 64 | }).Info("Starting NTP client") 65 | 66 | c.syncOnce() 67 | 68 | go c.syncLoop(ctx) 69 | } 70 | 71 | func (c *Client) Stop() { 72 | if !c.stopped.CompareAndSwap(false, true) { 73 | return 74 | } 75 | close(c.stopCh) 76 | logrus.Info("NTP client stopped") 77 | } 78 | 79 | func (c *Client) syncLoop(ctx context.Context) { 80 | ticker := time.NewTicker(c.syncInterval) 81 | defer ticker.Stop() 82 | 83 | for { 84 | select { 85 | case <-ctx.Done(): 86 | return 87 | case <-c.stopCh: 88 | return 89 | case <-ticker.C: 90 | c.syncOnce() 91 | } 92 | } 93 | } 94 | 95 | func (c *Client) syncOnce() { 96 | for _, server := range c.servers { 97 | if c.trySyncWithServer(server) { 98 | return 99 | } 100 | } 101 | 102 | logrus.Warn("Failed to synchronize with any NTP server, using local time") 103 | } 104 | 105 | func (c *Client) trySyncWithServer(server string) bool { 106 | ctx, cancel := context.WithTimeout(context.Background(), c.queryTimeout) 107 | defer cancel() 108 | 109 | options := ntp.QueryOptions{ 110 | Timeout: c.queryTimeout, 111 | } 112 | 113 | response, err := ntp.QueryWithOptions(server, options) 114 | if err != nil { 115 | logrus.WithFields(logrus.Fields{ 116 | "server": server, 117 | "error": err, 118 | }).Debug("Failed to query NTP server") 119 | return false 120 | } 121 | 122 | if err := response.Validate(); err != nil { 123 | logrus.WithFields(logrus.Fields{ 124 | "server": server, 125 | "error": err, 126 | }).Debug("Invalid response from NTP server") 127 | return false 128 | } 129 | 130 | c.offset.Store(int64(response.ClockOffset)) 131 | c.lastSync.Store(time.Now().Unix()) 132 | 133 | logrus.WithFields(logrus.Fields{ 134 | "server": server, 135 | "offset": response.ClockOffset, 136 | "precision": response.RTT / 2, 137 | "rtt": response.RTT, 138 | }).Info("Successfully synchronized with NTP server") 139 | 140 | select { 141 | case <-ctx.Done(): 142 | return false 143 | default: 144 | return true 145 | } 146 | } 147 | 148 | func (c *Client) now() time.Time { 149 | offset := time.Duration(c.offset.Load()) 150 | return time.Now().Add(offset) 151 | } 152 | 153 | func (c *Client) NowUnixMilli() int64 { 154 | return c.now().UnixMilli() 155 | } 156 | -------------------------------------------------------------------------------- /test/clustered/run-cluster-3-nodes.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Script to run tests on 3-node Valkey cluster 4 | # Usage: ./run-cluster-3-nodes.sh [write-heavy|read-heavy|mixed] 5 | 6 | set -e 7 | 8 | SCENARIO=${1:-mixed} 9 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 10 | COMPOSE_FILE="$SCRIPT_DIR/docker-compose.cluster-3-nodes.yml" 11 | 12 | # Define test scenarios 13 | case "$SCENARIO" in 14 | write-heavy) 15 | echo "Running WRITE-HEAVY scenario (75% writes, 25% reads)" 16 | export SSE_VUS=250 17 | export SEND_RATE=7500 18 | export TEST_DURATION=2m 19 | ;; 20 | read-heavy) 21 | echo "Running READ-HEAVY scenario (25% writes, 75% reads)" 22 | export SSE_VUS=7500 23 | export SEND_RATE=250 24 | export TEST_DURATION=2m 25 | ;; 26 | mixed) 27 | echo "Running MIXED scenario (50% writes, 50% reads)" 28 | export SSE_VUS=500 29 | export SEND_RATE=5000 30 | export TEST_DURATION=2m 31 | ;; 32 | *) 33 | echo "Unknown scenario: $SCENARIO" 34 | echo "Usage: $0 [write-heavy|read-heavy|mixed]" 35 | exit 1 36 | ;; 37 | esac 38 | 39 | # Create results directory 40 | mkdir -p "$SCRIPT_DIR/results/cluster-3-nodes" 41 | 42 | # Clean up any existing containers 43 | echo "Cleaning up existing containers..." 44 | docker-compose -f "$COMPOSE_FILE" down -v 2>/dev/null || true 45 | 46 | # Start the cluster 47 | echo "Starting 3-node Valkey cluster..." 48 | docker-compose -f "$COMPOSE_FILE" up -d valkey-shard1 valkey-shard2 valkey-shard3 valkey-cluster-init bridge data-monitor 49 | 50 | # Wait for cluster to be ready 51 | echo "Waiting for cluster initialization..." 52 | sleep 15 53 | 54 | # Check cluster status 55 | echo "Checking cluster status..." 56 | docker-compose -f "$COMPOSE_FILE" exec -T valkey-shard1 valkey-cli cluster info || true 57 | 58 | # Run benchmark 59 | echo "" 60 | echo "========================================" 61 | echo "Starting benchmark: $SCENARIO" 62 | echo "SSE_VUS=$SSE_VUS" 63 | echo "SEND_RATE=$SEND_RATE" 64 | echo "TEST_DURATION=$TEST_DURATION" 65 | echo "========================================" 66 | echo "" 67 | 68 | docker-compose -f "$COMPOSE_FILE" up --abort-on-container-exit benchmark 69 | 70 | # Save test metadata 71 | RESULT_FILE="$SCRIPT_DIR/results/cluster-3-nodes/test-metadata-$SCENARIO.txt" 72 | echo "Test Scenario: $SCENARIO" > "$RESULT_FILE" 73 | echo "Date: $(date)" >> "$RESULT_FILE" 74 | echo "SSE_VUS: $SSE_VUS" >> "$RESULT_FILE" 75 | echo "SEND_RATE: $SEND_RATE" >> "$RESULT_FILE" 76 | echo "TEST_DURATION: $TEST_DURATION" >> "$RESULT_FILE" 77 | echo "Cluster: 3-node (0.45 CPU total)" >> "$RESULT_FILE" 78 | 79 | # Rename summary file to include scenario 80 | if [ -f "$SCRIPT_DIR/results/cluster-3-nodes/summary.json" ]; then 81 | mv "$SCRIPT_DIR/results/cluster-3-nodes/summary.json" \ 82 | "$SCRIPT_DIR/results/cluster-3-nodes/summary-$SCENARIO.json" 83 | fi 84 | 85 | echo "" 86 | echo "========================================" 87 | echo "Test completed: $SCENARIO" 88 | echo "Results saved to: results/cluster-3-nodes/" 89 | echo "========================================" 90 | echo "" 91 | 92 | # Show quick summary 93 | if [ -f "$SCRIPT_DIR/results/cluster-3-nodes/summary-$SCENARIO.json" ]; then 94 | echo "Quick Summary:" 95 | cat "$SCRIPT_DIR/results/cluster-3-nodes/summary-$SCENARIO.json" | \ 96 | grep -E '"http_req_failed"|"http_req_duration"|"delivery_latency"' || true 97 | fi 98 | 99 | # Keep cluster running for inspection or tear down 100 | read -p "Keep cluster running for inspection? (y/N) " -n 1 -r 101 | echo 102 | if [[ ! $REPLY =~ ^[Yy]$ ]]; then 103 | echo "Stopping cluster..." 104 | docker-compose -f "$COMPOSE_FILE" down 105 | else 106 | echo "Cluster is still running. To stop it later, run:" 107 | echo " docker-compose -f $COMPOSE_FILE down" 108 | echo "" 109 | echo "To view logs:" 110 | echo " docker-compose -f $COMPOSE_FILE logs -f" 111 | fi 112 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "log" 5 | "strings" 6 | 7 | "github.com/caarlos0/env/v6" 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | var Config = struct { 12 | // Core Settings 13 | LogLevel string `env:"LOG_LEVEL" envDefault:"info"` 14 | Port int `env:"PORT" envDefault:"8081"` 15 | MetricsPort int `env:"METRICS_PORT" envDefault:"9103"` 16 | PprofEnabled bool `env:"PPROF_ENABLED" envDefault:"true"` 17 | 18 | // Storage 19 | Storage string `env:"STORAGE" envDefault:"memory"` // For v3 only: memory or valkey 20 | ValkeyURI string `env:"VALKEY_URI"` 21 | 22 | // PostgreSQL Pool Settings 23 | PostgresURI string `env:"POSTGRES_URI"` 24 | PostgresMaxConns int32 `env:"POSTGRES_MAX_CONNS" envDefault:"25"` 25 | PostgresMinConns int32 `env:"POSTGRES_MIN_CONNS" envDefault:"0"` 26 | PostgresMaxConnLifetime string `env:"POSTGRES_MAX_CONN_LIFETIME" envDefault:"1h"` 27 | PostgresMaxConnLifetimeJitter string `env:"POSTGRES_MAX_CONN_LIFETIME_JITTER" envDefault:"10m"` 28 | PostgresMaxConnIdleTime string `env:"POSTGRES_MAX_CONN_IDLE_TIME" envDefault:"30m"` 29 | PostgresHealthCheckPeriod string `env:"POSTGRES_HEALTH_CHECK_PERIOD" envDefault:"1m"` 30 | PostgresLazyConnect bool `env:"POSTGRES_LAZY_CONNECT" envDefault:"false"` 31 | 32 | // Performance & Limits 33 | HeartbeatInterval int `env:"HEARTBEAT_INTERVAL" envDefault:"10"` 34 | RPSLimit int `env:"RPS_LIMIT" envDefault:"10"` 35 | ConnectionsLimit int `env:"CONNECTIONS_LIMIT" envDefault:"50"` 36 | MaxBodySize int64 `env:"MAX_BODY_SIZE" envDefault:"10485760"` // 10 MB 37 | RateLimitsByPassToken []string `env:"RATE_LIMITS_BY_PASS_TOKEN"` 38 | 39 | // Security 40 | CorsEnable bool `env:"CORS_ENABLE" envDefault:"true"` 41 | TrustedProxyRanges []string `env:"TRUSTED_PROXY_RANGES" envDefault:"0.0.0.0/0"` 42 | SelfSignedTLS bool `env:"SELF_SIGNED_TLS" envDefault:"false"` 43 | 44 | // Caching 45 | ConnectCacheSize int `env:"CONNECT_CACHE_SIZE" envDefault:"2000000"` 46 | ConnectCacheTTL int `env:"CONNECT_CACHE_TTL" envDefault:"300"` 47 | EnableTransferedCache bool `env:"ENABLE_TRANSFERED_CACHE" envDefault:"true"` 48 | EnableExpiredCache bool `env:"ENABLE_EXPIRED_CACHE" envDefault:"true"` 49 | 50 | // Events & Webhooks 51 | DisconnectEventsTTL int64 `env:"DISCONNECT_EVENTS_TTL" envDefault:"3600"` 52 | DisconnectEventMaxSize int `env:"DISCONNECT_EVENT_MAX_SIZE" envDefault:"512"` 53 | WebhookURL string `env:"WEBHOOK_URL"` 54 | CopyToURL string `env:"COPY_TO_URL"` 55 | 56 | // NTP Configuration 57 | NTPEnabled bool `env:"NTP_ENABLED" envDefault:"true"` 58 | NTPServers []string `env:"NTP_SERVERS" envSeparator:"," envDefault:"time.google.com,time.cloudflare.com,pool.ntp.org"` 59 | NTPSyncInterval int `env:"NTP_SYNC_INTERVAL" envDefault:"300"` 60 | NTPQueryTimeout int `env:"NTP_QUERY_TIMEOUT" envDefault:"5"` 61 | 62 | // TON Analytics 63 | TONAnalyticsEnabled bool `env:"TON_ANALYTICS_ENABLED" envDefault:"false"` 64 | TonAnalyticsURL string `env:"TON_ANALYTICS_URL" envDefault:"https://analytics.ton.org/events"` 65 | TonAnalyticsBridgeVersion string `env:"TON_ANALYTICS_BRIDGE_VERSION" envDefault:"1.0.0"` // TODO start using build version 66 | TonAnalyticsBridgeURL string `env:"TON_ANALYTICS_BRIDGE_URL" envDefault:"localhost"` 67 | TonAnalyticsNetworkId string `env:"TON_ANALYTICS_NETWORK_ID" envDefault:"-239"` 68 | }{} 69 | 70 | func LoadConfig() { 71 | if err := env.Parse(&Config); err != nil { 72 | log.Fatalf("config parsing failed: %v\n", err) 73 | } 74 | 75 | level, err := logrus.ParseLevel(strings.ToLower(Config.LogLevel)) 76 | if err != nil { 77 | log.Printf("Invalid LOG_LEVEL '%s', using default 'info'. Valid levels: panic, fatal, error, warn, info, debug, trace", Config.LogLevel) 78 | level = logrus.InfoLevel 79 | } 80 | logrus.SetLevel(level) 81 | } 82 | -------------------------------------------------------------------------------- /scripts/pr-metrics.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | STORAGE_TYPE="${1:-unknown}" 5 | BRIDGE_HOST="${2:-localhost}" 6 | BRIDGE_PORT="${3:-9103}" 7 | 8 | echo "Waiting for bridge service at $BRIDGE_HOST:$BRIDGE_PORT..." >&2 9 | for i in {1..30}; do 10 | if curl -s "http://$BRIDGE_HOST:$BRIDGE_PORT/metrics" > /dev/null 2>&1; then 11 | echo "Bridge is ready!" >&2 12 | break 13 | fi 14 | if [ $i -eq 30 ]; then 15 | echo "📊 Performance Metrics ($STORAGE_TYPE storage)" 16 | echo "" 17 | echo "❌ **Bridge service not accessible after 30 seconds**" 18 | exit 0 19 | fi 20 | sleep 1 21 | done 22 | 23 | # Get goroutine count (need ?debug=1 for text output) 24 | GOROUTINE_DATA=$(curl -s "http://$BRIDGE_HOST:$BRIDGE_PORT/debug/pprof/goroutine?debug=1" 2>/dev/null || echo "") 25 | GOROUTINES=$(echo "$GOROUTINE_DATA" | head -1 | grep -o '[0-9]\+' | head -1 || echo "0") 26 | 27 | # Get metrics data once and reuse 28 | METRICS_DATA=$(curl -s "http://$BRIDGE_HOST:$BRIDGE_PORT/metrics" 2>/dev/null || echo "") 29 | 30 | # CPU Metrics 31 | PROCESS_CPU_TOTAL=$(echo "$METRICS_DATA" | grep "process_cpu_seconds_total" | grep -o '[0-9.]\+' | tail -1 || echo "0") 32 | PROCESS_CPU_FORMATTED=$(echo "$PROCESS_CPU_TOTAL" | awk '{printf "%.2f", $1}' 2>/dev/null || echo "0.00") 33 | GOMAXPROCS=$(echo "$METRICS_DATA" | grep "go_sched_gomaxprocs_threads" | grep -o '[0-9]\+' || echo "0") 34 | 35 | # Memory Metrics 36 | HEAP_BYTES=$(echo "$METRICS_DATA" | grep "go_memstats_heap_inuse_bytes" | grep -o '[0-9.e+-]\+' | tail -1 || echo "0") 37 | HEAP_MB=$(echo "$HEAP_BYTES" | awk '{printf "%.1f", $1/1024/1024}' 2>/dev/null || echo "0.0") 38 | 39 | HEAP_ALLOC_BYTES=$(echo "$METRICS_DATA" | grep "go_memstats_heap_alloc_bytes " | grep -o '[0-9.e+-]\+' | tail -1 || echo "0") 40 | HEAP_ALLOC_MB=$(echo "$HEAP_ALLOC_BYTES" | awk '{printf "%.1f", $1/1024/1024}' 2>/dev/null || echo "0.0") 41 | 42 | RSS_BYTES=$(echo "$METRICS_DATA" | grep "process_resident_memory_bytes" | grep -o '[0-9.e+-]\+' | tail -1 || echo "0") 43 | RSS_MB=$(echo "$RSS_BYTES" | awk '{printf "%.1f", $1/1024/1024}' 2>/dev/null || echo "0.0") 44 | 45 | TOTAL_ALLOCS=$(echo "$METRICS_DATA" | grep "go_memstats_alloc_bytes_total" | grep -o '[0-9.e+-]\+' | tail -1 || echo "0") 46 | TOTAL_ALLOCS_MB=$(echo "$TOTAL_ALLOCS" | awk '{printf "%.1f", $1/1024/1024}' 2>/dev/null || echo "0.0") 47 | 48 | # Garbage Collection Metrics 49 | GC_COUNT=$(echo "$METRICS_DATA" | grep "go_gc_duration_seconds_count" | grep -o '[0-9]\+' || echo "0") 50 | GC_TOTAL_TIME=$(echo "$METRICS_DATA" | grep "go_gc_duration_seconds_sum" | grep -o '[0-9.]\+' || echo "0") 51 | GC_AVG_MS=$(echo "$GC_TOTAL_TIME $GC_COUNT" | awk '{if($2>0) printf "%.2f", ($1/$2)*1000; else print "0"}' 2>/dev/null || echo "0") 52 | 53 | # Resource Metrics - robust cross-platform parsing 54 | OPEN_FDS=$(echo "$METRICS_DATA" | awk '/^process_open_fds / {print int($2); exit}' || echo "0") 55 | MAX_FDS=$(echo "$METRICS_DATA" | awk '/^process_max_fds / {print int($2); exit}' || echo "0") 56 | FD_USAGE_PERCENT=$(echo "$OPEN_FDS $MAX_FDS" | awk '{if($2>0) printf "%.1f", ($1/$2)*100; else print "0.0"}' || echo "0.0") 57 | 58 | # Get total allocation count from metrics (not pprof sampling) 59 | ALLOCS_COUNT=$(echo "$METRICS_DATA" | awk '/^go_memstats_mallocs_total / {print int($2); exit}' || echo "0") 60 | 61 | # Get thread count 62 | THREADS_DATA=$(curl -s "http://$BRIDGE_HOST:$BRIDGE_PORT/debug/pprof/threadcreate?debug=1" 2>/dev/null || echo "") 63 | THREADS=$(echo "$THREADS_DATA" | head -1 | grep -o 'total [0-9]\+' | grep -o '[0-9]\+' || echo "0") 64 | 65 | # Output compact markdown to stdout 66 | echo "**Performance Metrics** ($STORAGE_TYPE storage)" 67 | echo "" 68 | echo "- **CPU:** ${PROCESS_CPU_FORMATTED}s (${GOMAXPROCS} cores) • **Goroutines:** $GOROUTINES • **Threads:** $THREADS" 69 | echo "- **Memory:** ${HEAP_ALLOC_MB}MB heap • ${RSS_MB}MB RAM • ${TOTAL_ALLOCS_MB}MB total • $ALLOCS_COUNT allocs" 70 | echo "- **GC:** $GC_COUNT cycles (${GC_AVG_MS}ms avg)" 71 | echo "- **FDs:** $OPEN_FDS/$MAX_FDS (${FD_USAGE_PERCENT}%)" 72 | echo "" 73 | -------------------------------------------------------------------------------- /test/clustered/run-cluster-6-nodes.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Script to run tests on 6-node Valkey cluster 4 | # Usage: ./run-cluster-6-nodes.sh [write-heavy|read-heavy|mixed] 5 | 6 | set -e 7 | 8 | SCENARIO=${1:-mixed} 9 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 10 | COMPOSE_FILE="$SCRIPT_DIR/docker-compose.cluster-6-nodes.yml" 11 | 12 | # Define test scenarios 13 | case "$SCENARIO" in 14 | write-heavy) 15 | echo "Running WRITE-HEAVY scenario (75% writes, 25% reads)" 16 | export SSE_VUS=250 17 | export SEND_RATE=7500 18 | export TEST_DURATION=2m 19 | ;; 20 | read-heavy) 21 | echo "Running READ-HEAVY scenario (25% writes, 75% reads)" 22 | export SSE_VUS=7500 23 | export SEND_RATE=250 24 | export TEST_DURATION=2m 25 | ;; 26 | mixed) 27 | echo "Running MIXED scenario (50% writes, 50% reads)" 28 | export SSE_VUS=500 29 | export SEND_RATE=5000 30 | export TEST_DURATION=2m 31 | ;; 32 | *) 33 | echo "Unknown scenario: $SCENARIO" 34 | echo "Usage: $0 [write-heavy|read-heavy|mixed]" 35 | exit 1 36 | ;; 37 | esac 38 | 39 | # Create results directory 40 | mkdir -p "$SCRIPT_DIR/results/cluster-6-nodes" 41 | 42 | # Clean up any existing containers 43 | echo "Cleaning up existing containers..." 44 | docker-compose -f "$COMPOSE_FILE" down -v 2>/dev/null || true 45 | 46 | # Start the cluster 47 | echo "Starting 6-node Valkey cluster..." 48 | docker-compose -f "$COMPOSE_FILE" up -d valkey-shard1 valkey-shard2 valkey-shard3 valkey-shard4 valkey-shard5 valkey-shard6 valkey-cluster-init bridge data-monitor 49 | 50 | # Wait for cluster to be ready 51 | echo "Waiting for cluster initialization..." 52 | sleep 20 53 | 54 | # Check cluster status 55 | echo "Checking cluster status..." 56 | docker-compose -f "$COMPOSE_FILE" exec -T valkey-shard1 valkey-cli cluster info || true 57 | 58 | # Run benchmark 59 | echo "" 60 | echo "========================================" 61 | echo "Starting benchmark: $SCENARIO" 62 | echo "SSE_VUS=$SSE_VUS" 63 | echo "SEND_RATE=$SEND_RATE" 64 | echo "TEST_DURATION=$TEST_DURATION" 65 | echo "========================================" 66 | echo "" 67 | 68 | docker-compose -f "$COMPOSE_FILE" up --abort-on-container-exit benchmark 69 | 70 | # Save test metadata 71 | RESULT_FILE="$SCRIPT_DIR/results/cluster-6-nodes/test-metadata-$SCENARIO.txt" 72 | echo "Test Scenario: $SCENARIO" > "$RESULT_FILE" 73 | echo "Date: $(date)" >> "$RESULT_FILE" 74 | echo "SSE_VUS: $SSE_VUS" >> "$RESULT_FILE" 75 | echo "SEND_RATE: $SEND_RATE" >> "$RESULT_FILE" 76 | echo "TEST_DURATION: $TEST_DURATION" >> "$RESULT_FILE" 77 | echo "Cluster: 6-node (0.90 CPU total)" >> "$RESULT_FILE" 78 | 79 | # Rename summary file to include scenario 80 | if [ -f "$SCRIPT_DIR/results/cluster-6-nodes/summary.json" ]; then 81 | mv "$SCRIPT_DIR/results/cluster-6-nodes/summary.json" \ 82 | "$SCRIPT_DIR/results/cluster-6-nodes/summary-$SCENARIO.json" 83 | fi 84 | 85 | echo "" 86 | echo "========================================" 87 | echo "Test completed: $SCENARIO" 88 | echo "Results saved to: results/cluster-6-nodes/" 89 | echo "========================================" 90 | echo "" 91 | 92 | # Show quick summary 93 | if [ -f "$SCRIPT_DIR/results/cluster-6-nodes/summary-$SCENARIO.json" ]; then 94 | echo "Quick Summary:" 95 | cat "$SCRIPT_DIR/results/cluster-6-nodes/summary-$SCENARIO.json" | \ 96 | grep -E '"http_req_failed"|"http_req_duration"|"delivery_latency"' || true 97 | fi 98 | 99 | # Keep cluster running for inspection or tear down 100 | read -p "Keep cluster running for inspection? (y/N) " -n 1 -r 101 | echo 102 | if [[ ! $REPLY =~ ^[Yy]$ ]]; then 103 | echo "Stopping cluster..." 104 | docker-compose -f "$COMPOSE_FILE" down 105 | else 106 | echo "Cluster is still running. To stop it later, run:" 107 | echo " docker-compose -f $COMPOSE_FILE down" 108 | echo "" 109 | echo "To view logs:" 110 | echo " docker-compose -f $COMPOSE_FILE logs -f" 111 | fi 112 | -------------------------------------------------------------------------------- /internal/v1/storage/pg_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/jackc/pgx/v4/pgxpool" 8 | "github.com/ton-connect/bridge/internal/config" 9 | ) 10 | 11 | func TestConfigurePoolSettings(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | uri string 15 | setup func() 16 | wantErr bool 17 | validate func(*testing.T, *pgxpool.Config) 18 | }{ 19 | { 20 | name: "default settings", 21 | uri: "postgres://user:pass@localhost/test", 22 | setup: func() { 23 | config.Config.PostgresMaxConns = 4 24 | config.Config.PostgresMinConns = 0 25 | config.Config.PostgresMaxConnLifetime = "1h" 26 | config.Config.PostgresMaxConnLifetimeJitter = "10m" 27 | config.Config.PostgresMaxConnIdleTime = "30m" 28 | config.Config.PostgresHealthCheckPeriod = "1m" 29 | config.Config.PostgresLazyConnect = false 30 | }, 31 | wantErr: false, 32 | validate: func(t *testing.T, cfg *pgxpool.Config) { 33 | if cfg.MaxConns != 4 { 34 | t.Errorf("Expected MaxConns=4, got %d", cfg.MaxConns) 35 | } 36 | if cfg.MinConns != 0 { 37 | t.Errorf("Expected MinConns=0, got %d", cfg.MinConns) 38 | } 39 | if cfg.MaxConnLifetime != time.Hour { 40 | t.Errorf("Expected MaxConnLifetime=1h, got %v", cfg.MaxConnLifetime) 41 | } 42 | }, 43 | }, 44 | { 45 | name: "custom settings", 46 | uri: "postgres://user:pass@localhost/test", 47 | setup: func() { 48 | config.Config.PostgresMaxConns = 10 49 | config.Config.PostgresMinConns = 2 50 | config.Config.PostgresMaxConnLifetime = "45m" 51 | config.Config.PostgresMaxConnLifetimeJitter = "5m" 52 | config.Config.PostgresMaxConnIdleTime = "15m" 53 | config.Config.PostgresHealthCheckPeriod = "30s" 54 | config.Config.PostgresLazyConnect = true 55 | }, 56 | wantErr: false, 57 | validate: func(t *testing.T, cfg *pgxpool.Config) { 58 | if cfg.MaxConns != 10 { 59 | t.Errorf("Expected MaxConns=10, got %d", cfg.MaxConns) 60 | } 61 | if cfg.MinConns != 2 { 62 | t.Errorf("Expected MinConns=2, got %d", cfg.MinConns) 63 | } 64 | if cfg.MaxConnLifetime != 45*time.Minute { 65 | t.Errorf("Expected MaxConnLifetime=45m, got %v", cfg.MaxConnLifetime) 66 | } 67 | if !cfg.LazyConnect { 68 | t.Error("Expected LazyConnect=true") 69 | } 70 | }, 71 | }, 72 | { 73 | name: "invalid duration uses pgxpool defaults", 74 | uri: "postgres://user:pass@localhost/test", 75 | setup: func() { 76 | config.Config.PostgresMaxConns = 5 77 | config.Config.PostgresMinConns = 1 78 | config.Config.PostgresMaxConnLifetime = "invalid-duration" 79 | config.Config.PostgresMaxConnLifetimeJitter = "1m" 80 | config.Config.PostgresMaxConnIdleTime = "10m" 81 | config.Config.PostgresHealthCheckPeriod = "30s" 82 | config.Config.PostgresLazyConnect = false 83 | }, 84 | wantErr: false, 85 | validate: func(t *testing.T, cfg *pgxpool.Config) { 86 | if cfg.MaxConns != 5 { 87 | t.Errorf("Expected MaxConns=5, got %d", cfg.MaxConns) 88 | } 89 | // Invalid duration should keep pgxpool default, which is 0 initially 90 | // but pgxpool may set its own defaults internally 91 | }, 92 | }, 93 | { 94 | name: "invalid URI", 95 | uri: "not-a-valid-uri", 96 | setup: func() {}, 97 | wantErr: true, 98 | validate: func(t *testing.T, cfg *pgxpool.Config) { 99 | // Should not be called for error cases 100 | }, 101 | }, 102 | } 103 | 104 | for _, tt := range tests { 105 | t.Run(tt.name, func(t *testing.T) { 106 | tt.setup() 107 | 108 | poolConfig, err := configurePoolSettings(tt.uri) 109 | 110 | if (err != nil) != tt.wantErr { 111 | t.Errorf("configurePoolSettings() error = %v, wantErr %v", err, tt.wantErr) 112 | return 113 | } 114 | 115 | if !tt.wantErr && poolConfig != nil { 116 | tt.validate(t, poolConfig) 117 | } 118 | }) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /internal/handler/params_test.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/labstack/echo/v4" 10 | ) 11 | 12 | const maxBodySize = 1048576 // 1 MB 13 | 14 | func TestParamsStorage_URLParameters(t *testing.T) { 15 | e := echo.New() 16 | req := httptest.NewRequest(http.MethodGet, "/test?client_id=test123&heartbeat=message", nil) 17 | rec := httptest.NewRecorder() 18 | c := e.NewContext(req, rec) 19 | 20 | params, err := NewParamsStorage(c, maxBodySize) 21 | if err != nil { 22 | t.Error(err) 23 | return 24 | } 25 | 26 | clientID, ok := params.Get("client_id") 27 | if !ok { 28 | t.Error("Expected to find client_id parameter") 29 | } 30 | if clientID != "test123" { 31 | t.Errorf("Expected client_id=test123, got %s", clientID) 32 | } 33 | 34 | heartbeat, ok := params.Get("heartbeat") 35 | if !ok { 36 | t.Error("Expected to find heartbeat parameter") 37 | } 38 | if heartbeat != "message" { 39 | t.Errorf("Expected heartbeat=message, got %s", heartbeat) 40 | } 41 | } 42 | 43 | func TestParamsStorage_JSONBodyParameters(t *testing.T) { 44 | e := echo.New() 45 | 46 | jsonBody := `{"client_id": "test456", "to": "test123", "ttl": "300"}` 47 | req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(jsonBody)) 48 | req.Header.Set("Content-Type", "application/json") 49 | rec := httptest.NewRecorder() 50 | c := e.NewContext(req, rec) 51 | 52 | params, err := NewParamsStorage(c, maxBodySize) 53 | if err != nil { 54 | t.Error(err) 55 | return 56 | } 57 | 58 | clientID, ok := params.Get("client_id") 59 | if !ok { 60 | t.Error("Expected to find client_id parameter") 61 | } 62 | if clientID != "test456" { 63 | t.Errorf("Expected client_id=test456, got %s", clientID) 64 | } 65 | 66 | to, ok := params.Get("to") 67 | if !ok { 68 | t.Error("Expected to find to parameter") 69 | } 70 | if to != "test123" { 71 | t.Errorf("Expected to=test123, got %s", to) 72 | } 73 | } 74 | 75 | func TestParamsStorage_JSONBodyParametersWithWrongContentType(t *testing.T) { 76 | e := echo.New() 77 | 78 | jsonBody := `{"client_id": "test456", "to": "test123", "ttl": "300"}` 79 | req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(jsonBody)) 80 | req.Header.Set("Content-Type", "text/event-stream") 81 | rec := httptest.NewRecorder() 82 | c := e.NewContext(req, rec) 83 | 84 | params, err := NewParamsStorage(c, maxBodySize) 85 | if err != nil { 86 | t.Error(err) 87 | return 88 | } 89 | 90 | _, ok := params.Get("client_id") 91 | if ok { 92 | t.Error("Expected not to find client_id parameter") 93 | } 94 | } 95 | 96 | func TestParamsStorage_NonJSONBody(t *testing.T) { 97 | e := echo.New() 98 | 99 | body := "This is just a regular message, not JSON" 100 | req := httptest.NewRequest(http.MethodPost, "/test?client_id=test123", strings.NewReader(body)) 101 | rec := httptest.NewRecorder() 102 | c := e.NewContext(req, rec) 103 | 104 | params, err := NewParamsStorage(c, maxBodySize) 105 | if err != nil { 106 | t.Error(err) 107 | return 108 | } 109 | 110 | clientID, ok := params.Get("client_id") 111 | if !ok { 112 | t.Error("Expected to find client_id parameter") 113 | } 114 | if clientID != "test123" { 115 | t.Errorf("Expected client_id=test123, got %s", clientID) 116 | } 117 | } 118 | 119 | func TestParamsStorage_JSONBodyIsTooLarge(t *testing.T) { 120 | e := echo.New() 121 | 122 | jsonBody := `{"client_id": "test456", "to": "test123", "ttl": "300"}` 123 | req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(jsonBody)) 124 | req.Header.Set("Content-Type", "application/json") 125 | rec := httptest.NewRecorder() 126 | c := e.NewContext(req, rec) 127 | 128 | _, err := NewParamsStorage(c, 10) 129 | if err == nil { 130 | t.Error("Expected error when body is too large") 131 | } else if !strings.Contains(err.Error(), "body too large") { 132 | t.Errorf("Expected 'body too large' error, got: %s", err.Error()) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /tonmetrics/analytics.go: -------------------------------------------------------------------------------- 1 | package tonmetrics 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/sirupsen/logrus" 12 | "github.com/ton-connect/bridge/internal/config" 13 | ) 14 | 15 | // AnalyticsClient defines the interface for analytics clients 16 | type AnalyticsClient interface { 17 | SendBatch(ctx context.Context, events []interface{}) error 18 | } 19 | 20 | // TonMetricsClient handles sending analytics events 21 | type TonMetricsClient struct { 22 | client *http.Client 23 | analyticsURL string 24 | bridgeURL string 25 | environment string 26 | subsystem string 27 | version string 28 | networkId string 29 | } 30 | 31 | // NewAnalyticsClient creates a new analytics client 32 | func NewAnalyticsClient() AnalyticsClient { 33 | configuredAnalyticsURL := config.Config.TonAnalyticsURL 34 | if !config.Config.TONAnalyticsEnabled { 35 | return NewNoopMetricsClient(configuredAnalyticsURL) 36 | } 37 | if config.Config.TonAnalyticsNetworkId != "-239" && config.Config.TonAnalyticsNetworkId != "-3" { 38 | logrus.Fatalf("invalid NETWORK_ID '%s'. Allowed values: -239 (mainnet) and -3 (testnet)", config.Config.TonAnalyticsNetworkId) 39 | } 40 | return &TonMetricsClient{ 41 | client: http.DefaultClient, 42 | analyticsURL: configuredAnalyticsURL, 43 | bridgeURL: config.Config.TonAnalyticsBridgeURL, 44 | subsystem: "bridge", 45 | environment: "bridge", 46 | version: config.Config.TonAnalyticsBridgeVersion, 47 | networkId: config.Config.TonAnalyticsNetworkId, 48 | } 49 | } 50 | 51 | // SendBatch sends a batch of events to the analytics endpoint in a single HTTP request. 52 | func (a *TonMetricsClient) SendBatch(ctx context.Context, events []interface{}) error { 53 | return a.send(ctx, events, a.analyticsURL, "analytics") 54 | } 55 | 56 | func (a *TonMetricsClient) send(ctx context.Context, events []interface{}, endpoint string, prefix string) error { 57 | if len(events) == 0 { 58 | return nil 59 | } 60 | 61 | log := logrus.WithField("prefix", prefix) 62 | 63 | log.Debugf("preparing to send analytics batch of %d events to %s", len(events), endpoint) 64 | 65 | analyticsData, err := json.Marshal(events) 66 | if err != nil { 67 | return fmt.Errorf("failed to marshal analytics batch: %w", err) 68 | } 69 | 70 | log.Debugf("marshaled analytics data size: %d bytes", len(analyticsData)) 71 | 72 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(analyticsData)) 73 | if err != nil { 74 | return fmt.Errorf("failed to create analytics request: %w", err) 75 | } 76 | 77 | req.Header.Set("Content-Type", "application/json") 78 | req.Header.Set("X-Client-Timestamp", fmt.Sprintf("%d", time.Now().Unix())) 79 | 80 | log.Debugf("sending analytics batch request to %s", endpoint) 81 | 82 | resp, err := a.client.Do(req) 83 | if err != nil { 84 | return fmt.Errorf("failed to send analytics batch: %w", err) 85 | } 86 | defer func() { 87 | if closeErr := resp.Body.Close(); closeErr != nil { 88 | log.Errorf("failed to close response body: %v", closeErr) 89 | } 90 | }() 91 | 92 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 93 | return fmt.Errorf("analytics batch request to %s returned status %d", endpoint, resp.StatusCode) 94 | } 95 | 96 | log.Debugf("analytics batch of %d events sent successfully with status %d", len(events), resp.StatusCode) 97 | return nil 98 | } 99 | 100 | // NoopMetricsClient forwards analytics to a mock endpoint when analytics are disabled. 101 | type NoopMetricsClient struct { 102 | client *http.Client 103 | mockURL string 104 | } 105 | 106 | // NewNoopMetricsClient builds a mock metrics client to help integration tests capture analytics payloads. 107 | func NewNoopMetricsClient(mockURL string) *NoopMetricsClient { 108 | return &NoopMetricsClient{ 109 | client: http.DefaultClient, 110 | mockURL: mockURL, 111 | } 112 | } 113 | 114 | // SendBatch forwards analytics to the configured mock endpoint to aid testing. 115 | func (n *NoopMetricsClient) SendBatch(ctx context.Context, events []interface{}) error { 116 | return nil 117 | } 118 | -------------------------------------------------------------------------------- /docs/ARCHITECTURE.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | TON Connect Bridge uses pub/sub architecture to synchronize state across multiple instances, enabling true high-availability deployments. It is designed for horizontal scaling with Redis-compatible storage. 4 | 5 | ``` 6 | Load Balancer / DNS 7 | │ 8 | ┌───────────────┼───────────────┐ 9 | │ │ │ 10 | ▼ ▼ ▼ 11 | ┌────────┐ ┌────────┐ ┌────────┐ 12 | │BridgeV3│ │BridgeV3│ │BridgeV3│ 13 | │Instance│ │Instance│ │Instance│ 14 | └───┬────┘ └────┬───┘ └────┬───┘ 15 | │ │ │ 16 | └────────────────┼───────────────┘ 17 | │ 18 | ▼ 19 | ┌───────────────────────────────┐ 20 | │ Clustered/Not Clustered Redis │ 21 | └───────────────────────────────┘ 22 | ``` 23 | 24 | ## How It Works 25 | 26 | **Deployment:** 27 | - Run any number of bridge instances simultaneously 28 | - User setup required: DNS, load balancing, Kubernetes, etc. to present multiple instances as a single endpoint 29 | - All instances share state through Redis pub/sub + sorted sets 30 | 31 | **Message Storage & Sharing:** 32 | - Pub/Sub: Real-time message delivery across all bridge instances 33 | - Sorted Sets (ZSET): Persistent message storage with TTL-based expiration 34 | - All bridge instances subscribe to the same Redis channels 35 | - Messages published to Redis are instantly visible to all instances 36 | 37 | **Client Subscription Flow:** 38 | 1. Client subscribes to messages via SSE (`GET /bridge/events`) 39 | 2. Bridge subscribes to Redis pub/sub channel for that client 40 | 3. Bridge reads pending messages from Redis sorted set (ZRANGE) 41 | 4. Bridge pushes historical messages to the client 42 | 5. Bridge continues serving new messages via pub/sub in real-time 43 | 44 | **Message Sending Flow:** 45 | 1. Client sends message via `POST /bridge/message` 46 | 2. Bridge generates a monotonic event ID using time-based generation 47 | 3. Bridge publishes message to Redis pub/sub channel (instant delivery to all instances) 48 | 4. Bridge stores message in Redis sorted set (for offline clients) 49 | 5. All bridge instances with subscribed clients receive the message via pub/sub 50 | 6. Bridge instances deliver message to their connected clients via SSE 51 | 52 | ## Time Synchronization 53 | 54 | **Event ID Generation:** 55 | - Bridge uses time-based event IDs to ensure monotonic ordering across instances 56 | - Format: `(timestamp_ms << 11) | local_counter` (53 bits total for JavaScript compatibility) 57 | - 42 bits for timestamp (supports dates up to year 2100), 11 bits for counter 58 | - Provides ~2K events per millisecond per instance 59 | 60 | **NTP Synchronization (Optional):** 61 | - When enabled, all bridge instances synchronize their clocks with NTP servers 62 | - Improves event ordering consistency across distributed instances 63 | - Fallback to local system time if NTP is unavailable 64 | - Configuration: `NTP_ENABLED`, `NTP_SERVERS`, `NTP_SYNC_INTERVAL` 65 | 66 | **Time Provider Architecture:** 67 | - Uses `TimeProvider` interface for clock abstraction 68 | - `ntp.Client`: NTP-synchronized time (recommended for multi-instance deployments) 69 | - `ntp.LocalTimeProvider`: Local system time (single instance or testing) 70 | 71 | ## Scaling Requirements 72 | 73 | **Redis Version:** 74 | - Redis 7.0+ (or Valkey, or any Redis-compatible database) for production deployments 75 | 76 | **Redis Deployment Options:** 77 | - Redis Cluster (required for Bridge v3 - high availability and scale) 78 | - Managed services: AWS ElastiCache, GCP Memorystore, Azure Cache for Redis 79 | 80 | **Bridge Instances:** 81 | - Run any number of instances simultaneously 82 | - Each instance handles its own SSE connections 83 | - All instances share state through Redis 84 | 85 | ## Storage Options 86 | 87 | Bridge v3 supports: 88 | 89 | - **Redis/Valkey Cluster** (required for production) - Full pub/sub support, horizontal scaling 90 | - **Memory** (development/testing only) - No persistence, single instance only 91 | 92 | For production deployments, **always use Redis or Valkey in cluster mode** to enable high availability. 93 | -------------------------------------------------------------------------------- /cmd/bridge/README.md: -------------------------------------------------------------------------------- 1 | # TON Connect Bridge v1 (Legacy) 2 | 3 | > **⚠️ Warning:** Bridge v1 is deprecated and will be removed in future versions. 4 | > 5 | > **Please use [TON Connect Bridge v3](../../README.md) for all new deployments.** 6 | > 7 | > Bridge v1 is the original implementation of TON Connect Bridge. It was production-proven but has a fundamental limitation: **it cannot be horizontally scaled** due to its in-memory message storage architecture. 8 | 9 | --- 10 | 11 | ## Architecture 12 | 13 | Bridge v1 uses a single application instance with in-memory caching backed by PostgreSQL for persistence. 14 | 15 | ### How It Works 16 | 17 | **Message Storage:** 18 | - Messages are pushed directly between clients in real-time via SSE 19 | - Messages are pushed to PostgreSQL for persistent storage 20 | 21 | **Client Subscription Flow:** 22 | 1. Client subscribes to messages via SSE (`GET /bridge/events`) 23 | 2. Bridge reads all pending messages from PostgreSQL first 24 | 3. Bridge pushes these messages to the client 25 | 4. Bridge continues serving new messages via SSE in real-time 26 | 27 | **Message Sending Flow:** 28 | 1. Client sends message via `POST /bridge/message` 29 | 2. Bridge immediately pushes message to all subscribed clients via SSE 30 | 3. Bridge writes message to PostgreSQL for persistence 31 | 32 | ### Architecture Diagram 33 | 34 | ``` 35 | Internet 36 | │ 37 | ├── TLS Termination (Cloudflare/nginx) 38 | │ 39 | ▼ 40 | ┌────────┐ 41 | │ Bridge │──── PostgreSQL 42 | │ v1 │ 43 | └────────┘ 44 | ``` 45 | 46 | ### Fundamental Limitation 47 | 48 | **Bridge v1 cannot be horizontally scaled.** Since messages are stored in the memory of a single application instance, running multiple bridge instances would result in: 49 | - Messages sent to instance A not visible to clients connected to instance B 50 | - No way to synchronize in-memory state across instances 51 | - Clients unable to receive messages if connected to different instances 52 | 53 | This limitation led to the development of Bridge v3. 54 | 55 | ## Building & Running 56 | 57 | ### Build 58 | 59 | ```bash 60 | make build 61 | ./bridge 62 | ``` 63 | 64 | ### Storage Options 65 | 66 | - **PostgreSQL** (required for production) 67 | - **Memory only** (development/testing, no persistence) 68 | 69 | ## Configuration 70 | 71 | ### PostgreSQL Settings 72 | 73 | | Variable | Type | Default | Description | 74 | |----------|------|---------|-------------| 75 | | `POSTGRES_URI` | string | - | **Required for production**
Format: `postgres://user:pass@host:port/db?sslmode=require` | 76 | | `POSTGRES_MAX_CONNS` | int | `25` | Max connections in pool | 77 | | `POSTGRES_MIN_CONNS` | int | `0` | Min idle connections | 78 | | `POSTGRES_MAX_CONN_LIFETIME` | duration | `1h` | Connection lifetime (`1h`, `30m`, `90s`) | 79 | | `POSTGRES_MAX_CONN_LIFETIME_JITTER` | duration | `10m` | Random jitter to prevent thundering herd | 80 | | `POSTGRES_MAX_CONN_IDLE_TIME` | duration | `30m` | Max idle time before closing | 81 | | `POSTGRES_HEALTH_CHECK_PERIOD` | duration | `1m` | Health check interval | 82 | | `POSTGRES_LAZY_CONNECT` | bool | `false` | Create connections on-demand | 83 | 84 | ### Production Configuration Example 85 | 86 | ```bash 87 | LOG_LEVEL=info 88 | POSTGRES_URI="postgres://bridge:${PASSWORD}@db.internal:5432/bridge?sslmode=require" 89 | POSTGRES_MAX_CONNS=100 90 | POSTGRES_MIN_CONNS=10 91 | CORS_ENABLE=true 92 | RPS_LIMIT=10000 93 | CONNECTIONS_LIMIT=50000 94 | TRUSTED_PROXY_RANGES="10.0.0.0/8,172.16.0.0/12,{use_your_own}" 95 | ENVIRONMENT=production 96 | BRIDGE_URL="https://use-your-own-bridge.myapp.com" 97 | ``` 98 | 99 | ## Deployment 100 | 101 | ### Docker Deployment 102 | 103 | ```bash 104 | docker compose -f docker/docker-compose.postgres.yml up -d 105 | ``` 106 | 107 | ### Environment File 108 | 109 | ```bash 110 | # .env 111 | LOG_LEVEL=info 112 | PORT=8081 113 | POSTGRES_URI=postgres://bridge:password@postgres:5432/bridge 114 | CORS_ENABLE=true 115 | RPS_LIMIT=1000 116 | CONNECTIONS_LIMIT=5000 117 | ``` 118 | 119 | ## Support 120 | 121 | Bridge v1 receives security updates only. For new features and improvements, please use Bridge v3. 122 | 123 | **Questions?** See the [main documentation](../../docs/) or open an issue. 124 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | 10 | jobs: 11 | unit: 12 | name: unit 13 | runs-on: ubuntu-latest 14 | steps: 15 | 16 | - name: Check out code 17 | uses: actions/checkout@v6 18 | 19 | - name: unit tests 20 | run: make test 21 | 22 | integration: 23 | name: integration-${{ matrix.storage }} 24 | runs-on: ubuntu-latest 25 | strategy: 26 | matrix: 27 | storage: [memory, postgres, nginx, dnsmasq, cluster-valkey] 28 | 29 | steps: 30 | - uses: actions/checkout@v6 31 | 32 | - name: Set up Docker Buildx 33 | uses: docker/setup-buildx-action@v3 34 | 35 | - name: Start bridge 36 | run: docker compose -f docker/docker-compose.${{ matrix.storage }}.yml up --build -d bridge 37 | 38 | - name: Run bridge-sdk tests 39 | id: bridge_sdk_tests 40 | run: | 41 | docker compose -f docker/docker-compose.${{ matrix.storage }}.yml run --rm integration || rc=$? 42 | rc=${rc:-0} 43 | echo "sdk_rc=$rc" >> $GITHUB_OUTPUT 44 | if [ $rc -ne 0 ]; then 45 | echo "Bridge SDK tests failed with exit code $rc" 46 | fi 47 | 48 | - name: Run go-integration tests 49 | id: go_integration_tests 50 | run: | 51 | docker compose -f docker/docker-compose.${{ matrix.storage }}.yml run --rm gointegration || rc=$? 52 | rc=${rc:-0} 53 | echo "go_rc=$rc" >> $GITHUB_OUTPUT 54 | if [ $rc -ne 0 ]; then 55 | echo "Go integration tests failed with exit code $rc" 56 | fi 57 | 58 | - name: Get performance metrics 59 | if: github.event_name == 'pull_request' 60 | run: | 61 | ./scripts/pr-metrics.sh "${{ matrix.storage }}" > metrics-${{ matrix.storage }}.md 62 | 63 | - name: Update PR comment with current metrics 64 | if: github.event_name == 'pull_request' 65 | continue-on-error: true 66 | uses: actions/github-script@v8 67 | with: 68 | script: | 69 | const fs = require('fs'); 70 | const metrics = fs.readFileSync('metrics-${{ matrix.storage }}.md', 'utf8'); 71 | 72 | const { data: comments } = await github.rest.issues.listComments({ 73 | owner: context.repo.owner, 74 | repo: context.repo.repo, 75 | issue_number: context.issue.number, 76 | }); 77 | 78 | const marker = ''; 79 | const botComment = comments.find(c => c.user.type === 'Bot' && c.body.includes(marker)); 80 | 81 | if (botComment) { 82 | await github.rest.issues.updateComment({ 83 | owner: context.repo.owner, 84 | repo: context.repo.repo, 85 | comment_id: botComment.id, 86 | body: `${marker}\n# 📊 Performance Metrics\n\n${metrics}` 87 | }); 88 | } else { 89 | await github.rest.issues.createComment({ 90 | owner: context.repo.owner, 91 | repo: context.repo.repo, 92 | issue_number: context.issue.number, 93 | body: `${marker}\n# 📊 Performance Metrics\n\n${metrics}` 94 | }); 95 | } 96 | 97 | - name: Upload metrics artifact 98 | if: github.event_name == 'pull_request' 99 | uses: actions/upload-artifact@v5 100 | with: 101 | name: metrics-${{ matrix.storage }} 102 | path: metrics-${{ matrix.storage }}.md 103 | retention-days: 1 104 | 105 | - name: Clean up containers 106 | if: always() 107 | run: docker compose -f docker/docker-compose.${{ matrix.storage }}.yml down -v 108 | 109 | - name: Fail job if any tests failed 110 | if: ${{ fromJSON(steps.bridge_sdk_tests.outputs.sdk_rc) != 0 || fromJSON(steps.go_integration_tests.outputs.go_rc) != 0 }} 111 | run: | 112 | echo "Test failures detected:" 113 | echo " Bridge SDK tests: exit code ${{ steps.bridge_sdk_tests.outputs.sdk_rc }}" 114 | echo " Go integration tests: exit code ${{ steps.go_integration_tests.outputs.go_rc }}" 115 | exit 1 116 | -------------------------------------------------------------------------------- /docker/docker-compose.postgres.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | postgres: 5 | image: postgres:15-alpine 6 | environment: 7 | POSTGRES_DB: bridge 8 | POSTGRES_USER: bridge_user 9 | POSTGRES_PASSWORD: bridge_password 10 | ports: 11 | - "5432:5432" 12 | volumes: 13 | - postgres_data:/var/lib/postgresql/data 14 | healthcheck: 15 | test: ["CMD-SHELL", "pg_isready -U bridge_user -d bridge"] 16 | interval: 5s 17 | timeout: 5s 18 | retries: 5 19 | networks: 20 | - bridge_network 21 | 22 | bridge: 23 | build: .. 24 | environment: 25 | PORT: 8081 26 | POSTGRES_URI: "postgres://bridge_user:bridge_password@postgres:5432/bridge?sslmode=disable" 27 | CORS_ENABLE: "true" 28 | HEARTBEAT_INTERVAL: 10 29 | RPS_LIMIT: 1000 30 | CONNECTIONS_LIMIT: 200 31 | TON_ANALYTICS_ENABLED: "true" 32 | TON_ANALYTICS_URL: "http://analytics-mock:9090/events" 33 | TON_ANALYTICS_NETWORK_ID: "-239" 34 | TON_ANALYTICS_BRIDGE_VERSION: "test-1.0.0" 35 | TON_ANALYTICS_BRIDGE_URL: "http://bridge-instance1:8080" 36 | ports: 37 | - "8081:8081" # bridge 38 | - "9103:9103" # pprof and metrics 39 | depends_on: 40 | postgres: 41 | condition: service_healthy 42 | networks: 43 | - bridge_network 44 | restart: unless-stopped 45 | 46 | integration: 47 | build: 48 | context: .. 49 | dockerfile: docker/Dockerfile.integration 50 | container_name: bridge-integration 51 | environment: 52 | BRIDGE_URL: "http://bridge:8081/bridge" 53 | depends_on: 54 | bridge: 55 | condition: service_started 56 | networks: 57 | - bridge_network 58 | command: ["/bin/bash", "-c", "make test-bridge-sdk"] 59 | # command: ["/bin/bash", "-c", "tail -f /dev/null"] 60 | 61 | gointegration: 62 | build: 63 | context: .. 64 | dockerfile: docker/Dockerfile.gointegration 65 | container_name: bridge-gointegration 66 | environment: 67 | BRIDGE_URL: "http://bridge:8081/bridge" 68 | depends_on: 69 | bridge: 70 | condition: service_started 71 | networks: 72 | - bridge_network 73 | command: ["/bin/bash", "-c", "make test-gointegration"] 74 | # command: ["/bin/bash", "-c", "tail -f /dev/null"] 75 | 76 | benchmark: 77 | build: 78 | context: .. 79 | dockerfile: docker/Dockerfile.benchmark 80 | container_name: benchmark 81 | working_dir: /scripts 82 | environment: 83 | BRIDGE_URL: "http://bridge:8081/bridge" 84 | RAMP_UP: "1m" 85 | HOLD: "3m" 86 | RAMP_DOWN: "1m" 87 | SSE_VUS: "500" 88 | SEND_RATE: "10000" 89 | depends_on: 90 | bridge: 91 | condition: service_started 92 | networks: 93 | - bridge_network 94 | volumes: 95 | - ../benchmark:/scripts:ro 96 | - ./benchmark/results:/results 97 | entrypoint: [ "/bin/sh", "-lc" ] 98 | command: 99 | - > 100 | printf '\n\n====== pprof BEFORE ======\n\n'; 101 | pprof -top -sample_index=alloc_space -unit=GB -nodecount=5 http://bridge:9103/debug/pprof/allocs 2>/dev/null || true; 102 | pprof -proto http://bridge:9103/debug/pprof/allocs > /tmp/allocs-before.pb.gz 2>/dev/null || true; 103 | printf '\n\n====== starting BENCH ======\n\n'; 104 | printf 'Running load test for %s with:\n' "$$TEST_DURATION"; 105 | printf ' - SSE Virtual Users: %s\n' "$$SSE_VUS"; 106 | printf ' - Send Rate: %s msg/s\n' "$$SEND_RATE"; 107 | printf ' - Target: %s\n\n' "$$BRIDGE_URL"; 108 | printf '\n\nPlease wait...\n\n'; 109 | /usr/local/bin/k6 run --quiet --summary-export=/results/summary.json /scripts/bridge_test.js; 110 | printf '\n\n====== pprof AFTER ======\n\n'; 111 | pprof -top -sample_index=alloc_space -unit=GB -nodecount=5 http://bridge:9103/debug/pprof/allocs 2>/dev/null || true; 112 | pprof -proto http://bridge:9103/debug/pprof/allocs > /tmp/allocs-after.pb.gz 2>/dev/null || true; 113 | printf '\n\n====== pprof DIFF ======\n\n'; 114 | pprof -top -sample_index=alloc_space -unit=GB -nodecount=5 -base=/tmp/allocs-before.pb.gz /tmp/allocs-after.pb.gz || true; 115 | rm -f /tmp/allocs-before.pb.gz /tmp/allocs-after.pb.gz 2>/dev/null || true; 116 | printf '\n\n============================\n\n'; 117 | 118 | volumes: 119 | postgres_data: 120 | 121 | networks: 122 | bridge_network: 123 | driver: bridge 124 | -------------------------------------------------------------------------------- /test/clustered/results/cluster-6-nodes/summary-write-heavy.json: -------------------------------------------------------------------------------- 1 | { 2 | "root_group": { 3 | "path": "", 4 | "id": "d41d8cd98f00b204e9800998ecf8427e", 5 | "groups": {}, 6 | "checks": {}, 7 | "name": "" 8 | }, 9 | "metrics": { 10 | "dropped_iterations": { 11 | "count": 10038, 12 | "rate": 47.79895226995526 13 | }, 14 | "http_req_duration": { 15 | "avg": 1.5983435341401413, 16 | "min": 0, 17 | "med": 0.142632, 18 | "max": 550.243562, 19 | "p(90)": 0.412478, 20 | "p(95)": 0.9129674499999995 21 | }, 22 | "missing_timestamps": { 23 | "rate": 0, 24 | "count": 0, 25 | "thresholds": { 26 | "count<100": false 27 | } 28 | }, 29 | "http_req_connecting": { 30 | "min": 0, 31 | "med": 0, 32 | "max": 1067.688529, 33 | "p(90)": 0, 34 | "p(95)": 0, 35 | "avg": 2.0120261039013 36 | }, 37 | "data_sent": { 38 | "count": 399464513, 39 | "rate": 1902170.2720091576 40 | }, 41 | "http_req_failed": { 42 | "passes": 5617, 43 | "fails": 1109345, 44 | "thresholds": { 45 | "rate<0.01": false 46 | }, 47 | "value": 0.005037839854631817 48 | }, 49 | "data_received": { 50 | "count": 173152780, 51 | "rate": 824518.9745596796 52 | }, 53 | "iteration_duration": { 54 | "avg": 3.7013683799152637, 55 | "min": 0.086462, 56 | "med": 0.20897, 57 | "max": 1083.623988, 58 | "p(90)": 0.5128559, 59 | "p(95)": 1.1024207499999987 60 | }, 61 | "http_req_tls_handshaking": { 62 | "avg": 0, 63 | "min": 0, 64 | "med": 0, 65 | "max": 0, 66 | "p(90)": 0, 67 | "p(95)": 0 68 | }, 69 | "vus": { 70 | "max": 1608, 71 | "value": 1, 72 | "min": 1 73 | }, 74 | "vus_max": { 75 | "value": 1752, 76 | "min": 350, 77 | "max": 1752 78 | }, 79 | "post_errors": { 80 | "count": 5617, 81 | "rate": 26.747032765524875 82 | }, 83 | "http_req_receiving": { 84 | "med": 0.011584, 85 | "max": 42.608958, 86 | "p(90)": 0.023543, 87 | "p(95)": 0.03396, 88 | "avg": 0.016449625373778788, 89 | "min": 0 90 | }, 91 | "iterations": { 92 | "count": 1114962, 93 | "rate": 5309.2264814518685 94 | }, 95 | "delivery_latency": { 96 | "avg": 0, 97 | "min": 0, 98 | "med": 0, 99 | "max": 0, 100 | "p(90)": 0, 101 | "p(95)": 0, 102 | "thresholds": { 103 | "p(95)<500": false 104 | } 105 | }, 106 | "http_req_sending": { 107 | "avg": 0.017619797221514515, 108 | "min": 0, 109 | "med": 0.009084, 110 | "max": 46.18912, 111 | "p(90)": 0.019209, 112 | "p(95)": 0.031001 113 | }, 114 | "json_parse_errors": { 115 | "rate": 0, 116 | "count": 0, 117 | "thresholds": { 118 | "count<5": false 119 | } 120 | }, 121 | "http_reqs": { 122 | "count": 1114962, 123 | "rate": 5309.2264814518685 124 | }, 125 | "sse_event": { 126 | "count": 1790, 127 | "rate": 8.523622690099613 128 | }, 129 | "sse_errors": { 130 | "count": 250, 131 | "rate": 1.1904500963826274, 132 | "thresholds": { 133 | "count<10": true 134 | } 135 | }, 136 | "http_req_blocked": { 137 | "med": 0.001208, 138 | "max": 1069.098329, 139 | "p(90)": 0.002, 140 | "p(95)": 0.002542, 141 | "avg": 2.0139764498907775, 142 | "min": 0 143 | }, 144 | "http_req_waiting": { 145 | "max": 550.231687, 146 | "p(90)": 0.371729, 147 | "p(95)": 0.8204189, 148 | "avg": 1.5643862640340909, 149 | "min": 0, 150 | "med": 0.116215 151 | } 152 | } 153 | } -------------------------------------------------------------------------------- /test/clustered/results/cluster-3-nodes/summary-write-heavy.json: -------------------------------------------------------------------------------- 1 | { 2 | "root_group": { 3 | "path": "", 4 | "id": "d41d8cd98f00b204e9800998ecf8427e", 5 | "groups": {}, 6 | "checks": {}, 7 | "name": "" 8 | }, 9 | "metrics": { 10 | "http_req_failed": { 11 | "fails": 1112015, 12 | "passes": 4469, 13 | "thresholds": { 14 | "rate<0.01": false 15 | }, 16 | "value": 0.004002744329520172 17 | }, 18 | "iteration_duration": { 19 | "p(90)": 0.41753470000000004, 20 | "p(95)": 0.8533455499999989, 21 | "avg": 4.233018233016491, 22 | "min": 0.080365, 23 | "med": 0.195183, 24 | "max": 1147.457595 25 | }, 26 | "dropped_iterations": { 27 | "count": 8516, 28 | "rate": 40.551650556713156 29 | }, 30 | "http_reqs": { 31 | "count": 1116484, 32 | "rate": 5316.494718196493 33 | }, 34 | "sse_event": { 35 | "rate": 4.752295356458399, 36 | "count": 998 37 | }, 38 | "missing_timestamps": { 39 | "count": 0, 40 | "rate": 0, 41 | "thresholds": { 42 | "count<100": false 43 | } 44 | }, 45 | "delivery_latency": { 46 | "avg": 0, 47 | "min": 0, 48 | "med": 0, 49 | "max": 0, 50 | "p(90)": 0, 51 | "p(95)": 0, 52 | "thresholds": { 53 | "p(95)<500": false 54 | } 55 | }, 56 | "http_req_blocked": { 57 | "p(90)": 0.001667, 58 | "p(95)": 0.002083, 59 | "avg": 2.8325453927770052, 60 | "min": 0, 61 | "med": 0.001166, 62 | "max": 1081.384152 63 | }, 64 | "http_req_tls_handshaking": { 65 | "avg": 0, 66 | "min": 0, 67 | "med": 0, 68 | "max": 0, 69 | "p(90)": 0, 70 | "p(95)": 0 71 | }, 72 | "vus_max": { 73 | "value": 1464, 74 | "min": 350, 75 | "max": 1464 76 | }, 77 | "vus": { 78 | "value": 1, 79 | "min": 1, 80 | "max": 1422 81 | }, 82 | "data_sent": { 83 | "count": 400342308, 84 | "rate": 1906357.6065152688 85 | }, 86 | "http_req_duration": { 87 | "avg": 1.327377975747971, 88 | "min": 0, 89 | "med": 0.132844, 90 | "max": 692.152203, 91 | "p(90)": 0.3311267, 92 | "p(95)": 0.699809 93 | }, 94 | "data_received": { 95 | "count": 173550292, 96 | "rate": 826415.0769374742 97 | }, 98 | "iterations": { 99 | "count": 1116484, 100 | "rate": 5316.494718196493 101 | }, 102 | "sse_errors": { 103 | "count": 250, 104 | "rate": 1.1904547486118235, 105 | "thresholds": { 106 | "count<10": true 107 | } 108 | }, 109 | "post_errors": { 110 | "rate": 21.280569086184958, 111 | "count": 4469 112 | }, 113 | "json_parse_errors": { 114 | "count": 0, 115 | "rate": 0, 116 | "thresholds": { 117 | "count<5": false 118 | } 119 | }, 120 | "http_req_connecting": { 121 | "max": 1081.321992, 122 | "p(90)": 0, 123 | "p(95)": 0, 124 | "avg": 2.8307758948520556, 125 | "min": 0, 126 | "med": 0 127 | }, 128 | "http_req_sending": { 129 | "avg": 0.017667226472015213, 130 | "min": 0, 131 | "med": 0.009249, 132 | "max": 18.594986, 133 | "p(90)": 0.017248, 134 | "p(95)": 0.028373 135 | }, 136 | "http_req_waiting": { 137 | "p(95)": 0.622754, 138 | "avg": 1.2950331693495307, 139 | "min": 0, 140 | "med": 0.107552, 141 | "max": 692.125576, 142 | "p(90)": 0.296026 143 | }, 144 | "http_req_receiving": { 145 | "avg": 0.01478111219148556, 146 | "min": 0, 147 | "med": 0.011543, 148 | "max": 19.706001, 149 | "p(90)": 0.021374, 150 | "p(95)": 0.030581 151 | } 152 | } 153 | } -------------------------------------------------------------------------------- /test/replica-dead/results/summary.json: -------------------------------------------------------------------------------- 1 | { 2 | "root_group": { 3 | "groups": {}, 4 | "checks": {}, 5 | "name": "", 6 | "path": "", 7 | "id": "d41d8cd98f00b204e9800998ecf8427e" 8 | }, 9 | "metrics": { 10 | "http_req_tls_handshaking": { 11 | "avg": 0, 12 | "min": 0, 13 | "med": 0, 14 | "max": 0, 15 | "p(90)": 0, 16 | "p(95)": 0 17 | }, 18 | "sse_errors": { 19 | "rate": 0, 20 | "count": 0, 21 | "thresholds": { 22 | "count<10": false 23 | } 24 | }, 25 | "http_reqs": { 26 | "count": 240000, 27 | "rate": 727.2690350358248 28 | }, 29 | "http_req_duration": { 30 | "med": 0.136625, 31 | "max": 10.163849, 32 | "p(90)": 0.21987410000000002, 33 | "p(95)": 0.27375, 34 | "avg": 0.16154028030833445, 35 | "min": 0.045417 36 | }, 37 | "sse_message_received": { 38 | "count": 232237, 39 | "rate": 703.7449120400619, 40 | "thresholds": { 41 | "count>5": false 42 | } 43 | }, 44 | "http_req_sending": { 45 | "avg": 0.010980361607663348, 46 | "min": 0.001875, 47 | "med": 0.008833, 48 | "max": 7.497701, 49 | "p(90)": 0.016834, 50 | "p(95)": 0.022584 51 | }, 52 | "sse_event": { 53 | "count": 234987, 54 | "rate": 712.078203066514 55 | }, 56 | "vus": { 57 | "min": 1, 58 | "max": 102, 59 | "value": 1 60 | }, 61 | "json_parse_errors": { 62 | "count": 0, 63 | "rate": 0, 64 | "thresholds": { 65 | "count<5": false 66 | } 67 | }, 68 | "http_req_failed": { 69 | "passes": 0, 70 | "fails": 240000, 71 | "thresholds": { 72 | "rate<0.01": false 73 | }, 74 | "value": 0 75 | }, 76 | "vus_max": { 77 | "value": 200, 78 | "min": 200, 79 | "max": 200 80 | }, 81 | "http_req_receiving": { 82 | "med": 0.015167, 83 | "max": 3.831621, 84 | "p(90)": 0.037417, 85 | "p(95)": 0.048916, 86 | "avg": 0.020553525004166524, 87 | "min": 0.003083 88 | }, 89 | "delivery_latency": { 90 | "med": 0, 91 | "max": 299911, 92 | "p(90)": 1, 93 | "p(95)": 1, 94 | "avg": 12527.961074247429, 95 | "min": 0, 96 | "thresholds": { 97 | "p(95)<2000": false 98 | } 99 | }, 100 | "http_req_connecting": { 101 | "med": 0, 102 | "max": 6.258107, 103 | "p(90)": 0, 104 | "p(95)": 0, 105 | "avg": 0.00013919303750000003, 106 | "min": 0 107 | }, 108 | "http_req_waiting": { 109 | "avg": 0.1302774341416691, 110 | "min": 0.035958, 111 | "med": 0.109167, 112 | "max": 10.134349, 113 | "p(90)": 0.181875, 114 | "p(95)": 0.224749 115 | }, 116 | "data_received": { 117 | "count": 101159738, 118 | "rate": 306543.1043322369 119 | }, 120 | "iteration_duration": { 121 | "avg": 0.23684215559583552, 122 | "min": 0.103958, 123 | "med": 0.202875, 124 | "max": 10.866156, 125 | "p(90)": 0.30437910000000024, 126 | "p(95)": 0.384958 127 | }, 128 | "sse_message_sent": { 129 | "count": 240000, 130 | "rate": 727.2690350358248, 131 | "thresholds": { 132 | "count>5": false 133 | } 134 | }, 135 | "http_req_blocked": { 136 | "p(95)": 0.002584, 137 | "avg": 0.0015513585041666267, 138 | "min": 0.000458, 139 | "med": 0.001042, 140 | "max": 6.309441, 141 | "p(90)": 0.001875 142 | }, 143 | "iterations": { 144 | "count": 240000, 145 | "rate": 727.2690350358248 146 | }, 147 | "data_sent": { 148 | "count": 84862054, 149 | "rate": 257156.43384890855 150 | }, 151 | "missing_timestamps": { 152 | "count": 0, 153 | "rate": 0, 154 | "thresholds": { 155 | "count<100": false 156 | } 157 | } 158 | } 159 | } -------------------------------------------------------------------------------- /internal/analytics/collector.go: -------------------------------------------------------------------------------- 1 | package analytics 2 | 3 | import ( 4 | "context" 5 | "sync/atomic" 6 | "time" 7 | 8 | "github.com/sirupsen/logrus" 9 | "github.com/ton-connect/bridge/tonmetrics" 10 | ) 11 | 12 | // EventCollector is a non-blocking analytics producer API. 13 | type EventCollector interface { 14 | // TryAdd attempts to enqueue the event. Returns true if enqueued, false if dropped. 15 | TryAdd(interface{}) bool 16 | } 17 | 18 | // Collector provides bounded, non-blocking storage for analytics events and 19 | // periodically flushes them to a backend. When buffer is full, new events are dropped. 20 | type Collector struct { 21 | // Buffer fields 22 | eventCh chan interface{} 23 | notifyCh chan struct{} 24 | 25 | capacity int 26 | triggerCapacity int 27 | dropped atomic.Uint64 28 | 29 | // Sender fields 30 | sender tonmetrics.AnalyticsClient 31 | flushInterval time.Duration 32 | } 33 | 34 | // NewCollector builds a collector with a periodic flush. 35 | func NewCollector(capacity int, client tonmetrics.AnalyticsClient, flushInterval time.Duration) *Collector { 36 | triggerCapacity := capacity 37 | if capacity > 10 { 38 | triggerCapacity = capacity - 10 39 | } 40 | return &Collector{ 41 | eventCh: make(chan interface{}, capacity), // channel for events 42 | notifyCh: make(chan struct{}, 1), // channel to trigger flushing 43 | capacity: capacity, 44 | triggerCapacity: triggerCapacity, 45 | sender: client, 46 | flushInterval: flushInterval, 47 | } 48 | } 49 | 50 | // TryAdd enqueues without blocking. If full, returns false and increments drop count. 51 | func (c *Collector) TryAdd(event interface{}) bool { 52 | result := false 53 | select { 54 | case c.eventCh <- event: 55 | result = true 56 | default: 57 | c.dropped.Add(1) 58 | result = false 59 | } 60 | 61 | if len(c.eventCh) >= c.triggerCapacity { 62 | select { 63 | case c.notifyCh <- struct{}{}: 64 | default: 65 | } 66 | } 67 | return result 68 | } 69 | 70 | // PopAll drains all pending events. 71 | // If there are 100 or more events, only reads 100 elements. 72 | func (c *Collector) PopAll() []interface{} { 73 | channelLen := len(c.eventCh) 74 | if channelLen == 0 { 75 | return nil 76 | } 77 | 78 | limit := channelLen 79 | if channelLen >= 100 { 80 | limit = 100 81 | } 82 | 83 | result := make([]interface{}, 0, limit) 84 | for i := 0; i < limit; i++ { 85 | select { 86 | case event := <-c.eventCh: 87 | result = append(result, event) 88 | default: 89 | return result 90 | } 91 | } 92 | return result 93 | } 94 | 95 | // Dropped returns the number of events that were dropped due to buffer being full. 96 | func (c *Collector) Dropped() uint64 { 97 | return c.dropped.Load() 98 | } 99 | 100 | // IsFull returns true if the buffer is at capacity. 101 | func (c *Collector) IsFull() bool { 102 | return len(c.eventCh) >= c.capacity 103 | } 104 | 105 | // Len returns the current number of events in the buffer. 106 | func (c *Collector) Len() int { 107 | return len(c.eventCh) 108 | } 109 | 110 | // Run periodically flushes events until the context is canceled. 111 | // Flushes occur when: 112 | // 1. The flush interval (500ms) has elapsed and there are events 113 | // 2. The buffer has reached the trigger capacity (capacity - 10) 114 | func (c *Collector) Run(ctx context.Context) { 115 | flushTicker := time.NewTicker(c.flushInterval) 116 | defer flushTicker.Stop() 117 | 118 | logrus.WithField("prefix", "analytics").Debugf("analytics collector started with flush interval %v", c.flushInterval) 119 | 120 | for { 121 | select { 122 | case <-ctx.Done(): 123 | logrus.WithField("prefix", "analytics").Debug("analytics collector stopping, performing final flush") 124 | // Use fresh context for final flush since ctx is already cancelled 125 | flushCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 126 | c.Flush(flushCtx) 127 | cancel() 128 | logrus.WithField("prefix", "analytics").Debug("analytics collector stopped") 129 | return 130 | case <-flushTicker.C: 131 | if c.Len() > 0 { 132 | logrus.WithField("prefix", "analytics").Debug("analytics collector ticker fired") 133 | c.Flush(ctx) 134 | } 135 | case <-c.notifyCh: 136 | logrus.WithField("prefix", "analytics").Debugf("analytics collector buffer reached %d events, flushing", c.Len()) 137 | c.Flush(ctx) 138 | } 139 | } 140 | } 141 | 142 | func (c *Collector) Flush(ctx context.Context) { 143 | events := c.PopAll() 144 | if len(events) > 0 { 145 | logrus.WithField("prefix", "analytics").Debugf("flushing %d events from collector", len(events)) 146 | if err := c.sender.SendBatch(ctx, events); err != nil { 147 | logrus.WithError(err).Warnf("analytics: failed to send batch of %d events", len(events)) 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /internal/v1/storage/mem_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "testing" 7 | "time" 8 | 9 | "github.com/ton-connect/bridge/internal/analytics" 10 | "github.com/ton-connect/bridge/internal/models" 11 | ) 12 | 13 | func newMessage(expire time.Time, i int) message { 14 | return message{ 15 | SseMessage: models.SseMessage{EventId: int64(i)}, 16 | expireAt: expire, 17 | } 18 | } 19 | 20 | func Test_removeExpiredMessages(t *testing.T) { 21 | now := time.Now() 22 | tests := []struct { 23 | name string 24 | ms []message 25 | now time.Time 26 | want []message 27 | }{ 28 | { 29 | name: "all expired", 30 | ms: []message{ 31 | newMessage(now.Add(2*time.Second), 1), 32 | newMessage(now.Add(3*time.Second), 2), 33 | newMessage(now.Add(4*time.Second), 3), 34 | newMessage(now.Add(5*time.Second), 4), 35 | }, 36 | want: []message{}, 37 | now: now.Add(10 * time.Second), 38 | }, 39 | { 40 | name: "some expired", 41 | ms: []message{ 42 | newMessage(now.Add(10*time.Second), 1), 43 | newMessage(now.Add(9*time.Second), 2), 44 | newMessage(now.Add(2*time.Second), 3), 45 | newMessage(now.Add(1*time.Second), 4), 46 | newMessage(now.Add(5*time.Second), 5), 47 | }, 48 | want: []message{ 49 | newMessage(now.Add(10*time.Second), 1), 50 | newMessage(now.Add(9*time.Second), 2), 51 | newMessage(now.Add(5*time.Second), 5), 52 | }, 53 | now: now.Add(4 * time.Second), 54 | }, 55 | } 56 | for _, tt := range tests { 57 | t.Run(tt.name, func(t *testing.T) { 58 | if got, _ := removeExpiredMessages(tt.ms, tt.now); !reflect.DeepEqual(got, tt.want) { 59 | t.Errorf("removeExpiredMessages() = %v, want %v", got, tt.want) 60 | } 61 | }) 62 | } 63 | } 64 | 65 | func TestStorage(t *testing.T) { 66 | builder := analytics.NewEventBuilder("http://test", "test", "bridge", "1.0.0", "-239") 67 | s := &MemStorage{ 68 | db: map[string][]message{}, 69 | analytics: analytics.NewCollector(10, nil, 0), 70 | eventBuilder: builder, 71 | } 72 | _ = s.Add(context.Background(), models.SseMessage{EventId: 1, To: "1"}, 2) 73 | _ = s.Add(context.Background(), models.SseMessage{EventId: 2, To: "2"}, 2) 74 | _ = s.Add(context.Background(), models.SseMessage{EventId: 3, To: "2"}, 2) 75 | _ = s.Add(context.Background(), models.SseMessage{EventId: 4, To: "1"}, 2) 76 | tests := []struct { 77 | name string 78 | keys []string 79 | lastEventId int64 80 | want []models.SseMessage 81 | }{ 82 | { 83 | name: "one key", 84 | keys: []string{"1"}, 85 | want: []models.SseMessage{ 86 | {EventId: 1, To: "1"}, 87 | {EventId: 4, To: "1"}, 88 | }, 89 | }, 90 | { 91 | name: "keys not found", 92 | keys: []string{"10", "20"}, 93 | want: []models.SseMessage{}, 94 | }, 95 | { 96 | name: "get all keys", 97 | keys: []string{"1", "2"}, 98 | want: []models.SseMessage{ 99 | {EventId: 1, To: "1"}, 100 | {EventId: 4, To: "1"}, 101 | {EventId: 2, To: "2"}, 102 | {EventId: 3, To: "2"}, 103 | }, 104 | }, 105 | } 106 | for _, tt := range tests { 107 | t.Run(tt.name, func(t *testing.T) { 108 | messages, _ := s.GetMessages(context.Background(), tt.keys, tt.lastEventId) 109 | if !reflect.DeepEqual(messages, tt.want) { 110 | t.Errorf("GetMessages() = %v, want %v", message{}, tt.want) 111 | } 112 | 113 | }) 114 | } 115 | } 116 | 117 | func TestStorage_watcher(t *testing.T) { 118 | now := time.Now() 119 | tests := []struct { 120 | name string 121 | db map[string][]message 122 | want map[string][]message 123 | }{ 124 | { 125 | db: map[string][]message{ 126 | "1": { 127 | newMessage(now.Add(2*time.Second), 1), 128 | newMessage(now.Add(-2*time.Second), 2), 129 | }, 130 | "2": { 131 | newMessage(now.Add(-1*time.Second), 4), 132 | newMessage(now.Add(-3*time.Second), 1), 133 | }, 134 | "3": { 135 | newMessage(now.Add(1*time.Second), 4), 136 | newMessage(now.Add(3*time.Second), 1), 137 | }, 138 | }, 139 | want: map[string][]message{ 140 | "1": { 141 | newMessage(now.Add(2*time.Second), 1), 142 | }, 143 | "2": {}, 144 | "3": { 145 | newMessage(now.Add(1*time.Second), 4), 146 | newMessage(now.Add(3*time.Second), 1), 147 | }, 148 | }, 149 | }, 150 | } 151 | for _, tt := range tests { 152 | t.Run(tt.name, func(t *testing.T) { 153 | builder := analytics.NewEventBuilder("http://test", "test", "bridge", "1.0.0", "-239") 154 | s := &MemStorage{ 155 | db: tt.db, 156 | analytics: analytics.NewCollector(10, nil, 0), 157 | eventBuilder: builder, 158 | } 159 | go s.watcher() 160 | time.Sleep(500 * time.Millisecond) 161 | s.lock.Lock() 162 | defer s.lock.Unlock() 163 | 164 | if !reflect.DeepEqual(s.db, tt.want) { 165 | t.Errorf("GetMessages() = %v, want %v", message{}, tt.want) 166 | } 167 | }) 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /cmd/bridge/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/http/pprof" 8 | "time" 9 | 10 | "github.com/labstack/echo-contrib/prometheus" 11 | "github.com/labstack/echo/v4" 12 | "github.com/labstack/echo/v4/middleware" 13 | "github.com/prometheus/client_golang/prometheus/promhttp" 14 | log "github.com/sirupsen/logrus" 15 | "github.com/ton-connect/bridge/internal" 16 | "github.com/ton-connect/bridge/internal/analytics" 17 | "github.com/ton-connect/bridge/internal/app" 18 | "github.com/ton-connect/bridge/internal/config" 19 | bridge_middleware "github.com/ton-connect/bridge/internal/middleware" 20 | "github.com/ton-connect/bridge/internal/utils" 21 | handlerv1 "github.com/ton-connect/bridge/internal/v1/handler" 22 | "github.com/ton-connect/bridge/internal/v1/storage" 23 | "github.com/ton-connect/bridge/tonmetrics" 24 | "golang.org/x/exp/slices" 25 | "golang.org/x/time/rate" 26 | ) 27 | 28 | func main() { 29 | log.Info(fmt.Sprintf("Bridge %s is running", internal.BridgeVersionRevision)) 30 | config.LoadConfig() 31 | app.InitMetrics() 32 | if config.Config.PostgresURI == "" { 33 | app.SetBridgeInfo("bridgev1", "memory") 34 | } else { 35 | app.SetBridgeInfo("bridgev1", "postgres") 36 | } 37 | 38 | tonAnalytics := tonmetrics.NewAnalyticsClient() 39 | 40 | collector := analytics.NewCollector(200, tonAnalytics, 500*time.Millisecond) 41 | go collector.Run(context.Background()) 42 | 43 | analyticsBuilder := analytics.NewEventBuilder( 44 | config.Config.TonAnalyticsBridgeURL, 45 | "bridge", 46 | "bridge", 47 | config.Config.TonAnalyticsBridgeVersion, 48 | config.Config.TonAnalyticsNetworkId, 49 | ) 50 | 51 | dbConn, err := storage.NewStorage(config.Config.PostgresURI, collector, analyticsBuilder) 52 | if err != nil { 53 | log.Fatalf("db connection %v", err) 54 | } 55 | 56 | healthManager := app.NewHealthManager() 57 | healthManager.UpdateHealthStatus(dbConn) 58 | go healthManager.StartHealthMonitoring(dbConn) 59 | 60 | extractor, err := utils.NewRealIPExtractor(config.Config.TrustedProxyRanges) 61 | if err != nil { 62 | log.Warnf("failed to create realIPExtractor: %v, using defaults", err) 63 | extractor, _ = utils.NewRealIPExtractor([]string{}) 64 | } 65 | 66 | mux := http.NewServeMux() 67 | mux.Handle("/health", http.HandlerFunc(healthManager.HealthHandler)) 68 | mux.Handle("/ready", http.HandlerFunc(healthManager.HealthHandler)) 69 | mux.Handle("/version", http.HandlerFunc(app.VersionHandler)) 70 | mux.Handle("/metrics", promhttp.Handler()) 71 | if config.Config.PprofEnabled { 72 | mux.HandleFunc("/debug/pprof/", pprof.Index) 73 | } 74 | go func() { 75 | log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", config.Config.MetricsPort), mux)) 76 | }() 77 | 78 | e := echo.New() 79 | e.Use(middleware.RecoverWithConfig(middleware.RecoverConfig{ 80 | Skipper: nil, 81 | DisableStackAll: true, 82 | DisablePrintStack: false, 83 | })) 84 | e.Use(middleware.Logger()) 85 | e.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ 86 | Skipper: func(c echo.Context) bool { 87 | if app.SkipRateLimitsByToken(c.Request()) || c.Path() != "/bridge/message" { 88 | return true 89 | } 90 | return false 91 | }, 92 | Store: middleware.NewRateLimiterMemoryStore(rate.Limit(config.Config.RPSLimit)), 93 | })) 94 | e.Use(app.ConnectionsLimitMiddleware(bridge_middleware.NewConnectionLimiter(config.Config.ConnectionsLimit, extractor), func(c echo.Context) bool { 95 | if app.SkipRateLimitsByToken(c.Request()) || c.Path() != "/bridge/events" { 96 | return true 97 | } 98 | return false 99 | })) 100 | 101 | if config.Config.CorsEnable { 102 | corsConfig := middleware.CORSWithConfig(middleware.CORSConfig{ 103 | AllowOrigins: []string{"*"}, 104 | AllowMethods: []string{echo.GET, echo.POST, echo.OPTIONS}, 105 | AllowHeaders: []string{"DNT", "X-CustomHeader", "Keep-Alive", "User-Agent", "X-Requested-With", "If-Modified-Since", "Cache-Control", "Content-Type", "Authorization"}, 106 | AllowCredentials: true, 107 | MaxAge: 86400, 108 | }) 109 | e.Use(corsConfig) 110 | } 111 | 112 | h := handlerv1.NewHandler(dbConn, time.Duration(config.Config.HeartbeatInterval)*time.Second, extractor, collector, analyticsBuilder) 113 | 114 | e.GET("/bridge/events", h.EventRegistrationHandler) 115 | e.POST("/bridge/message", h.SendMessageHandler) 116 | e.POST("/bridge/verify", h.ConnectVerifyHandler) 117 | 118 | var existedPaths []string 119 | for _, r := range e.Routes() { 120 | existedPaths = append(existedPaths, r.Path) 121 | } 122 | p := prometheus.NewPrometheus("http", func(c echo.Context) bool { 123 | return !slices.Contains(existedPaths, c.Path()) 124 | }) 125 | e.Use(p.HandlerFunc) 126 | if config.Config.SelfSignedTLS { 127 | cert, key, err := utils.GenerateSelfSignedCertificate() 128 | if err != nil { 129 | log.Fatalf("failed to generate self signed certificate: %v", err) 130 | } 131 | log.Fatal(e.StartTLS(fmt.Sprintf(":%v", config.Config.Port), cert, key)) 132 | } else { 133 | log.Fatal(e.Start(fmt.Sprintf(":%v", config.Config.Port))) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /test/gointegration/bridge_stress_test.go: -------------------------------------------------------------------------------- 1 | package bridge_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | // A tiny threadsafe buffer for received wallet messages. 12 | type recvBuf struct { 13 | mu sync.Mutex 14 | msgs map[string]BridgeAppEvent 15 | } 16 | 17 | func (b *recvBuf) add(e BridgeAppEvent) { 18 | b.mu.Lock() 19 | b.msgs[e.ID] = e 20 | b.mu.Unlock() 21 | } 22 | 23 | func (b *recvBuf) len() int { 24 | b.mu.Lock() 25 | defer b.mu.Unlock() 26 | return len(b.msgs) 27 | } 28 | 29 | func (b *recvBuf) contains(method, id string, params []string) bool { 30 | b.mu.Lock() 31 | defer b.mu.Unlock() 32 | for _, m := range b.msgs { 33 | if m.ID == id && m.Method == method { 34 | // params here are single string in tests, but compare anyway 35 | if len(m.Params) == len(params) { 36 | eq := true 37 | for i := range params { 38 | if m.Params[i] != params[i] { 39 | eq = false 40 | break 41 | } 42 | } 43 | if eq { 44 | return true 45 | } 46 | } 47 | } 48 | } 49 | return false 50 | } 51 | 52 | func TestBridgeStress_10x100(t *testing.T) { 53 | const ( 54 | CLIENT_COUNT = 10 55 | MESSAGES_PER_CLIENT = 100 56 | ) 57 | 58 | type pair struct { 59 | app *BridgeProvider 60 | wallet *BridgeProvider 61 | appSession string 62 | walletSession string 63 | received *recvBuf 64 | } 65 | 66 | clients := make([]*pair, 0, CLIENT_COUNT) 67 | 68 | // Create CLIENT_COUNT client pairs (app+wallet) 69 | for i := 0; i < CLIENT_COUNT; i++ { 70 | appSession := randomSessionID(t) 71 | walletSession := randomSessionID(t) 72 | 73 | // app provider (listen noop) 74 | app, err := OpenProvider(context.Background(), ProviderOpenOpts{ 75 | BridgeURL: BRIDGE_URL_Provider, 76 | Clients: []clientPair{{SessionID: appSession, ClientID: walletSession}}, 77 | Listener: func(BridgeAppEvent) {}, 78 | }) 79 | if err != nil { 80 | t.Fatalf("open app #%d: %v", i, err) 81 | } 82 | 83 | rb := &recvBuf{ 84 | msgs: map[string]BridgeAppEvent{}, 85 | } 86 | 87 | // wallet provider (records messages) 88 | wallet, err := OpenProvider(context.Background(), ProviderOpenOpts{ 89 | BridgeURL: BRIDGE_URL_Provider, 90 | Clients: []clientPair{{SessionID: walletSession, ClientID: appSession}}, 91 | Listener: func(e BridgeAppEvent) { rb.add(e) }, 92 | }) 93 | if err != nil { 94 | t.Fatalf("open wallet #%d: %v", i, err) 95 | } 96 | 97 | clients = append(clients, &pair{ 98 | app: app, 99 | wallet: wallet, 100 | appSession: appSession, 101 | walletSession: walletSession, 102 | received: rb, 103 | }) 104 | } 105 | 106 | t.Logf("Created %d client pairs", CLIENT_COUNT) 107 | 108 | // Send messages from all clients in parallel 109 | var wg sync.WaitGroup 110 | totalMsgs := CLIENT_COUNT * MESSAGES_PER_CLIENT 111 | wg.Add(totalMsgs) 112 | 113 | for ci := 0; ci < CLIENT_COUNT; ci++ { 114 | c := clients[ci] 115 | for mi := 0; mi < MESSAGES_PER_CLIENT; mi++ { 116 | ci, mi := ci, mi // capture 117 | go func() { 118 | defer wg.Done() 119 | msg := JSONRPC{ 120 | Method: "sendTransaction", 121 | Params: []string{fmt.Sprintf("client-%d-message-%d", ci, mi)}, 122 | ID: fmt.Sprintf("%d-%d", ci, mi), 123 | } 124 | if err := c.app.Send(context.Background(), msg, c.appSession, c.walletSession); err != nil { 125 | // Don’t fail the whole test immediately; record it and let the final checks catch any gaps. 126 | t.Errorf("send fail client=%d msg=%d: %v", ci, mi, err) 127 | } 128 | }() 129 | } 130 | } 131 | 132 | t.Logf("Sending %d messages...", totalMsgs) 133 | wg.Wait() 134 | t.Log("All messages sent, waiting for delivery...") 135 | 136 | // Wait for all messages to be received (with timeout) 137 | maxWait := 30 * time.Second 138 | start := time.Now() 139 | for { 140 | var totalReceived int 141 | for _, c := range clients { 142 | totalReceived += c.received.len() 143 | } 144 | t.Logf("Received %d/%d messages", totalReceived, totalMsgs) 145 | if totalReceived >= totalMsgs { 146 | break 147 | } 148 | if time.Since(start) > maxWait { 149 | t.Fatalf("timeout waiting for all messages: got %d/%d", totalReceived, totalMsgs) 150 | } 151 | time.Sleep(1 * time.Second) 152 | } 153 | 154 | // Verify all messages were received correctly per client 155 | for ci := 0; ci < CLIENT_COUNT; ci++ { 156 | c := clients[ci] 157 | if got := c.received.len(); got != MESSAGES_PER_CLIENT { 158 | t.Fatalf("client %d received %d/%d", ci, got, MESSAGES_PER_CLIENT) 159 | } 160 | for mi := 0; mi < MESSAGES_PER_CLIENT; mi++ { 161 | expParams := []string{fmt.Sprintf("client-%d-message-%d", ci, mi)} 162 | expID := fmt.Sprintf("%d-%d", ci, mi) 163 | if !c.received.contains("sendTransaction", expID, expParams) { 164 | t.Fatalf("client %d missing message id=%s params=%v", ci, expID, expParams) 165 | } 166 | } 167 | } 168 | 169 | // Cleanup 170 | for _, c := range clients { 171 | _ = c.app.Close() 172 | _ = c.wallet.Close() 173 | } 174 | t.Logf("Successfully processed %d messages across %d clients", totalMsgs, CLIENT_COUNT) 175 | } 176 | -------------------------------------------------------------------------------- /docs/MONITORING.md: -------------------------------------------------------------------------------- 1 | # Monitoring 2 | 3 | Comprehensive monitoring and observability for TON Connect Bridge. 4 | 5 | ## Quick Overview 6 | 7 | Bridge exposes Prometheus metrics at http://localhost:9103/metrics. 8 | 9 | **Key metrics to watch:** 10 | - `number_of_active_connections` - Current SSE connections 11 | - `number_of_transfered_messages` - Total messages sent 12 | - `bridge_health_status` - Health status (1 = healthy) 13 | - `bridge_ready_status` - Ready status (1 = ready) 14 | 15 | ## Profiling 16 | 17 | Profiling will not affect performance unless you start exploring it. To view all available profiles, open http://localhost:9103/debug/pprof in your browser. For more information, see the [usage examples](https://pkg.go.dev/net/http/pprof/#hdr-Usage_examples). 18 | 19 | To enable profiling feature, use `PPROF_ENABLED=true` flag. 20 | 21 | ## Health Endpoints 22 | 23 | ### `/health` 24 | 25 | Basic health check endpoint. Returns 200 if the service is running. 26 | 27 | **Usage:** 28 | ```bash 29 | curl http://localhost:9103/health 30 | # Response: 200 OK 31 | ``` 32 | 33 | ### `/ready` 34 | 35 | Readiness check including storage connectivity. Returns 200 only if bridge and storage are operational. 36 | 37 | **Usage:** 38 | ```bash 39 | curl http://localhost:9103/ready 40 | # Response: 200 OK (if ready) 41 | ``` 42 | 43 | ### `/version` 44 | 45 | Returns bridge version and build information. 46 | 47 | **Usage:** 48 | ```bash 49 | curl http://localhost:9103/version 50 | ``` 51 | 52 | ## Prometheus Metrics 53 | 54 | ### Connection Metrics 55 | 56 | #### `number_of_active_connections` 57 | **Type:** Gauge 58 | **Description:** Current number of active SSE connections. 59 | 60 | **Usage:** 61 | ```promql 62 | # Current connections 63 | number_of_active_connections 64 | 65 | # Average over 5 minutes 66 | avg_over_time(number_of_active_connections[5m]) 67 | 68 | # Connection limit utilization (%) 69 | (number_of_active_connections / $CONNECTIONS_LIMIT) * 100 70 | ``` 71 | 72 | #### `number_of_active_subscriptions` 73 | **Type:** Gauge 74 | **Description:** Current number of active client subscriptions (client IDs being monitored). 75 | 76 | **Usage:** 77 | ```promql 78 | # Current subscriptions 79 | number_of_active_subscriptions 80 | 81 | # Subscriptions per connection ratio 82 | number_of_active_subscriptions / number_of_active_connections 83 | ``` 84 | 85 | #### `number_of_client_ids_per_connection` 86 | **Type:** Histogram 87 | **Description:** Distribution of client IDs per connection. 88 | 89 | **Buckets:** 1, 2, 3, 4, 5, 10, 20, 50, 100, 500 90 | 91 | **Usage:** 92 | ```promql 93 | # Average client IDs per connection 94 | rate(number_of_client_ids_per_connection_sum[5m]) / 95 | rate(number_of_client_ids_per_connection_count[5m]) 96 | 97 | # P95 client IDs per connection 98 | histogram_quantile(0.95, number_of_client_ids_per_connection_bucket) 99 | ``` 100 | 101 | ### Message Metrics 102 | 103 | #### `number_of_transfered_messages` 104 | **Type:** Counter 105 | **Description:** Total number of messages transferred through the bridge. 106 | 107 | **Usage:** 108 | ```promql 109 | # Messages per second 110 | rate(number_of_transfered_messages[1m]) 111 | 112 | # Total messages today 113 | increase(number_of_transfered_messages[24h]) 114 | 115 | # Messages per connection 116 | rate(number_of_transfered_messages[5m]) / 117 | avg_over_time(number_of_active_connections[5m]) 118 | ``` 119 | 120 | #### `number_of_delivered_messages` 121 | **Type:** Counter 122 | **Description:** Total number of messages successfully delivered to clients. 123 | 124 | **Usage:** 125 | ```promql 126 | # Delivery rate 127 | rate(number_of_delivered_messages[1m]) 128 | 129 | # Delivery success rate (%) 130 | rate(number_of_delivered_messages[5m]) / 131 | rate(number_of_transfered_messages[5m]) * 100 132 | ``` 133 | 134 | ### Error Metrics 135 | 136 | #### `number_of_bad_requests` 137 | **Type:** Counter 138 | **Description:** Total number of bad requests (4xx errors). 139 | 140 | **Usage:** 141 | ```promql 142 | # Error rate 143 | rate(number_of_bad_requests[5m]) 144 | 145 | # Error percentage 146 | rate(number_of_bad_requests[5m]) / 147 | rate(http_requests_total[5m]) * 100 148 | ``` 149 | 150 | ### Token Usage Metrics 151 | 152 | #### `bridge_token_usage` 153 | **Type:** Counter 154 | **Labels:** `token` 155 | **Description:** Usage count per bypass token (from `RATE_LIMITS_BY_PASS_TOKEN`). 156 | 157 | **Usage:** 158 | ```promql 159 | # Usage by token 160 | rate(bridge_token_usage{token="token1"}[5m]) 161 | 162 | # Top tokens 163 | topk(5, rate(bridge_token_usage[5m])) 164 | ``` 165 | 166 | ### Health Metrics 167 | 168 | #### `bridge_health_status` 169 | **Type:** Gauge 170 | **Description:** Health status (1 = healthy, 0 = unhealthy). 171 | 172 | **Usage:** 173 | ```promql 174 | # Current health 175 | bridge_health_status 176 | 177 | # Health over time 178 | avg_over_time(bridge_health_status[1h]) 179 | ``` 180 | 181 | #### `bridge_ready_status` 182 | **Type:** Gauge 183 | **Description:** Ready status including storage (1 = ready, 0 = not ready). 184 | 185 | **Usage:** 186 | ```promql 187 | # Current readiness 188 | bridge_ready_status 189 | 190 | # Downtime in last hour 191 | count_over_time((bridge_ready_status == 0)[1h:1m]) 192 | ``` 193 | -------------------------------------------------------------------------------- /test/clustered/k6/bridge_test.js: -------------------------------------------------------------------------------- 1 | import http from 'k6/http'; 2 | import { Counter, Trend } from 'k6/metrics'; 3 | import { SharedArray } from 'k6/data'; 4 | import exec from 'k6/execution'; 5 | import sse from 'k6/x/sse'; 6 | import encoding from 'k6/encoding'; 7 | 8 | export let sse_messages = new Counter('sse_messages'); 9 | export let sse_errors = new Counter('sse_errors'); 10 | export let post_errors = new Counter('post_errors'); 11 | export let delivery_latency = new Trend('delivery_latency'); 12 | export let json_parse_errors = new Counter('json_parse_errors'); 13 | export let missing_timestamps = new Counter('missing_timestamps'); 14 | 15 | const PRE_ALLOCATED_VUS = 100 16 | const MAX_VUS = PRE_ALLOCATED_VUS * 25 17 | 18 | const BRIDGE_URL = __ENV.BRIDGE_URL || 'http://localhost:8081/bridge'; 19 | const TEST_DURATION = __ENV.TEST_DURATION || '2m'; 20 | const SSE_VUS = Number(__ENV.SSE_VUS || 500); 21 | const SEND_RATE = Number(__ENV.SEND_RATE || 5000); 22 | 23 | // Generate valid hex client IDs that the bridge expects 24 | const ID_POOL = new SharedArray('ids', () => Array.from({length: 100}, (_, i) => { 25 | return i.toString(16).padStart(64, '0'); // 64-char hex strings 26 | })); 27 | 28 | // Test with configurable ramp up and duration 29 | const RAMP_UP = '30s'; 30 | const HOLD = TEST_DURATION; 31 | const RAMP_DOWN = '30s'; 32 | 33 | export const options = { 34 | discardResponseBodies: true, 35 | systemTags: ['status', 'method', 'name', 'scenario'], // Exclude 'url' to prevent metrics explosion 36 | thresholds: { 37 | http_req_failed: ['rate<0.01'], // Less than 1% errors 38 | delivery_latency: ['p(95)<500'], // p95 under 500ms 39 | sse_errors: ['count<10'], // SSE should be very stable 40 | json_parse_errors: ['count<5'], // Should rarely fail to parse 41 | missing_timestamps: ['count<100'], // Most messages should have timestamps 42 | }, 43 | scenarios: { 44 | sse: { 45 | executor: 'ramping-vus', 46 | startVUs: 0, 47 | stages: [ 48 | { duration: RAMP_UP, target: SSE_VUS }, // warm-up 49 | { duration: HOLD, target: SSE_VUS }, // steady 50 | { duration: RAMP_DOWN, target: 0 }, // cool-down 51 | ], 52 | gracefulRampDown: '30s', 53 | exec: 'sseWorker' 54 | }, 55 | senders: { 56 | executor: 'ramping-arrival-rate', 57 | startRate: 0, 58 | timeUnit: '1s', 59 | preAllocatedVUs: PRE_ALLOCATED_VUS, 60 | maxVUs: MAX_VUS, 61 | stages: [ 62 | { duration: RAMP_UP, target: SEND_RATE }, // warm-up 63 | { duration: HOLD, target: SEND_RATE }, // steady 64 | { duration: RAMP_DOWN, target: 0 }, // cool-down 65 | ], 66 | gracefulStop: '30s', 67 | exec: 'messageSender' 68 | }, 69 | }, 70 | }; 71 | 72 | export function sseWorker() { 73 | // Use round-robin assignment for more predictable URLs 74 | const vuIndex = exec.vu.idInTest - 1; 75 | const groupId = Math.floor(vuIndex / 10); // 10 VUs per group 76 | const ids = [`client_${groupId * 3}`, `client_${groupId * 3 + 1}`, `client_${groupId * 3 + 2}`]; 77 | const url = `${BRIDGE_URL}/events?client_id=${ids.join(',')}`; 78 | 79 | // Keep reconnecting for the test duration 80 | for (;;) { 81 | try { 82 | sse.open(url, { 83 | headers: { Accept: 'text/event-stream' }, 84 | tags: { name: 'SSE /events' } 85 | }, (c) => { 86 | c.on('event', (ev) => { 87 | if (ev.data === 'heartbeat' || !ev.data || ev.data.trim() === '') { 88 | return; // Skip heartbeats and empty events 89 | } 90 | try { 91 | const m = JSON.parse(ev.data); 92 | if (m.ts) { 93 | const latency = Date.now() - m.ts; 94 | delivery_latency.add(latency); 95 | } else { 96 | missing_timestamps.add(1); 97 | console.log('Message missing timestamp:', ev.data); 98 | } 99 | } catch(e) { 100 | json_parse_errors.add(1); 101 | console.log('JSON parse error:', e, 'data:', ev.data); 102 | } 103 | sse_messages.add(1); 104 | }); 105 | c.on('error', (err) => { 106 | console.log('SSE error:', err); 107 | sse_errors.add(1); 108 | }); 109 | }); 110 | } catch (e) { 111 | console.log('SSE connection failed:', e); 112 | sse_errors.add(1); 113 | } 114 | } 115 | } 116 | 117 | export function messageSender() { 118 | // Use fixed client pairs to reduce URL variations 119 | const vuIndex = exec.vu.idInTest % ID_POOL.length; 120 | const to = ID_POOL[vuIndex]; 121 | const from = ID_POOL[(vuIndex + 1) % ID_POOL.length]; 122 | const topic = Math.random() < 0.5 ? 'sendTransaction' : 'signData'; 123 | const body = encoding.b64encode(JSON.stringify({ ts: Date.now(), data: 'test_message' })); 124 | const url = `${BRIDGE_URL}/message?client_id=${from}&to=${to}&ttl=300&topic=${topic}`; 125 | 126 | const r = http.post(url, body, { 127 | headers: { 'Content-Type': 'text/plain' }, 128 | timeout: '10s', 129 | tags: { name: 'POST /message' }, // Group all message requests 130 | }); 131 | if (r.status !== 200) post_errors.add(1); 132 | } 133 | -------------------------------------------------------------------------------- /internal/utils/http_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | ) 7 | 8 | func TestExtractOrigin(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | rawURL string 12 | want string 13 | }{ 14 | { 15 | name: "empty string", 16 | rawURL: "", 17 | want: "", 18 | }, 19 | { 20 | name: "valid http URL but not valid Origin", 21 | rawURL: "http://example.com/path/to/resource", 22 | want: "http://example.com", 23 | }, 24 | { 25 | name: "URL with port", 26 | rawURL: "https://example.com:8080/api", 27 | want: "https://example.com:8080", 28 | }, 29 | { 30 | name: "invalid URL - no scheme", 31 | rawURL: "example.com/path", 32 | want: "example.com/path", 33 | }, 34 | { 35 | name: "invalid URL - malformed", 36 | rawURL: "ht tp://example.com", 37 | want: "ht tp://example.com", 38 | }, 39 | { 40 | name: "URL with subdomain", 41 | rawURL: "https://api.example.com/", 42 | want: "https://api.example.com", 43 | }, 44 | } 45 | 46 | for _, tt := range tests { 47 | t.Run(tt.name, func(t *testing.T) { 48 | if got := ExtractOrigin(tt.rawURL); got != tt.want { 49 | t.Errorf("ExtractOrigin() = %v, want %v", got, tt.want) 50 | } 51 | }) 52 | } 53 | } 54 | 55 | func TestRealIPExtractor(t *testing.T) { 56 | tests := []struct { 57 | name string 58 | headers map[string]string 59 | remoteAddr string 60 | trustedRanges []string 61 | want string 62 | }{ 63 | { 64 | name: "X-Forwarded-For with single IP - trusted proxy", 65 | headers: map[string]string{"X-Forwarded-For": "203.0.113.1"}, 66 | remoteAddr: "192.168.1.1", 67 | trustedRanges: []string{"192.168.1.0/24"}, 68 | want: "203.0.113.1", 69 | }, 70 | { 71 | name: "X-Forwarded-For with multiple IPs - trusted proxy", 72 | headers: map[string]string{"X-Forwarded-For": "203.0.113.1, 192.168.1.1"}, 73 | remoteAddr: "192.168.1.2", 74 | trustedRanges: []string{"192.168.1.0/24"}, 75 | want: "203.0.113.1", 76 | }, 77 | { 78 | name: "No X-Forwarded-For header, use RemoteAddr", 79 | headers: map[string]string{}, 80 | remoteAddr: "203.0.113.1", 81 | trustedRanges: []string{"192.168.1.0/24"}, 82 | want: "203.0.113.1", 83 | }, 84 | { 85 | name: "Untrusted X-Forwarded-For and RemoteAddr - uses RemoteAddr", 86 | headers: map[string]string{"X-Forwarded-For": "203.0.113.1"}, 87 | remoteAddr: "192.168.1.1", 88 | trustedRanges: []string{"10.0.0.0/8"}, 89 | want: "192.168.1.1", 90 | }, 91 | { 92 | name: "Untrusted X-Forwarded-For and trusted RemoteAddr", 93 | headers: map[string]string{"X-Forwarded-For": "203.0.113.1"}, 94 | remoteAddr: "10.0.0.1", 95 | trustedRanges: []string{"10.0.0.0/8"}, 96 | want: "203.0.113.1", 97 | }, 98 | { 99 | name: "X-Real-IP header ignored (not supported)", 100 | headers: map[string]string{"X-Real-IP": "203.0.113.5"}, 101 | remoteAddr: "192.168.1.2", 102 | trustedRanges: []string{"192.168.1.0/24"}, 103 | want: "192.168.1.2", 104 | }, 105 | { 106 | name: "Long proxy chain - 5 IPs", 107 | headers: map[string]string{"X-Forwarded-For": "203.0.113.1, 198.51.100.1, 192.168.1.5, 192.168.1.10, 10.0.0.5"}, 108 | remoteAddr: "192.168.1.1", 109 | trustedRanges: []string{"192.168.1.0/24", "10.0.0.0/8"}, 110 | want: "198.51.100.1", 111 | }, 112 | { 113 | name: "Very long proxy chain - 10 IPs", 114 | headers: map[string]string{"X-Forwarded-For": "203.0.113.50, 198.51.100.25, 172.16.0.1, 10.1.1.1, 10.2.2.2, 192.168.10.1, 192.168.20.1, 172.17.0.1, 10.0.1.1, 192.168.1.100"}, 115 | remoteAddr: "192.168.1.1", 116 | trustedRanges: []string{"192.168.0.0/16", "10.0.0.0/8", "172.16.0.0/12"}, 117 | want: "198.51.100.25", 118 | }, 119 | { 120 | name: "Proxy chain with mixed trusted/untrusted - returns rightmost untrusted", 121 | headers: map[string]string{"X-Forwarded-For": "203.0.113.100, 8.8.8.8, 192.168.1.50, 10.0.0.25"}, 122 | remoteAddr: "192.168.1.1", 123 | trustedRanges: []string{"192.168.1.0/24", "10.0.0.0/8"}, 124 | want: "8.8.8.8", 125 | }, 126 | { 127 | name: "IPv6 RemoteAddr", 128 | headers: map[string]string{}, 129 | remoteAddr: "[2001:db8::1]", 130 | trustedRanges: []string{"192.168.1.0/24"}, 131 | want: "2001:db8::1", 132 | }, 133 | { 134 | name: "Empty RemoteAddr with X-Forwarded-For", 135 | headers: map[string]string{"X-Forwarded-For": "203.0.113.1"}, 136 | remoteAddr: "", 137 | trustedRanges: []string{"192.168.1.0/24"}, 138 | want: "", 139 | }, 140 | } 141 | 142 | for _, tt := range tests { 143 | t.Run(tt.name, func(t *testing.T) { 144 | realIP, err := NewRealIPExtractor(tt.trustedRanges) 145 | if err != nil { 146 | t.Fatalf("failed to create realIPExtractor: %v", err) 147 | } 148 | 149 | req := &http.Request{ 150 | Header: make(http.Header), 151 | RemoteAddr: tt.remoteAddr, 152 | } 153 | 154 | for k, v := range tt.headers { 155 | req.Header.Set(k, v) 156 | } 157 | 158 | result := realIP.Extract(req) 159 | if result != tt.want { 160 | t.Errorf("realIP.Extract() = %q, want %q", result, tt.want) 161 | } 162 | }) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /docs/CONFIGURATION.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | Complete reference for all environment variables supported by TON Connect Bridge. 4 | 5 | ## Core Settings 6 | 7 | | Variable | Type | Default | Description | 8 | |----------|------|---------|-------------| 9 | | `LOG_LEVEL` | string | `info` | Logging level
`panic` `fatal` `error` `warn` `info` `debug` `trace` | 10 | | `PORT` | int | `8081` | HTTP server port for bridge endpoints | 11 | | `METRICS_PORT` | int | `9103` | Metrics port: `/health` `/ready` `/metrics` `/version` `/debug/pprof/*` | 12 | | `PPROF_ENABLED` | bool | `true` | See [pprof docs](https://pkg.go.dev/net/http/pprof) | 13 | 14 | ## Storage 15 | 16 | | Variable | Type | Default | Description | 17 | |----------|------|---------|-------------| 18 | | `STORAGE` | string | `memory` | `valkey` (cluster required) or `memory` (dev only) | 19 | | `VALKEY_URI` | string | - | Cluster format: `rediss://default:@clustercfg.example.com:6379?skip_verify=true` | 20 | 21 | ## Performance & Limits 22 | 23 | | Variable | Type | Default | Description | 24 | |----------|------|---------|-------------| 25 | | `HEARTBEAT_INTERVAL` | int | `10` | SSE heartbeat interval (seconds) | 26 | | `RPS_LIMIT` | int | `1` | Requests/sec per IP for `/bridge/message` | 27 | | `CONNECTIONS_LIMIT` | int | `50` | Max concurrent SSE connections per IP | 28 | | `MAX_BODY_SIZE` | int | `10485760` | Max HTTP request body size (bytes) for `/bridge/message` | 29 | | `RATE_LIMITS_BY_PASS_TOKEN` | string | - | Bypass tokens (comma-separated) | 30 | 31 | ## Security 32 | 33 | | Variable | Type | Default | Description | 34 | |----------|------|---------|-------------| 35 | | `CORS_ENABLE` | bool | `false` | Enable CORS: origins `*`, methods `GET/POST/OPTIONS`, credentials `true` | 36 | | `TRUSTED_PROXY_RANGES` | string | `0.0.0.0/0` | Trusted proxy CIDRs for `X-Forwarded-For` (comma-separated)
Example: `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | 37 | | `SELF_SIGNED_TLS` | bool | `false` | ⚠️ **Dev only**: Self-signed TLS cert. Use nginx/Cloudflare in prod | 38 | 39 | ## Caching 40 | 41 | | Variable | Type | Default | Description | 42 | |----------|------|---------|-------------| 43 | | `CONNECT_CACHE_SIZE` | int | `2000000` | Max entries in connect client cache | 44 | | `CONNECT_CACHE_TTL` | int | `300` | Cache TTL (seconds) | 45 | 46 | ## Webhooks 47 | 48 | | Variable | Type | Default | Description | 49 | |----------|------|---------|-------------| 50 | | `WEBHOOK_URL` | string | - | URL for bridge event notifications | 51 | | `COPY_TO_URL` | string | - | Mirror all messages to URL (debugging/analytics) | 52 | 53 | ## TON Analytics 54 | 55 | TODO where to read more about it? 56 | 57 | | Variable | Type | Default | Description | 58 | |--------------------------------|--------|---------|--------------------------------------------------------------| 59 | | `TON_ANALYTICS_ENABLED` | bool | `false` | Enable TonConnect analytics | 60 | | `TON_ANALYTICS_URL` | string | `https://analytics.ton.org/events` | TON Analytics endpoint URL | 61 | | `TON_ANALYTICS_BRIDGE_VERSION` | string | `1.0.0` | Bridge version for analytics tracking (auto-set during build) | 62 | | `TON_ANALYTICS_BRIDGE_URL` | string | `localhost` | Public bridge URL for analytics | 63 | | `TON_ANALYTICS_NETWORK_ID` | string | `-239` | TON network: `-239` (mainnet), `-3` (testnet) | 64 | 65 | ## NTP Time Synchronization 66 | 67 | Bridge v3 supports NTP time synchronization for consistent `event_id` generation across multiple instances. This ensures monotonic event ordering even when bridge instances run on different servers. 68 | 69 | | Variable | Type | Default | Description | 70 | |----------|------|---------|-------------| 71 | | `NTP_ENABLED` | bool | `true` | Enable NTP time synchronization | 72 | | `NTP_SERVERS` | string | `time.google.com,time.cloudflare.com,pool.ntp.org` | Comma-separated NTP server list | 73 | | `NTP_SYNC_INTERVAL` | int | `300` | NTP sync interval (seconds) | 74 | | `NTP_QUERY_TIMEOUT` | int | `5` | NTP query timeout (seconds) | 75 | 76 | **Note:** NTP synchronization is only available in bridge v3. Bridge v1 uses local system time. 77 | 78 | ## Configuration Presets 79 | 80 | ### 🧪 Development (Memory) 81 | 82 | ```bash 83 | LOG_LEVEL=debug 84 | STORAGE=memory 85 | CORS_ENABLE=true 86 | HEARTBEAT_INTERVAL=10 87 | RPS_LIMIT=50 88 | CONNECTIONS_LIMIT=50 89 | NTP_ENABLED=true 90 | ``` 91 | 92 | ### 🚀 Production (Redis/Valkey Cluster) 93 | 94 | ```bash 95 | LOG_LEVEL=info 96 | STORAGE=valkey 97 | VALKEY_URI="rediss://username:yourpassword@clustercfg.example.com:6379?skip_verify=true" 98 | CORS_ENABLE=true 99 | RPS_LIMIT=100000 100 | CONNECTIONS_LIMIT=500000 101 | CONNECT_CACHE_SIZE=500000 102 | TRUSTED_PROXY_RANGES="10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,{use_your_own_please}" 103 | ENVIRONMENT=production 104 | BRIDGE_URL="https://use-your-own-bridge.myapp.com" 105 | NTP_ENABLED=true 106 | NTP_SERVERS=time.google.com,time.cloudflare.com,pool.ntp.org 107 | NTP_SYNC_INTERVAL=300 108 | ``` 109 | 110 | ## Using Environment Files 111 | 112 |
113 | 💾 .env file 114 | 115 | ```bash 116 | # .env 117 | LOG_LEVEL=info 118 | PORT=8081 119 | STORAGE=valkey 120 | VALKEY_URI=rediss://clustercfg.example.com:6379 121 | CORS_ENABLE=true 122 | RPS_LIMIT=100 123 | CONNECTIONS_LIMIT=200 124 | ``` 125 | 126 | **Load:** 127 | ```bash 128 | export $(cat .env | xargs) && ./bridge3 129 | ``` 130 | 131 |
132 | -------------------------------------------------------------------------------- /internal/storage/message_cache_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "runtime" 5 | "sync" 6 | "sync/atomic" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestMarkAndIsMarkedBasic(t *testing.T) { 12 | mc := NewMessageCache(true, time.Hour) 13 | 14 | // Initially not marked 15 | if mc.IsMarked(42) { 16 | t.Fatalf("expected not marked") 17 | } 18 | 19 | // Mark 20 | mc.Mark(42) 21 | 22 | // Now marked 23 | if !mc.IsMarked(42) { 24 | t.Fatalf("expected marked after Mark") 25 | } 26 | 27 | // MarkIfNotExists should return false now 28 | if ok := mc.MarkIfNotExists(42); ok { 29 | t.Fatalf("expected MarkIfNotExists to return false for existing id") 30 | } 31 | } 32 | 33 | func TestConcurrentMarkAndIsMarked(t *testing.T) { 34 | const total = 1000000 35 | mc := NewMessageCache(true, time.Hour) 36 | workers := runtime.NumCPU() * 4 37 | 38 | // First pass: ensure MarkIfNotExists returns true for each unique id and IsMarked true 39 | var successFirst int64 40 | var badFirst int64 41 | 42 | jobs := make(chan int64, workers) 43 | var wg sync.WaitGroup 44 | for i := 0; i < workers; i++ { 45 | wg.Add(1) 46 | go func() { 47 | defer wg.Done() 48 | for id := range jobs { 49 | if mc.MarkIfNotExists(id) { 50 | atomic.AddInt64(&successFirst, 1) 51 | } else { 52 | atomic.AddInt64(&badFirst, 1) 53 | } 54 | if !mc.IsMarked(id) { 55 | atomic.AddInt64(&badFirst, 1) 56 | } 57 | } 58 | }() 59 | } 60 | 61 | for i := 0; i < total; i++ { 62 | jobs <- int64(i) 63 | } 64 | close(jobs) 65 | wg.Wait() 66 | 67 | if got := atomic.LoadInt64(&successFirst); got != total { 68 | t.Fatalf("expected %d successes in first pass, got %d (bad: %d)", total, got, atomic.LoadInt64(&badFirst)) 69 | } 70 | if atomic.LoadInt64(&badFirst) != 0 { 71 | t.Fatalf("unexpected failures in first pass: %d", atomic.LoadInt64(&badFirst)) 72 | } 73 | 74 | // Second pass: MarkIfNotExists should return false for all duplicate ids 75 | var duplicatesReported int64 76 | jobs2 := make(chan int64, workers) 77 | var wg2 sync.WaitGroup 78 | for i := 0; i < workers; i++ { 79 | wg2.Add(1) 80 | go func() { 81 | defer wg2.Done() 82 | for id := range jobs2 { 83 | if mc.MarkIfNotExists(id) { 84 | // unexpected true 85 | atomic.AddInt64(&duplicatesReported, 1) 86 | } 87 | // IsMarked must still be true 88 | if !mc.IsMarked(id) { 89 | atomic.AddInt64(&duplicatesReported, 1) 90 | } 91 | } 92 | }() 93 | } 94 | 95 | for i := 0; i < total; i++ { 96 | jobs2 <- int64(i) 97 | } 98 | close(jobs2) 99 | wg2.Wait() 100 | 101 | if got := atomic.LoadInt64(&duplicatesReported); got != 0 { 102 | t.Fatalf("expected 0 unexpected successes/failed checks in second pass, got %d", got) 103 | } 104 | } 105 | 106 | func TestCleanupRemovesOldEntries(t *testing.T) { 107 | ttl := 50 * time.Millisecond 108 | mc := NewMessageCache(true, ttl).(*InMemoryMessageCache) 109 | 110 | // populate entries: 10 old, 10 fresh 111 | mc.mutex.Lock() 112 | now := time.Now() 113 | for i := 0; i < 10; i++ { 114 | mc.markedMessages[int64(i)] = now.Add(-ttl * 10) // old 115 | } 116 | for i := 10; i < 20; i++ { 117 | mc.markedMessages[int64(i)] = now // fresh 118 | } 119 | mc.mutex.Unlock() 120 | 121 | removed := mc.Cleanup() 122 | if removed != 10 { 123 | t.Fatalf("expected removed 10 old entries, got %d", removed) 124 | } 125 | 126 | // ensure remaining are the fresh ones 127 | mc.mutex.RLock() 128 | remaining := len(mc.markedMessages) 129 | mc.mutex.RUnlock() 130 | if remaining != 10 { 131 | t.Fatalf("expected 10 remaining entries, got %d", remaining) 132 | } 133 | for i := 10; i < 20; i++ { 134 | if !mc.IsMarked(int64(i)) { 135 | t.Fatalf("expected id %d to remain marked", i) 136 | } 137 | } 138 | } 139 | 140 | func BenchmarkConcurrentMarkIsMarked(b *testing.B) { 141 | const total = 1000000 142 | workers := runtime.NumCPU() * 4 143 | 144 | for it := 0; it < b.N; it++ { 145 | mc := NewMessageCache(true, time.Second) 146 | var wg sync.WaitGroup 147 | jobs := make(chan int64, workers) 148 | 149 | start := time.Now() 150 | b.ResetTimer() 151 | 152 | for i := 0; i < workers; i++ { 153 | wg.Add(1) 154 | go func() { 155 | defer wg.Done() 156 | for id := range jobs { 157 | _ = mc.MarkIfNotExists(id) 158 | _ = mc.IsMarked(id) 159 | } 160 | }() 161 | } 162 | 163 | for i := 0; i < total; i++ { 164 | jobs <- int64(i) 165 | } 166 | close(jobs) 167 | wg.Wait() 168 | 169 | b.StopTimer() 170 | _ = start // keep start if additional instrumentation needed in future 171 | } 172 | } 173 | 174 | func BenchmarkConcurrentCleaning(b *testing.B) { 175 | const total = 1000000 176 | workers := runtime.NumCPU() * 4 177 | 178 | for it := 0; it < b.N; it++ { 179 | mc := NewMessageCache(true, 100*time.Millisecond) 180 | var wg sync.WaitGroup 181 | jobs := make(chan int64, workers) 182 | 183 | b.ResetTimer() 184 | 185 | for i := 0; i < workers; i++ { 186 | wg.Add(1) 187 | go func() { 188 | defer wg.Done() 189 | for id := range jobs { 190 | _ = mc.MarkIfNotExists(id) 191 | _ = mc.IsMarked(id) 192 | } 193 | }() 194 | } 195 | 196 | for i := 0; i < total; i++ { 197 | jobs <- int64(i) 198 | } 199 | close(jobs) 200 | wg.Wait() 201 | 202 | b.StopTimer() 203 | time.Sleep(time.Second) 204 | // runtime.GC() 205 | _ = mc.Cleanup() 206 | size := mc.Len() 207 | b.Logf("final cache size after wait: %d", size) 208 | if size != 0 { 209 | b.Fatalf("expected cache to be fully cleaned, got size %d", size) 210 | } 211 | 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GIT_REVISION=`git rev-parse --short HEAD` 2 | BRIDGE_VERSION=`git describe --tags --abbrev=0` 3 | LDFLAGS=-ldflags "-X github.com/ton-connect/bridge/internal.GitRevision=${GIT_REVISION} -X github.com/ton-connect/bridge/internal.BridgeVersion=${BRIDGE_VERSION}" 4 | GOFMT_FILES?=$$(find . -name '*.go' | grep -v vendor | grep -v yacc | grep -v .git) 5 | 6 | .PHONY: all imports fmt test test-unit test-bench test-bridge-sdk run stop clean logs status help 7 | 8 | STORAGE ?= memory 9 | DOCKER_COMPOSE_FILE = docker/docker-compose.memory.yml 10 | 11 | # Set compose file based on storage type 12 | ifeq ($(STORAGE),memory) 13 | DOCKER_COMPOSE_FILE = docker/docker-compose.memory.yml 14 | else ifeq ($(STORAGE),postgres) 15 | DOCKER_COMPOSE_FILE = docker/docker-compose.postgres.yml 16 | endif 17 | 18 | all: imports fmt test 19 | 20 | build: 21 | go build -mod=mod ${LDFLAGS} -o bridge ./cmd/bridge 22 | 23 | build3: 24 | go build -mod=mod ${LDFLAGS} -o bridge3 ./cmd/bridge3 25 | 26 | fmt: 27 | gofmt -w $(GOFMT_FILES) 28 | 29 | fmtcheck: 30 | @sh -c "'$(CURDIR)/scripts/gofmtcheck.sh'" 31 | 32 | lint: 33 | golangci-lint run --timeout=10m --color=always 34 | 35 | test: test-unit test-bench 36 | 37 | test-unit: 38 | go test $$(go list ./... | grep -v vendor | grep -v test) -race -coverprofile cover.out 39 | 40 | test-bench: 41 | go test -race -count 10 -timeout 15s -bench=BenchmarkConnectionCache -benchmem ./internal/v1/handler 42 | 43 | test-gointegration: 44 | go test -v -p 10 -v -run TestBridge ./test/gointegration 45 | 46 | test-bridge-sdk: 47 | @./scripts/test-bridge-sdk.sh 48 | 49 | run: 50 | @echo "Starting bridge environment with $(STORAGE) storage using $(DOCKER_COMPOSE_FILE)..." 51 | @if command -v docker-compose >/dev/null 2>&1; then \ 52 | docker-compose -f $(DOCKER_COMPOSE_FILE) up --build -d; \ 53 | elif command -v docker >/dev/null 2>&1; then \ 54 | docker compose -f $(DOCKER_COMPOSE_FILE) up --build -d; \ 55 | else \ 56 | echo "Error: Docker is not installed or not in PATH"; \ 57 | echo "Please install Docker Desktop from https://www.docker.com/products/docker-desktop"; \ 58 | exit 1; \ 59 | fi 60 | @echo "Environment started! Access the bridge at http://localhost:8081" 61 | @echo "Use 'make logs' to view logs, 'make stop' to stop services" 62 | 63 | stop: 64 | @echo "Stopping bridge environment using $(DOCKER_COMPOSE_FILE)..." 65 | @if command -v docker-compose >/dev/null 2>&1; then \ 66 | docker-compose -f $(DOCKER_COMPOSE_FILE) down; \ 67 | elif command -v docker >/dev/null 2>&1; then \ 68 | docker compose -f $(DOCKER_COMPOSE_FILE) down; \ 69 | else \ 70 | echo "Error: Docker is not installed or not in PATH"; \ 71 | exit 1; \ 72 | fi 73 | 74 | clean: 75 | @echo "Cleaning up bridge environment and volumes using $(DOCKER_COMPOSE_FILE)..." 76 | @if command -v docker-compose >/dev/null 2>&1; then \ 77 | docker-compose -f $(DOCKER_COMPOSE_FILE) down -v --rmi local; \ 78 | elif command -v docker >/dev/null 2>&1; then \ 79 | docker compose -f $(DOCKER_COMPOSE_FILE) down -v --rmi local; \ 80 | else \ 81 | echo "Error: Docker is not installed or not in PATH"; \ 82 | exit 1; \ 83 | fi 84 | @if command -v docker >/dev/null 2>&1; then \ 85 | docker system prune -f; \ 86 | fi 87 | 88 | logs: 89 | @if command -v docker-compose >/dev/null 2>&1; then \ 90 | docker-compose -f $(DOCKER_COMPOSE_FILE) logs -f; \ 91 | elif command -v docker >/dev/null 2>&1; then \ 92 | docker compose -f $(DOCKER_COMPOSE_FILE) logs -f; \ 93 | else \ 94 | echo "Error: Docker is not installed or not in PATH"; \ 95 | exit 1; \ 96 | fi 97 | 98 | status: 99 | @if command -v docker-compose >/dev/null 2>&1; then \ 100 | docker-compose -f $(DOCKER_COMPOSE_FILE) ps; \ 101 | elif command -v docker >/dev/null 2>&1; then \ 102 | docker compose -f $(DOCKER_COMPOSE_FILE) ps; \ 103 | else \ 104 | echo "Error: Docker is not installed or not in PATH"; \ 105 | exit 1; \ 106 | fi 107 | 108 | help: 109 | @echo "TON Connect Bridge - Available Commands" 110 | @echo "======================================" 111 | @echo "" 112 | @echo "Development Commands:" 113 | @echo " all - Run imports, format, and test (default target)" 114 | @echo " build - Build the bridge binary" 115 | @echo " fmt - Format Go source files" 116 | @echo " fmtcheck - Check Go source file formatting" 117 | @echo " lint - Run golangci-lint" 118 | @echo " test - Run unit tests with race detection and coverage" 119 | @echo " test-unit - Run unit tests only (same as test)" 120 | @echo " test-bench - Run ConnectionCache benchmark tests" 121 | @echo " test-bridge-sdk - Run bridge SDK integration tests" 122 | @echo "" 123 | @echo "Docker Environment Commands:" 124 | @echo " run - Start bridge environment (use STORAGE=memory|postgres)" 125 | @echo " stop - Stop bridge environment" 126 | @echo " clean - Stop environment and remove volumes/images" 127 | @echo " logs - Follow logs from running containers" 128 | @echo " status - Show status of running containers" 129 | @echo "" 130 | @echo "Storage Options:" 131 | @echo " STORAGE=memory - Use in-memory storage (default, no persistence)" 132 | @echo " STORAGE=postgres - Use PostgreSQL storage (persistent)" 133 | @echo "" 134 | @echo "Usage Examples:" 135 | @echo " make run # Start with memory storage" 136 | @echo " make STORAGE=postgres run # Start with PostgreSQL storage" 137 | @echo " make test # Run all unit tests" 138 | @echo " make test-bench # Run ConnectionCache benchmarks" 139 | @echo " make clean # Clean up everything" 140 | @echo "" 141 | @echo "Bridge will be available at: http://localhost:8081" -------------------------------------------------------------------------------- /benchmark/bridge_test.js: -------------------------------------------------------------------------------- 1 | import http from 'k6/http'; 2 | import { Counter, Trend } from 'k6/metrics'; 3 | import { SharedArray } from 'k6/data'; 4 | import exec from 'k6/execution'; 5 | import sse from 'k6/x/sse'; 6 | import encoding from 'k6/encoding'; 7 | 8 | export let sse_message_received = new Counter('sse_message_received'); 9 | export let sse_message_sent = new Counter('sse_message_sent'); 10 | export let sse_errors = new Counter('sse_errors'); 11 | export let post_errors = new Counter('post_errors'); 12 | export let delivery_latency = new Trend('delivery_latency'); 13 | export let json_parse_errors = new Counter('json_parse_errors'); 14 | export let missing_timestamps = new Counter('missing_timestamps'); 15 | 16 | const PRE_ALLOCATED_VUS = 100 17 | const MAX_VUS = PRE_ALLOCATED_VUS * 25 18 | 19 | const BRIDGE_URL = __ENV.BRIDGE_URL || 'http://localhost:8081/bridge'; 20 | 21 | // 1 minutes ramp-up, 1 minutes steady, 1 minutes ramp-down 22 | const RAMP_UP = __ENV.RAMP_UP || '10s'; 23 | const HOLD = __ENV.HOLD || '10s'; 24 | const RAMP_DOWN = __ENV.RAMP_DOWN || '10s'; 25 | 26 | const SSE_VUS = Number(__ENV.SSE_VUS || 100); 27 | const SEND_RATE = Number(__ENV.SEND_RATE || 1000); 28 | 29 | // Generate valid hex client IDs that the bridge expects 30 | const ID_POOL = new SharedArray('ids', () => Array.from({length: 100}, (_, i) => { 31 | return i.toString(16).padStart(64, '0'); // 64-char hex strings 32 | })); 33 | 34 | 35 | 36 | export const options = { 37 | discardResponseBodies: true, 38 | systemTags: ['status', 'method', 'name', 'scenario'], // Exclude 'url' to prevent metrics explosion 39 | thresholds: { 40 | http_req_failed: ['rate<0.01'], 41 | delivery_latency: ['p(95)<2000'], 42 | sse_errors: ['count<10'], // SSE should be very stable 43 | json_parse_errors: ['count<5'], // Should rarely fail to parse 44 | missing_timestamps: ['count<100'], // Most messages should have timestamps 45 | sse_message_sent: ['count>5'], 46 | sse_message_received: ['count>5'], 47 | 48 | }, 49 | scenarios: { 50 | sse: { 51 | executor: 'ramping-vus', 52 | startVUs: 0, 53 | stages: [ 54 | { duration: RAMP_UP, target: SSE_VUS }, // warm-up 55 | { duration: HOLD, target: SSE_VUS }, // steady 56 | { duration: RAMP_DOWN, target: 0 }, // cool-down 57 | ], 58 | gracefulRampDown: '30s', 59 | exec: 'sseWorker' 60 | }, 61 | senders: { 62 | executor: 'ramping-arrival-rate', 63 | startRate: 0, 64 | timeUnit: '1s', 65 | preAllocatedVUs: PRE_ALLOCATED_VUS, 66 | maxVUs: MAX_VUS, 67 | stages: [ 68 | { duration: RAMP_UP, target: SEND_RATE }, // warm-up 69 | { duration: HOLD, target: SEND_RATE }, // steady 70 | { duration: RAMP_DOWN, target: 0 }, // cool-down 71 | ], 72 | gracefulStop: '30s', 73 | exec: 'messageSender' 74 | }, 75 | }, 76 | }; 77 | 78 | export function sseWorker() { 79 | // Use round-robin assignment for more predictable URLs 80 | const vuIndex = exec.vu.idInTest - 1; 81 | const groupId = Math.floor(vuIndex / 10); // 10 VUs per group 82 | // Use same IDs as sender 83 | const ids = [ 84 | ID_POOL[groupId * 3 % ID_POOL.length], 85 | ID_POOL[(groupId * 3 + 1) % ID_POOL.length], 86 | ID_POOL[(groupId * 3 + 2) % ID_POOL.length] 87 | ]; 88 | const url = `${BRIDGE_URL}/events?client_id=${ids.join(',')}`; 89 | 90 | // Keep reconnecting for the test duration 91 | for (;;) { 92 | try { 93 | sse.open(url, { 94 | headers: { Accept: 'text/event-stream' }, 95 | tags: { name: 'SSE /events' } 96 | }, (c) => { 97 | c.on('event', (ev) => { 98 | if (ev.data === 'heartbeat' || !ev.data || ev.data.trim() === '') { 99 | return; // Skip heartbeats and empty events 100 | } 101 | try { 102 | // Parse the SSE event data first 103 | const eventData = JSON.parse(ev.data); 104 | // Then decode the base64 message field 105 | const decoded = encoding.b64decode(eventData.message, 'std', 's'); 106 | const m = JSON.parse(decoded); 107 | if (m.ts) { 108 | const latency = Date.now() - m.ts; 109 | delivery_latency.add(latency); 110 | sse_message_received.add(1); 111 | } else { 112 | missing_timestamps.add(1); 113 | console.log('Message missing timestamp:', decoded); 114 | } 115 | } catch(e) { 116 | json_parse_errors.add(1); 117 | console.log('JSON parse error:', e, 'data:', ev.data); 118 | } 119 | }); 120 | c.on('error', (err) => { 121 | console.log('SSE error:', err); 122 | sse_errors.add(1); 123 | }); 124 | }); 125 | } catch (e) { 126 | console.log('SSE connection failed:', e); 127 | sse_errors.add(1); 128 | } 129 | } 130 | } 131 | 132 | export function messageSender() { 133 | // Use fixed client pairs to reduce URL variations 134 | const vuIndex = exec.vu.idInTest % ID_POOL.length; 135 | const to = ID_POOL[vuIndex]; 136 | const from = ID_POOL[(vuIndex + 1) % ID_POOL.length]; 137 | const topic = Math.random() < 0.5 ? 'sendTransaction' : 'signData'; 138 | const body = encoding.b64encode(JSON.stringify({ ts: Date.now(), data: 'test_message' })); 139 | const url = `${BRIDGE_URL}/message?client_id=${from}&to=${to}&ttl=300&topic=${topic}`; 140 | 141 | const r = http.post(url, body, { 142 | headers: { 'Content-Type': 'text/plain' }, 143 | timeout: '10s', 144 | tags: { name: 'POST /message' }, // Group all message requests 145 | }); 146 | if (r.status !== 200) { 147 | post_errors.add(1); 148 | } else { 149 | sse_message_sent.add(1); 150 | } 151 | } 152 | --------------------------------------------------------------------------------