Fundable Indexer is a Bun/Turborepo workspace that reads Soroban contract events, turns them into typed application data, and stores the result in PostgreSQL.
This repository is intentionally split into a small number of clear layers so a new contributor can follow the path from smart contract event to database row without guessing.
If you only remember one line, remember this:
Soroban contract event -> indexer -> Postgres -> API -> client
The indexer watches the Fundable Soroban contracts and persists:
- stream identity records
- deposit activity
- withdrawal activity
- lifecycle events for Flow, Lockup, and Stream NFT contracts
- cursor state so polling can resume safely
- indexed-event state so duplicate events are ignored safely
The current runtime is the streams package. The current read layer is the api package. distributions is reserved for later.
Here is the full path in simple terms:
- A Soroban contract emits an event.
- The indexer asks Soroban RPC for that event.
- The indexer checks whether it already processed it.
- If it is new, a handler turns it into a database row.
- PostgreSQL stores the row.
- The API reads that stored row back.
- Your app or client asks the API for the data.
That is the whole system.
common is shared infrastructure. It should stay reusable and should not contain stream-specific business logic.
It currently owns:
- environment config loading
- PostgreSQL pool creation
- cursor repository and storage
- indexed-event repository and storage
- shared Drizzle schema
If a piece of code is needed by more than one indexer domain, it belongs here.
streams is the payment-stream indexer.
It currently owns:
- event catalog and event shapes
- Soroban RPC adapter
- Stellar SDK RPC adapter
- batch runner
- runtime wiring
- domain handlers
- domain storage
- executable entrypoint
If a piece of code only makes sense for the stream contracts, it belongs here.
api is the read layer.
It currently owns:
- Apollo server setup
- GraphQL schema and resolvers
- HTTP startup for the API process
It reads rows from Postgres and returns them through GraphQL.
The API starts at v1.
The queries exposed under v1 are stream(id), streams, flowDeposits, flowWithdrawals, flowAdjustments, flowEvents, lockupEvents, and nftTransfers.
The stream read model now includes the canonical snapshot fields:
streamTypestartTimeendTimestartTimeReadableendTimeReadablestatusremainingAmountsnapshotTimesnapshotTimeReadablepayloadRawpayloadReadable
distributions is reserved for the later distribution contract indexer. It stays separate so the stream code does not become a catch-all.
config/- loads and validates environment variables.cursor/- persists indexer cursor state.db/- schema, pool, and migrations.events/- persists indexed-event identities.
events/- contract event catalog and event metadata.indexer/- RPC adapter, SDK client, runner, and shared indexer types.app/- composes config, DB, source, storage, and handlers.runtime/- boots the app from runtime dependencies.main.ts- executable entrypoint.handlers/- event-to-storage mapping logic.streamRecords/- stream identity persistence.deposits/- Flow deposit persistence.withdrawals/- Flow withdrawal persistence.adjustments/- Flow adjustment persistence.flowEvents/- Flow lifecycle persistence.lockupEvents/- Lockup lifecycle persistence.transfers/- Stream NFT transfer persistence.api/- GraphQL/Apollo read layer.
Pull requests run the repository checks in GitHub Actions:
bun run lintbun run type-checkbun run test
Deployment details are intentionally kept out of this public README.
This event creates the base stream record.
Flow:
- contract emits
flow_created - event source normalizes the event
createFlowCreatedHandlerparses the payloadcreatePostgresStreamStorageinserts the stream row
Stored data:
- stream id
- sender
- recipient
- token
- rate per second
- snapshot time
- canonical stream snapshot fields
The stream row is the shared read model for both Flow and Lockup creation now. It carries the current snapshot state alongside the raw and readable payload forms.
This event records a deposit into an existing Flow stream.
Flow:
- contract emits
flow_deposit - event source normalizes the event
createFlowDepositHandlerparses the payloadcreatePostgresFlowDepositStorageinserts the deposit row
Stored data:
- stream id
- funder
- amount
- ledger number
- transaction hash
- event index
This event records a withdrawal from a Flow stream.
Flow:
- contract emits
flow_withdraw - event source normalizes the event
createFlowWithdrawHandlerparses the payloadcreatePostgresFlowWithdrawStorageinserts the withdrawal row
Stored data:
- stream id
- recipient address
- caller address
- amount
- ledger number
- transaction hash
- event index
This event records a rate change on an existing Flow stream.
Flow:
- contract emits
flow_adjusted - event source normalizes the event
createFlowAdjustedHandlerparses the payloadcreatePostgresFlowAdjustmentStorageinserts the adjustment row- the same event also continues to be written into
flow_eventsfor the lifecycle timeline
Stored data:
- stream id
- total debt
- old rate
- new rate
- payload JSON
- payload readable JSON
- ledger number
- transaction hash
- event index
These are stored as Flow lifecycle events.
They use the same pattern:
- event name
- stream id
- payload stored as structured JSON text
- readable payload JSON
- ledger / transaction / event identity
These are stored as Lockup lifecycle events.
They follow the same model as Flow lifecycle events.
The lockup creation event also writes the canonical streams row, including:
streamTypestartTimeendTimestartTimeReadableendTimeReadablestatusremainingAmountsnapshotTimesnapshotTimeReadablepayloadRawpayloadReadable
This is the Stream NFT transfer event.
It records ownership movement for the stream NFT that represents the stream recipient rights.
The streams table is now the canonical snapshot for both Flow and Lockup creation events.
It stores:
- the stream identity
- the contract that created it
- the sender, recipient, and token
- the stream type
- the current status
- the current remaining amount
- the chain timestamps as raw Unix values and readable ISO strings
- the raw contract payload and a readable JSON payload
That makes the stream row fast to read for the frontend without losing the original event data.
The api package does not invent new data.
It reads what the indexer already saved in PostgreSQL.
Today it exposes:
v1.stream(id)- returns one stream rowv1.streams- returns all stream rows ordered by creation timev1.flowDeposits(streamId: ...)- returns stream deposit historyv1.flowWithdrawals(streamId: ...)- returns stream withdrawal historyv1.flowAdjustments(streamId: ...)- returns Flow rate-change historyv1.flowEvents(streamId: ...)- returns Flow lifecycle historyv1.lockupEvents(streamId: ...)- returns Lockup lifecycle historyv1.nftTransfers(contractId: ..., tokenId: ...)- returns NFT transfer history
The Stream GraphQL type includes the readable time fields as well as the raw timestamps.
The pattern is always the same:
- Add a GraphQL field.
- Read the matching row or rows from Postgres.
- Convert the database row into the API shape.
- Cover it with a test.
New API work should go under v1.
The runtime starts with common/src/config.
It reads environment values such as:
DATABASE_URLSOROBAN_RPC_URLSTELLAR_NETWORK_PASSPHRASE- contract IDs for Flow, Lockup, and Stream NFT
common/src/db creates the PostgreSQL pool and exposes the shared schema.
The shared schema includes:
indexer_cursorsindexed_eventsstreamsflow_adjustmentsflow_depositsflow_withdrawalsflow_eventslockup_eventsnft_transfers
streams/src/indexer/sorobanEventSource.ts converts the configured event catalog into Soroban RPC requests.
That file is the bridge between:
- your contract event names
- Soroban RPC response format
- the internal
StreamIndexerEventtype
streams/src/indexer/runner.ts is the polling loop.
It:
- reads or creates the cursor
- fetches the next batch of events
- skips events already recorded in
indexed_events - looks up the handler by event name
- runs the handler
- records the event as indexed
- advances the cursor after success
Handlers are small functions that translate one event into one domain write.
Examples:
flow_created->streamsflow_deposit->flow_depositsflow_withdraw->flow_withdrawals- lifecycle events ->
flow_eventsorlockup_events - NFT
transfer->nft_transfers
Each domain has a Postgres storage adapter.
The pattern is the same everywhere:
findById(id)reads a rowinsert(input)writes a row- if the insert conflicts, the storage reloads the existing row
That gives idempotent writes.
The code is split this way on purpose.
The reason is simple:
- easier to find code
- easier to test one behavior at a time
- less risk of turning
commoninto a junk drawer - cleaner separation between infrastructure and business rules
If you are looking for something:
- DB connection logic ->
common/src/db - cursor behavior ->
common/src/cursor - event dedupe ->
common/src/events - event matching ->
streams/src/events - event fetching ->
streams/src/indexer - event-to-row translation ->
streams/src/handlers - stream persistence ->
streams/src/streamRecords - Flow deposit logic ->
streams/src/deposits - Flow withdrawal logic ->
streams/src/withdrawals - lifecycle event persistence ->
streams/src/flowEventsorstreams/src/lockupEvents - NFT transfer persistence ->
streams/src/transfers - API read layer ->
api/src
Use the same pattern every time:
- Add the event name and topic shape to
streams/src/events/catalog.ts. - Add a failing test in the module-local
tests/folder. - Add the storage types in
types.ts. - Add the Postgres storage adapter.
- Add the handler that maps the raw event to the storage input.
- Export the new pieces from the package entrypoint.
- Wire the handler into
streams/src/handlers/index.ts. - Update this README and
docs/stream-indexer.md.
That sequence keeps the codebase consistent and easy to extend.
- Types belong in
types.tsfiles. - Tests belong in local
tests/folders next to the code they cover. - Shared infrastructure belongs in
common. - Domain-specific logic belongs in the package that owns it.
- Use the existing patterns before inventing a new one.
Install dependencies:
bun installRun checks:
bun run type-check
bun run test
bun run lintRun migrations:
bun run db:generate
bun run db:migrateStart the stream indexer:
bun run devThe detailed architecture guide lives in:
That document explains the implementation flow in more detail. This README is the quickest entry point.