A treasury and credit product for Whop sellers. A seller pledges idle balance — stablecoins to stay flat, bitcoin to keep upside — and borrows against it. The advance is sized off real sales history and repaid automatically out of future sales. If bitcoin falls far enough, the seller is warned, given a window to cure, and only then do we sell the minimum that closes the gap.
Live demo: stack-iota-roan.vercel.app (runs against Whop sandbox — no real money moves).
- What this is
- Architecture at a glance
- Invariants and how they're enforced
- The event log
- Money handling
- Whop gateway
- Seams
- Layout and dependency direction
- Getting started
- The five gates
- Testing
- Deployment
- Trigger surface
One dashboard shows a Whop seller three numbers: what they own (Whop balance + pledged collateral), what they owe (outstanding on an active advance), and what they can borrow now (a live ceiling sized from their real sales history).
The value proposition splits into three motions:
- Park stablecoin — move idle USDT into a Stack-owned custody account so it sits ready as cure material if a warning ever fires.
- Pledge bitcoin — convert parked stablecoin into cbBTC held in the seller's dedicated custody account. Never a sale, never a tax event; the seller keeps every satoshi and any price gains.
- Borrow against it — a fixed-fee advance sized off underwriting. Repaid automatically as a slice of future sales sweeps in, with a hard 180-day maturity beyond which any remaining balance triggers a 24-hour cure notice before Stack sells the minimum collateral needed to close the gap.
The credit product is designed around what happens when the market moves against the seller: a three-tier warning ladder (24h → 6h → immediate) protects notification, and liquidation is sized to bring loan-to-value back to a safe target — never a fire sale.
Stack is an event-sourced Next.js application. Every fact about money — deposits, draws, sweeps, marks, warnings, liquidations — is an append-only event in a Postgres event log, and every balance is a pure fold over that log.
app/ ──► jobs/ ──► ledger/ ──► domain/
└────────┴──────────┴──────────► lib/ ──► lib/whop/
Dependencies flow inward only. domain/ is pure — no I/O, no clock, no random,
no network — so folds are deterministic and replayable byte-for-byte. ledger/
owns Postgres and enforces append-only at the database trigger level, not by
convention. jobs/ orchestrates side-effecting work (custody transfers, mark
refreshes, sweep collection, liquidation) by taking Clock, PriceOracle, and
RevenueSource as constructor parameters — never new. lib/whop/gateway.ts
is the single seam that talks to api.whop.com; every other module in the tree
is forbidden from calling Whop directly by an ESLint rule.
Every rule in docs/INVARIANTS.md is enforced at the lowest possible layer: database triggers, TypeScript types, or transaction locks — not runtime application checks.
A representative selection:
| ID | Rule | Enforcement |
|---|---|---|
| A1 | Money rows are append-only. | BEFORE UPDATE OR DELETE Postgres trigger. |
| A3 | Replay is deterministic. | domain/ folds are pure. |
| A4 | Every money-moving event carries an idempotency key, written before the Whop call. | MoneyEventTypesAreIdempotent compile-time proof. |
| A6 | Amounts are integer minor units with an explicit asset. | Branded Money<Asset> types; bigint end-to-end. |
| A7 | Assets never implicitly convert. | Adding Money<USD> to Money<USDT> is a compile error. |
| B4 | Collateral is never moved anywhere except back to the creator, or to a pool repayment. | Custody allow-list in the gateway. |
| C1 | At most one non-terminal advance per creator. | Partial unique index on projection_advance. |
| C6 | Fee/rate/checkpoint priced once, immutable. | priceOffer runs exactly once in acceptOffer, output copied verbatim. |
| D3 | Creator is always told before we sell. | State machine has no direct active → liquidating edge. |
| D6 | One swap in flight per pledge account. | Two independent guards (local intent + remote GET /swaps). |
| E1 | All Whop access goes through one gateway module. | ESLint rule local/no-whop-fetch-outside-gateway. |
| F4 | Sandbox and production credentials cannot be confused. | loadConfig refuses to boot when host and key disagree. |
| P1 | Never offer or draw beyond deployable_capital. |
Checked at offer time under SELECT ... FOR UPDATE on pool:main, re-checked at draw time in the same transaction. |
| P2 | No single advance exceeds 10% of the pool. | Same lock, same transaction, same re-check. |
The full list is in docs/INVARIANTS.md. Every test cites the invariants it exercises by ID.
Events are the source of truth. Balances are derived — never stored as truth —
via pure folds over the event stream. Cached projections exist for the UI and
are labelled as such; when a cache and a fold disagree, the fold wins and the
disagreement is a drift_detected event that halts money movement for the
affected creator until an operator appends drift_resolved.
Roughly 40 event types form a discriminated union in lib/events.ts. Every
*_initiated event carries idempotency_key; the invariant that they all do is
proved at compile time (MoneyEventTypesAreIdempotent). Idempotency has three
distinct spellings in the Whop API — header Idempotency-Key, body
idempotence_key, body idempotency_key — all derived from the same event id.
Three projections rebuild deterministically from readAll():
projection_activity— the UI activity feed.projection_stream_state— folded snapshot per stream.projection_advance— the state-machine row, protected by the C1 partial unique index.
The state machine (domain/state-machine.ts) covers nine states with a strict
transition table; the invariant that no path skips a warning tier is enforced
by construction — there is no active → liquidating edge.
Every dollar, satoshi, and USDT unit is a bigint at the asset's minor-unit
precision. No floats, ever. Whop returns amounts with a per-currency
precision field, and USD's precision is 100_000_000 (1e8) — not 100.
Assuming cents is a money bug, not a rounding bug.
The three assets are branded distinct types. addMoney(usd, usdt) is a
compile-time error, not a runtime one:
export type Money<A extends Asset> = Readonly<{
amount: bigint;
asset: A;
[moneyBrand]: A;
}>;Conversions are explicit events carrying the rate, the source of the rate, and the timestamp it was observed. There is no implicit path from one asset to another anywhere in the code.
Every request to api.whop.com goes through lib/whop/gateway.ts. No direct
fetch — not in a job, not in a route, not in a test helper. An ESLint rule
(local/no-whop-fetch-outside-gateway) is exercised programmatically in
test/review-fixes.test.ts to prove it actually fires.
The gateway owns:
- Auth — API key or short-lived OAuth token.
- Version pinning — a single
API_VERSION_DATEconstant; unpinned requests silently get 2025 response shapes. - Idempotency — the three-spelling nightmare compressed into one call site.
- Retry and rate-limit backoff — 429 with
Retry-Afterregex + jittered exponential backoff, capped at 5 attempts. - Custody allow-list — B4/B5 enforced per operation type; the caller cannot pass in the wrong destination.
- Kill switch and demo mode —
#assertMoneyMovementEnabledrejects every outbound money call regardless of caller.
Five facts about Whop's credentials, each of which presents as a different
problem than it is — sandbox origin comes from WHOP_API_BASE, OAuth paths sit
at the origin root not under /api/v1, the OAuth client secret is apik_
prefixed, webhook_secret is null when read via API key, webhook event names
are underscored — are documented as comments inside lib/whop/gateway.ts.
Three injected interfaces let the app run against fakes in tests and demo mode:
| Seam | Real | Demo |
|---|---|---|
Clock |
RealClock |
(mutable-timestamp instance in tests) |
PriceOracle |
SwapQuoteOracle |
ShiftedOracle |
RevenueSource |
WhopStatsSource |
SeededSource |
Feature code never imports a concrete implementation. Wiring happens once at
the composition root (app/composition.ts).
Demo mode (F3) is defended in three places, not one:
/demoreturns 404 unlessDEMO_MODE=true.- Production actions branch on
services.demoModeand route tojobs/demo.*twins that never touch the gateway. - The authoritative gate —
WhopGateway.#assertMoneyMovementEnabledthrows on every outbound money method regardless of caller.
Every event a demo control produces carries origin: 'synthetic' and remains
labelled as such forever in the activity feed.
| Path | Holds |
|---|---|
app/ |
UI, routes, server actions. The only place that renders. |
lib/ |
Config, shared types, branded money types, utilities. |
lib/whop/ |
The Whop gateway. The only code that talks to api.whop.com. |
domain/ |
Pure business logic: reducers, folds, LTV, underwriting, sizing, the state machine. |
ledger/ |
Event store: append, read, projections. |
jobs/ |
Clock jobs and webhook handlers. |
test/ |
Tests. |
Rules the ESLint config enforces:
domain/imports nothing butlib/types. No I/O, clock, network, random, or ORM.ledger/may usedomain/reducers.pglives here only. It never calls Whop.- Only
jobs/andapp/may reachlib/whop/. - Nothing imports
app/.
- Node.js 22+ (Next 16 requires modern Node).
- Postgres 15+ locally, or a Neon/Supabase URL. Two databases are needed — one for dev, one for test — because the append-only trigger test wipes the test DB to prove the trigger actually fires.
- A Whop sandbox app with:
- An API key with
POST /accountspermission (child-account provisioning). - OAuth Confidential app with the
oauth:token_exchangepermission enabled. - Redirect URIs registered for both
http://localhost:3000and the deployed domain.
- An API key with
npm installCopy .env.example to .env.local and fill in the values. Every required
field is documented inline; the loader in lib/config.ts refuses to boot when
STACK_ENV and WHOP_API_BASE disagree (F4).
Migrations live in ledger/migrations/ and are applied by ledger/migrate.ts.
One-shot run:
DATABASE_URL="postgres://..." npx tsx -e "
import { sharedDatabase } from '@/ledger/database';
import { migrate } from '@/ledger/migrate';
(async () => {
const db = sharedDatabase(process.env.DATABASE_URL);
await migrate(db);
await db.pool.end();
})();
"npm run devVisit http://localhost:3000 and click Sign in with Whop.
Stack is a seller/creator product. Every downstream call assumes the caller
has a biz_ account on Whop, because that's the account we can lend against
and hold custody for. Signing in with a plain user account (no company set
up on Whop) will land on /needs-business — a static page explaining why
the app can't do anything for that account and how to set up a business on
Whop. This is the intended behaviour; only accounts with a Whop business
can borrow, pledge, or park through Stack.
Follow these steps in order to reach a working dashboard.
- Sign in. Click Sign in with Whop on the landing page. OAuth
walks you through Whop's consent screen. If you have no business on
Whop yet, you'll land on
/needs-business— set one up on sandbox.whop.com first and sign back in. - Choose a business (only if you have more than one). If your Whop
account owns multiple businesses, you'll land on
/select-businesswith a chooser. Pick the one you want Stack to work with; the choice sticks for the rest of the sign-in session. Sign out and back in to switch. Stack's own custody child accounts are filtered out of the list — only your real businesses show up. - Provision your custody account. Visit
/connectand click Prepare custody account. This calls Whop'sPOST /accountsunder Stack's platform account to create a per-creator custody child. That's where your parked stablecoins and pledged bitcoin will live — segregated from Stack's lending pool and every other creator. Whop's UI will show it as a connected account namedStack custody — <your biz id>. - Park stablecoin. From the dashboard, use Park stablecoin to move idle USDT from your Whop balance into your custody account. This is cure material — the fastest way to top up a warning without touching your bitcoin.
- Pledge bitcoin. Visit
/pledgeto convert parked USDT into cbBTC held in your custody account. Live quote, live re-quote, no sale (Whop swaps stablecoin → bitcoin in-place). - Take an advance. Back on the dashboard, if underwriting approves you (based on your Whop sales history), an Apply for advance button appears. Draw the offered amount; USDT lands in your Whop balance and the ladder + sweep machinery takes over from there.
The /ops screen (/ops?token=<OPS_SECRET>) is a separate operator
surface for viewing pool state, drift banners, and reconciliation
status. It's gated by a distinct secret and not part of the creator
flow.
The chooser at /select-business only appears when your Whop account
owns two or more real businesses. To test it end-to-end:
- Create a second business on Whop's sandbox dashboard.
- Sign out of Stack, sign back in.
- You should land on the chooser. Pick either; the choice is stored in the encrypted session cookie.
- Refresh
/dashboard— no chooser this time (the selection persists). - Sign out and back in — chooser reappears (the choice is scoped to the sign-in session, not persisted per-user).
npm run format:check
npm run typecheck
npm run lint
npm run test
npm run build
These five are the loop's gates. They're referenced by name from the build/review pipeline, so renaming or loosening any one of them rewrites the contract retroactively. Add new scripts freely — never edit these.
Additional scripts:
npm run format— writes formatting fixes.npm run dev— Next dev server.npm run start— Next production server.npm run rebuild-projections— rebuildsprojection_*tables deterministically from the event log, then byte-compares against the previous snapshot. Any disagreement is a bug.
Real Postgres is required for phase tests — the append-only trigger, partial
unique index, and advisory-lock behaviour cannot be faithfully mocked. Set
TEST_DATABASE_URL to a database that is safe to wipe.
TEST_DATABASE_URL="postgres://stack:stack@localhost:5432/stack_test" \
DATABASE_URL="postgres://stack:stack@localhost:5432/stack_dev" \
npm run testThe test tree is organised around the build phases documented in
docs/PLAN.md (referenced in commit history). Every phase test cites the
invariant IDs it exercises. Highlights:
phase-4.test.ts— underwriting, offer pricing, pool caps (P1/P2 concurrency viaPromise.allon two accepts against a pool with room for one).phase-5.test.ts— draw, including a positive test that mutates the source to move the P1 check outside the transaction and asserts the test then fails — the guard is the transaction boundary itself.phase-6.test.ts— sales/sweep, including sweep reversal bounds (C2), the three sale states (recorded → settled,refund_recorded,dispute_opened → resolved), and evasion detection.phase-7.test.ts— the warning ladder. Includes the "gap move past floor" test that verifies a bitcoin crash which jumps every tier at once still emits every interveningwarning_issuedin the same mark evaluation, before any sale (D3).phase-8.test.ts— liquidation. Intent-before-executor, D6 double guard, minimality (post-sale LTV ≤ cure; one satoshi less would fail), slippage guard, over-settle →drift_detected.phase-9.test.ts— reconciliation. Silence-is-alert (F5).phase-10.test.ts— demo mode. Provenance labelling (origin: 'synthetic'vs'api'), F4 boot mismatch matrix, the 30-second seed→draw→mark-drop→warned→cure_expired→liquidation walk.
Total coverage: ~230 tests, all real-DB where relevant.
Stack ships as a standard Next.js app to Vercel. The build has one non-obvious step: migrations must be applied against the production database before first traffic, because the schema is empty on a fresh Postgres.
See .env.example for the full list with inline documentation.
Vercel gives every deployment a distinct URL. If a user starts the OAuth flow
on one Vercel URL (e.g. stack-abc123-project.vercel.app) but Whop redirects
back to a different one (e.g. stack-iota-roan.vercel.app), the session
cookie set on the first domain never reaches the callback, and OAuth fails
with "state did not match." Register the reviewer-facing production URL as the
redirect URI on both sides — the Whop app dashboard and
WHOP_OAUTH_REDIRECT_URI in Vercel — and instruct testers to hit that URL
directly.
Sessions are AES-256-GCM encrypted client cookies. SESSION_SECRET must be a
stable, secret 32-byte value; rotating it invalidates every active session.
Cookies are HttpOnly, SameSite=Lax, and Secure in production.
OAuth refresh runs in middleware.ts (Node runtime, not Edge — the crypto
API used for cookie encryption isn't available on Edge). Refreshing during a
page render is blocked by Next 16 (Cookies can only be modified in a Server Action or Route Handler), so middleware is the authoritative refresh path.
Six cron endpoints under app/api/cron/*, all gated by a Bearer header
against CRON_SECRET:
| Endpoint | Purpose | Cadence |
|---|---|---|
mark-collateral |
Refresh cbBTC → USD mark; run ladder eval. | Every minute. |
settle-sweeps |
Drain webhook queue → poll activity → collect obligations. | Every 5 minutes. |
evaluate-checkpoints |
Day-60 shortfall check; evasion detection. | Daily 09:00 UTC. |
evaluate-maturity |
180-day hard maturity; tier-1 notice on shortfall. | Daily 09:00 UTC. |
expire-cures |
Run D7 waterfall on standing cure_expired warnings. | Hourly. |
expire-offers |
Age out accepted-but-abandoned offers on the 7-day TTL. | Hourly. |
Webhooks land at POST /api/webhooks/whop. The handler verifies the Standard
Webhooks signature before parsing the body (E3), dedupes on webhook-id (a DB
primary key), and enqueues the delivery for the settle-sweeps cron. Whop
retries 3 times over ~70 seconds and then stops forever, so every state
transition reachable by webhook is also reachable by the reconciler reading
GET /financial-activity. A permanently lost webhook costs latency, not
correctness (E4).
Private — take-home evaluation. Not licensed for reuse.