Skip to content

fix(account): derive the deploy chain from config, preflight the RPC (#439) - #442

Merged
jhfnetboy merged 2 commits into
masterfrom
fix/439-account-service-chain
Aug 1, 2026
Merged

fix(account): derive the deploy chain from config, preflight the RPC (#439)#442
jhfnetboy merged 2 commits into
masterfrom
fix/439-account-service-chain

Conversation

@jhfnetboy

Copy link
Copy Markdown
Member

Closes #439

问题

#434 在 GuardianService 修掉的完全同构,只是发生在建号流程:

  • submitCreateWithPasskey(~409 行)用 chain: sepolia 硬编码建 deployer wallet
  • prepareGuardianSetup(~122 行)把 guardian acceptance hash 绑定到 configService.get("chainId")

configuration.ts:52 默认 chainId: parseInt(process.env.CHAIN_ID, 10) || 10 —— 未显式设 CHAIN_ID 时是 10,于是用户签的是一条链、账户部署在另一条链上。旁边那个 || 11155111 是死代码(config 已保证非空),只起到掩盖分歧的作用。

viem 抓不到这个:assertCurrentChain 只在 json-rpc account 分支跑,而 deployer 是本地私钥账户,其 prepareTransactionRequest 直接 if (chain) return chain.id从不发 eth_chainId。所以前置校验必须自己做,而且必须在一次性 WebAuthn 仪式被消耗之前 —— 否则用户白做一次仪式,换来一笔和自己签名 digest 对不上的部署。

改法

  • chain.util.ts 新增两个 helper(复用 refactor: migrate all ethers users → viem, drop ethers dep (CC-43) #434 建好的文件):

    • assertValidChainId —— 校验配置,不做静默兜底
    • assertRpcChain —— 比对端点真实 eth_chainId,接一个 why 字符串让报错说清楚"这个不一致会破坏什么"

    两者都不依赖 Nest,各 service 保留自己的异常映射。

  • AccountService 增加 getChainId() / assertChainMatchesRpc();deployer wallet 改用 resolveChain(this.getChainId())prepareGuardianSetupsubmitCreateWithPasskey 都先做 RPC 前置校验。

关于重复代码

GuardianService 里还留着 #434 那份同样逻辑的副本。这里故意没有一并迁移 —— #441 正开在同一个文件上,冲突不值得。等 #441 落地后另开一个 follow-up 把 guardian 也切到共享 helper,届时才有单一真相源。

测试(新建 account.service.spec.ts,9 个用例;后端共 66)

11155111 / 10 / 8453

  • acceptance hash 的 chainId(buildGuardianAcceptanceHash 第 4 个位置参数)
  • QR payload 里的 chainId
  • deployer wallet 的 chain.id

三者全部等于配置值。不一致时两个入口都拒绝,且 hash 不构建、部署不发起;chainId 不可用时直接拒绝而非兜底。

变异验证非空测:改回硬编码 sepolia 挂 2 个;去掉前置校验挂 2 个。

Gates

backend type-check + build + 66 tests + lint + prettier 全绿。

Claude-Session: https://claude.ai/code/session_01BxmyQj2A82DfFXu97kKACk

…439)

Same defect PR #434 fixed in GuardianService, in the account-creation flow:
`submitCreateWithPasskey` built the deployer wallet with a hardcoded
`chain: sepolia` (~409) while `prepareGuardianSetup` bound the guardian
acceptance hash to `configService.get("chainId")` (~122). configuration.ts
defaults chainId to 10, so unless CHAIN_ID is set the user signs for one chain
and the account is deployed on another; the `|| 11155111` sitting next to it was
dead code that only hid the divergence.

viem does not catch this: `assertCurrentChain` runs only on the json-rpc account
branch, and the deployer is a local (private-key) account whose
`prepareTransactionRequest` returns `chain.id` without ever issuing
`eth_chainId`. So the preflight has to be ours, and it has to happen before the
one-time WebAuthn ceremony is spent — otherwise the user burns a ceremony on a
deploy that cannot match its own signed digest.

- Two new helpers in the existing chain.util.ts: `assertValidChainId` (config
  validation, no silent fallback) and `assertRpcChain` (compares the endpoint's
  eth_chainId, takes a `why` string so the error says what would have broken).
  Both are Nest-free so services keep their own exception mapping.
- AccountService gets `getChainId()` / `assertChainMatchesRpc()`; the deployer
  wallet now uses `resolveChain(this.getChainId())`, and both
  `prepareGuardianSetup` and `submitCreateWithPasskey` preflight the RPC first.

GuardianService still carries its own copy of this logic from #434. Not migrated
here on purpose: #441 is open against that same file and the conflict is not
worth it — worth a follow-up once that lands, so there is one source of truth.

Tests (new account.service.spec.ts, 9 cases; 66 total): across 11155111/10/8453,
the acceptance hash's chainId, the QR payload's chainId and the deployer wallet's
chain.id are all the configured value; on mismatch both entry points refuse and
neither the hash nor the deploy is attempted; an unusable chainId is refused
rather than defaulted. Verified non-vacuous by mutation: restoring the hardcoded
sepolia fails 2, removing the preflight fails 2.

Gates: backend type-check + build + 66 tests + lint + prettier green.

Closes #439

Claude-Session: https://claude.ai/code/session_01BxmyQj2A82DfFXu97kKACk
@jhfnetboy
jhfnetboy requested a review from fanhousanbu as a code owner August 1, 2026 16:15

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1b579ab7ba

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +447 to +449
await this.assertChainMatchesRpc(
"The signed CREATE_ACCOUNT digest would not match the chain the deploy is relayed to."
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add passkey preflight before preparing the digest

Putting the chain/RPC check only in submit still lets /account/prepare-create-with-passkey build the CREATE_ACCOUNT digest, create the pending challenge, and return publicKeyOptions; the frontend then runs navigator.credentials.get before this line can reject. When CHAIN_ID and ETH_RPC_URL are mismatched, users still spend the passkey ceremony and leave an unusable prepare session before seeing the configuration error. Please run the same assertChainMatchesRpc check at the start of prepareCreateWithPasskey, before the SDK issues the digest/challenge.

Useful? React with 👍 / 👎.

@clestons clestons left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — #442 (fix(account): derive the deploy chain from config, preflight the RPC, closes #439)

结论:APPROVE

#434(guardian)、#441 同一类缺陷,这次是账户创建流程:submitCreateWithPasskey 部署钱包写死 chain: sepolia,而 prepareGuardianSetup 的 guardian acceptance hash 走的是 config 的 chainId——非 Sepolia 配置下 guardian 签的链和账户实际部署的链对不上。configuration.ts chainId 默认 10,原来那行 || 11155111 是死代码,只是掩盖了分叉,不是真兜底。

修复思路和 #434/#441 一致:chain.util.ts 新增两个不依赖 Nest 的通用 helper(assertValidChainId/assertRpcChain),AccountService 包一层自己的异常映射,prepareGuardianSetupsubmitCreateWithPasskey 都在动手前先 assertChainMatchesRpc()。故意没动 GuardianService#434 留下的同款逻辑——PR 描述里说得很清楚,#441 正开在那个文件上,不值得为了这个顺手改而产生冲突,等 #441 落地后再统一收口。

验证

  • 本地实跑(worktree):npx jest 66/66 全绿(9 suite,新增 account.service.spec.ts 9 条:11155111/10/8453 三条链分别验证 acceptance hash / QR payload / 部署钱包 chain.id 都是配置值;RPC 和 config 不一致时两个入口都拒绝;非法 chainId 直接拒绝不兜底);npx tsc --noEmit 干净。
  • 确认了 submitCreateWithPasskeyassertChainMatchesRpc() 真的在构建部署钱包之前、在花掉一次性 WebAuthn ceremony 之前调用——直接读代码验证过位置。

[Note] Medium — prepareCreateWithPasskey(第三个入口,这次没改)也会在 preflight 覆盖范围之外构建链绑定的产物

Codex PK 独立挖出的:这个 PR 只给 prepareGuardianSetupsubmitCreateWithPasskey 加了 preflight,但账户创建还有第三个方法 prepareCreateWithPasskey(account.service.ts:350)完全没碰。它调用 SDK 的 prepareCreateAccountWithPasskey,我验证过 SDK 内部(node_modules/@aastar/sdk/dist/kms.js:1321)确实会读 chainId 去构建 CREATE_ACCOUNT digest(WebAuthn 要签的那个东西)。也就是说:链不一致的情况下,理论上可能在这一步就已经签出一个绑定了不一致链的挑战,用户在真正走到有 preflight 保护的 submitCreateWithPasskey 之前,ceremony 已经可能被"花在"一个有问题的产物上——这直接触碰了这个 PR 自己声明的核心目标("preflight 要在 ceremony 之前,不能让用户白跑一次 WebAuthn")。不算这个 PR 引入的新问题(这个方法本来就没改),但因为跟这个 PR 想解决的正是同一件事,建议尽快补一个 follow-up 给 prepareCreateWithPasskey 也加上 preflight,不要等太久。

PK Summary | Verification

  • R1 DeepSeek(flash):R1a+R1b 都跑了,这轮质量一般。R1a 主发现("submitCreateWithPasskey 里没有 preflight")直接被 diff 内容和它自己输出的 skeleton 文字自相矛盾证伪——skeleton 里自己写"preflight 已经放在两个入口的 WebAuthn ceremony 之前",跟它自己列的 finding 完全矛盾。R1b 两条(私钥没格式校验、私钥可能在日志里泄露)Codex 核实:前者是这个 PR 完全没碰的既有代码,而且 viem 的 privateKeyToAccount 自己就会校验格式拒绝畸形密钥;后者没有具体引用,查了一圈没有任何地方真的记录了私钥。
  • Codex PK:独立 worktree。驳回了 R1a 的错误主张,确认 R1a 那条 ensureSigner 排序小问题真实但低价值(还额外查证了 ensureSigner 只是解析已有 KMS 钱包地址、不产生任何副作用,排序不重要),驳回 R1b 两条私钥相关发现,确认 R1b 的 RPC-TOCTOU 存在但门槛高(需要后端基础设施/网络层面被控制,比 #441 那个"另一个 guardian 就能触发"的门槛高得多),并独立挖出上面这条 prepareCreateWithPasskey 缺口。

自评 — #442

  • 轮数:R1 DeepSeek(R1a+R1b 真跑)+ 我独立分析(含验证 preflight 调用顺序)+ 本地实跑 66/66 测试+tsc 干净 + Codex PK(1 轮,深挖到 SDK 内部代码)。
  • 机械证据:npx jest 66/66、npx tsc --noEmit 干净;grep SDK 源码(node_modules/@aastar/sdk/dist/kms.js)独立验证 Codex 关于 prepareCreateWithPasskey 内部读 chainId 的说法。
  • DeepSeek flash 评级:2/5 —— 这轮质量不如 #441。R1a 主发现是明显的假阳性(而且是那种"自己输出前后矛盾"级别的低级错误——skeleton 段落写对了,finding 段落却写反);R1b 两条私钥相关发现都不成立(一条范围外、一条无凭据)。样本 9/20,这轮拉低了 security 模式(R1b)在这批评估里的平均命中率——上一轮(#441)R1b 刚给了这批最好的一次命中,这轮 R1b 两条全军覆没,说明"R1b 更可信"这个规律还需要更多样本验证,不能过早下结论。
  • 与 skill 设计一致,R1a+R1b 双通道+Codex PK 都跑了。

R1 DeepSeek(R1a+R1b)真跑 + 独立分析(验证 preflight 顺序)+ 本地实跑 66/66 测试+tsc 干净 + Codex PK 1 轮(深挖到 SDK 内部代码,独立发现 prepareCreateWithPasskey 缺口)。

@jhfnetboy
jhfnetboy merged commit de56e05 into master Aug 1, 2026
11 of 13 checks passed
@jhfnetboy
jhfnetboy deleted the fix/439-account-service-chain branch August 1, 2026 16:46
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 1, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

account.service: deployer wallet 硬编码 sepolia,与 CREATE_ACCOUNT digest 的 config chainId 可分歧

2 participants