NFT staking on Robinhood Chain
A staking contract and app for a 5,000-NFT collection on Robinhood Chain. 665 NFTs are staked by 174 wallets, and 56% of them are locked for a full year.
- Status
- Live on Robinhood Chain
- When
- Aug to Oct 2026
- Role
- Wrote the contract, the operations scripts and the app
- Team
- Solo
- Stack
- Solidity, Hardhat, viem, Next.js, Prisma, PostgreSQL
- 6651
- NFTs staked by 174 wallets, against a cap of 1,000
- 56%1
- of staked NFTs locked for the full 365 days
- 3.5 min1
- from the doors opening to the first stake
- 2372
- tests: 45 contract, 135 unit and 57 end-to-end
Summary
Gremlin Cartel is a 5,000-piece NFT collection on Robinhood Chain. Its holders wanted to lock their NFTs for a share of the project's revenue in several tokens, earn points while locked, and have the launch rules announced in advance hold on chain: a fixed opening time, a first hour reserved for top holders, a cap of 1,000 staked NFTs and 15 per wallet.
I wrote the staking contract, the scripts that deployed, rehearsed and checked it, and the app around it. Staking opened on 10 August 2026. The first NFT was staked three and a half minutes later, and by the time staking closed, 665 NFTs from 174 wallets were locked, more than half of them for a full year1.
The contract
Stakers lock an NFT for one of five terms, from 30 days to a year. Longer locks weigh more, from 1x to 10x, and rewards and points are shared by weight. Up to 16 reward tokens can be listed, and claims work while an NFT is still locked. The contract has no upgrade path, no pause and no emergency withdrawal: once the rules were deployed, they were the rules.
Paying out had to cost the same whether ten wallets were staked or a thousand, so rewards use an accumulator per token, the pattern MasterChef made common. A deposit adds its share per unit of weight to one number, and each staker's earnings are their weight times that number, minus what was already counted when they joined. Late stakers get no share of rewards paid before they arrived.
function notifyReward(address token, uint256 amount) external {
if (!isDistributor[msg.sender]) revert NotDistributor();
if (!isRewardToken[token]) revert UnknownRewardToken();
if (amount == 0) revert ZeroAmount();
uint256 weight = totalWeight;
if (weight == 0) revert NoStakers();
uint256 balanceBefore = IERC20(token).balanceOf(address(this));
IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
uint256 received = IERC20(token).balanceOf(address(this)) - balanceBefore;
uint256 acc = accRewardPerWeight[token] + (received * ACC_PRECISION) / weight;
accRewardPerWeight[token] = acc;
totalDistributed[token] += received;
emit RewardNotified(token, received, weight, acc);
}Launch rules that hold on chain
The opening time is immutable, set at deploy. For the first hour only wallets on an allowlist can stake, and the allowlist came from a snapshot script that rebuilt holdings from transfer logs and credited NFTs already in escrow to the person who staked them. The deploy script refuses to run on a live chain without an opening time or with a stand-in collection, and a rehearsal script plays the whole launch in compressed time against the real bytecode.
The client changed the terms days before launch: the 15-day tier was replaced by a year at 10x, and the per-wallet cap went from 50 to 15. A 10x multiplier is 100,000 basis points, which overflowed the uint16 fields the tiers were packed into. I widened them to uint32 and kept each stake's data in two storage slots.
How holders locked
| Lock term | Bar | NFTs |
|---|---|---|
| 30 days, 1x | 16 | |
| 60 days, 2x | 40 | |
| 90 days, 3x | 160 | |
| 180 days, 5x | 79 | |
| 365 days, 10x | 370 |
Bugs that would have stayed quiet
A wallet with 248 Gremlins showed one
The app first found a holder's NFTs by replaying every transfer since the collection deployed, and on a new chain the node timed out. I switched to reading ownerOf for all 5,000 tokens through Multicall3, which took 1.4 seconds, but transport batching stacked on top of multicall batching and 4,984 of the 5,000 reads failed without an error. I turned off transport batching and made failed reads throw.
Points credited on the caller's word
Swaps through the app earn points, and the first version of that endpoint trusted the request. It now loads the transaction receipt, checks the sender and refuses to count a swap twice.
A router version off by one minor release
Uniswap's Trading API returned no quotes because requests pinned Universal Router 2.0 while Robinhood Chain runs 2.1.1.
Verification
- 45 Hardhat tests on the contracts, 135 unit tests in the app and 57 Playwright cases across 8 files2.
- A read-only script that checks the live deployment's settings against what was announced.
- The live contract's runtime bytecode matches the build from the repository's head, with immutables masked1.
- A responsive sweep at eight widths from 320 to 3,440 pixels, with a Playwright check that fails on horizontal overflow.
- No third-party audit. The README says so.
| Contract | Chain | Address |
|---|---|---|
GremlinStaking | Robinhood Chain (4663) | 0x4a03a56b41203e19a0414c0382886b562fb7a936 |
What I would change
The tier change days before launch was absorbed by a type change in packed storage, which worked, but only because the tests caught the overflow. I would put the economic parameters through a short sign-off with the client a week before deploy, and keep the packing loose enough that a late change to a multiplier is a constant, not a storage layout decision.
Sources
- 1.Read-only calls to the live staking contract on Robinhood Chain, and a bytecode comparison with the repository build. Block 90,192,632, 16 Oct 2026.
- 2.Hardhat, Vitest and Playwright suites in the project repository. Private; contract and unit tests run on 16 Oct 2026, end-to-end cases counted.
- 3.GremlinStaking.sol at the deployed revision. Private repository.