教程三:为能力收费
x402:标价、报价、结算、兑付、对账
这一页把教程二里那条免费能力变成收费能力,并把钱的整条路径走完:标价、报价、 签授权、结算、双方上链、看余额、对账、审计 hub 的发放链、兑付。
付费在 ANet 里是一个可插拔模块(ANet/module/x402)。它不是内核的一部分,
也不是"一个可以在运行时关掉的开关"—— -tags no_x402 编出来的二进制里,
module/x402 的符号数是 0。
前提
一、这个构建里有没有 x402
第二行是从二进制里实际链接进来的模块注册表读出来的:
有 x402 就能收费。GitHub release 的默认二进制、以及一行安装装到的二进制,
都是完整减法模块集,所以本来就带 x402;paid 档是自己按
-tags no_mcp,no_taskboard,no_cas,no_org,no_blackboard,no_p2p,no_anetlink
裁出来的一个名字(scripts/onboard.sh 里的 PROFILE[paid]),不是 release 里的一个文件。
买卖双方都需要 x402:卖方要报价和结算,买方要签授权。任何一侧缺了, 这条路径都走不完(见本页最后一节)。
二、要注册在一个 hub 上
credit 记在 hub 的账本里,hub:<hub-aid> 就是账本的名字。没有 hub 就没有账本,
anet balance、anet redeem 之类会直接回答"没有配 hub"。
托管边界,先说清楚。 credit 记在 hub 的库里。hub 可以增发,可以在你不知情时 改一个数字,可以收了钱不给你兑付。协议阻止不了这些,代码也阻止不了。
它能做到的是另一件事:hub 改不掉双方手里已经签好的记录。付款方手里有自己签的 授权,收付双方手里都有 hub 签的结算收据,两边的证据链上各有一条。所以一笔账对不上时, 这是一场双方都拿得出记录的争执,而不是一方的说法。
你注册到哪个 hub,就是选择信任谁。
闭环:标价 → 报价 → 付款 → 结算 → 上链
给能力加 "price"
~/.anet/config.json 里,modules.service 的每条能力可以带一个 price,
单位是 hub 账本的 credit,整数:
price 缺席或为 0 就是免费,而免费的表达方式是根本不出现 402,不是"价钱为零"。
这条写在 provider.Priced 的接口注释里:Price 返回 (0, false) 表示不收费,
调用方不必去解析一个付款要求才能知道不用付钱。
改完配置要重启:
daemon 每次启动都会重新注册一次,所以显式 hub-register 不是必须的 ——
要顺便改展示名或手写标签时才用得上:
注册那一刻,daemon 会把标价折进它自己签名的卡片,放在 anet.pricing 扩展里
(能力 id → credit 数)。折进去的只有本节点真正服务、且真正收费的 id ——
为一条本节点不提供的能力公布价钱,等于邀请别人买一件交付不了的东西。
价钱在签名里面,这一点在 hub 网关那一节会再用到:hub 最多能拒卖,没法按自己编的 价钱卖,因为它拿不出那个签名。
买方先问价
不带 --pay 地调用一次:
结果里那一条是这样:
PAYMENT_REQUIRED 不是错误,是一个完整答案:它被签名、有收据、上了双方的链,
和任何别的答案一样投递。一句没人能回头指认的报价不算报价。
问价不动余额。
accepts 是一个列表,第一条永远是卖方自己 hub 的账本 —— 那条结算最便宜也最确定,
不需要第二个 hub 可达。后面跟的是卖方的 hub 在 GET /x402/supported 里报出的对端账本,
给注册在别的 hub 上的买家用。买方挑自己有余额的那一条。
--pay:照价付款并重投
daemon 做的事,按顺序:
- 先照常投一次(免费投递),等第一个答案,最多等 60 秒。
- 没有报价就说明这活是免费的,已经在做了,到此为止。
- 有报价就挑一条轨:优先自己 hub 的账本,那是本节点真正持有 credit 的地方; 自己的账本不在对方给的列表里时退回列表里第一条 credit 轨,让 facilitator 去说不。
- 先铸一个新的
interaction_id,再对着这个 id 签授权 —— 顺序不能反,因为 授权里绑着这个 id,这个绑定就是"这笔钱只能花在这一次工作上"的来源。 - 用新 id 带着授权重投。
授权里被签进去的东西:付款方、收款方、金额、账本(hub:<hub-aid>)、一个随机 nonce、
一个 5 分钟的有效窗口、以及那个 interaction id。每一样都在签名里,所以它
没法被改指向别的工作、更大的金额、别的提供方或者别的 hub。
这是第二次委派,不是第一次的续接。 第一次以一个真答案结束 —— 一个报价 —— 它签了名、有收据、在双方链上。重开它去假装那次报价没发生过,等于抹掉"价钱被说出来过" 的唯一记录。
或者手工签一个付款头
--network 和 --interaction 都可省:不给 network 时默认本节点自己 hub 的账本 ——
那是它唯一真正能动用的账本;指到别处是允许的(买方可能在别处有余额),但必须是有意的。
这条命令只在 stdout 上打印 PAYMENT-SIGNATURE 头的值,别的什么都不打印,
所以可以直接管进 curl。它用来喂 hub 的 x402 网关(见后文),或者你自己写的客户端。
签名不等于花钱。 这条命令不结算任何东西:它签出一张授权,拿到它的一方可能把它 交给 facilitator,在那之前什么都没动过。这更接近开一张支票而不是花钱,窗口和 nonce 就是让它保持这个性质的东西。
卖方收款:先结算,后干活
提供方拿到授权,先去自己 hub 的 POST /x402/settle 结算,结算成功才开始干活。
这个顺序是有意选的,代价也是明说的:后结算意味着干完才发现收不到钱;先结算意味着 活失败了钱已经付了。选第二个,因为第二种情况证据模型说得清楚 —— 效果和付款都在 两条链上,退款是一场有记录的商量。
结算失败时,提供方答的是 PAYMENT_REQUIRED 加上失败原因(见"出错时读什么"),
并把报价再附一遍;它不会去干活。
结算成功后,双方各自上链:
| 谁 | 事件类型 | 什么时候写 |
|---|---|---|
| 付款方 | anet.payment.authorized | 签授权时,在它离开本机之前 |
| 付款方 | anet.payment.settled | 结果回来、且验过 hub 的收据签名之后 |
| 收款方 | anet.payment.settled | 结算返回之后,干活之前 |
结算收据(hub 签的,base64 CoreDet-CBOR)随结果回到付款方,在 paid.receipt 里。
付款方拿本节点已验证过的 hub 密钥历史去验它,验过了才记 verified: true。
verified 这个字段是这条记录的要点:"对方说它收到钱了"和"hub 签字说它移动了额度"
是两个不同的事实,分不开它们的链会被当成在声称更强的那一个。
看余额
余额是问 hub 要的,因为 hub 就是托管方 —— 把一个本地数字说成权威会歪曲这层关系。 让这个安排可过日子的是另一半:这里每一行在某一方的签名链上都有对应记录。
新注册的账户有 100 credit 的注册赠额(ANetHub 的 RegistrationGrant 常量),
这样一个新节点不必等人来充值就能把付费路径试一遍。金额小是有意的:铸一个身份是免费的,
所以赠额永远不能值得为它去刷身份。
运营者给账户充值(-grant 的 flag 说明写明要先停 hub):
对账与审计:节点自己查 hub 的账
余额是 hub 的,记录是双方的 —— 那么"双方的记录"就得真的被拿出来比一次, 否则它只是一句设计上的说法。两条命令,都不需要任何人的许可。
anet reconcile:本节点的付款记录 vs hub 的流水
它拉 /agents/<aid>/balance 和 /agents/<aid>/ledger?limit=500,再读本节点链上的
anet.payment.authorized / anet.payment.settled / anet.credit.redeemed,按交易 id 比对。
输出里值得看的字段:
| 字段 | 含义 |
|---|---|
balance / derived_from_entries | hub 报的余额,和它自己公布的流水加起来的数。两个都是 hub 说的,相等只证明它自洽;不等就是 hub 自相矛盾 |
hub_entries / hub_entries_truncated | hub 为这个账户存了多少条,以及这一页是不是全部。分开报是为了能区分"账对不上"和"我只看了一部分" |
authorized_on_chain / settled_on_chain / redeemed_on_chain / matched | 本节点链上有多少条,其中多少条在 hub 那边找到了对应 |
missing_from_hub | 这些是严重的:本节点记过的付款,hub 的流水里没有。其中带"本节点持有 hub 签名收据"的那几条,是 hub 在和自己的签名矛盾 |
unexplained_hub_entries | hub 那边有、本节点这边没有对应事件的条目。赠额天然如此(credit 从运营者来,节点侧没有事件),所以是列出来而不是报成欺诈 |
problems | hub 自己的两个数对不上时写在这里(报的余额 ≠ 它公布的流水之和) |
agrees | problems 与 missing_from_hub 都为空。unexplained_hub_entries 不影响它 |
限制要说准:它只查这一个账户。 它对发给别人的 credit 一无所知 —— 那正是发放链 和见证要单独存在的原因。
anet audit-hub:验 hub 的发放链
它拉 GET /x402/issuance?from=0,对每一条记录:
- 用 hub 公布的密钥历史验签(密钥历史本身在被采信前先验过重放)
- 验前后哈希链接
- 与本节点此前记录过的链头比对
issued / retired 的合计是从签名记录自己的事件类型和载荷重算的,不读 hub 摆在
旁边的 JSON 列。那些列是 hub 渲染的,不是它签的;两者不一致恰恰是审计该暴露的东西,
而不是该采信的东西。
输出:hub、chain_did、entries、head_seq、head_id、issued、retired、
verified,以及非空时才出现的 problems。任何一条 seq 的 id 与本节点此前记过的不同,
problems 里会直接写出
"hub 改写了它的发放历史",带上本节点当初看到它的时间。
它防不住什么:发放。 hub 是发行方,可以随时创造 credit —— 那就是"发行方"的意思。 变的是它无法追溯地或无声地做这件事,前提是有人看过。
见证:让"有人看过"这件事对陌生人也成立
链只对"已经持有旧副本"的人可验分叉。从未被观察过的 hub 可以出示任意一条链。 所以节点可以定期把链头钉下来:
witness_hub 默认关。打开后,节点每隔 witness_every_seconds(缺省 3600 秒;
启动后 30 秒先跑一次)取一次 GET /x402/issuance/head,先把它记进自己的链
(anet.issuance.head_seen),再签一份见证投给 POST /x402/witness。先记自己那份,
因为那一份是 hub 够不着的,也是这件事值得做的全部理由。
默认关是有理由的:一个精简版节点可能什么都不提供、只和自己的 hub 说话, 要求它为网络干活不是它答应过的事。打开的节点收益最直接 —— 它拿到了关于自己余额 所在账本的独立证据。
任何人都可以核一份见证:
hub 侧四个公开只读端点,不需要认证:
| 端点 | 内容 |
|---|---|
GET /x402/supply | 已发行 / 已兑付 / 未清偿 / 各账户合计,以及从链推导的同一组数与两者是否一致 |
GET /x402/issuance | 发放链本身,每条带可独立验签的记录 |
GET /x402/issuance/head | 当前链头,见证者签的就是它 |
GET /x402/witnesses | 别人对这个 hub 签过的见证 |
/x402/supply 里 outstanding == balances 是任何人都能自己重算的等式;
chain_opening 单独列出而不并入 chain_issued,因为前者是 hub 关于自己过去的自述,
后者每一条都有签名记录可被钉住。
兑付:把 credit 拿出来
它签一张"付给 hub"的授权 —— 提现本来就是这件事。hub 那边 credit 真的离开流通
(未清偿负债降低),hub 签字说明取走了多少、对着哪个外部凭据。节点验过那个签名之后
记 anet.credit.redeemed,带 verified 字段:
这套代码不碰外部支付,也不假装碰。 --ref 对它是一串不透明的字符 —— 发票号、
打款流水、你和运营者约好的任何东西。它做的是销毁额度、签一份"我拿走了 100,
对应 invoice-2026-08"的声明。那份声明能不能兑现,是你和 hub 之间的事;
但你手里有它签的字,这是争执和摊手的区别。
兑付前后 GET /x402/supply 的 outstanding 应当正好降低你取走的数,
且 outstanding == balances 仍然成立。这两条是 scripts/scenario.sh 6.7 节的断言。
可选的公开兑付口:在 hub 付钱,到节点取货
前面那条路(委派 + --pay)全程经 hub 中继,不需要任何公开端口。
这一节是另一条路:hub 替提供方开一个 x402 资源服务器,收 402、结算,
然后把一张凭证交给买家,买家自己拿凭证去找提供方取货。
hub 因此从头到尾没见过请求,也没见过结果。
voucher_addr 会打开一个公开监听口。 默认构建除了回环控制面之外不监听任何东西;
配了这个字段就多一个对外的 HTTP 口,买家要能直接连上它。
NAT 后面没有入口的节点不能这样卖。 hub 会在卖之前就拒绝(节点卡片里没有可达的 兑付地址),而不是卖一张兑不掉的票。这种情况走上面那条中继路径。
配置:
两个字段必须同时给,只给一个会在 daemon 启动时被拒。那是会在很久以后才安静失败的 形状:要么监听了但没人知道地址,要么卡片指向一个从未打开的端口,两种都在卖收不到的货。
两个字段分开,是因为监听地址常常是 0.0.0.0 或容器内端口,把那种地址签进卡片
等于公布一个谁也到不了的门牌 —— 世界看到的是什么,只有运维知道。
voucher_url 要写成买家真正能 POST 到的那个地址,也就是带上兑付路径
(scripts/scenario.sh 与 scripts/prodtest.sh 里都是 …/x402/redeem 形式)。
它会被签进本节点卡片的 x402-redeem endpoint。
监听口上有三条路由:GET /x402/redeem(自我说明,给撞见这个端口的人看)、
POST /x402/redeem(真正的兑付)、GET /healthz。
走一遍
凭证窗口是 10 分钟(hub 的 voucherWindow)。
hub 在这条路上能做和不能做的
| 为什么 | |
|---|---|
| 不能定价 | 价钱读自 agent 自己签的卡片(anet.pricing)。hub 最多拒卖,没法按自己编的价钱卖,因为它拿不出那个签名 |
| 不能指路 | 取货地址同样在卡片的签名里(x402-redeem endpoint)。否则 hub 就能把买家指到自己的机器上,那又变成代理了 |
| 不能替你查重 | 一张凭证只能用一次,而这道关卡在 agent 那边 —— hub 不是干活的那一方,无从知道凭证用没用过 |
| 会核对条款 | 授权的 payTo 必须是它在卖的那个 agent,金额必须是那个 agent 公布的数。否则一笔小额授权能换到一张完整凭证 |
一次性由卖方把关
- 已兑付的凭证 id 记在卖方链上的
anet.voucher.redeemed,启动时从链回读, 所以重启不会让一张旧凭证复活。回读深度 10000 条;更深处会打印一行日志说明 集合被截断,而不是假装它完整。 - 先记链,后干活:崩在中间不会留下一张"看起来没兑过"的已兑凭证。
- 同一张凭证第二次来 →
409,this voucher has already been redeemed。 - 本节点根本不提供这条能力 →
404,并且释放占用 —— 买家应该能拿这张票去找 hub, 一张被我们吃掉却没干活的凭证是他们没法证明未使用的。 - 被拒绝的兑付也上链(
anet.voucher.refused,带原因)。一次拒绝也是证据: 没有它,付了钱被挡回去的买家只拿到本节点的沉默,运维也无从发现自己在拒绝所有人。
兑付跑出来的效果记的是 anet.capability.effect,和经中继委派的活同一个事件类型,
只是多一个 via: voucher。不同的门,同样的证据。
没有付费构建时:答 UNAVAILABLE,不会免费干
判据是符号数,不是"运行时禁用",而且两个方向都要验 —— 只验裁剪版为 0, 会让一个从未被链接进去的模块看起来"可插拔":
这两条就是 CI 的 pluggable job 对每个减法 tag 跑的检查
(.github/workflows/ci.yml)。这里用裸 go build 而不是 scripts/build.sh:
后者带 -ldflags "-s -w",-s 会把符号表一起去掉,go tool nm 于是查不了它的产物。
release 二进制刻意只用 -w(deploy/release/build-release.sh 里写明了理由),
所以对装好的二进制可以直接
go tool nm "$(command -v anet)" | grep -cE 'module/x402'。
这样的节点遇到一条标价的能力时:
它不会免费把活干了。"我收不了你的钱,所以我不干"是真话;"给你,不要钱" 是没人做过的决定。这条是拆模块时发现的:内核若直接跳过付费逻辑,一条标价 25 credits 的能力在无付费构建上会照干不误,而且全套测试全绿。
同一个构建上,anet balance、anet redeem、anet reconcile、anet audit-hub
也都明确回答本构建没有付费支持,而不是返回一个空结果。
scripts/onboard.sh 在 min 档上逐条断言了这些行为。
买方侧同理:没有 x402 的构建执行 anet delegate … --pay 会直接报同一句话,
因为签授权需要的那个接缝(module.PaymentSeam)在这个二进制里不存在。
出错时读什么
| 状态 / 字段 | 含义 |
|---|---|
PAYMENT_REQUIRED | 不是错误。对方要钱,并告诉了你多少 |
insufficient_funds: has N, needs M | 余额不够。活本身没问题 —— 这两件事分开报,是为了不让人去调试一个没坏的能力 |
payer <aid> not registered here | 这个 facilitator 没有付款方的账户,既没有额度可动,也没有密钥历史可核 |
this facilitator settles "hub:A", not "hub:B" | 授权签的是另一个 hub 的账本。跨 hub 时由两 hub 清算,不是本地放行 |
payment: authorization outside its validity window | 超出授权的窗口(委派路径是 5 分钟)才拿去结算 |
payment: unsigned authorization / payment: envelope signer is not the payer / INVALID_SIGNATURE | 授权没能对着付款方的密钥历史验过,包括"签的是另一套条款" |
malformed_payment | 载荷读不成一张授权。hub 网关和 POST /x402/redeem 的入口用这个常量;结算路径答的是 payload has no authorization、authorization malformed: … 一类的句子 |
| 同一张授权结算两次 | 不是错误,也不会扣两次。facilitator 以授权 id 为幂等键,第二次返回同样的成功和同样的收据;直连 POST /x402/settle 时回复的 extensions 里多一个 anet.replayed,anet 自己的输出不带这个标记 |
paid.verified: false | 记了结算,但没能验证 hub 的签名。"不知道"和"知道没问题"是两种状态 |
凭证 already been redeemed | 这张票用过了。一次性由提供方把关 |
启动时 voucher_addr and voucher_url must be set together | 两个字段只给了一个 |
no hub … | 没有 hub 就没有账本。措辞按面而异 —— 余额是 x402: no hub configured, so there is no ledger to read,兑付是 x402: no hub, so no ledger to redeem from |
这里要说准一件事:x402 的原因常量和 hub 实际吐出的字符串不是一回事。
ANetCore/payment 定义了一套原因词汇 —— insufficient_funds、invalid_signature、
expired、unknown_payer、network_mismatch、malformed_payment —— 但当前的
ANetHub 只有两个是照常量原样发出的:结算路径上的 insufficient_funds,以及
hub 网关与 POST /x402/redeem 入口上的 malformed_payment。其余几种情况答的是
上表里那些散句。想按常量分支的客户端现在只能靠这两个;别的要读文本。
已知边界
- 托管:余额在 hub 手里。上面所有工具改变的是"能不能被查出来",不是"能不能发生"。
anet reconcile只查本账户,对发给别人的 credit 一无所知。anet audit-hub拦不住增发,只拦追溯改写与无声改写。- 见证品要在见证者手里才有意义。只由被审计方保管的证据不是证据;hub 也公布收到的 见证,那是给读者一个起点 —— 它可以扣着不发,但伪造不了。
- 跨 hub:一次付款只造一份 credit。付款方 hub 的 payee 不在本地时,它销毁
credit 并在
hub_due里记欠款;收款方 hub 凭收据创造 credit。两侧都把这次变动 写进各自的发放链。引入的信任要说明白:B 给自己用户入账,是因为 A 说它扣了自己用户的钱。 收据让这个说法可追责,但不能让它变真 —— 所以 peer 是白名单,欠款是记着而不是抹平的。 运营面:anet-hub -due列出对外负债,anet-hub -clear <peer-aid> -amount <n> -payee <aid> -peer-endpoint <url>签一份 清偿声明投给对端。减记发生在投递之后,对端拒收的清偿仍然是欠着的。 - NAT 后面的节点不能用 hub 网关卖东西,只能走中继委派路径。
- 凭证的一次性由卖方把关。不记的卖方等于同意"一份钱两份活"。