Audience: contributors adding or changing features in the RemitWise Frontend. Operators and downstream integrators have their own entry points (
README.md→ "API Versioning",docs/openapi.json) — keep those separate.
This document defines the internal jargon used throughout the RemitWise codebase and docs. Every entry is one paragraph for the definition, followed by a short concrete example that compiles against the current source and a link to the canonical doc or source file for the whole story.
When you introduce a new in-codebase term, add it here in alphabetical position rather than inventing a new top-level doc — orphaned docs rot fastest.
In the Stellar ecosystem, an anchor is the regulated service that exchanges
fiat (USD, EUR, etc.) for the on-chain USDC/XLM that RemitWise sends. In this
repo "anchor" refers to the same concept plus the small set of HTTP endpoints
that talk to one: /api/anchor/rates, /api/anchor/deposit,
/api/anchor/withdraw, plus the inbound /api/webhooks/anchor callback.
Anchors are configured via ANCHOR_API_BASE_URL and authenticated either
through SEP-10 web auth for outbound requests or via HMAC for inbound
webhooks.
# Cache miss on the server: hit the anchor's /quote through our wrapper.
curl -s http://localhost:3000/api/anchor/rates
# → { "USDC": { "USD": 1.0, "MXN": 17.4 }, "XLM": { ... } }See also: docs/Anchor_Webhooks.md, docs/ANCHOR_ADMIN_SHUTDOWN.md.
An anchor webhook is the asynchronous callback an anchor posts to
POST /api/webhooks/anchor after a deposit or withdrawal changes state. The
handler at app/api/webhooks/anchor/route.ts returns 200 OK immediately
after verifying the HMAC signature and persisting the raw payload, then runs
the actual reconciliation in the background via
runBackgroundJob('anchor_webhook_event', …) so a slow anchor never blocks
the response. Every event goes through the same retry path; events that
exceed WEBHOOK_MAX_RETRIES end up in the
Dead-Letter Queue.
// app/api/webhooks/anchor/route.ts
import { verifySignature } from '@/lib/webhooks/verify'
import { saveWebhookEvent, processWebhookEvent } from '@/lib/webhooks/processor'
export async function POST(request: NextRequest) {
const rawBody = await request.text()
const secret = process.env.ANCHOR_WEBHOOK_SECRET!
if (!verifySignature(rawBody, request.headers.get('x-signature'), secret)) {
return new Response('Invalid signature', { status: 401 })
}
const payload = JSON.parse(rawBody)
const eventId = await saveWebhookEvent('anchor', payload.event_type, rawBody)
runBackgroundJob('anchor_webhook_event', () => processWebhookEvent(eventId, handleAnchorEvent))
return NextResponse.json({ received: true, eventId })
}See also: docs/Anchor_Webhooks.md, docs/WEBHOOK_RETRY_AND_DLQ.md.
The approvals queue is a client-side reducer + hook pair that drives the
multi-signer flow on Family Wallet actions (buildAddMemberTx,
buildUpdateSpendingLimitTx). Each item starts in building, advances to
pending once the unsigned XDR is produced, then to
awaiting-signature → signed | rejected | expired as co-signers act. The hook
useApprovalsQueue is shared by lib/hooks/useApprovalsQueue.ts and consumed
from components/family/ApprovalsQueue.tsx. Signing is delegated to
signTransaction from the wallet-kit (useWallet) — there is no bespoke
signing primitive.
// lib/hooks/useApprovalsQueue.ts (reducer in pseudo-shape)
type QueueItem = {
id: string
kind: 'add-member' | 'update-limit'
status: 'building' | 'pending' | 'awaiting-signature' | 'signed' | 'rejected' | 'expired'
xdr?: string
signatures: string[]
}See also: docs/family-multisig-approvals.md.
A CSP nonce in this repo is a per-request opaque string emitted by
middleware.ts → applySecurityHeaders(response, nonce, isApiRoute) and
embedded into the Content-Security-Policy header
(script-src 'self' 'nonce-<value>' 'strict-dynamic') plus every authorised
inline <script> tag. Without the matching nonce attribute the browser
refuses to run the script, which neutralises injected <script> blocks.
Always read headers().get('x-nonce') from a Server Component when adding a
new inline script (see How Nonces Work in the security doc). This is a
different string from the auth nonce produced by
/api/auth/nonce — they are generated by different code paths and have
different lifetimes.
// app/layout.tsx
const nonce = headersList.get('x-nonce') || ''
return <script nonce={nonce} dangerouslySetInnerHTML={{ __html: themeScript }} />See also: docs/SECURITY.md.
The dashboard widget deep-link is a small URL convention used to send a
support user (or yourself) directly to a specific dashboard panel:
/dashboard?widget=six-month-trends. The four stable widget IDs live in
lib/config/widgets.ts → WIDGET_IDS and must not be renamed: they are
part of the public URL surface and any rename is a breaking change.
useWidgetDeepLink(id) (from lib/hooks/useWidgetDeepLink.ts) reads the
query string, scrolls the matching element into view, and pulses a brand-red
outline (widget-highlight class in app/globals.css); the pulse is
suppressed for prefers-reduced-motion: reduce users.
import { useWidgetDeepLink } from '@/lib/hooks/useWidgetDeepLink'
import { WIDGET_IDS } from '@/lib/config/widgets'
export function SixMonthTrendsWidget() {
const ref = useWidgetDeepLink(WIDGET_IDS.SIX_MONTH_TRENDS)
return <section id={WIDGET_IDS.SIX_MONTH_TRENDS} ref={ref}>{/* … */}</section>
}Supported values: six-month-trends, money-distribution,
recent-transactions, savings-by-goal. See also:
docs/architecture.md → "Deep-link support for dashboard widgets".
The DLQ is the terminal holding pen for webhook events that failed
processing enough times to exhaust WEBHOOK_MAX_RETRIES (default 5, see
lib/webhooks/processor.ts → WEBHOOK_RETRY_CONFIG). Failed events get status
dlq plus a lastError message and surface in the admin UI at
/admin and through GET /api/v1/admin/webhooks/dlq. Operators replay them
via POST /api/v1/admin/webhooks/dlq/[id]/replay once the underlying issue is
fixed; until then they are durable via Prisma (WebhookEvent).
Note: this is not a queue in the streaming sense — it is a status flag on a
persisted row.
# Admin: list failed events from the Anchor pipeline.
curl -H "X-Admin-Key: $ADMIN_SECRET" \
"http://localhost:3000/api/v1/admin/webhooks/dlq?source=anchor&limit=20"See also: docs/WEBHOOK_RETRY_AND_DLQ.md.
An idempotency key is an opaque, client-generated string (typically a
UUID v4) the caller sends in the idempotency-key header on money-moving
POSTs (/api/remittance/send, /api/bills, /api/insurance, etc.) so that a
network retry, a double-click, or a client crash does not produce two
transfers. The middleware at lib/idempotency/middleware.ts stores the
response keyed by the string plus a hash of the body, replays the cached body
on hit (X-Idempotent-Replay: true), and returns 409 Conflict if the same
key arrives with a different body. TTL is 24h and the store is in-memory —
duplicate the write path in production with Redis. Generate one key per
logical operation, never per request.
const idempotencyKey = crypto.randomUUID()
await fetch('/api/remittance/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'idempotency-key': idempotencyKey, // reuse on retry
},
body: JSON.stringify({ recipient: 'G...ABC', amount: 100 }),
})See also: docs/IDEMPOTENCY.md.
The iron-session cookie (default name SESSION_COOKIE_NAME, conventionally
remitwise-session) is an encrypted, httpOnly cookie that ties a session to
the user's Stellar address. lib/session.ts uses the sealData/unsealData
helpers from the iron-session library
with SESSION_PASSWORD as the keying material; on login, the iron-session
helper is invoked with the verified Stellar address and the cookie is set for
SESSION_MAX_AGE (default 86 400 s / 24 h). The library uses
iron-session (no next-auth/next-iron-session), which is why Sentry
scrubbers (sentry.server.config.ts, instrumentation.ts) redact anything
matching the "iron-session": "..." field pattern.
import { sealData } from 'iron-session'
// After /api/auth/login verifies the signed nonce…
const sealed = await sealData(
{ address: 'G...PUBLIC', createdAt: Date.now() },
{ password: process.env.SESSION_PASSWORD!, ttl: 60 * 60 * 24 },
)
response.cookies.set('remitwise-session', sealed, {
httpOnly: true, sameSite: 'lax', secure: true,
})See also: docs/architecture.md → "Session & Auth",
lib/session.ts.
A Stellar ManageData operation is the on-chain instruction we use to
encode RemitWise "application state" actions (create / pay / cancel a bill,
create / pay / deactivate an insurance policy) without deploying a new
contract method per action. The server assembles an unsigned
TransactionBuilder whose sole op is manageData({ name: 'rw:<kind>', value: JSON.stringify(payload) }), then returns the XDR for the user to sign and
submit. This keeps the contract surface stable across app releases.
// lib/contracts/bill-payments.ts (simplified)
const op = Operation.manageData({
name: 'rw:create-bill',
value: JSON.stringify({ name, amount, dueDate }),
})
const tx = new TransactionBuilder(source, { fee, networkPassphrase })
.addOperation(op)
.setTimeout(30)
.build()
return { xdr: tx.toXDR() }See also: docs/bills-state-inventory.md, docs/SAVINGS_GOALS_IMPLEMENTATION.md.
The auth nonce is the 32-byte random hex string the server returns from
GET /api/auth/nonce?address=<G_PUBLIC_KEY> (the same handler also accepts
POST /api/auth/nonce with { address | publicKey: "G…" } in the JSON body),
and the client must sign with their Stellar wallet key as part of the
challenge-response login flow. The server stores it in lib/auth-cache.ts
against the caller's address with a 5-minute TTL; the same store is consulted
atomically on POST /api/auth/login via getAndClearNonce(address) so a
nonce cannot be consumed twice. The signed message is the byte representation
of the hex nonce (NOT a human-readable prefix), and the address is checked
with StrKey.isValidEd25519PublicKey first — bad addresses return 400 with
a hint referencing the ?address=G… syntax. This nonce is unrelated to the
CSP nonce emitted by middleware.ts.
# 1. Get nonce (GET-with-query OR POST-with-body are both supported)
curl -s -X POST http://localhost:3000/api/auth/nonce \
-H 'content-type: application/json' -d '{"address":"G...PUBLIC"}'
# → { "nonce": "9f86d081…", "address": "G...PUBLIC", "expiresAt": "2026-…" }
# 2. Sign nonce with wallet; then:
curl -s -X POST http://localhost:3000/api/auth/login \
-H 'content-type: application/json' \
-d '{"address":"G...PUBLIC","nonce":"9f86...","signature":"<base64>"}'
# → 200 + Set-Cookie: remitwise-session=...See also: docs/AUTH_IMPLEMENTATION.md, docs/AUTH_QUICK_REF.md, docs/CANONICALISATION.md → "Auth nonce bytes".
A flow is non-custodial when RemitWise never holds the user's private keys
and the server only ever returns an unsigned XDR for the client wallet to
sign. Every contract-mutating route (/api/remittance/build,
/api/v1/bills, /api/v1/insurance, family wallet, etc.) follows this rule;
in practice this means: a server can be compromised in ways that leak
metadata but cannot move funds. Sources of truth: docs/TRANSACTION_INTEGRATION.md,
the relevant route file's docstring, and the ReviewStep.tsx warning copy.
// app/api/remittance/build/route.ts — what we return to the client
return NextResponse.json({ xdr: transaction.toXDR() })
// We do NOT call transaction.sign(...) and we do NOT submit to Horizon.See also: docs/TRANSACTION_INTEGRATION.md, docs/REMITTANCE_FLOW.md.
Every request crossing the gateway is tagged with a request ID (header
X-Request-ID, internal constant API_REQUEST_ID_HEADER). middleware.ts
either adopts a valid client-supplied value or generates a fresh one with
generateRequestId() from lib/requestId.ts, then echoes it on the response
and threads it through every structured log line. Always include it in bug
reports — the value is surfaced in error toasts (Toast.tsx → diagnostics.requestId)
and pasted from the dev mode panel at the bottom-left of any page when
?dev=1 is appended to the URL.
curl -i http://localhost:3000/api/health \
-H 'X-Request-ID: support-ticket-42'
# Response headers include:
# X-Request-ID: support-ticket-42
# Server logs for this request are filterable by requestId="support-ticket-42".See also: docs/logging.md, docs/infrastructure.md.
The Sentry tunnel is the URL path that all Sentry browser requests are
proxied through so they bypass ad blockers: tunnelRoute: "/monitoring" is
configured in next.config.js via the @sentry/nextjs webpack plugin and all
*.sentry.io requests are rewritten to /monitoring?…. If you add a new
environment that talks to Sentry directly, do not route through the
canonical *.sentry.io hosts in the browser — your events will be silently
dropped by uBlock / Brave Shields / corporate DNS blockers. The same
next.config.js flag also turns on hideSourceMaps: true so source maps do
not leak to the browser bundle.
// next.config.js (excerpt)
const sentryWebpackPluginOptions = {
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN,
silent: !process.env.CI,
tunnelRoute: "/monitoring",
hideSourceMaps: true,
disableLogger: true,
}See also: README.md → "Sentry".
Stellar Ecosystem Proposals are the formal specs wallets and integrations implement against the network. RemitWise supports two of them in production:
- SEP-1 — Stellar TOML. Served from
/api/.well-known/stellar.tomlwith the requiredAccess-Control-Allow-Origin: *header so wallets can discover our anchor / signing key / endpoints. - SEP-38 — Anchor quote API. When
ANCHOR_API_BASE_URLis set,/api/remittance/quotedelegates to the anchor'sGET /quoteto return a compliant price quote for a(sell_asset, buy_asset, amount)triple.
# /.well-known/stellar.toml (excerpt)
VERSION = "2.0.0"
NETWORK_PASSPHRASE = "Test SDF Network ; September 2015"
SIGNING_KEY = "G...SIGNING_KEY"
TRANSFER_SERVER = "https://anchor.example.com"
WEB_AUTH_ENDPOINT = "https://auth.example.com/auth"See also: lib/contracts/network-resolution.ts,
docs/REMITTANCE_FLOW.md.
The Smart Money Split ("Split") is the percentage-based auto-allocation
of an incoming remittance across the four RemitWise buckets — Spending,
Savings, Bills, and Insurance. Until per-user overrides ship, the in-repo
default is DEFAULT_SPLIT_CONFIG = { spending: 50, savings: 30, bills: 15, insurance: 5 } (must sum to 100) from lib/remittance/split.ts; once the
Soroban remittance_split contract is wired in, live values are read via
resolveContractId('REMITTANCE_SPLIT') and the contract getter. Integer
rounding can leave a 1-unit remainder per allocation, which computeAllocation
absorbs into the Spending bucket to avoid silent loss for any other bucket.
The frontend surfaces the planned allocation as a preview on the Send screen;
on confirmation it returns the unsigned XDR using the same manageData
pattern as bills/insurance.
import { DEFAULT_SPLIT_CONFIG, computeAllocation } from '@/lib/remittance/split'
const amounts = computeAllocation(1000, DEFAULT_SPLIT_CONFIG)
// → { spending: 500, savings: 300, bills: 150, insurance: 50 }See also: docs/CONTRACT_INTEGRATION.md, docs/REMITTANCE_FLOW.md.
Soroban is Stellar's smart-contract platform. In the RemitWise frontend,
"the Soroban client" is the server-only SorobanRpc.Server instance
returned by getServer() from lib/soroban/client.ts — that is the canonical
client. lib/soroban-client.ts still exists but is deprecated and bundled
into the browser via NEXT_PUBLIC_SOROBAN_RPC_URL; never import it in new
server code. RPC URL and network passphrase resolve from
SOROBAN_RPC_URL and getSorobanNetworkPassphrase() respectively; contract
IDs resolve from per-network env vars (REMITTANCE_SPLIT_CONTRACT_ID_TESTNET,
etc.) via resolveContractId('REMITTANCE_SPLIT').
// lib/soroban/client.ts (real shape)
import { SorobanRpc } from '@stellar/stellar-sdk'
export function getServer(): SorobanRpc.Server {
if (!_server) {
const rpcUrl = process.env.SOROBAN_RPC_URL ?? 'https://soroban-testnet.stellar.org'
_server = new SorobanRpc.Server(rpcUrl, {
allowHttp: rpcUrl.startsWith('http://'), // only allow plain HTTP for local dev
})
}
return _server
}See also: docs/CACHE_STRATEGY.md,
lib/contracts/README.md.
The v1 namespace is the URL-prefix versioning convention for all
externally consumed API routes — /api/v1/remittance/history,
/api/v1/bills/[id]/pay, /api/v1/insurance/[id]/deactivate, etc. New
versioned routes live under app/api/v1/. The legacy /api/* paths are
rewritten to /api/v1/* at the Next.js layer (next.config.js) so existing
callers keep working unchanged. When a new major version is cut (v2), the
rewrite flips and v1 stays published for ≥6 months for migration.
# Both URLs reach the same handler today.
curl http://localhost:3000/api/remittance/history
curl http://localhost:3000/api/v1/remittance/historySee also: next.config.js → rewrites,
docs/architecture.md → "API Routes".
XDR (External Data Representation) is Stellar's base64-encoded binary
format for transactions and operation sets. Contract-mutating handlers in
RemitWise build an unsigned XDR server-side, return it as { xdr: string },
and expect the client to pipe it into the wallet (signTransaction(xdr))
before submission. Building helpers live in lib/contracts/*.ts and follow
the same shape: TransactionBuilder → addOperation(...) → setTimeout(30)
→ build() → tx.toXDR(). Type bytes are documented in
Stellar's XDR reference.
import { TransactionBuilder, Operation, Networks } from '@stellar/stellar-sdk'
const tx = new TransactionBuilder(sourceAccount, {
fee: '100',
networkPassphrase: Networks.TESTNET,
})
.addOperation(Operation.payment({ destination, asset, amount }))
.setTimeout(30)
.build()
return { xdr: tx.toXDR() }See also: docs/TRANSACTION_INTEGRATION.md, docs/api/savings-goals-transactions.md.