Architecture
Stellaroid Earn is a Next.js 15 (App Router) frontend deployed on Vercel that talks directly to a Soroban smart contract on Stellar testnet. There is no traditional backend — the chain is the system of record.
System overview
The system has two deployed pieces: a Next.js 15 (App Router) + React 19 frontend on Vercel, and a Soroban smart contract (Rust, soroban-sdk 22.0.0, compiled to wasm32v1-none) on Stellar testnet. All state — issuer records, certificate records, payment links, admin config — lives in contract storage. Reads reach the chain through Soroban RPC simulateTransaction; writes are signed in the user's wallet and submitted through the same RPC. The only server-side code is Next.js itself: React Server Components render proof pages, and four API routes: GET /api/health, GET /api/events and its /stream variant (read-only public event aggregation), and POST /api/fee-bump — an optional, bearer-token-gated endpoint that signs fee-bump transactions server-side with a dedicated testnet sponsor key.
Users (issuer / student / employer)
|
v
Next.js 15 frontend (Vercel)
config layer -> wallet layer (Freighter | Albedo) -> contract client
| |
| reads: simulateTransaction | writes: signTransaction -> sendTransaction
v v
Soroban RPC -- Stellar testnet
stellaroid_earn contract
- Issuer Registry (persistent storage)
- Certificate Store (persistent storage)
- Payment Links (persistent storage)
- Admin Config (instance storage)
XLM Stellar Asset Contract (SAC) - native assetAccess control is enforced in the contract, not the frontend: init, approve_issuer, suspend_issuer, and reward_student are admin-only; register_certificate, verify_certificate, revoke_certificate, and suspend_certificate require an approved issuer; register_issuer, link_payment, get_certificate, and get_issuer are public. Failures surface as a typed #[contracterror] enum with 17 variants.
Component breakdown
| Component | Location | Responsibility |
|---|---|---|
| Config layer | frontend/src/lib/config.ts | Reads NEXT_PUBLIC_* env vars (RPC URL, network, passphrase, contract ID, read address). The passphrase must match the network the contract is deployed on. |
| Wallet layer | frontend/src/lib/wallet/ + hooks/ | Provider registry with two wallets — Freighter (browser extension) and Albedo (web-based signer). Manages connection state, public key, network validation, and signTransaction. |
| Contract client | frontend/src/lib/contract-client.ts, contract-read-server.ts | Builds transactions with @stellar/stellar-sdk; handles ScVal argument serialization, return-value decoding, and error normalization. Server-side reads have a dedicated module for RSC use. |
| UI | frontend/src/app/ + components/ | App Router routes (/app, /issuer, /proof/[hash], /proof/[hash]/embed, /about, …). Wallet-touching components are "use client" because wallet APIs are browser-only; proof pages render server-side. |
| Smart contract | contract/src/lib.rs (separate deploy) | Issuer registry, certificate lifecycle, payment links. Persistent storage with 518,400–1,036,800 ledger TTLs; admin config in instance storage. |
| Soroban RPC | https://soroban-testnet.stellar.org (default) | Simulation for reads, submission and polling for writes, getEvents for recent contract events. |
| Stellar Expert | https://stellar.expert/explorer/testnet | Explorer links, plus a public contract-event index used to supplement RPC's limited event retention. |
| Friendbot | https://friendbot.stellar.org | Funds testnet accounts, including the read-only simulation address. |
Data flow: the three core actions
All writes follow one pipeline in contract-client.ts: build the invocation → simulateTransaction to assemble it → sign in the connected wallet → sendTransaction → poll getTransaction until a terminal status. Reads stop after simulation and never submit anything.
1. Credential issuance and verification (issuer signs)
- An issuer calls
register_issuer(public, signed by the issuer's own wallet); the admin then callsapprove_issuer. These emitiss_regandiss_apprevents. - The approved issuer registers a certificate via
register_certificate— the transaction is simulated, signed in Freighter or Albedo, submitted, and polled to confirmation. Emits acert_regevent carrying the proof hash. - The issuer attests the credential with
verify_certificate, which emitscert_ver. The student can now share the/proof/<hash>URL.
2. Public proof verification (nobody signs)
- Anyone opens
/proof/<hash>. The route validates the 64-character hex format before making any RPC call. - A React Server Component calls
get_certificatethroughsimulateTransaction, using the funded account inNEXT_PUBLIC_STELLAR_READ_ADDRESSas the transaction source. The simulation is never submitted — no signature, no fee, no on-chain footprint, and no events. - The page renders certificate status, issuer trust evidence, and a verification breakdown, and is CDN-cached with
revalidate=60. Client-side dashboard components use the same simulation mechanism directly for real-time state.
3. Employer payment (employer signs)
- An employer viewing a proof calls
link_payment(public), signing with their own wallet through the same simulate → sign → submit → poll pipeline. - XLM moves through the native Stellar Asset Contract (SAC) — no custom token, so graduates receive actual XLM with no trustline setup.
- The contract records a
PaymentRecord(payer, amount, linked certificate hash) and emits apaymentevent with the amount. Admin-initiatedreward_studentcalls emit a separaterewardevent.
Emitted events (init, iss_reg, iss_appr, iss_susp, cert_reg, cert_ver, reward, payment) are surfaced as public evidence via /api/events, a short-lived Server-Sent Events stream at /api/events/stream, and /status#metrics. Because RPC event retention only covers recent ledgers, the frontend supplements getEvents with Stellar Expert's public contract-event index and labels each item by source (rpc, stellar_expert, or e2e). This is display-grade evidence, not an audit-grade analytics store.
Deployment
| Component | Platform | Detail |
|---|---|---|
| Frontend | Vercel | stellaroid.tech |
| Contract | Stellar testnet | CDMUOHMARNVOJZM3IVOCJUPGBHDTHFBMZCCZXEZPQDVJGILH3NIKTTW3 |
| Source verification | Stellar Expert | Fresh security-hardened deploy; source re-verification pending |
Configuration is entirely env-var driven from lib/config.ts. The app refuses to operate meaningfully without NEXT_PUBLIC_SOROBAN_CONTRACT_ID and an RPC URL. Two test-only variables (NEXT_PUBLIC_E2E_MODE, NEXT_PUBLIC_PLAYWRIGHT) also flow through lib/config.ts but are gated to be inert outside local test runs, and NEXT_PUBLIC_CANONICAL_URL feeds the SEO/security layers.
| Variable | Default | Purpose |
|---|---|---|
NEXT_PUBLIC_STELLAR_RPC_URL | https://soroban-testnet.stellar.org | Soroban RPC endpoint |
NEXT_PUBLIC_STELLAR_NETWORK | TESTNET | Network name, mapped to the expected passphrase for wallet network checks |
NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE | Test SDF Network ; September 2015 | Must match the network the contract is deployed on |
NEXT_PUBLIC_SOROBAN_CONTRACT_ID | (none — required) | Deployed contract address |
NEXT_PUBLIC_SOROBAN_ASSET_ADDRESS | (none) | XLM SAC contract address |
NEXT_PUBLIC_SOROBAN_ASSET_CODE / _DECIMALS | XLM / 7 | Display metadata for the payment asset |
NEXT_PUBLIC_STELLAR_READ_ADDRESS | (none) | Funded testnet G... account used only as the source for read-only simulations (fund via Friendbot) |
NEXT_PUBLIC_STELLAR_ADMIN_ADDRESS | (none) | Admin public key, used for role detection in the UI |
NEXT_PUBLIC_FEE_SPONSOR_ADDRESS | (none) | Fee-sponsorship account; sponsor signing sits behind server auth with contract/method/fee validation |
NEXT_PUBLIC_STELLAR_EXPLORER_URL | https://stellar.expert/explorer/testnet | Explorer link base |
One build-time value is set automatically in next.config.ts: NEXT_PUBLIC_SW_BUILD is derived from VERCEL_GIT_COMMIT_SHA (first 12 characters, with a timestamp fallback) and stamps the service worker registration URL per deploy.
Notable technical decisions
Reads via simulation with a dedicated read address
Public proof verification must not require a wallet — that is critical for employer adoption. Read calls are built as normal contract invocations but only ever passed to simulateTransaction, sourced from the funded read-only account. Nothing is signed or submitted, so verification is free, anonymous, and cacheable (revalidate=60 on proof pages).
Nonce-based CSP assembled in middleware
Static security headers (X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy, Permissions-Policy, and 2-year preloaded HSTS) come from next.config.ts. The Content-Security-Policy itself is built per-request in src/middleware.ts: a fresh nonce is generated with crypto.getRandomValues, passed to the app via an x-nonce header, and embedded in script-src — so production scripts run without 'unsafe-inline'. connect-src is restricted to 'self' and https://*.stellar.org. frame-ancestors is 'none' everywhere except /proof/<hash>/embed (matched against the 64-hex pattern), which allows framing so proofs can be embedded; CSP frame-ancestors takes precedence over X-Frame-Options in modern browsers.
default-src 'self';
script-src 'self' 'nonce-<per-request>' https://va.vercel-scripts.com;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: blob:;
worker-src 'self';
manifest-src 'self';
connect-src 'self' https://*.stellar.org;
frame-src 'none';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none' (frame-ancestors * on /proof/<hash>/embed only)A deliberately conservative service worker
The PWA service worker (frontend/public/sw.js) is scoped narrowly because the app's data is on-chain and staleness is dangerous:
- Navigations: network-first, falling back to a cached copy of that page, then
/offline.html(page cache capped at 30 entries). /_next/static/*: cache-first — these assets are content-hashed and immutable.- Same-origin images, fonts, and icons: stale-while-revalidate.
/api/*and all cross-origin requests (Soroban RPC, fonts CDN): untouched — always network.- Framed navigations (the
/proof/<hash>/embedroute) are skipped entirely, because a cached fallback cannot reproduce the route'sframe-ancestorsheaders.
The worker is registered as /sw.js?v=<build id> and names its caches with that version, so each deploy installs a new worker, refreshes the precache, and purges the previous deploy's caches. next.config.ts additionally serves /sw.js with Cache-Control: no-cache, no-store, must-revalidate so the new worker takes over quickly.
Verification pages are never served from cache
Pages under /proof, /talent, and /opportunity are explicitly excluded from the service worker's page cache. The comment in sw.js states the rationale: a stale on-chain verdict is worse than no page — a revoked credential replayed from cache as "verified" would invert the product's core guarantee. If the network is unavailable, these routes fall through to the offline page rather than a cached verdict.
Other decisions
| Decision | Rationale |
|---|---|
| Soroban over classic Stellar | The issuer trust layer and credential lifecycle states need custom logic that classic offers/payments cannot express |
| XLM via SAC, not a custom token | Graduates receive actual XLM; no trustline to a custom asset required |
| Persistent storage with long TTLs | Credentials should outlive short-term contract state; 518,400–1,036,800 ledger TTLs provide months of persistence |
Typed #[contracterror] enum | Clear, actionable errors instead of opaque integer codes |
| Fee sponsorship behind server auth | Bearer authorization plus contract/method/fee validation prevents arbitrary public XDR from being sponsor-signed |
| Public indexer fallback for event metrics | RPC event retention covers only recent ledgers; Stellar Expert's index keeps older public evidence visible without claiming first-party analytics |
Frequently asked questions
- Does Stellaroid Earn have a backend server?
- No traditional backend. The Soroban contract on Stellar testnet is the system of record. The only server-side code is the Next.js app itself: React Server Components that render proof pages via read-only simulation, and four API routes: GET /api/health, GET /api/events and its /stream variant (read-only public event aggregation), and POST /api/fee-bump — an optional, bearer-token-gated endpoint that signs fee-bump transactions server-side with a dedicated testnet sponsor key.
- How do proof pages verify credentials without a wallet?
- The /proof/[hash] route validates the 64-character hex hash, then calls the contract's get_certificate function through Soroban RPC simulateTransaction, using a dedicated funded read-only address as the transaction source. Simulations are never signed or submitted, so verification requires no wallet, no login, and no fee.
- Which wallets are supported for signing?
- Two providers are registered in the wallet layer: Freighter (browser extension) and Albedo (web-based signer, which enables mobile signing). All writes go through the connected provider: build transaction, simulate, sign in the wallet, submit via sendTransaction, then poll getTransaction until confirmed.
- Is anything deployed to mainnet?
- No. This is an early-access pilot on Stellar testnet only. The contract (CDMUOHMARNVOJZM3IVOCJUPGBHDTHFBMZCCZXEZPQDVJGILH3NIKTTW3) is deployed to testnet, the default RPC endpoint is soroban-testnet.stellar.org, and all payment flows use testnet XLM.
- Why doesn't the service worker cache proof pages?
- Because a stale on-chain verdict is worse than no page. If a revoked credential were replayed from cache as verified, it would invert the product's core guarantee. Pages under /proof, /talent, and /opportunity always hit the network, and /api/* plus cross-origin RPC requests bypass the service worker entirely.
Run it yourself on testnet
Everything documented here is live in the early-access pilot — free, testnet-only, and auditable on stellar.expert.