Sitowise
Contracts

Factory interface

The complete external surface of SitowiseFactory, function by function, with every custom error it can revert with. Nothing here is a summary of a larger interface; this is the whole thing.

Shape of the contract

One contract, no inheritance, no libraries, no proxy. Access control is three address variables and three modifiers rather than a role registry, because there are exactly three privileged callers and a registry would only make them harder to read off the chain.

Storage that governs everything else
address public owner;         // cold key: roles, pause, cap, rescue
address public pendingOwner;  // owner-elect, until it accepts
address public relayer;        // may call mintFor, nothing else
address public distributor;    // may call creditBatch, nothing else

uint256 public maxPerWallet = 25;
uint256 public constant MAX_PER_WALLET_CEILING = 100;
bool    public paused;

uint256 public outstanding;    // sum of every node balance

A node is a struct, not a token. There is no ownerOf, no transferFrom and no approval surface, so a node cannot be sold or moved once minted. See Node model.

struct Node
struct Node {
    address owner;
    uint64  createdAt;
    uint128 balance;         // withdrawable right now
    uint128 totalReceived;   // credited over the node's whole life
    uint128 totalWithdrawn;
}

Minting

mintFor
function mintFor(address to, bytes32 paymentRef)
    external
    onlyRelayer
    returns (uint256 id);

Not payable, because the contract never receives the purchase money. Payment is a plain transfer to the payments wallet, and paymentRef is that transfer’s transaction hash. The relayer sends this call and pays its gas.

DetailValue
Callerrelayer() only, otherwise NotRelayer
ReturnsThe new node id, ++totalNodes, so ids are sequential from 1
EmitsNodeMinted(id, to, paymentRef, createdAt)
RevertsIsPaused, BadInput (zero to or zero paymentRef), RefAlreadyUsed, WalletLimit

paymentRefUsed[paymentRef] is set before anything else, so one payment backs exactly one node. That mapping is the reason the reference in the log is evidence rather than decoration. See Settlement.

Crediting

creditBatch
function creditBatch(uint256[] calldata ids, uint256[] calldata amounts)
    external
    payable
    onlyDistributor;

Payable, and msg.value must equal the sum of amounts exactly. The ETH that backs the balances arrives in the same call that records them, so a balance can never exist unbacked.

DetailValue
Callerdistributor() only, otherwise NotDistributor
EffectAdds to each node’s balance and totalReceived, then to outstanding and totalDistributed
EmitsOne Credited(id, amount, newBalance) per node in the batch
RevertsBadInput (empty batch, mismatched lengths, a zero amount, or an id that was never minted), AmountTooLarge, ValueMismatch

The sum is checked before any storage is touched, so a malformed batch costs the caller memory-only gas instead of one write per node. It is not pausable: pausing stops sales, not payouts.

Withdrawing

withdraw and withdrawAll
function withdraw(uint256 id, address to) external;

function withdrawAll(address to) external returns (uint256 amount);

These are the only two functions an ordinary user ever calls, and they are callable by the node’s owner and by nobody else. There is no amount argument: a withdrawal always moves the node’s whole balance.

withdrawwithdrawAll
Callernode.owner, otherwise NotNodeOwnerAnyone, but it only sweeps the caller’s own nodes
MovesThe whole balance of one nodeThe combined balance of every node the caller owns
ReturnsNothingThe total sent
EmitsWithdrawn(id, to, amount)One Withdrawn per node with a non-zero balance
RevertsNotNodeOwner, BadInput, NothingToWithdraw, TransferFailed, ReentrancyBadInput, NothingToWithdraw (no node held any balance), TransferFailed, Reentrancy

Both zero the balances and update outstanding before sending, and both carry nonReentrant on top of that. withdrawAll skips nodes with a zero balance rather than reverting on them, so a wallet holding a mix of credited and uncredited nodes still sweeps in one transaction. Neither reads paused.

Reads

Every one of these is view and free to call.

FunctionReturns
nodeInfo(uint256 id)(address nodeOwner, uint64 createdAt, uint256 balance, uint256 totalReceived, uint256 totalWithdrawnByNode). Everything about one node in a single call, for the explorer’s Read Contract tab. An unminted id answers with the zero address.
nodesOf(address who)uint256[], every node id that wallet owns, in mint order
nodeCountOf(address who)How many nodes that wallet owns, which is what the cap is checked against
balanceOfOwner(address who)Combined withdrawable balance across every node of a wallet
outstanding()Sum of every node balance. The contract must always hold at least this much
freeBalance()address(this).balance - outstanding, or zero. Contract funds attached to no node, and all the owner can ever rescue
isSolvent()balance >= outstanding. False would mean node balances are not fully backed
paymentRefUsed(bytes32)Whether that payment transaction hash has already minted a node
totalNodes()Nodes ever minted, and the id of the most recent one
totalDistributed()Everything ever credited to node balances
totalWithdrawn()Everything ever withdrawn out of them
owner(), pendingOwner(), relayer(), distributor()The current roles. See Addresses
maxPerWallet(), MAX_PER_WALLET_CEILING()Currently 25, and the constant 100 the owner cannot raise it past
paused()Whether new mints are blocked. It has no effect on withdrawals
There is no price(). The contract never sees the purchase money, so it has no opinion about what a node costs; the price lives off chain and is the figure the watcher checks a payment against.

Admin

Every function here is onlyOwner except acceptOwnership, fund and receive, and every one of them emits an event, so the entire history of the admin surface is readable from logs.

FunctionEffect
setRelayer(address v)Who may mint. Cannot be zero (BadInput). Emits RelayerChanged
setDistributor(address v)Who may credit. Cannot be zero. Emits DistributorChanged
setMaxPerWallet(uint256 v)Nodes per wallet. Must be between 1 and 100 inclusive, else BadInput. Emits MaxPerWalletChanged
setPaused(bool v)Blocks mintFor. Read nowhere else in the contract. Emits PausedChanged
transferOwnership(address v)Records pendingOwner only. Ownership does not move yet. Emits OwnershipOfferStarted
acceptOwnership()Callable by pendingOwner alone, else NotPendingOwner. This is what actually moves ownership. Emits OwnerChanged
rescue(address to, uint256 amount)Sends unattached funds only. Reverts ExceedsFree above freeBalance(). Emits Rescued
fund() payable, and receive()Anyone may top the contract up without attaching the money to a node. Emits Funded. A plain transfer to the contract lands here, which is why it does not buy a node

Every custom error

Fifteen, all of them zero-argument, so a revert is four bytes and a wallet that decodes the ABI can name it exactly. The selector is the first four bytes of the keccak hash of the signature, which is what you will see in raw RPC output when a wallet fails to decode.

ErrorSelectorRaised when
NotOwner()0x30cd7471An admin function was called by anything other than the owner
NotRelayer()0xc64891a5mintFor was called by anything other than the relayer
NotDistributor()0x385296d5creditBatch was called by anything other than the distributor
NotNodeOwner()0xd08a05d5withdraw was sent from a wallet that does not own that node. Usually the wrong account is selected in the wallet
NotPendingOwner()0x1853971cacceptOwnership was called by anyone but the recorded owner-elect
WalletLimit()0x5426a580The buyer already holds maxPerWallet nodes. See Limits
IsPaused()0x1309a563Minting is paused. Withdrawals are never affected
BadInput()0x2bb9acf7A zero address, a zero paymentRef, an empty or mismatched batch, a zero credit amount, an unminted node id, or a cap outside 1 to 100
NothingToWithdraw()0xd0d04f60The balance is already zero. Often means an earlier withdrawAll already swept it
ValueMismatch()0xdd8e4af7msg.value did not equal the sum of the credited amounts
AmountTooLarge()0x06250401A single credit exceeded type(uint128).max, which would truncate silently on the cast into a balance
TransferFailed()0x90b8ec18The destination rejected the ETH. The whole call reverts, so the balance stays where it was. See Destination addresses
ExceedsFree()0x887a9e7arescue asked for more than freeBalance(). This is the holders’ guarantee refusing
RefAlreadyUsed()0x45e84473That payment transaction hash has already minted a node
Reentrancy()0xab143c06A paying function was re-entered. Every ETH-sending path is already checks-effects-interactions, so this is a second line rather than the first

Getting the ABI

The ABI this site uses is generated from the compiled artifact and lives in lib/abi.ts, so it cannot drift from the deployed bytecode. To produce it yourself:

shell
cd contracts
forge build
jq .abi out/SitowiseFactory.sol/SitowiseFactory.json

Or take it from the explorer, where the source is verified. Both should match, and if they do not, trust neither and ask why. The events, with their topic hashes, are on Events.