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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions frontend/src/lib/federation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* SEP-0002 federated Stellar address resolution (#1225).
*
* `@stellar/stellar-sdk` already ships a full federation client
* (`Federation.Server.resolve`) that fetches the domain's stellar.toml,
* finds `FEDERATION_SERVER`, and queries it for the `name*domain` address —
* so this is a thin wrapper, not a protocol reimplementation.
*/

import { Federation } from '@stellar/stellar-sdk';

/** A `name*domain` federated Stellar address, e.g. `alice*trivela.network`. */
export function isFederatedAddress(value) {
if (typeof value !== 'string') return false;
const parts = value.split('*');
return parts.length === 2 && parts[0].length > 0 && parts[1].includes('.');
}

/**
* Resolves a federated address (`name*domain`) to its Stellar account ID.
* Values that aren't federated addresses (e.g. already a G... account ID)
* are returned unchanged, so callers can pass either through unconditionally.
*
* @param {string} value
* @param {{ timeout?: number }} [opts]
* @returns {Promise<string>} the resolved account ID, or `value` unchanged
* @throws {Error} if `value` looks like a federated address but resolution
* fails (no stellar.toml, no FEDERATION_SERVER, name not found, etc.)
*/
export async function resolveFederatedAddress(value, opts = {}) {
if (!isFederatedAddress(value)) return value;

try {
const record = await Federation.Server.resolve(value, { timeout: opts.timeout ?? 5000 });
return record.account_id;
} catch (err) {
throw new Error(`Could not resolve "${value}": ${err?.message ?? 'unknown federation error'}`);
}
}
61 changes: 61 additions & 0 deletions frontend/src/lib/federation.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { Federation } from '@stellar/stellar-sdk';
import { isFederatedAddress, resolveFederatedAddress } from './federation';

afterEach(() => {
vi.restoreAllMocks();
});

describe('isFederatedAddress', () => {
it('accepts a name*domain address', () => {
expect(isFederatedAddress('alice*trivela.network')).toBe(true);
});

it('rejects a raw G... account ID', () => {
expect(isFederatedAddress('GABC1234DEFG5678HIJK9012LMNO3456PQRS7890TUVW1234XYZ56789ABC')).toBe(
false,
);
});

it('rejects a domain-less value with an asterisk', () => {
expect(isFederatedAddress('alice*notadomain')).toBe(false);
});

it('rejects non-string values', () => {
expect(isFederatedAddress(undefined)).toBe(false);
expect(isFederatedAddress(null)).toBe(false);
});
});

describe('resolveFederatedAddress', () => {
it('returns non-federated values unchanged without calling the federation server', async () => {
const spy = vi.spyOn(Federation.Server, 'resolve');
const accountId = 'GABC1234DEFG5678HIJK9012LMNO3456PQRS7890TUVW1234XYZ56789ABC';

await expect(resolveFederatedAddress(accountId)).resolves.toBe(accountId);
expect(spy).not.toHaveBeenCalled();
});

it('resolves a federated address to its account_id', async () => {
const resolved = 'GB5XVAABEQMY63WTHDQ5RXADGYF345VWMNPTN2GFUDZT57D57ZQTJ7PS';
vi.spyOn(Federation.Server, 'resolve').mockResolvedValue({
account_id: resolved,
memo_type: undefined,
memo: undefined,
});

await expect(resolveFederatedAddress('alice*trivela.network')).resolves.toBe(resolved);
expect(Federation.Server.resolve).toHaveBeenCalledWith(
'alice*trivela.network',
expect.objectContaining({ timeout: expect.any(Number) }),
);
});

it('wraps federation server failures in a descriptive error', async () => {
vi.spyOn(Federation.Server, 'resolve').mockRejectedValue(new Error('name not found'));

await expect(resolveFederatedAddress('ghost*trivela.network')).rejects.toThrow(
/Could not resolve "ghost\*trivela\.network".*name not found/,
);
});
});
94 changes: 76 additions & 18 deletions frontend/src/pages/PublicProfile.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import PageMeta from '../components/PageMeta';
import { apiUrl, SITE_URL } from '../config';
import { isFederatedAddress, resolveFederatedAddress } from '../lib/federation';

// ── Badge emoji map — extended as NEW-071 types grow ────────────────────────
const BADGE_ICONS = {
Expand Down Expand Up @@ -119,6 +120,26 @@ function EmptyProfile({ address }) {
);
}

// ── Federation resolution failed ─────────────────────────────────────────────

function FederationError({ address }) {
return (
<div className="profile-federation-error" data-testid="profile-federation-error">
<span className="profile-not-found-icon" aria-hidden="true">
🔗
</span>
<h2>Couldn&apos;t resolve address</h2>
<p>
<strong>{address}</strong> doesn&apos;t resolve to a Stellar account. Double-check the
federated address, or use the account&apos;s G... address directly.
</p>
<Link to="/" className="btn btn-primary">
Back to campaigns
</Link>
</div>
);
}

// ── Not-found view ────────────────────────────────────────────────────────────

function NotFound({ address }) {
Expand Down Expand Up @@ -207,47 +228,73 @@ function ProfileView({ profile, address }) {

export default function PublicProfile() {
const { address } = useParams();
const [status, setStatus] = useState('loading'); // loading | ok | private | notfound | error
// loading | resolving | ok | private | notfound | federation-error | error
const [status, setStatus] = useState('loading');
const [profile, setProfile] = useState(null);
// The account ID actually used to fetch the profile — same as `address`
// unless it was a federated (`name*domain`) address that got resolved.
const [resolvedAddress, setResolvedAddress] = useState(null);

useEffect(() => {
if (!address) return;

let cancelled = false;
setStatus('loading');
setProfile(null);
setResolvedAddress(null);

async function loadProfile() {
let accountId = address;

// SEP-0002 federated address (#1225), e.g. `alice*trivela.network`.
if (isFederatedAddress(address)) {
setStatus('resolving');
try {
accountId = await resolveFederatedAddress(address);
} catch {
if (!cancelled) setStatus('federation-error');
return;
}
}

fetch(apiUrl(`/api/v1/participants/${encodeURIComponent(address)}/profile`))
.then((res) => {
if (res.status === 404) return { _notFound: true };
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => {
if (cancelled) return;
setResolvedAddress(accountId);
setStatus('loading');

try {
const res = await fetch(apiUrl(`/api/v1/participants/${encodeURIComponent(accountId)}/profile`));
if (cancelled) return;
if (data._notFound) {
if (res.status === 404) {
setStatus('notfound');
} else if (data.isPublic === false) {
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (cancelled) return;
if (data.isPublic === false) {
setStatus('private');
setProfile(data);
} else {
setStatus('ok');
setProfile(data);
}
})
.catch(() => {
} catch {
if (!cancelled) setStatus('error');
});
}
}

loadProfile();

return () => {
cancelled = true;
};
}, [address]);

const displayAddress = resolvedAddress ?? address;

// ── OG meta ────────────────────────────────────────────────────────────────
const isPublicProfile = status === 'ok' && profile;
const metaTitle = isPublicProfile
? `${profile.handle ?? shortenAddress(address)} on Trivela`
? `${profile.handle ?? shortenAddress(displayAddress)} on Trivela`
: 'Participant Profile — Trivela';
const metaDescription = isPublicProfile
? `${profile.handle ?? shortenAddress(address)} has earned ${profile.reputation ?? 0} reputation, ${profile.badges?.length ?? 0} badge${(profile.badges?.length ?? 0) !== 1 ? 's' : ''}, and joined ${profile.campaigns?.length ?? 0} campaign${(profile.campaigns?.length ?? 0) !== 1 ? 's' : ''} on Trivela.`
Expand All @@ -270,6 +317,13 @@ export default function PublicProfile() {
</header>

<main className="profile-main" aria-live="polite">
{status === 'resolving' && (
<div className="profile-loading" data-testid="profile-resolving">
<div className="spinner" aria-label="Resolving address…" />
<p>Resolving {address}…</p>
</div>
)}

{status === 'loading' && (
<div className="profile-loading" data-testid="profile-loading">
<div className="spinner" aria-label="Loading profile…" />
Expand All @@ -286,11 +340,15 @@ export default function PublicProfile() {
</div>
)}

{status === 'notfound' && <NotFound address={address} />}
{status === 'federation-error' && <FederationError address={address} />}

{status === 'notfound' && <NotFound address={displayAddress} />}

{status === 'private' && <PrivateProfile address={address} />}
{status === 'private' && <PrivateProfile address={displayAddress} />}

{status === 'ok' && profile && <ProfileView profile={profile} address={address} />}
{status === 'ok' && profile && (
<ProfileView profile={profile} address={displayAddress} />
)}
</main>
</div>
</>
Expand Down
Loading