From 8435b35a7f23745b5168c0062e898eba532c061d Mon Sep 17 00:00:00 2001 From: Julien Genestoux <17735+julien51@users.noreply.github.com> Date: Mon, 16 Mar 2026 21:36:57 -0400 Subject: [PATCH 1/4] ci: add gh pr review to claude-code-review allowed tools (#16336) The code-review plugin submits reviews via gh pr review, which was not in the allowed_tools list. This caused 3 permission denials per run and prevented the review from being posted to the PR. Co-authored-by: Claude Sonnet 4.6 --- .github/workflows/claude-code-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 8d2c75183cd..8cada9f34e3 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -39,7 +39,7 @@ jobs: plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' plugins: 'code-review@claude-code-plugins' prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' - allowed_tools: 'mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Read,Glob,Grep' + allowed_tools: 'mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr review:*),Bash(gh pr view:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Read,Glob,Grep' display_report: 'true' use_sticky_comment: 'true' # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md From 5914c9aa59674df4e281043a8b6d9a0b97c9df8b Mon Sep 17 00:00:00 2001 From: Julien Genestoux <17735+julien51@users.noreply.github.com> Date: Mon, 16 Mar 2026 21:54:22 -0400 Subject: [PATCH 2/4] ci: enable show_full_output for claude code review debugging (#16337) Temporarily enable full output to diagnose why reviews aren't posting. Co-authored-by: Claude Sonnet 4.6 --- .github/workflows/claude-code-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 8cada9f34e3..7ccd6c688ef 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -38,7 +38,7 @@ jobs: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' plugins: 'code-review@claude-code-plugins' - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + prompt: '/code-review:code-review --comment ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' allowed_tools: 'mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr review:*),Bash(gh pr view:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Read,Glob,Grep' display_report: 'true' use_sticky_comment: 'true' From 4ebd9b7cbb45aac9398df2f829503af87fd6e338 Mon Sep 17 00:00:00 2001 From: Julien Genestoux <17735+julien51@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:12:42 -0400 Subject: [PATCH 3/4] docs: add CLAUDE.md (#16338) docs: add CLAUDE.md with monorepo overview and conventions Covers project structure, essential commands, testing setup, code style, architecture notes, and PR workflow. The Claude Code Review action uses this file to check compliance on every PR. Co-authored-by: Claude Sonnet 4.6 --- CLAUDE.md | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..3ea065daf87 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,96 @@ +# Unlock Protocol Monorepo + +Unlock is a decentralized membership/subscription protocol on EVM blockchains. NFT-based access control: **Locks** are smart contracts, **Keys** are NFTs granting membership. + +## Monorepo Structure + +Yarn 4.10.3 workspaces, Node >= 20 required, TypeScript throughout. + +**Applications:** +- `unlock-app/` — Main dashboard (Next.js 14, Privy auth, ethers.js v6, React Query, Tailwind) +- `locksmith/` — Backend API (Express 5, Sequelize/Postgres, Graphile Worker jobs) +- `governance-app/` — DAO governance UI +- `wedlocks/` — Email service (Cloudflare Worker, Handlebars) +- `paywall-app/` — Embeddable paywall widget (Next.js, static export) +- `provider/` — RPC proxy (Cloudflare Worker, multi-chain rate limiting) +- `unlock-protocol-com/` — Marketing site (Docusaurus) + +**Core:** +- `smart-contracts/` — Solidity (Hardhat, OpenZeppelin upgradeable proxies) +- `subgraph/` — The Graph indexer (AssemblyScript, GraphQL) +- `governance/` — DAO tooling (proposals, voting, cross-chain) +- `packages/` — 14 shared npm packages (networks, ui, unlock-js, contracts, etc.) + +## Essential Commands + +```bash +yarn # Install all deps +yarn build # Full monorepo build +yarn packages:build # Shared packages only (topological order) +yarn workspace @unlock-protocol/ # Run command in specific workspace +yarn start # Start Docker infra + all apps +yarn start:infra # Docker only (Postgres, Graph Node, IPFS) +yarn stop / yarn nuke # Stop / destroy infrastructure +yarn lint # ESLint all packages +yarn lint:contracts # Solhint for Solidity +``` + +## Testing + +Each workspace has its own test setup — always run tests from the workspace directory: + +```bash +# Smart contracts (Hardhat/Mocha) +cd smart-contracts && yarn test + +# Backend (Vitest + Supertest, requires Postgres) +cd locksmith && yarn test + +# Frontend (Vitest/jsdom) +cd unlock-app && yarn test + +# Subgraph (Matchstick) +cd subgraph && yarn test +``` + +**Practice TDD**: write tests first, minimal code to pass, refactor while green. + +## Code Style + +- **Prettier**: no semicolons, single quotes, trailing commas (es5) +- **Solidity**: 80 col, double quotes, no bracket spacing (via prettier-plugin-solidity) +- **ESLint 9**: shared config from `packages/eslint-config` +- Pre-commit: lint-staged runs prettier + eslint on staged files +- Pre-push: ESLint on changed JS files since `origin/master` +- **Never use `--no-verify`** + +## Architecture Notes + +- **Upgradeable Proxies**: Unlock (factory) deploys PublicLock (template) via TransparentUpgradeableProxy +- **Mixin Pattern**: PublicLock is composed of ~13 mixins (MixinPurchase, MixinKeys, MixinRoles, etc.) +- **Hook System**: 10 extensible hooks (purchase, transfer, extend, cancel, etc.) for custom logic +- **Multi-version**: Multiple PublicLock versions can be deployed simultaneously +- **Current versions**: Unlock v14, PublicLock v15 +- **Next.js pinned**: `"next": "14.2.35"` in root `resolutions` — do not upgrade without discussion + +## Key Tokens + +- **UDT** (Unlock Discount Token) — Legacy governance token +- **UP** (UPToken) — New governance token (UDT→UP swap available) + +## Supported Networks + +Mainnet, Optimism, BSC, Gnosis, Polygon, zkSync, zkEVM, Arbitrum, Celo, Avalanche, Base, Sepolia, Base Sepolia, Linea, Scroll (15+ total). Network config lives in `packages/networks`. + +## CI/CD + +- PRs: conditional testing based on changed files, preview deploys to Vercel +- `master`: auto-deploys to staging (Railway for locksmith, Vercel for frontends) +- Production: explicit promotion via separate PR/approval +- Secrets via 1Password service account (`op://vault/item/field` syntax) + +## PR Workflow + +- Never merge directly — always go through PR review +- After pushing, check CI status and iterate on failures automatically +- Claude Code Review runs on every PR and posts inline comments for issues From 951d9761fe909fff478b8fced5669ce8a4c2c28d Mon Sep 17 00:00:00 2001 From: Julien Genestoux <17735+julien51@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:12:55 -0400 Subject: [PATCH 4/4] feat: add governance subgraph sources (#16331) * feat: add governance subgraph sources * fix: normalize test address constants to lowercase hex Aligns governance test fixtures with toHexString() output and existing test conventions in constants.ts to avoid comparison mismatches. Co-Authored-By: Claude Opus 4.6 * fix: rename misleading timestamp fields to block fields in Proposal entity voteStart/voteEnd from OZ Governor are timepoints (block numbers), not timestamps. Rename voteStartTimestamp/voteEndTimestamp to voteStartBlock/voteEndBlock. Rename createdAtTimestamp to createdAt (stores block.timestamp, an actual Unix timestamp). Co-Authored-By: Claude Sonnet 4.6 * ci: trigger re-run for claude review debug * ci: add gh pr review to allowed tools for claude code review * revert: undo workflow change (needs its own PR to master) --------- Co-authored-by: Claude Opus 4.6 --- subgraph/bin/abis.js | 8 +- subgraph/bin/networks.js | 14 ++ subgraph/schema.graphql | 50 +++++ subgraph/src/governance.ts | 253 +++++++++++++++++++++++ subgraph/subgraph.template.yaml | 59 ++++++ subgraph/tests/governance-utils.ts | 319 +++++++++++++++++++++++++++++ subgraph/tests/governance.test.ts | 210 +++++++++++++++++++ tests/subgraph.test.yaml | 55 +++++ 8 files changed, 967 insertions(+), 1 deletion(-) create mode 100644 subgraph/src/governance.ts create mode 100644 subgraph/tests/governance-utils.ts create mode 100644 subgraph/tests/governance.test.ts diff --git a/subgraph/bin/abis.js b/subgraph/bin/abis.js index 7e12b9f1c77..b2bc6283f3f 100644 --- a/subgraph/bin/abis.js +++ b/subgraph/bin/abis.js @@ -17,6 +17,7 @@ const getVersions = (contractName) => const unlockVersions = ['v11'] const publicLockVersions = getVersions('PublicLock') +const governanceContracts = ['UPGovernor', 'UPToken'] function setupFolder() { // make sure we clean up @@ -47,8 +48,13 @@ function parseAndCopyAbis() { setupFolder() publicLockVersions.map((version) => copyAbi('PublicLock', version)) unlockVersions.map((version) => copyAbi('Unlock', version)) + governanceContracts.map((contractName) => { + const { abi } = abis[contractName] + const abiPath = path.join(abisFolderPath, `${contractName}.json`) + fs.writeJSONSync(abiPath, abi, { spaces: 2 }) + }) console.log( - `Abis file saved at: ${abisFolderPath} (PublicLock : ${publicLockVersions.toString()} - Unlock: ${unlockVersions.toString()})` + `Abis file saved at: ${abisFolderPath} (PublicLock : ${publicLockVersions.toString()} - Unlock: ${unlockVersions.toString()} - Governance: ${governanceContracts.toString()})` ) // merge diff --git a/subgraph/bin/networks.js b/subgraph/bin/networks.js index 9c4e2c77e76..a70974d9421 100644 --- a/subgraph/bin/networks.js +++ b/subgraph/bin/networks.js @@ -57,6 +57,7 @@ function setupFolderConfig() { function createNetworkConfig(network, chainName) { const name = networkName(chainName) + const governanceToken = network.tokens?.find(({ symbol }) => symbol === 'UP') const networkFile = { network: name, deployments: [ @@ -77,6 +78,19 @@ function createNetworkConfig(network, chainName) { }) } + if (network.dao?.governor && governanceToken?.address) { + networkFile.governance = { + governor: { + address: network.dao.governor, + startBlock: network.startBlock, + }, + token: { + address: governanceToken.address, + startBlock: network.startBlock, + }, + } + } + const configPath = path.join(configFolderPath, `${name}.json`) fs.writeJSONSync(configPath, networkFile, { spaces: 2 }) } diff --git a/subgraph/schema.graphql b/subgraph/schema.graphql index 5f37ee84be8..4e332463f1a 100644 --- a/subgraph/schema.graphql +++ b/subgraph/schema.graphql @@ -144,3 +144,53 @@ type ReferrerFee @entity { "In the Unlock ecosystem, a “Lock” is a smart contract that creates (or “mints”) NFTs" lock: Lock! } + +type Proposal @entity { + "On-chain proposalId as a decimal string" + id: ID! + proposer: String! + description: String! + forVotes: BigInt! + againstVotes: BigInt! + abstainVotes: BigInt! + voteStartBlock: BigInt! + voteEndBlock: BigInt! + createdAt: BigInt! + quorum: BigInt! + proposalThreshold: BigInt! + targets: [String!]! + values: [BigInt!]! + calldatas: [Bytes!]! + etaSeconds: BigInt + executedAt: BigInt + canceledAt: BigInt + transactionHash: String! + votes: [Vote!]! @derivedFrom(field: "proposal") +} + +type Vote @entity { + id: ID! + proposal: Proposal! + voter: String! + support: Int! + weight: BigInt! + reason: String + createdAt: BigInt! + transactionHash: String! +} + +type Delegate @entity { + id: ID! + delegatedTo: String! + votingPower: BigInt! + tokenBalance: BigInt! + updatedAt: BigInt! +} + +type DelegateSummary @entity { + id: ID! + totalDelegatedPower: BigInt! + delegatorCount: Int! + proposalsVoted: Int! + updatedAt: BigInt! +} diff --git a/subgraph/src/governance.ts b/subgraph/src/governance.ts new file mode 100644 index 00000000000..4550e0397c1 --- /dev/null +++ b/subgraph/src/governance.ts @@ -0,0 +1,253 @@ +import { Address, BigInt, Bytes } from '@graphprotocol/graph-ts' +import { + ProposalCanceled, + ProposalCreated, + ProposalExecuted, + ProposalQueued, + UPGovernor, + VoteCast, + VoteCastWithParams, +} from '../generated/UPGovernor/UPGovernor' +import { + DelegateChanged, + DelegateVotesChanged, + Transfer, +} from '../generated/UPToken/UPToken' +import { Delegate, DelegateSummary, Proposal, Vote } from '../generated/schema' + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' + +export function handleProposalCreated(event: ProposalCreated): void { + const id = event.params.proposalId.toString() + const proposal = new Proposal(id) + const governor = UPGovernor.bind(event.address) + const quorumCall = governor.try_quorum(event.params.voteStart) + const proposalThresholdCall = governor.try_proposalThreshold() + + proposal.proposer = event.params.proposer.toHexString() + proposal.description = event.params.description + proposal.forVotes = BigInt.zero() + proposal.againstVotes = BigInt.zero() + proposal.abstainVotes = BigInt.zero() + proposal.voteStartBlock = event.params.voteStart + proposal.voteEndBlock = event.params.voteEnd + proposal.createdAt = event.block.timestamp + proposal.quorum = quorumCall.reverted ? BigInt.zero() : quorumCall.value + proposal.proposalThreshold = proposalThresholdCall.reverted + ? BigInt.zero() + : proposalThresholdCall.value + proposal.targets = addressArrayToStrings(event.params.targets) + proposal.values = event.params.values + proposal.calldatas = event.params.calldatas + proposal.transactionHash = event.transaction.hash.toHexString() + proposal.save() +} + +export function handleVoteCast(event: VoteCast): void { + createVote( + event.params.proposalId, + event.params.voter, + event.params.support, + event.params.weight, + event.params.reason, + event.block.timestamp, + event.transaction.hash + ) +} + +export function handleVoteCastWithParams(event: VoteCastWithParams): void { + createVote( + event.params.proposalId, + event.params.voter, + event.params.support, + event.params.weight, + event.params.reason, + event.block.timestamp, + event.transaction.hash + ) +} + +export function handleProposalQueued(event: ProposalQueued): void { + const proposal = Proposal.load(event.params.proposalId.toString()) + if (!proposal) { + return + } + + proposal.etaSeconds = event.params.etaSeconds + proposal.save() +} + +export function handleProposalExecuted(event: ProposalExecuted): void { + const proposal = Proposal.load(event.params.proposalId.toString()) + if (!proposal) { + return + } + + proposal.executedAt = event.block.timestamp + proposal.save() +} + +export function handleProposalCanceled(event: ProposalCanceled): void { + const proposal = Proposal.load(event.params.proposalId.toString()) + if (!proposal) { + return + } + + proposal.canceledAt = event.block.timestamp + proposal.save() +} + +export function handleDelegateChanged(event: DelegateChanged): void { + const delegate = loadOrCreateDelegate( + event.params.delegator, + event.block.timestamp + ) + + delegate.delegatedTo = event.params.toDelegate.toHexString() + delegate.updatedAt = event.block.timestamp + delegate.save() + + updateDelegatorCount(event.params.fromDelegate, -1, event.block.timestamp) + updateDelegatorCount(event.params.toDelegate, 1, event.block.timestamp) +} + +export function handleDelegateVotesChanged(event: DelegateVotesChanged): void { + const delegate = loadOrCreateDelegate( + event.params.delegate, + event.block.timestamp + ) + delegate.votingPower = event.params.newVotes + delegate.updatedAt = event.block.timestamp + delegate.save() + + const summary = loadOrCreateDelegateSummary( + event.params.delegate, + event.block.timestamp + ) + summary.totalDelegatedPower = event.params.newVotes + summary.updatedAt = event.block.timestamp + summary.save() +} + +export function handleUPTokenTransfer(event: Transfer): void { + if (event.params.from.toHexString() != ZERO_ADDRESS) { + const fromDelegate = loadOrCreateDelegate( + event.params.from, + event.block.timestamp + ) + fromDelegate.tokenBalance = fromDelegate.tokenBalance.minus( + event.params.value + ) + fromDelegate.updatedAt = event.block.timestamp + fromDelegate.save() + } + + if (event.params.to.toHexString() != ZERO_ADDRESS) { + const toDelegate = loadOrCreateDelegate( + event.params.to, + event.block.timestamp + ) + toDelegate.tokenBalance = toDelegate.tokenBalance.plus(event.params.value) + toDelegate.updatedAt = event.block.timestamp + toDelegate.save() + } +} + +function createVote( + proposalId: BigInt, + voter: Address, + support: i32, + weight: BigInt, + reason: string, + createdAt: BigInt, + transactionHash: Bytes +): void { + const proposal = Proposal.load(proposalId.toString()) + if (!proposal) { + return + } + + const voterId = voter.toHexString() + const vote = new Vote(proposalId.toString().concat('-').concat(voterId)) + vote.proposal = proposal.id + vote.voter = voterId + vote.support = support + vote.weight = weight + vote.reason = reason + vote.createdAt = createdAt + vote.transactionHash = transactionHash.toHexString() + vote.save() + + if (support == 0) { + proposal.againstVotes = proposal.againstVotes.plus(weight) + } else if (support == 1) { + proposal.forVotes = proposal.forVotes.plus(weight) + } else if (support == 2) { + proposal.abstainVotes = proposal.abstainVotes.plus(weight) + } + proposal.save() + + const summary = loadOrCreateDelegateSummary(voter, createdAt) + summary.proposalsVoted = summary.proposalsVoted + 1 + summary.updatedAt = createdAt + summary.save() +} + +function loadOrCreateDelegate(address: Address, timestamp: BigInt): Delegate { + const id = address.toHexString() + let delegate = Delegate.load(id) + + if (!delegate) { + delegate = new Delegate(id) + delegate.delegatedTo = ZERO_ADDRESS + delegate.votingPower = BigInt.zero() + delegate.tokenBalance = BigInt.zero() + delegate.updatedAt = timestamp + } + + return delegate +} + +function loadOrCreateDelegateSummary( + address: Address, + timestamp: BigInt +): DelegateSummary { + const id = address.toHexString() + let summary = DelegateSummary.load(id) + + if (!summary) { + summary = new DelegateSummary(id) + summary.totalDelegatedPower = BigInt.zero() + summary.delegatorCount = 0 + summary.proposalsVoted = 0 + summary.updatedAt = timestamp + } + + return summary +} + +function updateDelegatorCount( + delegateAddress: Address, + delta: i32, + timestamp: BigInt +): void { + if (delegateAddress.toHexString() == ZERO_ADDRESS) { + return + } + + const summary = loadOrCreateDelegateSummary(delegateAddress, timestamp) + const nextCount = summary.delegatorCount + delta + summary.delegatorCount = nextCount < 0 ? 0 : nextCount + summary.updatedAt = timestamp + summary.save() +} + +function addressArrayToStrings(addresses: Array
): Array { + const values = new Array(addresses.length) + + for (let i = 0; i < addresses.length; i++) { + values[i] = addresses[i].toHexString() + } + + return values +} diff --git a/subgraph/subgraph.template.yaml b/subgraph/subgraph.template.yaml index 3821f2f2aab..c70c624dbb8 100644 --- a/subgraph/subgraph.template.yaml +++ b/subgraph/subgraph.template.yaml @@ -39,6 +39,65 @@ dataSources: handler: handleGNPChanged file: ./src/unlock.ts {{/deployments}} +{{#governance}} + - kind: ethereum + name: UPGovernor + network: {{ network }} + source: + abi: UPGovernor + address: '{{governor.address}}' + startBlock: {{ governor.startBlock }} + mapping: + kind: ethereum/events + apiVersion: 0.0.7 + language: wasm/assemblyscript + entities: + - Proposal + - Vote + - DelegateSummary + abis: + - name: UPGovernor + file: ./abis/UPGovernor.json + eventHandlers: + - event: ProposalCreated(uint256,address,address[],uint256[],string[],bytes[],uint256,uint256,string) + handler: handleProposalCreated + - event: VoteCast(indexed address,uint256,uint8,uint256,string) + handler: handleVoteCast + - event: VoteCastWithParams(indexed address,uint256,uint8,uint256,string,bytes) + handler: handleVoteCastWithParams + - event: ProposalQueued(uint256,uint256) + handler: handleProposalQueued + - event: ProposalExecuted(uint256) + handler: handleProposalExecuted + - event: ProposalCanceled(uint256) + handler: handleProposalCanceled + file: ./src/governance.ts + - kind: ethereum + name: UPToken + network: {{ network }} + source: + abi: UPToken + address: '{{token.address}}' + startBlock: {{ token.startBlock }} + mapping: + kind: ethereum/events + apiVersion: 0.0.7 + language: wasm/assemblyscript + entities: + - Delegate + - DelegateSummary + abis: + - name: UPToken + file: ./abis/UPToken.json + eventHandlers: + - event: DelegateChanged(indexed address,indexed address,indexed address) + handler: handleDelegateChanged + - event: DelegateVotesChanged(indexed address,uint256,uint256) + handler: handleDelegateVotesChanged + - event: Transfer(indexed address,indexed address,uint256) + handler: handleUPTokenTransfer + file: ./src/governance.ts +{{/governance}} templates: - kind: ethereum name: PublicLock diff --git a/subgraph/tests/governance-utils.ts b/subgraph/tests/governance-utils.ts new file mode 100644 index 00000000000..b872e553a4c --- /dev/null +++ b/subgraph/tests/governance-utils.ts @@ -0,0 +1,319 @@ +import { newMockEvent } from 'matchstick-as' +import { createMockedFunction } from 'matchstick-as/assembly/index' +import { Address, BigInt, Bytes, ethereum } from '@graphprotocol/graph-ts' +import { + DelegateChanged, + DelegateVotesChanged, + Transfer, +} from '../generated/UPToken/UPToken' +import { + ProposalCanceled, + ProposalCreated, + ProposalExecuted, + ProposalQueued, + VoteCast, + VoteCastWithParams, +} from '../generated/UPGovernor/UPGovernor' + +export const governorAddress = '0x00000000000000000000000000000000000000a1' +export const upTokenAddress = '0x00000000000000000000000000000000000000a2' +export const proposalId = BigInt.fromI32(1) +export const proposerAddress = '0x00000000000000000000000000000000000000b1' +export const voterAddress = '0x00000000000000000000000000000000000000b2' +export const secondVoterAddress = '0x00000000000000000000000000000000000000b6' +export const delegatorAddress = '0x00000000000000000000000000000000000000b3' +export const delegateAddress = '0x00000000000000000000000000000000000000b4' +export const secondDelegateAddress = + '0x00000000000000000000000000000000000000b5' +export const targetAddress = '0x00000000000000000000000000000000000000c1' + +export function mockGovernorCalls( + voteStart: BigInt, + quorum: BigInt, + proposalThreshold: BigInt +): void { + createMockedFunction( + Address.fromString(governorAddress), + 'quorum', + 'quorum(uint256):(uint256)' + ) + .withArgs([ethereum.Value.fromUnsignedBigInt(voteStart)]) + .returns([ethereum.Value.fromUnsignedBigInt(quorum)]) + + createMockedFunction( + Address.fromString(governorAddress), + 'proposalThreshold', + 'proposalThreshold():(uint256)' + ) + .withArgs([]) + .returns([ethereum.Value.fromUnsignedBigInt(proposalThreshold)]) +} + +export function createProposalCreatedEvent( + proposalId: BigInt, + voteStart: BigInt, + voteEnd: BigInt, + description: string +): ProposalCreated { + const event = changetype(newMockEvent()) + + event.address = Address.fromString(governorAddress) + event.block.timestamp = BigInt.fromI32(100) + event.transaction.hash = Bytes.fromHexString( + '0x1000000000000000000000000000000000000000000000000000000000000001' + ) + event.parameters = [ + new ethereum.EventParam( + 'proposalId', + ethereum.Value.fromUnsignedBigInt(proposalId) + ), + new ethereum.EventParam( + 'proposer', + ethereum.Value.fromAddress(Address.fromString(proposerAddress)) + ), + new ethereum.EventParam( + 'targets', + ethereum.Value.fromAddressArray([Address.fromString(targetAddress)]) + ), + new ethereum.EventParam( + 'values', + ethereum.Value.fromUnsignedBigIntArray([BigInt.fromI32(0)]) + ), + new ethereum.EventParam( + 'signatures', + ethereum.Value.fromStringArray(['transfer(address,uint256)']) + ), + new ethereum.EventParam( + 'calldatas', + ethereum.Value.fromBytesArray([ + Bytes.fromHexString( + '0xa9059cbb0000000000000000000000000000000000000000' + ), + ]) + ), + new ethereum.EventParam( + 'voteStart', + ethereum.Value.fromUnsignedBigInt(voteStart) + ), + new ethereum.EventParam( + 'voteEnd', + ethereum.Value.fromUnsignedBigInt(voteEnd) + ), + new ethereum.EventParam( + 'description', + ethereum.Value.fromString(description) + ), + ] + + return event +} + +export function createVoteCastEvent( + proposalId: BigInt, + support: i32, + weight: BigInt, + voter: string = voterAddress +): VoteCast { + const event = changetype(newMockEvent()) + + event.address = Address.fromString(governorAddress) + event.block.timestamp = BigInt.fromI32(110) + event.transaction.hash = Bytes.fromHexString( + '0x2000000000000000000000000000000000000000000000000000000000000002' + ) + event.parameters = [ + new ethereum.EventParam( + 'voter', + ethereum.Value.fromAddress(Address.fromString(voter)) + ), + new ethereum.EventParam( + 'proposalId', + ethereum.Value.fromUnsignedBigInt(proposalId) + ), + new ethereum.EventParam( + 'support', + ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(support)) + ), + new ethereum.EventParam( + 'weight', + ethereum.Value.fromUnsignedBigInt(weight) + ), + new ethereum.EventParam('reason', ethereum.Value.fromString('ship it')), + ] + + return event +} + +export function createVoteCastWithParamsEvent( + proposalId: BigInt, + support: i32, + weight: BigInt, + voter: string = voterAddress +): VoteCastWithParams { + const event = changetype(newMockEvent()) + + event.address = Address.fromString(governorAddress) + event.block.timestamp = BigInt.fromI32(120) + event.transaction.hash = Bytes.fromHexString( + '0x3000000000000000000000000000000000000000000000000000000000000003' + ) + event.parameters = [ + new ethereum.EventParam( + 'voter', + ethereum.Value.fromAddress(Address.fromString(voter)) + ), + new ethereum.EventParam( + 'proposalId', + ethereum.Value.fromUnsignedBigInt(proposalId) + ), + new ethereum.EventParam( + 'support', + ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(support)) + ), + new ethereum.EventParam( + 'weight', + ethereum.Value.fromUnsignedBigInt(weight) + ), + new ethereum.EventParam('reason', ethereum.Value.fromString('with params')), + new ethereum.EventParam( + 'params', + ethereum.Value.fromBytes(Bytes.fromHexString('0x1234')) + ), + ] + + return event +} + +export function createDelegateChangedEvent( + delegator: string, + fromDelegate: string, + toDelegate: string +): DelegateChanged { + const event = changetype(newMockEvent()) + + event.address = Address.fromString(upTokenAddress) + event.block.timestamp = BigInt.fromI32(130) + event.parameters = [ + new ethereum.EventParam( + 'delegator', + ethereum.Value.fromAddress(Address.fromString(delegator)) + ), + new ethereum.EventParam( + 'fromDelegate', + ethereum.Value.fromAddress(Address.fromString(fromDelegate)) + ), + new ethereum.EventParam( + 'toDelegate', + ethereum.Value.fromAddress(Address.fromString(toDelegate)) + ), + ] + + return event +} + +export function createDelegateVotesChangedEvent( + delegate: string, + previousVotes: BigInt, + newVotes: BigInt +): DelegateVotesChanged { + const event = changetype(newMockEvent()) + + event.address = Address.fromString(upTokenAddress) + event.block.timestamp = BigInt.fromI32(140) + event.parameters = [ + new ethereum.EventParam( + 'delegate', + ethereum.Value.fromAddress(Address.fromString(delegate)) + ), + new ethereum.EventParam( + 'previousVotes', + ethereum.Value.fromUnsignedBigInt(previousVotes) + ), + new ethereum.EventParam( + 'newVotes', + ethereum.Value.fromUnsignedBigInt(newVotes) + ), + ] + + return event +} + +export function createProposalQueuedEvent( + proposalId: BigInt, + etaSeconds: BigInt +): ProposalQueued { + const event = changetype(newMockEvent()) + + event.address = Address.fromString(governorAddress) + event.block.timestamp = BigInt.fromI32(160) + event.parameters = [ + new ethereum.EventParam( + 'proposalId', + ethereum.Value.fromUnsignedBigInt(proposalId) + ), + new ethereum.EventParam( + 'etaSeconds', + ethereum.Value.fromUnsignedBigInt(etaSeconds) + ), + ] + + return event +} + +export function createProposalExecutedEvent( + proposalId: BigInt +): ProposalExecuted { + const event = changetype(newMockEvent()) + + event.address = Address.fromString(governorAddress) + event.block.timestamp = BigInt.fromI32(170) + event.parameters = [ + new ethereum.EventParam( + 'proposalId', + ethereum.Value.fromUnsignedBigInt(proposalId) + ), + ] + + return event +} + +export function createProposalCanceledEvent( + proposalId: BigInt +): ProposalCanceled { + const event = changetype(newMockEvent()) + + event.address = Address.fromString(governorAddress) + event.block.timestamp = BigInt.fromI32(180) + event.parameters = [ + new ethereum.EventParam( + 'proposalId', + ethereum.Value.fromUnsignedBigInt(proposalId) + ), + ] + + return event +} + +export function createTransferEvent( + from: string, + to: string, + value: BigInt +): Transfer { + const event = changetype(newMockEvent()) + + event.address = Address.fromString(upTokenAddress) + event.block.timestamp = BigInt.fromI32(150) + event.parameters = [ + new ethereum.EventParam( + 'from', + ethereum.Value.fromAddress(Address.fromString(from)) + ), + new ethereum.EventParam( + 'to', + ethereum.Value.fromAddress(Address.fromString(to)) + ), + new ethereum.EventParam('value', ethereum.Value.fromUnsignedBigInt(value)), + ] + + return event +} diff --git a/subgraph/tests/governance.test.ts b/subgraph/tests/governance.test.ts new file mode 100644 index 00000000000..2e099d6ee84 --- /dev/null +++ b/subgraph/tests/governance.test.ts @@ -0,0 +1,210 @@ +import { + beforeEach, + assert, + clearStore, + describe, + test, +} from 'matchstick-as/assembly/index' +import { Address, BigInt } from '@graphprotocol/graph-ts' +import { + handleDelegateChanged, + handleDelegateVotesChanged, + handleProposalCanceled, + handleProposalCreated, + handleProposalExecuted, + handleProposalQueued, + handleUPTokenTransfer, + handleVoteCast, + handleVoteCastWithParams, +} from '../src/governance' +import { + createDelegateChangedEvent, + createDelegateVotesChangedEvent, + createProposalCanceledEvent, + createProposalCreatedEvent, + createProposalExecutedEvent, + createProposalQueuedEvent, + createTransferEvent, + createVoteCastEvent, + createVoteCastWithParamsEvent, + delegateAddress, + delegatorAddress, + mockGovernorCalls, + proposalId, + proposerAddress, + secondDelegateAddress, + secondVoterAddress, + voterAddress, +} from './governance-utils' +import { nullAddress } from './constants' + +const quorum = BigInt.fromString('3000000') +const proposalThreshold = BigInt.fromString('100000') +const etaSeconds = BigInt.fromI32(400) +const voteStart = BigInt.fromI32(200) +const voteEnd = BigInt.fromI32(300) + +describe('governance mappings', () => { + beforeEach(() => { + clearStore() + mockGovernorCalls(voteStart, quorum, proposalThreshold) + }) + + test('ProposalCreated creates a proposal with indexed governor metadata', () => { + handleProposalCreated( + createProposalCreatedEvent( + proposalId, + voteStart, + voteEnd, + 'Upgrade protocol\n\nShip the upgrade.' + ) + ) + + assert.entityCount('Proposal', 1) + assert.fieldEquals( + 'Proposal', + proposalId.toString(), + 'proposer', + Address.fromString(proposerAddress).toHexString() + ) + assert.fieldEquals( + 'Proposal', + proposalId.toString(), + 'description', + 'Upgrade protocol\n\nShip the upgrade.' + ) + assert.fieldEquals( + 'Proposal', + proposalId.toString(), + 'quorum', + quorum.toString() + ) + assert.fieldEquals( + 'Proposal', + proposalId.toString(), + 'proposalThreshold', + proposalThreshold.toString() + ) + assert.fieldEquals( + 'Proposal', + proposalId.toString(), + 'transactionHash', + '0x1000000000000000000000000000000000000000000000000000000000000001' + ) + }) + + test('VoteCast and VoteCastWithParams update proposal tallies and participation', () => { + handleProposalCreated( + createProposalCreatedEvent( + proposalId, + voteStart, + voteEnd, + 'Upgrade protocol\n\nShip the upgrade.' + ) + ) + + handleVoteCast(createVoteCastEvent(proposalId, 1, BigInt.fromI32(42))) + handleVoteCastWithParams( + createVoteCastWithParamsEvent( + proposalId, + 2, + BigInt.fromI32(8), + secondVoterAddress + ) + ) + + assert.entityCount('Vote', 2) + assert.fieldEquals( + 'Proposal', + proposalId.toString(), + 'forVotes', + BigInt.fromI32(42).toString() + ) + assert.fieldEquals( + 'Proposal', + proposalId.toString(), + 'abstainVotes', + BigInt.fromI32(8).toString() + ) + assert.fieldEquals('DelegateSummary', voterAddress, 'proposalsVoted', '1') + assert.fieldEquals( + 'DelegateSummary', + secondVoterAddress, + 'proposalsVoted', + '1' + ) + }) + + test('delegation handlers update delegate profile, balances, and leaderboard summary', () => { + handleDelegateChanged( + createDelegateChangedEvent(delegatorAddress, nullAddress, delegateAddress) + ) + handleDelegateVotesChanged( + createDelegateVotesChangedEvent( + delegateAddress, + BigInt.zero(), + BigInt.fromI32(500) + ) + ) + handleUPTokenTransfer( + createTransferEvent(nullAddress, delegatorAddress, BigInt.fromI32(25)) + ) + handleDelegateChanged( + createDelegateChangedEvent( + delegatorAddress, + delegateAddress, + secondDelegateAddress + ) + ) + + assert.fieldEquals( + 'Delegate', + delegatorAddress, + 'delegatedTo', + secondDelegateAddress + ) + assert.fieldEquals('Delegate', delegatorAddress, 'tokenBalance', '25') + assert.fieldEquals( + 'DelegateSummary', + delegateAddress, + 'totalDelegatedPower', + BigInt.fromI32(500).toString() + ) + assert.fieldEquals( + 'DelegateSummary', + delegateAddress, + 'delegatorCount', + '0' + ) + assert.fieldEquals( + 'DelegateSummary', + secondDelegateAddress, + 'delegatorCount', + '1' + ) + }) + + test('proposal lifecycle handlers persist queued, executed, and canceled timestamps', () => { + handleProposalCreated( + createProposalCreatedEvent( + proposalId, + voteStart, + voteEnd, + 'Upgrade protocol\n\nShip the upgrade.' + ) + ) + + handleProposalQueued(createProposalQueuedEvent(proposalId, etaSeconds)) + handleProposalExecuted(createProposalExecutedEvent(proposalId)) + handleProposalCanceled(createProposalCanceledEvent(proposalId)) + + assert.fieldEquals( + 'Proposal', + proposalId.toString(), + 'etaSeconds', + etaSeconds.toString() + ) + assert.fieldEquals('Proposal', proposalId.toString(), 'executedAt', '170') + assert.fieldEquals('Proposal', proposalId.toString(), 'canceledAt', '180') + }) +}) diff --git a/tests/subgraph.test.yaml b/tests/subgraph.test.yaml index 45edcfb72fa..e965741877d 100644 --- a/tests/subgraph.test.yaml +++ b/tests/subgraph.test.yaml @@ -36,6 +36,61 @@ dataSources: - event: GNPChanged(uint256,uint256,address,uint256,address) handler: handleGNPChanged file: ./src/unlock.ts + - kind: ethereum + name: UPGovernor + network: localhost + source: + abi: UPGovernor + address: '0x00000000000000000000000000000000000000a1' + mapping: + kind: ethereum/events + apiVersion: 0.0.7 + language: wasm/assemblyscript + entities: + - Proposal + - Vote + - DelegateSummary + abis: + - name: UPGovernor + file: ./abis/UPGovernor.json + eventHandlers: + - event: ProposalCreated(uint256,address,address[],uint256[],string[],bytes[],uint256,uint256,string) + handler: handleProposalCreated + - event: VoteCast(indexed address,uint256,uint8,uint256,string) + handler: handleVoteCast + - event: VoteCastWithParams(indexed address,uint256,uint8,uint256,string,bytes) + handler: handleVoteCastWithParams + - event: ProposalQueued(uint256,uint256) + handler: handleProposalQueued + - event: ProposalExecuted(uint256) + handler: handleProposalExecuted + - event: ProposalCanceled(uint256) + handler: handleProposalCanceled + file: ./src/governance.ts + - kind: ethereum + name: UPToken + network: localhost + source: + abi: UPToken + address: '0x00000000000000000000000000000000000000a2' + mapping: + kind: ethereum/events + apiVersion: 0.0.7 + language: wasm/assemblyscript + entities: + - Delegate + - DelegateSummary + abis: + - name: UPToken + file: ./abis/UPToken.json + eventHandlers: + - event: DelegateChanged(indexed address,indexed address,indexed address) + handler: handleDelegateChanged + - event: DelegateVotesChanged(indexed address,uint256,uint256) + handler: handleDelegateVotesChanged + - event: Transfer(indexed address,indexed address,uint256) + handler: handleUPTokenTransfer + file: ./src/governance.ts templates: - kind: ethereum name: PublicLock