Showing Posts From

Web3

EIP-1559 Gas 费计算与 Python 转账清仓脚本

EIP-1559 的 Gas 费有三个参数,容易写错导致交易失败或余额卡住。 三个参数的含义参数 含义baseFee 当前区块的基础 Gas 价格,由网络自动调整,不能低于这个值maxPriorityFeePerGas 给矿工的小费(Tip),影响打包速度maxFeePerGas 用户愿意支付的最高单价,节点用 max(baseFee + tip, maxFeePerGas) 结算实际每笔交易扣费:gasUsed × (baseFee + tip),多余部分退还。 常见错误写法 # 问题:maxFee 等于 baseFee + priority,baseFee 稍微上涨就失效 max_fee_per_gas = base_fee + priority_fee如果在你签名到广播之间的时间里,下一个区块的 baseFee 上涨,节点会拒绝交易(因为 maxFeePerGas < baseFee)。 推荐写法 base_fee = web3.eth.get_block("pending")["baseFeePerGas"]try: priority_fee = web3.eth.max_priority_fee except Exception: priority_fee = web3.to_wei(0.1, "gwei")# baseFee 乘以 2 留有余量(MetaMask 等钱包的标准做法) max_fee_per_gas = base_fee * 2 + priority_fee清仓转账(全额转出) 把钱包余额全部转出时,amount = balance - fee 看起来简单,但有几个坑: from web3 import Web3def transfer_all(web3, private_key, dst_address): account = web3.eth.account.from_key(private_key) from_address = account.address balance = web3.eth.get_balance(from_address) nonce = web3.eth.get_transaction_count(from_address, "pending") base_fee = web3.eth.get_block("pending")["baseFeePerGas"] try: priority_fee = web3.eth.max_priority_fee except Exception: priority_fee = web3.to_wei(0.1, "gwei") max_fee_per_gas = base_fee * 2 + priority_fee # estimate_gas 比写死 30000 更准确 gas_limit = web3.eth.estimate_gas({ "from": from_address, "to": dst_address, "value": 1 }) gas_limit = int(gas_limit * 1.2) fee = gas_limit * max_fee_per_gas # 额外留安全余量,防止 baseFee 微小变化导致不足 reserve = web3.to_wei(0.00000001, "ether") amount = balance - fee - reserve if amount <= 0: print("余额不足支付 Gas 费") return None tx = { "from": from_address, "to": Web3.to_checksum_address(dst_address), "value": amount, "gas": gas_limit, "maxFeePerGas": max_fee_per_gas, "maxPriorityFeePerGas": priority_fee, "nonce": nonce, "chainId": web3.eth.chain_id, "type": 2, } signed = web3.eth.account.sign_transaction(tx, private_key) tx_hash = web3.eth.send_raw_transaction(signed.raw_transaction) print(f"发送地址: {from_address}") print(f"转出: {web3.from_wei(amount, 'ether')} ETH") print(f"Gas 费: {web3.from_wei(fee, 'ether')} ETH") print(f"TX: {tx_hash.hex()}") return tx_hash.hex()关键细节 nonce 用 "pending" 而非 "latest":如果有未确认交易,用 "latest" 得到的 nonce 偏小,新交易会被拒绝。 gas_limit 用 estimateGas:普通 ETH 转账是 21000,但某些链可能不同,estimate_gas * 1.2 更稳妥。 chainId 用 web3.eth.chain_id 获取:不要硬编码,避免接错链。 确认当前链 ID print(web3.eth.chain_id)Gravity L1 Mainnet 的 Chain ID 是 127001,支持 EIP-1559。 两步计算更可靠 对于高精度要求的清仓场景,建议:先构造 value=0 估算 gasLimit 计算 fee = gasLimit × maxFeePerGas,加安全余量 签名广播 如果仍然报 insufficient funds(baseFee 变化导致),重新读取参数再签一次

EIP-1559 Gas 费计算:Python Web3 转账扣余额的正确姿势

做链上转账时,想把账户余额全部转走,需要提前扣除 Gas 费。EIP-1559 下的费用计算和普通 Legacy 交易有些区别,容易算错。 常见的错误写法 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_gasamount = balance - fee这里有两个问题: 问题一:fee 计算偏高但不算错 gas_limit * max_fee_per_gas 是最坏情况下的费用上限,实际扣款是: actual_fee = gas_used × (base_fee + priority_fee)gas_used ≤ gas_limit,所以实际花费通常比预留值少。转账类交易 gas_used 基本等于 gas_limit,误差不大;合约调用误差可能较大。 问题二:maxFeePerGas 应该 > base_fee + priority_fee EIP-1559 规范里,maxFeePerGas 是你愿意支付的上限,真正矿工收到的是: effective_gas_price = base_fee + priority_fee如果下一个区块 base_fee 涨了,用 maxFeePerGas = base_fee + priority_fee 可能导致交易因 maxFeePerGas < 新 base_fee 而被拒绝。 正确写法 def send_all_balance(w3, private_key, to_address, chain_id): account = w3.eth.account.from_key(private_key) from_address = account.address balance = w3.eth.get_balance(from_address) nonce = w3.eth.get_transaction_count(from_address) # base_fee 加 20% 缓冲,防止下一块涨价 base_fee = w3.eth.get_block("latest")["baseFeePerGas"] base_fee_with_buffer = int(base_fee * 1.2) priority_fee = w3.to_wei(0.1, "gwei") max_fee_per_gas = base_fee_with_buffer + priority_fee gas_limit = 21000 # 纯转账固定 21000;合约调用需要 estimateGas # 预留的 Gas 费上限 max_gas_cost = gas_limit * max_fee_per_gas amount = balance - max_gas_cost if amount <= 0: print(f"余额不足:balance={w3.from_wei(balance, 'ether')} ETH") return None tx = { "from": from_address, "to": w3.to_checksum_address(to_address), "value": amount, "gas": gas_limit, "maxFeePerGas": max_fee_per_gas, "maxPriorityFeePerGas": priority_fee, "nonce": nonce, "chainId": chain_id, "type": 2, } signed = w3.eth.account.sign_transaction(tx, private_key) tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) print(f"发送: {w3.from_wei(amount, 'ether')} ETH") print(f"Gas 预留: {w3.from_wei(max_gas_cost, 'ether')} ETH") print(f"TX: {tx_hash.hex()}") return tx_hash.hex()EIP-1559 费用结构字段 含义baseFeePerGas 协议自动设置,每笔交易必须支付,销毁maxPriorityFeePerGas 给矿工的小费,激励打包maxFeePerGas 用户愿意支付的上限实际每 Gas 费用 min(maxFeePerGas, baseFee + priorityFee)实际从账户扣除: actual_cost = gas_used × effective_gas_price其中 effective_gas_price = baseFee + priorityFee(不超过 maxFeePerGas)。 多余的 (maxFeePerGas - effectiveGasPrice) × gasUsed 会退还给发送方,但退还发生在交易上链后,所以发送前账户余额必须 ≥ gasLimit × maxFeePerGas。 纯转账 vs 合约调用 纯 ETH 转账 gas 固定是 21000,可以直接写死。合约调用要用 estimate_gas: gas_limit = w3.eth.estimate_gas({ "from": from_address, "to": contract_address, "data": encoded_data, }) gas_limit = int(gas_limit * 1.1) # 加 10% 缓冲合约执行失败时 gas 仍会被扣,所以建议先 call 验证不会 revert 再发送。 常见的链 gas_limit 不同链的标准转账 gas 不完全一样,普通 EVM 链一般是 21000,部分 L2 有所不同: GAS_LIMITS = { "ethereum": 21000, "polygon": 21000, "bsc": 21000, "gravity": 30000, # 某些链会高一些 }不确定的情况下用 estimate_gas 最安全。 一句话总结 EIP-1559 转账预留费用用 gasLimit × maxFeePerGas,maxFeePerGas 要比当前 baseFee 高出一定缓冲(一般 20%),防止下一块 baseFee 上涨导致交易失败。纯转账 gas 固定 21000,合约调用用 estimateGas。

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,三件事一起做,出问题的概率会低一个数量级。

Web3.py EIP-1559 交易 Gas 费计算:baseFee 波动与余额预留

EIP-1559 Gas 费结构 EIP-1559 交易有两个关键字段:maxFeePerGas:你愿意支付的最高单价(base fee + priority fee) maxPriorityFeePerGas:给矿工的小费("tip")节点在验证交易时检查: balance >= value + gasLimit × maxFeePerGas实际扣费是: gasUsed × (baseFeePerGas + priorityFeePerGas)其中 baseFeePerGas 由网络自动调整,gasUsed <= gasLimit。 典型实现(web3.py) from web3 import Web3w3 = Web3(Web3.HTTPProvider("https://mainnet-rpc.example.xyz"))def send_max(private_key, dst_address, chain_id): account = w3.eth.account.from_key(private_key) from_address = account.address balance = w3.eth.get_balance(from_address) nonce = w3.eth.get_transaction_count(from_address) # 动态读取 baseFee base_fee = w3.eth.get_block("latest")["baseFeePerGas"] # 用节点推荐的 priority fee,更可靠 priority_fee = w3.eth.max_priority_fee # maxFee 加 buffer,防 baseFee 上涨 max_fee_per_gas = base_fee * 2 + priority_fee # 估算 gas,比写死更安全 gas_limit = w3.eth.estimate_gas({ "from": from_address, "to": dst_address, "value": 1, }) gas_limit = int(gas_limit * 1.2) # 20% buffer fee = gas_limit * max_fee_per_gas # 预留少量 wei 防止因 baseFee 微小波动导致余额不足 reserve = w3.to_wei(0.000001, "ether") amount = balance - fee - reserve if amount <= 0: print(f"余额不足,balance={balance}, fee={fee}") return None tx = { "from": from_address, "to": Web3.to_checksum_address(dst_address), "value": amount, "gas": gas_limit, "maxFeePerGas": max_fee_per_gas, "maxPriorityFeePerGas": priority_fee, "nonce": nonce, "chainId": chain_id, "type": 2, } signed = w3.eth.account.sign_transaction(tx, private_key) tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) return tx_hash.hex()常见坑 坑一:maxFee 太紧,baseFee 上涨就失败 # 错误写法:maxFee 刚好等于 baseFee + priority max_fee = base_fee + priority_fee # 一旦下一个区块 baseFee 上涨,交易立即失效改为: max_fee = base_fee * 2 + priority_fee # 留足余量,只多花极少的钱坑二:写死 gasLimit gas_limit = 21000 # ETH 原生转账是这个值,但其他链可能不同更安全的方式: gas_limit = w3.eth.estimate_gas({"from": from_addr, "to": dst, "value": 1}) gas_limit = int(gas_limit * 1.2)坑三:不预留 reserve 导致余额精确到 wei 后失败 节点验证 balance >= value + gasLimit × maxFeePerGas。如果 amount = balance - fee 算完后 baseFee 微涨,条件就不满足。 预留 0.000001 ETH(约 1,000,000,000,000 wei)几乎没有损失但能避免大量边缘失败。 坑四:priority_fee 写死 priority_fee = w3.to_wei(0.1, "gwei") # 可能太低导致交易迟迟不确认改为从节点获取推荐值: priority_fee = w3.eth.max_priority_fee # 节点返回当前合理的 tip

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 是应该的。 只要用正规钱包生成 + 离线纸质备份,安全性远高于随机重复这种理论问题。

BIP39 助记词生成原理与 Python 实现

BIP39 定义了一套将随机数编码为可记忆单词的标准,大多数钱包(MetaMask、Phantom、imToken)都支持。 BIP39 原理生成 128/160/192/224/256 位随机熵 取熵的 SHA-256 哈希前 len/32 位作为校验位,拼接到熵末尾 每 11 位映射一个 BIP39 词表单词(共 2048 个词) 最终得到 12/15/18/21/24 个单词熵长度 校验位 总位数 单词数128 bit 4 bit 132 bit 12256 bit 8 bit 264 bit 24Python 实现(bip-utils) pip install bip-utils从助记词派生以太坊私钥和地址 from bip_utils import ( Bip39SeedGenerator, Bip39MnemonicValidator, Bip44, Bip44Coins, Bip44Changes, )mnemonic = "word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12"# 可选:验证助记词是否合法 if not Bip39MnemonicValidator().IsValid(mnemonic): raise ValueError("助记词无效")# 助记词 → 种子(可选 passphrase) seed = Bip39SeedGenerator(mnemonic).Generate(passphrase="")# 种子 → BIP44 派生路径 m/44'/60'/0'/0/0 ctx = Bip44.FromSeed(seed, Bip44Coins.ETHEREUM) addr_ctx = ( ctx.Purpose() .Coin() .Account(0) .Change(Bip44Changes.CHAIN_EXT) .AddressIndex(0) )private_key_hex = addr_ctx.PrivateKey().Raw().ToHex() address = addr_ctx.PublicKey().ToAddress()print(f"私钥: {private_key_hex}") print(f"地址: {address}")派生多个地址 for i in range(5): addr_ctx = ( ctx.Purpose() .Coin() .Account(0) .Change(Bip44Changes.CHAIN_EXT) .AddressIndex(i) ) print(f"[{i}] {addr_ctx.PublicKey().ToAddress()}")Solana(BIP44 Coin 501) from bip_utils import Bip44Coinsctx = Bip44.FromSeed(seed, Bip44Coins.SOLANA) sol_ctx = ctx.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT).AddressIndex(0) print(f"Solana 地址: {sol_ctx.PublicKey().ToAddress()}")手动验证 BIP39 校验和(纯 Python) import hashlibdef validate_bip39(mnemonic: str, wordlist_path: str) -> bool: with open(wordlist_path) as f: words = f.read().splitlines() word_index = {w: i for i, w in enumerate(words)} mnemonic_words = mnemonic.strip().split() bits = "" for word in mnemonic_words: if word not in word_index: return False bits += format(word_index[word], "011b") # 最后 len/33 位是校验位 cs_len = len(bits) // 33 entropy_bits = bits[:-cs_len] checksum_bits = bits[-cs_len:] # 还原熵字节 entropy = int(entropy_bits, 2).to_bytes(len(entropy_bits) // 8, "big") # SHA256 前 cs_len 位 h = hashlib.sha256(entropy).digest() expected_cs = format(h[0], "08b")[:cs_len] return checksum_bits == expected_cs注意事项助记词等同于钱包全部资产的控制权,不能在任何联网代码中明文存储 生产环境派生私钥只能在离线机器或 HSM 上操作 仅用于工具开发/验证时,建议使用测试网地址,不要导入真实资产

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 一下,绝大多数情况就通了。

Hardhat 本地链卡住:交易 pending、区块不出块的排查与解决

Hardhat 本地链卡住通常表现为:交易发出后一直 pending、await tx.wait() 不返回、区块号停止增长。 快速诊断:区块是否在出块 # 用 cast(foundry 工具) cast block-number --rpc-url http://127.0.0.1:8545# 用 curl curl -s -X POST http://127.0.0.1:8545 \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}'重复执行,如果区块号不增长说明链已卡死。 原因一:开启了手动挖矿模式 hardhat.config.js 中配置了 auto: false: networks: { hardhat: { mining: { auto: false, interval: 0 } } }此时交易发出后不会自动打包,需要手动触发出块: // Hardhat 测试脚本里 await network.provider.send("evm_mine");// 一次挖多个块 await network.provider.send("hardhat_mine", ["0xa"]); // 10 个块用 curl 触发: curl -X POST http://127.0.0.1:8545 \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"evm_mine","params":[],"id":1}'原因二:nonce 冲突导致交易队列堵塞 某笔 gas 过低的交易占住 nonce,后续交易全部等待。查当前 nonce: cast nonce 0x你的地址 --rpc-url http://127.0.0.1:8545最简单的解法是直接重置链: await network.provider.request({ method: "hardhat_reset", params: [] });或者重置到 fork 某个区块高度: await network.provider.request({ method: "hardhat_reset", params: [{ forking: { jsonRpcUrl: "https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY", blockNumber: 20000000 } }] });原因三:fork 主网时 RPC 超时 npx hardhat node --fork https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY如果 Alchemy/Infura 限速或网络超时,Hardhat 会表现得像卡住。开启调试日志: DEBUG=hardhat* npx hardhat node --fork ...看是否有 timeout 或 rate limit 报错。 原因四:缓存/artifacts 脏数据 有时前端或脚本缓存了旧的合约地址/ABI,链本身没问题但调用失败。清理后重新编译部署: rm -rf cache artifacts npx hardhat compile npx hardhat run scripts/deploy.js --network localhost最直接的解法:重启 如果不需要保留链状态,直接重启即可: Ctrl+C npx hardhat nodeHardhat 本地链的状态不持久化,重启后从创世块重新开始。如果需要持久化,使用 hardhat_reset + snapshot(evm_snapshot / evm_revert)来管理状态。

Solidity 合约安全优化:SimpleDeposit 代码审查

一份简单的 EVM 充值合约,在上主网前需要做几项安全优化。 原始合约 // SPDX-License-Identifier: MIT pragma solidity ^0.8.28;contract SimpleDeposit { address public owner; bool public paused; event Deposit( address indexed user, uint256 amount, uint256 indexed orderId, uint256 timestamp ); modifier onlyOwner() { require(msg.sender == owner, "Not owner"); _; } constructor() { owner = msg.sender; } function deposit(uint256 orderId) public payable { require(!paused, "Paused"); require(msg.value > 0, "Amount zero"); emit Deposit(msg.sender, msg.value, orderId, block.timestamp); } function withdraw(uint256 amount) external onlyOwner { (bool success, ) = payable(owner).call{value: amount}(""); require(success, "Transfer failed."); } function setPaused(bool _paused) external onlyOwner { paused = _paused; } receive() external payable { emit Deposit(msg.sender, msg.value, 0, block.timestamp); } }优化 1:withdraw 前检查余额 余额不足时 call 会失败,但报错信息不明确: function withdraw(uint256 amount) external onlyOwner { require(address(this).balance >= amount, "Insufficient balance"); (bool success, ) = payable(owner).call{value: amount}(""); require(success, "Transfer failed"); }优化 2:补充 Withdraw 事件 充值有事件,提现没有事件,后端无法追踪提现记录: event Withdraw( address indexed to, uint256 amount, uint256 timestamp );function withdraw(uint256 amount) external onlyOwner { require(address(this).balance >= amount, "Insufficient balance"); (bool success, ) = payable(owner).call{value: amount}(""); require(success, "Transfer failed"); emit Withdraw(owner, amount, block.timestamp); }优化 3:自定义 error 替代 require 字符串 require("string") 会把字符串编码进 calldata,消耗更多 gas。用 custom error 替代: error NotOwner(); error ContractPaused(); error ZeroAmount(); error InsufficientBalance(); error TransferFailed();modifier onlyOwner() { if (msg.sender != owner) revert NotOwner(); _; }function deposit(uint256 orderId) public payable { if (paused) revert ContractPaused(); if (msg.value == 0) revert ZeroAmount(); emit Deposit(msg.sender, msg.value, orderId, block.timestamp); }自定义 error 平均节省 30~50% gas。 优化 4:owner 转移功能 现在 owner 一旦设置就无法更改,新增两步转移以防误操作: address public pendingOwner;event OwnershipTransferred(address indexed oldOwner, address indexed newOwner);function transferOwnership(address newOwner) external onlyOwner { require(newOwner != address(0), "Zero address"); pendingOwner = newOwner; }function acceptOwnership() external { require(msg.sender == pendingOwner, "Not pending owner"); emit OwnershipTransferred(owner, pendingOwner); owner = pendingOwner; pendingOwner = address(0); }优化 5:ReentrancyGuard 防重入 withdraw 使用 call,存在重入风险(尽管对当前合约逻辑影响有限): bool private _locked;modifier nonReentrant() { require(!_locked, "Reentrant call"); _locked = true; _; _locked = false; }function withdraw(uint256 amount) external onlyOwner nonReentrant { require(address(this).balance >= amount, "Insufficient balance"); (bool success, ) = payable(owner).call{value: amount}(""); require(success, "Transfer failed"); emit Withdraw(owner, amount, block.timestamp); }或者直接引入 OpenZeppelin: import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";contract SimpleDeposit is ReentrancyGuard { // ... function withdraw(uint256 amount) external onlyOwner nonReentrant { ... } }可升级代理(按需) 如果合约逻辑以后可能更新,考虑 UUPS 代理模式。但对于简单充值合约,升级能力也意味着合约不再是纯粹的"不可更改",用户信任模型会变化,需要权衡。 最终版本概览优化点 作用withdraw 余额检查 明确报错,节省 gasWithdraw 事件 链上可追踪提现记录Custom error 减少 gas 30~50%两步 owner 转移 防止误转到无效地址ReentrancyGuard 防重入攻击

平台积分体系设计:Credits(余额)与 Points(积分)的区别与应用

Credits vs Points 是两套独立系统特性 Credits Points本质 消费余额(货币) 生态贡献积分可消费 是(API 调用扣费) 否可提现 是(换回 USDC/法币) 否是否等价法币 基本等价 不等价数量变化 实时增减 只增(或少量减)未来用途 功能消费 Token 空投、VIP、治理Credits:平台货币 用户充值后获得 Credits,Credits 直接用于消费: 充值 100 USDT = 获得 100 Credits 调用 API 消费 2 Credits → 余额 98 Credits 提现 50 Credits → 取回约 50 USDTCredits 必须与真实价值挂钩,用户信任建立在 1 Credit ≈ 1 USD 的基础上。 Points:生态积分 Points 不直接等于钱,是平台的"长期价值记录": 充值 100 USDT → +100 Points API 消费 20 USD → +20 Points 邀请好友充值 → +邀请奖励 Points 每日活跃 → +少量 PointsPoints 积累后可以用于:VIP 等级解锁 折扣和返佣 Token 空投快照 DAO 治理权重 锁仓质押防止套利:提现扣回 Points 如果充值得 Points、提现不扣,用户会利用套利: 1. 充值 1000 USDT → 得 1000 Credits + 1000 Points 2. 立刻提现 1000 Credits 3. 净得:0 Credits(本金拿回)+ 1000 Points(免费)解决方案:提现时扣回相应 Points: 提现 1000 Credits → 余额 -1000 Credits,同时 Points -1000或者更稳健:Points 只按实际消费累计,充值/提现不影响 Points: 充值 → Credits+,Points 不变 消费 1 USD → Points +1 提现 → Credits-,Points 不变数据库设计 CREATE TABLE user_balance ( user_id BIGINT PRIMARY KEY, credits DECIMAL(18, 6) DEFAULT 0, -- 可消费余额 points BIGINT DEFAULT 0 -- 累计积分 );-- 消费记录 CREATE TABLE consumption_log ( id BIGINT AUTO_INCREMENT, user_id BIGINT, credits_used DECIMAL(18, 6), points_earned INT, action VARCHAR(100), created_at DATETIME );Points 用整数(无小数),Credits 用高精度小数(避免浮点误差)。 Credits 扣费保证原子性 Credits 扣费和 Points 增加应在同一事务中完成: @Transactional public void consumeCredits(Long userId, BigDecimal amount, String action) { // 检查余额 UserBalance balance = balanceMapper.selectForUpdate(userId); if (balance.getCredits().compareTo(amount) < 0) { throw new InsufficientCreditsException(); } // 扣 Credits balanceMapper.deductCredits(userId, amount); // 加 Points(按消费比例) long points = amount.longValue(); balanceMapper.addPoints(userId, points); // 记录消费日志 consumptionLogMapper.insert(userId, amount, points, action); }SELECT FOR UPDATE 防止并发扣款导致余额超扣。 首版设计建议Credits 优先实现,这是核心盈利结构 Points 首版只记录,不设兑换规则(避免承诺后反悔) 后期发 Token 时,用 Points 快照作为分配依据 积分比例(1 Credits 消费 = 多少 Points)后期可调,但不能追溯修改历史记录

Web3 AI API 平台设计:Credits、Points 与链上 Vault 合约

面向区块链用户的 AI API 平台,核心是弄清楚什么适合上链、什么必须留在链下。 Credits 与 Points 的区别 两者经常被混淆,但定位完全不同:项目 Credits Points是否可消费 是(API 扣费) 否是否能提现 是 否是否等价美元 基本是 不是是否实时扣减 是 一般不扣后续用途 付费 API 使用 Token 空投、VIP 等级、DAO 治理Credits 是"钱",用来实时扣 API 费用。充值 100 USDT → 获得 100 credits。 Points 是"生态贡献值",记录用户长期行为,为后续 Token 发行、空投、质押做准备。首版只记录,不承诺兑换比例。 Points 推荐规则(防刷) 充值不给 points(容易刷) 实际 API 消费给 points:1 USD 消费 = 1 point 邀请用户消费后返 points:被邀请人消费 100 USD → 邀请人 +10 points提现时扣减对应 points,避免"充值即拿 points 然后提现"的空刷。 链上 vs 链下的边界 AI API 平台天然偏中心化,因为每秒有大量 streaming、token 级扣费、fallback 重试。内容 位置 原因充值记录 链上 不可篡改,用户可自证钱包资金 链上 透明储备Credits 余额 链下 高频修改,链上 Gas 不可接受API token 计费 链下 streaming 无法逐 token 上链Fallback/retry 逻辑 链下 毫秒级响应要求链上充值的意义不是"绝对无法改余额",而是让作恶留下公开证据:用户可以用 tx hash 证明确实充值了,平台无法赖账。 Vault 合约设计 首版只需要一个极简充值金库,不需要 upgradeable proxy、自动提现或 token 合约。 核心功能接收 BNB(depositBNB) 接收 ERC20(USDT/USDC,depositToken) 每笔充值绑定 orderId,供后端对账 管理员提现(用 Safe 多签作为 owner) Pausable + ReentrancyGuardorderId 的必要性 不用 orderId 的话,用户直接 transfer 到合约,后端无法知道这笔钱对应哪个平台账户的哪个充值订单,难以补单和退款。 Solidity 实现 // SPDX-License-Identifier: MIT pragma solidity ^0.8.24;import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import "@openzeppelin/contracts/utils/Pausable.sol";contract VibeVault is Ownable, ReentrancyGuard, Pausable { using SafeERC20 for IERC20; mapping(address => bool) public allowedTokens; event DepositBNB(address indexed user, uint256 amount, bytes32 indexed orderId); event DepositToken(address indexed user, address indexed token, uint256 amount, bytes32 indexed orderId); event WithdrawToken(address indexed token, address indexed to, uint256 amount); event WithdrawBNB(address indexed to, uint256 amount); constructor(address initialOwner) Ownable(initialOwner) {} function setAllowedToken(address token, bool allowed) external onlyOwner { allowedTokens[token] = allowed; } function pause() external onlyOwner { _pause(); } function unpause() external onlyOwner { _unpause(); } function depositBNB(bytes32 orderId) external payable whenNotPaused { require(msg.value > 0, "invalid amount"); emit DepositBNB(msg.sender, msg.value, orderId); } function depositToken( address token, uint256 amount, bytes32 orderId ) external whenNotPaused nonReentrant { require(allowedTokens[token], "token not allowed"); require(amount > 0, "invalid amount"); IERC20(token).safeTransferFrom(msg.sender, address(this), amount); emit DepositToken(msg.sender, token, amount, orderId); } function withdrawToken(address token, address to, uint256 amount) external onlyOwner nonReentrant { require(to != address(0), "invalid to"); IERC20(token).safeTransfer(to, amount); emit WithdrawToken(token, to, amount); } function withdrawBNB(address payable to, uint256 amount) external onlyOwner nonReentrant { require(to != address(0), "invalid to"); (bool success,) = to.call{value: amount}(""); require(success, "transfer failed"); emit WithdrawBNB(to, amount); } }部署时 initialOwner 传入 Safe 多签地址,不要传 EOA 钱包,否则单私钥泄露即丢失全部资金。 链下 Credits 账本设计 Credits 必须在链下用数据库管理,但要做到可审计: users: wallet_address, credits, frozen_credits, points api_keys: user_id, key_hash, status requests: user_id, model_tier, input_tokens, output_tokens, actual_cost, status balance_logs: user_id, type(deposit/consume/refund/withdraw), amount, balance_before, balance_after, request_id预扣费防并发刷余额 streaming 请求可能持续数分钟,必须先冻结余额: 请求开始 → 冻结 1 USD(frozen_credits += 1) 请求结束 → 按实际 token 数结算 实际费用 0.23 USD → 真正扣 0.23,解冻 0.77 上游失败 → 全部解冻,不扣费Credits 存储用整数 内部:1 credit = 1,000,000 units(类似 USDC 的 6 位精度) 不要用 float,浮点误差会导致账单对不上BSC 充值确认数建议资产 建议确认数BNB 6~12USDT 12USDC 12后端监听 DepositBNB 和 DepositToken 事件,达到确认数后入账 credits。 降低管理员作恶风险Safe 多签(2/3):单私钥泄露无法提币 热钱包只放小额:大额资金存 Safe 每 6 小时上链余额 Merkle Root:用户可验证平台没有偷改余额 balance_logs 不可删除:所有扣费操作可完整审计

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 就行。

查 Solana 钱包交易:Solscan API vs 原生 RPC

Solana 生态里,Solscan 是最常用的区块浏览器(类似以太坊的 Etherscan)。除了网页查询,它也有 API——加上 Solana 原生 RPC,就够做钱包监控 / 交易分析 / DeFi 数据抓取了。 Solscan 网页能查什么交易:交易 hash → 转账明细、手续费、状态 钱包地址:余额、SPL Token、NFT、历史交易 区块:高度、出块时间、确认数 代币:合约详情、持币地址、24h 交易量 NFT:铸造、转移 DeFi:部分 DEX / 质押 / 借贷协议数据日常查东西直接搜地址或 hash 就行。要批量、定时、集成到自己系统里就得走 API。 Solscan Pro API 老公共 API public-api.solscan.io 现在已经不推荐了,改用 Pro API pro-api.solscan.io(免费额度也有)。 账户交易列表: GET https://pro-api.solscan.io/v2.0/account/transactions?address=<WALLET>&limit=10 Header: token: <API_KEY>账户详情(余额、类型): GET https://pro-api.solscan.io/v2.0/account/detail?address=<WALLET>代币持仓: GET https://pro-api.solscan.io/v2.0/account/token-accounts?address=<WALLET>去 pro-api.solscan.io 申请免费 API Key。速率限制看套餐,免费大概 60 次/分钟。 Solana 原生 RPC(更灵活) 不想被限流、想拿完整交易数据,走 JSON-RPC。任何 Solana 节点都支持。 列钱包最近交易签名: POST https://api.mainnet-beta.solana.com Content-Type: application/json{ "jsonrpc": "2.0", "id": 1, "method": "getSignaturesForAddress", "params": [ "<WALLET>", { "limit": 20, "commitment": "finalized" } ] }查单笔交易详情: { "jsonrpc": "2.0", "id": 1, "method": "getTransaction", "params": [ "<SIGNATURE>", { "encoding": "jsonParsed", "maxSupportedTransactionVersion": 0 } ] }jsonParsed 编码会把系统程序、Token 程序的指令解析成人可读的 JSON,比 base58 好读多了。 Python 版本:定时监控新交易 import time import requestsRPC = "https://api.mainnet-beta.solana.com" WALLET = "<你的钱包地址>"seen = set()def rpc(method, params): r = requests.post(RPC, json={ "jsonrpc": "2.0", "id": 1, "method": method, "params": params, }, timeout=10) return r.json().get("result")def poll(): sigs = rpc("getSignaturesForAddress", [WALLET, {"limit": 20}]) or [] for s in reversed(sigs): sig = s["signature"] if sig in seen: continue seen.add(sig) tx = rpc("getTransaction", [ sig, {"encoding": "jsonParsed", "maxSupportedTransactionVersion": 0}, ]) if not tx: continue block_time = tx.get("blockTime") err = tx["meta"]["err"] print(f"新交易 {sig[:16]}... block_time={block_time} err={err}") # 这里根据业务解析 innerInstructions 判断是转账 / swap / mintwhile True: try: poll() except Exception as e: print("poll error:", e) time.sleep(15)要点:seen 记忆已处理签名,避免重复 reversed() 让最老的先出,保持时间顺序 生产上把 seen 落地到 Redis / SQLite,重启不丢免费 RPC 的坑 api.mainnet-beta.solana.com 是 Solana Labs 的公共节点,速率限制很紧,高频请求会 429。生产环境走托管 RPC:Helius — 免费 10 万请求/月 QuickNode Alchemy Solana Chainstack免费额度足够个人项目。 拿 SPL Token 余额 { "jsonrpc": "2.0", "id": 1, "method": "getTokenAccountsByOwner", "params": [ "<WALLET>", { "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" }, { "encoding": "jsonParsed" } ] }programId 是 SPL Token 程序的地址,返回该钱包持有的所有 SPL Token 账户,包含 mint 地址、余额、decimals。 一句话总结 Solscan API 好用但有限流,Solana 原生 RPC 更自由。核心组合 getSignaturesForAddress + getTransaction,生产环境别用免费公共节点,走 Helius / QuickNode。