diff --git a/CONTRACT_VERIFICATION.md b/CONTRACT_VERIFICATION.md index e4bf841..0c53a90 100644 --- a/CONTRACT_VERIFICATION.md +++ b/CONTRACT_VERIFICATION.md @@ -1,56 +1,142 @@ -# Contract verification on block explorers +# Contract Verification on Block Explorers -This repository ships a **Soroban (Stellar)** contract (`contracts/ajo-circle`). If you also deploy an **EVM** build (e.g. Polygon), use **Etherscan** (or the network’s explorer) to verify Solidity sources. +> **Issue #165** — Push ABI definitions to block explorers to enable GUI tracking +> and community transparency on Sepolia (and Ethereum mainnet). -## Stellar / Soroban (this repo) +--- -1. Build the WASM artifact: +## Quick Reference - ```bash - cd contracts/ajo-circle - cargo build --target wasm32-unknown-unknown --release - ``` +| Command | What it does | +|---------|-------------| +| `npm run deploy:sepolia` | Deploy AjoCircle + AjoFactory to Sepolia | +| `npm run verify:sepolia` | Verify both contracts on Etherscan | +| `npm run deploy:verify:sepolia` | Deploy and verify in one step | -2. Install the [Stellar CLI](https://developers.stellar.org/docs/tools/developer-tools) and deploy or reuse your deployed contract address. +--- -3. Publish **verified source** on [Stellar Expert](https://stellar.expert) (or your network’s explorer): upload the matching WASM and, when prompted, the **exact** constructor / install parameters used at deploy time. +## Prerequisites -4. Optionally run the helper script (prints build info and verification checklist): +1. **Etherscan API key** — create one at + Free tier is sufficient; 5 calls/second, unlimited verifications. - ```bash - ./scripts/verify-stellar-contract.sh +2. **Environment variables** — copy `.env.example` → `.env` and fill in: + + ```env + ETHERSCAN_API_KEY=YOUR_ETHERSCAN_API_KEY + SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/YOUR_INFURA_API_KEY + PRIVATE_KEY=0xYOUR_DEPLOYER_PRIVATE_KEY ``` -## Ethereum / Polygon — Etherscan-style verification +3. **Deploy first** — the verification script reads + `contracts/ethereum/deployed-.json` to obtain the exact constructor + arguments used on-chain. Run deployment before verification. + +--- + +## Step-by-step -For Solidity contracts (not the Soroban crate in this tree): +### 1 · Deploy -1. Create an API key at [Etherscan](https://etherscan.io/apis) (or the target chain’s explorer, e.g. Polygonscan). +```bash +# From the repo root +npm run deploy:sepolia +``` + +This runs `contracts/ethereum/scripts/deploy.js`, which: +- Deploys **AjoCircle** (implementation, constructor: none) +- Deploys **AjoFactory** (constructor: `address _implementation`) +- Saves addresses + constructor args to `contracts/ethereum/deployed-sepolia.json` +- Attempts inline Etherscan verification (non-blocking) -2. Use **Hardhat** with `@nomicfoundation/hardhat-verify` (successor to `hardhat-etherscan`) or **Foundry** `forge verify-contract`. +### 2 · Verify (if inline verification failed or was skipped) -3. Pass **constructor arguments** exactly as used on-chain (often ABI-encoded; Hardhat’s `verify` task can take them via `--constructor-args`). +```bash +npm run verify:sepolia +``` -4. Open the contract page on the explorer and confirm the **green checkmark** / verified badge. +This runs `scripts/verify.ts`, which: +- Reads `contracts/ethereum/deployed-sepolia.json` +- Waits 30 s for Etherscan to index the contracts +- Calls `verify:verify` for **AjoCircle** (0 constructor args) +- Calls `verify:verify` for **AjoFactory** (`_implementation = `) +- Prints direct Etherscan `#code` links on success +- Gracefully skips contracts that are already verified -### Hardhat example (EVM projects) +### 3 · Combined (deploy + verify in one shot) ```bash -npx hardhat verify --network "" "" +npm run deploy:verify:sepolia ``` -Or use the helper script provided in this repo (reuses deployment metadata and the exact constructor args for AjoCircle): +### 4 · Manual fallback + +If the scripts fail for any reason you can verify manually: ```bash -cd contracts -npm run verify:sepolia -# or -npm run verify:mainnet +# AjoCircle — no constructor args +npx hardhat verify \ + --network sepolia \ + --contract "contracts/ethereum/contracts/AjoCircle.sol:AjoCircle" \ + + +# AjoFactory — takes the AjoCircle address as its single constructor arg +npx hardhat verify \ + --network sepolia \ + --contract "contracts/ethereum/contracts/AjoFactory.sol:AjoFactory" \ + \ + ``` -Set `ETHERSCAN_API_KEY` (or `POLYGONSCAN_API_KEY`, etc.) in your environment or `hardhat.config`. +--- + +## How verification works + +`@nomicfoundation/hardhat-verify` (already installed) submits the **Solidity +source + compiler settings** from your local build to the Etherscan API. Etherscan +re-compiles the source and checks that the resulting bytecode matches what is +stored on-chain. On success it: + +- Displays a **green ✓ checkmark** on the contract page +- Publishes the **ABI** so users can interact via the Etherscan GUI ("Read/Write + Contract" tabs) +- Enables **event log decoding** — community members see human-readable event + names instead of raw hex topics + +A **Sourcify** fallback is also configured in `hardhat.config.ts` (no API key +required) for decentralised, IPFS-backed verification. + +--- + +## Stellar / Soroban (main contract) + +This repo's primary savings contract (`contracts/ajo-circle`) runs on Soroban. +Verification there is WASM-based: + +1. Build the WASM artifact: + + ```bash + cd contracts/ajo-circle + cargo build --target wasm32-unknown-unknown --release + ``` + +2. Upload the WASM + matching constructor parameters to + [Stellar Expert](https://stellar.expert) (testnet or mainnet). + +3. Optionally run the helper: + + ```bash + ./scripts/verify-stellar-contract.sh + ``` + +--- -## Why both sections? +## Troubleshooting -- **Stellar Ajo** uses Soroban; verification is WASM + explorer metadata. -- Issue trackers sometimes say “Etherscan” generically; the equivalent workflow for this codebase is **Stellar Expert** unless you maintain a separate EVM deployment. +| Symptom | Fix | +|---------|-----| +| `ETHERSCAN_API_KEY is not set` | Add the key to `.env` | +| `Contract does not have bytecode` | Wait ~1 min for the TX to be indexed, then retry | +| `Already Verified` | Nothing to do — the contract is already public | +| Wrong constructor args | Check `deployed-sepolia.json`; re-run deploy if stale | +| `hardhat verify` flag errors | Ensure `@nomicfoundation/hardhat-verify` is installed: `npm install` | diff --git a/contracts/ethereum/hardhat.config.js b/contracts/ethereum/hardhat.config.js index fad5dfb..94e8c4b 100644 --- a/contracts/ethereum/hardhat.config.js +++ b/contracts/ethereum/hardhat.config.js @@ -49,8 +49,19 @@ module.exports = { chainId: 1, }, }, + // @nomicfoundation/hardhat-verify resolves the key by network name. + // Add more networks here (e.g. mainnet, polygon) as needed. etherscan: { - apiKey: ETHERSCAN_API_KEY, + apiKey: { + sepolia: ETHERSCAN_API_KEY || "", + mainnet: ETHERSCAN_API_KEY || "", + }, + customChains: [], + }, + + // Sourcify: decentralised fallback (no API key needed). + sourcify: { + enabled: true, }, gasReporter: { enabled: process.env.REPORT_GAS === "true", diff --git a/contracts/ethereum/package.json b/contracts/ethereum/package.json index 0941be2..be374c3 100644 --- a/contracts/ethereum/package.json +++ b/contracts/ethereum/package.json @@ -8,7 +8,7 @@ "deploy:local": "hardhat run scripts/deploy.js --network localhost", "deploy:sepolia": "hardhat run scripts/deploy.js --network sepolia", "deploy:mainnet": "hardhat run scripts/deploy.js --network mainnet", - "verify:sepolia": "hardhat verify --network sepolia", + "verify:sepolia": "echo 'Run: npm run verify:sepolia from the repo root (uses scripts/verify.ts)' && exit 1", "node": "hardhat node" }, "devDependencies": { @@ -18,4 +18,4 @@ "dotenv": "^16.3.1", "hardhat": "^2.22.0" } -} +} \ No newline at end of file diff --git a/hardhat.config.ts b/hardhat.config.ts index e8d29dd..1a10de8 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -1,8 +1,6 @@ +/// import { HardhatUserConfig } from "hardhat/config"; import "@nomicfoundation/hardhat-toolbox"; -import "@nomicfoundation/hardhat-verify"; -import "hardhat-gas-reporter"; -import "solidity-coverage"; import dotenv from "dotenv"; dotenv.config(); @@ -17,6 +15,12 @@ const SEPOLIA_RPC_URL = process.env.SEPOLIA_RPC_URL || ""; const SEPOLIA_PRIVATE_KEY = process.env.SEPOLIA_PRIVATE_KEY || ""; const ETHERSCAN_API_KEY = process.env.ETHERSCAN_API_KEY || ""; +if (!ETHERSCAN_API_KEY && process.env.NODE_ENV !== "test") { + console.warn( + "⚠️ ETHERSCAN_API_KEY is not set — contract verification will fail on Sepolia." + ); +} + if (!SEPOLIA_RPC_URL && process.env.NODE_ENV === "production") { console.warn("⚠️ SEPOLIA_RPC_URL is not set in .env"); } @@ -58,11 +62,22 @@ const config: HardhatUserConfig = { }, }, - // Etherscan verification + // ─── Block-explorer verification ───────────────────────────────────────── + // @nomicfoundation/hardhat-verify resolves apiKey by network name. + // Add entries here when targeting additional chains (e.g. mainnet, polygon). etherscan: { apiKey: { - sepolia: ETHERSCAN_API_KEY || "", + sepolia: ETHERSCAN_API_KEY, + mainnet: ETHERSCAN_API_KEY, }, + // Extend with custom chains that are not natively supported by hardhat-verify + customChains: [], + }, + + // Sourcify: decentralised, IPFS-backed verification (no API key required). + // Falls back to this if Etherscan verification is unavailable. + sourcify: { + enabled: true, }, // Gas reporter diff --git a/package.json b/package.json index 70c5348..0aebd1b 100644 --- a/package.json +++ b/package.json @@ -11,15 +11,16 @@ "start": "next start", "start:server": "node server/dist/index.js", "lint": "eslint .", - "test": "npm run test:contracts && npm run test:unit", + "test": "npm run test:contracts", "test:unit": "jest --runInBand", "test:api": "jest --runInBand --selectProjects api", "db:generate": "prisma generate", "db:push": "prisma db push", "db:migrate": "prisma migrate dev", - "test": "npm run test:contracts", "test:contracts": "cd contracts/ajo-circle && cargo test --lib", - "deploy:sepolia": "hardhat run scripts/deploy.js --network sepolia" + "deploy:sepolia": "hardhat run contracts/ethereum/scripts/deploy.js --network sepolia", + "verify:sepolia": "hardhat run scripts/verify.ts --network sepolia", + "deploy:verify:sepolia": "npm run deploy:sepolia && npm run verify:sepolia" }, "dependencies": { "@hookform/resolvers": "^3.9.1", @@ -69,6 +70,7 @@ "cors": "^2.8.5", "date-fns": "4.1.0", "embla-carousel-react": "8.6.0", + "ethers": "^5.7.2", "express": "^4.21.2", "express-rate-limit": "^7.5.0", "helmet": "^8.0.0", @@ -89,6 +91,7 @@ "react-resizable-panels": "^2.1.7", "recharts": "2.15.0", "resend": "^4.8.0", + "siwe": "^2.2.0", "sonner": "^1.7.1", "swr": "^2.2.5", "tailwind-merge": "^3.3.1", @@ -120,6 +123,8 @@ "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "concurrently": "^9.1.2", + "dotenv": "^17.3.1", + "hardhat": "^3.2.0", "identity-obj-proxy": "^3.0.0", "jest": "^30.3.0", "jest-environment-jsdom": "^30.3.0", @@ -128,13 +133,10 @@ "tailwindcss": "^4.2.0", "ts-jest": "^29.4.6", "ts-node": "^10.9.2", - "hardhat": "^3.2.0", - "postcss": "^8.5", - "prisma": "^5.10.0", - "tailwindcss": "^4.2.0", "tsx": "^4.19.2", "tw-animate-css": "1.3.3", "typescript": "5.7.3" }, - "packageManager": "pnpm@10.30.2+sha512.36cdc707e7b7940a988c9c1ecf88d084f8514b5c3f085f53a2e244c2921d3b2545bc20dd4ebe1fc245feec463bb298aecea7a63ed1f7680b877dc6379d8d0cb4" + "packageManager": "pnpm@10.30.2+sha512.36cdc707e7b7940a988c9c1ecf88d084f8514b5c3f085f53a2e244c2921d3b2545bc20dd4ebe1fc245feec463bb298aecea7a63ed1f7680b877dc6379d8d0cb4", + "type": "module" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6130f54..c6a7aba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -297,6 +297,9 @@ importers: concurrently: specifier: ^9.1.2 version: 9.2.1 + dotenv: + specifier: ^17.3.1 + version: 17.3.1 hardhat: specifier: ^3.2.0 version: 3.2.0 @@ -3070,6 +3073,10 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dotenv@17.3.1: + resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -7829,6 +7836,8 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + dotenv@17.3.1: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 diff --git a/scripts/verify.ts b/scripts/verify.ts new file mode 100644 index 0000000..0281918 --- /dev/null +++ b/scripts/verify.ts @@ -0,0 +1,197 @@ +/** + * @file scripts/verify.ts + * @description Post-deploy Etherscan verification for AjoCircle + AjoFactory. + * Reads the deployment manifest written by deploy.js so + * constructor arguments are always in sync with on-chain state. + * + * Usage (issue #165): + * npx hardhat run scripts/verify.ts --network sepolia + * + * Prerequisites: + * - ETHERSCAN_API_KEY set in your .env + * - contracts/ethereum/deployed-sepolia.json populated by the deploy script + */ + +// @ts-ignore +import { run, network } from "hardhat"; +import fs from "fs"; +import path from "path"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface ContractEntry { + address: string | null; + txHash: string | null; + role: string; +} + +interface DeploymentManifest { + network: string; + chainId: number; + contracts: { + AjoCircle?: ContractEntry; + AjoFactory?: ContractEntry; + [key: string]: ContractEntry | undefined; + }; + chainlink?: { + ethUsdPriceFeed: string; + }; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Sleep for `ms` milliseconds. */ +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Call hardhat's verify:verify task and handle common non-fatal errors gracefully. + */ +async function verifyContract( + label: string, + address: string, + constructorArguments: unknown[], + contract?: string +): Promise { + console.log(`\n▸ Verifying ${label} at ${address} …`); + try { + await run("verify:verify", { + address, + constructorArguments, + ...(contract ? { contract } : {}), + }); + console.log(` ✓ ${label} verified successfully.`); + } catch (err: any) { + const msg: string = err?.message ?? String(err); + + if (/already verified/i.test(msg)) { + console.log(` ℹ ${label} is already verified — skipping.`); + } else if (/does not have bytecode/i.test(msg)) { + console.warn( + ` ⚠ ${label}: contract not yet visible on Etherscan. ` + + `Try again in a minute or verify manually:\n` + + ` npx hardhat verify --network ${network.name} ${address}` + ); + } else { + // Re-throw unexpected errors so the CI pipeline can catch them. + throw err; + } + } +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +async function main(): Promise { + const NET = network.name; + const SEP = "═".repeat(54); + + console.log(`\n${SEP}`); + console.log(` Ajo Contracts — Etherscan Verification`); + console.log(` Network : ${NET.toUpperCase()}`); + console.log(SEP); + + // ── Guard: skip on local networks ────────────────────────────────────────── + if (NET === "hardhat" || NET === "localhost") { + console.log(`⏭ Skipping verification on local network '${NET}'.`); + return; + } + + // ── Guard: require API key ───────────────────────────────────────────────── + if (!process.env.ETHERSCAN_API_KEY) { + console.error( + `❌ ETHERSCAN_API_KEY is not set.\n` + + ` Obtain one at https://etherscan.io/myapikey and add it to your .env.` + ); + process.exit(1); + } + + // ── Load deployment manifest ─────────────────────────────────────────────── + const manifestPath = path.join( + __dirname, + "../contracts/ethereum", + `deployed-${NET}.json` + ); + + if (!fs.existsSync(manifestPath)) { + console.error( + `❌ Deployment manifest not found: ${manifestPath}\n` + + ` Run the deploy script first:\n` + + ` npx hardhat run contracts/ethereum/scripts/deploy.js --network ${NET}` + ); + process.exit(1); + } + + const manifest: DeploymentManifest = JSON.parse( + fs.readFileSync(manifestPath, "utf-8") + ); + + const ajoCircleEntry = manifest.contracts?.AjoCircle; + const ajoFactoryEntry = manifest.contracts?.AjoFactory; + const priceFeedAddress = + manifest.chainlink?.ethUsdPriceFeed ?? + "0x0000000000000000000000000000000000000000"; + + if (!ajoCircleEntry?.address || !ajoFactoryEntry?.address) { + console.error( + `❌ One or both contract addresses are null in ${manifestPath}.\n` + + ` Please deploy first and ensure the manifest is populated.` + ); + process.exit(1); + } + + // After the guard above TypeScript cannot narrow through process.exit(), + // so we extract typed string constants to eliminate string | null errors. + const circleAddr: string = ajoCircleEntry!.address!; + const factoryAddr: string = ajoFactoryEntry!.address!; + + console.log(`\nLoaded deployment manifest:`); + console.log(` AjoCircle : ${circleAddr}`); + console.log(` AjoFactory : ${factoryAddr}`); + console.log(` Price Feed : ${priceFeedAddress}`); + + // ── Wait for Etherscan to index ──────────────────────────────────────────── + const WAIT_MS = 30_000; + console.log( + `\n⏳ Waiting ${WAIT_MS / 1000}s for Etherscan to index the contracts …` + ); + await sleep(WAIT_MS); + + // ── Verify AjoCircle ─────────────────────────────────────────────────────── + // AjoCircle uses `_disableInitializers()` in its empty constructor — no + // user-facing constructor args are needed. The full contract path + // disambiguates it from the stub AjoCircle.sol in the top-level contracts/. + await verifyContract( + "AjoCircle", + circleAddr, + [], // constructor() { _disableInitializers(); } — no args + "contracts/ethereum/contracts/AjoCircle.sol:AjoCircle" + ); + + // ── Verify AjoFactory ────────────────────────────────────────────────────── + // AjoFactory(address _implementation) — deployed with AjoCircle's address. + await verifyContract( + "AjoFactory", + factoryAddr, + [circleAddr], + "contracts/ethereum/contracts/AjoFactory.sol:AjoFactory" + ); + + // ── Summary ──────────────────────────────────────────────────────────────── + console.log(`\n${SEP}`); + console.log(` Verification Complete`); + console.log(SEP); + console.log(`\nExplorer links:`); + console.log( + ` AjoCircle → https://${NET}.etherscan.io/address/${circleAddr}#code` + ); + console.log( + ` AjoFactory → https://${NET}.etherscan.io/address/${factoryAddr}#code` + ); + console.log(); +} + +main().catch((err: unknown) => { + console.error("\n✗ Verification failed:", err); + process.exit(1); +}); diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json new file mode 100644 index 0000000..86048cc --- /dev/null +++ b/tsconfig.scripts.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "Node16", + "moduleResolution": "Node16", + "lib": [ + "ES2020" + ], + "types": [ + "node" + ], + "esModuleInterop": true, + "resolveJsonModule": true, + "strict": true, + "skipLibCheck": true, + "outDir": "./dist-scripts" + }, + "include": [ + "hardhat.config.ts", + "scripts/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file