Sitowise
Contracts

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.

WalletWhere it livesHolds fundsCan do
OwnerCold, off the serverNo, beyond gasRotate the relayer and distributor, pause minting, set the cap, rescue unattached funds, hand over ownership in two steps
PaymentsOffline; the server only watches the addressYes, node purchasesNothing on the contract. It is a recipient address with no privileges of any kind
RelayerOn the serverNo, gas onlymintFor, and nothing else
DistributorOn the serverYes, the payout floatcreditBatch, 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.

There is no signer key
Withdrawals used to be authorised by a server signature. They are not any more. Nothing on the server can authorise a payment to anyone, which removes the sharpest edge the earlier design had. See Settlement.

What the owner can do

  • Rotate the relayer with setRelayer, and the distributor with setDistributor. 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 calls acceptOwnership itself, 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.owner and against nothing else.
  • Cannot pause withdrawals. The pause flag is read by mintFor and nowhere else in the contract. There is no state in which a balance is stuck.
  • Cannot credit a node without paying for it. creditBatch is payable and reverts unless msg.value equals 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.

SitowiseFactory
/// @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.

You can check this at any time without permission. 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.

KeyWorst case if it leaksWhat it still cannot do
RelayerUnauthorised 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 thatCredit anything, move any balance, change any setting, or mint while paused
DistributorThe payout float that wallet is holding at that moment, which is why it is topped up with days of runway rather than monthsTake 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

AttackDefence
Withdrawing against someone else’s nodenode.owner != msg.sender reverts NotNodeOwner, checked per node even inside a sweep
Reentrancy on payoutThe balance is zeroed and outstanding reduced before any transfer, and every value-moving function carries nonReentrant on top of that
One payment minting many nodespaymentRefUsed is set before the node is created; a repeat reverts RefAlreadyUsed
A balance that is not backed by ETHcreditBatch reverts ValueMismatch unless msg.value is exactly the sum credited
A credit silently truncating on the cast into a balanceAmounts 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 rewardsPayment 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 owedrescue is capped at freeBalance() and reverts ExceedsFree
A mistyped ownership handover locking the admin surfaceOwnership moves only when the new owner calls acceptOwnership themselves
A withdrawAll sweep exceeding the block gas limitmaxPerWallet cannot be raised past MAX_PER_WALLET_CEILING, and per-node withdraw works regardless

What it does not defend against

The complete list of ways this can go wrong for you, including the ones above, is on Risks.