HOODBROKER

The desk

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
TermWeightShares Forfeit day 0Forfeit halfwayFree after

Forfeit decays linearly: 20% of principal the moment you commit, zero once the term is served.

Documentation

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
  1. STAGE 01

    Deposit

    in HOOD · out dHOOD

    DeskVault takes 1% as an entry fee and forwards it to FeeRouter. The rest mints shares to you.

  2. STAGE 02

    Stake

    in dHOOD · out position

    Lock shares into DeskStaking for a fixed term. The position is bound to your address and cannot be sold or moved.

  3. STAGE 03

    Earn

    in weight · out dHOOD

    Two revenue streams accrue to the position in proportion to its weight. Nothing accrues to shares you merely hold.

  4. STAGE 04

    Claim

    in position · out dHOOD

    Take rewards any time without touching principal - or unstake to close the position entirely.

Return path unstake 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
TermDurationMultiplier Weight on 10,000 sharesRelative pull
Short30 days1.0×10,000
Standard90 days1.5×15,000
Long180 days2.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
  1. HOOD accumulates in FeeRouter from the sources listed below.
  2. route() deposits that HOOD into the vault, converting it to dHOOD.
  3. 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
  1. A staker closes a position before the term is served.
  2. Their forfeit is deducted from principal, in dHOOD.
  3. 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 FeeRouterRateEnforced by
Vault entry fee1% of every deposit DeskVault - constant, no function can change it
Trading tax · staker share1.5% of every buy and sell Custom token contract - not built yet
Team share, forwardeddiscretionary 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
20%15%10% 5%0 03060 90120150 180 forfeit · % of principal days since you committed 30d 90d 180d

day 90 of 180

Term

Cost to walk away

on a 10,000 dHOOD position, using the deployed contract arithmetic

−10.0% forfeit −1,000.00 dHOOD you keep 9,000.00 dHOOD
forfeit = shares × 20% × (time remaining / term)

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.

06 · Reward accounting

MasterChef accumulators at 1e18
distribute:  acc += rewardShares × 1e18 / totalWeight
pending:     weight × acc / 1e18 − debt

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.

PassRouted Reaches stakersStays in the router
1100.00 HOOD+99.010.99
20.99 HOOD+0.980.01
30.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.

Contracts

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
ContractLocal addressRole
HoodToken0x5FbDB231…64180aa3Fixed-supply ERC20
DeskVault0xe7f1725E…bb3F0512ERC-4626 over HOOD
DeskStaking0x9fE46736…3c7fa6e0Bound term positions
FeeRouter0xCf7Ed3Ac…34fB0Fc9Revenue conversion

Solidity 0.8.26 · OpenZeppelin v5.5.0 · optimizer on, 200 runs

HoodToken

1,000,000,000 fixed · no mint function
CapabilityWhoStatus
adminBurnownerLive - burns from any wallet
renounceBurnPowerownerOne-way kill switch for the above
renounceOwnershipownerStandard OZ Ownable
burnanyoneYour own balance only, never privileged

DeskVault

compliant ERC-4626 · shares transferable
CapabilityWhoStatus
setFeeRouterownerOne-shot - reverts on the second call, so the fee destination can never be changed after wiring
renounceOwnershipownerStandard 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.

stake(uint256,uint8)          claim(uint256)          unstake(uint256)
notifyTradeFeeReward(uint256) pendingRewards(uint256) currentPenalty(uint256)
timeRemaining(uint256)        positions(uint256)      positionsOf(address)
totalWeight() totalStaked()   accTradeFeePerWeight()  accPenaltyPerWeight()
lifetimeTradeFees()           lifetimePenalties()     terms(uint256) termCount()
shareToken() treasury()       feeRouter()             MAX_PENALTY_BPS()
nextPositionId() owner()      setFeeRouter(address)
renounceOwnership()           transferOwnership(address)

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.

SliceRateDestination
Stakers1.5% FeeRouter, then route() to DeskStaking
Team0.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.

Verifying any of this

forge inspect DeskStaking methodIdentifiers   # confirm no transfer exists
cast call $TOKEN "burnPowerRenounced()(bool)" --rpc-url $RPC
cast call $VAULT "ENTRY_FEE_BPS()(uint256)"   --rpc-url $RPC
cast call $STAKE "treasury()(address)"        --rpc-url $RPC

Assurance

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
  • Frontend, key management, upgrade paths - none exist
Solidity 0.8.26 OpenZeppelin v5.5.0 Tests 61 passing Audit none Deployed to local anvil Figures simulated in-browser