找回密码
立即注册
搜索
发回帖 发新帖

6413

积分

0

好友

811

主题
发表于 17 小时前 | 查看: 4| 回复: 0

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 原生支持这条流程。

参考链接

编号 内容
1 项目源码 https://github.com/qiaopengjun5162/state-pass
2 x402 协议 https://x402.org
3 Solana x402 Hackathon https://colosseum.org/hackathons/x402
4 Token-2022 标准 https://spl.solana.com/token-2022
5 合约 Devnet Explorer https://solana.fm/address/21xpRqRTFk7N7ybdPA2RyTmRqQB9FH4Xerty9jeTU1Dx?cluster=devnet-solana



上一篇:Aleo 执行 API 三模式实战:local 证明、离线运行与 ZK 验证
下一篇:让AI讲原理不再是一坨文字:这个Agent Skill直接渲染成一页HTML
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-10-8 19:53 , Processed in 0.115564 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表