Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions apps/zerodev-signer-demo/src/app/wagmi-config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import { ZeroDevLogo } from '@zerodev/react-ui'
import { type WalletMode } from '@zerodev/wallet-react'
import { zeroDevWallet } from '@zerodev/wallet-react-ui'
import { createConfig, http } from 'wagmi'
import { arbitrumSepolia, sepolia } from 'wagmi/chains'
import { arbitrumSepolia, sepolia, arbitrum, mainnet } from 'wagmi/chains'

const rpcUrls: Record<number, string | undefined> = {
[arbitrumSepolia.id]: process.env.NEXT_PUBLIC_ARB_SEPOLIA_RPC_URL,
[sepolia.id]: process.env.NEXT_PUBLIC_SEPOLIA_RPC_URL,
[mainnet.id]: process.env.NEXT_PUBLIC_MAINNET_RPC_URL,
[arbitrum.id]: process.env.NEXT_PUBLIC_ARBITRUM_RPC_URL,
}

// Local testing toggle for the connector's account mode.
Expand All @@ -27,12 +29,12 @@ function getEmailAuthMethod(): 'otp' | 'magicLink' {
}

export const config = createConfig({
chains: [arbitrumSepolia, sepolia],
chains: [arbitrumSepolia, sepolia, mainnet, arbitrum],
connectors: [
zeroDevWallet({
projectId: process.env.NEXT_PUBLIC_ZERODEV_PROJECT_ID!,
proxyBaseUrl: process.env.NEXT_PUBLIC_KMS_PROXY_BASE_URL!,
chains: [arbitrumSepolia, sepolia],
chains: [arbitrumSepolia, sepolia, mainnet, arbitrum],
// Bundler/paymaster host override (defaults to the SDK's prod host).
// CI/e2e sets this to staging to match NEXT_PUBLIC_KMS_PROXY_BASE_URL.
...(process.env.NEXT_PUBLIC_ZERODEV_AA_HOST && {
Expand All @@ -46,8 +48,10 @@ export const config = createConfig({
...(mode && { mode }),
config: {
logo: <ZeroDevLogo variant="mark" tone="color" className="zd:h-8 zd:w-auto" />,
// PoC Reown Cloud project id (public client identifier).
walletConnectProjectId: 'a6b5206ed2bb5ffce9937671b0f8f187',
auth: {
enabledMethods: ['email', 'google', 'passkey'],
enabledMethods: ['email', 'google', 'passkey', 'external-wallet'],
emailAuthMethod: getEmailAuthMethod(),
},
},
Expand All @@ -57,5 +61,7 @@ export const config = createConfig({
transports: {
[arbitrumSepolia.id]: http(rpcUrls[arbitrumSepolia.id]),
[sepolia.id]: http(rpcUrls[sepolia.id]),
[mainnet.id]: http(rpcUrls[mainnet.id]),
[arbitrum.id]: http(rpcUrls[arbitrum.id]),
},
})
3 changes: 2 additions & 1 deletion packages/wallet-react-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@
"web3"
],
"dependencies": {
"@zerodev/react-ui": "workspace:*"
"@zerodev/react-ui": "workspace:*",
"uqr": "^0.1.3"
},
"peerDependencies": {
"@tanstack/react-query": "^5.0.0",
Expand Down
33 changes: 33 additions & 0 deletions packages/wallet-react-ui/src/auth/components/QrCode/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { encode } from 'uqr'

/**
* Renders `value` as a QR code. ECC level Q so ~25% of the modules are
* recoverable — leaves room to overlay a center logo.
*/
export function QrCode({
value,
className,
}: {
value: string
className?: string
}) {
const qr = encode(value, { ecc: 'Q', border: 2 })

return (
<svg
viewBox={`0 0 ${qr.size} ${qr.size}`}
className={className}
role="img"
aria-label="WalletConnect QR code"
>
{qr.data.flatMap((row, y) =>
row.map((dark, x) =>
dark ? (
// biome-ignore lint/suspicious/noArrayIndexKey: grid cells are positional
<rect key={`${x}:${y}`} x={x} y={y} width={1} height={1} />
) : null,
),
)}
</svg>
)
}
4 changes: 4 additions & 0 deletions packages/wallet-react-ui/src/auth/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import { ErrorScreen } from './pages/ErrorScreen'
import { OtpInput } from './pages/OtpInput'
import { SignUp } from './pages/SignUp'
import { Verifying } from './pages/Verifying'
import { WalletConnectQr } from './pages/WalletConnectQr'
import { WalletSelection } from './pages/WalletSelection'
import type { AuthStep } from './types'
import { hasMagicLinkCodeInUrl, stripMagicLinkCodeFromUrl } from './utils/url'

const TITLE_BY_STEP: Partial<Record<AuthStep, string>> = {
'wallet-selection': 'Choose your wallet',
'wallet-connect': 'WalletConnect',
}

function OAuthCallback() {
Expand Down Expand Up @@ -54,6 +56,8 @@ function renderStep(step: AuthStep | null): ReactNode {
return <PasskeyPrompt />
case 'wallet-selection':
return <WalletSelection />
case 'wallet-connect':
return <WalletConnectQr />
case 'error':
return <ErrorScreen />
default:
Expand Down
10 changes: 10 additions & 0 deletions packages/wallet-react-ui/src/auth/pages/SignUp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,16 @@ export function SignUp() {
)}
</Input>
)}
{enabledMethods.includes('external-wallet') && (
<ListItem
iconName="walletOutline"
title="Continue with a wallet"
chevron
className="zd:rounded-3xl"
disabled={anyPending}
onClick={() => goToStep('wallet-selection')}
/>
)}
</div>
</div>
</div>
Expand Down
139 changes: 139 additions & 0 deletions packages/wallet-react-ui/src/auth/pages/WalletConnectQr.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { Button, Text } from '@zerodev/react-ui'
import { useEffect, useRef, useState } from 'react'
import { type Connector, useConfig, useConnect } from 'wagmi'
import { useStore } from 'zustand'
import { PoweredBy } from '../../shared/components/PoweredBy'
import { useKitStore } from '../../shared/hooks/useKitStore'
import { QrCode } from '../components/QrCode'
import { useAuth } from '../hooks/useAuth'

export function WalletConnectQr() {
const { goToStep } = useAuth()
const config = useConfig()
const walletConnectProjectId = useStore(
useKitStore(),
(s) => s.walletConnectProjectId,
)
const { connect } = useConnect()

const [connector, setConnector] = useState<Connector | null>(null)
const [uri, setUri] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)

// Both refs survive Strict Mode's unmount/remount, so the connector is
// created and the pairing kicked off exactly once.
const ensureConnectorRef = useRef<Promise<Connector | null> | null>(null)
const startedRef = useRef(false)

const startConnect = (target: Connector) => {
setError(null)
setUri(null)
connect(
{ connector: target },
{
onSuccess: () => goToStep(null),
onError: (err) => setError(err.message),
},
)
}

useEffect(() => {
let unsubscribe: (() => void) | undefined
let cancelled = false

ensureConnectorRef.current ??= (async () => {
const existing = config.connectors.find((c) => c.type === 'walletConnect')
if (existing) return existing
if (!walletConnectProjectId) return null

const { walletConnect } = await import('wagmi/connectors')
// Same registration wagmi's own `connect()` performs for connector
// factories — makes the connector visible to hooks and reconnects.
const created = config._internal.connectors.setup(
walletConnect({
projectId: walletConnectProjectId,
showQrModal: false,
}),
)
config._internal.connectors.setState((prev) => [...prev, created])
return created
})()

ensureConnectorRef.current.then((target) => {
if (cancelled || !target) return
setConnector(target)

const onMessage = ({ type, data }: { type: string; data?: unknown }) => {
if (type === 'display_uri' && typeof data === 'string') setUri(data)
}
target.emitter.on('message', onMessage)
unsubscribe = () => target.emitter.off('message', onMessage)

if (!startedRef.current) {
startedRef.current = true
startConnect(target)
}
})

return () => {
cancelled = true
unsubscribe?.()
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only pairing setup
}, [])

const copyUri = async () => {
if (!uri) return
await navigator.clipboard.writeText(uri)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}

if (!walletConnectProjectId && !connector) {
return (
<div className="zd:flex-1 zd:flex zd:items-center zd:justify-center">
<Text className="zd:text-center">
WalletConnect is not configured. Pass `walletConnectProjectId` to the
zeroDevWallet connector config.
</Text>
</div>
)
}

return (
<>
<div className="zd:flex-1 zd:flex zd:flex-col zd:gap-4 zd:items-center zd:justify-center">
<Text className="zd:text-h2 zd:text-center">Scan with your wallet</Text>

{error ? (
<div className="zd:flex zd:flex-col zd:gap-3 zd:items-center">
<Text className="zd:text-center zd:text-red-500">{error}</Text>
<Button
action="secondary"
text="Try again"
onClick={() => connector && startConnect(connector)}
/>
</div>
) : uri ? (
<>
<div className="zd:bg-white zd:rounded-3xl zd:p-4 zd:text-black">
<QrCode value={uri} className="zd:w-56 zd:h-56" />
</div>
<Button
action="secondary"
text={copied ? 'Copied' : 'Copy link'}
onClick={copyUri}
/>
</>
) : (
<Text className="zd:text-center zd:text-greyScale/50">
Generating connection link…
</Text>
)}
</div>

<PoweredBy className="zd:self-center zd:pt-4 zd:pb-6" />
</>
)
}
35 changes: 35 additions & 0 deletions packages/wallet-react-ui/src/auth/pages/WalletSelection.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { ListItem, Text } from '@zerodev/react-ui'
import { useConnect } from 'wagmi'
import { useStore } from 'zustand'
import { PoweredBy } from '../../shared/components/PoweredBy'
import { useKitStore } from '../../shared/hooks/useKitStore'
import { useAuth } from '../hooks/useAuth'
import { WALLET_GUIDE } from '../walletGuide'

export function WalletSelection() {
const { goToStep } = useAuth()
const { connect, connectors, isPending } = useConnect()
const walletConnectProjectId = useStore(
useKitStore(),
(s) => s.walletConnectProjectId,
)

const externalConnectors = connectors.filter((c) => c.id !== 'zerodev-wallet')

Expand Down Expand Up @@ -45,6 +52,34 @@ export function WalletSelection() {
))
)}
</div>

<div className="zd:flex zd:flex-col zd:gap-2">
{walletConnectProjectId && (
<ListItem
title="WalletConnect"
iconName="walletOutline"
chevron
disabled={isPending}
onClick={() => goToStep('wallet-connect')}
className="zd:rounded-3xl"
/>
)}
<Text className="zd:text-body3 zd:text-greyScale/50">
More wallets
</Text>
{WALLET_GUIDE.map((wallet) => (
<ListItem
key={wallet.id}
title={wallet.name}
imageUri={wallet.icon}
disabled={isPending}
onClick={() =>
window.open(wallet.downloadUrl, '_blank', 'noopener')
}
className="zd:rounded-3xl"
/>
))}
</div>
</div>

<PoweredBy className="zd:self-center zd:pt-4 zd:pb-6" />
Expand Down
3 changes: 2 additions & 1 deletion packages/wallet-react-ui/src/auth/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type AuthMethod = 'email' | 'google' | 'passkey'
export type AuthMethod = 'email' | 'google' | 'passkey' | 'external-wallet'

export type AuthStep =
| 'sign-up'
Expand All @@ -8,6 +8,7 @@ export type AuthStep =
| 'passkey-prompt'
| 'oauth-in-progress'
| 'wallet-selection'
| 'wallet-connect'
| 'authenticated'
| 'error'

Expand Down
Loading
Loading