Skip to content
Merged
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
25 changes: 20 additions & 5 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,20 @@ import { cookieStorage, createConfig, createStorage, http } from 'wagmi'
* connection happens entirely in the browser via EIP-1193 + EIP-6963, so no
* WalletConnect relay or hosted service is involved.
*/
export type CreateWalletLinkConfigParameters = {
export type CreateWalletLinkConfigParameters<
chains extends readonly [Chain, ...Chain[]] = readonly [Chain, ...Chain[]],
> = {
/** Chains the dapp supports. At least one is required. */
chains: readonly [Chain, ...Chain[]]
chains: chains
/**
* Per-chain RPC transports. When omitted, a default `http()` transport is
* created for every chain (uses each chain's public RPC URLs).
*
* Keyed to `chains`, so supplying this means supplying one for every chain —
* the same requirement wagmi enforces. A chain left out would otherwise only
* fail at runtime, the first time something used it.
*/
transports?: Record<number, Transport>
transports?: Record<chains[number]['id'], Transport>
/**
* Enable SSR-safe hydration. Set this to `true` in Next.js apps. When `true`
* and no `storage` is provided, cookie-based storage is used automatically so
Expand Down Expand Up @@ -68,14 +74,23 @@ export type CreateWalletLinkConfigParameters = {
* `transports` for chains without a public RPC, or to avoid leaking user IPs to
* public RPC providers.
*/
export function createWalletLinkConfig(parameters: CreateWalletLinkConfigParameters) {
export function createWalletLinkConfig<const chains extends readonly [Chain, ...Chain[]]>(
parameters: CreateWalletLinkConfigParameters<chains>,
) {
const { chains, transports, ssr = false, storage, connectors = [] } = parameters

return createConfig({
chains,
connectors,
multiInjectedProviderDiscovery: true,
transports: transports ?? Object.fromEntries(chains.map((chain) => [chain.id, http()])),
// Cast: built from `chains`, so every chain id is covered by construction,
// but `Object.fromEntries` widens the keys back to `string`.
transports:
transports ??
(Object.fromEntries(chains.map((chain) => [chain.id, http()])) as Record<
chains[number]['id'],
Transport
>),
ssr,
// Under SSR, fall back to cookie storage so server-rendered and client
// markup agree on connection state (prevents React hydration mismatch).
Expand Down
11 changes: 9 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
// Public entry point for @stability-nexus/walletlink.
//
// SaaS-free EVM wallet connection built on EIP-1193 + EIP-6963 — no
// WalletConnect relay, no projectId. The headless hook and connect UI
// components are added in subsequent PRs.
// WalletConnect relay, no projectId. The connect UI components are added in a
// subsequent PR.
export { createWalletLinkConfig } from './config'
export type { CreateWalletLinkConfigParameters } from './config'
export { useWalletLink } from './useWalletLink'
export type {
UseWalletLinkReturnType,
WalletLinkConnectParameters,
WalletLinkConnectResult,
WalletLinkStatus,
} from './useWalletLink'
141 changes: 141 additions & 0 deletions src/useWalletLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// No 'use client' directive here on purpose. The published bundle is a single
// file, so the directive would be hoisted over `createWalletLinkConfig` too and
// make it a client-only export — breaking `cookieToInitialState(config, ...)`,
// which has to run in a Next.js Server Component. This hook is client-only
// regardless; the component calling it supplies the boundary. Splitting the
// package into server and client entry points would let us mark it properly.
import { useCallback } from 'react'
import type { Address, Chain } from 'viem'
import type { Connector } from 'wagmi'
import { useAccount, useConnect, useConnectors, useDisconnect } from 'wagmi'

/** Result of a successful {@link UseWalletLinkReturnType.connectAsync}. */
export type WalletLinkConnectResult = {
accounts: readonly [Address, ...Address[]]
chainId: number
}

/** Lifecycle of the current account. Mirrors wagmi's account status. */
export type WalletLinkStatus = 'connected' | 'connecting' | 'reconnecting' | 'disconnected'

/** Optional per-connect overrides. */
export type WalletLinkConnectParameters = {
/** Chain to connect on. Defaults to the wallet's current chain. */
chainId?: number
}

export type UseWalletLinkReturnType = {
/**
* Wallets available to connect: every EIP-6963 provider the browser
* announced, plus any connectors passed to `createWalletLinkConfig`.
*
* Empty during server rendering and on the first client render when
* `ssr: true` — discovery is a browser-only handshake, so wagmi appends the
* announced wallets once `WagmiProvider` mounts. Render a loading/empty state
* rather than concluding the user has no wallet installed.
*/
wallets: readonly Connector[]
/** Connect to `wallet`. Rejection surfaces on {@link error}, not as a throw. */
connect: (wallet: Connector, parameters?: WalletLinkConnectParameters) => void
/** Promise-returning {@link connect}. Rejects if the user declines. */
connectAsync: (
wallet: Connector,
parameters?: WalletLinkConnectParameters,
) => Promise<WalletLinkConnectResult>
/** Disconnect the active wallet. */
disconnect: () => void
/** Promise-returning {@link disconnect}. */
disconnectAsync: () => Promise<void>
/** Active account, or `undefined` when disconnected. */
address: Address | undefined
/** Active chain, or `undefined` if the wallet is on a chain the dapp did not configure. */
chain: Chain | undefined
/** Active chain id, even when the chain is not one the dapp configured. */
chainId: number | undefined
/** Connector backing the active account. */
connector: Connector | undefined
status: WalletLinkStatus
isConnected: boolean
/** A connection attempt is in flight. */
isConnecting: boolean
/** wagmi is restoring a previous session on mount. */
isReconnecting: boolean
isDisconnected: boolean
/**
* The wallet currently being connected to, while {@link isConnecting}. Use it
* to show a pending state on the specific wallet the user picked.
*/
pendingWallet: Connector | undefined
/** Last connection error — most often the user rejecting the request. */
error: Error | null
/** Clear {@link error} and any pending state. */
reset: () => void
}

/**
* Headless wallet connection: the discovered wallets, connect/disconnect, and
* the current account, with no UI attached.
*
* Wallets come from EIP-6963 announcements, so there is no relay and no
* `projectId`. Requires a {@link createWalletLinkConfig} config mounted on
* wagmi's `WagmiProvider`.
*
* @example
* ```tsx
* const { wallets, connect, address, isConnected, disconnect } = useWalletLink()
*
* if (isConnected) return <button onClick={() => disconnect()}>{address}</button>
*
* return wallets.map((wallet) => (
* <button key={wallet.uid} onClick={() => connect(wallet)}>{wallet.name}</button>
* ))
* ```
*/
export function useWalletLink(): UseWalletLinkReturnType {
const wallets = useConnectors()
const account = useAccount()
const { connect, connectAsync, error, isPending, reset, variables } = useConnect()
const { disconnect, disconnectAsync } = useDisconnect()

const connectWallet = useCallback(
(wallet: Connector, parameters?: WalletLinkConnectParameters) => {
connect({ connector: wallet, chainId: parameters?.chainId })
},
[connect],
)

const connectWalletAsync = useCallback(
(wallet: Connector, parameters?: WalletLinkConnectParameters) =>
connectAsync({ connector: wallet, chainId: parameters?.chainId }),
[connectAsync],
)

const disconnectWallet = useCallback(() => disconnect(), [disconnect])
const disconnectWalletAsync = useCallback(() => disconnectAsync(), [disconnectAsync])

// `variables` outlives the mutation, so only report a pending wallet while one
// is actually in flight. The connector is narrowed because `connect` also
// accepts a `CreateConnectorFn`, which this hook never passes.
const submitted = isPending ? variables?.connector : undefined
const pendingWallet = typeof submitted === 'function' ? undefined : submitted

return {
wallets,
connect: connectWallet,
connectAsync: connectWalletAsync,
disconnect: disconnectWallet,
disconnectAsync: disconnectWalletAsync,
address: account.address,
chain: account.chain,
chainId: account.chainId,
connector: account.connector,
status: account.status,
isConnected: account.isConnected,
isConnecting: account.isConnecting || isPending,
isReconnecting: account.isReconnecting,
isDisconnected: account.isDisconnected,
pendingWallet,
error,
reset,
}
}