AI Agent 调 API 有一个根问题:Web2 的认证方式(API Key、OAuth、session)全部假设背后是一个人,但 Agent 是无状态的——10 个副本同时调同一个 API,谁是谁根本分不清。
另一个问题是支付颗粒度。按月的订阅制对 Agent 不合理:一个批量扫描 Agent 可能一分钟调 1000 次,但每次只消耗 0.0001 USDC 的计算资源。按次计费?传统 Stripe 一笔交易的手续费就 30 美分 + 2.9%。
Solana 的微交易(不到 0.000001 SOL/笔)和 x402 协议(HTTP 402 Payment Required)的组合给出了一个新答案。
本文记录 StatePass 项目从零到部署的全过程:合约编写、RateConfig 链上定价、x402 Server 集成、测试、部署到 devnet、踩坑与修复。
问题拆解
要把「API 调用 = 微支付」这件事落地,需要三层能力:
第一层:链上身份。一个 AI Agent 需要持有一个不可转让的链上资产,证明「我是谁」。
第二层:动态定价。不同身份等级享受不同费率——等级越高的 Agent(已证明信誉/能力),单次调用越便宜。
第三层:无摩擦支付。不需要预充值、不需要 API Key、不需要浏览器的 OAuth 弹窗。一次 HTTP 请求,顺便把钱付了。
为什么选 Token-2022
Solana 有两个 NFT 标准:
| 标准 |
说明 |
| Metaplex(旧标准) |
metadata 存在单独账户,合约改 metadata 要走 mpl-token-metadata 程序的 CPI 调用 |
| Token-2022(新标准) |
Metadata Pointer 扩展直接把 metadata 存在 mint 账户内部 |
选 Token-2022 的原因是:
| 编号 |
原因 |
说明 |
| 1 |
元数据与 mint 同寿命 |
URI 失效时链上字段还在 |
| 2 |
合约直接写 metadata |
不用调外部程序的 CPI(节省一次跨程序调用) |
| 3 |
getTokenMetadata() 方法官方支持 |
server 端一行代码读等级 |
第一步:合约设计
先看最终的数据结构。RateConfig 是核心定价表,存在 PDA 里:
// programs/state-pass/src/state.rs
#[account]
pub struct RateConfig {
pub authority: Pubkey, // 32 字节 — 管理员地址
pub level1_rate: u64, // 8 字节 — Level 1 单次价格(最小单位)
pub level2_rate: u64, // 8 字节
pub level3_rate: u64, // 8 字节
pub max_level: u8, // 1 字节 — 最大等级
}
// 总大小:8(Anchor discriminator) + 32 + 8 + 8 + 8 + 1 = 65 字节
其中 level1_rate、level2_rate、level3_rate 的单位是 USDC 的最小单位(1 USDC = 1,000,000 最小单位),所以 100 = 0.0001 USDC。
初始化这个 PDA 的指令很直接:
// programs/state-pass/src/instructions/rate_config.rs
pub fn initialize_rate_config(
ctx: Context<InitializeRateConfig>,
level1_rate: u64,
level2_rate: u64,
level3_rate: u64,
max_level: u8,
) -> Result<()> {
let config = &mut ctx.accounts.rate_config;
config.authority = ctx.accounts.signer.key();
config.level1_rate = level1_rate;
config.level2_rate = level2_rate;
config.level3_rate = level3_rate;
config.max_level = max_level;
Ok(())
}
dNFT 的等级存在 Token-2022 的 additional_metadata 里,升级时合约直接写:
// programs/state-pass/src/instructions/update_pass_level.rs
token_metadata::update_field(
&token_metadata_program,
&mint,
&update_authority,
&metadata_authority,
"level".to_string(),
new_level.to_string(),
)?;
升级完成后链上的 metadata 变了,server 端下次读到的是新等级。
第二步:本地开发与测试
启动本地验证器
$ solana-test-validator --reset
等 10 秒左右节点启动完成。
$ solana config set --url localhost
Config File: /Users/qiaopengjun/.config/solana/cli/config.yml
RPC URL: http://localhost:8899
$ solana balance
500000000 SOL
本地验证器默认给了 id.json 这个密钥对 5 亿 SOL,足够开发测试用。
部署合约
$ solana program deploy \
--url http://localhost:8899 \
--use-rpc \
--program-id target/deploy/state_pass-keypair.json \
target/deploy/state_pass.so
Program Id: 21xpRqRTFk7N7ybdPA2RyTmRqQB9FH4Xerty9jeTU1Dx
Signature: 4xbKmuJ4GnB48Ps7ksaskHPWQpEYYsbjw3rNb7N7CuhAQkhZb...
--use-rpc 这个参数后面会讲到为什么必须加。
跑测试
$ anchor test --skip-local-validator --skip-build
state-pass
✔ Is initialized!
✔ 1. Initializes the Program PDA (Initialize)
✔ 2. Mints the initial StatePass NFT (Level 1)
✔ 3. Backend updates the StatePass to Level 2
✔ 4. Initializes the global RateConfig
✔ 5. Updates a rate via setRate
6 passing (7s)
6 个测试全部通过。测试里铸造了一个 Level 1 的 StatePass dNFT,再升级到 Level 2,然后初始化 RateConfig 并修改了一个费率。注意这里 setRate 的 authority 检查现在在 Anchor 账本结构体层面就做了,不需要进 handler(审计部分会讲)。
测试跑出来的关键地址:
| 项目 |
值 |
| Mint Public Key |
6hhZMetXyYp2DcuQ1p8NP2h4tEw7D6mDNGgvS4MLw9eq |
| NFT Authority PDA |
9kSHfYu9z2C7aadu6cG6UYG2hp28MMv7mwcJW7DsJjzG |
| RateConfig PDA |
3GoWWZk1xbMqzKwTn5DKsgdDXjTo7g7RJbGgvkec8apU |
验证链上数据
用 solana account 直接读 RateConfig PDA 的原始数据:
$ solana account 3GoWWZk1xbMqzKwTn5DKsgdDXjTo7g7RJbGgvkec8apU
Public Key: 3GoWWZk1xbMqzKwTn5DKsgdDXjTo7g7RJbGgvkec8apU
Balance: 0.00134328 SOL
Owner: 21xpRqRTFk7N7ybdPA2RyTmRqQB9FH4Xerty9jeTU1Dx
Length: 65 (0x41) bytes
0000: 65 c2 37 a1 af d4 12 43 4f 8e 79 c1 00 3c 7a a7
0010: f9 3a 5c f3 71 05 d0 c9 cd 65 d9 f5 0b 56 1e b4
0020: 45 e6 6e 33 7f 08 cb a6 64 00 00 00 00 00 00 00
0030: fa 00 00 00 00 00 00 00 2c 01 00 00 00 00 00 00
0040: 03
解释这个 65 字节的二进制布局:
65 c2 37 a1 af d4 12 43 — Anchor 自动加的 8 字节 discriminator(sha256("account:RateConfig")[:8]),合约用它来区分账户类型。
接下来 32 字节是 authority(Pubkey),最后:
64 00 00 00 00 00 00 00 — level1_rate = 100(0x64 = 100)
fa 00 00 00 00 00 00 00 — level2_rate = 250(0xfa = 250)
2c 01 00 00 00 00 00 00 — level3_rate = 300(0x12c = 300)
03 — max_level = 3
小端序(LE),所以 hex 64 = decimal 100,fa = 250,2c 01 反转成 01 2c = 300。
定价方案:Level 1 每次 100 最小单位(0.0001 USDC),Level 2 每次 250(0.00025 USDC),Level 3 每次 300(0.0003 USDC)。
这里有一个反直觉的设计:等级越高反而越贵?是的,这个场景假设是——高等级 Agent 访问的是高价值 API(比如链上实时仓位分析),单次调用价值高,但频次低;低等级 Agent 批量扫数据,频次高但单次价值低。后续可以改成任意幂函数。
第三步:Server 端实现
Server 不跑 Anchor 库,全部用原生 @solana/web3.js 操作链上数据。
反序列化 RateConfig
从链上读到的 65 字节 buffer 需要自己解析——因为没用 Anchor IDL,就手动按偏移量读了:
// server/src/index.ts
const RATE_CONFIG_ACCOUNT_SIZE = 8 + 32 + 8 + 8 + 8 + 1; // 65
function deserializeRateConfig(data: Buffer): RateConfigData {
let offset = 8; // 跳过 discriminator
const authority = new PublicKey(data.subarray(offset, offset + 32));
offset += 32;
const level1Rate = Number(data.readBigUInt64LE(offset));
offset += 8;
const level2Rate = Number(data.readBigUInt64LE(offset));
offset += 8;
const level3Rate = Number(data.readBigUInt64LE(offset));
offset += 8;
const maxLevel = data.readUint8(offset);
return { authority, level1Rate, level2Rate, level3Rate, maxLevel };
}
readBigUInt64LE(offset) 读 8 字节小端 u64,再用 Number() 转成 JS number。因为 USDC 最小单位最大不会超过 Number.MAX_SAFE_INTEGER(9e15),所以安全。
输出定价到 HTTP 响应
async function getRateForLevel(level: number): Promise<number> {
const accountInfo = await connection.getAccountInfo(rateConfigPDA);
if (accountInfo && accountInfo.data.length >= RATE_CONFIG_ACCOUNT_SIZE) {
const config = deserializeRateConfig(accountInfo.data);
if (level > config.maxLevel) level = config.maxLevel;
switch (level) {
case 1: return config.level1Rate;
case 2: return config.level2Rate;
case 3: return config.level3Rate;
}
}
// fallback 硬编码
switch (level) {
case 1: return 100_000;
case 2: return 50_000;
case 3: return 10_000;
default: return 100_000;
}
}
如果链上读取失败(比如 PDA 还没初始化),fallback 是反向的——等级越高越便宜——这和我们的实际定价设计相反。上线前一定要初始化好 RateConfig。实测里已经初始化了,所以用 actual 值。
x402 支付验证
收到带 X-Payment header 的请求时,server 做四步验证:
// 1. 解析 base64 编码的签名交易
const paymentData = JSON.parse(
Buffer.from(xPaymentHeader, "base64").toString("utf-8"),
);
const txBuffer = Buffer.from(
paymentData.payload.serializedTransaction,
"base64",
);
const tx = Transaction.from(txBuffer);
// 2. 检查交易里是否有一笔 USDC 转账到指定收款账户
for (const ix of tx.instructions) {
if (ix.programId.equals(TOKEN_PROGRAM_ID)) {
if (ix.data.length >= 9 && ix.data[0] === 3) {
// opcode 3 = Transfer (SPL Token)
transferAmount = Number(ix.data.readBigUInt64LE(1));
const destAccount = ix.keys[1]?.pubkey;
if (destAccount.equals(RECIPIENT_TOKEN_ACCOUNT) && transferAmount >= price) {
validTransfer = true;
break;
}
}
}
}
转账指令的 opcode 是 3,前 1 字节是 opcode,接着 8 字节是金额。收款地址在 keys[1](Transfer 的 account 索引:0=source, 1=dest, 2=owner)。
验证通过后,server 模拟 + 提交交易:
// 3. 在链上模拟执行(省 gas)
const sim = await connection.simulateTransaction(tx);
if (sim.value.err) {
return res.status(402).json({ error: "simulation failed", details: sim.value.err });
}
// 4. 提交到链
const signature = await connection.sendRawTransaction(txBuffer, {
skipPreflight: true,
preflightCommitment: "confirmed",
});
await connection.confirmTransaction(signature, "confirmed");
skipPreflight: true 是因为签名已经在客户端做过,server 端不需要重复本地的 preflight 检查。
第四步:完整交互验证
启动 server 并连接到本地验证器:
$ cd server
$ RPC_URL=http://localhost:8899 npx tsx src/index.ts
检查 Server 状态
$ curl -s http://localhost:3001/status | python3 -m json.tool
{
"programId": "21xpRqRTFk7N7ybdPA2RyTmRqQB9FH4Xerty9jeTU1Dx",
"rateConfigPDA": "3GoWWZk1xbMqzKwTn5DKsgdDXjTo7g7RJbGgvkec8apU",
"rateConfig": {
"authority": "6MZDRo5v8K2NfdohdD76QNpSgk3GH3Aup53BeMaRAEpd",
"level1Rate": 100,
"level2Rate": 250,
"level3Rate": 300,
"maxLevel": 3
},
"rpcUrl": "http://localhost:8899"
}
报价(402 Payment Required)
$ curl -s "http://localhost:3001/premium?mint=6hhZMetXyYp2DcuQ1p8NP2h4tEw7D6mDNGgvS4MLw9eq"
{
"payment": {
"recipientWallet": "seFkxFkXEY9JGEpCyPfCWTuPZG9WK6ucf95zvKCfsRX",
"amount": 250,
"amountUSDC": 0.00025,
"level": 2,
"message": "Send 0.00025 USDC (level 2 rate) to access premium content"
}
}
Server 干了三件事:
| 步骤 |
说明 |
| 1 |
读 dNFT 的 metadata——查到 level 字段等于 "2" |
| 2 |
读 RateConfig PDA——查到 level2Rate = 250 |
| 3 |
返回 HTTP 402 状态码 + 支付参数 |
客户端看到这笔报价,如果 USDC 余额够,就签一笔转账交易但不提交,序列化后放进 X-Payment header 再发一次。server 验证后替客户端提交链上,确认后返回 200 OK 和 premium 内容。
完整端到端演示
CLI demo 脚本完整展示了从钱包信息到支付解锁的每一步:
$ npx tsx cli-demo.ts 6hhZMetXyYp2DcuQ1p8NP2h4tEw7D6mDNGgvS4MLw9eq
━━━ StatePass × x402 CLI Demo ━━━
📦 1. Wallet Info
─────────────────────────────────
Address: 6MZDRo5v8K2NfdohdD76QNpSgk3GH3Aup53BeMaRAEpd
SOL: 0.8379376 SOL
🎫 2. StatePass dNFT Info
─────────────────────────────────
Mint: 6hhZMetXyYp2DcuQ1p8NP2h4tEw7D6mDNGgvS4MLw9eq
Level: 2
Name: StatePass
⚙️ 3. RateConfig (On-Chain)
─────────────────────────────────
Level 1: 100 (0.0001 USDC)
Level 2: 250 (0.00025 USDC)
Level 3: 300 (0.0003 USDC)
💸 4. x402 Quote
─────────────────────────────────
Level → 2
Price: 0.00025 USDC
Message: Send 0.00025 USDC to access premium content
💳 5. Pay & Unlock
─────────────────────────────────
✅ Payment verified! Transaction submitted.
TX: https://explorer.solana.com/tx/{signature}?cluster=devnet
第五步:部署到 devnet
开发完本地,部署到 devnet。这一步踩了三个坑。
坑 1:代理堵了 Solana CLI
本机有 Clash 代理(HTTP_PROXY=http://127.0.0.1:7890),Solana 的 HTTP client 走代理时 DNS 解析失败:
$ solana program deploy target/deploy/state_pass.so
Error: AccountNotFound: pubkey=21xpRqRTFk7N7ybdPA2RyTmRqQB9FH4Xerty9jeTU1Dx:
error sending request for url (https://api.devnet.solana.com/)
但同环境 curl 能正常访问。原因:solana CLI 的 reqwest 库在内网 DNS 解析走代理时被拦截。
解决:加 --use-rpc 参数,让所有通信走 HTTP 而非 TPU(直连 UDP 也过不了代理):
$ solana program deploy \
--url https://api.devnet.solana.com \
--use-rpc \
--program-id target/deploy/state_pass-keypair.json \
target/deploy/state_pass.so
Program Id: 21xpRqRTFk7N7ybdPA2RyTmRqQB9FH4Xerty9jeTU1Dx
Deploy success
坑 2:NftAuthority PDA 不存在
在 devnet 上初始化 RateConfig 时失败——InitializeRateConfig 指令的 account 列表里要求了一个 nft_authority PDA:
// 修改前的 account 结构
#[derive(Accounts)]
pub struct InitializeRateConfig<'info> {
pub signer: Signer<'info>,
pub nft_authority: Account<'info, NftAuthority>, // <- 这个 PDA 不存在
pub rate_config: Account<'info, RateConfig>,
pub system_program: Program<'info, System>,
}
问题是 NftAuthority 这个 PDA 只在 mint_nft 时才创建,但 RateConfig 和 NFT 铸造是独立的——不应该耦合。
检查 handler 代码,发现 handler 根本不读 nft_authority 参数:
pub fn handler_initialize_rate_config(...) -> Result<()> {
let config = &mut ctx.accounts.rate_config;
config.authority = ctx.accounts.signer.key();
// nft_authority 全程没用
...
}
解决:从 account 结构体里移除 nft_authority 字段:
#[derive(Accounts)]
pub struct InitializeRateConfig<'info> {
pub signer: Signer<'info>,
#[account(
init,
payer = signer,
space = 8 + std::mem::size_of::<RateConfig>(),
seeds = [RATE_CONFIG_SEED],
bump,
)]
pub rate_config: Account<'info, RateConfig>,
pub system_program: Program<'info, System>,
}
同样,SetRate 指令也改了。重建 + 重新部署:
$ anchor build
$ solana program deploy \
--url https://api.devnet.solana.com --use-rpc \
--program-id target/deploy/state_pass-keypair.json \
target/deploy/state_pass.so
坑 3:Node.js fetch 也过不了代理
初始化 RateConfig 要发一条交易到 devnet。但 devnet 初始化脚本用 Node.js 写的:
// init-devnet.ts
const response = await connection.sendTransaction(tx, [wallet]);
运行时报 fetch failed——Node.js 继承了 HTTP_PROXY 环境变量。
解决:用 Python solana-py 跑,execute_code 里的 subprocess 环境干净(不继承 shell 代理变量):
from solana.rpc.api import Client
from solana.transaction import Transaction
from solders.pubkey import Pubkey
from solders.keypair import Keypair
from solders.instruction import AccountMeta, Instruction
client = Client("https://api.devnet.solana.com")
# 构造 initialize_rate_config 指令
# ...
tx = Transaction()
tx.add(ix)
sig = client.send_transaction(tx, wallet)
print(f"✅ RateConfig initialized at {rate_config_pda}")
最后确认 RateConfig 已创建成功:
$ solana account 3GoWWZk1xbMqzKwTn5DKsgdDXjTo7g7RJbGgvkec8apU
# 65 字节数据,level1=100, level2=250, level3=300, max=3 ✅
审计与修复
项目写完后做了一轮全面审查,发现并修了 4 个问题。
问题 1:Web3.js 前端用了不存在的 API
前端 payAndUnlock() 里写了 solanaWeb3.Token.createTransferInstruction()——这个 API 在 @solana/web3.js v1 里不存在。它是 @solana/spl-token 包的,且导入方式不同。
现象:页面打开时报 TypeError: solanaWeb3.Token.createTransferInstruction is not a function。
修复:手动构造 SPL Token Transfer 指令的原始字节——opcode(1 字节 0x03)+ u64 金额(8 字节小端):
const transferData = Buffer.alloc(9);
transferData[0] = 3; // opcode: Transfer
transferData.writeBigUInt64LE(BigInt(amount), 1);
const transferIx = {
programId: solanaWeb3.TOKEN_PROGRAM_ID,
keys: [
{ pubkey: senderATA, isSigner: false, isWritable: true }, // source
{ pubkey: recipientATA, isSigner: false, isWritable: true }, // dest
{ pubkey: publicKey, isSigner: true, isWritable: false }, // owner
],
data: Array.from(transferData),
};
同时修复了 blockhash 读两次导致 lastValidBlockHeight 可能不匹配的 bug 和 findProgramAddress 的调用签名(await 版本返回数组,同步版本直接 destructure)。
问题 2:Server fallback 价格方向反了
getRateForLevel() 函数在链上 RateConfig PDA 读不到的时候走 fallback:
// 修复前
case 1: return 100_000; // 0.1 USDC — 比实际高 1000 倍
case 2: return 50_000; // 0.05 USDC — 等级越高越便宜,跟 config 反向
case 3: return 10_000; // 0.01 USDC
// 修复后
case 1: return 100; // 0.0001 USDC
case 2: return 250; // 0.00025 USDC
case 3: return 300; // 0.0003 USDC
如果上线前忘了初始化 RateConfig,之前会多收客户 1000 倍的钱。
问题 3:未验证支付者是否持有 dNFT
Server 的支付验证只检查了 USDC 转账金额和收款地址,没检查 tx.feePayer 是否是该 dNFT 的 owner。理论上可以用别人的高等级 NFT 拿低价报价,然后用自己钱包签名转账。
修复:新增 verifyNftOwnership() 函数,在验证 USDC 转账之前先查链上:
async function verifyNftOwnership(mint: PublicKey, feePayer: PublicKey): Promise<boolean> {
const tokenAccounts = await connection.getTokenAccountsByOwner(feePayer, { mint });
for (const { account } of tokenAccounts.value) {
if (account.data.length >= 165) {
const amount = Number(account.data.readBigUInt64LE(64));
if (amount > 0) return true;
}
}
return false;
}
Token Account 数据的偏移量 64 处是 amount 字段(u64),大于 0 意味着 feePayer 至少持有 1 枚。
问题 4:SetRate 缺少 Anchor 层面的权限约束
SetRate 的 handler 里确实做了 require! 检查,但 Anchor account 结构体上没有 restriction。最佳实践是在 #[account(...)] 上也声明:
#[derive(Accounts)]
pub struct SetRate<'info> {
#[account(
mut,
constraint = signer.key() == rate_config.authority @ StatePassError::UnauthorizedRateConfigUpdate
)]
pub signer: Signer<'info>,
...
}
这样 Anchor 在反序列化后就能在 CPI 验证阶段拦截,不需要进 handler。
其他改进
前端加了 USDC 余额显示 和 Devnet USDC Faucet 按钮——用户在浏览器里可以直接申请测试 USDC,不用打开终端敲 spl-token faucet。
总结
从构思到部署完成,这个项目覆盖了:
| 层级 |
内容 |
| 1 |
合约层:Token-2022 dNFT + RateConfig PDA 定价方案 |
| 2 |
Server 层:原生 solana/web3.js 读链上数据 + x402 支付验证 |
| 3 |
客户端:签名不提交模式(省 gas 费给 server 付) |
| 4 |
部署:devnet 上线,遇到代理/NftAuthority/Node.js 代理三个坑全部解决 |
最终定价方案:
| 等级 |
价格 |
| Level 1 |
100 最小单位 = 0.0001 USDC/次(批量扫描场景) |
| Level 2 |
250 最小单位 = 0.00025 USDC/次(常规调用) |
| Level 3 |
300 最小单位 = 0.0003 USDC/次(高价值 API) |
这套架构的核心价值不在代码量(全项目不到 2000 行),而在「API 调用权 = 链上资产 + 微支付」这个模式:没有 API Key 需要保护、没有配额需要管理、Agent 持有 dNFT 就能自动按等级支付。
下一步可以做 X/Twitter 上的内容绑定到 x402 API,或者接入 Eliza Agent 框架让 AI Agent 原生支持这条流程。
参考链接