Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion sdk/plugin-openclaw/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,10 @@ Then ask the agent: _"join tiny.place and buy the domain @hermes"_.

All via environment variables (see `tinyplace-agent help`): `TINYPLACE_API_URL`,
`TINYPLACE_SOLANA_RPC_URL`, `TINYPLACE_AGENT_HOME`, `TINYPLACE_WALLET_PASSPHRASE`,
`NEXT_PUBLIC_MOONPAY_API_KEY`, `MOONPAY_SECRET_KEY`, `MOONPAY_ENV`.
`TINYPLACE_HARNESS_KEY`, `NEXT_PUBLIC_MOONPAY_API_KEY`, `MOONPAY_SECRET_KEY`,
`MOONPAY_ENV`.

`TINYPLACE_HARNESS_KEY` defaults to `openclaw-v1` and is recorded on the
wallet's tiny.place profile when the agent registers a handle. Set it to a more
specific runtime label such as `hermes-v3` when a named harness/plugin owns the
wallet session.
5 changes: 5 additions & 0 deletions sdk/plugin-openclaw/skill/tinyplace/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ tinyplace-agent card publish --name "Hermes" \
tinyplace-agent status # your owned handles + whether a card is published
```

When the harness has a stable runtime/version label, set
`TINYPLACE_HARNESS_KEY` before wallet/profile operations, for example
`TINYPLACE_HARNESS_KEY=hermes-v3`. The SDK records that key on the wallet
profile so tiny.place can associate wallets, contact emails, and harnesses.

## Polling for Updates

Agents should check in periodically. `poll` reports unread inbox items, new
Expand Down
15 changes: 14 additions & 1 deletion sdk/plugin-openclaw/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ export function makeClient(
config: AgentConfig,
signer: LocalSigner,
): TinyPlaceClient {
return new TinyPlaceClient({ baseUrl: config.apiUrl, signer });
return new TinyPlaceClient({
baseUrl: config.apiUrl,
harnessKey: config.harnessKey,
signer,
});
}

function normalizeHandle(name: string): string {
Expand Down Expand Up @@ -101,6 +105,7 @@ export async function buyDomain(
let challenge: PaymentChallenge | undefined;
try {
const identity = await client.registry.register(request);
await recordHarness(client, signer);
// Free / no-payment registration path.
return summarize(identity);
} catch (error) {
Expand Down Expand Up @@ -132,13 +137,21 @@ export async function buyDomain(
});

const identity = await client.registry.register({ ...request, payment });
await recordHarness(client, signer);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not fail paid registrations on harness recording

In the paid registration path, registry.register has already returned success and the x402 payment has been accepted before this extra profile update runs. If /users/{id}/profile is unavailable or rejects the harness update, buyDomain now throws even though the handle and payment succeeded, which can make agents retry/pay again or report a false failure; treat harness recording as best-effort or return the registration result.

Useful? React with 👍 / 👎.

return {
...summarize(identity),
paidAmount: challenge.amount,
paidAsset: challenge.asset,
};
}

async function recordHarness(
client: TinyPlaceClient,
signer: LocalSigner,
): Promise<void> {
await client.users.updateProfile(signer.agentId, {});
}

function summarize(identity: {
username: string;
cryptoId: string;
Expand Down
4 changes: 4 additions & 0 deletions sdk/plugin-openclaw/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface AgentConfig {
usdcMint: string;
/** Directory holding the encrypted wallet vault + cached identity state. */
home: string;
/** Harness identifier recorded on the wallet profile. */
harnessKey: string;
/** True when the RPC endpoint is a local validator (enables airdrops). */
isLocal: boolean;
/** MoonPay publishable key (safe for the browser/agent — widget only). */
Expand All @@ -37,6 +39,7 @@ export interface AgentConfig {

const DEFAULT_API_URL = "https://staging-api.tiny.place";
const DEFAULT_RPC_URL = "https://api.mainnet-beta.solana.com";
const DEFAULT_HARNESS_KEY = "openclaw-v1";
// MoonPay's shared sandbox test key — matches website/src/common/moonpay.ts so
// the widget renders out of the box. Override with NEXT_PUBLIC_MOONPAY_API_KEY.
const DEFAULT_MOONPAY_KEY = "pk_test_oPfe89bYFJ6NJqrxXrZ4srpDInxvicu";
Expand All @@ -63,6 +66,7 @@ export function loadConfig(): AgentConfig {
network: env("TINYPLACE_NETWORK", SOLANA_MAINNET_NETWORK),
usdcMint: env("TINYPLACE_USDC_MINT", SOLANA_USDC_MINT),
home: env("TINYPLACE_AGENT_HOME", join(homedir(), ".tinyplace-agent")),
harnessKey: env("TINYPLACE_HARNESS_KEY", DEFAULT_HARNESS_KEY),
isLocal: looksLocal(solanaRpcUrl),
moonpayApiKey: env(
"NEXT_PUBLIC_MOONPAY_API_KEY",
Expand Down
1 change: 1 addition & 0 deletions sdk/plugin-openclaw/src/moonpay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ function config(overrides: Partial<AgentConfig> = {}): AgentConfig {
network: "solana-localnet",
usdcMint: "usdc-mint",
home: "/tmp/tinyplace-agent-test",
harnessKey: "openclaw-vtest",
isLocal: true,
moonpayApiKey: "pk_test",
moonpaySecretKey: undefined,
Expand Down
1 change: 1 addition & 0 deletions sdk/plugin-openclaw/src/wallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ function config(home: string): AgentConfig {
network: "solana-localnet",
usdcMint: "usdc-mint",
home,
harnessKey: "openclaw-vtest",
isLocal: true,
moonpayApiKey: "pk_test",
moonpaySecretKey: undefined,
Expand Down
88 changes: 87 additions & 1 deletion sdk/typescript/src/api/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import type { SigningKey } from "../auth.js";
import { signFreshCanonicalPayload } from "../auth.js";
import { canonicalPayload } from "../crypto.js";
import type { HttpClient } from "../http.js";
import type { User, UserProfileUpdate } from "../types/index.js";
import type {
User,
UserEmailVerificationConfirmRequest,
UserEmailVerificationRequest,
UserProfileUpdate,
} from "../types/index.js";

/**
* UsersApi reads and writes the per-wallet User profile — the single source of
Expand All @@ -14,6 +19,7 @@ export class UsersApi {
constructor(
private readonly http: HttpClient,
private readonly signingKey?: SigningKey,
private readonly harnessKey?: string,
) {}

/** Fetch a wallet's profile by its cryptoId. */
Expand All @@ -30,6 +36,7 @@ export class UsersApi {
cryptoId: string,
update: UserProfileUpdate,
): Promise<User> {
update = withDefaultHarnessKey(update, this.harnessKey);
if (this.signingKey && !update.signature) {
const payload = userProfileSignaturePayload(cryptoId, update);
update = {
Expand All @@ -42,6 +49,51 @@ export class UsersApi {
update,
);
}

/**
* Start email verification for a wallet. The backend stores the normalized
* email on the wallet profile, marks it unverified, and sends a short-lived
* code through the configured email provider.
*/
async startEmailVerification(
cryptoId: string,
request: UserEmailVerificationRequest,
): Promise<User> {
request = withDefaultHarnessKey(request, this.harnessKey);
if (this.signingKey && !request.signature) {
const payload = userEmailStartSignaturePayload(cryptoId, request);
request = {
...request,
signature: await signFreshCanonicalPayload(this.signingKey, payload),
};
}
return this.http.postDirectoryAuth<User>(
`/users/${encodeURIComponent(cryptoId)}/email/verification`,
request,
);
}

/**
* Confirm a wallet email verification code. Emails are not unique across
* wallets; verification is scoped to the signed wallet cryptoId.
*/
async confirmEmailVerification(
cryptoId: string,
request: UserEmailVerificationConfirmRequest,
): Promise<User> {
request = withDefaultHarnessKey(request, this.harnessKey);
if (this.signingKey && !request.signature) {
const payload = userEmailConfirmSignaturePayload(cryptoId, request);
request = {
...request,
signature: await signFreshCanonicalPayload(this.signingKey, payload),
};
}
return this.http.postDirectoryAuth<User>(
`/users/${encodeURIComponent(cryptoId)}/email/verification/confirm`,
request,
);
}
}

/**
Expand All @@ -60,7 +112,41 @@ function userProfileSignaturePayload(
bio: update.bio ?? null,
cryptoId,
displayName: update.displayName ?? null,
harnessKey: update.harnessKey ?? null,
link: update.link ?? null,
tags: update.tags ?? null,
});
}

function userEmailStartSignaturePayload(
cryptoId: string,
request: UserEmailVerificationRequest,
): string {
return canonicalPayload("user.email.start", {
cryptoId,
email: request.email,
harnessKey: request.harnessKey ?? null,
});
}

function userEmailConfirmSignaturePayload(
cryptoId: string,
request: UserEmailVerificationConfirmRequest,
): string {
return canonicalPayload("user.email.confirm", {
code: request.code,
cryptoId,
email: request.email,
harnessKey: request.harnessKey ?? null,
});
}

function withDefaultHarnessKey<T extends { harnessKey?: string }>(
value: T,
harnessKey: string | undefined,
): T {
if (value.harnessKey || !harnessKey) {
return value;
}
return { ...value, harnessKey };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not mutate already-signed profile payloads

When callers pass a precomputed signature while the client has a default harnessKey, this helper adds a new signed field before the !signature guard skips re-signing. The backend verifies the signature over the body it receives, so the signature no longer matches unless the caller knew to include exactly this harness key; preserve signed payloads or only apply the default when signing locally.

Useful? React with 👍 / 👎.

}
4 changes: 3 additions & 1 deletion sdk/typescript/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export interface TinyPlaceClientOptions {
adminSigningKey?: SigningKey;
/** Admin actor and optional role to bind into TinyPlace-Admin signatures. */
admin?: AdminSigningOptions;
/** Client/runtime identifier recorded on wallet profiles, e.g. hermes-v1 or openclaw-v2. */
harnessKey?: string;
fetch?: typeof globalThis.fetch;
/**
* Invoked when any request is rejected with 401/403. Lets the app react to an
Expand Down Expand Up @@ -156,7 +158,7 @@ export class TinyPlaceClient {
this.search = new SearchApi(this.http);
this.signers = new SignersApi(this.http);
this.profiles = new ProfilesApi(this.http);
this.users = new UsersApi(this.http, signingKey);
this.users = new UsersApi(this.http, signingKey, options.harnessKey);
this.explorer = new ExplorerApi(this.http, wsFactory);
this.feedback = new FeedbackApi(this.http);
this.pricing = new PricingApi(this.http);
Expand Down
19 changes: 19 additions & 0 deletions sdk/typescript/src/types/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export interface User {
displayName: string;
bio: string;
avatarEmail?: string;
email?: string;
emailVerified: boolean;
emailVerifiedAt?: string;
emailVerificationRequestedAt?: string;
harnessKey?: string;
link?: string;
tags?: Array<string>;
createdAt: string;
Expand All @@ -34,7 +39,21 @@ export interface UserProfileUpdate {
displayName?: string;
bio?: string;
avatarEmail?: string;
harnessKey?: string;
link?: string;
tags?: Array<string>;
signature?: string;
}

export interface UserEmailVerificationRequest {
email: string;
harnessKey?: string;
signature?: string;
}

export interface UserEmailVerificationConfirmRequest {
email: string;
code: string;
harnessKey?: string;
signature?: string;
}
71 changes: 70 additions & 1 deletion sdk/typescript/tests/users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ describe("UsersApi", () => {
const requests: Array<Request> = [];
const client = new TinyPlaceClient({
baseUrl: "https://example.test",
harnessKey: "hermes-v1",
signer,
fetch: async (input, init) => {
const request = new Request(input, init);
Expand All @@ -44,6 +45,8 @@ describe("UsersApi", () => {
actorType: "agent",
displayName: "Ada",
bio: "Updated bio.",
emailVerified: false,
harnessKey: "hermes-v1",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-02T00:00:00Z",
});
Expand All @@ -69,8 +72,74 @@ describe("UsersApi", () => {
signer.publicKeyBase64,
);
// The freshness-bound signature travels in the body, not just the headers.
const body = (await request.json()) as { signature?: string };
const body = (await request.json()) as {
harnessKey?: string;
signature?: string;
};
expect(body.harnessKey).toBe("hermes-v1");
expect(typeof body.signature).toBe("string");
expect(body.signature!.length).toBeGreaterThan(0);
});

it("starts and confirms email verification with signed wallet-scoped payloads", async () => {
const signer = await LocalSigner.fromSeed(new Uint8Array(32).fill(8));
const requests: Array<Request> = [];
const client = new TinyPlaceClient({
baseUrl: "https://example.test",
harnessKey: "openclaw-v1",
signer,
fetch: async (input, init) => {
const request = new Request(input, init);
requests.push(request);
return Response.json({
cryptoId: "WalletCrypto111",
actorType: "agent",
displayName: "",
bio: "",
email: "agent@example.com",
emailVerified: request.url.endsWith("/confirm"),
harnessKey: "openclaw-v1",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-02T00:00:00Z",
});
},
});

const pending = await client.users.startEmailVerification(
"WalletCrypto111",
{ email: "agent@example.com" },
);
const verified = await client.users.confirmEmailVerification(
"WalletCrypto111",
{ email: "agent@example.com", code: "123456" },
);

expect(pending.emailVerified).toBe(false);
expect(verified.emailVerified).toBe(true);
expect(requests).toHaveLength(2);
expect(requests[0]!.method).toBe("POST");
expect(requests[0]!.url).toBe(
"https://example.test/users/WalletCrypto111/email/verification",
);
expect(requests[1]!.url).toBe(
"https://example.test/users/WalletCrypto111/email/verification/confirm",
);

const startBody = (await requests[0]!.json()) as {
email?: string;
harnessKey?: string;
signature?: string;
};
const confirmBody = (await requests[1]!.json()) as {
code?: string;
harnessKey?: string;
signature?: string;
};
expect(startBody.email).toBe("agent@example.com");
expect(startBody.harnessKey).toBe("openclaw-v1");
expect(typeof startBody.signature).toBe("string");
expect(confirmBody.code).toBe("123456");
expect(confirmBody.harnessKey).toBe("openclaw-v1");
expect(typeof confirmBody.signature).toBe("string");
});
});
5 changes: 1 addition & 4 deletions website/src/components/explore/Feedback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@

import { useState } from "react";
import type { FeedbackItem, FeedbackStatus } from "@tinyhumansai/tinyplace";
import {
ArrowDownIcon,
ArrowUpIcon,
} from "@heroicons/react/24/outline";
import { ArrowDownIcon, ArrowUpIcon } from "@heroicons/react/24/outline";

import type { FunctionComponent } from "@src/common/types";
import {
Expand Down
1 change: 1 addition & 0 deletions website/src/components/profile/profile-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export function emptyUser(cryptoId: string): User {
actorType: "human",
displayName: "",
bio: "",
emailVerified: false,
createdAt: "",
updatedAt: "",
};
Expand Down
Loading