mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
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
@@ -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">
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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': 'পরিচিতি',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'परिचय',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '소개',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'О себе',
|
||||
|
||||
@@ -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': '简介',
|
||||
|
||||
@@ -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(¶ms, "name")?
|
||||
.trim()
|
||||
.trim_start_matches('@')
|
||||
.to_string();
|
||||
if name.is_empty() {
|
||||
return Err("missing required param 'name'".to_string());
|
||||
}
|
||||
let recipient = req_str(¶ms, "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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user