在一个单文件 HTML 里做一套可供加密货币支付的付费墙这个想法初看像 demo真正落地时涉及的细节远比想象中多。Onefile-unlock 的目标很明确把内容锁定、钱包支付、交易确认、内容解锁整条链路封装成一个自包含 HTML 文件让创作者不依赖后端服务就能发布一篇需要付费访问的页面也让开发者在学习链上支付流程时有一个最小可运行样本。这里的 crypto 指的是加密货币付款paywall 是页面上的付费门槛而 onefile 约束了项目形态所有 CSS、JavaScript、交互逻辑和内容都内联在同一个 index.html 中。这篇文章会围绕一条主线展开先拆解 Crypto Paywall 的工作原理再准备好浏览器和测试链环境然后实现一个可运行的付费墙页面接着把校验、状态、持久化这些关键代码讲透最后给出本地验证、常见报错排查和生产化建议。全文给出的代码是教学用最小闭环实际项目落地时要把收款地址、链 ID、价格、RPC 地址替换成自己的配置。1. 先理解 Crypto Paywall一个页面如何锁定并解锁内容1.1 什么是 Paywall为什么选择加密货币支付Paywall 是网上内容付费门槛的通用说法。常见的形态有三种订阅制门槛、单篇购买门槛、以及“关注或转发后可见”的轻量门槛。传统网页里的付费墙通常依赖账号体系、支付回调、订单状态这些后端机制由服务器把未付费用户挡在内容之外。加密货币支付的独特之处在于它天然适合“无账号、无服务器”的支付场景。用户使用自己的钱包直接发起转账交易记录公开可查付款方不需要注册收款方只需要公开一个地址。对于独立开发者、内容创作者、开源工具作者来说这意味着可以用很小的成本验证“用户是否真的付过钱”从而决定是否解锁内容。在 Onefile-unlock 这个场景里选择加密货币支付的性价比尤为明显整个文件不引入第三方支付 SDK不申请支付渠道不处理用户注册只需要和链上的交易数据打交道。1.2 单文件设计onefile 方案的价值与约束“单文件”不是把代码压缩成一个 HTML 那么简单它带来的实际收益包括零构建步骤修改后浏览器直接刷新即可不需要 npm install、webpack、打包发布。分发方便一个 HTML 文件可以通过邮件、对象存储、网盘甚至即时通讯工具分发复制到任何目录都能跑。依赖隔离所有脚本体内联不会因为 CDN 挂掉或版本升级导致页面崩溃。但单文件也有明显约束必须提前认识到没有服务端意味着无法绝对保护内容。如果内容本身直接写在 HTML 里用户查看源码就能绕过付费。这个限制会在第七章详细讨论。验证交易时依赖公网 RPC 或区块浏览器 API存在 CORS、网络不稳定和免费额度问题。钱包扩展只在部分浏览器上下文注入页面file:// 协议下经常拿不到window.ethereum。所以单文件付费墙适合保护轻量内容、工具页面、试用版本或者作为学习链上支付的训练项目。要想保护高价值内容必须引入后端做最终拦截。1.3 解锁流程拆解从点击支付到内容可见整个解锁流程可以用一个状态机来理解这也是实现时的核心抽象locked锁定 - paying支付中 - verifying校验交易 - unlocked解锁初始状态是 locked页面显示付费墙正文被隐藏。用户点击支付按钮后进入 paying此时调用钱包发送交易拿到交易哈希。随后进入 verifying脚本循环查询链上交易回执确认收款地址正确、金额足够、交易状态为成功。确认通过后进入 unlocked隐藏付费墙、显示正文并把解锁状态写入 localStorage。这个流程最关键的设计点在于不能只看交易哈希生成了就认为支付成功必须等到交易被打包并确认。链上交易有 pending、success、failed 三种常见结局只有回执中status为0x1才代表成功。2. 准备运行环境与调试工具2.1 浏览器、本地服务和钱包环境Onefile-unlock 的核心运行环境是浏览器不是 Node.js。开发调试时建议满足以下条件项目推荐配置说明浏览器Chrome / Edge 最新版对 EIP-1193 钱包协议支持最稳定本地服务python3 -m http.server 8080比直接双击 HTML 文件更接近生产环境钱包MetaMask 或支持window.ethereum的钱包用于签名和发送测试交易网络HTTPS 或 localhostWeb Crypto 部分 API 在非安全上下文不可用测试链Sepolia 测试网主网交易有真实成本不适合调试如果只双击 HTML 文件会走file://协议。在这个协议下浏览器可能不会向页面注入钱包扩展对象也可能因为跨域限制请求不了 RPC。最稳妥的做法是把文件放到一个本地静态服务里访问。2.2 测试链选择先用本地节点或测试网调试支付流程时不要一上来就用以太坊主网。主网一笔转账需要真实的 ETH出错成本高。推荐三种方案按优先级排列本地开发网络使用 Hardhat 或 Ganache 启动一条本地链区块确认快余额随便改最适合反复测试交易回执解析逻辑。公共测试网 Sepolia接近主网行为需要从测试网水龙头领取测试币适合验证钱包切换网络、交易广播这些真实流程。主网只适合最终验收且要确保价格配置和收款地址万无一失。一个常见的误区是测试时只在 MetaMask 里切到测试网但页面里写死的链 ID 还是主网。这样钱包会反复提醒切换网络。需要在页面配置里维护chainId、chainName、rpcUrl和对应的区块浏览器地址并在支付前主动检查。2.3 单文件项目结构说明整个项目只有一个index.html内部按功能块组织index.html ├── head │ ├── meta 基础信息 │ └── style 所有内联样式 ├── body │ ├── section idlocked-content 被锁定内容 │ ├── section idpaywall 付费墙 UI │ └── section idunlocked-content hidden 解锁后可见内容 └── script ├── CONFIG 配置对象 ├── 状态机与工具函数 ├── 钱包连接、支付、校验 └── 初始化与事件绑定样式和脚本都内联是为了保持“一个文件分发”的特性。为了避免选择器冲突和全局污染建议给顶层容器使用固定 id不要定义大量全局变量所有状态都收敛到状态机里。3. 最小可运行案例把内容锁进一个 HTML 文件3.1 页面骨架锁定区、解锁区、支付面板下面这个页面骨架定义了三个区域被锁定的内容区、付费墙按钮区、解锁后的内容区。核心思路是锁定区默认显示付费墙也默认显示解锁内容默认隐藏。!doctype html html langzh-CN head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1 titleOnefile-unlock Crypto Paywall/title style body { font-family: system-ui, -apple-system, sans-serif; max-width: 720px; margin: 40px auto; padding: 0 16px; line-height: 1.7; color: #222; } .paywall { border: 1px solid #ddd; border-radius: 8px; padding: 32px; text-align: center; background: #fafafa; } .paywall .price { font-size: 28px; font-weight: 700; } .paywall button { cursor: pointer; padding: 10px 20px; border-radius: 6px; border: 1px solid #888; background: #fff; font-size: 15px; } .paywall button.primary { background: #1a73e8; border-color: #1a73e8; color: #fff; } .status { margin: 14px 0; font-size: 14px; color: #555; min-height: 20px; } [hidden] { display: none !important; } /style /head body h1Onefile-unlock 单文件付费墙演示/h1 section idlocked-content p这篇文章的完整内容已锁定。完成一笔测试网支付后正文会自动显示。/p /section section idpaywall classpaywall p classprice0.001 ETH/p p解锁完整内容/p div classstatus idstatus等待支付/div button typebutton idbtn-pay classprimary连接钱包并支付/button p stylefont-size: 12px; color: #999;演示环境使用 Sepolia 测试网不会产生真实资金支出。/p /section section idunlocked-content hidden h2这里是解锁后的正文/h2 p恭喜支付校验通过。这段内容在实际项目中可以替换为文章、工具入口或下载链接。/p p解锁状态已经保存到 localStorage刷新页面后仍然可见。/p /section !-- 内联脚本见下文 -- /body /html这个骨架不依赖任何外部 CSS 库。hidden属性作为最基础的显隐控制配合[hidden] { display: none !important; }可以避免被其它样式覆盖。3.2 支付参数配置块所有可变参数集中在CONFIG对象里这是单文件付费墙最容易维护的一部分。改动支付地址、价格、链 ID 时不需要去业务代码里找。const CONFIG { // 收款地址正式使用前必须替换为自己的地址 merchantAddress: 0xYourMerchantAddressHere, // 支付金额单位是 Wei。1000000000000000 0.001 ETH priceWei: 1000000000000000, // Sepolia 测试网 Chain ID十六进制格式 chainId: 0xaa36a7, chainName: Sepolia, rpcUrl: https://rpc.sepolia.org, // localStorage 中记录解锁状态的键名 unlockKey: onefile_unlock_demo, // 轮询交易回执的最大次数 maxCheckTimes: 30 }; const State Object.freeze({ LOCKED: locked, PAYING: paying, VERIFYING: verifying, UNLOCKED: unlocked }); let currentState State.LOCKED;实际使用时要特别注意priceWei的单位。在 EVM 链上ETH 的最小单位是 Wei1 ETH 10^18 Wei。写死字符串而不是浮点数是避免精度丢失的基本要求。如果支付的是 USDT 这类 ERC-20 代币就不能只给value字段而要调用合约的transfer方法流程会更复杂。chaindId使用十六进制字符串是因为eth_chainId返回值约定是十六进制。主网是0x1Sepolia 是0xaa36a7。3.3 连接钱包并发送交易的完整逻辑这一节是付费墙的核心动作连接钱包、检查网络、发送交易、轮询回执、解锁内容。下面代码是一个可运行的最小实现。const $ (id) document.getElementById(id); function setStatus(text) { $(status).textContent text; } function setState(next) { currentState next; const btn $(btn-pay); if (next State.PAYING || next State.VERIFYING) { btn.disabled true; setStatus(next State.PAYING ? 等待钱包确认... : 正在验证交易...); } else { btn.disabled false; } } function genRequestId() { if (typeof crypto ! undefined typeof crypto.getRandomValues function) { const arr new Uint8Array(16); crypto.getRandomValues(arr); return Array.from(arr, (b) b.toString(16).padStart(2, 0)).join(); } // 兜底仅用于展示类场景不要用于安全敏感逻辑 return String(Date.now()) - String(Math.floor(Math.random() * 1e9)); } async function connectWallet() { if (typeof window.ethereum undefined) { setStatus(未检测到钱包扩展请先安装 MetaMask); return null; } const accounts await window.ethereum.request({ method: eth_requestAccounts }); const chainId await window.ethereum.request({ method: eth_chainId }); if (chainId ! CONFIG.chainId) { try { await window.ethereum.request({ method: wallet_switchEthereumChain, params: [{ chainId: CONFIG.chainId }] }); } catch (error) { if (error.code 4902) { await window.ethereum.request({ method: wallet_addEthereumChain, params: [{ chainId: CONFIG.chainId, chainName: CONFIG.chainName, rpcUrls: [CONFIG.rpcUrl] }] }); } } } return accounts[0]; } async function pay() { const account await connectWallet(); if (!account) return; setState(State.PAYING); try { const txHash await window.ethereum.request({ method: eth_sendTransaction, params: [{ from: account, to: CONFIG.merchantAddress, value: CONFIG.priceWei }] }); console.log([onefile-unlock] txHash , txHash); setState(State.VERIFYING); const ok await verifyTransaction(txHash); if (ok) { unlock(); } else { setState(State.LOCKED); setStatus(未能在超时时间内确认交易请重试); } } catch (err) { console.error([onefile-unlock] payment failed, err); setState(State.LOCKED); setStatus(支付失败或已取消); } } async function verifyTransaction(txHash) { for (let i 0; i CONFIG.maxCheckTimes; i) { const [tx, receipt] await Promise.all([ fetchRpc(eth_getTransactionByHash, [txHash]), fetchRpc(eth_getTransactionReceipt, [txHash]) ]); if (tx receipt receipt.status 0x1) { const toOk tx.to tx.to.toLowerCase() CONFIG.merchantAddress.toLowerCase(); const amountOk BigInt(tx.value) BigInt(CONFIG.priceWei); if (toOk amountOk) { return true; } return false; } await new Promise((resolve) setTimeout(resolve, 3000)); } return false; } async function fetchRpc(method, params) { const resp await fetch(CONFIG.rpcUrl, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ jsonrpc: 2.0, id: genRequestId(), method, params }) }); const json await resp.json(); return json.result || null; } function unlock() { try { localStorage.setItem(CONFIG.unlockKey, 1); } catch (err) { console.warn([onefile-unlock] localStorage unavailable, err); } $(paywall).hidden true; $(locked-content).hidden true; $(unlocked-content).hidden false; setState(State.UNLOCKED); setStatus(已解锁感谢支持); } function checkStoredState() { try { if (localStorage.getItem(CONFIG.unlockKey) 1) { unlock(); } } catch (err) { console.warn([onefile-unlock] read localStorage failed, err); } } $(btn-pay).addEventListener(click, pay); checkStoredState();这段代码的校验顺序值得注意先确认交易被打包且状态是0x1再检查收款地址和金额。如果只检查回执存在攻击者可以给任意地址转账后伪造一个回执对象如果只检查地址不检查金额付款不足也会通过。BigInt把十六进制字符串转成大整数比较避免出现精度问题。4. 关键代码详解状态、校验与持久化4.1 用 Web Crypto 生成请求 ID避开 crypto.getRandomValues 报错上面代码中的genRequestId使用了 Web Crypto API 的crypto.getRandomValues。它的作用是生成随机请求 ID用来区分每次 RPC 调用避免把不同请求的响应搞混。很多开发者在本地打开页面时遇到过下面这类报错TypeError: crypto.getRandomValues is not a function或者在使用打包工具启动开发服务器时看到类似的压缩变量名报错TypeError: crypto$2.getRandomValues is not a function这类报错的常见原因有三个页面运行在非安全上下文。crypto.getRandomValues在部分旧浏览器或非 HTTPS 环境下不可用http://192.168.x.x这类局域网地址经常触发这个问题。调试环境里的 Node.js 版本过旧。某些构建工具或依赖库在启动时调用了 Node 侧crypto.getRandomValues而旧版本 Node 没有暴露这个方法。代码在非浏览器环境执行。比如在 SSR、小程序或某些 WebView 里全局crypto对象没有对应的实现。解决方案对应如下本地开发优先使用http://localhost生产环境强制 HTTPS。升级 Node.js 到当前维护版本并更新构建工具链。访问前先做能力检测像示例里那样用typeof crypto.getRandomValues function判断再决定是否走兜底。需要强调兜底分支里的Math.random()不适用于安全场景它不具备密码学强度。这个兜底只是为了不让页面在展示场景下直接崩溃真实支付项目里不能依赖它生成签名或密钥。4.2 为什么不要再引入 CryptoJS很多早期单页应用习惯引入 CryptoJS 这类库做 AES 加密、SHA 哈希。但在单文件付费墙场景里强烈建议使用浏览器原生 Web Crypto API而不是引入 CryptoJS。原因有三层浏览器已提供原生的crypto.subtle和crypto.getRandomValues覆盖常见哈希、加解密、随机数需求不需要维护额外依赖。新版浏览器和工具链会在控制台输出类似Using CryptoJS is deprecated. Use global crypto object instead.的警告。这不是说页面马上坏而是提醒开发者迁移到标准 API。CryptoJS 的旧版本内部随机数实现依赖Math.random在密码学场景下有安全隐患。原生crypto.subtle使用系统级随机源更可靠。在实际单文件付费墙中常见的加密需求是给锁定内容做一个简单的不透明处理。这里不建议做“本地 JS 加密内容”这种自欺欺人的方案因为密钥也写在同一个文件里用户打开开发者工具就能看到。真正需要加密时应由后端保管密钥用户支付后由后端返回解密密钥或单独的内容地址。4.3 为什么校验交易要用回执而不是交易哈希交易哈希只是广播成功的凭证不代表交易最终成功。一笔交易可能因为 Gas 不足、合约执行失败等原因最终上链失败此时也有哈希也有回执但回执里的status是0x0。因此校验逻辑必须同时满足三个条件校验项方法失败后果交易是否上链eth_getTransactionReceipt返回非空停留在 pending交易状态成功receipt.status 0x1误放行失败交易收款地址和金额正确eth_getTransactionByHash比对to与value付错地址或金额不足也解锁示例代码把两个 RPC 用Promise.all并行拉取然后统一判断。这里还有一个未处理的细节真实项目还需要确认交易所在区块已经获得足够确认数特别是大额支付场景防止链重组导致交易被回滚。可以读取receipt.blockNumber再调用eth_blockNumber比较差值。4.4 用 localStorage 记录解锁状态解锁状态保存到 localStorage是为了让用户刷新页面后仍然保持解锁。这个逻辑写在unlock()和checkStoredState()两个函数里。这里有几个要点localStorage.setItem和getItem都要包在 try/catch 里。某些浏览器隐私模式、沙箱 iframe 或部分 WebView 会禁用 localStorage不加保护会导致整个解锁流程异常。localStorage 可以被用户手动清掉所以它只是“体验优化”不是权限控制。不要在 localStorage 里保存付费凭证、私钥、交易签名任何敏感信息。它只是解锁状态的标记。如果希望更严谨可以在解锁时保存交易哈希并在下次加载时调用 RPC 重新校验。这样即使 localStorage 被篡改服务端或链上校验也能兜底。不过这就超出了单文件无后端的范围属于生产化改造内容。5. 运行验证从本地服务器到测试链支付5.1 启动本地静态服务不要直接双击 HTML 文件先用本地静态服务cd onefile-unlock python3 -m http.server 8080然后在浏览器打开http://localhost:8080。使用 localhost 的好处是满足安全上下文要求钱包扩展也更容易注入。也可以使用 Node 生态的静态服务工具npx serve .两者效果相同。启动后先确认页面能正常显示付费墙再打开浏览器开发者工具 Console 面板确认没有红色报错。5.2 在测试网上完成一笔最小支付验证前需要准备三个东西一个 MetaMask 钱包导入测试网账户。一笔 Sepolia 测试币可以从水龙头领取。一个收款地址可以用另一个测试账户代替。在页面点击“连接钱包并支付”钱包会依次弹出连接授权、切换网络、确认交易三个弹窗。确认后页面状态会从“等待钱包确认”变为“正在验证交易”Console 里会打印交易哈希[onefile-unlock] txHash 0x7f8c9a...等待几秒到几十秒后页面应显示解锁后的正文。打开 Etherscan 测试网浏览器输入这个交易哈希可以看到交易状态为 SuccessTo地址和金额与页面配置一致。5.3 验证结果与日志观察验证分为三个层次验证层次操作预期结果UI 层点击支付并完成钱包确认页面切换到“正在验证交易”数据层查看 Console 中的 txHash哈希能在测试网浏览器查到逻辑层回执校验通过付费墙隐藏正文显示刷新后仍可见只跑通一次正向流程还不够还要验证异常分支。可以故意把CONFIG.merchantAddress改成一个错误地址再用原地址支付观察页面是否仍然解锁。正常情况下页面应停在“未能在超时时间内确认交易”因为校验逻辑发现了地址不匹配。这才是校验函数真正起作用的证明。6. 常见问题排查6.1 crypto.getRandomValues 实际排查路径如果页面或开发服务器报crypto.getRandomValues is not a function按下面顺序排查看报错来自浏览器还是 Node。浏览器控制台报错优先检查访问地址是不是http://localhost或 HTTPS。看协议。局域网 IP 加 HTTP 会被浏览器视为不安全上下文换成localhost或其他 HTTPS 地址。看 Node 版本。启动开发服务器时如果 Node 版本过旧升级到当前维护版本后重新启动。看代码位置。如果在页面脚本里调用加typeof crypto前置判断并打印crypto对象确认哪些属性存在。在代码里保留一份能力检测分支是成本最低的防御措施。6.2 钱包没有注入 window.ethereum现象是点击支付后提示“未检测到钱包扩展”。可能原因检查方式解决方案钱包扩展未安装点击浏览器扩展栏查看安装 MetaMask 后刷新页面运行在 file:// 协议地址栏是否以 file:// 开头改用本地静态服务钱包未启用当前站点权限查看钱包扩展的站点连接列表在钱包中允许该站点浏览器限制注入用 Chrome 无痕模式验证换浏览器或关闭受限模式MetaMask 等钱包通常只在 HTTPS 或 localhost 页面注入window.ethereum。这几乎是单文件付费墙最常见的踩坑点直接双击 HTML 文件常常看不到钱包对象。6.3 支付后内容没有解锁交易已经确认但页面始终停留在“正在验证交易”从三个方向排查交易是否真的成功。用测试网浏览器查交易哈希看status是否为 Success。校验参数是否匹配。检查CONFIG.merchantAddress是否和实际发起交易时的to地址一致注意大小写。地址比较时统一toLowerCase()。RPC 是否可用。公共 RPC 偶尔会限流或超时反复请求失败会走到超时分支。可以在 Console 里手动执行fetch(CONFIG.rpcUrl, ...)看返回。通常最隐蔽的问题是地址不一致。例如页面配置的是收款地址 A钱包实际转账到了地址 B肉眼看起来差不多但代码比对不通过。6.4 CryptoJS 弃用警告的处理控制台出现Using CryptoJS is deprecated. Use global crypto object instead.时先确认代码里是否真正使用了 CryptoJS。如果是旧项目遗留尽快替换为原生 API如果只是某个依赖间接引用了 CryptoJS可以升级该依赖或在建设阶段评估是否保留。单文件付费墙项目本身不需要 CryptoJS。哈希运算可以学习使用原生crypto.subtle.digest随机 ID 使用crypto.getRandomValues签名和验证交给钱包完成业务脚本里几乎不需要自己实现任何加密算法。7. 安全边界与生产化建议7.1 客户端验证的局限防盗链要到服务端解决必须坦白一个边界纯客户端付费墙无法真正阻止技术用户免费看内容。HTML 里的正文、脚本里的解锁标记都在用户本机用户打开开发者工具就能看到全部内容或者直接改 localStorage 里的解锁状态。所以单文件客户端付费墙适合保护低价值内容、演示页、试用工具不能当作 DRM 使用。高价值内容必须由服务端拦截。页面只展示摘要完整正文通过接口获取接口根据服务端验证过的支付记录返回内容。如果既要单文件分发又要内容保护可以给正文做服务端托管HTML 里只保留获取正文的地址和支付校验逻辑。支付回调由服务端接收再签发短期访问令牌。如果不打算引入后端至少不要在脚本里写任何密钥、私钥或管理口令。收款地址是公开信息暴露没有问题私钥一旦写在 HTML 里等于把钱包交给所有访问者。7.2 生产环境的必要补充回调、防重放、对账、监控如果这个付费墙要服务真实用户以下改造必不可少改造项目的推荐做法服务端回调摆脱浏览器侧轮询实时确认支付使用 Blocknative、Alchemy Webhook 等工具监听地址入账防重放同一笔交易不能解锁多次内容服务端维护已处理交易哈希集合重复哈希直接拒绝订单映射区分不同用户和不同商品给每个订单生成唯一 memo 或收款子地址对账及时发现漏单和异常每日拉取链上交易记录与实际订单比对日志监控定位支付失败和校验异常记录交易哈希、轮询次数、失败原因接入告警链上确认数防止因链重组产生误判大额支付等待 12 个以上确认小额等待 1 到 3 个确认这些内容虽然不在这一个 HTML 文件里但它们是同一个业务系统的一部分。单文件方案的价值在于快速起步和分享生意真正跑起来后后端保障不可省略。7.3 扩展方向从 ETH 小额支付到多链和代币当前示例支持的是 ETH 原生代币转账。按使用场景可以扩展出几个方向支持 ERC-20 代币支付。调用合约transfer方法需要构造交易数据界面也要展示代币符号和小数精度。支持 Bitcoin 风格的地址收款。页面生成收款地址用户扫码支付后轮询区块浏览器 API。此时为了保持单文件二维码生成可以调用在线服务或内联一段最小 QR 库。支持订阅制。把 localStorage 解锁标记换成过期时间到期后重新锁定适合按周、按月付费的场景。把支付成功后的跳转链接做成可配置适合“付费后进入网盘或资源页”的轻量资源交易。每个扩展都会增加校验复杂度建议保持“状态机 配置对象”的骨架不变只替换支付和校验两个模块。发布前可以按这份清单检查收款地址是否替换正确链 ID 是否匹配目标网络金额单位是否为 Wei 字符串RPC 地址是否可用钱包切换网络逻辑是否覆盖了4902错误码localStorage 读写是否包了异常处理页面刷新后解锁状态是否保留错误分支是否都有用户提示。把这几项确认完一个单文件 Crypto Paywall 就可以稳定运行在测试网上了。