릴레이로 지갑 연결
릴레이 연결을 사용하면 데스크톱 dApp의 QR을 모바일 지갑으로 스캔하거나, 같은 기기에서 딥링크를 열어 지갑 세션을 수립할 수 있습니다. 사용자가 승인하면 transport: "relay"인 WalletConnection이 활성 세션이 됩니다.
Scope Connect(scope)처럼 브라우저에 주입된 프로바이더가 없는 릴레이 전용 지갑은 이 흐름으로 연결합니다. @scope-connect/appkit-react는 UI 없는 appKit.pair()를 useWalletConnection 훅으로 감싸, 페어링 URI를 pairingUri로 노출하고 <PairingQr />로 렌더링합니다. connect("scope")처럼 릴레이 지갑 ID를 넘기면 훅이 내부에서 pair()를 호출하고 릴레이 경로로 자동 전달합니다.
이 가이드는 React 바인딩에서 설명하는 Provider 마운트와 훅 구성을 전제로 합니다.
준비 사항
- React 18 이상 프로젝트에
@scope-connect/appkit-react설치 및ScopeAppKitProvider마운트 (React 바인딩) - QR을 표시할 화면과 사용자의 취소·재시도 동작
릴레이에는 추가 config가 필요하지 않습니다. 릴레이 WSS 엔드포인트는 @scope-connect/appkit 빌드 시점에 고정되며(DEFAULT_RELAY_URL), dApp이 런타임에 다른 릴레이를 지정할 수 없습니다. clientId는 필수 옵션이고, 백엔드 루트도 같은 방식으로 빌드 시점에 고정됩니다.
"use client";
import dynamic from "next/dynamic";
import type { ReactNode } from "react";
import type { ScopeAppKitConfig } from "@scope-connect/appkit-react";
// 커넥터가 window에 접근하므로 클라이언트에서만 마운트합니다.
const ScopeAppKitProvider = dynamic(
() => import("@scope-connect/appkit-react").then((m) => m.ScopeAppKitProvider),
{ ssr: false },
);
export function AppKitProviders({ children }: { children: ReactNode }) {
// 릴레이용 추가 설정은 없습니다 — 엔드포인트는 SDK에 빌드 시점에 고정됩니다.
const config: ScopeAppKitConfig = {
dappName: "Example dApp",
clientId: process.env.NEXT_PUBLIC_APPKIT_CLIENT_ID,
};
return <ScopeAppKitProvider config={config}>{children}</ScopeAppKitProvider>;
}
루트 레이아웃에서 앱을 감싸는 방법과 Next.js transpile·SSR 설정은 React 바인딩을 참고합니다.
1. 페어링 시작
React에서는 appKit.pair()를 직접 호출하지 않습니다. useWalletConnection()의 connect(walletId)에 릴레이 지갑 ID("scope")를 넘기면, 훅이 내부에서 pair()를 호출하고 릴레이 경로로 전달합니다.
"use client";
import { useWalletConnection } from "@scope-connect/appkit-react";
export function RelayConnect() {
const conn = useWalletConnection();
return (
<button
onClick={() => void conn.connect("scope")}
disabled={conn.connectionStatus === "connecting"}
>
Scope Connect로 연결
</button>
);
}
connect("scope")은 먼저 Connect 백엔드에서 릴레이 토큰을 받고 WebSocket을 연결한 뒤 페어링을 시작합니다. 요청할 체인·메서드·이벤트(requiredNamespaces)는 훅이 기본값으로 구성하며, connect("scope", "eip155:1")처럼 두 번째 인자로 특정 체인만 요청하도록 좁힐 수 있습니다.
requiredNamespaces를 직접 제어해야 한다면 useScopeAppKit()으로 얻은 AppKit 인스턴스의 pair()를 호출합니다. 이때 체인은 하드코딩하지 말고 getSupportedNetworks()로 SDK가 지원하는 EVM(eip155) 네트워크에서 가져옵니다.
const appkit = useScopeAppKit();
const eip155Chains = appkit
.getSupportedNetworks()
.filter((network) => network.namespace === "eip155")
.map((network) => network.chainId);
const pairing = await appkit.pair({
requiredNamespaces: {
eip155: {
chains: eip155Chains,
methods: ["eth_signTypedData_v4"],
events: ["accountsChanged", "chainChanged"],
},
},
metadata: { name: "Example dApp" },
});
methods에는 eth_signTypedData_v4를 반드시 포함합니다 — 현재 WalletKit 지갑이 릴레이 세션에서 서명하는 유일한 메서드이며, 지갑은 다른 메서드를 요청해도 세션을 eth_signTypedData_v4로 좁혀 승인하고 그 외 서명 요청은 거부합니다.
2. QR 표시
connect("scope") 호출 직후 훅의 pairingUri가 scope: 페어링 URI로 채워집니다. 이 값을 <PairingQr />에 넘기면 QR로 렌더링됩니다. PairingQr는 내부에서 qrcode를 사용하므로 별도 설치가 필요 없습니다.
"use client";
import { useWalletConnection, PairingQr } from "@scope-connect/appkit-react";
export function RelayConnect() {
const conn = useWalletConnection();
// 릴레이 페어링 중 → QR 표시
if (conn.pairingUri) {
return (
<div>
<PairingQr uri={conn.pairingUri} size={256} />
<p>지갑에서 QR을 스캔하고 연결을 승인하세요.</p>
</div>
);
}
// 시작 전 → 연결 버튼
return (
<button
onClick={() => void conn.connect("scope")}
disabled={conn.connectionStatus === "connecting"}
>
Scope Connect로 연결
</button>
);
}
pairingUri는 페어링 중에만 null이 아닌 값이고, 연결이 완료되거나 실패하면 다시 null이 됩니다. 같은 기기에서는 이 값이 곧 scope: URI이므로 버튼에서 window.location.assign(conn.pairingUri)로 열 수 있으나, 대상 지갑이 scope: URI 스킴을 등록한 경우에만 표시합니다.
pairingUri에는 세션 대칭키인 symKey가 포함됩니다. 이 값과 QR 원문을 로그·오류 추적·분석 이벤트·영구 저장소에 남기거나 URL query로 전달하지 않습니다. 화면을 닫거나 실패하면 기존 값을 재사용하지 말고 disconnect()으로 정리한 뒤 새 connect()로 다시 시작합니다.
3. 지갑 승인 기다리기
UI 없는 AppKit을 직접 사용할 때는 pairing.session을 await하지만, React에서는 훅의 connectionStatus를 관찰합니다. 지갑이 연결 제안을 승인하고 세션 연결을 마치면 connectionStatus가 "connected"로 바뀌고 connectedAddress·displayAddress가 채워집니다.
// 연결됨 → 계정 표시
if (conn.connectionStatus === "connected" && conn.connectedAddress) {
return (
<div>
<p>연결됨: {conn.displayAddress}</p>
<button onClick={() => void conn.disconnect()}>연결 해제</button>
</div>
);
}
connectedAddress는 전체 계정 주소, displayAddress는 축약 표시용 주소입니다. 연결된 세션은 릴레이 전송을 사용하며, 이후 useScopeAppKit()으로 얻은 AppKit 인스턴스의 request()·서명 호출이 활성 릴레이 세션으로 전달됩니다. 이 블록을 기본 제공 <ConnectedCard />로 대체할 수도 있습니다.
4. 오류 처리
훅은 릴레이 오류를 세부 코드가 아니라 상태로 축약해 노출합니다. 두 가지를 구분해 처리합니다.
notInstalledWalletId === "scope"— 현재 AppKit 설정으로 페어링을 시작할 수 없는 상태입니다(INVALID_CONFIG). 릴레이 엔드포인트는 빌드에 고정돼 있으므로, 남는 원인은 AppKit 자체 설정(예: 초기화 누락)입니다.connectionStatus === "failed"— 세션 연결 실패입니다. 승인 시간 만료(SESSION_TIMEOUT), 지갑의 세션 응답 검증 실패(INVALID_RESPONSE), 릴레이 연결 종료 등이 모두 여기로 모입니다. 훅은 실제 원인을 콘솔에 기록합니다.
사용자가 지갑에서 거절하면(USER_REJECTED) status는 실패가 아니라 "idle"로 돌아갑니다. 실패 원인을 코드로 분기해 안내하려면 훅의 lastError를 읽습니다 — 마지막 연결 실패의 AppResultCode와 메시지를 담고, 재시도하면 초기화됩니다(useWalletConnection 반환값).
앞 절들의 분기를 하나로 모은 전체 컴포넌트입니다.
"use client";
import { useWalletConnection, PairingQr } from "@scope-connect/appkit-react";
export function RelayConnect() {
const conn = useWalletConnection();
// 릴레이 미설정 → config 안내
if (conn.notInstalledWalletId === "scope") {
return <p>이 환경에서는 Scope Connect 지갑을 사용할 수 없습니다.</p>;
}
// 연결됨 → 계정 표시
if (conn.connectionStatus === "connected" && conn.connectedAddress) {
return (
<div>
<p>연결됨: {conn.displayAddress}</p>
<button onClick={() => void conn.disconnect()}>연결 해제</button>
</div>
);
}
// 실패 → 새 QR로 재시도
if (conn.connectionStatus === "failed") {
return (
<div>
<p>연결에 실패했습니다. 새 QR로 다시 시도하세요.</p>
<button onClick={() => void conn.connect("scope")}>다시 시도</button>
</div>
);
}
// 페어링 중 → QR 표시
if (conn.pairingUri) {
return (
<div>
<PairingQr uri={conn.pairingUri} size={256} />
<p>지갑에서 QR을 스캔하고 연결을 승인하세요.</p>
<button onClick={() => void conn.disconnect()}>취소</button>
</div>
);
}
// 그 외(idle/connecting) → 연결 버튼
return (
<button
onClick={() => void conn.connect("scope")}
disabled={conn.connectionStatus === "connecting"}
>
Scope Connect로 연결
</button>
);
}
세부 오류 코드(RelayConnectionError, RelayRpcError, AppResultCode)로 분기해야 한다면 훅 대신 useScopeAppKit()이 반환하는 AppKit 인스턴스의 pair()를 직접 호출해 try/catch로 분류합니다. WebSocket close code와 릴레이 RPC code는 AppKit 릴레이 오류, 세션 오류 코드는 AppResultCode를 기준으로 대응합니다.
5. 시간 초과와 화면 닫기 처리
현재 proposal 승인과 session settle은 각각 300초 제한을 사용하며 바꿀 수 없습니다. 시간이 초과되면 connectionStatus가 "failed"로 바뀌므로, 진행 상태에는 정확한 남은 시간을 알려 주기보다 "지갑에서 승인 대기 중"이라고 표시하고 실패 시 connect("scope")로 재시도하게 합니다. 기본 제한은 제한 시간에서 확인합니다.
사용자가 QR 화면을 닫으면 disconnect()으로 진행 중인 페어링을 정리합니다. 훅의 disconnect()은 진행 중인 pair()를 무효화하고 pairingUri와 상태를 초기화하므로, 이후에는 새 connect()만 허용됩니다.
// useWalletConnection()에서 얻은 conn을 사용하는 취소 핸들러
async function closePairing() {
try {
await conn.disconnect();
} catch {
// 화면은 닫되, 로깅에 pairingUri를 포함하지 않습니다.
}
}
다음 단계
- 요청 보내기 — 릴레이 연결의
request<T>()호출 - AppKit Connection — 계정 변경과 연결 해제
- React 바인딩 —
<ScopeConnect />기본 UI 구성