1. 背景与核心概念
1.1 什么是加密货币付费墙(Crypto Paywall)
先从一个直观场景说起。你在个人网站上发布了一篇深度技术教程、一组高清壁纸,或者一个在线计算工具,希望只有付费用户才能看到完整内容。传统做法是接入 Stripe、支付宝、微信支付,但这类方案通常需要注册商户账号、配置回调接口、处理退款,甚至还需要企业资质。对于一个个人开发者或者小团队来说,这套链路并不轻松。
加密货币付费墙(Crypto Paywall)则提供了一种更轻量的思路:访客通过 MetaMask 等加密钱包,向指定地址转账一定金额的数字货币,支付成功后自动解锁网页中的隐藏内容。整个过程不依赖中心化支付平台,没有退款和风控纠纷,交易记录在链上公开可查。
而 Onefile-unlock 这个名字非常直白——“one file + unlock”,即把所有逻辑打包到一个自包含的 HTML 文件中,打开浏览器即可运行,不需要安装 Node.js,不需要后端服务器,不需要数据库。
1.2 单文件 HTML 方案为什么值得关注
传统 Web 项目通常分为前端、后端、数据库三层。前端页面负责展示,后端负责验证支付状态,数据库负责记录哪些用户已经付费。这样做的好处是安全可控,但部署成本也高。
Onefile-unlock 选择了一条不同的路径:所有功能集中在一个 HTML 文件中,前端即应用,浏览器即运行时。这种方案适合以下场景:
- 个人作品集网站中的付费内容解锁;
- 临时活动页面的付费访问;
- 技术演示和概念验证;
- 不想维护服务器的小型数字产品。
当然,单文件方案也有明显边界,比如纯前端逻辑无法保证内容绝对安全,用户可以通过阅读源码绕过付费。因此它更适合“防君子不防小人”的场景。本文后面会专门讨论这个问题。
1.3 读懂这个方案需要哪些基础
这篇文章定位是中级偏入门教程,需要你具备以下基础:
- 熟悉 HTML、CSS、JavaScript 基本语法;
- 了解 MetaMask 钱包的基本操作;
- 对以太坊交易和智能合约有初步认识;
- 能看懂并修改简单的 Solidity 合约(示例合约并不复杂)。
如果你完全不了解区块链,也不要着急。下文会先讲解工作原理,再给出可直接运行的代码,最后通过“常见问题”帮你避坑。
2. 运行原理拆解
2.1 整体架构
Onefile-unlock 的简化版实现由三部分构成:
- 一个可部署的智能合约,通常部署在以太坊测试网或主网,用于接收付款并记录付款状态;
- 一个钱包地址,作为收款方;
- 一个 HTML 文件,内部包含 Web3 逻辑、内容锁定逻辑和解锁后的页面展示。
用户操作流程如下:
- 打开 HTML 页面,某段正文被遮罩遮挡。
- 点击“解锁内容”按钮,MetaMask 弹窗弹出交易确认请求。
- 用户确认支付,交易被广播到区块链网络。
- 页面监听交易状态,一旦确认,立即解锁内容。
这里的关键点在于:交易状态是通过钱包地址和交易哈希(Transaction Hash)来确定的。
2.2 为什么需要智能合约
有人可能会问:“我直接用 MetaMask 转账不就行了吗?为什么还要写合约?”
直接转账确实可以完成支付,但有一个问题:你怎么证明某个地址已经付过款?纯前端只能通过交易哈希来查询记录,而 MetaMask 弹出的交易确认窗口中,用户未必能准确区分付款对象。
引入一个极简合约后,我们可以把“支付动作”和“解锁状态”绑定在一起。用户调用合约的pay()函数并附带金额,合约记录调用者的地址;前端再通过hasPaid(address)查询该地址是否已解锁。
这样做的好处是:
- 状态统一记录在合约中,不需要额外维护数据库;
- 多用户场景下,每个地址独立记录,互不影响;
- 后续可以扩展出不同级别的付费档位。
2.3 前端如何与区块链交互
HTML 文件通过ethers.js(或 Web3.js)连接 MetaMask 注入的以太坊 Provider。浏览器访问页面时,MetaMask 会自动向页面注入window.ethereum对象,前端通过这个对象获取用户账户、发起交易、监听事件。
基本交互步骤:
- 检测
window.ethereum是否存在; - 请求用户授权连接钱包;
- 构造合约实例;
- 调用合约的支付函数;
- 监听交易确认。
这些步骤在后面的完整代码中会逐一体现。
3. 环境准备与版本说明
3.1 环境工具清单
由于这是一个单文件 HTML 项目,环境准备非常简单。你只需要以下工具:
| 工具 | 说明 |
|---|---|
| 浏览器 | Chrome、Edge、Firefox 均可,推荐 Chrome |
| MetaMask 钱包插件 | 用于连接以太坊网络、签名交易 |
| 文本编辑器 | VS Code、Sublime Text,甚至记事本都可以 |
| 本地 HTTP 服务器 | 可选,推荐用 VS Code Live Server 或 Python 自带服务 |
| 测试网代币 | 用于测试支付流程,可在 Sepolia 水龙头免费领取 |
3.2 网络与版本说明
因为这是一个示例项目,本文使用的关键库和网络信息如下:
- Solidity 版本:
^0.8.0; - ethers.js:通过 CDN 引入,使用 v5 版本,v6 部分 API 有变化,需要按实际情况调整;
- 智能合约部署网络:默认使用以太坊 Sepolia 测试网;
- MetaMask:安装最新稳定版即可。
版本需要根据你的项目实际情况调整。正式部署到主网前,一定先在小额测试网验证完整流程。
4. 完整实战案例
4.1 项目结构设计
虽然最终只有一个 HTML 文件,但为了演示清晰,我们分两步走:
- 先编写并部署一个极简合约;
- 再编写一个自包含的 HTML 文件,集成 ABI、合约地址和前端逻辑。
如果你想把合约也编译成字节码嵌入 HTML,也可以实现,但会增加文件体积。本文采用“合约部署到链上,HTML 只存 ABI 和地址”的方式,这是最通用的做法。
推荐本地目录结构:
onefile-unlock/ ├── index.html # 最终的单文件应用 ├── Paywall.sol # 智能合约源码(开发时使用,不属于最终文件) └── README.md # 说明文档4.2 编写智能合约
合约的作用是接收用户付款,并记录每个地址的付款状态。这里不搞复杂功能,只保留核心逻辑。
文件路径:Paywall.sol
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract Paywall { address public owner; uint256 public unlockPrice; mapping(address => bool) public hasPaid; event ContentUnlocked(address indexed user, uint256 amount); constructor(uint256 _price) { owner = msg.sender; unlockPrice = _price; } function pay() external payable { require(msg.value >= unlockPrice, "Insufficient payment"); require(!hasPaid[msg.sender], "Already unlocked"); hasPaid[msg.sender] = true; // 转账给 owner (bool sent, ) = owner.call{value: msg.value}(""); require(sent, "Failed to send Ether"); emit ContentUnlocked(msg.sender, msg.value); } function setPrice(uint256 _newPrice) external onlyOwner { unlockPrice = _newPrice; } modifier onlyOwner() { require(msg.sender == owner, "Not owner"); _; } }关键点说明:
unlockPrice是解锁价格,单位是 wei;hasPaid是一个映射,记录地址是否完成支付;pay()是付费函数,用户调用并附带足够金额后,hasPaid[msg.sender]会被标记为true;owner.call{value: msg.value}()会把收到的以太币实时转给合约部署者。
该合约是示例性质,生产环境建议加入暂停开关、提款保护、价格修改时的事件通知等机制。
4.3 部署合约
部署方式有两种:使用 Remix IDE,或者使用 Hardhat 脚本。这里推荐 Remix,因为不需要额外安装环境。
操作步骤:
- 打开 Remix IDE ;
- 新建文件
Paywall.sol,粘贴上面的代码; - 在编译面板选择 Solidity
0.8.x版本,点击 Compile; - 切换到 Deploy 面板,Environment 选择
Injected Provider - MetaMask; - 确认 MetaMask 当前网络是 Sepolia,并且账户里有测试 ETH;
- 在构造函数参数中输入价格,例如
1000000000000000(表示 0.001 ETH); - 点击 Deploy,MetaMask 弹窗确认交易。
部署成功后,记下合约地址,后面会用到。
4.4 编写单文件 HTML
现在进入核心部分:编写一个直接双击打开就能运行的index.html。
文件路径:index.html
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Onefile Unlock - Crypto Paywall Demo</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f7fb; color: #1f2937; display: flex; justify-content: center; padding: 40px 16px; } .container { max-width: 720px; width: 100%; background: #fff; border-radius: 16px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06); padding: 32px; } .article-header { margin-bottom: 24px; } .article-header h1 { font-size: 24px; margin-bottom: 8px; } .article-header .meta { color: #6b7280; font-size: 14px; } .article-content { line-height: 1.8; font-size: 15px; } .article-content .preview p { margin-bottom: 16px; } .locked-content { position: relative; filter: blur(6px); user-select: none; pointer-events: none; background: #f9fafb; padding: 16px; border-radius: 12px; } .locked-content.unlocked { filter: none; user-select: auto; pointer-events: auto; background: transparent; padding: 0; } .paywall-overlay { text-align: center; padding: 32px 16px; background: linear-gradient(135deg, #f6d365 0%, #fda085 100%); border-radius: 12px; margin-top: 16px; } .paywall-overlay h3 { margin-bottom: 8px; } .paywall-overlay p { color: #4b5563; font-size: 14px; margin-bottom: 20px; } button { cursor: pointer; border: none; border-radius: 8px; padding: 12px 24px; font-size: 14px; font-weight: 500; transition: background 0.2s ease; } button.primary { background: #111827; color: #fff; } button.primary:hover { background: #1f2937; } button:disabled { cursor: not-allowed; opacity: 0.5; } .status { margin-top: 16px; font-size: 14px; color: #2563eb; text-align: center; word-break: break-all; } .status.error { color: #dc2626; } .unlocked-message { background: #ecfdf5; border: 1px solid #a7f3d0; color: #065f46; padding: 12px; border-radius: 8px; margin-top: 16px; font-size: 14px; text-align: center; } </style> </head> <body> <div class="container"> <div class="article-header"> <h1>🔒 付费解锁完整内容演示</h1> <div class="meta">这是一个单文件 HTML 实现的加密货币付费墙示例</div> </div> <div class="article-content"> <div class="preview"> <p>这是文章开头的一段免费内容,所有访客都可以阅读。</p> <p>在下方可以看到一部分被模糊遮挡的付费内容预览。当交易确认后,遮挡会自动消失。</p> </div> <div id="lockedContent" class="locked-content"> <h3>付费内容区域</h3> <p>恭喜!你已经成功解锁了这篇付费文章。</p> <p>在这里可以放置你真正想售卖的完整内容:代码示例、高清资源链接、深度教程、工具下载地址等。</p> <p>如果这是你的正式项目,建议结合 IPFS、云存储或者后端接口来承载大量付费资源,避免把敏感信息直接嵌在前端。</p> </div> <div id="paywallOverlay" class="paywall-overlay"> <h3>解锁完整内容</h3> <p>使用 MetaMask 支付 0.001 ETH(测试网)即可解锁本篇文章</p> <button id="unlockBtn" class="primary">立即解锁</button> <div id="status" class="status"></div> </div> <div id="unlockedMessage" class="unlocked-message" style="display: none;"> ✅ 支付成功,内容已解锁! </div> </div> </div> <!-- 引入 ethers.js v5 --> <script src="https://cdn.jsdelivr.net/npm/ethers@5.7.2/dist/ethers.umd.min.js"></script> <script> // ========================================== // 核心配置:部署合约后替换成你的实际地址 // ========================================== const CONTRACT_ADDRESS = "0xYourContractAddressHere"; // 合约 ABI(与 Paywall.sol 对应) const CONTRACT_ABI = [ "constructor(uint256 _price)", "function pay() external payable", "function hasPaid(address) external view returns (bool)", "function owner() external view returns (address)", "function unlockPrice() external view returns (uint256)", "function setPrice(uint256 _newPrice) external", "event ContentUnlocked(address indexed user, uint256 amount)" ]; let provider, signer, contract; let alreadyUnlocked = false; // ========================================== // 工具函数 // ========================================== const statusEl = document.getElementById('status'); const unlockBtn = document.getElementById('unlockBtn'); const lockedContent = document.getElementById('lockedContent'); const unlockedMessage = document.getElementById('unlockedMessage'); const overlay = document.getElementById('paywallOverlay'); function setStatus(msg, isError = false) { statusEl.textContent = msg; statusEl.classList.toggle('error', isError); } async function init() { if (typeof window.ethereum === 'undefined') { setStatus("未检测到 MetaMask,请先安装浏览器钱包插件。", true); unlockBtn.disabled = true; return; } try { provider = new ethers.providers.Web3Provider(window.ethereum); await provider.send("eth_requestAccounts", []); signer = provider.getSigner(); contract = new ethers.Contract(CONTRACT_ADDRESS, CONTRACT_ABI, signer); // 检查当前用户是否已经付过费 const address = await signer.getAddress(); const paid = await contract.hasPaid(address); if (paid) { unlockContent(); } else { setStatus("钱包已连接,等待解锁操作。"); } } catch (err) { console.error(err); setStatus("连接钱包失败:" + err.message, true); } } async function handleUnlock() { if (!contract) { setStatus("请先连接钱包再执行解锁操作。", true); return; } try { setStatus("正在请求支付,请在 MetaMask 中确认交易..."); unlockBtn.disabled = true; const tx = await contract.pay({ value: ethers.utils.parseEther("0.001") }); setStatus("交易已提交,等待链上确认,交易哈希:" + tx.hash); // 等待 1 个区块确认(测试环境 1-2 个即可) const receipt = await tx.wait(1); if (receipt.status === 1) { unlockContent(); } else { setStatus("交易失败,请检查网络状态后重试。", true); unlockBtn.disabled = false; } } catch (err) { console.error(err); // 用户取消交易或交易失败 setStatus("解锁失败:" + err.message, true); unlockBtn.disabled = false; } } function unlockContent() { alreadyUnlocked = true; lockedContent.classList.add('unlocked'); overlay.style.display = 'none'; unlockedMessage.style.display = 'block'; setStatus("内容已解锁。"); } // 初始化监听 unlockBtn.addEventListener('click', init); // 页面加载时先尝试连接(不强制弹窗) window.addEventListener('load', async () => { if (typeof window.ethereum !== 'undefined') { try { provider = new ethers.providers.Web3Provider(window.ethereum); const accounts = await provider.listAccounts(); if (accounts.length > 0) { signer = provider.getSigner(); contract = new ethers.Contract(CONTRACT_ADDRESS, CONTRACT_ABI, signer); const paid = await contract.hasPaid(accounts[0]); if (paid) { unlockContent(); } } } catch (err) { console.warn("自动检查钱包状态失败:", err); } } }); </script> </body> </html>4.5 代码逐段解析
上面这个 HTML 文件很长,但逻辑并不复杂。我们逐个关键部分说明。
样式部分
被锁定的内容使用filter: blur(6px)做了模糊处理,同时禁用鼠标选择和点击事件。这样访问者能看到内容确实存在,但无法阅读。解锁后移除这些样式。
这种方式只适合演示。有经验的用户打开开发者工具,直接删除locked-content样式类就能看到内容,因此正式项目需要在内容传输机制上做更强的保护。
合约连接部分
provider = new ethers.providers.Web3Provider(window.ethereum); await provider.send("eth_requestAccounts", []); signer = provider.getSigner(); contract = new ethers.Contract(CONTRACT_ADDRESS, CONTRACT_ABI, signer);这段代码的作用是:获取 MetaMask 注入的 Provider,请求用户授权,然后创建一个可写合约实例。
已支付检查
const paid = await contract.hasPaid(address);如果当前钱包地址已经调用过pay(),hasPaid返回true,页面直接解锁,不会重复扣费。
支付流程
const tx = await contract.pay({ value: ethers.utils.parseEther("0.001") });调用合约的pay()函数,并附带 0.001 ETH。这里要求 MetaMask 当前网络和合约部署网络一致。
4.6 运行与验证
完成后的操作步骤如下:
- 用 VS Code 打开项目文件夹,右键
index.html,选择 “Open with Live Server”。 - 确保 MetaMask 已连接 Sepolia 测试网,账户中有测试 ETH。
- 点击页面中的“立即解锁”按钮。
- MetaMask 弹出交易确认窗口,检查金额和地址无误后,点击确认。
- 大约几秒到十几秒后,页面提示“交易已提交”,随后自动解锁内容。
预期结果:
- 免费内容正常显示;
- 付费内容初始状态被模糊遮挡;
- 点击按钮后,MetaMask 弹出交易确认;
- 交易确认后,模糊效果消失,解锁成功提示出现;
- 再次刷新页面,仍然保持解锁状态,因为合约中已经记录了该地址的支付记录。
4.7 将合约地址写入 HTML
在正式使用前,务必修改下面这行代码:
const CONTRACT_ADDRESS = "0xYourContractAddressHere";改成你自己部署的合约地址。如果地址写错,前端调用合约时会报错或者完全无法交互。
5. 常见问题与排查思路
5.1 常见报错场景
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 提示“未检测到 MetaMask” | 未安装 MetaMask 或浏览器插件未启用 | 安装并启用 MetaMask,刷新页面 |
| 点击按钮后 MetaMask 没有弹出 | 页面未正确连接钱包,或 MetaMask 被浏览器拦截 | 刷新页面后再次点击,检查页面是否通过 HTTP 协议访问 |
| 交易一直处于 Pending 状态 | 测试网拥挤,或 Gas 费用设置过低 | 等待区块确认,或在 MetaMask 中提高 Gas 限额 |
| 交易完成后内容仍未解锁 | 前端监听状态失败,或合约地址/ABI 配置错误 | 刷新页面,查看浏览器控制台报错信息 |
| 提示“Insufficient payment” | 支付金额小于unlockPrice | 调整前端parseEther参数或调整合约价格 |
| 重复付款 | 当前地址未在合约中查询到已支付记录,或前端状态未同步 | 先检查合约中的hasPaid(address)返回值 |
5.2 测试网如何获取测试 ETH
Sepolia 测试网的 ETH 需要通过水龙头(Faucet)获取。常见方式是:
- 在搜索引擎搜索 “Sepolia Faucet”;
- 进入支持 Sepolia 的免费水龙头网站;
- 输入你 MetaMask 中的 Sepolia 地址;
- 点击领取,等待测试 ETH 到账。
不同水龙头可能有注册或验证码要求,选择一个你能正常使用的即可。
5.3 为什么内容没有绝对保护
这是整个方案最容易被质疑的地方:纯前端 HTML 文件无法真正保护内容。用户阅读 HTML 源码后,可以直接看到被模糊遮挡的文字,或者修改 CSS 去掉模糊效果。
如果你需要更严格的内容保护,可以考虑以下思路:
- 后端接口鉴权方案:内容不直接写在 HTML 中,而是放在服务端,用户支付后获取访问令牌,再请求对应内容;
- IPFS + 加密方案:将内容加密后上传到 IPFS,支付后将解密密钥通过页面脚本返回给用户;
- 服务端渲染方案:支付完成后服务器返回完整页面,但这样就不能称为“一个 HTML 文件”了。
5.4 在本地双击打开 HTML 会怎样
双击本地文件时,页面通过file://协议打开。MetaMask 在某些浏览器配置下可能无法正常注入window.ethereum,即使注入也可能会出现连接问题。
更稳妥的方式是使用本地 HTTP 服务器。最简单的办法是使用 VS Code 的 Live Server 插件,或者运行 Python 命令:
python3 -m http.server 8000然后访问http://localhost:8000。
6. 最佳实践与工程建议
6.1 安全边界与内容保护
如果只是把内容直接写在 HTML 里,加密和付费墙都只是一个“过程仪式”。正式项目中,建议把真正值钱的内容放服务端或加密存储。
这里提供三个保护等级供参考:
- 第一级:前端模糊遮挡,适合展示演示、测试流程;
- 第二级:服务端记录用户地址,支付确认后返回内容,适合个人作品站;
- 第三级:内容加密 + 密钥按账户分发,适合数字商品销售。
6.2 合约设计的注意事项
生产环境部署合约前,至少要审视以下几个点:
pay()函数是否允许重复支付?当前示例中已经用了hasPaid限制,但如果用户想再次支付支持作者,需要额外的捐赠函数;- 合约中转移 ETH 用的是
call,而不是transfer,因为transfer的 Gas 限制已经过时; - 是否设置了暂停功能?遇到紧急情况可以暂停支付并保护用户资金;
- 是否有调整价格的事件日志?价格变动最好记录
NewPrice事件,方便前端同步。
6.3 前端错误处理
用户取消交易、网络切换、钱包未授权,这些都是常见异常。代码中已经通过try-catch捕获并展示错误信息,但在真实项目中建议补充以下处理:
- 监听 MetaMask
chainChanged事件,网络切换后重新加载合约实例; - 监听
accountsChanged事件,账户切换后重新检查hasPaid状态; - 按钮状态切换要覆盖所有分支,避免用户重复点击提交多次交易。
6.4 避免把私钥和敏感信息写进前端
Onefile-unlock 是纯前端方案,因此绝不能把合约部署者私钥、API Key 等敏感信息写入 HTML。所有用户交互都通过 MetaMask 签名完成,前端只接触公钥地址和合约 ABI。
6.5 测试流程建议
在测试网上的完整验证流程如下:
- 使用测试 ETH 部署合约;
- 使用一个新的钱包地址访问页面,确认内容被锁定;
- 使用同一钱包发起支付,确认内容解锁;
- 刷新页面,确认解锁状态保留;
- 使用另一个未支付的钱包访问页面,确认内容仍然锁定;
- 检查合约地址中的 ETH 余额是否正确累计。
7. 总结与学习路线
本文从一个简单的场景出发,完整拆解了 Onefile-unlock 这个单文件 HTML 实现的加密付费墙方案。核心收获有三点:
第一,理解了一个最小可用的链上付费墙架构,包括智能合约、前端交互和状态查询这三个环节。
第二,掌握了一套可运行的前端代码,基于 ethers.js 连接 MetaMask,调用合约支付函数,并根据交易状态解锁内容。
第三,明确了这种方案的边界:它适合轻量级、部署简单、不追求高安全性的场景,不适合处理需要强内容保护的商业业务。
如果你还想继续深入,建议按以下方向进阶:
- 学习 Hardhat 或 Foundry,用脚本自动化部署合约和编写测试;
- 研究 ERC-20 代币支付,比如让用户用 USDC 支付而不是 ETH;
- 了解智能合约安全审计,重点学习重入攻击、拒绝服务攻击和 gas 限制的相关知识;
- 尝试把内容迁移到 IPFS + 加密方案,增强内容保护能力。
动手把合约部署到 Sepolia 测试网,替换掉 HTML 里的合约地址,跑通一遍完整流程,你就能真正掌握这个单文件付费墙的实现了。如果部署过程中遇到本文没有覆盖的报错,可以翻一翻浏览器控制台和 MetaMask 的交易详情,通常能找到具体的失败原因。