Smart Contracts
JellyPadStaker
JellyPad's in-house liquid staker. Replaces the v3 dependency on Splash spSEI. The receipt token is jpSEI. The contract IS the ERC-20.
Three pools of value
- Buffer (native SEI on this contract's balance, tracked in
bufferSei). Pays instant-unstake exits. - Validator stake (delegated principal, off-chain at the Cosmos staking module via the precompile at
0x0000000000000000000000000000000000001005). Earns ~4.4% APR. - Pending slow-unstake debt (
totalPendingSlowSei). 21-day Cosmos unbonds queued for users.
Key state
| Variable | Purpose |
|---|---|
bufferSei | Native SEI in contract, immediately spendable for instant exits |
totalDelegated | SEI delegated to validators, earning rewards |
totalPendingSlowSei | Sum of queued 21-day claims |
targetBuffer | Owner-set absolute floor on the target |
dynamicMode | Toggle: when on, target is computed from supply / demand / pending claims |
bufferSupplyRatioBps | % of total supply to keep buffered (clamped 1% to 30%) |
demandMultiplierBps | Multiplier on trailing-7d demand (capped 3x) |
feeTiers[] | 0.5% / 1% / 2% / 4% based on buffer ratio |
minBufferRatioBps | Below this ratio, instant-unstake reverts |
stake(bool autoCompound) payable
Mirrors ISpSEIPool.stake(bool) so call sites that used Splash drop in unchanged.
function stake(bool /* autoCompound */) external payable {
require(msg.value >= MIN_STAKE, "amount too small");
uint256 jpSeiOut = previewStake(msg.value);
uint256 target = effectiveTargetBuffer();
if (bufferSei < target) {
// Fill buffer up to target, delegate the rest.
...
} else {
_delegate(msg.value);
}
_mint(recipient, jpSeiOut);
}The mint amount is computed at the rate snapshot before updating accounting. New stakes are rate-neutral.
instantUnstake(uint256 jpSeiAmount)
Burn jpSEI, pay native SEI from buffer at the current tier fee. The fee stays in the buffer (lifts the rate).
Tiered fee schedule:
| Buffer ratio | Fee |
|---|---|
| ≥ 100% of target | 0.5% |
| 50% to 100% | 1.0% |
| 20% to 50% | 2.0% |
| 5% to 20% | 4.0% |
| < 5% | reverts (buffer too low; use slow unstake) |
unstake(uint256 jpSeiAmount) and claimSlowUnstake()
Slow path. Queues a 21-day Cosmos undelegation. No fee.
function unstake(uint256 jpSeiAmount) external {
require(pendingSlowSei[msg.sender] == 0, "claim pending unstake first");
uint256 unbondAmount = previewUnstake(jpSeiAmount);
require(totalDelegated >= unbondAmount, "not enough delegated");
_burn(msg.sender, jpSeiAmount);
STAKING.undelegate(activeValidator, _toUsei(unbondAmount));
totalDelegated -= unbondAmount;
pendingSlowSei[msg.sender] = unbondAmount;
pendingSlowReadyAt[msg.sender] = block.timestamp + 21 days;
totalPendingSlowSei += unbondAmount;
}claim() (anyone)
Pulls validator rewards from the distribution precompile. Lifts the exchange rate. Permissionless.
Dynamic buffer
When dynamicMode = true, the target is max(static, supplyRatio, pendingSlow, demandMultiplier). See decisions.mdx for the full rationale.
Owner ops
| Method | Effect |
|---|---|
seedBuffer() payable | Deposit SEI to buffer, receive jpSEI back. Recoverable later. |
donateToBuffer() payable | Permanent gift, no jpSEI minted. Lifts rate for everyone. |
harvestSurplus(to, amount) | Withdraw above effective target |
setTargetBuffer(uint256) | Cannot drop below totalPendingSlowSei |
setDynamicMode(bool) | Toggle dynamic targeting |
setDynamicParams(ratio, mult, trigger) | All bounded |
setActiveValidator(string) | Rotate validator |
setProtocolFeeSink(addr, allowed) | Whitelist factory/router for donateToBuffer |
undelegateToBuffer(amount) | Queue an owner-initiated 21d unbond |
markUndelegationComplete(amount) | After 21d, credit returned SEI to buffer (legacy alias for topUpFromUndelegation; same logic, owner-only) |
rescueERC20(token, to, amount) | Pull stranded ERC-20s. Cannot rescue jpSEI itself (would inflate claims). |
rescueNative(to, amount) | Pull native SEI that isn't part of bufferSei. Refuses to dip below bufferSei — buffer principal is contractually owed to jpSEI holders and cannot be rescued. |
Permissionless ops
| Method | Effect |
|---|---|
claim() | Pull validator rewards from the distribution precompile. Lifts the exchange rate. |
rebalance() | Queue a 21-day refill from totalDelegated when buffer dips below rebalanceTriggerBps. |
topUpFromUndelegation(amount) | After a 21d unbond returns SEI to the contract, anyone can credit it to bufferSei + decrement totalDelegated. The dead-team fallback for the user-redemption path. |
Aggregate read
bufferHealth() returns (buffer, target, delegated, pendingSlow, supply, rate, ratioBps, tierFeeBps, demand7d, dynamic) in one call. The operator console + public dashboard read this directly so all values are from the same block.
Critical implementation note: wei vs usei
Sei's staking precompile is asymmetric.
delegate(string)ispayable. The precompile readsmsg.value(wei) and converts internally.undelegate(string, uint256)takes the amount as a uint256 parameter, interpreted as usei (Cosmos 1e6 denom), not wei (EVM 1e18).
This bit us on the v1 staker deploy. Mixing units silently reverts. The fix is _toUsei(wei) = wei / 1e12 applied to every undelegate call. See JellyPadStaker.sol.
JellyPadToken
The launched-token contract. ERC-20 + bonding curve + DEX-graduation logic.
Constructor params (InitParams)
| Field | Type | Notes |
|---|---|---|
name / symbol | string | ERC-20 metadata |
imageURI | string | Off-chain image |
description | string | Free-text |
factory | address | Deploying factory |
spSeiPool | address | The JellyPadStaker. Same address used for both pool and token in v4 |
spSeiToken | address | Same |
dexRouter | address | Saphyre/DragonSwap V2 router |
graduationMcapUsd | uint256 | $69k = 69_000e18 default |
seiUsdPrice | uint256 | USD wei per SEI |
The fields are still named spSeiPool and spSeiToken for ABI continuity with v3 deployments. They both point at the JellyPadStaker now.
buy() payable
function buy() external payable nonReentrant {
require(!graduated, "Graduated to DEX");
require(msg.value > 0, "Send SEI");
uint256 fee = (msg.value * 100) / 10_000; // 1%
uint256 seiIn = msg.value - fee;
uint256 tokensOut = _getTokensOut(seiIn);
// Stake principal in-flight via JellyPadStaker.
SPSEI_POOL.stake{value: seiIn}(false);
realSeiReserves += seiIn;
virtualSeiReserves += seiIn;
virtualTokenReserves -= tokensOut;
_transfer(address(this), msg.sender, tokensOut);
payable(factory).call{value: fee}("");
emit Buy(msg.sender, seiIn, tokensOut, _currentPrice());
_checkGraduation();
}sell()
Computes the curve's gross SEI output, takes a 1% protocol fee, calls instantUnstake on the staker for enough jpSEI to cover. Uses worst-case 4% tier in the redemption math so sells succeed even when the buffer is stressed; any dust stays in the contract.
_migrateToDex()
Auto-trigger on realSeiReserves × seiUsdPrice ≥ graduationMcapUsd.
- Take 1% of accumulated jpSEI as the graduation fee, transfer to the factory address. Factory holds it as protocol revenue.
- Approve the Saphyre router for the remaining 99% jpSEI + all curve-side TOKEN.
- Call
addLiquidity(TOKEN, jpSEI, ..., to: 0xdEaD). The LP receipt is minted directly to0xdEaD. No address has a withdrawal path.
JellyPadFactory
Owner-controlled factory.
Constructor
constructor(
address _treasury,
address _spSeiPool, // = JellyPadStaker
address _spSeiToken, // = JellyPadStaker (same)
address _dexRouter
)Fee routing
The factory routes incoming native-SEI fees based on operatorFeeBps:
- Creation fee (0.01 SEI per token): 100% to staker buffer (or treasury if
stakerBufferSinkunset). - Protocol fees (1% buy / 1% sell): split.
operatorFeeBpsto treasury (default 5000 = 50%), the remainder to staker buffer. Bounded byMAX_OPERATOR_FEE_BPS = 7000so the owner can never raise above 70%. - Graduation fee (1% of accumulated jpSEI): comes in as jpSEI, not native SEI. Stays at the factory address as protocol revenue, converted to native SEI via the 21-day slow-unstake path so the buffer is never drained for team revenue.
Key methods
| Method | Caller | Effect |
|---|---|---|
createToken(...) payable | anyone (with 0.01 SEI) | Deploys new token |
setTreasury(addr) | owner | Updates treasury |
setSpSei(pool, token) | owner | Updates the staker addresses on the factory |
setDexRouter(addr) | owner | Swaps DEX router |
setStakerBufferSink(addr) | owner | Routes fee inflow to staker buffer |
setOperatorFeeBps(uint256) | owner | Bounded by MAX_OPERATOR_FEE_BPS |
setTokenSeiUsdPrice(token, price) | owner | Updates oracle on a deployed token |
transferOwnership(addr) | owner | Hands off control |
withdrawERC20(token, to, amount) | owner | Sweep accumulated ERC-20s. Cannot touch LP shares (those go to 0xdEaD directly). |
withdrawSei(to, amount) | owner | Sweep accidentally-trapped native SEI (fees normally pass through to treasury). |
Graduation-revenue pipeline (v5)
The factory accumulates jpSEI from each graduation's 1% fee and converts it to native SEI for the team via the 21-day slow-unstake path. Buffer is never drained by team-revenue extraction — slow-unstake pulls from totalDelegated, not bufferSei.
| Method | Caller | Effect |
|---|---|---|
receiveGraduationFee(amount) | token only | Notification hook fired by _migrateToDex after the jpSEI transfer; emits an event for indexers. |
withdrawGraduationRevenue(to, amount) | owner | Direct transfer of factory-held jpSEI to a recipient. Bypasses the slow path. |
queueGraduationUnstake(amount) | owner | Burn factory's jpSEI on the staker, queue 21-day undelegation. Reverts if a previous cycle is still pending. |
claimGraduationRevenue() | owner | After the unbond elapses, sweep the matured native SEI directly to treasury (suppresses receive()'s fee-split for the duration). |
pendingGraduationUnstake() | view | Amount of jpSEI currently queued. |
graduationUnstakeReadyAt() | view | Unix timestamp when the queue matures (0 if no cycle in flight). |
Constants
CREATION_FEE = 0.01 etherDEFAULT_GRAD_MCAP_USD = 69_000e18MAX_OPERATOR_FEE_BPS = 7000(70% cap)
JellyPadRouter
Post-graduation swap router. Wraps staking + Saphyre swap into a single user transaction. Token contract stays a clean ERC-20 with no transfer side-effects.
Key methods
| Method | Caller | Effect |
|---|---|---|
buy(token, minTokensOut, deadline) payable | anyone | Take 1% fee to factory; stake remaining SEI to jpSEI; swap jpSEI to token on Saphyre |
sell(token, tokenAmount, minSeiOut, deadline) | anyone (after token.approve(router, amount)) | Pull token; swap for jpSEI; instantUnstake to native SEI; take 1% protocol fee; transfer net to caller |
quoteBuy(token, seiIn) view | anyone | Token-out estimate for a given SEI-in |
quoteSell(token, tokenAmount) view | anyone | Net-SEI estimate (accounts for staker exit fee + protocol 1%) |
rescueERC20(token, to, amount) | owner | Pull stranded ERC-20s. Router is stateless by design — this is a safety net only. |
rescueNative(to, amount) | owner | Pull stranded native SEI. Same reasoning. |
transferOwnership(addr) | owner | Hand off control of the rescue functions. |
Constants
PROTOCOL_FEE = 100bps (1%) per leg- All addresses (
factory,SPSEI_POOL,SPSEI_TOKEN,DEX_ROUTER) areimmutable. To repoint at a new factory or staker, redeploy the router. owneris set to the deployer at construction; only used for the rescue paths above.
Test coverage
~109 tests across 7 suites (16 net new in v5; pre-existing v1 legacy + a flaky-seed staker test fail on main, unrelated):
test/JellyPadStaker.t.sol stake / unstake / claim / dynamic buffer / tiers / harvest
test/StakerIntegration.t.sol end-to-end with real staker
test/JellyPadToken.t.sol curve buy/sell, fees, graduation, LP burn
test/GraduationRevenuePipeline.t.sol ✓ NEW — 21d unstake pipeline + buffer-untouched invariant + rescues
test/JellyPadRouter.t.sol 8 ✓ routed buy/sell, allowance, slippage
test/StakingPrecompile.t.sol 2 ✓ precompile-call gating
test/SeiLunchToken.t.sol 15 ✓ + 4 ✗ v1 legacy (deprecated; failures don't block)Run from the contracts directory:
cd contracts && forge testThe 4 legacy v1 failures are pre-existing soft-fail bugs in deprecated v1 contracts that don't ship. The current v4 stack (Staker, Factory, Token, Router) passes 100%.