Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 96 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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/<name> <cmd> # 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
8 changes: 7 additions & 1 deletion subgraph/bin/abis.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand 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
Expand Down
14 changes: 14 additions & 0 deletions subgraph/bin/networks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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 })
}
Expand Down
50 changes: 50 additions & 0 deletions subgraph/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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!
}
Loading
Loading