diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 8d2c75183cd..7ccd6c688ef 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -38,8 +38,8 @@ 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 }}' - 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' + 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' # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.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 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