fix(tinyplace): wire handle transfer end-to-end (Closes #4929) (#4998)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Steven Enamakel <31011319+senamakel@users.noreply.github.com>
Co-authored-by: Cyrus Gray <144336577+graycyrus@users.noreply.github.com>
Co-authored-by: oxoxDev <164490987+oxoxDev@users.noreply.github.com>
Co-authored-by: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
Co-authored-by: CodeGhost21 <164498022+CodeGhost21@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: sanil-23 <sanil@tinyhumans.ai>
Co-authored-by: M3gA-Mind <elvin@mahadao.com>
Co-authored-by: oxoxDev <oxoxdev@users.noreply.github.com>
This commit is contained in:
Mega Mind
2026-07-22 05:03:45 +03:00
committed by GitHub
co-authored by github-actions[bot] <github-actions[bot]@users.noreply.github.com> Steven Enamakel Cyrus Gray oxoxDev YellowSnnowmann Steven Enamakel CodeGhost21 Claude Opus 4.8 sanil-23 M3gA-Mind oxoxDev
parent ddeee5b5d6
commit 06be00c75e
21 changed files with 1048 additions and 2 deletions
@@ -0,0 +1,95 @@
/**
* Tests for TransferHandleModal (GH-4929) — the confirm + execute dialog for a
* Tiny Place handle transfer. A transfer is destructive/irreversible, so these
* assert the wiring to `apiClient.registry.transfer`, that confirm is gated on a
* recipient, and that the flow fails CLOSED (error keeps the dialog open and
* never reports success).
*
* All handles/recipients are generic placeholders, never real identities.
*/
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { renderWithProviders } from '../../test/test-utils';
import { apiClient } from '../AgentWorldShell';
import TransferHandleModal from './TransferHandleModal';
vi.mock('../AgentWorldShell', () => ({ apiClient: { registry: { transfer: vi.fn() } } }));
const transfer = vi.mocked(apiClient.registry.transfer);
beforeEach(() => {
vi.clearAllMocks();
});
function setup() {
const onClose = vi.fn();
const onTransferred = vi.fn();
renderWithProviders(
<TransferHandleModal handle="alpha" onClose={onClose} onTransferred={onTransferred} />
);
return { onClose, onTransferred };
}
describe('TransferHandleModal', () => {
test('shows the handle + irreversible warning and gates confirm on a recipient', () => {
setup();
expect(screen.getByTestId('transfer-handle-modal')).toBeInTheDocument();
expect(screen.getByText('@alpha')).toBeInTheDocument();
expect(screen.getByText(/permanent and cannot be undone/i)).toBeInTheDocument();
// Confirm is disabled until a recipient is entered.
expect(screen.getByTestId('transfer-handle-confirm')).toBeDisabled();
});
test('keeps confirm disabled until the exact handle is re-typed', async () => {
const user = userEvent.setup();
setup();
const confirmBtn = screen.getByTestId('transfer-handle-confirm');
// A recipient alone is not enough for a destructive action.
await user.type(screen.getByPlaceholderText(/Recipient @handle/i), 'bravo');
expect(confirmBtn).toBeDisabled();
// A wrong handle keeps it disabled.
await user.type(screen.getByTestId('transfer-handle-confirm-input'), 'wrong');
expect(confirmBtn).toBeDisabled();
// The exact handle (case- and @-insensitive) enables it.
await user.clear(screen.getByTestId('transfer-handle-confirm-input'));
await user.type(screen.getByTestId('transfer-handle-confirm-input'), '@ALPHA');
expect(confirmBtn).toBeEnabled();
});
test('confirming transfers to the resolved recipient, then closes on success', async () => {
const user = userEvent.setup();
transfer.mockResolvedValueOnce({ identity: { username: 'alpha' } as never });
const { onClose, onTransferred } = setup();
await user.type(screen.getByPlaceholderText(/Recipient @handle/i), '@bravo');
// The irreversible action is gated behind re-typing the handle.
await user.type(screen.getByTestId('transfer-handle-confirm-input'), '@alpha');
await user.click(screen.getByTestId('transfer-handle-confirm'));
// Leading @ is stripped before the RPC; handle passed through verbatim.
await waitFor(() => expect(transfer).toHaveBeenCalledWith('alpha', 'bravo'));
await waitFor(() => expect(onTransferred).toHaveBeenCalledTimes(1));
expect(onClose).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId('transfer-handle-error')).not.toBeInTheDocument();
});
test('fails closed: on error it shows the message and does not report success', async () => {
const user = userEvent.setup();
transfer.mockRejectedValueOnce(new Error('recipient handle is not registered on tiny.place'));
const { onClose, onTransferred } = setup();
await user.type(screen.getByPlaceholderText(/Recipient @handle/i), 'bravo');
await user.type(screen.getByTestId('transfer-handle-confirm-input'), 'alpha');
await user.click(screen.getByTestId('transfer-handle-confirm'));
await waitFor(() =>
expect(screen.getByTestId('transfer-handle-error')).toHaveTextContent(/not registered/i)
);
// Fail closed: no success callbacks, dialog stays open.
expect(onTransferred).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByTestId('transfer-handle-modal')).toBeInTheDocument();
});
});
@@ -0,0 +1,155 @@
/**
* TransferHandleModal — confirm + execute a Tiny Place handle transfer (GH-4929).
*
* A handle transfer is DESTRUCTIVE and irreversible for the sender: on success
* the recipient becomes the handle's sole owner. So this modal states that
* plainly, requires an explicit recipient, requires the user to re-type the
* handle to confirm intent, and takes an explicit confirm click — and it fails
* **closed**: on any error it keeps the dialog open with the message and never
* reports success. The core handler resolves the recipient @handle and
* read-back-confirms the new owner before this promise resolves, so a resolved
* transfer means the reassignment actually landed.
*/
import debugFactory from 'debug';
import { useCallback, useState } from 'react';
import Button from '../../components/ui/Button';
import { ModalShell } from '../../components/ui/ModalShell';
import { useT } from '../../lib/i18n/I18nContext';
import { apiClient } from '../AgentWorldShell';
// Namespaced already ('agentworld:identity'), so messages carry no prefix.
const debug = debugFactory('agentworld:identity');
export interface TransferHandleModalProps {
/** The handle being transferred away (without a leading @). */
handle: string;
onClose: () => void;
/** Called after a confirmed, read-back-verified transfer. */
onTransferred: () => void;
}
/** Normalize a handle for comparison: strip leading @, trim, lowercase. */
function normalizeHandle(value: string): string {
return value.trim().replace(/^@+/, '').toLowerCase();
}
export default function TransferHandleModal({
handle,
onClose,
onTransferred,
}: TransferHandleModalProps) {
const { t } = useT();
const [recipient, setRecipient] = useState('');
const [confirmText, setConfirmText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleClean = handle.replace(/^@+/, '');
// Guard the irreversible action: the user must re-type the exact handle.
const confirmMatches = normalizeHandle(confirmText) === normalizeHandle(handleClean);
const submit = useCallback(async () => {
const target = recipient.trim().replace(/^@+/, '');
if (!target) {
setError(t('agentWorld.transferHandle.recipientRequired'));
return;
}
// Belt-and-suspenders: the button is disabled without a match, but never
// execute a destructive transfer unless the typed confirmation matches.
if (!confirmMatches) {
setError(t('agentWorld.transferHandle.confirmMismatch'));
return;
}
setSubmitting(true);
setError(null);
// Never log the handle or recipient — both identify a user.
debug('handle transfer requested');
try {
// Send the normalized handle (not the raw prop) so the invariant is local
// and doesn't rest on every caller pre-cleaning the value (#4998 review).
await apiClient.registry.transfer(handleClean, target);
debug('handle transfer confirmed');
onTransferred();
onClose();
} catch (err) {
// Fail closed: keep the dialog open, show why, report no success.
// Log only the status (no raw error — it can carry backend/SDK detail);
// the raw message still surfaces in the UI via setError.
debug('handle transfer failed');
setError(String(err));
setSubmitting(false);
}
}, [recipient, confirmMatches, handleClean, t, onTransferred, onClose]);
return (
<ModalShell
title={t('agentWorld.transferHandle.title')}
titleId="agentworld-transfer-handle-title"
maxWidthClassName="max-w-sm"
onClose={submitting ? () => undefined : onClose}>
<div className="space-y-4" data-testid="transfer-handle-modal">
<p className="text-sm text-content">@{handleClean}</p>
<p className="text-xs text-red-600 dark:text-red-400">
{t('agentWorld.transferHandle.warning')}
</p>
<input
type="text"
value={recipient}
onChange={e => {
setRecipient(e.target.value);
setError(null);
}}
disabled={submitting}
placeholder={t('agentWorld.transferHandle.recipientPlaceholder')}
aria-label={t('agentWorld.transferHandle.recipientPlaceholder')}
className="w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-content placeholder-content-faint outline-none focus:border-primary-500"
/>
{/* Type-to-confirm guard for the irreversible action. */}
<div className="space-y-1">
<p className="text-xs text-content-muted">
{t('agentWorld.transferHandle.confirmLabel')}
</p>
<input
type="text"
value={confirmText}
onChange={e => {
setConfirmText(e.target.value);
setError(null);
}}
disabled={submitting}
placeholder={`@${handleClean}`}
aria-label={t('agentWorld.transferHandle.confirmLabel')}
data-testid="transfer-handle-confirm-input"
className="w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-content placeholder-content-faint outline-none focus:border-primary-500"
/>
</div>
{error && (
<p className="text-xs text-red-600 dark:text-red-400" data-testid="transfer-handle-error">
{error}
</p>
)}
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={onClose} disabled={submitting}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
size="sm"
tone="danger"
onClick={() => void submit()}
disabled={submitting || !recipient.trim() || !confirmMatches}
data-testid="transfer-handle-confirm">
{submitting
? t('agentWorld.transferHandle.submitting')
: t('agentWorld.transferHandle.confirm')}
</Button>
</div>
</div>
</ModalShell>
);
}
@@ -19,7 +19,7 @@ vi.mock('../AgentWorldShell', () => ({
apiClient: {
directory: { reverse: vi.fn() },
follows: { stats: vi.fn() },
registry: { export: vi.fn(), assignPrimary: vi.fn() },
registry: { export: vi.fn(), assignPrimary: vi.fn(), transfer: vi.fn() },
graphql: { user: vi.fn() },
users: { get: vi.fn(), updateProfile: vi.fn() },
},
@@ -362,6 +362,74 @@ function makeProfile(overrides: Partial<GqlProfile> = {}): GqlProfile {
};
}
// GH-4929: a non-primary owned handle offers an enabled Transfer action that
// opens the destructive-confirm modal. #4998 M3: a primary (active) handle now
// renders a DISABLED Transfer control with an explanation (make another handle
// active first) instead of silently omitting it. A handle with no explicit
// primary flag (unknown state) shows no Transfer control at all.
describe('handle transfer action', () => {
test('opens the transfer confirm modal for a non-primary owned handle', async () => {
graphqlUser.mockResolvedValueOnce(
makeProfile({
displayName: 'Owner',
identities: [
{ ...minimalIdentity, username: 'primaryhandle', cryptoId: SOLANA_ADDR, primary: true },
{ ...minimalIdentity, username: 'giftme', cryptoId: SOLANA_ADDR, primary: false },
],
})
);
render(<ProfilesSection />);
// Both handles render a Transfer control (#4998 M3): the non-primary one is
// enabled, the primary one disabled. Pick the enabled one — it opens the modal.
const transferButtons = await screen.findAllByRole('button', { name: 'Transfer' });
const enabled = transferButtons.find(b => !(b as HTMLButtonElement).disabled);
expect(enabled, 'the non-primary handle must expose an enabled Transfer control').toBeDefined();
fireEvent.click(enabled!);
// The destructive-confirm modal opens with its explicit confirm control.
expect(await screen.findByTestId('transfer-handle-modal')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Transfer handle' })).toBeInTheDocument();
});
test('shows a disabled Transfer control with an explanation for a primary handle (#4998 M3)', async () => {
graphqlUser.mockResolvedValueOnce(
makeProfile({
displayName: 'Owner',
identities: [
{ ...minimalIdentity, username: 'onlyprimary', cryptoId: SOLANA_ADDR, primary: true },
],
})
);
render(<ProfilesSection />);
// Rendered, but disabled and explained — not an invisible dead end.
const transferButton = await screen.findByRole('button', { name: 'Transfer' });
expect(transferButton).toBeDisabled();
expect(transferButton).toHaveAttribute(
'title',
'A primary handle cannot be transferred. Make another handle active first.'
);
// Clicking the locked control must NOT open the destructive modal.
fireEvent.click(transferButton);
expect(screen.queryByTestId('transfer-handle-modal')).not.toBeInTheDocument();
});
test('shows no Transfer control when the primary flag is absent (unknown state)', async () => {
graphqlUser.mockResolvedValueOnce(
makeProfile({
displayName: 'Owner',
identities: [{ ...minimalIdentity, username: 'unknownstate', cryptoId: SOLANA_ADDR }],
})
);
render(<ProfilesSection />);
expect(await screen.findByRole('button', { name: 'Make active' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Transfer' })).not.toBeInTheDocument();
});
});
describe('graphql-enriched profile card', () => {
test('renders rich profile from graphql.user when available', async () => {
graphqlUser.mockResolvedValueOnce(
@@ -22,6 +22,7 @@ import {
import { useT } from '../../lib/i18n/I18nContext';
import { fetchWalletStatus } from '../../services/walletApi';
import { apiClient } from '../AgentWorldShell';
import TransferHandleModal from '../components/TransferHandleModal';
const log = debug('agentworld:profile');
@@ -348,6 +349,8 @@ function AgentProfileCard({ data, onSwitched }: { data: ProfileData; onSwitched?
// Handle currently being promoted to primary (in-flight), and any error.
const [switchingHandle, setSwitchingHandle] = useState<string | null>(null);
const [switchError, setSwitchError] = useState<string | null>(null);
// The owned handle whose transfer modal is open (without @), or null.
const [transferHandle, setTransferHandle] = useState<string | null>(null);
// ── Extract display fields from either data source ─────────────────────────
const isGraphql = data.source === 'graphql';
@@ -568,6 +571,29 @@ function AgentProfileCard({ data, onSwitched }: { data: ProfileData; onSwitched?
: 'Make active'}
</Button>
)}
{/* Transfer is destructive/irreversible — the modal confirms
intent and fails closed. A primary (active) handle is
locked from sale/transfer server-side, so it renders
disabled with an explanation of the path out (make another
handle active first) rather than silently vanishing (#4998
M3). Only an explicit non-primary handle is transferable. */}
{id.primary === false && (
<Button
variant="secondary"
size="sm"
onClick={() => setTransferHandle(id.username.replace(/^@+/, ''))}>
{t('agentWorld.transferHandle.action')}
</Button>
)}
{id.primary === true && (
<Button
variant="secondary"
size="sm"
disabled
title={t('agentWorld.transferHandle.primaryLocked')}>
{t('agentWorld.transferHandle.action')}
</Button>
)}
<span className="text-[10px] uppercase tracking-wide text-content-faint">
{id.status}
</span>
@@ -581,6 +607,14 @@ function AgentProfileCard({ data, onSwitched }: { data: ProfileData; onSwitched?
</div>
)}
{transferHandle && (
<TransferHandleModal
handle={transferHandle}
onClose={() => setTransferHandle(null)}
onTransferred={() => onSwitched?.()}
/>
)}
{followStats && (
<div className="mt-4 border-t border-line pt-4">
<div className="flex gap-6">
+16
View File
@@ -336,6 +336,12 @@ export interface AssignPrimaryResult {
[key: string]: unknown;
}
/** Result of a handle transfer: the Identity now owned by the recipient. */
export interface TransferHandleResult {
identity?: Identity;
[key: string]: unknown;
}
// -- Registry export types ------------------------------------------------
export interface LedgerReference {
@@ -1863,6 +1869,16 @@ export function createInvokeApiClient() {
*/
assignPrimary: (name: string) =>
call<AssignPrimaryResult>('openhuman.tinyplace_registry_assign_primary', { name }),
/**
* Transfer one of the wallet's handles to another tiny.place identity
* (#4929). DESTRUCTIVE + irreversible: on success the `recipient` handle's
* owner becomes the sole owner of `name`. The recipient is resolved from
* their @handle server-side; an unregistered recipient or an unconfirmed
* read-back fails closed. The owning wallet is proven by the
* signer-attached signature, not by params.
*/
transfer: (name: string, recipient: string) =>
call<TransferHandleResult>('openhuman.tinyplace_registry_transfer', { name, recipient }),
},
directoryIdentities: {
/** List identity listings from the directory. */
+12
View File
@@ -498,6 +498,18 @@ const messages: TranslationMap = {
'agentWorld.directory.profile.loadError': 'تعذّر تحميل الملف الشخصي الكامل.',
'agentWorld.identities': 'الهويات',
'agentWorld.profiles': 'الملفات الشخصية',
'agentWorld.transferHandle.action': 'نقل',
'agentWorld.transferHandle.title': 'نقل المعرّف',
'agentWorld.transferHandle.warning':
'نقل المعرّف نهائي ولا يمكن التراجع عنه. يصبح المستلم مالكه الوحيد.',
'agentWorld.transferHandle.recipientPlaceholder': 'معرّف@ المستلم',
'agentWorld.transferHandle.confirm': 'نقل المعرّف',
'agentWorld.transferHandle.submitting': 'جارٍ النقل…',
'agentWorld.transferHandle.recipientRequired': 'أدخل معرّف المستلم.',
'agentWorld.transferHandle.confirmLabel': 'اكتب المعرّف للتأكيد',
'agentWorld.transferHandle.confirmMismatch': 'المعرّف المكتوب غير مطابق.',
'agentWorld.transferHandle.primaryLocked':
'لا يمكن نقل المعرّف الأساسي. اجعل معرّفًا آخر نشطًا أولًا.',
'agentWorld.profile.edit': 'تعديل الملف الشخصي',
'agentWorld.profile.displayName': 'الاسم المعروض',
'agentWorld.profile.bio': 'نبذة',
+13 -1
View File
@@ -487,7 +487,7 @@ const messages: TranslationMap = {
'agentWorld.profileViewer.notFoundTitle': 'প্রোফাইল পাওয়া যায়নি',
'agentWorld.profileViewer.notFoundBody': 'এই হ্যান্ডেলের জন্য এখনও কোনো প্রকাশিত প্রোফাইল নেই।',
'agentWorld.profileViewer.errorTitle': 'প্রোফাইল লোড করা যায়নি',
'agentWorld.profileViewer.follow': 'অনুসরণ',
'agentWorld.profileViewer.follow': 'অনুসরণ করুন',
'agentWorld.profileViewer.following': 'অনুসরণ করছেন',
'agentWorld.profileViewer.copyLink': 'লিঙ্ক কপি করুন',
'agentWorld.profileViewer.linkCopied': 'লিঙ্ক কপি হয়েছে',
@@ -514,6 +514,18 @@ const messages: TranslationMap = {
'agentWorld.directory.profile.loadError': 'সম্পূর্ণ প্রোফাইল লোড করা যায়নি।',
'agentWorld.identities': 'পরিচয়',
'agentWorld.profiles': 'প্রোফাইল',
'agentWorld.transferHandle.action': 'স্থানান্তর',
'agentWorld.transferHandle.title': 'হ্যান্ডেল স্থানান্তর',
'agentWorld.transferHandle.warning':
'হ্যান্ডেল স্থানান্তর স্থায়ী এবং এটি ফেরানো যায় না। প্রাপক একমাত্র মালিক হয়ে যাবেন।',
'agentWorld.transferHandle.recipientPlaceholder': 'প্রাপকের @handle',
'agentWorld.transferHandle.confirm': 'হ্যান্ডেল স্থানান্তর',
'agentWorld.transferHandle.submitting': 'স্থানান্তর করা হচ্ছে…',
'agentWorld.transferHandle.recipientRequired': 'প্রাপকের হ্যান্ডেল লিখুন।',
'agentWorld.transferHandle.confirmLabel': 'নিশ্চিত করতে হ্যান্ডেলটি টাইপ করুন',
'agentWorld.transferHandle.confirmMismatch': 'টাইপ করা হ্যান্ডেল মিলছে না।',
'agentWorld.transferHandle.primaryLocked':
'প্রাথমিক হ্যান্ডেল স্থানান্তর করা যায় না। প্রথমে অন্য একটি হ্যান্ডেল সক্রিয় করুন।',
'agentWorld.profile.edit': 'প্রোফাইল সম্পাদনা',
'agentWorld.profile.displayName': 'প্রদর্শন নাম',
'agentWorld.profile.bio': 'পরিচিতি',
+12
View File
@@ -541,6 +541,18 @@ const messages: TranslationMap = {
'agentWorld.directory.profile.loadError': 'Vollständiges Profil konnte nicht geladen werden.',
'agentWorld.identities': 'Identitäten',
'agentWorld.profiles': 'Profile',
'agentWorld.transferHandle.action': 'Übertragen',
'agentWorld.transferHandle.title': 'Handle übertragen',
'agentWorld.transferHandle.warning':
'Das Übertragen eines Handles ist dauerhaft und kann nicht rückgängig gemacht werden. Der Empfänger wird alleiniger Eigentümer.',
'agentWorld.transferHandle.recipientPlaceholder': 'Empfänger-@handle',
'agentWorld.transferHandle.confirm': 'Handle übertragen',
'agentWorld.transferHandle.submitting': 'Wird übertragen…',
'agentWorld.transferHandle.recipientRequired': 'Gib den Empfänger-Handle ein.',
'agentWorld.transferHandle.confirmLabel': 'Zum Bestätigen den Handle eingeben',
'agentWorld.transferHandle.confirmMismatch': 'Der eingegebene Handle stimmt nicht überein.',
'agentWorld.transferHandle.primaryLocked':
'Ein primäres Handle kann nicht übertragen werden. Mache zuerst ein anderes Handle aktiv.',
'agentWorld.profile.edit': 'Profil bearbeiten',
'agentWorld.profile.displayName': 'Anzeigename',
'agentWorld.profile.bio': 'Bio',
+12
View File
@@ -221,6 +221,18 @@ const en: TranslationMap = {
'agentWorld.directory.profile.loadError': "Couldn't load the full profile.",
'agentWorld.identities': 'Identities',
'agentWorld.profiles': 'Profiles',
'agentWorld.transferHandle.action': 'Transfer',
'agentWorld.transferHandle.title': 'Transfer handle',
'agentWorld.transferHandle.warning':
'Transferring a handle is permanent and cannot be undone. The recipient becomes its sole owner.',
'agentWorld.transferHandle.recipientPlaceholder': 'Recipient @handle',
'agentWorld.transferHandle.confirm': 'Transfer handle',
'agentWorld.transferHandle.submitting': 'Transferring…',
'agentWorld.transferHandle.recipientRequired': 'Enter the recipient handle.',
'agentWorld.transferHandle.confirmLabel': 'Type the handle to confirm',
'agentWorld.transferHandle.confirmMismatch': "The typed handle doesn't match.",
'agentWorld.transferHandle.primaryLocked':
'A primary handle cannot be transferred. Make another handle active first.',
'agentWorld.profile.edit': 'Edit profile',
'agentWorld.profile.displayName': 'Display name',
'agentWorld.profile.bio': 'Bio',
+12
View File
@@ -525,6 +525,18 @@ const messages: TranslationMap = {
'agentWorld.directory.profile.loadError': 'No se pudo cargar el perfil completo.',
'agentWorld.identities': 'Identidades',
'agentWorld.profiles': 'Perfiles',
'agentWorld.transferHandle.action': 'Transferir',
'agentWorld.transferHandle.title': 'Transferir handle',
'agentWorld.transferHandle.warning':
'Transferir un handle es permanente y no se puede deshacer. El destinatario se convierte en su único propietario.',
'agentWorld.transferHandle.recipientPlaceholder': '@handle del destinatario',
'agentWorld.transferHandle.confirm': 'Transferir handle',
'agentWorld.transferHandle.submitting': 'Transfiriendo…',
'agentWorld.transferHandle.recipientRequired': 'Introduce el handle del destinatario.',
'agentWorld.transferHandle.confirmLabel': 'Escribe el handle para confirmar',
'agentWorld.transferHandle.confirmMismatch': 'El handle escrito no coincide.',
'agentWorld.transferHandle.primaryLocked':
'Un identificador principal no se puede transferir. Activa primero otro identificador.',
'agentWorld.profile.edit': 'Editar perfil',
'agentWorld.profile.displayName': 'Nombre visible',
'agentWorld.profile.bio': 'Biografía',
+12
View File
@@ -534,6 +534,18 @@ const messages: TranslationMap = {
'agentWorld.directory.profile.loadError': 'Impossible de charger le profil complet.',
'agentWorld.identities': 'Identités',
'agentWorld.profiles': 'Profils',
'agentWorld.transferHandle.action': 'Transférer',
'agentWorld.transferHandle.title': 'Transférer le handle',
'agentWorld.transferHandle.warning':
"Le transfert d'un handle est définitif et irréversible. Le destinataire en devient le seul propriétaire.",
'agentWorld.transferHandle.recipientPlaceholder': '@handle du destinataire',
'agentWorld.transferHandle.confirm': 'Transférer le handle',
'agentWorld.transferHandle.submitting': 'Transfert…',
'agentWorld.transferHandle.recipientRequired': 'Saisissez le handle du destinataire.',
'agentWorld.transferHandle.confirmLabel': 'Saisissez le handle pour confirmer',
'agentWorld.transferHandle.confirmMismatch': 'Le handle saisi ne correspond pas.',
'agentWorld.transferHandle.primaryLocked':
"Un identifiant principal ne peut pas être transféré. Activez d'abord un autre identifiant.",
'agentWorld.profile.edit': 'Modifier le profil',
'agentWorld.profile.displayName': 'Nom affiché',
'agentWorld.profile.bio': 'Bio',
+12
View File
@@ -514,6 +514,18 @@ const messages: TranslationMap = {
'agentWorld.directory.profile.loadError': 'पूरा प्रोफ़ाइल लोड नहीं हो सका।',
'agentWorld.identities': 'पहचान',
'agentWorld.profiles': 'प्रोफ़ाइल',
'agentWorld.transferHandle.action': 'स्थानांतरित करें',
'agentWorld.transferHandle.title': 'हैंडल स्थानांतरित करें',
'agentWorld.transferHandle.warning':
'हैंडल स्थानांतरण स्थायी है और इसे पूर्ववत नहीं किया जा सकता। प्राप्तकर्ता इसका एकमात्र स्वामी बन जाता है।',
'agentWorld.transferHandle.recipientPlaceholder': 'प्राप्तकर्ता का @handle',
'agentWorld.transferHandle.confirm': 'हैंडल स्थानांतरित करें',
'agentWorld.transferHandle.submitting': 'स्थानांतरित किया जा रहा है…',
'agentWorld.transferHandle.recipientRequired': 'प्राप्तकर्ता का हैंडल दर्ज करें।',
'agentWorld.transferHandle.confirmLabel': 'पुष्टि के लिए हैंडल टाइप करें',
'agentWorld.transferHandle.confirmMismatch': 'टाइप किया गया हैंडल मेल नहीं खाता।',
'agentWorld.transferHandle.primaryLocked':
'प्राथमिक हैंडल स्थानांतरित नहीं किया जा सकता। पहले कोई अन्य हैंडल सक्रिय करें।',
'agentWorld.profile.edit': 'प्रोफ़ाइल संपादित करें',
'agentWorld.profile.displayName': 'प्रदर्शित नाम',
'agentWorld.profile.bio': 'परिचय',
+12
View File
@@ -520,6 +520,18 @@ const messages: TranslationMap = {
'agentWorld.directory.profile.loadError': 'Tidak dapat memuat profil lengkap.',
'agentWorld.identities': 'Identitas',
'agentWorld.profiles': 'Profil',
'agentWorld.transferHandle.action': 'Pindahkan',
'agentWorld.transferHandle.title': 'Pindahkan handle',
'agentWorld.transferHandle.warning':
'Memindahkan handle bersifat permanen dan tidak dapat dibatalkan. Penerima menjadi satu-satunya pemilik.',
'agentWorld.transferHandle.recipientPlaceholder': '@handle penerima',
'agentWorld.transferHandle.confirm': 'Pindahkan handle',
'agentWorld.transferHandle.submitting': 'Memindahkan…',
'agentWorld.transferHandle.recipientRequired': 'Masukkan handle penerima.',
'agentWorld.transferHandle.confirmLabel': 'Ketik handle untuk mengonfirmasi',
'agentWorld.transferHandle.confirmMismatch': 'Handle yang diketik tidak cocok.',
'agentWorld.transferHandle.primaryLocked':
'Handle utama tidak dapat ditransfer. Aktifkan handle lain terlebih dahulu.',
'agentWorld.profile.edit': 'Edit profil',
'agentWorld.profile.displayName': 'Nama tampilan',
'agentWorld.profile.bio': 'Bio',
+12
View File
@@ -528,6 +528,18 @@ const messages: TranslationMap = {
'agentWorld.directory.profile.loadError': 'Impossibile caricare il profilo completo.',
'agentWorld.identities': 'Identità',
'agentWorld.profiles': 'Profili',
'agentWorld.transferHandle.action': 'Trasferisci',
'agentWorld.transferHandle.title': 'Trasferisci handle',
'agentWorld.transferHandle.warning':
"Il trasferimento di un handle è permanente e non può essere annullato. Il destinatario ne diventa l'unico proprietario.",
'agentWorld.transferHandle.recipientPlaceholder': '@handle del destinatario',
'agentWorld.transferHandle.confirm': 'Trasferisci handle',
'agentWorld.transferHandle.submitting': 'Trasferimento…',
'agentWorld.transferHandle.recipientRequired': "Inserisci l'handle del destinatario.",
'agentWorld.transferHandle.confirmLabel': "Digita l'handle per confermare",
'agentWorld.transferHandle.confirmMismatch': "L'handle digitato non corrisponde.",
'agentWorld.transferHandle.primaryLocked':
'Un handle primario non può essere trasferito. Rendi prima attivo un altro handle.',
'agentWorld.profile.edit': 'Modifica profilo',
'agentWorld.profile.displayName': 'Nome visualizzato',
'agentWorld.profile.bio': 'Bio',
+12
View File
@@ -507,6 +507,18 @@ const messages: TranslationMap = {
'agentWorld.directory.profile.loadError': '전체 프로필을 불러오지 못했습니다.',
'agentWorld.identities': '아이덴티티',
'agentWorld.profiles': '프로필',
'agentWorld.transferHandle.action': '이전',
'agentWorld.transferHandle.title': '핸들 이전',
'agentWorld.transferHandle.warning':
'핸들 이전은 영구적이며 되돌릴 수 없습니다. 수신자가 유일한 소유자가 됩니다.',
'agentWorld.transferHandle.recipientPlaceholder': '수신자 @handle',
'agentWorld.transferHandle.confirm': '핸들 이전',
'agentWorld.transferHandle.submitting': '이전 중…',
'agentWorld.transferHandle.recipientRequired': '수신자 핸들을 입력하세요.',
'agentWorld.transferHandle.confirmLabel': '확인하려면 핸들을 입력하세요',
'agentWorld.transferHandle.confirmMismatch': '입력한 핸들이 일치하지 않습니다.',
'agentWorld.transferHandle.primaryLocked':
'기본 핸들은 이전할 수 없습니다. 먼저 다른 핸들을 활성화하세요.',
'agentWorld.profile.edit': '프로필 편집',
'agentWorld.profile.displayName': '표시 이름',
'agentWorld.profile.bio': '소개',
+12
View File
@@ -527,6 +527,18 @@ const messages: TranslationMap = {
'agentWorld.directory.profile.loadError': 'Nie udało się załadować pełnego profilu.',
'agentWorld.identities': 'Tożsamości',
'agentWorld.profiles': 'Profile',
'agentWorld.transferHandle.action': 'Przekaż',
'agentWorld.transferHandle.title': 'Przekaż handle',
'agentWorld.transferHandle.warning':
"Przekazanie handle'a jest trwałe i nie można go cofnąć. Odbiorca staje się jego jedynym właścicielem.",
'agentWorld.transferHandle.recipientPlaceholder': '@handle odbiorcy',
'agentWorld.transferHandle.confirm': 'Przekaż handle',
'agentWorld.transferHandle.submitting': 'Przekazywanie…',
'agentWorld.transferHandle.recipientRequired': 'Podaj handle odbiorcy.',
'agentWorld.transferHandle.confirmLabel': 'Wpisz handle, aby potwierdzić',
'agentWorld.transferHandle.confirmMismatch': 'Wpisany handle nie pasuje.',
'agentWorld.transferHandle.primaryLocked':
"Podstawowego handle'a nie można przekazać. Najpierw ustaw inny handle jako aktywny.",
'agentWorld.profile.edit': 'Edytuj profil',
'agentWorld.profile.displayName': 'Wyświetlana nazwa',
'agentWorld.profile.bio': 'Bio',
+12
View File
@@ -520,6 +520,18 @@ const messages: TranslationMap = {
'agentWorld.directory.profile.loadError': 'Não foi possível carregar o perfil completo.',
'agentWorld.identities': 'Identidades',
'agentWorld.profiles': 'Perfis',
'agentWorld.transferHandle.action': 'Transferir',
'agentWorld.transferHandle.title': 'Transferir handle',
'agentWorld.transferHandle.warning':
'Transferir um handle é permanente e não pode ser desfeito. O destinatário torna-se o seu único proprietário.',
'agentWorld.transferHandle.recipientPlaceholder': '@handle do destinatário',
'agentWorld.transferHandle.confirm': 'Transferir handle',
'agentWorld.transferHandle.submitting': 'A transferir…',
'agentWorld.transferHandle.recipientRequired': 'Introduza o handle do destinatário.',
'agentWorld.transferHandle.confirmLabel': 'Digite o handle para confirmar',
'agentWorld.transferHandle.confirmMismatch': 'O handle digitado não corresponde.',
'agentWorld.transferHandle.primaryLocked':
'Um identificador principal não pode ser transferido. Ative outro identificador primeiro.',
'agentWorld.profile.edit': 'Editar perfil',
'agentWorld.profile.displayName': 'Nome de exibição',
'agentWorld.profile.bio': 'Bio',
+12
View File
@@ -520,6 +520,18 @@ const messages: TranslationMap = {
'agentWorld.directory.profile.loadError': 'Не удалось загрузить полный профиль.',
'agentWorld.identities': 'Идентичности',
'agentWorld.profiles': 'Профили',
'agentWorld.transferHandle.action': 'Передать',
'agentWorld.transferHandle.title': 'Передать хэндл',
'agentWorld.transferHandle.warning':
'Передача хэндла необратима и не может быть отменена. Получатель становится его единственным владельцем.',
'agentWorld.transferHandle.recipientPlaceholder': '@handle получателя',
'agentWorld.transferHandle.confirm': 'Передать хэндл',
'agentWorld.transferHandle.submitting': 'Передача…',
'agentWorld.transferHandle.recipientRequired': 'Введите хэндл получателя.',
'agentWorld.transferHandle.confirmLabel': 'Введите хэндл для подтверждения',
'agentWorld.transferHandle.confirmMismatch': 'Введённый хэндл не совпадает.',
'agentWorld.transferHandle.primaryLocked':
'Основной хэндл нельзя передать. Сначала сделайте активным другой хэндл.',
'agentWorld.profile.edit': 'Изменить профиль',
'agentWorld.profile.displayName': 'Отображаемое имя',
'agentWorld.profile.bio': 'О себе',
+10
View File
@@ -482,6 +482,16 @@ const messages: TranslationMap = {
'agentWorld.directory.profile.loadError': '无法加载完整资料。',
'agentWorld.identities': '身份',
'agentWorld.profiles': '档案',
'agentWorld.transferHandle.action': '转移',
'agentWorld.transferHandle.title': '转移句柄',
'agentWorld.transferHandle.warning': '句柄转移是永久性的,无法撤销。接收者将成为其唯一所有者。',
'agentWorld.transferHandle.recipientPlaceholder': '接收者 @handle',
'agentWorld.transferHandle.confirm': '转移句柄',
'agentWorld.transferHandle.submitting': '正在转移…',
'agentWorld.transferHandle.recipientRequired': '请输入接收者的句柄。',
'agentWorld.transferHandle.confirmLabel': '输入句柄以确认',
'agentWorld.transferHandle.confirmMismatch': '输入的句柄不匹配。',
'agentWorld.transferHandle.primaryLocked': '无法转移主用户名。请先将另一个用户名设为活跃。',
'agentWorld.profile.edit': '编辑资料',
'agentWorld.profile.displayName': '显示名称',
'agentWorld.profile.bio': '简介',
+477
View File
@@ -2496,6 +2496,249 @@ pub(crate) fn handle_tinyplace_registry_assign_primary(
})
}
fn transfer_allowed_by_primary_state(primary: Option<bool>) -> bool {
primary == Some(false)
}
/// Transfer one of the wallet's handles to another tiny.place identity
/// (gift / account move, #4929). DESTRUCTIVE and irreversible for the sender:
/// on success the recipient becomes the sole owner of `name`.
///
/// The recipient is given as a `recipient` handle (with or without a leading
/// @) — we resolve it to the recipient's `cryptoId` + `publicKey` via
/// `registry.get`, so the caller never has to know raw key material and an
/// unresolvable recipient fails **closed** before anything is signed. The
/// SDK attaches the owning wallet's signature over the transfer payload, so
/// the backend only lets a caller transfer a handle their *own* wallet owns.
///
/// Ordering (read-only preflight → sign+POST → real read-back):
/// 1. Resolve the recipient and validate it is a live registration whose
/// `available`/`status` don't flag a stale/expired record (M2, #4998).
/// 2. Read the sender's *own* current ownership. If it already reads back as
/// the recipient — a retry after a lost-but-applied POST — return the
/// existing state without re-signing (idempotency, M1, #4998), and reject a
/// self-transfer / unregistered / primary handle before signing.
/// 3. Sign + POST the transfer.
/// 4. **Read-back via `registry.get`**, NOT the POST response body. The POST has
/// already returned 2xx by this point, so a mismatch means "submitted but
/// unconfirmed", never "did not happen" — we never claim the handle wasn't
/// reassigned, because the handler cannot know that (B1, #4998).
pub(crate) fn handle_tinyplace_registry_transfer(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let name = req_str(&params, "name")?
.trim()
.trim_start_matches('@')
.to_string();
if name.is_empty() {
return Err("missing required param 'name'".to_string());
}
let recipient = req_str(&params, "recipient")?
.trim()
.trim_start_matches('@')
.to_string();
if recipient.is_empty() {
return Err("missing required param 'recipient'".to_string());
}
// Cheap reject before any network/signing: a self-transfer burns a
// signature as a no-op and would read back as trivially "confirmed".
if name.eq_ignore_ascii_case(&recipient) {
return Err("cannot transfer a handle to itself".to_string());
}
// Never log the handle or recipient — both are user-identifying.
log::debug!("{LOG_PREFIX} registry_transfer requested");
let client = global_state().client().await?;
// (1) Resolve the recipient handle to a verified cryptoId + publicKey.
// An unknown/expired/available recipient fails closed here, before we
// sign. We query with the `@`-prefixed form, matching the proven
// availability path (`useHandleAvailability` → registry.get(`@handle`)).
let availability = client
.registry
.get(&format!("@{recipient}"))
.await
.map_err(map_err)?;
let (recipient_crypto_id, recipient_public_key) =
validate_recipient_availability(&availability)?;
log::debug!("{LOG_PREFIX} registry_transfer recipient resolved");
// (2) Read the sender's own current ownership BEFORE signing. This
// hoisted, side-effect-free read lets us (a) short-circuit a retry whose
// transfer already landed, and (b) reject a not-registered / primary
// handle with the right diagnosis — separately from the nonexistent case.
let own = client
.registry
.get(&format!("@{name}"))
.await
.map_err(map_err)?;
match preflight_own_handle(&own, &recipient_crypto_id) {
OwnHandlePreflight::AlreadyTransferred => {
// Idempotency (M1): the handle already reads back as the
// recipient — a prior POST landed but its response was lost. Do
// NOT re-sign; report the existing state.
log::debug!("{LOG_PREFIX} registry_transfer already applied; idempotent no-op");
return to_value(serde_json::json!({
"identity": own.identity,
"alreadyTransferred": true,
}));
}
OwnHandlePreflight::Reject(message) => {
log::warn!("{LOG_PREFIX} registry_transfer rejected in preflight");
return Err(message);
}
OwnHandlePreflight::Proceed => {}
}
// (3) Sign + POST the transfer.
let request = tinyplace::types::IdentityTransferRequest {
crypto_id: recipient_crypto_id.clone(),
public_key: recipient_public_key,
..Default::default()
};
client
.registry
.transfer(&name, request)
.await
.map_err(map_err)?;
// (4) Real read-back: re-query the registry rather than trusting the POST
// response body (`Identity::crypto_id` is `#[serde(default)]`, so an
// enveloped/renamed/partial body deserializes to "" and would falsely
// fail a transfer that DID land — B1). The transfer is already submitted,
// so an unconfirmed read-back is "submitted but unconfirmed", NOT "did
// not happen": we must never tell the user they still own it.
let readback_identity = client
.registry
.get(&format!("@{name}"))
.await
.ok()
.and_then(|r| r.identity);
let confirmed_owner = readback_identity
.as_ref()
.map(|i| i.crypto_id.trim().to_string())
.unwrap_or_default();
match classify_transfer_readback(&confirmed_owner, &recipient_crypto_id) {
TransferReadback::Confirmed => {
log::debug!("{LOG_PREFIX} registry_transfer confirmed by read-back");
// Return the identity from the SAME read-back we confirmed against
// (no redundant second GET — the confirmed owner is already here).
to_value(serde_json::json!({ "identity": readback_identity, "confirmed": true }))
}
TransferReadback::Unconfirmed => {
log::warn!("{LOG_PREFIX} registry_transfer submitted but unconfirmed by read-back");
Err(
"handle transfer was submitted but could not be confirmed. Do not retry \
until you have checked the handle's current owner."
.to_string(),
)
}
}
})
}
/// Validate a recipient's availability lookup before a transfer (#4998, M2).
///
/// Returns the recipient's `(cryptoId, publicKey)` when it is a live
/// registration, or an error when the record is stale/expired/incomplete — so
/// an irreversible transfer never targets a wallet the recipient may no longer
/// control at that `@handle`. Pure, so the policy is unit-testable without a
/// live registry.
fn validate_recipient_availability(
availability: &tinyplace::types::AvailabilityResponse,
) -> Result<(String, String), String> {
// A registered handle reads back as NOT available. `available == true` means
// the name is currently free (never registered, or expired-and-released),
// so any attached `identity` is stale — refuse rather than transfer to it.
if availability.available {
return Err(
"recipient handle is not currently registered; refusing to transfer".to_string(),
);
}
let identity = availability
.identity
.as_ref()
.ok_or("recipient handle is not registered on tiny.place")?;
// An expired identity carries a stale owner record even before the name is
// released back to `available` — never send an irreversible transfer to it.
if identity.status.trim().eq_ignore_ascii_case("expired") {
return Err("recipient handle has expired; refusing to transfer".to_string());
}
let crypto_id = identity.crypto_id.trim().to_string();
let public_key = identity.public_key.trim().to_string();
if crypto_id.is_empty() || public_key.is_empty() {
return Err(
"recipient identity is missing key material; cannot transfer safely".to_string(),
);
}
Ok((crypto_id, public_key))
}
/// Outcome of the pre-sign check on the sender's own handle (#4998, M1/M2).
#[derive(Debug, PartialEq, Eq)]
enum OwnHandlePreflight {
/// The handle already reads back as owned by the recipient — a retry after a
/// lost-but-applied POST. Return the existing state; do NOT re-sign.
AlreadyTransferred,
/// Safe to proceed to signing + POST.
Proceed,
/// Reject before signing with this message.
Reject(String),
}
/// Decide whether to proceed with, short-circuit, or reject a transfer based on
/// the sender's own current ownership. Pure, so unit-testable without a client.
fn preflight_own_handle(
own: &tinyplace::types::AvailabilityResponse,
recipient_crypto_id: &str,
) -> OwnHandlePreflight {
let Some(identity) = own.identity.as_ref() else {
// Not registered at all — a distinct diagnosis from the primary case, so
// a nonexistent handle doesn't get "mark it non-primary" (M2 tail).
return OwnHandlePreflight::Reject(
"this handle is not registered on tiny.place".to_string(),
);
};
// Idempotency (M1): already owned by the recipient — a prior transfer landed.
if identity.crypto_id.trim() == recipient_crypto_id {
return OwnHandlePreflight::AlreadyTransferred;
}
// A primary (active) handle is locked from transfer server-side; mirror that
// client-side so a direct JSON-RPC caller can't bypass the UI gate.
if !transfer_allowed_by_primary_state(identity.primary) {
return OwnHandlePreflight::Reject(
"cannot transfer this handle while it is your active (primary) handle; make \
another handle active first"
.to_string(),
);
}
OwnHandlePreflight::Proceed
}
/// Outcome of the post-transfer read-back confirmation (#4998, B1).
#[derive(Debug, PartialEq, Eq)]
enum TransferReadback {
/// The registry reads back the recipient as the current owner.
Confirmed,
/// The read-back neither confirms nor refutes reassignment — the POST is
/// already submitted, so this is "submitted but unconfirmed", never a claim
/// that the transfer did not happen.
Unconfirmed,
}
/// Classify the post-transfer read-back. Only a non-empty owner that equals the
/// recipient confirms; anything else (empty/enveloped body, mismatch, failed
/// read) is treated as unconfirmed. Pure, so unit-testable without a client.
fn classify_transfer_readback(
confirmed_owner: &str,
recipient_crypto_id: &str,
) -> TransferReadback {
if !confirmed_owner.is_empty() && confirmed_owner == recipient_crypto_id {
TransferReadback::Confirmed
} else {
TransferReadback::Unconfirmed
}
}
// ── Users email verification ────────────────────────────────────────────────
pub(crate) fn handle_tinyplace_users_start_email_verification(
@@ -5283,6 +5526,240 @@ mod tests {
assert!(err.contains("username"), "got: {err}");
}
/// #4929: transfer rejects a missing/blank `name` or `recipient` before any
/// client/network work — the destructive path never runs on bad input.
#[test]
fn transfer_requires_name_and_recipient() {
// Missing name.
let err = block_on(handle_tinyplace_registry_transfer(Map::new())).unwrap_err();
assert!(err.contains("name"), "got: {err}");
// A bare "@" normalizes to an empty name and is rejected before any
// client/network work (the destructive path never runs).
let mut params = Map::new();
params.insert("name".to_string(), Value::String("@".to_string()));
params.insert("recipient".to_string(), Value::String("bravo".to_string()));
let err = block_on(handle_tinyplace_registry_transfer(params)).unwrap_err();
assert!(err.contains("name"), "got: {err}");
// Name present, recipient missing.
let mut params = Map::new();
params.insert("name".to_string(), Value::String("alpha".to_string()));
let err = block_on(handle_tinyplace_registry_transfer(params)).unwrap_err();
assert!(err.contains("recipient"), "got: {err}");
// Name present, recipient blank → still rejected.
let mut params = Map::new();
params.insert("name".to_string(), Value::String("alpha".to_string()));
params.insert("recipient".to_string(), Value::String(" ".to_string()));
let err = block_on(handle_tinyplace_registry_transfer(params)).unwrap_err();
assert!(err.contains("recipient"), "got: {err}");
}
/// #4929: transfer eligibility fails closed when the registry omits the
/// primary flag; only an explicit `false` may reach the destructive path.
#[test]
fn transfer_requires_explicit_non_primary_state() {
assert!(!transfer_allowed_by_primary_state(None));
assert!(!transfer_allowed_by_primary_state(Some(true)));
assert!(transfer_allowed_by_primary_state(Some(false)));
}
// ── #4998 destructive-path coverage (B1 read-back, M1 idempotency, M2
// recipient validation) — the pure policy helpers the async handler
// delegates to, so the irreversible-transfer decisions are unit-tested. ──
fn test_identity(
crypto_id: &str,
status: &str,
primary: Option<bool>,
) -> tinyplace::types::Identity {
tinyplace::types::Identity {
username: "handle".to_string(),
crypto_id: crypto_id.to_string(),
public_key: if crypto_id.is_empty() {
String::new()
} else {
"pubkey".to_string()
},
registered_at: String::new(),
expires_at: String::new(),
status: status.to_string(),
registration_tx: None,
payment_methods: None,
primary,
subnames: None,
signature: None,
payment: None,
last_renewal_tx: None,
updated_at: String::new(),
}
}
fn availability(
available: bool,
identity: Option<tinyplace::types::Identity>,
) -> tinyplace::types::AvailabilityResponse {
tinyplace::types::AvailabilityResponse {
available,
name: "@handle".to_string(),
identity,
lifecycle: None,
}
}
/// M2: a recipient whose name is currently `available` (free / released) is
/// refused even if a stale `identity` record is attached — never transfer to
/// a wallet the recipient may no longer control at that @handle.
#[test]
fn recipient_available_is_refused() {
let avail = availability(
true,
Some(test_identity("recipientCid", "active", Some(false))),
);
let err = validate_recipient_availability(&avail).unwrap_err();
assert!(err.contains("not currently registered"), "got: {err}");
}
/// M2: a recipient with no identity record at all fails closed.
#[test]
fn recipient_missing_identity_is_refused() {
let err = validate_recipient_availability(&availability(false, None)).unwrap_err();
assert!(err.contains("not registered on tiny.place"), "got: {err}");
}
/// M2: an EXPIRED recipient identity (stale owner record before release) is
/// refused — case-insensitively.
#[test]
fn recipient_expired_status_is_refused() {
for status in ["expired", "EXPIRED", "Expired"] {
let avail = availability(
false,
Some(test_identity("recipientCid", status, Some(false))),
);
let err = validate_recipient_availability(&avail).unwrap_err();
assert!(err.contains("expired"), "status {status} → {err}");
}
}
/// M2: a recipient missing key material can't be transferred to safely.
#[test]
fn recipient_missing_key_material_is_refused() {
let avail = availability(false, Some(test_identity("", "active", Some(false))));
let err = validate_recipient_availability(&avail).unwrap_err();
assert!(err.contains("missing key material"), "got: {err}");
}
/// A live, registered recipient resolves to its (cryptoId, publicKey).
#[test]
fn recipient_live_resolves_key_material() {
let avail = availability(
false,
Some(test_identity("recipientCid", "active", Some(false))),
);
let (cid, pk) = validate_recipient_availability(&avail).unwrap();
assert_eq!(cid, "recipientCid");
assert_eq!(pk, "pubkey");
}
/// M1 idempotency: when the sender's own handle ALREADY reads back as the
/// recipient (a retry after a lost-but-applied POST), the preflight
/// short-circuits and must NOT re-sign.
#[test]
fn preflight_already_transferred_short_circuits() {
let own = availability(
false,
Some(test_identity("recipientCid", "active", Some(false))),
);
assert_eq!(
preflight_own_handle(&own, "recipientCid"),
OwnHandlePreflight::AlreadyTransferred
);
}
/// M2 tail: a nonexistent own-handle gets the right diagnosis, NOT the
/// "mark it non-primary" message.
#[test]
fn preflight_unregistered_own_handle_is_distinct() {
match preflight_own_handle(&availability(true, None), "recipientCid") {
OwnHandlePreflight::Reject(msg) => {
assert!(msg.contains("not registered"), "got: {msg}");
assert!(
!msg.contains("primary"),
"must not misdiagnose as primary: {msg}"
);
}
other => panic!("expected Reject, got {other:?}"),
}
}
/// A primary (active) own-handle is rejected before signing.
#[test]
fn preflight_primary_handle_is_rejected() {
let own = availability(false, Some(test_identity("ownerCid", "active", Some(true))));
match preflight_own_handle(&own, "recipientCid") {
OwnHandlePreflight::Reject(msg) => assert!(msg.contains("active"), "got: {msg}"),
other => panic!("expected Reject, got {other:?}"),
}
// Missing primary flag also fails closed.
let own_none = availability(false, Some(test_identity("ownerCid", "active", None)));
assert!(matches!(
preflight_own_handle(&own_none, "recipientCid"),
OwnHandlePreflight::Reject(_)
));
}
/// A non-primary own-handle owned by someone other than the recipient
/// proceeds to signing.
#[test]
fn preflight_non_primary_proceeds() {
let own = availability(
false,
Some(test_identity("ownerCid", "active", Some(false))),
);
assert_eq!(
preflight_own_handle(&own, "recipientCid"),
OwnHandlePreflight::Proceed
);
}
/// B1: the read-back confirms ONLY when the registry returns a non-empty
/// owner equal to the recipient. An empty owner (enveloped/renamed/partial
/// body, or a failed read) is "unconfirmed" — never a claim the transfer
/// didn't happen.
#[test]
fn readback_confirms_only_on_matching_nonempty_owner() {
assert_eq!(
classify_transfer_readback("recipientCid", "recipientCid"),
TransferReadback::Confirmed
);
// Empty owner (default-deserialized crypto_id "" from an enveloped body).
assert_eq!(
classify_transfer_readback("", "recipientCid"),
TransferReadback::Unconfirmed
);
// Owner reads back as someone else.
assert_eq!(
classify_transfer_readback("otherCid", "recipientCid"),
TransferReadback::Unconfirmed
);
// Defensive: an empty recipient never vacuously confirms on an empty read.
assert_eq!(
classify_transfer_readback("", ""),
TransferReadback::Unconfirmed
);
}
/// A self-transfer is rejected before any network/signing work.
#[test]
fn transfer_rejects_self_transfer() {
let mut params = Map::new();
params.insert("name".to_string(), Value::String("@alpha".to_string()));
params.insert("recipient".to_string(), Value::String("alpha".to_string()));
let err = block_on(handle_tinyplace_registry_transfer(params)).unwrap_err();
assert!(err.contains("itself"), "got: {err}");
}
/// Buy handlers reject a missing/blank `id` before any client/network work.
#[test]
fn buy_handlers_require_id() {
+35
View File
@@ -151,6 +151,7 @@ use crate::openhuman::tinyplace::manifest::{
handle_tinyplace_registry_export,
handle_tinyplace_registry_get,
handle_tinyplace_registry_register,
handle_tinyplace_registry_transfer,
handle_tinyplace_search_unified,
handle_tinyplace_signal_decrypt_message,
handle_tinyplace_signal_get_bundle,
@@ -1467,6 +1468,35 @@ fn schema_registry_export() -> ControllerSchema {
}
}
fn schema_registry_transfer() -> ControllerSchema {
ControllerSchema {
namespace: "tinyplace",
function: "registry_transfer",
description:
"Transfer one of the wallet's handles to another tiny.place identity (gift / account \
move). DESTRUCTIVE and irreversible: on success the recipient becomes the handle's \
sole owner. The recipient is resolved from their @handle; an unregistered recipient \
or an unconfirmed read-back fails closed. The owning wallet is proven by the \
signer-attached signature, so a caller can only transfer a handle their own wallet \
owns.",
inputs: vec![
required_string(
"name",
"The handle to transfer away (with or without a leading @).",
),
required_string(
"recipient",
"The recipient's registered @handle (with or without a leading @); resolved to \
their cryptoId + publicKey server-side.",
),
],
outputs: vec![json_output(
"identity",
"The transferred Identity, now owned by the recipient (read-back confirmed).",
)],
}
}
// ── Feedback schemas ────────────────────────────────────────────────────────
fn schema_feedback_list() -> ControllerSchema {
@@ -2727,6 +2757,7 @@ pub fn all_tinyplace_controller_schemas() -> Vec<ControllerSchema> {
schema_registry_get(),
schema_registry_register(),
schema_registry_assign_primary(),
schema_registry_transfer(),
schema_marketplace_buy_product(),
schema_marketplace_buy_identity(),
schema_marketplace_bid(),
@@ -2980,6 +3011,10 @@ pub fn all_tinyplace_registered_controllers() -> Vec<RegisteredController> {
schema: schema_registry_assign_primary(),
handler: handle_tinyplace_registry_assign_primary,
},
RegisteredController {
schema: schema_registry_transfer(),
handler: handle_tinyplace_registry_transfer,
},
RegisteredController {
schema: schema_marketplace_buy_product(),
handler: handle_tinyplace_marketplace_buy_product,