Skip to content

Latest commit

 

History

11 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

shellr-contracts

The draw, the bankroll, and the commit queue.

Solidity Foundry Chain Audit

Three contracts on Robinhood Chain mainnet.

What it does
ShellrPacks Sealed packs of memecoins, paid in ETH or $SHELLR
ShellrStockPacks One random tokenized equity per pack, filled via Voxelithic. Integration layer: shellr-stock-packs
ShellrStaking Stake $SHELLR, earn WETH from the token's Pons creator fees
ShellrLaunchpad Launch a token through pons and tie its fees to a pack
ShellrFeeVault One per launch. Turns that token's fees into pack coins and a burn

Deployed addresses are not published here. Every consumer - the site, the keeper, the SDK - takes them from its own environment, so a redeploy is one variable rather than a release across four repositories.


Read this before mainnet

Short, and every line of it is money.

  • Nothing here is audited. maxStake ships at 0.25 ETH and pause() exists so that a bad day stays a small bad day. Leave the cap where it is until somebody who audits contracts for a living has read this.
  • The keeper's key is hot. It lives in Railway's environment. It cannot steal a pack - it can only reveal the seed that was already committed, or fail to, and failing to just refunds the buyer. Give it gas and nothing else.
  • MASTER_SECRET is the whole fairness story and it does not rotate. Every pack's secret is derived from it. Lose it and every queued commitment becomes unrevealable and every pack in flight refunds. Leak it and anyone can predict what a pack will drop before they buy it - they still cannot change it, but they can wait for a good one. Back it up somewhere that is not the same place as the keeper's key.
  • The bankroll is real money at risk. Packs that pay more than they cost are paid out of it. If it empties, buy refuses rather than promising a payout the contract cannot make. That refusal is correct behaviour and not an outage.

Provable fairness

Robinhood Chain has no Chainlink VRF. We checked against Chainlink's supported networks list - Arbitrum, Avalanche, BASE, BNB, Ethereum, OP, Polygon, Ronin, Soneium, and not this chain. So randomness is commit-reveal across two transactions, which turns out to be better than a VRF for this product anyway: the buyer contributes to the seed, so they do not have to trust an oracle either.

  operator                          chain                          buyer
  ────────                          ─────                          ─────

  commitSeeds([keccak(s_i)…])  ──►  commitments[]
                                    queued BEFORE demand

                                                     ◄──  buy(clientSeed)
                                    pack takes the         + ETH
                                    next commitment
                                    at purchase time

  reveal(packId, s, minOuts)   ──►  require(keccak(s) == pack.commitment)
                                    seed = keccak(s, clientSeed, packId, buyer)
                                    payout, count, coins ← seed
                                    swap, deliver

Neither side can steer it. The operator was committed before clientSeed existed; the buyer never saw s. packId and buyer are in the seed so two people who pick the same client seed in the same block still get different packs.

Afterwards, anyone can check it. The secret is in the reveal calldata, the commitment was on chain before the buy, and the draw is recomputable from ShellrPacks.sol. @shellr/sdk ships that recomputation as verifyPack, and the contract itself exposes drawSeed, drawPayout, drawCount and drawPick as views so you can check it against the deployed bytecode rather than against our JavaScript.

What a pack pays

A pack does not spend the stake. It spends the stake times a multiplier drawn from the same seed, across seven bands. Read the current numbers off the contract rather than out of this file:

payoutBps   the chance of each band, in basis points, totalling 10_000
payoutLo    the low end of each band's multiplier, in bps of the stake
payoutHi    the high end
bandBps     the chance of each coin rarity band

10_000 is break even. topPayoutBps() returns the highest multiplier any band can produce, which is the bankroll's exposure. expectedPayoutBps in @shellr/sdk computes the mean those bands imply, so the house edge is derived from live state rather than quoted from a document that can go stale the next time setPayoutBands is called.

This makes ShellrPacks a house-banked game rather than a shop. Somebody has to fund the packs that pay more than they cost; that money is bankroll. The owner puts it in, winners are paid out of it, losers pay into it. It is tracked separately from the contract's ETH balance because that balance also holds buyers' money between buy and reveal, and a pack's stake is never the bankroll.

Which coins drop

A band by the odds in bandBps, then uniformly among the live coins in that band. An empty band walks to the next one rather than reverting, which is what keeps packs openable on the day a pool is pulled and the owner marks a coin dead mid-flight. A dead coin keeps its array index so settled packs still resolve.

Paying in $SHELLR

The token is not sold. It is taken in, kept, and the pack is funded from the bankroll instead. That is the only way to accept it at all while its liquidity sits in a Pons pool this contract cannot reach.

Be clear about what that is: the bankroll buys the token at a rate the owner sets. A pack paid in ETH only asks the bankroll for the difference when it wins; a pack paid in token asks for the whole spend. Three brakes, all of them load-bearing:

  • tokenPerEth - the rate, set by hand because there is no pool here to read one from. Stale in one direction is a gift.
  • tokenDailyCap - a ceiling on how much ETH the bankroll hands out this way per day, reset on its own.
  • tokenSales - an off switch that touches nothing else.


The launchpad

Anyone can launch a token on this chain in one click; pons already does that well and there is no reason to rebuild it. What a launch does not normally have is a reason for anybody to hold the token after the first week.

So the launchpad does not create tokens. It creates them through pons and redirects one thing: where the creator's fees go.

  creator                    ShellrLaunchpad                    pons v2
  ───────                    ───────────────                    ───────

  launch(params, pack)  ──►  deploy ShellrFeeVault
                             params.creatorFeeRecipient = vault
                             params.creatorTaxBps       = 0
                                     |
                                     └──────────────────►  launchToken()
                                                             deploys token
                                                             opens the curve
                             vault.bindToken(token)  ◄─────  returns token

                          ...trading...

                             every trade pays pons' 1%
                             pons keeps its protocol share
                             the rest credits the vault in PonsV2FeeEscrow
                                     |
  keeper  ──────────────►  vault.sweep()
                             claim() from the escrow
                             20% buys $SHELLR and destroys it
                             80% buys the pack's coins
                                     |
                                     └──►  distributor, for holders to claim

No tax is added

creatorTaxBps goes out as zero, always, and the launchpad overwrites whatever the caller put there. pons lets a creator charge an extra tax on every trade; using it would make a token launched here more expensive to trade than the identical token launched anywhere else, and a token that is worse to trade is a token that does not trade.

What gets redirected is the creator's existing share of pons' standard fee. The money already existed. The launchpad only changes where it lands.

One vault per launch

ShellrFeeVault is deployed per token rather than shared. A shared vault would have to attribute every incoming payment to the token that produced it, and pons' escrow credits a recipient, not a reason. One contract per launch makes the accounting a fact about the address rather than a ledger we maintain.

The vault is also what pons is handed as creatorFeeRecipient, which is why it has to exist before the launch call - and why the token is bound to it afterwards rather than predicted. The factory's comments point at a predictLaunchAddresses helper for computing the CREATE2 address in advance; that function is not in the published deployer, and recomputing the address here would pin this contract to the exact creation code of theirs. bindToken is one storage write and depends on nothing that can move.

The line-up is snapshotted

A launch copies the pack's coins into its vault. A pack edited next week does not change what a token launched today buys, because its holders bought into the line-up that was published with it.

What the keeper can and cannot do

sweep takes the swap minimums as arguments, for the same reason ShellrStockPacks.reveal does: the quoter answers by reverting, which is fine from a staticcall and useless inside a transaction that carries on afterwards. So the keeper quotes off chain and passes the results in.

A single failed pool reverts the whole sweep on purpose. A partial sweep would credit holders for a pack missing a coin while the fees that should have bought it were already spent; reverting leaves the money claimable and lets the keeper retry once the pool is fixed.

Burning across both pons generations

pons v1 tokens are a plain ERC-20 and cannot burn themselves; v2 tokens are ERC20Burnable. The vault tries burn(uint256) and falls back to transferring to the dead address. Both remove supply from circulation, only one lowers totalSupply, and nothing downstream depends on which. $SHELLR predates v2, so in practice it takes the second path - and without the fallback every sweep would have reverted.

Not finished

The distributor is an address the vault pays into, and the contract that hands those coins to holders is not written yet. Snapshot and claim, so that passive holders get their share without having to stake anything. Until it exists, bought coins accumulate at the distributor address and go nowhere.


Layout

src/
  ShellrPacks.sol        the packs, the draw, the bankroll, the commit queue
  ShellrStockPacks.sol   one tokenized equity per pack, via Voxelithic's router
  ShellrStaking.sol      stake $SHELLR, earn WETH
  ShellrLaunchpad.sol    launch through pons, tie the fees to a pack
  ShellrFeeVault.sol     one per launch: claim, split, buy, burn
test/
  ShellrPacks.t.sol      draw vectors, bankroll accounting, refund window
  ShellrStockPacks.t.sol symbol weighting, router failure paths
  ShellrLaunchpad.t.sol  the fee recipient is ours, the creator tax is zero
  ShellrFeeVault.t.sol   the split, the remainder, the burn fallback

Running the tests

lib/ is not committed.

forge install foundry-rs/forge-std
forge test

If forge cannot reach binaries.soliditylang.org for the compiler, fetch it from the solidity GitHub releases and point at it directly:

forge test --use /path/to/solc-0.8.26

forge test -vv asserts the same draw vectors that shellr-sdk and shellr-keeper pin on their side. All three have to agree. If they drift, reveals revert on the first slippage check and every pack stalls until its refund window - loudly, which is the one mercy in that failure.


Deploying

Foundry is optional here. The deploy path lives in shellr-keeper and runs on Node: it compiles with the solc npm package and sends the creation transaction with viem, which is what forge create does in three steps rather than one. Foundry gives you forge test and source verification on the explorer, and nothing else in the sequence needs it.

The order matters and the runbook is in the keeper's README. Briefly:

  1. npm run pools - find out which coins actually have a v3 pool against WETH. A coin that prints NO POOL cannot be in a pack, and finding that out here rather than after deploy is the point of the step.
  2. npm run check - the keeper's arithmetic against the contract's. If they differ, every reveal reverts.
  3. npm run deploy - dry run, read every constructor argument, then CONFIRM=1.
  4. npm run configure - registers each coin against its deepest pool.
  5. Start the keeper. buy reverts with no seeds queued until the first batch of commitments lands, which is correct and not a bug.
  6. Buy one pack at the minimum from a wallet that is not the owner's. Watch it reveal. Check the explorer that the coins reached the buyer and the fee reached the treasury. Then consider raising maxStake.

Turning it off:

npm run pause          # stops new buys and sell-backs
npm run pause -- off

Pausing does not stop reveals or refunds, on purpose. Packs already paid for still have to settle.


License

MIT. See LICENSE.

About

ShellrPacks, ShellrStockPacks and ShellrStaking. Commit-reveal packs on Robinhood Chain.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages