From 51aeefd49fd42a96c3c66bd357486b079872bfcd Mon Sep 17 00:00:00 2001
From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com>
Date: Mon, 29 Jun 2026 09:56:40 +0530
Subject: [PATCH] fix(tinyplace): switch active handle across multiple
purchased identities (#4216)
---
.../agentworld/pages/ProfilesSection.test.tsx | 54 ++++++++++++++-
app/src/agentworld/pages/ProfilesSection.tsx | 68 +++++++++++++++----
.../lib/agentworld/invokeApiClient.test.ts | 14 ++++
app/src/lib/agentworld/invokeApiClient.ts | 15 ++++
src/openhuman/tinyplace/manifest.rs | 44 ++++++++++++
src/openhuman/tinyplace/schemas.rs | 23 +++++++
6 files changed, 204 insertions(+), 14 deletions(-)
diff --git a/app/src/agentworld/pages/ProfilesSection.test.tsx b/app/src/agentworld/pages/ProfilesSection.test.tsx
index 74c9748c8..5f8a449a7 100644
--- a/app/src/agentworld/pages/ProfilesSection.test.tsx
+++ b/app/src/agentworld/pages/ProfilesSection.test.tsx
@@ -6,7 +6,7 @@
* renders one of: loading / wallet_locked / no_handle / payment_required /
* error / populated card. All handles/ids are GENERIC placeholders.
*/
-import { render, screen, waitFor } from '@testing-library/react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { type GqlProfile, PaymentRequiredError } from '../../lib/agentworld/invokeApiClient';
@@ -19,7 +19,7 @@ vi.mock('../AgentWorldShell', () => ({
apiClient: {
directory: { reverse: vi.fn() },
follows: { stats: vi.fn() },
- registry: { export: vi.fn() },
+ registry: { export: vi.fn(), assignPrimary: vi.fn() },
graphql: { user: vi.fn() },
},
}));
@@ -29,6 +29,7 @@ const reverse = vi.mocked(apiClient.directory.reverse);
const walletStatus = vi.mocked(fetchWalletStatus);
const followStats = vi.mocked(apiClient.follows.stats);
const registryExport = vi.mocked(apiClient.registry.export);
+const assignPrimary = vi.mocked(apiClient.registry.assignPrimary);
const graphqlUser = vi.mocked(apiClient.graphql.user);
const SOLANA_ADDR = 'WaLLetSoLanaAddr0123456789';
@@ -416,6 +417,55 @@ describe('graphql-enriched profile card', () => {
expect(reverse).not.toHaveBeenCalled();
});
+ test('header shows the PRIMARY handle, not identities[0], with multiple handles (#4198)', async () => {
+ graphqlUser.mockResolvedValueOnce(
+ makeProfile({
+ displayName: 'Multi Agent',
+ identities: [
+ { username: 'firstbought', cryptoId: SOLANA_ADDR, primary: false, ...minimalIdentity },
+ { username: 'chosenactive', cryptoId: SOLANA_ADDR, primary: true, ...minimalIdentity },
+ ],
+ })
+ );
+ render();
+ // The header must reflect the primary-flagged handle even though it is not
+ // first in the array — pre-fix this showed @firstbought.
+ expect(await screen.findByRole('heading', { name: /@chosenactive/ })).toBeInTheDocument();
+ });
+
+ test('clicking "Make active" promotes a non-primary handle and refetches (#4198)', async () => {
+ graphqlUser
+ .mockResolvedValueOnce(
+ makeProfile({
+ identities: [
+ { username: 'active1', cryptoId: SOLANA_ADDR, primary: true, ...minimalIdentity },
+ { username: 'spare2', cryptoId: SOLANA_ADDR, primary: false, ...minimalIdentity },
+ ],
+ })
+ )
+ // After the switch, the refetch returns spare2 as the new primary.
+ .mockResolvedValueOnce(
+ makeProfile({
+ identities: [
+ { username: 'active1', cryptoId: SOLANA_ADDR, primary: false, ...minimalIdentity },
+ { username: 'spare2', cryptoId: SOLANA_ADDR, primary: true, ...minimalIdentity },
+ ],
+ })
+ );
+ assignPrimary.mockResolvedValueOnce({
+ identity: { username: 'spare2', cryptoId: SOLANA_ADDR, primary: true, ...minimalIdentity },
+ });
+ render();
+
+ const makeActive = await screen.findByRole('button', { name: /Make active/i });
+ fireEvent.click(makeActive);
+
+ await waitFor(() => expect(assignPrimary).toHaveBeenCalledWith('spare2'));
+ // Refetch promoted spare2 — the header now shows it.
+ expect(await screen.findByRole('heading', { name: /@spare2/ })).toBeInTheDocument();
+ expect(graphqlUser).toHaveBeenCalledTimes(2);
+ });
+
test('renders profile with empty attestations array — no Verified Accounts section', async () => {
graphqlUser.mockResolvedValueOnce(
makeProfile({
diff --git a/app/src/agentworld/pages/ProfilesSection.tsx b/app/src/agentworld/pages/ProfilesSection.tsx
index 4b437c414..e98d31522 100644
--- a/app/src/agentworld/pages/ProfilesSection.tsx
+++ b/app/src/agentworld/pages/ProfilesSection.tsx
@@ -77,13 +77,17 @@ type ProfileState =
// ── Data hook ─────────────────────────────────────────────────────────────────
-/** Pick the primary handle, else the first, from a reverse-lookup result. */
-function pickPrimary(identities: OwnedIdentity[]): OwnedIdentity | undefined {
+/** Pick the primary handle, else the first, from a reverse-lookup result.
+ * Generic over the identity shape so it accepts both the bare directory
+ * `OwnedIdentity` and the richer GraphQL `Identity` (it only reads `primary`). */
+function pickPrimary(identities: T[]): T | undefined {
return identities.find(i => i.primary) ?? identities[0];
}
-/** Load the wallet's own identity: wallet_status → reverse-lookup → primary handle. */
-function useMyIdentity(): ProfileState {
+/** Load the wallet's own identity: wallet_status → reverse-lookup → primary handle.
+ * `reloadKey` is bumped by the active-handle switch so the profile refetches and
+ * the newly-promoted primary is reflected (#4198). */
+function useMyIdentity(reloadKey: number): ProfileState {
const [state, setState] = useState({ status: 'loading' });
useEffect(() => {
@@ -157,18 +161,21 @@ function useMyIdentity(): ProfileState {
return () => {
cancelled = true;
};
- }, []);
+ }, [reloadKey]);
return state;
}
// ── Sub-components ────────────────────────────────────────────────────────────
-function AgentProfileCard({ data }: { data: ProfileData }) {
+function AgentProfileCard({ data, onSwitched }: { data: ProfileData; onSwitched?: () => void }) {
const [followStats, setFollowStats] = useState(null);
const [exportData, setExportData] = useState(null);
const [exportLoading, setExportLoading] = useState(false);
const [exportError, setExportError] = useState(null);
+ // Handle currently being promoted to primary (in-flight), and any error.
+ const [switchingHandle, setSwitchingHandle] = useState(null);
+ const [switchError, setSwitchError] = useState(null);
// ── Extract display fields from either data source ─────────────────────────
const isGraphql = data.source === 'graphql';
@@ -176,10 +183,13 @@ function AgentProfileCard({ data }: { data: ProfileData }) {
const identity = isGraphql ? null : data.identity;
// Determine the display name / handle.
- // GraphQL with a registered identity → @username.
+ // GraphQL with a registered identity → @username (the wallet's PRIMARY handle,
+ // not merely identities[0] — a user with several handles can mark any one
+ // primary, #4198).
// GraphQL without identities (null) → displayName (no @ prefix, not a handle).
// Directory fallback → @username from identity.
- const primaryIdentityUsername = isGraphql ? (profile!.identities?.[0]?.username ?? null) : null;
+ const primaryIdentity = isGraphql ? pickPrimary(profile!.identities ?? []) : undefined;
+ const primaryIdentityUsername = isGraphql ? (primaryIdentity?.username ?? null) : null;
const hasHandle = isGraphql
? primaryIdentityUsername !== null
: (identity!.username ?? null) !== null;
@@ -226,6 +236,26 @@ function AgentProfileCard({ data }: { data: ProfileData }) {
}
}, [exportLoading, exportData, handle]);
+ // Promote one of the wallet's handles to primary (active), then ask the
+ // parent to refetch so the new primary is reflected everywhere (#4198).
+ const handleSetActive = useCallback(
+ async (username: string) => {
+ const name = username.replace(/^@+/, '');
+ if (switchingHandle) return;
+ setSwitchError(null);
+ setSwitchingHandle(name);
+ try {
+ await apiClient.registry.assignPrimary(name);
+ onSwitched?.();
+ } catch (err) {
+ setSwitchError(String(err));
+ } finally {
+ setSwitchingHandle(null);
+ }
+ },
+ [switchingHandle, onSwitched]
+ );
+
useEffect(() => {
if (!agentId) return;
let cancelled = false;
@@ -326,10 +356,20 @@ function AgentProfileCard({ data }: { data: ProfileData }) {
@{id.username.replace(/^@+/, '')}
- {id.primary && (
+ {id.primary ? (
- primary
+ active
+ ) : (
+
)}
{id.status}
@@ -338,6 +378,9 @@ function AgentProfileCard({ data }: { data: ProfileData }) {
))}
+ {switchError && (
+ {switchError}
+ )}
)}
@@ -399,7 +442,8 @@ function StatusBlock({ tone, title, body }: { tone: string; title: string; body?
// ── Main export ───────────────────────────────────────────────────────────────
export default function ProfilesSection() {
- const state = useMyIdentity();
+ const [reloadKey, setReloadKey] = useState(0);
+ const state = useMyIdentity(reloadKey);
let body: React.ReactNode;
@@ -444,7 +488,7 @@ export default function ProfilesSection() {
} else {
// Render the wallet's own profile with either rich GraphQL data or bare
// directory.reverse identity. AgentProfileCard handles both shapes internally.
- body = ;
+ body = setReloadKey(k => k + 1)} />;
}
return {body};
diff --git a/app/src/lib/agentworld/invokeApiClient.test.ts b/app/src/lib/agentworld/invokeApiClient.test.ts
index bb04b0248..953e0c829 100644
--- a/app/src/lib/agentworld/invokeApiClient.test.ts
+++ b/app/src/lib/agentworld/invokeApiClient.test.ts
@@ -479,6 +479,20 @@ describe('registry.export', () => {
});
});
+describe('registry.assignPrimary', () => {
+ test('calls openhuman.tinyplace_registry_assign_primary with name (#4198)', async () => {
+ mockCallCoreRpc.mockResolvedValueOnce({ identity: { username: '@second', primary: true } });
+ const client = createInvokeApiClient();
+ const result = await client.registry.assignPrimary('@second');
+
+ expect(mockCallCoreRpc).toHaveBeenCalledWith({
+ method: 'openhuman.tinyplace_registry_assign_primary',
+ params: { name: '@second' },
+ });
+ expect(result.identity?.primary).toBe(true);
+ });
+});
+
describe('registry.get', () => {
test('calls openhuman.tinyplace_registry_get with name', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ available: true, name: '@atlas' });
diff --git a/app/src/lib/agentworld/invokeApiClient.ts b/app/src/lib/agentworld/invokeApiClient.ts
index fcf658383..d60664b18 100644
--- a/app/src/lib/agentworld/invokeApiClient.ts
+++ b/app/src/lib/agentworld/invokeApiClient.ts
@@ -330,6 +330,12 @@ export interface RegistrationResult {
[key: string]: unknown;
}
+/** Result of `registry.assignPrimary` — the handle now flagged primary. */
+export interface AssignPrimaryResult {
+ identity?: Identity;
+ [key: string]: unknown;
+}
+
// -- Registry export types ------------------------------------------------
export interface LedgerReference {
@@ -1787,6 +1793,15 @@ export function createInvokeApiClient() {
/** Export an identity with its ledger history and cryptographic proofs. */
export: (name: string) =>
call('openhuman.tinyplace_registry_export', { name }),
+ /**
+ * Make one of the wallet's purchased handles its primary (active) identity.
+ * The backend clears the primary flag on the wallet's other handles, so
+ * this is how a user with multiple identities switches the active handle
+ * reflected across feed / profile / directory (#4198). The owning wallet
+ * is proven by the signer-attached signature, not by params.
+ */
+ assignPrimary: (name: string) =>
+ call('openhuman.tinyplace_registry_assign_primary', { name }),
},
directoryIdentities: {
/** List identity listings from the directory. */
diff --git a/src/openhuman/tinyplace/manifest.rs b/src/openhuman/tinyplace/manifest.rs
index 618425b2d..5ccd7e51b 100644
--- a/src/openhuman/tinyplace/manifest.rs
+++ b/src/openhuman/tinyplace/manifest.rs
@@ -2396,6 +2396,35 @@ pub(crate) fn handle_tinyplace_registry_export(params: Map) -> Co
})
}
+/// Assign one of the wallet's purchased handles as its **primary** (active)
+/// identity. The backend clears the primary flag on the wallet's other names
+/// (one primary per wallet), so this is how a user who owns multiple identities
+/// switches which handle is shown as active across feed / profile / directory
+/// (#4198). ANTI-SPOOF: the owning wallet is proven by the request signature the
+/// SDK attaches from the unlocked signer, never from params — so a caller can
+/// only re-point their *own* wallet's primary.
+pub(crate) fn handle_tinyplace_registry_assign_primary(
+ params: Map,
+) -> ControllerFuture {
+ Box::pin(async move {
+ let name = req_str(¶ms, "name")?
+ .trim()
+ .trim_start_matches('@')
+ .to_string();
+ if name.is_empty() {
+ return Err("missing required param 'name'".to_string());
+ }
+ log::debug!("{LOG_PREFIX} registry_assign_primary name={name}");
+ let client = global_state().client().await?;
+ let identity = client
+ .registry
+ .assign_primary(&name)
+ .await
+ .map_err(map_err)?;
+ to_value(serde_json::json!({ "identity": identity }))
+ })
+}
+
// ── Users email verification ────────────────────────────────────────────────
pub(crate) fn handle_tinyplace_users_start_email_verification(
@@ -5922,6 +5951,21 @@ mod tests {
);
}
+ /// registry_assign_primary rejects a blank/@-only name before any client
+ /// work (#4198) — the trim + strip-@ must not leave an empty handle that
+ /// would hit the backend.
+ #[test]
+ fn registry_assign_primary_rejects_blank_name() {
+ let mut params = Map::new();
+ params.insert("name".to_string(), Value::String(" @ ".to_string()));
+ let result = block_on(handle_tinyplace_registry_assign_primary(params));
+ assert!(result.is_err());
+ assert!(
+ result.unwrap_err().contains("name"),
+ "expected 'name' in error"
+ );
+ }
+
/// bounties_submit rejects blank url before any client work.
#[test]
fn bounties_submit_rejects_blank_url() {
diff --git a/src/openhuman/tinyplace/schemas.rs b/src/openhuman/tinyplace/schemas.rs
index f6676b32c..49b6e749d 100644
--- a/src/openhuman/tinyplace/schemas.rs
+++ b/src/openhuman/tinyplace/schemas.rs
@@ -138,6 +138,7 @@ use crate::openhuman::tinyplace::manifest::{
handle_tinyplace_profiles_broadcasts,
handle_tinyplace_profiles_get,
handle_tinyplace_profiles_groups,
+ handle_tinyplace_registry_assign_primary,
handle_tinyplace_registry_export,
handle_tinyplace_registry_get,
handle_tinyplace_registry_register,
@@ -1424,6 +1425,23 @@ fn schema_follows_feed() -> ControllerSchema {
// ── Registry export schema ─────────────────────────────────────────────────────
+fn schema_registry_assign_primary() -> ControllerSchema {
+ ControllerSchema {
+ namespace: "tinyplace",
+ function: "registry_assign_primary",
+ description: "Assign one of the wallet's handles as its primary (active) identity; \
+ clears the primary flag on the wallet's other handles.",
+ inputs: vec![required_string(
+ "name",
+ "The handle to make primary/active (with or without a leading @).",
+ )],
+ outputs: vec![json_output(
+ "identity",
+ "The updated Identity with primary=true.",
+ )],
+ }
+}
+
fn schema_registry_export() -> ControllerSchema {
ControllerSchema {
namespace: "tinyplace",
@@ -2584,6 +2602,7 @@ pub fn all_tinyplace_controller_schemas() -> Vec {
schema_marketplace_recent(),
schema_registry_get(),
schema_registry_register(),
+ schema_registry_assign_primary(),
schema_marketplace_buy_product(),
schema_marketplace_buy_identity(),
schema_marketplace_bid(),
@@ -2823,6 +2842,10 @@ pub fn all_tinyplace_registered_controllers() -> Vec {
schema: schema_registry_register(),
handler: handle_tinyplace_registry_register,
},
+ RegisteredController {
+ schema: schema_registry_assign_primary(),
+ handler: handle_tinyplace_registry_assign_primary,
+ },
RegisteredController {
schema: schema_marketplace_buy_product(),
handler: handle_tinyplace_marketplace_buy_product,