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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ In the app, open the wallet menu and use **Friendbot** to fund your Freighter ac

## How it works

> Detailed contract architecture, mathematical formulas, and sequence/flow diagrams are documented in [`docs/architecture.md`](docs/architecture.md).

### StreamRouter
Accrual is a pure function of ledger close time, so no keeper, cron, or oracle is needed:

Expand Down
204 changes: 204 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
# FlowPulse Architecture Specification

FlowPulse provides continuous, per-second micro-funding streams and recursive dependency split cascades on Stellar Soroban. This document details the contract architecture, state models, accrual checkpointing formulas, and recursive distribution mechanics.

---

## 1. System Overview

FlowPulse is structured into two core Soroban smart contracts alongside an RFC-8785 cryptographic receipt verification layer:

```mermaid
graph TD
classDef contract fill:#f8fafc,stroke:#0284c7,stroke-width:2px,color:#0f172a;
classDef client fill:#f1f5f9,stroke:#475569,stroke-width:1.5px,color:#0f172a;
classDef storage fill:#fef3c7,stroke:#d97706,stroke-width:1.5px,color:#78350f;

subgraph Userland ["Client Applications & Integrations"]
UI["Web App (Vite + React)"]:::client
CI["CI/CD Bounty Runner"]:::client
Verifier["Client-side JCS Receipt Verifier"]:::client
end

subgraph Soroban ["Stellar Soroban Environment"]
SR["StreamRouter Contract<br/><code>CA45PGMY...</code>"]:::contract
CS["CascadeSplitter Contract<br/><code>CA4U6VUI...</code>"]:::contract
SAC["Stellar Asset Contract (SAC)<br/>(XLM / USDC)"]:::contract

SR_Store[("Stream Storage<br/>Instance & Persistent")]:::storage
CS_Store[("Split Tables<br/>Persistent Storage")]:::storage
end

UI -->|Create / Withdraw / Cancel| SR
UI -->|Set Splits / Distribute| CS
CI -->|Trigger Bounty Stream| SR
SR <-->|Escrow Transfer / Payout| SAC
CS <-->|Multi-hop Route Payout| SAC
SR --> SR_Store
CS --> CS_Store
UI --> Verifier
```

---

## 2. StreamRouter: Accrual & Checkpointing

### 2.1 Pure Function of Ledger Timestamp
Accrual requires no off-chain keeper, cron daemon, or external oracle. The accrued amount is a pure mathematical function of the current ledger timestamp $t = \text{ledger}().\text{timestamp}()$.

#### Accrual Mathematical Formula
Given a stream $S$ with:
- $D$: Total escrowed deposit (`deposit`)
- $W$: Total withdrawn so far (`withdrawn`)
- $R$: Accrual rate in base units per second (`rate_per_sec`)
- $t_{\text{chk}}$: Checkpoint ledger timestamp (`checkpoint_time`)
- $A_{\text{chk}}$: Checkpoint accrued amount (`checkpoint_amount`)
- $t_{\text{cancel}}$: Cancellation timestamp ($0$ if active)

The effective evaluation timestamp $t_{\text{eval}}$ is:
$$t_{\text{eval}} = \begin{cases} t_{\text{cancel}}, & \text{if } t_{\text{cancel}} \neq 0 \\ t, & \text{otherwise} \end{cases}$$

The total streamed amount at timestamp $t$ is:
$$\text{streamed}(t) = \min\left(D, A_{\text{chk}} + R \times \max(0, t_{\text{eval}} - t_{\text{chk}})\right)$$

The instantly withdrawable balance for the recipient is:
$$\text{withdrawable}(t) = \text{streamed}(t) - W$$

### 2.2 Checkpointing on Top-Up (Idle Gap Protection)
When an existing stream runs dry ($\text{streamed}(t) \ge D$) and the sender later invokes `top_up(id, amount)`:
1. Accrual is checkpointed at the moment of top-up:
$$A_{\text{chk}} \leftarrow D$$
$$t_{\text{chk}} \leftarrow t$$
2. The deposit is increased: $D \leftarrow D + \text{amount}$.
3. Accrual resumes linearly from $t$. The idle period between stream exhaustion and top-up is never back-paid to the recipient.

```mermaid
sequenceDiagram
autonumber
actor Sender
participant SR as StreamRouter Contract
participant SAC as Token (SAC)
actor Recipient

Sender->>SR: create_stream(recipient, token, rate, deposit)
SR->>SAC: transfer(from: sender, to: contract, deposit)
Note over SR: Init Stream:<br/>checkpoint_time = start_time<br/>checkpoint_amount = 0

loop Time Advances (Ledgers Close)
Note over SR: Accrual = min(deposit, rate * elapsed)
end

Recipient->>SR: withdraw(id)
SR->>SR: amount = streamed_at(now) - withdrawn
SR->>SAC: transfer(from: contract, to: recipient, amount)
Note over SR: withdrawn += amount

opt Stream Runs Dry & Top-Up
Note over SR: streamed_at(now) == deposit
Sender->>SR: top_up(id, topup_amount)
SR->>SAC: transfer(from: sender, to: contract, topup_amount)
Note over SR: checkpoint_amount = deposit<br/>checkpoint_time = now<br/>deposit += topup_amount
end

opt Stream Cancellation
Sender->>SR: cancel(id)
Note over SR: owed = streamed_at(now) - withdrawn<br/>refund = deposit - streamed_at(now)<br/>cancelled_at = now
SR->>SAC: transfer(contract -> recipient, owed)
SR->>SAC: transfer(contract -> sender, refund)
end
```

---

## 3. CascadeSplitter: Recursive Revenue Splits

### 3.1 Split Table Specification
Maintainers and projects publish an upstream split table via `set_splits(owner, splits)`:
- **Total Basis Points:** $\text{TOTAL\_BPS} = 10{,}000$ ($100\%$).
- **Capacity:** At most $\text{MAX\_SPLITS} = 32$ entries per table.
- **Constraints:** Zero weights, self-splits, and duplicate recipients trigger immediate contract errors.
- **Reserve:** Any unallocated basis points ($10{,}000 - \sum \text{bps}_i$) remain with the table owner.

### 3.2 Recursive Routing Algorithm (`route`)
When `distribute(from, owner, token, amount)` or `preview(owner, amount)` is executed:
- The contract routes funds recursively up to $\text{MAX\_DEPTH} = 3$ levels.
- **Rounding Dust Conservation:** At every level, fractional rounding dust and unallocated shares stay with the current owner.
- **Cycle Termination:** Because split lookup halts when $\text{depth} \ge 3$, circular dependencies terminate predictably without infinite recursion or stack exhaustion.

```mermaid
flowchart TD
classDef startNode fill:#0284c7,stroke:#0369a1,stroke-width:2px,color:#ffffff;
classDef decisionNode fill:#f8fafc,stroke:#475569,stroke-width:2px,color:#0f172a;
classDef actionNode fill:#f1f5f9,stroke:#64748b,stroke-width:1px,color:#0f172a;
classDef creditNode fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#14532d;

Start(["route(owner, amount, depth, out)"]):::startNode --> CheckDepth{"depth < MAX_DEPTH (3)?"}:::decisionNode

CheckDepth -- Yes --> LoadSplits["read_splits(env, owner)"]:::actionNode
CheckDepth -- No --> EmptySplits["splits = []"]:::actionNode

LoadSplits --> InitRemaining["remaining = amount"]:::actionNode
EmptySplits --> InitRemaining

InitRemaining --> LoopSplits{"For each Split (bps, recipient)"}:::decisionNode

LoopSplits --> CalcShare["share = amount * bps / 10,000"]:::actionNode
CalcShare --> CheckShare{"share > 0?"}:::decisionNode

CheckShare -- Yes --> SubRemaining["remaining -= share"]:::actionNode
SubRemaining --> Recurse["route(recipient, share, depth + 1, out)"]:::startNode
Recurse --> LoopSplits
CheckShare -- No --> LoopSplits

LoopSplits -- Loop Complete --> CheckRemaining{"remaining > 0?"}:::decisionNode
CheckRemaining -- Yes --> CreditOwner["credit(out, owner, remaining, depth)"]:::creditNode
CheckRemaining -- No --> Done(["End Step"]):::startNode
CreditOwner --> Done
```

### 3.3 Cascade Example
Consider a top-level library funding its transitive dependencies:

```mermaid
graph LR
classDef rootNode fill:#0284c7,stroke:#0369a1,color:#fff,stroke-width:2px;
classDef midNode fill:#38bdf8,stroke:#0284c7,color:#0f172a,stroke-width:1.5px;
classDef leafNode fill:#bae6fd,stroke:#0284c7,color:#0f172a,stroke-width:1.5px;

App["Payer: 1,000 XLM"] --> Root["Root Project<br/>Reserve: 10% (100 XLM)"]:::rootNode
Root -->|60% (600 XLM)| DepA["Dependency A<br/>Reserve: 50% (300 XLM)"]:::midNode
Root -->|30% (300 XLM)| DepB["Dependency B<br/>Leaf (300 XLM)"]:::leafNode
DepA -->|50% (300 XLM)| SubDep["Sub-Dependency C<br/>Leaf (300 XLM)"]:::leafNode

style App fill:#f1f5f9,stroke:#475569,stroke-width:1.5px;
```

Total settled:
- **Root:** 100 XLM (unallocated reserve)
- **Dependency A:** 300 XLM (unallocated reserve)
- **Dependency B:** 300 XLM (direct leaf)
- **Sub-Dependency C:** 300 XLM (cascaded depth 2)
- **Sum:** $100 + 300 + 300 + 300 = 1{,}000\text{ XLM}$ (Conservation invariant strictly held: $\sum \text{payouts} = \text{amount}$).

---

## 4. Cryptographic Audit Receipts

Every state-changing transaction in FlowPulse can generate a verifiable off-chain receipt:

```mermaid
flowchart LR
classDef proc fill:#f8fafc,stroke:#475569,stroke-width:1.5px;
classDef output fill:#f0fdf4,stroke:#16a34a,stroke-width:1.5px;

TxData["Transaction Data<br/>(contract_id, function, ledger, envelope_xdr)"]:::proc --> JCS["RFC-8785 Canonicalization<br/>(JCS)"]:::proc
JCS --> SHA["SHA-256 Hash"]:::proc
SHA --> Digest["Digest Envelope<br/>alg: SHA-256"]:::output

TxData --> Horizon["Horizon Ledger Query"]:::proc
Horizon --> Anchor["Ledger Anchor & Ed25519 Signature Match"]:::output
```

1. **RFC-8785 (JSON Canonicalization Scheme):** Eliminates non-deterministic JSON serialization differences (whitespace, dictionary key order).
2. **SHA-256 Digest:** Fixed 32-byte commitment of canonical receipt fields.
3. **Horizon Ledger Anchor:** Re-verifies inclusion in the declared Stellar ledger close and validates the transaction envelope signature.
56 changes: 55 additions & 1 deletion frontend/src/components/ReceiptModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useApp } from "../state";
import { canonical, downloadJson, jsonSafe, verifyReceipt, type Receipt, type Verification } from "../lib/receipts";
import { config } from "../lib/config";
import { shortAddr } from "../lib/format";
import { encodeQr, qrToSvgPath } from "../lib/qr";
import { CopyButton, Icon, Sheet, Spinner } from "./ui";

/** Flip one bit of the first character of the function name to show tamper detection. */
Expand Down Expand Up @@ -45,6 +46,21 @@ export function ReceiptModal() {
}
}, [receipt]);

const explorerUrl = useMemo(
() => (receipt ? `${config.explorer}/tx/${receipt.receipt.tx_hash}` : ""),
[receipt]
);

const qrData = useMemo(() => {
if (!explorerUrl) return null;
try {
const matrix = encodeQr(explorerUrl);
return { size: matrix.size, path: qrToSvgPath(matrix) };
} catch {
return null;
}
}, [explorerUrl]);

if (!receipt || !subject) return null;
const body = mode === "pretty" ? JSON.stringify(jsonSafe(subject), null, 2) : canonical(subject.receipt);
const r = subject.receipt;
Expand Down Expand Up @@ -190,13 +206,51 @@ export function ReceiptModal() {
/>
</div>

{/* Mobile Verification QR Code */}
<div className="panel mb-4 p-4">
<div className="flex flex-col items-center gap-4 sm:flex-row">
<div className="flex size-28 shrink-0 items-center justify-center rounded-sm border border-line bg-white p-1.5 shadow-xs">
{qrData ? (
<svg
viewBox={`-2 -2 ${qrData.size + 4} ${qrData.size + 4}`}
className="size-full fill-ink"
shapeRendering="crispEdges"
aria-label="Transaction explorer QR Code"
>
<path d={qrData.path} />
</svg>
) : (
<Spinner className="size-6 text-lead" />
)}
</div>
<div className="min-w-0 flex-1 space-y-1 text-center sm:text-left">
<div className="flex items-center justify-center gap-1.5 font-display text-sm font-semibold text-ink sm:justify-start">
<Icon name="qr_code_2" className="text-cobalt" /> Mobile explorer verification
</div>
<p className="text-xs text-lead">
Scan with your phone camera to inspect and verify this transaction on Stellar Expert testnet explorer.
</p>
<div className="pt-1">
<a
className="inline-flex items-center gap-1 truncate font-mono text-[11px] text-cobalt hover:underline"
target="_blank"
rel="noreferrer"
href={explorerUrl}
>
{explorerUrl} <Icon name="open_in_new" className="!text-[12px]" />
</a>
</div>
</div>
</div>
</div>

{allGood !== undefined && (
<div className="grid grid-cols-2 gap-2">
<button className="btn-primary col-span-2" onClick={() => downloadJson(`receipt-${r.tx_hash.slice(0, 12)}.json`, receipt)}>
<Icon name="download" /> Download receipt.json
</button>
<CopyButton value={JSON.stringify(receipt, null, 2)} label="Copy JSON" className="!py-2 !text-sm" />
<a className="btn-secondary" target="_blank" rel="noreferrer" href={`${config.explorer}/tx/${r.tx_hash}`}>
<a className="btn-secondary" target="_blank" rel="noreferrer" href={explorerUrl}>
<Icon name="open_in_new" /> View on explorer
</a>
</div>
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/components/TxDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { signWithFreighter } from "../lib/wallet";
import { makeReceipt, saveReceipt } from "../lib/receipts";
import { config } from "../lib/config";
import { fromUnits, shortAddr } from "../lib/format";
import { toStellarCliCommand } from "../lib/cli";
import { CopyButton, Icon, KV, Sheet, Spinner } from "./ui";

type Phase = "simulating" | "ready" | "failed" | "signing" | "submitting" | "done" | "error";
Expand Down Expand Up @@ -78,6 +79,13 @@ export function TxDrawer() {
const fee = sim?.minResourceFee ? fromUnits(BigInt(sim.minResourceFee) + 100n, 7) : null;
const busy = phase === "signing" || phase === "submitting" || phase === "simulating";

const cliCommand = toStellarCliCommand({
contractId: req.contractId,
method: req.method,
args: req.args,
source: address ?? undefined,
});

return (
<Sheet
open
Expand Down Expand Up @@ -122,6 +130,10 @@ export function TxDrawer() {
))}
</div>
<div className="font-mono text-sm text-ink">)</div>
<div className="mt-3 flex items-center justify-between border-t border-line-faint pt-2">
<span className="font-mono text-[10px] text-lead">Stellar CLI command</span>
<CopyButton value={cliCommand} label="Copy CLI command" className="!py-0.5 text-[11px]" />
</div>
</div>

{/* Simulation */}
Expand Down
Loading