From f2db9f8560853cb641f8b7b83ea4cc189b766cd6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel's Droid Date: Mon, 15 Jun 2026 16:10:55 +0000 Subject: [PATCH 01/13] test: cover live leaderboards and stats --- website/e2e/functional-leaderboards.spec.ts | 101 ++++++++++++++++++++ website/e2e/functional-platform.spec.ts | 3 +- 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 website/e2e/functional-leaderboards.spec.ts diff --git a/website/e2e/functional-leaderboards.spec.ts b/website/e2e/functional-leaderboards.spec.ts new file mode 100644 index 00000000..903b99c1 --- /dev/null +++ b/website/e2e/functional-leaderboards.spec.ts @@ -0,0 +1,101 @@ +import { expect, test } from "@playwright/test"; + +const API_URL = process.env.API_URL ?? "http://localhost:8080"; + +type JSONRecord = Record; + +async function apiJson(path: string): Promise { + const response = await fetch(`${API_URL}${path}`); + expect(response.status, path).toBe(200); + expect(response.headers.get("content-type"), path).toContain( + "application/json" + ); + return (await response.json()) as JSONRecord; +} + +function records(value: unknown): Array { + expect(value).toEqual(expect.any(Array)); + return value as Array; +} + +function record(value: unknown): JSONRecord { + expect(value).toEqual(expect.any(Object)); + return value as JSONRecord; +} + +function formatNumber(value: number): string { + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; + return value.toLocaleString(); +} + +function leaderboardLabel(entry: JSONRecord): string { + const username = entry.username; + if (typeof username === "string" && username.length > 0) return username; + const name = entry.name; + if (typeof name === "string" && name.length > 0) return name; + const groupId = entry.groupId; + if (typeof groupId === "string" && groupId.length > 0) return groupId; + const cryptoId = entry.cryptoId; + if (typeof cryptoId === "string" && cryptoId.length > 0) { + return cryptoId.slice(0, 12); + } + return "Unknown"; +} + +test.describe("live reputation, leaderboards, and stats", () => { + test("renders live reputation leaderboard rows from the backend", async ({ + page, + }) => { + const body = await apiJson("/leaderboards/reputation?limit=25"); + const entries = records(body.entries); + expect(entries.length, "seeded reputation leaderboard rows").toBeGreaterThan( + 0 + ); + const expectedLabel = leaderboardLabel(entries[0]!); + + await page.goto("/leaderboards"); + + await expect(page.getByRole("button", { name: "Top Agents" })).toBeVisible(); + await expect(page.getByText(expectedLabel, { exact: true })).toBeVisible(); + await expect(page.getByText("Score", { exact: true })).toBeVisible(); + }); + + test("renders live referral graph state from signed vouches", async ({ + page, + }) => { + const graph = await apiJson("/reputation/trust/graph?limit=50"); + expect(records(graph.nodes).length, "trust graph nodes").toBeGreaterThan(0); + expect(records(graph.edges).length, "trust graph edges").toBeGreaterThan(0); + + await page.goto("/reputation/graph"); + + await expect(page.getByText("High trust")).toBeVisible(); + await expect(page.getByText("Medium trust")).toBeVisible(); + await expect(page.getByText("Low trust")).toBeVisible(); + await expect( + page.getByText("No vouches have been recorded yet") + ).toBeHidden(); + }); + + test("renders live ledger-backed stats from explorer overview", async ({ + page, + }) => { + const overview = await apiJson("/explorer/overview"); + const ledger = record(overview.ledger); + const totalEntries = ledger.totalEntries; + expect(typeof totalEntries).toBe("number"); + expect(totalEntries as number, "ledger-backed explorer entries").toBeGreaterThan( + 0 + ); + + await page.goto("/stats"); + + await expect(page.getByText("Total Entries")).toBeVisible(); + await expect( + page.getByText(formatNumber(totalEntries as number)).first() + ).toBeVisible(); + await expect(page.getByText("24h Transactions")).toBeVisible(); + await expect(page.getByText("Total Volume")).toBeVisible(); + }); +}); diff --git a/website/e2e/functional-platform.spec.ts b/website/e2e/functional-platform.spec.ts index 750a1fbf..c10f67ec 100644 --- a/website/e2e/functional-platform.spec.ts +++ b/website/e2e/functional-platform.spec.ts @@ -287,7 +287,7 @@ test.describe("functional platform stack", () => { const spec = await apiJson("/spec"); expect(spec.name).toBe("tiny.place Network"); expect(spec.documents).toEqual(expect.any(Array)); - expect(spec.documents).toHaveLength(38); + expect(spec.documents).toHaveLength(39); expect(spec.documents).toEqual( expect.arrayContaining([ "activity", @@ -296,6 +296,7 @@ test.describe("functional platform stack", () => { "crypto-identity", "directory", "escrow", + "feedback", "games", "harness", "identity-registry", From c50e98f4ea896914375d8e49d876708878033f6a Mon Sep 17 00:00:00 2001 From: Steven Enamakel's Droid Date: Mon, 15 Jun 2026 16:19:06 +0000 Subject: [PATCH 02/13] test: skip live e2e without backend --- website/e2e/functional-leaderboards.spec.ts | 2 ++ website/e2e/identity-management.spec.ts | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/website/e2e/functional-leaderboards.spec.ts b/website/e2e/functional-leaderboards.spec.ts index 903b99c1..94b59fbe 100644 --- a/website/e2e/functional-leaderboards.spec.ts +++ b/website/e2e/functional-leaderboards.spec.ts @@ -44,6 +44,8 @@ function leaderboardLabel(entry: JSONRecord): string { } test.describe("live reputation, leaderboards, and stats", () => { + test.skip(!process.env.API_URL, "requires an explicit functional backend"); + test("renders live reputation leaderboard rows from the backend", async ({ page, }) => { diff --git a/website/e2e/identity-management.spec.ts b/website/e2e/identity-management.spec.ts index 03588884..d081ae7b 100644 --- a/website/e2e/identity-management.spec.ts +++ b/website/e2e/identity-management.spec.ts @@ -18,6 +18,11 @@ async function openRegistryTab(page: Page): Promise { } test.describe("identity handle input", () => { + test.skip( + !process.env.E2E_API_URL, + "live registry availability requires an explicit backend" + ); + test("sanitizes handle input to a-z0-9_ (no spaces/uppercase/punctuation)", async ({ page, }) => { From df1763cc6aa741320ad9c1149e4f0d5fef37a7bc Mon Sep 17 00:00:00 2001 From: Steven Enamakel's Droid Date: Mon, 15 Jun 2026 20:32:20 +0000 Subject: [PATCH 03/13] test: cover SDK harness capability surface --- sdk/typescript/src/api/registry.ts | 4 + .../tests/harness-capabilities.test.ts | 102 ++++++++++++++++++ sdk/typescript/tests/registry.test.ts | 26 +++++ 3 files changed, 132 insertions(+) create mode 100644 sdk/typescript/tests/harness-capabilities.test.ts diff --git a/sdk/typescript/src/api/registry.ts b/sdk/typescript/src/api/registry.ts index 1388f466..58268f29 100644 --- a/sdk/typescript/src/api/registry.ts +++ b/sdk/typescript/src/api/registry.ts @@ -321,6 +321,10 @@ export class RegistryApi { ); } + exportIdentity(name: string): Promise { + return this.export(name); + } + async updateProfileVisibility( name: string, update: ProfileVisibilityUpdate, diff --git a/sdk/typescript/tests/harness-capabilities.test.ts b/sdk/typescript/tests/harness-capabilities.test.ts new file mode 100644 index 00000000..7b7742d7 --- /dev/null +++ b/sdk/typescript/tests/harness-capabilities.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { TinyPlaceClient } from "../src/index.js"; + +describe("harness capability parity", () => { + it("exposes the documented harness capability families through the Node SDK", () => { + const client = new TinyPlaceClient({ + baseUrl: "https://example.test", + fetch: async () => Response.json({}), + }); + + const capabilityMethods: Record> = { + identity: [ + ["registry", "register"], + ["registry", "get"], + ["registry", "updateProfileVisibility"], + ["registry", "exportIdentity"], + ["directory", "resolve"], + ], + directory: [ + ["directory", "listAgents"], + ["directory", "getAgent"], + ["groups", "list"], + ["directory", "skills"], + ], + channels: [ + ["channels", "list"], + ["channels", "get"], + ["channels", "create"], + ["channels", "postMessage"], + ["channels", "members"], + ], + broadcasts: [ + ["broadcasts", "list"], + ["broadcasts", "get"], + ["broadcasts", "create"], + ["broadcasts", "subscribe"], + ["broadcasts", "postMessage"], + ], + messaging: [ + ["messages", "send"], + ["messages", "list"], + ["messages", "acknowledge"], + ["keys", "getBundle"], + ["keys", "health"], + ["a2a", "sendTask"], + ], + inbox: [ + ["inbox", "list"], + ["inbox", "markRead"], + ["inbox", "archive"], + ["inbox", "stream"], + ], + marketplace: [ + ["marketplace", "browseMarketplace"], + ["marketplace", "getProduct"], + ["marketplace", "createProduct"], + ["marketplace", "buyProduct"], + ["marketplace", "createProductReview"], + ], + reputation: [ + ["reputation", "getScore"], + ["reputation", "createAttestation"], + ["reputation", "leaderboard"], + ], + pricing: [ + ["pricing", "quote"], + ["pricing", "history"], + ["pricing", "assets"], + ["pricing", "pairs"], + ["pricing", "networks"], + ["pricing", "gas"], + ], + signers: [ + ["signers", "approve"], + ["signers", "list"], + ["signers", "get"], + ["signers", "revoke"], + ], + payments: [ + ["payments", "settle"], + ["payments", "verify"], + ["payments", "supported"], + ["payments", "createSubscription"], + ["payments", "cancelSubscription"], + ], + ledger: [ + ["ledger", "list"], + ["ledger", "get"], + ["ledger", "verify"], + ["ledger", "stream"], + ], + }; + + for (const [capability, methods] of Object.entries(capabilityMethods)) { + for (const [namespace, method] of methods) { + const api = client[namespace as keyof TinyPlaceClient] as Record; + expect(api, `${capability}.${namespace}`).toBeTruthy(); + expect(typeof api[method], `${capability}.${namespace}.${method}`).toBe("function"); + } + } + }); +}); diff --git a/sdk/typescript/tests/registry.test.ts b/sdk/typescript/tests/registry.test.ts index 06e395d2..efd858d7 100644 --- a/sdk/typescript/tests/registry.test.ts +++ b/sdk/typescript/tests/registry.test.ts @@ -996,6 +996,32 @@ describe("RegistryApi", () => { expect(ok).toBe(true); }); + it("exports identity records through the harness-facing alias", async () => { + const requests: Array = []; + const client = new TinyPlaceClient({ + baseUrl: "https://example.test", + fetch: async (input, init) => { + requests.push(new Request(input, init)); + return Response.json({ + identity: { username: "@agent" }, + ledger: [], + }); + }, + }); + + const exported = await client.registry.exportIdentity("@agent"); + + expect(exported).toEqual({ + identity: { username: "@agent" }, + ledger: [], + }); + expect(requests).toHaveLength(1); + expect(requests[0]!.method).toBe("GET"); + expect(requests[0]!.url).toBe( + "https://example.test/registry/names/%40agent/export", + ); + }); + it("signs profile visibility updates with null omitted fields", async () => { const signer = await LocalSigner.fromSeed(new Uint8Array(32).fill(16)); const requests: Array = []; From 19f45b49b635af46d90d193409e025a5319ceaf8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel's Droid Date: Mon, 15 Jun 2026 20:43:17 +0000 Subject: [PATCH 04/13] test: cover harness CLI surface --- sdk/typescript/package.json | 3 + sdk/typescript/src/cli.ts | 401 +++++++++++++++++++++++++++++++ sdk/typescript/tests/cli.test.ts | 88 +++++++ 3 files changed, 492 insertions(+) create mode 100644 sdk/typescript/src/cli.ts create mode 100644 sdk/typescript/tests/cli.test.ts diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index c8648f0c..4f3d37e0 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -7,6 +7,9 @@ "files": [ "dist" ], + "bin": { + "tinyplace": "./dist/cli.js" + }, "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts new file mode 100644 index 00000000..98765fb2 --- /dev/null +++ b/sdk/typescript/src/cli.ts @@ -0,0 +1,401 @@ +#!/usr/bin/env node +import { TinyPlaceClient } from "./client.js"; +import { LocalSigner } from "./local-signer.js"; + +type Flags = Record>; +type JsonObject = Record; + +interface ParsedArgs { + command?: string; + positionals: Array; + flags: Flags; +} + +export interface TinyPlaceCliCommand { + name: string; + capability: string; + description: string; +} + +export interface TinyPlaceCliOptions { + env?: Record; + fetch?: typeof globalThis.fetch; +} + +export interface TinyPlaceCliResult { + code: number; + stdout: string; + stderr: string; +} + +export const HARNESS_CLI_COMMANDS: Array = [ + { name: "register", capability: "identity", description: "Register a new @handle identity." }, + { name: "profile", capability: "identity", description: "Get an identity profile and cryptoId." }, + { name: "profile-visibility", capability: "identity", description: "Update profile and search visibility." }, + { name: "identity-export", capability: "identity", description: "Export an identity with ledger references." }, + { name: "resolve", capability: "identity", description: "Resolve @handle to cryptoId." }, + { name: "search", capability: "directory", description: "Search for agents by skill, tag, or name." }, + { name: "card", capability: "directory", description: "Get an agent card." }, + { name: "groups", capability: "directory", description: "List groups." }, + { name: "channels", capability: "channels", description: "List public channels." }, + { name: "channel", capability: "channels", description: "Get a channel." }, + { name: "channel-create", capability: "channels", description: "Create a channel." }, + { name: "channel-join", capability: "channels", description: "Join a channel." }, + { name: "channel-messages", capability: "channels", description: "List channel messages." }, + { name: "channel-post", capability: "channels", description: "Post a channel message." }, + { name: "channel-members", capability: "channels", description: "List channel members." }, + { name: "broadcasts", capability: "broadcasts", description: "List broadcasts." }, + { name: "broadcast", capability: "broadcasts", description: "Get a broadcast." }, + { name: "broadcast-create", capability: "broadcasts", description: "Create a broadcast." }, + { name: "broadcast-subscribe", capability: "broadcasts", description: "Subscribe to a broadcast." }, + { name: "broadcast-messages", capability: "broadcasts", description: "List broadcast messages." }, + { name: "broadcast-post", capability: "broadcasts", description: "Post a broadcast message." }, + { name: "broadcast-subscribers", capability: "broadcasts", description: "List broadcast subscribers." }, + { name: "send", capability: "messaging", description: "Send an encrypted message envelope." }, + { name: "messages", capability: "messaging", description: "Fetch pending messages." }, + { name: "ack", capability: "messaging", description: "Acknowledge a message." }, + { name: "key-bundle", capability: "messaging", description: "Fetch a Signal key bundle." }, + { name: "key-health", capability: "messaging", description: "Check Signal key health." }, + { name: "prekeys", capability: "messaging", description: "Upload Signal one-time prekeys." }, + { name: "signed-prekey", capability: "messaging", description: "Rotate a Signal signed prekey." }, + { name: "task", capability: "messaging", description: "Send an A2A task." }, + { name: "inbox", capability: "inbox", description: "List or search inbox items." }, + { name: "inbox-read", capability: "inbox", description: "Mark an inbox item read." }, + { name: "inbox-archive", capability: "inbox", description: "Archive an inbox item." }, + { name: "products", capability: "marketplace", description: "Browse marketplace products." }, + { name: "product", capability: "marketplace", description: "Get product details." }, + { name: "buy", capability: "marketplace", description: "Buy a product." }, + { name: "review", capability: "marketplace", description: "Review a product." }, + { name: "reputation", capability: "reputation", description: "Get reputation score." }, + { name: "attest", capability: "reputation", description: "Submit an attestation." }, + { name: "leaderboard", capability: "reputation", description: "Get reputation leaderboard." }, + { name: "pricing-quote", capability: "pricing", description: "Get a price quote." }, + { name: "pricing-history", capability: "pricing", description: "Get price history." }, + { name: "pricing-assets", capability: "pricing", description: "List pricing assets." }, + { name: "pricing-pairs", capability: "pricing", description: "List pricing pairs." }, + { name: "pricing-networks", capability: "pricing", description: "List pricing networks." }, + { name: "pricing-gas", capability: "pricing", description: "Get gas estimates." }, + { name: "signer-create", capability: "signers", description: "Create an approved signer." }, + { name: "signers", capability: "signers", description: "List approved signers." }, + { name: "signer", capability: "signers", description: "Get approved signer details." }, + { name: "signer-revoke", capability: "signers", description: "Revoke an approved signer." }, + { name: "pay", capability: "payments", description: "Settle an x402 payment." }, + { name: "payment-verify", capability: "payments", description: "Verify an x402 payment." }, + { name: "balance", capability: "payments", description: "List supported payment networks." }, + { name: "subscription", capability: "payments", description: "Get subscription status." }, + { name: "subscription-create", capability: "payments", description: "Create a subscription." }, + { name: "subscription-cancel", capability: "payments", description: "Cancel a subscription." }, + { name: "ledger", capability: "ledger", description: "List ledger transactions." }, + { name: "ledger-transaction", capability: "ledger", description: "Get a ledger transaction." }, + { name: "ledger-verify", capability: "ledger", description: "Verify a ledger transaction." }, +]; + +const HELP = `tinyplace [options] + +Commands: +${HARNESS_CLI_COMMANDS.map((command) => ` ${command.name.padEnd(22)} ${command.description}`).join("\n")} + +Configuration: + TINYPLACE_ENDPOINT, TINYPLACE_API_URL, or NEXT_PUBLIC_API_URL sets the API endpoint. + TINYPLACE_SECRET_KEY may contain a hex Ed25519 seed for signed operations. +`; + +export async function runTinyPlaceCli( + argv: Array, + options: TinyPlaceCliOptions = {}, +): Promise { + const parsed = parseArgs(argv); + if (!parsed.command || parsed.command === "help" || parsed.command === "--help") { + return { code: 0, stdout: HELP, stderr: "" }; + } + + try { + const client = await makeCliClient(options); + const result = await dispatch(client, parsed); + return { code: 0, stdout: `${JSON.stringify(result, null, 2)}\n`, stderr: "" }; + } catch (error) { + const detail = error as { status?: number; body?: unknown; paymentRequired?: unknown }; + return { + code: 1, + stdout: "", + stderr: `${JSON.stringify({ + error: error instanceof Error ? error.message : String(error), + ...(detail.status ? { status: detail.status } : {}), + ...(detail.body !== undefined ? { body: detail.body } : {}), + ...(detail.paymentRequired ? { paymentRequired: detail.paymentRequired } : {}), + }, null, 2)}\n`, + }; + } +} + +function parseArgs(argv: Array): ParsedArgs { + const positionals: Array = []; + const flags: Flags = {}; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index] ?? ""; + if (token.startsWith("--")) { + const key = token.slice(2); + const next = argv[index + 1]; + const value = next === undefined || next.startsWith("--") ? true : next; + const existing = flags[key]; + if (existing === undefined) { + flags[key] = value; + } else if (Array.isArray(existing)) { + existing.push(String(value)); + } else { + flags[key] = [String(existing), String(value)]; + } + if (value !== true) { + index += 1; + } + } else { + positionals.push(token); + } + } + return { + command: positionals[0], + positionals: positionals.slice(1), + flags, + }; +} + +async function makeCliClient(options: TinyPlaceCliOptions): Promise { + const env = options.env ?? process.env; + const baseUrl = + env.TINYPLACE_ENDPOINT ?? + env.TINYPLACE_API_URL ?? + env.NEXT_PUBLIC_API_URL ?? + "https://api.tiny.place"; + const seed = env.TINYPLACE_SECRET_KEY; + return new TinyPlaceClient({ + baseUrl, + ...(seed ? { signer: await LocalSigner.fromSeed(hexToBytes(seed)) } : {}), + fetch: options.fetch, + }); +} + +async function dispatch(client: TinyPlaceClient, parsed: ParsedArgs): Promise { + const [first, second] = parsed.positionals; + const flags = parsed.flags; + switch (parsed.command) { + case "register": + return client.registry.register({ + username: requiredFlag(flags, "handle"), + cryptoId: stringFlag(flags, "crypto-id") ?? "", + publicKey: stringFlag(flags, "public-key") ?? "", + ...(stringFlag(flags, "bio") ? { bio: stringFlag(flags, "bio") } : {}), + }); + case "profile": + return client.registry.get(required(first, "profile ")); + case "profile-visibility": + return client.registry.updateProfileVisibility(required(first, "profile-visibility "), bodyFlag(flags)); + case "identity-export": + return client.registry.exportIdentity(required(first, "identity-export ")); + case "resolve": + return client.directory.resolve(required(first, "resolve ")); + case "search": + return client.directory.listAgents(queryFlags(flags, ["q", "skill", "tag", "network", "asset", "limit"])); + case "card": + return client.directory.getAgent(required(first, "card ")); + case "groups": + return client.groups.list(queryFlags(flags, ["q", "tag", "limit", "offset"])); + case "channels": + return client.channels.list(queryFlags(flags, ["q", "tag", "sort", "limit", "offset"])); + case "channel": + return client.channels.get(required(first, "channel ")); + case "channel-create": + return client.channels.create(bodyFlag(flags)); + case "channel-join": + return client.channels.join(required(first, "channel-join "), requiredFlag(flags, "agent-id")); + case "channel-messages": + return client.channels.listMessages(required(first, "channel-messages "), queryFlags(flags, ["limit", "cursor"])); + case "channel-post": + return client.channels.postMessage(required(first, "channel-post "), bodyFlag(flags)); + case "channel-members": + return client.channels.members(required(first, "channel-members ")); + case "broadcasts": + return client.broadcasts.list(queryFlags(flags, ["q", "tag", "owner", "sort", "limit", "offset"])); + case "broadcast": + return client.broadcasts.get(required(first, "broadcast ")); + case "broadcast-create": + return client.broadcasts.create(typedBody(flags)); + case "broadcast-subscribe": + return client.broadcasts.subscribe(required(first, "broadcast-subscribe "), bodyFlag(flags)); + case "broadcast-messages": + return client.broadcasts.listMessages(required(first, "broadcast-messages "), queryFlags(flags, ["limit", "cursor"])); + case "broadcast-post": + return client.broadcasts.postMessage(required(first, "broadcast-post "), bodyFlag(flags)); + case "broadcast-subscribers": + return client.broadcasts.subscribers(required(first, "broadcast-subscribers ")); + case "send": + return client.messages.send({ ...bodyFlag(flags), to: first, body: second } as never); + case "messages": + return client.messages.list(stringFlag(flags, "agent-id") ?? required(first, "messages "), numberFlag(flags, "limit")); + case "ack": + return client.messages.acknowledge(required(first, "ack "), requiredFlag(flags, "agent-id")); + case "key-bundle": + return client.keys.getBundle(required(first, "key-bundle ")); + case "key-health": + return client.keys.health(required(first, "key-health ")); + case "prekeys": + return client.keys.uploadPreKeys(required(first, "prekeys "), typedBody(flags)); + case "signed-prekey": + return client.keys.rotateSignedPreKey(required(first, "signed-prekey "), typedBody(flags)); + case "task": + return client.a2a.sendTask(required(first, "task "), typedBody(flags)); + case "inbox": + return stringFlag(flags, "search") + ? client.inbox.search(requiredFlag(flags, "search"), stringFlag(flags, "owner")) + : client.inbox.list(queryFlags(flags, ["status", "type", "limit", "cursor"]), stringFlag(flags, "owner")); + case "inbox-read": + return client.inbox.markRead(required(first, "inbox-read "), stringFlag(flags, "owner")); + case "inbox-archive": + return client.inbox.archive(required(first, "inbox-archive "), stringFlag(flags, "owner")); + case "products": + return client.marketplace.browseMarketplace(queryFlags(flags, ["category", "tag", "q", "limit", "offset"])); + case "product": + return client.marketplace.getProduct(required(first, "product ")); + case "buy": + return client.marketplace.buyProduct(required(first, "buy "), bodyFlag(flags)); + case "review": + return client.marketplace.createProductReview(required(first, "review "), bodyFlag(flags)); + case "reputation": + return client.reputation.getScore(required(first, "reputation ")); + case "attest": + return client.reputation.createAttestation(typedBody(flags)); + case "leaderboard": + return client.reputation.leaderboard(); + case "pricing-quote": + return client.pricing.quote({ + base: requiredFlag(flags, "base"), + quote: stringFlag(flags, "quote") ?? "USDC", + ...(stringFlag(flags, "network") ? { network: stringFlag(flags, "network") } : {}), + }); + case "pricing-history": + return client.pricing.history({ + base: requiredFlag(flags, "base"), + quote: stringFlag(flags, "quote") ?? "USDC", + interval: requiredFlag(flags, "interval"), + ...(stringFlag(flags, "from") ? { from: stringFlag(flags, "from") } : {}), + ...(stringFlag(flags, "to") ? { to: stringFlag(flags, "to") } : {}), + }); + case "pricing-assets": + return client.pricing.assets(); + case "pricing-pairs": + return client.pricing.pairs(); + case "pricing-networks": + return client.pricing.networks(); + case "pricing-gas": + return client.pricing.gas(requiredFlag(flags, "network")); + case "signer-create": + return client.signers.approve(typedBody(flags)); + case "signers": + return client.signers.list(stringFlag(flags, "grantor")); + case "signer": + return client.signers.get(required(first, "signer "), stringFlag(flags, "grantor")); + case "signer-revoke": + return client.signers.revoke(required(first, "signer-revoke "), stringFlag(flags, "grantor")); + case "pay": + return client.payments.settle(typedBody(flags)); + case "payment-verify": + return client.payments.verify(typedBody(flags)); + case "balance": + return client.payments.supported(); + case "subscription": + return client.payments.getSubscription(required(first, "subscription "), stringFlag(flags, "actor")); + case "subscription-create": + return client.payments.createSubscription(typedBody(flags)); + case "subscription-cancel": + return client.payments.cancelSubscription(required(first, "subscription-cancel "), stringFlag(flags, "actor")); + case "ledger": + return client.ledger.list(flags.recent === true ? { limit: 20 } : queryFlags(flags, ["agent", "type", "status", "limit"])); + case "ledger-transaction": + return client.ledger.get(required(first, "ledger-transaction ")); + case "ledger-verify": + return client.ledger.verify(typedBody(flags)); + default: + throw new Error(`unknown command: ${parsed.command}`); + } +} + +function stringFlag(flags: Flags, name: string): string | undefined { + const value = flags[name]; + if (typeof value === "string") { + return value; + } + if (Array.isArray(value)) { + return value[value.length - 1]; + } + return undefined; +} + +function numberFlag(flags: Flags, name: string): number | undefined { + const value = stringFlag(flags, name); + if (value === undefined) { + return undefined; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function requiredFlag(flags: Flags, name: string): string { + return required(stringFlag(flags, name), `--${name}`); +} + +function required(value: string | undefined, usage: string): string { + if (!value) { + throw new Error(`usage: ${usage}`); + } + return value; +} + +function bodyFlag(flags: Flags): JsonObject { + const body = stringFlag(flags, "data"); + if (!body) { + return {}; + } + const parsed = JSON.parse(body) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("--data must be a JSON object"); + } + return parsed as JsonObject; +} + +function typedBody(flags: Flags): T { + return bodyFlag(flags) as T; +} + +function queryFlags(flags: Flags, names: Array): JsonObject { + const query: JsonObject = {}; + for (const name of names) { + const value = stringFlag(flags, name); + if (value !== undefined) { + query[name] = name === "limit" || name === "offset" ? Number(value) : value; + } + } + return query; +} + +function hexToBytes(value: string): Uint8Array { + const normalized = value.trim().replace(/^0x/i, ""); + if (normalized.length % 2 !== 0) { + throw new Error("TINYPLACE_SECRET_KEY must be an even-length hex string"); + } + const out = new Uint8Array(normalized.length / 2); + for (let index = 0; index < out.length; index += 1) { + out[index] = Number.parseInt(normalized.slice(index * 2, index * 2 + 2), 16); + } + return out; +} + +if (typeof process !== "undefined" && process.argv[1]?.endsWith("cli.js")) { + runTinyPlaceCli(process.argv.slice(2)).then((result) => { + if (result.stdout) { + process.stdout.write(result.stdout); + } + if (result.stderr) { + process.stderr.write(result.stderr); + } + process.exit(result.code); + }); +} diff --git a/sdk/typescript/tests/cli.test.ts b/sdk/typescript/tests/cli.test.ts new file mode 100644 index 00000000..0fa24a80 --- /dev/null +++ b/sdk/typescript/tests/cli.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { HARNESS_CLI_COMMANDS, runTinyPlaceCli } from "../src/cli.js"; + +describe("tinyplace CLI", () => { + it("exposes documented harness command families", () => { + const byCapability = new Map>(); + for (const command of HARNESS_CLI_COMMANDS) { + byCapability.set(command.capability, [ + ...(byCapability.get(command.capability) ?? []), + command.name, + ]); + } + + for (const capability of [ + "identity", + "directory", + "channels", + "broadcasts", + "messaging", + "inbox", + "marketplace", + "reputation", + "pricing", + "signers", + "payments", + "ledger", + ]) { + expect(byCapability.get(capability), capability).toBeTruthy(); + expect(byCapability.get(capability)!.length, capability).toBeGreaterThan(0); + } + + expect(HARNESS_CLI_COMMANDS.map((command) => command.name)).toEqual( + expect.arrayContaining([ + "register", + "search", + "channels", + "broadcasts", + "send", + "inbox", + "products", + "reputation", + "pricing-quote", + "signer-create", + "payment-verify", + "ledger", + ]), + ); + }); + + it("dispatches representative commands through the SDK routes and prints JSON", async () => { + const requests: Array = []; + const fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push(new Request(input, init)); + return Response.json({ ok: true }); + }; + const env = { TINYPLACE_ENDPOINT: "https://example.test" }; + + const pricing = await runTinyPlaceCli( + ["pricing-quote", "--base", "SOL", "--quote", "USDC", "--network", "solana:local"], + { env, fetch }, + ); + const ledger = await runTinyPlaceCli(["ledger", "--recent"], { env, fetch }); + const profile = await runTinyPlaceCli(["profile", "@agent"], { env, fetch }); + + expect([pricing.code, ledger.code, profile.code]).toEqual([0, 0, 0]); + expect(JSON.parse(pricing.stdout)).toEqual({ ok: true }); + expect(requests.map((request) => [request.method, request.url])).toEqual([ + [ + "GET", + "https://example.test/pricing/quote?base=SOL"e=USDC&network=solana%3Alocal", + ], + ["GET", "https://example.test/ledger/transactions?limit=20"], + ["GET", "https://example.test/registry/names/%40agent"], + ]); + }); + + it("returns parseable JSON errors", async () => { + const result = await runTinyPlaceCli(["profile"], { + env: { TINYPLACE_ENDPOINT: "https://example.test" }, + fetch: async () => Response.json({ ok: true }), + }); + + expect(result.code).toBe(1); + expect(JSON.parse(result.stderr)).toMatchObject({ + error: "usage: profile ", + }); + }); +}); From ceebbed2633633ea6d64c7969c97cd879c339a75 Mon Sep 17 00:00:00 2001 From: Steven Enamakel's Droid Date: Mon, 15 Jun 2026 20:52:18 +0000 Subject: [PATCH 05/13] test: cover CLI error and secret handling --- sdk/typescript/src/cli.ts | 59 ++++++++++++++-- sdk/typescript/tests/cli.test.ts | 113 +++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 4 deletions(-) diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 98765fb2..9157b61e 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -1,4 +1,7 @@ #!/usr/bin/env node +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { TinyPlaceClient } from "./client.js"; import { LocalSigner } from "./local-signer.js"; @@ -22,6 +25,11 @@ export interface TinyPlaceCliOptions { fetch?: typeof globalThis.fetch; } +interface TinyPlaceCliConfig { + endpoint?: string; + secretKey?: string; +} + export interface TinyPlaceCliResult { code: number; stdout: string; @@ -86,6 +94,7 @@ export const HARNESS_CLI_COMMANDS: Array = [ { name: "subscription-create", capability: "payments", description: "Create a subscription." }, { name: "subscription-cancel", capability: "payments", description: "Cancel a subscription." }, { name: "ledger", capability: "ledger", description: "List ledger transactions." }, + { name: "ledger-tx", capability: "ledger", description: "Get a ledger transaction." }, { name: "ledger-transaction", capability: "ledger", description: "Get a ledger transaction." }, { name: "ledger-verify", capability: "ledger", description: "Verify a ledger transaction." }, ]; @@ -112,18 +121,18 @@ export async function runTinyPlaceCli( try { const client = await makeCliClient(options); const result = await dispatch(client, parsed); - return { code: 0, stdout: `${JSON.stringify(result, null, 2)}\n`, stderr: "" }; + return { code: 0, stdout: `${JSON.stringify(redactSecrets(result), null, 2)}\n`, stderr: "" }; } catch (error) { const detail = error as { status?: number; body?: unknown; paymentRequired?: unknown }; return { code: 1, stdout: "", - stderr: `${JSON.stringify({ + stderr: `${JSON.stringify(redactSecrets({ error: error instanceof Error ? error.message : String(error), ...(detail.status ? { status: detail.status } : {}), ...(detail.body !== undefined ? { body: detail.body } : {}), ...(detail.paymentRequired ? { paymentRequired: detail.paymentRequired } : {}), - }, null, 2)}\n`, + }), null, 2)}\n`, }; } } @@ -161,12 +170,14 @@ function parseArgs(argv: Array): ParsedArgs { async function makeCliClient(options: TinyPlaceCliOptions): Promise { const env = options.env ?? process.env; + const config = await loadCliConfig(env); const baseUrl = env.TINYPLACE_ENDPOINT ?? env.TINYPLACE_API_URL ?? env.NEXT_PUBLIC_API_URL ?? + config.endpoint ?? "https://api.tiny.place"; - const seed = env.TINYPLACE_SECRET_KEY; + const seed = env.TINYPLACE_SECRET_KEY ?? config.secretKey; return new TinyPlaceClient({ baseUrl, ...(seed ? { signer: await LocalSigner.fromSeed(hexToBytes(seed)) } : {}), @@ -174,6 +185,26 @@ async function makeCliClient(options: TinyPlaceCliOptions): Promise): Promise { + const configPath = env.TINYPLACE_CONFIG ?? join(homedir(), ".tinyplace", "config.json"); + try { + const parsed = JSON.parse(await readFile(configPath, "utf8")) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + const config = parsed as Record; + return { + ...(typeof config.endpoint === "string" ? { endpoint: config.endpoint } : {}), + ...(typeof config.secretKey === "string" ? { secretKey: config.secretKey } : {}), + }; + } catch (error) { + if ((error as { code?: string }).code === "ENOENT") { + return {}; + } + throw error; + } +} + async function dispatch(client: TinyPlaceClient, parsed: ParsedArgs): Promise { const [first, second] = parsed.positionals; const flags = parsed.flags; @@ -309,6 +340,7 @@ async function dispatch(client: TinyPlaceClient, parsed: ParsedArgs): Promise"), stringFlag(flags, "actor")); case "ledger": return client.ledger.list(flags.recent === true ? { limit: 20 } : queryFlags(flags, ["agent", "type", "status", "limit"])); + case "ledger-tx": case "ledger-transaction": return client.ledger.get(required(first, "ledger-transaction ")); case "ledger-verify": @@ -388,6 +420,25 @@ function hexToBytes(value: string): Uint8Array { return out; } +function redactSecrets(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => redactSecrets(item)); + } + if (!value || typeof value !== "object") { + return value; + } + const out: JsonObject = {}; + for (const [key, child] of Object.entries(value)) { + out[key] = isSecretKeyName(key) ? "[redacted]" : redactSecrets(child); + } + return out; +} + +function isSecretKeyName(key: string): boolean { + const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, ""); + return normalized.includes("secret") || normalized.includes("privatekey"); +} + if (typeof process !== "undefined" && process.argv[1]?.endsWith("cli.js")) { runTinyPlaceCli(process.argv.slice(2)).then((result) => { if (result.stdout) { diff --git a/sdk/typescript/tests/cli.test.ts b/sdk/typescript/tests/cli.test.ts index 0fa24a80..b9484f8c 100644 --- a/sdk/typescript/tests/cli.test.ts +++ b/sdk/typescript/tests/cli.test.ts @@ -1,3 +1,6 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { HARNESS_CLI_COMMANDS, runTinyPlaceCli } from "../src/cli.js"; @@ -43,6 +46,7 @@ describe("tinyplace CLI", () => { "signer-create", "payment-verify", "ledger", + "ledger-tx", ]), ); }); @@ -85,4 +89,113 @@ describe("tinyplace CLI", () => { error: "usage: profile ", }); }); + + it("maps payment challenges into parseable JSON errors", async () => { + const challenge = { + error: "x402 payment is required", + payment: { + scheme: "exact", + network: "solana:mainnet", + asset: "USDC", + amount: "1000000", + to: "treasury", + }, + }; + + const result = await runTinyPlaceCli(["profile", "@paid"], { + env: { TINYPLACE_ENDPOINT: "https://example.test" }, + fetch: async () => + Response.json( + { error: "payment required" }, + { + status: 402, + headers: { + "X-Payment-Required": toBase64Url(JSON.stringify(challenge)), + }, + }, + ), + }); + + expect(result.code).toBe(1); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr)).toMatchObject({ + error: "HTTP 402: /registry/names/%40paid", + status: 402, + body: { error: "payment required" }, + paymentRequired: { + payment: { + amount: "1000000", + asset: "USDC", + to: "treasury", + }, + }, + }); + }); + + it("loads signer and endpoint config from the CLI config file", async () => { + const dir = await mkdtemp(join(tmpdir(), "tinyplace-cli-")); + const configPath = join(dir, "config.json"); + await writeFile( + configPath, + JSON.stringify({ + endpoint: "https://configured.example.test", + secretKey: "01".repeat(32), + }), + "utf8", + ); + + const requests: Array = []; + const result = await runTinyPlaceCli( + ["profile-visibility", "@agent", "--data", "{}"], + { + env: { TINYPLACE_CONFIG: configPath }, + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push(new Request(input, init)); + return Response.json({ ok: true }); + }, + }, + ); + + expect(result.code).toBe(0); + expect(requests).toHaveLength(1); + expect(requests[0].url).toBe( + "https://configured.example.test/registry/names/%40agent/profile-visibility", + ); + expect(requests[0].headers.get("X-TinyPlace-Signature")).toBeTruthy(); + expect(requests[0].headers.get("X-TinyPlace-Public-Key")).toBeTruthy(); + }); + + it("redacts secret-shaped fields from stdout and stderr JSON", async () => { + const ok = await runTinyPlaceCli(["profile", "@agent"], { + env: { TINYPLACE_ENDPOINT: "https://example.test" }, + fetch: async () => + Response.json({ + handle: "@agent", + secretKey: "do-not-print", + nested: { private_key: "do-not-print" }, + }), + }); + const failure = await runTinyPlaceCli(["profile", "@agent"], { + env: { TINYPLACE_ENDPOINT: "https://example.test" }, + fetch: async () => + Response.json( + { error: "bad", privateKey: "do-not-print" }, + { status: 500 }, + ), + }); + + expect(JSON.parse(ok.stdout)).toMatchObject({ + handle: "@agent", + secretKey: "[redacted]", + nested: { private_key: "[redacted]" }, + }); + expect(JSON.parse(failure.stderr)).toMatchObject({ + body: { privateKey: "[redacted]" }, + }); + expect(ok.stdout + failure.stderr).not.toContain("do-not-print"); + }); }); + +function toBase64Url(value: string): string { + return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} From ef824fd47210e4803fb5cbbe05a945cded7e38ee Mon Sep 17 00:00:00 2001 From: Steven Enamakel's Droid Date: Mon, 15 Jun 2026 21:00:05 +0000 Subject: [PATCH 06/13] test: cover identity export proof typing --- sdk/typescript/src/types/identity.ts | 22 +++++++++++++ sdk/typescript/tests/registry.test.ts | 46 ++++++++++++++++++++++++--- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/sdk/typescript/src/types/identity.ts b/sdk/typescript/src/types/identity.ts index 95404b26..a7a58c6a 100644 --- a/sdk/typescript/src/types/identity.ts +++ b/sdk/typescript/src/types/identity.ts @@ -83,6 +83,28 @@ export interface IdentityExport { ledgerTransactions: Array; exportedAt: string; verification: Record; + proofs: IdentityExportProofs; +} + +export interface IdentityExportProofs { + ownership: IdentityOwnershipProof; + ledgerReferences: Array; +} + +export interface IdentityOwnershipProof { + algorithm: string; + cryptoId: string; + publicKey: string; + publicKeyMatchesCryptoId: boolean; +} + +export interface IdentityLedgerReferenceProof { + txId: string; + onChainTx: string; + network: string; + status: import("./ledger.js").LedgerStatus; + type: import("./ledger.js").LedgerType; + reference: import("./ledger.js").LedgerReference; } export interface IdentityLifecycle { diff --git a/sdk/typescript/tests/registry.test.ts b/sdk/typescript/tests/registry.test.ts index efd858d7..82248d76 100644 --- a/sdk/typescript/tests/registry.test.ts +++ b/sdk/typescript/tests/registry.test.ts @@ -1004,17 +1004,55 @@ describe("RegistryApi", () => { requests.push(new Request(input, init)); return Response.json({ identity: { username: "@agent" }, - ledger: [], + ledgerTransactions: [ + { + txId: "ledger_tx_1", + visibility: "unshielded", + type: "REGISTRATION", + network: "solana", + timestamp: "2026-06-06T12:00:00Z", + reference: { kind: "identity", id: "@agent" }, + onChainTx: "tx_register", + status: "SETTLED", + }, + ], + exportedAt: "2026-06-06T12:00:00Z", + verification: { registrationTx: "tx_register" }, + proofs: { + ownership: { + algorithm: "ed25519-solana-public-key", + cryptoId: "crypto", + publicKey: "public", + publicKeyMatchesCryptoId: true, + }, + ledgerReferences: [ + { + txId: "ledger_tx_1", + onChainTx: "tx_register", + network: "solana", + status: "SETTLED", + type: "REGISTRATION", + reference: { kind: "identity", id: "@agent" }, + }, + ], + }, }); }, }); const exported = await client.registry.exportIdentity("@agent"); - expect(exported).toEqual({ - identity: { username: "@agent" }, - ledger: [], + expect(exported.proofs.ownership).toMatchObject({ + algorithm: "ed25519-solana-public-key", + cryptoId: "crypto", + publicKeyMatchesCryptoId: true, }); + expect(exported.proofs.ledgerReferences).toEqual([ + expect.objectContaining({ + onChainTx: "tx_register", + reference: { kind: "identity", id: "@agent" }, + }), + ]); expect(requests).toHaveLength(1); expect(requests[0]!.method).toBe("GET"); expect(requests[0]!.url).toBe( From 96f6ce96a10805fa7d7361930c86a46c622c6bbd Mon Sep 17 00:00:00 2001 From: Steven Enamakel's Droid Date: Mon, 15 Jun 2026 22:15:25 +0000 Subject: [PATCH 07/13] test: cover website x402 challenge signing --- .../explore/DomainRegistration.test.tsx | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 website/src/components/explore/DomainRegistration.test.tsx diff --git a/website/src/components/explore/DomainRegistration.test.tsx b/website/src/components/explore/DomainRegistration.test.tsx new file mode 100644 index 00000000..b7e31c42 --- /dev/null +++ b/website/src/components/explore/DomainRegistration.test.tsx @@ -0,0 +1,215 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + TinyPlaceError, + type Signer, + type X402AuthorizationFields, +} from "@tinyhumansai/tinyplace"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { useAuthStore } from "@src/store/auth"; + +import { DomainRegistration } from "./DomainRegistration"; + +type RegisterRequest = { + actorType: "human"; + cryptoId: string; + payment?: Record; + primary: boolean; + publicKey: string; + username: string; +}; + +type ConfirmRequest = { + amount?: string; + asset?: string; + confirmLabel: string; + note: string; + recipient?: string; + subject: string; + title: string; +}; + +const register = vi.hoisted(() => vi.fn()); +const createClient = vi.hoisted(() => vi.fn(() => ({ registry: { register } }))); +const confirmX402 = vi.hoisted(() => vi.fn()); + +vi.mock("@src/common/api-client", () => ({ + createClient: (signer?: Signer): unknown => createClient(signer), +})); + +vi.mock("@src/hooks/use-registry", () => ({ + useHandleAvailability: ( + name: string + ): { data: { available: boolean; name: string } | undefined; isLoading: boolean } => ({ + data: name ? { available: true, name } : undefined, + isLoading: false, + }), +})); + +vi.mock("@src/hooks/use-marketplace", () => ({ + useOwnedIdentities: (): { data: { identities: Array<{ primary: boolean }> } } => ({ + data: { identities: [] }, + }), +})); + +vi.mock("./x402-confirm", () => ({ + useOptionalX402Confirm: (): typeof confirmX402 => confirmX402, +})); + +function renderRegistration(): void { + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false }, queries: { retry: false } }, + }); + render( + + + + ); +} + +function challenge(fields: Partial = {}): TinyPlaceError { + return new TinyPlaceError(402, { + error: "x402 payment is required", + payment: { + scheme: "exact", + network: "solana:devnet", + asset: "SOL", + amount: "1500000", + to: "treasury-wallet", + nonce: "registration-nonce", + expiresAt: "2026-06-16T00:00:00.000Z", + metadata: { serverTrace: "quote-1" }, + ...fields, + }, + }); +} + +function containsSensitiveKey(value: unknown): boolean { + if (!value || typeof value !== "object") { + return false; + } + for (const [key, child] of Object.entries(value)) { + if (/private|secret/i.test(key)) { + return true; + } + if (containsSensitiveKey(child)) { + return true; + } + } + return false; +} + +function metadataRecord( + metadata: Array<{ key: string; value: string }> +): Record { + return Object.fromEntries(metadata.map(({ key, value }) => [key, value])); +} + +afterEach(() => { + register.mockReset(); + createClient.mockClear(); + confirmX402.mockReset(); + useAuthStore.getState().clearSession(); +}); + +describe("DomainRegistration x402 payment signing", () => { + it("signs server challenges with the wallet identity and never submits private key material", async () => { + const canonicalMessages: Array = []; + const wallet = { + agentId: "wallet-agent", + publicKeyBase64: "wallet-public-key", + sign: (data: Uint8Array): Uint8Array => { + canonicalMessages.push(new TextDecoder().decode(data)); + return new Uint8Array([1, 2, 3, 4]); + }, + } as Signer; + const user = userEvent.setup(); + + useAuthStore.getState().setSigner(wallet, wallet.agentId); + register.mockRejectedValueOnce(challenge()).mockResolvedValueOnce({ + username: "@atlas", + cryptoId: "wallet-agent", + }); + confirmX402.mockImplementation( + async ( + request: ConfirmRequest, + execute: () => Promise> + ): Promise> => { + expect(request).toMatchObject({ + title: "Register identity", + subject: "@atlas", + amount: "1500000", + asset: "SOL", + recipient: "treasury-wallet", + confirmLabel: "Sign x402", + }); + return execute(); + } + ); + + renderRegistration(); + await user.type(screen.getByPlaceholderText("Search for a name..."), "atlas"); + await user.click(screen.getByRole("button", { name: "Check" })); + await user.click( + screen.getByRole("button", { name: "Authorize 0.005 USDC & Register" }) + ); + + await waitFor(() => { + expect(register).toHaveBeenCalledTimes(2); + }); + + expect(createClient).toHaveBeenCalledWith(wallet); + const unpaidRequest = register.mock.calls[0]?.[0] as RegisterRequest; + expect(unpaidRequest).toMatchObject({ + username: "@atlas", + cryptoId: "wallet-agent", + publicKey: "wallet-public-key", + primary: true, + actorType: "human", + }); + expect(unpaidRequest.payment).toBeUndefined(); + + const paidRequest = register.mock.calls[1]?.[0] as RegisterRequest; + expect(paidRequest.payment).toMatchObject({ + scheme: "exact", + network: "solana:devnet", + asset: "SOL", + amount: "1500000", + from: "wallet-agent", + to: "treasury-wallet", + nonce: "registration-nonce", + expiresAt: "2026-06-16T00:00:00.000Z", + signature: "AQIDBA==", + "metadata.domain": "tiny.place", + "metadata.identity": "@atlas", + "metadata.publicKey": "wallet-public-key", + "metadata.purpose": "registration", + "metadata.serverTrace": "quote-1", + }); + expect(containsSensitiveKey(paidRequest)).toBe(false); + + expect(canonicalMessages).toHaveLength(1); + const canonical = JSON.parse(canonicalMessages[0]!) as { + metadata: Array<{ key: string; value: string }>; + }; + expect(canonical).toMatchObject({ + scheme: "exact", + network: "solana:devnet", + asset: "SOL", + amount: "1500000", + from: "wallet-agent", + to: "treasury-wallet", + nonce: "registration-nonce", + expiresAt: "2026-06-16T00:00:00.000Z", + }); + expect(metadataRecord(canonical.metadata)).toEqual({ + domain: "tiny.place", + identity: "@atlas", + publicKey: "wallet-public-key", + purpose: "registration", + serverTrace: "quote-1", + }); + }); +}); From eed16bde629e4ba7890a391726557dede4567ad6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel's Droid Date: Mon, 15 Jun 2026 22:19:56 +0000 Subject: [PATCH 08/13] fix: type website x402 test mock --- website/src/components/explore/DomainRegistration.test.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/website/src/components/explore/DomainRegistration.test.tsx b/website/src/components/explore/DomainRegistration.test.tsx index b7e31c42..fba43afa 100644 --- a/website/src/components/explore/DomainRegistration.test.tsx +++ b/website/src/components/explore/DomainRegistration.test.tsx @@ -32,7 +32,12 @@ type ConfirmRequest = { }; const register = vi.hoisted(() => vi.fn()); -const createClient = vi.hoisted(() => vi.fn(() => ({ registry: { register } }))); +const createClient = vi.hoisted(() => + vi.fn((signer: Signer | undefined) => { + void signer; + return { registry: { register } }; + }) +); const confirmX402 = vi.hoisted(() => vi.fn()); vi.mock("@src/common/api-client", () => ({ From b2457fe0234215625017653c774ae0067a3552cd Mon Sep 17 00:00:00 2001 From: Steven Enamakel's Droid Date: Mon, 15 Jun 2026 22:26:26 +0000 Subject: [PATCH 09/13] test: cover x402 payment dialog states --- .../explore/X402ConfirmDialog.test.tsx | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 website/src/components/explore/X402ConfirmDialog.test.tsx diff --git a/website/src/components/explore/X402ConfirmDialog.test.tsx b/website/src/components/explore/X402ConfirmDialog.test.tsx new file mode 100644 index 00000000..10b22fa9 --- /dev/null +++ b/website/src/components/explore/X402ConfirmDialog.test.tsx @@ -0,0 +1,112 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import type { FunctionComponent } from "@src/common/types"; + +import { X402ConfirmProvider } from "./X402ConfirmDialog"; +import { useX402Confirm } from "./x402-confirm"; + +type HarnessProperties = { + execute: () => Promise; +}; + +function Harness({ execute }: HarnessProperties): FunctionComponent { + const confirm = useX402Confirm(); + + return ( + + ); +} + +function renderHarness(execute: () => Promise): void { + render( + + + + ); +} + +describe("X402ConfirmProvider", () => { + it("renders the 402 challenge details and success state", async () => { + const user = userEvent.setup(); + const execute = vi.fn(() => Promise.resolve({ ledgerTxId: "tx_1" })); + + renderHarness(execute); + await user.click(screen.getByRole("button", { name: "Start payment" })); + + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveTextContent("Buy identity"); + expect(dialog).toHaveTextContent("@seller"); + expect(dialog).toHaveTextContent("120000 USDC"); + expect(dialog).toHaveTextContent("treasury-wallet"); + expect(dialog).toHaveTextContent("The server returned an x402 challenge."); + + await user.click(screen.getByRole("button", { name: "Buy" })); + + await waitFor(() => { + expect(execute).toHaveBeenCalledTimes(1); + }); + expect(screen.getByText("Payment confirmed.")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Done" })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("cancels before wallet signing", async () => { + const user = userEvent.setup(); + const execute = vi.fn(() => Promise.resolve({ ledgerTxId: "tx_1" })); + + renderHarness(execute); + await user.click(screen.getByRole("button", { name: "Start payment" })); + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(execute).not.toHaveBeenCalled(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it.each([ + ["wallet rejection", "User rejected wallet signature."], + ["insufficient funds", "Insufficient funds for this payment."], + ["verifier rejection", "Payment verifier rejected the authorization."], + ["settlement failure", "Settlement transaction failed."], + ["duplicate nonce", "payment nonce already settled"], + ])("shows %s errors and lets the user retry", async (_name, message) => { + const user = userEvent.setup(); + const execute = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error(message)) + .mockResolvedValueOnce({ ledgerTxId: "tx_retry" }); + + renderHarness(execute); + await user.click(screen.getByRole("button", { name: "Start payment" })); + await user.click(screen.getByRole("button", { name: "Buy" })); + + await expect(screen.findByText(message)).resolves.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Retry" })).toBeEnabled(); + + await user.click(screen.getByRole("button", { name: "Retry" })); + + await waitFor(() => { + expect(execute).toHaveBeenCalledTimes(2); + }); + expect(screen.getByText("Payment confirmed.")).toBeInTheDocument(); + }); +}); From ad4a371353d24d8722794ad8abd727ef10491065 Mon Sep 17 00:00:00 2001 From: Steven Enamakel's Droid Date: Mon, 15 Jun 2026 22:33:38 +0000 Subject: [PATCH 10/13] test: cover session signer approval state --- website/src/common/session-wallet.test.ts | 116 +++++++++++++++++ .../src/components/explore/Signers.test.tsx | 117 ++++++++++++++++++ website/src/components/explore/Signers.tsx | 8 +- 3 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 website/src/common/session-wallet.test.ts create mode 100644 website/src/components/explore/Signers.test.tsx diff --git a/website/src/common/session-wallet.test.ts b/website/src/common/session-wallet.test.ts new file mode 100644 index 00000000..9fbc41a1 --- /dev/null +++ b/website/src/common/session-wallet.test.ts @@ -0,0 +1,116 @@ +import { + LocalSigner, + SOLANA_MAINNET_NETWORK, + type TinyPlaceClient, + type X402Authorization, +} from "@tinyhumansai/tinyplace"; +import { describe, expect, it, vi } from "vitest"; + +import { SessionWalletSigner } from "./session-wallet"; + +function parseCanonical(message: string): { + amount: string; + asset: string; + expiresAt: string; + from: string; + metadata: Array<{ key: string; value: string }>; + network: string; + nonce: string; + scheme: string; + to: string; +} { + return JSON.parse(message) as { + amount: string; + asset: string; + expiresAt: string; + from: string; + metadata: Array<{ key: string; value: string }>; + network: string; + nonce: string; + scheme: string; + to: string; + }; +} + +function metadataRecord( + metadata: Array<{ key: string; value: string }> +): Record { + return Object.fromEntries(metadata.map(({ key, value }) => [key, value])); +} + +describe("SessionWalletSigner", () => { + it("establishes a bounded session grant without replacing the wallet identity key", async () => { + const wallet = await LocalSigner.fromSeed(new Uint8Array(32).fill(16)); + const signedMessages: Array = []; + let approved: X402Authorization | undefined; + const approve = vi.fn((authorization: X402Authorization) => { + approved = authorization; + return Promise.resolve({ + signerKey: "session-key", + grantor: wallet.agentId, + network: authorization.network, + asset: authorization.asset, + budget: authorization.amount, + spent: "0", + remaining: authorization.amount, + expiresAt: authorization.expiresAt, + nonce: authorization.nonce, + status: "active", + createdAt: "2026-06-15T00:00:00.000Z", + }); + }); + const client = { + signers: { + approve, + }, + } as unknown as TinyPlaceClient; + + const session = await SessionWalletSigner.establish( + wallet.publicKey, + async (message): Promise => { + signedMessages.push(new TextDecoder().decode(message)); + return wallet.sign(message); + }, + client + ); + + expect(approve).toHaveBeenCalledTimes(1); + expect(approved).toBeDefined(); + expect(approved).toMatchObject({ + scheme: "upto", + network: SOLANA_MAINNET_NETWORK, + asset: "USDC", + amount: "100000000", + from: wallet.agentId, + to: "", + }); + expect(approved!.nonce).toMatch(/^signer_/); + expect(Date.parse(approved!.expiresAt)).toBeGreaterThan(Date.now()); + + const canonical = parseCanonical(signedMessages[0]!); + expect(canonical).toMatchObject({ + scheme: "upto", + network: SOLANA_MAINNET_NETWORK, + asset: "USDC", + amount: "100000000", + from: wallet.agentId, + to: "", + nonce: approved!.nonce, + expiresAt: approved!.expiresAt, + }); + expect(metadataRecord(canonical.metadata)).toEqual({ + domain: "tiny.place", + publicKey: wallet.publicKeyBase64, + signerKey: session.sessionKey, + }); + + expect(session.agentId).toBe(wallet.agentId); + expect(session.walletSigner.publicKeyBase64).toBe(wallet.publicKeyBase64); + expect(session.identityPublicKeyBase64).toBe(wallet.publicKeyBase64); + expect(session.publicKeyBase64).not.toBe(wallet.publicKeyBase64); + expect(session.x402PaymentMetadata()).toEqual({ + publicKey: session.publicKeyBase64, + parentNonce: approved!.nonce, + }); + }); +}); diff --git a/website/src/components/explore/Signers.test.tsx b/website/src/components/explore/Signers.test.tsx new file mode 100644 index 00000000..ef986ded --- /dev/null +++ b/website/src/components/explore/Signers.test.tsx @@ -0,0 +1,117 @@ +import type { Signer, SignerApproval } from "@tinyhumansai/tinyplace"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useAuthStore } from "@src/store/auth"; + +import { Signers } from "./Signers"; + +const useApprovedSigners = vi.hoisted(() => vi.fn()); +const useRevokeSigner = vi.hoisted(() => vi.fn()); +const revokeMutate = vi.hoisted(() => vi.fn()); + +vi.mock("@src/hooks/use-signers", () => ({ + useApprovedSigners: (): unknown => useApprovedSigners(), + useRevokeSigner: (): unknown => useRevokeSigner(), +})); + +function fakeSigner(): Signer { + return { + agentId: "wallet-agent", + publicKeyBase64: "wallet-public-key", + } as Signer; +} + +function approval( + overrides: Partial = {} +): SignerApproval { + return { + signerKey: "session-key-active-123456", + grantor: "wallet-agent", + network: "solana:mainnet", + asset: "USDC", + budget: "100000000", + spent: "25000000", + remaining: "75000000", + expiresAt: "2026-06-20T00:00:00.000Z", + nonce: "signer_nonce", + status: "active", + createdAt: "2026-06-15T00:00:00.000Z", + ...overrides, + }; +} + +beforeEach(() => { + useAuthStore.getState().clearSession(); + useApprovedSigners.mockReset(); + useRevokeSigner.mockReset(); + revokeMutate.mockReset(); + useRevokeSigner.mockReturnValue({ + isError: false, + isPending: false, + mutate: revokeMutate, + }); +}); + +describe("Signers", () => { + it("prompts disconnected users to connect before managing approvals", () => { + useApprovedSigners.mockReturnValue({ + data: undefined, + isError: false, + isLoading: false, + }); + + render(); + + expect( + screen.getByText("Connect your wallet to manage approved signers.") + ).toBeInTheDocument(); + expect(useApprovedSigners).toHaveBeenCalledTimes(1); + }); + + it("shows bounded, budget-decrementing approvals and revokes active sessions", async () => { + const user = userEvent.setup(); + useAuthStore.getState().setSigner(fakeSigner(), "wallet-agent"); + useApprovedSigners.mockReturnValue({ + data: { + signers: [ + approval(), + approval({ + signerKey: "session-key-revoked-123456", + spent: "100000000", + remaining: "0", + status: "revoked", + }), + ], + }, + isError: false, + isLoading: false, + }); + + render(); + + expect(screen.getByText("Approved Wallet Signers")).toBeInTheDocument(); + expect( + screen.getByText( + "Session keys that can authorize payments against a signed budget." + ) + ).toBeInTheDocument(); + expect(screen.getAllByText("solana:mainnet / USDC")).toHaveLength(2); + expect(screen.getAllByText("Budget")).toHaveLength(2); + expect(screen.getAllByText("Spent")).toHaveLength(2); + expect(screen.getAllByText("Remaining")).toHaveLength(2); + expect(screen.getAllByText("100000000")).toHaveLength(3); + expect(screen.getByText("25000000")).toBeInTheDocument(); + expect(screen.getByText("75000000")).toBeInTheDocument(); + expect(screen.getAllByText("0")).toHaveLength(1); + + const revokeButtons = screen.getAllByRole("button", { name: "Revoke" }); + expect(revokeButtons[0]).toBeEnabled(); + expect(revokeButtons[1]).toBeDisabled(); + + await user.click(revokeButtons[0]!); + + expect(revokeMutate).toHaveBeenCalledWith("session-key-active-123456"); + }); +}); diff --git a/website/src/components/explore/Signers.tsx b/website/src/components/explore/Signers.tsx index d3cab6ee..cc474fcc 100644 --- a/website/src/components/explore/Signers.tsx +++ b/website/src/components/explore/Signers.tsx @@ -70,7 +70,7 @@ function SignerRow({

Budget

@@ -84,6 +84,12 @@ function SignerRow({ {approval.spent}

+
+

Remaining

+

+ {approval.remaining} +

+

Expires

From fdd7dbbc7aa6a586cd5549c74c707ab077ac71f2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel's Droid Date: Mon, 15 Jun 2026 22:39:09 +0000 Subject: [PATCH 11/13] test: assert escrow vault binding fields --- contracts-sol/tests/escrow.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/contracts-sol/tests/escrow.ts b/contracts-sol/tests/escrow.ts index 412272ec..754a6d5b 100644 --- a/contracts-sol/tests/escrow.ts +++ b/contracts-sol/tests/escrow.ts @@ -10,7 +10,9 @@ import { tokenBalance, createVault, initNonce, + jobPda, noncePda, + vaultAuthorityPda, payload, expectRevert, BN, @@ -29,7 +31,7 @@ describe("escrow (custody)", () => { }); it("creates a vault bound to a settlement program", async () => { - const { vault, vaultToken } = await createVault( + const { vaultId, vault, vaultToken } = await createVault( jobProgram.programId, mint, feeAccount, @@ -37,8 +39,11 @@ describe("escrow (custody)", () => { ); const acc = await escrowProgram.account.vault.fetch(vault); assert.equal(acc.settlementProgram.toBase58(), jobProgram.programId.toBase58()); + assert.equal(acc.owner.toBase58(), jobPda(vaultId).toBase58()); + assert.equal(acc.authority.toBase58(), vaultAuthorityPda(jobProgram.programId).toBase58()); assert.equal(acc.mint.toBase58(), mint.toBase58()); assert.equal(acc.tokenAccount.toBase58(), vaultToken.toBase58()); + assert.equal(acc.feeAccount.toBase58(), feeAccount.toBase58()); assert.equal(acc.deposited.toNumber(), 0); assert.equal(acc.disbursed.toNumber(), 0); }); From 76f45fa6c948da67f5af4bd5d42f48cd9802d58a Mon Sep 17 00:00:00 2001 From: Steven Enamakel's Droid Date: Mon, 15 Jun 2026 22:46:01 +0000 Subject: [PATCH 12/13] test: assert settlement job lifecycle states --- contracts-sol/tests/settlement_job.ts | 29 +++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/contracts-sol/tests/settlement_job.ts b/contracts-sol/tests/settlement_job.ts index 88b9e3d2..b5aa7e64 100644 --- a/contracts-sol/tests/settlement_job.ts +++ b/contracts-sol/tests/settlement_job.ts @@ -35,6 +35,15 @@ describe("settlement_job", () => { feeAccount = await ata(mint, payer.publicKey); }); + async function expectJobState( + job: PublicKey, + state: "open" | "delivered" | "disputed" | "resolved" | "refunded", + ) { + assert.deepEqual((await jobProgram.account.job.fetch(job)).state, { + [state]: {}, + }); + } + // Create a bound vault + job, fund it from `client`. Returns the handles. async function setupJob(label: string, client: Keypair, provider: PublicKey, controller: PublicKey) { const { vault, vaultToken } = await createVault(jobProgram.programId, mint, feeAccount, label); @@ -65,6 +74,7 @@ describe("settlement_job", () => { .rpc(); assert.equal((await escrowProgram.account.vault.fetch(vault)).deposited.toNumber(), FUND); + await expectJobState(job, "open"); return { vault, vaultToken, job, jobId }; } @@ -84,6 +94,7 @@ describe("settlement_job", () => { .accounts({ job, actor: providerKp.publicKey }) .signers([providerKp]) .rpc(); + await expectJobState(job, "delivered"); await jobProgram.methods .approve() @@ -106,6 +117,11 @@ describe("settlement_job", () => { assert.equal((await tokenBalance(feeAccount)) - feeBefore, BigInt(fee)); const acc = await escrowProgram.account.vault.fetch(vault); assert.equal(acc.disbursed.toNumber(), FUND); + await expectJobState(job, "resolved"); + await expectRevert( + jobProgram.methods.markDelivered().accounts({ job, actor: providerKp.publicKey }).signers([providerKp]).rpc(), + "resolved job cannot be delivered again", + ); }); it("dispute -> controller resolves to client (refund, no rake)", async () => { @@ -120,7 +136,9 @@ describe("settlement_job", () => { const clientBefore = await tokenBalance(clientToken); await jobProgram.methods.markDelivered().accounts({ job, actor: providerKp.publicKey }).signers([providerKp]).rpc(); + await expectJobState(job, "delivered"); await jobProgram.methods.dispute().accounts({ job, actor: client.publicKey }).signers([client]).rpc(); + await expectJobState(job, "disputed"); await jobProgram.methods .resolve(false) // award_provider = false -> client @@ -139,6 +157,11 @@ describe("settlement_job", () => { .rpc(); assert.equal((await tokenBalance(clientToken)) - clientBefore, BigInt(FUND)); // full refund, no fee + await expectJobState(job, "resolved"); + await expectRevert( + jobProgram.methods.dispute().accounts({ job, actor: client.publicKey }).signers([client]).rpc(), + "resolved dispute cannot be disputed again", + ); }); it("refund while Open returns funds to client", async () => { @@ -146,6 +169,7 @@ describe("settlement_job", () => { const providerKp = Keypair.generate(); const controller = Keypair.generate(); await fundSol(client.publicKey); + await fundSol(providerKp.publicKey); const clientToken = await ata(mint, client.publicKey); const { vault, vaultToken, job } = await setupJob("job-refund", client, providerKp.publicKey, controller.publicKey); @@ -168,6 +192,11 @@ describe("settlement_job", () => { .rpc(); assert.equal((await tokenBalance(clientToken)) - before, BigInt(FUND)); + await expectJobState(job, "refunded"); + await expectRevert( + jobProgram.methods.markDelivered().accounts({ job, actor: providerKp.publicKey }).signers([providerKp]).rpc(), + "refunded job cannot be delivered", + ); }); it("rejects unauthorized actors", async () => { From 8ba6f98e81217b80cb8487b9d7eae9d5ebfd50ce Mon Sep 17 00:00:00 2001 From: Steven Enamakel's Droid Date: Mon, 15 Jun 2026 22:51:00 +0000 Subject: [PATCH 13/13] test: assert poker settlement guards --- contracts-sol/tests/settlement_game_poker.ts | 92 ++++++++++++++++++-- 1 file changed, 87 insertions(+), 5 deletions(-) diff --git a/contracts-sol/tests/settlement_game_poker.ts b/contracts-sol/tests/settlement_game_poker.ts index ced4acf1..684d9659 100644 --- a/contracts-sol/tests/settlement_game_poker.ts +++ b/contracts-sol/tests/settlement_game_poker.ts @@ -40,6 +40,15 @@ describe("settlement_game_poker", () => { await fundSol(settler.publicKey); }); + async function expectGameState( + game: PublicKey, + state: "open" | "settled" | "cancelled", + ) { + assert.deepEqual((await pokerProgram.account.game.fetch(game)).state, { + [state]: {}, + }); + } + async function newGame(label: string) { const { vault, vaultToken } = await createVault(pokerProgram.programId, mint, feeAccount, label); const gameId = id32(label); @@ -49,15 +58,23 @@ describe("settlement_game_poker", () => { .accounts({ game, creator: payer.publicKey, vault, systemProgram: SystemProgram.programId }) .signers([]) .rpc(); + await expectGameState(game, "open"); return { vault, vaultToken, game, gameId }; } - async function join(game: PublicKey, vault: PublicKey, vaultToken: PublicKey, player: Keypair, nonce: number) { + async function join( + game: PublicKey, + vault: PublicKey, + vaultToken: PublicKey, + player: Keypair, + nonce: number, + amount = STAKE, + ) { await fundSol(player.publicKey); const playerToken = await fundTokens(mint, player.publicKey, STAKE); await initNonce(player); await pokerProgram.methods - .join(payload(player.publicKey, STAKE, nonce)) + .join(payload(player.publicKey, amount, nonce)) .accounts({ game, playerEntry: playerPda(game, player.publicKey), @@ -72,6 +89,13 @@ describe("settlement_game_poker", () => { }) .signers([player]) .rpc(); + + const entry = await pokerProgram.account.playerEntry.fetch(playerPda(game, player.publicKey)); + assert.equal(entry.game.toBase58(), game.toBase58()); + assert.equal(entry.player.toBase58(), player.publicKey.toBase58()); + assert.equal(entry.amount.toNumber(), amount); + assert.equal(entry.refunded, false); + return playerToken; } it("two players join and the settler awards the pot minus rake", async () => { @@ -83,6 +107,7 @@ describe("settlement_game_poker", () => { assert.equal((await pokerProgram.account.game.fetch(game)).playerCount, 2); assert.equal(await tokenBalance(vaultToken), BigInt(2 * STAKE)); + await expectGameState(game, "open"); const winnerToken = await ata(mint, p1.publicKey); const feeBefore = await tokenBalance(feeAccount); @@ -108,6 +133,10 @@ describe("settlement_game_poker", () => { const fee = Math.floor((pot * FEE_BPS) / 10000); // 100 assert.equal(await tokenBalance(winnerToken), BigInt(pot - fee)); assert.equal((await tokenBalance(feeAccount)) - feeBefore, BigInt(fee)); + await expectGameState(game, "settled"); + + const latePlayer = Keypair.generate(); + await expectRevert(join(game, vault, vaultToken, latePlayer, 1), "join after settle"); }); it("cancel + claim_refund returns each player's stake", async () => { @@ -118,9 +147,31 @@ describe("settlement_game_poker", () => { await join(game, vault, vaultToken, p2, 1); await pokerProgram.methods.cancel().accounts({ game, settler: settler.publicKey }).signers([settler]).rpc(); + await expectGameState(game, "cancelled"); + + await expectRevert( + pokerProgram.methods + .settle() + .accounts({ + game, + settler: settler.publicKey, + winnerEntry: playerPda(game, p1.publicKey), + vault, + vaultAuthority: vaultAuthority(), + vaultToken, + winnerToken: await ata(mint, p1.publicKey), + feeToken: feeAccount, + tokenProgram: TOKEN_PROGRAM_ID, + escrowProgram: escrowProgram.programId, + }) + .signers([settler]) + .rpc(), + "settle after cancel", + ); for (const p of [p1, p2]) { - const before = await tokenBalance(await ata(mint, p.publicKey)); + const playerToken = await ata(mint, p.publicKey); + const before = await tokenBalance(playerToken); await pokerProgram.methods .claimRefund() .accounts({ @@ -130,15 +181,37 @@ describe("settlement_game_poker", () => { vault, vaultAuthority: vaultAuthority(), vaultToken, - playerToken: await ata(mint, p.publicKey), + playerToken, feeToken: feeAccount, tokenProgram: TOKEN_PROGRAM_ID, escrowProgram: escrowProgram.programId, }) .signers([p]) .rpc(); - assert.equal((await tokenBalance(await ata(mint, p.publicKey))) - before, BigInt(STAKE)); + assert.equal((await tokenBalance(playerToken)) - before, BigInt(STAKE)); + const entry = await pokerProgram.account.playerEntry.fetch(playerPda(game, p.publicKey)); + assert.equal(entry.refunded, true); } + + await expectRevert( + pokerProgram.methods + .claimRefund() + .accounts({ + game, + playerEntry: playerPda(game, p1.publicKey), + player: p1.publicKey, + vault, + vaultAuthority: vaultAuthority(), + vaultToken, + playerToken: await ata(mint, p1.publicKey), + feeToken: feeAccount, + tokenProgram: TOKEN_PROGRAM_ID, + escrowProgram: escrowProgram.programId, + }) + .signers([p1]) + .rpc(), + "double refund", + ); }); it("rejects settle with < 2 players, double-join, and non-settler", async () => { @@ -170,6 +243,10 @@ describe("settlement_game_poker", () => { // Same player joining twice -> PlayerEntry already initialized. await expectRevert(join(game, vault, vaultToken, p1, 2), "double join"); + // Buy-in amount must exactly match the game stake. + const shortBuyer = Keypair.generate(); + await expectRevert(join(game, vault, vaultToken, shortBuyer, 1, STAKE - 1), "short buy-in"); + // Add a second player so the game is settleable, then a non-settler tries. const p2 = Keypair.generate(); await join(game, vault, vaultToken, p2, 1); @@ -194,6 +271,11 @@ describe("settlement_game_poker", () => { .rpc(), "non-settler settle", ); + + // The configured table cap is enforced. + await join(game, vault, vaultToken, Keypair.generate(), 1); + await join(game, vault, vaultToken, Keypair.generate(), 1); + await expectRevert(join(game, vault, vaultToken, Keypair.generate(), 1), "game full"); }); // Regression for the CRITICAL vault-theft finding: a pot vault is bound 1:1 to