diff --git a/docs/M1_DESIGN.md b/docs/M1_DESIGN.md new file mode 100644 index 00000000..dd725f46 --- /dev/null +++ b/docs/M1_DESIGN.md @@ -0,0 +1,399 @@ +# UltraRelay-AAStar · M1 产品设计 + +> **里程碑定位**:把本仓库交付为一个**完整、合规、可生产**的标准 ERC-4337 bundler,部署在 OP-Sepolia + OP-Mainnet。M1 之后才进入 M2(绿色通道)和 M3(X402 / 监控 / 上游自动化)。 +> +> **不在 M1**:trusted-paymaster 白名单、xPNTs 收费、X402、监控告警 webhook、RPC 缓存、上游同步自动化、postOp gas 精算。 +> +> **当前状态**:bundler 大部分协议能力继承自 ZeroDev fork of Pimlico Alto,已覆盖大半。M1 的工作主要是 (a) 确保所有继承能力在 OP-Sepolia/OP-Mainnet 实测通过;(b) 把 #12-#15 四个 AAStar 增量补完 e2e;(c) 加 HTTP rate limit;(d) 把上游同步治理的轨道铺好。 + +--- + +## 0 · M1 验收顺序 + +1. 部分 1(协议核心能力)—— 写测试覆盖矩阵,测一遍,签字 +2. 部分 2(ZeroDev 上游修改)—— 理解清楚为什么保留,写 e2e 防止误删 +3. 部分 3(AAStar fork 增量)—— 现有 PR 的 e2e 补齐,运维文档化 +4. 部分 4(M1 新加)—— rate limit + 上游同步治理 +5. 部分 5(部署运维)—— OP-Sepolia 灰度 → OP-Mainnet 上线 + +--- + +## 部分 1 · 协议核心能力(继承,需验收) + +> 这些能力由 Alto/ZeroDev 已经实现。**M1 工作 = 写测试 + 实测,不需要写新代码**。每条配上"如何验证"。 + +### 1.1 ERC-4337 多版本支持(v0.6 / v0.7 / v0.8) + +- **业务价值**:不同钱包/SDK 用不同 EntryPoint。AirAccount v7 用的是 v0.7,其他生态合作方可能还在 v0.6。bundler 必须三个版本都接得住,否则就把生态合作方挡在门外。 +- **必要性**:标准 ERC-4337 bundler 的最低门槛。无此则不算合规 bundler。 +- **流程**:bundler 启动时通过 `--entrypoints "0xv06,0xv07,0xv08"` 注册多个 EntryPoint 地址;每笔 UserOp 在 RPC 调用里携带 entryPoint 参数,bundler 按地址路由到对应版本的 handler。 +- **技术方案**: + - 已实现位置:`src/rpc/methods/*` 各方法内部按 `isVersion06 / isVersion07 / isVersion08` 分支;`src/rpc/validation/` 三个版本各有 `BundlerCollectorTracerV0X` 和 `TracerResultParserV0X` + - 验收:e2e 在每个版本上跑:账户部署 → `eth_sendUserOperation` → 等收据 → `eth_getUserOperationReceipt` + - **OP-Mainnet 主推 v0.7**(AirAccount v7 默认),v0.6/v0.8 保留兼容 + +### 1.2 标准 RPC 方法集 + +- **业务价值**:任何符合 ERC-4337 标准的 SDK / 钱包都能直接对接,无须为我们做特殊适配——这是"开放 bundler"业务诉求的协议基座。 +- **必要性**:标准必备,缺一项就不能自称 ERC-4337 bundler。 +- **流程**:客户端 POST 到 `/rpc` / `/:version/rpc` / `/`(`src/rpc/server.ts:128-130`)。 +- **技术方案**: + - 已实现:`eth_chainId` / `eth_supportedEntryPoints` / `eth_estimateUserOperationGas` / `eth_sendUserOperation` / `eth_getUserOperationByHash` / `eth_getUserOperationReceipt` + - 注册位置:`src/rpc/methods/index.ts` + - 验收:每个方法用 OP-Sepolia 跑 happy path + 至少 1 个 error path(如非法签名、余额不足) + +### 1.3 Pimlico 扩展 RPC 方法集 + +- **业务价值**:Pimlico 扩展接口已被生态广泛使用(permissionless.js 等 SDK 默认调用),保留可让任何用 Pimlico SDK 的开发者无缝切到我们 bundler。 +- **必要性**:保持 SDK 兼容性的"零摩擦切换"承诺。 +- **流程**:客户端调 `pimlico_*` 方法,bundler 按 namespace 分发。 +- **技术方案**: + - 已实现:`pimlico_getUserOperationGasPrice`(按链/拥塞返回 slow/standard/fast)、`pimlico_getUserOperationStatus`、`pimlico_sendUserOperationNow`(同步等收据)、`pimlico_simulateAssetChange` + - 验收:四个方法各跑一次,结果与 OP-Sepolia 上同等查询的实际值相符(例如 GasPrice 与链上 baseFee 一致) + +### 1.4 Debug 接口(dev/staging 启用,prod 关闭) + +- **业务价值**:本地开发、E2E 测试和 spec-tests 强依赖 debug 接口。bundler-spec-tests 用 `debug_bundler_setBundlingMode("manual")` + `debug_bundler_sendBundleNow` 来精确控制 bundle 时序。 +- **必要性**:spec-tests 通过的硬性前置条件。 +- **流程**:通过 `--environment development` 或 `--safe-mode` 配置启用;prod 默认关闭。 +- **技术方案**: + - 已实现:`debug_bundler_clearState / clearReputation / dumpMempool / dumpReputation / setReputation / sendBundleNow / setBundlingMode / getStakeStatus` + - 验收:`pnpm run test:spec` 全套 eth-infinitism bundler-spec-tests 通过 + +### 1.5 Bundling 模式(auto / manual) + +- **业务价值**:生产用 auto;spec-tests 和压力测试用 manual。可切换是测试可重现性的前提。 +- **必要性**:spec-tests 强依赖 manual 模式。 +- **流程**:`--bundle-mode auto`(默认按时间/数量打包)或 `--bundle-mode manual`(仅 `debug_bundler_sendBundleNow` 触发)。 +- **技术方案**: + - 已实现:`src/executor/executorManager.ts` 的 bundling loop + - 验收:在 manual 模式下提交 UserOp 不上链,调 `debug_bundler_sendBundleNow` 后才上链 + +### 1.6 Validation 模式(safe / unsafe,ERC-7562 tracer) + +- **业务价值**:生产用 safe(启用 ERC-7562 tracer 拦截恶意 paymaster/factory);本地开发或不支持 `debug_traceCall` 的 RPC 用 unsafe。 +- **必要性**:safe 是生产合规要求;unsafe 是本地开发兜底。 +- **流程**:`--safe-mode true`(默认)/ `--safe-mode false`。safe 模式下 bundler 对每笔 op 跑 `debug_traceCall` 抓取 opcode 和 storage 访问,按 `TracerResultParserV07.ts` 拒绝违规 op。 +- **技术方案**: + - 已实现:`src/rpc/validation/SafeValidator.ts` (safe) vs `UnsafeValidator.ts` (unsafe) + - 验收:safe 模式下 spec-tests 全套通过;unsafe 模式下能在本地不支持 traceCall 的 anvil 上跑 + +### 1.7 Storage 后端(in-memory / Redis) + +- **业务价值**:单机开发用 in-memory,零依赖;生产用 Redis 共享 mempool 状态,支持多 bundler 实例水平扩展和重启不丢 op。 +- **必要性**:生产环境多实例 + 高可用要求。 +- **流程**:默认 in-memory;配 `--enable-horizontal-scaling true` + `--redis-endpoint redis://...` 切到 Redis。可选附加配置:`--enable-redis-receipt-cache`、`--redis-key-prefix`(默认 `alto`)、`--redis-events-queue-endpoint` + `--redis-events-queue-name`(独立的 userOp 事件队列)。 +- **技术方案**: + - 已实现:编排入口 `src/store/createMempoolStore.ts:51-100` 按 `enableHorizontalScaling && redisEndpoint` 分支调用 `createRedisOutstandingQueue` / `createRedisStore`,否则调用 `createMemoryOutstandingQueue` / `createMemoryStore`(注意函数名是 `*OutstandingQueue` 不是 `*OutstandingStore`) + - 验收:双后端各跑一次完整 e2e;Redis 后端额外测重启场景(kill bundler → 重启 → mempool 恢复) + +### 1.8 Gas 处理器(M1 主推 EVM 默认 + Optimism) + +- **业务价值**:OP-Mainnet 是 L2,gas 计算包含 L1 数据费。直接用 EVM 默认 oracle 会严重低估,导致 op 上链 OOG。`optimismManager` 把 L1 数据费纳入。 +- **必要性**:上 OP-Mainnet 必备。 +- **流程**:dispatch 由运维显式配置 `--chain-type` 决定(`src/cli/config/options.ts:471-484`,choices: `default | op-stack | arbitrum | hedera | mantle | abstract | etherlink`),**不是按 chainId 自动选**。OP-Sepolia / OP-Mainnet 必须显式 `--chain-type op-stack`。 +- **技术方案**: + - 已实现:L2-fee 分支位于 `src/utils/preVerificationGasCalulator.ts:360-377`(`switch (config.chainType)` → op-stack/arbitrum/mantle)、`src/executor/filterOpsAndEstimateGas.ts:62-102`、`src/executor/executor.ts:110,118` + - M1 验收:OP-Sepolia + OP-Mainnet 跑通,对比同笔 op 的估算值与实际链上消耗,误差 < 5% + - Arbitrum / Mantle handler 保留代码、不上线、不测试(保留是为了 merge 上游不破坏) + - **如果运维忘配 `--chain-type op-stack`**:bundler 静默 fall back 到默认 oracle,preVerificationGas 严重低估(不计 L1 数据费)→ UserOp 上链 OOG,executor wallet 烧空 gas 但 op 失败。详见 `docs/RUNBOOK.md` §1.1 + +--- + +## 部分 2 · ZeroDev 上游核心修改(继承,理解为什么保留) + +> 我们 fork 自 ZeroDev/ultra-relay 而不是直接从 Pimlico/alto,**就是为了拿这一组修改**。每条都要理解清楚,避免未来 merge 上游时被误删。 + +### 2.1 Boost endpoint:`boost_sendUserOperation`(relayer-without-paymaster) + +- **业务价值**:传统 ERC-4337 流程要求 UserOp 要么自带 ETH 要么挂 paymaster。Boost endpoint 让 bundler 直接以"运营商身份"垫 ETH——这对"内部生态、bundler 是同一运营方"的场景天然契合:用户根本不需要 paymaster,bundler 用 utility wallet 出 ETH,对账在链下完成。 +- **必要性**:这是 ZeroDev fork 区别于 Pimlico 主线的**核心增量**。AAStar 业务里"内部 SuperPaymaster + xPNTs UserOp"理论上可以走 boost 路径(不带 paymaster,直接由 bundler 垫付,xPNTs 在链下另算)——M2 的绿色通道实现可能复用这条通道,所以 M1 必须确保它工作。 +- **流程**: + 1. 客户端调 `boost_sendUserOperation(userOp, entryPoint)` + 2. bundler 校验 `userOp.maxFeePerGas == 0` 且 `maxPriorityFeePerGas == 0`,且**不带任何 paymaster 字段**(v0.6 要求 `paymasterAndData == "0x"`;v0.7 要求所有 paymaster 字段为空) + 3. 校验通过 → bundler 按"自己出 gas"模式打包 → utility wallet 签 handleOps tx 上链 +- **技术方案**: + - 已实现:`src/rpc/methods/boost_sendUserOperation.ts:9-39` 校验函数;`addToMempoolIfValid({ ..., boost: true })` 走 boost 分支 + - 验收:在 OP-Sepolia 提交一笔零 fee、零 paymaster 的 UserOp,确认上链且 utility wallet 余额减少 + - **保留 PR #11 的修复**:boost 路径下 simulation 不要做 sender balance override(因为 sender 真没钱、是 bundler 在垫) + +### 2.2 移除非必要的 sender balance override(PR #2、PR #11) + +- **业务价值**:默认 simulation 会给 sender 和 paymaster 做 balance override(强行让模拟阶段余额够),这在某些场景(例如 boost、某些 L2 上的 verifying paymaster)会掩盖真实失败。ZeroDev 移除了不必要的 override,让 simulation 反映真实链上行为。 +- **必要性**:避免"模拟通过、上链失败"的假阳性,减少 utility wallet 烧空 gas 的事故。 +- **流程**:bundler simulation 阶段对 sender/paymaster 不做 balance override;只对 EntryPoint deposit 做必要 override。 +- **技术方案**: + - 已实现:`src/rpc/estimation/` 和 `src/rpc/validation/` 内 simulation 调用的 stateOverride 参数 + - 验收:boost 路径 e2e 已经覆盖(同 2.1) + +### 2.3 结构化 JSON 日志(PR `feat: enable structured JSON logging in production builds`) + +- **业务价值**:JSON 日志可以直接被 Loki / CloudWatch / Datadog 摄取,按 sender/paymaster/userOpHash 字段查询失败链路。文本日志在生产基本不可用。 +- **必要性**:生产可观测性的最低基线。 +- **流程**:bundler 启动时按 `NODE_ENV` / `--log-format json` 切换日志格式。Pino 自动按 level 输出。 +- **技术方案**: + - 已实现:Pino + 自定义 serializer(BigInt → hex),见 `src/utils/` + - M1 验收:在 OP-Sepolia 部署后,日志能被 stdout 收集、JSON 行可被 jq 解析;hex revert reason 解码工作正常(PR `fix: decode hex-encoded revert reasons`) + +### 2.4 `--max-bundle-count` 每次 getBundles 迭代的每 entrypoint bundle 数上限 + +- **业务价值**:单次 getBundles 调用凑出的 bundle 过多,会把 executor 队列打爆、延误后续 op。上限限速。 +- **必要性**:生产稳定性。 +- **流程**:`getBundles(maxBundleCount)` 对每个 entrypoint 分别限制 bundle 产出数;多 entrypoint 场景最多返回 `entrypoints × maxBundleCount` 个 bundle。 +- **技术方案**: + - 已实现:`src/cli/config/options.ts` 的 `--max-bundle-count` flag;`executorManager.ts` 的 `autoScalingBundling()` 已正确传入 `this.config.maxBundleCount` + - M1 验收:配置生效,灰度跑通 + +### 2.5 详细日志 + UserOp drop 原因(PR `Add detailed logging for UserOp drops`) + +- **业务价值**:op 被 drop 的理由(reputation、validation 失败、过期)必须能精确追踪到,否则 SDK 侧调试无从下手。 +- **必要性**:开发者支持效率。 +- **流程**:每次 drop 在日志里输出 `{ userOpHash, reason, paymaster, sender, code }`。 +- **技术方案**: + - 已实现:`src/mempool/mempool.ts` drop 路径上的 logger.warn 调用 + - M1 验收:人工触发几种 drop(reputation throttled、validation revert、过期)确认日志完整 + +--- + +## 部分 3 · AAStar fork 增量(PR #12-#15,已落地,需补 e2e + 文档) + +### 3.1 PR #12 — `--block-tag-support` 控制 getLogs 调用 + +- **业务价值**:部分 L2 / Rollup(特别是新链)的 RPC 不支持 `eth_getLogs` 的 block tag(如 "latest"、"finalized"),只接受具体区块号。bundler 默认带 block tag 调用会在这种链上直接报错。这条 flag 让我们在不支持的链上自动 fallback 到 block 号方式。 +- **必要性**:扩链能力——AAStar 想覆盖的链不止 OP,未来上 Linea / Scroll / 自家 Rollup 都可能撞上这个问题。 +- **流程**:启动时配 `--block-tag-support true|false`(按链查"推荐配置矩阵"决定)。`true` 时用 block tag(节省一次 `eth_blockNumber`);`false` 时先查 block number 再用具体数字调 getLogs。 +- **技术方案**: + - 已实现:`src/cli/config/options.ts` 加 flag;实际生效位置仅两处——`src/executor/bundleManager.ts:456`(getLogs)和 `src/rpc/methods/eth_getUserOperationByHash.ts:43`(getLogs) + - **范围说明**:该 flag 语义只覆盖 `getLogs` 调用。OP-Sepolia / OP-Mainnet 都支持 block tag,业务不受影响。其他 RPC 调用(`getTransactionCount`、`getBalance` 等)在 `executor.ts` / `executorManager.ts` / `gasPriceManager.ts` / `rpcHandler.ts` / `utils.ts` 中仍硬编码 `blockTag: "latest"`。**如未来上链不支持 block tag 的链(如某些 alt-L2),需扩展到所有 RPC 调用——目前不在 M1 范围** + - M1 验收: + - 在 OP-Mainnet 配 `true` 跑通(OP 支持) + - 在某条不支持的链(如本地 anvil 模拟拒绝 block tag)配 `false` 跑通 + - 文档化:`docs/CHAIN_CONFIG.md`(M1 一并产出)列每条目标链的推荐值 + +### 3.2 PR #13 — `authorizationList` in estimateGas(EIP-7702 路径) + +- **业务价值**:EIP-7702 让 EOA 可以临时挂 smart wallet 代码(`SET_CODE_TX_TYPE = 0x04`),是 AirAccount/账户抽象演进的下一站。bundler 在 estimateGas 阶段需要把 UserOp 里的 `authorizationList` 一并塞给 underlying RPC,否则 estimate 不准确(gas 算少了上链 OOG)。 +- **必要性**:AirAccount 团队规划中的 EIP-7702 升级路径要求 bundler 支持。M1 至少把通路打通,实战验收推到 M3。 +- **流程**:UserOp 携带 `authorizationList` → bundler `eth_estimateUserOperationGas` 内部调 `eth_estimateGas` 时透传该字段 → 仅当 `--rpc-gas-estimate` 模式启用时生效。 +- **技术方案**: + - 已实现:`src/rpc/estimation/` 估算路径 + - M1 验收:单元测试覆盖即可,e2e 推到 M3 + +### 3.3 PR #14 — RPC basic auth 支持 + +- **业务价值**:很多 RPC provider(Alchemy / QuickNode / 自建 Geth)支持 basic auth 隔离 endpoint。bundler 同时维护 public client(读链)和 wallet client(发 tx),都要能配 basic auth。 +- **必要性**:上线 OP-Mainnet 用付费 RPC provider 时必须。 +- **流程**:通过两个独立 CLI flag 显式配置——`--rpc-basic-auth-username ` + `--rpc-basic-auth-password `(`src/cli/config/options.ts:584-593`)。bundler 在 `customTransport` 内构造 `Authorization: Basic ` header(`src/cli/customTransport.ts:18-41`),注入 viem 的 transport fetch options。 +- **技术方案**: + - 已实现:`src/cli/customTransport.ts:18-41` 的 `getRpcFetchOptions`;同一组 credentials 在 `src/cli/handler.ts:124-198` 同时应用到 public client 和 wallet client + - **caveat**:`--send-transaction-rpc-url` 与 main `--rpc-url` 共用同一组 basic auth credentials,**无独立 auth 支持**。如需 send-tx RPC 用不同 credentials,当前架构需改造(M2/M3 评估) + - M1 验收:用一个真实带 basic auth 的 OP-Mainnet RPC 配置跑通;对比明文 URL 配置确认行为一致 + +### 3.4 PR #15 — `/wallets` HTTP 端点 + +- **业务价值**:运营方需要快速查到 bundler 当前在用的 executor 钱包地址列表(监控余额、做 dashboard、上链查询 nonce)。从配置文件 grep 不可靠(多实例、私钥派生不同地址)。HTTP 端点是单一真相源。 +- **必要性**:运维可观测性的最小集——比 Prometheus 指标更直接,DevOps 一条 curl 就能查。 +- **流程**:`GET /wallets` → 返回 `{ wallets, chainId, utilityWalletAddress, refillingWallets }`(upstream PR #17 已扩展,包含 utility 与 refilling pool)。 +- **技术方案**: + - 已实现:`src/rpc/server.ts` 的 `getWallets` handler(merge upstream `3f3bc2c` 后字段已齐全) + - M1 验收:部署后 curl 能拿到 executor / utility / refilling 三组地址;对比 chain explorer 上 executor 钱包发出的 tx,地址匹配;e2e `test/e2e/tests/wallets.test.ts` 覆盖三种字段断言 + EIP-55 checksum 校验 + +--- + +## 部分 4 · M1 新加 feature + +### 4.1 HTTP rate limit(按 IP) + +- **业务价值**:bundler 是公网开放服务(生态内任何 SDK 都能调)。没有限流时一个 buggy 客户端或恶意脚本能瞬间把 bundler 的 RPC quota 烧干、把 mempool 灌满。 +- **必要性**:上 OP-Mainnet 公网部署的最低安全门槛。**M1 不加,后面任何 DDoS 都需要紧急修。** +- **流程**: + 1. bundler 启动时读 `--rate-limit-*` 一组参数(或 config 文件) + 2. Fastify 注册 `@fastify/rate-limit` 插件,按 IP 限流 + 3. 超限请求返回 HTTP 429 + `Retry-After` header + 4. 白名单 IP(运营方自己的 SDK 服务器、监控系统)通过配置免限流 +- **技术方案**: + - 依赖:`@fastify/rate-limit` (已有 Fastify 生态官方插件) + - 实现位置:`src/rpc/server.ts` 的 setupServer 阶段,注册插件 + - 配置项(CLI flag + config 文件双通道,按 ZeroDev 已有的 CLI 风格扩展): + - `--rate-limit-enabled true|false`(默认 true) + - `--rate-limit-max 100`(每窗口最大请求数) + - `--rate-limit-window-ms 60000`(窗口长度,默认 1 分钟) + - `--rate-limit-allowlist "1.2.3.4,5.6.7.8"`(豁免 IP 列表) + - `--rate-limit-config-file ./rate-limit.json`(高级配置文件,按 method 分别限流,可选) + - **可配置文件**示例(用户要求): + ```json + { + "global": { "max": 100, "windowMs": 60000 }, + "perMethod": { + "eth_sendUserOperation": { "max": 30, "windowMs": 60000 }, + "boost_sendUserOperation": { "max": 30, "windowMs": 60000 }, + "eth_estimateUserOperationGas": { "max": 60, "windowMs": 60000 } + }, + "allowlist": ["1.2.3.4"] + } + ``` + - M1 验收: + - 单元测试:超限返回 429 + - 灰度测试:用 `wrk` 或 `k6` 打 200 req/min,确认前 100 通过、剩下被 429 + - 配置 allowlist:白名单 IP 不被限 + +### 4.2 上游同步治理 + +- **业务价值**:业务目标 1 是"持续跟住 ZeroDev 上游"。当前 git 没配 upstream remote,过去 PR #5 是手动 cherry-pick,没有可重复流程,长期会越漂越远。 +- **必要性**:fork 治理基线——没这个就没法保证我们继承上游 bug fix 和新 feature。 +- **流程**: + 1. **一次性配置**:`git remote add upstream git@github.com:zerodevapp/ultra-relay.git` + 2. **每月例行**(手动,自动化推到 M3): + a. `git fetch upstream` + b. `git checkout main && git merge upstream/main` → 推到我们的 `main` 分支(保持镜像) + c. 在 `main` 上开 PR 把 `aastar-dev` rebase/merge `main`,处理冲突 + d. CI 跑通后 merge 到 `aastar-dev` + 3. **冲突处理依据**:CLAUDE.md 已有"AAStar additions"清单(CLAUDE.md:14-15),冲突时按清单判断哪些是"我们的"必须保留 + 4. **fork-specific 改动注册表**(M1 产出 `docs/FORK_DELTA.md`):每条增量列 `PR # | 文件 | 语义 | 上线日期` +- **技术方案**: + - 文档化:`docs/UPSTREAM_SYNC.md` 描述步骤 + - `docs/FORK_DELTA.md` 维护增量清单(M2/M3 任何新增都更新这里) + - **不写代码**——纯流程 + - M1 验收:跑一次完整流程(即便上游没新东西,也走一遍 fetch + diff + 文档更新) + +--- + +## 部分 5 · 部署与运维(已存在但需验收) + +### 5.1 `/health` 健康检查端点 + +- **业务价值**:负载均衡器 / k8s liveness probe / 监控系统都靠这个判断 bundler 是否存活。 +- **必要性**:生产部署必备。 +- **流程**:`GET /health` → 200 OK + JSON `{ status: "ok" }` 或类似。 +- **技术方案**: + - 已实现:`src/rpc/server.ts:155` + - M1 验收:部署后 curl 200;kill underlying RPC 后端确认是否要返回 503(取决于现有实现,验收时确认行为) + - **不改代码**,只验收 + +### 5.2 `/metrics` Prometheus 端点 + +- **业务价值**:所有指标(mempool size、bundle 提交速率、failure rate、wallet balance)都通过这里被 Prometheus 抓取。 +- **必要性**:生产可观测性。 +- **流程**:`GET /metrics` → Prometheus exposition format。 +- **技术方案**: + - 已实现:`src/rpc/server.ts:156` + - M1 验收:部署后 curl 拿到指标列表;用 prom2json 验证格式合规 + - **不改代码**,只验收。配 Prometheus scrape + Grafana dashboard 推到 M3 + +### 5.3 `utilityWalletMonitor` 余额监控 + +- **业务价值**:utility wallet 出 boost 路径 ETH,executor wallet 出常规 handleOps gas。任何一个余额耗尽 bundler 立刻停摆。 +- **必要性**:生产稳定性的最低保障。 +- **流程**:bundler 内部周期检查 utility wallet 余额,低于 `--min-balance` 在日志告警。 +- **技术方案**: + - 已实现:`src/executor/utilityWalletMonitor.ts` + - M1 验收:`--min-balance` 配一个高于实际余额的值,确认日志告警 + - 告警 webhook(Slack / Discord)推到 M3 + +### 5.4 Docker 部署 + +- **业务价值**:生产环境一键部署,环境一致性。 +- **必要性**:上线 OP-Mainnet 必备。 +- **流程**:`docker build -f Dockerfile -t ultra-relay-aastar .` → 推 ECR → 跑容器。 +- **技术方案**: + - 已实现:根目录 `Dockerfile`、`.dockerignore` + - PR #6(`Add CI to build and push to ECR`)已合,需要在 M1 验收 ECR 推送是否真的工作 + - M1 验收:CI 推一次镜像;从 ECR 拉镜像在 OP-Sepolia 跑通 + +--- + +## 5.5 Known Limitations(M1 不修,文档化) + +### Limitation 1: JSON logging fallback +- **现象**:`--json true` 在未配置 `BETTER_STACK_TOKEN` 环境变量时,fallback 到 pino-pretty 彩色输出,并非 JSON 格式 +- **来源**:上游 ZeroDev fork 自带(`src/utils/logger.ts` 中 `initProductionLogger` 的 `if (!transport) return initDebugLogger(level)` 分支) +- **upstream 已合的相关 fix(不解决根因)**:`520f27a fix: add error handler on logtail pino transport (#18)` + `0993646 fix: noop dead logtail transport to prevent request timeouts (#19)` — 这两个 fix 让有 token 时 logtail 异常更稳,但**未改 fallback 逻辑**,无 token 时仍 pino-pretty +- **业务影响**:若需把日志摄入 Loki / CloudWatch / Datadog,必须配置 `BETTER_STACK_TOKEN`(即便不真用 Better Stack,也要设一个),或日志聚合系统直接解析 pino-pretty 的 stdout 文本(多数 aggregator 支持) +- **M1 处理**:不修代码(最小化原则)。M3 配 Loki / Grafana 时若证明真有问题,再 fork branch 修并提 PR 给 zerodevapp/ultra-relay +- **临时绕过**:在容器/服务环境变量加 `BETTER_STACK_TOKEN=dummy`(注意这会让 pino 试图连真的 Better Stack endpoint 失败,但 stdout 部分会工作。或者部署期日志走 stderr 由 sidecar 拦截) + +### Limitation 2: eth_sendUserOperation 隐式 boost 升级 +- **现象**:`eth_sendUserOperation` 接到 `maxFeePerGas == 0 && maxPriorityFeePerGas == 0` 的 op 时,自动升级为 boost 模式(bundler utility wallet 垫付 ETH),不需要客户端显式调 `boost_sendUserOperation` +- **来源**:上游 ZeroDev fork 设计(`src/rpc/methods/eth_sendUserOperation.ts:230-236`) +- **业务定位**:ZeroDev 的产品定位是 "relayer-without-paymaster",bundler 默认承担垫付,他们的安全前提是 HTTP 入口已有 API key 鉴权 +- **我们的安全前提**:M1 §4.1 加的 HTTP rate limit + IP allowlist 是入口防线。**prod 部署时必须配 IP allowlist 只允许 AAStar 自己的 SDK 服务器/后端 IP**(详见 `docs/RUNBOOK.md`),否则任何外部 IP 都能让 bundler 垫付 gas +- **阶段处理**:M1 / M2 不修代码(行为合理)。M3 启动 X402 收费时必须加 `--allow-implicit-boost false` flag 切换语义——届时这条限制升级为阻塞器并修复 + +### Limitation 3: --max-bundle-count 描述误导(已修复) +- **原现象**:CLI flag 描述说 "Maximum number of UserOperations to include in a bundle",实际行为是**每 entrypoint** 限制单次 `getBundles()` 产出的 bundle 数 +- **来源**:上游 ZeroDev fork(`src/cli/config/options.ts`,实际行为 `src/mempool/mempool.ts` `getBundles()`/`process()` 方法) +- **已修复**:description 已更新为"Maximum bundles per entrypoint per getBundles iteration";`executorManager.ts` `autoScalingBundling()` 已传入 `maxBundleCount`;多 entrypoint 场景总 bundle 数 = entrypoints × maxBundleCount(per-entrypoint 语义,非全局上限) +- **上游 PR**:cleanup 部分已提 zerodevapp/ultra-relay PR #27(见 `docs/UPSTREAM_PR_QUEUE.md`) + +### Limitation 4: --block-tag-support 范围 +(同 §3.1 已加说明,此处只引用)该 flag 仅影响 `getLogs` 调用;其他 RPC 调用仍硬编码 `blockTag: "latest"`。 + +### Limitation 5: Drop 日志结构化字段 +- **现象**:UserOp drop 日志中 `sender` / `paymaster` / `factory` 嵌在 stringified userOp 里,不是顶级 JSON key +- **来源**:上游 ZeroDev fork(`src/mempool/mempool.ts:155-180`) +- **业务影响**:M1 不影响(pino-pretty 肉眼可读);M3 配日志聚合时 filter 困难 +- **处理**:M3 监控成熟时一并修,给 zerodevapp/ultra-relay 提 PR + +--- + +## 6 · 验收检查表(最终签字依据) + +| # | Feature | 类型 | 验收方式 | 状态 | +|---|---------|------|---------|------| +| 1.1 | EntryPoint v0.6/v0.7/v0.8 | 协议 | 三版本各一笔 e2e on OP-Sepolia | ☐ | +| 1.2 | 标准 RPC 6 个方法 | 协议 | 每方法 happy + 1 error path | ☐ | +| 1.3 | Pimlico 扩展 4 个方法 | 协议 | 每方法实测 OP-Sepolia | ☐ | +| 1.4 | Debug 接口 | 协议 | `pnpm run test:spec` 全过 | ☐ | +| 1.5 | Bundling 模式 auto/manual | 协议 | manual 模式下 sendBundleNow 才上链 | ☐ | +| 1.6 | Validation safe/unsafe | 协议 | safe 模式 spec-tests 全过;unsafe 在 anvil 跑通 | ☐ | +| 1.7 | Storage in-memory + Redis | 协议 | 双后端各 e2e;Redis 重启场景 | ☐ | +| 1.8 | OP gas oracle | 协议 | OP-Sepolia + OP-Mainnet 估算误差 < 5% | ☐ | +| 2.1 | Boost endpoint | ZeroDev | OP-Sepolia 零 fee 零 paymaster e2e | ☐ | +| 2.2 | Sender balance override 移除 | ZeroDev | 同 2.1 覆盖 | ☐ | +| 2.3 | JSON 日志 | ZeroDev | jq 可解析 + revert reason 解码 | ☐ | +| 2.4 | maxBundleCount | ZeroDev | 配置生效,灰度跑通 | ☐ | +| 2.5 | UserOp drop 详细日志 | ZeroDev | 触发 3 种 drop 看日志 | ☐ | +| 3.1 | block-tag-support | AAStar | OP-Mainnet 跑通 + chain config 文档 | ☐ | +| 3.2 | EIP-7702 estimateGas | AAStar | 单元测试覆盖 | ☐ | +| 3.3 | RPC basic auth | AAStar | 真带 basic auth 的 RPC 跑通 | ☐ | +| 3.4 | /wallets 端点 | AAStar | curl 返回正确地址 | ☐ | +| 4.1 | HTTP rate limit | M1 新加 | 429 测试 + allowlist 测试 | ☐ | +| 4.2 | 上游同步治理 | M1 新加 | UPSTREAM_SYNC.md + FORK_DELTA.md + 跑一次流程 | ☐ | +| 5.1 | /health | 运维 | curl 200 | ☐ | +| 5.2 | /metrics | 运维 | prom2json 验证 | ☐ | +| 5.3 | utilityWalletMonitor | 运维 | 触发余额告警日志 | ☐ | +| 5.4 | Docker 部署 | 运维 | ECR 推 + 拉镜像跑通 | ☐ | +| J | OP-Sepolia 部署灰度 | 部署 | 24h 稳定运行 + 100+ 笔 op | ☐ | +| K | OP-Mainnet 部署上线 | 部署 | 灰度 N 笔标准 SuperPaymaster + xPNTs UserOp | ☐ | + +--- + +## 7 · M1 输出物清单 + +代码改动: +- `src/rpc/server.ts` — 注册 `@fastify/rate-limit` 插件 +- `src/cli/config/options.ts` — 加 `--rate-limit-*` 系列 flag +- `package.json` — 加 `@fastify/rate-limit` 依赖 + +文档(新增): +- `docs/M1_DESIGN.md` — 本文件 +- `docs/UPSTREAM_SYNC.md` — 上游同步流程 +- `docs/FORK_DELTA.md` — fork-specific 改动注册表 +- `docs/CHAIN_CONFIG.md` — 每条目标链的推荐配置(block-tag-support、gas oracle 等) +- `docs/M1_ACCEPTANCE.md` — 验收检查表(同 §6,独立成文便于打钩归档) +- `docs/RUNBOOK.md` — 运营手册:M1 prod 部署的必配项、部署清单、月度上游同步、常见故障排查、版本与回滚 +- `docs/UPSTREAM_PR_QUEUE.md` — 跟踪需要给 zerodevapp/ultra-relay 提的 PR 队列 + +不动: +- `src/rpc/methods/*` — 无新方法 +- `src/mempool/*` — reputationManager 已合规 +- `src/rpc/validation/*` — 不放宽任何规则 +- 任何合约 — bundler 不出合约改动 + +--- + +## 8 · M1 → M2 切换条件 + +M1 §6 验收表全部打钩 + OP-Mainnet 稳定运行 1 周后,启动 M2(绿色通道 / trusted-paymasters)。 + +M2 设计文档在 M1 验收完成后写。 diff --git a/docs/M2_DESIGN.md b/docs/M2_DESIGN.md new file mode 100644 index 00000000..c928dc8b --- /dev/null +++ b/docs/M2_DESIGN.md @@ -0,0 +1,781 @@ +# UltraRelay-AAStar · M2 产品设计 + +> **里程碑定位**:在 M1 完成"标准合规 ERC-4337 bundler"之上,给**白名单内的 trusted paymaster**(首发 SuperPaymaster v3)开一条**绿色通道(Fast Lane)**——同样的合规验证、同样的安全门槛,但出队优先 + 立即广播 + 0 priority fee。M2 之后才进入 M3(X402 收费 / attestation / 监控自动化)。 +> +> **不在 M2**: +> - **不放宽** ERC-7562 验证规则(safe 模式 tracer 一律照跑,opcode/storage 限制不动) +> - **不收费**——xPNTs / X402 计费链路推到 M3 +> - **不协调 SuperPaymaster 加 attestation 字段**——M2 仅"运营白名单 + 合约自身 sponsorship 资格"双闸;attestation/链上信任绑定是 M3 的 C 方案 +> - **不放过任何 op**——白名单只决定优先级,不绕过任何 simulation / reputation / paymaster 自身校验 +> +> **判定方案(已敲定,本文不再讨论备选)**:方案 A — **仅看 paymaster 地址**。`userOp.paymaster (v0.7) / paymasterAndData[0:20] (v0.6)` ∈ trusted-paymasters 白名单 ⇒ 走 Fast Lane。不看 sender、不看 callData、不看 factory(factory 留作 M2 §6 可选双因子的扩展点)。 +> +> **当前状态**:bundler 已具备 boost endpoint(ZeroDev §2.1)和完整 mempool / executor 通路,M2 的工作是 (a) 加 trusted-paymasters 配置;(b) 抽象 paymasterProfiles 插件骨架;(c) 在 RPC 入队 / mempool 出队 / executor 计费 三个点接入 Fast Lane 标记。**全部增量集中在 ≤ 5 个 hook 位置**,不动验证逻辑、不动协议核心。 + +--- + +## 0 · M2 验收顺序 + +1. §1 配置层(CLI flag + 配置文件)—— 配进来能读到,先打印日志验证 +2. §2 paymasterProfiles 插件骨架 + SuperPaymaster v3 profile —— 单元测试覆盖判定矩阵 +3. §3 三处核心 hook 接入(RPC 入口标记 / mempool 优先队列 / executor fee 策略) +4. §4 EIP-7702 完整实战 —— authorizationList 在 estimate / simulation / handleOps 全链路透传 + OP-Mainnet e2e +5. §5 安全分析复盘 —— 把"为什么不放过任何 op"写到 `docs/SECURITY_M2.md` 作为审计依据 +6. §6 (可选) 双因子识别 —— 评估是否纳入 M2,否则推 M3 +7. §7 验收(单元 + e2e on OP-Sepolia + 安全测试) +8. §8 验收检查表打钩 +9. §9 输出物归档 +10. §10 进入 M3 切换条件 + +--- + +## 1 · trusted-paymasters 配置 + +### 1.1 CLI flag `--trusted-paymasters` + +- **业务价值**:运营方需要一种"零依赖、最小配置"的方式声明哪些 paymaster 走绿色通道——比如本地开发、单链测试网、灰度 OP-Sepolia 这些场景,命令行传一行 `--trusted-paymasters 0xAddr1,0xAddr2` 就完事,比配文件更轻。这是 M2 落地的最小可用形态(MVP)。 +- **必要性**: + - 没有这个 flag,绿色通道就只能写死在源码里——不可运维、改一次要重新构建镜像 + - 与 ZeroDev 现有 CLI 风格(`--entrypoints "0xv06,0xv07,0xv08"`)一致,避免引入新概念 + - 方案 A(仅看 paymaster 地址)天然只需要一个地址列表,命令行能装下 +- **流程**: + 1. bundler 启动时解析 `--trusted-paymasters "0xAddr1,0xAddr2"` 到 `config.trustedPaymasters: Address[]`(空数组 = Fast Lane 关闭) + 2. 启动后日志打印 `{ trustedPaymasterCount: N, addresses: ["0x...", ...] }` + 3. 运行时由 paymasterProfiles 注册器把 CLI 列表合并到内置 profile(CLI 列表优先级高于内置默认) +- **技术方案**: + - 改动位置:`src/cli/config/options.ts` 加 flag + - 类型:以逗号分隔的 0x 地址列表,启动时校验 checksum / 长度 / 重复 + - 默认值:`[]`(不传 = Fast Lane 关闭,行为完全等同 M1) + - 配置注入:`src/createConfig.ts` 把解析结果挂到 `AltoConfig.trustedPaymasters` + - 校验:地址非法 / 长度不为 20 字节 / 重复 ⇒ 启动 fail-fast,**不静默忽略**(避免运维以为生效了实际没生效) + - 验收: + - 单元测试:传 `--trusted-paymasters 0xabc...,0xdef...` 解析得到长度为 2 的数组 + - 单元测试:传非法地址(如 `0x123`)启动 fail-fast + - e2e:OP-Sepolia 启动带 SuperPaymaster v3 OP-Sepolia 合约地址,`/wallets` 或专用 `/admin/trusted-paymasters`(见 §1.3)endpoint 能查到 + +### 1.2 配置文件 `--trusted-paymasters-file` + +- **业务价值**:生产环境一个 bundler 实例可能要服务多条链(虽然 M2 主推 OP-Sepolia + OP-Mainnet 各一个 bundler,但插件骨架要为多链留口子)。命令行传地址在多链/多 paymaster 场景下不可维护。配置文件按 `chainId` 分组,**同一个 bundler 启动不同 chain 时自动选对应组**——也方便运维做 GitOps(配置进 repo、版本化、PR 评审)。 +- **必要性**: + - 多链场景下配置必须文件化 + - SuperPaymaster v3 在 OP-Sepolia 和 OP-Mainnet 部署的合约地址不同(`docs/CHAIN_CONFIG.md` 已有的"按链推荐配置"延伸) + - 配置文件比 CLI flag 更利于后续 §1.3 的热更 +- **流程**: + 1. bundler 启动时如果传了 `--trusted-paymasters-file ./trusted-paymasters.json`,先读文件 + 2. 文件按 chainId 分组:`{ "11155420": [{ address, name, profile }], "10": [...] }` + 3. 启动时按 `config.chainId` 选出当前链的 paymaster 列表 + 4. 与 §1.1 CLI flag 合并:CLI 列表追加在文件列表之后,去重以**文件列表为基准**(CLI 是临时 override / 调试通道) + 5. 启动后日志打印 `{ chainId, source: "file+cli", paymasterCount: N, paymasters: [{address, name}] }` +- **技术方案**: + - 改动位置:`src/cli/config/options.ts` 加 flag;`src/cli/config/loadTrustedPaymasters.ts` 新文件实现解析 + 合并 + - 配置文件 schema(Zod 验证): + ```jsonc + { + "$schema": "./trusted-paymasters.schema.json", + "chains": { + "11155420": [ + { + "address": "0xSuperPaymasterV3OnOpSepolia", + "name": "SuperPaymaster v3 (OP-Sepolia)", + "profile": "superpaymaster-v3", + "notes": "AAStar 内部 + xPNTs 业务" + } + ], + "10": [ + { + "address": "0xSuperPaymasterV3OnOpMainnet", + "name": "SuperPaymaster v3 (OP-Mainnet)", + "profile": "superpaymaster-v3" + } + ] + } + } + ``` + - `profile` 字段必须匹配 §2 注册的 profile id;未知 profile ⇒ 启动 fail-fast + - 校验: + - 文件不存在 / JSON parse 失败 ⇒ fail-fast + - 当前 chainId 在文件中无条目 + CLI 也没传 ⇒ 警告日志 + Fast Lane 关闭(不算错误) + - 同一 chainId 下重复 address ⇒ fail-fast + - 验收: + - 单元测试:覆盖解析、Zod schema 校验、CLI+File 合并去重逻辑 + - 单元测试:未知 chainId 不报错只警告;未知 profile 报错 + - e2e:OP-Sepolia 启动加载文件、`/admin/trusted-paymasters` 返回正确条目 + +### 1.3 运行时 reload(SIGHUP / admin endpoint,**可选**) + +- **业务价值**:上线后想加新 trusted paymaster(如新合作运营方上线新 SuperPaymaster 实例),不能要求 bundler 重启——重启会丢 in-memory mempool、断开所有 WebSocket 连接、影响 SLA。SIGHUP 或 admin endpoint 触发热更是生产通常做法。 +- **必要性**: + - 可选项——M2 MVP 阶段重启可接受(OP-Sepolia 灰度阶段更新频率低) + - 但插件骨架(§2)必须为热更**留接口**,否则 M3 想加就要大改架构 +- **流程**: + 1. **SIGHUP 通道**:bundler 进程收到 SIGHUP ⇒ 重新读配置文件 ⇒ 重新调用 paymasterProfiles 注册器 + 2. **admin endpoint 通道**:`POST /admin/reload-trusted-paymasters`(仅 `--admin-enabled true` 启用,并要求 IP 在 `--admin-allowlist` 中)⇒ 同上 + 3. 热更**只换白名单和 profile 的 isFastLane / feeOverride 函数**——不动 mempool 已有的 op、不动 executor 已有的 in-flight bundle + 4. 热更日志记录 diff:`{ added: ["0x..."], removed: ["0x..."], unchanged: N }` +- **技术方案**: + - 改动位置:`src/cli/main.ts` 注册 SIGHUP handler;`src/rpc/server.ts` 加 admin route(受 `--admin-enabled` 保护) + - 并发安全:reload 写新白名单时用原子替换(新对象赋值给 `config.trustedPaymastersRef`),读侧不加锁——JS 单线程保证 + - **M2 决策**:默认实现 SIGHUP;admin endpoint 推到 M3 与监控告警 webhook 一起做(M3 反正要加 admin 面板) + - 验收: + - 单元测试:mock 配置文件,发 SIGHUP,确认白名单更新 + - 灰度测试:OP-Sepolia 部署后 `kill -HUP ` 无中断 + - 在 reload 进行中提交一笔 op,确认无竞态丢失 + +--- + +## 2 · paymasterProfiles 插件骨架(hook 数严格 ≤ 5) + +### 2.1 接口定义 + +- **业务价值**:M2 起步只有 SuperPaymaster v3 一个 profile,但**未来必然有第二、第三个** trusted paymaster(合作运营方接入、自家 v4 升级、跨链版本差异)。如果把 SuperPaymaster v3 的逻辑直接 hardcode 进 mempool / executor,下一个 paymaster 接入要再修一遍代码——不可持续。**必须先抽象出插件接口**,再把 SuperPaymaster v3 实现成第一个插件。 +- **必要性**: + - 可扩展性:新增 paymaster 只需写一个 profile 文件 + 配置一行,不动核心代码 + - 测试隔离:profile 单元测试不依赖 mempool / executor + - hook 数控制:所有插件共享同一组 ≤ 5 个 hook 接入点(见 §2.4),bundler 主流程零侵入 +- **流程**: + 1. 定义 `PaymasterProfile` interface + 2. profile 实现方按接口写文件放到 `src/paymasterProfiles//index.ts` + 3. `src/paymasterProfiles/index.ts` 静态扫描 + 注册 + 4. bundler 在 5 个 hook 点查询当前 op 对应的 profile(按 paymaster 地址 lookup) +- **技术方案**: + - 接口定义(`src/paymasterProfiles/types.ts`): + ```typescript + export interface PaymasterProfile { + // === 静态身份 === + id: string // 唯一 id, e.g. "superpaymaster-v3" + name: string // 人类可读名称 + addresses: { // 按链 chainId → 合约地址列表 + [chainId: number]: Address[] + } + + // === 行为 hook(被 bundler 主流程调用)=== + + // hook 1: 入口处判定是否走 Fast Lane + // 输入:完整 UserOp + 当前 chainId + // 输出:true=走 Fast Lane | false=走标准路径 + // 约束:必须是 pure / 同步——不能发 RPC、不能查链 + isFastLane(args: { + userOp: UserOperation + chainId: number + }): boolean + + // hook 2: 给 executor 提供 fee 策略(仅 Fast Lane op 调用) + // 输入:当前网络 baseFee + // 输出:覆盖后的 maxFeePerGas / maxPriorityFeePerGas + // 默认行为(superpaymaster-v3):priority=0, max=baseFee+ε + feeOverride(args: { + networkBaseFee: bigint + chainId: number + }): { maxFeePerGas: bigint; maxPriorityFeePerGas: bigint } + + // hook 3 (可选): 额外验证(M2 默认 no-op,给 M3 attestation 留口子) + extraValidation?(args: { + userOp: UserOperation + entryPoint: Address + }): Promise<{ ok: boolean; reason?: string }> + } + ``` + - **接口设计原则**: + - `isFastLane` 必须 pure 同步——避免每笔 op 入队时多一次 RPC + - `feeOverride` 入参只给 baseFee,不给完整 op——防止 profile 写出"按 sender 区别定价"这种灰色逻辑 + - `extraValidation` 标 optional + 异步——给 M3 留扩展(如 attestation 链上查询),M2 全部 profile 不实现 + - 验收: + - 单元测试:mock profile 实现,调用 `isFastLane` / `feeOverride` 行为符合预期 + - 类型测试:profile 不实现 `id` / `addresses` / `isFastLane` / `feeOverride` 编译报错 + +### 2.2 注册机制 + +- **业务价值**:插件注册要"零运行时依赖、零重启风险"——profile 文件加进去,下次 bundler 启动自动生效。运营方不需要懂 TypeScript 模块系统,新增 profile 走 PR 流程审过即可上线。 +- **必要性**: + - 静态注册比动态扫描更安全(不会 import 未审过的代码) + - 与 ZeroDev 现有 `src/handlers/` 工厂模式一致 +- **流程**: + 1. profile 实现写在 `src/paymasterProfiles//index.ts`,导出 default `PaymasterProfile` 对象 + 2. `src/paymasterProfiles/index.ts` 静态 `import` 所有已知 profile: + ```typescript + import superPaymasterV3 from "./superpaymaster-v3" + export const ALL_PROFILES: PaymasterProfile[] = [superPaymasterV3] + ``` + 3. 启动时构造 `PaymasterProfileRegistry`:按 `address.toLowerCase()` 建索引 + 4. 运行时按 `userOp.paymaster` lookup 一次 O(1) +- **技术方案**: + - 改动位置(新增): + - `src/paymasterProfiles/index.ts` —— 静态聚合 + Registry 类 + - `src/paymasterProfiles/types.ts` —— interface + - `src/paymasterProfiles/superpaymaster-v3/index.ts` —— 第一个 profile + - Registry 关键方法: + ```typescript + class PaymasterProfileRegistry { + constructor(args: { + allProfiles: PaymasterProfile[] + trustedPaymasters: Address[] // 来自 §1 配置(合并后) + chainId: number + }) + // lookup: 仅返回"在白名单 AND 在 profile addresses 中"的 profile + getProfile(paymasterAddress: Address): PaymasterProfile | null + } + ``` + - **关键约束**:profile 在 `addresses[chainId]` 中声明的地址 **AND** 该地址在 §1 trustedPaymasters 白名单中——**两个都满足才走 Fast Lane**。这保证: + - profile 静态声明的地址是"我们认识的 paymaster"(避免误把陌生 address 当 SuperPaymaster v3 处理) + - 运营白名单是"我们信任的 paymaster"(运营方决定上线哪些) + - 验收: + - 单元测试:mock 多个 profile,注册器按地址正确分发 + - 单元测试:address 在 profile 但不在 trustedPaymasters ⇒ 返回 null + - 单元测试:address 在 trustedPaymasters 但不在 profile ⇒ 返回 null(可改 warn 日志,因为这种配置很可能是运维笔误) + +### 2.3 SuperPaymaster v3 作为首个 profile + +- **业务价值**:SuperPaymaster v3 是 AAStar 自家的、链上已经部署的、已经在跑业务的合约。M2 的全部业务收益都来自这一个 profile。把它做对,就是把 M2 做对。 +- **必要性**: + - 验证插件接口是否实用——一个真实 profile 跑通了才说明接口设计合理 + - 给后续 profile 实现做模板 +- **流程**: + 1. 在 `src/paymasterProfiles/superpaymaster-v3/index.ts` 写实现: + - `addresses`:OP-Sepolia + OP-Mainnet 实际部署地址 + - `isFastLane`:方案 A 实现——只要 op 的 paymaster 字段命中本 profile 的 addresses,返回 true + - `feeOverride`:返回 `{ maxPriorityFeePerGas: 0n, maxFeePerGas: networkBaseFee + ε }`,ε 配置项(默认 100 wei,OP 链 baseFee 极低 + 0 拥塞条件下 ε 实际无影响) +- **技术方案**: + - profile 文件结构: + ```typescript + // src/paymasterProfiles/superpaymaster-v3/index.ts + import type { PaymasterProfile } from "../types" + + const SUPERPAYMASTER_V3: PaymasterProfile = { + id: "superpaymaster-v3", + name: "SuperPaymaster v3 (AAStar)", + addresses: { + 11155420: ["0x..."], // OP-Sepolia + 10: ["0x..."] // OP-Mainnet + }, + isFastLane({ userOp, chainId }) { + // 方案 A: 只看 paymaster 地址 + const paymaster = extractPaymasterAddress(userOp) + if (!paymaster) return false + return SUPERPAYMASTER_V3.addresses[chainId] + ?.some(a => a.toLowerCase() === paymaster.toLowerCase()) ?? false + }, + feeOverride({ networkBaseFee, chainId }) { + // 0 priority fee + baseFee + 100 wei 容错 + return { + maxFeePerGas: networkBaseFee + 100n, + maxPriorityFeePerGas: 0n + } + } + // extraValidation 不实现 — M2 不放宽不收紧,M3 attestation 时再加 + } + export default SUPERPAYMASTER_V3 + ``` + - **地址来源**:从 `SuperPaymaster` 项目部署文档拿(同 repo 链 deployments),M2 验收时双重确认 OP-Sepolia 上 `eth_getCode` 非空且 `version()` 返回 `"SuperPaymaster-5.3.0"` 或后续兼容版本 + - **paymaster 地址提取**(共享工具函数 `src/utils/extractPaymaster.ts`): + - v0.6: `userOp.paymasterAndData.slice(0, 42)`(前 20 字节) + - v0.7: `userOp.paymaster`(直接读字段) + - v0.8: 同 v0.7 + - 空 `paymaster` / 空 `paymasterAndData == "0x"` ⇒ 返回 null(boost / self-pay 路径,**不走 Fast Lane**) + - 验收: + - 单元测试:构造 v0.6 / v0.7 UserOp 各一笔,paymaster 命中 → `isFastLane = true`;不命中 → false + - 单元测试:`paymasterAndData = "0x"` → false + - e2e on OP-Sepolia:用真实 SuperPaymaster v3 + xPNTs 钱包发一笔,bundler 日志 `{ profile: "superpaymaster-v3", isFastLane: true }` + +### 2.4 hook 接入点(严格列出全部 ≤ 5 个) + +- **业务价值**:插件接口最大风险是"接入点失控"——profile 一旦能在任意位置插逻辑,bundler 主流程就变成插件的奴隶,未来 merge ZeroDev 上游每次都要小心避让。**M2 一开始就把 hook 数封顶**,每个 hook 文档化、有 owner、未来加新 hook 必须是文档级评审。 +- **必要性**: + - fork 可持续性:hook 越少,merge 上游冲突越小 + - 可审计性:审计员只需看 5 个文件就能判断 fork 行为 + - 防止 profile 滥权:profile 不能在 5 个点之外影响 bundler +- **M2 hook 接入点全清单(共 5 个,写死,超过需 M3 重新评审)**: + + | # | 位置 | 调用的 profile 方法 | 数据流 | M2 实现 | + |---|------|------------------|-------|--------| + | H1 | `src/rpc/methods/eth_sendUserOperation.ts:addToMempoolIfValid` 入口 | `registry.getProfile(paymaster)` + `profile.isFastLane()` | 给 mempool entry 贴 `metadata.isFastLane = true` 和 `metadata.profileId` | 调用即可 | + | H2 | `src/rpc/methods/boost_sendUserOperation.ts` 入口 | 同 H1(boost 路径也允许 Fast Lane,但 boost 路径要求 paymaster 为空,所以默认 `isFastLane = false`) | 同上 | 调用即可(结果总为 false) | + | H3 | `src/mempool/mempool.ts` 出队循环(`popOutstanding` 之前的优先级判定) | 不调用 profile 方法——**只读 mempool entry 的 `metadata.isFastLane` 标记** | 由 store 暴露 `peekFastLane / popFastLane` 优先消费 | store 改造 + 出队循环加分支 | + | H4 | `src/executor/executor.ts:calculateGasPrice` | `profile.feeOverride()` —— 仅当当前 bundle **全部** op 都是 Fast Lane 且 profile 一致时调用 | 覆盖 `maxFeePerGas` / `maxPriorityFeePerGas` | 加一个 if 分支 | + | H5 | `src/executor/executorManager.ts` bundle 创建后立即提交 | 不调用 profile 方法——**只读 bundle metadata 的 `isFastLane` 总标记** | 跳过 batching 等待,直接发 tx | 加一个 if 分支提前出 loop | + + **超出范围的 hook(M2 明确不做)**: + - simulation 阶段不挂 hook(不放宽 / 不收紧 ERC-7562 验证) + - reputation manager 不挂 hook(throttled / banned 状态对 Fast Lane 同样生效) + - postOp 不挂 hook(M2 不收费) + - dropUserOps 不挂 hook(drop 策略对所有 op 一视同仁) +- **流程**: + 1. bundler 启动构造 Registry + 2. RPC 入队 H1/H2 调 `isFastLane` 打标 + 3. mempool H3 按标优先出队 + 4. executor H4/H5 按标改 fee + 提前提交 + 5. 返回 userOpHash 给客户端,业务侧不感知 Fast Lane 存在(透明加速) +- **技术方案**: + - 5 个 hook 在代码里加注释 `// HOOK H1: paymasterProfiles isFastLane lookup`,方便 grep + merge 上游时定位 + - 单元测试:每个 hook 写一个测试覆盖"profile 命中 / 未命中"两个分支 + - 验收: + - grep `HOOK H[1-5]` 全 repo 必须正好 5 个匹配 + - PR 评审:任何新增 `HOOK H[6-9]` 必须文档级评审更新本表 + +--- + +## 3 · Fast-lane 三处核心实现 + +### 3.1 入口标记(H1 / H2) + +- **业务价值**:标记必须在**最早的可能位置**贴上,否则后续 mempool / executor 阶段就要重复跑一遍 paymaster 提取 + lookup,浪费 CPU 还可能不一致(如 op 在 mempool 期间配置 reload,前后判定不同)。**入口标记一次定终身**——同一个 op 的 Fast Lane 状态在整个生命周期内不变。 +- **必要性**: + - 性能:mempool 出队、executor 计费各只读一个 boolean,O(1) + - 一致性:op 不会"中途突然不是 Fast Lane 了" + - 可观测性:日志里直接带 `isFastLane: true/false`,运维一眼分辨 +- **流程**: + 1. RPC `eth_sendUserOperation` / `boost_sendUserOperation` 收到 op + 2. 在 `addToMempoolIfValid` 里、所有验证之后、`mempool.add` 之前: + ```typescript + // HOOK H1 + const profile = rpcHandler.paymasterRegistry.getProfile( + extractPaymasterAddress(userOp) + ) + const isFastLane = profile?.isFastLane({ + userOp, + chainId: rpcHandler.config.chainId + }) ?? false + ``` + 3. 把 `{ isFastLane, profileId }` 作为 `UserOpInfo.metadata` 传给 `mempool.add` + 4. 日志:`{ userOpHash, paymaster, isFastLane, profileId }` +- **技术方案**: + - 改动位置: + - `src/rpc/methods/eth_sendUserOperation.ts` —— H1,`addToMempoolIfValid` 加 lookup + 传入 metadata + - `src/rpc/methods/boost_sendUserOperation.ts` —— H2,同 H1(实际总为 false 因为 boost 要求 paymaster 为空,但**保持代码对称**——避免未来加 boost+paymaster 混合路径时漏改) + - `src/types/userop.ts` 或类似 —— `UserOpInfo` 增 optional `metadata: { isFastLane?: boolean; profileId?: string }` + - `src/mempool/mempool.ts:add` —— 透传 metadata 到 store + - `src/store/createMempoolStore.ts` —— `addOutstanding` 接受 metadata 字段 + - **不动**:验证逻辑、PVG 计算、reputation 校验、nonce 校验 + - 验收: + - 单元测试:白名单内 paymaster ⇒ metadata.isFastLane === true + - 单元测试:白名单外 paymaster ⇒ false + - 单元测试:boost 路径 ⇒ false + - e2e:日志看到正确标记 + +### 3.2 mempool 优先级队列(H3) + +- **业务价值**:标记打上之后,mempool 出队顺序必须给 Fast Lane op 让路——否则前面有一堆普通 op 排队,Fast Lane op 等的时间和普通 op 一样长,"绿色"两字白叫。 +- **必要性**: + - Fast Lane 的"快"主要来自三件事:(a) 不等 batching;(b) 0 priority fee 不与人争;(c) **优先出队**——三者缺一不可 + - 出队优先 = mempool 数据结构改造 = M2 最大的代码改动点(但仍局限在 store 层 + mempool.ts 单文件) +- **流程**: + 1. bundler 构造下一个 bundle 时,循环 `popOutstanding`: + 2. **改造**:先调 `peekFastLane(entryPoint)` 看有没有 Fast Lane op;有 ⇒ `popFastLane`;无 ⇒ 退化到 `popOutstanding` + 3. **关键约束**:Fast Lane op **不与普通 op 同 bundle 混打**——同 bundle 全部 Fast Lane 或全部普通。理由:feeOverride 只对全 Fast Lane 的 bundle 生效(H4),混合 bundle 的 fee 策略无定义;且 Fast Lane 的"立即广播"语义要求 bundle 不等 batching + 4. Fast Lane bundle 满足以下任一条件即结束(不等 minOpsPerBundle): + - 当前 entryPoint 下没有更多 Fast Lane op + - 达到 maxBundleCount + - 达到 bundle gas 上限 +- **技术方案**: + - 改动位置: + - `src/store/index.ts` —— store interface 加 `peekFastLane` / `popFastLane` 方法 + - `src/store/createMemoryOutstandingStore.ts` —— 内存实现:维护两个数组 `outstanding` + `outstandingFastLane`,`addOutstanding` 按 metadata 分流 + - `src/store/createRedisOutstandingStore.ts` —— Redis 实现:同样的双 list,key 带 `:fastlane` 后缀 + - `src/mempool/mempool.ts` —— 出队循环按 §3.2 流程改造(HOOK H3 注释) + - **关键设计**: + - Fast Lane 队列内部仍是 FIFO(不按 priority fee 二级排序——Fast Lane op 的 priority fee 都是 0,无意义) + - Fast Lane bundle 与普通 bundle 各算各的 maxBundleCount——避免一笔 Fast Lane "吃掉" 普通 op 的配额 + - 普通 op 不会"饿死":bundle creator 在每轮 `popFastLane` 之后下一轮 `popOutstanding` 仍照常进行;Fast Lane 不会无限爆量(受 trusted paymaster 自身合约的 minTxInterval 限制 §5) + - **删除场景**:op 被 drop(reputation / 超时)⇒ 不论是否 Fast Lane 都从对应队列移除;`removeOutstanding` 接口需要能在两个队列里都找 + - 验收: + - 单元测试:连续 add 5 普通 + 3 Fast Lane → 出队前 3 笔是 Fast Lane(任意顺序)→ 之后是普通 + - 单元测试:Fast Lane bundle 不与普通 op 混合 + - 单元测试:Fast Lane op 也走 reputation 校验、被 throttled 后从 Fast Lane 队列移除 + - e2e:OP-Sepolia 同时灌 1 笔 Fast Lane + 5 笔普通,Fast Lane 上链 tx index < 任何普通 op + +### 3.3 executor fee 策略(H4 / H5) + +- **业务价值**:0 priority fee 不只是省钱——是表明"我不与公共 mempool 抢 block 排序"。SuperPaymaster v3 业务里所有 op 的 gas 由 trusted paymaster 自己付,bundler 只需要 baseFee 就够上链;多付 priority fee 等于把 SuperPaymaster 的 aPNTs 储备烧给 block builder——纯亏损。 +- **必要性**: + - 业务必要:Fast Lane 的核心经济价值就是"免争抢" + - 0 priority fee 在 OP 链(包括 OP-Sepolia / OP-Mainnet)天然可行——OP 出块顺序是 sequencer 时间戳排序,priority fee 不影响排序,0 同样会被打包 + - "立即广播"(H5)= bundler 不等 `bundleInterval` / `minOpsPerBundle` 凑批,bundle 一形成立即 sendTransaction +- **流程**: + 1. mempool 把 Fast Lane op 凑成 bundle 时,bundle 上整体打 `metadata.isFastLane = true`(同 §3.2,bundle 内 op 全是 Fast Lane) + 2. executor `calculateGasPrice` 检查 bundle metadata: + - Fast Lane bundle ⇒ 调 `profile.feeOverride({ networkBaseFee, chainId })` 拿到 fee + - 普通 bundle ⇒ 走原有 `bundlerInitialCommission` / `breakEvenGasPrice` 逻辑(M2 完全不动) + 3. executorManager 收到 Fast Lane bundle 后**跳过 batching 等待**(不等下一个 tick),立即 sendTransaction +- **技术方案**: + - 改动位置: + - `src/executor/executor.ts:calculateGasPrice` —— H4,加 if 分支: + ```typescript + // HOOK H4: Fast Lane fee override + if (bundle.metadata?.isFastLane && bundle.metadata?.profileId) { + const profile = this.paymasterRegistry.getById(bundle.metadata.profileId) + if (profile) { + return profile.feeOverride({ + networkBaseFee, + chainId: this.config.chainId + }) + } + // profile 找不到 — 退化到普通策略 + warn 日志(不应发生,配置错误) + } + // ...原有逻辑 + ``` + - `src/executor/executorManager.ts` bundle creation loop —— H5,bundle 出来后查 metadata,是 Fast Lane 立即提交不等下一 tick + - **关键约束**: + - feeOverride 返回的 maxFeePerGas 仍要 ≥ networkBaseFee(OP 链节点会拒绝低于 baseFee 的 tx)——profile 实现里加保底 ε + - resubmissionAttempts 累计后 fee 加价逻辑(原有 `bundle.submissionAttempts > 0` 分支)**对 Fast Lane 同样生效**:第一次 submit 用 feeOverride 的 fee,underpriced retry 时按原有 retry 倍数加价(避免 baseFee 突涨时 Fast Lane tx 卡住) + - resubmit 时如果加价后超出 SuperPaymaster v3 的 `maxRate` 校验上限(合约里的 paymasterAndData rate commitment)⇒ tx 上链会被合约拒——这是 paymaster 合约自身保护,bundler 不需要预判 + - **不动**: + - Arbitrum 分支(`chainType === "arbitrum"`,§3.3 §3.4 流程对 OP 链已足够) + - legacyTransactions 分支(OP 是 EIP-1559,不走 legacy) + - 验收: + - 单元测试:Fast Lane bundle ⇒ `calculateGasPrice` 返回 priority=0 + - 单元测试:普通 bundle ⇒ `calculateGasPrice` 返回原逻辑结果 + - 单元测试:resubmit 第 1 次 ⇒ feeOverride * 120% (resubmissionMultiplier) + - e2e on OP-Sepolia:实测 tx 链上 `effectiveGasPrice == baseFee`(可允许 +ε),`maxPriorityFeePerGas == 0` + +--- + +## 4 · EIP-7702 完整实战 + +### 4.1 业务价值 + +- AirAccount 演进路径明确将 EIP-7702(Pectra 升级,OP-Mainnet 已支持)作为**EOA → smart wallet 平滑过渡**的核心通道:用户原生 EOA 通过 `SET_CODE_TX_TYPE = 0x04` + `authorizationList` **临时挂载** AirAccount implementation 代码,单笔 tx 内拥有 smart wallet 能力,tx 结束后 EOA 身份不变(authorization 不持久化) +- 对 M2 fast-lane 的直接业务价值:拓宽 sender 来源——白名单内 SuperPaymaster v3 paymaster 不仅能 sponsor 已部署 AirAccount sender,还能 sponsor 任意 EOA + EIP-7702 authorization 路径的 sender +- 对运营方的业务价值:拥抱"AirAccount 用户" + "原生 EOA 用户"两类客户群,无需要求 EOA 用户先部署合约钱包 + +### 4.2 必要性 + +- **fast-lane 业务必须前置考虑 EIP-7702 sender 路径**:M2 §3 入口标记(H1)、mempool 出队(H3)、executor fee 策略(H4)均不感知 sender 类型,但**`authorizationList` 字段必须正确透传到 estimate / simulation / handleOps 全链路**——否则 EIP-7702 op 在 fast-lane 路径上 estimate 通过但 simulation 失败、或 simulation 通过但 handleOps 上链失败 +- bundler 在 PR #13 已经把 `authorizationList` 透传进 `eth_estimateGas` 的通路打通(`--rpc-gas-estimate` 模式),但**只覆盖 estimate 单元测试,没跑过端到端**——M2 必须补齐 simulation 路径 + handleOps 提交路径,并在 OP-Mainnet 真链上做 fast-lane EIP-7702 e2e +- 不在 M2 做 = M3 / 后续要把"fast-lane 通路"和"EIP-7702 通路"分两次集成,会引入回归风险(fast-lane mempool / executor 变更后 EIP-7702 路径需要重测) + +### 4.3 流程 + +1. **客户端构造**: + - sender = EOA 地址 + - `authorizationList: [{ chainId, address: , nonce, signature }]`(v0.7 schema 扩展位) + - paymaster = SuperPaymaster v3 (在 §1 trusted-paymasters 白名单内) +2. **bundler `eth_sendUserOperation` 入口**: + - §3.1 H1 标记 `metadata.isFastLane = true`(paymaster 命中白名单) + - schema 校验:`authorizationList` 字段格式正确 +3. **estimate 路径**(PR #13 已实现): + - `eth_estimateUserOperationGas` 调 `eth_estimateGas` 时,`authorizationList` 透传给 RPC provider +4. **simulation 路径**(M2 补齐): + - validation 阶段调 `debug_traceCall` 时,`authorizationList` 必须随 call 参数一同传递 + - safe-mode tracer 对 EIP-7702 sender 同样跑 ERC-7562 opcode/storage 限制——authorization 临时挂的 wallet code 同样受约束 +5. **mempool 出队 + bundle 构造**(§3.2/§3.3 不变): + - Fast-lane bundle 包含 EIP-7702 op 时,bundle metadata 透传 `authorizationList` 到 executor +6. **handleOps tx 提交**(M2 补齐): + - executor `sendTransaction` 必须用 `SET_CODE_TX_TYPE = 0x04`,tx 携带 `authorizationList` + - executor wallet 用 viem `writeContract` / `sendTransaction` 时显式带 `authorizationList` 参数 +7. **上链验证**: + - 区块浏览器显示 tx type = 0x04 + - UserOp 执行成功(authorization 在 handleOps 期间临时生效,EOA 在 tx 后仍是 EOA) + +### 4.4 技术方案 + +- **代码基础**(已有): + - PR #13 已实现 estimate 路径透传:`src/executor/filterOpsAndEstimateGas.ts:116-134`(`--rpc-gas-estimate` 模式下 `authorizationList` 注入 `estimateGas` call) + - viem >= 2.18 已原生支持 `sendTransaction({ authorizationList })` +- **M2 补齐项**: + - **simulation 路径**:`src/rpc/validation/SafeValidator.ts` / `UnsafeValidator.ts` 调 `debug_traceCall` 时检查是否带 `authorizationList` 字段;缺失则在含 `authorizationList` 的 op 上**fail-fast 而非静默忽略** + - **handleOps 路径**:`src/executor/executor.ts:sendBundle`(或等价位置)调 `sendTransaction` 时显式透传 `bundle.authorizationList`(按 bundle 所含 op 聚合) + - **bundle 聚合规则**:fast-lane bundle 内若有 EIP-7702 op,bundle 整体走 SET_CODE_TX;同 bundle 内**不混 EIP-7702 op 和非 EIP-7702 op**(约束类似 §3.2 fast-lane / 普通不混合) +- **配置**: + - `--eip7702-enabled true|false`(默认 true,按链关——OP-Mainnet/OP-Sepolia 默认开) + - 与 `--rpc-gas-estimate` 配合:rpc-gas-estimate 模式下 PR #13 路径生效;非 rpc-gas-estimate 模式 M2 也要保证 simulation/handleOps 路径透传 +- **e2e 验收**: + - 测试位置:`e2e/m2-eip7702.test.ts`(新增) + - 真实场景:在 OP-Mainnet(fork 或灰度)跑一笔 fast-lane EIP-7702 UserOp + - sender = 测试 EOA + - authorization 指向已部署的 AirAccount implementation + - paymaster = SuperPaymaster v3 OP-Mainnet 地址(白名单内) + - 期望:bundler 日志 `{ isFastLane: true, profileId: "superpaymaster-v3", eip7702: true }`;上链 tx type = 0x04;UserOpEvent success = true + - 边界 case: + - authorizationList 缺失 → bundler 仅按普通 v0.7 op 处理(不影响普通路径) + - authorization signature 无效 → simulation reject,op drop,不上链 + - RPC provider 不支持 `authorizationList` 字段 → bundler 启动 fail-fast 提示用户切支持的 provider(Alchemy / QuickNode 已支持) + - 联合验收:与 AirAccount 团队共同执行——他们提供 wallet implementation 部署地址和测试 EOA 私钥;我们跑 bundler 端 + +--- + +## 5 · 安全分析 + +### 5.1 安全三层(M2 信任模型) + +- **业务价值**:把"为什么允许某些 op 走 Fast Lane 是安全的"写成可审计的论证。审计员、运营方、合作 paymaster 团队、未来的我们自己——任何人质疑 Fast Lane 安全性时,能直接指向本节回答。 +- **必要性**: + - Fast Lane 是 fork 引入的非标准能力,必须有显式安全模型 + - 没有这一节,下一个 reviewer 会自然怀疑"是不是放宽了 ERC-4337 的某项保护" +- **三层防御**(M2 实际启用的全部安全机制,按顺序触发): + + **第一层:运营白名单(链下)** + - 只有 §1 trusted-paymasters 配置中的地址才走 Fast Lane + - 白名单变更走 PR + 配置文件版本化 + reload 日志审计 + - 信任假设:运营方知道自己白名单了什么 paymaster;新增 paymaster 必经过运营方 KYC(如 SuperPaymaster v3 是 AAStar 自家合约,本身就是受信主体) + + **第二层:标准 ERC-4337 simulation(链上 + 链下 tracer)** + - **完全不放宽**:safe 模式 ERC-7562 tracer 一律照跑(opcode 限制、storage 访问限制、外部合约调用限制) + - paymaster 合约里任何违规(GAS opcode、SELFDESTRUCT、跨账户 storage 写)—— Fast Lane op 同样被拒 + - reputation manager 同样生效:throttled / banned 的 paymaster 即使在白名单里也按 reputation 处理(不出队) + - PVG 校验、nonce 校验、签名校验—— Fast Lane op 同样跑 + + **第三层:SuperPaymaster 合约自身的 sponsorship 资格门槛(链上)** + - SuperPaymaster v3 在 `validatePaymasterUserOp`([SuperPaymaster.sol:725](../../SuperPaymaster/contracts/src/paymasters/superpaymaster/v3/SuperPaymaster.sol#L725))里强制: + - **operator 配置门槛**:`operators[operator].isConfigured` 必须 true([行 737](../../SuperPaymaster/contracts/src/paymasters/superpaymaster/v3/SuperPaymaster.sol#L737)) + - **operator 未暂停**:`!config.isPaused`([行 747](../../SuperPaymaster/contracts/src/paymasters/superpaymaster/v3/SuperPaymaster.sol#L747)) + - **用户资格双通道**:`isEligibleForSponsorship(userOp.sender)` —— 必须是 SBT 持有者 OR 注册 ERC-8004 Agent([行 752, 1010-1012](../../SuperPaymaster/contracts/src/paymasters/superpaymaster/v3/SuperPaymaster.sol#L752)) + - **用户未被屏蔽**:`!userState.isBlocked`([行 760](../../SuperPaymaster/contracts/src/paymasters/superpaymaster/v3/SuperPaymaster.sol#L760)) + - **rate limit**:`config.minTxInterval` 强制([行 766-772](../../SuperPaymaster/contracts/src/paymasters/superpaymaster/v3/SuperPaymaster.sol#L766),靠 `validAfter` 实现) + - **operator aPNTs 余额充足**:覆盖 maxCost + 协议费 + 10% 验证 buffer([行 794](../../SuperPaymaster/contracts/src/paymasters/superpaymaster/v3/SuperPaymaster.sol#L794)) + - **bundler 不重复检查这些**——合约自己拒,bundler 看到 simulation revert 直接 drop op 即可 +- **流程**: + 1. op 进来 → §3.1 入口标记是否 Fast Lane(第一层:白名单决定优先级) + 2. op 走标准 simulation(第二层:ERC-7562 tracer 一律跑) + 3. simulation 调 SuperPaymaster.validatePaymasterUserOp(第三层:合约自身资格门槛) + 4. 三层全过 → 入 mempool(Fast Lane 队列或普通队列) + 5. 出队、打包、上链 +- **技术方案**: + - **不写新代码** —— 三层防御全部已存在 + - 文档化:本节内容独立成 `docs/SECURITY_M2.md`,作为审计入口 + - 验收:见 §7.3 + +### 5.2 不放宽 ERC-7562 验证规则 + +- **业务价值**:保持 bundler 协议合规——bundler-spec-tests 全套照过,eth-infinitism 任何审计能直接通过。 +- **必要性**: + - 一旦放宽,bundler 就脱离"标准 ERC-4337 bundler"身份,无法宣称合规 + - 放宽 ERC-7562 等于把 paymaster 合约的安全责任转嫁给运营方人工审核——风险面失控 +- **流程 / 技术方案**: + - safe 模式 tracer ([src/rpc/validation/SafeValidator.ts](../src/rpc/validation/SafeValidator.ts)) 对所有 op 一视同仁,**不区分 Fast Lane / 普通** + - **关键依据**:SuperPaymaster v3.6 已在合约层面规避了 ERC-4337 banned opcode 问题: + - 注释见 [SuperPaymaster.sol:702](../../SuperPaymaster/contracts/src/paymasters/superpaymaster/v3/SuperPaymaster.sol#L702):`V3.6 FIX: Remove TIMESTAMP check here to avoid Banned Opcode AA33. Staleness is enforced via validUntil signal in validatePaymasterUserOp` + - 即 SuperPaymaster v3.6 自己已经把"价格陈旧检查"从 validation 阶段移除,改用 `_packValidationData` 的 `validUntil` 信号让 EntryPoint 处理——这正好是 ERC-4337 标准做法 + - 因此 SuperPaymaster v3 走 Fast Lane **不需要任何 tracer 例外**,bundler 一行验证规则不动 + - 验收:spec-tests 在加了 §1-§3 改动后仍 100% 通过(M1 §6 验收表第 1.6 项的延续) + +### 5.3 Sybil / DDoS 在 M2 上下文 + +- **业务价值**:Fast Lane 听起来像"快通道 = 容易被滥用 = Sybil 攻击放大器"——本节正面回应,说明 M2 的反 Sybil / DDoS 机制依然完整。 +- **必要性**:审计 / 红队的标准质疑点 +- **威胁模型 + 缓解**: + + | 威胁 | M2 是否有放大风险 | 缓解措施 | + |------|----------------|---------| + | 恶意用户对 Fast Lane 灌大量 op | 不放大 | (a) Fast Lane 标记本身不绕过任何验证;(b) SuperPaymaster v3 的 `minTxInterval` 限制每用户每秒最多 1 笔([行 766](../../SuperPaymaster/contracts/src/paymasters/superpaymaster/v3/SuperPaymaster.sol#L766));(c) `isEligibleForSponsorship` 要求 SBT 或 Agent NFT,新建账号无法立即获得资格([行 1010](../../SuperPaymaster/contracts/src/paymasters/superpaymaster/v3/SuperPaymaster.sol#L1010))| + | 恶意 paymaster 假冒 SuperPaymaster | 不可能 | profile addresses 里写死 SuperPaymaster v3 实际部署地址;攻击者无法伪造同地址(除非攻破 EOA owner key 重新部署,但那是上游合约层威胁) | + | bundler 节点被打满 RPC | 不放大(与普通路径相同) | M1 §4.1 加的 `@fastify/rate-limit` 同样作用于 Fast Lane 入口(按 IP 限流) | + | Fast Lane op 把普通 op 挤到饿死 | 限定不会 | mempool 是双队列、不是优先级权重;普通 op 出队循环正常进行;且 Fast Lane 上限受 SuperPaymaster `aPNTsBalance` 限制(合约层面发不出来更多 op) | + | bundler 运营方误把恶意地址加进白名单 | 风险存在 | (a) 配置 PR + 多人评审;(b) reload 日志审计;(c) 白名单 paymaster 所有 op 仍走标准 simulation,最坏情况是恶意 paymaster 拒所有 op(DoS 自己),不会污染其他 paymaster | + +- **流程 / 技术方案**: + - **核心论断**:Fast Lane 不放过任何 op——"在白名单 + 通过 simulation + 通过 SuperPaymaster 自身校验"才会被打包;只是**打包顺序**和**fee 策略**不同 + - **第二核心论断**:SuperPaymaster v3 的 SBT / Agent NFT 资格门槛是**主防线**——攻击者要刷 op 必先取得 SBT,那就走另一个反 Sybil 战场(ERC-8004 Agent registry 自身的 reputation 系统) + - 验收:见 §7.3 + +--- + +## 6 · (可选)双因子识别 — trusted-account-implementations + +### 6.1 双因子方案 + +- **业务价值**:方案 A(仅看 paymaster 地址)足够 M2 落地,但理论上有一种边缘场景:恶意 sender 发起带 SuperPaymaster paymaster 的 op,希望蹭 Fast Lane(虽然 SuperPaymaster 合约自己会拒,但 bundler 入口已经把 op 标成 Fast Lane 了,意味着这种 op 短暂占用 Fast Lane 队列资源直到 simulation 拒掉)。**双因子识别**额外检查 sender 是否已知钱包实现(如 AirAccount v7),从源头降噪。 +- **必要性**: + - **M2 不强制**——边缘场景实际影响小(SuperPaymaster simulation 阶段就会把这种 op 拒掉,Fast Lane 队列里待的时间 < 1 秒) + - 提案纳入 §6 是为了把"未来想法"明确成"已评估、已决定推 M3",避免下次 review 时重新讨论 +- **流程**: + 1. profile 接口扩展(M3 引入):`trustedAccountImplementations: Hex[]` 字段——已知钱包 implementation 字节码 hash + 2. RPC 入口除了查 paymaster,再查 sender 的 implementation:`eth_getCode(sender)` → keccak256 → 命中列表 ⇒ 双因子通过 + 3. 双因子未通过 ⇒ 仍允许走 Fast Lane(向后兼容),但日志 warn `{ reason: "unknown_account_implementation" }` + 4. 严格模式(M3 配置开关):未通过 ⇒ 退化到普通路径 +- **技术方案 / trade-off**: + - **成本**:每笔 op 多一次 `eth_getCode` RPC 调用——可缓存(sender 部署后 implementation 极少变),但缓存层是新基础设施 + - **收益**:把 Fast Lane "白名单 paymaster" 收紧到 "白名单 paymaster + 已知钱包" 双重交集,攻击面更小 + - **决策**:**M2 不做**。理由: + - 增加配置面(profile 接口扩展、缓存层、严格模式开关) + - 实际防御收益小(边缘场景) + - SuperPaymaster v3 自己已经通过 `isEligibleForSponsorship` 在 sender 维度做了 Sybil 防御(SBT/Agent),双因子是重复防线 + - **何时触发 M3 重新评估**: + - 出现真实滥用案例(运营方观察到 Fast Lane 队列被恶意 op 短暂占用) + - 引入第二个 trusted paymaster,且该 paymaster 自身没有 sender 维度防御 +- 验收:M2 不验收,仅文档化决策依据 + +--- + +## 7 · 验收 + +### 7.1 单元测试:isFastLane 判定矩阵 + +- **业务价值**:核心判定函数错一个字符就可能导致全量 op 被错误标记 / 全量错过 Fast Lane。判定矩阵必须穷举。 +- **必要性**:M2 最高风险点 +- **流程 / 技术方案**: + - 测试位置:`src/paymasterProfiles/superpaymaster-v3/index.test.ts` + - 测试矩阵(至少覆盖以下 case,每条期望值明确): + + | # | userOp paymaster 字段 | chainId | 期望 isFastLane | + |---|---------------------|---------|---------------| + | 1 | SuperPaymaster v3 OP-Sepolia 地址 | 11155420 | true | + | 2 | SuperPaymaster v3 OP-Mainnet 地址 | 10 | true | + | 3 | SuperPaymaster v3 OP-Sepolia 地址 | 10 | false (地址 / 链不匹配)| + | 4 | 任意未知 paymaster 地址 | 11155420 | false | + | 5 | `paymaster = "0x"`(v0.7 空字段) | 11155420 | false | + | 6 | `paymasterAndData = "0x"`(v0.6 空字段) | 11155420 | false | + | 7 | SuperPaymaster v3 地址但**不在 §1 白名单**(profile 知道但运营没批) | 11155420 | false (via Registry) | + | 8 | v0.6 op,paymasterAndData 含 SuperPaymaster v3 地址 + 额外 data | 11155420 | true | + | 9 | v0.7 op,paymaster 字段大小写不一(checksum 形态 / 全小写) | 11155420 | true(`toLowerCase` 比较)| + | 10 | v0.8 op | 11155420 | 同 v0.7 行为 | + - 验收:CI 跑 `pnpm test` profile 测试套件 100% 通过 + +### 7.2 e2e on OP-Sepolia + +- **业务价值**:单元测试无法验证三件事:(a) Fast Lane op 真的优先出队;(b) 链上 tx 真的 priority fee = 0;(c) 立即广播真的发生(不等 batching)。这三条只能在 OP-Sepolia 实测。 +- **必要性**:M2 上 OP-Mainnet 的硬性前置条件 +- **流程**: + 1. 准备: + - bundler 配置 `--trusted-paymasters-file ./trusted-paymasters.json`(含 SuperPaymaster v3 OP-Sepolia 地址) + - bundler `--bundle-mode auto` + `--bundler-interval 5000`(auto 模式 5 秒一打——立即广播会跳过这个等待) + 2. 测试用例 A:**单笔 Fast Lane op** + - 客户端用真实 SuperPaymaster v3 + xPNTs 钱包(SBT 持有者)发一笔 op + - 期望:bundler 在 < 1 秒内 sendTransaction(不等 5 秒 interval) + - 链上验证:tx 的 `maxPriorityFeePerGas == 0`、`effectiveGasPrice ≈ baseFee` + - 日志验证:`{ isFastLane: true, profileId: "superpaymaster-v3" }` + 3. 测试用例 B:**Fast Lane vs 普通混合提交** + - 同时灌:5 笔普通 op + 1 笔 Fast Lane op + - 期望:Fast Lane op 上链 block number ≤ 任何普通 op 上链 block + - 期望:两个 bundle,Fast Lane bundle 在前 + 4. 测试用例 C:**resubmit 场景** + - 提交 1 笔 Fast Lane op,模拟 baseFee 突涨(在 anvil fork 上手动调) + - 期望:bundler retry 时 maxFeePerGas 加 20%,仍保持 priority=0 +- **技术方案**: + - 测试代码:`e2e/m2-fast-lane.test.ts`(新文件) + - 使用 viem 客户端 + 真实 OP-Sepolia RPC + - 使用一个预部署的 SuperPaymaster v3 OP-Sepolia 实例 + 一个测试 SBT 持有者钱包 +- 验收:三个测试用例全部通过 + 至少 24h 灰度无误判 + +### 7.3 安全测试 + +- **业务价值**:Fast Lane 不破坏 §5 三层防御的证据 +- **必要性**:审计依据 +- **流程 / 技术方案**: + - 测试 1:**白名单外 paymaster 走标准路径** + - 配置:`--trusted-paymasters 0xAddrA` + - 提交:`paymaster = 0xAddrB` 的 op + - 期望:bundler 日志 `isFastLane: false`,op 走原有 mempool 普通队列 + - 测试 2:**恶意 op 被 SuperPaymaster 拒绝时 bundler 不重试** + - 提交:sender 不是 SBT 持有者的 op(`isEligibleForSponsorship == false`) + - 期望:simulation 阶段 SuperPaymaster.validatePaymasterUserOp 返回 sigFailed + - 期望:bundler `dropUserOps` 调用、日志 `{ reason: "AA34 signature error", paymaster: "0x..." }` + - 期望:op **不进 Fast Lane 队列**也不进普通队列、不重试 + - 测试 3:**reputation throttled paymaster 即使在白名单也被限流** + - 用 debug 接口手动把 SuperPaymaster paymaster 的 reputation 设为 throttled + - 提交多笔 Fast Lane op + - 期望:mempool 出队按 throttled limit 限流(最多 `throttledEntityBundleCount = 4` 笔) + - 测试 4:**ERC-7562 tracer 对 Fast Lane op 同样生效** + - 部署一个故意违规的测试 paymaster(如在 validation 中 SLOAD 别人 storage),加进白名单 + - 提交 op + - 期望:safe 模式下 tracer 拒绝,op drop + - 验收:4 个测试用例全部通过 + +--- + +## 8 · 验收检查表(最终签字依据) + +| # | Feature | 类型 | 验收方式 | 状态 | +|---|---------|------|---------|------| +| 1.1 | `--trusted-paymasters` CLI flag | 配置 | 单元测试 + 启动日志 | ☐ | +| 1.2 | `--trusted-paymasters-file` 配置文件 | 配置 | 单元测试 + Zod schema 校验 + 多链 e2e | ☐ | +| 1.3 | SIGHUP 热更(admin endpoint 推 M3) | 配置 | 单元测试 + `kill -HUP` 灰度 | ☐ | +| 2.1 | `PaymasterProfile` interface 定义 | 插件 | 类型检查 + 接口文档化 | ☐ | +| 2.2 | profile 注册器(双重交集逻辑) | 插件 | 单元测试覆盖三种 case | ☐ | +| 2.3 | SuperPaymaster v3 profile 实现 | 插件 | 单元测试 §7.1 矩阵 100% 通过 | ☐ | +| 2.4 | hook 接入点严格 5 个 | 插件 | `grep -c "HOOK H[1-5]"` 全 repo == 5 | ☐ | +| 3.1 | RPC 入口标记 H1/H2 | 核心 | 单元测试 + 日志 metadata.isFastLane | ☐ | +| 3.2 | mempool 双队列 H3 + 不混合 bundle | 核心 | 单元测试出队顺序 + e2e | ☐ | +| 3.3 | executor feeOverride H4 + 立即广播 H5 | 核心 | 单元测试 + e2e 链上验证 priority=0 | ☐ | +| 4.1 | EIP-7702 业务价值与必要性文档化 | 协议 | 文档评审通过 | ☐ | +| 4.2 | EIP-7702 estimate 路径透传(PR #13) | 协议 | 已合并 + 单元测试覆盖 | ☐ | +| 4.3 | EIP-7702 simulation 路径透传 | 协议 | `debug_traceCall` 带 `authorizationList` 单元测试 | ☐ | +| 4.4 | EIP-7702 handleOps 路径透传 | 协议 | executor `sendTransaction` 带 `authorizationList` + tx type 0x04 | ☐ | +| 4.5 | EIP-7702 fast-lane e2e on OP-Mainnet | 协议 | 真链跑通 + 区块浏览器 type 0x04 + AirAccount 联合验收 | ☐ | +| 5.1 | 三层安全防御文档 (`docs/SECURITY_M2.md`) | 安全 | 审计员 review 通过 | ☐ | +| 5.2 | 不放宽 ERC-7562 验证 | 安全 | spec-tests 在 M2 改动后仍 100% 通过 | ☐ | +| 5.3 | Sybil/DDoS 缓解机制矩阵 | 安全 | 安全测试 §7.3 4 个用例通过 | ☐ | +| 6.1 | 双因子识别评估文档化 | 文档 | 决策记录纳入本文 §6 | ☐ | +| 7.1 | isFastLane 单元测试矩阵 | 测试 | 10 个 case 100% 通过 | ☐ | +| 7.2 | OP-Sepolia e2e 三个用例 | 测试 | 单笔 / 混合 / resubmit 全过 | ☐ | +| 7.3 | 安全测试 4 个用例 | 测试 | 全过 | ☐ | +| J | OP-Sepolia 灰度 1 周 | 部署 | 至少 50 笔 Fast Lane op 上链、0 误判 | ☐ | +| K | OP-Mainnet 上线 | 部署 | 灰度 N 笔 SuperPaymaster v3 + xPNTs op | ☐ | + +--- + +## 9 · M2 输出物清单 + +### 代码改动 + +新增: +- `src/paymasterProfiles/types.ts` —— `PaymasterProfile` interface +- `src/paymasterProfiles/index.ts` —— 静态注册聚合 + Registry 类 +- `src/paymasterProfiles/superpaymaster-v3/index.ts` —— SuperPaymaster v3 profile +- `src/paymasterProfiles/superpaymaster-v3/index.test.ts` —— §7.1 判定矩阵 +- `src/cli/config/loadTrustedPaymasters.ts` —— 配置文件解析 + CLI 合并 +- `src/utils/extractPaymaster.ts` —— v0.6/v0.7/v0.8 通用 paymaster 地址提取工具 +- `e2e/m2-fast-lane.test.ts` —— §7.2 e2e +- `e2e/m2-security.test.ts` —— §7.3 安全测试 +- `e2e/m2-eip7702.test.ts` —— §4 EIP-7702 fast-lane e2e on OP-Mainnet + +修改: +- `src/cli/config/options.ts` —— 加 `--trusted-paymasters` / `--trusted-paymasters-file` / `--admin-enabled` / `--fast-lane-fee-tolerance` 系列 flag +- `src/createConfig.ts` —— 注入 `trustedPaymasters` + `paymasterRegistry` 到 `AltoConfig` +- `src/cli/main.ts` —— SIGHUP handler 注册 +- `src/rpc/methods/eth_sendUserOperation.ts` —— **HOOK H1** +- `src/rpc/methods/boost_sendUserOperation.ts` —— **HOOK H2** +- `src/types/userop.ts`(或 `UserOpInfo` 定义所在文件) —— `metadata: { isFastLane?, profileId? }` optional +- `src/store/index.ts` —— interface 加 `peekFastLane` / `popFastLane` +- `src/store/createMemoryOutstandingStore.ts` —— 双队列实现 +- `src/store/createRedisOutstandingStore.ts` —— Redis 双 list 实现 +- `src/store/createMempoolStore.ts` —— `addOutstanding` 透传 metadata +- `src/mempool/mempool.ts` —— **HOOK H3**:出队循环加 Fast Lane 优先 + bundle 不混合 +- `src/executor/executor.ts` —— **HOOK H4**:calculateGasPrice 加 Fast Lane 分支 +- `src/executor/executorManager.ts` —— **HOOK H5**:Fast Lane bundle 立即广播 + +### 配置 + +- `trusted-paymasters.json`(仓库 root 示例文件)—— 含 OP-Sepolia + OP-Mainnet 两条 SuperPaymaster v3 entry,运营方按场景修改 +- `trusted-paymasters.schema.json` —— JSON schema for IDE 智能提示 + +### 文档 + +新增: +- `docs/M2_DESIGN.md` —— 本文件 +- `docs/PAYMASTER_PROFILES.md` —— 插件开发指南(如何写一个新 profile) +- `docs/SECURITY_M2.md` —— §5 安全分析独立成文,作为审计入口 + +更新: +- `docs/FORK_DELTA.md` —— 增加 M2 增量条目(trusted-paymasters 配置 / paymasterProfiles 插件骨架 / Fast Lane 5 个 hook) +- `docs/CHAIN_CONFIG.md` —— 每条目标链推荐的 trusted-paymasters 地址和 fast-lane-fee-tolerance + +不动: +- 所有 M1 章节涉及的代码(rate limit、上游同步治理、协议核心方法) +- `src/rpc/validation/*` —— 不动 ERC-7562 tracer 一行 +- `src/mempool/reputationManager.ts` —— reputation 对 Fast Lane 同样生效,不需改 +- `src/handlers/*` —— gas oracle 不动 +- 任何合约 —— bundler 不出合约改动;SuperPaymaster v3 协调放 M3 + +--- + +## 10 · M2 → M3 切换条件 + +满足以下**全部**条件后,关闭 M2,启动 M3 设计稿: + +1. M2 §8 验收检查表全部打钩 +2. OP-Sepolia 灰度 ≥ 1 周稳定运行: + - Fast Lane op 上链 ≥ 50 笔 + - 0 误判事件(普通 op 被错误打 Fast Lane 标 / Fast Lane op 被错误走普通路径) + - 无安全事件(恶意 paymaster 加白、Fast Lane 路径漏过任何 simulation) +3. OP-Mainnet 灰度 N 笔 SuperPaymaster v3 + xPNTs UserOp 全部成功上链 +4. 运营方至少做过一次配置 reload(SIGHUP 验证生产可用) +5. SuperPaymaster v3 团队确认合约层无需为 Fast Lane 做任何修改(M2 没有任何 attestation / signaling 协调,确认这一假设成立) + +**M3 预定范围**(此处仅占位提示,正式 M3 设计稿由独立文档定义): +- X402 收费链路(xPNTs / aPNTs 计费走 SuperPaymaster `settleX402Payment`) +- attestation 方案 C(SuperPaymaster 合约暴露 attestation,bundler 改为链上信任绑定,移除运营白名单依赖) +- 双因子识别(§6 trusted-account-implementations) +- admin endpoint reload(§1.3 推后部分) +- 监控告警 webhook(utility wallet 余额、Fast Lane 队列长度、误判率) +- 上游同步自动化(M1 §4.2 的手动流程升级为 GitHub Actions) + +M3 设计文档在 M2 验收完成且生产灰度 1 周后开写。 diff --git a/docs/M3_DESIGN.md b/docs/M3_DESIGN.md new file mode 100644 index 00000000..e3b16b88 --- /dev/null +++ b/docs/M3_DESIGN.md @@ -0,0 +1,899 @@ +# UltraRelay-AAStar · M3 产品设计 + +> **里程碑定位**:M3 = "**对外开放 + 收费 + 运维成熟**"。M2 完成绿色通道(trusted-paymasters 白名单 + fast-lane 协议核心 + EIP-7702 完整实战)后,bundler 在内部生态闭环已经跑通;M3 把 bundler 升级为**面向公网开放、可计费、可观测、可水平扩展的生产级服务**。具体覆盖四大块:(A) 对外收费通道(X402 / xPNTs 预存 / ETH 预存);(B) 运维基础设施成熟化(监控告警 webhook / Prometheus + Grafana / 上游同步自动化 / RPC 成本治理);(C) 协议演进(Operator attestation / postOp gas 精算);(D) 横向扩展(多链 / 多实例 / 灰度部署)。 +> +> **不在 M3**:bundler 内置 paymaster 业务逻辑(永远不在 bundler,由 SuperPaymaster 负责);UserOp 级别的链上隐私保护;自研聚合器 / mev-share 集成;非 EVM 链支持;自营算力托管控制面(Web 控制台、计费 dashboard 全功能版)。 +> +> **当前状态**:M1 已交付协议核心 + 公网最低安全门槛 + 上游同步治理铺路;M2 已交付 trusted-paymasters 白名单、fast-lane 通道、operator attestation 协议位(占位)。M3 是把"开放收费"和"生产高可用"两条腿同时补齐——任何一条腿先上都会被另一条腿卡住(开放了但运维没成熟会被打挂;运维成熟了但收费没接通无法验证 SLA 与商业模式)。 +> +> **依赖前提**: +> - M1 全部验收完毕(协议合规 + rate limit + 上游同步治理) +> - M2 全部验收完毕(trusted-paymasters 白名单 + fast-lane) +> - SuperPaymaster v3 在目标链已部署且 postOp 计费稳定 +> - xPNTs 社区 token 在目标链已部署且 community owner 配合调用 `addAutoApprovedSpender(bundlerAddr)` + +--- + +## 0 · M3 验收顺序 + +M3 范围广,按"先收费通道再运维再协议演进再横向扩展"四阶段推进。每阶段必须前置阶段完成且无回归。 + +1. **阶段 A:收费通道**(A.1 → A.2 → A.3 → A.4) + - 先 X402 协议层(A.1),跑通 HTTP 402 握手回路 + - 再 xPNTs 预存收款(A.2),与 trusted-paymasters 白名单互斥规则验证 + - 备选 ETH 预存(A.3)做 trade-off 评估,按需启用 + - SDK 集成示例(A.4)作为对外接入文档 +2. **阶段 B:运维成熟**(B.1 → B.2 → B.3 → B.4 并行可行) + - B.1 监控告警 webhook(运维硬门槛,先做) + - B.2 Grafana dashboard 完整化 + - B.3 上游同步自动化(GitHub Action) + - B.4 RPC 成本治理(getLogs cache + receiptCache 暴露 + 调用计数) +3. **阶段 C:协议演进**(C.1 → C.2) + - C.1 Operator attestation in paymasterAndData(跨仓库协调,需 SP / AirAccount 团队同步发版) + - C.2 postOp gas 精算(仅外部 X402 用户受益,明确写"内部 fast-lane 无收益") +4. **阶段 D:横向扩展**(D.1 → D.2 → D.3) + - D.1 多链上线(Linea / Scroll / Base,按链入 `docs/CHAIN_CONFIG.md`) + - D.2 多实例水平扩展(复用 M1 已支持的 Redis 共享 mempool) + - D.3 灰度 / canary 部署流程 + +每阶段产出验收报告(写入 `docs/M3_ACCEPTANCE.md`),全部打钩后 M3 签字关闭。 + +--- + +## 部分 A · 收费通道 + +> M3 的核心商业逻辑:bundler 不再只服务白名单内的 SuperPaymaster fast-lane 用户,**对所有非白名单 UserOp 开放收费通道**。收费通道必须满足三个原则: +> 1. **协议标准**:用 Coinbase x402 业界标准,不自创协议 +> 2. **互斥**:白名单内(fast-lane)免费 + 不收 X402;白名单外才走 X402——避免双重收费 +> 3. **多种支付资产**:xPNTs(生态首选)+ ETH(备选)双通道,用户按场景选 + +### A.1 X402 收费协议(HTTP 402 Payment Required) + +- **业务价值**:bundler 一旦对外开放,必须有标准化的"收费握手"协议——客户端首次提交 → bundler 返回 402 + 明码标价 → 客户端付款 → 重发带支付凭证 → bundler 受理。Coinbase 的 x402 标准已被 OpenAI / Anthropic / 多家 AI agent 服务采用,作为"AI 调链上服务"的支付层事实标准。我们采用 x402 而非自创协议,可以让任何已实现 x402 的 SDK / agent 框架零适配接入。 +- **必要性**: + - 没有 X402,bundler 没法对白名单外的 op 收费——要么白送(utility wallet 烧钱),要么全部拒绝(关闭对外开放) + - 没有标准化协议,每个客户端都要为我们做特殊适配,等于把生态合作方挡在门外 + - x402 与 HTTP 标准 status code 402 完全兼容,任何 HTTP 客户端都能解析 +- **流程**: + 1. 客户端调 `POST /rpc` 提交 `eth_sendUserOperation(userOp, entryPoint)` + 2. bundler 进入收费判定: + a. 解析 `userOp.paymaster`(v0.7)或 `paymasterAndData`(v0.6) + b. 若 paymaster ∈ trusted-paymasters 白名单(M2 已加载)→ 走 fast-lane,不收费,跳过 X402 + c. 若 `userOp.paymaster == 0` 且 `boost == false` 且 `maxFeePerGas > 0` → 标准 ERC-4337 流程(用户自付 gas),不收费,跳过 X402 + d. 若 paymaster 为 0 且走 boost 路径 → bundler 垫 ETH,**必须收费** + e. 若 paymaster 不在白名单 → bundler 不愿意承担其失败风险(reputation 损耗),**必须收费** + 3. **收费场景**触发时,bundler 不立即处理 op,而是返回 HTTP 402: + ```http + HTTP/1.1 402 Payment Required + Content-Type: application/json + X-Payment-Required: {"version":"x402/1","accepts":[ + {"scheme":"erc20","token":"0x","amount":"","recipient":"0x","chainId":10}, + {"scheme":"deposit","mode":"eth","amount":"","recipient":"0x","chainId":10} + ],"nonce":"","expiresAt":} + + {"jsonrpc":"2.0","error":{"code":-32402,"message":"Payment required","data":{...}},"id":} + ``` + 4. 客户端选一种支付方式: + - **xPNTs 预存**(A.2):客户端无需现场转账,bundler 内部记账扣余额;客户端在请求 header 带 `X-Payment-Proof: {"scheme":"xpnts-prepaid","account":"0x"}` + - **ETH 预存**(A.3):客户端在 `PrepaidGas.sol` 合约里有余额;带 `X-Payment-Proof: {"scheme":"eth-prepaid","account":"0x"}` + - **现场支付**(可选 M3+,本期不做):客户端先转账,把 tx hash 作为 proof + 5. 客户端**重发原 UserOp** + `X-Payment-Proof` header + 6. bundler 校验 proof: + - 解析 proof 的 `scheme` + - xPNTs:查内部预存账本余额 ≥ 报价 `amount` → 扣账本 → 进入 mempool + - eth-prepaid:查 `PrepaidGas` 合约 `balanceOf(sender) ≥ amount` → 调 `PrepaidGas.charge(sender, amount, opHash)` → 进入 mempool + 7. bundle 上链后: + - 若 op 成功 → 收费已扣,结束 + - 若 op revert / drop → bundler 退款(xPNTs 回账本;eth-prepaid 调 `PrepaidGas.refund(sender, amount, opHash)`) +- **技术方案**: + - **新增模块**:`src/billing/` + - `src/billing/x402.ts` — X402 协议封装:`buildPaymentRequiredResponse(userOp, quotes)` 构造 402 响应;`parsePaymentProof(headers)` 解析 proof;`Quote` 类型(`{ scheme, token?, amount, recipient, chainId }`) + - `src/billing/pricing.ts` — 报价计算:基于 `userOp.callGasLimit + verificationGasLimit + preVerificationGas` × `gasPrice` × `markup`(默认 1.2x)+ 固定服务费(如 0.001 USD 等值 xPNTs) + - `src/billing/ledger.ts` — 内部预存账本(xPNTs / ETH 双通道),后端用 Redis(M1 已支持)持久化;接口 `getBalance(account, asset)` / `charge(account, asset, amount, opHash)` / `refund(account, asset, amount, opHash)` / `topup(account, asset, amount, source)`;charge/refund 用 `opHash` 做幂等 + - `src/billing/index.ts` — `enforceX402({ userOp, headers, trustedPaymasters }): Promise<{ allow: true } | { allow: false, response: X402Response }>` + - **挂载点**:`src/rpc/methods/eth_sendUserOperation.ts` 和 `boost_sendUserOperation.ts` 的入口,在 mempool 校验**之前**调 `enforceX402`;返回 `allow: false` 时 RPC 直接抛带 `httpStatus: 402` 的 RpcError(需要扩展 `RpcError` 支持自定义 httpStatus,或者在 fastify 层用钩子拦截) + - **CLI 配置**: + - `--billing-enabled true|false`(默认 false,灰度逐链开) + - `--billing-markup-bps 2000`(gas 加价 20%) + - `--billing-service-fee-usd 0.001`(固定服务费等值) + - `--billing-accepted-assets "xpnts,eth-prepaid"`(逗号分隔) + - `--billing-quote-ttl-seconds 60`(402 报价有效期) + - `--billing-prepaid-gas-contract 0x...`(PrepaidGas 合约地址) + - **互斥规则**:`enforceX402` 第一行就检查 `if (trustedPaymasters.has(userOp.paymaster)) return { allow: true }`——白名单永远优先,永不收费,与 M2 fast-lane 完全互斥 + - **响应规范**:返回 HTTP status 402(不是 200),body 用 JSON-RPC error 格式 `code = -32402`,data 字段放 X402 详情;header `X-Payment-Required` 为 stringify 的 X402 quote 列表(兼容只看 header 的简化客户端) + - **测试**: + - 单元:402 响应结构、报价计算、proof 解析、互斥规则 + - 集成:白名单 paymaster → 不返 402;非白名单无 proof → 返 402;带正确 proof → 进 mempool;带过期 proof → 返 402 + `expired` reason +- **验收**: + - OP-Sepolia 上提交一笔无 paymaster 的 UserOp → 收 402 → 客户端预存 xPNTs → 重发 → 成功上链 → 账本余额正确扣减 + - 白名单 paymaster UserOp 提交 → 不触发 402,直接进 mempool(互斥确认) + - 标准 ERC-4337(用户自付 gas,不走 boost)UserOp → 不触发 402(仅对"bundler 出 gas"或"bundler 承担 paymaster 风险"的场景收费) + +### A.2 xPNTs 预存收款(生态首选) + +- **业务价值**:xPNTs 是 AAStar 生态的社区积分代币,用户在社区获得后**无需 approve** 就能被预授权 spender 扣款(`xPNTsToken.sol:241-251` 的 `allowance` 重写返回 `type(uint256).max`)。bundler 接入 xPNTs 收费通道后: + - 用户体验:不需要单独购买 xPNTs,社区内自然流通的 xPNTs 直接可付 bundler 费 + - 生态闭环:bundler 收来的 xPNTs 可转回 SuperPaymaster 兑换 aPNTs / 反哺社区,资金不出生态 + - 零额外 approve:用户无需对 bundler 单独 approve,体感与"白名单免费"无差异(仅扣点 xPNTs 而已) +- **必要性**: + - 没有 xPNTs 通道,外部用户只能用 ETH 付费——破坏 AAStar"用户全程不接触 ETH"的核心叙事 + - xPNTs 的预授权机制(autoApprovedSpenders)+ 防火墙(transferFrom 只允许 to=msg.sender 或 to=SuperPaymaster)天然适合 bundler 收款:bundler 把自己加为 spender 后,只能把用户 xPNTs 转给自己(`to == msg.sender == bundler`),合约层杜绝越权 +- **流程**: + 1. **预备**(一次性,per community per chain): + a. 社区 owner 对每个 xPNTsToken 调 `addAutoApprovedSpender(bundlerCollectorAddr)`(`xPNTsToken.sol:446-453`) + b. bundlerCollectorAddr 是 bundler 配置里的"收款地址"(与 executor wallet 区分,防止收款混入运营资金) + c. 这一步**必须由社区 owner 主动配合**——bundler 没有调用权限。运营层需要建立"接入清单"治理(哪些社区接入了 xPNTs 收费、bundler 在哪些 token 上是 autoApprovedSpender) + 2. **客户端预存**: + a. 客户端首次接入时调 bundler 的 `pimlico_topupXPNTs(account, token, amount)` 端点(M3 新增),bundler 返回需要的转账信息 + b. 客户端用户 wallet 手动转 xPNTs 给 `bundlerCollectorAddr`(不走 bundler,是直接的 ERC20 transfer);或者通过 SuperPaymaster 走 SponsorTransfer 路径 + c. bundler 监听 xPNTsToken 的 `Transfer(from, to=bundlerCollectorAddr, value)` 事件,识别后入账到内部账本 `ledger.topup(account, "xpnts:", value, "transfer:")` + d. 客户端 query `pimlico_getXPNTsBalance(account, token)` 查余额 + 3. **扣款**(每笔 op): + a. X402 报价命中 xPNTs(A.1 步骤 6)→ bundler 在 mempool 接收前调 `ledger.charge(account, "xpnts:", amount, opHash)` + b. 账本扣减 → 进 mempool + c. **链下扣账,链上不发 transferFrom**——这是预存模型的核心,避免每笔 op 多发一次 ERC20 tx 烧 gas + d. 周期(如每日)批量结算:bundler 把当日所有用户的"已扣 xPNTs"调 `xPNTsToken.transferFrom(user, bundlerCollectorAddr, totalAmount)` 一次性上链 + - 单笔 ≤ 5000 ether(`MAX_SINGLE_TX_LIMIT`,约 $100);超过则拆多笔 + - 每笔 transferFrom 走的是 `to == msg.sender == bundlerCollectorAddr`,触发 `xPNTsToken.sol:262-277` 的防火墙允许路径 + - 注意:`autoApprovedSpenders` 路径的 transferFrom 限制 `to ∈ {msg.sender, SUPERPAYMASTER_ADDRESS}`,bundler 必须用自己的 collector 地址作为 `to` +- **技术方案**: + - **新增模块**: + - `src/billing/xpnts/collector.ts` — xPNTs 转账事件监听 + 入账(用现有 `eventManager` 基础设施扩展,每个 xPNTsToken 一个 watcher) + - `src/billing/xpnts/settler.ts` — 周期结算 cron,批量调 transferFrom 上链(用 utility wallet 签) + - **配置**: + - `--xpnts-collector-address 0x...`(收款地址,必须 ≠ executor wallet,建议独立 EOA) + - `--xpnts-tokens "10:0xtok1,10:0xtok2"`(chain:token 列表) + - `--xpnts-settlement-interval-seconds 86400`(默认每日结算一次) + - `--xpnts-settlement-batch-size 50`(一次结算最多多少用户) + - **互斥与防双重收费**(**关键**): + - **trusted-paymasters 白名单内的 paymaster**(M2 加)→ 不触发 X402 → 不扣 xPNTs → 仅 SuperPaymaster v3 内部按其逻辑扣 aPNTs/xPNTs 一次 + - **白名单外**(如外部 paymaster 或无 paymaster)→ 触发 X402 → 扣 xPNTs(bundler 收)+ 若挂了外部 paymaster,paymaster 自己也会扣一次(按它的合约逻辑) + - **必须**在文档和 SDK 里**显式声明**:白名单 paymaster 与 X402 互斥;外部 paymaster 用户需要自己确认 paymaster 不会重复收费 + - 实现保证:`enforceX402` 第一行硬编码 `if (trustedPaymasters.has(paymaster)) return allow`——这一规则纳入 M3 验收 checklist + - **退款逻辑**: + - op revert / drop 后调 `ledger.refund(account, "xpnts:", amount, opHash)` 把扣的 xPNTs 还回账本 + - opHash 作为幂等 key,防止退款被重复 + - **失败兜底**: + - 结算 transferFrom 失败(用户 xPNTs 余额已转走 → 余额不足)→ 标记 user 为"欠费",加入黑名单短期不接其 op;运维收 webhook 告警人工处理 +- **验收**: + - 社区 owner 调 `addAutoApprovedSpender(bundlerCollector)` 后,bundler 能识别并允许接收该 token 收费 + - 用户预存 100 xPNTs → bundler 账本显示 100 → 提交一笔 op 报价 5 xPNTs → 扣后账本显示 95 + - 周期结算 → 链上 `xPNTsToken.balanceOf(bundlerCollector)` 增加;用户 xPNTsToken 余额减少;与账本扣减总额相符 + - 单笔超过 5000 ether 自动拆分;不会触发 `SingleTxLimitExceeded` + - 白名单 paymaster UserOp 不触发 xPNTs 扣款(互斥规则) + +### A.3 ETH 预存模式(备选) + +- **业务价值**:xPNTs 通道需要每个社区 owner 配合调 `addAutoApprovedSpender`,存在跨社区配置成本和治理成本(社区 owner 不配合 → 该社区用户无法用 xPNTs 付费)。ETH 预存提供一条"零生态依赖"的备用通道:客户端往 bundler 部署的 `PrepaidGas.sol` 合约预存 ETH,bundler 内部账本扣账。适用场景: + - 非 AAStar 生态的外部 SDK / agent 框架接入 + - 新部署的链上 xPNTs 还没有社区铺开 + - 紧急通道:xPNTs 结算系统出故障时的兜底 +- **必要性**: + - 生态外用户没有 xPNTs,只有 ETH + - 给 bundler 一条不依赖 SuperPaymaster / xPNTs 治理的独立收款通路,降低单点依赖风险 + - **trade-off 必须明示**:要部署额外合约(`PrepaidGas.sol`,独立审计)+ 要写 SDK 配合(客户端预存调用)+ 用户体验差(需要先持有 ETH 并转账,破坏"用户不接触 ETH"叙事)。所以 A.3 是**备选不是首选**——默认关闭,按需开启。 +- **流程**: + 1. **合约部署**:M3 一次性部署 `PrepaidGas.sol`(在 UltraRelay-AAStar 仓库,参考 SuperPaymaster 的合约组织方式或新建 `contracts/` 目录),每条目标链一份 + 2. **客户端预存**: + a. 客户端调 `PrepaidGas.deposit{value: amount}()`(直接转 ETH 进合约,记到 `balances[msg.sender]`) + b. bundler 监听 `Deposited(account, amount)` 事件,入账到 `ledger.topup(account, "eth", amount, "deposit:")` + 3. **扣款**: + a. X402 报价命中 `eth-prepaid` → bundler 调 `ledger.charge(account, "eth", amount, opHash)` + b. **链下扣账**,与 xPNTs 同模式 + c. 周期(如每日)批量结算:bundler 调 `PrepaidGas.batchCharge(accounts[], amounts[], opHashes[])` 一次性上链;ETH 从合约的用户余额转到 bundler 收款地址 + 4. **客户端提现**:用户随时调 `PrepaidGas.withdraw(amount)` 取回未扣的余额(合约内 `balances[msg.sender] - pendingCharges[msg.sender] >= amount` 才允许) +- **技术方案**: + - **新增合约**:`contracts/PrepaidGas.sol` + - `mapping(address => uint256) public balances` + - `mapping(address => uint256) public pendingCharges`(防止用户在 bundler 链下扣账后立即 withdraw 偷跑) + - `deposit() payable`:增加 balance + emit `Deposited` + - `withdraw(amount)`:要求 `balances[msg.sender] - pendingCharges[msg.sender] >= amount` + - `lockCharge(address user, uint256 amount, bytes32 opHash) onlyBundler`:链下扣账后,bundler 调此函数把待扣金额标 pending(防 withdraw 攻击) + - `unlockCharge(address user, uint256 amount, bytes32 opHash) onlyBundler`:op revert 退款时撤销 pending + - `batchSettle(bytes32[] opHashes) onlyBundler`:把对应 pending 转为实际扣款,从 balance 减 + 转 ETH 给 collector + - `setBundler(address) onlyOwner`:bundler 角色可热更换 + - 简单可审计,无升级代理(一次性部署,不需要也不要 upgradeable) + - **合约审计**:M3 必须有第三方审计(即便代码量小),收费合约一旦有 bug 会直接吞用户预存 ETH + - **新增模块**: + - `src/billing/eth/collector.ts` — `PrepaidGas` Deposited 事件监听 + 入账 + - `src/billing/eth/settler.ts` — 周期 batchSettle 调用 + - `src/billing/eth/lock.ts` — 链下扣账后异步调 `lockCharge` 防 withdraw 偷跑 + - **配置**: + - `--billing-prepaid-gas-contract 0x...`(每链一份,与 chainId 关联) + - `--billing-eth-enabled true|false`(默认 false,按链开) + - `--billing-eth-settlement-interval-seconds 86400` + - `--billing-eth-collector-address 0x...` + - **trade-off 明示文档**:`docs/BILLING_ETH_VS_XPNTS.md`(M3 产出)列两通道对比表(部署成本 / SDK 复杂度 / 用户体验 / 治理依赖 / 风控) +- **验收**: + - PrepaidGas 合约部署 + 第三方审计报告归档 + - 客户端 deposit 1 ETH → bundler 账本显示 1 ETH + - 提交一笔报价 0.001 ETH 的 op → 账本扣 → lockCharge 成功 → 不能 withdraw 已 pending 部分 + - 周期 settle → 链上 collector 收到 ETH,pendingCharges 清零 + - op revert → unlockCharge 退账,用户可全额 withdraw + +### A.4 X402 SDK 集成示例(curl + permissionless.js) + +- **业务价值**:协议规范有了不等于客户端会用——必须有可复制的接入示例,否则生态合作方接入成本高、问题多。我们提供 (a) 最小 curl 脚本演示协议握手 (b) permissionless.js 适配示例(permissionless.js 是 ERC-4337 SDK 事实标准),这两份就能覆盖 90% 接入场景。 +- **必要性**: + - 让首次接入的开发者 1 小时内跑通"提交 op → 收 402 → 预存 → 重发 → 上链"全流程 + - 暴露设计中没考虑到的边缘 case(如 nonce 顺序、超时重试、并发预存) +- **流程**: + 1. 写两份 example 项目:`examples/x402-curl/` 和 `examples/x402-permissionless/` + 2. curl 版本:纯 shell + jq,演示协议层;包含 (a) 提交 op (b) 解析 402 (c) 预存(直接 ERC20 transfer 或 PrepaidGas.deposit)(d) 重发带 X-Payment-Proof (e) 查询 receipt + 3. permissionless.js 版本:TypeScript,演示如何把 X402 拦截器接到 viem-style transport;包含自动预存触发和重试逻辑 + 4. 各配 `README.md` 说明依赖、配置、运行步骤 + 5. 把 examples 链接到主仓 `README.md` 和 `docs/M3_DESIGN.md` 的接入章节 +- **技术方案**: + - **examples/x402-curl/**: + - `submit.sh` — 调 `eth_sendUserOperation`,捕获 402 响应 + - `parse-402.sh` — 提取 `X-Payment-Required` 报价 + - `topup-xpnts.sh` — 调链 ERC20 transfer(用 cast / foundry) + - `topup-eth.sh` — 调 PrepaidGas.deposit(用 cast) + - `resubmit.sh` — 带 `X-Payment-Proof` 重发 + - `Makefile` 串起来 + - **examples/x402-permissionless/**: + - `package.json` 依赖 `permissionless`、`viem` + - `src/x402-transport.ts` — 实现 `customTransport` 包装,自动捕获 402 → 触发预存 → 重发;提供 `createX402Transport({ topupCallback, paymentProofProvider })` 工厂 + - `src/example.ts` — 完整 e2e:deploy account → send userOp → 自动走 X402 + - `README.md` 含 quickstart + - **文档**: + - `docs/X402_INTEGRATION.md`(M3 产出)— 协议规范、报价语义、错误码、SDK 适配指南 +- **验收**: + - 两份 example 在 OP-Sepolia 跑通(含完整步骤截图 / 日志) + - 第三方开发者按 README 1 小时内能跑通(找一个 AirAccount/SP 团队同事做用户测试) + - 协议错误(过期 proof、余额不足、reused opHash)的客户端处理路径都有示例 + +--- + +## 部分 B · 运维成熟 + +> M2 完成时 bundler 已经"协议合规、对内闭环",但运维基础设施仍停留在"日志 + Prometheus 端点存在"的最低水位。M3 把它升级到"出问题 5 分钟内有人收到告警、Grafana dashboard 一眼看到健康度、上游同步无人值守、RPC 成本可观测可治理"的生产高可用水准。 + +### B.1 监控告警 webhook(Slack / Discord / PagerDuty) + +- **业务价值**:M1 的 `utilityWalletMonitor` 只在日志里告警——日志没人主动看,等到余额耗尽 bundler 停摆才发现就太晚了。webhook 推到 IM / on-call 系统能让运维"分钟级响应"。同时把"bundler 异常退出"、"RPC 失败率"、"bundle revert 率"三类高优事件也接入。 +- **必要性**: + - 公网开放 + 收费 → SLA 敏感度直线上升 → 必须有秒级 / 分钟级告警链路 + - utility wallet 余额耗尽 → bundler 全部 boost / fast-lane op 立即停摆 → 用户感知度 100% → 必须 P0 告警 + - bundler 进程 crash 自重启失败 → 完全不可用 → 必须 P0 告警 + - RPC 失败率突增 → 上游 provider 故障或限流 → 必须 P1 告警以便切备用 RPC + - bundle revert 率突增 → 可能是新链协议 bug 或恶意 op 风暴 → 必须 P1 告警 +- **流程**: + 1. **告警源接入**:bundler 内部新增 `AlertManager` 中央告警分发器;现有的 `utilityWalletMonitor` 和新增的健康检查模块向其上报事件 + 2. **事件类型与级别**: + - P0:utility/executor wallet 余额 < 阈值;bundler 进程退出;RPC 100% 失败连续 N 秒 + - P1:bundle revert rate > 阈值(默认 5%)持续 N 分钟;RPC 失败率 > 阈值(默认 10%);x402 收款延迟 > 阈值 + - P2:mempool size > 阈值;wallet balance < 警戒线但未到 P0 + 3. **路由**:bundler 启动时按配置把不同级别推到不同 webhook(P0 → PagerDuty;P1 → Slack #ops;P2 → Slack #ops-low) + 4. **抑制**:同一 alertKey 在 cooldown 时间内(默认 5 分钟)不重复推送,避免告警风暴 + 5. **resolved 通知**:告警条件恢复后推送一条 `[RESOLVED]` 消息(Slack / Discord 支持) +- **技术方案**: + - **新增模块**:`src/monitoring/alertManager.ts` + - `interface Alert { key: string, level: "P0"|"P1"|"P2", title: string, body: object, source: string }` + - `class AlertManager { fire(alert: Alert): Promise; resolve(alertKey: string): Promise }` + - 内部维护 `Map` 做 cooldown + - **Channel adapters**:`src/monitoring/channels/` + - `slack.ts` — POST 到 Slack Incoming Webhook URL,body 用 attachments 格式(红/黄/绿色块按级别) + - `discord.ts` — POST 到 Discord webhook URL(content + embed) + - `pagerduty.ts` — Events API v2,触发 incident(dedup_key = alertKey 自动 dedup) + - 每个 adapter 接 `{ url, ...auth }` 配置,统一接口 `send(alert)` + - **集成点**: + - `src/executor/utilityWalletMonitor.ts` — 现有日志告警旁加 `alertManager.fire(...)` + - `src/executor/executorManager.ts` — bundle revert 统计 + 阈值触发 + - `src/cli/createServer.ts`(或 entry)— `process.on('uncaughtException' | 'unhandledRejection' | 'SIGTERM')` 触发 P0 告警 + - RPC 失败统计:在 viem transport 包装层加成功/失败计数器,per-method 累加,周期 evaluator 触发告警 + - **配置**: + - `--alert-slack-webhook https://hooks.slack.com/...` + - `--alert-discord-webhook https://discord.com/api/webhooks/...` + - `--alert-pagerduty-routing-key xxx` + - `--alert-channels "p0:pagerduty,slack;p1:slack;p2:slack"` + - `--alert-cooldown-seconds 300` + - `--alert-bundle-revert-threshold-bps 500`(5%) + - `--alert-rpc-failure-threshold-bps 1000`(10%) + - `--alert-rpc-failure-window-seconds 60` + - `--alert-min-balance-eth 0.1` +- **验收**: + - 触发 utility wallet 低余额告警 → Slack / Discord 收到消息(含 wallet 地址、当前余额、阈值) + - kill bundler 进程 → PagerDuty 收到 incident + - cooldown 验证:连续 10 次低余额事件 → 5 分钟内只推 1 条 + - resolved 验证:充值后再 fire 不重复,转入 resolved 状态推 1 条恢复通知 + - 配置不同 webhook URL 不同级别 → 路由分流正确 + +### B.2 Prometheus metrics 完整化 + Grafana dashboard + +- **业务价值**:M1 已有 `/metrics` 端点但暴露的指标不全;现在 M3 要把"运营关心的所有维度"补齐,并做一个开箱即用的 Grafana dashboard 模板,运维 / 商务 / 产品都能用同一份 dashboard 看到 (a) bundler 健康度 (b) 商业指标(X402 收入)(c) 资产指标(钱包余额)。 +- **必要性**: + - 没有完整 metrics 就没有 SLO/SLA 度量基础;告警阈值(B.1)的设定都靠 metrics 历史数据 + - Grafana dashboard 是非工程师(商务、产品、合规)了解 bundler 状态的唯一可读窗口 + - 多 bundler 实例(D.2)部署后必须有汇总视图 +- **流程**: + 1. **指标盘点**:列出全部需暴露指标,分四类 + - 协议指标:mempool size、bundle 提交速率、failure rate、reputation drop count、validation reject count + - 资产指标:wallet balance(per executor wallet、per chainId)、xPNTs collector balance(per token)、PrepaidGas contract TVL + - 商业指标:paymaster usage(按 paymaster 地址聚合 op count + gas 总消耗)、fast-lane 命中率(fast-lane vs 标准 vs X402 比例)、X402 收费总额(per asset、per chain)、X402 退款总额、X402 拒付次数 + - RPC 治理指标:RPC call 计数(按 method + provider)、RPC 失败率、getLogs cache 命中率、receiptCache 命中率 + 2. **实现**:用 prom-client(已有依赖)扩展 metric,按 Prometheus 命名规范加 namespace `alto_` 前缀 + 3. **Grafana dashboard**:建一份 JSON dashboard 模板,按角色分 row(健康 / 资产 / 商业 / RPC);提交到 `monitoring/grafana-dashboard.json` + 4. **告警规则模板**:在 `monitoring/alert-rules.yaml` 提供示例 PromQL 告警表达式(与 B.1 webhook 配对) +- **技术方案**: + - **新增 / 扩展模块**: + - `src/utils/metrics.ts`(已有,扩展)— 加新 metric 注册: + - `alto_billing_x402_charged_total{asset, chain}` — Counter + - `alto_billing_x402_refunded_total{asset, chain}` — Counter + - `alto_billing_x402_402_responses_total{reason}` — Counter + - `alto_billing_xpnts_settlement_total{token, chain}` — Counter + - `alto_billing_ledger_balance{account, asset}` — Gauge(仅大额账户暴露,避免 cardinality 爆炸;需阈值过滤) + - `alto_paymaster_usage_total{paymaster, chain}` — Counter + - `alto_paymaster_gas_total{paymaster, chain}` — Counter + - `alto_lane_hits_total{lane="fast"|"standard"|"x402"|"boost"}` — Counter + - `alto_rpc_calls_total{method, provider, status}` — Counter + - `alto_rpc_cache_hits_total{cache="getLogs"|"receipt"}` — Counter + - `alto_rpc_cache_misses_total{cache="getLogs"|"receipt"}` — Counter + - `alto_wallet_balance_eth{wallet_role, address, chain}` — Gauge + - `alto_xpnts_collector_balance{token, chain}` — Gauge + - `alto_prepaid_gas_tvl{chain}` — Gauge + - 注意 cardinality:account / userOpHash / sender 这种高 cardinality label 不进 metrics(只进日志) + - **Grafana dashboard JSON**: + - 4 row:Health / Assets / Business / RPC + - 每 panel 配默认时间窗(1h / 6h / 24h / 7d) + - 多链切换:dashboard 顶部加 `chain` 变量,所有 panel 用 `chain=$chain` 过滤 + - 多实例切换:加 `instance` 变量 + - **配置**: + - 现有 `/metrics` 端点不变;只是暴露的 metric 数量增加 + - 新增 `--metrics-account-balance-threshold 100`(账本余额超过此值才暴露,控 cardinality) +- **验收**: + - prom2json `/metrics` 输出包含全部新 metric,无 cardinality 爆炸(label 组合数 < 10000) + - Grafana 导入 `monitoring/grafana-dashboard.json` 后 4 row 全部出图 + - 模拟一笔 X402 收费 → `alto_billing_x402_charged_total` +1 + - 多实例部署后 dashboard 切换 instance 变量正确分流 + +### B.3 上游同步自动化(GitHub Action) + +- **业务价值**:M1 的上游同步是手动月度流程(`docs/UPSTREAM_SYNC.md`),依赖人记得做、依赖人有空做。自动化后:定时跑 `git fetch upstream && git merge upstream/main → 创建 PR`,CI 跑通后人审 merge。降低漂移风险,把"是否要 merge 上游"从"决策"变成"review PR"。 +- **必要性**: + - fork 治理基线——M1 已经定下"持续跟住上游"的目标,但手动流程随团队规模会失效 + - 上游 ZeroDev / Pimlico 修 bug 越快接入越好(特别是安全 fix) +- **流程**: + 1. **GitHub Action workflow**:`.github/workflows/upstream-sync.yml` + - cron 每周日 02:00 UTC 触发(也支持 workflow_dispatch 手动) + - checkout 仓库 + 配 upstream remote(用 secret token) + - `git fetch upstream` + - 检查 `git log main..upstream/main` 是否有新 commit;若无则跳过 + - 在 `chore/upstream-sync-YYYYMMDD` 分支上 `git merge upstream/main` + - 若 merge 冲突 → 把冲突标记 commit 后开 PR;PR description 列冲突文件清单 + 提示 reviewer 按 `docs/FORK_DELTA.md` 解决 + - 若 merge 干净 → 直接开 PR + - PR 自动加 label `upstream-sync`、reviewer 默认配运维 owner + - PR description 包含 upstream commit 列表(commit hash + 标题) + 2. **CI 集成**:PR 触发现有 CI(lint / build / unit test / e2e) + 3. **人审 merge**:reviewer 看 CI 全绿 + 逐 commit 看 upstream 变更后 squash merge 到 `aastar-dev`(**不**自动 merge——上游有可能引入业务行为变化,必须人审) + 4. **冲突解决依据**:仍参照 `docs/FORK_DELTA.md`(M1 产出,M3 持续维护) +- **技术方案**: + - **新增文件**:`.github/workflows/upstream-sync.yml` + ```yaml + name: Upstream Sync + on: + schedule: + - cron: "0 2 * * 0" # 每周日 02:00 UTC + workflow_dispatch: + jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.UPSTREAM_SYNC_TOKEN }} + - name: Configure git + run: | + git config user.name "upstream-sync-bot" + git config user.email "ops@aastar.io" + - name: Add upstream + run: git remote add upstream https://github.com/zerodevapp/ultra-relay.git + - name: Fetch upstream + run: git fetch upstream + - name: Check for new commits + id: check + run: | + count=$(git rev-list --count main..upstream/main) + echo "new_commits=$count" >> $GITHUB_OUTPUT + - name: Create sync branch + if: steps.check.outputs.new_commits != '0' + run: | + date=$(date +%Y%m%d) + git checkout -b chore/upstream-sync-$date main + git merge upstream/main || true + git push origin chore/upstream-sync-$date + - name: Open PR + if: steps.check.outputs.new_commits != '0' + uses: peter-evans/create-pull-request@v6 + with: + base: aastar-dev + title: "chore: upstream sync $(date +%Y-%m-%d)" + body: | + Automated upstream sync. ${{ steps.check.outputs.new_commits }} new commits. + Resolve conflicts per docs/FORK_DELTA.md. + labels: upstream-sync + reviewers: + ``` + - **Secret**:`UPSTREAM_SYNC_TOKEN` — fine-grained PAT,权限 `contents:write` + `pull-requests:write` on this repo + - **文档更新**:`docs/UPSTREAM_SYNC.md` 加"自动化部分"章节描述 workflow 行为;`docs/FORK_DELTA.md` 维护增量清单不变 +- **验收**: + - workflow_dispatch 手动触发能跑通(在 upstream 没新 commit 时正确跳过;有时正确开 PR) + - 故意制造一个冲突场景 → PR 开出 + body 标注冲突文件 + - PR 触发 CI 全套通过 + - reviewer 流程跑一次(即便上游没新东西也走 sandbox 演练) + +### B.4 RPC 成本治理 + +- **业务价值**:bundler 是重 RPC 调用的服务(estimateGas / call / getLogs / getTransactionReceipt 全都频繁)。付费 RPC provider(Alchemy / QuickNode)按调用量计费,月成本随业务量线性涨。M3 加 (a) getLogs LRU cache 削减重复调用 (b) receiptCache TTL/容量参数化暴露 (c) 按 method 统计 RPC call,可观测可治理。每条都直接降本或提供降本依据。 +- **必要性**: + - getLogs 是最贵的调用(按 block 范围扫,没 cache 会被反复调) + - receipt query 在重发轮询场景下有重复(pimlico_getUserOperationStatus + eth_getUserOperationReceipt 共享底层 receipt) + - 没 RPC call 计数 → 涨账时无法定位到底是哪个 method 烧的 +- **流程**: + 1. **getLogs LRU cache**: + - 加内存 LRU cache,key = `${address}:${fromBlock}-${toBlock}:${topic0}:${topicHash}` + - TTL 可配(默认 30 秒——logs 在 confirmed block 上不会变) + - cache size 上限可配(默认 1000 条) + - 仅对 confirmed block range(`toBlock <= latest - confirmations`)才 cache;包含 latest 的范围不 cache + - hit / miss 暴露到 metrics(B.2 已加) + 2. **receiptCache 参数化**: + - 现有 receipt cache(如已存在)的 TTL / 容量从硬编码改为 CLI flag + - 加 metrics + 3. **RPC call 计数**: + - viem transport 包装层加 hook:每次 call 前后记录 `{method, provider, status, durationMs}` + - 增量到 `alto_rpc_calls_total{method, provider, status}` Counter + - 周期 dump 到日志(每分钟一次,方便 grep) +- **技术方案**: + - **新增 / 扩展模块**: + - `src/utils/getLogsCache.ts`(新增)— 用 `lru-cache` 包: + ```ts + import LRU from "lru-cache" + export interface GetLogsCacheConfig { maxSize: number, ttlMs: number, confirmations: number } + export class GetLogsCache { + private cache: LRU + constructor(config: GetLogsCacheConfig) { ... } + async get({ address, fromBlock, toBlock, topics, latestBlock, fetcher }): Promise { + if (toBlock > latestBlock - this.config.confirmations) { + return fetcher() // not cacheable + } + const key = this.makeKey({ address, fromBlock, toBlock, topics }) + const cached = this.cache.get(key) + if (cached) { + metrics.cacheHits.inc({ cache: "getLogs" }) + return cached + } + metrics.cacheMisses.inc({ cache: "getLogs" }) + const fresh = await fetcher() + this.cache.set(key, fresh) + return fresh + } + } + ``` + - `src/handlers/eventManager.ts`(扩展)— 替换直接 `client.getLogs()` 为 `getLogsCache.get(...)` + - `src/cli/customTransport.ts`(扩展)— 在 `request` hook 加计数: + ```ts + const start = Date.now() + try { + const result = await innerRequest(args) + metrics.rpcCalls.inc({ method: args.method, provider: providerLabel, status: "ok" }) + metrics.rpcDuration.observe({ method: args.method, provider: providerLabel }, Date.now() - start) + return result + } catch (e) { + metrics.rpcCalls.inc({ method: args.method, provider: providerLabel, status: "error" }) + throw e + } + ``` + - **配置**: + - `--get-logs-cache-enabled true|false`(默认 true) + - `--get-logs-cache-ttl-ms 30000` + - `--get-logs-cache-max-size 1000` + - `--get-logs-cache-confirmations 10`(确认数以下不 cache) + - `--receipt-cache-ttl-ms`(已有 → 保留) + - `--receipt-cache-max-size`(已有 → 保留) + - `--rpc-call-log-interval-seconds 60`(周期日志 dump 频率,0 = 不 dump) +- **验收**: + - 同样 getLogs 查询 100 次 → cache hit 率 > 95%(仅 confirmed range) + - 包含 latest 的查询每次 cache miss(不 cache 行为正确) + - `alto_rpc_calls_total` metric 按 method / provider / status 正确分桶 + - Grafana dashboard RPC row 出图 + - cache 满时 LRU evict 行为正常 + +--- + +## 部分 C · 协议演进 + +> M1 / M2 实现的是 ERC-4337 标准能力 + 业务定制;M3 加入下一代账户抽象演进所需的协议位(Operator attestation 信任链、postOp 精算)。这部分对内主要是协议合规升级,对外则提升与 AirAccount / SuperPaymaster 协同效率。 +> +> **注**:EIP-7702 完整实战 已迁移到 M2 §4(业务上 fast-lane 需要支持 EOA-as-sender 路径,故前置)。 + +### C.1 Operator attestation in `paymasterAndData` + +- **业务价值**:M2 fast-lane 方案 A 已经允许 trusted-paymasters 白名单内的 op 跳过部分 validation,但信任的边界仍是 paymaster 合约——若 paymaster 合约被攻击或私钥泄露,整个白名单失效。M3 加入"Operator attestation"信任层:在 `paymasterAndData` 末尾追加 `[operatorSig(65)]` 字段,由 operator(与 paymaster 协作的运营方,如 SuperPaymaster operator)私钥签 op 摘要。bundler 持公钥白名单,对每笔 fast-lane op 用 `ecrecover` 校验一次(无需上链 / 无 gas 消耗)。这把信任链从"合约层"延伸到"运营层"——即使 paymaster 私钥泄露,operator 私钥仍能拦下未授权 op。 +- **必要性**: + - 防御纵深:paymaster 合约 + operator 签名双因子,任一层被攻破不导致灾难 + - 跨主体信任:bundler / paymaster / operator 可能是不同主体(M3 后期可能 SuperPaymaster 由社区运营、operator 由 AAStar 核心团队签),attestation 是"主体间信任"的协议位 + - 这是 M2 方案 A 之上的最强信任链路,但**跨仓库协调成本大**——需要 SuperPaymaster + AirAccount 团队协同发版本,需要协议升级文档 +- **流程**: + 1. **协议规范**(M3 跨仓库产出 `docs/OPERATOR_ATTESTATION_SPEC.md`,与 SP / AirAccount 评审): + - `paymasterAndData` 末尾追加 `[operatorSig(65)]` + - 摘要:`keccak256(abi.encode(chainId, entryPoint, userOpHash, paymaster, validUntil))`(与 SuperPaymaster 现有 paymaster signature 摘要解耦——不复用,避免冲突) + - 签名格式:EIP-191 personal_sign(更易兼容硬件钱包) + - validUntil 复用 paymaster 现有字段 + 2. **SuperPaymaster 侧改动**(**不在本仓库**,需协调): + - SP 构造 `paymasterAndData` 时在末尾 append operator signature + - 现有 paymaster validation 不变(兼容旧版本不带 operator sig 的 op) + - 接口加 `setOperatorSig(bytes)` 配 SP SDK 用 + 3. **bundler 侧改动**(本仓库): + - 加载 operator 公钥白名单(CLI flag) + - fast-lane 校验路径:解析 `paymasterAndData`,提取末尾 65 字节 → ecrecover → 检查 recovered address ∈ operator allowlist + - 校验失败 → 拒绝走 fast-lane,降级到标准 validation 路径(不直接 reject,给一次容错机会) + - 加 metric `alto_operator_attestation_total{result="ok"|"invalid"|"missing"}` + 4. **AirAccount 侧改动**(**不在本仓库**,需协调): + - SDK 在构造 op 时调 SP 后端拿 operator 签名一并塞进 `paymasterAndData` +- **技术方案**: + - **新增模块**:`src/validator/operatorAttestation.ts` + ```ts + export interface OperatorAttestationConfig { + enabled: boolean + operatorAllowlist: Address[] + digestVersion: "v1" + } + export function verifyOperatorAttestation({ + userOp, + chainId, + entryPoint, + userOpHash, + config + }): { ok: boolean, recovered?: Address, reason?: string } { + const paymasterData = extractPaymasterData(userOp) + if (paymasterData.length < 65) return { ok: false, reason: "missing" } + const sig = paymasterData.slice(-65) + const validUntil = extractValidUntil(paymasterData) + const digest = encodeOperatorDigest({ chainId, entryPoint, userOpHash, paymaster: userOp.paymaster, validUntil }) + const recovered = ecrecover(digest, sig) + if (!config.operatorAllowlist.includes(recovered)) { + return { ok: false, reason: "not-in-allowlist", recovered } + } + return { ok: true, recovered } + } + ``` + - **集成点**:M2 fast-lane 通道入口处调 `verifyOperatorAttestation`;失败则降级走标准路径 + - **配置**: + - `--operator-attestation-enabled true|false`(默认 false,与 SP / AirAccount 一起灰度开) + - `--operator-allowlist "0x...,0x..."`(公钥列表) + - **跨仓库协调清单**(**M3 关键风险项**,明示): + - 与 SuperPaymaster 团队对齐 attestation 协议规范、digest 编码、签名 schema + - 与 AirAccount 团队对齐 SDK 改动、灰度计划 + - 三方联合 e2e 测试 + - 协议升级文档(`docs/OPERATOR_ATTESTATION_SPEC.md`)三仓库共享 + - **协调成本评估**:≥ 2 周,包含规范评审 + 各侧实现 + 联合测试。如果时间不允许,C.1 可推到 M4,M3 只完成本仓库 bundler 侧实现并注入测试 stub +- **验收**: + - 单元测试:digest 编码与 SP 侧实现 byte-perfect 一致;ecrecover 路径全过 + - 集成测试(mock operator 签名):fast-lane op 带正确签名 → 通过;无签名或错签名 → 降级走标准路径 + - 三方联合 e2e(OP-Sepolia):SP 构造 op + operator 签 + bundler 校验 + 上链 + - metric `alto_operator_attestation_total` 正确分桶 + +### C.2 postOp gas 精算(仅 SuperPaymaster v3 用户) + +- **业务价值**:bundler `eth_estimateUserOperationGas` 默认对 paymaster postOp 用通用 buffer(如 50000 gas)。SuperPaymaster v3 的 postOp 逻辑相对复杂(refund 计算 + xPNTs burn / debt record + reputation feedback),通用 buffer 既可能高估(用户多付 xPNTs)也可能低估(OOG)。M3 加"识别 paymaster ∈ SuperPaymaster v3 → 用专用 estimate 替代通用 buffer",让外部 X402 用户对接 SP v3 时少付 xPNTs。 +- **必要性**: + - **明确收益方**:仅外部 X402 用户有收益(精确度提升 → 用户少付 xPNTs 给 SuperPaymaster)。**内部 fast-lane 用户无此收益**——fast-lane op 不走 paymaster v3 计费,跑的是绿色通道,bundler 直接垫付 ETH 然后链下记账。这点必须在文档里强调,避免误期望。 + - 长期看:postOp 精算降低 paymaster 的 "validation buffer" 系数(SP v3 当前 `VALIDATION_BUFFER_BPS` 高估),整体生态 xPNTs 流转效率提升 +- **流程**: + 1. **识别**:bundler 在 estimation 阶段拿到 `userOp.paymaster`,查内置 paymaster profile 表 + 2. **profile 表**:表驱动,每个已知 paymaster 一份配置: + ```ts + interface PostOpProfile { + paymasterAddress: Address + paymasterName: "SuperPaymaster-v3.5" | "SuperPaymaster-v3.6" + postOpGasEstimate: bigint // 实测得来的 95% percentile + comment: string + } + ``` + 3. **使用**:estimation 时若 paymaster ∈ profile 表 → 用 `profile.postOpGasEstimate` 替换默认 buffer;否则用默认 buffer + 4. **校准**:M3 期间用 e2e + 主网真实数据收集 SP v3 实际 postOp gas 分布,定 95% percentile 写入表 +- **技术方案**: + - **新增模块**:`src/executor/paymasterPostOpProfile.ts` + ```ts + export const POST_OP_PROFILES: Record = { + "0x": { + paymasterAddress: "0x", + paymasterName: "SuperPaymaster-v3.5", + postOpGasEstimate: 80000n, // 95p, measured on OP-Mainnet 2026-04 + comment: "burnFromWithOpHash success path; recordDebt fallback adds ~15k" + }, + "0x": { ... } + } + + export function getPostOpGasEstimate(paymaster: Address | undefined, defaultBuffer: bigint): bigint { + if (!paymaster) return 0n + const profile = POST_OP_PROFILES[paymaster.toLowerCase()] + return profile?.postOpGasEstimate ?? defaultBuffer + } + ``` + - **集成点**:`src/rpc/estimation/` 估算路径里替换原 buffer 取值为 `getPostOpGasEstimate` + - **配置**: + - `--postop-profile-enabled true|false`(默认 true) + - `--postop-profile-file ./postop-profiles.json`(覆盖内置表,运维快速调参用) + - **校准方法**: + - e2e 跑 100 笔 SP v3 op → 收集 `actualGasCost - validationGasUsed - executionGasUsed` 当 postOp 实际值 + - 取 95p(避免极端 case 导致 OOG) + - 每季度 review 一次 +- **验收**: + - SP v3 paymaster 的 op estimate 结果中 postOp gas 与默认 buffer 显著不同(验证 profile 生效) + - 100 笔 e2e 无 OOG 失败(profile 值合理) + - 文档明确写"仅外部 X402 用户有收益,内部 fast-lane 无影响" + - profile 表更新流程文档化(`docs/POSTOP_PROFILE_CALIBRATION.md`) + +--- + +## 部分 D · 横向扩展 + +> M1/M2 把 OP-Sepolia / OP-Mainnet 跑通;M3 把 bundler 推到多链、多实例、灰度部署的真生产架构。这部分的工程量主要在配置矩阵和运维流程,代码改动相对少。 + +### D.1 多链上线(Linea / Scroll / Base) + +- **业务价值**:AAStar 业务覆盖范围超出 OP——Linea / Scroll / Base 是当前 EVM L2 三大候选,每条链都有 AirAccount 部署需求和潜在 paymaster 合作方。bundler 多链支持是"扩生态"的硬门槛。 +- **必要性**: + - 单链 bundler = 单链业务上限 + - 不同 L2 的 RPC 行为差异(gas oracle / block tag / debug_traceCall 支持度 / EIP-7702 支持度)必须在配置层固化,否则每新接一条链都要改代码 +- **流程**: + 1. **逐链 spike**:每条新链先做 1-2 周 spike,覆盖 + - RPC provider 选型(Alchemy / QuickNode / Infura 各自支持度) + - gas oracle 选择(继承 EVM 默认 / OP / 自研 manager) + - block tag 支持(PR #12 的 flag) + - EIP-7702 支持度 + - debug_traceCall 支持度(决定 safe-mode 可否开启) + - 真链一笔 e2e UserOp(含部署 + 提交 + 收据) + 2. **配置矩阵入文档**:每条链一份配置示例 + 推荐参数,写入 `docs/CHAIN_CONFIG.md` + 3. **CI 增量**:e2e suite 加 multi-chain 矩阵(用 anvil fork 各链) + 4. **灰度上线**:新链先 testnet 跑 1 周 → mainnet 灰度(仅运营方自己 SDK 流量)→ 全开 +- **技术方案**: + - **代码改动**: + - 多数情况无需改代码,靠 CLI flag + chain handler 选择就够(M1 已有 `optimismManager` 的 chainId 路由模式) + - 若新链需要专用 gas manager(如 Linea 有特殊 priority fee 计算)→ 新增 `src/handlers/lineaGasPriceManager.ts` 等 + - chain handler factory(`src/handlers/index.ts` 或类似)按 chainId 路由 + - **配置矩阵**(写入 `docs/CHAIN_CONFIG.md`): + | Chain | chainId | gas oracle | block-tag | debug_traceCall | EIP-7702 | safe-mode | + |-------|---------|-----------|-----------|-----------------|----------|-----------| + | OP-Mainnet | 10 | optimism | true | yes | yes | true | + | OP-Sepolia | 11155420 | optimism | true | yes | yes | true | + | Linea | 59144 | TBD | TBD | TBD | TBD | TBD | + | Scroll | 534352 | TBD | TBD | TBD | TBD | TBD | + | Base | 8453 | TBD(OP-stack 衍生,可能复用 optimism manager) | TBD | TBD | TBD | TBD | + - **测试**:每条链至少 e2e 跑通:boost 路径 + 标准 ERC-4337 + fast-lane(若该链有部署 SP)+ X402(若该链开了 billing) +- **验收**: + - Linea / Scroll / Base 三条链 testnet 各 e2e 跑通 + - mainnet 至少一条新链灰度 1 周稳定 + - `docs/CHAIN_CONFIG.md` 全表填满 + - 多链共享 mempool 验证(D.2 配合) + +### D.2 多 bundler 实例水平扩展 + +- **业务价值**:单实例 bundler 是 SPOF(单点故障)+ 容量上限(单进程 RPS / mempool size)。多实例水平扩展提供 (a) 高可用(任一实例挂另一实例顶上)(b) 可扩容量(按 RPS 加实例)(c) 灰度能力(D.3 前提)。 +- **必要性**: + - 公网开放后,业务量增长不可预测,必须有水平扩展能力 + - SLA 承诺(如 99.9%)单实例做不到,多实例 + 健康检查 + 负载均衡才能做到 +- **流程**: + 1. **架构**:多 bundler 实例共享 Redis mempool(M1 已支持)+ 各自独立 executor wallet 池(避免 nonce 冲突)+ 共享 utility wallet pool(用 mutex 协调) + 2. **executor wallet 池**: + - 每实例分配独立 executor wallet 子集,wallet 与 instance 1:1 绑定 + - 通过 deterministic key derivation 从 master mnemonic 派生:`m/44'/60'/'/0/` + - bundler CLI 接 `--instance-id N --wallet-count M`,自动派生 + 3. **utility wallet 协调**(boost 路径用): + - 多实例共享一个 utility wallet pool(如 5 个),用 Redis 分布式锁租用 + - 每发一笔 boost tx 前 acquire lock → send → release + - lock TTL = 30 秒(防 deadlock) + 4. **负载均衡**: + - HTTP 层用 nginx / ALB 做 round-robin,所有实例的 `/health` 端点喂给 LB + - WebSocket 用 sticky session(按 client IP hash 路由到固定实例) + 5. **配置同步**: + - trusted-paymasters 白名单 / operator allowlist / billing 配置等放 config 文件,所有实例 mount 同一份 + - 重新加载用 SIGHUP 信号或 etcd / consul watch(M3 取简单方案:SIGHUP) +- **技术方案**: + - **代码改动**: + - 大部分基础设施 M1 已就绪(Redis store / Redis mempool) + - 新增 `src/executor/utilityWalletLock.ts`:基于 Redis SETNX 实现分布式锁(lock key = `utility-wallet:${address}`、TTL 30s) + - executor wallet 派生:`src/cli/walletDerivation.ts`,按 instance_id + index BIP-44 派生 + - **配置**: + - `--instance-id 0`(实例编号,0..N-1) + - `--instance-count 4`(总实例数) + - `--executor-wallet-count 10`(每实例 wallet 数) + - `--utility-wallet-pool-key prefix:utility`(Redis 锁 key 前缀) + - **运维 runbook**(写入 `docs/MULTI_INSTANCE.md`): + - 启动顺序、滚动更新流程、wallet 充值矩阵、健康检查配置 +- **验收**: + - 部署 3 实例共享 Redis → 提交 30 笔 op → 三实例分别处理 ~10 笔 → 全部上链 + - kill 1 实例 → 剩下 2 实例继续正常处理 + - utility wallet lock 验证:3 实例并发 30 笔 boost → 无 nonce 冲突 + - LB 健康检查:故意让 1 实例 `/health` 503 → LB 自动剔除 + +### D.3 灰度 / canary 部署 + +- **业务价值**:M3 后业务对 bundler 高度依赖;任何升级(新版本 / 新配置)的 bug 都会直接影响线上用户。灰度部署让我们先把 1% 流量打到新版本 → 观察 metrics 1-2 小时 → 没问题再 10% → 最终 100%。回滚时间 < 5 分钟。 +- **必要性**: + - 没有灰度 → 升级 = 全量风险 → 团队怕升级 → bug fix 上线慢 + - SLA 要求 → 必须有可控回滚机制 +- **流程**: + 1. **基础设施**: + a. CI/CD 把每次 release 部署到 `canary` 实例(独立 1 个实例,不在主流量池) + b. nginx / ALB 配置 weighted round-robin:99% → 主集群、1% → canary + 2. **观察期**: + a. canary 上线后 30 分钟自动跑 smoke test(自动化 e2e 一笔 op) + b. 接下来 1-2 小时人审 Grafana dashboard:error rate / latency p99 / bundle revert rate / X402 收费成功率 + 3. **晋升**: + a. 通过 → 把 canary 配置升级到主集群(rolling update,逐实例替换) + b. 失败 → 回滚 canary(撤掉权重)+ 故障 review + 4. **回滚**: + a. 任一时点把 LB 权重改 0% canary → 流量秒级回主集群 + b. canary 实例继续保留 24 小时供事后分析 + 5. **流程文档**:`docs/RELEASE_PLAYBOOK.md` 详述每步 +- **技术方案**: + - **代码改动**:无(bundler 本身不感知是否是 canary) + - **基础设施**: + - LB 配置(nginx / ALB)支持 weighted backend,文档化配置模板 + - CI/CD(GitHub Actions)增加 deploy-canary job + - smoke test 脚本:`scripts/canary-smoke-test.sh`,自动跑一笔 op + 检查 receipt + - **观察指标 SLO**:canary 与主集群同期对比,差异 > 阈值(默认 latency p99 +30% / error rate +1%)则不晋升 + - **配置**: + - `--canary-mode true|false`(仅影响日志 label,方便 metrics 分流) + - canary 与主集群共享 Redis mempool(同一池,通过 instance_id 区分日志) +- **验收**: + - 灰度发布一次新版本 → canary 1% 流量 → 30 分钟 smoke test 通过 → 晋升到 10% → 全开 + - 故意发一个有 bug 的版本 → canary smoke test 失败 → 自动 alert + 不晋升 + - 回滚演练:从 canary 100% 回滚到 0% < 5 分钟 + - `docs/RELEASE_PLAYBOOK.md` 完整可执行 + +--- + +## E · 验收检查表(最终签字依据) + +| # | Feature | 类型 | 验收方式 | 状态 | +|---|---------|------|---------|------| +| A.1 | X402 协议握手 | 收费 | OP-Sepolia 收 402 + 重发上链 e2e | ☐ | +| A.1 | X402 互斥规则 | 收费 | 白名单 paymaster 不触发 402 | ☐ | +| A.2 | xPNTs 预存入账 | 收费 | Transfer 事件入账 + 余额查询正确 | ☐ | +| A.2 | xPNTs 链下扣账 | 收费 | charge → 账本扣 → opHash 幂等 | ☐ | +| A.2 | xPNTs 周期结算 | 收费 | 链上 transferFrom 成功 + 拆分 5000 ether 上限 | ☐ | +| A.2 | xPNTs 退款 | 收费 | op revert → refund → 账本回滚 | ☐ | +| A.3 | PrepaidGas 合约审计 | 收费 | 第三方审计报告归档 | ☐ | +| A.3 | ETH 预存入账 | 收费 | deposit → 账本入账 | ☐ | +| A.3 | ETH 链下扣账 + lock | 收费 | charge → lockCharge → 不可 withdraw | ☐ | +| A.3 | ETH batchSettle | 收费 | 周期结算上链 ETH 给 collector | ☐ | +| A.4 | curl SDK 示例 | 收费 | OP-Sepolia 跑通 + README 完整 | ☐ | +| A.4 | permissionless.js SDK 示例 | 收费 | OP-Sepolia 跑通 + 第三方用户测试 | ☐ | +| A.4 | docs/X402_INTEGRATION.md | 收费 | 协议规范完整 | ☐ | +| B.1 | Slack/Discord webhook | 运维 | 触发余额告警 → IM 收到 | ☐ | +| B.1 | PagerDuty webhook | 运维 | kill bundler → incident 触发 | ☐ | +| B.1 | 告警 cooldown | 运维 | 连续告警 5 分钟内只推 1 条 | ☐ | +| B.1 | resolved 通知 | 运维 | 恢复后推送 RESOLVED | ☐ | +| B.2 | 完整 metrics | 运维 | prom2json 验证全部新 metric | ☐ | +| B.2 | Grafana dashboard | 运维 | 4 row 全部出图 + 多链/多实例切换 | ☐ | +| B.2 | metrics cardinality | 运维 | label 组合数 < 10000 | ☐ | +| B.3 | upstream-sync workflow | 运维 | workflow_dispatch 触发跑通 | ☐ | +| B.3 | 冲突 PR 流程 | 运维 | 故意制造冲突 → PR body 标注 | ☐ | +| B.4 | getLogs LRU cache | 运维 | hit 率 > 95% on confirmed range | ☐ | +| B.4 | RPC call 计数 | 运维 | metric 按 method/provider 分桶 | ☐ | +| B.4 | receiptCache 参数化 | 运维 | CLI flag 暴露 + 生效 | ☐ | +| C.1 | OPERATOR_ATTESTATION_SPEC | 协议 | 三仓库评审通过 | ☐ | +| C.1 | bundler 校验路径 | 协议 | 单元 + 集成测试覆盖 | ☐ | +| C.1 | 三方联合 e2e | 协议 | OP-Sepolia 跑通 | ☐ | +| C.2 | postop-profile 表 | 协议 | SP v3 estimate 走专用值 | ☐ | +| C.2 | 100 笔 e2e 无 OOG | 协议 | profile 值合理验证 | ☐ | +| C.2 | 校准流程文档 | 协议 | docs/POSTOP_PROFILE_CALIBRATION.md | ☐ | +| D.1 | Linea testnet e2e | 横向 | 真链跑通 | ☐ | +| D.1 | Scroll testnet e2e | 横向 | 真链跑通 | ☐ | +| D.1 | Base testnet e2e | 横向 | 真链跑通 | ☐ | +| D.1 | docs/CHAIN_CONFIG.md | 横向 | 全表填满 | ☐ | +| D.2 | 3 实例共享 mempool | 横向 | 30 笔 op 三实例分担 + 全上链 | ☐ | +| D.2 | utility wallet 分布式锁 | 横向 | 并发 boost 无 nonce 冲突 | ☐ | +| D.2 | 实例故障切换 | 横向 | kill 1 实例继续正常 | ☐ | +| D.3 | canary 灰度发布 | 横向 | 1% → 10% → 100% 全流程 | ☐ | +| D.3 | canary 回滚 | 横向 | < 5 分钟回滚演练 | ☐ | +| D.3 | RELEASE_PLAYBOOK | 横向 | 文档完整可执行 | ☐ | +| F | 主网灰度收费 | 部署 | 真实 X402 op 上链且账本对账成功 | ☐ | +| G | 多链生产部署 | 部署 | 至少 3 链 mainnet 稳定 1 周 | ☐ | + +--- + +## F · M3 输出物清单 + +代码改动(新增): +- `src/billing/x402.ts` — X402 协议封装 +- `src/billing/pricing.ts` — 报价计算 +- `src/billing/ledger.ts` — 内部预存账本(Redis 持久化) +- `src/billing/index.ts` — `enforceX402` 入口 +- `src/billing/xpnts/collector.ts` — xPNTs 转账事件监听 + 入账 +- `src/billing/xpnts/settler.ts` — 周期结算 cron +- `src/billing/eth/collector.ts` — PrepaidGas Deposited 监听 +- `src/billing/eth/settler.ts` — 周期 batchSettle +- `src/billing/eth/lock.ts` — 异步 lockCharge 调用 +- `src/monitoring/alertManager.ts` — 中央告警分发器 +- `src/monitoring/channels/{slack,discord,pagerduty}.ts` — 三类 webhook adapter +- `src/utils/getLogsCache.ts` — LRU cache +- `src/validator/operatorAttestation.ts` — Operator attestation 校验 +- `src/executor/paymasterPostOpProfile.ts` — postOp 精算 profile +- `src/executor/utilityWalletLock.ts` — Redis 分布式锁 +- `src/cli/walletDerivation.ts` — 多实例 BIP-44 派生 +- `src/handlers/lineaGasPriceManager.ts`(如需要) — 新链 gas manager +- `src/handlers/scrollGasPriceManager.ts`(如需要) + +代码改动(扩展): +- `src/cli/config/options.ts` — 加 M3 全部 CLI flag(billing-* / alert-* / get-logs-cache-* / operator-attestation-* / postop-profile-* / instance-* / canary-mode) +- `src/rpc/methods/eth_sendUserOperation.ts` — 入口接 enforceX402 +- `src/rpc/methods/boost_sendUserOperation.ts` — 同上 +- `src/rpc/methods/index.ts` — 注册 `pimlico_topupXPNTs` / `pimlico_getXPNTsBalance` / `pimlico_getETHPrepaidBalance` 等查询端点 +- `src/rpc/estimation/` — 接 `getPostOpGasEstimate` +- `src/handlers/eventManager.ts` — 替换 getLogs 为 getLogsCache +- `src/cli/customTransport.ts` — 加 RPC call 计数 hook +- `src/utils/metrics.ts` — 注册全部新 metric +- `src/executor/utilityWalletMonitor.ts` — 接 alertManager +- `src/executor/executorManager.ts` — bundle revert 阈值告警 + +合约(新增): +- `contracts/PrepaidGas.sol` — ETH 预存合约(含 deposit / withdraw / lockCharge / unlockCharge / batchSettle) + +CI/CD: +- `.github/workflows/upstream-sync.yml` — 上游同步自动化 +- `.github/workflows/deploy-canary.yml` — canary 部署 job +- `scripts/canary-smoke-test.sh` — 灰度后自动 smoke test + +监控配置: +- `monitoring/grafana-dashboard.json` — Grafana dashboard 模板 +- `monitoring/alert-rules.yaml` — Prometheus 告警规则示例 + +文档(新增): +- `docs/M3_DESIGN.md` — 本文件 +- `docs/M3_ACCEPTANCE.md` — 验收检查表(同 §E,独立成文便于打钩归档) +- `docs/X402_INTEGRATION.md` — X402 协议规范、报价语义、错误码、SDK 适配指南 +- `docs/BILLING_ETH_VS_XPNTS.md` — xPNTs vs ETH 通道 trade-off 对比 +- `docs/OPERATOR_ATTESTATION_SPEC.md` — Operator attestation 协议规范(三仓库共享) +- `docs/POSTOP_PROFILE_CALIBRATION.md` — postOp profile 校准流程 +- `docs/MULTI_INSTANCE.md` — 多实例部署 runbook +- `docs/RELEASE_PLAYBOOK.md` — 灰度 / 回滚 playbook +- `docs/UPSTREAM_SYNC.md` — 加自动化章节(更新 M1 文档) + +文档(更新): +- `docs/CHAIN_CONFIG.md` — 加 Linea / Scroll / Base 配置矩阵 +- `docs/FORK_DELTA.md` — 持续更新(新增 M3 fork 增量;PR #13 已在 M2 §4 实战验证) + +Examples: +- `examples/x402-curl/` — curl + jq 协议握手示例 +- `examples/x402-permissionless/` — TypeScript SDK 集成示例 + +不动: +- ERC-4337 standard / Pimlico 扩展 RPC 方法集(M1 已固定) +- M2 fast-lane / trusted-paymasters 通道核心逻辑 +- ZeroDev boost endpoint 协议层(M1 已固定) +- SuperPaymaster 合约(不在本仓库;C.1 协议规范变更需要 SP 团队配合发版本,但本仓库不直接改 SP 合约) +- xPNTsToken 合约(不在本仓库;A.2 仅依赖既有 `addAutoApprovedSpender` / `transferFrom` 接口,不要求合约改动) + +--- + +## G · M3 风险与缓解 + +| 风险 | 影响 | 缓解 | +|------|------|------| +| X402 协议规范与生态实现不一致 | SDK 接入失败 | 严格遵循 Coinbase x402/1 spec;examples 用业界 SDK 验证 | +| xPNTs 跨社区接入治理慢 | A.2 上线延迟 | 先在 1-2 个旗舰社区验证;其余按需接入;同时 A.3 ETH 通道做兜底 | +| PrepaidGas 合约 bug 吞用户预存 ETH | 资金风险 | 强制第三方审计;合约不升级(无 proxy);金额上限保护 | +| Operator attestation 跨仓库协调超期 | C.1 推迟 | M3 内 bundler 侧做完即可,SP/AirAccount 侧推到 M4 不阻塞;本仓库提供 mock-operator e2e 验证逻辑正确性 | +| 多实例 wallet 派生密钥泄露 | 资金被盗 | 用 KMS / HSM 管 master mnemonic;派生 key 只在内存;定期轮换 | +| canary 灰度的 1% 流量打到关键大用户 | 大用户体验受损 | LB 配置基于 IP hash 排除大用户白名单 IP,确保灰度流量都是低敏感度请求 | +| xPNTs 5000 ether 单笔上限触发 | 结算失败 | settler 自动按上限拆分;超大额账户单独走多笔结算 | +| 双重收费(X402 + 外部 paymaster 自己收)| 用户多付 | 文档显式声明;SDK 检测警告;M3+ 可探索"协议级 fee 透明"机制 | +| 上游 sync 引入 breaking change | 大量手工修改 | PR 流程强制人审;CI e2e 全套通过;冲突按 FORK_DELTA.md 决策 | + +--- + +## H · M3 → 后续切换条件 + +M3 §E 验收表全部打钩 + 至少 3 链 mainnet 稳定运行 1 周 + 至少一笔真实 X402 收费完整对账(上链 + 账本 + 结算)后,M3 关闭。 + +后续方向(M4 候选,**不在 M3 范围**): +- 自营 bundler 控制台(Web UI 显示账本、运维操作、对账报表) +- 自动化对账与发票(X402 收入按客户聚合、月度对账单) +- bundler-as-a-service 多租户(不同租户独立 mempool / 独立 wallet pool / 独立计费) +- mev-share / private mempool 集成 +- 非 EVM 链探索(Solana / TON 的等效抽象层,需要重新评估架构) diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md new file mode 100644 index 00000000..63a27ebc --- /dev/null +++ b/docs/RUNBOOK.md @@ -0,0 +1,191 @@ +# UltraRelay-AAStar Operations Runbook + +## 0 · 适用范围 + +M1 阶段 OP-Sepolia / OP-Mainnet prod 部署。M2 / M3 上线时本手册扩展。 + +本文是给运维 / DevOps 看的"上线必读",列出 prod 部署的关键约束、部署清单、月度上游同步流程、故障排查手册和版本回滚策略。配套 `docs/M1_DESIGN.md`(产品设计)+ `docs/UPSTREAM_SYNC.md`(同步细则)+ `docs/UPSTREAM_PR_QUEUE.md`(待提上游 PR)一同阅读。 + +--- + +## 1 · 必配项(不配会出事) + +### 1.1 --chain-type op-stack +- **必须配置** for OP-Sepolia (chainId 11155420) 和 OP-Mainnet (chainId 10) +- **不配的后果**:preVerificationGas 严重低估(不计 L1 数据费),UserOp 上链 OOG,executor wallet 烧空 gas 但 op 失败 +- **来源**:dispatch 由运维显式配置 `--chain-type` 决定(`src/cli/config/options.ts:471-484`),bundler **不会**按 chainId 自动选 manager +- **验证方式**:启动后查 logs 确认 `chainType: op-stack` 输出;提交一笔 UserOp,对比 estimateGas 给出的 preVerificationGas 与链上实际消耗(误差应 < 5%) + +### 1.2 IP allowlist (--rate-limit-allowlist) +- **必须配置** prod 环境 +- **列出允许调用 bundler 的 IP**:AAStar 自己的 SDK 服务器、内部后端、监控系统 +- **不配的后果**:任何外部 IP 都能调 `eth_sendUserOperation` with `maxFeePerGas == 0` 触发 bundler 垫付 ETH(详见 M1 §5.5 Known Limitation 2 — `eth_sendUserOperation` 接到零 fee 时自动升级为 boost 模式,由 utility wallet 垫付) +- **配置方式**:CLI `--rate-limit-allowlist "1.2.3.4,5.6.7.8"`,或 rate-limit config 文件中的 `allowlist` 数组 +- **验证方式**:从 allowlist 之外的 IP 发 200 req/min,确认前 N 通过、剩下被 429;从 allowlist IP 不被限 + +### 1.3 BETTER_STACK_TOKEN 环境变量(如需 JSON 日志) +- 详见 M1 §5.5 Known Limitation 1 +- **不配的后果**:`--json true` 退化为 pino-pretty 彩色输出,日志聚合 filter 困难 +- **临时绕过**:`BETTER_STACK_TOKEN=dummy`(注意这会让 pino 尝试连 Better Stack endpoint 失败,但 stdout JSON 部分会工作) + +### 1.4 utility wallet 余额监控 +- bundler 启动配 `--min-balance`(建议至少够 100 笔 boost op 的总 gas) +- 余额低于阈值时 `utilityWalletMonitor` 在日志告警;M3 加 webhook(Slack / Discord) +- **余额耗尽 = bundler 立刻停摆**(boost 路径无 utility ETH 即无法发 handleOps tx) + +--- + +## 2 · 部署清单(prod 上线前) + +### 2.1 环境变量 +- `BETTER_STACK_TOKEN`(如需 JSON 日志,详见 §1.3;未配置时 logger 退化到 pino-pretty) +- 其他根据部署平台需要的环境变量(k8s secret、ECR auth、Redis auth 等) + +### 2.2 CLI flags 必备组合(OP-Mainnet 启动示例) + +```bash +node ./lib/cli/alto.js \ + --network op \ + --rpc-url "https://opt-mainnet.g.alchemy.com/v2/YOUR_KEY" \ + --rpc-basic-auth-username "your_user" \ + --rpc-basic-auth-password "your_pass" \ + --entrypoints "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789,0x0000000071727De22E5E9d8BAf0edAc6f37da032" \ + --executor-private-keys "0x...,0x...,0x..." \ + --utility-private-key "0x..." \ + --chain-type op-stack \ + --safe-mode true \ + --bundle-mode auto \ + --max-bundle-count 8 \ + --min-balance 100000000000000000 \ + --rate-limit-enabled true \ + --rate-limit-max 100 \ + --rate-limit-window-ms 60000 \ + --rate-limit-allowlist "10.0.1.5,10.0.1.6" \ + --enable-horizontal-scaling true \ + --redis-endpoint "redis://your-redis-host:6379" \ + --json true \ + --log-level info \ + --port 3000 +``` + +注意点: +- `--chain-type op-stack` 不能省(详见 §1.1) +- `--rate-limit-allowlist` 必须填实际的 SDK / 后端 IP(详见 §1.2) +- `--executor-private-keys` 多个用逗号分隔,建议 3-5 个并发 executor +- `--utility-private-key` 单个,需保持余额(详见 §1.4) +- 横向扩展用 Redis 时 `--enable-horizontal-scaling true` + `--redis-endpoint` 必须同时配 + +### 2.3 RPC provider +- **必须支持** `debug_traceCall`(safe-mode 必备;不支持则 spec-tests 不过) +- **推荐** OP-Mainnet 用付费 provider(Alchemy / QuickNode / Infura)。免费 endpoint quota 太低,bundler 灰度期就会撞限 +- 配 basic auth 见 M1 §3.3 — 通过 `--rpc-basic-auth-username` + `--rpc-basic-auth-password` 两个 flag 显式传递;同一组 credentials 会同时应用到 public client 和 wallet client +- **caveat**:`--send-transaction-rpc-url` 与 main `--rpc-url` 共用同一组 basic auth credentials,无独立 auth 支持 + +### 2.4 健康检查 +- `GET /health` 接 LB / k8s liveness probe(200 = bundler 进程存活,不保证 RPC 后端可用) +- `GET /metrics` 接 Prometheus scrape(M3 加 dashboard) +- `GET /wallets` 返回 `{ wallets: [executor 地址数组], chainId }`,可用于运维快速查 executor 钱包余额 + +--- + +## 3 · 月度上游同步流程 + +详细步骤见 `docs/UPSTREAM_SYNC.md`,本节列流程要点: + +1. **fetch upstream** + ```bash + git fetch upstream + git log --oneline aastar-dev..upstream/main # 看上游有什么新东西 + ``` + +2. **检查 docs/UPSTREAM_PR_QUEUE.md** + - 我们之前提给 ZeroDev 的 PR 是否已被上游 merge + - 若已 merge → 在 sync 时移除我们 fork 的等价改动(避免重复 patch) + - 若未 merge → 维持 fork 本地改动,下个月再查 + +3. **merge upstream/main 到 main → PR 到 aastar-dev** + ```bash + git checkout main + git merge upstream/main # 推到 origin/main 保持镜像 + git checkout aastar-dev + git merge main # 处理冲突,参考 docs/FORK_DELTA.md 判断哪些必须保留 + ``` + +4. **跑测试** + - `pnpm test` (e2e) + - `pnpm run test:spec`(eth-infinitism bundler-spec-tests) + - 跑通才能 push + +5. **部署灰度** + - OP-Sepolia 灰度 24h,监控错误率、bundle 提交率、wallet 余额 + - 24h 稳定后再上 OP-Mainnet + - 先 1% canary 流量,观察 1h 后 100% + +--- + +## 4 · 常见故障排查 + +### 4.1 "submitted UserOp reverted onchain" +- 检查 `--chain-type op-stack` 是否配(见 §1.1,最常见原因) +- 检查 utility / executor wallet 余额(见 §1.4) +- 看 drop 日志里的 `reason` 字段(M1 §5.5 Known Limitation 5:`sender` / `paymaster` / `factory` 嵌在 stringified userOp 里,需 grep 字符串) +- 看链上 tx 的 revert reason;hex revert reason 解码由 ZeroDev 上游已支持 + +### 4.2 bundler 不出 bundle +- 检查 `--bundle-mode` 是 `auto` 不是 `manual`(manual 模式必须显式调 `debug_bundler_sendBundleNow`) +- 检查 mempool 是否真有 op:`debug_bundler_dumpMempool`(dev/staging 可用,prod 关闭) +- 检查 executor wallet 是否被 throttled:`debug_bundler_dumpReputation` +- 检查 `--max-bundle-count` 配置(注意 M1 §5.5 Known Limitation 3:该 flag 实际限制 `getBundles()` 循环产出的 bundle 数量,不是单 bundle 内 op 数) + +### 4.3 JSON 日志不工作 +- 见 M1 §5.5 Known Limitation 1 +- 临时方案:设 `BETTER_STACK_TOKEN=dummy` 让 logger 走 JSON 分支 +- 或:让日志聚合系统直接解析 pino-pretty 的 stdout 文本(多数 aggregator 支持,效率略低) + +### 4.4 utility wallet 烧 gas 但 op 失败 +- 通常是 §4.1 的下游表现 +- 先确认 `--chain-type` 配对,再看 simulation vs onchain 的差异 +- ZeroDev 上游 PR #2 / PR #11 已移除非必要的 sender balance override(M1 §2.2),降低假阳性。若仍出现,需排查具体 op 的 paymaster / factory 行为 + +### 4.5 RPC 限流(429 from upstream provider) +- bundler 自身的 fastify rate-limit 见 M1 §4.1 +- upstream provider quota 用满需联系 provider 升档,或 reduce 自身的 polling interval +- 配多个 RPC URL 做 fallback(viem `fallback` transport,bundler 已支持 `--send-transaction-rpc-url`) + +--- + +## 5 · 版本与回滚 + +### 5.1 版本标签 +- bundler image 标签建议格式 `aastar-x.y.z`(区别于上游 zerodevapp/ultra-relay 的版本号,避免混淆) +- 每次发布在 git 打 tag:`git tag aastar-1.0.0 && git push origin aastar-1.0.0` +- 在 `docs/FORK_DELTA.md` 记录该版本包含的 fork-specific 改动 + +### 5.2 prod 灰度策略 +1. OP-Sepolia 部署 → 24h 稳定运行 + 100+ 笔 op +2. OP-Mainnet 1% canary(用 LB 切流量比例)→ 1h 观察 +3. OP-Mainnet 50% → 1h 观察 +4. OP-Mainnet 100% + +### 5.3 回滚流程 +- 上一版 image 替换(k8s rollout undo / docker tag 切换) +- **Redis state 兼容性**:M1 schema 稳定,回滚不破数据。M2 / M3 加字段时需评估迁移(字段加了不破,删字段需脚本清理) +- 回滚后立刻在 `docs/UPSTREAM_PR_QUEUE.md` 加一条 incident 记录,分析根因 + +### 5.4 紧急停摆 +- 如发现严重 bug(如 utility wallet 被恶意流量烧空、bundler 接错版本 op): + 1. 先把 LB 流量切到维护页 / 503 + 2. 再调查 + 回滚 + 修复 + 3. 修复后从 OP-Sepolia 灰度走一遍再上 OP-Mainnet +- 不要"在线热修",prod 修代码不走灰度 = 二次事故 + +--- + +## 6 · 相关文档 + +- `docs/M1_DESIGN.md` — M1 产品设计 + Known Limitations +- `docs/UPSTREAM_SYNC.md` — 月度上游同步详细步骤 +- `docs/UPSTREAM_PR_QUEUE.md` — 待提给 zerodevapp/ultra-relay 的 PR 队列 +- `docs/FORK_DELTA.md` — fork-specific 改动注册表 +- `docs/CHAIN_CONFIG.md` — 每条目标链的推荐配置矩阵 +- `docs/M1_ACCEPTANCE.md` — M1 验收检查表 diff --git a/docs/SUBSCRIPTION_DESIGN.md b/docs/SUBSCRIPTION_DESIGN.md new file mode 100644 index 00000000..c5cb1644 --- /dev/null +++ b/docs/SUBSCRIPTION_DESIGN.md @@ -0,0 +1,1414 @@ +# UltraRelay-AAStar · 订阅与计费产品规划 + +> **文档类型**:产品规划(Product Planning),不是单一里程碑设计稿。 +> +> **定位**:M1/M2/M3 已经定义"内部生态闭环 + 白名单 fast-lane + X402 一次性收费"三条 bundler-as-a-service 通道;本文档规划**面向外部用户**(不在 trusted-paymasters 名单里、也不愿走 X402 一次性付款的开发者 / 企业 / 个人)的**第四条通道**——**订阅式 / 配额式 / 合约结算式**计费体系。 +> +> **解决问题**:M3 §A.1 的 X402 是 per-UserOp 一次性握手,对**频繁调用**的客户端来说握手开销大、报价波动不可预期、跨 op 状态难追踪。订阅模型把"一次性付款"换成"长期可预测计费 + 链上账户化",且能与 SuperPaymaster v5 的 credit/agent 体系互通。 +> +> **三层设计**(可独立、可组合): +> - **Layer 1:API key 鉴权**——HTTP 层 X-API-Key,bundler 运营方人工管控,企业 / 合作 SDK 第一站 +> - **Layer 2:链上订阅状态合约**——`SubscriptionManager.sol`,链上配额(quota)+ 订阅 tier,bundler 链下查询缓存 +> - **Layer 3:aPNTs 自动抵扣**——订阅费 / per-UserOp 费用从用户 aPNTs 余额扣,依赖 **AirAccount Session Key**(algId 0x08)授权 bundler 在限定 scope 内代签 +> +> **不在本文档**: +> - bundler 内置 paymaster 业务逻辑(永远由 SuperPaymaster 出,bundler 只读 / 只签 intent) +> - 订阅价格的最终定价(由商务团队给) +> - 跨链订阅互通的具体桥接路径(Phase 4 占位) +> - SuperPaymaster v5 内部信用 / 角色 / agent 系统的合约改动(依赖 SP v5 已落地能力) +> +> **关键决策(已敲定,本文不再讨论备选)**: +> 1. SubscriptionManager **独立合约**部署在 bundler 自家仓库,不嵌入 SuperPaymaster +> 2. **复用 xPNTs 作为支付 token**(同 M3 §A.2),不引入新代币 +> 3. **复用 AirAccount Session Key 协议**(algId 0x08)做 bundler 代签授权,不自创签名协议 +> 4. Layer 1 / 2 / 3 是**正交的可组合层**,运营方按客户画像自由排列 +> 5. 订阅与 trusted-paymasters fast-lane / X402 **可共存不互斥**——一笔 op 可能同时命中订阅配额(不收费)+ trusted-paymasters fast-lane(优先级);命中规则见 §6 +> +> **当前状态**:M1 已上线、M2 设计完成、M3 设计完成。本文档属于**M4 / 长期规划**,分四个 Phase 推进,每个 Phase 可独立验收上线。 + +--- + +## 0 · 文档定位 + 与 M1/M2/M3 关系图 + +### 0.1 与既有里程碑的关系 + +``` + ┌─────────────────────────────────────────────────────┐ + │ bundler 入口 (eth_sendUserOperation / boost_*) │ + └─────────────────────────────────────────────────────┘ + │ + ┌─────────────────────────────┼─────────────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ + │ M1 标准合规通道 │ │ M2 trusted-pm │ │ M3 X402 一次性 │ + │ (用户自付 gas) │ │ fast-lane │ │ 收费 + 监控运维 │ + └────────────────┘ └────────────────┘ └────────────────┘ + │ │ │ + │ 公网开放但限流 │ 白名单内免费 │ 非白名单付钱 + │ (M1 §4.1 rate limit) │ (M2 §1) │ (M3 §A.1) + │ │ │ + └─────────────────┬───────────┴─────────────────────────────┘ + │ + │ 都不解决:频繁调用、可预期月度计费 + │ + ▼ + ┌─────────────────────────────────────┐ + │ M4 / 长期:订阅与配额计费体系 │ + │ Layer 1: API key 鉴权 │ ← 本文档 + │ Layer 2: SubscriptionManager 合约 │ + │ Layer 3: aPNTs 自动抵扣 │ + │ (AirAccount Session Key) │ + └─────────────────────────────────────┘ +``` + +### 0.2 差异化定位(与 fast-lane / X402 对比) + +| 维度 | M2 trusted-pm fast-lane | M3 X402 一次性收费 | M4 订阅模型(本文)| +|------|----------------------|------------------|------------------| +| 目标用户 | AAStar 自家 / 合作伙伴的 SuperPaymaster 实例 | 偶发外部 UserOp / AI agent 单次调用 | 高频外部用户(企业 SDK / 个人 Pro) | +| 收费方式 | 不收费(运营方自付) | 一次性 HTTP 402 握手 + 单笔报价 | 月度 / 配额预存 + per-op 抵扣 | +| 计费单位 | N/A | 一次握手一次报价 | 订阅 tier (Free/Basic/Pro) + 余量 | +| 状态位置 | 链下白名单 | 链下账本 (Redis) | **链上 SubscriptionManager + 链下缓存** | +| 信任模型 | 运营方 KYC paymaster | 客户端预存 → bundler 信账本 | 链上订阅状态 = 单一真相源 | +| 报价稳定性 | 免费 | 每次握手报价(gas 波动) | tier 内固定 | +| 用户体验 | 完全无感 | 首笔握手有延迟 | 首次订阅一次设置后无感 | +| 资金流 | 运营方 → utility wallet | 客户预存 xPNTs/ETH → bundler 收款 | 用户 aPNTs / xPNTs → SubscriptionManager → bundler | + +### 0.3 与 SuperPaymaster v5 的关系 + +SP v5 已经实现的部分([INTERFACES.md](file:///Users/jason/Dev/Brood/orgs/aastar/INTERFACES.md#L51-L62)): +- 角色体系:`registerRole / configureRole / hasRole / ROLE_*`(ANODE / DVT / KMS / Community / EndUser) +- 信用 / 债务:`recordDebt / repayDebt / clearPendingDebt / getCreditLimit / getDebt` +- Agent 注册:`registerAgent / revokeAgent / isRegisteredAgent / setAgentPolicies` +- SBT 声誉:`safeMint / burnSBT / getUserSBT / setReputation` +- 代币操作:`mint / burn / burnFromWithOpHash / faucet / transferAndCall` + +**本文设计原则**:bundler 的订阅合约**不复制** SP v5 的角色 / Agent / SBT,而是**复用**——SubscriptionManager 在订阅资格检查时调用 `SP.hasRole(EndUser, user) || SP.isRegisteredAgent(user)` 作为门槛;支付 token 用现有 xPNTs([xPNTsToken.sol:33](file:///Users/jason/Dev/aastar/SuperPaymaster/contracts/src/tokens/xPNTsToken.sol#L33));不在 SP 合约里新增任何状态。 + +详见 §8。 + +--- + +## 1 · 三层鉴权 / 计费机制总览 + +### 1.1 用户旅程图(外部用户从打开 SDK 到 op 上链) + +``` +[外部客户端] [bundler] [链上] + │ │ │ + │ ① POST /rpc + X-API-Key (可选) │ │ + ├───────────────────────────────────────────▶│ │ + │ │ │ + │ │ ② 鉴权前置(HTTP middleware) │ + │ │ ┌────────────────────────────────────┐ │ + │ │ │ Layer 1: API key check │ │ + │ │ │ - 白名单:直通 │ │ + │ │ │ - 限流:per-key rate limit │ │ + │ │ │ - 黑名单:return 403 │ │ + │ │ └────────────────────────────────────┘ │ + │ │ │ + │ │ ③ 命中规则判断 (RPC handler 入口) │ + │ │ ┌────────────────────────────────────┐ │ + │ │ │ paymaster ∈ trusted-paymasters? │ │ + │ │ │ → fast-lane (M2),跳过订阅 + X402│ │ + │ │ │ 否则继续 │ │ + │ │ └────────────────────────────────────┘ │ + │ │ │ + │ │ ④ Layer 2: 查链上订阅状态 │ + │ │ ┌────────────────────────────────────┐ │ + │ │ │ SubscriptionManager.getQuota(sender)│ │ + │ │ │ (链下缓存 5-30s TTL) │ │ ◀── 链上读 + │ │ │ - quota > 0:扣本地 quota; │ │ + │ │ │ 若需要链上扣减,进 ⑤ │ │ + │ │ │ - quota = 0 + tier=Pro:进 ⑥ │ │ + │ │ │ - 无订阅:fallback X402 (M3) │ │ + │ │ └────────────────────────────────────┘ │ + │ │ │ + │ │ ⑤ Layer 3a: 订阅预付(per epoch) │ + │ │ ┌────────────────────────────────────┐ │ + │ │ │ 用户已 deposit aPNTs 到 SM │ │ + │ │ │ bundler 周期 settlement,本笔不扣 │ │ + │ │ └────────────────────────────────────┘ │ + │ │ │ + │ │ ⑥ Layer 3b: per-UserOp 抵扣 │ + │ │ ┌────────────────────────────────────┐ │ + │ │ │ bundler 持 user 的 SessionKey │ │ + │ │ │ (algId 0x08, scope=SM.payFor) │ │ + │ │ │ 用 SessionKey 签 intent UserOp │ │ ──▶ 上链 + │ │ │ → SM.payFor(user, fee_aPNTs) │ │ + │ │ └────────────────────────────────────┘ │ + │ │ │ + │ │ ⑦ 标准 simulation + mempool + bundle │ + │ ├──────────────────────────────────────────────────▶ 上链 + │ │ │ + │ ⑧ userOpHash + 收据 │ │ + ◀────────────────────────────────────────────┤ │ +``` + +### 1.2 三层正交性 + +每一层可独立启用 / 关闭,bundler CLI 通过三组开关控制: +- `--api-key-auth-enabled true|false` +- `--subscription-manager-address 0x...`(不配 = Layer 2 关闭) +- `--apnts-deduct-enabled true|false`(依赖 Layer 2 + AirAccount session key 协议) + +实际部署常见组合(详见 §6 矩阵): +- **企业 SDK 集成**:Layer 1 only(API key 即足) +- **个人 Pro 订阅者**:Layer 2 + Layer 3a(链上订阅 + aPNTs 预付月费) +- **AI agent 高频调用**:Layer 1 + Layer 2 + Layer 3b(API key 限流 + 订阅 quota + 链上 per-op 扣) +- **一次性外部 op**:Layer 都不命中 → fallback 到 M3 X402 + +### 1.3 与既有 M2/M3 通道的优先级 + +bundler 入口处的命中顺序(从高到低): +1. **trusted-paymasters fast-lane**(M2 §2.4 H1)—— 命中即免费走快通道 +2. **订阅 quota 命中**(本文 Layer 2/3)—— 命中即用配额,不收 X402 +3. **X402 收费**(M3 §A.1)—— 前两者都未命中 +4. **API key 黑名单 / 限流耗尽** —— 直接 403/429 + +详见 §6.2 决策树。 + +--- + +## 2 · Layer 1 — API key 鉴权 + +### 2.1 业务价值 + +- **企业客户接入门槛**:合作 SDK / 企业开发者期望的不是"链上订阅 / aPNTs 钱包",而是"给我一个 API key,我喂给我的 backend 服务"——这是 Web2 标准做法,AWS / Stripe / Anthropic 都这样 +- **SLA 兑现的最小单元**:API key 是 bundler 与外部客户的 SLA 合同 ID。出问题时按 key 查日志、按 key 计费、按 key 限流 +- **运营可控**:运营方人工签发 / 撤销 / 调整额度,不用走链上治理(链上治理 latency 太长,Web2 客户受不了) +- **迁移路径**:API key 客户后续可以选择"升级"到 Layer 2 / Layer 3,但 Layer 1 永远是最低门槛 + +### 2.2 必要性 + +- 公网开放后,纯 IP 限流(M1 §4.1)只能挡野生流量,无法精细化为不同付费等级客户 +- 没有 key 就没有"客户身份",所有 RPC 调用是匿名的,运营方无法追溯出问题的源头 +- 链上订阅模型(Layer 2)从签 onchain tx 到生效有 10-60s 延迟(OP 链 finality),不能作为唯一鉴权 +- 与 trusted-paymasters 白名单(M2)正交:白名单按 paymaster 地址(链上身份),API key 按 HTTP 调用方(客户身份) + +### 2.3 流程 + +#### 2.3.1 Key 颁发 + +1. 运营方在内部 admin 系统(**M4 不做完整的 Web 控制台**,先用 CLI / SQL 直操作 Redis)执行: + ```bash + pnpm run admin:issue-key \ + --owner-name "AcmeCorp" \ + --tier enterprise \ + --rate-limit-per-min 1000 \ + --rate-limit-per-day 1000000 \ + --allowed-methods "eth_sendUserOperation,eth_estimateUserOperationGas" \ + --expires-at "2026-12-31T23:59:59Z" + ``` +2. 命令生成: + - `key_id`:4 字符前缀(`acme`),便于日志辨识 + - `key_secret`:32 字节随机,base64url 编码 + - 完整 key:`url_aastar__`(前缀 `url_aastar_` 便于 grep / 区分泄露的其他 service key) + - 写入 Redis:`api_key:` → JSON 元数据 +3. 运营方把 full key 一次性交付客户(与 Stripe `sk_live_...` 同模式),客户存自己 secret store + +#### 2.3.2 客户端调用 + +```http +POST /rpc HTTP/1.1 +Host: bundler.aastar.io +X-API-Key: url_aastar_acme_ +Content-Type: application/json + +{"jsonrpc":"2.0","method":"eth_sendUserOperation","params":[...]} +``` + +#### 2.3.3 bundler 鉴权 + +1. Fastify middleware(`onRequest` hook)取 `X-API-Key` header +2. 计算 `sha256(headerValue)` 查 Redis `api_key:` +3. 三种结果: + - **命中且有效**:把 metadata(owner / tier / quotas)注入 `request.context`,下游 handler 能读 + - **命中但过期 / 黑名单**:返回 `403 Forbidden`,body `{"error":"key_revoked","reason":"..."}` + - **未命中**: + - 若 `--api-key-required true` → 返回 `401 Unauthorized` + - 若 `--api-key-required false`(默认 false,兼容公开访问)→ 走匿名路径(受 §4 / §5 / M3 X402 约束) +4. 命中时立即做 per-key 限流: + - 用 `@fastify/rate-limit` 的 `keyGenerator: req => req.context.apiKeyId` + - 超限返回 `429 Too Many Requests` + `Retry-After` + +#### 2.3.4 撤销与轮换 + +- **撤销**:管理员设 Redis key 的 `revoked: true` 字段,立刻生效(middleware 每次都查 Redis) +- **轮换**:管理员调 `pnpm run admin:rotate-key --key-id acme`,生成新 secret 同时把旧 secret 标 `grace_until: `(24h grace period),客户更新 SDK 后旧 key 自动失效 +- **客户自助查询**:bundler 暴露 `GET /admin/api-keys/me` 端点(带自己的 key 调),返回当前 quota / 用量 / 过期时间(不返回 secret) + +### 2.4 技术方案 + +#### 2.4.1 新增模块 + +``` +src/auth/ +├── apiKey.ts # KeyManager:issue / revoke / rotate / lookup +├── apiKeyMiddleware.ts # Fastify hook +├── apiKeyAdmin.ts # admin CLI 子命令 +└── types.ts # ApiKeyMetadata 类型 +``` + +#### 2.4.2 关键类型 + +```ts +// src/auth/types.ts +export interface ApiKeyMetadata { + keyId: string // 4 字符前缀,e.g. "acme" + sha256: string // 索引用,base64url + ownerName: string // 客户名(运营内部) + tier: "free" | "basic" | "pro" | "enterprise" + rateLimitPerMin: number + rateLimitPerDay: number + allowedMethods: string[] // 空数组 = 所有方法 + allowedChainIds: number[] // 空数组 = 所有链 + issuedAt: number // unix + expiresAt: number // unix; 0 = 永不过期 + revoked: boolean + revokedAt?: number + revokedReason?: string + graceUntil?: number // 轮换 grace period + metadata?: Record +} + +export interface ApiKeyContext { + apiKeyId: string // 命中后的 keyId + tier: ApiKeyMetadata["tier"] + ownerName: string +} +``` + +#### 2.4.3 KeyManager 接口 + +```ts +// src/auth/apiKey.ts +export class ApiKeyManager { + constructor(args: { redisClient: Redis | null; logger: Logger }) + + async issue(args: Omit): Promise<{ + fullKey: string + metadata: ApiKeyMetadata + }> + + async lookup(headerValue: string): Promise + + async revoke(keyId: string, reason: string): Promise + + async rotate(keyId: string, graceSeconds: number): Promise<{ newFullKey: string }> + + async listKeys(filter?: { ownerName?: string; tier?: string }): Promise +} +``` + +存储后端: +- 主存储:Redis hash `api_key:` → serialized JSON +- 反向索引:Redis set `api_key_owner:` → 一组 sha256 +- 旋转期间:旧 sha256 仍可查到,但响应里 `metadata.graceUntil` 提示客户端切换 + +#### 2.4.4 Fastify middleware + +```ts +// src/auth/apiKeyMiddleware.ts +export const apiKeyHook = (manager: ApiKeyManager, config: AuthConfig) => + async (request: FastifyRequest, reply: FastifyReply) => { + const headerValue = request.headers["x-api-key"] + if (!headerValue || typeof headerValue !== "string") { + if (config.apiKeyRequired) { + return reply.code(401).send({ error: "api_key_missing" }) + } + request.context = { ...request.context, apiKey: null } + return + } + + const meta = await manager.lookup(headerValue) + if (!meta) { + if (config.apiKeyRequired) { + return reply.code(401).send({ error: "api_key_invalid" }) + } + request.context = { ...request.context, apiKey: null } + return + } + + if (meta.revoked) { + return reply.code(403).send({ + error: "api_key_revoked", + reason: meta.revokedReason + }) + } + if (meta.expiresAt && meta.expiresAt < Date.now() / 1000) { + return reply.code(403).send({ error: "api_key_expired" }) + } + + // method 白名单 + const body = request.body as { method?: string } + if ( + meta.allowedMethods.length > 0 && + body.method && + !meta.allowedMethods.includes(body.method) + ) { + return reply.code(403).send({ error: "method_not_allowed_for_key" }) + } + + request.context = { + ...request.context, + apiKey: { apiKeyId: meta.keyId, tier: meta.tier, ownerName: meta.ownerName } + } + } +``` + +#### 2.4.5 CLI flags + +- `--api-key-auth-enabled true|false`(默认 false) +- `--api-key-required true|false`(默认 false,启用 auth 但允许匿名走 X402) +- `--api-key-redis-prefix api_key`(与其他 namespace 隔离) +- `--api-key-grace-seconds 86400`(rotate 默认 24h grace) + +#### 2.4.6 与 M1 §4.1 全局 IP rate-limit 的关系 + +- M1 的 `@fastify/rate-limit` 是 per-IP 全局限流,**先生效** +- API key per-key 限流是 **after** middleware 运行后再叠加(如同一 key 命中后再走自己的 quota) +- 两层串行:客户端必须既不被 IP 限流又不被 key 限流才能进入 RPC handler + +#### 2.4.7 验收 + +- 单元测试:issue / lookup / revoke / rotate 全路径 +- 集成测试: + - 带正确 key 调 RPC → 200 + - 带过期 key → 403 + `api_key_expired` + - 带轮换中的旧 key → 200 + 响应 header `Warning: api_key_rotating, switch by ` + - 不带 key + `--api-key-required true` → 401 + - 不带 key + `--api-key-required false` → 走匿名路径 +- 限流验证:1 个 key 配 60/min → 第 61 笔 429 +- 撤销实时性:`revoke` 后 < 5 秒(受 Redis cache TTL)下一笔请求 403 + +--- + +## 3 · Layer 2 — 链上账户订阅 + +### 3.1 业务价值 + +- **链上订阅状态 = 单一真相源**:bundler 多实例(M3 §D.2)共享 Redis 也只是 cache,真相在合约里。任一实例宕机或 cache 失效,下次启动重新查链即可恢复 +- **订阅可证明**:用户可以 `etherscan.io/address/#read` 直接看自己的订阅状态,不依赖 bundler 运营方诚信 +- **可与 Agent / SBT 体系互通**:SubscriptionManager 检查 `SP.hasRole(EndUser, user)` 或 `SP.isRegisteredAgent(user)` 作为订阅资格门槛,复用 SP v5 现有的身份证书 +- **跨 bundler 实例 / 跨运营方共享**:SubscriptionManager 不绑定特定 bundler 运营方——任何遵循同一合约接口的 bundler 都能查到用户订阅状态。**这是订阅模型相对 X402 的最大价值**:X402 账本是 bundler 私有 (M3 §A.1),订阅是开放标准 +- **跨链可移植**:每条链一份 SubscriptionManager 部署,未来可加 cross-chain message 把订阅在多链同步 + +### 3.2 必要性 + +- 链下记账(M3 §A.2 xPNTs ledger)的局限: + - 用户只在 bundler 持有"虚拟余额",bundler 跑路 = 余额没了 + - 用户换 bundler 服务商需要把余额提现再转,体验差 + - 用户无法证明自己"确实是 Pro 订阅者"(除非看 bundler 私有日志) +- API key(Layer 1)局限: + - 无法做到"用户自助订阅"——必须运营方人工签发 + - 不能与链上身份(SBT / Agent NFT)绑定 +- X402(M3 §A.1)局限: + - 一次性付款 → 用户每个月签 N 次 op 就需要 N 次握手 + - 报价随 gas 波动,企业财务难做预算 + +### 3.3 流程 + +#### 3.3.1 用户首次订阅 + +1. 用户访问 bundler 运营方 dashboard(**Phase 2 不实现完整 UI**,可用 etherscan 直接交互) +2. 用户调 `SubscriptionManager.subscribe(tier, paymentToken, paymentAmount)`: + - 选择 tier(Free / Basic / Pro) + - 选择支付 token(默认 xPNTs,未来可加 USDC) + - 提交对应 amount(提前 approve 或用 xPNTs 的 autoApprovedSpenders 机制) +3. 合约把 `paymentAmount` 从用户钱包转入 SM 账户 +4. 合约写入 `subscriptions[user] = { tier, expiresAt: now + 30 days, remainingQuota: tier.monthlyQuota }` +5. emit `Subscribed(user, tier, expiresAt, remainingQuota)` + +#### 3.3.2 bundler 查询订阅状态 + +1. 客户端发 `eth_sendUserOperation(userOp)`,bundler 提取 `userOp.sender` +2. bundler 先查 Redis cache `subscription:` (TTL 30s) +3. miss → 调 `SubscriptionManager.getSubscription(user)` → 写回 cache +4. 三种结果: + - **有效订阅 + quota > 0**:bundler 标 `metadata.subscriptionHit = true`,本笔不收 X402 + - **有效订阅 + quota = 0**:根据 tier 决定 fallback: + - Free tier:拒绝(403 over_quota,提示升级) + - Basic tier:fallback 到 X402(按笔付) + - Pro tier:触发 Layer 3b auto-deduct(链上扣 aPNTs 充配额) + - **无订阅 / 已过期**:fallback 到 X402(M3 §A.1) + +#### 3.3.3 quota 消耗 + +- **方案 A(推荐)**:链下扣减 + 周期 settlement + - bundler 在内存账本扣 `localQuota[user] -= 1` + - 周期(每日 / 每千笔)批量调 `SubscriptionManager.consumeQuotaBatch(users[], counts[])` 上链同步 + - 优点:单 op 不发 tx,gas 成本接近 0 + - 缺点:bundler 宕机 / cache 丢失会导致超扣,需 settlement 时和链上 nonce 对账 +- **方案 B**:每笔 op 发链上 consumeQuota tx + - 优点:链上完全准确 + - 缺点:每笔 op 多一次 tx,与 Layer 3b 合并发 intent 可缓解 +- **本文档默认方案 A**,方案 B 留给极端审计场景 + +#### 3.3.4 续订与取消 + +- **自动续订**:Pro tier 默认开自动续订,到期前 1 天 bundler 调 SM 触发 `autoRenew(user)` 从用户 aPNTs 扣下个月费用(依赖 Layer 3b session key) +- **手动续订**:用户重新调 `subscribe(tier, ...)`,合约延长 expiresAt +- **取消**:用户调 `cancelSubscription()`,合约把 `autoRenew=false`,剩余 quota 仍可用至 expiresAt + +### 3.4 技术方案 + +#### 3.4.1 SubscriptionManager.sol 合约接口 + +```solidity +// contracts/SubscriptionManager.sol +pragma solidity ^0.8.20; + +interface ISubscriptionManager { + enum Tier { None, Free, Basic, Pro } + + struct Subscription { + Tier tier; + uint64 expiresAt; // unix + uint64 monthlyQuota; // tier 配额,写入时定值 + uint64 remainingQuota; // 链下消耗后周期同步 + uint64 lastSyncedAt; // bundler 上次 consumeQuotaBatch 的 ts,便于审计 + address paymentToken; // 用户选的支付 token (xPNTs) + bool autoRenew; + } + + struct TierConfig { + uint64 monthlyQuota; + uint128 priceInAPNTs; // 月费,以 aPNTs 计价 + uint16 prioritySlot; // mempool 优先级提示,bundler 可读 + } + + // ─── User-facing ─── + function subscribe(Tier tier, address paymentToken, uint256 amount) external; + function cancelSubscription() external; + function setAutoRenew(bool enabled) external; + function getSubscription(address user) external view returns (Subscription memory); + + // ─── Bundler-facing ─── + /// Atomic check + decrement for single op (方案 B) + function consumeQuota(address user, uint64 count) external; + + /// Batch settlement (方案 A) + function consumeQuotaBatch(address[] calldata users, uint64[] calldata counts) external; + + /// 用户预付 aPNTs 触发 quota 充值(Layer 3a 调用) + function topupAPNTs(address user, uint256 amount) external; + + /// Auto-renew 触发(bundler 用 session key 代签调) + function payFor(address user) external returns (uint256 chargedAPNTs); + + // ─── Admin ─── + function setTierConfig(Tier tier, TierConfig calldata config) external; // onlyOwner + function setBundlerAllowlist(address bundler, bool allowed) external; // onlyOwner + function setSuperPaymaster(address sp) external; // onlyOwner + + // ─── Events ─── + event Subscribed(address indexed user, Tier tier, uint64 expiresAt, uint64 quota); + event QuotaConsumed(address indexed user, uint64 count, uint64 remaining); + event Cancelled(address indexed user); + event AutoRenewed(address indexed user, uint256 chargedAPNTs, uint64 newExpiresAt); +} +``` + +#### 3.4.2 资格门槛(复用 SP v5 身份) + +`subscribe()` 的实现里: + +```solidity +function subscribe(Tier tier, address paymentToken, uint256 amount) external { + // 复用 SP v5 的 dual-channel eligibility + require( + ISuperPaymaster(superPaymaster).isEligibleForSponsorship(msg.sender), + "not eligible: need SBT or registered Agent" + ); + // ... 其余订阅逻辑 +} +``` + +参考 [SuperPaymaster.sol:752](file:///Users/jason/Dev/aastar/SuperPaymaster/contracts/src/paymasters/superpaymaster/v3/SuperPaymaster.sol#L752) 和 [行 1010-1012](file:///Users/jason/Dev/aastar/SuperPaymaster/contracts/src/paymasters/superpaymaster/v3/SuperPaymaster.sol#L1010)。 + +#### 3.4.3 默认 tier 配置(建议值,需商务确认) + +| Tier | 月配额(笔) | 月费 | 备注 | +|------|------------|------|------| +| Free | 50 | 0 aPNTs | 必须有 SBT 或 Agent NFT;超量直接拒 | +| Basic | 500 | 50 aPNTs (≈ $1) | 超量 fallback X402 | +| Pro | 5000 | 300 aPNTs (≈ $6) | 超量自动用 session key 扣 aPNTs 续配额;含 mempool 优先级 | +| Enterprise | 不走链上订阅 | 走 Layer 1 API key + 商务合同 | | + +#### 3.4.4 bundler 链下查询缓存策略 + +```ts +// src/subscription/subscriptionCache.ts +export class SubscriptionCache { + private cache = new LRU({ + max: 100_000 + }) + + constructor( + private contract: ISubscriptionManager, + private logger: Logger, + private ttlMs = 30_000 + ) {} + + async get(user: Address): Promise { + const hit = this.cache.get(user) + if (hit && Date.now() - hit.cachedAt < this.ttlMs) return hit.sub + + const sub = await this.contract.getSubscription(user) + if (sub.tier === Tier.None || sub.expiresAt < now()) { + this.cache.set(user, { sub, cachedAt: Date.now() }) + return null + } + this.cache.set(user, { sub, cachedAt: Date.now() }) + return sub + } + + /// 链下扣减 + consumeLocal(user: Address): boolean { + const hit = this.cache.get(user) + if (!hit || hit.sub.remainingQuota === 0) return false + hit.sub.remainingQuota-- + return true + } +} +``` + +cache invalidation: +- 监听 SM 事件 `Subscribed / QuotaConsumed / Cancelled / AutoRenewed`,命中即 `cache.delete(user)` +- TTL 30s 兜底(事件丢失场景) +- bundler 多实例共享 Redis cache 时用 Redis pub/sub 广播 invalidation + +#### 3.4.5 周期 settlement + +```ts +// src/subscription/settlement.ts +export class SubscriptionSettler { + private pendingConsumption = new Map() + + record(user: Address): void { + this.pendingConsumption.set(user, (this.pendingConsumption.get(user) ?? 0) + 1) + } + + async settle(): Promise { + const batch = Array.from(this.pendingConsumption.entries()) + if (batch.length === 0) return + + const users = batch.map(([u]) => u) + const counts = batch.map(([, c]) => BigInt(c)) + + try { + const txHash = await this.contract.consumeQuotaBatch(users, counts) + this.logger.info({ txHash, count: batch.length }, "subscription settlement") + this.pendingConsumption.clear() + } catch (err) { + this.logger.error({ err }, "settlement failed, retrying next interval") + } + } +} +``` + +settlement 频率:每 1 小时一次(CLI flag 可配 `--subscription-settlement-interval-seconds 3600`)。 + +#### 3.4.6 与 SP v5 信用系统对比 + +| 机制 | SP v5 信用(recordDebt / repayDebt) | SubscriptionManager | +|------|--------------------------------------|---------------------| +| 触发方 | postOp 阶段,paymaster 自己 | 用户主动订阅 | +| 计价单位 | xPNTs(按 op 实际 cost) | aPNTs(按月度 tier) | +| 用户感知 | 后付(debt 累加,下次 mint xPNTs 自动还) | 预付 | +| 适合场景 | 单笔 op 计费 | 长期订阅 | +| 关系 | **互补**:SP 处理"per-op 后付";SM 处理"per-month 预付" | | + +订阅期间 op 走 SuperPaymaster 时仍按 SP 自己的 paymaster 逻辑结算(postOp 扣 xPNTs);订阅 quota 是**bundler 服务费**(不是 paymaster gas 费),两者计费维度不同。 + +### 3.5 验收 + +- 合约单测覆盖 subscribe / cancel / consumeQuota / autoRenew 全路径 +- bundler 集成测试: + - 用户订阅后 bundler 缓存命中 + - cache invalidation 事件正确处理 + - settlement 调用上链成功 + 链下账本与链上一致 +- 资格门槛验证:非 SBT 持有者订阅失败 +- 多实例一致性:3 bundler 实例并行扣同一 user,settlement 后链上 quota 减少正确 + +--- + +## 4 · Layer 3a — aPNTs 订阅预付(依赖 AirAccount Session Key) + +### 4.1 业务价值 + +- **零摩擦续订**:用户订阅 Pro tier 后,每月不需要手动续费——bundler 在到期前一天用 session key 自动调 `SubscriptionManager.payFor(user)`,从用户 aPNTs 扣下月费 +- **用户主权保留**:session key 是用户**主动签发**的(一次性 setup),随时可撤销;bundler 没有任意花用户钱的能力 +- **预算可控**:session key 限定 scope(only `SubscriptionManager.payFor` 一个 selector)+ 限定 amount cap + 限定 TTL +- **与 SP v5 信用系统互补**:信用系统是"先消费后还债",预付订阅是"先充值再消费"——同一用户可同时拥有 + +### 4.2 必要性 + +- 用户体验:纯链上订阅每月手动签 tx 续费 → 80% 用户会忘记 → 订阅模型崩溃 +- 自动续订要求 bundler 有签 tx 的权力,但 bundler **不是用户钱包 owner** → 必须有"受限授权"机制 +- AirAccount 已实现 SessionKey(algId 0x08,[SessionKeyValidator.sol](file:///Users/jason/Dev/aastar/airaccount-contract/src/validators/SessionKeyValidator.sol))—— 直接复用,无需自创 +- 区别于 Layer 3b:3a 是**月度大额一次性扣款**(订阅费),3b 是**per-op 小额连续扣款**(按笔付);两者都用 session key 但 scope 配置不同 + +### 4.3 流程 + +#### 4.3.1 用户授权 session key(一次性) + +1. 用户在 SDK / dashboard 中选择"启用自动续订" +2. SDK 生成或选择 bundler 的"hot wallet 地址"作为 sessionKey(bundler 公开自己的 hot wallet 地址供 SDK 查询) +3. SDK 构造 grant: + - `account` = 用户 AirAccount 地址 + - `sessionKey` = bundler hot wallet 地址 + - `expiry` = `now + 7 days`(受 [SessionKeyValidator.sol:38](file:///Users/jason/Dev/aastar/airaccount-contract/src/validators/SessionKeyValidator.sol#L38) `MAX_SESSION_DURATION` 限制) + - `contractScope` = `SubscriptionManager.address` + - `selectorScope` = `bytes4(keccak256("payFor(address)"))` +4. 用户用 owner key(passkey / TEE 签)签 grant hash([L271-L292](file:///Users/jason/Dev/aastar/airaccount-contract/src/validators/SessionKeyValidator.sol#L271-L292)) +5. SDK 把签名发给 SessionKeyValidator:调 `grantSession(account, sessionKey, expiry, contractScope, selectorScope, ownerSig)`([L111-L127](file:///Users/jason/Dev/aastar/airaccount-contract/src/validators/SessionKeyValidator.sol#L111-L127)) +6. 链上记录 session,emit `SessionGranted`([L67-L73](file:///Users/jason/Dev/aastar/airaccount-contract/src/validators/SessionKeyValidator.sol#L67-L73)) +7. SDK 通知 bundler:"你已被授权,下次续订请用此 session key" + +> **session key 续期**:因 7 天上限,session 到期后 bundler 需要再次提示用户授权下一个 7 天窗口。订阅自动续订实际是"7 天内的某次扣款",**不是 7 天扣一次**——bundler 每 30 天扣一次月费,但 session key 必须每 7 天 refresh 一次。运营方需在 SDK 里做"快到期 → 推送通知 → 用户一键 grant"流程。 + +#### 4.3.2 用户预付 aPNTs + +1. 用户调 `SubscriptionManager.topupAPNTs(self, amount)`(如 1 年额度 = 12 × 月费) +2. SM 把 aPNTs 从用户钱包转入合约,记 `prepaidBalance[user] += amount` +3. emit `APNTsToppedUp(user, amount, newBalance)` + +#### 4.3.3 bundler 自动续订 + +1. bundler 每天扫一次"24 小时内到期"的订阅(用 SM 的 `getExpiringSoon(within = 86400)` view,**M4 在合约里加一个分页 view**) +2. 对每个到期 user: + - 检查 session key 是否仍有效(链上调 `SessionKeyValidator.isSessionActive(user, bundlerHotWallet)`) + - 若有效:bundler 用 hot wallet 私钥签一笔 UserOp: + ``` + sender = user (AirAccount) + callData = SubscriptionManager.payFor(user) + signature = [account(20)][sessionKey(20)][ECDSASig(65)] = 105 bytes + (algId 0x08,dispatch by length,[SessionKeyValidator.sol:103]) + ``` + - bundler 把这笔 intent UserOp 提交给自己(自家 bundler,自家入口),打包上链 + - SM.payFor 内部从 `prepaidBalance[user]` 扣月费,延长订阅 30 天 +3. 失败处理: + - session key 失效 → 不扣,把 user 标 `pending_grant`,运营方通过 push 通知 user + - prepaidBalance 不足 → 不扣,user 订阅自然到期 → fallback X402 + +### 4.4 技术方案 + +#### 4.4.1 新增模块 + +``` +src/subscription/ +├── sessionKeyClient.ts # 与 SessionKeyValidator 交互:查 isSessionActive +├── intentBuilder.ts # 构造 intent UserOp(用 algId 0x08 签名) +├── autoRenewWorker.ts # 周期扫到期 + 触发续订 +└── prepaidLedger.ts # 链下镜像 SM.prepaidBalance(cache) +``` + +#### 4.4.2 IntentBuilder 关键代码 + +```ts +// src/subscription/intentBuilder.ts +import { encodePacked } from "viem" + +export class IntentBuilder { + constructor(private hotWalletAccount: PrivateKeyAccount) {} + + /// 构造 SubscriptionManager.payFor(user) 的 intent UserOp + /// signature 布局必须匹配 SessionKeyValidator._validateECDSASession + /// (引 SessionKeyValidator.sol:255-269) + async buildPayForIntent(args: { + userAccount: Address + subscriptionManager: Address + nonce: bigint + gasLimits: GasLimits + userOpHash: Hex + }): Promise { + const callData = encodeFunctionData({ + abi: SUBSCRIPTION_MANAGER_ABI, + functionName: "payFor", + args: [args.userAccount] + }) + + // ECDSA session signature: [account(20)][sessionKey(20)][ECDSASig(65)] = 105 bytes + // (引 SessionKeyValidator.sol:97 注释 + L255-L269 验证逻辑) + const ecdsaSig = await this.hotWalletAccount.signMessage({ + message: { raw: args.userOpHash } + }) + + const sessionSig = encodePacked( + ["address", "address", "bytes"], + [args.userAccount, this.hotWalletAccount.address, ecdsaSig] + ) + + return { + sender: args.userAccount, + nonce: args.nonce, + callData, + signature: sessionSig, + // ... callGasLimit / verificationGasLimit / preVerificationGas 等 + } + } +} +``` + +#### 4.4.3 AutoRenewWorker + +```ts +// src/subscription/autoRenewWorker.ts +export class AutoRenewWorker { + async run(): Promise { + const expiringSoon = await this.sm.getExpiringSoon(86400) + for (const user of expiringSoon) { + try { + const sessionActive = await this.sessionKeyClient.isSessionActive( + user, + this.hotWallet.address + ) + if (!sessionActive) { + await this.notifier.notifyGrantNeeded(user) + continue + } + + const intent = await this.intentBuilder.buildPayForIntent({ + userAccount: user, + subscriptionManager: this.sm.address, + nonce: await this.entryPoint.getNonce(user, 0n), + gasLimits: this.config.intentGasLimits, + userOpHash: /* compute */ + }) + + await this.bundlerClient.sendUserOperation(intent) + this.metrics.autoRenewSucceeded.inc() + } catch (err) { + this.metrics.autoRenewFailed.inc() + this.logger.error({ err, user }, "auto-renew failed") + } + } + } +} +``` + +#### 4.4.4 CLI flags + +- `--subscription-manager-address 0x...`(必填) +- `--subscription-bundler-hot-wallet 0x...`(bundler 用作 sessionKey 的 hot wallet) +- `--subscription-auto-renew-enabled true|false`(默认 false,需要 hot wallet 配置完才开) +- `--subscription-auto-renew-interval-hours 24` +- `--subscription-auto-renew-look-ahead-seconds 86400` + +### 4.5 验收 + +- 单测:buildPayForIntent 生成的 105 字节 sig 能被 SessionKeyValidator._validateECDSASession 通过 +- e2e on OP-Sepolia: + - 用户 grant session → bundler 触发 autoRenew → SM.payFor 上链成功 → 订阅延长 + - session 已 revoked → autoRenew 跳过 + 推送通知 + - prepaidBalance 不足 → autoRenew 不扣,订阅自然到期 +- 安全测试: + - 改 callData 为非 payFor selector → SessionKeyValidator validation 失败 + - 改 contractScope 为非 SubscriptionManager → AAStarAirAccountBase._enforceGuard 拒(运行时 scope 检查,注释见 [SessionKeyValidator.sol:21-23](file:///Users/jason/Dev/aastar/airaccount-contract/src/validators/SessionKeyValidator.sol#L21-L23)) + +--- + +## 5 · Layer 3b — aPNTs 按 UserOp 计量(核心:AirAccount Session Key 集成) + +### 5.1 业务价值 + +- **真正按用量付费**:用户不需要预付月费、不需要承诺月配额——每发一笔 op 扣相应 aPNTs,零浪费 +- **AI agent 友好**:agent 调用频率剧烈波动(有时 0/天,有时 10K/天),订阅模型 / 月度配额都不合适;按 op 计量与 agent 的"思考一步行动一步"模式天然契合 +- **与 X402 的价值差**:X402 每次 op 都要 HTTP 握手 → 报价 → 付款 → 重发,4 个 RTT;Layer 3b 一次 setup 后无握手,bundler 直接用 session key 在链上扣费 +- **可编程额度**:scope 中可配 amount cap,用户给 agent 一个"每天最多扣 100 aPNTs"的预算,bundler 强制不超 + +### 5.2 必要性 + +- M3 §A.1 X402 的 4-step 握手对高频 agent 是巨大延迟(每笔 op +200ms,agent 跑 100 步就 +20s) +- 链下记账(M3 §A.2)需要用户对 bundler 信任:"我充进去的钱不会被乱扣"——Layer 3b 把这个信任转嫁到链上 session key scope +- 与 Layer 3a 互补:3a 是按月一次大额,3b 是按 op 多次小额;不同用户画像选不同 +- 这是订阅设计的**最复杂层**,但也是技术含金量最高的层——做对了 bundler 直接获得"代签 + 自动扣费"能力,是商业模式的护城河 + +### 5.3 流程 + +#### 5.3.1 用户授权 session key(与 4.3.1 类似但 scope 不同) + +1. SDK 生成 grant: + - `account` = user AirAccount + - `sessionKey` = bundler hot wallet + - `expiry` = `now + 7 days`(受 MAX_SESSION_DURATION 限制) + - `contractScope` = `SubscriptionManager.address`(**或** xPNTs token,见下文方案) + - `selectorScope` = 选其一: + - **方案 X**:`SubscriptionManager.payFor(user)` —— 仍走 SM 中央扣账,bundler 每笔扣的金额由 SM tier 配置或 SM 内部 metering 决定 + - **方案 Y**:`xPNTsToken.transferFrom(user, bundler, amount)` —— 直接 ERC20 转账,bundler 自定 amount,金额受 xPNTs 防火墙 `MAX_SINGLE_TX_LIMIT = 5000 ether` 约束([xPNTsToken.sol:76](file:///Users/jason/Dev/aastar/SuperPaymaster/contracts/src/tokens/xPNTsToken.sol#L76)) +2. 用户 owner 签 grantHash(同 4.3.1) +3. SDK 调 `grantSession(...)` 上链 + +> **方案 X vs Y 决策**(本文档默认推方案 X): +> - **方案 X 优点**:所有计费逻辑在 SubscriptionManager 集中,bundler 只是触发器;金额由合约根据 op 复杂度动态算(SM 可读 op metadata);tier 限制(如 Free user 不能用 3b)合约层强制 +> - **方案 Y 优点**:直接 transferFrom,绕过 SM 中转;xPNTs 自带 firewall 保护(`to == msg.sender == bundler`,[xPNTsToken.sol:262-277](file:///Users/jason/Dev/aastar/SuperPaymaster/contracts/src/tokens/xPNTsToken.sol#L262-L277) 强制此规则);不依赖 SM 部署 +> - **决策**:M4 主推方案 X,方案 Y 留给 xPNTs 生态外的简化 fallback;SDK 默认配方案 X scope + +#### 5.3.2 bundler 接收用户的 op 并自动扣费 + +1. 客户端发 `eth_sendUserOperation(originalUserOp)`,sender = user +2. bundler 鉴权 + 命中规则判断(§1.1 ② ③ ④) +3. 命中 Layer 3b(用户订阅 Pro tier,且开了 per-op metering): + - bundler 算本笔报价 `feeInAPNTs = pricing(originalUserOp)`(仿 M3 §A.1 pricing) + - bundler 用 session key 构造 intent UserOp: + ``` + sender = user + callData = SubscriptionManager.payFor(user) + (SM 内部按当前时间戳 / op metadata 决定本次扣多少; + 或 SM.chargeForOp(user, opHash, feeAPNTs),bundler 显式传金额) + signature = [account(20)][bundlerHotWallet(20)][ECDSASig(65)] = 105 bytes + ``` + - bundler 把 intent UserOp 与 originalUserOp **打包成同一 bundle**(atomic 上链) + - bundle 上链: + - intent op 先执行 → SM.chargeForOp 扣 user aPNTs + - originalUserOp 后执行 → 用户业务逻辑 + - 任一失败 → EntryPoint revert(atomicity 由 EntryPoint handleOps 多 op 处理保证) + +#### 5.3.3 bundler 自治范围(持有 session key 后能做什么) + +session key 让 bundler **对 user account 有有限代签权**,能做: +- 调 `SubscriptionManager.payFor(user)` —— allowed(contractScope + selectorScope 命中) +- 调 `SubscriptionManager.chargeForOp(user, opHash, amount)` —— **如果 selectorScope 配的是 chargeForOp** + +**不能做**: +- 调 SM 的其他 selector(如 `subscribe / cancel`)—— `_enforceGuard` 拒([SessionKeyValidator.sol:21-23](file:///Users/jason/Dev/aastar/airaccount-contract/src/validators/SessionKeyValidator.sol#L21-L23) 注释) +- 调 SM 之外的合约(如直接 transferFrom)—— 同上 +- 超出 expiry 后任何调用 —— `_validateECDSASession` 拒([L262](file:///Users/jason/Dev/aastar/airaccount-contract/src/validators/SessionKeyValidator.sol#L262)) +- 在 user `revokeSession` 后任何调用 —— `_validateECDSASession` 拒([L261](file:///Users/jason/Dev/aastar/airaccount-contract/src/validators/SessionKeyValidator.sol#L261)) + +#### 5.3.4 撤销路径 + +- **用户主动撤销**:用户在 dashboard 调 `SessionKeyValidator.revokeSession(account, sessionKey)`([L146-L153](file:///Users/jason/Dev/aastar/airaccount-contract/src/validators/SessionKeyValidator.sol#L146-L153)) +- **过期自动失效**:7 天 TTL 到,session 自动无效 +- **bundler 主动放弃**:bundler 通知 SDK,自己不再用此 sessionKey(不上链,链下行为) +- **运营方紧急撤销**(可选):M4 不实现;若需要,未来可在 SubscriptionManager 加 `setEmergencyBlock(user)`,bundler 在每次 intent 前 check 一遍 + +### 5.4 技术方案 + +#### 5.4.1 SessionKey 签名布局(关键) + +引 [SessionKeyValidator.sol](file:///Users/jason/Dev/aastar/airaccount-contract/src/validators/SessionKeyValidator.sol) 实际实现: + +- **dispatcher**:`validate(userOpHash, signature)` 按 `signature.length` 分发(L99-L106) + - **105 字节** → ECDSA session:`[account(20)][sessionKey(20)][ECDSASig(65)]`(L97 注释 + L255-L269 解析) + - **148 字节** → P256 session:`[account(20)][keyX(32)][keyY(32)][r(32)][s(32)]`(L98 注释 + L319-L340 解析) + - 其他长度 → 返回 `1`(验证失败) + +> **注意**:原任务描述里说"106 字节带 algId(1)"——这与实际合约不符。**实际合约用 length-based dispatch 不是 algId 字节**。本文档以合约实现为准。algId 0x08 是该 validator 在 `AAStarValidator` 中的注册 id(L17 注释),不出现在 sig 字节流里。bundler 在构造 sig 时只需要打 105 字节 ECDSA payload,无需加 algId 前缀。 + +#### 5.4.2 bundler 端集成 + +```ts +// src/subscription/perOpDeducter.ts +export class PerOpDeducter { + constructor( + private hotWallet: PrivateKeyAccount, + private sm: ISubscriptionManager, + private sessionKeyClient: SessionKeyClient, + private intentBuilder: IntentBuilder, + private pricing: PricingEngine + ) {} + + /// 在 mempool 入队前调用:构造 intent + 打包 + async maybeBuildDeductIntent(args: { + originalUserOp: UserOperation + userTier: Tier + }): Promise { + if (args.userTier !== Tier.Pro) return null + + const user = args.originalUserOp.sender + const sessionActive = await this.sessionKeyClient.isSessionActive( + user, + this.hotWallet.address + ) + if (!sessionActive) return null + + const feeAPNTs = this.pricing.calculate(args.originalUserOp) + + return this.intentBuilder.buildChargeForOpIntent({ + userAccount: user, + opHash: getUserOpHash(args.originalUserOp), + amount: feeAPNTs, + sessionKey: this.hotWallet + }) + } +} +``` + +#### 5.4.3 mempool 改造(与 M2 H3 类似但语义不同) + +- intent UserOp 与 originalUserOp 必须同 bundle,**且 intent 在前** +- 在 mempool entry 加 `metadata.linkedIntentOpHash` 字段,bundle 创建时强制把 linked pair 一起出队 +- 若 intent 失败(gas / nonce / SM revert)→ originalUserOp 也撤回(不入 mempool 或 bundle 重组) + +#### 5.4.4 风险分析 + +| 风险 | 影响 | 缓解 | +|------|------|------| +| bundler hot wallet 私钥泄露 | 攻击者可用所有授权过的 user session 任意调 SM.payFor/chargeForOp,扣空 user aPNTs prepaidBalance | (a) hot wallet 用 KMS / HSM 管理;(b) SM 加 per-tx 上限 + per-day 上限;(c) hot wallet 与 executor / utility wallet 隔离,盗一不影响其他;(d) 紧急时社区 owner 可暂停 SM 合约 | +| bundler 串改 callData | 攻击者改 intent callData 调 SM 之外的方法 | SessionKey scope 强制:contractScope 命中 + selectorScope 命中(在 AirAccount `_enforceGuard` 运行时检查),bundler 改了链上拒 | +| bundler 超额扣费 | 用户 aPNTs 一次性被扣空 | (a) SM.chargeForOp 内部加 max-per-call 上限(如 100 aPNTs / call);(b) SM.chargeForOp 加 daily cap per-user;(c) 用户在 grant 时通过 SDK 指定 amount cap(M5 扩展,本文 M4 范围内不强制) | +| session key 被多 bundler 并发使用 | nonce 冲突 / replay | bundler 与用户 1:1 绑定(user 选一个 bundler 签 session key);多 bundler 抢同一 user 时由 SDK 决定授权对象;bundler 之间不共享 hot wallet 私钥 | +| 用户在 bundler 处理 intent 时 revoke session | intent op 上链失败 → originalUserOp 也失败 | EntryPoint atomicity 保证;bundler 重新走 X402 或拒此笔 | +| SM 升级 / 改 selector | 老 session key contractScope/selectorScope 失效 | SM 不可升级(部署时 set immutable);如必须升级用 proxy 升级前广播 → 用户 re-grant;selector 永远向后兼容 | +| AirAccount session 7 天上限 | 长期 agent 用户每周需要 re-grant | SDK 在 D-1 推 push notification;可选用 P256 session(148 字节,passkey 一键签)减少摩擦 | + +### 5.5 验收 + +- 单测:buildChargeForOpIntent 生成的 105 字节 sig 通过 SessionKeyValidator +- 集成测试: + - 正常路径:grant → originalUserOp → bundle 含 intent 上链 → SM.chargeForOp 扣 aPNTs + - revoke 后:bundler 拒绝构造 intent → originalUserOp fallback X402 / 直接拒 + - intent 改 callData:链上 validation 失败(运行时 _enforceGuard 拒) +- 压力测试:100 user 并发 grant + 1000 op/s 持续 1 小时,无 nonce 冲突 / 无超扣 +- 安全测试:故意泄露一个 hot wallet → 立即 SM admin 暂停该 hot wallet → 攻击窗口 < 5 分钟 + +--- + +## 6 · 三层组合策略 + +### 6.1 用户画像 × 层组合矩阵 + +| 用户画像 | API key (L1) | 链上订阅 (L2) | aPNTs 抵扣 (L3) | 备注 | +|---------|-------------|--------------|-----------------|------| +| 企业 SDK 集成(AAStar 自家 SDK 接入合作 partner) | ✅ Enterprise | ✗ | ✗ | 走 Layer 1 即足,商务合同结算,不上链 | +| 个人 Pro 订阅者(活跃个人开发者) | 可选 | ✅ Pro tier | ✅ 3a 自动续订 | 月费 + 大配额,超量 fallback X402 | +| AI agent 高频调用 | ✅ Basic+ | ✅ Pro tier | ✅ 3b per-op metering | API key 限流防过载,3b 按真实用量精确扣 | +| 个人 Free 用户(轻度玩家) | ✗ | ✅ Free tier (50/月) | ✗ | 资格门槛 SBT 持有;超量直接拒 | +| 一次性外部 op(curl 用户、AI agent 单次) | ✗ | ✗ | ✗ | fallback M3 X402 一次性付款 | +| 内部生态 op(AAStar 自家 paymaster + xPNTs) | ✗ | ✗ | ✗ | M2 trusted-paymasters fast-lane,不收费 | + +### 6.2 优先级决策树(命中规则) + +``` +入口:eth_sendUserOperation(userOp, ...) + │ + ▼ +[1] paymaster ∈ trusted-paymasters? + ├─ YES → M2 fast-lane,免费,不计入订阅,不走 X402,END + └─ NO ↓ +[2] 有 X-API-Key? + ├─ YES → Layer 1 鉴权 + │ ├─ 失败 → 401/403 END + │ └─ 通过 ↓ + └─ NO ↓ +[3] sender 在 SubscriptionManager 有有效订阅? + ├─ YES + quota>0 → Layer 2 命中,扣 quota,免 X402,END + ├─ YES + quota=0 + tier=Pro + sessionKey active → Layer 3b 触发 intent,END + ├─ YES + quota=0 + tier=Basic → fallback X402 + ├─ YES + quota=0 + tier=Free → 拒 (over_quota),END + └─ NO ↓ +[4] M3 X402:返回 HTTP 402 + 报价 + ├─ 客户端付款重发 → 走 op + └─ 客户端不付 → END +``` + +### 6.3 与 trusted-paymasters / X402 的兜底关系 + +| 来源 | 命中即终结 | 可降级到 | 永不冲突 | +|------|----------|---------|---------| +| trusted-paymasters fast-lane | ✅ | 不降级 | 与订阅 / X402 互斥(命中 fast-lane 不收任何费) | +| 订阅 quota 命中 | ✅ | 配额耗尽降 X402 / 拒 | 与 fast-lane 互斥(命中其一即不进另一) | +| Layer 3b per-op intent | ✅ | intent 失败降 X402 | 隐性依赖订阅 Pro tier | +| X402 一次性收费 | ✅ | 客户端拒付 → 拒 op | 兜底通道 | + +**关键**:trusted-paymasters > 订阅 > X402。运营方可在配置中为某些 paymaster 同时启用"订阅 quota 计入但不收费"模式(即使是 trusted-paymasters,仍统计 quota 用于 SLA 报告),但默认行为是命中 fast-lane 后跳过订阅检查。 + +--- + +## 7 · 安全与防护 + +### 7.1 Sybil 攻击 + +**威胁**:同一人注册多个 SubscriptionManager 账号,刷 Free tier 配额(50/月 × N 个账号)。 + +**缓解**: +- SubscriptionManager.subscribe 调用 `SP.isEligibleForSponsorship(msg.sender)`([SuperPaymaster.sol:752](file:///Users/jason/Dev/aastar/SuperPaymaster/contracts/src/paymasters/superpaymaster/v3/SuperPaymaster.sol#L752)) +- SBT 是非转让 + 一人一份(KYC 限制) +- Agent NFT 注册需要质押 / 信誉门槛(ERC-8004 体系) +- 即便 Sybil 成功创建多账号,每账号 Free tier 仅 50/月,攻击成本 > 收益(攻击者要花 N × KYC 成本换 N × 50 op) + +### 7.2 DDoS + +**威胁 1:API key 被盗** +- 攻击者用泄露 key 大量调用,把企业客户的 quota 烧光 +- **缓解**:(a) per-key rate limit(§2)阻止瞬时洪峰;(b) bundler 监控 RPS 突增 → 自动 throttle → 推 webhook 给 owner(M3 §B.1);(c) owner 可调 admin endpoint 立即 revoke + rotate + +**威胁 2:session key 被盗(hot wallet 私钥泄露)** +- 攻击者用 hot wallet 调 SM.payFor / chargeForOp 扣空所有授权 user 的 aPNTs +- **缓解**:(a) hot wallet 用 KMS / HSM;(b) SM.chargeForOp 加 per-call max(如 100 aPNTs)+ per-user-per-day cap(如 1000 aPNTs);(c) 监控 SM.payFor 调用频率,异常 → 自动暂停合约(owner pausable);(d) hot wallet 与 utility / executor 隔离,泄露不放大;(e) 用 P256 session(148 字节)+ passkey 提高签名硬件门槛 + +**威胁 3:mempool 灌大量 intent op** +- 攻击者构造大量带 session key 的伪 intent op(用过期 / revoked session)打 mempool +- **缓解**:simulation 阶段 SessionKeyValidator 直接拒 → bundler 触发 reputation drop → 同源大量伪 op → IP 限流 → 短期 ban + +### 7.3 经济攻击 + +**订阅退款攻击**: +- 用户付了 Pro 月费,第一天用完 5000 quota,然后取消订阅要求退款 +- **缓解**:合约无退款条款;`cancelSubscription` 仅停止 autoRenew,已付月费不退;剩余 quota 仍可用至 expiresAt + +**aPNTs 余额耗尽攻击**: +- 用户 prepaidBalance 用尽,bundler autoRenew 失败 +- **缓解**:autoRenew 失败 → 推送通知用户 → 订阅自然到期 → 自动 fallback 到 X402 / 拒 + +**chargeForOp 滥用**: +- bundler 故意每笔 op 收高额 aPNTs(按 op 复杂度算的报价 inflate) +- **缓解**:(a) SM.chargeForOp 内有 max-per-call hardcap;(b) 用户的 session key 配 per-day cap(M5 扩展);(c) bundler 报价透明(M3 §A.1 X402 规范要求 quote 公开),用户可对账 + +### 7.4 风控 + +**黑名单 user**: +- SM 加 `mapping(address => bool) public blocked` + admin function `setBlocked(user, true)` +- bundler 在每笔 op 入口先 check `SM.blocked(sender)`,命中即 403 +- 黑名单触发条件:可疑大量 op、欺诈支付、链上行为异常 + +**黑名单 subscription**: +- 极端情况:某 tier 配置错(如 Free quota 错配 50000)→ admin 调 `pauseTier(Tier.Free)` → 该 tier 下所有用户配额冻结 → 排查后调 `resumeTier` + +**审计 log**: +- 所有 SM 状态变更(subscribe / consume / charge / cancel)都 emit event +- bundler 侧每笔订阅命中 / autoRenew / chargeForOp 都结构化日志(M1 §2.3 JSON 格式) +- 周期 bundler 与链上 SM 对账(每 24h 跑一次 reconciliation 任务,差异 > threshold 推 webhook) + +--- + +## 8 · 与 SuperPaymaster v5 的关系 + +### 8.1 复用 vs 独立 + +**决策**:SubscriptionManager **独立合约**部署在 UltraRelay-AAStar 仓库(`contracts/SubscriptionManager.sol`),但 **复用** SP v5 的能力: + +| 能力 | 复用方式 | +|------|---------| +| **SBT 资格门槛** | SM.subscribe 调 `SP.isEligibleForSponsorship(user)` 检查 SBT / Agent NFT | +| **xPNTs 支付 token** | SM.subscribe 接受 xPNTs 作为 paymentToken;用 SP 已建立的 xPNTs 流通体系 | +| **aPNTs 计价单位** | tier 月费定价用 aPNTs(与 SP postOp 计费同单位,便于用户理解) | +| **role system** | SM admin 角色复用 SP `ROLE_PAYMASTER_SUPER` 或自定义 `ROLE_BUNDLER_OPERATOR` | +| **Agent registry** | SM 在 Layer 3b chargeForOp 时可读 SP.agentPolicies 给 agent 用户优惠 | + +不复用的部分(SM 自有): +- subscribe / cancelSubscription / consumeQuota / payFor 等订阅核心逻辑(SP 没有等价物) +- prepaidBalance(SP 有 aPNTsBalance per-operator,是不同概念) +- session key 协议(SM 不持 sessionKey,session key 在 AirAccount 合约里) + +### 8.2 决策依据 + +**为何不嵌入 SP v5?** +- SP v5 已 1176 行([SuperPaymaster.sol](file:///Users/jason/Dev/aastar/SuperPaymaster/contracts/src/paymasters/superpaymaster/v3/SuperPaymaster.sol)),加订阅会进一步膨胀,部署 / 审计 / 升级风险大 +- 订阅是 bundler 业务,与 paymaster gas sponsorship 业务**不同维度**:paymaster 收 op 的 gas 费;订阅收 bundler 服务费——混在一起难解释 +- 独立合约便于不同团队 / 不同链部署不同版本(如 OP-Mainnet 启用,新链先观察) +- SP v5 升级 / 审计 / 部署节奏已经够紧;订阅独立部署不阻塞 SP 路线图 + +**为何要 reuse SP 资格门槛?** +- SBT / Agent NFT 是 AAStar 生态统一的反 Sybil 证书,**不应该让 bundler 重新发明一遍** +- 用户已经为业务获取了 SBT,订阅时直接复用,零额外门槛 +- 跨产品身份一致:用户在 SP 是 EndUser,在 SM 也是 EndUser + +### 8.3 接口对接 + +```solidity +// contracts/SubscriptionManager.sol +interface ISuperPaymasterReadOnly { + function isEligibleForSponsorship(address user) external view returns (bool); + function isRegisteredAgent(address account) external view returns (bool); + function hasRole(bytes32 role, address account) external view returns (bool); +} + +contract SubscriptionManager is Ownable { + ISuperPaymasterReadOnly public immutable superPaymaster; + + constructor(address _sp) { + superPaymaster = ISuperPaymasterReadOnly(_sp); + } + + function subscribe(Tier tier, address paymentToken, uint256 amount) external { + require(superPaymaster.isEligibleForSponsorship(msg.sender), "not eligible"); + // ... 后续订阅逻辑 + } +} +``` + +### 8.4 协调清单(跨仓库) + +| 项 | 责任方 | 状态 | +|---|------|------| +| SP v5 暴露 `isEligibleForSponsorship` view | SP 团队 | ✅ 已完成([SuperPaymaster.sol:1010](file:///Users/jason/Dev/aastar/SuperPaymaster/contracts/src/paymasters/superpaymaster/v3/SuperPaymaster.sol#L1010)) | +| xPNTs `addAutoApprovedSpender(SubscriptionManager)` | 各社区 owner 配合 | M4 Phase 3 协调 | +| SP `ROLE_BUNDLER_OPERATOR` 注册(可选) | SP 团队 | M4 Phase 4 协商 | +| AirAccount SessionKey scope 文档化 | AirAccount 团队 | ✅ 已实现([SessionKeyValidator.sol](file:///Users/jason/Dev/aastar/airaccount-contract/src/validators/SessionKeyValidator.sol)) | +| AirAccount SDK 暴露 grantSession 友好 API | AirAccount 团队 | M4 Phase 3 协调 | + +--- + +## 9 · 演进路径 + +### 9.1 Phase 1:纯 API key(最快上线,企业客户) + +**目标**:bundler 对外开放,企业客户能拿 key 接入。 + +**范围**: +- §2 全部内容 +- 不依赖任何合约部署 +- 不依赖 AirAccount session key +- 不依赖 SubscriptionManager + +**时间估算**:2-3 周(含运营 SOP) + +**验收**:见 §10.1 + +### 9.2 Phase 2:+ 链上订阅(需部署 SubscriptionManager) + +**目标**:用户可以在链上自助订阅,bundler 链下查询缓存命中。 + +**范围**: +- §3 全部内容 +- 部署 SubscriptionManager.sol(每条目标链一份) +- bundler 集成 SubscriptionCache + Settler +- 不要求 AirAccount session key(Layer 3 推迟) + +**时间估算**:4-6 周(含合约审计) + +**依赖前置**:Phase 1 完成 + +### 9.3 Phase 3:+ aPNTs auto-deduct(需 AirAccount session key 协调) + +**目标**:用户开 Pro tier 自动续订;高级用户开 per-op metering。 + +**范围**: +- §4 + §5 全部内容 +- 需 AirAccount 团队配合:SDK 暴露 grantSession 友好 API +- 需 SuperPaymaster 团队配合:xPNTs autoApprovedSpender(如走方案 Y) +- bundler 集成 SessionKeyClient + IntentBuilder + AutoRenewWorker + PerOpDeducter + +**时间估算**:6-8 周(含跨仓库协调 + e2e) + +**依赖前置**:Phase 2 完成 + AirAccount SessionKeyValidator 部署 + SDK 集成 + +### 9.4 Phase 4:+ 跨社区订阅互通 + +**目标**:用户在 community A 持有的 xPNTs 可付 community B bundler 的订阅;多社区共享一个 SubscriptionManager 实例。 + +**范围**: +- SubscriptionManager 加 multi-token 支持(同时接受多种 xPNTs 作为 payment) +- 与 OpenPNTs 协议层对齐(PROFILE.md 提到 AAStar 依赖 OpenPNTs 协议) +- 跨链 bridge(M3 §D.1 多链上线后规划) + +**时间估算**:8-12 周(含协议设计 + 多社区接入) + +**依赖前置**:Phase 3 完成 + M3 §D.1 多链上线 + OpenPNTs 跨社区规范确定 + +--- + +## 10 · 验收里程碑 + +### 10.1 Phase 1 验收(API key) + +| # | 验收项 | 验收方式 | 状态 | +|---|--------|---------|------| +| 1.1 | KeyManager issue / lookup / revoke / rotate 单测 | `pnpm test src/auth` 全过 | ☐ | +| 1.2 | Fastify middleware 集成测试 | 8 个用例(带正确 / 过期 / 撤销 / 轮换中 / 无 key + required / 无 key + optional / 限流 / method 白名单) | ☐ | +| 1.3 | admin CLI(issue/revoke/rotate/list) | OP-Sepolia 环境验证全流程 | ☐ | +| 1.4 | per-key rate limit | 1 key 配 60/min,第 61 笔 429 | ☐ | +| 1.5 | revoke 实时性 | revoke 后 < 5 秒下一笔 403 | ☐ | +| 1.6 | 与 M1 IP rate-limit 共存 | 两层串行,先 IP 后 key | ☐ | +| 1.7 | 运营 SOP 文档 | `docs/RUNBOOK_API_KEY.md` 完整 | ☐ | + +### 10.2 Phase 2 验收(链上订阅) + +| # | 验收项 | 验收方式 | 状态 | +|---|--------|---------|------| +| 2.1 | SubscriptionManager 合约审计 | 第三方审计报告归档 | ☐ | +| 2.2 | SM 全 selector 单测覆盖 | foundry test 全过 + coverage > 95% | ☐ | +| 2.3 | 部署 SM 到 OP-Sepolia | 链上确认部署 + tier 配置 | ☐ | +| 2.4 | bundler SubscriptionCache 集成 | mock SM 单测 + 真链 e2e | ☐ | +| 2.5 | 资格门槛验证 | 非 SBT / Agent 调 subscribe → revert | ☐ | +| 2.6 | quota 周期 settlement | 100 笔 op 后 batch settle 上链成功 + 链上链下一致 | ☐ | +| 2.7 | cache invalidation | 监听 Subscribed 事件正确更新 cache | ☐ | +| 2.8 | 多实例一致性 | 3 bundler 实例并行扣同一 user,settlement 后链上正确 | ☐ | + +### 10.3 Phase 3 验收(aPNTs auto-deduct + per-op) + +| # | 验收项 | 验收方式 | 状态 | +|---|--------|---------|------| +| 3.1 | IntentBuilder 105-byte sig 单测 | 通过 SessionKeyValidator._validateECDSASession | ☐ | +| 3.2 | autoRenewWorker 端到端 | grant → 触发 autoRenew → SM.payFor 上链 → 订阅延长 | ☐ | +| 3.3 | session key 撤销路径 | revokeSession 后 autoRenew 跳过 + 推送通知 | ☐ | +| 3.4 | session key 7 天过期 | TTL 到期后 autoRenew 失败 + 触发 re-grant 提示 | ☐ | +| 3.5 | PerOpDeducter intent 打包 | originalUserOp + intent 同 bundle 上链 atomic | ☐ | +| 3.6 | scope 越权拒绝 | 故意改 callData 为非 payFor → 链上 _enforceGuard 拒 | ☐ | +| 3.7 | hot wallet 紧急撤销演练 | 模拟泄露 → admin 暂停 → 攻击窗口 < 5 分钟 | ☐ | +| 3.8 | 与 AirAccount 联合 e2e | AirAccount 团队签字 | ☐ | +| 3.9 | per-call max + per-day cap | SM.chargeForOp 拒超额请求 | ☐ | + +### 10.4 Phase 4 验收(跨社区互通) + +| # | 验收项 | 验收方式 | 状态 | +|---|--------|---------|------| +| 4.1 | SM multi-token 支持 | 同一 user 用 community A 的 xPNTs 订阅 | ☐ | +| 4.2 | OpenPNTs 跨社区规范对齐 | OpenPNTs 团队签字 | ☐ | +| 4.3 | 多链部署 | 至少 2 链 SM 部署 + bundler 跨链查询 | ☐ | +| 4.4 | 跨社区订阅 e2e | 用户 community A xPNTs 付 community B bundler 订阅 | ☐ | + +--- + +## 11 · 输出物清单 + +### 11.1 合约(新增,本仓库) + +- `contracts/SubscriptionManager.sol` — 订阅核心合约 +- `contracts/SubscriptionManager.t.sol` — foundry 单测 +- `contracts/script/DeploySubscriptionManager.s.sol` — 部署脚本 + +### 11.2 bundler 代码(新增) + +``` +src/auth/ +├── apiKey.ts +├── apiKeyMiddleware.ts +├── apiKeyAdmin.ts +└── types.ts + +src/subscription/ +├── subscriptionCache.ts +├── settlement.ts +├── sessionKeyClient.ts +├── intentBuilder.ts +├── autoRenewWorker.ts +├── perOpDeducter.ts +├── prepaidLedger.ts +└── types.ts +``` + +### 11.3 bundler 代码(修改) + +- `src/cli/config/options.ts` — 加 §2/§3/§4/§5 全部 CLI flags +- `src/rpc/methods/eth_sendUserOperation.ts` — 入口接订阅命中判定(§1.1 ④) +- `src/rpc/methods/boost_sendUserOperation.ts` — 同上 +- `src/rpc/server.ts` — 注册 apiKeyHook +- `src/mempool/mempool.ts` — intent + originalOp linked-pair 出队(§5.4.3) +- `src/utils/metrics.ts` — 加订阅相关 metric(subscription_hit_total / autoRenew_total / chargeForOp_total) + +### 11.4 文档 + +新增: +- `docs/SUBSCRIPTION_DESIGN.md` — 本文件 +- `docs/RUNBOOK_API_KEY.md` — Phase 1 运营 SOP(issue / revoke / rotate / 客户支持) +- `docs/SUBSCRIPTION_CONTRACT_SPEC.md` — SM 合约 ABI + 事件 + 升级策略 +- `docs/SESSION_KEY_INTEGRATION.md` — bundler 与 AirAccount session key 的集成规范(与 AirAccount 团队共享) +- `docs/SUBSCRIPTION_TIER_PRICING.md` — tier 定价 / 配额 / 升级路径(商务团队维护) +- `docs/SUBSCRIPTION_SECURITY.md` — §7 安全分析独立成文 +- `docs/RUNBOOK_AUTO_RENEW.md` — Phase 3 运营 SOP(hot wallet 管理 / 紧急撤销 / 用户 re-grant 推送) + +更新: +- `docs/CHAIN_CONFIG.md` — 加各链 SubscriptionManager 部署地址 +- `docs/FORK_DELTA.md` — 加 M4 增量条目 + +### 11.5 SDK / 示例 + +- `examples/subscription-curl/` — curl 示例:subscribe / get-status / cancel +- `examples/subscription-permissionless/` — permissionless.js 集成订阅 + session key grant +- `examples/auto-renew-grant/` — 用户授权 session key 给 bundler 的最小示例 +- `docs/SUBSCRIPTION_INTEGRATION.md` — 客户端 SDK 集成指南 + +### 11.6 运维 + +- `monitoring/grafana-dashboard-subscription.json` — Grafana dashboard(订阅命中率 / autoRenew 成功率 / hot wallet 余额) +- `monitoring/alert-rules-subscription.yaml` — Prometheus 告警规则(autoRenew 失败率 > 阈值 / hot wallet 余额 < 警戒) +- `scripts/reconciliation.ts` — 周期对账脚本(链上 SM quota vs 链下 cache) + +### 11.7 不动 + +- 任何 SP v5 合约改动(仅复用 view 接口) +- 任何 AirAccount 合约改动(仅复用 SessionKeyValidator) +- 任何 xPNTs 合约改动(仅依赖既有 addAutoApprovedSpender + transferFrom firewall) +- M1/M2/M3 已交付的 bundler 核心通路(fast-lane / X402 / 监控)—— 订阅是新增层,不改既有层 + +--- + +## 12 · 风险与缓解表 + +| 风险 | 影响 | 缓解 | 责任方 | +|------|------|------|------| +| API key 泄露 | 客户配额被烧 | (a) per-key rate limit + 异常 RPS webhook;(b) 一键 revoke + rotate;(c) 客户使用文档强调 secret 存 vault | 运营 | +| SubscriptionManager 合约 bug 吞用户预存 | 资金损失 | (a) 第三方审计;(b) 无 proxy 升级(部署即 immutable);(c) admin pausable + 紧急 emergencyWithdraw 给 user | 合约团队 | +| bundler hot wallet 私钥泄露 | 攻击者扣空所有授权 user 的 aPNTs | (a) KMS / HSM 管理;(b) SM per-call + per-day cap;(c) admin 一键暂停 hot wallet;(d) hot wallet 与其他 wallet 隔离;(e) 推 P256 session 替代 ECDSA session | 运营 + 合约 | +| AirAccount SessionKey 协议变更 | bundler 构造的 sig 失效 | (a) 与 AirAccount 团队定 ABI 兼容性承诺;(b) bundler 加 signature version 字段,旧 / 新两版并存灰度 | AirAccount + bundler | +| session key 7 天上限被用户嫌烦 | 用户不开自动续订 → 订阅模型崩溃 | (a) SDK 推 P256 session(passkey 一键签);(b) SDK 在 D-1 推 push notification;(c) 推动 AirAccount 评估提高上限到 30 天(改合约常量) | bundler + AirAccount | +| 跨社区互通 (Phase 4) 协议争议 | OpenPNTs 规范未对齐 | Phase 4 不阻塞 Phase 1-3 上线;Phase 4 协议先 RFC 6 个月 | OpenPNTs + AAStar | +| SubscriptionManager 升级需求 | 早期 tier 配置 / 业务规则改不动 | tier config 用 admin function 可改(不动合约逻辑);业务规则改动需重新部署 + 用户迁移工具 | 合约 + 运营 | +| 链上 quota 与链下 cache 不一致 | 用户被超扣 / 少扣 | (a) 周期对账脚本;(b) cache invalidation 监听事件;(c) settlement 失败重试;(d) 最坏 case 链上为准 | bundler | +| chargeForOp 报价不透明 | 用户无法验证 bundler 计费 | (a) 报价用 M3 §A.1 X402 同样 quote 格式(公开、可对账);(b) bundler 在 RPC 响应里返回本笔扣的 aPNTs;(c) 周期 dashboard 公开总收入 | bundler | +| 多 bundler 抢同一 user session | nonce 冲突 / replay | session key 只 grant 给一个 bundler;多 bundler 抢用户由 SDK 决定授权对象;不共享 hot wallet 私钥 | SDK + bundler | +| 与 SP v5 信用系统语义混淆 | 用户搞不清扣的是 paymaster 费还是 bundler 费 | (a) 文档清晰区分两类费;(b) bundler 在响应日志里分别标 `paymaster_fee` / `bundler_subscription_fee`;(c) dashboard 分两栏展示 | 文档 + bundler | +| Free tier 被 Sybil 滥刷 | 配额被无门槛账户吃光 | (a) 资格门槛 SBT/Agent NFT;(b) Free tier 配额低(50/月)使攻击不经济;(c) 监控异常注册速率 | 合约 + 监控 | +| autoRenew 在用户睡觉时失败导致服务中断 | 用户体验差 | (a) D-3 / D-1 / D-0 三次 push notification;(b) 失败后 24h grace period(订阅过期但仍可用,给 user 时间续);(c) 失败 fallback 到 X402 + 推送提示 | bundler + SDK | + +--- + +## 13 · 附录 + +### 13.1 名词表 + +- **bundler**:本仓库 UltraRelay-AAStar,ERC-4337 bundler +- **fast-lane**:M2 trusted-paymasters 绿色通道 +- **X402**:M3 §A.1 HTTP 402 一次性收费协议 +- **订阅 (subscription)**:本文 Layer 2 链上订阅 +- **API key**:本文 Layer 1 HTTP 鉴权 +- **session key**:AirAccount algId 0x08 时间限制代签密钥 +- **intent UserOp**:bundler 用 session key 签的代扣 op,与原 op 同 bundle 上链 +- **quota**:订阅 tier 内的月度 op 数 +- **prepaidBalance**:用户在 SubscriptionManager 中预存的 aPNTs 余额 +- **hot wallet**:bundler 持有的、用作 sessionKey 的 EOA 私钥;与 executor / utility wallet 隔离 +- **tier**:订阅等级(None / Free / Basic / Pro / Enterprise) + +### 13.2 关键 file:line 索引 + +| 引用 | 文件 | 行 | +|-----|------|---| +| SessionKeyValidator dispatcher | [SessionKeyValidator.sol](file:///Users/jason/Dev/aastar/airaccount-contract/src/validators/SessionKeyValidator.sol) | L99-L106 | +| ECDSA session sig 105-byte 布局 | [SessionKeyValidator.sol](file:///Users/jason/Dev/aastar/airaccount-contract/src/validators/SessionKeyValidator.sol) | L97 (注释) + L255-L269 (验证) | +| P256 session sig 148-byte 布局 | [SessionKeyValidator.sol](file:///Users/jason/Dev/aastar/airaccount-contract/src/validators/SessionKeyValidator.sol) | L98 (注释) + L319-L340 | +| MAX_SESSION_DURATION = 7 days | [SessionKeyValidator.sol](file:///Users/jason/Dev/aastar/airaccount-contract/src/validators/SessionKeyValidator.sol) | L38 | +| grantSession (off-chain owner sig) | [SessionKeyValidator.sol](file:///Users/jason/Dev/aastar/airaccount-contract/src/validators/SessionKeyValidator.sol) | L111-L127 | +| revokeSession + nonce++ | [SessionKeyValidator.sol](file:///Users/jason/Dev/aastar/airaccount-contract/src/validators/SessionKeyValidator.sol) | L146-L153 | +| isSessionActive view | [SessionKeyValidator.sol](file:///Users/jason/Dev/aastar/airaccount-contract/src/validators/SessionKeyValidator.sol) | L156-L159 | +| _validateECDSASession 拒绝条件 | [SessionKeyValidator.sol](file:///Users/jason/Dev/aastar/airaccount-contract/src/validators/SessionKeyValidator.sol) | L260-L266 | +| scope 由 _enforceGuard 运行时强制 | [SessionKeyValidator.sol](file:///Users/jason/Dev/aastar/airaccount-contract/src/validators/SessionKeyValidator.sol) | L21-L23 (注释) | +| xPNTs autoApprovedSpenders mapping | [xPNTsToken.sol](file:///Users/jason/Dev/aastar/SuperPaymaster/contracts/src/tokens/xPNTsToken.sol) | L49 | +| xPNTs allowance() 重写返回 max | [xPNTsToken.sol](file:///Users/jason/Dev/aastar/SuperPaymaster/contracts/src/tokens/xPNTsToken.sol) | L241-L251 | +| xPNTs transferFrom firewall (to == msg.sender or SP) | [xPNTsToken.sol](file:///Users/jason/Dev/aastar/SuperPaymaster/contracts/src/tokens/xPNTsToken.sol) | L262-L277 | +| xPNTs MAX_SINGLE_TX_LIMIT = 5000 ether | [xPNTsToken.sol](file:///Users/jason/Dev/aastar/SuperPaymaster/contracts/src/tokens/xPNTsToken.sol) | L76 | +| xPNTs addAutoApprovedSpender | [xPNTsToken.sol](file:///Users/jason/Dev/aastar/SuperPaymaster/contracts/src/tokens/xPNTsToken.sol) | L446-L453 | +| xPNTs burnFromWithOpHash (replay protection) | [xPNTsToken.sol](file:///Users/jason/Dev/aastar/SuperPaymaster/contracts/src/tokens/xPNTsToken.sol) | L298-L319 | +| xPNTs recordDebt (信用记录) | [xPNTsToken.sol](file:///Users/jason/Dev/aastar/SuperPaymaster/contracts/src/tokens/xPNTsToken.sol) | L330-L343 | +| SP v3 validatePaymasterUserOp 入口 | [SuperPaymaster.sol](file:///Users/jason/Dev/aastar/SuperPaymaster/contracts/src/paymasters/superpaymaster/v3/SuperPaymaster.sol) | L725 | +| SP v3 isEligibleForSponsorship (SBT or Agent) | [SuperPaymaster.sol](file:///Users/jason/Dev/aastar/SuperPaymaster/contracts/src/paymasters/superpaymaster/v3/SuperPaymaster.sol) | L752 + L1010-L1012 | +| SP v3 isRegisteredAgent | [SuperPaymaster.sol](file:///Users/jason/Dev/aastar/SuperPaymaster/contracts/src/paymasters/superpaymaster/v3/SuperPaymaster.sol) | L1015-L1023 | +| SP v3 minTxInterval 限频 | [SuperPaymaster.sol](file:///Users/jason/Dev/aastar/SuperPaymaster/contracts/src/paymasters/superpaymaster/v3/SuperPaymaster.sol) | L766-L772 | +| SP v3 postOp burnFromWithOpHash + recordDebt fallback | [SuperPaymaster.sol](file:///Users/jason/Dev/aastar/SuperPaymaster/contracts/src/paymasters/superpaymaster/v3/SuperPaymaster.sol) | L869-L874 | +| AAStar INTERFACES SP v5 信用 / Agent / SBT | [INTERFACES.md](file:///Users/jason/Dev/Brood/orgs/aastar/INTERFACES.md) | L51-L62 | +| AAStar PROFILE 三模块定位 | [PROFILE.md](file:///Users/jason/Dev/Brood/orgs/aastar/PROFILE.md) | L49-L54 | + +### 13.3 与 M1/M2/M3 的位置交叉引用 + +| 概念 | M1 引 | M2 引 | M3 引 | 本文引 | +|-----|-------|-------|-------|--------| +| HTTP rate limit (per-IP) | §4.1 | — | — | §2.4.6 (与 per-key 限流串行) | +| trusted-paymasters fast-lane | — | §1-§3 | §A.1 互斥规则 | §6.2 优先级最高 | +| paymasterProfiles 插件骨架 | — | §2 | — | (无关),订阅是新轴 | +| X402 一次性收费 | — | — | §A.1 | §6.2 兜底,§5.2 对比 | +| xPNTs 预存账本(链下) | — | — | §A.2 | §3.4.6 对比,§8.1 复用 token | +| ETH PrepaidGas 合约 | — | — | §A.3 | (无关),订阅用 aPNTs / xPNTs | +| 监控告警 webhook | — | — | §B.1 | §7 风控全部接 | +| 多实例水平扩展 | (Redis store) | — | §D.2 | §3.4.4 cache 一致性 | +| Operator attestation | — | — | §C.2 | (无关),订阅信任在 session key | +| EIP-7702 | (PR #13) | — | §C.1 | (无关) | +| 跨链 / 多链 | — | — | §D.1 | §9.4 Phase 4 | + +--- + +**文档结束**。下次评审入口:`docs/SUBSCRIPTION_DESIGN.md` §0 文档定位。 diff --git a/docs/UPSTREAM_PR_QUEUE.md b/docs/UPSTREAM_PR_QUEUE.md new file mode 100644 index 00000000..218dab71 --- /dev/null +++ b/docs/UPSTREAM_PR_QUEUE.md @@ -0,0 +1,82 @@ +# Upstream PR Queue (zerodevapp/ultra-relay) + +> 本文件跟踪 AAStar fork 发现的、应当回馈给上游 ZeroDev / ultra-relay 的修复。 +> 月度上游同步时检查这里的 PR 是否已被上游 merge。已 merge 则在 sync 时移除我们 fork 的本地等价改动(避免双 patch)。 + +--- + +## 待 review + +### PR #1 — chore: remove debug log and clarify max-bundle-count description +- **Status**: 已提交,等待 review — **https://github.com/zerodevapp/ultra-relay/pull/27** +- **Branch**: `AAStarCommunity:upstream-pr/cleanup-debug-and-cli-description` → `zerodevapp:ultra-relay:main` +- **Files**: + - `src/rpc/methods/eth_sendUserOperation.ts:225` — 删除 `console.log("=== eth_sendUserOperation called ===")`(commit 702f9af 引入的 debug 残留) + - `src/cli/config/options.ts:103-108` — `--max-bundle-count` description 修正为 "Maximum number of bundles produced per getBundles iteration (NOT per-bundle op count)" +- **Why upstream**: 两处都是 ZeroDev fork 自带,提到 ZeroDev 让所有下游 fork 受益(不只 AAStar) +- **Risk**: 极低(一行 description + 删一行 console.log) +- **Local note**: AAStar fork 的 `m1/acceptance-and-planning` branch 已包含等价改动;如 PR #27 被 merge,月度 sync 时上游 patch 会覆盖本地(无操作需要);如被拒,转入下文 "已被上游拒绝" 段维护本地 patch + +--- + +## 未来 PR(推到 M3 一并提) + +### Pending — refactor: structured drop logging +- **来源**: M1 §5.5 Known Limitation 5 + M3 监控成熟需求 +- **修改**: `src/mempool/mempool.ts` 的 `dropUserOps` 方法,把 `sender` / `paymaster` / `factory` 提到 pino log 顶级 key(当前嵌在 stringified userOp 里,filter 困难) +- **预计触发**: M3 配 Loki / Grafana 时 +- **Why upstream**: 任何接日志聚合的 ZeroDev 用户都会撞上这个问题 + +### Pending — feat: --allow-implicit-boost flag +- **来源**: M1 §5.5 Known Limitation 2 + M3 X402 启动需求 +- **修改**: `src/rpc/methods/eth_sendUserOperation.ts:230-236` 的 boost 自动升级逻辑加 flag 控制(默认保持现行为以兼容 ZeroDev 当前用户) +- **预计触发**: M3 X402 启动时(届时必须能关掉隐式垫付,由 X402 收费决定是否走 boost 路径) +- **Why upstream**: ZeroDev 的产品定位是 "relayer-without-paymaster" + 入口 API key 鉴权,他们自己的部署不需要这个 flag。但任何想"按调用方分级"的 fork(包括我们)需要这个开关 +- **风险点**: ZeroDev 可能拒绝(认为该行为是产品定位)。如被拒,我们在本仓库长期维护此 patch 并在 docs/FORK_DELTA.md 记录决策 + +### Pending — fix: JSON logging without Better Stack +- **来源**: M1 §5.5 Known Limitation 1 +- **修改**: `src/utils/logger.ts` 的 `initProductionLogger`,无 `BETTER_STACK_TOKEN` 时也能输出 JSON 到 stdout(当前会 fallback 到 pino-pretty) +- **预计触发**: M3 监控成熟时(如生产用 Loki 而非 Better Stack) +- **Why upstream**: Better Stack 不是 universal 选择,多数生产环境用 Loki / CloudWatch / Datadog,这个 fallback 让所有用户受益 +- **背景**: 上游 `520f27a` (#18) + `0993646` (#19) 已加 logtail transport 异常处理 + dead transport noop,但 fallback 逻辑未改——这条 limitation 仍适用 + +### Pending — fix: --json with formatter on Better Stack branch +- **来源**: M1 §5.5 Known Limitation 1(sub-issue) +- **修改**: `src/utils/logger.ts` Better Stack 分支也应用 customSerializer(BigInt → hex)。当前只有 pino-pretty 分支处理 BigInt +- **预计触发**: 同上(一并提) +- **Why upstream**: Better Stack branch 收到 BigInt 会 JSON.stringify 报错,是上游真 bug + +--- + +## 已 merge(历史) + +(空,待第一个 PR 进入此节) + +--- + +## 流程 + +1. **fix branch 在本地完成**:在 AAStarCommunity/UltraRelay-AAStar 上开 branch(如 `fix/xxx`),完成 + 本地 e2e 通过 → push 到 origin +2. **在 zerodevapp/ultra-relay 上发起 PR**: + - head = `AAStarCommunity:UltraRelay-AAStar:fix/xxx` + - base = `zerodevapp:ultra-relay:main` +3. **PR description 必须包含**: + - 发现于哪个 commit / 复现路径 + - 修复方式(diff 摘要) + - 为何上游也应该接收(不只是我们 fork 的特殊需求) + - 测试覆盖(unit / e2e) +4. **tag** @doug 或当前的 ZeroDev maintainer +5. **等待 review**: + - 通过 → 等 merge,merge 后从本节 "待提" 移到 "已 merge",下次月度同步时确认上游 patch 已覆盖我们 fork 的等价改动 + - 拒绝(如 ZeroDev 有意保留行为,例如隐式 boost 升级)→ 在本仓库的 `docs/FORK_DELTA.md` 文档化决策(我们自己长期维护此 patch,靠其他机制解决业务问题,如 IP allowlist + rate limit) +6. **每月例行**:在 `docs/RUNBOOK.md` §3 上游同步流程时检查本文件,确认状态 + +--- + +## 相关文档 + +- `docs/M1_DESIGN.md` — Known Limitations 来源 +- `docs/UPSTREAM_SYNC.md` — 上游同步详细步骤 +- `docs/RUNBOOK.md` §3 — 月度上游同步流程在运营手册中的位置 +- `docs/FORK_DELTA.md` — fork-specific 改动注册表(当 PR 被上游拒绝时,记录为长期 fork-only 改动) diff --git a/src/cli/config/bundler.ts b/src/cli/config/bundler.ts index 97472e66..6436f360 100644 --- a/src/cli/config/bundler.ts +++ b/src/cli/config/bundler.ts @@ -54,7 +54,7 @@ export const bundlerArgsSchema = z.object({ .string() .transform((val) => BigInt(val)) .default("20000000"), - "max-bundle-count": z.number().int().optional(), + "max-bundle-count": z.number().int().min(1).optional(), "rpc-methods": z .string() .nullable() diff --git a/src/cli/config/options.ts b/src/cli/config/options.ts index e7685b1b..e6245ea2 100644 --- a/src/cli/config/options.ts +++ b/src/cli/config/options.ts @@ -101,8 +101,11 @@ export const bundlerOptions: CliCommandOptions = { default: "20000000" }, "max-bundle-count": { + // Cap is enforced per-entrypoint in the getBundles() loop in + // src/mempool/mempool.ts. With N entrypoints and maxBundleCount=M, + // up to N*M bundles may be returned per getBundles() call. description: - "Maximum number of UserOperations to include in a bundle. If not set, no limit is applied.", + "Maximum bundles per entrypoint per getBundles iteration (not per-bundle UserOp count; total bundles = entrypoints × this value)", type: "number", require: false }, diff --git a/src/executor/executorManager.ts b/src/executor/executorManager.ts index 13ceb72e..37e1741a 100644 --- a/src/executor/executorManager.ts +++ b/src/executor/executorManager.ts @@ -95,7 +95,9 @@ export class ExecutorManager { (timestamp) => now - timestamp < RPM_WINDOW ) - const bundles = await this.mempool.getBundles() + const bundles = await this.mempool.getBundles( + this.config.maxBundleCount + ) if (bundles.length > 0) { // Count total ops and add timestamps diff --git a/src/rpc/methods/debug_bundler_sendBundleNow.ts b/src/rpc/methods/debug_bundler_sendBundleNow.ts index f238a928..0ea45571 100644 --- a/src/rpc/methods/debug_bundler_sendBundleNow.ts +++ b/src/rpc/methods/debug_bundler_sendBundleNow.ts @@ -8,16 +8,27 @@ export const debugBundlerSendBundleNowHandler = createMethodHandler({ rpcHandler.ensureDebugEndpointsAreEnabled("debug_bundler_sendBundleNow") const bundles = await rpcHandler.mempool.getBundles(1) - const bundle = bundles[0] - if (bundles.length === 0 || bundle.userOps.length === 0) { + if (bundles.length === 0 || bundles[0].userOps.length === 0) { throw new Error("no userOps in mempool") } - const txHash = - await rpcHandler.executorManager.sendBundleToExecutor(bundle) + let submitted = false + for (const bundle of bundles) { + try { + const txHash = + await rpcHandler.executorManager.sendBundleToExecutor( + bundle + ) + if (txHash) { + submitted = true + } + } catch { + // continue submitting remaining entrypoint bundles + } + } - if (!txHash) { + if (!submitted) { throw new Error("no tx hash") } diff --git a/src/rpc/methods/eth_sendUserOperation.ts b/src/rpc/methods/eth_sendUserOperation.ts index 9d8bea63..920e5eb8 100644 --- a/src/rpc/methods/eth_sendUserOperation.ts +++ b/src/rpc/methods/eth_sendUserOperation.ts @@ -222,7 +222,6 @@ export const ethSendUserOperationHandler = createMethodHandler({ method: "eth_sendUserOperation", schema: sendUserOperationSchema, handler: async ({ rpcHandler, params, apiVersion }) => { - console.log("=== eth_sendUserOperation called ===") const [userOp, entryPoint] = params let status: "added" | "queued" | "rejected" = "rejected" diff --git a/src/rpc/server.ts b/src/rpc/server.ts index aeb6cf95..8af73eaa 100644 --- a/src/rpc/server.ts +++ b/src/rpc/server.ts @@ -21,6 +21,7 @@ import { toHex } from "viem" import type * as WebSocket from "ws" import { fromZodError } from "zod-validation-error" import type { AltoConfig } from "../createConfig" +import { getAvailableWallets } from "../executor/senderManager" import rpcDecorators, { RpcStatus } from "../utils/fastify-rpc-decorators" import RpcReply from "../utils/rpc-reply" import type { RpcHandler } from "./rpcHandler" @@ -181,7 +182,7 @@ export class Server { reply: FastifyReply ): Promise { try { - const wallets = this.config.executorPrivateKeys.map( + const wallets = getAvailableWallets(this.config).map( (account) => account.address ) await reply.status(200).send({ diff --git a/test/e2e/tests/wallets.test.ts b/test/e2e/tests/wallets.test.ts new file mode 100644 index 00000000..4b02cfdc --- /dev/null +++ b/test/e2e/tests/wallets.test.ts @@ -0,0 +1,96 @@ +import { type Address, getAddress, isAddress } from "viem" +import { privateKeyToAccount } from "viem/accounts" +import { foundry } from "viem/chains" +import { beforeEach, describe, expect, inject, test } from "vitest" +import altoConfig from "../alto-config.json" with { type: "json" } +import { beforeEachCleanUp } from "../src/utils/index.js" + +// Endpoint contract (src/rpc/server.ts getWallets, post upstream PR #17): +// GET /wallets -> { +// wallets: Address[], // executor addresses +// chainId: number, +// utilityWalletAddress: Address, // utility/sponsor wallet +// refillingWallets: boolean // whether this instance refills wallets +// } + +type WalletsResponse = { + wallets: Address[] + chainId: number + utilityWalletAddress: Address + refillingWallets: boolean +} + +const altoRpc = inject("altoRpc") +const anvilRpc = inject("anvilRpc") + +// Derive expected executor addresses from the same private keys the bundler +// is configured with (test/e2e/alto-config.json). +const expectedExecutorAddresses = altoConfig["executor-private-keys"] + .split(",") + .map((pk) => privateKeyToAccount(pk.trim() as `0x${string}`).address) + +const fetchWallets = async (): Promise<{ + status: number + body: WalletsResponse +}> => { + const response = await fetch(`${altoRpc}/wallets`) + const body = (await response.json()) as WalletsResponse + return { status: response.status, body } +} + +describe("GET /wallets", () => { + beforeEach(async () => { + await beforeEachCleanUp({ anvilRpc, altoRpc }) + }) + + test("returns 200 with wallets, chainId, utilityWalletAddress, refillingWallets", async () => { + const { status, body } = await fetchWallets() + + expect(status).toBe(200) + expect(body).toHaveProperty("wallets") + expect(body).toHaveProperty("chainId") + expect(body).toHaveProperty("utilityWalletAddress") + expect(body).toHaveProperty("refillingWallets") + + expect(Array.isArray(body.wallets)).toBe(true) + expect(body.wallets.length).toBeGreaterThan(0) + for (const wallet of body.wallets) { + expect(isAddress(wallet)).toBe(true) + } + + expect(body.chainId).toBe(foundry.id) + expect(isAddress(body.utilityWalletAddress)).toBe(true) + expect(typeof body.refillingWallets).toBe("boolean") + }) + + test("returned wallets match addresses derived from configured executor keys", async () => { + const { body } = await fetchWallets() + + // Order is implementation-defined; compare as sets (lower-cased so a + // mismatch in EIP-55 casing surfaces in the format test below). + const returned = new Set( + body.wallets.map((address) => address.toLowerCase()) + ) + const expected = new Set( + expectedExecutorAddresses.map((address) => address.toLowerCase()) + ) + + expect(returned).toEqual(expected) + expect(body.wallets).toHaveLength(expectedExecutorAddresses.length) + }) + + test("each returned address is in EIP-55 checksum format", async () => { + const { body } = await fetchWallets() + + const allAddresses = [ + ...body.wallets, + body.utilityWalletAddress + ] + for (const wallet of allAddresses) { + // viem.getAddress throws on non-checksummed input; for already + // valid input it returns the canonical checksummed form, which + // must be byte-equal to what the endpoint returned. + expect(getAddress(wallet)).toBe(wallet) + } + }) +})