Building a Real-Time Payment Gateway for Solana with Go

This post is about a service I’ve just implemented called forohtoo (read: 402), a Go-based service for awaiting Solana transactions and integrating payment verification into workflows. The most useful part is the client library that provides a helpful Await method that can be used to block until a payment arrives.

Think of it like this: you have a business process (i.e., workflow) that requires payment before it can proceed, so you show your client a QR code with a prefilled Solana transaction that they have to send before you’ll process their business request. You could poll your Solana wallet for the transaction with the appropriate amount and memo data, but that’s error prone and not scalable at all. Instead, this service provides an easy way to stream your wallet’s incoming transactions and then apply custom validation logic to determine if a payment has arrived. It’s a simple pattern that can be used in a number of different situations, and you don’t have to worry about polling or rate limits at all. Sound cool? Let’s dive in!

The Problem

If you’re building an application that needs to verify Solana payments, you face a few challenges:

  1. Polling overhead: Each service that needs payment data must poll Solana RPC nodes, hitting rate limits and complicating infrastructure, not to mention network errors, retries, and backoffs (oh my!).
  2. Historical data: You need long-term storage for analytics, but also real-time streaming for immediate verification.
  3. Workflow integration: Blocking a workflow until payment arrives requires careful coordination between polling, storage, and client notification.
  4. Client complexity: Most message queue solutions require clients to run specialized libraries and manage persistent connections.

Implementing this for every service that requires payment verification is a colossal pain, so I figured I’d build a service that would handle it for me and be done with it.

The Architecture

Here’s how forohtoo solves these problems:

graph TB
    subgraph "Backend Service"
        Worker[Temporal Worker<br/>Polls Solana RPC]
        DB[(TimescaleDB<br/>Long-term Storage)]
        NATS[NATS JetStream<br/>Internal Broker]
        Server[HTTP Server<br/>REST + SSE]

        Worker -->|Write| DB
        Worker -->|Publish| NATS
        NATS -.->|Internal| Server
    end

    subgraph "Clients"
        Browser[Web Browser]
        CLI[CLI Tool]
        App[Go Application]
    end

    Server -->|SSE| Browser
    Server -->|SSE| CLI
    Server -->|SSE| App

    style NATS fill:#f9f,stroke:#333,stroke-width:2px,stroke-dasharray: 5 5
    style Server fill:#9f9,stroke:#333,stroke-width:2px

Key Design Decisions

1. NATS is Internal

NATS JetStream serves as the internal message broker between components, but clients never connect to it directly. This simplifies security (no exposed message broker), eliminates client library requirements, and centralizes authentication at the HTTP layer. Yeah this could be any message broker, but I chose NATS because it’s cool (despite the recent CNFC boondoggle).

2. Server-Sent Events for Clients

Instead of requiring clients to connect to NATS, the HTTP server subscribes to NATS internally and forwards events to clients via Server-Sent Events (SSE). Wow, SSE can go really far! Lot’s of complexity avoided here by dodging WebSocket. This gives us:

  • Standard browser API (EventSource)
  • Works through proxies and load balancers
  • Automatic reconnection
  • Simple authentication (HTTP headers/cookies)
  • No specialized client libraries

3. Dual Storage Strategy

graph LR
    Solana[Solana RPC] -->|Poll| Worker[Worker]
    Worker -->|Write| DB[(TimescaleDB)]
    Worker -->|Publish| JS[JetStream]

    DB -.->|Months/Years| Analytics[Analytics Queries]
    JS -.->|Days/Weeks| Clients[Real-time Clients]

    style DB fill:#bbf,stroke:#333
    style JS fill:#bfb,stroke:#333

TimescaleDB provides long-term retention for analytics, aggregations, and audit trails. JetStream keeps recent transactions (typically not more than a month) for real-time delivery and catch-up after disconnection. This separation lets us optimize each store for its use case. Could I have used LISTEN/NOTIFY instead of JetStream? Sure, but I wanted to have NATS in the title of the post!

Implementation Patterns

Explicit Dependencies (go-kit Style)

Following the go-kit philosophy, all dependencies are explicit. No globals, no singletons, no magic.

type SSEPublisher struct {
    nc     *nats.Conn
    js     jetstream.JetStream
    logger *slog.Logger
}

func NewSSEPublisher(natsURL string, logger *slog.Logger) (*SSEPublisher, error) {
    nc, err := nats.Connect(natsURL, /* options */)
    if err != nil {
        return nil, fmt.Errorf("failed to connect: %w", err)
    }

    js, err := jetstream.New(nc)
    if err != nil {
        nc.Close()
        return nil, fmt.Errorf("failed to create JetStream: %w", err)
    }

    return &SSEPublisher{nc: nc, js: js, logger: logger}, nil
}

Reading the struct definition tells you exactly what this component depends on. No surprises, no hidden state.

Handler Functions Over Frameworks

Following Mat Ryer’s pattern, handlers are functions that return http.Handler, with dependencies passed as parameters:

func handleStreamTransactions(publisher *SSEPublisher, logger *slog.Logger) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Handler has everything it needs in closure
        address := r.PathValue("address")

        // Set SSE headers
        w.Header().Set("Content-Type", "text/event-stream")
        w.Header().Set("Cache-Control", "no-cache")
        w.Header().Set("Connection", "keep-alive")

        // Create ephemeral JetStream consumer
        cons, err := publisher.js.CreateOrUpdateConsumer(r.Context(),
            streamName,
            jetstream.ConsumerConfig{
                FilterSubject: fmt.Sprintf("txns.%s", address),
                AckPolicy:     jetstream.AckExplicitPolicy,
                DeliverPolicy: jetstream.DeliverNewPolicy,
            })

        // Stream events to client...
    })
}

This keeps dependencies explicit, makes testing easy (just call the function and test the handler), and avoids framework magic.

The Await Pattern for Payment Gating

The core feature is client.Await(), which blocks until a transaction matching custom criteria arrives:

// Block until payment arrives
txn, err := client.Await(ctx, walletAddress, func(txn *client.Transaction) bool {
    // Custom matching logic - any criteria you want
    return strings.Contains(txn.Memo, workflowID)
})

This connects to the SSE stream, calls your matcher function on every transaction, and returns when a match is found. It’s particularly useful in Temporal workflows:

sequenceDiagram
    participant User
    participant Workflow
    participant Activity
    participant SSE
    participant NATS

    User->>Workflow: Start order workflow
    Workflow->>User: Display payment instructions
    Workflow->>Activity: Wait for payment
    Activity->>SSE: Connect & await transaction

    loop Poll Solana
        NATS->>SSE: New transaction
        SSE->>Activity: Check matcher
        Activity->>Activity: No match, keep waiting
    end

    NATS->>SSE: Matching transaction!
    SSE->>Activity: Match found
    Activity->>Workflow: Payment confirmed
    Workflow->>User: Process order

The matcher function can implement any logic: minimum amount, specific signature, memo content, token type, etc. Client-side filtering gives you complete control. In my case, the “client side” will typically be a Temporal workflow managing the business logic for one of my other services (e.g., IncentivizeThis).

Type-Safe SQL with sqlc

Rather than using an ORM, we use sqlc to generate type-safe Go code from SQL:

-- queries.sql
-- name: InsertTransaction :one
INSERT INTO transactions (
    wallet_address, signature, slot, amount, token_mint, memo,
    block_time, confirmation_status
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;

-- name: GetTransactionsByWallet :many
SELECT * FROM transactions
WHERE wallet_address = $1
ORDER BY block_time DESC
LIMIT $2;

Running sqlc generate produces:

func (q *Queries) InsertTransaction(ctx context.Context, arg InsertTransactionParams) (Transaction, error)
func (q *Queries) GetTransactionsByWallet(ctx context.Context, walletAddress string, limit int32) ([]Transaction, error)

Benefits:

  • Compile-time SQL validation
  • No reflection overhead
  • Direct SQL execution
  • Generated code is readable and debuggable
  • Full PostgreSQL/TimescaleDB feature support

Temporal Integration

The system uses Temporal for scheduling wallet polls:

graph LR
    Server[HTTP Server] -->|Create Schedule| Temporal[Temporal]
    Temporal -->|Trigger| Worker[Worker]
    Worker -->|Poll RPC| Solana[Solana]
    Worker -->|Write| DB[(TimescaleDB)]
    Worker -->|Publish| NATS[NATS]

When you register a wallet via POST /api/v1/wallets, the server creates a Temporal schedule that triggers the worker at specified intervals. The worker is just a Temporal worker executing poll activities.

This gives us:

  • Reliable scheduling: Temporal handles retries, failure detection, and exactly-once execution
  • Scalable polling: Add more workers to handle more wallets
  • Visibility: Temporal UI shows schedule status, execution history, and errors
  • Easy operations: Pause/resume schedules via Temporal API

Testing Strategy

Unit Tests: Fast, no external dependencies, use mocks for HTTP handlers and JetStream consumers.

Integration Tests: Require Docker services (Postgres, NATS, Temporal), test full end-to-end flows.

Smoke Tests: Verify the entire pipeline works in production

Local Development with Air

For rapid iteration, we use Air for hot reloading:

[build]
  cmd = "go build -o ./tmp/server cmd/server/main.go"
  bin = "./tmp/server"
  include_ext = ["go", "html"]
  exclude_dir = ["tmp", "vendor"]

Combined with tmux, you can have server, worker, and logs running in split panes with automatic reload on file changes.

Wrapping Up

Building forohtoo has been an exercise in applying Go best practices: explicit dependencies, simple interfaces, avoiding frameworks, and composable tools. The implementations here—SSE for streaming, sqlc for type-safe SQL, handler functions, and Temporal for scheduling—are patterns that I’ve been working on over the years and it’s really cool to see them come together in a useful service like this.


Links: