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.
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 balanceA 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 {
address owner;
uint64 createdAt;
uint128 balance; // withdrawable right now
uint128 totalReceived; // credited over the node's whole life
uint128 totalWithdrawn;
}Minting
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.
| Detail | Value |
|---|---|
| Caller | relayer() only, otherwise NotRelayer |
| Returns | The new node id, ++totalNodes, so ids are sequential from 1 |
| Emits | NodeMinted(id, to, paymentRef, createdAt) |
| Reverts | IsPaused, 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
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.
| Detail | Value |
|---|---|
| Caller | distributor() only, otherwise NotDistributor |
| Effect | Adds to each node’s balance and totalReceived, then to outstanding and totalDistributed |
| Emits | One Credited(id, amount, newBalance) per node in the batch |
| Reverts | BadInput (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
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.
withdraw | withdrawAll | |
|---|---|---|
| Caller | node.owner, otherwise NotNodeOwner | Anyone, but it only sweeps the caller’s own nodes |
| Moves | The whole balance of one node | The combined balance of every node the caller owns |
| Returns | Nothing | The total sent |
| Emits | Withdrawn(id, to, amount) | One Withdrawn per node with a non-zero balance |
| Reverts | NotNodeOwner, BadInput, NothingToWithdraw, TransferFailed, Reentrancy | BadInput, 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.
| Function | Returns |
|---|---|
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 |
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.
| Function | Effect |
|---|---|
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 |
rescue may take. The two-step ownership handover exists so a typo in transferOwnership cannot brick the admin surface. See Security model.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.
| Error | Selector | Raised when |
|---|---|---|
NotOwner() | 0x30cd7471 | An admin function was called by anything other than the owner |
NotRelayer() | 0xc64891a5 | mintFor was called by anything other than the relayer |
NotDistributor() | 0x385296d5 | creditBatch was called by anything other than the distributor |
NotNodeOwner() | 0xd08a05d5 | withdraw was sent from a wallet that does not own that node. Usually the wrong account is selected in the wallet |
NotPendingOwner() | 0x1853971c | acceptOwnership was called by anyone but the recorded owner-elect |
WalletLimit() | 0x5426a580 | The buyer already holds maxPerWallet nodes. See Limits |
IsPaused() | 0x1309a563 | Minting is paused. Withdrawals are never affected |
BadInput() | 0x2bb9acf7 | A 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() | 0xd0d04f60 | The balance is already zero. Often means an earlier withdrawAll already swept it |
ValueMismatch() | 0xdd8e4af7 | msg.value did not equal the sum of the credited amounts |
AmountTooLarge() | 0x06250401 | A single credit exceeded type(uint128).max, which would truncate silently on the cast into a balance |
TransferFailed() | 0x90b8ec18 | The destination rejected the ETH. The whole call reverts, so the balance stays where it was. See Destination addresses |
ExceedsFree() | 0x887a9e7a | rescue asked for more than freeBalance(). This is the holders’ guarantee refusing |
RefAlreadyUsed() | 0x45e84473 | That payment transaction hash has already minted a node |
Reentrancy() | 0xab143c06 | A 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:
cd contracts
forge build
jq .abi out/SitowiseFactory.sol/SitowiseFactory.jsonOr 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.