Privacy-first social tipping on Solana. Tip anyone by X handle — their claim is gated by X OAuth, settled through Loyal Network's private rail, and auto-refunded if unclaimed. Built for the Loyal Hackathon.
See GhostTip_Full_Spec.md for the complete
product / architecture / security spec.
# 1. install
bun install # runs `prisma generate` automatically
# 2. env
cp .env.example .env.local
# demo-friendly defaults are already set; only DATABASE_URL / REDIS_URL
# need to match where you actually run Postgres + Redis.
# 3. start Postgres + Redis in Docker (see docker-compose.yml)
bun run db:up # Postgres on :5433, Redis on :6380
# 4. apply schema
bun run db:migrate
# 5. dev server
bun dev # http://localhost:3000DB helper scripts (all defined in package.json):
| Script | What it does |
|---|---|
bun run db:up |
start the docker-compose stack |
bun run db:down |
stop it (keeps data volumes) |
bun run db:reset |
wipe volumes and restart |
bun run db:logs |
tail Postgres + Redis logs |
bun run db:migrate |
prisma migrate dev |
bun run db:studio |
Prisma Studio on :5555 |
Ports are shifted by +1 (5433 / 6380) so the stack coexists with a native Postgres.app / Homebrew Postgres / local Redis on default ports.
.env.example ships with the following demo-friendly defaults so judges can
run the full flow without secrets:
| Var | Value | Effect |
|---|---|---|
NEXT_PUBLIC_OAUTH_BYPASS |
true |
Verify step trusts the intended handle — skip real X OAuth. |
NEXT_PUBLIC_LOYAL_MOCK |
true |
Loyal calls return a mocked private-rail settlement. |
ANCHOR_ON_CHAIN_DISABLED |
true |
Backend claim_tip / refund_tip use mock tx signatures. |
Flip these to false once you've wired real keys / deployed the program.
-
Program
cd anchor && anchor build && anchor deploy # update NEXT_PUBLIC_PROGRAM_ID with the deployed address (also in Anchor.toml)
-
Env
GHOSTTIP_AUTHORITY_KEYPAIR— paste the JSON array output ofcat authority.json. Must be the same key used when initialising the program'sAuthorityConfigPDA viainit_authority.TWITTER_CLIENT_ID+TWITTER_CLIENT_SECRET— OAuth 2.0 credentials from a Project-attached X App, not the API Key/Secret. Use Web App mode with scopestweet.readandusers.read. Callback URL:http://localhost:3000/api/auth/x/callbacklocally andhttps://your-domain.com/api/auth/x/callbackin production. Regenerate these credentials after changing X auth settings, then redeploy Vercel. The Project must also have active X API access for v2 user-context endpoints; OAuth can succeed while/2/users/mestill returnsclient-not-enrolledif the access enrollment/tier is missing.NEXT_PUBLIC_OAUTH_BYPASS=false,ANCHOR_ON_CHAIN_DISABLED=false.
-
Cron
bun run jobs/expiry.ts(one-shot)- or
GET /api/cron/expirywith headerx-cron-secret: $CRON_SECRET - or Vercel Cron on
/api/cron/expiry(Vercel injectsx-vercel-cron: 1, no secret needed).
Frontend (Next.js 16 · App Router · @solana/kit · Framer Motion)
├─ / ← send flow (wallet + handle + amount)
├─ /claim/[token] ← claim flow (OAuth gate + wallet + claim)
├─ /tip/[id] ← sender-facing status + cancel
└─ /profile ← sender history
Backend (Next.js API routes, node runtime)
├─ /api/tips POST create
├─ /api/tips/history GET list by sender
├─ /api/tips/[id] GET status
├─ /api/tips/[id]/submit POST confirm deposit tx
├─ /api/tips/[id]/cancel POST sender cancel
├─ /api/claim/[token] GET preview
├─ /api/claim/[token]/verify POST check OAuth session
├─ /api/claim/[token]/execute POST wallet-signed claim
├─ /api/auth/x/start GET begin OAuth (PKCE)
├─ /api/auth/x/callback GET OAuth return
└─ /api/cron/expiry GET refund expired tips
On-chain (Anchor · programs/ghosttip)
├─ deposit_tip (sender signs)
├─ claim_tip (backend authority signs — gated by X OAuth off-chain)
├─ refund_tip (backend authority signs — gated by Clock)
└─ cancel_tip (original sender signs)
State (Postgres via Prisma: TipIntent, ClaimLink, IdentityMap, AuditEvent)
Cache (Redis: claim_token → tipId, oauth_state, claim_session)
Settlement (Loyal Network — mock by default, swap the SDK in `app/lib/loyal.ts`)
- Claim tokens never leave the URL — only SHA-256 hashes hit the DB.
claim_tiprequires the backend authority keypair on-chain, so the OAuth gate is enforced at the program boundary.refund_tipadditionally checksClock::get().unix_timestamp >= escrow.expiry_aton-chain.- Claim execution requires a wallet signature over
ghosttip-claim:${tipId}:${token}:${wallet}— prevents claim hijacking via a leaked OAuth session. - Double claim is blocked by an atomic
updateManyon ClaimLink plus the on-chain status check insideclaim_tip.
anchor/programs/ghosttip/ # Anchor program
prisma/schema.prisma # Postgres schema
app/
page.tsx # send
claim/[token]/page.tsx
tip/[id]/page.tsx
profile/page.tsx
api/... # route handlers (see above)
components/
layout/ # Header, PageWrapper, Footer
ui/ # Button, Input, Card, Badge, Countdown, Copy…
tip/ # TipForm, TipStatusCard
claim/ # ClaimFlow (3-step gate)
lib/
loyal.ts # Loyal SDK wrapper (mock fallback)
anchor-client.ts # browser-side instruction builders
server/
anchor.ts # PDAs, instruction builders, on-chain submit
authority.ts # backend authority signer (keypair loader)
crypto.ts # claim tokens, tip ids, PKCE
identity.ts # handle normalisation, IdentityMap, audit
prisma.ts # Prisma singleton
redis.ts # ioredis singleton (+ in-memory fallback)
api.ts # { ok, fail, serialise } envelope helpers
verify-signature.ts # ed25519 wallet signature check
store/
tipStore.ts # zustand (persisted) — sender's last tips
sessionStore.ts # zustand (session) — OAuth claim sessions
types/tip.ts # shared types + error codes
jobs/expiry.ts # cron entrypoint + runExpiryJob()
See GhostTip_Full_Spec.md §20 for the judge-facing walkthrough. TL;DR:
- Browser A — connect wallet, tip
@targethandle0.1 SOL with a short expiry. - Confirmation screen shows claim link + live countdown.
- Browser B (incognito) — open the claim link.
- Verify with X (bypass in demo mode, real OAuth otherwise).
- Connect wallet → Claim → success animation + tx signature.
- Browser A flips to
CLAIMEDlive via SWR polling. - Bonus: second tip with 60-second expiry → wait → cron →
REFUNDED.