技术栈 @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 需要你在官方控制台创建项目:
- 打开 **cloud.walletconnect.com**(WalletConnect Cloud)。
- 使用 GitHub / Google 登录,点击 Create new Project。
- 在 Project → Overview 页面复制 Project ID(一串 32 位左右的字符串)。
- 在你的前端
.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 → 填到
.env的PUBLIC_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 仅演示用途,请替换成你的真实业务合约。