Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.git
.github
.turbo
**/node_modules
coverage
dist
*.log
.DS_Store
8 changes: 4 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ START_LEDGER=
# Contracts
# Keep real production values out of commits unless they are intended to be
# public configuration.
FLOW_CONTRACT_ID=
LOCKUP_CONTRACT_ID=
STREAM_NFT_CONTRACT_ID=
ROUTER_CONTRACT_ID=
FLOW_CONTRACT_ID=CB7W5RFUINUCDAIR2AFY7EIZ66EYIPVKD4CQCQGUGY6GKTYAGYCA2M7C
LOCKUP_CONTRACT_ID=CBQC5JBGUUOU74BIT67QOAEDIEUDFN3KIVNKIOHAJ5XBXX6HFG7M5FMB
STREAM_NFT_CONTRACT_ID=CA6YXNZ2IDFZVO4VXINT2E4R7UNAMYM6INH333VLVAK6P3HY5O3T4CRT
ROUTER_CONTRACT_ID=CDQRBMV73XXTDBIEMMA7U4ZSQ42DYSKLE7HE4QJIOMFJA5ZEYMLCJYKK
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: CI

on:
pull_request:
push:
branches:
- staging
- main

jobs:
verify:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.8

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Lint
run: bun run lint

- name: Type check
run: bun run type-check

- name: Test
run: bun run test
29 changes: 29 additions & 0 deletions .github/workflows/deploy-staging.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Deploy Staging

on:
push:
branches:
- staging

permissions:
contents: read

jobs:
deploy:
runs-on: ubuntu-latest

steps:
- name: Load SSH key
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.STAGING_VPS_SSH_KEY }}

- name: Add VPS host key
run: |
mkdir -p ~/.ssh
ssh-keyscan -p "${{ secrets.STAGING_VPS_PORT || '22' }}" "${{ secrets.STAGING_VPS_HOST }}" >> ~/.ssh/known_hosts

- name: Deploy
run: |
ssh -p "${{ secrets.STAGING_VPS_PORT || '22' }}" "${{ secrets.STAGING_VPS_USER }}@${{ secrets.STAGING_VPS_HOST }}" \
"cd \"${{ secrets.STAGING_DEPLOY_PATH || '/opt/fundable-indexer-staging' }}\" && APP_ROOT=\"${{ secrets.STAGING_DEPLOY_PATH || '/opt/fundable-indexer-staging' }}\" DEPLOY_ENV=staging DEPLOY_BRANCH=staging ENV_FILE=deploy/.env.staging ./deploy/deploy.sh"
29 changes: 29 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Deploy

on:
push:
branches:
- main

permissions:
contents: read

jobs:
deploy:
runs-on: ubuntu-latest

steps:
- name: Load SSH key
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.PROD_VPS_SSH_KEY }}

- name: Add VPS host key
run: |
mkdir -p ~/.ssh
ssh-keyscan -p "${{ secrets.PROD_VPS_PORT || '22' }}" "${{ secrets.PROD_VPS_HOST }}" >> ~/.ssh/known_hosts

- name: Deploy
run: |
ssh -p "${{ secrets.PROD_VPS_PORT || '22' }}" "${{ secrets.PROD_VPS_USER }}@${{ secrets.PROD_VPS_HOST }}" \
"cd \"${{ secrets.PROD_DEPLOY_PATH || '/opt/fundable-indexer' }}\" && APP_ROOT=\"${{ secrets.PROD_DEPLOY_PATH || '/opt/fundable-indexer' }}\" DEPLOY_ENV=production DEPLOY_BRANCH=main ENV_FILE=deploy/.env.production ./deploy/deploy.sh"
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,9 @@ coverage
.turbo
*.tsbuildinfo
.env
deploy/.env
deploy/.env.staging
deploy/.env.production
!deploy/.env.example
indexer-spec.md
docs/
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,20 @@ 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:

- `streamType`
- `startTime`
- `endTime`
- `startTimeReadable`
- `endTimeReadable`
- `status`
- `remainingAmount`
- `snapshotTime`
- `snapshotTimeReadable`
- `payloadRaw`
- `payloadReadable`

### `distributions`

`distributions` is reserved for the later distribution contract indexer. It stays separate so the stream code does not become a catch-all.
Expand Down Expand Up @@ -114,6 +128,16 @@ The queries exposed under `v1` are `stream(id)`, `streams`, `flowDeposits`, `flo
- `transfers/` - Stream NFT transfer persistence.
- `api/` - GraphQL/Apollo read layer.

## CI

Pull requests run the repository checks in GitHub Actions:

- `bun run lint`
- `bun run type-check`
- `bun run test`

Deployment details are intentionally kept out of this public README.

## Current Implemented Data Paths

### `flow_created`
Expand All @@ -135,6 +159,9 @@ Stored data:
- 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.

### `flow_deposit`

Expand Down Expand Up @@ -196,6 +223,7 @@ Stored data:
- old rate
- new rate
- payload JSON
- payload readable JSON
- ledger number
- transaction hash
- event index
Expand All @@ -209,6 +237,7 @@ They use the same pattern:
- event name
- stream id
- payload stored as structured JSON text
- readable payload JSON
- ledger / transaction / event identity

### `lockup_created`, `lockup_withdraw`, `lockup_canceled`, `lockup_renounced`
Expand All @@ -217,12 +246,43 @@ 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:

- `streamType`
- `startTime`
- `endTime`
- `startTimeReadable`
- `endTimeReadable`
- `status`
- `remainingAmount`
- `snapshotTime`
- `snapshotTimeReadable`
- `payloadRaw`
- `payloadReadable`

### `transfer`

This is the Stream NFT transfer event.

It records ownership movement for the stream NFT that represents the stream recipient rights.

## Canonical Stream Snapshot

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.

## How The API Layer Fits In

The `api` package does not invent new data.
Expand All @@ -239,6 +299,8 @@ Today it exposes:
- `v1.lockupEvents(streamId: ...)` - returns Lockup lifecycle history
- `v1.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:

1. Add a GraphQL field.
Expand Down
11 changes: 11 additions & 0 deletions api/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
FROM oven/bun:1.3.8

WORKDIR /app

COPY . .

RUN bun install --frozen-lockfile

WORKDIR /app/api

CMD ["bun", "run", "start"]
1 change: 1 addition & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"codegen": "bun --print \"'api: no codegen configured yet'\"",
"dev": "bun --watch src/main.ts",
"lint": "biome check .",
"start": "bun src/main.ts",
"test": "vitest run src",
"type-check": "tsc -p tsconfig.json --noEmit"
},
Expand Down
6 changes: 4 additions & 2 deletions api/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { fileURLToPath } from "node:url";
import { startStandaloneServer } from "@apollo/server/standalone";
import { createDatabasePool, loadConfig } from "@fundable-indexer/common";

import { createDatabasePool, loadConfig, loadRuntimeEnv } from "@fundable-indexer/common";

import { createStreamGraphqlServer } from "./server.js";

async function main(): Promise<void> {
const config = loadConfig();
const config = loadConfig(loadRuntimeEnv(fileURLToPath(new URL(".", import.meta.url))));
const pool = createDatabasePool(config);
const server = createStreamGraphqlServer({ sqlClient: pool });

Expand Down
5 changes: 3 additions & 2 deletions api/src/resolvers/flows/adjustments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ async function findFlowAdjustments(
options: FlowAdjustmentListQueryOptions,
): Promise<FlowAdjustmentRow[]> {
const parts = [
"select id, contract_id, stream_id, total_debt, old_rate, new_rate, payload, ledger_number, transaction_hash, event_index, created_at from flow_adjustments",
"select id, contract_id, stream_id, total_debt, old_rate, new_rate, payload, payload_readable, ledger_number, transaction_hash, event_index, created_at from flow_adjustments",
];
const values: unknown[] = [options.streamId];

Expand Down Expand Up @@ -62,7 +62,7 @@ async function findFlowAdjustmentsConnection(
): Promise<FlowAdjustmentRow[]> {
return queryCursorRows(
client,
"select id, contract_id, stream_id, total_debt, old_rate, new_rate, payload, ledger_number, transaction_hash, event_index, created_at from flow_adjustments",
"select id, contract_id, stream_id, total_debt, old_rate, new_rate, payload, payload_readable, ledger_number, transaction_hash, event_index, created_at from flow_adjustments",
options,
["stream_id = ?"],
[options.streamId],
Expand All @@ -78,6 +78,7 @@ function mapFlowAdjustmentRow(row: FlowAdjustmentRow) {
oldRate: row.old_rate,
newRate: row.new_rate,
payload: row.payload,
payloadReadable: row.payload_readable,
ledgerNumber: row.ledger_number.toString(),
transactionHash: row.transaction_hash,
eventIndex: row.event_index,
Expand Down
5 changes: 3 additions & 2 deletions api/src/resolvers/flows/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ async function findFlowEvents(
options: ActivityListQueryOptions,
): Promise<FlowEventRow[]> {
const parts = [
"select id, contract_id, stream_id, event_name, payload, ledger_number, transaction_hash, event_index, created_at from flow_events",
"select id, contract_id, stream_id, event_name, payload, payload_readable, ledger_number, transaction_hash, event_index, created_at from flow_events",
];
const values: unknown[] = [options.streamId];

Expand Down Expand Up @@ -62,7 +62,7 @@ async function findFlowEventsConnection(
): Promise<FlowEventRow[]> {
return queryCursorRows(
client,
"select id, contract_id, stream_id, event_name, payload, ledger_number, transaction_hash, event_index, created_at from flow_events",
"select id, contract_id, stream_id, event_name, payload, payload_readable, ledger_number, transaction_hash, event_index, created_at from flow_events",
options,
["stream_id = ?"],
[options.streamId],
Expand All @@ -76,6 +76,7 @@ function mapFlowEventRow(row: FlowEventRow) {
streamId: row.stream_id.toString(),
eventName: row.event_name,
payload: row.payload,
payloadReadable: row.payload_readable,
ledgerNumber: row.ledger_number.toString(),
transactionHash: row.transaction_hash,
eventIndex: row.event_index,
Expand Down
37 changes: 37 additions & 0 deletions api/src/resolvers/jsonScalar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { GraphQLError, GraphQLScalarType, Kind, type ValueNode } from "graphql";

export const jsonScalar = new GraphQLScalarType({
name: "JSON",
description: "Arbitrary JSON value",
serialize(value) {
return value;
},
parseValue(value) {
return value;
},
parseLiteral(ast) {
return parseJsonLiteral(ast);
},
});

function parseJsonLiteral(ast: ValueNode): unknown {
switch (ast.kind) {
case Kind.STRING:
case Kind.BOOLEAN:
return ast.value;
case Kind.INT:
case Kind.FLOAT:
return Number(ast.value);
case Kind.NULL:
return null;
case Kind.LIST:
return ast.values.map(parseJsonLiteral);
case Kind.OBJECT:
return Object.fromEntries(
ast.fields.map((field) => [field.name.value, parseJsonLiteral(field.value)]),
);
case Kind.ENUM:
case Kind.VARIABLE:
throw new GraphQLError(`JSON literal cannot represent ${ast.kind}`);
}
}
5 changes: 3 additions & 2 deletions api/src/resolvers/lockups/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ async function findLockupEvents(
options: ActivityListQueryOptions,
): Promise<LockupEventRow[]> {
const parts = [
"select id, contract_id, stream_id, event_name, payload, ledger_number, transaction_hash, event_index, created_at from lockup_events",
"select id, contract_id, stream_id, event_name, payload, payload_readable, ledger_number, transaction_hash, event_index, created_at from lockup_events",
];
const values: unknown[] = [options.streamId];

Expand Down Expand Up @@ -53,7 +53,7 @@ async function findLockupEventsConnection(
): Promise<LockupEventRow[]> {
return queryCursorRows(
client,
"select id, contract_id, stream_id, event_name, payload, ledger_number, transaction_hash, event_index, created_at from lockup_events",
"select id, contract_id, stream_id, event_name, payload, payload_readable, ledger_number, transaction_hash, event_index, created_at from lockup_events",
options,
["stream_id = ?"],
[options.streamId],
Expand All @@ -67,6 +67,7 @@ function mapLockupEventRow(row: LockupEventRow) {
streamId: row.stream_id.toString(),
eventName: row.event_name,
payload: row.payload,
payloadReadable: row.payload_readable,
ledgerNumber: row.ledger_number.toString(),
transactionHash: row.transaction_hash,
eventIndex: row.event_index,
Expand Down
Loading
Loading