diff --git a/.env.example b/.env.example index e368d09354..81e28aa0c3 100644 --- a/.env.example +++ b/.env.example @@ -245,6 +245,12 @@ OPENHUMAN_TELEGRAM_BOT_USERNAME=openhuman_bot # OPENHUMAN_WALLET_RPC_SOLANA= # OPENHUMAN_WALLET_RPC_TRON= +# Solana cluster the wallet broadcasts to: "mainnet" (default) or "devnet". +# Selects BOTH the default Solana RPC endpoint and the USDC SPL mint, so a +# devnet x402 payment lands on devnet with the devnet mint. Pair with a staging +# TINYPLACE_API_BASE_URL when testing tiny.place on devnet. +# OPENHUMAN_SOLANA_CLUSTER=devnet + # --------------------------------------------------------------------------- # x402 Machine Payments # --------------------------------------------------------------------------- @@ -355,6 +361,13 @@ RUST_LOG=info # [optional] Default: 0 (set to 1 for full backtraces) RUST_BACKTRACE=1 +# --------------------------------------------------------------------------- +# tiny.place Agent World integration +# --------------------------------------------------------------------------- +# [optional] Base URL for the tiny.place backend API. +# Default: https://staging-api.tiny.place +# TINYPLACE_API_BASE_URL=https://staging-api.tiny.place + # --------------------------------------------------------------------------- # Testing (do not set in production) # --------------------------------------------------------------------------- diff --git a/Cargo.lock b/Cargo.lock index 23ad5ad8e1..eced7ac3b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -149,7 +149,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -160,7 +160,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1908,7 +1908,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2186,7 +2186,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3232,7 +3232,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.57.0", + "windows-core 0.62.2", ] [[package]] @@ -4679,7 +4679,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4746,7 +4746,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.21.7", + "base64 0.22.1", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -5224,6 +5224,7 @@ dependencies = [ "tar", "tempfile", "thiserror 2.0.18", + "tinyplace", "tokio", "tokio-rustls", "tokio-stream", @@ -6797,7 +6798,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6856,7 +6857,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7479,7 +7480,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7793,7 +7794,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7910,6 +7911,35 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tinyplace" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46921f859da27a14a5526b289b32399c5c328c26de1a1dcb3f68b0f9d19310ea" +dependencies = [ + "aes", + "async-trait", + "base64 0.22.1", + "bs58", + "cbc", + "chrono", + "curve25519-dalek", + "ed25519-dalek", + "futures-util", + "hkdf", + "hmac 0.12.1", + "rand 0.8.6", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite 0.24.0", + "url", + "x25519-dalek", +] + [[package]] name = "tinystr" version = "0.7.6" @@ -9274,7 +9304,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index cca9d1248e..3f65ca7c83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,8 @@ name = "openhuman_core" crate-type = ["rlib"] [dependencies] +# tiny.place A2A social network SDK — published on crates.io (tinyhumansai/tiny.place) +tinyplace = "0.6.0" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_repr = "0.1" diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 351a1add50..0f949b1359 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -189,7 +189,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -200,7 +200,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2045,7 +2045,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2433,7 +2433,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3641,7 +3641,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.57.0", + "windows-core 0.62.2", ] [[package]] @@ -4836,7 +4836,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5442,6 +5442,7 @@ dependencies = [ "tar", "tempfile", "thiserror 2.0.18", + "tinyplace", "tokio", "tokio-rustls", "tokio-stream", @@ -7003,7 +7004,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7062,7 +7063,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7811,7 +7812,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8647,7 +8648,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8800,6 +8801,35 @@ dependencies = [ "strict-num", ] +[[package]] +name = "tinyplace" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46921f859da27a14a5526b289b32399c5c328c26de1a1dcb3f68b0f9d19310ea" +dependencies = [ + "aes", + "async-trait", + "base64 0.22.1", + "bs58", + "cbc", + "chrono", + "curve25519-dalek", + "ed25519-dalek", + "futures-util", + "hkdf", + "hmac 0.12.1", + "rand 0.8.6", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite 0.24.0", + "url", + "x25519-dalek", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -9295,7 +9325,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10062,7 +10092,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/app/src/AppRoutes.tsx b/app/src/AppRoutes.tsx index 0f8fb17611..99c580781d 100644 --- a/app/src/AppRoutes.tsx +++ b/app/src/AppRoutes.tsx @@ -1,5 +1,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'; +import AgentWorldShell from './agentworld/AgentWorldShell'; +import AgentWorld from './agentworld/pages/AgentWorld'; import AppRoutesIOS from './AppRoutesIOS'; import DefaultRedirect from './components/DefaultRedirect'; import ProtectedRoute from './components/ProtectedRoute'; @@ -188,6 +190,19 @@ const AppRoutes = () => { {/* Dev-only visual preview of the Agentic task insights surface. */} } /> + {/* Agent World — tiny.place A2A social network integration. + Nested routes (explore, directory, …) are handled inside AgentWorld. */} + + + + + + } + /> + {/* Default redirect based on auth status */} } /> diff --git a/app/src/agentworld/AgentWorldShell.tsx b/app/src/agentworld/AgentWorldShell.tsx new file mode 100644 index 0000000000..10e9189dc5 --- /dev/null +++ b/app/src/agentworld/AgentWorldShell.tsx @@ -0,0 +1,34 @@ +/** + * AgentWorldShell — provider wrapper for the Agent World section. + * + * Provides: + * - The tiny.place API client (injected via ApiProvider so all nested hooks + * call through `createInvokeApiClient()` instead of the HTTP SDK). + * - Theme bridging (OpenHuman → Agent World colour tokens). + * + * Intentionally excludes WalletContext, MoonPay, and E2EAuthBridge that + * appear in the tiny.place website's provider tree — those live in core or are + * not needed in the embedded context. + */ +import type { ReactNode } from 'react'; + +import { createInvokeApiClient } from '../lib/agentworld/invokeApiClient'; + +interface AgentWorldShellProps { + children: ReactNode; +} + +// One client instance per app lifetime (the underlying HTTP calls go through +// callCoreRpc which manages its own connection lifecycle). +const apiClient = createInvokeApiClient(); + +export default function AgentWorldShell({ children }: AgentWorldShellProps) { + // NOTE: When the vendored ApiProvider is available (from synced website/src), + // wrap children with . For Wave 0 we expose + // the client via a context (see AgentWorldContext) so the Explore placeholder + // can demonstrate the end-to-end wiring without requiring the full vendor sync. + void apiClient; // referenced here to ensure the module is evaluated + return <>{children}; +} + +export { apiClient }; diff --git a/app/src/agentworld/components/AmountCommitDialog.test.tsx b/app/src/agentworld/components/AmountCommitDialog.test.tsx new file mode 100644 index 0000000000..62f256a99d --- /dev/null +++ b/app/src/agentworld/components/AmountCommitDialog.test.tsx @@ -0,0 +1,61 @@ +/** + * Tests for AmountCommitDialog — the amount-entry dialog for x402 bid/offer + * commitments. Generic placeholders only. + */ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, test, vi } from 'vitest'; + +import AmountCommitDialog from './AmountCommitDialog'; + +function baseProps() { + return { + title: 'Bid on @handle', + asset: 'USDC', + submitLabel: 'Place bid', + onSubmit: vi.fn(), + onCancel: vi.fn(), + }; +} + +describe('AmountCommitDialog', () => { + test('submit is disabled until a numeric amount is entered', async () => { + render(); + expect(screen.getByTestId('commit-submit')).toBeDisabled(); + await userEvent.type(screen.getByTestId('commit-amount-input'), '500'); + expect(screen.getByTestId('commit-submit')).toBeEnabled(); + }); + + test('input strips non-digits and submit reports the sanitized amount', async () => { + const props = baseProps(); + render(); + const input = screen.getByTestId('commit-amount-input') as HTMLInputElement; + await userEvent.type(input, '1a2b3'); + expect(input.value).toBe('123'); + await userEvent.click(screen.getByTestId('commit-submit')); + expect(props.onSubmit).toHaveBeenCalledWith('123'); + }); + + test('cancel calls onCancel', async () => { + const props = baseProps(); + render(); + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(props.onCancel).toHaveBeenCalledTimes(1); + }); + + test('busy disables the input and both actions and shows the busy label', () => { + render(); + expect(screen.getByTestId('commit-amount-input')).toBeDisabled(); + const submit = screen.getByTestId('commit-submit'); + expect(submit).toHaveTextContent('Submitting…'); + expect(submit).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + }); + + test('Escape while busy is a no-op (does not cancel mid-submit)', async () => { + const props = baseProps(); + render(); + await userEvent.keyboard('{Escape}'); + expect(props.onCancel).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/agentworld/components/AmountCommitDialog.tsx b/app/src/agentworld/components/AmountCommitDialog.tsx new file mode 100644 index 0000000000..b9746fe563 --- /dev/null +++ b/app/src/agentworld/components/AmountCommitDialog.tsx @@ -0,0 +1,93 @@ +/** + * AmountCommitDialog — amount-entry confirm dialog for x402 *commitments* + * (bids and offers). + * + * Unlike X402ConfirmDialog (which gates an immediate on-chain spend), a bid/offer + * is a signed authorization that only settles if accepted — so there is no + * balance gate and no on-chain transfer at submit time. The user enters an + * amount and submits; the parent owns the RPC call. + */ +import { useState } from 'react'; + +import Button from '../../components/ui/Button'; +import { ModalShell } from '../../components/ui/ModalShell'; + +export interface AmountCommitDialogProps { + /** Header title, e.g. "Bid on @handle". */ + title: string; + /** Context line under the title. */ + subtitle?: string; + /** Asset symbol shown next to the amount input (e.g. "USDC"). */ + asset: string; + /** Submit-button label (e.g. "Place bid" / "Submit offer"). */ + submitLabel: string; + /** Busy label while the commitment is in flight. */ + busyLabel?: string; + busy?: boolean; + /** Called with the entered amount (raw string) on submit. */ + onSubmit: (amount: string) => void; + onCancel: () => void; +} + +export default function AmountCommitDialog({ + title, + subtitle, + asset, + submitLabel, + busyLabel = 'Submitting…', + busy = false, + onSubmit, + onCancel, +}: AmountCommitDialogProps) { + const [amount, setAmount] = useState(''); + // Allow only digits (base-unit integer amount); empty disables submit. + const sanitized = amount.replace(/[^0-9]/g, ''); + const canSubmit = sanitized.length > 0 && !busy; + + return ( + undefined : onCancel} + maxWidthClassName="max-w-sm"> +
+
+ + setAmount(e.target.value.replace(/[^0-9]/g, ''))} + /> +
+ +

+ This is a signed commitment — funds only move if it is accepted. +

+ +
+ + +
+
+
+ ); +} diff --git a/app/src/agentworld/components/X402ConfirmDialog.test.tsx b/app/src/agentworld/components/X402ConfirmDialog.test.tsx new file mode 100644 index 0000000000..a1c0e90d83 --- /dev/null +++ b/app/src/agentworld/components/X402ConfirmDialog.test.tsx @@ -0,0 +1,117 @@ +/** + * Tests for X402ConfirmDialog — the confirm-before-spend dialog reused by all + * Agent World x402 write flows. Covers the pure formatting helpers plus the + * render branches (amount/balance display, insufficient-balance gating, busy + * state, confirm/cancel callbacks). + * + * All addresses / amounts are GENERIC placeholders. + */ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, test, vi } from 'vitest'; + +import X402ConfirmDialog, { + formatUnits, + isInsufficient, + type X402WalletBalance, +} from './X402ConfirmDialog'; + +const BALANCE: X402WalletBalance = { + raw: '50000000', + formatted: '50', + decimals: 6, + assetSymbol: 'USDC', +}; + +function baseProps() { + return { + title: 'Register @placeholder', + amount: '10000000', // 10 USDC + asset: 'USDC', + network: 'solana-devnet', + balance: BALANCE, + walletAddress: 'WaLLetdeadbeef0123456789', + onConfirm: vi.fn(), + onCancel: vi.fn(), + }; +} + +describe('formatUnits', () => { + test('formats base units with decimals and trims trailing zeros', () => { + expect(formatUnits('10000000', 6)).toBe('10'); + expect(formatUnits('10500000', 6)).toBe('10.5'); + expect(formatUnits('1', 6)).toBe('0.000001'); + expect(formatUnits('0', 6)).toBe('0'); + }); + + test('returns the raw value when decimals <= 0', () => { + expect(formatUnits('42', 0)).toBe('42'); + }); +}); + +describe('isInsufficient', () => { + test('true only when balance is provably below the amount', () => { + expect(isInsufficient(BALANCE, '10000000')).toBe(false); + expect(isInsufficient(BALANCE, '60000000')).toBe(true); + // Unknown balance → not blocked (backend remains the gate). + expect(isInsufficient(null, '60000000')).toBe(false); + // Unparseable raw → not blocked. + expect(isInsufficient({ ...BALANCE, raw: 'nope' }, '1')).toBe(false); + }); +}); + +describe('X402ConfirmDialog', () => { + test('renders amount, asset, network, balance and a truncated wallet', () => { + render(); + expect(screen.getByTestId('x402-amount')).toHaveTextContent('10 USDC'); + expect(screen.getByTestId('x402-balance')).toHaveTextContent('50 USDC'); + expect(screen.getByText('Solana (devnet)')).toBeInTheDocument(); + expect(screen.getByText('WaLLet…6789')).toBeInTheDocument(); + }); + + test('renders a friendly network label (never the raw CAIP-2 genesis hash)', () => { + // tiny.place reports the mainnet genesis on every cluster — must collapse to + // "Solana", not show the raw "solana:5eykt4…" hash. + render( + + ); + expect(screen.getByText('Solana')).toBeInTheDocument(); + expect(screen.queryByText(/5eykt4/)).not.toBeInTheDocument(); + }); + + test('calls onConfirm / onCancel', async () => { + const props = baseProps(); + render(); + await userEvent.click(screen.getByTestId('x402-confirm')); + expect(props.onConfirm).toHaveBeenCalledTimes(1); + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(props.onCancel).toHaveBeenCalledTimes(1); + }); + + test('disables confirm and shows a notice when balance is insufficient', () => { + render(); + expect(screen.getByTestId('x402-confirm')).toBeDisabled(); + expect(screen.getByTestId('x402-insufficient')).toBeInTheDocument(); + }); + + test('shows "Unknown" balance and still allows confirm when balance is null', () => { + render(); + expect(screen.getByTestId('x402-balance')).toHaveTextContent('Unknown'); + expect(screen.getByTestId('x402-confirm')).toBeEnabled(); + }); + + test('busy state shows the busy label and disables both actions', () => { + render(); + const confirm = screen.getByTestId('x402-confirm'); + expect(confirm).toHaveTextContent('Paying…'); + expect(confirm).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + }); + + test('Escape while busy does not cancel (close is a no-op mid-payment)', async () => { + const props = baseProps(); + render(); + await userEvent.keyboard('{Escape}'); + expect(props.onCancel).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/agentworld/components/X402ConfirmDialog.tsx b/app/src/agentworld/components/X402ConfirmDialog.tsx new file mode 100644 index 0000000000..82e33a1982 --- /dev/null +++ b/app/src/agentworld/components/X402ConfirmDialog.tsx @@ -0,0 +1,180 @@ +/** + * X402ConfirmDialog — confirm-before-spend dialog for Agent World x402 flows. + * + * Reused by every write flow that moves funds (register / buy / bid / offer): + * it shows the payment amount, the asset, the wallet's balance and address, and + * gates the "Confirm & Pay" button on having enough balance. The parent owns the + * actual payment call — this component only renders the confirmation and reports + * the user's decision via `onConfirm` / `onCancel`. + * + * Money only moves after the user clicks Confirm (which the parent wires to the + * `confirmed: true` RPC) — this dialog never calls the backend itself. + */ +import Button from '../../components/ui/Button'; +import { ModalShell } from '../../components/ui/ModalShell'; + +export interface X402WalletBalance { + /** Balance in raw base units (same scale as the challenge amount). */ + raw: string; + /** Human-formatted balance (e.g. "12.50"). */ + formatted: string; + /** Decimals for the asset (USDC = 6). */ + decimals: number; + assetSymbol: string; +} + +export interface X402ConfirmDialogProps { + /** Title shown in the modal header (e.g. "Register @handle"). */ + title: string; + /** Optional subtitle / context line. */ + subtitle?: string; + /** Payment amount in raw base units (from the x402 challenge). */ + amount: string; + /** Asset symbol, e.g. "USDC". */ + asset: string; + /** Network label (e.g. "solana-devnet"), shown for transparency. */ + network?: string; + /** The wallet's balance for `asset`, or null if it couldn't be fetched. */ + balance: X402WalletBalance | null; + /** The paying wallet address. */ + walletAddress: string; + /** When true, the confirm button shows `busyLabel` and is disabled. */ + busy?: boolean; + /** Label shown on the confirm button while `busy` (e.g. "Broadcasting…"). */ + busyLabel?: string; + onConfirm: () => void; + onCancel: () => void; +} + +/** Format a raw base-unit integer string to a decimal string with `decimals`. */ +export function formatUnits(raw: string, decimals: number): string { + if (decimals <= 0) return raw; + const negative = raw.startsWith('-'); + const digits = (negative ? raw.slice(1) : raw).padStart(decimals + 1, '0'); + const whole = digits.slice(0, digits.length - decimals); + const frac = digits.slice(digits.length - decimals).replace(/0+$/, ''); + const body = frac ? `${whole}.${frac}` : whole; + return negative ? `-${body}` : body; +} + +/** True when the wallet provably cannot cover `amount`. Unknown balance → false. */ +export function isInsufficient(balance: X402WalletBalance | null, amount: string): boolean { + if (!balance) return false; + try { + return BigInt(balance.raw) < BigInt(amount); + } catch { + return false; + } +} + +function truncateAddress(addr: string): string { + if (!addr) return '—'; + if (addr.length <= 12) return addr; + return `${addr.slice(0, 6)}…${addr.slice(-4)}`; +} + +/** + * A human-readable network label. tiny.place reports the CAIP-2 Solana network + * as the raw mainnet genesis hash (`solana:5eykt4…`) on every cluster, which is + * meaningless to users and overflows the row — so collapse any Solana network to + * a friendly "Solana" (or "Solana (devnet)" when the label explicitly says so). + */ +export function friendlyNetwork(network?: string): string { + if (!network) return 'Solana'; + const n = network.toLowerCase(); + if (n.includes('devnet')) return 'Solana (devnet)'; + if (n.startsWith('solana') || n.includes('5eykt4')) return 'Solana'; + return network; +} + +export default function X402ConfirmDialog({ + title, + subtitle, + amount, + asset, + network, + balance, + walletAddress, + busy = false, + busyLabel = 'Processing…', + onConfirm, + onCancel, +}: X402ConfirmDialogProps) { + const decimals = balance?.decimals ?? (asset === 'USDC' ? 6 : 0); + const amountDisplay = formatUnits(amount, decimals); + const insufficient = isInsufficient(balance, amount); + const confirmDisabled = busy || insufficient; + + return ( + undefined : onCancel} + maxWidthClassName="max-w-sm"> +
+
+ + + {amountDisplay} {asset} + + + + + {friendlyNetwork(network)} + + + + + {balance ? `${balance.formatted} ${balance.assetSymbol}` : 'Unknown'} + + + + + {truncateAddress(walletAddress)} + + +
+ + {insufficient ? ( +

+ Insufficient {asset} balance to complete this payment. +

+ ) : ( +

+ Your wallet will sign and broadcast this payment on {friendlyNetwork(network)}. +

+ )} + +
+ + +
+
+
+ ); +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} diff --git a/app/src/agentworld/hooks/useTinyplaceStream.test.ts b/app/src/agentworld/hooks/useTinyplaceStream.test.ts new file mode 100644 index 0000000000..cc621575ec --- /dev/null +++ b/app/src/agentworld/hooks/useTinyplaceStream.test.ts @@ -0,0 +1,138 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { useTinyplaceStream } from './useTinyplaceStream'; + +// ── Mock socketService ──────────────────────────────────────────────────────── + +const onListeners = new Map void>>(); + +vi.mock('../../services/socketService', () => ({ + socketService: { + on: vi.fn((event: string, cb: (...args: unknown[]) => void) => { + if (!onListeners.has(event)) onListeners.set(event, new Set()); + onListeners.get(event)!.add(cb); + }), + off: vi.fn((event: string, cb: (...args: unknown[]) => void) => { + onListeners.get(event)?.delete(cb); + }), + }, +})); + +function emit(event: string, data: unknown) { + for (const cb of onListeners.get(event) ?? []) { + cb(data); + } +} + +beforeEach(() => { + vi.clearAllMocks(); + onListeners.clear(); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('useTinyplaceStream', () => { + test('starts with idle status and empty messages', () => { + const { result } = renderHook(() => useTinyplaceStream('inbox')); + expect(result.current.status).toBe('idle'); + expect(result.current.messages).toEqual([]); + }); + + test('updates status on tinyplace:stream_status event', () => { + const { result } = renderHook(() => useTinyplaceStream('inbox')); + act(() => { + emit('tinyplace:stream_status', { stream_id: 'inbox', status: 'connected' }); + }); + expect(result.current.status).toBe('connected'); + }); + + test('ignores status events for other stream ids', () => { + const { result } = renderHook(() => useTinyplaceStream('inbox')); + act(() => { + emit('tinyplace:stream_status', { stream_id: 'conversation:abc', status: 'connected' }); + }); + expect(result.current.status).toBe('idle'); + }); + + test('collects stream messages', () => { + const { result } = renderHook(() => useTinyplaceStream('inbox')); + act(() => { + emit('tinyplace:stream_message', { + stream_id: 'inbox', + kind: 'inbox', + message: { itemId: '1', type: 'conversation_message' }, + }); + }); + expect(result.current.messages).toHaveLength(1); + expect((result.current.messages[0].message as Record).itemId).toBe('1'); + }); + + test('ignores messages for other stream ids', () => { + const { result } = renderHook(() => useTinyplaceStream('inbox')); + act(() => { + emit('tinyplace:stream_message', { + stream_id: 'conversation:xyz', + kind: 'conversation', + message: { id: '99' }, + }); + }); + expect(result.current.messages).toHaveLength(0); + }); + + test('caps messages at 100', () => { + const { result } = renderHook(() => useTinyplaceStream('inbox')); + act(() => { + for (let i = 0; i < 110; i++) { + emit('tinyplace:stream_message', { + stream_id: 'inbox', + kind: 'inbox', + message: { itemId: String(i) }, + }); + } + }); + expect(result.current.messages.length).toBeLessThanOrEqual(100); + }); + + test('clearMessages resets the list', () => { + const { result } = renderHook(() => useTinyplaceStream('inbox')); + act(() => { + emit('tinyplace:stream_message', { + stream_id: 'inbox', + kind: 'inbox', + message: { itemId: '1' }, + }); + }); + expect(result.current.messages).toHaveLength(1); + act(() => { + result.current.clearMessages(); + }); + expect(result.current.messages).toHaveLength(0); + }); + + test('unsubscribes on unmount', () => { + const { unmount } = renderHook(() => useTinyplaceStream('inbox')); + // Listeners should be registered. + expect(onListeners.get('tinyplace:stream_message')?.size).toBeGreaterThan(0); + unmount(); + // Listeners should be cleaned up. + expect(onListeners.get('tinyplace:stream_message')?.size ?? 0).toBe(0); + }); + + test('accepts messages for all stream ids when no filter is given', () => { + const { result } = renderHook(() => useTinyplaceStream()); + act(() => { + emit('tinyplace:stream_message', { + stream_id: 'inbox', + kind: 'inbox', + message: { itemId: 'A' }, + }); + emit('tinyplace:stream_message', { + stream_id: 'conversation:123', + kind: 'conversation', + message: { id: 'B' }, + }); + }); + expect(result.current.messages).toHaveLength(2); + }); +}); diff --git a/app/src/agentworld/hooks/useTinyplaceStream.ts b/app/src/agentworld/hooks/useTinyplaceStream.ts new file mode 100644 index 0000000000..d34152cc53 --- /dev/null +++ b/app/src/agentworld/hooks/useTinyplaceStream.ts @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { socketService } from '../../services/socketService'; + +export interface TinyplaceStreamMessage { + stream_id: string; + kind: string; + message: unknown; +} + +export interface TinyplaceStreamStatus { + stream_id: string; + status: string; +} + +/** + * Subscribe to tinyplace WebSocket stream events via the core's Socket.IO + * bridge. The hook listens for `tinyplace:stream_message` and + * `tinyplace:stream_status` events, optionally filtered by `streamId`. + * + * Returns: + * - `messages` — received stream messages (capped at 100). + * - `status` — latest lifecycle status: `"idle"` | `"connecting"` | `"connected"` | `"disconnected"` | `"failed"`. + * - `clearMessages` — reset the messages array. + */ +export function useTinyplaceStream(streamId?: string) { + const [messages, setMessages] = useState([]); + const [status, setStatus] = useState('idle'); + const messagesRef = useRef(messages); + messagesRef.current = messages; + + const handleMessage = useCallback( + (data: unknown) => { + const msg = data as TinyplaceStreamMessage | null; + if (!msg || typeof msg !== 'object') return; + if (streamId !== undefined && msg.stream_id !== streamId) return; + setMessages(prev => [...prev.slice(-99), msg]); + }, + [streamId] + ); + + const handleStatus = useCallback( + (data: unknown) => { + const s = data as TinyplaceStreamStatus | null; + if (!s || typeof s !== 'object') return; + if (streamId !== undefined && s.stream_id !== streamId) return; + setStatus(s.status); + }, + [streamId] + ); + + useEffect(() => { + socketService.on('tinyplace:stream_message', handleMessage); + socketService.on('tinyplace:stream_status', handleStatus); + return () => { + socketService.off('tinyplace:stream_message', handleMessage); + socketService.off('tinyplace:stream_status', handleStatus); + }; + }, [handleMessage, handleStatus]); + + const clearMessages = useCallback(() => setMessages([]), []); + + return { messages, status, clearMessages }; +} diff --git a/app/src/agentworld/hooks/useX402Buy.test.ts b/app/src/agentworld/hooks/useX402Buy.test.ts new file mode 100644 index 0000000000..9970b74d87 --- /dev/null +++ b/app/src/agentworld/hooks/useX402Buy.test.ts @@ -0,0 +1,121 @@ +/** + * Tests for useX402Buy — the shared x402 buy state machine + its helpers. + * Drives the hook with a mocked buy function (no RPC). Generic placeholders only. + */ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; + +import { PaymentRequiredError, type X402BuyResult } from '../../lib/agentworld/invokeApiClient'; +import { explorerTxUrl, extractOnChainTx, useX402Buy } from './useX402Buy'; + +const CHALLENGE = { amount: '10000000', asset: 'USDC', network: 'solana-devnet' }; +const BALANCE = { raw: '50000000', formatted: '50', decimals: 6, assetSymbol: 'USDC' }; + +describe('helpers', () => { + test('explorerTxUrl appends the devnet cluster only for devnet networks', () => { + expect(explorerTxUrl('Tx1', 'solana-devnet')).toBe( + 'https://explorer.solana.com/tx/Tx1?cluster=devnet' + ); + expect(explorerTxUrl('Tx1', 'solana-mainnet')).toBe('https://explorer.solana.com/tx/Tx1'); + expect(explorerTxUrl('Tx1')).toBe('https://explorer.solana.com/tx/Tx1'); + }); + + test('extractOnChainTx pulls the tx out of an error string', () => { + expect(extractOnChainTx('paid but failed (onChainTx=Sig9); retry')).toBe('Sig9'); + expect(extractOnChainTx('no tx here')).toBeUndefined(); + }); +}); + +describe('useX402Buy', () => { + test('begin → confirm exposes the challenge + balance', async () => { + const buyFn = vi + .fn() + .mockResolvedValue({ + challenge: CHALLENGE, + walletBalance: BALANCE, + walletAddress: 'Wallet1', + } satisfies X402BuyResult); + const { result } = renderHook(() => useX402Buy(buyFn)); + + act(() => result.current.begin('id-1')); + await waitFor(() => expect(result.current.state.phase).toBe('confirm')); + expect(buyFn).toHaveBeenCalledWith('id-1', { confirmed: false }); + if (result.current.state.phase === 'confirm') { + expect(result.current.state.balance).toEqual(BALANCE); + expect(result.current.state.walletAddress).toBe('Wallet1'); + } + }); + + test('begin short-circuits to success when the result needs no payment', async () => { + const buyFn = vi.fn().mockResolvedValue({ result: { saleId: 's1' } }); + const { result } = renderHook(() => useX402Buy(buyFn)); + act(() => result.current.begin('id-1')); + await waitFor(() => expect(result.current.state.phase).toBe('success')); + }); + + test('begin with neither result nor challenge errors', async () => { + const buyFn = vi.fn().mockResolvedValue({}); + const { result } = renderHook(() => useX402Buy(buyFn)); + act(() => result.current.begin('id-1')); + await waitFor(() => expect(result.current.state.phase).toBe('error')); + if (result.current.state.phase === 'error') { + expect(result.current.state.message).toMatch(/Unexpected/); + } + }); + + test('begin maps a PaymentRequiredError to a payment notice', async () => { + const buyFn = vi.fn().mockRejectedValue(new PaymentRequiredError({ t: 1 })); + const { result } = renderHook(() => useX402Buy(buyFn)); + act(() => result.current.begin('id-1')); + await waitFor(() => expect(result.current.state.phase).toBe('error')); + if (result.current.state.phase === 'error') { + expect(result.current.state.message).toBe('Payment required.'); + } + }); + + test('confirmPay success carries the on-chain tx + network', async () => { + const buyFn = vi + .fn() + .mockResolvedValueOnce({ challenge: CHALLENGE, walletBalance: BALANCE, walletAddress: 'W' }) + .mockResolvedValueOnce({ result: { saleId: 's1' }, payment: { onChainTx: 'TxOK' } }); + const { result } = renderHook(() => useX402Buy(buyFn)); + act(() => result.current.begin('id-1')); + await waitFor(() => expect(result.current.state.phase).toBe('confirm')); + act(() => result.current.confirmPay('id-1', CHALLENGE, BALANCE, 'W')); + await waitFor(() => expect(result.current.state.phase).toBe('success')); + expect(buyFn).toHaveBeenLastCalledWith('id-1', { confirmed: true }); + if (result.current.state.phase === 'success') { + expect(result.current.state.onChainTx).toBe('TxOK'); + expect(result.current.state.network).toBe('solana-devnet'); + } + }); + + test('confirmPay with no result errors', async () => { + const buyFn = vi.fn().mockResolvedValue({}); + const { result } = renderHook(() => useX402Buy(buyFn)); + act(() => result.current.confirmPay('id-1', CHALLENGE, BALANCE, 'W')); + await waitFor(() => expect(result.current.state.phase).toBe('error')); + if (result.current.state.phase === 'error') { + expect(result.current.state.message).toMatch(/did not complete/); + } + }); + + test('confirmPay failure extracts the broadcast tx from the error', async () => { + const buyFn = vi.fn().mockRejectedValue(new Error('paid (onChainTx=BrokeTx)')); + const { result } = renderHook(() => useX402Buy(buyFn)); + act(() => result.current.confirmPay('id-1', CHALLENGE, BALANCE, 'W')); + await waitFor(() => expect(result.current.state.phase).toBe('error')); + if (result.current.state.phase === 'error') { + expect(result.current.state.onChainTx).toBe('BrokeTx'); + } + }); + + test('reset returns to idle', async () => { + const buyFn = vi.fn().mockResolvedValue({ result: { saleId: 's1' } }); + const { result } = renderHook(() => useX402Buy(buyFn)); + act(() => result.current.begin('id-1')); + await waitFor(() => expect(result.current.state.phase).toBe('success')); + act(() => result.current.reset()); + expect(result.current.state.phase).toBe('idle'); + }); +}); diff --git a/app/src/agentworld/hooks/useX402Buy.ts b/app/src/agentworld/hooks/useX402Buy.ts new file mode 100644 index 0000000000..41b24d535c --- /dev/null +++ b/app/src/agentworld/hooks/useX402Buy.ts @@ -0,0 +1,124 @@ +/** + * useX402Buy — shared confirm-before-spend state machine for Agent World x402 + * buy flows (products and identity listings). + * + * Parameterised by a buy function `(id, { confirmed }) => Promise` + * so the same two-call flow drives both `marketplace.buyProduct` and + * `marketplace.buyIdentity`. The hook never spends on its own: `begin` probes + * for the challenge (confirmed:false), `confirmPay` is the only call that runs + * with confirmed:true. + */ +import { useState } from 'react'; + +import { + PaymentRequiredError, + type RegistrationChallenge, + type RegistryWalletBalance, + type X402BuyResult, +} from '../../lib/agentworld/invokeApiClient'; + +export type X402BuyState = + | { phase: 'idle' } + | { phase: 'challenge_loading' } + | { + phase: 'confirm'; + challenge: RegistrationChallenge; + balance: RegistryWalletBalance | null; + walletAddress: string; + } + | { + phase: 'paying'; + challenge: RegistrationChallenge; + balance: RegistryWalletBalance | null; + walletAddress: string; + } + | { phase: 'success'; result: Record; onChainTx?: string; network?: string } + | { phase: 'error'; message: string; onChainTx?: string }; + +export type X402BuyFn = (id: string, opts: { confirmed: boolean }) => Promise; + +/** Devnet/mainnet Solana explorer link for a settled payment tx. */ +export function explorerTxUrl(tx: string, network?: string): string { + const cluster = (network ?? '').toLowerCase().includes('devnet') ? '?cluster=devnet' : ''; + return `https://explorer.solana.com/tx/${tx}${cluster}`; +} + +/** Pull the broadcast tx out of a post-payment error string ("onChainTx="). */ +export function extractOnChainTx(message: string): string | undefined { + const match = /onChainTx=([^)\s;]+)/.exec(message); + return match?.[1]; +} + +export interface UseX402Buy { + state: X402BuyState; + reset: () => void; + begin: (id: string) => void; + confirmPay: ( + id: string, + challenge: RegistrationChallenge, + balance: RegistryWalletBalance | null, + walletAddress: string + ) => void; +} + +export function useX402Buy(buyFn: X402BuyFn): UseX402Buy { + const [state, setState] = useState({ phase: 'idle' }); + + function reset() { + setState({ phase: 'idle' }); + } + + // Phase A — probe for the challenge + balance (no spend). + function begin(id: string) { + setState({ phase: 'challenge_loading' }); + void buyFn(id, { confirmed: false }) + .then(res => { + if (res.challenge) { + setState({ + phase: 'confirm', + challenge: res.challenge, + balance: res.walletBalance ?? null, + walletAddress: res.walletAddress ?? '', + }); + } else if (res.result) { + setState({ phase: 'success', result: res.result }); + } else { + setState({ phase: 'error', message: 'Unexpected response from purchase.' }); + } + }) + .catch((err: unknown) => { + const message = err instanceof PaymentRequiredError ? 'Payment required.' : String(err); + setState({ phase: 'error', message }); + }); + } + + // Phase B — pay on-chain + complete the purchase (spends). Carries the + // confirm-phase balance + wallet through so the dialog keeps showing them. + function confirmPay( + id: string, + challenge: RegistrationChallenge, + balance: RegistryWalletBalance | null, + walletAddress: string + ) { + setState({ phase: 'paying', challenge, balance, walletAddress }); + void buyFn(id, { confirmed: true }) + .then(res => { + if (res.result) { + setState({ + phase: 'success', + result: res.result, + onChainTx: res.payment?.onChainTx, + network: challenge.network, + }); + } else { + setState({ phase: 'error', message: 'Purchase did not complete.' }); + } + }) + .catch((err: unknown) => { + const message = String(err); + setState({ phase: 'error', message, onChainTx: extractOnChainTx(message) }); + }); + } + + return { state, reset, begin, confirmPay }; +} diff --git a/app/src/agentworld/pages/AgentWorld.tsx b/app/src/agentworld/pages/AgentWorld.tsx new file mode 100644 index 0000000000..9f51d39a1d --- /dev/null +++ b/app/src/agentworld/pages/AgentWorld.tsx @@ -0,0 +1,165 @@ +/** + * AgentWorld — section host for the Tiny.Place integration. + * + * The section navigation lives in the root app sidebar's dynamic region (the + * "session sidebar"), projected there via `SidebarContent` — the same pattern + * as Brain. The active section fills the content pane flush. The section name + * is carried by the sidebar (no per-section page title), so sections render + * their own body chrome via `PanelScaffold`. + * + * Sub-navigation keys: agentWorld.explore (+ future section keys). + */ +import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; + +import { SidebarContent } from '../../components/layout/shell/SidebarSlot'; +import TwoPaneNav from '../../components/layout/TwoPaneNav'; +import { useT } from '../../lib/i18n/I18nContext'; +import BountiesSection from './BountiesSection'; +import DirectorySection from './DirectorySection'; +import ExploreSection from './ExploreSection'; +import FeedSection from './FeedSection'; +import IdentitiesSection from './IdentitiesSection'; +import JobsSection from './JobsSection'; +import LedgerSection from './LedgerSection'; +import MarketplaceSection from './MarketplaceSection'; +import MessagingSection from './MessagingSection'; +import ProfilesSection from './ProfilesSection'; + +// Sub-nav section definition (one per section). +interface AgentWorldSection { + slug: string; + labelKey: string; + iconPath: string; +} + +/** Small inline icon helper for the sidebar nav (matches Brain's). */ +const navIcon = (d: string) => ( + + + +); + +// === AGENT-WORLD SECTIONS (append one per section) === +// Format: { slug: '', labelKey: 'agentWorld.', iconPath: '' } +// Fan-out agents: add a row here AND a below AND an i18n key. +// Sidebar order: Feed first, then Messages, then the rest; Profiles sits at the +// end. Marketplace is intentionally OMITTED from the sidebar (its route still +// exists below so buy/bid/offer flows remain reachable) — hidden, not removed. +const SECTIONS: AgentWorldSection[] = [ + { + slug: 'feed', + labelKey: 'agentWorld.feed', + iconPath: + 'M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z', + }, + { + slug: 'messaging', + labelKey: 'agentWorld.messaging', + iconPath: + 'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z', + }, + { + slug: 'ledger', + labelKey: 'agentWorld.ledger', + iconPath: + 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01', + }, + { + slug: 'jobs', + labelKey: 'agentWorld.jobs', + iconPath: + 'M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2M3.20898 7H20.791C21.4593 7 22 7.54066 22 8.20898V10.291C22 10.9593 21.4593 11.5 20.791 11.5H3.20898C2.54066 11.5 2 10.9593 2 10.291V8.20898C2 7.54066 2.54066 7 3.20898 7ZM5 11.5V19C5 20.1046 5.89543 21 7 21H17C18.1046 21 19 20.1046 19 19V11.5', + }, + { + slug: 'bounties', + labelKey: 'agentWorld.bounties', + iconPath: + 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z', + }, + { + slug: 'explore', + labelKey: 'agentWorld.explore', + iconPath: 'M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z', + }, + { + slug: 'directory', + labelKey: 'agentWorld.directory', + iconPath: + 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z', + }, + { + slug: 'identities', + labelKey: 'agentWorld.identities', + iconPath: + 'M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0', + }, + { + slug: 'profiles', + labelKey: 'agentWorld.profiles', + iconPath: + 'M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0z', + }, +]; + +export default function AgentWorld() { + const { t } = useT(); + const navigate = useNavigate(); + const location = useLocation(); + + // Derive the active slug from the current sub-path + // e.g. /agent-world/explore → 'explore' + const pathParts = location.pathname.split('/'); + const activeSlug = pathParts[pathParts.length - 1] || 'feed'; + + return ( +
+ {/* The Tiny.Place section navigation lives in the root app sidebar's + dynamic region (the session sidebar), projected via SidebarContent. */} + +
+ navigate(`/agent-world/${slug}`)} + groups={[ + { + items: SECTIONS.map(section => ({ + value: section.slug, + label: t(section.labelKey), + icon: navIcon(section.iconPath), + })), + }, + ]} + header={ +

+ {t('agentWorld.description')} +

+ } + /> +
+
+ {/* Card surface around the active section so the section chrome and its + inner cards sit on a framed panel (matching Brain) instead of floating + flush on the bare shell background. */} +
+
+ + } /> + } /> + } /> + } /> + } /> + } /> + {/* === AGENT-WORLD SECTION ROUTES (append one per section) === */} + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+
+ ); +} diff --git a/app/src/agentworld/pages/BountiesSection.test.tsx b/app/src/agentworld/pages/BountiesSection.test.tsx new file mode 100644 index 0000000000..2f7596612e --- /dev/null +++ b/app/src/agentworld/pages/BountiesSection.test.tsx @@ -0,0 +1,744 @@ +/** + * Tests for BountiesSection — the Agent World Bounties section (Phase B). + * + * Covers loading / error / empty / populated states, BountyStatusBadge colors, + * reward display formatting, accordion expand/collapse, wallet-gated actions, + * form validation, and fund/submit/comment/cancel/council flows. + * + * apiClient is mocked at module level; no real RPC calls are made. + * All sample data uses generic placeholder names/IDs per project rules. + */ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { type Bounty } from '../../lib/agentworld/invokeApiClient'; +import { fetchWalletStatus } from '../../services/walletApi'; +import { apiClient } from '../AgentWorldShell'; +import BountiesSection, { BountyStatusBadge } from './BountiesSection'; + +vi.mock('../AgentWorldShell', () => ({ + apiClient: { + bounties: { + list: vi.fn(), + get: vi.fn(), + create: vi.fn(), + fund: vi.fn(), + cancel: vi.fn(), + submit: vi.fn(), + listSubmissions: vi.fn(), + comment: vi.fn(), + listComments: vi.fn(), + runCouncil: vi.fn(), + approve: vi.fn(), + }, + }, +})); + +vi.mock('../../services/walletApi', () => ({ fetchWalletStatus: vi.fn() })); + +const MY_AGENT_ID = 'my-agent-solana-addr-1111'; +const OTHER_AGENT_ID = 'other-agent-addr-2222'; +const sampleWalletStatus = { accounts: [{ chain: 'solana', address: MY_AGENT_ID }] }; + +// ── Sample data (generic placeholders) ─────────────────────────────────────── + +const sampleBounty: Bounty = { + bountyId: 'bounty-001', + creator: OTHER_AGENT_ID, + title: 'Build an integration plugin', + description: 'Create a TypeScript plugin that connects to our API.', + reward: { amount: '5000000', asset: 'USDC', network: 'solana-devnet' }, + status: 'open', + submissionCount: 2, + commentCount: 3, + council: undefined, + deadline: '2026-09-01T00:00:00Z', + startAt: '2026-06-01T00:00:00Z', + createdAt: '2026-06-01T12:00:00Z', + updatedAt: '2026-06-01T12:00:00Z', +}; + +const sampleOwnBounty: Bounty = { + ...sampleBounty, + bountyId: 'bounty-002', + creator: MY_AGENT_ID, + title: 'My own draft bounty', + status: 'draft', + submissionCount: 0, + commentCount: 0, +}; + +const sampleBountyWithCouncil: Bounty = { + ...sampleBounty, + bountyId: 'bounty-003', + title: 'Bounty with council', + status: 'judging', + council: { + status: 'complete', + winnerSubmissionId: 'sub-winner-001', + reasoning: 'Best submission by far.', + votes: [ + { + model: 'gpt-4o', + winnerSubmissionId: 'sub-winner-001', + reasoning: 'Excellent implementation', + }, + ], + }, +}; + +const emptyListResponse = { bounties: [] }; +const listWithBounties = { bounties: [sampleBounty] }; +const listWithOwnBounty = { bounties: [sampleOwnBounty] }; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(apiClient.bounties.list).mockResolvedValue(emptyListResponse); + vi.mocked(apiClient.bounties.listSubmissions).mockResolvedValue({ submissions: [] }); + vi.mocked(apiClient.bounties.listComments).mockResolvedValue({ comments: [] }); + vi.mocked(fetchWalletStatus).mockResolvedValue(sampleWalletStatus as never); +}); + +// ── Loading state ───────────────────────────────────────────────────────────── + +describe('Loading state', () => { + test('shows loading spinner before fetch resolves', () => { + vi.mocked(apiClient.bounties.list).mockReturnValue(new Promise(() => {})); + render(); + expect(screen.getByText(/loading bounties/i)).toBeInTheDocument(); + }); +}); + +// ── Error state ─────────────────────────────────────────────────────────────── + +describe('Error state', () => { + test('shows error message on API failure', async () => { + vi.mocked(apiClient.bounties.list).mockRejectedValue(new Error('network error')); + render(); + await waitFor(() => { + expect(screen.getByText(/failed to load bounties/i)).toBeInTheDocument(); + expect(screen.getByText(/network error/i)).toBeInTheDocument(); + }); + }); +}); + +// ── Empty state ─────────────────────────────────────────────────────────────── + +describe('Empty state', () => { + test('shows "No bounties found" when list is empty', async () => { + vi.mocked(apiClient.bounties.list).mockResolvedValue(emptyListResponse); + render(); + await waitFor(() => { + expect(screen.getByText(/no bounties found/i)).toBeInTheDocument(); + }); + }); + + test('tolerates missing bounties field and shows empty', async () => { + vi.mocked(apiClient.bounties.list).mockResolvedValue({} as never); + render(); + await waitFor(() => { + expect(screen.getByText(/no bounties found/i)).toBeInTheDocument(); + }); + }); +}); + +// ── Populated list ──────────────────────────────────────────────────────────── + +describe('Populated list', () => { + test('renders bounty rows with title, reward, status badge, counts, deadline', async () => { + vi.mocked(apiClient.bounties.list).mockResolvedValue(listWithBounties); + render(); + await waitFor(() => { + expect(screen.getByText('Build an integration plugin')).toBeInTheDocument(); + }); + // Reward: 5000000 base units USDC = 5 USDC displayed + expect(screen.getByText(/5.*USDC/)).toBeInTheDocument(); + // Status badge + expect(screen.getByText('open')).toBeInTheDocument(); + // Submission/comment counts + expect(screen.getByText(/2 submission/)).toBeInTheDocument(); + expect(screen.getByText(/3 comment/)).toBeInTheDocument(); + }); +}); + +// ── BountyStatusBadge ───────────────────────────────────────────────────────── + +describe('BountyStatusBadge colors', () => { + test('draft → gray (stone)', () => { + render(); + const badge = screen.getByText('draft'); + expect(badge.className).toContain('stone'); + }); + + test('open → green', () => { + render(); + const badge = screen.getByText('open'); + expect(badge.className).toContain('green'); + }); + + test('judging → amber', () => { + render(); + const badge = screen.getByText('judging'); + expect(badge.className).toContain('amber'); + }); + + test('review → ocean (blue)', () => { + render(); + const badge = screen.getByText('review'); + expect(badge.className).toContain('primary'); + }); + + test('awarded → purple', () => { + render(); + const badge = screen.getByText('awarded'); + expect(badge.className).toContain('purple'); + }); + + test('cancelled → gray (stone)', () => { + render(); + const badge = screen.getByText('cancelled'); + expect(badge.className).toContain('stone'); + }); + + test('refunded → gray (stone)', () => { + render(); + const badge = screen.getByText('refunded'); + expect(badge.className).toContain('stone'); + }); +}); + +// ── Accordion expand ────────────────────────────────────────────────────────── + +describe('Accordion expand', () => { + test('click expands row to show description and reward detail', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.bounties.list).mockResolvedValue(listWithBounties); + render(); + + await waitFor(() => { + expect(screen.getByText('Build an integration plugin')).toBeInTheDocument(); + }); + + // Description not visible before expand + expect( + screen.queryByText('Create a TypeScript plugin that connects to our API.') + ).not.toBeInTheDocument(); + + await user.click(screen.getByText('Build an integration plugin')); + + await waitFor(() => { + expect( + screen.getByText('Create a TypeScript plugin that connects to our API.') + ).toBeInTheDocument(); + }); + // Deadline shown in detail + expect(screen.getByText(/Deadline/)).toBeInTheDocument(); + }); + + test('click again collapses row', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.bounties.list).mockResolvedValue(listWithBounties); + render(); + + await waitFor(() => { + expect(screen.getByText('Build an integration plugin')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Build an integration plugin')); + await waitFor(() => { + expect( + screen.getByText('Create a TypeScript plugin that connects to our API.') + ).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Build an integration plugin')); + await waitFor(() => { + expect( + screen.queryByText('Create a TypeScript plugin that connects to our API.') + ).not.toBeInTheDocument(); + }); + }); +}); + +// ── Wallet-gated actions ────────────────────────────────────────────────────── + +describe('Wallet-gated Create Bounty button', () => { + test('Create Bounty button is visible when wallet is unlocked', async () => { + vi.mocked(fetchWalletStatus).mockResolvedValue(sampleWalletStatus as never); + render(); + await waitFor(() => { + expect(screen.getByText('Create Bounty')).toBeInTheDocument(); + }); + }); + + test('Create Bounty button is hidden when wallet is locked', async () => { + vi.mocked(fetchWalletStatus).mockResolvedValue({ accounts: [] } as never); + render(); + await waitFor(() => { + expect(screen.queryByText('Create Bounty')).not.toBeInTheDocument(); + }); + }); +}); + +// ── Create Bounty form ──────────────────────────────────────────────────────── + +describe('Create Bounty form', () => { + test('opens modal on Create Bounty click and validates required fields', async () => { + const user = userEvent.setup(); + vi.mocked(fetchWalletStatus).mockResolvedValue(sampleWalletStatus as never); + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /create bounty/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /create bounty/i })); + + // Modal opens — find the form by waiting for the title input to appear + let titleInput: HTMLElement | null = null; + await waitFor(() => { + titleInput = document.querySelector('input[placeholder="Bounty title"]') as HTMLElement; + expect(titleInput).not.toBeNull(); + }); + + // Submit with empty form — should show validation error (title field is empty) + const submitBtn = document.querySelector('button[type="submit"]') as HTMLElement; + expect(submitBtn).not.toBeNull(); + await user.click(submitBtn); + await waitFor(() => { + expect(screen.getByText(/title is required/i)).toBeInTheDocument(); + }); + }); + + test('submits create form and calls bounties.create', async () => { + const user = userEvent.setup(); + // create is now an x402 confirm-before-spend flow; the probe returns the + // bounty directly when no payment is required (free path). + vi.mocked(apiClient.bounties.create).mockResolvedValue({ + bounty: { ...sampleOwnBounty, status: 'open' }, + } as never); + vi.mocked(apiClient.bounties.list).mockResolvedValue(listWithOwnBounty); + vi.mocked(fetchWalletStatus).mockResolvedValue(sampleWalletStatus as never); + render(); + + await waitFor(() => { + expect(screen.getByText('Create Bounty')).toBeInTheDocument(); + }); + + // Click the outer "Create Bounty" button (type=button) + const createBtn = screen + .getAllByRole('button') + .find( + btn => btn.textContent?.includes('Create Bounty') && btn.getAttribute('type') === 'button' + ); + expect(createBtn).toBeDefined(); + await user.click(createBtn!); + + // Fill in the form + await user.type(screen.getByPlaceholderText(/bounty title/i), 'Test bounty title'); + await user.type(screen.getByPlaceholderText(/describe the bounty/i), 'Test description'); + await user.clear(screen.getByPlaceholderText('5')); + await user.type(screen.getByPlaceholderText('5'), '10'); + + const submitBtn2 = screen + .getAllByRole('button') + .find(btn => btn.getAttribute('type') === 'submit'); + expect(submitBtn2).toBeDefined(); + await user.click(submitBtn2!); + + await waitFor(() => { + expect(apiClient.bounties.create).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Test bounty title', + description: 'Test description', + amount: '10', // human-decimal amount (SDK BountyCreateRequest.amount) + asset: 'USDC', + }), + { confirmed: false } + ); + }); + }); +}); + +// ── Submit Work flow ────────────────────────────────────────────────────────── + +describe('Submit Work', () => { + test('Submit Work button visible to non-creator on open bounty', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.bounties.list).mockResolvedValue(listWithBounties); + // sampleBounty.creator is OTHER_AGENT_ID, not MY_AGENT_ID + render(); + + await waitFor(() => { + expect(screen.getByText('Build an integration plugin')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Build an integration plugin')); + + await waitFor(() => { + expect(screen.getByText('Submit Work')).toBeInTheDocument(); + }); + }); + + test('Submit Work modal validates URL required', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.bounties.list).mockResolvedValue(listWithBounties); + render(); + + await waitFor(() => { + expect(screen.getByText('Build an integration plugin')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Build an integration plugin')); + + await waitFor(() => { + expect(screen.getByText('Submit Work')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Submit Work')); + + // Wait for the URL input to appear (modal opened) + await waitFor(() => { + expect(document.querySelector('input[placeholder="https://github.com/…"]')).not.toBeNull(); + }); + + // Click the submit button (type=submit) without filling in URL + const submitBtn = document.querySelector('button[type="submit"]') as HTMLElement; + expect(submitBtn).not.toBeNull(); + await user.click(submitBtn); + + await waitFor(() => { + expect(screen.getByText(/url is required/i)).toBeInTheDocument(); + }); + }); + + test('Submit Work calls bounties.submit with URL', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.bounties.list).mockResolvedValue(listWithBounties); + vi.mocked(apiClient.bounties.submit).mockResolvedValue({} as never); + render(); + + await waitFor(() => { + expect(screen.getByText('Build an integration plugin')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Build an integration plugin')); + + await waitFor(() => { + expect(screen.getByText('Submit Work')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Submit Work')); + + await waitFor(() => { + expect(screen.getByPlaceholderText(/https:\/\/github\.com/i)).toBeInTheDocument(); + }); + + await user.type( + screen.getByPlaceholderText(/https:\/\/github\.com/i), + 'https://github.com/example/submission' + ); + await user.click(screen.getByText('Submit Work', { selector: '[type=submit]' })); + + await waitFor(() => { + expect(apiClient.bounties.submit).toHaveBeenCalledWith( + sampleBounty.bountyId, + 'https://github.com/example/submission', + undefined, + undefined + ); + }); + }); +}); + +// ── Comment flow ────────────────────────────────────────────────────────────── + +describe('Comment flow', () => { + test('Comment button calls bounties.comment', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.bounties.list).mockResolvedValue(listWithBounties); + vi.mocked(apiClient.bounties.comment).mockResolvedValue({} as never); + render(); + + await waitFor(() => { + expect(screen.getByText('Build an integration plugin')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Build an integration plugin')); + + await waitFor(() => { + expect(screen.getByText('Comment')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Comment')); + + await waitFor(() => { + expect(screen.getByPlaceholderText(/your comment/i)).toBeInTheDocument(); + }); + + await user.type(screen.getByPlaceholderText(/your comment/i), 'Great bounty!'); + await user.click(screen.getByText('Post Comment')); + + await waitFor(() => { + expect(apiClient.bounties.comment).toHaveBeenCalledWith( + sampleBounty.bountyId, + 'Great bounty!' + ); + }); + }); +}); + +// ── Fund flow (x402) ────────────────────────────────────────────────────────── + +describe('Fund flow (x402)', () => { + test('Fund button visible to creator on draft bounty', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.bounties.list).mockResolvedValue(listWithOwnBounty); + render(); + + await waitFor(() => { + expect(screen.getByText('My own draft bounty')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('My own draft bounty')); + + await waitFor(() => { + expect(screen.getByText('Fund Bounty')).toBeInTheDocument(); + }); + }); + + test('Fund button triggers x402 challenge (confirmed:false)', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.bounties.list).mockResolvedValue(listWithOwnBounty); + vi.mocked(apiClient.bounties.fund).mockResolvedValue({ + challenge: { + amount: '5000000', + asset: 'USDC', + network: 'solana-devnet', + nonce: 'test-nonce', + payTo: 'pay-to-addr', + }, + walletBalance: { raw: '10000000', formatted: '10.00', decimals: 6, assetSymbol: 'USDC' }, + walletAddress: MY_AGENT_ID, + } as never); + render(); + + await waitFor(() => { + expect(screen.getByText('My own draft bounty')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('My own draft bounty')); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /fund bounty/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /fund bounty/i })); + + // Verify fund was called with confirmed:false + await waitFor(() => { + expect(apiClient.bounties.fund).toHaveBeenCalledWith(sampleOwnBounty.bountyId, { + confirmed: false, + }); + }); + }); +}); + +// ── Cancel flow ─────────────────────────────────────────────────────────────── + +describe('Cancel flow', () => { + test('Cancel button visible to creator on draft bounty', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.bounties.list).mockResolvedValue(listWithOwnBounty); + render(); + + await waitFor(() => { + expect(screen.getByText('My own draft bounty')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('My own draft bounty')); + + await waitFor(() => { + expect(screen.getByText('Cancel')).toBeInTheDocument(); + }); + }); + + test('Cancel calls bounties.cancel and refetches', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.bounties.list).mockResolvedValue(listWithOwnBounty); + vi.mocked(apiClient.bounties.cancel).mockResolvedValue({} as never); + render(); + + await waitFor(() => { + expect(screen.getByText('My own draft bounty')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('My own draft bounty')); + + await waitFor(() => { + expect(screen.getByText('Cancel')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Cancel')); + + await waitFor(() => { + expect(apiClient.bounties.cancel).toHaveBeenCalledWith(sampleOwnBounty.bountyId); + }); + }); +}); + +// ── Run Council flow ────────────────────────────────────────────────────────── + +describe('Run Council flow', () => { + test('Run Council button visible to creator on open bounty', async () => { + const user = userEvent.setup(); + const openOwnBounty = { ...sampleOwnBounty, status: 'open' }; + vi.mocked(apiClient.bounties.list).mockResolvedValue({ bounties: [openOwnBounty] }); + render(); + + await waitFor(() => { + expect(screen.getByText('My own draft bounty')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('My own draft bounty')); + + await waitFor(() => { + expect(screen.getByText('Run Council')).toBeInTheDocument(); + }); + }); + + test('Run Council calls bounties.runCouncil', async () => { + const user = userEvent.setup(); + const openOwnBounty = { ...sampleOwnBounty, status: 'open' }; + vi.mocked(apiClient.bounties.list).mockResolvedValue({ bounties: [openOwnBounty] }); + vi.mocked(apiClient.bounties.runCouncil).mockResolvedValue({} as never); + render(); + + await waitFor(() => { + expect(screen.getByText('My own draft bounty')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('My own draft bounty')); + + await waitFor(() => { + expect(screen.getByText('Run Council')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Run Council')); + + await waitFor(() => { + expect(apiClient.bounties.runCouncil).toHaveBeenCalledWith(sampleOwnBounty.bountyId); + }); + }); +}); + +// ── Wallet-locked state ─────────────────────────────────────────────────────── + +describe('Wallet-locked state', () => { + test('shows "Unlock your wallet" when wallet is locked and user expands a row', async () => { + const user = userEvent.setup(); + vi.mocked(fetchWalletStatus).mockResolvedValue({ accounts: [] } as never); + vi.mocked(apiClient.bounties.list).mockResolvedValue(listWithBounties); + render(); + + await waitFor(() => { + expect(screen.getByText('Build an integration plugin')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Build an integration plugin')); + + await waitFor(() => { + expect(screen.getByText(/unlock your wallet/i)).toBeInTheDocument(); + }); + }); +}); + +// ── Reward formatting ───────────────────────────────────────────────────────── + +describe('Reward amount display', () => { + test('formats base-unit USDC (5000000) as "5 USDC"', async () => { + vi.mocked(apiClient.bounties.list).mockResolvedValue(listWithBounties); + render(); + await waitFor(() => { + expect(screen.getByText('Build an integration plugin')).toBeInTheDocument(); + }); + // Check the reward display (5000000 base units = 5 USDC) + expect(screen.getByText(/5.*USDC/)).toBeInTheDocument(); + }); +}); + +// ── Council section ─────────────────────────────────────────────────────────── + +describe('Council section', () => { + test('renders council section when council is present', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.bounties.list).mockResolvedValue({ bounties: [sampleBountyWithCouncil] }); + render(); + + await waitFor(() => { + expect(screen.getByText('Bounty with council')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Bounty with council')); + + await waitFor(() => { + expect(screen.getByText('Council')).toBeInTheDocument(); + expect(screen.getByText('complete')).toBeInTheDocument(); + expect(screen.getByText(/Best submission by far/)).toBeInTheDocument(); + }); + }); + + test('renders council votes when present', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.bounties.list).mockResolvedValue({ bounties: [sampleBountyWithCouncil] }); + render(); + + await waitFor(() => { + expect(screen.getByText('Bounty with council')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Bounty with council')); + + await waitFor(() => { + expect(screen.getByText('Votes')).toBeInTheDocument(); + expect(screen.getByText('gpt-4o')).toBeInTheDocument(); + expect(screen.getByText(/Excellent implementation/)).toBeInTheDocument(); + }); + }); +}); + +// ── Submissions section ─────────────────────────────────────────────────────── + +describe('Submissions section', () => { + test('renders submissions list when loaded on expand', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.bounties.list).mockResolvedValue(listWithBounties); + vi.mocked(apiClient.bounties.listSubmissions).mockResolvedValue({ + submissions: [ + { + submissionId: 'sub-001', + bountyId: 'bounty-001', + submitter: 'submitter-addr-aabb', + url: 'https://github.com/example/work', + title: 'My plugin implementation', + status: 'submitted', + createdAt: '2026-06-02T10:00:00Z', + updatedAt: '2026-06-02T10:00:00Z', + }, + ], + }); + render(); + + await waitFor(() => { + expect(screen.getByText('Build an integration plugin')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Build an integration plugin')); + + await waitFor(() => { + expect(screen.getByText('My plugin implementation')).toBeInTheDocument(); + expect(screen.getByText('https://github.com/example/work')).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/agentworld/pages/BountiesSection.tsx b/app/src/agentworld/pages/BountiesSection.tsx new file mode 100644 index 0000000000..c07020d555 --- /dev/null +++ b/app/src/agentworld/pages/BountiesSection.tsx @@ -0,0 +1,1135 @@ +/** + * BountiesSection — Agent World "Bounties" section (Phase B). + * + * Renders the bounty board via `apiClient.bounties.list()`. + * Supports inline row expansion to show full bounty details including + * description, reward, council votes, submissions, comments, and on-chain data. + * + * Write surface: Create Bounty, Fund (x402 two-call), Submit Work, Comment, + * Run Council, Cancel. All write actions are wallet-gated behind useMyAgentId(). + * Approve is wired but HIDDEN in v1 UI (admin-only, backend-enforced). + * + * Pattern mirrors JobsSection: useState + useEffect fetch, PanelScaffold + * wrapper, StatusBlock for loading/error/empty states. + */ +import { useCallback, useEffect, useState } from 'react'; + +import PanelScaffold from '../../components/layout/PanelScaffold'; +import Button from '../../components/ui/Button'; +import { ModalShell } from '../../components/ui/ModalShell'; +import { + type Bounty, + type BountyComment, + type BountyCreateParams, + type BountyListResponse, + type BountySubmission, + type RegistrationChallenge, + type RegistryWalletBalance, +} from '../../lib/agentworld/invokeApiClient'; +import { fetchWalletStatus } from '../../services/walletApi'; +import { apiClient } from '../AgentWorldShell'; +import X402ConfirmDialog, { formatUnits } from '../components/X402ConfirmDialog'; +import { useX402Buy } from '../hooks/useX402Buy'; + +// ── State types ─────────────────────────────────────────────────────────────── + +type BountiesState = + | { status: 'loading' } + | { status: 'error'; message: string } + | { status: 'ok'; bounties: Bounty[] }; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function relativeTime(iso: string): string { + const ms = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(ms / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; +} + +/** Group the integer part of a numeric amount with thousands separators. */ +function formatAmount(amount: string): string { + if (!Number.isFinite(Number(amount))) return amount; + const negative = amount.startsWith('-'); + const body = negative ? amount.slice(1) : amount; + const [intPart, fracPart] = body.split('.'); + const grouped = Number(intPart).toLocaleString('en-US'); + const out = fracPart != null ? `${grouped}.${fracPart}` : grouped; + return negative ? `-${out}` : out; +} + +/** Collapse a raw base58 address to `abcd…wxyz`; leave short names. */ +function abbrev(addr: string): string { + if (addr.length > 16 && !/\s/.test(addr)) { + return `${addr.slice(0, 4)}…${addr.slice(-4)}`; + } + return addr; +} + +/** Decimals for a given asset symbol. USDC = 6, SOL = 9, others = 0. */ +function decimalsForAsset(asset: string): number { + const up = asset.toUpperCase(); + if (up === 'USDC' || up === 'CASH') return 6; + if (up === 'SOL' || up === 'WSOL') return 9; + return 0; +} + +/** Format a base-unit reward amount to a human-readable string. */ +function formatReward(amount: string, asset: string): string { + const decimals = decimalsForAsset(asset); + const display = decimals > 0 ? formatUnits(amount, decimals) : amount; + return `${formatAmount(display)} ${asset}`; +} + +/** Centered status message for loading / error / info states. */ +function StatusBlock({ tone, title, body }: { tone: string; title: string; body?: string }) { + return ( +
+

{title}

+ {body &&

{body}

} +
+ ); +} + +// ── useMyAgentId ────────────────────────────────────────────────────────────── + +function useMyAgentId(): string | null { + const [agentId, setAgentId] = useState(null); + useEffect(() => { + void fetchWalletStatus() + .then(status => { + const solana = (status.accounts ?? []).find(a => a.chain === 'solana'); + if (solana?.address) setAgentId(solana.address); + }) + .catch(() => {}); + }, []); + return agentId; +} + +// ── BountyStatusBadge ───────────────────────────────────────────────────────── +// 7 bounty statuses — exported for test access. + +export function BountyStatusBadge({ status }: { status: string }) { + const color = + status === 'draft' + ? 'bg-stone-100 text-stone-600 dark:bg-neutral-800 dark:text-neutral-400' + : status === 'open' + ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' + : status === 'judging' + ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' + : status === 'review' + ? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400' + : status === 'awarded' + ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' + : 'bg-stone-100 text-stone-600 dark:bg-neutral-800 dark:text-neutral-400'; // refunded / cancelled + return ( + + {status} + + ); +} + +// ── BountyRow ───────────────────────────────────────────────────────────────── + +interface BountyRowProps { + bounty: Bounty; + expanded: boolean; + onToggle: () => void; + myAgentId: string | null; + onFund: (bountyId: string) => void; + onSubmit: (bountyId: string) => void; + onComment: (bountyId: string) => void; + onCancel: (bountyId: string) => void; + onRunCouncil: (bountyId: string) => void; + mutating: boolean; +} + +function BountyRow({ + bounty, + expanded, + onToggle, + myAgentId, + onFund, + onSubmit, + onComment, + onCancel, + onRunCouncil, + mutating, +}: BountyRowProps) { + const [submissions, setSubmissions] = useState([]); + const [comments, setComments] = useState([]); + const [detailLoading, setDetailLoading] = useState(false); + + const isCreator = myAgentId !== null && bounty.creator === myAgentId; + + // Load submissions + comments on expand + useEffect(() => { + if (!expanded) return; + setDetailLoading(true); + Promise.all([ + apiClient.bounties + .listSubmissions(bounty.bountyId) + .then(res => setSubmissions(res.submissions ?? [])), + apiClient.bounties.listComments(bounty.bountyId).then(res => setComments(res.comments ?? [])), + ]) + .catch(() => {}) + .finally(() => setDetailLoading(false)); + }, [expanded, bounty.bountyId]); + + return ( +
+ {/* Summary row */} + + + {/* Detail panel */} + {expanded && ( +
+ {detailLoading && ( +

+ Loading details… +

+ )} + + {/* Description */} +

+ {bounty.description} +

+ + {/* Reward detail */} +
+
+ Reward: + + {formatReward(bounty.reward.amount, bounty.reward.asset)} + + {bounty.reward.network && ( + ({bounty.reward.network}) + )} +
+ {bounty.deadline && ( +
+ Deadline: + + {new Date(bounty.deadline).toLocaleString()} + +
+ )} +
+ Created: + + {new Date(bounty.createdAt).toLocaleString()} + +
+
+ + {/* Council section */} + {bounty.council && ( +
+

+ Council +

+
+ + Status: {bounty.council.status} + + {bounty.council.winnerSubmissionId && ( + + Winner:{' '} + {abbrev(bounty.council.winnerSubmissionId)} + + )} +
+ {bounty.council.reasoning && ( +

+ {bounty.council.reasoning} +

+ )} + {bounty.council.votes && bounty.council.votes.length > 0 && ( +
+

+ Votes +

+
+ {bounty.council.votes.map((vote, i) => ( +
+ + {vote.model ?? 'judge'} + + {vote.winnerSubmissionId && ( + + → {abbrev(vote.winnerSubmissionId)} + + )} + {vote.reasoning && ( +

+ {vote.reasoning} +

+ )} +
+ ))} +
+
+ )} +
+ )} + + {/* Submissions section */} + {submissions.length > 0 && ( +
+

+ Submissions ({submissions.length}) +

+
+ {submissions.map(sub => ( +
+
+ + {abbrev(sub.submitter)} + + {sub.status} +
+ {sub.title && ( +

+ {sub.title} +

+ )} + + {sub.url} + + {sub.note && ( +

+ {sub.note} +

+ )} +
+ ))} +
+
+ )} + + {/* Comments section */} + {comments.length > 0 && ( +
+

+ Comments ({comments.length}) +

+
+ {comments.map(c => ( +
+
+ + {abbrev(c.author)} + + + {relativeTime(c.createdAt)} + +
+

{c.body}

+
+ ))} +
+
+ )} + + {/* On-chain section */} + {(bounty.escrowAddress ?? bounty.fundingTxSig ?? bounty.payoutTxSig) && ( +
+

On-chain

+ {bounty.escrowAddress && ( +
+ Escrow: + + {abbrev(bounty.escrowAddress)} + +
+ )} + {bounty.fundingTxSig && ( +
+ Funding tx: + + {abbrev(bounty.fundingTxSig)} + +
+ )} + {bounty.payoutTxSig && ( +
+ Payout tx: + + {abbrev(bounty.payoutTxSig)} + +
+ )} +
+ )} + + {/* Action buttons (wallet-gated) */} + {myAgentId ? ( +
+ {/* Fund: creator + draft status */} + {isCreator && bounty.status === 'draft' && ( + + )} + {/* Submit Work: non-creator + open status */} + {!isCreator && bounty.status === 'open' && ( + + )} + {/* Comment: any authenticated user */} + + {/* Run Council: creator + open status */} + {isCreator && bounty.status === 'open' && ( + + )} + {/* Cancel: creator + draft or open status */} + {isCreator && (bounty.status === 'draft' || bounty.status === 'open') && ( + + )} + {/* TODO: surface Approve when admin role detection is available */} +
+ ) : ( +

+ Unlock your wallet to interact with this bounty. +

+ )} +
+ )} +
+ ); +} + +// ── CreateBountyModal ───────────────────────────────────────────────────────── + +function CreateBountyModal({ + onClose, + onCreated, +}: { + onClose: () => void; + onCreated: (bounty: Bounty) => void; +}) { + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [amount, setAmount] = useState(''); + const [asset, setAsset] = useState('USDC'); + const [deadline, setDeadline] = useState(''); + const [durationDays, setDurationDays] = useState(''); + // Tomorrow (YYYY-MM-DD), computed once — the date picker's min. Lazy init keeps + // the impure Date.now() out of render (react-hooks purity). + const [minDeadline] = useState(() => new Date(Date.now() + 86400000).toISOString().slice(0, 10)); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + // Confirm-before-spend: creating a bounty funds the reward into escrow via + // x402, so the first submit probes for the challenge, then a confirm dialog + // pays and creates. `confirm` holds the probe result + the params to re-send. + const [confirm, setConfirm] = useState<{ + params: BountyCreateParams; + challenge: RegistrationChallenge; + balance: RegistryWalletBalance | null; + walletAddress: string; + } | null>(null); + const [paying, setPaying] = useState(false); + + /** Build the create params from the form, or null + set an error if invalid. */ + function buildParams(): BountyCreateParams | null { + if (!title.trim()) { + setError('Title is required'); + return null; + } + if (!description.trim()) { + setError('Description is required'); + return null; + } + if (!amount.trim() || isNaN(Number(amount)) || Number(amount) <= 0) { + setError('Amount must be a positive number'); + return null; + } + // deadline and durationDays are alternatives — a yields + // "YYYY-MM-DD" but the backend wants an RFC3339 timestamp, so pin it to + // end-of-day UTC. Send only one of the two (deadline wins when set). + const deadlineIso = deadline.trim() ? `${deadline.trim()}T23:59:59Z` : undefined; + if (deadlineIso && new Date(deadlineIso).getTime() <= Date.now()) { + setError('Deadline must be in the future'); + return null; + } + return { + title: title.trim(), + description: description.trim(), + // BountyCreateRequest.amount is a HUMAN-decimal amount (e.g. "5"), not base units. + amount: amount.trim(), + asset: asset.trim() || 'USDC', + deadline: deadlineIso, + durationDays: deadlineIso + ? undefined + : durationDays.trim() + ? Number(durationDays) + : undefined, + }; + } + + // Phase 1 — probe for the x402 challenge (no spend). + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + const params = buildParams(); + if (!params) return; + setSubmitting(true); + try { + const res = await apiClient.bounties.create(params, { confirmed: false }); + if (res.challenge) { + setConfirm({ + params, + challenge: res.challenge, + balance: res.walletBalance ?? null, + walletAddress: res.walletAddress ?? '', + }); + } else if (res.bounty) { + onCreated(res.bounty as Bounty); + } else { + setError('Unexpected response from create.'); + } + } catch (err) { + setError(String(err)); + } finally { + setSubmitting(false); + } + } + + // Phase 2 — pay on-chain + create (spends). + async function handleConfirm() { + if (!confirm) return; + setPaying(true); + setError(null); + try { + const res = await apiClient.bounties.create(confirm.params, { confirmed: true }); + if (res.bounty) { + onCreated(res.bounty as Bounty); + } else { + setError('Bounty creation did not complete.'); + setConfirm(null); + } + } catch (err) { + setError(String(err)); + setConfirm(null); + } finally { + setPaying(false); + } + } + + // Confirm-before-spend dialog (shown after the probe returns a challenge). + if (confirm) { + return ( + void handleConfirm()} + onCancel={() => { + if (!paying) setConfirm(null); + }} + /> + ); + } + + return ( + +
{ + void handleSubmit(e); + }} + className="space-y-3"> +
+ + setTitle(e.target.value)} + placeholder="Bounty title" + className="w-full rounded border border-stone-300 bg-white px-3 py-1.5 text-sm text-stone-900 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-100" + /> +
+
+ +