One global 16×4 grid drum machine on MegaETH. Cells are rented in USDm; a finished pattern is recorded as a Series of NFT editions priced on a bonding curve, and every edition pressed pays the loop's co-creators — the cell owners snapshotted when it was recorded.
loopclub.walkthrough.mp4
Live: app.loopclub.xyz · loopclub.xyz
MegaETH mainnet (chain 4326). Loopclub 0x1030…3631 · USDm 0xFAfD…79E7. See docs/deployments.md.
loopclub is a collaborative, realtime instrument: a single global grid that everyone plays at once, where each cell you light up is a real on-chain state change — not a cached UI flourish synced later. That design choice is the whole product, and it dictated the chain.
Realtime collaboration is write-heavy. Every toggle, renewal, record, and press is a transaction. On a typical L1/L2 — multi-second blocks, gas measured in cents-to-dollars — the experience falls apart two ways: the grid feels laggy (you click, then wait for a block to see your cell light up), and per-click cost makes spontaneous play economically absurd. A drum machine where each hi-hat costs a confirmation dialog and a dime is not a drum machine.
MegaETH is the unlock. Its low-latency blocks and high throughput mean a cell lights up for everyone watching almost as fast as a local app would repaint, and sub-cent gas means a full session of toggling costs a rounding error (rent is priced so full-grid spam runs ~$1/min, paid in USDm so it's stable in dollars). Combined with smart-wallet session UX — one approval at session start, zero modals per toggle — playing loopclub feels like a normal web instrument that just happens to be entirely on-chain, co-created live with strangers.
The short version: loopclub is the kind of consumer product that can only exist on a chain fast and cheap enough to put realtime collaboration directly on-chain. That's why it's on MegaETH, and not anywhere else.
┌─────────────────┐
│ 1. Connect │ Privy login (email/Google) → Kernel smart wallet auto-created.
│ │ A fund popover surfaces the deposit address — copy + top up.
└────────┬────────┘
│
┌────────▼────────┐
│ 2. Approve once │ One-time max-uint256 approve of USDm → loopclub contract.
│ (modal) │ Confirms the long-running spend permission. Modal is intentional.
└────────┬────────┘
│
┌────────▼────────┐
│ 3. Toggle cells │ Click a cell → a popover opens right on it. Default 16 loops;
│ (silent) │ press T to toggle or M for a max toggle. Smart-wallet userOp
│ │ ships without modal. Cell lights up live for everyone watching.
└────────┬────────┘
│
┌────────▼────────┐
│ 4. Cells expire │ After N loops (4s each), cell auto-clears. Owner can renew
│ on time │ before expiry, or someone else can rent it once expired. Lazy
│ │ expiry — no "tick" tx needed.
└────────┬────────┘
│
┌────────▼────────┐
│ 5. Record │ Anyone calls `record()` for `basePrice` (1 USDm). Snapshots the
│ (Series + #1) │ live pattern into a new **Series** and mints **edition #1** to the
│ │ recorder. The co-creator set (cell owners) is frozen here.
└────────┬────────┘
│
┌────────▼────────┐
│ 6. Press │ Anyone calls `press(seriesId)` to mint edition #N. Price follows
│ (edition #N) │ a quadratic curve: `basePrice + alpha·(n−1)²`. Each press splits
│ │ 70% to the frozen co-creators (pro-rata to cells) / 30% treasury.
└────────┬────────┘
│
┌────────▼────────┐
│ 7. Resale │ ERC-2981 5% royalty → contract → `claimRoyalty(seriesId)` lets
│ royalties │ each original co-creator pull their share. Royalties are
│ │ series-keyed: every edition feeds one shared pool.
└─────────────────┘
Key UX guarantees:
- One approval modal at session start, zero modals on per-cell toggles (
uiOptions.showWalletUIs: falseon the smart-walletsendTransaction). - Contextual toggle — the popover opens on the clicked cell (default 16 loops);
Ttoggles,Mmax-toggles, so there are no mouse round-trips. - Fund popover surfaces the smart-wallet deposit address on connect, re-openable any time from the header
⊕ fundbutton. - Sub-cent gas on MegaETH; rent and presses are paid in USDm so cost is stable in dollars.
- Lazy expiry — anyone reading
livePattern()gets the truthful current state without a keeper.
Prices are owner-tunable via setPrices(rentPerLoop, basePrice, alpha, maxRentDurationLoops); the primary-sale split via setSplit(holdersBps, treasuryBps). Treasury is rotatable via setTreasury(addr), and accumulated rent is moved out with sweepUnattributed(to, amount) — see Rent collection below.
| Param | Value | Why |
|---|---|---|
| Loop length | 4 s @ 120 BPM | One bar at the canonical tempo. |
| Grid | 16 steps × 4 tracks = 64 cells | Tight enough for collision pressure, loose enough for collaboration. Tracks: kick / snare / hat / synth. |
| Synth pitch | 3-bit pentatonic (5 of 8 slots used) | Anything you toggle in row 3 sounds in key. Cells 48–63 carry a pitchIdx 0..4. |
| Rent | 0.004 USDm / cell / loop | At ~$0.004 it's effectively free per click; full-grid spam costs ~$1/min. |
| Max rent duration | 32 loops (~2 min) | Stops anyone from camping a cell across a viral pattern. Renewable. |
| Base price (edition #1) | 1 USDm | The flat cost to record() a loop and mint its first edition. |
| Press curve | price(n) = basePrice + alpha·(n−1)², alpha = 0.25 USDm |
Quadratic: #2 = 1.25, #5 = 5, #10 = 21.25 USDm. Popular loops get progressively expensive to copy. |
| Primary-sale split | 70 / 30 co-creators / treasury | Recorder/presser get the NFT but no cut — only cell holders earn. Kills rent-extraction by squatters. |
| Royalty | 5% (ERC-2981) | Series-keyed pull-claim by original co-creators via claimRoyalty(seriesId). |
Calibration intent: recording is cheap (1 USDm) so any finished loop is worth pressing once. The quadratic curve means a loop only gets expensive to copy if it's actually wanted — that's the demand signal. Most loops get pressed 0–1 times; the tail is what matters. Tune from data, not theory. Full rationale (and the original one-shot design it replaced) in docs/economics.md.
record() does, atomically, in one tx:
- Reads
livePattern()(lazy: cells whoseexpiresAtLoop > currentLoop()). - Reverts if the pattern is empty.
- Pulls
basePriceUSDm from the recorder. - Counts unique cell holders × cells held, deduped on-chain (≤64 entries, linear scan is fine).
- Creates a Series storing
pattern,pitches,mintedAtLoop, and the frozenholders[]+cellsPerHolder[]co-creator snapshot. - Mints edition #1 (an ERC-721) to the recorder.
- Splits the
basePrice: 70% pro-rata across the co-creators by cells contributed, 30% totreasury. The recorder gets the NFT but no financial cut. - Emits
SeriesRecorded.
press(seriesId) mints edition #N of an existing series:
- Computes
price(n) = basePrice + alpha·(n−1)²for the next edition number. - Pulls that price in USDm from the presser.
- Splits it 70 / 30 across the same frozen co-creator set — holders don't need to still own the cells.
- Mints the edition to the presser and emits
SeriesPressed.
Co-creators are paid push-style on every record/press (USDm safeTransfer). Resale royalties are pull-style and series-keyed: marketplaces send the ERC-2981 5% to the contract, anyone calls depositRoyalty(seriesId, amount) to attribute it, and each original co-creator pulls their pro-rata share via claimRoyalty(seriesId) whenever they want. The frontend Library surfaces a claim button when you have an unclaimed balance.
The contract takes in money from two streams, and they are handled differently on purpose.
| Stream | Source | Routing |
|---|---|---|
| Primary sale | record() / press() |
Split immediately, in the same tx — 70% safeTransfer'd to co-creators, 30% to treasury. Nothing lingers. |
| Rent | toggle() |
Accumulates in the contract. toggle() pulls rentPerLoop × durationLoops USDm in and leaves it there — it is not forwarded per toggle. |
Rent is therefore an unattributed USDm balance that builds up on the contract over time. The owner drains it on whatever cadence suits them by calling sweepUnattributed(to, amount) — normally to = treasury, but the destination is chosen at sweep time, not hard-coded (treasury, a cold wallet, or a multisig — the owner decides each time).
Why rent isn't auto-routed to the treasury. toggle() is the hot path — it fires on every cell click. Adding a second safeTransfer (rent → treasury) inside toggle() would charge every player extra gas on every click, forever, only to spare the owner an occasional sweep transaction. Letting rent pool and draining it with infrequent owner-initiated sweeps keeps the per-click cost minimal and shifts the gas of moving funds onto the operator, who pays it rarely. This is a deliberate gas trade-off, not an oversight.
Operator note. sweepUnattributed is onlyOwner and transfers whatever amount is passed — it does not enforce the "unattributed" boundary on-chain. The safe-to-sweep balance is the contract's USDm balance minus still-unclaimed royalties (Σ royaltyDepositedSeries − Σ royaltyClaimedSeries). The operator must leave that royalty reserve in place; everything above it is rent plus primary-sale rounding dust.
| Layer | State |
|---|---|
| v1 spec | locked, then reworked one-shot → Series + bonding curve — see docs/v1-spec.md |
| Economics | locked — see docs/economics.md |
| Smart-account stack | Kernel via Privy + EIP-7702 — see docs/stack-and-7702.md |
Contracts (contracts/) |
✅ built + tested (23/23) on Foundry 1.7.1 |
| Bundler / paymaster | configured in Privy dashboard (Kernel MegaETH mainnet endpoints) |
| Hot wallet funding | ✅ 0x6cF2577B57ab7041Ec8815afC768cf73fd9C0Ee3 funded |
| Testnet deploy | chain 6343 — stale, runs the old one-shot model (not redeployed) |
| Mainnet deploy | ✅ chain 4326 — Loopclub 0x1030…3631 (post-rename redeploy, MIDI 128 + 16×9 grid + paid kit flip, deployed 2026-05-28) |
Frontend (frontend/) |
✅ live on Vercel — repoint VITE_LOOPCLUB_ADDRESS to the new address + redeploy |
| Toggle UX | ✅ silent (no modal), approve modal preserved |
| Live grid | ✅ event-streamed — CellRented over WebSocket (getLogs-poll fallback), cells coloured by owner, block-sync badge |
| Record / press / royalty-claim UI | ✅ wired |
| Session keys | not yet — see "Roadmap" below |
Detailed step-by-step status: docs/progress.md.
- Contracts — Solidity 0.8.26, OpenZeppelin (ERC-721 + ERC-2981 + Ownable), Foundry.
- Payment token —
IERC20parameter. Testnet usesMockUsdm(open faucet). Mainnet uses real Ethena USDm at0xFAfD…79E7. - Smart wallet — Kernel via Privy. Not Stateless7702, not MetaMask Smart Accounts Kit.
- Session keys (planned) —
@zerodev/sdk+@zerodev/permissions. - EIP-7702 — set in client SDK code, defaults to counterfactual mode.
- Frontend — Vite + React + TS + Privy SDK + viem + Tone.js.
loopclub/
├── contracts/ # Foundry project — Loopclub.sol + MockUsdm.sol + tests + deploy
├── frontend/ # Vite + React app — live on Vercel
└── docs/
├── v1-spec.md # the on-chain protocol spec
├── economics.md # rent / record / press / split rationale (read this first)
├── ux-architecture.md # FE architecture (note: §3 is superseded by stack-and-7702.md)
├── stack-and-7702.md # current stack + EIP-7702 plan (authoritative)
├── deployments.md # testnet + mainnet addresses
└── progress.md # build plan + per-step status
cd contracts
forge install OpenZeppelin/openzeppelin-contracts --no-commit
forge install foundry-rs/forge-std --no-commit
cp .env.example .env # fill DEPLOYER_PRIVATE_KEY + MEGAETH_MAINNET_RPC
forge build
forge test -vvDeploy:
forge script script/Deploy.s.sol:Deploy --rpc-url megaeth_mainnet --broadcast -vvvforge auto-loads .env. PAYMENT_TOKEN selects the payment token (real USDm on mainnet; unset on testnet to deploy a fresh MockUsdm). TREASURY defaults to the deployer if unset.
cd frontend
cp .env.example .env.local # mainnet addresses already in .env.example
npm install
npm run dev- Session keys — install a permission-scoped key on the Kernel account at login (target = loopclub, selectors =
toggle/record/press, valid 24h). All toggles sign locally — sub-50ms latency, no Privy roundtrip. Today'sshowWalletUIs: falsealready kills the modal, but each toggle still hops through the bundler. - True WebSocket push — set
VITE_WS_RPC_URLto a MegaETH WS endpoint so the live grid streamsCellRentedovereth_subscribeinstead of the 1sgetLogspoll it falls back to today. - Playable NFT / series page — a
/loop/:seriesIdroute that plays the snapshot (Tone.js + on-chain read). Share links already carry?loop=<seriesId>; this would give each loop a real page. - Per-loop share cards — dynamic OG images for
?loop=Nlinks (needs a Vercel edge function); static OG card ships today. - Royalty keeper — a bot that watches marketplace transfers and calls
depositRoyalty(seriesId, …)so resale royalties get attributed without a manual step. - Daily highlight auto-mint — treasury-funded keeper records "loop of the day" by some heuristic (most renewals, most distinct contributors). Drives press demand without a human curator.
- Treasury → multisig — when revenue exists, rotate
owner+treasuryto a Safe (or self-deployed 2-of-3 if Safe isn't on MegaETH yet).
| Contract | Mainnet | Testnet |
|---|---|---|
Loopclub |
0x1030D1a60e248E280294d1b04394f706904E3631 |
0xc655B264Fb2Ae5Ccc203Ba2524FAA8F1834ef249 — stale, old one-shot model |
| Payment token | USDm (real) 0xFAfD…79E7 |
MockUsdm 0x6B92…dab3 (open faucet) |
Previous mainnet deploys (one-shot 0x6B92…dab3, _popcount-bug Series build 0x64D8…bf76, pre-expansion 0xE9Ba…1a, sound-expansion 0xb083…39D6, pre-rename MIDI 128 0xE67B…9A56) are all superseded — see docs/deployments.md for the full audit trail.
0x6cF2577B57ab7041Ec8815afC768cf73fd9C0Ee3 — controlled by the team, used for testnet + mainnet deploy and currently both owner and treasury. Deployer key stays off-repo. Rotate owner + treasury to a multisig when revenue justifies the operational overhead.
