Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.**
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/features/communities/useCommunityInit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -69,6 +70,7 @@ function resetCommunityState({
resetRenderScopedReactionHydration();
clearSearchHitEventCache();
clearMarkdownNodeCache();
resetNetworkSpendLiveStore();
}

type CommunityInitResult =
Expand Down
118 changes: 118 additions & 0 deletions desktop/src/features/profile/lib/networkSpendLiveStore.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
113 changes: 113 additions & 0 deletions desktop/src/features/profile/lib/networkSpendLiveStore.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
127 changes: 127 additions & 0 deletions desktop/src/features/profile/lib/networkSpendState.test.mjs
Original file line number Diff line number Diff line change
@@ -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$/);
});
Loading
Loading