Deposit HOOD, take vault shares, commit them for a fixed term, and collect a share of what the
desk earns. No NFTs - the position is a bound entry in a register, not a collectible.
01 · Open a position
figures use the deployed contract arithmetic
Longer term carries more weight, so it earns a bigger slice of the same revenue.
Trade ticket
02 · The register
signal degrades with the forfeit you'd pay
Coherence is not decoration - it is 1 − forfeit/maxForfeit, read live from the position, which works out to the share of the term already served. A position at full term is stable; one opened moments ago is not.
03 · Desk activity
drive the protocol forward
04 · Distributions to date
two streams, never merged into one number
05 · Forfeit schedule
for the deposit entered above
Term
Weight
Shares
Forfeit day 0
Forfeit halfway
Free after
Forfeit decays linearly: 20% of principal the moment you commit, zero once the term is served.
Every term used in the interface, defined once. Read top to bottom it is one signal
chain: what goes in, what it becomes, what it earns, and what it costs to pull out early.
01 · The loop
four stages, one round trip
STAGE 01
Deposit
inHOOD·outdHOOD
DeskVault takes 1% as an entry fee and forwards it to FeeRouter. The rest mints shares to you.
STAGE 02
Stake
indHOOD·outposition
Lock shares into DeskStaking for a fixed term. The position is bound to your address and cannot be sold or moved.
STAGE 03
Earn
inweight·outdHOOD
Two revenue streams accrue to the position in proportion to its weight. Nothing accrues to shares you merely hold.
STAGE 04
Claim
inposition·outdHOOD
Take rewards any time without touching principal - or unstake to close the position entirely.
Return pathunstake→redeem→HOOD· early exit forfeits a slice, see 05
Holding dHOOD without staking earns nothing. That is deliberate - the reward is for
committing, not for holding.
02 · Shares
the fee is 1% of the net, not the gross
Worked example
You deposit
1,010.00 HOOD
Entry fee
−10.00 HOOD
Reaches the vault
1,000.00 HOOD
Fee, against what arrived
1.00 %
Computed as ceil(assets × 100 / 10100) - 100/10100 of the gross is
exactly 1% of the net.
What you are holding
DeskVault is a compliant ERC-4626. dHOOD carries 24 decimals
- 18 from HOOD plus a 6-decimal virtual offset that hardens the vault against the empty-vault
inflation attack.
Both previewDeposit and previewMint account for the fee, so ERC-4626's
preview-matches-execution guarantee holds - the property a fee-charging vault most easily breaks.
Share price does not appreciate.totalAssets() moves only on deposit
and withdrawal. Revenue is never paid into the vault; it arrives as claimable shares in
DeskStaking instead.
03 · Terms and weight
weight = shares × multiplier
Term
Duration
Multiplier
Weight on 10,000 shares
Relative pull
Short
30 days
1.0×
10,000
Standard
90 days
1.5×
15,000
Long
180 days
2.0×
20,000
Weight is fixed at stake time and sets your slice of every distribution. Two people
staking the same amount for 30 and 180 days earn in a 1:2 ratio - the longer commitment
is not paid more per day, it is simply counted twice as heavily.
04 · The two revenue streams
separate accumulators, never merged
Trade fees · paid in
HOOD accumulates in FeeRouter from the sources listed below.
route() deposits that HOOD into the vault, converting it to dHOOD.
Those shares are pushed to DeskStaking and split across every open position by weight.
Permissionless - anyone can trigger it, nobody can redirect it. Uniswap pool fees
swept in later use the same path.
Forfeits · paid by leavers
A staker closes a position before the term is served.
Their forfeit is deducted from principal, in dHOOD.
It is split across everyone still staked - the leaver's own weight is removed first.
Leaving early pays the people who stayed. If nobody is left to pay, it goes to the
treasury rather than being stranded.
Inbound to FeeRouter
Rate
Enforced by
Vault entry fee
1% of every deposit
DeskVault - constant, no function can change it
Trading tax · staker share
1.5% of every buy and sell
Custom token contract - not built yet
Team share, forwarded
discretionary
Team wallet - nothing on-chain schedules or requires it
The 2% trading tax splits 0.5% to the team and 1.5% to stakers.
Routing passes through the vault, which charges its own entry fee like it would anyone, so about
1.485% of each trade reaches stakers on the first pass and the ~0.015% remainder rides
along in the next batch.
Both streams are denominated in dHOOD, so there is one payout asset and two counters.
pendingRewards() returns them separately and the interface never collapses them into a
single opaque "rewards" figure.
05 · The forfeit
same schedule, different runway
day 90 of 180
Term
Cost to walk away
on a 10,000 dHOOD position, using the deployed contract arithmetic
20% at the moment you commit, 10% halfway, zero once served. The percentage is identical across
terms; only the duration differs - which is why the term you pick matters most in the middle of it.
Thirty days in, a short position is already free while a long one still owes 16.7%.
Forfeited shares are redistributed to everyone still staked. totalWeight is decremented
before the forfeit is distributed, so an exiting staker cannot collect a slice of
their own forfeit.
If the last remaining staker closes early there is nobody to pay, so the forfeit goes to the treasury
rather than being stranded or dividing by zero.
acc is a running total of revenue-per-unit-weight since deployment. debt is a
watermark - where that total stood the moment you joined. What you can claim is everything the total has
risen since: your weight times the gap.
A new position's debt is initialised to the current accumulator rounded up,
so it can never be credited revenue distributed before it existed. Rounding down leaks 1 wei per position
- enough to break the final withdrawal. Not hypothetical; see assurance.
The reason is one line of integer arithmetic. Payout is floor(w·acc₁/1e18) − debt, while the
honest entitlement is floor(w·(acc₁−acc₀)/1e18):
debt = floor(w·acc₀/1e18) → floor(a) − floor(b) can exceed floor(a−b) by 1
debt = ceil(w·acc₀/1e18) → floor(a) − ceil(b) never does
That surplus wei is the fractional remainder other stakers' floored accruals left in the pot. Taking it
means paying out more than came in - and here the reward asset is the staked asset, so it
comes out of principal.
07 · The 1%-of-1% residual
delayed, never lost
route() deposits into the vault, and the vault charges its entry fee like it would to
anyone. So ~1% of each routed batch bounces back to the router and rides along in the next batch.
Pass
Routed
Reaches stakers
Stays in the router
1
100.00 HOOD
+99.01
0.99
2
0.99 HOOD
+0.98
0.01
3
0.01 HOOD
+0.01
<0.01
Each pass moves 100/101 of whatever is left, so the remainder converges to zero and every routed wei
eventually reaches stakers. Nothing is lost - only delayed.
The alternative was exempting the router from the fee, which would make previewDeposit
caller-dependent and break ERC-4626 compliance for everyone. A self-recycling residual is the cheaper
trade.
Four contracts. Every privileged capability is listed here - if a power exists in the code and is
not on this page, that is a documentation bug.
Deployment
local anvil · nothing public
Contract
Local address
Role
HoodToken
0x5FbDB231…64180aa3
Fixed-supply ERC20
DeskVault
0xe7f1725E…bb3F0512
ERC-4626 over HOOD
DeskStaking
0x9fE46736…3c7fa6e0
Bound term positions
FeeRouter
0xCf7Ed3Ac…34fB0Fc9
Revenue conversion
Solidity 0.8.26 · OpenZeppelin v5.5.0 · optimizer on, 200 runs
HoodToken
1,000,000,000 fixed · no mint function
Capability
Who
Status
adminBurn
owner
Live - burns from any wallet
renounceBurnPower
owner
One-way kill switch for the above
renounceOwnership
owner
Standard OZ Ownable
burn
anyone
Your own balance only, never privileged
DeskVault
compliant ERC-4626 · shares transferable
Capability
Who
Status
setFeeRouter
owner
One-shot - reverts on the second call, so the fee destination can never be changed after wiring
renounceOwnership
owner
Standard OZ Ownable
ENTRY_FEE_BPS
-
constant at 100. No function exists to change it, for anyone
Deposits revert with FeeRouterNotSet until the router is wired, so there is no window where an unwired vault accepts money. Overridden from stock ERC-4626: previewDeposit, previewMint, _deposit, and _decimalsOffset (returns 6).
DeskStaking
positions are structs in a mapping
There is no transfer function in the ABI
Not a reverting override - the capability was never written. Non-transferability is structural, and checkable with forge inspect DeskStaking methodIdentifiers. The complete external surface is below; count it yourself.
The owner cannot touch positions, move staked shares, change terms, change the penalty, or pause anything. MAX_PENALTY_BPS and the term table are fixed at deployment. treasury is immutable.
notifyTradeFeeReward is callable only by the FeeRouter and is pull-based - the contract transfers the shares in itself rather than trusting that a prior transfer landed.
FeeRouter
no owner · no admin functions
Holds no privileges over anything. The entire surface is route(), pendingAssets(), lifetimeRouted() and three immutable address getters.
route() is permissionless - anyone can trigger it, nobody can misdirect it. It reverts when there is nothing to route, when no one is staked, or when the pending balance is too small to convert to a whole share. In every case the balance waits for the next call; revenue is never lost.
Trading tax
planned · not built
Not in any contract yet
There is no launchpad. The 2% buy/sell tax is meant to live in a custom token contract, and
that contract is not written. HoodToken as it stands has no transfer hook
and no fee logic - check the ABI. Nothing here levies, splits, caps or enforces a tax, and no function
here can change one. The rates below are a design intention, not a property of any bytecode.
Slice
Rate
Destination
Stakers
1.5%
FeeRouter, then route() to DeskStaking
Team
0.5%
Team wallet - an ordinary EOA, not a contract
Any onward transfer of the team share to FeeRouter is a manual decision. There is no
schedule, no vesting and no minimum - nothing on-chain requires it to happen at all.
The staker share needs no change to the four contracts above to arrive: route() already
forwards whatever HOOD reaches the router, whatever put it there. A tax hook will bring privileged
capabilities of its own, though - at minimum whatever decides which addresses are taxed. None appear in
the tables on this page because none exist yet.
What the test suite does and does not establish, stated so it can be checked rather than believed.
Unit tests
35
named behaviours
Fuzz properties
14 × 512
arbitrary inputs
Invariants
12 × 12,288
arbitrary action orders
Properties checked by fuzzing
512 random inputs each
previewDeposit(x) == deposit(x) and previewMint(x) == mint(x) for all x
The entry fee splits exactly; the vault holds precisely what its accounting claims
Deposit-then-redeem never returns more than went in
Weight always lands within the multiplier table's bounds
The forfeit decays monotonically, never exceeds 20%, never consumes the whole principal
Revenue splits in exact proportion to weight - cross-multiplied, so the assertion introduces no rounding of its own
A staker who joins after a distribution earns none of it
Claiming twice pays nothing the second time
An exiting staker never collects a slice of their own forfeit
Invariants
after any interleaving of every action
Solvency - DeskStaking always holds enough dHOOD to return every principal and pay every unclaimed reward
Conservation - HOOD in minus HOOD out equals exactly what the vault and router hold
No value from nothing - users can never extract more HOOD than was put in
Share supply - every dHOOD is accounted for across the addresses that can hold one
Aggregates match positions - totalWeight and totalStaked equal the sum over live positions
Accumulators only rise - reward accumulators and lifetime counters are one-way
The suite runs with fail_on_revert = true, and the handler guards its own preconditions rather than reverting. This matters: with reverts tolerated, a handler that silently fails every call still passes every invariant, and the run proves nothing.
The bug this found
critical · fixed
stake() initialised each position's reward debt with a floored division. Rounding debt down credits a new position up to 1 wei of revenue distributed before it existed - 1 wei per position, accumulating.
In most staking systems that is invisible dust. Here the reward asset is the staked asset, so the shortfall eventually reaches principal:
staking cannot cover what it owes: 9789102232319278373992587352 < ...353
ERC20InsufficientBalance(staking, 6220287, 6220288)
The last staker to unstake could not withdraw. Off by one wei, funds stuck.
Fixed by initialising debt with Math.ceilDiv and saturating the subtraction so the now-slightly-high debt cannot underflow. Payouts are provably bounded by contributions; residual dust stays in the contract instead of being over-distributed.
All 35 unit tests passed with that bug present, including one named test_lateStakerDoesNotEarnEarlierRevenue. Finding it needed an adversarial ordering of stakes, distributions and exits.
Verifying the suite is not vacuous
A green suite proves nothing until it has been seen to go red. Injecting the bug back in turns invariant_stakingIsSolvent red with the exact wei shortfall, and testFuzz_lateStakerEarnsNoPriorRevenue produces a concrete counterexample. Reverting the mutation returns all 61 to green.
What none of this covers
No third-party review of any kind
Economic and game-theoretic behaviour - the tests check arithmetic, not whether the incentives are sound
Gas griefing, MEV, or ordering attacks by a motivated adversary
Anything about a deployment, since there has never been one