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
94 changes: 94 additions & 0 deletions frontend/wallet/app/api/federation/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* @jest-environment node
*/

import { NextRequest } from 'next/server'

import { GET, OPTIONS } from '../route'

function get(query: string): NextRequest {
return new NextRequest(`https://app.useveilapp.xyz/api/federation${query}`)
}

describe('GET /api/federation', () => {
it('is reachable at all — the endpoint stellar.toml advertises', async () => {
// The regression this route exists for: the published FEDERATION_SERVER
// URL returned Next's HTML 404, so wallets got markup where they expected
// JSON. Any JSON answer, including a 404 body, is the fix.
const res = await GET(get('?q=adebayo*useveilapp.xyz&type=name'))
expect(res.headers.get('content-type')).toContain('application/json')
})

describe('parameter validation', () => {
it('400s without q', async () => {
expect((await GET(get('?type=name'))).status).toBe(400)
})

it('400s without type', async () => {
// SEP-2 requires `type`; guessing it would make the endpoint answer
// differently from every other federation server.
expect((await GET(get('?q=adebayo*useveilapp.xyz'))).status).toBe(400)
})

it('400s on an unknown type and names the ones it takes', async () => {
const res = await GET(get('?q=adebayo*useveilapp.xyz&type=nonsense'))
expect(res.status).toBe(400)

const body = await res.json()
expect(body.detail).toContain('name')
expect(body.detail).toContain('id')
})
})

describe('type=name', () => {
it('404s for an unregistered name, since the registry is not deployed', async () => {
const res = await GET(get('?q=adebayo*useveilapp.xyz&type=name'))
expect(res.status).toBe(404)
expect((await res.json()).detail).toBeTruthy()
})

it('404s rather than answering for another domain', async () => {
expect((await GET(get('?q=adebayo*lobstr.co&type=name'))).status).toBe(404)
})

it('never caches, because expiry is a ledger fact', async () => {
// A cached 404 would outlive a registration, and a cached hit would
// outlive an expiry — which is exactly the stale-contact hazard the
// contract's 90-day recovery window exists to contain.
const res = await GET(get('?q=adebayo*useveilapp.xyz&type=name'))
expect(res.headers.get('Cache-Control')).toBe('no-store')
})
})

describe('the other SEP-2 types', () => {
it('404s on type=id, because the registry has no reverse index', async () => {
const res = await GET(get('?q=CWALLET123&type=id'))
expect(res.status).toBe(404)
})

it('400s on type=forward, because Veil runs no off-network rail', async () => {
// Deliberately not a 404: "not found" would imply a right destination
// might exist, and for forwarding none can.
expect((await GET(get('?q=adebayo*useveilapp.xyz&type=forward'))).status).toBe(400)
})

it('404s on type=txid', async () => {
expect((await GET(get('?q=abc123&type=txid'))).status).toBe(404)
})
})

describe('CORS', () => {
it('is open to any origin, as a federation server must be', async () => {
// Public by specification: any wallet on any origin resolves a name
// without a key. This is the one endpoint where `*` is correct.
const res = await GET(get('?q=adebayo*useveilapp.xyz&type=name'))
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*')
})

it('answers a preflight with 204', async () => {
const res = await OPTIONS()
expect(res.status).toBe(204)
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*')
})
})
})
122 changes: 122 additions & 0 deletions frontend/wallet/app/api/federation/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* `GET /api/federation` — the SEP-2 endpoint `stellar.toml` advertises.
*
* `public/.well-known/stellar.toml` has published
*
* FEDERATION_SERVER = "https://app.useveilapp.xyz/api/federation"
*
* while this route did not exist, so every lookup of a Veil name got Next's
* HTML 404 where a wallet expected JSON. Veil was naming a federation server
* it did not run.
*
* Spec: https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0002.md
*
* Resolution reads `contracts/veil-names` (see `lib/federationServer.ts`),
* which is not deployed yet — so today every lookup is a truthful 404 rather
* than an invented answer. The shape is what matters now: a wallet that
* handles this endpoint correctly today keeps working the moment the registry
* is live.
*
* ## Which query types are answered
*
* - `name` — `alice*useveilapp.xyz` → the address holding it. The one that
* matters, and the one `lib/federation.ts` on the client sends.
* - `id` — reverse lookup. **Not supported**, and that is a property of the
* registry, not an omission here: the contract is keyed name → address with
* no reverse index, so answering would mean scanning every name. A 404 is
* the honest reply, and `lib/reverseResolution.ts` already degrades to null,
* which renders the raw address.
* - `forward` — for handing a payment to an off-network rail. Veil operates
* none, so per the spec this is an error rather than a not-found.
* - `txid` — not supported; Veil has no federation-side transaction records.
*/

import { NextRequest, NextResponse } from 'next/server'

import { lookupFederationName } from '@/lib/federationServer'

/** SEP-2 lookup types, per the spec's `type` parameter. */
const KNOWN_TYPES = ['name', 'id', 'forward', 'txid'] as const

function corsHeaders(): Record<string, string> {
return {
// A federation server is public by specification — the whole point is
// that any wallet, on any origin, can resolve a name without a key.
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
}
}

/**
* A 404 body.
*
* SEP-2 does not fix the error shape, so this follows the convention of the
* SDF reference server: a `detail` string. Clients key off the status, which
* `lib/federation.ts` does — 404 means "address not found" and anything else
* means "server unreachable", a distinction worth preserving.
*/
function notFound(detail: string): NextResponse {
return NextResponse.json(
{ detail },
{
status: 404,
headers: {
...corsHeaders(),
// A name's expiry is a ledger fact that can change between requests,
// and a cached "not found" would outlive a registration. The client
// keeps its own 60s cache (`DEFAULT_FEDERATION_CACHE_TTL_MS`).
'Cache-Control': 'no-store',
},
},
)
}

function badRequest(detail: string): NextResponse {
return NextResponse.json({ detail }, { status: 400, headers: corsHeaders() })
}

export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url)
const q = searchParams.get('q')
const type = searchParams.get('type')

if (!q) return badRequest('Query parameter "q" is required.')
if (!type) return badRequest('Query parameter "type" is required.')
if (!(KNOWN_TYPES as readonly string[]).includes(type)) {
return badRequest(`Unknown lookup type "${type}". Expected one of: ${KNOWN_TYPES.join(', ')}.`)
}

if (type === 'forward') {
// Not a 404: the spec reserves `forward` for handing a payment to an
// off-network rail, and Veil runs none. Saying "not found" would imply
// the right destination might exist, which it cannot.
return badRequest('This federation server does not support forward requests.')
}

if (type === 'txid') {
return notFound('This federation server does not record transaction ids.')
}

if (type === 'id') {
// See the module header: the registry has no reverse index.
return notFound('This federation server does not support reverse lookups.')
}

const record = await lookupFederationName(q)
if (!record) {
return notFound(`No Veil name matches ${q}.`)
}

return NextResponse.json(record, {
status: 200,
headers: {
...corsHeaders(),
'Cache-Control': 'no-store',
},
})
}

export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders() })
}
158 changes: 158 additions & 0 deletions frontend/wallet/lib/__tests__/federationServer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/**
* @jest-environment node
*/

import {
HOME_DOMAIN,
getNamesContractId,
lookupFederationName,
parseFederationName,
} from '../federationServer'

describe('parseFederationName', () => {
it('splits a name on our own domain', () => {
expect(parseFederationName('adebayo*useveilapp.xyz')).toEqual({
name: 'adebayo',
domain: 'useveilapp.xyz',
})
})

it('lowercases both halves, because a name is case-insensitive to a sender', () => {
expect(parseFederationName('Adebayo*UseVeilApp.XYZ')).toEqual({
name: 'adebayo',
domain: 'useveilapp.xyz',
})
})

it('refuses another domain rather than answering for it', () => {
// Being asked about someone else's domain is normal, not an error — the
// caller found us through *their* stellar.toml, or is probing.
expect(parseFederationName('adebayo*lobstr.co')).toBeNull()
expect(parseFederationName(`adebayo*evil-${HOME_DOMAIN}`)).toBeNull()
expect(parseFederationName(`adebayo*${HOME_DOMAIN}.attacker.com`)).toBeNull()
})

it('refuses anything that is not exactly one asterisk', () => {
expect(parseFederationName('adebayo')).toBeNull()
expect(parseFederationName('adebayo*useveilapp.xyz*extra')).toBeNull()
expect(parseFederationName('*useveilapp.xyz')).toBeNull()
expect(parseFederationName('adebayo*')).toBeNull()
})

it('refuses a raw Stellar address', () => {
expect(
parseFederationName('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'),
).toBeNull()
expect(parseFederationName('CCZ3JLRESNLDADGXWNEH4YQ4NXUUAHRJNCWZHYG6QB4KTDYHOH6OQ7BK')).toBeNull()
})

it('refuses empty and whitespace queries', () => {
expect(parseFederationName('')).toBeNull()
expect(parseFederationName(' ')).toBeNull()
})

it('trims surrounding whitespace', () => {
expect(parseFederationName(' adebayo*useveilapp.xyz ')?.name).toBe('adebayo')
})

// The shape rules mirror `validate_shape` in contracts/veil-names/src/lib.rs.
// They are a short-circuit, not the authority — the contract re-checks,
// because anyone can invoke it directly.
describe('name shape, mirroring the contract', () => {
it('refuses names outside 3 to 20 characters', () => {
expect(parseFederationName('ab*useveilapp.xyz')).toBeNull()
expect(parseFederationName(`${'a'.repeat(21)}*useveilapp.xyz`)).toBeNull()
expect(parseFederationName('abc*useveilapp.xyz')?.name).toBe('abc')
expect(parseFederationName(`${'a'.repeat(20)}*useveilapp.xyz`)?.name).toBe('a'.repeat(20))
})

it('allows an interior underscore and digits', () => {
expect(parseFederationName('ade_bayo9*useveilapp.xyz')?.name).toBe('ade_bayo9')
})

it('refuses a leading, trailing or doubled underscore', () => {
expect(parseFederationName('_adebayo*useveilapp.xyz')).toBeNull()
expect(parseFederationName('adebayo_*useveilapp.xyz')).toBeNull()
expect(parseFederationName('ade__bayo*useveilapp.xyz')).toBeNull()
})

it('refuses characters outside lowercase, digits and underscore', () => {
for (const bad of ['ade-bayo', 'ade.bayo', 'ade bayo', 'adeb@yo', 'adebayó']) {
expect(parseFederationName(`${bad}*useveilapp.xyz`)).toBeNull()
}
})

it('refuses a Cyrillic homoglyph rather than folding it to ASCII', () => {
// jаck — the second character is Cyrillic "а". Folding it would let
// one name resolve to another holder's payment destination.
expect(parseFederationName('jаck*useveilapp.xyz')).toBeNull()
})
})
})

describe('getNamesContractId', () => {
const saved = { ...process.env }

afterEach(() => {
process.env = { ...saved }
})

it('is null with nothing configured, because the registry is not deployed', () => {
delete process.env.NEXT_PUBLIC_NAMES_CONTRACT_ID
delete process.env.NEXT_PUBLIC_NAMES_CONTRACT_ID_MAINNET
delete process.env.NEXT_PUBLIC_NAMES_CONTRACT_ID_TESTNET
expect(getNamesContractId()).toBeNull()
})

it('treats a blank value as unset rather than as a contract id', () => {
process.env.NEXT_PUBLIC_NAMES_CONTRACT_ID = ' '
expect(getNamesContractId()).toBeNull()
})

it('reads the shared variable when no per-network one is set', () => {
delete process.env.NEXT_PUBLIC_NAMES_CONTRACT_ID_MAINNET
delete process.env.NEXT_PUBLIC_NAMES_CONTRACT_ID_TESTNET
process.env.NEXT_PUBLIC_NAMES_CONTRACT_ID = 'CSHARED'
expect(getNamesContractId()).toBe('CSHARED')
})
})

describe('lookupFederationName', () => {
it('returns the record a wallet needs, with the canonical address echoed back', async () => {
const record = await lookupFederationName('Adebayo*UseVeilApp.xyz', async () => 'CWALLET123')

expect(record).toEqual({
// Echoed normalized, so a caller that uppercased the query still gets
// the form the registry holds.
stellar_address: 'adebayo*useveilapp.xyz',
account_id: 'CWALLET123',
})
})

it('is null when the name is not registered', async () => {
expect(await lookupFederationName('adebayo*useveilapp.xyz', async () => null)).toBeNull()
})

it('never calls the resolver for a query that is not ours', async () => {
const resolver = jest.fn(async () => 'CWALLET123')

expect(await lookupFederationName('adebayo*lobstr.co', resolver)).toBeNull()
expect(await lookupFederationName('ab*useveilapp.xyz', resolver)).toBeNull()
expect(await lookupFederationName('not-a-federated-address', resolver)).toBeNull()

// An RPC round trip for something the registry could never hold is pure
// latency, and on mainnet it is a metered QuickNode call.
expect(resolver).not.toHaveBeenCalled()
})

it('passes the bare name to the resolver, not the full address', async () => {
const resolver = jest.fn(async () => 'CWALLET123')
await lookupFederationName('adebayo*useveilapp.xyz', resolver)

expect(resolver).toHaveBeenCalledWith('adebayo')
})

it('treats an empty resolver answer as unregistered, not as an address', async () => {
expect(await lookupFederationName('adebayo*useveilapp.xyz', async () => '')).toBeNull()
})
})
Loading
Loading