[ 11 / 16 ] · Monad课程

第 11 课:支付(加密)

14 分钟400 XP

在 Monad 上接受链上支付——无需支付处理商、信用卡网络或银行账户。

你的应用已经能够存储数据并对用户进行身份认证。但要让它成为一个真正的产品,还差一件事:收钱。这一课会补上这一块——而且不需要支付处理商(payment processor)、信用卡网络(card network)或银行账户。


收钱,是「兴趣项目」与「生意」之间的分水岭。在传统 Web 上,这意味着接入 Stripe、把 PCI 合规推给别人、等好几天才能拿到钱、并且每笔交易都要付掉约 3% 的手续费。在 Monad 上,意味的是另一回事:用户从自己的钱包向你的钱包发送 MON,交易在链上不到一秒就清算(settle)完成,你的应用通过读取区块链来核验它确实发生了。

你将构建一个最小的端到端示例:一个带三个按钮(0.01、0.05 和 0.1 MON)的小费罐(tip jar)。点击按钮,钱包弹出请你确认转账,交易落到 Monad testnet 上,你的服务器在感谢用户之前先验证它。同样这一套流程,也撑起了整个 EVM 生态里的链上结账(on-chain checkout)、订阅产品和打赏功能。

本课在 testnet 上完成。 这一课中你做的所有事情都发生在 Monad testnet。Testnet 上的 MON 没有任何货币价值——它是水龙头(faucet)发出的免费「演习币」,正是为这种学习场景设计的。没有真金白银流动,没有真实卡片被扣款,你钱包里的资产没有任何风险。这里搭起来的流程将来在 mainnet 上完全一样跑得通,只是需要换一下链的配置(chain config)。

安全是这一课贯穿始终的主题。 即便是在 testnet 上,也要把习惯练得像对待真钱一样。等你切到 mainnet 的那一天,一个 bug 不再是学习契机,而是变成被洗空的钱包,或者攻击者免费坐顺风车。


为什么要走链上支付,而不是刷卡?

传统支付处理商(Stripe、Adyen、Braintree)夹在你的应用和信用卡网络之间。它们处理卡数据(card data)、风控评分(fraud scoring)、PCI 合规和打款。好用,但贵:每笔交易付约 3%,要等几天钱才到账,还要担心几个月内的拒付(chargeback)。

链上支付把这一整套换成了一个原语(primitive):在公共账本(public ledger)上发生的钱包到钱包转账。

  • 没有卡数据。 没有 PAN、有效期或 CVC 需要存储。用户在自己的钱包里签名(sign)一笔交易。你的服务器永远看不到私钥。
  • 没有 PCI DSS。 针对卡数据的安全标准不再适用,因为根本没有卡数据。
  • 没有处理商,没有中间人。 用户的钱包直接和 Monad 对话,你从链上读取结果。
  • 几秒清算完成。 Monad 出块快到——当用户看到成功页时,钱已经是你的了。没有 3 天冻结期。
  • 可编程。 这笔支付就是区块链上的一笔交易。退款、分账、托管(escrow)、订阅都可以用智能合约来搭,而不再依赖某个第三方厂商的 API。

代价是:用户得有一个钱包,而且收到的钱是 MON(一种加密资产),而不是美元。对已经在生态里的用户来说,这是个特性;对其他人来说,这是道你的 UX 必须解决的门槛。本课不会替你解决这个 UX 问题,但给你一套后端,让你应用其他部分能稳稳建在上面。

为什么 PCI DSS 不适用于基于 Monad 的支付流程?

信用卡支付与 Monad 支付在清算上的主要差别是什么?


一笔链上支付到底发生了什么

下面这些步骤你都不用自己去实现。用户的钱包和 Monad 会负责难的部分。但搞清楚步骤,你才会真正理解后面的安全建议为什么这么写。

  • 客户端发起意图(intent)。 你的应用说「从用户钱包向商户地址转 0.05 MON」。用户的钱包(MetaMask、Rabby 等)会显示出他即将签署什么。
  • 签名(signature)。 用户在钱包里确认。钱包用用户的私钥(private key)对交易进行签名。你的应用永远看不到这把私钥。
  • 广播。 已签名的交易通过一个 RPC 节点(RPC endpoint)发送到 Monad。某个验证节点(validator)将它打包到下一个区块里。
  • 确认。 Monad 完成出块。这笔转账成为永久公共账本的一部分。任何人都能读到它,没有人能撤销它。
  • 服务器侧验证。 你的服务器拿到客户端报上来的交易哈希(transaction hash),从 Monad 自身重新拉取这笔交易,并核验收款方、金额、链是否都是你预期的。只有全都对上时,应用才给用户记账。

关键洞察:客户端说「我付过了」只是 UX 上的提示。链才是真相(the chain is the truth)。 永远不要仅凭客户端发来的消息就发货或给账户加额度。永远要从链上重新读取这笔交易。


动手之前:你的收款钱包

要接受支付,你只需要一样东西:一个用来收 MON 的钱包地址。就这么多。你不需要把私钥放在服务器上——地址本身就是公开信息(public information),收 MON 的整个过程不涉及任何签名。

  1. 打开 MetaMask(或任何 EVM 钱包),专门为这个小费罐项目创建一个新账号。
  2. 复制公开地址(public address)(以 0x… 开头)。这就是你要放进服务器环境变量里的 RECIPIENT_ADDRESS
  3. 不要复制私钥。不要把它放进 .env,不要把它粘进任何智能体(agent)的提示词里。你不需要它来收钱,只有花钱时才需要——而本课从不会从这个收款钱包里花钱。
  4. 确保你将作为*付款方(payer)*使用的那个钱包里有一些 testnet MON。可以从 Build Anything 水龙头 领取。

安全:收款钱包的私钥完全不要进入这个项目。 公开地址进 env,可以;私钥,绝不。在 testnet 上最坏的情况只是丢一点免费的水龙头 MON,无关紧要。但现在就要把习惯养好——到了 mainnet,同样的泄露会在每一笔小费到账的瞬间被洗空,那是真金白银。把这个 testnet 钱包当作生产环境的银行登录凭据来对待,因为肌肉记忆才是真正会带过去的东西。

对于这个小费罐,收款钱包的私钥应当放在哪里?


一起来构建一个小费罐

我们要在 Monad testnet 上构建一个能完成链上支付的最小应用——三个按钮:0.01、0.05、0.1 MON。点击按钮,钱包弹出,你确认转账,等服务器在链上验证通过后,你会跳转到 /success 页面。记住:testnet MON 没有货币价值,所以你可以放心折腾。

新建一个 Repl,打开 AI 面板,把下面这段粘进去:

I'm building a tip jar app to learn on-chain payments on Monad
testnet. The user connects a wallet and sees three tip buttons:
0.01, 0.05, and 0.1 MON. Clicking a button sends a MON transfer
from their wallet to my recipient address. After the transfer
confirms on-chain and my server verifies it, they land on a
/success page.

Rules:
- Target Monad testnet. Chain ID: 10143. RPC:
  https://testnet-rpc.monad.xyz. Block explorer:
  https://testnet.monadscan.com. Native token: MON.
- Use Next.js with wagmi + viem + RainbowKit on the client for
  wallet connection and sending the transaction. Do NOT install
  ethers.
- Read the recipient address from process.env.RECIPIENT_ADDRESS
  on the server. Expose it to the client via a server endpoint,
  not by hard-coding it in a component.
- Tip amounts MUST be defined in server code, not accepted from
  the client. The client sends { button: 1 | 2 | 3 }; the server
  maps that to the exact wei amount. Otherwise a malicious client
  could claim "I paid 0.1 MON" for a 0.01 MON transaction.
- After the wallet confirms, the client POSTs
  { txHash, button } to /api/verify-payment. That route:
    * Uses viem's createPublicClient({ chain: monadTestnet,
      transport: http(MONAD_RPC) }) to fetch the transaction and
      its receipt from Monad testnet.
    * Asserts receipt.status === 'success'.
    * Asserts tx.to (lowercased) === RECIPIENT_ADDRESS (lowercased).
    * Asserts tx.value >= the expected wei amount for the button.
    * Asserts tx.chainId matches 10143.
    * Tracks seen tx hashes (in-memory Set for this lesson is
      fine) and rejects duplicates — same hash cannot be reused.
  Return 200 only when every check passes. 4xx otherwise with a
  clear error message.
- Success URL: /success (a simple page that says "Thanks!" and
  shows the tx hash + a link to the explorer).
- If RECIPIENT_ADDRESS is missing, show a clear error in place
  of the buttons. Do not silently fail.
- Keep the UI minimal. Three buttons on a plain page, standard
  system fonts, plain colors, no gradients.

Run it locally so I can test it in my browser.

Replit Agent 会搭好这个应用,并弹出一个环境变量面板向你索要 RECIPIENT_ADDRESS。把你收款钱包的公开地址(以 0x… 开头)粘进去,点击 Save Variables。Replit 会重启应用,智能体会把它接好。

试一试

打开预览。点击 Connect Wallet,连接一个有些 testnet MON 的钱包(如果没有,去 Build Anything 水龙头 领点)。点击 0.01 MON 按钮。你的钱包会弹出来请你确认这笔 testnet 转账。批准它——没有真钱在动。

交易确认后,你会跳到 /success,页面上有一条指向 testnet.monadscan.com 上这笔交易的链接。打开浏览器(block explorer)——你能看到自己的转账、发送方、收款方、金额,全都摆在公共 testnet 账本上。

也可以试试这些:

  • 在钱包里拒绝交易。 你的应用应该展示一个清晰的错误,并把用户留在小费罐页面。
  • 在钱包里切到错误的网络。 你的应用应当在交易能继续之前提示你切回 Monad testnet。
  • 试着重放(replay)一个交易哈希。 打开开发者工具,拿一个已经成功过的 tx hash,用同样的 button 再次 POST 到 /api/verify-payment。服务器应当拒绝它。

为什么小费金额必须在服务器上定义,而不是由客户端来定?

收款钱包地址应该从哪里来?


在链上验证支付

客户端发来「这是我的 tx hash,请给我加额度」只是 UX 提示,不是证据。恶意客户端可以发任何哈希——一笔老的、一笔发到别人钱包的、一笔在另一条链上的、一笔被 revert 的。你的服务器必须自己从 Monad 重新读取这笔交易,并核验它和你预期的完全一致。

下面是验证逻辑的最小骨架——智能体生成的代码里应该差不多就是这个样子(去看看它写的 /api/verify-payment):

import { createPublicClient, http, parseEther } from 'viem'
import { defineChain } from 'viem'

const monadTestnet = defineChain({
  id: 10143,
  name: 'Monad Testnet',
  nativeCurrency: { name: 'MON', symbol: 'MON', decimals: 18 },
  rpcUrls: { default: { http: ['https://testnet-rpc.monad.xyz'] } },
})

const client = createPublicClient({ chain: monadTestnet, transport: http() })

const AMOUNTS_WEI = {
  1: parseEther('0.01'),
  2: parseEther('0.05'),
  3: parseEther('0.1'),
} as const

const seen = new Set<string>()

export async function verify({ txHash, button }: { txHash: `0x${string}`, button: 1 | 2 | 3 }) {
  if (seen.has(txHash)) throw new Error('tx hash already used')

  const [tx, receipt] = await Promise.all([
    client.getTransaction({ hash: txHash }),
    client.getTransactionReceipt({ hash: txHash }),
  ])

  if (receipt.status !== 'success') throw new Error('tx did not succeed')
  if (tx.chainId !== 10143) throw new Error('wrong chain')
  if (tx.to?.toLowerCase() !== process.env.RECIPIENT_ADDRESS!.toLowerCase())
    throw new Error('wrong recipient')
  if (tx.value < AMOUNTS_WEI[button]) throw new Error('amount too low')

  seen.add(txHash)
}

每一项检查都对应着一种具体的攻击:

  • seen.has(txHash) 防重放(replay)——同一笔已成功的交易不能被兑换两次。生产环境里,这要由数据库列上的唯一索引来撑住;内存里的 set 在每次部署后都会被清空。
  • receipt.status === 'success' 阻止把 revert 掉的交易当成功来记账。
  • tx.chainId 阻止用户在某条便宜的 EVM 链上付 0.1 MON、再把哈希拿到这里来用。
  • tx.to 阻止用户付给自己再把哈希提交上来。
  • tx.value >= expected 阻止用户付了 0.01 MON 那一档却来领 0.1 MON 的奖励。

安全:永远在服务器端从链上验证。 区块链是「这笔支付到底有没有发生」唯一的真相来源。在这里相信客户端,等同于在 Stripe 流程里仅凭成功重定向页面加载就发货——攻击者只需要 POST 一个编出来的哈希,就能把货物拿走。

为什么 /api/verify-payment 路由要从 Monad 重新拉取交易,而不是直接信任客户端?

为什么要追踪已经见过的 tx hash 并拒绝重复?


从 Testnet 到 Mainnet

到目前为止一切都跑在 Monad testnet 上。没有真实价值,免费的水龙头 MON,一个可以随便炸的沙箱。等到 mainnet 可用、你也准备好接受真实支付时:

  1. 专门为 mainnet 创建一个新的收款钱包。不要复用你的 testnet 收款钱包——把环境完全分开,这样一个被泄露的 testnet 私钥永远不会沾上真钱。
  2. 换一下链的配置:mainnet chain ID、mainnet RPC URL、mainnet block explorer。(最新的 mainnet 取值请查 docs.monad.xyz。)
  3. 更新服务器端的 env:把 RECIPIENT_ADDRESS 换成新的 mainnet 地址,把 MONAD_RPC 换成 mainnet 的 RPC。
  4. 不要在本地开发里换这些。本地保持 testnet,这样调试时就不会一不小心向真钱包扣款。
  5. 在生产环境里考虑把请求挂在付费的 RPC 提供商后面(公共 RPC 在高负载下会被限流),并为验证失败设置监控。

不像 Stripe,这里没有「激活账户」这一步——没有 KYC、没有银行信息、没有几周的审核。你已经能收钱了。代价是 Monad 也不会替你做 KYC——如果你的产品出于合规原因需要它,这是你要自己叠在上面的一层。

安全:把 testnet 和 mainnet 的配置分别放在不同的 env 文件 / 项目里。 一个本地 dev 指向 mainnet 的环境变量配错了,就足以一不小心把你的水龙头钱包洗空,或者在一次测试点击里向真实用户扣款。


出入金通道(仅 mainnet)

一旦上了 mainnet,链上支付要跑得通,就得用户手里有 MON 可以发——而对作为收款方的你来说,则要能在需要时把 MON 换回法币(fiat)。这条横在银行账户和钱包之间的桥梁,叫做出入金通道(onramp/offramp):onramp 是把卡或银行账户转成钱包里的 MON;offramp 则是把 MON 换回银行账户。把它内嵌到你的结账流程里,新用户即使没有加密资产也能完成支付,而你也能在另一头把钱兑成美元。这一切在 testnet 上都不适用——testnet MON 来自水龙头,没有货币价值,也无法买卖。当前支持 Monad mainnet 的服务商列表请见 docs.monad.xyz/tooling-and-infra/onramps


其他链上支付流程(可选)

小费罐是最简单的入门方式。生态里还能支持更多模式:

  • 订阅。 链上做循环扣款(recurring payment)更麻烦(没有「绑定卡片」可以扣)。常见做法是:每个周期都让用户发一笔新交易,或者部署一个智能合约,让它持有 ERC-20 allowance,按计划自动从用户那里拉款。
  • 退款。 没有处理商替你撤销扣款。退款就是从你钱包向用户钱包发一笔新交易。在数据库里,把退款和原始支付记在一起。
  • 分账 / 市场(marketplace)。 写一个智能合约来接收支付,并在同一笔交易里把每个参与方该拿的那份立刻转过去。这比事后对账 Stripe Connect 的转账简单太多。
  • 稳定币(stablecoin)。 如果你想让价格以美元计价而不是 MON 计价,可以接受稳定币(例如 Monad 上的 USDC),用 ERC-20 的 transferFrom 模式。验证逻辑类似,只是检查的是合约事件(contract event),而不是原生的 value
  • 代币化访问(tokenized access)。 给付款人铸造一个 NFT 或灵魂绑定代币(soulbound token)。你的应用通过查询钱包里的持有情况来解锁功能,而不是去查一行订阅记录。

对一个初次上线的链上产品来说,本课里的原生 MON 转账就是最合适的选择。代码最少、活动部件最少、端到端最容易验证。


安全要点回顾

  • 收款私钥: 完全不要进入项目。env 里只放公开地址。
  • Testnet vs mainnet: 永远先用 testnet 开发。只在生产环境的 env 里才能切到 mainnet 的取值。
  • 金额: 永远在服务器上定义。客户端选一个按钮,服务器把它映射成 wei 金额,并在验证时作为最小值使用。
  • 收款方: 每一次请求都从服务器 env 里读。永远不要相信客户端传来的地址。
  • 验证: 从 Monad 重新拉取交易。检查 statuschainIdtovalue。一个「成功」的重定向或者客户端的声明都不算证据。
  • 防重放(replay protection): 在数据库(或一个持久化的存储)里给 tx hash 加唯一约束。内存里的 set 是个一部署就清空的玩具。
  • RPC 信任: 生产环境里用你付费或自有的 RPC。一个恶意 RPC 可以在交易上撒谎。
  • 成功 / 取消路由: 硬编码到你自有域名下的路径。不要让用户输入决定支付完成后跳到哪里。

你刚刚构建了什么

你的应用现在能:

  • 接受链上支付——在 Monad testnet 上接收 testnet MON
  • 完全不在项目里保留收款私钥——因为收款根本不需要它
  • 在服务器端定义价格——这样恶意用户没法用便宜档付款却来领贵档奖励
  • 靠读取 Monad 自身来验证每一笔支付——这样伪造的 tx hash 和跨链伪装都拿不到访问权

这套东西,正是每一个链上结账、每一个打赏接入、每一条订阅流程背后的同一台机器——只是少了支付处理商、少了 PCI 合规、少了那 3% 的手续费。等你的项目上 Monad mainnet 时,只要换一下链的配置,同样的代码就开始搬动真实价值了。


接下来是什么

你的应用现在能存数据、做身份认证、收链上支付。下一站:把这些都用上,做一个收费订阅的 newsletter 应用——就像发布一个真正的产品那样。

0/7 正确

0% — 全部答对即可完成

注册以记录进度