From 620713c2eec4b7dbb9db968e19be49130e4a7fad Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Mon, 3 Aug 2026 22:29:28 +0000 Subject: [PATCH 1/2] RALPH: feat(desktop): net-flow/runway domain logic for Money tab earning (buzz#86) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: toon-protocol/buzz#86, part of the agent-fleet-money epic (toon-meta#262 decision 9 / toon-meta#261 decision 4). Ships the pure net-flow domain logic the ticket's four acceptance criteria all reduce to — income beside spend, runway that accounts for income, the earning predicate for AgentIdentityCard, and low-funds-alert suppression. Key decisions: - New `desktop/src/features/profile/lib/agentNetworkFlow.ts`: models `spendable = deposit − owed + credited` (the connector's stated netting formula, one channel, never a second "earnings" pot) plus a runway derivation that discounts untrusted income. Sustained income (>= 3 samples in the trailing window) that covers burn yields a `self-funding` state with no depletion date; anything less degrades to burn-rate-alone runway — a single paid job must not flip the UI to "indefinite" and strand someone when the job stream dries up. `isEarning` (AgentIdentityCard badge) and `shouldSuppressLowFundsAlert` reuse the same trusted-income bar so the fleet glance and the alert never disagree about whether an agent pays for itself. - Followed the `paymentsOverview.ts` / `huddleFeeQuote.ts` idiom: pure, fully unit-tested (RGR — 8 new cases), no DOM/network, ready for a caller to wire once real data exists. - Not wired into any component. The two things this logic needs to render for real do not exist yet in this repo: the Network spend block itself (#80 — still a "Coming soon" placeholder, no balance/ burn-rate read of any kind) and a per-agent claim-state read (toon-client#494's `getClaimState()`, not present in this repo's pinned `@toon-protocol/client@0.25.1`). Per the issue's own Gotchas, income must come from the connector's claim state, never inferred by scanning NIP-90 job events client-side — so there is no honest workaround available in this repo's current state. Updated the Money tab's Network spend placeholder copy to state the net-flow model (income nets into the same balance) without fabricating a live read. Files changed: desktop/src/features/profile/lib/agentNetworkFlow.ts (new, +test), desktop/src/features/profile/ui/UserProfilePanelMoneyTab.tsx. Full local gate green: fmt-check, desktop-tauri-fmt-check, clippy, test-unit (864 tests), desktop-check, desktop-test (4209 passed), desktop-build, web-check, web-build. Blockers for next iteration: #80 (Network spend block: balance, burn-rate read via `onPaidWrite`, refill) must land first, and the per-agent claim-state read this ticket's income data depends on needs either a newer `@toon-protocol/client` with `getClaimState()` or the buzz-cli/relay-side plumbing to expose it to the desktop for an agent that is not the local wallet identity. Once either lands, `deriveNetworkRunway`/ `isEarning`/`shouldSuppressLowFundsAlert` are ready to consume it directly. Signed-off-by: Claude Sonnet 5 --- .../profile/lib/agentNetworkFlow.test.mjs | 117 +++++++++++++++ .../features/profile/lib/agentNetworkFlow.ts | 135 ++++++++++++++++++ .../profile/ui/UserProfilePanelMoneyTab.tsx | 3 +- 3 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 desktop/src/features/profile/lib/agentNetworkFlow.test.mjs create mode 100644 desktop/src/features/profile/lib/agentNetworkFlow.ts diff --git a/desktop/src/features/profile/lib/agentNetworkFlow.test.mjs b/desktop/src/features/profile/lib/agentNetworkFlow.test.mjs new file mode 100644 index 00000000000..3be3efa0b2d --- /dev/null +++ b/desktop/src/features/profile/lib/agentNetworkFlow.test.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + deriveNetworkRunway, + isEarning, + netSpendableBaseUnits, + networkRunwayCaption, + shouldSuppressLowFundsAlert, +} from "./agentNetworkFlow.ts"; + +/** + * Covers buzz#86: net flow (income beside spend, same channel) and a + * runway that accounts for income without lying about a single lucky job. + * Pure-function tests in the `paymentsOverview` / `huddleFeeQuote` mold — + * no DOM, no network. + */ + +const NO_INCOME = { + depositBaseUnits: 10_000_000n, + owedBaseUnits: 4_000_000n, + creditedBaseUnits: 0n, + burnRateBaseUnitsPerSec: 100, + incomeRateBaseUnitsPerSec: 0, + incomeSampleCount: 0, +}; + +test("net spendable nets income into the same channel, never a second pot", () => { + const read = { + ...NO_INCOME, + creditedBaseUnits: 2_000_000n, + }; + // deposit 10 - owed 4 + credited 2 = 8 + assert.equal(netSpendableBaseUnits(read), 8_000_000n); +}); + +test("net spendable floors at zero rather than going negative", () => { + const read = { + ...NO_INCOME, + depositBaseUnits: 1_000_000n, + owedBaseUnits: 1_500_000n, + }; + assert.equal(netSpendableBaseUnits(read), 0n); +}); + +test("a depleted balance reports depleted regardless of rates", () => { + const state = deriveNetworkRunway({ + ...NO_INCOME, + depositBaseUnits: 1_000_000n, + owedBaseUnits: 1_000_000n, + }); + assert.deepEqual(state, { kind: "depleted" }); + assert.match(networkRunwayCaption(state), /depleted/); +}); + +test("no income yet — runway is finite, driven by burn rate alone", () => { + const state = deriveNetworkRunway(NO_INCOME); + assert.equal(state.kind, "finite"); + if (state.kind === "finite") { + // 6,000,000 remaining / 100 base units per sec = 60,000s + assert.equal(state.runwaySeconds, 60_000); + } + assert.match(networkRunwayCaption(state), /runway left/); +}); + +test("a single payment does not flip runway to indefinite", () => { + // Income currently outpaces burn, but only one sample has ever landed — + // must not be trusted as self-funding yet. + const state = deriveNetworkRunway({ + ...NO_INCOME, + incomeRateBaseUnitsPerSec: 500, + incomeSampleCount: 1, + }); + assert.equal(state.kind, "finite"); + assert.equal(isEarning(NO_INCOME) || false, false); +}); + +test("sustained income that covers burn is self-funding — no depletion date", () => { + const read = { + ...NO_INCOME, + incomeRateBaseUnitsPerSec: 150, + incomeSampleCount: 5, + }; + const state = deriveNetworkRunway(read); + assert.deepEqual(state, { + kind: "self-funding", + remainingBaseUnits: 6_000_000n, + }); + assert.match(networkRunwayCaption(state), /no depletion date/); + assert.equal(isEarning(read), true); + assert.equal(shouldSuppressLowFundsAlert(read), true); +}); + +test("sustained income that only partially covers burn extends but does not erase runway", () => { + const read = { + ...NO_INCOME, + incomeRateBaseUnitsPerSec: 40, + incomeSampleCount: 4, + }; + const state = deriveNetworkRunway(read); + assert.equal(state.kind, "finite"); + if (state.kind === "finite") { + // net burn = 100 - 40 = 60/sec; 6,000,000 / 60 = 100,000s + assert.equal(state.runwaySeconds, 100_000); + } + assert.equal(isEarning(read), false); + assert.equal(shouldSuppressLowFundsAlert(read), false); +}); + +test("low-funds alert is not suppressed while income is unproven", () => { + const read = { + ...NO_INCOME, + incomeRateBaseUnitsPerSec: 500, + incomeSampleCount: 2, + }; + assert.equal(shouldSuppressLowFundsAlert(read), false); +}); diff --git a/desktop/src/features/profile/lib/agentNetworkFlow.ts b/desktop/src/features/profile/lib/agentNetworkFlow.ts new file mode 100644 index 00000000000..f9af995a220 --- /dev/null +++ b/desktop/src/features/profile/lib/agentNetworkFlow.ts @@ -0,0 +1,135 @@ +import { formatUsdcBaseUnits } from "@/features/onboarding/toon/toonOnboardingFormat"; + +/** + * Net-flow domain logic for the Money tab's Network spend block (buzz#86). + * + * toon-meta#262 decision 9 puts earning on the SAME channel an agent spends + * from: `spendable = deposit − owed + credited`, one balance, never a + * second "earnings" pot. #261 decision 4 modelled money as net flow for + * exactly this reason — earning lands here without a UI rewrite. + * + * This module is deliberately decoupled from any live data source. The + * connector claim-state read this feeds from (toon-client#494's + * `getClaimState()`) is not yet vendored in this repo's pinned + * `@toon-protocol/client` (0.25.1), and the Network spend block itself + * (#80 — balance/allowance/refill, `onPaidWrite` live spend) has not + * landed, so there is no per-agent channel read to attach a UI to yet. + * These are the pure derivations #80 and the AgentIdentityCard earning + * badge / low-funds alert can wire straight into once that read exists — + * mirrors `paymentsOverview.ts`'s pure-derivation-first idiom. + * + * Per the Gotchas, income here is never read from a self-reported + * money-report event — only from `NetworkFlowRead`, the shape a connector + * claim-state read produces. + */ + +/** + * A single lucky job must not flip runway to "indefinite" — that is a lie + * that strands someone once the job stream dries up. Require sustained + * income over several samples before treating an agent as self-funding. + */ +const MIN_INCOME_SAMPLES_TO_TRUST = 3; + +/** What a connector claim-state read reports for one agent's channel. */ +export type NetworkFlowRead = { + depositBaseUnits: bigint; + /** Claimed/spent against the deposit so far. */ + owedBaseUnits: bigint; + /** Earned into this same channel — never a separate balance. */ + creditedBaseUnits: bigint; + /** Trailing-window spend rate. */ + burnRateBaseUnitsPerSec: number; + /** Trailing-window income rate. */ + incomeRateBaseUnitsPerSec: number; + /** Distinct income events observed in the trailing window. */ + incomeSampleCount: number; +}; + +/** + * `spendable = deposit − owed + credited`, floored at zero — a stale or + * racy read must never show a negative balance. + */ +export function netSpendableBaseUnits(read: NetworkFlowRead): bigint { + const net = + read.depositBaseUnits - read.owedBaseUnits + read.creditedBaseUnits; + return net > 0n ? net : 0n; +} + +/** Whether `read`'s income has enough evidence behind it to be trusted. */ +function hasTrustedIncome(read: NetworkFlowRead): boolean { + return read.incomeSampleCount >= MIN_INCOME_SAMPLES_TO_TRUST; +} + +/** The runway half of the Network spend block. */ +export type NetworkRunwayState = + | { kind: "depleted" } + /** Trusted income covers or exceeds burn — no depletion date to show. */ + | { kind: "self-funding"; remainingBaseUnits: bigint } + | { kind: "finite"; remainingBaseUnits: bigint; runwaySeconds: number }; + +/** + * Derive runway from a net-flow read. Untrusted income (too few samples) + * is excluded from the burn-rate offset entirely, so an agent's runway + * degrades to "burn rate alone" — the honest, conservative default — + * until income has proven itself sustained. + */ +export function deriveNetworkRunway(read: NetworkFlowRead): NetworkRunwayState { + const remainingBaseUnits = netSpendableBaseUnits(read); + if (remainingBaseUnits <= 0n) return { kind: "depleted" }; + + const trustedIncomeRate = hasTrustedIncome(read) + ? read.incomeRateBaseUnitsPerSec + : 0; + const netBurnRateBaseUnitsPerSec = + read.burnRateBaseUnitsPerSec - trustedIncomeRate; + + if (netBurnRateBaseUnitsPerSec <= 0) { + return { kind: "self-funding", remainingBaseUnits }; + } + + const runwaySeconds = Number(remainingBaseUnits) / netBurnRateBaseUnitsPerSec; + return { kind: "finite", remainingBaseUnits, runwaySeconds }; +} + +function formatRunwayDuration(seconds: number): string { + if (seconds < 60) return "under a minute"; + const minutes = seconds / 60; + if (minutes < 60) return `${Math.round(minutes)} min`; + const hours = minutes / 60; + if (hours < 24) return `${Math.round(hours)} hr`; + const days = Math.round(hours / 24); + return `${days} day${days === 1 ? "" : "s"}`; +} + +/** The caption the Network spend block shows under the runway row. */ +export function networkRunwayCaption(state: NetworkRunwayState): string { + switch (state.kind) { + case "depleted": + return "Balance is depleted — writes will fail until it is topped up."; + case "self-funding": + return `${formatUsdcBaseUnits(state.remainingBaseUnits)} available — income is covering spend, so there's no depletion date.`; + case "finite": + return `${formatUsdcBaseUnits(state.remainingBaseUnits)} available — about ${formatRunwayDuration(state.runwaySeconds)} of runway left.`; + } +} + +/** + * Whether an agent pays for itself — the `AgentIdentityCard` earning badge + * predicate. Requires the same trusted-income bar as runway, so the fleet + * glance never claims self-funding off one job. + */ +export function isEarning(read: NetworkFlowRead): boolean { + return ( + hasTrustedIncome(read) && + read.incomeRateBaseUnitsPerSec >= read.burnRateBaseUnitsPerSec + ); +} + +/** + * A rescue prompt for a self-funding agent is noise that teaches people to + * ignore the alert — suppress it only once `deriveNetworkRunway` has + * actually concluded the agent is self-funding. + */ +export function shouldSuppressLowFundsAlert(read: NetworkFlowRead): boolean { + return deriveNetworkRunway(read).kind === "self-funding"; +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelMoneyTab.tsx b/desktop/src/features/profile/ui/UserProfilePanelMoneyTab.tsx index 8e33408ff68..14aad69a80c 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelMoneyTab.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelMoneyTab.tsx @@ -175,7 +175,8 @@ function NetworkSpendPlaceholder() {

Balance, runway, and refill will land here — never summed with model - usage above. + usage above. Any income this agent earns nets into this same balance; + there's no separate earnings account to check.

From 285569312e595e969fbd695078de42184859e873 Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Mon, 3 Aug 2026 22:38:36 +0000 Subject: [PATCH 2/2] fix(desktop): test the actual read in the single-payment isEarning assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "single payment does not flip runway to indefinite" test built its finite-runway state from a read with incomeSampleCount: 1, but then asserted isEarning(NO_INCOME) — the untouched base fixture with a zero income rate, which trivially returns false regardless of the sample-count logic under test. Assert against the same read used to derive the state. Signed-off-by: Claude Sonnet 5 --- desktop/src/features/profile/lib/agentNetworkFlow.test.mjs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/profile/lib/agentNetworkFlow.test.mjs b/desktop/src/features/profile/lib/agentNetworkFlow.test.mjs index 3be3efa0b2d..55707633b5c 100644 --- a/desktop/src/features/profile/lib/agentNetworkFlow.test.mjs +++ b/desktop/src/features/profile/lib/agentNetworkFlow.test.mjs @@ -66,13 +66,14 @@ test("no income yet — runway is finite, driven by burn rate alone", () => { test("a single payment does not flip runway to indefinite", () => { // Income currently outpaces burn, but only one sample has ever landed — // must not be trusted as self-funding yet. - const state = deriveNetworkRunway({ + const read = { ...NO_INCOME, incomeRateBaseUnitsPerSec: 500, incomeSampleCount: 1, - }); + }; + const state = deriveNetworkRunway(read); assert.equal(state.kind, "finite"); - assert.equal(isEarning(NO_INCOME) || false, false); + assert.equal(isEarning(read), false); }); test("sustained income that covers burn is self-funding — no depletion date", () => {