# Hello FHEVM：私密计数器（完整教程）

By [pangdong](https://paragraph.com/@pangdong) · 2025-09-19

---

用最小可用的 Demo，带你从零实现：**密文加法 + 明文解密展示**。 技术栈：Solidity + Hardhat + React/Vite + Zama FHEVM SDK。 目标链：**Sepolia** 测试网。

* * *

0\. 你将得到什么？
-----------

*   一个可以**接受加密输入**（+1）的合约 `PrivateCounter`
    
*   一个前端 DApp：
    
    *   先**连接任意 EIP-1193 钱包**（MetaMask/OKX/Bitget 等均可）
        
    *   使用 **Zama Relayer** 注册加密输入，调用合约的 `add()`
        
    *   通过合约发起解密 `requestReveal()`，等待回调后读取 `totalPlain`
        
*   一键部署到 **Vercel** 的网站链接
    

* * *

1\. 准备环境
--------

*   Node.js ≥ 18（推荐 20+）
    
*   pnpm（也可用 npm/yarn）
    
        npm i -g pnpm
        
    
*   一个可用的 **Sepolia RPC**（公共节点即可）
    
    *   推荐先测试延迟/可用性：
        
            # 任选其一可用即可
            for u in https://ethereum-sepolia.publicnode.com https://rpc.sepolia.org; do
              printf "%-38s" "$u"
              r=$(curl -s -m 6 -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' "$u")
              bn=$(echo "$r" | sed -n 's/.*"result":"\([^"]*\)".*/\1/p'); 
              if [ -n "$bn" ]; then echo " OK block=$bn"; else echo " FAIL resp=$(echo "$r" | cut -c1-80)"; fi
            done
            
        
*   钱包（浏览器扩展），建议 **MetaMask**
    

* * *

2\. 初始化 Hardhat 项目
------------------

    mkdir -p hello-fhevm && cd hello-fhevm
    pnpm init -y
    mkdir hardhat && cd hardhat
    pnpm add -D hardhat typescript ts-node @types/node @nomicfoundation/hardhat-toolbox \
               @fhevm/solidity @fhevm/hardhat-plugin dotenv
    pnpm dlx hardhat  # 选“Create a TypeScript project”
    

生成的 `hardhat.config.ts` 替换为（适配 FHEVM 插件并禁用它在非本地链的 remapping）：

    // hardhat/hardhat.config.ts
    import { HardhatUserConfig } from "hardhat/config";
    import "@nomicfoundation/hardhat-toolbox";
    import * as dotenv from "dotenv";
    dotenv.config();
    
    const SEPOLIA_RPC_URL = process.env.SEPOLIA_RPC_URL || "https://ethereum-sepolia.publicnode.com";
    const PRIVATE_KEY = (process.env.PRIVATE_KEY || "").replace(/^"|"$/g, "");
    
    const config: HardhatUserConfig = {
      solidity: {
        version: "0.8.24",
        settings: { viaIR: false, optimizer: { enabled: true, runs: 200 } },
      },
      networks: {
        sepolia: {
          url: SEPOLIA_RPC_URL,
          accounts: PRIVATE_KEY ? [PRIVATE_KEY] : [],
        },
      },
    };
    export default config;
    

> 说明：FHEVM 的 `@fhevm/hardhat-plugin` 只用于本地模拟开发，本教程直接用 Zama 提供的链上 KMS/Relayer，不需要在编译阶段做 remapping，所以上面的配置**不启用**插件扩展。

`.env`（放在 `hardhat/` 目录下）：

    SEPOLIA_RPC_URL=https://ethereum-sepolia.publicnode.com
    PRIVATE_KEY=0x你的私钥
    

* * *

3\. 编写合约 `PrivateCounter.sol`
-----------------------------

    // hardhat/contracts/PrivateCounter.sol
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.24;
    
    import "@fhevm/solidity/lib/FHE.sol";
    import { SepoliaConfig } from "@fhevm/solidity/config/ZamaConfig.sol";
    
    /**
     * 私密计数器：
     * - add():   接收加密的 euint32，累计到密文 total
     * - requestReveal(): 发起一次解密请求（走 Zama KMS）
     * - onReveal():      KMS 回调，验签 & 更新明文 totalPlain
     */
    contract PrivateCounter is SepoliaConfig {
        euint32 private total;
        uint32  public  totalPlain;
        bool    private pending;
        uint256 private lastReqId;
    
        constructor() {
            total = FHE.asEuint32(0);
            FHE.allowThis(total);
        }
    
        function add(
            externalEuint32 encryptedDelta,
            bytes calldata inputProof
        ) external {
            euint32 delta = FHE.fromExternal(encryptedDelta, inputProof);
            total = FHE.add(total, delta);
            FHE.allowThis(total);
        }
    
        function requestReveal() external {
            require(!pending, "Decrypting in progress");
    
            bytes32;
            // 某些版本是 toBytes(total)；新版本为 toBytes32(total)
            handles[0] = FHE.toBytes32(total);
    
            lastReqId = FHE.requestDecryption(handles, this.onReveal.selector);
            pending = true;
        }
    
        function onReveal(
            uint256 requestId,
            bytes memory cleartexts,
            bytes memory decryptionProof
        ) external returns (bool) {
            require(requestId == lastReqId, "Invalid request id");
            FHE.checkSignatures(requestId, cleartexts, decryptionProof);
    
            (uint32 value) = abi.decode(cleartexts, (uint32));
            totalPlain = value;
            pending = false;
            return true;
        }
    }
    

编译：

    pnpm hardhat clean && pnpm hardhat compile
    

* * *

4\. 部署脚本
--------

`scripts/deploy.ts`：

    import { ethers } from "hardhat";
    
    async function main() {
      const [deployer] = await ethers.getSigners();
      console.log("Deployer:", deployer.address);
    
      const Factory = await ethers.getContractFactory("PrivateCounter");
      const c = await Factory.deploy();
      await c.waitForDeployment();
    
      console.log("✅ PrivateCounter deployed at:", await c.getAddress());
    }
    main().catch((e)=>{ console.error(e); process.exit(1); });
    

部署到 Sepolia：

    pnpm hardhat run scripts/deploy.ts --network sepolia
    

> 记下输出的合约地址，后面前端要用。文中示例： `0x9F8069282814a1177C1f6b8D7d8f7cC11A663554`

* * *

5.（可选）链上验证
----------

此 Demo 不强制验证。若你需要，在 Etherscan 开个 API Key 后，使用 `@nomicfoundation/hardhat-verify` 即可。

* * *

6\. 前端工程（Vite + React）
----------------------

在 `hello-fhevm/` 下创建 `frontend/`：

    cd ../
    pnpm create vite@latest frontend -- --template react-ts
    cd frontend
    pnpm add ethers
    

### 6.1 环境变量

`.env`（部署时在 Vercel 里配置同名变量）：

    VITE_COUNTER_ADDRESS=0x你的合约地址
    VITE_RPC_URL=https://ethereum-sepolia.publicnode.com
    # （可选，若你的网络环境对某些域名有限制）
    # VITE_RELAYER_URL=https://relayer.testnet.zama.cloud
    # VITE_GATEWAY_URL=https://gateway.testnet.zama.cloud
    

### 6.2 类型声明（用于从 CDN 动态导入 SDK）

    mkdir -p src/types
    

`src/types/zama-relayer-cdn.d.ts`：

    declare module "https://cdn.zama.ai/relayer-sdk-js/0.2.0/relayer-sdk-js.js" {
      export function initSDK(): Promise<void>;
      export function createInstance(cfg: any): Promise<any>;
      export const SepoliaConfig: any;
    }
    

确保 `tsconfig.json` 包含 `src` 与 `src/types/**/*.d.ts`（Vite 默认模板基本 OK）：

    {
      "compilerOptions": {
        "target": "ES2020",
        "useDefineForClassFields": true,
        "lib": ["ES2020", "DOM", "DOM.Iterable"],
        "module": "ESNext",
        "skipLibCheck": true,
        "moduleResolution": "Bundler",
        "resolveJsonModule": true,
        "isolatedModules": true,
        "noEmit": true,
        "jsx": "react-jsx",
        "allowJs": true
      },
      "include": ["src", "src/types/**/*.d.ts"]
    }
    

### 6.3 核心页面 `src/App.tsx`

这版逻辑是：**先连接钱包（任何 EIP-1193 钱包都可以）** → 切到 **Sepolia** → `initSDK()` → `createInstance({...SepoliaConfig, network: provider, chainId: 11155111})`。

    import { useRef, useState } from "react";
    import { ethers } from "ethers";
    
    /** PrivateCounter ABI */
    const ABI = [
      "function add(bytes32 encryptedDelta, bytes inputProof)",
      "function requestReveal()",
      "function totalPlain() view returns (uint32)",
    ] as const;
    
    const COUNTER_ADDR =
      (import.meta.env.VITE_COUNTER_ADDRESS as `0x${string}`) ||
      "0x9F8069282814a1177C1f6b8D7d8f7cC11A663554";
    
    function pickProvider(): any {
      const eth = (window as any).ethereum;
      if (!eth) return null;
      if (Array.isArray(eth?.providers)) {
        const anyProv = eth.providers.find((p: any) => typeof p?.request === "function");
        if (anyProv) return anyProv;
      }
      return eth;
    }
    
    const RPC_FALLBACK =
      (import.meta.env.VITE_RPC_URL as string) || "https://ethereum-sepolia.publicnode.com";
    
    const RELAYER_CANDIDATES = [
      import.meta.env.VITE_RELAYER_URL as string,
      "https://relayer.testnet.zama.cloud",
      "https://relayer.fhevm.zama.ai",
      "https://relayer.zama.ai",
      "https://devnet.zama.ai/relayer",
    ].filter(Boolean);
    
    const GATEWAY_CANDIDATES = [
      import.meta.env.VITE_GATEWAY_URL as string,
      "https://gateway.testnet.zama.cloud",
      "https://gateway.fhevm.zama.ai",
    ].filter(Boolean);
    
    export default function App() {
      const [status, setStatus] = useState("尚未连接钱包");
      const [addr, setAddr] = useState<string | null>(null);
      const [plain, setPlain] = useState<number | null>(null);
      const [chainId, setChainId] = useState<number | null>(null);
      const [usingRelayer, setUsingRelayer] = useState<string | null>(null);
      const [usingGateway, setUsingGateway] = useState<string | null>(null);
    
      const providerRef = useRef<any>(null);
      const fheRef = useRef<any>(null);
    
      async function connectAndInit() {
        try {
          const eth = pickProvider();
          if (!eth) throw new Error("未检测到钱包。请安装或启用一个 EIP-1193 钱包扩展（如 MetaMask）。");
          providerRef.current = eth;
    
          setStatus("请求账户授权…");
          const accounts: string[] = await eth.request({ method: "eth_requestAccounts" });
          if (!accounts?.length) throw new Error("未授权任何账户。");
          setAddr(accounts[0]);
    
          const hexId: string = await eth.request({ method: "eth_chainId" });
          let id = parseInt(hexId, 16);
          if (id !== 11155111) {
            setStatus("切换到 Sepolia…");
            try {
              await eth.request({ method: "wallet_switchEthereumChain", params: [{ chainId: "0xaa36a7" }] });
              id = 11155111;
            } catch (e: any) {
              if (e?.code === 4902) {
                await eth.request({
                  method: "wallet_addEthereumChain",
                  params: [{
                    chainId: "0xaa36a7",
                    chainName: "Sepolia",
                    nativeCurrency: { name: "Sepolia ETH", symbol: "ETH", decimals: 18 },
                    rpcUrls: [RPC_FALLBACK],
                    blockExplorerUrls: ["https://sepolia.etherscan.io/"]
                  }]
                });
                id = 11155111;
              } else {
                throw e;
              }
            }
          }
          setChainId(id);
    
          setStatus("加载 FHEVM SDK…");
          const mod = await import(
            /* @vite-ignore */ "https://cdn.zama.ai/relayer-sdk-js/0.2.0/relayer-sdk-js.js"
          );
          const { initSDK, createInstance, SepoliaConfig } = mod as any;
          await initSDK();
    
          let lastErr: any = null;
          for (const r of RELAYER_CANDIDATES) {
            for (const g of GATEWAY_CANDIDATES) {
              try {
                setStatus(`创建 FHE 实例…（尝试 ${r} / ${g}）`);
                const cfg = {
                  ...SepoliaConfig,
                  relayerUrl: r,
                  gatewayUrl: g,
                  network: providerRef.current,
                  chainId: 11155111,
                };
                const inst = await createInstance(cfg);
                if (typeof inst.init === "function") await inst.init();
                fheRef.current = inst;
                setUsingRelayer(r);
                setUsingGateway(g);
                setStatus("✅ 已连接 & SDK 就绪");
                return;
              } catch (e: any) {
                lastErr = e;
              }
            }
          }
          throw new Error(`所有 relayer/gateway 组合均失败。最后错误：${lastErr?.message || lastErr}`);
        } catch (e: any) {
          console.error(e);
          setStatus("❌ 连接/初始化失败: " + (e?.message || e));
        }
      }
    
      async function getSignerAndContract() {
        const eth = providerRef.current || pickProvider();
        if (!eth) throw new Error("未检测到钱包。");
        const browserProvider = new ethers.BrowserProvider(eth);
        await browserProvider.send("eth_requestAccounts", []);
        const signer = await browserProvider.getSigner();
        const contract = new ethers.Contract(COUNTER_ADDR, ABI, signer);
        return { signer, contract, addr: await signer.getAddress() };
      }
    
      async function handleAddOne() {
        try {
          if (!fheRef.current) throw new Error("SDK 未就绪，请先连接钱包。");
          setStatus("注册加密输入…");
          const { addr, contract } = await getSignerAndContract();
    
          const buf = fheRef.current.createEncryptedInput(COUNTER_ADDR, addr);
          buf.add32(1n);
          const cipher = await buf.encrypt();
    
          setStatus("发送交易 add(+1)…");
          const tx = await contract.add(cipher.handles[0], cipher.inputProof);
          await tx.wait();
          setStatus("✅ 已提交 +1");
        } catch (e: any) {
          console.error(e);
          setStatus("❌ 失败: " + (e?.message || e));
        }
      }
    
      async function handleReveal() {
        try {
          if (!fheRef.current) throw new Error("SDK 未就绪，请先连接钱包。");
          setStatus("请求解密…");
          const { contract } = await getSignerAndContract();
          const tx = await contract.requestReveal();
          await tx.wait();
    
          setStatus("等待回调（~30s）…");
          await new Promise((r) => setTimeout(r, 30_000));
    
          const v: bigint = await contract.totalPlain();
          setPlain(Number(v));
          setStatus("✅ 完成");
        } catch (e: any) {
          console.error(e);
          setStatus("❌ 失败: " + (e?.message || e));
        }
      }
    
      return (
        <div style={{ maxWidth: 880, margin: "48px auto", fontFamily: "system-ui" }}>
          <h1>Hello FHEVM: 私密计数器</h1>
          <p>合约地址：<code>{COUNTER_ADDR}</code></p>
          {(usingRelayer || usingGateway) && (
            <p style={{ fontSize: 12, opacity: .7 }}>
              relayer: <code>{usingRelayer}</code> · gateway: <code>{usingGateway}</code>
            </p>
          )}
    
          <div style={{ display: "flex", gap: 12, marginTop: 16, flexWrap: "wrap" }}>
            {!addr ? (
              <button onClick={connectAndInit} style={{ background: "#e5f0ff" }}>
                🔌 连接钱包并初始化
              </button>
            ) : (
              <>
                <button onClick={handleAddOne}>➕ 加 1（加密提交）</button>
                <button onClick={handleReveal}>🔓 解密总数</button>
                <button onClick={connectAndInit} style={{ background: "#f3f4f6" }}>
                  ♻️ 重新连接/重新初始化
                </button>
              </>
            )}
          </div>
    
          <p style={{ marginTop: 16 }}>
            状态：{status}
            {addr && <>  |  账户：<code>{addr}</code></>}
            {chainId !== null && <>  |  ChainId：<code>{chainId}</code></>}
          </p>
          <p>明文总数：{plain === null ? "（未解密）" : plain}</p>
        </div>
      );
    }
    

开发预览：

    pnpm i
    pnpm dev
    # 打开提示的本地域名即可
    

* * *

7\. 部署到 Vercel
--------------

*   New Project → 选择你的仓库 → **Root Directory =** `frontend`
    
*   Build Command：`pnpm build`
    
*   Output Directory：`dist`
    
*   Install Command：`pnpm install`
    
*   **Environment Variables**：
    
    *   `VITE_COUNTER_ADDRESS=0x你的合约地址`
        
    *   `VITE_RPC_URL=https://ethereum-sepolia.publicnode.com`
        
    *   （可选）`VITE_RELAYER_URL`、`VITE_GATEWAY_URL`
        

点 Deploy，完成后打开域名。

* * *

8\. 使用步骤
--------

1.  打开你的 Vercel 域名（**https**）
    
2.  浏览器钱包**解锁**
    
3.  点击页面上的 **“🔌 连接钱包并初始化”** → 同意授权/切换到 **Sepolia**
    
4.  点击 **“➕ 加 1（加密提交）”**，确认交易
    
5.  点击 **“🔓 解密总数”**，状态会显示“等待回调（~30s）…”，随后明文更新
    

* * *

9\. 常见错误速查
----------

*   `Invalid JSON-RPC response received: invalid project id` → 你用了 Infura/Alchemy 但没带 Project ID。换公共节点或换成自己的完整 RPC URL。
    
*   `KMS contract address is not valid or empty` / `Impossible to fetch public key: wrong relayer url.` → 当前 Relayer/Gateway 与链/KMS 不匹配或不可达。 → 试试教程里前端的**候选端点重试逻辑**，或换浏览器/网络（关闭广告拦截、公司代理、无痕模式）。
    
*   `wallet must has at least one account` / `ACTION_REJECTED` → 钱包没给站点授权、或被其它钱包扩展“抢占”。 → 先只启用 MetaMask，删除 **Connected sites** 中的站点记录 → 刷新 → 重新连接。
    
*   `Relayer didn't response correctly. REQUEST FAILED RESPONSE` → 多半是浏览器/网络拦截了 `*.zama.ai / *.zama.cloud`。 → 换正常网络 + Chrome 正常窗口，或在 Vercel 上设置自定义 `VITE_RELAYER_URL / VITE_GATEWAY_URL`。
    

* * *

10\. 项目结构参考
-----------

    hello-fhevm/
    ├─ hardhat/
    │  ├─ contracts/PrivateCounter.sol
    │  ├─ scripts/deploy.ts
    │  ├─ hardhat.config.ts
    │  └─ .env
    └─ frontend/
       ├─ src/App.tsx
       ├─ src/types/zama-relayer-cdn.d.ts
       ├─ .env
       └─ ...
    

* * *

---

*Originally published on [pangdong](https://paragraph.com/@pangdong/hello-fhevm)*
