Next.js 14 frontend for Novatip.
Next.js 14 App Router, TypeScript 5, Tailwind CSS 3, Freighter via @novatip/sdk, qrcode.react, canvas-confetti, React context.
Prerequisites: Node.js 20 (pinned in .nvmrc, matching CI — run nvm use), novatip-backend on port 3001, Freighter browser extension
npm install
cp .env.example .env.local
npm run dev
App available at http://localhost:3000
The quick-start above assumes a backend already running at port 3001. This section covers the full path from a clean checkout to a working tip page — including PostgreSQL, Redis, a database migration, a deployed contract, and the SDK build.
The three services are separate repositories. Check them out as siblings in the
same parent directory so the relative path ../novatip-sdk resolves correctly:
parent/
├── novatip-web/ ← this repo
├── novatip-backend/ ← https://github.com/Novatip/novatip-backend
└── novatip-sdk/ ← https://github.com/Novatip/novatip-sdk
git clone https://github.com/Novatip/novatip-backend ../novatip-backend
git clone https://github.com/Novatip/novatip-sdk ../novatip-sdk
The backend requires PostgreSQL (for the database) and Redis (for job queues and session state). The simplest way to run them locally is Docker Compose, but any running instances work:
docker run -d --name novatip-pg -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgres:16
docker run -d --name novatip-redis -p 6379:6379 redis:7
cd ../novatip-backend
npm install
cp .env.example .env
Edit .env to set DATABASE_URL and REDIS_URL, then run the database
migration and start the dev server:
npm run db:migrate # applies all Prisma migrations
npm run dev # starts on http://localhost:3001
See the novatip-backend README for the full list of required environment variables (JWT secret, Stellar RPC URL, etc.).
When you install this repo's dependencies with npm install, the SDK is
fetched directly from GitHub (see the @novatip/sdk entry in package.json).
No separate build step is needed for normal frontend work.
In package.json, @novatip/sdk is pinned to an exact commit SHA:
"@novatip/sdk": "github:Novatip/novatip-sdk#eeb581655ecc20b7a27ba16705bab62311e491b4"The pin exists because an unpinned specification (such as pointing to a branch or loose tag) allowed a stale copy of the package to survive in deployment caches (e.g. Vercel and CI). This resulted in production builds deploying against an outdated SDK version that no longer matched updated contract interfaces or backend endpoints.
Caution: Do not loosen the dependency spec (e.g. to
github:Novatip/novatip-sdk#main). Loosening the spec reintroduces the deployment cache bug.
If you want to work on the SDK and see your changes reflected in this app without publishing a new commit, switch the dependency to the local checkout:
- Edit
package.jsonand change:to:"@novatip/sdk": "github:Novatip/novatip-sdk#<commit>"
"@novatip/sdk": "file:../novatip-sdk"
- Build the SDK:
cd ../novatip-sdk npm install npm run build - Re-link in this repo:
cd ../novatip-web npm install
Revert the package.json change before opening a pull request.
When changes to novatip-sdk are merged and need to be pulled into this app:
- Push or merge the changes in
novatip-sdkand copy the full 40-character commit SHA. - Update
package.jsonand regeneratepackage-lock.json:or editnpm install github:Novatip/novatip-sdk#<commit-sha>
package.jsonwith the new commit SHA and runnpm install. - Verify the lockfile uses HTTPS, not SSH:
Check
package-lock.jsonto confirm that theresolvedentry for@novatip/sdkuses an HTTPS URL:The lockfile must use an https URL, not ssh ("resolved": "git+https://github.com/Novatip/novatip-sdk.git#<commit-sha>"
git+ssh:orgit@github.com:). Automated CI environments and deployment platforms (e.g., Vercel) build without SSH keys, and an SSH URL in the lockfile will fail the deployment build. - Verify tests and types:
npm run typecheck npm test
The app cannot process tips without a deployed Soroban contract. Follow the
novatip-backend deployment guide
to deploy tip_splitter to Testnet and copy the returned contract address.
cd ../novatip-web
npm install
cp .env.example .env.local
Edit .env.local and set NEXT_PUBLIC_TIP_SPLITTER_CONTRACT_ID to the address
from step 5. The other variables have working defaults for a local Testnet setup.
npm run dev # http://localhost:3000
At this point, opening http://localhost:3000 should show the landing page, and
navigating to /onboarding with a Testnet Freighter wallet should complete the
full tip flow.
Copy .env.example to .env.local and fill in the values before running npm run dev.
Variables marked required are validated at build/boot time. The app throws a descriptive error naming the variable if one is absent or malformed — the failure happens before any request is served, not mid-funnel when a supporter presses Tip.
| Variable | Required | Default | Notes |
|---|---|---|---|
NEXT_PUBLIC_TIP_SPLITTER_CONTRACT_ID |
yes | — | 56-char Soroban contract ID starting with C. Missing or malformed → build/boot error. |
NEXT_PUBLIC_API_URL |
no | http://localhost:3001/api/v1 |
Backend API base URL. Override in staging/production. |
NEXT_PUBLIC_SITE_URL |
no | http://localhost:3000 |
Public origin for Open Graph metadataBase. Must be an absolute http(s) URL. A malformed value fails the build. |
NEXT_PUBLIC_STELLAR_NETWORK |
no | testnet |
testnet or mainnet. |
NEXT_PUBLIC_USDC_CONTRACT_ID |
no | CBIELTK6…QDAMA |
USDC SAC address. Override only for custom local networks. |
This is the only variable that is truly required. Without it the SDK cannot
build a transaction and the tip button will always throw. The validation in
src/lib/config.ts catches both a missing value and a malformed one (wrong
length, wrong prefix) at the time the config module is first evaluated — during
next build or at server startup — so a misconfiguration fails loudly before
any user sees the app.
This one has to be set per deployment rather than once in the repo — production gets the real domain, each preview deployment gets its own host.
It backs metadataBase in src/app/layout.tsx, which is what every relative
metadata URL is resolved against. Open Graph images are the reason it matters:
tip links spread by being pasted into Twitter, WhatsApp and Discord, and a
preview image still pointing at localhost:3000 is one no scraper can fetch.
It must be an absolute http or https URL — https://novatip.xyz, not
novatip.xyz. A malformed value fails the build with a message naming it,
rather than falling back to localhost and shipping broken previews; see
resolveSiteUrl in src/lib/config.ts. Trailing slashes, whitespace, and any
query or fragment are normalised away, so https://novatip.xyz and
https://novatip.xyz/ are equivalent.
NEXT_PUBLIC_ variables are substituted into the client bundle at build
time by Next.js matching the exact literal text process.env.NEXT_PUBLIC_X
in source. A computed lookup — process.env[key] or
process.env[\NEXT_PUBLIC_${name}`]— is **never replaced** and evaluates toundefined` in the browser.
The failure mode is subtle: the computed lookup works fine in npm run dev
(Node has access to the real process.env), so a broken read is invisible
locally and only surfaces in production after a deploy.
src/lib/config.ts is written to respect this rule: every variable is read as
a direct literal member access (process.env.NEXT_PUBLIC_FOO) rather than
through a shared helper that takes a variable name. Do not refactor this into a
loop or a helper function that receives the key as a string argument — every
value will be undefined in the browser.
The app deploys to Vercel from this repository.
Set every variable from the Environment Variables
table above in the Vercel project (Settings → Environment Variables), for
each environment you deploy (Production, Preview, Development). Vercel never
reads .env.local — that file is local-development-only and is not part of
the deployment.
| Variable | Set it on Vercel? | Why |
|---|---|---|
NEXT_PUBLIC_TIP_SPLITTER_CONTRACT_ID |
Always. | No default. The build fails without it (see above). |
NEXT_PUBLIC_SITE_URL |
Always. | Defaults to http://localhost:3000, which is wrong for any deployed environment. Set it to the real domain for Production, and to the deployment's own preview URL for Preview deployments — Open Graph images resolve against this. |
NEXT_PUBLIC_API_URL |
Always. | Defaults to http://localhost:3001/api/v1. Set it to the deployed novatip-backend's URL, or every dashboard panel and the public tip page will fail to load data. |
NEXT_PUBLIC_STELLAR_NETWORK |
Only to deploy against Mainnet. | Defaults to testnet, which is correct for a staging/preview deployment. Production against real funds needs mainnet. |
NEXT_PUBLIC_USDC_CONTRACT_ID |
Rarely. | The default is the well-known SAC address for both Testnet and Mainnet. Only override for a custom network (e.g. a private Futurenet node). |
{ "installCommand": "npm ci" }This exists for the same reason @novatip/sdk is pinned to an exact commit
(see Why the SDK is pinned to an exact
commit above): an unpinned SDK
spec previously let a stale copy of the package survive in Vercel's build
cache, shipping production against an SDK version that no longer matched the
deployed contract. npm ci installs strictly from package-lock.json and
fails rather than silently re-resolving if the lockfile and
package.json disagree. Vercel's zero-config default (npm install) does
not give that guarantee — it will happily rewrite the lockfile to make things
line up, which is exactly the drift that let the stale-SDK bug happen. Do not
remove installCommand from vercel.json; doing so re-opens it.
NEXT_PUBLIC_ variables are baked into the client bundle at build time
(see How NEXT_PUBLIC_ variables are
read
above). Editing one in the Vercel dashboard does not change anything about an
already-built deployment — the live site keeps serving the bundle built with
the old value until a new build runs. After changing a variable, trigger a
redeploy (push a commit, or use Deployments → Redeploy in the Vercel
dashboard) — do not expect the change to take effect on its own.
Cause: The contract ID was not set before starting the dev server. The config
module is evaluated at server start (and during next build), so a missing value
throws immediately — before any page is served.
Fix: Copy .env.example to .env.local and set NEXT_PUBLIC_TIP_SPLITTER_CONTRACT_ID
to the 56-character Soroban contract address returned when you deployed the
tip_splitter contract. If you have not deployed it yet, see the
novatip-backend README for the
stellar contract deploy step.
Cause: The SDK is declared as a file:../novatip-sdk dependency, so it
resolves to a sibling directory checkout rather than a registry package. The
package exports from ./dist/index.js — if that folder does not exist yet
(a fresh clone has no dist/), Node cannot resolve the import and npm run dev
fails before the server starts.
Fix:
# in the sibling SDK checkout
cd ../novatip-sdk
npm install
npm run build
# back in this repo
cd ../novatip-web
npm install # re-links the built package into node_modules
npm run dev
Cause: The frontend fetches data from NEXT_PUBLIC_API_URL (default
http://localhost:3001/api/v1). If the backend is not running, every component
that calls lib/api.ts receives a network error and renders its error state.
Fix: Start the backend. See the
novatip-backend README for how to
bring it up (PostgreSQL, Redis and a database migration are needed first). If
your backend is on a different port, set NEXT_PUBLIC_API_URL in .env.local
before restarting the dev server.
Cause: Freighter is connected to a different Stellar network than the one
the app targets (NEXT_PUBLIC_STELLAR_NETWORK, default testnet). The mismatch
is detected at signing time, after the transaction has already been built and
simulated — the error only appears when the user clicks Tip.
Fix: Open Freighter, go to Settings → Network, and switch to the same
network the app uses. For local development that is almost always Testnet. If
you are running against a private Futurenet node, set
NEXT_PUBLIC_STELLAR_NETWORK=futurenet (and NEXT_PUBLIC_USDC_CONTRACT_ID to
match) in .env.local.
Cause: The Freighter extension has a different account selected than the one
that was connected when the session started. The transaction is built for the
connected address, so a signature from a different key is rejected by the
network as txBAD_AUTH. The app detects the mismatch before building the
transaction and surfaces a readable message instead of raw XDR.
Fix: Either:
- Switch to the correct account in Freighter (the one shown in the error), or
- Click Disconnect in the app and reconnect — the new connect flow will pick up whichever account is currently active in Freighter.
Cause: Every recipient in a split must hold a USDC trustline before the jar
can be saved. The tip_splitter contract pays all recipients atomically, so a
single account without a trustline causes the entire tip to fail — and it fails
for the supporter, who did nothing wrong and cannot fix it. The app checks
truslines at save time so the creator can act on it before any supporter is
affected.
The full message is one of:
<addr> cannot receive USDC yet. That account needs to add a USDC trustline before it can be paid, otherwise every tip to this jar fails.
These accounts cannot receive USDC yet: <addr1>, <addr2>. Each needs to add a USDC trustline before it can be paid, otherwise every tip to this jar fails.
Fix: Each flagged account must add a USDC trustline. In Freighter:
- Open Freighter and switch to the flagged account.
- Go to Manage Assets → Add Asset.
- Search for USDC and add the Circle-issued token on the correct network
(Testnet or Mainnet to match
NEXT_PUBLIC_STELLAR_NETWORK). - Once added, retry saving the splits in the dashboard.
A freshly funded Stellar account has no trustlines by default — this step is required for any new collaborator account before it can appear in a split.
This list reflects all routes under src/app. Each route is verified against
the directory structure rather than written from memory.
/ - Home landing page /[slug] - Public tip page for a creator /onboarding - Creator onboarding wizard /dashboard - Creator earnings overview /dashboard/history - Tip history and transaction log /dashboard/qr - QR code and share link /dashboard/settings - Profile and notification preferences /dashboard/splits - Collaborator splits manager /dashboard/webhooks - Webhook configuration and testing
This table maps each screen to the backend API endpoints it calls. The mapping
is derived from src/lib/api.ts (which defines all client methods) and the
screen component files under src/app.
Endpoints marked [NOT IMPLEMENTED] do not yet exist on the backend.
| Screen | Endpoints called | Notes |
|---|---|---|
/ (home) |
None | Static landing page |
/[slug] (tip page) |
GET /resolve/:slug |
Fetches creator profile, splits, and recent tips |
/onboarding |
GET /auth/meGET /creators/:slugPOST /creators/claim |
Checks session, fetches existing creator data if resuming, claims slug |
/dashboard (overview) |
GET /auth/meGET /creators/:slugGET /analytics/totals |
Fetches creator profile and earnings summary |
/dashboard/history |
GET /analytics/recent |
Paginated tip history with offset |
/dashboard/qr |
GET /auth/me |
Fetches slug to generate QR code client-side |
/dashboard/settings |
GET /auth/meGET /creators/:slugPATCH /creators/mePATCH /creators/me/slugGET /creators/check/:slug |
Profile editing and slug rename |
/dashboard/splits |
GET /auth/meGET /creators/:slugPATCH /creators/me/splits |
Collaborator split configuration |
/dashboard/webhooks |
GET /webhooks [NOT IMPLEMENTED]POST /webhooks [NOT IMPLEMENTED]DELETE /webhooks/:id [NOT IMPLEMENTED]POST /webhooks/:id/ping [NOT IMPLEMENTED] |
Webhook management UI is built but backend routes do not exist yet |
The SIWS (Sign-In With Stellar) flow is used across all authenticated screens:
POST /auth/challenge— request a nonce for the wallet to sign- Wallet signs the nonce (client-side, via Freighter)
POST /auth/verify— exchange the signed nonce for a JWT
The JWT is stored in localStorage.novatip_jwt and included in the
Authorization: Bearer <token> header for all subsequent authenticated requests.
A tip page answers on two URL shapes:
| Shape | Example | Notes |
|---|---|---|
/alice |
https://novatip.xyz/alice |
Canonical. Share this link and use it in QR codes. |
/@alice |
https://novatip.xyz/@alice |
Also resolves. Both forms reach the same page. |
The @ belongs to the jar ID, not to the URL. When a creator claims the
slug alice, the corresponding on-chain jar is registered as @alice. The web
URL is /alice (without the @). Visiting /@alice also works — the page
component strips the leading @ via normalizeSlug before resolving the
creator — but /alice is the form the app generates for share links and QR
codes.
Relationship between slug and jar ID:
- Slug (
alice) — the identifier stored in the backend database and used in all web URLs. - Jar ID (
@alice) — the on-chain identifier passed to thetip_splittercontract. Derived from the slug by prepending@. SeejarIdForSluginsrc/lib/jar.ts.
This distinction matters when reading contract state or events: the contract
always uses @alice, while the REST API and web routes always use alice.
- Visitor opens /@alice
- Creator profile loads from backend resolver API
- Visitor connects Freighter wallet
- Visitor picks amount (///0/5 or custom) and optional message
- TipSplitterClient builds and simulates Soroban transaction
- Freighter prompts for signature
- Transaction confirmed on Stellar
- Confetti fires, success screen shown
- Backend indexer picks up TipReceived event within ~6 seconds
- Dashboard analytics update live
TipForm (src/components/TipForm.tsx) is a small state machine. The current
state is step, typed as FormStep: input, confirm, signing, success
or error. The wallet is not a step — it is a condition checked while in
input (see Guards).
stateDiagram-v2
[*] --> input
input --> signing: Send tip, under $100
input --> confirm: Send tip, $100 or more
confirm --> input: Cancel
confirm --> signing: Confirm
signing --> success: tip() resolves
signing --> error: tip() throws
error --> input: Retry
success --> input: TipSuccess onReset
| State | What the user sees | Leaves when |
|---|---|---|
input |
Amount picker, message box, send button. Without a connected wallet the button is replaced by a connect prompt. | The send button is clicked (handleCtaClick). |
confirm |
A warning naming the amount, with Cancel and Confirm. The message box is disabled. | Cancel returns to input; Confirm calls handleTip. |
signing |
Picker and message box disabled, button shows "Waiting for signature…". The wallet prompt is open. | client.tip() resolves (success) or throws (error). |
success |
TipSuccess replaces the whole form. tipEvents is emitted and the amount is saved to localStorage. |
onReset returns to input with the last tip amount and an empty message. |
error |
Red banner with the error message and a Retry button. | Retry (handleRetry) returns to input and clears the error. |
A rejected wallet prompt, a failed simulation and a failed submission all land
in error. The banner shows err.message as thrown; the form does not decode
it (see Error taxonomy).
handleCtaClick sends the user to confirm when isLargeTip(amount) is true,
and straight to handleTip otherwise. isLargeTip and its threshold,
LARGE_TIP_CONFIRM_USDC = 100, live in src/lib/tipAmount.ts. The comparison
is inclusive, so exactly 100 asks for confirmation. Presets stop at $25, so in
practice only the custom field reaches it.
Every guard below must hold before client.tip() is called, so before the
wallet prompt opens. They are checked in two places: canSubmit decides
whether the send button is enabled, and handleTip re-checks a subset when it
runs.
| Guard | Stops the tip when | Where it lives | Checked by |
|---|---|---|---|
| Wallet connected | No wallet is connected. The connect prompt replaces the button. | isConnected from useWallet(); src/contexts/WalletContext |
canSubmit; the isConnected ? … : … branch of the CTA. handleTip also returns early without publicKey. |
| Valid amount | The amount is not a positive number the SDK accepts. | amountValid = isValidTipAmount(usdcToStroops(amount)) from @novatip/sdk |
canSubmit and handleTip |
| Jar has recipients | splits is empty, so the contract would have nobody to pay. The button reads "Jar not configured". |
hasRecipients in TipForm.tsx |
canSubmit and handleTip |
| Enough balance | The amount is more than the supporter's USDC balance. Skipped while the balance is unknown. | insufficientBalance; balance from getUsdcBalance in src/lib/balance.ts |
canSubmit only |
| Message length | The trimmed message is over 280 bytes (not characters). | utf8ByteLength and MAX_MESSAGE_BYTES in src/lib/tipMessage.ts |
canSubmit only |
| Idle form | The form is in any step other than input, so a second submit cannot start while one is in flight. |
step === "input" |
canSubmit only |
Not a blocker, but shown: when a collaborator's share would round to zero,
zeroPaidCount drives a warning in the form. It does not disable the button.
These are how the code behaves today, not how it is meant to behave. Each is a candidate for its own issue.
- Retry cannot be clicked. In
error, the button isdisabled={!canSubmit}andcanSubmitrequiresstep === "input", so the Retry button (handleRetry) is always disabled. Nothing else resetsstep, soerroris a dead end until the page is reloaded. - The wrong-account check is not applied to tips.
TipFormimportsassertActiveAccountbut never calls it; onlysrc/lib/jar.tsdoes (split saves).makeSignTransaction(publicKey)only tells Freighter which address to use; it does not compare it with the active account. A tip signed on the wrong account fails at submission, andTipFormshows the raw error rather than thedescribeSubmissionErrortext. See Wallet account requirements. - The $1000 ceiling is not checked in
TipForm.isWithinTipCeiling(MAX_CUSTOM_TIP_USDC) is imported but unused here.AmountPickeruses it to show its own error, andlastTipAmount.tsuses it to reject stored values, butcanSubmitrelies onisValidTipAmountalone.tipAmount.tsnotes the real SDK has no upper bound of its own, so the ceiling only holds if the SDK enforces one. confirmre-checks less thaninput. The amount picker stays enabled inconfirm(only the message box is disabled), and Confirm callshandleTip, which skips the balance, message-length and idle-form checks. Changing the amount while the confirmation is open therefore bypasses them.
- Connect wallet (Freighter SIWS)
- Claim unique slug (e.g. alice for /@alice)
- Configure collaborator splits (optional)
- Download QR code and share tip link
| Wallet | Status | Notes |
|---|---|---|
| Freighter | Supported | The only wallet integrated today. Required for connecting, tipping, and creator onboarding. |
| Other Stellar wallets | Not supported | WalletConnect / SEP-07 integration is planned but not yet implemented. |
Freighter is a browser extension. If it is not installed when a visitor presses Connect, the app shows:
Freighter wallet extension not found. Install it from freighter.app, then reload this page.
Freighter is not available on mobile browsers. A visitor on a phone cannot connect a wallet, which means:
- Tipping requires a desktop browser with the Freighter extension installed.
- Viewing a creator's tip page (the
/@slugroute) works on any device — the page loads, shows the creator's profile, and displays the tip form, but the Connect button will fail if Freighter is absent.
Mobile wallet support (via WalletConnect or a similar deep-link protocol) is on the roadmap but not currently implemented.
The app is developed and tested against the following desktop browsers:
| Browser | Status |
|---|---|
| Chrome / Chromium | Primary development target |
| Firefox | Tested — Freighter supports it |
| Edge (Chromium) | Tested — Freighter supports it |
| Safari | Not tested — Freighter is not available on Safari |
| Brave | Tested — Freighter supports it |
Any browser that supports the Freighter extension and modern ES2020 features
(optional chaining, nullish coalescing, Promise.allSettled) is expected to
work. No IE11 or legacy-browser polyfills are included.
The app stores the following keys in localStorage. This list is verified
against the constants in src/contexts/WalletContext.tsx, src/lib/theme.ts,
and src/lib/lastTipAmount.ts.
| Key | Contents | Removed by | Sensitive? |
|---|---|---|---|
novatip_jwt |
Session JWT issued by the backend after wallet sign-in (SIWS). Used to authenticate creator dashboard requests. | Session expiry (detected client-side), any 401 response from the backend, or explicit disconnect. | Yes — grants read access to the creator's dashboard. Does not control funds or allow writes without a fresh wallet signature. |
novatip_pk |
Connected wallet's public key (Stellar address). Identifies which account is connected for tipping and dashboard access. | Explicit disconnect or any 401 response. | No — public keys are inherently public. |
novatip_theme |
User's explicit theme preference: "light", "dark", or absent (meaning "system"). |
Switching back to system preference removes the key. | No. |
novatip_last_tip_amount |
The last amount entered in the tip picker. Pre-fills the amount field on the next visit. | Never removed automatically. Invalid values (non-numeric, exceeds ceiling) are ignored on read. | No. |
- Stuck session or "unauthorized" loop: Clear
novatip_jwt. The next connect will generate a fresh session. - Testing a first-time visitor: Clear all four keys. This resets the app to the state of someone who has never connected or visited before.
- Freighter account mismatch errors: Clear
novatip_pkandnovatip_jwt, then reconnect with the correct account active in Freighter.
Only novatip_jwt holds anything sensitive. See the Session token storage:
localStorage and its tradeoffs
section for why the session is stored in localStorage instead of an httpOnly
cookie, what risks that carries, and what limits the damage.
Two conditions must be met before a tip or a split save can succeed. Both are enforced in code and produce clear messages, but they are worth knowing before you test with fresh accounts.
Freighter signs with whichever account is currently active in the extension.
The transaction is built for the address that was connected at login, so if you
switch accounts in Freighter between connecting and tipping, the network rejects
the submission as txBAD_AUTH. The app catches this before submitting and shows:
Freighter is currently on XXXX…XXXX but this action is for YYYY…YYYY. Switch accounts in Freighter, or reconnect your wallet to use the active one.
The guard lives in src/lib/wallet.ts (assertActiveAccount). Split saves call
it (src/lib/jar.ts); TipForm does not yet — see
TipForm states.
On Stellar an account cannot hold an asset it has not explicitly opted in to.
The tip_splitter contract pays all split recipients in a single atomic call,
so one collaborator without a USDC trustline fails the whole tip. The app checks
truslines when splits are saved rather than when a tip arrives, so the creator
(not the supporter) sees the error at the moment they can act on it:
<addr> cannot receive USDC yet. That account needs to add a USDC trustline before it can be paid, otherwise every tip to this jar fails.
A freshly created Stellar account has no trustlines. Any new collaborator must
add the USDC asset in Freighter (Manage Assets → Add Asset) before being
added to a split. The guard lives in src/lib/trustline.ts
(assertRecipientsCanReceive).
- Request nonce: POST /auth/challenge
- Freighter signs the nonce
- Verify signature: POST /auth/verify
- JWT stored in localStorage
- JWT attached to all authenticated API requests
The creator session JWT is stored in localStorage rather than an httpOnly cookie. This section documents why that choice was made and what security costs it carries.
Cross-origin architecture
The frontend (novatip.xyz) and backend API (api.novatip.xyz) are separate origins. An httpOnly cookie set by the API cannot be read by JavaScript (which is the security benefit), but it also cannot be sent with fetch() requests to the API unless:
- The API sets
Access-Control-Allow-Credentials: true, and - Every fetch includes
credentials: 'include', and - The
Access-Control-Allow-Originresponse header is set to the exact frontend origin (wildcards are forbidden with credentials).
This works, but it makes local development harder (every developer needs matching CORS config) and makes the API harder to use from other clients (mobile apps, CLI tools, third-party integrations). localStorage with a bearer token in the Authorization header has none of these constraints.
No server-side rendering for authenticated pages
The creator dashboard pages are client-side only ("use client" directive). There is no server component that needs to read the session to pre-render personalized HTML. If the session lived in an httpOnly cookie, the Next.js server still could not read it (the cookie is httpOnly precisely so JavaScript cannot), so SSR would not work anyway.
Because the dashboard is always hydrated client-side, there is no SSR benefit to giving up the flexibility of localStorage.
The tip flow does not involve the session
Sending a tip is an on-chain transaction: the tipper signs it with their wallet and submits it directly to the Stellar network. The backend is not in that path — there is no API call, so there is no session token to protect. The session is only needed for creator-facing features (onboarding, splits, analytics), and those are behind wallet signature verification (SIWS) at sign-in time.
A compromised session token lets an attacker read a creator's dashboard. It does not let them:
- Steal funds (tips go to on-chain addresses, not to "whoever holds the session")
- Change splits or claim a new slug (those endpoints re-verify the wallet signature)
- Send tips on someone else's behalf (tips are signed by the sender's wallet, not by a session)
The blast radius is dashboard read access, not write or financial access.
Script injection can read the token
Any script running on the page can call localStorage.getItem("novatip_jwt") and exfiltrate the session. This includes:
- A compromised third-party script (analytics, ads, a supply-chain attack on a dependency)
- A stored XSS vulnerability (if user input is ever rendered unescaped — this app does not render user HTML, but the risk is non-zero)
- A browser extension with overly broad permissions
An httpOnly cookie would be invisible to all of those, which is why it is the default recommendation for session tokens.
Mitigations in place:
-
Short token expiry: The JWT expires after a limited time (configured server-side). An exfiltrated token becomes useless once it expires, limiting the window for abuse.
-
No write operations without wallet re-verification: Sensitive endpoints (claiming a slug, updating splits) require a fresh wallet signature, not just the session token. A stolen JWT grants read access to the dashboard but cannot change on-chain state or modify the creator's profile.
-
Server remains the authority: The token's
expclaim is checked client-side as a UX optimization (to skip a guaranteed-failing request), but the server validates every token on every request. A manipulated or forged token is rejected immediately. -
Minimal third-party scripts: The app includes no analytics, no ads, and no third-party scripts beyond the essential SDK and wallet libraries. The attack surface for script injection is kept small.
-
Content Security Policy (future work): A strict CSP would block inline scripts and restrict script sources to a known allowlist, further reducing XSS risk. This is not currently implemented but is planned.
What is not protected:
-
If an attacker gains script execution on the page (via XSS or a compromised dependency), they can read the session token and any other
localStoragedata. This is the inherent cost of client-side storage. -
A session token does not prove wallet ownership at request time — it proves it was owned at sign-in. If a creator's wallet is compromised separately (phishing, leaked seed phrase), the session token does not add protection, but it also does not make things worse (the attacker already has the wallet, which is the real credential).
| Defense | What it prevents |
|---|---|
| Short token expiry | An exfiltrated token becomes useless after expiry (hours, not days or weeks) |
| Wallet re-verification on writes | Stolen session cannot change splits, claim slugs, or modify on-chain state |
| No financial access via session | Tips are on-chain transactions; session tokens do not control funds |
| Server-side validation on every request | Forged or tampered tokens are rejected immediately |
| Minimal third-party scripts | Reduces the number of scripts that could exfiltrate the token |
An httpOnly cookie would be invisible to scripts, eliminating the XSS exfiltration risk. The costs:
- CORS complexity (see above)
- No SSR benefit (the dashboard is client-only, and the cookie is
httpOnly) - Harder to use from non-browser clients (mobile apps, CLI tools)
- CSRF risk (though that can be mitigated with SameSite and CSRF tokens)
The current choice prioritizes flexibility and simplicity over defense-in-depth against XSS. The tradeoff is acceptable because:
- The session grants only read access to the dashboard, not write or financial access.
- Write operations require fresh wallet signatures, not just the session.
- The token expires quickly, limiting the blast radius of a compromised session.
If XSS defense becomes a higher priority (e.g., if the app begins rendering user-generated HTML, or if a security audit recommends it), switching to httpOnly cookies is a reversible decision. The backend already supports stateless JWT validation, so no server-side session store is needed — the cookie would carry the JWT just as localStorage does today.
Light and dark are driven by a dark class on <html> (Tailwind darkMode: "class").
The theme is applied before the first paint by a small blocking inline script in
the <head> of src/app/layout.tsx — THEME_INIT_SCRIPT, defined in src/lib/theme.ts.
It reads localStorage.novatip_theme and falls back to prefers-color-scheme. Because
the server render cannot know the result, <html> carries suppressHydrationWarning.
Two rules keep it flash-free:
- Never re-apply the theme after hydration. The class the script already applied
is the source of truth for what is painted (
getAppliedTheme()).ThemeToggledoes read the stored preference after hydration so it can distinguish "system" from an explicit light/dark choice, but it never re-applies the theme and its first render uses the server-safe "system" default so hydration still matches. - Style with the semantic tokens, not raw colours. Use
bg-canvas,bg-surface,border-hairline,text-fg/-muted/-subtle/-faint/-dim,text-accent, andsuccess/warning/danger. They are CSS variables defined for both themes insrc/app/globals.cssand mapped intailwind.config.ts; opacity modifiers still work (bg-canvas/80). Reach for a literal colour or adark:variant only when a value is genuinely theme-independent — e.g.text-whiteon a brand-coloured button, or the QR code's white backing.
This section walks through building a new component so it inherits the active theme rather than hardcoding colours that break in one mode.
Every component in src/components/ui/ is built with the token classes defined
in tailwind.config.ts and src/app/globals.css. They resolve to different CSS
variable values in light and dark:
| Token class | Light | Dark |
|---|---|---|
bg-canvas |
slate-50 | gray-950 |
bg-surface |
white | gray-900 |
bg-surface-strong |
slate-100 | gray-800 |
border-hairline |
slate-200 | near-gray-800 |
text-fg |
slate-900 | gray-100 |
text-fg-muted |
slate-700 | gray-300 |
text-fg-subtle |
slate-600 | gray-400 |
text-accent |
brand-600 | brand-400 |
text-success / bg-success |
green-600 | green-400 |
text-warning / bg-warning |
yellow-600 | yellow-400 |
text-danger / bg-danger |
red-600 | red-400 |
Opacity modifiers work on all of them: bg-success/20, border-danger/30,
text-fg-muted/80 all resolve correctly in both themes.
- Raw slate/gray/zinc colours such as
text-slate-900,bg-gray-100,border-zinc-200— these are fixed values that do not change with the theme. Atext-slate-900heading is invisible against a dark canvas. dark:variants on literal colours such astext-slate-900 dark:text-white— this pattern works for simple cases but proliferates as the component grows and is impossible to audit at a glance. Use the token instead and remove thedark:override entirely.- Hardcoded hex or RGB in
style={{ color: "#0f172a" }}or similar — same problem, but even harder to catch in review.
// src/components/ui/StatusBanner.tsx
import { cn } from "@/lib/utils";
type Variant = "info" | "success" | "warning" | "error";
const styles: Record<Variant, string> = {
info: "bg-accent/10 text-accent border-accent/20",
success: "bg-success/10 text-success border-success/20",
warning: "bg-warning/10 text-warning border-warning/20",
error: "bg-danger/10 text-danger border-danger/20",
};
interface StatusBannerProps {
variant?: Variant;
children: React.ReactNode;
className?: string;
}
export function StatusBanner({ variant = "info", children, className }: StatusBannerProps) {
return (
<div
role="status"
className={cn(
"rounded-lg border px-4 py-3 text-sm font-medium",
styles[variant],
className,
)}
>
{children}
</div>
);
}Notice:
- Every colour is a token. No
dark:overrides are needed. - Opacity modifiers (
/10,/20) give tinted backgrounds without adding a new CSS variable. - The
role="status"keeps it accessible — see the Accessibility section.
- Run
npm run devand openhttp://localhost:3000. - Click the theme toggle to switch to dark mode.
- Inspect the component in both modes — look for text that disappears, borders that vanish, or backgrounds that clash.
- If you added a
dark:variant on a raw colour, that is a sign to reach for a token instead.
npm run dev - hot reload dev server
npm run build - production build
npm run start - production server
npm run typecheck - tsc --noEmit
npm run lint - eslint
npm run format - prettier
npm test - run tests once (Vitest)
npm run test:watch - run tests in watch mode
npm run test:coverage - run tests with v8 coverage report
The project uses Vitest with React Testing Library.
Running tests
npm test # single run, exits with pass/fail
npm run test:watch # watch mode — re-runs on file changes
npm run test:coverage # single run with v8 coverage report (text + lcov)
Coverage output goes to coverage/. The lcov report (coverage/lcov.info) is
consumed by editor plugins and CI coverage services. Requires
@vitest/coverage-v8 which is listed in devDependencies; run npm install
before the first coverage run.
Writing tests
Place test files next to the source they test: src/components/Foo.test.tsx or src/lib/bar.test.ts. They are picked up automatically.
Vitest globals (describe, it, expect, vi) are available without imports. @testing-library/jest-dom matchers (.toBeInTheDocument(), .toBeDisabled(), etc.) are loaded globally via src/test/setup.ts.
If a test depends on @novatip/sdk, the stub at src/test/mocks/novatip-sdk.ts is resolved automatically. To override specific exports in a single test file, use vi.mock('@novatip/sdk', ...).
CI
npm test runs as part of the GitHub Actions CI pipeline defined in .github/workflows/ci.yml, alongside lint, typecheck, and build steps.
Tests run against a hand-maintained stub of @novatip/sdk located at src/test/mocks/novatip-sdk.ts. The stub exists because the real SDK depends on Node crypto APIs and Stellar transaction primitives that are difficult to run in a jsdom test environment.
vitest.config.mts aliases @novatip/sdk to this stub, so every test import of the SDK resolves to the mock rather than the real package. The stub is not automatically synchronized with the real SDK — when the SDK's public interface changes, the stub must be updated by hand.
The stub exports the following to match the real SDK's public API:
| Export | Type | Purpose |
|---|---|---|
usdcToStroops |
function | Converts a USDC string to stroops (1 USDC = 10_000_000 stroops) |
isValidTipAmount |
function | Validates a tip amount in stroops (> 0 and <= 1000 USDC) |
formatUsdc |
function | Formats stroops as a USDC decimal string |
shortenAddress |
function | Abbreviates a Stellar address to GABC…WXYZ format |
validateSplitsBps |
function | Validates that an array of basis points sums to exactly 10_000 |
ContractErrorCode |
enum | Error codes mirroring the tip_splitter contract's Error enum |
NovatipContractError |
class | SDK-level error wrapping a contract error code |
Split |
interface | { to: string; bps: number } |
Jar |
interface | { owner: string; splits: Split[] } |
The stub intentionally omits blockchain interaction classes like TipSplitterClient and wallet signing helpers, since those are not unit-testable in isolation. Tests that need those behaviors should use vi.mock('@novatip/sdk', ...) to provide test doubles for the specific functions under test.
Update the stub whenever you bump the SDK pin in package.json and the SDK's public API has changed. Common triggers:
- A new helper function is added to the SDK and production code begins using it
- An existing function's signature changes (new parameter, different return type)
ContractErrorCodegains or loses a variant because the contract'sErrorenum changed- Type definitions for
Split,Jar, or other interfaces are modified
The stub does not need to match private SDK internals — only the exports that production code actually imports.
- Identify which SDK exports changed by reviewing the commit that bumped the pin.
- Open
src/test/mocks/novatip-sdk.tsand add, remove, or update the affected exports. - Ensure the stub's behavior matches the real SDK's documented behavior (especially for validation functions like
isValidTipAmountandvalidateSplitsBps). - Run
npm testto verify that existing tests still pass with the updated stub. - If a test fails after updating the stub, the failure indicates either:
- The stub was changed incorrectly (fix the stub), or
- The production code depends on old SDK behavior and needs updating (fix the production code)
Do not introduce behavior that the real SDK does not have. The stub exists to let tests run, not to add test-only features. If a test needs behavior the SDK does not provide, mock the SDK in that specific test file rather than polluting the shared stub.
Suppose the SDK adds a new function parseJarId that extracts the slug from a jar ID:
// Real SDK: novatip-sdk/src/jar.ts
export function parseJarId(jarId: string): string | null {
if (!jarId.startsWith('@')) return null;
return jarId.slice(1);
}After bumping the SDK pin and seeing production code import parseJarId, add it to the stub:
// src/test/mocks/novatip-sdk.ts
export function parseJarId(jarId: string): string | null {
if (!jarId.startsWith('@')) return null;
return jarId.slice(1);
}Then verify the change:
npm testIf tests fail, inspect the failure to determine whether the stub's implementation matches the real SDK's behavior.
There are three places state can live. Use the right one for the job.
WalletContext (src/contexts/WalletContext.tsx)
The single source of truth for identity. It holds:
| Field | What it is |
|---|---|
publicKey |
The connected Stellar address, or null |
jwt |
The creator session JWT issued by the backend, or null |
isConnected |
Convenience boolean — !!publicKey |
isConnecting |
True while the Freighter + SIWS round trip is in flight |
error |
The last connection or sign-in error message, or null |
publicKey and jwt are persisted to localStorage and rehydrated on mount.
They are separate because a visitor who only wants to tip needs a wallet
connection but never needs a creator JWT.
Put state here only if it must survive navigation or must be visible to unrelated parts of the tree at the same time. Everything else belongs closer to the component that uses it.
Per-component fetching
Dashboard pages and widgets fetch their own data with useEffect + AbortController.
Each component is responsible for its own loading, error, and data states.
This is intentional: the pages are independent, they load in parallel, and an
error in one should never block the others.
The pattern every dashboard page follows:
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
if (!jwt) return;
// cancel any in-flight request for this component
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
setLoading(true);
someApi.someMethod(jwt, { signal: controller.signal })
.then(setData)
.catch((e) => { if (e.code !== "ABORTED") setError(e.message); })
.finally(() => {
if (abortControllerRef.current === controller) {
abortControllerRef.current = null;
setLoading(false);
}
});
return () => { abortControllerRef.current?.abort(); };
}, [jwt]);The sessionKey on the dashboard layout's <main> remounts all children
whenever the connected wallet changes, so stale data from a previous creator
session is never shown.
Local component state
Ephemeral UI state (open/closed, form values, hover, copy status) lives in
useState inside the component that owns it. It is never promoted unless two
unrelated components genuinely need to react to the same change.
Two tiny pub/sub buses in src/lib/ decouple things that cannot share a React
ancestor without prop-drilling or an unnecessary shared context.
tipEvents (src/lib/tipEvents.ts)
Carries a TipSuccessPayload (fromAddress, amount, message) after a tip
transaction is confirmed on-chain.
- Emitter:
TipForm— after the Soroban transaction is confirmed. - Subscribers:
RecentTipsandLeaderboard— they prepend the new tip optimistically rather than waiting for the backend indexer (~6 s).
Without this bus, TipForm would have to lift its success state up to a common
ancestor of RecentTips and Leaderboard, which are on a completely different
page (/dashboard). The bus is the right scope for a one-way broadcast with no
shared ancestor.
authEvents (src/lib/authEvents.ts)
Carries a single signal: emitUnauthorized().
- Emitter:
lib/api.ts— on any HTTP 401 from any endpoint. - Subscriber:
WalletContext— clears the JWT and public key so every dashboard widget drops its auth state at once.
This bus exists because lib/api.ts is a plain TypeScript module with no React
dependency. It cannot call useWallet() directly. The bus keeps the API client
framework-agnostic while still letting one 401 tear down the whole session.
Before adding a new event bus: check whether
WalletContextor a lifted state in the nearest common ancestor already covers the need. The buses exist to solve a specific structural problem, not as a general state-sharing mechanism. Duplicating them for convenience will make the data flow harder to follow.
TipFormcallsTipSplitterClient.submit()and awaits on-chain confirmation.- On success,
TipFormcallstipEvents.emit(payload). RecentTipsandLeaderboardare subscribed viatipEvents.subscribe()in their ownuseEffects. They prepend / update their local state immediately.- Separately, the backend indexer picks up the
TipReceivedSoroban event within ~6 seconds and writes it to the database. - The next time the dashboard loads (or the wallet reconnects), components re-fetch from the API and reflect the persisted record.
Steps 3 and 5 are independent. The optimistic update in step 3 means the creator sees the tip immediately; the re-fetch in step 5 is the source of truth.
The RecentTips component (src/components/RecentTips.tsx) displays live tips on the creator dashboard. It combines optimistic updates with adaptive polling to balance responsiveness with backend load.
- Normal cadence (15s): In steady state,
RecentTipspollsanalyticsApi.recent()every 15 seconds (NORMAL_INTERVAL = 15_000). Polling automatically pauses when the browser tab is hidden and aborts in-flight requests viausePolling/useAbortableRequest. - Optimistic prepending on tip success: When a tip succeeds,
TipFormemits an event ontipEvents.RecentTipsimmediately creates an optimistic entry withkind: "pending", a unique client ID, and an expiration timestampexpiresAt = Date.now() + 30_000, prepending it to the list with a "confirming…" status. - Fast polling burst (3s for 30s): To catch the indexed tip as soon as possible,
RecentTipstemporarily switches its polling interval from 15 seconds to 3 seconds (FAST_INTERVAL = 3_000) for a 30-second window (FAST_WINDOW_MS = 30_000). Once the window expires, it reverts to the 15-second interval. - Reconciliation and replacement: When a fresh batch of indexed tips arrives from the API,
mergeWithPending()reconciles pending entries with indexed records. An entry is confirmed whenisMatch(indexed, pending)matches both the sender's address AND the amount. Confirmed pending items are removed from state, seamlessly replaced by the persisted indexed record. - Downgrade to unconfirmed: If the 30-second window elapses without the tip appearing in the indexed response,
mergeWithPending()downgrades the entry tokind: "unconfirmed", allowing the UI to notify the user rather than leaving a permanent "confirming…" state.
When a transaction is confirmed on Stellar by Soroban, the client's wallet knows immediately. However:
- The backend indexer must observe the new ledger, extract the
TipReceivedcontract event, process splits, and write records to PostgreSQL. - This indexing cycle introduces an inherent delay of ~5–10 seconds (typically ~6 seconds).
- Polling at a normal 15-second interval after transaction confirmation would force users to wait anywhere from 6 to 21 seconds to see their tip reflected.
- The optimistic update provides instant visual confirmation, while the 3-second fast polling burst ensures the canonical indexed record replaces the placeholder almost as soon as the indexer commits it to the database.
Contributors modifying RecentTips, matching helpers, or event payloads should be vigilant about these potential pitfalls:
- Duplicate entries ("ghost duplicates"): If
isMatch()is too strict (e.g. strict string matching on amounts formatted differently) or if fields don't match, the optimistic entry will never be reconciled with the indexed counterpart. Both the pending item and the indexed record will render simultaneously. - Premature clearing of pending tips: If
isMatch()matches only on sender address without verifying amount (or timestamp), an earlier tip from a returning supporter will falsely match and clear their new pending tip before it actually indexes. - Stuck pending rows: If the backend indexer drops an event, hangs, or experiences an extended lag exceeding 30 seconds, pending items without expiration handling would spin forever. Always preserve the
expiresAtexpiration check and the downgrade tokind: "unconfirmed". - In-flight request races on visibility change: When a tab is backgrounded or brought into focus, un-aborted in-flight requests could resolve out of order. Ensure requests use abort signals so stale polling responses never overwrite newer state.
Scenario: you want to show a "New tip!" badge in the dashboard sidebar whenever a tip arrives, without polling.
Step 1 — subscribe to the existing bus.
The tip already flows through tipEvents. Add a subscriber in the dashboard
layout (or a new hook) — do not create a second bus.
// src/hooks/useNewTipBadge.ts
import { useEffect, useState } from "react";
import { tipEvents } from "@/lib/tipEvents";
export function useNewTipBadge() {
const [hasNew, setHasNew] = useState(false);
useEffect(() => {
return tipEvents.subscribe(() => setHasNew(true));
}, []);
return { hasNew, clear: () => setHasNew(false) };
}Step 2 — consume the hook where the UI lives.
Call it in the dashboard layout and pass hasNew down to the sidebar nav item.
Clear it when the user visits the page that shows recent tips.
Step 3 — ask: does this need to outlive the component?
If the badge should survive a client-side navigation (e.g. the user goes to
Splits and back), lift the useState into the dashboard layout where the
sidebar already lives. It does not belong in WalletContext because it is
dashboard-local UI state, not identity state.
If it needed to survive a full page reload, you would persist it in
localStorage yourself — but that is almost never the right call for a
transient UI indicator.
Failures across the client arrive in four distinct shapes depending on where in the stack the failure originates. Knowing the error type determines where the error should be handled or translated into user-facing text:
| Source | Error Type / Format | Example | Responsible Module |
|---|---|---|---|
| Backend API | ApiError class instance |
new ApiError(404, "NOT_FOUND", "Creator not found") |
src/lib/api.ts constructs ApiError from response payloads (status, code, message). UI components and pages inspect err.status/err.code or display err.message. |
| Contract (typed) | NovatipContractError from @novatip/sdk |
NovatipContractError with code: ContractErrorCode.JarNotFound |
@novatip/sdk parses contract error codes from simulations. Feature modules such as src/lib/jar.ts inspect err.code or map contract codes to friendly messages. |
| Simulation (raw) | Plain Error with simulation diagnostic string |
Error("HostError: Error(Contract, #3)") |
Soroban RPC / Stellar SDK produces raw diagnostic strings when simulation fails without a structured SDK contract error. Modules such as src/lib/jar.ts check err.message via pattern matching. |
| Transaction Submission | Base64-encoded TransactionResult XDR |
Error("Transaction submission failed: AAAAAAAA+QT////6AAAAAA==") |
src/lib/txerror.ts (describeSubmissionError, decodeResultCode) decodes the base64 XDR using @stellar/stellar-sdk and translates result codes (e.g., txBadAuth, txInsufficientBalance, txBadSeq) into actionable human prose. |
-
Backend
ApiError(src/lib/api.ts)- When it occurs: Thrown by
request()insrc/lib/api.tswhenever the REST backend returns a non-2xx HTTP status, as well as on network timeouts or aborted requests. - Shape:
ApiErrorinstance with propertiesstatus(number),code(string), andmessage(string). - Example:
throw new ApiError(404, "NOT_FOUND", "Creator not found"); - Handling: UI components and route handlers (such as
src/app/[slug]/page.tsx) catchApiErrorand branch onerror.statusorerror.codeto show specific UI states (like 404 views) or displayerror.message.
- When it occurs: Thrown by
-
Contract
NovatipContractError(@novatip/sdk)- When it occurs: Thrown by
@novatip/sdkmethods when a Soroban contract call simulation fails with a known contract error code. - Shape: An instance of
NovatipContractErrorwith a typedcodeproperty (ContractErrorCode). - Example:
new NovatipContractError(ContractErrorCode.JarNotFound) - Handling: Catch blocks in feature modules (e.g.
src/lib/jar.ts) checkerr instanceof NovatipContractErrorand testerr.codeagainstContractErrorCodeto handle known states (for example, treatingJarNotFoundasnullduring onboarding).
- When it occurs: Thrown by
-
Raw Simulation String
- When it occurs: Soroban RPC returns simulation diagnostic failures that the SDK could not parse into a typed
NovatipContractError(e.g., host errors, budget exhaustion, or contract panics). - Shape: A standard JavaScript
Errorwhosemessagecontains diagnostic strings like"HostError: Error(Contract, #3)". - Example:
new Error("Transaction simulation failed: HostError: Error(Contract, #3)") - Handling: Catch blocks perform regex or substring matching on
err.message(e.g.isJarNotFoundinsrc/lib/jar.tstests/Error\(Contract,\s*#3\)/) to identify the failure when SDK error typing is unavailable.
- When it occurs: Soroban RPC returns simulation diagnostic failures that the SDK could not parse into a typed
-
Transaction Submission Failures (
src/lib/txerror.ts)- When it occurs: The transaction was simulated successfully and signed by the wallet, but the Stellar network rejected it during submission.
- Shape: The SDK surfaces the error as a raw message ending in a base64-encoded
TransactionResultXDR. - Example:
Error: Transaction submission failed: AAAAAAAA+QT////6AAAAAA== - Handling: Wrap transaction submission promises with
describeSubmissionError()fromsrc/lib/txerror.ts(as insrc/lib/jar.ts).lib/txerror.tsextracts the base64 XDR, decodes the union result code viaxdr.TransactionResult.fromXDR(), and maps cryptic codes (such astxBadAuth,txBadSeq,txInsufficientBalance) to clear, actionable user messages (e.g. switching Freighter accounts or acquiring XLM).
Novatip is used on mobile, with keyboard navigation, and by screen-reader users. Every contributor is expected to apply the following checklist before opening a pull request. Reviewers should use it as a concrete rubric.
Labels
- Every interactive element has an accessible name: a visible label, an
aria-label, or anaria-labelledbypointing at a visible element. - Icon-only buttons always have
aria-label(search the codebase for existing examples inWalletConnectButtonandThemeToggle). - Form inputs are associated with a
<label>viahtmlFor/id, or havearia-labelwhen a visible label is impractical.
Focus order
- Tab order follows the visual reading order. Do not use
tabindexvalues greater than0. - Dialogs, drawers, and popovers trap focus while open and return it to the trigger on close.
- No interactive element is reachable only via pointer (hover menus, etc.).
Visible focus states
- The focused element is clearly visible at all times. Do not suppress the default outline without providing a custom one.
- Use the
focus:ring-2 focus:ring-brand-500/50utility pattern already used inCopyFallbackandInputrather thanoutline-nonealone.
Colour contrast
- Body text meets WCAG AA (4.5 : 1 against its background).
- Large text and UI components meet AA (3 : 1).
- Use the semantic colour tokens (
text-fg,text-fg-subtle,text-accent, etc.) rather than raw Tailwind colours — they are already contrast-checked for both light and dark themes. - Never rely on colour alone to convey state: pair it with an icon, label, or pattern.
Announcing dynamic changes
- Loading states that replace content use
aria-live="polite"or a visually hidden status message so screen readers announce the update. - Error messages use
role="alert"(seeCopyFallbackfor an example). - Toast-style confirmations ("Copied!") should also carry
role="status"oraria-live="polite". - After a form submission, focus moves to the confirmation or error message so keyboard users know what happened.
| Tool | How to run | What it catches |
|---|---|---|
| axe DevTools (browser extension) | Open DevTools → axe tab → Analyze | Missing labels, contrast failures, ARIA misuse |
| Lighthouse | DevTools → Lighthouse → Accessibility | WCAG automated audit, score and issue list |
| Keyboard-only walkthrough | Unplug/ignore the mouse, Tab through the page | Focus traps, focus order, missing focus rings |
| Screen reader | macOS VoiceOver (⌘ F5), Windows NVDA (free), or Android TalkBack |
Label quality, live-region announcements, interactive element names |
eslint-plugin-jsx-a11y |
npm run lint (already included via eslint-config-next) |
Common JSX accessibility mistakes at author time |
Full WCAG 2.1 AA compliance requires manual testing with assistive technologies in addition to automated tools — automated tools catch roughly 30–40 % of issues.
MIT