Design Decisions

Design Decisions

A running log of architectural choices. The alternative considered, and why we picked the one we did.

1. In-house liquid staker (jpSEI), not Splash

Chosen: JellyPad runs its own liquid staker, JellyPadStaker. The receipt token is jpSEI. Principal is delegated directly to a Sei validator via the staking precompile.

Alternative: Splash spSEI. Used in v3.

Why we moved off Splash. Two real problems with relying on a third-party LST:

  1. Splash's instant-unstake buffer was sized for the whole Sei DeFi ecosystem. We saw it as low as 80 SEI. A single sell anywhere on Sei could drain it and break every JellyPad sell flow until they refilled. Hard dependency we couldn't control.
  2. Splash takes 6% of validator rewards as a platform fee. Running our own staker captures that share back for jpSEI holders.

Trade-off. We now own the buffer-management problem. If our buffer drains and demand persists, instant-unstakes start reverting and users have to use the 21-day path. Mitigated by the dynamic-buffer logic (decision 5) and per-tier fee escalation.

2. Pair (jpSEI, TOKEN) at graduation, not (WSEI, TOKEN)

Chosen: The graduated Saphyre pair is (jpSEI, TOKEN). The pool's jpSEI side carries the staking-yield exchange rate forward in time.

Trade-off. Buyers hitting Saphyre directly with native SEI need a multi-hop path (SEI → WSEI → jpSEI → TOKEN). For most aggregators this works automatically once jpSEI has any liquidity registered. For hand-rolled swaps via the V2 router it's an extra step. The reference frontend's JellyPadRouter handles the staking leg natively.

Alternative: (WSEI, TOKEN). Cleaner UX from outside, but loses the in-pool yield property. Rejected.

3. LP burned at graduation (not held by factory)

Chosen: At _migrateToDex(), the LP receipt is minted directly to address(0xdEaD). Mathematically unrecoverable.

Why not held by factory. Earlier versions sent LP shares to the factory, leaning on "the factory has no removeLiquidity helper" for permanence. Burning is strictly safer:

  • Owner key compromise: factory-held LP could be drained by a malicious upgrade. Burned LP cannot.
  • Audit clarity: "factory holds LP, trust we won't withdraw" is a weaker claim than "burned, here's the on-chain receipt at 0xdEaD."
  • No real upside to factory custody. Saphyre's in-pool fees still accrue to the LP's reserves either way; nobody can withdraw them in either model.

Side effect. The pool's jpSEI side compounds in SEI value over time, which makes the graduated LP's effective TVL grow without anyone having to top it up.

4. Configurable graduation mcap (per-token)

Chosen: factory.createToken() takes a graduationMcapUsd parameter. Default $69k for production launches; can be overridden per-call (e.g., $1 for a smoke test).

Alternative: Hardcoded $69k. Simpler but useless for testing on small budgets.

5. Dynamic buffer, with bounds

Chosen: When dynamicMode = true, the effective buffer target is computed from on-chain state:

target = max(
  staticTargetBuffer,        // owner-set absolute floor
  totalUnderlying * ratio,    // % of total jpSEI value (default 5%, bounded 1% to 30%)
  totalPendingSlowSei,        // already-promised debt to slow-unstakers
  trailingDemand7d * mult     // recent demand × multiplier (default 2x, capped 3x)
)

The point: buffer should react to the actual size of the protocol and to recent stress, without manual intervention.

Why bounded. An attacker spamming instantUnstake could otherwise inflate trailing demand and force the protocol to undelegate everything. The 3x multiplier cap and 30% ratio cap prevent unbounded over-buffering.

Why opt-in. We deploy with dynamicMode = false so we can observe behavior under static targets first. Owner flips it on once operational data informs the right ratios.

6. SEI/USD price set at deploy, owner-updateable (not oracle)

Chosen: Each token stores its own seiUsdPrice. Owner can update via setTokenSeiUsdPrice.

Alternative: read live from Sei's oracle precompile (0x0000000000000000000000000000000000000091). Rejected because the oracle isn't deployed on testnet and the wrapping/parsing is non-trivial. Worth revisiting once a meaningful number of tokens are live on mainnet.

7. Curve sells through instantUnstake, not undelegate

Chosen: During the curve phase, the token contract calls staker.instantUnstake to obtain native SEI for the seller.

Why not undelegate. The Cosmos staking module has a 21-day unbonding queue. A sell flow that went through STAKING.undelegate would mean every seller waits 21 days for their SEI.

8. Symmetric 1% buy + 1% sell, with 50/50 split

Chosen: Curve sells take a 1% fee on the gross SEI output. Routed sells (post-graduation, via JellyPadRouter) apply the same. Both protocol fees split 50/50: half to the operator treasury, half donated to the staker buffer.

Why symmetric. Mirrors pump.fun's 1% buy + 1% sell. Predictable.

Why split fees, not 100% to one or the other.

  • 100% to operator: every other launchpad does this. Defeats the "fees compound back to holders" mechanic that's our headline.
  • 100% to buffer: maximum holder yield, but no operating revenue. Can't run a team.
  • 50/50: keeps the network-effect mechanic real (50% does compound back) while funding ongoing development. Capped at 70% operator share by MAX_OPERATOR_FEE_BPS so the owner can never unilaterally take it all.

9. 1% graduation fee in jpSEI, not native SEI

Chosen: At _migrateToDex(), the contract takes 1% of its accumulated jpSEI off the top before pairing the rest with TOKEN on Saphyre. Fee delivered as jpSEI, sent to the factory.

Why 1%. Aligns with the launchpad norm — letsbonk, sun.pump and ape.store all sit at ~1% on graduation. Pump.fun dropped to zero, but its yield-on-buy mechanic (the structural differentiator) requires meaningful protocol revenue to fund the staker buffer; 1% is the smallest number that does both.

Why jpSEI. Avoids paying the staker's instant-unstake fee at the migration step. The factory accumulates yield-bearing principal, then converts to native SEI via the 21-day slow-unstake path — so the buffer is never drained to fund team revenue.

10. JellyPadRouter contract, not token transfer hook

Chosen: Post-graduation buys/sells route through JellyPadRouter. The router takes the protocol fee, performs the staking step in-flight, and forwards to Saphyre. Token contract stays a clean ERC-20 with no transfer side-effects.

Alternative: Fee-on-transfer hook on the token. Rejected because:

  • Aggregators (1inch, Squid, Matcha) blacklist fee-on-transfer ERC-20s
  • DEXes show "honeypot" warnings on the pair
  • Token loses portability. Every wallet, lending app, bridge has to special-case it

11. Wei vs usei: precompile units

Note for contributors. Sei's staking precompile is asymmetric in how it accepts amounts. delegate(string) is payable and reads msg.value (wei, EVM convention). undelegate(string, uint256) reads the amount as a parameter, interpreted as usei (Cosmos 1e6 denom).

This bit us on the v1 staker. Mixing the units silently reverts. The fix lives in JellyPadStaker._toUsei(wei) = wei / 1e12, applied to every undelegate call.

If you ever add a new precompile call, double-check the unit convention.

12. Permission model: factory owner ≠ token creator

Chosen: The factory owner can update integrations (router, staker addresses) and per-token oracle price. Token creators have no post-deploy control over their token's contract logic.

Alternative: Creator-owned tokens with their own admin keys. Adds a per-token rug surface. Rejected.

13. Operator console as a separate dev-only route

Chosen: Owner ops live at /admin in the reference frontend. The route is gitignored at apps/web/app/(local-admin)/ and runtime-gated to NODE_ENV === "development". Production builds 404 the route entirely.

Why dev-only. Owner-controlled functions don't need to be public. Bundling them into a public site would be both a UX surface for normal users and a footgun (anyone seeing the page would assume they could call those functions). Keeping admin local also means we never accidentally ship our private operating dashboard.

14. seiscan.io as the canonical explorer

Chosen: All script output, doc links, and frontend external links point at seiscan.io (mainnet) or testnet.seiscan.io (atlantic-2). DexScreener uses seiv2 as Sei's chain slug; the canonical link to a JellyPad pair is https://dexscreener.com/seiv2/<pair>.

15. Stripped-down forge-std

Note for contributors. The contracts repo's forge-std is a minimal subset (5 files). Tests can't use vm.skip, vm.envOr, or several console.log overloads.

We patched one issue. Modern Foundry expects assertEq failures to revert the test, but the legacy stripped forge-std only set a _failed flag (soft-fail). Tests that mismatched assertions would log errors but report PASS. Fixed in lib/forge-std/src/Test.sol: fail() now actually reverts. Discovering this turned up 4 latent bugs in deprecated v1 contracts (now flagged as known failures, not blocking).

16. Operational CLI (sei)

Note for contributors. The repo ships a single bash wrapper at contracts/script/sei that dispatches subcommands. It reads creds and addresses from contracts/.env. --mainnet switches the active network. Subcommands include:

  • Networks: balance, state, factory, tokens, tx
  • Deploys: deploy-factory{,-v2}, deploy-router, deploy-staker
  • Lifecycle: create-token{,-v2}, buy, sell, harvest, seed, router-buy, router-sell, buysell, run test|staker-test
  • Staker ops: staker-state, staker-set-validator, staker-allow-sink, staker-seed, staker-stake, staker-instant-unstake, staker-slow-unstake, staker-claim

Reduces friction for repeated operations during development.

17. Graduation revenue via 21-day slow-unstake, not instant conversion

Chosen (v5): When the team converts factory-held graduation jpSEI to native SEI, the path goes through the staker's slow-unstake (unstake → 21d wait → topUpFromUndelegationclaimSlowUnstake). The factory wraps this as queueGraduationUnstake + claimGraduationRevenue. Buffer is never drained for team revenue.

Why not instantUnstake. That path pays from bufferSei and charges the tier fee. Both consequences are bad for team-revenue purposes:

  1. Reduces the redemption backing every jpSEI holder is contractually entitled to. Team-revenue extraction shouldn't borrow from user solvency, even temporarily.
  2. The protocol pays itself a 0.5–4% tier fee — pure waste, since the fee just stays in the buffer (which we just took from).

Why the 21-day cost is acceptable. Day-to-day operations are funded by the 50% operator share of the 1% trade fee, which arrives as native SEI in real time. Graduation revenue is the lumpier, lagged channel — bonus money rather than payroll. A 21-day rolling pipeline is fine when daily ops aren't dependent on it.

Failure-mode cleanliness. If a token never graduates, the team gets nothing. No extraction from buyers, no clawback, no dispute. Buyers on a failed token can still curve-sell back through instantUnstake and recover their position — the team's revenue is gated on graduation success.

Why not configurable. Conceivably the team could want a "fast" mode that pays the tier fee for immediate cash. We don't ship that knob: it's exactly the kind of optionality that becomes pressure to use under stress, and the trade-fee operator share already covers immediate cash needs.

18. Rescue functions, with hard invariant guards

Chosen (v5): Every contract that holds value now has rescueERC20 + rescueNative for owner-driven recovery of stranded funds, but the staker's versions are structurally incapable of being a buffer-drain backdoor:

  • staker.rescueERC20(token, …) reverts if token == address(this). The owner cannot mint claims on the underlying by transferring jpSEI out of the staker.
  • staker.rescueNative(to, amount) requires address(this).balance > bufferSei and only allows withdrawals up to (balance - bufferSei). Any pull that would dip into the redemption-backing buffer reverts. The buffer is invariant against owner action; the rescue function literally cannot reduce it.

The factory + router rescue functions don't have invariant guards because those contracts are not redemption-backing. Factory holds team revenue (jpSEI from grad fees, transient native SEI). Router is stateless by design — it pulls and pushes within a single tx, never accumulating.

Why not "owner can withdraw bufferSei in emergencies". The buffer backs every jpSEI in existence. A function that lets the owner drain it is mathematically equivalent to a backdoor on every redemption — regardless of intent. The right framing is: emergencies that involve the buffer are user-recovery events (not team-recovery), and the user-recovery path is permissionless via topUpFromUndelegation + claimSlowUnstake. The team has zero legitimate need to pull from the buffer; therefore the function does not exist.

Why even rescueNative exists. Stuck native SEI on the staker can come from: someone direct-sending SEI to the contract address, a Cosmos-side reward landing before claim() registers it, a future contract change that leaves SEI behind. Without a rescue path that SEI is permanently lost. With the buffer-floor guard, the function recovers stranded SEI without weakening any user-trust property.

19. bufferHealth() aggregate view

Chosen (v5): A single 10-tuple read returns (buffer, target, delegated, pendingSlow, supply, rate, ratioBps, tierFeeBps, demand7d, dynamic). The operator console + public dashboard call it once per refresh.

Why a single call. RPC round-trip cost on Sei mainnet aggregates fast when 9 reads happen per page load every 30s. More importantly, a single call guarantees all values are from the same block — no inter-call drift where, say, bufferSei is from block N and effectiveTargetBuffer is from block N+1. Drift gives stale ratio computations, which the UI then renders as a misleading tier label.

Why not multicall. Same-block consistency is what multicall guarantees, but a typed view function is one call instead of nine, doesn't require a multicall router at the read site, and the contract author chose the field set deliberately (no wasted reads on fields the UI never displays).