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
22 changes: 13 additions & 9 deletions desktop/src/features/profile/lib/agentNetworkFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,19 @@ import { formatUsdcBaseUnits } from "@/features/onboarding/toon/toonOnboardingFo
* 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.
* This module started deliberately decoupled from any live data source and
* stayed pure by design (mirrors `paymentsOverview.ts`'s
* pure-derivation-first idiom) — the connector claim-state read it feeds
* from (toon-client#494's `getClaimState()`) now IS live, wired through
* `ToonPaidWriter.getNetworkFlowStatus()` and `networkSpendState.ts`
* (buzz#80, buzz#108), for the identity this desktop process pays as. The
* `creditedBaseUnits` half of a `NetworkFlowRead` comes straight from that
* read; `incomeRateBaseUnitsPerSec`/`incomeSampleCount` remain unwired —
* no live event feed exists for inbound payments (only
* `networkSpendLiveStore.ts`'s outbound `onPaidWrite`) — so runway still
* degrades to burn-only until that lands. A per-agent read for any identity
* other than `isSelf` is still absent (buzz#79's ADR 0006 — no
* `toon-clientd` spawn/lifecycle for managed agents yet).
*
* Per the Gotchas, income here is never read from a self-reported
* money-report event — only from `NetworkFlowRead`, the shape a connector
Expand Down
13 changes: 13 additions & 0 deletions desktop/src/features/profile/lib/networkSpendState.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const RAW = {
channelId: "channel-1",
depositTotalBaseUnits: 10_000_000n,
cumulativeClaimedBaseUnits: 4_000_000n,
creditedBaseUnits: 0n,
source: "claim-state",
};

Expand Down Expand Up @@ -82,6 +83,18 @@ test("a real read quotes the block and carries its source through", () => {
assert.equal(canRefillNetworkSpend(state), true);
});

test("a credited amount on the claim-state read feeds straight into the quoted state (buzz#108)", () => {
const state = deriveNetworkSpendState({
isToon: true,
isSelf: true,
raw: { ...RAW, creditedBaseUnits: 2_000_000n },
live: NO_LIVE,
});
assert.equal(state.kind, "quoted");
if (state.kind !== "quoted") return;
assert.equal(state.read.creditedBaseUnits, 2_000_000n);
});

test("a claim-state failure degrades to the local source, still quoted, never blank", () => {
const state = deriveNetworkSpendState({
isToon: true,
Expand Down
13 changes: 8 additions & 5 deletions desktop/src/features/profile/lib/networkSpendState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,14 @@ export function deriveNetworkSpendState(input: {
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,
// Fed straight from the same claim-state read as deposit/owed (buzz#108)
// — always 0n for `source: "local"`, since the locally-tracked watermark
// only knows this client's own spend, never a connector-applied credit.
// Income RATE (burnRate's earning-side counterpart) is a separate,
// still-unwired gap: no live event feed exists for inbound payments,
// only `networkSpendLiveStore.ts`'s outbound `onPaidWrite` — so runway
// still degrades to burn-only until that lands.
creditedBaseUnits: input.raw.creditedBaseUnits,
burnRateBaseUnitsPerSec: input.live.burnRateBaseUnitsPerSec,
incomeRateBaseUnitsPerSec: 0,
incomeSampleCount: 0,
Expand Down
26 changes: 26 additions & 0 deletions desktop/src/shared/api/toonPaidWriter.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,31 @@ test("getNetworkFlowStatus prefers a verified claim-state read over the local wa
assert.equal(status.source, "claim-state");
assert.equal(status.depositTotalBaseUnits, 10_000_000n);
assert.equal(status.cumulativeClaimedBaseUnits, 4_000_000n);
assert.equal(status.creditedBaseUnits, 0n);
});

test("getNetworkFlowStatus splits a negative claim-state watermark into a credited amount (buzz#108)", async () => {
// A watermark below zero is the connector's netted signal that this
// identity has been credited more than it has spent on this channel
// (@toon-protocol/client's "Earning" docs, toon-meta#262 decision 9).
const client = scriptedClient({
getClaimState: (channelIds) =>
Promise.resolve(
channelIds.map(() => ({
ok: true,
depositTotal: "10000000",
cumulativeClaimed: "-1500000",
})),
),
});
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, 0n);
assert.equal(status.creditedBaseUnits, 1_500_000n);
});

test("getNetworkFlowStatus falls back to the local watermark when the client has no getClaimState", async () => {
Expand All @@ -389,6 +414,7 @@ test("getNetworkFlowStatus falls back to the local watermark when the client has
assert.equal(status.source, "local");
assert.equal(status.depositTotalBaseUnits, 5_000_000n);
assert.equal(status.cumulativeClaimedBaseUnits, 1_000_000n);
assert.equal(status.creditedBaseUnits, 0n);
});

test("getNetworkFlowStatus falls back to the local watermark when claim-state is unreachable", async () => {
Expand Down
66 changes: 35 additions & 31 deletions desktop/src/shared/api/toonPaidWriter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
import type { ClaimStateResult } from "@toon-protocol/client";

import {
clearPersistedChannel,
Expand Down Expand Up @@ -43,14 +44,22 @@ 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
* deposit, cumulative-claimed, and credited, 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;
/**
* Earned credit netted into this SAME channel's claim watermark — never a
* separate ledger (`@toon-protocol/client`'s "Earning" docs, toon-meta#262
* decision 9). Always `0n` for `source: "local"`: this client's own
* tracked watermark only knows what IT spent, not a credit the connector
* applied — see {@link ToonPaidWriter.tryClaimState}.
*/
creditedBaseUnits: bigint;
source: "claim-state" | "local";
};

Expand Down Expand Up @@ -101,25 +110,6 @@ 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
Expand Down Expand Up @@ -231,19 +221,19 @@ type PaidClient = {
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 yetcallers 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`.
* (toon-client#494, live since `@toon-protocol/client@0.26.0`) — the
* runway source of truth (toon-meta#261 decision 5), correct even when
* this client's own local watermark has drifted. Optional rather than
* required only so `scriptedClient()` test doubles that omit it still
* satisfy this interfaceevery real build supplies it; a real client
* that ever didn't (or an unreachable connector) falls back to the
* locally-tracked `getChannelDepositTotal`/`getChannelCumulativeAmount`,
* see `getNetworkFlowStatus`.
*/
getClaimState?(
channelIds?: string[],
opts?: { expiresInSeconds?: number },
): Promise<ClaimStateReadResult[]>;
): Promise<ClaimStateResult[]>;
/** Add collateral to an open channel. `amount` is the delta, base units. */
depositToChannel(
channelId: string,
Expand Down Expand Up @@ -712,6 +702,7 @@ export class ToonPaidWriter {
source: "local",
depositTotalBaseUnits: client.getChannelDepositTotal(channelId),
cumulativeClaimedBaseUnits: client.getChannelCumulativeAmount(channelId),
creditedBaseUnits: 0n,
};
}

Expand All @@ -721,21 +712,34 @@ export class ToonPaidWriter {
* 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.
*
* `cumulativeClaimed` is the connector's NETTED watermark for this one
* channel (`@toon-protocol/client`'s "Earning" docs — earnings net
* off-chain on the same channel a client spends from, there is no
* separate earned ledger), so it can read below zero once this identity
* has been credited more than it has spent. Split that signed watermark
* into the two non-negative buckets {@link RawNetworkFlowStatus} carries
* (`spendable = deposit − owed + credited`, toon-meta#262 decision 9)
* rather than handing callers a watermark they'd each have to re-interpret.
*/
private async tryClaimState(
client: PaidClient,
channelId: string,
): Promise<{
depositTotalBaseUnits: bigint;
cumulativeClaimedBaseUnits: bigint;
creditedBaseUnits: bigint;
} | null> {
if (!client.getClaimState) return null;
try {
const [result] = await client.getClaimState([channelId]);
if (!result?.ok || result.depositTotal === null) return null;
const cumulativeClaimed = BigInt(result.cumulativeClaimed);
return {
depositTotalBaseUnits: BigInt(result.depositTotal),
cumulativeClaimedBaseUnits: BigInt(result.cumulativeClaimed),
cumulativeClaimedBaseUnits:
cumulativeClaimed > 0n ? cumulativeClaimed : 0n,
creditedBaseUnits: cumulativeClaimed < 0n ? -cumulativeClaimed : 0n,
};
} catch (error) {
console.warn(
Expand Down