@actora/sdk
v0.20.5
Published
Actora — Decentralized AI Agent SDK with ReAct AgentLoop + Multi-Tenant LLM Providers
Maintainers
Readme
@actora/sdk v0.20.1
去中心化 AI Agent 平台 SDK —— 链上铸造 / 订阅 / 支付(多链)+ 托管对话(REST/SSE)+ 多链中心化市场,面向 B 端(合作伙伴)接入。
Agent = Prompt + Skills + MCP一个 Agent 就是一枚链上 NFT:Prompt(系统提示词)+ Skills(可执行工具)+ MCP(远程工具连接),加密存放在 IPFS,通过 AgentRegistry 铸造、SubscriptionManager 订阅、ConversationClient 托管对话。B 端只需一个 actora_... API Key 即可接入,无需自建钱包基础设施。
1. 支持的多链市场
| 链 | chainId | IdentityRegistry | SubscriptionManager | 原生代币 | RPC |
|----|---------|------------------|---------------------|----------|-----|
| BSC | 56 | 0xeF96D883e1082E3e102d925c8190A13EEC1ffF33 | 0x6f18Aa693e5062Ef98bBA5A865F225f2e652DF6b | BNB | https://bsc-rpc.publicnode.com |
| Ethereum | 1 | 0xb38BC382aA438F8B4f0BFA902041330Bc14DD02e | 0x7b4002a416E9c0cA68d7dEDc6c734CFab4D396FE | ETH | https://ethereum-rpc.publicnode.com |
| Base | 8453 | 0x37f3919D64d648ddF70747e159fd9A1c6890CA3b | 0x0f129e8C31Dd7642cd7e852fBf682fCb75104b29 | ETH | https://base-rpc.publicnode.com |
| Sepolia 测试网 | 11155111 | 0xe94ad380d3F8d08a7590eda0C84f354a93F96e5F | 0xC15fE80b9d800abb72121F353a6ae6d6E9077E63 | ETH | 自备(Alchemy/Infura 等) |
⚠️ 地址来源:上表全部取自
KNOWN_CHAINS[chainId](src/config/config.ts)。生产环境请优先从KNOWN_CHAINS[chainId].contracts.identityRegistry / subscriptionManager读取,避免硬编码——合约地址可能随部署修正而更新。
各链完整合约地址(6 个核心合约)
3 条对外市场链(BSC / Ethereum / Base)均已部署全套 6 个合约,可直接用于链上铸造 / 订阅 / A2A。下表与 KNOWN_CHAINS[chainId].contracts 一一对应:
| 合约 | BSC (56) | Ethereum (1) | Base (8453) |
|------|----------|--------------|-------------|
| IdentityRegistry | 0xeF96D883e1082E3e102d925c8190A13EEC1ffF33 | 0xb38BC382aA438F8B4f0BFA902041330Bc14DD02e | 0x37f3919D64d648ddF70747e159fd9A1c6890CA3b |
| SubscriptionManager | 0x6f18Aa693e5062Ef98bBA5A865F225f2e652DF6b | 0x7b4002a416E9c0cA68d7dEDc6c734CFab4D396FE | 0x0f129e8C31Dd7642cd7e852fBf682fCb75104b29 |
| A2AProtocolRegistry | 0x842F0B9E85f5aBd9752B5ea20Cc888b94dBb5E16 | 0x09f6C8f9AE6CEf12C4a078c91aeb2EcCb7CbD012 | 0x2172145F2A8f7A66019A4F4048Af46844243607E |
| ReputationRegistry | 0xBaDB0F3956252DE6e31aE749245E8bA6Fda288ee | 0x72C1236bc425b1A6129b76400aE7b26c1fbFAeFE | 0x3f30d87BDFFa66f0e4664F1cE65a0e3a91FB4A9e |
| ConfigurationRegistry | 0xd9B984cF591dc1C3663991e7D75c44C2bDE1a9BB | 0x2Bf6962301164A301e4fEC9036aB97edcBcE1bCa | 0x5EF8c35738311B971A64de64487E1E304Ae53993 |
| MultiEndpointRegistry | 0x80Bc36634693d7935109f5cdbc95E64a9c1bdA6a | 0xE93fbED3Df2a5D357856f2f2712B3c1Ebb8d026f | 0xB8065829dBb450B8fC7375D910521EA641EC6Ee5 |
部署起始块(网关索引
*_SYNC_FROM_BLOCK用):BSC SM122277574、Ethereum SM25992521、Base SM51401326。 程序化访问请直接读KNOWN_CHAINS[chainId].contracts(六个字段同名),避免复制粘贴出错。
白名单稳定币(USDT / USDC)
BSC / Ethereum / Base 三链的 SubscriptionManager 已登记 USDT / USDC 白名单,计划可用稳定币定价(createPlan 的 payToken 传下表地址):
| 链 | 代币 | 地址 | 精度 |
|----|------|------|------|
| BSC | USDT | 0x55d398326f99059fF775485246999027B3197955 | 18 |
| BSC | USDC | 0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d | 18 |
| Ethereum | USDT | 0xdAC17F958D2ee523a2206206994597C13D831ec7 | 6 |
| Ethereum | USDC | 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 | 6 |
| Base | USDT | 0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2 | 6 |
| Base | USDC | 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 | 6 |
ERC20 计划要点:
price用代币最小单位(注意 BSC 上的 USDT/USDC 是 18 位,ETH/Base 上是 6 位,不要统一按 1e6 换算);- 订阅方必须先
approve(SubscriptionManager, price),再由subscribe(planId)完成订阅;SDK 的SubscriptionManager.subscribe会自动处理 approve(见 3.e); - ERC20 计划的订阅交易 不要带原生币(合约要求
msg.value == 0),原生币只用于付 gas; - 非白名单 ERC20 会被合约 revert,新增币种需由合约 owner 调
setTokenWhitelist(token, true)。
import { parseUnits } from 'viem'
// Base 上用 USDC 定价:9.9 USDC / 月
const { planId } = await sub.createPlan({
agentId,
price: parseUnits('9.9', 6), // Base USDC 精度 6
period: 'month',
payToken: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
})链选择指引
- B 端优先 Base / Ethereum:Gas 便宜或生态成熟,原生币即 ETH;
- 测试联调用 Sepolia:无真实资金风险;
- BSC 为对外默认链:平台默认支付/验证链(
SubscriptionPayments的chain参数默认'bsc'); - 每链的
SubscriptionManager独立托管各自链上的订阅计划与资金,订阅与铸造必须在同一链上完成(agentId是链内 ID)。
RPC 注意事项
eth_call/ 事件监听:publicnode 即可(上面 BSC/ETH/Base 的 RPC);getTransactionReceipt(archive 查询)在 publicnode 会 403,需换带 archive 的 RPC:- BSC:
https://1rpc.io/bnb - Base:
https://mainnet.base.org
- BSC:
2. 安装
npm install @actora/sdk
# 或锁定版本
npm install @actora/[email protected]包内含
examples/(B 端可运行样例)与UPGRADE.md(逐版本迁移指南),安装后可从node_modules/@actora/sdk/查看。
Peer Dependencies(均为 optional)
| 包 | 版本 | 用途 |
|----|------|------|
| react | ^18 或 ^19 | React hooks(@actora/sdk/react) |
| wagmi | ^2.0 | React hooks 内部 |
| @tanstack/react-query | ^5.0 | React hooks 内部 |
viem(^2.0)不是 peer dependency:仅链上模块(AgentRegistry/SubscriptionManager/A2AProtocol等)需要 viem 的PublicClient/WalletClient,使用时请自行安装;纯 REST 客户端(ConversationClient/CentralAgentClient/BillingClient等)完全不需要。仓库内可运行示例见examples/(viem 已在 devDependencies 中)。
3. 快速开始 —— B 端典型接入流程
完整可运行脚本见 examples/mint-and-subscribe.mjs。下面按步骤说明。
a. 开户:注册 partner 租户,拿到 API Key
B 端接入的第一步是用钱包签名建一个 partner 租户,网关会签发一把长期有效的租户 API Key(actora_<hex>):
# 1) 取挑战
curl -X POST https://actora.codes/api/v1/auth/challenge \
-H 'Content-Type: application/json' -d '{"address":"0xYourWallet"}'
# 2) 用钱包对 challenge 做 EIP-191 签名,再调 verify(intent=partner)
curl -X POST https://actora.codes/api/v1/auth/verify \
-H 'Content-Type: application/json' \
-d '{"address":"0xYourWallet","signature":"0x...","intent":"partner"}'
# → { "token": "<jwt>", "tenant": {...}, "api_key": "actora_..." } ← api_key 仅返回一次- partner 租户初始无套餐:
quota_daily=0、rate_limit_rpm=5、max_concurrent=1,平台 LLM 不可用;可先 BYOK,或购买平台档位(见 §8)。 - 一个钱包只对应一个租户;已有租户再传
intent='partner'不会改变其 kind。 api_key丢失只能轮换(POST /api/v1/tenant/rotate-key),不能找回。- 拿到 Key 后即可跳过后面的链上部分(b–d),直接使用 e/f 的订阅支付与托管对话能力。
b. 连接钱包(viem)
import { createPublicClient, createWalletClient, http } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { KNOWN_CHAINS } from '@actora/sdk'
const chainId = 8453 // Base —— 用 KNOWN_CHAINS 选链
const cfg = KNOWN_CHAINS[chainId]
const chain = {
id: cfg.chainId,
name: 'Base',
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
}
const account = privateKeyToAccount(`0x${process.env.PRIVATE_KEY}`) // 服务端私钥;浏览器用 window.ethereum 注入
const publicClient = createPublicClient({ chain, transport: http(cfg.rpcUrl) })
const walletClient = createWalletClient({ account, chain, transport: http(cfg.rpcUrl) })c. 铸造 Agent(AgentRegistry)
import { AgentRegistry } from '@actora/sdk'
const registry = new AgentRegistry({
contractAddress: cfg.contracts.identityRegistry,
publicClient,
walletClient,
})
// 简单铸造:仅 tokenURI
const { agentId, txHash } = await registry.registerSimple('ipfs://Qm...')
// 完整铸造:tokenURI + 链上元数据(加密载荷 CID / ECIES 公钥等)
const { agentId } = await registry.register('ipfs://Qm...', [
{ key: 'encryptedPayloadCid', value: encryptedCid },
{ key: 'eciesEncryptedKey', value: eciesEncryptedKeyHex },
])d. 创建订阅计划(SubscriptionManager)
import { SubscriptionManager } from '@actora/sdk'
import { parseEther, parseUnits } from 'viem' // 价格单位工具来自 viem
const sub = new SubscriptionManager({
contractAddress: cfg.contracts.subscriptionManager,
publicClient,
walletClient,
})
// 原生币计划(BNB / ETH)
const { planId, txHash } = await sub.createPlan({
agentId,
price: parseEther('0.01'), // 原生币 wei
period: 'month', // 'day' | 'week' | 'month' | 'year'
// trialDays: 3, // 0–30,默认 0
})
// 稳定币计划(USDT / USDC)—— payToken 传白名单地址,price 用代币最小单位
const usdcPlan = await sub.createPlan({
agentId,
price: parseUnits('9.9', 6), // Base USDC 精度 6
period: 'month',
payToken: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
})e. 订阅(SubscriptionManager.subscribe 或 SubscriptionPayments.pay)
链上原生币订阅(自动按 plan.price 支付;ERC20 计划自动 approve):
const result = await sub.subscribe(planId)
// → { subscriptionId, txHash, subscriber, agentId, expiresAt }统一支付入口(三条支付轨道,B 端常用):
import { SubscriptionPayments } from '@actora/sdk'
// 轨道 1:chain —— 走链上 SubscriptionManager
const payments = new SubscriptionPayments({
subscriptionManager: sub,
chain: 'base', // 验证链
})
const res = await payments.pay({ method: 'chain', planId, agentId })
// → { method: 'chain', subscriptionId, txHash }
// 轨道 2:fiat —— Stripe 卡支付,无需钱包(需 gatewayUrl)
const payments2 = new SubscriptionPayments({ gatewayUrl: 'https://actora.codes' })
const { sessionUrl } = await payments2.pay({
method: 'fiat',
planId,
agentId,
subscriber: '0x...', // 买家钱包
successUrl: 'https://your-app.com/success',
cancelUrl: 'https://your-app.com/cancel',
})
// 轨道 3:x402 —— 原生币按期支付(自动代付需 walletClient + subscriptionManager 读价格;
// 也可自己先发送原生币到平台 payTo 后传 txHash)
const payments3 = new SubscriptionPayments({
gatewayUrl: 'https://actora.codes',
subscriptionManager: sub, // 读取计划价格(priceWei)
walletClient, // 自动把原生币转到平台 payTo 钱包
chain: 'base',
})
const res3 = await payments3.pay({ method: 'x402', planId, agentId, subscriber: '0x...', period: 'month' })
// → { method: 'x402', subscriptionId, txHash, creditedWei }f. 与 Agent 对话(ConversationClient)
B 端只需 tenant API Key(actora_...),无需钱包:
import { ConversationClient } from '@actora/sdk'
const conv = new ConversationClient({
gatewayUrl: 'https://actora.codes',
apiKey: 'actora_your_tenant_key', // X-Api-Key;与 accessToken(JWT) 二选一
endUserId: 'user-123', // X-End-User-Id:租户内最终用户的记忆隔离维度
// llmApiKey: 'sk-...', // BYOK:用自己的 LLM Key,不计入平台配额
// llmEndpoint: 'https://api.deepseek.com/v1',
// llmModel: 'deepseek-chat',
})
// 单轮对话(内部就是 stream 聚合)
const res = await conv.chat({ agentId, message: '帮我分析这份合约' })
console.log(res.text)
// 流式(SSE,逐事件)
for await (const ev of conv.stream({ agentId, message: '实时输出' })) {
if (ev.type === 'text') process.stdout.write(ev.content ?? '')
if (ev.type === 'done') console.log('\n[done]', ev.usage)
}鉴权 / 隔离语义:
X-Api-Key: actora_...—— 租户级鉴权(注册后签发);Authorization: Bearer <jwt>—— 钱包签名登录的 JWT,与 apiKey 二选一;X-End-User-Id—— 租户内按最终用户隔离记忆;若传0x<wallet>,网关会额外做订阅代理鉴权(该钱包的订阅/所有权决定访问权);- BYOK:
llmApiKey/llmEndpoint/llmModel三个头,运行时用自己的 Key(llmSource: 'byok'不计费)。
4. 多链指南
如何切链
所有链上模块(AgentRegistry / SubscriptionManager / A2AProtocol)都接收 { contractAddress, publicClient, walletClient }——换链 = 换一组配置:
function makeChainClients(chainId: number) {
const cfg = KNOWN_CHAINS[chainId]
if (!cfg) throw new Error(`unsupported chain ${chainId}`)
const publicClient = createPublicClient({ chain: chainById(chainId), transport: http(cfg.rpcUrl) })
const walletClient = createWalletClient({ account, chain: chainById(chainId), transport: http(cfg.rpcUrl) })
return { cfg, publicClient, walletClient }
}价格单位
- 原生币计划:
price为该链原生代币的 wei(BNB / ETH); - ERC20 计划:
price为代币最小单位 —— ETH/Base 的 USDT/USDC 是 6 位(1e6),BSC 的 USDT/USDC 是 18 位(1e18); - 跨链比较价格时统一用
getPlan(planId).price+plan.payToken判断计价币种; - 中心化 Agent 的价格以
priceWei字符串返回(见CentralPlan),避免 JS 精度丢失。
合约地址获取(不要硬编码)
import { KNOWN_CHAINS } from '@actora/sdk'
const base = KNOWN_CHAINS[8453]
const registryAddr = base.contracts.identityRegistry
const subManagerAddr = base.contracts.subscriptionManager托管对话:链由平台解析,调用方不需要声明(0.17.0 起)
链上模块(AgentRegistry / SubscriptionManager)是「哪条链的合约就读哪条链」,但托管对话(ConversationClient,
agentId 模式)相反:你只要给出 agentId,平台会自己判断该 Agent 在哪条链、以及你在哪条链上有权。
- 平台从
agents表列出该agentId存在的全部候选链(按平台内部固定的链优先序:内部测试链 → 中心化 → BSC → Ethereum → Base 排序),逐链判定你(X-End-User-Id或租户钱包)是否拥有 / 已订阅,取第一条放行的链; - 这条链会一路贯穿「访问判定 → 取 Agent 上下文 → 组装工具」,所以不会出现「按 A 链判权、按 B 链取到别人的同名 Agent」;
- 为什么必须这样:各链独立编号,同一个数字 ID 可同时在多条链存在(实测 BSC 与 Ethereum 都有
id=2, 且是两个不同的 Agent)。只看「优先序第一条」会取错,故按「你在哪条链有权」来锚定; - 该判定发生在平台侧,托管对话请求里不接受调用方指定链(
chat/stream/createTask均无需、 也无法传chain);只有只读的GET /api/v1/agents/:id?chain=可以显式指定链; - 历史行为(
0.16.0及以前):该路径只认内部测试链 / 中心化市场,Agent 发在 BSC / Ethereum / Base 时 详情接口直接 404、或判权失败 403 —— 已在0.17.0随平台侧修复。
实践建议:集成时不要猜链、也不要声明链,直接给出
agentId调POST /api/v1/agent/runs或POST /api/v1/sessions/:id/tasks即可;平台会按「你在哪条链有权」自动锚定。
5. 模块参考
5.1 AgentRegistry —— 链上铸造 / 查询
- 用途:封装 IdentityRegistry 合约,Agent NFT 的铸造与只读查询。
- 构造:
new AgentRegistry({ contractAddress, publicClient, walletClient }) - 关键方法:
| 方法 | 说明 |
|------|------|
| register(tokenURI, metadata[], valueWei?) | 铸造 + 链上元数据(key/value 对),返回 { agentId, txHash } |
| registerSimple(tokenURI?, valueWei?) | 仅 tokenURI 铸造,返回 { agentId, txHash } |
| getAgentsByOwner(owner) | 某地址拥有的全部 agentId |
| getCurrentAgentId() / totalAgents() | 当前计数 / 总铸造数 |
| agentExists(agentId) | 是否存在 |
| getAttributes(agentId) / tokenURI(agentId) | 读元数据键值对 / tokenURI |
| overwriteAttributes(agentId, attrs[]) | 整组覆盖链上属性(updateMetadata),返回 txHash |
| setAttributes(agentId, patch) | 增量更新链上属性:读回现有 → 合并 → 整组写回,返回 txHash |
| getAgentMetadata(agentId) | 结构化元数据(name/capabilities/encryptedPayloadCid 等) |
| getAllAgents(opts?) | 批量扫描(fromId/toId/activeOnly/capabilities/batchSize),返回 AgentSummary[] |
const { agentId } = await registry.registerSimple('ipfs://QmX...')
const owners = await registry.getAgentsByOwner(account.address)
const meta = await registry.getAgentMetadata(agentId) // { name, description, capabilities, encryptedPayloadCid, ... }
// 存量 Agent 补写公开 skills(只改 skills,其余属性自动保留)
await registry.setAttributes(agentId, {
skills: [{ name: 'consult', description: '向该专家发起咨询', inputSchema: { type: 'object' } }],
})
// 整组覆盖(只在需要「删掉某个属性键」时用;先读回原有属性,否则会被抹掉)
const current = await registry.getAttributes(agentId) // { name: '…', description: '…', … }
await registry.overwriteAttributes(agentId, [
...Object.entries(current).map(([key, value]) => ({ key, value })),
{ key: 'skills', value: '[]' },
])⚠️ 生产合约的
updateMetadata是整组覆盖语义(先清空全部属性键再写入传入数组),所以优先用setAttributes()(自动读回合并);overwriteAttributes()必须把原有name/description/category/pricing_type/ … 全部一起传,否则会被抹掉。辅助函数toAttributes(patch)可把{ key: value }(字符串 / 数字 / 布尔 / 数组 / 对象)规范化为链上属性数组。
5.2 SubscriptionManager —— 链上计划 / 订阅 / 退款
- 用途:封装 SubscriptionManager v2(托管 escrow、平台费、多币种)。
- 构造:
new SubscriptionManager({ contractAddress, publicClient, walletClient }) - 关键方法:
getPlatformFeeBps()、isTokenWhitelisted(token)、getPlan(planId)、createPlan(CreatePlanParams)、createPlanAndSubscribe(params)、subscribe(planId, opts?)、releaseFunds(subscriptionId)、cancel(subscriptionId)、hasActiveSubscription(subscriber, agentId)、getSubscription(subscriber, agentId)、getSubscriptionDetail(subscriptionId)、getUserSubscriptions(user) CreatePlanParams:{ agentId, price: bigint, period: 'day'|'week'|'month'|'year', payToken? = ZERO_ADDRESS, trialDays? = 0 }(trialDays 0–30)- 常量:
ZERO_ADDRESS(= 原生币)、SUBSCRIPTION_PERIODS
const { planId } = await sub.createPlan({ agentId, price: parseEther('0.005'), period: 'week' })
const ok = await sub.hasActiveSubscription(buyer, agentId) // 网关/服务端鉴权常用
const detail = await sub.getSubscriptionDetail(subscriptionId)
// status: 0=Inactive 1=Active 2=Expired 3=Cancelled(SDK 返回 pending/active/expired/cancelled)5.3 SubscriptionPayments —— 统一支付(chain / fiat / x402)
- 用途:三条支付轨道统一入口。
chain= 链上 SubscriptionManager;fiat= Stripe 卡;x402= 原生币按期支付(走网关统一端点)。 - 构造:
new SubscriptionPayments({ gatewayUrl?, accessToken?, subscriptionManager?, walletClient?, chain? })chain取值:'oxachain' | 'sepolia' | 'bsc' | 'ethereum' | 'base'(默认'bsc')
- 关键方法:
pay(PaySubscriptionInput)、hasAccess(agentId, subscriber)(跨轨道访问检查)、fetchX402Info()(x402 发现:price/payTo/network) PaySubscriptionInput字段(以源码为准):planId, agentId, method, subscriber?, valueWei?, approveTokenFirst?, amountCents?, currency?, successUrl?, cancelUrl?, txHash?, priceWei?, period?priceWei:中心化 Agent(无链上计划)时直接指定价格;amountCents省略时网关按链上计划自动计价。
const payments = new SubscriptionPayments({ gatewayUrl: 'https://actora.codes', chain: 'base' })
const hasAccess = await payments.hasAccess(agentId, '0x...') // true / false
const info = await payments.fetchX402Info() // { enabled, priceWei, payTo, network, chain }5.4 ConversationClient —— 托管对话(REST/SSE)
- 用途:托管对话服务客户端(
POST /api/v1/agent/runs,SSE 流式),支持会话、并行任务、A2A 委派、余额查询。 - 构造:
new ConversationClient({ gatewayUrl, apiKey?, accessToken?, endUserId?, llmApiKey?, llmEndpoint?, llmModel?, timeoutMs? }) - 关键方法:
- 对话:
chat(params)→{ text, toolCalls, clarification?, usage? };stream(params, { signal? })→ SSE 事件(text | tool_call | tool_result | thinking | done | error | clarification | onchain_approval_required) - 会话/任务:
createSession(params)、createTask(params)(立即返回status: 'queued',后台执行)、getTask(taskId)、listTasks(sessionId)、cancelTask(taskId)、getCapabilities()(parallelTasks开关,关闭时createTask返回 403PARALLEL_TASKS_DISABLED,应降级为chat()) - A2A/计费:
getA2ATaskResult(taskId)、retryA2ATask(taskId)、getSessionKeyInfo()、getBalance()
- 对话:
- 参数中的
endUserId传0x<wallet>会触发网关订阅代理鉴权。 - 多链(0.17.0 起):
chat()/stream()/createTask()不需要传链,也无法传链 —— 平台按 「你在这条agentId的哪条链上有权」自动解析并全链路沿用(详见 §4「托管对话:链由平台解析」)。 因此 BSC / Ethereum / Base 上的 Agent 直接可用,不会再 404。
const conv = new ConversationClient({ gatewayUrl, apiKey: 'actora_...', endUserId: '0x...' })
const session = await conv.createSession({ agentId })
const task = await conv.createTask({ sessionId: session.id, agentId, message: '批量处理' })
while (true) {
const t = await conv.getTask(task.id)
if (t.status === 'done' || t.status === 'error') break
await new Promise((r) => setTimeout(r, 1000))
}5.5 CentralAgentClient —— 中心化市场(C-6)
- 用途:中心化 Agent(
source='central',负 ID,不上链)的公开查询 + 鉴权管理。订阅计划存在网关 DB(central_plans),付费复用 x402 通道(SubscriptionPayments.pay({ priceWei, period }))。 - 构造:
new CentralAgentClient({ baseUrl, accessToken?, apiKey? }) - 关键方法:
list(filters?)、get(agentId)、checkSubscription(subscriber, agentId)、mySubscriptions(subscriber)、create(input)、listMine()、update(agentId, input)、activate(agentId)/deactivate(agentId)、createPlan(agentId, input)、deactivatePlan(agentId, planId) CreateCentralAgentInput:{ name, description?, tags?, capabilities?, skills?, category? }CreateCentralPlanInput:{ priceWei: string, period?: 'day'|'week'|'month'|'year' }(priceWei 为十进制字符串)
const central = new CentralAgentClient({ baseUrl: 'https://actora.codes', apiKey: 'actora_...' })
const { agents, total } = await central.list({ activeOnly: true, page: 1, pageSize: 20 })
const { agent } = await central.create({ name: '行情分析助手', description: '...', capabilities: ['market-data'] })
const { plan } = await central.createPlan(agent.id, { priceWei: '10000000000000000', period: 'month' })5.6 BillingClient / TenantPlanPayments / AgentWalletConfig / A2AClient / PeriodClient
| 类 | 用途 | 构造 | 关键方法 |
|----|------|------|----------|
| BillingClient | B 端余额预检(x402 台账,委托前判断余额是否够按次付费) | { gatewayUrl, apiKey?, accessToken?, endUserId? } | getBalance({ endUserId? }) → { balance, balanceWei, currency, subject, payTo?, priceWei? }(currency 为该链原生币符号) |
| TenantPlanPayments | 购买平台订阅档位(purpose='tenant-plan',绑定租户配额) | { gatewayUrl, accessToken?, chain? } | buy({ tenantPlanId, subscriber, method, txHash?, successUrl?, cancelUrl? }) |
| AgentWalletConfig | agent 自主 MPC 钱包管理(A2A 按次付费自动代付) | { baseUrl, adminKey? \| apiKey? } | bindWallet(...)、authorizePaymentSession(...)、status(agentId)、list()、unbind(agentId) |
| A2AClient | a2a-pay 支付意图两段式(create → pay → settle) | { baseUrl, accessToken? }(ClientOptions) | create({ payer, amountWei, payee?, chain?, metadata? })、settle({ paymentId, txHash, chain? }) |
| PeriodClient | 周期授权(P4):按周期扣款/读状态 | { baseUrl, accessToken? }(ClientOptions) | charge(authorizationId)、authorization(authorizationId) |
// BillingClient —— 委托前预检
const billing = new BillingClient({ gatewayUrl, apiKey: 'actora_...' })
const { balanceWei, priceWei } = await billing.getBalance()
if (priceWei && BigInt(balanceWei) < BigInt(priceWei)) {
// 提示充值:向 balance.payTo 转原生币
}5.7 A2AProtocol —— 链上 A2A(Agent Card / Task)
- 用途:封装 A2AProtocolRegistry:Agent Card(能力声明)、Skill 注册、Task 生命周期。
- 构造:
new A2AProtocol({ contractAddress, publicClient, walletClient }) - 关键方法:
createAgentCard(agentId, card)、getAgentCard(agentId)、createTask(agentId, taskType, input)、completeTask(taskId, output, status?)、getTask(taskId)、getUserTasks(user)、getAgentTasks(agentId) - 相关:
ConversationClient.getA2ATaskResult / retryA2ATask(网关侧 A2A 委派结果);A2AClient(a2a-pay 支付)。
5.8 IPFSUploader —— IPFS 上传
- 用途:Pinata(JWT)/ 自定义端点上传 JSON / 文件 / 加密载荷。
- 构造:
new IPFSUploader({ pinataJwt?, customEndpoint?, customApiKey?, gatewayUrl?, timeoutMs?, pinataGroupId?, namePrefix? }) - 关键方法:
uploadJSON(data, metadata?)、uploadFile(content, fileName?, mimeType?)、uploadEncryptedPayload(payload, agentName?)、uploadString(content, name?)、getUrl(cid)、isConfigured() - 上传结果
{ cid, url, raw }。
const uploader = new IPFSUploader({ pinataJwt: process.env.PINATA_JWT })
const { cid, url } = await uploader.uploadJSON({ name: 'my-agent', capabilities: ['web'] })5.9 MCPConnector —— MCP 远程工具
- 用途:最小 MCP 客户端(HTTP/SSE),用于 Closed Skill 的远程执行。
- 构造:
new MCPConnector({ url, transport?, authHeader?, timeoutMs?, subscriberAddress?, signature?, timestamp? })(或MCPConnector.fromAgent(mcp, opts?)) - 关键方法:
listTools()→MCPTool[]、callTool(name, args?)→MCPCallResult、listResources()、readResource(uri)
const mcp = new MCPConnector({ url: 'https://mcp.example.com', authHeader: 'Bearer xxx' })
const tools = await mcp.listTools()
const out = await mcp.callTool('fetch_price', { symbol: 'ETH' })5.10 AgentRunner + AgentLoop —— 本地运行 Agent
AgentRunner:加载 + 解密 Agent(订阅校验 → IPFS 拉取 → 解包 AES Key → AES-256-GCM 解包 prompt/skills)。构造new AgentRunner({ reader, wallet, ipfsFetcher?, ipfsGateways?, unpack?, aesKeyHex? }),reader实现OnChainReader(getTokenURI/getAttributes/hasActiveSubscription),wallet实现WalletSigner(signMessage/getAddress/deriveEncryptionKey?/getPrivateKey?)。核心方法useAgent(agentId)→{ prompt, skills, mcp, subscriptionExpiry }。密钥来源优先级:config.unpack→config.aesKeyHex→wallet.deriveEncryptionKey()→wallet.getPrivateKey()(浏览器钱包见 §5.12)。AgentLoop:ReAct 风格循环(Think → Tool Call → Execute → …)。构造new AgentLoop({ ctx, llmProvider, maxIterations?, timeoutMs?, memory?, trace?, contextBudget?, onTextDelta?, onToolCall?, onToolResult?, onThinking?, onComplete?, onError? }),llmProvider实现LLMProvider.chatStream(request, signal?)。核心方法run(userMessage, history?)→AgentLoopResult、abort()。
const runner = new AgentRunner({ reader, wallet }) // reader/wallet 由 viem/wagmi 实现
const ctx = await runner.useAgent(agentId) // 需当前钱包已订阅该 agent
const loop = new AgentLoop({
ctx,
llmProvider, // 任意 OpenAI 兼容 provider
maxIterations: 5,
onTextDelta: (d) => process.stdout.write(d),
})
const { finalText } = await loop.run('帮我做这件事')B 端通常不需要自建 Runner/Loop——托管对话(
ConversationClient)已覆盖;Runner/Loop 适合自托管、完全掌控加密与执行的场景。
5.11 加密核心(crypto)
aesEncrypt(plaintext, keyHex)/aesDecrypt(encryptedBase64, keyHex)—— AES-256-GCM(线格式base64(IV[12]||ct||tag[16]))eciesEncrypt(dataHex, publicKey)/eciesDecrypt(dataHex, privateKey)—— ECIES(secp256k1,与 eciesjs 兼容)generateKeyPair()→{ privateKey, publicKey };getPublicKey(privateKey)derivePrivateKeyFromSignature(signature, address, params?)/derivePrivateKeyFromIkm(ikmHex, address, params?)/deriveKeyPairFromWallet({ mode?, signMessage, signTypedData?, getPrfOutput?, address, … })—— 浏览器钱包密钥派生(v1personal_sign/ v2 EIP-712 / v3 WebAuthn PRF + HKDF-SHA256,见 §5.12);配套buildPayloadKeyTypedData(address, overrides?)与DEFAULT_EIP712_DOMAIN/DEFAULT_EIP712_TYPES/DEFAULT_EIP712_PRIMARY_TYPE/DEFAULT_DERIVATION_INFO_PREFIX_V2/DEFAULT_DERIVATION_INFO_PREFIX_V3/DEFAULT_PRF_SALT(v3 的 IKM 不再是签名,直接用derivePrivateKeyFromIkm喂 32 字节 PRF 输出)generateAesKey();高级封装:encryptPayload / decryptPayload / packAgentForPublish / publishAgent / unpackAgent- 发布绑定与自检:
publishAgent({ ..., verifyWithPrivateKey })(铸造前 dry-run 解包);PAYLOAD_KEY_MODE_ATTRIBUTE/readPayloadKeyMode(attrs)/isDerivationMode(v)/AgentRegistry.setPayloadKeyMode(agentId, mode);v3 另有PAYLOAD_KEY_CREDENTIAL_ATTRIBUTE/serializePayloadKeyCredential()/readPayloadKeyCredential(attrs)/AgentRegistry.setPayloadKeyCredential(agentId, credential)
import { generateKeyPair, aesEncrypt, aesDecrypt, eciesEncrypt, eciesDecrypt } from '@actora/sdk'
const { privateKey, publicKey } = generateKeyPair()
const enc = eciesEncrypt('my-aes-key-hex', publicKey)
const dec = eciesDecrypt(enc, privateKey) // 'my-aes-key-hex'
const c = aesEncrypt('hello', '00'.repeat(32))
aesDecrypt(c, '00'.repeat(32)) // 'hello'5.12 浏览器钱包(EIP-1193)—— 发布与运行
结论先讲:MetaMask / OKX / Rabby / Trust / Coinbase 等注入式钱包无法导出账户私钥, 所以
AgentRunner.useAgent()不能靠账户私钥做 ECIES 解密;而eth_getEncryptionPublicKey(EIP-1024)已废弃且仅 MetaMask 实现。浏览器端的可行路径有三条:从确定性签名派生(v1personal_sign/ v2 EIP-712)、 从 WebAuthn PRF 派生(v3,非签名、来源绑定)、或改用托管密钥(服务端生成密钥对)。
派生规则(冻结契约)
三种模式只有「IKM 从哪来」不同,HKDF 构造逐字节一致:
v1 mode: 'personal-sign-hkdf' IKM = personal_sign 签名(65 字节 r‖s‖v,要求 RFC 6979 确定性)
v2 mode: 'eip712-hkdf' IKM = eth_signTypedData_v4 签名(65 字节)
v3 mode: 'webauthn-prf-hkdf' IKM = WebAuthn PRF 输出(32 字节,非签名)
三者共用:
privateKey = HKDF-SHA256(IKM,
salt = "actora/expert-enc",
info = "<infoPrefix>/<地址小写>")[retry:i]
publicKey = 未压缩公钥(0x04 + 128 hex)→ 传给 publishAgent
infoPrefix = "agent-payload-encryption/v1" // v1
| "agent-payload-encryption/v2" // v2
| "agent-payload-encryption/v3" // v3- 账户私钥永不离开钱包;派生私钥只存在于内存,不落盘、不上传;解密时用同一来源重现同一 IKM 即可(v1/v2 再签一次,v3 再求一次 PRF);
- 冻结面:改动其中任一值,已发布的 payload 永久解不开——上线前冻结,文案按字节冻结(不要本地化、不要改空格):
- v1:
message/salt/infoPrefix/ 发布地址; - v2:
domain/types/primaryType/infoPrefix/ 发布地址(typed data 的message由地址派生,不含时间戳 / nonce, 否则签名不可复现、密钥不可复原); - v3:
prfSalt/salt/infoPrefix/rpId/credentialId/ 发布地址(prfSalt即上表的salt,经 PRF 扩展求值后作为 HKDF 的 salt 输入);
- v1:
- 越界(HKDF 输出非合法 secp256k1 标量)时按
info + "/retry:<i>"重试,首次命中的方案即冻结方案; - v1 / v2 / v3 并存:0.18.0 起新档案建议用 v2;0.20.0 起需要「非签名、来源绑定」时用 v3。已用旧 mode 发布的载荷继续用旧 mode 解(不提供自动迁移,
因为
tokenURI铸后不可改)。
选型:四条路径怎么选(先定 Runner 跑在哪,再选密钥来源)
第一问不是「哪个更安全」,而是「Runner 跑在哪」:
- Runner 在你的服务端(服务化 Agent / 后端调用 / 自建执行)⇒ 用私钥托管,另外三条都不需要 —— 密钥不下发浏览器,落地见下方「托管密钥」一节。这是规避 v3 兼容性问题最彻底的一条路。
- Runner 在终端用户浏览器(用户自持访问权、去中心化叙事)⇒ 服务端私钥下不到浏览器, 只能在 v1 / v2 / v3 之间选。
| 维度 | v1 personal-sign-hkdf | v2 eip712-hkdf | v3 webauthn-prf-hkdf | 私钥托管 |
|---|---|---|---|---|
| IKM 来源 | personal_sign 签名 | eth_signTypedData_v4 签名 | WebAuthn PRF 输出(非签名) | 无(服务端 generateKeyPair()) |
| 对浏览器/设备的依赖 | 注入式钱包 | 注入式钱包 + EIP-712 | WebAuthn + PRF(移动端钱包内置浏览器普遍没有) | 无(解密在服务端) |
| 可复用签名攻击面 | 有 | 有 | 无 | 无(根本没有签名) |
| 来源绑定 | 无 | 无(domain 只是域分隔符) | 有(浏览器强制 RP ID) | 无 ⇒ 改为服务端鉴权 |
| 是否有盲签/签名提示 | 文本盲签 | 结构化签名 | 无签名提示 | 无 |
| 要求确定性签名(RFC 6979) | 是 | 是 | 否 | 否 |
| 需额外落盘的信息 | — | — | credentialId + rpId | 私钥的备份与恢复 |
| 主要不可逆风险 | 钓鱼站可复现签名 ⇒ 派生私钥 | 同 v1 | 换域名 / 丢凭据 ⇒ 永久解不开 | 私钥丢失 ⇒ 永久解不开,且不可轮换 |
| v3 兼容性矩阵 | 不涉及 | 不涉及 | 需自行承担(见 v3 一节) | 全部规避 |
| 默认值 | 否 | 否 | 否(opt-in) | 否(opt-in) |
一句话结论:若 B 端的 Runner 能跑在服务端,私钥托管可完全规避 WebAuthn/PRF 的兼容性问题 —— 移动端内置浏览器、跨设备、混合传输(QR)、域名迁移全都不涉及(密钥材料不进浏览器)。 但它的代价是信任边界转移到运维侧:私钥在 KMS/HSM、不可轮换、丢失即永久解不开, 且它提供的是服务端鉴权而非「客户端自持 + 域绑定」。若后者是硬需求,则只能走 v3。
v2:用 EIP-712 取代文本盲签(mode: 'eip712-hkdf')
v1 的 personal_sign 有两个先天问题:钱包一律按文本盲签渲染(MetaMask / Rabby / OKX 提示「无法验证来源」),
且文案公开固定、可无限复用(任何钓鱼站让用户签同一串文本即可派生出该用户的 payload 私钥)。v2 把「被签的东西」
换成 EIP-712 typed data,派生算法不变,钱包会展示结构化、带 domain 的签名请求。
⚠️ v2 只解决「呈现」,不解决「可复用」:typed data 依然是公开常量、无时效、可无限重放, 上面那条攻击路径照旧成立(见「前置条件与失败语义」)。换编码消不掉 IKM 是签名这个事实; 要消掉它有两条路:托管密钥(见「托管密钥」一节)或 WebAuthn PRF(v3,0.20.0 已交付,见下节)。
默认值(SDK 内置,刻意不含时间戳):
domain = { name: 'Actora Agent Payload', version: '2' }
types = { PayloadKey: [
{ name: 'address', type: 'address' }, // 与 HKDF info 的地址同口径(小写),双保险
{ name: 'purpose', type: 'string' }, // 'agent-payload-encryption'
{ name: 'version', type: 'uint256' }, // 2
] }
primaryType = 'PayloadKey'
message = { address: <地址小写>, purpose: 'agent-payload-encryption', version: 2 }发布侧(deriveKeyPairFromWallet 接受 typed data,不再是只有 signMessage):
import { deriveKeyPairFromWallet } from '@actora/sdk'
const { privateKey, publicKey } = await deriveKeyPairFromWallet({
mode: 'eip712-hkdf',
signTypedData: (typedData) => walletClient.signTypedData({ account, ...typedData }),
address: account.address,
// assertDeterministic 自 0.19.0 起默认开启;如需关掉:assertDeterministic: false
})
// 铸造前自检:把 privateKey 传给 publishAgent({ ..., verifyWithPrivateKey: privateKey })运行侧(默认值即上表;domain / types / primaryType 可覆盖,覆盖后同样进入冻结面):
const { ctx, error } = useAgentRunner({
agentId,
encryptionKeyDerivation: { mode: 'eip712-hkdf' }, // v2
// encryptionKeyDerivation: { mode: 'personal-sign-hkdf' }, // v1:解已有载荷时用
})v3:WebAuthn PRF —— 把 IKM 从「签名」换成「浏览器绑定的密钥材料」(mode: 'webauthn-prf-hkdf',0.20.0+)
前两节的 IKM 都是签名,于是「公开 / 无时效 / 可复用」成为必然代价。v3 换掉 IKM 的来源:用 WebAuthn PRF 扩展 的确定性输出(32 字节)。PRF 由浏览器/认证器强制绑定到 RP ID(你的注册域),不是签名,因此:
| 维度 | v1 / v2(IKM = 签名) | v3(IKM = PRF 输出) |
|---|---|---|
| 是否可被重放 | ✅ 载荷是公开常量,钓鱼站让用户签同一份即可派生 | ❌ 浏览器拒绝在非注册域使用该凭据,拿不到同值 |
| 钱包提示 | 文本盲签 / typed data 签名 | 无签名提示(不经过 EIP-1193) |
| 是否需要 assertDeterministic | 需要(非 RFC 6979 钱包不可复现) | 不需要:PRF 无计数器,构造上确定 |
| 与钱包地址的关系 | 绑定发布地址(HKDF info) | 同样绑定地址;但密钥主体来自 PRF ⇒ 与钱包账户解耦 |
| 新增依赖面 | 无 | rpId / credentialId(必须落盘,无法从地址反推) |
发布侧(先注册 passkey,再派生):
import {
getPrfCapability, createPrfCredential, evaluatePrf,
deriveKeyPairFromWallet, publishAgent, DEFAULT_PRF_SALT,
} from '@actora/sdk'
// ① 能力探测(不弹窗)。prf: 'unknown' 是真实答案 —— 旧浏览器无法在不弹窗的前提下回答,
// 它**不等于**失败;v3 真不可用时会在 ③ 抛出可操作错误。
const cap = await getPrfCapability()
// ② 注册 passkey(residentKey + userVerification + PRF 一并开启)
const cred = await createPrfCredential() // rpId 默认 location.hostname
// cred.credentialId 是 base64url,**务必持久化**(见 ④);丢了就再也派生不出同一把密钥
// ③ 派生:IKM = evaluatePrf 的 32 字节输出
const { privateKey, publicKey } = await deriveKeyPairFromWallet({
mode: 'webauthn-prf-hkdf',
address: account.address,
getPrfOutput: async () => (await evaluatePrf({ credentialId: cred.credentialId })).prfOutputHex,
})
// v3 忽略 assertDeterministic(PRF 无计数器);铸造前自检照旧
const result = await publishAgent({ agent, publicKey, uploader, verifyWithPrivateKey: privateKey })运行侧与链上绑定(把 rpId + credentialId 记到档案上,否则将来无从复现):
const { ctx, error } = useAgentRunner({
agentId,
encryptionKeyDerivation: {
mode: 'webauthn-prf-hkdf',
credentialId, // 从链上属性读回
rpId: 'app.example.com', // 必须就是注册该 passkey 时的域
// prfSalt: DEFAULT_PRF_SALT, // 默认值,改动即进入冻结面
},
})// 发布后落盘绑定(跨实现复现所需的最小信息)
await registry.setPayloadKeyMode(agentId, 'webauthn-prf-hkdf')
await registry.setPayloadKeyCredential(agentId, {
rpId: 'app.example.com',
credentialId, // base64url
prfSalt: DEFAULT_PRF_SALT, // 显式写入,便于第三方 runner 复现
address: account.address,
})
// 读取:readPayloadKeyCredential(await registry.getAttributes(agentId))
// (属性名常量 PAYLOAD_KEY_CREDENTIAL_ATTRIBUTE = 'payload_key_credential',JSON 字符串)零回退(有意设计):PRF 不可用时抛 PrfError,绝不静默退化为 signMessage ——
静默回退会让用户以为持有「passkey 绑定」的密钥,实际回到可复用签名,比直接报错危险得多。
错误码(error.code,error instanceof PrfError):
| PrfErrorCode | 触发 | 处置 |
|---|---|---|
| PRF_UNSUPPORTED | 浏览器/认证器没有 PRF 扩展(移动端钱包内置浏览器普遍如此) | 改用 mode: 'eip712-hkdf'(v2)或托管密钥 |
| PRF_CREDENTIAL_NOT_FOUND | 凭据不在本设备(InvalidStateError) | 在注册设备或同一 passkey 同步服务上重试 |
| PRF_CANCELLED | 用户取消 / 超时 / 凭据不存在(NotAllowedError) | 重新发起;确认 RP ID 正确 |
| PRF_FAILED | RP ID 与当前来源不匹配(SecurityError)、输出长度异常、其他 | 检查 rpId 是否等于注册时的域 |
⚠️ v3 的已知局限(务必在选型时权衡): ① 移动端钱包内置浏览器普遍不支持 WebAuthn/PRF ⇒ 这类端侧请继续用 v2 或托管密钥; ② 密钥绑定 (rpId, credential) ⇒ 换域名、或丢失凭据,已发布载荷永久解不开(
tokenURI铸后不可改), 故credentialId+rpId必须随档案落盘(payload_key_credential属性); ③ 跨设备 / 混合传输(QR)场景的 PRF 一致性存在公开缺陷史(如 WebKit bug 311099:Safari 对安全密钥返回 未解密的 hmac-secret 输出)⇒ v3 因此设计为 opt-in,不改变任何默认值。 ④getPrfCapability()在无getClientCapabilities()的浏览器返回prf: 'unknown',只能靠真实 ceremony 判定。
发布(浏览器)
import { deriveKeyPairFromWallet, publishAgent } from '@actora/sdk'
// v1(沿用既有档案)
const { privateKey, publicKey } = await deriveKeyPairFromWallet({
signMessage: (message) => walletClient.signMessage({ account, message }),
address: account.address,
// 0.19.0 起默认开启(多签一次即拦下非确定性钱包);确认钱包确定性时可关掉
// assertDeterministic: true,
})
// v2(新档案推荐):见上一节 `mode: 'eip712-hkdf'`
// 铸造前自检:私钥解不开就不上传、不铸造(tokenURI 铸后不可改)
const result = await publishAgent({
agent, publicKey, uploader,
verifyWithPrivateKey: privateKey,
})
// result.encryptedCid → 链上 tokenURI;result.eciesEncryptedKeyHex → 元数据属性
// 铸造后记录派生 mode —— 跨实现的标准字段,第三方 runner 据此选解包方式
await registry.setPayloadKeyMode(agentId, 'personal-sign-hkdf') // 或 'eip712-hkdf';v3 用 'webauthn-prf-hkdf'(另需 setPayloadKeyCredential)发布链路上有三道铸造前护栏,都会在「已经不可回退」之前拦住你:
| 护栏 | 何时触发 | 默认 |
|---|---|---|
| assertDeterministic | 钱包对同一载荷返回不同签名(非 RFC 6979)⇒ 将来必然解不开 | 开(0.19.0+) |
| selfCheck | 派生出的密钥对无法 ECIES 往返(密钥本身有问题) | 开(0.19.0+) |
| verifyWithPrivateKey | 传入的私钥解不开本次包裹的 AES key(含 v1/v2 mode 用错) | 传了才查 |
读取模式用 readPayloadKeyMode(await registry.getAttributes(agentId))(属性名常量 PAYLOAD_KEY_MODE_ATTRIBUTE = payload_key_mode;
未知值返回 undefined,不要猜 —— 「用错 mode」与「载荷损坏」现象完全一样)。
运行 / 解密(浏览器)
const { ctx, error } = useAgentRunner({
agentId,
encryptionKeyDerivation: { mode: 'personal-sign-hkdf' }, // 显式 opt-in(v1 / v2 / v3 三选一)
})非 React 场景:自行实现 WalletSigner.deriveEncryptionKey()(返回派生私钥);或由宿主完全接管密钥来源
(网关下发 / 会话密钥 / HSM 等),优先级最高的两种注入方式:
const runner = new AgentRunner({
reader,
wallet, // deriveEncryptionKey() / getPrivateKey() 二选一,见 WalletSigner
// ① 完全自定义解包
unpack: async (encryptedPayload, eciesEncryptedKey) => {
const aesKeyHex = await myKeyService.resolve(agentId)
return decryptPayload(encryptedPayload, aesKeyHex)
},
})
// ② 或直接给已解出的 AES key(跳过 ECIES)
new AgentRunner({ reader, wallet, aesKeyHex: sessionAesKey })密钥来源优先级:config.unpack → config.aesKeyHex → wallet.deriveEncryptionKey() → wallet.getPrivateKey()。
托管密钥:不经钱包派生(服务端生成密钥对)—— 受支持的一等用法
「密钥对由服务端生成、不经任何钱包签名」是受支持的发布形态,不是 hack:publishAgent({ publicKey }) 的公钥本来就是入参,
generateKeyPair() 也是公开 API。服务化 Agent(咨询走自有后端)建议优先用这条路径 —— 它没有「可复用签名」问题
(根本没有签名),也不受钱包确定性、盲签提示、personal_sign 文案复用的影响。
它同时规避了 v3 的全部兼容性问题:密钥材料不进浏览器 ⇒ 移动端钱包内置浏览器无 WebAuthn、跨设备 / 混合传输(QR) 的 PRF 一致性、域名迁移全都不涉及。代价见下方「安全建议」表(尤以不可迁移 / 不可轮换为重)与 §5.12 开头的「选型」矩阵。
import { generateKeyPair, publishAgent, AgentRegistry } from '@actora/sdk'
// ① 服务端生成密钥对(私钥只留在你的 KMS / HSM,永不进浏览器)
const { privateKey, publicKey } = generateKeyPair()
// ② 用公钥发布;顺便做铸造前自检 —— 私钥能解开才算通过
const result = await publishAgent({
agent, publicKey, uploader,
verifyWithPrivateKey: privateKey, // 0.19.0+:不通过就不上传、不铸造
})
// ③ 铸造并记录派生 mode(第三方 runner 据此选解包方式)
const { agentId } = await registry.register(result.encryptedCid, {
eciesEncryptedKey: result.eciesEncryptedKeyHex,
})
await registry.setPayloadKeyMode(agentId, 'personal-sign-hkdf') // 或 'eip712-hkdf';v3 用 'webauthn-prf-hkdf'(另需 setPayloadKeyCredential)运行侧(只在这两类位置持有密钥,不要下发到浏览器):
// A. 后端解包后自行使用(推荐):完全自定义解包
new AgentRunner({
reader, wallet,
unpack: async (encryptedPayload, eciesEncryptedKey) => {
const aesKeyHex = await kms.unwrapAgentKey(agentId) // 你的 KMS / HSM
return decryptPayload(encryptedPayload, aesKeyHex)
},
})
// B. 让 SDK 用托管私钥做 ECIES 解包(私钥需从安全存储短期取出)
new AgentRunner({ reader, wallet, aesKeyHex: await kms.getAgentAesKey(agentId) })安全建议
| 项 | 建议 |
|---|---|
| 存放 | 私钥进 KMS / HSM / 云 KMS(unpack 路径下私钥无需离开 KMS);绝不写进前端、CDN、仓库或日志;.env 只放引用 ID,不放密钥本体 |
| 轮换 | 每个档案一把密钥(generateKeyPair() 每次调用都新生成),不要跨档案复用;单个档案的密钥不能轮换 —— 见下一条 |
| 审计 | 记录「谁在何时用哪个 keyId 解包了哪个 agentId」;解包是敏感操作,建议限流 + 告警 |
| ⚠️ 不可迁移 | tokenURI / 链上属性铸造后不可改,而 payload 的 AES key 是用发布时的公钥包裹的 —— 所以这把私钥丢了就是载荷永久解不开,且不能换(不能像钱包派生那样「换个设备重新派生」)。请把「密钥备份与恢复」当成上线前的硬性检查项 |
| 与签名派生对比 | 托管密钥:无签名可复用、可服务端审计,但中心化托管(你的运维即信任边界);签名派生:非托管、用户自持,但有上述固有边界。按业务选择,SDK 两条都支持 |
参考实现(Actora 平台自带):平台侧已按本节形态落地一条托管链路,可直接对照 —— 网关
POST /api/v1/agents/payload-keys为每个 Agent 生成一把独立密钥对(keyId+ 公钥返回, 私钥用环境主密钥PAYLOAD_KEY_KEK经 AES-256-GCM 封装后落库,明文不回传、不上链), 前端用公钥eciesEncrypt(aesKey, publicKey)包裹后把eciesEncryptedKey与payload_key_id写进链上属性(不需要第二笔交易 ——agentId → keyId → 公钥的绑定随铸造元数据一并上链), 作者可随时POST /api/v1/agents/payload-keys/:keyId/export可重复导出私钥(带export_count/last_exported_at审计)。落库表见gateway/db/migrations/039_agent_payload_keys.sql。 注意:该链路用payload_key_mode = 'hosted'标注,不属于 v1/v2/v3 三个DerivationMode(readPayloadKeyMode()对未知值返回undefined,第三方 runner 见此值应改用「调用平台/导出私钥解包」而非派生)。
读取闭环(用托管私钥把 Agent 跑起来):发布方(owner)在 payload_key_mode === 'hosted' 时按下面的路径取回私钥并解包:
import { unpackAgent, eciesDecrypt } from '@actora/sdk/core'
import { AgentRunner } from '@actora/sdk'
// 1) 链上取密文引用(eciesEncryptedKey 是用托管公钥包裹的 AES key)
const attrs = await reader.getAttributes(agentId)
// → { encryptedPayloadCid, eciesEncryptedKey, payload_key_mode, payload_key_id }
const encryptedPayload = await ipfsFetcher.fetchEncryptedPayload(attrs.encryptedPayloadCid)
// 2) owner 向平台按 agentId 取回私钥(可重复导出 + 审计;需登录该 Agent 的钱包)
const res = await fetch(
`${GATEWAY}/api/v1/agents/payload-keys/by-agent/${agentId}/export?chain=bsc`,
{ method: 'POST', headers: { Authorization: `Bearer ${accessToken}` } },
)
const { key } = await res.json() // { privateKey, keyId, publicKey, exportCount, ... }
// 3a) 一步解包得到 { prompt, skills, mcp }
const privatePayload = unpackAgent(encryptedPayload, attrs.eciesEncryptedKey, key.privateKey)
// 3b) 或把 AES key 交给 Runner(私钥只在内存短暂存在,不落盘)
const runner = new AgentRunner({
reader, wallet,
aesKeyHex: eciesDecrypt(attrs.eciesEncryptedKey, key.privateKey),
})网关不可用时,Studio 会本地兜底生成密钥对(
payload_key_mode = 'local',平台不托管)并在发布成功后 立即下载私钥;此时改用unpack/aesKeyHex传入该本地私钥即可(by-agent导出对它是 404)。
前置条件与失败语义
| 项 | 说明 |
|---|---|
| 确定性签名 | 必须满足 RFC 6979(MetaMask / Ledger / Trezor 满足;部分 MPC / 托管钱包不满足),v1 的 personal_sign 与 v2 的 eth_signTypedData_v4 同样适用。非确定性钱包一旦发布,Agent 就再也解不开(静默数据损坏),故 deriveKeyPairFromWallet() 自 0.19.0 起默认开启 assertDeterministic(多一次签名提示);确认签名为确定性时可显式关掉。v3 不涉及签名,此约束不适用 |
| 域隔离 | 派生载荷只在 Actora 使用:任何能让同一地址对同一载荷签名的场景都等价于拿到内容密钥;换地址即失效。v1 是自由文本(任何站点都能复现该提示词);v2 的 EIP-712 domain + types 让钱包渲染结构化、带域分隔符的请求,比盲签可辨;v3 由浏览器按 RP ID 强制隔离(换域即换密钥空间,无需依赖文案) |
| ⚠️ 签名路径的固有边界(v1 与 v2 同样适用) | 只要 IKM 来自签名,「公开 / 无时效 / 可复用」就是必然代价:文案与 typed data 都是公开常量,钓鱼站让用户签同一份载荷即可派生出其 payload 私钥,配合公开的 encryptedPayloadCid 解开私有载荷。这不是 v1 的 bug,换编码消不掉 —— 加时间戳 / nonce 会直接让密钥不可复原(载荷永久解不开),所以「抗重放」与「可复原」在这条路径上互斥。在 typed data 里加「来源域名」字符串也不构成密码学绑定:字段值公开可复现、当前主流钱包对离线 typed data 不做 origin 校验(origin 仅用于 UI 展示)。真正做到来源绑定需要非签名密钥来源:托管密钥或 WebAuthn PRF(v3) |
| 密钥来源(除签名外) | ① 托管密钥(服务端生成密钥对,见上一节)—— 无「可复用签名」问题,因为根本没有签名;② WebAuthn PRF(v3) —— 非签名、由浏览器按 RP ID 绑定,见 v3 一节;③ 签名派生(v1 / v2) |
| 错误码 | 钱包不提供任何密钥来源 → WALLET_NOT_CONNECTED(不再被包成 DECRYPTION_FAILED);密钥不对/密文损坏 → DECRYPTION_FAILED,原始错误见 error.cause;v3 的 PRF 失败 → PrfError(error.code 见 v3 一节,不回退到签名派生) |
| 订阅者能否解密 | 不能。payload 的 AES key 由发布时的公钥包裹,只有持有对应私钥的发布方解得开。订阅者侧要拿到明文 prompt/skills,需走托管对话,或由发布方经 MCP 下发 |
隐私边界:托管对话读的是什么
托管对话(ConversationClient,agentId 模式)由 Conversation Service 组装上下文,其 system prompt 取公开
metadata 的 description(回退 name),skills 取 metadata_json.skills —— 不会读取加密 payload
(见 conversation-service agent-context-loader)。
metadata_json 的来源需要注意:它由网关索引器写入,基底是 tokenURI 解析出的 JSON(解析器
parseTokenURIJSON() 只认 data:application/json;base64,<base64>,ipfs:// 与普通 https URL 一律返回 null),
再叠加链上 skills 属性。
托管对话各字段的真实来源与优先级
| 托管对话要用的字段 | 来源(前者优先) | 说明 |
|---|---|---|
| system prompt | description → name | 索引侧:tokenURI JSON 的 description → 链上 description 属性兜底;都空则用 Agent <id>。只取这两者,不读链上 prompt 属性 |
| 工具列表(skills) | metadata_json.skills → metadata_json.attributes.skills | first hit wins,两者不合并。顶层 skills 来自 tokenURI JSON;attributes.skills 来自链上 skills 属性(SkillDef[] 形态) |
| 市场展示(skills 列) | tokenURI JSON 的 skills → 链上 skills 属性(string[] 形态) | 只喂列表页,不参与工具构建 |
tokenURI指向密文(ipfs://<encryptedCid>)既不会报错、也不会 404:parseTokenURIJSON()只是解析不到 ⇒metadata_json基底为空,此时name/description/skills全部回落到链上属性。 所以「tokenURI指密文 + 明文 metadata 走链上属性」是被正式支持的形态(Studio 发布流程正是如此)。若想让
tokenURI改指公开 CID:生产IdentityRegistry的对外方法只有register/registerWithMetadata/updateMetadata/getCurrentAgentId/agentExists/getAgentOwner/getAgentsByOwner/getAgentMetadata/totalAgents—— 没有updateTokenURI/setTokenURI⇒tokenURI只在铸造时写入、之后不可改,要换只能重新铸造(换新 agentId)。 因此不推荐为了读 metadata 去重铸:把name/description当链上属性补写即可, 与skills同一笔updateMetadata一起写(省 gas,也不用动 tokenURI)。
可执行 skills 的公开约定如下:
| 发布方写什么 | 落到哪里 | 谁消费 |
|---|---|---|
| 链上属性 skills = JSON SkillDef[]([{ name, description, inputSchema, execution? }]) | metadata_json.attributes.skills | 托管对话 → 可执行工具(execution.type='mcp' / 'http' 会真实调用) |
| 链上属性 skills = JSON string[](技能名列表) | skills 列(tokenURI JSON 里已有 skills 时不覆盖) | 市场展示 |
| tokenURI 为 base64 data URI JSON 且含 skills | metadata_json.skills | 托管对话(优先于 attributes.skills,两者不合并) |
- 托管对话取值顺序:
metadata_json.skills→metadata_json.attributes.skills;只有带非空name的对象才算技能 定义,纯字符串数组会被跳过(不会再因inputSchema缺失而在建工具时抛错); - ⚠️ 链上
skills属性值必须是合法 JSON 数组,且整体必须是同一种形态:要么全是带非空name的对象 (SkillDef[]),要么全是非空字符串(string[])。混入一个字符串、或有一个对象缺name,整组会被判为无效并 静默丢弃(不会报错,表现为「工具一个都不出现」); - 链上属性无长度/条数限制,但每条是一次 SSTORE ⇒ skills 序列化到数 KB 会显著抬高 gas;建议只公开托管对话真正
需要的那几条(例如单个 MCP/HTTP 入口),展示用的名字列表可另走
string[]形态; - 存量 Agent 补写属性:链上属性只能整组覆盖写入 —— 所有者调
IdentityRegistry.updateMetadata(agentId, [{ key, value }…])(合约没有setMetadata(agentId,key,bytes)单键方法; 实现里会先delete _agentMetadataKeys[agentId]再写入传入数组),因此必须把原有name/description/category/pricing_type/ … 全部一起重传,只传skills会抹掉其余属性。value为bytes,写入时用stringToHex(text)编码; SDK 0.17.0+ 已封装:registry.setAttributes(agentId, { skills: [...] })自动读回现有属性、合并后整组写回 (未提及的键保持原值);需要删键时用registry.overwriteAttributes(agentId, attrs[])(整组覆盖,须自行带上全部属性)。 写入后等网关 fallback 全量同步(AGENTS_SYNC_INTERVAL_SEC,默认 120s;或直接POST /api/v1/agents-sync), 再等 Conversation Service 上下文缓存 TTL(CONTEXT_CACHE_TTL_SEC,默认 300s)过期。 注意:网关未监听MetadataUpdated事件(只监听AgentRegistered/Transfer)⇒ 属性变更只能靠上述轮询或手动触发; - 多链(0.17.0 起):托管对话的
agentId模式已支持 BSC / Ethereum / Base 全链。调用方不需要、 也无法声明链(详见 §4「托管对话:链由平台解析」):网关从agents表列出该agentId存在的候选链 (按平台内部固定的链优先序:内部测试链 → 中心化 → BSC → Ethereum → Base 排序),逐链判定调用方是否拥有 / 已订阅,取第一条放行的链, 并把它一路下传到 Conversation Service(SSE 单轮与后台任务两条路径都下传,后台任务在chat_tasks.chain落库)。 因此同 ID 跨链冲突(各链独立编号,实测 BSC 与 Ethereum 都存在 id=2)不会被取错:只要你在其中一条链上有权, 系统就会命中那条链。只读的GET /api/v1/agents/:id支持?chain=精确指定;不带时按上述优先序跨链解析 (历史行为:只命中内部测试链 / 中心化,BSC / Base 会 404 —— 已修复)。
skills[].execution:可选,托管对话支持的类型与调用约定
execution 是可选字段。不写不影响工具被列出——工具列表只判断「是否为带非空 name 的对象」,
execution 完全不参与过滤;但调用时固定返回一个 error 对象(不抛异常),用于「先让工具出现在列表里」:
{ "error": "Skill \"<name>\" has no remote execution configured. Add execution.type=\"mcp\" with an endpoint." }⚠️ 三条路径支持的写法不完全相同:
- 托管对话(
agentId模式):读链上公开属性,execution.type支持mcp/http/a2a(下表);- inline 注入(
ConversationClient传prompt+skills):走 SDK 的ConversationSkillDef, 同样已声明mcp/http/a2a(有完整类型提示);- SDK 加密发布类型(
Skill.execution,写入加密 payload、由本地AgentRunner解密执行): 只声明mcp/a2a。注意open不是execution.type的取值,它是「未配置execution时本地执行器的默认模式」。
| execution.type | 字段 | 平台(托管对话)侧行为 |
|---|---|---|
| mcp | endpoint?(缺省 <gateway>/mcp)、toolName?(缺省技能名) | 真实发起 JSON-RPC 2.0:POST <endpoint>,body {"jsonrpc":"2.0","id":<ts>,"method":"tools/call","params":{"name":"<toolName>","arguments":<入参>}},取 result.content[0].text |
| http | endpoint(必填) | 真实发起 POST <endpoint>,Content-Type: application/json,body = 模型按 inputSchema 产出的入参 JSON 原样,响应按 JSON 解析后作为工具结果 |
| a2a | targetAgentId?、skillFilter?、promptOverride? | 不发起 HTTP:返回 { type: 'a2a_delegation', targetAgentId, skillFilter, promptOverride, callerInput } 作为工具结果,由上层决定是否走 A2A |
| 其他 / 不写 execution | — | 进工具列表;调用返回上面的 error 对象 |
http 类型的边界(当前实现实测):
- 没有入参映射、没有包装:body 就是模型给出的工具参数。要让 body 对上你 endpoint 的形状,请把
inputSchema.properties直接写成你 endpoint 期望的字段(例:{ question, history })——模型会按 schema 产出参数; - 不注入任何鉴权头(只有
Content-Type: application/json):无签名 / JWT / 共享密钥,平台不代理你的鉴权, 多轮长期接口请自备限流(平台侧无额外白名单机制,也无需登记密钥)。若要鉴权,只能把凭证放进endpointURL, 或改用execution.type='mcp'让你的 MCP server 自行校验调用者; - 方法固定
POST,无method/headers/ 重试 / 超时的配置项; - 超时 30 秒(硬编码
AbortSignal.timeout(30_000),mcp相同); - 失败语义:HTTP 非 2xx ⇒ 抛
HTTP skill "<name>" failed: HTTP <status>;超时 / 网络错误 ⇒ 底层 fetch 抛错。 错误作为工具结果回给模型,不会中断整轮对话; - 响应约定:无强制结构,返回的 JSON 原样交给模型。建议返回对象而非超长文本。
因此:
- 「密文存放于 IPFS、平台与 B 端都无法解密」成立,平台侧没有任何 Agent 内容解密实现;
- 但不要把敏感内容写进
description:它以明文入库,并会作为托管对话的 system prompt 被使用; - 真 prompt/skills 要参与托管对话有三条路:① 公开链上
skills属性(推荐,人格与细节仍留在私有载荷里); ② inline 模式(调用方自备prompt/skills);③ 自托管 Runner(持派生密钥那一侧); ConversationClient.getSessionKeyInfo()是 A2A 代付的会话密钥(EIP-712 授权派生,私钥不出端侧),与内容解密无关。
6. 中心化(Central)vs 链上(On-chain)市场对比
| 维度 | 链上(On-chain) | 中心化(Central) |
|------|------------------|-------------------|
| Agent 载体 | 链上 NFT(AgentRegistry 铸造,正 ID) | 网关 DB 记录(source='central',负 ID) |
| 订阅计划 | SubscriptionManager.createPlan(链上合约) | CentralAgentClient.createPlan(central_plans 表) |
| 付费 | subscribe(原生币/ERC20,托管 escrow) | SubscriptionPayments.pay({ method: 'x402', priceWei }) 或 fiat |
| 鉴权判定 | 链上 hasActiveSubscription | 网关 checkSubscription(fiat_subscriptions) |
| 适用场景 | 需要公开可验证、用户自持钱包、跨平台互操作 | 平台自己建 Agent、快速上架、无需用户钱包的 B 端 |
流程差异:链上 Agent 的用户必须「铸造者/订阅者」都有钱包且在同一链;中心化 Agent 的订阅方无需钱包(fiat 卡付),B 端拿到 planId + priceWei 直接调统一支付即可。
7. B 端常见问题(FAQ)
鉴权方式对照表
| 场景 | 头 | 适用客户端 |
|------|-----|-----------|
| 租户 API Key(推荐 B 端) | X-Api-Key: actora_... | ConversationClient / BillingClient / CentralAgentClient(发布管理) |
| 钱包签名 JWT | Authorization: Bearer <jwt> | 同上(accessToken 字段) |
| 平台管理员 | X-Admin-Key: <ADMIN_KEY> | AgentWalletConfig(平台全量作用域) |
| 钱包/私钥(链上交易) | 无头,viem WalletClient 签名 | AgentRegistry / SubscriptionManager / A2AProtocol |
ConversationClient必须二选一提供apiKey或accessToken,否则构造后请求即抛错。
B 端开户后为什么调不通平台 LLM?
partner 租户注册时不带套餐(quota_daily=0),平台模型列表为空。两条路:先配 BYOK(POST /api/v1/tenant/keys,不计额度),或购买平台档位(GET /api/v1/tenant/plans → POST /api/v1/payments,purpose='tenant-plan')。详见 §3.a 与 §8。
一个租户如何服务多个终端用户?
服务端调用时带 X-End-User-Id: 0x<用户钱包或业务 ID>,网关会以该地址作为计费 / 订阅判定的 subject;若传 0x 钱包地址,还会额外做订阅代理鉴权(该钱包的订阅决定访问权)。见 §8.1 / §8.6。
archive RPC 的坑
eth_call(读合约)与事件订阅用 publicnode 免费 RPC 即可;getTransactionReceipt(历史交易收据 / archive 查询)在 publicnode 返回 403;- 换用:BSC →
https://1rpc.io/bnb;Base →https://mainnet.base.org。
nonce 缓存 / 交易重试的坑
- viem
WalletClient默认本地缓存 nonce,并发或失败重试时可能复用已用 nonce 导致nonce too low; - 串行执行交易并等待
waitForTransactionReceipt;失败重试前调用publicClient.reset()或在sendTransaction/writeContract显式传nonce: await publicClient.getTransactionCount({ address })。
Agent 发在 BSC / Ethereum / Base,托管对话会 404 吗?需要我传链吗?
不需要传链,也不会再 404(0.17.0 起)。平台侧会列出该 agentId 存在的候选链,逐链判定你在哪条链上有权,
取第一条放行的链并全链路沿用(详见 §4「托管对话:链由平台解析」)。
注意两条限制与易混点:
- 托管对话请求里没有
chain参数(chat/stream/createTask都一样)——链是平台解析的,不是调用方声明的。 只有只读的GET /api/v1/agents/:id?chain=可以显式指定链; - 链上操作仍然必须明确链:
AgentRegistry/SubscriptionManager/A2AProtocol都是「哪条链的合约就读哪条链」, 而agentId是链内编号(各链独立,实测 BSC 与 Ethereum 都有id=2且是不同 Agent)⇒ 换链 = 换 ID,需重新铸造 / 订阅。
托管对话里 Agent 的工具(skills)是空的?
托管对话不读加密 payload,技能集取自公开 metadata:metadata_json.skills → metadata_json.attributes.skills
(见 §5.12「隐私边界」)。链上没写公开 skills 属性时,工具列表必然为空 —— 这是发布方动作,不是调用方的 bug。
发布方用 registry.setAttributes(agentId, { skills: [...] }) 补写(SDK 0.17.0+,见 §5.1),随后等网关同步
(AGENTS_SYNC_INTERVAL_SEC 默认 120s,或手动 POST /api/v1/agents-sync)+ Conversation Service 上下文缓存
TTL(CONTEXT_CACHE_TTL_SEC 默认 300s)过期。
两个最常见的「工具不出现」原因:
- 链上
skills属性值不是合法 JSON 数组,或混了形态(既有对象又有字符串 / 有对象缺name)⇒ 整组被静默丢弃; - 写成了
string[](技能名列表)——那只会进市场展示的skills列,不会变成可执行工具。
托管对话的 system prompt(人格)从哪儿取?需要把 tokenURI 改成公开 CID 吗?
不需要动 tokenURI。 system prompt 由索引器按 description → name 组装(见 §5.12「各字段的真实来源与优先级」):
tokenURI解析出的 JSON 里的description优先,链上description属性兜底;- 都为空时退到
name(再空则Agent <id>);不会读链上prompt属性(想公开人格就写description)。
tokenURI 指向密文(ipfs://<encryptedCid>)是受支持的形态,不会报错。且生产 IdentityRegistry
没有 updateTokenURI / setTokenURI ⇒ tokenURI 铸造后不可改,换内容只能重新铸造(换新 agentId)。
因此推荐做法:不要重铸,直接把 name / description / skills 一起用 setAttributes 写成链上公开属性——
与 skills 同一笔 updateMetadata,省 gas。
其他
agentId是链内 ID:各链独立编号(同一个数字在多条链可能是不同的 Agent)⇒ 链上操作(铸造 / 订阅 / 退款 / A2A) 必须明确链,换链 = 换 ID,需重新铸造 / 订阅; 托管对话是例外:agentId模式不用声明链,平台按「你在哪条链有权」自动解析(§4);SubscriptionPayments.hasAccess需要gatewayUrl;method: 'chain'需要 config 里的subscriptionManager;- 中心化 Agent 的计划价格是
priceWei字符串,传给pay({ priceWei })时保持字符串避免精度丢失; AgentRunner.useAgent需要一条可用的解密密钥来源:config.unpack/config.aesKeyHex/wallet.deriveEncryptionKey()/wallet.getPrivateKey()。 EIP-1193 注入式钱包无法导出账户私钥,因此浏览器里必须用派生密钥路径(useAgentRunner的encryptionKeyDerivationopt-in,或自行实现deriveEncryptionKey)——详见 §5.12;- 密钥来源全部缺失时抛
WALLET_NOT_CONNECTED;密钥不匹配时抛DECRYPTION_FAILED,原始错误保留在error.cause中。
8. 网关 REST API 参考(B 端接入面)
SDK 是网关 REST API 的封装;需要自行实现 HTTP 客户端(Go / Java / Python 等)时,按本节对接即可。
网关地址:https://actora.codes(生产)。平台侧运维视角的完整端点清单见仓库根目录 DEPLOYMENT.md §13。
8.1 鉴权头
| 头 | 取值 | 适用 |
|----|------|------|
| X-Api-Key | actora_<32hex> | B 端主用(租户级)。服务端存 SHA-256 摘要,旧租户回退明文校验 |
| Authorization | Bearer <jwt> | 钱包签名登录(POST /api/v1/auth/verify)签发的 JWT,与 X-Api-Key 二选一 |
| X-End-User-Id | 0x... 或业务用户 ID | 代终端用户调用:计费 / 订阅判定以该地址为 subject;0x 地址还会触发订阅代理鉴权 |
| X-Admin-Key | <ADMIN_KEY> | 仅平台运维(/api/v1/admin/*),B 端不需要 |
X-Api-Key缺失时请求会回落到 JWT 校验;两者都无 → 401。 MCP 端点(/mcp、/mcp/agent/:id)只接受注册用户 JWT,不接受 API Key,B 端需自行代理。
BYOK(自带模型 Key)另用三个头逐请求覆盖:X-LLM-Api-Key / X-LLM-Endpoint / X-LLM-Model(ConversationClient 的 llmApiKey / llmEndpoint / llmModel 即写这三个头),此类调用 llmSource='byok',不计平台额度。
8.2 认证与租户
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | /api/v1/auth/challenge?address=0x... | 取登录挑战(nonce + 时间戳,5 分钟有效) |
| POST | /api/v1/auth/verify | 提交签名换 JWT;带 intent:'partner' 时注册 B 端租户并一次性返回 api_key |
| GET | /api/v1/auth/api-key | 取回明文 Key(仅旧租户可用;摘要化后的 Key 只在签发/轮换时返回) |
| GET | /api/v1/tenant/me | 档位、quota_daily / quota_used、rate_limit_rpm / max_concurrent / parallel_tasks、usage_today |
| GET | /api/v1/tenant/usage?days=30 | 用量汇总(按 key_source=platform\|byok)+ 日粒度 timeline(days 1–365) |
| GET | /api/v1/tenant/plans | 可购买平台档位(含 price_wei,由 price_monthly USD 折算) |
| POST | /api/v1/tenant/rotate-key | 轮换 Key,旧 Key 立即失效,新 Key 仅返回一次 |
| GET/POST/DELETE | /api/v1/tenant/keys[/:keyId] | BYOK Key 增删查(api_key 密文入库) |
| POST | /api/v1/tenant/keys/:keyId/validate | BYOK Key 探活(请求 <endpoint>/models) |
| GET | /api/v1/models | 可用模型:platform(按档位过滤,需平台 Key 存在)+ tenant_owned(BYOK) |
8.3 市场与订阅
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | /api/v1/agents | 市场列表,支持 ?chain=bsc\|ethereum\|base、分页 / 标签过滤 |
| GET | /api/v1/agents/:id | 详情。缺省跨链解析该 ID 所属链(平台内部链优先序:内部测试链 → 中心化 → BSC → Ethereum → Base);?chain= 可精确指定 |
| GET | /api/v1/agents/check-subscription | 校验订阅态(subscriber + agentId,可选 chain) |
| GET | /api/v1/chain/health?chain=bsc | 链索引健康(status: ok \| degraded \| pending) |
| GET | /api/v1/chain/plans/:planId · /chain/total · /chain/agents | 实时链上读(SDK 直读合约) |
| GET | /api/v1/agents/central | 中心化 Agent 管理(需鉴权;CentralAgentClient 对应) |
8.4 支付与计费
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | /api/v1/payments | 统一下单。purpose = agent-subscription(默认)/ tenant-plan / token-pack / a2a;method = chain / fiat / x402 / mpp |
| POST | /api/v1/payments/verify | 校验链上交易并记账({ txHash, chain }) |
| GET | /api/v1/payments/access | 跨轨道访问检查(链上订阅 / x402 台账 / fiat 订阅) |
| GET | /api/v1/payments/info · /quote | x402 发现信息(priceWei / payTo / network)与报价 |
| POST | /api/v1/payments/a2a · /a2a/settle | A2A 按次付费两段式(create → settle) |
| POST | /api/v1/payments/period/authorize · /charge · GET /period/authorization | 周期授权扣款(P4) |
| POST | /api/v1/payments/mpp/open · /voucher · /topup · /settle · /close · GET /mpp/session | MPP 通道(按量结算) |
| GET | /api/v1/billing/balance | 余额 / token 包余量 / Session Key 授权态(BillingClient.getBalance) |
| GET | /api/v1/billing/token-packs | 加油包列表(5M/$9.9、20M/$29.9、100M/$99.9,30 天有效) |
| GET/POST | /api/v1/billing/agent-wallets | agent 自主 MPC 钱包绑定 / 解锁 / 解绑(A2A 自动代付) |
| GET | /api/v1/x402/info · /balance · /paywall | x402 支付发现与台账(公开 / 租户) |
purpose 与关键字段:
// 1) 购买平台档位(partner 开通平台 LLM)
POST /api/v1/payments
{ "method": "chain", "purpose": "tenant-plan", "tenantPlanId": "<plans.id>",
"subscriber": "0xWallet", "chain": "bsc", "txHash": "0x..." }
// 2) 购买 token 加油包(超出日额度后自动消耗,先到期先用)
{ "method": "chain", "purpose": "token-pack", "packId": "<pack id>",
"subscriber": "0xWallet", "chain": "bsc", "txHash": "0x..." }
// 3) 订阅链上 Agent(原生币 / ERC20)
{ "method": "chain", "purpose": "agent-subscription",
"agentId": 1, "planId": 2, "subscriber": "0xWallet", "chain": "base", "txHash": "0x..." }8.5 托管对话与任务
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | /api/v1/agent/runs | 执行 Agent(SSE 流式返回;ConversationClient.chat/stream 对应) |
| POST | /api/v1/chat/completions | 平台 / BYOK LLM 直连对话(计费入口,OpenAI 兼容请求体) |
| POST | /api/v1/sessions · GET /api/v1/sessions/:id | 会话管理 |
| POST | /api/v1/tasks · GET /api/v1/tasks/:id · POST /api/v1/tasks/:id/cancel | 异步任务(parallel_tasks 关闭时返回 403 PARALLEL_TASKS_DISABLED) |
| POST | /api/v1/a2a/tasks/:id/resume · /retry · GET /a2a/task-result/:taskId | A2A 委派任务续跑 / 重试 / 取结果 |
| GET | /api/v1/a2a/session-key/info | Session Key 授权状态(balanceReturnEnabled 等) |
| POST | /api/v1/a2a/session-key/create · /revoke | 创建 / 撤销 Session Key(自动代付) |
链不用你传:POST /api/v1/agent/runs 与 POST /api/v1/sessions/:id/tasks 都不接受调用方指定链,
平台自行按上文的多链解析判定(列出该 agentId 的候选链 → 取你在其中有权的那条),并把该链下传给执行侧
取 Agent 上下文;后台任务会把判权链落库(chat_tasks.chain)以保证后台执行时刻用同一条链。
只读的 GET /api/v1/agents/:id 支持 ?chain= 显式指定链(用于展示 / 调试)。
8.6 计费与配额语义
- 两条 Key 来源:
platform(平台 LLM,按quota_daily扣 token,超出后消耗 token 包)/byok(自带 Key,不计费);用量日志按key_source分组。 - 额度耗尽:返回 402(
QUOTA_EXHAUSTED),Session Key 自动代付任务会被挂起为awaiting_payment,24 小时内充值即可续跑。 - 限流:默认 partner
5 rpm/ 并发1;购买档位后按档位生效。超限返回 429。 X-End-User-Id:同一把租户 API Key 可服务多个终端用户,计费与订阅判定按该地址归属,是 B 端「一次接入多用户」的标准做法。
8.7 常见错误码
| 状态 | 含义 | 处理建议 |
|------|------|----------|
| 401 | Key / JWT 缺失或失效 | 检查 X-Api-Key;若被轮换需换新 Key |
| 402 | 额度耗尽 / 订阅过期 | 购档位或加油包;x402 任务等待充值 |
| 403 | 租户被 suspend / 能力未开通;或对目标 Agent 无归属 / 无有效订阅(AGENT_ACCESS_DENIED) | 查 GET /tenant/me 的 status;PARALLEL_TASKS_DISABLED 时降级为 chat();AGENT_ACCESS_DENIED 则确认该钱包在 Agent 所在链上确实拥有 / 已订阅(多链下无需自己判断是哪条链,平台会逐链判定) |
| 404 | 路径或资源不存在(含 Key 摘要不可读回);GET /agents/:id 时表示该 ID 在你指定的链上不存在(?chain= 指定时),或不带 chain 时所有候选链都没有该 ID | 核对端点;Key 丢失只能轮换;Agent 详情可先不带 ?chain= 让平台跨链解析 |
| 429 | 触发限流 | 指数退避重试 |
| 503 | 链未开通 / 索引不可用 | 先调 GET /api/v1/chain/health?chain=,pending 表示该链未部署 |
8.8 B 端上线检查清单
POST /api/v1/auth/verify(intent:'partner')建租户,立即落库api_key;GET /api/v1/tenant/me确认档位 / 限流;未购档位则先配 BYOK(POST /api/v1/tenant/keys)跑通链路;- 需要平台 LLM → 购档位(
GET /tenant/plans+POST /api/v1/payments,purpose='tenant-plan'); - 需要链上 Agent → 选链(
GET /api/v1/chain/health探活)→ 订阅(SubscriptionManager/SubscriptionPayments); - 服务端调用一律带
X-Api-Key+X-End-User-Id,并实现 401/402/429/503 的重试与降级; - 监控
GET /api/v1/tenant/usage与GET /api/v1/billing/balance,避免线上断供; - 托管对话不要传链(也没有该参数):传
agentId即可,BSC / Ethereum / Base 的 Agent 由平台自动解析 (§4);若收到 403AGENT_ACCESS_DENIED,先确认该钱包在 Agent 所在链上确实拥有 / 已订阅; - 若需 Agent 的可执行工具生效,确认发布方已用
registry.setAttributes(agentId, { skills: [...] })补写公开skills属性(§5.12 / FAQ),否则技能集恒为空。
9. 版本记录
| 版本 | 要点 |
|------|------|
| 0.12.0+ | BSC 多链:KNOWN_CHAINS 增加 chainId 56(BSC 主网,Agent 市场开通),链上模块全链通用 |
| 0.13.0 | ETH / Base 预留:KNOWN_CHAINS 增加 1 / 8453 占位条目(合约未部署) |
| 0.14.0 | ETH 开通:Ethereum 主网合约部署(SubscriptionManager block 25992521) |
| 0.15.0 | Base 开通:Base 主网合约部署(SubscriptionManager block 51401326) |
| 0.15.1 | Base 地址修正:按 broadcast/DeployBase.s.sol/8453/run-latest.json 的 contractName 映射修正 Base 合约地址(SubscriptionManager 修正为 0x0f129e...) |
| 0.15.2 | B 端接入文档/样例:README 彻底重写(多链市场、接入全流程、模块参考、FAQ);examples/ 随包发布(mint-and-subscribe / conversation-bend / central-agent-bend) |
| 0.15.3 | USDT/USDC 计价:BSC/ETH/Base 三链 SubscriptionManager 登记 USDT/USDC 白名单;README 补稳定币地址表、精度差异(BSC 18 / ETH·Base 6)与 ERC20 订阅要点;mint-and-subscribe 支持 PAY_TOKEN=usdt/usdc |
| 0.16.0 | 浏览器钱包(EIP-1193)发布/运行打通:derivePrivateKeyFromSignature / deriveKeyPairFromWallet(personal_sign + HKDF-SHA256)、useAgentRunner 的 encryptionKeyDerivation opt-in、AgentRunnerConfig.unpack?/aesKeyHex? 与 WalletSigner.deriveEncryptionKey?() 密钥注入;错误语义修正(WALLET_NOT_CONNECTED 不再降级为 DECRYPTION_FAILED,DECRYPTION_FAILED 携带 cause);README 新增 §5.12 与托管链路隐私边界说明 |
| 0.17.0 | 链上属性写入助手:registry.setAttributes()(读回合并后整组写回,补写公开 skills 不必手写合约调用)/ overwriteAttributes() / toAttributes();配套托管对话多链修复(agentId 模式的 BSC / Ethereum / Base 404 与 403,见 §5.12) |
| 0.17.1 | DX 修复:eciesDecrypt() / unpackAgent() / getPublicKey() 的 privateKey 接受可选 0x 前缀(此前抛底层库的模糊错误),非法格式时报 Invalid private key: expected 32-byte hex…;UPGRADE.md 加入 npm 包 files(此前包内缺失);修正 package-lock.json 残留旧包名 @agentxv2/sdk。无 API 破坏 |
| 0.18.0 | payload 密钥派生 v2(EIP-712):encryptionKeyDerivation: { mode: 'eip712-hkdf' } 与 deriveKeyPairFromWallet({ mode, signTypedData }),把「被签的东西」从自由文本换成 EIP-712 typed data(buildPayloadKeyTypedData + DEFAULT_EIP712_*),HKDF 构造与 IKM 不变、infoPrefix 版本化为 agent-payload-encryption/v2;v1(personal-sign-hkdf)保留且行为不变,旧档案继续可解 |
| 0.19.0 | 密钥来源与发布护栏:① 托管密钥(服务端生成密钥对)写成受支持的一等用法(含安全建议 / 不可迁移性);② deriveKeyPairFromWallet() 默认开启 assertDeterministic(可关)并新增默认开启的 selfCheck;③ publishAgent({ verifyWithPrivateKey }) 铸造前 dry-run 解包校验(含 v1/v2 mode 用错);④ 新增跨实现的派生绑定字段 payload_key_mode(PAYLOAD_KEY_MODE_ATTRIBUTE / readPayloadKeyMode() / isDerivationMode() / AgentRegistry.setPayloadKeyMode());⑤ README 明确「签名路径固有边界」并收回 v2「来源可验证」的过度声明。若发布流程依赖单次签名提示,请显式传 assertDeterministic: false |
| 0.20.0 | payload 密钥派生 v3(WebAuthn PRF,非签名):新增 mode: 'webauthn-prf-hkdf',IKM 改为 WebAuthn PRF 扩展输出(32 字节,由浏览器按 RP ID 绑定),HKDF 构造与 v1/v2 逐字节一致、infoPrefix 版本化为 agent-payload-encryption/v3;配套 getPrfCapability()(不弹窗探测)/ createPrfCredential() / evaluatePrf() / PrfError(PRF_UNSUPPORTED 等 4 码,不支持时报可操作错误、绝不静默回退到签名派生);派生入口新增 derivePrivateKeyFromIkm() 与 DEFAULT_PRF_SALT / DEFAULT_DERIVATION_INFO_PREFIX_V3;新增 credential 绑定字段 payload_key_credential(PAYLOAD_KEY_CREDENTIAL_ATTRIBUTE / serializePayloadKeyCredential() / readPayloadKeyCredential() / AgentRegistry.setPayloadKeyCredential())。v1/v2 保留且行为不变,默认值不变、v3 为 opt-in |
| 0.20.1 | 纯文档:§5.12 新增「选型:四条路径怎么选」矩阵(v1 / v2 / v3 / 私钥托管并列对照),并明确「Runner 跑在服务端 ⇒ 私钥托管可完全规避 WebAuthn/PRF 的兼容性问题」及其代价(信任边界转移、不可轮换、丢失即永久解不开、提供的是服务端鉴权而非客户端自持 + 域绑定)。无代码 / API / 行为变更 |
附录:可运行示例
见 examples/:
examples/mint-and-subscribe.mjs—— 链上完整流程(选链 → 铸造 → 建计划 → 订阅 → 验证)examples/conversation-bend.mjs—— B 端托管对话(chat + stream,BYOK)examples/central-agent-bend.mjs—— 中心化市场(list / create / createPlan)
