Back to work

Forma

A staking protocol where the stake is an object you own — an NFT carrying its own lock, multiplier, reward stream and routing policy.

Category
Staking Protocol
Type
Personal Project
Year
2026
Status
In Development
Role
Solo Developer
Technologies
Solidity, Foundry, Next.js, React, TypeScript, viem, Tailwind CSS
Index
02 / 05
The Forma landing page on a cream ground: the headline “Your stake is an object”, with a circular position glyph showing a 1.75× multiplier, labelled specimen — not a real position, where ticks encode size, the arc lock progress and the hue the lock tier.

01Overview

Forma is a staking protocol where a stake is not a number in a mapping — it is an object. Each stake mints an ERC-721 that carries its own lock, multiplier, reward stream and routing policy, with the terms snapshotted at stake time so they cannot silently change. Alongside it sits stFORGE, an ERC-4626 vault for people who would rather stay liquid. Five non-upgradeable contracts, a generated TypeScript SDK, and a Next.js front end that reads every number from chain.

02Problem

Most staking contracts reduce a stake to a balance in a mapping. That makes locked stake illiquid and opaque — you cannot hold, transfer or inspect “my 90-day stake” as a thing. Reward handling becomes one-size-fits-all, and the interfaces on top tend to show an invented APY rather than what the protocol is actually doing.

03Approach

Give the position an identity. ERC-721 already provides ownership, transfer, inspection and wallet support, so the design question becomes what travels with the token. Lock, multiplier, penalty and pending rewards do — a transfer can never bypass a lock. The reward-routing policy does not: it records who configured it and silently reverts to “keep” for any new owner, so a buyer never inherits a redirect paying out to the seller. That needed no transfer hook.

04System

One contract holds all funds and accounting. Rewards use accumulator-per-weight arithmetic over a single global stream — the accumulator advances by emitted × 1e30 / totalWeight, and a position earns weight × Δacc / 1e30 — so lock tiers share one stream weighted by principal × multiplier rather than each running its own. Positions settle before any weight change. Principal and reward reserve are separate buckets, emissions during zero-stake periods and forfeited rewards are recycled into the next stream, and every rounding decision favours the protocol. The solvency relations are asserted as invariants rather than assumed.

Contracts hold the truth; everything above them is generated from the ABIs. Hover a component for detail.
  1. 01

    Surface

    • Stake · PositionsOpen a position by lock tier, then hold, configure, compound or close it. Each position has its own page rendered from the NFT's on-chain owner index — no indexer involved.
    • Earn · LiquidThe reward stream and keeper board, and the stFORGE vault for staying liquid instead of locking.
    • Docs · ActivityThe protocol spec shipped as a page, and event history read from bounded eth_getLogs ranges — labelled as indexed rather than authoritative.
  2. 02

    Client

    • wagmi · viemReads and writes from the browser through RainbowKit, with TanStack Query holding chain state. Every figure on screen comes from a contract call.
  3. 03

    SDK

    • Generated ABIs · typesExported from the Foundry build by scripts/export-abis.mjs, so the client cannot drift from the contracts it is calling.
    • Rates · glyph · errorsRate maths, the position glyph model and custom-error decoding live in the SDK — derived from chain state, never fetched from a price or stats API.
    • Deployment manifestdeployments/*.json carries addresses, roles and transaction hashes per chain. Only anvil.json exists today; base-sepolia.json is what a testnet deployment would add.
  4. 04

    Chain

    • FormaStaking · PositionNFTAll funds and accounting in one contract, plus the ERC-721 that represents a position and the renderer that draws its metadata on-chain.
    • LiquidStakingVault · ForgeTokenThe ERC-4626 vault over a single no-lock position, and the test token with a rate-limited faucet. Neither is upgradeable.
ProtocolFormaStaking holds funds and accounting. PositionNFT is the ownership token, PositionRenderer draws its metadata as on-chain SVG. Nothing is upgradeable and there is no proxy.
VaultLiquidStakingVault (stFORGE) is ERC-4626 over a single no-lock position, with internal asset accounting, a virtual-share offset and a minimum first deposit so donations cannot move the share price.
SDKABIs, types, rate maths, the glyph model and error decoding are generated from the contracts, so the front end cannot drift from what was deployed.
InterfaceNext.js reading state through wagmi and viem. Estimates state their assumptions, and a transaction shows five real stages rather than claiming success before a receipt is mined.
  1. OpenStaked and locked. Earning at principal × multiplier.
  2. UnlockedLock elapsed. Boost drops to 1.00×.
  3. CompoundingRouting set to COMPOUND; any keeper may compound it.
  4. RedirectedRewards routed to another wallet by the owner.
  5. ClosedterminalPrincipal and rewards withdrawn.
  6. Emergency exitterminalLeft while locked; penalty kept and recycled.

Transitions

  • OpenUnlockedlock elapses
  • OpenCompoundingsetRouting(COMPOUND)
  • OpenRedirectedsetRouting(REDIRECT)
  • CompoundingOpencompound() by keeper
  • UnlockedClosedwithdraw()
  • OpenEmergency exitemergencyWithdraw()

A position's life. Terms are snapshotted at stake time, so none of these transitions can change the deal after the fact.

05Interface

The interface is built so a number can always be traced back to a call. Estimates say what they assume instead of printing a headline APY, the hero glyph is labelled “specimen — not a real position” so a demo is never mistaken for state, and a transaction moves through five real stages rather than reporting success before a receipt exists. The testnet disclosure is section 00 of the docs page, not a footnote.

06Build

  • Routing does not travel with the tokenLock, multiplier, penalty and pending rewards move with a transfer so a sale cannot bypass a lock. Routing deliberately does not: it records who set it and falls back to “keep” for a new owner, so a buyer never inherits a redirect paying the seller. No transfer hook was needed.
  • One reward stream, weighted — not a stream per tierAccumulator-per-weight accounting means adding a lock tier is a parameter, not another accounting surface. Positions settle before any weight change, and rounding always favours the protocol so the reserve cannot be drained by repeated small operations.
  • ERC-4626 done defensivelyThe standard is what makes stFORGE legible to an integrator, but it ships with known traps. Internal asset accounting so donations cannot move the share price, a virtual-share decimals offset, a minimum first deposit, zero-share reverts, and max* functions that reflect protocol pauses. Inflation and donation attacks have dedicated tests.
  • Invariants over assertions193 Foundry tests: unit, fuzz at 1,000 runs, and invariant campaigns driven by a handler that exercises every user, keeper, vault and admin path against exact ghost accounting. Six of them run the real deployment script. End-to-end tests against a local chain found three bugs a unit test would not have — a clock-dependent lock state, a lost confirmation after closing a position, and a missing accessible label.

07Current Status

Contracts, SDK, tests and front end are complete and work end to end against a local chain, including a two-wallet Playwright run with real mined transactions. The front end is deployed to Vercel and browsable, but the contracts are not on a public network: only `anvil.json` exists in the deployment manifests, so the panels that read chain state sit idle there. The deployment pipeline — allow-listed networks, keystore signing, Basescan verification, manifest with transaction hashes, role hand-over — is built and tested locally, with Base Sepolia next. Nothing here is audited, FORGE and stFORGE have no intended value, and testnet rates are not returns.