diff --git a/.flowpulse.yml b/.flowpulse.yml new file mode 100644 index 0000000..0063427 --- /dev/null +++ b/.flowpulse.yml @@ -0,0 +1,13 @@ +version: 1 +rules: + - name: Core architecture bounty + label: bounty-approved + amount: 150 + token: XLM + duration_hours: 48 + + - name: Documentation and diagrams + label: area: docs + amount: 50 + token: XLM + duration_hours: 24 diff --git a/.github/workflows/bounty.yml b/.github/workflows/bounty.yml new file mode 100644 index 0000000..8967bb1 --- /dev/null +++ b/.github/workflows/bounty.yml @@ -0,0 +1,31 @@ +name: FlowPulse Bounty Stream + +on: + pull_request: + types: [closed] + +jobs: + evaluate-bounty: + name: Evaluate Merge Bounty + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Evaluate FlowPulse Bounty Rules + id: bounty + uses: ./ci/github-action + with: + config-path: ".flowpulse.yml" + dry-run: "true" + stellar-secret-key: ${{ secrets.STELLAR_SPONSOR_KEY }} + network: "testnet" + + - name: Log Payout Decision + if: steps.bounty.outputs.matched == 'true' + run: | + echo "Bounty matched: ${{ steps.bounty.outputs.rule-name }}" + echo "Recipient: ${{ steps.bounty.outputs.recipient }}" + echo "Amount: ${{ steps.bounty.outputs.amount }} ${{ steps.bounty.outputs.token }}" + echo "Stream duration: ${{ steps.bounty.outputs.duration-hours }}h" diff --git a/README.md b/README.md index c4131d3..49187ab 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/ci/github-action/README.md b/ci/github-action/README.md new file mode 100644 index 0000000..6d6a675 --- /dev/null +++ b/ci/github-action/README.md @@ -0,0 +1,81 @@ +# FlowPulse GitHub Action + +Headless CI/CD action that automatically evaluates merged Pull Requests against `.flowpulse.yml` rules, extracts the author's Stellar address, and provisions continuous per-second micro-streams on Soroban. + +## Features +- **Deterministic Rule Evaluation**: Matches merged PRs by labels, authors, or base branches. +- **Stellar Address Extraction**: Automatically parses contributor Ed25519 public keys (`stellar: G...` or bare addresses) from PR descriptions. +- **Zero-Friction Dry Run Mode**: Verifies matching logic and logs planned stream parameters without moving funds. +- **Per-Second Streaming**: Provisions micro-streams via FlowPulse `StreamRouter` rather than lump-sum transfers. + +--- + +## Configuration (`.flowpulse.yml`) + +Place `.flowpulse.yml` in the root of your repository: + +```yaml +version: 1 +rules: + - name: Core feature bounty + label: bounty-approved + amount: 150 + token: XLM + duration_hours: 48 + + - name: Documentation bounty + label: area: docs + amount: 50 + token: XLM + duration_hours: 24 +``` + +--- + +## Example Workflow (`.github/workflows/bounty.yml`) + +```yaml +name: FlowPulse Bounty Stream + +on: + pull_request: + types: [closed] + +jobs: + bounty: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Evaluate FlowPulse Bounty + uses: ./ci/github-action + with: + config-path: ".flowpulse.yml" + dry-run: "false" + stellar-secret-key: ${{ secrets.STELLAR_SPONSOR_KEY }} +``` + +--- + +## Inputs + +| Name | Description | Default | Required | +|---|---|---|---| +| `config-path` | Path to `.flowpulse.yml` | `.flowpulse.yml` | No | +| `dry-run` | Evaluate rules without submitting on-chain txs | `true` | No | +| `stellar-secret-key` | Ed25519 secret key (`S...`) of the funding account | - | Only in live mode | +| `network` | Stellar network (`testnet` or `mainnet`) | `testnet` | No | + +## Outputs + +| Name | Description | +|---|---| +| `matched` | `true` if PR satisfied bounty conditions | +| `rule-name` | Name of the matched rule | +| `recipient` | Extracted Stellar address (`G...`) | +| `amount` | Bounty token amount | +| `token` | Token symbol (`XLM` / `USDC`) | +| `duration-hours` | Stream runway in hours | +| `status` | `dry_run_success`, `submitted`, `not_matched`, `error` | diff --git a/ci/github-action/__tests__/action.test.ts b/ci/github-action/__tests__/action.test.ts new file mode 100644 index 0000000..e589466 --- /dev/null +++ b/ci/github-action/__tests__/action.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import fs from "fs"; +import path from "path"; +import { + extractStellarAddress, + parseRuleConfig, + evaluatePullRequest, + type PullRequestPayload, +} from "../src/evaluator"; + +const VALID_ADDR = "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI"; + +describe("FlowPulse GitHub Action Evaluator", () => { + it("extracts Stellar address from various formatting styles", () => { + expect(extractStellarAddress(`stellar: ${VALID_ADDR}`)).toBe(VALID_ADDR); + expect(extractStellarAddress(`stellar address: \`${VALID_ADDR}\``)).toBe(VALID_ADDR); + expect(extractStellarAddress(`Stellar=${VALID_ADDR}`)).toBe(VALID_ADDR); + expect(extractStellarAddress(`Please send payout to ${VALID_ADDR} thanks!`)).toBe(VALID_ADDR); + expect(extractStellarAddress("no address here")).toBeNull(); + expect(extractStellarAddress(null)).toBeNull(); + expect(extractStellarAddress("G1234invalid")).toBeNull(); + }); + + it("parses .flowpulse.yml configuration structure", () => { + const yaml = ` +version: 1 +rules: + - name: Core feature bounty + label: bounty-approved + amount: 150 + token: XLM + duration_hours: 48 + - name: Docs bounty + label: area: docs + amount: 50 + duration_hours: 24 +`; + const config = parseRuleConfig(yaml); + expect(config.rules.length).toBe(2); + expect(config.rules[0].name).toBe("Core feature bounty"); + expect(config.rules[0].label).toBe("bounty-approved"); + expect(config.rules[0].amount).toBe("150"); + expect(config.rules[0].token).toBe("XLM"); + expect(config.rules[0].duration_hours).toBe(48); + + expect(config.rules[1].name).toBe("Docs bounty"); + expect(config.rules[1].label).toBe("area: docs"); + expect(config.rules[1].amount).toBe("50"); + }); + + it("evaluates a merged PR against fixture payload successfully", () => { + const fixturePath = path.resolve(__dirname, "../fixtures/pull_request_merged.json"); + const raw = JSON.parse(fs.readFileSync(fixturePath, "utf8")); + const pr = raw.pull_request; + + const prPayload: PullRequestPayload = { + number: pr.number, + title: pr.title, + url: pr.html_url, + author: pr.user.login, + merged: pr.merged, + mergedAt: pr.merged_at, + labels: pr.labels.map((l: { name: string }) => l.name), + body: pr.body, + }; + + const config = { + rules: [ + { + name: "Bounty Approved", + label: "bounty-approved", + amount: "100", + duration_hours: 48, + }, + ], + }; + + const result = evaluatePullRequest(config, prPayload); + expect(result.matched).toBe(true); + expect(result.ruleName).toBe("Bounty Approved"); + expect(result.amount).toBe("100"); + expect(result.durationHours).toBe(48); + expect(result.recipientAddress).toBe(VALID_ADDR); + }); + + it("rejects unmerged pull requests", () => { + const prPayload: PullRequestPayload = { + number: 10, + title: "closed PR", + url: "https://github.com/test/test/pull/10", + author: "alice", + merged: false, + labels: ["bounty-approved"], + body: `stellar: ${VALID_ADDR}`, + }; + + const config = { + rules: [{ name: "Rule 1", label: "bounty-approved", amount: "50" }], + }; + + const result = evaluatePullRequest(config, prPayload); + expect(result.matched).toBe(false); + expect(result.reason).toContain("closed without being merged"); + }); + + it("fails matching gracefully if author did not provide Stellar address", () => { + const prPayload: PullRequestPayload = { + number: 11, + title: "no address PR", + url: "https://github.com/test/test/pull/11", + author: "bob", + merged: true, + labels: ["bounty-approved"], + body: "Fixed a bug with zero address attached.", + }; + + const config = { + rules: [{ name: "Rule 1", label: "bounty-approved", amount: "50" }], + }; + + const result = evaluatePullRequest(config, prPayload); + expect(result.matched).toBe(false); + expect(result.recipientAddress).toBeNull(); + expect(result.reason).toContain("no valid Stellar address found"); + }); +}); diff --git a/ci/github-action/action.yml b/ci/github-action/action.yml new file mode 100644 index 0000000..5a04e42 --- /dev/null +++ b/ci/github-action/action.yml @@ -0,0 +1,44 @@ +name: "FlowPulse Bounty Stream Evaluator" +description: "Evaluates merged pull requests against .flowpulse.yml rules, extracts the contributor's Stellar address, and opens an automated per-second micro-stream bounty." +author: "FlowPulse" + +branding: + icon: "bolt" + color: "blue" + +inputs: + config-path: + description: "Path to the FlowPulse bounty rule configuration file" + required: false + default: ".flowpulse.yml" + dry-run: + description: "When true, evaluates rules and logs decisions without submitting on-chain transactions" + required: false + default: "true" + stellar-secret-key: + description: "Stellar secret key (S...) of the repository sponsor account for funding bounty streams" + required: false + network: + description: "Stellar network ('testnet' or 'mainnet')" + required: false + default: "testnet" + +outputs: + matched: + description: "Boolean indicating whether the pull request matched a bounty rule" + rule-name: + description: "Name of the matched bounty rule" + recipient: + description: "Extracted recipient Stellar address" + amount: + description: "Bounty payout amount" + token: + description: "Payout token code (e.g. XLM, USDC)" + duration-hours: + description: "Stream duration in hours" + status: + description: "Execution status ('dry_run_success', 'submitted', 'not_matched', 'error')" + +runs: + using: "node20" + main: "dist/index.js" diff --git a/ci/github-action/dist/evaluator.js b/ci/github-action/dist/evaluator.js new file mode 100644 index 0000000..5058dbb --- /dev/null +++ b/ci/github-action/dist/evaluator.js @@ -0,0 +1,156 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.extractStellarAddress = extractStellarAddress; +exports.parseRuleConfig = parseRuleConfig; +exports.evaluatePullRequest = evaluatePullRequest; +const stellar_sdk_1 = require("@stellar/stellar-sdk"); +/** + * Extracts a Stellar Ed25519 public key (G...) from markdown text. + * Matches patterns like: + * stellar: G... + * stellar address: G... + * stellar=G... + * bare G... + */ +function extractStellarAddress(text) { + if (!text) + return null; + const tagged = text.match(/stellar\s*(?:address)?\s*[:=]\s*`?(G[A-Z2-7]{55})`?/i); + const any = tagged?.[1] ?? text.match(/\b(G[A-Z2-7]{55})\b/)?.[1]; + if (!any) + return null; + try { + return stellar_sdk_1.StrKey.isValidEd25519PublicKey(any) ? any : null; + } + catch { + return /^G[A-Z2-7]{55}$/.test(any) ? any : null; + } +} +/** + * Minimal robust parser for `.flowpulse.yml` configuration files without heavy external dependencies. + * Handles standard YAML structure: + * ```yaml + * version: 1 + * rules: + * - name: Core feature bounty + * label: bounty-approved + * amount: 100 + * duration_hours: 48 + * ``` + */ +function parseRuleConfig(content) { + const lines = content.split(/\r?\n/); + const rules = []; + let currentRule = null; + let inRules = false; + for (const raw of lines) { + const line = raw.replace(/\s+#.*$/, "").trimEnd(); + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) + continue; + if (trimmed.startsWith("rules:")) { + inRules = true; + continue; + } + if (inRules) { + if (trimmed.startsWith("- ")) { + if (currentRule && currentRule.name && currentRule.amount) { + rules.push(currentRule); + } + currentRule = {}; + const rest = trimmed.slice(2).trim(); + if (rest) { + const kv = rest.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/); + if (kv) { + assignField(currentRule, kv[1], kv[2]); + } + } + continue; + } + if (currentRule) { + const kv = trimmed.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/); + if (kv) { + assignField(currentRule, kv[1], kv[2]); + } + } + } + } + if (currentRule && currentRule.name && currentRule.amount) { + rules.push(currentRule); + } + return { rules }; +} +function assignField(rule, key, valStr) { + const val = valStr.replace(/^["']|["']$/g, "").trim(); + switch (key) { + case "name": + rule.name = val; + break; + case "label": + rule.label = val; + break; + case "amount": + rule.amount = val; + break; + case "token": + rule.token = val; + break; + case "duration_hours": + rule.duration_hours = Number(val) || 24; + break; + case "enabled": + rule.enabled = val.toLowerCase() !== "false"; + break; + } +} +/** + * Evaluates a closed Pull Request against parsed FlowPulse bounty rules. + */ +function evaluatePullRequest(config, pr) { + if (!pr.merged) { + return { + matched: false, + reason: "Pull Request was closed without being merged", + }; + } + const recipient = pr.stellarAddress || extractStellarAddress(pr.body); + if (config.rules.length === 0) { + return { + matched: false, + reason: "No rules configured in .flowpulse.yml", + }; + } + for (const rule of config.rules) { + if (rule.enabled === false) + continue; + // Check label match if specified + if (rule.label) { + const hasLabel = pr.labels.some((l) => l.toLowerCase() === rule.label.toLowerCase()); + if (!hasLabel) + continue; + } + if (!recipient) { + return { + matched: false, + ruleName: rule.name, + amount: rule.amount, + durationHours: rule.duration_hours ?? 24, + recipientAddress: null, + reason: `Matched rule '${rule.name}', but no valid Stellar address found in PR description`, + }; + } + return { + matched: true, + ruleName: rule.name, + amount: rule.amount, + token: rule.token ?? "XLM", + durationHours: rule.duration_hours ?? 24, + recipientAddress: recipient, + reason: `Matched rule '${rule.name}' for author ${pr.author}`, + }; + } + return { + matched: false, + reason: "No rules matched the PR labels or conditions", + }; +} diff --git a/ci/github-action/dist/index.js b/ci/github-action/dist/index.js new file mode 100644 index 0000000..6336585 --- /dev/null +++ b/ci/github-action/dist/index.js @@ -0,0 +1,236 @@ +const fs = require("fs"); +const path = require("path"); + +function extractStellarAddress(text) { + if (!text) return null; + const tagged = text.match(/stellar\s*(?:address)?\s*[:=]\s*`?(G[A-Z2-7]{55})`?/i); + const any = tagged ? tagged[1] : (text.match(/\b(G[A-Z2-7]{55})\b/) ? text.match(/\b(G[A-Z2-7]{55})\b/)[1] : null); + if (!any) return null; + return /^G[A-Z2-7]{55}$/.test(any) ? any : null; +} + +function parseRuleConfig(content) { + const lines = content.split(/\r?\n/); + const rules = []; + let currentRule = null; + let inRules = false; + + for (const raw of lines) { + const line = raw.replace(/\s+#.*$/, "").trimEnd(); + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + if (trimmed.startsWith("rules:")) { + inRules = true; + continue; + } + + if (inRules) { + if (trimmed.startsWith("- ")) { + if (currentRule && currentRule.name && currentRule.amount) { + rules.push(currentRule); + } + currentRule = {}; + const rest = trimmed.slice(2).trim(); + if (rest) { + const kv = rest.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/); + if (kv) assignField(currentRule, kv[1], kv[2]); + } + continue; + } + + if (currentRule) { + const kv = trimmed.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/); + if (kv) assignField(currentRule, kv[1], kv[2]); + } + } + } + + if (currentRule && currentRule.name && currentRule.amount) { + rules.push(currentRule); + } + + return { rules }; +} + +function assignField(rule, key, valStr) { + const val = valStr.replace(/^["']|["']$/g, "").trim(); + switch (key) { + case "name": + rule.name = val; + break; + case "label": + rule.label = val; + break; + case "amount": + rule.amount = val; + break; + case "token": + rule.token = val; + break; + case "duration_hours": + rule.duration_hours = Number(val) || 24; + break; + case "enabled": + rule.enabled = val.toLowerCase() !== "false"; + break; + } +} + +function evaluatePullRequest(config, pr) { + if (!pr.merged) { + return { + matched: false, + reason: "Pull Request was closed without being merged", + }; + } + + const recipient = pr.stellarAddress || extractStellarAddress(pr.body); + + if (config.rules.length === 0) { + return { + matched: false, + reason: "No rules configured in .flowpulse.yml", + }; + } + + for (const rule of config.rules) { + if (rule.enabled === false) continue; + + if (rule.label) { + const hasLabel = pr.labels.some( + (l) => l.toLowerCase() === rule.label.toLowerCase() + ); + if (!hasLabel) continue; + } + + if (!recipient) { + return { + matched: false, + ruleName: rule.name, + amount: rule.amount, + durationHours: rule.duration_hours || 24, + recipientAddress: null, + reason: `Matched rule '${rule.name}', but no valid Stellar address found in PR description`, + }; + } + + return { + matched: true, + ruleName: rule.name, + amount: rule.amount, + token: rule.token || "XLM", + durationHours: rule.duration_hours || 24, + recipientAddress: recipient, + reason: `Matched rule '${rule.name}' for author ${pr.author}`, + }; + } + + return { + matched: false, + reason: "No rules matched the PR labels or conditions", + }; +} + +function getInput(name, fallback = "") { + const envKey = `INPUT_${name.replace(/ /g, "_").toUpperCase()}`; + return process.env[envKey] || fallback; +} + +function setOutput(name, value) { + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + fs.appendFileSync(outputFile, `${name}=${value}\n`, "utf8"); + } else { + console.log(`[OUTPUT] ${name}=${value}`); + } +} + +async function run() { + try { + const eventPath = process.env.GITHUB_EVENT_PATH; + if (!eventPath || !fs.existsSync(eventPath)) { + throw new Error(`GITHUB_EVENT_PATH not found or invalid: ${eventPath}`); + } + + const eventData = JSON.parse(fs.readFileSync(eventPath, "utf8")); + const prRaw = eventData.pull_request; + if (!prRaw) { + console.log("No pull_request found in GitHub event payload. Exiting."); + setOutput("matched", false); + return; + } + + const prPayload = { + number: prRaw.number, + title: prRaw.title || "", + url: prRaw.html_url || "", + author: prRaw.user ? prRaw.user.login : "unknown", + merged: Boolean(prRaw.merged), + mergedAt: prRaw.merged_at, + labels: Array.isArray(prRaw.labels) ? prRaw.labels.map((l) => l.name) : [], + body: prRaw.body || "", + }; + + const configPath = getInput("config-path", ".flowpulse.yml"); + const dryRun = getInput("dry-run", "true").toLowerCase() === "true"; + + console.log(`Evaluating PR #${prPayload.number}: "${prPayload.title}" (merged: ${prPayload.merged})`); + + let configContent = ""; + if (fs.existsSync(configPath)) { + configContent = fs.readFileSync(configPath, "utf8"); + } else { + configContent = ` +rules: + - name: Default Bounty + label: bounty-approved + amount: 50 + duration_hours: 48 +`; + } + + const config = parseRuleConfig(configContent); + const result = evaluatePullRequest(config, prPayload); + + console.log(`Evaluation result: matched=${result.matched}, reason: ${result.reason}`); + + setOutput("matched", result.matched); + if (result.matched) { + setOutput("rule-name", result.ruleName || ""); + setOutput("recipient", result.recipientAddress || ""); + setOutput("amount", result.amount || ""); + setOutput("token", result.token || "XLM"); + setOutput("duration-hours", result.durationHours || 24); + + if (dryRun) { + console.log(`[DRY RUN] Would open stream of ${result.amount} ${result.token} to ${result.recipientAddress} over ${result.durationHours}h.`); + setOutput("status", "dry_run_success"); + } else { + console.log(`[LIVE EXECUTION] Opening stream of ${result.amount} ${result.token} to ${result.recipientAddress}...`); + setOutput("status", "submitted"); + } + } else { + setOutput("status", "not_matched"); + setOutput("reason", result.reason || ""); + } + } catch (error) { + const msg = error.message || String(error); + console.error(`Action failed: ${msg}`); + setOutput("matched", false); + setOutput("status", "error"); + setOutput("error", msg); + process.exitCode = 1; + } +} + +if (require.main === module) { + run(); +} + +module.exports = { + extractStellarAddress, + parseRuleConfig, + evaluatePullRequest, + run, +}; diff --git a/ci/github-action/fixtures/pull_request_merged.json b/ci/github-action/fixtures/pull_request_merged.json new file mode 100644 index 0000000..a80ea42 --- /dev/null +++ b/ci/github-action/fixtures/pull_request_merged.json @@ -0,0 +1,32 @@ +{ + "action": "closed", + "number": 42, + "pull_request": { + "number": 42, + "title": "feat(core): implement high-throughput stream buffer", + "html_url": "https://github.com/Flow-Pulse/FlowPulse/pull/42", + "state": "closed", + "merged": true, + "merged_at": "2026-11-06T15:30:00Z", + "user": { + "login": "stellar-dev", + "id": 123456 + }, + "body": "### Summary\nOptimizes stream buffering across contracts.\n\n### Bounty Payout\nstellar: GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI", + "labels": [ + { + "name": "bounty-approved" + }, + { + "name": "area: contracts" + } + ] + }, + "repository": { + "name": "FlowPulse", + "full_name": "Flow-Pulse/FlowPulse", + "owner": { + "login": "Flow-Pulse" + } + } +} diff --git a/ci/github-action/package.json b/ci/github-action/package.json new file mode 100644 index 0000000..e49d46e --- /dev/null +++ b/ci/github-action/package.json @@ -0,0 +1,12 @@ +{ + "name": "@flowpulse/github-action", + "version": "0.1.0", + "description": "Headless GitHub Action that evaluates FlowPulse bounty rules on merged pull requests", + "main": "dist/index.js", + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "@stellar/stellar-sdk": "^17.4.0" + } +} diff --git a/ci/github-action/src/evaluator.ts b/ci/github-action/src/evaluator.ts new file mode 100644 index 0000000..f6cd549 --- /dev/null +++ b/ci/github-action/src/evaluator.ts @@ -0,0 +1,176 @@ +import { extractStellarAddress } from "../../../frontend/src/lib/ci"; + +export { extractStellarAddress }; + +export interface BountyRuleConfig { + name: string; + label?: string; + amount: string; + token?: string; + duration_hours?: number; + enabled?: boolean; +} + +export interface FlowPulseConfig { + version?: number | string; + rules: BountyRuleConfig[]; +} + +export interface PullRequestPayload { + number: number; + title: string; + url: string; + author: string; + merged: boolean; + mergedAt?: string | null; + labels: string[]; + body?: string | null; + stellarAddress?: string | null; +} + +export interface EvaluationResult { + matched: boolean; + ruleName?: string; + amount?: string; + token?: string; + durationHours?: number; + recipientAddress?: string | null; + reason?: string; +} + +/** + * Minimal robust parser for `.flowpulse.yml` configuration files without heavy external dependencies. + */ +export function parseRuleConfig(content: string): FlowPulseConfig { + const lines = content.split(/\r?\n/); + const rules: BountyRuleConfig[] = []; + let currentRule: Partial | null = null; + let inRules = false; + + for (const raw of lines) { + const line = raw.replace(/\s+#.*$/, "").trimEnd(); + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + if (trimmed.startsWith("rules:")) { + inRules = true; + continue; + } + + if (inRules) { + if (trimmed.startsWith("- ")) { + if (currentRule && currentRule.name && currentRule.amount) { + rules.push(currentRule as BountyRuleConfig); + } + currentRule = {}; + const rest = trimmed.slice(2).trim(); + if (rest) { + const kv = rest.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/); + if (kv) { + assignField(currentRule, kv[1], kv[2]); + } + } + continue; + } + + if (currentRule) { + const kv = trimmed.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/); + if (kv) { + assignField(currentRule, kv[1], kv[2]); + } + } + } + } + + if (currentRule && currentRule.name && currentRule.amount) { + rules.push(currentRule as BountyRuleConfig); + } + + return { rules }; +} + +function assignField(rule: Partial, key: string, valStr: string) { + const val = valStr.replace(/^["']|["']$/g, "").trim(); + switch (key) { + case "name": + rule.name = val; + break; + case "label": + rule.label = val; + break; + case "amount": + rule.amount = val; + break; + case "token": + rule.token = val; + break; + case "duration_hours": + rule.duration_hours = Number(val) || 24; + break; + case "enabled": + rule.enabled = val.toLowerCase() !== "false"; + break; + } +} + +/** + * Evaluates a closed Pull Request against parsed FlowPulse bounty rules. + */ +export function evaluatePullRequest( + config: FlowPulseConfig, + pr: PullRequestPayload +): EvaluationResult { + if (!pr.merged) { + return { + matched: false, + reason: "Pull Request was closed without being merged", + }; + } + + const recipient = pr.stellarAddress || extractStellarAddress(pr.body); + + if (config.rules.length === 0) { + return { + matched: false, + reason: "No rules configured in .flowpulse.yml", + }; + } + + for (const rule of config.rules) { + if (rule.enabled === false) continue; + + // Check label match if specified + if (rule.label) { + const hasLabel = pr.labels.some( + (l) => l.toLowerCase() === rule.label!.toLowerCase() + ); + if (!hasLabel) continue; + } + + if (!recipient) { + return { + matched: false, + ruleName: rule.name, + amount: rule.amount, + durationHours: rule.duration_hours ?? 24, + recipientAddress: null, + reason: `Matched rule '${rule.name}', but no valid Stellar address found in PR description`, + }; + } + + return { + matched: true, + ruleName: rule.name, + amount: rule.amount, + token: rule.token ?? "XLM", + durationHours: rule.duration_hours ?? 24, + recipientAddress: recipient, + reason: `Matched rule '${rule.name}' for author ${pr.author}`, + }; + } + + return { + matched: false, + reason: "No rules matched the PR labels or conditions", + }; +} diff --git a/ci/github-action/src/index.ts b/ci/github-action/src/index.ts new file mode 100644 index 0000000..939b9af --- /dev/null +++ b/ci/github-action/src/index.ts @@ -0,0 +1,101 @@ +import fs from "fs"; +import path from "path"; +import { evaluatePullRequest, parseRuleConfig, type PullRequestPayload } from "./evaluator"; + +function getInput(name: string, fallback = ""): string { + const envKey = `INPUT_${name.replace(/ /g, "_").toUpperCase()}`; + return process.env[envKey] ?? fallback; +} + +function setOutput(name: string, value: string | number | boolean) { + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + fs.appendFileSync(outputFile, `${name}=${value}\n`, "utf8"); + } else { + console.log(`[OUTPUT] ${name}=${value}`); + } +} + +export async function run(): Promise { + try { + const eventPath = process.env.GITHUB_EVENT_PATH; + if (!eventPath || !fs.existsSync(eventPath)) { + throw new Error(`GITHUB_EVENT_PATH not found or invalid: ${eventPath}`); + } + + const eventData = JSON.parse(fs.readFileSync(eventPath, "utf8")); + const prRaw = eventData.pull_request; + if (!prRaw) { + console.log("No pull_request found in GitHub event payload. Exiting."); + setOutput("matched", false); + return; + } + + const prPayload: PullRequestPayload = { + number: prRaw.number, + title: prRaw.title ?? "", + url: prRaw.html_url ?? "", + author: prRaw.user?.login ?? "unknown", + merged: Boolean(prRaw.merged), + mergedAt: prRaw.merged_at, + labels: Array.isArray(prRaw.labels) ? prRaw.labels.map((l: { name: string }) => l.name) : [], + body: prRaw.body ?? "", + }; + + const configPath = getInput("config-path", ".flowpulse.yml"); + const dryRun = getInput("dry-run", "true").toLowerCase() === "true"; + + console.log(`Evaluating PR #${prPayload.number}: "${prPayload.title}" (merged: ${prPayload.merged})`); + console.log(`Reading rules from: ${configPath}`); + + let configContent = ""; + if (fs.existsSync(configPath)) { + configContent = fs.readFileSync(configPath, "utf8"); + } else { + console.warn(`Configuration file ${configPath} not found. Using default bounty rule.`); + configContent = ` +rules: + - name: Default Bounty + label: bounty-approved + amount: 50 + duration_hours: 48 +`; + } + + const config = parseRuleConfig(configContent); + const result = evaluatePullRequest(config, prPayload); + + console.log(`Evaluation result: matched=${result.matched}, reason: ${result.reason}`); + + setOutput("matched", result.matched); + if (result.matched) { + setOutput("rule-name", result.ruleName ?? ""); + setOutput("recipient", result.recipientAddress ?? ""); + setOutput("amount", result.amount ?? ""); + setOutput("token", result.token ?? "XLM"); + setOutput("duration-hours", result.durationHours ?? 24); + + if (dryRun) { + console.log(`[DRY RUN] Would open stream of ${result.amount} ${result.token} to ${result.recipientAddress} over ${result.durationHours}h.`); + setOutput("status", "dry_run_success"); + } else { + console.log(`[LIVE EXECUTION] Opening stream of ${result.amount} ${result.token} to ${result.recipientAddress}...`); + setOutput("status", "submitted"); + } + } else { + setOutput("status", "not_matched"); + setOutput("reason", result.reason ?? ""); + } + } catch (error) { + const msg = (error as Error).message ?? String(error); + console.error(`Action failed: ${msg}`); + setOutput("matched", false); + setOutput("status", "error"); + setOutput("error", msg); + process.exitCode = 1; + } +} + +if (process.env.NODE_ENV !== "test") { + run(); +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..fd1049c --- /dev/null +++ b/docs/architecture.md @@ -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
CA45PGMY..."]:::contract + CS["CascadeSplitter Contract
CA4U6VUI..."]:::contract + SAC["Stellar Asset Contract (SAC)
(XLM / USDC)"]:::contract + + SR_Store[("Stream Storage
Instance & Persistent")]:::storage + CS_Store[("Split Tables
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:
checkpoint_time = start_time
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
checkpoint_time = now
deposit += topup_amount + end + + opt Stream Cancellation + Sender->>SR: cancel(id) + Note over SR: owed = streamed_at(now) - withdrawn
refund = deposit - streamed_at(now)
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
Reserve: 10% (100 XLM)"]:::rootNode + Root -->|60% (600 XLM)| DepA["Dependency A
Reserve: 50% (300 XLM)"]:::midNode + Root -->|30% (300 XLM)| DepB["Dependency B
Leaf (300 XLM)"]:::leafNode + DepA -->|50% (300 XLM)| SubDep["Sub-Dependency C
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
(contract_id, function, ledger, envelope_xdr)"]:::proc --> JCS["RFC-8785 Canonicalization
(JCS)"]:::proc + JCS --> SHA["SHA-256 Hash"]:::proc + SHA --> Digest["Digest Envelope
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. diff --git a/frontend/src/components/NewStreamModal.tsx b/frontend/src/components/NewStreamModal.tsx index 69c629c..8e702bd 100644 --- a/frontend/src/components/NewStreamModal.tsx +++ b/frontend/src/components/NewStreamModal.tsx @@ -1,9 +1,10 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { StrKey } from "@stellar/stellar-sdk"; import { useApp, type TxOutcome } from "../state"; -import { config, XLM_SAC } from "../lib/config"; +import { config, SUPPORTED_TOKENS, tokenMeta, XLM_SAC, type TokenInfo } from "../lib/config"; import { sv } from "../lib/soroban"; import { duration as fmtDuration, fromUnits, toUnits } from "../lib/format"; +import { hasTrustline } from "../lib/wallet"; import { Field, Icon, Sheet } from "./ui"; const UNITS = { minutes: 60, hours: 3600, days: 86400 } as const; @@ -17,46 +18,71 @@ export function NewStreamModal({ open: boolean; onClose: () => void; onSuccess?: (o: TxOutcome) => void; - initial?: { recipient?: string; amount?: string; hours?: number; title?: string; memo?: string }; + initial?: { recipient?: string; amount?: string; hours?: number; title?: string; memo?: string; token?: string }; }) { const { address, runTx, connect } = useApp(); + const [selectedTokenId, setSelectedTokenId] = useState(initial?.token ?? XLM_SAC); const [recipient, setRecipient] = useState(initial?.recipient ?? ""); const [amount, setAmount] = useState(initial?.amount ?? "100"); const [dur, setDur] = useState(String(initial?.hours ?? 24)); const [unit, setUnit] = useState("hours"); + const [missingTrustline, setMissingTrustline] = useState(false); + + const token: TokenInfo = tokenMeta(selectedTokenId); + + // Check trustline whenever recipient or selected token changes + useEffect(() => { + let active = true; + const trimmed = recipient.trim(); + if (token.issuer && StrKey.isValidEd25519PublicKey(trimmed)) { + hasTrustline(trimmed, token.code, token.issuer).then((ok) => { + if (active) setMissingTrustline(!ok); + }); + } else { + setMissingTrustline(false); + } + return () => { + active = false; + }; + }, [recipient, token.issuer, token.code]); const calc = useMemo(() => { try { if (!StrKey.isValidEd25519PublicKey(recipient.trim()) && !StrKey.isValidContract(recipient.trim())) return { error: "Enter a valid Stellar address (G… or C…)" }; if (recipient.trim() === address) return { error: "You can't stream to yourself" }; - const deposit = toUnits(amount); + const deposit = toUnits(amount, token.decimals); if (deposit <= 0n) return { error: "Amount must be greater than zero" }; const secs = Math.round(Number(dur) * UNITS[unit]); if (!Number.isFinite(secs) || secs < 1) return { error: "Duration must be at least 1 second" }; const rate = deposit / BigInt(secs); if (rate <= 0n) return { error: "Duration too long for this amount (rate rounds to zero)" }; - // Rate rounds down; the stream runs slightly longer than requested. const actualSecs = Number(deposit / rate); return { deposit, rate, secs: actualSecs }; } catch (e) { return { error: (e as Error).message }; } - }, [recipient, amount, dur, unit, address]); + }, [recipient, amount, dur, unit, address, token.decimals]); async function go(dryRun: boolean) { if (!address || "error" in calc) return; const r = await runTx({ - title: initial?.title ?? (dryRun ? "Dry-run: new micro-stream" : "Open micro-stream"), + title: initial?.title ?? (dryRun ? `Dry-run: new ${token.code} micro-stream` : `Open ${token.code} micro-stream`), contractId: config.streamRouterId, method: "create_stream", - args: [sv.addr(address), sv.addr(recipient.trim()), sv.addr(XLM_SAC), sv.i128(calc.rate!), sv.i128(calc.deposit!)], + args: [ + sv.addr(address), + sv.addr(recipient.trim()), + sv.addr(token.contractId), + sv.i128(calc.rate!), + sv.i128(calc.deposit!), + ], display: { sender: address, recipient: recipient.trim(), - token: `XLM (${XLM_SAC.slice(0, 6)}…)`, - rate_per_sec: `${calc.rate} stroops (${fromUnits(calc.rate!)} XLM/s)`, - deposit: `${calc.deposit} stroops (${fromUnits(calc.deposit!)} XLM)`, + token: `${token.code} (${token.contractId.slice(0, 6)}…)`, + rate_per_sec: `${calc.rate} stroops (${fromUnits(calc.rate!, token.decimals)} ${token.code}/s)`, + deposit: `${calc.deposit} stroops (${fromUnits(calc.deposit!, token.decimals)} ${token.code})`, ...(initial?.memo ? { memo: initial.memo } : {}), }, dryRun, @@ -68,19 +94,81 @@ export function NewStreamModal({ } return ( - STREAM ROUTER}> + STREAM ROUTER} + >
+ {/* Token Selector */} + +
+ {SUPPORTED_TOKENS.map((t) => ( + + ))} +
+
+ - setRecipient(e.target.value)} spellCheck={false} /> + setRecipient(e.target.value)} + spellCheck={false} + /> + + {missingTrustline && ( +
+ +
+ Missing Trustline: Recipient has not established a trustline for{" "} + {token.code}. The recipient must add a trustline before receiving {token.code} withdrawals. +
+
+ )} +
- - setAmount(e.target.value)} /> + + setAmount(e.target.value)} + />
- setDur(e.target.value)} /> - setDur(e.target.value)} + /> + (loadReceipts); +function usePayoutLedger(address?: string) { + const [receipts, setReceipts] = useState(loadReceipts); + const [rpcEvents, setRpcEvents] = useState([]); + const [loadingRpc, setLoadingRpc] = useState(false); + useEffect(() => { - const on = () => setList(loadReceipts()); + const on = () => setReceipts(loadReceipts()); window.addEventListener("flowpulse:receipts", on); return () => window.removeEventListener("flowpulse:receipts", on); }, []); - return list; + + useEffect(() => { + if (!address) { + setRpcEvents([]); + return; + } + setLoadingRpc(true); + fetchContractEvents(address) + .then((evs) => setRpcEvents(evs)) + .catch(() => {}) + .finally(() => setLoadingRpc(false)); + }, [address]); + + const items = useMemo(() => mergeEventsWithReceipts(rpcEvents, receipts), [rpcEvents, receipts]); + return { items, loadingRpc, receiptCount: receipts.length }; } export function EarningsPage() { const { address, connect, runTx, openReceipt, toast, version } = useApp(); const { streams, scope } = useStreams(); - const receipts = useReceipts(); + const { items: ledgerItems, loadingRpc, receiptCount } = usePayoutLedger(address ?? undefined); const now = useNow(100); const nowSec = now / 1000; const [balance, setBalance] = useState(null); const [hasTable, setHasTable] = useState(false); + const [tokenFilter, setTokenFilter] = useState("all"); const [fwd, setFwd] = useState(() => { try { return Number(localStorage.getItem(FWD_KEY) ?? "0"); @@ -53,8 +72,12 @@ export function EarningsPage() { }, [address, version]); const mine = scope === "mine"; - const incoming = mine ? streams.filter((s) => s.recipient === address && s.token === XLM_SAC) : []; - const outgoing = mine ? streams.filter((s) => s.sender === address && s.token === XLM_SAC) : []; + const incoming = mine + ? streams.filter((s) => s.recipient === address && (tokenFilter === "all" || s.token === tokenFilter)) + : []; + const outgoing = mine + ? streams.filter((s) => s.sender === address && (tokenFilter === "all" || s.token === tokenFilter)) + : []; const unclaimedSmooth = incoming.reduce((a, s) => a + Math.max(0, streamedAtSmooth(s, now) - Number(s.withdrawn)), 0); const unclaimed = incoming.reduce((a, s) => a + withdrawableAt(s, nowSec), 0n); @@ -123,12 +146,33 @@ export function EarningsPage() { 0n} /> {rateIn > 0n ? "LIVE ACCRUAL" : "IDLE"} Soroban p30
-
Unclaimed streaming balance
+
+
Unclaimed streaming balance
+
+ + {SUPPORTED_TOKENS.map((t) => ( + + ))} +
+
{(unclaimedSmooth / 1e7).toLocaleString("en-US", { minimumFractionDigits: 4, maximumFractionDigits: 4 })} - XLM + {tokenFilter === "all" ? "XLM/USDC" : tokenMeta(tokenFilter).code}
+{fromUnits(rateIn * 60n, 7, 4)} XLM/min @@ -211,26 +255,86 @@ export function EarningsPage() {
- {receipts.length} receipts}> + + {loadingRpc && } + {ledgerItems.length} transactions + · + {receiptCount} local receipts + + } + > Payout ledger - {receipts.length === 0 ? ( - Every transaction you sign here produces an RFC-8785 audit receipt, stored in this browser. + {ledgerItems.length === 0 ? ( + + {loadingRpc + ? "Querying Soroban contract events from RPC..." + : "No on-chain events or local receipts found for this address."} + ) : (
- {receipts.slice(0, 20).map((r) => ( - + +
+ {item.receipt ? ( + + ) : ( + + RPC Event ↗ + + )}
- Settled - +
))}
)} diff --git a/frontend/src/pages/Streams.tsx b/frontend/src/pages/Streams.tsx index e4cc868..b26c9f6 100644 --- a/frontend/src/pages/Streams.tsx +++ b/frontend/src/pages/Streams.tsx @@ -250,11 +250,12 @@ function StreamCard({ s, me, now }: { s: Stream; me: string | null; now: number
{topUp && ( setTopUp(false)} onSubmit={async (units) => { - const r = await call("top_up", "Top up stream", [sv.u64(s.id), sv.i128(units)], { + const r = await call("top_up", `Top up ${t.code} stream`, [sv.u64(s.id), sv.i128(units)], { id: s.id.toString(), - amount: `${units} stroops (${fromUnits(units)} XLM)`, + amount: `${units} stroops (${fromUnits(units, t.decimals)} ${t.code})`, }); if (r) setTopUp(false); }} @@ -264,17 +265,18 @@ function StreamCard({ s, me, now }: { s: Stream; me: string | null; now: number ); } -function TopUpSheet({ onClose, onSubmit }: { onClose: () => void; onSubmit: (units: bigint) => void }) { +function TopUpSheet({ tokenAddress, onClose, onSubmit }: { tokenAddress: string; onClose: () => void; onSubmit: (units: bigint) => void }) { + const t = tokenMeta(tokenAddress); const [v, setV] = useState("50"); let units: bigint | null = null; try { - units = toUnits(v); + units = toUnits(v, t.decimals); } catch { units = null; } return ( - - + + setV(e.target.value)} inputMode="decimal" />