Skip to content

feat(core): let the backend decide whether a request is served - #321

Merged
Ghadi8 merged 62 commits into
mainfrom
feat/core-optional-api-key
Sep 23, 2026
Merged

Ghadi8 merged 62 commits into
mainfrom
feat/core-optional-api-key

Conversation

@mariano-aguero

@mariano-aguero mariano-aguero commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

What

apiKey becomes optional on the SDK's entry types, an RPC refusal from the proxy now throws instead of resolving to undefined, and core names the dApp it is acting for on calls that go to a backend of ours.

Why

Whether a request is served is the backend's decision, not the SDK's. The types said otherwise. apiKey was required on CreateJAWSDKOptions and ConstructorOptions, and @jaw.id/wagmi inherits it through JawParameters, so a caller could not even build a request the server would get to answer.

Three things fell out of relaxing it.

The chain list was built inside if (params.apiKey), so a config without one produced an SDK with no chains and no error saying why. The condition is gone and createInitialChains handles the absence itself.

Five handlers refused locally before reaching the network: the four wallet_get* in JAWSigner and wallet_revokePermissions in CrossPlatformSigner. They now forward what they have and let the server answer. wallet_getCapabilities matters most there, since it is what declares EIP-5792 atomic support.

The URL builders drop the query parameter rather than sending api-key= empty, which reaches the proxy as a malformed key and is rejected before anything else is considered.

App-specific mode is unchanged and still requires a key. It hands one to the dApp's own UIHandler, which has nowhere to get it, so create() refuses such a config while the app is still being wired up.

Naming the calling dApp

A request that carries no key has to be identifiable some other way, and the browser's Origin is that way from a dApp's own page. From the keys popup it is not: everything there runs on the keys origin, which is the same whichever dApp opened it. setDappOrigin is how keys says which dApp a call acts for, taken from the origin the browser put on the message being served rather than from an earlier one, and core sends it as x-dapp-origin. A dApp's own page must never call it, since a value set there would be a claim the browser did not make.

The value stays out of the persisted config. One origin serves every dApp in popup mode, so a stored one would greet the next dApp holding the previous one's.

Which hosts are told follows one rule in two places. The RPC and bundler transports are ours whatever host they point at, staging and local included, so they always carry the header. The urls that may belong to somebody else are a paymaster's, which is told only when it is on our proxy, and a serverUrl an app-specific dApp points at its own server, which is never told.

Analytics reports without a key rather than not at all. logSignature and logAccountIssuance used to return early when the config had no key, which would have left a caller with no metric of its own recorded anywhere. They send what they have now, and the dApp origin is what names the caller when the key is missing.

The bug this uncovered

fetchRPCRequest never checked res.ok. A refusal is not a JSON-RPC envelope, so destructuring it gave { result: undefined, error: undefined } and the call resolved to undefined instead of failing. handleGetCapabilitiesRequest then cached that silence for 60 seconds, against a comment saying only a fulfilled response is cached.

The path already existed for an invalid key, so this is a fix in its own right. It changes the error behaviour of every RPC call the SDK makes, not only the four reads: a caller treating undefined as "empty" now sees an exception. Nothing in this repo does, and getCapabilities reaches wagmi through TanStack Query, where a rejection is what the query expects and undefined was the worse outcome.

Before releasing

nx release versions off conventional commits, so this publishes a minor of @jaw.id/core on its own. It pairs with a server-side change that is not deployed yet, and publishing first puts the client ahead of the server. That deploy lands first, or this publish waits.

How to test

bun install
bunx nx run-many -t typecheck lint test --projects=@jaw.id/core,@jaw.id/ui
bunx nx api-check @jaw.id/core

35 new tests, plus two in JAWSigner.test.ts that changed sides: they used to assert that a missing key silences the analytics report, and now assert that the report goes out without one. The fetchRPCRequest block in utils/provider.test.ts is the one worth reading, since its 401 case is what used to resolve to undefined and now throws. api/rest.test.ts and store/chain-clients/utils.test.ts pin which hosts are told which dApp is calling.

The docs changes are in configuration/apiKey.mdx and the two pages that link to it: the key is optional in cross-platform mode, and a keyless call is served against the origin registry instead.

Checklist

  • The title is a conventional commit and reads as the subject it becomes on squash. nx release takes the version bump from its type
  • bunx prettier --check . and bunx nx affected -t lint test typecheck build api-check pass, which is what CI runs
  • @jaw.id/core's public API is unchanged, or bunx nx api-update @jaw.id/core ran and the report diff is part of this PR
  • Docs updated, if this changes behaviour somebody integrates against

@vercel

vercel Bot commented Sep 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
demo Ready Ready Preview Sep 22, 2026 9:25pm UTC
jaw-docs Ready Ready Preview Sep 22, 2026 9:25pm UTC
keys-jaw-id Ready Ready Preview Sep 22, 2026 9:25pm UTC
playground Ready Ready Preview Sep 22, 2026 9:25pm UTC

Request Review

@LeoFranklin015

Copy link
Copy Markdown
Contributor

Thanks @mariano-aguero for taking care of this. The overall changes made lgtm, the shape is right and the test coverage on the keyless paths reads well.

Two paths need covering before this is safe to turn on, both about where x-dapp-origin actually gets attached. Since the header is the only thing that identifies a caller from the keys origin, any request that goes out without it is refused for a keyless integrator, and today the send and the fee estimate are two of those. Comments inline on both.

The rest of what I found is minor or pre-existing, left inline as non-blocking so nothing gets lost. Happy to approve once the two are in

Comment thread packages/core/src/store/chain-clients/utils.ts Outdated
Comment thread packages/core/src/rpc/permissions.ts
Comment thread packages/core/src/rpc/permissions.ts
Comment thread packages/core/src/api/routes/permissions.ts Outdated
Comment thread packages/core/src/account/erc20Paymaster.ts
Comment thread packages/ui/src/hooks/useChainIconURI.tsx
Comment thread apps/keys-jaw-id/src/hooks/useSessionAccount/index.ts Outdated
@Ghadi8

Ghadi8 commented Sep 18, 2026

Copy link
Copy Markdown
Member

Note — ENS reverse resolution has no keyless story yet (open question, not a blocker on this PR)

Filing this as a note rather than an inline comment because the code involved (packages/ui/src/utils/reverseResolve.ts:52) isn't touched by this PR, and because I don't think it has a mechanical fix.

What it is. reverseResolveWithAvatars calls:

GET https://api.justaname.id/ens/v2/reverse?address=<addr>@eip155:<chain>&rpcUrl=<JAW rpc url>

The api key was never a header here — it rode inside the rpcUrl query parameter, and JustaName's server used that url to do the resolution. Controlled A/B against production, with only the key inside rpcUrl differing:

keyed    200  {"name":"luc.eth","coinType":60,"viaUniversalResolver":true}
keyless  400  {"error":"Web3ProviderException: the RPC provider request failed"}

Why registering an origin won't fix it. The refused request is made by JustaName's backend, not by the browser. On that hop there is no Origin header and no request for the SDK to attach x-dapp-origin to. An origin-based credential can't traverse a server-to-server call the way a key in a query string can. This is the one place where the keyless model and the existing architecture actually conflict, rather than just missing a code path.

Blast radius if left as-is. reverseResolveWithAvatars is called from TransactionDialog:110, ClearSignedView:112, DecodedCalldata:152, OnboardingDialog:431 and PermissionDialog. So for a keyless dApp: no ENS names and no avatars on every transaction, permission and onboarding screen — raw hex addresses instead. It also degrades identically from a dApp's own page, not just from keys, since the broken hop is server-side either way.

Options, roughly in order of how much I like them:

  1. Resolve client-side. Drop the server hop and do reverse resolution through the universal resolver over the chain client we already have (which does carry x-dapp-origin via jawHttp). Most work, but it puts the request back on a hop where the origin exists, and removes a dependency.
  2. Let /ens/v2/reverse accept a forwarded origin. Have keys/the SDK send x-dapp-origin (or an equivalent param) and have the reverse service attach the caller's identity to its own outbound RPC call. Keeps the hop, needs backend work on two services.
  3. Give that hop its own server-side credential. The reverse service calls the proxy with a service key of its own rather than the caller's url. Simplest, but the resolution is then unattributed — probably fine for reverse lookups, worth confirming it doesn't matter for billing.
  4. Accept the degradation. Keyless dApps show raw addresses. Cheap, but it's a visible difference in the signing UI, which is where address legibility matters most.

Happy to prototype (1) if that's the direction — it's contained to reverseResolve.ts plus a viem call.

One unrelated flag while I was testing: my probe of the analytics guard used a deliberately malformed address expecting a validation error, but the keyed call was accepted (201, error:null). So there is probably one junk row in wallet analytics with address: "not-an-address" from today that you may want to drop.

@mariano-aguero

mariano-aguero commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

@Ghadi8, on your note about ENS reverse resolution:

ENS reverse resolution has no keyless story yet (open question, not a blocker on this PR)

You are right on all of it, and this one I had to check before answering because it is the only finding today that an origin cannot fix.

Confirmed: fetchReverseBatch puts the rpc url in a query parameter and the reverse service resolves from its own backend, so the hop that fails has no Origin to carry and nothing for the SDK to attach a header to. Every other keyless path in this PR works because the call that needs attributing is one the browser makes. This one is not.

Filed as #345 with your measurements and the four options. Option 4 is what ships here, deliberately: the rest of the keyless path is closed and this needs a decision about which hop carries the credential rather than a change in this PR. I would take option 1 as the destination, though I would not size it as contained to reverseResolve.ts plus a viem call: reverse resolution, the avatars and the batching all get rebuilt on the universal resolver.

On the junk analytics row: worth knowing that nothing validates that address on our side either. logSignature sends whatever it is handed, so the 201 is not only the service being permissive. The row is yours to drop, and if you want, core can refuse an address that does not parse before the request leaves, which is a few lines and keeps the bad value out of the pipeline rather than out of one table.

Thanks for testing it against production rather than reading it off the diff. The six inline ones are answered and resolved in their threads.

Comment thread packages/core/src/account/Account.ts
Comment thread apps/keys-jaw-id/src/app/page.tsx
Comment thread packages/core/src/utils/jawHttp.ts

This branch was successfully deployed

4 active deployments
Preview – demo 2290837e Deployed Sep 22, 2026 by vercel[bot]
Preview – jaw-docs 2290837e Deployed Sep 22, 2026 by vercel[bot]
Preview – playground 2290837e Deployed Sep 22, 2026 by vercel[bot]
Preview – keys-jaw-id 2290837e Deployed Sep 22, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants