Overview
MASS is an RWA lending protocol for creator fees. A coin launched through MASS names one or more X handles. Its creator fees are collected into the MASS vault, the handles' share is lent into real-world credit through Maple's syrupUSDC, and principal plus yield is paid to each handle on X Money once it reaches critical mass.
pump.fun trade
| creator fee
v
+-----------+ 20% +----------------------+
| vault |-------->| buy + burn $MASS |
+-----------+ +----------------------+
| 80%, split across the named handles
v
+--------------------+ USDC -> syrupUSDC (Jupiter) +------------------+
| ledger[@handle] |------------------------------>| lent via Maple |
+--------------------+ +------------------+
| principal + yield >= $25, checked once a day
v
+--------------------+ operator sends, marks sent +------------------+
| payout queue |------------------------------>| @handle, X Money |
+--------------------+ +------------------+
#The numbers
| Parameter | Value |
|---|---|
| Split of every creator fee | 80% to the named handles, 20% buys and burns $MASS |
| Handles per coin | 1 to 6, split evenly unless shares are set at launch |
| Where the handles' share sits | Maple syrupUSDC on Solana |
| Critical mass (payout threshold) | $25 of principal + yield |
| Payout schedule | once a day, for every handle past critical mass |
| Payout rail | X Money, USD |
| Recipient requirements | none: no sign-up, no wallet |
#Status
| Part | State |
|---|---|
| Site (trymass.tech) | live |
| $MASS token | launching on pump.fun; the CA appears across the site the moment it's set |
| Keeper and ledger (backend) | built and tested in dry-run mode: transactions are built and simulated, nothing is sent |
| Numbers on the home page | sample data until the keeper is live; the footer says so |
These docs describe how MASS works and how its code is built. The proof panel above only shows what a running backend reports, never typed-in numbers.
Launch
A MASS coin is an ordinary pump.fun coin with one difference: the MASS vault is written in as the creator-fee recipient when it's created, and that setting is locked.
#What the launcher does
- Picks a name, ticker and image, and names 1 to 6 X handles (shares default to an even split).
- Signs a single pump.fun create transaction with their own wallet. MASS builds the transaction; it never holds the launcher's keys.
- The coin trades on pump.fun like any other. Nothing about trading changes.
#What the named handles do
Nothing. They don't sign up, connect a wallet or hold any crypto. Their balance starts accruing from the first trade, and the first they hear of it can be a payment on X.
#Why the lock matters
If the fee recipient could be changed after launch, the launcher could redirect fees away from the people they named. Locking it at creation means every creator fee the coin earns reaches the vault, and from there the ledger.
launch tx +- create coin (name, ticker, metadata) +- creator-fee recipient = MASS vault <- fixed at creation +- handles + shares recorded against the mint in the MASS ledger
Fees and the ledger
The keeper claims the creator fees pump.fun accrues to the vault, then books them. Every amount in the ledger is an integer number of base units (lamports, token units, cents): no floating point touches money. The exact implementation is in the Backend code part below.
#The split
For a claimed fee of F base units on a coin with handles h₁…hₙ and shares w₁…wₙ (summing to 1):
to_handles = ⌊0.8 · F⌋ burn = F − to_handles → buys $MASS on the market, then burns it credit(hᵢ) = ⌊to_handles · wᵢ⌋ for every handle remainder = to_handles − Σ credit(hᵢ) → booked explicitly, so no unit is lost or invented
The ledger's first invariant is that nothing appears or disappears:
Σ fees claimed = Σ credited to handles + Σ spent on burns
#Exactly once
Claims, swaps and payouts can all be retried after a crash. Each is keyed by something unique on-chain (a transaction signature, or the claim window it covers), so running a job twice never books the same fee twice.
Lending through Maple
A handle's balance doesn't sit idle while it waits for critical mass. It's held as syrupUSDC, Maple's USDC lending token, on Solana.
#How the vault gets syrupUSDC
syrupUSDC is minted and redeemed with Maple on Ethereum and bridged to Solana, where it trades against USDC. The vault swaps into syrupUSDC through Jupiter, and back to USDC when a payout is due, with a slippage limit on every swap.
#Where the yield comes from
Maple's pool lends to institutional borrowers. As interest accrues, one syrupUSDC becomes worth more USDC. The vault earns that appreciation; it isn't paid out as separate tokens.
#Who the yield belongs to
All of it belongs to the handles, in proportion to how much each had lent and for how long. The clean way to book that is in syrupUSDC units, like shares in a fund:
on a credit of c cents at exchange rate r (USDC per syrupUSDC):
units(h) += c / r
value of h's balance now:
value(h) = units(h) · r_now
yield earned by h:
yield(h) = value(h) − principal(h)
A handle that has been lent for longer, or with more, earns proportionally more, and the sum over every handle equals what the vault actually holds.
Yield is variable and not guaranteed. Lending carries risk, including loss of principal. MASS is independent and isn't affiliated with Maple.
Payouts on X Money
#Critical mass
A handle reaches critical mass when its principal plus yield is at least $25. Once a day the keeper runs payouts: every handle past critical mass (and not set to let it ride) has its balance redeemed from syrupUSDC to USDC and queued for payment.
#Why the last step is a person
X Money has no public API for third parties to send money. So the queue is worked by an operator who sends each payment from the MASS X Money account and records its reference. The backend enforces the rules around that step:
- a payout can be marked sent once; a second attempt is rejected
- every change goes to an audit trail with who did it and when
- each payout is for the exact amount the run queued
#Returned and unclaimed payments
If a recipient hasn't set up X Money, the payment waits for them to claim it. If it comes back unclaimed, the amount is credited to their balance again and goes back to earning. Nothing is kept by MASS.
#Let it ride
A handle can ask to keep its balance lent. With the flag set, the daily run skips it and it keeps earning until the handle asks for it.
Security and custody
#What can move money
| Action | Who | Guard |
|---|---|---|
| Launching a coin | the launcher's own wallet | MASS only builds the transaction |
| Claiming creator fees | keeper, vault key | fees can only go to the vault |
| Swapping into and out of syrupUSDC | keeper, vault key | slippage limit on every swap |
| Buying and burning $MASS | keeper, vault key | 20% of claimed fees, nothing else |
| Sending a payout | operator, X Money | recorded once against the queued amount, with an audit trail |
#Dry run
The keeper runs with DRY_RUN=1 by default. In that mode every transaction is built and simulated against the chain and nothing is sent. Sending real transactions takes an explicit DRY_RUN=0 on the server.
#Keys
The vault key lives only on the server that runs the keeper, never in the repository or on the website. The website holds no keys at all.
#Known risks, stated plainly
- Custody. The vault holds other people's money until it's paid out.
- syrupUSDC is a lending product: Maple's borrowers could default, its liquidity on Solana is finite, and its token has a freeze authority.
- X Money. Payouts depend on an X Money account and X's policies for it.
- $MASS is not a claim on the vault, its loans or their yield.
The site
trymass.tech is a static site: HTML, CSS and plain JavaScript with no framework and no build step. Every file is served as written.
| File | What it does |
|---|---|
site/index.html |
the page; also carries the one-line $MASS launch config |
site/css/style.css |
the whole design system: tokens, components, motion rules |
site/js/accretion.js |
the hero: a canvas accretion disk that fees fall into and payouts fire out of |
site/js/data.js |
the numbers: sample data today; live from the backend's /api/home once the keeper is deployed, falling back to sample data if it doesn't answer |
site/js/main.js |
everything else that moves: counters, ticker, vault chart, keeper log, payout meter, estimator, boards |
site/js/ca.js |
shows the $MASS contract address everywhere it belongs once it's set |
site/docs.html |
this page, generated by tools/build-docs.mjs from Markdown |
#The accretion disk
About 4,200 particles (2,200 on phones) orbit a black core. Each one has a radius and an angle, and every frame they follow two rules: inner orbits turn faster (Kepler's third law, angular speed ∝ r^−1.5), and matter drifts inward more slowly the closer it gets, so a bright ring piles up at the inner edge. A particle that crosses the horizon is absorbed, respawns at the rim, and nudges the vault counter up.
function step(dt) {
dt *= SPD;
tilt += (tTilt - tilt) * Math.min(1, dt * 2); rot += (tRot - rot) * Math.min(1, dt * 2);
for (let i = 0; i < N; i++) {
const o = i * S; let r = P[o]; const q = r / RMAX;
P[o + 1] += Math.min(3.2, 0.13 * Math.pow(q, -1.5)) * dt; // Kepler: inner orbits spin fast
r -= dt * RMAX * 0.022 * Math.pow(q, 0.8) * P[o + 4]; // in-fall slows near the core, so the inner ring piles up
if (r < R0 * 1.1) { spawn(i, true); absorbedAcc++; continue; }
P[o] = r;
}The disk is tilted towards the viewer and rolled slightly, then projected to the screen:
function proj(r, th, z, out) {
const x = r * Math.cos(th), y = r * Math.sin(th) * tilt + z;
const c = Math.cos(rot), s = Math.sin(rot);
out[0] = cx + x * c - y * s; out[1] = cy + x * s + y * c;
}The far half and the near half of the disk are drawn into two separate trail buffers, so the black core sits between them and the near side passes in front of it. The side turning towards the viewer is drawn brighter (Doppler beaming), and a thin arc over the top of the core stands in for the far side of the disk bent by gravity. Every few seconds a jet fires along the disk's axis and a payment pill appears at its tip.
#Motion
Motion is on by default. If a visitor's system asks for reduced motion, the disk runs at 55% speed and slide-ins are dropped; the Motion button in the footer turns everything off and is remembered.
#The payout estimator
The "Pick a handle" calculator uses the same rules as the protocol: 80% of fees to the handle, payouts on a daily run once a balance is past $25, and monthly compounding at the current lending rate for "let it ride".
const dailyFees = vol * fee, toHandle = dailyFees * D.split.handle * 30, burn = dailyFees * D.split.burn * 30;
const i = D.apy / 100 / 12, m = toHandle; const fv = m * ((Math.pow(1 + i, 12) - 1) / i);
const perDay = dailyFees * D.split.handle; const hours = (D.criticalMass / perDay) * 24;It's labelled as an estimate on the page: the lending rate moves.
Launch switch and hosting
#Putting the $MASS CA on the site
The contract address is set with one command, node tools/set-ca.mjs <CA>. It refuses anything that isn't the real $MASS mint before it writes a byte.
First it asks Solana what the address is. It must be an existing token mint, on either SPL Token or Token-2022:
if (![TOKEN, TOKEN22].includes(owner) || parsed?.type !== 'mint') fail(`this address is not a token mint (owner ${owner}, type ${parsed?.type || 'unknown'}). It may be a wallet, a pool or a bonding-curve account.`);Then it reads the token's symbol, from the mint's own on-chain metadata where it exists (pump.fun coins on Token-2022 carry it), falling back to pump.fun and DexScreener. Anything other than MASS is rejected:
if (sym.toUpperCase() === 'MASS') ok(`symbol $${sym} · name "${meta.name}" (${src})`);
else if (flags.has('force')) warn(`symbol is $${sym}, not $MASS — continuing because of --force`);
else fail(`this token's symbol is $${sym} ("${meta.name}", from ${src}), not $MASS. Wrong CA? Re-run with --force if you're sure.`);Only then does it rewrite the single config line in index.html, with an atomic rename so the server never serves half a file:
const re = /<script id="mass-config">[^<]*<\/script>/;
if (!re.test(html)) fail(`${idx} has no <script id="mass-config"> line to update.`);
const line = `<script id="mass-config">window.MASS_CONFIG = ${JSON.stringify(cfg)};</script>`;
const next = html.replace(re, line);
if (flags.has('dry')) { console.log('\nWould write:\n ' + line + '\n\n(dry run: nothing written)'); return false; }
fs.writeFileSync(idx + '.tmp', next); fs.renameSync(idx + '.tmp', idx); // atomic swap so the server never serves half a fileIn the browser, site/js/ca.js reads that config and fills in every place the CA appears: the nav pill, the "$MASS is live" strip, the buy button and the footer. Before launch the pill says "soon" and nothing on the page copies an address.
const ca = typeof C.ca === 'string' && /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(C.ca) ? C.ca : null;
const short = ca ? `${ca.slice(0, 4)}…${ca.slice(-4)}` : 'soon';The script then uploads the page and fetches https://trymass.tech to confirm the live copy shows the new CA. Each change is logged in launch-log.json, and --clear returns the site to its pre-launch state.
#Hosting
visitor --https--> Cloudflare --tunnel--> cloudflared-mass (VDS) --> nginx 127.0.0.1:8089 --> /var/www/mass
- The site has its own Cloudflare tunnel and its own nginx site; nothing else on the server shares them.
- nginx listens only on localhost, so the only way in is through Cloudflare.
- HTML is served
no-cache, which is why a new CA is live the moment it uploads. Scripts and styles cache for five minutes, images for a day. /api/is reserved on the same origin for the backend, so the site and this page's proof panel call it with no cross-origin setup.
The server config is kept in the repo under deploy/, and node tools/deploy-site.mjs publishes the site.
Overview
The MASS backend turns pump.fun creator fees into dollar payouts to X handles. It is two processes built from one TypeScript codebase in backend/: a keeper that runs the money pipeline on a schedule, and an HTTP API that serves the site, builds launch transactions and runs the operator's payout queue. Both share one Postgres database.
trade on pump.fun
│ creator fee (SOL)
▼
coin's creator vault ──distribute_creator_fees (permissionless)──▶ MASS vault
│ claim job books it per coin
▼
┌──────────── ledger (double entry, integer units) ────────────┐
│ 80% → h:<handle> (SOL, by the coin's bps) 20% → burn (SOL) │
└───────────────────────────────────────────────────────────────┘
h:<handle> SOL ──convert (Jupiter)──▶ USDC ──lend (Jupiter)──▶ syrupUSDC (Maple, earns NAV growth)
│
daily run: mass ≥ $25 (or asked) ──redeem (Jupiter)──▶ USDC ──reserve──▶ q:<handle> = due payout
│
operator pays on X Money by hand ──mark sent + reference──▶ float (returned → back on h:<handle>)
burn SOL ──buyback (Jupiter)──▶ $MASS ──SPL BurnChecked──▶ burned
#What each part does
- Launch (
src/launch/): builds the pump.fun launch transactions with the MASS vault locked in as the coin's only creator-fee shareholder (100%, one-time split), for the deployer's wallet to sign; registers the coin and its 1–6 handles after checking the chain. - Accrete (
src/jobs/claim.ts): pays each coin's creator vault to the MASS vault and books every distribution: 80% to the handles by basis points, 20% to the burn bucket. - Lend (
src/jobs/lend.ts): converts each handle's fee SOL to USDC, then USDC to Maple syrupUSDC through Jupiter. Each handle owns its shares; its yield is its shares × (NAV now − NAV at entry). - Pay (
src/jobs/payout-run.ts,src/ledger/payouts.ts): once a day, handles at or above $25 (and not letting it ride) are redeemed to USDC and queued; an operator sends each payment on X Money and records the reference, exactly once. - Burn (
src/jobs/buyback.ts): the 20% buys $MASS and burns it. $MASS is not a claim on the vault; nothing credits holders.
#Simulated vs live
| Piece | Status |
|---|---|
| Ledger, jobs, payout queue, API, ops page | Built and tested (52 tests) against an in-memory Postgres and an in-memory chain |
| pump.fun launch transaction | Built by buildLaunch; simulated on mainnet (read-only): the resulting fee-sharing config has the vault at 10 000 bps, locked (scripts/verify-launch.ts) |
pump.fun claim (distribute_creator_fees) |
Built with Bling's live instruction layout; exercised through the mocked real adapter; the devnet end-to-end script (scripts/devnet-e2e.ts) is ready but was blocked by the devnet faucet |
| Jupiter swaps | /build → compile → simulateTransaction verified on mainnet for USDC→syrupUSDC and SOL→USDC (scripts/verify-jupiter.ts); nothing sent |
| syrupUSDC NAV | Read live from Ethereum (convertToAssets) by EthNavSource |
| Sending transactions | Off. DRY_RUN=1 is the default and the only send path refuses under it (see 08-security) |
| X Money | No API exists; payments are sent by a person and recorded in the ops queue |
The public site shows sample data until the API reports real (non-demo) data; GET /api/proof states the mode (dry-run, devnet or mainnet) on every response.
Architecture
#Processes
One entry point, src/main.ts, started with flags:
tsx src/main.ts --api # HTTP API only (production unit mass-api)
tsx src/main.ts --keeper # keeper only (production unit mass-keeper)
tsx src/main.ts # both in one process
tsx src/main.ts --demo # both, on the in-memory chain + in-memory database, with 30 simulated days of history- Keeper: node-cron schedules (
src/keeper/jobs.ts) →runJob(src/keeper/scheduler.ts) writes ajob_runsrow per run. Jobs that send transactions or move balances run one at a time behindchainMutex. A lease insettings(keeper.lease, renewed by the heartbeat) stops a second keeper from starting against the same database. - API: Hono on Node (
src/api/server.ts), listening on127.0.0.1:8477by default. On the VDS nginx proxieshttps://trymass.tech/api/to it, so the site calls it same-origin. - Database: Postgres via postgres-js in production (
DATABASE_URL=postgres://…); PGlite (Postgres compiled to WASM) for tests and the demo (DATABASE_URL=pglite:memoryorpglite:<dir>). Queries are parameterizeddrizzle-ormsqltemplates that run unchanged on both (src/db/client.ts). The schema is one hand-written migration,migrations/0000_init.sql, applied bymigrate().
#The chain boundary
Everything the keeper needs from Solana is behind the Chain interface (src/chain/types.ts):
| Implementation | Used for |
|---|---|
FakeChain (src/chain/fake.ts) |
tests and --demo: per-coin fee vaults, swaps with configurable costs, NAV, tx fees, failure injection. Never touches a network |
RealChain (src/chain/real.ts) |
JSON-RPC reads, simulateTransaction, Jupiter /build, and the single guarded send() |
Price and NAV sources are separate interfaces: SpotSolPrice (lower of Coinbase and Kraken) and EthNavSource (src/chain/sources.ts).
#Data flow
jobs (keeper) ──▶ ledger + swaps + intents + payouts (Postgres) ◀── ops routes (API)
│
└──▶ read models (src/api/queries.ts) ──▶ /api/home · /api/proof · …
The keeper is the only writer of swaps and claims. The API writes launch records, and the ops routes write payout state changes and audit rows; every money-moving state change is a ledger entry in the same DB transaction.
#Directory map
| Path | What |
|---|---|
src/main.ts |
entry point, flags, demo boot, shutdown |
src/env.ts |
configuration; DRY_RUN default; secrets readers |
src/runtime.ts |
builds the chain, sources and database from the env |
src/core/ |
pure integer math: money.ts, split.ts (80/20, pro rata), handles.ts (X handles, launch record), payout.ts (selection, breakdown, APY) |
src/db/client.ts, migrations/ |
driver selection, query helpers, migrations |
src/ledger/ |
ledger.ts (double entry), claims.ts, swaps.ts (batched swaps, exactly once), payouts.ts (ops queue + audit) |
src/jobs/ |
rates, coins, claim, lend (convert + lend), payout-run, buyback, reconcile (+ heartbeat, TVL) |
src/keeper/ |
job context, settings, events, scheduler, schedules |
src/chain/ |
chain interface, fake, real, Jupiter client, price/NAV sources |
src/solana/ |
no-SDK primitives from Bling: base58, PDAs, legacy tx compile/sign, pump.fun layouts, SPL burn, v0 signing |
src/launch/ |
pump.fun launch builder (@solana/web3.js), build/submit service |
src/api/ |
HTTP server, read models, ops auth, ops page |
scripts/ |
devnet keys, read-only mainnet verifiers, devnet end-to-end |
test/ |
vitest suites |
deploy/ |
systemd units, nginx location, env template, runbook script |
#Provenance
The pump.fun layer (src/solana/pumpcore.ts, pump.ts, keys.ts, tx.ts, rpc.ts, src/launch/pump-launch.ts) and the scheduler, claim and ops patterns are copied from Bling, the sibling project running the same pump.fun pipeline live since 2026-09-27, with Bling-specific parts (Pons, EVM, mindshare, lotto, swap.my) removed.
Launch and the fee-recipient lock
Anyone launches a coin on pump.fun and names 1–6 X handles. The launch makes the MASS vault the coin's only creator-fee shareholder and locks that split, so every creator fee the coin ever earns can only be paid to the vault. The named people never sign anything.
#How pump.fun routes creator fees
A pump.fun coin's creator fees accrue in a per-creator vault (creator-vault(creator)). pump.fun's fee sharing replaces the creator with a per-mint sharing config (pump_fees, seeds ["sharing-config", mint]) that lists shareholders in basis points. update_fee_shares sets the list once and revokes the admin, after which it can't change. distribute_creator_fees is permissionless: anyone may call it to pay the vault's balance to the shareholders.
#The transactions (src/launch/pump-launch.ts, buildLaunch)
tx 1 create_v2(creator = deployer) signers: deployer, mint
create_fee_sharing_config (the curve's creator becomes the sharing config)
tx 2 update_fee_shares([{ vault, 10 000 bps }]) signer: deployer ← split set and locked
[ATA + buy_exact_sol_in] optional first buy
With LAUNCH_LUT set to an address lookup table holding launchLookupAddresses(), the whole launch fits one v0 transaction and is atomic. Without it, it is the two transactions above, signed together by the wallet.
The instruction layouts are Bling's, which mirror @pump-fun/pump-sdk 2.0.0; the Anchor discriminators match sha256("global:<name>")[0..8] (checked by scripts/verify-launch.ts). pump.fun's current docs also describe _v2 variants of update_fee_shares and distribute_creator_fees; the v1 instructions used here were accepted by mainnet simulation on 2026-09-27.
#The API flow (src/launch/service.ts)
POST /api/launch/buildvalidates the name, ticker and handles (even split unless bps are given; shares must total 10 000), creates a nonce, and returns:transactions: base64 v0 transactions. The server generates the mint keypair, signs its slot in tx 1 and discards the key; or the client passes its ownmintand signs that slot itself.recordText: a plain-text launch record naming the coin, the mint, the vault and the split, ending in canonical JSON.description: the public split line for the coin's metadata, e.g.Fees split via MASS: @a 50% · @b 50%.simulation: tx 1 simulated against the configured cluster (real mode).
- The deployer's wallet signs
recordTextwithsignMessage, signs and sends the transaction(s). POST /api/launch/submit {nonce, signature, tx}verifies the ed25519 signature against the deployer, then checks the chain (checkLaunchOnChain): the launch transaction was paid by the deployer and touches the mint; the bonding curve's creator is the sharing config; the sharing config's only shareholder is the vault at 10 000 bps and it is locked; the on-chain name and ticker match.409means "not visible yet, retry". Then it registerscoins+coin_sharesin one DB transaction. Submitting the same record again returns the same coin.
const onlyVault = sc.shareholders.length === 1 && sc.shareholders[0]!.address === a.vault && sc.shareholders[0]!.bps === 10_000;
if (!onlyVault) return sharingConfigLocked(sc) ? fail(400, "the coin's creator fees do not go to the MASS vault") : fail(409, "the coin's fee split is not set yet");#Coins launched elsewhere
The coins job scans pump_fees for sharing configs whose first shareholder is the vault (RealChain.scanCoins). Such coins have no signed record; after a grace period their 80% goes to the first @handle in their description (firstHandle), otherwise it waits in the unassigned account.
#Verified on mainnet (read-only, 2026-09-27)
pnpm verify:launch simulated, with a funded mainnet wallet as the unsigned payer and a throwaway key as the vault:
- the whole launch in one transaction (create + fee config + lock + 0.01 SOL buy, via a lookup table): the resulting sharing config was
[{vault, 10000}],locked=true, and the curve's creator was the sharing config; - the default two-transaction launch: tx 1 simulated OK (≈180 000 CU), 1 093 + 1 144 bytes. Nothing was signed or sent. A single transaction without a lookup table is 1 321 bytes, over Solana's 1 232-byte limit, which is why MASS needs its own lookup table before launch (see 10-running).
Fees and the ledger
#Claim → book (src/jobs/claim.ts)
- Send: for each active coin whose pending creator fees (× the vault's bps) reach
claim.min_lamports(0.02 SOL), one permissionless transaction:transfer_creator_fees_to_pump(graduated coins, moves PumpSwap fees into the creator vault) +distribute_creator_fees. Intents gobuilt → sent → confirmed | failed | expired; the signature is stored before the transaction leaves. UnderDRY_RUNit is built and simulated only (simulated). - Ingest: because anyone can call
distribute_creator_fees, the keeper does not trust its own sends for booking.ingestDistributionswalks the vault's own signatures since a cursor, decodes each pump.funDistributeCreatorFeesEventthat paid the vault (from the transaction's inner instructions), and callsrecordClaimonce per(tx, coin).
#The 80/20 split (src/core/split.ts)
All in lamports, integer only:
const handlesTotal = (total * 8000n) / 10_000n; // floor(80%)
const parts = allocateByBps(handlesTotal, shares); // by bps, remainder to the largest share
return { handlesTotal, burn: total - handlesTotal, … }; // the burn gets 20% plus roundingHandles are split evenly unless the launch gave shares (evenBps(3) → 3334/3333/3333). The USD value at claim time is stored for display only (fee_claims.usd_cents); the ledger stays in lamports until the SOL is actually converted, so each handle's principal is exactly the USDC its SOL bought.
#Double-entry ledger (src/ledger/ledger.ts)
Every event is one entry with a unique key, posted at most once, whose rows sum to zero per asset. post() throws UnbalancedEntry otherwise; re-posting an existing key writes nothing and returns false.
| Account | Holds |
|---|---|
h:<handle> |
the handle's SOL (unconverted), USDC (liquid), SYRUP (lent) |
q:<handle> |
USDC reserved for its due payout |
burn |
the 20% (SOL, then $MASS until burned) |
unassigned |
the 80% of a coin with no handles yet |
float |
USDC owed to the X Money operator for payments already sent |
swap:<id> |
inputs of a swap in flight |
ops:gas |
SOL the vault spent on fees (topped up by the operator) |
ext:* |
the outside world (ext:fees, ext:market, ext:offramp, ext:burned, ext:gas, ext:adjust) |
Units per asset: SOL lamports, USDC micro-dollars (1 cent = 10 000), syrupUSDC base units (6 decimals), $MASS base units. Nothing money-related is a float; fee_claims, ledger and swaps amounts are numeric(39,0).
#Invariants
- Every entry balances per asset:
unbalancedEntries()must be empty. - The vault holds what the ledger says: for each asset, the on-chain balance = Σ of every non-
ext:account (internalTotals()). Thereconcilejob checks both every 10 minutes and stores the result for/api/proof. - Each claim's rows are
ext:fees −total,h:… +80% by bps,burn +rest: sums to zero by construction (claimRows).
#Idempotency keys
| Step | Key |
|---|---|
| fee claim | fee_claims UNIQUE (tx, coin) + entry claim:<id> |
| late handle assignment | assign:<claim id> |
| swap plan / settle / revert | swap:<id>:plan · swap:<id>:settle · swap:<id>:revert; swaps.sig UNIQUE |
| transaction fees | gas:<signature> |
| payout state changes | payout:<id>:reserve · :sent · :returned · :cancel; payouts.ref UNIQUE |
| daily run | payout_runs.run_date UNIQUE; one open payout per handle (partial unique index) |
| burns | burn:<intent id> |
| float / gas top-up | float:<signature> · gas_topup:<signature> |
Tests: test/core.test.ts (split, rounding, allocation) and test/pipeline.test.ts (every step re-run, invariants checked after each).
Lending through Maple syrupUSDC
#What syrupUSDC is on Solana (verified 2026-09-27)
- Mint
AvZZF1YaZDziPY2RCK4oJrRVrbN3mTD9NL24hPeaZeUj: classic SPL Token, 6 decimals, mint authority = the Chainlink CCIP burn/mint pool signer, freeze authority set (76QpxAGtsrbwP5nmdCVHyQe2AHVML7cqhxF9FpBxLJjp, holder not published). - It is the Ethereum syrupUSDC share (
0x80ac24aA929eaF5013f6436cdA2a7ba190f5Cc0b, ERC-4626) bridged 1:1 by CCIP (lock/release on Ethereum, burn/mint on Solana). - Maple has no program on Solana. Native mint/redeem is an asynchronous CCIP round trip to Ethereum (redemptions go through Maple's withdrawal queue). Instant entry and exit on Solana is a DEX swap, which is what the keeper does.
- Jupiter routes both directions through Manifest, Orca Whirlpool and AlphaQ; quotes for $1k–$100k priced 0.3–2.3 bps from NAV (research, 2026-09-27).
#The two swaps (src/jobs/lend.ts)
- convert: all handles' fee SOL → USDC in one batched swap, proceeds split pro rata by lamports. Guard: at most
guard.convert_bps(1.5%) worse than SOL spot. - lend: all handles' liquid USDC (except handles being paid) → syrupUSDC, shares split pro rata by USDC. Guard: pay at most NAV +
guard.lend_premium_bps(0.5%). Minimum batchlend.min_micro($20).
Both go through runSwap (src/ledger/swaps.ts): Jupiter Swap API V2 GET /build (quote + instructions for the vault as taker) → compile a v0 transaction (compileBuild) → simulate for compute units → recompile → store the signature → send → settle. Batches are capped at swap.max_in_micro ($10 000) per swap.
#NAV
EthNavSource reads convertToAssets(1e18) on the Ethereum pool with eth_call: USDC base units per 10^18 share units, stored as rates.SYRUP_NAV_E18 by the nav job every 15 minutes. On 2026-09-27 it returned 1185113312315532327 (1.185113 USDC per syrupUSDC). A drop of more than 0.5% between reads raises an alert (it should only go up).
#Yield, per handle, over time
Each handle owns its own syrupUSDC shares. When a lend settles, each handle's shares are recorded with their NAV cost basis (basis_micro = shares × NAV_entry), separately from the USDC it paid (the difference is its execution cost, cost_micro):
value_h(t) = shares_h × NAV(t) yield_h(t) = shares_h × NAV(t) − Σ basis_h (unrealized, pure exchange-rate appreciation) realized at redeem = shares × NAV_exit − basis removed (ledger yield_micro on the handle's USDC row)
Because yield is shares × ΔNAV, it is automatically pro rata to each handle's lent balance and to how long it was lent: a handle that lent $120 for 30 days earns 120/36 of what a handle that lent $36 for the same days earned (asserted in test/pipeline.test.ts). Every cent of it stays on the handle. APY for the site is NAV growth annualized over the last 7 days (apyPercent); Maple's API reported 5.17% (7-day) on 2026-09-27.
#Costs
Swap costs (paying slightly above NAV, selling slightly below) are the handle's and are recorded apart from yield (HandleBalances.costs, swapCostsCents in /api/handles/:handle). For a handle paid out within days, round-trip costs of a few basis points can exceed the yield earned; see the open decisions in the README.
#Verified on mainnet (read-only)
pnpm verify:jupiter ran the production RealChain.quote() → prepareSwap() path with funded public wallets as the simulated taker: USDC → syrupUSDC ($100, Manifest, ~1 bp under NAV) and SOL → USDC (0.5 SOL) compiled and simulated OK. The redeem direction (syrupUSDC → USDC) was not simulated: no plain-wallet syrupUSDC holder turned up in recent activity to stand in as taker. Its quotes were checked separately (1.0–2.3 bps under NAV for 844–84 380 shares). Nothing was signed or sent.