diff --git a/desktop/src/features/profile/lib/agentNetworkFlow.ts b/desktop/src/features/profile/lib/agentNetworkFlow.ts index f9af995a220..90d44331089 100644 --- a/desktop/src/features/profile/lib/agentNetworkFlow.ts +++ b/desktop/src/features/profile/lib/agentNetworkFlow.ts @@ -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 diff --git a/desktop/src/features/profile/lib/networkSpendState.test.mjs b/desktop/src/features/profile/lib/networkSpendState.test.mjs index 327ef842602..5b2062d88c3 100644 --- a/desktop/src/features/profile/lib/networkSpendState.test.mjs +++ b/desktop/src/features/profile/lib/networkSpendState.test.mjs @@ -19,6 +19,7 @@ const RAW = { channelId: "channel-1", depositTotalBaseUnits: 10_000_000n, cumulativeClaimedBaseUnits: 4_000_000n, + creditedBaseUnits: 0n, source: "claim-state", }; @@ -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, diff --git a/desktop/src/features/profile/lib/networkSpendState.ts b/desktop/src/features/profile/lib/networkSpendState.ts index 6095f0a719d..2e81e96d552 100644 --- a/desktop/src/features/profile/lib/networkSpendState.ts +++ b/desktop/src/features/profile/lib/networkSpendState.ts @@ -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, diff --git a/desktop/src/shared/api/toonPaidWriter.test.mjs b/desktop/src/shared/api/toonPaidWriter.test.mjs index b689951223e..757f1e95db9 100644 --- a/desktop/src/shared/api/toonPaidWriter.test.mjs +++ b/desktop/src/shared/api/toonPaidWriter.test.mjs @@ -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 () => { @@ -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 () => { diff --git a/desktop/src/shared/api/toonPaidWriter.ts b/desktop/src/shared/api/toonPaidWriter.ts index 4657857cedc..455be545ef3 100644 --- a/desktop/src/shared/api/toonPaidWriter.ts +++ b/desktop/src/shared/api/toonPaidWriter.ts @@ -1,4 +1,5 @@ import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; +import type { ClaimStateResult } from "@toon-protocol/client"; import { clearPersistedChannel, @@ -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"; }; @@ -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 @@ -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 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`. + * (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 interface — every 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; + ): Promise; /** Add collateral to an open channel. `amount` is the delta, base units. */ depositToChannel( channelId: string, @@ -712,6 +702,7 @@ export class ToonPaidWriter { source: "local", depositTotalBaseUnits: client.getChannelDepositTotal(channelId), cumulativeClaimedBaseUnits: client.getChannelCumulativeAmount(channelId), + creditedBaseUnits: 0n, }; } @@ -721,6 +712,15 @@ 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, @@ -728,14 +728,18 @@ export class ToonPaidWriter { ): 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(