NATS-Forohtoo: Payment Invite Required
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:
- 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!).
- Historical data: You need long-term storage for analytics, but also real-time streaming for immediate verification.
- Workflow integration: Blocking a workflow until payment arrives requires careful coordination between polling, storage, and client notification.
- 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:
Comments
Loading comments...