The leg word
A leg is one stretch of the journey, packed into exactly one 256-bit storage slot.
255 … 208207 … 4847 … 2423 … 0
blockuint48
runneraddress · 160 bits
postint24
preint24
pre | (post << 24) | (runner << 48) | (block << 208)
pre = int24(uint24( leg & 0xFFFFFF ))
post = int24(uint24((leg >> 24) & 0xFFFFFF ))
runner = address(uint160((leg >> 48) & (1<<160)-1))
block = uint48((leg >> 208) & (1<<48)-1)
48 bits of block number runs out around block 2.8 × 1014. At twelve seconds
a block that is roughly a hundred million years, which is long enough.
The hook keeps no history of its own beyond this array. It stores
lastTick, the tick the previous leg ended on, and that is
exactly where the next one starts: minting, burning and donating never move the tick, so
the pool is its own record of where it was.
The crossing rule
function crosses(uint256 leg, int24 target, bool up) internal pure returns (bool) {
(int24 pre, int24 post,,) = unpack(leg);
return up ? (pre < target && target <= post)
: (post <= target && target < pre);
}
Half-open at pre, closed at post,
in both directions. pre is where the previous leg already left
the price, so a target sitting exactly there has been reached already and is not this
leg's work; post is where this leg put it, and arriving exactly
on the target counts as arriving.
Worked cases
| pre | post | target | up | crosses | why |
| 206,700 | 207,100 | 207,000 | true | true | Carried past it on the way through. |
| 206,700 | 207,000 | 207,000 | true | true | Landed exactly on it. Arriving counts. |
| 207,000 | 207,400 | 207,000 | true | false | It was already there when this leg started. Somebody else's work. |
| 206,700 | 206,900 | 207,000 | true | false | Did not get there. |
| 207,100 | 206,300 | 206,400 | false | true | Carried back under it. |
| 207,100 | 206,400 | 206,400 | false | true | Landed exactly on it. |
| 206,400 | 206,000 | 206,400 | false | false | Already there at the start of the leg. |
| 206,700 | 207,100 | 206,400 | false | false | Wrong way. A down errand is only ever satisfied by a down leg. |
Two further checks belong to the board rather than the library:
legIndex >= errand.fromLeg, which stops history being mined
for work nobody was asked to do, and the hold below, which is the one that matters.
The hold
Crossing the target opens the job. It does not finish it. Payment requires a second
observation, at least HOLD = 300 blocks after the crossing
leg's block, showing the price still past the target.
// claim — the proof is a later leg
if (proofIndex < legIndex || proofBlk < blk + HOLD) revert NotHeldYet();
if (e.up ? heldAt < e.target : heldAt > e.target) revert DidNotHold();
// claimLive — the proof is the pool, right now
if (block.number < uint256(blk) + HOLD) revert NotHeldYet();
if (POOL_MANAGER.getLiquidity(key.toId()) == 0) revert NoLiquidity();
if (e.up ? now_ < e.target : now_ > e.target) revert DidNotHold();
Note the comparison at the far end: >= the target for an
up errand, <= for a down one — still past it, not merely
across it again. And note what is not compared: neither route examines the interval
between the two observations. A price that leaves and comes back still qualifies. The
property being bought is not continuity, it is that the two observations are 300 blocks
apart, and no transaction spans 300 blocks.
MIN_LIFE is 2 × HOLD for the
obvious reason: an errand has to be claimable long enough to be carried and held.
Functions
Errands — the board
post(int24 target, uint96 base, uint64 rate, uint96 escrow, uint48 expiry) → uint256 id
Pulls escrow from the caller with
transferFrom and writes the errand. The direction is read from
the pool, not passed in. fromLeg is set to the current leg
count.
- BadEscrow — escrow == 0 or base > escrow.
- PaysNothing — base and rate both zero. A job no runner could ever collect on is not a job.
- BadExpiry — expiry < block.number + MIN_LIFE (600 blocks).
- PoolNotOpen — the pool has never been initialised.
- BadTarget — the target is the tick the price is on right now.
- TooManyLegs — the leg count no longer fits in uint32. Unreachable before the heat death of the pool; a revert beats a silently truncated index.
- TransferFailed — the escrow transfer returned false.
rate is a uint64,
so at 18 decimals it tops out at about 18.446 ACTION per block. base
and escrow are uint96, which is far
above the whole supply.
claim(uint256 id, uint256 legIndex, uint256 proofIndex) → uint256 paid
Permissionless. Settles against a crossing leg plus a later leg proving the price
stayed, and pays the runner in the record, never the caller. In one
transaction: the whole bounty to the runner, escrow − pay back
to the poster, a Waybill minted inside a try, and
Ran emitted.
- NoSuchErrand — no errand with that id.
- AlreadySettled — claimed or withdrawn already.
- LegTooEarly — legIndex < fromLeg.
- DoesNotCross — that leg does not take the price past the target.
- NotHeldYet — proofIndex < legIndex, or the proof leg is less than 300 blocks after the crossing.
- DidNotHold — the proof leg's own end is back on the wrong side of the target.
Because the proof is a fact about the past, this route never expires
and cannot be raced. A crossing that held remains claimable indefinitely — unless the
poster reclaims the escrow through announce and
withdraw first.
claimLive(uint256 id, uint256 legIndex) → uint256 paid
The same settlement, proved against the pool's price right now instead of a later
leg. Cheaper, and available as soon as it is true.
- NotHeldYet — fewer than 300 blocks since the crossing leg.
- NoLiquidity — getLiquidity == 0. A tick parked where nobody is providing is a number, not a price; in an empty region it can be walked anywhere for gas.
- DidNotHold — the pool's current tick is back on the wrong side of the target.
- Plus NoSuchErrand, AlreadySettled, LegTooEarly, DoesNotCross.
announce(uint256 id)
Poster only, after block.number > expiry. Sets
abandonAt = block.number + GRACE and emits
Announced. Public and on chain, so anyone holding a completing
leg has a day's warning to claim.
- NotPoster, NotYetExpired, AlreadySettled, NoSuchErrand.
withdraw(uint256 id)
Poster only, no sooner than abandonAt. Returns the whole
escrow and marks the errand settled. This and a claim are the only two ways money ever
leaves an errand, and there is no early version of either.
- NotAnnounced — announce was never called.
- NotYetExpired — the grace period has not run out.
- NotPoster, AlreadySettled, NoSuchErrand.
quote(uint256 id, uint256 legIndex) → (uint256 pay, address runner)
What the errand would pay if that leg were the crossing, and who would get it. A
view. It checks fromLeg but does not check that the
leg crosses or that anything held — it answers the money question only.
payNow(uint256 id) → uint256
The same formula against the current block: what it would be worth if the price were
carried across in this block. The board's live figure. Reverts
NoSuchErrand on an unknown id.
board(uint256 from, uint256 to, uint256 legFrom, uint256 legTo) → (uint256[] ids, bool[] crossed, uint256[] atLeg)
Every unsettled errand in [from, to), and for each one the
first leg at or after legFrom that crosses it. Both ranges are
paged, because scanning every leg for every open errand is quadratic and a long-lived
board would put it past any node's call limit. to or
legTo of zero means the end.
crossed is exactly that — crossed, not
completed. Whether it held is a separate question, and this view does not answer it.
Note also that a claimant may name any qualifying crossing leg, not just this first
one, and a later crossing pays more.
ledger() → (uint256 posted, uint256 paid, uint256 returned, uint256 open)
Running totals, where open = posted − paid − returned.
Four values rather than five, because nothing is skimmed.
Dispatch — the record
legAt(uint256 i) → (int24 pre, int24 post, address runner, uint48 blk)
One leg, unpacked. Out of range reverts on the array access.
legs(i) gives the raw word and
legCount() the length.
recentLegs(uint256 n) → uint256[]
The most recent n packed legs, newest last, clamped to the
length. For wallets and indexers.
findCrossing(int24 target, bool up, uint256 from) → (bool found, uint256 index)
The first leg at or after from that took the price past
target in that direction. A helper for whoever is about to
claim: linear, meant for eth_call. The claim itself names the
index and costs one read. It answers the crossing question only; the hold is checked at
the claim.
poolKey() → PoolKey · isBoundPool(PoolKey) → bool · lastTick() → int24
The pool this hook keeps the record for, a test for it, and where the last leg left
the price. Legs are only written for the bound pool; every other pool gets the selector
back and nothing else. Errands's constructor uses this pair to
check that the bounty token really is the pool's
currency1 — asking the hook rather than trusting the deployer
— and reverts WrongToken if it is not.
Waybill — the receipt
bind(address errands) · issue(...) · tokenURI(uint256) · records(uint256)
bind is callable once, by the deployer, to point the receipt
contract at the board — the single privileged action in the whole system, and after it
nothing is privileged. issue is callable only by the board.
tokenURI returns a base64 data URI with the SVG inside it; no
gateway, no server, nothing to keep paying for. The rest is a plain ERC-721.