Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
27 changes: 15 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ this policy.

## Routes

| Path | Page |
|---|---|
| `/` | Landing — live oracle status, recent payouts, protocol stats |
| `/cover` | Policy purchase flow |
| `/provide` | LP capital provision + pool allocation |
| Path | Page |
| ---------- | ------------------------------------------------------------ |
| `/` | Landing — live oracle status, recent payouts, protocol stats |
| `/cover` | Policy purchase flow |
| `/provide` | LP capital provision + pool allocation |

## Quick start

Expand All @@ -49,16 +49,19 @@ Other environment variables:
used to filter the Dashboard's Transaction History (read client-side from public Horizon
`/accounts/{address}/operations`, paged by cursor, retried with backoff on 429/5xx). When
unset, every Soroban contract call by the wallet is listed.
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` — WalletConnect Cloud project ID used by the
WalletConnect v2 mobile-wallet adapter. Desktop users receive a QR code; mobile users
receive a registered-wallet deep link. The UI reports a configuration error when unset.

## Scripts

| Command | Purpose |
|---|---|
| `npm run dev` | Dev server with HMR |
| `npm run build` | Production build |
| `npm start` | Serve the production build |
| `npm run lint` | `next lint` |
| `npm run typecheck` | `tsc --noEmit` |
| Command | Purpose |
| ------------------- | -------------------------- |
| `npm run dev` | Dev server with HMR |
| `npm run build` | Production build |
| `npm start` | Serve the production build |
| `npm run lint` | `next lint` |
| `npm run typecheck` | `tsc --noEmit` |

## License

Expand Down
140 changes: 140 additions & 0 deletions docs/realtime-protocol.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Refract realtime protocol v1

This is the frontend/backend contract for realtime updates. REST remains the source for initial state and full resynchronization. The v1 endpoint is `GET /api/v1/realtime`; clients request WebSocket with `Upgrade: websocket`. SSE may later expose the same envelopes, cursors, and auth rules.

## Transport decision

All four topics use one WebSocket. Claims and notifications need an authenticated, account-scoped subscription; bidirectional WebSocket frames support that handshake and dynamic subscriptions without reconnecting. Oracle readings and pool stats are broadcast and could use SSE, but sharing the connection avoids another transport, heartbeat, cursor store, and retry loop. If an SSE endpoint is added, it must preserve these frames and use the event `id` as `Last-Event-ID`.

| Topic | Type | Delivery | Deduplication key |
| ------------------- | --------------------------- | ------------------------------------------------ | ----------------------------------------- |
| `oracle.readings` | `oracle.reading.updated` | Ordered, best effort; REST refresh after any gap | `id` (stable per source observation) |
| `claims.settlement` | `claims.settlement.updated` | Ordered, at least once with cursor replay | `id` (stable per claim status transition) |
| `pool.stats` | `pool.stats.updated` | Ordered, best effort; latest snapshot wins | `id` (stable snapshot ID) |
| `notifications` | `notifications.created` | Ordered, at least once with cursor replay | `id` (same value across retries) |

`seq` increases within one topic and deployment. Consumers discard duplicate `id` values. A frame with `seq` below the last applied sequence is out of order and ignored; a gap on a best-effort topic triggers a REST refresh. At-least-once topics request replay. Delivery order across topics is unspecified.

## Common envelope and compatibility

Every server frame carries `v`, `topic`, `type`, `id`, `seq`, `ts`, and object `payload`. `v` versions the envelope and payload together. Additive payload fields are allowed in v1 and clients ignore them. Breaking changes require v2. Clients ignore and log unknown topics, types, and versions; they never throw into the UI.

`id` is the idempotency key. `ts` is an RFC 3339 UTC server timestamp for display and diagnostics only; clients do not use it for ordering because clocks can skew. UTF-8 frames are at most 64 KiB. The server closes oversized input with WebSocket code 1009. Schemas are in [`realtime.schema.json`](./realtime.schema.json); CI validates every named example fence.

```json realtime-example:oracle.reading.updated
{
"v": 1,
"topic": "oracle.readings",
"type": "oracle.reading.updated",
"id": "weather-4-2026-10-21T19:00:00Z",
"seq": 4102,
"ts": "2026-10-21T19:00:01Z",
"payload": {
"coverageType": 4,
"severity": "medium",
"value": 47.5,
"observedAt": "2026-10-21T19:00:00Z"
}
}
```

```json realtime-example:claims.settlement.updated
{
"v": 1,
"topic": "claims.settlement",
"type": "claims.settlement.updated",
"id": "claim_73:paid",
"seq": 992,
"ts": "2026-10-21T19:01:12Z",
"payload": {
"claimId": "claim_73",
"policyId": "policy_19",
"status": "paid",
"txHash": "4da51c"
}
}
```

```json realtime-example:pool.stats.updated
{
"v": 1,
"topic": "pool.stats",
"type": "pool.stats.updated",
"id": "pool:ledger:814220",
"seq": 814220,
"ts": "2026-10-21T19:02:00Z",
"payload": {
"totalUsdc": "250000000000",
"lockedUsdc": "62500000000",
"utilizationBps": 2500,
"apyBps": 840
}
}
```

```json realtime-example:notifications.created
{
"v": 1,
"topic": "notifications",
"type": "notifications.created",
"id": "notification_984",
"seq": 214,
"ts": "2026-10-21T19:03:00Z",
"payload": {
"notificationId": "notification_984",
"account": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
"kind": "claim_paid",
"title": "Claim paid",
"body": "Your payout has settled."
}
}
```

## Authentication and subscriptions

After opening, the client sends `auth.challenge.request` with its public key. The server returns a short-lived SEP-10 challenge transaction. The client signs it through the active wallet and sends `auth.challenge.submit` with the signed XDR. The server responds with `auth.ok` containing an opaque connection-bound token and expiry, or a `system.error`. Tokens never appear in URLs. Anonymous connections may subscribe only to `oracle.readings` and `pool.stats`; `claims.settlement` and `notifications` require authentication and are restricted to the authenticated account.

The client sends `subscribe` with `topics` and an optional `cursor` object mapping each topic to its last applied `seq`. Client control frames use the common envelope and a unique client-generated `id`; their payloads carry these fields. The server acknowledges with `subscribed`, including accepted and rejected topics.

## Resume and resynchronization

The server retains at least 15 minutes of replay for claims and notifications. On reconnect, the client supplies each last applied sequence. The server replays retained messages in order, including duplicates at the cursor boundary. If a cursor is expired, unknown, ahead of the server, or from an earlier deployment, the server sends `system.error` code `RESYNC_REQUIRED`. The client clears that cursor, fetches REST, and subscribes again from the current head.

Oracle and pool updates do not require replay because each update supersedes earlier state. On reconnect or a sequence gap the client fetches REST, records the announced current sequence, and resumes.

## Heartbeats, errors, and backpressure

The server sends a heartbeat every 25 seconds. A client receiving no frame for 60 seconds closes and reconnects with jittered exponential backoff.

```json realtime-example:system.heartbeat
{
"v": 1,
"topic": "system",
"type": "system.heartbeat",
"id": "heartbeat:814221",
"seq": 814221,
"ts": "2026-10-21T19:03:25Z",
"payload": { "intervalMs": 25000 }
}
```

Errors are connection-scoped unless their payload names a topic. A recoverable rate limit keeps the connection open and provides `retryAfterMs`; authentication failures and malformed input may close it.

```json realtime-example:system.error
{
"v": 1,
"topic": "system",
"type": "system.error",
"id": "error:rate:88",
"seq": 88,
"ts": "2026-10-21T19:03:26Z",
"payload": {
"code": "RATE_LIMITED",
"message": "Subscription update rate exceeded",
"recoverable": true,
"retryAfterMs": 5000
}
}
```

Clients may change subscriptions at most five times per 10 seconds and send at most 20 control frames per minute. The server coalesces queued oracle and pool frames to the newest snapshot. It never silently drops claims or notifications: when a connection cannot drain 256 queued frames or 1 MiB, it sends `RESYNC_REQUIRED` when possible and closes with code 1013. Clients reconnect after the supplied delay and follow the resume rules above.
161 changes: 161 additions & 0 deletions docs/realtime.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://refract.finance/schemas/realtime-v1.json",
"title": "Refract realtime protocol v1",
"$defs": {
"envelope": {
"type": "object",
"required": ["v", "topic", "type", "id", "seq", "ts", "payload"],
"properties": {
"v": { "const": 1 },
"topic": { "type": "string" },
"type": { "type": "string" },
"id": { "type": "string", "minLength": 1 },
"seq": { "type": "integer", "minimum": 0 },
"ts": { "type": "string", "format": "date-time" },
"payload": { "type": "object" }
},
"additionalProperties": true
},
"oracle.reading.updated": {
"allOf": [
{ "$ref": "#/$defs/envelope" },
{
"type": "object",
"properties": {
"topic": { "const": "oracle.readings" },
"type": { "const": "oracle.reading.updated" },
"payload": {
"type": "object",
"required": ["coverageType", "severity", "value", "observedAt"],
"properties": {
"coverageType": { "type": "integer", "minimum": 0 },
"severity": { "enum": ["low", "medium", "high", "triggered"] },
"value": { "type": "number" },
"observedAt": { "type": "string", "format": "date-time" }
},
"additionalProperties": true
}
},
"required": ["topic", "type", "payload"]
}
]
},
"claims.settlement.updated": {
"allOf": [
{ "$ref": "#/$defs/envelope" },
{
"type": "object",
"properties": {
"topic": { "const": "claims.settlement" },
"type": { "const": "claims.settlement.updated" },
"payload": {
"type": "object",
"required": ["claimId", "policyId", "status", "txHash"],
"properties": {
"claimId": { "type": "string" },
"policyId": { "type": "string" },
"status": { "enum": ["pending", "approved", "paid", "rejected"] },
"txHash": { "type": ["string", "null"] }
},
"additionalProperties": true
}
},
"required": ["topic", "type", "payload"]
}
]
},
"pool.stats.updated": {
"allOf": [
{ "$ref": "#/$defs/envelope" },
{
"type": "object",
"properties": {
"topic": { "const": "pool.stats" },
"type": { "const": "pool.stats.updated" },
"payload": {
"type": "object",
"required": ["totalUsdc", "lockedUsdc", "utilizationBps", "apyBps"],
"properties": {
"totalUsdc": { "type": "string", "pattern": "^[0-9]+$" },
"lockedUsdc": { "type": "string", "pattern": "^[0-9]+$" },
"utilizationBps": { "type": "integer", "minimum": 0, "maximum": 10000 },
"apyBps": { "type": "integer", "minimum": 0 }
},
"additionalProperties": true
}
},
"required": ["topic", "type", "payload"]
}
]
},
"notifications.created": {
"allOf": [
{ "$ref": "#/$defs/envelope" },
{
"type": "object",
"properties": {
"topic": { "const": "notifications" },
"type": { "const": "notifications.created" },
"payload": {
"type": "object",
"required": ["notificationId", "account", "kind", "title"],
"properties": {
"notificationId": { "type": "string" },
"account": { "type": "string", "pattern": "^G[A-Z2-7]{55}$" },
"kind": { "type": "string" },
"title": { "type": "string" },
"body": { "type": "string" }
},
"additionalProperties": true
}
},
"required": ["topic", "type", "payload"]
}
]
},
"system.heartbeat": {
"allOf": [
{ "$ref": "#/$defs/envelope" },
{
"type": "object",
"properties": {
"topic": { "const": "system" },
"type": { "const": "system.heartbeat" },
"payload": {
"type": "object",
"required": ["intervalMs"],
"properties": { "intervalMs": { "type": "integer", "minimum": 1000 } },
"additionalProperties": true
}
},
"required": ["topic", "type", "payload"]
}
]
},
"system.error": {
"allOf": [
{ "$ref": "#/$defs/envelope" },
{
"type": "object",
"properties": {
"topic": { "const": "system" },
"type": { "const": "system.error" },
"payload": {
"type": "object",
"required": ["code", "message", "recoverable"],
"properties": {
"code": { "type": "string" },
"message": { "type": "string" },
"recoverable": { "type": "boolean" },
"retryAfterMs": { "type": "integer", "minimum": 0 }
},
"additionalProperties": true
}
},
"required": ["topic", "type", "payload"]
}
]
}
}
}
Loading