最新资讯
dev.to 我们在 DolphinBench 上测试记忆检索:纯关键词搜索暴露了自身回归 dev.to 别再把 Agent 写成 while 循环,把它建模为状态机 bloomberg.com Rightmove 与 Scout24 借 AI 产品应对低迷房市并上调展望 stackoverflow.blog 欢迎来到芯片时代:半导体制造、AI 数据中心与芯片设计的未来 www.youtube.com AI Agent 总犯同样的错:CentralAgent AI 将记忆重构为经验系统 pub.towardsai.net Netflix 科学家建议重采样 LLM 评判:5 次仅将误差棒缩小 13% pub.towardsai.net Jev 作为 LLM 前的静默层:构建提示感知的模型路由器 reddit.com OpenAI 称 Astra 仅满足 10% 安全任务标准 dev.to FreeToken:在自有 GPU 上运行前沿 MoE 模型及其内存需求 dev.to 当 AI Agent 说“完成”时,究竟完成了什么? dev.to 部署 Underdog Bark 3.0 Pro:逐字转写、自定义后端与分块设置 dev.to 为艰难对话构建 AI:五个 Agent、一条安全正则与一位盲评监督者
多元动态
登录 注册
行业动态 dev.to · @Eric Shepard

上线 x402 实时端点时踩到的四个 bug

原文 我的收藏

作者复盘一个按次付费抓取 API(在 Base 上以 USDC 结算)上线后出现的四个生产 bug,且每个都通过了绿色测试套件。其一,cdp_facilitator.py 在签名函数内懒加载 import jwt,但 PyJWT 未写入 requirements.txt,仅作为其他依赖的传递依赖存在于开发机,容器中没有,导致经 Coinbase facilitator 的支付数周失败并只返回 facilitator_unreachable;修复方式是增加运行时 import 与声明依赖的一致性测试,但该守卫只检查硬编码列表。其二,Base 上 x402 exact 方案用 EIP-3009 transferWithAuthorization 结算,需要私钥产生的 ECDSA 签名,智能合约钱包没有私钥因而永远无法作为付款方,可通过 eth_getCode 返回 0x(EOA)或更长字节码(合约)区分。其三,充值地址不等于控制权,作者曾把交易所充值页复制的地址当作已验证,链上实际零转账;新规则是转入一美元再转出,转不出去就说明不控制该地址。

AI推荐理由 作者以第一人称复盘一个按次付费抓取 API(用 USDC 在 Base 上结算)上线后出现的四个生产环境 bug,并给出具体代码与排查命令。核心新信息包括:懒加载 import jwt 未写入 requirements.txt 导致 Coinbase facilitator 支付数周失败且报错无意义;EIP-3009 transferWithAuthorization 要求 ECDSA 签名,因此智能合约钱包无法作为付款方,可用 eth_getCode 返回 0x 与否区分 EOA 和合约;以及“充值地址不等于控制权”的教训。局限是这些是个人项目经验,未提供可复现仓库或量化数据,且文中自述的测试守卫只检查硬编码列表,保护范围有限。对做 x402/加密支付和 Agent 付费接口的开发者有直接参考价值。

72

正文

Four bugs shipping a live x402 endpoint Four bugs reached production in a pay-per-call scraping API that settles USDC on Base. Every one of them passed a green test suite. In each case the thing meant to catch the bug was more forgiving than production was. The dependency nobody declared Every payment through the Coinbase facilitator had been failing for weeks, and the error said nothing useful: clients got facilitator_unreachable. The cause was one line that did not exist. cdp_facilitator.py imported PyJWT lazily, inside the function that signs the bearer token: def build_bearer_jwt(...): import jwt # never declared in requirements.txt Enter fullscreen mode Exit fullscreen mode PyJWT was installed on every development machine as somebody else's transitive dependency. It was not in the container. The import only ran when a real payment arrived, so nothing failed at startup, no health check noticed, and the test suite passed because the tests ran where the package happened to exist. A lazy import is exactly the shape that survives local development and dies in production. It runs on the one code path nobody exercises before shipping. The fix is a test, not a discipline: RUNTIME_IMPORTS = {"jwt": "pyjwt", "cryptography": "cryptography", "stripe": "stripe"} def test_runtime_imports_are_declared(self): for module, distribution in RUNTIME_IMPORTS.items(): assert distribution in declared_distributions() Enter fullscreen mode Exit fullscreen mode Worth knowing the limit of that guard: it checks a hardcoded list rather than scanning source for imports. It protects the dependencies someone remembered to add to it, which is better than nothing and less than it sounds. A wallet that can be paid and can never pay The x402 exact scheme on Base settles with EIP-3009 transferWithAuthorization. The payer signs an authorization off-chain, the facilitator submits it and pays the gas. That signature is ECDSA, produced by a private key. A smart-contract wallet has no private key. It cannot produce that signature, so it can never be the payer ΓÇö even though it receives USDC perfectly well and looks identical in a block explorer. One call tells them apart: curl -s -X POST https://mainnet.base.org \ -H 'content-type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"eth_getCode","params":["0xYOUR_ADDRESS","latest"]}' Enter fullscreen mode Exit fullscreen mode 0x means an EOA, which can sign. Anything longer is a contract, which cannot. The receiving wallet here returned 48 bytes of code. Two related things cost real time: A deposit address is not proof of control. An earlier receiving address had been pasted from an exchange deposit screen and treated as verified. On-chain it had never received or sent anything ΓÇö zero transfers, ever. The test deposit it was supposed to have received did not exist. The rule that replaced it: send a dollar in, send it back out. If you cannot send it back, you do not control it. Contract wallets pay gas in tokens. The one here pays via a paymaster, charging roughly 1.5 cents in USDC per outbound transfer. Fine for receiving revenue, expensive to spend from. The transaction that never committed This one shipped to production and reported success every time. transaction = self._client.transaction() customer_doc = customer_ref.get(transaction=transaction) ... transaction.update(customer_ref, {"credit_used_micro": new_value}) transaction.set(event_ref, {...}) return DebitResult("ok", remaining) Enter fullscreen mode Exit fullscreen mode Transaction.update() and .set() in google-cloud-firestore append to an internal write buffer. Those writes are sent by commit(), which is called by the @firestore.transactional decorator or by using the transaction as a context manager. This code does neither, so the buffer is discarded when the object is garbage collected. Nothing raises. The function returns ok, the request is served, and the customer's balance never moves. Refunds wrote directly rather than through a transaction, so a balance could only ever go up. The reason 678 passing tests said nothing about it is the part worth copying. The fake Firestore client applied transaction writes immediately: class FakeTransaction: def update(self, ref, data): ref.update(data) # real client buffers; this does not Enter fullscreen mode Exit fullscreen mode The fake modelled the API I assumed rather than the one that exists. A test double more forgiving than production hides exactly the class of bug it exists to catch. The suite was not weak on coverage; it was confidently wrong about the dependency. Two changes, in order of importance: The fake now buffers until commit(), and a test asserts writes are invisible before it. The regression test for the bug is a runner that never commits, asserting the balance stays put while the call claims success. Begin, retry and commit moved into a single injected runner, so no individual method drives a transaction by hand and no future method can forget. It cost nothing, because no customer had a key yet. It would have hit the first one. Completed does not mean paid The card path had the same shape of error, caught before it shipped. checkout.session.completed sounds like the fulfilment event. For card payments it is. For delayed payment methods ΓÇö bank debits and similar ΓÇö the session completes when the customer finishes the flow, which is before the money arrives. Crediting on that event alone grants credit for funds that may never settle. So the handler gated on payment status: if session.get("payment_status") not in (None, "paid"): return None Enter fullscreen mode Exit fullscreen mode Correct, and half a feature. Nothing listened for checkout.session.async_payment_succeeded, the event that fires when the delayed payment actually settles. A customer paying by bank debit would have been charged and never credited ΓÇö the failure mode that generates a support ticket and a chargeback rather than a quiet loss. Both events fulfil now, both gated on payment status. Two smaller things from the same reading of Stripe's own guidance: Setting the module-level stripe.api_key on each call is deprecated, and in a concurrent server it is shared mutable state. A StripeClient instance carries the key instead. Passing payment_method_types disables dynamic payment methods, which decide what to show each buyer. Omitting it is the default for a reason. The webhook is also the most attacker-interesting endpoint in the service: a public URL that moves money into an account. It verifies the signature before parsing the body, grants only what the processor reports as paid, and is idempotent on the event id, because a webhook is retried on any non-2xx. A verified event that does nothing still returns 200 ΓÇö otherwise it is redelivered forever. The pattern None of these were hard bugs. Each one hid behind something that was supposed to catch it and was more permissive than production: Bug What should have caught it Why it didn't PyJWT undeclared The test suite Ran where the package happened to exist Contract wallet as payer The address looking valid Only eth_getCode distinguishes them Transaction never committed 678 passing tests The fake applied writes the real client buffers Credit on completed Reading the event name The name describes the session, not the money The cheap checks, in the order I wish I had done them: Assert your declared dependencies, do not trust your machine. A lazy import is the one that gets you. Prove wallet control by moving money out, not in. Anyone can send to an address nobody holds the key to. Make test doubles pessimistic. If the real client buffers, the fake buffers. A double that is kinder than production converts a green suite into false confidence. Read the payment provider's own guidance before going live, not after. Two of these came straight out of it, and it took ten minutes. The one that generalises furthest is the third. Coverage was never the problem ΓÇö the money path had tests for concurrency, idempotency, expiry and refunds, and they all passed. They were all asking a stub that had been written to agree with me. The endpoint these came from is a hosted Crawl4AI service that takes x402 payments: page in, markdown or LLM-extracted JSON out. There is a demo that needs no signup, if you want something to point an agent at. curl -X POST https://api.doit2winsolutions.co/demo/scrape \ -H "Content-Type: application/json" \ -d '{"url":"https://example.com"}' Enter fullscreen mode Exit fullscreen mode It is new and has no customers yet. I would rather hear that it breaks on your pages than hear nothing.

来源报道 · 1 篇 · 以下链接均前往原始媒体

Eric Shepard 上线 x402 实时端点时踩到的四个 bug 09-19 · 原文发布
← 返回精选动态 原文

更多内容