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 ( + + + VeChain + + + + {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 ( + + ); +} + +function PhoneRow({ + onClick, + isRecent, +}: { + onClick: () => void; + isRecent?: boolean; +}) { + return ( + + ); +} + +function FarcasterRow({ + onClick, + isRecent, +}: { + onClick: () => void; + isRecent?: boolean; +}) { + return ( + + ); +} + +/** + * 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? + + + + + + + + {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 && ( + + + + + {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 */} - - - - - ( + + ))} + - 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 */} - - - - - ( + + ))} + {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"