Skip to content
4 changes: 3 additions & 1 deletion app/[lang]/developers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ This directory contains the `/developers` landing page: the entry point for dApp
## Technical Implementation

- 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).
- `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}`.
- `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}`.
- **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.
- `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.
- Client components (interactive state, refs, or the widget's own client-only requirements): `DevelopersHero`, `DevelopersWidgetSection`, `DevelopersApiSection`, `DevelopersFaq`, `DevelopersPartnerLogos`. Everything else is a server component.
- Reuses existing shared components (`Button`, `LocalizedLink`) and Tailwind color tokens from `tailwind.config.ts`.

Expand Down
11 changes: 3 additions & 8 deletions app/[lang]/developers/_components/DevelopersSwapWidget.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
'use client'

import { SwapWidget } from '@shapeshiftoss/swap-widget'
import '@shapeshiftoss/swap-widget/style.css'
import dynamic from 'next/dynamic'

import type { ReactNode } from 'react'

// Loaded client-side only, per the SDK's own docs: the widget initializes Reown AppKit at load,
// which reads browser-only state and has no meaningful server-rendered output.
const SwapWidget = dynamic(async () => (await import('@shapeshiftoss/swap-widget')).SwapWidget, {
ssr: false,
loading: () => <div className={'h-[660px] w-[420px] max-w-full rounded-[20px] bg-[#0A0A14]'} />,
})

// Loaded only via DevelopersHero's `dynamic(..., { ssr: false })`. AppKit is browser-only and
// the widget has no meaningful server-rendered output.
export function DevelopersSwapWidget(): ReactNode {
return (
<>
Expand Down
44 changes: 37 additions & 7 deletions middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,30 @@ function hasLocaleInPath(pathname: string): boolean {
}

/**
* Check if pathname is the developers page (with or without a locale prefix)
* Strip a leading locale segment (e.g. /en/trade -> /trade) so route checks work with or without one
*/
function isDevelopersPath(pathname: string): boolean {
const withoutLocale = SUPPORTED_LANGUAGES.reduce(
function stripLocale(pathname: string): string {
return SUPPORTED_LANGUAGES.reduce(
(path, lang) => (path.startsWith(`/${lang.code}/`) ? path.slice(lang.code.length + 1) : path),
pathname
)
}

/**
* Check if pathname is the developers page (with or without a locale prefix)
*/
function isDevelopersPath(pathname: string): boolean {
const withoutLocale = stripLocale(pathname)
return withoutLocale === '/developers' || withoutLocale.startsWith('/developers/')
}

/**
* Check if pathname is the trade page (with or without a locale prefix)
*/
function isTradePath(pathname: string): boolean {
return stripLocale(pathname) === '/trade'
}

/**
* Create headers with locale information
*/
Expand Down Expand Up @@ -185,14 +199,30 @@ export function middleware(request: NextRequest): NextResponse {
? "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://api.hypelab.com https://app.chatwoot.com https://widget.chatwoot.com https://cdn.weglot.com"
: `script-src 'self' 'nonce-${nonce}' https://api.hypelab.com https://app.chatwoot.com https://widget.chatwoot.com https://cdn.weglot.com`
// The developers embed needs market data plus AppKit's API, RPC, telemetry and relay.
// These exact origins come from the installed SDKs; keep them scoped to this page.
// Keep these scoped to this page. WalletConnect/Reown use wildcards (see below).
//
// - *.shapeshift.com: the swap API, app redirects, and the per-chain RPC proxies
// (api.<chain>.shapeshift.com) the widget's viem clients use to poll EVM tx status and read
// balances. New chains land as new subdomains, so allow the wildcard rather than chasing each.
// - rpc.monad.xyz / mainnet.megaeth.com / rpc.hyperliquid.xyz / rpc.plasma.to /
// rpc.katana.network: viem's default RPCs for the EVM chains ShapeShift has no proxy for.
// - mempool.space: Bitcoin balance + tx status.
// - api.mainnet-beta.solana.com: the widget's Solana fallback when AppKit has no connection.
// Without these, status polling silently retries forever and BTC/SOL balances render as empty.
const developersFontSrc = isDevelopersPath(pathname) ? ' https://fonts.reown.com' : ''
// WalletConnect / Reown hosts are wildcards on purpose. widget.shapeshift.com ships with no CSP;
// pinning exact hosts here dropped echo.walletconnect.com and secure-mobile.walletconnect.* —
// the paths AppKit uses to deliver session requests (including eth_chainId / eth_sendTransaction)
// to a mobile wallet. New WC subdomains should not require a CSP chase.
const developersConnectSrc = isDevelopersPath(pathname)
? ' 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'
? ' 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'
: ''
const developersFrameSrc = isDevelopersPath(pathname)
? ' https://secure.walletconnect.org https://verify.walletconnect.org https://verify.walletconnect.com'
? ' https://*.walletconnect.org https://*.walletconnect.com https://verify.walletconnect.org https://verify.walletconnect.com'
: ''
// The Buy Crypto card on /trade embeds Onramper. This header replaces (not merges with) the
// route-level CSP from next.config.ts, so the iframe origins have to be allowed here.
const tradeFrameSrc = isTradePath(pathname) ? ' https://buy.onramper.com https://widget.onramper.com' : ''
// Coinbase Wallet SDK / Base Account SDK (pulled in transitively by the swap widget's wagmi
// connectors) inject their own inline bootstrap <script> tags, which our own nonce doesn't cover.
// 'strict-dynamic' lets scripts loaded by an already-nonce-trusted script (the widget bundle
Expand All @@ -201,7 +231,7 @@ export function middleware(request: NextRequest): NextResponse {
// 'strict-dynamic' with no nonce/hash present disables ALL host-based allowlisting and
// 'unsafe-inline', blocking every script on the page, not just the ones it's meant to loosen.
const developersScriptSrc = isDevelopersPath(pathname) && !isDevelopment ? " 'strict-dynamic'" : ''
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;`
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;`
response.headers.set('Content-Security-Policy', cspHeader)

// Handle locale routing
Expand Down
21 changes: 5 additions & 16 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,27 +47,16 @@ const nextConfig = {
source: '/(.*)',
headers: [{key: 'cross-origin-resource-policy', value: 'cross-origin'}]
},
// The Onramper iframe on /trade needs popups for its checkout flow. Its frame-src
// allowance lives in middleware.ts, which owns the Content-Security-Policy header.
{
// Allow Onramper iframe on /trade
source: '/trade',
headers: [
{key: 'cross-origin-opener-policy', value: 'same-origin-allow-popups'},
{
key: 'Content-Security-Policy',
value: "frame-src 'self' https://buy.onramper.com https://widget.onramper.com; child-src 'self' https://buy.onramper.com https://widget.onramper.com;"
}
]
headers: [{key: 'cross-origin-opener-policy', value: 'same-origin-allow-popups'}]
},
{
// Also allow Onramper on localized routes like /en/trade
// Localized routes like /en/trade
source: '/:lang/trade',
headers: [
{key: 'cross-origin-opener-policy', value: 'same-origin-allow-popups'},
{
key: 'Content-Security-Policy',
value: "frame-src 'self' https://buy.onramper.com https://widget.onramper.com; child-src 'self' https://buy.onramper.com https://widget.onramper.com;"
}
]
headers: [{key: 'cross-origin-opener-policy', value: 'same-origin-allow-popups'}]
}
]
},
Expand Down
Loading