Skip to content

Repository files navigation

Stack

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).


Table of contents


What this is

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.


Architecture at a glance

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.


Invariants and how they're enforced

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.


The event log

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.


Money handling

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.


Whop gateway

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_DATE constant; 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-After regex + 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#assertMoneyMovementEnabled rejects 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.


Seams

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:

  1. /demo returns 404 unless DEMO_MODE=true.
  2. Production actions branch on services.demoMode and route to jobs/demo.* twins that never touch the gateway.
  3. The authoritative gateWhopGateway.#assertMoneyMovementEnabled throws 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.


Layout and dependency direction

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 but lib/ types. No I/O, clock, network, random, or ORM.
  • ledger/ may use domain/ reducers. pg lives here only. It never calls Whop.
  • Only jobs/ and app/ may reach lib/whop/.
  • Nothing imports app/.

Getting started

Prerequisites

  • 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 /accounts permission (child-account provisioning).
    • OAuth Confidential app with the oauth:token_exchange permission enabled.
    • Redirect URIs registered for both http://localhost:3000 and the deployed domain.

Install

npm install

Configure

Copy .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).

Migrate

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();
})();
"

Run

npm run dev

Visit http://localhost:3000 and click Sign in with Whop.

Sign in with a business account

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.

First-time setup checklist

Follow these steps in order to reach a working dashboard.

  1. 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.
  2. Choose a business (only if you have more than one). If your Whop account owns multiple businesses, you'll land on /select-business with 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.
  3. Provision your custody account. Visit /connect and click Prepare custody account. This calls Whop's POST /accounts under 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 named Stack custody — <your biz id>.
  4. 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.
  5. Pledge bitcoin. Visit /pledge to convert parked USDT into cbBTC held in your custody account. Live quote, live re-quote, no sale (Whop swaps stablecoin → bitcoin in-place).
  6. 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.

Testing with more than one business

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).

The five gates

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 — rebuilds projection_* tables deterministically from the event log, then byte-compares against the previous snapshot. Any disagreement is a bug.

Testing

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 test

The 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 via Promise.all on 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 intervening warning_issued in 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.


Deployment

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.

Environment variables

See .env.example for the full list with inline documentation.

The WHOP_OAUTH_REDIRECT_URI gotcha

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.

Session cookies

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.

Middleware

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.


Trigger surface

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).


License

Private — take-home evaluation. Not licensed for reuse.

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages