Showing Posts From
Web3
EIP-1559 交易脚本的六个 Gas 坑:baseFee 突变到 nonce 冲突
写一个"清仓转账"的脚本——把地址里所有余额扫到目标地址,用 EIP-1559。核心代码大致这样: base_fee = w3.eth.get_block("latest")["baseFeePerGas"] priority_fee = w3.to_wei(0.1, "gwei") max_fee_per_gas = base_fee + priority_feegas_limit = 30000 fee = gas_limit * max_fee_per_gas amount = balance - feetx = { "to": w3.to_checksum_address(dst), "value": amount, "gas": gas_limit, "maxFeePerGas": max_fee_per_gas, "maxPriorityFeePerGas": priority_fee, "nonce": w3.eth.get_transaction_count(from_address), "chainId": CHAIN_ID, "type": 2, }跑起来经常报: insufficient funds for gas * price + value问题不是一处,是好几个细节叠加。 坑 1:maxFeePerGas 写死等于 baseFee + priority 节点校验时: balance >= value + gasLimit × maxFeePerGasbaseFee 每个区块都会浮动(EIP-1559 每次可变 ±12.5%)。你算完刚好够,几秒后新块 baseFee 涨了: max_fee < new_base_fee + priority要么交易过不了、要么"钱不够"。MetaMask 的做法是: max_fee_per_gas = base_fee * 2 + priority_feebase_fee * 2 是常规冗余系数——baseFee 一个区块最多涨 12.5%,2x 完全够缓冲。多花的部分节点会退还给你,只按 effectiveGasPrice 收。 坑 2:gasLimit 硬编码 普通转账 21000 够,Gravity 这类链要 30000。合约调用差别就更大了。别写死,先估算再加冗余: gas_limit = w3.eth.estimate_gas({ "from": from_address, "to": dst, "value": 1, }) gas_limit = int(gas_limit * 1.2)* 1.2 是通用的 20% 余量,避免 estimation 边缘 case。 坑 3:priority fee 硬编码 0.1 gwei 网络拥堵时 0.1 gwei 的小费根本进不了下一个块。用节点建议值: priority_fee = w3.eth.max_priority_fee或者查 feeHistory 取近期百分位: history = w3.eth.fee_history(5, "latest", [50]) priority_fee = max(history["reward"][-1][0], w3.to_wei(1, "gwei"))坑 4:nonce 用了 latest 默认: w3.eth.get_transaction_count(from_address) # 等价于 "latest"如果地址有未确认的 pending 交易,latest 会返回过时的 nonce,新交易和旧的撞车,两个都卡住。改成: nonce = w3.eth.get_transaction_count(from_address, "pending")坑 5:清仓转账没留冗余 amount = balance - fee 是理论极限。实际因为 baseFee 突变或 estimation 偏差,几乎必然凑不够。留一点小额缓冲: reserve = w3.to_wei(0.0001, "ether") amount = balance - fee - reserve坑 6:chainId 抄错 "chainId": 127001,链 ID 不匹配的交易节点直接拒。跟节点确认一次: print(w3.eth.chain_id)修正后的样板 base_fee = w3.eth.get_block("latest")["baseFeePerGas"] priority_fee = w3.eth.max_priority_fee max_fee_per_gas = base_fee * 2 + priority_feegas_limit = w3.eth.estimate_gas({ "from": from_address, "to": dst, "value": 1, }) gas_limit = int(gas_limit * 1.2)fee_ceiling = gas_limit * max_fee_per_gas reserve = w3.to_wei(0.0001, "ether") amount = balance - fee_ceiling - reserve if amount <= 0: raise RuntimeError("余额不足以支付 Gas + 冗余")tx = { "to": w3.to_checksum_address(dst), "value": amount, "gas": gas_limit, "maxFeePerGas": max_fee_per_gas, "maxPriorityFeePerGas": priority_fee, "nonce": w3.eth.get_transaction_count(from_address, "pending"), "chainId": w3.eth.chain_id, "type": 2, }一句话总结 EIP-1559 参数不是"算准就行",是"抗突变才稳"。maxFee = baseFee * 2 + priority、gas = estimate * 1.2、nonce = pending,三件事一起做,出问题的概率会低一个数量级。
BIP39 助记词生成 Solana 地址:SLIP-10 + Ed25519 全流程
BIP39 助记词生成 ETH 地址靠 secp256k1 + keccak256,走 Solana 就完全换了一套——Ed25519 + SLIP-0010,最后不做 hash,直接 Base58 编码公钥。 全流程 助记词 (Mnemonic) │ PBKDF2-HMAC-SHA512 ▼ Seed (64 字节) │ SLIP-0010 (Ed25519) ▼ Master Key │ 按路径 m/44'/501'/0'/0' 派生 ▼ Ed25519 私钥 (32 字节) │ Ed25519 公钥算法 ▼ 公钥 (32 字节) │ Base58 编码 ▼ Solana 地址和 BTC/ETH 唯一相同的只有第一步"助记词转 seed",之后全变了。 第一步:助记词 → seed 标准 BIP39,PBKDF2-HMAC-SHA512: password = 助记词字符串 salt = "mnemonic" + passphrase iters = 2048 output = 64 字节Python: from mnemonic import Mnemonicmnemo = Mnemonic("english") seed = mnemo.to_seed( "abandon abandon abandon abandon abandon abandon " "abandon abandon abandon abandon abandon about", passphrase="", ) print(seed.hex())第二步:seed → Master Key(SLIP-10 Ed25519) Solana 用 SLIP-0010(Ed25519 版本),和 BIP32 的 secp256k1 分道扬镳: I = HMAC-SHA512(key="ed25519 seed", data=seed) IL = master private key (32 字节) IR = master chain code (32 字节)第三步:派生路径 Solana 默认路径: m/44'/501'/0'/0'44':BIP44 501':Solana 的 coin type 0':account 0':change每一级都是 hardened derivation(' 表示 + 2³¹)。Ed25519 只支持 hardened 派生,非 hardened 会直接报错。 第四步:公钥 & 地址 私钥经 Ed25519 算出 32 字节公钥。地址 = Base58(公钥),就这么简单:没有 SHA-256 double hash 没有 RIPEMD-160 没有 keccak 没有 checksum(Base58 本身没自带校验,也没有像 BTC 那样附 4 字节 hash)Python 一把梭 bip_utils 把上面所有细节封好了: from bip_utils import Bip39SeedGenerator, Bip44, Bip44Coinsmnemonic = ("abandon abandon abandon abandon abandon abandon " "abandon abandon abandon abandon abandon about")seed = Bip39SeedGenerator(mnemonic).Generate()bip44 = Bip44.FromSeed(seed, Bip44Coins.SOLANA) account = bip44.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT).AddressIndex(0)print("地址:", account.PublicKey().ToAddress()) print("私钥:", account.PrivateKey().Raw().ToHex())Phantom / Solflare 里的第一个账号导出出来应该跟上面一样。 常见误区 Solana 地址和 ETH 地址长得像 其实完全不同:ETH 地址:40 字符 hex + 0x 前缀,checksum 在 EIP-55 里靠大小写实现 Solana 地址:Base58 编码的 32 字节公钥,长度 32~44 字符,无固定前缀Solana 私钥文件是 64 字节而不是 32 Solana 的钱包 JSON(Phantom 导出)通常是 64 字节:前 32 是私钥 seed、后 32 是公钥。生成时: priv32 = account.PrivateKey().Raw().ToBytes() pub32 = account.PublicKey().RawUncompressed().ToBytes()[-32:] keypair_bytes = priv32 + pub32 # 64 字节导入 solana-cli 或 Phantom 时用这个格式。 Ed25519 派生路径可以省略部分层级 Phantom 早期用 m/44'/501'/0'(只三层)而非 m/44'/501'/0'/0'——同一助记词在这两种路径下算出来的地址不同。发现"钱包地址对不上"时先检查是不是路径差异,试试:m/44'/501'/0' — Phantom "legacy" m/44'/501'/0'/0' — 现在的默认一句话总结 Solana 地址生成 = BIP39 seed → SLIP-10 Ed25519 派生 → Base58(公钥)。和 EVM 完全两套加密路径,别拿 ETH 的经验类比。用 bip_utils 三行搞定,别自己实现 Ed25519。
BIP39 助记词会不会重复:数学上不会,现实中被坑的另有其人
有人问过我一个纠结的问题:用钱包 APP 生成助记词,会不会跟别人重复导致钱包被盗? 数学上算一下就知道,重复的概率低到根本不用担心;真正被盗的原因,几乎都是别的坑。 BIP39 生成流程 12 词助记词的生成,标准流程只有三步: 1. 从系统 CSPRNG 取 128 位熵 import secrets entropy = secrets.token_bytes(16) # 16 字节 = 128 位secrets 底层用的是操作系统提供的密码学安全随机源:Windows:BCryptGenRandom Linux:/dev/urandom macOS:SecRandomCopyBytes2. 拼上 4 位校验和 对 128 位熵做 SHA-256,取前 4 位挂到熵尾部,凑成 132 位: 128 位熵 + 4 位校验和 = 132 位3. 每 11 位查一次词表 BIP39 词表有 2^11 = 2048 个词。132 位 / 11 = 12 个词。 10010101100 → abandon 11100101010 → ability ...碰撞概率有多低 12 词的熵是 128 位,等于 3.4 × 10³⁸ 种可能。用生日悖论算一下极端情况: 假设全球 100 亿人,每人生成 100 万个钱包,一共 10¹⁶ 个: 碰撞概率 ≈ N² / (2 × 2¹²⁸) ≈ 10³² / 6.8 × 10³⁸ ≈ 1.5 × 10⁻⁷约 0.000015%,还是极端假设。真实世界远远达不到这个量级。24 词是 256 位熵,那更是天文数字。 只要用正规钱包 + 系统 CSPRNG 生成,重复被盗几乎不可能。 真被盗的常见姿势 1. 随机数不安全 自己造轮子最容易出问题: import random random.seed(time.time()) # 危险Python 的 random 是伪随机 + 可预测种子。攻击者知道大概时间戳,就能枚举出你所有可能的助记词。 永远用 secrets 或 os.urandom,不用 random。 2. "幸运数字"当种子 有人觉得自己的生日、手机号能当种子更好记: seed = "5201314" entropy = sha256(seed)这种熵严重不足。攻击者跑一遍 0 到 9,999,999,999 就把你的钱包翻出来了。任何"人可以记住"的种子都不够安全。 3. 脑钱包 想一句话当助记词: iloveyou123彩虹表早就把常见短语算光了,这种钱包上链一分钟就被扫走。 4. 假钱包 APP 流程: APP 生成助记词 → 悄悄上传服务器 → 等你存币 → 转走来路不明的浏览器插件、"新币空投工具"是重灾区。装钱包只从官网或 App Store。 5. 助记词泄露截屏保存到手机相册(云同步就完了) 记在便笺、微信自己的对话 输入到"辅助工具"网页正确做法:只离线纸质备份,多份异地存放。 安全生成的一行代码 用 bip_utils: from bip_utils import Bip39MnemonicGenerator, Bip39WordsNummnemonic = Bip39MnemonicGenerator().FromWordsNumber(Bip39WordsNum.WORDS_NUM_12) print(mnemonic)内部就是 CSPRNG + 128 位熵 + SHA-256 校验,标准 BIP39 流程。 一句话总结 担心助记词重复是想多了;担心自己不用 CSPRNG、拿脑钱包和假钱包 APP 是应该的。 只要用正规钱包生成 + 离线纸质备份,安全性远高于随机重复这种理论问题。
Hardhat 本地链卡链?先看是不是关了自动挖矿
Hardhat 本地链跑合约,await tx.wait() 卡住不返回,感觉链死了。看着像 bug,其实基本都是配置问题。 快速定位 一条命令看当前区块号: cast block-number --rpc-url http://127.0.0.1:8545没有 cast 就用 curl: curl -X POST http://127.0.0.1:8545 \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}'隔几秒再跑一次,区块号不变——链停了。 原因 1:配了 auto: false hardhat.config.js 里如果有: networks: { hardhat: { mining: { auto: false, // 关掉了自动挖矿 // interval: 5000 // 或者只按间隔挖 } } }交易发到内存池后不会被打包,得手动 mine: await network.provider.send("evm_mine");或者恢复自动挖矿: await network.provider.send("evm_setAutomine", [true]);原因 2:nonce 冲突 之前发过一个低 gas 交易占住了某个 nonce,后面的交易全排在它后面: cast nonce 0x<address> --rpc-url http://127.0.0.1:8545解法:发一个相同 nonce、更高 gas 的交易顶掉旧的(cancelTx 模式)。 原因 3:fork 主网时 RPC 挂了 npx hardhat node --fork https://eth-mainnet.g.alchemy.com/v2/xxxAlchemy 超限、RPC 断线、网络超时,Hardhat 表现得就像卡住。开 debug 日志看: DEBUG=hardhat* npx hardhat node会看到"Waiting for chain state"之类的提示。 原因 4:不是链卡,是前端缓存了 Hardhat 每次重启链状态会重置,但前端还拿着旧合约地址、旧 ABI。清理编译产物: rm -rf cache artifacts npx hardhat compile npx hardhat run scripts/deploy.js --network localhost原因 5:evm_mine 返回 0 不等于失败 有人看到: await network.provider.send("evm_mine"); // '0'以为报错了。其实 '0' 是 RPC 的正常返回值,表示成功。区块号会 +1,验证一下: await ethers.provider.getBlockNumber();兜底:一键重置 想快速回到初始状态而不重启进程: await network.provider.request({ method: "hardhat_reset", params: [], });链状态、快照、内存池全清。跑测试之间用这个特别方便。 或者最粗暴——Ctrl+C 结束 npx hardhat node,重新起一个。 一句话总结 Hardhat "卡链" 九成是 auto: false 关了自动挖矿。先 evm_setAutomine [true] 或手动 evm_mine 一下,绝大多数情况就通了。
Go 后端接入 Web3 钱包登录:nonce + personal_sign 验签
Go 后端接 Web3 钱包登录,标准流程只有四步:后端下发 nonce 前端钱包用 personal_sign 签 "Login\nNonce: xxx" 后端 recover 出地址,比对是不是同一个 通过后签发 JWT / session省心的做法是前端签名 + 后端验签,别在后端搞私钥。 生成 nonce 一次性、5 分钟过期、写 Redis: package authimport ( "crypto/rand" "encoding/hex" )func GenerateNonce() string { b := make([]byte, 16) rand.Read(b) return hex.EncodeToString(b) }Redis key:wallet:nonce:0xabc... → <nonce>,TTL 5 分钟。 前端签名 const message = `Login\nNonce: ${nonce}`; const signature = await ethereum.request({ method: "personal_sign", params: [message, address], });要点:用 personal_sign,不要自己算 hash 消息用明文发给 MetaMask,它会自动加 EIP-191 前缀Go 侧验签 package authimport ( "fmt" "strings" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" )func VerifySignature(address, message, sigHex string) bool { sigHex = strings.TrimPrefix(sigHex, "0x") sig := common.FromHex(sigHex) if len(sig) != 65 { return false } // 关键点 1:v 值处理(MetaMask 返回 27/28,crypto 库要 0/1) if sig[64] != 27 && sig[64] != 28 { return false } sig[64] -= 27 // 关键点 2:EIP-191 前缀 prefixed := fmt.Sprintf( "\x19Ethereum Signed Message:\n%d%s", len(message), message, ) hash := crypto.Keccak256Hash([]byte(prefixed)) pubKey, err := crypto.SigToPub(hash.Bytes(), sig) if err != nil { return false } recovered := crypto.PubkeyToAddress(*pubKey) return strings.EqualFold(recovered.Hex(), address) }登录 handler type LoginReq struct { Address string `json:"address"` Signature string `json:"signature"` }func Login(c *gin.Context) { var req LoginReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "bad request"}) return } key := "wallet:nonce:" + strings.ToLower(req.Address) nonce, err := redisClient.Get(ctx, key).Result() if err != nil { c.JSON(401, gin.H{"error": "nonce expired"}) return } message := "Login\nNonce: " + nonce if !VerifySignature(req.Address, message, req.Signature) { c.JSON(401, gin.H{"error": "invalid signature"}) return } // 防重放:立刻删掉 nonce redisClient.Del(ctx, key) token, _ := GenerateJWT(req.Address) c.JSON(200, gin.H{"token": token}) }三个必踩的坑 1. 少了 EIP-191 前缀 MetaMask 的 personal_sign 会自动加: "\x19Ethereum Signed Message:\n" + len(msg) + msg后端验签必须手动加回这个前缀再算 keccak。少了直接验不过。 2. v 值 27/28 vs 0/1 MetaMask 返回的签名最后一个字节(v)是 27 或 28(EIP-155 前的格式)。go-ethereum 的 crypto.SigToPub 要求是 0 或 1。必须减 27。 3. 忘了防重放 nonce 验完不删,攻击者抓包重放就能永远登进来。验签成功后立刻 redis.Del。 建议直接上 SIWE Sign-In with Ethereum(EIP-4361)是现在的标准,消息格式包括域名、URI、Chain ID、Issued At 等,钱包会更友好地显示: example.com wants you to sign in with your Ethereum account: 0xabc...123URI: https://example.com Version: 1 Chain ID: 1 Nonce: 8a2c... Issued At: 2026-05-10T16:31:04ZGo 侧现成库: go get github.com/spruceid/siwe-goimport siwe "github.com/spruceid/siwe-go"msg, err := siwe.ParseMessage(rawMessage) if err != nil { ... }if _, err := msg.Verify(signature, &domain, &nonce, nil); err != nil { // 验签失败 }SIWE 帮你处理消息构造、时效、chain id、防重放,比手写靠谱。 前端推荐栈wagmi + viem:现在 EVM dApp 的事实标准 直接 useSignMessage,签名一行搞定 不用自己处理 window.ethereum一句话总结 Web3 登录 = nonce + personal_sign + Go recover 比地址。三个坑:加 EIP-191 前缀、v 值减 27、nonce 用完立刻删。新项目直接上 SIWE,别自己拼消息格式。
ethers v6 里 provider / signer 怎么拿,以及手写一个 signer
前端 dApp 连 MetaMask 用 ethers.js,v5 和 v6 API 变了。写代码总卡在"provider 怎么初始化"这一步。 ethers v6 的最小骨架 <script src="https://cdn.jsdelivr.net/npm/ethers@6.7.1/dist/ethers.min.js"></script> <script> let provider, signer;if (window.ethereum) { provider = new ethers.BrowserProvider(window.ethereum); } else { alert("请先安装 MetaMask"); }document.getElementById("connect").addEventListener("click", async () => { await provider.send("eth_requestAccounts", []); signer = await provider.getSigner(); const address = await signer.getAddress(); const balance = await provider.getBalance(address); console.log(address, ethers.formatEther(balance), "ETH"); }); </script>要点:v6 用 ethers.BrowserProvider(v5 是 ethers.providers.Web3Provider) getSigner() 现在返回 Promise,要 await ethers.formatEther(v6)替代了 ethers.utils.formatEther(v5)v5 vs v6 对照场景 v5 v6浏览器 Provider new ethers.providers.Web3Provider(eth) new ethers.BrowserProvider(eth)RPC Provider ethers.providers.JsonRpcProvider(url) ethers.JsonRpcProvider(url)formatEther ethers.utils.formatEther(v) ethers.formatEther(v)parseEther ethers.utils.parseEther(s) ethers.parseEther(s)BigNumber ethers.BigNumber.from(x) 原生 BigInt手写一个 signer(不依赖 ethers) 有些教学场景或轻量项目不想引入整个 ethers.js,直接靠 window.ethereum 也能拼出一个 signer: async function myGetSigner() { const accounts = await window.ethereum.request({ method: "eth_requestAccounts", }); const address = accounts[0]; return { address, async getAddress() { return address; }, async signMessage(message) { const hex = typeof message === "string" ? toHex(message) : message; return window.ethereum.request({ method: "personal_sign", params: [hex, address], }); }, async sendTransaction(tx) { const params = { from: address, to: tx.to, value: tx.value ? "0x" + BigInt(tx.value).toString(16) : "0x0", gas: tx.gas ? "0x" + BigInt(tx.gas).toString(16) : undefined, data: tx.data || "0x", }; return window.ethereum.request({ method: "eth_sendTransaction", params: [params], }); }, }; }// 浏览器没有 Buffer,自己写一个字符串转 hex function toHex(str) { let hex = ""; for (const ch of str) { hex += ch.charCodeAt(0).toString(16).padStart(2, "0"); } return "0x" + hex; }常见踩坑 Buffer is not defined 从 Node 教程抄的 Buffer.from(...).toString('hex') 在浏览器不能用。用上面的 toHex 或 TextEncoder: const hex = "0x" + Array.from(new TextEncoder().encode(str)) .map(b => b.toString(16).padStart(2, "0")).join("");BigInt 序列化 v6 起金额和 nonce 都是原生 BigInt,直接 JSON.stringify 会崩: TypeError: Do not know how to serialize a BigInt要么手动转字符串,要么全局 patch: BigInt.prototype.toJSON = function () { return this.toString(); };window.ethereum 是数组 装了多个钱包(MetaMask + Coinbase + OKX)后,window.ethereum 可能变成聚合对象,isMetaMask 判断失效。EIP-6963 出来后的做法: window.addEventListener("eip6963:announceProvider", (event) => { console.log(event.detail.info.name, event.detail.provider); }); window.dispatchEvent(new Event("eip6963:requestProvider"));一句话总结 ethers v6:new BrowserProvider(window.ethereum) + await provider.getSigner();不想引 ethers 时手写 signer 也不难,注意浏览器里没 Buffer 就行。
