Security model
Four wallets exist, and each can do a specific and limited set of things. This page states all of them, including the ones that are uncomfortable, and names what the contract does not defend against.
The four wallets
They are separate keys because they are separate powers and separate risks. What matters is the last column: none of them is a key that can move a node balance.
| Wallet | Where it lives | Holds funds | Can do |
|---|---|---|---|
| Owner | Cold, off the server | No, beyond gas | Rotate the relayer and distributor, pause minting, set the cap, rescue unattached funds, hand over ownership in two steps |
| Payments | Offline; the server only watches the address | Yes, node purchases | Nothing on the contract. It is a recipient address with no privileges of any kind |
| Relayer | On the server | No, gas only | mintFor, and nothing else |
| Distributor | On the server | Yes, the payout float | creditBatch, and nothing else. Because that call is payable, it spends its own ETH rather than the contract’s |
The deploy script refuses to run if the relayer or the distributor equals the deployer, because the deployer becomes the owner and that check is what keeps the cold key off an internet-facing server.
What the owner can do
- Rotate the relayer with
setRelayer, and the distributor withsetDistributor. Neither can be set to the zero address. - Change the per-wallet cap with
setMaxPerWallet, within 1 to 100. Lowering it does not remove nodes anyone already holds. - Pause minting with
setPaused. - Take unattached funds with
rescue, bounded as described below. - Offer ownership with
transferOwnership. It does not take effect until the new address callsacceptOwnershipitself, so a typo cannot brick the admin surface.
What the owner cannot do
- Cannot take a node. There is no function that reassigns
node.owner. Nodes cannot be moved by anyone, including the owner. - Cannot withdraw on your behalf. Both withdrawal functions check the caller against
node.ownerand against nothing else. - Cannot pause withdrawals. The pause flag is read by
mintForand nowhere else in the contract. There is no state in which a balance is stuck. - Cannot credit a node without paying for it.
creditBatchis payable and reverts unlessmsg.valueequals the sum of the amounts, so no key can inflate a balance the contract cannot honour. - Cannot reach money owed to holders. This is the point of the next section.
- Cannot upgrade the contract. There is no proxy and no implementation slot. The code at the address is the code that was deployed.
Why rescue is bounded
rescue exists so that ETH sent in error, or held in excess of what is owed, is not stranded forever. It is capped at freeBalance(), which is the contract balance minus outstanding, the sum of every node balance.
/// @notice Sum of every node balance. The contract must always hold at least this.
uint256 public outstanding;
function freeBalance() public view returns (uint256) {
uint256 bal = address(this).balance;
return bal > outstanding ? bal - outstanding : 0;
}
function rescue(address to, uint256 amount) external onlyOwner nonReentrant {
if (to == address(0)) revert BadInput();
if (amount > freeBalance()) revert ExceedsFree();
(bool ok,) = payable(to).call{value: amount}("");
if (!ok) revert TransferFailed();
emit Rescued(to, amount);
}outstanding is not a figure anyone publishes or attests to. It rises by the exact msg.value of every credit and falls by the exact amount of every withdrawal, inside the same transactions that move the ETH. It cannot be set, cannot be lowered by hand, and there is no admin path that touches it. So crediting a node is a one-way commitment: the same arithmetic that gives you a balance is what stops the owner taking it back.
The property is enforced by a fuzz invariant in the test suite, and the invariant is verified by mutation. Changing rescue’s bound to the full balance makes the suite fail, which is the only evidence that the test was ever testing anything. See Audits.
freeBalance() is the absolute ceiling on what the owner could remove right now, outstanding() is what is owed to node balances, and isSolvent() says whether the contract holds at least that much. All three are public views; see Factory interface.What a compromised server key could do
Two keys sit on an internet-facing machine. Both are deliberately shaped so that losing one costs a bounded, known amount and never costs a holder their balance.
| Key | Worst case if it leaks | What it still cannot do |
|---|---|---|
| Relayer | Unauthorised mints, up to maxPerWallet per wallet, and the gas spent doing it. Every one of them is capped by needing an unused paymentRef, so a mint without a real payment is visible as exactly that | Credit anything, move any balance, change any setting, or mint while paused |
| Distributor | The payout float that wallet is holding at that moment, which is why it is topped up with days of runway rather than months | Take ETH out of the contract. creditBatch only ever moves value inwards, and there is no matching function that moves it back out |
The owner can rotate either key immediately with setRelayer or setDistributor, and can pause minting while doing it. Neither rotation affects a single existing balance.
What the contract defends against
| Attack | Defence |
|---|---|
| Withdrawing against someone else’s node | node.owner != msg.sender reverts NotNodeOwner, checked per node even inside a sweep |
| Reentrancy on payout | The balance is zeroed and outstanding reduced before any transfer, and every value-moving function carries nonReentrant on top of that |
| One payment minting many nodes | paymentRefUsed is set before the node is created; a repeat reverts RefAlreadyUsed |
| A balance that is not backed by ETH | creditBatch reverts ValueMismatch unless msg.value is exactly the sum credited |
| A credit silently truncating on the cast into a balance | Amounts above type(uint128).max revert AmountTooLarge rather than wrapping, which would leave a node credited less than the ETH behind it |
| Purchase money being paid out as rewards | Payment never enters the contract. It is a transfer to a separate wallet, so there is no path from a sale into a balance |
| Owner draining what is owed | rescue is capped at freeBalance() and reverts ExceedsFree |
| A mistyped ownership handover locking the admin surface | Ownership moves only when the new owner calls acceptOwnership themselves |
A withdrawAll sweep exceeding the block gas limit | maxPerWallet cannot be raised past MAX_PER_WALLET_CEILING, and per-node withdraw works regardless |
What it does not defend against
Operator discretion. Nothing in the contract compels Sitowise to credit anything to any node, ever. Credits are decided off chain. During the launch period they are funded by Sitowise and can be reduced or stopped at any time.
A payment that is sent but never minted. The purchase happens outside the contract, so nothing on chain guarantees that a transfer to the payments wallet becomes a node. That step depends on the watcher and the relayer, which means it depends on Sitowise. Withdrawing does not.
Code that has not been audited. No third party has reviewed this. See Audits.
Your own keys. Losing the wallet that owns a node loses the node and its balance. There is no recovery path, because there is no function that could move it.
The complete list of ways this can go wrong for you, including the ones above, is on Risks.