Web3——Svelte / SvelteKit 接入 EVM 钱包

151 阅读3分钟

技术栈 @wagmi/core + viem + wagmi/connectors(不依赖 React),用 Svelte stores 管理状态,覆盖 Injected/WalletConnect/Coinbase Wallet,实现连接、断开、切链、签名、读写合约、事件监听与常见排错。
文档按模块拆分,逐段复制即可运行;所有关键代码均附中文注释。


0. 预备知识 & 环境

  • Node 18+(建议 20+),pnpm/npm/yarn 任意。
  • 会用 Svelte/SvelteKit、TypeScript。
  • 了解基础链上概念(EVM、链 ID、Gas、EIP-1559、签名等)。

0.1 获取 WalletConnect「Project ID」

WalletConnect v2 需要你在官方控制台创建项目:

  1. 打开 **cloud.walletconnect.com**(WalletConnect Cloud)。
  2. 使用 GitHub / Google 登录,点击 Create new Project
  3. Project → Overview 页面复制 Project ID(一串 32 位左右的字符串)。
  4. 在你的前端 .env 中配置这个 ID(见下文 1.1)。

生产建议:为不同应用创建独立 Project,便于统计与限流。


1. 初始化项目与安装依赖

# 新建 SvelteKit(已有项目可跳过)
npm create svelte@latest web3-svelte && cd web3-svelte
npm i

# Web3 依赖
npm i viem @wagmi/core wagmi @walletconnect/ethereum-provider

# 类型/工具(可选)
npm i -D typescript

1.1 配置环境变量(SvelteKit 推荐写法)

创建 .env(开发)与 .env.production(线上),加入:

# SvelteKit 客户端可读的前缀,使用 $env/static/public 读取
PUBLIC_WC_PROJECT_ID=粘贴你在 cloud.walletconnect.com 拿到的 Project ID

也可以走 import.meta.env 路线,把变量名改成 VITE_WC_PROJECT_ID,并在代码里改为 import.meta.env.VITE_WC_PROJECT_ID 读取。本文主线采用 SvelteKit 官方推荐的 PUBLIC_* 前缀。


2. 目录结构(建议)

src/
 └─ lib/
     └─ web3/
         ├─ config.ts        # wagmi/viem 全局配置(链、连接器、RPC)
         ├─ stores.ts        # Svelte stores(地址、连接状态、链ID)
         └─ actions.ts       # 连接/断开/切链/签名/读写 合约 API
     └─ components/
         ├─ ConnectPanel.svelte  # 钱包连接与切链 UI
         └─ Examples.svelte      # 读余额、签名、写交易 示例
routes/
 └─ +page.svelte

3. 配置:链、连接器与传输(src/lib/web3/config.ts

// src/lib/web3/config.ts
// -- wagmi v2 + viem(不依赖 React)--
// 连接器从 'wagmi/connectors' 引入;核心 API 来自 '@wagmi/core'。

import { http, createConfig } from '@wagmi/core'
import { mainnet, polygon, arbitrum, base, optimism, sepolia } from 'wagmi/chains'
import { injected, walletConnect, coinbaseWallet } from 'wagmi/connectors'

// ✅ SvelteKit 官方推荐:用 $env/static/public 读取 PUBLIC_* 变量
import { PUBLIC_WC_PROJECT_ID } from '$env/static/public'
const WC_ID = PUBLIC_WC_PROJECT_ID

// (可选)如果你不用 SvelteKit,也可以这样读 Vite 变量:
// const WC_ID = import.meta.env.VITE_WC_PROJECT_ID as string

// 声明要支持的链
export const CHAINS = [mainnet, base, optimism, arbitrum, polygon, sepolia]

// wagmi 全局配置(供 @wagmi/core 所有方法使用)
export const wagmiConfig = createConfig({
  chains: CHAINS,
  // viem 传输层:此处使用默认公共 RPC;生产建议替换为自建/服务商(Alchemy/Infura/Ankr)
  transports: {
    [mainnet.id]: http(),
    [base.id]: http(),
    [optimism.id]: http(),
    [arbitrum.id]: http(),
    [polygon.id]: http(),
    [sepolia.id]: http(),
  },
  // 连接器列表:Injected / WalletConnect / Coinbase Wallet
  connectors: [
    // 注:shimDisconnect 让“断开连接”在 injected 钱包中也能生效(本地维护连接态)
    injected({ shimDisconnect: true }),
    walletConnect({
      projectId: WC_ID,
      // showQrModal=true 时自动弹出官方二维码模态框;
      // 若你自带 UI,可设为 false 并用 Web3Modal 手动弹出
      showQrModal: true
    }),
    coinbaseWallet({ appName: 'Svelte Web3 Demo' })
  ]
})

RPC 选择建议:公共 RPC 易限速、兼容性不一。

  • 开发:公共 RPC + 本地节点(Anvil/Hardhat)。
  • 生产:专用服务商(Alchemy/Infura/QuickNode 等)+ 失败重试与降级。

4. Svelte Stores:统一钱包/链状态(src/lib/web3/stores.ts

// src/lib/web3/stores.ts
// 将 wagmi 的 watch* API 映射为 Svelte 的 store,便于组件直接订阅。

import { writable, derived } from 'svelte/store'
import { getAccount, watchAccount, watchChainId, getChainId, reconnect } from '@wagmi/core'
import type { Address } from 'viem'
import { browser } from '$app/environment'
import { wagmiConfig } from './config'

export const address = writable<Address | undefined>()
export const isConnected = writable(false)
export const chainId = writable<number | undefined>()
export const status = writable<'disconnected' | 'connecting' | 'connected'>('disconnected')

// 简短地址显示
export const shortAddr = derived(address, ($a) => $a ? `${$a.slice(0,6)}...${$a.slice(-4)}` : '')

// 仅在浏览器端初始化(SSR 下没有 window)
if (browser) {
  // 恢复上次连接(例如注入式钱包)
  reconnect(wagmiConfig).catch(() => {})

  // 订阅账户变化
  const unwatchAccount = watchAccount(wagmiConfig, {
    onChange(acct) {
      address.set(acct.address as Address | undefined)
      isConnected.set(!!acct.isConnected)
      status.set(acct.status ?? 'disconnected')
    }
  })
  // 订阅链 ID 变化
  const unwatchChain = watchChainId(wagmiConfig, {
    onChange(id) { chainId.set(id) }
  })

  // 设置初始值(避免首屏空白)
  const acct = getAccount(wagmiConfig)
  address.set(acct.address as Address | undefined)
  isConnected.set(!!acct.isConnected)
  status.set(acct.status ?? 'disconnected')
  chainId.set(getChainId(wagmiConfig))

  // 可选:在页面卸载时清理订阅
  // addEventListener('beforeunload', () => { unwatchAccount(); unwatchChain() })
}

5. 统一动作 API:连接/断开/切链/签名/读写(src/lib/web3/actions.ts

// src/lib/web3/actions.ts
// 面向业务组件的“无框架” API。只依赖 @wagmi/core + viem 类型。

import {
  connect, disconnect, getAccount, switchChain,
  signMessage, signTypedData, readContract, writeContract,
  waitForTransactionReceipt, getWalletClient, getPublicClient, simulateContract,
} from '@wagmi/core'
import { injected, walletConnect, coinbaseWallet } from 'wagmi/connectors'
import type { Abi, Address } from 'viem'
import { wagmiConfig } from './config'
import { PUBLIC_WC_PROJECT_ID } from '$env/static/public'

// --- 连接/断开 ---
export const connectInjected = () =>
  connect(wagmiConfig, { connector: injected() })

export const connectWalletConnect = () =>
  connect(wagmiConfig, { connector: walletConnect({ projectId: PUBLIC_WC_PROJECT_ID }) })

export const connectCoinbase = () =>
  connect(wagmiConfig, { connector: coinbaseWallet({ appName: 'Svelte Web3 Demo' }) })

export const disconnectAll = () => disconnect(wagmiConfig)

// --- 切链 ---
export const switchTo = (chainId: number) =>
  switchChain(wagmiConfig, { chainId })

// --- 签名(EIP-191 / EIP-712)---
export const personalSign = (message: string) =>
  signMessage(wagmiConfig, { message })

export async function signTyped(domain: any, types: any, value: Record<string, any>) {
  const acct = getAccount(wagmiConfig)
  if (!acct.address) throw new Error('Not connected')
  // primaryType 从 types 的键中选择(示例:Permit / Mail 等)
  const primaryType = Object.keys(types)[0]
  return signTypedData(wagmiConfig, { account: acct.address, domain, types, primaryType, message: value })
}

// --- 合约读取 ---
export async function contractRead<T = unknown>(
  params: { address: Address; abi: Abi; functionName: string; args?: any[] }
) {
  return readContract(wagmiConfig, params) as Promise<T>
}

// --- 合约写入(先 simulate 再发送,最后等待回执) ---
export async function contractWrite(
  params: { address: Address; abi: Abi; functionName: string; args?: any[] }
) {
  // 预执行:提前发现 Revert 原因与 Gas 估算
  const sim = await simulateContract(wagmiConfig, params as any)
  const hash = await writeContract(wagmiConfig, { ...params, ...sim.request })
  const receipt = await waitForTransactionReceipt(wagmiConfig, { hash })
  return receipt
}

// --- 暴露 viem 原生客户端(高级用法:事件、批量、多调用等)---
export const getWallet = () => getWalletClient(wagmiConfig)
export const getPublic = () => getPublicClient(wagmiConfig)

6. 连接与切链 UI(src/lib/components/ConnectPanel.svelte

<script lang="ts">
  import { address, isConnected, status, chainId, shortAddr } from '$lib/web3/stores'
  import { CHAINS } from '$lib/web3/config'
  import { connectInjected, connectWalletConnect, connectCoinbase, disconnectAll, switchTo } from '$lib/web3/actions'

  let selected = 0
  $: selected = $chainId ?? CHAINS[0].id
</script>

<div class="card">
  <div class="row"><b>状态:</b> {$status}</div>
  <div class="row"><b>地址:</b> {$address ? $shortAddr : '未连接'}</div>

  <div class="row">
    <button on:click={connectInjected}>Injected</button>
    <button on:click={connectWalletConnect}>WalletConnect</button>
    <button on:click={connectCoinbase}>Coinbase</button>
    <button on:click={disconnectAll} disabled={!$isConnected}>断开</button>
  </div>

  <div class="row">
    <label>切换网络:</label>
    <select bind:value={selected} on:change={() => switchTo(+selected)}>
      {#each CHAINS as c}
        <option value={c.id} selected={c.id === $chainId}>{c.name}</option>
      {/each}
    </select>
  </div>
</div>

<style>
  .card { padding: 12px; border: 1px solid #2a2a2a; border-radius: 8px; display: grid; gap: 8px; }
  .row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
  button { padding:6px 10px; }
  select { padding:4px 6px; }
</style>

7. 示例:读余额、签名、写交易(src/lib/components/Examples.svelte

<script lang="ts">
  import { address, chainId } from '$lib/web3/stores'
  import { contractRead, contractWrite, personalSign } from '$lib/web3/actions'
  import type { Address } from 'viem'

  // 示例:Base 链 USDC(仅示意,实际按你的合约替换地址/ABI/精度)
  const USDC: Address = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'
  const ERC20 = [
    {"inputs":[{"name":"owner","type":"address"}],"name":"balanceOf","outputs":[{"type":"uint256"}],"stateMutability":"view","type":"function"},
    {"inputs":[{"name":"spender","type":"address"},{"name":"amount","type":"uint256"}],"name":"approve","outputs":[{"type":"bool"}],"stateMutability":"nonpayable","type":"function"}
  ] as const

  let balance = '-'
  let pending = false
  let signResult = ''

  async function loadBalance() {
    if (!$address) return
    const raw = await contractRead<bigint>({ address: USDC, abi: ERC20 as any, functionName: 'balanceOf', args: [$address] })
    balance = (Number(raw) / 1e6).toLocaleString() + ' USDC' // USDC 6 位小数
  }

  async function doSign() {
    signResult = await personalSign('Hello from Svelte!')
  }

  async function doApprove() {
    if (!$address) return
    pending = true
    try {
      // 仅示例:给一个假地址 approve 0;真实项目请替换参数!
      const r = await contractWrite({
        address: USDC, abi: ERC20 as any, functionName: 'approve',
        args: ['0x0000000000000000000000000000000000000001', 0n]
      })
      alert(`Tx confirmed in block ${r.blockNumber}`)
    } catch (e) {
      // 常见报错:User rejected / execution reverted / underpriced / nonce too low 等
      console.error(e)
      alert((e as Error).message)
    } finally {
      pending = false
      await loadBalance()
    }
  }
</script>

<div class="card">
  <div>当前链:{$chainId}</div>
  <div>地址:{$address ?? '-'}</div>

  <button on:click={loadBalance} disabled={!$address || pending}>读 USDC 余额</button>
  <div>余额:{balance}</div>

  <button on:click={doSign} disabled={!$address || pending}>签名一段消息</button>
  <div style="word-break:break-all">{signResult}</div>

  <button on:click={doApprove} disabled={!$address || pending}>
    {pending ? '发送中…' : '发送一笔写交易(示例)'}
  </button>
</div>

<style>
  .card { padding: 12px; border: 1px dashed #666; border-radius: 8px; display: grid; gap: 8px; }
</style>

8. 页面集成(src/routes/+page.svelte

<script>
  import ConnectPanel from '$lib/components/ConnectPanel.svelte'
  import Examples from '$lib/components/Examples.svelte'
</script>

<h1>Svelte Web3 Playground</h1>
<ConnectPanel />
<Examples />

所有链交互逻辑均在 浏览器端 执行(stores 中已通过 browser 保护),与 SvelteKit SSR 不冲突。


9. 进阶:事件监听、多链映射、乐观更新

// 监听 ERC20 Transfer 事件
import { getPublic } from '$lib/web3/actions'
import type { Address, Abi } from 'viem'

const publicClient = getPublic()
const USDC: Address = '0x...' // 按链替换
const ERC20_ABI: Abi = [ { "type":"event", "name":"Transfer", "inputs":[
  {"indexed":true,"name":"from","type":"address"},
  {"indexed":true,"name":"to","type":"address"},
  {"indexed":false,"name":"value","type":"uint256"}] } ]

const unwatch = publicClient.watchContractEvent({
  address: USDC,
  abi: ERC20_ABI,
  eventName: 'Transfer',
  onLogs(logs) { console.log('Transfer logs', logs) }
})
// 调用 unwatch() 停止监听

多链地址映射:

export const CONTRACTS: Record<number, { usdc: Address }> = {
  1: { usdc: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' },
  8453: { usdc: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' },
}

乐观更新建议:发送交易前标记 pending;幂等操作可先写 UI 后回执校正;DeFi 强相关场景建议事件驱动刷新或回执后刷新。


10. 常见问题(FAQ / 排错)

  • WalletConnect Project ID 在哪搞?cloud.walletconnect.com → 登录 → 新建 Project → 复制 Project ID → 填到 .envPUBLIC_WC_PROJECT_ID
  • SSR 报 window 未定义:把 window 相关初始化放到 if (browser)onMount
  • Injected 看不到/地址为空:确认装了钱包扩展;移动端用钱包内置 DApp 浏览器。多钱包扩展共存时会互相覆盖,建议先禁用其他扩展测试。
  • 切链失败:有些钱包不支持自动加链;wagmi 会尝试 wallet_addEthereumChain,失败时提示用户手动切换。
  • 交易卡 pending:排查 RPC 限速与网络拥堵;允许“替换/加速”,注意 nonce too low / replacement underpriced
  • 读取失败(execution reverted):先 simulateContract;常见是参数/权限/余额不足。

11. 安全与 UX 建议

  • EIP-712(结构化数据)替代原始 eth_sign,清楚展示金额、地址、截止时间。
  • 把常见错误翻译成用户能懂的提示(余额不足、授权不足、白名单、链不匹配…)。
  • 可访问性:pending/confirmed/failed 有文字 + 视觉提示;加“复制 Tx Hash”。
  • 隐私:日志别记录敏感信息;生产日志注意脱敏与合规。

12. 可选:更好看的扫码弹窗(Web3Modal)

npm i @web3modal/wagmi

src/routes/+layout.svelte

<script lang="ts">
  import { onMount } from 'svelte'
  import { createWeb3Modal } from '@web3modal/wagmi'
  import { wagmiConfig } from '$lib/web3/config'
  import { PUBLIC_WC_PROJECT_ID } from '$env/static/public'

  onMount(() => {
    createWeb3Modal({
      wagmiConfig,
      projectId: PUBLIC_WC_PROJECT_ID,
      themeMode: 'dark'
    })
  })
</script>

13. 许可证与声明

  • 本文与示例代码 MIT License,可自由复制修改。
  • 示例合约地址/ABI 仅演示用途,请替换成你的真实业务合约。