AlphaCOLLAB runs on GenesisL1 mainnet and has had no external audit. Use small amounts.Security notes

COLLAB

Reading COLLAB from the chain

Everything the site shows is public chain state, and you can read it without the site: for an indexer, a bot, a research script or a second interface. This page shows which views to call, how large a page each can safely be, how to read and verify a DAO's logo and description, and which events to follow, with examples in Foundry's cast. It is for developers; the Contracts guide explains what the contracts do.

Part
Developers
Version
COLLAB 2.2.0
Updated
Reading time
8 min

What you need

  • A GenesisL1 JSON-RPC endpoint, chain id 29: the default presets are https://rpc.genesisl1.org and https://gl1x.com/api/eth-rpc.
  • The deployment's addresses. A COLLAB site publishes them in its /config.js (addresses, and startBlock); the operator's record deployments/29.json has the same ones (Data formats). Most reads need only factory, daoLens and marketLens; a DAO's own six contracts come from the factory.
  • cast from Foundry, or any client with the ABIs in abi/ (one JSON file per contract).
RPC=https://rpc.genesisl1.org
FACTORY=0x…   # addresses.factory
LENS=0x…      # addresses.daoLens
MARKET=0x…    # addresses.marketLens

The outputs below come from the local demo chain (tools/local_chain.py up --seed), so the addresses differ from GenesisL1's.

Limits that shape every read

LimitValueWhat to do
eth_call and eth_estimateGas gas cap25,000,000 by default on Evmos-based nodes such as GenesisL1'skeep lens pages small (below); a call over the cap fails, whatever the block gas limit
eth_getLogs block rangeat most 10,000 blocks per requestquery in ranges of 10,000 blocks
eth_getLogs resultsat most 10,000 logs per answernarrow the filter (address, topics) or the range; COLLAB's server tools halve a range a node refuses
first blockthe deployment's startBlocknever ask for logs before it; for a DAO's contracts start at its createdBlock
ViewSafe pageCOLLAB uses
DAOLens.getDAOs(offset, limit, newestFirst)at most 100 (200 summaries with the longest fields take 23.6M gas)60 in the web app, 50 in the prerender
DAOLens.directory(offset, limit)at most 200 (13.1M gas with the longest fields, about 70k per DAO)200 in the web app, 100 in the prerender
MarketLens.getPools(offset, limit, user)at most 80 pools if every token is hostile40 in the web app and in the prerender
MarketLens.tokenInfo(tokens, owner, spender)at most 60 tokens if every token is hostile40 in the web app and in the prerender
MarketLens.modelsOf, cipnftsOfmax at most 50
DAOProfile.logoChunk, descriptionChunkat most 16,384 bytes (about 1.2M gas each)16,384, four reads in flight

The factory: count, slugs and addresses

cast call $FACTORY 'VERSION()(string)' --rpc-url $RPC                  # "COLLAB DAO v2.2.0"
cast call $FACTORY 'daoCount()(uint256)' --rpc-url $RPC                # 5
cast call $FACTORY 'daoBySlug(string)(uint256)' open-proteomics-collective --rpc-url $RPC   # 1 (0 = no such DAO)
cast call $FACTORY 'slugAvailable(string)(bool)' my-new-lab --rpc-url $RPC                 # true: well formed and free
cast call $FACTORY 'slugOf(address)(string)' $TREASURY --rpc-url $RPC  # "" when the address is not a DAO

daoAt(id) (ids start at 1) returns the DAO's six addresses, its creator and when it was created:

cast call $FACTORY 'daoAt(uint256)((address,address,address,address,address,address,address,uint64,uint64))' 1 --rpc-url $RPC
# (treasury, governor, shares, profile, policy, vault, creator, createdAt, createdBlock)

daoOf(treasury) and idOf(treasury) go the other way (idOf is 0 for an address that is not a DAO). A slug resolves to the same DAO forever: slugs are permanent and unique, and never start with a digit. Titles are not unique; when a DAO vouches for something, show its slug.

The DAO lens

For one DAO, getDAO(treasury) or getDAOBySlug(slug) returns (DAOSummary, DAODetails), or reverts "not a DAO". The full return types are long; build cast's signature from the ABI file:

cat > sig.py <<'EOF'
import json, sys
def t(p):
    if p["type"].startswith("tuple"):
        return "(" + ",".join(t(c) for c in p["components"]) + ")" + p["type"][5:]
    return p["type"]
abi, name = json.load(open(sys.argv[1])), sys.argv[2]
f = next(x for x in abi if x.get("type") == "function" and x["name"] == name)
print(f"{name}({','.join(t(i) for i in f['inputs'])})({','.join(t(o) for o in f['outputs'])})")
EOF
cast call $LENS "$(python3 sig.py abi/DAOLens.json getDAOBySlug)" open-proteomics-collective --rpc-url $RPC

The summary starts with the id, the six addresses, the creator, createdAt, createdBlock, the title, the slug and the logo's hash and length; the details start with the description's hash and length. The field order is in the Contract API reference.

For lists, directory(offset, limit) is the light view: one DirectoryEntry per DAO in ascending ids, with what changes when a profile changes.

cast call $LENS 'directory(uint256,uint256)((uint256,address,address,string,string,bytes32,uint32,bytes32,uint32,uint64,uint64)[],uint256)' 0 2 --rpc-url $RPC
# [(1, 0x3189…, 0xeB37…, "open-proteomics-collective", "Open Proteomics Collective",
#   0x3666…e3f6, 4264, 0xecfe…a510, 279, 1793564960, 1793564960), (2, …)]
# 5

The fields are id, treasury, profile, slug, title, logo hash and length, description hash and length, createdAt and profileUpdatedAt; the second value is the total, so you page with offset += page.length until you reach it. getDAOs returns the heavier DAOSummary rows (share supply, members, treasury balance, proposal count) and can page newest first.

Proposals: getProposals(governor, fromId, count, viewer) pages newest first (fromId 0 = the latest), and getProposal(governor, id, viewer) returns one, with the viewer's vote, preview weight and deposit when viewer is not the zero address. getMembers(treasury, accounts) reads the accounts you pass; to find them, replay the share token's UnitsChanged logs.

Logos and descriptions

A DAO's logo (a 128 × 128 PNG of at most 524,288 bytes) and description (UTF-8 of at most 262,144 bytes) live in its profile's storage. Read them in four steps:

  1. logoInfo() or descriptionInfo() returns (bytes32 hash, uint32 length): keccak-256 of the stored bytes and their length, (0, 0) when empty. The lens rows carry the same pair.
  2. Read logoChunk(offset, 16384) (or descriptionChunk) for offset = 0, 16384, … below the length. A chunk returns the bytes in [offset, min(offset + size, length)), empty past the end; a size above 16,384 reverts ChunkTooLarge().
  3. Concatenate the chunks and check that keccak-256 of the result equals the hash and that its length matches.
  4. On a mismatch an edit landed between your calls: read the info again and start over.
read HASH LEN < <(cast call $PROFILE 'logoInfo()(bytes32,uint32)' --rpc-url $RPC | tr '\n' ' ')
: > logo.hex
for ((off = 0; off < LEN; off += 16384)); do
  cast call $PROFILE 'logoChunk(uint256,uint256)(bytes)' $off 16384 --rpc-url $RPC | sed 's/^0x//' >> logo.hex
done
[ "$({ printf 0x; tr -d '\n' < logo.hex; } | cast keccak)" = "$HASH" ] && echo verified
python3 -c "import sys; sys.stdout.buffer.write(bytes.fromhex(open('logo.hex').read()))" > logo.png

The hex goes to cast keccak on standard input because a 512 KB logo is a megabyte of hex, more than one command-line argument may hold. Use descriptionChunk(…)(string) to see the text directly. The contract checks only the PNG's header (signature, IHDR, IEND), never its image data, so show the bytes only as an image, and treat the description as untrusted Markdown.

A COLLAB server also publishes verified copies by hash at /data/logos/<hash>.png and /data/texts/<hash>.txt (64 lowercase hex digits, no 0x). They are a cache, not a source: check the hash of what you fetch, and fall back to the chunks (Data formats).

Constitutions, announcements and other texts

Constitutions, announcements, proposal descriptions and token logos are TextStore blobs: a pointer and a hash. read(pointer) returns the bytes, hashOf(pointer) and lengthOf(pointer) what was registered:

cast call $TEXTSTORE 'read(address)(string)' $PTR --rpc-url $RPC
cast call $TEXTSTORE 'read(address)(bytes)' $PTR --rpc-url $RPC | cast keccak    # must equal the record's hash
cast code $PTR --rpc-url $RPC    # 0x00 "CTS1" kind length payload: no gas cap

The record that points to a blob (a constitution version, a proposal, an announcement event) carries its hash too; compare it with keccak-256 of what you read before you show it. For large blobs read the manifest and its chunks with eth_getCode, which no gas cap limits.

Pools and tokens

cast call $MARKET 'getPools(uint256,uint256,address)((address,address,address,string,string,uint8,uint8,uint16,bool,uint112,uint112,uint112,uint112,uint256,uint128,uint128,uint256,uint256,uint256)[],uint256)' 0 40 0x0000000000000000000000000000000000000000 --rpc-url $RPC
# [(pool, token0, token1, "USDC", "wL1", 6, 18, 30, false, reserve0, reserve1, virtual0, virtual1, lpSupply, feeReserve0, feeReserve1, 0, 0, 0), …]

A pool row has its tokens, symbols and decimals, the fee in basis points, whether it is a one-sided launch pool, its real and virtual reserves, the LP supply, the fees held for LPs, and, for a non-zero user, that account's LP and pending fees. tokenInfo(tokens, owner, spender) reads names, symbols, decimals and supply with gas-capped reads (ok is false for a token that does not answer like an ERC-20), and launchedBy names the issuing DAO only for tokens a factory DAO launched. Names and symbols come from the tokens themselves: clean them before you show them, and never treat a symbol as an identity. The DeSciFi factory lists every pool with allPairsLength() and pairsPage(offset, limit).

Prices are not stored anywhere: COLLAB computes them from reserves, along the deepest path of pools to wL1. tools/seo/tokens.py does it in Python for the server's /data/tokens.json, with the same integer math as web/js/descifi/data.js.

Events

cast logs takes the event's signature and, after it, values for its indexed arguments:

cast logs --from-block $START --address $FACTORY 'DAOCreated(uint256 indexed id, address indexed treasury, address indexed creator, address governor, address shares, address profile, address policy, address vault, string title, string slug)' --rpc-url $RPC

# a proposal's actions: the ProposalSubmitted log of proposal 2, in its createdBlock
cast logs --from-block 48 --to-block 48 --address $GOVERNOR 'ProposalSubmitted(uint256 indexed id, address indexed proposer, uint8 indexed kind, bool critical, string title, address descriptionPtr, bytes32 descriptionHash, (address,uint256,bytes)[] actions, uint48 snapshot, uint64 depositEnd)' 2 --rpc-url $RPC

# when a logo changed (no bytes in the event: read them from storage)
cast logs --from-block $START --address $PROFILE 'LogoChanged(bytes32,uint32)' --rpc-url $RPC
cast decode-event --sig 'LogoChanged(bytes32 hash, uint32 length)' $DATA    # DATA: a log's data field
# 0x3666541155b237c991f35160b8c49908bdfa8e4ee1b9c291742ec56bc24ee3f6
# 4264

On GenesisL1 each of these must stay within 10,000 blocks per request: loop over ranges from the start block to the head. The events worth following are listed in the Contracts guide. Two habits keep an indexer honest:

  • Read the actions of a proposal from its ProposalSubmitted log and check keccak256(abi.encode(actions)) against the proposal's actionsHash before you show or execute them.
  • Members are the accounts with voting units: replay ShareToken.UnitsChanged(account, previousUnits, newUnits) and confirm the result against memberCount(), as tools/seo/network.py does.