Skip to content

Commit 66a6dcf

Browse files
fix(developers): address swap widget QA findings (#108)
* fix(csp): allow the swap widget's RPC, mempool and Solana endpoints on /developers The widget polls EVM transaction status through viem clients pointed at ShapeShift's per-chain RPC proxies (api.<chain>.shapeshift.com) and at viem's default RPCs for Monad, MegaETH, HyperEVM, Plasma and Katana. It reads Bitcoin balances and tx status from mempool.space, and falls back to api.mainnet-beta.solana.com for Solana when AppKit has no connection. None of those origins were in connect-src, so every status poll failed and was retried forever (the widget maps RPC errors to 'pending'), leaving a swap that had already confirmed on-chain stuck on 'Confirming Transaction'. BTC and SOL balances failed the same way and rendered as nothing. Allow *.shapeshift.com so new chain proxies don't need a CSP change each time, and list the remaining third-party RPCs explicitly. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(csp): restore the Onramper iframe on /trade The Buy Crypto card on /trade embeds buy.onramper.com. Its frame-src allowance lived in a route-level Content-Security-Policy header in next.config.ts, which only ever took effect because the middleware's own CSP header was being dropped on the /trade -> /en/trade rewrite. #104 fixed that header loss, so the middleware CSP (chatwoot-only frame-src) now reaches /trade and replaces the next.config one, and the iframe is blocked with a broken-page glyph on develop. Move the Onramper origins into the middleware CSP, scoped to /trade with or without a locale prefix, and drop the now-dead CSP entries from next.config.ts (the COOP header there still does its job). Co-authored-by: Cursor <cursoragent@cursor.com> * feat(developers): initialise AppKit host-side with chain icons and ShapeShift RPCs Let the page own the Reown AppKit instance instead of the swap widget's self-init. The widget documents this mode (it detects the singleton and reuses its wagmi config), and it gives us two knobs the widget doesn't expose: - chainImages: AppKit has no preset icons for MegaETH, HyperEVM, Plasma or Katana, so its network picker showed a generic globe for them. Feed it the same icon set the widget draws in its own chain sidebar so both surfaces match. - customRpcUrls: route wallet balance reads and the Solana connection through ShapeShift's RPC proxies (api.<chain>.shapeshift.com), the same endpoints the widget already uses for status polling, with Reown's Blockchain API kept as the fallback. The network list, adapters and features mirror the widget's own config/appkit.ts so wallet support is unchanged. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(developers): spell out the Reown allowed-origins requirement The blank WalletConnect QR on staging is the relay rejecting the origin: the Reown project's allowlist only contains the *-widget.shapeshift.com demo hosts, and the same project ID is baked into the website bundle. Nothing in this repo can fix that; document exactly what to add and how to check the current list, and note the new host-owned AppKit init. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(developers): note the website origins are now on the Reown allowlist Co-authored-by: Cursor <cursoragent@cursor.com> * fix(developers): let the widget own AppKit and unblock WalletConnect signing Host-side AppKit plus a pinned WC CSP dropped echo/secure-mobile hosts, so session requests never reached the wallet. Match the stock embed path and allow WalletConnect/Reown wildcards on /developers. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b2e08ff commit 66a6dcf

4 files changed

Lines changed: 48 additions & 32 deletions

File tree

‎app/[lang]/developers/README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ This directory contains the `/developers` landing page: the entry point for dApp
2424
## Technical Implementation
2525

2626
- Most copy lives in `app/[lang]/_utils/dictionary/developers.ts` under `DEVELOPERS_DICT.page`, **except** WhyShapeShift, WidgetSection's feature ring, ApiSection's code panels, EconomicsSection's milestones, and LaunchPath's Q&A, which hardcode their copy directly in JSX (illustration- or interaction-heavy sections where copy, visuals, and behavior are tightly coupled).
27-
- `DevelopersHero.tsx` embeds the real `@shapeshiftoss/swap-widget` React SDK (dynamically imported, `ssr: false`) only when `NEXT_PUBLIC_ENABLE_DEVELOPERS_SWAP_WIDGET=true`. The embed failed QA, so this flag stays off for the current release; set it to `true` and rebuild after the follow-up fix. When enabled, it needs `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`. Set this public Reown project ID before building (Next.js embeds it in the client bundle); the existing swap-widget service uses the value documented in `.env.local.sample`. Configure it in each Railway environment before promoting this page. Verify the website origin is allowed in Reown and test wallet connection before release. Set `NEXT_PUBLIC_SHAPESHIFT_PARTNER_CODE` to ShapeShift's registered affiliate code before release so website swaps are attributed to its payout account; verify the code via `/v1/partner/{code}`.
27+
- `DevelopersHero.tsx` embeds the real `@shapeshiftoss/swap-widget` React SDK (dynamically imported, `ssr: false`) only when `NEXT_PUBLIC_ENABLE_DEVELOPERS_SWAP_WIDGET=true`. The embed failed QA, so this flag stays off for the current release; set it to `true` and rebuild after the follow-up fix. When enabled, it needs `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`. Set this public Reown project ID before building (Next.js embeds it in the client bundle); the existing swap-widget service uses the value documented in `.env.local.sample`. Configure it in each Railway environment before promoting this page. Set `NEXT_PUBLIC_SHAPESHIFT_PARTNER_CODE` to ShapeShift's registered affiliate code before release so website swaps are attributed to its payout account; verify the code via `/v1/partner/{code}`.
28+
- **Reown allowed origins (required for WalletConnect).** The Reown project behind `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` has a domain allowlist, enforced server-side by the WalletConnect relay. An origin that isn't listed gets its relay socket closed with `3000 Unauthorized: origin not allowed`, so the WalletConnect QR renders blank and mobile wallet pairing never starts. Injected wallets (Rabby, MetaMask, Phantom) are unaffected, which is why everything else on the page looks fine. Before enabling the embed in any environment, add that environment's exact origin (e.g. `https://shapeshift.com`, `https://website-frontend-develop.up.railway.app`, any PR preview URL) under the project's allowed domains at <https://dashboard.reown.com>. Check the current list with `curl "https://api.web3modal.org/projects/v1/origins?projectId=<id>&st=appkit&sv=html-wagmi-1" -H "x-project-id: <id>" -H "x-sdk-type: appkit" -H "x-sdk-version: html-wagmi-1"`. `https://shapeshift.com` and `https://website-frontend-develop.up.railway.app` were added in September 2026; any new environment (e.g. PR previews) needs its own entry.
29+
- `DevelopersSwapWidget.tsx` is the documented default: it passes `walletConnectProjectId` and lets the widget initialise AppKit (same `createAppKit` options as widget.shapeshift.com's `#external` demo). The official widget site has no CSP; this site does, so `/developers` must allow WalletConnect/Reown connect and frame hosts or session requests never reach a mobile wallet. `localhost` is not on the Reown origin allowlist — test on an allowed origin or add the local origin in the Reown dashboard.
2830
- Client components (interactive state, refs, or the widget's own client-only requirements): `DevelopersHero`, `DevelopersWidgetSection`, `DevelopersApiSection`, `DevelopersFaq`, `DevelopersPartnerLogos`. Everything else is a server component.
2931
- Reuses existing shared components (`Button`, `LocalizedLink`) and Tailwind color tokens from `tailwind.config.ts`.
3032

‎app/[lang]/developers/_components/DevelopersSwapWidget.tsx‎

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,12 @@
11
'use client'
22

3+
import { SwapWidget } from '@shapeshiftoss/swap-widget'
34
import '@shapeshiftoss/swap-widget/style.css'
4-
import dynamic from 'next/dynamic'
55

66
import type { ReactNode } from 'react'
77

8-
// Loaded client-side only, per the SDK's own docs: the widget initializes Reown AppKit at load,
9-
// which reads browser-only state and has no meaningful server-rendered output.
10-
const SwapWidget = dynamic(async () => (await import('@shapeshiftoss/swap-widget')).SwapWidget, {
11-
ssr: false,
12-
loading: () => <div className={'h-[660px] w-[420px] max-w-full rounded-[20px] bg-[#0A0A14]'} />,
13-
})
14-
8+
// Loaded only via DevelopersHero's `dynamic(..., { ssr: false })`. AppKit is browser-only and
9+
// the widget has no meaningful server-rendered output.
1510
export function DevelopersSwapWidget(): ReactNode {
1611
return (
1712
<>

‎middleware.ts‎

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,30 @@ function hasLocaleInPath(pathname: string): boolean {
3636
}
3737

3838
/**
39-
* Check if pathname is the developers page (with or without a locale prefix)
39+
* Strip a leading locale segment (e.g. /en/trade -> /trade) so route checks work with or without one
4040
*/
41-
function isDevelopersPath(pathname: string): boolean {
42-
const withoutLocale = SUPPORTED_LANGUAGES.reduce(
41+
function stripLocale(pathname: string): string {
42+
return SUPPORTED_LANGUAGES.reduce(
4343
(path, lang) => (path.startsWith(`/${lang.code}/`) ? path.slice(lang.code.length + 1) : path),
4444
pathname
4545
)
46+
}
47+
48+
/**
49+
* Check if pathname is the developers page (with or without a locale prefix)
50+
*/
51+
function isDevelopersPath(pathname: string): boolean {
52+
const withoutLocale = stripLocale(pathname)
4653
return withoutLocale === '/developers' || withoutLocale.startsWith('/developers/')
4754
}
4855

56+
/**
57+
* Check if pathname is the trade page (with or without a locale prefix)
58+
*/
59+
function isTradePath(pathname: string): boolean {
60+
return stripLocale(pathname) === '/trade'
61+
}
62+
4963
/**
5064
* Create headers with locale information
5165
*/
@@ -185,14 +199,30 @@ export function middleware(request: NextRequest): NextResponse {
185199
? "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://api.hypelab.com https://app.chatwoot.com https://widget.chatwoot.com https://cdn.weglot.com"
186200
: `script-src 'self' 'nonce-${nonce}' https://api.hypelab.com https://app.chatwoot.com https://widget.chatwoot.com https://cdn.weglot.com`
187201
// The developers embed needs market data plus AppKit's API, RPC, telemetry and relay.
188-
// These exact origins come from the installed SDKs; keep them scoped to this page.
202+
// Keep these scoped to this page. WalletConnect/Reown use wildcards (see below).
203+
//
204+
// - *.shapeshift.com: the swap API, app redirects, and the per-chain RPC proxies
205+
// (api.<chain>.shapeshift.com) the widget's viem clients use to poll EVM tx status and read
206+
// balances. New chains land as new subdomains, so allow the wildcard rather than chasing each.
207+
// - rpc.monad.xyz / mainnet.megaeth.com / rpc.hyperliquid.xyz / rpc.plasma.to /
208+
// rpc.katana.network: viem's default RPCs for the EVM chains ShapeShift has no proxy for.
209+
// - mempool.space: Bitcoin balance + tx status.
210+
// - api.mainnet-beta.solana.com: the widget's Solana fallback when AppKit has no connection.
211+
// Without these, status polling silently retries forever and BTC/SOL balances render as empty.
189212
const developersFontSrc = isDevelopersPath(pathname) ? ' https://fonts.reown.com' : ''
213+
// WalletConnect / Reown hosts are wildcards on purpose. widget.shapeshift.com ships with no CSP;
214+
// pinning exact hosts here dropped echo.walletconnect.com and secure-mobile.walletconnect.* —
215+
// the paths AppKit uses to deliver session requests (including eth_chainId / eth_sendTransaction)
216+
// to a mobile wallet. New WC subdomains should not require a CSP chase.
190217
const developersConnectSrc = isDevelopersPath(pathname)
191-
? ' https://api.shapeshift.com https://app.shapeshift.com https://api.coingecko.com https://api.proxy.shapeshift.com https://api.web3modal.org https://rpc.walletconnect.org https://pulse.walletconnect.org wss://relay.walletconnect.org https://verify.walletconnect.org https://verify.walletconnect.com'
218+
? ' https://*.shapeshift.com https://api.coingecko.com https://rpc.monad.xyz https://mainnet.megaeth.com https://rpc.hyperliquid.xyz https://rpc.plasma.to https://rpc.katana.network https://mempool.space https://api.mainnet-beta.solana.com https://api.web3modal.org https://*.walletconnect.org https://*.walletconnect.com wss://*.walletconnect.org wss://*.walletconnect.com https://*.reown.com'
192219
: ''
193220
const developersFrameSrc = isDevelopersPath(pathname)
194-
? ' https://secure.walletconnect.org https://verify.walletconnect.org https://verify.walletconnect.com'
221+
? ' https://*.walletconnect.org https://*.walletconnect.com https://verify.walletconnect.org https://verify.walletconnect.com'
195222
: ''
223+
// The Buy Crypto card on /trade embeds Onramper. This header replaces (not merges with) the
224+
// route-level CSP from next.config.ts, so the iframe origins have to be allowed here.
225+
const tradeFrameSrc = isTradePath(pathname) ? ' https://buy.onramper.com https://widget.onramper.com' : ''
196226
// Coinbase Wallet SDK / Base Account SDK (pulled in transitively by the swap widget's wagmi
197227
// connectors) inject their own inline bootstrap <script> tags, which our own nonce doesn't cover.
198228
// 'strict-dynamic' lets scripts loaded by an already-nonce-trusted script (the widget bundle
@@ -201,7 +231,7 @@ export function middleware(request: NextRequest): NextResponse {
201231
// 'strict-dynamic' with no nonce/hash present disables ALL host-based allowlisting and
202232
// 'unsafe-inline', blocking every script on the page, not just the ones it's meant to loosen.
203233
const developersScriptSrc = isDevelopersPath(pathname) && !isDevelopment ? " 'strict-dynamic'" : ''
204-
const cspHeader = `default-src 'self'; ${scriptPolicy}${developersScriptSrc}; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.weglot.com; font-src 'self' https://fonts.gstatic.com${developersFontSrc}; img-src 'self' data: https: blob:; media-src 'self' https:; connect-src 'self' https://api.hypelab.com https://app.chatwoot.com https://widget.chatwoot.com ${strapiHostname} https://cdn.weglot.com https://api.weglot.com https://cdn-api-weglot.com wss://app.chatwoot.com https://api.thorchain.shapeshift.com${developersConnectSrc}; frame-src 'self' https://widget.chatwoot.com https://app.chatwoot.com${developersFrameSrc}; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self' https://app.chatwoot.com; frame-ancestors 'self'; upgrade-insecure-requests;`
234+
const cspHeader = `default-src 'self'; ${scriptPolicy}${developersScriptSrc}; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.weglot.com; font-src 'self' https://fonts.gstatic.com${developersFontSrc}; img-src 'self' data: https: blob:; media-src 'self' https:; connect-src 'self' https://api.hypelab.com https://app.chatwoot.com https://widget.chatwoot.com ${strapiHostname} https://cdn.weglot.com https://api.weglot.com https://cdn-api-weglot.com wss://app.chatwoot.com https://api.thorchain.shapeshift.com${developersConnectSrc}; frame-src 'self' https://widget.chatwoot.com https://app.chatwoot.com${developersFrameSrc}${tradeFrameSrc}; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self' https://app.chatwoot.com; frame-ancestors 'self'; upgrade-insecure-requests;`
205235
response.headers.set('Content-Security-Policy', cspHeader)
206236

207237
// Handle locale routing

‎next.config.ts‎

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -47,27 +47,16 @@ const nextConfig = {
4747
source: '/(.*)',
4848
headers: [{key: 'cross-origin-resource-policy', value: 'cross-origin'}]
4949
},
50+
// The Onramper iframe on /trade needs popups for its checkout flow. Its frame-src
51+
// allowance lives in middleware.ts, which owns the Content-Security-Policy header.
5052
{
51-
// Allow Onramper iframe on /trade
5253
source: '/trade',
53-
headers: [
54-
{key: 'cross-origin-opener-policy', value: 'same-origin-allow-popups'},
55-
{
56-
key: 'Content-Security-Policy',
57-
value: "frame-src 'self' https://buy.onramper.com https://widget.onramper.com; child-src 'self' https://buy.onramper.com https://widget.onramper.com;"
58-
}
59-
]
54+
headers: [{key: 'cross-origin-opener-policy', value: 'same-origin-allow-popups'}]
6055
},
6156
{
62-
// Also allow Onramper on localized routes like /en/trade
57+
// Localized routes like /en/trade
6358
source: '/:lang/trade',
64-
headers: [
65-
{key: 'cross-origin-opener-policy', value: 'same-origin-allow-popups'},
66-
{
67-
key: 'Content-Security-Policy',
68-
value: "frame-src 'self' https://buy.onramper.com https://widget.onramper.com; child-src 'self' https://buy.onramper.com https://widget.onramper.com;"
69-
}
70-
]
59+
headers: [{key: 'cross-origin-opener-policy', value: 'same-origin-allow-popups'}]
7160
}
7261
]
7362
},

0 commit comments

Comments
 (0)