Skip to content

Elysium testnet. Tokens have no value.

DanakeDanake
Protocol

The Roll

The Roll is the loss ledger of one dead token at one snapshot: for each wallet, what it paid in HYPE for the tokens it still holds, minus what they are worth.

What goes in#

The Roll reads public logs only, up to and including the snapshot block:

  • the token's Transfer events;
  • its curve's Trade events (trader, isBuy, hypeAmount, tokenAmount, hypeReserveAfter, tokenReserveAfter, timestamp);
  • its curve's Rug event, if any (demo launchpad only).

The starting state of the curve (virtual HYPE reserve and token reserve) comes from the factory's CurveCreated event. Events are processed in (blockNumber, logIndex) order. Nothing else is used: no off-chain prices, no indexer, no private data.

Cost-basis rules#

Each wallet has a balance (tokens) and a cost (HYPE wei). The Roll uses the average-cost method.

EventEffect
Buy on the curvebalance += tokensOut, cost += hypeIn
Any outflow of x (sell, transfer out, burn)cost -= floor(cost * x / balance), then balance -= x
Transfer in (any other inflow)balance += x, cost unchanged

A buy is a Transfer from the curve to a wallet that matches a buy Trade in the same transaction, with the same trader and the same token amount. The HYPE paid in that Trade is added to the cost. Any other inflow, including a transfer from another wallet, costs zero. This is why received tokens never show a loss.

Value and loss#

At the snapshot, the Roll takes the curve's reserves after the last Trade or Rug event: real HYPE reserve H, token reserve T, virtual HYPE reserve V. With X = V + H:

value = min(H, X - ceil(X * T / (T + balance)))
loss  = max(0, cost - value)

value is what the curve would pay for the wallet's whole balance if it sold it alone. It mirrors DemoCurve.quoteSell exactly, including the rounding up inside the curve. It can never exceed the real HYPE reserve.

Wallets with zero loss are left out. These addresses are never listed: the curve, the factory, the market, the fee vault, the zero address and 0x…dEaD.

Rounding#

All math is in integers (bigint in TypeScript, uint256 in Solidity). Every division floors, except the one ceiling inside the curve formula, copied from the contract so that the Roll and the curve agree to the wei. Flooring the cost reduction on an outflow leaves at most one wei of extra cost per outflow on the wallet.

Worked example#

Take one wallet, W, and round numbers. Amounts are in HYPE and whole tokens; the real computation is the same in wei.

StepEventbalancecost
1W buys 1,000 DOG for 1.0 HYPE1,0001.0
2W buys 1,000 DOG for 2.0 HYPE2,0003.0
3W sends 500 DOG to F1,5003.0 − 3.0 × 500 / 2,000 = 2.25
4W sells 500 DOG to the curve1,0002.25 − 2.25 × 500 / 1,500 = 1.5

F received 500 DOG by transfer: balance 500, cost 0.

At the snapshot the token has been rugged. The curve holds H = 0.1 HYPE, V = 0.1 HYPE and T = 1,000 DOG, so X = 0.2.

  • W: value = min(0.1, 0.2 − 0.2 × 1,000 / (1,000 + 1,000)) = min(0.1, 0.1) = 0.1. loss = 1.5 − 0.1 = 1.4 HYPE.
  • F: value = min(0.1, 0.2 − 0.2 × 1,000 / 1,500) ≈ 0.0667. cost = 0, so loss = 0. F is left out.

The Roll has one entry: W, bag 1,000 DOG, proven loss 1.4 HYPE.

Minimum purchase age#

A Fare can ask for a minimum purchase age. Buys whose Trade timestamp is later than snapshotTime − minAge are treated as transfers in: they add to the balance at zero cost. This makes it harder to manufacture a loss just before a snapshot. The parameter is part of the Fare's rollURI (see below). The default is 0.

The tree#

Each entry becomes a leaf (address wallet, uint256 bag, uint256 loss), built with OpenZeppelin's StandardMerkleTree: the leaf is keccak256(bytes.concat(keccak256(abi.encode(wallet, bag, loss)))) and pairs are hashed in sorted order. FareMarket.cross checks the same leaf with MerkleProof. A parity test in packages/contracts/test/Parity.t.sol proves that trees built by packages/roll verify in Solidity.

The root is deterministic: the same logs give the same root, whatever order they are fetched in.

rollURI#

Each Fare stores a rollURI that says how to rebuild its Roll. The token and the snapshot block are already in the Fare; the URI adds the rule version and parameters:

danake:roll:v1:minAge=<seconds>

Recompute it yourself#

The CLI in packages/roll rebuilds any Roll from logs, with no server of ours in the loop:

pnpm roll build <token> <block|latest> [--minAge N] [--out roll.json] [--network testnet|local] [--rpc URL]
pnpm roll verify <fareId> [--network testnet|local] [--rpc URL]

build prints the root, the total loss and the largest entries, and writes the full Roll as JSON. verify reads a Fare from the chain, rebuilds its Roll and exits with code 0 if it matches.

The RPC limits of the public endpoint (2,000 blocks per eth_getLogs without an address filter, 10,000 logs per query) are handled by always filtering by address and paging adaptively: the range halves when the node refuses and grows again after a success.

What "Roll verified" means#

Every Fare page recomputes the Roll from logs and shows one of two badges:

  • Roll verified: the recomputed root equals the root stored in the Fare, and the Fare's snapshotTime equals the timestamp of its snapshotBlock.
  • Roll mismatch: one of the two checks failed. The payer committed a root, or a snapshot time, that the public logs do not produce. Do not cross a Fare with a mismatch.

The contract cannot compute the Roll itself in v1, so this check is the trust mechanism. It is done during the dispute period, before anyone can burn. See Risks and limitations.