Balances
A balance is real ETH, held by the factory contract and attributed to one node id. It is not a promise, a ledger entry or a figure waiting to be approved. Only the node owner can move it, and they move it themselves.
The three numbers
Every node carries three figures, and all three live in the contract. One call returns them together.
| Number | What it is | Moves when | Can decrease |
|---|---|---|---|
balance | ETH held for this node and withdrawable right now | A credit adds to it, a withdrawal empties it | Yes, to zero, and only by the owner withdrawing |
totalReceived | Everything ever credited to the node | A distribution round credits it | No, it only advances |
totalWithdrawn | Everything ever paid out against the node | A withdrawal confirms | No, it only advances |
function nodeInfo(uint256 id) external view returns (
address nodeOwner,
uint64 createdAt,
uint256 balance,
uint256 totalReceived,
uint256 totalWithdrawnByNode
);The relationship between them holds by construction: balance == totalReceived - totalWithdrawn, because the only two operations that exist are a credit that raises the first two and a withdrawal that zeroes the balance while raising the third by the same amount.
Where the ETH actually is
Balances are created by creditBatch(ids, amounts), which the distributor calls. It is a payable call, and msg.value must equal the sum of amounts exactly, otherwise it reverts with ValueMismatch before a single balance is touched.
uint256 sum;
for (uint256 i; i < n; ++i) {
uint256 amt = amounts[i];
if (amt == 0) revert BadInput();
if (amt > type(uint128).max) revert AmountTooLarge();
sum += amt;
}
if (sum != msg.value) revert ValueMismatch();That is the whole point of doing it this way. A balance cannot exist without the ETH behind it, because the transaction that creates the balance is the transaction that delivers the money. There is no window in which the contract owes something it does not hold. A zero amount, a length mismatch, an empty batch or an unknown node id all revert with BadInput, so a malformed round credits nothing rather than crediting part of itself. How rounds are funded is on Distribution.
Balances for a wallet
The chart reads GET /api/nodes/:address for any address you enter. Each bar is one node: the filled part is still on the contract, the outlined part has already been withdrawn.
Who can move a balance
- You, by calling
withdraworwithdrawAllyourself. The contract comparesnode.ownerwithmsg.senderand nothing else, so there is no message, signature or approval anybody else could hold that would let them move it. - The distributor can add to a balance and can decide not to add to it again. It has no path that lowers one and no path that withdraws.
- The contract owner can pause minting, change roles and take free funds, meaning contract ETH attached to no node. Node balances are outside what that role can reach, by the bound described below.
Outstanding, free balance and solvency
outstanding is the sum of every node balance. It goes up by exactly what creditBatch delivers and down by exactly what a withdrawal sends, so it is always the total the contract owes to nodes.
Everything above that is free: freeBalance() returns address(this).balance - outstanding, and rescue(to, amount) reverts with ExceedsFree for anything larger. That single bound is the reason the owner key cannot reach holder money under any sequence of calls. It is enforced by a fuzz invariant, and the invariant is verified by mutation: widening the bound to the full balance makes the test suite fail. The rest of what the owner can and cannot do is on Security model.
isSolvent() returns whether the contract holds at least outstanding. Under normal operation it is always true, because credits arrive with their own funding. It is worth reading anyway: it is the one call that checks the invariant the whole design rests on, and it costs nothing to make.
Units and rounding
Everything is wei. Balances are stored as uint128 and passed to the contract as exact integers, so nothing is rounded anywhere in the accounting path. An amount above type(uint128).max is rejected rather than silently truncated, because a truncated credit would put a balance on a node that the delivered ETH did not match.
Display rounds to six decimal places and truncates rather than rounding up, so the number shown is never more than you can actually withdraw. If a node holds a very small balance, the display can read 0.000000 while the underlying figure is non-zero; the withdrawal path uses the exact figure regardless, and takes all of it.
{
"id": 1,
"chainNodeId": "1",
"balanceWei": "0", // node.balance, withdrawable now
"cumulativeWei": "0", // node.totalReceived, ever credited
"withdrawnWei": "0" // node.totalWithdrawn, ever paid out
}Wei figures are strings in JSON on purpose. They routinely exceed what a double can hold exactly, and a client that parses them as numbers will silently lose precision. Parse them with a big-integer type. The full response, including the mint transaction, is on GET /api/nodes/:address.
Verifying a balance
All three numbers are on chain, so none of them has to be taken on trust from this website or the API.
# owner, createdAt, balance, totalReceived, totalWithdrawn for one node
cast call $FACTORY "nodeInfo(uint256)(address,uint64,uint256,uint256,uint256)" $NODE_ID
# combined withdrawable balance across every node of a wallet
cast call $FACTORY "balanceOfOwner(address)(uint256)" $YOUR_ADDRESS
# what the contract owes to nodes, what is unattached, and whether it is covered
cast call $FACTORY "outstanding()(uint256)"
cast call $FACTORY "freeBalance()(uint256)"
cast call $FACTORY "isSolvent()(bool)"The same reads are available in the Read Contract tab of the explorer, which is why the contract is verified there. Addresses are on Addresses and every function is listed on Factory interface.
Several nodes at once
balanceOfOwner(who) adds up the balances of every node a wallet owns, which is the total the dashboard shows. Each node still holds its own balance underneath, and withdrawAll(to) empties all of them into one destination in a single transaction. See Withdrawing.