diff --git a/.changeset/cruel-bobcats-like.md b/.changeset/cruel-bobcats-like.md new file mode 100644 index 0000000000..f847f8a8d6 --- /dev/null +++ b/.changeset/cruel-bobcats-like.md @@ -0,0 +1,6 @@ +--- +"create-eth": patch +--- + +- Add agent skills: x402, drizzle-neon, subgraph (https://github.com/scaffold-eth/scaffold-eth-2/pull/1240) +- Trim tier 2+3 skills to eval-proven content (83% reduction) (https://github.com/scaffold-eth/scaffold-eth-2/pull/1245) diff --git a/templates/base/.agents/skills/defi-protocol-templates/SKILL.md b/templates/base/.agents/skills/defi-protocol-templates/SKILL.md deleted file mode 100644 index fdf5480d4f..0000000000 --- a/templates/base/.agents/skills/defi-protocol-templates/SKILL.md +++ /dev/null @@ -1,442 +0,0 @@ ---- -name: defi-protocol-templates -description: Implement DeFi protocols with production-ready templates for staking, AMMs, governance, and lending systems. Use when building decentralized finance applications or smart contract protocols. ---- - -# DeFi Protocol Templates - -Production-ready templates for common DeFi protocols including staking, AMMs, governance, lending, and flash loans. - -## When to Use This Skill - -- Building staking platforms with reward distribution -- Implementing AMM (Automated Market Maker) protocols -- Creating governance token systems -- Developing lending/borrowing protocols -- Integrating flash loan functionality -- Launching yield farming platforms - -## Staking Contract - -```solidity -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; -import "@openzeppelin/contracts/access/Ownable.sol"; - -contract StakingRewards is ReentrancyGuard, Ownable { - IERC20 public stakingToken; - IERC20 public rewardsToken; - - uint256 public rewardRate = 100; // Rewards per second - uint256 public lastUpdateTime; - uint256 public rewardPerTokenStored; - - mapping(address => uint256) public userRewardPerTokenPaid; - mapping(address => uint256) public rewards; - mapping(address => uint256) public balances; - - uint256 private _totalSupply; - - event Staked(address indexed user, uint256 amount); - event Withdrawn(address indexed user, uint256 amount); - event RewardPaid(address indexed user, uint256 reward); - - constructor(address _stakingToken, address _rewardsToken) { - stakingToken = IERC20(_stakingToken); - rewardsToken = IERC20(_rewardsToken); - } - - modifier updateReward(address account) { - rewardPerTokenStored = rewardPerToken(); - lastUpdateTime = block.timestamp; - - if (account != address(0)) { - rewards[account] = earned(account); - userRewardPerTokenPaid[account] = rewardPerTokenStored; - } - _; - } - - function rewardPerToken() public view returns (uint256) { - if (_totalSupply == 0) { - return rewardPerTokenStored; - } - return rewardPerTokenStored + - ((block.timestamp - lastUpdateTime) * rewardRate * 1e18) / _totalSupply; - } - - function earned(address account) public view returns (uint256) { - return (balances[account] * - (rewardPerToken() - userRewardPerTokenPaid[account])) / 1e18 + - rewards[account]; - } - - function stake(uint256 amount) external nonReentrant updateReward(msg.sender) { - require(amount > 0, "Cannot stake 0"); - _totalSupply += amount; - balances[msg.sender] += amount; - stakingToken.transferFrom(msg.sender, address(this), amount); - emit Staked(msg.sender, amount); - } - - function withdraw(uint256 amount) public nonReentrant updateReward(msg.sender) { - require(amount > 0, "Cannot withdraw 0"); - _totalSupply -= amount; - balances[msg.sender] -= amount; - stakingToken.transfer(msg.sender, amount); - emit Withdrawn(msg.sender, amount); - } - - function getReward() public nonReentrant updateReward(msg.sender) { - uint256 reward = rewards[msg.sender]; - if (reward > 0) { - rewards[msg.sender] = 0; - rewardsToken.transfer(msg.sender, reward); - emit RewardPaid(msg.sender, reward); - } - } - - function exit() external { - withdraw(balances[msg.sender]); - getReward(); - } -} -``` - -## AMM (Automated Market Maker) - -```solidity -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -contract SimpleAMM { - IERC20 public token0; - IERC20 public token1; - - uint256 public reserve0; - uint256 public reserve1; - - uint256 public totalSupply; - mapping(address => uint256) public balanceOf; - - event Mint(address indexed to, uint256 amount); - event Burn(address indexed from, uint256 amount); - event Swap(address indexed trader, uint256 amount0In, uint256 amount1In, uint256 amount0Out, uint256 amount1Out); - - constructor(address _token0, address _token1) { - token0 = IERC20(_token0); - token1 = IERC20(_token1); - } - - function addLiquidity(uint256 amount0, uint256 amount1) external returns (uint256 shares) { - token0.transferFrom(msg.sender, address(this), amount0); - token1.transferFrom(msg.sender, address(this), amount1); - - if (totalSupply == 0) { - shares = sqrt(amount0 * amount1); - } else { - shares = min( - (amount0 * totalSupply) / reserve0, - (amount1 * totalSupply) / reserve1 - ); - } - - require(shares > 0, "Shares = 0"); - _mint(msg.sender, shares); - _update( - token0.balanceOf(address(this)), - token1.balanceOf(address(this)) - ); - - emit Mint(msg.sender, shares); - } - - function removeLiquidity(uint256 shares) external returns (uint256 amount0, uint256 amount1) { - uint256 bal0 = token0.balanceOf(address(this)); - uint256 bal1 = token1.balanceOf(address(this)); - - amount0 = (shares * bal0) / totalSupply; - amount1 = (shares * bal1) / totalSupply; - - require(amount0 > 0 && amount1 > 0, "Amount0 or amount1 = 0"); - - _burn(msg.sender, shares); - _update(bal0 - amount0, bal1 - amount1); - - token0.transfer(msg.sender, amount0); - token1.transfer(msg.sender, amount1); - - emit Burn(msg.sender, shares); - } - - function swap(address tokenIn, uint256 amountIn) external returns (uint256 amountOut) { - require(tokenIn == address(token0) || tokenIn == address(token1), "Invalid token"); - - bool isToken0 = tokenIn == address(token0); - (IERC20 tokenIn_, IERC20 tokenOut, uint256 resIn, uint256 resOut) = isToken0 - ? (token0, token1, reserve0, reserve1) - : (token1, token0, reserve1, reserve0); - - tokenIn_.transferFrom(msg.sender, address(this), amountIn); - - // 0.3% fee - uint256 amountInWithFee = (amountIn * 997) / 1000; - amountOut = (resOut * amountInWithFee) / (resIn + amountInWithFee); - - tokenOut.transfer(msg.sender, amountOut); - - _update( - token0.balanceOf(address(this)), - token1.balanceOf(address(this)) - ); - - emit Swap(msg.sender, isToken0 ? amountIn : 0, isToken0 ? 0 : amountIn, isToken0 ? 0 : amountOut, isToken0 ? amountOut : 0); - } - - function _mint(address to, uint256 amount) private { - balanceOf[to] += amount; - totalSupply += amount; - } - - function _burn(address from, uint256 amount) private { - balanceOf[from] -= amount; - totalSupply -= amount; - } - - function _update(uint256 res0, uint256 res1) private { - reserve0 = res0; - reserve1 = res1; - } - - function sqrt(uint256 y) private pure returns (uint256 z) { - if (y > 3) { - z = y; - uint256 x = y / 2 + 1; - while (x < z) { - z = x; - x = (y / x + x) / 2; - } - } else if (y != 0) { - z = 1; - } - } - - function min(uint256 x, uint256 y) private pure returns (uint256) { - return x <= y ? x : y; - } -} -``` - -## Governance Token - -```solidity -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol"; -import "@openzeppelin/contracts/access/Ownable.sol"; - -contract GovernanceToken is ERC20Votes, Ownable { - constructor() ERC20("Governance Token", "GOV") ERC20Permit("Governance Token") { - _mint(msg.sender, 1000000 * 10**decimals()); - } - - function _afterTokenTransfer( - address from, - address to, - uint256 amount - ) internal override(ERC20Votes) { - super._afterTokenTransfer(from, to, amount); - } - - function _mint(address to, uint256 amount) internal override(ERC20Votes) { - super._mint(to, amount); - } - - function _burn(address account, uint256 amount) internal override(ERC20Votes) { - super._burn(account, amount); - } -} - -contract Governor is Ownable { - GovernanceToken public governanceToken; - - struct Proposal { - uint256 id; - address proposer; - string description; - uint256 forVotes; - uint256 againstVotes; - uint256 startBlock; - uint256 endBlock; - bool executed; - mapping(address => bool) hasVoted; - } - - uint256 public proposalCount; - mapping(uint256 => Proposal) public proposals; - - uint256 public votingPeriod = 17280; // ~3 days in blocks - uint256 public proposalThreshold = 100000 * 10**18; - - event ProposalCreated(uint256 indexed proposalId, address proposer, string description); - event VoteCast(address indexed voter, uint256 indexed proposalId, bool support, uint256 weight); - event ProposalExecuted(uint256 indexed proposalId); - - constructor(address _governanceToken) { - governanceToken = GovernanceToken(_governanceToken); - } - - function propose(string memory description) external returns (uint256) { - require( - governanceToken.getPastVotes(msg.sender, block.number - 1) >= proposalThreshold, - "Proposer votes below threshold" - ); - - proposalCount++; - Proposal storage newProposal = proposals[proposalCount]; - newProposal.id = proposalCount; - newProposal.proposer = msg.sender; - newProposal.description = description; - newProposal.startBlock = block.number; - newProposal.endBlock = block.number + votingPeriod; - - emit ProposalCreated(proposalCount, msg.sender, description); - return proposalCount; - } - - function vote(uint256 proposalId, bool support) external { - Proposal storage proposal = proposals[proposalId]; - require(block.number >= proposal.startBlock, "Voting not started"); - require(block.number <= proposal.endBlock, "Voting ended"); - require(!proposal.hasVoted[msg.sender], "Already voted"); - - uint256 weight = governanceToken.getPastVotes(msg.sender, proposal.startBlock); - require(weight > 0, "No voting power"); - - proposal.hasVoted[msg.sender] = true; - - if (support) { - proposal.forVotes += weight; - } else { - proposal.againstVotes += weight; - } - - emit VoteCast(msg.sender, proposalId, support, weight); - } - - function execute(uint256 proposalId) external { - Proposal storage proposal = proposals[proposalId]; - require(block.number > proposal.endBlock, "Voting not ended"); - require(!proposal.executed, "Already executed"); - require(proposal.forVotes > proposal.againstVotes, "Proposal failed"); - - proposal.executed = true; - - // Execute proposal logic here - - emit ProposalExecuted(proposalId); - } -} -``` - -## Flash Loan - -```solidity -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -interface IFlashLoanReceiver { - function executeOperation( - address asset, - uint256 amount, - uint256 fee, - bytes calldata params - ) external returns (bool); -} - -contract FlashLoanProvider { - IERC20 public token; - uint256 public feePercentage = 9; // 0.09% fee - - event FlashLoan(address indexed borrower, uint256 amount, uint256 fee); - - constructor(address _token) { - token = IERC20(_token); - } - - function flashLoan( - address receiver, - uint256 amount, - bytes calldata params - ) external { - uint256 balanceBefore = token.balanceOf(address(this)); - require(balanceBefore >= amount, "Insufficient liquidity"); - - uint256 fee = (amount * feePercentage) / 10000; - - // Send tokens to receiver - token.transfer(receiver, amount); - - // Execute callback - require( - IFlashLoanReceiver(receiver).executeOperation( - address(token), - amount, - fee, - params - ), - "Flash loan failed" - ); - - // Verify repayment - uint256 balanceAfter = token.balanceOf(address(this)); - require(balanceAfter >= balanceBefore + fee, "Flash loan not repaid"); - - emit FlashLoan(receiver, amount, fee); - } -} - -// Example flash loan receiver -contract FlashLoanReceiver is IFlashLoanReceiver { - function executeOperation( - address asset, - uint256 amount, - uint256 fee, - bytes calldata params - ) external override returns (bool) { - // Decode params and execute arbitrage, liquidation, etc. - // ... - - // Approve repayment - IERC20(asset).approve(msg.sender, amount + fee); - - return true; - } -} -``` - -## Best Practices - -1. **Use Established Libraries**: OpenZeppelin, Solmate -2. **Test Thoroughly**: Unit tests, integration tests, fuzzing -3. **Audit Before Launch**: Professional security audits -4. **Start Simple**: MVP first, add features incrementally -5. **Monitor**: Track contract health and user activity -6. **Upgradability**: Consider proxy patterns for upgrades -7. **Emergency Controls**: Pause mechanisms for critical issues - -## Common DeFi Patterns - -- **Time-Weighted Average Price (TWAP)**: Price oracle resistance -- **Liquidity Mining**: Incentivize liquidity provision -- **Vesting**: Lock tokens with gradual release -- **Multisig**: Require multiple signatures for critical operations -- **Timelocks**: Delay execution of governance decisions diff --git a/templates/base/.agents/skills/drizzle-neon/SKILL.md b/templates/base/.agents/skills/drizzle-neon/SKILL.md new file mode 100644 index 0000000000..6da787f0e5 --- /dev/null +++ b/templates/base/.agents/skills/drizzle-neon/SKILL.md @@ -0,0 +1,297 @@ +--- +name: drizzle-neon +description: "Add a PostgreSQL database with Drizzle ORM to a Scaffold-ETH 2 project. Use when the user wants to: add a database, use Drizzle ORM, integrate Neon PostgreSQL, store off-chain data, build a backend with database, or add persistent storage to their dApp." +--- + +# Drizzle ORM + Neon PostgreSQL Integration for Scaffold-ETH 2 + +## Prerequisites + +Check if `./packages/nextjs/scaffold.config.ts` exists directly in the current working directory (do not search subdirectories). If it doesn't exist, this is not a Scaffold-ETH 2 project. Follow the instructions at https://docs.scaffoldeth.io/SKILL.md to scaffold it first. If it exists, continue directly with building. + +## Overview + +[Drizzle ORM](https://orm.drizzle.team/) is a TypeScript ORM for SQL databases with a type-safe query builder. [Neon](https://neon.tech/) is a serverless PostgreSQL platform. This skill integrates both into SE-2, with a smart database client that auto-detects the environment (local Postgres via Docker, Neon serverless, or Neon HTTP) and uses the optimal driver. + +For Drizzle API reference beyond what's covered here, refer to the [Drizzle docs](https://orm.drizzle.team/docs/overview). For Neon specifics, see the [Neon docs](https://neon.tech/docs). This skill focuses on SE-2 integration patterns and the tri-driver architecture. + +## Dependencies & Scripts + +### NextJS package + +Add to `packages/nextjs/package.json`: + +```json +{ + "scripts": { + "db:seed": "tsx services/database/seed.ts", + "db:wipe": "tsx services/database/wipe.ts", + "drizzle-kit": "drizzle-kit" + }, + "dependencies": { + "@neondatabase/serverless": "^1.0.0", + "dotenv": "^17.0.0", + "drizzle-orm": "^0.44.0", + "pg": "^8.16.0" + }, + "devDependencies": { + "@types/pg": "^8", + "drizzle-kit": "^0.31.0", + "drizzle-seed": "^0.3.0", + "tsx": "^4.20.0" + } +} +``` + +### Root package.json scripts + +```json +{ + "drizzle-kit": "yarn workspace @se-2/nextjs drizzle-kit", + "db:seed": "yarn workspace @se-2/nextjs db:seed", + "db:wipe": "yarn workspace @se-2/nextjs db:wipe" +} +``` + +### Environment variables + +Create `packages/nextjs/.env.development`: + +```env +POSTGRES_URL="postgresql://postgres:mysecretpassword@localhost:5432/postgres" +``` + +Also add `POSTGRES_URL=` to `packages/nextjs/.env.example`. + +### Docker Compose (local development) + +Create `docker-compose.yml` at project root: + +```yaml +version: "3" +services: + db: + image: postgres:16 + environment: + POSTGRES_PASSWORD: mysecretpassword + ports: + - "5432:5432" + volumes: + - ./data/db:/var/lib/postgresql/data +``` + +Add `data` to `.gitignore`. + +## Database Client Architecture + +The key integration piece is a smart database client at `packages/nextjs/services/database/config/postgresClient.ts` that auto-selects the right Postgres driver based on the connection string and runtime: + +| Connection URL contains | Runtime | Driver used | Why | +|------------------------|---------|-------------|-----| +| `neondb` | Next.js (`NEXT_RUNTIME` set) | `drizzle-orm/neon-serverless` | WebSocket-based, works in serverless | +| `neondb` | Scripts (no `NEXT_RUNTIME`) | `drizzle-orm/neon-http` | Supports `batch()` for bulk operations | +| anything else | Any | `drizzle-orm/node-postgres` | Standard `pg` Pool for local/other Postgres | + +This matters because Neon's serverless driver uses WebSockets (required in edge/serverless runtimes), while the HTTP driver is better for scripts that need batch operations. For local development with Docker, the standard `pg` driver is used. + +Reference implementation: + +```typescript +// packages/nextjs/services/database/config/postgresClient.ts +import * as schema from "./schema"; +import { Pool as NeonPool, neon } from "@neondatabase/serverless"; +import { drizzle as drizzleNeonHttp } from "drizzle-orm/neon-http"; +import { drizzle as drizzleNeon } from "drizzle-orm/neon-serverless"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; + +export const PRODUCTION_DATABASE_HOSTNAME = "your-production-database-hostname"; + +let dbInstance: ReturnType> | null = null; +let poolInstance: Pool | NeonPool | null = null; + +export function getDb() { + if (dbInstance) return dbInstance; + + const isNextRuntime = !!process.env.NEXT_RUNTIME; + + if (process.env.POSTGRES_URL?.includes("neondb")) { + if (isNextRuntime) { + poolInstance = new NeonPool({ connectionString: process.env.POSTGRES_URL }); + dbInstance = drizzleNeon(poolInstance as NeonPool, { schema, casing: "snake_case" }); + } else { + const sql = neon(process.env.POSTGRES_URL); + dbInstance = drizzleNeonHttp({ client: sql, schema, casing: "snake_case" }); + } + } else { + const pool = new Pool({ connectionString: process.env.POSTGRES_URL }); + poolInstance = pool; + dbInstance = drizzle(pool, { schema, casing: "snake_case" }); + } + + return dbInstance; +} + +export async function closeDb(): Promise { + if (poolInstance) { + await poolInstance.end(); + poolInstance = null; + dbInstance = null; + } +} +``` + +Expose the client as a lazy proxy so imports don't eagerly connect: + +```typescript +// Same file, bottom +const dbProxy = new Proxy({}, { + get: (_, prop) => { + if (prop === "close") return closeDb; + const db = getDb(); + return db[prop as keyof typeof db]; + }, +}); + +export const db = dbProxy as ReturnType & { close: () => Promise }; +``` + +## Schema Definition + +Define tables in `packages/nextjs/services/database/config/schema.ts`: + +```typescript +// packages/nextjs/services/database/config/schema.ts +import { pgTable, uuid, varchar } from "drizzle-orm/pg-core"; + +export const users = pgTable("users", { + id: uuid("id").defaultRandom().primaryKey(), + name: varchar({ length: 255 }).notNull(), +}); +``` + +For the full column types and schema API, see [Drizzle schema docs](https://orm.drizzle.team/docs/sql-schema-declaration). + +## Drizzle Config + +The `drizzle.config.ts` at `packages/nextjs/` configures Drizzle Kit for migrations and studio: + +```typescript +// packages/nextjs/drizzle.config.ts +import * as dotenv from "dotenv"; +import { defineConfig } from "drizzle-kit"; + +dotenv.config({ path: ".env.development" }); + +export default defineConfig({ + schema: "./services/database/config/schema.ts", + out: "./services/database/migrations", + dialect: "postgresql", + dbCredentials: { + url: process.env.POSTGRES_URL as string, + }, + casing: "snake_case", +}); +``` + +> **Important:** `casing: "snake_case"` must be set in **both** `drizzle.config.ts` and the `drizzle()` client initialization. This tells Drizzle to convert camelCase TypeScript property names to snake_case column names. If they don't match, queries will fail silently or return wrong data. + +## Repository Pattern + +Use a repository pattern at `packages/nextjs/services/database/repositories/`. Each entity gets its own file with typed CRUD functions: + +```typescript +// packages/nextjs/services/database/repositories/users.ts +import { users } from "../config/schema"; +import type { InferInsertModel } from "drizzle-orm"; +import { eq } from "drizzle-orm"; +import { db } from "~~/services/database/config/postgresClient"; + +export type User = InferInsertModel; + +export async function getAllUsers() { + return await db.query.users.findMany(); +} + +export async function getUserById(id: string) { + return await db.query.users.findFirst({ + where: eq(users.id, id), + }); +} + +export async function createUser(user: User) { + return await db.insert(users).values(user); +} +``` + +## Using the Database in Next.js + +Server components can import repository functions directly — no API route needed: + +```typescript +// packages/nextjs/app/users/page.tsx — Server Component +import { getAllUsers, createUser } from "~~/services/database/repositories/users"; + +const users = await getAllUsers(); // Direct DB access in server components +``` + +For client-side mutations, create an API route that calls the repository: + +```typescript +// packages/nextjs/app/api/users/route.ts +import { createUser } from "~~/services/database/repositories/users"; + +export async function POST(request: NextRequest) { + const { name } = await request.json(); + const user = await createUser({ name }); + return NextResponse.json(user); +} +``` + +SE-2 already includes `@tanstack/react-query` — use `useQuery` and `useMutation` for client-side data fetching and mutations against these API routes. + +## Database Workflow + +```bash +# Development (fast, no migrations) +docker compose up -d # Start local Postgres +yarn drizzle-kit push # Push schema directly to DB +yarn drizzle-kit studio # Open Drizzle Studio UI +yarn db:seed # Seed with test data +yarn db:wipe # Reset all tables + +# Production (stable schema) +yarn drizzle-kit generate # Generate SQL migration files +yarn drizzle-kit migrate # Apply migrations to DB +``` + +Use `push` during active development. Switch to `generate` + `migrate` when the schema is stable. Never use `push` in production. + +## Gotchas & Common Pitfalls + +**`casing: "snake_case"` must match everywhere.** Set it in both `drizzle.config.ts` and every `drizzle()` client call. Mismatched casing causes queries to reference wrong column names. + +**Don't import the `db` client in client components.** The database client only works server-side (Server Components, API routes, Server Actions). For client-side mutations, use API routes or Server Actions. + +**Docker must be running for local development.** If `docker compose up` hasn't been run, the database connection will fail. The `.env.development` points to `localhost:5432`. + +**Production safety guard.** The seed/wipe scripts should check if the connection URL points to production (via `PRODUCTION_DATABASE_HOSTNAME`). Update `your-production-database-hostname` in `postgresClient.ts` to your actual Neon project hostname to enable this protection. + +**Use `.env.development` not `.env.local`.** SE-2 convention is `.env.development` for local env vars. + +## How to Test + +1. `docker compose up -d` — start local Postgres +2. `yarn drizzle-kit push` — apply schema to local DB +3. `yarn start` — start Next.js +4. Visit the users page — should show empty list, add a user via the form +5. `yarn db:seed` — seed with test data +6. `yarn drizzle-kit studio` — inspect data at `https://local.drizzle.studio` + +### Production (Neon) + +1. Create a Neon project at [neon.tech](https://neon.tech/) +2. Set `POSTGRES_URL` to the Neon connection string (contains `neondb`) +3. Update `PRODUCTION_DATABASE_HOSTNAME` in `postgresClient.ts` to your Neon project hostname +4. Run `yarn drizzle-kit generate` then `yarn drizzle-kit migrate` +5. The database client auto-switches to Neon's serverless driver diff --git a/templates/base/.agents/skills/eip-5792/SKILL.md b/templates/base/.agents/skills/eip-5792/SKILL.md index 8ce2d1aa71..2db2180874 100644 --- a/templates/base/.agents/skills/eip-5792/SKILL.md +++ b/templates/base/.agents/skills/eip-5792/SKILL.md @@ -11,26 +11,19 @@ Check if `./packages/nextjs/scaffold.config.ts` exists directly in the current w ## Overview -[EIP-5792](https://eips.ethereum.org/EIPS/eip-5792) (Wallet Call API) lets apps send batched onchain write calls to wallets via `wallet_sendCalls`, check their status with `wallet_getCallsStatus`, and query wallet capabilities with `wallet_getCapabilities`. This replaces the one-tx-at-a-time pattern of `eth_sendTransaction`. - -This skill covers integrating EIP-5792 batched transactions into an SE-2 project using [wagmi's EIP-5792 hooks](https://wagmi.sh/react/api/hooks/useWriteContracts). For anything not covered here, refer to the [EIP-5792 docs](https://www.eip5792.xyz/) or [wagmi docs](https://wagmi.sh/). This skill focuses on SE-2 integration specifics and the wallet compatibility gotchas that trip people up. - -## Dependencies - -No new dependencies needed. SE-2 already includes wagmi, which has the EIP-5792 hooks. The experimental hooks live at `wagmi/experimental`: +[EIP-5792](https://eips.ethereum.org/EIPS/eip-5792) (Wallet Call API) lets apps send batched onchain write calls to wallets via `wallet_sendCalls`. No new dependencies needed — SE-2 already includes wagmi, which has the EIP-5792 hooks at `wagmi/experimental`: - [`useWriteContracts`](https://wagmi.sh/react/api/hooks/useWriteContracts) — batch multiple contract calls into one wallet request - [`useCapabilities`](https://wagmi.sh/react/api/hooks/useCapabilities) — detect what the connected wallet supports (batching, paymasters, etc.) - [`useShowCallsStatus`](https://wagmi.sh/react/api/hooks/useShowCallsStatus) — ask the wallet to display status of a batch -> **Import paths are moving.** `useCapabilities` and `useShowCallsStatus` have been promoted to `wagmi` (stable). `useWriteContracts` is still in `wagmi/experimental` as of early 2026. Always check the [wagmi docs](https://wagmi.sh/) for the current import paths — they may have changed. +> **Import paths are moving.** `useCapabilities` and `useShowCallsStatus` have been promoted to `wagmi` (stable). `useWriteContracts` is still in `wagmi/experimental` as of early 2026. Always check the [wagmi docs](https://wagmi.sh/) for the current import paths. ## Smart Contract -EIP-5792 works with any contract — there's nothing special about the contract side. The point is batching multiple calls (to one or more contracts) into a single wallet interaction. For a demo, a simple contract with two or more state-changing functions works well so users can see them batched: +EIP-5792 works with any contract — the point is batching multiple calls into a single wallet interaction. A simple contract with two or more state-changing functions works well for demonstrating batching: ```solidity -// Syntax reference — adapt to the user's actual needs contract BatchExample { string public greeting = "Hello!"; uint256 public counter = 0; @@ -66,11 +59,11 @@ const { isSuccess: isEIP5792Wallet, data: walletCapabilities } = useCapabilities const isPaymasterSupported = walletCapabilities?.[chainId]?.paymasterService?.supported; ``` -`isSuccess` being `true` means the wallet responded to `wallet_getCapabilities` — i.e., it's EIP-5792 compliant. The `data` object is keyed by chain ID, with each chain listing its supported capabilities. +`isSuccess` being `true` means the wallet responded to `wallet_getCapabilities` — i.e., it's EIP-5792 compliant. ### Batching contract calls -Use `useWriteContracts` to send multiple calls in one wallet interaction. You need the contract ABI and address — get these from SE-2's `useDeployedContractInfo` hook: +Use `useWriteContracts` to send multiple calls in one wallet interaction. Get the contract ABI and address from SE-2's `useDeployedContractInfo` hook: ```tsx import { useWriteContracts } from "wagmi/experimental"; @@ -94,56 +87,38 @@ const result = await writeContractsAsync({ functionName: "incrementCounter", }, ], - // Optional: add capabilities like paymaster + // Optional: add paymaster capability if supported by the wallet capabilities: isPaymasterSupported ? { paymasterService: { url: paymasterURL } } : undefined, }); ``` -The `result` contains an `id` that can be used to check status. - ### Showing batch status -Use `useShowCallsStatus` to let the wallet display the status of a batch: - ```tsx import { useShowCallsStatus } from "wagmi/experimental"; const { showCallsStatusAsync } = useShowCallsStatus(); -// After getting a batch ID from writeContractsAsync: await showCallsStatusAsync({ id: batchId }); ``` -This opens the wallet's native UI for showing transaction status — the app doesn't need to build its own status tracker. - -## Wallet Compatibility Gotchas - -This is the main source of confusion with EIP-5792. Not all wallets behave the same way: - -**SE-2's burner wallet supports EIP-5792 with sequential (non-atomic) calls.** It handles `wallet_sendCalls` by executing calls one at a time. However, advanced capabilities like paymasters and atomic execution aren't supported on the burner wallet or local chain. Test those features on a live testnet with a compliant wallet. - -**Coinbase Wallet is the most complete implementation.** It supports batching, paymasters (via [ERC-7677](https://eips.ethereum.org/EIPS/eip-7677)), and atomic execution. [MetaMask has partial support](https://www.eip5792.xyz/ecosystem/wallets). Check the [EIP-5792 ecosystem page](https://www.eip5792.xyz/ecosystem/wallets) for the current list. - -**Capabilities vary by chain.** A wallet might support paymasters on Base but not on Ethereum mainnet. Always check `walletCapabilities?.[chainId]` for the specific chain the user is on, not just whether the wallet is EIP-5792 compliant in general. - -**Paymaster integration (ERC-7677) is optional.** If you want gas sponsorship, you need a paymaster service URL. This is passed as a `capability` in the `writeContracts` call. The paymaster service is external to SE-2 — you'll need to set one up (e.g., via [Coinbase Developer Platform](https://docs.cdp.coinbase.com/paymaster/docs/welcome) or other providers). +## Wallet Compatibility & Graceful Fallback -**Graceful degradation is important.** The UI should work for both EIP-5792 and non-EIP-5792 wallets. Use SE-2's standard `useScaffoldWriteContract` for individual calls as a fallback, and only show the batch button when `useCapabilities` succeeds. Consider offering a "switch to Coinbase Wallet" prompt when the connected wallet doesn't support EIP-5792. +**Graceful degradation is critical.** The UI must work for both EIP-5792 and non-EIP-5792 wallets: +- Use SE-2's `useScaffoldWriteContract` for individual calls as fallback +- Only show/enable the batch button when `useCapabilities` succeeds (`isEIP5792Wallet`) +- Consider a "switch to Coinbase Wallet" prompt for unsupported wallets -## Frontend +**Capabilities vary by chain.** Always check `walletCapabilities?.[chainId]` for the specific chain, not just whether the wallet is EIP-5792 compliant in general. -Build a page that demonstrates both individual and batched contract interactions. The key UX pattern: +**SE-2's burner wallet supports EIP-5792** with sequential (non-atomic) calls. Advanced capabilities like paymasters require a live testnet with a compliant wallet (Coinbase Wallet has the most complete implementation). -1. **Read state** — use `useScaffoldReadContract` to show current contract values (these update after transactions) -2. **Individual writes** — use `useScaffoldWriteContract` for single calls (works with any wallet) -3. **Batched writes** — use `useWriteContracts` for the EIP-5792 batch (only enabled when wallet supports it) -4. **Status display** — use `useShowCallsStatus` to show batch result -5. **Wallet detection** — conditionally show/disable batch UI based on `useCapabilities` +**Paymaster integration (ERC-7677) is optional.** If you want gas sponsorship, you need a paymaster service URL passed as a `capability` in the `writeContracts` call. The paymaster service is external to SE-2. ## How to Test 1. Deploy the contract: `yarn deploy` 2. Start the frontend: `yarn start` -3. For basic batching: use any wallet on localhost +3. For basic batching: use any wallet on localhost (SE-2's burner wallet works) 4. For advanced capabilities (paymasters, atomic execution): deploy to a live testnet and connect with an [EIP-5792 compliant wallet](https://www.eip5792.xyz/ecosystem/wallets) diff --git a/templates/base/.agents/skills/eip-712/SKILL.md b/templates/base/.agents/skills/eip-712/SKILL.md deleted file mode 100644 index 8191d92cd3..0000000000 --- a/templates/base/.agents/skills/eip-712/SKILL.md +++ /dev/null @@ -1,229 +0,0 @@ ---- -name: eip-712 -description: "Add EIP-712 typed structured data signing and verification to a Scaffold-ETH 2 project. Use when the user wants to: sign typed data, verify signatures, implement EIP-712, add off-chain signing, build a signature verification UI, or work with eth_signTypedData_v4." ---- - -# EIP-712 Typed Data Signing for Scaffold-ETH 2 - -## Prerequisites - -Check if `./packages/nextjs/scaffold.config.ts` exists directly in the current working directory (do not search subdirectories). If it doesn't exist, this is not a Scaffold-ETH 2 project. Follow the instructions at https://docs.scaffoldeth.io/SKILL.md to scaffold it first. If it exists, continue directly with building. - -## Overview - -[EIP-712](https://eips.ethereum.org/EIPS/eip-712) defines a standard for hashing and signing typed structured data in Ethereum. Instead of signing opaque hex blobs, wallets display human-readable fields (names, addresses, messages) so users can see exactly what they're signing — reducing phishing risk and user errors. - -This skill covers integrating EIP-712 signing and verification (both frontend and backend) into an SE-2 project using [wagmi hooks](https://wagmi.sh/react/api/hooks/useSignTypedData) and [viem utilities](https://viem.sh/docs/actions/public/verifyTypedData). This skill focuses on SE-2 integration specifics and gotchas, not a complete reference. For anything not covered here, refer to the [EIP-712 specification](https://eips.ethereum.org/EIPS/eip-712) or the [wagmi](https://wagmi.sh/) / [viem](https://viem.sh/) docs. - -## EIP-712 Concepts - -### Domain separator - -Every EIP-712 signature is scoped to a **domain** that prevents cross-application replay attacks. The domain object typically includes: - -| Field | Type | Purpose | -|-------|------|---------| -| `name` | `string` | Human-readable app name | -| `version` | `string` | Signing schema version | -| `chainId` | `uint256` | Prevents cross-chain replay (optional but recommended) | -| `verifyingContract` | `address` | Ties signature to a specific contract (optional) | -| `salt` | `bytes32` | Extra disambiguation (rarely needed) | - -**Only `name` and `version` are required.** Add `chainId` and `verifyingContract` when the signature will be verified on-chain — they prevent replay across chains or contracts. For purely off-chain verification, `name` + `version` is sufficient. - -### Type definitions - -EIP-712 types follow a specific format — an object where each key is a type name mapping to an array of `{ name, type }` field definitions: - -```typescript -// Syntax reference — adapt types to your domain -const types = { - Person: [ - { name: "name", type: "string" }, - { name: "wallet", type: "address" }, - ], - Mail: [ - { name: "from", type: "Person" }, - { name: "to", type: "Person" }, - { name: "contents", type: "string" }, - ], -} as const; -``` - -Supported atomic types: `address`, `bool`, `string`, `bytes`, `bytesN` (1-32), `intN`, `uintN` (8-256 in steps of 8). Custom struct types (like `Person` above) are referenced by name. Arrays use `TypeName[]` syntax. - -**The `primaryType` must match one of the top-level keys** in the types object — it tells the wallet which type is the root message being signed. - -### Signing vs verification - -| Operation | Where | Tool | Function | -|-----------|-------|------|----------| -| Sign typed data | Client (wallet) | wagmi | `useSignTypedData` → calls `eth_signTypedData_v4` | -| Verify (frontend) | Client (browser) | wagmi | `useVerifyTypedData` | -| Verify (backend) | Server (API route) | viem | `recoverTypedDataAddress` | -| Verify (on-chain) | Smart contract | Solidity | `ecrecover` with EIP-712 hash | - -## EIP-712 Integration Pattern - -### Utility module - -Create a shared utility file for domain, types, and message generation. Both the page and API route import from here to stay in sync: - -```typescript -// packages/nextjs/utils/eip-712.ts — adapt to your use case -import { Address, SignTypedDataReturnType } from "viem"; - -export const EIP_712_DOMAIN = { - name: "My App", - version: "1", - // Add chainId / verifyingContract if doing on-chain verification -} as const; - -export const EIP_712_TYPE = { - // Define your struct types here - Person: [ - { name: "name", type: "string" }, - { name: "wallet", type: "address" }, - ], - Mail: [ - { name: "from", type: "Person" }, - { name: "to", type: "Person" }, - { name: "contents", type: "string" }, - ], -} as const; - -// Helper to construct a typed message -export function generateMessage({ - fromName, - fromAddress, - toName, - toAddress, - contents, -}: { - fromName: string; - fromAddress?: string; - toName: string; - toAddress: string; - contents: string; -}) { - return { - from: { name: fromName, wallet: fromAddress || "" }, - to: { name: toName, wallet: toAddress }, - contents, - }; -} - -export type VerifyRequestBody = { - fromName: string; - message: string; - signature: SignTypedDataReturnType; - signer: Address; -}; -``` - -**Key pattern:** The domain, types, and message construction must be identical on both signing and verification sides. Extract them into a shared module — mismatch is the #1 cause of verification failures. - -### Signing typed data - -Use wagmi's `useSignTypedData` hook. This calls `eth_signTypedData_v4` under the hood, which prompts the wallet to display the structured data for user review: - -```tsx -import { useSignTypedData } from "wagmi"; -import { useAccount } from "wagmi"; -import { EIP_712_DOMAIN, EIP_712_TYPE, generateMessage } from "~~/utils/eip-712"; -import { getParsedError, notification } from "~~/utils/scaffold-eth"; - -const { address } = useAccount(); -const { signTypedDataAsync } = useSignTypedData(); - -const typedData = { - domain: EIP_712_DOMAIN, - types: EIP_712_TYPE, - primaryType: "Mail" as const, - message: generateMessage({ /* ... */ }), -}; - -try { - const signature = await signTypedDataAsync(typedData); - // signature is a hex string (0x...) -} catch (e) { - notification.error(getParsedError(e)); -} -``` - -### Frontend verification - -Use wagmi's `useVerifyTypedData` hook to verify a signature client-side. It's reactive — pass the same typed data plus the signer's address and signature: - -```tsx -import { useVerifyTypedData } from "wagmi"; - -const { data: isValid } = useVerifyTypedData({ - domain: EIP_712_DOMAIN, - types: EIP_712_TYPE, - primaryType: "Mail", - message: generateMessage({ /* same params used during signing */ }), - address: signerAddress, - signature, -}); -``` - -`isValid` is `true` if the signature matches the given address and typed data. **The typed data must be identical to what was signed** — any difference (even whitespace in strings) produces a different hash and verification fails. - -### Backend verification (API route) - -Use viem's `recoverTypedDataAddress` in a Next.js API route to verify server-side without trusting the client: - -```typescript -// packages/nextjs/app/api/verify/route.ts -import { NextResponse } from "next/server"; -import { recoverTypedDataAddress } from "viem"; -import { EIP_712_DOMAIN, EIP_712_TYPE, VerifyRequestBody, generateMessage } from "~~/utils/eip-712"; - -export async function POST(req: Request) { - try { - const { fromName, message, signature, signer } = (await req.json()) as VerifyRequestBody; - - const recoveredAddress = await recoverTypedDataAddress({ - domain: EIP_712_DOMAIN, - types: EIP_712_TYPE, - primaryType: "Mail", - message: generateMessage({ fromName, fromAddress: signer, toName: "Bob", toAddress: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB", contents: message }), - signature, - }); - - if (recoveredAddress !== signer) { - return NextResponse.json({ error: "Signature verification failed" }, { status: 401 }); - } - - return NextResponse.json({ message: "Verified" }, { status: 200 }); - } catch (e) { - console.error(e); - return NextResponse.json({ error: "Verification error" }, { status: 500 }); - } -} -``` - -## Gotchas and Pitfalls - -**Domain/type mismatch between signing and verification.** This is the most common bug. If the domain, types, or message object differ even slightly between the signing call and the verification call, the EIP-712 hash changes and verification fails silently (returns a different address). Always import from the same shared utility module. - -**`primaryType` must match a key in `types`.** If your types object has `{ Mail: [...], Person: [...] }`, then `primaryType` must be `"Mail"` or `"Person"`. A typo here causes a runtime error. - -**Wallet support for `eth_signTypedData_v4`.** Most modern wallets (MetaMask, Coinbase Wallet, Rainbow, etc.) support v4. SE-2's burner wallet also supports it, so you can test locally without an external wallet. Older wallets may only support v3 (no array types, no nested structs). - -**`as const` is critical for TypeScript.** Without `as const` on the domain and types objects, TypeScript widens string literals and wagmi/viem type inference breaks. You'll get confusing type errors about missing properties. - -**`chainId` replay protection.** If you omit `chainId` from the domain, the same signature is valid on all chains. This is fine for off-chain-only use, but dangerous if the signature authorizes on-chain actions (e.g., permit signatures). Include `chainId` when signatures interact with contracts. - -**Empty address handling.** When the wallet isn't connected, `address` is `undefined`. The utility function should handle this gracefully (e.g., default to empty string) rather than passing `undefined` into the typed data, which would cause a signing error. - -**On-chain verification.** For verifying EIP-712 signatures in Solidity (e.g., meta-transactions, permits), use OpenZeppelin's `EIP712` base contract and `ECDSA.recover`. The domain separator must be constructed identically in both the contract and the frontend. This is a more advanced pattern — see the [OpenZeppelin EIP-712 docs](https://docs.openzeppelin.com/contracts/5.x/api/utils#EIP712). - -## How to Test - -1. Start the frontend: `yarn start` -2. Connect a wallet — the burner wallet, MetaMask, Coinbase Wallet all support `eth_signTypedData_v4` -3. Fill in the name and message fields, click Sign, review the typed data in the wallet popup -4. Verify on frontend (instant, client-side) or backend (API route call) -5. To test verification failure: change the name or message after signing, then verify — it should fail diff --git a/templates/base/.agents/skills/erc-20/SKILL.md b/templates/base/.agents/skills/erc-20/SKILL.md deleted file mode 100644 index b62ea673d9..0000000000 --- a/templates/base/.agents/skills/erc-20/SKILL.md +++ /dev/null @@ -1,159 +0,0 @@ ---- -name: erc-20 -description: "Add an ERC-20 token contract to a Scaffold-ETH 2 project. Use when the user wants to: create a fungible token, deploy an ERC-20, add token minting, build a token transfer UI, or work with ERC-20 tokens in SE-2." ---- - -# ERC-20 Token Integration for Scaffold-ETH 2 - -## Prerequisites - -Check if `./packages/nextjs/scaffold.config.ts` exists directly in the current working directory (do not search subdirectories). If it doesn't exist, this is not a Scaffold-ETH 2 project. Follow the instructions at https://docs.scaffoldeth.io/SKILL.md to scaffold it first. If it exists, continue directly with building. - -## Overview - -[ERC-20](https://eips.ethereum.org/EIPS/eip-20) is the standard interface for fungible tokens on Ethereum. This skill covers adding an ERC-20 token contract to a Scaffold-ETH 2 project using [OpenZeppelin's ERC-20 implementation](https://docs.openzeppelin.com/contracts/5.x/erc20), along with deployment scripts and a frontend for interacting with the token. - -For anything not covered here, refer to the [OpenZeppelin ERC-20 docs](https://docs.openzeppelin.com/contracts/5.x/api/token/erc20) or search the web. This skill focuses on what's hard to discover: SE-2 integration specifics, common pitfalls, and ERC-20 gotchas that trip up both humans and AI. - -## Dependencies - -OpenZeppelin contracts are already included in SE-2's Hardhat and Foundry setups, so no additional dependency installation is needed. If for some reason they're missing: - -- **Hardhat**: `@openzeppelin/contracts` in `packages/hardhat/package.json` -- **Foundry**: installed via `forge install OpenZeppelin/openzeppelin-contracts`, with remapping `@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/` - -No new frontend dependencies are required. - -## Smart Contract - -The token contract extends OpenZeppelin's `ERC20` base. Import path: `@openzeppelin/contracts/token/ERC20/ERC20.sol`. The constructor takes a token name and symbol. Beyond that, add whatever minting/access control logic the project needs. - -Syntax reference for a basic token with open minting: - -```solidity -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.0 <0.9.0; - -import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; - -contract MyToken is ERC20 { - constructor() ERC20("MyToken", "MTK") {} - - function mint(address to, uint256 amount) public { - _mint(to, amount); - } -} -``` - -Adapt the contract name, symbol, and minting logic based on the user's requirements. Common extensions (all under `@openzeppelin/contracts/token/ERC20/extensions/`): - -- **`ERC20Capped`**: enforces a maximum supply, set once in constructor as immutable -- **`ERC20Burnable`**: adds `burn(amount)` and `burnFrom(account, amount)` for holders to destroy tokens -- **`ERC20Pausable`**: lets an admin freeze all transfers (useful for emergency stops or regulatory compliance) -- **`ERC20Permit`** (ERC-2612): gasless approvals via off-chain signatures, effectively standard for new tokens now -- **`ERC20Votes`**: governance checkpoints, tracks historical voting power per address. Replaces the deprecated `ERC20Snapshot` from v4 -- **`ERC20FlashMint`** (ERC-3156): flash loan minting, tokens are minted and must be returned (+fee) within a single transaction -- **Access-controlled minting**: use `Ownable` or `AccessControl` from OpenZeppelin - -See [OpenZeppelin's ERC-20 extensions](https://docs.openzeppelin.com/contracts/5.x/api/token/erc20#extensions) for the full list. The [Contracts Wizard](https://wizard.openzeppelin.com/) is useful for generating a starting template with specific features. - -### OpenZeppelin v5 changes to be aware of - -If referencing older tutorials or code, note these breaking changes in OpenZeppelin v5: - -- **`_beforeTokenTransfer` and `_afterTokenTransfer` hooks are gone.** Replaced by a single `_update(address from, address to, uint256 value)` override point for customizing mint, transfer, and burn behavior. -- **`increaseAllowance()` and `decreaseAllowance()` were removed** from the base contract. -- **Custom errors** replaced revert strings (e.g. `ERC20InsufficientBalance` instead of `require(balance >= amount, "...")`) -- **Explicit named imports are required**: `import {ERC20} from "..."` not `import "..."` - -## Decimals: The Most Common Source of Bugs - -ERC-20 tokens default to 18 decimals, but many major tokens use different values. Getting this wrong causes balances to display as astronomically wrong numbers or makes contract math silently produce garbage. - -| Token | Decimals | Why it matters | -|-------|----------|----------------| -| USDC | 6 | The most used stablecoin in DeFi uses 6, not 18 | -| USDT | 6 | Same as USDC | -| WBTC | 8 | Mirrors Bitcoin's satoshi precision | -| DAI | 18 | Standard | -| WETH | 18 | Standard | - -**Frontend impact**: `formatEther` from viem assumes 18 decimals. For tokens with different decimals, use `formatUnits(value, decimals)` instead. Similarly, use `parseUnits(amount, decimals)` instead of `parseEther`. - -**Contract math impact**: When performing arithmetic between tokens with different decimals, you must normalize. A raw value of `1000000` means 1.0 USDC (6 decimals) but 0.000000000001 for an 18-decimal token. Always call `decimals()` and normalize rather than hardcoding 18. - -## Gotchas and Non-Standard Behaviors in the Wild - -These are real behaviors of deployed tokens that break common assumptions. Important when building contracts or frontends that interact with existing ERC-20 tokens. - -### Missing return values - -Per the standard, `transfer()` and `transferFrom()` should return `bool`. In practice, USDT, BNB, and OMG return `void` (no return data). Calling these through the standard `IERC20` interface reverts because Solidity's ABI decoder expects 32 bytes of return data and gets 0. - -**Solution**: Use OpenZeppelin's `SafeERC20` wrapper, which handles both no-return-value and false-return tokens: - -```solidity -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -using SafeERC20 for IERC20; -token.safeTransfer(to, amount); // instead of token.transfer(to, amount) -token.safeTransferFrom(from, to, amount); -token.forceApprove(spender, amount); // handles USDT's approve-to-zero requirement -``` - -### USDT's approve-to-zero requirement - -USDT's `approve` function reverts if you set a non-zero allowance when the current allowance is already non-zero. You must first `approve(spender, 0)` then `approve(spender, newAmount)`. SafeERC20's `forceApprove()` handles this automatically. - -### Upgradeable proxies - -USDC and USDT are deployed behind upgradeable proxies. The token admin can change the implementation at any time, potentially altering transfer semantics or adding fees. USDC and USDT both have fee infrastructure built in (currently set to 0%) that could be activated in the future. - -### Fee-on-transfer tokens - -Some tokens deduct a percentage on every transfer (e.g. PAXG has a 0.02% fee). This breaks any contract that assumes `amount sent == amount received`. The safe pattern is to measure the actual balance change: - -```solidity -uint256 balanceBefore = token.balanceOf(address(this)); -token.safeTransferFrom(user, address(this), amount); -uint256 received = token.balanceOf(address(this)) - balanceBefore; -``` - -### Rebasing tokens - -Tokens like stETH and AMPL change balances without any transfer event. `balanceOf()` returns different values at different times for the same holder. Any contract that caches balances will have wrong accounting. Use the wrapped version (wstETH instead of stETH) which has stable balances. - -## Security Considerations - -### Approve/transferFrom front-running (the race condition) - -When Alice changes an approval from 100 to 50, a malicious Bob can front-run the second `approve` by spending the full 100, then spend the new 50 after it lands. Total stolen: 150 instead of 50. - -Mitigations: -- Approve to zero first, then set the new value (two transactions) -- Use `SafeERC20.forceApprove()` which handles this -- Use [Permit2](https://github.com/Uniswap/permit2) for a universal signature-based approval system - -### ERC-777 reentrancy via transfer hooks - -ERC-777 tokens implement `tokensToSend` and `tokensReceived` hooks that fire during transfers. These tokens are backward-compatible with ERC-20, so protocols may unknowingly accept them. The imBTC/Uniswap V1 exploit drained ~$300K and the dForce/Lendf.Me exploit stole $25M using this vector. - -Mitigation: Use `nonReentrant` modifier from OpenZeppelin on any function that interacts with arbitrary ERC-20 tokens. Follow the checks-effects-interactions pattern. - -### Flash loan governance attacks - -Any governance mechanism based on token balance at call time can be manipulated: borrow tokens via flash loan, vote, return tokens. Use `ERC20Votes` with checkpoints instead of raw `balanceOf()` for governance. - -## Well-Known Token Addresses (Ethereum Mainnet) - -For reference when integrating with existing tokens. All verified on [Etherscan](https://etherscan.io/tokens). - -| Token | Address | Decimals | Quirks | -|-------|---------|----------|--------| -| USDT | `0xdAC17F958D2ee523a2206206994597C13D831ec7` | 6 | No return value, approve-to-zero required, blocklist, pausable, upgradeable | -| USDC | `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48` | 6 | Blocklist, pausable, upgradeable | -| DAI | `0x6B175474E89094C44Da98b954EedeAC495271d0F` | 18 | Non-standard permit signature, flash-mintable | -| WETH | `0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2` | 18 | Has `deposit()`/`withdraw()`, no permit | -| WBTC | `0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599` | 8 | Standard ERC-20 | -| LINK | `0x514910771AF9Ca656af840dff83E8264EcF986CA` | 18 | Implements ERC-677 (`transferAndCall`) | \ No newline at end of file diff --git a/templates/base/.agents/skills/erc-721/SKILL.md b/templates/base/.agents/skills/erc-721/SKILL.md index 402d0f1786..3b23970136 100644 --- a/templates/base/.agents/skills/erc-721/SKILL.md +++ b/templates/base/.agents/skills/erc-721/SKILL.md @@ -9,88 +9,47 @@ description: "Add an ERC-721 NFT contract to a Scaffold-ETH 2 project. Use when Check if `./packages/nextjs/scaffold.config.ts` exists directly in the current working directory (do not search subdirectories). If it doesn't exist, this is not a Scaffold-ETH 2 project. Follow the instructions at https://docs.scaffoldeth.io/SKILL.md to scaffold it first. If it exists, continue directly with building. -## Overview +Also read `.agents/skills/openzeppelin/SKILL.md` since ERC-721 contracts use OpenZeppelin, and that skill has critical guidance on reading the installed source for correct import syntax, override points, and constructor patterns. -[ERC-721](https://eips.ethereum.org/EIPS/eip-721) is the standard interface for non-fungible tokens (NFTs) on Ethereum. This skill covers adding an ERC-721 contract to a Scaffold-ETH 2 project using [OpenZeppelin's ERC-721 implementation](https://docs.openzeppelin.com/contracts/5.x/erc721), along with deployment scripts and a frontend for minting, listing, and transferring NFTs. +## Gotchas -For anything not covered here, refer to the [OpenZeppelin ERC-721 docs](https://docs.openzeppelin.com/contracts/5.x/api/token/erc721) or search the web. This skill focuses on what's hard to discover: SE-2 integration specifics, common pitfalls, and ERC-721 gotchas. +Key pitfalls and gotchas to watch for when working with ERC-721. -## Dependencies +### 1. `_safeMint` Reentrancy -OpenZeppelin contracts are already included in SE-2's Hardhat and Foundry setups, so no additional dependency installation is needed. If for some reason they're missing: +`_safeMint` and `safeTransferFrom` invoke `onERC721Received()` on the recipient if it's a contract. This is an external call **after** the token has been minted, creating a reentrancy vector. -- **Hardhat**: `@openzeppelin/contracts` in `packages/hardhat/package.json` -- **Foundry**: installed via `forge install OpenZeppelin/openzeppelin-contracts`, with remapping `@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/` - -No new frontend dependencies are required. - -## Smart Contract - -The token contract extends OpenZeppelin's `ERC721` base. Import path: `@openzeppelin/contracts/token/ERC721/ERC721.sol`. The constructor takes a token name and symbol. - -Syntax reference for a basic NFT with open minting and IPFS metadata: +**Real exploit (HypeBears, Feb 2022):** State updated after `_safeMint` allowed attacker to re-enter and bypass per-address minting limits. ```solidity -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.0 <0.9.0; - -import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; -import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol"; - -contract MyNFT is ERC721Enumerable { - uint256 public tokenIdCounter; - - constructor() ERC721("MyNFT", "MNFT") {} - - function mintItem(address to) public returns (uint256) { - tokenIdCounter++; - _safeMint(to, tokenIdCounter); - return tokenIdCounter; - } - - function tokenURI(uint256 tokenId) public view override returns (string memory) { - _requireOwned(tokenId); - return string.concat(_baseURI(), Strings.toString(tokenId)); - } +// VULNERABLE: state update after _safeMint +function mintNFT() public { + require(!addressMinted[msg.sender], "Already minted"); + _safeMint(msg.sender, tokenId); + addressMinted[msg.sender] = true; // too late +} - function _baseURI() internal pure override returns (string memory) { - return "ipfs://YourCID/"; - } +// SAFE: state update before _safeMint +function mintNFT() public { + require(!addressMinted[msg.sender], "Already minted"); + addressMinted[msg.sender] = true; // update first + _safeMint(msg.sender, tokenId); } ``` -Adapt the contract based on the user's requirements. Available extensions (all under `@openzeppelin/contracts/token/ERC721/extensions/`): - -- **`ERC721Enumerable`**: on-chain enumeration of all tokens and per-owner tokens. Enables `totalSupply()`, `tokenByIndex()`, `tokenOfOwnerByIndex()`. Convenient but expensive (see gas section below). -- **`ERC721URIStorage`**: per-token URI storage via `_setTokenURI()`. Emits ERC-4906 `MetadataUpdate` events in v5. -- **`ERC721Burnable`**: lets token owners destroy their NFTs -- **`ERC721Pausable`**: admin can freeze all transfers -- **`ERC721Votes`**: governance checkpoints, each NFT = 1 vote -- **`ERC721Royalty`**: ERC-2981 royalty info (see royalties section below) -- **`ERC721Consecutive`**: batch minting during construction (ERC-2309) - -See [OpenZeppelin's ERC-721 extensions](https://docs.openzeppelin.com/contracts/5.x/api/token/erc721#extensions) for the full list. +### 2. On-Chain SVG Stack-Too-Deep -### OpenZeppelin v5 changes to be aware of +When generating SVG on-chain, the `tokenURI` or `generateSVG` function easily hits Solidity's 16-local-variable stack limit. Split SVG generation into multiple helper functions (e.g., `_svgBackground`, `_svgShapes`, `_svgText`) rather than building the entire SVG in one function. -If referencing older tutorials or code, note these breaking changes in OpenZeppelin v5: +### 3. Marketplace Metadata `attributes` Array -- **`_beforeTokenTransfer` and `_afterTokenTransfer` hooks are gone.** Replaced by a single `_update(address to, uint256 tokenId, address auth)` override point that handles mint, transfer, and burn. -- **Custom errors** replaced revert strings (e.g. `ERC721NonexistentToken`, `ERC721InsufficientApproval`) -- **`Ownable` requires explicit owner**: `Ownable(msg.sender)` instead of `Ownable()` -- **`ERC721URIStorage`** now emits ERC-4906 `MetadataUpdate` events when `_setTokenURI` is called - -## Metadata: The Part Most People Get Wrong - -### The metadata JSON schema - -ERC-721 metadata follows a standard JSON structure returned by `tokenURI()`: +The `attributes` array in NFT metadata JSON is not in the ERC-721 EIP but is the de facto standard used by OpenSea, Blur, and every marketplace. Without it, traits won't display: ```json { "name": "My NFT #1", - "description": "Description of the NFT", - "image": "ipfs://QmImageCID", + "description": "...", + "image": "data:image/svg+xml;base64,...", "attributes": [ { "trait_type": "Color", "value": "Blue" }, { "trait_type": "Rarity", "value": "Rare" } @@ -98,114 +57,24 @@ ERC-721 metadata follows a standard JSON structure returned by `tokenURI()`: } ``` -The `attributes` array is not in the EIP but is the de facto standard used by OpenSea, Blur, and every other marketplace. Without it, traits won't display. - -### On-chain vs off-chain metadata - -| Factor | On-chain (base64/SVG) | Off-chain (IPFS/Arweave) | -|--------|----------------------|--------------------------| -| Permanence | Permanent as long as Ethereum exists | Depends on pinning/persistence | -| Gas cost | Very expensive (~128KB payload ceiling) | Cheap (just store a URI string) | -| Mutability | Immutable once deployed | Can disappear if unpinned | -| Best for | Small collections, generative art | Large collections, rich media | - -### IPFS gotchas - -About 20% of sampled NFTs have broken or expired metadata links. Common causes: - -- **Unpinned data gets garbage collected.** IPFS nodes drop data nobody is actively pinning. If the original pinner stops, the data vanishes. -- **Gateway URLs vs protocol URIs.** Use `ipfs://QmCID` (content-addressed, portable) not `https://gateway.pinata.cloud/ipfs/QmCID` (depends on one gateway staying up). -- **Base URI must end with `/`.** OpenZeppelin's `tokenURI()` concatenates `_baseURI() + tokenId.toString()`. If the base URI is `ipfs://QmCID` without a trailing slash, token 42 becomes `ipfs://QmCID42` instead of `ipfs://QmCID/42`. -- **File naming.** If using base URI + token ID, metadata files must be named `0`, `1`, `2` etc. (no `.json` extension) unless you override `tokenURI()` to append it. - -For permanent storage, consider [Arweave](https://www.arweave.org/) or a paid IPFS pinning service (Pinata, Filebase). - -## ERC721Enumerable: Convenient but Expensive - -ERC721Enumerable maintains four additional data structures that get updated on every mint and transfer. Concrete gas comparison: +### 4. Required Overrides with ERC721Enumerable -- Minting 5 tokens with ERC721Enumerable: ~566,000 gas -- Minting 5 tokens with ERC721A: ~104,000 gas (5.5x cheaper) - -**When to use it**: Small collections, learning/demos, when you need on-chain enumeration without an indexer. - -**When to skip it**: Large collections (1k+ tokens), gas-sensitive mints. Use a simple counter for `totalSupply()` and index token ownership off-chain using `Transfer` events (via a subgraph or Ponder, both available as SE-2 skills). - -### ERC721A as an alternative - -[ERC721A](https://github.com/chiru-labs/ERC721A) by Azuki makes batch minting cost nearly the same as minting a single token. A 10-token mint costs ~110,000 gas vs ~1,100,000+ with ERC721Enumerable. It works by lazily initializing ownership: only the first token in a batch gets an ownership record, and later tokens infer ownership by scanning backwards. - -Trade-offs: -- Requires sequential token IDs (no random IDs) -- First transfer after a batch mint is more expensive (must initialize ownership) -- Not an OpenZeppelin extension; separate dependency from `erc721a` npm package - -## Security: The Reentrancy You Didn't Expect - -### `_safeMint` and `safeTransferFrom` call external code - -Both `_safeMint` and `safeTransferFrom` invoke `onERC721Received()` on the recipient if it's a contract. This is an external call that happens after the token has been minted/transferred, creating a reentrancy vector. - -**Real exploit (HypeBears, Feb 2022):** The contract tracked per-address minting limits but updated state after `_safeMint`. An attacker's `onERC721Received` callback called `mintNFT` again before the limit was recorded, bypassing the per-address cap entirely. +When combining `ERC721` + `ERC721Enumerable`, both define `_update` and `_increaseBalance`. You must explicitly override them or the contract won't compile: ```solidity -// VULNERABLE: state update after _safeMint -function mintNFT() public { - require(!addressMinted[msg.sender], "Already minted"); - _safeMint(msg.sender, tokenId); // calls onERC721Received on attacker - addressMinted[msg.sender] = true; // too late, attacker already re-entered +function _update(address to, uint256 tokenId, address auth) internal override(ERC721, ERC721Enumerable) returns (address) { + return super._update(to, tokenId, auth); } -// SAFE: state update before _safeMint -function mintNFT() public { - require(!addressMinted[msg.sender], "Already minted"); - addressMinted[msg.sender] = true; // update state first - _safeMint(msg.sender, tokenId); +function _increaseBalance(address account, uint128 amount) internal override(ERC721, ERC721Enumerable) { + super._increaseBalance(account, amount); } -``` - -Mitigations: Update state before `_safeMint`/`safeTransferFrom` (checks-effects-interactions pattern), or use OpenZeppelin's `ReentrancyGuard` (`nonReentrant` modifier). - -### `setApprovalForAll` is a dangerous permission - -`setApprovalForAll(operator, true)` grants an operator control over **all** of an owner's NFTs in that collection. Phishing attacks trick users into signing this for malicious operators. Once approved, the attacker can transfer away every NFT the victim owns. Most marketplaces require `setApprovalForAll` to list NFTs, which is why phishing is so effective. - -### Flash loan governance attacks - -NFTs used for governance (each NFT = 1 vote) can be manipulated via flash loans: borrow NFTs, vote, return them. Use `ERC721Votes` with checkpoints and voting delays rather than raw `balanceOf()` for governance. - -## Royalties (ERC-2981) - -ERC-2981 defines a standard `royaltyInfo(tokenId, salePrice)` function that returns the royalty receiver and amount. OpenZeppelin provides `ERC721Royalty` to implement this. -**The critical thing to know: ERC-2981 is advisory, not enforceable.** The standard provides an interface for querying royalty info, but nothing forces marketplaces to honor it. Anyone can transfer an NFT via `transferFrom` without paying royalties. - -Current marketplace stance: -- **OpenSea**: ended mandatory enforcement Aug 2023. Added ERC-721C support Apr 2024 for opt-in on-chain enforcement. -- **Blur**: enforces only a 0.5% minimum on most collections. - -[ERC-721C](https://github.com/limitbreak/creator-token-standards) by Limit Break attempted to solve this by restricting transfers to whitelisted operator contracts. Adoption is growing but not universal. - -## Soulbound Tokens (ERC-5192) - -For non-transferable NFTs (credentials, memberships, achievements), [ERC-5192](https://eips.ethereum.org/EIPS/eip-5192) adds a minimal `locked(tokenId)` interface. In OpenZeppelin v5, the simplest approach is overriding `_update`: - -```solidity -function _update(address to, uint256 tokenId, address auth) - internal override returns (address) -{ - address from = super._update(to, tokenId, auth); - require(from == address(0) || to == address(0), "Non-transferable"); - return from; +function supportsInterface(bytes4 interfaceId) public view override(ERC721, ERC721Enumerable) returns (bool) { + return super.supportsInterface(interfaceId); } ``` -## Well-Known NFT Contracts (Ethereum Mainnet) +### 5. IPFS Base URI Trailing Slash -| Collection | Address | Notes | -|------------|---------|-------| -| CryptoPunks (original) | `0xb47e3cd837dDF8e4c57F05d70Ab865de6e193BBB` | **NOT ERC-721.** Pre-dates the standard (June 2017). Custom contract with its own marketplace built in. Had a critical bug where sale ETH was credited to the buyer, not the seller. | -| Wrapped CryptoPunks | `0xb7F7F6C52F2e2fdb1963Eab30438024864c313F6` | ERC-721 wrapper around original punks | -| Bored Ape Yacht Club | `0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D` | 10,000 apes, standard ERC-721 | -| Azuki | `0xED5AF388653567Af2F388E6224dC7C4b3241C544` | Uses ERC721A for gas-optimized batch minting | -| Pudgy Penguins | `0xBd3531dA5CF5857e7CfAA92426877b022e612cf8` | 8,888 penguins | \ No newline at end of file +OpenZeppelin's `tokenURI()` concatenates `_baseURI() + tokenId.toString()`. If the base URI is `ipfs://QmCID` without a trailing slash, token 42 becomes `ipfs://QmCID42` instead of `ipfs://QmCID/42`. diff --git a/templates/base/.agents/skills/openzeppelin/SKILL.md b/templates/base/.agents/skills/openzeppelin/SKILL.md new file mode 100644 index 0000000000..512d9148b0 --- /dev/null +++ b/templates/base/.agents/skills/openzeppelin/SKILL.md @@ -0,0 +1,79 @@ +--- +name: openzeppelin +description: "Develop smart contracts using OpenZeppelin Contracts library. Use when the user wants to create or modify Solidity contracts that use OpenZeppelin — including token standards (ERC20, ERC721, ERC1155), access control (Ownable, AccessControl), security primitives (Pausable, ReentrancyGuard), or any OZ extension. Covers library-first integration, pattern discovery from installed source, and version-safe development." +--- + +# Develop Smart Contracts with OpenZeppelin + +Adapted from [OpenZeppelin's official skill](https://github.com/OpenZeppelin/openzeppelin-skills) for Scaffold-ETH 2 Solidity projects. + +## Prerequisites + +Check if `./packages/nextjs/scaffold.config.ts` exists directly in the current working directory (do not search subdirectories). If it doesn't exist, this is not a Scaffold-ETH 2 project. Follow the instructions at https://docs.scaffoldeth.io/SKILL.md to scaffold it first. If it exists, continue directly with building. + +Detect the Solidity framework flavor: +- If `packages/hardhat` exists → **Hardhat flavor** +- If `packages/foundry` exists → **Foundry flavor** + +## Core Principle: Prefer Library Components Over Custom Code + +Before writing ANY contract logic, search the installed OpenZeppelin library for an existing component: + +1. **Exact match exists?** Import and use it directly — inherit, override only what's needed +2. **Close match exists?** Import and extend — override only functions the library marks as `virtual` +3. **No match?** Only then write custom logic. Confirm by browsing the library's directory first + +**Never hand-write what the library already provides:** +- Don't write `require(totalSupply() + amount <= cap)` when `ERC20Capped` exists +- Don't write `require(msg.sender == owner)` when `Ownable` exists +- Don't implement token transfer hooks manually when the library's base contracts handle it +- Don't copy library source into your contract — always import from the dependency so the project gets security updates + +## Pattern Discovery: Read the Installed Source + +APIs, override points, and import syntax change between major versions. Don't assume patterns from memory — always verify by reading the installed source. + +### Step 1: Identify the installed version and browse the library + +Find the OpenZeppelin version and locate the installed source: + +- **Hardhat**: check `packages/hardhat/package.json` for `@openzeppelin/contracts` version, then browse `packages/hardhat/node_modules/@openzeppelin/contracts/` +- **Foundry**: check `packages/foundry/lib/openzeppelin-contracts/` and the remappings in `packages/foundry/foundry.toml` + +Browse the library's directory structure to discover available components. Key directories inside the OZ contracts root: +- `token/{ERC20,ERC721,ERC1155}/` — token standards and their base implementations +- `token/{ERC20,ERC721}/extensions/` — Capped, Burnable, Pausable, Permit, Votes, Enumerable, etc. +- `access/` — Ownable, AccessControl, AccessManager +- `utils/` — ReentrancyGuard, Pausable, math, structs + +### Step 2: Read the source file for the component you need + +Look at: +- **Import syntax**: how the library's own files import each other — mirror that style. OZ v5+ uses named imports: + ```solidity + // ✅ import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + // ❌ import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + ``` +- **`virtual` functions**: only these can be overridden. Functions that were virtual in one version may not be in another +- **Constructor parameters**: what must be passed during deployment +- **NatSpec comments**: `NOTE: This function is not virtual, {X} should be overridden instead` — follow these + +### Step 3: Extract the minimal integration pattern + +From the source, identify only what's needed: +- Imports to add +- Inheritance chain +- Constructor parameters and chaining +- Required overrides (when inheriting multiple contracts that define the same virtual function, you must override it and call `super`) +- New functions to expose + +### Step 4: Apply to the user's contract + +Integrate into existing code. Check for conflicts: duplicate access control, incompatible inheritance, missing overrides. The Solidity compiler will error if two parent contracts define the same function and you don't explicitly override it. + +## SE-2 Integration Notes + +- OpenZeppelin is pre-installed in both flavors — no additional dependency installation needed +- Contracts: `packages/hardhat/contracts/` (Hardhat) or `packages/foundry/contracts/` (Foundry) +- Deploy scripts: `packages/hardhat/deploy/` (Hardhat) or `packages/foundry/script/` (Foundry) +- Frontend reads contract ABIs from `packages/nextjs/contracts/deployedContracts.ts` (auto-generated by `yarn deploy`) diff --git a/templates/base/.agents/skills/ponder/SKILL.md b/templates/base/.agents/skills/ponder/SKILL.md index 1ffcb9d989..8684e5e1e2 100644 --- a/templates/base/.agents/skills/ponder/SKILL.md +++ b/templates/base/.agents/skills/ponder/SKILL.md @@ -23,7 +23,7 @@ Look at the actual project structure and contracts before setting things up. Ada ### Ponder package (`packages/ponder/`) -The `packages/ponder/package.json` should follow SE-2's workspace naming convention (`@se-2/ponder`). Reference structure with minimum version requirements. Check [npm](https://www.npmjs.com/package/ponder) or the [Ponder docs](https://ponder.sh/docs/requirements) for the latest versions before installing: +The `packages/ponder/package.json` should follow SE-2's workspace naming convention (`@se-2/ponder`). Check [npm](https://www.npmjs.com/package/ponder) or the [Ponder docs](https://ponder.sh/docs/requirements) for the latest versions before installing: ```json { @@ -56,11 +56,9 @@ The `packages/ponder/package.json` should follow SE-2's workspace naming convent } ``` -> **Note:** `ponder` and `eslint-config-ponder` versions should match. Use `latest` or check the [releases](https://github.com/ponder-sh/ponder/releases) for the current stable version. - ### NextJS package additions -These are needed in `packages/nextjs/` for querying Ponder's GraphQL API from the frontend: +For querying Ponder's GraphQL API from the frontend, add to `packages/nextjs/`: ```json { @@ -71,8 +69,6 @@ These are needed in `packages/nextjs/` for querying Ponder's GraphQL API from th ### Root package.json scripts -Wire up workspace commands so they're accessible from the monorepo root: - ```json { "ponder:dev": "yarn workspace @se-2/ponder dev", @@ -89,23 +85,16 @@ Wire up workspace commands so they're accessible from the monorepo root: A `.env.example` in `packages/ponder/` for reference: ``` -# RPC URL for the target chain (replace {chainId} with actual chain ID, e.g. PONDER_RPC_URL_1 for mainnet) PONDER_RPC_URL_{chainId}= - -# Database schema name DATABASE_SCHEMA=my_schema - -# (Optional) Postgres database URL. If not provided, PGlite (embedded Postgres) will be used. DATABASE_URL= ``` The frontend uses `NEXT_PUBLIC_PONDER_URL` to know where the Ponder API lives (defaults to `http://localhost:42069` in dev). -## Ponder Package Configuration - -### ponder.config.ts - bridging SE-2 and Ponder +## ponder.config.ts — Bridging SE-2 and Ponder -The config needs to read SE-2's deployed contracts and scaffold config so Ponder is aware of what to index. Here's a reference implementation that dynamically builds the Ponder config from SE-2's data. Adapt it based on the project's actual setup (e.g., if multiple networks are needed, or if contracts should be filtered): +This is the critical integration piece. The config below is a reference implementation that dynamically reads SE-2's deployed contracts and scaffold config so Ponder automatically knows what to index. Adapt it based on the project's actual setup: ```ts import { createConfig } from "ponder"; @@ -153,24 +142,11 @@ export default createConfig({ }); ``` -### Schema definition +## Schema, Handlers, and API -The schema in `ponder.schema.ts` should reflect the project's actual contract events. Look at what events the deployed contracts emit and design tables to capture that data. Each `onchainTable` defines a table that Ponder populates during indexing. +### Schema (`ponder.schema.ts`) -Solidity-to-Ponder type reference: - -| Solidity | Ponder | TS type | -| ----------------------- | ------------- | ------------------- | -| `address` | `t.hex()` | `` `0x${string}` `` | -| `uint256` / `int256` | `t.bigint()` | `bigint` | -| `string` | `t.text()` | `string` | -| `bool` | `t.boolean()` | `boolean` | -| `bytes` / `bytes32` | `t.hex()` | `` `0x${string}` `` | -| `uint8` / `uint32` etc. | `t.integer()` | `number` | - -Additional column types: `t.real()` (floats), `t.timestamp()` (Date), `t.json()` (arbitrary JSON). Columns support modifiers: `.primaryKey()`, `.notNull()`, `.default(value)`, `.array()`. See [schema docs](https://ponder.sh/docs/schema/tables) for the full API including composite primary keys, indexes, and enums. - -Syntax example (for a greeting event, your schema will differ based on the actual contracts): +Use `onchainTable` to define tables. Adapt to the project's actual contract events: ```ts import { onchainTable } from "ponder"; @@ -185,11 +161,11 @@ export const greeting = onchainTable("greeting", (t) => ({ })); ``` -### Event handlers +For the full schema API (column types, indexes, enums), see [Ponder schema docs](https://ponder.sh/docs/schema/tables). -Handlers go in `packages/ponder/src/` and define what happens when contract events are detected. Look at the project's contracts to decide which events matter and what data to extract. The handler name format is `"ContractName:EventName"`, where `ContractName` matches the key in `deployedContracts`. +### Event handlers (`src/`) -Syntax example: +Use Ponder's virtual module imports. Handler name format is `"ContractName:EventName"`: ```ts import { ponder } from "ponder:registry"; @@ -207,9 +183,9 @@ ponder.on("YourContract:GreetingChange", async ({ event, context }) => { }); ``` -### GraphQL API +### GraphQL API (`src/api/index.ts`) -Ponder serves data via a Hono-based API. This is mostly boilerplate. A minimal `packages/ponder/src/api/index.ts`: +Ponder serves data via Hono. Minimal setup: ```ts import { db } from "ponder:api"; @@ -224,49 +200,33 @@ app.use("/graphql", graphql({ db, schema })); export default app; ``` -Custom API routes can be added to this Hono app if GraphQL alone isn't sufficient. See [Ponder API docs](https://ponder.sh/docs/query/api-endpoints). - -### Boilerplate files +### Required boilerplate -These are standard Ponder project files, nothing SE-2-specific, just needed for Ponder to work: - -- **`ponder-env.d.ts`**: type declarations for Ponder's virtual modules (`ponder:registry`, `ponder:schema`, `ponder:api`, etc.) -- **`tsconfig.json`**: standard strict TS config with `moduleResolution: "bundler"`, `module: "ESNext"`, `target: "ES2022"` -- **`.gitignore`**: should include `node_modules`, `.ponder`, `/generated/` +- **`ponder-env.d.ts`**: type declarations for Ponder's virtual modules (`ponder:registry`, `ponder:schema`, `ponder:api`, etc.). Without this, TypeScript won't resolve the virtual imports. +- **`tsconfig.json`**: strict TS config with `moduleResolution: "bundler"`, `module: "ESNext"`, `target: "ES2022"` +- **`.gitignore`**: include `node_modules`, `.ponder`, `/generated/` ## Frontend -The frontend needs a page to display Ponder-indexed data. Use `graphql-request` and `@tanstack/react-query` (both available in SE-2) to query the Ponder API. The GraphQL query shape depends on what you defined in `ponder.schema.ts`. Ponder auto-generates queries from your schema, with each `onchainTable` getting a pluralized query with `items`, `orderBy`, and `orderDirection` support. - -Fetch pattern for reference: +Use `graphql-request` and `@tanstack/react-query` (both available in SE-2) to query the Ponder API. Ponder auto-generates GraphQL queries from your schema — each `onchainTable` gets a pluralized query with `items`, `orderBy`, and `orderDirection` support: ```tsx -const fetchData = async () => { - const query = gql` - query { - greetings(orderBy: "timestamp", orderDirection: "desc") { - items { - id - text - setterId - premium - value - timestamp - } - } - } - `; - return request( - `${process.env.NEXT_PUBLIC_PONDER_URL || "http://localhost:42069"}/graphql`, - query, - ); -}; +import { gql, request } from "graphql-request"; +import { useQuery } from "@tanstack/react-query"; + +const PONDER_URL = process.env.NEXT_PUBLIC_PONDER_URL || "http://localhost:42069"; -// In component: -const { data } = useQuery({ queryKey: ["ponder-data"], queryFn: fetchData }); +const { data } = useQuery({ + queryKey: ["ponder-greetings"], + queryFn: () => request(`${PONDER_URL}/graphql`, gql`{ + greetings(orderBy: "timestamp", orderDirection: "desc") { + items { id text setterId premium value timestamp } + } + }`), +}); ``` ## Development & Production - `yarn ponder:dev` starts the dev server with hot reload. GraphiQL explorer available at `http://localhost:42069` for testing queries interactively. -- For production deployment, see [Ponder deployment docs](https://ponder.sh/docs/production/railway). Key things: set `PONDER_RPC_URL_{chainId}` with a production RPC, optionally configure `DATABASE_URL` for Postgres (defaults to PGlite in dev), and point the frontend's `NEXT_PUBLIC_PONDER_URL` to the deployed Ponder URL. +- For production, set `PONDER_RPC_URL_{chainId}` with a production RPC, optionally configure `DATABASE_URL` for Postgres (defaults to PGlite in dev), and point `NEXT_PUBLIC_PONDER_URL` to the deployed Ponder URL. See [Ponder deployment docs](https://ponder.sh/docs/production/railway). diff --git a/templates/base/.agents/skills/siwe/SKILL.md b/templates/base/.agents/skills/siwe/SKILL.md index 8f727be00c..0c2a039e2a 100644 --- a/templates/base/.agents/skills/siwe/SKILL.md +++ b/templates/base/.agents/skills/siwe/SKILL.md @@ -9,65 +9,57 @@ description: "Add Sign-In with Ethereum (SIWE) authentication to a Scaffold-ETH Check if `./packages/nextjs/scaffold.config.ts` exists directly in the current working directory (do not search subdirectories). If it doesn't exist, this is not a Scaffold-ETH 2 project. Follow the instructions at https://docs.scaffoldeth.io/SKILL.md to scaffold it first. If it exists, continue directly with building. -## Overview +## Critical: Use viem's Native SIWE — NOT the `siwe` npm Package -[Sign-In with Ethereum (SIWE / EIP-4361)](https://eips.ethereum.org/EIPS/eip-4361) lets users authenticate to web applications by signing a standardized message with their Ethereum wallet. Instead of username/password or OAuth, the user proves ownership of an address via a cryptographic signature. The server verifies it and creates a session — no database needed for basic auth. +Viem provides all SIWE utilities natively via `viem/siwe`. **Do not install the `siwe` npm package**. It pulls in `ethers` as a peer dependency, which is unnecessary since SE-2 already uses viem. -This skill covers integrating SIWE into an SE-2 project using [viem's native SIWE utilities](https://viem.sh/docs/siwe/utilities/createSiweMessage) and [iron-session](https://github.com/vvo/iron-session) for encrypted cookie-based sessions. This skill focuses on SE-2 integration specifics and gotchas, not a complete reference. For anything not covered here, refer to the [EIP-4361 spec](https://eips.ethereum.org/EIPS/eip-4361) or [viem SIWE docs](https://viem.sh/docs/siwe/utilities/createSiweMessage). +Here are some commonly useful imports (but check official docs for any updates or alternatives): -## Dependencies +```typescript +import { + createSiweMessage, + parseSiweMessage, + verifySiweMessage, + generateSiweNonce, +} from "viem/siwe"; +``` -Add `iron-session` to the nextjs workspace for encrypted cookie-based session management: +## Dependencies ```bash yarn workspace @se-2/nextjs add iron-session ``` -Everything else (viem, wagmi, RainbowKit) is already in SE-2. Viem provides all SIWE utilities natively — **do not install the `siwe` npm package**. - -### Environment variables - -Add to `packages/nextjs/.env.local`: - -``` -IRON_SESSION_SECRET=your_secret_key_at_least_32_characters_long -``` - -In development, the code can fall back to a hardcoded dev secret. **In production, this MUST be set** to a random 32+ character string. Generate one with: +## Gotchas -```bash -openssl rand -base64 32 -``` +### 1. Domain Validation in the Verify Route -## SIWE Authentication Flow +The verify route **must** validate the SIWE message's domain against the request's `Host` header. Without this, an attacker can replay a signature from a different domain. This is a critical security check that's easy to skip: -The flow follows the standard EIP-4361 pattern with three API routes: +```typescript +// In your verify API route +const expectedDomain = req.headers.get("host"); +if (!expectedDomain) { + return NextResponse.json({ error: "Missing Host header" }, { status: 400 }); +} +// Pass domain to verifySiweMessage +const isValid = await verifySiweMessage(client, { + message, + signature, + nonce: storedNonce, + domain: expectedDomain, // CRITICAL — validates domain match +}); ``` -1. Client: GET /api/siwe/nonce → Server generates random nonce, stores in session -2. Client: Create SIWE message → Uses viem's createSiweMessage with nonce -3. Client: Wallet signs message → signMessageAsync from wagmi -4. Client: POST /api/siwe/verify → Server verifies signature + nonce + domain -5. Server: Creates authenticated session → Encrypted cookie via iron-session -6. Client: GET /api/siwe/session → Check if session is active -7. Client: DELETE /api/siwe/session → Logout (destroy session) -``` - -### Why nonce-first? - -The nonce prevents replay attacks. The server generates a random nonce, stores it in the session cookie, and the client must include that exact nonce in the SIWE message. During verification, the server checks the nonce matches. After verification, the nonce is cleared so it can't be reused. -## Implementation +### 2. Session Options Must Use a Lazy Getter -### Session configuration (`packages/nextjs/utils/siwe.ts`) - -This is the core session setup. It configures iron-session and provides helper utilities: +`sessionOptions` must NOT be a module-level constant that calls `getSessionPassword()` at import time. During `next build`, the code runs in production mode, and the env var won't be set, causing a build failure. Use a lazy factory: ```typescript -// packages/nextjs/utils/siwe.ts — adapt session options to your needs +// packages/nextjs/utils/siwe.ts import { SessionOptions } from "iron-session"; -// Session data stored in the encrypted cookie export type SiweSessionData = { nonce?: string; address?: string; @@ -76,471 +68,72 @@ export type SiweSessionData = { signedInAt?: string; }; -export const defaultSession: SiweSessionData = { - isLoggedIn: false, -}; +export const defaultSession: SiweSessionData = { isLoggedIn: false }; -function getSessionPassword(): string { +// Lazy getter — defers env var evaluation to request time +export function getSessionOptions(): SessionOptions { const secret = process.env.IRON_SESSION_SECRET; - if (secret && secret.length >= 32) return secret; + const password = + secret && secret.length >= 32 + ? secret + : process.env.NODE_ENV === "production" + ? (() => { + throw new Error( + "IRON_SESSION_SECRET must be set in production (32+ chars)", + ); + })() + : "complex_password_at_least_32_characters_long_for_dev"; - if (process.env.NODE_ENV === "production") { - throw new Error( - "IRON_SESSION_SECRET must be set in production (32+ chars)", - ); - } - // Dev-only fallback - return "complex_password_at_least_32_characters_long_for_dev"; -} - -export const sessionOptions: SessionOptions = { - password: getSessionPassword(), - cookieName: "siwe-session", - cookieOptions: { - httpOnly: true, - sameSite: "lax", - secure: process.env.NODE_ENV === "production", - maxAge: 7 * 24 * 60 * 60, // 7 days — adapt as needed - }, -}; - -// Type guard for authenticated sessions -export function isAuthenticated( - session: SiweSessionData, -): session is SiweSessionData & { address: string; chainId: number } { - return session.isLoggedIn && !!session.address && !!session.chainId; + return { + password, + cookieName: "siwe-session", + cookieOptions: { + httpOnly: true, + sameSite: "lax" as const, + secure: process.env.NODE_ENV === "production", + maxAge: 7 * 24 * 60 * 60, + }, + }; } ``` -### SIWE config (`packages/nextjs/utils/siwe.config.ts`) +### 3. `hasSeenWalletConnected` Ref to Prevent False Auto-Logout -Separate config for tunable parameters: +On page refresh, the wallet reconnects asynchronously, causing a brief `isConnected: false` state. Without tracking whether the wallet was ever connected, this triggers a false logout. Use a ref: ```typescript -// packages/nextjs/utils/siwe.config.ts -const siweConfig = { - sessionDurationDays: 7, - messageExpirationMinutes: 10, - statement: "Sign in with Ethereum to the app.", -} as const; +const hasSeenWalletConnected = useRef(false); -export default siweConfig; -export const { sessionDurationDays, messageExpirationMinutes, statement } = - siweConfig; -``` - -### API Route: Nonce (`packages/nextjs/app/api/siwe/nonce/route.ts`) - -Generates a cryptographic nonce and stores it in the session: - -```typescript -// packages/nextjs/app/api/siwe/nonce/route.ts -import { NextResponse } from "next/server"; -import { getIronSession } from "iron-session"; -import { cookies } from "next/headers"; -import { generateSiweNonce } from "viem/siwe"; -import { SiweSessionData, defaultSession, sessionOptions } from "~~/utils/siwe"; - -export async function GET() { - try { - const session = await getIronSession( - await cookies(), - sessionOptions, - ); - - // Reset session and generate fresh nonce - session.isLoggedIn = defaultSession.isLoggedIn; - session.address = undefined; - session.chainId = undefined; - session.signedInAt = undefined; - session.nonce = generateSiweNonce(); - await session.save(); - - return NextResponse.json({ nonce: session.nonce }); - } catch (error) { - console.error("Nonce generation error:", error); - return NextResponse.json( - { error: "Failed to generate nonce" }, - { status: 500 }, - ); +useEffect(() => { + if (isConnected) { + hasSeenWalletConnected.current = true; } -} + if (!isConnected && hasSeenWalletConnected.current && state.isSignedIn) { + // Wallet actually disconnected — sign out + fetch("/api/siwe/session", { method: "DELETE" }).then(() => { + setState((prev) => ({ ...prev, isSignedIn: false, address: undefined })); + }); + } +}, [isConnected, state.isSignedIn]); ``` -### API Route: Verify (`packages/nextjs/app/api/siwe/verify/route.ts`) +## ERC-6492 Smart Wallet Support -The most complex route — validates the signed SIWE message: +The verify route should create a `publicClient` per chain to support smart contract wallet (Safe, Argent) signature verification. Maintain a `SUPPORTED_CHAINS` map and reject unknown chains: ```typescript -// packages/nextjs/app/api/siwe/verify/route.ts -import { NextRequest, NextResponse } from "next/server"; -import { getIronSession } from "iron-session"; -import { cookies } from "next/headers"; -import { type Chain, createPublicClient, http } from "viem"; -import { parseSiweMessage, verifySiweMessage } from "viem/siwe"; -import { - mainnet, - polygon, - optimism, - arbitrum, - base, - gnosis, - scroll, - zkSync, - sepolia, - hardhat, -} from "viem/chains"; -import { SiweSessionData, sessionOptions } from "~~/utils/siwe"; +import { createPublicClient, http, type Chain } from "viem"; +import { mainnet, sepolia, hardhat /* ... */ } from "viem/chains"; -// Add/remove chains your app supports — needed for ERC-6492 smart wallet verification const SUPPORTED_CHAINS: Record = { [mainnet.id]: mainnet, - [polygon.id]: polygon, - [optimism.id]: optimism, - [arbitrum.id]: arbitrum, - [base.id]: base, - [gnosis.id]: gnosis, - [scroll.id]: scroll, - [zkSync.id]: zkSync, [sepolia.id]: sepolia, [hardhat.id]: hardhat, }; -export async function POST(req: NextRequest) { - try { - const session = await getIronSession( - await cookies(), - sessionOptions, - ); - const { message, signature } = await req.json(); - - if (!message || !signature) { - return NextResponse.json( - { error: "Missing message or signature" }, - { status: 400 }, - ); - } - - const storedNonce = session.nonce; - if (!storedNonce) { - return NextResponse.json( - { error: "No nonce found. Request /api/siwe/nonce first." }, - { status: 400 }, - ); - } - - const parsedMessage = parseSiweMessage(message); - - // SECURITY: Validate domain against Host header. In production behind a reverse proxy, - // ensure Host is forwarded correctly, or replace with a hardcoded expected domain. - const expectedDomain = req.headers.get("host"); - if (!expectedDomain) { - return NextResponse.json( - { error: "Missing Host header" }, - { status: 400 }, - ); - } - - // Create a client for the chain to support ERC-6492 (smart wallet) verification - const chainId = parsedMessage.chainId; - const chain = chainId ? SUPPORTED_CHAINS[chainId] : undefined; - if (!chain) { - return NextResponse.json( - { error: `Unsupported chain: ${chainId}` }, - { status: 400 }, - ); - } - - const client = createPublicClient({ chain, transport: http() }); - const isValid = await verifySiweMessage(client, { - message, - signature, - nonce: storedNonce, - domain: expectedDomain, - }); - - if (!isValid) { - return NextResponse.json( - { error: "Signature verification failed" }, - { status: 401 }, - ); - } - - // Create authenticated session - session.isLoggedIn = true; - session.address = parsedMessage.address; - session.chainId = chainId; - session.signedInAt = new Date().toISOString(); - session.nonce = undefined; // Clear nonce — one-time use - await session.save(); - - return NextResponse.json({ - ok: true, - address: session.address, - chainId: session.chainId, - }); - } catch (error) { - console.error("SIWE verify error:", error); - return NextResponse.json({ error: "Verification failed" }, { status: 500 }); - } -} +// In verify route: +const chain = SUPPORTED_CHAINS[parsedMessage.chainId!]; +if (!chain) + return NextResponse.json({ error: "Unsupported chain" }, { status: 400 }); +const client = createPublicClient({ chain, transport: http() }); ``` - -### API Route: Session (`packages/nextjs/app/api/siwe/session/route.ts`) - -Check and destroy sessions: - -```typescript -// packages/nextjs/app/api/siwe/session/route.ts -import { NextResponse } from "next/server"; -import { getIronSession } from "iron-session"; -import { cookies } from "next/headers"; -import { SiweSessionData, defaultSession, sessionOptions } from "~~/utils/siwe"; - -export async function GET() { - const session = await getIronSession( - await cookies(), - sessionOptions, - ); - - if (session.isLoggedIn) { - return NextResponse.json({ - isLoggedIn: true, - address: session.address, - chainId: session.chainId, - signedInAt: session.signedInAt, - }); - } - - return NextResponse.json(defaultSession); -} - -export async function DELETE() { - const session = await getIronSession( - await cookies(), - sessionOptions, - ); - session.destroy(); - return NextResponse.json(defaultSession); -} -``` - -### Custom hook (`packages/nextjs/hooks/useSiwe.ts`) - -The `useSiwe` hook encapsulates the entire auth flow: - -```typescript -// packages/nextjs/hooks/useSiwe.ts — syntax reference, adapt to your needs -import { useCallback, useEffect, useRef, useState } from "react"; -import { useAccount, useSignMessage } from "wagmi"; -import { createSiweMessage } from "viem/siwe"; -import { messageExpirationMinutes, statement } from "~~/utils/siwe.config"; - -type SiweState = { - address: string | undefined; - chainId: number | undefined; - isSignedIn: boolean; - isLoading: boolean; - error: string | undefined; - siweMessage: string | undefined; - signedInAt: string | undefined; -}; - -export function useSiwe() { - const { address: connectedAddress, chainId, isConnected } = useAccount(); - const { signMessageAsync } = useSignMessage(); - const hasSeenWalletConnected = useRef(false); - - const [state, setState] = useState({ - address: undefined, - chainId: undefined, - isSignedIn: false, - isLoading: true, - error: undefined, - siweMessage: undefined, - signedInAt: undefined, - }); - - // Check session on mount - const checkSession = useCallback(async () => { - try { - const res = await fetch("/api/siwe/session"); - const data = await res.json(); - setState((prev) => ({ - ...prev, - isSignedIn: data.isLoggedIn ?? false, - address: data.address, - chainId: data.chainId, - signedInAt: data.signedInAt, - isLoading: false, - })); - } catch { - setState((prev) => ({ ...prev, isLoading: false })); - } - }, []); - - useEffect(() => { - checkSession(); - }, [checkSession]); - - // Auto-logout on wallet disconnect or address change - useEffect(() => { - if (isConnected) { - hasSeenWalletConnected.current = true; - } - if (!isConnected && hasSeenWalletConnected.current && state.isSignedIn) { - // Wallet disconnected after being connected — sign out - fetch("/api/siwe/session", { method: "DELETE" }).then(() => { - setState((prev) => ({ - ...prev, - isSignedIn: false, - address: undefined, - chainId: undefined, - siweMessage: undefined, - signedInAt: undefined, - })); - }); - } - }, [isConnected, state.isSignedIn]); - - const signIn = useCallback(async () => { - if (!connectedAddress || !chainId) { - setState((prev) => ({ ...prev, error: "Wallet not connected" })); - return; - } - - setState((prev) => ({ ...prev, isLoading: true, error: undefined })); - try { - // 1. Fetch nonce - const nonceRes = await fetch("/api/siwe/nonce"); - const { nonce } = await nonceRes.json(); - - // 2. Create SIWE message - const now = new Date(); - const message = createSiweMessage({ - domain: window.location.host, - address: connectedAddress, - chainId, - nonce, - uri: window.location.origin, - version: "1", - statement, - issuedAt: now, - expirationTime: new Date( - now.getTime() + messageExpirationMinutes * 60 * 1000, - ), - }); - - // 3. Sign with wallet - const signature = await signMessageAsync({ message }); - - // 4. Verify on server - const verifyRes = await fetch("/api/siwe/verify", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message, signature }), - }); - - if (!verifyRes.ok) { - const errorData = await verifyRes.json(); - throw new Error(errorData.error || "Verification failed"); - } - - const result = await verifyRes.json(); - setState((prev) => ({ - ...prev, - isSignedIn: true, - address: result.address, - chainId: result.chainId, - siweMessage: message, - signedInAt: new Date().toISOString(), - isLoading: false, - })); - } catch (e) { - setState((prev) => ({ - ...prev, - isLoading: false, - error: e instanceof Error ? e.message : "Sign-in failed", - })); - } - }, [connectedAddress, chainId, signMessageAsync]); - - const signOut = useCallback(async () => { - await fetch("/api/siwe/session", { method: "DELETE" }); - setState((prev) => ({ - ...prev, - isSignedIn: false, - address: undefined, - chainId: undefined, - siweMessage: undefined, - signedInAt: undefined, - error: undefined, - })); - }, []); - - return { - ...state, - isWalletConnected: isConnected, - connectedAddress, - signIn, - signOut, - checkSession, - }; -} -``` - -**Key hook behaviors:** - -- Checks session on mount so refreshing the page preserves auth state -- Auto-signs out when wallet disconnects or address changes -- Uses a `hasSeenWalletConnected` ref to avoid false auto-logout on initial page load (when wallet reconnects asynchronously) -- Separate `connectedAddress` (current wallet) vs `address` (authenticated session) — these can differ if the user switches wallets - -## Gotchas and Pitfalls - -**Nonce must be fetched fresh before each sign-in attempt.** The nonce is single-use and stored server-side. If the user cancels signing or the request fails, they need a new nonce. The `signIn` function handles this by always fetching a fresh nonce first. - -**Domain validation is critical for security.** The verify route checks that the SIWE message's domain matches the `Host` header. In production behind a reverse proxy, ensure the `Host` header is forwarded correctly, or domain verification will fail. - -**Message expiration window.** The SIWE message includes `expirationTime` (default: 10 minutes from creation). If the user takes too long to sign, verification fails. This is configurable in `siwe.config.ts`. - -**ERC-6492 smart wallet support.** Viem's `verifySiweMessage` supports [ERC-6492](https://eips.ethereum.org/EIPS/eip-6492) signatures automatically, meaning smart contract wallets (Safe, Argent, etc.) work out of the box. The verify route creates a public client for the signer's chain to enable on-chain verification of smart wallet signatures. - -**`SUPPORTED_CHAINS` in the verify route must include all chains your app supports.** If a user signs from a chain not in the map, verification is rejected with a 400 error. Add any chains your SE-2 project targets. - -**Auto-logout timing on wallet changes.** The hook watches `isConnected` and signs out when the wallet disconnects. But on page refresh, the wallet reconnects asynchronously, causing a brief `isConnected: false` state. The `hasSeenWalletConnected` ref prevents this from triggering a false logout. - -**Session vs wallet address mismatch.** After signing in, a user could switch to a different wallet address without signing out. The hook exposes both `address` (session) and `connectedAddress` (current wallet) — compare them if you need to detect this mismatch and prompt re-authentication. - -**iron-session cookie size limits.** Cookies are limited to ~4KB. The session data is small (address, chainId, timestamps), so this is rarely an issue, but don't try to store large payloads in the session. - -**Server-side session checks.** To protect API routes or server components, use `getIronSession` with the same `sessionOptions` and check `session.isLoggedIn`. Don't rely on client-side checks alone for sensitive operations. - -The hook returns these properties: - -| Property | Type | Description | -| ------------------- | --------------------- | ------------------------------------------------ | -| `isSignedIn` | `boolean` | Whether user has an active SIWE session | -| `address` | `string \| undefined` | Authenticated wallet address | -| `chainId` | `number \| undefined` | Chain ID from signed message | -| `signedInAt` | `string \| undefined` | ISO timestamp of sign-in | -| `isLoading` | `boolean` | Loading state during sign-in/session check | -| `error` | `string \| undefined` | Error message from last operation | -| `siweMessage` | `string \| undefined` | The raw SIWE message that was signed | -| `isWalletConnected` | `boolean` | Whether any wallet is currently connected | -| `connectedAddress` | `string \| undefined` | Current wallet address (may differ from session) | -| `signIn` | `() => Promise` | Initiate SIWE sign-in flow | -| `signOut` | `() => Promise` | Destroy session and sign out | -| `checkSession` | `() => Promise` | Manually recheck session state | - -## How to Test - -1. Start the frontend: `yarn start` -2. Connect a wallet — MetaMask, Coinbase Wallet, and the burner wallet all support `personal_sign` which is what SIWE uses -3. Click "Sign In" — review the SIWE message in the wallet popup, confirm -4. The session persists across page refreshes (encrypted cookie) -5. Disconnect wallet or click "Sign Out" to end the session - -## Production: - -1. In production make sure to set `IRON_SESSION_SECRET` environment variable to a secure 32+ character random string diff --git a/templates/base/.agents/skills/solidity-security/SKILL.md b/templates/base/.agents/skills/solidity-security/SKILL.md deleted file mode 100644 index c2337fe064..0000000000 --- a/templates/base/.agents/skills/solidity-security/SKILL.md +++ /dev/null @@ -1,532 +0,0 @@ ---- -name: solidity-security -description: Master smart contract security best practices to prevent common vulnerabilities and implement secure Solidity patterns. Use when writing smart contracts, auditing existing contracts, or implementing security measures for blockchain applications. ---- - -# Solidity Security - -Master smart contract security best practices, vulnerability prevention, and secure Solidity development patterns. - -## When to Use This Skill - -- Writing secure smart contracts -- Auditing existing contracts for vulnerabilities -- Implementing secure DeFi protocols -- Preventing reentrancy, overflow, and access control issues -- Optimizing gas usage while maintaining security -- Preparing contracts for professional audits -- Understanding common attack vectors - -## Critical Vulnerabilities - -### 1. Reentrancy - -Attacker calls back into your contract before state is updated. - -**Vulnerable Code:** - -```solidity -// VULNERABLE TO REENTRANCY -contract VulnerableBank { - mapping(address => uint256) public balances; - - function withdraw() public { - uint256 amount = balances[msg.sender]; - - // DANGER: External call before state update - (bool success, ) = msg.sender.call{value: amount}(""); - require(success); - - balances[msg.sender] = 0; // Too late! - } -} -``` - -**Secure Pattern (Checks-Effects-Interactions):** - -```solidity -contract SecureBank { - mapping(address => uint256) public balances; - - function withdraw() public { - uint256 amount = balances[msg.sender]; - require(amount > 0, "Insufficient balance"); - - // EFFECTS: Update state BEFORE external call - balances[msg.sender] = 0; - - // INTERACTIONS: External call last - (bool success, ) = msg.sender.call{value: amount}(""); - require(success, "Transfer failed"); - } -} -``` - -**Alternative: ReentrancyGuard** - -```solidity -import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; - -contract SecureBank is ReentrancyGuard { - mapping(address => uint256) public balances; - - function withdraw() public nonReentrant { - uint256 amount = balances[msg.sender]; - require(amount > 0, "Insufficient balance"); - - balances[msg.sender] = 0; - - (bool success, ) = msg.sender.call{value: amount}(""); - require(success, "Transfer failed"); - } -} -``` - -### 2. Integer Overflow/Underflow - -**Vulnerable Code (Solidity < 0.8.0):** - -```solidity -// VULNERABLE -contract VulnerableToken { - mapping(address => uint256) public balances; - - function transfer(address to, uint256 amount) public { - // No overflow check - can wrap around - balances[msg.sender] -= amount; // Can underflow! - balances[to] += amount; // Can overflow! - } -} -``` - -**Secure Pattern (Solidity >= 0.8.0):** - -```solidity -// Solidity 0.8+ has built-in overflow/underflow checks -contract SecureToken { - mapping(address => uint256) public balances; - - function transfer(address to, uint256 amount) public { - // Automatically reverts on overflow/underflow - balances[msg.sender] -= amount; - balances[to] += amount; - } -} -``` - -**For Solidity < 0.8.0, use SafeMath:** - -```solidity -import "@openzeppelin/contracts/utils/math/SafeMath.sol"; - -contract SecureToken { - using SafeMath for uint256; - mapping(address => uint256) public balances; - - function transfer(address to, uint256 amount) public { - balances[msg.sender] = balances[msg.sender].sub(amount); - balances[to] = balances[to].add(amount); - } -} -``` - -### 3. Access Control - -**Vulnerable Code:** - -```solidity -// VULNERABLE: Anyone can call critical functions -contract VulnerableContract { - address public owner; - - function withdraw(uint256 amount) public { - // No access control! - payable(msg.sender).transfer(amount); - } -} -``` - -**Secure Pattern:** - -```solidity -import "@openzeppelin/contracts/access/Ownable.sol"; - -contract SecureContract is Ownable { - function withdraw(uint256 amount) public onlyOwner { - payable(owner()).transfer(amount); - } -} - -// Or implement custom role-based access -contract RoleBasedContract { - mapping(address => bool) public admins; - - modifier onlyAdmin() { - require(admins[msg.sender], "Not an admin"); - _; - } - - function criticalFunction() public onlyAdmin { - // Protected function - } -} -``` - -### 4. Front-Running - -**Vulnerable:** - -```solidity -// VULNERABLE TO FRONT-RUNNING -contract VulnerableDEX { - function swap(uint256 amount, uint256 minOutput) public { - // Attacker sees this in mempool and front-runs - uint256 output = calculateOutput(amount); - require(output >= minOutput, "Slippage too high"); - // Perform swap - } -} -``` - -**Mitigation:** - -```solidity -contract SecureDEX { - mapping(bytes32 => bool) public usedCommitments; - - // Step 1: Commit to trade - function commitTrade(bytes32 commitment) public { - usedCommitments[commitment] = true; - } - - // Step 2: Reveal trade (next block) - function revealTrade( - uint256 amount, - uint256 minOutput, - bytes32 secret - ) public { - bytes32 commitment = keccak256(abi.encodePacked( - msg.sender, amount, minOutput, secret - )); - require(usedCommitments[commitment], "Invalid commitment"); - // Perform swap - } -} -``` - -## Security Best Practices - -### Checks-Effects-Interactions Pattern - -```solidity -contract SecurePattern { - mapping(address => uint256) public balances; - - function withdraw(uint256 amount) public { - // 1. CHECKS: Validate conditions - require(amount <= balances[msg.sender], "Insufficient balance"); - require(amount > 0, "Amount must be positive"); - - // 2. EFFECTS: Update state - balances[msg.sender] -= amount; - - // 3. INTERACTIONS: External calls last - (bool success, ) = msg.sender.call{value: amount}(""); - require(success, "Transfer failed"); - } -} -``` - -### Pull Over Push Pattern - -```solidity -// Prefer this (pull) -contract SecurePayment { - mapping(address => uint256) public pendingWithdrawals; - - function recordPayment(address recipient, uint256 amount) internal { - pendingWithdrawals[recipient] += amount; - } - - function withdraw() public { - uint256 amount = pendingWithdrawals[msg.sender]; - require(amount > 0, "Nothing to withdraw"); - - pendingWithdrawals[msg.sender] = 0; - payable(msg.sender).transfer(amount); - } -} - -// Over this (push) -contract RiskyPayment { - function distributePayments(address[] memory recipients, uint256[] memory amounts) public { - for (uint i = 0; i < recipients.length; i++) { - // If any transfer fails, entire batch fails - payable(recipients[i]).transfer(amounts[i]); - } - } -} -``` - -### Input Validation - -```solidity -contract SecureContract { - function transfer(address to, uint256 amount) public { - // Validate inputs - require(to != address(0), "Invalid recipient"); - require(to != address(this), "Cannot send to contract"); - require(amount > 0, "Amount must be positive"); - require(amount <= balances[msg.sender], "Insufficient balance"); - - // Proceed with transfer - balances[msg.sender] -= amount; - balances[to] += amount; - } -} -``` - -### Emergency Stop (Circuit Breaker) - -```solidity -import "@openzeppelin/contracts/security/Pausable.sol"; - -contract EmergencyStop is Pausable, Ownable { - function criticalFunction() public whenNotPaused { - // Function logic - } - - function emergencyStop() public onlyOwner { - _pause(); - } - - function resume() public onlyOwner { - _unpause(); - } -} -``` - -## Gas Optimization - -### Use `uint256` Instead of Smaller Types - -```solidity -// More gas efficient -contract GasEfficient { - uint256 public value; // Optimal - - function set(uint256 _value) public { - value = _value; - } -} - -// Less efficient -contract GasInefficient { - uint8 public value; // Still uses 256-bit slot - - function set(uint8 _value) public { - value = _value; // Extra gas for type conversion - } -} -``` - -### Pack Storage Variables - -```solidity -// Gas efficient (3 variables in 1 slot) -contract PackedStorage { - uint128 public a; // Slot 0 - uint64 public b; // Slot 0 - uint64 public c; // Slot 0 - uint256 public d; // Slot 1 -} - -// Gas inefficient (each variable in separate slot) -contract UnpackedStorage { - uint256 public a; // Slot 0 - uint256 public b; // Slot 1 - uint256 public c; // Slot 2 - uint256 public d; // Slot 3 -} -``` - -### Use `calldata` Instead of `memory` for Function Arguments - -```solidity -contract GasOptimized { - // More gas efficient - function processData(uint256[] calldata data) public pure returns (uint256) { - return data[0]; - } - - // Less efficient - function processDataMemory(uint256[] memory data) public pure returns (uint256) { - return data[0]; - } -} -``` - -### Use Events for Data Storage (When Appropriate) - -```solidity -contract EventStorage { - // Emitting events is cheaper than storage - event DataStored(address indexed user, uint256 indexed id, bytes data); - - function storeData(uint256 id, bytes calldata data) public { - emit DataStored(msg.sender, id, data); - // Don't store in contract storage unless needed - } -} -``` - -## Common Vulnerabilities Checklist - -```solidity -// Security Checklist Contract -contract SecurityChecklist { - /** - * [ ] Reentrancy protection (ReentrancyGuard or CEI pattern) - * [ ] Integer overflow/underflow (Solidity 0.8+ or SafeMath) - * [ ] Access control (Ownable, roles, modifiers) - * [ ] Input validation (require statements) - * [ ] Front-running mitigation (commit-reveal if applicable) - * [ ] Gas optimization (packed storage, calldata) - * [ ] Emergency stop mechanism (Pausable) - * [ ] Pull over push pattern for payments - * [ ] No delegatecall to untrusted contracts - * [ ] No tx.origin for authentication (use msg.sender) - * [ ] Proper event emission - * [ ] External calls at end of function - * [ ] Check return values of external calls - * [ ] No hardcoded addresses - * [ ] Upgrade mechanism (if proxy pattern) - */ -} -``` - -## Testing for Security - -```typescript -// Hardhat test example -import { expect } from "chai"; -import { ethers } from "hardhat"; -import { SecureBank, ReentrancyAttacker, SecureToken, SecureContract } from "../typechain-types"; - -describe("Security Tests", function () { - describe("Reentrancy Protection", function () { - let bank: SecureBank; - let attackerContract: ReentrancyAttacker; - - before(async () => { - const [deployer] = await ethers.getSigners(); - - const VictimBank = await ethers.getContractFactory("SecureBank"); - bank = (await VictimBank.deploy()) as SecureBank; - await bank.waitForDeployment(); - - const Attacker = await ethers.getContractFactory("ReentrancyAttacker"); - attackerContract = (await Attacker.deploy(await bank.getAddress())) as ReentrancyAttacker; - await attackerContract.waitForDeployment(); - }); - - it("Should prevent reentrancy attack", async function () { - // Deposit funds - await bank.deposit({ value: ethers.parseEther("10") }); - - // Attempt reentrancy attack - await expect( - attackerContract.attack({ value: ethers.parseEther("1") }), - ).to.be.revertedWith("ReentrancyGuard: reentrant call"); - }); - }); - - describe("Integer Overflow Protection", function () { - let token: SecureToken; - - before(async () => { - const Token = await ethers.getContractFactory("SecureToken"); - token = (await Token.deploy()) as SecureToken; - await token.waitForDeployment(); - }); - - it("Should prevent integer overflow", async function () { - const [, attacker] = await ethers.getSigners(); - - // Attempt overflow - await expect(token.transfer(attacker.address, ethers.MaxUint256)) - .to.be.reverted; - }); - }); - - describe("Access Control", function () { - let contract: SecureContract; - - before(async () => { - const Contract = await ethers.getContractFactory("SecureContract"); - contract = (await Contract.deploy()) as SecureContract; - await contract.waitForDeployment(); - }); - - it("Should enforce access control", async function () { - const [, attacker] = await ethers.getSigners(); - - // Attempt unauthorized withdrawal - await expect(contract.connect(attacker).withdraw(100)).to.be.revertedWith( - "Ownable: caller is not the owner", - ); - }); - }); -}); -``` - -## Audit Preparation - -```solidity -contract WellDocumentedContract { - /** - * @title Well Documented Contract - * @dev Example of proper documentation for audits - * @notice This contract handles user deposits and withdrawals - */ - - /// @notice Mapping of user balances - mapping(address => uint256) public balances; - - /** - * @dev Deposits ETH into the contract - * @notice Anyone can deposit funds - */ - function deposit() public payable { - require(msg.value > 0, "Must send ETH"); - balances[msg.sender] += msg.value; - } - - /** - * @dev Withdraws user's balance - * @notice Follows CEI pattern to prevent reentrancy - * @param amount Amount to withdraw in wei - */ - function withdraw(uint256 amount) public { - // CHECKS - require(amount <= balances[msg.sender], "Insufficient balance"); - - // EFFECTS - balances[msg.sender] -= amount; - - // INTERACTIONS - (bool success, ) = msg.sender.call{value: amount}(""); - require(success, "Transfer failed"); - } -} -``` - -## Common Pitfalls - -1. **Using `tx.origin` for Authentication**: Use `msg.sender` instead -2. **Unchecked External Calls**: Always check return values -3. **Delegatecall to Untrusted Contracts**: Can hijack your contract -4. **Floating Pragma**: Pin to specific Solidity version -5. **Missing Events**: Emit events for state changes -6. **Excessive Gas in Loops**: Can hit block gas limit -7. **No Upgrade Path**: Consider proxy patterns if upgrades needed diff --git a/templates/base/.agents/skills/subgraph/SKILL.md b/templates/base/.agents/skills/subgraph/SKILL.md new file mode 100644 index 0000000000..7414812173 --- /dev/null +++ b/templates/base/.agents/skills/subgraph/SKILL.md @@ -0,0 +1,380 @@ +--- +name: subgraph +description: "Integrate The Graph subgraph into a Scaffold-ETH 2 project for indexing blockchain events. Use when the user wants to: index contract events with The Graph, add a subgraph, query onchain data with GraphQL, set up a local graph node, or deploy a subgraph to Subgraph Studio." +--- + +# The Graph Subgraph Integration for Scaffold-ETH 2 + +## Prerequisites + +Check if `./packages/nextjs/scaffold.config.ts` exists directly in the current working directory (do not search subdirectories). If it doesn't exist, this is not a Scaffold-ETH 2 project. Follow the instructions at https://docs.scaffoldeth.io/SKILL.md to scaffold it first. If it exists, continue directly with building. + +## Overview + +[The Graph](https://thegraph.com/) is a decentralized indexing protocol for querying blockchain data via GraphQL. A **subgraph** defines which contract events to index, how to transform them, and exposes the indexed data through a GraphQL API. This skill adds a subgraph workspace to SE-2, with a local Graph Node (via Docker) for development and deployment to [Subgraph Studio](https://thegraph.com/studio/) for production. + +For The Graph's full API reference, see the [official docs](https://thegraph.com/docs/). This skill focuses on the SE-2 integration — the workspace structure, the ABI copy bridge, and local development workflow. + +## Dependencies & Scripts + +### Subgraph package (`packages/subgraph/`) + +Create `packages/subgraph/package.json`: + +```json +{ + "name": "@se-2/subgraph", + "version": "0.0.1", + "type": "module", + "scripts": { + "abi-copy": "tsx scripts/abi_copy.ts", + "codegen": "graph codegen", + "build": "graph build", + "graph": "graph", + "deploy": "graph deploy --node https://api.studio.thegraph.com/deploy/ your-contract", + "create-local": "graph create --node http://localhost:8020/ scaffold-eth/your-contract", + "remove-local": "graph remove --node http://localhost:8020/ scaffold-eth/your-contract", + "deploy-local": "graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 scaffold-eth/your-contract", + "local-ship": "yarn abi-copy && yarn codegen && yarn build --network localhost && yarn deploy-local", + "test": "graph test -d", + "run-node": "cd graph-node && docker compose up", + "stop-node": "cd graph-node && docker compose down", + "clean-node": "rm -rf graph-node/data/" + }, + "dependencies": { + "@graphprotocol/graph-cli": "^0.98.0", + "@graphprotocol/graph-ts": "^0.38.0", + "tsx": "^4.0.0", + "typescript": "^5.7.0" + }, + "devDependencies": { + "@types/chalk": "^2.2.0", + "@types/node": "^20.11.0", + "matchstick-as": "~0.6.0" + } +} +``` + +### NextJS package additions + +For querying the subgraph from the frontend via Graph Client: + +```json +{ + "scripts": { + "client": "graphclient build" + }, + "dependencies": { + "graphql": "^16.8.0" + }, + "devDependencies": { + "@graphprotocol/client-cli": "^3.0.0" + } +} +``` + +### Root package.json scripts + +```json +{ + "graph": "yarn workspace @se-2/subgraph graph", + "graphclient:build": "yarn workspace @se-2/nextjs client", + "subgraph:abi-copy": "yarn workspace @se-2/subgraph abi-copy", + "subgraph:build": "yarn workspace @se-2/subgraph build", + "subgraph:clean-node": "yarn workspace @se-2/subgraph clean-node", + "subgraph:codegen": "yarn workspace @se-2/subgraph codegen", + "subgraph:create-local": "yarn workspace @se-2/subgraph create-local", + "subgraph:local-ship": "yarn workspace @se-2/subgraph local-ship", + "subgraph:run-node": "yarn workspace @se-2/subgraph run-node", + "subgraph:stop-node": "yarn workspace @se-2/subgraph stop-node", + "subgraph:test": "yarn workspace @se-2/subgraph test -d" +} +``` + +## Docker Setup (Local Graph Node) + +The Graph requires three services: a Graph Node, IPFS, and PostgreSQL. Create `packages/subgraph/graph-node/docker-compose.yml` with these three services: + +- **graph-node**: `graphprotocol/graph-node:v0.41.1` — ports 8000 (GraphQL), 8001, 8020 (admin), 8030, 8040. Set `ethereum: "localhost:http://host.docker.internal:8545"` to connect to the local chain. Add `extra_hosts: ["host.docker.internal:host-gateway"]`. +- **ipfs**: `ipfs/kubo:v0.39.0` (not the legacy `ipfs/go-ipfs`) — port 5001, volume `./data/ipfs:/data/ipfs` +- **postgres**: `postgres` — port 5432, volume `./data/postgres:/var/lib/postgresql/data`. Credentials: user `graph-node`, password `let-me-in`, db `graph-node`. **Must set `POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8"`** — graph-node requires the C locale and will panic on startup otherwise. + +The graph-node environment also needs: `postgres_host: postgres`, `postgres_user/pass/db`, `ipfs: "ipfs:5001"`, `GRAPH_LOG: info`. + +## Subgraph Configuration + +### Subgraph manifest (`subgraph.yaml`) + +The manifest defines what to index. Adapt this to the project's actual contracts: + +```yaml +# packages/subgraph/subgraph.yaml +specVersion: 0.0.4 +description: Your subgraph description +schema: + file: ./src/schema.graphql +dataSources: + - kind: ethereum/contract + name: YourContract + network: localhost + source: + abi: YourContract + address: "0x5FbDB2315678afecb367f032d93F642f64180aa3" + mapping: + kind: ethereum/events + apiVersion: 0.0.6 + language: wasm/assemblyscript + entities: + - Greeting + - Sender + abis: + - name: YourContract + file: ./abis/localhost_YourContract.json + eventHandlers: + - event: GreetingChange(indexed address,string,bool,uint256) + handler: handleGreetingChange + file: ./src/mapping.ts +``` + +**Key fields to update per project:** + +- `name` — must match the contract name in `deployedContracts.ts` +- `address` — auto-updated by `abi-copy` script for localhost +- `eventHandlers` — must match the exact Solidity event signatures (parameter names don't matter, types and order do) +- `entities` — must match what's defined in `schema.graphql` + +### GraphQL schema (`src/schema.graphql`) + +Define entities that represent your indexed data. Each entity maps to a table in the Graph Node's Postgres: + +```graphql +# packages/subgraph/src/schema.graphql +type Greeting @entity(immutable: true) { + id: ID! + sender: Sender! + greeting: String! + premium: Boolean + value: BigInt + createdAt: BigInt! + transactionHash: String! +} + +type Sender @entity(immutable: false) { + id: ID! + address: Bytes! + greetings: [Greeting!] @derivedFrom(field: "sender") + createdAt: BigInt! + greetingCount: BigInt! +} +``` + +### AssemblyScript mappings (`src/mapping.ts`) + +Mappings transform raw event data into entities. They're written in [AssemblyScript](https://www.assemblyscript.org/) (a TypeScript subset that compiles to WASM): + +```typescript +// packages/subgraph/src/mapping.ts +import { BigInt } from "@graphprotocol/graph-ts"; +import { GreetingChange } from "../generated/YourContract/YourContract"; +import { Greeting, Sender } from "../generated/schema"; + +export function handleGreetingChange(event: GreetingChange): void { + const senderString = event.params.greetingSetter.toHexString(); + let sender = Sender.load(senderString); + + if (sender === null) { + sender = new Sender(senderString); + sender.address = event.params.greetingSetter; + sender.createdAt = event.block.timestamp; + sender.greetingCount = BigInt.fromI32(1); + } else { + sender.greetingCount = sender.greetingCount.plus(BigInt.fromI32(1)); + } + + const greeting = new Greeting( + event.transaction.hash.toHex() + "-" + event.logIndex.toString(), + ); + greeting.greeting = event.params.newGreeting; + greeting.sender = senderString; + greeting.premium = event.params.premium; + greeting.value = event.params.value; + greeting.createdAt = event.block.timestamp; + greeting.transactionHash = event.transaction.hash.toHex(); + + greeting.save(); + sender.save(); +} +``` + +AssemblyScript compiles to WASM — no closures, no `Array.map/filter/reduce`, no `console.log`. Use `@graphprotocol/graph-ts` utilities for logging (`log.info()`). + +## ABI Copy Bridge + +The `abi-copy` script bridges SE-2's deployment output to the subgraph. It reads `packages/nextjs/contracts/deployedContracts.ts`, extracts ABIs and addresses for chain ID 31337 (localhost), and writes them to `packages/subgraph/abis/` and `networks.json`. + +Create `packages/subgraph/scripts/abi_copy.ts` — this script parses the deployedContracts file, extracts contract data, and publishes it: + +```typescript +// packages/subgraph/scripts/abi_copy.ts +import * as fs from "fs"; +import type { Abi } from "viem"; + +const DEPLOYED_CONTRACTS_FILE = "../nextjs/contracts/deployedContracts.ts"; +const GRAPH_DIR = "./"; + +function publishContract( + contractName: string, + contractObject: { address: string; abi: Abi }, + networkName: string, +) { + const graphConfigPath = `${GRAPH_DIR}/networks.json`; + let graphConfig = fs.existsSync(graphConfigPath) + ? JSON.parse(fs.readFileSync(graphConfigPath, "utf8")) + : {}; + + if (!graphConfig[networkName]) graphConfig[networkName] = {}; + graphConfig[networkName][contractName] = { address: contractObject.address }; + + fs.writeFileSync(graphConfigPath, JSON.stringify(graphConfig, null, 2)); + if (!fs.existsSync(`${GRAPH_DIR}/abis`)) fs.mkdirSync(`${GRAPH_DIR}/abis`); + fs.writeFileSync( + `${GRAPH_DIR}/abis/${networkName}_${contractName}.json`, + JSON.stringify(contractObject.abi, null, 2), + ); +} + +async function main() { + const fileContent = fs.readFileSync(DEPLOYED_CONTRACTS_FILE, "utf8"); + const match = fileContent.match( + /const deployedContracts = ({[^;]+}) as const;/s, + ); + if (!match?.[1]) throw new Error("Failed to find deployedContracts"); + + // Parse the TS object literal as JSON (add quotes around keys, remove trailing commas) + let json = match[1] + .replace(/(\w+)(?=\s*:)/g, '"$1"') + .replace(/,(?=\s*[}\]])/g, ""); + const contracts = JSON.parse(json); + const localContracts = contracts[31337]; + + if (!localContracts) { + console.error("No contracts for local network."); + return; + } + + for (const name in localContracts) { + publishContract(name, localContracts[name], "localhost"); + } + console.log("Published contracts to subgraph package."); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); +``` + +## Graph Client (Frontend Queries) + +[Graph Client](https://github.com/graphprotocol/graph-client) provides a typed GraphQL client with features like client-side composition and automatic pagination. + +### Configuration + +```yaml +# packages/nextjs/.graphclientrc.yml +sources: + - name: YourContract + handler: + graphql: + endpoint: http://localhost:8000/subgraphs/name/scaffold-eth/your-contract +documents: + - ./graphql/GetGreetings.gql +``` + +### GraphQL queries + +```graphql +# packages/nextjs/graphql/GetGreetings.gql +query GetGreetings { + greetings(first: 25, orderBy: createdAt, orderDirection: desc) { + id + greeting + premium + value + createdAt + sender { + address + greetingCount + } + } +} +``` + +### Using in components + +After running `yarn graphclient:build`, import the generated client. Use TanStack Query (already available in SE-2) for data fetching: + +```tsx +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { GetGreetingsDocument, execute } from "~~/.graphclient"; + +async function fetchGreetings() { + const result = await execute(GetGreetingsDocument, {}); + return result.data?.greetings ?? []; +} + +const GreetingsTable = () => { + const { + data: greetings = [], + isLoading, + error, + } = useQuery({ + queryKey: ["subgraph-greetings"], + queryFn: fetchGreetings, + }); + + // Render data... +}; +``` + +> **`~~/.graphclient`** is the generated runtime artifact. It only exists after `yarn graphclient:build`. The `.graphclient/` directory should NOT be committed — it's generated from `.graphclientrc.yml` and the GQL files. + +## Gotchas & Common Pitfalls + +**Docker must be running.** The local Graph Node, IPFS, and Postgres all run in Docker. If Docker isn't running, `yarn subgraph:run-node` will fail. + +**`yarn deploy` must run before `yarn subgraph:abi-copy`.** The ABI copy script reads from `deployedContracts.ts` which is generated by the deploy step. If you haven't deployed, there's nothing to copy. + +**`local-ship` does everything in one command.** It runs `abi-copy` → `codegen` → `build` → `deploy-local` sequentially. Use this instead of running each step manually. + +**`create-local` only needs to run once.** It registers the subgraph name with the local Graph Node. Running it again will error with "subgraph already exists." Only re-run after `clean-node`. + +**Linux users need `--hostname 0.0.0.0`.** The default Hardhat/Anvil config binds to `127.0.0.1`, which Docker can't reach. Add `--hostname 0.0.0.0` (Hardhat) or `--host 0.0.0.0` (Anvil) to the chain command. You may also need `sudo ufw allow 8545/tcp`. + +**Graph Client artifacts must be regenerated after schema changes.** Run `yarn graphclient:build` whenever you change the GraphQL schema or queries. The frontend imports from `~~/.graphclient` which contains generated types. + +**Port conflicts with other services.** The Graph Node stack uses ports 5001 (IPFS), 5432 (Postgres), 8000 (GraphQL), 8020 (admin). If you're also running the drizzle-neon extension (which uses port 5432 for its own Postgres), you'll have a conflict. Change one of the Postgres ports. + +## How to Test + +1. `yarn chain` — start local blockchain +2. `yarn deploy` — deploy contracts (generates `deployedContracts.ts`) +3. `yarn subgraph:run-node` — start Docker Graph Node (keep this terminal open) +4. `yarn subgraph:create-local` — register subgraph (once only) +5. `yarn subgraph:local-ship` — copies ABIs, generates types, builds, and deploys +6. Visit `http://localhost:8000/subgraphs/name/scaffold-eth/your-contract/graphql` — test GraphQL queries +7. `yarn graphclient:build` — generate frontend client artifacts +8. `yarn start` — visit the subgraph page to see indexed data +9. `yarn subgraph:test` — run Matchstick unit tests + +### Deploying to Subgraph Studio + +1. Update `subgraph.yaml`: change `network` from `localhost` to target network (e.g., `sepolia`), add deployed `address` and `startBlock` +2. Create a subgraph on [Subgraph Studio](https://thegraph.com/studio/) +3. `yarn graph auth --studio ` +4. `yarn graph deploy --studio ` +5. Update the Graph Client endpoint in `.graphclientrc.yml` to point to the Studio URL + +For the full list of [supported networks](https://thegraph.com/docs/networks), check The Graph docs. diff --git a/templates/base/.agents/skills/x402/SKILL.md b/templates/base/.agents/skills/x402/SKILL.md new file mode 100644 index 0000000000..53ab5fab70 --- /dev/null +++ b/templates/base/.agents/skills/x402/SKILL.md @@ -0,0 +1,230 @@ +--- +name: x402 +description: "Add x402 payment-gated routes to a Scaffold-ETH 2 project. Use when the user wants to: monetize an API with micropayments, add HTTP 402 payment required to routes, gate content behind crypto payments, implement pay-per-call APIs, or integrate the x402 protocol." +--- + +# x402 Payment Protocol Integration for Scaffold-ETH 2 + +## Prerequisites + +Check if `./packages/nextjs/scaffold.config.ts` exists directly in the current working directory (do not search subdirectories). If it doesn't exist, this is not a Scaffold-ETH 2 project. Follow the instructions at https://docs.scaffoldeth.io/SKILL.md to scaffold it first. If it exists, continue directly with building. + +## Overview + +[x402](https://www.x402.org/) is an open payment protocol by Coinbase that uses HTTP status code 402 ("Payment Required") to enable instant stablecoin micropayments over HTTP. When a client requests a protected resource without payment, the server responds with 402 + payment instructions. The client signs a payment, retries the request, and gets access. + +This skill covers integrating x402 into SE-2 using Next.js middleware. For the full protocol spec and advanced usage, refer to the [x402 docs](https://docs.cdp.coinbase.com/x402/welcome) or the [GitHub repo](https://github.com/coinbase/x402). This skill focuses on SE-2 integration specifics and gotchas. + +## Dependencies + +### NextJS package + +Add to `packages/nextjs/package.json`: + +```json +{ + "dependencies": { + "@x402/core": "^2.2.0", + "@x402/evm": "^2.2.0", + "@x402/next": "^2.2.0", + "@x402/paywall": "^2.2.0" + } +} +``` + +### Hardhat package (for CLI payment script) + +If the user wants a CLI script to test API routes programmatically, add to `packages/hardhat/package.json`: + +```json +{ + "scripts": { + "send402request": "hardhat run scripts/send402request.ts" + }, + "dependencies": { + "@x402/core": "^2.2.0", + "@x402/evm": "^2.2.0", + "@x402/fetch": "^2.2.0" + } +} +``` + +Add to root `package.json`: `"send402request": "yarn workspace @se-2/hardhat send402request"` + +### Environment variables + +Create `packages/nextjs/.env.development` (or `.env.local`): + +```env +# Facilitator service URL — verifies and settles payments +# Default testnet facilitator (free, no signup needed): +NEXT_PUBLIC_FACILITATOR_URL=https://x402.org/facilitator + +# Address that receives payments (set to your deployer or any wallet) +RESOURCE_WALLET_ADDRESS=0xYourAddressHere + +# CAIP-2 network identifier (eip155:84532 = Base Sepolia, eip155:8453 = Base Mainnet) +NETWORK=eip155:84532 +``` + +### scaffold.config.ts + +x402 payments happen onchain, so `targetNetworks` must include a supported chain. For development, use `baseSepolia`: + +```typescript +targetNetworks: [chains.baseSepolia], +``` + +**Do not use `hardhat` (localhost) as the target network for x402** — the facilitator needs a real chain to verify/settle payments. + +## x402 Protocol Flow + +``` +Client GET /api/protected + → Server: no X-PAYMENT header → responds 402 + PAYMENT-REQUIRED header +Client: signs EIP-712 payment authorization (USDC approve) +Client GET /api/protected + X-PAYMENT header + → Server middleware: sends payment to facilitator for verification + → Facilitator: verifies signature, checks balance + → Server: serves content + → Server middleware: sends settlement to facilitator + → Facilitator: executes the USDC transfer onchain +``` + +Key insight: **The user never sends a transaction themselves.** They sign an EIP-712 message authorizing a USDC transfer. The facilitator executes it after the server confirms content was delivered. + +## Middleware Configuration + +The core of x402 integration is `middleware.ts` in the Next.js app root. The v2 API uses `paymentProxy` from `@x402/next` with explicit server and paywall setup. + +```typescript +// packages/nextjs/middleware.ts +import { paymentProxy } from "@x402/next"; +import { HTTPFacilitatorClient, x402ResourceServer } from "@x402/core/server"; +import { registerExactEvmScheme } from "@x402/evm/exact/server"; +import { createPaywall } from "@x402/paywall"; +import { evmPaywall } from "@x402/paywall/evm"; + +const facilitatorUrl = process.env.NEXT_PUBLIC_FACILITATOR_URL!; +const payTo = process.env.RESOURCE_WALLET_ADDRESS as `0x${string}`; +const network = process.env.NETWORK as `${string}:${string}`; + +// Create facilitator client and resource server +const facilitatorClient = new HTTPFacilitatorClient({ url: facilitatorUrl }); +const server = new x402ResourceServer(facilitatorClient); +registerExactEvmScheme(server); + +// Create paywall UI (shown to browsers visiting protected pages) +const paywall = createPaywall() + .withNetwork(evmPaywall) + .withConfig({ + appName: "My dApp", + appLogo: "/logo.png", + testnet: true, // set false for mainnet + }) + .build(); + +export const middleware = paymentProxy( + { + "/api/payment/:path*": { + accepts: [ + { + scheme: "exact", + price: "$0.01", + network, + payTo, + }, + ], + description: "Access to premium API data", + mimeType: "application/json", + }, + "/payment/:path*": { + accepts: [ + { + scheme: "exact", + price: "$0.01", + network, + payTo, + }, + ], + description: "Access to premium content", + mimeType: "text/html", + }, + }, + server, + undefined, // optional request context + paywall, +); + +// IMPORTANT: matcher must cover all protected routes +export const config = { + matcher: ["/api/payment/:path*", "/payment/:path*"], +}; +``` + +## CAIP-2 Network Identifiers + +The v2 API uses [CAIP-2](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md) network identifiers — format: `eip155:{chainId}` for EVM chains. + +| CAIP-2 ID | Chain | Notes | +|-----------|-------|-------| +| `eip155:84532` | Base Sepolia | Default for development — [Circle faucet](https://faucet.circle.com/) for test USDC | +| `eip155:8453` | Base | Recommended for production — lowest fees | + +Legacy network names (`base-sepolia`, `base`, etc.) may still work for backwards compatibility, but prefer CAIP-2 format. For the full list of supported networks, check the [x402 docs](https://docs.cdp.coinbase.com/x402/welcome). + +## Gotchas & Common Pitfalls + +**Facilitator is required.** x402 doesn't do peer-to-peer payments. The facilitator service verifies signatures and executes settlements. For testnet, `https://x402.org/facilitator` works without signup. For production, you may need to run your own — check [x402 docs](https://docs.cdp.coinbase.com/x402/welcome). + +**Register the EVM scheme.** The server needs `registerExactEvmScheme(server)` in middleware.ts. Without this, payment payloads won't be understood. + +**Payments are in USDC by default.** The `$0.01` price syntax means USDC. + +**Don't use `hardhat` localhost as the network.** The facilitator can't verify or settle payments on a local chain. Always use a testnet (`eip155:84532`) even during development. + +**The `matcher` in `middleware.ts` must cover protected routes.** If you add a new protected route in the routes config but forget to add it to `matcher`, the middleware won't run on that route. + +## CLI Payment Script + +For testing API routes programmatically (without a browser), create a script using `@x402/fetch`: + +```typescript +// packages/hardhat/scripts/send402request.ts +import { privateKeyToAccount } from "viem/accounts"; +import { x402Client, wrapFetchWithPayment } from "@x402/fetch"; +import { registerExactEvmScheme } from "@x402/evm/exact/client"; + +async function main() { + const privateKey = process.env.DEPLOYER_PRIVATE_KEY as `0x${string}`; + if (!privateKey) { console.log("No deployer key. Run `yarn generate` first."); return; } + + const signer = privateKeyToAccount(privateKey); + const client = new x402Client(); + registerExactEvmScheme(client, { signer }); + + const fetchWithPayment = wrapFetchWithPayment(fetch, client); + const response = await fetchWithPayment("http://localhost:3000/api/payment/builder", { method: "GET" }); + console.log("Response:", await response.json()); +} + +main().catch(console.error); +``` + +> **Note:** Register the EVM scheme on the client side too — `registerExactEvmScheme(client, { signer })` from `@x402/evm/exact/client`. + +## How to Test + +1. Set `targetNetworks: [chains.baseSepolia]` in `scaffold.config.ts` +2. Configure `.env.development` with facilitator URL, pay-to address, and `NETWORK=eip155:84532` +3. `yarn start` — visit `http://localhost:3000` +4. Navigate to a protected page — you should see the x402 paywall +5. To test API routes: `curl http://localhost:3000/api/payment/builder` should return 402 with `PAYMENT-REQUIRED` header +6. To test paid access: `yarn send402request` (needs funded wallet on Base Sepolia — get test USDC from [Circle faucet](https://faucet.circle.com/)) + +### Production + +- Switch `NETWORK` to `eip155:8453` (Base mainnet) +- Update `scaffold.config.ts` to target the mainnet chain +- Set `RESOURCE_WALLET_ADDRESS` to your production payment receiver +- Set `testnet: false` in paywall config diff --git a/templates/base/AGENTS.md.template.mjs b/templates/base/AGENTS.md.template.mjs index d59a3ad6db..6c1f33a550 100644 --- a/templates/base/AGENTS.md.template.mjs +++ b/templates/base/AGENTS.md.template.mjs @@ -240,14 +240,14 @@ IMPORTANT: Prefer retrieval-led reasoning over pre-trained knowledge. Before sta **Skills** (read \`.agents/skills//SKILL.md\` before implementing): -- **erc-20** — fungible tokens, decimals, approve patterns, OpenZeppelin ERC-20 -- **erc-721** — NFTs, metadata standards, royalties (ERC-2981), ERC721A, soulbound -- **eip-712** — typed structured data signing, off-chain signatures, signature verification +- **openzeppelin** — OpenZeppelin Contracts integration, library-first development, pattern discovery from installed source. Use for any contract using OZ (tokens, access control, security primitives) +- **erc-721** — NFT-specific pitfalls: \`_safeMint\` reentrancy, on-chain SVG stack-too-deep, marketplace metadata \`attributes\`, IPFS base URI trailing slash - **eip-5792** — batch transactions, wallet_sendCalls, paymaster, ERC-7677 - **ponder** — blockchain event indexing, GraphQL APIs, onchain data queries - **siwe** — Sign-In with Ethereum, wallet authentication, SIWE sessions, EIP-4361 -- **defi-protocol-templates** — staking, AMMs, governance, flash loans, lending -- **solidity-security** — security audits, reentrancy, access control, gas optimization +- **x402** — HTTP 402 payment-gated routes, micropayments, API monetization, x402 protocol +- **drizzle-neon** — Drizzle ORM, Neon PostgreSQL, database integration, off-chain storage +- **subgraph** — The Graph subgraph integration, blockchain event indexing, GraphQL APIs **Agents** (in \`.agents/agents/\`):