Shaft
Payout—:—:—
Docs

Security

This page is a plain, specific account of how the FeeDistributor contract is built, what has been tested, and what trust assumptions remain. It is written for someone deciding whether to interact with the contract, not as marketing copy.

Pull-payment model

Every payout (the ETH refund on a flip, the token rebate to an incoming buyer, the ongoing LP fee credit to a holder) is credited to an internal balance first and only ever leaves the contract when the recipient themselves calls the claim function. No function that changes who holds the slot ever pushes a transfer to a third party as part of its own execution.

This matters for a very concrete reason: if payouts were pushed automatically during a flip, a malicious or simply broken recipient contract could make its own transfer revert (or burn excessive gas) and thereby block the entire flip, freezing the game for everyone. Because payouts are pull-only, a reverting or hostile recipient can only ever hurt themselves: they simply never call the withdrawal function to collect what's owed to them. They cannot block anyone else's transaction.

Reentrancy protection

The two functions that ever move real value out of the contract, the slot-claim function and the withdrawal function, are both guarded against reentrancy. The permissionless fee-collection heartbeat function does not move any value to an external address itself (it only pulls fees in from the external fee source and updates internal accounting), so it does not carry the same guard, but it is exercised directly inside reentrancy test coverage regardless, including against a hostile recipient designed specifically to try to reenter mid-flip.

Solvency invariant, and a bug that was caught before launch

The core safety property of this contract is simple to state: it can never owe more, in total, than it actually holds. Every payout has to come from money that is genuinely available to pay it.

During development, an internal review caught a real solvency hole in an early version of the decay-and-refund logic: a displaced holder was being refunded 75% of what they had originally paid, but the incoming buyer who displaced them, on the decay path, could be paying strictly less than that. Repeated across enough decay cycles, that shortfall could have been paid out straight from the protocol's own retained balance and, eventually, from other users' unclaimed balances.

The fix, and the rule that runs today, is that the 75% ETH refund and the token rebate are paid out only on a genuine in-window flip, where the incoming buyer is always paying strictly more than the outgoing holder did, which is exactly what funds the refund. The moment a holder's window has expired, that relationship breaks, so an expiry takeover pays the outgoing holder and the incoming buyer nothing extra at all: the outgoing holder simply forfeits, having already collected the full fee stream for their occupancy.

What makes this worth calling out specifically: the first version of the automated test suite covering this invariant passed cleanly even with the bug still present, because it only checked an aggregate balance that happened to net out correctly regardless of which internal bucket the money landed in. The bug was only actually caught by deliberately reintroducing it into the code after the "fix" and confirming the test suite failed: a mutation-testing pass, not just a green checkmark. The test suite was then hardened to assert the exact expected change to every individual balance on every single settlement, not just an aggregate total, specifically so this class of bug can never hide again. That hardened, per-event invariant test is part of the suite described below and still runs today.

Immutability

The contract has no proxy pattern, no delegatecall-based upgrade path, and no admin-controlled implementation pointer. The token, the wrapped-ETH address, and the external fee source it integrates with are all set once in the constructor as immutable values and can never be changed after deployment. The contract you interact with today is the entire contract. There is no mechanism by which its core logic can be swapped out later.

Certain economic parameters (things like the escalation step size or timing windows) are configurable by the protocol owner and may be adjusted over time as the game is tuned; that adjustability is disclosed here at a high level rather than as a function-by-function list, since the specific mechanism is not the relevant fact for a user: what matters is that some parameters can move, and the fixed 75% refund share on an in-window flip is not one of them (it is a hard-coded constant, not adjustable at all).

Pause mechanism

The protocol owner has the ability to pause new slot claims. This is scoped narrowly: pausing blocks new claims only. It does not block the withdrawal function: anyone with a credited balance can always withdraw it, paused or not. It also does not block the permissionless fee-collection heartbeat, so fee accounting keeps running even while claims are paused. A pause cannot trap anyone's already-earned balance.

Test coverage

As independently re-run against the current source in this repository:

  • Contract test suite: 102 tests passing, 0 failing, across 13 test files. This breaks down as 85 tests that run entirely offline (unit tests, boundary-condition regression tests around the exact moment a window expires, a randomized fuzz test of the solvency invariant across 257 runs, and dedicated reentrancy-attack tests), plus 17 additional tests that run as live fork tests against the real external fee source and real token contracts on Robinhood Chain. These confirm the integration assumptions (fee collection behavior, revert shapes, gas costs, and the reentrancy guard) hold against the genuine external contract the game depends on, not just a simplified mock of it.
  • Keeper test suite: 42 tests passing, 0 failing, across 5 test files, covering the automation bot described below. All of these run against mocked chain clients and fake timers; none of them touch a live network.

Keeper trust model

An off-chain "keeper" bot exists to call the permissionless fee-collection heartbeat function automatically at useful moments (right before a window expires, right after, and on a periodic schedule), so fee accounting stays fresh without requiring a user transaction to trigger it.

What the keeper can do: call the one permissionless function that pulls newly accrued fees into the contract's accounting. That function is open to literally anyone: the keeper has no special privilege in calling it, and calling it does not change who holds the slot, what the price is, or who owns any credited balance.

What the keeper cannot do: it holds no owner privileges over the contract at all, it cannot move any user's credited balance, it cannot change the current holder, and it cannot alter any economic parameter. Its private key only pays for its own gas. If the keeper goes offline entirely, nothing is lost: fees simply sit uncollected in the external fee source until the next poke() call from anyone, and the next flip or claim call still runs the same collection step inline regardless.

The keeper also defaults to a dry-run mode that only logs what it would do, and requires two independent configuration flags to be deliberately flipped, plus a live check that real contract bytecode actually exists at the configured address, before it will ever broadcast a transaction. This is an operational safety property of the automation, not a property of the on-chain contract itself.

User-side risks

  • Smart contract risk. This is a from-scratch contract. It has an extensive automated test suite including fork tests against the real external dependency and a hardened solvency invariant, but no test suite, however thorough, is a formal proof of correctness. Only interact with funds you can afford to lose.
  • Chain risk. Robinhood Chain is a comparatively young network. Its operational characteristics (including its ordering model, discussed in provably-fair.md) are relevant to how the game behaves in practice and are worth understanding before playing.
  • Forfeiting your deposit is by design, not a bug. If you hold the slot and your window expires before anyone flips you, you keep everything you already earned during your window, but you permanently forfeit your entry deposit and any ETH refund if someone later takes the slot from you. This is not a malfunction: it is the intended mechanic described in overview.md, and it is exactly the behavior the solvency fix above exists to guarantee.
  • Parameters may change. Some economic parameters can be adjusted by the protocol over time. Always check the live, on-chain values (via the view functions in contracts.md) rather than relying on any historical number quoted in this documentation.
  • No guarantee of continued operation. As with any early-stage protocol, there is no guarantee the game continues to run indefinitely.