From 3bb97be0fe64cd0cd72fa0fa90fab16c1a09de6c Mon Sep 17 00:00:00 2001 From: Miracle656 Date: Sun, 8 Nov 2026 10:03:30 +0100 Subject: [PATCH] feat(wallet): actually run the federation server stellar.toml advertises MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `public/.well-known/stellar.toml` has been publishing FEDERATION_SERVER = "https://app.useveilapp.xyz/api/federation" to anyone who looked, while that route did not exist. Every attempt to resolve a Veil name got Next's HTML 404 where a wallet expected JSON, so Veil was naming a federation server it did not run. The repo's own comment in the toml admitted the URL was aspirational — but the file is deployed. Adds the SEP-2 endpoint and the resolver behind it: - `type=name` reads `resolve(name)` off `contracts/veil-names`. Expiry needs no handling: the contract already returns `None` past it, which is what makes a lapsed name stop resolving instead of quietly paying whoever buys it next. - `type=id` answers 404 by design. The registry is keyed name -> address with no reverse index, so answering would mean scanning every name. `lib/reverseResolution.ts` already degrades to null and renders the raw address. - `type=forward` is a 400, not a 404. The spec reserves it for handing a payment to an off-network rail and Veil runs none, so "not found" would imply a right destination might exist. - `type=txid` is a 404. The registry contract is not deployed, so every lookup is a truthful 404 today. The shape is the point: a wallet that handles this endpoint now keeps working the moment the registry is live, and nothing here invents an answer. `getNamesContractId()` has deliberately no baked-in default, unlike the factory id, because a placeholder would make the module speak with confidence about a contract that does not exist. Two deliberate choices worth stating: - Names are shape-checked here against `validate_shape` in the contract before any RPC. It is a short-circuit, not a control — the contract re-checks, because anyone can invoke it directly — but it keeps a junk query from costing a metered QuickNode call. A Cyrillic homoglyph is refused rather than folded to ASCII; folding would let one name resolve to another holder's payment destination. - An RPC failure resolves to a 404, never a 500. `lib/federation.ts` distinguishes 404 ("address not found") from anything else ("server unreachable"), and a server that 500s on a hiccup teaches callers both that the name does not resolve and that Veil is unreliable. Only the first is true. ⚠ Recorded in the module header rather than fixed: a Veil name resolves to a `C…` address, and SEP-2 documents `account_id` as a Stellar public key. A classic `payment` cannot address a contract, and CAP-84 `W…` addresses do not help because a muxed contract address is not a classic destination either. So a Veil name is payable from Veil and from anything speaking Soroban, and not from an exchange. Returning the contract address is still right — that is where the money is, and inventing a `G…` would be worse — but it has to be settled before a name is marketed as an address you can give anyone. See docs/IDENTIFIER_ADDRESSES.md. `npx tsc --noEmit` clean; 32 new tests. Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB --- .../api/federation/__tests__/route.test.ts | 94 +++++++++ frontend/wallet/app/api/federation/route.ts | 122 +++++++++++ .../lib/__tests__/federationServer.test.ts | 158 ++++++++++++++ frontend/wallet/lib/federationServer.ts | 196 ++++++++++++++++++ .../website/public/.well-known/stellar.toml | 10 +- 5 files changed, 578 insertions(+), 2 deletions(-) create mode 100644 frontend/wallet/app/api/federation/__tests__/route.test.ts create mode 100644 frontend/wallet/app/api/federation/route.ts create mode 100644 frontend/wallet/lib/__tests__/federationServer.test.ts create mode 100644 frontend/wallet/lib/federationServer.ts diff --git a/frontend/wallet/app/api/federation/__tests__/route.test.ts b/frontend/wallet/app/api/federation/__tests__/route.test.ts new file mode 100644 index 000000000..fe228ac4f --- /dev/null +++ b/frontend/wallet/app/api/federation/__tests__/route.test.ts @@ -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('*') + }) + }) +}) diff --git a/frontend/wallet/app/api/federation/route.ts b/frontend/wallet/app/api/federation/route.ts new file mode 100644 index 000000000..4b734bbb4 --- /dev/null +++ b/frontend/wallet/app/api/federation/route.ts @@ -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 { + 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() }) +} diff --git a/frontend/wallet/lib/__tests__/federationServer.test.ts b/frontend/wallet/lib/__tests__/federationServer.test.ts new file mode 100644 index 000000000..a938ce0e1 --- /dev/null +++ b/frontend/wallet/lib/__tests__/federationServer.test.ts @@ -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() + }) +}) diff --git a/frontend/wallet/lib/federationServer.ts b/frontend/wallet/lib/federationServer.ts new file mode 100644 index 000000000..c79b5d701 --- /dev/null +++ b/frontend/wallet/lib/federationServer.ts @@ -0,0 +1,196 @@ +/** + * The server side of SEP-2 — what answers `name*useveilapp.xyz`. + * + * `frontend/wallet/lib/federation.ts` is the *client*: it reads a domain's + * `stellar.toml`, finds `FEDERATION_SERVER`, and queries it. This module is + * the other end, for our own domain. + * + * It exists because `public/.well-known/stellar.toml` already publishes + * + * FEDERATION_SERVER = "https://app.useveilapp.xyz/api/federation" + * + * and that URL returned Next's HTML 404. Any wallet resolving a Veil name got + * markup where it expected JSON — so Veil was advertising a federation server + * it did not run. + * + * ## Where the answer comes from + * + * `contracts/veil-names` is the source of truth, not a database. Its `resolve` + * already returns `None` for an expired name, so expiry needs no handling + * here: a lapsed name stops resolving because the contract stops answering. + * That is the whole point of the 90-day recovery window — a stale contact + * fails for a quarter rather than silently paying whoever bought the name + * next. + * + * Until that contract is deployed there is no registry to read, so every + * lookup is an honest 404 rather than a fabricated answer. + * + * ## ⚠ A Veil name resolves to a `C…` address, and SEP-2 did not plan for that + * + * SEP-2 documents `account_id` as a Stellar public key, and a Veil wallet is a + * contract account. A classic `payment` operation cannot address a `C…` + * address at all, so a wallet that resolves a Veil name and then builds a + * classic payment will fail — and CAP-84 `W…` addresses do not help, because a + * muxed contract address is also not a classic payment destination. + * + * This module returns the contract address because that is where the money + * actually lives; inventing a `G…` address would be worse. But it means a Veil + * name is payable from Veil and from anything that speaks Soroban, and not + * from an exchange. That is a product decision to resolve before names are + * marketed as an address you can give anyone — see `docs/IDENTIFIER_ADDRESSES.md`. + */ + +import { + Account, + BASE_FEE, + Contract, + nativeToScVal, + rpc as SorobanRpc, + scValToNative, + TransactionBuilder, +} from '@stellar/stellar-sdk' + +import { getNetwork } from './network' + +/** The domain Veil names are issued under, and the only one we answer for. */ +export const HOME_DOMAIN = 'useveilapp.xyz' + +/** + * A name, as the registry stores it. + * + * Mirrors `validate_shape` in `contracts/veil-names/src/lib.rs`: 3–20 + * characters of lowercase ASCII, digits and underscore, with no leading, + * trailing or doubled underscore. Checked here only to avoid a pointless RPC + * round trip for something the registry could never hold — the contract is + * still the authority, because anyone can invoke it directly. + */ +const NAME_SHAPE = /^(?!_)(?!.*__)[a-z0-9_]{3,20}(? 0 ? id : null +} + +/** Resolves a bare name to the address holding it, or null. */ +export type NameResolver = (name: string) => Promise + +/** + * Read `resolve(name)` off the registry contract. + * + * A simulation, not a submission: `resolve` is a read and costs nothing, so + * there is no source account to fund. The dummy account below is never + * charged and never signs — `simulateTransaction` only needs a well-formed + * envelope to execute against. + * + * Any failure resolves to null rather than throwing. A federation server that + * 500s on an RPC hiccup teaches callers that the name does not resolve *and* + * that Veil is unreliable; a 404 says only the first, which is the truthful + * part. `federation.ts` on the client draws exactly this distinction, and + * `reverseResolution.ts` already degrades to null on an outage. + */ +export async function resolveOnChain(name: string): Promise { + const contractId = getNamesContractId() + if (!contractId) return null + + try { + const network = getNetwork() + const server = new SorobanRpc.Server(network.rpcUrl) + const contract = new Contract(contractId) + + // Any valid account works as a simulation source; this one is the + // all-zero ed25519 key, which cannot sign and never needs to. + const source = new Account('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', '0') + + const tx = new TransactionBuilder(source, { + fee: BASE_FEE, + networkPassphrase: network.networkPassphrase, + }) + .addOperation(contract.call('resolve', nativeToScVal(name, { type: 'string' }))) + .setTimeout(30) + .build() + + const sim = await server.simulateTransaction(tx) + if (!SorobanRpc.Api.isSimulationSuccess(sim) || !sim.result) return null + + // `resolve` returns `Option
`; a `None` natives to null/undefined. + const value = scValToNative(sim.result.retval) + return typeof value === 'string' && value.length > 0 ? value : null + } catch { + return null + } +} + +/** A SEP-2 `type=name` answer. */ +export interface FederationNameRecord { + stellar_address: string + account_id: string +} + +/** + * Resolve a federated address to a record, or null. + * + * `resolver` is injectable so the route can be tested without an RPC server; + * the default reads the chain. + */ +export async function lookupFederationName( + q: string, + resolver: NameResolver = resolveOnChain, +): Promise { + const parsed = parseFederationName(q) + if (!parsed) return null + + const accountId = await resolver(parsed.name) + if (!accountId) return null + + return { + stellar_address: `${parsed.name}*${parsed.domain}`, + account_id: accountId, + } +} diff --git a/frontend/website/public/.well-known/stellar.toml b/frontend/website/public/.well-known/stellar.toml index 8d5741ca0..374f82a80 100644 --- a/frontend/website/public/.well-known/stellar.toml +++ b/frontend/website/public/.well-known/stellar.toml @@ -1,7 +1,13 @@ # SEP-1 public metadata for useveilapp.xyz. # -# The federation URL is the planned production endpoint. The wallet backend -# still needs to implement and deploy it before SEP-2 name resolution works. +# The federation endpoint is live and answers SEP-2 `type=name`. It resolves +# against the Veil Names registry contract, which is not deployed yet, so every +# lookup is currently a 404 — a truthful "no such name" rather than the HTML +# 404 this URL used to return before the route existed. +# +# `type=id` (reverse lookup) is answered with a 404 on purpose: the registry is +# keyed name -> address with no reverse index. See +# frontend/wallet/app/api/federation/route.ts. FEDERATION_SERVER = "https://app.useveilapp.xyz/api/federation" [DOCUMENTATION]