Security
COLLAB is meant to hold real treasuries without anyone in charge of it. This document says what the system guarantees, what you still have to trust, what was done to check it, and what is not done yet.
Status
- The contracts have a Foundry test suite with unit, fuzz and invariant tests (see the release notes for current counts), including end-to-end runs of every proposal type against the real CIPNFT and GL1F contracts compiled from their published sources.
- The interface has browser end-to-end tests (
tools/e2e) that create DAOs, submit, vote and execute proposals, launch tokens, trade and use sealed data through an injected wallet against a local chain. - The contracts went through an internal security review. It was AI-assisted and done separately from the implementation work, with a proof-of-concept test for every finding. All findings are resolved; the list is below, and the proof-of-concept tests now assert the fixed behaviour.
- Release 2.0 had two more reviews of token and share management, a review and a verification of its fixes. Both were separate AI-assisted reviews, done apart from the implementation work; their findings and resolutions are listed below.
- Release 2.0 rebuilt the contracts on audited libraries (OpenZeppelin Contracts and Contracts
Upgradeable 5.1, Solady's SSTORE2; see
THIRD_PARTY_NOTICES.md) and added invariant tests for every token and share rule (test/invariants/, listed below). Each invariant was also run against deliberately broken copies of the contracts and caught its bug. The invariants found one more issue, V2-01, which is fixed. - The web interface and the site tooling had reviews of their own in releases 1.0, 2.0 and 2.1, also AI-assisted and separate from the implementation work (see Interface hardening below).
- Release 2.1 answers an outside review of the published 2.0.0 archive. Its author read the source and the documentation but could not run the Solidity suite; it was not an audit. Its items, OR-1 to OR-9, and how 2.1 resolves them are listed below.
- The 2.1 contract changes then had an internal review of their own before release, AI-assisted and separate from the implementation work, with a proof-of-concept test for every finding, run against the real GL1F and CIPNFT bytecode. It found no High issues. Its findings, RV-1 to RV-12, are resolved; the list is below, and its proof-of-concept tests now assert the fixed behaviour.
- A separate verification of those fixes followed, also AI-assisted and done apart from the implementation work, with a test for every verdict. It found no High or Medium issues and confirmed the fixes, except that RV-3 was incomplete. That gap and three smaller details, V21-1 to V21-4, are resolved or documented as listed below, and its tests now assert the resolution.
- Release 2.2 keeps each DAO's logo and description in its profile's own storage, rewritten in place, makes DAO titles permanent and gives every DAO a permanent, unique slug. Two weaknesses found while building these changes, unbounded share names and the market lens's copying of token returndata, are fixed (H22-1, H22-2). The contract changes then had an internal review of their own, AI-assisted and done apart from the implementation work, with a proof-of-concept test for every finding. It found no High or Medium issues; its findings, RV22-1 to RV22-8, are documented or handled by the interface as listed below, and the reviewed contracts are the released ones. The 2.2 server kit had a separate review of its own (SV22-1 to SV22-6, also below), and so did the 2.2 web and prerender changes (WB22-1 to WB22-5, under Interface hardening); each was followed by a verification of its fixes. A rehearsal of the operator's whole path, from the archive through the installer, /admin, deploy and publish to using the site and upgrading, passed in a sandbox.
- There has been no external audit. Start with small amounts, short periods and a conservative configuration, and treat the first DAOs as a public test.
What the contracts guarantee
No owner, no admin, no upgrades in COLLAB's own contracts. The factory and every DAO contract are immutable. DAO contracts are minimal clones initialized in the same transaction that creates them; implementation contracts lock their own initializer (OpenZeppelin
Initializable). The outside contracts a DAO chooses to work with (GL1F collections, CIPNFT contracts) have admin powers of their own; see What you still trust.Only passed proposals move the treasury.
DAOTreasury.executeBatchaccepts calls only from the DAO's governor, and the governor executes only the exact actions whose hash was stored at submission, only after the vote passed and the timelock ended, only within the execution window, and only once. All actions of a proposal succeed or the whole execution reverts.Every governed contract checks
msg.sender == treasury. Parameters, policy, profile, constitution, membership, vault and tokens issued by the DAO cannot be changed any other way.Call policy. Each proposal kind may call only the (target, function) pairs its policy allows; value transfers and token approvals are allowed only where the policy says so, and approvals only to the spenders approved for that kind: the protocol contract it uses, and for GL1F proposals the market of each collection the DAO lists, which the DAO then trusts with the approvals it gives. Revocations (
approve(address(0), …),setApprovalForAll(x, false)) name no one to trust and need only a rule that reaches the token. Calls to the DAO's own governor, policy or shares, and raw transactions, are always critical, so a stricter vote is needed. Changing the policy is itself a critical proposal. Handing over or giving up a contract that belongs to no proposal type is always critical. A rule applies in this order: a rule set for the exact contract (an explicit "deny" included), then the rule set for the contract's integration role, then the proposal type's wildcard rule where it reaches. Actions of any type but raw transactions can never target the rule keys (addresses up to0x105).Integrations are each DAO's choice. A DAO lists the GL1F collections (registry, Model NFT, market, runtime) and CIPNFT contracts it works with: chosen at creation, then added or removed only by its own critical PARAMS votes, at most eight of each. There is no global list and no admin; the deployment's lists are only what the interface offers first. A contract is listed once, in one role, only if it has code and is none of the DAO's own contracts (a token it launched included), none of the protocol contracts its factory knows (the factory itself, its policy builder, wL1, the keyring, the text store, the launcher, the distributor, the DeSciFi factory and router, the lock factory), no DeSciFi pool or lock box and no approved spender, and a collection's four contracts must name each other (read with a gas cap). The rules of a role (on
roleTarget(role)) apply to every contract listed in it, a DAO's own systems included. A proposal's later actions may use a contract its earlier action lists, so one proposal can list a CIPNFT contract, grant the key vault its two calls there and register the DAO's key; if a listing fails at execution, the whole proposal reverts. Removing an integration leaves the DAO's assets where they are, and they then belong to no proposal type. Models of a removed GL1F collection follow the treasury's rules, and a treasury proposal can revoke both kinds of approval the DAO gave there, for one model (approve(address(0), id)) and for all of them (setApprovalForAll(market, false)), which also stops a listing on the removed market from selling; cancelling the listing itself then takes a raw transaction, so cancel listings before removing a collection. No rule reaches assets on a removed CIPNFT contract (CIPNFT transfers are disabled): close the DAO's listings and cancel its offers there first. A listing left open cannot complete a sale, since delivery needs the key vault, which refuses unlisted contracts; an offer left there is refunded once it expires (refundExpiredOffer, open to anyone).Treasury value follows the treasury's rules. In a DAO whose treasury spends are critical, every action that can give up treasury value is critical too, whatever the proposal's type. That is an action that
- carries L1;
- grants an allowance (of anything but LP tokens; on a listed GL1F Model NFT any token, id 0 included) or an operator approval;
- trades or sells at a price the proposal sets: swaps, adding liquidity, launch pools, token launches, listings on any GL1F market the DAO lists, opening a listing on any CIPNFT contract it lists, and deliveries and purchase completions there made directly rather than through the key vault's checked steps;
- sets what a model's use costs or who may use it, on any GL1F registry the DAO lists, whoever is
paid: inference pricing and revenue (
updateModelSettings), access plans (createAccessPlan,setAccessPlan) and perpetual owner access keys (setOwnerAccessKey); - sends tokens, NFTs, LP, fees, a lock box, a token's ownership or newly minted tokens to anyone but the treasury (the burn address included);
- locks LP for good: a lock box, new or extended, that opens more than ten years after the
proposal's submission (
DAOPolicy.LOCK_HORIZON) or never; - gives up what a token is worth or can do: burning tokens the treasury holds, or renouncing a token's ownership or its minting.
Recipients and dates are read at their exact place in the call, and a call too short to hold one counts against the treasury. Removing liquidity to the treasury, collecting fees to it, and lock boxes that the treasury owns, that pay it and that open within ten years stay standard.
Votes are read at two moments. Voting power is read at the block before submission and at
holdingBlocksblocks before that, and each account counts the lower of its two readings. Only those two moments matter: units that leave an account and come back between them count in full, and units that arrive between them count only as far as the earlier reading shows, so shares bought, borrowed or delegated after a proposal exists, or within the window before it, add nothing. DAOs with transferable shares start with 7,200 blocks (about 28 hours), and transfers cannot be switched on without a holding period. A direct vote takes the voter's units out of whichever delegate held them, separately at each reading, so tallies do not depend on the order of votes. Rules are copied into each proposal at submission, so a rules change cannot alter a vote in progress.Critical rules are never laxer than the standard ones. In every parameter: voting period, timelock, quorum, threshold, veto threshold (lower or equal) and minimum Yes, checked at creation and on every change. Each rule set may require a minimum Yes (
minYesBps, at most 50%): Yes must reach that share of all voting units at the snapshot. Abstain counts towards quorum but not towards the threshold, so without a minimum a small Yes passes when most voters abstain.A freeze vote locks what it decides. With
FLAG_FREEZE_LOCK, on in every preset, a freeze proposal (a membership proposal that freezes accounts) locks its targets' share transfers from the start of its vote through the last second at which it can be executed. Several freeze votes on one account may run at once. Each holds its own lock claim, released only by that proposal (by anyone, once its vote has failed, it has been executed or its window has ended:releaseLocks) or when its deadline passes, so one vote ending never frees an account that another vote still locks. WithFLAG_FREEZE_BOND, also on in every preset, a freeze that ends Rejected or NoQuorum forfeits its deposit. A freeze naming an excluded account is refused at submission. A raw transaction that freezes an account is not a freeze proposal: it is always critical, but places no lock and carries no bond.Frozen members cannot vote, propose, delegate, move shares or receive member payouts, and their shares leave the totals from the next block. Proposals that were already open keep their snapshot. Freezing and unfreezing need critical membership proposals.
Excluded accounts (pools, lock boxes and other infrastructure, chosen by a critical vote) hold no voting units, are not members, and can be neither frozen nor locked by a freeze vote.
Modules (stewards completing CIPNFT trades, representatives' operator calls) can call one exact (target, function) pair each, with no value, and only after a vote granted it. The grant covers any arguments: for a token's transfer function, any amount to anyone. They can never call the treasury, the governor, or the DAO's own policy, shares, profile or key vault. The key vault works only with the CIPNFT contracts the policy lists, with the DAO's one key. Activating a key epoch checks the key on every one of them: an epoch with a post-quantum key needs it registered on each, and one without is refused while any of them still holds one for the DAO (a contract that cannot hold post-quantum keys counts as holding none). The key vault approves and completes a purchase only when the offer is sealed to the active epoch's key: its classic key, or its post-quantum key when it has one. A proposal that calls CIPNFT's
finalizeOfferordeliverOfferitself skips the vault's checks, so those two calls are critical in every DAO.Contracts belong to a proposal type. A call into another type's contracts is as critical as that type makes it. Listed CIPNFT contracts and the key vault are CIPNFT's, the four contracts of each listed GL1F collection GL1F's. Wildcard rules reach the proposal type's own contracts; contracts of no type only for treasury spends, launches and token administration, and for approvals, which always name an approved spender (or revoke one). Irreversible actions (reveals, declassification, burning or erasing sealed data, re-sealing, removing view keys, deleting models, opening a GL1F Crypto model's licence, changing how its internals are shown) and operator appointments are critical by default. Selling a model's use is not on that list: access plans, like other sales, are critical where treasury spends are (see above), although on GL1F Crypto a paid subscription can never be revoked and keeps the model from being deleted until it ends. In a DAO whose treasury spends are standard, a standard vote can already spend.
Representatives act only within listed permissions and terms (the description, the logo, the links, announcements and, granted by a critical vote, operator calls), lose them when frozen, and can be removed by any profile proposal. No permission reaches the title.
A DAO's title and slug never change. Both are set when the DAO is created. Slugs are unique (
SlugTaken): 3 to 48 characters ofa-z,0-9and-, starting with a letter, so a slug never reads as an address, with no--and no-at the end. The factory resolves them (daoBySlug,slugOf), so a link by slug leads to the same DAO on every copy of the site.Logo and description live in the DAO's profile itself, with their keccak256 hash and length beside them. An edit overwrites them in place: it reuses the same storage and clears the old tail, so the profile holds exactly the latest version and editing never grows its state. A logo is empty, or at most 512 KiB whose signature, header fields and closing chunk are those of a 128 × 128 PNG (
InvalidPng,WrongLogoSize; nothing else in it is checked, see the known limitations). A description is at most 256 KiB of well-formed UTF-8 without C0 control characters other than tab and line feed (DEL and the C1 controls are not refused). Both are read in chunks of at most 16 KiB, about 1.2 million gas each, so no read comes near an RPC's gas cap, and the interface shows them only when the bytes it read match the stored hash, a logo only as an image.On-chain text (constitutions, announcements) is content-addressed; every reference stores the hash and the interface refuses to show content that does not match.
DeSciFi keeps swap fees out of the reserves (so they cannot be skimmed or used to move the price), locks the first liquidity, checks the constant-product invariant on effective reserves for launch pools, and lets the router use registered pools only. Pools can only be created for tokens that already exist, and each LP holder collects only its own fees.
Readable text. Every string that apps decode and display is well-formed UTF-8: DAO titles and descriptions, announcement headings and links, proposal titles, share and token names and symbols, and token logo types. Titles, descriptions, names and symbols hold no C0 control characters other than tab and line feed, and DAO titles and announcement headings none at all and no DEL either. DEL elsewhere and the C1 controls (U+0080 to U+009F) are not refused; the interface strips them before display. Slugs hold only lowercase letters, digits and hyphens. The market lens reports a token whose name or symbol is not well-formed as unreadable instead of passing it on, and returns such a model or CIPNFT title as empty. One malformed string cannot break a page that lists it.
Sealed purchases complete only against the exact delivery a key steward verified. Envelopes a steward posted cannot be overwritten by another active steward unless the recipient changed keys.
Bounds. Every governance parameter has limits, so a single vote cannot price every future proposal out of reach. The minimum deposit is at most 1,000,000 L1, the proposal threshold is at most the voting units in existence, and a minimum Yes at most half of all voting units, at creation and on later changes. The lens reads DAOs a page at a time and every field of an entry is bounded, so a growing list of DAOs never pushes a page past an RPC's gas cap at the stated page sizes: at most 100 per
getDAOspage (200 entries with the longest fields allowed take 23.6 million gas of a 25 million cap) and at most 200 perdirectorypage (13.1 million). The interface reads 60 at a time and the prerender 50.
What you still trust
- GenesisL1 itself: validators, finality and the EVM.
- The DAO's members. A majority that meets the configured quorum and thresholds can do anything the DAO can do, including raw transactions. Choose quorum, thresholds, the veto threshold, the timelock and critical rules with that in mind; the timelock is the time members have to react.
- The outside contracts a DAO lists. COLLAB's contracts have no admin, but the GL1F and CIPNFT
contracts a DAO works with have their own. The original GL1F registry's owner can delete models
(
adminBurnAndDelete), replace the Model NFT contract (setModelNFT, repeatable) and sets fees, licences and terms. The owner of either GL1F market sets the listing fee and can cancel any listing, the DAO's included. GL1F Crypto has no admin delete, wires its Model NFT contract once, cannot withdraw fees (anyone can burn them) and moves a model's licence only to a later or more open one. CIPNFT's admin sets fees, offer rules and terms, withdraws mint fees (never escrowed offers) and can hand the admin role on. A DAO that lists contracts of its own trusts whoever controls them. The interface shows the live holder of each outside admin key. What the outside contracts let a model's owner undo differs too: on GL1F Crypto a paid subscription can never be revoked and blocks deleting the model (burnAndDelete) until it ends, while the original GL1F registry lets the owner zero any access key, one call per key. - Key stewards for sealed data. Stewards hold the DAO's CIPNFT private key and can read any asset the DAO owns. A reveal to members cannot be taken back. Elect stewards accordingly and rotate the key (a new key epoch) when stewards change.
- The RPC endpoint for what the interface displays. You can choose your own endpoint in Settings. A malicious one could show false data, but it cannot sign for you, and the interface does not take its word where it matters: before a transaction goes to a DAO's contracts or pays a DAO's treasury, the DAO's addresses are read again through your wallet's own network, and nothing is sent if they differ. Before data keys are sealed to stewards or members, their encryption keys are read again the same way, and a proposal page checks the actions it shows against the commitment your wallet's network stores. The endpoint's chain is checked when you save it and every few minutes after. Your wallet shows the actual transaction, and every proposal page lets you simulate execution. Use an endpoint you trust or run a node.
- The website you load. Host the release yourself or compare the files you are served with the
release
SHA256SUMS. The site loads no third-party code, apart from Google's analytics script on a site that turns analytics on, and only after you accept it. It talks to the RPC endpoints, to the price API for currency display (CoinGecko by default; read only, never used for amounts you sign) and, only if a deployment turns on Google Analytics and you accept it, to Google. A browser that sends Global Privacy Control counts as a decline. On a site set up withserver/install.sh, its operator publishesconfig.js, and with it the contract addresses the pages use, from the admin page ("The server kit" below).
Known limitations
- Execution is permissionless by design. Anyone may trigger a passed proposal during its window, choosing the moment and the order of passed proposals that conflict.
- Actions that depend on prices or deadlines (swaps, liquidity) are fixed when the proposal is submitted and executed later. A swap can be executed exactly at its minimum output, so set the minimum to what the DAO actually accepts. Expect such proposals to fail if the market moved too far. Where treasury spends are critical, these proposals use the critical rules.
- Where treasury spends are critical, a lock box counts as giving up its LP when it opens more than
ten years after the proposal is submitted (
DAOPolicy.LOCK_HORIZON), or never. Locks within ten years stay standard; the horizon is measured at submission, so a lock that a long vote brings within the decade is still judged by its submission date. - The stance recognises the calls of the protocol's default rules, the protocol's own contracts and
the contracts a DAO lists, by role. A rule that a PARAMS vote adds later, for another call that moves
value or for a contract the DAO has not listed, is standard unless the rule itself is marked
critical (
RULE_CRITICAL), so mark such rules critical. - The listing checks cannot tell a real GL1F or CIPNFT contract from a look-alike: a listed contract gets its role's rules, and a listed collection's market is trusted with the approvals the DAO gives it. They know the DAO's own contracts, every protocol contract its factory was deployed with, the DeSciFi pools and lock boxes, the tokens the DAO launched and every approved spender. They do not know the lenses, which are deployed after the factory; a lens holds nothing and has only views, so listing one could only block key rotation, like any listed contract that does not answer (below). List only contracts you trust.
- Removing a CIPNFT contract with the interface's Integrations builder also takes the key vault's
module grants there back, in the same proposal. A removal made any other way (a raw transaction,
say) leaves those grants in the treasury until a vote removes them; the vault refuses unlisted
contracts, so they are not used. A proposal that lists a
CIPNFT contract should register the DAO's active key there in the same proposal (the interface
does). Until a vote registers it, the next key epoch cannot be activated, and a contract that still
holds an older key of the DAO (one listed again after a rotation) takes no purchases: the vault
approves and completes only offers sealed to the active key (
OfferKeyNotActive). - A listed CIPNFT contract that does not answer for the DAO's key (
encryptionPubKeyreverts or runs out of gas) blocks key rotation until it is unlisted, and so does one withoutpqcPubKeyfor an epoch with a post-quantum key (for an epoch without one, it simply holds no post-quantum key). One PARAMS proposal can unlist such a contract and activate the next key epoch. - A share lock placed by a freeze vote before an account was excluded lasts until that proposal releases it, and halts a pool for that long. Exclude pools and other infrastructure before trading starts.
- A GL1F market listing outlives the model leaving its seller: if the model comes back, the old
listing applies again while the market is still approved. Approve the market for one model at a time
rather than for all, and cancel a listing before moving its model. Revoking the market's approvals
(
approve(address(0), id), andsetApprovalForAll(market, false)if it was approved for all) also stops an old listing from selling. - Handing over or giving up a contract of no proposal type is recognised by the Ownable functions
(
transferOwnership,renounceOwnership). No default rule allows other ways of giving up control, such assetOwneror role grants; a DAO that adds a rule for one should mark it critical. - The freeze lock (
FREEZE_LOCKflag) locks a target's shares against transfer from the moment a freeze proposal's voting starts through the last second it can be executed. Each proposal holds its own claim, which only that proposal releases (or its deadline ends), also when the account is frozen and unfrozen in between, and the account is free only once no claim is left. Freeze votes on one account can overlap, so a series of freeze proposals keeps a member's shares locked for as long as the series lasts, and anyone can fund one. One proposal can also name up to 32 accounts (one freeze action each) and lock them all for one deposit. With the freeze bond each one that fails costs its deposit: in a DAO with transferable shares, set a minimum deposit that makes such series expensive (the interface warns). A freeze that passes but cannot be executed keeps its lock until its window ends and gets its deposit back, since the bond covers votes that fail, not execution. Anyone can also complete a partly funded freeze proposal's deposit and so start its vote at a moment of their choosing; with the bond, the deposits already placed are then at risk if the vote fails. A proposal keeps the flags it was submitted with, so a freeze proposal submitted before the DAO turned on the lock or the bond has neither. The lock only matters when shares are transferable; leave the flag off if that trade-off is not wanted. - For proposals already open when a member is frozen, the frozen units stay in that proposal's quorum base, and a delegate the member chose earlier can still vote with them there.
- A token allowance left behind by an earlier proposal could be used by later proposals of a type that may call the same spender. The interface resets every allowance it grants to zero in the same proposal; raw transactions should do the same.
- Units that change hands inside the holding window give neither the seller nor the buyer any weight on proposals whose window covers the trade (each counts the lower of its two readings). A delegate that held such units at both readings, for example when seller and buyer delegate to the same member, still counts them, never above its lower reading. When the seller or the buyer then votes directly, its units leave each of the delegate's readings separately, so the moved units can end up counting for nobody; either can also take them out of the delegate's vote by voting with weight 0.
- Within the bounds, a DAO can still vote itself into an impractical configuration, for example with very long voting periods, a very high quorum, or a minimum Yes above the share of voting units its members actually use. There is no admin to undo that.
- DeSciFi does not support fee-on-transfer or rebasing tokens.
- Encryption keys for sealed data are derived from wallet signatures. Anyone who can make your wallet sign the same COLLAB key message gets your key; the interface explains what is being signed. The message names the chain and the keyring but not the site's address, on purpose, so every copy of COLLAB on the same deployment derives the same key. Wallets that do not sign deterministically cannot recreate it.
- Key stewards can read every asset the DAO holds. Removing a steward does not take back what it saw, so create a new DAO key afterwards. Copies made for accounts without a post-quantum key use classic encryption only and stay on-chain permanently.
- On CIPNFT, a buyer can cancel an offer after the data key was delivered and keep the data. That is CIPNFT's design, and the interface warns before every DAO sale.
- Slugs are first come, first served. Anyone can create a DAO under any free slug, including one
that imitates another group's name or takes a name someone else wanted, and a creation waiting to
be mined shows its slug, so someone watching pending transactions can take that slug first. The
original creation then fails with
SlugTaken, and on GenesisL1 a failed transaction still pays half its gas limit (see the gas limitation below). Taking a slug costs the front-runner about 5.4 million gas (0.27 L1 at a 50 gwei base fee), while the creation it beats pays half the explicit limit that large creations need: about 315 million gas (15.8 L1) for the largest creation, whose limit is about 631 million, and about 35.6 million (1.8 L1) for a 64 KiB logo with a 16 KiB description, a limit of about 71 million. The interface's fee review states what a failure costs. There is no commit-reveal step in 2.2. A slug reservation by commit and reveal, and a creation in two steps (the DAO first, then its logo and description uploaded by its creator as a representative, so that a lost slug costs only the small first step), are future work. Slugs use lowercase ASCII only, but look-alikes remain (land1,0ando,rnandm). A slug or a title says nothing about who runs a DAO: check its address. A site's admin can hide squatted or spam DAOs from that site's listings (hiddenDaosin its configuration); that changes nothing on chain or on other copies of the site. - A DAO's title and slug can never change, so a DAO that needs a new name needs a new DAO.
- A title holds no C0 control characters and no DEL, but it may hold C1 controls, bidi controls,
invisible characters and noncharacters, and it can never be corrected. The interface removes such
characters from the names it shows and isolates the direction of every on-chain string
(
dir="auto"), so a title cannot reorder the text around it. - Only a logo's header fields and closing chunk are checked: the header chunk's checksum, the image data and any other chunks are not, so a logo may be any byte string with that start and end: malformed (browsers then show a broken image), animated (APNG), or no image at all. The interface shows logos only as PNG images, never as SVG or HTML.
- An edit overwrites the logo or the description, but the transactions that set earlier versions
stay in the chain's history, so published content cannot be truly withdrawn. An edit made by a
proposal puts the bytes there three times (the submission's calldata, its
ProposalSubmittedevent and the execution's calldata), a representative's direct edit once, so large edits are better made by a representative. A later release could have proposals approve the hash of new content, uploaded once afterwards. MarketLens.aggregateis a plain multicall: it returns whatever its targets return, so a hostile target can make a batch it is part of fail or cost as much as the batch's gas allows. The lens's token, pool and NFT views are gas-capped and bounded (H22-2); the interface reads untrusted tokens through them, never in a sharedaggregatebatch.- Large logos and descriptions are expensive, and wallets cannot estimate them. Writing a new
32-byte word costs about 23,000 gas, so a creation with a 64 KiB logo and a 16 KiB description
uses about 64 million gas. Clearing a word costs about 5,000 gas and London refunds at most a
fifth of a transaction's gas afterwards, so removing or shrinking a large logo is not cheap
either: removing a 512 KiB logo costs about 66 million gas, and needs a limit of about 83 million.
Nodes cap
eth_estimateGasandeth_callat their json-rpc gas cap (25,000,000 by default), so a creation or an executed proposal writing more than about 20 KB of new logo and description, or removing more than about 150 KiB, needs an explicit gas limit, which the interface sets from a measured formula with a margin. GenesisL1's fee market charges every transaction at least half its gas limit, even when it fails (it charges the larger of the gas used and half the limit, as its receipts show), so a large transaction that fails pays half its explicit limit: a creation that loses its slug (above), or the second of two executions of one large proposal in the same block. A transaction that succeeds pays the gas it used when that is at least half its limit, as it is with the interface's limits. The largest creation (a 512 KiB logo and a 256 KiB description) uses about 570 million gas, and up to 620 million for the description text that is slowest to check, more than half of GenesisL1's block gas limit of one billion, so a block holds at most one of them. A node whose operator caps gas per transaction below that refuses such transactions.
Interface hardening
- Strict Content-Security-Policy:
default-src 'none', no inline scripts or styles, no framing,base-uriandform-actionnone. Network access is limited to the configured RPC endpoints and the price API, plus other https origins for reads where a deployment allows custom endpoints. Google's exact origins are added only when a deployment configures analytics. The same policy is served by every supported host (nginx, Caddy, Netlify and Cloudflare Pages) and checked bydeploy/sync_csp.pyanddeploy/check_site.py. - The DOM is built node by node; on-chain text is never parsed as HTML. Markdown is parsed by
markdown-it 14.1.0 with raw HTML and images turned off, and COLLAB's own code renders its tokens;
links are restricted to http(s) and mailto, and long delimiter runs are escaped so hostile input
renders in linear time. Images from the chain are shown only as hash-verified data URLs inside
<img>. - Proposals are decoded into plain language, checked against their on-chain hash and can be simulated exactly as execution would run them. The plain-language review never summarises an allocation or a rule away: every recipient and every rule is listed. Transactions are preflighted before the wallet opens and carry the chain id.
- Prerendered pages and page descriptions are written from on-chain text through escaping and a linear-time Markdown-to-text conversion, identical in the prerender and in the browser.
- Secrets (encryption keys) stay in memory and are dropped on account change.
Interface review, release 1.0
The browser interface had its own review, separate from the contracts: CSP and headers, DOM sinks, the Markdown renderer, on-chain images, sealed-data downloads and cryptography, wallet flows, proposal integrity, RPC trust, local storage and the vendored libraries. The vendored ethers.js, libsodium.js and mlkem-wasm builds were checked byte for byte against their published releases. There were no critical or high findings.
| ID | Severity | Finding | Resolution |
|---|---|---|---|
| F-1 | Medium | The Markdown renderer had no input bound and parsed inline syntax in quadratic time, so a very long description, announcement or constitution could freeze every visitor's tab. | The inline parser now scans each line once with bounded patterns, verified against the old output on 8,000 random inputs. Very long lines are shown as plain text and quote nesting is capped. A 1 MiB adversarial document renders in about two seconds on a small machine, where it used to take minutes. |
| F-2 | Info | DeSciFi used its own, narrower filter for invisible and control characters in token names. | It uses the shared filter, so a token reads the same everywhere. |
Interface review, release 2.0
A second review, separate AI-assisted work done apart from the implementation, covered the redesigned interface and the site tooling: the prerendered pages, the router and preset links, settings and custom RPC endpoints, the price display, the cookie notice, the deploy page, token and share flows, sealed data and the server configurations. The vendored ethers.js, markdown-it, libsodium.js and mlkem-wasm files were checked byte for byte against their published releases.
| ID | Severity | Finding | Resolution |
|---|---|---|---|
| FE2-H1 | High | The review of a token launch listed only the first 20 recipients and summarised the rest without amounts, so a large allocation could go unseen. Policy changes were cut after 12 rules. | Every recipient and every rule is listed, the largest allocation first, with an allocation summary that must add up to the supply and a warning when one account outside the treasury receives a quarter of it or more. |
| FE2-M1 | Medium | The servers' trailing-slash redirect reflected the decoded path: header injection with nginx, an open redirect with Caddy. | Only paths of plain URL characters are redirected; deploy/check_site.py sends the attack strings. |
| FE2-M2 | Medium | Turning Markdown into plain text for page descriptions took quadratic time, in the prerender and in the browser, so one hostile DAO could stall both. | The input is cut before parsing and every pattern is linear, identically in Python and JavaScript; hostile input of the maximum size is checked against both. |
| FE2-L1 | Low | A custom RPC endpoint could choose the contract addresses of DAO transactions and the keys data was sealed to. | Both are read again through the wallet's network before use, and a proposal's actions are checked against its commitment there; the endpoint's chain is re-checked periodically; the deploy page's check prefers the wallet's network. |
| FE2-L2 | Low | Tokens preset in a swap or liquidity link carried no warning that they are unverified. | Such tokens show the same notice as a pasted address and need a confirmation before any approval. |
Hardening from the same review: transactions carry the chain id; saved deploy progress is checked against the release's code before it is used; Global Privacy Control counts as a decline; protocol-relative links and string event handlers are refused by the DOM builder; the analytics script source is Google's gtag path rather than any Tag Manager container; origins written into the CSP are validated; the prerender's default lock is private to its user; and a failure while building the member network no longer stops the other prerendered files.
Interface changes, release 2.1
- Integrations a deployment lists come from its
config.js, checked strictly on load. Any other collection or contract a DAO lists is called "Unlisted GL1F collection" or "Unlisted CIPNFT contract", never by its own name, and flagged in the review of every proposal that touches it. - Before a transaction to an unlisted integration, the wallet's own network must confirm that the DAO's policy gives that address that role, so a dishonest RPC endpoint can't point a transaction at a contract of its choosing. Listed integrations and the DAO's own contracts are checked as in 2.0.
- Adding your own collection or contract: the builders and the creation wizard check the cross-references live and warn that the deployment doesn't list it. The policy's own check at execution decides.
- Outside admin powers: the settings page reads the live holder of each outside admin key
(
owner(),admin(), pending owners included) and lists what that key can and can't do to the DAO's assets. For a contract whose code COLLAB doesn't know, it lists every such holder it finds. - The deploy page recognises a CIPNFT contract by its interface (
MAX_CIPHERTEXT_BYTES(),tosVersionCurrent()), never by its symbol, and checks every GL1F collection's cross-references before it writes the deployment record. - Exact simulation and fresh lists: see OR-5 and OR-8 below.
Interface review, release 2.1
A third review of the interface, again separate AI-assisted work done apart from the implementation, covered the 2.1 changes: where transaction addresses come from, how unlisted integrations are named and flagged, the new and changed builders, the creation wizard, the deploy page, config.js validation and the simulation labels. It found no High issues. The same reviewer then checked the fixes.
| ID | Severity | Finding | Resolution |
|---|---|---|---|
| FE21-1 | Medium | The review of a proposal flagged an unlisted integration only when it was a call's target. A GL1F proposal can approve the treasury's Model NFTs to any approved GL1F spender, the market of a collection the DAO added itself included, and such an approval showed the spender as a bare address without a warning. Some market and registry calls on unlisted contracts weren't flagged either. | An approval names its spender and warns when it isn't the model's own market: another collection's market (flagged when unlisted), a name from the address book, or an address COLLAB doesn't know. A collection-wide approval always warns that the spender could move every Model NFT the treasury holds there. Every GL1F and CIPNFT call on an unlisted contract is flagged. |
| FE21-2 | Medium | Adding a CIPNFT contract composed the registration of the DAO key as the page's RPC endpoint reported it, so a dishonest endpoint could get an attacker's key into the proposal. Minting on a contract whose registered key wasn't the active one only warned. | The builder registers the key of the vault's active key epoch, read through the wallet's network; a post-quantum key is used only when its hash matches the epoch's. Minting stops unless the contract holds the active key, re-read through the wallet. The review of a proposal compares every key registration with the active epoch and warns when they differ. |
| FE21-3 | Low | Listed integrations could share a display name or use look-alike characters, so two different contracts could read the same in a review. | One rule for integration names in config.js, the config writer and the deploy page: Latin or CJK letters in NFKC form, digits and common punctuation, and no two integrations with the same name. |
| FE21-4 | Info | The settings page named a pending collection addition after a listed collection when only its registry matched. | It uses a listed name only when all four addresses match. |
The reviewer's check of these fixes confirmed all four and found two more, both fixed:
| ID | Severity | Finding | Resolution |
|---|---|---|---|
| FE21-5 | Low | Look-alikes within the Latin script still passed the name rule ("GLIF" beside "GL1F", "CıPNFT" beside "CIPNFT"), and on GenesisL1 the deploy page only noted a look-alike of a default collection or contract at other addresses. | Names are compared by a confusable skeleton (1, l, I and their look-alikes count as one letter, as do 0 and O), listed names use Basic Latin and Latin-1 letters or CJK, and the deploy page fails a look-alike of a default at other addresses. |
| FE21-6 | Low | Introduced by the FE21-2 fix: when a key registration matched the active epoch, as the page's RPC endpoint reported it, the review dropped the prompt to compare the key's fingerprint with the stewards'. | The prompt stays on every key registration, and a match reads as the endpoint's report. |
Interface review, release 2.2
The 2.2 web and prerender changes had a review of their own, AI-assisted and separate from the
implementation work: every place chain data reaches a page, a prerendered file or /data/, the
on-chain logo pipeline, the tokens pages, the admin page and the deploy flow. It found no way for
chain data to run as script: the prerender's escaping (HTML, attributes, JSON-LD, the sitemap and the
/dao/<address> redirect page), the hash checks of the /data/ cache, Pillow against crafted PNGs,
one strict CSP on every entry, consent and prices all held, no module loads under two URLs in a
versioned build, and the admin token is never stored. The same reviewer then checked the fixes with
the original probes.
| ID | Severity | Finding | Resolution |
|---|---|---|---|
| WB22-1 | Medium | Three lopsided DeSciFi pools, about 1 L1 of an attacker's own tokens, made the tokens step of the prerender raise an exception, and the job then wrote nothing at all: no pages for new DAOs, no sitemap, no /data/ cache and no fresh price.json, so after a day visitors lost the default US dollar amounts. Hiding the tokens did not help. | tools/seo/tokens.py keeps every figure as an integer until it is written, publishes null for a figure above 10^30 L1 (shown as "—", sorted last) and has no float conversion that can overflow; the pages apply the same cap. tools/prerender.py turns any failure of the tokens, member-network or price step into a warning, keeps their last good files and writes everything else. Tests: market.tokens_lopsided_pools, seo.prerender_survives_failing_parts. |
| WB22-2 | Medium | A token launched by any COLLAB DAO skipped the "not verified" notice and the check for tokens opened from a link, and its pill named the DAO by title and logo only. Titles aren't unique, so a copycat DAO with another DAO's title, logo and description could launch a counterfeit that looked vouched for. | Launch by a DAO is shown as information, not trust: a token counts as vouched for only when the site's operator verified it (verifiedTokens) or features its DAO (featuredDaos); otherwise it carries the notice and the link check. Every place a DAO vouches shows its unique, permanent /dao/<slug>, and a DAO whose cleaned name matches another's says "Another DAO has this name" and links the others. Tests: market.dao_tokens_need_vouching, profile.copycat_name_is_flagged. |
| WB22-3 | Low | nginx cached any ?v= URL for a year, so right after an upgrade the previous release's prerendered pages fetched the new files under the old URLs and browsers kept them; a browser could then mix two versions of the same module. | Only the current release's ?v= (read by the installer from web/BUILD.json on every run, rollbacks included) is cached for a year; any other value is revalidated. The installer removes the previous release's prerendered pages before switching, so the release's own entries serve until the job writes new ones. |
| WB22-4 | Info | Several readers of /data/ files checked their size cap only against Content-Length, which is the compressed size with gzip_static, and the home page read tokens.json with no cap. | One streaming reader with a cap (web/js/core/capped.js) for every /data/ file; a file over its cap is refused. |
| WB22-5 | Info | The invisible-character cleaning missed a few more default-ignorable code points (Mongolian variation selectors, the rest of the tag and supplementary variation selector blocks). | Added to all four copies of the pattern (the app's and the prerender's, for names and for Markdown), which stay identical; the hostile-title test covers them. |
The verification also asked for two refinements, both made: a featured DAO's token reads the same in the swap ticket as everywhere else, and the shared-name check compares names by the confusable skeleton already used for integration names, so a copy with one look-alike letter is flagged too.
The server kit
server/install.sh (docs/INSTALL.md) runs a COLLAB site on nginx with an admin page and a
prerender job. It is new in 2.2. The public site stays static, every chain read happens in the
visitor's browser, and the pages keep working when the admin service or the prerender job is down.
What each part may do:
| Part | Runs as | May write | Talks to |
|---|---|---|---|
| nginx | its workers (www-data) | nothing of COLLAB's | visitors; the admin service on 127.0.0.1 |
collab-admin.service (the admin API) | collab | shared/: config.js, site.json, the deployment record, history, request files | 127.0.0.1 only (systemd IPAddressDeny) |
collab-prerender.service | collab-prerender | shared/prerender, shared/data, shared/jobs only | the RPC endpoints, api.coingecko.com |
collab-apply.service (the content policy step) | root, on request | the release's pages, COLLAB's own nginx file | nginx (nginx -t, reload) |
| certbot | root | its certificate | Let's Encrypt |
What follows when each part is taken over:
- The admin token, or a signed-in session. Everything the admin page can do: publish any
deployment record (so the contract addresses this site's pages use) and any site settings. Treat
the token like a key.
- It travels only over HTTPS. The session cookie is HttpOnly, Secure, SameSite=Strict, limited
to
/admin, signed with HMAC-SHA256 and lasts 12 hours. - A new token (
collab admin-token --new) ends every session. Signing out revokes that session durably: the revocation is kept in acollab-owned file undershared/status/and reloaded when the admin service restarts, so a signed-out cookie stays signed out. If it cannot be saved (a full disk, say), the cookie is still cleared but the sign-out answers with an error that says so: sign out again, or end every session withcollab admin-token --new. A damaged file starts a fresh signing salt, which ends every session. - The page that takes the token is served only from the release's root-owned files.
- It travels only over HTTPS. The session cookie is HttpOnly, Secure, SameSite=Strict, limited
to
- The
collabuser, through a flaw in the admin service: the same as the token, no more.- It cannot write the release, nginx's configuration or anything outside
shared/. - The root step trusts nothing it wrote (below).
- It cannot write the release, nginx's configuration or anything outside
- The
collab-prerenderuser, through a flaw in the prerender job or in Pillow, which decodes on-chain logos. That user can change the prerendered pages, preview images and the/data/cache: defacement, or misleading text on DAO pages. Not the contracts the site uses, nor its code:- it cannot read
collab.env(it hasprerender.env, the same settings without the secrets); - it cannot write
config.js(the admin service writes it; nginx serves it fromshared/, not from the prerender overlay); - from the prerender overlay nginx serves only the pages it prerenders (as HTML), the preview
images under
/og/(an allowlist ofdao-<address>.png,dao-<address>-logo.pngandog/network.json— every other name, every script, stylesheet or SVG, is 404), and/data/(PNG, JSON and plain text withnosniff); never the admin page, and no same-origin script, so a prerender-user compromise cannot get code to run on the site's origin; - the pages it prerenders are HTML, so it can reference the release's own scripts in them, but it
cannot introduce new script code: the CSP allows only same-origin scripts, and nginx serves none
from the overlay. With analytics turned on, the policy also allows Google's tag loader
(
https://www.googletagmanager.com/gtag/), so a compromised prerender user could load it in its pages with a measurement ID of its own, before any consent; that is Google's code, not the attacker's, but visits would be counted for someone else; - nginx follows a symbolic link in its folders only when the link and its target have the same owner, so a link it plants publishes nothing of anyone else's (other sites' files, logs);
- it cannot make
collabwrite through a link it plants inshared/jobs/, the folder they share: every request file is written through a random temporary name and renamed into place; - nginx's own Content-Security-Policy header, which only root writes, forbids inline scripts and every script origin but the site's own and, with analytics on, Google's tag loader.
- it cannot read
- The content policy step is the only root code that reads what the services wrote.
- It reads
config.jswithout following links, checks every value that reaches the policy again, builds the policy from origins only, and refuses one with any character it never needs. - It writes nothing but that policy, into the release's pages and COLLAB's own nginx file, and refuses a release tree another user could change.
- When
nginx -trefuses the result, it puts every file back.
- It reads
The admin API itself:
- It accepts only requests that carry the secret header nginx adds.
- A state-changing request must be a POST of JSON with
X-Collab-Admin: 1and anOrigin(or, without one, aReferer) of the main host; Fetch Metadata from another site is refused. - The token is compared in constant time.
- After 10 failed sign-ins in 10 minutes from one address (an IPv6 /64 counts as one), sign-in is refused (429). nginx also limits sign-ins to 6 a minute and the API to 5 requests a second per address.
- Bodies are limited to 64 KB (4 KB for sign-in). JSON with repeated keys, NaN or deep nesting is refused.
- Records and settings are checked against strict schemas that name the refused field. Addresses with a wrong EIP-55 checksum are refused.
- Every write is atomic and keeps the version it replaces.
- The log names addresses and endpoints, never the token.
What the kit does not protect against:
- root on the server, or whoever controls its DNS;
- a stolen token until it is replaced;
- an operator who publishes a wrong deployment record. Check it on the admin page's Check tab
(
/admin/check).
Behind a reverse proxy such as Cloudflare's, the sign-in limit counts the proxy's addresses unless
nginx's real_ip module is set up for it. The kit's review status is that of the rest of the
release: AI-assisted, no external audit.
Reporting a vulnerability
Please report security issues privately through a GitHub security advisory on the COLLAB repository, or to the maintainers of GenesisL1 directly, before disclosing them publicly. Include a proof of concept if you can.
Review findings and resolution
Release 1.0.0. The proof-of-concept tests are in test/audit/. Each was written to fail on the
reviewed code and now asserts the fixed behaviour.
| ID | Severity | Finding | Resolution | Covered by |
|---|---|---|---|---|
| H-01 | High | Anyone could strand a lock box's LP fees by collecting them into the box, which had no way to pass them on. | collectFeesFor is removed; each LP holder collects its own fees. LockBox.collectFees() also forwards any pool tokens sent to the box. | AuditPoC H-01 |
| H-02 | High | A seller could replace the delivered data key after a steward checked it, and the purchase completed against the new delivery. | finalizePurchase takes the hashes of the envelope and view wrap the steward verified and reverts DeliveryChanged otherwise. | AuditPoC H-02, CipherVault |
| M-01 | Medium | Wildcard rules let a standard proposal type act on contracts belonging to a critical type. | Contracts have a home type. Wildcards reach only generic contracts or the proposal type's own; calls into another type's contracts are as critical as that type; explicit rules, including explicit denies, override wildcards and can be cleared. | AuditPoC M-01a–c |
| M-02 | Medium | A standard profile vote could hand out the operator power, which only a critical vote may grant. | Appointing a representative with the operator permission makes the proposal critical. | AuditPoC M-02 |
| M-03 | Medium | Shares held for a single block could decide a proposal. | Holding period: voting power is the lower of the snapshot and holding-snapshot readings. Transferable DAOs start with 7,200 blocks, and transfers cannot be switched on without a holding period. | AuditPoC M-03, Governance, HoldingVotes |
| M-04 | Medium | The target of a freeze vote could release its own transfer lock with a decoy proposal. | Locks are recorded per proposal; releasing one never releases another. | AuditPoC M-04 |
| M-05 | Medium | Equal-share airdrops could be farmed by splitting shares across accounts. | Equal mode needs shares that were never transferable (ShareToken.everTransferable, see R2 below), and eligibility is read at the proposal's snapshot. | AuditPoC M-05, TokenReviewR2EqualDropAfterTransfersOff |
| M-06 | Medium | Contracts holding shares counted as members, so anyone could push a pool's airdrop share into the pool and skim it. | Contract members claim only for themselves, and DAOs can exclude infrastructure accounts from membership and voting. | AuditPoC M-06 |
| M-07 | Medium | A launch with a two-sided pool could be blocked forever by creating its pair first. | Pools can only be created for tokens that already exist. | AuditPoC M-07, Controls |
| M-08 | Medium | An unfunded freeze proposal could lock any account, including share pools. | The lock starts only when voting starts, after the deposit is reached. | AuditPoC M-08a–b |
| L-01 | Low | The market lens could be made to revert, and its lists spammed. | Pools need existing tokens, and token metadata reads are gas-capped, length-checked and fail soft. | AuditPoC L-01, Controls |
| L-02 | Low | A 1-wei donation plus sync() blocked the router's first deposit into an empty pool. | The router uses the desired amounts when a pool is empty or one reserve is zero. | AuditPoC L-02 |
| I-1 | Info | Withdrawing a proposal escaped the pre-vote deposit penalty. | Withdrawn proposals pay it when the flag is on. | Informational |
| I-2 | Info | No upper bounds on the proposal threshold and the minimum deposit. | Bounded as described above. | Informational, Modules |
| I-3 | Info | Executors choose timing and order; swaps can execute at their minimum output. | By design; documented above. | — |
| I-4 | Info | A frozen member's units still count in proposals already open. | By design (snapshots); documented above and in the code. | — |
| I-5 | Info | Module permissions could target the DAO's shares, policy, profile or vault. | Forbidden. | Informational |
| I-6 | Info | Any steward could overwrite other stewards' envelopes. | Replacement is limited as described above. | CipherVault |
| I-7 | Info | Irreversible actions defaulted to standard rules. | Critical by default. | Informational |
| I-8 | Info | A leftover share allowance could be used by later standard proposals. | The interface resets allowances in the same proposal; documented above. | — |
| I-9 | Info | Istanbul EVM compatibility. | Confirmed; nothing to change. | — |
| N-1 | Medium | Found while documenting the M-03 fix: a member who delegated inside the holding window and then voted directly cut the delegate's own votes. | Direct votes now remove units per reading, and delegates keep the lower remaining reading. Tallies are order independent and checked against an independent computation. | HoldingVotes (fuzz) |
Release 2.0.0
In release 2.0.0, forge test ran 197 tests in 54 suites (unit, fuzz, invariant and proof-of-concept
tests), all passing; the counts of later releases are below. The invariant suites in test/invariants/
drive the contracts through random sequences of calls from authorized and unauthorized accounts.
Their handlers predict from the rules whether each call must succeed, so a call that should have
failed, or one that should have worked, counts as a violation. FOUNDRY_PROFILE=ci forge test --match-path "test/invariants/*" runs longer campaigns.
| Suite | What always holds |
|---|---|
ShareTokenInvariantTest | Units of unfrozen, non-excluded accounts are their balances and sum to the total; frozen and excluded accounts and the treasury hold none. Each delegate holds exactly its delegators' units, now and at every past block. Checkpoint block numbers strictly increase (read from storage). Lock claims are counted exactly and a live claim blocks transfer, transferFrom and return to the treasury. Only the DAO mints and burns, and only the DAO and the governor use admin functions. everTransferable never turns off again, and excluded accounts can be neither frozen nor locked. |
DAOTokenInvariantTest | Supply never exceeds the cap. Renounced capabilities never return and an ownerless token can neither mint nor pause. A pause blocks transfer and transferFrom between holders. Only the owner mints (while MINTABLE is held), pauses (while PAUSABLE is held), sets metadata and moves ownership. |
LauncherInvariantTest | Allocations sum to the supply and each part reaches the treasury, its recipient, the airdrop or its pool. Pools hold exactly the asked reserves and virtual reserves, and LP goes to the treasury or into a lock box with the asked terms. Mis-allocated launches revert. The launcher keeps no token, LP, quote asset or L1, and no allowance. |
DistributorInvariantTest | Each claim pays exactly the entitlement computed from the campaign's snapshot, once. Campaigns created by a proposal use its snapshot, even when shares move and members are frozen before it executes. Claims never exceed a campaign's total, and the distributor always holds what open campaigns still owe. No equal-per-member campaign is created once shares have been transferable. |
DeSciFiInvariantTest | K on effective reserves never decreases in a swap, including raw swaps asking for more than the quote. Deposits and withdrawals are proportional and never dilute other LPs. Fees owed never exceed fee reserves, and every collection pays exactly what is owed. Lock boxes release LP only to their owner after unlock (never when permanent) and pay fees only to their fixed recipient. |
GovernorInvariantTest | No voter's weight exceeds what it held, or was lent, at both readings, so shares bought inside the holding window add nothing, and no tally exceeds the voting units at either reading. Frozen accounts never vote, propose or delegate. |
TreasuryStanceInvariantTest | In a DAO where only treasury spends are critical, no action of any other proposal type that the policy calls standard leaves the treasury with less: random actions of every type and rule (including transfer rules a DAO might add), with fuzzed recipients, amounts, values and unlock dates, between trades and the passing of time. Balances, fees owed, LP (held, locked in its own boxes that open within ten years, or turned into its pro-rata share), NFTs, lock boxes, the administration and minting of its tokens, model revenue, and the absence of allowances, approvals, listings and mints to others all hold. Since 2.1 the DAO lists two GL1F collections and two CIPNFT contracts, holds a model in each collection and a sealed asset on each contract, and the GL1F and CIPNFT actions pick either. Both models sell subscriptions through an access plan, and what their use costs and who may use it (inference pricing, every access plan, the access of others and, on GL1F Crypto, how long subscriptions run) holds too, against actions that create, reprice and buy plans and hand out owner keys. |
| ID | Severity | Finding | Resolution | Covered by |
|---|---|---|---|---|
| V2-01 | Medium | Found by the share-token invariants. Freezing an account reset its lock counter but left each proposal's lock claim set. After an unfreeze, releasing the stale claim of the old, executed freeze proposal released the lock of a new freeze vote still in progress, so its target could move its shares away before the decision. | Freezing no longer touches lock claims. A claim is placed even while the account is frozen and is released only by the proposal that placed it. | AuditV201StaleLockClaim, ShareTokenInvariantTest |
| V2-02 | Medium | Reported during the interface work. A title, name or symbol that was not well-formed UTF-8 made ethers throw while decoding, so one such DAO, proposal or token broke every page that listed it. | The Utf8 library rejects such strings with InvalidText() wherever COLLAB stores or emits text for display, and control characters in titles, names and symbols. The market lens reports malformed token strings as unreadable. | Utf8Test (edge cases, fuzzed against a reference decoder), Utf8IntegrationTest |
v2 token and share review
A separate AI-assisted review of token and share management, done apart from the implementation
work, found the issues below. Each reviewer proof of concept was ported to
test/audit/TokenReviewV2.t.sol and now asserts the fixed behaviour.
| ID | Severity | Finding | Resolution | Covered by |
|---|---|---|---|---|
| R1 | Medium | DeSciFi proposals could move treasury value to any recipient (swap output, LP, removed liquidity) under the standard rules, also in DAOs that made treasury spends critical. | The policy reads the recipient of every DeSciFi call that names one: router swaps and liquidity calls, launch pools, the owner and fee recipient of new lock boxes, pool fee collection, and lock box withdrawals and handovers. Anyone but the treasury makes the call as critical as a treasury spend. The verification below generalised this to every action that can give up treasury value (N1, N2) and removed the burn-address exception for lock box fees (I2). | TokenReviewR1DescifiOutflow (with a fuzz test), TokenVerifyR1Recipients |
| R2 | Medium | Accounts split while shares were transferable stayed members after transfers were switched off, and each could claim a full equal-per-member airdrop. | ShareToken.everTransferable is set when shares first become transferable and never cleared, and equal mode is refused for such shares (EqualNeedsFixedMembers). | TokenReviewR2EqualDropAfterTransfersOff, DistributorInvariantTest, ShareTokenInvariantTest |
| R3 | Medium | The DeSciFi wildcard meant for lock boxes' transferOwnership matched every Ownable contract by selector, so a standard DeSciFi vote could hand away a token another DAO had handed over. | DeSciFi wildcards reach only real pools and lock boxes. Wildcards reach contracts of no type only for treasury spends, launches and token administration, and for approvals. transferOwnership and renounceOwnership on such contracts are critical whatever the kind. | TokenReviewR3DescifiWildcardOwnership |
| W1 | Medium | The plain-language review named a recipient contract by its own symbol(), so a payment to a contract calling itself "burn address" read as a burn. | Fixed in the interface: recipients, spenders and to arguments are named only from the address book of known protocol, DAO and burn addresses, and everything else shows its address. | Interface |
| L1 | Low | A funded freeze proposal could lock an excluded share pool for the whole vote, halting swaps and LP exits, and the deposit came back afterwards. | Excluded accounts cannot be frozen (AccountExcluded) or locked by a freeze vote, and a freeze proposal naming one is refused at submission (FreezeTargetExcluded); since 2.1 whatever the flags. | TokenReviewL1ExcludedFreeze, TokenVerifyL1Exclusions, ShareTokenInvariantTest |
| L2 | Low | TokenLauncher.launch is open to anyone, so any account's own launch carried a launchedBy the interface showed as issued by a COLLAB DAO. | The market lens reports a launcher only when it is a DAO of the COLLAB factory. The launcher stays permissionless. | TokenReviewL2LaunchedBy |
Verification of the token and share fixes
A second separate AI-assisted review, also done apart from the implementation work, verified the
fixes above. It confirmed R1, R2, R3, L1 and L2 and found N1 to N4 and I1 to I4. Its tests were
ported to test/audit/TokenVerifyFixes.t.sol (the fixes) and test/audit/TokenVerifyFindings.t.sol
(the new findings, turned around to assert the fixed behaviour). A follow-up pass of the same review
confirmed N1, N2, N4 and I2, found no way around the stance (look-alike approval and transfer
functions, value nested in calls, module grants, leftover allowances), and left the two residuals B1
and B2; its tests are in test/audit/TokenVerifyBypass.t.sol.
| ID | Severity | Finding | Resolution | Covered by |
|---|---|---|---|---|
| N1 | Medium | R1 checked recipients only. In a DAO with critical treasury spends, a standard DeSciFi vote could still give treasury value away through the price: a launch pool seeded with the treasury's USDC at a quote of 1 wei, or a swap with no minimum that its executor sandwiched. | Where treasury spends are critical, every action that can give up treasury value is critical, whatever the proposal type (see "Treasury value follows the treasury's rules" above). Swaps, deposits into pools, launch pools and launches trade at a price the proposal sets, so they are critical whoever receives the output. | TokenVerifyN1PriceOutflow, TokenVerifyStanceTable, TreasuryStanceInvariantTest |
| N2 | Medium | The same class through other proposal types: giving away a model NFT, buying a model or access with treasury L1, redirecting model revenue, listing at 1 wei, offers carrying L1, a CIPNFT listing at 1 wei, and a launch in a DAO where only treasury spends are critical. | The same stance covers every type: L1, allowances and operator approvals, listings and direct sales, transfers, mints, ownership and revenue recipients, permanent locks and burns. Setting the DAO's CIPNFT encryption keys, which decide who can read data delivered to it, is critical in every DAO. | TokenVerifyN2OtherKindsOutflow, TokenVerifyStanceTable, TreasuryStanceInvariantTest |
| N3 | Low | The "Issued by a COLLAB DAO" badge was cheap: a one-member DAO made in minutes could launch a look-alike token, then hand its administration to a wallet. | MarketLens.owners(tokens) returns each token's administrator (zero once renounced), gas-capped and fail-soft, so the interface names the issuing DAO and shows whether it still administers the token. | TokenVerifyN3BadgeAdmin |
| N4 | Low | Anyone could fill a DAO's lock box and airdrop lists (about 690 empty boxes, or 640 junk campaigns, pushed the lens views past a 25M-gas eth_call cap), and junk campaigns showed among the DAO's airdrops. | Empty lock boxes are refused (InvalidAmount). locksOfPage, locksPayingPage and campaignsOfPage read one page at a time, newest first, at a cost that does not grow with the list, and campaignsOfPage marks the campaigns the DAO created, launch airdrops included (byDao). The lock factory's owner lists now hold exactly the boxes each account owns, so a box handed away leaves its old owner's list and every page but the last is full. The old views remain for compatibility. | TokenVerifyN4ListSpam, TokenVerifyN4OwnerIndex |
| I1 | Info | A share lock placed before an account was excluded outlives the exclusion. | Documented under known limitations: exclude pools before trading starts. | TokenVerifyL1Exclusions |
| I2 | Info | The burn-address exception let a standard vote lock LP permanently with its fees burned. | Removed. Lock box fees for the burn address, and permanent locks and extensions, are critical where treasury spends are (and, since B1, locks beyond ten years). | TokenVerifyR1Recipients, TokenVerifyN1PriceOutflow |
| I3 | Info | GL1F market listings survive the model leaving its seller. | Documented under known limitations: approve the market per model and cancel listings before moving a model. Listings and approvals are critical where treasury spends are. | TokenVerifyN2OtherKindsOutflow |
| I4 | Info | R3's defence in depth recognises only the Ownable functions. | Documented under known limitations. | — |
| B1 | Low | Only the exact permanent value counted as giving up LP, so a standard vote could lock the treasury's LP until the year 584 billion (2^64 - 2), which left I2 nominal. | A lock box that opens more than DAOPolicy.LOCK_HORIZON (ten years) after the proposal's submission, created or extended, is critical like a permanent one where treasury spends are critical. | TokenVerifyStanceBypass, TreasuryStanceInvariantTest |
| B2 | Info | With treasury spends critical and token administration standard (a custom mask), renouncing the ownership or the minting of the DAO's own token stayed standard, while handing it over or burning was critical. | Renouncing a token's ownership, or its minting (renounceCapability(MINTABLE)), is critical where treasury spends are, like handing it over or burning. Renouncing the pause capability stays standard. | TokenVerifyStanceBypass, TokenVerifyStanceTable, TreasuryStanceInvariantTest |
Release 2.1.0: outside review of the 2.0.0 archive
An outside review read the published 2.0.0 archive, its source and its documentation; it could not run
the Solidity suite, and it was not an audit. Its nine items were all confirmed. The contract changes
are covered by the tests named below; test/Integrations.t.sol also covers the per-DAO integrations
of 2.1 (lists, roles, rule precedence with a fuzz test, rule keys, listings within one proposal,
removal), test/CipherVaultMulti.t.sol the key vault across CIPNFT contracts, and
test/SizeLimits.t.sol checks that every COLLAB contract fits the EIP-170 code size limit (local test
chains raise it).
In release 2.1.0, forge test ran 289 tests in 84 suites (unit, fuzz, invariant and proof-of-concept
tests), all passing.
| ID | Item | Resolution | Covered by |
|---|---|---|---|
| OR-1 | The admin powers of the outside contracts (GL1F registry and market owners, CIPNFT's admin) were not visible, and "no admin keys" read as if it covered them. | "No admin" now names COLLAB's own contracts, and What you still trust lists each family's outside powers. Each DAO chooses its GL1F collections and CIPNFT contracts, may list its own and remove any, by its own critical votes; the interface shows the live holder of each outside admin key. | IntegrationListsTest, IntegrationRemovalTest, SameProposalRolesTest, CipherVaultMultiTest; interface |
| OR-2 | A funded freeze proposal locks its target's share transfers for its whole vote, and overlapping freeze proposals extended the lock. | The lock covers the vote, through the last second the freeze can be executed, and anyone can release it once the vote has failed. With FLAG_FREEZE_BOND, on in every preset, a failed freeze (Rejected or NoQuorum) costs its deposit. Freeze votes on one account may still overlap, each with its own lock, and one proposal can lock up to 32 accounts for one deposit (V21-4), so a DAO with transferable shares should set a meaningful minimum deposit; the interface warns. A one-vote-per-account rule was tried first and removed after the 2.1 review (RV-2): it opened a gap between votes. | FreezeLockTest, FreezeBondTest, RvFreezeGapTest |
| OR-3 | The critical voting period and veto threshold were not ordered against the standard rules, and the creation wizard did not check them. | The governor requires, at creation and on every change, a critical voting period at least the standard one, a critical veto threshold at most the standard one and a critical minimum Yes at least the standard one, besides the existing quorum, threshold and timelock checks. The wizard and the parameters builder refuse the same. | RuleOrderingTest |
| OR-4 | Abstain counts towards quorum but not towards the threshold, so a tiny Yes could pass. | An optional minimum Yes per rule set (minYesBps, at most 50%, see RV-10): Yes must reach that share of all voting units at the snapshot, or the proposal is Rejected. The interface states the smallest Yes that passes. | MinYesTest |
| OR-5 | The interface's simulation lacked the governor's executingProposal context. | Interface: an executable proposal is simulated through governor.execute itself; earlier ones are simulated without that context and labelled approximate. | interface |
| OR-6 | Module permissions are a target and a function only, which the documentation did not say plainly. | Stated: a module may call its function with any arguments, for a token's transfer any amount to anyone (see Modules above). | — |
| OR-7 | The holding period compares two moments, which the wording did not make clear. | The wording now says so (Votes are read at two moments, and the known limitation on units that change hands). Tests: units moved out and back inside the window, and two accounts sharing a delegate. | HoldingTwoMomentsTest |
| OR-8 | The directory's open-proposal cache was keyed on the proposal count only. | Interface: the cache expires after 60 seconds. | interface |
| OR-9 | The documentation did not separate holding encrypted data from owning the rights in it. | Documentation: a CIPNFT asset and its data key are not the intellectual property in the data. | documentation |
Release 2.1.0: internal review of the 2.1 changes
Before release, a separate AI-assisted review, done apart from the implementation work, read the 2.1
changes against the 2.0.0 archive and wrote a proof-of-concept test for every finding, against the
real GL1F Forest, GL1F Crypto and CIPNFT bytecode. It found no High issues and no way for an outsider,
a single member or a single steward to take treasury assets or sealed data. Its proof-of-concept tests
are in test/audit/Review21.t.sol: each RV test now asserts the fixed behaviour, and the attacks that
failed are kept as tests (test_ok_…).
| ID | Severity | Finding | Resolution | Covered by |
|---|---|---|---|---|
| RV-1 | Medium | In a DAO whose treasury spends are critical, a standard GL1F vote could sell a model's use at any price: reprice or create an access plan (on GL1F Crypto a subscription bought from it can never be revoked and keeps the model from being deleted until it ends), hand out a perpetual owner access key, or make inference free. | Where treasury spends are critical, updateModelSettings, createAccessPlan, setAccessPlan and setOwnerAccessKey on a listed GL1F registry are critical, whoever is paid. Elsewhere they stay standard, as a standard vote there can already spend. The stance invariant now checks pricing, plans and the access of others. | RvCryptoAccessPlanTest, IntegrationStanceTest, TreasuryStanceInvariantTest |
| RV-2 | Low | The one-vote-per-account rule for freezes left gaps in the lock. A vote set up to fail held a genuine one back, and at its end the target could release the old lock and move its shares before the genuine vote started; such votes could be chained, and anyone could fund them. A freeze submitted under older flags started without a lock and held back locked ones. | The rule is removed (FreezeVotePending, freezeVoteOf): freeze votes run side by side as in 2.0, each with its own lock, so a vote that ends never frees an account another one still locks. The bond, the targets recorded for every membership proposal and the refusal of excluded accounts stay. | RvFreezeGapTest, FreezeLockTest |
| RV-3 | Low | Removing a GL1F collection stranded the DAO's listings and approvals there: only a raw transaction could cancel or revoke them, and a listing kept selling. | Revocations (approve(address(0), …), setApprovalForAll(x, false)) need no approved spender, and a treasury proposal reaches a removed collection's Model NFT with both (operator approvals since V21-1), so it can revoke what the DAO approved there, which stops the old listing from selling. Cancelling the listing itself still takes a raw transaction: cancel listings before removing a collection (documented, also for CIPNFT contracts). | RvRemovedCollectionTest, VerifyRv3RevocationTest |
| RV-4 | Low | The key vault never checked that a purchase was sealed to the active key epoch: a CIPNFT contract listed again after a rotation kept the retired key, and the vault bought with it. A classic-only epoch after a post-quantum one left the old post-quantum key registered. | approvePurchase and finalizePurchase require the offer to be sealed to the active epoch's classic key or its post-quantum key (OfferKeyNotActive). activateKeyEpoch refuses an epoch without a post-quantum key while a listed contract holds one for the DAO (PqcKeyRequired). | RvVaultKeyTest |
| RV-5 | Info | One listed CIPNFT contract that does not answer blocked key rotation until a separate PARAMS vote unlisted it. | activateKeyEpoch is allowed in PARAMS proposals (critical), so one proposal can unlist the contract and rotate. | RvHostileCipnftTest |
| RV-6 | Info | A freeze that passed but could not be executed held other freeze votes back for its vote, timelock and window, and got its deposit back despite the bond. | With RV-2's fix it holds no other vote back. Its deposit is still refunded, since the bond covers votes that fail, not execution (documented). | RvFreezeDetailsTest |
| RV-7 | Info | The freeze lock ended one second before a freeze stopped being executable, as in 2.0. | The lock lasts through the last second the freeze can be executed. | RvFreezeDetailsTest, FreezeLockTest |
| RV-8 | Info | A raw-transaction freeze was outside the one-vote rule, the lock and the bond. | The rule is gone (RV-2). A raw-transaction freeze stays critical, without a lock or a bond (documented). | RvFreezeDetailsTest |
| RV-9 | Info | approve(spender, 0) on an ERC-721 grants token 0, but the stance did not count it as an allowance. | On a listed GL1F Model NFT, every approval to a spender gives up value where treasury spends are, token 0 included. A listed collection's market is trusted with the approvals the DAO gives it (documented). | RvApproveZeroTest |
| RV-10 | Info | A minimum Yes up to 100% was accepted, so a DAO could lock itself. | minYesBps is capped at 50% (InvalidParams("minYes")). | RvLivenessTest, RuleOrderingTest |
| RV-11 | Info | Reveals continued on an unlisted CIPNFT contract while declassification stopped there. | postRevealEnvelopes checks the listing too. | RvLivenessTest |
| RV-12 | Info | wL1, the keyring, the text store, the factory and the lenses could be listed, contrary to the specification; listing wL1 took it out of the reach of treasury, DeSciFi and launch proposals. | Every protocol contract the DAO's factory knows is refused: the factory itself, its policy builder and every contract of its protocol(). The lenses are deployed after the factory, which does not know them; they hold nothing and have only views (documented). | RvLivenessTest, IntegrationListsTest |
Release 2.1.0: verification of the 2.1 fixes
A separate verification, also AI-assisted and done apart from the implementation work, compared the
code before and after the fixes for RV-1 to RV-12, tried variants of each, and wrote a test for every
verdict and every new finding, against the real external bytecode. It found no High or Medium issues
and no way for an outsider, a single member or a single steward to take funds, tokens, shares or sealed
data. It confirmed RV-1, RV-2 (with the whole return to 2.0's freeze votes: M-04, V2-01, M-08 and L1
hold again) and RV-4 to RV-12, RV-5 with a regression (V21-2), and found RV-3 incomplete (V21-1). Its
tests are in test/audit/Verify21.t.sol: the test_V_… tests confirm the fixes, and the V21 tests now
assert the resolution.
| ID | Severity | Finding | Resolution | Covered by |
|---|---|---|---|---|
| V21-1 | Low | RV-3 did not reach operator approvals. After a collection was removed, no proposal type but raw transactions had a rule for setApprovalForAll on its Model NFT, so a market's operator approval kept a stale listing selling. | Treasury proposals may call setApprovalForAll on any contract without a type. Grants still need the treasury's approved spender (the distributor) and are critical where treasury spends are, so the rule grants nothing new; revocations now reach a removed collection's Model NFT. The DAO's own contracts stay critical, and contracts of a type stay out of its reach. | VerifyRv3RevocationTest |
| V21-2 | Info | Since RV-4, every key rotation asked each listed contract for pqcPubKey, so a listed contract without it (a DAO's own classic-only system) blocked even classic rotations. | For an epoch without a post-quantum key, the vault reads pqcPubKey with a gas cap and counts a failure, an exhausted cap or a malformed answer as no key: such a contract cannot hold post-quantum tokens, and the vault refuses offers that claim otherwise. An epoch with a post-quantum key still needs it on every listed contract (documented). | VerifyRv5RotationTest |
| V21-3 | Info | RV-4's key check lives in the key vault. A proposal calling CIPNFT's finalizeOffer (or deliverOffer) directly skipped it, and was standard where treasury spends are standard. | Both calls are critical in every DAO. The key vault's own grants for them are module permissions, not proposals, so the vault's flows are unchanged. | VerifyRv4KeyTest |
| V21-4 | Info | One freeze proposal can name up to 32 accounts and lock them all for one minimum deposit, and such proposals can follow one another. | Documented with the freeze lock's trade-off (OR-2 and the known limitation): DAOs with transferable shares should keep a meaningful minimum deposit. | VerifyFreezeRollbackTest |
Release 2.2.0: on-chain profiles, permanent titles and slugs
The 2.2 contract changes and the tests that cover them. test/SizeLimits.t.sol still checks every
COLLAB contract against the EIP-170 code size limit.
| Change | What the tests hold | Covered by |
|---|---|---|
The logo lives in the profile's storage (setLogo, logoInfo, logoChunk). | Empty, or at most 512 KiB with the signature, IHDR fields (every valid bit depth and colour type) and closing IEND chunk of a 128 × 128 PNG; malformed headers and other sizes are refused. It is stored at its ERC-7201 base and overwritten in place, the old tail cleared and the bytes past the length zero. Chunks rebuild the stored bytes at every offset and size, and logoInfo and LogoChanged carry their hash and length. Only the DAO and representatives with the logo permission may set it. The demo logos pass. | ProfileLogoTest (with fuzz tests of chunk reads and rewrites) |
The description lives in the profile's storage (setDescription, descriptionInfo, descriptionChunk). | Well-formed UTF-8 without C0 control characters other than tab and line feed, at most 256 KiB, at creation and on every edit; the same storage scheme as the logo. | ProfileDescriptionTest, Utf8IntegrationTest, Utf8Test (the 32-byte fast path at every window boundary, long texts fuzzed against a reference decoder) |
| Titles are permanent. | setTitle and its permission are gone; a representative granted bit 1 is refused. | ProfileLogoTest, ModulesTest |
| Slugs. | The rules, fuzzed against a reference; one DAO per slug; resolution by slug and by treasury; the slug in DAOCreated. | SlugTest |
| Directory views. | directory, getDAOBySlug and the slug, logo and description fields of the summaries. | DirectoryTest |
| Gas. | Creations under GenesisL1's London pricing, typical and largest. Its figures for edits are measured inside the creating transaction, where rewritten words are priced as already written, and are about half the real cost (RV22-2); the edit costs below were measured as separate transactions. | ProfileGasTest, a local chain (below) |
Two weaknesses were found while building the 2.2 lens views, both cheap to exploit and both
fixed. Their proof-of-concept tests are in test/audit/Hardening22.t.sol; the one for H22-2 also
runs the old reading pattern against the same tokens and shows it passing the cap.
| ID | Severity | Finding | Resolution | Covered by |
|---|---|---|---|---|
| H22-1 | Low | Share names and symbols had no length limit (the interface capped them, a direct call did not), and the DAO lens returns both with every DAO. A few DAOs with names of about 100 KB, costing a few L1 each to create, could push every getDAOs page holding them past an RPC's 25M eth_call gas cap, so those listing pages would fail. | Share names are at most 80 bytes and symbols at most 32 (InvalidMetadata), so every field the DAO lens returns is bounded. | H22ShareMetadataTest |
| H22-2 | Low | The market lens copied the whole returndata of every token read into memory. Memory never shrinks during a call and its cost grows quadratically, and a hostile token can return about 140 KB within the 60,000 gas a read gets: 16 such tokens in pools of their own (creating a pool costs only gas; 32 returns) pushed a getPools or tokenInfo page past 25M. Reads of model and CIPNFT titles, which had no gas cap, and the DAO lens's read of a DAO's CIPNFT key followed the same pattern, there within the pages of a DAO that lists such a contract. | The lens's reads copy nothing by themselves: only the words they decode leave the return buffer, at most 129 bytes of a name or symbol and 513 of a title. Title reads are gas-capped (300,000) and cut to 512 bytes at a character boundary, and titles that are not well-formed UTF-8 read as empty. Offsets, lengths, the bytes32 form and the 128-byte cut are checked as before. | H22MarketLensBombTest, H22DaoLensKeyTest |
Gas used, measured as separate transactions on a local chain with GenesisL1's gas rules and block gas limit (random image bytes, ASCII text unless stated; the smallest gas limit that works is up to 6% higher for the largest writes, and more where refunds apply):
| Transaction | Gas used |
|---|---|
createDAO without logo or description | 5.4 million |
createDAO with a 64 KiB logo and a 16 KiB description | 63.9 million |
createDAO with a 512 KiB logo and a 256 KiB description | 570.5 million (620.3 million with the text slowest to check) |
A representative's setLogo: 64 KiB over 64 KiB / a first 64 KiB logo | 11.5 million / 46.6 million |
| … removing a 64 KiB logo / a 512 KiB logo | 8.3 million / 66.3 million |
A representative's setDescription: 16 KiB over 16 KiB | 1.6 to 6.1 million |
submitProposal setting a 512 KiB logo and a 256 KiB description | 27.1 million |
execute of a first 64 KiB logo and 16 KiB description / of rewriting a 64 KiB logo | 58.7 million / 11.7 million |
execute of a first 512 KiB logo and 256 KiB description | 570.6 million |
In release 2.2.0, forge test runs 331 tests in 92 suites (unit, fuzz, invariant and proof-of-concept
tests), all passing.
Release 2.2.0: internal review of the 2.2 contract changes
Before release, a separate AI-assisted review, done apart from the implementation work, read the 2.2 changes against the 2.1.0 archive and wrote a proof-of-concept test for every finding, in a suite of 39 tests run against the real GL1F and CIPNFT bytecode. It also tested the new UTF-8 scanner against an independent decoder and the PNG check over every header field, deployed the exported bundle on a local chain, and read GenesisL1's fee behaviour from mainnet receipts. It found no High or Medium issues, and no way to store a logo or description the profile refuses, to break the in-place storage, to use the removed title permission, to change a title or to give two DAOs one slug. Its findings are documented here or handled by the interface; no contract changed after it, so the reviewed bytecode is the released one.
| ID | Severity | Finding | Resolution |
|---|---|---|---|
| RV22-1 | Low | GenesisL1 charges every transaction at least half its gas limit, even when it fails. A creation that loses its slug to one mined first fails at the slug check after about 12.6 million gas, but it carries the explicit limit that large creations need (about 631 million for the largest), so it pays about 315 million gas, 15.8 L1 at 50 gwei, while taking the slug costs the front-runner 0.27 L1. This document said such a creation costs the gas it used. The same rule charges the loser of two executions of one large proposal in a block. | Documented, with the numbers (the slug and gas limitations above). The interface's fee review states what a failure costs. A commit-reveal slug reservation and a two-step creation are future work. |
| RV22-2 | Info | ProfileGasTest measures edits in the transaction that created the DAO, where rewritten words are priced as already written, so its edit figures are about half the real cost, and this document cited it for edits. | The edit costs above were measured as separate transactions; ProfileGasTest is cited for creations only. The interface's gas formula always used the separate-transaction figures. |
| RV22-3 | Info | getDAOs stated no page limit: 200 entries with the longest fields allowed take 23.6 million gas of a 25 million eth_call cap. | Documented under Bounds: at most 100 per getDAOs page and 200 per directory page (13.1 million). The interface reads 60 at a time, the prerender 50. |
| RV22-4 | Info | Documentation was stale around 2.2: ARCHITECTURE.md still placed DAO logos and descriptions in TextStore, DEPLOY.md described 2.1's logos (and the prerender drew no logo above 256 KiB in preview images), the cost of removing a large logo was not stated, and two different chunk-read costs were given. | ARCHITECTURE.md and DEPLOY.md are corrected, and the prerender draws logos up to 512 KiB. Removing a 512 KiB logo costs about 66 million gas after London's refund cap and needs an explicit limit (documented above). A full 16 KiB chunk costs about 1.2 million gas to read. |
| RV22-5 | Info | An edit made by a proposal puts the bytes into the chain's history three times (the submission's calldata, the ProposalSubmitted event and the execution's calldata); a representative's direct edit puts them there once. | Documented above. A later release could have proposals approve the hash of new content, uploaded once. |
| RV22-6 | Info | DEL and the C1 controls (U+0080 to U+009F) pass where this document said no control characters do: in descriptions, share names and symbols and proposal titles, and the C1 controls also in DAO titles and announcement headings. | Documented: no C0 control characters other than tab and line feed; DEL and the C1 controls are not refused. The interface strips them before display. |
| RV22-7 | Info | The logo check accepts byte strings that are not images: it reads only the signature, the header fields and the closing chunk, not even the header chunk's checksum, while one sentence here called the header chunk valid. | Reworded to the signature, header fields and closing chunk of a 128 × 128 PNG; the known limitation already said the rest is not checked. Logos are shown only as PNG images. |
| RV22-8 | Info | A permanent DAO title may hold bidi controls, invisible characters and noncharacters, and the interface's cleaning missed a few invisible characters outside the Basic Multilingual Plane. | Interface: the names it shows are cleaned of these characters, those the review found included, and every on-chain string is shown direction-isolated (dir="auto"). Documented under known limitations. |
Release 2.2.0: internal review of the server kit
The 2.2 server kit (server/, the nginx templates, the admin service and the content-policy step)
had a review of its own, AI-assisted and done apart from the implementation work. It found the content-policy injection defences, the config.js generation,
the admin API and the nginx routing sound, and raised four hardening items; its verification of the
fixes raised two more (SV22-5 and SV22-6). All six presuppose an existing foothold as one of the
service users or a captured cookie; none is a pre-auth, internet-facing bug. All six are fixed and
have tests (server/tests/test_admin.py and the two sandbox scripts).
| ID | Severity | Finding | Resolution |
|---|---|---|---|
| SV22-1 | Medium | install.sh created shared/* with install -d, which follows a symlink a service user could plant in the collab-owned shared/, so the next install/upgrade would chown/chmod an attacker-chosen target to collab. | collab_admin.py shared-dirs creates and owns the folders through O_NOFOLLOW directory descriptors from the root-owned base: it follows no symlink, acts only on file descriptors, and refuses a folder that is a symlink or belongs to the wrong owner. |
| SV22-2 | Medium | The /og/ overlay served any file extension from the prerender user's tree, so a user compromised through Pillow could place a same-origin script. | /og/ serves an allowlist only — dao-<address>.png, dao-<address>-logo.png and og/network.json — and 404s everything else, mirroring /data/. The other overlay locations serve fixed filenames only. |
| SV22-3 | Low | Logout revocation was in-memory, so a signed-out cookie was accepted again after the admin service restarted. | Revocations and the signing salt are kept in a collab-owned shared/status/sessions.json, loaded at start; a signed-out cookie stays signed out across a restart. |
| SV22-4 | Medium (race) | Root used os.chown (which follows symlinks) on files in the service-writable shared/status and shared/jobs, a TOCTOU a looping collab could win. | Root writes those files through an O_NOFOLLOW directory-descriptor chain and sets owner and mode on the descriptor (fchown/fchmod) before an atomic rename; the helper's reads of service files run as the service user, not root. |
| SV22-5 | Low | nginx followed symbolic links in the trees the services write, so a link the prerender user planted at a served name (og/dao-<address>.png, data/*.json, robots.txt) published any file nginx's workers can read, such as other sites' files or logs. | disable_symlinks if_not_owner from=$document_root in COLLAB's server (and in deploy/nginx/collab.conf, whose prerender cron user writes the web root): a link below a location's root is followed only when it and its target have the same owner. try_files then falls back as if the file were missing; the release, all root's, is unaffected. Checked with an operator-wide open_file_cache too: a link swapped in for a cached file is refused when the entry is revalidated. |
| SV22-6 | Low | The installer and collab prerender --request wrote shared/jobs/prerender.request as collab through a fixed temporary name in jobs/, a folder the prerender user also writes, so a link planted there made collab overwrite config.js or the deployment record. | Every request file goes through collab_admin.py (request-prerender, the admin service, the policy step): a random O_EXCL/O_NOFOLLOW temporary file and an atomic rename, so a planted link is replaced, never written through. The lock in shared/status/ is opened O_NOFOLLOW too. |
The verification pass also made three Info-level points, all taken up: a sign-out whose revocation
cannot be saved (a full disk, say) clears the cookie but answers with an error saying so, and is
logged; a damaged sessions.json (deep nesting, huge numbers, wrong types; its size and nesting are
capped before parsing) starts a fresh signing salt, which ends every session, instead of stopping the
service; and shared-dirs checks that a folder root has just created is still root's before handing
it over.
The proof-of-concept and regression tests: test_nofollow_dir_and_write, test_ensure_subdir_refuses,
test_logout_is_durable, test_request_never_follows_a_link, test_sessions_file_malformed and
test_logout_reports_an_unsaved_revocation in server/tests/test_admin.py; in
server/tests/sandbox_install.sh the step that plants a symlink in shared/ and confirms the installer
refuses and leaves the target alone; and in server/tests/sandbox_checks.py the /og/ allowlist checks
(.js, .html, .svg and a stray name all 404), the planted-link checks (cross-owner links at
og/, data/ (a file, a folder and a .gz sibling), robots.txt, a DAO page and config.js answer
as if missing, never with the link's target) and the request written past a planted link with
config.js unchanged. The kit's review status is otherwise that of the rest of the release:
AI-assisted, no external audit.