Tallybook is settlement bookkeeping for services that charge per HTTP request — APIs paid by AI agents and other machines over Stellar, using the x402 protocol, MPP charge mode, or MPP payment channels.
The Soroban contracts Tallybook settles against — price_book,
statement_registry — are built, deployed, and owned by a separate
repository, tallybook-contracts. This repository consumes their
deployed, immutable interfaces (contract IDs supplied via
TB_PRICE_BOOK_ID / TB_STATEMENT_REGISTRY_ID) and restates none of
their logic: no contract code is vendored or forked here, and if an
interface here looks wrong, the fix belongs in that repository, not this
one. Tallybook also consumes a third, external contract it does not own
at all — see below.
This repository is everything off-chain: a metering collector, a settlement daemon, and a chain indexer, all backed by Postgres.
In MPP session mode, a payer deposits into a one-way payment channel and signs cumulative off-chain commitments as they consume an API. That revenue is a signature in the recipient's own database until it's submitted on chain — and once the channel's refund waiting period elapses, the payer can reclaim the entire remaining balance, including anything the recipient earned but never settled for. The contract reserves nothing for the recipient.
That means a lost commitment is lost revenue, and a missed settlement
deadline is lost revenue. The settler (internal/settle) is the
component that decides whether the operator actually gets paid, and its
design favors durability over throughput, and settling early over
settling optimally, throughout.
Three services, sharing one Postgres database:
- Collector (
cmd/collector) — an HTTP metering layer. Wraps a paid API, records every request, prices it against the operator's published schedule, and holds custody of payment-channel commitments (internal/httpapi,internal/meter,internal/custody). - Settler (
cmd/settler) — a daemon that watches open payment channels and sweeps earned revenue on chain before the payer can reclaim it (internal/settle). - Indexer (
cmd/indexer) — ingests Soroban events into Postgres so metered usage can be reconciled against what actually settled (internal/indexer).
Each owns its own slice of the schema and talks to Stellar through
internal/stellar, a thin Soroban JSON-RPC client and set of typed
contract bindings — there is no official Go SDK for Soroban RPC, so this
client is hand-rolled and verified against the live RPC surface rather
than assumed.
internal/
├── config/ # env loading, validation, secret redaction
├── stellar/ # Soroban RPC client, XDR helpers, contract bindings
├── merkle/ # usage-root tree builder, matching the contract byte for byte
├── meter/ # price schedules, request pricing, period lifecycle, anchoring
├── custody/ # commitment verification and persist-before-acknowledge storage
├── settle/ # exposure calculation, sweep policy, submission, retry, metrics
├── indexer/ # event ingestion, channel state tracking, reconciliation
├── store/ # migration runner and numbered .sql migrations
└── httpapi/ # the collector's metering middleware
cmd/
├── collector/ # HTTP metering layer entrypoint
├── settler/ # settlement daemon entrypoint
└── indexer/ # chain indexer entrypoint
Tallybook consumes three Soroban contracts; it does not modify, fork, or vendor any of them.
| Contract | Owner | Role |
|---|---|---|
price_book |
tallybook-contracts | Operators publish versioned price schedules here. |
statement_registry |
tallybook-contracts | Billing periods are anchored here as merkle-rooted statements, with on-chain dispute/resolution. |
one-way-channel |
stellar-experimental (third party) | External and unaudited. MPP session payment channels: cumulative signed commitments, settled or closed on chain, with a funder-reclaim path after a waiting period. |
Tallybook is not a payment facilitator, router, or client-side pay()
SDK, and it takes no protocol fee — see CLAUDE.md for the full
specification this codebase was built against, including every on-chain
interface in detail.
Prerequisites: Go (see go.mod for the minimum version), Docker, and a
Soroban RPC endpoint (testnet works out of the box).
git clone https://github.com/Tallybook-Org/tallybook.git
cd tallybook
cp .env.example .env # fill in TB_PRICE_BOOK_ID, TB_STATEMENT_REGISTRY_ID,
# TB_OPERATOR_ADDRESS, and an operator signing key
make up # Postgres only, on localhost:5433 — what the test suite needs
make check # gofmt, go vet, staticcheck, and the full test suitemake check runs the same steps CI does, in the same order — see
.github/workflows/ci.yml. The test suite includes integration tests
against a real Postgres instance (not mocks, §8), so make up needs to
have succeeded first.
docker-compose.yml defines all four containers — Postgres plus
collector, settler, and indexer — so the whole stack comes up with one
command once .env is filled in:
make up-all # docker compose up -d --build: postgres + all three services
make down-all # docker compose downEach service binary fails fast at startup, naming whichever required
TB_* variable is still missing (§7 of CLAUDE.md; internal/config),
so an incomplete .env shows up immediately in docker compose logs
rather than as a silent misconfiguration. make up (Postgres alone,
without --build) is what local development and the test suite use day
to day; make up-all is for running the real binaries.
Every environment variable is documented in .env.example and validated
at startup — a missing or malformed one fails fast with a clear message
naming it.
| Variable | Example | Notes |
|---|---|---|
TB_DATABASE_URL |
postgres://... |
Required |
TB_STELLAR_RPC_URL |
https://soroban-testnet.stellar.org |
Required |
TB_NETWORK_PASSPHRASE |
Test SDF Network ; September 2015 |
Required |
TB_PRICE_BOOK_ID |
CB2IEP... |
From tallybook-contracts' README |
TB_STATEMENT_REGISTRY_ID |
CB75TT... |
From tallybook-contracts' README |
TB_OPERATOR_ADDRESS |
G... |
Required |
TB_OPERATOR_SECRET_SOURCE |
env | file |
How the signing key is obtained |
TB_OPERATOR_SECRET |
S... |
Only when source is env. Never logged, never committed |
TB_OPERATOR_SECRET_PATH |
/run/secrets/operator.key |
Only when source is file |
TB_SAFETY_MARGIN_LEDGERS |
1440 |
Settler deadline margin |
TB_MAX_EXPOSURE |
10000000 |
Stroops; triggers a sweep |
TB_MAX_EXPOSURE_AGE |
24h |
Duration |
TB_PERIOD_DURATION |
720h |
How long a billing period may stay open before it's closed on calendar grounds alone, independent of any price change |
TB_SETTLER_TICK |
30s |
Settler poll interval |
TB_INDEXER_START_LEDGER |
4590000 |
Where ingestion begins |
TB_COLLECTOR_ADDR |
:8080 |
Collector listen address |
TB_LOG_LEVEL |
info |
Secrets never appear in logs, error messages, metrics labels, or commits
— enforced by a redacting Secret type in internal/config and a test
asserting it.
- Channel discovery is filtered by topic, network-wide.
one-way-channelis deployed once per channel (its own factory contract), and nothing inCLAUDE.md's env vars names that factory's address — sointernal/indexercan't scope itsgetEventsfilter to "channels this factory created" the way it scopesprice_bookandstatement_registryevents to their own known contract IDs. Channel lifecycle events are filtered by topic only, across the whole network, which is the only way to discover a channel never seen before. This is narrowed one step: a discovered channel is only ever tracked if its recipient matchesTB_OPERATOR_ADDRESS(internal/indexer/channels.go), so a channel paying out to some other operator is discarded — but the underlying topic-only exposure remains, and an unrelated contract emitting the same bare-symbol topic shape would still be evaluated (and then discarded, once its recipient doesn't match). Seeinternal/indexer/ingest.go's package doc comment for the full reasoning. - The settler doesn't estimate a wall-clock refund deadline.
refund_deadline_ledgeris a ledger number; converting it to a wall-clock countdown for retry pacing would need verified, live ledger-close-time data this codebase doesn't have —CLAUDE.mditself warns against trusting that arithmetic unverified (§6). Instead,internal/settle.Daemonre-evaluates deadline pressure fresh every tick and tightens its own polling interval under pressure — seeinternal/settle/daemon.go. cmd/collectormounts no protected route. Tallybook meters "a paid API" generically; there is no config naming a specific upstream to reverse-proxy. The collector binary starts up, migrates the schema, and exposes what an operator's own route registration needs (httpapi.Middlewareand its dependencies) — wiring an actual route table is deployment-specific.
- Operator runbook — operational notes for deploying and running the on-chain and off-chain pieces, including known hazards worth knowing before you hit them yourself.
- Contributing
- Security policy
CLAUDE.md— the full specification this codebase implements.