diff --git a/AGENTS.md b/AGENTS.md index 7ff0eb4d477..31be30c630b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -503,6 +503,7 @@ reconnects preserve pending avatar verification work): - `resetRenderScopedReactionHydration()` — reaction hydration cache - `clearSearchHitEventCache()` — search result event cache - `clearMarkdownNodeCache()` — markdown parse-node cache +- `resetNetworkSpendLiveStore()` — Money tab live burn-rate trailing window **If you add a new module-level cache, Map, or class instance that holds community-scoped data, you must add its reset to `resetCommunityState()`.** diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index afa69f913f6..097d3ae1fc6 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -28,6 +28,7 @@ import { resetAgentWorkingSignal } from "@/features/agents/agentWorkingSignal"; import { resetAgentObserverStore } from "@/features/agents/observerRelayStore"; import { resetAvatarPresentations } from "@/features/profile/avatarPresentationStore"; import { resetAvatarProfileSync } from "@/features/profile/avatarProfileSync"; +import { resetNetworkSpendLiveStore } from "@/features/profile/lib/networkSpendLiveStore"; import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; import { clearMarkdownNodeCache } from "@/shared/ui/markdown/nodeCache"; import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState"; @@ -69,6 +70,7 @@ function resetCommunityState({ resetRenderScopedReactionHydration(); clearSearchHitEventCache(); clearMarkdownNodeCache(); + resetNetworkSpendLiveStore(); } type CommunityInitResult = diff --git a/desktop/src/features/profile/lib/networkSpendLiveStore.test.mjs b/desktop/src/features/profile/lib/networkSpendLiveStore.test.mjs new file mode 100644 index 00000000000..6e92d0f4662 --- /dev/null +++ b/desktop/src/features/profile/lib/networkSpendLiveStore.test.mjs @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import test, { beforeEach } from "node:test"; + +import { + getNetworkSpendLiveSnapshot, + recordNetworkSpendWrite, + resetNetworkSpendLiveStore, + subscribeNetworkSpendLive, +} from "./networkSpendLiveStore.ts"; + +/** + * Covers #80's live-spend half: a module-level trailing-window store fed by + * `ToonEventTransport.onPaidWrite`, the burn-rate source + * `agentNetworkFlow.ts`'s `NetworkFlowRead.burnRateBaseUnitsPerSec` reads + * from for the currently active identity. + */ + +beforeEach(() => { + resetNetworkSpendLiveStore(); +}); + +test("no writes yet — no sample, zero rate", () => { + assert.deepEqual(getNetworkSpendLiveSnapshot(), { + burnRateBaseUnitsPerSec: 0, + hasSample: false, + }); +}); + +test("a write inside the window contributes to the burn rate", () => { + recordNetworkSpendWrite({ + eventId: "e1", + amount: 300n, + assetScale: 6, + asset: "USDC", + destination: "g.toon.relay", + }); + const snapshot = getNetworkSpendLiveSnapshot(); + assert.equal(snapshot.hasSample, true); + // 300 base units / 300s window = 1 base unit/sec. + assert.equal(snapshot.burnRateBaseUnitsPerSec, 1); +}); + +test("multiple writes inside the window sum together", () => { + recordNetworkSpendWrite({ + eventId: "e1", + amount: 300n, + assetScale: 6, + asset: "USDC", + destination: "g.toon.relay", + }); + recordNetworkSpendWrite({ + eventId: "e2", + amount: 600n, + assetScale: 6, + asset: "USDC", + destination: "g.toon.relay", + }); + const snapshot = getNetworkSpendLiveSnapshot(); + // (300 + 600) / 300s = 3 base units/sec. + assert.equal(snapshot.burnRateBaseUnitsPerSec, 3); +}); + +test("reset clears every recorded write", () => { + recordNetworkSpendWrite({ + eventId: "e1", + amount: 300n, + assetScale: 6, + asset: "USDC", + destination: "g.toon.relay", + }); + resetNetworkSpendLiveStore(); + assert.deepEqual(getNetworkSpendLiveSnapshot(), { + burnRateBaseUnitsPerSec: 0, + hasSample: false, + }); +}); + +test("subscribers are notified on write and on reset", () => { + let notifications = 0; + const unsubscribe = subscribeNetworkSpendLive(() => { + notifications += 1; + }); + + recordNetworkSpendWrite({ + eventId: "e1", + amount: 300n, + assetScale: 6, + asset: "USDC", + destination: "g.toon.relay", + }); + assert.equal(notifications, 1); + + resetNetworkSpendLiveStore(); + assert.equal(notifications, 2); + + unsubscribe(); + recordNetworkSpendWrite({ + eventId: "e2", + amount: 300n, + assetScale: 6, + asset: "USDC", + destination: "g.toon.relay", + }); + assert.equal(notifications, 2); +}); + +test("getNetworkSpendLiveSnapshot returns a stable reference when nothing changed", () => { + recordNetworkSpendWrite({ + eventId: "e1", + amount: 300n, + assetScale: 6, + asset: "USDC", + destination: "g.toon.relay", + }); + const first = getNetworkSpendLiveSnapshot(); + const second = getNetworkSpendLiveSnapshot(); + assert.equal(first, second); +}); diff --git a/desktop/src/features/profile/lib/networkSpendLiveStore.ts b/desktop/src/features/profile/lib/networkSpendLiveStore.ts new file mode 100644 index 00000000000..fc99ef134ef --- /dev/null +++ b/desktop/src/features/profile/lib/networkSpendLiveStore.ts @@ -0,0 +1,113 @@ +import * as React from "react"; + +import type { PaidWriteReceipt } from "@/shared/api/toonPaidWriter"; + +/** + * Live in-session network spend for the Money tab's Network spend block + * (#80) — module-level store + `useSyncExternalStore`, fed by + * `ToonEventTransport.onPaidWrite`, per the epic's established idiom + * (toon-meta#261). Feeds `burnRateBaseUnitsPerSec` into + * `agentNetworkFlow.ts`'s `NetworkFlowRead` from this session's own + * observed spend — the one burn signal available without the connector's + * claim-state history (which reports a position, not a rate). + * + * Trailing-window, not cumulative: a write from ten minutes ago says + * nothing about the CURRENT burn rate, so old receipts age out rather than + * dragging the average down forever. + */ + +const WINDOW_MS = 5 * 60 * 1000; + +type Receipt = { amountBaseUnits: bigint; atMs: number }; + +export type LiveSpendSnapshot = { + /** Sum of `amountBaseUnits` still inside the trailing window, / window length. */ + burnRateBaseUnitsPerSec: number; + /** Whether any write has landed in the trailing window — see networkSpendState.ts's "not yet measured" caption. */ + hasSample: boolean; +}; + +const EMPTY_SNAPSHOT: LiveSpendSnapshot = { + burnRateBaseUnitsPerSec: 0, + hasSample: false, +}; + +let receipts: Receipt[] = []; +// Referentially stable until the computed rate actually changes — +// `useSyncExternalStore` requires `getSnapshot` to return the same +// reference when nothing changed, or React logs an infinite-loop warning +// (CONTRIBUTING.md's React-perf gotcha: a fresh object every call defeats +// consumers just as surely as `React.memo` would be defeated by one). +let cachedSnapshot: LiveSpendSnapshot = EMPTY_SNAPSHOT; +const listeners = new Set<() => void>(); + +function notify() { + for (const listener of listeners) listener(); +} + +/** Drop expired receipts and refresh `cachedSnapshot`, preserving its reference when the computed value is unchanged. */ +function refreshCachedSnapshot(nowMs: number) { + const cutoff = nowMs - WINDOW_MS; + receipts = receipts.filter((receipt) => receipt.atMs >= cutoff); + + const next: LiveSpendSnapshot = + receipts.length === 0 + ? EMPTY_SNAPSHOT + : { + burnRateBaseUnitsPerSec: + Number( + receipts.reduce( + (sum, receipt) => sum + receipt.amountBaseUnits, + 0n, + ), + ) / + (WINDOW_MS / 1000), + hasSample: true, + }; + + if ( + next.hasSample !== cachedSnapshot.hasSample || + next.burnRateBaseUnitsPerSec !== cachedSnapshot.burnRateBaseUnitsPerSec + ) { + cachedSnapshot = next; + } +} + +/** Record one paid write. Registered on `ToonEventTransport.onPaidWrite` when TOON installs. */ +export function recordNetworkSpendWrite(receipt: PaidWriteReceipt): void { + receipts.push({ amountBaseUnits: receipt.amount, atMs: Date.now() }); + refreshCachedSnapshot(Date.now()); + notify(); +} + +export function subscribeNetworkSpendLive(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** + * Current trailing-window snapshot. Stale entries age out lazily, on the + * next call — a rate that stops updating because writes stopped is exactly + * the "burn rate dropped to zero" case, so a caller (or a future refresh + * affordance) re-reading this is what surfaces the decay; there is no + * background timer here. + */ +export function getNetworkSpendLiveSnapshot(): LiveSpendSnapshot { + refreshCachedSnapshot(Date.now()); + return cachedSnapshot; +} + +/** Community-switch reset (see resetCommunityState in useCommunityInit) — a new relay is a new channel, a new burn rate. */ +export function resetNetworkSpendLiveStore(): void { + receipts = []; + cachedSnapshot = EMPTY_SNAPSHOT; + notify(); +} + +/** Live burn-rate snapshot for the currently active TOON identity's channel. */ +export function useNetworkSpendLive(): LiveSpendSnapshot { + return React.useSyncExternalStore( + subscribeNetworkSpendLive, + getNetworkSpendLiveSnapshot, + ); +} diff --git a/desktop/src/features/profile/lib/networkSpendState.test.mjs b/desktop/src/features/profile/lib/networkSpendState.test.mjs new file mode 100644 index 00000000000..327ef842602 --- /dev/null +++ b/desktop/src/features/profile/lib/networkSpendState.test.mjs @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + canRefillNetworkSpend, + deriveNetworkSpendState, + formatBurnRatePerMinute, + networkSpendRunwayCaption, +} from "./networkSpendState.ts"; + +/** + * Covers #80: the Network spend block's discriminated union + * (relay | pending | unavailable | quoted), mirroring the huddle fee + * quote's shape, plus "can I act" answered separately from `kind`. + */ + +const NO_LIVE = { burnRateBaseUnitsPerSec: 0, hasSample: false }; +const RAW = { + channelId: "channel-1", + depositTotalBaseUnits: 10_000_000n, + cumulativeClaimedBaseUnits: 4_000_000n, + source: "claim-state", +}; + +test("not on TOON transport reads as relay, regardless of anything else", () => { + const state = deriveNetworkSpendState({ + isToon: false, + isSelf: true, + raw: RAW, + live: NO_LIVE, + }); + assert.deepEqual(state, { kind: "relay" }); + assert.equal(canRefillNetworkSpend(state), false); +}); + +test("TOON active but viewing another agent — no per-agent read exists, so unavailable", () => { + const state = deriveNetworkSpendState({ + isToon: true, + isSelf: false, + raw: RAW, + live: NO_LIVE, + }); + assert.deepEqual(state, { kind: "unavailable" }); +}); + +test("read in flight reports pending", () => { + const state = deriveNetworkSpendState({ + isToon: true, + isSelf: true, + raw: "pending", + live: NO_LIVE, + }); + assert.deepEqual(state, { kind: "pending" }); + assert.equal(canRefillNetworkSpend(state), false); +}); + +test("no channel ever opened for this identity reports unavailable, never blank", () => { + const state = deriveNetworkSpendState({ + isToon: true, + isSelf: true, + raw: null, + live: NO_LIVE, + }); + assert.deepEqual(state, { kind: "unavailable" }); +}); + +test("a real read quotes the block and carries its source through", () => { + const state = deriveNetworkSpendState({ + isToon: true, + isSelf: true, + raw: RAW, + live: { burnRateBaseUnitsPerSec: 2, hasSample: true }, + }); + assert.equal(state.kind, "quoted"); + if (state.kind !== "quoted") return; + assert.equal(state.source, "claim-state"); + assert.equal(state.hasBurnSample, true); + assert.equal(state.read.depositBaseUnits, 10_000_000n); + assert.equal(state.read.owedBaseUnits, 4_000_000n); + assert.equal(state.read.creditedBaseUnits, 0n); + assert.equal(state.read.burnRateBaseUnitsPerSec, 2); + assert.equal(canRefillNetworkSpend(state), true); +}); + +test("a claim-state failure degrades to the local source, still quoted, never blank", () => { + const state = deriveNetworkSpendState({ + isToon: true, + isSelf: true, + raw: { ...RAW, source: "local" }, + live: NO_LIVE, + }); + assert.equal(state.kind, "quoted"); + if (state.kind !== "quoted") return; + assert.equal(state.source, "local"); +}); + +test("runway caption says burn hasn't been measured yet when there is no sample", () => { + const read = { + depositBaseUnits: 10_000_000n, + owedBaseUnits: 4_000_000n, + creditedBaseUnits: 0n, + burnRateBaseUnitsPerSec: 0, + incomeRateBaseUnitsPerSec: 0, + incomeSampleCount: 0, + }; + const caption = networkSpendRunwayCaption(read, false); + assert.match(caption, /hasn't been measured yet/); + assert.match(caption, /6\.00/); // 10 - 4 = 6 USDC spendable +}); + +test("runway caption defers to the real runway derivation once a burn sample exists", () => { + const read = { + depositBaseUnits: 10_000_000n, + owedBaseUnits: 4_000_000n, + creditedBaseUnits: 0n, + burnRateBaseUnitsPerSec: 100, + incomeRateBaseUnitsPerSec: 0, + incomeSampleCount: 0, + }; + const caption = networkSpendRunwayCaption(read, true); + assert.match(caption, /runway left/); +}); + +test("burn rate formats as a per-minute USDC caption", () => { + // 100 base units/sec * 60 = 6000 base units/min = 0.006 USDC/min. + assert.match(formatBurnRatePerMinute(100), /\/min$/); +}); diff --git a/desktop/src/features/profile/lib/networkSpendState.ts b/desktop/src/features/profile/lib/networkSpendState.ts new file mode 100644 index 00000000000..6095f0a719d --- /dev/null +++ b/desktop/src/features/profile/lib/networkSpendState.ts @@ -0,0 +1,112 @@ +import { formatUsdcBaseUnits } from "@/features/onboarding/toon/toonOnboardingFormat"; +import { + deriveNetworkRunway, + netSpendableBaseUnits, + networkRunwayCaption, + type NetworkFlowRead, +} from "@/features/profile/lib/agentNetworkFlow"; +import type { LiveSpendSnapshot } from "@/features/profile/lib/networkSpendLiveStore"; +import type { RawNetworkFlowStatus } from "@/shared/api/toonPaidWriter"; + +/** + * Budget state for the Money tab's Network spend block (#80). + * + * Mirrors the huddle fee quote's discriminated union + * (`relay | pending | unavailable | quoted`) rather than a pile of nullable + * numbers — the same shape `paymentsOverview.ts`'s `PaymentsCardState` + * already uses for the sibling Settings -> Payments card. "Can the refill + * action run" is answered separately by {@link canRefillNetworkSpend}, + * never by matching on `kind` at each call site. + */ +export type NetworkSpendState = + /** Not on TOON transport — nothing here costs money. */ + | { kind: "relay" } + /** TOON is active; the channel read is in flight. */ + | { kind: "pending" } + /** + * TOON is active but there is nothing to read: viewing an agent other + * than the identity this desktop process pays as (no per-agent channel + * read exists yet — see the module doc), or this identity has never + * opened a channel. + */ + | { kind: "unavailable" } + | { + kind: "quoted"; + read: NetworkFlowRead; + /** Where the deposit/owed pair came from — connector-verified or this client's own tracked watermark. */ + source: "claim-state" | "local"; + /** Whether any spend has been observed this session — see {@link networkSpendRunwayCaption}. */ + hasBurnSample: boolean; + }; + +/** + * Combine a channel read with the live burn-rate snapshot into the block's + * state. `isSelf` gates the whole per-agent read: this desktop process only + * ever holds a live `ToonClient` for the identity it itself pays as (its own + * wallet, account index 0) — a managed agent's own `toon-clientd` sidecar + * tracks its channel independently, and nothing here can read that + * remotely yet (no spawn/lifecycle for those daemons exists — buzz#79's ADR + * 0006 — and no account-index lookup is exposed to the frontend). That is a + * real architectural gap, not a state this function papers over: any other + * agent honestly reads `unavailable`. + */ +export function deriveNetworkSpendState(input: { + isToon: boolean; + isSelf: boolean; + raw: RawNetworkFlowStatus | null | "pending"; + live: LiveSpendSnapshot; +}): NetworkSpendState { + if (!input.isToon) return { kind: "relay" }; + if (!input.isSelf) return { kind: "unavailable" }; + if (input.raw === "pending") return { kind: "pending" }; + if (input.raw === null) return { kind: "unavailable" }; + + const read: NetworkFlowRead = { + depositBaseUnits: input.raw.depositTotalBaseUnits, + owedBaseUnits: input.raw.cumulativeClaimedBaseUnits, + // No income source is wired here (#80's scope is spend, not earning) — + // buzz#86's agentNetworkFlow.ts already defaults an absent income to a + // plain burn-rate-driven runway, so this stays honest rather than + // fabricating a credited figure. + creditedBaseUnits: 0n, + burnRateBaseUnitsPerSec: input.live.burnRateBaseUnitsPerSec, + incomeRateBaseUnitsPerSec: 0, + incomeSampleCount: 0, + }; + + return { + kind: "quoted", + read, + source: input.raw.source, + hasBurnSample: input.live.hasSample, + }; +} + +/** Whether the refill action can run — the separate "can I act" answer the state's `kind` alone must not be read as. */ +export function canRefillNetworkSpend(state: NetworkSpendState): boolean { + return state.kind === "quoted"; +} + +/** + * The runway caption the block shows under balance/allowance. Burn rate is + * observed only from this session's own writes (`networkSpendLiveStore.ts`) + * — with none yet, claiming a runway (finite OR self-funding) would be a + * guess dressed as a reading, so this says so instead of calling + * `deriveNetworkRunway` on a zero rate it would otherwise read as + * "self-funding". + */ +export function networkSpendRunwayCaption( + read: NetworkFlowRead, + hasBurnSample: boolean, +): string { + if (!hasBurnSample) { + return `${formatUsdcBaseUnits(netSpendableBaseUnits(read))} available — burn rate hasn't been measured yet this session.`; + } + return networkRunwayCaption(deriveNetworkRunway(read)); +} + +/** Render a per-second base-unit rate as a per-minute caption, matching the huddle fee quote's per-minute framing. */ +export function formatBurnRatePerMinute(baseUnitsPerSec: number): string { + const perMinuteBaseUnits = BigInt(Math.round(baseUnitsPerSec * 60)); + return `${formatUsdcBaseUnits(perMinuteBaseUnits)}/min`; +} diff --git a/desktop/src/features/profile/lib/useNetworkSpend.ts b/desktop/src/features/profile/lib/useNetworkSpend.ts new file mode 100644 index 00000000000..5a14d30f977 --- /dev/null +++ b/desktop/src/features/profile/lib/useNetworkSpend.ts @@ -0,0 +1,98 @@ +import * as React from "react"; + +import { + canRefillNetworkSpend, + deriveNetworkSpendState, + type NetworkSpendState, +} from "@/features/profile/lib/networkSpendState"; +import { useNetworkSpendLive } from "@/features/profile/lib/networkSpendLiveStore"; +import { + getActiveToonTransport, + getActiveTransportSelection, +} from "@/shared/api/transportSelection"; +import type { RawNetworkFlowStatus } from "@/shared/api/toonPaidWriter"; + +/** + * Wires `networkSpendState.ts`'s pure derivation to the network reads and + * the refill action the Money tab's Network spend block needs (#80). + * Follows `usePaymentsOverview.ts`'s shape: an explicit, re-triggerable + * `refresh` rather than a background poll, so a tab a user glances at and + * leaves does not keep spending the connector's attention. + * + * `isSelf` gates every network read — see `networkSpendState.ts`'s module + * doc for why only the identity this desktop process itself pays as has a + * channel to read at all today. + */ +export function useNetworkSpend(isSelf: boolean) { + const selection = getActiveTransportSelection(); + const isToon = selection?.mode === "toon"; + const live = useNetworkSpendLive(); + + const [raw, setRaw] = React.useState( + "pending", + ); + const [refreshing, setRefreshing] = React.useState(false); + const [depositPending, setDepositPending] = React.useState(false); + const [depositError, setDepositError] = React.useState(null); + + const refresh = React.useCallback(async () => { + if (!isToon || !isSelf) { + setRaw(null); + return; + } + setRefreshing(true); + try { + const status = + (await getActiveToonTransport() + ?.getPaidWriter() + .getNetworkFlowStatus()) ?? null; + setRaw(status); + } catch (error) { + console.error("[network-spend] refresh failed", error); + setRaw(null); + } finally { + setRefreshing(false); + } + }, [isToon, isSelf]); + + React.useEffect(() => { + void refresh(); + }, [refresh]); + + const deposit = React.useCallback( + async (amountBaseUnits: bigint): Promise => { + const writer = getActiveToonTransport()?.getPaidWriter(); + if (!writer) return false; + setDepositError(null); + setDepositPending(true); + try { + await writer.depositToChannel(amountBaseUnits); + await refresh(); + return true; + } catch (error) { + setDepositError(error instanceof Error ? error.message : String(error)); + return false; + } finally { + setDepositPending(false); + } + }, + [refresh], + ); + + const state: NetworkSpendState = deriveNetworkSpendState({ + isToon, + isSelf, + raw, + live, + }); + + return { + state, + refresh, + refreshing, + deposit, + canDeposit: canRefillNetworkSpend(state), + depositPending, + depositError, + }; +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelMoneyTab.tsx b/desktop/src/features/profile/ui/UserProfilePanelMoneyTab.tsx index 14aad69a80c..caaacc25fb4 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelMoneyTab.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelMoneyTab.tsx @@ -1,4 +1,4 @@ -import { CircleDollarSign, Coins, Sparkles, Wallet } from "lucide-react"; +import { CircleDollarSign, Coins, Sparkles } from "lucide-react"; import { type AgentModelUsageSummary, @@ -10,22 +10,25 @@ import { type ProfileField, ProfileFieldGroup, } from "@/features/profile/ui/UserProfilePanelFields"; +import { NetworkSpendSection } from "@/features/profile/ui/UserProfilePanelNetworkSpend"; /** - * Money tab (buzz#75): two blocks, deliberately never summed into one number. + * Money tab (buzz#75 + #80): two blocks, deliberately never summed into one + * number. * - * - Model usage (this ticket): LLM tokens, postpaid, estimated — billed to the - * owner's own provider account, where buzz cannot see it. No refill - * affordance; there is nothing here buzz could refill. - * - Network spend (#80): USDC, prepaid, exact, enforcing — the only block - * that gets a refill action. Reserved as a sibling section below so #80 - * lands beside this one rather than retrofitting the layout. + * - Model usage: LLM tokens, postpaid, estimated — billed to the owner's own + * provider account, where buzz cannot see it. No refill affordance; there + * is nothing here buzz could refill. + * - Network spend (this ticket): USDC, prepaid, exact, enforcing — the only + * block that gets a refill action. */ export function ProfileMoneyTabContent({ agentPubkey, + isSelf, ownerPubkey, }: { agentPubkey: string; + isSelf: boolean; ownerPubkey: string | null; }) { const usageQuery = useAgentModelUsageQuery(agentPubkey, ownerPubkey); @@ -37,7 +40,7 @@ export function ProfileMoneyTabContent({ isPending={usageQuery.isPending} summary={usageQuery.data ?? null} /> - + ); } @@ -158,27 +161,3 @@ function ModelUsageSection({ ); } - -function NetworkSpendPlaceholder() { - return ( -
-

- Network spend - - Coming soon - -

-
- -

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

-
-
- ); -} diff --git a/desktop/src/features/profile/ui/UserProfilePanelNetworkSpend.tsx b/desktop/src/features/profile/ui/UserProfilePanelNetworkSpend.tsx new file mode 100644 index 00000000000..3482eff0f33 --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePanelNetworkSpend.tsx @@ -0,0 +1,192 @@ +import * as React from "react"; +import { Clock, Flame, Landmark, Wallet } from "lucide-react"; + +import { formatUsdcBaseUnits } from "@/features/onboarding/toon/toonOnboardingFormat"; +import { netSpendableBaseUnits } from "@/features/profile/lib/agentNetworkFlow"; +import { + formatBurnRatePerMinute, + networkSpendRunwayCaption, + type NetworkSpendState, +} from "@/features/profile/lib/networkSpendState"; +import { parseUsdcAmount } from "@/features/payments/lib/paymentsOverview"; +import { useNetworkSpend } from "@/features/profile/lib/useNetworkSpend"; +import { + type ProfileField, + ProfileFieldGroup, +} from "@/features/profile/ui/UserProfilePanelFields"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; + +/** + * Network spend block (#80) — USDC, prepaid, exact, enforcing. Sibling to + * the Model usage block (buzz#75) in the Money tab, never summed with it: + * different physics, different verbs, and a refill would not help the + * other one (see `UserProfilePanelMoneyTab.tsx`'s module doc). + * + * Only the identity this desktop process itself pays as (`isSelf`) has a + * channel to read — see `networkSpendState.ts`'s module doc for the + * architectural reason a managed agent's own spend cannot be read + * remotely yet. + */ +export function NetworkSpendSection({ isSelf }: { isSelf: boolean }) { + const network = useNetworkSpend(isSelf); + + return ( +
+

+ Network spend +

+ +

+ USDC, prepaid, and exact — this balance empties and stays empty until + topped up. Any income this agent earns nets into the same balance; + there's no separate earnings account. +

+
+ ); +} + +function NetworkSpendNotice({ + children, + testId, +}: { + children: React.ReactNode; + testId: string; +}) { + return ( +
+ +

{children}

+
+ ); +} + +function NetworkSpendBody({ + isSelf, + network, +}: { + isSelf: boolean; + network: ReturnType; +}) { + const state = network.state; + + switch (state.kind) { + case "relay": + return ( + + This community isn't using paid transport — nothing here costs money. + + ); + case "pending": + return ( + + Checking network spend… + + ); + case "unavailable": + return ( + + {isSelf + ? "No payment channel is open yet — it opens automatically on the first paid write." + : "This agent's network spend can't be read from this device yet — only your own wallet's channel is."} + + ); + case "quoted": + return ; + } +} + +function buildNetworkSpendFields( + state: Extract, +): ProfileField[] { + return [ + { + displayValue: formatUsdcBaseUnits(netSpendableBaseUnits(state.read)), + icon: Wallet, + label: "Balance", + testId: "user-profile-money-network-balance", + }, + { + displayValue: formatUsdcBaseUnits(state.read.depositBaseUnits), + icon: Landmark, + label: "Allowance", + testId: "user-profile-money-network-allowance", + }, + { + displayValue: networkSpendRunwayCaption(state.read, state.hasBurnSample), + icon: Clock, + label: "Runway", + testId: "user-profile-money-network-runway", + }, + { + displayValue: state.hasBurnSample + ? formatBurnRatePerMinute(state.read.burnRateBaseUnitsPerSec) + : "Not yet measured", + icon: Flame, + label: "Burn rate", + testId: "user-profile-money-network-burn-rate", + }, + ]; +} + +function NetworkSpendReady({ + network, + state, +}: { + network: ReturnType; + state: Extract; +}) { + const [depositInput, setDepositInput] = React.useState(""); + const fields = buildNetworkSpendFields(state); + + return ( +
+ {network.depositError ? ( +

+ {network.depositError} +

+ ) : null} + + + + {network.canDeposit ? ( +
+ setDepositInput(event.target.value)} + placeholder="Amount in USDC" + value={depositInput} + /> + +
+ ) : null} +
+ ); +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index fa11ce5252e..c6c1a204ee4 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -118,10 +118,7 @@ export type ProfileSummaryViewProps = { type RuntimeTabStatus = "running" | "stopped" | "error"; -const PROFILE_HERO_SPACING = { - "0": 0, - "6": 24, -} as const; +const PROFILE_HERO_SPACING = { "0": 0, "6": 24 } as const; const PROFILE_HERO_PRESENCE_BADGE = { cutout: { cx: 68, cy: 68, r: 15 }, @@ -468,6 +465,7 @@ export function ProfileSummaryView({ {activeTab === "money" && pubkey ? ( ) : null} diff --git a/desktop/src/shared/api/toonPaidWriter.test.mjs b/desktop/src/shared/api/toonPaidWriter.test.mjs index a56a4581ddc..b689951223e 100644 --- a/desktop/src/shared/api/toonPaidWriter.test.mjs +++ b/desktop/src/shared/api/toonPaidWriter.test.mjs @@ -348,6 +348,75 @@ test("payFactoryJobIncrement throws when accepted but no fulfillment came back ); }); +test("getNetworkFlowStatus returns null when no channel has ever opened", async () => { + const client = scriptedClient(); + const writer = writerOver(client); + + assert.equal(await writer.getNetworkFlowStatus(), null); +}); + +test("getNetworkFlowStatus prefers a verified claim-state read over the local watermark", async () => { + const client = scriptedClient({ + getChannelDepositTotal: () => 999n, + getChannelCumulativeAmount: () => 999n, + getClaimState: (channelIds) => + Promise.resolve( + channelIds.map(() => ({ + ok: true, + depositTotal: "10000000", + cumulativeClaimed: "4000000", + })), + ), + }); + const writer = writerOver(client); + await writer.publish(EVENT); + + const status = await writer.getNetworkFlowStatus(); + assert.equal(status.source, "claim-state"); + assert.equal(status.depositTotalBaseUnits, 10_000_000n); + assert.equal(status.cumulativeClaimedBaseUnits, 4_000_000n); +}); + +test("getNetworkFlowStatus falls back to the local watermark when the client has no getClaimState", async () => { + const client = scriptedClient({ + getChannelDepositTotal: () => 5_000_000n, + getChannelCumulativeAmount: () => 1_000_000n, + }); + const writer = writerOver(client); + await writer.publish(EVENT); + + const status = await writer.getNetworkFlowStatus(); + assert.equal(status.source, "local"); + assert.equal(status.depositTotalBaseUnits, 5_000_000n); + assert.equal(status.cumulativeClaimedBaseUnits, 1_000_000n); +}); + +test("getNetworkFlowStatus falls back to the local watermark when claim-state is unreachable", async () => { + const client = scriptedClient({ + getChannelDepositTotal: () => 5_000_000n, + getChannelCumulativeAmount: () => 1_000_000n, + getClaimState: () => Promise.reject(new Error("connector unreachable")), + }); + const writer = writerOver(client); + await writer.publish(EVENT); + + const status = await writer.getNetworkFlowStatus(); + assert.equal(status.source, "local"); +}); + +test("getNetworkFlowStatus falls back to the local watermark when the connector could not verify the challenge", async () => { + const client = scriptedClient({ + getChannelDepositTotal: () => 5_000_000n, + getChannelCumulativeAmount: () => 1_000_000n, + getClaimState: () => Promise.resolve([{ ok: false }]), + }); + const writer = writerOver(client); + await writer.publish(EVENT); + + const status = await writer.getNetworkFlowStatus(); + assert.equal(status.source, "local"); +}); + test("the client is built for the BTP session by default (buzz#23 stage 2)", () => { // `proxyUrl` must NOT be among the fields: the real client prefers the // stateless HTTP transport whenever a proxyUrl is present, which caps paid diff --git a/desktop/src/shared/api/toonPaidWriter.ts b/desktop/src/shared/api/toonPaidWriter.ts index 2213f764e2a..f6a9898429c 100644 --- a/desktop/src/shared/api/toonPaidWriter.ts +++ b/desktop/src/shared/api/toonPaidWriter.ts @@ -41,6 +41,19 @@ export type PaidWriteReceipt = { export type PaidWriteListener = (receipt: PaidWriteReceipt) => void; +/** + * What {@link ToonPaidWriter.getNetworkFlowStatus} reads for one channel — + * deposit and cumulative-claimed, tagged with where the read came from + * (`"claim-state"` when the connector verified it, `"local"` for this + * client's own tracked watermark). + */ +export type RawNetworkFlowStatus = { + channelId: string; + depositTotalBaseUnits: bigint; + cumulativeClaimedBaseUnits: bigint; + source: "claim-state" | "local"; +}; + /** * What paying one factory-job increment (buzz#85) settles: the fulfillment * IS the artifact's decryption key, per `docs/factory-job-protocol.md` §4.2 @@ -88,6 +101,25 @@ export class ToonPaidWriteError extends Error { } } +/** + * The one shape `getNetworkFlowStatus` reads off a connector claim-state + * answer, as `ToonClient.getClaimState` (toon-client#494) returns it — hand- + * rolled rather than imported from `@toon-protocol/client`'s own + * `ClaimStateResult` type, same reason every other `PaidClient` method + * below is a hand-written subset: `getClaimState` ships in + * `@toon-protocol/client@0.26.0`, and buzz is still pinned to `^0.25.1` + * (toon-client#494 is not vendored yet — see `getNetworkFlowStatus`'s doc). + * Only the fields this module reads are named. + */ +type ClaimStateReadResult = + | { + ok: true; + /** `null` for a channel the connector only declared, never funded. */ + depositTotal: string | null; + cumulativeClaimed: string; + } + | { ok: false }; + /** * A signed balance proof, as `ToonClient.signBalanceProof` returns it — * carried through to `publishEvent`'s `claim` option unmodified. Only the @@ -197,6 +229,21 @@ type PaidClient = { /** Where a tracked channel sits in the withdraw journey. */ getChannelCloseState(channelId: string): ChannelCloseState; getSettleableAt(channelId: string): bigint | undefined; + /** + * Connector-verified deposit/cumulative-claimed for tracked channels + * (toon-client#494) — the runway source of truth (toon-meta#261 decision + * 5), correct even when this client's own local watermark has drifted. + * Optional: buzz's pinned `@toon-protocol/client@^0.25.1` predates + * toon-client#494 (it lands in 0.26.0), so no real client build supplies + * this yet — callers fall back to the locally-tracked + * `getChannelDepositTotal`/`getChannelCumulativeAmount` until the pin + * bumps. Kept on the interface (and wired below) so that bump is the only + * remaining step once it's unblocked; see `getNetworkFlowStatus`. + */ + getClaimState?( + channelIds?: string[], + opts?: { expiresInSeconds?: number }, + ): Promise; /** Add collateral to an open channel. `amount` is the delta, base units. */ depositToChannel( channelId: string, @@ -614,6 +661,66 @@ export class ToonPaidWriter { }; } + /** + * The deposit/owed pair the Money tab's Network spend block (#80) reads, + * or `null` when no channel has ever opened for this destination — never + * opens one as a side effect (same guard as {@link getChannelStatus}). + * + * Prefers the connector's claim-state endpoint (toon-meta#261 decision 5 — + * the runway source of truth, correct even if this client's own watermark + * has drifted). A connector that is unreachable, a `PaidClient` build that + * predates `getClaimState`, or a response the connector could not verify + * (`ok: false`) all fall back to this client's own locally-tracked read — + * the "always-available free floor" decision 5 also calls for — so the + * block degrades gracefully instead of going blank. + */ + async getNetworkFlowStatus(): Promise { + if (!this.hasChannel()) return null; + const { client, channelId } = await this.requireChannelId(); + + const verified = await this.tryClaimState(client, channelId); + if (verified) { + return { channelId, source: "claim-state", ...verified }; + } + return { + channelId, + source: "local", + depositTotalBaseUnits: client.getChannelDepositTotal(channelId), + cumulativeClaimedBaseUnits: client.getChannelCumulativeAmount(channelId), + }; + } + + /** + * Ask the connector for `channelId`'s verified position. Never throws — + * every failure (no `getClaimState` on this client build, an unreachable + * connector, or a challenge the connector could not verify) reads as "no + * verified answer", which {@link getNetworkFlowStatus} treats as a signal + * to fall back to the local read, not as an error to surface. + */ + private async tryClaimState( + client: PaidClient, + channelId: string, + ): Promise<{ + depositTotalBaseUnits: bigint; + cumulativeClaimedBaseUnits: bigint; + } | null> { + if (!client.getClaimState) return null; + try { + const [result] = await client.getClaimState([channelId]); + if (!result?.ok || result.depositTotal === null) return null; + return { + depositTotalBaseUnits: BigInt(result.depositTotal), + cumulativeClaimedBaseUnits: BigInt(result.cumulativeClaimed), + }; + } catch (error) { + console.warn( + "[toon] claim-state read failed — falling back to the local channel record", + error, + ); + return null; + } + } + /** Add collateral to the open channel. Throws if none is open. */ async depositToChannel( amountBaseUnits: bigint, diff --git a/desktop/src/shared/api/transportSelection.ts b/desktop/src/shared/api/transportSelection.ts index a204e6675d0..50ea45818af 100644 --- a/desktop/src/shared/api/transportSelection.ts +++ b/desktop/src/shared/api/transportSelection.ts @@ -1,4 +1,5 @@ import { getStoredMnemonic } from "@/features/onboarding/toon/toonOnboardingStore"; +import { recordNetworkSpendWrite } from "@/features/profile/lib/networkSpendLiveStore"; import { setEventTransport } from "@/shared/api/eventTransport"; import { resetMediaUploader, setMediaUploader } from "@/shared/api/mediaUpload"; import { StoreMediaUploader } from "@/shared/api/storeMediaUploader"; @@ -107,6 +108,9 @@ export async function installSelectedTransport(): Promise { setEventTransport(transport); setArweaveGateways(selection.config.arweaveGateways); setMediaUploader(new StoreMediaUploader(transport.getPaidWriter())); + // Feeds the Money tab's Network spend burn rate (#80) — module-level + // store + useSyncExternalStore per the epic's established idiom. + transport.onPaidWrite(recordNetworkSpendWrite); activeToonTransport = transport; console.info( `[transport] TOON active — paying ${selection.config.destination} via ${