├── 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 |
--------------------------------------------------------------------------------