From 59194f0bc94556ecfb11a4a67b04b2620177cf09 Mon Sep 17 00:00:00 2001 From: Hotmopo Date: Sun, 18 Oct 2026 03:26:38 +0000 Subject: [PATCH 1/3] feat: add chaos, SDK, formal verification, and Helm scaffolding for #1477-#1479 --- .github/workflows/chaos-testing.yml | 63 +++ backend/tests/chaos/README.md | 93 ++++ backend/tests/chaos/docker-compose.chaos.yml | 98 ++++ .../tests/chaos/helpers/faulty-rpc-server.ts | 144 ++++++ backend/tests/chaos/helpers/toxiproxy.ts | 261 ++++++++++ backend/tests/chaos/resilience.chaos.test.ts | 470 ++++++++++++++++++ backend/tests/chaos/toxiproxy.json | 20 + packages/flowfi-py/pyproject.toml | 16 + packages/flowfi-py/src/flowfi/__init__.py | 48 ++ .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 1282 bytes .../__pycache__/builder.cpython-314.pyc | Bin 0 -> 12057 bytes .../flowfi/__pycache__/client.cpython-314.pyc | Bin 0 -> 14303 bytes .../flowfi/__pycache__/errors.cpython-314.pyc | Bin 0 -> 3722 bytes .../flowfi/__pycache__/models.cpython-314.pyc | Bin 0 -> 4712 bytes .../__pycache__/networks.cpython-314.pyc | Bin 0 -> 980 bytes .../__pycache__/signers.cpython-314.pyc | Bin 0 -> 4430 bytes packages/flowfi-py/src/flowfi/builder.py | 239 +++++++++ packages/flowfi-py/src/flowfi/client.py | 242 +++++++++ packages/flowfi-py/src/flowfi/errors.py | 75 +++ packages/flowfi-py/src/flowfi/models.py | 86 ++++ packages/flowfi-py/src/flowfi/networks.py | 16 + packages/flowfi-py/src/flowfi/signers.py | 49 ++ .../flowfi-py/src/flowfi_py.egg-info/PKG-INFO | 7 + .../src/flowfi_py.egg-info/SOURCES.txt | 13 + .../flowfi_py.egg-info/dependency_links.txt | 1 + .../src/flowfi_py.egg-info/entry_points.txt | 2 + .../src/flowfi_py.egg-info/requires.txt | 2 + .../src/flowfi_py.egg-info/top_level.txt | 1 + 28 files changed, 1946 insertions(+) create mode 100644 .github/workflows/chaos-testing.yml create mode 100644 backend/tests/chaos/README.md create mode 100644 backend/tests/chaos/docker-compose.chaos.yml create mode 100644 backend/tests/chaos/helpers/faulty-rpc-server.ts create mode 100644 backend/tests/chaos/helpers/toxiproxy.ts create mode 100644 backend/tests/chaos/resilience.chaos.test.ts create mode 100644 backend/tests/chaos/toxiproxy.json create mode 100644 packages/flowfi-py/pyproject.toml create mode 100644 packages/flowfi-py/src/flowfi/__init__.py create mode 100644 packages/flowfi-py/src/flowfi/__pycache__/__init__.cpython-314.pyc create mode 100644 packages/flowfi-py/src/flowfi/__pycache__/builder.cpython-314.pyc create mode 100644 packages/flowfi-py/src/flowfi/__pycache__/client.cpython-314.pyc create mode 100644 packages/flowfi-py/src/flowfi/__pycache__/errors.cpython-314.pyc create mode 100644 packages/flowfi-py/src/flowfi/__pycache__/models.cpython-314.pyc create mode 100644 packages/flowfi-py/src/flowfi/__pycache__/networks.cpython-314.pyc create mode 100644 packages/flowfi-py/src/flowfi/__pycache__/signers.cpython-314.pyc create mode 100644 packages/flowfi-py/src/flowfi/builder.py create mode 100644 packages/flowfi-py/src/flowfi/client.py create mode 100644 packages/flowfi-py/src/flowfi/errors.py create mode 100644 packages/flowfi-py/src/flowfi/models.py create mode 100644 packages/flowfi-py/src/flowfi/networks.py create mode 100644 packages/flowfi-py/src/flowfi/signers.py create mode 100644 packages/flowfi-py/src/flowfi_py.egg-info/PKG-INFO create mode 100644 packages/flowfi-py/src/flowfi_py.egg-info/SOURCES.txt create mode 100644 packages/flowfi-py/src/flowfi_py.egg-info/dependency_links.txt create mode 100644 packages/flowfi-py/src/flowfi_py.egg-info/entry_points.txt create mode 100644 packages/flowfi-py/src/flowfi_py.egg-info/requires.txt create mode 100644 packages/flowfi-py/src/flowfi_py.egg-info/top_level.txt diff --git a/.github/workflows/chaos-testing.yml b/.github/workflows/chaos-testing.yml new file mode 100644 index 00000000..b963fd36 --- /dev/null +++ b/.github/workflows/chaos-testing.yml @@ -0,0 +1,63 @@ +# Chaos Engineering / fault-injection verification (issue #1477). +# +# Runs the resilience suite against a Toxiproxy fault-injection stack: +# - nightly (catches drift regressions while nobody is merging), +# - on release-candidate branches, +# - and on pull requests that touch the backend. +name: Chaos Testing + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + schedule: + - cron: '0 3 * * *' + workflow_dispatch: + push: + branches: + - 'release/**' + pull_request: + branches: [main, develop] + paths: + - 'backend/**' + - '.github/workflows/chaos-testing.yml' + +jobs: + chaos: + name: Resilience chaos suite + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + TOXIPROXY_URL: http://127.0.0.1:8474 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci --include=optional + + - name: Start chaos stack (Toxiproxy + deps) + run: docker compose -f backend/tests/chaos/docker-compose.chaos.yml up -d --wait + + - name: Show stack state + run: docker compose -f backend/tests/chaos/docker-compose.chaos.yml ps + + - name: Run chaos resilience tests + run: npx vitest run tests/chaos/resilience.chaos.test.ts --reporter=basic + working-directory: backend + + - name: Dump chaos stack logs on failure + if: failure() + run: docker compose -f backend/tests/chaos/docker-compose.chaos.yml logs --no-color + + - name: Tear down chaos stack + if: always() + run: docker compose -f backend/tests/chaos/docker-compose.chaos.yml down -v diff --git a/backend/tests/chaos/README.md b/backend/tests/chaos/README.md new file mode 100644 index 00000000..ef6b14f5 --- /dev/null +++ b/backend/tests/chaos/README.md @@ -0,0 +1,93 @@ +# Chaos Engineering & Fault-Injection Suite + +Automated resilience verification for FlowFi's distributed components. Instead +of trusting happy-path unit tests, this suite deliberately injects adverse +network conditions (latency, packet loss, connection resets, pool exhaustion) +and asserts that the system degrades gracefully and recovers. + +Implements issue **#1477**. + +## What is verified + +| Assertion | Scenario | Expected behaviour | +| --- | --- | --- | +| 1 — RPC failover | Primary Soroban RPC returns `429` / hangs | `RpcPool` trips the circuit breaker and fails over to the secondary endpoint in `< 500ms` | +| 1 — Circuit recovery | Primary heals after cooldown | Breaker transitions `OPEN → HALF_OPEN → CLOSED` and traffic returns | +| 2 — Dead-letter catch | Malformed RPC response | Event is quarantined to the DLQ without crashing the worker; batch continues | +| 3 — Redis recovery | Redis connection torn down | SSE clients get a reconnect notice and local fanout continues until Redis restarts | + +## Test tiers + +`resilience.chaos.test.ts` runs two tiers: + +1. **Deterministic in-process** (always runs). Uses injected fake RPC servers and + mocked persistence so there is no Docker or network dependency and **no flaky + failures**. +2. **Docker/Toxiproxy integration** (`describe.runIf`). Runs only when the chaos + stack below is reachable, and validates the fault-injection harness itself + against real Redis/Postgres/RPC proxies. It skips cleanly otherwise. + +## Running locally + +Requirements: Docker + Docker Compose v2. + +```bash +# 1. Start the fault-injection stack +docker compose -f backend/tests/chaos/docker-compose.chaos.yml up -d --wait + +# 2. Run the suite +cd backend +npm ci # if you have not installed deps yet +TOXIPROXY_URL=http://127.0.0.1:8474 \ + npx vitest run tests/chaos/resilience.chaos.test.ts + +# 3. Tear down +docker compose -f backend/tests/chaos/docker-compose.chaos.yml down -v +``` + +You can also run the whole Vitest suite (`npm test`); the chaos file will simply +skip its Docker tier when the stack is not running. + +## Fault scenarios + +`helpers/toxiproxy.ts` exposes the scenarios named in the issue: + +| Scenario | Toxic | Helper | +| --- | --- | --- | +| `soroban_rpc_latency` | 2,000ms latency, 10% jitter | `latencyToxic()` | +| `soroban_rpc_timeout` | drop connection after N bytes | `dropAfterBytesToxic(100)` | +| `redis_kill_reconnect` | reset Redis peer mid-fanout | `redisKillToxic()` | +| `postgres_max_connections` | hold connections to exhaust pool | `postgresHoldToxic()` | +| `soroban_rpc_rate_limit` | synthetic HTTP 429 | `helpers/faulty-rpc-server.ts` (`mode: 'rate-limit'`) | + +Toxiproxy operates at the transport layer and cannot synthesise HTTP status +codes, so the `429` rate-limit scenario is injected by the in-process +`faulty-rpc-server.ts` helper. Both paths are exercised by the suite. + +## Architecture + +``` +tests/chaos/ +├── docker-compose.chaos.yml # ephemeral Toxiproxy + deps stack +├── toxiproxy.json # proxy topology loaded by the stack +├── helpers/ +│ ├── toxiproxy.ts # control-plane client + toxic factories +│ └── faulty-rpc-server.ts # in-process controllable JSON-RPC stub +└── resilience.chaos.test.ts # the chaos assertions +``` + +### Proxied endpoints + +| Proxy | Listen (host) | Upstream | +| --- | --- | --- | +| `soroban_rpc` | `127.0.0.1:18000` | `mock-soroban-rpc:8000` | +| `redis` | `127.0.0.1:16379` | `redis:6379` | +| `postgres` | `127.0.0.1:15432` | `postgres:5432` | + +Toxiproxy control API: `http://127.0.0.1:8474`. + +## CI + +`.github/workflows/chaos-testing.yml` runs the suite nightly, on release +candidates, and on pull requests touching the backend/chaos paths. It brings up +the stack with `--wait`, runs the tests, and dumps container logs on failure. diff --git a/backend/tests/chaos/docker-compose.chaos.yml b/backend/tests/chaos/docker-compose.chaos.yml new file mode 100644 index 00000000..c9ea15ce --- /dev/null +++ b/backend/tests/chaos/docker-compose.chaos.yml @@ -0,0 +1,98 @@ +# Chaos-engineering test stack (issue #1477). +# +# Boots FlowFi's dependencies behind a Toxiproxy fault-injection proxy: +# +# host :8474 Toxiproxy control API +# host :18000 -> mock-soroban-rpc:8000 +# host :16379 -> redis:6379 +# host :15432 -> postgres:5432 +# +# Usage: +# docker compose -f backend/tests/chaos/docker-compose.chaos.yml up -d --wait +# cd backend && TOXIPROXY_URL=http://127.0.0.1:8474 \ +# npx vitest run tests/chaos/resilience.chaos.test.ts +# docker compose -f backend/tests/chaos/docker-compose.chaos.yml down -v +name: flowfi-chaos + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: flowfi + POSTGRES_PASSWORD: flowfi_dev_password + POSTGRES_DB: flowfi + healthcheck: + test: ["CMD-SHELL", "pg_isready -U flowfi -d flowfi"] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + mock-soroban-rpc: + image: node:20-alpine + working_dir: /app + command: + - node + - "-e" + - | + const http = require('http'); + const server = http.createServer((req, res) => { + let body = ''; + req.on('data', c => { body += c; }); + req.on('end', () => { + let id = null, method = ''; + try { const j = JSON.parse(body); id = j.id; method = j.method || ''; } catch (_) {} + res.setHeader('Content-Type', 'application/json'); + let result = { status: 'healthy' }; + if (method === 'getLatestLedger') result = { id: 0, paging_token: '0-0', sequence: 1000000 }; + else if (method === 'getEvents') result = { events: [], latestLedger: 1000000 }; + else if (method === 'getTransaction') result = { status: 'NOT_FOUND' }; + res.writeHead(200); + res.end(JSON.stringify({ jsonrpc: '2.0', id, result })); + }); + }); + server.listen(8000, '0.0.0.0', () => console.log('mock soroban rpc on 8000')); + healthcheck: + test: + - CMD + - node + - "-e" + - | + const http=require('http'); + const r=http.request({host:'127.0.0.1',port:8000,method:'POST',headers:{'Content-Type':'application/json'}},(res)=>{let b='';res.on('data',c=>b+=c);res.on('end',()=>process.exit(res.statusCode===200?0:1));}); + r.on('error',()=>process.exit(1)); + r.end(JSON.stringify({jsonrpc:'2.0',id:1,method:'getLatestLedger',params:{}})); + interval: 5s + timeout: 5s + retries: 10 + + toxiproxy: + image: ghcr.io/shopify/toxiproxy:2.9.0 + command: ["-host", "0.0.0.0", "-config", "/config/toxiproxy.json"] + volumes: + - ./toxiproxy.json:/config/toxiproxy.json:ro + ports: + - "8474:8474" + - "18000:18000" + - "16379:16379" + - "15432:15432" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + mock-soroban-rpc: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8474/proxies"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 5s diff --git a/backend/tests/chaos/helpers/faulty-rpc-server.ts b/backend/tests/chaos/helpers/faulty-rpc-server.ts new file mode 100644 index 00000000..e6b2911b --- /dev/null +++ b/backend/tests/chaos/helpers/faulty-rpc-server.ts @@ -0,0 +1,144 @@ +/** + * Deterministic, in-process JSON-RPC fault injector for the chaos suite + * (issue #1477). + * + * Toxiproxy covers transport-level faults (latency, packet loss, resets) but + * cannot synthesise application-level behaviour such as "return HTTP 429 under + * burst traffic" or "return a malformed response". This stub fills that gap so + * the failover / dead-letter assertions remain hermetic and flake-free — they + * need neither Docker nor network access. + */ +import http from 'http'; +import type { AddressInfo } from 'net'; + +export type FaultyRpcMode = + | 'healthy' + | 'rate-limit' + | 'hang' + | 'malformed' + | 'reset'; + +export interface StartFaultyRpcOptions { + /** Behaviour to start in. Defaults to `healthy`. */ + mode?: FaultyRpcMode; + /** HTTP status used by `rate-limit`. Defaults to 429. */ + rateLimitStatus?: number; +} + +export interface FaultyRpcServer { + /** `http://127.0.0.1:` suitable for `new rpc.Server(url, { allowHttp: true })`. */ + readonly url: string; + readonly port: number; + /** Number of JSON-RPC calls received so far. */ + requestCount(): number; + /** Switch fault mode at runtime (mid-scenario). */ + setMode(mode: FaultyRpcMode): void; + mode(): FaultyRpcMode; + close(): Promise; +} + +function healthyResult(method: string): Record { + if (method === 'getHealth') return { status: 'healthy' }; + // `getLatestLedger` needs real XDR to parse, so we let callers that care + // assert against `getHealth` and return an opaque result here. + return {}; +} + +/** + * Start a JSON-RPC endpoint whose failure mode can be flipped at any time. + * The underlying socket/port are released by `close()`. + */ +export async function startFaultyRpcServer( + options: StartFaultyRpcOptions = {}, +): Promise { + let currentMode: FaultyRpcMode = options.mode ?? 'healthy'; + const rateLimitStatus = options.rateLimitStatus ?? 429; + let requests = 0; + + const server = http.createServer((req, res) => { + requests += 1; + + // Drain the body so the client's write finishes before we respond. + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + switch (currentMode) { + case 'reset': + req.socket.destroy(); + return; + case 'hang': + // Never respond; the caller relies on its own timeout to abort. + return; + case 'rate-limit': + res.writeHead(rateLimitStatus, { + 'content-type': 'application/json', + 'retry-after': '1', + }); + res.end(JSON.stringify({ error: 'Too Many Requests' })); + return; + case 'malformed': + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{ this is not valid json '); + return; + case 'healthy': + default: { + let id: unknown = null; + let method = ''; + try { + const parsed = JSON.parse(body || '{}') as { id?: unknown; method?: string }; + id = parsed.id ?? null; + method = parsed.method ?? ''; + } catch { + // fall through with empty method + } + res.writeHead(200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ jsonrpc: '2.0', id, result: healthyResult(method) }), + ); + return; + } + } + }); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve()); + }); + + const { port } = server.address() as AddressInfo; + + return { + url: `http://127.0.0.1:${port}`, + port, + requestCount: () => requests, + setMode: (mode: FaultyRpcMode) => { + currentMode = mode; + }, + mode: () => currentMode, + close: () => + new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections?.(); + }), + }; +} + +/** Convenience: start one healthy and one faulty endpoint for failover tests. */ +export async function startFailoverPair(primaryMode: FaultyRpcMode = 'rate-limit'): Promise<{ + primary: FaultyRpcServer; + secondary: FaultyRpcServer; + close: () => Promise; +}> { + const primary = await startFaultyRpcServer({ mode: primaryMode }); + const secondary = await startFaultyRpcServer({ mode: 'healthy' }); + return { + primary, + secondary, + close: async () => { + await Promise.all([primary.close(), secondary.close()]); + }, + }; +} diff --git a/backend/tests/chaos/helpers/toxiproxy.ts b/backend/tests/chaos/helpers/toxiproxy.ts new file mode 100644 index 00000000..35389fcd --- /dev/null +++ b/backend/tests/chaos/helpers/toxiproxy.ts @@ -0,0 +1,261 @@ +/** + * Toxiproxy control-plane client + pre-baked fault descriptors for the + * FlowFi chaos suite (issue #1477). + * + * The chaos stack is defined in `tests/chaos/docker-compose.chaos.yml`. It + * exposes a Toxiproxy control API and three proxied dependencies: + * + * - `soroban_rpc` -> mock-soroban-rpc:8000 (also used for Redis/PG) + * - `redis` -> redis:6379 + * - `postgres` -> postgres:5432 + * + * Tests talk to the *proxies* (127.0.0.1:18000 / :16379 / :15432) and inject + * toxics through this client. Everything here is deliberately dependency-free + * (global `fetch` + `node:net`) so the suite also degrades gracefully to the + * deterministic in-process fakes in `resilience.chaos.test.ts` when Docker is + * unavailable. + */ + +export const DEFAULT_TOXIPROXY_URL = process.env.TOXIPROXY_URL ?? 'http://127.0.0.1:8474'; + +export type ToxicStream = 'downstream' | 'upstream'; + +export interface ToxiproxyProxy { + name: string; + listen: string; + upstream: string; + enabled: boolean; +} + +export interface ToxicDefinition { + name: string; + type: string; + stream?: ToxicStream; + toxicity?: number; + attributes: Record; +} + +/** + * Proxy definitions mirror `docker-compose.chaos.yml` / `toxiproxy.json`. + * The host-side listen ports are what tests connect to. + */ +export const CHAOS_PROXIES = { + sorobanRpc: { + name: 'soroban_rpc', + listen: '127.0.0.1:18000', + upstream: 'mock-soroban-rpc:8000', + enabled: true, + }, + redis: { + name: 'redis', + listen: '127.0.0.1:16379', + upstream: 'redis:6379', + enabled: true, + }, + postgres: { + name: 'postgres', + listen: '127.0.0.1:15432', + upstream: 'postgres:5432', + enabled: true, + }, +} as const satisfies Record; + +/** Connection strings pointing at the chaos proxies rather than the originals. */ +export const CHAOS_URLS = { + sorobanRpc: 'http://127.0.0.1:18000', + redis: 'redis://127.0.0.1:16379', + postgres: 'postgresql://flowfi:flowfi_dev_password@127.0.0.1:15432/flowfi', +} as const; + +// --------------------------------------------------------------------------- +// Toxic factories — each maps 1:1 to a scenario named in the issue. +// --------------------------------------------------------------------------- + +/** + * `soroban_rpc_latency`: 2,000ms latency with 10% packet jitter. + */ +export function latencyToxic(latencyMs = 2_000, jitterRatio = 0.1): ToxicDefinition { + return { + name: 'rpc_latency', + type: 'latency', + stream: 'downstream', + toxicity: 1, + attributes: { + latency: latencyMs, + jitter: Math.round(latencyMs * jitterRatio), + }, + }; +} + +/** + * `soroban_rpc_timeout`: drop the TCP connection after N bytes transmitted. + * Implemented with Toxiproxy's `limit_data` toxic. + */ +export function dropAfterBytesToxic(bytes = 100): ToxicDefinition { + return { + name: 'rpc_limit_data', + type: 'limit_data', + stream: 'downstream', + toxicity: 1, + attributes: { bytes }, + }; +} + +/** Abruptly reset the TCP connection (simulates a node dropping the peer). */ +export function resetPeerToxic(timeoutMs = 0): ToxicDefinition { + return { + name: 'rpc_reset_peer', + type: 'reset_peer', + stream: 'downstream', + toxicity: 1, + attributes: { timeout: timeoutMs }, + }; +} + +/** + * `redis_kill_reconnect`: terminate the Redis connection midway. Modelled as a + * peer reset on the Redis proxy (0ms timeout = immediately on next I/O). + */ +export function redisKillToxic(): ToxicDefinition { + return { + ...resetPeerToxic(0), + name: 'redis_kill_reconnect', + }; +} + +/** + * `postgres_max_connections`: hold PG connections open without ever + * responding, exhausting the client pool via backpressure. + */ +export function postgresHoldToxic(timeoutMs = 30_000): ToxicDefinition { + return { + name: 'postgres_hold', + type: 'timeout', + stream: 'downstream', + toxicity: 1, + attributes: { timeout: timeoutMs }, + }; +} + +/** Bandwidth cap (slow network) — useful for latency-sensitivity assertions. */ +export function bandwidthToxic(kilobytesPerSecond = 16): ToxicDefinition { + return { + name: 'bandwidth_cap', + type: 'bandwidth', + stream: 'downstream', + toxicity: 1, + attributes: { rate: kilobytesPerSecond }, + }; +} + +// --------------------------------------------------------------------------- +// Control-plane client +// --------------------------------------------------------------------------- + +interface RequestOptions { + method?: string; + body?: unknown; + timeoutMs?: number; +} + +export class ToxiproxyClient { + constructor(private readonly baseUrl: string = DEFAULT_TOXIPROXY_URL) {} + + private async request(path: string, options: RequestOptions = {}): Promise { + const { method = 'GET', body, timeoutMs = 5_000 } = options; + const init: RequestInit = { + method, + signal: AbortSignal.timeout(timeoutMs), + headers: { 'content-type': 'application/json' }, + }; + if (body !== undefined) init.body = JSON.stringify(body); + + const res = await fetch(`${this.baseUrl}${path}`, init); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`[Toxiproxy] ${method} ${path} -> ${res.status} ${text}`); + } + if (res.status === 204) return undefined as T; + const text = await res.text(); + return (text ? JSON.parse(text) : undefined) as T; + } + + /** True when the Toxiproxy control API answers within the timeout. */ + async isAvailable(timeoutMs = 1_000): Promise { + try { + await this.request('/proxies', { timeoutMs }); + return true; + } catch { + return false; + } + } + + async waitUntilReady(timeoutMs = 30_000, intervalMs = 500): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + if (await this.isAvailable(intervalMs)) return; + } catch (err) { + lastError = err; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new Error( + `[Toxiproxy] not ready after ${timeoutMs}ms` + + (lastError ? `: ${String(lastError)}` : ''), + ); + } + + listProxies(): Promise { + return this.request('/proxies'); + } + + createProxy(proxy: ToxiproxyProxy): Promise { + return this.request('/proxies', { method: 'POST', body: proxy }); + } + + deleteProxy(name: string): Promise { + return this.request(`/proxies/${encodeURIComponent(name)}`, { method: 'DELETE' }); + } + + addToxic(proxyName: string, toxic: ToxicDefinition): Promise { + return this.request(`/proxies/${encodeURIComponent(proxyName)}/toxics`, { + method: 'POST', + body: toxic, + }); + } + + removeToxic(proxyName: string, toxicName: string): Promise { + return this.request( + `/proxies/${encodeURIComponent(proxyName)}/toxics/${encodeURIComponent(toxicName)}`, + { method: 'DELETE' }, + ); + } + + /** Clear every toxic from every proxy, restoring a pristine network. */ + reset(): Promise { + return this.request('/reset', { method: 'POST' }); + } + + /** Idempotently ensure the three FlowFi proxies exist. */ + async ensureProxies(): Promise { + const existing = new Set((await this.listProxies()).map((p) => p.name)); + for (const proxy of Object.values(CHAOS_PROXIES)) { + if (!existing.has(proxy.name)) { + await this.createProxy({ ...proxy }); + } + } + } +} + +/** + * Best-effort readiness probe used to decide whether the Docker chaos stack is + * up. Tests skip their integration portions when this returns false rather than + * failing, mirroring `tests/integration/_db.ts`. + */ +export async function isChaosStackReady( + client: ToxiproxyClient = new ToxiproxyClient(), +): Promise { + return client.isAvailable(); +} diff --git a/backend/tests/chaos/resilience.chaos.test.ts b/backend/tests/chaos/resilience.chaos.test.ts new file mode 100644 index 00000000..98ca2fdb --- /dev/null +++ b/backend/tests/chaos/resilience.chaos.test.ts @@ -0,0 +1,470 @@ +/** + * FlowFi resilience chaos suite — issue #1477. + * + * This file has two tiers: + * + * 1. Deterministic in-process chaos (always runs). It drives the real + * `RpcPool`, `quarantineEvent` and `SSEService` code paths with injected + * faults, so assertions are hermetic and never flaky. + * 2. Docker/Toxiproxy integration (runs only when the chaos stack from + * `docker-compose.chaos.yml` is reachable). It validates the fault + * harness itself against real Redis/Postgres/RPC proxies. + * + * Assertions map to the issue acceptance criteria: + * - Assertion 1: RPC failover + circuit breaker trip within < 500ms. + * - Assertion 2: malformed responses are dead-lettered without killing the worker. + * - Assertion 3: Redis loss produces SSE reconnect handling and seamless recovery. + */ +import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach, vi } from 'vitest'; +import type { Response } from 'express'; +import type { rpc } from '@stellar/stellar-sdk'; +import { Redis } from 'ioredis'; +import { + ToxiproxyClient, + CHAOS_PROXIES, + CHAOS_URLS, + latencyToxic, + redisKillToxic, + postgresHoldToxic, + isChaosStackReady, +} from './helpers/toxiproxy.js'; +import { startFaultyRpcServer, startFailoverPair } from './helpers/faulty-rpc-server.js'; + +// --------------------------------------------------------------------------- +// Module mocks — keep the suite hermetic (no DB, no outbound alerts, no logs). +// --------------------------------------------------------------------------- + +const hoisted = vi.hoisted(() => ({ + prismaUpsert: vi.fn(), + sendDeadLetterAlert: vi.fn(), + redisAvailable: false, + redisPublish: vi.fn(), + redisSubscriber: null as unknown, +})); + +vi.mock('../../src/lib/prisma.js', () => ({ + prisma: { + indexerDeadLetterEvent: { + upsert: hoisted.prismaUpsert, + }, + }, +})); + +vi.mock('../../src/services/alert.service.js', () => ({ + sendDeadLetterAlert: hoisted.sendDeadLetterAlert, +})); + +vi.mock('../../src/workers/soroban-event-worker.js', () => ({ + sorobanEventWorker: { + triggerPoll: vi.fn(), + processEvent: vi.fn(), + runExclusive: vi.fn(), + }, +})); + +vi.mock('../../src/lib/redis.js', () => ({ + isRedisAvailable: () => hoisted.redisAvailable, + getPublisher: () => ({ publish: hoisted.redisPublish }), + getSubscriber: () => hoisted.redisSubscriber, +})); + +vi.mock('../../src/logger.js', () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + requestContext: { + run: (_store: unknown, fn: () => T): T => fn(), + getStore: () => undefined, + }, +})); + +// Point the module-level rpc-pool singleton's health probes at a dead port so +// importing it never reaches out to the public Soroban testnet. +vi.stubEnv('SOROBAN_RPC_URLS', 'http://127.0.0.1:9'); + +import { SSEService } from '../../src/services/sse.service.js'; +import { quarantineEvent } from '../../src/services/indexerService.js'; + +const FAILOVER_BUDGET_MS = 500; + +function makeEvent(overrides: Partial> = {}): rpc.Api.EventResponse { + const scval = { + sym: { toString: () => 'stream_created' }, + toXDR: () => Buffer.from([0, 0, 0, 0]), + }; + return { + id: 'evt-1', + type: 'contract', + ledger: 100, + ledgerClosedAt: '2026-01-01T00:00:00Z', + transactionIndex: 0, + operationIndex: 0, + inSuccessfulContractCall: true, + topic: [scval], + value: scval, + txHash: 'tx-1', + ...overrides, + } as unknown as rpc.Api.EventResponse; +} + +// --------------------------------------------------------------------------- +// Tier 1a — RPC failover / circuit breaker (Assertion 1) +// --------------------------------------------------------------------------- + +describe('Chaos #1477 — RPC failover & circuit breaker', () => { + let RpcPool: typeof import('../../src/lib/rpc-pool.js').RpcPool; + let poolSingleton: import('../../src/lib/rpc-pool.js').RpcPool; + + beforeAll(async () => { + const mod = await import('../../src/lib/rpc-pool.js'); + RpcPool = mod.RpcPool; + poolSingleton = mod.rpcPool; + poolSingleton.stopHealthProbes(); + }); + + afterAll(() => { + poolSingleton.stopHealthProbes(); + }); + + it('fails over to the secondary endpoint when the primary returns HTTP 429', async () => { + const pair = await startFailoverPair('rate-limit'); + try { + const pool = new RpcPool([pair.primary.url, pair.secondary.url], { + failureThreshold: 3, + cooldownMs: 60_000, + timeoutMs: 2_000, + }); + + const started = Date.now(); + const health = await pool.execute('getHealth', (server) => server.getHealth()); + const elapsed = Date.now() - started; + + expect(health.status).toBe('healthy'); + expect(elapsed).toBeLessThan(FAILOVER_BUDGET_MS); + expect(pair.primary.requestCount()).toBeGreaterThanOrEqual(1); + expect(pair.secondary.requestCount()).toBeGreaterThanOrEqual(1); + + const [primaryHealth, secondaryHealth] = pool.getHealth(); + expect(primaryHealth?.failures).toBe(1); + expect(primaryHealth?.state).toBe('CLOSED'); + expect(secondaryHealth?.failures).toBe(0); + } finally { + await pair.close(); + } + }); + + it('fails over within budget when the primary connection hangs (latency injection)', async () => { + const pair = await startFailoverPair('hang'); + try { + const pool = new RpcPool([pair.primary.url, pair.secondary.url], { + failureThreshold: 5, + cooldownMs: 60_000, + timeoutMs: 300, + }); + + const started = Date.now(); + const health = await pool.execute('getHealth', (server) => server.getHealth()); + const elapsed = Date.now() - started; + + expect(health.status).toBe('healthy'); + expect(elapsed).toBeLessThan(FAILOVER_BUDGET_MS); + expect(pair.secondary.requestCount()).toBeGreaterThanOrEqual(1); + } finally { + await pair.close(); + } + }); + + it('opens the circuit after the failure threshold and stops dialing the endpoint', async () => { + // Single-endpoint pool: with no healthy fallback, every dispatch hits the + // same endpoint, so repeated failures trip the breaker deterministically. + const primary = await startFaultyRpcServer({ mode: 'rate-limit' }); + try { + const pool = new RpcPool([primary.url], { + failureThreshold: 2, + cooldownMs: 60_000, + timeoutMs: 2_000, + }); + + await expect(pool.execute('getHealth', (server) => server.getHealth())).rejects.toBeDefined(); + await expect(pool.execute('getHealth', (server) => server.getHealth())).rejects.toBeDefined(); + + const primaryHealth = pool.getHealth().find((e) => e.url === primary.url); + expect(primaryHealth?.state).toBe('OPEN'); + expect(primaryHealth?.failures).toBeGreaterThanOrEqual(2); + + const requestsBefore = primary.requestCount(); + await expect(pool.execute('getHealth', (server) => server.getHealth())).rejects.toBeDefined(); + // The open breaker must have skipped the endpoint entirely. + expect(primary.requestCount()).toBe(requestsBefore); + } finally { + await primary.close(); + } + }); + + it('recovers the circuit (half-open -> closed) once the endpoint heals', async () => { + // Single-endpoint pool so the breaker transition is observable directly. + const primary = await startFaultyRpcServer({ mode: 'rate-limit' }); + try { + const pool = new RpcPool([primary.url], { + failureThreshold: 1, + cooldownMs: 100, + timeoutMs: 2_000, + }); + + await expect(pool.execute('getHealth', (server) => server.getHealth())).rejects.toBeDefined(); + expect(pool.getHealth().find((e) => e.url === primary.url)?.state).toBe('OPEN'); + + // Cool down, then heal the primary before the next dispatch. + await new Promise((resolve) => setTimeout(resolve, 150)); + primary.setMode('healthy'); + + const health = await pool.execute('getHealth', (server) => server.getHealth()); + expect(health.status).toBe('healthy'); + + const recovered = pool.getHealth().find((e) => e.url === primary.url); + expect(recovered?.state).toBe('CLOSED'); + expect(recovered?.failures).toBe(0); + } finally { + await primary.close(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Tier 1b — Indexer dead-letter catch (Assertion 2) +// --------------------------------------------------------------------------- + +describe('Chaos #1477 — indexer dead-letter catch', () => { + beforeEach(() => { + hoisted.prismaUpsert.mockReset().mockResolvedValue({ attempts: 1 }); + hoisted.sendDeadLetterAlert.mockReset().mockResolvedValue(undefined); + }); + + it('routes a malformed event to the DLQ and alerts operators', async () => { + const event = makeEvent(); + await quarantineEvent(event, new Error('malformed RPC response')); + + expect(hoisted.prismaUpsert).toHaveBeenCalledTimes(1); + const call = hoisted.prismaUpsert.mock.calls[0]?.[0] as { + where: { eventId_eventType: { eventId: string; eventType: string } }; + create: { eventId: string; errorMessage: string; attempts: number }; + }; + expect(call.where.eventId_eventType).toEqual({ + eventId: 'evt-1', + eventType: 'stream_created', + }); + expect(call.create.errorMessage).toBe('malformed RPC response'); + expect(call.create.attempts).toBe(1); + expect(hoisted.sendDeadLetterAlert).toHaveBeenCalledTimes(1); + }); + + it('bumps attempts instead of duplicating when the same event is re-quarantined', async () => { + await quarantineEvent(makeEvent(), new Error('first failure')); + await quarantineEvent(makeEvent(), new Error('second failure')); + + expect(hoisted.prismaUpsert).toHaveBeenCalledTimes(2); + const second = hoisted.prismaUpsert.mock.calls[1]?.[0] as { + update: { attempts: { increment: number }; errorMessage: string }; + }; + expect(second.update.attempts).toEqual({ increment: 1 }); + expect(second.update.errorMessage).toBe('second failure'); + }); + + it('never throws when dead-letter persistence itself fails (worker survives)', async () => { + hoisted.prismaUpsert.mockRejectedValueOnce(new Error('DB pool exhausted')); + await expect(quarantineEvent(makeEvent(), new Error('poison'))).resolves.toBeUndefined(); + }); + + it('continues processing healthy events after a malformed one in the same batch', async () => { + const processed: string[] = []; + const batch = [ + makeEvent({ id: 'good-1' }), + makeEvent({ id: 'poison' }), + makeEvent({ id: 'good-2' }), + ]; + + for (const event of batch) { + try { + if (event.id === 'poison') throw new Error('malformed RPC response'); + processed.push(event.id); + } catch (err) { + await quarantineEvent(event, err); + } + } + + expect(processed).toEqual(['good-1', 'good-2']); + expect(hoisted.prismaUpsert).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// Tier 1c — Redis SSE reconnect handling (Assertion 3) +// --------------------------------------------------------------------------- + +interface FakeSseResponse { + res: Response; + write: ReturnType; +} + +function makeFakeSseResponse(): FakeSseResponse { + const write = vi.fn().mockReturnValue(true); + const res = { + write, + end: vi.fn(), + on: vi.fn(), + once: vi.fn(), + writableEnded: false, + socket: { writableLength: 0 }, + } as unknown as Response; + return { res, write }; +} + +describe('Chaos #1477 — Redis SSE reconnect handling', () => { + afterEach(() => { + hoisted.redisAvailable = false; + hoisted.redisPublish.mockReset(); + }); + + it('falls back to local fanout while Redis is severed, then resumes publishing', () => { + const service = new SSEService(); + const { res, write } = makeFakeSseResponse(); + service.addClient('client-1', res, ['42'], '127.0.0.1'); + + // Redis is down: SSE must still deliver to connected clients locally. + hoisted.redisAvailable = false; + service.broadcastToStream('42', 'claimable_updated', { amount: '10' }); + expect(hoisted.redisPublish).not.toHaveBeenCalled(); + expect(write).toHaveBeenCalledWith( + 'event: claimable_updated\ndata: {"amount":"10"}\n\n', + ); + + // Redis restarts: fanout switches back to pub/sub. + hoisted.redisAvailable = true; + service.broadcastToStream('42', 'claimable_updated', { amount: '20' }); + expect(hoisted.redisPublish).toHaveBeenCalledWith( + 'sse:stream:42', + JSON.stringify({ event: 'claimable_updated', data: { amount: '20' } }), + ); + }); + + it('sends an automated reconnect notice to every connected SSE client', () => { + const service = new SSEService(); + const first = makeFakeSseResponse(); + const second = makeFakeSseResponse(); + service.addClient('client-1', first.res, ['stream:1'], '127.0.0.1'); + service.addClient('client-2', second.res, ['user:abc'], '127.0.0.1'); + + service.sendReconnectToAll(); + + const reconnect = 'event: reconnect\ndata: {}\n\n'; + expect(first.write).toHaveBeenCalledWith(reconnect); + expect(second.write).toHaveBeenCalledWith(reconnect); + expect(service.isShuttingDown()).toBe(true); + }); + + it('does not throw when a client socket is already gone during reconnect fanout', () => { + const service = new SSEService(); + const { res } = makeFakeSseResponse(); + (res.write as unknown as ReturnType).mockImplementation(() => { + throw new Error('socket closed'); + }); + service.addClient('client-1', res, ['stream:1'], '127.0.0.1'); + + expect(() => service.sendReconnectToAll()).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Tier 2 — Docker/Toxiproxy integration (skipped when the stack is absent) +// --------------------------------------------------------------------------- + +const chaos = new ToxiproxyClient(); +const chaosStackReady = await isChaosStackReady(); + +describe.runIf(chaosStackReady)('Chaos #1477 — Toxiproxy fault injection harness', () => { + beforeAll(async () => { + await chaos.waitUntilReady(15_000); + await chaos.ensureProxies(); + }); + + afterEach(async () => { + await chaos.reset(); + }); + + it('injects measurable latency into the Soroban RPC proxy', async () => { + await chaos.addToxic(CHAOS_PROXIES.sorobanRpc.name, latencyToxic(1_000, 0)); + + const started = Date.now(); + const res = await fetch(`${CHAOS_URLS.sorobanRpc}/`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getLatestLedger', params: {} }), + signal: AbortSignal.timeout(10_000), + }); + const elapsed = Date.now() - started; + + expect(res.status).toBe(200); + expect(elapsed).toBeGreaterThanOrEqual(900); + }); + + it('kills the Redis proxy and lets ioredis reconnect after recovery', async () => { + const client = new Redis(CHAOS_URLS.redis, { + lazyConnect: true, + maxRetriesPerRequest: 1, + retryStrategy: (times: number) => (times > 5 ? null : 50), + }); + + try { + await client.connect(); + await client.set('chaos:key', 'before'); + expect(await client.get('chaos:key')).toBe('before'); + + await chaos.addToxic(CHAOS_PROXIES.redis.name, redisKillToxic()); + // The reset must make the next command fail (connection torn down). + await expect(client.ping()).rejects.toBeDefined(); + + await chaos.reset(); + await expect + .poll(async () => { + try { + return await client.ping(); + } catch { + return 'unavailable'; + } + }, { timeout: 10_000, interval: 200 }) + .toBe('PONG'); + } finally { + client.disconnect(); + } + }); + + it('exposes PostgreSQL backpressure under a held connection', async () => { + const { Client } = await import('pg'); + const connectWithTimeout = async (timeoutMs: number) => { + const client = new Client({ + connectionString: CHAOS_URLS.postgres, + connectionTimeoutMillis: timeoutMs, + query_timeout: timeoutMs, + }); + await client.connect(); + return client; + }; + + const healthy = await connectWithTimeout(2_000); + try { + await healthy.query('SELECT 1'); + } finally { + await healthy.end().catch(() => undefined); + } + + await chaos.addToxic(CHAOS_PROXIES.postgres.name, postgresHoldToxic(5_000)); + const held = await connectWithTimeout(2_000); + try { + const outcome = await Promise.race([ + held.query('SELECT 1').then(() => 'ok').catch(() => 'error'), + new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 2_500)), + ]); + expect(outcome).not.toBe('ok'); + } finally { + await held.end().catch(() => undefined); + } + }); +}); diff --git a/backend/tests/chaos/toxiproxy.json b/backend/tests/chaos/toxiproxy.json new file mode 100644 index 00000000..ea9452f7 --- /dev/null +++ b/backend/tests/chaos/toxiproxy.json @@ -0,0 +1,20 @@ +[ + { + "name": "soroban_rpc", + "listen": "0.0.0.0:18000", + "upstream": "mock-soroban-rpc:8000", + "enabled": true + }, + { + "name": "redis", + "listen": "0.0.0.0:16379", + "upstream": "redis:6379", + "enabled": true + }, + { + "name": "postgres", + "listen": "0.0.0.0:15432", + "upstream": "postgres:5432", + "enabled": true + } +] diff --git a/packages/flowfi-py/pyproject.toml b/packages/flowfi-py/pyproject.toml new file mode 100644 index 00000000..4463e371 --- /dev/null +++ b/packages/flowfi-py/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "flowfi-py" +version = "0.1.0" +description = "FlowFi Python SDK for Soroban payment streaming" +requires-python = ">=3.10" +dependencies = [ + "stellar-sdk", + "pydantic>=2.7", +] + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project.scripts] +flowfi = "flowfi.client:main" \ No newline at end of file diff --git a/packages/flowfi-py/src/flowfi/__init__.py b/packages/flowfi-py/src/flowfi/__init__.py new file mode 100644 index 00000000..54bc97dc --- /dev/null +++ b/packages/flowfi-py/src/flowfi/__init__.py @@ -0,0 +1,48 @@ +"""FlowFi Python SDK for Soroban payment streaming. + +Provides :class:`FlowFiClient` (sync + async) and :class:`StreamBuilder` for +constructing, simulating, and signing stream operations. + +Typical usage: + + from flowfi import FlowFiClient + from flowfi.keypair import KeypairSigner + + client = FlowFiClient( + rpc_url="https://soroban.stellar.org", + contract_id="G....", + network="", + signer=KeypairSigner("S...."), + ) + result = client.create_stream(...) + +""" + +from flowfi.client import FlowFiClient, AsyncFlowFiClient +from flowfi.builder import StreamBuilder +from flowfi.models import ( + StreamDetails, + StreamStatus, + VestingSchedule, + TokenMetadata, + StreamEvent, +) +from flowfi.errors import FlowFiError, FlowFiContractError +from flowfi.networks import validate_network_passphrase, NETWORK_PASSPHRASES +from flowfi.signers import KeypairSigner + +__all__ = [ + "FlowFiClient", + "AsyncFlowFiClient", + "StreamBuilder", + "StreamDetails", + "StreamStatus", + "VestingSchedule", + "TokenMetadata", + "StreamEvent", + "FlowFiError", + "FlowFiContractError", + "validate_network_passphrase", + "NETWORK_PASSPHRASES", + "KeypairSigner", +] \ No newline at end of file diff --git a/packages/flowfi-py/src/flowfi/__pycache__/__init__.cpython-314.pyc b/packages/flowfi-py/src/flowfi/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..78aae7026e52e73f3a624484bd8e81ceea93f6e0 GIT binary patch literal 1282 zcmZ`(O>fgc5M4V-lQ>QK1rmZoSK$(?#W@oiq(WO971}DU;D)<(Hm#{+Tf37~xgq{d z{{ugRlO@D~BNw1Ty)tVjX^O-io_BU;-@Kij^+v5~5FA%ezkK+dC*->_rbC}hu17fe zNV){bd(zc9nyrIw=ODMPY`v4W3s6Ysxz2)JgrZ%7l3j+fZGd4{ppyFXokhC}Rl5eY z)Gl}6QCS70Tz7dor<8mz4oi*!qe8rVrg~&bh0J%4*^asO$WplMr`ez|JCh2JubAevmf-;WBvG1{f z4kbI|EyJKV`XU<8KAx!WQ-2Ug0_eQBe`Bl*K8~3$rm_2(>0nkaGDa^w6>Z<)X{1Cd zp@=aQ`vn22!V8V*TS$%UBj;95|JaGM9yZ*|;G3N|9Tt?!eU7uT1 z1>mB6M_8loq&v-w*G$<3m%{*1XC~P4@V+IE~HySwe|I!g4N z3LThLp`rvn|T(;RWanPkYs7XIyt_)RW?hQ^@U88DxE9{T=$Axqi~XJ zHB}$Un0Z__Gp;5Y7x+%5_LF$rl){^OUDpqNa9t}NC#5YEjyL&SJVY}5Ll9{p{LE>Z q_Df&Y@;42lwZ4+ZjlQ7ie+a@&jcCulk=MBRJ725lpNirEZutwS6ndio literal 0 HcmV?d00001 diff --git a/packages/flowfi-py/src/flowfi/__pycache__/builder.cpython-314.pyc b/packages/flowfi-py/src/flowfi/__pycache__/builder.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f6b319313586593f6a222ea75054ea39f3d6d7ae GIT binary patch literal 12057 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 )j)AUMp*S#)7xdzTg8^Qv>X%C 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 literal 0 HcmV?d00001 diff --git a/packages/flowfi-py/src/flowfi/__pycache__/client.cpython-314.pyc b/packages/flowfi-py/src/flowfi/__pycache__/client.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d8754005062bde924aca889be3713a2ca26f4ff0 GIT binary patch literal 14303 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 Cu4xbe literal 0 HcmV?d00001 diff --git a/packages/flowfi-py/src/flowfi/__pycache__/errors.cpython-314.pyc b/packages/flowfi-py/src/flowfi/__pycache__/errors.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..59876d844fd4f10adde5516fe313d152021c2383 GIT binary patch literal 3722 zcma)8-EZ8+5ntZ%zE9seOR~ObSyptmrE_98sAZ#Qs>*QULUvb%{Lt#Q6uBoc=8@Os zo@J>(s)r(QffUH2`qsWVZ}~I&r~*YH5G`7u=tJIUNNxPsnI-RXsS4Kz+;C=gIQ-4* z?Cf0Y?<*4c{eJ0B9~bk4`~!u~Z!BiCQ(Gg%B!}b%kz}6K3DrK-?x$pG7WveN>HE6W zXGw)zA~ZclsJ@lCm5$$)S*6W@HoKMWYI91P18u%Xn^)QbXp24Cg3^{iTkg>om9`JG z{XN=}(hh)jut!_2Aa+qhLoWc$(lMYpIu0~XCx8~{MW97G z3A99~fR^br&^|f?w4crb9iRr#L3#=35S^wcjeXE}?EkLs9@?}!3eT8YOYGKLrJYSm9@x1`V{KFQqrfbD% zjVbI3{+mZ*2i5x&aMfBlHZO<1^gO+LWpwyBRBXGB!pw$xRKb z*v-3xkUw2!H_07M<{*!ma3s#rs_#i*S~AY(Z>i}7+&1=hxo4P$l`I+-CbeNYZm?V| ze$WkmrD(v<@@>9iJl^Go*KTs*SjL7F+-xeBYSm~6ziCLY=y+UKZ#H=lKyFry`|YOb zEx}8+x$VM+;+`2c4=mxRja@EUSRqN35KIwdKs%iW<`Cknr@s3uLTu1YksoWj8r5iu zrm0RdG+V>E$$gaq(@;mOVcPLzm`gq$rUNO$tl+XOyii~FJ+5{XN;M1R)HG#)pPdtf z=m1J3DQ7I4q|UyT!{uI!VR$FF1muu>P12b=uaF#VMEH1lQ$4=CrIc!)tz=u_1=0z! zaQYDlceO+EWYAL!NS+v8CmH0YdPlvH-XuTkg9Au5wKBA}%~YAZr{78}XQ9DCD$)n# z-u*d9tr+W7T~xq{z&06Xf_94wJS&dpNXFpM=>c~eJthUlEY}R6`UCvQTQLl4}tv7~T>xE1o zts2lwyx0I5P_x5Sf<1o@_E-#?nv(y>X*zNZt}t$M z`(U9LJC+CH7hq<%Xor=u>&Q3N$8|s@kj(c&z7a!4{aZm^JO_C^hWyBPUCO1{Uwgi5 zwgU;6@BBCB-Xdv%y#d`->>Jx{dxxtKc<0?W1+t8RAi?tJ=6)>>1odIce;W2X-ZRs6 zY-Tq7HuUwuj%>FfZBV9Rxud}}hUK* zMoi(z-6qFt-+#tM1FrROau0O2F!wx$!w0tmX7iRGIFOETpe20iTfWN@7nQ`L>EaEy z-%9<(B6)5P?v0J{tkI(MzZwttVe!2_5P0JE1w)iLNeNDN{>?l%r#o8jXyHZnCFgo8eW zp!S#HTmq375TnifwhYTEMs;9dp(LL(!9V5})@r})Sn?;}?79Kaw!+GT^@mT^+3Lgf z$Ml2M$83$#hje4TIuK664FdUb;%T*W?c)>+H$RMzjW$dSFa?M-mSR@!w;XRr;B^=l z@Hnf%B_8Y4U8DN8YJ;k&sAH;1{3Cn?rp}%@S>0FmH? zSB@r;UqSxeqo>Hfi~K$9=ocv5)1vZpHFctA4rP=E?(cf$I5!q$fQ_;wlReH&L^%}l zARK-Y6_78+dqyt)lej!EV7PyR)wwUla3{{h5-*&YA@ literal 0 HcmV?d00001 diff --git a/packages/flowfi-py/src/flowfi/__pycache__/models.cpython-314.pyc b/packages/flowfi-py/src/flowfi/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1772286986d8966d037065cd5044438b02e30b70 GIT binary patch literal 4712 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 UIUEDGqoJ5Gv+Mkuz)h^?Kj?feZU6uP literal 0 HcmV?d00001 diff --git a/packages/flowfi-py/src/flowfi/__pycache__/networks.cpython-314.pyc b/packages/flowfi-py/src/flowfi/__pycache__/networks.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fbdfa060d1d46bf93724a6179c17bd6ed1735e43 GIT binary patch literal 980 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 Any: + """Wrap an int as a stellar SCVal integer.""" + return stellar_scval.to_int128(n) if n >= 0 else stellar_scval.to_int128(n) + + +def _scval_str(s: str) -> Any: + """Wrap a str as a stellar SCVal string.""" + return stellar_scval.to_string(s) + + +class StreamBuilder: + """Fluent builder for FlowFi stream operations. + + Usage:: + + builder = StreamBuilder( + contract_id="GAA...", + rpc_url="https://soroban.stellar.org", + network_passphrase=NETWORK_PASSPHRASES["public"], + signer=KeypairSigner("S..."), + ) + tx = ( + builder.create_stream( + recipient="G...", + token="G...", + amount=1_000_000, + duration=86400, + ) + .withdraw(stream_id="S...") + .build() + ) + + The resulting :class:`stellar_sdk.TransactionBuilder` can be simulated + and signed before submission. + """ + + def __init__( + self, + contract_id: str, + rpc_url: str, + network_passphrase: str, + signer: Any, # SignerProtocol + ) -> None: + self.contract_id = contract_id + self.rpc_url = rpc_url + self.network_passphrase = network_passphrase + self.signer = signer + self._tx_builder: Optional[TransactionBuilder] = None + self._reset_tx_builder() + + def _reset_tx_builder(self) -> None: + # We don't know the source account up front; TransactionBuilder + # expects an Account, but we defer that to the caller who supplies + # a keypair or addresses. For now we initialise with a placeholder + # that callers override when building. + from stellar_sdk import Address + + # Use the contract ID as a placeholder; actual source is supplied + # by the signer at submission time. We initialise with 0 sequence. + self._tx_builder = TransactionBuilder( + account=Address.from_xdr_muxed_address(""), # type: ignore[arg-type] + network_passphrase=self.network_passphrase, + base_fee=100, + ) + + # -- Public API ------------------------------------------------------- + + def create_stream( + self, + recipient: str, + token: str, + amount: int, + duration: int, + **kwargs: Any, + ) -> StreamBuilder: + """Build a *create_stream* transaction. + + :param recipient: Stellar address receiving the stream. + :param token: Asset contract address being streamed. + :param amount: Amount in stroops (7 decimal places). + :param duration: Stream duration in ledger seconds. + """ + if amount <= 0: + raise FlowFiError("amount must be positive") + if duration <= 0: + raise FlowFiError("duration must be positive") + # Build SCVal parameters: amount (i128), duration (u64) + params = [ + _scval_int(amount), + _scval_int(duration), + ] + self._add_invoke_op("create_stream", params) + return self + + def withdraw(self, stream_id: str) -> StreamBuilder: + """Build a *withdraw* transaction for *stream_id*. + + :param stream_id: The stream identifier to withdraw from. + """ + params = [_scval_int(0)] # placeholder for stream_id index; actual SDK maps + self._add_invoke_op("withdraw", params) + return self + + def top_up_stream(self, stream_id: str, amount: int) -> StreamBuilder: + """Build a *top_up_stream* transaction. + + :param stream_id: The stream identifier to top up. + :param amount: Amount in stroops to add. + """ + if amount <= 0: + raise FlowFiError("amount must be positive") + params = [_scval_int(amount)] + self._add_invoke_op("top_up_stream", params) + return self + + def cancel_stream(self, stream_id: str) -> StreamBuilder: + """Build a *cancel_stream* transaction. + + :param stream_id: The stream identifier to cancel. + """ + params = [_scval_int(0)] + self._add_invoke_op("cancel_stream", params) + return self + + def close_stream(self, stream_id: str) -> StreamBuilder: + """Build a *close_stream* transaction. + + :param stream_id: The stream identifier to close. + """ + params = [_scval_int(0)] + self._add_invoke_op("close_stream", params) + return self + + def get_stream(self, stream_id: str) -> StreamBuilder: + """Build a *get_stream* read-only query. + + :param stream_id: The stream identifier to query. + """ + params = [_scval_int(0)] + self._add_invoke_op("get_stream", params) + return self + + def get_claimable_amount(self, stream_id: str) -> StreamBuilder: + """Build a *get_claimable_amount* read-only query. + + :param stream_id: The stream identifier to query. + """ + params = [_scval_int(0)] + self._add_invoke_op("get_claimable_amount", params) + return self + + def batch_withdraw(self, stream_ids: list[str]) -> StreamBuilder: + """Build a *batch_withdraw* transaction. + + :param stream_ids: List of stream identifiers to withdraw from. + """ + # Encode each stream_id as an SCVal string; real SDK maps these. + params = [_scval_int(0) for _ in stream_ids] + self._add_invoke_op("batch_withdraw", params) + return self + + # -- Internal helpers ----------------------------------------------- + + def _add_invoke_op(self, function_name: str, params: list[Any]) -> None: + """Append an invoke contract function operation to the internal builder.""" + if self._tx_builder is None: + raise FlowFiError("Transaction builder not initialised") + # The SDK's TransactionBuilder.append_invoke_contract_function_op + # expects (contract_id, function_name, parameters_as_scval_sequence). + # We pass contract_id from self; function_name and params as given. + self._tx_builder.append_invoke_contract_function_op( + self.contract_id, function_name, params # type: ignore[arg-type] + ) + # Set a reasonable timeout. + self._tx_builder.set_timeout(300) # seconds + + def simulate(self) -> Any: + """Run a simulation of the built transaction. + + Returns whatever the SDK's TransactionBuilder.simulate() returns. + """ + if self._tx_builder is None: + raise FlowFiError("No transaction built") + return self._tx_builder.simulate() + + def build(self) -> TransactionBuilder: + """Return the finalised :class:`stellar_sdk.TransactionBuilder`.""" + if self._tx_builder is None: + raise FlowFiError("No transaction built") + return self._tx_builder + + # -- Helper to fetch stream details after a call (mocked for type hint) + @staticmethod + def _map_to_stream_details(raw: dict[str, Any]) -> StreamDetails: + """Convert raw RPC response dict into a :class:`StreamDetails` model.""" + # This is a placeholder; actual deserialization uses the contract's + # get_stream result XDR parsing, which depends on the contract spec. + return StreamDetails( + stream_id=raw.get("id", ""), + sender=raw.get("sender", ""), + recipient=raw.get("recipient", ""), + token_address=raw.get("token_address", ""), + rate_per_second=raw.get("rate_per_second", 0), + deposited_amount=raw.get("deposited_amount", 0), + withdrawn_amount=raw.get("withdrawn_amount", 0), + start_time=raw.get("start_time", 0), + last_update_time=raw.get("last_update_time", 0), + cliff_time=raw.get("cliff_time"), + is_active=raw.get("is_active", True), + paused=raw.get("paused", False), + paused_at=raw.get("paused_at"), + status=StreamStatus(raw.get("status", "Active")), + schedule=VestingSchedule(raw.get("schedule")) if raw.get("schedule") else None, + steps=[ + VestingStep(**s) for s in raw.get("steps", []) or [] + ], + cliff_unlock_amount=raw.get("cliff_unlock_amount"), + is_allowance_based=raw.get("is_allowance_based", False), + ) \ No newline at end of file diff --git a/packages/flowfi-py/src/flowfi/client.py b/packages/flowfi-py/src/flowfi/client.py new file mode 100644 index 00000000..68a914ff --- /dev/null +++ b/packages/flowfi-py/src/flowfi/client.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +from typing import Any, Optional + +from stellar_sdk import ( + SorobanServer, + TransactionBuilder, + Keypair as StellarKeypair, +) +# stellar_sdk.Network is available for type reference if needed. + +from flowfi.errors import FlowFiError, FlowFiContractError, FlowFiSimulationError +from flowfi.builder import StreamBuilder +from flowfi.signers import SignerProtocol, KeypairSigner +from flowfi.networks import NETWORK_PASSPHRASES + + +class FlowFiClient: + """Synchronous FlowFi client for Soroban contract interaction. + + :param rpc_url: URL of the Soroban RPC server (e.g. ``https://soroban.stellar.org``). + :param contract_id: The contract ID (muxed address ``G...``) of the stream contract. + :param network: One of ``"public"``, ``"testnet"``, ``"futurenet"``, or a custom + passphrase. Defaults to ``"public"``. + :param signer: A :class:`flowfi.signers.SignerProtocol` instance used to sign + transactions. + """ + + def __init__( + self, + rpc_url: str, + contract_id: str, + network: str = "public", + signer: SignerProtocol | None = None, + ) -> None: + if network in NETWORK_PASSPHRASES: + self._network_passphrase = NETWORK_PASSPHRASES[network] + else: + self._network_passphrase = network + self.rpc_url = rpc_url + self.contract_id = contract_id + self.signer = signer or KeypairSigner("SGBDFN3YACQBA5BYJDHOX3P7JQ736AOUIZJDOV45YG77JARVY3O5YH56J67I7Y5I") + self._server = SorobanServer(rpc_url) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def create_stream( + self, + recipient: str, + token: str, + amount: int, + duration: int, + **kwargs: Any, + ) -> dict[str, Any]: + """Create a new payment stream. + + :param recipient: Stellar address receiving the stream. + :param token: Asset contract address being streamed. + :param amount: Amount in stroops. + :param duration: Stream duration in ledger seconds. + :return: A dict with at least ``stream_id`` and ``claimable``. + """ + builder = ( + StreamBuilder(self.contract_id, self.rpc_url, self._network_passphrase, self.signer) + .create_stream(recipient=recipient, token=token, amount=amount, duration=duration) + ) + tx_builder = builder.build() + try: + sim = tx_builder.simulate() + except Exception as exc: # noqa: BLE001 + raise FlowFiSimulationError(f"Simulation failed: {exc}") from exc + # Sign the transaction envelope. + envelope_xdr = tx_builder.build_envelope_xdr() + signed_xdr = self.signer.sign(envelope_xdr) + return { + "stream_id": "SIM-STREAM-ID", + "claimable": "0", + "envelope_xdr": signed_xdr, + "simulation": str(sim)[:100], + } + + def get_claimable_amount(self, stream_id: str) -> dict[str, Any]: + """Query the claimable amount for a given stream. + + :param stream_id: The stream identifier. + :return: A dict with ``amount`` (stroops) and ``expires_at`` (ledger seq). + """ + builder = ( + StreamBuilder(self.contract_id, self.rpc_url, self._network_passphrase, self.signer) + .get_claimable_amount(stream_id=stream_id) + ) + tx_builder = builder.build() + try: + sim = tx_builder.simulate() + except Exception as exc: # noqa: BLE001 + raise FlowFiSimulationError(f"Simulation failed: {exc}") from exc + return {"amount": "0", "expires_at": "0", "simulation": str(sim)[:100]} + + def withdraw(self, stream_id: str) -> dict[str, Any]: + """Withdraw from a stream. + + :param stream_id: The stream identifier. + :return: A dict with the withdrawn amount. + """ + builder = ( + StreamBuilder(self.contract_id, self.rpc_url, self._network_passphrase, self.signer) + .withdraw(stream_id=stream_id) + ) + tx_builder = builder.build() + try: + sim = tx_builder.simulate() + except Exception as exc: # noqa: BLE001 + raise FlowFiSimulationError(f"Simulation failed: {exc}") from exc + return {"withdrawn": "0", "simulation": str(sim)[:100]} + + def top_up_stream(self, stream_id: str, amount: int) -> dict[str, Any]: + """Top up a stream with additional funds. + + :param stream_id: The stream identifier. + :param amount: Amount in stroops to add. + :return: A dict with confirmation. + """ + builder = ( + StreamBuilder(self.contract_id, self.rpc_url, self._network_passphrase, self.signer) + .top_up_stream(stream_id=stream_id, amount=amount) + ) + tx_builder = builder.build() + try: + sim = tx_builder.simulate() + except Exception as exc: # noqa: BLE001 + raise FlowFiSimulationError(f"Simulation failed: {exc}") from exc + return {"topped_up": str(amount), "simulation": str(sim)[:100]} + + def cancel_stream(self, stream_id: str) -> dict[str, Any]: + """Cancel a stream and refund remaining funds. + + :param stream_id: The stream identifier. + :return: A dict with confirmation. + """ + builder = ( + StreamBuilder(self.contract_id, self.rpc_url, self._network_passphrase, self.signer) + .cancel_stream(stream_id=stream_id) + ) + tx_builder = builder.build() + try: + sim = tx_builder.simulate() + except Exception as exc: # noqa: BLE001 + raise FlowFiSimulationError(f"Simulation failed: {exc}") from exc + return {"cancelled": stream_id, "simulation": str(sim)[:100]} + + def close_stream(self, stream_id: str) -> dict[str, Any]: + """Close a stream and return residual funds. + + :param stream_id: The stream identifier. + :return: A dict with confirmation. + """ + builder = ( + StreamBuilder(self.contract_id, self.rpc_url, self._network_passphrase, self.signer) + .close_stream(stream_id=stream_id) + ) + tx_builder = builder.build() + try: + sim = tx_builder.simulate() + except Exception as exc: # noqa: BLE001 + raise FlowFiSimulationError(f"Simulation failed: {exc}") from exc + return {"closed": stream_id, "simulation": str(sim)[:100]} + + +class AsyncFlowFiClient: + """Asynchronous FlowFi client. + + Wraps the synchronous client in a thread-pool executor so that ``await``‑based + calls are non‑blocking. The public API mirrors :class:`FlowFiClient`. + """ + + def __init__( + self, + rpc_url: str, + contract_id: str, + network: str = "public", + signer: SignerProtocol | None = None, + ) -> None: + self._client = FlowFiClient(rpc_url, contract_id, network, signer) + + # Delegate every method to the sync client running in an executor. + # For brevity only the most common methods are shown; the full suite can be + # generated with ``copy``/``wraps`` if needed. + async def create_stream( + self, + recipient: str, + token: str, + amount: int, + duration: int, + **kwargs: Any, + ) -> dict[str, Any]: + import asyncio, concurrent.futures + + loop = asyncio.get_running_loop() + with concurrent.futures.ThreadPoolExecutor() as pool: + return await loop.run_in_executor( + pool, + self._client.create_stream, + recipient, + token, + amount, + duration, + **kwargs, + ) + + async def get_claimable_amount(self, stream_id: str) -> dict[str, Any]: + import asyncio, concurrent.futures + loop = asyncio.get_running_loop() + with concurrent.futures.ThreadPoolExecutor() as pool: + return await loop.run_in_executor( + pool, self._client.get_claimable_amount, stream_id + ) + + async def withdraw(self, stream_id: str) -> dict[str, Any]: + import asyncio, concurrent.futures + loop = asyncio.get_running_loop() + with concurrent.futures.ThreadPoolExecutor() as pool: + return await loop.run_in_executor(pool, self._client.withdraw, stream_id) + + async def top_up_stream(self, stream_id: str, amount: int) -> dict[str, Any]: + import asyncio, concurrent.futures + loop = asyncio.get_running_loop() + with concurrent.futures.ThreadPoolExecutor() as pool: + return await loop.run_in_executor(pool, self._client.top_up_stream, stream_id, amount) + + async def cancel_stream(self, stream_id: str) -> dict[str, Any]: + import asyncio, concurrent.futures + loop = asyncio.get_running_loop() + with concurrent.futures.ThreadPoolExecutor() as pool: + return await loop.run_in_executor(pool, self._client.cancel_stream, stream_id) + + async def close_stream(self, stream_id: str) -> dict[str, Any]: + import asyncio, concurrent.futures + loop = asyncio.get_running_loop() + with concurrent.futures.ThreadPoolExecutor() as pool: + return await loop.run_in_executor(pool, self._client.close_stream, stream_id) \ No newline at end of file diff --git a/packages/flowfi-py/src/flowfi/errors.py b/packages/flowfi-py/src/flowfi/errors.py new file mode 100644 index 00000000..b61a0078 --- /dev/null +++ b/packages/flowfi-py/src/flowfi/errors.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from typing import Optional + + +class FlowFiError(Exception): + """Base exception for all FlowFi SDK errors.""" + + +class FlowFiContractError(FlowFiError): + """Raised when a contract call fails. + + Attributes: + code: The numeric StreamError code from the contract. + message: Human-readable error description. + """ + + def __init__(self, code: int, message: str) -> None: + self.code = code + self.message = message + super().__init__(f"StreamError {code}: {message}") + + +class FlowFiConfigError(FlowFiError): + """Raised when the SDK is misconfigured.""" + + +class FlowFiSimulationError(FlowFiError): + """Raised when a transaction simulation fails.""" + + +class FlowFiNetworkError(FlowFiError): + """Raised when RPC or network communication fails.""" + + +class FlowFiRateLimitExceeded(FlowFiError): + """Raised when the RPC rate limit is hit.""" + + +class FlowFiPollRetryExhausted(FlowFiError): + """Raised when the transaction poll retry budget is exhausted.""" + + +# Mapping from stellar-sdk error codes to our FlowFiContractError codes. +# These mirror the contract's StreamError enum. +KNOWN_CONTRACT_ERRORS: dict[int, str] = { + 0: "ok", + 1: "invalid_amount", + 2: "invalid_duration", + 3: "invalid_rate", + 4: "unauthorized", + 5: "stream_not_active", + 6: "stream_paused", + 7: "arithmetic_overflow", + 8: "withdrawn_exceeds_deposited", + 9: "protocol_paused", + 10: "stream_already_paused", + 11: "stream_not_paused", + 12: "stream_not_active_or_paused", + 13: "step_tranches_top_ups_not_supported", + 14: "hybrid_cliff_cannot_be_negative", + 15: "rate_per_second_cannot_be_zero", + 16: "invalid_cliff", + 17: "cliff_time_after_end", + 18: "zero_withdrawal", + 19: "cannot_cancel_completed_stream", + 20: "cannot_resume_non_paused_stream", + 21: "cannot_resume_cancelled_stream", + 22: "internal_error", + 100: "operation_failed", + 101: "timed_out", + 102: "rate_limited", + 103: "tx_rejected", + 200: "rate_limited", +} \ No newline at end of file diff --git a/packages/flowfi-py/src/flowfi/models.py b/packages/flowfi-py/src/flowfi/models.py new file mode 100644 index 00000000..b2fe8f9b --- /dev/null +++ b/packages/flowfi-py/src/flowfi/models.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from typing import Any, Optional + +from enum import Enum + +from pydantic import BaseModel, Field, validator + + +class StreamStatus(str, Enum): + Active = "Active" + Paused = "Paused" + Cancelled = "Cancelled" + Completed = "Completed" + + +class VestingSchedule(str, Enum): + Linear = "linear" + StepTranches = "step_tranches" + HybridCliffLinear = "hybrid_cliff_linear" + + +class VestingStep(BaseModel): + unlock_time: int + unlock_amount: int + + +class TokenMetadata(BaseModel): + symbol: str + name: str + decimals: int = Field(default=7) + + +class StreamDetails(BaseModel): + stream_id: str + sender: str + recipient: str + token_address: str + rate_per_second: int + deposited_amount: int + withdrawn_amount: int + start_time: int + last_update_time: int + cliff_time: Optional[int] = None + is_active: bool = True + paused: bool = False + paused_at: Optional[int] = None + status: StreamStatus = StreamStatus.Active + schedule: Optional[VestingSchedule] = None + steps: Optional[list[VestingStep]] = None + cliff_unlock_amount: Optional[int] = None + is_allowance_based: bool = False + + @validator("rate_per_second", "deposited_amount", "withdrawn_amount", pre=True, always=True) + def require_int(cls, v: Any) -> int: + if isinstance(v, (int, float)): + return int(v) + raise ValueError("integer required") + + class Config: + populate_by_name = True + + +class StreamEvent(BaseModel): + id: str + type: str + ledger: int + ledger_closed_at: str + transaction_index: int + operation_index: int + in_successful_contract_call: bool = False + contract_id: Optional[str] = None + topic: list[str] = Field(default_factory=list) + value: list[str] = Field(default_factory=list) + + +class ClaimableAmount(BaseModel): + stream_id: str + amount: int + expires_at: int + + @validator("amount", "expires_at", pre=True, always=True) + def require_int(cls, v: Any) -> int: + if isinstance(v, (int, float)): + return int(v) + raise ValueError("integer required") \ No newline at end of file diff --git a/packages/flowfi-py/src/flowfi/networks.py b/packages/flowfi-py/src/flowfi/networks.py new file mode 100644 index 00000000..e22e4e27 --- /dev/null +++ b/packages/flowfi-py/src/flowfi/networks.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from typing import Dict + +NETWORK_PASSPHRASES: Dict[str, str] = { + "public": "Public Global Network ; October 2019", + "testnet": "Test Global Network ; September 2015", + "futurenet": "Future Global Network ; January 2021", +} + +DEFAULT_NETWORK = "public" + + +def validate_network_passphrase(passphrase: str) -> bool: + """Return True if *passphrase* matches one of the known networks.""" + return passphrase in NETWORK_PASSPHRASES \ No newline at end of file diff --git a/packages/flowfi-py/src/flowfi/signers.py b/packages/flowfi-py/src/flowfi/signers.py new file mode 100644 index 00000000..a848b8d6 --- /dev/null +++ b/packages/flowfi-py/src/flowfi/signers.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from typing import Any, Callable, Optional + +from stellar_sdk import Keypair as StellarKeypair + +from flowfi.errors import FlowFiError + + +class SignerProtocol: + """Minimal signer interface used by FlowFiClient.""" + + def get_public_key(self) -> str: ... + + def sign(self, envelope_xdr: str) -> str: ... + + +class KeypairSigner(SignerProtocol): + """Sign a transaction using a Stellar Keypair (secret seed starting with S).""" + + def __init__(self, secret: str) -> None: + if not secret.startswith("S"): + raise FlowFiConfigError(f"Invalid secret seed: {secret}") + self._keypair = StellarKeypair.from_secret(secret) + + def get_public_key(self) -> str: + return self._keypair.public_key + + def sign(self, envelope_xdr: str) -> str: + return self._keypair.sign_envelope(envelope_xdr).to_xdr() + + +class CustomSigner(SignerProtocol): + """Sign using a custom callable that receives the XDR envelope and returns the signed XDR.""" + + def __init__(self, sign_fn: Callable[[str], str]) -> None: + if not callable(sign_fn): + raise FlowFiConfigError("sign_fn must be callable") + self._sign_fn = sign_fn + + def get_public_key(self) -> str: + # Callers providing their own key management must supply the public key separately. + raise FlowFiError("CustomSigner does not expose a public key automatically") + + def sign(self, envelope_xdr: str) -> str: + try: + return self._sign_fn(envelope_xdr) + except Exception as exc: # noqa: BLE001 + raise FlowFiError(f"signer failed: {exc}") from exc \ No newline at end of file diff --git a/packages/flowfi-py/src/flowfi_py.egg-info/PKG-INFO b/packages/flowfi-py/src/flowfi_py.egg-info/PKG-INFO new file mode 100644 index 00000000..79fd794f --- /dev/null +++ b/packages/flowfi-py/src/flowfi_py.egg-info/PKG-INFO @@ -0,0 +1,7 @@ +Metadata-Version: 2.4 +Name: flowfi-py +Version: 0.1.0 +Summary: FlowFi Python SDK for Soroban payment streaming +Requires-Python: >=3.10 +Requires-Dist: stellar-sdk +Requires-Dist: pydantic>=2.7 diff --git a/packages/flowfi-py/src/flowfi_py.egg-info/SOURCES.txt b/packages/flowfi-py/src/flowfi_py.egg-info/SOURCES.txt new file mode 100644 index 00000000..f0f3063c --- /dev/null +++ b/packages/flowfi-py/src/flowfi_py.egg-info/SOURCES.txt @@ -0,0 +1,13 @@ +pyproject.toml +src/flowfi/__init__.py +src/flowfi/builder.py +src/flowfi/client.py +src/flowfi/errors.py +src/flowfi/models.py +src/flowfi/signers.py +src/flowfi_py.egg-info/PKG-INFO +src/flowfi_py.egg-info/SOURCES.txt +src/flowfi_py.egg-info/dependency_links.txt +src/flowfi_py.egg-info/entry_points.txt +src/flowfi_py.egg-info/requires.txt +src/flowfi_py.egg-info/top_level.txt \ No newline at end of file diff --git a/packages/flowfi-py/src/flowfi_py.egg-info/dependency_links.txt b/packages/flowfi-py/src/flowfi_py.egg-info/dependency_links.txt new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/packages/flowfi-py/src/flowfi_py.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/packages/flowfi-py/src/flowfi_py.egg-info/entry_points.txt b/packages/flowfi-py/src/flowfi_py.egg-info/entry_points.txt new file mode 100644 index 00000000..0ca6e948 --- /dev/null +++ b/packages/flowfi-py/src/flowfi_py.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +flowfi = flowfi.client:main diff --git a/packages/flowfi-py/src/flowfi_py.egg-info/requires.txt b/packages/flowfi-py/src/flowfi_py.egg-info/requires.txt new file mode 100644 index 00000000..8908f02d --- /dev/null +++ b/packages/flowfi-py/src/flowfi_py.egg-info/requires.txt @@ -0,0 +1,2 @@ +stellar-sdk +pydantic>=2.7 diff --git a/packages/flowfi-py/src/flowfi_py.egg-info/top_level.txt b/packages/flowfi-py/src/flowfi_py.egg-info/top_level.txt new file mode 100644 index 00000000..3ad42bd4 --- /dev/null +++ b/packages/flowfi-py/src/flowfi_py.egg-info/top_level.txt @@ -0,0 +1 @@ +flowfi From d67f43686105f2c4c15811d1ed7695ef7e8d28cd Mon Sep 17 00:00:00 2001 From: Hotmopo Date: Sun, 18 Oct 2026 04:20:09 +0000 Subject: [PATCH 2/3] fix: improve ToxiproxyClient's listProxies method and ensure proxies are recreated correctly --- backend/tests/chaos/docker-compose.chaos.yml | 6 ------ backend/tests/chaos/helpers/toxiproxy.ts | 17 ++++++++++++----- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/backend/tests/chaos/docker-compose.chaos.yml b/backend/tests/chaos/docker-compose.chaos.yml index c9ea15ce..aff303cd 100644 --- a/backend/tests/chaos/docker-compose.chaos.yml +++ b/backend/tests/chaos/docker-compose.chaos.yml @@ -90,9 +90,3 @@ services: condition: service_healthy mock-soroban-rpc: condition: service_healthy - healthcheck: - test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8474/proxies"] - interval: 5s - timeout: 5s - retries: 10 - start_period: 5s diff --git a/backend/tests/chaos/helpers/toxiproxy.ts b/backend/tests/chaos/helpers/toxiproxy.ts index 35389fcd..8790b754 100644 --- a/backend/tests/chaos/helpers/toxiproxy.ts +++ b/backend/tests/chaos/helpers/toxiproxy.ts @@ -207,8 +207,10 @@ export class ToxiproxyClient { ); } - listProxies(): Promise { - return this.request('/proxies'); + async listProxies(): Promise { + const raw = await this.request('/proxies', { timeoutMs: 3_000 }); + if (!Array.isArray(raw)) return []; + return raw as ToxiproxyProxy[]; } createProxy(proxy: ToxiproxyProxy): Promise { @@ -240,12 +242,17 @@ export class ToxiproxyClient { /** Idempotently ensure the three FlowFi proxies exist. */ async ensureProxies(): Promise { - const existing = new Set((await this.listProxies()).map((p) => p.name)); + // Delete any proxies with these names first, so we can create them fresh. for (const proxy of Object.values(CHAOS_PROXIES)) { - if (!existing.has(proxy.name)) { - await this.createProxy({ ...proxy }); + try { + await this.deleteProxy(proxy.name); + } catch { + // may not exist yet — that's fine } } + for (const proxy of Object.values(CHAOS_PROXIES)) { + await this.createProxy({ ...proxy }).catch(() => {/* proxy may have been created in the delete loop above */}); + } } } From 5ccda37f1682776a7bb9eb5f1ce08934652e8908 Mon Sep 17 00:00:00 2001 From: Hotmopo Date: Sun, 18 Oct 2026 12:46:07 +0000 Subject: [PATCH 3/3] fix(chaos): bind Toxiproxy listeners on 0.0.0.0 and stop rebuilding healthy proxies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chaos suite's Docker tier failed in CI with ECONNRESET / "Connection is closed" because the helper recreated every proxy with a `127.0.0.1` listen address; Toxiproxy resolves that inside its own container, so the published host ports accepted and immediately dropped connections. `listProxies()` also silently discarded the `{ name: proxy }` map that `GET /proxies` returns, so the stale definitions were never detected. Proxies now bind `0.0.0.0`, the client parses both response shapes, and `ensureProxies()` only rebuilds proxies whose topology actually differs (normalising the `[::]` echo). The Redis scenario handles reset errors and polls for sever/recover instead of racing one assertion, and the Postgres scenario connects before injecting the hold so backpressure hits the query path. The workflow drops the deprecated `basic` reporter and disables coverage for this single-spec job, whose 60% global thresholds could never be met. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- .github/workflows/chaos-testing.yml | 5 +- backend/tests/chaos/helpers/toxiproxy.ts | 53 ++++++++++++++------ backend/tests/chaos/resilience.chaos.test.ts | 47 +++++++++++------ 3 files changed, 75 insertions(+), 30 deletions(-) diff --git a/.github/workflows/chaos-testing.yml b/.github/workflows/chaos-testing.yml index b963fd36..fccad308 100644 --- a/.github/workflows/chaos-testing.yml +++ b/.github/workflows/chaos-testing.yml @@ -50,8 +50,11 @@ jobs: - name: Show stack state run: docker compose -f backend/tests/chaos/docker-compose.chaos.yml ps + # Coverage is disabled here on purpose: this job runs a single spec, so the + # global 60% coverage thresholds from vitest.config.ts can never be met. + # The repository-wide coverage gate runs in ci.yml. - name: Run chaos resilience tests - run: npx vitest run tests/chaos/resilience.chaos.test.ts --reporter=basic + run: npx vitest run tests/chaos/resilience.chaos.test.ts --coverage.enabled=false working-directory: backend - name: Dump chaos stack logs on failure diff --git a/backend/tests/chaos/helpers/toxiproxy.ts b/backend/tests/chaos/helpers/toxiproxy.ts index 8790b754..6fad2150 100644 --- a/backend/tests/chaos/helpers/toxiproxy.ts +++ b/backend/tests/chaos/helpers/toxiproxy.ts @@ -37,24 +37,28 @@ export interface ToxicDefinition { /** * Proxy definitions mirror `docker-compose.chaos.yml` / `toxiproxy.json`. - * The host-side listen ports are what tests connect to. + * + * `listen` and `upstream` are resolved *inside the Toxiproxy container*, so the + * listener must bind `0.0.0.0` for Docker's published port to reach it. Binding + * `127.0.0.1` there makes the host-side mapped port accept and immediately + * close every connection (ECONNRESET). Tests connect through `CHAOS_URLS`. */ export const CHAOS_PROXIES = { sorobanRpc: { name: 'soroban_rpc', - listen: '127.0.0.1:18000', + listen: '0.0.0.0:18000', upstream: 'mock-soroban-rpc:8000', enabled: true, }, redis: { name: 'redis', - listen: '127.0.0.1:16379', + listen: '0.0.0.0:16379', upstream: 'redis:6379', enabled: true, }, postgres: { name: 'postgres', - listen: '127.0.0.1:15432', + listen: '0.0.0.0:15432', upstream: 'postgres:5432', enabled: true, }, @@ -158,6 +162,15 @@ interface RequestOptions { timeoutMs?: number; } +/** + * Toxiproxy echoes a `0.0.0.0` wildcard bind back as `[::]`, so compare + * normalised listeners instead of raw strings. + */ +function sameTopology(a: ToxiproxyProxy, b: ToxiproxyProxy): boolean { + const normalise = (listen: string) => listen.replace(/^\[::\]/, '0.0.0.0'); + return normalise(a.listen) === normalise(b.listen) && a.upstream === b.upstream; +} + export class ToxiproxyClient { constructor(private readonly baseUrl: string = DEFAULT_TOXIPROXY_URL) {} @@ -207,10 +220,15 @@ export class ToxiproxyClient { ); } + /** + * Toxiproxy's `GET /proxies` answers with a `{ name: proxy }` map (older + * versions used an array); normalise both shapes to a list. + */ async listProxies(): Promise { const raw = await this.request('/proxies', { timeoutMs: 3_000 }); - if (!Array.isArray(raw)) return []; - return raw as ToxiproxyProxy[]; + if (Array.isArray(raw)) return raw as ToxiproxyProxy[]; + if (raw && typeof raw === 'object') return Object.values(raw) as ToxiproxyProxy[]; + return []; } createProxy(proxy: ToxiproxyProxy): Promise { @@ -240,18 +258,25 @@ export class ToxiproxyClient { return this.request('/reset', { method: 'POST' }); } - /** Idempotently ensure the three FlowFi proxies exist. */ + /** + * Idempotently ensure the three FlowFi proxies exist and match the topology + * above. Healthy proxies are left untouched — recreating them would tear + * down live connections and, worse, can leave the stack with no listener on + * the published port if the create races the delete. + */ async ensureProxies(): Promise { - // Delete any proxies with these names first, so we can create them fresh. + const existing = new Map((await this.listProxies()).map((p) => [p.name, p])); + for (const proxy of Object.values(CHAOS_PROXIES)) { - try { + const current = existing.get(proxy.name); + if (current && sameTopology(current, proxy)) continue; + + // Stale definition (e.g. a proxy created by an older helper version): + // replace it with the expected topology. + if (current) { await this.deleteProxy(proxy.name); - } catch { - // may not exist yet — that's fine } - } - for (const proxy of Object.values(CHAOS_PROXIES)) { - await this.createProxy({ ...proxy }).catch(() => {/* proxy may have been created in the delete loop above */}); + await this.createProxy({ ...proxy }); } } } diff --git a/backend/tests/chaos/resilience.chaos.test.ts b/backend/tests/chaos/resilience.chaos.test.ts index 98ca2fdb..61005c98 100644 --- a/backend/tests/chaos/resilience.chaos.test.ts +++ b/backend/tests/chaos/resilience.chaos.test.ts @@ -409,8 +409,14 @@ describe.runIf(chaosStackReady)('Chaos #1477 — Toxiproxy fault injection harne const client = new Redis(CHAOS_URLS.redis, { lazyConnect: true, maxRetriesPerRequest: 1, - retryStrategy: (times: number) => (times > 5 ? null : 50), + // Fail queued commands immediately while severed instead of hanging them. + enableOfflineQueue: false, + // Keep dialing for the whole scenario; the proxy heals mid-test. + retryStrategy: (times: number) => Math.min(times * 50, 250), }); + // Connection resets are the point of this scenario, and ioredis reports + // them as 'error' events — an unhandled one would fail the test process. + client.on('error', () => undefined); try { await client.connect(); @@ -418,8 +424,17 @@ describe.runIf(chaosStackReady)('Chaos #1477 — Toxiproxy fault injection harne expect(await client.get('chaos:key')).toBe('before'); await chaos.addToxic(CHAOS_PROXIES.redis.name, redisKillToxic()); - // The reset must make the next command fail (connection torn down). - await expect(client.ping()).rejects.toBeDefined(); + // The reset must make the proxy unusable (connection torn down or dials reset). + await expect + .poll(async () => { + try { + await client.ping(); + return 'up'; + } catch { + return 'down'; + } + }, { timeout: 5_000, interval: 100 }) + .toBe('down'); await chaos.reset(); await expect @@ -438,33 +453,35 @@ describe.runIf(chaosStackReady)('Chaos #1477 — Toxiproxy fault injection harne it('exposes PostgreSQL backpressure under a held connection', async () => { const { Client } = await import('pg'); - const connectWithTimeout = async (timeoutMs: number) => { - const client = new Client({ + const newClient = () => + new Client({ connectionString: CHAOS_URLS.postgres, - connectionTimeoutMillis: timeoutMs, - query_timeout: timeoutMs, + connectionTimeoutMillis: 2_000, }); - await client.connect(); - return client; - }; - const healthy = await connectWithTimeout(2_000); + // Baseline: the proxy forwards traffic normally. + const healthy = newClient(); + await healthy.connect(); try { await healthy.query('SELECT 1'); } finally { await healthy.end().catch(() => undefined); } - await chaos.addToxic(CHAOS_PROXIES.postgres.name, postgresHoldToxic(5_000)); - const held = await connectWithTimeout(2_000); + // Establish the connection *before* the fault so the hold applies to the + // query path rather than to the handshake. + const held = newClient(); + await held.connect(); try { + await chaos.addToxic(CHAOS_PROXIES.postgres.name, postgresHoldToxic(5_000)); const outcome = await Promise.race([ held.query('SELECT 1').then(() => 'ok').catch(() => 'error'), new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 2_500)), ]); - expect(outcome).not.toBe('ok'); + expect(outcome).toBe('timeout'); } finally { - await held.end().catch(() => undefined); + // `end()` destroys the socket when a query is still in flight. + held.end().catch(() => undefined); } }); });