diff --git a/cross-app-connect/.env.example b/cross-app-connect/.env.example
new file mode 100644
index 00000000..c79184c0
--- /dev/null
+++ b/cross-app-connect/.env.example
@@ -0,0 +1,13 @@
+# Privy app this host represents
+# Default = VeChain's mainnet Privy app (mirrors VECHAIN_PRIVY_APP_ID in vechain-kit)
+NEXT_PUBLIC_PRIVY_APP_ID=cm4wxxujb022fyujl7g0thb21
+NEXT_PUBLIC_PRIVY_CLIENT_ID=
+# REQUIRED. Whitelabel Privy auth subdomain provisioned for your app in the
+# Privy dashboard. Must include scheme, e.g. https://privy.your-app.privy.dev
+# This is NOT the public auth.privy.io host.
+NEXT_PUBLIC_PRIVY_DOMAIN=
+
+# VeChain network -- main | test | solo
+NEXT_PUBLIC_NETWORK_TYPE=main
+NEXT_PUBLIC_DELEGATOR_URL=
+NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=
diff --git a/cross-app-connect/.eslintrc.js b/cross-app-connect/.eslintrc.js
new file mode 100644
index 00000000..0a3bb5f9
--- /dev/null
+++ b/cross-app-connect/.eslintrc.js
@@ -0,0 +1,6 @@
+module.exports = {
+ extends: ['next'],
+ rules: {
+ '@typescript-eslint/unbound-method': 'off',
+ },
+};
diff --git a/cross-app-connect/.gitignore b/cross-app-connect/.gitignore
new file mode 100644
index 00000000..01b33258
--- /dev/null
+++ b/cross-app-connect/.gitignore
@@ -0,0 +1,38 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+.yarn/install-state.gz
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+/dist/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# local env files
+.env
+.env*.local
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/cross-app-connect/next.config.js b/cross-app-connect/next.config.js
new file mode 100644
index 00000000..120f196e
--- /dev/null
+++ b/cross-app-connect/next.config.js
@@ -0,0 +1,44 @@
+const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? process.env.BASE_PATH ?? '';
+
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ basePath,
+ assetPrefix: basePath,
+ output: 'export',
+ distDir: 'dist',
+
+ compiler: {
+ removeConsole:
+ process.env.NODE_ENV === 'production'
+ ? {
+ exclude: ['error', 'warn'],
+ }
+ : false,
+ },
+
+ turbopack: {
+ resolveAlias: {
+ '@/*': './src/*',
+ },
+ },
+
+ experimental: {
+ webpackBuildWorker: true,
+ optimizePackageImports: ['@chakra-ui/react', '@vechain/vechain-kit'],
+ },
+
+ images: {
+ unoptimized: true,
+ },
+ env: {
+ basePath,
+ },
+ eslint: {
+ ignoreDuringBuilds: true,
+ },
+
+ poweredByHeader: false,
+ generateEtags: false,
+};
+
+module.exports = nextConfig;
diff --git a/cross-app-connect/package.json b/cross-app-connect/package.json
new file mode 100644
index 00000000..4df8eb5e
--- /dev/null
+++ b/cross-app-connect/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "cross-app-connect",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "build": "next build",
+ "clean": "rm -rf .next dist .turbo",
+ "dev": "next dev --turbopack",
+ "generate-app-hub": "node scripts/fetch-app-hub.mjs",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@chakra-ui/react": "2.8.2",
+ "@emotion/react": "^11.13.5",
+ "@emotion/styled": "^11.13.5",
+ "@privy-io/cross-app-provider": "^0.3.4",
+ "@privy-io/react-auth": "2.25.0",
+ "@tanstack/react-query": "^5.64.2",
+ "@vechain/dapp-kit-react": "2.1.0-rc.5",
+ "@vechain/sdk-core": "2.0.7",
+ "@vechain/sdk-network": "2.0.7",
+ "@vechain/vechain-kit": "workspace:*",
+ "next": "~16.2.3",
+ "react": "^18",
+ "react-dom": "^18",
+ "viem": "^2.22.0"
+ },
+ "devDependencies": {
+ "@next/eslint-plugin-next": "^14.1.4",
+ "@types/node": "^20",
+ "@types/react": "^18",
+ "@types/react-dom": "^18",
+ "eslint": "^9.12.0",
+ "eslint-config-next": "14.1.4",
+ "typescript": "5.3.3"
+ }
+}
diff --git a/cross-app-connect/public/brand/vechain-logomark-dark.png b/cross-app-connect/public/brand/vechain-logomark-dark.png
new file mode 100644
index 00000000..23bb598b
Binary files /dev/null and b/cross-app-connect/public/brand/vechain-logomark-dark.png differ
diff --git a/cross-app-connect/public/brand/vechain-logomark-light.png b/cross-app-connect/public/brand/vechain-logomark-light.png
new file mode 100644
index 00000000..b5457ba3
Binary files /dev/null and b/cross-app-connect/public/brand/vechain-logomark-light.png differ
diff --git a/cross-app-connect/public/brand/vechain-wordmark-dark.svg b/cross-app-connect/public/brand/vechain-wordmark-dark.svg
new file mode 100644
index 00000000..0f2eb1fb
--- /dev/null
+++ b/cross-app-connect/public/brand/vechain-wordmark-dark.svg
@@ -0,0 +1,10 @@
+
diff --git a/cross-app-connect/public/brand/vechain-wordmark-light.svg b/cross-app-connect/public/brand/vechain-wordmark-light.svg
new file mode 100644
index 00000000..20537d95
--- /dev/null
+++ b/cross-app-connect/public/brand/vechain-wordmark-light.svg
@@ -0,0 +1,10 @@
+
diff --git a/cross-app-connect/scripts/fetch-app-hub.mjs b/cross-app-connect/scripts/fetch-app-hub.mjs
new file mode 100644
index 00000000..ace883b8
--- /dev/null
+++ b/cross-app-connect/scripts/fetch-app-hub.mjs
@@ -0,0 +1,98 @@
+#!/usr/bin/env node
+/**
+ * Pulls every manifest.json under vechain/app-hub@master into a single
+ * JSON file at src/app/cross-app/_lib/app-hub.json, keyed by origin so the
+ * runtime lookup is O(1) ('https://nubila.ai' -> { name: 'Nubila', ... }).
+ *
+ * Re-run when the registry adds new apps:
+ *
+ * yarn workspace cross-app-connect generate-app-hub
+ */
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const REPO = 'vechain/app-hub';
+const BRANCH = 'master';
+const OUT_PATH = resolve(
+ dirname(fileURLToPath(import.meta.url)),
+ '..',
+ 'src/app/cross-app/_lib/app-hub.json',
+);
+
+async function ghFetch(url) {
+ const res = await fetch(url, {
+ headers: {
+ accept: 'application/vnd.github+json',
+ // Optional: GITHUB_TOKEN env var raises the rate limit from
+ // 60/hr (anonymous) to 5000/hr.
+ ...(process.env.GITHUB_TOKEN && {
+ authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
+ }),
+ },
+ });
+ if (!res.ok) {
+ throw new Error(`${url} -> ${res.status} ${res.statusText}`);
+ }
+ return res.json();
+}
+
+function originOf(href) {
+ try {
+ return new URL(href).origin;
+ } catch {
+ return null;
+ }
+}
+
+const tree = await ghFetch(
+ `https://api.github.com/repos/${REPO}/git/trees/${BRANCH}?recursive=1`,
+);
+const manifestPaths = tree.tree
+ .filter((t) => t.path.endsWith('manifest.json'))
+ .map((t) => t.path);
+
+console.log(`Found ${manifestPaths.length} manifests`);
+
+const byOrigin = {};
+const concurrency = 12;
+let completed = 0;
+const queue = [...manifestPaths];
+
+async function worker() {
+ while (queue.length > 0) {
+ const path = queue.shift();
+ if (!path) break;
+ const slug = path.replace(/^apps\//, '').replace(/\/manifest\.json$/, '');
+ try {
+ const manifest = await ghFetch(
+ `https://raw.githubusercontent.com/${REPO}/${BRANCH}/${path}`,
+ );
+ const origin = originOf(manifest.href);
+ if (!origin) continue;
+ // Prefer the first manifest we see for a given origin.
+ if (!byOrigin[origin]) {
+ byOrigin[origin] = {
+ slug,
+ name: manifest.name,
+ category: manifest.category ?? null,
+ href: manifest.href,
+ };
+ }
+ } catch (e) {
+ console.warn(`skip ${path}: ${e.message}`);
+ }
+ completed++;
+ if (completed % 25 === 0) {
+ console.log(` ${completed}/${manifestPaths.length}`);
+ }
+ }
+}
+
+await Promise.all(Array.from({ length: concurrency }, worker));
+
+mkdirSync(dirname(OUT_PATH), { recursive: true });
+writeFileSync(OUT_PATH, JSON.stringify(byOrigin, null, 2) + '\n');
+console.log(
+ `Wrote ${Object.keys(byOrigin).length} entries to ${OUT_PATH}`,
+);
diff --git a/cross-app-connect/src/app/components/AccountChip.tsx b/cross-app-connect/src/app/components/AccountChip.tsx
new file mode 100644
index 00000000..8461d6e5
--- /dev/null
+++ b/cross-app-connect/src/app/components/AccountChip.tsx
@@ -0,0 +1,269 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import {
+ Box,
+ HStack,
+ Icon,
+ IconButton,
+ Image,
+ Skeleton,
+ SkeletonCircle,
+ Stack,
+ Text,
+ Tooltip,
+ useClipboard,
+} from '@chakra-ui/react';
+import { LuCheck, LuCopy } from 'react-icons/lu';
+import { Address } from '@vechain/sdk-core';
+import type { ThorClient } from '@vechain/sdk-network';
+import {
+ useGetAvatarOfAddress,
+ useVechainDomain,
+} from '@vechain/vechain-kit';
+import { executeCallClause } from '@vechain/vechain-kit/utils';
+import { formatUnits, parseAbi } from 'viem';
+import type { TokenInfo } from '../cross-app/_lib/decoder';
+
+const ERC20_BALANCE_ABI = parseAbi([
+ 'function balanceOf(address owner) view returns (uint256)',
+]);
+
+type Props = {
+ address: string;
+ thor: ThorClient | null;
+ /**
+ * Additional tokens (beyond native VET) to display the user's balance
+ * for. Typically the tokens the current transaction touches.
+ */
+ relevantTokens?: TokenInfo[];
+};
+
+type LiveBalance = { token: TokenInfo; raw: bigint };
+
+const VET_TOKEN: TokenInfo = {
+ address: 'VET',
+ symbol: 'VET',
+ decimals: 18,
+};
+
+function truncate(addr: string): string {
+ if (!addr || addr.length < 12) return addr;
+ return `${addr.slice(0, 6)}…${addr.slice(-4)}`;
+}
+
+function formatVET(raw: bigint): string {
+ const str = formatUnits(raw, 18);
+ if (!str.includes('.')) return str;
+ const [whole, frac] = str.split('.');
+ const trimmed = frac.replace(/0+$/, '').slice(0, 4);
+ return trimmed.length === 0 ? whole : `${whole}.${trimmed}`;
+}
+
+/**
+ * Header row identifying the account that will sign. Shows the address with
+ * its VeChain domain (if any) plus the avatar (custom for .vet domains,
+ * Picasso identicon as fallback), a copy button, and the live VET balance.
+ */
+export function AccountChip({ address, thor, relevantTokens }: Props) {
+ const { onCopy, hasCopied } = useClipboard(address);
+ const [balances, setBalances] = useState(null);
+ const { data: domainInfo, isPending: domainPending } =
+ useVechainDomain(address);
+ const { data: avatar, isPending: avatarPending } =
+ useGetAvatarOfAddress(address);
+ const balancesPending = balances === null;
+
+ const tokenKey = (relevantTokens ?? [])
+ .map((t) => t.address.toLowerCase())
+ .sort()
+ .join(',');
+
+ useEffect(() => {
+ if (!thor || !address) return;
+ let cancelled = false;
+ (async () => {
+ try {
+ // VET always shown first; then any tokens the transaction
+ // touches. Single Thor batch via Promise.all -- each is its
+ // own request but they fire in parallel.
+ const vetTask = thor.accounts
+ .getAccount(Address.of(address))
+ .then((acc) => ({
+ token: VET_TOKEN,
+ raw: BigInt(acc.balance.toString()),
+ }));
+ const erc20Tasks = (relevantTokens ?? [])
+ .filter(
+ (t) => t.address !== 'VET' && t.address !== 'vet',
+ )
+ .map(async (token) => {
+ const res = await executeCallClause({
+ thor,
+ contractAddress: token.address,
+ abi: ERC20_BALANCE_ABI,
+ method: 'balanceOf' as const,
+ args: [address as `0x${string}`],
+ });
+ return {
+ token,
+ raw: BigInt(
+ (res as unknown as [bigint])[0],
+ ),
+ };
+ });
+ const results = await Promise.all([
+ vetTask,
+ ...erc20Tasks,
+ ]);
+ if (!cancelled) setBalances(results);
+ } catch {
+ if (!cancelled) setBalances(null);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [thor, address, tokenKey]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const domain = domainInfo?.domain;
+
+ return (
+
+
+ {avatar ? (
+
+ }
+ />
+ ) : (
+
+ )}
+
+
+
+ Your account
+
+
+
+ {domain ? (
+
+
+ {domain}
+
+
+ {truncate(address)}
+
+
+ ) : (
+
+ {truncate(address)}
+
+ )}
+
+
+
+ }
+ size="xs"
+ variant="ghost"
+ h="22px"
+ minW="22px"
+ border="none"
+ rounded="md"
+ onClick={(e) => {
+ e.stopPropagation();
+ onCopy();
+ }}
+ />
+
+
+
+
+
+ Balance
+
+ {balancesPending ? (
+
+ ) : (
+
+ {balances!.map((b) => (
+
+ {formatVET(b.raw)} {b.token.symbol}
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/cross-app-connect/src/app/components/AddressTag.tsx b/cross-app-connect/src/app/components/AddressTag.tsx
new file mode 100644
index 00000000..9369c8be
--- /dev/null
+++ b/cross-app-connect/src/app/components/AddressTag.tsx
@@ -0,0 +1,161 @@
+'use client';
+
+import { HStack, Icon, Image, Stack, Text, Tooltip } from '@chakra-ui/react';
+import { LuCircleCheck, LuTriangleAlert } from 'react-icons/lu';
+import {
+ useGetAvatarOfAddress,
+ useVechainDomain,
+ type AppConfig,
+} from '@vechain/vechain-kit';
+import { resolveContractLabel } from '../cross-app/_lib/contracts';
+
+type Props = {
+ address: string;
+ appConfig?: AppConfig;
+ self?: string;
+ /**
+ * Distinguishes "contract this user is calling" (`'contract'` — default)
+ * from "address receiving funds" (`'recipient'`).
+ *
+ * `'contract'` shows an "Unverified contract" warning when the address
+ * isn't in the kit's appConfig — that's the phishing-defence path.
+ *
+ * `'recipient'` is for token transfer destinations (a 2nd-leg argument
+ * to `transfer(address,uint256)` — typically just a wallet address, not
+ * a contract at all). Showing "Unverified contract" there would be
+ * scary noise.
+ */
+ kind?: 'contract' | 'recipient';
+ /** Avatar size in px. Defaults to 20 for inline use. */
+ avatarSize?: number;
+};
+
+function truncate(addr: string): string {
+ if (!addr || addr.length < 12) return addr;
+ return `${addr.slice(0, 6)}…${addr.slice(-4)}`;
+}
+
+export function AddressTag({
+ address,
+ appConfig,
+ self,
+ kind = 'contract',
+ avatarSize = 20,
+}: Props) {
+ // Hooks must be unconditional — React Query caches across instances so
+ // multiple AddressTags pointing at the same address share one fetch.
+ const { data: domainInfo } = useVechainDomain(address);
+ const { data: avatar } = useGetAvatarOfAddress(address);
+ const resolved = resolveContractLabel(address, appConfig, self);
+
+ // Verified VeChain-maintained contract: avatar (Picasso identicon)
+ // + friendly label + check. Avatar is here for visual consistency with
+ // unresolved addresses -- otherwise verified contracts feel like a
+ // different family.
+ if (resolved) {
+ return (
+
+ {avatar && (
+
+ }
+ />
+ )}
+
+ {resolved.label}
+
+ {resolved.verified && (
+
+
+
+
+
+ )}
+
+ );
+ }
+
+ // Unresolved: avatar + (domain ?? truncated address). Warn only when
+ // we're rendering the contract the user is *calling*, not a destination
+ // wallet for a token transfer.
+ const domain = domainInfo?.domain;
+ return (
+
+ {avatar && (
+ }
+ />
+ )}
+ {domain ? (
+
+
+ {domain}
+
+
+ {truncate(address)}
+
+
+ ) : (
+
+ {truncate(address)}
+
+ )}
+ {kind === 'contract' && (
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/cross-app-connect/src/app/components/ColorModeToggle.tsx b/cross-app-connect/src/app/components/ColorModeToggle.tsx
new file mode 100644
index 00000000..983a8b71
--- /dev/null
+++ b/cross-app-connect/src/app/components/ColorModeToggle.tsx
@@ -0,0 +1,35 @@
+'use client';
+
+import { IconButton, useColorMode } from '@chakra-ui/react';
+import { LuMoon, LuSun } from 'react-icons/lu';
+
+/**
+ * Floating debug toggle for swapping light / dark themes. Set
+ * NEXT_PUBLIC_SHOW_COLOR_MODE_TOGGLE=true to render it; defaults to dev only.
+ */
+export function ColorModeToggle() {
+ const { colorMode, toggleColorMode } = useColorMode();
+ // Hidden by default. Set NEXT_PUBLIC_SHOW_COLOR_MODE_TOGGLE=true to
+ // surface it (in dev or prod) when you need to debug theme swaps.
+ const show = process.env.NEXT_PUBLIC_SHOW_COLOR_MODE_TOGGLE === 'true';
+ if (!show) return null;
+ return (
+ : }
+ onClick={toggleColorMode}
+ position="fixed"
+ top={4}
+ left={4}
+ zIndex={9999}
+ size="md"
+ isRound
+ bg="accent"
+ color="white"
+ boxShadow="0 8px 24px rgba(0,0,0,0.25)"
+ _hover={{ bg: 'accent' }}
+ />
+ );
+}
diff --git a/cross-app-connect/src/app/components/ForceColorMode.tsx b/cross-app-connect/src/app/components/ForceColorMode.tsx
new file mode 100644
index 00000000..510368ef
--- /dev/null
+++ b/cross-app-connect/src/app/components/ForceColorMode.tsx
@@ -0,0 +1,20 @@
+'use client';
+
+import { useEffect } from 'react';
+import { useColorMode } from '@chakra-ui/react';
+
+/**
+ * Force Chakra into a specific color mode regardless of what's cached in
+ * localStorage. Chakra's ColorModeScript reads `chakra-ui-color-mode` from
+ * storage before our theme config can take effect, so a user who toggled
+ * dark earlier in the session keeps seeing dark after we change theme.
+ * This component overrides the cached value on mount so light-mode stays
+ * locked while we evaluate.
+ */
+export function ForceColorMode({ mode }: { mode: 'light' | 'dark' }) {
+ const { colorMode, setColorMode } = useColorMode();
+ useEffect(() => {
+ if (colorMode !== mode) setColorMode(mode);
+ }, [colorMode, mode, setColorMode]);
+ return null;
+}
diff --git a/cross-app-connect/src/app/components/RequesterChip.tsx b/cross-app-connect/src/app/components/RequesterChip.tsx
new file mode 100644
index 00000000..ab5b291b
--- /dev/null
+++ b/cross-app-connect/src/app/components/RequesterChip.tsx
@@ -0,0 +1,129 @@
+'use client';
+
+import { useState } from 'react';
+import { HStack, Icon, Image, Text, Tooltip } from '@chakra-ui/react';
+import {
+ LuCircleCheck,
+ LuGlobe,
+ LuLockKeyhole,
+ LuTriangleAlert,
+} from 'react-icons/lu';
+import { lookupAppByUrl } from '../cross-app/_lib/app-hub';
+
+type Props = {
+ url: string;
+};
+
+/**
+ * Identifies the dApp asking to connect. Three signals stacked on the chip:
+ *
+ * 1. HTTPS lock -- raw transport security check.
+ * 2. Favicon -- visual recognition cue from the requester's domain.
+ * 3. Verified badge -- match against vechain/app-hub registry. Listed
+ * apps render their canonical Name + green check;
+ * everything else gets an orange warning triangle.
+ */
+export function RequesterChip({ url }: Props) {
+ const [iconBroken, setIconBroken] = useState(false);
+ const parsed = safeParseUrl(url);
+ if (!parsed) {
+ return (
+
+ {url}
+
+ );
+ }
+
+ const isSecure = parsed.protocol === 'https:';
+ const display =
+ parsed.port && parsed.port !== '80' && parsed.port !== '443'
+ ? `${parsed.hostname}:${parsed.port}`
+ : parsed.hostname;
+ const faviconSrc = `https://www.google.com/s2/favicons?domain=${parsed.hostname}&sz=64`;
+ const appHubEntry = lookupAppByUrl(url);
+ const verified = Boolean(appHubEntry);
+
+ return (
+
+
+ {!iconBroken && (
+ setIconBroken(true)}
+ draggable={false}
+ />
+ )}
+
+ {verified ? appHubEntry!.name : display}
+
+ {verified ? (
+
+
+
+
+
+ ) : (
+
+
+
+
+
+ )}
+
+ );
+}
+
+function safeParseUrl(url: string): URL | null {
+ try {
+ return new URL(url);
+ } catch {
+ return null;
+ }
+}
diff --git a/cross-app-connect/src/app/components/VechainHeader.tsx b/cross-app-connect/src/app/components/VechainHeader.tsx
new file mode 100644
index 00000000..61d0f367
--- /dev/null
+++ b/cross-app-connect/src/app/components/VechainHeader.tsx
@@ -0,0 +1,80 @@
+'use client';
+
+import { Box, HStack, Icon, Image, Stack, Text } from '@chakra-ui/react';
+import type { IconType } from 'react-icons';
+import { RequesterChip } from './RequesterChip';
+
+type Props = {
+ title?: string;
+ /**
+ * Optional icon rendered alongside the title. Used on the transact
+ * screen to anchor a security framing (LuShieldCheck / LuShieldAlert /
+ * LuShieldX depending on risk).
+ */
+ titleIcon?: IconType;
+ /**
+ * Color token for the title icon. Defaults to 'accent'. The transact
+ * screen passes 'orange.400' / 'red.400' on cautioned / dangerous
+ * transactions so the icon swaps in tandem with the verb.
+ */
+ titleIconColor?: string;
+ subtitle?: string;
+ /**
+ * Requester dApp's callbackUrl. When provided, renders a chip with the
+ * site's favicon + hostname under the title to identify who's asking
+ * to connect.
+ */
+ requesterUrl?: string;
+};
+
+export function VechainHeader({
+ title = 'Log in to your wallet',
+ titleIcon,
+ titleIconColor = 'accent',
+ subtitle,
+ requesterUrl,
+}: Props) {
+ // Render BOTH logomarks and toggle via Chakra's _light / _dark CSS
+ // pseudo selectors. Avoids the React-state flicker we'd get if we
+ // picked the src from useColorMode() (SSR default vs client value).
+ return (
+
+
+
+
+
+
+ {titleIcon && (
+
+ )}
+
+ {title}
+
+
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+ {requesterUrl && }
+
+
+ );
+}
diff --git a/cross-app-connect/src/app/cross-app/_lib/app-hub.json b/cross-app-connect/src/app/cross-app/_lib/app-hub.json
new file mode 100644
index 00000000..f5a8993b
--- /dev/null
+++ b/cross-app-connect/src/app/cross-app/_lib/app-hub.json
@@ -0,0 +1,734 @@
+{
+ "https://nubila.ai": {
+ "slug": "ai.nubila",
+ "name": "Nubila",
+ "category": "defi",
+ "href": "https://nubila.ai/vbd"
+ },
+ "https://gaspump.cc": {
+ "slug": "cc.gaspump",
+ "name": "Gas Pump",
+ "category": "defi",
+ "href": "https://gaspump.cc"
+ },
+ "https://play2048x.com": {
+ "slug": "com.2048x.2048x",
+ "name": "2048x",
+ "category": "games",
+ "href": "https://play2048x.com"
+ },
+ "https://b3ttery.hyenaworks.xyz": {
+ "slug": "com.b3ttery.hyenaworks.xyz",
+ "name": "B3TTERY",
+ "category": "defi",
+ "href": "https://b3ttery.hyenaworks.xyz/"
+ },
+ "https://www.b3dtime.com": {
+ "slug": "com.b3dtime",
+ "name": "B3DTIME",
+ "category": "defi",
+ "href": "https://www.b3dtime.com"
+ },
+ "https://b3trtransit.com": {
+ "slug": "com.b3trtransit",
+ "name": "B3TR Transit",
+ "category": "defi",
+ "href": "https://b3trtransit.com"
+ },
+ "https://1mpxl.site": {
+ "slug": "com.1mpxl.site",
+ "name": "1M PXL",
+ "category": "defi",
+ "href": "https://1mpxl.site/"
+ },
+ "https://bybgym.com": {
+ "slug": "com.bybgym",
+ "name": "BYB - Build Your Body",
+ "category": "defi",
+ "href": "https://bybgym.com"
+ },
+ "https://theshits.art": {
+ "slug": "art.theshits",
+ "name": "The Shts",
+ "category": "collectibles",
+ "href": "https://theshits.art"
+ },
+ "https://vechain.bike": {
+ "slug": "com.bikademy.app",
+ "name": "BIKADEMY",
+ "category": "defi",
+ "href": "https://vechain.bike/"
+ },
+ "https://recirclerewards.app": {
+ "slug": "app.recirclerewards",
+ "name": "ReCircle",
+ "category": "utilities",
+ "href": "https://recirclerewards.app"
+ },
+ "https://app.cleanmatedao.com": {
+ "slug": "com.cleanmatedao.app",
+ "name": "Cleanmate",
+ "category": "utilities",
+ "href": "https://app.cleanmatedao.com"
+ },
+ "https://h5.eatup.vet": {
+ "slug": "com.eatup.vet",
+ "name": "Eat Up",
+ "category": "defi",
+ "href": "https://h5.eatup.vet/"
+ },
+ "https://myvechain.com": {
+ "slug": "com.myvechain",
+ "name": "myVeChain",
+ "category": "utilities",
+ "href": "https://myvechain.com"
+ },
+ "https://minomob.com": {
+ "slug": "com.minomob",
+ "name": "Mino Mob",
+ "category": "collectibles",
+ "href": "https://minomob.com"
+ },
+ "https://avoco.app": {
+ "slug": "com.likapa.avoco",
+ "name": "Avoco",
+ "category": "defi",
+ "href": "https://avoco.app"
+ },
+ "https://greenambassadorchallenge.com": {
+ "slug": "com.greenambassadorchallenge",
+ "name": "Green Ambassador Challenge",
+ "category": "games",
+ "href": "https://greenambassadorchallenge.com/"
+ },
+ "https://gopaperoo.xyz": {
+ "slug": "com.gopaperoo.xyz",
+ "name": "GoPaperoo!",
+ "category": "defi",
+ "href": "https://gopaperoo.xyz/"
+ },
+ "https://pauseyourcarbon.com": {
+ "slug": "com.pauseyourcarbon",
+ "name": "Pause your carbon",
+ "category": "defi",
+ "href": "https://pauseyourcarbon.com/"
+ },
+ "https://www.nfbclub.com": {
+ "slug": "com.nfbc",
+ "name": "Non-Fungible Book Club",
+ "category": "marketplaces",
+ "href": "https://www.nfbclub.com/"
+ },
+ "https://www.groncard.com": {
+ "slug": "com.groncard.www",
+ "name": "GronCard",
+ "category": "defi",
+ "href": "https://www.groncard.com"
+ },
+ "https://mvanfts.com": {
+ "slug": "com.mvanfts",
+ "name": "MVA Fight Club",
+ "category": "games",
+ "href": "https://mvanfts.com"
+ },
+ "https://3dables.smuzzies.com": {
+ "slug": "com.smuzzies.3dables",
+ "name": "3DAbles",
+ "category": "collectibles",
+ "href": "https://3dables.smuzzies.com/"
+ },
+ "https://plant2earn.xyz": {
+ "slug": "com.plant2earn.xyz",
+ "name": "Plant To Earn",
+ "category": "defi",
+ "href": "https://plant2earn.xyz"
+ },
+ "https://snapbox.online": {
+ "slug": "com.snapbox.online",
+ "name": "SnapBox",
+ "category": "defi",
+ "href": "https://snapbox.online"
+ },
+ "https://insight.vecha.in": {
+ "slug": "com.vechain.insight",
+ "name": "Insight",
+ "category": "utilities",
+ "href": "https://insight.vecha.in/"
+ },
+ "https://thesagaz.com": {
+ "slug": "com.thesagaz",
+ "name": "Sagaz",
+ "category": "collectibles",
+ "href": "https://thesagaz.com"
+ },
+ "https://bmac.vecha.in": {
+ "slug": "com.vechain.bmac",
+ "name": "Buy me a coffee",
+ "category": "utilities",
+ "href": "https://bmac.vecha.in/"
+ },
+ "https://vevote.vechain.org": {
+ "slug": "com.vechain.vevote",
+ "name": "VeVote",
+ "category": "utilities",
+ "href": "https://vevote.vechain.org/"
+ },
+ "https://mysteryboxes.contest.vebetter.com": {
+ "slug": "com.vechain.mysteryboxes",
+ "name": "Mystery Boxes",
+ "category": "games",
+ "href": "https://mysteryboxes.contest.vebetter.com/"
+ },
+ "https://win.vet": {
+ "slug": "com.vechain.vewin",
+ "name": "VeWin",
+ "category": "games",
+ "href": "https://win.vet"
+ },
+ "https://inspector.vecha.in": {
+ "slug": "com.vechain.inspector",
+ "name": "Inspector",
+ "category": "utilities",
+ "href": "https://inspector.vecha.in"
+ },
+ "https://vaiyansworld.com": {
+ "slug": "com.vaiyansworld",
+ "name": "VaiyansWorld",
+ "category": "collectibles",
+ "href": "https://vaiyansworld.com"
+ },
+ "https://manager.vechainstats.com": {
+ "slug": "com.vechainstats.manager",
+ "name": "Manager",
+ "category": "utilities",
+ "href": "https://manager.vechainstats.com/"
+ },
+ "https://vechainstats.com": {
+ "slug": "com.vechainstats",
+ "name": "VeChainStats",
+ "category": "utilities",
+ "href": "https://vechainstats.com/"
+ },
+ "https://thorhead.com": {
+ "slug": "com.thorhead",
+ "name": "Thorhead",
+ "category": "collectibles",
+ "href": "https://thorhead.com"
+ },
+ "https://vefam.com": {
+ "slug": "com.vefam.pixelpuffs",
+ "name": "PixelPuffs NFT",
+ "category": "collectibles",
+ "href": "https://vefam.com/projects/pixel-puffs/"
+ },
+ "https://www.veproof.com": {
+ "slug": "com.veproof",
+ "name": "VeProof",
+ "category": "utilities",
+ "href": "https://www.veproof.com"
+ },
+ "https://vyvo.com": {
+ "slug": "com.vyvo.www",
+ "name": "Vyvo",
+ "category": "defi",
+ "href": "https://vyvo.com"
+ },
+ "https://vpunks.com": {
+ "slug": "com.vpunks",
+ "name": "VPunks",
+ "category": "collectibles",
+ "href": "https://vpunks.com"
+ },
+ "https://app.verocket.com": {
+ "slug": "com.verocket.app",
+ "name": "VeRocket (ZumoSwap)",
+ "category": "defi",
+ "href": "https://app.verocket.com"
+ },
+ "https://walkie.space": {
+ "slug": "com.walkie.space",
+ "name": "Walkie",
+ "category": "defi",
+ "href": "https://walkie.space"
+ },
+ "https://wattly-app.com": {
+ "slug": "com.wattly-app",
+ "name": "Wattly",
+ "category": "defi",
+ "href": "https://wattly-app.com"
+ },
+ "https://worldofv.art": {
+ "slug": "com.worldofv.marketplace-nft",
+ "name": "WoV Marketplace",
+ "category": "marketplaces",
+ "href": "https://worldofv.art/"
+ },
+ "https://staking.worldofv.art": {
+ "slug": "com.worldofv.staking",
+ "name": "WoV Staking",
+ "category": "defi",
+ "href": "https://staking.worldofv.art/"
+ },
+ "https://vehashes.club": {
+ "slug": "com.worldofv.vehashes",
+ "name": "VeHashes",
+ "category": "collectibles",
+ "href": "https://vehashes.club/"
+ },
+ "https://vet.domains": {
+ "slug": "domains.vet",
+ "name": "vet.domains",
+ "category": "utilities",
+ "href": "https://vet.domains/"
+ },
+ "https://ramp.vechain.energy": {
+ "slug": "energy.vechain.ramp",
+ "name": "Fiat On-Ramp",
+ "category": "defi",
+ "href": "https://ramp.vechain.energy/"
+ },
+ "https://swap.vechain.energy": {
+ "slug": "energy.vechain.swap",
+ "name": "Token Swap / Exchange",
+ "category": "defi",
+ "href": "https://swap.vechain.energy/"
+ },
+ "https://wipe.tools.vechain.energy": {
+ "slug": "energy.vechain.tools.wipe",
+ "name": "Empty Wallet",
+ "category": "utilities",
+ "href": "https://wipe.tools.vechain.energy/"
+ },
+ "https://bangzboardz.turtlelabs.finance": {
+ "slug": "finance.turtlelabs.bangzboardz",
+ "name": "Bangz Boardz",
+ "category": "defi",
+ "href": "https://BangzBoardz.TurtleLabs.Finance"
+ },
+ "https://revoke.vechain.energy": {
+ "slug": "energy.vechain.revoke",
+ "name": "Revoke Management",
+ "category": "defi",
+ "href": "https://revoke.vechain.energy/"
+ },
+ "https://vechain.energy": {
+ "slug": "energy.vechain",
+ "name": "vechain.energy · blockchain development platform",
+ "category": "utilities",
+ "href": "https://vechain.energy/"
+ },
+ "https://vtho.exchange": {
+ "slug": "exchange.vtho",
+ "name": "VTHO Exchange",
+ "category": "defi",
+ "href": "https://vtho.exchange/"
+ },
+ "https://clayworld.turtlelabs.finance": {
+ "slug": "finance.turtlelabs.clayworld",
+ "name": "Clay World",
+ "category": "collectibles",
+ "href": "https://clayworld.TurtleLabs.Finance"
+ },
+ "https://pepeplug.turtlelabs.finance": {
+ "slug": "finance.turtlelabs.pepeplug",
+ "name": "Pepe Plugs",
+ "category": "collectibles",
+ "href": "https://pepeplug.turtlelabs.finance/"
+ },
+ "https://dreamchicks.turtlelabs.finance": {
+ "slug": "finance.turtlelabs.dreamchicks",
+ "name": "Dream Chicks",
+ "category": "collectibles",
+ "href": "https://dreamchicks.TurtleLabs.Finance"
+ },
+ "https://psychobeasts.turtlelabs.finance": {
+ "slug": "finance.turtlelabs.psychobeasts",
+ "name": "Psycho Beasts",
+ "category": "collectibles",
+ "href": "https://psychobeasts.TurtleLabs.Finance"
+ },
+ "https://turtleswap.turtlelabs.finance": {
+ "slug": "finance.turtlelabs.turtleswap",
+ "name": "Turtle Swap",
+ "category": "defi",
+ "href": "https://TurtleSwap.TurtleLabs.Finance"
+ },
+ "https://squadvechain.fun": {
+ "slug": "fun.squadvechain",
+ "name": "SQUAD VeChain",
+ "category": "defi",
+ "href": "https://SquadVechain.fun/"
+ },
+ "https://vearn.finance": {
+ "slug": "finance.vearn",
+ "name": "Vearn Finance",
+ "category": "defi",
+ "href": "https://vearn.finance"
+ },
+ "https://squadtc.fun": {
+ "slug": "fun.squadtc",
+ "name": "Squad Trading Cards Dapp",
+ "category": "collectibles",
+ "href": "https://squadtc.fun"
+ },
+ "https://www.betterswap.io": {
+ "slug": "io.betterswap",
+ "name": "BetterSwap",
+ "category": "defi",
+ "href": "https://www.betterswap.io"
+ },
+ "https://app.dappies.io": {
+ "slug": "io.dappies",
+ "name": "Dappies",
+ "category": "defi",
+ "href": "https://app.dappies.io"
+ },
+ "https://dthor.io": {
+ "slug": "io.dthor",
+ "name": "DThor Swap",
+ "category": "defi",
+ "href": "https://dthor.io/#/?network=vechain&from=apphub"
+ },
+ "https://bubbles.green": {
+ "slug": "green.bubbles",
+ "name": "Bubbles",
+ "category": "defi",
+ "href": "https://bubbles.green"
+ },
+ "https://evearn.io": {
+ "slug": "io.evearn",
+ "name": "Evearn",
+ "category": "defi",
+ "href": "https://evearn.io"
+ },
+ "https://gangstergorillaz.io": {
+ "slug": "io.gangstergorillaz",
+ "name": "Gangster Gorillaz.",
+ "category": "collectibles",
+ "href": "https://gangstergorillaz.io/"
+ },
+ "https://app.juicyfinance.io": {
+ "slug": "io.juicyfinance",
+ "name": "Juicy Finance",
+ "category": "defi",
+ "href": "https://app.juicyfinance.io"
+ },
+ "https://vebetter.stellapay.io": {
+ "slug": "io.stellapay.vebetter",
+ "name": "Stella Pay",
+ "category": "utilities",
+ "href": "https://vebetter.stellapay.io/"
+ },
+ "https://realitems.io": {
+ "slug": "io.realitems",
+ "name": "Real Items",
+ "category": "utilities",
+ "href": "https://realitems.io"
+ },
+ "https://app.safeswap.io": {
+ "slug": "io.safeswap.app",
+ "name": "SafeSwap",
+ "category": "defi",
+ "href": "https://app.safeswap.io"
+ },
+ "https://vestation.io": {
+ "slug": "io.vestation",
+ "name": "VeStation",
+ "category": "defi",
+ "href": "https://vestation.io/"
+ },
+ "https://veswap.io": {
+ "slug": "io.veswap",
+ "name": "VeSwap",
+ "category": "defi",
+ "href": "https://veswap.io/"
+ },
+ "https://disperse.me": {
+ "slug": "me.disperse",
+ "name": "Disperse",
+ "category": "utilities",
+ "href": "https://disperse.me"
+ },
+ "https://www.mendify.me": {
+ "slug": "me.mendify",
+ "name": "Mendify",
+ "category": "defi",
+ "href": "https://www.mendify.me"
+ },
+ "https://nopaper.life": {
+ "slug": "life.nopaper",
+ "name": "NoPaper",
+ "category": "defi",
+ "href": "https://nopaper.life"
+ },
+ "https://envelop.favo.org": {
+ "slug": "org.favo.envelop",
+ "name": "Message Exchange",
+ "category": "utilities",
+ "href": "https://envelop.favo.org/"
+ },
+ "https://bridge.wanchain.org": {
+ "slug": "org.bridge.wanchain",
+ "name": "WanBridge",
+ "category": "defi",
+ "href": "https://bridge.wanchain.org/"
+ },
+ "https://dapp.carbonlarity.org": {
+ "slug": "org.carbonlarity.dapp",
+ "name": "Carbonlarity",
+ "category": "defi",
+ "href": "https://dapp.carbonlarity.org"
+ },
+ "https://redeno.org": {
+ "slug": "org.redeno",
+ "name": "Redeno",
+ "category": "defi",
+ "href": "https://redeno.org/"
+ },
+ "https://hangndry.org": {
+ "slug": "org.hangndry",
+ "name": "HangnDry",
+ "category": "defi",
+ "href": "https://hangndry.org"
+ },
+ "https://thearborapp.org": {
+ "slug": "org.thearborapp",
+ "name": "Arbor",
+ "category": "utilities",
+ "href": "https://thearborapp.org/"
+ },
+ "https://connect.vebetterdao.org": {
+ "slug": "org.vebetterdao.connect",
+ "name": "VeChain Discord Connect",
+ "category": "utilities",
+ "href": "https://connect.vebetterdao.org"
+ },
+ "https://relayers.vebetterdao.org": {
+ "slug": "org.vebetterdao.relayers",
+ "name": "VeBetter Relayers",
+ "category": "utilities",
+ "href": "https://relayers.vebetterdao.org"
+ },
+ "https://governance.vebetterdao.org": {
+ "slug": "org.vebetterdao.governance",
+ "name": "VeBetter",
+ "category": "defi",
+ "href": "https://governance.vebetterdao.org"
+ },
+ "https://solarwise.marketplace.vechain.org": {
+ "slug": "org.vechain.marketplace.solarwise",
+ "name": "Solarwise Marketplace",
+ "category": "marketplaces",
+ "href": "https://solarwise.marketplace.vechain.org/"
+ },
+ "https://nfbclub.marketplace.vechain.org": {
+ "slug": "org.vechain.marketplace.nfbc",
+ "name": "Non-Fungible Book Club Marketplace",
+ "category": "marketplaces",
+ "href": "https://nfbclub.marketplace.vechain.org/"
+ },
+ "https://app.stargate.vechain.org": {
+ "slug": "org.vechain.stargate.app",
+ "name": "StarGate",
+ "category": "utilities",
+ "href": "https://app.stargate.vechain.org/"
+ },
+ "https://stargate.marketplace.vechain.org": {
+ "slug": "org.vechain.marketplace.stargate",
+ "name": "StarGate Marketplace",
+ "category": "marketplaces",
+ "href": "https://stargate.marketplace.vechain.org/"
+ },
+ "https://app.rewards.vechain.org": {
+ "slug": "org.vechain.rewards.app",
+ "name": "vechain Rewards dApp",
+ "category": "defi",
+ "href": "https://app.rewards.vechain.org"
+ },
+ "https://b3trsmile.site": {
+ "slug": "site.b3trsmile",
+ "name": "B3TR Smile",
+ "category": "defi",
+ "href": "https://b3trsmile.site"
+ },
+ "https://vechainkit.vechain.org": {
+ "slug": "org.vechain.vechainkit",
+ "name": "VeChain Kit",
+ "category": "utilities",
+ "href": "https://vechainkit.vechain.org/"
+ },
+ "https://vechain.github.io": {
+ "slug": "smart-accounts.io.github.vechain",
+ "name": "VeChain Smart Accounts",
+ "category": "utilities",
+ "href": "https://vechain.github.io/smart-accounts"
+ },
+ "https://app.glodollar.org": {
+ "slug": "org.glodollar.app",
+ "name": "Glo Dollar App",
+ "category": "defi",
+ "href": "https://app.glodollar.org"
+ },
+ "https://games.sproutlyrwa.com": {
+ "slug": "sproutly.inc.vet",
+ "name": "Sproutly Inc.",
+ "category": "utilities",
+ "href": "https://games.sproutlyrwa.com/get-started"
+ },
+ "https://betterbag.vet": {
+ "slug": "vet.betterbag",
+ "name": "BetterBag",
+ "category": "defi",
+ "href": "https://betterbag.vet"
+ },
+ "https://bettermode.vet": {
+ "slug": "vet.bettermode",
+ "name": "BetterMode",
+ "category": "defi",
+ "href": "https://bettermode.vet"
+ },
+ "https://app.bigbottle.vet": {
+ "slug": "vet.bigbottle.app",
+ "name": "BigBottle",
+ "category": "defi",
+ "href": "https://app.bigbottle.vet"
+ },
+ "https://app.bitegram.vet": {
+ "slug": "vet.bitegram",
+ "name": "BiteGram",
+ "category": "defi",
+ "href": "https://app.bitegram.vet"
+ },
+ "https://bridge.vet": {
+ "slug": "vet.bridge",
+ "name": "bridge.vet",
+ "category": "defi",
+ "href": "https://bridge.vet/"
+ },
+ "https://app.byebyebites.vet": {
+ "slug": "vet.byebyebites",
+ "name": "Bye Bye Bites",
+ "category": "defi",
+ "href": "https://app.byebyebites.vet"
+ },
+ "https://ecomeal.vet": {
+ "slug": "vet.ecomeal",
+ "name": "EcoMeal",
+ "category": "defi",
+ "href": "https://ecomeal.vet"
+ },
+ "https://ecobag.solutions": {
+ "slug": "vet.ecobag",
+ "name": "ecobag",
+ "category": "defi",
+ "href": "https://ecobag.solutions/"
+ },
+ "https://app.cleanify.vet": {
+ "slug": "vet.cleanify",
+ "name": "Cleanify",
+ "category": "defi",
+ "href": "https://app.cleanify.vet"
+ },
+ "https://app.greencart.ai": {
+ "slug": "vet.greencart",
+ "name": "Greencart",
+ "category": "defi",
+ "href": "https://app.greencart.ai"
+ },
+ "https://justote.vet": {
+ "slug": "vet.justote",
+ "name": "JusTote",
+ "category": "defi",
+ "href": "https://justote.vet"
+ },
+ "https://metermate.vet": {
+ "slug": "vet.metermate",
+ "name": "MeterMate",
+ "category": "defi",
+ "href": "https://metermate.vet"
+ },
+ "https://nanoact.vet": {
+ "slug": "vet.nanoact",
+ "name": "NanoAct",
+ "category": "defi",
+ "href": "https://nanoact.vet"
+ },
+ "https://mugshot.vet": {
+ "slug": "vet.mugshot",
+ "name": "Mugshot",
+ "category": "defi",
+ "href": "https://mugshot.vet"
+ },
+ "https://app.powerup.vet": {
+ "slug": "vet.powerup.app",
+ "name": "Power Up!",
+ "category": "defi",
+ "href": "https://app.powerup.vet"
+ },
+ "https://restifyapp.org": {
+ "slug": "vet.restify",
+ "name": "Restify",
+ "category": "games",
+ "href": "https://restifyapp.org/"
+ },
+ "https://scoopup.vet": {
+ "slug": "vet.scoopup",
+ "name": "ScoopUp",
+ "category": "defi",
+ "href": "https://scoopup.vet"
+ },
+ "https://app.solarwise.vet": {
+ "slug": "vet.solarwise.app",
+ "name": "Solarwise",
+ "category": "defi",
+ "href": "https://app.solarwise.vet"
+ },
+ "https://vedelegate.vet": {
+ "slug": "vet.vedelegate",
+ "name": "veDelegate.vet",
+ "category": "defi",
+ "href": "https://vedelegate.vet"
+ },
+ "https://velottery.vet": {
+ "slug": "vet.velottery",
+ "name": "VeLottery",
+ "category": "games",
+ "href": "https://velottery.vet/"
+ },
+ "https://st3pr.vet": {
+ "slug": "vet.st3pr",
+ "name": "ST3PR",
+ "category": "defi",
+ "href": "https://st3pr.vet"
+ },
+ "https://app.trashdash.vet": {
+ "slug": "vet.trashdash",
+ "name": "TrashDash",
+ "category": "defi",
+ "href": "https://app.trashdash.vet"
+ },
+ "https://verecycle.vet": {
+ "slug": "vet.verecycle",
+ "name": "VeRecycle",
+ "category": "defi",
+ "href": "https://verecycle.vet"
+ },
+ "https://vetrade.vet": {
+ "slug": "vet.vetrade",
+ "name": "VeTrade",
+ "category": "defi",
+ "href": "https://vetrade.vet/"
+ },
+ "https://www.velottery.xyz": {
+ "slug": "xyz.velottery",
+ "name": "VeLottery",
+ "category": "games",
+ "href": "https://www.velottery.xyz/"
+ },
+ "https://bubblycaps.xyz": {
+ "slug": "xyz.bubblycaps",
+ "name": "Bubbly Caps Dapp",
+ "category": "collectibles",
+ "href": "https://bubblycaps.xyz"
+ }
+}
diff --git a/cross-app-connect/src/app/cross-app/_lib/app-hub.ts b/cross-app-connect/src/app/cross-app/_lib/app-hub.ts
new file mode 100644
index 00000000..6b5865ae
--- /dev/null
+++ b/cross-app-connect/src/app/cross-app/_lib/app-hub.ts
@@ -0,0 +1,28 @@
+/**
+ * Runtime lookup against the snapshot of vechain/app-hub baked at build time.
+ * Refresh the snapshot with:
+ *
+ * yarn workspace cross-app-connect generate-app-hub
+ *
+ * (the script lives at cross-app-connect/scripts/fetch-app-hub.mjs).
+ */
+import data from './app-hub.json';
+
+export type AppHubEntry = {
+ slug: string;
+ name: string;
+ category: string | null;
+ href: string;
+};
+
+const REGISTRY = data as Record;
+
+export function lookupAppByUrl(url: string | undefined): AppHubEntry | null {
+ if (!url) return null;
+ try {
+ const origin = new URL(url).origin;
+ return REGISTRY[origin] ?? null;
+ } catch {
+ return null;
+ }
+}
diff --git a/cross-app-connect/src/app/cross-app/_lib/client.ts b/cross-app-connect/src/app/cross-app/_lib/client.ts
new file mode 100644
index 00000000..1a3d1695
--- /dev/null
+++ b/cross-app-connect/src/app/cross-app/_lib/client.ts
@@ -0,0 +1,21 @@
+'use client';
+
+import { useMemo } from 'react';
+import { createClient } from '@privy-io/cross-app-provider/connect';
+
+export const useCrossAppClient = () =>
+ useMemo(() => {
+ const privyDomain = process.env.NEXT_PUBLIC_PRIVY_DOMAIN;
+ if (!privyDomain) {
+ throw new Error(
+ 'NEXT_PUBLIC_PRIVY_DOMAIN is required. Set it to the whitelabel ' +
+ 'auth subdomain provisioned in the Privy dashboard (e.g. ' +
+ 'https://privy.your-app.privy.dev).',
+ );
+ }
+ return createClient({
+ appId: process.env.NEXT_PUBLIC_PRIVY_APP_ID!,
+ appClientId: process.env.NEXT_PUBLIC_PRIVY_CLIENT_ID,
+ privyDomain,
+ });
+ }, []);
diff --git a/cross-app-connect/src/app/cross-app/_lib/contracts.ts b/cross-app-connect/src/app/cross-app/_lib/contracts.ts
new file mode 100644
index 00000000..63e58918
--- /dev/null
+++ b/cross-app-connect/src/app/cross-app/_lib/contracts.ts
@@ -0,0 +1,77 @@
+/**
+ * Resolve a raw address into a human-readable contract label so the user
+ * can tell a real contract from a phishing spoof. The registry walks the
+ * kit's `appConfig` (which already has every VeChain-maintained contract
+ * address keyed by name) and maps each field to a friendly label.
+ *
+ * Verified = present in the kit's appConfig. Anything else gets an
+ * "Unverified" treatment so the UI shows a warning rather than silently
+ * rendering a truncated hex string.
+ */
+import type { AppConfig } from '@vechain/vechain-kit';
+
+export type ContractLabel = {
+ label: string;
+ verified: boolean;
+};
+
+const APP_CONFIG_LABELS: Partial> = {
+ vthoContractAddress: 'VTHO Token',
+ b3trContractAddress: 'B3TR Token',
+ vot3ContractAddress: 'VOT3 Token',
+ b3trGovernorAddress: 'VeBetter Governor',
+ timelockContractAddress: 'Timelock',
+ xAllocationPoolContractAddress: 'X-Allocation Pool',
+ xAllocationVotingContractAddress: 'X-Allocation Voting',
+ emissionsContractAddress: 'Emissions',
+ voterRewardsContractAddress: 'Voter Rewards',
+ galaxyMemberContractAddress: 'Galaxy Member',
+ treasuryContractAddress: 'VeBetter Treasury',
+ x2EarnAppsContractAddress: 'X2Earn Apps Registry',
+ x2EarnCreatorContractAddress: 'X2Earn Creator',
+ x2EarnRewardsPoolContractAddress: 'X2Earn Rewards Pool',
+ nodeManagementContractAddress: 'Node Management',
+ veBetterPassportContractAddress: 'VeBetter Passport',
+ veDelegateTokenContractAddress: 'veDelegate Token',
+ oracleContractAddress: 'Oracle',
+ accountFactoryAddress: 'Smart Account Factory',
+ cleanifyCampaignsContractAddress: 'Cleanify Campaigns',
+ cleanifyChallengesContractAddress: 'Cleanify Challenges',
+ veWorldSubdomainClaimerContractAddress: 'VeWorld Subdomain Claimer',
+ vetDomainsContractAddress: 'VeChain Domains',
+ vetDomainsPublicResolverAddress: 'VeChain Domains Resolver',
+ vetDomainsReverseRegistrarAddress: 'VeChain Domains Reverse Registrar',
+ vnsResolverAddress: 'VNS Resolver',
+ sassContractAddress: 'SASS Token',
+ vvetContractAddress: 'vVET',
+ stargateContractAddress: 'Stargate',
+ stargateNftContractAddress: 'Stargate NFT',
+ veDelegate: 'veDelegate',
+ veDelegateVotes: 'veDelegate Votes',
+};
+
+export function resolveContractLabel(
+ address: string | undefined,
+ appConfig: AppConfig | undefined,
+ self?: string,
+): ContractLabel | null {
+ if (!address) return null;
+ const lower = address.toLowerCase();
+
+ if (self && lower === self.toLowerCase()) {
+ return { label: 'Your account', verified: true };
+ }
+
+ if (!appConfig) return null;
+
+ for (const [field, label] of Object.entries(APP_CONFIG_LABELS) as Array<
+ [keyof AppConfig, string]
+ >) {
+ const value = appConfig[field];
+ if (typeof value === 'string' && value.toLowerCase() === lower) {
+ return { label, verified: true };
+ }
+ }
+
+ return null;
+}
diff --git a/cross-app-connect/src/app/cross-app/_lib/decoder.ts b/cross-app-connect/src/app/cross-app/_lib/decoder.ts
new file mode 100644
index 00000000..612a2947
--- /dev/null
+++ b/cross-app-connect/src/app/cross-app/_lib/decoder.ts
@@ -0,0 +1,296 @@
+/**
+ * Translates raw VeChain clauses into plain-language summaries the average
+ * (non-crypto) user can understand. Three layers:
+ *
+ * 1. Native VET transfer (no calldata, value > 0) -> "Send X VET".
+ * 2. ERC-20 transfer / approve (4-byte selector match) -> "Send X B3TR",
+ * "Allow up to Y USDC", or "Allow unlimited B3TR spending". Token symbol
+ * and decimals come from the kit's address book (B3TR / VOT3 / VTHO) or
+ * a live Thor read on the token's ERC-20 metadata, whichever resolves
+ * first.
+ * 3. Anything else -> b32 lookup at https://b32.vecha.in/ for a human-
+ * readable function name; falls back to "Interact with contract" if
+ * the selector is unknown.
+ *
+ * The transact page treats any 'unknown' result as a "couldn't be checked"
+ * warning so equally-loud rows don't make malicious calls look as safe as
+ * benign ones.
+ */
+import {
+ decodeFunctionData,
+ formatUnits,
+ parseAbi,
+ isAddress,
+} from 'viem';
+import type { ThorClient } from '@vechain/sdk-network';
+import { executeCallClause } from '@vechain/vechain-kit/utils';
+import { type NETWORK_TYPE, getConfig } from './network-tokens';
+
+const ERC20_ABI = parseAbi([
+ 'function transfer(address to, uint256 amount)',
+ 'function approve(address spender, uint256 amount)',
+ 'function transferFrom(address from, address to, uint256 amount)',
+]);
+
+const ERC20_METADATA_ABI = parseAbi([
+ 'function symbol() view returns (string)',
+ 'function decimals() view returns (uint8)',
+]);
+
+const SELECTOR_TRANSFER = '0xa9059cbb';
+const SELECTOR_APPROVE = '0x095ea7b3';
+const SELECTOR_TRANSFER_FROM = '0x23b872dd';
+
+// Treat anything above 2^240 as "unlimited" — covers UI tools that send
+// 2^256-1, 2^255-1, etc. BigInt() constructor (not the `n` suffix) so the
+// kit's ES5 tsconfig target is happy.
+const UNLIMITED_THRESHOLD = BigInt(1) << BigInt(240);
+const ZERO = BigInt(0);
+
+export type Clause = { to: string; value: string; data: string };
+
+export type TokenInfo = {
+ address: string;
+ symbol: string;
+ decimals: number;
+};
+
+export type DecodedClause =
+ | {
+ kind: 'native_transfer';
+ summary: string;
+ recipient: string;
+ amount: string;
+ }
+ | {
+ kind: 'token_transfer';
+ summary: string;
+ token: TokenInfo;
+ recipient: string;
+ amount: string;
+ }
+ | {
+ kind: 'token_approve';
+ summary: string;
+ token: TokenInfo;
+ spender: string;
+ amount: string;
+ unlimited: boolean;
+ }
+ | {
+ kind: 'unknown';
+ summary: string;
+ selector?: string;
+ functionName?: string;
+ signature?: string;
+ };
+
+export async function decodeClause(
+ clause: Clause,
+ thor: ThorClient | null,
+ network: NETWORK_TYPE,
+): Promise {
+ const data = (clause.data ?? '0x').toLowerCase();
+ const value = (() => {
+ try {
+ return BigInt(clause.value || '0');
+ } catch {
+ return ZERO;
+ }
+ })();
+
+ // 1. Native VET transfer
+ if ((data === '0x' || data === '') && value > ZERO) {
+ const amount = formatUnits(value, 18);
+ return {
+ kind: 'native_transfer',
+ recipient: clause.to,
+ amount,
+ summary: `Send ${trimAmount(amount)} VET`,
+ };
+ }
+
+ // 2. ERC-20 transfer / approve
+ if (data.length >= 10 && isAddress(clause.to as `0x${string}`)) {
+ const selector = data.slice(0, 10);
+ if (
+ selector === SELECTOR_TRANSFER ||
+ selector === SELECTOR_APPROVE
+ ) {
+ try {
+ const decoded = decodeFunctionData({
+ abi: ERC20_ABI,
+ data: data as `0x${string}`,
+ });
+ const token = await lookupToken(
+ clause.to,
+ network,
+ thor,
+ );
+ if (decoded.functionName === 'transfer') {
+ const [recipient, raw] = decoded.args as [
+ string,
+ bigint,
+ ];
+ const amount = formatUnits(raw, token.decimals);
+ return {
+ kind: 'token_transfer',
+ recipient,
+ token,
+ amount,
+ summary: `Send ${trimAmount(amount)} ${token.symbol}`,
+ };
+ }
+ if (decoded.functionName === 'approve') {
+ const [spender, raw] = decoded.args as [
+ string,
+ bigint,
+ ];
+ const unlimited = raw >= UNLIMITED_THRESHOLD;
+ const amount = unlimited
+ ? 'unlimited'
+ : formatUnits(raw, token.decimals);
+ return {
+ kind: 'token_approve',
+ spender,
+ token,
+ amount,
+ unlimited,
+ summary: unlimited
+ ? `Allow unlimited ${token.symbol} spending`
+ : `Allow spending up to ${trimAmount(amount)} ${token.symbol}`,
+ };
+ }
+ } catch {
+ // fall through to unknown / b32 lookup
+ }
+ }
+ if (selector === SELECTOR_TRANSFER_FROM) {
+ // Common but messier; classify as unknown so the caller shows
+ // a "couldn't be checked" warning rather than a half-decoded line.
+ }
+ }
+
+ // 3. b32 fallback — at least show the function name when known.
+ if (data.length >= 10) {
+ const selector = data.slice(0, 10);
+ const sig = await fetchB32Signature(selector);
+ if (sig) {
+ const fnName = sig.split('(')[0];
+ return {
+ kind: 'unknown',
+ selector,
+ functionName: fnName,
+ signature: sig,
+ summary: `Run ${humanize(fnName)} on a contract`,
+ };
+ }
+ return {
+ kind: 'unknown',
+ selector,
+ summary: 'Interact with a contract',
+ };
+ }
+
+ return {
+ kind: 'unknown',
+ summary: 'Interact with a contract',
+ };
+}
+
+// Strip trailing zeros and excessive decimals: 10.000000000000000000 -> 10,
+// 0.123456789012345678 -> 0.123456789012345678 (kept as-is for tokens with
+// long fractional parts). Limit to 6 fractional digits for readability.
+function trimAmount(amount: string): string {
+ if (!amount.includes('.')) return amount;
+ const [whole, frac] = amount.split('.');
+ const trimmedFrac = frac.replace(/0+$/, '').slice(0, 6);
+ return trimmedFrac.length === 0 ? whole : `${whole}.${trimmedFrac}`;
+}
+
+function humanize(fnName: string): string {
+ // camelCase -> "camel case", then capitalise.
+ const spaced = fnName.replace(/([A-Z])/g, ' $1').toLowerCase().trim();
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
+}
+
+const b32Cache = new Map();
+
+async function fetchB32Signature(selector: string): Promise {
+ if (b32Cache.has(selector)) return b32Cache.get(selector)!;
+ try {
+ const res = await fetch(`https://b32.vecha.in/q/${selector}.json`, {
+ cache: 'force-cache',
+ });
+ if (!res.ok) {
+ b32Cache.set(selector, null);
+ return null;
+ }
+ const json = (await res.json()) as Array<{ name?: string }>;
+ const name =
+ Array.isArray(json) && json[0]?.name ? json[0].name : null;
+ b32Cache.set(selector, name);
+ return name;
+ } catch {
+ b32Cache.set(selector, null);
+ return null;
+ }
+}
+
+const tokenInfoCache = new Map();
+
+/**
+ * Resolve a token's symbol + decimals. Order:
+ * 1. Static address book (VET, VTHO, B3TR, VOT3 — instant).
+ * 2. In-memory cache from a previous live lookup.
+ * 3. Live Thor read of `symbol()` + `decimals()` on the contract.
+ * 4. Generic "tokens" / 18-decimals fallback if the contract doesn't
+ * implement the standard interface or Thor is unreachable.
+ */
+async function lookupToken(
+ address: string,
+ network: NETWORK_TYPE,
+ thor: ThorClient | null,
+): Promise {
+ const lower = address.toLowerCase();
+ const known = getConfig(network)[lower];
+ if (known) return known;
+ const cached = tokenInfoCache.get(lower);
+ if (cached) return cached;
+ if (!thor) {
+ return { address, symbol: 'tokens', decimals: 18 };
+ }
+ try {
+ const [symbolRes, decimalsRes] = await Promise.all([
+ executeCallClause({
+ thor,
+ contractAddress: address,
+ abi: ERC20_METADATA_ABI,
+ method: 'symbol' as const,
+ args: [],
+ }),
+ executeCallClause({
+ thor,
+ contractAddress: address,
+ abi: ERC20_METADATA_ABI,
+ method: 'decimals' as const,
+ args: [],
+ }),
+ ]);
+ const info: TokenInfo = {
+ address,
+ symbol: String((symbolRes as unknown as [string])[0]),
+ decimals: Number((decimalsRes as unknown as [number])[0]),
+ };
+ tokenInfoCache.set(lower, info);
+ return info;
+ } catch {
+ const fallback: TokenInfo = {
+ address,
+ symbol: 'tokens',
+ decimals: 18,
+ };
+ tokenInfoCache.set(lower, fallback);
+ return fallback;
+ }
+}
diff --git a/cross-app-connect/src/app/cross-app/_lib/labels.ts b/cross-app-connect/src/app/cross-app/_lib/labels.ts
new file mode 100644
index 00000000..3db5b781
--- /dev/null
+++ b/cross-app-connect/src/app/cross-app/_lib/labels.ts
@@ -0,0 +1,151 @@
+/**
+ * Human-readable labels + risk classification for the technical strings the
+ * smart-account flow surfaces. Kept separate from decoder.ts so it stays
+ * trivial to scan and extend when new EIP-712 primary types or action
+ * shapes show up.
+ */
+import type { DecodedClause, TokenInfo } from './decoder';
+
+export type Risk = 'safe' | 'caution' | 'danger';
+
+export function computeRisk(
+ decoded: DecodedClause[] | null,
+ blocked: boolean,
+): Risk {
+ if (blocked) return 'danger';
+ if (!decoded) return 'safe';
+ const hasUnknown = decoded.some((d) => d.kind === 'unknown');
+ const hasUnlimited = decoded.some(
+ (d) => d.kind === 'token_approve' && d.unlimited,
+ );
+ if (hasUnknown && hasUnlimited) return 'danger';
+ if (hasUnknown || hasUnlimited) return 'caution';
+ return 'safe';
+}
+
+/**
+ * Title for the transact card. Specific verbs read better than the previous
+ * generic "Confirm action" -- users land on the page and immediately know
+ * what kind of thing they're about to do.
+ */
+export function titleForActions(
+ decoded: DecodedClause[] | null,
+ blocked: boolean,
+): string {
+ if (blocked) return 'Action blocked';
+ if (!decoded || decoded.length === 0) return 'Confirm action';
+
+ const transfers = decoded.filter(
+ (d) => d.kind === 'native_transfer' || d.kind === 'token_transfer',
+ );
+ const approves = decoded.filter((d) => d.kind === 'token_approve');
+ const unknowns = decoded.filter((d) => d.kind === 'unknown');
+
+ if (transfers.length === decoded.length) return 'Send tokens';
+ if (approves.length === decoded.length) return 'Approve spending';
+ if (unknowns.length === decoded.length) return 'Interact with contract';
+ return `Confirm ${decoded.length} actions`;
+}
+
+/**
+ * Label for the primary CTA. Escalates verb as risk grows so the user is
+ * given more friction the closer they get to signing something we can't
+ * vouch for. The button is disabled outright when phase==='blocked', so the
+ * "danger" copy only fires for non-blocking risk (unknown + unlimited
+ * approve together).
+ */
+export function continueLabel(risk: Risk): string {
+ switch (risk) {
+ case 'safe':
+ return 'Continue';
+ case 'caution':
+ return 'Continue anyway';
+ case 'danger':
+ return 'I understand, continue';
+ }
+}
+
+/**
+ * Distinct token contracts the batch touches. Used by the AccountChip to
+ * fetch live balances for the tokens the user is about to move, so the
+ * header reads "1,240 B3TR · 12 VET" instead of just "12 VET" when the
+ * batch is moving B3TR. Native VET is implicit (the chip always shows it).
+ */
+export function uniqueTokensFromDecoded(
+ decoded: DecodedClause[] | null,
+): TokenInfo[] {
+ if (!decoded) return [];
+ const seen = new Map();
+ for (const d of decoded) {
+ if (d.kind === 'token_transfer' || d.kind === 'token_approve') {
+ const key = d.token.address.toLowerCase();
+ if (!seen.has(key)) seen.set(key, d.token);
+ }
+ }
+ return Array.from(seen.values());
+}
+
+export function humanPrimaryType(value: string): string {
+ switch (value) {
+ case 'ExecuteWithAuthorization':
+ return 'Authorized call';
+ case 'ExecuteBatchWithAuthorization':
+ return 'Authorized batch call';
+ default:
+ // Fallback: SCREAMING_SNAKE -> Title Case, camelCase -> Title Case.
+ return value
+ .replace(/_/g, ' ')
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
+ .toLowerCase()
+ .replace(/(^|\s)\S/g, (m) => m.toUpperCase());
+ }
+}
+
+/**
+ * One-sentence plain-English summary of what the batch does. Read by the
+ * user before they look at the per-clause action list; goal is to dramatically
+ * reduce signing anxiety / support load.
+ */
+export function summarizeActions(decoded: DecodedClause[]): string {
+ if (decoded.length === 0) return 'Nothing to do.';
+
+ const transfers = decoded.filter(
+ (d) => d.kind === 'native_transfer' || d.kind === 'token_transfer',
+ );
+ const approves = decoded.filter((d) => d.kind === 'token_approve');
+ const unknowns = decoded.filter((d) => d.kind === 'unknown');
+ const unlimited = approves.some(
+ (a) => a.kind === 'token_approve' && a.unlimited,
+ );
+
+ // Single-clause shortcuts read most cleanly.
+ if (decoded.length === 1) {
+ const d = decoded[0];
+ if (d.kind === 'native_transfer' || d.kind === 'token_transfer') {
+ return 'You’re about to send tokens out of your wallet.';
+ }
+ if (d.kind === 'token_approve') {
+ return d.unlimited
+ ? 'You’re giving this app unlimited access to one of your tokens.'
+ : 'You’re letting this app spend some of your tokens.';
+ }
+ return 'You’re running an action we couldn’t fully verify.';
+ }
+
+ // Batches.
+ if (unknowns.length > 0 && transfers.length === 0 && approves.length === 0) {
+ return 'You’re running actions we couldn’t fully verify.';
+ }
+ if (transfers.length > 0 && approves.length === 0 && unknowns.length === 0) {
+ return `You’re sending tokens out of your wallet in ${decoded.length} steps.`;
+ }
+ if (approves.length > 0 && transfers.length === 0 && unknowns.length === 0) {
+ return unlimited
+ ? 'You’re giving this app unlimited access to your tokens.'
+ : 'You’re letting this app spend some of your tokens.';
+ }
+ if (unknowns.length > 0) {
+ return `You’re approving ${decoded.length} actions, some of which we couldn’t fully verify.`;
+ }
+ return `You’re approving ${decoded.length} actions on your wallet.`;
+}
diff --git a/cross-app-connect/src/app/cross-app/_lib/network-tokens.ts b/cross-app-connect/src/app/cross-app/_lib/network-tokens.ts
new file mode 100644
index 00000000..edc98cc1
--- /dev/null
+++ b/cross-app-connect/src/app/cross-app/_lib/network-tokens.ts
@@ -0,0 +1,73 @@
+/**
+ * Static address book of the well-known VeChain tokens, lifted from the kit's
+ * config (packages/vechain-kit/src/config/{mainnet,testnet,solo}.ts). Hits
+ * before any live RPC lookup so common cases ("Send 10 B3TR") render
+ * instantly without a network round trip.
+ */
+import type { TokenInfo } from './decoder';
+
+export type NETWORK_TYPE = 'main' | 'test' | 'solo';
+
+const lower = (s: string) => s.toLowerCase();
+
+const VTHO_ADDR = '0x0000000000000000000000000000456E65726779';
+
+const MAINNET: Record = {
+ [lower(VTHO_ADDR)]: {
+ address: VTHO_ADDR,
+ symbol: 'VTHO',
+ decimals: 18,
+ },
+ [lower('0x5ef79995FE8a89e0812330E4378eB2660ceDe699')]: {
+ address: '0x5ef79995FE8a89e0812330E4378eB2660ceDe699',
+ symbol: 'B3TR',
+ decimals: 18,
+ },
+ [lower('0x76Ca782B59C74d088C7D2Cce2f211BC00836c602')]: {
+ address: '0x76Ca782B59C74d088C7D2Cce2f211BC00836c602',
+ symbol: 'VOT3',
+ decimals: 18,
+ },
+};
+
+const TESTNET: Record = {
+ [lower(VTHO_ADDR)]: {
+ address: VTHO_ADDR,
+ symbol: 'VTHO',
+ decimals: 18,
+ },
+ [lower('0x95761346d18244bb91664181bf91193376197088')]: {
+ address: '0x95761346d18244bb91664181bf91193376197088',
+ symbol: 'B3TR',
+ decimals: 18,
+ },
+ [lower('0x6e8b4a88d37897fc11f6ba12c805695f1c41f40e')]: {
+ address: '0x6e8b4a88d37897fc11f6ba12c805695f1c41f40e',
+ symbol: 'VOT3',
+ decimals: 18,
+ },
+};
+
+const SOLO: Record = {
+ [lower(VTHO_ADDR)]: {
+ address: VTHO_ADDR,
+ symbol: 'VTHO',
+ decimals: 18,
+ },
+ [lower('0xd31A6f2DBa8785cE41AB68Ea192791B5175309F4')]: {
+ address: '0xd31A6f2DBa8785cE41AB68Ea192791B5175309F4',
+ symbol: 'B3TR',
+ decimals: 18,
+ },
+ [lower('0x028Af33230576c1e073C8245F72a7A4aa53564E4')]: {
+ address: '0x028Af33230576c1e073C8245F72a7A4aa53564E4',
+ symbol: 'VOT3',
+ decimals: 18,
+ },
+};
+
+export function getConfig(network: NETWORK_TYPE): Record {
+ if (network === 'main') return MAINNET;
+ if (network === 'test') return TESTNET;
+ return SOLO;
+}
diff --git a/cross-app-connect/src/app/cross-app/_lib/recent.ts b/cross-app-connect/src/app/cross-app/_lib/recent.ts
new file mode 100644
index 00000000..810646f5
--- /dev/null
+++ b/cross-app-connect/src/app/cross-app/_lib/recent.ts
@@ -0,0 +1,29 @@
+/**
+ * Tracks the most recently used login provider per browser, so the SignInPanel
+ * can surface a "Recent" badge in the next popup. Scoped per app id so a host
+ * pointed at multiple Privy apps doesn't cross-pollute.
+ */
+const KEY_PREFIX = 'vk-cross-app-connect:recent-provider';
+
+function key(): string {
+ const appId = process.env.NEXT_PUBLIC_PRIVY_APP_ID ?? 'default';
+ return `${KEY_PREFIX}:${appId}`;
+}
+
+export function getRecentProvider(): string | null {
+ if (typeof localStorage === 'undefined') return null;
+ try {
+ return localStorage.getItem(key());
+ } catch {
+ return null;
+ }
+}
+
+export function setRecentProvider(providerId: string): void {
+ if (typeof localStorage === 'undefined') return;
+ try {
+ localStorage.setItem(key(), providerId);
+ } catch {
+ // quota / private-browsing — fail open
+ }
+}
diff --git a/cross-app-connect/src/app/cross-app/connect/page.tsx b/cross-app-connect/src/app/cross-app/connect/page.tsx
new file mode 100644
index 00000000..1e172ad9
--- /dev/null
+++ b/cross-app-connect/src/app/cross-app/connect/page.tsx
@@ -0,0 +1,991 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+ Alert,
+ AlertDescription,
+ AlertIcon,
+ Box,
+ Button,
+ Card,
+ CardBody,
+ Center,
+ Container,
+ HStack,
+ Icon,
+ Image,
+ Input,
+ PinInput,
+ PinInputField,
+ Skeleton,
+ SkeletonCircle,
+ Spinner,
+ Stack,
+ Text,
+ Tooltip,
+ useColorMode,
+} from '@chakra-ui/react';
+import { FcGoogle } from 'react-icons/fc';
+import { FaApple, FaDiscord, FaGithub, FaLine, FaTiktok } from 'react-icons/fa';
+import { FaXTwitter } from 'react-icons/fa6';
+import { SiFarcaster } from 'react-icons/si';
+import { LuPhone } from 'react-icons/lu';
+import type { IconType } from 'react-icons';
+import {
+ useLoginWithOAuth,
+ useLoginWithSms,
+ useLogout,
+ usePrivy,
+ useWallets,
+} from '@privy-io/react-auth';
+import {
+ useGetAvatarOfAddress,
+ useSmartAccount,
+ useVechainDomain,
+} from '@vechain/vechain-kit';
+import { useCrossAppClient } from '../_lib/client';
+import { lookupAppByUrl } from '../_lib/app-hub';
+import { getRecentProvider, setRecentProvider } from '../_lib/recent';
+import { VechainHeader } from '../../components/VechainHeader';
+
+type ConnectionRequest = ReturnType<
+ ReturnType['getConnectionRequestFromUrlParams']
+>;
+
+// Providers enabled in VeChain's Privy dashboard that go through Privy's
+// headless useLoginWithOAuth. Phone (SMS) and Farcaster (SIWF) are enabled
+// too but use different flows: phone has its own inline form; Farcaster
+// would need a Warpcast QR/deeplink integration (TODO).
+const OAUTH_PROVIDERS = [
+ { id: 'google', label: 'Google', Icon: FcGoogle, tier: 'primary' },
+ { id: 'apple', label: 'Apple', Icon: FaApple, tier: 'primary' },
+ { id: 'twitter', label: 'X', Icon: FaXTwitter, tier: 'primary' },
+ { id: 'discord', label: 'Discord', Icon: FaDiscord, tier: 'other' },
+ { id: 'github', label: 'GitHub', Icon: FaGithub, tier: 'other' },
+ { id: 'tiktok', label: 'TikTok', Icon: FaTiktok, tier: 'other' },
+ { id: 'line', label: 'LINE', Icon: FaLine, tier: 'other' },
+] as const satisfies ReadonlyArray<{
+ id: string;
+ label: string;
+ Icon: IconType;
+ tier: 'primary' | 'other';
+}>;
+type OAuthProvider = (typeof OAUTH_PROVIDERS)[number]['id'];
+
+// Brand hexes for providers whose glyph reads better in their official
+// color rather than the kit's monochrome text color. Discord blurple,
+// TikTok pink, LINE green. Phone (not OAuth) uses iMessage-style green.
+// Google keeps its own multi-color glyph (FcGoogle is already colored);
+// Apple, GitHub, X are intentionally monochrome.
+const BRAND_GLYPH_COLOR: Partial> = {
+ discord: '#5865F2',
+ tiktok: '#FE2C55',
+ line: '#06C755',
+};
+const PHONE_GLYPH_COLOR = '#34C759';
+
+const INTENT_METHODS = [
+ ...OAUTH_PROVIDERS.map((p) => p.id),
+ 'phone',
+ 'farcaster',
+] as const;
+type IntentMethod = (typeof INTENT_METHODS)[number];
+
+function isIntent(value: string | null): value is IntentMethod {
+ return !!value && (INTENT_METHODS as readonly string[]).includes(value);
+}
+
+const OAUTH_ATTEMPTED_STORAGE_KEY = 'vk-cross-app-connect:oauth-attempted';
+
+type Phase =
+ | 'loading'
+ | 'no_params'
+ | 'parse_error'
+ | 'switching_provider'
+ | 'auth_pending'
+ | 'show_picker'
+ | 'show_connect';
+
+type PrivyUser = ReturnType['user'];
+
+function hasLinkedProvider(
+ user: PrivyUser,
+ intent: IntentMethod | null,
+): boolean {
+ if (!user || !intent) return false;
+ if (intent === 'phone') return Boolean(user.phone);
+ if (intent === 'farcaster') return Boolean(user.farcaster);
+ return Boolean((user as unknown as Record)[intent]);
+}
+
+function isOAuthIntent(value: IntentMethod | null): value is OAuthProvider {
+ return !!value && OAUTH_PROVIDERS.some((p) => p.id === value);
+}
+
+function truncateAddress(addr?: string): string {
+ if (!addr) return '';
+ return `${addr.slice(0, 6)}…${addr.slice(-4)}`;
+}
+
+export default function CrossAppConnectPage() {
+ const client = useCrossAppClient();
+ const { ready, authenticated, user, getAccessToken } = usePrivy();
+ const { wallets } = useWallets();
+ const { logout } = useLogout();
+ const { initOAuth, loading: oauthLoading } = useLoginWithOAuth();
+
+ const [request, setRequest] = useState(null);
+ const [parseError, setParseError] = useState<
+ { kind: 'no_params' } | { kind: 'invalid'; message: string } | null
+ >(null);
+ const [submitting, setSubmitting] = useState(false);
+ const [submitError, setSubmitError] = useState(null);
+
+ useEffect(() => {
+ const hasRequesterKey =
+ typeof window !== 'undefined' &&
+ new URL(window.location.href).searchParams.has(
+ 'requester_public_key',
+ );
+ if (!hasRequesterKey) {
+ setParseError({ kind: 'no_params' });
+ return;
+ }
+ try {
+ setRequest(client.getConnectionRequestFromUrlParams());
+ } catch (e) {
+ setParseError({
+ kind: 'invalid',
+ message:
+ e instanceof Error
+ ? e.message
+ : 'Invalid connection request',
+ });
+ }
+ }, [client]);
+
+ const intent = useMemo(() => {
+ if (typeof window === 'undefined') return null;
+ const value = new URL(window.location.href).searchParams.get('intent');
+ return isIntent(value) ? value : null;
+ }, []);
+
+ const embedded = wallets.find((w) => w.walletClientType === 'privy');
+ const { data: smartAccount } = useSmartAccount(embedded?.address);
+
+ const onAccept = useCallback(async () => {
+ if (!request || !embedded || !user) return;
+ setSubmitting(true);
+ setSubmitError(null);
+ try {
+ const accessToken = await getAccessToken();
+ if (!accessToken) throw new Error('Missing access token');
+ await client.acceptConnection({
+ accessToken,
+ address: embedded.address,
+ userId: user.id,
+ connectionRequest: request,
+ });
+ window.close();
+ } catch (e) {
+ setSubmitError(
+ e instanceof Error ? e.message : 'Failed to accept connection',
+ );
+ } finally {
+ setSubmitting(false);
+ }
+ }, [client, request, embedded, user, getAccessToken]);
+
+ const onReject = useCallback(async () => {
+ if (!request) return;
+ setSubmitting(true);
+ try {
+ const accessToken = await getAccessToken();
+ await client.rejectConnection({
+ accessToken: accessToken ?? undefined,
+ callbackUrl: request.callbackUrl,
+ });
+ } finally {
+ window.close();
+ }
+ }, [client, request, getAccessToken]);
+
+ const phase: Phase = useMemo(() => {
+ if (parseError?.kind === 'no_params') return 'no_params';
+ if (parseError?.kind === 'invalid') return 'parse_error';
+ if (!ready || !request) return 'loading';
+
+ // Only OAuth intents auto-redirect. Phone (inline SMS OTP form) and
+ // Farcaster (SIWF placeholder) live inside the picker.
+ if (intent && isOAuthIntent(intent)) {
+ if (!authenticated) return 'auth_pending';
+ if (!user) return 'loading';
+ return hasLinkedProvider(user, intent)
+ ? 'show_connect'
+ : 'switching_provider';
+ }
+
+ if (!authenticated) return 'show_picker';
+ return 'show_connect';
+ }, [parseError, ready, request, intent, authenticated, user]);
+
+ const logoutForIntentRef = useRef(false);
+ useEffect(() => {
+ if (phase !== 'switching_provider') return;
+ if (logoutForIntentRef.current) return;
+ logoutForIntentRef.current = true;
+ logout().catch((e) => console.error('Failed to logout:', e));
+ }, [phase, logout]);
+
+ useEffect(() => {
+ if (phase !== 'auth_pending') return;
+ if (!intent || !isOAuthIntent(intent)) return;
+ if (oauthLoading) return;
+ if (typeof sessionStorage !== 'undefined') {
+ if (
+ sessionStorage.getItem(OAUTH_ATTEMPTED_STORAGE_KEY) === intent
+ ) {
+ return;
+ }
+ sessionStorage.setItem(OAUTH_ATTEMPTED_STORAGE_KEY, intent);
+ }
+ setRecentProvider(intent);
+ initOAuth({ provider: intent }).catch((e) => setSubmitError(String(e)));
+ }, [phase, intent, oauthLoading, initOAuth]);
+
+ const initialAuthRef = useRef(undefined);
+ useEffect(() => {
+ if (!ready) return;
+ if (initialAuthRef.current !== undefined) return;
+ initialAuthRef.current = authenticated;
+ }, [ready, authenticated]);
+
+ const autoAcceptedRef = useRef(false);
+ useEffect(() => {
+ if (autoAcceptedRef.current) return;
+ if (phase !== 'show_connect') return;
+ if (!embedded || !user) return;
+ if (submitting || submitError) return;
+ if (initialAuthRef.current !== false) return;
+ autoAcceptedRef.current = true;
+ onAccept();
+ }, [phase, embedded, user, submitting, submitError, onAccept]);
+
+ if (phase === 'no_params') {
+ return (
+
+
+
+
+
+ This page handles cross-app connection requests from
+ other VeChain dApps. It can't be opened
+ directly — the requesting app will open it
+ with the parameters it needs.
+
+
+
+
+ );
+ }
+
+ if (phase === 'parse_error') {
+ return (
+
+
+
+
+
+ {parseError?.kind === 'invalid'
+ ? parseError.message
+ : 'Invalid connection request'}
+
+
+
+ );
+ }
+
+ if (
+ phase === 'loading' ||
+ phase === 'switching_provider' ||
+ phase === 'auth_pending'
+ ) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ if (phase === 'show_picker') {
+ return (
+
+
+
+
+ );
+ }
+
+ const appHubEntry = lookupAppByUrl(request?.callbackUrl);
+ const verifiedApp = Boolean(appHubEntry);
+ return (
+
+
+
+
+
+
+ {!verifiedApp && (
+
+
+ This app isn’t listed in the VeChain App
+ Hub, so only continue if you trust the site.
+
+
+ )}
+ {submitError && (
+
+
+
+ {submitError}
+
+
+ )}
+
+
+
+
+ Not you?
+
+
+
+
+
+
+
+ );
+}
+
+type PanelView = 'picker' | 'phone' | 'farcaster';
+
+function SignInPanel({
+ intent,
+ onCancel,
+}: {
+ intent: IntentMethod | null;
+ onCancel: () => void;
+}) {
+ const { colorMode } = useColorMode();
+ const [error, setError] = useState(null);
+ const { initOAuth, loading: oauthLoading } = useLoginWithOAuth({
+ onError: (e) => setError(String(e)),
+ });
+ const { state: smsState, sendCode, loginWithCode } = useLoginWithSms();
+
+ const [view, setView] = useState(() =>
+ intent === 'phone'
+ ? 'phone'
+ : intent === 'farcaster'
+ ? 'farcaster'
+ : 'picker',
+ );
+ const [showOther, setShowOther] = useState(false);
+ const [phone, setPhone] = useState('');
+ const [code, setCode] = useState('');
+ const [recent, setRecent] = useState(null);
+
+ useEffect(() => {
+ setRecent(getRecentProvider());
+ }, []);
+
+ // Build the visible row order. Recent provider (if any) jumps to the top
+ // regardless of tier; everything else respects the OAUTH_PROVIDERS array
+ // order plus Phone in the primary row and Farcaster in the other group.
+ const rows = useMemo(() => {
+ const primary = OAUTH_PROVIDERS.filter((p) => p.tier === 'primary');
+ const other = OAUTH_PROVIDERS.filter((p) => p.tier === 'other');
+ return { primary, other };
+ }, []);
+
+ const onOAuth = (provider: OAuthProvider) => {
+ setError(null);
+ setRecentProvider(provider);
+ initOAuth({ provider }).catch((e) => setError(String(e)));
+ };
+
+ const onSendCode = async () => {
+ setError(null);
+ try {
+ await sendCode({ phoneNumber: phone });
+ setRecentProvider('phone');
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Failed to send code');
+ }
+ };
+
+ const onSubmitCode = async () => {
+ setError(null);
+ try {
+ await loginWithCode({ code });
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Failed to verify code');
+ }
+ };
+
+ const awaitingCode = smsState.status === 'awaiting-code-input';
+ const sendingCode = smsState.status === 'sending-code';
+ const submittingCode = smsState.status === 'submitting-code';
+
+ const isRecent = (id: string) => recent === id;
+
+ return (
+
+
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+ {view === 'picker' && (
+
+ {/* Primary row: Google, Apple, X, Phone */}
+ {rows.primary.map((p) => (
+ onOAuth(p.id)}
+ isDisabled={oauthLoading}
+ isRecent={isRecent(p.id)}
+ colorMode={colorMode}
+ />
+ ))}
+ setView('phone')}
+ isRecent={isRecent('phone')}
+ />
+ {/* Other socials: render either the "show more"
+ link or the expanded list, never both. Avoids
+ the chevron-row inside a button stack pattern. */}
+ {!showOther && rows.other.length > 0 && (
+
+
+
+ )}
+ {showOther && (
+
+ {/* Discord, GitHub, TikTok, Farcaster, LINE */}
+ {rows.other
+ .filter(
+ (p) =>
+ p.id === 'discord' ||
+ p.id === 'github' ||
+ p.id === 'tiktok',
+ )
+ .map((p) => (
+ onOAuth(p.id)}
+ isDisabled={oauthLoading}
+ isRecent={isRecent(p.id)}
+ colorMode={colorMode}
+ />
+ ))}
+ setView('farcaster')}
+ isRecent={isRecent('farcaster')}
+ />
+ {rows.other
+ .filter((p) => p.id === 'line')
+ .map((p) => (
+ onOAuth(p.id)}
+ isDisabled={oauthLoading}
+ isRecent={isRecent(p.id)}
+ colorMode={colorMode}
+ />
+ ))}
+
+ )}
+
+ )}
+
+ {view === 'phone' && !awaitingCode && !submittingCode && (
+
+ setPhone(e.target.value)}
+ autoFocus
+ h="48px"
+ bg="card-bg"
+ borderColor="card-border"
+ color="text-strong"
+ _placeholder={{ color: 'text-subtle' }}
+ _focusVisible={{
+ borderColor: 'accent',
+ boxShadow: 'none',
+ }}
+ />
+
+ Include the country code, e.g. +1 for US, +44
+ for UK.
+
+
+
+
+ )}
+
+ {view === 'phone' && (awaitingCode || submittingCode) && (
+
+
+ We sent a 6-digit code to{' '}
+
+ {phone}
+
+ .
+
+
+ {
+ setCode(v);
+ loginWithCode({
+ code: v,
+ }).catch((e) => setError(String(e)));
+ }}
+ otp
+ >
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {view === 'farcaster' && (
+
+
+ Farcaster sign-in is coming soon. It uses Sign
+ In With Farcaster (SIWF), which needs a Warpcast
+ scan and isn't wired up here yet. Please
+ choose another option for now.
+
+
+
+ )}
+
+
+
+
+
+ );
+}
+
+function ProviderRow({
+ provider,
+ isRecent,
+ onClick,
+ isDisabled,
+ colorMode,
+}: {
+ provider: (typeof OAUTH_PROVIDERS)[number];
+ isRecent?: boolean;
+ onClick: () => void;
+ isDisabled?: boolean;
+ colorMode: 'light' | 'dark';
+}) {
+ // Apple / GitHub / X are intentionally monochrome -- their wordmarks
+ // are black/white by brand. Flip them with the color mode for legibility.
+ // Everyone else gets their brand hex so the picker has the same
+ // chromatic feel as Privy's hosted UI.
+ const brandColor = BRAND_GLYPH_COLOR[provider.id];
+ const monoFlip =
+ provider.id === 'apple' ||
+ provider.id === 'github' ||
+ provider.id === 'twitter';
+ const iconColor = brandColor
+ ? brandColor
+ : monoFlip
+ ? colorMode === 'dark'
+ ? 'white'
+ : 'text-strong'
+ : undefined;
+ return (
+
+ }
+ rightIcon={isRecent ? : undefined}
+ >
+
+ Continue with {provider.label}
+
+
+ );
+}
+
+function PhoneRow({
+ onClick,
+ isRecent,
+}: {
+ onClick: () => void;
+ isRecent?: boolean;
+}) {
+ return (
+
+ }
+ rightIcon={isRecent ? : undefined}
+ >
+
+ Continue with Phone
+
+
+ );
+}
+
+function FarcasterRow({
+ onClick,
+ isRecent,
+}: {
+ onClick: () => void;
+ isRecent?: boolean;
+}) {
+ return (
+ }
+ rightIcon={isRecent ? : undefined}
+ >
+
+ Continue with Farcaster
+
+
+ );
+}
+
+/**
+ * Quiet "Recent" indicator -- a small green dot with a tooltip. Lets a
+ * returning user spot the previously used provider at a glance without a
+ * loud chip stealing attention from the recommended path.
+ */
+function RecentDot() {
+ return (
+
+
+
+ );
+}
+
+function IdentityRow({
+ walletAddress,
+ user,
+}: {
+ walletAddress?: string;
+ user: ReturnType['user'];
+}) {
+ const { data: domainInfo, isPending: domainPending } =
+ useVechainDomain(walletAddress);
+ const { data: avatar, isPending: avatarPending } =
+ useGetAvatarOfAddress(walletAddress);
+ const domain = domainInfo?.domain;
+ const email = user?.email?.address ?? user?.google?.email ?? user?.id;
+ const linked = linkedSocials(user);
+ const walletPending = !walletAddress || domainPending;
+
+ return (
+
+
+ {avatar ? (
+
+ }
+ />
+ ) : (
+
+ )}
+
+
+
+
+ {email ?? 'Signed in'}
+
+ {linked.length > 0 && (
+
+ {linked.map((s) => (
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ {walletAddress ? (
+
+ {domain
+ ? `${domain} · ${truncateAddress(walletAddress)}`
+ : truncateAddress(walletAddress)}
+
+ ) : (
+
+ Creating your VeChain account…
+
+ )}
+
+
+
+ );
+}
+
+type LinkedSocialBadge = {
+ id: string;
+ label: string;
+ Icon: IconType;
+ color?: string;
+};
+
+function linkedSocials(
+ user: ReturnType['user'],
+): LinkedSocialBadge[] {
+ if (!user) return [];
+ const u = user as unknown as Record;
+ const badges: LinkedSocialBadge[] = [];
+ for (const p of OAUTH_PROVIDERS) {
+ if (u[p.id]) {
+ badges.push({
+ id: p.id,
+ label: p.label,
+ Icon: p.Icon,
+ color: BRAND_GLYPH_COLOR[p.id],
+ });
+ }
+ }
+ if (u.phone) {
+ badges.push({
+ id: 'phone',
+ label: 'Phone',
+ Icon: LuPhone,
+ color: PHONE_GLYPH_COLOR,
+ });
+ }
+ if (u.farcaster) {
+ badges.push({
+ id: 'farcaster',
+ label: 'Farcaster',
+ Icon: SiFarcaster,
+ color: '#8A63D2',
+ });
+ }
+ return badges;
+}
+
+function PageShell({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/cross-app-connect/src/app/cross-app/transact/page.tsx b/cross-app-connect/src/app/cross-app/transact/page.tsx
new file mode 100644
index 00000000..0127896f
--- /dev/null
+++ b/cross-app-connect/src/app/cross-app/transact/page.tsx
@@ -0,0 +1,1156 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import {
+ Alert,
+ AlertDescription,
+ Box,
+ Button,
+ Card,
+ CardBody,
+ Center,
+ Code,
+ Collapse,
+ Container,
+ HStack,
+ Icon,
+ Skeleton,
+ Spinner,
+ Stack,
+ Text,
+ useDisclosure,
+} from '@chakra-ui/react';
+import {
+ LuChevronDown,
+ LuChevronUp,
+ LuShieldAlert,
+ LuShieldCheck,
+ LuShieldX,
+} from 'react-icons/lu';
+import type { IconType } from 'react-icons';
+import { useLogin, usePrivy, useWallets } from '@privy-io/react-auth';
+import {
+ useSmartAccount,
+ useGetChainId,
+ useVeChainKitConfig,
+} from '@vechain/vechain-kit';
+import { useThor } from '@vechain/dapp-kit-react';
+import type { VerifiedTransactionRequest } from '@privy-io/cross-app-provider/connect';
+import { useCrossAppClient } from '../_lib/client';
+import { VechainHeader } from '../../components/VechainHeader';
+import { AddressTag } from '../../components/AddressTag';
+import { AccountChip } from '../../components/AccountChip';
+import { decodeClause, type DecodedClause } from '../_lib/decoder';
+import {
+ computeRisk,
+ continueLabel,
+ humanPrimaryType,
+ summarizeActions,
+ titleForActions,
+ uniqueTokensFromDecoded,
+ type Risk,
+} from '../_lib/labels';
+import type { NETWORK_TYPE } from '../_lib/network-tokens';
+import { formatUnits } from 'viem';
+import type { AppConfig } from '@vechain/vechain-kit';
+
+const RISK_SHIELD: Record<
+ Risk,
+ { Icon: IconType; color: string }
+> = {
+ safe: { Icon: LuShieldCheck, color: 'accent' },
+ caution: { Icon: LuShieldAlert, color: 'orange.400' },
+ danger: { Icon: LuShieldX, color: 'red.400' },
+};
+
+const SUPPORTED_METHODS = [
+ 'eth_signTypedData_v4',
+ 'personal_sign',
+] as const;
+const SMART_ACCOUNT_PRIMARY_TYPES = [
+ 'ExecuteWithAuthorization',
+ 'ExecuteBatchWithAuthorization',
+] as const;
+
+type Clause = {
+ to: string;
+ value: string;
+ data: string;
+};
+
+type SmartAccountTypedData = {
+ domain: {
+ name: string;
+ version: string;
+ chainId: string | number;
+ verifyingContract: string;
+ };
+ types: Record>;
+ primaryType: string;
+ message: Record & {
+ to: string | string[];
+ value: string | string[];
+ data: string | string[];
+ validAfter: string | number;
+ validBefore: string | number;
+ };
+};
+
+type GenericTypedData = {
+ domain: {
+ name?: string;
+ version?: string;
+ chainId?: string | number;
+ verifyingContract?: string;
+ };
+ types: Record>;
+ primaryType: string;
+ message: Record;
+};
+
+type ParsedRequest =
+ | {
+ kind: 'smart_account';
+ typedData: SmartAccountTypedData;
+ clauses: Clause[];
+ }
+ | { kind: 'typed_data'; typedData: GenericTypedData }
+ | { kind: 'message'; message: string; raw: string };
+
+// Decode hex-encoded message payloads back to UTF-8 so the user sees the
+// actual text being signed, not 0x6f6e6c79... For non-hex strings, pass
+// through as-is.
+function decodePersonalSignMessage(raw: string): string {
+ if (typeof raw !== 'string') return '';
+ if (!raw.startsWith('0x')) return raw;
+ try {
+ const hex = raw.slice(2);
+ const bytes = new Uint8Array(
+ hex.match(/.{1,2}/g)?.map((b) => parseInt(b, 16)) ?? [],
+ );
+ return new TextDecoder('utf-8', { fatal: false }).decode(bytes);
+ } catch {
+ return raw;
+ }
+}
+
+function parseClauses(typedData: SmartAccountTypedData): Clause[] {
+ const { to, value, data } = typedData.message;
+ if (Array.isArray(to)) {
+ const values = (value as string[]) ?? [];
+ const datas = (data as string[]) ?? [];
+ return to.map((t, i) => ({
+ to: t,
+ value: String(values[i] ?? '0'),
+ data: datas[i] ?? '0x',
+ }));
+ }
+ return [
+ {
+ to: to as string,
+ value: String(value ?? '0'),
+ data: (data as string) ?? '0x',
+ },
+ ];
+}
+
+function truncate(addr: string): string {
+ if (!addr || addr.length < 12) return addr;
+ return `${addr.slice(0, 6)}…${addr.slice(-4)}`;
+}
+
+export default function CrossAppTransactPage() {
+ const client = useCrossAppClient();
+ const {
+ ready,
+ authenticated,
+ user,
+ signTypedData,
+ signMessage,
+ getAccessToken,
+ } = usePrivy();
+ const { wallets } = useWallets();
+ const { login } = useLogin();
+ const embedded = wallets.find((w) => w.walletClientType === 'privy');
+ const { data: smartAccount } = useSmartAccount(embedded?.address);
+ const { data: kitChainId } = useGetChainId();
+ const { network, appConfig } = useVeChainKitConfig();
+ const thor = useThor();
+
+ const [verified, setVerified] = useState(
+ null,
+ );
+ const [parseError, setParseError] = useState<
+ { kind: 'no_params' } | { kind: 'invalid'; message: string } | null
+ >(null);
+ const [block, setBlock] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+ const [submitError, setSubmitError] = useState(null);
+ const [decoded, setDecoded] = useState(null);
+ const inspect = useDisclosure();
+
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+ if (!window.location.search) {
+ setParseError({ kind: 'no_params' });
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!authenticated || !user?.id) return;
+ if (typeof window !== 'undefined' && !window.location.search) return;
+ let cancelled = false;
+ (async () => {
+ try {
+ const data = await client.getVerifiedTransactionRequest({
+ userId: user.id,
+ });
+ if (!cancelled) setVerified(data);
+ } catch (e) {
+ if (!cancelled)
+ setParseError({
+ kind: 'invalid',
+ message:
+ e instanceof Error
+ ? e.message
+ : 'Failed to read request',
+ });
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [client, authenticated, user?.id]);
+
+ const parsed = useMemo(() => {
+ if (!verified) return null;
+ const { method, params } = verified.request;
+
+ if (method === 'personal_sign') {
+ // wagmi sends params as [message, address]. Some libs reverse
+ // them; accept either order by picking the first string-looking
+ // entry that isn't a 20-byte address.
+ const args = Array.isArray(params) ? params : [];
+ const rawMessage = args.find(
+ (p) =>
+ typeof p === 'string' &&
+ !/^0x[a-fA-F0-9]{40}$/.test(p),
+ );
+ if (typeof rawMessage !== 'string') return null;
+ return {
+ kind: 'message',
+ message: decodePersonalSignMessage(rawMessage),
+ raw: rawMessage,
+ };
+ }
+
+ if (method === 'eth_signTypedData_v4') {
+ const raw = Array.isArray(params) ? params[1] : undefined;
+ const typedData =
+ typeof raw === 'string' ? JSON.parse(raw) : raw;
+ if (!typedData?.domain || !typedData?.message) return null;
+ const primaryType = typedData.primaryType;
+ const isSmartAccountAuth =
+ SMART_ACCOUNT_PRIMARY_TYPES.includes(
+ primaryType as 'ExecuteWithAuthorization',
+ ) &&
+ typedData.domain?.name === 'Wallet' &&
+ typedData.domain?.version === '1';
+ if (isSmartAccountAuth) {
+ return {
+ kind: 'smart_account',
+ typedData,
+ clauses: parseClauses(typedData),
+ };
+ }
+ return { kind: 'typed_data', typedData };
+ }
+
+ return null;
+ }, [verified]);
+
+ useEffect(() => {
+ if (!verified) {
+ setBlock(null);
+ return;
+ }
+ const method = verified.request?.method;
+ if (
+ !SUPPORTED_METHODS.includes(
+ method as (typeof SUPPORTED_METHODS)[number],
+ )
+ ) {
+ setBlock(`Unsupported method: ${method}`);
+ return;
+ }
+ if (!parsed) {
+ setBlock(null);
+ return;
+ }
+ // Only the smart-account ExecuteWithAuthorization path is subject to
+ // the deep chain-id / verifyingContract safety gates. Plain
+ // personal_sign and generic eth_signTypedData_v4 just need the user
+ // to recognise what they're signing -- handled at the UI level.
+ if (parsed.kind !== 'smart_account') {
+ setBlock(null);
+ return;
+ }
+ if (!smartAccount?.address || !kitChainId) {
+ setBlock(null);
+ return;
+ }
+ const { typedData } = parsed;
+ try {
+ if (BigInt(typedData.domain.chainId) !== BigInt(kitChainId)) {
+ setBlock('Chain id mismatch');
+ return;
+ }
+ } catch {
+ setBlock('Invalid chain id in request');
+ return;
+ }
+ if (
+ typedData.domain.verifyingContract.toLowerCase() !==
+ smartAccount.address.toLowerCase()
+ ) {
+ setBlock(
+ 'Smart account mismatch: the request is signing for a different smart account.',
+ );
+ return;
+ }
+ setBlock(null);
+ }, [verified, parsed, smartAccount?.address, kitChainId]);
+
+ // Decode each clause to a human-readable summary. Fires once whenever
+ // the parsed clauses change. Results cached in module-level Maps inside
+ // decoder.ts so a re-render or a returning user doesn't re-fetch b32
+ // signatures. Only smart-account requests have clauses to decode.
+ useEffect(() => {
+ if (parsed?.kind !== 'smart_account') {
+ setDecoded(null);
+ return;
+ }
+ let cancelled = false;
+ (async () => {
+ const results = await Promise.all(
+ parsed.clauses.map((c) =>
+ decodeClause(
+ c,
+ thor ?? null,
+ network.type as NETWORK_TYPE,
+ ),
+ ),
+ );
+ if (!cancelled) setDecoded(results);
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [parsed, thor, network.type]);
+
+ const onApprove = useCallback(async () => {
+ if (!verified || !parsed) return;
+ setSubmitting(true);
+ setSubmitError(null);
+ try {
+ let signature: string;
+ if (parsed.kind === 'message') {
+ const result = await signMessage(
+ { message: parsed.message },
+ {
+ uiOptions: {
+ title: 'Sign message',
+ buttonText: 'Sign',
+ },
+ },
+ );
+ signature = result.signature;
+ } else {
+ const result = await signTypedData(
+ parsed.typedData as Parameters[0],
+ {
+ uiOptions: {
+ title:
+ parsed.kind === 'smart_account'
+ ? 'Approve VeChain transaction'
+ : 'Sign structured data',
+ buttonText: 'Sign',
+ },
+ },
+ );
+ signature = result.signature;
+ }
+ const accessToken = await getAccessToken();
+ await client.handleRequestResult({
+ accessToken: accessToken ?? undefined,
+ result: signature,
+ connection: verified.connection,
+ });
+ window.close();
+ } catch (e) {
+ const message =
+ e instanceof Error ? e.message : 'Failed to sign request';
+ setSubmitError(message);
+ try {
+ const accessToken = await getAccessToken();
+ await client.handleError({
+ accessToken: accessToken ?? undefined,
+ error: e instanceof Error ? e : new Error(message),
+ callbackUrl: verified.connection.callbackUrl,
+ errorCode: 4001,
+ });
+ } catch {
+ // swallow; user can still close window manually
+ }
+ } finally {
+ setSubmitting(false);
+ }
+ }, [
+ client,
+ verified,
+ parsed,
+ signMessage,
+ signTypedData,
+ getAccessToken,
+ ]);
+
+ const onReject = useCallback(async () => {
+ if (!verified) {
+ window.close();
+ return;
+ }
+ setSubmitting(true);
+ try {
+ const accessToken = await getAccessToken();
+ await client.rejectRequest({
+ accessToken: accessToken ?? undefined,
+ callbackUrl: verified.connection.callbackUrl,
+ });
+ } finally {
+ window.close();
+ }
+ }, [client, verified, getAccessToken]);
+
+ if (parseError?.kind === 'no_params') {
+ return (
+
+
+
+
+
+ This page handles cross-app transaction requests
+ from other VeChain dApps. It can't be opened
+ directly — the requesting app will open it
+ with the parameters it needs.
+
+
+
+
+ );
+ }
+
+ if (!ready) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ if (!authenticated) {
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+ if (parseError?.kind === 'invalid') {
+ return (
+
+
+
+
+ {parseError.message}
+
+
+
+ );
+ }
+
+ if (!verified || !parsed) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ const blocked = block !== null;
+ const isSmartAccount = parsed.kind === 'smart_account';
+ const stillDecoding = isSmartAccount && decoded === null;
+ const hasUnknown =
+ isSmartAccount && (decoded?.some((d) => d.kind === 'unknown') ?? false);
+ const hasUnlimitedApprove =
+ isSmartAccount &&
+ (decoded?.some(
+ (d) => d.kind === 'token_approve' && d.unlimited,
+ ) ?? false);
+ const risk: Risk = isSmartAccount
+ ? computeRisk(decoded, blocked)
+ : 'safe';
+ const { Icon: ShieldIcon, color: shieldColor } = RISK_SHIELD[risk];
+ const title = isSmartAccount
+ ? titleForActions(decoded, blocked)
+ : parsed.kind === 'message'
+ ? 'Sign a message'
+ : 'Sign data';
+ const subtitle = isSmartAccount
+ ? decoded
+ ? summarizeActions(decoded)
+ : 'Checking what this does…'
+ : parsed.kind === 'message'
+ ? 'Review the message this app wants you to sign.'
+ : 'Review the data this app wants you to sign.';
+ const ctaLabel = isSmartAccount
+ ? continueLabel(risk)
+ : 'Sign';
+ const relevantTokens = isSmartAccount
+ ? uniqueTokensFromDecoded(decoded)
+ : [];
+ const accountChipAddress = isSmartAccount
+ ? smartAccount?.address
+ : embedded?.address;
+ const continueDisabled =
+ blocked ||
+ submitting ||
+ (isSmartAccount && (!smartAccount?.address || stillDecoding));
+
+ return (
+
+
+
+
+
+ {accountChipAddress && (
+
+ )}
+ {parsed.kind === 'smart_account' &&
+ (stillDecoding ? (
+
+ {parsed.clauses.map((_, i) => (
+
+ ))}
+
+ ) : (
+
+ {decoded!.map((d, i) => (
+
+ ))}
+
+ ))}
+ {parsed.kind === 'message' && (
+
+ )}
+ {parsed.kind === 'typed_data' && (
+
+ )}
+
+ {blocked && (
+
+
+ {block}
+
+
+ )}
+
+ {!blocked && hasUnknown && (
+
+
+ We couldn’t double-check every step, so
+ only continue if you trust this app.
+
+
+ )}
+
+ {!blocked &&
+ !hasUnknown &&
+ hasUnlimitedApprove && (
+
+
+ This app is asking for unlimited
+ access to one of your tokens — make
+ sure you trust it.
+
+
+ )}
+
+ {submitError && (
+
+
+ {submitError}
+
+
+ )}
+
+
+
+
+
+
+ Want the technical details?
+
+
+ }
+ >
+ {inspect.isOpen ? 'Hide' : 'Inspect'}
+
+
+
+
+
+
+ {parsed.kind === 'smart_account' && (
+ <>
+
+
+
+ {parsed.clauses.length === 1
+ ? 'Clause'
+ : `Clauses (${parsed.clauses.length})`}
+
+ {parsed.clauses.map((c, i) => (
+
+ ))}
+
+ >
+ )}
+ {parsed.kind === 'typed_data' && (
+ <>
+
+ {parsed.typedData.domain.name && (
+
+ )}
+
+ >
+ )}
+ {parsed.kind === 'message' && (
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+function MessageView({ message }: { message: string }) {
+ return (
+
+
+ Message
+
+
+ {message || '(empty message)'}
+
+
+ );
+}
+
+function TypedDataView({ typedData }: { typedData: GenericTypedData }) {
+ return (
+
+
+
+ {humanPrimaryType(typedData.primaryType)}
+
+ {typedData.domain.name && (
+
+ From: {typedData.domain.name}
+
+ )}
+
+ {JSON.stringify(typedData.message, null, 2)}
+
+
+
+ );
+}
+
+function RawJsonBlock({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+function ActionRowSkeleton() {
+ return (
+
+
+
+
+ );
+}
+
+function ActionRow({
+ action,
+ appConfig,
+ self,
+}: {
+ action: DecodedClause;
+ appConfig?: AppConfig;
+ self?: string;
+}) {
+ // Strong type carries the action -- no leading icon needed. A subtle
+ // left border tinted by risk gives at-a-glance scanning without the
+ // round icon container reading as decorative chrome.
+ const accent =
+ action.kind === 'unknown'
+ ? 'orange.400'
+ : action.kind === 'token_approve' &&
+ (action as { unlimited: boolean }).unlimited
+ ? 'orange.400'
+ : 'card-border';
+ return (
+
+
+ {action.summary}
+
+
+
+ );
+}
+
+function ActionRowDetail({
+ action,
+ appConfig,
+ self,
+}: {
+ action: DecodedClause;
+ appConfig?: AppConfig;
+ self?: string;
+}) {
+ switch (action.kind) {
+ case 'native_transfer':
+ case 'token_transfer':
+ return (
+
+ To
+
+
+ );
+ case 'token_approve':
+ return (
+
+ Spender:
+
+
+ );
+ case 'unknown':
+ if (action.signature) {
+ return (
+
+ Function: {action.signature}
+
+ );
+ }
+ if (action.selector) {
+ return (
+
+ Selector: {action.selector}
+
+ );
+ }
+ return null;
+ default:
+ return null;
+ }
+}
+
+function formatAmount(raw: bigint, decimals: number): string {
+ const str = formatUnits(raw, decimals);
+ if (!str.includes('.')) return str;
+ const [whole, frac] = str.split('.');
+ const trimmed = frac.replace(/0+$/, '').slice(0, 4);
+ return trimmed.length === 0 ? whole : `${whole}.${trimmed}`;
+}
+
+function parseValueOrZero(value: string): bigint {
+ if (!value) return BigInt(0);
+ try {
+ return BigInt(value);
+ } catch {
+ return BigInt(0);
+ }
+}
+
+function RawClauseRow({
+ clause,
+ index,
+ total,
+ appConfig,
+}: {
+ clause: { to: string; value: string; data: string };
+ index: number;
+ total: number;
+ appConfig?: AppConfig;
+}) {
+ const showRaw = useDisclosure();
+ const valueWei = parseValueOrZero(clause.value);
+ const hasValue = valueWei > BigInt(0);
+ const hasData = Boolean(clause.data) && clause.data !== '0x';
+ return (
+
+
+
+
+ Clause {index + 1} of {total} · to
+
+
+
+ {hasValue && (
+
+ {formatAmount(valueWei, 18)} VET
+
+ }
+ />
+ )}
+ {hasData && (
+
+
+ }
+ >
+ {showRaw.isOpen
+ ? 'Hide raw calldata'
+ : 'Show raw calldata'}
+
+
+
+ {clause.data}
+
+
+
+ )}
+
+
+ );
+}
+
+function DetailRow({
+ label,
+ value,
+}: {
+ label: string;
+ value: React.ReactNode;
+}) {
+ return (
+
+
+ {label}
+
+ {typeof value === 'string' ? (
+
+ {value}
+
+ ) : (
+ value
+ )}
+
+ );
+}
+
+function networkLabel(type: string): string {
+ switch (type) {
+ case 'main':
+ return 'VeChain Mainnet';
+ case 'test':
+ return 'VeChain Testnet';
+ case 'solo':
+ return 'Local Thor Solo';
+ default:
+ return type;
+ }
+}
+
+function PageShell({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/cross-app-connect/src/app/globals.css b/cross-app-connect/src/app/globals.css
new file mode 100644
index 00000000..5be68ff9
--- /dev/null
+++ b/cross-app-connect/src/app/globals.css
@@ -0,0 +1,7 @@
+html,
+body {
+ margin: 0;
+ min-height: 100vh;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
diff --git a/cross-app-connect/src/app/layout.tsx b/cross-app-connect/src/app/layout.tsx
new file mode 100644
index 00000000..b891f1cd
--- /dev/null
+++ b/cross-app-connect/src/app/layout.tsx
@@ -0,0 +1,55 @@
+'use client';
+
+import { ChakraProvider, ColorModeScript } from '@chakra-ui/react';
+import dynamic from 'next/dynamic';
+import { vechainTheme } from './theme';
+import { ColorModeToggle } from './components/ColorModeToggle';
+import { ForceColorMode } from './components/ForceColorMode';
+import './globals.css';
+
+const VechainKitProviderWrapper = dynamic(
+ async () =>
+ (await import('./providers/VechainKitProviderWrapper'))
+ .VechainKitProviderWrapper,
+ { ssr: false },
+);
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+
+
+ VeChain Cross-App Connect
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/cross-app-connect/src/app/page.tsx b/cross-app-connect/src/app/page.tsx
new file mode 100644
index 00000000..499cecc6
--- /dev/null
+++ b/cross-app-connect/src/app/page.tsx
@@ -0,0 +1,49 @@
+'use client';
+
+import {
+ Box,
+ Code,
+ Container,
+ Heading,
+ ListItem,
+ Stack,
+ Text,
+ UnorderedList,
+} from '@chakra-ui/react';
+import { VechainHeader } from './components/VechainHeader';
+
+export default function LandingPage() {
+ return (
+
+
+
+
+
+ Routes
+
+
+
+ /cross-app/connect — handles
+ connection requests
+
+
+ /cross-app/transact — handles
+ transaction / signing requests
+
+
+
+
+ This page isn't opened directly by users.
+
+
+
+ );
+}
diff --git a/cross-app-connect/src/app/providers/VechainKitProviderWrapper.tsx b/cross-app-connect/src/app/providers/VechainKitProviderWrapper.tsx
new file mode 100644
index 00000000..a75e4f09
--- /dev/null
+++ b/cross-app-connect/src/app/providers/VechainKitProviderWrapper.tsx
@@ -0,0 +1,72 @@
+'use client';
+
+import dynamic from 'next/dynamic';
+
+const VeChainKitProvider = dynamic(
+ async () => (await import('@vechain/vechain-kit')).VeChainKitProvider,
+ { ssr: false },
+);
+
+interface Props {
+ children: React.ReactNode;
+}
+
+const coloredLogo =
+ 'https://vechain.org/wp-content/uploads/2025/02/VeChain_Icon_Quartz_300ppi.png';
+
+export function VechainKitProviderWrapper({ children }: Props) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/cross-app-connect/src/app/theme/brand.ts b/cross-app-connect/src/app/theme/brand.ts
new file mode 100644
index 00000000..6887c77d
--- /dev/null
+++ b/cross-app-connect/src/app/theme/brand.ts
@@ -0,0 +1,60 @@
+/**
+ * Design tokens mirror the defaults used by `@vechain/vechain-kit` so the
+ * whitelabel host's UI feels native next to consumer dApps. The source of
+ * truth is `packages/vechain-kit/src/theme/tokens.ts` (defaultLightTokens
+ * / defaultDarkTokens) -- keep them in sync if the kit's defaults shift.
+ *
+ * Brand identity comes from the VeChain wordmark / VeChain purple chip,
+ * not from a brand-colored primary button -- the kit deliberately uses
+ * monochrome buttons (dark in light mode, white in dark mode) with a
+ * blue accent for spinners / focus rings.
+ */
+export const tokens = {
+ light: {
+ modalBg: '#FFFFFF',
+ cardBg: '#F5F5F5',
+ cardElevatedBg: '#FFFFFF',
+ textPrimary: '#2E2E2E',
+ textSecondary: '#4D4D4D',
+ textTertiary: '#718096',
+ borderDefault: 'transparent',
+ borderButton: '#EBEBEB',
+ borderHover: '#D0D0D0',
+ loginBtnBg: '#FFFFFF',
+ loginBtnColor: '#1A1A1A',
+ loginBtnHoverBg: '#F0F0F0',
+ primaryBtnBg: '#272A2E',
+ primaryBtnColor: '#FFFFFF',
+ accent: '#3B82F6',
+ chipBg: 'rgba(114, 102, 255, 0.12)',
+ chipText: '#5B50CC',
+ },
+ dark: {
+ modalBg: '#151515',
+ cardBg: 'rgba(255, 255, 255, 0.04)',
+ cardElevatedBg: '#2A2A2A',
+ textPrimary: 'rgb(223, 223, 221)',
+ textSecondary: 'rgba(223, 223, 221, 0.6)',
+ textTertiary: 'rgba(223, 223, 221, 0.4)',
+ borderDefault: 'rgba(255, 255, 255, 0.1)',
+ borderButton: 'rgba(255, 255, 255, 0.1)',
+ borderHover: 'rgba(255, 255, 255, 0.2)',
+ loginBtnBg: 'transparent',
+ loginBtnColor: '#FFFFFF',
+ loginBtnHoverBg: 'rgba(255, 255, 255, 0.05)',
+ primaryBtnBg: '#FFFFFF',
+ primaryBtnColor: 'rgba(0, 0, 0, 0.9)',
+ accent: '#60A5FA',
+ chipBg: 'rgba(114, 102, 255, 0.2)',
+ chipText: '#B9B0FF',
+ },
+ fontHeading: `"Satoshi", "Inter", system-ui, -apple-system, sans-serif`,
+ fontBody: `"Inter", system-ui, -apple-system, sans-serif`,
+ radius: {
+ sm: '8px',
+ md: '12px',
+ lg: '16px',
+ xl: '24px',
+ full: '9999px',
+ },
+} as const;
diff --git a/cross-app-connect/src/app/theme/index.ts b/cross-app-connect/src/app/theme/index.ts
new file mode 100644
index 00000000..c1ea60bc
--- /dev/null
+++ b/cross-app-connect/src/app/theme/index.ts
@@ -0,0 +1,2 @@
+export { vechainTheme } from './theme';
+export { tokens } from './brand';
diff --git a/cross-app-connect/src/app/theme/theme.tsx b/cross-app-connect/src/app/theme/theme.tsx
new file mode 100644
index 00000000..0567ea99
--- /dev/null
+++ b/cross-app-connect/src/app/theme/theme.tsx
@@ -0,0 +1,175 @@
+import { extendTheme, type ThemeConfig } from '@chakra-ui/react';
+import { tokens } from './brand';
+
+// Force light mode for now. To re-enable system / dark, switch
+// initialColorMode back to 'system' and useSystemColorMode to true.
+const config: ThemeConfig = {
+ initialColorMode: 'light',
+ useSystemColorMode: false,
+ cssVarPrefix: 'vc',
+};
+
+export const vechainTheme = extendTheme({
+ config,
+ fonts: {
+ heading: tokens.fontHeading,
+ body: tokens.fontBody,
+ },
+ semanticTokens: {
+ colors: {
+ 'page-bg': {
+ _light: tokens.light.modalBg,
+ _dark: tokens.dark.modalBg,
+ },
+ 'card-bg': {
+ _light: tokens.light.cardBg,
+ _dark: tokens.dark.cardBg,
+ },
+ 'card-elevated-bg': {
+ _light: tokens.light.cardElevatedBg,
+ _dark: tokens.dark.cardElevatedBg,
+ },
+ 'card-border': {
+ _light: 'transparent',
+ _dark: tokens.dark.borderDefault,
+ },
+ 'text-strong': {
+ _light: tokens.light.textPrimary,
+ _dark: tokens.dark.textPrimary,
+ },
+ 'text-muted': {
+ _light: tokens.light.textSecondary,
+ _dark: tokens.dark.textSecondary,
+ },
+ 'text-subtle': {
+ _light: tokens.light.textTertiary,
+ _dark: tokens.dark.textTertiary,
+ },
+ 'login-btn-bg': {
+ _light: tokens.light.loginBtnBg,
+ _dark: tokens.dark.loginBtnBg,
+ },
+ 'login-btn-color': {
+ _light: tokens.light.loginBtnColor,
+ _dark: tokens.dark.loginBtnColor,
+ },
+ 'login-btn-border': {
+ _light: tokens.light.borderButton,
+ _dark: tokens.dark.borderButton,
+ },
+ 'login-btn-hover-bg': {
+ _light: tokens.light.loginBtnHoverBg,
+ _dark: tokens.dark.loginBtnHoverBg,
+ },
+ 'primary-btn-bg': {
+ _light: tokens.light.primaryBtnBg,
+ _dark: tokens.dark.primaryBtnBg,
+ },
+ 'primary-btn-color': {
+ _light: tokens.light.primaryBtnColor,
+ _dark: tokens.dark.primaryBtnColor,
+ },
+ 'chip-bg': { _light: tokens.light.chipBg, _dark: tokens.dark.chipBg },
+ 'chip-text': {
+ _light: tokens.light.chipText,
+ _dark: tokens.dark.chipText,
+ },
+ accent: { _light: tokens.light.accent, _dark: tokens.dark.accent },
+ },
+ },
+ components: {
+ Button: {
+ baseStyle: {
+ fontFamily: tokens.fontBody,
+ fontWeight: 500,
+ letterSpacing: '-0.005em',
+ },
+ variants: {
+ // Primary CTA: kit's vechainKitPrimary look -- pill shape,
+ // 60px tall, monochrome (dark on light bg / white on dark).
+ brand: {
+ bg: 'primary-btn-bg',
+ color: 'primary-btn-color',
+ rounded: tokens.radius.full,
+ h: '60px',
+ px: 4,
+ fontWeight: 500,
+ _hover: { opacity: 0.8 },
+ _disabled: { opacity: 0.5, cursor: 'not-allowed' },
+ transition: 'all 0.2s',
+ },
+ // Provider row: kit's loginIn look -- 52px tall, large radius,
+ // subtle border, label left-aligned.
+ row: {
+ bg: 'login-btn-bg',
+ color: 'login-btn-color',
+ border: '1px solid',
+ borderColor: 'login-btn-border',
+ rounded: tokens.radius.lg,
+ h: '52px',
+ px: '18px',
+ w: 'full',
+ justifyContent: 'flex-start',
+ fontWeight: 600,
+ fontSize: '15px',
+ _hover: {
+ bg: 'login-btn-hover-bg',
+ borderColor: 'login-btn-border',
+ },
+ _active: { transform: 'scale(0.99)' },
+ transition: 'all 0.2s',
+ },
+ // Ghost: pill-shaped outlined button so it reads as a real
+ // action at rest, not stray text. Border matches the login
+ // row border so they nest visually.
+ ghost: {
+ bg: 'transparent',
+ color: 'text-muted',
+ border: '1px solid',
+ borderColor: 'login-btn-border',
+ rounded: tokens.radius.full,
+ h: '48px',
+ px: 6,
+ _hover: {
+ bg: 'login-btn-hover-bg',
+ color: 'text-strong',
+ },
+ _active: { bg: 'login-btn-hover-bg' },
+ _disabled: { opacity: 0.4 },
+ transition: 'all 0.2s',
+ },
+ // Subtle text link, used for "show more" type affordances.
+ link: {
+ bg: 'transparent',
+ color: 'text-muted',
+ fontWeight: 500,
+ fontSize: 'sm',
+ h: 'auto',
+ p: 0,
+ _hover: {
+ color: 'text-strong',
+ textDecoration: 'underline',
+ },
+ },
+ },
+ },
+ Card: {
+ baseStyle: {
+ container: {
+ bg: 'card-bg',
+ borderRadius: tokens.radius.md,
+ border: 'none',
+ },
+ },
+ },
+ },
+ styles: {
+ global: {
+ 'html, body': {
+ bg: 'page-bg',
+ color: 'text-strong',
+ fontFamily: tokens.fontBody,
+ },
+ },
+ },
+});
diff --git a/cross-app-connect/tsconfig.json b/cross-app-connect/tsconfig.json
new file mode 100644
index 00000000..139a8524
--- /dev/null
+++ b/cross-app-connect/tsconfig.json
@@ -0,0 +1,37 @@
+{
+ "compilerOptions": {
+ "target": "es5",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "tsBuildInfoFile": ".next/.tsbuildinfo",
+ "assumeChangesOnlyAffectDirectDependencies": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./src/*"],
+ "@vechain-kit/*": ["../packages/vechain-kit/src/*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ "dist/types/**/*.ts"
+ ],
+ "exclude": ["node_modules", ".next", "dist"]
+}
diff --git a/docs/login-modal.md b/docs/login-modal.md
index e42a361a..66a6bb71 100644
--- a/docs/login-modal.md
+++ b/docs/login-modal.md
@@ -30,9 +30,14 @@ Without Privy:
```
[ Continue with VeWorld ]
+[ Continue with Google ]
+[ Continue with Apple ]
+[ Continue with Email ]
[ Sync2 ] [ WalletConnect ]
```
+Social methods (Google, Apple, Email, X, Discord, GitHub, TikTok, LINE) work without a host-supplied `privy` prop too — the kit routes them through VeChain's whitelabel cross-app host. Only `passkey` and `more` still require Privy.
+
## Configuration
The grid is controlled by `loginMethods` on ``. Each entry has a `method` and an optional `gridColumn` (1–4) that sets how many of the four columns the button occupies.
@@ -65,12 +70,12 @@ The grid is controlled by `loginMethods` on ``. Each entry h
| `veworld` | `dappKit.allowedWallets` includes `'veworld'` | Custom flow — opens the VeWorld extension / mobile in-app browser and shows the kit's "Waiting for signature…" view. |
| `sync2` | `dappKit.allowedWallets` includes `'sync2'` | Custom flow — drives Sync2 with the same waiting view. |
| `wallet-connect` | `dappKit.allowedWallets` includes `'wallet-connect'` + `walletConnectOptions.projectId` | Triggers WalletConnect's own QR modal programmatically. The kit's loading view sits behind. |
-| `google` | `privy` | Privy Google OAuth. |
-| `apple` | `privy` | Privy Apple OAuth. |
-| `github` | `privy` | Privy GitHub OAuth. |
-| `email` | `privy` | Inline email pill + 6-digit code modal. |
-| `passkey` | `privy` | Privy WebAuthn flow. |
-| `vechain` | `privy` | VeChain cross-app login (single wallet across Privy ecosystem apps). |
+| `google` | — | Google OAuth via host's Privy when `privy` is set, otherwise via VeChain's whitelabel cross-app host. |
+| `apple` | — | Apple OAuth via host's Privy when `privy` is set, otherwise via VeChain's whitelabel cross-app host. |
+| `github` | — | GitHub OAuth via host's Privy when `privy` is set, otherwise via VeChain's whitelabel cross-app host. |
+| `email` | — | Inline email pill + 6-digit code modal with `privy` set; otherwise hands the email/OTP flow off to VeChain's whitelabel cross-app host. |
+| `passkey` | `privy` | Privy WebAuthn flow. No cross-app fallback yet. |
+| `vechain` | — | VeChain cross-app login (single wallet across Privy ecosystem apps). |
| `ecosystem` | — | Renders a footer button that opens a sub-view listing x2earn ecosystem apps. |
| `more` | — | Renders a "More options ⌄" link footer that opens an in-modal sub-view containing _every_ overflow option (other wallets, other Privy socials, ecosystem apps). |
| `dappkit` | `dappKit` | **Legacy.** Opens dapp-kit's native picker modal. Kept for backwards compatibility. Prefer the granular methods above. |
@@ -80,7 +85,7 @@ The grid is controlled by `loginMethods` on ``. Each entry h
When the user taps **More options ⌄**, the modal cross-fades into a sub-view that surfaces _overflow_ from your provider config:
- **Other wallets** — every entry in `dappKit.allowedWallets` not already on the main grid (VeWorld / Sync2 / WalletConnect).
-- **Other sign-in** — every Privy method in `privy.loginMethods` we render natively (Google, Apple, GitHub, email, passkey). Anything else (Twitter, Discord, Farcaster, TikTok, …) is reachable via a fallback link that opens Privy's own modal.
+- **Other sign-in** — every Privy method in `privy.loginMethods` we render natively (Google, Apple, GitHub, email, passkey). Anything else (Twitter, Discord, Farcaster, TikTok, LINE, …) is reachable via a fallback link.
- **Ecosystem apps** — the x2earn apps configured via Privy ecosystem.
Items already shown on the main grid are de-duplicated. Sections collapse when they would be empty.
@@ -143,5 +148,6 @@ Both flows show the same "Waiting for signature…" view and surface errors / re
## Migration from `< 2.6.x`
- The default `loginMethods` changed. If you _didn't_ pass `loginMethods`, the modal previously rendered `[vechain, ecosystem, dappkit]` and now renders `[veworld, google, apple, more]` (Privy) or `[veworld, sync2, wallet-connect]` (no Privy).
+- `google`, `apple`, and `email` no longer throw a "requires Privy configuration" error when listed in `loginMethods` without a `privy` prop. They route through VeChain's whitelabel cross-app host instead. `useLoginWithOAuth({ provider })` does the same routing for `google | apple | twitter | discord | github | tiktok | line`. To pre-select a provider via the cross-app flow, use `useLoginWithVeChain({ intent: 'google' })`.
- The legacy `'dappkit'` method is still supported and still opens dapp-kit's native modal — no breaking change for apps that pin it.
- The new granular methods (`'veworld'`, `'sync2'`, `'wallet-connect'`) honour `dappKit.allowedWallets` as a gate, so you can't accidentally render a wallet you didn't enable.
diff --git a/examples/homepage/src/app/components/features/LoginUIControl/LoginUIControl.tsx b/examples/homepage/src/app/components/features/LoginUIControl/LoginUIControl.tsx
index e9590be7..1a473e89 100644
--- a/examples/homepage/src/app/components/features/LoginUIControl/LoginUIControl.tsx
+++ b/examples/homepage/src/app/components/features/LoginUIControl/LoginUIControl.tsx
@@ -1,6 +1,14 @@
'use client';
-import { VStack, Text, Box, Grid, Button, Icon } from '@chakra-ui/react';
+import {
+ VStack,
+ Text,
+ Box,
+ Grid,
+ Button,
+ Icon,
+ SimpleGrid,
+} from '@chakra-ui/react';
import {
WalletButton,
useConnectModal,
@@ -8,7 +16,38 @@ import {
useLoginWithOAuth,
} from '@vechain/vechain-kit';
import { FcGoogle } from 'react-icons/fc';
-import { LuGithub } from 'react-icons/lu';
+import {
+ FaApple,
+ FaDiscord,
+ FaGithub,
+ FaLine,
+ FaTiktok,
+} from 'react-icons/fa';
+import { FaXTwitter } from 'react-icons/fa6';
+import type { IconType } from 'react-icons';
+
+// OAuth providers enabled in VeChain's Privy dashboard. Farcaster and
+// WhatsApp are also enabled but use non-OAuth login flows.
+const OAUTH_PROVIDERS: ReadonlyArray<{
+ id:
+ | 'google'
+ | 'apple'
+ | 'twitter'
+ | 'discord'
+ | 'github'
+ | 'tiktok'
+ | 'line';
+ label: string;
+ icon: IconType;
+}> = [
+ { id: 'google', label: 'Google', icon: FcGoogle },
+ { id: 'apple', label: 'Apple', icon: FaApple },
+ { id: 'twitter', label: 'X', icon: FaXTwitter },
+ { id: 'discord', label: 'Discord', icon: FaDiscord },
+ { id: 'github', label: 'GitHub', icon: FaGithub },
+ { id: 'tiktok', label: 'TikTok', icon: FaTiktok },
+ { id: 'line', label: 'LINE', icon: FaLine },
+];
export const LoginUIControl = () => {
const { open } = useConnectModal();
@@ -166,85 +205,36 @@ export const LoginUIControl = () => {
bg="whiteAlpha.50"
>
OAuth Login Examples
-
- {/* Google OAuth Button */}
-
-
-
-
-
- OAuth: Google
-
-
-
- {/* GitHub OAuth Button */}
-
-
-
-
- (
+
-
-
+ {p.label}
+
+ ))}
+
- Note: These buttons use the useLoginWithOAuth hook to
- initiate OAuth authentication flows with social providers.
- Make sure the providers are configured in your Privy
- dashboard.
+ Note: These buttons use the useLoginWithOAuth hook. With a
+ privy prop on VeChainKitProvider, OAuth runs through your
+ own Privy app. Without it, the hook automatically routes
+ through the VeChain whitelabel cross-app host — so
+ these buttons work for any consumer dApp out of the box.
diff --git a/examples/homepage/src/app/providers/VechainKitProviderWrapper.tsx b/examples/homepage/src/app/providers/VechainKitProviderWrapper.tsx
index a6611450..29134aca 100644
--- a/examples/homepage/src/app/providers/VechainKitProviderWrapper.tsx
+++ b/examples/homepage/src/app/providers/VechainKitProviderWrapper.tsx
@@ -139,31 +139,32 @@ export function VechainKitProviderWrapper({ children }: Props) {
},
},
}}
- privy={{
- appId: process.env.NEXT_PUBLIC_PRIVY_APP_ID!,
- clientId: process.env.NEXT_PUBLIC_PRIVY_CLIENT_ID!,
- loginMethods: [
- 'google',
- 'apple',
- 'twitter',
- 'github',
- 'farcaster',
- // 'email',
- 'discord',
- 'tiktok',
- // 'rabby_wallet',
- // 'coinbase_wallet',
- // 'rainbow',
- // 'metamask',
- ],
- appearance: {
- loginMessage: 'Select a login method',
- logo: logo,
- },
- embeddedWallets: {
- createOnLogin: 'all-users',
- },
- }}
+ // privy={{
+ // appId: process.env.NEXT_PUBLIC_PRIVY_APP_ID!,
+ // clientId: process.env.NEXT_PUBLIC_PRIVY_CLIENT_ID!,
+ // loginMethods: [
+ // 'google',
+ // 'apple',
+ // 'twitter',
+ // 'github',
+ // 'farcaster',
+ // // 'email',
+ // 'discord',
+ // 'tiktok',
+ // // 'rabby_wallet',
+ // // 'coinbase_wallet',
+ // // 'rainbow',
+ // // 'metamask',
+ // ],
+ // appearance: {
+ // loginMessage: 'Select a login method',
+ // logo: logo,
+ // },
+ // embeddedWallets: {
+ // createOnLogin: 'all-users',
+ // },
+ // }}
+
dappKit={{
allowedWallets: ['veworld', 'wallet-connect'],
walletConnectOptions: {
@@ -193,7 +194,7 @@ export function VechainKitProviderWrapper({ children }: Props) {
{ method: 'veworld', gridColumn: 4 },
{ method: 'google', gridColumn: 4 },
{ method: 'apple', gridColumn: 4 },
- { method: 'more', gridColumn: 4 },
+ // { method: 'more', gridColumn: 4 },
]}
darkMode={false}
network={{
@@ -201,6 +202,9 @@ export function VechainKitProviderWrapper({ children }: Props) {
// nodeUrl: 'http://localhost:8669',
}}
allowCustomTokens={true}
+ feeDelegation={{
+ delegatorUrl: process.env.NEXT_PUBLIC_DELEGATOR_URL!,
+ }}
>
{children}
diff --git a/examples/playground/src/app/components/features/LoginUIControl/LoginUIControl.tsx b/examples/playground/src/app/components/features/LoginUIControl/LoginUIControl.tsx
index 5b37c406..bd56c235 100644
--- a/examples/playground/src/app/components/features/LoginUIControl/LoginUIControl.tsx
+++ b/examples/playground/src/app/components/features/LoginUIControl/LoginUIControl.tsx
@@ -1,6 +1,14 @@
'use client';
-import { VStack, Text, Box, Grid, Button, Icon } from '@chakra-ui/react';
+import {
+ VStack,
+ Text,
+ Box,
+ Grid,
+ Button,
+ Icon,
+ SimpleGrid,
+} from '@chakra-ui/react';
import {
WalletButton,
useConnectModal,
@@ -9,7 +17,38 @@ import {
} from '@vechain/vechain-kit';
import { useTranslation } from 'react-i18next';
import { FcGoogle } from 'react-icons/fc';
-import { LuGithub } from 'react-icons/lu';
+import {
+ FaApple,
+ FaDiscord,
+ FaGithub,
+ FaLine,
+ FaTiktok,
+} from 'react-icons/fa';
+import { FaXTwitter } from 'react-icons/fa6';
+import type { IconType } from 'react-icons';
+
+// OAuth providers enabled in VeChain's Privy dashboard. Farcaster and
+// WhatsApp are also enabled but use non-OAuth login flows.
+const OAUTH_PROVIDERS: ReadonlyArray<{
+ id:
+ | 'google'
+ | 'apple'
+ | 'twitter'
+ | 'discord'
+ | 'github'
+ | 'tiktok'
+ | 'line';
+ label: string;
+ icon: IconType;
+}> = [
+ { id: 'google', label: 'Google', icon: FcGoogle },
+ { id: 'apple', label: 'Apple', icon: FaApple },
+ { id: 'twitter', label: 'X', icon: FaXTwitter },
+ { id: 'discord', label: 'Discord', icon: FaDiscord },
+ { id: 'github', label: 'GitHub', icon: FaGithub },
+ { id: 'tiktok', label: 'TikTok', icon: FaTiktok },
+ { id: 'line', label: 'LINE', icon: FaLine },
+];
export const LoginUIControl = () => {
const { open } = useConnectModal();
@@ -171,83 +210,33 @@ export const LoginUIControl = () => {
bg="whiteAlpha.50"
>
OAuth Login Examples
-
- {/* Google OAuth Button */}
-
-
-
-
-
- OAuth: Google
-
-
-
- {/* GitHub OAuth Button */}
-
-
-
-
- (
+
-
-
+ {p.label}
+
+ ))}
+
{t(
- 'Note: These buttons use the useLoginWithOAuth hook to initiate OAuth authentication flows with social providers. Make sure the providers are configured in your Privy dashboard.',
+ 'Note: These buttons use the useLoginWithOAuth hook. With a privy prop on VeChainKitProvider, OAuth runs through your own Privy app. Without it, the hook automatically routes through the VeChain whitelabel cross-app host -- so these buttons work for any consumer dApp out of the box.',
)}
diff --git a/examples/playground/src/app/providers/VechainKitProviderWrapper.tsx b/examples/playground/src/app/providers/VechainKitProviderWrapper.tsx
index 82be6d16..fe51e34e 100644
--- a/examples/playground/src/app/providers/VechainKitProviderWrapper.tsx
+++ b/examples/playground/src/app/providers/VechainKitProviderWrapper.tsx
@@ -168,31 +168,32 @@ export function VechainKitProviderWrapper({ children }: Props) {
theme={theme}
language={kitLanguage}
i18n={playgroundTranslations}
- privy={{
- appId: process.env.NEXT_PUBLIC_PRIVY_APP_ID!,
- clientId: process.env.NEXT_PUBLIC_PRIVY_CLIENT_ID!,
- loginMethods: [
- 'google',
- 'apple',
- 'twitter',
- 'github',
- 'farcaster',
- // 'email',
- 'discord',
- 'tiktok',
- // 'rabby_wallet',
- // 'coinbase_wallet',
- // 'rainbow',
- // 'metamask',
- ],
- appearance: {
- loginMessage: 'Select a login method',
- logo: logo,
- },
- embeddedWallets: {
- createOnLogin: 'all-users',
- },
- }}
+ // privy={{
+ // appId: process.env.NEXT_PUBLIC_PRIVY_APP_ID!,
+ // clientId: process.env.NEXT_PUBLIC_PRIVY_CLIENT_ID!,
+ // loginMethods: [
+ // 'google',
+ // 'apple',
+ // 'twitter',
+ // 'github',
+ // 'farcaster',
+ // // 'email',
+ // 'discord',
+ // 'tiktok',
+ // // 'rabby_wallet',
+ // // 'coinbase_wallet',
+ // // 'rainbow',
+ // // 'metamask',
+ // ],
+ // appearance: {
+ // loginMessage: 'Select a login method',
+ // logo: logo,
+ // },
+ // embeddedWallets: {
+ // createOnLogin: 'all-users',
+ // },
+ // }}
+
dappKit={{
allowedWallets: ['veworld', 'wallet-connect'],
walletConnectOptions: {
@@ -211,16 +212,16 @@ export function VechainKitProviderWrapper({ children }: Props) {
},
}}
loginMethods={[
- // { method: 'email', gridColumn: 4 },
- // { method: 'google', gridColumn: 4 },
+ { method: 'google', gridColumn: 4 },
+ { method: 'apple', gridColumn: 4 },
+ { method: 'vechain', gridColumn: 4 },
// { method: 'github', gridColumn: 4 },
- // { method: 'vechain', gridColumn: 4 },
// { method: 'dappkit', gridColumn: 4 },
// { method: 'ecosystem', gridColumn: 4 },
- { method: 'veworld', gridColumn: 4 },
- { method: 'google', gridColumn: 4 },
- { method: 'apple', gridColumn: 4 },
- { method: 'more', gridColumn: 4 },
+ // { method: 'veworld', gridColumn: 4 },
+ // { method: 'google', gridColumn: 4 },
+ // { method: 'apple', gridColumn: 4 },
+ // { method: 'more', gridColumn: 4 },
// { method: 'passkey', gridColumn: 4 },
// { method: 'more', gridColumn: 1 },
]}
@@ -236,6 +237,9 @@ export function VechainKitProviderWrapper({ children }: Props) {
// vot3ContractAddress:
// '0xf7a08af15cb3501feee53ebe11f4428a966fa459',
// }}
+ feeDelegation={{
+ delegatorUrl: process.env.NEXT_PUBLIC_DELEGATOR_URL!,
+ }}
>
{children}
diff --git a/package.json b/package.json
index 03bfdc42..39f1008e 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,8 @@
"examples/*",
"!examples/test-tailwind-vck",
"packages/*",
- "lambda"
+ "lambda",
+ "cross-app-connect"
],
"keywords": [
"vechain",
@@ -48,7 +49,9 @@
"dev:homepage:build": "yarn workspace @vechain/vechain-kit build & yarn workspace vechain-kit-homepage build",
"dev:next-template": "yarn workspace @vechain/vechain-kit watch & yarn workspace next-template dev",
"dev:next-template:build": "yarn workspace @vechain/vechain-kit build & yarn workspace next-template build",
- "dev:next-chakra-v3": "yarn workspace @vechain/vechain-kit watch & yarn workspace next-chakra-v3 dev"
+ "dev:next-chakra-v3": "yarn workspace @vechain/vechain-kit watch & yarn workspace next-chakra-v3 dev",
+ "dev:cross-app-connect": "yarn workspace @vechain/vechain-kit watch & yarn workspace cross-app-connect dev",
+ "dev:cross-app-connect:build": "yarn workspace @vechain/vechain-kit build & yarn workspace cross-app-connect build"
},
"husky": {
"hooks": {
diff --git a/packages/vechain-kit/package.json b/packages/vechain-kit/package.json
index 7d5e6a86..4dca0fbe 100644
--- a/packages/vechain-kit/package.json
+++ b/packages/vechain-kit/package.json
@@ -58,7 +58,7 @@
"@adraffy/ens-normalize": "^1.11.0",
"@chakra-ui/react": "^2.8.2",
"@emotion/styled": "^11.14.1",
- "@privy-io/cross-app-connect": "0.2.2",
+ "@privy-io/cross-app-connect": "0.5.8",
"@privy-io/react-auth": "2.25.0",
"@solana/web3.js": "^1.98.0",
"@tanstack/react-query": "^5.64.2",
diff --git a/packages/vechain-kit/src/components/ConnectModal/Components/EmailLoginButton.tsx b/packages/vechain-kit/src/components/ConnectModal/Components/EmailLoginButton.tsx
index 2671ade7..258439f6 100644
--- a/packages/vechain-kit/src/components/ConnectModal/Components/EmailLoginButton.tsx
+++ b/packages/vechain-kit/src/components/ConnectModal/Components/EmailLoginButton.tsx
@@ -15,11 +15,17 @@ import { EmailCodeVerificationModal } from '../../EmailCodeVerificationModal/Ema
import { useTranslation } from 'react-i18next';
import { useVeChainKitConfig } from '@/providers';
+/**
+ * Inline email input + OTP modal flow. Requires a host-supplied privy
+ * prop because VeChain's own Privy app has email disabled, so the
+ * whitelabel cross-app host can't accept email-based logins. When
+ * the consumer dApp has no privy, useLoginModalContent hides this
+ * button entirely.
+ */
export const EmailLoginButton = () => {
const { t } = useTranslation();
const { darkMode: isDark } = useVeChainKitConfig();
- // Email login
const [email, setEmail] = useState('');
const { sendCode, state: emailState } = useLoginWithEmail({});
@@ -28,7 +34,6 @@ export const EmailLoginButton = () => {
const handleSendCode = async () => {
await sendCode({ email });
- // onClose();
emailCodeVerificationModal.onOpen();
};
@@ -96,3 +101,4 @@ export const EmailLoginButton = () => {
>
);
};
+
diff --git a/packages/vechain-kit/src/hooks/login/useLoginWithOAuth.ts b/packages/vechain-kit/src/hooks/login/useLoginWithOAuth.ts
index 8e17c559..6198c714 100644
--- a/packages/vechain-kit/src/hooks/login/useLoginWithOAuth.ts
+++ b/packages/vechain-kit/src/hooks/login/useLoginWithOAuth.ts
@@ -4,6 +4,9 @@ import {
OAuthProviderType,
} from '@privy-io/react-auth';
import { useCallback } from 'react';
+import { useVeChainKitConfig } from '@/providers';
+import { useLoginWithVeChain } from './useLoginWithVeChain';
+import type { CrossAppLoginIntent } from '@/providers/PrivyCrossAppProvider';
interface OAuthOptions {
provider: OAuthProviderType;
@@ -12,8 +15,25 @@ interface OAuthOptions {
// Module-level variable shared across all hook instances
let hasCreatedWallet = false;
+// Providers enabled in VeChain's Privy app that the whitelabel
+// cross-app-connect host can handle via `useLoginWithOAuth`. Spotify /
+// Instagram / LinkedIn are NOT in this set because they are disabled in
+// the VeChain Privy dashboard; calling them would 4xx at the provider.
+// Farcaster and WhatsApp are enabled but use different login flows.
+const CROSS_APP_INTENT_PROVIDERS = new Set([
+ 'google',
+ 'apple',
+ 'twitter',
+ 'discord',
+ 'github',
+ 'tiktok',
+ 'line',
+]);
+
export const useLoginWithOAuth = () => {
+ const { privy } = useVeChainKitConfig();
const { createWallet } = useCreateWallet();
+ const { login: loginViaCrossApp } = useLoginWithVeChain();
// Memoize the onComplete callback to prevent recreation on every render
const handleComplete = useCallback(
@@ -23,7 +43,7 @@ export const useLoginWithOAuth = () => {
if (isNewUser && !hasCreatedWallet) {
// Set the flag BEFORE the async operation to prevent race conditions
hasCreatedWallet = true;
-
+
try {
await createWallet();
} catch (error) {
@@ -42,11 +62,25 @@ export const useLoginWithOAuth = () => {
});
const initOAuth = async ({ provider }: OAuthOptions) => {
- try {
- await privyInitOAuth({ provider });
- } catch (error) {
- throw error;
+ // When the consumer dApp doesn't supply a `privy` prop, route
+ // supported OAuth providers through the VeChain whitelabel cross-app
+ // flow instead of Privy directly (whose dummy app id can't service
+ // a real OAuth handshake).
+ if (!privy) {
+ if (CROSS_APP_INTENT_PROVIDERS.has(provider)) {
+ await loginViaCrossApp({
+ intent: provider as CrossAppLoginIntent,
+ });
+ return;
+ }
+ throw new Error(
+ `OAuth provider "${provider}" requires a Privy configuration. ` +
+ `Supported without Privy via the VeChain whitelabel host: ` +
+ `${[...CROSS_APP_INTENT_PROVIDERS].join(', ')}.`,
+ );
}
+
+ await privyInitOAuth({ provider });
};
return { initOAuth };
diff --git a/packages/vechain-kit/src/hooks/login/useLoginWithVeChain.ts b/packages/vechain-kit/src/hooks/login/useLoginWithVeChain.ts
index 405482e1..d283f6eb 100644
--- a/packages/vechain-kit/src/hooks/login/useLoginWithVeChain.ts
+++ b/packages/vechain-kit/src/hooks/login/useLoginWithVeChain.ts
@@ -1,18 +1,32 @@
-import { usePrivyCrossAppSdk } from '@/providers/PrivyCrossAppProvider';
+import {
+ usePrivyCrossAppSdk,
+ type CrossAppLoginIntent,
+} from '@/providers/PrivyCrossAppProvider';
+
+export type { CrossAppLoginIntent };
import { useCrossAppConnectionCache } from '@/hooks/cache/useCrossAppConnectionCache';
import { useFetchAppInfo } from '@/hooks';
import { VECHAIN_PRIVY_APP_ID } from '@/utils';
import { handlePopupError } from '@/utils/handlePopupError';
import { VEBETTERDAO_GOVERNANCE_BASE_URL } from '@/constants';
+export type UseLoginWithVeChainOptions = {
+ /**
+ * Pre-select a login method on the VeChain whitelabel connect page.
+ * When set, the user skips the provider picker and jumps straight into
+ * the matching OAuth flow (or email form for `'email'`).
+ */
+ intent?: CrossAppLoginIntent;
+};
+
export const useLoginWithVeChain = () => {
const { login: loginWithVeChain } = usePrivyCrossAppSdk();
const { setConnectionCache } = useCrossAppConnectionCache();
const { data: appsInfo } = useFetchAppInfo([VECHAIN_PRIVY_APP_ID]);
- const login = async () => {
+ const login = async (options?: UseLoginWithVeChainOptions) => {
try {
- await loginWithVeChain(VECHAIN_PRIVY_APP_ID);
+ await loginWithVeChain(VECHAIN_PRIVY_APP_ID, options);
setConnectionCache({
name: 'VeChain',
diff --git a/packages/vechain-kit/src/hooks/modals/useLoginModalContent.ts b/packages/vechain-kit/src/hooks/modals/useLoginModalContent.ts
index e2341894..671d30fe 100644
--- a/packages/vechain-kit/src/hooks/modals/useLoginModalContent.ts
+++ b/packages/vechain-kit/src/hooks/modals/useLoginModalContent.ts
@@ -112,15 +112,15 @@ export const useLoginModalContent = (): LoginModalContentConfig => {
};
if (!privy) {
- // External apps (no self hosted privy)
+ // External apps (no self hosted privy). Most OAuth methods fall
+ // back to the VeChain whitelabel cross-app flow via
+ // useLoginWithVeChain({ intent }). Email / Passkey / 'more' have
+ // no fallback (VeChain has email disabled), so they stay hidden.
return {
...baseConfig,
- showGoogleLogin: false,
- showAppleLogin: false,
showEmailLogin: false,
showPasskey: false,
showMoreLogin: false,
- showGithubLogin: false,
};
}
diff --git a/packages/vechain-kit/src/languages/en.json b/packages/vechain-kit/src/languages/en.json
index 65ab667a..ff683bf9 100644
--- a/packages/vechain-kit/src/languages/en.json
+++ b/packages/vechain-kit/src/languages/en.json
@@ -490,5 +490,6 @@
"Trait": "Trait",
"Send NFT": "Send NFT",
"{{count}} item": "{{count}} item",
- "{{count}} items": "{{count}} items"
+ "{{count}} items": "{{count}} items",
+ "Continue with Email": "Continue with Email"
}
diff --git a/packages/vechain-kit/src/providers/PrivyCrossAppProvider.tsx b/packages/vechain-kit/src/providers/PrivyCrossAppProvider.tsx
index d3e6814e..f2d6e1d1 100644
--- a/packages/vechain-kit/src/providers/PrivyCrossAppProvider.tsx
+++ b/packages/vechain-kit/src/providers/PrivyCrossAppProvider.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useRef, useState } from 'react';
import { toPrivyWalletConnector } from '@privy-io/cross-app-connect/rainbow-kit';
+import { createPrivyCrossAppClient } from '@privy-io/cross-app-connect';
import {
useConnect,
useDisconnect,
@@ -21,6 +22,46 @@ import {
VECHAINSTATS_BASE_URL,
} from '@/constants';
+/**
+ * Login methods that requester apps can pre-select on the whitelabel
+ * cross-app-connect host. When passed, the host skips its provider picker
+ * and jumps straight into the matching flow.
+ *
+ * Matches the providers enabled in VeChain's Privy dashboard. Email is
+ * intentionally excluded -- VeChain has email disabled, so the host
+ * doesn't surface it. Farcaster is included but currently shows a
+ * "coming soon" placeholder on the host (SIWF flow not yet wired).
+ */
+export type CrossAppLoginIntent =
+ | 'google'
+ | 'apple'
+ | 'twitter'
+ | 'discord'
+ | 'github'
+ | 'tiktok'
+ | 'line'
+ | 'phone'
+ | 'farcaster';
+
+export type LoginWithCrossAppOptions = {
+ /** Pre-select a login method on the provider's connect page. */
+ intent?: CrossAppLoginIntent;
+};
+
+const appendIntent = (url: string, intent: CrossAppLoginIntent) => {
+ const parsed = new URL(url);
+ parsed.searchParams.set('intent', intent);
+ return parsed.toString();
+};
+
+const resolveProviderConnectUrl = async (appID: string) => {
+ const client = createPrivyCrossAppClient({
+ providerAppId: appID,
+ chains: [vechain],
+ });
+ return client.getProviderConnectUrl();
+};
+
export const vechain = defineChain({
id: '1176455790972829965191905223412607679856028701100105089447013101863' as unknown as number,
name: 'Vechain',
@@ -115,22 +156,44 @@ export const usePrivyCrossAppSdk = () => {
}, [disconnectAsync, isConnected]);
const login = useCallback(
- async (appID: string) => {
+ async (appID: string, options?: LoginWithCrossAppOptions) => {
try {
setIsConnecting(true);
setConnectionError(null);
+ const resolvedAppId = appID || VECHAIN_PRIVY_APP_ID;
+
+ if (options?.intent) {
+ // Resolve the registered whitelabel connect URL via the
+ // Privy backend and append intent. This avoids hardcoding
+ // the whitelabel domain in the kit.
+ const baseUrl = await resolveProviderConnectUrl(
+ resolvedAppId,
+ );
+ const overrideConnectUrl = appendIntent(
+ baseUrl,
+ options.intent,
+ );
+ const customConnector = toPrivyWalletConnector({
+ id: resolvedAppId,
+ name:
+ resolvedAppId === VECHAIN_PRIVY_APP_ID
+ ? 'VeChain'
+ : '',
+ iconUrl: '',
+ overrideConnectUrl,
+ });
+ return await connectAsync({ connector: customConnector });
+ }
+
const connector = connectors.find(
- (c) => c.id === (appID || VECHAIN_PRIVY_APP_ID),
+ (c) => c.id === resolvedAppId,
);
-
if (!connector) {
throw new Error('Connector not found');
}
- const result = await connectAsync({ connector });
-
- return result;
+ return await connectAsync({ connector });
} catch (error) {
setConnectionError(error as Error);
throw error;
diff --git a/packages/vechain-kit/src/providers/VeChainKitProvider.tsx b/packages/vechain-kit/src/providers/VeChainKitProvider.tsx
index e2acad01..795d6566 100644
--- a/packages/vechain-kit/src/providers/VeChainKitProvider.tsx
+++ b/packages/vechain-kit/src/providers/VeChainKitProvider.tsx
@@ -48,6 +48,7 @@ import { ModalProvider } from './ModalProvider';
import {
VECHAIN_KIT_STORAGE_KEYS,
DEFAULT_PRIVY_ECOSYSTEM_APPS,
+ VECHAIN_PRIVY_APP_ID,
getGenericDelegatorUrl,
} from '@/utils/constants';
import { Certificate, CertificateData } from '@vechain/sdk-core';
@@ -337,19 +338,17 @@ const validateConfig = (
: [{ method: 'veworld', gridColumn: 4 }];
}
- // Validate login methods if Privy is not configured
+ // Validate login methods if Privy is not configured.
+ // Most OAuth providers fall back to the VeChain whitelabel cross-app
+ // flow (via useLoginWithVeChain({ intent })) when no privy prop is set.
+ // Email, passkey, and 'more' have no cross-app fallback -- VeChain
+ // has email disabled in its Privy app, so cross-app-connect doesn't
+ // surface it either.
if (validatedProps.loginMethods) {
if (!validatedProps.privy) {
const invalidMethods = validatedProps.loginMethods.filter(
(method) =>
- [
- 'email',
- 'google',
- 'apple',
- 'github',
- 'passkey',
- 'more',
- ].includes(method.method),
+ ['email', 'passkey', 'more'].includes(method.method),
);
if (invalidMethods.length > 0) {
@@ -500,9 +499,19 @@ export const VeChainKitProvider = (
let privyAppId: string, privyClientId: string;
if (!privy) {
- // We set dummy values for the appId and clientId so that the PrivyProvider doesn't throw an error
- privyAppId = 'clzdb5k0b02b9qvzjm6jpknsc';
- privyClientId = 'client-WY2oy87y6KNrHFnpXuwVsiFMkwPZKTYpExtjvUQuMbCMF';
+ // No host-supplied Privy config -- fall back to VeChain's own
+ // Privy app so PrivyProvider mounts cleanly. The previous dummy
+ // (`clzdb5k0b02b9qvzjm6jpknsc`) only allowed a handful of origins,
+ // so any deploy outside that list 403'd /api/v1/sessions on
+ // mount and stalled the connect button forever. VeChain's real
+ // app (VECHAIN_PRIVY_APP_ID) has the full kit-ecosystem allow
+ // list and is the same app the whitelabel cross-app host serves,
+ // so the requester and host stay consistent. The cross-app
+ // OAuth fallback in useLoginWithOAuth is gated on the `privy`
+ // prop, not on which app id is mounted, so logging in via this
+ // path still routes through the whitelabel host.
+ privyAppId = VECHAIN_PRIVY_APP_ID;
+ privyClientId = '';
} else {
privyAppId = privy.appId;
privyClientId = privy.clientId;
diff --git a/yarn.lock b/yarn.lock
index cf963cdf..cbd0b9e8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4892,24 +4892,48 @@ __metadata:
languageName: node
linkType: hard
-"@privy-io/cross-app-connect@npm:0.2.2":
- version: 0.2.2
- resolution: "@privy-io/cross-app-connect@npm:0.2.2"
+"@privy-io/cross-app-connect@npm:0.5.8":
+ version: 0.5.8
+ resolution: "@privy-io/cross-app-connect@npm:0.5.8"
dependencies:
"@noble/curves": "npm:^1.5.0"
- "@noble/hashes": "npm:1.3.2"
+ "@noble/hashes": "npm:^1.8.0"
+ "@privy-io/encoding": "npm:0.1.3"
+ "@privy-io/popup": "npm:0.0.4"
"@scure/base": "npm:~1.1.2"
fflate: "npm:0.8.2"
peerDependencies:
"@rainbow-me/rainbowkit": ^2.2.3
"@wagmi/core": ^2.16.4
- viem: ^2.22.23
+ viem: 2.47.12
peerDependenciesMeta:
"@rainbow-me/rainbowkit":
optional: true
"@wagmi/core":
optional: true
- checksum: 10c0/0270d68509a9ebfd3e5bd7767b2292f31c8f5ce651d9dde0916a1e77649037ddd3b06448bfd49e409ce37268858e231e22a693c7ebdf4058532d45da8ca9c804
+ checksum: 10c0/24ccd47887e7d4f4ccd78f565ecdb915d8e7e57b1facbd87b21c592749d3b811eb177415c712b67e054030423b8e1d3c30aff2231fdedd1994387c3d00bab049
+ languageName: node
+ linkType: hard
+
+"@privy-io/cross-app-provider@npm:^0.3.4":
+ version: 0.3.4
+ resolution: "@privy-io/cross-app-provider@npm:0.3.4"
+ dependencies:
+ "@noble/curves": "npm:^1.5.0"
+ "@privy-io/encoding": "npm:0.1.3"
+ "@privy-io/web-storage": "npm:0.0.4"
+ "@scure/base": "npm:^1.2.6"
+ fflate: "npm:^0.8.2"
+ checksum: 10c0/904e09d68780e7c71f432ca8f098d7069c0d622e860f1c7ad5cd9093f36e04a3f44bb5755e14a589ba7b7d20d01f336216d2075e18e93f96aa367053f94c9b4a
+ languageName: node
+ linkType: hard
+
+"@privy-io/encoding@npm:0.1.3":
+ version: 0.1.3
+ resolution: "@privy-io/encoding@npm:0.1.3"
+ dependencies:
+ "@scure/base": "npm:^1.2.6"
+ checksum: 10c0/3b5732af08e339a260201f8b8dda63cad1c223c77bdca567ba4782c7467d4d6e4843703e1a78eb4b11ad02c6ec385a8d578f8d622932cbcda77c1d4b7c143137
languageName: node
linkType: hard
@@ -4955,6 +4979,13 @@ __metadata:
languageName: node
linkType: hard
+"@privy-io/popup@npm:0.0.4":
+ version: 0.0.4
+ resolution: "@privy-io/popup@npm:0.0.4"
+ checksum: 10c0/8684108dc37e152a3e373a3b438cdc0f21ce5463f16c96fd499400023e1660883ab2f2ac35574605c5b350af7b7903aa93b14f3c9429a7aa196c53444248a4ff
+ languageName: node
+ linkType: hard
+
"@privy-io/public-api@npm:2.45.0":
version: 2.45.0
resolution: "@privy-io/public-api@npm:2.45.0"
@@ -5101,6 +5132,13 @@ __metadata:
languageName: node
linkType: hard
+"@privy-io/web-storage@npm:0.0.4":
+ version: 0.0.4
+ resolution: "@privy-io/web-storage@npm:0.0.4"
+ checksum: 10c0/5b6245c70052470962e3bb09b2ba561fe4a94dffc4a22c5546e8d66f997848b259bd6a12731a1bcaa6b455f6e841c353cf672181ffc3a6c438dd025ea36f4240
+ languageName: node
+ linkType: hard
+
"@quansync/fs@npm:^0.1.5":
version: 0.1.5
resolution: "@quansync/fs@npm:0.1.5"
@@ -7845,7 +7883,7 @@ __metadata:
"@adraffy/ens-normalize": "npm:^1.11.0"
"@chakra-ui/react": "npm:^2.8.2"
"@emotion/styled": "npm:^11.14.1"
- "@privy-io/cross-app-connect": "npm:0.2.2"
+ "@privy-io/cross-app-connect": "npm:0.5.8"
"@privy-io/react-auth": "npm:2.25.0"
"@solana/web3.js": "npm:^1.98.0"
"@tanstack/react-query": "npm:^5.64.2"
@@ -11139,6 +11177,34 @@ __metadata:
languageName: node
linkType: hard
+"cross-app-connect@workspace:cross-app-connect":
+ version: 0.0.0-use.local
+ resolution: "cross-app-connect@workspace:cross-app-connect"
+ dependencies:
+ "@chakra-ui/react": "npm:2.8.2"
+ "@emotion/react": "npm:^11.13.5"
+ "@emotion/styled": "npm:^11.13.5"
+ "@next/eslint-plugin-next": "npm:^14.1.4"
+ "@privy-io/cross-app-provider": "npm:^0.3.4"
+ "@privy-io/react-auth": "npm:2.25.0"
+ "@tanstack/react-query": "npm:^5.64.2"
+ "@types/node": "npm:^20"
+ "@types/react": "npm:^18"
+ "@types/react-dom": "npm:^18"
+ "@vechain/dapp-kit-react": "npm:2.1.0-rc.5"
+ "@vechain/sdk-core": "npm:2.0.7"
+ "@vechain/sdk-network": "npm:2.0.7"
+ "@vechain/vechain-kit": "workspace:*"
+ eslint: "npm:^9.12.0"
+ eslint-config-next: "npm:14.1.4"
+ next: "npm:~16.2.3"
+ react: "npm:^18"
+ react-dom: "npm:^18"
+ typescript: "npm:5.3.3"
+ viem: "npm:^2.22.0"
+ languageName: unknown
+ linkType: soft
+
"cross-env@npm:^7.0.3":
version: 7.0.3
resolution: "cross-env@npm:7.0.3"
@@ -12787,7 +12853,7 @@ __metadata:
languageName: node
linkType: hard
-"fflate@npm:0.8.2":
+"fflate@npm:0.8.2, fflate@npm:^0.8.2":
version: 0.8.2
resolution: "fflate@npm:0.8.2"
checksum: 10c0/03448d630c0a583abea594835a9fdb2aaf7d67787055a761515bf4ed862913cfd693b4c4ffd5c3f3b355a70cf1e19033e9ae5aedcca103188aaff91b8bd6e293
@@ -16244,6 +16310,27 @@ __metadata:
languageName: node
linkType: hard
+"ox@npm:0.14.20":
+ version: 0.14.20
+ resolution: "ox@npm:0.14.20"
+ dependencies:
+ "@adraffy/ens-normalize": "npm:^1.11.0"
+ "@noble/ciphers": "npm:^1.3.0"
+ "@noble/curves": "npm:1.9.1"
+ "@noble/hashes": "npm:^1.8.0"
+ "@scure/bip32": "npm:^1.7.0"
+ "@scure/bip39": "npm:^1.6.0"
+ abitype: "npm:^1.2.3"
+ eventemitter3: "npm:5.0.1"
+ peerDependencies:
+ typescript: ">=5.4.0"
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+ checksum: 10c0/fe1c34577536aea3bda5ec47e083be9069d99cb35a270527921f1c7b406283f12bc4b7e9348b46aa3ba88555fc8abc963129c6b5b8b3b375d5e1549f65f2ce9a
+ languageName: node
+ linkType: hard
+
"ox@npm:0.6.7":
version: 0.6.7
resolution: "ox@npm:0.6.7"
@@ -20064,6 +20151,27 @@ __metadata:
languageName: node
linkType: hard
+"viem@npm:^2.22.0":
+ version: 2.49.2
+ resolution: "viem@npm:2.49.2"
+ dependencies:
+ "@noble/curves": "npm:1.9.1"
+ "@noble/hashes": "npm:1.8.0"
+ "@scure/bip32": "npm:1.7.0"
+ "@scure/bip39": "npm:1.6.0"
+ abitype: "npm:1.2.3"
+ isows: "npm:1.0.7"
+ ox: "npm:0.14.20"
+ ws: "npm:8.18.3"
+ peerDependencies:
+ typescript: ">=5.0.4"
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+ checksum: 10c0/216bb08cc0d3022d68a7452832130e86a853bc00cb846a22329c47483e9bccfba65cb5d2625c1ceb7f2ce03564ee8885ccd088c03109088a6ba935e6a7615893
+ languageName: node
+ linkType: hard
+
"viem@npm:^2.7.9":
version: 2.43.5
resolution: "viem@npm:2.43.5"