the whole system in prose, then the source.
most tokens that pay holders pay them by balance, so the wallet that bought a minute ago and the wallet that has held for a month are treated the same, and the payout becomes a reason to arrive just before it and leave just after. ponshold was built so that the only way to be paid the most is to have held the longest, and so that what is paid is more of the token, which makes the holding itself grow.
the whole thing is one contract with no owner, no pause, no upgrade, and no admin key. the fee that pays for it is the ordinary creator fee that pons pays for the token. nobody has to top it up, and nobody can take it out except through a round, which pays everyone.
a seat is a timestamp. it is the mean time at which the tokens in a wallet were acquired, weighted by how many arrived at each time. it lives in the token contract and moves on every balance change, inside the transfer itself, so there is no separate claim, stake, or register step and nothing to forget to do.
when tokens come in from anywhere except the contract, the new seat is the old balance times the old seat plus the new quantity times now, divided by the new balance. new tokens are born with no age and they dilute the old. when tokens come in from the contract, which only happens when a round pays, the seat does not move at all.
when tokens go out for any reason, the wallet's remaining age is multiplied by one minus twice the fraction that left, floored at zero, and the seat is set to now minus that age. a tenth out is a fifth of the age gone. a quarter out is half. half out is all of it. the rule is deliberately harsher than proportional so that trimming a bag is never free.
tenure is turned into a multiplier by a curve with one constant, the half tenure of seven days. the multiplier starts at one, climbs by half of the remaining distance to eight every seven days, and never quite reaches eight. the shape was chosen so that the first week matters most, the first month matters a lot, and there is always something left to earn by holding longer, but never so much that an ancient bag can starve everyone else.
the curve is computed on chain in fixed point with eighteen decimals. the integer part of the tenure in half tenures is a shift, the fractional part goes through an exponential series of twelve terms, and the result agrees with the floating point curve on this site to at least six decimals.
a round is the unit of paying. it opens when the last one seals, gathers fees for at least six hours, and may be closed by anyone once the pot is at or above the minimum. the closer takes half a percent of the pot as payment for calling. the rest is swapped for the token in the same transaction, on the token's own pool, with a bound of three percent against the pool's spot at the top of the block so that a close cannot be sandwiched into paying too much.
what the swap buys is the round's payout. a quarter percent of it is set aside for the people who run the sweep. the remainder is split by weight.
anyone can move the sweep. tally and pay are public functions that take one number, how many wallets to walk, up to four hundred. calling them costs gas and nothing else. a quarter percent of every round's bought tokens is set aside and split among everyone who ran a page, by pages run, when the round seals.
a round cannot get stuck. if nobody runs a page the round simply waits, fees keep gathering in the pot, and the next close cannot happen until this round is sealed, so anyone waiting on the next round has a reason to finish this one.
the sweep reads checkpoints at the close time. selling during a sweep does not change what this round pays you. it does move your seat, so it changes the next one.
the holder list is append only. a wallet that sells to zero keeps its slot and simply weighs nothing, and comes back to life if it buys again. this keeps the walk stable while a round is in flight, because the list frozen at the close cannot be reordered by anyone leaving.
a wallet cannot be paid twice in a round, because the pay cursor only moves forward. a wallet cannot dodge the sell penalty by moving tokens to a second wallet, because a transfer is an outbound on one side and an inbound on the other, and the inbound arrives with no age. a wallet cannot inherit age by receiving from an old wallet. a round cannot pay more than it bought. a round cannot be closed twice. a sweep cannot skip a wallet.
a wallet can be paid while it is selling, for what it held at the close. a wallet can hold through many rounds and see its multiplier climb with no action. anyone can close and anyone can sweep, and both are paid a little for it in the open.
this site reads Robinhood Chain directly with a public rpc and keeps an index of the contract's events in a small database so that tables can be sorted and paged. nothing on the site is written by hand. every number has a read behind it, and where the read has not happened yet the slot is empty rather than filled with a guess.
the token contract is deployed first with the fee asset, the pool, the venue adapter, the pons fee source, the minimum pot, and the symbol as constructor arguments, and the whole supply minted to the deployer for the pons listing. the token is then listed on pons with the contract's own address as the creator fee recipient. once listed, the deployer's role is over. the addresses in the contract page are the only ones this site ever reads.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IERC20Min {
function balanceOf(address a) external view returns (uint256);
function transfer(address to, uint256 v) external returns (bool);
function approve(address s, uint256 v) external returns (bool);
}
interface IVenue {
function quote(uint256 amountIn) external view returns (uint256 amountOut);
function swap(uint256 amountIn, uint256 minOut, address to) external returns (uint256 amountOut);
}
interface IFeeSource {
function claim(address token) external returns (uint256 amount);
}
contract Ponshold {
string public constant name = "ponshold";
string public symbol;
uint8 public constant decimals = 18;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
struct Checkpoint { uint64 t; uint64 seat; uint96 bal; }
mapping(address => Checkpoint[]) private cps;
mapping(address => bool) public excluded;
address[] public holders;
mapping(address => uint256) private slotOf;
IERC20Min public immutable feeAsset;
IVenue public immutable venue;
IFeeSource public immutable feeSource;
address public immutable pool;
uint64 public constant H = 604800;
uint256 public constant M_MINUS_ONE = 7e18;
uint256 private constant ONE = 1e18;
uint256 private constant LN2 = 693147180559945309;
uint64 public constant ROUND_LENGTH = 21600;
uint256 public immutable MIN_POT;
uint256 public constant CLOSER_BPS = 50;
uint256 public constant SWEEP_BPS = 25;
uint256 public constant PAGE_MAX = 400;
uint256 public constant SLIPPAGE_BPS = 300;
enum Phase { Open, Tally, Pay }
struct Round {
uint64 openedAt;
uint64 closedAt;
uint256 pot;
uint256 bought;
uint256 sweepReserve;
uint256 sumW;
uint256 n;
uint256 tallyCursor;
uint256 payCursor;
uint256 pages;
Phase phase;
address closer;
}
uint256 public roundNumber;
uint256 public carry;
mapping(uint256 => Round) public rounds;
mapping(uint256 => mapping(address => uint256)) public weightIn;
mapping(uint256 => mapping(address => uint256)) public pagesBy;
mapping(uint256 => address[]) private sweepers;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
event Seat(address indexed wallet, uint64 seat);
event Collected(uint256 amount);
event RoundClosed(uint256 indexed round, uint64 closedAt, uint256 pot, address closer, uint256 bought);
event Tallied(uint256 indexed round, uint256 from, uint256 to, uint256 sumSoFar);
event Paid(uint256 indexed round, address indexed wallet, uint256 weight, uint256 amount);
event RoundSealed(uint256 indexed round, uint256 sumW, uint256 dust);
constructor(
string memory symbol_,
uint256 supply,
address feeAsset_,
address venue_,
address feeSource_,
address pool_,
uint256 minPot,
address[] memory excluded_
) {
symbol = symbol_;
feeAsset = IERC20Min(feeAsset_);
venue = IVenue(venue_);
feeSource = IFeeSource(feeSource_);
pool = pool_;
MIN_POT = minPot;
excluded[address(this)] = true;
excluded[pool_] = true;
excluded[venue_] = true;
excluded[feeSource_] = true;
excluded[address(0)] = true;
for (uint256 i = 0; i < excluded_.length; i += 1) excluded[excluded_[i]] = true;
roundNumber = 1;
rounds[1].openedAt = uint64(block.timestamp);
_mint(msg.sender, supply);
}
// erc20
function transfer(address to, uint256 v) external returns (bool) {
_move(msg.sender, to, v);
return true;
}
function approve(address s, uint256 v) external returns (bool) {
allowance[msg.sender][s] = v;
emit Approval(msg.sender, s, v);
return true;
}
function transferFrom(address from, address to, uint256 v) external returns (bool) {
uint256 a = allowance[from][msg.sender];
if (a != type(uint256).max) allowance[from][msg.sender] = a - v;
_move(from, to, v);
return true;
}
function _mint(address to, uint256 v) internal {
totalSupply += v;
balanceOf[to] += v;
_afterIn(to, v, false);
emit Transfer(address(0), to, v);
}
function _move(address from, address to, uint256 v) internal {
require(to != address(0), "zero");
uint256 b = balanceOf[from];
require(b >= v, "balance");
_beforeOut(from, b, v);
balanceOf[from] = b - v;
balanceOf[to] += v;
_afterIn(to, v, from == address(this));
emit Transfer(from, to, v);
}
// seats
function _beforeOut(address a, uint256 b, uint256 q) internal {
if (excluded[a] || b == 0) return;
Checkpoint[] storage list = cps[a];
uint64 seat = list.length == 0 ? uint64(block.timestamp) : list[list.length - 1].seat;
uint256 age = block.timestamp > seat ? block.timestamp - seat : 0;
uint256 twice = 2 * q;
uint256 keep = twice >= b ? 0 : age * (b - twice) / b;
uint64 newSeat = uint64(block.timestamp - keep);
_push(a, uint96(b - q), newSeat);
}
function _afterIn(address a, uint256 q, bool fromSelf) internal {
if (excluded[a] || q == 0) return;
Checkpoint[] storage list = cps[a];
uint256 b = balanceOf[a] - q;
uint64 seat;
if (b == 0 || list.length == 0) {
seat = uint64(block.timestamp);
} else if (fromSelf) {
seat = list[list.length - 1].seat;
} else {
uint256 old = list[list.length - 1].seat;
seat = uint64((b * old + q * block.timestamp) / (b + q));
}
if (slotOf[a] == 0) {
holders.push(a);
slotOf[a] = holders.length;
}
_push(a, uint96(b + q), seat);
}
function _push(address a, uint96 bal, uint64 seat) internal {
cps[a].push(Checkpoint(uint64(block.timestamp), seat, bal));
emit Seat(a, seat);
}
function _lookup(address a, uint64 t) internal view returns (uint96 bal, uint64 seat) {
Checkpoint[] storage list = cps[a];
uint256 n = list.length;
if (n == 0 || list[0].t > t) return (0, 0);
uint256 lo = 0;
uint256 hi = n - 1;
while (lo < hi) {
uint256 mid = (lo + hi + 1) / 2;
if (list[mid].t <= t) lo = mid; else hi = mid - 1;
}
return (list[lo].bal, list[lo].seat);
}
// curve
function pow2neg(uint256 x) public pure returns (uint256) {
uint256 ip = x / ONE;
uint256 fp = x % ONE;
if (ip >= 64) return 0;
uint256 y = fp * LN2 / ONE;
int256 term = int256(ONE);
int256 sum = int256(ONE);
for (uint256 k = 1; k <= 12; k += 1) {
term = term * int256(y) / int256(ONE) / int256(k);
term = 0 - term;
sum += term;
}
return uint256(sum) >> ip;
}
function curve(uint256 tenure) public pure returns (uint256) {
uint256 x = tenure * ONE / H;
return ONE + M_MINUS_ONE * (ONE - pow2neg(x)) / ONE;
}
function seatOf(address a) public view returns (uint64) {
Checkpoint[] storage list = cps[a];
return list.length == 0 ? 0 : list[list.length - 1].seat;
}
function tenureOf(address a) public view returns (uint256) {
uint64 s = seatOf(a);
return block.timestamp > s ? block.timestamp - s : 0;
}
function multiplierOf(address a) public view returns (uint256) {
return curve(tenureOf(a));
}
function weightOf(address a) public view returns (uint256) {
return balanceOf[a] * multiplierOf(a) / ONE;
}
function balanceAt(address a, uint64 t) public view returns (uint256) {
(uint96 b, ) = _lookup(a, t);
return b;
}
function seatAt(address a, uint64 t) public view returns (uint64) {
(, uint64 s) = _lookup(a, t);
return s;
}
function holderCount() external view returns (uint256) {
return holders.length;
}
// rounds
function collect() public {
uint256 got = feeSource.claim(address(this));
emit Collected(got);
}
function canClose() public view returns (bool) {
Round storage r = rounds[roundNumber];
return r.phase == Phase.Open
&& block.timestamp >= r.openedAt + ROUND_LENGTH
&& feeAsset.balanceOf(address(this)) >= MIN_POT;
}
function close(uint256 minOut) external {
require(canClose(), "not yet");
Round storage r = rounds[roundNumber];
uint256 pot = feeAsset.balanceOf(address(this));
uint256 fee = pot * CLOSER_BPS / 10000;
uint256 spend = pot - fee;
uint256 floor_ = venue.quote(spend) * (10000 - SLIPPAGE_BPS) / 10000;
if (minOut < floor_) minOut = floor_;
feeAsset.transfer(msg.sender, fee);
feeAsset.approve(address(venue), spend);
uint256 got = venue.swap(spend, minOut, address(this)) + carry;
carry = 0;
r.closedAt = uint64(block.timestamp);
r.pot = pot;
r.closer = msg.sender;
r.bought = got;
r.sweepReserve = got * SWEEP_BPS / 10000;
r.n = holders.length;
r.phase = Phase.Tally;
emit RoundClosed(roundNumber, r.closedAt, pot, msg.sender, got);
}
function tally(uint256 n) external {
Round storage r = rounds[roundNumber];
require(r.phase == Phase.Tally, "not tally");
if (n > PAGE_MAX) n = PAGE_MAX;
uint256 from = r.tallyCursor;
uint256 to = from + n;
if (to > r.n) to = r.n;
uint256 sum = r.sumW;
for (uint256 i = from; i < to; i += 1) {
address a = holders[i];
(uint96 b, uint64 s) = _lookup(a, r.closedAt);
if (b == 0) continue;
uint256 age = r.closedAt > s ? r.closedAt - s : 0;
uint256 w = uint256(b) * curve(age) / ONE;
weightIn[roundNumber][a] = w;
sum += w;
}
r.sumW = sum;
r.tallyCursor = to;
_credit(msg.sender);
emit Tallied(roundNumber, from, to, sum);
if (to == r.n) r.phase = Phase.Pay;
}
function pay(uint256 n) external {
Round storage r = rounds[roundNumber];
require(r.phase == Phase.Pay, "not pay");
if (n > PAGE_MAX) n = PAGE_MAX;
uint256 from = r.payCursor;
uint256 to = from + n;
if (to > r.n) to = r.n;
uint256 net = r.bought - r.sweepReserve;
for (uint256 i = from; i < to; i += 1) {
address a = holders[i];
uint256 w = weightIn[roundNumber][a];
if (w == 0 || r.sumW == 0) continue;
uint256 amt = net * w / r.sumW;
if (amt == 0) continue;
_move(address(this), a, amt);
emit Paid(roundNumber, a, w, amt);
}
r.payCursor = to;
_credit(msg.sender);
if (to == r.n) _seal();
}
function _credit(address who) internal {
Round storage r = rounds[roundNumber];
if (pagesBy[roundNumber][who] == 0) sweepers[roundNumber].push(who);
pagesBy[roundNumber][who] += 1;
r.pages += 1;
}
function _seal() internal {
Round storage r = rounds[roundNumber];
address[] storage s = sweepers[roundNumber];
uint256 paidOut = 0;
for (uint256 i = 0; i < s.length; i += 1) {
uint256 amt = r.sweepReserve * pagesBy[roundNumber][s[i]] / r.pages;
if (amt > 0) {
_move(address(this), s[i], amt);
paidOut += amt;
}
}
uint256 dust = balanceOf[address(this)];
carry = dust;
emit RoundSealed(roundNumber, r.sumW, dust);
roundNumber += 1;
rounds[roundNumber].openedAt = uint64(block.timestamp);
}
}the venue adapter is not part of this file. it is a separate contract of two functions, quote and swap, written against the pool that pons created for the token and verified on the explorer at the address shown on the contract page.