From 58e13db0776fcdc4f49abb2652169524fc6a9633 Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Tue, 4 Aug 2026 03:07:42 +0000 Subject: [PATCH] =?UTF-8?q?RALPH:=20provisioning=20as=20one=20action=20?= =?UTF-8?q?=E2=80=94=20derive=20key,=20fund=20gas+USDC,=20open=20channel?= =?UTF-8?q?=20(buzz#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: toon-protocol/buzz#74, part of the agent-fleet-money epic (toon-meta#261 decision "provisioning as one action"). Unblocked by toon-client#491 (plain sendTransfer, merged into @toon-protocol/client 0.26.0) and buzz#79 (account-index registry, merged). Before this, making a managed agent able to pay on TOON needed manual CLI work outside the app; this wires the desktop app itself to derive the agent's payment address, fund it from the owner's wallet, and open its channel, with progress shown inline in the create-agent flow. Key decisions: - Bumped `@toon-protocol/client` to ^0.26.0 for `sendTransfer` (issue #491) and `getRoutePrice`-based quoting — 0.25.1 had no transfer primitive at all, only channel-collateral locking. - Step derivation (`agentProvisioningState.ts`) mirrors `toonOnboardingState.ts`'s ADR exactly: key/fund/channel status comes from live reads (Rust-assigned account index, the agent's own derived-address balances), never a stored counter, so reopening the flow for a partly provisioned agent resumes from reality. The channel-open step is the one persisted flag (`agentProvisioningStore.ts`, keyed per agent pubkey) — same tradeoff the onboarding wizard already makes for its own channel step, since there's no free on-chain probe for "does this address already have a channel." - Initial allowance (`agentProvisioningAllowance.ts`) prefers a measured burn rate (reusing `agentNetworkFlow.ts`'s `NetworkFlowRead` shape) and falls back to `quotedPrice × FALLBACK_WRITES_PER_DAY × FALLBACK_RUNWAY_DAYS` for a brand-new agent's inherent lack of history — every creation-time caller takes this fallback today, since the per-agent burn-rate feed (`agentNetworkFlow.ts`'s own documented blocker, buzz#86) isn't wired yet. - Funding (`provisionAgent.ts`) relies on `sendTransfer`'s own built-in balance-delta confirmation (throws `TransferNotDeliveredError` rather than resolving on a send that landed but delivered nothing — the exact devnet faucet failure mode connector#691 documents) instead of re-verifying itself. The two legs (gas, USDC) run independently via `Promise.all` and report as a tagged result rather than throwing, so a failed gas leg never loses a successful USDC leg — this is what makes "token landed, gas didn't" a legitimate resumable state instead of a hard failure. - Channel-open uses the actual funded balance on hand as the collateral amount (not a re-quoted estimate), so it's correct even resumed in a later session with no memory of what the fund step originally computed. - Extracted `buildToonClientOptions` out of `toonPaidWriter.ts`'s `createToonClient` so the writer's client and both provisioning clients (owner-scoped for `sendTransfer`, agent-scoped for `openChannel`) share one bootstrap rather than duplicating it. - Rust: `account_index.rs` gains a pure read-only `find_account_index` (buzz#79's `assign_account_index` mutates/creates; provisioning only needs to read what `create_managed_agent` already assigned), exposed via a new `commands/agent_provisioning.rs` module rather than added to `agents.rs`/`tauri.ts`, both already at their file-size ratchet ceiling. - UI: `AgentProvisioningDialog` (StepProgress, mirrors `ToonOnboardingGate`) is handed off from `RequestedAgentCreateDialogs` once `SecretRevealDialog` closes for a successfully created agent — sequential, not simultaneous, so only one dialog is ever open at a time. Files changed: desktop/package.json, pnpm-lock.yaml, pnpm-workspace.yaml, desktop/src-tauri/src/{commands/agent_provisioning.rs (new),commands/mod.rs, lib.rs,managed_agents/{account_index.rs,mod.rs}}, desktop/src/features/agents/{lib/agentProvisioningState.ts (new, +test), lib/agentProvisioningAllowance.ts (new, +test), lib/agentProvisioningStore.ts (new, +test),lib/provisionAgent.ts (new, +test), useAgentProvisioning.ts (new), ui/{AgentProvisioningDialog.tsx (new),RequestedAgentCreateDialogs.tsx}}, desktop/src/shared/api/{tauriAgentProvisioning.ts (new),toonPaidWriter.ts}. Verified: fmt-check, desktop-tauri-fmt-check, clippy (workspace, -D warnings), test-unit (864 tests), desktop-check, desktop-test (4291 passed), desktop-build, web-check, web-build — all green. Blockers/notes for next iteration: desktop/src-tauri cannot be compiled or unit-tested in this sandbox (no GTK/WebKit/sidecar stubs), so `account_index.rs`'s new `find_account_index` and its tests are verified by `cargo fmt --check` plus manual review, not `cargo check`/`clippy`/`test` — upstream ci.yml's desktop-tauri-check/test job is the first real compile. `solana`/`mina` are typed as valid `TransferChain`/faucet chains in provisionAgent.ts but the fund step always calls with `chain: "evm"` (TOON_DEVNET_DEFAULTS.chain is evm:84532 only) — fine today, would need a config-driven chain pick if the devnet's default settlement chain changes. The "one action" is UX-sequential (fund button, then open-channel button, both inline in one dialog with derived progress) rather than one click that silently chains both network calls — matches the onboarding wizard's own established "every network step is a button press" convention rather than adding a new auto-chaining pattern. Signed-off-by: Claude Sonnet 5 --- desktop/package.json | 2 +- .../src/commands/agent_provisioning.rs | 14 + desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/lib.rs | 1 + .../src/managed_agents/account_index.rs | 44 ++++ desktop/src-tauri/src/managed_agents/mod.rs | 3 +- .../lib/agentProvisioningAllowance.test.mjs | 67 +++++ .../agents/lib/agentProvisioningAllowance.ts | 88 +++++++ .../lib/agentProvisioningState.test.mjs | 114 ++++++++ .../agents/lib/agentProvisioningState.ts | 98 +++++++ .../lib/agentProvisioningStore.test.mjs | 68 +++++ .../agents/lib/agentProvisioningStore.ts | 114 ++++++++ .../agents/lib/provisionAgent.test.mjs | 232 +++++++++++++++++ .../src/features/agents/lib/provisionAgent.ts | 194 ++++++++++++++ .../agents/ui/AgentProvisioningDialog.tsx | 174 +++++++++++++ .../agents/ui/RequestedAgentCreateDialogs.tsx | 21 ++ .../features/agents/useAgentProvisioning.ts | 245 ++++++++++++++++++ .../src/shared/api/tauriAgentProvisioning.ts | 18 ++ desktop/src/shared/api/toonPaidWriter.ts | 51 +++- pnpm-lock.yaml | 41 +-- pnpm-workspace.yaml | 2 +- 21 files changed, 1559 insertions(+), 34 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_provisioning.rs create mode 100644 desktop/src/features/agents/lib/agentProvisioningAllowance.test.mjs create mode 100644 desktop/src/features/agents/lib/agentProvisioningAllowance.ts create mode 100644 desktop/src/features/agents/lib/agentProvisioningState.test.mjs create mode 100644 desktop/src/features/agents/lib/agentProvisioningState.ts create mode 100644 desktop/src/features/agents/lib/agentProvisioningStore.test.mjs create mode 100644 desktop/src/features/agents/lib/agentProvisioningStore.ts create mode 100644 desktop/src/features/agents/lib/provisionAgent.test.mjs create mode 100644 desktop/src/features/agents/lib/provisionAgent.ts create mode 100644 desktop/src/features/agents/ui/AgentProvisioningDialog.tsx create mode 100644 desktop/src/features/agents/useAgentProvisioning.ts create mode 100644 desktop/src/shared/api/tauriAgentProvisioning.ts diff --git a/desktop/package.json b/desktop/package.json index c41cabaa3ab..cab28230c2b 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -62,7 +62,7 @@ "@tiptap/react": "^3.22.3", "@tiptap/starter-kit": "^3.22.3", "@toon-protocol/arweave": "^0.2.0", - "@toon-protocol/client": "^0.25.1", + "@toon-protocol/client": "^0.26.0", "@toon-protocol/core": "^3.1.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/desktop/src-tauri/src/commands/agent_provisioning.rs b/desktop/src-tauri/src/commands/agent_provisioning.rs new file mode 100644 index 00000000000..1dc0927c371 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_provisioning.rs @@ -0,0 +1,14 @@ +//! Frontend-facing read of the account index `create_managed_agent` already +//! assigns (buzz#79) — the desktop UI needs it to derive an agent's TOON +//! payment address (owner mnemonic + this index) before it can provision +//! that agent's wallet (buzz#74). + +use tauri::AppHandle; + +#[tauri::command] +pub fn get_managed_agent_account_index( + pubkey: String, + app: AppHandle, +) -> Result, String> { + crate::managed_agents::find_account_index(&app, &pubkey) +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index e37a51dd425..c278f189ff7 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -7,6 +7,7 @@ mod agent_model_process; mod agent_models; mod agent_models_env; mod agent_providers; +mod agent_provisioning; mod agent_settings; mod agent_update_rollback; mod agents; @@ -71,6 +72,7 @@ pub use agent_logs::*; pub use agent_metric_archive::*; pub use agent_models::*; pub use agent_providers::*; +pub use agent_provisioning::*; pub use agent_settings::*; pub use agents::*; pub use canvas::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 3f36d50d900..812db5e983b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -820,6 +820,7 @@ pub fn run() { set_managed_agent_start_on_app_launch, set_managed_agent_auto_restart, delete_managed_agent, + get_managed_agent_account_index, get_managed_agent_log, get_agent_models, discover_agent_models, diff --git a/desktop/src-tauri/src/managed_agents/account_index.rs b/desktop/src-tauri/src/managed_agents/account_index.rs index d7ed3e33e8c..8ca03d835e1 100644 --- a/desktop/src-tauri/src/managed_agents/account_index.rs +++ b/desktop/src-tauri/src/managed_agents/account_index.rs @@ -104,6 +104,16 @@ fn assign_index_in(entries: &mut Vec, pubkey: &str, agent_nam next_index } +/// Pure lookup: `pubkey`'s already-assigned index, or `None` if it has never +/// been assigned one (never mutates `entries` — unlike [`assign_index_in`], +/// this must not conjure an index just because a caller asked to look). +fn find_index_in(entries: &[AccountIndexEntry], pubkey: &str) -> Option { + entries + .iter() + .find(|entry| entry.pubkey == pubkey) + .map(|entry| entry.account_index) +} + /// Tombstone `pubkey`'s entry in place. Returns `true` if an entry was found /// (tombstoned or already tombstoned); `false` if `pubkey` has no entry /// (agent deleted before it ever spawned under this registry). @@ -132,6 +142,19 @@ pub fn assign_account_index( Ok(index) } +/// Look up `pubkey`'s already-assigned account index without assigning one +/// (buzz#74: the provisioning flow needs to derive the agent's payment +/// address before it can fund it, and `create_managed_agent` already +/// assigns the index synchronously at creation — this is a read, not a +/// fallback path). `None` when `pubkey` has no entry yet, which the caller +/// should treat as "not provisionable yet" rather than an error. +pub fn find_account_index(app: &AppHandle, pubkey: &str) -> Result, String> { + let path = account_index_registry_path(app)?; + let _guard = REGISTRY_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let entries = load_registry_from_path(&path)?; + Ok(find_index_in(&entries, pubkey)) +} + /// Tombstone `pubkey`'s registry entry on agent deletion. The entry (and its /// index) is retained, never removed — see the module doc. No-op if /// `pubkey` was never assigned an index. @@ -211,6 +234,27 @@ mod tests { assert_eq!(entries.len(), 1); } + #[test] + fn find_returns_none_for_an_unassigned_pubkey_without_mutating() { + let entries = Vec::new(); + assert_eq!(find_index_in(&entries, "agent-a"), None); + } + + #[test] + fn find_returns_the_assigned_index() { + let mut entries = Vec::new(); + let a = assign_index_in(&mut entries, "agent-a", "Agent A"); + assert_eq!(find_index_in(&entries, "agent-a"), Some(a)); + } + + #[test] + fn find_still_returns_a_tombstoned_agents_index() { + let mut entries = Vec::new(); + let a = assign_index_in(&mut entries, "agent-a", "Agent A"); + tombstone_index_in(&mut entries, "agent-a"); + assert_eq!(find_index_in(&entries, "agent-a"), Some(a)); + } + #[test] fn tombstoned_index_is_never_reused() { let mut entries = Vec::new(); diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 0fa53678e8a..2d25dd9c287 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -48,7 +48,8 @@ pub(crate) fn lock_path_mutex() -> std::sync::MutexGuard<'static, ()> { } pub(crate) use account_index::{ - apply_account_index_env, assign_account_index, tombstone_account_index_best_effort, + apply_account_index_env, assign_account_index, find_account_index, + tombstone_account_index_best_effort, }; pub use backend::*; pub use discovery::*; diff --git a/desktop/src/features/agents/lib/agentProvisioningAllowance.test.mjs b/desktop/src/features/agents/lib/agentProvisioningAllowance.test.mjs new file mode 100644 index 00000000000..f6d186966ae --- /dev/null +++ b/desktop/src/features/agents/lib/agentProvisioningAllowance.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DEFAULT_CHANNEL_COLLATERAL_BASE_UNITS } from "../../onboarding/toon/toonOnboardingFormat.ts"; +import { + FALLBACK_RUNWAY_DAYS, + FALLBACK_WRITES_PER_DAY, + deriveInitialAllowanceBaseUnits, +} from "./agentProvisioningAllowance.ts"; + +test("no history, no quote — falls back to the devnet channel-open default", () => { + const amount = deriveInitialAllowanceBaseUnits({ + measuredBurnRateBaseUnitsPerSec: null, + quotedWritePriceBaseUnits: null, + }); + assert.equal(amount, DEFAULT_CHANNEL_COLLATERAL_BASE_UNITS); +}); + +test("no history — sizes from the quoted per-write price and the fallback runway", () => { + const amount = deriveInitialAllowanceBaseUnits({ + measuredBurnRateBaseUnitsPerSec: null, + quotedWritePriceBaseUnits: 1_000n, + }); + assert.equal( + amount, + 1_000n * BigInt(FALLBACK_WRITES_PER_DAY) * BigInt(FALLBACK_RUNWAY_DAYS), + ); +}); + +test("no history — a custom runwayDays scales the fallback estimate", () => { + const amount = deriveInitialAllowanceBaseUnits({ + measuredBurnRateBaseUnitsPerSec: null, + quotedWritePriceBaseUnits: 1_000n, + runwayDays: 1, + }); + assert.equal(amount, 1_000n * BigInt(FALLBACK_WRITES_PER_DAY)); +}); + +test("a measured burn rate wins over the quoted-price fallback", () => { + const amount = deriveInitialAllowanceBaseUnits({ + // 10 base units/sec sustained. + measuredBurnRateBaseUnitsPerSec: 10, + quotedWritePriceBaseUnits: 1_000_000n, + runwayDays: 1, + }); + assert.equal(amount, 10n * 24n * 60n * 60n); +}); + +test("a zero measured burn rate is not trusted — falls through to the quote", () => { + // A perfectly idle sample must not size a zero allowance; the agent still + // needs enough to make its next write. + const amount = deriveInitialAllowanceBaseUnits({ + measuredBurnRateBaseUnitsPerSec: 0, + quotedWritePriceBaseUnits: 1_000n, + runwayDays: 1, + }); + assert.equal(amount, 1_000n * BigInt(FALLBACK_WRITES_PER_DAY)); +}); + +test("a fractional burn-rate*runway amount rounds up, never under-funds", () => { + const amount = deriveInitialAllowanceBaseUnits({ + measuredBurnRateBaseUnitsPerSec: 1.5, + quotedWritePriceBaseUnits: null, + runwayDays: 1, + }); + assert.equal(amount, BigInt(Math.ceil(1.5 * 24 * 60 * 60))); +}); diff --git a/desktop/src/features/agents/lib/agentProvisioningAllowance.ts b/desktop/src/features/agents/lib/agentProvisioningAllowance.ts new file mode 100644 index 00000000000..d9f2f3b8ee9 --- /dev/null +++ b/desktop/src/features/agents/lib/agentProvisioningAllowance.ts @@ -0,0 +1,88 @@ +import { DEFAULT_CHANNEL_COLLATERAL_BASE_UNITS } from "@/features/onboarding/toon/toonOnboardingFormat"; + +/** + * Initial payment-channel allowance for a freshly-provisioned agent (buzz#74, + * toon-meta#262 decision 8): sized from the agent's own measured burn rate + * so the default survives an operator price change, rather than a fixed + * USDC number. + * + * A brand-new agent has no burn history by construction — provisioning at + * creation always takes the days-of-runway fallback below. The burn-rate + * branch is here for a later re-provisioning/top-up caller once an agent has + * spend history: it reuses `agentNetworkFlow.ts`'s `NetworkFlowRead.burnRateBaseUnitsPerSec` + * shape (passed as a plain number rather than the whole read, since this + * function needs nothing else from it) so both callers agree on what "burn + * rate" means. That per-agent live read does not exist yet — same documented + * blocker `agentNetworkFlow.ts` already carries (toon-client#494's + * `getClaimState()`, and the Network spend block, #80) — so today every + * caller passes `null` here and takes the fallback. + */ + +/** Conservative runway target when there is no spend history to measure. */ +export const FALLBACK_RUNWAY_DAYS = 7; + +/** + * A first-cut estimate of write volume for an agent with no history yet — + * deliberately generous (epic decision 8's "generous default") rather than + * bare-minimum, so a freshly provisioned agent is not starved on day one. + * Revisit once real per-agent write-rate data exists to calibrate against. + */ +export const FALLBACK_WRITES_PER_DAY = 2_000; + +const SECONDS_PER_DAY = 24 * 60 * 60; + +/** + * Native gas to send an agent on provisioning — not sized from burn rate + * (gas pays for the one channel-open transaction, not per-write spend). + * 0.002 ETH on Base Sepolia: comfortably covers a channel-open with room for + * gas-price movement, without being large enough to matter if it sits idle. + * A first-cut estimate — revisit if devnet gas prices move materially. + */ +export const DEFAULT_AGENT_NATIVE_GAS_BASE_UNITS = 2_000_000_000_000_000n; + +/** + * The channel-open collateral to request for a freshly provisioned agent. + * + * Prefers `measuredBurnRateBaseUnitsPerSec` (a positive measured rate) when + * available; otherwise falls back to `quotedWritePriceBaseUnits × + * FALLBACK_WRITES_PER_DAY × runwayDays`; and if neither input is known at + * all, falls back to the flat devnet channel-open default the onboarding + * wizard already shows, so this never returns an amount too small to open a + * channel with. + */ +export function deriveInitialAllowanceBaseUnits(params: { + /** The agent's own measured burn rate, or null/zero with no trusted history yet. */ + measuredBurnRateBaseUnitsPerSec: number | null; + /** The connector's quoted per-write price, or null if unquoted. Only consulted with no burn-rate history. */ + quotedWritePriceBaseUnits: bigint | null; + /** How many days of runway the allowance should cover. */ + runwayDays?: number; +}): bigint { + const runwayDays = params.runwayDays ?? FALLBACK_RUNWAY_DAYS; + + if ( + params.measuredBurnRateBaseUnitsPerSec !== null && + params.measuredBurnRateBaseUnitsPerSec > 0 + ) { + // Round up — an allowance that undershoots the measured rate by a + // fraction of a base unit is worse than one that overshoots by one. + return BigInt( + Math.ceil( + params.measuredBurnRateBaseUnitsPerSec * SECONDS_PER_DAY * runwayDays, + ), + ); + } + + if ( + params.quotedWritePriceBaseUnits !== null && + params.quotedWritePriceBaseUnits > 0n + ) { + return ( + params.quotedWritePriceBaseUnits * + BigInt(FALLBACK_WRITES_PER_DAY) * + BigInt(runwayDays) + ); + } + + return DEFAULT_CHANNEL_COLLATERAL_BASE_UNITS; +} diff --git a/desktop/src/features/agents/lib/agentProvisioningState.test.mjs b/desktop/src/features/agents/lib/agentProvisioningState.test.mjs new file mode 100644 index 00000000000..3ddd58b39ae --- /dev/null +++ b/desktop/src/features/agents/lib/agentProvisioningState.test.mjs @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + AGENT_PROVISIONING_STEP_ORDER, + agentProvisioningStepNumber, + deriveAgentProvisioningStatus, +} from "./agentProvisioningState.ts"; + +const BASE = { + accountIndex: null, + usdcBaseUnits: null, + nativeBaseUnits: null, + channelConfirmed: false, +}; + +test("no account index yet lands on the key step, regardless of everything else", () => { + const status = deriveAgentProvisioningStatus({ + ...BASE, + usdcBaseUnits: 1_000_000n, + nativeBaseUnits: 1_000_000_000_000_000n, + channelConfirmed: true, + }); + assert.equal(status.step, "key"); +}); + +test("an assigned index with an unread balance stays on fund, not funded", () => { + // null must not read as zero — an unread balance is not an empty wallet. + const status = deriveAgentProvisioningStatus({ ...BASE, accountIndex: 1 }); + assert.equal(status.step, "fund"); + assert.equal(status.fundedForToken, false); + assert.equal(status.hasNativeGas, false); +}); + +test("zero of both balances stays on fund", () => { + const status = deriveAgentProvisioningStatus({ + ...BASE, + accountIndex: 1, + usdcBaseUnits: 0n, + nativeBaseUnits: 0n, + }); + assert.equal(status.step, "fund"); +}); + +test("token landed but gas did not — fund step, flagged for manual top-up", () => { + const status = deriveAgentProvisioningStatus({ + ...BASE, + accountIndex: 1, + usdcBaseUnits: 1_000_000n, + nativeBaseUnits: 0n, + }); + assert.equal(status.step, "fund"); + assert.equal(status.fundedForToken, true); + assert.equal(status.hasNativeGas, false); + assert.equal(status.needsManualGasTopUp, true); +}); + +test("gas landed but token did not — fund step, no manual-gas flag", () => { + const status = deriveAgentProvisioningStatus({ + ...BASE, + accountIndex: 1, + usdcBaseUnits: 0n, + nativeBaseUnits: 1_000_000_000_000_000n, + }); + assert.equal(status.step, "fund"); + assert.equal(status.needsManualGasTopUp, false); +}); + +test("funded on both moves to the channel step", () => { + const status = deriveAgentProvisioningStatus({ + ...BASE, + accountIndex: 1, + usdcBaseUnits: 1_000_000n, + nativeBaseUnits: 1_000_000_000_000_000n, + }); + assert.equal(status.step, "channel"); + assert.equal(status.needsManualGasTopUp, false); +}); + +test("funded and channel-confirmed is done", () => { + const status = deriveAgentProvisioningStatus({ + ...BASE, + accountIndex: 1, + usdcBaseUnits: 1_000_000n, + nativeBaseUnits: 1_000_000_000_000_000n, + channelConfirmed: true, + }); + assert.equal(status.step, "done"); +}); + +test("re-entrancy: a lost channel-confirmed flag re-quotes the channel step, not funding", () => { + // Funding is derived from a live balance read, never from the flag, so a + // lost flag (quit right after opening, or a reinstall) cannot strand the + // agent back on the fund step it already cleared. + const status = deriveAgentProvisioningStatus({ + ...BASE, + accountIndex: 1, + usdcBaseUnits: 1_000_000n, + nativeBaseUnits: 1_000_000_000_000_000n, + channelConfirmed: false, + }); + assert.equal(status.step, "channel"); +}); + +test("step numbers are 1-based and stable", () => { + assert.equal(agentProvisioningStepNumber("key"), 1); + assert.equal(agentProvisioningStepNumber("fund"), 2); + assert.equal(agentProvisioningStepNumber("channel"), 3); +}); + +test("done clamps to the last step number", () => { + assert.equal(agentProvisioningStepNumber("done"), 3); + assert.equal(AGENT_PROVISIONING_STEP_ORDER.length, 3); +}); diff --git a/desktop/src/features/agents/lib/agentProvisioningState.ts b/desktop/src/features/agents/lib/agentProvisioningState.ts new file mode 100644 index 00000000000..a7690e43846 --- /dev/null +++ b/desktop/src/features/agents/lib/agentProvisioningState.ts @@ -0,0 +1,98 @@ +/** + * Provisioning-step derivation for a managed agent's TOON wallet (buzz#74). + * + * Mirrors `toonOnboardingState.ts`'s ADR directly: the current step must be + * derived from what is actually true of the agent's own derived address — + * key assigned, funded, channel open — not replayed from a stored counter, + * so quitting mid-flow and coming back resumes from reality rather than + * stranding the operator on a step already finished (or re-running one that + * was not). + * + * `accountIndex` and the two balances are reality-derived with no flag at + * all (a Rust registry read and two free RPC balance reads). `channelConfirmed` + * is a persisted flag set only once this flow's own channel-open action + * succeeds — same tradeoff the onboarding wizard already makes for its own + * channel step, and for the same reason: there is no free on-chain probe for + * "does this address already have a channel with this destination" to check + * instead. + */ + +/** One position in the flow. `"done"` means every step cleared. */ +export type AgentProvisioningStepId = "key" | "fund" | "channel" | "done"; + +/** Everything the derivation needs, gathered by the caller. */ +export type AgentProvisioningSnapshot = { + /** + * The agent's BIP-44 account index, assigned by `create_managed_agent` + * (buzz#79) — null while that read has not resolved yet, never treated as + * "assign one now" (this module never assigns, only reads). + */ + accountIndex: number | null; + /** + * The agent's own derived-address settlement-token balance, base units, or + * null when not yet read (never treated as zero). + */ + usdcBaseUnits: bigint | null; + /** The agent's own derived-address native-gas balance, base units, or null when not yet read. */ + nativeBaseUnits: bigint | null; + /** This flow's own channel-open action has succeeded (persisted flag). */ + channelConfirmed: boolean; +}; + +export type AgentProvisioningStatus = { + step: AgentProvisioningStepId; + /** The settlement token has landed on the agent's address. */ + fundedForToken: boolean; + /** Native gas has landed — required before the agent's channel-open transaction. */ + hasNativeGas: boolean; + /** + * The token arrived but gas did not. `sendTransfer`'s two legs (buzz#74) + * are independent calls, so one can fail while the other lands — this is + * an expected steady state to surface for manual retry, not a crash. + */ + needsManualGasTopUp: boolean; +}; + +function isPositive(amount: bigint | null): boolean { + return amount !== null && amount > 0n; +} + +/** + * Derive the flow's current step and the fund step's sub-state from a + * snapshot of reality. No side effects, no I/O — everything it needs has + * already been read by the caller. + */ +export function deriveAgentProvisioningStatus( + snapshot: AgentProvisioningSnapshot, +): AgentProvisioningStatus { + const fundedForToken = isPositive(snapshot.usdcBaseUnits); + const hasNativeGas = isPositive(snapshot.nativeBaseUnits); + const needsManualGasTopUp = fundedForToken && !hasNativeGas; + + let step: AgentProvisioningStepId; + if (snapshot.accountIndex === null) { + step = "key"; + } else if (!fundedForToken || !hasNativeGas) { + step = "fund"; + } else if (!snapshot.channelConfirmed) { + step = "channel"; + } else { + step = "done"; + } + + return { step, fundedForToken, hasNativeGas, needsManualGasTopUp }; +} + +/** 1-based position for `StepProgress`, `"done"` clamped to the last step. */ +export const AGENT_PROVISIONING_STEP_ORDER: AgentProvisioningStepId[] = [ + "key", + "fund", + "channel", +]; + +export function agentProvisioningStepNumber( + step: AgentProvisioningStepId, +): number { + const index = AGENT_PROVISIONING_STEP_ORDER.indexOf(step); + return index === -1 ? AGENT_PROVISIONING_STEP_ORDER.length : index + 1; +} diff --git a/desktop/src/features/agents/lib/agentProvisioningStore.test.mjs b/desktop/src/features/agents/lib/agentProvisioningStore.test.mjs new file mode 100644 index 00000000000..185bd72b718 --- /dev/null +++ b/desktop/src/features/agents/lib/agentProvisioningStore.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isAgentChannelConfirmed, + setAgentChannelConfirmed, + setAgentProvisioningStorage, + subscribeToAgentProvisioningState, +} from "./agentProvisioningStore.ts"; + +function memoryStorage() { + const values = new Map(); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => { + values.set(key, value); + }, + removeItem: (key) => { + values.delete(key); + }, + }; +} + +test.beforeEach(() => { + setAgentProvisioningStorage(memoryStorage()); +}); + +test("an agent with no record reads as not confirmed", () => { + assert.equal(isAgentChannelConfirmed("agent-a"), false); +}); + +test("confirming an agent's channel persists and reads back true", () => { + setAgentChannelConfirmed("agent-a", true); + assert.equal(isAgentChannelConfirmed("agent-a"), true); +}); + +test("confirming one agent does not confirm another — keyed per pubkey", () => { + setAgentChannelConfirmed("agent-a", true); + assert.equal(isAgentChannelConfirmed("agent-b"), false); +}); + +test("un-confirming clears the flag", () => { + setAgentChannelConfirmed("agent-a", true); + setAgentChannelConfirmed("agent-a", false); + assert.equal(isAgentChannelConfirmed("agent-a"), false); +}); + +test("a corrupt/unreadable storage read is treated as not confirmed, never throws", () => { + setAgentProvisioningStorage({ + getItem: () => { + throw new Error("boom"); + }, + setItem: () => {}, + removeItem: () => {}, + }); + assert.equal(isAgentChannelConfirmed("agent-a"), false); +}); + +test("subscribers are notified when a flag changes", () => { + let notified = 0; + const unsubscribe = subscribeToAgentProvisioningState(() => { + notified += 1; + }); + setAgentChannelConfirmed("agent-a", true); + unsubscribe(); + setAgentChannelConfirmed("agent-a", false); + assert.equal(notified, 1); +}); diff --git a/desktop/src/features/agents/lib/agentProvisioningStore.ts b/desktop/src/features/agents/lib/agentProvisioningStore.ts new file mode 100644 index 00000000000..8def83d7315 --- /dev/null +++ b/desktop/src/features/agents/lib/agentProvisioningStore.ts @@ -0,0 +1,114 @@ +import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; + +/** + * Whether buzz#74's provisioning flow has already confirmed it opened a + * given agent's payment channel — one persisted flag per agent pubkey. + * + * Same tradeoff `toonOnboardingStore.ts` documents for its own single + * channel-confirmed flag (see `agentProvisioningState.ts`'s header): there is + * no free on-chain probe for "does this agent's derived address already have + * a channel with this destination", so the flag is the source of truth for + * the channel step, and it is safe to lose — a lost flag only re-runs the + * channel-open step, which `ToonClient.openChannel` treats as idempotent per + * peer. + * + * Keyed per pubkey (unlike the owner's single flag) because a host can run + * many managed agents, each provisioning its own channel independently. + */ + +const STORAGE_PREFIX = "buzz-agent-provisioning.v1"; + +export type AgentProvisioningStorage = { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +}; + +function memoryStorage(): AgentProvisioningStorage { + const values = new Map(); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => { + values.set(key, value); + }, + removeItem: (key) => { + values.delete(key); + }, + }; +} + +function defaultStorage(): AgentProvisioningStorage { + if (typeof window === "undefined" || !window.localStorage) { + return memoryStorage(); + } + return { + getItem: (key) => window.localStorage.getItem(key), + setItem: (key, value) => { + setLocalStorageItemWithRecovery(key, value); + }, + removeItem: (key) => { + window.localStorage.removeItem(key); + }, + }; +} + +let storage: AgentProvisioningStorage = defaultStorage(); +const listeners = new Set<() => void>(); + +/** Swap the backing store. For tests and for a future keychain backend. */ +export function setAgentProvisioningStorage( + next: AgentProvisioningStorage | null, +): void { + storage = next ?? defaultStorage(); + notify(); +} + +function storageKey(pubkey: string): string { + return `${STORAGE_PREFIX}:${pubkey}`; +} + +function notify(): void { + for (const listener of listeners) listener(); +} + +/** Whether this flow has already confirmed `pubkey`'s channel is open. */ +export function isAgentChannelConfirmed(pubkey: string): boolean { + try { + return storage.getItem(storageKey(pubkey)) === "true"; + } catch (error) { + console.warn( + "[agent-provisioning] could not read the channel-confirmed flag", + error, + ); + return false; + } +} + +export function setAgentChannelConfirmed( + pubkey: string, + confirmed: boolean, +): void { + try { + if (confirmed) { + storage.setItem(storageKey(pubkey), "true"); + } else { + storage.removeItem(storageKey(pubkey)); + } + } catch (error) { + console.warn( + "[agent-provisioning] could not persist the channel-confirmed flag", + error, + ); + } + notify(); +} + +/** Observe any change — the provisioning UI re-renders from this. */ +export function subscribeToAgentProvisioningState( + listener: () => void, +): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/desktop/src/features/agents/lib/provisionAgent.test.mjs b/desktop/src/features/agents/lib/provisionAgent.test.mjs new file mode 100644 index 00000000000..80a6ff275c4 --- /dev/null +++ b/desktop/src/features/agents/lib/provisionAgent.test.mjs @@ -0,0 +1,232 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + AgentProvisioningError, + buildAgentProvisioningClient, + buildOwnerProvisioningClient, + deriveAgentAddress, + fundAgentWallet, + openAgentChannel, +} from "./provisionAgent.ts"; +import { resolveToonTransportConfig } from "@/shared/api/toonTransportConfig"; + +/** + * Covers buzz#74's fund + open-channel orchestration. Every test drives a + * scripted `ProvisioningClient` stub rather than a real `ToonClient` — same + * dependency-injection shape `toonPaidWriter.test.mjs` uses for `PaidClient`. + */ + +function scriptedClient(overrides = {}) { + return { + started: 0, + stopped: 0, + transfers: [], + openedDestinations: [], + start() { + this.started += 1; + return Promise.resolve({}); + }, + stop() { + this.stopped += 1; + return Promise.resolve(); + }, + sendTransfer(params) { + this.transfers.push(params); + return Promise.resolve({ + txHash: "0xabc", + balanceBefore: "0", + balanceAfter: params.amount.toString(), + }); + }, + openChannel(destination) { + this.openedDestinations.push(destination); + return Promise.resolve("channel-1"); + }, + ...overrides, + }; +} + +test("fundAgentWallet sends both legs to the agent's address", async () => { + const client = scriptedClient(); + const result = await fundAgentWallet({ + ownerClient: client, + agentAddress: "0xagent", + chain: "evm", + nativeAmountBaseUnits: 1_000n, + tokenAmountBaseUnits: 2_000n, + }); + + assert.equal(result.native.status, "ok"); + assert.equal(result.token.status, "ok"); + assert.deepEqual( + client.transfers.map((t) => ({ + asset: t.asset, + to: t.to, + amount: t.amount, + })), + [ + { asset: "native", to: "0xagent", amount: 1_000n }, + { asset: "token", to: "0xagent", amount: 2_000n }, + ], + ); +}); + +test("fundAgentWallet reports one leg's failure without losing the other's success", async () => { + const client = scriptedClient({ + sendTransfer(params) { + if (params.asset === "native") { + return Promise.reject(new Error("faucet's gas leg is best-effort")); + } + this.transfers.push(params); + return Promise.resolve({ + txHash: "0xabc", + balanceBefore: "0", + balanceAfter: params.amount.toString(), + }); + }, + }); + + const result = await fundAgentWallet({ + ownerClient: client, + agentAddress: "0xagent", + chain: "evm", + nativeAmountBaseUnits: 1_000n, + tokenAmountBaseUnits: 2_000n, + }); + + assert.equal(result.native.status, "error"); + assert.match(result.native.message, /best-effort/); + assert.equal(result.token.status, "ok"); +}); + +test("fundAgentWallet never throws — both legs report as tagged results", async () => { + const client = scriptedClient({ + sendTransfer: () => Promise.reject(new Error("delivery not observed")), + }); + + const result = await fundAgentWallet({ + ownerClient: client, + agentAddress: "0xagent", + chain: "evm", + nativeAmountBaseUnits: 1_000n, + tokenAmountBaseUnits: 2_000n, + }); + + assert.equal(result.native.status, "error"); + assert.equal(result.token.status, "error"); +}); + +test("openAgentChannel starts the client, opens against the destination, then stops it", async () => { + const client = scriptedClient(); + const channelId = await openAgentChannel({ + agentClient: client, + destination: "g.toon.relay", + }); + + assert.equal(channelId, "channel-1"); + assert.equal(client.started, 1); + assert.deepEqual(client.openedDestinations, ["g.toon.relay"]); + assert.equal(client.stopped, 1); +}); + +test("openAgentChannel still stops the client when openChannel throws", async () => { + const client = scriptedClient({ + openChannel: () => Promise.reject(new Error("insufficient funds")), + }); + + await assert.rejects( + () => + openAgentChannel({ agentClient: client, destination: "g.toon.relay" }), + /insufficient funds/, + ); + assert.equal(client.stopped, 1); +}); + +const CONFIG_WITH_MNEMONIC = resolveToonTransportConfig({ + BUZZ_TOON_MNEMONIC: "test test test", +}); +const CONFIG_WITHOUT_MNEMONIC = resolveToonTransportConfig({}); + +test("buildOwnerProvisioningClient rejects with no owner mnemonic configured, before touching the factory", async () => { + let factoryCalled = false; + await assert.rejects( + () => + buildOwnerProvisioningClient(CONFIG_WITHOUT_MNEMONIC, async () => { + factoryCalled = true; + return scriptedClient(); + }), + AgentProvisioningError, + ); + assert.equal(factoryCalled, false); +}); + +test("buildOwnerProvisioningClient builds at the owner's account index and starts it", async () => { + const client = scriptedClient(); + let seenArgs = null; + const result = await buildOwnerProvisioningClient( + CONFIG_WITH_MNEMONIC, + async (_config, accountIndex, initialDeposit) => { + seenArgs = { accountIndex, initialDeposit }; + return client; + }, + ); + assert.equal(result, client); + assert.equal(client.started, 1); + assert.equal(seenArgs.accountIndex, CONFIG_WITH_MNEMONIC.accountIndex); + assert.equal(seenArgs.initialDeposit, null); +}); + +test("buildAgentProvisioningClient rejects with no owner mnemonic configured", async () => { + await assert.rejects( + () => + buildAgentProvisioningClient( + CONFIG_WITHOUT_MNEMONIC, + 3, + 10_000_000n, + async () => scriptedClient(), + ), + AgentProvisioningError, + ); +}); + +test("buildAgentProvisioningClient builds at the agent's index with the allowance as a string, without starting it", async () => { + const client = scriptedClient(); + let seenArgs = null; + const result = await buildAgentProvisioningClient( + CONFIG_WITH_MNEMONIC, + 3, + 10_000_000n, + async (_config, accountIndex, initialDeposit) => { + seenArgs = { accountIndex, initialDeposit }; + return client; + }, + ); + assert.equal(result, client); + assert.equal(client.started, 0); + assert.equal(seenArgs.accountIndex, 3); + assert.equal(seenArgs.initialDeposit, "10000000"); +}); + +test("deriveAgentAddress is deterministic and index-scoped", async () => { + // Anvil's well-known test phrase — a fixed test vector rather than a + // generated one, so a derivation regression fails against a known answer. + const mnemonic = + "test test test test test test test test test test test junk"; + + const indexOne = await deriveAgentAddress(mnemonic, 1); + assert.match(indexOne, /^0x[0-9a-fA-F]{40}$/); + // Same phrase, same index — derivation must be deterministic. + assert.equal(await deriveAgentAddress(mnemonic, 1), indexOne); + + // A different index must derive a different address — two agents on the + // same owner mnemonic must never collide onto the same payment identity. + const indexTwo = await deriveAgentAddress(mnemonic, 2); + assert.notEqual(indexTwo, indexOne); +}); + +test("AgentProvisioningError carries a readable message", () => { + const error = new AgentProvisioningError("no owner mnemonic configured"); + assert.equal(error.name, "AgentProvisioningError"); + assert.equal(error.message, "no owner mnemonic configured"); +}); diff --git a/desktop/src/features/agents/lib/provisionAgent.ts b/desktop/src/features/agents/lib/provisionAgent.ts new file mode 100644 index 00000000000..bf7716cc296 --- /dev/null +++ b/desktop/src/features/agents/lib/provisionAgent.ts @@ -0,0 +1,194 @@ +import { buildToonClientOptions } from "@/shared/api/toonPaidWriter"; +import type { ToonTransportConfig } from "@/shared/api/toonTransportConfig"; + +/** + * Fund + open-channel orchestration for provisioning a managed agent's TOON + * wallet as one action (buzz#74): derive the agent's address from the + * owner's mnemonic at its Rust-assigned account index (buzz#79), send it + * native gas + settlement token from the owner's own wallet, then open the + * agent's own payment channel with an initial allowance. + * + * Deliberately a distinct client seam from `toonPaidWriter.ts`'s + * `PaidClient`: provisioning never signs a claim or publishes an event, only + * moves capital (`sendTransfer`) and opens a channel (`openChannel`) — a + * narrower surface than the writer needs, and one that never touches + * `getActiveToonTransport()`'s singleton writer (see `buildToonClientOptions`'s + * doc comment), since a provisioning client is never the owner's or an + * agent's *running* transport, only a short-lived one used once here. + */ + +export type TransferChain = "evm" | "solana" | "mina"; + +/** What `ToonClient.sendTransfer` returns once the destination's balance delta was observed. */ +export type SendTransferResult = { + txHash: string; + balanceBefore: string; + balanceAfter: string; +}; + +/** The subset of `ToonClient` this module drives. */ +export type ProvisioningClient = { + start(): Promise; + stop(): Promise; + /** + * `ToonClient.sendTransfer` (toon-client#491) already confirms delivery by + * an OBSERVED balance delta at the destination before resolving — throwing + * `TransferNotDeliveredError` rather than resolving on a send that landed + * on-chain but delivered nothing (the devnet faucet's Solana leg has done + * exactly that, connector#691). This module does not re-verify the delta + * itself; it relies on that guarantee. + */ + sendTransfer(params: { + chain: TransferChain; + asset: "native" | "token"; + to: string; + amount: string | bigint; + }): Promise; + /** Open (or reuse, per-peer idempotent) a payment channel for `destination`. */ + openChannel(destination?: string): Promise; + /** The flat per-packet route price, when sizing the fund step's allowance from a live quote rather than the fallback. */ + getRoutePrice?(destination: string): Promise; +}; + +export type ProvisioningClientFactory = ( + config: ToonTransportConfig, + accountIndex: number, + initialDeposit?: string | null, +) => Promise; + +/** Thrown when provisioning cannot proceed — never for a single funding leg's failure, see {@link FundLegResult}. */ +export class AgentProvisioningError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "AgentProvisioningError"; + } +} + +const createProvisioningClient: ProvisioningClientFactory = async ( + config, + accountIndex, + initialDeposit, +) => { + const [options, { ToonClient }] = await Promise.all([ + buildToonClientOptions(config, accountIndex, initialDeposit ?? undefined), + import("@toon-protocol/client"), + ]); + return new ToonClient(options as never) as unknown as ProvisioningClient; +}; + +/** The EVM address `mnemonic` derives to at `accountIndex` — pure, local, offline. */ +export async function deriveAgentAddress( + mnemonic: string, + accountIndex: number, +): Promise { + const { deriveFullIdentity } = await import("@toon-protocol/client"); + const identity = await deriveFullIdentity(mnemonic, accountIndex); + return identity.evm.address; +} + +/** The owner's own client (account index 0), used to fund an agent's address. */ +export async function buildOwnerProvisioningClient( + config: ToonTransportConfig, + factory: ProvisioningClientFactory = createProvisioningClient, +): Promise { + if (config.mnemonic === null) { + throw new AgentProvisioningError( + "No owner payment mnemonic configured — cannot fund an agent's wallet.", + ); + } + const client = await factory(config, config.accountIndex, null); + await client.start(); + return client; +} + +/** A short-lived client at the agent's own account index, used once to open its channel. */ +export async function buildAgentProvisioningClient( + config: ToonTransportConfig, + accountIndex: number, + initialDepositBaseUnits: bigint, + factory: ProvisioningClientFactory = createProvisioningClient, +): Promise { + if (config.mnemonic === null) { + throw new AgentProvisioningError( + "No owner payment mnemonic configured — cannot open the agent's channel.", + ); + } + return factory(config, accountIndex, initialDepositBaseUnits.toString()); +} + +/** One funding leg's outcome — never thrown, so a caller can show "gas failed, USDC landed" rather than losing the successful leg to a rejected promise. */ +export type FundLegResult = + | { status: "ok"; result: SendTransferResult } + | { status: "error"; message: string }; + +async function attemptTransfer( + client: ProvisioningClient, + params: { + chain: TransferChain; + asset: "native" | "token"; + to: string; + amount: bigint; + }, +): Promise { + try { + const result = await client.sendTransfer(params); + return { status: "ok", result }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * Fund `agentAddress` with native gas and the settlement token from the + * owner's own (already-started) client. The two legs run independently and + * neither can fail the other — `agentProvisioningState.ts`'s fund step + * already treats "token landed, gas didn't" (and the reverse) as a legitimate + * resumable state, so surfacing a partial result here (rather than throwing + * on the first rejection) is what makes that resumability possible. + */ +export async function fundAgentWallet(params: { + ownerClient: ProvisioningClient; + agentAddress: string; + chain: TransferChain; + nativeAmountBaseUnits: bigint; + tokenAmountBaseUnits: bigint; +}): Promise<{ native: FundLegResult; token: FundLegResult }> { + const [native, token] = await Promise.all([ + attemptTransfer(params.ownerClient, { + chain: params.chain, + asset: "native", + to: params.agentAddress, + amount: params.nativeAmountBaseUnits, + }), + attemptTransfer(params.ownerClient, { + chain: params.chain, + asset: "token", + to: params.agentAddress, + amount: params.tokenAmountBaseUnits, + }), + ]); + return { native, token }; +} + +/** + * Open the agent's own payment channel against `destination`, using a + * client scoped to the agent's own account index (never the owner's) — the + * channel's collateral and signing identity belong to the agent. The client + * is started and stopped around the single call: nothing here keeps it + * alive for future writes, since the agent's own runtime (once spawned) + * tracks and resumes this channel independently. + */ +export async function openAgentChannel(params: { + agentClient: ProvisioningClient; + destination: string; +}): Promise { + await params.agentClient.start(); + try { + return await params.agentClient.openChannel(params.destination); + } finally { + await params.agentClient.stop(); + } +} diff --git a/desktop/src/features/agents/ui/AgentProvisioningDialog.tsx b/desktop/src/features/agents/ui/AgentProvisioningDialog.tsx new file mode 100644 index 00000000000..48e24833b4b --- /dev/null +++ b/desktop/src/features/agents/ui/AgentProvisioningDialog.tsx @@ -0,0 +1,174 @@ +import { agentProvisioningStepNumber } from "@/features/agents/lib/agentProvisioningState"; +import { useAgentProvisioning } from "@/features/agents/useAgentProvisioning"; +import { formatUsdcBaseUnits } from "@/features/onboarding/toon/toonOnboardingFormat"; +import { Alert, AlertDescription } from "@/shared/ui/alert"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { StepProgress } from "@/shared/ui/step-progress"; + +/** + * The provisioning-as-one-action flow (buzz#74): derive an agent's payment + * key, fund it, and open its channel from inside the create-agent flow — + * shown right after `SecretRevealDialog` hands off (see + * `RequestedAgentCreateDialogs.tsx`), so an operator never has to run + * separate CLI commands to make a freshly created agent able to pay. + * + * Mirrors `ToonOnboardingGate.tsx`'s shape: `StepProgress` plus one panel per + * derived step, driven by `useAgentProvisioning`'s reality-derived status + * (`agentProvisioningState.ts`) rather than a stored counter, so reopening + * this dialog for an agent that is already partway funded resumes correctly. + * Renders nothing once TOON is not the active transport, or once the agent + * is fully provisioned. + */ +export function AgentProvisioningDialog({ + agent, + onDismiss, +}: { + agent: { pubkey: string; name: string } | null; + onDismiss: () => void; +}) { + const provisioning = useAgentProvisioning(agent?.pubkey ?? ""); + const open = + agent !== null && + provisioning.active && + provisioning.status.step !== "done"; + + return ( + !next && onDismiss()} open={open}> + + + + Set up {agent?.name ?? "this agent"}'s wallet + + + Derive a payment key, fund it from your own wallet, and open a + payment channel so this agent can pay for TOON writes. + + + + + +
+ {provisioning.status.step === "key" ? ( +

+ Waiting for the agent's payment key to be assigned… +

+ ) : provisioning.status.step === "fund" ? ( + + ) : provisioning.status.step === "channel" ? ( + + ) : null} +
+ +
+ +
+
+
+ ); +} + +function FundStep({ + provisioning, +}: { + provisioning: ReturnType; +}) { + return ( +
+

+ Sends native gas and USDC from your own wallet to this agent's derived + address. +

+ {provisioning.address ? ( +
+ {provisioning.address} +
+ ) : null} + +
+ USDC + + {provisioning.balances.tokenBaseUnits !== null + ? formatUsdcBaseUnits(provisioning.balances.tokenBaseUnits) + : "—"} + +
+
+ Native gas + {provisioning.status.hasNativeGas ? "funded" : "none yet"} +
+ + {provisioning.status.needsManualGasTopUp ? ( + + + USDC arrived, but the gas transfer didn't land this time — retry, or + send a small amount of native gas to the address above by hand. + + + ) : null} + {provisioning.fundError ? ( + + {provisioning.fundError} + + ) : null} + + +
+ ); +} + +function ChannelStep({ + provisioning, +}: { + provisioning: ReturnType; +}) { + return ( +
+

+ Opening a payment channel locks{" "} + + {provisioning.balances.tokenBaseUnits !== null + ? formatUsdcBaseUnits(provisioning.balances.tokenBaseUnits) + : "the funded amount"} + {" "} + as collateral — released when the channel is closed. This is an on-chain + transaction. +

+ {provisioning.channelError ? ( + + {provisioning.channelError} + + ) : null} + +
+ ); +} diff --git a/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx b/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx index ee800b5cb59..ee543018362 100644 --- a/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx +++ b/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx @@ -6,6 +6,7 @@ import { type OpenCreateAgentOptions, } from "@/features/agents/openCreateAgentEvent"; import { AgentDialog } from "./AgentDialog"; +import { AgentProvisioningDialog } from "./AgentProvisioningDialog"; import { SecretRevealDialog } from "./SecretRevealDialog"; import { usePersonaActions } from "./usePersonaActions"; @@ -17,6 +18,22 @@ export function RequestedAgentCreateDialogs() { name: string; } | null>(null); const [isOpen, setIsOpen] = React.useState(false); + const [provisioningAgent, setProvisioningAgent] = React.useState<{ + pubkey: string; + name: string; + } | null>(null); + + // Hand off to provisioning (buzz#74) once the secret-reveal dialog closes + // for a successfully created (non-spawn-error) agent — sequential, not + // simultaneous, so the operator sees one dialog at a time. + const previousCreatedAgent = React.useRef(personas.createdAgent); + React.useEffect(() => { + const was = previousCreatedAgent.current; + previousCreatedAgent.current = personas.createdAgent; + if (was && !personas.createdAgent && !was.spawnError) { + setProvisioningAgent({ pubkey: was.agent.pubkey, name: was.agent.name }); + } + }, [personas.createdAgent]); const openCreate = React.useEffectEvent((options: OpenCreateAgentOptions) => { personas.prepareCreate(); @@ -71,6 +88,10 @@ export function RequestedAgentCreateDialogs() { }} /> ) : null} + setProvisioningAgent(null)} + /> ); } diff --git a/desktop/src/features/agents/useAgentProvisioning.ts b/desktop/src/features/agents/useAgentProvisioning.ts new file mode 100644 index 00000000000..dcedaaa9fb6 --- /dev/null +++ b/desktop/src/features/agents/useAgentProvisioning.ts @@ -0,0 +1,245 @@ +import * as React from "react"; + +import { readToonOnboardingBalances } from "@/features/onboarding/toon/toonOnboardingBalances"; +import { getStoredMnemonic } from "@/features/onboarding/toon/toonOnboardingStore"; +import { getManagedAgentAccountIndex } from "@/shared/api/tauriAgentProvisioning"; +import { getActiveTransportSelection } from "@/shared/api/transportSelection"; +import { + DEFAULT_AGENT_NATIVE_GAS_BASE_UNITS, + deriveInitialAllowanceBaseUnits, +} from "./lib/agentProvisioningAllowance"; +import { + deriveAgentProvisioningStatus, + type AgentProvisioningStatus, +} from "./lib/agentProvisioningState"; +import { + isAgentChannelConfirmed, + setAgentChannelConfirmed, + subscribeToAgentProvisioningState, +} from "./lib/agentProvisioningStore"; +import { + buildAgentProvisioningClient, + buildOwnerProvisioningClient, + deriveAgentAddress, + fundAgentWallet, + openAgentChannel, +} from "./lib/provisionAgent"; + +/** + * Wires the pure step-derivation (`agentProvisioningState.ts`) to the + * network calls buzz#74's provisioning flow needs a step at a time: derive + * the agent's address from the owner's mnemonic, fund it, open its channel. + * + * Mirrors `useToonOnboarding.ts`'s shape and its "deliberately un-clever + * about polling" stance — every network step is a button press the caller UI + * drives, not a background loop, and the flow reads its own progress from + * live state on mount, so quitting mid-flow and reopening resumes correctly. + */ + +export type AgentProvisioningBalanceState = { + tokenBaseUnits: bigint | null; + nativeBaseUnits: bigint | null; + /** True once at least one read has completed (success or failure). */ + checked: boolean; + unreadable: boolean; +}; + +export function useAgentProvisioning(pubkey: string) { + const selection = getActiveTransportSelection(); + const active = selection?.mode === "toon"; + const config = selection?.config ?? null; + const ownerMnemonic = config?.mnemonic ?? getStoredMnemonic(); + + const [accountIndex, setAccountIndex] = React.useState(null); + const [address, setAddress] = React.useState(null); + const [balances, setBalances] = React.useState( + { + tokenBaseUnits: null, + nativeBaseUnits: null, + checked: false, + unreadable: false, + }, + ); + const [balancesLoading, setBalancesLoading] = React.useState(false); + const [fundLoading, setFundLoading] = React.useState(false); + const [fundError, setFundError] = React.useState(null); + const [channelLoading, setChannelLoading] = React.useState(false); + const [channelError, setChannelError] = React.useState(null); + + const channelConfirmed = React.useSyncExternalStore( + subscribeToAgentProvisioningState, + () => isAgentChannelConfirmed(pubkey), + ); + + // Resolve the account index once — `create_managed_agent` already assigns + // it synchronously at creation (buzz#79), so this is a read, not a wait. + // Skipped for an empty pubkey (no agent selected yet — `AgentProvisioningDialog` + // stays mounted with `agent: null` between creations). + React.useEffect(() => { + if (!pubkey) { + setAccountIndex(null); + return; + } + let cancelled = false; + getManagedAgentAccountIndex(pubkey) + .then((index) => { + if (!cancelled) setAccountIndex(index); + }) + .catch((error: unknown) => { + console.error( + "[agent-provisioning] could not read the account index", + error, + ); + }); + return () => { + cancelled = true; + }; + }, [pubkey]); + + // Derive the address whenever the owner mnemonic or index resolve — pure, + // local, offline, so there is no reason to gate this behind a button. + React.useEffect(() => { + if (!ownerMnemonic || accountIndex === null) { + setAddress(null); + return; + } + let cancelled = false; + deriveAgentAddress(ownerMnemonic, accountIndex) + .then((derived) => { + if (!cancelled) setAddress(derived); + }) + .catch((error: unknown) => { + console.error( + "[agent-provisioning] could not derive the agent's address", + error, + ); + }); + return () => { + cancelled = true; + }; + }, [ownerMnemonic, accountIndex]); + + const readBalances = React.useCallback(async () => { + if (!config || !address) return; + setBalancesLoading(true); + try { + const result = await readToonOnboardingBalances(config, address); + setBalances({ + tokenBaseUnits: result.tokenBaseUnits, + nativeBaseUnits: result.nativeBaseUnits, + checked: true, + unreadable: result.unreadable, + }); + } catch (error) { + console.error("[agent-provisioning] balance read failed", error); + setBalances((prev) => ({ ...prev, checked: true, unreadable: true })); + } finally { + setBalancesLoading(false); + } + }, [config, address]); + + // One read as soon as there is an address to read, so the fund step opens + // already knowing whether an earlier attempt already funded this address. + React.useEffect(() => { + if (address) void readBalances(); + }, [address, readBalances]); + + const status: AgentProvisioningStatus = deriveAgentProvisioningStatus({ + accountIndex, + usdcBaseUnits: balances.tokenBaseUnits, + nativeBaseUnits: balances.nativeBaseUnits, + channelConfirmed, + }); + + /** + * Fund the agent's address with native gas and settlement token from the + * owner's own wallet. `sendTransfer` (toon-client#491) already confirms + * delivery by an observed balance delta before resolving, so a single + * re-read afterward is enough — no polling loop needed. + */ + const fund = React.useCallback(async () => { + if (!config || !address) return; + setFundLoading(true); + setFundError(null); + try { + const ownerClient = await buildOwnerProvisioningClient(config); + try { + const quotedPrice = + (await ownerClient.getRoutePrice?.(config.destination)) ?? null; + const tokenAmount = deriveInitialAllowanceBaseUnits({ + measuredBurnRateBaseUnitsPerSec: null, + quotedWritePriceBaseUnits: quotedPrice, + }); + const result = await fundAgentWallet({ + ownerClient, + agentAddress: address, + chain: "evm", + nativeAmountBaseUnits: DEFAULT_AGENT_NATIVE_GAS_BASE_UNITS, + tokenAmountBaseUnits: tokenAmount, + }); + const failures = [result.native, result.token].filter( + (leg) => leg.status === "error", + ); + if (failures.length > 0) { + setFundError( + failures + .map((leg) => (leg.status === "error" ? leg.message : "")) + .join(" / "), + ); + } + } finally { + await ownerClient.stop(); + } + } catch (error) { + setFundError(error instanceof Error ? error.message : String(error)); + } finally { + setFundLoading(false); + } + await readBalances(); + }, [config, address, readBalances]); + + /** + * Open the agent's own channel, collateralized with whatever landed in + * the fund step — the actual balance on hand, not a fresh estimate, so + * this is correct even resumed in a later session with no memory of what + * the fund step originally computed. + */ + const openChannel = React.useCallback(async () => { + if (!config || accountIndex === null || balances.tokenBaseUnits === null) + return; + setChannelLoading(true); + setChannelError(null); + try { + const agentClient = await buildAgentProvisioningClient( + config, + accountIndex, + balances.tokenBaseUnits, + ); + await openAgentChannel({ + agentClient, + destination: config.destination, + }); + setAgentChannelConfirmed(pubkey, true); + } catch (error) { + setChannelError(error instanceof Error ? error.message : String(error)); + } finally { + setChannelLoading(false); + } + }, [config, accountIndex, balances.tokenBaseUnits, pubkey]); + + return { + active, + config, + status, + address, + balances, + balancesLoading, + refreshBalances: readBalances, + fund, + fundLoading, + fundError, + openChannel, + channelLoading, + channelError, + }; +} diff --git a/desktop/src/shared/api/tauriAgentProvisioning.ts b/desktop/src/shared/api/tauriAgentProvisioning.ts new file mode 100644 index 00000000000..28c9020773b --- /dev/null +++ b/desktop/src/shared/api/tauriAgentProvisioning.ts @@ -0,0 +1,18 @@ +import { invokeTauri } from "@/shared/api/tauri"; + +/** + * The BIP-44 account index `create_managed_agent` already assigned this + * agent (buzz#79), or `null` if it has none yet — the provisioning flow + * (buzz#74) needs this to derive the agent's own TOON payment address. + * + * Split from `tauri.ts` rather than added there — that file is already at + * the desktop file-size ratchet ceiling, and every other binding this small + * follows the same split (`tauriGlobalAgentConfig.ts`, `agentControl.ts`). + */ +export async function getManagedAgentAccountIndex( + pubkey: string, +): Promise { + return invokeTauri("get_managed_agent_account_index", { + pubkey, + }); +} diff --git a/desktop/src/shared/api/toonPaidWriter.ts b/desktop/src/shared/api/toonPaidWriter.ts index 2213f764e2a..5e76f3eb779 100644 --- a/desktop/src/shared/api/toonPaidWriter.ts +++ b/desktop/src/shared/api/toonPaidWriter.ts @@ -247,7 +247,12 @@ export function transportEndpointFields( } /** - * Build a real `ToonClient` for `config`. + * The `ToonClient` constructor options for `config` at `accountIndex` — the + * identity/settlement bootstrap every `ToonClient` this app builds shares, + * whether it is this writer's own client or one of buzz#74's provisioning + * clients (`provisionAgent.ts`'s owner-scoped client for `sendTransfer`, and + * agent-scoped client for `openChannel`), which need a different account + * index and initial deposit but nothing else about the bootstrap. * * `supportedChains` and `chainRpcUrls` are both load-bearing and easy to * mistake for optional: the client only constructs an on-chain channel client @@ -265,22 +270,36 @@ export function transportEndpointFields( * them. The exact BTP config shape is the one proven live by the huddle * prototype (toon-meta `proto/huddle-multi-speaker`, `multi.mjs`). */ -const createToonClient: PaidClientFactory = async (config) => { +export async function buildToonClientOptions( + config: Pick< + ToonTransportConfig, + | "mnemonic" + | "connectorUrl" + | "btpUrl" + | "proxyUrl" + | "relayUrl" + | "destination" + | "chain" + | "chainRpcUrl" + | "tokenNetwork" + | "preferredToken" + >, + accountIndex: number, + initialDeposit?: string | null, +): Promise> { if (config.mnemonic === null) { throw new ToonPaidWriteError( "No TOON payment identity configured (BUZZ_TOON_MNEMONIC).", ); } - const [{ ToonClient }, { encodeEventToToon, decodeEventFromToon }] = - await Promise.all([ - import("@toon-protocol/client"), - import("@toon-protocol/core"), - ]); + const { encodeEventToToon, decodeEventFromToon } = await import( + "@toon-protocol/core" + ); - return new ToonClient({ + return { mnemonic: config.mnemonic, - mnemonicAccountIndex: config.accountIndex, + mnemonicAccountIndex: accountIndex, ...transportEndpointFields(config), relayUrl: config.relayUrl, destinationAddress: config.destination, @@ -299,10 +318,16 @@ const createToonClient: PaidClientFactory = async (config) => { preferredTokens: { [config.chain]: config.preferredToken }, // Collateral for a fresh channel open. The client's own default (0.1 // USDC) is exhausted by ~2 seconds of huddle audio; see the config field. - ...(config.initialDeposit !== null - ? { initialDeposit: config.initialDeposit } - : {}), - }) as unknown as PaidClient; + ...(initialDeposit != null ? { initialDeposit } : {}), + }; +} + +const createToonClient: PaidClientFactory = async (config) => { + const [options, { ToonClient }] = await Promise.all([ + buildToonClientOptions(config, config.accountIndex, config.initialDeposit), + import("@toon-protocol/client"), + ]); + return new ToonClient(options as never) as unknown as PaidClient; }; export class ToonPaidWriter { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5741bd510b..84f4ff79984 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -184,8 +184,8 @@ importers: specifier: ^0.2.0 version: 0.2.0 '@toon-protocol/client': - specifier: ^0.25.1 - version: 0.25.1(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(@tanstack/query-core@5.100.14)(@tanstack/react-query@5.100.14(react@19.2.8))(@types/react@19.2.17)(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.8)(typescript@6.0.3)(utf-8-validate@6.0.6)(zod@4.4.3) + specifier: ^0.26.0 + version: 0.26.0(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(@tanstack/query-core@5.100.14)(@tanstack/react-query@5.100.14(react@19.2.8))(@types/react@19.2.17)(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.8)(typescript@6.0.3)(utf-8-validate@6.0.6)(zod@4.4.3) '@toon-protocol/core': specifier: ^3.1.4 version: 3.1.4(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(@tanstack/query-core@5.100.14)(@tanstack/react-query@5.100.14(react@19.2.8))(@types/react@19.2.17)(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.8)(typescript@6.0.3)(utf-8-validate@6.0.6) @@ -3128,8 +3128,8 @@ packages: '@toon-protocol/arweave@0.2.0': resolution: {integrity: sha512-+SQSrzzg1hmm6lzjptuCNxDcT8TkTEhP7E3k9HKdOm5kUHiCgdVfCtvnquzskmPYRSeEDKl7TJq0Lh5eY5hSqQ==} - '@toon-protocol/client@0.25.1': - resolution: {integrity: sha512-D80IA/SpNh30TXdZNjGDwezkKgMx4IMqlpvE2r4AkorKWlhPHtIfR2TnUYis2CCwEveFxLI8U9jnG/gSdIC8lw==} + '@toon-protocol/client@0.26.0': + resolution: {integrity: sha512-mb878Un/qQWjkqfFjcOJPSIGUYCwTNI3i38dnmqxCNhdlsn3mHJmsxUz6uzFackiG6A4S0qYR+uR9K38xhzaww==} '@toon-protocol/core@3.1.3': resolution: {integrity: sha512-qN+RXHwriLXDl67hjBWiWXo49aqLBBtOnuh+aykpjKDyuMe26cKEHraV7ydiSpHFefgzA/yOAmuKl+ArmKcpZw==} @@ -10012,7 +10012,7 @@ snapshots: '@toon-protocol/arweave@0.2.0': {} - '@toon-protocol/client@0.25.1(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(@tanstack/query-core@5.100.14)(@tanstack/react-query@5.100.14(react@19.2.8))(@types/react@19.2.17)(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.8)(typescript@6.0.3)(utf-8-validate@6.0.6)(zod@4.4.3)': + '@toon-protocol/client@0.26.0(@solana/sysvars@5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3))(@tanstack/query-core@5.100.14)(@tanstack/react-query@5.100.14(react@19.2.8))(@types/react@19.2.17)(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.8)(typescript@6.0.3)(utf-8-validate@6.0.6)(zod@4.4.3)': dependencies: '@noble/ciphers': 2.1.1 '@noble/curves': 2.0.1 @@ -11480,6 +11480,11 @@ snapshots: typescript: 6.0.3 zod: 4.4.3 + abitype@1.3.0(typescript@6.0.3)(zod@3.22.4): + optionalDependencies: + typescript: 6.0.3 + zod: 3.22.4 + abitype@1.3.0(typescript@6.0.3)(zod@3.25.76): optionalDependencies: typescript: 6.0.3 @@ -13247,7 +13252,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@6.0.3)(zod@3.22.4) + abitype: 1.3.0(typescript@6.0.3)(zod@3.22.4) eventemitter3: 5.0.1 optionalDependencies: typescript: 6.0.3 @@ -13262,7 +13267,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@6.0.3)(zod@3.25.76) + abitype: 1.3.0(typescript@6.0.3)(zod@3.25.76) eventemitter3: 5.0.1 optionalDependencies: typescript: 6.0.3 @@ -13277,7 +13282,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@6.0.3)(zod@4.4.3) + abitype: 1.3.0(typescript@6.0.3)(zod@4.4.3) eventemitter3: 5.0.1 optionalDependencies: typescript: 6.0.3 @@ -13287,11 +13292,11 @@ snapshots: ox@0.6.7(typescript@6.0.3)(zod@3.25.76): dependencies: '@adraffy/ens-normalize': 1.11.1 - '@noble/curves': 1.8.1 - '@noble/hashes': 1.7.1 - '@scure/bip32': 1.6.2 - '@scure/bip39': 1.5.4 - abitype: 1.0.8(typescript@6.0.3)(zod@3.25.76) + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.3.0(typescript@6.0.3)(zod@3.25.76) eventemitter3: 5.0.1 optionalDependencies: typescript: 6.0.3 @@ -13301,11 +13306,11 @@ snapshots: ox@0.6.7(typescript@6.0.3)(zod@4.4.3): dependencies: '@adraffy/ens-normalize': 1.11.1 - '@noble/curves': 1.8.1 - '@noble/hashes': 1.7.1 - '@scure/bip32': 1.6.2 - '@scure/bip39': 1.5.4 - abitype: 1.0.8(typescript@6.0.3)(zod@4.4.3) + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.3.0(typescript@6.0.3)(zod@4.4.3) eventemitter3: 5.0.1 optionalDependencies: typescript: 6.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f9e51ea891d..4a48e0b3f5f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -15,7 +15,7 @@ allowBuilds: secp256k1: false utf-8-validate: false minimumReleaseAgeExclude: - - '@toon-protocol/client@0.25.1' + - '@toon-protocol/client@0.26.0' - '@toon-protocol/core@3.1.4' overrides: # Force a single copy of the dismissable-layer. Radix packages otherwise