fix(composer): preserve extra on accept rows so x402 v1 validation accepts them#814
fix(composer): preserve extra on accept rows so x402 v1 validation accepts them#814scifantastic wants to merge 3 commits into
extra on accept rows so x402 v1 validation accepts them#814Conversation
|
@scifantastic is attempting to deploy a commit to the Merit Systems Team on Vercel. A member of the Team first needs to authorize it. |
|
Quick follow-up — both fixes verified end-to-end against a real provider since filing:
The Happy to provide more repro detail or rebase if anything's stale. |
|
@shafu0x ping — this PR is the fix for the specific deepnets origin issue you're looking into on Telegram (server id The "no x402OriginId assigned" symptom is downstream of This PR coerces |
|
Friendly nudge — this bug is still actively biting on prod as of today (2026-05-04). Re-confirmed against All 16 canonical entries land in Repro for any maintainer who wants to verify: # Confirm 16 entries exist in resources DB:
curl -s 'https://www.x402scan.com/api/trpc/public.resources.search?input=%7B%22json%22%3A%7B%22search%22%3A%22deepnets%22%2C%22limit%22%3A50%7D%7D' \
| jq '.result.data.json | map(.resource) | length'
# 50 (incl deprecated legacy)
# Same query through tools.search → empty:
curl -s 'https://www.x402scan.com/api/trpc/public.tools.search?input=%7B%22json%22%3A%7B%22search%22%3A%22deepnets%22%2C%22limit%22%3A50%7D%7D' \
| jq '.result.data.json | length'
# 0Re-checking the writer ( Happy to rebase if there are conflicts with the recent merges. Anything else needed from my end to move this forward? |
|
Wouldn't a simpler fix be to just swap the optional zod schema to be nullish? |
| // Postgres/Prisma round-trips a JSONB `undefined` as `null`, but the | ||
| // v1 PaymentRequirementsSchema declares `extra` as `optional()` — i.e. | ||
| // `T | undefined`, not `T | null` — so a `null` here trips the parse | ||
| // and the resource is silently dropped from the composer's tool list. |
There was a problem hiding this comment.
would not be necessary with a zod.nullish?
There was a problem hiding this comment.
Done in 872aa0b9 — overrode extra: z3.record(z3.any()).nullish() on paymentRequirementsSchemaV1 and dropped this null-stripping branch. Cleaner.
| // `optional()` not `nullish()`) and the resource was silently | ||
| // dropped from the agent composer. Falling back to `undefined` | ||
| // when the source omits `extra` keeps prior behaviour. | ||
| extra: |
There was a problem hiding this comment.
Quite an extensive ternary, very difficult to read
There was a problem hiding this comment.
Fair, that was ugly. Simplified to a single cast in a77e9c9f — @agentcash/discovery's X402V{1,2}PaymentOption types don't declare extra yet, hence the cast; once they do it can go away.
|
Good call — pushed Kept the writer-side change in |
…accepts them
`registerResource()` was hard-coding `extra: undefined` on every mapped
accept row regardless of what the source 402 sent. Prisma persists that
as `extra: null` in the JSONB column, but the v1 PaymentRequirementsSchema
declares `extra` as `optional()` — i.e. `T | undefined`, not `T | null` —
so the read-side `paymentRequirementsSchemaV1.safeParse(...)` in
`searchX402Tools()` rejects every such row. The resource is silently
`continue`d and never reaches the composer's tool list.
Concrete repro: every deepnets.ai endpoint sends `extra: { feePayer: ... }`
on its 402 (Solana fee payer for the relayed-tx flow). All ~34 rows for
the deepnets origin are present in `public.resources.search` results, but
zero appear in `public.tools.search` because every accept row's stored
`extra` is `null` and parse fails.
Two-part fix:
- `apps/scan/src/lib/resources.ts` — copy `opt.extra` through to the
mapped accept when the source provides it. Falls back to `undefined`
(preserving prior behaviour) when omitted.
- `apps/scan/src/lib/x402/index.ts` — defensive read-side coercion in
`coerceAcceptForV1Schema`: strip `null` from known-optional fields
(currently just `extra`) before validation. This unblocks the existing
rows already in the DB without requiring a re-crawl.
…dback)
zdql suggested a cleaner read-side fix: rather than coercing `null`
to `undefined` in `coerceAcceptForV1Schema`, just override the
inherited `extra: optional()` on `paymentRequirementsSchemaV1` to
`extra: nullish()`. Same effect at the validation boundary, fewer
moving parts.
- Drop the `if (base.extra === null) delete base.extra` branch in
`coerceAcceptForV1Schema` — no longer needed.
- Override `extra: z3.record(z3.any()).nullish()` in
`paymentRequirementsSchemaV1.extend({ ... })` so `null` parses
cleanly.
Writer-side change in `resources.ts` is intentionally retained — it
preserves the source `extra` payload itself (e.g. Solana fee payer)
rather than dropping it on the floor. That isn't about validation
tolerance; without it, every Solana accept row stored after this
PR still loses its `extra.feePayer`, which downstream tool-execution
needs to construct the relayed-tx.
…w feedback) zdql noted the previous ternary was hard to read. Simplify to a single locally-scoped cast — `@agentcash/discovery`'s payment-option types don't declare `extra` yet, hence the cast; once they do it can go away entirely.
a77e9c9 to
ebf3e4f
Compare
|
Heads up — that last force-push was a rebase onto current
Final tip is |
Summary
The agent composer's tool picker (
api.public.tools.search) silently drops every resource whose accept rows were stored withextra: null. This affects every Solana provider (and any other provider that sends anextrapayload) — they show up in the resource list but never in the composer's tool search.Symptom
For deepnets.ai, all ~34 endpoints send a populated
extra: { feePayer: <pubkey> }on their 402 (Solana relayed-tx fee payer). They all appear inpublic.resources.search, none appear inpublic.tools.search— including withshowExcluded: true, showDeprecated: true. Composer search for "deepnets" returns zero results.Root cause
Two compounding bugs, both in this repo:
1. Writer drops
extra—apps/scan/src/lib/resources.ts:138hard-codesextra: undefinedon every mapped accept row regardless of what the source 402 sent. Prisma persistsundefinedasnullin the JSONB column.2. Reader can't parse
null—paymentRequirementsSchemaV1extendsPaymentRequirementsSchemafromx402/types, which declaresextra: z.record(z.any()).optional(). Zod'soptional()meansT | undefined, notT | null— so a storednullfails validation. WhensearchX402Tools()(apps/scan/src/services/agent/search-tools.ts:38-54) callspaymentRequirementsSchemaV1.safeParse(...)and it fails, the resource is silentlycontinued.So the pipeline writes data its own reader can't read.
Fix
Two minimal, surgical changes:
apps/scan/src/lib/resources.ts— copyopt.extrathrough to the mapped accept when the source provides it. Falls back toundefined(preserving prior behaviour) when omitted. Stops new rows from being persisted withnull.apps/scan/src/lib/x402/index.ts— defensive read-side coercion incoerceAcceptForV1Schema: stripnullfromextrabefore validation. Unblocks the existing rows already in the DB without requiring a re-crawl.Verification
pnpm types:checkfromapps/scan: zero new errors (unchanged at 324 — all pre-existing inpackages/internal/databases/transfersdue to ungenerated Prisma clients).npx eslint apps/scan/src/lib/resources.ts apps/scan/src/lib/x402/index.ts: zero new errors (unchanged).npx prettier --check ...: clean.Manual repro of the symptom (before this fix)
Happy to split this into two PRs (writer-side and reader-side) if preferred. There's a separate but related bug in
register-origin.tswhere OpenAPI-discovered routes are stored with un-substituted path templates (/api/token-safety/{mint}literal); I can file that as a follow-up if you're interested.