One Reown project across login, profile, NFT and Swap
One project id and one AppKit instance serve eight mount points across three addons — what that shares, which network list each surface offers, and why every change needs a rebuild.
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID is not a Wallet Connect setting. It is
the project id for every wallet interaction the platform performs — wallet
sign-in, profile linking, four NFT flows, the Swap terminal and the admin pool
console — because createAppKit() is called once, at module scope, in a single
file that all of them import.
That has consequences an operator meets in three separate support tickets before realising they are the same fact. This page is that fact, written down.
One AppKit, eight leaf mounts, no root provider
frontend/context/wallet.tsx calls createAppKit() at module scope. Module
scope means once per browser tab, idempotent afterwards, and unreachable from
anything fetched at runtime — there is no point at which a value from your
database could reach it.
There is no root WagmiProvider. Instead LazyWalletProvider is mounted at
eight leaf sites:
| # | Surface | File |
|---|---|---|
| 1 | The wallet login form in the auth modal | components/auth/auth-modal.tsx |
| 2 | Profile → Wallet tab (/user/profile?tab=wallet) |
user/profile/components/tabs/wallet-tab.tsx |
| 3 | /nft/create |
(ext)/nft/create/page.tsx |
| 4 | /nft/creator |
(ext)/nft/creator/page.tsx |
| 5 | /nft/token/[id] |
(ext)/nft/token/[id]/page.tsx |
| 6 | /nft/token/[id]/list |
(ext)/nft/token/[id]/list/page.tsx |
| 7 | The Swap terminal (/dex/swap only, not the /dex landing page) |
(ext)/dex/layout.tsx |
| 8 | The admin pool detail console (/admin/dex/pool/[id]) |
(ext)/admin/dex/pool/[id]/layout.tsx |
LazyWalletProvider is next/dynamic(…, { ssr: false, loading: () => null }).
It renders nothing on the server and nothing until the web3 chunk arrives —
children included, because the component that would render {children} is the
one being deferred. That is why it wraps single screens rather than whole
segments: wrapping (ext)/dex would blank the public /dex landing page in the
server response, and wrapping admin/dex would blank the chains, tokens, pairs,
fees and settings consoles and pull roughly 600 KB of web3 into five screens that
never ask for a signature.
A second WagmiProvider builds a second wagmi context. A connection made in one
is invisible to the other, and the symptom is a page whose header shows a
connected address while its action button reads not connected. The mount sites
above are the complete list; adding a ninth inside any of them breaks that
screen.
The Swap terminal does nest DexTonConnectProvider outside LazyWalletProvider,
and that is not the same thing — TON Connect is a different protocol holding a
TON session and touching no wagmi state.
Wallet Connect being disabled does not switch this off
Only mount 1 is gated on the extension: the auth modal renders the wallet branch
when /api/settings lists wallet_connect among the enabled extensions. Mounts
2 through 8 are unconditional.
frontend/config/wallet.tsx throws at module load when the project id is
unset:
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID is not defined. Set it in .env and REBUILD the frontend — this value is inlined at build time, a restart is not enough.
There is no fallback id, deliberately: a fallback would make a misconfigured deploy run silently on somebody else's Reown project. So on an install running NFT Marketplace or Swap and not Wallet Connect, the variable is still mandatory, and leaving it out does not degrade those pages — it stops them rendering.
The network list, and where it narrows
config/wallet.tsx declares thirteen AppKit networks, and this is the list
createAppKit() is built with:
Ethereum · Polygon · Arbitrum · Optimism · Base · BNB Smart Chain · Avalanche · Linea · Celo · Cronos · Fantom · Rootstock · Solana
defaultNetwork is Ethereum mainnet. Twelve of the thirteen are EVM and route
through the wagmi adapter; Solana routes through a second SolanaAdapter
registered in the same call, because AppKit maps each network to whichever
adapter claims its CAIP namespace — eip155:* to wagmi, solana:* to the Solana
adapter. A network listed with no adapter is one the modal offers and cannot
connect to.
That list is the same on all eight mounts. The Reown modal opened from the login form offers the same thirteen networks as the one opened from the Swap terminal. What differs is what each surface does afterwards.
| Surface | Which networks matter |
|---|---|
| Wallet sign-in and profile linking | Whatever the wallet is on. The chain id is only used to route signature verification, and the backend's own SIWE allow-list is a separate, shorter list |
| NFT flows | The chain the contract is on |
| Swap terminal | Only chains in switchableChains — see below |
The Swap terminal's chain switcher is an intersection
The terminal's chain picker does not show the thirteen. It shows the intersection of two sets:
-
What the server says is usable.
GET /api/dex/chainreturns the chains whosedexChainrow is enabled and that pass thedexEnabledChainsnarrowing list, each carrying arouterEnabledflag which is simply "this chain has at least one allowlisted router". -
What this build's wagmi config can switch to — the ids of the thirteen networks above, exposed to the terminal as
wallet.supportedChainIds.
A chain in one set and not the other is filtered out silently. That is deliberate: a chain the wallet cannot switch to dead-ends the user at the wallet prompt, and a chain with no router dead-ends them at the quote. When the intersection is empty the picker reads No network is configured for swapping yet.
So the operator-visible rule is:
- Disabling a chain on
/admin/dex/chainremoves it from the Swap terminal. It does not remove it from the Reown modal — the modal is built from the static list and never consults your server. - A user can therefore connect on, or switch their wallet to, a network you have disabled for swapping. Nothing breaks; the terminal simply will not quote on it.
There is a helper, networksFor(enabledChainIds), that filters the static list by
the server's answer and would make the modal itself match. It has no callers
— chain-registry.test.ts says so in as many words. Do not describe the modal to
a customer as reflecting your enabled chains, because it does not.
Three copies of one list, and the test that keeps them equal
The same chain list is written out in three files that cannot import one another:
| Copy | File | Why it is separate |
|---|---|---|
DEX_CHAIN_STATIC |
backend/src/api/(ext)/dex/utils/chains.ts |
The backend cannot import an AppKit network object |
networks / ALL_DEX_NETWORKS |
frontend/config/wallet.tsx |
The frontend cannot import the backend registry |
DEX_WALLET_CHAIN_IDS |
frontend/config/dex-chain-ids.ts |
Importing config/wallet.tsx constructs the wagmi adapter and throws when the project id is unset — the admin readiness console needs the ids and none of that |
e2e/unit/backend/dex/chain-registry.test.ts parses all three files and fails
the build if they disagree, in every direction. It is the only thing that can:
the backend's boot-time assertChainIdConsistency() compares the DEX registry
against the Ecosystem's chain configs, which is a different question and skips
silently when Ecosystem is absent.
Two chains are registered exceptions, because AppKit does not broker them at all:
TRON (connected through the injected TronLink provider) and TON
(connected through TON Connect). Your Reown project covers neither. That is why
DEX_WALLET_CHAIN_IDS has fifteen entries and config/wallet.tsx has thirteen.
If you ever add a chain, it is a three-file commit plus a frontend rebuild and a backend restart. Adding it to one file alone produces exactly one of two silent failures: a chain the admin console reports ready and no user is ever offered, or a chain the switcher offers and no quote route knows.
Everything here is a build-time value
Every variable on this page begins with NEXT_PUBLIC_, which means Next.js
inlines the value into the JavaScript bundle at build time. Editing .env
and restarting changes nothing — the old value is already compiled in.
The approval-sheet icon is /img/logo/logo.png on your own origin. Set the name
and description before you invite anyone to sign: a request to sign a message
from "Bicrypto" on a site branded something else is exactly what users are taught
to reject.
pnpm build:frontend
pnpm restartThe project id is the one value that needs both halves — see the backend uses it too.
The embedded wallet toggle
With NEXT_PUBLIC_DEX_EMBEDDED_WALLET=true, AppKit is configured with
features.email: true and four socials — Google, X, Apple and Discord — so the
modal offers an embedded wallet whose key lives in an iframe at
secure.walletconnect.org. External wallets are shown alongside it rather than
behind it (emailShowWallets: true), because a self-custody product whose first
screen is an email field reads as a custodial one.
Three things to know before you turn it on:
It is a request, not a guarantee. dashboard.reown.com can override email,
socials, swaps, onramp and activity; when the cloud answers, the values
compiled into your build are discarded and AppKit logs a warning saying so. The
durable switch for all five is the Reown project dashboard, not your .env.
Absent is not off. These keys used to be missing from the config, and AppKit
resolves a missing value from its own defaults where email and all seven
socials are on. The variable exists so that the build states an intent
either way.
Nothing in the platform branches on the result. If the connector does not
appear — because this is off, because the dashboard says so, or because Reown is
down — every code path behaves identically and the terminal simply asks for an
external wallet. A unit test (embedded-wallet-optional.test.ts) asserts that no
file under (ext)/dex gates on a connector's existence.
The user is told which kind of wallet they have, and that is the only thing that reads it. An embedded wallet is still self-custody and this platform still cannot recover it — which a user who signed in with Google has every reason to assume otherwise.
AppKit analytics are on
features.analytics is true. Connection events are reported to Reown and show
up on your project dashboard. onramp and swaps are both explicitly false,
so AppKit's own buy and swap panels are not offered — the platform's Swap
terminal is not the AppKit one.
Auto-connect is on, deliberately
wagmi is configured with persistent storage — window.localStorage under the key
wagmi. A returning visitor is reconnected without pressing anything.
This was the opposite for a long time. The old storage adapter dropped every key
containing recentConnector or store, so wagmi had nothing to rehydrate and
every mount started disconnected. That is unusable for the Swap terminal: signing
on mobile deep-links out to the wallet app and returns to a cold page load,
and with the filter in place the user came back to a Connect button while their
transaction was already in the mempool, with no way for the page to know it
existed.
With reconnect off, a page could not see a connection appear without a user gesture — so the profile Wallet tab could safely build and sign a SIWE message the moment one did, from an effect.
With reconnect on, that effect fires on page open and pops a signature prompt the user never asked for. It is now an explicit Link this wallet to my account button. Signature prompts must come from a click; if anyone re-introduces an auto-signing effect anywhere, persistence has to be turned back off in the same commit.
What it means in practice:
- A user who deep-links out to their wallet and returns to a cold page comes back connected, and the terminal can find the transaction it started.
- A connection made on one mount survives navigation to another, even though each mount is a separate provider instance, because both rehydrate from the same key.
- On a shared computer the connection persists across visits. It is a connection, not a session — no Bicrypto cookie is involved and no wallet can be spent from — but the address is visible until the user disconnects or clears site data. That is worth a line in your own house rules.
- Every mount passes
cookies="", so wagmi's SSR cookie-hydration path contributes nothing. Persistence is entirely client-side.
The backend interpolates the same id
Server-side signature verification builds a viem public client pointed at:
https://rpc.walletconnect.org/v1/?chainId=<eip155:id>&projectId=<your project id>read from the same NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID, at module load, in
the backend process.
If the project is deleted, its domain allow-list stops matching, or it exceeds
its quota, that call fails. verifySignature() returns false, and every wallet
sign-in and every profile wallet link returns 401 "Signature verification
failed" — including for users whose signature is perfectly valid.
The wallet prompt still succeeds, so it presents as a signature problem rather
than as an account problem. The only tell is a Signature verification error
line in the backend log under the AUTH module.
The same failure mode arrives from a firewall: the backend needs outbound
HTTPS to rpc.walletconnect.org, not just the browser.
Because the two halves read the same name in different ways, the middle states are the confusing ones:
| You did | Result |
|---|---|
Edited .env, restarted only |
Frontend still on the old id, backend on the new one |
Edited .env, rebuilt only |
Frontend on the new id, backend still verifying against the old one |
| Rebuilt and restarted | Correct |
When you change the project id
-
Create or update the project at
cloud.reown.comand add your production origin to its allowed-domains list. The id ships in public JavaScript, so domain restriction is the only thing stopping it being spent elsewhere against your quota. -
Set
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_IDin.env. It is not in.env.example; add it by hand. -
Rebuild the frontend and restart the backend —
pnpm build:frontend && pnpm restart, orpnpm updatorfor the full path. -
Check the modal. Open the Reown modal from any of the eight surfaces. The header should show your
NEXT_PUBLIC_SITE_NAME, and wallets should list. -
Check the server half, which the modal cannot tell you about. Link or sign in with a wallet end to end. A 401 Signature verification failed on a prompt the wallet accepted means the backend is on a different or a dead project id.
-
Check the Swap terminal separately if you run it, since a terminal that renders proves the id is present in the bundle but proves nothing about the backend.
Related
- Installing Wallet Connect — the variable, the licence and the rebuild, in order.
- Managing linked wallets — what a link is once the signature has verified.
- Wallets, chains and endpoints — the backend's separate, shorter SIWE chain allow-list.
- The Chains console — where a Swap chain is enabled or disabled.
- Environment variables Swap actually reads — the server-side RPC and aggregator variables, which are a different set entirely.