A 100-line Solidity contract holds the on-chain skeleton of a vote — proposer, deadline, yes/no tallies — while the human-readable proposal body lives on Walrus. The proposer pays WAL once via the public publisher; the DAO contract never holds WAL. Anyone reads the proposal anonymously through any Walrus aggregator.
"4Everland is used by Snapshot governance for Aave and Uniswap DAO votes" — bundled into the same hosted-IPFS tier that absorbs the operational pain because public gateways can't be trusted.— 2026 builder-sentiment survey
Governance.vote() reads
voteToken.balanceOf(msg.sender) at the instant of the call. Against any ERC-20 with a flash-mint
or flash-borrow integration, this is exploitable: an attacker can borrow tokens, vote, and repay in a single
transaction. For production, pair with an OpenZeppelin ERC20Votes token and switch
vote() to IVotes(voteToken).getPastVotes(msg.sender, proposalStartBlock).
propose(blobId, deadline) takes a 32-byte Walrus blob id and a unix-seconds deadline. The
contract reverts on past deadlines and zero blob ids, then stores the
Proposal(proposer, blobId, deadline, yes, no) struct and emits Proposed.
vote(id, support) reads the caller's voteToken balance, gates on
!hasVoted && block.timestamp < deadline, and accumulates the weight on yes or
no.
tally(id) is the canonical read for (yes, no, passed, closed). The proposal
body is fetched from <aggregator>/v1/blobs/<blobIdBase64Url> — no Snapshot adapter,
no pinning vendor.
// Governance.sol struct Proposal { address proposer; bytes32 blobId; uint64 deadline; uint128 yes; uint128 no; } function propose(bytes32 blobId, uint64 deadline) external returns (uint256 id); function vote(uint256 id, bool support) external; function tally(uint256 id) external view returns (uint128, uint128, bool passed, bool closed);
cd showcases/contracts forge create src/Governance.sol:Governance \ --rpc-url $EVM_RPC_URL \ --private-key $DEPLOYER_PRIVATE_KEY \ --constructor-args $VOTE_TOKEN_ADDRESS # local: anvil + a dummy ERC-20 you already control work fine.
cd showcases/04-dao-proposals pnpm install
| Env | Required? | Purpose |
|---|---|---|
| EVM_RPC_URL | yes | JSON-RPC for the chain holding Governance. |
| EVM_CHAIN_ID | default 31337 | Anvil; mainnet=1, sepolia=11155111. |
| GOVERNANCE_ADDRESS | yes | Deployed Governance address. |
| PROPOSER_PRIVATE_KEY | propose only | Signer for the propose() tx. |
| WALRUS_PUBLISHER | default testnet | Public Walrus publisher URL. |
| WALRUS_AGGREGATOR | default testnet | Walrus aggregator URL. |
| WALRUS_EPOCHS | default 5 | Blob retention window (≈14 days at 5 epochs). |
# dry-run prints the calldata but skips the EVM tx (the Walrus upload still happens) pnpm propose --dry-run ./fixtures/sample-proposal.md 2027-01-15T00:00:00Z # real run pnpm propose ./fixtures/sample-proposal.md 2027-01-15T00:00:00Z # → [walrus] PUT publisher/v1/blobs?epochs=5 (1234 bytes) # → [walrus] blobId (base64url): xT4f... # → [walrus] blobId (bytes32): 0x... # → [evm] tx submitted: 0xabc... # → [evm] tx mined in block 1234 (status=success) # → [evm] lastProposalId: 1 # → [ok] proposal stored — re-fetch the body with: # pnpm tally 1
The deadline accepts either a unix-seconds integer or an ISO-8601 string. The contract stores it as uint64.
pnpm tally 1 # → proposal #1 # proposer: 0x... # deadline: 1768435200 (2027-01-15T00:00:00.000Z) # blobId: 0x... # aggregator: https://aggregator.walrus-testnet.walrus.space/v1/blobs/xT4f... # yes: 0 # no: 0 # closed: false # passed: false # ---- body (1234 bytes) ---- # # [Proposal] ...
No signer is required for the read — anyone can verify the body matches the on-chain pointer.
cast send $GOVERNANCE_ADDRESS "vote(uint256,bool)" 1 true \ --rpc-url $EVM_RPC_URL \ --private-key $VOTER_PRIVATE_KEY # then re-run `pnpm tally 1` and the yes count reflects voteToken.balanceOf(voter).
cast call $GOVERNANCE_ADDRESS "lastProposalId()(uint256)" matches the id propose printed.pnpm tally 1 shows the tally; passed stays false until block.timestamp >= deadline.vote() from the same address reverts with Governance: already voted.balanceOf reads is real. Use ERC20Votes + getPastVotes in any real DAO.epochs is a number the proposer picks. WAL cost is paid by the proposer's wallet to the publisher upfront — neither the DAO contract nor the voters touch WAL.--dry-run still spends WAL. The Walrus upload runs before the dry-run gate so the CLI can preview the real blobId; only the EVM tx is skipped. Rename to --no-broadcast in your fork if the literal "dry-run = no side effects" reading matters.propose / vote / tally + flash-loan warning in NatSpec.
contracts/test/Governance.t.sol12 Foundry tests covering propose validation, vote weight + double-vote rejection, tally state transitions.
04-dao-proposals/src/propose.tsMarkdown → publisher PUT → propose(blobId, deadline) via viem; --dry-run support.
04-dao-proposals/src/tally.tsRead-only tally + body fetch.
04-dao-proposals/src/lib/walrus.tsPublisher PUT + aggregator GET + hex↔base64url conversion.
showcases/04-dao-proposals/README.mdFull walkthrough + the flash-loan warning verbatim.