Protocol
Architecture
One contract, five storage maps, no dependencies.
Cairn is a single Solidity file, around 250 lines, compiled with solc 0.8.26, optimizer on at 200 runs and via-ir enabled. It imports only IERC20 and SafeERC20 from OpenZeppelin. Ownership and the reentrancy guard are written by hand, because inheriting them would have cost deploy gas for features that are not used.
Storage layout
A pot packs into four slots plus its name. The struct is ordered so the hot fields sit together:
struct Pot {
address creator; // slot 0, with deadline + 3 flags
uint64 deadline;
bool shared;
bool released;
bool cancelled;
address asset; // slot 1, with contributors + createdAt
uint32 contributors;
uint64 createdAt;
address beneficiary; // slot 2
uint128 target; // slot 3, with raised
uint128 raised;
string name; // slot 4+
}Alongside it there are five mappings:
| Mapping | Purpose |
|---|---|
| _pots | id to Pot. |
| _contributions | id to address to raw amount. The only thing a refund reads. |
| _contributorList | id to the addresses that have contributed, for display. |
| _created | address to the ids it created. |
| _joined | address to the ids it has contributed to. |
Denomination
The contract stores the asset address and nothing else about it. It never calls decimals(), never reads a price, never converts. Every amount inside the contract is a raw token unit. That is why a 6 decimal stablecoin and an 18 decimal stock token behave identically: the contract does not know the difference and does not need to.
Formatting and USD estimates happen in this interface, using the token registry and Blockscout exchange rates. None of that is part of settlement.
Accounting
contribute measures the contract balance before and after the transfer, and credits the difference rather than the requested amount. A token that takes a cut on transfer therefore credits what actually arrived, and the sum of contributions always equals the balance the contract holds for that pot.
uint256 before = token.balanceOf(address(this)); token.safeTransferFrom(msg.sender, address(this), amount); uint256 received = token.balanceOf(address(this)) - before;
Every state change happens before any external transfer, and a hand-written nonReentrant guard sits on contribute, release and refund.
Reading pots
listPots(offset, limit) returns a window of ids and structs, clamped to the number of pots that exist, so a front end never has to guess. potsOf and joinedBy give the two lists a dashboard needs without scanning events.
cast call 0xC73f689442B6D61e34ba36199e23855c61b11A0b \ "listPots(uint256,uint256)" 0 20 \ --rpc-url https://rpc.mainnet.chain.robinhood.com