Linky is a mobile-first PWA for contacts, Nostr messaging, and Lightning/Cashu payments. It is local-first: data is stored in Evolu (SQLite) and syncs between devices.
The repo also contains a separate public website in apps/site/ intended for linky.fit, while the product app remains a distinct deployment on app.linky.fit. Its /cashu/ redemption page uses the shared linkshu wallet and linkstr delivery packages, with local recovery for interrupted payments.
packages/linkstr— Nostr protocol library; usage guides inpackages/linkstr/docs/(also covers@linky/linkstr-react)packages/linkshu— cashu wallet library; usage guides inpackages/linkshu/docs/
- Nostr (chat, profile, auth-related flows)
- LNURL (pay, withdraw, and LUD-04 auth as a signer for third-party logins)
- Evolu (local-first DB + sync)
- Cashu + mints (Lightning wallet flow)
- npub.cash (LN address + mint preference sync on Linky's own server; payments to
<npub>@npub.cashare collected from upstream too)
- Login supports either:
nsec, or- one 20-word SLIP-39 share
- With SLIP-39 login:
- Nostr keypair is derived at
m/44'/1237'/0'/0/0 - deterministic Evolu owner lanes are derived for:
- contacts (
contacts-n) - cashu (
cashu-n) - messages (
messages-n) - owner metadata (
ownerMeta)
- contacts (
- Nostr keypair is derived at
- Seed backup uses the browser credential API where supported. Otherwise, use Show/Copy in Master keys and save the seed manually. Linky does not submit the seed to a server to trigger password saving.
- If user pastes custom
nsecduring a SLIP-39 session, app switches to pasted key locally without immediate Evolu restore/write; choosing Derive switches back to seed-derived key.
Constants live in apps/web-app/src/utils/constants.ts; the mechanics are in docs/architecture.md ("Evolu persistence and owner lanes").
- Each Evolu owner lane rotates on its own historical mutation threshold: contacts
220, cashu170, messages160, transactions220(*_OWNER_ROTATION_TRIGGER_WRITE_COUNT), with a per-scopeOWNER_ROTATION_COOLDOWN_MS = 60_000cooldown. - Existing quota failures need relay capacity before rejected history can sync. Keep the device's local data, increase the relay's quota or add a relay with capacity, then reload normally.
- An upgrade silently adds and enables
wss://evolu.linky.fitand addswss://nostr.linky.fitto the user's Nostr relay lists once, preserving custom endpoints. Later user edits are respected; explicit development relay overrides skip the migration. - Rotation is pointer-only for every scope: the active lane index moves forward in
ownerMeta, nothing is copied, and older lanes stay readable instead of being pruned. - Contacts are additionally capped at
MAX_CONTACTS_PER_OWNER = 100per active lane; a full lane triggers rotation to the next one.
- Contacts: add/edit/delete, QR scan/share, grouping
- Tilt to show profile (on by default, switch in Settings): flip the phone so the top of the screen points down and a full-screen contact card (avatar, name, QR) appears rotated towards the other person; turning the phone back closes it. Works in the browser, the installed PWA, and the Android app; iOS asks for motion access when the profile page is opened or the setting is turned on
- Messages: encrypted private chat (gift-wrap/NIP-17 flows), with image and PDF attachments. Pasting an image with Cmd+V, Ctrl+V, or the phone's system Paste action, or picking a file with the gallery button, stages them above the message field as thumbnails (several at once, more can be added); the send button ships each attachment as its own message, in order, and then any typed text as a final message. Staged files survive navigating away from the chat. Tapping a received image opens it full screen; pinch, double-tap, or scroll to zoom.
- Wallet: Cashu token ingest, missing-token search, bulk reclaim of handed-out/NFC tokens, full restore-and-reclaim, validation, spend; the wallet inventory is one row per proof (available, held by a payment, handed out, spent), with the mint's live answer per proof and the open transfers (issued, pending, failed) listed separately. A token too large for one QR is shown as a NUT-16 animated QR and reassembled by the scanner
- Lightning address receive: payments to
<npub>@linky.fitand<npub>@npub.cashboth land in the wallet, whatever address the profile advertises - LNURL login: scan a site's
lnurl1…login QR and Linky signs it (LUD-04). The linking key is derived per domain from the active Nostr key — different sites cannot recognize each other's user — so it works for bothnsecand SLIP-39 logins. Logging into Linky still needsnsecor the SLIP-39 share - Payments:
- Lightning invoice and LN address payment; a payment the mint has not settled shows as pending in the history and is finished (or refunded to the balance) on the next launch or reconnect
- contact payment via Cashu message flow
- proxy payment of a scanned bank QR (SPD, EPC, PAY by square) with editable fields before the offer is sent
- Push: optional Bun push service in
apps/push/for generic Web Push notifications on new outer inboxkind: 1059events - Debug pages for Evolu current/history data and owner/rotation diagnostics
Requirements: Bun; Docker for the local dev service stack
For Android native builds: Java 17
bun run dev— full local environment: startsdocker-compose.dev.yml(local Nostr relay :7777, Evolu sync relay :4001, Cashu Nutshell FakeWallet mint :3338 that auto-settles invoices with fake sats), then runs the web app (:5173) and push service (:8787) against it via the committed.env.developmentfiles. npub.cash flows are disabled locally (#219); the mint has no real Lightning backend (#220).bun run dev:prod— web app only, on :5175, against production services. The separate port keeps browser storage isolated from local-dev sessions.bun run dev:services— just the docker stack, attached.- The
e2eandquotaCompose profiles also start an isolated Evolu relay on :4002 with a 16 KiB per-owner quota for recovery tests. The normal :4001 relay stays unlimited unlessEVOLU_OWNER_QUOTA_BYTESsets a positive byte limit.
bun run errors:dev starts the Nostr error tracker at http://127.0.0.1:5190.
Sign in with the telemetry collector account's 20-word Linky recovery phrase.
The seed is saved in this browser until sign-out. Inspect existing encrypted
errors across versions and dates, mark issues solved, and sync resolutions through
Evolu. New occurrences reopen solved issues. See
tools/nostr-error-tracker/README.md.
apps/linkshu-cli/ is a terminal cashu wallet and @linky/linkshu's first consumer — it runs
under plain Bun with file-based implementations of all three platform ports, which is how the
package's independence from the browser stays honest. It needs the dev stack's mint:
docker compose -f docker-compose.dev.yml up -d --wait cashu-mint
bun run linkshu --help
bun run linkshu --data-dir /tmp/wallet topup 128
bun run linkshu --data-dir /tmp/wallet balanceCommands: balance, topup, receive, send, melt, restore. See
apps/linkshu-cli/README.md for the data directory layout, seed
handling, and the port implementations.
While the dev server runs, the domain-agnostic inspector shows events on open, namespaced channels.
Current Nostr emitters use nostr.operation (linky-level linkstr operations and routed inbox facts)
and nostr.wire (raw relay traffic — publishes, subscriptions, incoming events). Rows are
correlated by shared ids in their links (gift-wrap ids, rumor ids, optimistic-update client ids),
while non-correlating location metadata such as relay urls lives in context. Development builds
collect automatically until the setting is explicitly turned off.
- Start the app (
bun run devorbun run dev:prod). - Open
http://localhost:5173/inspector.html(:5175fordev:prod) in a window next to the app — not as a route inside the app. - Use the app; rows stream in live. Selecting a row highlights every related row and the detail
pane lists them (click to jump) — e.g. one
reactions.reactoperation and the twoWirePublishedwraps it produced.
The same timeline is available in-app at #advanced/inspector/timeline. All inspector controls
live on one settings page at #advanced/inspector (reached from Advanced → Inspector): the
collection toggle, the persistent-log toggle with download/clear, the timeline link, and the
Push/SW debug page. Production builds collect only after enabling Inspector there. The in-app
buffer can contain decrypted message payloads in plain text, stays in browser memory only, and is
cleared on reload or when the setting is turned off. Production collection never sends inspector
rows to a server. An independent toggle on the same page can retain the same plain-text rows in
on-device browser storage for up to 24 hours (about 25 MiB) and download them as import-compatible
.ndjson.
Toolbar: channel chips and a text filter narrow the timeline; Pause freezes the view while
still buffering; Clear resets the collector; the timeline auto-follows the newest row until you
scroll up (Follow ↓ jumps back). When several app tabs report, an App selector appears.
Import loads .ndjson, .jsonl, or .txt captures entirely in the browser and switches the
timeline offline; close the displayed file name to discard it and reconnect to the live feed.
Programmatic access:
# poll as JSON; cursor in the response resumes the next call
# optional filters: channel=<lowercase dotted token>, client=<per-tab app id>
curl "http://localhost:5173/__inspector/events?cursor=0&channel=nostr.wire"
# live SSE stream / reset between scenarios
curl -N "http://localhost:5173/__inspector/stream"
curl -X POST "http://localhost:5173/__inspector/clear"
# or tail the append-only file (one JSON row per line, reset on dev-server start)
tail -f apps/web-app/.inspector/rows-5173.ndjsonbun install
bun run dev
bun run site:dev
bun run push:devBuild:
bun run build
bun run site:buildAndroid APK/AAB builds, signing, Firebase push setup, and the iOS project are documented in
apps/native-shell/README.md. The root native:* scripts
(bun run native:apk:debug, bun run native:apk:release, bun run native:aab:release, ...) forward
to that workspace. Native push delivery additionally needs apps/push configured with
PUSH_FIREBASE_SERVICE_ACCOUNT_JSON.
Versioned Android releases publish the APK to GitHub and Zapstore, and the AAB to Google Play internal and open testing. See Play Console setup.
Start the push service once:
bun run push:startUnit tests (Vitest) across all workspaces:
bun run test@linky/linkshu additionally has an integration suite against the local
docker mints (started via docker compose -f docker-compose.dev.yml up -d --wait cashu-mint cashu-mint-target): bun run --filter @linky/linkshu test:integration.
@linky/linkshu-cli runs its port and argument-parsing tests under bun test (no mint needed);
they are part of bun run test.
End-to-end tests (Playwright) live in apps/web-app/tests/*.spec.ts.
The local-stack runs the proxy-payment flow — three accounts on one machine, talking over the local
Nostr relay and paying each other with the local Cashu mint — plus the linkshu storage-migration
scenario, chat/edit/offline-reaction and top-up recovery, and signup/manual password saving
with checks that recovery seeds stay out of HTTP requests. Attachment tests send encrypted
images and PDFs between browsers and verify
decryption, seen receipts, downloads, and bytes handed to the browser sharing API. Owner-lane
tests verify old and new contacts, messages, transactions, and tokens across devices and reloads.
Boot and route tests cover fresh profiles, restore, unavailable browser storage, and navigation.
The full suite and site redemption tests run on pull requests and pushes to main.
Payments use separate local mints on :3338 and :3339. It needs the
docker stack up first, because the app is served from it as a production build on :5176:
docker compose -f docker-compose.dev.yml --profile e2e up -d --build --wait
cd apps/web-app && bunx playwright test --project=local-stackRe-run the up --build after changing app source; the endpoints are baked into the image.
To watch or debug a run:
bunx playwright test --project=local-stack --ui # step through it
bunx playwright test --project=local-stack --headed # three live browsers
bunx playwright show-trace test-results/*local-stack/trace.zip # after the factEvery run records a trace containing all three accounts, and the console output of each app is
printed prefixed with its account label ([A], [B], [C]). The run takes ~20s, so --ui and the
trace viewer are far more useful than watching it live.
In CI, local-stack gates every release: it runs on each push to main (Vercel Deployment Checks
holds the production promotion until it passes) and as a required job in the Android release
workflow.
Always run the full check pipeline after changes:
bun run check-codeThis runs:
typecheckeslint --fixprettier --write
Workspace-scoped commands (web app only):
bun run --filter @linky/web-app typecheck
bun run --filter @linky/web-app eslint
bun run --filter @linky/web-app prettierWorkspace-scoped commands (public site only):
bun run --filter @linky/site dev
bun run --filter @linky/site build
bun run --filter @linky/site preview
bun run --filter @linky/site test:e2eThe site smoke suite needs the local mints on 3338/3339 and Nostr relay on 7777. It builds and serves the site on 5180, enabling test-mint redemption only for that build. It covers direct and proxied LNURL payments plus reload recovery after lost swap and melt responses.
Workspace-scoped commands (native shell):
bun run --filter @linky/native-shell android:sync
bun run --filter @linky/native-shell android:open
bun run --filter @linky/native-shell android:apk:debugPush service workspace commands:
bun run --filter @linky/push typecheck
bun run --filter @linky/push startPush service container artifacts live in apps/push/:
Dockerfilebuilds a production Bun imagedocker-compose.example.ymlshows a persistent SQLite/datavolume for prod-style deployment.env.production.examplelists the runtime env vars expected by that compose setup
Linky is released under the Zero-Clause BSD license (0BSD). See
LICENSE.